feat(payment): 支付管理 - 合并 PR #533 支付订单与对账 CSV 导出

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
ddCatandCursor committed 2026-09-18 10:38:07 +08:00
commit ef8f49e4fc
18 files changed
+1120 -65

No files matched your search

+6 -2
View File
@@ -929,12 +929,16 @@ func paymentOrderView(order model.PaymentOrder) PaymentOrderView {
}
}
func (s *Service) AdminPaymentOrderPage(actor *model.User, status, keyword string, page, limit int) (*AdminPaymentOrderPage, error) {
func (s *Service) AdminPaymentOrderPage(actor *model.User, query PaymentOrderQuery, page, limit int) (*AdminPaymentOrderPage, error) {
if err := s.RequireAdmin(actor); err != nil {
return nil, err
}
page, limit = normalizeAdminPage(page, limit)
orders, total, err := s.repo.AdminPaymentOrders(status, keyword, limit, (page-1)*limit)
filter, err := query.filter()
if err != nil {
return nil, err
}
orders, total, err := s.repo.AdminPaymentOrders(filter, limit, (page-1)*limit)
if err != nil {
return nil, err
}
+256
View File
@@ -0,0 +1,256 @@
package app
import (
"bytes"
"context"
"encoding/csv"
"errors"
"net/http"
"strconv"
"strings"
"time"
"unicode"
"infinite-canvas/backend/internal/model"
"infinite-canvas/backend/internal/repository"
)
const paymentExportLimit = 10_000
type PaymentOrderQuery struct {
Status, Keyword, ProviderID, TimeField, From, To string
}
type PaymentReconciliationQuery struct {
ProviderID, Status, From, To string
}
func paymentDateRange(from, to string) (time.Time, time.Time, error) {
var start, end time.Time
var err error
if from != "" {
start, err = time.ParseInLocation("2006-01-02", from, paymentBillLocation)
if err != nil {
return start, end, BadAuthRequest("开始日期格式必须为 YYYY-MM-DD")
}
}
if to != "" {
end, err = time.ParseInLocation("2006-01-02", to, paymentBillLocation)
if err != nil {
return start, end, BadAuthRequest("结束日期格式必须为 YYYY-MM-DD")
}
if !start.IsZero() && end.Before(start) {
return start, end, BadAuthRequest("结束日期不能早于开始日期")
}
end = end.AddDate(0, 0, 1)
}
return start, end, nil
}
func (q PaymentOrderQuery) filter() (repository.PaymentOrderFilter, error) {
f := repository.PaymentOrderFilter{Status: strings.TrimSpace(q.Status), Keyword: strings.TrimSpace(q.Keyword), ProviderID: strings.TrimSpace(q.ProviderID), TimeField: q.TimeField}
switch f.Status {
case "", "all", "created", "pending", "closing", "closed", "credited", "create_failed":
default:
return f, BadAuthRequest("无效的订单状态")
}
switch f.TimeField {
case "", "created", "paid", "credited":
default:
return f, BadAuthRequest("无效的订单时间口径")
}
var err error
f.From, f.Until, err = paymentDateRange(q.From, q.To)
return f, err
}
func (q PaymentReconciliationQuery) filter() (repository.PaymentReconciliationFilter, error) {
f := repository.PaymentReconciliationFilter{ProviderID: strings.TrimSpace(q.ProviderID), Status: strings.TrimSpace(q.Status), From: q.From, To: q.To}
switch f.Status {
case "", "all", "running", "completed", "failed":
default:
return f, BadAuthRequest("无效的对账状态")
}
_, _, err := paymentDateRange(q.From, q.To)
return f, err
}
func validatePaymentResult(result string) error {
switch result {
case "", "all", "abnormal", "matched", "recovered", "local_order_not_found", "provider_record_missing", "amount_mismatch", "trade_no_mismatch", "credit_failed":
return nil
default:
return BadAuthRequest("无效的对账结果")
}
}
func (s *Service) AdminPaymentOrdersCSV(ctx context.Context, actor *model.User, query PaymentOrderQuery) ([]byte, error) {
if err := s.RequireAdmin(actor); err != nil {
return nil, err
}
filter, err := query.filter()
if err != nil {
return nil, err
}
orders, users, err := s.repo.WithContext(ctx).ExportPaymentOrders(filter, paymentExportLimit+1)
if err != nil {
return nil, err
}
if err := checkPaymentExportSize(len(orders)); err != nil {
return nil, err
}
rows := [][]string{{"订单ID", "商户订单号", "渠道交易号", "用户ID", "用户名", "显示名称", "邮箱", "商品名称", "支付渠道", "金额(元)", "币种", "积分", "订单状态", "渠道状态", "创建时间(北京时间)", "支付时间(北京时间)", "入账时间(北京时间)", "关闭时间(北京时间)"}}
for _, order := range orders {
user := users[order.UserID]
trade := ""
if order.ProviderTradeNo != nil {
trade = *order.ProviderTradeNo
}
rows = append(rows, []string{paymentCSVIdentifier(order.ID), paymentCSVIdentifier(order.MerchantOrderNo), paymentCSVIdentifier(trade), paymentCSVIdentifier(order.UserID), user.Username, user.DisplayName, user.Email, order.ProductName, s.paymentExportProviderName(order.ProviderID), paymentDecimal(order.AmountFen, 2, false), order.Currency, paymentDecimal(order.CreditsMicrocredits, 6, true), paymentExportLabel(string(order.Status)), order.ProviderStatus, paymentCSVTime(&order.CreatedAt), paymentCSVTime(order.ProviderPaidAt), paymentCSVTime(order.CreditedAt), paymentCSVTime(order.ClosedAt)})
}
return paymentCSV(rows)
}
func (s *Service) AdminPaymentReconciliationsCSV(ctx context.Context, actor *model.User, query PaymentReconciliationQuery) ([]byte, error) {
if err := s.RequireAdmin(actor); err != nil {
return nil, err
}
filter, err := query.filter()
if err != nil {
return nil, err
}
runs, err := s.repo.WithContext(ctx).ExportPaymentReconciliations(filter, paymentExportLimit+1)
if err != nil {
return nil, err
}
if err := checkPaymentExportSize(len(runs)); err != nil {
return nil, err
}
rows := [][]string{{"对账记录ID", "账单日期", "支付渠道", "执行状态", "总笔数", "一致笔数", "自动补发笔数", "异常笔数", "失败原因", "发起人ID/系统", "开始时间(北京时间)", "完成时间(北京时间)"}}
for _, run := range runs {
actor := run.StartedBy
if actor == "system" {
actor = "系统"
}
rows = append(rows, []string{paymentCSVIdentifier(run.ID), run.BillDate, s.paymentExportProviderName(run.ProviderID), paymentExportLabel(string(run.Status)), strconv.Itoa(run.TotalItems), strconv.Itoa(run.MatchItems), strconv.Itoa(run.RecoveredItems), strconv.Itoa(run.ErrorItems), run.Error, paymentCSVIdentifier(actor), paymentCSVTime(&run.StartedAt), paymentCSVTime(run.CompletedAt)})
}
return paymentCSV(rows)
}
func (s *Service) AdminPaymentReconciliationItemsCSV(ctx context.Context, actor *model.User, runID, result string) ([]byte, error) {
if err := s.RequireAdmin(actor); err != nil {
return nil, err
}
if err := validatePaymentResult(result); err != nil {
return nil, err
}
run, items, err := s.repo.WithContext(ctx).ExportPaymentReconciliationItems(runID, result, paymentExportLimit+1)
if errors.Is(err, repository.ErrPaymentReconciliationRunning) {
return nil, NewAppError(http.StatusConflict, "对账正在执行,请完成后再导出")
}
if err != nil {
return nil, err
}
if run.Status != model.PaymentReconciliationCompleted {
return nil, NewAppError(http.StatusConflict, "本次对账未完成,请导出汇总查看失败原因")
}
if err := checkPaymentExportSize(len(items)); err != nil {
return nil, err
}
rows := [][]string{{"对账记录ID", "账单日期", "支付渠道", "本地订单ID", "商户订单号", "渠道交易号", "记录金额(元)", "金额来源", "币种", "对账结果", "是否已解决", "异常说明", "对账开始时间(北京时间)", "对账完成时间(北京时间)"}}
for _, item := range items {
source, resolved := "渠道账单", "否"
if item.Result == model.PaymentReconciliationProviderRecordMissing {
source = "本地订单"
}
if item.Resolved {
resolved = "是"
}
rows = append(rows, []string{paymentCSVIdentifier(run.ID), run.BillDate, s.paymentExportProviderName(run.ProviderID), paymentCSVIdentifier(item.PaymentOrderID), paymentCSVIdentifier(item.MerchantOrderNo), paymentCSVIdentifier(item.ProviderTradeNo), paymentDecimal(item.AmountFen, 2, false), source, item.Currency, paymentExportLabel(string(item.Result)), resolved, item.Detail, paymentCSVTime(&run.StartedAt), paymentCSVTime(run.CompletedAt)})
}
return paymentCSV(rows)
}
func checkPaymentExportSize(size int) error {
if size > paymentExportLimit {
return BadAuthRequest("单次最多导出 10000 行,请缩小筛选范围后重试")
}
return nil
}
func paymentCSV(rows [][]string) ([]byte, error) {
var buffer bytes.Buffer
buffer.WriteString("\xEF\xBB\xBF")
writer := csv.NewWriter(&buffer)
writer.UseCRLF = true
for _, row := range rows {
for i, value := range row {
row[i] = paymentCSVText(value)
}
if err := writer.Write(row); err != nil {
return nil, err
}
}
writer.Flush()
return buffer.Bytes(), writer.Error()
}
func paymentCSVText(value string) string {
trimmed := strings.TrimLeftFunc(value, func(r rune) bool { return unicode.IsSpace(r) || unicode.IsControl(r) || r == '\uFEFF' })
if strings.ContainsAny(value, "\t\r\n") || (trimmed != "" && strings.ContainsRune("=+-@", rune(trimmed[0]))) {
return "'" + value
}
return value
}
func paymentCSVIdentifier(value string) string {
// CSV cannot declare text types. Prefix numeric/scientific IDs with a literal
// apostrophe, never a formula; generic CSV consumers may remove this prefix.
if value != "" {
if _, err := strconv.ParseFloat(strings.TrimSpace(value), 64); err == nil {
return "'" + value
}
}
return value
}
func paymentDecimal(value int64, places int, trim bool) string {
// Format integers directly to avoid rounding microcredits through float64.
digits := strconv.FormatInt(value, 10)
sign := ""
if strings.HasPrefix(digits, "-") {
sign, digits = "-", digits[1:]
}
if len(digits) <= places {
digits = strings.Repeat("0", places+1-len(digits)) + digits
}
result := sign + digits[:len(digits)-places] + "." + digits[len(digits)-places:]
if trim {
result = strings.TrimRight(strings.TrimRight(result, "0"), ".")
}
return result
}
func paymentCSVTime(value *time.Time) string {
if value == nil || value.IsZero() {
return ""
}
return value.In(paymentBillLocation).Format("2006-01-02 15:04:05")
}
func (s *Service) paymentExportProviderName(id string) string {
if s.paymentRegistry != nil {
if provider, ok := s.paymentRegistry.Get(id); ok {
return provider.Descriptor().Name
}
}
return id
}
func paymentExportLabel(value string) string {
labels := map[string]string{"created": "创建中", "pending": "待支付", "closing": "关单中", "closed": "已关闭", "credited": "已入账", "create_failed": "下单失败", "running": "执行中", "completed": "已完成", "failed": "失败", "matched": "一致", "recovered": "已自动补发", "local_order_not_found": "本地订单缺失", "provider_record_missing": "渠道记录缺失", "amount_mismatch": "金额不一致", "trade_no_mismatch": "交易号不一致", "credit_failed": "补发失败"}
if label, ok := labels[value]; ok {
return label
}
return value
}
+220
View File
@@ -0,0 +1,220 @@
package app
import (
"context"
"encoding/csv"
"errors"
"fmt"
"strings"
"testing"
"time"
"infinite-canvas/backend/internal/model"
"infinite-canvas/backend/internal/repository"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestPaymentCSVEncodingAndPrecision(t *testing.T) {
id := "000123456789012345678901234567890"
rows := [][]string{{"名称", "订单号", "金额", "积分", "备注"}, {"中文,\"名称\"", paymentCSVIdentifier(id), paymentDecimal(1001, 2, false), paymentDecimal(999999999999999, 6, true), "换行\n说明"}, {" \t=HYPERLINK(\"evil\")", paymentCSVIdentifier("12E20"), "", "", "@SUM(1)"}}
data, err := paymentCSV(rows)
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(string(data), "\xEF\xBB\xBF") || !strings.Contains(string(data), "\r\n") {
t.Fatalf("missing BOM/CRLF: %q", data)
}
parsed, err := csv.NewReader(strings.NewReader(strings.TrimPrefix(string(data), "\xEF\xBB\xBF"))).ReadAll()
if err != nil {
t.Fatal(err)
}
if parsed[1][0] != "中文,\"名称\"" || parsed[1][1] != "'"+id || parsed[1][2] != "10.01" || parsed[1][3] != "999999999.999999" {
t.Fatalf("lost precision or escaping: %#v", parsed[1])
}
if !strings.HasPrefix(parsed[2][0], "'") || parsed[2][1] != "'12E20" || parsed[2][4] != "'@SUM(1)" {
t.Fatalf("unsafe cell: %#v", parsed[2])
}
for _, dangerous := range []string{"=1", "+1", "-1", "@SUM(1)", " \r=1", "\uFEFF=1", "\x00=1"} {
if !strings.HasPrefix(paymentCSVText(dangerous), "'") {
t.Fatalf("unsafe: %q", dangerous)
}
}
if paymentDecimal(1, 6, true) != "0.000001" || paymentDecimal(1000000, 6, true) != "1" || paymentDecimal(0, 2, false) != "0.00" {
t.Fatal("invalid decimal formatting")
}
if paymentCSVTime(nil) != "" {
t.Fatal("nil date should be empty")
}
}
func TestPaymentExportDateValidation(t *testing.T) {
f, err := (PaymentOrderQuery{From: "2026-09-01", To: "2026-09-01", TimeField: "paid"}).filter()
if err != nil {
t.Fatal(err)
}
if f.From.UTC().Format(time.RFC3339) != "2026-08-31T16:00:00Z" || f.Until.Sub(f.From) != 24*time.Hour {
t.Fatalf("wrong date bounds: %#v", f)
}
for _, query := range []PaymentOrderQuery{{From: "2026-02-30"}, {To: "2026-9-1"}, {From: "2026-09-02", To: "2026-09-01"}, {TimeField: "created_at; DELETE"}, {Status: "unknown"}} {
if _, err := query.filter(); err == nil {
t.Fatalf("accepted invalid query: %#v", query)
}
}
if _, err := (PaymentReconciliationQuery{Status: "unknown"}).filter(); err == nil {
t.Fatal("accepted invalid run status")
}
if validatePaymentResult("abnormal") != nil || validatePaymentResult("unknown") == nil {
t.Fatal("invalid result validation")
}
if checkPaymentExportSize(10000) != nil || checkPaymentExportSize(10001) == nil {
t.Fatal("export limit is not enforced")
}
}
func TestPaymentExportsRequireAdmin(t *testing.T) {
s := &Service{}
for _, actor := range []*model.User{nil, {Role: model.UserRoleUser}} {
for _, export := range []func() ([]byte, error){
func() ([]byte, error) {
return s.AdminPaymentOrdersCSV(context.Background(), actor, PaymentOrderQuery{})
},
func() ([]byte, error) {
return s.AdminPaymentReconciliationsCSV(context.Background(), actor, PaymentReconciliationQuery{})
},
func() ([]byte, error) {
return s.AdminPaymentReconciliationItemsCSV(context.Background(), actor, "run", "")
},
} {
if _, err := export(); err == nil {
t.Fatal("non-admin export accepted")
}
}
}
}
func TestPaymentExportsAllRowsAndReconciliationSemantics(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
sqlDB, _ := db.DB()
t.Cleanup(func() { _ = sqlDB.Close() })
if err := db.AutoMigrate(&model.User{}, &model.PaymentOrder{}, &model.PaymentReconciliationRun{}, &model.PaymentReconciliationItem{}); err != nil {
t.Fatal(err)
}
s := &Service{repo: repository.New(db)}
admin := &model.User{ID: "admin", Role: model.UserRoleAdmin}
now := time.Date(2026, 9, 17, 8, 0, 0, 0, time.UTC)
orders := make([]model.PaymentOrder, 31)
for i := range orders {
orders[i] = model.PaymentOrder{ID: fmt.Sprint(i), UserID: "missing", IdempotencyKey: fmt.Sprint(i), MerchantOrderNo: fmt.Sprintf("000000000000000000%02d", i), AmountFen: 1001, CreditsMicrocredits: 1000001, CreatedAt: now}
}
if err := db.Create(&orders).Error; err != nil {
t.Fatal(err)
}
data, err := s.AdminPaymentOrdersCSV(context.Background(), admin, PaymentOrderQuery{})
if err != nil {
t.Fatal(err)
}
rows := readPaymentTestCSV(t, data)
if len(rows) != 32 || rows[1][3] != "missing" || rows[1][4] != "" || rows[1][9] != "10.01" || rows[1][11] != "1.000001" || rows[1][14] != "2026-09-17 16:00:00" {
t.Fatalf("wrong order export: count=%d row=%#v", len(rows), rows[1])
}
run := model.PaymentReconciliationRun{ID: "run", ProviderID: "wechat-native", BillDate: "2026-09-17", Status: model.PaymentReconciliationCompleted, TotalItems: 2, MatchItems: 1, ErrorItems: 1, StartedBy: "system", StartedAt: now, CompletedAt: &now}
if err := db.Create(&run).Error; err != nil {
t.Fatal(err)
}
items := []model.PaymentReconciliationItem{{ID: "matched", RunID: run.ID, Result: model.PaymentReconciliationMatched, Resolved: true}, {ID: "missing", RunID: run.ID, Result: model.PaymentReconciliationProviderRecordMissing, AmountFen: 1001, Detail: "本地已入账"}}
if err := db.Create(&items).Error; err != nil {
t.Fatal(err)
}
data, err = s.AdminPaymentReconciliationItemsCSV(context.Background(), admin, run.ID, "abnormal")
if err != nil {
t.Fatal(err)
}
rows = readPaymentTestCSV(t, data)
if len(rows) != 2 || rows[1][7] != "本地订单" || rows[1][9] != "渠道记录缺失" || rows[1][10] != "否" {
t.Fatalf("wrong detail export: %#v", rows)
}
data, err = s.AdminPaymentReconciliationsCSV(context.Background(), admin, PaymentReconciliationQuery{})
if err != nil {
t.Fatal(err)
}
rows = readPaymentTestCSV(t, data)
if rows[1][3] != "已完成" || rows[1][7] != "1" || rows[1][9] != "系统" {
t.Fatalf("completed must retain anomalies: %#v", rows)
}
for _, status := range []model.PaymentReconciliationStatus{model.PaymentReconciliationRunning, model.PaymentReconciliationFailed} {
if err := db.Model(&run).Updates(map[string]any{"status": status, "error": "BILL_ERROR"}).Error; err != nil {
t.Fatal(err)
}
_, err = s.AdminPaymentReconciliationItemsCSV(context.Background(), admin, run.ID, "")
var appErr *AppError
if !errors.As(err, &appErr) || appErr.Status != 409 {
t.Fatalf("unfinished detail export: %v", err)
}
}
data, err = s.AdminPaymentReconciliationsCSV(context.Background(), admin, PaymentReconciliationQuery{Status: "failed"})
if err != nil {
t.Fatal(err)
}
rows = readPaymentTestCSV(t, data)
if len(rows) != 2 || rows[1][8] != "BILL_ERROR" {
t.Fatal("failed run lost its error")
}
}
func TestPaymentExportRejectsTruncation(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
sqlDB, _ := db.DB()
t.Cleanup(func() { _ = sqlDB.Close() })
if err := db.AutoMigrate(&model.User{}, &model.PaymentOrder{}); err != nil {
t.Fatal(err)
}
s := &Service{repo: repository.New(db)}
admin := &model.User{Role: model.UserRoleAdmin}
orders := make([]model.PaymentOrder, paymentExportLimit)
for i := range orders {
id := fmt.Sprintf("order-%05d", i)
orders[i] = model.PaymentOrder{ID: id, UserID: "missing", IdempotencyKey: id, MerchantOrderNo: id}
}
if err := db.CreateInBatches(orders, 100).Error; err != nil {
t.Fatal(err)
}
data, err := s.AdminPaymentOrdersCSV(context.Background(), admin, PaymentOrderQuery{})
if err != nil {
t.Fatal(err)
}
if rows := readPaymentTestCSV(t, data); len(rows) != paymentExportLimit+1 {
t.Fatalf("truncated export: %d", len(rows))
}
if err := db.Create(&model.PaymentOrder{ID: "overflow", UserID: "missing", IdempotencyKey: "overflow", MerchantOrderNo: "overflow"}).Error; err != nil {
t.Fatal(err)
}
data, err = s.AdminPaymentOrdersCSV(context.Background(), admin, PaymentOrderQuery{})
var appErr *AppError
if !errors.As(err, &appErr) || appErr.Status != 400 || len(data) != 0 {
t.Fatalf("oversize returned file: bytes=%d err=%v", len(data), err)
}
data, err = s.AdminPaymentOrdersCSV(context.Background(), admin, PaymentOrderQuery{Keyword: "no-such-order"})
if err != nil {
t.Fatal(err)
}
if rows := readPaymentTestCSV(t, data); len(rows) != 1 {
t.Fatalf("empty export: %d", len(rows))
}
}
func readPaymentTestCSV(t *testing.T, data []byte) [][]string {
t.Helper()
rows, err := csv.NewReader(strings.NewReader(strings.TrimPrefix(string(data), "\xEF\xBB\xBF"))).ReadAll()
if err != nil {
t.Fatal(err)
}
return rows
}
@@ -272,12 +272,16 @@ func safePaymentReconciliationError(err error) string {
return truncateRunes(strings.TrimSpace(err.Error()), 1000)
}
func (s *Service) AdminPaymentReconciliationPage(actor *model.User, providerID, status string, page, limit int) (*AdminPaymentReconciliationPage, error) {
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)
runs, total, err := s.repo.AdminPaymentReconciliationRuns(providerID, status, limit, (page-1)*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
}
@@ -289,6 +293,9 @@ func (s *Service) AdminPaymentReconciliationItems(actor *model.User, runID, resu
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
+3 -2
View File
@@ -178,6 +178,7 @@ func RegisterPaymentRoutes(r *gin.RouterGroup, svc *service.Service) {
})
admin := r.Group("/admin/payments")
registerPaymentExportRoutes(admin, svc)
admin.GET("/providers", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
@@ -269,7 +270,7 @@ func RegisterPaymentRoutes(r *gin.RouterGroup, svc *service.Service) {
fail(c, http.StatusBadRequest, err)
return
}
result, err := svc.AdminPaymentOrderPage(user, c.Query("status"), c.Query("keyword"), page, limit)
result, err := svc.AdminPaymentOrderPage(user, paymentOrderQuery(c), page, limit)
if err != nil {
failService(c, err)
return
@@ -331,7 +332,7 @@ func RegisterPaymentRoutes(r *gin.RouterGroup, svc *service.Service) {
fail(c, http.StatusBadRequest, err)
return
}
result, err := svc.AdminPaymentReconciliationPage(user, c.Query("providerId"), c.Query("status"), page, limit)
result, err := svc.AdminPaymentReconciliationPage(user, paymentReconciliationQuery(c), page, limit)
if err != nil {
failService(c, err)
return
@@ -0,0 +1,51 @@
package handler
import (
"net/http"
"time"
"infinite-canvas/backend/internal/service"
"github.com/gin-gonic/gin"
)
func paymentOrderQuery(c *gin.Context) service.PaymentOrderQuery {
return service.PaymentOrderQuery{Status: c.Query("status"), Keyword: c.Query("keyword"), ProviderID: c.Query("providerId"), TimeField: c.Query("timeField"), From: c.Query("from"), To: c.Query("to")}
}
func paymentReconciliationQuery(c *gin.Context) service.PaymentReconciliationQuery {
return service.PaymentReconciliationQuery{ProviderID: c.Query("providerId"), Status: c.Query("status"), From: c.Query("from"), To: c.Query("to")}
}
func registerPaymentExportRoutes(admin *gin.RouterGroup, svc *service.Service) {
for _, spec := range []struct{ path, name string }{
{"/orders/export.csv", "payment-orders"},
{"/reconciliations/export.csv", "payment-reconciliations"},
{"/reconciliations/:id/items/export.csv", "payment-reconciliation-items"},
} {
admin.GET(spec.path, func(c *gin.Context) {
c.Header("Cache-Control", "no-store")
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
var data []byte
switch spec.name {
case "payment-orders":
data, err = svc.AdminPaymentOrdersCSV(c.Request.Context(), user, paymentOrderQuery(c))
case "payment-reconciliations":
data, err = svc.AdminPaymentReconciliationsCSV(c.Request.Context(), user, paymentReconciliationQuery(c))
default:
data, err = svc.AdminPaymentReconciliationItemsCSV(c.Request.Context(), user, c.Param("id"), c.Query("result"))
}
if err != nil {
failService(c, err)
return
}
c.Header("Content-Disposition", "attachment; filename="+spec.name+"-"+time.Now().UTC().Format("20060102-150405")+".csv")
c.Header("X-Content-Type-Options", "nosniff")
c.Data(http.StatusOK, "text/csv; charset=utf-8", data)
})
}
}
+50 -21
View File
@@ -382,23 +382,44 @@ func (r *Repository) PaymentOrdersNeedingQuery(cutoff time.Time, limit int) ([]m
return items, err
}
func (r *Repository) AdminPaymentOrders(status, keyword string, limit, offset int) ([]model.PaymentOrder, int64, error) {
func (r *Repository) AdminPaymentOrders(filter PaymentOrderFilter, limit, offset int) ([]model.PaymentOrder, int64, error) {
var items []model.PaymentOrder
var total int64
query := r.paymentOrderQuery(filter)
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
err := query.Order("created_at desc, id desc").Limit(limit).Offset(offset).Find(&items).Error
return items, total, err
}
func (r *Repository) paymentOrderQuery(filter PaymentOrderFilter) *gorm.DB {
query := r.db.Model(&model.PaymentOrder{})
if normalized := strings.TrimSpace(status); normalized != "" && normalized != "all" {
if normalized := strings.TrimSpace(filter.Status); normalized != "" && normalized != "all" {
query = query.Where("status = ?", normalized)
}
if normalized := strings.TrimSpace(keyword); normalized != "" {
if normalized := strings.TrimSpace(filter.Keyword); normalized != "" {
like := "%" + normalized + "%"
users := r.db.Model(&model.User{}).Select("id").Where("LOWER(username) LIKE ? OR LOWER(display_name) LIKE ? OR LOWER(email) LIKE ?", strings.ToLower(like), strings.ToLower(like), strings.ToLower(like))
query = query.Where("merchant_order_no LIKE ? OR provider_trade_no LIKE ? OR user_id = ? OR user_id IN (?)", like, like, normalized, users)
}
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
if filter.ProviderID != "" && filter.ProviderID != "all" {
query = query.Where("provider_id = ?", filter.ProviderID)
}
err := query.Order("created_at desc").Limit(limit).Offset(offset).Find(&items).Error
return items, total, err
column := "created_at"
switch filter.TimeField {
case "paid":
column = "provider_paid_at"
case "credited":
column = "credited_at"
}
if !filter.From.IsZero() {
query = query.Where(column+" >= ?", filter.From)
}
if !filter.Until.IsZero() {
query = query.Where(column+" < ?", filter.Until)
}
return query
}
// BeginPaymentReconciliation creates or safely reuses the single daily run
@@ -503,34 +524,42 @@ func (r *Repository) PaymentReconciliationRunByDate(providerID, billDate string)
return &run, r.db.First(&run, "provider_id = ? AND bill_date = ?", strings.TrimSpace(providerID), strings.TrimSpace(billDate)).Error
}
func (r *Repository) AdminPaymentReconciliationRuns(providerID, status string, limit, offset int) ([]model.PaymentReconciliationRun, int64, error) {
func (r *Repository) AdminPaymentReconciliationRuns(filter PaymentReconciliationFilter, limit, offset int) ([]model.PaymentReconciliationRun, int64, error) {
var runs []model.PaymentReconciliationRun
var total int64
query := r.db.Model(&model.PaymentReconciliationRun{})
if normalized := strings.TrimSpace(providerID); normalized != "" && normalized != "all" {
query = query.Where("provider_id = ?", normalized)
}
if normalized := strings.TrimSpace(status); normalized != "" && normalized != "all" {
query = query.Where("status = ?", normalized)
}
query := r.paymentReconciliationQuery(filter)
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
err := query.Order("bill_date desc, started_at desc").Limit(limit).Offset(offset).Find(&runs).Error
err := query.Order("bill_date desc, started_at desc, id desc").Limit(limit).Offset(offset).Find(&runs).Error
return runs, total, err
}
func (r *Repository) paymentReconciliationQuery(filter PaymentReconciliationFilter) *gorm.DB {
query := r.db.Model(&model.PaymentReconciliationRun{})
if normalized := strings.TrimSpace(filter.ProviderID); normalized != "" && normalized != "all" {
query = query.Where("provider_id = ?", normalized)
}
if normalized := strings.TrimSpace(filter.Status); normalized != "" && normalized != "all" {
query = query.Where("status = ?", normalized)
}
if filter.From != "" {
query = query.Where("bill_date >= ?", filter.From)
}
if filter.To != "" {
query = query.Where("bill_date <= ?", filter.To)
}
return query
}
func (r *Repository) PaymentReconciliationItems(runID, result string, limit, offset int) ([]model.PaymentReconciliationItem, int64, error) {
var items []model.PaymentReconciliationItem
var total int64
query := r.db.Model(&model.PaymentReconciliationItem{}).Where("run_id = ?", strings.TrimSpace(runID))
if normalized := strings.TrimSpace(result); normalized != "" && normalized != "all" {
query = query.Where("result = ?", normalized)
}
query := r.paymentReconciliationItemsQuery(runID, result)
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
err := query.Order("resolved asc, created_at asc").Limit(limit).Offset(offset).Find(&items).Error
err := query.Order("resolved asc, created_at asc, id asc").Limit(limit).Offset(offset).Find(&items).Error
return items, total, err
}
@@ -0,0 +1,80 @@
package repository
import (
"database/sql"
"errors"
"strings"
"time"
"infinite-canvas/backend/internal/model"
"gorm.io/gorm"
)
type PaymentOrderFilter struct {
Status, Keyword, ProviderID, TimeField string
From, Until time.Time
}
type PaymentReconciliationFilter struct {
ProviderID, Status, From, To string
}
var ErrPaymentReconciliationRunning = errors.New("payment reconciliation is running")
func (r *Repository) paymentReconciliationItemsQuery(runID, result string) *gorm.DB {
query := r.db.Model(&model.PaymentReconciliationItem{}).Where("run_id = ?", strings.TrimSpace(runID))
if normalized := strings.TrimSpace(result); normalized == "abnormal" {
query = query.Where("result NOT IN ?", []string{"matched", "recovered"})
} else if normalized != "" && normalized != "all" {
query = query.Where("result = ?", normalized)
}
return query
}
func (r *Repository) ExportPaymentOrders(filter PaymentOrderFilter, limit int) (orders []model.PaymentOrder, users map[string]model.User, err error) {
users = make(map[string]model.User)
err = r.db.Transaction(func(tx *gorm.DB) error {
snapshot := New(tx)
if err := snapshot.paymentOrderQuery(filter).Order("created_at desc, id desc").Limit(limit).Find(&orders).Error; err != nil {
return err
}
// Bound IN parameters for SQLite as well as PostgreSQL.
for start := 0; start < len(orders); start += 500 {
ids := make([]string, 0, 500)
for _, order := range orders[start:min(start+500, len(orders))] {
ids = append(ids, order.UserID)
}
batch, err := snapshot.UsersByIDs(ids)
if err != nil {
return err
}
for id, user := range batch {
users[id] = user
}
}
return nil
}, &sql.TxOptions{Isolation: sql.LevelRepeatableRead, ReadOnly: true})
return
}
func (r *Repository) ExportPaymentReconciliations(filter PaymentReconciliationFilter, limit int) ([]model.PaymentReconciliationRun, error) {
var runs []model.PaymentReconciliationRun
err := r.paymentReconciliationQuery(filter).Order("bill_date desc, started_at desc, id desc").Limit(limit).Find(&runs).Error
return runs, err
}
func (r *Repository) ExportPaymentReconciliationItems(runID, result string, limit int) (run model.PaymentReconciliationRun, items []model.PaymentReconciliationItem, err error) {
// A rerun deletes old items and reuses the run ID. Both reads must observe
// the same snapshot, including on PostgreSQL (whose default is read committed).
err = r.db.Transaction(func(tx *gorm.DB) error {
if err := tx.First(&run, "id = ?", strings.TrimSpace(runID)).Error; err != nil {
return err
}
if run.Status == model.PaymentReconciliationRunning {
return ErrPaymentReconciliationRunning
}
return New(tx).paymentReconciliationItemsQuery(run.ID, result).Order("resolved asc, created_at asc, id asc").Limit(limit).Find(&items).Error
}, &sql.TxOptions{Isolation: sql.LevelRepeatableRead, ReadOnly: true})
return
}
@@ -0,0 +1,149 @@
package repository
import (
"errors"
"path/filepath"
"testing"
"time"
"infinite-canvas/backend/internal/model"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestPaymentExportMatchesFiltersAcrossPages(t *testing.T) {
db := openPaymentTestDB(t)
if err := db.AutoMigrate(&model.User{}); err != nil {
t.Fatal(err)
}
user := model.User{ID: "user", Username: "alice", Email: "alice@example.com"}
if err := db.Create(&user).Error; err != nil {
t.Fatal(err)
}
start := time.Date(2026, 9, 1, 0, 0, 0, 0, time.FixedZone("CST", 8*3600))
end := start.AddDate(0, 0, 1)
middle := start.Add(time.Hour)
orders := []model.PaymentOrder{
{ID: "a", UserID: user.ID, IdempotencyKey: "a", MerchantOrderNo: "a", ProviderID: "wechat-native", Status: model.PaymentOrderCredited, CreatedAt: start, ProviderPaidAt: &middle, CreditedAt: &end},
{ID: "b", UserID: user.ID, IdempotencyKey: "b", MerchantOrderNo: "b", ProviderID: "wechat-native", Status: model.PaymentOrderCredited, CreatedAt: middle, ProviderPaidAt: &middle},
{ID: "c", UserID: user.ID, IdempotencyKey: "c", MerchantOrderNo: "c", ProviderID: "wechat-native", Status: model.PaymentOrderCredited, CreatedAt: end, ProviderPaidAt: &end},
{ID: "d", UserID: user.ID, IdempotencyKey: "d", MerchantOrderNo: "d", ProviderID: "alipay-page-pay", Status: model.PaymentOrderCredited, CreatedAt: middle},
}
if err := db.Create(&orders).Error; err != nil {
t.Fatal(err)
}
repo := New(db)
filter := PaymentOrderFilter{Keyword: "ALICE@EXAMPLE.COM", Status: "credited", ProviderID: "wechat-native", From: start, Until: end}
page, total, err := repo.AdminPaymentOrders(filter, 1, 0)
if err != nil || total != 2 || len(page) != 1 {
t.Fatalf("invalid list: %v %d %v", page, total, err)
}
exported, users, err := repo.ExportPaymentOrders(filter, 10001)
if err != nil || len(exported) != 2 || users[user.ID].Username != "alice" || exported[0].ID != page[0].ID {
t.Fatalf("export differs from list: %v %v", exported, err)
}
filter.TimeField = "paid"
exported, _, err = repo.ExportPaymentOrders(filter, 10001)
if err != nil || len(exported) != 2 {
t.Fatalf("paid bounds: %v %v", exported, err)
}
filter.TimeField = "credited"
exported, _, err = repo.ExportPaymentOrders(filter, 10001)
if err != nil || len(exported) != 0 {
t.Fatalf("credited null/end boundary: %v %v", exported, err)
}
}
func TestPaymentReconciliationExportSnapshotAndRerun(t *testing.T) {
db := openPaymentTestDB(t)
if err := db.AutoMigrate(&model.PaymentReconciliationRun{}, &model.PaymentReconciliationItem{}); err != nil {
t.Fatal(err)
}
repo := New(db)
run := model.PaymentReconciliationRun{ID: "run", ProviderID: "wechat-native", BillDate: "2026-09-01", Status: model.PaymentReconciliationCompleted, TotalItems: 3, MatchItems: 1, RecoveredItems: 1, ErrorItems: 1}
if err := db.Create(&run).Error; err != nil {
t.Fatal(err)
}
items := []model.PaymentReconciliationItem{{ID: "a", RunID: run.ID, Result: model.PaymentReconciliationMatched, Resolved: true}, {ID: "b", RunID: run.ID, Result: model.PaymentReconciliationRecovered, Resolved: true}, {ID: "c", RunID: run.ID, Result: model.PaymentReconciliationAmountMismatch}}
if err := db.Create(&items).Error; err != nil {
t.Fatal(err)
}
_, exported, err := repo.ExportPaymentReconciliationItems(run.ID, "abnormal", 10001)
if err != nil || len(exported) != 1 || exported[0].ID != "c" {
t.Fatalf("wrong abnormal items: %v %v", exported, err)
}
filter := PaymentReconciliationFilter{ProviderID: run.ProviderID, Status: "completed", From: run.BillDate, To: run.BillDate}
runs, total, err := repo.AdminPaymentReconciliationRuns(filter, 1, 0)
if err != nil || total != 1 || len(runs) != 1 {
t.Fatalf("wrong summary: %v %v", runs, err)
}
summaries, err := repo.ExportPaymentReconciliations(filter, 10001)
if err != nil || len(summaries) != 1 || summaries[0].ErrorItems != 1 {
t.Fatalf("wrong export: %v %v", summaries, err)
}
filter.From = "2026-09-02"
summaries, err = repo.ExportPaymentReconciliations(filter, 10001)
if err != nil || len(summaries) != 0 {
t.Fatalf("ignored date filter: %v %v", summaries, err)
}
_, started, err := repo.BeginPaymentReconciliation(&model.PaymentReconciliationRun{ID: "new-run", ProviderID: run.ProviderID, BillDate: run.BillDate})
if err != nil || !started {
t.Fatalf("rerun: %v %v", started, err)
}
_, _, err = repo.ExportPaymentReconciliationItems(run.ID, "", 10001)
if !errors.Is(err, ErrPaymentReconciliationRunning) {
t.Fatalf("running export accepted: %v", err)
}
}
func TestPaymentExportKeepsSnapshotWhenRerunCompletesBetweenReads(t *testing.T) {
dsn := filepath.Join(t.TempDir(), "payment.db") + "?_journal_mode=WAL&_busy_timeout=5000"
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
writer, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
for _, connection := range []*gorm.DB{db, writer} {
sqlDB, _ := connection.DB()
t.Cleanup(func() { _ = sqlDB.Close() })
}
if err := db.AutoMigrate(&model.PaymentReconciliationRun{}, &model.PaymentReconciliationItem{}); err != nil {
t.Fatal(err)
}
run := model.PaymentReconciliationRun{ID: "run", ProviderID: "provider", BillDate: "2026-09-01", Status: model.PaymentReconciliationCompleted, TotalItems: 1, ErrorItems: 1}
if err := db.Create(&run).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.PaymentReconciliationItem{ID: "old", RunID: run.ID, Result: model.PaymentReconciliationAmountMismatch}).Error; err != nil {
t.Fatal(err)
}
replaced := false
if err := db.Callback().Query().After("gorm:query").Register("test:rerun-between-export-reads", func(tx *gorm.DB) {
if replaced || tx.Statement.Table != "payment_reconciliation_runs" {
return
}
replaced = true
writerRepo := New(writer)
_, started, err := writerRepo.BeginPaymentReconciliation(&model.PaymentReconciliationRun{ID: "unused", ProviderID: run.ProviderID, BillDate: run.BillDate})
if err != nil || !started {
t.Fatalf("could not rerun: %v", err)
}
if err := writerRepo.CompletePaymentReconciliation(run.ID, []model.PaymentReconciliationItem{{ID: "new", RunID: run.ID, Result: model.PaymentReconciliationRecovered, Resolved: true}}, 0, 1, 0); err != nil {
t.Fatal(err)
}
}); err != nil {
t.Fatal(err)
}
snapshot, items, err := New(db).ExportPaymentReconciliationItems(run.ID, "", 10001)
if err != nil || !replaced || snapshot.ErrorItems != 1 || len(items) != 1 || items[0].ID != "old" {
t.Fatalf("mixed snapshot: run=%+v items=%v err=%v", snapshot, items, err)
}
latest, newItems, err := New(writer).ExportPaymentReconciliationItems(run.ID, "", 10001)
if err != nil || latest.RecoveredItems != 1 || len(newItems) != 1 || newItems[0].ID != "new" {
t.Fatalf("rerun not committed: %+v %v %v", latest, newItems, err)
}
}
+1 -1
View File
@@ -395,7 +395,7 @@ func TestAdminPaymentOrdersSearchUserIdentity(t *testing.T) {
{"merchant-c", "", 1}, {"trade-c", "", 1}, {"missing-user", "", 1}, {"", "", 3},
} {
t.Run(tc.keyword+"/"+tc.status, func(t *testing.T) {
items, total, err := repo.AdminPaymentOrders(tc.status, tc.keyword, 1, 0)
items, total, err := repo.AdminPaymentOrders(PaymentOrderFilter{Status: tc.status, Keyword: tc.keyword}, 1, 0)
if err != nil || total != tc.total || len(items) > 1 {
t.Fatalf("got items=%d total=%d err=%v; want total=%d", len(items), total, err, tc.total)
}
@@ -3,6 +3,8 @@ package service
import "infinite-canvas/backend/internal/app"
type (
PaymentOrderQuery = app.PaymentOrderQuery
PaymentReconciliationQuery = app.PaymentReconciliationQuery
CloudAgentRequest = app.CloudAgentRequest
CloudAgentMediaSettings = app.CloudAgentMediaSettings
CloudAgentCapabilitySet = app.CloudAgentCapabilitySet