Files
junhong_cmp_fiber/openspec/changes/archive/2025-04-11-fix-commission-status-constants/design.md
huang 677d6239ce
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m33s
fix: 统一佣金状态常量并修复佣金明细关联查询
- 删除 model/commission.go 中与 constants 包冲突的旧常量(值=1/2)
- 所有服务层改用 constants.CommissionStatusReleased(值=3)写入和查询
- 数据库迁移:status 1→3(已发放),2→4(已失效)
- 修复佣金明细列表接口,通过 JOIN 关联返回 order_no、iccid、virtual_no、order_created_at
- 新增 seller_shop_id / seller_shop_name 销售来源字段
- 统计接口过滤条件从精确匹配改为排除无效(NOT IN 4,99)
- 更新 OpenAPI 文档及 commission-record-query spec
2026-04-11 10:42:37 +08:00

7.8 KiB
Raw Blame History

Context

当前状态

佣金系统存在两套冲突的状态常量定义:

常量位置 状态 实际含义
model/commission.go CommissionStatusReleased 1 已入账
model/commission.go CommissionStatusInvalid 2 已失效
pkg/constants/iot.go CommissionStatusFrozen 1 已冻结
pkg/constants/iot.go CommissionStatusUnfreezing 2 解冻中
pkg/constants/iot.go CommissionStatusReleased 3 已发放
pkg/constants/iot.go CommissionStatusInvalid 4 已失效

问题

  1. 佣金计算时写入 model.CommissionStatusReleased(值=1getCommissionStatusName() 使用 pkg/constants/ 的映射,导致 status=1 显示为"已冻结"
  2. commission-records 接口 OrderNo、ICCID、VirtualNo 等字段硬编码为空,未关联查询
  3. 缺少销售来源店铺信息

受影响代码位置

文件 问题
model/commission.go:40-46 定义了与 constants 包冲突的常量
commission_calculation/service.go:151,216,659 使用 model 常量
shop_commission/service.go:421 使用 constants 包做映射(值冲突)
shop_commission/service.go:423-426 硬编码空值
commission_record_store.go:60-110 过滤条件未实现

Goals / Non-Goals

Goals:

  1. 统一佣金状态常量定义,消除二义性
  2. 修复 commission-records 接口的关联查询
  3. 新增销售来源店铺信息

Non-Goals:

  • 不修改订单佣金状态(order.commission_status)的定义
  • 不修改钱包冻结/解冻的业务逻辑
  • 不修改佣金计算引擎的逻辑

Decisions

Decision 1: 统一使用 pkg/constants/iot.go 的四态定义

选择理由

  • pkg/constants/iot.go 已有完整的四态定义(冻结→解冻中→已发放→失效)
  • 该常量包被多处使用,包括 getCommissionStatusName() 函数

关于冻结/解冻机制的澄清

IoT 卡佣金系统不实现冻结/解冻机制(见 add-one-time-commission/design.md Non-Goals。四态常量中 status=1已冻结和 status=2解冻中是为号卡业务预留的IoT 卡差价佣金和一次性佣金均直接入账status=3。因此本次修复的核心是消除 model 包旧常量(值=1 表示"已入账")与 constants 包新常量(值=1 表示"已冻结")之间的语义冲突,将所有写入和查询统一到 constants.CommissionStatusReleased(值=3

变更内容

// 删除 model/commission.go 第 40-46 行
const (
    // CommissionStatusReleased = 1  // 删除
    // CommissionStatusInvalid  = 2  // 删除
)

// 使用 constants 包的定义
Status: constants.CommissionStatusReleased  // 值=3

Decision 2: 数据库状态值迁移

方案:通过 SQL 直接迁移历史数据

-- 迁移脚本
UPDATE tb_commission_record SET status = 3 WHERE status = 1;  -- 已入账(旧值=1→ 已发放(新值=3
UPDATE tb_commission_record SET status = 4 WHERE status = 2;  -- 已失效(旧值=2→ 已失效(新值=4

注意:迁移后需要验证数据一致性。

Decision 3: Store 层实现 JOIN 关联查询

方案:修改 ListByShopID 方法,添加 LEFT JOIN并引入专用结果结构体承接扫描结果

// commission_record_store.go

// CommissionRecordWithRelations 包含关联字段的查询结果
type CommissionRecordWithRelations struct {
    model.CommissionRecord
    OrderNo        string     `gorm:"column:order_no"`
    OrderCreatedAt *time.Time `gorm:"column:order_created_at"`
    ICCID          string     `gorm:"column:iccid"`
    VirtualNo      string     `gorm:"column:virtual_no"`
    SellerShopID   *uint      `gorm:"column:seller_shop_id"`
}

func (s *CommissionRecordStore) ListByShopID(...) ([]*CommissionRecordWithRelations, int64, error) {
    query := s.db.WithContext(ctx).Model(&model.CommissionRecord{}).
        Joins("LEFT JOIN tb_order o ON tb_commission_record.order_id = o.id").
        Joins("LEFT JOIN tb_iot_card ic ON tb_commission_record.iot_card_id = ic.id").
        Joins("LEFT JOIN tb_device d ON tb_commission_record.device_id = d.id")
    
    // 投影必要字段
    query = query.Select(`tb_commission_record.*, 
        o.order_no, o.created_at as order_created_at, o.seller_shop_id,
        ic.iccid, d.virtual_no`)
    
    // 过滤条件...
    var records []*CommissionRecordWithRelations
    // ...
    query.Find(&records)
}

说明

  • JOIN 条件必须使用实际表名 tb_commission_recordGORM 不自动生成别名
  • 使用嵌入 model.CommissionRecord 的专用结构体,避免污染原始模型
  • SellerShopID 直接从订单表 JOIN 取得,SellerShopName 在 Service 层批量查询(见 Decision 5

替代方案考虑

  • 方案 A当前选择Store 层 JOIN - 减少数据库往返次数
  • 方案 BService 层批量查询 - 更灵活但 N+1 查询

Decision 4: DTO 新增销售来源字段

// shop_commission_dto.go
type ShopCommissionRecordItem struct {
    // ... 现有字段
    SellerShopID   uint   `json:"seller_shop_id"`   // 新增
    SellerShopName string `json:"seller_shop_name"` // 新增
}

查询逻辑

  • SellerShopID:通过 o.seller_shop_id 从订单 JOIN 直接获取(已在 Decision 3 的 SELECT 中包含)
  • SellerShopNameStore 层不再多加一次 JOIN避免进一步增加 JOIN 复杂度),由 Service 层收集所有 SellerShopID 后批量查询 tb_shop,填充到 DTO
// service 层伪代码
sellerShopIDs := collectUniqueSellerShopIDs(records)
shops, _ := s.shopStore.GetByIDs(ctx, sellerShopIDs)
shopNameMap := buildShopNameMap(shops)
for _, item := range items {
    item.SellerShopName = shopNameMap[item.SellerShopID]
}

Decision 5: 佣金统计查询的状态过滤语义

问题GetStatsGetDailyStats 目前用 status = model.CommissionStatusReleased(值=1过滤语义是"只统计已发放的佣金"。但总佣金应包含冻结中的佣金status=1,2,3 均为有效佣金,仅 status=4 失效、status=99 待人工处理应排除)。

决策:将两处过滤条件从"精确匹配已发放"改为"排除无效和待审"

// 修改前
Where("status = ?", model.CommissionStatusReleased)  // 值=1旧语义已入账

// 修改后
Where("status NOT IN (?)", []int{constants.CommissionStatusInvalid, constants.CommissionStatusPendingReview})
// 即 status NOT IN (4, 99),包含已冻结(1)、解冻中(2)、已发放(3)

影响文件internal/store/postgres/commission_record_store.go 第 123 行(GetStats)和第 169 行(GetDailyStats)。

IoT 卡当前实现中不存在 status=1/2 的记录,此改动为面向未来的正确语义,不影响现有数据结果。

Risks / Trade-offs

[风险] 数据库迁移可能影响历史数据

缓解措施

  1. 迁移前先备份数据
  2. 在测试环境验证迁移脚本
  3. 迁移后对比记录数确认

[风险] JOIN 查询可能影响性能

缓解措施

  1. 确保 order_idiot_card_iddevice_id 有索引
  2. 添加 LIMIT 和分页
  3. 监控查询性能P95 < 200ms

[风险] 常量变更可能影响其他模块

缓解措施

  1. 全局搜索 model.CommissionStatus 确保无遗漏
  2. 编写单元测试验证状态值

Open Questions

  1. 一次性佣金是否需要冻结逻辑? 已确认IoT 卡差价佣金和一次性佣金均直接入账,不实现冻结/解冻。冻结机制为号卡业务预留IoT 卡侧本期 Non-Goaladd-one-time-commission/design.md)。

  2. 是否需要回滚旧数据的 status 值? 还是直接迁移?→ 直接迁移1→3, 2→4

  3. 销售店铺名称是否需要缓存? 避免每次查询都 JOIN shop 表。