Files
NianAIGC/backend/internal/payments/service.go
T
2026-10-12 13:57:23 +08:00

237 lines
7.3 KiB
Go

package payments
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"net/http"
"regexp"
"strings"
"time"
)
const queryInterval = 5 * time.Second
var requestKeyPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
type Service struct {
repo Repository
provider Provider
config Config
now func() time.Time
}
func NewService(repo Repository, provider Provider, config Config) *Service {
if config.MinAmountFen == 0 {
config.MinAmountFen = 100
}
if config.MaxAmountFen == 0 {
config.MaxAmountFen = 1000000
}
if config.OrderTTL == 0 {
config.OrderTTL = 15 * time.Minute
}
if config.PresetAmountsFen == nil {
config.PresetAmountsFen = []int64{10000, 50000, 100000}
}
return &Service{repo: repo, provider: provider, config: config, now: time.Now}
}
func (s *Service) Options() Options {
presets := make([]int64, len(s.config.PresetAmountsFen))
copy(presets, s.config.PresetAmountsFen)
return Options{Enabled: s.config.Enabled, CanRecharge: s.config.Enabled && s.repo != nil && s.provider != nil,
PresetAmountsFen: presets, MinAmountFen: s.config.MinAmountFen, MaxAmountFen: s.config.MaxAmountFen}
}
func (s *Service) Create(ctx context.Context, organizationID, accountID, requestKey string, amountFen int64) (Order, error) {
if !s.Options().CanRecharge {
return Order{}, ErrUnavailable
}
if organizationID == "" || accountID == "" || !requestKeyPattern.MatchString(requestKey) || amountFen < s.config.MinAmountFen || amountFen > s.config.MaxAmountFen {
return Order{}, ErrInvalidAmount
}
id, err := newOrderID()
if err != nil {
return Order{}, err
}
now := s.now().UTC()
wanted := Order{ID: id, OrganizationID: organizationID, AccountID: accountID, RequestKey: requestKey,
MchID: s.config.MchID, AppID: s.config.AppID, AmountFen: amountFen, Currency: "CNY", Status: StatusPending,
CreatedAt: now, UpdatedAt: now, ExpiresAt: now.Add(s.config.OrderTTL)}
order, err := s.repo.CreatePaymentOrder(ctx, wanted)
if err != nil {
return Order{}, err
}
if order.OrganizationID != organizationID || order.AccountID != accountID || order.RequestKey != requestKey || order.AmountFen != amountFen || order.MchID != s.config.MchID || order.AppID != s.config.AppID || order.Currency != "CNY" {
return Order{}, ErrConflict
}
if order.Status != StatusPending || order.CodeURL != "" {
return order, nil
}
if !s.now().Before(order.ExpiresAt) {
return s.refresh(ctx, order, true)
}
codeURL, err := s.provider.Create(ctx, order)
if err != nil {
return order, err
}
if !validCodeURL(codeURL) {
return order, ErrVerification
}
return s.repo.UpdatePaymentOrder(ctx, order.ID, StatusPending, codeURL)
}
func (s *Service) Get(ctx context.Context, organizationID, id string) (Order, error) {
if s.repo == nil {
return Order{}, ErrUnavailable
}
order, err := s.repo.GetPaymentOrder(ctx, id)
if err != nil {
return Order{}, err
}
if order.OrganizationID != organizationID {
return Order{}, ErrNotFound
}
if order.Status != StatusPending || s.provider == nil || s.now().Sub(order.UpdatedAt) < queryInterval {
return order, nil
}
refreshed, err := s.refresh(ctx, order, true)
if err != nil {
return order, nil
} // Polling remains available during a provider outage.
return refreshed, nil
}
func (s *Service) List(ctx context.Context, organizationID string) ([]Order, error) {
if s.repo == nil {
return nil, ErrUnavailable
}
return s.repo.ListPaymentOrders(ctx, organizationID, 100)
}
func (s *Service) Notify(ctx context.Context, request *http.Request) error {
if s.repo == nil || s.provider == nil {
return ErrUnavailable
}
txn, err := s.provider.ParseNotification(ctx, request)
if err != nil {
return err
}
_, err = s.settle(ctx, txn)
return err
}
func (s *Service) Reconcile(ctx context.Context) error {
if s.repo == nil || s.provider == nil {
return ErrUnavailable
}
// Payment and refund queues must each get a chance within the application's
// bounded reconciliation context, even when the other provider API is slow.
errorsCh := make(chan error, 1)
go func() { errorsCh <- s.reconcileRefunds(ctx) }()
paymentErr := s.reconcilePayments(ctx)
refundErr := <-errorsCh
return errors.Join(paymentErr, refundErr)
}
func (s *Service) reconcilePayments(ctx context.Context) error {
orders, err := s.repo.PendingPaymentOrders(ctx, 100)
if err != nil {
return err
}
var failures []error
for _, order := range orders {
if ctx.Err() != nil {
return ctx.Err()
}
if s.now().Sub(order.UpdatedAt) < queryInterval {
continue
}
if _, err := s.refresh(ctx, order, true); err != nil {
failures = append(failures, fmt.Errorf("order %s: %w", order.ID, err))
}
}
return errors.Join(failures...)
}
func (s *Service) refresh(ctx context.Context, order Order, expire bool) (Order, error) {
// Advance the oldest-pending cursor before any remote call. Failed queries,
// prepay retries and close attempts must not starve newer paid orders.
current, err := s.repo.UpdatePaymentOrder(ctx, order.ID, StatusPending, "")
if err != nil {
return order, err
}
if current.Status != StatusPending {
return current, nil
}
order = current
txn, err := s.provider.Query(ctx, order.ID)
if err != nil && !errors.Is(err, ErrRemoteNotFound) {
return order, err
}
if err == nil {
if txn.OrderID != order.ID || txn.MchID != order.MchID || txn.AppID != order.AppID ||
(txn.AmountFen != 0 && txn.AmountFen != order.AmountFen) ||
(txn.Currency != "" && txn.Currency != order.Currency) {
return order, ErrVerification
}
if txn.TradeState == "SUCCESS" {
return s.settle(ctx, txn)
}
if txn.TradeState == "CLOSED" || txn.TradeState == "REVOKED" || txn.TradeState == "PAYERROR" {
return s.repo.UpdatePaymentOrder(ctx, order.ID, StatusClosed, "")
}
if txn.TradeState != "NOTPAY" && txn.TradeState != "USERPAYING" {
return order, ErrVerification
}
}
if expire && !s.now().Before(order.ExpiresAt) {
if err == nil {
if err = s.provider.Close(ctx, order.ID); err != nil {
return order, err
}
}
return s.repo.UpdatePaymentOrder(ctx, order.ID, StatusClosed, "")
}
if order.CodeURL == "" && (errors.Is(err, ErrRemoteNotFound) || (err == nil && txn.TradeState == "NOTPAY")) {
codeURL, createErr := s.provider.Create(ctx, order)
if createErr != nil {
return order, createErr
}
if !validCodeURL(codeURL) {
return order, ErrVerification
}
return s.repo.UpdatePaymentOrder(ctx, order.ID, StatusPending, codeURL)
}
return order, nil
}
func (s *Service) settle(ctx context.Context, txn Transaction) (Order, error) {
if txn.OrderID == "" || txn.TransactionID == "" || txn.TradeState != "SUCCESS" || txn.MchID != s.config.MchID || txn.AppID != s.config.AppID || txn.Currency != "CNY" || txn.AmountFen <= 0 {
return Order{}, ErrVerification
}
order, err := s.repo.GetPaymentOrder(ctx, txn.OrderID)
if err != nil {
return Order{}, err
}
if txn.AmountFen != order.AmountFen || txn.MchID != order.MchID || txn.AppID != order.AppID || txn.Currency != order.Currency {
return Order{}, ErrVerification
}
return s.repo.SettlePaymentOrder(ctx, txn)
}
func newOrderID() (string, error) {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return "", err
}
return hex.EncodeToString(b[:]), nil
}
func validCodeURL(url string) bool {
return strings.HasPrefix(url, "weixin://wxpay/bizpayurl?") && len(url) <= 2048 && !strings.ContainsAny(url, "\r\n")
}