Files
junhong_cmp_fiber/openspec/changes/add-order-payment/design.md
huang a945a4f554
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 5m37s
feat: 实现卡和设备的套餐系列绑定功能
- 添加 Device 和 IotCard 模型的 SeriesID 字段
- 实现 DeviceService 和 IotCardService 的套餐系列绑定逻辑
- 添加 DeviceStore 和 IotCardStore 的数据库操作方法
- 更新 API 接口和路由支持套餐系列绑定
- 创建数据库迁移脚本(000027_add_series_binding_fields)
- 添加完整的单元测试和集成测试
- 更新 OpenAPI 文档
- 归档 OpenSpec 变更文档

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-01-28 19:49:45 +08:00

8.2 KiB
Raw Blame History

Context

Phase 3 完成了卡/设备的套餐系列关联,现在需要实现订单和支付流程。核心是"强充"机制:用户必须通过购买套餐来充值,不能直接给钱包充值。这确保每笔资金流入都有对应的套餐购买记录。

三类买家

  1. 个人客户:通过 H5/小程序购买,使用卡/设备钱包或第三方支付
  2. 代理商:通过后台购买,使用店铺钱包
  3. 企业客户:后台直接分配套餐,不走订单流程(本期不做)

Goals / Non-Goals

Goals:

  • 设计订单和订单明细模型
  • 实现套餐购买订单创建流程
  • 实现钱包支付和第三方支付回调
  • 验证购买权限(卡/设备的套餐系列关联)
  • 套餐生效后更新流量额度

Non-Goals:

  • 不实现企业客户的套餐分配(后台直接操作)
  • 不实现第三方支付发起(仅处理回调)
  • 不实现佣金计算Phase 5
  • 不实现退款流程

Decisions

1. 订单模型设计

决策Order + OrderItem 两级结构

// Order 订单模型
type Order struct {
    gorm.Model
    BaseModel
    OrderNo         string     // 订单号(唯一)
    OrderType       string     // 订单类型: single_card-单卡购买 device-设备购买
    BuyerType       string     // 买家类型: personal-个人客户 agent-代理商
    BuyerID         uint       // 买家ID个人客户ID或店铺ID
    IotCardID       uint       // IoT卡ID单卡购买时
    DeviceID        uint       // 设备ID设备购买时
    TotalAmount     int64      // 订单总金额(分)
    PaymentMethod   string     // 支付方式: wallet-钱包 wechat-微信 alipay-支付宝
    PaymentStatus   int        // 支付状态: 1-待支付 2-已支付 3-已取消 4-已退款
    PaidAt          *time.Time // 支付时间
    CommissionStatus int       // 佣金状态: 1-待计算 2-已计算
}

// OrderItem 订单明细模型
type OrderItem struct {
    gorm.Model
    BaseModel
    OrderID         uint   // 订单ID
    PackageID       uint   // 套餐ID
    PackageName     string // 套餐名称(快照)
    Quantity        int    // 数量通常为1
    UnitPrice       int64  // 单价(分)
    Amount          int64  // 小计(分)
}

理由

  • 支持一个订单购买多个套餐(虽然初期可能只买一个)
  • 快照套餐名称,避免套餐修改影响历史订单
  • 佣金状态用于 Phase 5 的异步佣金计算

2. 订单号生成规则

决策:时间戳 + 随机数

格式ORD{YYYYMMDDHHMMSS}{6位随机数}
示例ORD20260127143052123456

理由

  • 可读性好,包含时间信息
  • 随机数避免并发冲突
  • 长度固定,便于存储和展示

3. 购买价格确定

决策:使用 Package.suggested_retail_price 作为统一售价

个人客户购买:支付金额 = Package.suggested_retail_price
代理为店铺购买:支付金额 = 代理的成本价(用于囤货/测试)

理由

  • 简化首期实现,所有终端用户统一售价
  • 代理的利润来自返佣(基础返佣 + 一次性佣金)
  • 后续如需支持代理自定义售价,可扩展 ShopPackageAllocation 增加 retail_price 字段

非首期功能

  • 代理自定义售价
  • 促销折扣价

3.1 佣金配置版本快照

决策:订单创建时快照当时的佣金配置版本

新增字段

type Order struct {
    // ... 现有字段
    
    // 🆕 佣金配置版本快照
    CommissionConfigVersion int `gorm:"column:commission_config_version;comment:佣金配置版本"`
}

理由

  • 佣金配置可能随时调整(基础返佣、一次性佣金等)
  • 订单创建时锁定配置版本,确保历史订单的佣金计算依据可追溯
  • 使用 ShopSeriesAllocationConfig 表查询特定版本的配置

查询示例

// 订单创建时
config := allocationConfigStore.GetEffective(allocationID, time.Now())
order.CommissionConfigVersion = config.Version

// 佣金计算时
config := allocationConfigStore.GetByVersion(allocationID, order.CommissionConfigVersion)
// 使用 config 中的返佣配置计算佣金

4. 购买权限验证

决策:多层验证

func ValidatePurchase(card/device, packageID) error {
    // 1. 获取卡/设备的 series_allocation_id
    allocationID := card.SeriesAllocationID
    if allocationID == 0 {
        return "该卡未关联套餐系列"
    }
    
    // 2. 获取套餐信息
    pkg := GetPackage(packageID)
    
    // 3. 验证套餐属于该系列
    allocation := GetAllocation(allocationID)
    if pkg.SeriesID != allocation.SeriesID {
        return "该套餐不在可购买范围内"
    }
    
    // 4. 验证套餐状态
    if pkg.Status != 1 || pkg.ShelfStatus != 1 {
        return "该套餐已下架"
    }
    
    return nil
}

5. 支付流程

决策:同步钱包支付 + 异步第三方支付

钱包支付流程:
1. 创建订单(待支付)
2. 检查钱包余额
3. 扣减钱包余额(事务)
4. 更新订单状态(已支付)
5. 套餐生效
6. 触发佣金计算(异步)

第三方支付流程:
1. 创建订单(待支付)
2. 返回订单信息,前端发起支付
3. 支付回调更新订单状态
4. 套餐生效
5. 触发佣金计算(异步)

6. 套餐生效逻辑

决策:创建 PackageUsage 记录 + 更新销售统计

func ActivatePackage(order *Order) {
    for _, item := range order.Items {
        pkg := GetPackage(item.PackageID)
        
        // 1. 创建套餐使用记录
        usage := &PackageUsage{
            OrderID:     order.ID,
            PackageID:   item.PackageID,
            UsageType:   order.OrderType, // single_card 或 device
            IotCardID:   order.IotCardID,
            DeviceID:    order.DeviceID,
            DataLimitMB: pkg.DataAmountMB,
            ActivatedAt: time.Now(),
            ExpiresAt:   time.Now().AddDate(0, pkg.DurationMonths, 0),
            Status:      1, // 生效中
        }
        CreatePackageUsage(usage)
        
        // 2. 🆕 更新销售统计(用于一次性佣金的梯度判断)
        allocationID := GetAllocationIDByPackage(order, item.PackageID)
        if allocationID > 0 {
            commissionStatsService.UpdateStats(ctx, allocationID, "all_time", 1, item.Amount)
        }
    }
}

关键说明

  • 套餐生效后,更新 ShopSeriesCommissionStats 表
  • 统计维度allocationID该系列分配的累计销量和销售额
  • 统计类型:"all_time" 表示永久累计(不按周期重置)
  • 一次性佣金的梯度判断依赖此统计数据

7. API 设计

# 订单管理(后台)
POST   /api/admin/orders           代理创建订单
GET    /api/admin/orders           订单列表
GET    /api/admin/orders/:id       订单详情
POST   /api/admin/orders/:id/cancel 取消订单

# 订单操作H5/个人客户)
POST   /api/h5/orders              个人客户创建订单
GET    /api/h5/orders              我的订单列表
GET    /api/h5/orders/:id          订单详情
POST   /api/h5/orders/:id/pay      钱包支付

# 支付回调
POST   /api/callback/wechat-pay    微信支付回调
POST   /api/callback/alipay        支付宝回调

Risks / Trade-offs

风险 1并发支付

风险:同一订单被重复支付

缓解

  • 支付前检查订单状态
  • 使用数据库乐观锁或 Redis 分布式锁
  • 支付回调幂等处理

风险 2套餐生效失败

风险:支付成功但套餐生效失败

缓解

  • 使用事务保证支付和套餐生效原子性
  • 失败时自动退款或人工处理
  • 记录详细日志便于排查

风险 3价格不一致

风险:下单时和支付时套餐价格变化

缓解

  • 订单中存储下单时的价格快照
  • 支付时使用订单金额,不重新查询套餐价格

Open Questions

  1. 订单超时取消?

    • 当前设计:不自动取消
    • 待确认:是否需要定时任务取消超时未支付订单?
  2. 部分支付?

    • 当前设计:不支持
    • 待确认:是否需要支持钱包余额不足时组合支付?
  3. 代理为终端用户购买?

    • 当前设计:代理只能为自己店铺购买
    • 待确认:是否需要代理帮终端用户购买的场景?