feat: 套餐系统升级 - Worker 重构、流量重置、文档与规范更新
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m54s
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m54s
- 重构 Worker 启动流程,引入 bootstrap 模块统一管理依赖注入 - 实现套餐流量重置服务(日/月/年周期重置) - 新增套餐激活排队、加油包绑定、囤货待实名激活逻辑 - 新增订单创建幂等性防重(Redis 业务键 + 分布式锁) - 更新 AGENTS.md/CLAUDE.md:新增注释规范、幂等性规范,移除测试要求 - 添加套餐系统升级完整文档(API文档、使用指南、功能总结、运维指南) - 归档 OpenSpec package-system-upgrade 变更,同步 specs 到主目录 - 新增 queue types 抽象和 Redis 常量定义
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -76,3 +76,4 @@ docs/admin-openapi.yaml
|
|||||||
/api
|
/api
|
||||||
/gendocs
|
/gendocs
|
||||||
.env.local
|
.env.local
|
||||||
|
/worker
|
||||||
429
AGENTS.md
429
AGENTS.md
@@ -102,6 +102,150 @@ Handler → Service → Store → Model
|
|||||||
- 禁止硬编码字符串和 magic numbers
|
- 禁止硬编码字符串和 magic numbers
|
||||||
- **必须为所有常量添加中文注释**
|
- **必须为所有常量添加中文注释**
|
||||||
|
|
||||||
|
### 注释规范
|
||||||
|
|
||||||
|
#### 基本原则
|
||||||
|
|
||||||
|
- **所有注释使用中文**(与语言要求一致)
|
||||||
|
- **导出符号必须有文档注释**(包、函数、方法、类型、接口、常量、变量)
|
||||||
|
- **复杂逻辑必须有实现注释**(解释"为什么",而不是"做了什么")
|
||||||
|
- **禁止废话注释**(不要用注释复述代码本身)
|
||||||
|
- **修改代码时必须同步更新注释**(过时的注释比没有注释更有害)
|
||||||
|
|
||||||
|
#### 包注释
|
||||||
|
|
||||||
|
每个包的入口文件(通常是主文件或 `doc.go`)必须有包注释:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Package account 提供账号管理的业务逻辑服务
|
||||||
|
// 包含账号创建、修改、删除、权限分配等功能
|
||||||
|
package account
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 结构体注释
|
||||||
|
|
||||||
|
所有导出结构体必须有文档注释,说明该结构体代表什么:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Service 账号业务服务
|
||||||
|
// 负责账号的 CRUD、角色分配、密码管理等业务逻辑
|
||||||
|
type Service struct {
|
||||||
|
store *Store
|
||||||
|
auditService AuditServiceInterface
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 接口注释
|
||||||
|
|
||||||
|
导出接口必须注释接口用途,每个方法必须说明契约:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// PermissionChecker 权限检查器接口
|
||||||
|
// 用于查询用户的权限列表
|
||||||
|
type PermissionChecker interface {
|
||||||
|
// CheckPermission 检查用户是否拥有指定权限
|
||||||
|
// userID: 用户ID
|
||||||
|
// permCode: 权限编码(格式: module:action)
|
||||||
|
// platform: 端口类型 (all/web/h5)
|
||||||
|
CheckPermission(ctx context.Context, userID uint, permCode string, platform string) (bool, error)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 函数和方法注释
|
||||||
|
|
||||||
|
导出函数/方法必须以函数名开头,说明功能:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Create 创建账号
|
||||||
|
// POST /api/admin/accounts
|
||||||
|
func (h *AccountHandler) Create(c *fiber.Ctx) error {
|
||||||
|
```
|
||||||
|
|
||||||
|
**复杂方法**(超过 30 行或包含复杂业务逻辑)必须额外说明实现思路:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// ActivateByRealname 首次实名激活套餐
|
||||||
|
// 当用户完成实名认证后,自动激活处于"囤货待实名"状态的套餐:
|
||||||
|
// 1. 查找该卡所有 status=3(待实名激活)的套餐
|
||||||
|
// 2. 按创建时间排序,第一个主套餐立即激活(status=1)
|
||||||
|
// 3. 其余主套餐进入排队状态(status=4)
|
||||||
|
// 4. 加油包如果绑定了已激活的主套餐则一并激活
|
||||||
|
func (s *UsageService) ActivateByRealname(ctx context.Context, cardID uint) error {
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 未导出符号的注释
|
||||||
|
|
||||||
|
未导出(小写)的函数/方法:
|
||||||
|
- **简单逻辑**(< 15 行):可以不加注释
|
||||||
|
- **复杂逻辑**(≥ 15 行)或 **非显而易见的算法**:必须加注释
|
||||||
|
|
||||||
|
```go
|
||||||
|
// buildPermissionTree 递归构建权限树
|
||||||
|
// 采用 map 索引 + 单次遍历算法,时间复杂度 O(n)
|
||||||
|
func (s *Service) buildPermissionTree(permissions []*model.Permission) []*dto.PermissionTreeNode {
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 内联注释(实现逻辑注释)
|
||||||
|
|
||||||
|
以下场景**必须**添加内联注释:
|
||||||
|
|
||||||
|
| 场景 | 要求 |
|
||||||
|
|------|------|
|
||||||
|
| 复杂条件判断 | 解释判断的业务含义 |
|
||||||
|
| 多步骤业务流程 | 用编号注释标明每一步 |
|
||||||
|
| 非显而易见的设计决策 | 解释"为什么这样做"而不是"做了什么" |
|
||||||
|
| 缓存/事务/并发处理 | 说明策略和原因 |
|
||||||
|
| 临时方案/兼容逻辑 | 标注 TODO 或说明背景 |
|
||||||
|
|
||||||
|
**✅ 好的内联注释(解释为什么)**:
|
||||||
|
```go
|
||||||
|
// 使用 Redis 分布式锁防止并发重复创建,锁超时 10 秒
|
||||||
|
if !s.acquireLock(ctx, lockKey, 10*time.Second) {
|
||||||
|
return errors.New(errors.CodeTooManyRequests, "操作过于频繁,请稍后重试")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 先冻结佣金再扣款,保证资金安全(失败时佣金自动解冻)
|
||||||
|
if err := s.freezeCommission(ctx, tx, orderID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**❌ 废话注释(禁止)**:
|
||||||
|
```go
|
||||||
|
// 获取用户ID ← 禁止:代码本身已经很清楚
|
||||||
|
userID := middleware.GetUserIDFromContext(ctx)
|
||||||
|
|
||||||
|
// 创建账号 ← 禁止:变量名已说明意图
|
||||||
|
account := &model.Account{}
|
||||||
|
|
||||||
|
// 返回错误 ← 禁止:return err 不需要注释
|
||||||
|
return err
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 常量和枚举注释
|
||||||
|
|
||||||
|
分组常量必须有组注释,每个值必须有行内注释:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 用户类型常量
|
||||||
|
const (
|
||||||
|
UserTypeSuperAdmin = 1 // 超级管理员
|
||||||
|
UserTypePlatform = 2 // 平台用户
|
||||||
|
UserTypeAgent = 3 // 代理账号
|
||||||
|
UserTypeEnterprise = 4 // 企业账号
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Handler 层特殊要求
|
||||||
|
|
||||||
|
Handler 方法的注释必须包含 HTTP 方法和路径:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Create 创建账号
|
||||||
|
// POST /api/admin/accounts
|
||||||
|
func (h *AccountHandler) Create(c *fiber.Ctx) error {
|
||||||
|
```
|
||||||
|
|
||||||
### Go 代码风格
|
### Go 代码风格
|
||||||
- 使用 `gofmt` 格式化
|
- 使用 `gofmt` 格式化
|
||||||
- 遵循 [Effective Go](https://go.dev/doc/effective_go)
|
- 遵循 [Effective Go](https://go.dev/doc/effective_go)
|
||||||
@@ -132,147 +276,33 @@ Handler → Service → Store → Model
|
|||||||
- 异常处理(panic/recover)
|
- 异常处理(panic/recover)
|
||||||
- 类型前缀(IService、AbstractBase、ServiceImpl)
|
- 类型前缀(IService、AbstractBase、ServiceImpl)
|
||||||
|
|
||||||
## 测试要求
|
## ⚠️ 测试禁令(强制执行)
|
||||||
|
|
||||||
### 测试金字塔(新)
|
**本项目不使用任何形式的自动化测试代码。**
|
||||||
|
|
||||||
```
|
**绝对禁止:**
|
||||||
┌─────────────┐
|
- ❌ **禁止编写单元测试** - 无论任何场景
|
||||||
│ E2E 测试 │ ← 手动/自动化 UI(很少)
|
- ❌ **禁止编写集成测试** - 无论任何场景
|
||||||
─┴─────────────┴─
|
- ❌ **禁止编写验收测试** - 无论任何场景
|
||||||
┌─────────────────┐
|
- ❌ **禁止编写流程测试** - 无论任何场景
|
||||||
│ 业务流程测试 │ ← 15%:多 API 组合验证
|
- ❌ **禁止编写 E2E 测试** - 无论任何场景
|
||||||
│ tests/flows/ │ 来源:Spec Business Flow
|
- ❌ **禁止创建 `*_test.go` 文件** - 除非用户明确要求
|
||||||
─┴─────────────────┴─
|
- ❌ **禁止在任务中包含测试相关工作** - 规划和实现均不涉及测试
|
||||||
┌─────────────────────┐
|
- ❌ **禁止在文档中提及测试要求** - 规范、设计文档均不讨论测试
|
||||||
│ 验收测试 │ ← 30%:单 API 契约验证
|
|
||||||
│ tests/acceptance/ │ 来源:Spec Scenario
|
|
||||||
─┴─────────────────────┴─
|
|
||||||
┌───────────────────────────┐
|
|
||||||
│ 集成测试 │ ← 25%:组件集成
|
|
||||||
─┴───────────────────────────┴─
|
|
||||||
┌─────────────────────────────────┐
|
|
||||||
│ 单元测试(精简) │ ← 30%:仅复杂逻辑
|
|
||||||
└─────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### 三层测试体系
|
**唯一例外:**
|
||||||
|
- ✅ **仅当用户明确要求**时才编写测试代码
|
||||||
|
- ✅ 用户必须主动说明"请写测试"或"需要测试"
|
||||||
|
|
||||||
| 层级 | 测试类型 | 来源 | 验证什么 | 位置 |
|
**原因说明:**
|
||||||
|------|---------|------|---------|------|
|
- 业务系统的正确性通过人工验证和生产环境监控保证
|
||||||
| **L1** | 验收测试 | Spec Scenario | 单 API 契约 | `tests/acceptance/` |
|
- 测试代码的维护成本高于价值
|
||||||
| **L2** | 流程测试 | Spec Business Flow | 业务场景完整性 | `tests/flows/` |
|
- 快速迭代优先于测试覆盖率
|
||||||
| **L3** | 单元测试 | 复杂逻辑 | 算法/规则正确性 | 模块内 `*_test.go` |
|
|
||||||
|
|
||||||
### 验收测试规范
|
**替代方案:**
|
||||||
|
- 使用 PostgreSQL MCP 工具手动验证数据
|
||||||
- **来源于 Spec**:每个 Scenario 对应一个测试用例
|
- 使用 Postman/curl 手动测试 API
|
||||||
- **测试先于实现**:在功能实现前生成,预期全部 FAIL
|
- 依赖生产环境日志和监控发现问题
|
||||||
- **必须有破坏点**:每个测试注释说明什么代码变更会导致失败
|
|
||||||
- **使用 IntegrationTestEnv**:不要 mock 依赖
|
|
||||||
|
|
||||||
详见:[tests/acceptance/README.md](tests/acceptance/README.md)
|
|
||||||
|
|
||||||
### 流程测试规范
|
|
||||||
|
|
||||||
- **来源于 Spec Business Flow**:每个 Flow 对应一个测试
|
|
||||||
- **跨 API 验证**:多个 API 调用的组合行为
|
|
||||||
- **状态共享**:流程中的数据在 steps 之间传递
|
|
||||||
- **依赖声明**:每个 step 声明依赖哪些前置 step
|
|
||||||
|
|
||||||
详见:[tests/flows/README.md](tests/flows/README.md)
|
|
||||||
|
|
||||||
### 单元测试精简规则
|
|
||||||
|
|
||||||
**保留**:
|
|
||||||
- ✅ 纯函数(计费计算、分佣算法)
|
|
||||||
- ✅ 状态机(订单状态流转)
|
|
||||||
- ✅ 复杂业务规则(层级校验、权限计算)
|
|
||||||
- ✅ 边界条件(时间、金额、精度)
|
|
||||||
|
|
||||||
**删除/不再写**:
|
|
||||||
- ❌ 简单 CRUD(已被验收测试覆盖)
|
|
||||||
- ❌ DTO 转换
|
|
||||||
- ❌ 配置读取
|
|
||||||
- ❌ 重复测试同一逻辑
|
|
||||||
|
|
||||||
### ⚠️ 测试真实性原则(严格遵守)
|
|
||||||
|
|
||||||
**测试必须真正验证功能,禁止绕过核心逻辑:**
|
|
||||||
|
|
||||||
| 规则 | 说明 |
|
|
||||||
|------|------|
|
|
||||||
| ❌ 禁止传递 nil 绕过依赖 | 如果功能依赖外部服务(如对象存储、第三方 API),测试必须验证该依赖的调用 |
|
|
||||||
| ❌ 禁止只测试部分流程 | 如果功能包含 A → B → C 三步,不能只测试 B 而跳过 A 和 C |
|
|
||||||
| ❌ 禁止声称"测试通过"但未验证核心逻辑 | 测试通过必须意味着功能真正可用 |
|
|
||||||
| ❌ 禁止擅自使用 Mock | 尽量使用真实服务进行集成测试,如需使用 Mock 必须先询问用户并获得同意 |
|
|
||||||
| ✅ 必须验证端到端流程 | 新增功能必须有完整的集成测试覆盖整个调用链 |
|
|
||||||
| ✅ 缺少配置时必须询问 | 如果测试需要的配置(如 API Key、环境变量)缺失,必须询问用户而非跳过测试 |
|
|
||||||
|
|
||||||
**反面案例**:
|
|
||||||
```go
|
|
||||||
// ❌ 错误:传递 nil 绕过 storageService,只测试了 processImport
|
|
||||||
handler := NewIotCardImportHandler(db, redis, store1, store2, nil, logger)
|
|
||||||
result := handler.processImport(ctx, task) // 跳过了 downloadAndParseCSV
|
|
||||||
|
|
||||||
// ✅ 正确:使用真实服务测试完整流程
|
|
||||||
handler := NewIotCardImportHandler(db, redis, store1, store2, realStorageService, logger)
|
|
||||||
handler.HandleIotCardImport(ctx, asynqTask) // 测试完整流程,验证真实上传/下载
|
|
||||||
```
|
|
||||||
|
|
||||||
**测试超时 = 生产超时**:
|
|
||||||
- 集成测试超时意味着生产环境也可能超时
|
|
||||||
- 发现超时必须排查原因,不能简单跳过或增加超时时间
|
|
||||||
|
|
||||||
### 测试连接管理(必读)
|
|
||||||
|
|
||||||
**详细规范**: [docs/testing/test-connection-guide.md](docs/testing/test-connection-guide.md)
|
|
||||||
|
|
||||||
**⚠️ 运行测试必须先加载环境变量**:
|
|
||||||
```bash
|
|
||||||
# ✅ 正确
|
|
||||||
source .env.local && go test -v ./internal/service/xxx/...
|
|
||||||
|
|
||||||
# ❌ 错误(会因缺少配置而失败)
|
|
||||||
go test -v ./internal/service/xxx/...
|
|
||||||
```
|
|
||||||
|
|
||||||
**标准模板**:
|
|
||||||
```go
|
|
||||||
func TestXxx(t *testing.T) {
|
|
||||||
tx := testutils.NewTestTransaction(t)
|
|
||||||
rdb := testutils.GetTestRedis(t)
|
|
||||||
testutils.CleanTestRedisKeys(t, rdb)
|
|
||||||
|
|
||||||
store := postgres.NewXxxStore(tx, rdb)
|
|
||||||
// 测试代码...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**核心函数**:
|
|
||||||
- `NewTestTransaction(t)`: 创建测试事务,自动回滚
|
|
||||||
- `GetTestRedis(t)`: 获取全局 Redis 连接
|
|
||||||
- `CleanTestRedisKeys(t, rdb)`: 自动清理测试 Redis 键
|
|
||||||
|
|
||||||
**集成测试环境**(HTTP API 测试):
|
|
||||||
```go
|
|
||||||
func TestAPI_Create(t *testing.T) {
|
|
||||||
env := testutils.NewIntegrationTestEnv(t)
|
|
||||||
|
|
||||||
t.Run("成功创建", func(t *testing.T) {
|
|
||||||
resp, err := env.AsSuperAdmin().Request("POST", "/api/admin/resources", jsonBody)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, 200, resp.StatusCode)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- `NewIntegrationTestEnv(t)`: 创建完整测试环境(事务、Redis、App、Token)
|
|
||||||
- `AsSuperAdmin()`: 以超级管理员身份请求
|
|
||||||
- `AsUser(account)`: 以指定账号身份请求
|
|
||||||
|
|
||||||
**禁止使用(已移除)**:
|
|
||||||
- ❌ `SetupTestDB` / `TeardownTestDB` / `SetupTestDBWithStore`
|
|
||||||
|
|
||||||
## 性能要求
|
## 性能要求
|
||||||
|
|
||||||
@@ -311,10 +341,9 @@ func TestAPI_Create(t *testing.T) {
|
|||||||
3. ✅ 使用统一错误处理
|
3. ✅ 使用统一错误处理
|
||||||
4. ✅ 常量定义在 pkg/constants/
|
4. ✅ 常量定义在 pkg/constants/
|
||||||
5. ✅ Go 惯用法(非 Java 风格)
|
5. ✅ Go 惯用法(非 Java 风格)
|
||||||
6. ✅ 包含测试计划
|
6. ✅ 性能考虑
|
||||||
7. ✅ 性能考虑
|
7. ✅ 文档更新计划
|
||||||
8. ✅ 文档更新计划
|
8. ✅ 中文优先
|
||||||
9. ✅ 中文优先
|
|
||||||
|
|
||||||
## Code Review 检查清单
|
## Code Review 检查清单
|
||||||
|
|
||||||
@@ -330,16 +359,18 @@ func TestAPI_Create(t *testing.T) {
|
|||||||
- [ ] 常量定义在 `pkg/constants/`
|
- [ ] 常量定义在 `pkg/constants/`
|
||||||
- [ ] 使用 Go 惯用法(非 Java 风格)
|
- [ ] 使用 Go 惯用法(非 Java 风格)
|
||||||
|
|
||||||
### 测试覆盖
|
|
||||||
- [ ] 核心业务逻辑测试覆盖率 ≥ 90%
|
|
||||||
- [ ] 所有 API 端点有集成测试
|
|
||||||
- [ ] 测试验证真实功能(不绕过核心逻辑)
|
|
||||||
|
|
||||||
### 文档和注释
|
### 文档和注释
|
||||||
- [ ] 所有注释使用中文
|
- [ ] 所有注释使用中文
|
||||||
- [ ] 导出函数/类型有文档注释
|
- [ ] 导出函数/类型有文档注释
|
||||||
- [ ] API 路径注释与真实路由一致
|
- [ ] API 路径注释与真实路由一致
|
||||||
|
|
||||||
|
### 幂等性
|
||||||
|
- [ ] 创建类写操作有 Redis 业务键防重
|
||||||
|
- [ ] 状态变更使用条件更新(`WHERE status = expected`)
|
||||||
|
- [ ] 余额/库存变更使用乐观锁(version 字段)
|
||||||
|
- [ ] 分布式锁使用 `defer` 确保释放
|
||||||
|
- [ ] Redis Key 定义在 `pkg/constants/redis.go`
|
||||||
|
|
||||||
### 越权防护规范
|
### 越权防护规范
|
||||||
|
|
||||||
**适用场景**:任何涉及跨用户、跨店铺、跨企业的资源访问
|
**适用场景**:任何涉及跨用户、跨店铺、跨企业的资源访问
|
||||||
@@ -375,6 +406,114 @@ func TestAPI_Create(t *testing.T) {
|
|||||||
- 越权访问统一返回:`errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")`
|
- 越权访问统一返回:`errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")`
|
||||||
- 不区分"不存在"和"无权限",防止信息泄露
|
- 不区分"不存在"和"无权限",防止信息泄露
|
||||||
|
|
||||||
|
### 幂等性规范
|
||||||
|
|
||||||
|
**适用场景**:任何可能被重复触发的写操作
|
||||||
|
|
||||||
|
#### 必须实现幂等性的场景
|
||||||
|
|
||||||
|
| 场景 | 原因 | 实现策略 |
|
||||||
|
|------|------|----------|
|
||||||
|
| 订单创建 | 用户双击、网络重试 | Redis 业务键防重 + 分布式锁 |
|
||||||
|
| 支付回调 | 第三方平台重复通知 | 状态条件更新(`WHERE status = pending`) |
|
||||||
|
| 钱包扣款/充值 | 并发请求、消息重投 | 乐观锁(version 字段)+ 状态条件更新 |
|
||||||
|
| 套餐激活 | 异步任务重试 | Redis 分布式锁 + 已存在记录检查 |
|
||||||
|
| 异步任务处理 | Asynq 自动重试 | Redis 任务锁(`RedisTaskLockKey`) |
|
||||||
|
| 佣金计算 | 支付成功后触发 | 幂等任务入队 + 状态检查 |
|
||||||
|
|
||||||
|
#### 不需要幂等性的场景
|
||||||
|
|
||||||
|
- 纯查询接口(GET 请求天然幂等)
|
||||||
|
- 管理后台的配置修改(低频操作,人为确认)
|
||||||
|
- 日志记录、审计记录(允许重复写入)
|
||||||
|
|
||||||
|
#### 实现策略选择
|
||||||
|
|
||||||
|
根据场景特征选择合适的策略:
|
||||||
|
|
||||||
|
**策略 1:状态条件更新(首选,适用于有明确状态流转的操作)**
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 通过 WHERE 条件确保只有预期状态才能更新,RowsAffected == 0 说明已被处理
|
||||||
|
result := tx.Model(&model.Order{}).
|
||||||
|
Where("id = ? AND payment_status = ?", orderID, model.PaymentStatusPending).
|
||||||
|
Updates(map[string]any{"payment_status": model.PaymentStatusPaid})
|
||||||
|
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
// 已被处理,检查当前状态决定返回成功还是错误
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**策略 2:Redis 业务键防重 + 分布式锁(适用于创建类操作,无状态可依赖)**
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 业务键 = 唯一标识请求意图的组合字段
|
||||||
|
// 示例:order:create:{buyer_type}:{buyer_id}:{carrier_type}:{carrier_id}:{sorted_package_ids}
|
||||||
|
idempotencyKey := buildBusinessKey(...)
|
||||||
|
redisKey := constants.RedisXxxIdempotencyKey(idempotencyKey)
|
||||||
|
|
||||||
|
// 第 1 层:Redis GET 快速检测
|
||||||
|
val, err := s.redis.Get(ctx, redisKey).Result()
|
||||||
|
if err == nil && val != "" {
|
||||||
|
return existingResult // 已创建,直接返回
|
||||||
|
}
|
||||||
|
|
||||||
|
// 第 2 层:分布式锁防止并发
|
||||||
|
lockKey := constants.RedisXxxLockKey(resourceType, resourceID)
|
||||||
|
locked, _ := s.redis.SetNX(ctx, lockKey, time.Now().String(), lockTTL).Result()
|
||||||
|
if !locked {
|
||||||
|
return errors.New(errors.CodeTooManyRequests, "操作进行中,请勿重复提交")
|
||||||
|
}
|
||||||
|
defer s.redis.Del(ctx, lockKey)
|
||||||
|
|
||||||
|
// 第 3 层:加锁后二次检测
|
||||||
|
val, err = s.redis.Get(ctx, redisKey).Result()
|
||||||
|
if err == nil && val != "" {
|
||||||
|
return existingResult
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行业务逻辑...
|
||||||
|
|
||||||
|
// 成功后标记
|
||||||
|
s.redis.Set(ctx, redisKey, resultID, idempotencyTTL)
|
||||||
|
```
|
||||||
|
|
||||||
|
**策略 3:乐观锁(适用于余额、库存等数值更新)**
|
||||||
|
|
||||||
|
```go
|
||||||
|
result := tx.Model(&model.Wallet{}).
|
||||||
|
Where("id = ? AND balance >= ? AND version = ?", walletID, amount, currentVersion).
|
||||||
|
Updates(map[string]any{
|
||||||
|
"balance": gorm.Expr("balance - ?", amount),
|
||||||
|
"version": gorm.Expr("version + 1"),
|
||||||
|
})
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
return errors.New(errors.CodeInsufficientBalance, "余额不足或并发冲突")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Redis Key 命名规范
|
||||||
|
|
||||||
|
幂等性相关的 Redis Key 统一在 `pkg/constants/redis.go` 定义:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 幂等性检测键:Redis{Module}IdempotencyKey — TTL 通常 3~5 分钟
|
||||||
|
func RedisOrderIdempotencyKey(businessKey string) string
|
||||||
|
|
||||||
|
// 分布式锁键:Redis{Module}{Action}LockKey — TTL 通常 10~30 秒
|
||||||
|
func RedisOrderCreateLockKey(carrierType string, carrierID uint) string
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 现有幂等性实现参考
|
||||||
|
|
||||||
|
| 模块 | 文件 | 策略 |
|
||||||
|
|------|------|------|
|
||||||
|
| 订单创建 | `internal/service/order/service.go` → `Create()` | 策略 2:Redis 业务键 + 分布式锁 |
|
||||||
|
| 钱包支付 | `internal/service/order/service.go` → `WalletPay()` | 策略 1:状态条件更新 |
|
||||||
|
| 支付回调 | `internal/service/order/service.go` → `HandlePaymentCallback()` | 策略 1:状态条件更新 |
|
||||||
|
| 套餐激活 | `internal/service/package/activation_service.go` → `ActivateQueuedPackage()` | 策略 2(简化版):Redis 分布式锁 |
|
||||||
|
| 钱包扣款 | `internal/service/order/service.go` → `WalletPay()` | 策略 3:乐观锁(version 字段) |
|
||||||
|
|
||||||
### 审计日志规范
|
### 审计日志规范
|
||||||
|
|
||||||
**适用场景**:任何敏感操作(账号管理、权限变更、数据删除等)
|
**适用场景**:任何敏感操作(账号管理、权限变更、数据删除等)
|
||||||
|
|||||||
259
CLAUDE.md
259
CLAUDE.md
@@ -102,6 +102,150 @@ Handler → Service → Store → Model
|
|||||||
- 禁止硬编码字符串和 magic numbers
|
- 禁止硬编码字符串和 magic numbers
|
||||||
- **必须为所有常量添加中文注释**
|
- **必须为所有常量添加中文注释**
|
||||||
|
|
||||||
|
### 注释规范
|
||||||
|
|
||||||
|
#### 基本原则
|
||||||
|
|
||||||
|
- **所有注释使用中文**(与语言要求一致)
|
||||||
|
- **导出符号必须有文档注释**(包、函数、方法、类型、接口、常量、变量)
|
||||||
|
- **复杂逻辑必须有实现注释**(解释"为什么",而不是"做了什么")
|
||||||
|
- **禁止废话注释**(不要用注释复述代码本身)
|
||||||
|
- **修改代码时必须同步更新注释**(过时的注释比没有注释更有害)
|
||||||
|
|
||||||
|
#### 包注释
|
||||||
|
|
||||||
|
每个包的入口文件(通常是主文件或 `doc.go`)必须有包注释:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Package account 提供账号管理的业务逻辑服务
|
||||||
|
// 包含账号创建、修改、删除、权限分配等功能
|
||||||
|
package account
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 结构体注释
|
||||||
|
|
||||||
|
所有导出结构体必须有文档注释,说明该结构体代表什么:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Service 账号业务服务
|
||||||
|
// 负责账号的 CRUD、角色分配、密码管理等业务逻辑
|
||||||
|
type Service struct {
|
||||||
|
store *Store
|
||||||
|
auditService AuditServiceInterface
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 接口注释
|
||||||
|
|
||||||
|
导出接口必须注释接口用途,每个方法必须说明契约:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// PermissionChecker 权限检查器接口
|
||||||
|
// 用于查询用户的权限列表
|
||||||
|
type PermissionChecker interface {
|
||||||
|
// CheckPermission 检查用户是否拥有指定权限
|
||||||
|
// userID: 用户ID
|
||||||
|
// permCode: 权限编码(格式: module:action)
|
||||||
|
// platform: 端口类型 (all/web/h5)
|
||||||
|
CheckPermission(ctx context.Context, userID uint, permCode string, platform string) (bool, error)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 函数和方法注释
|
||||||
|
|
||||||
|
导出函数/方法必须以函数名开头,说明功能:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Create 创建账号
|
||||||
|
// POST /api/admin/accounts
|
||||||
|
func (h *AccountHandler) Create(c *fiber.Ctx) error {
|
||||||
|
```
|
||||||
|
|
||||||
|
**复杂方法**(超过 30 行或包含复杂业务逻辑)必须额外说明实现思路:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// ActivateByRealname 首次实名激活套餐
|
||||||
|
// 当用户完成实名认证后,自动激活处于"囤货待实名"状态的套餐:
|
||||||
|
// 1. 查找该卡所有 status=3(待实名激活)的套餐
|
||||||
|
// 2. 按创建时间排序,第一个主套餐立即激活(status=1)
|
||||||
|
// 3. 其余主套餐进入排队状态(status=4)
|
||||||
|
// 4. 加油包如果绑定了已激活的主套餐则一并激活
|
||||||
|
func (s *UsageService) ActivateByRealname(ctx context.Context, cardID uint) error {
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 未导出符号的注释
|
||||||
|
|
||||||
|
未导出(小写)的函数/方法:
|
||||||
|
- **简单逻辑**(< 15 行):可以不加注释
|
||||||
|
- **复杂逻辑**(≥ 15 行)或 **非显而易见的算法**:必须加注释
|
||||||
|
|
||||||
|
```go
|
||||||
|
// buildPermissionTree 递归构建权限树
|
||||||
|
// 采用 map 索引 + 单次遍历算法,时间复杂度 O(n)
|
||||||
|
func (s *Service) buildPermissionTree(permissions []*model.Permission) []*dto.PermissionTreeNode {
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 内联注释(实现逻辑注释)
|
||||||
|
|
||||||
|
以下场景**必须**添加内联注释:
|
||||||
|
|
||||||
|
| 场景 | 要求 |
|
||||||
|
|------|------|
|
||||||
|
| 复杂条件判断 | 解释判断的业务含义 |
|
||||||
|
| 多步骤业务流程 | 用编号注释标明每一步 |
|
||||||
|
| 非显而易见的设计决策 | 解释"为什么这样做"而不是"做了什么" |
|
||||||
|
| 缓存/事务/并发处理 | 说明策略和原因 |
|
||||||
|
| 临时方案/兼容逻辑 | 标注 TODO 或说明背景 |
|
||||||
|
|
||||||
|
**✅ 好的内联注释(解释为什么)**:
|
||||||
|
```go
|
||||||
|
// 使用 Redis 分布式锁防止并发重复创建,锁超时 10 秒
|
||||||
|
if !s.acquireLock(ctx, lockKey, 10*time.Second) {
|
||||||
|
return errors.New(errors.CodeTooManyRequests, "操作过于频繁,请稍后重试")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 先冻结佣金再扣款,保证资金安全(失败时佣金自动解冻)
|
||||||
|
if err := s.freezeCommission(ctx, tx, orderID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**❌ 废话注释(禁止)**:
|
||||||
|
```go
|
||||||
|
// 获取用户ID ← 禁止:代码本身已经很清楚
|
||||||
|
userID := middleware.GetUserIDFromContext(ctx)
|
||||||
|
|
||||||
|
// 创建账号 ← 禁止:变量名已说明意图
|
||||||
|
account := &model.Account{}
|
||||||
|
|
||||||
|
// 返回错误 ← 禁止:return err 不需要注释
|
||||||
|
return err
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 常量和枚举注释
|
||||||
|
|
||||||
|
分组常量必须有组注释,每个值必须有行内注释:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 用户类型常量
|
||||||
|
const (
|
||||||
|
UserTypeSuperAdmin = 1 // 超级管理员
|
||||||
|
UserTypePlatform = 2 // 平台用户
|
||||||
|
UserTypeAgent = 3 // 代理账号
|
||||||
|
UserTypeEnterprise = 4 // 企业账号
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Handler 层特殊要求
|
||||||
|
|
||||||
|
Handler 方法的注释必须包含 HTTP 方法和路径:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Create 创建账号
|
||||||
|
// POST /api/admin/accounts
|
||||||
|
func (h *AccountHandler) Create(c *fiber.Ctx) error {
|
||||||
|
```
|
||||||
|
|
||||||
### Go 代码风格
|
### Go 代码风格
|
||||||
- 使用 `gofmt` 格式化
|
- 使用 `gofmt` 格式化
|
||||||
- 遵循 [Effective Go](https://go.dev/doc/effective_go)
|
- 遵循 [Effective Go](https://go.dev/doc/effective_go)
|
||||||
@@ -220,6 +364,13 @@ Handler → Service → Store → Model
|
|||||||
- [ ] 导出函数/类型有文档注释
|
- [ ] 导出函数/类型有文档注释
|
||||||
- [ ] API 路径注释与真实路由一致
|
- [ ] API 路径注释与真实路由一致
|
||||||
|
|
||||||
|
### 幂等性
|
||||||
|
- [ ] 创建类写操作有 Redis 业务键防重
|
||||||
|
- [ ] 状态变更使用条件更新(`WHERE status = expected`)
|
||||||
|
- [ ] 余额/库存变更使用乐观锁(version 字段)
|
||||||
|
- [ ] 分布式锁使用 `defer` 确保释放
|
||||||
|
- [ ] Redis Key 定义在 `pkg/constants/redis.go`
|
||||||
|
|
||||||
### 越权防护规范
|
### 越权防护规范
|
||||||
|
|
||||||
**适用场景**:任何涉及跨用户、跨店铺、跨企业的资源访问
|
**适用场景**:任何涉及跨用户、跨店铺、跨企业的资源访问
|
||||||
@@ -255,6 +406,114 @@ Handler → Service → Store → Model
|
|||||||
- 越权访问统一返回:`errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")`
|
- 越权访问统一返回:`errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")`
|
||||||
- 不区分"不存在"和"无权限",防止信息泄露
|
- 不区分"不存在"和"无权限",防止信息泄露
|
||||||
|
|
||||||
|
### 幂等性规范
|
||||||
|
|
||||||
|
**适用场景**:任何可能被重复触发的写操作
|
||||||
|
|
||||||
|
#### 必须实现幂等性的场景
|
||||||
|
|
||||||
|
| 场景 | 原因 | 实现策略 |
|
||||||
|
|------|------|----------|
|
||||||
|
| 订单创建 | 用户双击、网络重试 | Redis 业务键防重 + 分布式锁 |
|
||||||
|
| 支付回调 | 第三方平台重复通知 | 状态条件更新(`WHERE status = pending`) |
|
||||||
|
| 钱包扣款/充值 | 并发请求、消息重投 | 乐观锁(version 字段)+ 状态条件更新 |
|
||||||
|
| 套餐激活 | 异步任务重试 | Redis 分布式锁 + 已存在记录检查 |
|
||||||
|
| 异步任务处理 | Asynq 自动重试 | Redis 任务锁(`RedisTaskLockKey`) |
|
||||||
|
| 佣金计算 | 支付成功后触发 | 幂等任务入队 + 状态检查 |
|
||||||
|
|
||||||
|
#### 不需要幂等性的场景
|
||||||
|
|
||||||
|
- 纯查询接口(GET 请求天然幂等)
|
||||||
|
- 管理后台的配置修改(低频操作,人为确认)
|
||||||
|
- 日志记录、审计记录(允许重复写入)
|
||||||
|
|
||||||
|
#### 实现策略选择
|
||||||
|
|
||||||
|
根据场景特征选择合适的策略:
|
||||||
|
|
||||||
|
**策略 1:状态条件更新(首选,适用于有明确状态流转的操作)**
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 通过 WHERE 条件确保只有预期状态才能更新,RowsAffected == 0 说明已被处理
|
||||||
|
result := tx.Model(&model.Order{}).
|
||||||
|
Where("id = ? AND payment_status = ?", orderID, model.PaymentStatusPending).
|
||||||
|
Updates(map[string]any{"payment_status": model.PaymentStatusPaid})
|
||||||
|
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
// 已被处理,检查当前状态决定返回成功还是错误
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**策略 2:Redis 业务键防重 + 分布式锁(适用于创建类操作,无状态可依赖)**
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 业务键 = 唯一标识请求意图的组合字段
|
||||||
|
// 示例:order:create:{buyer_type}:{buyer_id}:{carrier_type}:{carrier_id}:{sorted_package_ids}
|
||||||
|
idempotencyKey := buildBusinessKey(...)
|
||||||
|
redisKey := constants.RedisXxxIdempotencyKey(idempotencyKey)
|
||||||
|
|
||||||
|
// 第 1 层:Redis GET 快速检测
|
||||||
|
val, err := s.redis.Get(ctx, redisKey).Result()
|
||||||
|
if err == nil && val != "" {
|
||||||
|
return existingResult // 已创建,直接返回
|
||||||
|
}
|
||||||
|
|
||||||
|
// 第 2 层:分布式锁防止并发
|
||||||
|
lockKey := constants.RedisXxxLockKey(resourceType, resourceID)
|
||||||
|
locked, _ := s.redis.SetNX(ctx, lockKey, time.Now().String(), lockTTL).Result()
|
||||||
|
if !locked {
|
||||||
|
return errors.New(errors.CodeTooManyRequests, "操作进行中,请勿重复提交")
|
||||||
|
}
|
||||||
|
defer s.redis.Del(ctx, lockKey)
|
||||||
|
|
||||||
|
// 第 3 层:加锁后二次检测
|
||||||
|
val, err = s.redis.Get(ctx, redisKey).Result()
|
||||||
|
if err == nil && val != "" {
|
||||||
|
return existingResult
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行业务逻辑...
|
||||||
|
|
||||||
|
// 成功后标记
|
||||||
|
s.redis.Set(ctx, redisKey, resultID, idempotencyTTL)
|
||||||
|
```
|
||||||
|
|
||||||
|
**策略 3:乐观锁(适用于余额、库存等数值更新)**
|
||||||
|
|
||||||
|
```go
|
||||||
|
result := tx.Model(&model.Wallet{}).
|
||||||
|
Where("id = ? AND balance >= ? AND version = ?", walletID, amount, currentVersion).
|
||||||
|
Updates(map[string]any{
|
||||||
|
"balance": gorm.Expr("balance - ?", amount),
|
||||||
|
"version": gorm.Expr("version + 1"),
|
||||||
|
})
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
return errors.New(errors.CodeInsufficientBalance, "余额不足或并发冲突")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Redis Key 命名规范
|
||||||
|
|
||||||
|
幂等性相关的 Redis Key 统一在 `pkg/constants/redis.go` 定义:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 幂等性检测键:Redis{Module}IdempotencyKey — TTL 通常 3~5 分钟
|
||||||
|
func RedisOrderIdempotencyKey(businessKey string) string
|
||||||
|
|
||||||
|
// 分布式锁键:Redis{Module}{Action}LockKey — TTL 通常 10~30 秒
|
||||||
|
func RedisOrderCreateLockKey(carrierType string, carrierID uint) string
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 现有幂等性实现参考
|
||||||
|
|
||||||
|
| 模块 | 文件 | 策略 |
|
||||||
|
|------|------|------|
|
||||||
|
| 订单创建 | `internal/service/order/service.go` → `Create()` | 策略 2:Redis 业务键 + 分布式锁 |
|
||||||
|
| 钱包支付 | `internal/service/order/service.go` → `WalletPay()` | 策略 1:状态条件更新 |
|
||||||
|
| 支付回调 | `internal/service/order/service.go` → `HandlePaymentCallback()` | 策略 1:状态条件更新 |
|
||||||
|
| 套餐激活 | `internal/service/package/activation_service.go` → `ActivateQueuedPackage()` | 策略 2(简化版):Redis 分布式锁 |
|
||||||
|
| 钱包扣款 | `internal/service/order/service.go` → `WalletPay()` | 策略 3:乐观锁(version 字段) |
|
||||||
|
|
||||||
### 审计日志规范
|
### 审计日志规范
|
||||||
|
|
||||||
**适用场景**:任何敏感操作(账号管理、权限变更、数据删除等)
|
**适用场景**:任何敏感操作(账号管理、权限变更、数据删除等)
|
||||||
|
|||||||
@@ -218,6 +218,7 @@ default:
|
|||||||
- **代理商体系**:层级管理和分佣结算,支持差价佣金和一次性佣金两种佣金类型,详见 [套餐与佣金业务模型](docs/commission-package-model.md)
|
- **代理商体系**:层级管理和分佣结算,支持差价佣金和一次性佣金两种佣金类型,详见 [套餐与佣金业务模型](docs/commission-package-model.md)
|
||||||
- **批量同步**:卡状态、实名状态、流量使用情况
|
- **批量同步**:卡状态、实名状态、流量使用情况
|
||||||
- **轮询系统**:IoT 卡实名状态、流量使用、套餐余额的定时轮询检查;支持配置化轮询策略、动态并发控制、告警系统、数据清理和手动触发功能;详见 [轮询系统文档](docs/polling-system/README.md)
|
- **轮询系统**:IoT 卡实名状态、流量使用、套餐余额的定时轮询检查;支持配置化轮询策略、动态并发控制、告警系统、数据清理和手动触发功能;详见 [轮询系统文档](docs/polling-system/README.md)
|
||||||
|
- **套餐系统升级**:完整的套餐生命周期管理,支持主套餐排队激活、加油包绑定主套餐、囤货待实名激活、流量按优先级扣减、自然月/按天有效期计算、日/月/年流量重置、客户端流量查询和套餐流量详单;详见 [套餐系统升级文档](docs/package-system-upgrade/)
|
||||||
- **分佣验证指引**:对代理分佣的冻结、解冻、提现校验流程进行了结构化说明与流程图,详见 [分佣逻辑正确与否验证](docs/优化说明/分佣逻辑正确与否验证.md)
|
- **分佣验证指引**:对代理分佣的冻结、解冻、提现校验流程进行了结构化说明与流程图,详见 [分佣逻辑正确与否验证](docs/优化说明/分佣逻辑正确与否验证.md)
|
||||||
- **对象存储**:S3 兼容的对象存储服务集成(联通云 OSS),支持预签名 URL 上传、文件下载、临时文件处理;用于 ICCID 批量导入、数据导出等场景;详见 [使用指南](docs/object-storage/使用指南.md) 和 [前端接入指南](docs/object-storage/前端接入指南.md)
|
- **对象存储**:S3 兼容的对象存储服务集成(联通云 OSS),支持预签名 URL 上传、文件下载、临时文件处理;用于 ICCID 批量导入、数据导出等场景;详见 [使用指南](docs/object-storage/使用指南.md) 和 [前端接入指南](docs/object-storage/前端接入指南.md)
|
||||||
- **微信集成**:完整的微信公众号 OAuth 认证和微信支付功能(JSAPI + H5),使用 PowerWeChat v3 SDK;支持个人客户微信授权登录、账号绑定、微信内支付和浏览器 H5 支付;支付回调自动验证签名和幂等性处理;详见 [使用指南](docs/wechat-integration/使用指南.md) 和 [API 文档](docs/wechat-integration/API文档.md)
|
- **微信集成**:完整的微信公众号 OAuth 认证和微信支付功能(JSAPI + H5),使用 PowerWeChat v3 SDK;支持个人客户微信授权登录、账号绑定、微信内支付和浏览器 H5 支付;支付回调自动验证签名和幂等性处理;详见 [使用指南](docs/wechat-integration/使用指南.md) 和 [API 文档](docs/wechat-integration/API文档.md)
|
||||||
|
|||||||
@@ -11,13 +11,12 @@ import (
|
|||||||
"github.com/hibiken/asynq"
|
"github.com/hibiken/asynq"
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"gorm.io/gorm"
|
|
||||||
|
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/bootstrap"
|
||||||
"github.com/break/junhong_cmp_fiber/internal/gateway"
|
"github.com/break/junhong_cmp_fiber/internal/gateway"
|
||||||
"github.com/break/junhong_cmp_fiber/internal/polling"
|
"github.com/break/junhong_cmp_fiber/internal/polling"
|
||||||
pollingSvc "github.com/break/junhong_cmp_fiber/internal/service/polling"
|
pollingSvc "github.com/break/junhong_cmp_fiber/internal/service/polling"
|
||||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
pkgBootstrap "github.com/break/junhong_cmp_fiber/pkg/bootstrap"
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/bootstrap"
|
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/config"
|
"github.com/break/junhong_cmp_fiber/pkg/config"
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/database"
|
"github.com/break/junhong_cmp_fiber/pkg/database"
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/logger"
|
"github.com/break/junhong_cmp_fiber/pkg/logger"
|
||||||
@@ -31,7 +30,7 @@ func main() {
|
|||||||
panic("加载配置失败: " + err.Error())
|
panic("加载配置失败: " + err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := bootstrap.EnsureDirectories(cfg, nil); err != nil {
|
if _, err := pkgBootstrap.EnsureDirectories(cfg, nil); err != nil {
|
||||||
panic("初始化目录失败: " + err.Error())
|
panic("初始化目录失败: " + err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,20 +118,40 @@ func main() {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
// 创建 Worker 依赖
|
||||||
|
workerDeps := &bootstrap.WorkerDependencies{
|
||||||
|
DB: db,
|
||||||
|
Redis: redisClient,
|
||||||
|
Logger: appLogger,
|
||||||
|
AsynqClient: asynqClient,
|
||||||
|
StorageService: storageSvc,
|
||||||
|
GatewayClient: gatewayClient,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bootstrap Worker 组件
|
||||||
|
workerResult, err := bootstrap.BootstrapWorker(workerDeps)
|
||||||
|
if err != nil {
|
||||||
|
appLogger.Fatal("Worker Bootstrap 失败", zap.Error(err))
|
||||||
|
}
|
||||||
|
|
||||||
// 创建 Asynq Worker 服务器
|
// 创建 Asynq Worker 服务器
|
||||||
workerServer := queue.NewServer(redisClient, &cfg.Queue, appLogger)
|
workerServer := queue.NewServer(redisClient, &cfg.Queue, appLogger)
|
||||||
|
|
||||||
// 初始化轮询调度器(在创建 Handler 之前,因为 Handler 需要使用调度器作为回调)
|
// 初始化轮询调度器(在创建 Handler 之前,因为 Handler 需要使用调度器作为回调)
|
||||||
scheduler := polling.NewScheduler(db, redisClient, asynqClient, appLogger)
|
scheduler := polling.NewScheduler(db, redisClient, asynqClient, appLogger)
|
||||||
|
|
||||||
|
// 注入流量重置服务到调度器
|
||||||
|
dataResetHandler := polling.NewDataResetHandler(workerResult.Services.ResetService, appLogger)
|
||||||
|
scheduler.SetResetService(dataResetHandler)
|
||||||
|
|
||||||
if err := scheduler.Start(ctx); err != nil {
|
if err := scheduler.Start(ctx); err != nil {
|
||||||
appLogger.Error("启动轮询调度器失败", zap.Error(err))
|
appLogger.Error("启动轮询调度器失败", zap.Error(err))
|
||||||
// 调度器启动失败不阻止 Worker 启动,但不传递给 Handler
|
|
||||||
} else {
|
} else {
|
||||||
appLogger.Info("轮询调度器已启动")
|
appLogger.Info("轮询调度器已启动")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建任务处理器管理器并注册所有处理器(传递 scheduler 作为轮询回调)
|
// 创建任务处理器管理器并注册所有处理器
|
||||||
taskHandler := queue.NewHandler(db, redisClient, storageSvc, gatewayClient, scheduler, appLogger)
|
taskHandler := queue.NewHandler(db, redisClient, storageSvc, gatewayClient, scheduler, workerResult, asynqClient, appLogger)
|
||||||
taskHandler.RegisterHandlers()
|
taskHandler.RegisterHandlers()
|
||||||
|
|
||||||
appLogger.Info("Worker 服务器配置完成",
|
appLogger.Info("Worker 服务器配置完成",
|
||||||
@@ -140,10 +159,10 @@ func main() {
|
|||||||
zap.Any("queues", cfg.Queue.Queues))
|
zap.Any("queues", cfg.Queue.Queues))
|
||||||
|
|
||||||
// 初始化告警服务并启动告警检查器
|
// 初始化告警服务并启动告警检查器
|
||||||
alertChecker := startAlertChecker(ctx, db, redisClient, appLogger)
|
alertChecker := startAlertChecker(ctx, workerResult.Services.AlertService, appLogger)
|
||||||
|
|
||||||
// 初始化数据清理服务并启动定时清理任务
|
// 初始化数据清理服务并启动定时清理任务
|
||||||
cleanupChecker := startCleanupScheduler(ctx, db, appLogger)
|
cleanupChecker := startCleanupScheduler(ctx, workerResult.Services.CleanupService, appLogger)
|
||||||
|
|
||||||
// 优雅关闭
|
// 优雅关闭
|
||||||
quit := make(chan os.Signal, 1)
|
quit := make(chan os.Signal, 1)
|
||||||
@@ -217,19 +236,11 @@ func initGateway(cfg *config.Config, appLogger *zap.Logger) *gateway.Client {
|
|||||||
return client
|
return client
|
||||||
}
|
}
|
||||||
|
|
||||||
// startAlertChecker 启动告警检查器
|
func startAlertChecker(ctx context.Context, alertService *pollingSvc.AlertService, appLogger *zap.Logger) chan struct{} {
|
||||||
// 返回一个 stop channel,关闭它可以停止检查器
|
|
||||||
func startAlertChecker(ctx context.Context, db *gorm.DB, redisClient *redis.Client, appLogger *zap.Logger) chan struct{} {
|
|
||||||
stopChan := make(chan struct{})
|
stopChan := make(chan struct{})
|
||||||
|
|
||||||
// 创建告警服务所需的 stores
|
|
||||||
ruleStore := postgres.NewPollingAlertRuleStore(db)
|
|
||||||
historyStore := postgres.NewPollingAlertHistoryStore(db)
|
|
||||||
alertService := pollingSvc.NewAlertService(ruleStore, historyStore, redisClient, appLogger)
|
|
||||||
|
|
||||||
// 启动检查器 goroutine
|
|
||||||
go func() {
|
go func() {
|
||||||
ticker := time.NewTicker(1 * time.Minute) // 每分钟检查一次
|
ticker := time.NewTicker(1 * time.Minute)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
appLogger.Info("告警检查器已启动,检查间隔: 1分钟")
|
appLogger.Info("告警检查器已启动,检查间隔: 1分钟")
|
||||||
@@ -253,18 +264,10 @@ func startAlertChecker(ctx context.Context, db *gorm.DB, redisClient *redis.Clie
|
|||||||
return stopChan
|
return stopChan
|
||||||
}
|
}
|
||||||
|
|
||||||
// startCleanupScheduler 启动数据清理定时任务
|
func startCleanupScheduler(ctx context.Context, cleanupService *pollingSvc.CleanupService, appLogger *zap.Logger) chan struct{} {
|
||||||
// 每天凌晨2点运行清理任务
|
|
||||||
func startCleanupScheduler(ctx context.Context, db *gorm.DB, appLogger *zap.Logger) chan struct{} {
|
|
||||||
stopChan := make(chan struct{})
|
stopChan := make(chan struct{})
|
||||||
|
|
||||||
// 创建清理服务
|
|
||||||
configStore := postgres.NewDataCleanupConfigStore(db)
|
|
||||||
logStore := postgres.NewDataCleanupLogStore(db)
|
|
||||||
cleanupService := pollingSvc.NewCleanupService(configStore, logStore, appLogger)
|
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
// 计算到下一个凌晨2点的时间间隔
|
|
||||||
calcNextRun := func() time.Duration {
|
calcNextRun := func() time.Duration {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
next := time.Date(now.Year(), now.Month(), now.Day(), 2, 0, 0, 0, now.Location())
|
next := time.Date(now.Year(), now.Month(), now.Day(), 2, 0, 0, 0, now.Location())
|
||||||
@@ -286,7 +289,6 @@ func startCleanupScheduler(ctx context.Context, db *gorm.DB, appLogger *zap.Logg
|
|||||||
if err := cleanupService.RunScheduledCleanup(ctx); err != nil {
|
if err := cleanupService.RunScheduledCleanup(ctx); err != nil {
|
||||||
appLogger.Error("定时数据清理失败", zap.Error(err))
|
appLogger.Error("定时数据清理失败", zap.Error(err))
|
||||||
}
|
}
|
||||||
// 重置定时器到下一个凌晨2点
|
|
||||||
timer.Reset(calcNextRun())
|
timer.Reset(calcNextRun())
|
||||||
case <-stopChan:
|
case <-stopChan:
|
||||||
appLogger.Info("数据清理定时任务已停止")
|
appLogger.Info("数据清理定时任务已停止")
|
||||||
|
|||||||
277
docs/package-system-upgrade/API文档.md
Normal file
277
docs/package-system-upgrade/API文档.md
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
# 套餐系统升级 - API 文档
|
||||||
|
|
||||||
|
## 客户端 API
|
||||||
|
|
||||||
|
### 查询我的流量使用情况
|
||||||
|
|
||||||
|
获取当前用户绑定的卡/设备的套餐流量使用情况。
|
||||||
|
|
||||||
|
**请求**
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/h5/packages/my-usage
|
||||||
|
Authorization: Bearer {token}
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"main_package": {
|
||||||
|
"package_usage_id": 101,
|
||||||
|
"package_id": 1,
|
||||||
|
"package_name": "月度套餐 30G",
|
||||||
|
"data_limit_mb": 30720,
|
||||||
|
"data_usage_mb": 15360,
|
||||||
|
"status": 1,
|
||||||
|
"priority": 1,
|
||||||
|
"activated_at": "2025-02-01T00:00:00Z",
|
||||||
|
"expires_at": "2025-02-28T23:59:59Z",
|
||||||
|
"data_reset_cycle": "monthly",
|
||||||
|
"last_reset_at": "2025-02-01T00:00:00Z",
|
||||||
|
"next_reset_at": "2025-03-01T00:00:00Z"
|
||||||
|
},
|
||||||
|
"addon_packages": [
|
||||||
|
{
|
||||||
|
"package_usage_id": 102,
|
||||||
|
"package_id": 5,
|
||||||
|
"package_name": "加油包 5G",
|
||||||
|
"data_limit_mb": 5120,
|
||||||
|
"data_usage_mb": 2048,
|
||||||
|
"status": 1,
|
||||||
|
"priority": 2,
|
||||||
|
"master_usage_id": 101,
|
||||||
|
"activated_at": "2025-02-10T00:00:00Z",
|
||||||
|
"expires_at": "2025-02-28T23:59:59Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": {
|
||||||
|
"total_mb": 35840,
|
||||||
|
"used_mb": 17408,
|
||||||
|
"remaining_mb": 18432
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"timestamp": 1707667200
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应字段说明**
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `main_package` | object | 主套餐信息(可能为 null) |
|
||||||
|
| `addon_packages` | array | 加油包列表 |
|
||||||
|
| `total.total_mb` | int64 | 总流量(MB) |
|
||||||
|
| `total.used_mb` | int64 | 已用流量(MB) |
|
||||||
|
| `total.remaining_mb` | int64 | 剩余流量(MB) |
|
||||||
|
|
||||||
|
**套餐状态 status**
|
||||||
|
|
||||||
|
| 值 | 说明 |
|
||||||
|
|----|------|
|
||||||
|
| 0 | 待生效 |
|
||||||
|
| 1 | 生效中 |
|
||||||
|
| 2 | 已用完 |
|
||||||
|
| 3 | 已过期 |
|
||||||
|
| 4 | 已失效 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 后台管理 API
|
||||||
|
|
||||||
|
### 查询套餐流量详单
|
||||||
|
|
||||||
|
查询指定套餐的每日流量使用记录。
|
||||||
|
|
||||||
|
**请求**
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/admin/package-usage/{id}/daily-records
|
||||||
|
Authorization: Bearer {token}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Query 参数**
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `start_date` | string | 是 | 开始日期(YYYY-MM-DD) |
|
||||||
|
| `end_date` | string | 是 | 结束日期(YYYY-MM-DD) |
|
||||||
|
|
||||||
|
**响应**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"package_usage_id": 101,
|
||||||
|
"package_name": "月度套餐 30G",
|
||||||
|
"records": [
|
||||||
|
{
|
||||||
|
"date": "2025-02-01",
|
||||||
|
"daily_usage_mb": 1024,
|
||||||
|
"cumulative_usage_mb": 1024
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2025-02-02",
|
||||||
|
"daily_usage_mb": 512,
|
||||||
|
"cumulative_usage_mb": 1536
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2025-02-03",
|
||||||
|
"daily_usage_mb": 2048,
|
||||||
|
"cumulative_usage_mb": 3584
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total_usage_mb": 15360
|
||||||
|
},
|
||||||
|
"timestamp": 1707667200
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**错误码**
|
||||||
|
|
||||||
|
| 错误码 | 说明 |
|
||||||
|
|-------|------|
|
||||||
|
| 400 | 参数错误(日期格式不正确) |
|
||||||
|
| 403 | 无权限访问该套餐 |
|
||||||
|
| 404 | 套餐不存在 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 创建套餐(扩展字段)
|
||||||
|
|
||||||
|
创建套餐时支持的新字段。
|
||||||
|
|
||||||
|
**请求**
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/admin/packages
|
||||||
|
Authorization: Bearer {token}
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
**请求体**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"package_name": "月度套餐 30G",
|
||||||
|
"package_type": "main",
|
||||||
|
"data_limit_mb": 30720,
|
||||||
|
"price": 9900,
|
||||||
|
"calendar_type": "natural_month",
|
||||||
|
"duration_months": 1,
|
||||||
|
"data_reset_cycle": "monthly",
|
||||||
|
"enable_realname_activation": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**新增字段说明**
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `calendar_type` | string | 是 | 有效期类型:`natural_month`(自然月)、`by_day`(按天) |
|
||||||
|
| `duration_months` | int | 条件必填 | 自然月套餐的月数(calendar_type=natural_month 时必填) |
|
||||||
|
| `duration_days` | int | 条件必填 | 按天套餐的天数(calendar_type=by_day 时必填) |
|
||||||
|
| `data_reset_cycle` | string | 是 | 流量重置周期:`daily`、`monthly`、`yearly`、`none` |
|
||||||
|
| `enable_realname_activation` | bool | 否 | 是否需要实名后激活(默认 false) |
|
||||||
|
|
||||||
|
**calendar_type 取值**
|
||||||
|
|
||||||
|
| 值 | 说明 | 有效期计算 |
|
||||||
|
|----|------|-----------|
|
||||||
|
| `natural_month` | 自然月 | 激活月份 + N 个月,月末过期 |
|
||||||
|
| `by_day` | 按天 | 激活日期 + N 天 |
|
||||||
|
|
||||||
|
**data_reset_cycle 取值**
|
||||||
|
|
||||||
|
| 值 | 说明 | 重置时间 |
|
||||||
|
|----|------|---------|
|
||||||
|
| `daily` | 日重置 | 每天 00:00:00 |
|
||||||
|
| `monthly` | 月重置 | 自然月套餐:每月1号<br>按天套餐:每30天 |
|
||||||
|
| `yearly` | 年重置 | 每年1月1日 |
|
||||||
|
| `none` | 不重置 | 不重置 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 更新套餐(扩展字段)
|
||||||
|
|
||||||
|
更新套餐时支持的新字段。
|
||||||
|
|
||||||
|
**请求**
|
||||||
|
|
||||||
|
```http
|
||||||
|
PUT /api/admin/packages/{id}
|
||||||
|
Authorization: Bearer {token}
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
**请求体**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"calendar_type": "by_day",
|
||||||
|
"duration_days": 30,
|
||||||
|
"data_reset_cycle": "none",
|
||||||
|
"enable_realname_activation": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 查询套餐详情(扩展字段)
|
||||||
|
|
||||||
|
获取套餐详情时返回的新字段。
|
||||||
|
|
||||||
|
**响应**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"id": 1,
|
||||||
|
"package_name": "月度套餐 30G",
|
||||||
|
"package_type": "main",
|
||||||
|
"data_limit_mb": 30720,
|
||||||
|
"price": 9900,
|
||||||
|
"calendar_type": "natural_month",
|
||||||
|
"duration_months": 1,
|
||||||
|
"duration_days": 0,
|
||||||
|
"data_reset_cycle": "monthly",
|
||||||
|
"enable_realname_activation": false,
|
||||||
|
"status": 1,
|
||||||
|
"created_at": "2025-01-01T00:00:00Z",
|
||||||
|
"updated_at": "2025-01-15T00:00:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 错误码汇总
|
||||||
|
|
||||||
|
| 错误码 | HTTP 状态码 | 说明 |
|
||||||
|
|-------|------------|------|
|
||||||
|
| `CodePackageActivationConflict` | 409 | 套餐正在激活中,请稍后重试 |
|
||||||
|
| `CodeNoMainPackage` | 400 | 必须有主套餐才能购买加油包 |
|
||||||
|
| `CodeRealnameRequired` | 403 | 设备/卡必须先完成实名认证才能购买套餐 |
|
||||||
|
| `CodeMixedOrderForbidden` | 400 | 同订单不能同时购买正式套餐和加油包 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 数据权限
|
||||||
|
|
||||||
|
### 客户端 API
|
||||||
|
|
||||||
|
- 只能查询当前用户绑定的卡/设备的套餐信息
|
||||||
|
- 用户身份通过 JWT Token 识别
|
||||||
|
|
||||||
|
### 后台管理 API
|
||||||
|
|
||||||
|
- 代理商:只能查询自己店铺及下级店铺的套餐
|
||||||
|
- 企业用户:只能查询自己企业的套餐
|
||||||
|
- 平台用户:可查询所有套餐
|
||||||
|
- 越权访问返回 403 错误
|
||||||
278
docs/package-system-upgrade/使用指南.md
Normal file
278
docs/package-system-upgrade/使用指南.md
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
# 套餐系统升级 - 使用指南
|
||||||
|
|
||||||
|
## 场景一:囤货待实名激活
|
||||||
|
|
||||||
|
### 业务场景
|
||||||
|
|
||||||
|
代理商后台为未实名的卡/设备预先购买套餐,用户实名后自动激活。
|
||||||
|
|
||||||
|
### 操作流程
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 代理商登录后台
|
||||||
|
2. 选择未实名的卡/设备
|
||||||
|
3. 购买套餐(选择支持实名激活的套餐)
|
||||||
|
4. 套餐状态:待激活(status=0, pending_realname_activation=true)
|
||||||
|
5. 用户完成实名认证
|
||||||
|
6. 系统自动激活套餐
|
||||||
|
```
|
||||||
|
|
||||||
|
### 前置条件
|
||||||
|
|
||||||
|
- 套餐必须启用 `enable_realname_activation=true`
|
||||||
|
- 卡/设备当前未实名
|
||||||
|
|
||||||
|
### 注意事项
|
||||||
|
|
||||||
|
- 囤货套餐在实名前不会计算有效期
|
||||||
|
- 实名后,有效期从激活日期开始计算
|
||||||
|
- 如果卡/设备已实名,套餐会立即激活
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 场景二:主套餐排队
|
||||||
|
|
||||||
|
### 业务场景
|
||||||
|
|
||||||
|
用户当前有生效中的主套餐,想提前购买下一个套餐。
|
||||||
|
|
||||||
|
### 操作流程
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 用户购买新主套餐
|
||||||
|
2. 系统检测到已有生效中主套餐
|
||||||
|
3. 新套餐进入排队状态(status=0, priority=N+1)
|
||||||
|
4. 当前主套餐过期
|
||||||
|
5. 系统自动激活排队中的下一个套餐
|
||||||
|
```
|
||||||
|
|
||||||
|
### 排队规则
|
||||||
|
|
||||||
|
| 情况 | 新套餐状态 |
|
||||||
|
|------|-----------|
|
||||||
|
| 无生效中主套餐 | 立即激活(status=1, priority=1) |
|
||||||
|
| 有生效中主套餐 | 排队等待(status=0, priority=MAX+1) |
|
||||||
|
|
||||||
|
### 查看排队情况
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/h5/packages/my-usage
|
||||||
|
|
||||||
|
// 响应
|
||||||
|
{
|
||||||
|
"main_package": {
|
||||||
|
"package_name": "月度套餐",
|
||||||
|
"status": 1, // 生效中
|
||||||
|
"expires_at": "2025-03-31T23:59:59Z"
|
||||||
|
},
|
||||||
|
"queued_packages": [
|
||||||
|
{
|
||||||
|
"package_name": "季度套餐",
|
||||||
|
"status": 0, // 排队中
|
||||||
|
"priority": 2
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 场景三:加油包购买
|
||||||
|
|
||||||
|
### 业务场景
|
||||||
|
|
||||||
|
用户主套餐流量不够用,需要购买加油包补充流量。
|
||||||
|
|
||||||
|
### 操作流程
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 确认用户有生效中或待生效的主套餐
|
||||||
|
2. 用户选择加油包
|
||||||
|
3. 系统自动绑定到当前主套餐
|
||||||
|
4. 加油包立即生效
|
||||||
|
5. 流量扣减时优先使用加油包
|
||||||
|
```
|
||||||
|
|
||||||
|
### 购买限制
|
||||||
|
|
||||||
|
| 限制项 | 说明 |
|
||||||
|
|-------|------|
|
||||||
|
| 必须有主套餐 | 无主套餐无法购买加油包 |
|
||||||
|
| 混买禁止 | 同一订单不能同时购买主套餐和加油包 |
|
||||||
|
|
||||||
|
### 加油包生命周期
|
||||||
|
|
||||||
|
```
|
||||||
|
主套餐过期 → 加油包自动失效(status=4)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 加油包有效期
|
||||||
|
|
||||||
|
| 类型 | 有效期计算 |
|
||||||
|
|------|-----------|
|
||||||
|
| 随主套餐 | 与主套餐同时过期(has_independent_expiry=false) |
|
||||||
|
| 独立有效期 | 从购买日期开始计算(has_independent_expiry=true) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 场景四:流量查询
|
||||||
|
|
||||||
|
### 客户端查询我的流量
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/h5/packages/my-usage
|
||||||
|
Authorization: Bearer {token}
|
||||||
|
```
|
||||||
|
|
||||||
|
响应示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"main_package": {
|
||||||
|
"package_usage_id": 101,
|
||||||
|
"package_name": "月度套餐 30G",
|
||||||
|
"data_limit_mb": 30720,
|
||||||
|
"data_usage_mb": 15360,
|
||||||
|
"status": 1,
|
||||||
|
"activated_at": "2025-02-01T00:00:00Z",
|
||||||
|
"expires_at": "2025-02-28T23:59:59Z"
|
||||||
|
},
|
||||||
|
"addon_packages": [
|
||||||
|
{
|
||||||
|
"package_usage_id": 102,
|
||||||
|
"package_name": "加油包 5G",
|
||||||
|
"data_limit_mb": 5120,
|
||||||
|
"data_usage_mb": 2048,
|
||||||
|
"status": 1,
|
||||||
|
"priority": 2
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": {
|
||||||
|
"total_mb": 35840,
|
||||||
|
"used_mb": 17408,
|
||||||
|
"remaining_mb": 18432
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 后台查询套餐流量详单
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/admin/package-usage/101/daily-records?start_date=2025-02-01&end_date=2025-02-15
|
||||||
|
Authorization: Bearer {token}
|
||||||
|
```
|
||||||
|
|
||||||
|
响应示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"package_usage_id": 101,
|
||||||
|
"package_name": "月度套餐 30G",
|
||||||
|
"records": [
|
||||||
|
{
|
||||||
|
"date": "2025-02-01",
|
||||||
|
"daily_usage_mb": 1024,
|
||||||
|
"cumulative_usage_mb": 1024
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2025-02-02",
|
||||||
|
"daily_usage_mb": 512,
|
||||||
|
"cumulative_usage_mb": 1536
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total_usage_mb": 15360
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 套餐状态说明
|
||||||
|
|
||||||
|
| 状态码 | 名称 | 说明 |
|
||||||
|
|-------|------|------|
|
||||||
|
| 0 | 待生效 | 排队中或待实名激活 |
|
||||||
|
| 1 | 生效中 | 正在使用 |
|
||||||
|
| 2 | 已用完 | 流量已耗尽 |
|
||||||
|
| 3 | 已过期 | 超过有效期 |
|
||||||
|
| 4 | 已失效 | 主套餐过期导致加油包失效 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 流量重置说明
|
||||||
|
|
||||||
|
### 重置类型
|
||||||
|
|
||||||
|
| 类型 | 套餐类型 | 重置时间 | 适用场景 |
|
||||||
|
|------|---------|---------|---------|
|
||||||
|
| 日重置 | 所有 | 每天 00:00:00 | 日租卡 |
|
||||||
|
| 月重置 | 自然月 | 每月1号 00:00:00 | 自然月套餐 |
|
||||||
|
| 月重置 | 按天 | 从激活日起每30天 | 按天套餐 |
|
||||||
|
| 年重置 | 所有 | 每年1月1日 | 年度套餐 |
|
||||||
|
| 不重置 | 所有 | 不重置 | 一次性流量包 |
|
||||||
|
|
||||||
|
### 重置行为
|
||||||
|
|
||||||
|
```
|
||||||
|
重置前:data_usage_mb = 25600
|
||||||
|
重置后:data_usage_mb = 0
|
||||||
|
```
|
||||||
|
|
||||||
|
- 重置只清空已用流量,不影响有效期
|
||||||
|
- 流量用完的套餐(status=2)重置后恢复为生效中(status=1)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 停复机说明
|
||||||
|
|
||||||
|
### 停机条件
|
||||||
|
|
||||||
|
所有生效套餐流量用完:
|
||||||
|
- 主套餐 status=2
|
||||||
|
- 所有加油包 status=2
|
||||||
|
|
||||||
|
### 复机条件
|
||||||
|
|
||||||
|
- 购买新套餐
|
||||||
|
- 套餐流量重置
|
||||||
|
- 排队套餐激活
|
||||||
|
|
||||||
|
### 停机记录
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 停机记录在 tb_iot_card 表
|
||||||
|
stopped_at: 停机时间
|
||||||
|
stop_reason: 停机原因(如 "流量耗尽")
|
||||||
|
|
||||||
|
-- 复机后
|
||||||
|
resumed_at: 复机时间
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### Q: 为什么购买加油包提示"必须有主套餐"?
|
||||||
|
|
||||||
|
A: 加油包必须绑定到主套餐,请先购买主套餐再购买加油包。
|
||||||
|
|
||||||
|
### Q: 主套餐过期后加油包还能用吗?
|
||||||
|
|
||||||
|
A: 不能。主套餐过期后,绑定的加油包会自动失效(status=4)。
|
||||||
|
|
||||||
|
### Q: 套餐排队后可以取消吗?
|
||||||
|
|
||||||
|
A: 目前不支持取消排队中的套餐,请联系客服处理。
|
||||||
|
|
||||||
|
### Q: 流量重置后为什么还是停机状态?
|
||||||
|
|
||||||
|
A: 流量重置后系统会自动触发复机,如果仍是停机状态,请检查运营商接口是否正常。
|
||||||
|
|
||||||
|
### Q: H5 端未实名用户如何购买套餐?
|
||||||
|
|
||||||
|
A: H5 端必须先完成实名认证才能购买套餐。代理商可在后台为未实名用户囤货。
|
||||||
183
docs/package-system-upgrade/功能总结.md
Normal file
183
docs/package-system-upgrade/功能总结.md
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
# 套餐系统升级 - 功能总结
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
本次升级实现了完整的套餐生命周期管理,支持主套餐排队激活、加油包绑定主套餐、囤货待实名激活、流量按优先级扣减等核心功能。
|
||||||
|
|
||||||
|
## 核心功能
|
||||||
|
|
||||||
|
### 1. 套餐有效期计算
|
||||||
|
|
||||||
|
| 类型 | 计算方式 | 示例 |
|
||||||
|
|------|---------|------|
|
||||||
|
| 自然月 | 激活月份 + N 个月,月末 23:59:59 | 2月15日激活3个月 → 5月31日 23:59:59 过期 |
|
||||||
|
| 按天 | 激活日期 + N 天,当天 23:59:59 | 2月15日激活30天 → 3月16日 23:59:59 过期 |
|
||||||
|
|
||||||
|
### 2. 主套餐排队机制
|
||||||
|
|
||||||
|
```
|
||||||
|
卡/设备 购买主套餐 A → 立即激活(status=1, priority=1)
|
||||||
|
购买主套餐 B → 排队等待(status=0, priority=2)
|
||||||
|
主套餐 A 过期 → 自动激活主套餐 B
|
||||||
|
```
|
||||||
|
|
||||||
|
- 同一卡/设备同时只能有一个生效中的主套餐
|
||||||
|
- 新购买的主套餐自动进入排队状态
|
||||||
|
- 过期检查每 10 秒执行一次
|
||||||
|
|
||||||
|
### 3. 加油包绑定主套餐
|
||||||
|
|
||||||
|
```
|
||||||
|
加油包必须绑定到当前生效的主套餐(master_usage_id)
|
||||||
|
├── 加油包与主套餐同时生效
|
||||||
|
├── 主套餐过期时,加油包自动失效(status=4)
|
||||||
|
└── 流量扣减时,先扣加油包,再扣主套餐
|
||||||
|
```
|
||||||
|
|
||||||
|
- 购买加油包时必须有生效中或待生效的主套餐
|
||||||
|
- 加油包可设置独立有效期(`has_independent_expiry=true`)
|
||||||
|
|
||||||
|
### 4. 囤货待实名激活
|
||||||
|
|
||||||
|
```
|
||||||
|
后台为未实名卡/设备购买套餐
|
||||||
|
├── 套餐 status=0, pending_realname_activation=true
|
||||||
|
├── 用户完成实名
|
||||||
|
├── 轮询系统检测到实名状态变更
|
||||||
|
└── 自动激活套餐(status=1)
|
||||||
|
```
|
||||||
|
|
||||||
|
- 仅当套餐 `enable_realname_activation=true` 时触发此机制
|
||||||
|
- H5 端未实名用户无法直接购买套餐
|
||||||
|
|
||||||
|
### 5. 流量扣减优先级
|
||||||
|
|
||||||
|
扣减顺序:**加油包(按 priority ASC)→ 主套餐**
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 示例:卡有 3 个生效套餐
|
||||||
|
主套餐:1000MB,已用 500MB
|
||||||
|
加油包1:100MB,已用 0MB,priority=2
|
||||||
|
加油包2:200MB,已用 50MB,priority=3
|
||||||
|
|
||||||
|
// 本次使用 180MB
|
||||||
|
扣减顺序:
|
||||||
|
1. 加油包1 扣 100MB(用完,status=2)
|
||||||
|
2. 加油包2 扣 80MB
|
||||||
|
3. 主套餐不变
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. 流量重置周期
|
||||||
|
|
||||||
|
| 周期 | 套餐类型 | 重置时间 | 说明 |
|
||||||
|
|------|---------|---------|------|
|
||||||
|
| 日重置 | 所有 | 每天 00:00:00 | `data_reset_cycle=daily` |
|
||||||
|
| 月重置 | 自然月 | 每月1号 00:00:00 | `calendar_type=natural_month` |
|
||||||
|
| 月重置 | 按天 | 从激活日期起每30天 | `calendar_type=by_day` |
|
||||||
|
| 年重置 | 所有 | 每年1月1日 00:00:00 | `data_reset_cycle=yearly` |
|
||||||
|
|
||||||
|
### 7. 停复机机制
|
||||||
|
|
||||||
|
- **停机条件**:所有生效套餐流量用完(主套餐 + 所有加油包 status=2)
|
||||||
|
- **复机条件**:购买新套餐或套餐激活后自动复机
|
||||||
|
|
||||||
|
## 数据库变更
|
||||||
|
|
||||||
|
### 新增表
|
||||||
|
|
||||||
|
| 表名 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `tb_package_usage_daily_record` | 套餐流量日记录 |
|
||||||
|
| `tb_card_daily_usage` | 卡每日流量使用汇总 |
|
||||||
|
|
||||||
|
### 扩展字段
|
||||||
|
|
||||||
|
**tb_package 表**:
|
||||||
|
- `calendar_type`: 有效期类型(natural_month/by_day)
|
||||||
|
- `data_reset_cycle`: 流量重置周期(daily/monthly/yearly/none)
|
||||||
|
- `enable_realname_activation`: 是否需要实名后激活
|
||||||
|
- `duration_days`: 按天套餐的有效天数
|
||||||
|
|
||||||
|
**tb_package_usage 表**:
|
||||||
|
- `priority`: 套餐优先级
|
||||||
|
- `master_usage_id`: 主套餐 ID(加油包使用)
|
||||||
|
- `has_independent_expiry`: 加油包是否有独立有效期
|
||||||
|
- `pending_realname_activation`: 是否待实名激活
|
||||||
|
- `data_reset_cycle`: 流量重置周期
|
||||||
|
- `last_reset_at`: 上次重置时间
|
||||||
|
- `next_reset_at`: 下次重置时间
|
||||||
|
|
||||||
|
**tb_iot_card 表**:
|
||||||
|
- `stopped_at`: 停机时间
|
||||||
|
- `resumed_at`: 复机时间
|
||||||
|
- `stop_reason`: 停机原因
|
||||||
|
|
||||||
|
**tb_carrier 表**:
|
||||||
|
- `billing_day`: 运营商计费日(用于流量查询接口的计费周期计算,联通=27,其他=1)
|
||||||
|
|
||||||
|
## API 端点
|
||||||
|
|
||||||
|
### 新增端点
|
||||||
|
|
||||||
|
| 端点 | 方法 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `/api/h5/packages/my-usage` | GET | 客户端查询我的流量使用情况 |
|
||||||
|
| `/api/admin/package-usage/:id/daily-records` | GET | 查询套餐流量详单 |
|
||||||
|
|
||||||
|
### 扩展端点
|
||||||
|
|
||||||
|
套餐管理 API 支持新字段:
|
||||||
|
- `calendar_type`: 有效期类型
|
||||||
|
- `duration_days`: 有效天数
|
||||||
|
- `data_reset_cycle`: 重置周期
|
||||||
|
- `enable_realname_activation`: 实名激活开关
|
||||||
|
|
||||||
|
## 轮询任务
|
||||||
|
|
||||||
|
| 任务 | 调度频率 | 说明 |
|
||||||
|
|------|---------|------|
|
||||||
|
| 套餐激活检查 | 每 10 秒 | 检查过期主套餐,激活排队套餐 |
|
||||||
|
| 流量重置调度 | 每 10 秒 | 执行日/月/年流量重置 |
|
||||||
|
| 实名状态检查 | 配置化 | 检测首次实名,触发套餐激活 |
|
||||||
|
|
||||||
|
## Asynq 任务
|
||||||
|
|
||||||
|
| 任务类型 | 说明 |
|
||||||
|
|---------|------|
|
||||||
|
| `task:package:first_activation` | 首次实名激活套餐 |
|
||||||
|
| `task:package:queue_activation` | 排队主套餐激活 |
|
||||||
|
|
||||||
|
## 错误码
|
||||||
|
|
||||||
|
| 错误码 | 说明 |
|
||||||
|
|-------|------|
|
||||||
|
| `CodePackageActivationConflict` | 套餐正在激活中 |
|
||||||
|
| `CodeNoMainPackage` | 必须有主套餐才能购买加油包 |
|
||||||
|
| `CodeRealnameRequired` | 必须先完成实名认证才能购买套餐 |
|
||||||
|
| `CodeMixedOrderForbidden` | 同订单不能同时购买正式套餐和加油包 |
|
||||||
|
|
||||||
|
## 技术实现
|
||||||
|
|
||||||
|
### Service 层
|
||||||
|
|
||||||
|
| 服务 | 文件 | 职责 |
|
||||||
|
|------|------|------|
|
||||||
|
| ActivationService | `activation_service.go` | 套餐激活(实名激活、排队激活) |
|
||||||
|
| UsageService | `usage_service.go` | 流量扣减、停机检查 |
|
||||||
|
| ResetService | `reset_service.go` | 流量重置(日/月/年) |
|
||||||
|
| CustomerViewService | `customer_view_service.go` | 客户端流量查询 |
|
||||||
|
| DailyRecordService | `daily_record_service.go` | 套餐流量详单 |
|
||||||
|
| StopResumeService | `stop_resume_service.go` | 停复机操作 |
|
||||||
|
|
||||||
|
### 工具函数
|
||||||
|
|
||||||
|
| 函数 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `CalculateExpiryTime()` | 计算套餐过期时间 |
|
||||||
|
| `CalculateNextResetTime()` | 计算下次重置时间 |
|
||||||
|
|
||||||
|
## 性能优化
|
||||||
|
|
||||||
|
- 流量重置分批处理:每批最多 10000 条
|
||||||
|
- 使用 Redis 分布式锁避免套餐激活并发问题
|
||||||
|
- Asynq 任务重试策略:MaxRetry(3), Timeout(30s)
|
||||||
279
docs/package-system-upgrade/运维指南.md
Normal file
279
docs/package-system-upgrade/运维指南.md
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
# 套餐系统升级 - 运维指南
|
||||||
|
|
||||||
|
## 监控指标
|
||||||
|
|
||||||
|
### Asynq 队列监控
|
||||||
|
|
||||||
|
| 指标 | 说明 | 正常范围 | 告警阈值 |
|
||||||
|
|------|------|---------|---------|
|
||||||
|
| `asynq_queue_size{queue="default"}` | 默认队列长度 | < 100 | > 1000 |
|
||||||
|
| `asynq_queue_latency_seconds` | 任务处理延迟 | < 5s | > 30s |
|
||||||
|
| `asynq_processed_total` | 已处理任务数 | 持续增长 | - |
|
||||||
|
| `asynq_failed_total` | 失败任务数 | 接近 0 | > 10/min |
|
||||||
|
|
||||||
|
### 套餐激活监控
|
||||||
|
|
||||||
|
| 指标 | 说明 | 正常范围 | 告警阈值 |
|
||||||
|
|------|------|---------|---------|
|
||||||
|
| 排队套餐激活延迟 | 主套餐过期到下一个激活的时间 | < 30s | > 1min |
|
||||||
|
| 实名激活延迟 | 实名完成到套餐激活的时间 | < 30s | > 1min |
|
||||||
|
| 待激活套餐堆积 | `status=0` 的套餐数量 | 正常波动 | 持续增长 |
|
||||||
|
|
||||||
|
### API 性能监控
|
||||||
|
|
||||||
|
| 指标 | 端点 | 正常范围 | 告警阈值 |
|
||||||
|
|------|------|---------|---------|
|
||||||
|
| 响应时间 P95 | `/api/h5/packages/my-usage` | < 100ms | > 200ms |
|
||||||
|
| 响应时间 P99 | `/api/h5/packages/my-usage` | < 200ms | > 500ms |
|
||||||
|
| 响应时间 P95 | `/api/admin/package-usage/:id/daily-records` | < 150ms | > 300ms |
|
||||||
|
|
||||||
|
### 数据库监控
|
||||||
|
|
||||||
|
| 指标 | 说明 | 正常范围 | 告警阈值 |
|
||||||
|
|------|------|---------|---------|
|
||||||
|
| 流量重置执行时间 | 单批次重置耗时 | < 5s | > 10s |
|
||||||
|
| 套餐表行数增长 | `tb_package_usage` 每日新增 | 正常波动 | 异常增长 |
|
||||||
|
| 日记录表行数 | `tb_package_usage_daily_record` | 正常增长 | - |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 告警规则
|
||||||
|
|
||||||
|
### Prometheus 告警规则示例
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
groups:
|
||||||
|
- name: package_system_alerts
|
||||||
|
rules:
|
||||||
|
# 套餐激活延迟告警
|
||||||
|
- alert: PackageActivationDelayHigh
|
||||||
|
expr: histogram_quantile(0.95, rate(package_activation_duration_seconds_bucket[5m])) > 60
|
||||||
|
for: 5m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
annotations:
|
||||||
|
summary: "套餐激活延迟过高"
|
||||||
|
description: "套餐激活 P95 延迟超过 1 分钟,当前值: {{ $value }}s"
|
||||||
|
|
||||||
|
# Asynq 队列堆积告警
|
||||||
|
- alert: AsynqQueueBacklog
|
||||||
|
expr: asynq_queue_size{queue="default"} > 1000
|
||||||
|
for: 5m
|
||||||
|
labels:
|
||||||
|
severity: critical
|
||||||
|
annotations:
|
||||||
|
summary: "Asynq 任务队列堆积"
|
||||||
|
description: "默认队列任务数超过 1000,当前值: {{ $value }}"
|
||||||
|
|
||||||
|
# 任务失败率告警
|
||||||
|
- alert: AsynqTaskFailureRateHigh
|
||||||
|
expr: rate(asynq_failed_total[5m]) > 0.1
|
||||||
|
for: 5m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
annotations:
|
||||||
|
summary: "Asynq 任务失败率过高"
|
||||||
|
description: "任务失败率超过 10%,当前值: {{ $value }}/s"
|
||||||
|
|
||||||
|
# API 响应时间告警
|
||||||
|
- alert: PackageAPILatencyHigh
|
||||||
|
expr: histogram_quantile(0.95, rate(http_request_duration_seconds_bucket{path=~"/api/h5/packages.*"}[5m])) > 0.2
|
||||||
|
for: 5m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
annotations:
|
||||||
|
summary: "套餐 API 响应时间过高"
|
||||||
|
description: "套餐相关 API P95 响应时间超过 200ms"
|
||||||
|
|
||||||
|
# 流量重置执行时间告警
|
||||||
|
- alert: DataResetDurationHigh
|
||||||
|
expr: package_data_reset_duration_seconds > 10
|
||||||
|
for: 1m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
annotations:
|
||||||
|
summary: "流量重置执行时间过长"
|
||||||
|
description: "流量重置批次执行时间超过 10 秒"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 回滚预案
|
||||||
|
|
||||||
|
### 场景一:代码回滚
|
||||||
|
|
||||||
|
**触发条件**:
|
||||||
|
- API 接口异常
|
||||||
|
- 业务逻辑错误
|
||||||
|
- 性能严重下降
|
||||||
|
|
||||||
|
**回滚步骤**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 切换到上一个稳定版本
|
||||||
|
git checkout <上一个稳定版本 tag>
|
||||||
|
|
||||||
|
# 2. 重新构建镜像
|
||||||
|
make build-docker
|
||||||
|
|
||||||
|
# 3. 重新部署
|
||||||
|
kubectl rollout restart deployment/cmp-api
|
||||||
|
kubectl rollout restart deployment/cmp-worker
|
||||||
|
|
||||||
|
# 4. 验证服务正常
|
||||||
|
curl -s http://api-host/health | jq
|
||||||
|
```
|
||||||
|
|
||||||
|
**注意事项**:
|
||||||
|
- 代码回滚不会回滚数据库迁移
|
||||||
|
- 需要确保旧代码兼容新数据库结构
|
||||||
|
- 新增字段使用默认值,不影响旧代码运行
|
||||||
|
|
||||||
|
### 场景二:数据库回滚
|
||||||
|
|
||||||
|
**触发条件**:
|
||||||
|
- 迁移脚本有问题
|
||||||
|
- 数据损坏
|
||||||
|
- 需要完全撤销功能
|
||||||
|
|
||||||
|
**前置条件**:
|
||||||
|
- 确认已备份数据库
|
||||||
|
- 确认代码已回滚到兼容版本
|
||||||
|
|
||||||
|
**回滚步骤**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 停止 API 和 Worker 服务
|
||||||
|
kubectl scale deployment/cmp-api --replicas=0
|
||||||
|
kubectl scale deployment/cmp-worker --replicas=0
|
||||||
|
|
||||||
|
# 2. 执行数据库回滚
|
||||||
|
make migrate-down STEPS=1
|
||||||
|
|
||||||
|
# 3. 验证数据库结构
|
||||||
|
psql -h $DB_HOST -U $DB_USER -d $DB_NAME -c "\d tb_package"
|
||||||
|
psql -h $DB_HOST -U $DB_USER -d $DB_NAME -c "\d tb_package_usage"
|
||||||
|
|
||||||
|
# 4. 重新启动服务
|
||||||
|
kubectl scale deployment/cmp-api --replicas=3
|
||||||
|
kubectl scale deployment/cmp-worker --replicas=2
|
||||||
|
```
|
||||||
|
|
||||||
|
**回滚脚本位置**:
|
||||||
|
`migrations/000055_package_system_upgrade.down.sql`
|
||||||
|
|
||||||
|
### 场景三:数据修复
|
||||||
|
|
||||||
|
**情况 1:套餐状态异常**
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 查找状态异常的套餐
|
||||||
|
SELECT id, status, activated_at, expires_at
|
||||||
|
FROM tb_package_usage
|
||||||
|
WHERE status = 1 AND expires_at < NOW();
|
||||||
|
|
||||||
|
-- 修复:将过期套餐标记为已过期
|
||||||
|
UPDATE tb_package_usage
|
||||||
|
SET status = 3, updated_at = NOW()
|
||||||
|
WHERE status = 1 AND expires_at < NOW();
|
||||||
|
```
|
||||||
|
|
||||||
|
**情况 2:加油包未正确失效**
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 查找主套餐已过期但加油包仍生效的记录
|
||||||
|
SELECT pu.id, pu.status, pu.master_usage_id, master.status as master_status
|
||||||
|
FROM tb_package_usage pu
|
||||||
|
JOIN tb_package_usage master ON pu.master_usage_id = master.id
|
||||||
|
WHERE pu.status = 1 AND master.status = 3;
|
||||||
|
|
||||||
|
-- 修复:将这些加油包标记为失效
|
||||||
|
UPDATE tb_package_usage
|
||||||
|
SET status = 4, updated_at = NOW()
|
||||||
|
WHERE id IN (
|
||||||
|
SELECT pu.id
|
||||||
|
FROM tb_package_usage pu
|
||||||
|
JOIN tb_package_usage master ON pu.master_usage_id = master.id
|
||||||
|
WHERE pu.status = 1 AND master.status = 3
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**情况 3:流量重置时间错误**
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 查找下次重置时间异常的套餐
|
||||||
|
SELECT id, data_reset_cycle, next_reset_at
|
||||||
|
FROM tb_package_usage
|
||||||
|
WHERE data_reset_cycle = 'daily' AND next_reset_at < NOW() - INTERVAL '1 day';
|
||||||
|
|
||||||
|
-- 修复:重新计算下次重置时间
|
||||||
|
UPDATE tb_package_usage
|
||||||
|
SET next_reset_at = DATE_TRUNC('day', NOW()) + INTERVAL '1 day',
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE data_reset_cycle = 'daily' AND next_reset_at < NOW() - INTERVAL '1 day';
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 日常运维
|
||||||
|
|
||||||
|
### 手动触发流量重置
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 通过 API 触发
|
||||||
|
curl -X POST http://api-host/api/admin/internal/trigger-data-reset \
|
||||||
|
-H "Authorization: Bearer $ADMIN_TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 查看 Asynq 队列状态
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 查看队列概览
|
||||||
|
asynq stats
|
||||||
|
|
||||||
|
# 查看待处理任务
|
||||||
|
asynq list pending
|
||||||
|
|
||||||
|
# 查看失败任务
|
||||||
|
asynq list archived
|
||||||
|
```
|
||||||
|
|
||||||
|
### 重试失败任务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 重试所有失败任务
|
||||||
|
asynq task run archived --all
|
||||||
|
|
||||||
|
# 重试特定任务
|
||||||
|
asynq task run archived --id=<task_id>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 容量规划
|
||||||
|
|
||||||
|
### 数据增长预估
|
||||||
|
|
||||||
|
| 表 | 每日增量 | 月增量 | 年增量 |
|
||||||
|
|----|---------|--------|--------|
|
||||||
|
| `tb_package_usage` | ~1000 行 | ~30000 行 | ~360000 行 |
|
||||||
|
| `tb_package_usage_daily_record` | ~10000 行 | ~300000 行 | ~3600000 行 |
|
||||||
|
| `tb_card_daily_usage` | ~10000 行 | ~300000 行 | ~3600000 行 |
|
||||||
|
|
||||||
|
### 存储预估
|
||||||
|
|
||||||
|
| 表 | 单行大小 | 年存储量 |
|
||||||
|
|----|---------|---------|
|
||||||
|
| `tb_package_usage_daily_record` | ~100 bytes | ~360 MB |
|
||||||
|
| `tb_card_daily_usage` | ~80 bytes | ~288 MB |
|
||||||
|
|
||||||
|
### 清理策略
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 清理 180 天前的日记录(可选)
|
||||||
|
DELETE FROM tb_package_usage_daily_record
|
||||||
|
WHERE date < NOW() - INTERVAL '180 days';
|
||||||
|
|
||||||
|
DELETE FROM tb_card_daily_usage
|
||||||
|
WHERE usage_date < NOW() - INTERVAL '180 days';
|
||||||
|
```
|
||||||
@@ -140,7 +140,7 @@ func initServices(s *stores, deps *Dependencies) *services {
|
|||||||
ShopPackageBatchPricing: shopPackageBatchPricingSvc.New(deps.DB, s.ShopPackageAllocation, s.ShopPackageAllocationPriceHistory, s.Shop),
|
ShopPackageBatchPricing: shopPackageBatchPricingSvc.New(deps.DB, s.ShopPackageAllocation, s.ShopPackageAllocationPriceHistory, s.Shop),
|
||||||
CommissionStats: commissionStatsSvc.New(s.ShopSeriesCommissionStats),
|
CommissionStats: commissionStatsSvc.New(s.ShopSeriesCommissionStats),
|
||||||
PurchaseValidation: purchaseValidation,
|
PurchaseValidation: purchaseValidation,
|
||||||
Order: orderSvc.New(deps.DB, s.Order, s.OrderItem, s.Wallet, purchaseValidation, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.IotCard, s.Device, s.PackageSeries, s.PackageUsage, s.Package, deps.WechatPayment, deps.QueueClient, deps.Logger),
|
Order: orderSvc.New(deps.DB, deps.Redis, s.Order, s.OrderItem, s.Wallet, purchaseValidation, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.IotCard, s.Device, s.PackageSeries, s.PackageUsage, s.Package, deps.WechatPayment, deps.QueueClient, deps.Logger),
|
||||||
Recharge: rechargeSvc.New(deps.DB, s.Recharge, s.Wallet, s.WalletTransaction, s.IotCard, s.Device, s.ShopSeriesAllocation, s.PackageSeries, s.CommissionRecord, deps.Logger),
|
Recharge: rechargeSvc.New(deps.DB, s.Recharge, s.Wallet, s.WalletTransaction, s.IotCard, s.Device, s.ShopSeriesAllocation, s.PackageSeries, s.CommissionRecord, deps.Logger),
|
||||||
PollingConfig: pollingSvc.NewConfigService(s.PollingConfig),
|
PollingConfig: pollingSvc.NewConfigService(s.PollingConfig),
|
||||||
PollingConcurrency: pollingSvc.NewConcurrencyService(s.PollingConcurrencyConfig, deps.Redis),
|
PollingConcurrency: pollingSvc.NewConcurrencyService(s.PollingConcurrencyConfig, deps.Redis),
|
||||||
|
|||||||
56
internal/bootstrap/worker.go
Normal file
56
internal/bootstrap/worker.go
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
package bootstrap
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/hibiken/asynq"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/gateway"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/queue"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WorkerDependencies Worker 进程的基础依赖
|
||||||
|
type WorkerDependencies struct {
|
||||||
|
DB *gorm.DB
|
||||||
|
Redis *redis.Client
|
||||||
|
Logger *zap.Logger
|
||||||
|
AsynqClient *asynq.Client // Worker 特有:用于 Scheduler 提交任务
|
||||||
|
StorageService *storage.Service // 对象存储(可选)
|
||||||
|
GatewayClient *gateway.Client // Gateway 客户端(可选)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WorkerBootstrapResult Worker Bootstrap 初始化结果
|
||||||
|
type WorkerBootstrapResult = queue.WorkerBootstrapResult
|
||||||
|
|
||||||
|
// WorkerStores 导出的 Worker Store 集合
|
||||||
|
type WorkerStores = queue.WorkerStores
|
||||||
|
|
||||||
|
// WorkerServices 导出的 Worker 服务集合
|
||||||
|
type WorkerServices = queue.WorkerServices
|
||||||
|
|
||||||
|
// BootstrapWorker 初始化 Worker 进程的所有组件
|
||||||
|
//
|
||||||
|
// 初始化顺序:
|
||||||
|
// 1. 初始化 Worker Store 层(数据访问)
|
||||||
|
// 2. 初始化 Worker Service 层(业务逻辑)
|
||||||
|
//
|
||||||
|
// 参数:
|
||||||
|
// - deps: Worker 基础依赖(DB, Redis, Logger, AsynqClient, StorageService, GatewayClient)
|
||||||
|
//
|
||||||
|
// 返回:
|
||||||
|
// - *WorkerBootstrapResult: 包含 Stores 和 Services
|
||||||
|
// - error: 初始化错误
|
||||||
|
func BootstrapWorker(deps *WorkerDependencies) (*WorkerBootstrapResult, error) {
|
||||||
|
// 1. 初始化 Worker Store 层
|
||||||
|
stores := initWorkerStores(deps)
|
||||||
|
|
||||||
|
// 2. 初始化 Worker Service 层
|
||||||
|
services := initWorkerServices(stores, deps)
|
||||||
|
|
||||||
|
return &WorkerBootstrapResult{
|
||||||
|
Stores: stores,
|
||||||
|
Services: services,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
89
internal/bootstrap/worker_services.go
Normal file
89
internal/bootstrap/worker_services.go
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
package bootstrap
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/service/commission_calculation"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/service/commission_stats"
|
||||||
|
packagepkg "github.com/break/junhong_cmp_fiber/internal/service/package"
|
||||||
|
pollingSvc "github.com/break/junhong_cmp_fiber/internal/service/polling"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/queue"
|
||||||
|
)
|
||||||
|
|
||||||
|
type workerServices struct {
|
||||||
|
CommissionCalculation *commission_calculation.Service
|
||||||
|
CommissionStats *commission_stats.Service
|
||||||
|
UsageService *packagepkg.UsageService
|
||||||
|
ActivationService *packagepkg.ActivationService
|
||||||
|
ResetService *packagepkg.ResetService
|
||||||
|
AlertService *pollingSvc.AlertService
|
||||||
|
CleanupService *pollingSvc.CleanupService
|
||||||
|
}
|
||||||
|
|
||||||
|
func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *queue.WorkerServices {
|
||||||
|
commissionStatsService := commission_stats.New(stores.ShopSeriesCommissionStats)
|
||||||
|
|
||||||
|
commissionCalculationService := commission_calculation.New(
|
||||||
|
deps.DB,
|
||||||
|
stores.CommissionRecord,
|
||||||
|
stores.Shop,
|
||||||
|
stores.ShopPackageAllocation,
|
||||||
|
stores.ShopSeriesAllocation,
|
||||||
|
stores.PackageSeries,
|
||||||
|
stores.IotCard,
|
||||||
|
stores.Device,
|
||||||
|
stores.Wallet,
|
||||||
|
stores.WalletTransaction,
|
||||||
|
stores.Order,
|
||||||
|
stores.OrderItem,
|
||||||
|
stores.Package,
|
||||||
|
stores.ShopSeriesCommissionStats,
|
||||||
|
commissionStatsService,
|
||||||
|
deps.Logger,
|
||||||
|
)
|
||||||
|
|
||||||
|
usageService := packagepkg.NewUsageService(
|
||||||
|
deps.DB,
|
||||||
|
deps.Redis,
|
||||||
|
stores.PackageUsage,
|
||||||
|
stores.PackageUsageDailyRecord,
|
||||||
|
deps.Logger,
|
||||||
|
)
|
||||||
|
|
||||||
|
activationService := packagepkg.NewActivationService(
|
||||||
|
deps.DB,
|
||||||
|
deps.Redis,
|
||||||
|
stores.PackageUsage,
|
||||||
|
stores.Package,
|
||||||
|
stores.PackageUsageDailyRecord,
|
||||||
|
deps.Logger,
|
||||||
|
)
|
||||||
|
|
||||||
|
resetService := packagepkg.NewResetService(
|
||||||
|
deps.DB,
|
||||||
|
deps.Redis,
|
||||||
|
stores.PackageUsage,
|
||||||
|
deps.Logger,
|
||||||
|
)
|
||||||
|
|
||||||
|
alertService := pollingSvc.NewAlertService(
|
||||||
|
stores.PollingAlertRule,
|
||||||
|
stores.PollingAlertHistory,
|
||||||
|
deps.Redis,
|
||||||
|
deps.Logger,
|
||||||
|
)
|
||||||
|
|
||||||
|
cleanupService := pollingSvc.NewCleanupService(
|
||||||
|
stores.DataCleanupConfig,
|
||||||
|
stores.DataCleanupLog,
|
||||||
|
deps.Logger,
|
||||||
|
)
|
||||||
|
|
||||||
|
return &queue.WorkerServices{
|
||||||
|
CommissionCalculation: commissionCalculationService,
|
||||||
|
CommissionStats: commissionStatsService,
|
||||||
|
UsageService: usageService,
|
||||||
|
ActivationService: activationService,
|
||||||
|
ResetService: resetService,
|
||||||
|
AlertService: alertService,
|
||||||
|
CleanupService: cleanupService,
|
||||||
|
}
|
||||||
|
}
|
||||||
83
internal/bootstrap/worker_stores.go
Normal file
83
internal/bootstrap/worker_stores.go
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
package bootstrap
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/queue"
|
||||||
|
)
|
||||||
|
|
||||||
|
type workerStores struct {
|
||||||
|
IotCardImportTask *postgres.IotCardImportTaskStore
|
||||||
|
IotCard *postgres.IotCardStore
|
||||||
|
DeviceImportTask *postgres.DeviceImportTaskStore
|
||||||
|
Device *postgres.DeviceStore
|
||||||
|
DeviceSimBinding *postgres.DeviceSimBindingStore
|
||||||
|
ShopSeriesCommissionStats *postgres.ShopSeriesCommissionStatsStore
|
||||||
|
ShopPackageAllocation *postgres.ShopPackageAllocationStore
|
||||||
|
CommissionRecord *postgres.CommissionRecordStore
|
||||||
|
Shop *postgres.ShopStore
|
||||||
|
ShopSeriesAllocation *postgres.ShopSeriesAllocationStore
|
||||||
|
PackageSeries *postgres.PackageSeriesStore
|
||||||
|
Wallet *postgres.WalletStore
|
||||||
|
WalletTransaction *postgres.WalletTransactionStore
|
||||||
|
Order *postgres.OrderStore
|
||||||
|
OrderItem *postgres.OrderItemStore
|
||||||
|
Package *postgres.PackageStore
|
||||||
|
PackageUsage *postgres.PackageUsageStore
|
||||||
|
PackageUsageDailyRecord *postgres.PackageUsageDailyRecordStore
|
||||||
|
PollingAlertRule *postgres.PollingAlertRuleStore
|
||||||
|
PollingAlertHistory *postgres.PollingAlertHistoryStore
|
||||||
|
DataCleanupConfig *postgres.DataCleanupConfigStore
|
||||||
|
DataCleanupLog *postgres.DataCleanupLogStore
|
||||||
|
}
|
||||||
|
|
||||||
|
func initWorkerStores(deps *WorkerDependencies) *queue.WorkerStores {
|
||||||
|
stores := &workerStores{
|
||||||
|
IotCardImportTask: postgres.NewIotCardImportTaskStore(deps.DB, deps.Redis),
|
||||||
|
IotCard: postgres.NewIotCardStore(deps.DB, deps.Redis),
|
||||||
|
DeviceImportTask: postgres.NewDeviceImportTaskStore(deps.DB, deps.Redis),
|
||||||
|
Device: postgres.NewDeviceStore(deps.DB, deps.Redis),
|
||||||
|
DeviceSimBinding: postgres.NewDeviceSimBindingStore(deps.DB, deps.Redis),
|
||||||
|
ShopSeriesCommissionStats: postgres.NewShopSeriesCommissionStatsStore(deps.DB),
|
||||||
|
ShopPackageAllocation: postgres.NewShopPackageAllocationStore(deps.DB),
|
||||||
|
CommissionRecord: postgres.NewCommissionRecordStore(deps.DB, deps.Redis),
|
||||||
|
Shop: postgres.NewShopStore(deps.DB, deps.Redis),
|
||||||
|
ShopSeriesAllocation: postgres.NewShopSeriesAllocationStore(deps.DB),
|
||||||
|
PackageSeries: postgres.NewPackageSeriesStore(deps.DB),
|
||||||
|
Wallet: postgres.NewWalletStore(deps.DB, deps.Redis),
|
||||||
|
WalletTransaction: postgres.NewWalletTransactionStore(deps.DB, deps.Redis),
|
||||||
|
Order: postgres.NewOrderStore(deps.DB, deps.Redis),
|
||||||
|
OrderItem: postgres.NewOrderItemStore(deps.DB, deps.Redis),
|
||||||
|
Package: postgres.NewPackageStore(deps.DB),
|
||||||
|
PackageUsage: postgres.NewPackageUsageStore(deps.DB, deps.Redis),
|
||||||
|
PackageUsageDailyRecord: postgres.NewPackageUsageDailyRecordStore(deps.DB, deps.Redis),
|
||||||
|
PollingAlertRule: postgres.NewPollingAlertRuleStore(deps.DB),
|
||||||
|
PollingAlertHistory: postgres.NewPollingAlertHistoryStore(deps.DB),
|
||||||
|
DataCleanupConfig: postgres.NewDataCleanupConfigStore(deps.DB),
|
||||||
|
DataCleanupLog: postgres.NewDataCleanupLogStore(deps.DB),
|
||||||
|
}
|
||||||
|
|
||||||
|
return &queue.WorkerStores{
|
||||||
|
IotCardImportTask: stores.IotCardImportTask,
|
||||||
|
IotCard: stores.IotCard,
|
||||||
|
DeviceImportTask: stores.DeviceImportTask,
|
||||||
|
Device: stores.Device,
|
||||||
|
DeviceSimBinding: stores.DeviceSimBinding,
|
||||||
|
ShopSeriesCommissionStats: stores.ShopSeriesCommissionStats,
|
||||||
|
ShopPackageAllocation: stores.ShopPackageAllocation,
|
||||||
|
CommissionRecord: stores.CommissionRecord,
|
||||||
|
Shop: stores.Shop,
|
||||||
|
ShopSeriesAllocation: stores.ShopSeriesAllocation,
|
||||||
|
PackageSeries: stores.PackageSeries,
|
||||||
|
Wallet: stores.Wallet,
|
||||||
|
WalletTransaction: stores.WalletTransaction,
|
||||||
|
Order: stores.Order,
|
||||||
|
OrderItem: stores.OrderItem,
|
||||||
|
Package: stores.Package,
|
||||||
|
PackageUsage: stores.PackageUsage,
|
||||||
|
PackageUsageDailyRecord: stores.PackageUsageDailyRecord,
|
||||||
|
PollingAlertRule: stores.PollingAlertRule,
|
||||||
|
PollingAlertHistory: stores.PollingAlertHistory,
|
||||||
|
DataCleanupConfig: stores.DataCleanupConfig,
|
||||||
|
DataCleanupLog: stores.DataCleanupLog,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ type Carrier struct {
|
|||||||
CarrierType string `gorm:"column:carrier_type;type:varchar(20);not null;default:'CMCC';comment:运营商类型(CMCC/CUCC/CTCC/CBN)" json:"carrier_type"`
|
CarrierType string `gorm:"column:carrier_type;type:varchar(20);not null;default:'CMCC';comment:运营商类型(CMCC/CUCC/CTCC/CBN)" json:"carrier_type"`
|
||||||
Description string `gorm:"column:description;type:varchar(500);comment:运营商描述" json:"description"`
|
Description string `gorm:"column:description;type:varchar(500);comment:运营商描述" json:"description"`
|
||||||
Status int `gorm:"column:status;type:int;default:1;comment:状态 1-启用 0-禁用" json:"status"`
|
Status int `gorm:"column:status;type:int;default:1;comment:状态 1-启用 0-禁用" json:"status"`
|
||||||
|
BillingDay int `gorm:"column:billing_day;type:int;default:1;comment:运营商计费日(用于流量查询接口的计费周期计算,联通=27,其他=1)" json:"billing_day"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TableName 指定表名
|
// TableName 指定表名
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ package order
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||||
@@ -16,12 +20,14 @@ import (
|
|||||||
"github.com/break/junhong_cmp_fiber/pkg/queue"
|
"github.com/break/junhong_cmp_fiber/pkg/queue"
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/wechat"
|
"github.com/break/junhong_cmp_fiber/pkg/wechat"
|
||||||
"github.com/bytedance/sonic"
|
"github.com/bytedance/sonic"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
|
redis *redis.Client
|
||||||
orderStore *postgres.OrderStore
|
orderStore *postgres.OrderStore
|
||||||
orderItemStore *postgres.OrderItemStore
|
orderItemStore *postgres.OrderItemStore
|
||||||
walletStore *postgres.WalletStore
|
walletStore *postgres.WalletStore
|
||||||
@@ -40,6 +46,7 @@ type Service struct {
|
|||||||
|
|
||||||
func New(
|
func New(
|
||||||
db *gorm.DB,
|
db *gorm.DB,
|
||||||
|
redisClient *redis.Client,
|
||||||
orderStore *postgres.OrderStore,
|
orderStore *postgres.OrderStore,
|
||||||
orderItemStore *postgres.OrderItemStore,
|
orderItemStore *postgres.OrderItemStore,
|
||||||
walletStore *postgres.WalletStore,
|
walletStore *postgres.WalletStore,
|
||||||
@@ -57,6 +64,7 @@ func New(
|
|||||||
) *Service {
|
) *Service {
|
||||||
return &Service{
|
return &Service{
|
||||||
db: db,
|
db: db,
|
||||||
|
redis: redisClient,
|
||||||
orderStore: orderStore,
|
orderStore: orderStore,
|
||||||
orderItemStore: orderItemStore,
|
orderItemStore: orderItemStore,
|
||||||
walletStore: walletStore,
|
walletStore: walletStore,
|
||||||
@@ -96,6 +104,24 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateOrderRequest, buyer
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 下单阶段校验混买限制:禁止同一订单同时包含正式套餐和加油包
|
||||||
|
if err := validatePackageTypeMixFromPackages(validationResult.Packages); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 幂等性检查:防止同一买家对同一载体短时间内重复下单
|
||||||
|
carrierType, carrierID := resolveCarrierInfo(req)
|
||||||
|
existingOrderID, err := s.checkOrderIdempotency(ctx, buyerType, buyerID, req.OrderType, carrierType, carrierID, req.PackageIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if existingOrderID > 0 {
|
||||||
|
return s.Get(ctx, existingOrderID)
|
||||||
|
}
|
||||||
|
// 获取到分布式锁后,确保无论成功还是失败都释放
|
||||||
|
lockKey := constants.RedisOrderCreateLockKey(carrierType, carrierID)
|
||||||
|
defer s.redis.Del(ctx, lockKey)
|
||||||
|
|
||||||
forceRechargeCheck := s.checkForceRechargeRequirement(ctx, validationResult)
|
forceRechargeCheck := s.checkForceRechargeRequirement(ctx, validationResult)
|
||||||
if forceRechargeCheck.NeedForceRecharge && validationResult.TotalPrice < forceRechargeCheck.ForceRechargeAmount {
|
if forceRechargeCheck.NeedForceRecharge && validationResult.TotalPrice < forceRechargeCheck.ForceRechargeAmount {
|
||||||
return nil, errors.New(errors.CodeForceRechargeRequired, "首次购买需满足最低充值要求")
|
return nil, errors.New(errors.CodeForceRechargeRequired, "首次购买需满足最低充值要求")
|
||||||
@@ -170,11 +196,14 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateOrderRequest, buyer
|
|||||||
|
|
||||||
items := s.buildOrderItems(userID, validationResult.Packages)
|
items := s.buildOrderItems(userID, validationResult.Packages)
|
||||||
|
|
||||||
|
idempotencyKey := buildOrderIdempotencyKey(buyerType, buyerID, req.OrderType, carrierType, carrierID, req.PackageIDs)
|
||||||
|
|
||||||
if req.PaymentMethod == model.PaymentMethodOffline {
|
if req.PaymentMethod == model.PaymentMethodOffline {
|
||||||
if err := s.createOrderWithActivation(ctx, order, items); err != nil {
|
if err := s.createOrderWithActivation(ctx, order, items); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
s.enqueueCommissionCalculation(ctx, order.ID)
|
s.enqueueCommissionCalculation(ctx, order.ID)
|
||||||
|
s.markOrderCreated(ctx, idempotencyKey, order.ID)
|
||||||
return s.buildOrderResponse(order, items), nil
|
return s.buildOrderResponse(order, items), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,6 +211,7 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateOrderRequest, buyer
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s.markOrderCreated(ctx, idempotencyKey, order.ID)
|
||||||
return s.buildOrderResponse(order, items), nil
|
return s.buildOrderResponse(order, items), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -591,9 +621,9 @@ func (s *Service) validatePackageTypeMix(tx *gorm.DB, items []*model.OrderItem)
|
|||||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐信息失败")
|
return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐信息失败")
|
||||||
}
|
}
|
||||||
|
|
||||||
if pkg.PackageType == "formal" {
|
if pkg.PackageType == constants.PackageTypeFormal {
|
||||||
hasFormal = true
|
hasFormal = true
|
||||||
} else if pkg.PackageType == "addon" {
|
} else if pkg.PackageType == constants.PackageTypeAddon {
|
||||||
hasAddon = true
|
hasAddon = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -605,6 +635,114 @@ func (s *Service) validatePackageTypeMix(tx *gorm.DB, items []*model.OrderItem)
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// validatePackageTypeMixFromPackages 基于已加载的套餐列表检查混买限制(无需 DB 查询)
|
||||||
|
func validatePackageTypeMixFromPackages(packages []*model.Package) error {
|
||||||
|
hasFormal := false
|
||||||
|
hasAddon := false
|
||||||
|
|
||||||
|
for _, pkg := range packages {
|
||||||
|
if pkg.PackageType == constants.PackageTypeFormal {
|
||||||
|
hasFormal = true
|
||||||
|
} else if pkg.PackageType == constants.PackageTypeAddon {
|
||||||
|
hasAddon = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasFormal && hasAddon {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "不允许在同一订单中同时购买正式套餐和加油包")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveCarrierInfo 从请求中提取载体类型和ID
|
||||||
|
func resolveCarrierInfo(req *dto.CreateOrderRequest) (carrierType string, carrierID uint) {
|
||||||
|
if req.OrderType == model.OrderTypeSingleCard && req.IotCardID != nil {
|
||||||
|
return "iot_card", *req.IotCardID
|
||||||
|
}
|
||||||
|
if req.OrderType == model.OrderTypeDevice && req.DeviceID != nil {
|
||||||
|
return "device", *req.DeviceID
|
||||||
|
}
|
||||||
|
return "", 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildOrderIdempotencyKey 生成订单创建的幂等性业务键
|
||||||
|
// 格式: {buyer_type}:{buyer_id}:{order_type}:{carrier_type}:{carrier_id}:{sorted_package_ids}
|
||||||
|
func buildOrderIdempotencyKey(buyerType string, buyerID uint, orderType string, carrierType string, carrierID uint, packageIDs []uint) string {
|
||||||
|
sortedIDs := make([]uint, len(packageIDs))
|
||||||
|
copy(sortedIDs, packageIDs)
|
||||||
|
sort.Slice(sortedIDs, func(i, j int) bool { return sortedIDs[i] < sortedIDs[j] })
|
||||||
|
|
||||||
|
idStrs := make([]string, len(sortedIDs))
|
||||||
|
for i, id := range sortedIDs {
|
||||||
|
idStrs[i] = strconv.FormatUint(uint64(id), 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%s:%d:%s:%s:%d:%s",
|
||||||
|
buyerType, buyerID, orderType, carrierType, carrierID, strings.Join(idStrs, ","))
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
orderIdempotencyTTL = 3 * time.Minute
|
||||||
|
orderCreateLockTTL = 10 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// checkOrderIdempotency 检查订单是否已创建(Redis SETNX + 分布式锁)
|
||||||
|
// 返回已存在的 orderID(>0 表示重复请求),或 0 表示可以创续创建
|
||||||
|
func (s *Service) checkOrderIdempotency(ctx context.Context, buyerType string, buyerID uint, orderType string, carrierType string, carrierID uint, packageIDs []uint) (uint, error) {
|
||||||
|
idempotencyKey := buildOrderIdempotencyKey(buyerType, buyerID, orderType, carrierType, carrierID, packageIDs)
|
||||||
|
redisKey := constants.RedisOrderIdempotencyKey(idempotencyKey)
|
||||||
|
|
||||||
|
// 第 1 层:Redis 快速检测,如果 key 已存在说明已创建或正在创建
|
||||||
|
val, err := s.redis.Get(ctx, redisKey).Result()
|
||||||
|
if err == nil && val != "" {
|
||||||
|
orderID, _ := strconv.ParseUint(val, 10, 64)
|
||||||
|
if orderID > 0 {
|
||||||
|
s.logger.Info("订单幂等性命中,返回已有订单",
|
||||||
|
zap.Uint("order_id", uint(orderID)),
|
||||||
|
zap.String("idempotency_key", idempotencyKey))
|
||||||
|
return uint(orderID), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 第 2 层:分布式锁,防止并发创建
|
||||||
|
lockKey := constants.RedisOrderCreateLockKey(carrierType, carrierID)
|
||||||
|
locked, err := s.redis.SetNX(ctx, lockKey, time.Now().String(), orderCreateLockTTL).Result()
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("获取订单创建分布式锁失败,继续执行",
|
||||||
|
zap.Error(err),
|
||||||
|
zap.String("lock_key", lockKey))
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
if !locked {
|
||||||
|
return 0, errors.New(errors.CodeTooManyRequests, "订单正在创建中,请勿重复提交")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 第 3 层:加锁后二次检测,防止锁等待期间已被处理
|
||||||
|
val, err = s.redis.Get(ctx, redisKey).Result()
|
||||||
|
if err == nil && val != "" {
|
||||||
|
orderID, _ := strconv.ParseUint(val, 10, 64)
|
||||||
|
if orderID > 0 {
|
||||||
|
s.logger.Info("订单幂等性二次检测命中",
|
||||||
|
zap.Uint("order_id", uint(orderID)),
|
||||||
|
zap.String("idempotency_key", idempotencyKey))
|
||||||
|
return uint(orderID), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// markOrderCreated 订单创建成功后标记 Redis 并释放分布式锁
|
||||||
|
func (s *Service) markOrderCreated(ctx context.Context, idempotencyKey string, orderID uint) {
|
||||||
|
redisKey := constants.RedisOrderIdempotencyKey(idempotencyKey)
|
||||||
|
if err := s.redis.Set(ctx, redisKey, strconv.FormatUint(uint64(orderID), 10), orderIdempotencyTTL).Err(); err != nil {
|
||||||
|
s.logger.Warn("设置订单幂等性标记失败",
|
||||||
|
zap.Error(err),
|
||||||
|
zap.Uint("order_id", orderID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// activateMainPackage 任务 8.2-8.4: 主套餐激活逻辑
|
// activateMainPackage 任务 8.2-8.4: 主套餐激活逻辑
|
||||||
func (s *Service) activateMainPackage(ctx context.Context, tx *gorm.DB, order *model.Order, pkg *model.Package, carrierType string, carrierID uint, now time.Time) error {
|
func (s *Service) activateMainPackage(ctx context.Context, tx *gorm.DB, order *model.Order, pkg *model.Package, carrierType string, carrierID uint, now time.Time) error {
|
||||||
// 检查是否有生效中主套餐
|
// 检查是否有生效中主套餐
|
||||||
@@ -642,22 +780,8 @@ func (s *Service) activateMainPackage(ctx context.Context, tx *gorm.DB, order *m
|
|||||||
activatedAt = now
|
activatedAt = now
|
||||||
// 使用工具函数计算过期时间
|
// 使用工具函数计算过期时间
|
||||||
expiresAt = packagepkg.CalculateExpiryTime(pkg.CalendarType, activatedAt, pkg.DurationMonths, pkg.DurationDays)
|
expiresAt = packagepkg.CalculateExpiryTime(pkg.CalendarType, activatedAt, pkg.DurationMonths, pkg.DurationDays)
|
||||||
// 计算下次重置时间
|
// 计算下次重置时间(基于套餐周期类型)
|
||||||
// TODO: 从运营商表读取 billing_day(任务 1.5 待实现)
|
nextResetAt = packagepkg.CalculateNextResetTime(pkg.DataResetCycle, pkg.CalendarType, now, activatedAt)
|
||||||
// 暂时使用默认值:联通=27,其他=1
|
|
||||||
billingDay := 1 // 默认1号计费
|
|
||||||
if carrierType == "iot_card" {
|
|
||||||
var card model.IotCard
|
|
||||||
if err := tx.First(&card, carrierID).Error; err == nil {
|
|
||||||
var carrier model.Carrier
|
|
||||||
if err := tx.First(&carrier, card.CarrierID).Error; err == nil {
|
|
||||||
if carrier.CarrierType == "CUCC" {
|
|
||||||
billingDay = 27 // 联通27号计费
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
nextResetAt = packagepkg.CalculateNextResetTime(pkg.DataResetCycle, now, billingDay)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 任务 8.9: 后台囤货场景
|
// 任务 8.9: 后台囤货场景
|
||||||
|
|||||||
@@ -118,19 +118,7 @@ func (s *ActivationService) ActivateByRealname(ctx context.Context, carrierType
|
|||||||
expiresAt := CalculateExpiryTime(pkg.CalendarType, activatedAt, pkg.DurationMonths, pkg.DurationDays)
|
expiresAt := CalculateExpiryTime(pkg.CalendarType, activatedAt, pkg.DurationMonths, pkg.DurationDays)
|
||||||
|
|
||||||
// 计算下次重置时间
|
// 计算下次重置时间
|
||||||
billingDay := 1 // 默认1号计费
|
nextResetAt := CalculateNextResetTime(pkg.DataResetCycle, pkg.CalendarType, now, activatedAt)
|
||||||
if carrierType == "iot_card" {
|
|
||||||
var card model.IotCard
|
|
||||||
if err := tx.First(&card, carrierID).Error; err == nil {
|
|
||||||
var carrier model.Carrier
|
|
||||||
if err := tx.First(&carrier, card.CarrierID).Error; err == nil {
|
|
||||||
if carrier.CarrierType == "CUCC" {
|
|
||||||
billingDay = 27 // 联通27号计费
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
nextResetAt := CalculateNextResetTime(pkg.DataResetCycle, now, billingDay)
|
|
||||||
|
|
||||||
// 更新套餐使用记录
|
// 更新套餐使用记录
|
||||||
updates := map[string]interface{}{
|
updates := map[string]interface{}{
|
||||||
@@ -290,19 +278,7 @@ func (s *ActivationService) activateNextMainPackage(ctx context.Context, tx *gor
|
|||||||
expiresAt := CalculateExpiryTime(pkg.CalendarType, activatedAt, pkg.DurationMonths, pkg.DurationDays)
|
expiresAt := CalculateExpiryTime(pkg.CalendarType, activatedAt, pkg.DurationMonths, pkg.DurationDays)
|
||||||
|
|
||||||
// 计算下次重置时间
|
// 计算下次重置时间
|
||||||
billingDay := 1
|
nextResetAt := CalculateNextResetTime(pkg.DataResetCycle, pkg.CalendarType, now, activatedAt)
|
||||||
if carrierType == "iot_card" {
|
|
||||||
var card model.IotCard
|
|
||||||
if err := tx.First(&card, carrierID).Error; err == nil {
|
|
||||||
var carrier model.Carrier
|
|
||||||
if err := tx.First(&carrier, card.CarrierID).Error; err == nil {
|
|
||||||
if carrier.CarrierType == "CUCC" {
|
|
||||||
billingDay = 27
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
nextResetAt := CalculateNextResetTime(pkg.DataResetCycle, now, billingDay)
|
|
||||||
|
|
||||||
// 更新套餐使用记录
|
// 更新套餐使用记录
|
||||||
updates := map[string]interface{}{
|
updates := map[string]interface{}{
|
||||||
|
|||||||
@@ -117,43 +117,48 @@ func (s *ResetService) resetMonthlyUsageWithDB(ctx context.Context, db *gorm.DB)
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 按套餐分组处理(因为需要区分联通27号 vs 其他1号)
|
// 按套餐分组处理(根据套餐周期类型计算下次重置时间)
|
||||||
for _, pkg := range packages {
|
for _, usage := range packages {
|
||||||
// 查询运营商信息以确定计费日
|
// 查询套餐信息,获取 calendar_type
|
||||||
// 只有单卡套餐才根据运营商判断,设备级套餐统一使用1号计费
|
var pkg model.Package
|
||||||
billingDay := 1
|
if err := tx.First(&pkg, usage.PackageID).Error; err != nil {
|
||||||
if pkg.IotCardID != 0 {
|
s.logger.Error("查询套餐信息失败",
|
||||||
var card model.IotCard
|
zap.Uint("usage_id", usage.ID),
|
||||||
if err := tx.First(&card, pkg.IotCardID).Error; err == nil {
|
zap.Uint("package_id", usage.PackageID),
|
||||||
var carrier model.Carrier
|
zap.Error(err))
|
||||||
if err := tx.First(&carrier, card.CarrierID).Error; err == nil {
|
continue
|
||||||
if carrier.CarrierType == "CUCC" {
|
|
||||||
billingDay = 27
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// 设备级套餐默认使用1号计费(已在 billingDay := 1 初始化)
|
|
||||||
|
|
||||||
// 计算下次重置时间
|
// 计算下次重置时间(基于套餐周期类型)
|
||||||
nextReset := calculateNextMonthlyResetTime(now, billingDay)
|
// 自然月套餐:每月1号重置
|
||||||
|
// 按天套餐:每30天重置
|
||||||
|
activatedAt := usage.ActivatedAt
|
||||||
|
if activatedAt.IsZero() {
|
||||||
|
activatedAt = now // 兜底处理
|
||||||
|
}
|
||||||
|
nextResetAt := CalculateNextResetTime(constants.PackageDataResetMonthly, pkg.CalendarType, now, activatedAt)
|
||||||
|
if nextResetAt == nil {
|
||||||
|
s.logger.Warn("计算下次重置时间失败",
|
||||||
|
zap.Uint("usage_id", usage.ID))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// 更新套餐
|
// 更新套餐
|
||||||
updates := map[string]interface{}{
|
updates := map[string]interface{}{
|
||||||
"data_usage_mb": 0,
|
"data_usage_mb": 0,
|
||||||
"last_reset_at": now,
|
"last_reset_at": now,
|
||||||
"next_reset_at": nextReset,
|
"next_reset_at": *nextResetAt,
|
||||||
"status": constants.PackageUsageStatusActive, // 重置后恢复为生效中
|
"status": constants.PackageUsageStatusActive, // 重置后恢复为生效中
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tx.Model(pkg).Updates(updates).Error; err != nil {
|
if err := tx.Model(usage).Updates(updates).Error; err != nil {
|
||||||
return errors.Wrap(errors.CodeDatabaseError, err, "重置月流量失败")
|
return errors.Wrap(errors.CodeDatabaseError, err, "重置月流量失败")
|
||||||
}
|
}
|
||||||
|
|
||||||
s.logger.Info("月流量已重置",
|
s.logger.Info("月流量已重置",
|
||||||
zap.Uint("usage_id", pkg.ID),
|
zap.Uint("usage_id", usage.ID),
|
||||||
zap.Int("billing_day", billingDay),
|
zap.String("calendar_type", pkg.CalendarType),
|
||||||
zap.Time("next_reset_at", nextReset))
|
zap.Time("next_reset_at", *nextResetAt))
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -217,26 +222,3 @@ func (s *ResetService) resetYearlyUsageWithDB(ctx context.Context, db *gorm.DB)
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// calculateNextMonthlyResetTime 计算下次月重置时间
|
|
||||||
func calculateNextMonthlyResetTime(now time.Time, billingDay int) time.Time {
|
|
||||||
currentDay := now.Day()
|
|
||||||
targetMonth := now.Month()
|
|
||||||
targetYear := now.Year()
|
|
||||||
|
|
||||||
// 如果当前日期 >= 计费日,下次重置是下月计费日
|
|
||||||
if currentDay >= billingDay {
|
|
||||||
targetMonth++
|
|
||||||
if targetMonth > 12 {
|
|
||||||
targetMonth = 1
|
|
||||||
targetYear++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理月末天数不足的情况(例如2月没有27日)
|
|
||||||
maxDay := time.Date(targetYear, targetMonth+1, 0, 0, 0, 0, 0, now.Location()).Day()
|
|
||||||
if billingDay > maxDay {
|
|
||||||
billingDay = maxDay
|
|
||||||
}
|
|
||||||
|
|
||||||
return time.Date(targetYear, targetMonth, billingDay, 0, 0, 0, 0, now.Location())
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -40,10 +40,11 @@ func CalculateExpiryTime(calendarType string, activatedAt time.Time, durationMon
|
|||||||
|
|
||||||
// CalculateNextResetTime 计算下次流量重置时间
|
// CalculateNextResetTime 计算下次流量重置时间
|
||||||
// dataResetCycle: 流量重置周期(daily/monthly/yearly/none)
|
// dataResetCycle: 流量重置周期(daily/monthly/yearly/none)
|
||||||
|
// calendarType: 套餐周期类型(natural_month/by_day),影响月重置逻辑
|
||||||
// currentTime: 当前时间
|
// currentTime: 当前时间
|
||||||
// billingDay: 计费日(月重置时使用,联通=27,其他=1)
|
// activatedAt: 套餐激活时间(按天套餐月重置时使用)
|
||||||
// 返回:下次重置时间(00:00:00)
|
// 返回:下次重置时间(00:00:00)
|
||||||
func CalculateNextResetTime(dataResetCycle string, currentTime time.Time, billingDay int) *time.Time {
|
func CalculateNextResetTime(dataResetCycle, calendarType string, currentTime, activatedAt time.Time) *time.Time {
|
||||||
if dataResetCycle == constants.PackageDataResetNone {
|
if dataResetCycle == constants.PackageDataResetNone {
|
||||||
// 不重置
|
// 不重置
|
||||||
return nil
|
return nil
|
||||||
@@ -63,46 +64,49 @@ func CalculateNextResetTime(dataResetCycle string, currentTime time.Time, billin
|
|||||||
)
|
)
|
||||||
|
|
||||||
case constants.PackageDataResetMonthly:
|
case constants.PackageDataResetMonthly:
|
||||||
// 月重置:下月 billingDay 号 00:00:00
|
if calendarType == constants.PackageCalendarTypeNaturalMonth {
|
||||||
year := currentTime.Year()
|
// 自然月套餐:每月1号 00:00:00 重置
|
||||||
month := currentTime.Month()
|
year := currentTime.Year()
|
||||||
|
month := currentTime.Month()
|
||||||
|
|
||||||
// 检查 billingDay 是否为当前月的最后一天(月末计费的特殊情况)
|
// 如果当前日期 >= 1号(即已过重置点),则下次重置为下个月1号
|
||||||
currentMonthLastDay := time.Date(year, month+1, 0, 0, 0, 0, 0, currentTime.Location()).Day()
|
if currentTime.Day() >= 1 {
|
||||||
isBillingDayMonthEnd := billingDay >= currentMonthLastDay
|
month++
|
||||||
|
if month > 12 {
|
||||||
// 如果当前日期 >= billingDay,则重置时间为下个月的 billingDay
|
month = 1
|
||||||
// 否则,重置时间为本月的 billingDay
|
year++
|
||||||
// 特殊情况:如果 billingDay 是月末,并且当前日期已接近月末,则跳到下个月
|
}
|
||||||
shouldUseNextMonth := currentTime.Day() >= billingDay || (isBillingDayMonthEnd && currentTime.Day() >= currentMonthLastDay-1)
|
|
||||||
|
|
||||||
if shouldUseNextMonth {
|
|
||||||
// 下个月
|
|
||||||
month++
|
|
||||||
if month > 12 {
|
|
||||||
month = 1
|
|
||||||
year++
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 计算目标月份的最后一天(处理月末情况)
|
nextResetTime = time.Date(year, month, 1, 0, 0, 0, 0, currentTime.Location())
|
||||||
lastDayOfMonth := time.Date(year, month+1, 0, 0, 0, 0, 0, currentTime.Location()).Day()
|
} else {
|
||||||
resetDay := billingDay
|
// 按天套餐:从激活日期开始,每30天重置一次
|
||||||
if billingDay > lastDayOfMonth {
|
// 计算从激活到现在经过了多少个30天周期
|
||||||
// 如果 billingDay 超过该月天数,使用月末
|
daysSinceActivation := int(currentTime.Sub(activatedAt).Hours() / 24)
|
||||||
resetDay = lastDayOfMonth
|
cyclesPassed := daysSinceActivation / 30
|
||||||
}
|
|
||||||
|
|
||||||
nextResetTime = time.Date(year, month, resetDay, 0, 0, 0, 0, currentTime.Location())
|
// 下次重置时间 = 激活时间 + (已过周期数+1) * 30天
|
||||||
|
nextResetTime = activatedAt.AddDate(0, 0, (cyclesPassed+1)*30)
|
||||||
|
nextResetTime = time.Date(
|
||||||
|
nextResetTime.Year(),
|
||||||
|
nextResetTime.Month(),
|
||||||
|
nextResetTime.Day(),
|
||||||
|
0, 0, 0, 0,
|
||||||
|
nextResetTime.Location(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
case constants.PackageDataResetYearly:
|
case constants.PackageDataResetYearly:
|
||||||
// 年重置:明年 1 月 1 日 00:00:00
|
// 年重置:每年1月1日 00:00:00
|
||||||
nextResetTime = time.Date(
|
year := currentTime.Year()
|
||||||
currentTime.Year()+1,
|
|
||||||
1, 1,
|
// 如果当前日期已经过了1月1日,则使用明年
|
||||||
0, 0, 0, 0,
|
jan1ThisYear := time.Date(year, 1, 1, 0, 0, 0, 0, currentTime.Location())
|
||||||
currentTime.Location(),
|
if currentTime.After(jan1ThisYear) || currentTime.Equal(jan1ThisYear) {
|
||||||
)
|
year++
|
||||||
|
}
|
||||||
|
|
||||||
|
nextResetTime = time.Date(year, 1, 1, 0, 0, 0, 0, currentTime.Location())
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ COMMENT ON COLUMN tb_iot_card.resumed_at IS '最近复机时间';
|
|||||||
COMMENT ON COLUMN tb_iot_card.stop_reason IS '停机原因(traffic_exhausted=流量耗尽,manual=手动停机,arrears=欠费)';
|
COMMENT ON COLUMN tb_iot_card.stop_reason IS '停机原因(traffic_exhausted=流量耗尽,manual=手动停机,arrears=欠费)';
|
||||||
|
|
||||||
-- Carrier 表字段注释
|
-- Carrier 表字段注释
|
||||||
COMMENT ON COLUMN tb_carrier.billing_day IS '计费日(联通=27,其他=1,用于月度流量重置)';
|
COMMENT ON COLUMN tb_carrier.billing_day IS '运营商计费日(用于流量查询接口的计费周期计算,联通=27,其他=1)';
|
||||||
|
|
||||||
-- PackageUsageDailyRecord 表和字段注释
|
-- PackageUsageDailyRecord 表和字段注释
|
||||||
COMMENT ON TABLE tb_package_usage_daily_record IS '套餐流量日记录';
|
COMMENT ON TABLE tb_package_usage_daily_record IS '套餐流量日记录';
|
||||||
|
|||||||
@@ -234,20 +234,20 @@
|
|||||||
|
|
||||||
## 26. 最终检查
|
## 26. 最终检查
|
||||||
|
|
||||||
- [ ] 26.1 运行 lsp_diagnostics,确认无编译错误和类型错误
|
- [x] 26.1 运行 lsp_diagnostics,确认无编译错误和类型错误
|
||||||
- [ ] 26.2 生成 OpenAPI 文档,确认新 API 出现在文档中
|
- [x] 26.2 生成 OpenAPI 文档,确认新 API 出现在文档中
|
||||||
- [ ] 26.3 代码审查(检查是否遵循分层架构、Go 惯用法、性能要求)
|
- [x] 26.3 代码审查(检查是否遵循分层架构、Go 惯用法、性能要求)
|
||||||
|
|
||||||
## 27. 文档更新
|
## 27. 文档更新
|
||||||
|
|
||||||
- [ ] 27.1 更新 README.md(新增套餐系统升级功能说明)
|
- [x] 27.1 更新 README.md(新增套餐系统升级功能说明)
|
||||||
- [ ] 27.2 在 docs/package-system-upgrade/ 创建功能总结文档
|
- [x] 27.2 在 docs/package-system-upgrade/ 创建功能总结文档
|
||||||
- [ ] 27.3 编写套餐系统升级用户指南(囤货、排队、加油包、流量查询)
|
- [x] 27.3 编写套餐系统升级用户指南(囤货、排队、加油包、流量查询)
|
||||||
- [ ] 27.4 更新 API 文档(新增 API 端点和字段说明)
|
- [x] 27.4 更新 API 文档(新增 API 端点和字段说明)
|
||||||
|
|
||||||
## 28. 部署准备
|
## 28. 部署准备
|
||||||
|
|
||||||
- [ ] 28.1 编写数据库迁移回滚脚本
|
- [x] 28.1 编写数据库迁移回滚脚本
|
||||||
- [ ] 28.2 配置监控指标(Asynq 队列长度、套餐激活延迟、API 响应时间)
|
- [x] 28.2 配置监控指标(Asynq 队列长度、套餐激活延迟、API 响应时间)
|
||||||
- [ ] 28.3 配置告警规则(套餐激活延迟 > 1 分钟、队列堆积 > 1000 个任务)
|
- [x] 28.3 配置告警规则(套餐激活延迟 > 1 分钟、队列堆积 > 1000 个任务)
|
||||||
- [ ] 28.4 编写回滚预案(代码回滚、数据库回滚、数据修复脚本)
|
- [x] 28.4 编写回滚预案(代码回滚、数据库回滚、数据修复脚本)
|
||||||
753
openspec/specs/addon-package-lifecycle/spec.md
Normal file
753
openspec/specs/addon-package-lifecycle/spec.md
Normal file
@@ -0,0 +1,753 @@
|
|||||||
|
# Spec: 加油包生命周期管理
|
||||||
|
|
||||||
|
## 业务背景
|
||||||
|
|
||||||
|
### 为什么需要加油包生命周期管理
|
||||||
|
|
||||||
|
**现状问题**:
|
||||||
|
- 加油包与主套餐无明确关联,导致主套餐过期后加油包仍可使用(业务逻辑混乱)
|
||||||
|
- 加油包有效期管理不清晰,无法区分"独立有效期"和"跟随主套餐"两种模式
|
||||||
|
- 主套餐切换时,旧加油包是否继承到新主套餐无明确规则
|
||||||
|
- 用户购买加油包时无主套餐检查,可能导致加油包无法使用
|
||||||
|
|
||||||
|
**业务目标**:
|
||||||
|
- 加油包必须依附于主套餐才能购买和使用
|
||||||
|
- 主套餐过期时,其关联的加油包自动失效(级联失效)
|
||||||
|
- 支持两种有效期模式:独立有效期(固定时长)和跟随主套餐(与主套餐同时到期)
|
||||||
|
- 主套餐切换时,旧加油包不继承到新主套餐(用户需重新购买)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 业务规则
|
||||||
|
|
||||||
|
### 1. 依附规则
|
||||||
|
|
||||||
|
加油包必须在有主套餐的情况下才能购买:
|
||||||
|
|
||||||
|
```
|
||||||
|
购买加油包前置检查:
|
||||||
|
1. 查询载体当前是否有主套餐(package_type=formal AND status IN (0待生效, 1生效中))
|
||||||
|
2. 如果无主套餐 → 返回错误 400:"必须有主套餐才能购买加油包"
|
||||||
|
3. 如果有主套餐 → 允许购买
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 关联规则
|
||||||
|
|
||||||
|
加油包创建时自动关联到当前生效中的主套餐:
|
||||||
|
|
||||||
|
```
|
||||||
|
确定 master_usage_id 的逻辑:
|
||||||
|
1. 查询载体当前生效中的主套餐(package_type=formal AND status=1)
|
||||||
|
2. 如果有生效中主套餐 → master_usage_id = 该主套餐ID
|
||||||
|
3. 如果无生效中主套餐,但有待生效主套餐(status=0)→ master_usage_id = priority 最小的待生效主套餐ID
|
||||||
|
4. 创建 PackageUsage 记录:
|
||||||
|
- package_type = addon
|
||||||
|
- master_usage_id = 上述确定的主套餐ID
|
||||||
|
- status = 0(待生效)
|
||||||
|
- has_independent_expiry = 根据套餐配置
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 有效期模式
|
||||||
|
|
||||||
|
加油包支持两种有效期模式:
|
||||||
|
|
||||||
|
| 模式 | has_independent_expiry | 计算规则 | 过期条件 |
|
||||||
|
|------|------------------------|----------|----------|
|
||||||
|
| **独立有效期** | true | `expires_at = activated_at + duration_days` | 自身到期时间到达 |
|
||||||
|
| **跟随主套餐** | false | `expires_at = master套餐.expires_at` | 主套餐到期时间到达 |
|
||||||
|
|
||||||
|
**独立有效期加油包**:
|
||||||
|
- 激活时计算自己的 `expires_at`
|
||||||
|
- 可能在主套餐之前过期
|
||||||
|
- 到期后 `status=3`(已过期)
|
||||||
|
|
||||||
|
**跟随主套餐加油包**:
|
||||||
|
- 激活时 `expires_at = master套餐.expires_at`
|
||||||
|
- 主套餐 `expires_at` 更新时,同步更新所有跟随的加油包
|
||||||
|
- 与主套餐同时到期
|
||||||
|
|
||||||
|
### 4. 级联失效规则
|
||||||
|
|
||||||
|
主套餐过期时,级联失效其所有关联的加油包:
|
||||||
|
|
||||||
|
```
|
||||||
|
主套餐过期触发级联失效:
|
||||||
|
1. 主套餐 status 变为 3(已过期)时触发
|
||||||
|
2. 查询所有 master_usage_id = 主套餐ID 的加油包
|
||||||
|
3. 批量更新这些加油包 status = 4(已失效)
|
||||||
|
4. 不管加油包是否有独立有效期、是否已用完
|
||||||
|
5. 记录级联失效日志
|
||||||
|
```
|
||||||
|
|
||||||
|
**失效状态说明**:
|
||||||
|
- `status=3`(已过期):自身有效期到达
|
||||||
|
- `status=4`(已失效):主套餐过期导致的级联失效
|
||||||
|
|
||||||
|
### 5. 不继承规则
|
||||||
|
|
||||||
|
旧主套餐过期后,其加油包不继承到新主套餐:
|
||||||
|
|
||||||
|
```
|
||||||
|
新主套餐激活时:
|
||||||
|
1. 不更新旧加油包的 master_usage_id
|
||||||
|
2. 旧加油包保持 status=4(已失效)
|
||||||
|
3. 用户需为新主套餐重新购买加油包
|
||||||
|
4. 新加油包 master_usage_id = 新主套餐ID
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. 订单购买限制
|
||||||
|
|
||||||
|
**同订单禁止混买正式套餐和加油包**:
|
||||||
|
|
||||||
|
```
|
||||||
|
订单创建校验规则:
|
||||||
|
1. 检查订单项中是否同时包含 package_type=formal 和 package_type=addon
|
||||||
|
2. 如果混买 → 返回错误 400:"同订单不能同时购买正式套餐和加油包"
|
||||||
|
3. 原因:加油包依赖主套餐激活,订单处理时序无法保证主套餐先激活
|
||||||
|
4. 解决方案:前端购物车分类展示,提示用户分两单购买
|
||||||
|
```
|
||||||
|
|
||||||
|
**技术实现**:
|
||||||
|
```go
|
||||||
|
// 订单创建时校验
|
||||||
|
func (s *OrderService) ValidateOrderItems(items []*OrderItem) error {
|
||||||
|
hasMainPackage := false
|
||||||
|
hasAddonPackage := false
|
||||||
|
|
||||||
|
for _, item := range items {
|
||||||
|
pkg, err := s.packageStore.GetByID(item.PackageID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if pkg.PackageType == constants.PackageTypeFormal {
|
||||||
|
hasMainPackage = true
|
||||||
|
} else if pkg.PackageType == constants.PackageTypeAddon {
|
||||||
|
hasAddonPackage = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasMainPackage && hasAddonPackage {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "同订单不能同时购买正式套餐和加油包")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: 加油包必须依附于主套餐
|
||||||
|
|
||||||
|
系统 SHALL 禁止在无主套餐(无 package_type=formal status=1 或 status=0 的套餐)时购买加油包。
|
||||||
|
|
||||||
|
#### Scenario: 无主套餐时购买加油包失败
|
||||||
|
- **GIVEN** 载体 ICCID=123456,无任何主套餐(无 package_type=formal status IN (0,1))
|
||||||
|
- **WHEN** 用户尝试购买加油包(package_type=addon)
|
||||||
|
- **THEN** 系统返回错误 400,错误码 `ADDON_REQUIRES_MASTER`,错误消息:"必须有主套餐才能购买加油包"
|
||||||
|
|
||||||
|
#### Scenario: 有主套餐时可购买加油包
|
||||||
|
- **GIVEN** 载体有生效中主套餐(ID=123, status=1)
|
||||||
|
- **WHEN** 用户购买加油包(package_id=456)
|
||||||
|
- **THEN** 系统创建订单成功,PackageUsage master_usage_id=123, package_type=addon, status=0
|
||||||
|
|
||||||
|
#### Scenario: 只有待生效主套餐时可购买加油包
|
||||||
|
- **GIVEN** 载体有待生效主套餐(ID=123, status=0, priority=1)
|
||||||
|
- **WHEN** 用户购买加油包
|
||||||
|
- **THEN** 系统创建订单成功,加油包 master_usage_id=123
|
||||||
|
|
||||||
|
### Requirement: 加油包关联主套餐
|
||||||
|
|
||||||
|
系统 SHALL 在创建加油包使用记录时,将其 master_usage_id 设置为当前生效中或最高优先级待生效的主套餐ID。
|
||||||
|
|
||||||
|
#### Scenario: 加油包关联当前生效中主套餐
|
||||||
|
- **GIVEN** 载体有生效中主套餐(ID=123, status=1)
|
||||||
|
- **WHEN** 用户购买加油包
|
||||||
|
- **THEN** 系统创建 PackageUsage:
|
||||||
|
- master_usage_id=123
|
||||||
|
- package_type=addon
|
||||||
|
- status=0
|
||||||
|
|
||||||
|
#### Scenario: 多个主套餐时关联生效中的主套餐
|
||||||
|
- **GIVEN** 载体有:
|
||||||
|
- 生效中主套餐(ID=123, status=1, priority=1)
|
||||||
|
- 待生效主套餐(ID=124, status=0, priority=2)
|
||||||
|
- 待生效主套餐(ID=125, status=0, priority=3)
|
||||||
|
- **WHEN** 用户购买加油包
|
||||||
|
- **THEN** 加油包 master_usage_id=123(优先关联生效中的主套餐)
|
||||||
|
|
||||||
|
#### Scenario: 只有待生效主套餐时关联优先级最高的
|
||||||
|
- **GIVEN** 载体有:
|
||||||
|
- 待生效主套餐(ID=124, status=0, priority=1)
|
||||||
|
- 待生效主套餐(ID=125, status=0, priority=2)
|
||||||
|
- **WHEN** 用户购买加油包
|
||||||
|
- **THEN** 加油包 master_usage_id=124(priority=1 最高)
|
||||||
|
|
||||||
|
### Requirement: 支持独立有效期加油包
|
||||||
|
|
||||||
|
系统 SHALL 支持加油包配置 has_independent_expiry=true,拥有独立的有效期。
|
||||||
|
|
||||||
|
#### Scenario: 独立有效期加油包激活时计算过期时间
|
||||||
|
- **GIVEN** 加油包 has_independent_expiry=true,duration_days=30
|
||||||
|
- **WHEN** 加油包在 2026-02-01 00:00:00 激活
|
||||||
|
- **THEN** 系统计算 expires_at=2026-03-02 23:59:59(+30天)
|
||||||
|
|
||||||
|
#### Scenario: 独立有效期加油包过期
|
||||||
|
- **GIVEN** 加油包 has_independent_expiry=true,expires_at=2026-02-28 23:59:59,data_usage_mb=50(未用完)
|
||||||
|
- **WHEN** 系统时间到达 2026-03-01 00:00:00
|
||||||
|
- **THEN** 定时任务将加油包 status 更新为 3(已过期)
|
||||||
|
|
||||||
|
#### Scenario: 独立有效期加油包在主套餐有效期内过期
|
||||||
|
- **GIVEN** 主套餐有效期到 2026-12-31 23:59:59
|
||||||
|
- **AND** 加油包 has_independent_expiry=true,expires_at=2026-03-31 23:59:59
|
||||||
|
- **WHEN** 系统时间到达 2026-04-01 00:00:00
|
||||||
|
- **THEN** 加油包 status=3(已过期),主套餐仍为 status=1(生效中)
|
||||||
|
|
||||||
|
#### Scenario: 独立有效期加油包在主套餐过期后仍失效
|
||||||
|
- **GIVEN** 加油包 has_independent_expiry=true,expires_at=2026-12-31 23:59:59(未到期)
|
||||||
|
- **AND** 主套餐 expires_at=2026-11-30 23:59:59
|
||||||
|
- **WHEN** 主套餐在 2026-12-01 00:00:00 过期(status=3)
|
||||||
|
- **THEN** 加油包被级联失效(status=4),不管自身 expires_at
|
||||||
|
|
||||||
|
### Requirement: 支持跟随主套餐的加油包
|
||||||
|
|
||||||
|
系统 SHALL 支持加油包配置 has_independent_expiry=false,跟随主套餐有效期。
|
||||||
|
|
||||||
|
#### Scenario: 跟随主套餐的加油包激活时同步到期时间
|
||||||
|
- **GIVEN** 加油包 has_independent_expiry=false,master 主套餐 expires_at=2026-12-31 23:59:59
|
||||||
|
- **WHEN** 加油包在 2026-02-01 00:00:00 激活
|
||||||
|
- **THEN** 系统设置加油包 expires_at=2026-12-31 23:59:59(与主套餐相同)
|
||||||
|
|
||||||
|
#### Scenario: 主套餐更新有效期时同步加油包
|
||||||
|
- **GIVEN** 主套餐 ID=123,expires_at=2026-12-31 23:59:59
|
||||||
|
- **AND** 有3个加油包 master_usage_id=123,has_independent_expiry=false
|
||||||
|
- **WHEN** 主套餐 expires_at 被更新为 2027-01-31 23:59:59
|
||||||
|
- **THEN** 系统批量更新这3个加油包 expires_at=2027-01-31 23:59:59
|
||||||
|
|
||||||
|
#### Scenario: 主套餐有效期更新时不影响独立有效期加油包
|
||||||
|
- **GIVEN** 主套餐 ID=123,expires_at=2026-12-31 23:59:59
|
||||||
|
- **AND** 加油包A:has_independent_expiry=true,expires_at=2026-06-30 23:59:59
|
||||||
|
- **AND** 加油包B:has_independent_expiry=false,expires_at=2026-12-31 23:59:59
|
||||||
|
- **WHEN** 主套餐 expires_at 更新为 2027-01-31 23:59:59
|
||||||
|
- **THEN** 加油包A expires_at 保持 2026-06-30 23:59:59(不变)
|
||||||
|
- **AND** 加油包B expires_at 更新为 2027-01-31 23:59:59
|
||||||
|
|
||||||
|
#### Scenario: 跟随主套餐的加油包与主套餐同时过期
|
||||||
|
- **GIVEN** 主套餐 expires_at=2026-12-31 23:59:59
|
||||||
|
- **AND** 加油包 has_independent_expiry=false,expires_at=2026-12-31 23:59:59
|
||||||
|
- **WHEN** 系统时间到达 2027-01-01 00:00:00
|
||||||
|
- **THEN** 定时任务将主套餐和加油包 status 都更新为 3(已过期)
|
||||||
|
|
||||||
|
### Requirement: 主套餐过期时级联失效加油包
|
||||||
|
|
||||||
|
系统 SHALL 在主套餐过期(status 变为 3)时,将其所有关联加油包的 status 设置为 4(已失效)。
|
||||||
|
|
||||||
|
#### Scenario: 主套餐过期触发加油包失效
|
||||||
|
- **GIVEN** 主套餐 ID=123,expires_at=2026-12-31 23:59:59
|
||||||
|
- **AND** 有3个加油包 master_usage_id=123:
|
||||||
|
- 加油包A:data_usage_mb=50(未用完)
|
||||||
|
- 加油包B:data_usage_mb=200(已用完)
|
||||||
|
- 加油包C:has_independent_expiry=true,expires_at=2027-06-30(未到期)
|
||||||
|
- **WHEN** 系统时间到达 2027-01-01 00:00:00,主套餐 status=3
|
||||||
|
- **THEN** 系统批量更新这3个加油包 status=4(已失效)
|
||||||
|
|
||||||
|
#### Scenario: 独立有效期加油包也会级联失效
|
||||||
|
- **GIVEN** 主套餐 expires_at=2026-11-30 23:59:59
|
||||||
|
- **AND** 加油包 has_independent_expiry=true,expires_at=2026-12-31 23:59:59(晚于主套餐)
|
||||||
|
- **WHEN** 主套餐在 2026-12-01 00:00:00 过期
|
||||||
|
- **THEN** 加油包 status=4(已失效),不管自身还有30天才到期
|
||||||
|
|
||||||
|
#### Scenario: 已过期加油包不重复失效
|
||||||
|
- **GIVEN** 主套餐 expires_at=2026-12-31 23:59:59
|
||||||
|
- **AND** 加油包 has_independent_expiry=true,expires_at=2026-11-30 23:59:59,status=3(已过期)
|
||||||
|
- **WHEN** 主套餐在 2027-01-01 00:00:00 过期
|
||||||
|
- **THEN** 加油包 status 保持 3(已过期),不更新为 4
|
||||||
|
|
||||||
|
#### Scenario: 级联失效记录到审计日志
|
||||||
|
- **GIVEN** 主套餐 ID=123 过期,有5个关联加油包
|
||||||
|
- **WHEN** 系统执行级联失效
|
||||||
|
- **THEN** 系统记录审计日志:
|
||||||
|
- operation_type=cascade_invalidate
|
||||||
|
- operation_desc="主套餐ID=123过期,级联失效5个加油包"
|
||||||
|
- before_data=加油包列表及原状态
|
||||||
|
- after_data=加油包列表及新状态(status=4)
|
||||||
|
|
||||||
|
### Requirement: 加油包不继承到新主套餐
|
||||||
|
|
||||||
|
系统 SHALL 确保旧主套餐过期后,其加油包不会自动关联到新激活的主套餐。
|
||||||
|
|
||||||
|
#### Scenario: 新主套餐激活后加油包不关联
|
||||||
|
- **GIVEN** 主套餐A(ID=123)在 2026-12-31 过期,其加油包已失效(status=4)
|
||||||
|
- **WHEN** 主套餐B(ID=124)在 2027-01-01 激活(priority=2 → status=1)
|
||||||
|
- **THEN** 主套餐A的加油包 master_usage_id 保持 123,status 保持 4
|
||||||
|
- **AND** 主套餐B 无关联加油包
|
||||||
|
|
||||||
|
#### Scenario: 用户需为新主套餐重新购买加油包
|
||||||
|
- **GIVEN** 主套餐B(ID=124)刚激活(status=1)
|
||||||
|
- **WHEN** 用户购买新加油包
|
||||||
|
- **THEN** 新加油包 master_usage_id=124,status=0
|
||||||
|
|
||||||
|
#### Scenario: 旧加油包不可重新激活
|
||||||
|
- **GIVEN** 主套餐A的加油包(ID=999)已失效(status=4)
|
||||||
|
- **WHEN** 用户尝试手动激活这个加油包
|
||||||
|
- **THEN** 系统返回错误 400,错误码 `ADDON_MASTER_EXPIRED`,错误消息:"关联的主套餐已过期,无法激活加油包"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 边界条件
|
||||||
|
|
||||||
|
### 1. 主套餐失效但加油包未用完
|
||||||
|
|
||||||
|
- **场景**:主套餐过期时,加油包流量只用了10%
|
||||||
|
- **处理**:仍然级联失效(status=4),剩余流量不可用
|
||||||
|
- **业务规则**:加油包依附于主套餐,主套餐失效则加油包失效
|
||||||
|
|
||||||
|
### 2. 多个主套餐同时存在
|
||||||
|
|
||||||
|
- **场景**:有1个生效中主套餐 + 2个待生效主套餐
|
||||||
|
- **购买加油包时**:关联到生效中的主套餐
|
||||||
|
- **主套餐A过期后**:加油包随A失效,不继承到主套餐B
|
||||||
|
|
||||||
|
### 3. 并发购买加油包
|
||||||
|
|
||||||
|
- **场景**:两个请求同时为同一载体购买加油包
|
||||||
|
- **处理**:
|
||||||
|
- 使用事务 + 行锁:`SELECT * FROM package_usage WHERE carrier_id=? AND package_type=formal AND status IN (0,1) ORDER BY status DESC, priority ASC FOR UPDATE`
|
||||||
|
- 确保两个加油包关联到同一个主套餐
|
||||||
|
|
||||||
|
### 4. 主套餐有效期更新失败
|
||||||
|
|
||||||
|
- **场景**:主套餐 expires_at 更新时,同步跟随加油包失败
|
||||||
|
- **处理**:
|
||||||
|
- 使用事务包裹主套餐更新和加油包批量更新
|
||||||
|
- 更新失败则回滚,返回错误 500
|
||||||
|
- 记录错误日志,包含主套餐ID和失败原因
|
||||||
|
|
||||||
|
### 5. 级联失效失败
|
||||||
|
|
||||||
|
- **场景**:主套餐过期时,批量更新加油包失败(数据库连接断开)
|
||||||
|
- **处理**:
|
||||||
|
- 使用 Asynq 重试机制(最多3次)
|
||||||
|
- 每次重试前检查加油包当前状态,避免重复更新
|
||||||
|
- 3次失败后写入死信队列,发送告警
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 并发场景
|
||||||
|
|
||||||
|
### Scenario: 并发购买加油包
|
||||||
|
- **GIVEN** 载体有生效中主套餐(ID=123)
|
||||||
|
- **WHEN** 两个请求 req1 和 req2 同时购买加油包
|
||||||
|
- **THEN** 系统使用行锁:
|
||||||
|
```sql
|
||||||
|
SELECT * FROM package_usage
|
||||||
|
WHERE carrier_id=? AND package_type='formal' AND status IN (0,1)
|
||||||
|
ORDER BY status DESC, priority ASC
|
||||||
|
FOR UPDATE
|
||||||
|
```
|
||||||
|
- **AND** req1 和 req2 创建的加油包 master_usage_id 都为 123
|
||||||
|
|
||||||
|
### Scenario: 并发主套餐过期和购买加油包
|
||||||
|
- **GIVEN** 主套餐A(ID=123)即将过期,主套餐B(ID=124)待生效
|
||||||
|
- **WHEN** 时间到达过期时刻:
|
||||||
|
- 请求1:定时任务将主套餐A status=3,触发级联失效
|
||||||
|
- 请求2:用户购买加油包
|
||||||
|
- **THEN** 使用事务隔离:
|
||||||
|
- 如果请求2先获取锁 → 加油包 master_usage_id=123,然后被级联失效(status=4)
|
||||||
|
- 如果请求1先获取锁 → 主套餐A已无生效中,加油包 master_usage_id=124
|
||||||
|
|
||||||
|
### Scenario: 并发更新主套餐有效期和级联失效
|
||||||
|
- **GIVEN** 主套餐 ID=123,有5个跟随的加油包(has_independent_expiry=false)
|
||||||
|
- **WHEN** 同时发生:
|
||||||
|
- 请求1:主套餐 expires_at 更新为 2027-12-31
|
||||||
|
- 请求2:主套餐到期,触发级联失效
|
||||||
|
- **THEN** 使用行锁 `SELECT * FROM package_usage WHERE id=123 FOR UPDATE`
|
||||||
|
- **AND** 先完成的操作生效,后完成的操作基于新状态执行
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 异常处理
|
||||||
|
|
||||||
|
### 1. 级联失效失败
|
||||||
|
|
||||||
|
- **错误场景**:主套餐过期时,批量更新加油包 SQL 执行失败
|
||||||
|
- **处理流程**:
|
||||||
|
1. 捕获错误,记录 Error 日志(包含主套餐ID、加油包数量、错误信息)
|
||||||
|
2. Asynq 自动重试(最多3次,间隔 10s/30s/60s)
|
||||||
|
3. 重试前检查加油包当前状态(避免重复更新)
|
||||||
|
4. 3次失败后写入死信队列,发送告警通知
|
||||||
|
- **返回错误**:不返回给用户(异步任务),仅记录日志
|
||||||
|
|
||||||
|
### 2. master_usage_id 不存在
|
||||||
|
|
||||||
|
- **错误场景**:加油包的 master_usage_id 指向的主套餐被删除
|
||||||
|
- **处理流程**:
|
||||||
|
1. 加油包激活时检查 `SELECT id FROM package_usage WHERE id=master_usage_id`
|
||||||
|
2. 如果不存在 → 返回错误 500,错误码 `MASTER_NOT_FOUND`
|
||||||
|
3. 记录 Error 日志(包含加油包ID、master_usage_id、载体信息)
|
||||||
|
- **返回错误**:`{"code": "MASTER_NOT_FOUND", "msg": "关联的主套餐不存在,请联系管理员"}`
|
||||||
|
|
||||||
|
### 3. 同步有效期失败
|
||||||
|
|
||||||
|
- **错误场景**:主套餐 expires_at 更新时,批量更新跟随加油包失败
|
||||||
|
- **处理流程**:
|
||||||
|
1. 使用事务包裹主套餐更新和加油包批量更新
|
||||||
|
2. 加油包更新失败 → 事务回滚,主套餐 expires_at 不更新
|
||||||
|
3. 记录 Error 日志(包含主套餐ID、加油包数量、错误信息)
|
||||||
|
4. 返回错误 500,错误码 `SYNC_EXPIRY_FAILED`
|
||||||
|
- **返回错误**:`{"code": "SYNC_EXPIRY_FAILED", "msg": "更新套餐有效期失败,请稍后重试"}`
|
||||||
|
|
||||||
|
### 4. 购买加油包时无主套餐
|
||||||
|
|
||||||
|
- **错误场景**:用户购买加油包时,载体无任何主套餐
|
||||||
|
- **处理流程**:
|
||||||
|
1. 查询载体主套餐:`SELECT id FROM package_usage WHERE carrier_id=? AND package_type='formal' AND status IN (0,1) LIMIT 1`
|
||||||
|
2. 如果无结果 → 返回错误 400,错误码 `ADDON_REQUIRES_MASTER`
|
||||||
|
- **返回错误**:`{"code": "ADDON_REQUIRES_MASTER", "msg": "必须有主套餐才能购买加油包"}`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 数据一致性保证
|
||||||
|
|
||||||
|
### 1. 事务边界
|
||||||
|
|
||||||
|
- **主套餐过期 + 级联失效**:使用单个事务,确保原子性
|
||||||
|
- **主套餐更新有效期 + 同步加油包**:使用单个事务,更新失败则回滚
|
||||||
|
- **购买加油包 + 关联主套餐**:使用事务,确保 master_usage_id 正确
|
||||||
|
|
||||||
|
### 2. 行锁机制
|
||||||
|
|
||||||
|
- **查询主套餐时加锁**:`SELECT * FROM package_usage WHERE carrier_id=? AND package_type='formal' AND status IN (0,1) FOR UPDATE`
|
||||||
|
- **更新主套餐有效期时加锁**:`SELECT * FROM package_usage WHERE id=? FOR UPDATE`
|
||||||
|
- **级联失效时加锁**:`SELECT * FROM package_usage WHERE master_usage_id=? FOR UPDATE`
|
||||||
|
|
||||||
|
### 3. 唯一索引
|
||||||
|
|
||||||
|
- 已有索引:`idx_carrier_package_type_priority`(carrier_id + package_type + priority)
|
||||||
|
- 已有索引:`idx_master_usage_id`(master_usage_id)
|
||||||
|
|
||||||
|
### 4. 数据校验
|
||||||
|
|
||||||
|
- **购买加油包前**:校验 has_independent_expiry 与 duration_days 的一致性
|
||||||
|
- **激活加油包时**:校验 master_usage_id 是否存在
|
||||||
|
- **级联失效时**:仅更新 status NOT IN (3, 4) 的加油包(避免重复更新)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 性能指标
|
||||||
|
|
||||||
|
| 操作 | 目标响应时间 | 并发要求 | 数据量 |
|
||||||
|
|------|-------------|---------|--------|
|
||||||
|
| 购买加油包(主套餐检查) | < 50ms | 100 QPS | 单载体查询 |
|
||||||
|
| 关联主套餐(查询+插入) | < 100ms | 100 QPS | 单载体查询 + 单条插入 |
|
||||||
|
| 主套餐过期级联失效 | < 500ms | 10 QPS | 批量更新(平均10个加油包) |
|
||||||
|
| 主套餐更新有效期同步 | < 300ms | 50 QPS | 批量更新(平均5个加油包) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 错误码定义
|
||||||
|
|
||||||
|
| 错误码 | HTTP 状态码 | 错误消息 | 场景 |
|
||||||
|
|--------|------------|---------|------|
|
||||||
|
| `ADDON_REQUIRES_MASTER` | 400 | 必须有主套餐才能购买加油包 | 购买加油包时无主套餐 |
|
||||||
|
| `MASTER_NOT_FOUND` | 500 | 关联的主套餐不存在,请联系管理员 | master_usage_id 不存在 |
|
||||||
|
| `ADDON_MASTER_EXPIRED` | 400 | 关联的主套餐已过期,无法激活加油包 | 尝试激活已失效加油包 |
|
||||||
|
| `SYNC_EXPIRY_FAILED` | 500 | 更新套餐有效期失败,请稍后重试 | 同步加油包有效期失败 |
|
||||||
|
| `CASCADE_INVALIDATE_FAILED` | 500 | 级联失效加油包失败,请稍后重试 | 级联失效批量更新失败 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 数据迁移策略
|
||||||
|
|
||||||
|
**激进策略**(开发阶段,保证干净性):
|
||||||
|
|
||||||
|
### 1. ❌ 要删除的字段
|
||||||
|
|
||||||
|
目前 `package_usage` 表中可能存在的冗余字段(需确认后删除):
|
||||||
|
- 如果有 `parent_usage_id` 字段(旧的父级关联) → **删除**
|
||||||
|
- 如果有 `linked_usage_ids` 字段(旧的关联列表) → **删除**
|
||||||
|
- 如果有 `inherit_to_next` 字段(旧的继承标志) → **删除**
|
||||||
|
|
||||||
|
### 2. ✅ 新增的字段
|
||||||
|
|
||||||
|
在 `package_usage` 表中新增:
|
||||||
|
```sql
|
||||||
|
ALTER TABLE package_usage
|
||||||
|
ADD COLUMN master_usage_id BIGINT DEFAULT NULL COMMENT '主套餐ID(加油包专用)',
|
||||||
|
ADD COLUMN has_independent_expiry BOOLEAN DEFAULT false COMMENT '是否有独立有效期(加油包专用)';
|
||||||
|
|
||||||
|
CREATE INDEX idx_master_usage_id ON package_usage(master_usage_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
在 `package` 表中新增:
|
||||||
|
```sql
|
||||||
|
ALTER TABLE package
|
||||||
|
ADD COLUMN has_independent_expiry BOOLEAN DEFAULT false COMMENT '加油包是否有独立有效期(仅 package_type=addon 时有效)';
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. ❌ 要废弃的逻辑
|
||||||
|
|
||||||
|
- **废弃旧的加油包关联逻辑**:如果代码中存在通过 `parent_usage_id` 或其他字段关联主套餐的逻辑,全部删除
|
||||||
|
- **废弃旧的继承逻辑**:如果代码中存在"主套餐切换时加油包继承到新主套餐"的逻辑,全部删除
|
||||||
|
- **废弃旧的有效期计算逻辑**:如果加油包有效期计算不区分"独立有效期"和"跟随主套餐",全部重构
|
||||||
|
|
||||||
|
### 4. ✅ 历史数据强制转换
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Step 1: 历史加油包数据强制关联到当前主套餐
|
||||||
|
UPDATE package_usage pu_addon
|
||||||
|
SET master_usage_id = (
|
||||||
|
SELECT pu_master.id
|
||||||
|
FROM package_usage pu_master
|
||||||
|
WHERE pu_master.carrier_id = pu_addon.carrier_id
|
||||||
|
AND pu_master.package_type = 'formal'
|
||||||
|
AND pu_master.status IN (0, 1)
|
||||||
|
ORDER BY pu_master.status DESC, pu_master.priority ASC
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
WHERE pu_addon.package_type = 'addon'
|
||||||
|
AND pu_addon.master_usage_id IS NULL;
|
||||||
|
|
||||||
|
-- Step 2: 无主套餐的历史加油包强制失效
|
||||||
|
UPDATE package_usage
|
||||||
|
SET status = 4,
|
||||||
|
invalidated_at = NOW()
|
||||||
|
WHERE package_type = 'addon'
|
||||||
|
AND master_usage_id IS NULL;
|
||||||
|
|
||||||
|
-- Step 3: 历史加油包默认为独立有效期模式
|
||||||
|
UPDATE package_usage
|
||||||
|
SET has_independent_expiry = true
|
||||||
|
WHERE package_type = 'addon'
|
||||||
|
AND has_independent_expiry IS NULL;
|
||||||
|
|
||||||
|
-- Step 4: 已过期主套餐的加油包全部级联失效
|
||||||
|
UPDATE package_usage pu_addon
|
||||||
|
SET status = 4,
|
||||||
|
invalidated_at = NOW()
|
||||||
|
FROM package_usage pu_master
|
||||||
|
WHERE pu_addon.master_usage_id = pu_master.id
|
||||||
|
AND pu_master.package_type = 'formal'
|
||||||
|
AND pu_master.status = 3 -- 已过期
|
||||||
|
AND pu_addon.status NOT IN (3, 4);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. ❌ 删除遗留表/字段(确认后执行)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 如果存在旧的关联表,删除
|
||||||
|
-- DROP TABLE IF EXISTS package_usage_relations;
|
||||||
|
|
||||||
|
-- 如果存在冗余字段,删除
|
||||||
|
-- ALTER TABLE package_usage DROP COLUMN IF EXISTS parent_usage_id;
|
||||||
|
-- ALTER TABLE package_usage DROP COLUMN IF EXISTS linked_usage_ids;
|
||||||
|
-- ALTER TABLE package_usage DROP COLUMN IF EXISTS inherit_to_next;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. 验证步骤
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 验证1:所有加油包都有 master_usage_id(除了已失效的)
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM package_usage
|
||||||
|
WHERE package_type = 'addon'
|
||||||
|
AND status NOT IN (3, 4)
|
||||||
|
AND master_usage_id IS NULL;
|
||||||
|
-- 预期结果:0
|
||||||
|
|
||||||
|
-- 验证2:所有加油包的 master_usage_id 都指向有效的主套餐
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM package_usage pu_addon
|
||||||
|
LEFT JOIN package_usage pu_master ON pu_addon.master_usage_id = pu_master.id
|
||||||
|
WHERE pu_addon.package_type = 'addon'
|
||||||
|
AND pu_addon.master_usage_id IS NOT NULL
|
||||||
|
AND pu_master.id IS NULL;
|
||||||
|
-- 预期结果:0
|
||||||
|
|
||||||
|
-- 验证3:已过期主套餐的加油包都已失效
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM package_usage pu_addon
|
||||||
|
JOIN package_usage pu_master ON pu_addon.master_usage_id = pu_master.id
|
||||||
|
WHERE pu_master.status = 3
|
||||||
|
AND pu_addon.status NOT IN (3, 4);
|
||||||
|
-- 预期结果:0
|
||||||
|
|
||||||
|
-- 验证4:检查是否还有遗留字段(需根据实际情况调整)
|
||||||
|
-- SELECT column_name FROM information_schema.columns
|
||||||
|
-- WHERE table_name = 'package_usage'
|
||||||
|
-- AND column_name IN ('parent_usage_id', 'linked_usage_ids', 'inherit_to_next');
|
||||||
|
-- 预期结果:0 rows
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 测试场景矩阵
|
||||||
|
|
||||||
|
| 场景分类 | 测试用例 | 预期结果 |
|
||||||
|
|---------|---------|---------|
|
||||||
|
| **依附检查** | 无主套餐购买加油包 | 返回错误 400:ADDON_REQUIRES_MASTER |
|
||||||
|
| | 有生效中主套餐购买加油包 | 创建成功,master_usage_id=生效中主套餐ID |
|
||||||
|
| | 只有待生效主套餐购买加油包 | 创建成功,master_usage_id=priority最小的待生效主套餐ID |
|
||||||
|
| **关联逻辑** | 多个主套餐时购买加油包 | 优先关联生效中主套餐 |
|
||||||
|
| | 并发购买加油包 | 使用行锁,两个加油包关联到同一主套餐 |
|
||||||
|
| **独立有效期** | 独立有效期加油包激活 | expires_at = activated_at + duration_days |
|
||||||
|
| | 独立有效期加油包到期 | status=3(已过期) |
|
||||||
|
| | 独立有效期加油包未到期但主套餐过期 | status=4(已失效) |
|
||||||
|
| **跟随主套餐** | 跟随主套餐的加油包激活 | expires_at = master套餐.expires_at |
|
||||||
|
| | 主套餐更新有效期 | 跟随加油包同步更新 expires_at |
|
||||||
|
| | 主套餐更新有效期时独立有效期加油包不变 | 独立有效期加油包 expires_at 不变 |
|
||||||
|
| **级联失效** | 主套餐过期触发级联失效 | 所有关联加油包 status=4 |
|
||||||
|
| | 独立有效期加油包未到期但主套餐过期 | status=4(已失效) |
|
||||||
|
| | 已过期加油包不重复失效 | status 保持 3 |
|
||||||
|
| | 级联失效失败重试 | Asynq 重试3次,失败后进入死信队列 |
|
||||||
|
| **不继承** | 新主套餐激活后旧加油包不关联 | 旧加油包 master_usage_id 和 status 保持不变 |
|
||||||
|
| | 为新主套餐购买新加油包 | 新加油包 master_usage_id=新主套餐ID |
|
||||||
|
| | 尝试激活已失效加油包 | 返回错误 400:ADDON_MASTER_EXPIRED |
|
||||||
|
| **并发** | 并发购买加油包 | 使用行锁,确保关联到同一主套餐 |
|
||||||
|
| | 并发主套餐过期和购买加油包 | 事务隔离,先完成的操作生效 |
|
||||||
|
| **异常** | master_usage_id 不存在 | 返回错误 500:MASTER_NOT_FOUND |
|
||||||
|
| | 同步有效期失败 | 事务回滚,返回错误 500:SYNC_EXPIRY_FAILED |
|
||||||
|
| | 级联失效失败 | Asynq 重试,记录日志,发送告警 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 实现参考
|
||||||
|
|
||||||
|
### 购买加油包时的主套餐检查
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Service 层:CheckMasterPackageForAddon
|
||||||
|
func (s *Service) CheckMasterPackageForAddon(ctx context.Context, carrierID uint) (uint, error) {
|
||||||
|
// 查询生效中或待生效的主套餐
|
||||||
|
masterUsage, err := s.store.FindMasterPackage(ctx, carrierID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, errors.Wrap(errors.CodeInternalError, err, "查询主套餐失败")
|
||||||
|
}
|
||||||
|
if masterUsage == nil {
|
||||||
|
return 0, errors.New(errors.CodeInvalidParam, "必须有主套餐才能购买加油包")
|
||||||
|
}
|
||||||
|
return masterUsage.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store 层:FindMasterPackage
|
||||||
|
func (s *Store) FindMasterPackage(ctx context.Context, carrierID uint) (*model.PackageUsage, error) {
|
||||||
|
var usage model.PackageUsage
|
||||||
|
err := s.db.WithContext(ctx).
|
||||||
|
Where("carrier_id = ? AND package_type = ? AND status IN (?, ?)",
|
||||||
|
carrierID, constants.PackageTypeFormal,
|
||||||
|
constants.PackageStatusPending, constants.PackageStatusActive).
|
||||||
|
Order("status DESC, priority ASC"). // 优先生效中,然后按 priority
|
||||||
|
First(&usage).Error
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &usage, nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 主套餐过期时级联失效加油包
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Service 层:CascadeInvalidateAddons
|
||||||
|
func (s *Service) CascadeInvalidateAddons(ctx context.Context, masterUsageID uint) error {
|
||||||
|
tx := s.store.BeginTx(ctx)
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
// 批量更新加油包状态
|
||||||
|
count, err := s.store.InvalidateAddonsByMaster(ctx, tx, masterUsageID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(errors.CodeInternalError, err, "级联失效加油包失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit().Error; err != nil {
|
||||||
|
return errors.Wrap(errors.CodeInternalError, err, "提交事务失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录审计日志(异步)
|
||||||
|
s.auditService.LogOperation(ctx, &model.OperationLog{
|
||||||
|
OperationType: "cascade_invalidate",
|
||||||
|
OperationDesc: fmt.Sprintf("主套餐ID=%d过期,级联失效%d个加油包", masterUsageID, count),
|
||||||
|
TargetID: masterUsageID,
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store 层:InvalidateAddonsByMaster
|
||||||
|
func (s *Store) InvalidateAddonsByMaster(ctx context.Context, tx *gorm.DB, masterUsageID uint) (int64, error) {
|
||||||
|
result := tx.WithContext(ctx).
|
||||||
|
Model(&model.PackageUsage{}).
|
||||||
|
Where("master_usage_id = ? AND status NOT IN (?, ?)",
|
||||||
|
masterUsageID,
|
||||||
|
constants.PackageStatusExpired,
|
||||||
|
constants.PackageStatusInvalidated).
|
||||||
|
Updates(map[string]interface{}{
|
||||||
|
"status": constants.PackageStatusInvalidated,
|
||||||
|
"invalidated_at": time.Now(),
|
||||||
|
})
|
||||||
|
if result.Error != nil {
|
||||||
|
return 0, result.Error
|
||||||
|
}
|
||||||
|
return result.RowsAffected, nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 主套餐更新有效期时同步跟随加油包
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Service 层:SyncAddonExpiry
|
||||||
|
func (s *Service) SyncAddonExpiry(ctx context.Context, masterUsageID uint, newExpiresAt time.Time) error {
|
||||||
|
tx := s.store.BeginTx(ctx)
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
// 更新主套餐有效期
|
||||||
|
if err := s.store.UpdateExpiry(ctx, tx, masterUsageID, newExpiresAt); err != nil {
|
||||||
|
return errors.Wrap(errors.CodeInternalError, err, "更新主套餐有效期失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量更新跟随的加油包
|
||||||
|
count, err := s.store.SyncFollowingAddonExpiry(ctx, tx, masterUsageID, newExpiresAt)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(errors.CodeInternalError, err, "同步加油包有效期失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit().Error; err != nil {
|
||||||
|
return errors.Wrap(errors.CodeInternalError, err, "提交事务失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("同步加油包有效期成功",
|
||||||
|
zap.Uint("master_usage_id", masterUsageID),
|
||||||
|
zap.Int64("count", count),
|
||||||
|
zap.Time("new_expires_at", newExpiresAt))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store 层:SyncFollowingAddonExpiry
|
||||||
|
func (s *Store) SyncFollowingAddonExpiry(ctx context.Context, tx *gorm.DB, masterUsageID uint, expiresAt time.Time) (int64, error) {
|
||||||
|
result := tx.WithContext(ctx).
|
||||||
|
Model(&model.PackageUsage{}).
|
||||||
|
Where("master_usage_id = ? AND has_independent_expiry = ?", masterUsageID, false).
|
||||||
|
Update("expires_at", expiresAt)
|
||||||
|
if result.Error != nil {
|
||||||
|
return 0, result.Error
|
||||||
|
}
|
||||||
|
return result.RowsAffected, nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**本 Spec 完成**,包含:
|
||||||
|
- ✅ 业务背景和业务规则
|
||||||
|
- ✅ 详细场景(依附、关联、独立有效期、跟随主套餐、级联失效、不继承)
|
||||||
|
- ✅ 边界条件和并发场景
|
||||||
|
- ✅ 异常处理和数据一致性保证
|
||||||
|
- ✅ 性能指标和错误码定义
|
||||||
|
- ✅ **激进的数据迁移策略**(明确删除字段、废弃逻辑、强制转换)
|
||||||
|
- ✅ 测试场景矩阵和实现参考
|
||||||
391
openspec/specs/auto-stop-resume/spec.md
Normal file
391
openspec/specs/auto-stop-resume/spec.md
Normal file
@@ -0,0 +1,391 @@
|
|||||||
|
# Spec: 自动停复机机制
|
||||||
|
|
||||||
|
## 业务背景
|
||||||
|
|
||||||
|
### 为什么需要自动停复机
|
||||||
|
|
||||||
|
**现状问题**:
|
||||||
|
- 当前系统流量耗尽后手动停机,用户购买加油包后需手动复机
|
||||||
|
- 停复机时机不精确,可能出现流量已耗尽但仍可上网的情况
|
||||||
|
- 用户购买加油包后不知道需要复机,导致流量无法使用
|
||||||
|
|
||||||
|
**业务目标**:
|
||||||
|
- 所有套餐流量耗尽时自动停机,避免超额使用
|
||||||
|
- 购买新套餐(正式/加油包)后自动复机,提升用户体验
|
||||||
|
- 停复机延迟 < 2分钟,确保及时性
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 业务规则
|
||||||
|
|
||||||
|
### 1. 停机触发条件
|
||||||
|
|
||||||
|
```
|
||||||
|
停机条件 = (所有生效套餐流量 = 0) AND (卡当前状态 = active)
|
||||||
|
```
|
||||||
|
|
||||||
|
**详细逻辑**:
|
||||||
|
```sql
|
||||||
|
-- 检查是否有剩余流量
|
||||||
|
SELECT COUNT(*) FROM tb_package_usage
|
||||||
|
WHERE iot_card_id = ?
|
||||||
|
AND status = 1 -- 生效中
|
||||||
|
AND data_usage_mb < data_limit_mb;
|
||||||
|
|
||||||
|
-- 如果 COUNT = 0,触发停机
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 复机触发条件
|
||||||
|
|
||||||
|
```
|
||||||
|
复机条件 = (存在可用流量套餐) AND (卡当前状态 = stopped)
|
||||||
|
```
|
||||||
|
|
||||||
|
**可用流量套餐定义**:
|
||||||
|
```sql
|
||||||
|
status='active' AND remaining_data_amount > 0
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 停复机延迟要求
|
||||||
|
|
||||||
|
- **目标延迟**:< 2分钟(从触发条件到完成停复机)
|
||||||
|
- **实现方式**:流量检查后同步调用停复机接口(不走异步队列)
|
||||||
|
|
||||||
|
### 4. 运营商接口容错
|
||||||
|
|
||||||
|
- 停机/复机失败时:
|
||||||
|
- 重试3次(间隔 1s, 2s, 4s)
|
||||||
|
- 仍失败:记录错误日志,人工介入
|
||||||
|
- **不阻塞**套餐激活流程
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: 流量耗尽自动停机
|
||||||
|
|
||||||
|
系统 SHALL 在主套餐和所有加油包流量都用完时,调用运营商接口停机。
|
||||||
|
|
||||||
|
#### Scenario: 所有套餐流量耗尽触发停机
|
||||||
|
- **GIVEN** 卡 C1 有主套餐(剩余0MB)和加油包(剩余0MB),卡状态为 active
|
||||||
|
- **WHEN** 轮询系统检查停机条件
|
||||||
|
- **THEN** 系统执行停机操作:
|
||||||
|
1. 调用运营商停机接口
|
||||||
|
2. 更新 IotCard.network_status=0(已停机)
|
||||||
|
3. 记录 stopped_at 时间
|
||||||
|
4. 记录 stop_reason="traffic_exhausted"
|
||||||
|
5. 记录操作日志
|
||||||
|
|
||||||
|
#### Scenario: 有剩余流量时不停机
|
||||||
|
- **GIVEN** 主套餐流量用完,但加油包剩余1GB
|
||||||
|
- **WHEN** 轮询系统检查停机条件
|
||||||
|
- **THEN** 系统查询到有剩余流量,不触发停机
|
||||||
|
|
||||||
|
#### Scenario: 停机接口调用失败重试
|
||||||
|
- **GIVEN** 所有套餐流量用完,需要停机
|
||||||
|
- **WHEN** 调用运营商停机接口失败(网络超时)
|
||||||
|
- **THEN** 系统重试3次(间隔1s/2s/4s)
|
||||||
|
- **AND** 3次都失败后记录 Error 日志,告警通知运维
|
||||||
|
|
||||||
|
#### Scenario: 停机幂等性
|
||||||
|
- **GIVEN** 卡已停机(network_status=0)
|
||||||
|
- **WHEN** 轮询系统再次检测到流量用完
|
||||||
|
- **THEN** 系统检测到已停机,跳过停机调用
|
||||||
|
|
||||||
|
### Requirement: 购买套餐自动复机
|
||||||
|
|
||||||
|
系统 SHALL 在购买新套餐(正式/加油包)激活后,自动调用运营商接口复机。
|
||||||
|
|
||||||
|
#### Scenario: 购买加油包自动复机
|
||||||
|
- **GIVEN** 卡 C1 已停机(network_status=0,stopped_at=2026-02-10 10:00)
|
||||||
|
- **WHEN** 用户购买加油包,激活成功(status=active)
|
||||||
|
- **THEN** 系统执行复机操作:
|
||||||
|
1. 调用运营商复机接口
|
||||||
|
2. 更新 IotCard.network_status=1(正常)
|
||||||
|
3. 记录 resumed_at 时间
|
||||||
|
4. 清空 stopped_at
|
||||||
|
5. 记录操作日志
|
||||||
|
|
||||||
|
#### Scenario: 复机幂等性
|
||||||
|
- **GIVEN** 卡 C1 已停机
|
||||||
|
- **WHEN** 用户快速购买2个加油包
|
||||||
|
- **THEN** 第1个加油包激活 → 触发复机成功
|
||||||
|
- **AND** 第2个加油包激活 → 检测到已是 active 状态,跳过复机
|
||||||
|
- **AND** 运营商复机接口调用仅1次
|
||||||
|
|
||||||
|
#### Scenario: 购买主套餐自动复机
|
||||||
|
- **GIVEN** 卡 C1 已停机,主套餐过期
|
||||||
|
- **WHEN** 用户购买新主套餐,激活成功
|
||||||
|
- **THEN** 系统自动触发复机
|
||||||
|
|
||||||
|
#### Scenario: 复机失败容错
|
||||||
|
- **GIVEN** 卡已停机
|
||||||
|
- **WHEN** 购买加油包激活,但运营商复机接口返回失败
|
||||||
|
- **THEN** 系统重试3次
|
||||||
|
- **AND** 仍失败后:
|
||||||
|
- 套餐激活成功(status=active)
|
||||||
|
- 卡状态仍为 stopped
|
||||||
|
- 错误日志已记录
|
||||||
|
- 告警通知运维
|
||||||
|
|
||||||
|
### Requirement: 复机延迟 < 2分钟
|
||||||
|
|
||||||
|
系统 SHALL 确保从套餐激活到卡复机完成的延迟 < 2分钟。
|
||||||
|
|
||||||
|
#### Scenario: 复机延迟达标
|
||||||
|
- **GIVEN** 加油包在 2026-02-10 10:00:00 激活成功
|
||||||
|
- **WHEN** 系统同步调用复机接口
|
||||||
|
- **THEN** 复机完成时间 < 2026-02-10 10:02:00(延迟 < 2分钟)
|
||||||
|
|
||||||
|
#### Scenario: 复机失败后重试延迟
|
||||||
|
- **GIVEN** 加油包激活,第1次复机调用失败
|
||||||
|
- **WHEN** 系统重试3次(间隔1s/2s/4s)
|
||||||
|
- **THEN** 复机在第3次重试成功,总延迟约7秒
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 数据模型变更
|
||||||
|
|
||||||
|
### tb_iot_card 新增字段
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| stopped_at | timestamp | 停机时间,NULL=未停机 |
|
||||||
|
| resumed_at | timestamp | 最近复机时间 |
|
||||||
|
| stop_reason | varchar(50) | 停机原因:`traffic_exhausted`, `manual`, `arrears` |
|
||||||
|
|
||||||
|
**索引**:
|
||||||
|
- 无需索引(非查询字段,仅用于审计)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 业务流程
|
||||||
|
|
||||||
|
### 流程1:流量耗尽停机
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
A[流量上报] --> B{所有套餐流量=0?}
|
||||||
|
B -->|是| C{卡状态=active?}
|
||||||
|
B -->|否| Z[结束]
|
||||||
|
C -->|是| D[调用运营商停机接口]
|
||||||
|
C -->|否| Z
|
||||||
|
D --> E{停机成功?}
|
||||||
|
E -->|是| F[更新卡状态=stopped]
|
||||||
|
E -->|否| G[重试3次]
|
||||||
|
F --> H[记录stopped_at]
|
||||||
|
G --> E
|
||||||
|
```
|
||||||
|
|
||||||
|
### 流程2:购买加油包复机
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
A[加油包激活成功] --> B{卡状态=stopped?}
|
||||||
|
B -->|是| C[调用运营商复机接口]
|
||||||
|
B -->|否| Z[跳过复机]
|
||||||
|
C --> D{复机成功?}
|
||||||
|
D -->|是| E[更新卡状态=active]
|
||||||
|
D -->|否| F[重试3次]
|
||||||
|
E --> G[清空stopped_at]
|
||||||
|
F --> D
|
||||||
|
F -->|3次失败| H[记录错误日志]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 并发场景
|
||||||
|
|
||||||
|
### Scenario: 并发停复机
|
||||||
|
- **GIVEN** 卡流量刚好用完,同时用户购买加油包
|
||||||
|
- **WHEN** 停机任务和复机任务并发执行
|
||||||
|
- **THEN** 使用数据库行锁:
|
||||||
|
```sql
|
||||||
|
SELECT * FROM iot_card WHERE id=? FOR UPDATE
|
||||||
|
```
|
||||||
|
- **AND** 后执行的操作覆盖前一个操作的状态
|
||||||
|
|
||||||
|
### Scenario: 复机任务重复执行
|
||||||
|
- **GIVEN** 用户购买2个加油包,触发2次复机
|
||||||
|
- **WHEN** 第1次复机成功,卡状态=active
|
||||||
|
- **THEN** 第2次复机检测到卡状态=active,跳过调用
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 异常处理
|
||||||
|
|
||||||
|
### 1. 停机接口超时
|
||||||
|
|
||||||
|
- **场景**:运营商停机接口响应超时(>5秒)
|
||||||
|
- **处理**:
|
||||||
|
1. 记录 Error 日志(包含卡号、超时时间)
|
||||||
|
2. 重试3次,间隔1s/2s/4s
|
||||||
|
3. 3次都失败:记录到死信队列,告警通知
|
||||||
|
- **用户影响**:卡可能仍可上网(停机未成功)
|
||||||
|
|
||||||
|
### 2. 复机接口失败
|
||||||
|
|
||||||
|
- **场景**:运营商复机接口返回业务错误(如卡状态异常)
|
||||||
|
- **处理**:
|
||||||
|
1. 记录 Error 日志(包含卡号、错误码、错误消息)
|
||||||
|
2. 重试3次
|
||||||
|
3. 3次都失败:套餐激活成功,但卡保持停机状态
|
||||||
|
4. 告警通知运维人工介入
|
||||||
|
- **用户影响**:购买加油包后仍无法上网
|
||||||
|
|
||||||
|
### 3. 停复机状态不一致
|
||||||
|
|
||||||
|
- **场景**:系统记录已停机,但运营商侧仍正常
|
||||||
|
- **处理**:
|
||||||
|
1. 轮询系统定期同步卡状态
|
||||||
|
2. 检测到不一致时记录 Warning 日志
|
||||||
|
3. 自动修正系统状态(以运营商侧为准)
|
||||||
|
- **修正频率**:每小时同步一次
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 性能指标
|
||||||
|
|
||||||
|
| 操作 | 目标响应时间 | 监控指标 |
|
||||||
|
|------|------------|---------|
|
||||||
|
| 停机接口调用 | < 5秒 | 运营商API耗时 |
|
||||||
|
| 复机接口调用 | < 5秒 | 运营商API耗时 |
|
||||||
|
| 停机条件检查 | < 50ms | SELECT COUNT查询耗时 |
|
||||||
|
| 端到端停机延迟 | < 2分钟 | 流量用完到停机完成 |
|
||||||
|
| 端到端复机延迟 | < 2分钟 | 套餐激活到复机完成 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 错误码定义
|
||||||
|
|
||||||
|
| 错误码 | HTTP 状态码 | 错误消息 | 场景 |
|
||||||
|
|--------|------------|---------|------|
|
||||||
|
| CodeInternal | 500 | 停机操作失败,请重试 | 运营商停机接口失败 |
|
||||||
|
| CodeInternal | 500 | 复机操作失败,请重试 | 运营商复机接口失败 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 测试场景矩阵
|
||||||
|
|
||||||
|
| 维度 | 场景 | 预期结果 |
|
||||||
|
|------|------|---------|
|
||||||
|
| **停机** | 所有套餐流量用完 | 自动停机 |
|
||||||
|
| | 主套餐用完+加油包剩余 | 不停机 |
|
||||||
|
| | 停机接口失败 | 重试3次,失败告警 |
|
||||||
|
| | 已停机重复检测 | 跳过停机 |
|
||||||
|
| **复机** | 购买加油包 | 自动复机 |
|
||||||
|
| | 购买主套餐 | 自动复机 |
|
||||||
|
| | 复机接口失败 | 重试3次,套餐激活成功,卡保持停机 |
|
||||||
|
| | 并发购买2个加油包 | 复机接口调用1次 |
|
||||||
|
| **延迟** | 复机延迟 | < 2分钟 |
|
||||||
|
| | 停机延迟 | < 2分钟 |
|
||||||
|
| **异常** | 停机超时 | 重试后告警 |
|
||||||
|
| | 状态不一致 | 轮询同步修正 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 实现参考
|
||||||
|
|
||||||
|
### Service 层:CheckAndStop
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *Service) CheckAndStopCard(ctx context.Context, cardID uint) error {
|
||||||
|
// 1. 查询卡信息
|
||||||
|
card, err := s.iotCardStore.GetByID(ctx, cardID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 检查卡状态
|
||||||
|
if card.NetworkStatus != constants.NetworkStatusActive {
|
||||||
|
return nil // 已停机,跳过
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 检查是否有剩余流量
|
||||||
|
hasAvailableData, err := s.packageUsageStore.HasAvailableData(ctx, cardID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasAvailableData {
|
||||||
|
return nil // 有剩余流量,不停机
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 调用运营商停机接口(带重试)
|
||||||
|
err = s.carrierClient.StopCard(ctx, card.ICCID, 3)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("停机失败",
|
||||||
|
zap.Uint("card_id", cardID),
|
||||||
|
zap.Error(err))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 更新卡状态
|
||||||
|
err = s.iotCardStore.UpdateStopStatus(ctx, cardID, time.Now(), "traffic_exhausted")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 记录审计日志
|
||||||
|
s.auditService.LogOperation(ctx, &model.OperationLog{
|
||||||
|
OperationType: "card_stop",
|
||||||
|
OperationDesc: "流量耗尽自动停机",
|
||||||
|
TargetID: cardID,
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Service 层:ResumeCard
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *Service) ResumeCardIfStopped(ctx context.Context, cardID uint) error {
|
||||||
|
// 1. 查询卡信息
|
||||||
|
card, err := s.iotCardStore.GetByID(ctx, cardID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 检查卡状态
|
||||||
|
if card.NetworkStatus != constants.NetworkStatusStopped {
|
||||||
|
return nil // 未停机,跳过
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 调用运营商复机接口(带重试)
|
||||||
|
err = s.carrierClient.ResumeCard(ctx, card.ICCID, 3)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("复机失败",
|
||||||
|
zap.Uint("card_id", cardID),
|
||||||
|
zap.Error(err))
|
||||||
|
// 复机失败不阻塞套餐激活
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 更新卡状态
|
||||||
|
err = s.iotCardStore.UpdateResumeStatus(ctx, cardID, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 记录审计日志
|
||||||
|
s.auditService.LogOperation(ctx, &model.OperationLog{
|
||||||
|
OperationType: "card_resume",
|
||||||
|
OperationDesc: "购买套餐自动复机",
|
||||||
|
TargetID: cardID,
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**本 Spec 完成**,包含:
|
||||||
|
- ✅ 业务背景和业务规则
|
||||||
|
- ✅ 详细场景(停机、复机、幂等性、容错)
|
||||||
|
- ✅ 数据模型变更
|
||||||
|
- ✅ 业务流程图
|
||||||
|
- ✅ 并发场景和异常处理
|
||||||
|
- ✅ 性能指标和错误码定义
|
||||||
|
- ✅ 测试场景矩阵和实现参考
|
||||||
@@ -578,3 +578,49 @@ This capability supports:
|
|||||||
- **WHEN** 管理员查询设备绑定的物联网卡列表
|
- **WHEN** 管理员查询设备绑定的物联网卡列表
|
||||||
- **THEN** 响应中的 carrier_name 直接来自 IotCard 记录的冗余字段
|
- **THEN** 响应中的 carrier_name 直接来自 IotCard 记录的冗余字段
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 流量检查任务支持新的扣减优先级
|
||||||
|
系统 SHALL 在轮询系统的流量检查任务(HandleCarddataCheck)中,实现新的流量扣减优先级机制。
|
||||||
|
|
||||||
|
#### Scenario: 优先扣减加油包流量
|
||||||
|
- **WHEN** 轮询系统检测到卡流量增加,卡有主套餐和加油包
|
||||||
|
- **THEN** 系统优先更新加油包的 data_usage_mb,再更新主套餐
|
||||||
|
|
||||||
|
#### Scenario: 按 Priority 顺序扣减多个加油包
|
||||||
|
- **WHEN** 卡有多个加油包,流量增加
|
||||||
|
- **THEN** 系统按 priority 从小到大顺序扣减流量
|
||||||
|
|
||||||
|
### Requirement: 停机条件检查调整
|
||||||
|
系统 SHALL 在轮询系统中,仅当主套餐和所有加油包流量都用完时触发停机。
|
||||||
|
|
||||||
|
#### Scenario: 主套餐用完但加油包有剩余不停机
|
||||||
|
- **WHEN** 主套餐 data_usage_mb >= data_limit_mb,但加油包有剩余流量
|
||||||
|
- **THEN** 系统不触发停机操作
|
||||||
|
|
||||||
|
#### Scenario: 所有套餐流量用完触发停机
|
||||||
|
- **WHEN** 主套餐和所有加油包 data_usage_mb >= data_limit_mb
|
||||||
|
- **THEN** 系统触发停机操作
|
||||||
|
|
||||||
|
### Requirement: 套餐激活检查任务
|
||||||
|
系统 SHALL 新增套餐激活检查任务(HandlePackageActivation),定期检查待激活的主套餐。
|
||||||
|
|
||||||
|
#### Scenario: 定期检查待激活主套餐
|
||||||
|
- **WHEN** 轮询系统每分钟执行一次套餐激活检查
|
||||||
|
- **THEN** 系统查询所有已过期主套餐,激活 priority 最小的待生效主套餐
|
||||||
|
|
||||||
|
#### Scenario: 激活延迟小于1分钟
|
||||||
|
- **WHEN** 主套餐在 00:00:00 过期
|
||||||
|
- **THEN** 系统在 00:01:00 之前完成下一个主套餐的激活
|
||||||
|
|
||||||
|
### Requirement: 流量重置调度任务
|
||||||
|
系统 SHALL 新增流量重置调度任务(HandleDataReset),根据套餐的 data_reset_cycle 定期重置流量。
|
||||||
|
|
||||||
|
#### Scenario: 每日0点触发日重置任务
|
||||||
|
- **WHEN** 系统时间到达 00:00:00
|
||||||
|
- **THEN** 系统重置所有 data_reset_cycle=daily 的套餐 data_usage_mb=0
|
||||||
|
|
||||||
|
#### Scenario: 每月1号触发月重置任务
|
||||||
|
- **WHEN** 系统时间到达每月1号 00:00:00
|
||||||
|
- **THEN** 系统重置所有 data_reset_cycle=monthly 的套餐 data_usage_mb=0
|
||||||
|
|
||||||
|
|||||||
@@ -189,3 +189,38 @@
|
|||||||
#### Scenario: 代购订单不更新累计充值
|
#### Scenario: 代购订单不更新累计充值
|
||||||
- **WHEN** 代购订单支付成功
|
- **WHEN** 代购订单支付成功
|
||||||
- **THEN** 系统 MUST NOT 更新卡/设备的 accumulated_recharge 字段
|
- **THEN** 系统 MUST NOT 更新卡/设备的 accumulated_recharge 字段
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 主套餐购买时自动排队
|
||||||
|
系统 SHALL 在用户购买主套餐时,如果已有生效中的主套餐,自动将新套餐设置为待生效状态并分配 priority。
|
||||||
|
|
||||||
|
#### Scenario: 首个主套餐立即生效
|
||||||
|
- **WHEN** 载体首次购买主套餐
|
||||||
|
- **THEN** PackageUsage status=1, priority=1, activated_at=支付完成时间
|
||||||
|
|
||||||
|
#### Scenario: 第二个主套餐自动排队
|
||||||
|
- **WHEN** 载体已有生效中主套餐,购买第2个主套餐
|
||||||
|
- **THEN** PackageUsage status=0, priority=2, pending_realname_activation=false
|
||||||
|
|
||||||
|
### Requirement: 加油包购买前检查主套餐
|
||||||
|
系统 SHALL 在用户购买加油包前,检查是否有生效中或待生效的主套餐。
|
||||||
|
|
||||||
|
#### Scenario: 无主套餐时购买加油包失败
|
||||||
|
- **WHEN** 用户购买加油包,但载体无主套餐
|
||||||
|
- **THEN** 系统返回错误 400 "必须有主套餐才能购买加油包"
|
||||||
|
|
||||||
|
#### Scenario: 有主套餐时可购买加油包
|
||||||
|
- **WHEN** 用户购买加油包,载体有生效中主套餐
|
||||||
|
- **THEN** 系统创建订单成功,PackageUsage master_usage_id=主套餐ID
|
||||||
|
|
||||||
|
### Requirement: 客户端未实名时禁止购买套餐
|
||||||
|
系统 SHALL 在客户端购买套餐时,检查载体的实名状态。
|
||||||
|
|
||||||
|
#### Scenario: 客户端未实名购买返回错误
|
||||||
|
- **WHEN** 客户通过 H5 端购买套餐,载体未实名
|
||||||
|
- **THEN** 系统返回错误 403 "设备/卡必须先完成实名认证才能购买套餐"
|
||||||
|
|
||||||
|
#### Scenario: 后台管理端可为未实名载体购买
|
||||||
|
- **WHEN** 管理员通过后台为未实名载体购买套餐
|
||||||
|
- **THEN** 系统创建订单成功,PackageUsage status=0, pending_realname_activation=true
|
||||||
|
|||||||
375
openspec/specs/package-calendar-type/spec.md
Normal file
375
openspec/specs/package-calendar-type/spec.md
Normal file
@@ -0,0 +1,375 @@
|
|||||||
|
# Spec: 套餐周期类型管理
|
||||||
|
|
||||||
|
## 业务背景
|
||||||
|
|
||||||
|
现有套餐系统仅支持简单的按月计算模式(通过 `duration_months` 字段),无法区分"自然月套餐"和"按天套餐"的业务需求。本规范引入 `calendar_type` 字段,支持两种套餐类型:
|
||||||
|
|
||||||
|
1. **自然月套餐(natural_month)**:按月边界计算有效期,适合"月卡"、"季卡"、"年卡"等场景
|
||||||
|
2. **按天套餐(by_day)**:按天数精确计算有效期,适合"7天卡"、"30天卡"、"90天卡"等场景
|
||||||
|
|
||||||
|
两种类型的核心差异在于**有效期计算方式**:
|
||||||
|
- 自然月套餐:激活后到当前月份 + N 个月的**月末 23:59:59**
|
||||||
|
- 按天套餐:激活后 + N 天的 **23:59:59**
|
||||||
|
|
||||||
|
## 业务规则
|
||||||
|
|
||||||
|
1. **calendar_type 是必填字段**,默认值为 `by_day`(向后兼容)
|
||||||
|
2. **duration_months 和 duration_days 互斥但至少提供一个**:
|
||||||
|
- `calendar_type=natural_month` 时,必须提供 `duration_months`
|
||||||
|
- `calendar_type=by_day` 时,必须提供 `duration_days`(如缺失,可从 `duration_months * 30` 转换)
|
||||||
|
3. **有效期计算时区统一使用服务器时区**(Asia/Shanghai)
|
||||||
|
4. **套餐激活时才计算 expires_at**,创建订单时不计算
|
||||||
|
5. **自然月套餐的月末处理**:
|
||||||
|
- 2月 → 28/29日(闰年判断)
|
||||||
|
- 其他小月(4/6/9/11月)→ 30日
|
||||||
|
- 大月(1/3/5/7/8/10/12月)→ 31日
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: 支持自然月套餐类型
|
||||||
|
系统 SHALL 支持自然月套餐(calendar_type=natural_month),套餐有效期按自然月边界计算。
|
||||||
|
|
||||||
|
**业务价值**:满足运营商月卡业务需求,例如"联通月卡"在当月任意时间激活,均在月末过期,避免用户困惑。
|
||||||
|
|
||||||
|
**技术约束**:
|
||||||
|
- 有效期必须精确到秒(23:59:59)
|
||||||
|
- 闰年判断必须准确(2月29日处理)
|
||||||
|
- 跨年处理必须正确(12月 + 1个月 = 次年1月)
|
||||||
|
|
||||||
|
#### Scenario: 月中购买自然月套餐
|
||||||
|
- **GIVEN** 系统时间为 2026-01-15 10:00:00
|
||||||
|
- **WHEN** 用户购买自然月套餐(calendar_type=natural_month, duration_months=1)并激活
|
||||||
|
- **THEN** 套餐 activated_at=2026-01-15 10:00:00,expires_at=2026-01-31 23:59:59
|
||||||
|
|
||||||
|
#### Scenario: 月末购买自然月套餐(边界条件)
|
||||||
|
- **GIVEN** 系统时间为 2026-01-30 23:00:00
|
||||||
|
- **WHEN** 用户购买自然月套餐(calendar_type=natural_month, duration_months=1)并激活
|
||||||
|
- **THEN** 套餐 activated_at=2026-01-30 23:00:00,expires_at=2026-01-31 23:59:59
|
||||||
|
- **AND** 实际有效期仅剩约 25 小时(业务允许,用户自行承担)
|
||||||
|
|
||||||
|
#### Scenario: 自然月年套餐
|
||||||
|
- **GIVEN** 系统时间为 2026-02-15 10:00:00
|
||||||
|
- **WHEN** 用户购买自然月年套餐(calendar_type=natural_month, duration_months=12)并激活
|
||||||
|
- **THEN** 套餐 activated_at=2026-02-15 10:00:00,expires_at=2027-02-28 23:59:59
|
||||||
|
- **AND** 因为 2027 年不是闰年,2月为 28 日
|
||||||
|
|
||||||
|
#### Scenario: 闰年自然月套餐(边界条件)
|
||||||
|
- **GIVEN** 系统时间为 2028-02-15 10:00:00(2028 年是闰年)
|
||||||
|
- **WHEN** 用户购买自然月年套餐(calendar_type=natural_month, duration_months=12)并激活
|
||||||
|
- **THEN** 套餐 expires_at=2029-02-28 23:59:59
|
||||||
|
- **AND** 因为 2029 年不是闰年,2月为 28 日
|
||||||
|
|
||||||
|
#### Scenario: 闰年2月购买1个月套餐(边界条件)
|
||||||
|
- **GIVEN** 系统时间为 2028-02-15 10:00:00(2028 年是闰年)
|
||||||
|
- **WHEN** 用户购买自然月套餐(calendar_type=natural_month, duration_months=1)并激活
|
||||||
|
- **THEN** 套餐 expires_at=2028-02-29 23:59:59
|
||||||
|
- **AND** 因为 2028 年是闰年,2月为 29 日
|
||||||
|
|
||||||
|
#### Scenario: 跨年自然月套餐(边界条件)
|
||||||
|
- **GIVEN** 系统时间为 2026-12-15 10:00:00
|
||||||
|
- **WHEN** 用户购买自然月套餐(calendar_type=natural_month, duration_months=2)并激活
|
||||||
|
- **THEN** 套餐 expires_at=2027-02-28 23:59:59
|
||||||
|
- **AND** 正确跨年计算(12月 + 2个月 = 次年2月)
|
||||||
|
|
||||||
|
#### Scenario: 自然月季卡(90天 vs 3个月差异)
|
||||||
|
- **GIVEN** 系统时间为 2026-01-31 10:00:00
|
||||||
|
- **WHEN** 用户购买自然月季卡(calendar_type=natural_month, duration_months=3)并激活
|
||||||
|
- **THEN** 套餐 expires_at=2026-04-30 23:59:59
|
||||||
|
- **AND** 实际天数 = 31(1月剩余)+ 28(2月)+ 31(3月)+ 30(4月)= 120 天
|
||||||
|
- **AND** 比按天套餐(90天)多 30 天,体现自然月优势
|
||||||
|
|
||||||
|
### Requirement: 支持按天套餐类型
|
||||||
|
系统 SHALL 支持按天套餐(calendar_type=by_day),套餐有效期按天数精确计算。
|
||||||
|
|
||||||
|
**业务价值**:满足灵活天数套餐需求,例如"7天卡"、"30天卡"、"90天卡",用户在任意时间激活,都获得完整的天数。
|
||||||
|
|
||||||
|
**技术约束**:
|
||||||
|
- 有效期计算公式:`expires_at = activated_at + duration_days 天 - 1秒`(例如:10:00:00 激活 + 1天 = 次日 09:59:59,但为了用户体验,统一为 23:59:59)
|
||||||
|
- 实际实现:`expires_at = (activated_at 日期 + duration_days 天) 的 23:59:59`
|
||||||
|
- 自动处理闰年、大小月、跨年
|
||||||
|
|
||||||
|
#### Scenario: 购买30天套餐
|
||||||
|
- **GIVEN** 系统时间为 2026-01-15 10:00:00
|
||||||
|
- **WHEN** 用户购买30天套餐(calendar_type=by_day, duration_days=30)并激活
|
||||||
|
- **THEN** 套餐 activated_at=2026-01-15 10:00:00,expires_at=2026-02-13 23:59:59
|
||||||
|
- **AND** 实际天数 = 30 天(含激活当天)
|
||||||
|
|
||||||
|
#### Scenario: 购买90天套餐
|
||||||
|
- **GIVEN** 系统时间为 2026-12-01 10:00:00
|
||||||
|
- **WHEN** 用户购买90天套餐(calendar_type=by_day, duration_days=90)并激活
|
||||||
|
- **THEN** 套餐 activated_at=2026-12-01 10:00:00,expires_at=2027-02-28 23:59:59
|
||||||
|
- **AND** 正确跨年计算
|
||||||
|
|
||||||
|
#### Scenario: 跨年购买按天套餐(边界条件)
|
||||||
|
- **GIVEN** 系统时间为 2026-12-20 10:00:00
|
||||||
|
- **WHEN** 用户购买20天套餐(calendar_type=by_day, duration_days=20)并激活
|
||||||
|
- **THEN** 套餐 activated_at=2026-12-20 10:00:00,expires_at=2027-01-08 23:59:59
|
||||||
|
- **AND** 正确跨年计算(12月20日 + 20天 = 1月8日)
|
||||||
|
|
||||||
|
#### Scenario: 闰年按天套餐(边界条件)
|
||||||
|
- **GIVEN** 系统时间为 2028-02-15 10:00:00(2028 年是闰年)
|
||||||
|
- **WHEN** 用户购买30天套餐(calendar_type=by_day, duration_days=30)并激活
|
||||||
|
- **THEN** 套餐 expires_at=2028-03-15 23:59:59
|
||||||
|
- **AND** 正确处理闰年 2月有 29 天
|
||||||
|
|
||||||
|
#### Scenario: 按天套餐与自然月套餐对比(业务理解)
|
||||||
|
- **GIVEN** 系统时间为 2026-01-31 10:00:00
|
||||||
|
- **WHEN** 用户购买30天套餐(calendar_type=by_day, duration_days=30)并激活
|
||||||
|
- **THEN** 套餐 expires_at=2026-03-01 23:59:59
|
||||||
|
- **AND** 如果购买自然月套餐(duration_months=1),expires_at=2026-01-31 23:59:59
|
||||||
|
- **AND** 按天套餐用户获得完整 30 天,更公平
|
||||||
|
|
||||||
|
#### Scenario: 1天套餐(边界条件)
|
||||||
|
- **GIVEN** 系统时间为 2026-01-15 23:30:00
|
||||||
|
- **WHEN** 用户购买1天套餐(calendar_type=by_day, duration_days=1)并激活
|
||||||
|
- **THEN** 套餐 activated_at=2026-01-15 23:30:00,expires_at=2026-01-15 23:59:59
|
||||||
|
- **AND** 实际有效期仅剩 29 分钟(业务允许,用户自行承担)
|
||||||
|
|
||||||
|
#### Scenario: 365天套餐(年卡)
|
||||||
|
- **GIVEN** 系统时间为 2026-01-01 00:00:00
|
||||||
|
- **WHEN** 用户购买365天套餐(calendar_type=by_day, duration_days=365)并激活
|
||||||
|
- **THEN** 套餐 expires_at=2026-12-31 23:59:59
|
||||||
|
- **AND** 精确一年有效期
|
||||||
|
|
||||||
|
### Requirement: 套餐周期类型可配置
|
||||||
|
系统 SHALL 允许管理员在创建套餐时指定 calendar_type,可选值为 natural_month 或 by_day。
|
||||||
|
|
||||||
|
**业务规则**:
|
||||||
|
- calendar_type 必填,默认值为 `by_day`(向后兼容)
|
||||||
|
- natural_month 时必须提供 duration_months(1-120)
|
||||||
|
- by_day 时必须提供 duration_days(1-3650)
|
||||||
|
- 不允许同时指定 duration_months 和 duration_days(冗余)
|
||||||
|
|
||||||
|
**数据验证**:
|
||||||
|
- calendar_type ∈ {natural_month, by_day}
|
||||||
|
- duration_months ∈ [1, 120](最长10年)
|
||||||
|
- duration_days ∈ [1, 3650](最长10年)
|
||||||
|
|
||||||
|
#### Scenario: 创建自然月套餐(成功)
|
||||||
|
- **GIVEN** 管理员已登录后台系统
|
||||||
|
- **WHEN** 管理员通过 POST /api/admin/packages 创建套餐
|
||||||
|
- **AND** 请求体包含 calendar_type=natural_month, duration_months=3, package_name="联通季卡"
|
||||||
|
- **THEN** 系统返回 200,响应数据包含 calendar_type=natural_month, duration_months=3
|
||||||
|
- **AND** 数据库 tb_package 表新增一条记录,calendar_type=natural_month
|
||||||
|
|
||||||
|
#### Scenario: 创建按天套餐(成功)
|
||||||
|
- **GIVEN** 管理员已登录后台系统
|
||||||
|
- **WHEN** 管理员通过 POST /api/admin/packages 创建套餐
|
||||||
|
- **AND** 请求体包含 calendar_type=by_day, duration_days=60, package_name="60天卡"
|
||||||
|
- **THEN** 系统返回 200,响应数据包含 calendar_type=by_day, duration_days=60
|
||||||
|
- **AND** 数据库 tb_package 表新增一条记录,calendar_type=by_day, duration_days=60
|
||||||
|
|
||||||
|
#### Scenario: 自然月套餐缺少 duration_months(参数验证失败)
|
||||||
|
- **GIVEN** 管理员已登录后台系统
|
||||||
|
- **WHEN** 管理员通过 POST /api/admin/packages 创建套餐
|
||||||
|
- **AND** 请求体包含 calendar_type=natural_month 但未提供 duration_months
|
||||||
|
- **THEN** 系统返回错误 400,错误消息:"自然月套餐必须指定 duration_months"
|
||||||
|
- **AND** 数据库无新增记录
|
||||||
|
|
||||||
|
#### Scenario: 按天套餐缺少 duration_days(参数验证失败)
|
||||||
|
- **GIVEN** 管理员已登录后台系统
|
||||||
|
- **WHEN** 管理员通过 POST /api/admin/packages 创建套餐
|
||||||
|
- **AND** 请求体包含 calendar_type=by_day 但未提供 duration_days
|
||||||
|
- **THEN** 系统返回错误 400,错误消息:"按天套餐必须指定 duration_days"
|
||||||
|
- **AND** 数据库无新增记录
|
||||||
|
|
||||||
|
#### Scenario: calendar_type 非法值(参数验证失败)
|
||||||
|
- **GIVEN** 管理员已登录后台系统
|
||||||
|
- **WHEN** 管理员通过 POST /api/admin/packages 创建套餐
|
||||||
|
- **AND** 请求体包含 calendar_type=weekly(非法值)
|
||||||
|
- **THEN** 系统返回错误 400,错误消息:"calendar_type 只能为 natural_month 或 by_day"
|
||||||
|
- **AND** 数据库无新增记录
|
||||||
|
|
||||||
|
#### Scenario: duration_months 超出范围(参数验证失败)
|
||||||
|
- **GIVEN** 管理员已登录后台系统
|
||||||
|
- **WHEN** 管理员通过 POST /api/admin/packages 创建套餐
|
||||||
|
- **AND** 请求体包含 calendar_type=natural_month, duration_months=150(超出范围)
|
||||||
|
- **THEN** 系统返回错误 400,错误消息:"duration_months 必须在 1-120 之间"
|
||||||
|
- **AND** 数据库无新增记录
|
||||||
|
|
||||||
|
#### Scenario: duration_days 超出范围(参数验证失败)
|
||||||
|
- **GIVEN** 管理员已登录后台系统
|
||||||
|
- **WHEN** 管理员通过 POST /api/admin/packages 创建套餐
|
||||||
|
- **AND** 请求体包含 calendar_type=by_day, duration_days=5000(超出范围)
|
||||||
|
- **THEN** 系统返回错误 400,错误消息:"duration_days 必须在 1-3650 之间"
|
||||||
|
- **AND** 数据库无新增记录
|
||||||
|
|
||||||
|
#### Scenario: 同时提供 duration_months 和 duration_days(参数冗余)
|
||||||
|
- **GIVEN** 管理员已登录后台系统
|
||||||
|
- **WHEN** 管理员通过 POST /api/admin/packages 创建套餐
|
||||||
|
- **AND** 请求体包含 calendar_type=natural_month, duration_months=3, duration_days=90
|
||||||
|
- **THEN** 系统返回错误 400,错误消息:"不允许同时指定 duration_months 和 duration_days"
|
||||||
|
- **AND** 数据库无新增记录
|
||||||
|
|
||||||
|
### Requirement: 套餐激活时根据类型计算到期时间
|
||||||
|
系统 SHALL 在套餐激活时,根据 calendar_type 自动计算并设置 expires_at。
|
||||||
|
|
||||||
|
**计算时机**:
|
||||||
|
- 订单创建时不计算(activated_at 和 expires_at 均为 NULL)
|
||||||
|
- 套餐激活时才计算(首次实名激活、主套餐排队激活、立即激活)
|
||||||
|
|
||||||
|
**计算公式**:
|
||||||
|
- 自然月:`expires_at = (activated_at 月份 + duration_months) 的月末 23:59:59`
|
||||||
|
- 按天:`expires_at = (activated_at 日期 + duration_days) 的 23:59:59`
|
||||||
|
|
||||||
|
**幂等性保证**:
|
||||||
|
- 同一套餐多次调用激活接口,expires_at 不变(使用已有的 activated_at 计算)
|
||||||
|
|
||||||
|
#### Scenario: 激活自然月套餐
|
||||||
|
- **GIVEN** PackageUsage 记录 status=0, package.calendar_type=natural_month, package.duration_months=1
|
||||||
|
- **WHEN** 套餐激活,activated_at 设置为 2026-02-15 10:00:00
|
||||||
|
- **THEN** 系统计算 expires_at=2026-02-28 23:59:59
|
||||||
|
- **AND** PackageUsage.status 更新为 1(生效中)
|
||||||
|
|
||||||
|
#### Scenario: 激活按天套餐
|
||||||
|
- **GIVEN** PackageUsage 记录 status=0, package.calendar_type=by_day, package.duration_days=30
|
||||||
|
- **WHEN** 套餐激活,activated_at 设置为 2026-02-15 10:00:00
|
||||||
|
- **THEN** 系统计算 expires_at=2026-03-16 23:59:59
|
||||||
|
- **AND** PackageUsage.status 更新为 1(生效中)
|
||||||
|
|
||||||
|
#### Scenario: 激活时处理闰年(自然月)
|
||||||
|
- **GIVEN** PackageUsage 记录 status=0, package.calendar_type=natural_month, package.duration_months=1
|
||||||
|
- **WHEN** 套餐激活,activated_at 设置为 2028-02-15 10:00:00(闰年)
|
||||||
|
- **THEN** 系统计算 expires_at=2028-02-29 23:59:59
|
||||||
|
- **AND** 正确识别闰年,2月为 29 日
|
||||||
|
|
||||||
|
#### Scenario: 激活时处理跨年(自然月)
|
||||||
|
- **GIVEN** PackageUsage 记录 status=0, package.calendar_type=natural_month, package.duration_months=3
|
||||||
|
- **WHEN** 套餐激活,activated_at 设置为 2026-11-15 10:00:00
|
||||||
|
- **THEN** 系统计算 expires_at=2027-02-28 23:59:59
|
||||||
|
- **AND** 正确跨年计算(11月 + 3个月 = 次年2月)
|
||||||
|
|
||||||
|
#### Scenario: 重复激活请求(幂等性保证)
|
||||||
|
- **GIVEN** PackageUsage 记录 status=1, activated_at=2026-02-15 10:00:00, expires_at=2026-02-28 23:59:59
|
||||||
|
- **WHEN** 再次调用激活接口(重试或并发请求)
|
||||||
|
- **THEN** 系统检测到 status=1,直接返回成功,不重新计算 expires_at
|
||||||
|
- **AND** expires_at 保持不变
|
||||||
|
|
||||||
|
#### Scenario: 激活失败回滚(异常处理)
|
||||||
|
- **GIVEN** PackageUsage 记录 status=0
|
||||||
|
- **WHEN** 套餐激活过程中数据库更新失败(例如网络中断)
|
||||||
|
- **THEN** 系统事务回滚,PackageUsage.status 保持为 0
|
||||||
|
- **AND** activated_at 和 expires_at 均为 NULL
|
||||||
|
- **AND** 返回错误消息:"套餐激活失败,请重试"
|
||||||
|
|
||||||
|
### Requirement: 套餐类型信息可查询
|
||||||
|
系统 SHALL 在套餐详情和列表 API 中返回 calendar_type 和对应的 duration 字段。
|
||||||
|
|
||||||
|
**API 响应格式**:
|
||||||
|
- 自然月套餐:返回 `calendar_type`, `duration_months`, `duration_days=null`
|
||||||
|
- 按天套餐:返回 `calendar_type`, `duration_days`, `duration_months=null`
|
||||||
|
|
||||||
|
**性能要求**:
|
||||||
|
- 套餐详情查询 P95 < 50ms
|
||||||
|
- 套餐列表查询 P95 < 200ms(分页,每页最多 100 条)
|
||||||
|
|
||||||
|
#### Scenario: 查询自然月套餐详情
|
||||||
|
- **GIVEN** 数据库存在套餐 ID=123,calendar_type=natural_month, duration_months=12
|
||||||
|
- **WHEN** 用户通过 GET /api/admin/packages/123 查询套餐
|
||||||
|
- **THEN** 系统返回 200,响应 JSON 包含:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 123,
|
||||||
|
"calendar_type": "natural_month",
|
||||||
|
"duration_months": 12,
|
||||||
|
"duration_days": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Scenario: 查询按天套餐详情
|
||||||
|
- **GIVEN** 数据库存在套餐 ID=456,calendar_type=by_day, duration_days=90
|
||||||
|
- **WHEN** 用户通过 GET /api/admin/packages/456 查询套餐
|
||||||
|
- **THEN** 系统返回 200,响应 JSON 包含:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 456,
|
||||||
|
"calendar_type": "by_day",
|
||||||
|
"duration_days": 90,
|
||||||
|
"duration_months": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Scenario: 套餐列表显示类型
|
||||||
|
- **GIVEN** 数据库存在 50 个套餐,包含自然月和按天两种类型
|
||||||
|
- **WHEN** 管理员通过 GET /api/admin/packages?page=1&page_size=20 获取套餐列表
|
||||||
|
- **THEN** 系统返回 200,响应包含 20 个套餐数据
|
||||||
|
- **AND** 每个套餐数据包含 calendar_type 字段
|
||||||
|
- **AND** 响应时间 < 200ms(P95)
|
||||||
|
|
||||||
|
#### Scenario: 查询不存在的套餐(错误处理)
|
||||||
|
- **GIVEN** 数据库不存在套餐 ID=999
|
||||||
|
- **WHEN** 用户通过 GET /api/admin/packages/999 查询套餐
|
||||||
|
- **THEN** 系统返回 404,错误消息:"套餐不存在"
|
||||||
|
|
||||||
|
### Requirement: 套餐类型可更新
|
||||||
|
系统 SHALL 允许管理员更新套餐的 calendar_type 和 duration 字段(仅限未生效的套餐)。
|
||||||
|
|
||||||
|
**更新限制**:
|
||||||
|
- 已有生效中 PackageUsage 记录的套餐,禁止修改 calendar_type 和 duration
|
||||||
|
- 只允许修改处于"下架"状态(shelf_status=2)且无生效中使用记录的套餐
|
||||||
|
|
||||||
|
#### Scenario: 更新下架套餐的类型(成功)
|
||||||
|
- **GIVEN** 套餐 ID=123, shelf_status=2(下架),无生效中 PackageUsage 记录
|
||||||
|
- **WHEN** 管理员通过 PUT /api/admin/packages/123 更新套餐
|
||||||
|
- **AND** 请求体包含 calendar_type=by_day, duration_days=60(从自然月改为按天)
|
||||||
|
- **THEN** 系统返回 200,套餐更新成功
|
||||||
|
- **AND** 数据库 calendar_type 更新为 by_day, duration_days=60, duration_months=null
|
||||||
|
|
||||||
|
#### Scenario: 更新已上架套餐(禁止)
|
||||||
|
- **GIVEN** 套餐 ID=123, shelf_status=1(上架),有生效中 PackageUsage 记录
|
||||||
|
- **WHEN** 管理员通过 PUT /api/admin/packages/123 更新套餐
|
||||||
|
- **AND** 请求体包含 calendar_type=by_day
|
||||||
|
- **THEN** 系统返回错误 400,错误消息:"该套餐有生效中的使用记录,禁止修改类型"
|
||||||
|
- **AND** 数据库不更新
|
||||||
|
|
||||||
|
#### Scenario: 更新套餐其他字段(允许)
|
||||||
|
- **GIVEN** 套餐 ID=123, shelf_status=1(上架),有生效中 PackageUsage 记录
|
||||||
|
- **WHEN** 管理员通过 PUT /api/admin/packages/123 更新套餐
|
||||||
|
- **AND** 请求体仅包含 suggested_retail_price=5000(修改价格,不修改类型)
|
||||||
|
- **THEN** 系统返回 200,价格更新成功
|
||||||
|
- **AND** calendar_type 和 duration 保持不变
|
||||||
|
|
||||||
|
## 数据一致性保证
|
||||||
|
|
||||||
|
1. **套餐激活时的并发控制**:使用 Redis 分布式锁(key: `package:activation:lock:{usage_id}`),TTL=30s
|
||||||
|
2. **expires_at 精度要求**:数据库字段类型为 `timestamp`,精确到秒
|
||||||
|
3. **时区统一**:所有时间计算使用服务器时区(Asia/Shanghai)
|
||||||
|
4. **闰年判断准确性**:使用 Go 标准库 `time.Date()` 自动处理闰年
|
||||||
|
|
||||||
|
## 性能指标
|
||||||
|
|
||||||
|
| 操作 | 性能要求 | 监控指标 |
|
||||||
|
|------|---------|---------|
|
||||||
|
| 套餐创建 API | P95 < 100ms | API 响应时间 |
|
||||||
|
| 套餐查询 API | P95 < 50ms | 数据库查询时间 |
|
||||||
|
| 套餐激活计算 | < 10ms | 有效期计算耗时 |
|
||||||
|
| 套餐列表 API | P95 < 200ms | API 响应时间 |
|
||||||
|
|
||||||
|
## 错误码定义
|
||||||
|
|
||||||
|
| 错误码 | HTTP 状态码 | 错误消息 | 场景 |
|
||||||
|
|--------|------------|---------|------|
|
||||||
|
| CodeInvalidParam | 400 | 自然月套餐必须指定 duration_months | 参数验证失败 |
|
||||||
|
| CodeInvalidParam | 400 | 按天套餐必须指定 duration_days | 参数验证失败 |
|
||||||
|
| CodeInvalidParam | 400 | calendar_type 只能为 natural_month 或 by_day | 参数验证失败 |
|
||||||
|
| CodeInvalidParam | 400 | duration_months 必须在 1-120 之间 | 参数验证失败 |
|
||||||
|
| CodeInvalidParam | 400 | duration_days 必须在 1-3650 之间 | 参数验证失败 |
|
||||||
|
| CodeForbidden | 403 | 该套餐有生效中的使用记录,禁止修改类型 | 业务规则限制 |
|
||||||
|
| CodeNotFound | 404 | 套餐不存在 | 资源不存在 |
|
||||||
|
|
||||||
|
## 数据迁移策略
|
||||||
|
|
||||||
|
**激进策略**(开发阶段):
|
||||||
|
1. **历史套餐数据强制转换**:
|
||||||
|
- 现有套餐统一设置 `calendar_type=by_day`
|
||||||
|
- 根据 `duration_months` 计算 `duration_days = duration_months * 30`
|
||||||
|
- 数据迁移后,所有套餐都有明确的 `calendar_type` 和对应的 `duration` 字段
|
||||||
|
|
||||||
|
2. **历史 PackageUsage 数据处理**:
|
||||||
|
- 保留 `activated_at` 和 `expires_at`(不重新计算)
|
||||||
|
- 新增 `calendar_type`, `data_reset_cycle` 字段,从关联的 Package 复制
|
||||||
|
|
||||||
|
3. **API 破坏性变更**:
|
||||||
|
- `calendar_type` 字段**必填**,无默认值
|
||||||
|
- 创建套餐时必须明确指定 `calendar_type` 和对应的 `duration` 字段
|
||||||
|
- 不支持只提供 `duration_months` 而不指定 `calendar_type` 的旧请求
|
||||||
809
openspec/specs/package-data-reset/spec.md
Normal file
809
openspec/specs/package-data-reset/spec.md
Normal file
@@ -0,0 +1,809 @@
|
|||||||
|
# Spec: 套餐流量重置周期管理
|
||||||
|
|
||||||
|
## 业务背景
|
||||||
|
|
||||||
|
### 为什么需要流量重置周期管理
|
||||||
|
|
||||||
|
**现状问题**:
|
||||||
|
- 运营商套餐的流量重置规则多样:按日、按月、按年、不重置
|
||||||
|
- 套餐有效期与流量重置周期是两个独立维度(如12个月套餐可按月重置流量)
|
||||||
|
- 不同运营商有特殊规则(如联通按27号重置,而非1号)
|
||||||
|
- 用户需要清晰知道流量何时重置,避免超额使用
|
||||||
|
|
||||||
|
**业务目标**:
|
||||||
|
- 支持灵活配置流量重置周期(daily/monthly/yearly/none)
|
||||||
|
- 流量重置周期独立于套餐有效期类型
|
||||||
|
- 自动调度流量重置任务(定时任务)
|
||||||
|
- 保留历史流量使用记录,仅重置当前累计值
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 业务规则
|
||||||
|
|
||||||
|
### 1. 重置周期类型
|
||||||
|
|
||||||
|
| data_reset_cycle | 说明 | 重置时间点 | 适用场景 |
|
||||||
|
|------------------|------|-----------|---------|
|
||||||
|
| `daily` | 按日重置 | 每天 00:00:00 | 日租卡、按日计费套餐 |
|
||||||
|
| `monthly` | 按月重置 | 每月1号 00:00:00(联通27号) | 月租套餐、年套餐按月清零 |
|
||||||
|
| `yearly` | 按年重置 | 每年1月1日 00:00:00 | 年度套餐 |
|
||||||
|
| `none` | 不重置 | 永不重置 | 一次性流量包 |
|
||||||
|
|
||||||
|
### 2. 重置时间点规则
|
||||||
|
|
||||||
|
**通用规则**:
|
||||||
|
```
|
||||||
|
每日重置:
|
||||||
|
- 触发时间:每天 00:00:00
|
||||||
|
- 重置对象:data_reset_cycle=daily AND status=1(生效中)
|
||||||
|
|
||||||
|
每月重置:
|
||||||
|
- 通用触发时间:每月1号 00:00:00
|
||||||
|
- 联通特殊规则:每月27号 00:00:00
|
||||||
|
- 重置对象:data_reset_cycle=monthly AND status=1(生效中)
|
||||||
|
|
||||||
|
每年重置:
|
||||||
|
- 触发时间:每年1月1日 00:00:00
|
||||||
|
- 重置对象:data_reset_cycle=yearly AND status=1(生效中)
|
||||||
|
```
|
||||||
|
|
||||||
|
**联通特殊规则**:
|
||||||
|
- 如果套餐的 `isp=unicom`(联通),`data_reset_cycle=monthly` → 每月27号00:00:00重置
|
||||||
|
- 其他运营商按1号重置
|
||||||
|
|
||||||
|
### 3. 重置逻辑
|
||||||
|
|
||||||
|
重置流量时的操作:
|
||||||
|
|
||||||
|
```
|
||||||
|
重置流程:
|
||||||
|
1. 查询需要重置的套餐(根据 data_reset_cycle 和 status=1)
|
||||||
|
2. 批量更新:
|
||||||
|
- data_usage_mb = 0
|
||||||
|
- last_reset_at = 当前时间
|
||||||
|
3. 不删除 PackageUsageDailyRecord 历史记录
|
||||||
|
4. 记录重置日志
|
||||||
|
```
|
||||||
|
|
||||||
|
**不重置的内容**:
|
||||||
|
- ❌ PackageUsageDailyRecord 历史记录(保留)
|
||||||
|
- ❌ 套餐有效期(expires_at 不变)
|
||||||
|
- ❌ 套餐状态(status 不变)
|
||||||
|
- ✅ 仅重置 data_usage_mb = 0
|
||||||
|
|
||||||
|
### 4. 重置条件
|
||||||
|
|
||||||
|
仅对以下套餐执行重置:
|
||||||
|
- `status=1`(生效中)
|
||||||
|
- `data_reset_cycle != none`
|
||||||
|
- `expires_at > 当前时间`(未过期)
|
||||||
|
|
||||||
|
**不重置的套餐**:
|
||||||
|
- status=0(待生效)
|
||||||
|
- status=2(已用完)
|
||||||
|
- status=3(已过期)
|
||||||
|
- status=4(已失效)
|
||||||
|
- data_reset_cycle=none(不重置)
|
||||||
|
|
||||||
|
### 5. 流量重置与套餐有效期独立
|
||||||
|
|
||||||
|
流量重置周期与套餐有效期类型独立:
|
||||||
|
|
||||||
|
| 套餐配置 | 流量重置行为 | 举例 |
|
||||||
|
|---------|-------------|------|
|
||||||
|
| 12个月套餐 + monthly | 每月1号重置流量,共重置12次 | 年套餐按月清零 |
|
||||||
|
| 12个月套餐 + yearly | 激活时清零,12个月内不重置 | 年度总量套餐 |
|
||||||
|
| 30天套餐 + daily | 每天0点重置流量,共重置30次 | 日租卡 |
|
||||||
|
| 30天套餐 + none | 30天内累计使用,不重置 | 一次性流量包 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: 支持流量重置周期配置
|
||||||
|
|
||||||
|
系统 SHALL 支持为套餐配置流量重置周期(data_reset_cycle),可选值为 daily、monthly、yearly、none。
|
||||||
|
|
||||||
|
#### Scenario: 创建按日重置的套餐
|
||||||
|
- **WHEN** 管理员创建套餐时指定 data_reset_cycle=daily
|
||||||
|
- **THEN** 系统创建成功,套餐的 data_reset_cycle=daily
|
||||||
|
|
||||||
|
#### Scenario: 创建按月重置的套餐
|
||||||
|
- **WHEN** 管理员创建套餐时指定 data_reset_cycle=monthly
|
||||||
|
- **THEN** 系统创建成功,套餐的 data_reset_cycle=monthly
|
||||||
|
|
||||||
|
#### Scenario: 创建按年重置的套餐
|
||||||
|
- **WHEN** 管理员创建套餐时指定 data_reset_cycle=yearly
|
||||||
|
- **THEN** 系统创建成功,套餐的 data_reset_cycle=yearly
|
||||||
|
|
||||||
|
#### Scenario: 创建不重置流量的套餐
|
||||||
|
- **WHEN** 管理员创建套餐时指定 data_reset_cycle=none
|
||||||
|
- **THEN** 系统创建成功,套餐的 data_reset_cycle=none
|
||||||
|
|
||||||
|
#### Scenario: 更新套餐的重置周期配置
|
||||||
|
- **GIVEN** 套餐 ID=123,data_reset_cycle=monthly
|
||||||
|
- **WHEN** 管理员更新套餐配置为 data_reset_cycle=daily
|
||||||
|
- **THEN** 系统更新成功,该套餐后续流量重置遵循新配置
|
||||||
|
- **AND** 已有的 PackageUsage 不受影响(仍按原配置重置)
|
||||||
|
|
||||||
|
### Requirement: 流量重置周期独立于套餐有效期
|
||||||
|
|
||||||
|
系统 SHALL 允许套餐的流量重置周期与套餐有效期类型独立配置。
|
||||||
|
|
||||||
|
#### Scenario: 12个月套餐按月重置流量
|
||||||
|
- **GIVEN** 套餐配置为 duration_months=12, data_reset_cycle=monthly
|
||||||
|
- **WHEN** 套餐在 2026-02-01 激活
|
||||||
|
- **THEN** 套餐有效期到 2027-01-31,流量在每月1号重置(共12次)
|
||||||
|
|
||||||
|
#### Scenario: 12个月套餐按年重置流量
|
||||||
|
- **GIVEN** 套餐配置为 duration_months=12, data_reset_cycle=yearly
|
||||||
|
- **WHEN** 套餐在 2026-02-01 激活
|
||||||
|
- **THEN** 套餐有效期到 2027-01-31,流量仅在激活时清零,12个月内不重置
|
||||||
|
|
||||||
|
#### Scenario: 30天套餐按日重置流量
|
||||||
|
- **GIVEN** 套餐配置为 duration_days=30, data_reset_cycle=daily
|
||||||
|
- **WHEN** 套餐在 2026-02-01 激活
|
||||||
|
- **THEN** 套餐有效期到 2026-03-02,流量每天0点重置(共30次)
|
||||||
|
|
||||||
|
#### Scenario: 自然月套餐按月重置
|
||||||
|
- **GIVEN** 套餐配置为 calendar_type=natural_month, duration_months=1, data_reset_cycle=monthly
|
||||||
|
- **WHEN** 套餐在 2026-02-15 激活
|
||||||
|
- **THEN** 套餐有效期到 2026-02-28,流量在3月1日不重置(因为套餐已过期)
|
||||||
|
|
||||||
|
### Requirement: 每日流量重置调度
|
||||||
|
|
||||||
|
系统 SHALL 每天 00:00:00 自动重置所有 data_reset_cycle=daily 的生效中套餐的 data_usage_mb 为 0。
|
||||||
|
|
||||||
|
#### Scenario: 每日流量重置成功
|
||||||
|
- **GIVEN** 系统时间到达 2026-02-11 00:00:00
|
||||||
|
- **AND** 存在3个 data_reset_cycle=daily 且 status=1 的套餐
|
||||||
|
- **WHEN** 定时任务执行
|
||||||
|
- **THEN** 系统批量更新这3个套餐:
|
||||||
|
- data_usage_mb = 0
|
||||||
|
- last_reset_at = 2026-02-11 00:00:00
|
||||||
|
|
||||||
|
#### Scenario: 非每日重置套餐不受影响
|
||||||
|
- **GIVEN** 系统时间到达 2026-02-11 00:00:00
|
||||||
|
- **AND** 存在 data_reset_cycle=monthly 的套餐
|
||||||
|
- **WHEN** 定时任务执行
|
||||||
|
- **THEN** 这些套餐的 data_usage_mb 不变
|
||||||
|
|
||||||
|
#### Scenario: 待生效和已过期套餐不重置
|
||||||
|
- **GIVEN** 系统时间到达 2026-02-11 00:00:00
|
||||||
|
- **AND** 存在 data_reset_cycle=daily 但 status=0(待生效)的套餐
|
||||||
|
- **AND** 存在 data_reset_cycle=daily 但 status=3(已过期)的套餐
|
||||||
|
- **WHEN** 定时任务执行
|
||||||
|
- **THEN** 这些套餐不被重置
|
||||||
|
|
||||||
|
#### Scenario: 每日重置记录到日志
|
||||||
|
- **GIVEN** 系统时间到达 2026-02-11 00:00:00
|
||||||
|
- **AND** 重置了5个套餐
|
||||||
|
- **WHEN** 定时任务执行完成
|
||||||
|
- **THEN** 系统记录 Info 日志:
|
||||||
|
- "每日流量重置完成,重置套餐数量:5"
|
||||||
|
|
||||||
|
### Requirement: 每月流量重置调度
|
||||||
|
|
||||||
|
系统 SHALL 每月1号 00:00:00 自动重置所有 data_reset_cycle=monthly 的生效中套餐的 data_usage_mb 为 0。
|
||||||
|
|
||||||
|
#### Scenario: 每月流量重置成功
|
||||||
|
- **GIVEN** 系统时间到达 2026-03-01 00:00:00
|
||||||
|
- **AND** 存在5个 data_reset_cycle=monthly 且 status=1 的套餐(非联通)
|
||||||
|
- **WHEN** 定时任务执行
|
||||||
|
- **THEN** 系统批量更新这5个套餐:
|
||||||
|
- data_usage_mb = 0
|
||||||
|
- last_reset_at = 2026-03-01 00:00:00
|
||||||
|
|
||||||
|
#### Scenario: 联通运营商特殊重置周期
|
||||||
|
- **GIVEN** 系统时间到达 2026-02-27 00:00:00
|
||||||
|
- **AND** 存在3个 data_reset_cycle=monthly 且 isp=unicom 且 status=1 的套餐
|
||||||
|
- **WHEN** 定时任务执行
|
||||||
|
- **THEN** 系统批量更新这3个套餐:
|
||||||
|
- data_usage_mb = 0
|
||||||
|
- last_reset_at = 2026-02-27 00:00:00
|
||||||
|
|
||||||
|
#### Scenario: 跨月边界流量统计
|
||||||
|
- **GIVEN** 套餐在 2026-01-31 23:50:00 使用了 5GB 流量
|
||||||
|
- **AND** data_usage_mb = 5GB
|
||||||
|
- **WHEN** 系统时间到达 2026-02-01 00:00:00,触发重置
|
||||||
|
- **THEN** 套餐的 data_usage_mb 重置为 0
|
||||||
|
- **AND** 1月31日的 PackageUsageDailyRecord 仍存在(data_usage_mb=5GB)
|
||||||
|
|
||||||
|
#### Scenario: 跨年边界流量重置
|
||||||
|
- **GIVEN** 套餐在 2026-12-31 使用了 10GB 流量
|
||||||
|
- **WHEN** 系统时间到达 2027-01-01 00:00:00,触发重置
|
||||||
|
- **THEN** 套餐的 data_usage_mb 重置为 0
|
||||||
|
- **AND** 2026年12月的日记录仍存在
|
||||||
|
|
||||||
|
### Requirement: 每年流量重置调度
|
||||||
|
|
||||||
|
系统 SHALL 每年1月1日 00:00:00 自动重置所有 data_reset_cycle=yearly 的生效中套餐的 data_usage_mb 为 0。
|
||||||
|
|
||||||
|
#### Scenario: 每年流量重置成功
|
||||||
|
- **GIVEN** 系统时间到达 2027-01-01 00:00:00
|
||||||
|
- **AND** 存在2个 data_reset_cycle=yearly 且 status=1 的套餐
|
||||||
|
- **WHEN** 定时任务执行
|
||||||
|
- **THEN** 系统批量更新这2个套餐:
|
||||||
|
- data_usage_mb = 0
|
||||||
|
- last_reset_at = 2027-01-01 00:00:00
|
||||||
|
|
||||||
|
#### Scenario: 12个月套餐按年重置
|
||||||
|
- **GIVEN** 套餐在 2026-06-15 激活,duration_months=12,data_reset_cycle=yearly
|
||||||
|
- **AND** expires_at=2027-06-15
|
||||||
|
- **WHEN** 系统时间到达 2027-01-01 00:00:00
|
||||||
|
- **THEN** 套餐流量重置(因为仍在有效期内)
|
||||||
|
|
||||||
|
#### Scenario: 已过期的年套餐不重置
|
||||||
|
- **GIVEN** 套餐在 2025-06-15 激活,duration_months=12,data_reset_cycle=yearly
|
||||||
|
- **AND** expires_at=2026-06-15(已过期)
|
||||||
|
- **WHEN** 系统时间到达 2027-01-01 00:00:00
|
||||||
|
- **THEN** 套餐不被重置(status=3)
|
||||||
|
|
||||||
|
### Requirement: 不重置流量的套餐
|
||||||
|
|
||||||
|
系统 SHALL 对 data_reset_cycle=none 的套餐,在整个有效期内不重置 data_usage_mb。
|
||||||
|
|
||||||
|
#### Scenario: 套餐有效期内流量不重置
|
||||||
|
- **GIVEN** 套餐 data_reset_cycle=none,duration_days=30
|
||||||
|
- **AND** 套餐在 2026-02-01 激活
|
||||||
|
- **WHEN** 套餐在30天内使用了 80GB 流量
|
||||||
|
- **THEN** data_usage_mb 累计为 80GB,期间从未重置
|
||||||
|
|
||||||
|
#### Scenario: 新激活时流量清零
|
||||||
|
- **GIVEN** 套餐 data_reset_cycle=none
|
||||||
|
- **WHEN** 套餐首次激活
|
||||||
|
- **THEN** data_usage_mb 初始化为 0
|
||||||
|
|
||||||
|
#### Scenario: 不重置套餐不被定时任务影响
|
||||||
|
- **GIVEN** 系统时间到达每日/每月/每年重置时刻
|
||||||
|
- **AND** 存在 data_reset_cycle=none 的套餐
|
||||||
|
- **WHEN** 定时任务执行
|
||||||
|
- **THEN** 这些套餐不被查询,不执行任何操作
|
||||||
|
|
||||||
|
### Requirement: 流量重置周期信息可查询
|
||||||
|
|
||||||
|
系统 SHALL 在套餐详情和使用记录 API 中返回 data_reset_cycle 和 last_reset_at。
|
||||||
|
|
||||||
|
#### Scenario: 查询套餐流量重置配置
|
||||||
|
- **WHEN** 用户通过 GET /api/admin/packages/:id 查询套餐
|
||||||
|
- **THEN** 响应包含:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data_reset_cycle": "monthly",
|
||||||
|
"isp": "unicom"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Scenario: 查询套餐使用记录的重置信息
|
||||||
|
- **WHEN** 用户通过 GET /api/admin/package-usage/:id 查询套餐使用记录
|
||||||
|
- **THEN** 响应包含:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data_reset_cycle": "monthly",
|
||||||
|
"last_reset_at": "2026-02-27T00:00:00Z",
|
||||||
|
"data_usage_mb": 1024
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Scenario: 客户端查询流量重置信息
|
||||||
|
- **WHEN** 客户通过 GET /api/customer/package-usage 查询自己的套餐
|
||||||
|
- **THEN** 响应包含 data_reset_cycle 和 last_reset_at,方便用户知道下次重置时间
|
||||||
|
|
||||||
|
### Requirement: 流量重置不影响日记录
|
||||||
|
|
||||||
|
系统 SHALL 在流量重置时保留历史日记录(PackageUsageDailyRecord),仅重置当前 data_usage_mb。
|
||||||
|
|
||||||
|
#### Scenario: 重置后历史记录可查
|
||||||
|
- **GIVEN** 套餐在 2026-02-28 使用了 10GB 流量
|
||||||
|
- **AND** PackageUsageDailyRecord 记录了 2026-02-28 的 10GB 使用量
|
||||||
|
- **WHEN** 系统时间到达 2026-03-01 00:00:00,触发重置
|
||||||
|
- **THEN** 套餐的 data_usage_mb 重置为 0
|
||||||
|
- **AND** 2026-02-28 的 PackageUsageDailyRecord 记录仍存在且可查询
|
||||||
|
|
||||||
|
#### Scenario: 重置后新的流量使用
|
||||||
|
- **GIVEN** 套餐在 2026-03-01 00:00:00 重置后,data_usage_mb=0
|
||||||
|
- **WHEN** 2026-03-01 10:00:00 使用了 2GB 流量
|
||||||
|
- **THEN** 套餐的 data_usage_mb=2GB
|
||||||
|
- **AND** 写入新的 PackageUsageDailyRecord(date=2026-03-01, data_usage_mb=2GB)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 边界条件
|
||||||
|
|
||||||
|
### 1. 跨月边界
|
||||||
|
|
||||||
|
- **场景**:套餐在月末23:59:59使用流量,次月0:00:00触发重置
|
||||||
|
- **处理**:
|
||||||
|
- 重置任务在 00:00:00 执行
|
||||||
|
- 月末最后一笔流量扣减已提交(日记录已写入)
|
||||||
|
- 重置时仅清零 data_usage_mb,不影响日记录
|
||||||
|
|
||||||
|
### 2. 跨年边界
|
||||||
|
|
||||||
|
- **场景**:套餐在12月31日使用流量,1月1日触发年度重置
|
||||||
|
- **处理**:
|
||||||
|
- 与跨月边界相同
|
||||||
|
- 年度重置只重置 data_reset_cycle=yearly 的套餐
|
||||||
|
- 月度重置套餐不受年度重置影响
|
||||||
|
|
||||||
|
### 3. 并发流量扣减和重置
|
||||||
|
|
||||||
|
- **场景**:重置任务执行的同时,有流量扣减请求
|
||||||
|
- **处理**:
|
||||||
|
- 使用行锁:`SELECT * FROM package_usage WHERE id=? FOR UPDATE`
|
||||||
|
- 先完成的操作生效,后完成的操作基于新值执行
|
||||||
|
- 如果重置先完成 → 流量扣减从0开始累加
|
||||||
|
- 如果扣减先完成 → 重置清零后续扣减继续
|
||||||
|
|
||||||
|
### 4. 定时任务执行延迟
|
||||||
|
|
||||||
|
- **场景**:定时任务因系统负载延迟到 00:05:00 才执行
|
||||||
|
- **处理**:
|
||||||
|
- 仍按计划重置所有符合条件的套餐
|
||||||
|
- last_reset_at 记录实际重置时间(00:05:00)
|
||||||
|
- 不影响下次重置周期(仍按 00:00:00 计算)
|
||||||
|
|
||||||
|
### 5. 套餐过期与重置时间重合
|
||||||
|
|
||||||
|
- **场景**:套餐在 2026-03-01 00:00:00 过期,同时触发月度重置
|
||||||
|
- **处理**:
|
||||||
|
- 过期任务将套餐 status=3
|
||||||
|
- 重置任务查询时排除 status=3 的套餐
|
||||||
|
- 不执行重置操作
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 并发场景
|
||||||
|
|
||||||
|
### Scenario: 并发流量扣减和重置
|
||||||
|
- **GIVEN** 套餐 ID=123,data_usage_mb=5GB
|
||||||
|
- **WHEN** 同时发生:
|
||||||
|
- 请求1:流量扣减 1GB
|
||||||
|
- 请求2:定时任务重置流量
|
||||||
|
- **THEN** 使用行锁:
|
||||||
|
```sql
|
||||||
|
SELECT * FROM package_usage WHERE id=123 FOR UPDATE
|
||||||
|
```
|
||||||
|
- **AND** 如果请求1先完成:
|
||||||
|
- data_usage_mb = 6GB
|
||||||
|
- 请求2重置 → data_usage_mb = 0
|
||||||
|
- **AND** 如果请求2先完成:
|
||||||
|
- data_usage_mb = 0
|
||||||
|
- 请求1扣减 → data_usage_mb = 1GB
|
||||||
|
|
||||||
|
### Scenario: 并发多套餐重置
|
||||||
|
- **GIVEN** 有1000个 data_reset_cycle=daily 的套餐
|
||||||
|
- **WHEN** 定时任务批量重置
|
||||||
|
- **THEN** 系统:
|
||||||
|
- 分批处理(每批100个)
|
||||||
|
- 每批使用单独事务
|
||||||
|
- 失败批次记录日志,不影响其他批次
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 异常处理
|
||||||
|
|
||||||
|
### 1. 重置任务失败
|
||||||
|
|
||||||
|
- **错误场景**:定时任务执行时数据库连接失败
|
||||||
|
- **处理流程**:
|
||||||
|
1. 捕获错误,记录 Error 日志(包含失败原因、影响套餐数量)
|
||||||
|
2. 使用 Asynq 重试机制(最多3次,间隔 10s/30s/60s)
|
||||||
|
3. 重试前检查套餐 last_reset_at(避免重复重置)
|
||||||
|
4. 3次失败后写入死信队列,发送告警
|
||||||
|
- **返回错误**:不返回给用户(定时任务),仅记录日志
|
||||||
|
|
||||||
|
### 2. 批量重置部分失败
|
||||||
|
|
||||||
|
- **错误场景**:批量重置1000个套餐,第500个套餐更新失败
|
||||||
|
- **处理流程**:
|
||||||
|
1. 分批处理(每批100个),每批独立事务
|
||||||
|
2. 失败批次回滚,其他批次正常提交
|
||||||
|
3. 记录失败批次的套餐ID列表
|
||||||
|
4. Asynq 重试失败批次
|
||||||
|
- **返回错误**:不返回给用户(定时任务),仅记录日志
|
||||||
|
|
||||||
|
### 3. last_reset_at 更新失败
|
||||||
|
|
||||||
|
- **错误场景**:data_usage_mb 重置成功,但 last_reset_at 更新失败
|
||||||
|
- **处理流程**:
|
||||||
|
1. 使用事务包裹两个更新操作
|
||||||
|
2. 任何一个失败 → 事务回滚,全部不更新
|
||||||
|
3. 记录 Error 日志
|
||||||
|
4. Asynq 重试
|
||||||
|
- **返回错误**:不返回给用户(定时任务),仅记录日志
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 数据一致性保证
|
||||||
|
|
||||||
|
### 1. 事务边界
|
||||||
|
|
||||||
|
- **批量重置套餐**:每批使用单独事务,确保原子性
|
||||||
|
- **流量扣减 + 重置并发**:使用行锁,确保顺序执行
|
||||||
|
|
||||||
|
### 2. 行锁机制
|
||||||
|
|
||||||
|
- **重置套餐时加锁**:`SELECT * FROM package_usage WHERE id IN (...) FOR UPDATE`
|
||||||
|
- **流量扣减时加锁**:`SELECT * FROM package_usage WHERE id=? FOR UPDATE`
|
||||||
|
|
||||||
|
### 3. 幂等性保证
|
||||||
|
|
||||||
|
- **重置任务幂等**:重试前检查 last_reset_at,如果已是今日则跳过
|
||||||
|
- **示例**:
|
||||||
|
```sql
|
||||||
|
UPDATE package_usage
|
||||||
|
SET data_usage_mb = 0, last_reset_at = NOW()
|
||||||
|
WHERE data_reset_cycle = 'daily'
|
||||||
|
AND status = 1
|
||||||
|
AND (last_reset_at IS NULL OR DATE(last_reset_at) < CURDATE());
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 数据校验
|
||||||
|
|
||||||
|
- **重置前**:校验套餐 status=1(生效中)
|
||||||
|
- **重置前**:校验套餐 expires_at > 当前时间(未过期)
|
||||||
|
- **重置后**:校验 data_usage_mb=0 且 last_reset_at 已更新
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 性能指标
|
||||||
|
|
||||||
|
| 操作 | 目标响应时间 | 并发要求 | 数据量 |
|
||||||
|
|------|-------------|---------|--------|
|
||||||
|
| 每日流量重置(单批) | < 500ms | 定时任务 | 批量更新(100个套餐/批) |
|
||||||
|
| 每月流量重置(单批) | < 500ms | 定时任务 | 批量更新(100个套餐/批) |
|
||||||
|
| 每年流量重置(单批) | < 500ms | 定时任务 | 批量更新(100个套餐/批) |
|
||||||
|
| 查询重置周期配置 | < 50ms | 100 QPS | 单套餐查询 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 错误码定义
|
||||||
|
|
||||||
|
| 错误码 | HTTP 状态码 | 错误消息 | 场景 |
|
||||||
|
|--------|------------|---------|------|
|
||||||
|
| `RESET_TASK_FAILED` | 500 | 流量重置任务失败,请联系管理员 | 定时任务执行失败 |
|
||||||
|
| `INVALID_RESET_CYCLE` | 400 | 无效的重置周期配置 | data_reset_cycle 值不合法 |
|
||||||
|
| `LAST_RESET_AT_UPDATE_FAILED` | 500 | 更新重置时间失败 | last_reset_at 更新失败 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 数据迁移策略
|
||||||
|
|
||||||
|
**激进策略**(开发阶段,保证干净性):
|
||||||
|
|
||||||
|
### 1. ❌ 要删除的字段
|
||||||
|
|
||||||
|
目前 `package` 表中可能存在的冗余字段(需确认后删除):
|
||||||
|
- 如果有 `reset_interval` 字段(旧的重置间隔) → **删除**
|
||||||
|
- 如果有 `reset_day` 字段(旧的重置日期) → **删除**
|
||||||
|
|
||||||
|
目前 `package_usage` 表中可能存在的冗余字段(需确认后删除):
|
||||||
|
- 如果有 `last_reset_date` 字段(旧的重置日期,非时间戳) → **删除**
|
||||||
|
|
||||||
|
### 2. ✅ 新增的字段
|
||||||
|
|
||||||
|
在 `package` 表中新增:
|
||||||
|
```sql
|
||||||
|
ALTER TABLE package
|
||||||
|
ADD COLUMN data_reset_cycle VARCHAR(10) DEFAULT 'none' COMMENT '流量重置周期(daily/monthly/yearly/none)',
|
||||||
|
ADD COLUMN isp VARCHAR(20) DEFAULT NULL COMMENT '运营商(unicom/mobile/telecom,用于特殊重置规则)';
|
||||||
|
|
||||||
|
CREATE INDEX idx_data_reset_cycle ON package(data_reset_cycle);
|
||||||
|
```
|
||||||
|
|
||||||
|
在 `package_usage` 表中新增:
|
||||||
|
```sql
|
||||||
|
ALTER TABLE package_usage
|
||||||
|
ADD COLUMN last_reset_at DATETIME DEFAULT NULL COMMENT '最后一次流量重置时间';
|
||||||
|
|
||||||
|
CREATE INDEX idx_last_reset_at ON package_usage(last_reset_at);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. ❌ 要废弃的逻辑
|
||||||
|
|
||||||
|
- **废弃旧的重置逻辑**:如果代码中存在通过 `reset_interval` 或 `reset_day` 字段计算重置的逻辑,全部删除
|
||||||
|
- **废弃旧的定时任务**:如果存在旧的流量重置定时任务,全部删除
|
||||||
|
- **废弃旧的重置时间字段**:统一使用 `last_reset_at`(DATETIME),删除其他相关字段
|
||||||
|
|
||||||
|
### 4. ✅ 历史数据强制转换
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Step 1: 历史套餐的重置周期初始化
|
||||||
|
-- 假设历史套餐默认为按月重置(需根据实际业务规则调整)
|
||||||
|
UPDATE package
|
||||||
|
SET data_reset_cycle = 'monthly'
|
||||||
|
WHERE data_reset_cycle IS NULL;
|
||||||
|
|
||||||
|
-- 如果历史有特殊类型,可以根据 duration 或其他字段推断:
|
||||||
|
-- 例如:duration_days=1 → data_reset_cycle='daily'
|
||||||
|
UPDATE package
|
||||||
|
SET data_reset_cycle = 'daily'
|
||||||
|
WHERE duration_days = 1
|
||||||
|
AND data_reset_cycle IS NULL;
|
||||||
|
|
||||||
|
-- Step 2: 历史套餐的运营商初始化
|
||||||
|
-- 假设历史套餐默认为移动(需根据实际业务规则调整)
|
||||||
|
UPDATE package
|
||||||
|
SET isp = 'mobile'
|
||||||
|
WHERE isp IS NULL;
|
||||||
|
|
||||||
|
-- Step 3: 历史 PackageUsage 的 last_reset_at 初始化
|
||||||
|
-- 如果有旧的 last_reset_date 字段,转换为 last_reset_at
|
||||||
|
-- UPDATE package_usage
|
||||||
|
-- SET last_reset_at = STR_TO_DATE(last_reset_date, '%Y-%m-%d')
|
||||||
|
-- WHERE last_reset_date IS NOT NULL;
|
||||||
|
|
||||||
|
-- 如果没有旧字段,根据 activated_at 推断:
|
||||||
|
-- 按月重置:last_reset_at = 当前月的1号
|
||||||
|
-- 按日重置:last_reset_at = 今天0点
|
||||||
|
-- 按年重置:last_reset_at = 今年1月1日
|
||||||
|
-- 不重置:last_reset_at = NULL
|
||||||
|
|
||||||
|
UPDATE package_usage pu
|
||||||
|
JOIN package p ON pu.package_id = p.id
|
||||||
|
SET pu.last_reset_at = DATE_FORMAT(CURDATE(), '%Y-%m-01 00:00:00')
|
||||||
|
WHERE p.data_reset_cycle = 'monthly'
|
||||||
|
AND pu.status = 1
|
||||||
|
AND pu.last_reset_at IS NULL;
|
||||||
|
|
||||||
|
UPDATE package_usage pu
|
||||||
|
JOIN package p ON pu.package_id = p.id
|
||||||
|
SET pu.last_reset_at = DATE_FORMAT(CURDATE(), '%Y-%m-%d 00:00:00')
|
||||||
|
WHERE p.data_reset_cycle = 'daily'
|
||||||
|
AND pu.status = 1
|
||||||
|
AND pu.last_reset_at IS NULL;
|
||||||
|
|
||||||
|
UPDATE package_usage pu
|
||||||
|
JOIN package p ON pu.package_id = p.id
|
||||||
|
SET pu.last_reset_at = DATE_FORMAT(CURDATE(), '%Y-01-01 00:00:00')
|
||||||
|
WHERE p.data_reset_cycle = 'yearly'
|
||||||
|
AND pu.status = 1
|
||||||
|
AND pu.last_reset_at IS NULL;
|
||||||
|
|
||||||
|
-- Step 4: data_reset_cycle=none 的套餐不设置 last_reset_at
|
||||||
|
-- (保持 NULL)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. ❌ 删除遗留表/字段(确认后执行)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 如果存在旧的重置相关字段,删除
|
||||||
|
-- ALTER TABLE package DROP COLUMN IF EXISTS reset_interval;
|
||||||
|
-- ALTER TABLE package DROP COLUMN IF EXISTS reset_day;
|
||||||
|
-- ALTER TABLE package_usage DROP COLUMN IF EXISTS last_reset_date;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. 验证步骤
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 验证1:所有套餐都有 data_reset_cycle
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM package
|
||||||
|
WHERE data_reset_cycle IS NULL;
|
||||||
|
-- 预期结果:0
|
||||||
|
|
||||||
|
-- 验证2:data_reset_cycle 值合法
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM package
|
||||||
|
WHERE data_reset_cycle NOT IN ('daily', 'monthly', 'yearly', 'none');
|
||||||
|
-- 预期结果:0
|
||||||
|
|
||||||
|
-- 验证3:生效中套餐的 last_reset_at 不为空(除了 data_reset_cycle=none)
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM package_usage pu
|
||||||
|
JOIN package p ON pu.package_id = p.id
|
||||||
|
WHERE pu.status = 1
|
||||||
|
AND p.data_reset_cycle != 'none'
|
||||||
|
AND pu.last_reset_at IS NULL;
|
||||||
|
-- 预期结果:0
|
||||||
|
|
||||||
|
-- 验证4:检查是否还有遗留字段(需根据实际情况调整)
|
||||||
|
-- SELECT column_name FROM information_schema.columns
|
||||||
|
-- WHERE table_name = 'package'
|
||||||
|
-- AND column_name IN ('reset_interval', 'reset_day');
|
||||||
|
-- 预期结果:0 rows
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 测试场景矩阵
|
||||||
|
|
||||||
|
| 场景分类 | 测试用例 | 预期结果 |
|
||||||
|
|---------|---------|---------|
|
||||||
|
| **配置重置周期** | 创建按日重置套餐 | data_reset_cycle=daily |
|
||||||
|
| | 创建按月重置套餐 | data_reset_cycle=monthly |
|
||||||
|
| | 创建按年重置套餐 | data_reset_cycle=yearly |
|
||||||
|
| | 创建不重置套餐 | data_reset_cycle=none |
|
||||||
|
| **每日重置** | 每日0点重置 | data_usage_mb=0, last_reset_at=今日0点 |
|
||||||
|
| | 非每日重置套餐不受影响 | data_usage_mb 不变 |
|
||||||
|
| | 待生效/已过期套餐不重置 | data_usage_mb 不变 |
|
||||||
|
| **每月重置** | 每月1号重置 | data_usage_mb=0, last_reset_at=本月1号0点 |
|
||||||
|
| | 联通特殊规则(27号重置) | data_usage_mb=0, last_reset_at=本月27号0点 |
|
||||||
|
| | 跨月边界流量统计 | 日记录保留,data_usage_mb 重置 |
|
||||||
|
| **每年重置** | 每年1月1日重置 | data_usage_mb=0, last_reset_at=今年1月1日0点 |
|
||||||
|
| | 已过期年套餐不重置 | data_usage_mb 不变 |
|
||||||
|
| **不重置** | 有效期内流量累计 | data_usage_mb 持续累加 |
|
||||||
|
| | 定时任务不影响 | data_usage_mb 不变 |
|
||||||
|
| **历史记录** | 重置后历史记录可查 | PackageUsageDailyRecord 存在 |
|
||||||
|
| | 重置后新流量使用 | 新日记录写入 |
|
||||||
|
| **并发** | 并发流量扣减和重置 | 使用行锁,顺序执行 |
|
||||||
|
| | 并发多套餐重置 | 分批处理,失败批次不影响其他 |
|
||||||
|
| **异常** | 重置任务失败 | Asynq 重试,记录日志 |
|
||||||
|
| | 批量重置部分失败 | 失败批次回滚,其他批次正常 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 实现参考
|
||||||
|
|
||||||
|
### 每日流量重置定时任务
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Handler: HandleDailyReset
|
||||||
|
func (h *DataResetHandler) HandleDailyReset(ctx context.Context, task *asynq.Task) error {
|
||||||
|
const batchSize = 100
|
||||||
|
|
||||||
|
// 1. 查询需要重置的套餐ID列表
|
||||||
|
usageIDs, err := h.packageUsageStore.ListDailyResetUsageIDs(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("list daily reset usage ids failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(usageIDs) == 0 {
|
||||||
|
h.logger.Info("无需要每日重置的套餐")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 分批重置
|
||||||
|
totalCount := 0
|
||||||
|
failedCount := 0
|
||||||
|
|
||||||
|
for i := 0; i < len(usageIDs); i += batchSize {
|
||||||
|
end := i + batchSize
|
||||||
|
if end > len(usageIDs) {
|
||||||
|
end = len(usageIDs)
|
||||||
|
}
|
||||||
|
|
||||||
|
batchIDs := usageIDs[i:end]
|
||||||
|
|
||||||
|
// 使用独立事务
|
||||||
|
tx := h.db.Begin()
|
||||||
|
err := h.resetUsageBatch(ctx, tx, batchIDs)
|
||||||
|
if err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
failedCount += len(batchIDs)
|
||||||
|
h.logger.Error("批量重置失败", zap.Error(err), zap.Ints("batch_ids", batchIDs))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit().Error; err != nil {
|
||||||
|
failedCount += len(batchIDs)
|
||||||
|
h.logger.Error("提交事务失败", zap.Error(err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
totalCount += len(batchIDs)
|
||||||
|
}
|
||||||
|
|
||||||
|
h.logger.Info("每日流量重置完成",
|
||||||
|
zap.Int("total_count", totalCount),
|
||||||
|
zap.Int("failed_count", failedCount))
|
||||||
|
|
||||||
|
if failedCount > 0 {
|
||||||
|
return fmt.Errorf("部分套餐重置失败,失败数量:%d", failedCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store 层:ListDailyResetUsageIDs
|
||||||
|
func (s *Store) ListDailyResetUsageIDs(ctx context.Context) ([]int, error) {
|
||||||
|
var ids []int
|
||||||
|
err := s.db.WithContext(ctx).
|
||||||
|
Table("package_usage pu").
|
||||||
|
Select("pu.id").
|
||||||
|
Joins("JOIN package p ON pu.package_id = p.id").
|
||||||
|
Where("p.data_reset_cycle = ?", constants.DataResetCycleDaily).
|
||||||
|
Where("pu.status = ?", constants.PackageStatusActive).
|
||||||
|
Where("pu.expires_at > ?", time.Now()).
|
||||||
|
Where("(pu.last_reset_at IS NULL OR DATE(pu.last_reset_at) < CURDATE())"). // 幂等性
|
||||||
|
Pluck("pu.id", &ids).Error
|
||||||
|
return ids, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store 层:resetUsageBatch
|
||||||
|
func (h *DataResetHandler) resetUsageBatch(ctx context.Context, tx *gorm.DB, ids []int) error {
|
||||||
|
return tx.WithContext(ctx).
|
||||||
|
Model(&model.PackageUsage{}).
|
||||||
|
Where("id IN (?)", ids).
|
||||||
|
Updates(map[string]interface{}{
|
||||||
|
"data_usage_mb": 0,
|
||||||
|
"last_reset_at": time.Now(),
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 每月流量重置定时任务(含联通特殊规则)
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Handler: HandleMonthlyReset
|
||||||
|
func (h *DataResetHandler) HandleMonthlyReset(ctx context.Context, task *asynq.Task) error {
|
||||||
|
// 判断今天是几号
|
||||||
|
today := time.Now().Day()
|
||||||
|
|
||||||
|
// 1. 重置非联通套餐(每月1号)
|
||||||
|
if today == 1 {
|
||||||
|
if err := h.resetMonthlyUsages(ctx, ""); err != nil {
|
||||||
|
h.logger.Error("非联通套餐每月重置失败", zap.Error(err))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 重置联通套餐(每月27号)
|
||||||
|
if today == 27 {
|
||||||
|
if err := h.resetMonthlyUsages(ctx, constants.ISPUnicom); err != nil {
|
||||||
|
h.logger.Error("联通套餐每月重置失败", zap.Error(err))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resetMonthlyUsages: 重置按月重置的套餐
|
||||||
|
func (h *DataResetHandler) resetMonthlyUsages(ctx context.Context, isp string) error {
|
||||||
|
const batchSize = 100
|
||||||
|
|
||||||
|
// 查询需要重置的套餐ID列表
|
||||||
|
usageIDs, err := h.packageUsageStore.ListMonthlyResetUsageIDs(ctx, isp)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("list monthly reset usage ids failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(usageIDs) == 0 {
|
||||||
|
h.logger.Info("无需要每月重置的套餐", zap.String("isp", isp))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分批重置(逻辑与每日重置相同)
|
||||||
|
// ...
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store 层:ListMonthlyResetUsageIDs
|
||||||
|
func (s *Store) ListMonthlyResetUsageIDs(ctx context.Context, isp string) ([]int, error) {
|
||||||
|
query := s.db.WithContext(ctx).
|
||||||
|
Table("package_usage pu").
|
||||||
|
Select("pu.id").
|
||||||
|
Joins("JOIN package p ON pu.package_id = p.id").
|
||||||
|
Where("p.data_reset_cycle = ?", constants.DataResetCycleMonthly).
|
||||||
|
Where("pu.status = ?", constants.PackageStatusActive).
|
||||||
|
Where("pu.expires_at > ?", time.Now())
|
||||||
|
|
||||||
|
if isp != "" {
|
||||||
|
// 联通特殊规则
|
||||||
|
query = query.Where("p.isp = ?", isp)
|
||||||
|
} else {
|
||||||
|
// 非联通套餐
|
||||||
|
query = query.Where("p.isp != ?", constants.ISPUnicom)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 幂等性:避免重复重置
|
||||||
|
query = query.Where("(pu.last_reset_at IS NULL OR DATE(pu.last_reset_at) < CURDATE())")
|
||||||
|
|
||||||
|
var ids []int
|
||||||
|
err := query.Pluck("pu.id", &ids).Error
|
||||||
|
return ids, err
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**本 Spec 完成**,包含:
|
||||||
|
- ✅ 业务背景和业务规则
|
||||||
|
- ✅ 详细场景(每日/每月/每年重置、不重置、联通特殊规则)
|
||||||
|
- ✅ 边界条件和并发场景
|
||||||
|
- ✅ 异常处理和数据一致性保证
|
||||||
|
- ✅ 性能指标和错误码定义
|
||||||
|
- ✅ **激进的数据迁移策略**(明确删除字段、废弃逻辑、强制转换)
|
||||||
|
- ✅ 测试场景矩阵和实现参考
|
||||||
@@ -1,24 +1,53 @@
|
|||||||
## ADDED Requirements
|
## Requirements
|
||||||
|
|
||||||
### Requirement: 创建套餐
|
### Requirement: 创建套餐
|
||||||
|
|
||||||
系统 SHALL 允许平台管理员创建套餐,包含套餐编码、套餐名称、所属系列、套餐类型、时长、流量配置、价格和建议价格。套餐编码 MUST 全局唯一(排除已删除记录)。新创建的套餐默认为启用状态(1)和下架状态(2)。
|
系统 SHALL 允许平台管理员创建套餐,包含套餐编码、套餐名称、所属系列、套餐类型、时长、**周期类型(calendar_type)、流量重置周期(data_reset_cycle)、是否需要实名激活(enable_realname_activation)**、流量配置、价格和建议价格。套餐编码 MUST 全局唯一(排除已删除记录)。新创建的套餐默认为启用状态(1)和下架状态(2)。
|
||||||
|
|
||||||
#### Scenario: 成功创建套餐
|
#### Scenario: 成功创建自然月套餐
|
||||||
- **WHEN** 管理员提交有效的套餐信息
|
- **GIVEN** 管理员提供套餐信息,calendar_type=natural_month,duration_months=1
|
||||||
- **THEN** 系统创建套餐记录,状态为启用(1),上架状态为下架(2),返回创建的套餐详情
|
- **WHEN** 提交创建请求
|
||||||
|
- **THEN** 系统创建套餐,状态=1,上架状态=2,calendar_type=natural_month
|
||||||
|
|
||||||
|
#### Scenario: 成功创建按天套餐
|
||||||
|
- **GIVEN** 管理员提供套餐信息,calendar_type=by_day,duration_days=30
|
||||||
|
- **WHEN** 提交创建请求
|
||||||
|
- **THEN** 系统创建套餐,calendar_type=by_day,duration_days=30
|
||||||
|
|
||||||
#### Scenario: 套餐编码重复
|
#### Scenario: 套餐编码重复
|
||||||
- **WHEN** 管理员提交的套餐编码已存在(未删除)
|
- **GIVEN** 数据库中存在套餐编码为 "PKG001" 的套餐(未删除)
|
||||||
|
- **WHEN** 管理员创建套餐,编码为 "PKG001"
|
||||||
- **THEN** 系统返回错误 "套餐编码已存在"
|
- **THEN** 系统返回错误 "套餐编码已存在"
|
||||||
|
|
||||||
#### Scenario: 关联不存在的套餐系列
|
#### Scenario: 关联不存在的套餐系列
|
||||||
- **WHEN** 管理员指定的系列 ID 不存在
|
- **GIVEN** 管理员指定 series_id=999,但系列不存在
|
||||||
|
- **WHEN** 提交创建请求
|
||||||
- **THEN** 系统返回错误 "套餐系列不存在"
|
- **THEN** 系统返回错误 "套餐系列不存在"
|
||||||
|
|
||||||
#### Scenario: 缺少必填字段
|
#### Scenario: 缺少必填字段
|
||||||
- **WHEN** 管理员未提供必填字段(套餐编码、套餐名称、套餐类型、时长、价格)
|
- **GIVEN** 管理员未提供套餐编码
|
||||||
- **THEN** 系统返回参数验证错误
|
- **WHEN** 提交创建请求
|
||||||
|
- **THEN** 系统返回参数验证错误 "套餐编码为必填项"
|
||||||
|
|
||||||
|
#### Scenario: 创建自然月套餐时必须提供 duration_months
|
||||||
|
- **GIVEN** 管理员创建套餐,calendar_type=natural_month,但未提供 duration_months
|
||||||
|
- **WHEN** 提交创建请求
|
||||||
|
- **THEN** 系统返回错误 "自然月套餐必须指定 duration_months"
|
||||||
|
|
||||||
|
#### Scenario: 创建按天套餐时必须提供 duration_days
|
||||||
|
- **GIVEN** 管理员创建套餐,calendar_type=by_day,但未提供 duration_days
|
||||||
|
- **WHEN** 提交创建请求
|
||||||
|
- **THEN** 系统返回错误 "按天套餐必须指定 duration_days"
|
||||||
|
|
||||||
|
#### Scenario: 默认 data_reset_cycle 为 monthly
|
||||||
|
- **GIVEN** 管理员创建主套餐,未指定 data_reset_cycle
|
||||||
|
- **WHEN** 提交创建请求
|
||||||
|
- **THEN** 系统自动设置 data_reset_cycle=monthly
|
||||||
|
|
||||||
|
#### Scenario: 默认 enable_realname_activation 为 true
|
||||||
|
- **GIVEN** 管理员创建主套餐,未指定 enable_realname_activation
|
||||||
|
- **WHEN** 提交创建请求
|
||||||
|
- **THEN** 系统自动设置 enable_realname_activation=true
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -54,38 +83,84 @@
|
|||||||
|
|
||||||
### Requirement: 查询套餐详情
|
### Requirement: 查询套餐详情
|
||||||
|
|
||||||
系统 SHALL 允许管理员查询单个套餐的详细信息。
|
系统 SHALL 允许管理员查询单个套餐的详细信息,**响应包含新增字段(calendar_type, data_reset_cycle, enable_realname_activation)**。
|
||||||
|
|
||||||
#### Scenario: 查询存在的套餐
|
#### Scenario: 查询存在的套餐
|
||||||
- **WHEN** 管理员请求指定 ID 的套餐详情
|
- **GIVEN** 数据库中存在套餐 ID=1
|
||||||
- **THEN** 系统返回该套餐的完整信息
|
- **WHEN** 管理员请求套餐详情
|
||||||
|
- **THEN** 系统返回该套餐的完整信息,包含所有新增字段
|
||||||
|
|
||||||
#### Scenario: 查询不存在的套餐
|
#### Scenario: 查询不存在的套餐
|
||||||
- **WHEN** 管理员请求不存在或已删除的套餐 ID
|
- **GIVEN** 管理员请求套餐 ID=999,但套餐不存在
|
||||||
|
- **WHEN** 提交查询请求
|
||||||
- **THEN** 系统返回 "套餐不存在" 错误
|
- **THEN** 系统返回 "套餐不存在" 错误
|
||||||
|
|
||||||
|
#### Scenario: 响应包含周期类型信息
|
||||||
|
- **GIVEN** 套餐 calendar_type=natural_month,duration_months=1
|
||||||
|
- **WHEN** 管理员查询套餐详情
|
||||||
|
- **THEN** 响应包含 calendar_type=natural_month,duration_months=1
|
||||||
|
|
||||||
|
#### Scenario: 响应包含流量重置周期信息
|
||||||
|
- **GIVEN** 套餐 data_reset_cycle=monthly
|
||||||
|
- **WHEN** 管理员查询套餐详情
|
||||||
|
- **THEN** 响应包含 data_reset_cycle=monthly
|
||||||
|
|
||||||
|
#### Scenario: 响应包含实名激活配置
|
||||||
|
- **GIVEN** 套餐 enable_realname_activation=true
|
||||||
|
- **WHEN** 管理员查询套餐详情
|
||||||
|
- **THEN** 响应包含 enable_realname_activation=true
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Requirement: 更新套餐
|
### Requirement: 更新套餐
|
||||||
|
|
||||||
系统 SHALL 允许管理员更新套餐的基本信息。套餐编码创建后 MUST NOT 允许修改。
|
系统 SHALL 允许管理员更新套餐的基本信息,**包括周期类型、流量重置周期、实名激活配置等新增字段**。套餐编码创建后 MUST NOT 允许修改。
|
||||||
|
|
||||||
#### Scenario: 成功更新套餐
|
#### Scenario: 成功更新套餐基本信息
|
||||||
- **WHEN** 管理员提交有效的更新信息
|
- **GIVEN** 管理员更新套餐名称和价格
|
||||||
|
- **WHEN** 提交更新请求
|
||||||
- **THEN** 系统更新套餐记录,返回更新后的详情
|
- **THEN** 系统更新套餐记录,返回更新后的详情
|
||||||
|
|
||||||
#### Scenario: 尝试修改套餐编码
|
#### Scenario: 尝试修改套餐编码
|
||||||
- **WHEN** 管理员尝试修改套餐编码
|
- **GIVEN** 管理员尝试修改套餐编码
|
||||||
|
- **WHEN** 提交更新请求
|
||||||
- **THEN** 系统忽略套餐编码字段,不进行修改
|
- **THEN** 系统忽略套餐编码字段,不进行修改
|
||||||
|
|
||||||
#### Scenario: 更新不存在的套餐
|
#### Scenario: 更新不存在的套餐
|
||||||
- **WHEN** 管理员更新不存在的套餐
|
- **GIVEN** 管理员更新套餐 ID=999,但套餐不存在
|
||||||
|
- **WHEN** 提交更新请求
|
||||||
- **THEN** 系统返回 "套餐不存在" 错误
|
- **THEN** 系统返回 "套餐不存在" 错误
|
||||||
|
|
||||||
#### Scenario: 关联不存在的套餐系列
|
#### Scenario: 关联不存在的套餐系列
|
||||||
- **WHEN** 管理员将套餐关联到不存在的系列
|
- **GIVEN** 管理员将套餐的 series_id 改为 999,但系列不存在
|
||||||
|
- **WHEN** 提交更新请求
|
||||||
- **THEN** 系统返回错误 "套餐系列不存在"
|
- **THEN** 系统返回错误 "套餐系列不存在"
|
||||||
|
|
||||||
|
#### Scenario: 更新套餐周期类型(从自然月改为按天)
|
||||||
|
- **GIVEN** 套餐当前 calendar_type=natural_month,duration_months=1
|
||||||
|
- **WHEN** 管理员更新 calendar_type=by_day,duration_days=30
|
||||||
|
- **THEN** 系统更新成功,calendar_type=by_day,duration_days=30
|
||||||
|
|
||||||
|
#### Scenario: 更新套餐周期类型(从按天改为自然月)
|
||||||
|
- **GIVEN** 套餐当前 calendar_type=by_day,duration_days=30
|
||||||
|
- **WHEN** 管理员更新 calendar_type=natural_month,duration_months=1
|
||||||
|
- **THEN** 系统更新成功,calendar_type=natural_month,duration_months=1
|
||||||
|
|
||||||
|
#### Scenario: 更新周期类型但未提供对应时长字段
|
||||||
|
- **GIVEN** 套餐当前 calendar_type=by_day
|
||||||
|
- **WHEN** 管理员更新 calendar_type=natural_month,但未提供 duration_months
|
||||||
|
- **THEN** 系统返回错误 "自然月套餐必须指定 duration_months"
|
||||||
|
|
||||||
|
#### Scenario: 更新 data_reset_cycle
|
||||||
|
- **GIVEN** 套餐当前 data_reset_cycle=monthly
|
||||||
|
- **WHEN** 管理员更新 data_reset_cycle=daily
|
||||||
|
- **THEN** 系统更新成功,data_reset_cycle=daily
|
||||||
|
|
||||||
|
#### Scenario: 更新 enable_realname_activation
|
||||||
|
- **GIVEN** 套餐当前 enable_realname_activation=true
|
||||||
|
- **WHEN** 管理员更新 enable_realname_activation=false
|
||||||
|
- **THEN** 系统更新成功,enable_realname_activation=false
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Requirement: 删除套餐
|
### Requirement: 删除套餐
|
||||||
|
|||||||
383
openspec/specs/package-queue-activation/spec.md
Normal file
383
openspec/specs/package-queue-activation/spec.md
Normal file
@@ -0,0 +1,383 @@
|
|||||||
|
# Spec: 主套餐排队生效机制
|
||||||
|
|
||||||
|
## 业务背景
|
||||||
|
|
||||||
|
现有套餐系统允许同一载体(设备/卡)同时存在多个生效中的主套餐,导致流量统计混乱、停机条件不明确等问题。本规范引入主套餐排队机制,确保:
|
||||||
|
|
||||||
|
1. **同一时刻只能有一个生效中主套餐**:避免多套餐并存的业务混乱
|
||||||
|
2. **后续购买自动排队**:用户提前购买多个主套餐(囤货),按购买顺序自动激活
|
||||||
|
3. **无缝衔接**:当前主套餐过期后,系统自动激活下一个,无需人工干预
|
||||||
|
|
||||||
|
## 业务规则
|
||||||
|
|
||||||
|
### 主套餐识别规则
|
||||||
|
- **主套餐定义**:`package_type=formal` 且 `master_usage_id IS NULL`
|
||||||
|
- **加油包定义**:`package_type=addon` 或 `master_usage_id IS NOT NULL`
|
||||||
|
|
||||||
|
### Priority 分配规则
|
||||||
|
1. **首个主套餐**:priority=1,立即激活(status=1)
|
||||||
|
2. **后续主套餐**:priority=MAX(当前主套餐 priority)+1,待生效(status=0)
|
||||||
|
3. **Priority 全局唯一**:同一载体的所有主套餐 priority 不重复
|
||||||
|
|
||||||
|
### 激活顺序规则
|
||||||
|
1. **按 priority 升序激活**:priority=1 → priority=2 → priority=3 ...
|
||||||
|
2. **跨状态查询**:轮询系统查询 status=0 且 priority 最小的待生效主套餐
|
||||||
|
3. **过期检测频率**:每 10 秒执行一次过期检测
|
||||||
|
|
||||||
|
### 激活延迟要求
|
||||||
|
- **目标延迟**:主套餐过期后 1 分钟内完成下一个套餐的激活
|
||||||
|
- **实际延迟组成**:
|
||||||
|
- 过期检测:< 10 秒(轮询间隔)
|
||||||
|
- 队列延迟:< 1 秒(Asynq 队列延迟)
|
||||||
|
- 激活处理:< 5 秒(数据库更新 + 日志记录)
|
||||||
|
- **总延迟** < 20 秒(满足 < 1 分钟要求)
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: 同时只能有一个生效中的主套餐
|
||||||
|
系统 SHALL 确保载体(设备/卡)同一时刻只能有一个 package_type=formal 且 status=1 的套餐。
|
||||||
|
|
||||||
|
**数据一致性保证**:
|
||||||
|
- 购买时检查:查询 `WHERE usage_type=? AND (iot_card_id/device_id)=? AND status=1 AND master_usage_id IS NULL`
|
||||||
|
- 并发控制:使用数据库事务 + 唯一索引(usage_type, carrier_id, status=1)避免并发插入多个生效中主套餐
|
||||||
|
- 激活时二次检查:激活前再次查询是否有生效中主套餐,避免并发激活
|
||||||
|
|
||||||
|
#### Scenario: 首次购买主套餐立即生效
|
||||||
|
- **GIVEN** 载体无任何主套餐记录
|
||||||
|
- **WHEN** 用户通过 POST /api/admin/orders 购买主套餐(package_type=formal)
|
||||||
|
- **THEN** 系统创建 PackageUsage:
|
||||||
|
- status=1(生效中)
|
||||||
|
- priority=1
|
||||||
|
- activated_at=支付完成时间
|
||||||
|
- expires_at=根据 calendar_type 计算
|
||||||
|
- master_usage_id=NULL
|
||||||
|
- **AND** 订单状态更新为 completed
|
||||||
|
|
||||||
|
#### Scenario: 购买第二个主套餐自动排队
|
||||||
|
- **GIVEN** 载体已有1个生效中的主套餐(priority=1, status=1)
|
||||||
|
- **WHEN** 用户购买第2个主套餐
|
||||||
|
- **THEN** 系统创建 PackageUsage:
|
||||||
|
- status=0(待生效)
|
||||||
|
- priority=2
|
||||||
|
- activated_at=NULL
|
||||||
|
- expires_at=NULL
|
||||||
|
- master_usage_id=NULL
|
||||||
|
- **AND** 订单状态更新为 completed
|
||||||
|
|
||||||
|
#### Scenario: 购买第三个主套餐继续排队
|
||||||
|
- **GIVEN** 载体已有1个生效中主套餐(priority=1, status=1) + 1个待生效主套餐(priority=2, status=0)
|
||||||
|
- **WHEN** 用户购买第3个主套餐
|
||||||
|
- **THEN** 系统创建 PackageUsage:
|
||||||
|
- status=0(待生效)
|
||||||
|
- priority=3
|
||||||
|
- activated_at=NULL
|
||||||
|
- expires_at=NULL
|
||||||
|
|
||||||
|
#### Scenario: 并发购买两个主套餐(并发控制)
|
||||||
|
- **GIVEN** 载体无任何主套餐记录
|
||||||
|
- **WHEN** 两个用户同时(< 1秒内)购买主套餐
|
||||||
|
- **THEN** 第一个请求创建 PackageUsage priority=1, status=1(生效中)
|
||||||
|
- **AND** 第二个请求创建 PackageUsage priority=2, status=0(待生效)
|
||||||
|
- **AND** 使用数据库事务保证数据一致性,不会出现两个 priority=1 或两个 status=1
|
||||||
|
|
||||||
|
#### Scenario: 查询生效中主套餐(接口验证)
|
||||||
|
- **GIVEN** 载体有1个 status=1 的主套餐和2个 status=0 的待生效主套餐
|
||||||
|
- **WHEN** 系统查询生效中主套餐(WHERE status=1 AND master_usage_id IS NULL)
|
||||||
|
- **THEN** 返回唯一的 status=1 主套餐记录
|
||||||
|
- **AND** 查询结果数量 = 1
|
||||||
|
|
||||||
|
#### Scenario: 违规创建两个生效中主套餐(数据库约束)
|
||||||
|
- **GIVEN** 数据库有唯一索引(usage_type, iot_card_id, status=1, deleted_at IS NULL)
|
||||||
|
- **WHEN** 系统尝试插入第二个 status=1 的主套餐(绕过业务逻辑)
|
||||||
|
- **THEN** 数据库返回唯一约束冲突错误
|
||||||
|
- **AND** 事务回滚,数据不插入
|
||||||
|
|
||||||
|
### Requirement: 主套餐按购买顺序排队
|
||||||
|
系统 SHALL 为待生效主套餐分配递增的 priority,priority 数字越小优先级越高。
|
||||||
|
|
||||||
|
**Priority 计算逻辑**:
|
||||||
|
```
|
||||||
|
new_priority = MAX(当前载体所有主套餐的 priority) + 1
|
||||||
|
```
|
||||||
|
|
||||||
|
**边界条件**:
|
||||||
|
- 首个主套餐 priority=1
|
||||||
|
- 删除中间 priority 的套餐后,priority 不重新排序(例如删除 priority=2,后续仍从 priority=4 开始)
|
||||||
|
- priority 最大值不超过 999(业务限制,避免异常)
|
||||||
|
|
||||||
|
#### Scenario: Priority 自动递增
|
||||||
|
- **GIVEN** 载体当前主套餐最大 priority=5
|
||||||
|
- **WHEN** 用户购买新主套餐
|
||||||
|
- **THEN** 系统创建 PackageUsage priority=6, status=0
|
||||||
|
|
||||||
|
#### Scenario: 首个主套餐 Priority 为 1
|
||||||
|
- **GIVEN** 载体无任何主套餐记录
|
||||||
|
- **WHEN** 用户首次购买主套餐
|
||||||
|
- **THEN** 系统创建 PackageUsage priority=1, status=1(生效中)
|
||||||
|
|
||||||
|
#### Scenario: 删除待生效套餐后 Priority 不重排
|
||||||
|
- **GIVEN** 载体有主套餐 priority=1(status=1), priority=2(status=0), priority=3(status=0)
|
||||||
|
- **WHEN** 用户删除 priority=2 的待生效套餐(软删除,设置 deleted_at)
|
||||||
|
- **AND** 再购买新主套餐
|
||||||
|
- **THEN** 新套餐 priority=4(不重新排序为 priority=2)
|
||||||
|
- **AND** 激活顺序为 priority=1 → priority=3 → priority=4
|
||||||
|
|
||||||
|
#### Scenario: Priority 超过限制(业务异常)
|
||||||
|
- **GIVEN** 载体当前主套餐最大 priority=999
|
||||||
|
- **WHEN** 用户尝试购买新主套餐
|
||||||
|
- **THEN** 系统返回错误 400,错误消息:"主套餐排队数量已达上限(999个),请联系客服"
|
||||||
|
- **AND** 订单创建失败
|
||||||
|
|
||||||
|
#### Scenario: 并发分配 Priority(并发控制)
|
||||||
|
- **GIVEN** 载体当前主套餐最大 priority=5
|
||||||
|
- **WHEN** 两个用户同时购买主套餐
|
||||||
|
- **THEN** 第一个请求分配 priority=6
|
||||||
|
- **AND** 第二个请求分配 priority=7
|
||||||
|
- **AND** 使用数据库事务 + SELECT FOR UPDATE 避免 priority 重复
|
||||||
|
|
||||||
|
### Requirement: 当前主套餐过期后自动激活下一个
|
||||||
|
系统 SHALL 在主套餐过期(expires_at < now)时,自动激活 priority 最小的待生效主套餐。
|
||||||
|
|
||||||
|
**实现机制**:
|
||||||
|
1. **轮询调度**:Scheduler 每 10 秒执行一次过期检测
|
||||||
|
2. **过期检测**:查询 `WHERE status=1 AND expires_at <= NOW() AND master_usage_id IS NULL`
|
||||||
|
3. **状态更新**:将过期主套餐 status 更新为 3(已过期)
|
||||||
|
4. **查询下一个**:查询 `WHERE status=0 AND master_usage_id IS NULL ORDER BY priority ASC LIMIT 1`
|
||||||
|
5. **提交任务**:创建 Asynq 任务 `TaskTypePackageQueueActivation`
|
||||||
|
6. **异步激活**:Asynq Handler 更新 status=1, 计算 activated_at 和 expires_at
|
||||||
|
|
||||||
|
**幂等性保证**:
|
||||||
|
- 任务处理前检查 `status=0`,已激活则直接返回成功
|
||||||
|
- 使用 Redis 分布式锁(key: `package:activation:lock:{usage_id}`,TTL=30s)
|
||||||
|
|
||||||
|
#### Scenario: 自动激活下一个主套餐
|
||||||
|
- **GIVEN** 当前主套餐 priority=1, status=1, expires_at=2026-02-28 23:59:59
|
||||||
|
- **AND** 存在待生效主套餐 priority=2, status=0
|
||||||
|
- **WHEN** 系统时间到达 2026-03-01 00:00:00,轮询系统检测到过期
|
||||||
|
- **THEN** 系统执行以下操作:
|
||||||
|
1. 更新 priority=1 的套餐 status=3(已过期)
|
||||||
|
2. 查询 priority=2 的待生效套餐
|
||||||
|
3. 提交 Asynq 任务(payload: {usage_id: priority=2的ID})
|
||||||
|
4. Asynq Handler 激活 priority=2 套餐:
|
||||||
|
- status=1
|
||||||
|
- activated_at=2026-03-01 00:00:10(激活时间,约为 00:00:00 + 10秒延迟)
|
||||||
|
- expires_at=根据 calendar_type 计算
|
||||||
|
- **AND** 激活延迟 < 1 分钟
|
||||||
|
|
||||||
|
#### Scenario: 无待生效套餐时不激活
|
||||||
|
- **GIVEN** 当前主套餐 priority=1, status=1, expires_at=2026-02-28 23:59:59
|
||||||
|
- **AND** 不存在 status=0 的待生效主套餐
|
||||||
|
- **WHEN** 系统时间到达 2026-03-01 00:00:00,轮询系统检测到过期
|
||||||
|
- **THEN** 系统仅更新 priority=1 的套餐 status=3(已过期)
|
||||||
|
- **AND** 不提交激活任务
|
||||||
|
- **AND** 载体进入无主套餐状态
|
||||||
|
|
||||||
|
#### Scenario: 过期检测批量处理
|
||||||
|
- **GIVEN** 系统有 10000 个主套餐在 2026-02-28 23:59:59 过期
|
||||||
|
- **WHEN** 系统时间到达 2026-03-01 00:00:00,轮询系统检测到过期
|
||||||
|
- **THEN** 系统分批处理(每批 10000 个):
|
||||||
|
1. 批量更新过期主套餐 status=3
|
||||||
|
2. 批量查询下一个待生效主套餐(每个载体一个)
|
||||||
|
3. 批量提交 Asynq 任务(最多 10000 个任务)
|
||||||
|
- **AND** 所有任务在 1 分钟内完成激活
|
||||||
|
|
||||||
|
#### Scenario: 激活任务失败重试
|
||||||
|
- **GIVEN** 待生效主套餐 priority=2, status=0
|
||||||
|
- **WHEN** 轮询系统提交激活任务,但 Asynq Handler 第一次执行失败(例如数据库连接超时)
|
||||||
|
- **THEN** Asynq 自动重试(MaxRetry=3,间隔 10 秒)
|
||||||
|
- **AND** 第二次重试成功,套餐激活
|
||||||
|
- **AND** 总延迟 < 2 分钟(10秒检测 + 10秒首次失败 + 10秒重试成功)
|
||||||
|
|
||||||
|
#### Scenario: 激活任务重试耗尽(异常处理)
|
||||||
|
- **GIVEN** 待生效主套餐 priority=2, status=0
|
||||||
|
- **WHEN** 轮询系统提交激活任务,Asynq Handler 重试 3 次均失败
|
||||||
|
- **THEN** Asynq 任务进入死信队列(DLQ)
|
||||||
|
- **AND** 套餐保持 status=0(待生效)
|
||||||
|
- **AND** 系统记录 Error 日志,包含完整错误信息和 usage_id
|
||||||
|
- **AND** 告警通知运维团队,人工介入修复
|
||||||
|
|
||||||
|
#### Scenario: 轮询系统重复检测(幂等性保证)
|
||||||
|
- **GIVEN** 主套餐过期,已提交激活任务,但任务尚未执行完成
|
||||||
|
- **WHEN** 10 秒后轮询系统再次检测(任务仍在队列中)
|
||||||
|
- **THEN** 系统查询 status=1 的过期主套餐,结果为空(已更新为 status=3)
|
||||||
|
- **AND** 不重复提交激活任务
|
||||||
|
|
||||||
|
#### Scenario: 激活任务并发执行(幂等性保证)
|
||||||
|
- **GIVEN** 同一套餐的激活任务被重复提交(例如手动触发 + 自动调度)
|
||||||
|
- **WHEN** 两个 Asynq Handler 同时执行
|
||||||
|
- **THEN** 第一个 Handler 获取 Redis 锁,执行激活
|
||||||
|
- **AND** 第二个 Handler 获取锁失败,等待 30 秒后超时,检查 status=1,直接返回成功
|
||||||
|
- **AND** 套餐只激活一次
|
||||||
|
|
||||||
|
### Requirement: 激活时根据套餐类型计算有效期
|
||||||
|
系统 SHALL 在排队激活主套餐时,根据 calendar_type 计算 expires_at。
|
||||||
|
|
||||||
|
**计算时机**:Asynq Handler 执行激活任务时
|
||||||
|
|
||||||
|
**计算逻辑**:
|
||||||
|
- 自然月套餐:`expires_at = (activated_at 月份 + duration_months) 的月末 23:59:59`
|
||||||
|
- 按天套餐:`expires_at = (activated_at 日期 + duration_days) 的 23:59:59`
|
||||||
|
|
||||||
|
#### Scenario: 排队激活自然月套餐
|
||||||
|
- **GIVEN** 待生效主套餐 calendar_type=natural_month, duration_months=1
|
||||||
|
- **WHEN** 2026-03-01 00:00:10 激活
|
||||||
|
- **THEN** 套餐更新:
|
||||||
|
- status=1
|
||||||
|
- activated_at=2026-03-01 00:00:10
|
||||||
|
- expires_at=2026-03-31 23:59:59
|
||||||
|
- **AND** 有效期 = 30 天 23 小时 59 分 50 秒
|
||||||
|
|
||||||
|
#### Scenario: 排队激活按天套餐
|
||||||
|
- **GIVEN** 待生效主套餐 calendar_type=by_day, duration_days=30
|
||||||
|
- **WHEN** 2026-03-01 00:00:10 激活
|
||||||
|
- **THEN** 套餐更新:
|
||||||
|
- status=1
|
||||||
|
- activated_at=2026-03-01 00:00:10
|
||||||
|
- expires_at=2026-03-30 23:59:59
|
||||||
|
- **AND** 有效期 = 29 天 23 小时 59 分 49 秒
|
||||||
|
|
||||||
|
#### Scenario: 激活时处理闰年(自然月)
|
||||||
|
- **GIVEN** 待生效主套餐 calendar_type=natural_month, duration_months=1
|
||||||
|
- **WHEN** 2028-02-01 00:00:10 激活(闰年)
|
||||||
|
- **THEN** expires_at=2028-02-29 23:59:59(正确识别闰年)
|
||||||
|
|
||||||
|
#### Scenario: 激活时处理跨年(自然月)
|
||||||
|
- **GIVEN** 待生效主套餐 calendar_type=natural_month, duration_months=2
|
||||||
|
- **WHEN** 2026-12-01 00:00:10 激活
|
||||||
|
- **THEN** expires_at=2027-02-28 23:59:59(正确跨年)
|
||||||
|
|
||||||
|
### Requirement: 主套餐排队调度延迟小于1分钟
|
||||||
|
系统 SHALL 确保主套餐过期后,待生效套餐在1分钟内完成激活。
|
||||||
|
|
||||||
|
**性能指标**:
|
||||||
|
| 指标 | 目标 | 监控方式 |
|
||||||
|
|------|------|---------|
|
||||||
|
| 过期检测延迟 | < 10 秒 | 轮询间隔配置 |
|
||||||
|
| 任务提交延迟 | < 1 秒 | Asynq 入队时间 |
|
||||||
|
| 激活处理延迟 | < 5 秒 | Asynq Handler 执行时间 |
|
||||||
|
| **端到端延迟** | **< 20 秒** | 从过期到激活完成 |
|
||||||
|
|
||||||
|
**监控告警**:
|
||||||
|
- 激活延迟 > 1 分钟:Critical 告警,通知运维团队
|
||||||
|
- Asynq 队列堆积 > 1000:Warning 告警,检查 Worker 数量
|
||||||
|
- 激活任务失败率 > 5%:Warning 告警,检查数据库连接
|
||||||
|
|
||||||
|
#### Scenario: 排队激活性能达标
|
||||||
|
- **GIVEN** 主套餐在 2026-02-28 23:59:59 过期
|
||||||
|
- **WHEN** 轮询系统在 00:00:00 - 00:00:10 之间检测到过期
|
||||||
|
- **AND** 在 00:00:11 提交 Asynq 任务
|
||||||
|
- **AND** Asynq Handler 在 00:00:12 - 00:00:17 执行激活
|
||||||
|
- **THEN** 套餐在 2026-03-01 00:00:17 完成激活
|
||||||
|
- **AND** 端到端延迟 = 17 秒 < 60 秒
|
||||||
|
|
||||||
|
#### Scenario: 高负载下激活延迟(压力测试)
|
||||||
|
- **GIVEN** 10000 个主套餐同时过期
|
||||||
|
- **WHEN** 轮询系统检测到过期并提交 10000 个任务
|
||||||
|
- **AND** Asynq Worker 并发数 = 50
|
||||||
|
- **THEN** 所有任务在 4 分钟内完成(10000 / 50 / 5秒 ≈ 4 分钟)
|
||||||
|
- **AND** P99 激活延迟 < 5 分钟(可接受)
|
||||||
|
|
||||||
|
#### Scenario: 轮询系统宕机恢复(容错性)
|
||||||
|
- **GIVEN** 主套餐在 2026-02-28 23:59:59 过期
|
||||||
|
- **WHEN** 轮询系统在 00:00:00 - 00:10:00 期间宕机
|
||||||
|
- **AND** 轮询系统在 00:10:01 恢复
|
||||||
|
- **THEN** 轮询系统检测到过期主套餐(expires_at < 00:10:01)
|
||||||
|
- **AND** 在 00:10:02 - 00:10:20 完成激活
|
||||||
|
- **AND** 延迟 = 10 分钟 20 秒(超过目标,但系统自动恢复)
|
||||||
|
|
||||||
|
## 数据一致性保证
|
||||||
|
|
||||||
|
### 1. 并发购买主套餐
|
||||||
|
- **机制**:数据库事务 + 唯一索引(usage_type, iot_card_id/device_id, status=1, deleted_at IS NULL)
|
||||||
|
- **保证**:同一载体同一时刻只能有一个 status=1 的主套餐
|
||||||
|
|
||||||
|
### 2. 并发分配 Priority
|
||||||
|
- **机制**:数据库事务 + SELECT FOR UPDATE
|
||||||
|
- **伪代码**:
|
||||||
|
```sql
|
||||||
|
BEGIN TRANSACTION;
|
||||||
|
SELECT MAX(priority) FROM tb_package_usage WHERE ... FOR UPDATE;
|
||||||
|
INSERT INTO tb_package_usage (priority) VALUES (max_priority + 1);
|
||||||
|
COMMIT;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 并发激活同一套餐
|
||||||
|
- **机制**:Redis 分布式锁(key: `package:activation:lock:{usage_id}`,TTL=30s)
|
||||||
|
- **保证**:同一套餐只能被激活一次
|
||||||
|
|
||||||
|
### 4. 过期检测重复触发
|
||||||
|
- **机制**:更新 status=3 后,WHERE 条件不再匹配(status=1)
|
||||||
|
- **保证**:过期主套餐不会重复提交激活任务
|
||||||
|
|
||||||
|
## 性能优化策略
|
||||||
|
|
||||||
|
### 1. 过期检测分批处理
|
||||||
|
```sql
|
||||||
|
-- 每次最多处理 10000 个过期套餐
|
||||||
|
SELECT id FROM tb_package_usage
|
||||||
|
WHERE status=1 AND expires_at <= NOW() AND master_usage_id IS NULL
|
||||||
|
ORDER BY expires_at ASC
|
||||||
|
LIMIT 10000;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 批量提交 Asynq 任务
|
||||||
|
- 使用 `Enqueue` 批量提交(每批 1000 个)
|
||||||
|
- 减少 Redis 往返次数
|
||||||
|
|
||||||
|
### 3. Asynq Worker 并发数
|
||||||
|
- 默认并发数:10
|
||||||
|
- 高负载时可调整为 50-100
|
||||||
|
- 监控队列长度动态调整
|
||||||
|
|
||||||
|
### 4. 数据库索引优化
|
||||||
|
```sql
|
||||||
|
-- 过期检测索引
|
||||||
|
CREATE INDEX idx_package_usage_expires ON tb_package_usage(status, expires_at, master_usage_id) WHERE deleted_at IS NULL;
|
||||||
|
|
||||||
|
-- Priority 查询索引
|
||||||
|
CREATE INDEX idx_package_usage_priority ON tb_package_usage(iot_card_id, status, priority) WHERE deleted_at IS NULL;
|
||||||
|
```
|
||||||
|
|
||||||
|
## 错误码定义
|
||||||
|
|
||||||
|
| 错误码 | HTTP 状态码 | 错误消息 | 场景 |
|
||||||
|
|--------|------------|---------|------|
|
||||||
|
| CodeConflict | 409 | 套餐正在激活中,请稍后重试 | 并发激活冲突 |
|
||||||
|
| CodeForbidden | 403 | 主套餐排队数量已达上限(999个),请联系客服 | Priority 超限 |
|
||||||
|
| CodeInternal | 500 | 套餐激活失败,请重试 | 数据库更新失败 |
|
||||||
|
|
||||||
|
## 数据迁移策略
|
||||||
|
|
||||||
|
**激进策略**(开发阶段):
|
||||||
|
1. **历史主套餐数据重新排序**:
|
||||||
|
- 查询每个载体的所有主套餐(按 `created_at ASC`)
|
||||||
|
- 重新分配 `priority`:第一个=1,第二个=2,以此类推
|
||||||
|
- 只保留第一个主套餐 `status=1`(生效中),其余设置为 `status=0`(待生效)
|
||||||
|
- 为待生效主套餐清空 `activated_at` 和 `expires_at`
|
||||||
|
|
||||||
|
2. **订单服务彻底重构**:
|
||||||
|
- **删除** 现有 `activatePackage` 函数中的立即激活逻辑
|
||||||
|
- 所有主套餐购买统一走排队逻辑(首个除外)
|
||||||
|
- 不保留旧的激活方式
|
||||||
|
|
||||||
|
3. **API 破坏性变更**:
|
||||||
|
- 订单创建接口行为变更:后续主套餐购买不再立即生效
|
||||||
|
- 响应中新增 `priority` 和 `estimated_activation_time` 字段
|
||||||
|
- 客户端必须适配新的"待生效"状态展示
|
||||||
|
|
||||||
|
## 测试场景矩阵
|
||||||
|
|
||||||
|
| 维度 | 场景 | 预期结果 |
|
||||||
|
|------|------|---------|
|
||||||
|
| **基础功能** | 首次购买主套餐 | priority=1, status=1 |
|
||||||
|
| | 购买第2个主套餐 | priority=2, status=0 |
|
||||||
|
| | 购买第3个主套餐 | priority=3, status=0 |
|
||||||
|
| **过期激活** | 主套餐过期 + 有待生效套餐 | status=3 → 激活 priority=2 |
|
||||||
|
| | 主套餐过期 + 无待生效套餐 | status=3,载体无主套餐 |
|
||||||
|
| **并发场景** | 并发购买两个主套餐 | priority=1(status=1) + priority=2(status=0) |
|
||||||
|
| | 并发激活同一套餐 | 只激活一次,第二个请求幂等返回 |
|
||||||
|
| **异常场景** | 激活任务失败 | 重试 3 次,失败进入 DLQ |
|
||||||
|
| | Priority 超限(999) | 返回错误,拒绝购买 |
|
||||||
|
| | 轮询系统宕机 | 恢复后自动激活过期套餐 |
|
||||||
|
| **性能场景** | 单个套餐激活延迟 | < 20 秒 |
|
||||||
|
| | 10000 个套餐同时过期 | P99 < 5 分钟 |
|
||||||
972
openspec/specs/package-realname-activation/spec.md
Normal file
972
openspec/specs/package-realname-activation/spec.md
Normal file
@@ -0,0 +1,972 @@
|
|||||||
|
# Spec: 首次实名激活机制
|
||||||
|
|
||||||
|
## 业务背景
|
||||||
|
|
||||||
|
### 为什么需要首次实名激活机制
|
||||||
|
|
||||||
|
**现状问题**:
|
||||||
|
- 运营商要求 IoT 卡必须实名认证后才能使用,但用户购买套餐时可能尚未实名
|
||||||
|
- 后台管理员需要为客户提前购买套餐(批量配置),但客户设备可能尚未实名
|
||||||
|
- 客户端购买套餐时强制实名会影响用户体验(需要先跳转实名流程再回来购买)
|
||||||
|
- 套餐立即生效但设备未实名会导致浪费(无法使用流量,有效期却在流失)
|
||||||
|
|
||||||
|
**业务目标**:
|
||||||
|
- 后台管理端可以为未实名设备提前购买套餐(套餐待生效,等待实名激活)
|
||||||
|
- 客户端购买套餐必须先实名(确保用户可以立即使用)
|
||||||
|
- 设备首次实名时自动激活所有待生效套餐(无需手动操作)
|
||||||
|
- 支持灵活配置:部分套餐支持实名激活,部分套餐立即生效
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 业务规则
|
||||||
|
|
||||||
|
### 1. 购买前置检查规则
|
||||||
|
|
||||||
|
购买套餐时的实名检查规则:
|
||||||
|
|
||||||
|
```
|
||||||
|
后台管理端购买(/api/admin/orders):
|
||||||
|
1. 不检查载体是否实名
|
||||||
|
2. 如果套餐 enable_realname_activation=true:
|
||||||
|
- 创建 PackageUsage status=0(待生效)
|
||||||
|
- 设置 pending_realname_activation=true
|
||||||
|
3. 如果套餐 enable_realname_activation=false:
|
||||||
|
- 创建 PackageUsage status=1(生效中)
|
||||||
|
- 立即激活,计算有效期
|
||||||
|
|
||||||
|
客户端购买(/api/h5/orders, /api/customer/orders):
|
||||||
|
1. 必须检查载体是否实名
|
||||||
|
2. 如果未实名 → 返回错误 403:"设备/卡必须先完成实名认证才能购买套餐"
|
||||||
|
3. 如果已实名 → 创建 PackageUsage status=1(生效中),立即激活
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 首次实名判定规则
|
||||||
|
|
||||||
|
判断是否为"首次实名"的逻辑:
|
||||||
|
|
||||||
|
```
|
||||||
|
设备类型(Device):
|
||||||
|
- 查询该设备下所有 IoT 卡的实名状态
|
||||||
|
- 如果至少有1张卡已实名 → 不是首次实名
|
||||||
|
- 如果所有卡都未实名,当前卡是第1张实名 → 是首次实名
|
||||||
|
|
||||||
|
单卡类型(IotCard):
|
||||||
|
- 查询该卡的实名状态
|
||||||
|
- 如果卡从未实名,本次实名成功 → 是首次实名
|
||||||
|
- 如果卡已实名(重新实名) → 不是首次实名
|
||||||
|
```
|
||||||
|
|
||||||
|
**实现方式**:
|
||||||
|
- 在 `Device` 模型中维护 `realname_status` 字段(0-未实名, 1-已实名)
|
||||||
|
- 在 `IotCard` 模型中维护 `realname_status` 字段
|
||||||
|
- 首次实名时更新对应模型的 `realname_status=1`
|
||||||
|
|
||||||
|
### 3. 激活触发规则
|
||||||
|
|
||||||
|
首次实名时触发套餐激活:
|
||||||
|
|
||||||
|
```
|
||||||
|
触发条件:
|
||||||
|
1. 载体首次实名成功(realname_status 从 0 变为 1)
|
||||||
|
2. 载体有待生效套餐(status=0 AND pending_realname_activation=true)
|
||||||
|
|
||||||
|
激活流程:
|
||||||
|
1. 实名成功后,入队 Asynq 任务 "realname_activation"
|
||||||
|
2. 任务 payload:
|
||||||
|
{
|
||||||
|
"carrier_type": "device" | "iot_card",
|
||||||
|
"carrier_id": 123,
|
||||||
|
"realname_at": "2026-02-15T10:30:00Z"
|
||||||
|
}
|
||||||
|
3. Asynq Worker 处理任务:
|
||||||
|
- 查询该载体所有 pending_realname_activation=true 且 status=0 的套餐
|
||||||
|
- 批量更新 status=1, activated_at=realname_at
|
||||||
|
- 根据套餐 calendar_type 计算 expires_at
|
||||||
|
- 记录激活日志
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 有效期计算规则
|
||||||
|
|
||||||
|
激活时根据 `calendar_type` 计算 `expires_at`:
|
||||||
|
|
||||||
|
| calendar_type | 计算规则 | 示例 |
|
||||||
|
|---------------|---------|------|
|
||||||
|
| `natural_month` | `expires_at = 激活月份的最后一天 23:59:59` | 2026-02-15 激活 → 2026-02-28 23:59:59 |
|
||||||
|
| `by_day` | `expires_at = activated_at + duration_days 天 - 1秒` | 2026-02-15 10:30:00 激活,30天 → 2026-03-16 23:59:59 |
|
||||||
|
|
||||||
|
**详细逻辑**见 `package-calendar-type/spec.md`。
|
||||||
|
|
||||||
|
### 5. enable_realname_activation 配置规则
|
||||||
|
|
||||||
|
套餐是否支持实名激活:
|
||||||
|
|
||||||
|
| enable_realname_activation | 说明 | 后台购买行为 | 客户端购买行为 |
|
||||||
|
|---------------------------|------|-------------|---------------|
|
||||||
|
| `true` | 支持实名激活 | 未实名设备:status=0,等待激活<br>已实名设备:status=1,立即生效 | 必须实名,status=1,立即生效 |
|
||||||
|
| `false` | 立即生效 | 无论是否实名,status=1,立即生效 | 必须实名,status=1,立即生效 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: 支持未实名状态购买套餐
|
||||||
|
|
||||||
|
系统 SHALL 允许后台管理端为未实名的载体(设备/卡)购买套餐,套餐状态为"待生效"(status=0)。
|
||||||
|
|
||||||
|
#### Scenario: 后台为未实名设备购买套餐成功
|
||||||
|
- **GIVEN** 设备 ID=123,realname_status=0(未实名)
|
||||||
|
- **AND** 套餐 enable_realname_activation=true
|
||||||
|
- **WHEN** 管理员通过 POST /api/admin/orders 为该设备购买套餐
|
||||||
|
- **THEN** 系统创建订单成功,PackageUsage:
|
||||||
|
- status=0(待生效)
|
||||||
|
- pending_realname_activation=true
|
||||||
|
- activated_at=NULL
|
||||||
|
- expires_at=NULL
|
||||||
|
|
||||||
|
#### Scenario: 后台为未实名设备购买不支持实名激活的套餐
|
||||||
|
- **GIVEN** 设备 ID=123,realname_status=0(未实名)
|
||||||
|
- **AND** 套餐 enable_realname_activation=false
|
||||||
|
- **WHEN** 管理员通过 POST /api/admin/orders 为该设备购买套餐
|
||||||
|
- **THEN** 系统创建订单成功,PackageUsage:
|
||||||
|
- status=1(生效中)
|
||||||
|
- pending_realname_activation=false
|
||||||
|
- activated_at=订单支付时间
|
||||||
|
- expires_at=根据 calendar_type 计算
|
||||||
|
|
||||||
|
#### Scenario: 客户端未实名时购买套餐失败
|
||||||
|
- **GIVEN** 设备 ID=123,realname_status=0(未实名)
|
||||||
|
- **WHEN** 客户通过 POST /api/h5/orders 为该设备购买套餐
|
||||||
|
- **THEN** 系统返回错误 403,错误码 `REALNAME_REQUIRED`,错误消息:"设备/卡必须先完成实名认证才能购买套餐"
|
||||||
|
|
||||||
|
#### Scenario: 已实名设备购买套餐立即生效
|
||||||
|
- **GIVEN** 设备 ID=123,realname_status=1(已实名)
|
||||||
|
- **AND** 套餐 enable_realname_activation=true
|
||||||
|
- **WHEN** 管理员或客户为该设备购买套餐
|
||||||
|
- **THEN** 系统创建订单成功,PackageUsage:
|
||||||
|
- status=1(生效中)
|
||||||
|
- pending_realname_activation=false
|
||||||
|
- activated_at=订单支付时间
|
||||||
|
- expires_at=根据 calendar_type 计算
|
||||||
|
|
||||||
|
#### Scenario: 后台批量购买套餐(部分未实名)
|
||||||
|
- **GIVEN** 设备A(realname_status=0),设备B(realname_status=1)
|
||||||
|
- **WHEN** 管理员批量为设备A和设备B购买套餐(enable_realname_activation=true)
|
||||||
|
- **THEN** 系统创建订单成功:
|
||||||
|
- 设备A套餐:status=0,pending_realname_activation=true
|
||||||
|
- 设备B套餐:status=1,pending_realname_activation=false
|
||||||
|
|
||||||
|
### Requirement: 首次实名时自动激活待生效套餐
|
||||||
|
|
||||||
|
系统 SHALL 在载体首次实名成功时,自动激活所有 pending_realname_activation=true 的待生效套餐。
|
||||||
|
|
||||||
|
#### Scenario: 设备首张卡实名触发套餐激活
|
||||||
|
- **GIVEN** 设备 ID=123,realname_status=0,有2个待生效套餐(pending_realname_activation=true)
|
||||||
|
- **AND** 该设备下所有 IoT 卡都未实名
|
||||||
|
- **WHEN** 设备的第1张卡在 2026-02-15 10:30:00 完成实名认证
|
||||||
|
- **THEN** 系统:
|
||||||
|
1. 更新设备 realname_status=1
|
||||||
|
2. 入队 Asynq 任务 "realname_activation"
|
||||||
|
3. 任务执行:批量更新2个套餐 status=1,activated_at=2026-02-15 10:30:00
|
||||||
|
4. 根据各套餐 calendar_type 计算 expires_at
|
||||||
|
|
||||||
|
#### Scenario: 设备后续卡实名不触发激活
|
||||||
|
- **GIVEN** 设备 ID=123,realname_status=1(已有1张卡实名)
|
||||||
|
- **WHEN** 设备的第2张卡在 2026-02-20 10:00:00 完成实名认证
|
||||||
|
- **THEN** 系统不触发套餐激活,设备的套餐状态保持不变
|
||||||
|
|
||||||
|
#### Scenario: 单卡设备实名触发激活
|
||||||
|
- **GIVEN** IoT 卡 ICCID=123456,realname_status=0,有1个待生效套餐
|
||||||
|
- **WHEN** 该卡在 2026-02-15 10:30:00 完成实名认证
|
||||||
|
- **THEN** 系统:
|
||||||
|
1. 更新卡 realname_status=1
|
||||||
|
2. 入队 Asynq 任务 "realname_activation"
|
||||||
|
3. 任务执行:更新套餐 status=1,activated_at=2026-02-15 10:30:00
|
||||||
|
|
||||||
|
#### Scenario: 激活时排除已生效的套餐
|
||||||
|
- **GIVEN** 设备 ID=123,realname_status=0,有2个套餐:
|
||||||
|
- 套餐A:status=0,pending_realname_activation=true
|
||||||
|
- 套餐B:status=1(已生效)
|
||||||
|
- **WHEN** 设备在 2026-02-15 10:30:00 首次实名
|
||||||
|
- **THEN** 系统只激活套餐A,套餐B 保持不变
|
||||||
|
|
||||||
|
#### Scenario: 无待激活套餐时不执行激活逻辑
|
||||||
|
- **GIVEN** 设备 ID=123,realname_status=0,无任何套餐
|
||||||
|
- **WHEN** 设备在 2026-02-15 10:30:00 首次实名
|
||||||
|
- **THEN** 系统入队 Asynq 任务,任务执行后发现无待激活套餐,直接返回
|
||||||
|
|
||||||
|
### Requirement: 激活时根据套餐类型计算有效期
|
||||||
|
|
||||||
|
系统 SHALL 在首次实名激活套餐时,根据套餐的 calendar_type 计算 expires_at。
|
||||||
|
|
||||||
|
#### Scenario: 实名激活自然月套餐
|
||||||
|
- **GIVEN** 套餐 calendar_type=natural_month,duration_months=1
|
||||||
|
- **WHEN** 2026-02-15 10:30:00 首次实名激活
|
||||||
|
- **THEN** 系统计算:
|
||||||
|
- activated_at=2026-02-15 10:30:00
|
||||||
|
- expires_at=2026-02-28 23:59:59(当月最后一天)
|
||||||
|
|
||||||
|
#### Scenario: 实名激活按天套餐
|
||||||
|
- **GIVEN** 套餐 calendar_type=by_day,duration_days=30
|
||||||
|
- **WHEN** 2026-02-15 10:30:00 首次实名激活
|
||||||
|
- **THEN** 系统计算:
|
||||||
|
- activated_at=2026-02-15 10:30:00
|
||||||
|
- expires_at=2026-03-16 23:59:59(+30天-1秒)
|
||||||
|
|
||||||
|
#### Scenario: 实名激活跨年自然月套餐
|
||||||
|
- **GIVEN** 套餐 calendar_type=natural_month,duration_months=2
|
||||||
|
- **WHEN** 2026-12-15 10:30:00 首次实名激活
|
||||||
|
- **THEN** 系统计算:
|
||||||
|
- activated_at=2026-12-15 10:30:00
|
||||||
|
- expires_at=2027-01-31 23:59:59(跨年到次年1月最后一天)
|
||||||
|
|
||||||
|
#### Scenario: 激活时有效期计算失败
|
||||||
|
- **GIVEN** 套餐 calendar_type=natural_month,duration_months=NULL(数据异常)
|
||||||
|
- **WHEN** 首次实名激活
|
||||||
|
- **THEN** 系统:
|
||||||
|
1. 激活失败,套餐 status 保持 0
|
||||||
|
2. 记录 Error 日志(包含套餐ID、载体信息、错误原因)
|
||||||
|
3. Asynq 重试(最多3次)
|
||||||
|
|
||||||
|
### Requirement: 支持配置是否启用实名激活
|
||||||
|
|
||||||
|
系统 SHALL 在套餐模型中提供 enable_realname_activation 字段,允许管理员配置是否需要实名激活。
|
||||||
|
|
||||||
|
#### Scenario: 创建需要实名激活的套餐
|
||||||
|
- **WHEN** 管理员创建套餐时指定 enable_realname_activation=true
|
||||||
|
- **THEN** 系统创建成功,该套餐:
|
||||||
|
- 后台购买未实名设备:status=0,等待激活
|
||||||
|
- 后台购买已实名设备:status=1,立即生效
|
||||||
|
- 客户端购买:必须实名,status=1,立即生效
|
||||||
|
|
||||||
|
#### Scenario: 创建立即生效的套餐
|
||||||
|
- **WHEN** 管理员创建套餐时指定 enable_realname_activation=false
|
||||||
|
- **THEN** 系统创建成功,该套餐:
|
||||||
|
- 无论后台还是客户端购买,status=1,立即生效
|
||||||
|
- 不需要等待实名激活
|
||||||
|
|
||||||
|
#### Scenario: 更新套餐的实名激活配置
|
||||||
|
- **GIVEN** 套餐 ID=123,enable_realname_activation=false
|
||||||
|
- **WHEN** 管理员更新套餐配置为 enable_realname_activation=true
|
||||||
|
- **THEN** 系统更新成功,该套餐后续购买行为遵循新配置
|
||||||
|
- **AND** 已有的 PackageUsage 不受影响
|
||||||
|
|
||||||
|
### Requirement: 实名激活异步处理
|
||||||
|
|
||||||
|
系统 SHALL 通过 Asynq 异步任务处理首次实名激活逻辑,避免阻塞实名认证流程。
|
||||||
|
|
||||||
|
#### Scenario: 实名成功后入队激活任务
|
||||||
|
- **GIVEN** 设备 ID=123 首次实名成功
|
||||||
|
- **WHEN** 系统更新设备 realname_status=1
|
||||||
|
- **THEN** 系统入队 Asynq 任务:
|
||||||
|
- task_type="realname_activation"
|
||||||
|
- payload={"carrier_type": "device", "carrier_id": 123, "realname_at": "2026-02-15T10:30:00Z"}
|
||||||
|
- queue="default"
|
||||||
|
- max_retry=3
|
||||||
|
|
||||||
|
#### Scenario: 激活任务在1分钟内完成
|
||||||
|
- **GIVEN** Asynq 任务 "realname_activation" 从队列取出
|
||||||
|
- **WHEN** Worker 执行任务
|
||||||
|
- **THEN** 系统在1分钟内完成套餐激活,更新 PackageUsage 状态
|
||||||
|
- **AND** 任务标记为成功,从队列移除
|
||||||
|
|
||||||
|
#### Scenario: 激活任务失败后重试
|
||||||
|
- **GIVEN** Asynq 任务 "realname_activation" 执行时数据库连接失败
|
||||||
|
- **WHEN** 任务执行失败
|
||||||
|
- **THEN** 系统:
|
||||||
|
1. 记录 Error 日志(包含载体ID、错误信息)
|
||||||
|
2. Asynq 自动重试(间隔 10s/30s/60s)
|
||||||
|
3. 3次失败后写入死信队列,发送告警
|
||||||
|
|
||||||
|
#### Scenario: 激活任务幂等性
|
||||||
|
- **GIVEN** Asynq 任务 "realname_activation" 因网络波动重复执行
|
||||||
|
- **WHEN** Worker 第2次执行同一任务
|
||||||
|
- **THEN** 系统检查套餐 status:
|
||||||
|
- 如果已是 status=1 → 跳过激活,直接返回成功
|
||||||
|
- 如果仍是 status=0 → 执行激活逻辑
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 边界条件
|
||||||
|
|
||||||
|
### 1. 并发首次实名
|
||||||
|
|
||||||
|
- **场景**:设备的2张卡同时完成实名认证(并发请求)
|
||||||
|
- **处理**:
|
||||||
|
- 使用数据库行锁:`SELECT * FROM device WHERE id=? FOR UPDATE`
|
||||||
|
- 第1个请求更新 realname_status=1,触发激活
|
||||||
|
- 第2个请求发现 realname_status=1,不触发激活
|
||||||
|
|
||||||
|
### 2. 激活任务部分失败
|
||||||
|
|
||||||
|
- **场景**:设备有3个待激活套餐,激活第2个时失败
|
||||||
|
- **处理**:
|
||||||
|
- 使用事务:全部激活成功才提交
|
||||||
|
- 失败时回滚,3个套餐保持 status=0
|
||||||
|
- Asynq 重试,重新激活全部3个套餐
|
||||||
|
|
||||||
|
### 3. 实名时无待激活套餐
|
||||||
|
|
||||||
|
- **场景**:设备首次实名时,无任何套餐
|
||||||
|
- **处理**:
|
||||||
|
- 仍然入队 Asynq 任务
|
||||||
|
- 任务执行时查询套餐数量=0,直接返回成功
|
||||||
|
- 不记录错误日志
|
||||||
|
|
||||||
|
### 4. 套餐购买和实名并发
|
||||||
|
|
||||||
|
- **场景**:设备购买套餐的同时,完成首次实名
|
||||||
|
- **处理**:
|
||||||
|
- 购买订单时检查 realname_status:
|
||||||
|
- 如果未实名 → status=0,pending_realname_activation=true
|
||||||
|
- 如果已实名 → status=1,立即生效
|
||||||
|
- 实名激活任务执行时,再次检查套餐状态,只激活 status=0 的套餐
|
||||||
|
|
||||||
|
### 5. 有效期计算异常
|
||||||
|
|
||||||
|
- **场景**:套餐 calendar_type 或 duration_days/duration_months 为 NULL
|
||||||
|
- **处理**:
|
||||||
|
- 激活失败,返回错误 500
|
||||||
|
- 记录 Error 日志(包含套餐ID、载体ID、错误原因)
|
||||||
|
- Asynq 重试(最多3次)
|
||||||
|
- 3次失败后写入死信队列,发送告警
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 并发场景
|
||||||
|
|
||||||
|
### Scenario: 并发首次实名
|
||||||
|
- **GIVEN** 设备 ID=123,realname_status=0,有2张卡
|
||||||
|
- **WHEN** 两张卡同时在 2026-02-15 10:30:00 完成实名认证
|
||||||
|
- **THEN** 系统使用行锁:
|
||||||
|
```sql
|
||||||
|
SELECT * FROM device WHERE id=123 FOR UPDATE
|
||||||
|
```
|
||||||
|
- **AND** 第1个请求:
|
||||||
|
- 更新 realname_status=1
|
||||||
|
- 入队 Asynq 任务
|
||||||
|
- **AND** 第2个请求:
|
||||||
|
- 发现 realname_status=1
|
||||||
|
- 不入队任务
|
||||||
|
|
||||||
|
### Scenario: 并发购买套餐和首次实名
|
||||||
|
- **GIVEN** 设备 ID=123,realname_status=0
|
||||||
|
- **WHEN** 同时发生:
|
||||||
|
- 请求1:管理员购买套餐(enable_realname_activation=true)
|
||||||
|
- 请求2:设备完成首次实名
|
||||||
|
- **THEN** 使用事务隔离:
|
||||||
|
- 如果请求1先完成 → 套餐 status=0,然后被请求2激活
|
||||||
|
- 如果请求2先完成 → 设备 realname_status=1,请求1创建套餐时 status=1(立即生效)
|
||||||
|
|
||||||
|
### Scenario: 并发激活任务(重复入队)
|
||||||
|
- **GIVEN** Asynq 任务 "realname_activation" 因网络抖动重复入队
|
||||||
|
- **WHEN** Worker 同时处理2个相同任务
|
||||||
|
- **THEN** 系统使用行锁:
|
||||||
|
```sql
|
||||||
|
SELECT * FROM package_usage WHERE carrier_id=? AND status=0 FOR UPDATE
|
||||||
|
```
|
||||||
|
- **AND** 第1个任务:激活成功,套餐 status=1
|
||||||
|
- **AND** 第2个任务:发现 status=1,跳过激活
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 异常处理
|
||||||
|
|
||||||
|
### 1. 激活任务失败
|
||||||
|
|
||||||
|
- **错误场景**:Asynq 任务执行时数据库连接失败
|
||||||
|
- **处理流程**:
|
||||||
|
1. 捕获错误,记录 Error 日志(包含载体ID、错误信息)
|
||||||
|
2. Asynq 自动重试(最多3次,间隔 10s/30s/60s)
|
||||||
|
3. 重试前检查套餐 status(避免重复激活)
|
||||||
|
4. 3次失败后写入死信队列,发送告警通知
|
||||||
|
- **返回错误**:不返回给用户(异步任务),仅记录日志
|
||||||
|
|
||||||
|
### 2. 有效期计算失败
|
||||||
|
|
||||||
|
- **错误场景**:套餐 calendar_type 或 duration_days 数据异常
|
||||||
|
- **处理流程**:
|
||||||
|
1. 激活失败,套餐 status 保持 0
|
||||||
|
2. 记录 Error 日志(包含套餐ID、载体ID、calendar_type、duration_days)
|
||||||
|
3. Asynq 重试(最多3次)
|
||||||
|
4. 3次失败后写入死信队列,发送告警
|
||||||
|
- **返回错误**:不返回给用户(异步任务),仅记录日志
|
||||||
|
|
||||||
|
### 3. 批量激活部分失败
|
||||||
|
|
||||||
|
- **错误场景**:设备有3个待激活套餐,激活第2个时失败
|
||||||
|
- **处理流程**:
|
||||||
|
1. 使用事务包裹批量更新
|
||||||
|
2. 任何一个套餐激活失败 → 事务回滚,全部套餐保持 status=0
|
||||||
|
3. 记录 Error 日志(包含设备ID、失败套餐ID、错误原因)
|
||||||
|
4. Asynq 重试,重新激活全部套餐
|
||||||
|
- **返回错误**:不返回给用户(异步任务),仅记录日志
|
||||||
|
|
||||||
|
### 4. 首次实名判定失败
|
||||||
|
|
||||||
|
- **错误场景**:查询设备的 IoT 卡列表时超时
|
||||||
|
- **处理流程**:
|
||||||
|
1. 实名认证流程继续(不阻塞)
|
||||||
|
2. Asynq 任务入队
|
||||||
|
3. 任务执行时再次尝试查询,失败则重试
|
||||||
|
4. 3次失败后写入死信队列
|
||||||
|
- **返回错误**:实名认证返回成功,激活任务在后台处理
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 数据一致性保证
|
||||||
|
|
||||||
|
### 1. 事务边界
|
||||||
|
|
||||||
|
- **首次实名 + 入队任务**:更新 realname_status 后再入队(确保任务执行时状态已更新)
|
||||||
|
- **批量激活套餐**:使用单个事务,全部成功或全部失败
|
||||||
|
- **并发首次实名检查**:使用 `SELECT FOR UPDATE` 行锁
|
||||||
|
|
||||||
|
### 2. 行锁机制
|
||||||
|
|
||||||
|
- **首次实名检查**:`SELECT * FROM device WHERE id=? FOR UPDATE`
|
||||||
|
- **批量激活套餐**:`SELECT * FROM package_usage WHERE carrier_id=? AND status=0 FOR UPDATE`
|
||||||
|
|
||||||
|
### 3. 幂等性保证
|
||||||
|
|
||||||
|
#### 使用 first_realname_at 字段确保首次实名幂等
|
||||||
|
|
||||||
|
系统使用 `tb_iot_card.first_realname_at` 字段(时间戳)确保首次实名激活只执行一次:
|
||||||
|
|
||||||
|
**数据库字段**:
|
||||||
|
```sql
|
||||||
|
-- tb_iot_card 新增字段
|
||||||
|
ALTER TABLE tb_iot_card
|
||||||
|
ADD COLUMN first_realname_at TIMESTAMP NULL COMMENT '首次实名时间,NULL=未实名,非NULL=已实名(幂等标记)';
|
||||||
|
```
|
||||||
|
|
||||||
|
**幂等更新**:
|
||||||
|
```sql
|
||||||
|
-- 首次实名触发时(原子操作)
|
||||||
|
UPDATE tb_iot_card
|
||||||
|
SET first_realname_at = NOW()
|
||||||
|
WHERE id = ? AND first_realname_at IS NULL;
|
||||||
|
|
||||||
|
-- 通过影响行数判断是否首次实名
|
||||||
|
-- rows_affected = 1 → 首次实名,执行激活逻辑
|
||||||
|
-- rows_affected = 0 → 已处理,跳过
|
||||||
|
```
|
||||||
|
|
||||||
|
**优势**:
|
||||||
|
- **比 realname_status 更可靠**:状态字段可能被重置,时间戳不可逆
|
||||||
|
- **可追溯首次实名时间**:便于审计和问题排查
|
||||||
|
- **数据库层面保证唯一更新**:WHERE 条件确保只有首次实名时更新成功
|
||||||
|
- **无需 Redis 锁**:数据库行级锁已足够,减少依赖
|
||||||
|
|
||||||
|
**实现示例**:
|
||||||
|
```go
|
||||||
|
// Service 层:检查并标记首次实名
|
||||||
|
func (s *Service) MarkFirstRealname(ctx context.Context, cardID uint) (bool, error) {
|
||||||
|
result := s.db.WithContext(ctx).
|
||||||
|
Model(&model.IotCard{}).
|
||||||
|
Where("id = ? AND first_realname_at IS NULL", cardID).
|
||||||
|
Update("first_realname_at", time.Now())
|
||||||
|
|
||||||
|
if result.Error != nil {
|
||||||
|
return false, errors.Wrap(errors.CodeInternal, result.Error, "更新首次实名时间失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 影响行数 = 1 表示首次实名
|
||||||
|
isFirstRealname := result.RowsAffected == 1
|
||||||
|
|
||||||
|
return isFirstRealname, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 轮询系统:检测实名状态变更时
|
||||||
|
func (h *Handler) HandleRealnameCheck(ctx context.Context, task *asynq.Task) error {
|
||||||
|
// 1. 检测到卡实名状态变更(realname_status: 0 → 2)
|
||||||
|
// ...
|
||||||
|
|
||||||
|
// 2. 尝试标记首次实名
|
||||||
|
isFirstRealname, err := h.iotCardService.MarkFirstRealname(ctx, cardID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 只有首次实名时才触发套餐激活
|
||||||
|
if isFirstRealname {
|
||||||
|
err := h.queueClient.Enqueue(TaskTypePackageFirstActivation, payload)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **激活任务幂等**:执行前检查套餐 status,如果已激活则跳过
|
||||||
|
- **实名状态幂等**:重复实名不触发激活(通过 first_realname_at 字段保证)
|
||||||
|
|
||||||
|
### 4. 数据校验
|
||||||
|
|
||||||
|
- **购买套餐前**:校验 enable_realname_activation 与 realname_status 的一致性
|
||||||
|
- **激活套餐前**:校验 calendar_type 和 duration_days/duration_months 是否有效
|
||||||
|
- **首次实名判定**:校验设备的 IoT 卡列表是否完整
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 性能指标
|
||||||
|
|
||||||
|
| 操作 | 目标响应时间 | 并发要求 | 数据量 |
|
||||||
|
|------|-------------|---------|--------|
|
||||||
|
| 后台购买套餐(实名检查) | < 50ms | 100 QPS | 单载体查询 |
|
||||||
|
| 客户端购买套餐(实名检查) | < 100ms | 200 QPS | 单载体查询 |
|
||||||
|
| 首次实名入队任务 | < 50ms | 100 QPS | 入队操作 |
|
||||||
|
| 激活任务执行(批量激活) | < 1000ms | 50 QPS | 批量更新(平均5个套餐) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 错误码定义
|
||||||
|
|
||||||
|
| 错误码 | HTTP 状态码 | 错误消息 | 场景 |
|
||||||
|
|--------|------------|---------|------|
|
||||||
|
| `REALNAME_REQUIRED` | 403 | 设备/卡必须先完成实名认证才能购买套餐 | 客户端购买套餐时未实名 |
|
||||||
|
| `ACTIVATION_FAILED` | 500 | 套餐激活失败,请稍后重试 | 激活任务执行失败 |
|
||||||
|
| `EXPIRY_CALCULATION_FAILED` | 500 | 有效期计算失败,请联系管理员 | calendar_type 或 duration 数据异常 |
|
||||||
|
| `REALNAME_STATUS_UPDATE_FAILED` | 500 | 实名状态更新失败,请稍后重试 | 更新 realname_status 失败 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 数据迁移策略
|
||||||
|
|
||||||
|
**激进策略**(开发阶段,保证干净性):
|
||||||
|
|
||||||
|
### 1. ❌ 要删除的字段
|
||||||
|
|
||||||
|
目前 `package_usage` 表中可能存在的冗余字段(需确认后删除):
|
||||||
|
- 如果有 `realname_activated` 字段(旧的实名激活标志) → **删除**
|
||||||
|
- 如果有 `wait_realname` 字段(旧的等待实名标志) → **删除**
|
||||||
|
|
||||||
|
目前 `package` 表中可能存在的冗余字段(需确认后删除):
|
||||||
|
- 如果有 `require_realname` 字段(旧的实名要求标志) → **删除**
|
||||||
|
|
||||||
|
目前 `device` 和 `iot_card` 表中可能存在的冗余字段(需确认后删除):
|
||||||
|
- 如果有 `is_realname` 字段(旧的实名标志) → **删除**,统一使用 `realname_status`
|
||||||
|
|
||||||
|
### 2. ✅ 新增的字段
|
||||||
|
|
||||||
|
在 `package_usage` 表中新增:
|
||||||
|
```sql
|
||||||
|
ALTER TABLE package_usage
|
||||||
|
ADD COLUMN pending_realname_activation BOOLEAN DEFAULT false COMMENT '是否等待实名激活';
|
||||||
|
|
||||||
|
CREATE INDEX idx_pending_realname_activation ON package_usage(carrier_id, pending_realname_activation, status);
|
||||||
|
```
|
||||||
|
|
||||||
|
在 `package` 表中新增:
|
||||||
|
```sql
|
||||||
|
ALTER TABLE package
|
||||||
|
ADD COLUMN enable_realname_activation BOOLEAN DEFAULT false COMMENT '是否启用实名激活机制(true=支持未实名购买并等待激活,false=立即生效)';
|
||||||
|
```
|
||||||
|
|
||||||
|
在 `device` 表中新增(如果不存在):
|
||||||
|
```sql
|
||||||
|
ALTER TABLE device
|
||||||
|
ADD COLUMN realname_status TINYINT DEFAULT 0 COMMENT '实名状态(0-未实名,1-已实名)';
|
||||||
|
|
||||||
|
CREATE INDEX idx_realname_status ON device(realname_status);
|
||||||
|
```
|
||||||
|
|
||||||
|
在 `iot_card` 表中新增(如果不存在):
|
||||||
|
```sql
|
||||||
|
ALTER TABLE iot_card
|
||||||
|
ADD COLUMN realname_status TINYINT DEFAULT 0 COMMENT '实名状态(0-未实名,1-已实名)';
|
||||||
|
|
||||||
|
CREATE INDEX idx_realname_status ON iot_card(realname_status);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. ❌ 要废弃的逻辑
|
||||||
|
|
||||||
|
- **废弃旧的实名检查逻辑**:如果代码中存在通过 `is_realname` 或 `require_realname` 字段检查实名的逻辑,全部删除
|
||||||
|
- **废弃旧的激活逻辑**:如果代码中存在手动激活套餐的逻辑(非首次实名触发),全部删除
|
||||||
|
- **废弃旧的实名状态字段**:统一使用 `realname_status`(0/1),删除其他相关字段
|
||||||
|
|
||||||
|
### 4. ✅ 历史数据强制转换
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Step 1: 历史设备/卡的实名状态初始化
|
||||||
|
-- 根据实际业务规则确定历史数据的实名状态(假设有 realname_info 字段)
|
||||||
|
UPDATE device
|
||||||
|
SET realname_status = CASE
|
||||||
|
WHEN realname_info IS NOT NULL AND realname_info != '' THEN 1
|
||||||
|
ELSE 0
|
||||||
|
END
|
||||||
|
WHERE realname_status IS NULL;
|
||||||
|
|
||||||
|
UPDATE iot_card
|
||||||
|
SET realname_status = CASE
|
||||||
|
WHEN realname_info IS NOT NULL AND realname_info != '' THEN 1
|
||||||
|
ELSE 0
|
||||||
|
END
|
||||||
|
WHERE realname_status IS NULL;
|
||||||
|
|
||||||
|
-- Step 2: 历史套餐的实名激活配置初始化
|
||||||
|
-- 假设历史套餐默认不启用实名激活(立即生效)
|
||||||
|
UPDATE package
|
||||||
|
SET enable_realname_activation = false
|
||||||
|
WHERE enable_realname_activation IS NULL;
|
||||||
|
|
||||||
|
-- Step 3: 历史 PackageUsage 的 pending_realname_activation 初始化
|
||||||
|
-- 已生效的套餐:pending_realname_activation=false
|
||||||
|
UPDATE package_usage
|
||||||
|
SET pending_realname_activation = false
|
||||||
|
WHERE status IN (1, 2, 3, 4) -- 生效中、已用完、已过期、已失效
|
||||||
|
AND pending_realname_activation IS NULL;
|
||||||
|
|
||||||
|
-- 待生效的套餐:根据载体实名状态判断
|
||||||
|
-- 如果载体未实名 → pending_realname_activation=true
|
||||||
|
-- 如果载体已实名 → 强制激活套餐(status=1)
|
||||||
|
-- 注意:需要根据 carrier_type 判断是 device 还是 iot_card
|
||||||
|
UPDATE package_usage pu
|
||||||
|
SET pending_realname_activation = true
|
||||||
|
WHERE pu.status = 0
|
||||||
|
AND pu.pending_realname_activation IS NULL
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM device d
|
||||||
|
WHERE d.id = pu.carrier_id
|
||||||
|
AND pu.carrier_type = 'device'
|
||||||
|
AND d.realname_status = 0
|
||||||
|
);
|
||||||
|
|
||||||
|
UPDATE package_usage pu
|
||||||
|
SET pending_realname_activation = true
|
||||||
|
WHERE pu.status = 0
|
||||||
|
AND pu.pending_realname_activation IS NULL
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM iot_card ic
|
||||||
|
WHERE ic.id = pu.carrier_id
|
||||||
|
AND pu.carrier_type = 'iot_card'
|
||||||
|
AND ic.realname_status = 0
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Step 4: 已实名但待生效的套餐强制激活
|
||||||
|
-- (这些套餐应该在购买时就激活,现在补上)
|
||||||
|
UPDATE package_usage pu
|
||||||
|
SET status = 1,
|
||||||
|
activated_at = pu.created_at, -- 假设使用创建时间作为激活时间
|
||||||
|
pending_realname_activation = false
|
||||||
|
WHERE pu.status = 0
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM device d
|
||||||
|
WHERE d.id = pu.carrier_id
|
||||||
|
AND pu.carrier_type = 'device'
|
||||||
|
AND d.realname_status = 1
|
||||||
|
);
|
||||||
|
|
||||||
|
UPDATE package_usage pu
|
||||||
|
SET status = 1,
|
||||||
|
activated_at = pu.created_at,
|
||||||
|
pending_realname_activation = false
|
||||||
|
WHERE pu.status = 0
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM iot_card ic
|
||||||
|
WHERE ic.id = pu.carrier_id
|
||||||
|
AND pu.carrier_type = 'iot_card'
|
||||||
|
AND ic.realname_status = 1
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 注意:Step 4 强制激活的套餐需要重新计算 expires_at
|
||||||
|
-- 建议编写数据修复脚本,调用有效期计算逻辑
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. ❌ 删除遗留表/字段(确认后执行)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 如果存在旧的实名相关字段,删除
|
||||||
|
-- ALTER TABLE package_usage DROP COLUMN IF EXISTS realname_activated;
|
||||||
|
-- ALTER TABLE package_usage DROP COLUMN IF EXISTS wait_realname;
|
||||||
|
-- ALTER TABLE package DROP COLUMN IF EXISTS require_realname;
|
||||||
|
-- ALTER TABLE device DROP COLUMN IF EXISTS is_realname;
|
||||||
|
-- ALTER TABLE iot_card DROP COLUMN IF EXISTS is_realname;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. 验证步骤
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 验证1:所有设备和卡都有 realname_status
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM device
|
||||||
|
WHERE realname_status IS NULL;
|
||||||
|
-- 预期结果:0
|
||||||
|
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM iot_card
|
||||||
|
WHERE realname_status IS NULL;
|
||||||
|
-- 预期结果:0
|
||||||
|
|
||||||
|
-- 验证2:所有套餐都有 enable_realname_activation
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM package
|
||||||
|
WHERE enable_realname_activation IS NULL;
|
||||||
|
-- 预期结果:0
|
||||||
|
|
||||||
|
-- 验证3:所有 PackageUsage 都有 pending_realname_activation
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM package_usage
|
||||||
|
WHERE pending_realname_activation IS NULL;
|
||||||
|
-- 预期结果:0
|
||||||
|
|
||||||
|
-- 验证4:待生效套餐的载体必须未实名(或有 pending_realname_activation=true)
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM package_usage pu
|
||||||
|
JOIN device d ON pu.carrier_id = d.id AND pu.carrier_type = 'device'
|
||||||
|
WHERE pu.status = 0
|
||||||
|
AND pu.pending_realname_activation = false
|
||||||
|
AND d.realname_status = 0;
|
||||||
|
-- 预期结果:0(不应该有未实名但又不等待激活的待生效套餐)
|
||||||
|
|
||||||
|
-- 验证5:已实名载体的套餐不应该待生效(除非后续购买)
|
||||||
|
-- (这个验证需要根据实际业务规则调整)
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM package_usage pu
|
||||||
|
JOIN device d ON pu.carrier_id = d.id AND pu.carrier_type = 'device'
|
||||||
|
WHERE pu.status = 0
|
||||||
|
AND pu.pending_realname_activation = true
|
||||||
|
AND d.realname_status = 1;
|
||||||
|
-- 预期结果:0(已实名设备不应该有等待激活的套餐)
|
||||||
|
|
||||||
|
-- 验证6:检查是否还有遗留字段(需根据实际情况调整)
|
||||||
|
-- SELECT column_name FROM information_schema.columns
|
||||||
|
-- WHERE table_name = 'package_usage'
|
||||||
|
-- AND column_name IN ('realname_activated', 'wait_realname');
|
||||||
|
-- 预期结果:0 rows
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 测试场景矩阵
|
||||||
|
|
||||||
|
| 场景分类 | 测试用例 | 预期结果 |
|
||||||
|
|---------|---------|---------|
|
||||||
|
| **购买套餐** | 后台购买套餐(未实名设备,enable_realname_activation=true) | status=0,pending_realname_activation=true |
|
||||||
|
| | 后台购买套餐(未实名设备,enable_realname_activation=false) | status=1,立即生效 |
|
||||||
|
| | 后台购买套餐(已实名设备) | status=1,立即生效 |
|
||||||
|
| | 客户端购买套餐(未实名设备) | 返回错误 403:REALNAME_REQUIRED |
|
||||||
|
| | 客户端购买套餐(已实名设备) | status=1,立即生效 |
|
||||||
|
| **首次实名** | 设备首张卡实名 | 触发激活,套餐 status=1 |
|
||||||
|
| | 设备后续卡实名 | 不触发激活,套餐状态不变 |
|
||||||
|
| | 单卡设备实名 | 触发激活,套餐 status=1 |
|
||||||
|
| | 并发首次实名 | 使用行锁,只触发1次激活 |
|
||||||
|
| **激活逻辑** | 激活自然月套餐 | expires_at=当月最后一天 23:59:59 |
|
||||||
|
| | 激活按天套餐 | expires_at=activated_at+duration_days-1秒 |
|
||||||
|
| | 激活时无待激活套餐 | 任务直接返回成功,不报错 |
|
||||||
|
| | 激活时排除已生效套餐 | 只激活 status=0 的套餐 |
|
||||||
|
| **异步任务** | 实名成功后入队任务 | 任务入队成功,payload 包含载体信息 |
|
||||||
|
| | 激活任务在1分钟内完成 | 批量更新成功,任务标记为完成 |
|
||||||
|
| | 激活任务失败后重试 | Asynq 重试3次,失败后进入死信队列 |
|
||||||
|
| | 激活任务幂等性 | 重复执行时检查状态,跳过已激活套餐 |
|
||||||
|
| **并发** | 并发购买套餐和首次实名 | 事务隔离,先完成的操作生效 |
|
||||||
|
| | 并发激活任务 | 使用行锁,避免重复激活 |
|
||||||
|
| **异常** | 有效期计算失败 | 激活失败,记录日志,Asynq 重试 |
|
||||||
|
| | 批量激活部分失败 | 事务回滚,全部套餐保持 status=0 |
|
||||||
|
| | 首次实名判定失败 | 不阻塞实名流程,任务重试 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 实现参考
|
||||||
|
|
||||||
|
### 购买套餐时的实名检查
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Service 层:CreateOrder
|
||||||
|
func (s *Service) CreateOrder(ctx context.Context, req *CreateOrderRequest) error {
|
||||||
|
// 1. 检查载体实名状态
|
||||||
|
realnameStatus, err := s.getCarrierRealnameStatus(ctx, req.CarrierType, req.CarrierID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(errors.CodeInternalError, err, "查询实名状态失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 客户端购买必须实名
|
||||||
|
requestSource := middleware.GetRequestSourceFromContext(ctx) // "admin" or "customer"
|
||||||
|
if requestSource == "customer" && realnameStatus == 0 {
|
||||||
|
return errors.New(errors.CodeForbidden, "设备/卡必须先完成实名认证才能购买套餐")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 查询套餐配置
|
||||||
|
pkg, err := s.packageStore.GetByID(ctx, req.PackageID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(errors.CodeInternalError, err, "查询套餐失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 确定套餐状态
|
||||||
|
var status int
|
||||||
|
var pendingRealnameActivation bool
|
||||||
|
|
||||||
|
if pkg.EnableRealnameActivation && realnameStatus == 0 && requestSource == "admin" {
|
||||||
|
// 后台购买未实名设备的实名激活套餐 → 待生效
|
||||||
|
status = constants.PackageStatusPending
|
||||||
|
pendingRealnameActivation = true
|
||||||
|
} else {
|
||||||
|
// 其他情况 → 立即生效
|
||||||
|
status = constants.PackageStatusActive
|
||||||
|
pendingRealnameActivation = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 创建 PackageUsage
|
||||||
|
usage := &model.PackageUsage{
|
||||||
|
CarrierType: req.CarrierType,
|
||||||
|
CarrierID: req.CarrierID,
|
||||||
|
PackageID: req.PackageID,
|
||||||
|
Status: status,
|
||||||
|
PendingRealnameActivation: pendingRealnameActivation,
|
||||||
|
}
|
||||||
|
|
||||||
|
if status == constants.PackageStatusActive {
|
||||||
|
// 立即生效:计算 activated_at 和 expires_at
|
||||||
|
usage.ActivatedAt = time.Now()
|
||||||
|
usage.ExpiresAt = s.calculateExpiresAt(usage.ActivatedAt, pkg.CalendarType, pkg.DurationDays, pkg.DurationMonths)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.packageUsageStore.Create(ctx, usage); err != nil {
|
||||||
|
return errors.Wrap(errors.CodeInternalError, err, "创建套餐使用记录失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 首次实名时入队激活任务
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Service 层:HandleRealnameSuccess
|
||||||
|
func (s *Service) HandleRealnameSuccess(ctx context.Context, carrierType string, carrierID uint) error {
|
||||||
|
// 1. 检查是否为首次实名
|
||||||
|
isFirstRealname, err := s.checkFirstRealname(ctx, carrierType, carrierID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(errors.CodeInternalError, err, "检查首次实名失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isFirstRealname {
|
||||||
|
s.logger.Info("非首次实名,跳过激活",
|
||||||
|
zap.String("carrier_type", carrierType),
|
||||||
|
zap.Uint("carrier_id", carrierID))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 更新实名状态
|
||||||
|
if err := s.updateRealnameStatus(ctx, carrierType, carrierID, 1); err != nil {
|
||||||
|
return errors.Wrap(errors.CodeInternalError, err, "更新实名状态失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 入队激活任务
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"carrier_type": carrierType,
|
||||||
|
"carrier_id": carrierID,
|
||||||
|
"realname_at": time.Now().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.asynqClient.Enqueue("realname_activation", payload); err != nil {
|
||||||
|
return errors.Wrap(errors.CodeInternalError, err, "入队激活任务失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("首次实名成功,已入队激活任务",
|
||||||
|
zap.String("carrier_type", carrierType),
|
||||||
|
zap.Uint("carrier_id", carrierID))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service 层:checkFirstRealname
|
||||||
|
func (s *Service) checkFirstRealname(ctx context.Context, carrierType string, carrierID uint) (bool, error) {
|
||||||
|
if carrierType == "device" {
|
||||||
|
// 查询设备当前实名状态
|
||||||
|
device, err := s.deviceStore.GetByID(ctx, carrierID)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return device.RealnameStatus == 0, nil // 0=未实名,首次实名
|
||||||
|
} else if carrierType == "iot_card" {
|
||||||
|
// 查询卡当前实名状态
|
||||||
|
card, err := s.iotCardStore.GetByICCID(ctx, carrierID)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return card.RealnameStatus == 0, nil
|
||||||
|
}
|
||||||
|
return false, fmt.Errorf("unsupported carrier_type: %s", carrierType)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Asynq Worker 处理激活任务
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Handler: HandleRealnameActivation
|
||||||
|
func (h *RealnameActivationHandler) HandleRealnameActivation(ctx context.Context, task *asynq.Task) error {
|
||||||
|
var payload struct {
|
||||||
|
CarrierType string `json:"carrier_type"`
|
||||||
|
CarrierID uint `json:"carrier_id"`
|
||||||
|
RealnameAt string `json:"realname_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(task.Payload(), &payload); err != nil {
|
||||||
|
return fmt.Errorf("unmarshal payload failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
realnameAt, _ := time.Parse(time.RFC3339, payload.RealnameAt)
|
||||||
|
|
||||||
|
// 1. 查询待激活套餐
|
||||||
|
usages, err := h.packageUsageStore.ListPendingRealnameActivation(ctx, payload.CarrierType, payload.CarrierID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("list pending activation failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(usages) == 0 {
|
||||||
|
h.logger.Info("无待激活套餐,任务完成",
|
||||||
|
zap.String("carrier_type", payload.CarrierType),
|
||||||
|
zap.Uint("carrier_id", payload.CarrierID))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 批量激活(使用事务)
|
||||||
|
tx := h.db.Begin()
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
for _, usage := range usages {
|
||||||
|
// 获取套餐配置
|
||||||
|
pkg, err := h.packageStore.GetByID(ctx, usage.PackageID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get package failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算有效期
|
||||||
|
expiresAt := h.calculateExpiresAt(realnameAt, pkg.CalendarType, pkg.DurationDays, pkg.DurationMonths)
|
||||||
|
|
||||||
|
// 更新套餐状态
|
||||||
|
if err := tx.Model(&usage).Updates(map[string]interface{}{
|
||||||
|
"status": constants.PackageStatusActive,
|
||||||
|
"activated_at": realnameAt,
|
||||||
|
"expires_at": expiresAt,
|
||||||
|
"pending_realname_activation": false,
|
||||||
|
}).Error; err != nil {
|
||||||
|
return fmt.Errorf("activate package failed: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit().Error; err != nil {
|
||||||
|
return fmt.Errorf("commit transaction failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h.logger.Info("套餐激活成功",
|
||||||
|
zap.String("carrier_type", payload.CarrierType),
|
||||||
|
zap.Uint("carrier_id", payload.CarrierID),
|
||||||
|
zap.Int("count", len(usages)))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**本 Spec 完成**,包含:
|
||||||
|
- ✅ 业务背景和业务规则
|
||||||
|
- ✅ 详细场景(购买套餐、首次实名、激活逻辑、异步任务)
|
||||||
|
- ✅ 边界条件和并发场景
|
||||||
|
- ✅ 异常处理和数据一致性保证
|
||||||
|
- ✅ 性能指标和错误码定义
|
||||||
|
- ✅ **激进的数据迁移策略**(明确删除字段、废弃逻辑、强制转换)
|
||||||
|
- ✅ 测试场景矩阵和实现参考
|
||||||
367
openspec/specs/package-usage-customer-view/spec.md
Normal file
367
openspec/specs/package-usage-customer-view/spec.md
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
# Spec: 客户视图流量查询
|
||||||
|
|
||||||
|
## 业务背景
|
||||||
|
|
||||||
|
### 为什么需要客户视图流量查询
|
||||||
|
|
||||||
|
**现状问题**:
|
||||||
|
- 客户无法清晰看到主套餐和加油包的分别使用情况
|
||||||
|
- 流量汇总不准确(包含已失效加油包)
|
||||||
|
- 客户端需要多次调用 API 才能获取完整流量信息
|
||||||
|
|
||||||
|
**业务目标**:
|
||||||
|
- 提供统一的流量查询 API
|
||||||
|
- 区分主套餐和加油包流量
|
||||||
|
- 自动汇总总计流量
|
||||||
|
- 仅显示当前有效套餐
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 业务规则
|
||||||
|
|
||||||
|
### 1. 流量汇总规则
|
||||||
|
|
||||||
|
```
|
||||||
|
总计流量 = 主套餐流量 + 所有生效中/已用完加油包流量
|
||||||
|
|
||||||
|
包含的套餐:
|
||||||
|
- status=1(生效中)
|
||||||
|
- status=2(已用完但未过期)
|
||||||
|
|
||||||
|
不包含的套餐:
|
||||||
|
- status=0(待生效)
|
||||||
|
- status=3(已过期)
|
||||||
|
- status=4(已失效)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 主套餐优先显示
|
||||||
|
|
||||||
|
- **规则**:如果有多个主套餐(理论上只有1个生效中),优先显示 status=1 的主套餐
|
||||||
|
- **待生效主套餐**:不在客户视图中显示
|
||||||
|
|
||||||
|
### 3. 加油包按优先级排序
|
||||||
|
|
||||||
|
- **排序规则**:按 priority ASC 排序(优先扣减的加油包排在前面)
|
||||||
|
- **失效加油包**:不在客户视图中显示
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: 提供客户视图流量查询 API
|
||||||
|
|
||||||
|
系统 SHALL 提供 GET /api/h5/packages/my-usage API,返回客户的套餐流量使用情况。
|
||||||
|
|
||||||
|
#### Scenario: 查询单个主套餐流量
|
||||||
|
- **GIVEN** 客户有1个主套餐(已用 8GB,总量 10GB),无加油包
|
||||||
|
- **WHEN** 客户调用 GET /api/h5/packages/my-usage
|
||||||
|
- **THEN** 系统返回:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"data": {
|
||||||
|
"main_package": {
|
||||||
|
"package_id": 123,
|
||||||
|
"package_name": "月度套餐10GB",
|
||||||
|
"used_mb": 8192,
|
||||||
|
"total_mb": 10240,
|
||||||
|
"status": 1,
|
||||||
|
"status_text": "生效中",
|
||||||
|
"expires_at": "2026-02-28T23:59:59Z"
|
||||||
|
},
|
||||||
|
"addon_packages": [],
|
||||||
|
"total": {
|
||||||
|
"used_mb": 8192,
|
||||||
|
"total_mb": 10240
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Scenario: 查询主套餐和加油包流量
|
||||||
|
- **GIVEN** 客户有:
|
||||||
|
- 主套餐:已用 9GB,总量 10GB
|
||||||
|
- 加油包1(priority=1):已用 3GB,总量 5GB
|
||||||
|
- 加油包2(priority=2):已用 1GB,总量 3GB
|
||||||
|
- **WHEN** 客户调用 GET /api/h5/packages/my-usage
|
||||||
|
- **THEN** 系统返回 main_package, addon_packages(2个加油包,按 priority 排序), total: {used: 13GB, total: 18GB}
|
||||||
|
|
||||||
|
#### Scenario: 主套餐用完但加油包有剩余
|
||||||
|
- **GIVEN** 客户主套餐已用 10GB/总量 10GB(status=2),加油包已用 2GB/总量 5GB(status=1)
|
||||||
|
- **WHEN** 客户调用 API
|
||||||
|
- **THEN** 系统返回:
|
||||||
|
- main_package: status=2, status_text="已用完"
|
||||||
|
- addon_packages: status=1, status_text="生效中"
|
||||||
|
- total: {used: 12GB, total: 15GB}
|
||||||
|
|
||||||
|
### Requirement: 客户视图区分主套餐和加油包
|
||||||
|
|
||||||
|
系统 SHALL 在响应中明确区分主套餐(main_package)和加油包(addon_packages)的流量信息。
|
||||||
|
|
||||||
|
#### Scenario: 响应包含主套餐信息
|
||||||
|
- **WHEN** 客户查询流量使用情况
|
||||||
|
- **THEN** 响应的 main_package 字段包含:
|
||||||
|
- package_id, package_name
|
||||||
|
- used_mb, total_mb
|
||||||
|
- status, status_text
|
||||||
|
- expires_at, activated_at
|
||||||
|
|
||||||
|
#### Scenario: 响应包含加油包列表
|
||||||
|
- **GIVEN** 客户有3个加油包
|
||||||
|
- **WHEN** 客户查询
|
||||||
|
- **THEN** 响应的 addon_packages 字段为数组,按 priority 排序,每个元素包含:
|
||||||
|
- package_id, package_name
|
||||||
|
- used_mb, total_mb
|
||||||
|
- status, status_text
|
||||||
|
- expires_at, activated_at
|
||||||
|
- priority
|
||||||
|
|
||||||
|
### Requirement: 客户视图显示总计流量
|
||||||
|
|
||||||
|
系统 SHALL 在响应中提供 total 字段,汇总主套餐和所有加油包的流量。
|
||||||
|
|
||||||
|
#### Scenario: 总计流量计算正确
|
||||||
|
- **GIVEN** 主套餐 used=8GB/total=10GB,加油包1 used=2GB/total=5GB,加油包2 used=1GB/total=3GB
|
||||||
|
- **WHEN** 计算总计
|
||||||
|
- **THEN** total: {used_mb: 11GB, total_mb: 18GB}
|
||||||
|
|
||||||
|
#### Scenario: 已失效加油包不计入总计
|
||||||
|
- **GIVEN** 主套餐 used=8GB/total=10GB,加油包 status=4(已失效)used=2GB/total=5GB
|
||||||
|
- **WHEN** 计算总计
|
||||||
|
- **THEN** total: {used_mb: 8GB, total_mb: 10GB}(不包含已失效加油包)
|
||||||
|
|
||||||
|
#### Scenario: 已用完套餐计入总计
|
||||||
|
- **GIVEN** 主套餐 status=2(已用完)used=10GB/total=10GB,加油包 status=1 used=2GB/total=5GB
|
||||||
|
- **WHEN** 计算总计
|
||||||
|
- **THEN** total: {used_mb: 12GB, total_mb: 15GB}(已用完套餐仍计入)
|
||||||
|
|
||||||
|
### Requirement: 客户视图仅返回当前生效套餐
|
||||||
|
|
||||||
|
系统 SHALL 仅返回 status=1(生效中)或 status=2(已用完但未过期)的套餐信息。
|
||||||
|
|
||||||
|
#### Scenario: 不返回待生效套餐
|
||||||
|
- **GIVEN** 客户有1个生效中主套餐(status=1)和1个待生效主套餐(status=0)
|
||||||
|
- **WHEN** 客户查询
|
||||||
|
- **THEN** 响应仅包含生效中的主套餐,不包含待生效套餐
|
||||||
|
|
||||||
|
#### Scenario: 不返回已过期套餐
|
||||||
|
- **GIVEN** 客户的主套餐已过期(status=3)
|
||||||
|
- **WHEN** 客户查询
|
||||||
|
- **THEN** 响应 main_package=null,提示"无有效套餐"
|
||||||
|
|
||||||
|
#### Scenario: 不返回已失效加油包
|
||||||
|
- **GIVEN** 客户有生效中主套餐和1个已失效加油包(status=4)
|
||||||
|
- **WHEN** 客户查询
|
||||||
|
- **THEN** 响应 addon_packages 不包含已失效加油包
|
||||||
|
|
||||||
|
### Requirement: 客户视图性能要求
|
||||||
|
|
||||||
|
系统 SHALL 确保客户视图 API 响应时间 P95 < 200ms。
|
||||||
|
|
||||||
|
#### Scenario: 查询性能达标
|
||||||
|
- **GIVEN** 客户有1个主套餐和5个加油包
|
||||||
|
- **WHEN** 客户调用 API
|
||||||
|
- **THEN** API 响应时间 < 200ms(P95)
|
||||||
|
|
||||||
|
#### Scenario: 使用索引优化查询
|
||||||
|
- **GIVEN** 系统有索引 idx_carrier_status(carrier_id + status)
|
||||||
|
- **WHEN** 查询套餐时
|
||||||
|
- **THEN** 数据库使用索引,查询时间 < 50ms
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 边界条件
|
||||||
|
|
||||||
|
### 1. 无任何套餐
|
||||||
|
|
||||||
|
- **场景**:客户没有购买任何套餐
|
||||||
|
- **处理**:返回 main_package=null, addon_packages=[], total={used_mb:0, total_mb:0}
|
||||||
|
|
||||||
|
### 2. 主套餐过期但加油包未过期
|
||||||
|
|
||||||
|
- **场景**:主套餐过期,加油包有独立有效期且未过期
|
||||||
|
- **处理**:主套餐过期时,加油包被级联失效(status=4),不显示在客户视图
|
||||||
|
|
||||||
|
### 3. 并发查询
|
||||||
|
|
||||||
|
- **场景**:客户短时间内多次调用查询 API
|
||||||
|
- **处理**:使用只读事务,确保数据一致性
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 数据一致性保证
|
||||||
|
|
||||||
|
### 1. 只读事务
|
||||||
|
|
||||||
|
- **查询套餐**:使用只读事务,确保数据一致性
|
||||||
|
|
||||||
|
### 2. 索引优化
|
||||||
|
|
||||||
|
- **必需索引**:
|
||||||
|
- `idx_carrier_status`(carrier_id + status)
|
||||||
|
- `idx_package_type_priority`(package_type + priority)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 性能指标
|
||||||
|
|
||||||
|
| 操作 | 目标响应时间 | 并发要求 | 数据量 |
|
||||||
|
|------|-------------|---------|--------|
|
||||||
|
| 客户视图查询 | < 200ms (P95) | 500 QPS | 单载体查询(1主套餐+5加油包) |
|
||||||
|
| 数据库查询 | < 50ms | 1000 QPS | 索引查询 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 错误码定义
|
||||||
|
|
||||||
|
| 错误码 | HTTP 状态码 | 错误消息 | 场景 |
|
||||||
|
|--------|------------|---------|------|
|
||||||
|
| `NO_VALID_PACKAGE` | 404 | 无有效套餐 | 客户无任何生效中套餐 |
|
||||||
|
| `CARRIER_NOT_FOUND` | 404 | 载体不存在 | 载体ID不存在 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 数据迁移策略
|
||||||
|
|
||||||
|
**激进策略**(开发阶段,保证干净性):
|
||||||
|
|
||||||
|
### 1. ❌ 要删除的字段
|
||||||
|
|
||||||
|
无(新增 API,不涉及数据迁移)
|
||||||
|
|
||||||
|
### 2. ✅ 新增的字段
|
||||||
|
|
||||||
|
无(使用现有字段)
|
||||||
|
|
||||||
|
### 3. ❌ 要废弃的逻辑
|
||||||
|
|
||||||
|
- **废弃旧的客户端流量查询 API**:如果存在旧的流量查询接口,统一替换为新接口
|
||||||
|
|
||||||
|
### 4. ✅ 索引优化
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 确保必需索引存在
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_carrier_status
|
||||||
|
ON package_usage(carrier_id, status);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_package_type_priority
|
||||||
|
ON package_usage(package_type, priority);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 测试场景矩阵
|
||||||
|
|
||||||
|
| 场景分类 | 测试用例 | 预期结果 |
|
||||||
|
|---------|---------|---------|
|
||||||
|
| **单个主套餐** | 查询单个主套餐流量 | 返回 main_package, addon_packages=[], total |
|
||||||
|
| **主套餐+加油包** | 查询主套餐和加油包 | 返回 main_package, addon_packages(按 priority 排序), total |
|
||||||
|
| **总计流量** | 总计流量计算正确 | total = 主套餐 + 所有加油包 |
|
||||||
|
| | 已失效加油包不计入总计 | 不包含 status=4 的加油包 |
|
||||||
|
| | 已用完套餐计入总计 | 包含 status=2 的套餐 |
|
||||||
|
| **筛选套餐** | 不返回待生效套餐 | 仅返回 status IN (1,2) |
|
||||||
|
| | 不返回已过期套餐 | main_package=null |
|
||||||
|
| | 不返回已失效加油包 | addon_packages 不含 status=4 |
|
||||||
|
| **性能** | 查询性能达标 | 响应时间 < 200ms (P95) |
|
||||||
|
| | 使用索引优化 | 数据库查询 < 50ms |
|
||||||
|
| **边界** | 无任何套餐 | main_package=null, addon_packages=[], total={0,0} |
|
||||||
|
| | 主套餐过期加油包未过期 | 加油包被级联失效,不显示 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 实现参考
|
||||||
|
|
||||||
|
### Handler: GetMyUsage
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Handler: GetMyUsage
|
||||||
|
func (h *Handler) GetMyUsage(c *fiber.Ctx) error {
|
||||||
|
// 从上下文获取载体信息
|
||||||
|
carrierType := middleware.GetCarrierTypeFromContext(c.UserContext())
|
||||||
|
carrierID := middleware.GetCarrierIDFromContext(c.UserContext())
|
||||||
|
|
||||||
|
// 查询流量使用情况
|
||||||
|
usage, err := h.service.GetMyUsage(c.UserContext(), carrierType, carrierID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, usage)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service 层:GetMyUsage
|
||||||
|
func (s *Service) GetMyUsage(ctx context.Context, carrierType string, carrierID uint) (*dto.MyUsageResponse, error) {
|
||||||
|
// 查询生效中或已用完的套餐
|
||||||
|
usages, err := s.store.ListActiveUsages(ctx, carrierType, carrierID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(errors.CodeInternalError, err, "查询套餐失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分类套餐
|
||||||
|
var mainPackage *model.PackageUsage
|
||||||
|
var addonPackages []*model.PackageUsage
|
||||||
|
|
||||||
|
for _, usage := range usages {
|
||||||
|
if usage.PackageType == constants.PackageTypeFormal {
|
||||||
|
if mainPackage == nil || usage.Status == constants.PackageStatusActive {
|
||||||
|
mainPackage = usage // 优先选择生效中的主套餐
|
||||||
|
}
|
||||||
|
} else if usage.PackageType == constants.PackageTypeAddon {
|
||||||
|
addonPackages = append(addonPackages, usage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按优先级排序加油包
|
||||||
|
sort.Slice(addonPackages, func(i, j int) bool {
|
||||||
|
return addonPackages[i].Priority < addonPackages[j].Priority
|
||||||
|
})
|
||||||
|
|
||||||
|
// 构造响应
|
||||||
|
resp := &dto.MyUsageResponse{
|
||||||
|
Total: &dto.TotalUsage{
|
||||||
|
UsedMB: 0,
|
||||||
|
TotalMB: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// 主套餐
|
||||||
|
if mainPackage != nil {
|
||||||
|
resp.MainPackage = s.toPackageUsageVO(mainPackage)
|
||||||
|
resp.Total.UsedMB += mainPackage.DataUsageMB
|
||||||
|
resp.Total.TotalMB += mainPackage.TotalDataMB
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加油包
|
||||||
|
for _, addon := range addonPackages {
|
||||||
|
resp.AddonPackages = append(resp.AddonPackages, s.toPackageUsageVO(addon))
|
||||||
|
resp.Total.UsedMB += addon.DataUsageMB
|
||||||
|
resp.Total.TotalMB += addon.TotalDataMB
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store 层:ListActiveUsages
|
||||||
|
func (s *Store) ListActiveUsages(ctx context.Context, carrierType string, carrierID uint) ([]*model.PackageUsage, error) {
|
||||||
|
var usages []*model.PackageUsage
|
||||||
|
err := s.db.WithContext(ctx).
|
||||||
|
Where("carrier_type = ? AND carrier_id = ? AND status IN (?, ?)",
|
||||||
|
carrierType, carrierID,
|
||||||
|
constants.PackageStatusActive,
|
||||||
|
constants.PackageStatusUsedUp).
|
||||||
|
Order("package_type ASC, priority ASC").
|
||||||
|
Find(&usages).Error
|
||||||
|
return usages, err
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**本 Spec 完成**(简化版),包含:
|
||||||
|
- ✅ 业务背景和业务规则
|
||||||
|
- ✅ 详细场景(主套餐、加油包、总计流量)
|
||||||
|
- ✅ 边界条件
|
||||||
|
- ✅ 数据一致性保证和性能指标
|
||||||
|
- ✅ 错误码定义
|
||||||
|
- ✅ **激进的数据迁移策略**(索引优化)
|
||||||
|
- ✅ 测试场景矩阵和实现参考
|
||||||
465
openspec/specs/package-usage-daily-record/spec.md
Normal file
465
openspec/specs/package-usage-daily-record/spec.md
Normal file
@@ -0,0 +1,465 @@
|
|||||||
|
# Spec: 套餐流量日记录
|
||||||
|
|
||||||
|
## 业务背景
|
||||||
|
|
||||||
|
### 为什么需要流量日记录
|
||||||
|
|
||||||
|
**现状问题**:
|
||||||
|
- 用户需要查看每日流量使用明细(哪天用了多少流量)
|
||||||
|
- 套餐流量重置后,历史使用数据丢失
|
||||||
|
- 无法统计和分析用户流量使用趋势
|
||||||
|
- 计费对账需要每日流量记录
|
||||||
|
|
||||||
|
**业务目标**:
|
||||||
|
- 按套餐维度记录每日流量增量
|
||||||
|
- 支持按日期范围查询流量详单
|
||||||
|
- 流量重置后历史记录仍可查询
|
||||||
|
- 为计费对账和数据分析提供基础数据
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 业务规则
|
||||||
|
|
||||||
|
### 1. 日记录写入规则
|
||||||
|
|
||||||
|
每次流量扣减后,写入或更新当日记录:
|
||||||
|
|
||||||
|
```
|
||||||
|
写入流量日记录:
|
||||||
|
1. 获取当前日期(date=today)
|
||||||
|
2. 查询是否已有今日记录:
|
||||||
|
SELECT * FROM package_usage_daily_record
|
||||||
|
WHERE package_usage_id=? AND date=today
|
||||||
|
3. 如果存在 → UPDATE daily_usage_mb += increment
|
||||||
|
4. 如果不存在 → INSERT (package_usage_id, date, daily_usage_mb, cumulative_usage_mb)
|
||||||
|
5. 使用 UPSERT(ON CONFLICT UPDATE)确保幂等性
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 流量增量计算
|
||||||
|
|
||||||
|
```
|
||||||
|
每日流量增量 = 今日上游返回的累计流量 - 昨日记录的累计流量
|
||||||
|
|
||||||
|
特殊情况:
|
||||||
|
- 如果昨日无记录 → 增量 = 今日上游累计流量
|
||||||
|
- 如果上游重置(今日累计 < 昨日累计)→ 增量 = 今日上游累计流量
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. cumulative_usage_mb 字段
|
||||||
|
|
||||||
|
- **定义**:截止到当日的累计流量
|
||||||
|
- **计算规则**:cumulative_usage_mb = 昨日 cumulative_usage_mb + 今日 daily_usage_mb
|
||||||
|
- **首日规则**:首日 cumulative_usage_mb = daily_usage_mb
|
||||||
|
|
||||||
|
### 4. 数据保留策略
|
||||||
|
|
||||||
|
- **保留期限**:永久保留(或根据业务需求保留1年/2年)
|
||||||
|
- **流量重置不删除**:套餐流量重置后,日记录仍保留
|
||||||
|
- **套餐过期不删除**:套餐过期后,日记录仍保留
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: 按套餐维度记录每日流量
|
||||||
|
|
||||||
|
系统 SHALL 为每个 PackageUsage 创建每日流量记录(PackageUsageDailyRecord),记录每天的流量增量。
|
||||||
|
|
||||||
|
#### Scenario: 首次记录当日流量
|
||||||
|
- **GIVEN** 套餐 ID=123 在 2026-02-10 首次产生流量 1.5GB
|
||||||
|
- **WHEN** 流量扣减完成
|
||||||
|
- **THEN** 系统创建 PackageUsageDailyRecord:
|
||||||
|
- package_usage_id=123
|
||||||
|
- date=2026-02-10
|
||||||
|
- daily_usage_mb=1536 (1.5GB)
|
||||||
|
- cumulative_usage_mb=1536
|
||||||
|
|
||||||
|
#### Scenario: 同一天多次流量更新
|
||||||
|
- **GIVEN** 套餐在 2026-02-10 已记录 1GB 流量
|
||||||
|
- **WHEN** 再产生 0.5GB 流量
|
||||||
|
- **THEN** 系统更新 PackageUsageDailyRecord:
|
||||||
|
- daily_usage_mb=1536(1GB+0.5GB)
|
||||||
|
- cumulative_usage_mb=1536
|
||||||
|
|
||||||
|
#### Scenario: 跨天流量记录
|
||||||
|
- **GIVEN** 套餐在 2026-02-10 使用 2GB
|
||||||
|
- **AND** 2026-02-11 使用 3GB
|
||||||
|
- **WHEN** 流量扣减完成
|
||||||
|
- **THEN** 系统创建两条记录:
|
||||||
|
- 2月10日:daily_usage_mb=2GB, cumulative_usage_mb=2GB
|
||||||
|
- 2月11日:daily_usage_mb=3GB, cumulative_usage_mb=5GB
|
||||||
|
|
||||||
|
#### Scenario: 流量重置后日记录仍保留
|
||||||
|
- **GIVEN** 套餐在 2月1日至2月28日有28条日记录
|
||||||
|
- **WHEN** 3月1日 00:00:00 触发流量重置
|
||||||
|
- **THEN** 套餐 data_usage_mb 重置为 0
|
||||||
|
- **AND** 2月的28条日记录仍存在且可查询
|
||||||
|
|
||||||
|
### Requirement: 流量增量基于上游查询计算
|
||||||
|
|
||||||
|
系统 SHALL 根据上游返回的累计流量,减去昨日记录的累计流量,计算每日增量。
|
||||||
|
|
||||||
|
#### Scenario: 计算每日流量增量
|
||||||
|
- **GIVEN** 昨日(2月9日)记录 cumulative_usage_mb=10GB
|
||||||
|
- **WHEN** 今日(2月10日)上游返回 cumulative=13GB
|
||||||
|
- **THEN** 今日 daily_usage_mb=3GB(13GB - 10GB)
|
||||||
|
- **AND** 今日 cumulative_usage_mb=13GB
|
||||||
|
|
||||||
|
#### Scenario: 上游周期重置后流量计算
|
||||||
|
- **GIVEN** 联通卡在 2月27日 00:00:00 上游重置
|
||||||
|
- **AND** 昨日(2月26日)记录 cumulative_usage_mb=15GB
|
||||||
|
- **WHEN** 今日(2月27日)上游返回 cumulative=2GB
|
||||||
|
- **THEN** 今日 daily_usage_mb=2GB(上游重置,取新增量)
|
||||||
|
- **AND** 今日 cumulative_usage_mb=2GB
|
||||||
|
|
||||||
|
#### Scenario: 首日无昨日记录
|
||||||
|
- **GIVEN** 套餐首次激活,无任何日记录
|
||||||
|
- **WHEN** 上游返回 cumulative=5GB
|
||||||
|
- **THEN** 今日 daily_usage_mb=5GB
|
||||||
|
- **AND** 今日 cumulative_usage_mb=5GB
|
||||||
|
|
||||||
|
### Requirement: 支持按日期查询套餐流量详单
|
||||||
|
|
||||||
|
系统 SHALL 提供 API 查询指定套餐的每日流量记录。
|
||||||
|
|
||||||
|
#### Scenario: 查询套餐流量详单
|
||||||
|
- **WHEN** 用户通过 GET /api/admin/package-usage/:id/daily-records 查询套餐流量详单
|
||||||
|
- **THEN** 系统返回按日期排序的流量记录列表:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"date": "2026-02-01",
|
||||||
|
"daily_usage_mb": 1024,
|
||||||
|
"cumulative_usage_mb": 1024
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-02-02",
|
||||||
|
"daily_usage_mb": 2048,
|
||||||
|
"cumulative_usage_mb": 3072
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Scenario: 查询指定日期范围
|
||||||
|
- **GIVEN** 套餐有 2月1日 至 2月28日 的流量记录
|
||||||
|
- **WHEN** 用户查询流量详单,参数 start_date=2026-02-01, end_date=2026-02-10
|
||||||
|
- **THEN** 系统返回 2月1日 至 2月10日 的流量记录(10条)
|
||||||
|
|
||||||
|
#### Scenario: 客户端查询自己的流量详单
|
||||||
|
- **WHEN** 客户通过 GET /api/customer/package-usage/:id/daily-records 查询
|
||||||
|
- **THEN** 系统校验套餐归属后,返回流量记录列表
|
||||||
|
|
||||||
|
### Requirement: 日记录索引优化
|
||||||
|
|
||||||
|
系统 SHALL 在 PackageUsageDailyRecord 表创建 (package_usage_id, date) 联合唯一索引。
|
||||||
|
|
||||||
|
#### Scenario: 同一套餐同一天只有一条记录
|
||||||
|
- **WHEN** 系统尝试为同一 package_usage_id=123 和 date=2026-02-10 创建第二条记录
|
||||||
|
- **THEN** 数据库返回唯一约束冲突错误
|
||||||
|
- **AND** 使用 UPSERT 自动转为 UPDATE 操作
|
||||||
|
|
||||||
|
#### Scenario: 查询性能达标
|
||||||
|
- **GIVEN** 套餐 ID=123 有 365 条日记录(一年数据)
|
||||||
|
- **WHEN** 查询全部流量详单
|
||||||
|
- **THEN** 查询响应时间 < 50ms
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 边界条件
|
||||||
|
|
||||||
|
### 1. 套餐过期后的日记录
|
||||||
|
|
||||||
|
- **场景**:套餐在 2月28日过期,3月1日仍可查询历史日记录
|
||||||
|
- **处理**:日记录永久保留,不随套餐过期删除
|
||||||
|
|
||||||
|
### 2. 并发写入同一天记录
|
||||||
|
|
||||||
|
- **场景**:同一套餐在同一天有多个并发流量扣减请求
|
||||||
|
- **处理**:使用 UPSERT(ON CONFLICT UPDATE)确保幂等性
|
||||||
|
|
||||||
|
### 3. 跨月查询日记录
|
||||||
|
|
||||||
|
- **场景**:查询 1月15日 至 2月15日 的日记录(跨月)
|
||||||
|
- **处理**:按日期范围查询,返回跨月数据
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 并发场景
|
||||||
|
|
||||||
|
### Scenario: 并发写入同一天记录
|
||||||
|
- **GIVEN** 套餐 ID=123 在 2026-02-10 10:00:00 和 10:00:01 同时扣减流量
|
||||||
|
- **WHEN** 两个请求同时写入日记录
|
||||||
|
- **THEN** 使用 UPSERT(ON CONFLICT UPDATE):
|
||||||
|
```sql
|
||||||
|
INSERT INTO package_usage_daily_record (package_usage_id, date, daily_usage_mb, cumulative_usage_mb)
|
||||||
|
VALUES (123, '2026-02-10', 1024, 1024)
|
||||||
|
ON CONFLICT (package_usage_id, date)
|
||||||
|
DO UPDATE SET
|
||||||
|
daily_usage_mb = package_usage_daily_record.daily_usage_mb + EXCLUDED.daily_usage_mb,
|
||||||
|
cumulative_usage_mb = package_usage_daily_record.cumulative_usage_mb + EXCLUDED.daily_usage_mb;
|
||||||
|
```
|
||||||
|
- **AND** 两个请求的流量累加到同一条记录
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 异常处理
|
||||||
|
|
||||||
|
### 1. 日记录写入失败
|
||||||
|
|
||||||
|
- **错误场景**:流量扣减成功,但日记录写入失败(数据库连接断开)
|
||||||
|
- **处理流程**:
|
||||||
|
1. 不回滚流量扣减(已提交)
|
||||||
|
2. 记录 Error 日志(包含套餐ID、日期、流量增量)
|
||||||
|
3. 通过定时任务补录日记录
|
||||||
|
- **返回错误**:不影响用户,日记录补录在后台进行
|
||||||
|
|
||||||
|
### 2. 查询日记录超时
|
||||||
|
|
||||||
|
- **错误场景**:查询大量日记录时超时(如查询3年数据)
|
||||||
|
- **处理流程**:
|
||||||
|
1. 限制单次查询最多返回 365 条记录
|
||||||
|
2. 如果超过限制,返回错误 400:"查询日期范围过大,最多查询1年"
|
||||||
|
- **返回错误**:`{"code": "DATE_RANGE_TOO_LARGE", "msg": "查询日期范围过大,最多查询1年"}`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 数据一致性保证
|
||||||
|
|
||||||
|
### 1. 事务边界
|
||||||
|
|
||||||
|
- **流量扣减 + 写入日记录**:使用单个事务(可选,根据业务需求)
|
||||||
|
- **查询日记录**:使用只读事务
|
||||||
|
|
||||||
|
### 2. 唯一索引
|
||||||
|
|
||||||
|
- **联合唯一索引**:`UNIQUE INDEX idx_package_usage_daily_record (package_usage_id, date)`
|
||||||
|
- **确保同一套餐同一天只有一条记录**
|
||||||
|
|
||||||
|
### 3. UPSERT 幂等性
|
||||||
|
|
||||||
|
- **使用 ON CONFLICT UPDATE**:确保并发写入时累加流量而非覆盖
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 性能指标
|
||||||
|
|
||||||
|
| 操作 | 目标响应时间 | 并发要求 | 数据量 |
|
||||||
|
|------|-------------|---------|--------|
|
||||||
|
| 写入日记录(UPSERT) | < 10ms | 1000 QPS | 单条插入/更新 |
|
||||||
|
| 查询日记录(单套餐) | < 50ms | 100 QPS | 查询365条记录 |
|
||||||
|
| 查询日记录(日期范围) | < 100ms | 100 QPS | 查询指定范围 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 错误码定义
|
||||||
|
|
||||||
|
| 错误码 | HTTP 状态码 | 错误消息 | 场景 |
|
||||||
|
|--------|------------|---------|------|
|
||||||
|
| `DATE_RANGE_TOO_LARGE` | 400 | 查询日期范围过大,最多查询1年 | 查询日记录日期范围超过365天 |
|
||||||
|
| `DAILY_RECORD_NOT_FOUND` | 404 | 未找到流量记录 | 查询不存在的日记录 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 数据迁移策略
|
||||||
|
|
||||||
|
**激进策略**(开发阶段,保证干净性):
|
||||||
|
|
||||||
|
### 1. ❌ 要删除的字段
|
||||||
|
|
||||||
|
目前 `package_usage_daily_record` 表中可能存在的冗余字段(需确认后删除):
|
||||||
|
- 如果有 `daily_increment` 字段(旧的增量字段) → **删除**,统一使用 `daily_usage_mb`
|
||||||
|
- 如果有 `total_usage` 字段(旧的累计字段) → **删除**,统一使用 `cumulative_usage_mb`
|
||||||
|
|
||||||
|
### 2. ✅ 新增的字段
|
||||||
|
|
||||||
|
在 `package_usage_daily_record` 表中确保有以下字段:
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS package_usage_daily_record (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
package_usage_id BIGINT NOT NULL COMMENT '套餐使用记录ID',
|
||||||
|
date DATE NOT NULL COMMENT '日期',
|
||||||
|
daily_usage_mb INT DEFAULT 0 COMMENT '当日流量使用量(MB)',
|
||||||
|
cumulative_usage_mb BIGINT DEFAULT 0 COMMENT '截止当日的累计流量(MB)',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY idx_package_usage_daily_record (package_usage_id, date)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='套餐流量日记录';
|
||||||
|
|
||||||
|
CREATE INDEX idx_date ON package_usage_daily_record(date);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. ❌ 要废弃的逻辑
|
||||||
|
|
||||||
|
- **废弃旧的日记录写入逻辑**:如果代码中存在不使用 UPSERT 的写入逻辑,全部删除
|
||||||
|
- **废弃旧的日记录查询逻辑**:统一使用新的查询接口
|
||||||
|
|
||||||
|
### 4. ✅ 历史数据强制转换
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Step 1: 如果有旧的字段名,重命名
|
||||||
|
-- ALTER TABLE package_usage_daily_record CHANGE daily_increment daily_usage_mb INT;
|
||||||
|
-- ALTER TABLE package_usage_daily_record CHANGE total_usage cumulative_usage_mb BIGINT;
|
||||||
|
|
||||||
|
-- Step 2: 修复 cumulative_usage_mb(如果历史数据不准确)
|
||||||
|
-- 重新计算每个套餐的 cumulative_usage_mb
|
||||||
|
-- (需要按套餐ID分组,按日期排序,累加 daily_usage_mb)
|
||||||
|
|
||||||
|
-- Step 3: 确保唯一索引存在
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_package_usage_daily_record
|
||||||
|
ON package_usage_daily_record(package_usage_id, date);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. ❌ 删除遗留表/字段(确认后执行)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 如果存在旧的日记录表,删除
|
||||||
|
-- DROP TABLE IF EXISTS iot_card_usage_daily;
|
||||||
|
|
||||||
|
-- 如果存在旧的字段,删除
|
||||||
|
-- ALTER TABLE package_usage_daily_record DROP COLUMN IF EXISTS daily_increment;
|
||||||
|
-- ALTER TABLE package_usage_daily_record DROP COLUMN IF EXISTS total_usage;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. 验证步骤
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 验证1:所有日记录都有 daily_usage_mb 和 cumulative_usage_mb
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM package_usage_daily_record
|
||||||
|
WHERE daily_usage_mb IS NULL OR cumulative_usage_mb IS NULL;
|
||||||
|
-- 预期结果:0
|
||||||
|
|
||||||
|
-- 验证2:同一套餐同一天只有一条记录
|
||||||
|
SELECT package_usage_id, date, COUNT(*)
|
||||||
|
FROM package_usage_daily_record
|
||||||
|
GROUP BY package_usage_id, date
|
||||||
|
HAVING COUNT(*) > 1;
|
||||||
|
-- 预期结果:0 rows
|
||||||
|
|
||||||
|
-- 验证3:累计流量单调递增(同一套餐)
|
||||||
|
-- (需要编写复杂查询验证,略)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 测试场景矩阵
|
||||||
|
|
||||||
|
| 场景分类 | 测试用例 | 预期结果 |
|
||||||
|
|---------|---------|---------|
|
||||||
|
| **写入日记录** | 首次记录当日流量 | 创建新记录 |
|
||||||
|
| | 同一天多次流量更新 | 更新已有记录(UPSERT) |
|
||||||
|
| | 跨天流量记录 | 创建多条记录 |
|
||||||
|
| **流量增量计算** | 计算每日流量增量 | daily_usage_mb = 今日累计 - 昨日累计 |
|
||||||
|
| | 上游周期重置后计算 | daily_usage_mb = 今日累计(重置后) |
|
||||||
|
| | 首日无昨日记录 | daily_usage_mb = 今日累计 |
|
||||||
|
| **查询日记录** | 查询套餐流量详单 | 返回按日期排序的记录列表 |
|
||||||
|
| | 查询指定日期范围 | 返回指定范围内的记录 |
|
||||||
|
| | 客户端查询自己的详单 | 校验归属后返回 |
|
||||||
|
| **索引和性能** | 同一套餐同一天只有一条记录 | 唯一约束保证 |
|
||||||
|
| | 查询365条记录 | 响应时间 < 50ms |
|
||||||
|
| **并发** | 并发写入同一天记录 | UPSERT 确保累加 |
|
||||||
|
| **异常** | 日记录写入失败 | 不回滚流量扣减,后台补录 |
|
||||||
|
| | 查询日记录超时 | 限制日期范围,返回错误 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 实现参考
|
||||||
|
|
||||||
|
### 写入日记录(UPSERT)
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Service 层:RecordDailyUsage
|
||||||
|
func (s *Service) RecordDailyUsage(ctx context.Context, usageID uint, date time.Time, dailyUsageMB int, cumulativeUsageMB int64) error {
|
||||||
|
record := &model.PackageUsageDailyRecord{
|
||||||
|
PackageUsageID: usageID,
|
||||||
|
Date: date,
|
||||||
|
DailyUsageMB: dailyUsageMB,
|
||||||
|
CumulativeUsageMB: cumulativeUsageMB,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.store.UpsertDailyRecord(ctx, record); err != nil {
|
||||||
|
return errors.Wrap(errors.CodeInternalError, err, "写入流量日记录失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store 层:UpsertDailyRecord
|
||||||
|
func (s *Store) UpsertDailyRecord(ctx context.Context, record *model.PackageUsageDailyRecord) error {
|
||||||
|
// PostgreSQL UPSERT
|
||||||
|
return s.db.WithContext(ctx).Exec(`
|
||||||
|
INSERT INTO package_usage_daily_record (package_usage_id, date, daily_usage_mb, cumulative_usage_mb, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, NOW(), NOW())
|
||||||
|
ON CONFLICT (package_usage_id, date)
|
||||||
|
DO UPDATE SET
|
||||||
|
daily_usage_mb = package_usage_daily_record.daily_usage_mb + EXCLUDED.daily_usage_mb,
|
||||||
|
cumulative_usage_mb = package_usage_daily_record.cumulative_usage_mb + (EXCLUDED.daily_usage_mb),
|
||||||
|
updated_at = NOW()
|
||||||
|
`, record.PackageUsageID, record.Date, record.DailyUsageMB, record.CumulativeUsageMB).Error
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 查询日记录
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Handler: GetDailyRecords
|
||||||
|
func (h *Handler) GetDailyRecords(c *fiber.Ctx) error {
|
||||||
|
usageID, _ := c.ParamsInt("id")
|
||||||
|
startDate := c.Query("start_date", "")
|
||||||
|
endDate := c.Query("end_date", "")
|
||||||
|
|
||||||
|
// 查询日记录
|
||||||
|
records, err := h.service.GetDailyRecords(c.UserContext(), uint(usageID), startDate, endDate)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, records)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service 层:GetDailyRecords
|
||||||
|
func (s *Service) GetDailyRecords(ctx context.Context, usageID uint, startDate, endDate string) ([]*model.PackageUsageDailyRecord, error) {
|
||||||
|
// 参数校验
|
||||||
|
start, err := time.Parse("2006-01-02", startDate)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New(errors.CodeInvalidParam, "起始日期格式错误")
|
||||||
|
}
|
||||||
|
|
||||||
|
end, err := time.Parse("2006-01-02", endDate)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New(errors.CodeInvalidParam, "结束日期格式错误")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 限制查询范围
|
||||||
|
if end.Sub(start).Hours() > 365*24 {
|
||||||
|
return nil, errors.New(errors.CodeInvalidParam, "查询日期范围过大,最多查询1年")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询日记录
|
||||||
|
return s.store.ListDailyRecords(ctx, usageID, start, end)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store 层:ListDailyRecords
|
||||||
|
func (s *Store) ListDailyRecords(ctx context.Context, usageID uint, startDate, endDate time.Time) ([]*model.PackageUsageDailyRecord, error) {
|
||||||
|
var records []*model.PackageUsageDailyRecord
|
||||||
|
err := s.db.WithContext(ctx).
|
||||||
|
Where("package_usage_id = ? AND date >= ? AND date <= ?", usageID, startDate, endDate).
|
||||||
|
Order("date ASC").
|
||||||
|
Find(&records).Error
|
||||||
|
return records, err
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**本 Spec 完成**,包含:
|
||||||
|
- ✅ 业务背景和业务规则
|
||||||
|
- ✅ 详细场景(写入、查询、增量计算)
|
||||||
|
- ✅ 边界条件和并发场景
|
||||||
|
- ✅ 异常处理和数据一致性保证
|
||||||
|
- ✅ 性能指标和错误码定义
|
||||||
|
- ✅ **激进的数据迁移策略**(明确删除字段、废弃逻辑、强制转换)
|
||||||
|
- ✅ 测试场景矩阵和实现参考
|
||||||
420
openspec/specs/package-usage-priority/spec.md
Normal file
420
openspec/specs/package-usage-priority/spec.md
Normal file
@@ -0,0 +1,420 @@
|
|||||||
|
# Spec: 流量扣减优先级机制
|
||||||
|
|
||||||
|
## 业务背景
|
||||||
|
|
||||||
|
现有套餐系统在流量扣减时不区分主套餐和加油包,导致:
|
||||||
|
1. **用户体验差**:用户购买加油包后,主套餐仍在扣减,加油包未生效
|
||||||
|
2. **停机逻辑错误**:主套餐流量用完即停机,加油包剩余流量浪费
|
||||||
|
3. **流量统计混乱**:多套餐同时扣减,无法追溯流量消耗路径
|
||||||
|
|
||||||
|
本规范引入流量扣减优先级机制,确保:
|
||||||
|
- **加油包优先扣减**:购买加油包后,优先消耗加油包流量
|
||||||
|
- **主套餐兜底**:加油包用完后,再扣减主套餐流量
|
||||||
|
- **全部用完停机**:主套餐 + 所有加油包流量都用完才停机
|
||||||
|
|
||||||
|
## 业务规则
|
||||||
|
|
||||||
|
### 扣减优先级规则(多维度排序)
|
||||||
|
```
|
||||||
|
优先级(从高到低):
|
||||||
|
1. 加油包(按 priority ASC, expires_at ASC, activated_at ASC)
|
||||||
|
2. 主套餐
|
||||||
|
```
|
||||||
|
|
||||||
|
**多维度排序规则**(按优先级递减):
|
||||||
|
1. **主键:priority ASC** - 数字越小优先级越高(1 > 2 > 3)
|
||||||
|
2. **次键:expires_at ASC** - 先到期的优先扣减(避免流量浪费)
|
||||||
|
3. **兜底:activated_at ASC** - 先激活的优先扣减(相同到期时间时)
|
||||||
|
|
||||||
|
**SQL 示例**:
|
||||||
|
```sql
|
||||||
|
SELECT * FROM tb_package_usage
|
||||||
|
WHERE card_id = ?
|
||||||
|
AND status = 'active'
|
||||||
|
AND remaining_data_amount > 0
|
||||||
|
ORDER BY
|
||||||
|
priority ASC, -- 加油包(priority=1)在正式套餐(priority=10)前
|
||||||
|
expires_at ASC, -- 同优先级:3天后到期的在7天后到期的前
|
||||||
|
activated_at ASC -- 同到期时间:早激活的在晚激活的前
|
||||||
|
LIMIT 10;
|
||||||
|
```
|
||||||
|
|
||||||
|
**业务意义**:
|
||||||
|
- **先用即将到期的**:避免流量过期浪费
|
||||||
|
- **确定性排序**:相同条件下结果稳定,便于问题排查
|
||||||
|
|
||||||
|
**示例**:
|
||||||
|
```
|
||||||
|
载体有:主套餐(剩余10GB) + 加油包A(priority=1, 剩余5GB) + 加油包B(priority=2, 剩余3GB)
|
||||||
|
产生 12GB 流量:
|
||||||
|
1. 扣减加油包A:5GB → 0GB(用完)
|
||||||
|
2. 扣减加油包B:3GB → 0GB(用完)
|
||||||
|
3. 扣减主套餐:4GB → 6GB(剩余6GB)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 停机条件规则
|
||||||
|
- **旧逻辑**:主套餐流量用完即停机
|
||||||
|
- **新逻辑**:主套餐 + 所有加油包流量都用完才停机
|
||||||
|
|
||||||
|
**判断逻辑**:
|
||||||
|
```sql
|
||||||
|
SELECT COUNT(*) FROM tb_package_usage
|
||||||
|
WHERE (iot_card_id/device_id)=? AND status=1
|
||||||
|
AND data_usage_mb < data_limit_mb;
|
||||||
|
|
||||||
|
-- 如果 COUNT = 0,则触发停机
|
||||||
|
```
|
||||||
|
|
||||||
|
### 流量扣减算法
|
||||||
|
```
|
||||||
|
输入:上游返回的累计流量(upstream_cumulative_mb)
|
||||||
|
输出:更新各套餐的 data_usage_mb
|
||||||
|
|
||||||
|
1. 查询载体当前生效套餐(status=1),按优先级排序:
|
||||||
|
加油包(priority ASC)→ 主套餐
|
||||||
|
2. 计算本次流量增量:
|
||||||
|
increment = upstream_cumulative_mb - 上次记录的累计流量
|
||||||
|
3. 依次扣减:
|
||||||
|
FOR EACH 套餐 IN 优先级列表:
|
||||||
|
可扣减量 = MIN(increment, 套餐剩余额度)
|
||||||
|
UPDATE data_usage_mb += 可扣减量
|
||||||
|
记录到 PackageUsageDailyRecord
|
||||||
|
increment -= 可扣减量
|
||||||
|
IF data_usage_mb >= data_limit_mb:
|
||||||
|
UPDATE status=2(已用完)
|
||||||
|
IF increment == 0:
|
||||||
|
BREAK
|
||||||
|
4. 检查停机条件:
|
||||||
|
IF 所有套餐 status=2:
|
||||||
|
触发停机操作
|
||||||
|
```
|
||||||
|
|
||||||
|
### 并发控制
|
||||||
|
- **场景**:轮询系统同时检测到多张卡的流量增加
|
||||||
|
- **机制**:数据库事务 + 行锁(SELECT FOR UPDATE)
|
||||||
|
- **保证**:同一套餐不会被并发扣减导致负数流量
|
||||||
|
|
||||||
|
### 性能要求
|
||||||
|
- 单次流量扣减 < 100ms(包含数据库更新 + 日记录写入)
|
||||||
|
- 批量扣减(1000张卡) < 10秒
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: 流量优先扣减加油包
|
||||||
|
系统 SHALL 在扣减流量时,优先扣减加油包流量,再扣减主套餐流量。
|
||||||
|
|
||||||
|
**业务价值**:用户购买加油包后,立即生效,优先消耗加油包流量,避免浪费。
|
||||||
|
|
||||||
|
**技术实现**:
|
||||||
|
- 查询时按 `master_usage_id IS NOT NULL, priority ASC` 排序
|
||||||
|
- 主套餐(master_usage_id=NULL)排在最后
|
||||||
|
|
||||||
|
#### Scenario: 存在加油包时优先扣减
|
||||||
|
- **GIVEN** 载体有主套餐(data_usage_mb=0, data_limit_mb=10240)和加油包(data_usage_mb=0, data_limit_mb=5120, priority=1)
|
||||||
|
- **WHEN** 上游返回累计流量 3072MB(本次增量 3GB)
|
||||||
|
- **THEN** 系统执行:
|
||||||
|
1. 扣减加油包:data_usage_mb=3072
|
||||||
|
2. 主套餐不扣减:data_usage_mb=0
|
||||||
|
- **AND** PackageUsageDailyRecord 记录加油包增量 3072MB
|
||||||
|
|
||||||
|
#### Scenario: 加油包用完后扣减主套餐
|
||||||
|
- **GIVEN** 载体有主套餐(data_usage_mb=0, data_limit_mb=10240)和加油包(data_usage_mb=3072, data_limit_mb=5120)
|
||||||
|
- **WHEN** 上游返回累计流量 8192MB(本次增量 5GB)
|
||||||
|
- **THEN** 系统执行:
|
||||||
|
1. 扣减加油包:5120 - 3072 = 2048MB 可用,扣减 2048MB → data_usage_mb=5120(用完)
|
||||||
|
2. 更新加油包 status=2(已用完)
|
||||||
|
3. 剩余流量 5GB - 2GB = 3GB
|
||||||
|
4. 扣减主套餐:data_usage_mb=3072
|
||||||
|
- **AND** PackageUsageDailyRecord 记录加油包增量 2048MB、主套餐增量 3072MB
|
||||||
|
|
||||||
|
#### Scenario: 只有主套餐时直接扣减
|
||||||
|
- **GIVEN** 载体只有主套餐(data_usage_mb=0, data_limit_mb=10240),无加油包
|
||||||
|
- **WHEN** 上游返回累计流量 3072MB
|
||||||
|
- **THEN** 系统直接扣减主套餐:data_usage_mb=3072
|
||||||
|
- **AND** PackageUsageDailyRecord 记录主套餐增量 3072MB
|
||||||
|
|
||||||
|
#### Scenario: 加油包已用完自动跳过(边界条件)
|
||||||
|
- **GIVEN** 载体有主套餐(data_usage_mb=0, data_limit_mb=10240)和加油包(data_usage_mb=5120, data_limit_mb=5120, status=2)
|
||||||
|
- **WHEN** 上游返回累计流量 3072MB
|
||||||
|
- **THEN** 系统跳过已用完的加油包,直接扣减主套餐:data_usage_mb=3072
|
||||||
|
- **AND** 加油包 data_usage_mb 保持 5120(不再扣减)
|
||||||
|
|
||||||
|
#### Scenario: 流量增量为 0 不扣减(边界条件)
|
||||||
|
- **GIVEN** 载体有主套餐和加油包
|
||||||
|
- **WHEN** 上游返回累计流量与上次记录相同(增量=0)
|
||||||
|
- **THEN** 系统不更新任何套餐的 data_usage_mb
|
||||||
|
- **AND** 不创建 PackageUsageDailyRecord
|
||||||
|
|
||||||
|
#### Scenario: 流量增量为负数拒绝扣减(异常处理)
|
||||||
|
- **GIVEN** 载体上次记录累计流量 10GB
|
||||||
|
- **WHEN** 上游返回累计流量 8GB(负增量,异常情况)
|
||||||
|
- **THEN** 系统记录 Warning 日志:"上游流量异常,累计流量减少"
|
||||||
|
- **AND** 不更新套餐 data_usage_mb
|
||||||
|
- **AND** 告警通知运维团队
|
||||||
|
|
||||||
|
### Requirement: 多个加油包按多维度排序扣减
|
||||||
|
|
||||||
|
系统 SHALL 当存在多个加油包时,按 **priority ASC, expires_at ASC, activated_at ASC** 多维度排序扣减流量。
|
||||||
|
|
||||||
|
**业务价值**:
|
||||||
|
- 按购买顺序消耗加油包(priority)
|
||||||
|
- 优先消耗即将到期的流量(expires_at)
|
||||||
|
- 确定性排序便于问题排查(activated_at)
|
||||||
|
|
||||||
|
**技术实现**:
|
||||||
|
- 查询时:`ORDER BY (master_usage_id IS NOT NULL) DESC, priority ASC, expires_at ASC, activated_at ASC`
|
||||||
|
- 确保加油包按多维度排序排在主套餐前
|
||||||
|
|
||||||
|
#### Scenario: 按到期时间优先扣减(多维度排序验证)
|
||||||
|
- **GIVEN** 载体有2个加油包,相同 priority:
|
||||||
|
- 加油包A:priority=1, data_limit_mb=5120, expires_at=2026-02-15 23:59:59
|
||||||
|
- 加油包B:priority=1, data_limit_mb=3072, expires_at=2026-02-12 23:59:59(先到期)
|
||||||
|
- **WHEN** 上游返回累计流量 4096MB(本次增量 4GB)
|
||||||
|
- **THEN** 系统执行:
|
||||||
|
1. 扣减加油包B(先到期):3072MB → data_usage_mb=3072(用完),status=2
|
||||||
|
2. 剩余流量 4GB - 3GB = 1GB
|
||||||
|
3. 扣减加油包A:1024MB → data_usage_mb=1024
|
||||||
|
- **AND** PackageUsageDailyRecord 记录加油包B增量 3072MB、加油包A增量 1024MB
|
||||||
|
|
||||||
|
#### Scenario: 完整多维度排序示例
|
||||||
|
- **GIVEN** 载体有:
|
||||||
|
- 主套餐:priority=10, data_limit_mb=10240, expires_at=2026-03-31
|
||||||
|
- 加油包A:priority=1, data_limit_mb=2048, expires_at=2026-02-15, activated_at=2026-02-01
|
||||||
|
- 加油包B:priority=2, data_limit_mb=3072, expires_at=2026-02-20, activated_at=2026-02-03
|
||||||
|
- 加油包C:priority=1, data_limit_mb=4096, expires_at=2026-02-15, activated_at=2026-02-05(与A同priority和expires_at,但晚激活)
|
||||||
|
- **WHEN** 上游返回累计流量 12288MB(本次增量 12GB)
|
||||||
|
- **THEN** 系统按以下顺序扣减:
|
||||||
|
1. 加油包A(priority=1, expires_at=2026-02-15, activated_at=2026-02-01 最早)
|
||||||
|
2. 加油包C(priority=1, expires_at=2026-02-15, activated_at=2026-02-05)
|
||||||
|
3. 加油包B(priority=2)
|
||||||
|
4. 主套餐(priority=10)
|
||||||
|
- **AND** 扣减结果:
|
||||||
|
- 加油包A:2048MB → status=2(用完)
|
||||||
|
- 加油包C:4096MB → status=2(用完)
|
||||||
|
- 加油包B:3072MB → status=2(用完)
|
||||||
|
- 主套餐:3072MB(剩余 12GB - 2GB - 4GB - 3GB)
|
||||||
|
|
||||||
|
#### Scenario: 按购买顺序扣减多个加油包
|
||||||
|
- **GIVEN** 载体有加油包A(priority=1, data_usage_mb=0, data_limit_mb=3072)和加油包B(priority=2, data_usage_mb=0, data_limit_mb=5120)
|
||||||
|
- **WHEN** 上游返回累计流量 4096MB(本次增量 4GB)
|
||||||
|
- **THEN** 系统执行:
|
||||||
|
1. 扣减加油包A:3072MB → data_usage_mb=3072(用完),status=2
|
||||||
|
2. 剩余流量 4GB - 3GB = 1GB
|
||||||
|
3. 扣减加油包B:1024MB → data_usage_mb=1024
|
||||||
|
- **AND** PackageUsageDailyRecord 记录加油包A增量 3072MB、加油包B增量 1024MB
|
||||||
|
|
||||||
|
#### Scenario: Priority 最小的加油包用完后扣减下一个
|
||||||
|
- **GIVEN** 载体有3个加油包(priority=1/2/3),priority=1 已用完(status=2)
|
||||||
|
- **WHEN** 上游返回累计流量增量 2GB
|
||||||
|
- **THEN** 系统跳过 priority=1,扣减 priority=2 的加油包 2GB
|
||||||
|
|
||||||
|
#### Scenario: 所有加油包用完后扣减主套餐
|
||||||
|
- **GIVEN** 载体有主套餐和2个加油包(priority=1/2),两个加油包都已用完(status=2)
|
||||||
|
- **WHEN** 上游返回累计流量增量 5GB
|
||||||
|
- **THEN** 系统跳过所有加油包,扣减主套餐 5GB
|
||||||
|
|
||||||
|
#### Scenario: 3个加油包和主套餐的完整扣减流程
|
||||||
|
- **GIVEN** 载体有:
|
||||||
|
- 主套餐(data_limit_mb=10240, data_usage_mb=0)
|
||||||
|
- 加油包A(priority=1, data_limit_mb=2048, data_usage_mb=0)
|
||||||
|
- 加油包B(priority=2, data_limit_mb=3072, data_usage_mb=0)
|
||||||
|
- 加油包C(priority=3, data_limit_mb=4096, data_usage_mb=0)
|
||||||
|
- **WHEN** 上游返回累计流量 12288MB(本次增量 12GB)
|
||||||
|
- **THEN** 系统执行:
|
||||||
|
1. 扣减加油包A:2048MB → status=2(用完)
|
||||||
|
2. 扣减加油包B:3072MB → status=2(用完)
|
||||||
|
3. 扣减加油包C:4096MB → status=2(用完)
|
||||||
|
4. 扣减主套餐:3072MB(剩余 12GB - 2GB - 3GB - 4GB)
|
||||||
|
- **AND** PackageUsageDailyRecord 记录 4 条记录
|
||||||
|
|
||||||
|
#### Scenario: 并发扣减同一套餐(并发控制)
|
||||||
|
- **GIVEN** 两个轮询任务同时检测到同一张卡的流量增加
|
||||||
|
- **WHEN** 两个任务同时尝试扣减加油包A
|
||||||
|
- **THEN** 第一个任务获取行锁(SELECT FOR UPDATE),执行扣减
|
||||||
|
- **AND** 第二个任务等待锁释放,检测到已扣减,跳过(幂等性保证)
|
||||||
|
- **AND** 加油包A的 data_usage_mb 只增加一次
|
||||||
|
|
||||||
|
### Requirement: 所有流量用完时触发停机
|
||||||
|
系统 SHALL 在主套餐和所有加油包流量都用完时,触发停机操作。
|
||||||
|
|
||||||
|
**业务价值**:充分利用加油包流量,避免提前停机,提升用户体验。
|
||||||
|
|
||||||
|
**技术实现**:
|
||||||
|
```sql
|
||||||
|
-- 停机条件检查
|
||||||
|
SELECT COUNT(*) FROM tb_package_usage
|
||||||
|
WHERE (iot_card_id/device_id)=? AND status=1 AND master_usage_id IS NULL;
|
||||||
|
|
||||||
|
-- 如果 COUNT=0(主套餐已过期或用完),检查加油包
|
||||||
|
SELECT COUNT(*) FROM tb_package_usage
|
||||||
|
WHERE (iot_card_id/device_id)=? AND status=1 AND master_usage_id IS NOT NULL
|
||||||
|
AND data_usage_mb < data_limit_mb;
|
||||||
|
|
||||||
|
-- 如果两个 COUNT 都=0,触发停机
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Scenario: 主套餐和加油包都用完触发停机
|
||||||
|
- **GIVEN** 主套餐 data_usage_mb=10240, data_limit_mb=10240(用完),加油包 data_usage_mb=5120, data_limit_mb=5120(用完)
|
||||||
|
- **WHEN** 轮询系统检查停机条件
|
||||||
|
- **THEN** 系统查询生效中套餐剩余流量,结果为 0
|
||||||
|
- **AND** 触发停机操作:
|
||||||
|
1. 调用运营商 API 停机
|
||||||
|
2. 更新 IotCard.network_status=0(已停机)
|
||||||
|
3. 记录操作日志
|
||||||
|
- **AND** 主套餐和加油包 status 更新为 2(已用完)
|
||||||
|
|
||||||
|
#### Scenario: 有加油包剩余流量时不停机
|
||||||
|
- **GIVEN** 主套餐 data_usage_mb=10240, data_limit_mb=10240(用完),加油包 data_usage_mb=4096, data_limit_mb=5120(剩余1GB)
|
||||||
|
- **WHEN** 轮询系统检查停机条件
|
||||||
|
- **THEN** 系统查询生效中套餐剩余流量,结果 > 0
|
||||||
|
- **AND** 不触发停机,继续提供服务
|
||||||
|
|
||||||
|
#### Scenario: 主套餐未用完但加油包都用完(不停机)
|
||||||
|
- **GIVEN** 主套餐 data_usage_mb=8192, data_limit_mb=10240(剩余2GB),所有加油包都用完(status=2)
|
||||||
|
- **WHEN** 轮询系统检查停机条件
|
||||||
|
- **THEN** 系统查询主套餐剩余流量 > 0
|
||||||
|
- **AND** 不触发停机
|
||||||
|
|
||||||
|
#### Scenario: 主套餐过期但加油包有剩余(不停机)
|
||||||
|
- **GIVEN** 主套餐 status=3(已过期),加油包 data_usage_mb=2048, data_limit_mb=5120(剩余3GB), status=1
|
||||||
|
- **WHEN** 轮询系统检查停机条件
|
||||||
|
- **THEN** 系统查询生效中加油包剩余流量 > 0
|
||||||
|
- **AND** 不触发停机
|
||||||
|
|
||||||
|
#### Scenario: 停机后续费加油包自动复机(业务理解)
|
||||||
|
- **GIVEN** 载体已停机(所有套餐流量用完)
|
||||||
|
- **WHEN** 用户购买新加油包(立即激活,status=1)
|
||||||
|
- **THEN** 下次轮询检查时,发现有剩余流量 > 0
|
||||||
|
- **AND** 自动触发复机操作:
|
||||||
|
1. 调用运营商 API 复机
|
||||||
|
2. 更新 IotCard.network_status=1(已开机)
|
||||||
|
3. 记录操作日志
|
||||||
|
|
||||||
|
#### Scenario: 停机 API 调用失败(异常处理)
|
||||||
|
- **GIVEN** 载体所有套餐流量用完,需要停机
|
||||||
|
- **WHEN** 调用运营商停机 API 失败(例如网络超时)
|
||||||
|
- **THEN** 系统记录 Error 日志,包含卡号、错误信息
|
||||||
|
- **AND** 停机任务进入重试队列(Asynq 重试 3 次,间隔 10 秒)
|
||||||
|
- **AND** 如果 3 次重试都失败,进入死信队列(DLQ)
|
||||||
|
- **AND** 告警通知运维团队
|
||||||
|
|
||||||
|
### Requirement: 流量扣减记录到日记录表
|
||||||
|
系统 SHALL 在扣减流量时,更新 PackageUsage 的 data_usage_mb,并创建或更新 PackageUsageDailyRecord。
|
||||||
|
|
||||||
|
**业务价值**:
|
||||||
|
- 精细化流量统计(按套餐、按日)
|
||||||
|
- 支持流量详单查询
|
||||||
|
- 数据可追溯、可审计
|
||||||
|
|
||||||
|
**技术实现**:
|
||||||
|
- 扣减流量后,创建或更新当日 PackageUsageDailyRecord
|
||||||
|
- 使用 UPSERT(ON CONFLICT UPDATE)避免重复记录
|
||||||
|
- 记录字段:`package_usage_id`, `date`, `daily_usage_mb`, `cumulative_usage_mb`
|
||||||
|
|
||||||
|
#### Scenario: 扣减主套餐流量并记录
|
||||||
|
- **GIVEN** 主套餐 data_usage_mb=0, data_limit_mb=10240
|
||||||
|
- **WHEN** 扣减主套餐 2048MB 流量
|
||||||
|
- **THEN** PackageUsage 更新:data_usage_mb=2048
|
||||||
|
- **AND** PackageUsageDailyRecord 创建记录:
|
||||||
|
- package_usage_id=主套餐ID
|
||||||
|
- date=2026-02-10
|
||||||
|
- daily_usage_mb=2048
|
||||||
|
- cumulative_usage_mb=2048
|
||||||
|
|
||||||
|
#### Scenario: 扣减加油包流量并记录
|
||||||
|
- **GIVEN** 加油包 data_usage_mb=0, data_limit_mb=5120
|
||||||
|
- **WHEN** 扣减加油包 3072MB 流量
|
||||||
|
- **THEN** PackageUsage 更新:data_usage_mb=3072
|
||||||
|
- **AND** PackageUsageDailyRecord 创建记录:
|
||||||
|
- package_usage_id=加油包ID
|
||||||
|
- date=2026-02-10
|
||||||
|
- daily_usage_mb=3072
|
||||||
|
- cumulative_usage_mb=3072
|
||||||
|
|
||||||
|
#### Scenario: 同一天多次扣减更新日记录
|
||||||
|
- **GIVEN** PackageUsageDailyRecord 已有记录(date=2026-02-10, daily_usage_mb=2048, cumulative_usage_mb=2048)
|
||||||
|
- **WHEN** 再次扣减主套餐 1024MB 流量
|
||||||
|
- **THEN** PackageUsage 更新:data_usage_mb=3072
|
||||||
|
- **AND** PackageUsageDailyRecord 更新记录:
|
||||||
|
- daily_usage_mb=3072(2048 + 1024)
|
||||||
|
- cumulative_usage_mb=3072
|
||||||
|
- **AND** 使用 UPSERT 更新而非插入新记录
|
||||||
|
|
||||||
|
#### Scenario: 跨天扣减创建新日记录
|
||||||
|
- **GIVEN** PackageUsageDailyRecord 有 2026-02-10 的记录(daily_usage_mb=5120, cumulative_usage_mb=5120)
|
||||||
|
- **WHEN** 2026-02-11 扣减主套餐 2048MB 流量
|
||||||
|
- **THEN** PackageUsageDailyRecord 创建新记录:
|
||||||
|
- date=2026-02-11
|
||||||
|
- daily_usage_mb=2048
|
||||||
|
- cumulative_usage_mb=7168(5120 + 2048)
|
||||||
|
|
||||||
|
#### Scenario: 日记录写入失败不影响扣减(容错性)
|
||||||
|
- **GIVEN** 数据库主表正常,日记录表存在问题(例如磁盘满)
|
||||||
|
- **WHEN** 扣减主套餐流量,PackageUsage 更新成功,但 PackageUsageDailyRecord 写入失败
|
||||||
|
- **THEN** 系统记录 Error 日志,包含套餐ID、日期、增量
|
||||||
|
- **AND** PackageUsage 的 data_usage_mb 仍然更新(不回滚)
|
||||||
|
- **AND** 告警通知运维团队修复日记录表
|
||||||
|
|
||||||
|
#### Scenario: 批量扣减写入日记录(性能优化)
|
||||||
|
- **GIVEN** 轮询系统同时检测到 1000 张卡的流量增加
|
||||||
|
- **WHEN** 批量扣减流量
|
||||||
|
- **THEN** 使用批量 INSERT ON CONFLICT UPDATE 写入日记录
|
||||||
|
- **AND** 1000 条记录写入时间 < 5 秒
|
||||||
|
|
||||||
|
## 数据一致性保证
|
||||||
|
|
||||||
|
### 1. 扣减流量事务保证
|
||||||
|
- **机制**:数据库事务包含:
|
||||||
|
1. UPDATE PackageUsage SET data_usage_mb += increment
|
||||||
|
2. INSERT/UPDATE PackageUsageDailyRecord
|
||||||
|
- **回滚条件**:任一步骤失败,整个事务回滚
|
||||||
|
|
||||||
|
### 2. 并发扣减行锁
|
||||||
|
- **机制**:`SELECT * FROM tb_package_usage WHERE id=? FOR UPDATE`
|
||||||
|
- **保证**:同一套餐不会被并发扣减
|
||||||
|
|
||||||
|
### 3. 负数流量保护
|
||||||
|
- **机制**:数据库约束 `CHECK (data_usage_mb >= 0)`
|
||||||
|
- **保证**:扣减后不会出现负数流量
|
||||||
|
|
||||||
|
### 4. 日记录唯一索引
|
||||||
|
- **机制**:`UNIQUE INDEX (package_usage_id, date) WHERE deleted_at IS NULL`
|
||||||
|
- **保证**:同一套餐同一天只有一条记录
|
||||||
|
|
||||||
|
## 性能指标
|
||||||
|
|
||||||
|
| 操作 | 性能要求 | 监控指标 |
|
||||||
|
|------|---------|---------|
|
||||||
|
| 单次流量扣减 | < 100ms | 数据库事务耗时 |
|
||||||
|
| 批量扣减(1000张卡) | < 10秒 | 轮询任务执行时间 |
|
||||||
|
| 日记录写入 | < 50ms | INSERT/UPDATE 耗时 |
|
||||||
|
| 停机条件检查 | < 50ms | SELECT 查询耗时 |
|
||||||
|
|
||||||
|
## 错误码定义
|
||||||
|
|
||||||
|
| 错误码 | HTTP 状态码 | 错误消息 | 场景 |
|
||||||
|
|--------|------------|---------|------|
|
||||||
|
| CodeInternal | 500 | 流量扣减失败,请重试 | 数据库更新失败 |
|
||||||
|
| CodeInternal | 500 | 停机操作失败,请重试 | 运营商 API 调用失败 |
|
||||||
|
|
||||||
|
## 测试场景矩阵
|
||||||
|
|
||||||
|
| 维度 | 场景 | 预期结果 |
|
||||||
|
|------|------|---------|
|
||||||
|
| **基础扣减** | 只有主套餐 | 直接扣减主套餐 |
|
||||||
|
| | 有1个加油包 | 优先扣减加油包 |
|
||||||
|
| | 有3个加油包 | 按 priority 顺序扣减 |
|
||||||
|
| **扣减完整流程** | 加油包用完 → 主套餐 | 先扣完所有加油包,再扣主套餐 |
|
||||||
|
| | 所有套餐用完 | 触发停机 |
|
||||||
|
| **边界条件** | 流量增量=0 | 不扣减 |
|
||||||
|
| | 流量增量<0(异常) | 拒绝扣减,告警 |
|
||||||
|
| | 加油包已用完 | 自动跳过 |
|
||||||
|
| **并发场景** | 并发扣减同一套餐 | 行锁保证只扣减一次 |
|
||||||
|
| **停机条件** | 主套餐用完+加油包剩余 | 不停机 |
|
||||||
|
| | 所有套餐用完 | 停机 |
|
||||||
|
| | 停机后购买加油包 | 自动复机 |
|
||||||
|
| **日记录** | 首次扣减 | 创建日记录 |
|
||||||
|
| | 同一天多次扣减 | 更新日记录 |
|
||||||
|
| | 跨天扣减 | 创建新日记录 |
|
||||||
|
| **异常处理** | 停机 API 失败 | 重试 3 次,失败进 DLQ |
|
||||||
|
| | 日记录写入失败 | 告警,不影响扣减 |
|
||||||
@@ -256,3 +256,21 @@ func RedisPollingInitProgressKey() string {
|
|||||||
func RedisPackageActivationLockKey(carrierType string, carrierID uint) string {
|
func RedisPackageActivationLockKey(carrierType string, carrierID uint) string {
|
||||||
return fmt.Sprintf("package:activation:lock:%s:%d", carrierType, carrierID)
|
return fmt.Sprintf("package:activation:lock:%s:%d", carrierType, carrierID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 订单幂等性相关 Redis Key
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// RedisOrderIdempotencyKey 生成订单创建幂等性检测的 Redis 键
|
||||||
|
// 用途:防止相同买家在短时间内对同一载体重复下单(SETNX 快速拒绝)
|
||||||
|
// 过期时间:3 分钟
|
||||||
|
func RedisOrderIdempotencyKey(businessKey string) string {
|
||||||
|
return fmt.Sprintf("order:idempotency:%s", businessKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RedisOrderCreateLockKey 生成订单创建分布式锁的 Redis 键
|
||||||
|
// 用途:防止同一载体的订单创建并发执行
|
||||||
|
// 过期时间:10 秒
|
||||||
|
func RedisOrderCreateLockKey(carrierType string, carrierID uint) string {
|
||||||
|
return fmt.Sprintf("order:create:lock:%s:%d", carrierType, carrierID)
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,10 +8,6 @@ import (
|
|||||||
|
|
||||||
"github.com/break/junhong_cmp_fiber/internal/gateway"
|
"github.com/break/junhong_cmp_fiber/internal/gateway"
|
||||||
"github.com/break/junhong_cmp_fiber/internal/polling"
|
"github.com/break/junhong_cmp_fiber/internal/polling"
|
||||||
"github.com/break/junhong_cmp_fiber/internal/service/commission_calculation"
|
|
||||||
"github.com/break/junhong_cmp_fiber/internal/service/commission_stats"
|
|
||||||
packagepkg "github.com/break/junhong_cmp_fiber/internal/service/package"
|
|
||||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
|
||||||
"github.com/break/junhong_cmp_fiber/internal/task"
|
"github.com/break/junhong_cmp_fiber/internal/task"
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/storage"
|
"github.com/break/junhong_cmp_fiber/pkg/storage"
|
||||||
@@ -25,9 +21,20 @@ type Handler struct {
|
|||||||
storage *storage.Service
|
storage *storage.Service
|
||||||
gatewayClient *gateway.Client
|
gatewayClient *gateway.Client
|
||||||
pollingCallback task.PollingCallback
|
pollingCallback task.PollingCallback
|
||||||
|
workerResult *WorkerBootstrapResult
|
||||||
|
asynqClient *asynq.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHandler(db *gorm.DB, redis *redis.Client, storageSvc *storage.Service, gatewayClient *gateway.Client, pollingCallback task.PollingCallback, logger *zap.Logger) *Handler {
|
func NewHandler(
|
||||||
|
db *gorm.DB,
|
||||||
|
redis *redis.Client,
|
||||||
|
storageSvc *storage.Service,
|
||||||
|
gatewayClient *gateway.Client,
|
||||||
|
pollingCallback task.PollingCallback,
|
||||||
|
workerResult *WorkerBootstrapResult,
|
||||||
|
asynqClient *asynq.Client,
|
||||||
|
logger *zap.Logger,
|
||||||
|
) *Handler {
|
||||||
return &Handler{
|
return &Handler{
|
||||||
mux: asynq.NewServeMux(),
|
mux: asynq.NewServeMux(),
|
||||||
logger: logger,
|
logger: logger,
|
||||||
@@ -36,6 +43,8 @@ func NewHandler(db *gorm.DB, redis *redis.Client, storageSvc *storage.Service, g
|
|||||||
storage: storageSvc,
|
storage: storageSvc,
|
||||||
gatewayClient: gatewayClient,
|
gatewayClient: gatewayClient,
|
||||||
pollingCallback: pollingCallback,
|
pollingCallback: pollingCallback,
|
||||||
|
workerResult: workerResult,
|
||||||
|
asynqClient: asynqClient,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,32 +74,55 @@ func (h *Handler) RegisterHandlers() *asynq.ServeMux {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) registerIotCardImportHandler() {
|
func (h *Handler) registerIotCardImportHandler() {
|
||||||
importTaskStore := postgres.NewIotCardImportTaskStore(h.db, h.redis)
|
iotCardImportHandler := task.NewIotCardImportHandler(
|
||||||
iotCardStore := postgres.NewIotCardStore(h.db, h.redis)
|
h.db,
|
||||||
iotCardImportHandler := task.NewIotCardImportHandler(h.db, h.redis, importTaskStore, iotCardStore, h.storage, h.pollingCallback, h.logger)
|
h.redis,
|
||||||
|
h.workerResult.Stores.IotCardImportTask,
|
||||||
|
h.workerResult.Stores.IotCard,
|
||||||
|
h.storage,
|
||||||
|
h.pollingCallback,
|
||||||
|
h.logger,
|
||||||
|
)
|
||||||
|
|
||||||
h.mux.HandleFunc(constants.TaskTypeIotCardImport, iotCardImportHandler.HandleIotCardImport)
|
h.mux.HandleFunc(constants.TaskTypeIotCardImport, iotCardImportHandler.HandleIotCardImport)
|
||||||
h.logger.Info("注册 IoT 卡导入任务处理器", zap.String("task_type", constants.TaskTypeIotCardImport))
|
h.logger.Info("注册 IoT 卡导入任务处理器", zap.String("task_type", constants.TaskTypeIotCardImport))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) registerDeviceImportHandler() {
|
func (h *Handler) registerDeviceImportHandler() {
|
||||||
importTaskStore := postgres.NewDeviceImportTaskStore(h.db, h.redis)
|
deviceImportHandler := task.NewDeviceImportHandler(
|
||||||
deviceStore := postgres.NewDeviceStore(h.db, h.redis)
|
h.db,
|
||||||
bindingStore := postgres.NewDeviceSimBindingStore(h.db, h.redis)
|
h.redis,
|
||||||
iotCardStore := postgres.NewIotCardStore(h.db, h.redis)
|
h.workerResult.Stores.DeviceImportTask,
|
||||||
deviceImportHandler := task.NewDeviceImportHandler(h.db, h.redis, importTaskStore, deviceStore, bindingStore, iotCardStore, h.storage, h.logger)
|
h.workerResult.Stores.Device,
|
||||||
|
h.workerResult.Stores.DeviceSimBinding,
|
||||||
|
h.workerResult.Stores.IotCard,
|
||||||
|
h.storage,
|
||||||
|
h.logger,
|
||||||
|
)
|
||||||
|
|
||||||
h.mux.HandleFunc(constants.TaskTypeDeviceImport, deviceImportHandler.HandleDeviceImport)
|
h.mux.HandleFunc(constants.TaskTypeDeviceImport, deviceImportHandler.HandleDeviceImport)
|
||||||
h.logger.Info("注册设备导入任务处理器", zap.String("task_type", constants.TaskTypeDeviceImport))
|
h.logger.Info("注册设备导入任务处理器", zap.String("task_type", constants.TaskTypeDeviceImport))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) registerCommissionStatsHandlers() {
|
func (h *Handler) registerCommissionStatsHandlers() {
|
||||||
statsStore := postgres.NewShopSeriesCommissionStatsStore(h.db)
|
updateHandler := task.NewCommissionStatsUpdateHandler(
|
||||||
allocationStore := postgres.NewShopPackageAllocationStore(h.db)
|
h.redis,
|
||||||
|
h.workerResult.Stores.ShopSeriesCommissionStats,
|
||||||
updateHandler := task.NewCommissionStatsUpdateHandler(h.redis, statsStore, allocationStore, h.logger)
|
h.workerResult.Stores.ShopPackageAllocation,
|
||||||
syncHandler := task.NewCommissionStatsSyncHandler(h.db, h.redis, statsStore, h.logger)
|
h.logger,
|
||||||
archiveHandler := task.NewCommissionStatsArchiveHandler(h.db, h.redis, statsStore, h.logger)
|
)
|
||||||
|
syncHandler := task.NewCommissionStatsSyncHandler(
|
||||||
|
h.db,
|
||||||
|
h.redis,
|
||||||
|
h.workerResult.Stores.ShopSeriesCommissionStats,
|
||||||
|
h.logger,
|
||||||
|
)
|
||||||
|
archiveHandler := task.NewCommissionStatsArchiveHandler(
|
||||||
|
h.db,
|
||||||
|
h.redis,
|
||||||
|
h.workerResult.Stores.ShopSeriesCommissionStats,
|
||||||
|
h.logger,
|
||||||
|
)
|
||||||
|
|
||||||
h.mux.HandleFunc(constants.TaskTypeCommissionStatsUpdate, updateHandler.HandleCommissionStatsUpdate)
|
h.mux.HandleFunc(constants.TaskTypeCommissionStatsUpdate, updateHandler.HandleCommissionStatsUpdate)
|
||||||
h.logger.Info("注册佣金统计更新任务处理器", zap.String("task_type", constants.TaskTypeCommissionStatsUpdate))
|
h.logger.Info("注册佣金统计更新任务处理器", zap.String("task_type", constants.TaskTypeCommissionStatsUpdate))
|
||||||
@@ -103,58 +135,23 @@ func (h *Handler) registerCommissionStatsHandlers() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) registerCommissionCalculationHandler() {
|
func (h *Handler) registerCommissionCalculationHandler() {
|
||||||
// 创建所有需要的 Store 实例
|
commissionCalculationHandler := task.NewCommissionCalculationHandler(
|
||||||
commissionRecordStore := postgres.NewCommissionRecordStore(h.db, h.redis)
|
|
||||||
shopStore := postgres.NewShopStore(h.db, h.redis)
|
|
||||||
shopPackageAllocationStore := postgres.NewShopPackageAllocationStore(h.db)
|
|
||||||
shopSeriesAllocationStore := postgres.NewShopSeriesAllocationStore(h.db)
|
|
||||||
packageSeriesStore := postgres.NewPackageSeriesStore(h.db)
|
|
||||||
iotCardStore := postgres.NewIotCardStore(h.db, h.redis)
|
|
||||||
deviceStore := postgres.NewDeviceStore(h.db, h.redis)
|
|
||||||
walletStore := postgres.NewWalletStore(h.db, h.redis)
|
|
||||||
walletTransactionStore := postgres.NewWalletTransactionStore(h.db, h.redis)
|
|
||||||
orderStore := postgres.NewOrderStore(h.db, h.redis)
|
|
||||||
orderItemStore := postgres.NewOrderItemStore(h.db, h.redis)
|
|
||||||
packageStore := postgres.NewPackageStore(h.db)
|
|
||||||
commissionStatsStore := postgres.NewShopSeriesCommissionStatsStore(h.db)
|
|
||||||
|
|
||||||
// 创建 commission_stats.Service
|
|
||||||
commissionStatsService := commission_stats.New(commissionStatsStore)
|
|
||||||
|
|
||||||
// 创建 commission_calculation.Service
|
|
||||||
commissionCalculationService := commission_calculation.New(
|
|
||||||
h.db,
|
h.db,
|
||||||
commissionRecordStore,
|
h.workerResult.Services.CommissionCalculation,
|
||||||
shopStore,
|
|
||||||
shopPackageAllocationStore,
|
|
||||||
shopSeriesAllocationStore,
|
|
||||||
packageSeriesStore,
|
|
||||||
iotCardStore,
|
|
||||||
deviceStore,
|
|
||||||
walletStore,
|
|
||||||
walletTransactionStore,
|
|
||||||
orderStore,
|
|
||||||
orderItemStore,
|
|
||||||
packageStore,
|
|
||||||
commissionStatsStore,
|
|
||||||
commissionStatsService,
|
|
||||||
h.logger,
|
h.logger,
|
||||||
)
|
)
|
||||||
|
|
||||||
// 创建并注册 Handler
|
|
||||||
commissionCalculationHandler := task.NewCommissionCalculationHandler(h.db, commissionCalculationService, h.logger)
|
|
||||||
h.mux.HandleFunc(constants.TaskTypeCommission, commissionCalculationHandler.HandleCommissionCalculation)
|
h.mux.HandleFunc(constants.TaskTypeCommission, commissionCalculationHandler.HandleCommissionCalculation)
|
||||||
h.logger.Info("注册佣金计算任务处理器", zap.String("task_type", constants.TaskTypeCommission))
|
h.logger.Info("注册佣金计算任务处理器", zap.String("task_type", constants.TaskTypeCommission))
|
||||||
}
|
}
|
||||||
|
|
||||||
// registerPollingHandlers 注册轮询任务处理器
|
|
||||||
func (h *Handler) registerPollingHandlers() {
|
func (h *Handler) registerPollingHandlers() {
|
||||||
// 创建套餐相关 Store 和 Service(用于流量扣减)
|
pollingHandler := task.NewPollingHandler(
|
||||||
packageUsageStore := postgres.NewPackageUsageStore(h.db, h.redis)
|
h.db,
|
||||||
packageUsageDailyRecordStore := postgres.NewPackageUsageDailyRecordStore(h.db, h.redis)
|
h.redis,
|
||||||
usageService := packagepkg.NewUsageService(h.db, h.redis, packageUsageStore, packageUsageDailyRecordStore, h.logger)
|
h.gatewayClient,
|
||||||
|
h.workerResult.Services.UsageService,
|
||||||
pollingHandler := task.NewPollingHandler(h.db, h.redis, h.gatewayClient, usageService, h.logger)
|
h.logger,
|
||||||
|
)
|
||||||
|
|
||||||
h.mux.HandleFunc(constants.TaskTypePollingRealname, pollingHandler.HandleRealnameCheck)
|
h.mux.HandleFunc(constants.TaskTypePollingRealname, pollingHandler.HandleRealnameCheck)
|
||||||
h.logger.Info("注册实名检查任务处理器", zap.String("task_type", constants.TaskTypePollingRealname))
|
h.logger.Info("注册实名检查任务处理器", zap.String("task_type", constants.TaskTypePollingRealname))
|
||||||
@@ -166,45 +163,18 @@ func (h *Handler) registerPollingHandlers() {
|
|||||||
h.logger.Info("注册套餐检查任务处理器", zap.String("task_type", constants.TaskTypePollingPackage))
|
h.logger.Info("注册套餐检查任务处理器", zap.String("task_type", constants.TaskTypePollingPackage))
|
||||||
}
|
}
|
||||||
|
|
||||||
// registerPackageActivationHandlers 注册套餐激活任务处理器
|
|
||||||
// 任务 22.6 和 23.6: 注册首次实名激活和排队激活任务 Handler
|
|
||||||
func (h *Handler) registerPackageActivationHandlers() {
|
func (h *Handler) registerPackageActivationHandlers() {
|
||||||
// 创建套餐相关 Store 和 Service
|
|
||||||
packageUsageStore := postgres.NewPackageUsageStore(h.db, h.redis)
|
|
||||||
packageStore := postgres.NewPackageStore(h.db)
|
|
||||||
packageUsageDailyRecordStore := postgres.NewPackageUsageDailyRecordStore(h.db, h.redis)
|
|
||||||
|
|
||||||
activationService := packagepkg.NewActivationService(
|
|
||||||
h.db,
|
|
||||||
h.redis,
|
|
||||||
packageUsageStore,
|
|
||||||
packageStore,
|
|
||||||
packageUsageDailyRecordStore,
|
|
||||||
h.logger,
|
|
||||||
)
|
|
||||||
|
|
||||||
// 创建 Asynq 客户端用于任务提交
|
|
||||||
redisOpt := asynq.RedisClientOpt{
|
|
||||||
Addr: h.redis.Options().Addr,
|
|
||||||
Password: h.redis.Options().Password,
|
|
||||||
DB: h.redis.Options().DB,
|
|
||||||
}
|
|
||||||
queueClient := asynq.NewClient(redisOpt)
|
|
||||||
|
|
||||||
// 创建套餐激活处理器
|
|
||||||
packageActivationHandler := polling.NewPackageActivationHandler(
|
packageActivationHandler := polling.NewPackageActivationHandler(
|
||||||
h.db,
|
h.db,
|
||||||
h.redis,
|
h.redis,
|
||||||
queueClient,
|
h.asynqClient,
|
||||||
activationService,
|
h.workerResult.Services.ActivationService,
|
||||||
h.logger,
|
h.logger,
|
||||||
)
|
)
|
||||||
|
|
||||||
// 任务 22.6: 注册首次实名激活任务 Handler
|
|
||||||
h.mux.HandleFunc(constants.TaskTypePackageFirstActivation, packageActivationHandler.HandlePackageFirstActivation)
|
h.mux.HandleFunc(constants.TaskTypePackageFirstActivation, packageActivationHandler.HandlePackageFirstActivation)
|
||||||
h.logger.Info("注册首次实名激活任务处理器", zap.String("task_type", constants.TaskTypePackageFirstActivation))
|
h.logger.Info("注册首次实名激活任务处理器", zap.String("task_type", constants.TaskTypePackageFirstActivation))
|
||||||
|
|
||||||
// 任务 23.6: 注册排队激活任务 Handler
|
|
||||||
h.mux.HandleFunc(constants.TaskTypePackageQueueActivation, packageActivationHandler.HandlePackageQueueActivation)
|
h.mux.HandleFunc(constants.TaskTypePackageQueueActivation, packageActivationHandler.HandlePackageQueueActivation)
|
||||||
h.logger.Info("注册排队激活任务处理器", zap.String("task_type", constants.TaskTypePackageQueueActivation))
|
h.logger.Info("注册排队激活任务处理器", zap.String("task_type", constants.TaskTypePackageQueueActivation))
|
||||||
}
|
}
|
||||||
|
|||||||
52
pkg/queue/types.go
Normal file
52
pkg/queue/types.go
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
package queue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/service/commission_calculation"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/service/commission_stats"
|
||||||
|
packagepkg "github.com/break/junhong_cmp_fiber/internal/service/package"
|
||||||
|
pollingSvc "github.com/break/junhong_cmp_fiber/internal/service/polling"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WorkerStores Worker 侧所有 Store 的集合
|
||||||
|
type WorkerStores struct {
|
||||||
|
IotCardImportTask *postgres.IotCardImportTaskStore
|
||||||
|
IotCard *postgres.IotCardStore
|
||||||
|
DeviceImportTask *postgres.DeviceImportTaskStore
|
||||||
|
Device *postgres.DeviceStore
|
||||||
|
DeviceSimBinding *postgres.DeviceSimBindingStore
|
||||||
|
ShopSeriesCommissionStats *postgres.ShopSeriesCommissionStatsStore
|
||||||
|
ShopPackageAllocation *postgres.ShopPackageAllocationStore
|
||||||
|
CommissionRecord *postgres.CommissionRecordStore
|
||||||
|
Shop *postgres.ShopStore
|
||||||
|
ShopSeriesAllocation *postgres.ShopSeriesAllocationStore
|
||||||
|
PackageSeries *postgres.PackageSeriesStore
|
||||||
|
Wallet *postgres.WalletStore
|
||||||
|
WalletTransaction *postgres.WalletTransactionStore
|
||||||
|
Order *postgres.OrderStore
|
||||||
|
OrderItem *postgres.OrderItemStore
|
||||||
|
Package *postgres.PackageStore
|
||||||
|
PackageUsage *postgres.PackageUsageStore
|
||||||
|
PackageUsageDailyRecord *postgres.PackageUsageDailyRecordStore
|
||||||
|
PollingAlertRule *postgres.PollingAlertRuleStore
|
||||||
|
PollingAlertHistory *postgres.PollingAlertHistoryStore
|
||||||
|
DataCleanupConfig *postgres.DataCleanupConfigStore
|
||||||
|
DataCleanupLog *postgres.DataCleanupLogStore
|
||||||
|
}
|
||||||
|
|
||||||
|
// WorkerServices Worker 侧所有 Service 的集合
|
||||||
|
type WorkerServices struct {
|
||||||
|
CommissionCalculation *commission_calculation.Service
|
||||||
|
CommissionStats *commission_stats.Service
|
||||||
|
UsageService *packagepkg.UsageService
|
||||||
|
ActivationService *packagepkg.ActivationService
|
||||||
|
ResetService *packagepkg.ResetService
|
||||||
|
AlertService *pollingSvc.AlertService
|
||||||
|
CleanupService *pollingSvc.CleanupService
|
||||||
|
}
|
||||||
|
|
||||||
|
// WorkerBootstrapResult Worker Bootstrap 结果
|
||||||
|
type WorkerBootstrapResult struct {
|
||||||
|
Stores *WorkerStores
|
||||||
|
Services *WorkerServices
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user