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

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

@@ -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 {

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
}
// 财务退款属于平台审核业务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),

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 订单。
func TestMockEcOrderRepairSpecs(t *testing.T) {
specs := mockEcOrderRepairSpecs()