All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 5m45s
- 归档 fix-one-time-commission-config-and-accumulation 到 archive/2026-01-29-* - 同步 delta specs 到主规范(one-time-commission-trigger、commission-calculation) - 新增累计触发逻辑文档和测试用例 - 修复一次性佣金配置落库和累计充值更新逻辑
320 lines
9.7 KiB
Markdown
320 lines
9.7 KiB
Markdown
# 一次性佣金修复 - 设计
|
||
|
||
## 目标
|
||
|
||
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_mode(fixed/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_type(sales_count/sales_amount)
|
||
- threshold_value
|
||
- commission_mode(fixed/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. 写入 Redis(TTL 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 ./...` 通过。
|
||
|