All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 5m37s
- 添加 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>
283 lines
8.2 KiB
Markdown
283 lines
8.2 KiB
Markdown
## 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. **代理为终端用户购买?**
|
||
- 当前设计:代理只能为自己店铺购买
|
||
- 待确认:是否需要代理帮终端用户购买的场景?
|