feat: live module and jenkins pipeline to harbor deploy
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
package recharge
|
||||
|
||||
import "github.com/google/wire"
|
||||
|
||||
var ProviderSet = wire.NewSet(NewUsecase)
|
||||
@@ -0,0 +1,197 @@
|
||||
package recharge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
kratoserrors "github.com/go-kratos/kratos/v3/errors"
|
||||
|
||||
bizpayment "kra/internal/biz/payment"
|
||||
)
|
||||
|
||||
const BusinessType = "live_recharge"
|
||||
|
||||
var (
|
||||
ErrNotFound = kratoserrors.NotFound("RECHARGE_ORDER_NOT_FOUND", "recharge order not found")
|
||||
ErrInvalid = kratoserrors.BadRequest("RECHARGE_INVALID", "invalid recharge data")
|
||||
ErrRefundUnsupported = kratoserrors.BadRequest("RECHARGE_REFUND_UNSUPPORTED", "recharge refunds require manual balance reconciliation")
|
||||
)
|
||||
|
||||
type Plan struct {
|
||||
ID uint
|
||||
Sort int
|
||||
Diamonds, AmountCents int64
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
// Order owns recharge facts. Payment state comes from KRA's payment order.
|
||||
type Order struct {
|
||||
ID uint
|
||||
OrderNo string
|
||||
UserID uint
|
||||
Username, Nickname string
|
||||
Diamonds, AmountCents int64
|
||||
Provider, ProviderTradeNo string
|
||||
PaymentStatus, FulfillmentStatus, RefundStatus string
|
||||
CreatedAt time.Time
|
||||
CreditedAt *time.Time
|
||||
}
|
||||
|
||||
type Checkout struct {
|
||||
Provider string
|
||||
NotifyURL string
|
||||
ReturnURL string
|
||||
ClientIP string
|
||||
UserAgent string
|
||||
DeviceID string
|
||||
Extra map[string]any
|
||||
}
|
||||
|
||||
type Filter struct {
|
||||
OrderNo, UserKeyword, PaymentStatus, Provider string
|
||||
CreatedAt []time.Time
|
||||
}
|
||||
|
||||
type ListOptions struct {
|
||||
Offset, Limit int
|
||||
OrderBy string
|
||||
}
|
||||
|
||||
type Summary struct{ Count, PaidCount, AmountCents int64 }
|
||||
|
||||
type Repo interface {
|
||||
Plans(context.Context) ([]*Plan, error)
|
||||
SavePlans(context.Context, []*Plan) error
|
||||
List(context.Context, Filter, ListOptions) ([]*Order, int64, *Summary, error)
|
||||
Get(context.Context, string) (*Order, error)
|
||||
Create(context.Context, uint, uint) (*Order, error)
|
||||
MarkPaid(context.Context, string) (*Order, error)
|
||||
}
|
||||
|
||||
type Usecase struct {
|
||||
repo Repo
|
||||
payments *bizpayment.PaymentUsecase
|
||||
}
|
||||
|
||||
func NewUsecase(repo Repo, payments *bizpayment.PaymentUsecase) (*Usecase, error) {
|
||||
uc := &Usecase{repo: repo, payments: payments}
|
||||
if payments == nil {
|
||||
return nil, fmt.Errorf("充值支付服务未接入")
|
||||
}
|
||||
if err := payments.RegisterBusinessModule(uc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return uc, nil
|
||||
}
|
||||
|
||||
func (uc *Usecase) Plans(ctx context.Context) ([]*Plan, error) { return uc.repo.Plans(ctx) }
|
||||
|
||||
func (uc *Usecase) SavePlans(ctx context.Context, plans []*Plan) error {
|
||||
if len(plans) < 1 || len(plans) > 8 {
|
||||
return ErrInvalid
|
||||
}
|
||||
for i, p := range plans {
|
||||
if p == nil || p.Diamonds < 1 || p.Diamonds > 99999 || p.AmountCents < 1 || p.AmountCents > 999900 {
|
||||
return ErrInvalid
|
||||
}
|
||||
if p.Sort == 0 {
|
||||
p.Sort = i + 1
|
||||
}
|
||||
p.Enabled = true
|
||||
}
|
||||
return uc.repo.SavePlans(ctx, plans)
|
||||
}
|
||||
|
||||
func (uc *Usecase) List(ctx context.Context, f Filter, o ListOptions) ([]*Order, int64, *Summary, error) {
|
||||
if o.Limit < 1 || o.Limit > 100 {
|
||||
o.Limit = 10
|
||||
}
|
||||
return uc.repo.List(ctx, f, o)
|
||||
}
|
||||
|
||||
func (uc *Usecase) Get(ctx context.Context, no string) (*Order, error) {
|
||||
if strings.TrimSpace(no) == "" {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return uc.repo.Get(ctx, no)
|
||||
}
|
||||
|
||||
func (uc *Usecase) Create(ctx context.Context, userID, planID uint, checkout Checkout) (*Order, *bizpayment.PaymentResult, error) {
|
||||
checkout.Provider = strings.TrimSpace(checkout.Provider)
|
||||
if userID == 0 || planID == 0 || checkout.Provider == "" || checkout.Provider == bizpayment.PaymentInternal || !bizpayment.IsSupportedPaymentProvider(checkout.Provider) {
|
||||
return nil, nil, ErrInvalid
|
||||
}
|
||||
order, err := uc.repo.Create(ctx, userID, planID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
extra := make(map[string]any, len(checkout.Extra)+2)
|
||||
for key, value := range checkout.Extra {
|
||||
extra[key] = value
|
||||
}
|
||||
extra["userId"] = order.UserID
|
||||
extra["diamonds"] = order.Diamonds
|
||||
result, err := uc.payments.Create(ctx, &bizpayment.PaymentRequest{
|
||||
Provider: checkout.Provider, TradeNo: order.OrderNo,
|
||||
BusinessType: BusinessType, BusinessID: order.OrderNo,
|
||||
NotifyURL: checkout.NotifyURL, ReturnURL: checkout.ReturnURL,
|
||||
ClientIP: checkout.ClientIP, UserAgent: checkout.UserAgent, DeviceID: checkout.DeviceID,
|
||||
Extra: extra,
|
||||
})
|
||||
if err != nil {
|
||||
return order, nil, err
|
||||
}
|
||||
refreshed, err := uc.repo.Get(ctx, order.OrderNo)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return refreshed, result, nil
|
||||
}
|
||||
|
||||
func (uc *Usecase) Type() string { return BusinessType }
|
||||
|
||||
func (uc *Usecase) PreparePayment(ctx context.Context, provider, tradeNo, businessID string) (*bizpayment.PaymentIntent, error) {
|
||||
if strings.TrimSpace(provider) == "" || tradeNo == "" || businessID != tradeNo {
|
||||
return nil, bizpayment.ErrPaymentOrderConflict
|
||||
}
|
||||
order, err := uc.repo.Get(ctx, businessID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if order.OrderNo != tradeNo || order.CreditedAt != nil {
|
||||
return nil, bizpayment.ErrPaymentOrderConflict
|
||||
}
|
||||
extra, err := json.Marshal(map[string]any{"userId": order.UserID, "diamonds": order.Diamonds})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &bizpayment.PaymentIntent{
|
||||
Provider: provider, TradeNo: tradeNo, BusinessType: BusinessType, BusinessID: businessID,
|
||||
Subject: fmt.Sprintf("充值 %d 钻石", order.Diamonds), Amount: order.AmountCents,
|
||||
OriginalAmount: order.AmountCents, Currency: "CNY", PaymentMode: bizpayment.PaymentModeExternal,
|
||||
Extra: extra,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (uc *Usecase) Fulfill(ctx context.Context, confirmation *bizpayment.PaymentConfirmation) error {
|
||||
if confirmation == nil || confirmation.BusinessType != BusinessType || confirmation.BusinessID == "" || confirmation.TradeNo != confirmation.BusinessID || !strings.EqualFold(confirmation.Currency, "CNY") {
|
||||
return ErrInvalid
|
||||
}
|
||||
order, err := uc.repo.Get(ctx, confirmation.BusinessID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if order.AmountCents != confirmation.Amount {
|
||||
return bizpayment.ErrPaymentOrderConflict
|
||||
}
|
||||
_, err = uc.repo.MarkPaid(ctx, order.OrderNo)
|
||||
return err
|
||||
}
|
||||
|
||||
// Refunds stay disabled until diamonds can be reserved across an async refund.
|
||||
func (uc *Usecase) AuthorizeRefund(context.Context, *bizpayment.PaymentOrder, int64) error {
|
||||
return ErrRefundUnsupported
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package recharge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
bizpayment "kra/internal/biz/payment"
|
||||
)
|
||||
|
||||
type repoStub struct {
|
||||
order *Order
|
||||
paidCall int
|
||||
}
|
||||
|
||||
func (r *repoStub) Plans(context.Context) ([]*Plan, error) { return nil, nil }
|
||||
func (r *repoStub) SavePlans(context.Context, []*Plan) error { return nil }
|
||||
func (r *repoStub) List(context.Context, Filter, ListOptions) ([]*Order, int64, *Summary, error) {
|
||||
return nil, 0, &Summary{}, nil
|
||||
}
|
||||
func (r *repoStub) Get(context.Context, string) (*Order, error) { return r.order, nil }
|
||||
func (r *repoStub) Create(context.Context, uint, uint) (*Order, error) { return r.order, nil }
|
||||
func (r *repoStub) MarkPaid(context.Context, string) (*Order, error) {
|
||||
r.paidCall++
|
||||
return r.order, nil
|
||||
}
|
||||
|
||||
func newTestUsecase(t *testing.T, repo Repo) *Usecase {
|
||||
t.Helper()
|
||||
payments := bizpayment.NewPaymentUsecase(nil, nil, bizpayment.NewPaymentHooks(), bizpayment.NewPaymentOrderSourceRegistry(), bizpayment.NewPaymentFulfillmentRegistry(), nil)
|
||||
uc, err := NewUsecase(repo, payments)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return uc
|
||||
}
|
||||
|
||||
func TestPreparePaymentUsesRechargeOrderFacts(t *testing.T) {
|
||||
repo := &repoStub{order: &Order{OrderNo: "CZ-1", UserID: 7, Diamonds: 300, AmountCents: 3000}}
|
||||
uc := newTestUsecase(t, repo)
|
||||
intent, err := uc.PreparePayment(context.Background(), bizpayment.PaymentWechatV3, "CZ-1", "CZ-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if intent.Amount != 3000 || intent.OriginalAmount != 3000 || intent.Currency != "CNY" || intent.Subject != "充值 300 钻石" {
|
||||
t.Fatalf("unexpected payment intent: %+v", intent)
|
||||
}
|
||||
if string(intent.Extra) != `{"diamonds":300,"userId":7}` {
|
||||
t.Fatalf("unexpected payment extra: %s", intent.Extra)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFulfillCreditsMatchingRechargeOnly(t *testing.T) {
|
||||
repo := &repoStub{order: &Order{OrderNo: "CZ-1", Diamonds: 60, AmountCents: 600}}
|
||||
uc := newTestUsecase(t, repo)
|
||||
err := uc.Fulfill(context.Background(), &bizpayment.PaymentConfirmation{
|
||||
TradeNo: "CZ-1", BusinessType: BusinessType, BusinessID: "CZ-1", Amount: 600, Currency: "CNY",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if repo.paidCall != 1 {
|
||||
t.Fatalf("paid calls = %d, want 1", repo.paidCall)
|
||||
}
|
||||
err = uc.Fulfill(context.Background(), &bizpayment.PaymentConfirmation{
|
||||
TradeNo: "CZ-1", BusinessType: BusinessType, BusinessID: "CZ-1", Amount: 601, Currency: "CNY",
|
||||
})
|
||||
if err == nil || repo.paidCall != 1 {
|
||||
t.Fatalf("mismatched payment was credited: err=%v calls=%d", err, repo.paidCall)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user