Two issues fixed: 1. Alipay.SupportedTypes() returned ["alipay_direct"] and Wxpay returned ["wxpay_direct"], but the frontend sends payment_type="alipay"/"wxpay". The registry lookup failed with "payment method (alipay) is not configured". Fix: return the base types ["alipay"]/["wxpay"]. 2. When multiple providers support the same payment type (e.g. EasyPay and Alipay direct both handle "alipay"), only the last-registered provider's instances were reachable — the registry mapped one type to one provider key, and SelectInstance queried by that single key. Fix: bypass the registry in invokeProvider and let SelectInstance query across all providers when providerKey is empty. The selected instance's own ProviderKey (now included in InstanceSelection) is used to create the correct provider, enabling true cross-provider load balancing. Closes #1592
312 lines
12 KiB
Go
312 lines
12 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"math"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
dbent "github.com/Wei-Shaw/sub2api/ent"
|
|
"github.com/Wei-Shaw/sub2api/ent/paymentorder"
|
|
"github.com/Wei-Shaw/sub2api/internal/payment"
|
|
"github.com/Wei-Shaw/sub2api/internal/payment/provider"
|
|
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
|
)
|
|
|
|
// --- Order Creation ---
|
|
|
|
func (s *PaymentService) CreateOrder(ctx context.Context, req CreateOrderRequest) (*CreateOrderResponse, error) {
|
|
if req.OrderType == "" {
|
|
req.OrderType = payment.OrderTypeBalance
|
|
}
|
|
cfg, err := s.configService.GetPaymentConfig(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get payment config: %w", err)
|
|
}
|
|
if !cfg.Enabled {
|
|
return nil, infraerrors.Forbidden("PAYMENT_DISABLED", "payment system is disabled")
|
|
}
|
|
plan, err := s.validateOrderInput(ctx, req, cfg)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.checkCancelRateLimit(ctx, req.UserID, cfg); err != nil {
|
|
return nil, err
|
|
}
|
|
user, err := s.userRepo.GetByID(ctx, req.UserID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get user: %w", err)
|
|
}
|
|
if user.Status != payment.EntityStatusActive {
|
|
return nil, infraerrors.Forbidden("USER_INACTIVE", "user account is disabled")
|
|
}
|
|
amount := req.Amount
|
|
if plan != nil {
|
|
amount = plan.Price
|
|
}
|
|
feeRate := s.getFeeRate(req.PaymentType)
|
|
payAmountStr := payment.CalculatePayAmount(amount, feeRate)
|
|
payAmount, _ := strconv.ParseFloat(payAmountStr, 64)
|
|
order, err := s.createOrderInTx(ctx, req, user, plan, cfg, amount, feeRate, payAmount)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resp, err := s.invokeProvider(ctx, order, req, cfg, payAmountStr, payAmount, plan)
|
|
if err != nil {
|
|
_, _ = s.entClient.PaymentOrder.UpdateOneID(order.ID).
|
|
SetStatus(OrderStatusFailed).
|
|
Save(ctx)
|
|
return nil, err
|
|
}
|
|
return resp, nil
|
|
}
|
|
|
|
func (s *PaymentService) validateOrderInput(ctx context.Context, req CreateOrderRequest, cfg *PaymentConfig) (*dbent.SubscriptionPlan, error) {
|
|
if req.OrderType == payment.OrderTypeBalance && cfg.BalanceDisabled {
|
|
return nil, infraerrors.Forbidden("BALANCE_PAYMENT_DISABLED", "balance recharge has been disabled")
|
|
}
|
|
if req.OrderType == payment.OrderTypeSubscription {
|
|
return s.validateSubOrder(ctx, req)
|
|
}
|
|
if math.IsNaN(req.Amount) || math.IsInf(req.Amount, 0) || req.Amount <= 0 {
|
|
return nil, infraerrors.BadRequest("INVALID_AMOUNT", "amount must be a positive number")
|
|
}
|
|
if (cfg.MinAmount > 0 && req.Amount < cfg.MinAmount) || (cfg.MaxAmount > 0 && req.Amount > cfg.MaxAmount) {
|
|
return nil, infraerrors.BadRequest("INVALID_AMOUNT", "amount out of range").
|
|
WithMetadata(map[string]string{"min": fmt.Sprintf("%.2f", cfg.MinAmount), "max": fmt.Sprintf("%.2f", cfg.MaxAmount)})
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func (s *PaymentService) validateSubOrder(ctx context.Context, req CreateOrderRequest) (*dbent.SubscriptionPlan, error) {
|
|
if req.PlanID == 0 {
|
|
return nil, infraerrors.BadRequest("INVALID_INPUT", "subscription order requires a plan")
|
|
}
|
|
plan, err := s.configService.GetPlan(ctx, req.PlanID)
|
|
if err != nil || !plan.ForSale {
|
|
return nil, infraerrors.NotFound("PLAN_NOT_AVAILABLE", "plan not found or not for sale")
|
|
}
|
|
group, err := s.groupRepo.GetByID(ctx, plan.GroupID)
|
|
if err != nil || group.Status != payment.EntityStatusActive {
|
|
return nil, infraerrors.NotFound("GROUP_NOT_FOUND", "subscription group is no longer available")
|
|
}
|
|
if !group.IsSubscriptionType() {
|
|
return nil, infraerrors.BadRequest("GROUP_TYPE_MISMATCH", "group is not a subscription type")
|
|
}
|
|
return plan, nil
|
|
}
|
|
|
|
func (s *PaymentService) createOrderInTx(ctx context.Context, req CreateOrderRequest, user *User, plan *dbent.SubscriptionPlan, cfg *PaymentConfig, amount, feeRate, payAmount float64) (*dbent.PaymentOrder, error) {
|
|
tx, err := s.entClient.Tx(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("begin transaction: %w", err)
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
if err := s.checkPendingLimit(ctx, tx, req.UserID, cfg.MaxPendingOrders); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.checkDailyLimit(ctx, tx, req.UserID, amount, cfg.DailyLimit); err != nil {
|
|
return nil, err
|
|
}
|
|
tm := cfg.OrderTimeoutMin
|
|
if tm <= 0 {
|
|
tm = defaultOrderTimeoutMin
|
|
}
|
|
exp := time.Now().Add(time.Duration(tm) * time.Minute)
|
|
b := tx.PaymentOrder.Create().
|
|
SetUserID(req.UserID).
|
|
SetUserEmail(user.Email).
|
|
SetUserName(user.Username).
|
|
SetNillableUserNotes(psNilIfEmpty(user.Notes)).
|
|
SetAmount(amount).
|
|
SetPayAmount(payAmount).
|
|
SetFeeRate(feeRate).
|
|
SetRechargeCode("").
|
|
SetOutTradeNo(generateOutTradeNo()).
|
|
SetPaymentType(req.PaymentType).
|
|
SetPaymentTradeNo("").
|
|
SetOrderType(req.OrderType).
|
|
SetStatus(OrderStatusPending).
|
|
SetExpiresAt(exp).
|
|
SetClientIP(req.ClientIP).
|
|
SetSrcHost(req.SrcHost)
|
|
if req.SrcURL != "" {
|
|
b.SetSrcURL(req.SrcURL)
|
|
}
|
|
if plan != nil {
|
|
b.SetPlanID(plan.ID).SetSubscriptionGroupID(plan.GroupID).SetSubscriptionDays(psComputeValidityDays(plan.ValidityDays, plan.ValidityUnit))
|
|
}
|
|
order, err := b.Save(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create order: %w", err)
|
|
}
|
|
code := fmt.Sprintf("PAY-%d-%d", order.ID, time.Now().UnixNano()%100000)
|
|
order, err = tx.PaymentOrder.UpdateOneID(order.ID).SetRechargeCode(code).Save(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("set recharge code: %w", err)
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return nil, fmt.Errorf("commit order transaction: %w", err)
|
|
}
|
|
return order, nil
|
|
}
|
|
|
|
func (s *PaymentService) checkPendingLimit(ctx context.Context, tx *dbent.Tx, userID int64, max int) error {
|
|
if max <= 0 {
|
|
max = defaultMaxPendingOrders
|
|
}
|
|
c, err := tx.PaymentOrder.Query().Where(paymentorder.UserIDEQ(userID), paymentorder.StatusEQ(OrderStatusPending)).Count(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("count pending orders: %w", err)
|
|
}
|
|
if c >= max {
|
|
return infraerrors.TooManyRequests("TOO_MANY_PENDING", fmt.Sprintf("too many pending orders (max %d)", max)).
|
|
WithMetadata(map[string]string{"max": strconv.Itoa(max)})
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *PaymentService) checkDailyLimit(ctx context.Context, tx *dbent.Tx, userID int64, amount, limit float64) error {
|
|
if limit <= 0 {
|
|
return nil
|
|
}
|
|
ts := psStartOfDayUTC(time.Now())
|
|
orders, err := tx.PaymentOrder.Query().Where(paymentorder.UserIDEQ(userID), paymentorder.StatusIn(OrderStatusPaid, OrderStatusRecharging, OrderStatusCompleted), paymentorder.PaidAtGTE(ts)).All(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("query daily usage: %w", err)
|
|
}
|
|
var used float64
|
|
for _, o := range orders {
|
|
used += o.Amount
|
|
}
|
|
if used+amount > limit {
|
|
return infraerrors.TooManyRequests("DAILY_LIMIT_EXCEEDED", fmt.Sprintf("daily recharge limit reached, remaining: %.2f", math.Max(0, limit-used)))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *PaymentService) invokeProvider(ctx context.Context, order *dbent.PaymentOrder, req CreateOrderRequest, cfg *PaymentConfig, payAmountStr string, payAmount float64, plan *dbent.SubscriptionPlan) (*CreateOrderResponse, error) {
|
|
// Select an instance across all providers that support the requested payment type.
|
|
// This enables cross-provider load balancing (e.g. EasyPay + Alipay direct for "alipay").
|
|
sel, err := s.loadBalancer.SelectInstance(ctx, "", req.PaymentType, payment.Strategy(cfg.LoadBalanceStrategy), payAmount)
|
|
if err != nil {
|
|
return nil, infraerrors.ServiceUnavailable("PAYMENT_GATEWAY_ERROR", fmt.Sprintf("payment method (%s) is not configured", req.PaymentType))
|
|
}
|
|
if sel == nil {
|
|
return nil, infraerrors.TooManyRequests("NO_AVAILABLE_INSTANCE", "no available payment instance")
|
|
}
|
|
prov, err := provider.CreateProvider(sel.ProviderKey, sel.InstanceID, sel.Config)
|
|
if err != nil {
|
|
return nil, infraerrors.ServiceUnavailable("PAYMENT_GATEWAY_ERROR", "payment method is temporarily unavailable")
|
|
}
|
|
subject := s.buildPaymentSubject(plan, payAmountStr, cfg)
|
|
outTradeNo := order.OutTradeNo
|
|
pr, err := prov.CreatePayment(ctx, payment.CreatePaymentRequest{OrderID: outTradeNo, Amount: payAmountStr, PaymentType: req.PaymentType, Subject: subject, ClientIP: req.ClientIP, IsMobile: req.IsMobile, InstanceSubMethods: sel.SupportedTypes})
|
|
if err != nil {
|
|
slog.Error("[PaymentService] CreatePayment failed", "provider", sel.ProviderKey, "instance", sel.InstanceID, "error", err)
|
|
return nil, infraerrors.ServiceUnavailable("PAYMENT_GATEWAY_ERROR", fmt.Sprintf("payment gateway error: %s", err.Error()))
|
|
}
|
|
_, err = s.entClient.PaymentOrder.UpdateOneID(order.ID).SetNillablePaymentTradeNo(psNilIfEmpty(pr.TradeNo)).SetNillablePayURL(psNilIfEmpty(pr.PayURL)).SetNillableQrCode(psNilIfEmpty(pr.QRCode)).SetNillableProviderInstanceID(psNilIfEmpty(sel.InstanceID)).Save(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("update order with payment details: %w", err)
|
|
}
|
|
s.writeAuditLog(ctx, order.ID, "ORDER_CREATED", fmt.Sprintf("user:%d", req.UserID), map[string]any{"amount": req.Amount, "paymentType": req.PaymentType, "orderType": req.OrderType})
|
|
return &CreateOrderResponse{OrderID: order.ID, Amount: order.Amount, PayAmount: payAmount, FeeRate: order.FeeRate, Status: OrderStatusPending, PaymentType: req.PaymentType, PayURL: pr.PayURL, QRCode: pr.QRCode, ClientSecret: pr.ClientSecret, ExpiresAt: order.ExpiresAt, PaymentMode: sel.PaymentMode}, nil
|
|
}
|
|
|
|
func (s *PaymentService) buildPaymentSubject(plan *dbent.SubscriptionPlan, payAmountStr string, cfg *PaymentConfig) string {
|
|
if plan != nil {
|
|
if plan.ProductName != "" {
|
|
return plan.ProductName
|
|
}
|
|
return "Sub2API Subscription " + plan.Name
|
|
}
|
|
pf := strings.TrimSpace(cfg.ProductNamePrefix)
|
|
sf := strings.TrimSpace(cfg.ProductNameSuffix)
|
|
if pf != "" || sf != "" {
|
|
return strings.TrimSpace(pf + " " + payAmountStr + " " + sf)
|
|
}
|
|
return "Sub2API " + payAmountStr + " CNY"
|
|
}
|
|
|
|
// --- Order Queries ---
|
|
|
|
func (s *PaymentService) GetOrder(ctx context.Context, orderID, userID int64) (*dbent.PaymentOrder, error) {
|
|
o, err := s.entClient.PaymentOrder.Get(ctx, orderID)
|
|
if err != nil {
|
|
return nil, infraerrors.NotFound("NOT_FOUND", "order not found")
|
|
}
|
|
if o.UserID != userID {
|
|
return nil, infraerrors.Forbidden("FORBIDDEN", "no permission for this order")
|
|
}
|
|
return o, nil
|
|
}
|
|
|
|
func (s *PaymentService) GetOrderByID(ctx context.Context, orderID int64) (*dbent.PaymentOrder, error) {
|
|
o, err := s.entClient.PaymentOrder.Get(ctx, orderID)
|
|
if err != nil {
|
|
return nil, infraerrors.NotFound("NOT_FOUND", "order not found")
|
|
}
|
|
return o, nil
|
|
}
|
|
|
|
func (s *PaymentService) GetUserOrders(ctx context.Context, userID int64, p OrderListParams) ([]*dbent.PaymentOrder, int, error) {
|
|
q := s.entClient.PaymentOrder.Query().Where(paymentorder.UserIDEQ(userID))
|
|
if p.Status != "" {
|
|
q = q.Where(paymentorder.StatusEQ(p.Status))
|
|
}
|
|
if p.OrderType != "" {
|
|
q = q.Where(paymentorder.OrderTypeEQ(p.OrderType))
|
|
}
|
|
if p.PaymentType != "" {
|
|
q = q.Where(paymentorder.PaymentTypeEQ(p.PaymentType))
|
|
}
|
|
total, err := q.Clone().Count(ctx)
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf("count user orders: %w", err)
|
|
}
|
|
ps, pg := applyPagination(p.PageSize, p.Page)
|
|
orders, err := q.Order(dbent.Desc(paymentorder.FieldCreatedAt)).Limit(ps).Offset((pg - 1) * ps).All(ctx)
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf("query user orders: %w", err)
|
|
}
|
|
return orders, total, nil
|
|
}
|
|
|
|
// AdminListOrders returns a paginated list of orders. If userID > 0, filters by user.
|
|
func (s *PaymentService) AdminListOrders(ctx context.Context, userID int64, p OrderListParams) ([]*dbent.PaymentOrder, int, error) {
|
|
q := s.entClient.PaymentOrder.Query()
|
|
if userID > 0 {
|
|
q = q.Where(paymentorder.UserIDEQ(userID))
|
|
}
|
|
if p.Status != "" {
|
|
q = q.Where(paymentorder.StatusEQ(p.Status))
|
|
}
|
|
if p.OrderType != "" {
|
|
q = q.Where(paymentorder.OrderTypeEQ(p.OrderType))
|
|
}
|
|
if p.PaymentType != "" {
|
|
q = q.Where(paymentorder.PaymentTypeEQ(p.PaymentType))
|
|
}
|
|
if p.Keyword != "" {
|
|
q = q.Where(paymentorder.Or(
|
|
paymentorder.OutTradeNoContainsFold(p.Keyword),
|
|
paymentorder.UserEmailContainsFold(p.Keyword),
|
|
paymentorder.UserNameContainsFold(p.Keyword),
|
|
))
|
|
}
|
|
total, err := q.Clone().Count(ctx)
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf("count admin orders: %w", err)
|
|
}
|
|
ps, pg := applyPagination(p.PageSize, p.Page)
|
|
orders, err := q.Order(dbent.Desc(paymentorder.FieldCreatedAt)).Limit(ps).Offset((pg - 1) * ps).All(ctx)
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf("query admin orders: %w", err)
|
|
}
|
|
return orders, total, nil
|
|
}
|