From 74efb323f01025a594b29dfb83b05949d46474b8 Mon Sep 17 00:00:00 2001 From: czl231 <3286836406@qq.com> Date: Mon, 17 Aug 2026 00:44:49 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=80=80=E6=AC=BE=E5=AE=A1?= =?UTF-8?q?=E6=A0=B8=E8=AF=A6=E6=83=85=E5=85=B3=E8=81=94=E4=B8=8E=E7=A9=BA?= =?UTF-8?q?=E5=80=BC=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/api/cmd/cli/main.go | 30 +- .../internal/logic/platform/payment/refund.go | 292 +++++++++++++++++- .../logic/platform/payment/refund_test.go | 50 +++ backend/api/internal/seed/mock.go | 46 ++- backend/api/internal/seed/mock_test.go | 10 + ...作日志_退款审核关联名称展示修复_20260817.md | 65 ++++ docs/项目文档_平台资源中文展示统一_v1.0.md | 4 + .../check-resource-display-contracts.mjs | 34 ++ .../src/api/resource-detail-contract.ts | 10 + frontend/platform_admin/src/api/resources.ts | 13 +- 10 files changed, 543 insertions(+), 11 deletions(-) create mode 100644 backend/api/internal/logic/platform/payment/refund_test.go create mode 100644 docs/操作日志_退款审核关联名称展示修复_20260817.md diff --git a/backend/api/cmd/cli/main.go b/backend/api/cmd/cli/main.go index 9f00537..2a7d4f3 100644 --- a/backend/api/cmd/cli/main.go +++ b/backend/api/cmd/cli/main.go @@ -78,6 +78,13 @@ func main() { os.Exit(1) } fmt.Printf("mock track points repaired: %d\n", count) + case "repair-mock-refund-reviewer": + count, err := repairMockRefundReviewer() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + fmt.Printf("mock refund reviewers repaired: %d\n", count) case "migrate": if err := migrateDatabase(); err != nil { fmt.Fprintln(os.Stderr, err) @@ -92,7 +99,7 @@ func main() { } func printUsage() { - fmt.Fprintln(os.Stderr, "usage: platform-cli ") + fmt.Fprintln(os.Stderr, "usage: platform-cli ") } type route struct { @@ -265,6 +272,27 @@ func repairMockTrackPoint() (int64, error) { return count, nil } +// repairMockRefundReviewer 使用独立幂等命令修复固定 Mock 退款的审核人归属。 +func repairMockRefundReviewer() (int64, error) { + config.New(serviceKey) + if config.Spec.Databases == nil { + return 0, fmt.Errorf("database configuration is required") + } + databaseService, err := database.NewDatabase( + config.Spec.Databases.Driver, + config.Spec.Databases.Source, + dbsql.SetOptions(nil), + ) + if err != nil { + return 0, fmt.Errorf("connect database: %w", err) + } + count, err := seed.RepairMockRefundReviewer(databaseService) + if err != nil { + return 0, fmt.Errorf("repair mock refund reviewer: %w", err) + } + return count, nil +} + func migrateDatabase() error { config.New(serviceKey) options := &types.SqlOptions{ diff --git a/backend/api/internal/logic/platform/payment/refund.go b/backend/api/internal/logic/platform/payment/refund.go index d48464d..75b6510 100644 --- a/backend/api/internal/logic/platform/payment/refund.go +++ b/backend/api/internal/logic/platform/payment/refund.go @@ -1,16 +1,304 @@ package payment import ( + "encoding/json" + "strconv" + "strings" + "git.apinb.com/bsm-sdk/core/errcode" "git.apinb.com/bsm-sdk/core/infra" + "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common" paylogic "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/payment" "git.apinb.com/heqiapp/platforms/backend/api/internal/models" "github.com/gin-gonic/gin" ) -func ListRefund(ctx *gin.Context) { common.ListResource(ctx, &models.PaymentRefund{}) } -func GetRefund(ctx *gin.Context) { common.GetResource(ctx, &models.PaymentRefund{}) } +// paymentRefundDisplay 在退款事实之外附加财务审核所需的可读关系名称。 +type paymentRefundDisplay struct { + models.PaymentRefund + BusinessDisplayName string `json:"business_display_name"` + UserDisplayName string `json:"user_display_name"` + ReviewerDisplayName string `json:"reviewer_display_name"` + RefundProductSummary string `json:"refund_product_summary"` +} + +// refundEcProductSnapshot 是商城退款读取成交商品名称所需的最小快照结构。 +type refundEcProductSnapshot struct { + Name string `json:"name"` +} + +// ListRefund 返回退款列表,并批量解析原订单号和申请用户姓名。 +func ListRefund(ctx *gin.Context) { + page, size := common.PageSize(ctx) + var refunds []models.PaymentRefund + var total int64 + query := common.ApplyKeywordFilter( + ctx, + common.ActiveRecords(impl.DBService.Model(&models.PaymentRefund{})), + &models.PaymentRefund{}, + ) + if err := query.Count(&total).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + if err := query.Order("created_at desc").Offset((page - 1) * size).Limit(size).Find(&refunds).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + displays, err := buildPaymentRefundDisplays(refunds) + if err != nil { + infra.Response.Error(ctx, err) + return + } + response, err := common.PublicResourceResponse(displays) + if err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, gin.H{"total": total, "list": response}) +} + +// GetRefund 返回退款详情,并附加与列表一致的可读关系名称。 +func GetRefund(ctx *gin.Context) { + var refund models.PaymentRefund + if err := common.ActiveRecords(impl.DBService). + Where("identity = ?", ctx.Param("identity")).First(&refund).Error; err != nil { + common.RespondRecordError(ctx, err) + return + } + displays, err := buildPaymentRefundDisplays([]models.PaymentRefund{refund}) + if err != nil { + infra.Response.Error(ctx, err) + return + } + response, err := common.PublicResourceResponse(displays[0]) + if err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, response) +} + +// buildPaymentRefundDisplays 批量读取当前页引用,避免退款列表逐行查询。 +func buildPaymentRefundDisplays(refunds []models.PaymentRefund) ([]paymentRefundDisplay, error) { + gasorderIdentities := make([]string, 0, len(refunds)) + ecOrderIdentities := make([]string, 0, len(refunds)) + userIdentities := make([]string, 0, len(refunds)) + for _, refund := range refunds { + switch refund.BusinessType { + case "gasorder": + gasorderIdentities = append(gasorderIdentities, refund.BusinessIdentity) + case "ec_order": + ecOrderIdentities = append(ecOrderIdentities, refund.BusinessIdentity) + } + userIdentities = append(userIdentities, refund.UserIdentity) + } + gasorderNames := map[string]string{} + if len(gasorderIdentities) > 0 { + var orders []models.GasorderBasic + if err := common.ActiveRecords(impl.DBService).Select("identity", "order_no"). + Where("identity IN ?", gasorderIdentities).Find(&orders).Error; err != nil { + return nil, err + } + for _, order := range orders { + gasorderNames[order.Identity] = order.OrderNo + } + } + ecOrderNames := map[string]string{} + if len(ecOrderIdentities) > 0 { + var orders []models.EcOrder + if err := common.ActiveRecords(impl.DBService).Select("identity", "order_no"). + Where("identity IN ?", ecOrderIdentities).Find(&orders).Error; err != nil { + return nil, err + } + for _, order := range orders { + ecOrderNames[order.Identity] = order.OrderNo + } + } + userNames := map[string]string{} + if len(userIdentities) > 0 { + var users []models.UserAccount + if err := common.ActiveRecords(impl.DBService).Select("identity", "name"). + Where("identity IN ?", userIdentities).Find(&users).Error; err != nil { + return nil, err + } + for _, user := range users { + userNames[user.Identity] = user.Name + } + } + reviewerNames := map[string]string{} + reviewerIdentities := make([]string, 0, len(refunds)) + for _, refund := range refunds { + if refund.ReviewerIdentity != "" { + reviewerIdentities = append(reviewerIdentities, refund.ReviewerIdentity) + } + } + if len(reviewerIdentities) > 0 { + var reviewers []models.PlatformAccount + // 历史审核人即使已经归档也需要保留可读名称,因此不套用活动记录过滤。 + if err := impl.DBService.Model(&models.PlatformAccount{}). + Select("identity", "display_name", "username"). + Where("identity IN ?", reviewerIdentities).Find(&reviewers).Error; err != nil { + return nil, err + } + for _, reviewer := range reviewers { + reviewerNames[reviewer.Identity] = readableRefundName( + strings.TrimSpace(reviewer.DisplayName), + strings.TrimSpace(reviewer.Username), + ) + } + } + productSummaries, err := loadPaymentRefundProductSummaries(refunds) + if err != nil { + return nil, err + } + displays := make([]paymentRefundDisplay, 0, len(refunds)) + for _, refund := range refunds { + displays = append(displays, paymentRefundDisplay{ + PaymentRefund: refund, + BusinessDisplayName: refundBusinessDisplayName(refund, gasorderNames, ecOrderNames), + UserDisplayName: readableRefundName(userNames[refund.UserIdentity], "用户记录已失效"), + ReviewerDisplayName: refundReviewerDisplayName(refund.ReviewerIdentity, reviewerNames), + RefundProductSummary: productSummaries[refund.ID], + }) + } + return displays, nil +} + +// refundReviewerDisplayName 区分尚未审核、历史账号失效和正常审核人三种状态。 +func refundReviewerDisplayName(identity string, names map[string]string) string { + if strings.TrimSpace(identity) == "" { + return "尚未审核" + } + return readableRefundName(names[identity], "审核人记录已失效") +} + +// loadPaymentRefundProductSummaries 批量读取退款明细及原订单项,生成退款商品名称与数量。 +func loadPaymentRefundProductSummaries(refunds []models.PaymentRefund) (map[uint64]string, error) { + refundIDs := make([]uint64, 0, len(refunds)) + refundTypes := make(map[uint64]string, len(refunds)) + for _, refund := range refunds { + refundIDs = append(refundIDs, refund.ID) + refundTypes[refund.ID] = refund.BusinessType + } + var refundItems []models.PaymentRefundItem + if len(refundIDs) > 0 { + if err := impl.DBService.Where("payment_refund_id IN ?", refundIDs). + Order("payment_refund_id asc, id asc").Find(&refundItems).Error; err != nil { + return nil, err + } + } + gasorderItemIdentities := make([]string, 0, len(refundItems)) + ecOrderItemIdentities := make([]string, 0, len(refundItems)) + for _, item := range refundItems { + if refundTypes[item.PaymentRefundID] == "gasorder" { + gasorderItemIdentities = append(gasorderItemIdentities, item.OrderItemIdentity) + } else if refundTypes[item.PaymentRefundID] == "ec_order" { + ecOrderItemIdentities = append(ecOrderItemIdentities, item.OrderItemIdentity) + } + } + gasorderItems := map[string]models.GasorderItem{} + productInfoIDs := make([]uint64, 0, len(gasorderItemIdentities)) + if len(gasorderItemIdentities) > 0 { + var items []models.GasorderItem + if err := common.ActiveRecords(impl.DBService). + Where("identity IN ?", gasorderItemIdentities).Find(&items).Error; err != nil { + return nil, err + } + for _, item := range items { + gasorderItems[item.Identity] = item + productInfoIDs = append(productInfoIDs, item.ProductInfoID) + } + } + productNames := map[uint64]string{} + if len(productInfoIDs) > 0 { + var products []models.ProductInfo + if err := common.ActiveRecords(impl.DBService).Select("id", "name"). + Where("id IN ?", productInfoIDs).Find(&products).Error; err != nil { + return nil, err + } + for _, product := range products { + productNames[product.ID] = product.Name + } + } + ecOrderItems := map[string]models.EcOrderItem{} + if len(ecOrderItemIdentities) > 0 { + var items []models.EcOrderItem + if err := common.ActiveRecords(impl.DBService). + Where("identity IN ?", ecOrderItemIdentities).Find(&items).Error; err != nil { + return nil, err + } + for _, item := range items { + ecOrderItems[item.Identity] = item + } + } + labelsByRefund := make(map[uint64][]string, len(refunds)) + for _, refundItem := range refundItems { + name := "" + switch refundTypes[refundItem.PaymentRefundID] { + case "gasorder": + item := gasorderItems[refundItem.OrderItemIdentity] + name = strings.TrimSpace(productNames[item.ProductInfoID]) + if name == "" { + name = readableRefundName(item.ProductTypeName, item.ProductCode) + } + case "ec_order": + item := ecOrderItems[refundItem.OrderItemIdentity] + var snapshot refundEcProductSnapshot + if json.Unmarshal([]byte(item.ProductSnapshot), &snapshot) == nil { + name = strings.TrimSpace(snapshot.Name) + } + } + labelsByRefund[refundItem.PaymentRefundID] = append( + labelsByRefund[refundItem.PaymentRefundID], + refundProductLabel(name, refundItem.Quantity), + ) + } + summaries := make(map[uint64]string, len(refunds)) + for _, refund := range refunds { + labels := labelsByRefund[refund.ID] + if len(labels) == 0 { + summaries[refund.ID] = "退款商品信息缺失" + continue + } + summaries[refund.ID] = strings.Join(labels, "、") + } + return summaries, nil +} + +// refundProductLabel 统一生成退款商品名称与数量文案。 +func refundProductLabel(name string, quantity int) string { + name = strings.TrimSpace(name) + if name == "" { + name = "商品信息缺失" + } + if quantity > 1 { + return name + " × " + strconv.Itoa(quantity) + } + return name +} + +// refundBusinessDisplayName 按稳定业务类型选择订单号,未知或失效关系给出明确文案。 +func refundBusinessDisplayName(refund models.PaymentRefund, gasorderNames, ecOrderNames map[string]string) string { + switch refund.BusinessType { + case "gasorder": + return readableRefundName(gasorderNames[refund.BusinessIdentity], "订单记录已失效") + case "ec_order": + return readableRefundName(ecOrderNames[refund.BusinessIdentity], "订单记录已失效") + default: + return "未知业务对象" + } +} + +// readableRefundName 统一处理退款关联名称缺失场景。 +func readableRefundName(value, fallback string) string { + if value == "" { + return fallback + } + return value +} + func review(approve bool) gin.HandlerFunc { return func(ctx *gin.Context) { var request struct { diff --git a/backend/api/internal/logic/platform/payment/refund_test.go b/backend/api/internal/logic/platform/payment/refund_test.go new file mode 100644 index 0000000..aa9cdf3 --- /dev/null +++ b/backend/api/internal/logic/platform/payment/refund_test.go @@ -0,0 +1,50 @@ +package payment + +import ( + "testing" + + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" +) + +// TestRefundReviewerDisplayName 验证审核人展示区分未审核、正常和历史失效账号。 +func TestRefundReviewerDisplayName(t *testing.T) { + if got := refundReviewerDisplayName("", nil); got != "尚未审核" { + t.Fatalf("未审核文案 = %q", got) + } + if got := refundReviewerDisplayName("reviewer-1", map[string]string{"reviewer-1": "平台根管理员"}); got != "平台根管理员" { + t.Fatalf("审核人名称 = %q", got) + } + if got := refundReviewerDisplayName("reviewer-2", map[string]string{}); got != "审核人记录已失效" { + t.Fatalf("失效审核人文案 = %q", got) + } +} + +// TestRefundBusinessDisplayName 验证退款业务对象按类型显示原订单号并处理失效关系。 +func TestRefundBusinessDisplayName(t *testing.T) { + gasRefund := models.PaymentRefund{BusinessType: "gasorder", BusinessIdentity: "gas-order"} + ecRefund := models.PaymentRefund{BusinessType: "ec_order", BusinessIdentity: "ec-order"} + gasNames := map[string]string{"gas-order": "GAS-ORDER-001"} + ecNames := map[string]string{"ec-order": "EC-ORDER-001"} + if got := refundBusinessDisplayName(gasRefund, gasNames, ecNames); got != "GAS-ORDER-001" { + t.Fatalf("气体配送订单号展示错误:%q", got) + } + if got := refundBusinessDisplayName(ecRefund, gasNames, ecNames); got != "EC-ORDER-001" { + t.Fatalf("商城订单号展示错误:%q", got) + } + if got := refundBusinessDisplayName(models.PaymentRefund{BusinessType: "gasorder", BusinessIdentity: "missing"}, gasNames, ecNames); got != "订单记录已失效" { + t.Fatalf("失效订单应显示稳定文案:%q", got) + } +} + +// TestRefundProductLabel 验证退款商品名称和数量使用稳定中文格式。 +func TestRefundProductLabel(t *testing.T) { + if got := refundProductLabel("示例液化气钢瓶", 1); got != "示例液化气钢瓶" { + t.Fatalf("单件退款商品展示错误:%q", got) + } + if got := refundProductLabel("15kg 液化气配送服务", 2); got != "15kg 液化气配送服务 × 2" { + t.Fatalf("多件退款商品展示错误:%q", got) + } + if got := refundProductLabel("", 1); got != "商品信息缺失" { + t.Fatalf("缺失退款商品应显示稳定文案:%q", got) + } +} diff --git a/backend/api/internal/seed/mock.go b/backend/api/internal/seed/mock.go index 3315fc5..add1bf2 100644 --- a/backend/api/internal/seed/mock.go +++ b/backend/api/internal/seed/mock.go @@ -45,6 +45,15 @@ func MockData(database *gorm.DB) error { return err } + // 财务退款属于平台审核业务,Mock 审核人必须引用平台账号而不是气站账号。 + if err := ensureMockRoot(tx, string(passwordHash)); err != nil { + return err + } + var rootAccount models.PlatformAccount + if err := tx.Where("username = ?", "root").First(&rootAccount).Error; err != nil { + return fmt.Errorf("find root platform account for mock refund: %w", err) + } + delivery := models.DeliveryBasic{ Entity: entity(3, common.StatusEnable), DeliveryCode: "MOCK-DELIVERY-001", GasBasicID: gas.ID, Name: "和气示例配送点", Principal: "李主管", @@ -394,11 +403,14 @@ func MockData(database *gorm.DB) error { Entity: entity(37, common.StatusEnable), RefundStatus: 20, WalletBasicID: wallet.ID, PaymentOrderID: walletPayment.ID, RefundNo: "MOCK-REFUND-001", RequestNo: "MOCK-REQ-REFUND-001", BusinessType: "gasorder", BusinessIdentity: gasOrder.Identity, UserIdentity: user.Identity, - Amount: 1000, Reason: "模拟部分退款", ReviewerIdentity: gasAccount.Identity, ReviewedAt: &refundCompletedAt, CompletedAt: &refundCompletedAt, + Amount: 1000, Reason: "模拟部分退款", ReviewerIdentity: rootAccount.Identity, ReviewedAt: &refundCompletedAt, CompletedAt: &refundCompletedAt, } if err := put(tx, &refund); err != nil { return err } + if err := repairMockRefundReviewer(tx, refund.Identity, rootAccount.Identity); err != nil { + return err + } applyCash := models.WalletApplyCash{ Entity: entity(38, common.StatusEnable), ApplyStatus: common.StatusApproved, WalletBasicID: wallet.ID, WalletBankID: bank.ID, @@ -539,9 +551,6 @@ func MockData(database *gorm.DB) error { return err } - if err := ensureMockRoot(tx, string(passwordHash)); err != nil { - return err - } var rootRole models.PlatformRole if err := tx.Where("role_code = ?", "root").First(&rootRole).Error; err != nil { return fmt.Errorf("find root role for mock menu: %w", err) @@ -1005,6 +1014,35 @@ func ensureMockRoot(database *gorm.DB, passwordHash string) error { return nil } +// repairMockRefundReviewer 幂等修复历史 Mock 退款误关联气站账号的问题。 +func repairMockRefundReviewer(database *gorm.DB, refundIdentity string, reviewerIdentity string) error { + if refundIdentity == "" || reviewerIdentity == "" { + return errors.New("mock refund reviewer identity is missing") + } + result := database.Model(&models.PaymentRefund{}). + Where("identity = ?", refundIdentity). + Update("reviewer_identity", reviewerIdentity) + if result.Error != nil { + return fmt.Errorf("repair mock refund reviewer: %w", result.Error) + } + return nil +} + +// RepairMockRefundReviewer 仅修复固定 Mock 退款,不触发其他模拟资源写入。 +func RepairMockRefundReviewer(database *gorm.DB) (int64, error) { + var rootAccount models.PlatformAccount + if err := database.Where("username = ?", "root").First(&rootAccount).Error; err != nil { + return 0, fmt.Errorf("find root platform account for mock refund: %w", err) + } + result := database.Model(&models.PaymentRefund{}). + Where("identity = ? AND refund_no = ?", entity(37, common.StatusEnable).Identity, "MOCK-REFUND-001"). + Update("reviewer_identity", rootAccount.Identity) + if result.Error != nil { + return 0, fmt.Errorf("repair mock refund reviewer: %w", result.Error) + } + return result.RowsAffected, nil +} + func entity(sequence int, status int) models.Entity { return models.Entity{ Identity: fmt.Sprintf("%s%012d", mockIdentityPrefix, sequence), diff --git a/backend/api/internal/seed/mock_test.go b/backend/api/internal/seed/mock_test.go index a5b6859..7518249 100644 --- a/backend/api/internal/seed/mock_test.go +++ b/backend/api/internal/seed/mock_test.go @@ -107,6 +107,16 @@ func TestMockEcOrderStatusesMatchWorkflow(t *testing.T) { } } +// TestRepairMockRefundReviewerRejectsMissingIdentity 验证修复逻辑不会扩大到未明确指定的记录。 +func TestRepairMockRefundReviewerRejectsMissingIdentity(t *testing.T) { + if err := repairMockRefundReviewer(nil, "", "platform-reviewer"); err == nil { + t.Fatal("缺少退款标识时必须拒绝修复") + } + if err := repairMockRefundReviewer(nil, "mock-refund", ""); err == nil { + t.Fatal("缺少审核人标识时必须拒绝修复") + } +} + // TestMockEcOrderRepairSpecs 验证修复命令只覆盖固定十条商城 Mock 订单。 func TestMockEcOrderRepairSpecs(t *testing.T) { specs := mockEcOrderRepairSpecs() diff --git a/docs/操作日志_退款审核关联名称展示修复_20260817.md b/docs/操作日志_退款审核关联名称展示修复_20260817.md new file mode 100644 index 0000000..5d784c7 --- /dev/null +++ b/docs/操作日志_退款审核关联名称展示修复_20260817.md @@ -0,0 +1,65 @@ +# 退款审核关联名称展示修复操作日志 + +操作时间:2026-08-17 + +操作类型:修改 + +影响模块:平台总后台、财务管理、退款审核 + +## 操作前状态 + +- 退款业务对象依赖前端动态加载原订单关系,加载不到时显示“名称加载失败”。 +- 申请用户直接显示截断 UUID,财务列表无法直接识别用户。 + +## 具体操作 + +1. 退款列表按当前页批量读取气体配送订单、商城订单和用户名称,不执行逐行查询。 +2. 退款详情复用同一展示构建逻辑,接口增加 `business_display_name` 和 `user_display_name` 只读展示字段。 +3. 业务对象显示原订单号,用户显示姓名;两个字段均保留 UUID 复制和详情跳转。 +4. 订单或用户已失效时显示明确业务文案,不将失效关系误报为普通网络加载失败。 +5. 增加业务类型、订单号和失效关系的回归测试及前端展示契约断言。 +6. 新增“退款商品”:商城订单使用成交快照,气体配送订单通过退款明细关联原订单项和产品档案,多件退款显示“× 数量”。 +7. 空说明和空审核说明分别显示“无说明”“无审核说明”,不再使用无语义短横线。 +8. 审核人由后端解析平台账号展示名称;历史账号归档后仍可读取,失效时显示明确文案,并保留标识复制。 +9. 支付记录显示支付单号,钱包显示归属类型摘要,完整唯一标识继续作为辅助复制信息。 +10. 修正 `MOCK-REFUND-001` 的审核人归属:由气站账号改为平台根管理员,并在重复执行 Mock 初始化时修复已有错误数据。 +11. 增加独立命令 `repair-mock-refund-reviewer`,用于绕开无关 Mock 数据冲突并安全修复当前数据库。 + +## 行为变化 + +- 修改前:业务对象显示“名称加载失败”,用户显示 UUID。 +- 修改后:业务对象显示原订单号,用户显示姓名。 +- 当前 Mock 退款的“退款商品”显示“示例液化气钢瓶”。 +- 当前 Mock 退款的“审核人”显示平台根管理员,不再显示“审核人记录已失效”。 +- 退款金额、审核状态、入账事务、权限和接口路径均未改变。 + +## 代码变更 + +- `backend/api/internal/logic/platform/payment/refund.go`:增加退款列表和详情可读关系字段。 +- `backend/api/internal/logic/platform/payment/refund_test.go`:增加订单类型、订单号和失效关系测试。 +- `backend/api/internal/seed/mock.go`:修正 Mock 退款审核人并增加历史错误数据幂等修复。 +- `backend/api/internal/seed/mock_test.go`:增加修复范围的输入保护测试。 +- `backend/api/cmd/cli/main.go`:增加 Mock 退款审核人定向修复命令。 +- `frontend/platform_admin/src/api/resources.ts`:退款关系字段改用接口展示名称。 +- `frontend/platform_admin/src/api/resource-detail-contract.ts`:固定退款详情字段顺序并隐藏审核人辅助名称字段。 +- `frontend/platform_admin/scripts/check-resource-display-contracts.mjs`:增加退款展示契约断言。 +- `docs/项目文档_平台资源中文展示统一_v1.0.md`:记录 v1.10 至 v1.13 变更。 + +## 验证结果 + +- `go test ./internal/logic/platform/payment ./internal/routers`:通过。 +- `go test ./...`:通过。 +- `go vet ./...`:通过。 +- `go build ./cmd/main/main.go`:通过。 +- `pnpm resource-display-contracts:check`:通过。 +- `pnpm contract:check`:通过,48 个资源契约一致。 +- 修改文件定向 Biome lint:通过。 +- `pnpm build`:通过,TypeScript 类型检查及 Vite 生产构建完成,共转换 2621 个模块。 +- 新增退款商品汇总后再次执行退款模块、全量后端测试、`go vet`、主程序构建及前端全套检查:全部通过。 +- `go run ./cmd/cli repair-mock-refund-reviewer`:执行成功,当前数据库修复 1 条 Mock 退款审核人记录。 +- 增加定向修复命令后再次执行后端全量测试、`go vet`、主程序与 CLI 构建:全部通过。 + +## 风险评估 + +- 可读名称仅用于已授权财务审核页面,不改变退款事实字段或跨服务标识。 +- 订单号来自不可变业务编号;用户姓名用于退款审核识别,UUID 仍作为审计定位依据保留。 diff --git a/docs/项目文档_平台资源中文展示统一_v1.0.md b/docs/项目文档_平台资源中文展示统一_v1.0.md index 9884715..1938b0f 100644 --- a/docs/项目文档_平台资源中文展示统一_v1.0.md +++ b/docs/项目文档_平台资源中文展示统一_v1.0.md @@ -72,3 +72,7 @@ frontend/platform_admin/ - v1.7.1:提现列表的审核人改为直接使用审核姓名快照,并保留审核人标识复制与详情跳转,避免历史账户关系加载失败影响列表展示。 - v1.8:财务支付记录的商城订单关系统一显示订单号,支付状态使用财务领域的 `35=已支付`,渠道按业务文档统一显示“余额支付”“支付宝”“微信支付”。 - v1.9:财务支付详情新增“支付商品”,由后端按商城订单明细的成交快照汇总商品名称和数量,确保商品改名后仍可按交易事实核对。 +- v1.10:退款审核列表和详情由接口直接返回原订单号与申请用户姓名,前端保留关系标识复制和详情跳转,不再依赖临时关系加载。 +- v1.11:退款审核新增“退款商品”,商城退款读取成交快照,气体配送退款通过原订单项读取产品名称,并汇总退款数量。 +- v1.12:退款审核补齐空值业务文案、审核人名称、支付单号和钱包归属摘要,UUID 统一降级为复制信息。 +- v1.13:修正固定 Mock 退款误把气站账号写为平台审核人的数据错误,并在重复初始化时幂等修复历史记录;退款审核人由接口稳定返回平台账号名称。 diff --git a/frontend/platform_admin/scripts/check-resource-display-contracts.mjs b/frontend/platform_admin/scripts/check-resource-display-contracts.mjs index 634b2a2..382a4d3 100644 --- a/frontend/platform_admin/scripts/check-resource-display-contracts.mjs +++ b/frontend/platform_admin/scripts/check-resource-display-contracts.mjs @@ -101,6 +101,40 @@ assertIncludes( "wallet_apply_cash: {", '提现详情必须声明专属字段顺序与审核姓名快照隐藏规则', ); + +// 退款审核的原订单和用户必须使用接口返回的可读名称,不依赖列表临时加载关系。 +const refundDefinition = resources.match( + /define\('payment_refund',[\s\S]*?\n\s*\], 'list',/, +)?.[0]; +if (!refundDefinition) throw new Error('无法读取退款审核资源定义'); +for (const contract of [ + "listDisplayKey: 'business_display_name'", + "detailDisplayKey: 'business_display_name'", + "listDisplayKey: 'user_display_name'", + "detailDisplayKey: 'user_display_name'", + "f('refund_product_summary', { emptyText: '退款商品信息缺失' })", + "f('description', { emptyText: '无说明' })", + "f('review_remark', { emptyText: '无审核说明' })", + "detailDisplayKey: 'reviewer_display_name'", + "relation('payment_order_identity', '/payment_order'", + "relation('wallet_basic_identity', '/wallet_basic'", + "relationOptionDisplay: 'wallet-owner'", + 'listDisplayIdentityCopy: true', +]) { + if (!refundDefinition.includes(contract)) { + throw new Error(`退款审核缺少可读关系展示契约:${contract}`); + } +} +assertIncludes( + detailContract, + "payment_refund: {", + '退款详情必须声明专属字段顺序和审核人展示名称隐藏规则', +); +assertIncludes( + detailContract, + "hiddenKeys: ['reviewer_display_name']", + '退款详情不得重复展示审核人名称辅助字段', +); assertIncludes( detailContract, "hiddenKeys: ['reviewer_name']", diff --git a/frontend/platform_admin/src/api/resource-detail-contract.ts b/frontend/platform_admin/src/api/resource-detail-contract.ts index 6961897..129a4a1 100644 --- a/frontend/platform_admin/src/api/resource-detail-contract.ts +++ b/frontend/platform_admin/src/api/resource-detail-contract.ts @@ -146,6 +146,16 @@ const contracts: Record = { // 审核姓名作为审核人关系的可读快照使用,不再重复渲染独立字段。 hiddenKeys: ['reviewer_name'], }, + payment_refund: { + leadingKeys: [ + 'refund_no', 'refund_status', 'business_type', 'business_identity', + 'user_identity', 'refund_product_summary', 'amount', 'reason', + 'description', 'reviewer_identity', 'review_remark', 'reviewed_at', + 'completed_at', 'payment_order_identity', 'wallet_basic_identity', 'identity', + ], + // 审核人可读名称仅作为标识字段主文案,不重复显示为独立详情项。 + hiddenKeys: ['reviewer_display_name'], + }, ec_order: { leadingKeys: [ 'order_no', 'order_status', 'request_no', 'user_account_identity', diff --git a/frontend/platform_admin/src/api/resources.ts b/frontend/platform_admin/src/api/resources.ts index cbb43d3..b80cd6b 100644 --- a/frontend/platform_admin/src/api/resources.ts +++ b/frontend/platform_admin/src/api/resources.ts @@ -178,6 +178,7 @@ const fieldLabels: Record = { previous_order_status: '异常前订单状态', payment_status: '支付状态', refund_status: '退款状态', + refund_product_summary: '退款商品', apply_status: '提现状态', ticket_status: '工单状态', reconciliation_status: '对账状态', @@ -774,11 +775,15 @@ export const resources: ResourceUiDefinition[] = [ f('operator_identity'), f('ymd'), f('ym'), f('remark'), ]), define('payment_refund', '退款审核', 'readonly', [ - f('refund_no'), f('business_type'), f('business_identity', { type: 'identity', label: '业务对象', listRelationNameOnly: true, displayRelationLabel: true, showIdentityCopy: true, dynamicRelation: { + f('refund_no'), f('business_type'), f('business_identity', { type: 'identity', label: '业务对象', listDisplayKey: 'business_display_name', detailDisplayKey: 'business_display_name', listDisplayIdentityCopy: true, displayRelationLabel: true, showIdentityCopy: true, dynamicRelation: { parentKey: 'business_type', parentLabel: '业务类型', resources: { gasorder: '/gasorder_basic', ec_order: '/ec_order' }, - } }), relation('user_identity', '/user_account'), - f('amount'), f('reason'), f('description'), f('refund_status'), - relation('reviewer_identity', '/platform_account'), f('review_remark'), f('reviewed_at'), f('completed_at'), + } }), relation('user_identity', '/user_account', false, { label: '用户', listDisplayKey: 'user_display_name', detailDisplayKey: 'user_display_name', listDisplayIdentityCopy: true, displayRelationLabel: true, showIdentityCopy: true }), + f('refund_product_summary', { emptyText: '退款商品信息缺失' }), + f('amount'), f('reason'), f('description', { emptyText: '无说明' }), f('refund_status'), + relation('reviewer_identity', '/platform_account', false, { label: '审核人', listDisplayKey: 'reviewer_display_name', detailDisplayKey: 'reviewer_display_name', listDisplayIdentityCopy: true, displayRelationLabel: true, showIdentityCopy: true }), + f('review_remark', { emptyText: '无审核说明' }), f('reviewed_at'), f('completed_at'), + relation('payment_order_identity', '/payment_order', false, { label: '支付记录', displayRelationLabel: true, showIdentityCopy: true }), + relation('wallet_basic_identity', '/wallet_basic', false, { label: '钱包', relationOptionDisplay: 'wallet-owner', displayRelationLabel: true, showIdentityCopy: true }), ], 'list', [ { name: '审核通过并退入钱包', resource: '/payment_refund/:identity/approve', fields: [f('remark')], visibleFor: { field: 'refund_status', values: [10] } }, { name: '驳回退款', resource: '/payment_refund/:identity/reject', danger: true, fields: [f('remark', { required: true })], visibleFor: { field: 'refund_status', values: [10] } },