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

356 lines
14 KiB
Go

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, providerID, status string, page, limit int) (*AdminPaymentReconciliationPage, error) {
if err := s.RequireAdmin(actor); err != nil {
return nil, err
}
page, limit = normalizeAdminPage(page, limit)
runs, total, err := s.repo.AdminPaymentReconciliationRuns(providerID, status, 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)
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()
}
}