修复退款审核详情关联与空值展示

This commit is contained in:
czl231
2026-08-17 00:44:49 +08:00
parent 64248973ee
commit 74efb323f0
10 changed files with 543 additions and 11 deletions

View File

@@ -78,6 +78,13 @@ func main() {
os.Exit(1) os.Exit(1)
} }
fmt.Printf("mock track points repaired: %d\n", count) 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": case "migrate":
if err := migrateDatabase(); err != nil { if err := migrateDatabase(); err != nil {
fmt.Fprintln(os.Stderr, err) fmt.Fprintln(os.Stderr, err)
@@ -92,7 +99,7 @@ func main() {
} }
func printUsage() { func printUsage() {
fmt.Fprintln(os.Stderr, "usage: platform-cli <version|resource-contract|gas-resource-contract|delivery-resource-contract|migrate|mock-data|repair-mock-gasorder-status|repair-mock-ec-order|repair-mock-track-point>") fmt.Fprintln(os.Stderr, "usage: platform-cli <version|resource-contract|gas-resource-contract|delivery-resource-contract|migrate|mock-data|repair-mock-gasorder-status|repair-mock-ec-order|repair-mock-track-point|repair-mock-refund-reviewer>")
} }
type route struct { type route struct {
@@ -265,6 +272,27 @@ func repairMockTrackPoint() (int64, error) {
return count, nil 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 { func migrateDatabase() error {
config.New(serviceKey) config.New(serviceKey)
options := &types.SqlOptions{ options := &types.SqlOptions{

View File

@@ -1,16 +1,304 @@
package payment package payment
import ( import (
"encoding/json"
"strconv"
"strings"
"git.apinb.com/bsm-sdk/core/errcode" "git.apinb.com/bsm-sdk/core/errcode"
"git.apinb.com/bsm-sdk/core/infra" "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" "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common"
paylogic "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/payment" paylogic "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/payment"
"git.apinb.com/heqiapp/platforms/backend/api/internal/models" "git.apinb.com/heqiapp/platforms/backend/api/internal/models"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
func ListRefund(ctx *gin.Context) { common.ListResource(ctx, &models.PaymentRefund{}) } // paymentRefundDisplay 在退款事实之外附加财务审核所需的可读关系名称。
func GetRefund(ctx *gin.Context) { common.GetResource(ctx, &models.PaymentRefund{}) } 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 { func review(approve bool) gin.HandlerFunc {
return func(ctx *gin.Context) { return func(ctx *gin.Context) {
var request struct { var request struct {

View File

@@ -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)
}
}

View File

@@ -45,6 +45,15 @@ func MockData(database *gorm.DB) error {
return err 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{ delivery := models.DeliveryBasic{
Entity: entity(3, common.StatusEnable), DeliveryCode: "MOCK-DELIVERY-001", Entity: entity(3, common.StatusEnable), DeliveryCode: "MOCK-DELIVERY-001",
GasBasicID: gas.ID, Name: "和气示例配送点", Principal: "李主管", 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, Entity: entity(37, common.StatusEnable), RefundStatus: 20, WalletBasicID: wallet.ID,
PaymentOrderID: walletPayment.ID, RefundNo: "MOCK-REFUND-001", RequestNo: "MOCK-REQ-REFUND-001", PaymentOrderID: walletPayment.ID, RefundNo: "MOCK-REFUND-001", RequestNo: "MOCK-REQ-REFUND-001",
BusinessType: "gasorder", BusinessIdentity: gasOrder.Identity, UserIdentity: user.Identity, 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 { if err := put(tx, &refund); err != nil {
return err return err
} }
if err := repairMockRefundReviewer(tx, refund.Identity, rootAccount.Identity); err != nil {
return err
}
applyCash := models.WalletApplyCash{ applyCash := models.WalletApplyCash{
Entity: entity(38, common.StatusEnable), ApplyStatus: common.StatusApproved, WalletBasicID: wallet.ID, WalletBankID: bank.ID, 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 return err
} }
if err := ensureMockRoot(tx, string(passwordHash)); err != nil {
return err
}
var rootRole models.PlatformRole var rootRole models.PlatformRole
if err := tx.Where("role_code = ?", "root").First(&rootRole).Error; err != nil { if err := tx.Where("role_code = ?", "root").First(&rootRole).Error; err != nil {
return fmt.Errorf("find root role for mock menu: %w", err) 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 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 { func entity(sequence int, status int) models.Entity {
return models.Entity{ return models.Entity{
Identity: fmt.Sprintf("%s%012d", mockIdentityPrefix, sequence), Identity: fmt.Sprintf("%s%012d", mockIdentityPrefix, sequence),

View File

@@ -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 订单。 // TestMockEcOrderRepairSpecs 验证修复命令只覆盖固定十条商城 Mock 订单。
func TestMockEcOrderRepairSpecs(t *testing.T) { func TestMockEcOrderRepairSpecs(t *testing.T) {
specs := mockEcOrderRepairSpecs() specs := mockEcOrderRepairSpecs()

View File

@@ -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 仍作为审计定位依据保留。

View File

@@ -72,3 +72,7 @@ frontend/platform_admin/
- v1.7.1:提现列表的审核人改为直接使用审核姓名快照,并保留审核人标识复制与详情跳转,避免历史账户关系加载失败影响列表展示。 - v1.7.1:提现列表的审核人改为直接使用审核姓名快照,并保留审核人标识复制与详情跳转,避免历史账户关系加载失败影响列表展示。
- v1.8:财务支付记录的商城订单关系统一显示订单号,支付状态使用财务领域的 `35=已支付`,渠道按业务文档统一显示“余额支付”“支付宝”“微信支付”。 - v1.8:财务支付记录的商城订单关系统一显示订单号,支付状态使用财务领域的 `35=已支付`,渠道按业务文档统一显示“余额支付”“支付宝”“微信支付”。
- v1.9:财务支付详情新增“支付商品”,由后端按商城订单明细的成交快照汇总商品名称和数量,确保商品改名后仍可按交易事实核对。 - v1.9:财务支付详情新增“支付商品”,由后端按商城订单明细的成交快照汇总商品名称和数量,确保商品改名后仍可按交易事实核对。
- v1.10:退款审核列表和详情由接口直接返回原订单号与申请用户姓名,前端保留关系标识复制和详情跳转,不再依赖临时关系加载。
- v1.11:退款审核新增“退款商品”,商城退款读取成交快照,气体配送退款通过原订单项读取产品名称,并汇总退款数量。
- v1.12退款审核补齐空值业务文案、审核人名称、支付单号和钱包归属摘要UUID 统一降级为复制信息。
- v1.13:修正固定 Mock 退款误把气站账号写为平台审核人的数据错误,并在重复初始化时幂等修复历史记录;退款审核人由接口稳定返回平台账号名称。

View File

@@ -101,6 +101,40 @@ assertIncludes(
"wallet_apply_cash: {", "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( assertIncludes(
detailContract, detailContract,
"hiddenKeys: ['reviewer_name']", "hiddenKeys: ['reviewer_name']",

View File

@@ -146,6 +146,16 @@ const contracts: Record<string, ResourceDetailContract> = {
// 审核姓名作为审核人关系的可读快照使用,不再重复渲染独立字段。 // 审核姓名作为审核人关系的可读快照使用,不再重复渲染独立字段。
hiddenKeys: ['reviewer_name'], 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: { ec_order: {
leadingKeys: [ leadingKeys: [
'order_no', 'order_status', 'request_no', 'user_account_identity', 'order_no', 'order_status', 'request_no', 'user_account_identity',

View File

@@ -178,6 +178,7 @@ const fieldLabels: Record<string, string> = {
previous_order_status: '异常前订单状态', previous_order_status: '异常前订单状态',
payment_status: '支付状态', payment_status: '支付状态',
refund_status: '退款状态', refund_status: '退款状态',
refund_product_summary: '退款商品',
apply_status: '提现状态', apply_status: '提现状态',
ticket_status: '工单状态', ticket_status: '工单状态',
reconciliation_status: '对账状态', reconciliation_status: '对账状态',
@@ -774,11 +775,15 @@ export const resources: ResourceUiDefinition[] = [
f('operator_identity'), f('ymd'), f('ym'), f('remark'), f('operator_identity'), f('ymd'), f('ym'), f('remark'),
]), ]),
define('payment_refund', '退款审核', 'readonly', [ 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' }, parentKey: 'business_type', parentLabel: '业务类型', resources: { gasorder: '/gasorder_basic', ec_order: '/ec_order' },
} }), relation('user_identity', '/user_account'), } }), relation('user_identity', '/user_account', false, { label: '用户', listDisplayKey: 'user_display_name', detailDisplayKey: 'user_display_name', listDisplayIdentityCopy: true, displayRelationLabel: true, showIdentityCopy: true }),
f('amount'), f('reason'), f('description'), f('refund_status'), f('refund_product_summary', { emptyText: '退款商品信息缺失' }),
relation('reviewer_identity', '/platform_account'), f('review_remark'), f('reviewed_at'), f('completed_at'), 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', [ ], 'list', [
{ name: '审核通过并退入钱包', resource: '/payment_refund/:identity/approve', fields: [f('remark')], visibleFor: { field: 'refund_status', values: [10] } }, { 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] } }, { name: '驳回退款', resource: '/payment_refund/:identity/reject', danger: true, fields: [f('remark', { required: true })], visibleFor: { field: 'refund_status', values: [10] } },