Files
ttstd-infinite-canvas/backend/internal/app/payment_reconciliation.go

363 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package app
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"time"
"infinite-canvas/backend/internal/model"
"infinite-canvas/backend/internal/payment"
"infinite-canvas/backend/internal/repository"
"gorm.io/gorm"
)
var paymentBillLocation = time.FixedZone("Asia/Shanghai", 8*60*60)
type RunPaymentReconciliationRequest struct {
ProviderID string `json:"providerId"`
BillDate string `json:"billDate"`
}
type AdminPaymentReconciliationPage struct {
Runs []model.PaymentReconciliationRun `json:"runs"`
Total int64 `json:"total"`
Page int `json:"page"`
Limit int `json:"pageSize"`
}
type AdminPaymentReconciliationItemPage struct {
Run model.PaymentReconciliationRun `json:"run"`
Items []model.PaymentReconciliationItem `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
Limit int `json:"pageSize"`
}
func (s *Service) RunPaymentReconciliation(ctx context.Context, actor *model.User, request RunPaymentReconciliationRequest) (*model.PaymentReconciliationRun, error) {
if err := s.RequireAdmin(actor); err != nil {
return nil, err
}
return s.runPaymentReconciliation(ctx, actor, strings.TrimSpace(request.ProviderID), strings.TrimSpace(request.BillDate))
}
func (s *Service) runPaymentReconciliation(ctx context.Context, actor *model.User, providerID, billDateValue string) (*model.PaymentReconciliationRun, error) {
provider, ok := s.paymentRegistry.Get(providerID)
if !ok {
return nil, BadAuthRequest("未知支付渠道")
}
billDate, err := parsePaymentBillDate(billDateValue)
if err != nil {
return nil, BadAuthRequest(err.Error())
}
config, err := s.repo.LatestPaymentProviderConfig(providerID)
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, BadAuthRequest("支付渠道尚未配置")
}
if err != nil {
return nil, err
}
values, err := s.decryptPaymentConfig(config)
if err != nil {
return nil, err
}
if err := provider.ValidateConfig(values); err != nil {
return nil, BadAuthRequest(err.Error())
}
startedBy := "system"
if actor != nil {
startedBy = actor.ID
}
run, started, err := s.repo.BeginPaymentReconciliation(&model.PaymentReconciliationRun{
ID: newID(), ProviderID: providerID, ConfigID: config.ID, BillDate: billDateValue,
Status: model.PaymentReconciliationRunning, StartedBy: startedBy, StartedAt: time.Now(),
})
if err != nil || !started {
return run, err
}
if actor != nil {
if err := s.appendAdminAudit(actor, "payment_reconciliation.run", "payment_provider", providerID, "执行支付渠道对账", map[string]any{"billDate": billDateValue, "runId": run.ID}); err != nil {
_ = s.repo.FailPaymentReconciliation(run.ID, safePaymentError(err))
return nil, err
}
}
start := billDate.In(paymentBillLocation)
end := start.AddDate(0, 0, 1)
localCredited, err := s.repo.CreditedPaymentOrdersBetween(providerID, start, end)
if err != nil {
return s.failPaymentReconciliation(run, err)
}
records, err := provider.DownloadTradeBill(ctx, values, billDate)
if errors.Is(err, payment.ErrTradeBillNotFound) && len(localCredited) == 0 {
candidateCount, countErr := s.repo.UnresolvedPaymentOrderCandidateCountOverlapping(providerID, start, end)
if countErr != nil {
return s.failPaymentReconciliation(run, countErr)
}
if candidateCount == 0 {
records = []payment.BillRecord{}
err = nil
}
}
if err != nil {
return s.failPaymentReconciliation(run, err)
}
items, matched, recovered, failed, err := s.comparePaymentReconciliation(ctx, provider, values, run, billDate, records, localCredited)
if err != nil {
return s.failPaymentReconciliation(run, err)
}
if err := s.repo.CompletePaymentReconciliation(run.ID, items, matched, recovered, failed); err != nil {
return s.failPaymentReconciliation(run, err)
}
return s.repo.PaymentReconciliationRun(run.ID)
}
func (s *Service) comparePaymentReconciliation(ctx context.Context, provider payment.Provider, values payment.Config, run *model.PaymentReconciliationRun, billDate time.Time, records []payment.BillRecord, localCredited []model.PaymentOrder) ([]model.PaymentReconciliationItem, int, int, int, error) {
items := make([]model.PaymentReconciliationItem, 0, len(records)+len(localCredited))
seen := make(map[string]struct{}, len(records))
matched, recovered, failed := 0, 0, 0
for _, record := range records {
merchantOrderNo := strings.TrimSpace(record.MerchantOrderNo)
if merchantOrderNo == "" {
continue
}
if _, duplicate := seen[merchantOrderNo]; duplicate {
continue
}
seen[merchantOrderNo] = struct{}{}
item := model.PaymentReconciliationItem{
ID: newID(), RunID: run.ID, ProviderID: run.ProviderID,
MerchantOrderNo: merchantOrderNo, ProviderTradeNo: strings.TrimSpace(record.ProviderTradeNo),
AmountFen: record.AmountFen, Currency: record.Currency, CreatedAt: time.Now(),
}
order, err := s.repo.PaymentOrderByMerchant(run.ProviderID, merchantOrderNo)
if errors.Is(err, gorm.ErrRecordNotFound) {
item.Result = model.PaymentReconciliationLocalOrderNotFound
item.Detail = "渠道账单存在成功交易,但本地订单不存在"
items = append(items, item)
failed++
continue
}
if err != nil {
return nil, 0, 0, 0, err
}
item.PaymentOrderID = order.ID
if record.AmountFen != order.AmountFen || record.Currency != order.Currency {
item.Result = model.PaymentReconciliationAmountMismatch
item.Detail = fmt.Sprintf("渠道金额 %d %s,本地金额 %d %s", record.AmountFen, record.Currency, order.AmountFen, order.Currency)
items = append(items, item)
failed++
continue
}
if order.ProviderTradeNo != nil && *order.ProviderTradeNo != "" && *order.ProviderTradeNo != record.ProviderTradeNo {
item.Result = model.PaymentReconciliationTradeNoMismatch
item.Detail = "渠道交易号与本地已记录交易号不一致"
items = append(items, item)
failed++
continue
}
if order.Status == model.PaymentOrderCredited {
item.Result = model.PaymentReconciliationMatched
item.Resolved = true
items = append(items, item)
matched++
continue
}
// A downloaded bill is a reconciliation hint, not sufficient payment
// authorization. This is especially important for Alipay bill URLs that
// may use the provider-documented HTTP download host. Confirm every
// recovery through the signed API response before granting credits.
confirmed, confirmErr := provider.QueryOrder(ctx, values, payment.QueryRequest{MerchantOrderNo: merchantOrderNo})
if confirmErr != nil {
item.Result = model.PaymentReconciliationCreditFailed
item.Detail = "渠道账单显示已支付,但签名查单确认失败:" + safePaymentReconciliationError(confirmErr)
items = append(items, item)
failed++
continue
}
if strings.TrimSpace(confirmed.MerchantOrderNo) != merchantOrderNo || !confirmed.Paid {
item.Result = model.PaymentReconciliationCreditFailed
item.Detail = "渠道账单显示已支付,但签名查单未确认该订单已支付"
items = append(items, item)
failed++
continue
}
if confirmed.AmountFen != order.AmountFen || confirmed.Currency != order.Currency {
item.Result = model.PaymentReconciliationAmountMismatch
item.Detail = fmt.Sprintf("签名查单金额 %d %s,本地金额 %d %s", confirmed.AmountFen, confirmed.Currency, order.AmountFen, order.Currency)
items = append(items, item)
failed++
continue
}
if strings.TrimSpace(confirmed.ProviderTradeNo) == "" || strings.TrimSpace(confirmed.ProviderTradeNo) != strings.TrimSpace(record.ProviderTradeNo) {
item.Result = model.PaymentReconciliationTradeNoMismatch
item.Detail = "渠道账单交易号与签名查单交易号不一致"
items = append(items, item)
failed++
continue
}
paidAt := confirmed.PaidAt
if paidAt.IsZero() {
paidAt = record.PaidAt
}
if paidAt.IsZero() {
paidAt = billDate.Add(12 * time.Hour)
}
_, _, creditErr := s.repo.CompletePaymentOrder(run.ProviderID, merchantOrderNo, repository.PaymentEvidence{
ProviderTradeNo: confirmed.ProviderTradeNo, ProviderStatus: confirmed.ProviderStatus,
AmountFen: confirmed.AmountFen, Currency: confirmed.Currency, PaidAt: paidAt,
})
if creditErr != nil {
item.Result = model.PaymentReconciliationCreditFailed
item.Detail = "渠道账单确认已支付,但自动补发积分失败:" + safePaymentError(creditErr)
items = append(items, item)
failed++
continue
}
item.Result = model.PaymentReconciliationRecovered
item.Resolved = true
item.Detail = "已根据渠道账单自动补发积分"
items = append(items, item)
recovered++
}
for _, order := range localCredited {
if _, ok := seen[order.MerchantOrderNo]; ok {
continue
}
tradeNo := ""
if order.ProviderTradeNo != nil {
tradeNo = *order.ProviderTradeNo
}
items = append(items, model.PaymentReconciliationItem{
ID: newID(), RunID: run.ID, ProviderID: run.ProviderID, PaymentOrderID: order.ID,
MerchantOrderNo: order.MerchantOrderNo, ProviderTradeNo: tradeNo,
AmountFen: order.AmountFen, Currency: order.Currency,
Result: model.PaymentReconciliationProviderRecordMissing,
Detail: "本地订单已入账,但渠道成功交易账单中未找到对应记录", CreatedAt: time.Now(),
})
failed++
}
return items, matched, recovered, failed, nil
}
func (s *Service) failPaymentReconciliation(run *model.PaymentReconciliationRun, cause error) (*model.PaymentReconciliationRun, error) {
if run != nil {
_ = s.repo.FailPaymentReconciliation(run.ID, safePaymentReconciliationError(cause))
if latest, err := s.repo.PaymentReconciliationRun(run.ID); err == nil {
run = latest
}
}
return run, WrapAppError(http.StatusBadGateway, "支付渠道对账失败,请稍后重试", cause)
}
func safePaymentReconciliationError(err error) string {
if err == nil {
return ""
}
if errors.Is(err, payment.ErrTradeBillNotFound) {
return "TRADE_BILL_NOT_FOUND"
}
var providerErr *payment.ProviderError
if errors.As(err, &providerErr) && strings.TrimSpace(providerErr.Code) != "" {
return truncateRunes(providerErr.Code, 1000)
}
// Reconciliation runs are admin-only diagnostics. Parser and persistence
// errors contain no provider credentials and are more actionable than only
// storing their Go concrete type.
return truncateRunes(strings.TrimSpace(err.Error()), 1000)
}
func (s *Service) AdminPaymentReconciliationPage(actor *model.User, query PaymentReconciliationQuery, page, limit int) (*AdminPaymentReconciliationPage, error) {
if err := s.RequireAdmin(actor); err != nil {
return nil, err
}
page, limit = normalizeAdminPage(page, limit)
filter, err := query.filter()
if err != nil {
return nil, err
}
runs, total, err := s.repo.AdminPaymentReconciliationRuns(filter, limit, (page-1)*limit)
if err != nil {
return nil, err
}
return &AdminPaymentReconciliationPage{Runs: runs, Total: total, Page: page, Limit: limit}, nil
}
func (s *Service) AdminPaymentReconciliationItems(actor *model.User, runID, result string, page, limit int) (*AdminPaymentReconciliationItemPage, error) {
if err := s.RequireAdmin(actor); err != nil {
return nil, err
}
page, limit = normalizeAdminPage(page, limit)
if err := validatePaymentResult(result); err != nil {
return nil, err
}
run, err := s.repo.PaymentReconciliationRun(runID)
if err != nil {
return nil, err
}
items, total, err := s.repo.PaymentReconciliationItems(run.ID, result, limit, (page-1)*limit)
if err != nil {
return nil, err
}
return &AdminPaymentReconciliationItemPage{Run: *run, Items: items, Total: total, Page: page, Limit: limit}, nil
}
func parsePaymentBillDate(value string) (time.Time, error) {
parsed, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(value), paymentBillLocation)
if err != nil {
return time.Time{}, errors.New("账单日期格式必须为 YYYY-MM-DD")
}
today := time.Now().In(paymentBillLocation)
today = time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, paymentBillLocation)
if !parsed.Before(today) {
return time.Time{}, errors.New("只能对账昨天及更早的账单")
}
if parsed.Before(today.AddDate(0, -3, 0)) {
return time.Time{}, errors.New("首期仅支持最近三个月的账单对账")
}
return parsed, nil
}
func (s *Service) maybeRunDailyPaymentReconciliation(ctx context.Context) {
now := time.Now().In(paymentBillLocation)
if now.Hour() < 10 || (now.Hour() == 10 && now.Minute() < 15) {
return
}
billDate := now.AddDate(0, 0, -1).Format("2006-01-02")
for _, descriptor := range s.paymentRegistry.Descriptors() {
config, err := s.repo.LatestPaymentProviderConfig(descriptor.ID)
if err != nil {
continue
}
// Disabling a channel stops new orders, but must not stop T+1
// reconciliation for payments created before it was disabled.
provider, ok := s.paymentRegistry.Get(descriptor.ID)
if !ok {
continue
}
values, err := s.decryptPaymentConfig(config)
if err != nil || provider.ValidateConfig(values) != nil {
continue
}
existing, err := s.repo.PaymentReconciliationRunByDate(descriptor.ID, billDate)
if err == nil {
if existing.Status == model.PaymentReconciliationCompleted || (existing.Status == model.PaymentReconciliationRunning && existing.UpdatedAt.After(time.Now().Add(-30*time.Minute))) || (existing.Status == model.PaymentReconciliationFailed && existing.UpdatedAt.After(time.Now().Add(-30*time.Minute))) {
continue
}
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
continue
}
providerContext, cancel := context.WithTimeout(ctx, 2*time.Minute)
if _, err := s.runPaymentReconciliation(providerContext, nil, descriptor.ID, billDate); err != nil {
// The persisted run contains a safe error code. The worker retries
// failed T+1 runs after the cooldown above.
}
cancel()
}
}