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 }