Files

198 lines
6.1 KiB
Go

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
}