## 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`(值=1),但 `getCommissionStatusName()` 使用 `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)。 **变更内容**: ```go // 删除 model/commission.go 第 40-46 行 const ( // CommissionStatusReleased = 1 // 删除 // CommissionStatusInvalid = 2 // 删除 ) // 使用 constants 包的定义 Status: constants.CommissionStatusReleased // 值=3 ``` ### Decision 2: 数据库状态值迁移 **方案**:通过 SQL 直接迁移历史数据 ```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,并引入专用结果结构体承接扫描结果 ```go // 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_record`,GORM 不自动生成别名 - 使用嵌入 `model.CommissionRecord` 的专用结构体,避免污染原始模型 - `SellerShopID` 直接从订单表 JOIN 取得,`SellerShopName` 在 Service 层批量查询(见 Decision 5) **替代方案考虑**: - 方案 A(当前选择):Store 层 JOIN - 减少数据库往返次数 - 方案 B:Service 层批量查询 - 更灵活但 N+1 查询 ### Decision 4: DTO 新增销售来源字段 ```go // 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 中包含) - `SellerShopName`:Store 层不再多加一次 JOIN(避免进一步增加 JOIN 复杂度),由 Service 层收集所有 `SellerShopID` 后批量查询 `tb_shop`,填充到 DTO ```go // service 层伪代码 sellerShopIDs := collectUniqueSellerShopIDs(records) shops, _ := s.shopStore.GetByIDs(ctx, sellerShopIDs) shopNameMap := buildShopNameMap(shops) for _, item := range items { item.SellerShopName = shopNameMap[item.SellerShopID] } ``` ### Decision 5: 佣金统计查询的状态过滤语义 **问题**:`GetStats` 和 `GetDailyStats` 目前用 `status = model.CommissionStatusReleased`(值=1)过滤,语义是"只统计已发放的佣金"。但总佣金应包含冻结中的佣金(status=1,2,3 均为有效佣金,仅 status=4 失效、status=99 待人工处理应排除)。 **决策**:将两处过滤条件从"精确匹配已发放"改为"排除无效和待审": ```go // 修改前 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_id`、`iot_card_id`、`device_id` 有索引 2. 添加 LIMIT 和分页 3. 监控查询性能(P95 < 200ms) **[风险] 常量变更可能影响其他模块** → **缓解措施**: 1. 全局搜索 `model.CommissionStatus` 确保无遗漏 2. 编写单元测试验证状态值 ## Open Questions 1. ~~**一次性佣金是否需要冻结逻辑?**~~ **已确认**:IoT 卡差价佣金和一次性佣金均直接入账,不实现冻结/解冻。冻结机制为号卡业务预留,IoT 卡侧本期 Non-Goal(见 `add-one-time-commission/design.md`)。 2. **是否需要回滚旧数据的 status 值?** 还是直接迁移?→ 直接迁移(1→3, 2→4)。 3. **销售店铺名称是否需要缓存?** 避免每次查询都 JOIN shop 表。