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

283 lines
8.2 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.
## Context
Phase 3 完成了卡/设备的套餐系列关联,现在需要实现订单和支付流程。核心是"强充"机制:用户必须通过购买套餐来充值,不能直接给钱包充值。这确保每笔资金流入都有对应的套餐购买记录。
**三类买家**
1. 个人客户:通过 H5/小程序购买,使用卡/设备钱包或第三方支付
2. 代理商:通过后台购买,使用店铺钱包
3. 企业客户:后台直接分配套餐,不走订单流程(本期不做)
## Goals / Non-Goals
**Goals:**
- 设计订单和订单明细模型
- 实现套餐购买订单创建流程
- 实现钱包支付和第三方支付回调
- 验证购买权限(卡/设备的套餐系列关联)
- 套餐生效后更新流量额度
**Non-Goals:**
- 不实现企业客户的套餐分配(后台直接操作)
- 不实现第三方支付发起(仅处理回调)
- 不实现佣金计算Phase 5
- 不实现退款流程
## Decisions
### 1. 订单模型设计
**决策**Order + OrderItem 两级结构
```go
// 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 佣金配置版本快照
**决策**:订单创建时快照当时的佣金配置版本
**新增字段**
```go
type Order struct {
// ... 现有字段
// 🆕 佣金配置版本快照
CommissionConfigVersion int `gorm:"column:commission_config_version;comment:佣金配置版本"`
}
```
**理由**
- 佣金配置可能随时调整(基础返佣、一次性佣金等)
- 订单创建时锁定配置版本,确保历史订单的佣金计算依据可追溯
- 使用 ShopSeriesAllocationConfig 表查询特定版本的配置
**查询示例**
```go
// 订单创建时
config := allocationConfigStore.GetEffective(allocationID, time.Now())
order.CommissionConfigVersion = config.Version
// 佣金计算时
config := allocationConfigStore.GetByVersion(allocationID, order.CommissionConfigVersion)
// 使用 config 中的返佣配置计算佣金
```
---
### 4. 购买权限验证
**决策**:多层验证
```go
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 记录 + 更新销售统计
```go
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. **代理为终端用户购买?**
- 当前设计:代理只能为自己店铺购买
- 待确认:是否需要代理帮终端用户购买的场景?