feat(payment): 支付管理 - 新增支付订单与对账 CSV 导出
This commit is contained in:
@@ -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
backend/internal/app/payment_export.go
Normal file
256
backend/internal/app/payment_export.go
Normal 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
backend/internal/app/payment_export_test.go
Normal file
220
backend/internal/app/payment_export_test.go
Normal 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
|
||||
|
||||
@@ -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
|
||||
|
||||
51
backend/internal/handler/payment_export.go
Normal file
51
backend/internal/handler/payment_export.go
Normal file
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
80
backend/internal/repository/payment_export.go
Normal file
80
backend/internal/repository/payment_export.go
Normal file
@@ -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
|
||||
}
|
||||
149
backend/internal/repository/payment_export_test.go
Normal file
149
backend/internal/repository/payment_export_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -24,6 +24,7 @@ description: 从产品功能快速定位主要代码入口
|
||||
| 画布编辑器 | `web/src/pages/canvas/project.tsx` | `pages/canvas/canvas-project-*`、`pages/canvas/use-canvas-*`、`components/canvas/`、`lib/canvas/`、`stores/canvas/` |
|
||||
| 公开分享 | `web/src/pages/canvas/shared.tsx` | `components/canvas/infinite-canvas.tsx`、`services/api/canvas-share.ts` |
|
||||
| 管理后台 | `web/src/pages/admin/`、`router.tsx` | `admin-context.tsx`、`users/`、`channels/`、`logs/`、`storyboard-prompts/`、`settings/`、`components/agent-lessons-panel.tsx` |
|
||||
| 支付 CSV 导出 | `web/src/pages/admin/payments/payments-page.tsx` | `services/api/payments.ts`、`AdminExportButton`;后端 `handler/payment_export.go`、`app/payment_export.go`、`repository/payment_export.go`(共享筛选、只读快照) |
|
||||
|
||||
## 画布核心
|
||||
|
||||
|
||||
@@ -62,12 +62,34 @@ Skills 的 `tool_completed` / `tool_failed` 事件附带 `skillId`、`skillName`
|
||||
|
||||
## 管理员支付订单
|
||||
|
||||
`GET /api/admin/payments/orders` 需要管理员权限,支持 `status`、`keyword`、`page`、`pageSize`。关键词匹配订单号、渠道交易号、用户名称、用户名或邮箱,也支持完整用户 ID 精确匹配。用户名和邮箱搜索忽略大小写;状态过滤同时生效,返回匹配总数和当前页订单。
|
||||
`GET /api/admin/payments/orders` 需要管理员权限,支持 `status`、`keyword`、`providerId`、`timeField`、`from`、`to`、`page`、`pageSize`。关键词匹配订单号、渠道交易号、用户名称、用户名或邮箱,也支持完整用户 ID 精确匹配。用户名和邮箱搜索忽略大小写;所有筛选同时生效,返回匹配总数和当前页订单。`timeField` 为 `created`(默认创建时间)、`paid`(渠道支付时间)、`credited`(入账时间)。`from`、`to` 为 `YYYY-MM-DD`,包含起止日期,按北京时间转换为左闭右开的时间区间;未填写的边界不限制,倒置日期或无效状态/时间口径返回 400。
|
||||
|
||||
每条管理员列表订单在原有字段之外包含 `user: { id, username, displayName, email }`,由当前页用户 ID 批量读取;用户不存在时返回 `user: null`,保留原 `userId` 与历史订单。此身份摘要仅用于管理员订单列表,不添加到普通用户支付接口或支付状态同步响应。
|
||||
|
||||
后台支付订单列表展示用户身份,点击名称打开用户详情;订单详情提供完整订单号、渠道交易号、用户 ID 和各阶段时间。同步支付状态与关闭订单位于更多菜单,关闭订单仍需确认。
|
||||
|
||||
### 支付对账筛选及 CSV 导出
|
||||
|
||||
`GET /api/admin/payments/reconciliations` 支持 `providerId`、`status`(`running` / `completed` / `failed`)、`from`、`to`(账单日期,包含起止日)、`page`、`pageSize`。`GET /api/admin/payments/reconciliations/:id/items` 支持 `result`、`page`、`pageSize`;`result=abnormal` 同时选出所有非 `matched`、非 `recovered` 明细,未指定或 `all` 表示全部。具体结果还支持 `matched`、`recovered`、`local_order_not_found`、`provider_record_missing`、`amount_mismatch`、`trade_no_mismatch`、`credit_failed`。
|
||||
|
||||
三个只读导出接口均在 service 校验管理员权限,返回 `text/csv; charset=utf-8` 附件,设置 `Cache-Control: no-store`。导出复用列表筛选条件,不受 `page`、`pageSize` 影响:
|
||||
|
||||
| 接口 | 参数与内容 |
|
||||
| --- | --- |
|
||||
| `GET /api/admin/payments/orders/export.csv` | 与订单列表相同的筛选;完整订单号、渠道交易号、用户身份、商品、渠道、金额、币种、积分、订单/渠道状态及各阶段时间 |
|
||||
| `GET /api/admin/payments/reconciliations/export.csv` | 与对账列表相同的筛选;每日执行状态、总笔数、一致/补发/异常笔数、失败原因、发起人 ID 或系统、开始/完成时间 |
|
||||
| `GET /api/admin/payments/reconciliations/:id/items/export.csv` | `result`;单次对账的完整交易明细及对账开始/完成时间,便于区分同日重跑结果 |
|
||||
|
||||
最多导出 10000 行数据(不含表头);超限返回 400 业务错误,提示缩小筛选范围,不截断文件。无匹配数据时接口返回仅含表头的 CSV;前端无数据时禁用导出。导出不查单、不重跑对账、不补发积分。
|
||||
|
||||
对账完成不代表无异常,汇总保留异常笔数;失败记录仍可导出汇总及失败原因。明细仅允许已完成的对账,执行中或失败返回 409。对账重跑复用记录并替换明细,明细导出使用可重复读快照,保证汇总与明细来自同一次数据库快照。订单及关联用户身份也在同一快照内读取。
|
||||
|
||||
明细的“记录金额”附带“金额来源”:通常为渠道账单,`provider_record_missing` 为本地订单。该字段不声称同时保存渠道和本地金额,不根据订单当前值推算历史差额。订单金额和积分为订单数据,未支付订单的积分不代表已经发放。
|
||||
|
||||
CSV 使用中文表头、UTF-8 BOM、CRLF;金额按整数分精确转换为两位小数,积分从微积分转换并保留最多六位小数,时间表头注明北京时间。空时间留空。文本转义逗号/引号/换行,并在公式触发字符前加单引号;可被解析为数字或科学计数法的 ID/订单号也加单引号防止表格软件丢失精度,不生成 `="..."` 公式。通用 CSV 消费者可能看见该保护前缀,按文本导入并按需移除;仅 CSV 引号不能声明列类型。
|
||||
|
||||
前端使用已经应用到列表的筛选条件导出;更改条件后,先查询/筛选再启用导出,分页和刷新沿用已应用条件。明细抽屉支持“仅异常”和单独刷新,切换记录/筛选时取消旧请求,避免旧响应覆盖新记录。下载接口的 JSON 错误 Blob 会解析为业务提示,超量或重跑冲突不会被保存为 CSV。
|
||||
|
||||
## 用户 Agent 记忆
|
||||
|
||||
`GET/POST /api/agent/memories`、`PATCH/DELETE /api/agent/memories/:id`、`POST /api/agent/memories/:id/decide`、`GET /api/agent/memories/export`、`POST /api/agent/memories/import`、`GET/PATCH /api/agent/memories/settings`、`POST /api/agent/memories/compact` 需要登录。记忆按 `author_user_id` 隔离;手动添加直接批准,Agent `remember_lesson` 写入待审,由本人 `decide`。导出 `kind` 为 `agent-memories`。压缩只处理该用户已批准记忆,创建 `canvas_text` 任务 `operation=agent_memory_compact`;worker 仅在 `agent_memory_settings.compact_task_id` 与任务 ID 一致时应用 JSON 计划(merge 保留第一条并删除其余,rewrite 原地更新)。周期为 `off` / `daily` / `weekly` / `monthly`,调度每 5 分钟扫描,每拍最多 3 个用户。
|
||||
|
||||
@@ -5,6 +5,10 @@ description: 当前项目已实现的核心工作台能力与使用入口
|
||||
|
||||
# 功能清单
|
||||
|
||||
## 支付订单与对账导出
|
||||
|
||||
管理员在 `/admin/payments` 可按关键词、状态、渠道和创建/支付/入账日期筛选订单,并导出全部匹配记录的 CSV。对账支持按渠道、执行状态和账单日期导出每日汇总;单次对账明细支持按结果或“仅异常”导出。单次最多 10000 行,超限明确提示,不静默截断。文件使用中文表头和北京时间,保留完整订单号、精确金额/积分及失败原因。导出不触发查单或积分补发;对账运行中不能导出明细。具体字段和参数见 [HTTP API 合同](../backend/http-api)。
|
||||
|
||||
## 工作区签到加油站
|
||||
|
||||
积分功能开启且后台「每日签到积分」大于 0 时,首页侧栏容量条上方固定显示领取卡片:标题为「{站点品牌名}加油站」,副文案为今日可领积分,黑色按钮「立即领取」。未领取时一直显示;当天领取成功后立即消失。关闭积分、签到积分为 0,或未登录时不显示。卡片用白底、黑按钮和青绿积分数字,不套浅蓝舞台底。
|
||||
|
||||
@@ -5,6 +5,18 @@ description: 当前项目已实现功能的手工验证清单与未确认边界
|
||||
|
||||
# 待测试
|
||||
|
||||
## 支付订单与对账 CSV 导出
|
||||
|
||||
已通过支付相关 Go 测试(含 SQLite 两连接重跑快照、10000/10001 行边界)、前端下载与请求测试、修改文件 ESLint 和 Vite 打包;Excel 隐藏实例打开测试文件验证中文、长数字/前导零、金额/积分及公式保护通过。完整前端构建的类型检查被现有 `canvas-cloud-agent-panel.tsx` 中未定义的 `markdownPlainText` 阻断;浏览器交互、WPS 和 PostgreSQL 并发仍待人工验收。
|
||||
|
||||
- 在 `/admin/payments` 使用超过一页的订单,确认导出包含筛选后的全部记录;关键词、渠道、状态、创建/支付/入账时间及北京时间起止日一致生效。
|
||||
- 修改筛选条件未查询时不能导出;查询失败不能导出旧结果。快速切换筛选、分页及明细记录时,旧请求不能覆盖新数据。
|
||||
- 汇总包含失败原因、自动补发笔数和异常笔数;“已完成”但有异常仍如实导出。明细“仅异常”排除一致和已自动补发。
|
||||
- 重跑期间明细导出禁用;直接调用接口也返回 409。并发重跑时导出的记录和明细保持同一数据库快照,补充 PostgreSQL 实例上的并发验收。
|
||||
- 单次 10000 行成功,10001 行拒绝;前端显示业务原因且不下载错误文件。空结果、未登录和普通用户权限也需覆盖。
|
||||
- 用 Excel/WPS 验收中文、含逗号/引号/换行名称、长数字及前导零订单号、微积分和公式触发文本。数字订单号的单引号保护前缀属于预期;不要按数值列导入。
|
||||
- 检查明暗主题、窄屏筛选换行、键盘操作及导出 loading;本清单中的人工验收尚未由用户确认。
|
||||
|
||||
## 开发镜像标签可在线更新到正式版(2026-09-18)
|
||||
|
||||
`CANVAS_IMAGE_TAG=sha-5509b17` 这类非正式标签不再被字典序判成比 `v1.5.0` 更新。待登录管理后台「系统配置 / 系统更新」:
|
||||
|
||||
@@ -6,12 +6,15 @@ import { Switch } from "@/pages/admin/ui/controls";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import dayjs, { type Dayjs } from "dayjs";
|
||||
import { Eye, Plus, RefreshCw, Search, Settings2, XCircle } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { PaginationBar } from "@/pages/admin/components/admin-ui";
|
||||
import { formatCredits } from "@/constant/credits";
|
||||
import {
|
||||
closeAdminPaymentOrder,
|
||||
exportAdminPaymentOrders,
|
||||
exportAdminPaymentReconciliations,
|
||||
exportAdminPaymentReconciliationItems,
|
||||
createAdminTopupProduct,
|
||||
listAdminPaymentOrders,
|
||||
listAdminPaymentProviders,
|
||||
@@ -25,13 +28,15 @@ import {
|
||||
type AdminPaymentProvider,
|
||||
type AdminPaymentOrder,
|
||||
type PaymentOrder,
|
||||
type PaymentOrderFilters,
|
||||
type PaymentReconciliationFilters,
|
||||
type PaymentReconciliationItem,
|
||||
type PaymentReconciliationRun,
|
||||
type TopupProduct,
|
||||
} from "@/services/api/payments";
|
||||
|
||||
import { AdminPageFrame } from "../components/admin-shell";
|
||||
import { AdminDataTable, AdminRowActions, AdminStatusBadge, AdminTableEmpty, configuredSecretText } from "../components/admin-ui";
|
||||
import { AdminDataTable, AdminExportButton, AdminRowActions, AdminStatusBadge, AdminTableEmpty, configuredSecretText } from "../components/admin-ui";
|
||||
import { AdminUserDetailDrawer } from "../components/admin-user-detail-drawer";
|
||||
import "./payments-page.css";
|
||||
|
||||
@@ -92,6 +97,12 @@ export default function AdminPaymentsPage() {
|
||||
const [orderPageSize, setOrderPageSize] = useState(30);
|
||||
const [orderStatusFilter, setOrderStatusFilter] = useState("all");
|
||||
const [orderKeyword, setOrderKeyword] = useState("");
|
||||
const [orderProviderFilter, setOrderProviderFilter] = useState("all");
|
||||
const [orderTimeField, setOrderTimeField] = useState<"created" | "paid" | "credited">("created");
|
||||
const [orderDates, setOrderDates] = useState<[Dayjs | null, Dayjs | null] | null>(null);
|
||||
const [appliedOrders, setAppliedOrders] = useState<PaymentOrderFilters>({ timeField: "created" });
|
||||
const [ordersReady, setOrdersReady] = useState(false);
|
||||
const orderRequest = useRef<AbortController | null>(null);
|
||||
const [ordersLoading, setOrdersLoading] = useState(false);
|
||||
const [orderActionId, setOrderActionId] = useState("");
|
||||
|
||||
@@ -101,6 +112,11 @@ export default function AdminPaymentsPage() {
|
||||
const [runPageSize, setRunPageSize] = useState(30);
|
||||
const [runsLoading, setRunsLoading] = useState(false);
|
||||
const [runProviderFilter, setRunProviderFilter] = useState("all");
|
||||
const [runStatusFilter, setRunStatusFilter] = useState("all");
|
||||
const [runDates, setRunDates] = useState<[Dayjs | null, Dayjs | null] | null>(null);
|
||||
const [appliedRuns, setAppliedRuns] = useState<PaymentReconciliationFilters>({});
|
||||
const [runsReady, setRunsReady] = useState(false);
|
||||
const runRequest = useRef<AbortController | null>(null);
|
||||
const [runningBill, setRunningBill] = useState(false);
|
||||
const [billProviderId, setBillProviderId] = useState("");
|
||||
const [billDate, setBillDate] = useState<Dayjs>(dayjs().subtract(1, "day"));
|
||||
@@ -111,6 +127,25 @@ export default function AdminPaymentsPage() {
|
||||
const [detailPageSize, setDetailPageSize] = useState(50);
|
||||
const [detailResult, setDetailResult] = useState("all");
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [detailReady, setDetailReady] = useState(false);
|
||||
const detailRequest = useRef<AbortController | null>(null);
|
||||
|
||||
const orderFilters: PaymentOrderFilters = {
|
||||
timeField: orderTimeField,
|
||||
status: orderStatusFilter === "all" ? undefined : orderStatusFilter,
|
||||
keyword: orderKeyword.trim() || undefined,
|
||||
providerId: orderProviderFilter === "all" ? undefined : orderProviderFilter,
|
||||
from: orderDates?.[0]?.format("YYYY-MM-DD"),
|
||||
to: orderDates?.[1]?.format("YYYY-MM-DD"),
|
||||
};
|
||||
const runFilters: PaymentReconciliationFilters = {
|
||||
providerId: runProviderFilter === "all" ? undefined : runProviderFilter,
|
||||
status: runStatusFilter === "all" ? undefined : runStatusFilter,
|
||||
from: runDates?.[0]?.format("YYYY-MM-DD"),
|
||||
to: runDates?.[1]?.format("YYYY-MM-DD"),
|
||||
};
|
||||
const ordersDirty = JSON.stringify(orderFilters) !== JSON.stringify(appliedOrders);
|
||||
const runsDirty = JSON.stringify(runFilters) !== JSON.stringify(appliedRuns);
|
||||
|
||||
const loadBase = async () => {
|
||||
setLoading(true);
|
||||
@@ -126,38 +161,57 @@ export default function AdminPaymentsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const loadOrders = async (page = orderPage, pageSize = orderPageSize) => {
|
||||
const loadOrders = async (page = orderPage, pageSize = orderPageSize, filters = appliedOrders) => {
|
||||
orderRequest.current?.abort();
|
||||
const controller = new AbortController();
|
||||
orderRequest.current = controller;
|
||||
setOrdersLoading(true);
|
||||
setOrdersReady(false);
|
||||
try {
|
||||
const result = await listAdminPaymentOrders({ status: orderStatusFilter === "all" ? undefined : orderStatusFilter, keyword: orderKeyword.trim() || undefined, page, pageSize });
|
||||
const result = await listAdminPaymentOrders({ ...filters, page, pageSize }, controller.signal);
|
||||
if (controller.signal.aborted) return;
|
||||
setOrders(result.orders);
|
||||
setOrderTotal(result.total);
|
||||
setOrderPage(result.page);
|
||||
setOrderPageSize(result.pageSize);
|
||||
setAppliedOrders(filters);
|
||||
setOrdersReady(true);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "读取支付订单失败");
|
||||
if (!controller.signal.aborted) message.error(error instanceof Error ? error.message : "读取支付订单失败");
|
||||
} finally {
|
||||
setOrdersLoading(false);
|
||||
if (!controller.signal.aborted) setOrdersLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadRuns = async (page = runPage, pageSize = runPageSize) => {
|
||||
const loadRuns = async (page = runPage, pageSize = runPageSize, filters = appliedRuns) => {
|
||||
runRequest.current?.abort();
|
||||
const controller = new AbortController();
|
||||
runRequest.current = controller;
|
||||
setRunsLoading(true);
|
||||
setRunsReady(false);
|
||||
try {
|
||||
const result = await listAdminPaymentReconciliations({ providerId: runProviderFilter === "all" ? undefined : runProviderFilter, page, pageSize });
|
||||
const result = await listAdminPaymentReconciliations({ ...filters, page, pageSize }, controller.signal);
|
||||
if (controller.signal.aborted) return;
|
||||
setRuns(result.runs);
|
||||
setRunTotal(result.total);
|
||||
setRunPage(result.page);
|
||||
setRunPageSize(result.pageSize);
|
||||
setAppliedRuns(filters);
|
||||
setRunsReady(true);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "读取对账记录失败");
|
||||
if (!controller.signal.aborted) message.error(error instanceof Error ? error.message : "读取对账记录失败");
|
||||
} finally {
|
||||
setRunsLoading(false);
|
||||
if (!controller.signal.aborted) setRunsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void Promise.all([loadBase(), loadOrders(1, orderPageSize), loadRuns(1, runPageSize)]);
|
||||
return () => {
|
||||
orderRequest.current?.abort();
|
||||
runRequest.current?.abort();
|
||||
detailRequest.current?.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const refresh = async () => {
|
||||
@@ -293,19 +347,26 @@ export default function AdminPaymentsPage() {
|
||||
};
|
||||
|
||||
const openRunDetails = async (run: PaymentReconciliationRun, page = 1, pageSize = detailPageSize, result = detailResult) => {
|
||||
detailRequest.current?.abort();
|
||||
const controller = new AbortController();
|
||||
detailRequest.current = controller;
|
||||
setDetailRun(run);
|
||||
setDetailResult(result);
|
||||
setDetailLoading(true);
|
||||
setDetailReady(false);
|
||||
try {
|
||||
const response = await listAdminPaymentReconciliationItems(run.id, { result: result === "all" ? undefined : result, page, pageSize });
|
||||
const response = await listAdminPaymentReconciliationItems(run.id, { result: result === "all" ? undefined : result, page, pageSize }, controller.signal);
|
||||
if (controller.signal.aborted) return;
|
||||
setDetailRun(response.run);
|
||||
setDetailItems(response.items);
|
||||
setDetailTotal(response.total);
|
||||
setDetailPage(response.page);
|
||||
setDetailPageSize(response.pageSize);
|
||||
setDetailReady(true);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "读取对账明细失败");
|
||||
if (!controller.signal.aborted) message.error(error instanceof Error ? error.message : "读取对账明细失败");
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
if (!controller.signal.aborted) setDetailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -490,7 +551,7 @@ export default function AdminPaymentsPage() {
|
||||
width: 90,
|
||||
align: "center",
|
||||
render: (_, run) => (
|
||||
<Button type="text" size="small" icon={<Eye className="size-3.5" />} onClick={() => void openRunDetails(run)}>
|
||||
<Button type="text" size="small" icon={<Eye className="size-3.5" />} onClick={() => void openRunDetails(run, 1, detailPageSize, "all")}>
|
||||
查看
|
||||
</Button>
|
||||
),
|
||||
@@ -558,20 +619,57 @@ export default function AdminPaymentsPage() {
|
||||
placeholder="搜索名称、用户名、邮箱、订单号"
|
||||
title="支持名称、用户名、邮箱、订单号、渠道交易号和完整用户 ID"
|
||||
onChange={(event) => setOrderKeyword(event.target.value)}
|
||||
onPressEnter={() => void loadOrders(1)}
|
||||
onPressEnter={() => void loadOrders(1, orderPageSize, orderFilters)}
|
||||
/>
|
||||
}
|
||||
toolbarFilters={
|
||||
<Select
|
||||
className="w-36"
|
||||
value={orderStatusFilter}
|
||||
onChange={setOrderStatusFilter}
|
||||
options={[{ value: "all", label: "全部状态" }, ...Object.entries(paymentOrderStatus).map(([value, item]) => ({ value, label: item.label }))]}
|
||||
/>
|
||||
<>
|
||||
<Select
|
||||
className="w-36"
|
||||
aria-label="订单状态"
|
||||
value={orderStatusFilter}
|
||||
onChange={setOrderStatusFilter}
|
||||
options={[{ value: "all", label: "全部状态" }, ...Object.entries(paymentOrderStatus).map(([value, item]) => ({ value, label: item.label }))]}
|
||||
/>
|
||||
<Select
|
||||
className="min-w-44"
|
||||
aria-label="订单支付渠道"
|
||||
value={orderProviderFilter}
|
||||
onChange={setOrderProviderFilter}
|
||||
options={[{ value: "all", label: "全部支付渠道" }, ...providers.map((provider) => ({ value: provider.id, label: provider.name }))]}
|
||||
/>
|
||||
<Select
|
||||
className="w-32"
|
||||
aria-label="订单时间口径"
|
||||
value={orderTimeField}
|
||||
onChange={setOrderTimeField}
|
||||
options={[
|
||||
{ value: "created", label: "创建时间" },
|
||||
{ value: "paid", label: "支付时间" },
|
||||
{ value: "credited", label: "入账时间" },
|
||||
]}
|
||||
/>
|
||||
<DatePicker.RangePicker value={orderDates} onChange={setOrderDates} placeholder={["开始日期(北京时间)", "结束日期"]} />
|
||||
</>
|
||||
}
|
||||
trailing={
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{ordersDirty && <span className="text-xs text-foreground/45">请先查询以应用筛选条件</span>}
|
||||
<Button loading={ordersLoading} onClick={() => void loadOrders(1, orderPageSize, orderFilters)}>
|
||||
查询
|
||||
</Button>
|
||||
<span title="导出筛选后的全部订单,最多 10000 行">
|
||||
<AdminExportButton
|
||||
label="导出 CSV"
|
||||
disabled={ordersDirty || !ordersReady || ordersLoading || orderTotal === 0}
|
||||
exportFile={() => exportAdminPaymentOrders(appliedOrders)}
|
||||
fileName={() => `支付订单-${dayjs().format("YYYYMMDD-HHmmss")}.csv`}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
trailing={<Button onClick={() => void loadOrders(1)}>查询</Button>}
|
||||
table={{ rowKey: "id", loading: ordersLoading, columns: orderColumns, dataSource: orders, pagination: false, tableLayout: "fixed", scroll: { x: 1110 } }}
|
||||
empty={<AdminTableEmpty filtered={Boolean(orderKeyword || orderStatusFilter !== "all")} title="没有支付订单" />}
|
||||
empty={<AdminTableEmpty filtered={Boolean(appliedOrders.keyword || appliedOrders.status || appliedOrders.providerId || appliedOrders.from)} title="没有支付订单" />}
|
||||
footer={<PaginationBar alwaysShow current={orderPage} pageSize={orderPageSize} total={orderTotal} onChange={(page, size) => void loadOrders(size !== orderPageSize ? 1 : page, size)} />}
|
||||
/>
|
||||
),
|
||||
@@ -599,15 +697,45 @@ export default function AdminPaymentsPage() {
|
||||
toolbar={
|
||||
<Select
|
||||
className="w-52"
|
||||
aria-label="对账支付渠道"
|
||||
value={runProviderFilter}
|
||||
onChange={(value) => {
|
||||
setRunProviderFilter(value);
|
||||
setRunPage(1);
|
||||
}}
|
||||
onChange={setRunProviderFilter}
|
||||
options={[{ value: "all", label: "全部支付渠道" }, ...providers.map((provider) => ({ value: provider.id, label: provider.name }))]}
|
||||
/>
|
||||
}
|
||||
trailing={<Button onClick={() => void loadRuns(1)}>筛选</Button>}
|
||||
toolbarFilters={
|
||||
<>
|
||||
<Select
|
||||
className="w-36"
|
||||
aria-label="对账执行状态"
|
||||
value={runStatusFilter}
|
||||
onChange={setRunStatusFilter}
|
||||
options={[
|
||||
{ value: "all", label: "全部状态" },
|
||||
{ value: "running", label: "执行中" },
|
||||
{ value: "completed", label: "已完成" },
|
||||
{ value: "failed", label: "失败" },
|
||||
]}
|
||||
/>
|
||||
<DatePicker.RangePicker value={runDates} onChange={setRunDates} placeholder={["账单开始日期", "账单结束日期"]} />
|
||||
</>
|
||||
}
|
||||
trailing={
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{runsDirty && <span className="text-xs text-foreground/45">请先筛选以应用条件</span>}
|
||||
<Button loading={runsLoading} onClick={() => void loadRuns(1, runPageSize, runFilters)}>
|
||||
筛选
|
||||
</Button>
|
||||
<span title="导出筛选后的全部对账汇总,含失败原因,最多 10000 行">
|
||||
<AdminExportButton
|
||||
label="导出汇总 CSV"
|
||||
disabled={runsDirty || !runsReady || runsLoading || runTotal === 0}
|
||||
exportFile={() => exportAdminPaymentReconciliations(appliedRuns)}
|
||||
fileName={() => `支付对账汇总-${dayjs().format("YYYYMMDD-HHmmss")}.csv`}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
table={{ rowKey: "id", loading: runsLoading, columns: runColumns, dataSource: runs, pagination: false, scroll: { x: 1000 } }}
|
||||
empty={<AdminTableEmpty title="还没有对账记录" />}
|
||||
footer={<PaginationBar alwaysShow current={runPage} pageSize={runPageSize} total={runTotal} onChange={(page, size) => void loadRuns(size !== runPageSize ? 1 : page, size)} />}
|
||||
@@ -736,6 +864,8 @@ export default function AdminPaymentsPage() {
|
||||
open={Boolean(detailRun)}
|
||||
destroyOnHidden
|
||||
onClose={() => {
|
||||
detailRequest.current?.abort();
|
||||
setDetailReady(false);
|
||||
setDetailRun(undefined);
|
||||
setDetailItems([]);
|
||||
}}
|
||||
@@ -749,14 +879,30 @@ export default function AdminPaymentsPage() {
|
||||
toolbar={
|
||||
<Select
|
||||
className="w-44"
|
||||
aria-label="对账结果"
|
||||
value={detailResult}
|
||||
onChange={(value) => {
|
||||
setDetailResult(value);
|
||||
if (detailRun) void openRunDetails(detailRun, 1, detailPageSize, value);
|
||||
}}
|
||||
options={[{ value: "all", label: "全部结果" }, ...Object.entries(reconciliationResult).map(([value, item]) => ({ value, label: item.label }))]}
|
||||
options={[{ value: "all", label: "全部结果" }, { value: "abnormal", label: "仅异常" }, ...Object.entries(reconciliationResult).map(([value, item]) => ({ value, label: item.label }))]}
|
||||
/>
|
||||
}
|
||||
trailing={
|
||||
<div className="flex items-center gap-2">
|
||||
<Button loading={detailLoading} onClick={() => detailRun && void openRunDetails(detailRun)}>
|
||||
刷新
|
||||
</Button>
|
||||
<span title={detailRun?.status === "running" ? "对账执行中,完成后可导出" : "导出当前账单及结果筛选下的全部明细,最多 10000 行"}>
|
||||
<AdminExportButton
|
||||
label="导出明细 CSV"
|
||||
disabled={!detailReady || detailLoading || runningBill || detailRun?.status !== "completed" || detailTotal === 0}
|
||||
exportFile={() => exportAdminPaymentReconciliationItems(detailRun!.id, detailResult === "all" ? undefined : detailResult)}
|
||||
fileName={() => `支付对账明细-${detailRun?.providerId}-${detailRun?.billDate}-${dayjs().format("YYYYMMDD-HHmmss")}.csv`}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
table={{ rowKey: "id", loading: detailLoading, columns: detailColumns, dataSource: detailItems, pagination: false, scroll: { x: 950 } }}
|
||||
empty={<AdminTableEmpty title={detailRun?.status === "failed" ? "本次对账未生成明细" : "账单没有交易记录"} />}
|
||||
footer={<PaginationBar alwaysShow current={detailPage} pageSize={detailPageSize} total={detailTotal} onChange={(page, size) => detailRun && void openRunDetails(detailRun, size !== detailPageSize ? 1 : page, size, detailResult)} />}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { http } from "@/services/api/request";
|
||||
import { ApiError, compactApiParams, http } from "@/services/api/request";
|
||||
|
||||
export type PaymentProvider = {
|
||||
id: "wechat-native" | "alipay-page-pay" | string;
|
||||
@@ -129,8 +129,11 @@ export type AdminPaymentOrder = PaymentOrder & {
|
||||
user: { id: string; username: string; displayName: string; email: string } | null;
|
||||
};
|
||||
|
||||
export function listAdminPaymentOrders(params: { status?: string; keyword?: string; page?: number; pageSize?: number } = {}) {
|
||||
return http.get<{ orders: AdminPaymentOrder[]; total: number; page: number; pageSize: number }>("/admin/payments/orders", { params });
|
||||
export type PaymentOrderFilters = { status?: string; keyword?: string; providerId?: string; timeField?: "created" | "paid" | "credited"; from?: string; to?: string };
|
||||
export type PaymentReconciliationFilters = { providerId?: string; status?: string; from?: string; to?: string };
|
||||
|
||||
export function listAdminPaymentOrders(params: PaymentOrderFilters & { page?: number; pageSize?: number } = {}, signal?: AbortSignal) {
|
||||
return http.get<{ orders: AdminPaymentOrder[]; total: number; page: number; pageSize: number }>("/admin/payments/orders", { params: compactApiParams(params), signal });
|
||||
}
|
||||
|
||||
export function queryAdminPaymentOrder(id: string) {
|
||||
@@ -182,10 +185,44 @@ export function runAdminPaymentReconciliation(input: { providerId: string; billD
|
||||
return http.post<{ run: PaymentReconciliationRun }>("/admin/payments/reconciliations", input);
|
||||
}
|
||||
|
||||
export function listAdminPaymentReconciliations(params: { providerId?: string; status?: string; page?: number; pageSize?: number } = {}) {
|
||||
return http.get<{ runs: PaymentReconciliationRun[]; total: number; page: number; pageSize: number }>("/admin/payments/reconciliations", { params });
|
||||
export function listAdminPaymentReconciliations(params: PaymentReconciliationFilters & { page?: number; pageSize?: number } = {}, signal?: AbortSignal) {
|
||||
return http.get<{ runs: PaymentReconciliationRun[]; total: number; page: number; pageSize: number }>("/admin/payments/reconciliations", { params: compactApiParams(params), signal });
|
||||
}
|
||||
|
||||
export function listAdminPaymentReconciliationItems(id: string, params: { result?: string; page?: number; pageSize?: number } = {}) {
|
||||
return http.get<{ run: PaymentReconciliationRun; items: PaymentReconciliationItem[]; total: number; page: number; pageSize: number }>(`/admin/payments/reconciliations/${encodeURIComponent(id)}/items`, { params });
|
||||
export function listAdminPaymentReconciliationItems(id: string, params: { result?: string; page?: number; pageSize?: number } = {}, signal?: AbortSignal) {
|
||||
return http.get<{ run: PaymentReconciliationRun; items: PaymentReconciliationItem[]; total: number; page: number; pageSize: number }>(`/admin/payments/reconciliations/${encodeURIComponent(id)}/items`, { params: compactApiParams(params), signal });
|
||||
}
|
||||
|
||||
export function exportAdminPaymentOrders(params: PaymentOrderFilters) {
|
||||
return downloadPaymentCSV("/admin/payments/orders/export.csv", params);
|
||||
}
|
||||
|
||||
export function exportAdminPaymentReconciliations(params: PaymentReconciliationFilters) {
|
||||
return downloadPaymentCSV("/admin/payments/reconciliations/export.csv", params);
|
||||
}
|
||||
|
||||
export function exportAdminPaymentReconciliationItems(id: string, result?: string) {
|
||||
return downloadPaymentCSV(`/admin/payments/reconciliations/${encodeURIComponent(id)}/items/export.csv`, { result });
|
||||
}
|
||||
|
||||
async function downloadPaymentCSV(url: string, params: Record<string, string | undefined>) {
|
||||
try {
|
||||
const response = await http.raw<Blob>({ method: "get", url, params: compactApiParams(params), responseType: "blob" });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
// Axios also wraps JSON error envelopes in a Blob for download requests.
|
||||
const blob = error instanceof ApiError ? (error.cause as { response?: { data?: unknown } } | undefined)?.response?.data : undefined;
|
||||
if (blob instanceof Blob) {
|
||||
let envelope: { msg?: string; code?: number; reason?: string } | undefined;
|
||||
try {
|
||||
envelope = JSON.parse(await blob.text());
|
||||
} catch {
|
||||
/* Keep the original transport error. */
|
||||
}
|
||||
if (envelope?.msg && error instanceof ApiError) {
|
||||
throw new ApiError(envelope.msg, { status: error.status, code: envelope.code, reason: envelope.reason, cause: error });
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
33
web/test/payment-export.test.ts
Normal file
33
web/test/payment-export.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { afterEach, expect, spyOn, test } from "bun:test";
|
||||
import { ApiError, http } from "../src/services/api/request";
|
||||
import { exportAdminPaymentOrders, exportAdminPaymentReconciliations, exportAdminPaymentReconciliationItems } from "../src/services/api/payments";
|
||||
|
||||
let raw: ReturnType<typeof spyOn> | undefined;
|
||||
afterEach(() => raw?.mockRestore());
|
||||
|
||||
test("payment exports pass filters without pagination and preserve downloaded bytes", async () => {
|
||||
const blob = new Blob(["\uFEFF订单号,金额\r\n'000000000000000001,10.01\r\n"], { type: "text/csv" });
|
||||
raw = spyOn(http, "raw").mockResolvedValue({ data: blob } as never);
|
||||
expect(await exportAdminPaymentOrders({ keyword: "alice", providerId: "wechat-native", timeField: "paid", from: "2026-09-01", to: "2026-09-17" })).toBe(blob);
|
||||
expect(raw.mock.calls[0][0]).toMatchObject({ url: "/admin/payments/orders/export.csv", responseType: "blob", params: { keyword: "alice", timeField: "paid", from: "2026-09-01", to: "2026-09-17" } });
|
||||
expect(raw.mock.calls[0][0].params).not.toHaveProperty("page");
|
||||
await exportAdminPaymentReconciliations({ status: "failed" });
|
||||
expect(raw.mock.calls[1][0]).toMatchObject({ url: "/admin/payments/reconciliations/export.csv", params: { status: "failed" } });
|
||||
await exportAdminPaymentReconciliationItems("run/id", "abnormal");
|
||||
expect(raw.mock.calls[2][0]).toMatchObject({ url: "/admin/payments/reconciliations/run%2Fid/items/export.csv", params: { result: "abnormal" } });
|
||||
});
|
||||
|
||||
test("payment downloads expose JSON Blob error messages instead of saving an error CSV", async () => {
|
||||
const error = new ApiError("Request failed", { status: 400, cause: { response: { data: new Blob([JSON.stringify({ code: 400, msg: "单次最多导出 10000 行,请缩小筛选范围后重试", reason: "bad_request" })], { type: "application/json" }) } } });
|
||||
raw = spyOn(http, "raw").mockRejectedValue(error);
|
||||
await expect(exportAdminPaymentOrders({})).rejects.toMatchObject({ status: 400, code: 400, reason: "bad_request", message: "单次最多导出 10000 行,请缩小筛选范围后重试" });
|
||||
});
|
||||
|
||||
test("payment downloads retain non-JSON errors and cancellation", async () => {
|
||||
const error = new ApiError("后端服务暂时不可用", { status: 502, cause: { response: { data: new Blob(["<html>Bad gateway</html>"]) } } });
|
||||
raw = spyOn(http, "raw").mockRejectedValue(error);
|
||||
await expect(exportAdminPaymentOrders({})).rejects.toBe(error);
|
||||
const cancelled = new DOMException("请求已取消", "AbortError");
|
||||
raw.mockRejectedValue(cancelled);
|
||||
await expect(exportAdminPaymentOrders({})).rejects.toBe(cancelled);
|
||||
});
|
||||
Reference in New Issue
Block a user