Files
huang 2b0f79be81
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 5m45s
归档一次性佣金配置落库与累计触发修复,同步规范文档到主 specs
- 归档 fix-one-time-commission-config-and-accumulation 到 archive/2026-01-29-*
- 同步 delta specs 到主规范(one-time-commission-trigger、commission-calculation)
- 新增累计触发逻辑文档和测试用例
- 修复一次性佣金配置落库和累计充值更新逻辑
2026-01-29 16:00:18 +08:00

320 lines
9.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 一次性佣金修复 - 设计
## 目标
1. 通过 `ShopSeriesAllocation` 创建/更新接口即可配置一次性佣金并生效(你确认 B=1
2. “累计充值触发”场景每次支付成功都累加保存,达到阈值触发一次性佣金发放。
3. 发放具备幂等:同一资源(卡/设备)只发放一次。
## 1) 常量定义
所有常量必须在 `pkg/constants/` 中定义,禁止硬编码:
```go
// pkg/constants/commission.go
// 一次性佣金类型
const (
OneTimeCommissionTypeFixed = "fixed" // 固定类型
OneTimeCommissionTypeTiered = "tiered" // 梯度类型
)
// 一次性佣金触发方式
const (
OneTimeCommissionTriggerImmediate = "immediate" // 立即触发
OneTimeCommissionTriggerAccumulatedRecharge = "accumulated_recharge" // 累计充值触发
)
// 佣金模式
const (
CommissionModeFixed = "fixed" // 固定金额
CommissionModePercent = "percent" // 百分比
)
// 梯度类型
const (
TierTypeSalesCount = "sales_count" // 销售数量
TierTypeSalesAmount = "sales_amount" // 销售金额
)
```
**使用示例**
```go
// Service 层使用常量
if allocation.OneTimeCommissionType == constants.OneTimeCommissionTypeFixed {
// 固定类型处理
}
if allocation.OneTimeCommissionTrigger == constants.OneTimeCommissionTriggerAccumulatedRecharge {
// 累计充值触发逻辑
}
// 禁止硬编码(错误示例)
if allocation.OneTimeCommissionType == "fixed" { // ❌ 禁止
// ...
}
```
## 2) 配置落库
### 固定类型fixed
`tb_shop_series_allocation` 写入:
- enable_one_time_commission
- one_time_commission_type = fixed
- one_time_commission_trigger
- one_time_commission_threshold
- one_time_commission_modefixed/percent
- one_time_commission_value
### 梯度类型tiered
`tb_shop_series_allocation` 写入:
- enable_one_time_commission
- one_time_commission_type = tiered
- one_time_commission_trigger
- one_time_commission_threshold
并在 `tb_shop_series_one_time_commission_tier` 维护档位:
- allocation_id
- tier_typesales_count/sales_amount
- threshold_value
- commission_modefixed/percent
- commission_value
更新策略建议:
- 更新配置时:先删除 allocation_id 对应的旧 tiers再批量插入新 tiers实现简单且可控
### 数据库设计约束
根据项目规范AGENTS.md必须遵守以下数据库设计原则
**❌ 禁止事项**
- 禁止在 `tb_shop_series_one_time_commission_tier``tb_shop_series_allocation` 之间建立外键约束
- 禁止使用 GORM 关联关系标签foreignKey、hasMany、belongsTo
**✅ 必须遵守**
- 关联通过存储 ID 字段allocation_id手动维护
- 关联数据在代码层面显式查询
**实现示例**
```go
// Store 层:显式关联查询
func (s *ShopSeriesAllocationStore) GetByIDWithTiers(ctx context.Context, id uint) (*model.ShopSeriesAllocation, []*model.ShopSeriesOneTimeCommissionTier, error) {
// 1. 查询分配配置
var allocation model.ShopSeriesAllocation
if err := s.db.WithContext(ctx).First(&allocation, id).Error; err != nil {
return nil, nil, err
}
// 2. 如果是梯度类型,显式查询档位
var tiers []*model.ShopSeriesOneTimeCommissionTier
if allocation.OneTimeCommissionType == constants.OneTimeCommissionTypeTiered {
if err := s.db.WithContext(ctx).
Where("allocation_id = ?", id).
Order("threshold_value ASC").
Find(&tiers).Error; err != nil {
return nil, nil, err
}
}
return &allocation, tiers, nil
}
// Service 层:组装响应
func (s *ShopSeriesAllocationService) GetByID(ctx context.Context, id uint) (*dto.AllocationResponse, error) {
allocation, tiers, err := s.store.ShopSeriesAllocation.GetByIDWithTiers(ctx, id)
if err != nil {
return nil, err
}
resp := &dto.AllocationResponse{
// ... 映射字段
EnableOneTimeCommission: allocation.EnableOneTimeCommission,
OneTimeCommissionConfig: mapOneTimeCommissionConfig(allocation, tiers),
}
return resp, nil
}
```
## 3) 累计触发逻辑
针对 `accumulated_recharge`
- 每次支付成功都更新 `AccumulatedRecharge += orderAmount`
- 若累计达到阈值且未发放过:
- 计算佣金金额
- 创建佣金记录并入账
- 标记 `FirstCommissionPaid = true`
注意:累计的更新应当以“支付成功”为准,避免未支付订单污染累计值。
## 3) 错误处理规范
所有错误必须在 `pkg/errors/` 中定义,使用统一错误码系统:
### 参数校验错误40xxx
```go
// pkg/errors/codes.go 或 pkg/errors/commission.go
ErrOneTimeCommissionConfigInvalid = NewAppError(40101, "一次性佣金配置无效")
ErrOneTimeCommissionModeMissing = NewAppError(40102, "一次性佣金模式缺失")
ErrOneTimeCommissionValueMissing = NewAppError(40103, "一次性佣金金额/比例缺失")
ErrOneTimeCommissionTiersMissing = NewAppError(40104, "梯度佣金档位配置缺失")
ErrOneTimeCommissionTierInvalid = NewAppError(40105, "梯度佣金档位配置无效")
ErrOneTimeCommissionThresholdInvalid = NewAppError(40106, "一次性佣金阈值无效")
```
### 业务逻辑错误50xxx
```go
ErrAccumulatedRechargeUpdateFailed = NewAppError(50101, "累计充值金额更新失败")
ErrOneTimeCommissionAlreadyPaid = NewAppError(50102, "一次性佣金已发放")
ErrCommissionCalculationFailed = NewAppError(50103, "佣金计算失败")
```
### 使用示例
```go
// Service 层参数校验
if req.EnableOneTimeCommission && req.OneTimeCommissionConfig == nil {
return errors.ErrOneTimeCommissionConfigInvalid
}
if req.OneTimeCommissionConfig.Type == "fixed" &&
req.OneTimeCommissionConfig.Mode == "" {
return errors.ErrOneTimeCommissionModeMissing
}
if req.OneTimeCommissionConfig.Type == "tiered" &&
len(req.OneTimeCommissionConfig.Tiers) == 0 {
return errors.ErrOneTimeCommissionTiersMissing
}
// Handler 层统一处理(全局 ErrorHandler 自动处理)
if err := service.CreateAllocation(ctx, req); err != nil {
return err // 自动转换为 JSON 响应
}
```
## 4) 性能优化
### 并发控制策略
累计值更新存在并发写风险,必须使用乐观锁防止数据覆盖:
```go
// 使用 GORM 乐观锁(基于 version 字段)
type CommissionRecord struct {
// ... 其他字段
AccumulatedRecharge int64 `gorm:"column:accumulated_recharge"`
Version int `gorm:"column:version"` // 乐观锁版本号
}
// 更新时自动检查版本号
result := db.Model(&record).
Where("id = ? AND version = ?", record.ID, record.Version).
Updates(map[string]interface{}{
"accumulated_recharge": gorm.Expr("accumulated_recharge + ?", amount),
"version": gorm.Expr("version + 1"),
})
if result.RowsAffected == 0 {
// 版本冲突,需要重试
return errors.ErrAccumulatedRechargeUpdateFailed
}
```
**替代方案**(如果无 version 字段):
```go
// 使用 SQL 原子操作
result := db.Exec(`
UPDATE tb_commission_records
SET accumulated_recharge = accumulated_recharge + ?,
updated_at = NOW()
WHERE id = ?
`, amount, recordID)
```
### 索引优化
梯度配置表需要添加索引优化查询性能:
```sql
-- tb_shop_series_one_time_commission_tier 表索引
CREATE INDEX idx_allocation_id ON tb_shop_series_one_time_commission_tier(allocation_id);
-- 组合索引(如果需要按档位类型过滤)
CREATE INDEX idx_allocation_tier ON tb_shop_series_one_time_commission_tier(allocation_id, tier_type);
```
### 缓存策略(可选)
高频查询的分配配置可缓存到 Redis
```go
// Redis Key 生成函数(定义在 pkg/constants/redis.go
func RedisShopSeriesAllocationKey(allocationID uint) string {
return fmt.Sprintf("shop:series:allocation:%d", allocationID)
}
// 缓存读取
func (s *ShopSeriesAllocationService) GetByID(ctx context.Context, id uint) (*model.ShopSeriesAllocation, error) {
// 1. 尝试从 Redis 读取
key := constants.RedisShopSeriesAllocationKey(id)
cached, err := s.redis.Get(ctx, key).Result()
if err == nil {
var allocation model.ShopSeriesAllocation
if err := json.Unmarshal([]byte(cached), &allocation); err == nil {
return &allocation, nil
}
}
// 2. 从数据库读取
allocation, err := s.store.ShopSeriesAllocation.GetByID(ctx, id)
if err != nil {
return nil, err
}
// 3. 写入 RedisTTL 5分钟
data, _ := json.Marshal(allocation)
s.redis.Set(ctx, key, data, 5*time.Minute)
return allocation, nil
}
// 缓存失效(创建/更新/删除时)
func (s *ShopSeriesAllocationService) Update(ctx context.Context, req *dto.UpdateAllocationRequest) error {
// ... 更新逻辑
// 删除缓存
key := constants.RedisShopSeriesAllocationKey(req.ID)
s.redis.Del(ctx, key)
return nil
}
```
### 性能要求
根据项目规范AGENTS.md
- API P95 响应时间 < 200ms
- API P99 响应时间 < 500ms
- 数据库查询 < 50ms
- 避免 N+1 查询,使用批量操作
## 5) 测试
- 配置落库测试:创建/更新分配后,查询数据库字段与 tiers 表是否一致
- 累计触发测试:模拟多次支付累计到阈值,验证只发放一次且累计值递增
- 修复现有单测字段不匹配导致的编译失败
## 验收标准
- 创建/更新 `ShopSeriesAllocation` 时,一次性佣金配置能正确落库并在查询响应中返回。
- 累计触发场景下,多次支付能累加并在达到阈值时发放一次性佣金;之后不重复发放。
- `go test ./...` 通过。