237 lines
7.3 KiB
Go
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")
|
|
}
|