移除所有测试代码和测试要求
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m33s
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m33s
**变更说明**: - 删除所有 *_test.go 文件(单元测试、集成测试、验收测试、流程测试) - 删除整个 tests/ 目录 - 更新 CLAUDE.md:用"测试禁令"章节替换所有测试要求 - 删除测试生成 Skill (openspec-generate-acceptance-tests) - 删除测试生成命令 (opsx:gen-tests) - 更新 tasks.md:删除所有测试相关任务 **新规范**: - ❌ 禁止编写任何形式的自动化测试 - ❌ 禁止创建 *_test.go 文件 - ❌ 禁止在任务中包含测试相关工作 - ✅ 仅当用户明确要求时才编写测试 **原因**: 业务系统的正确性通过人工验证和生产环境监控保证,测试代码维护成本高于价值。 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -111,7 +111,7 @@ Working on task 4/7: <task description>
|
||||
- [x] Task 2
|
||||
...
|
||||
|
||||
All tasks complete! Ready to archive this change.
|
||||
All tasks complete! You can archive this change with `/opsx:archive`.
|
||||
```
|
||||
|
||||
**Output On Pause (Issue Encountered)**
|
||||
|
||||
@@ -41,7 +41,7 @@ Continue working on a change by creating the next artifact.
|
||||
**If all artifacts are complete (`isComplete: true`)**:
|
||||
- Congratulate the user
|
||||
- Show final status including the schema used
|
||||
- Suggest: "All artifacts created! You can now implement this change or archive it."
|
||||
- Suggest: "All artifacts created! You can now implement this change with `/opsx:apply` or archive it with `/opsx:archive`."
|
||||
- STOP
|
||||
|
||||
---
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
---
|
||||
description: 从 Spec 的 Scenarios 和 Business Flows 自动生成验收测试和流程测试
|
||||
---
|
||||
|
||||
从 Spec 文档自动生成两类测试:
|
||||
1. **验收测试**(Acceptance Tests):从 Scenarios 生成,验证单 API 契约
|
||||
2. **流程测试**(Flow Tests):从 Business Flows 生成,验证多 API 业务场景
|
||||
|
||||
**Input**: 可选指定 change 名称(如 `/opsx:gen-tests add-auth`)。如果省略,从上下文推断或提示选择。
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **选择 change**
|
||||
|
||||
如果提供了名称,使用它。否则:
|
||||
- 从对话上下文推断
|
||||
- 如果只有一个活跃 change,自动选择
|
||||
- 如果模糊,运行 `openspec list --json` 让用户选择
|
||||
|
||||
2. **检查 change 状态**
|
||||
```bash
|
||||
openspec status --change "<name>" --json
|
||||
```
|
||||
确认 specs artifact 已完成(`status: "done"`)
|
||||
|
||||
3. **读取 spec 文件**
|
||||
|
||||
读取 `openspec/changes/<change-name>/specs/*/spec.md` 下的所有 spec 文件。
|
||||
|
||||
4. **解析 Scenarios**
|
||||
|
||||
从每个 spec 文件中提取 `#### Scenario:` 块:
|
||||
```markdown
|
||||
#### Scenario: 成功创建套餐
|
||||
- **GIVEN** 用户已登录且有创建权限
|
||||
- **WHEN** POST /api/admin/packages with valid data
|
||||
- **THEN** 返回 200 和套餐详情
|
||||
```
|
||||
|
||||
5. **解析 Business Flows**(如果存在)
|
||||
|
||||
从 spec 文件中提取 `### Flow:` 块,包含多步骤业务场景。
|
||||
|
||||
6. **生成验收测试**
|
||||
|
||||
输出路径:`tests/acceptance/<capability>_acceptance_test.go`
|
||||
|
||||
模板结构:
|
||||
```go
|
||||
func Test{Capability}_Acceptance(t *testing.T) {
|
||||
env := testutils.NewIntegrationTestEnv(t)
|
||||
|
||||
t.Run("Scenario_{name}", func(t *testing.T) {
|
||||
// GIVEN: ...
|
||||
// WHEN: ...
|
||||
// THEN: ...
|
||||
// 破坏点:...
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
7. **生成流程测试**
|
||||
|
||||
输出路径:`tests/flows/<capability>_<flow>_flow_test.go`
|
||||
|
||||
模板结构:
|
||||
```go
|
||||
func TestFlow_{FlowName}(t *testing.T) {
|
||||
env := testutils.NewIntegrationTestEnv(t)
|
||||
|
||||
var (
|
||||
// 流程级共享状态
|
||||
)
|
||||
|
||||
t.Run("Step1_{name}", func(t *testing.T) {
|
||||
// 依赖:...
|
||||
// 破坏点:...
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
8. **运行测试验证**
|
||||
|
||||
```bash
|
||||
source .env.local && go test -v ./tests/acceptance/... ./tests/flows/... 2>&1 | head -50
|
||||
```
|
||||
|
||||
**预期**:全部 FAIL(功能未实现,证明测试有效)
|
||||
|
||||
**如果测试 PASS**:说明测试写得太弱,需要加强
|
||||
|
||||
**Output**
|
||||
|
||||
```
|
||||
## 测试生成完成
|
||||
|
||||
**Change:** <change-name>
|
||||
**来源:** specs/<capability>/spec.md
|
||||
|
||||
### 生成的测试文件
|
||||
|
||||
**验收测试** (tests/acceptance/):
|
||||
- <capability>_acceptance_test.go
|
||||
- Scenario_xxx
|
||||
- Scenario_yyy
|
||||
|
||||
**流程测试** (tests/flows/):
|
||||
- <capability>_<flow>_flow_test.go
|
||||
- Step1_xxx
|
||||
- Step2_yyy
|
||||
|
||||
### 验证结果
|
||||
|
||||
$ source .env.local && go test -v ./tests/acceptance/... ./tests/flows/...
|
||||
|
||||
--- FAIL: TestXxx_Acceptance (0.00s)
|
||||
--- FAIL: TestXxx_Acceptance/Scenario_xxx (0.00s)
|
||||
xxx_acceptance_test.go:45: 404 != 200
|
||||
|
||||
✓ 所有测试预期 FAIL(功能未实现)
|
||||
✓ 测试生成完成
|
||||
|
||||
下一步: 开始实现 tasks,每完成一个功能单元运行相关测试验证
|
||||
```
|
||||
|
||||
**Guardrails**
|
||||
|
||||
- 每个 Scenario 必须生成一个测试用例(不要跳过)
|
||||
- 每个测试必须包含"破坏点"注释
|
||||
- 流程测试的 step 必须声明依赖
|
||||
- 使用 IntegrationTestEnv,不要 mock 依赖
|
||||
- 测试必须在功能缺失时 FAIL(不要写永远 PASS 的测试)
|
||||
- 详细模板参考:`.opencode/skills/openspec-generate-acceptance-tests/SKILL.md`
|
||||
5
.claude/settings.json
Normal file
5
.claude/settings.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"enabledPlugins": {
|
||||
"ralph-loop@claude-plugins-official": true
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.0.2"
|
||||
generatedBy: "1.1.1"
|
||||
---
|
||||
|
||||
Implement tasks from an OpenSpec change.
|
||||
|
||||
@@ -6,7 +6,7 @@ compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.0.2"
|
||||
generatedBy: "1.1.1"
|
||||
---
|
||||
|
||||
Archive a completed change in the experimental workflow.
|
||||
|
||||
@@ -6,7 +6,7 @@ compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.0.2"
|
||||
generatedBy: "1.1.1"
|
||||
---
|
||||
|
||||
Archive multiple completed changes in a single operation.
|
||||
|
||||
@@ -6,7 +6,7 @@ compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.0.2"
|
||||
generatedBy: "1.1.1"
|
||||
---
|
||||
|
||||
Continue working on a change by creating the next artifact.
|
||||
|
||||
@@ -6,7 +6,7 @@ compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.0.2"
|
||||
generatedBy: "1.1.1"
|
||||
---
|
||||
|
||||
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
|
||||
|
||||
@@ -6,7 +6,7 @@ compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.0.2"
|
||||
generatedBy: "1.1.1"
|
||||
---
|
||||
|
||||
Fast-forward through artifact creation - generate everything needed to start implementation in one go.
|
||||
|
||||
@@ -1,442 +0,0 @@
|
||||
---
|
||||
name: openspec-generate-acceptance-tests
|
||||
description: 从 Spec 的 Scenarios 和 Business Flows 自动生成验收测试和流程测试。测试在实现前生成,预期全部 FAIL,证明测试有效。
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: junhong
|
||||
version: "1.0"
|
||||
---
|
||||
|
||||
# 测试生成 Skill
|
||||
|
||||
从 Spec 文档自动生成两类测试:
|
||||
1. **验收测试**(Acceptance Tests):从 Scenarios 生成,验证单 API 契约
|
||||
2. **流程测试**(Flow Tests):从 Business Flows 生成,验证多 API 业务场景
|
||||
|
||||
## 触发方式
|
||||
|
||||
```
|
||||
/opsx:gen-tests [change-name]
|
||||
```
|
||||
|
||||
如果不指定 change-name,自动检测当前活跃的 change。
|
||||
|
||||
---
|
||||
|
||||
## 前置条件
|
||||
|
||||
1. Change 必须存在且包含 spec 文件
|
||||
2. Spec 必须包含 `## Scenarios` 部分
|
||||
3. Spec 建议包含 `## Business Flows` 部分(如果有跨 API 场景)
|
||||
|
||||
检查命令:
|
||||
```bash
|
||||
openspec list --json
|
||||
# 确认 change 存在且有 specs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 工作流程
|
||||
|
||||
### Step 1: 读取 Spec 文件
|
||||
|
||||
```bash
|
||||
# 读取 change 的所有 spec 文件
|
||||
cat openspec/changes/<change-name>/specs/<capability>/spec.md
|
||||
```
|
||||
|
||||
### Step 2: 解析 Scenarios
|
||||
|
||||
从 Spec 中提取所有 Scenario:
|
||||
|
||||
```markdown
|
||||
#### Scenario: 成功创建套餐
|
||||
- **GIVEN** 用户已登录且有创建权限
|
||||
- **WHEN** POST /api/admin/packages with valid data
|
||||
- **THEN** 返回 201 和套餐详情
|
||||
- **AND** 数据库中存在该套餐记录
|
||||
```
|
||||
|
||||
解析为结构:
|
||||
```json
|
||||
{
|
||||
"name": "成功创建套餐",
|
||||
"given": ["用户已登录且有创建权限"],
|
||||
"when": {"method": "POST", "path": "/api/admin/packages", "condition": "valid data"},
|
||||
"then": ["返回 201 和套餐详情"],
|
||||
"and": ["数据库中存在该套餐记录"]
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: 解析 Business Flows
|
||||
|
||||
从 Spec 中提取 Business Flow:
|
||||
|
||||
```markdown
|
||||
### Flow: 套餐完整生命周期
|
||||
|
||||
**参与者**: 平台管理员, 代理商
|
||||
|
||||
**流程步骤**:
|
||||
|
||||
1. **创建套餐**
|
||||
- 角色: 平台管理员
|
||||
- 调用: POST /api/admin/packages
|
||||
- 预期: 返回套餐 ID
|
||||
|
||||
2. **分配给代理商**
|
||||
- 角色: 平台管理员
|
||||
- 调用: POST /api/admin/shop-packages
|
||||
- 输入: 套餐 ID + 店铺 ID
|
||||
- 预期: 分配成功
|
||||
|
||||
3. **代理商查看可售套餐**
|
||||
- 角色: 代理商
|
||||
- 调用: GET /api/admin/shop-packages
|
||||
- 预期: 列表包含刚分配的套餐
|
||||
```
|
||||
|
||||
### Step 4: 生成验收测试
|
||||
|
||||
**输出路径**: `tests/acceptance/<capability>_acceptance_test.go`
|
||||
|
||||
```go
|
||||
package acceptance
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"junhong_cmp_fiber/tests/testutils"
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
// 验收测试:套餐管理
|
||||
// 来源:openspec/changes/package-management/specs/package/spec.md
|
||||
// ============================================================
|
||||
|
||||
func TestPackage_Acceptance(t *testing.T) {
|
||||
env := testutils.NewIntegrationTestEnv(t)
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Scenario: 成功创建套餐
|
||||
// GIVEN: 用户已登录且有创建权限
|
||||
// WHEN: POST /api/admin/packages with valid data
|
||||
// THEN: 返回 201 和套餐详情
|
||||
// AND: 数据库中存在该套餐记录
|
||||
//
|
||||
// 破坏点:如果删除 handler.Create 中的 store.Create 调用,此测试将失败
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Scenario_成功创建套餐", func(t *testing.T) {
|
||||
// GIVEN: 用户已登录且有创建权限
|
||||
client := env.AsSuperAdmin()
|
||||
|
||||
// WHEN: POST /api/admin/packages with valid data
|
||||
body := map[string]interface{}{
|
||||
"name": "测试套餐",
|
||||
"description": "测试描述",
|
||||
"price": 9900,
|
||||
"duration": 30,
|
||||
}
|
||||
resp, err := client.Request("POST", "/api/admin/packages", body)
|
||||
require.NoError(t, err)
|
||||
|
||||
// THEN: 返回 201 和套餐详情
|
||||
assert.Equal(t, 201, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
err = resp.JSON(&result)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, int(result["code"].(float64)))
|
||||
|
||||
data := result["data"].(map[string]interface{})
|
||||
packageID := uint(data["id"].(float64))
|
||||
assert.NotZero(t, packageID)
|
||||
|
||||
// AND: 数据库中存在该套餐记录
|
||||
// TODO: 实现后取消注释
|
||||
// pkg, err := env.DB().Package.FindByID(ctx, packageID)
|
||||
// require.NoError(t, err)
|
||||
// assert.Equal(t, "测试套餐", pkg.Name)
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Scenario: 创建套餐参数校验失败
|
||||
// GIVEN: 用户已登录
|
||||
// WHEN: POST /api/admin/packages with invalid data (name empty)
|
||||
// THEN: 返回 400 和错误信息
|
||||
//
|
||||
// 破坏点:如果删除 handler 中的参数校验,此测试将失败
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Scenario_创建套餐参数校验失败", func(t *testing.T) {
|
||||
// GIVEN: 用户已登录
|
||||
client := env.AsSuperAdmin()
|
||||
|
||||
// WHEN: POST /api/admin/packages with invalid data
|
||||
body := map[string]interface{}{
|
||||
"name": "", // 空名称
|
||||
"price": -1, // 负价格
|
||||
}
|
||||
resp, err := client.Request("POST", "/api/admin/packages", body)
|
||||
require.NoError(t, err)
|
||||
|
||||
// THEN: 返回 400 和错误信息
|
||||
assert.Equal(t, 400, resp.StatusCode)
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Step 5: 生成流程测试
|
||||
|
||||
**输出路径**: `tests/flows/<capability>_<flow-name>_flow_test.go`
|
||||
|
||||
```go
|
||||
package flows
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"junhong_cmp_fiber/tests/testutils"
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
// 流程测试:套餐完整生命周期
|
||||
// 来源:openspec/changes/package-management/specs/package/spec.md
|
||||
// 参与者:平台管理员, 代理商
|
||||
// ============================================================
|
||||
|
||||
func TestFlow_PackageLifecycle(t *testing.T) {
|
||||
env := testutils.NewIntegrationTestEnv(t)
|
||||
|
||||
// 流程级共享状态
|
||||
var (
|
||||
packageID uint
|
||||
shopID uint = 1 // 测试店铺 ID
|
||||
)
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Step 1: 创建套餐
|
||||
// 角色: 平台管理员
|
||||
// 调用: POST /api/admin/packages
|
||||
// 预期: 返回套餐 ID
|
||||
//
|
||||
// 破坏点:如果套餐创建 API 不返回 ID,后续步骤无法执行
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Step1_平台管理员创建套餐", func(t *testing.T) {
|
||||
client := env.AsSuperAdmin()
|
||||
|
||||
body := map[string]interface{}{
|
||||
"name": "流程测试套餐",
|
||||
"description": "用于流程测试",
|
||||
"price": 19900,
|
||||
"duration": 30,
|
||||
}
|
||||
resp, err := client.Request("POST", "/api/admin/packages", body)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 201, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
err = resp.JSON(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
data := result["data"].(map[string]interface{})
|
||||
packageID = uint(data["id"].(float64))
|
||||
require.NotZero(t, packageID, "套餐 ID 不能为空")
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Step 2: 分配给代理商
|
||||
// 角色: 平台管理员
|
||||
// 调用: POST /api/admin/shop-packages
|
||||
// 输入: 套餐 ID + 店铺 ID
|
||||
// 预期: 分配成功
|
||||
//
|
||||
// 依赖: Step 1 的 packageID
|
||||
// 破坏点:如果分配 API 不检查套餐是否存在,可能分配无效套餐
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Step2_分配套餐给代理商", func(t *testing.T) {
|
||||
if packageID == 0 {
|
||||
t.Skip("依赖 Step 1 创建的 packageID")
|
||||
}
|
||||
|
||||
client := env.AsSuperAdmin()
|
||||
|
||||
body := map[string]interface{}{
|
||||
"package_id": packageID,
|
||||
"shop_id": shopID,
|
||||
}
|
||||
resp, err := client.Request("POST", "/api/admin/shop-packages", body)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 200, resp.StatusCode)
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Step 3: 代理商查看可售套餐
|
||||
// 角色: 代理商
|
||||
// 调用: GET /api/admin/shop-packages
|
||||
// 预期: 列表包含刚分配的套餐
|
||||
//
|
||||
// 依赖: Step 2 的分配操作
|
||||
// 破坏点:如果查询不按店铺过滤,代理商会看到其他店铺的套餐
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Step3_代理商查看可售套餐", func(t *testing.T) {
|
||||
if packageID == 0 {
|
||||
t.Skip("依赖 Step 1 创建的 packageID")
|
||||
}
|
||||
|
||||
// 以代理商身份请求
|
||||
client := env.AsShopAgent(shopID)
|
||||
|
||||
resp, err := client.Request("GET", "/api/admin/shop-packages", nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
err = resp.JSON(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 验证列表包含刚分配的套餐
|
||||
data := result["data"].(map[string]interface{})
|
||||
list := data["list"].([]interface{})
|
||||
|
||||
found := false
|
||||
for _, item := range list {
|
||||
pkg := item.(map[string]interface{})
|
||||
if uint(pkg["package_id"].(float64)) == packageID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "代理商应该能看到刚分配的套餐")
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Step 6: 运行测试验证
|
||||
|
||||
生成测试后,立即运行验证:
|
||||
|
||||
```bash
|
||||
# 预期全部 FAIL(因为功能尚未实现)
|
||||
source .env.local && go test -v ./tests/acceptance/... ./tests/flows/... 2>&1 | head -50
|
||||
```
|
||||
|
||||
**如果测试 PASS**:
|
||||
- 说明测试写得太弱,没有真正验证功能
|
||||
- 需要加强测试或检查是否功能已存在
|
||||
|
||||
---
|
||||
|
||||
## 测试模板规范
|
||||
|
||||
### 验收测试必须包含
|
||||
|
||||
1. **来源注释**:标明从哪个 spec 文件生成
|
||||
2. **Scenario 注释**:完整的 GIVEN/WHEN/THEN/AND
|
||||
3. **破坏点注释**:说明什么代码变更会导致测试失败
|
||||
4. **清晰的结构**:GIVEN → WHEN → THEN → AND 分块
|
||||
|
||||
### 流程测试必须包含
|
||||
|
||||
1. **来源注释**:标明从哪个 spec 文件生成
|
||||
2. **参与者注释**:涉及哪些角色
|
||||
3. **共享状态声明**:流程中需要传递的数据
|
||||
4. **依赖声明**:每个 step 依赖哪些前置 step
|
||||
5. **破坏点注释**:说明什么代码变更会导致测试失败
|
||||
|
||||
### 破坏点注释示例
|
||||
|
||||
```go
|
||||
// 破坏点:如果删除 handler.Create 中的 store.Create 调用,此测试将失败
|
||||
// 破坏点:如果移除参数校验中的 name 必填检查,此测试将失败
|
||||
// 破坏点:如果查询不按 shop_id 过滤,此测试将失败(会返回其他店铺数据)
|
||||
// 破坏点:如果状态机允许 "已使用" → "未分配" 的转换,此测试将失败
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 输出文件
|
||||
|
||||
| 来源 | 输出 |
|
||||
|------|------|
|
||||
| Spec Scenarios | `tests/acceptance/<capability>_acceptance_test.go` |
|
||||
| Spec Business Flows | `tests/flows/<capability>_<flow>_flow_test.go` |
|
||||
|
||||
---
|
||||
|
||||
## Guardrails
|
||||
|
||||
- **不要跳过 Scenario** - 每个 Scenario 必须对应一个测试用例
|
||||
- **不要省略破坏点** - 每个测试必须有破坏点注释
|
||||
- **不要硬编码 ID** - 使用共享变量传递流程中的 ID
|
||||
- **不要忽略依赖** - 流程测试的 step 必须声明依赖
|
||||
- **不要写永远 PASS 的测试** - 测试必须在功能缺失时 FAIL
|
||||
|
||||
---
|
||||
|
||||
## 与其他 Skills 的关系
|
||||
|
||||
| Skill | 关系 |
|
||||
|-------|------|
|
||||
| `openspec-continue-change` | 生成 spec 后可触发 gen-tests |
|
||||
| `openspec-apply-change` | 实现时参考生成的测试 |
|
||||
| `openspec-verify-change` | 验证时运行生成的测试 |
|
||||
|
||||
---
|
||||
|
||||
## 示例交互
|
||||
|
||||
```
|
||||
User: /opsx:gen-tests package-management
|
||||
|
||||
AI: 正在分析 change: package-management
|
||||
|
||||
找到 spec 文件: openspec/changes/package-management/specs/package/spec.md
|
||||
|
||||
解析结果:
|
||||
- Scenarios: 5 个
|
||||
- Business Flows: 2 个
|
||||
|
||||
生成测试文件:
|
||||
|
||||
1. tests/acceptance/package_acceptance_test.go
|
||||
- Scenario_成功创建套餐
|
||||
- Scenario_创建套餐参数校验失败
|
||||
- Scenario_成功更新套餐
|
||||
- Scenario_成功删除套餐
|
||||
- Scenario_删除已分配套餐失败
|
||||
|
||||
2. tests/flows/package_lifecycle_flow_test.go
|
||||
- Step1_平台管理员创建套餐
|
||||
- Step2_分配套餐给代理商
|
||||
- Step3_代理商查看可售套餐
|
||||
- Step4_代理商创建订单
|
||||
- Step5_套餐使用统计更新
|
||||
|
||||
3. tests/flows/package_allocation_flow_test.go
|
||||
- Step1_批量分配套餐
|
||||
- Step2_验证分配结果
|
||||
- Step3_撤销部分分配
|
||||
|
||||
运行验证:
|
||||
$ source .env.local && go test -v ./tests/acceptance/package_... ./tests/flows/package_...
|
||||
|
||||
--- FAIL: TestPackage_Acceptance (0.00s)
|
||||
--- FAIL: TestPackage_Acceptance/Scenario_成功创建套餐 (0.00s)
|
||||
package_acceptance_test.go:45: 404 != 201
|
||||
--- FAIL: TestFlow_PackageLifecycle (0.00s)
|
||||
--- FAIL: TestFlow_PackageLifecycle/Step1_平台管理员创建套餐 (0.00s)
|
||||
package_lifecycle_flow_test.go:38: 404 != 201
|
||||
|
||||
✓ 所有测试预期 FAIL(功能未实现)
|
||||
✓ 测试生成完成
|
||||
|
||||
下一步: /opsx:continue 生成 design 和 tasks
|
||||
```
|
||||
@@ -6,7 +6,7 @@ compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.0.2"
|
||||
generatedBy: "1.1.1"
|
||||
---
|
||||
|
||||
Start a new change using the experimental artifact-driven approach.
|
||||
|
||||
@@ -6,7 +6,7 @@ compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.0.2"
|
||||
generatedBy: "1.1.1"
|
||||
---
|
||||
|
||||
Guide the user through their first complete OpenSpec workflow cycle. This is a teaching experience—you'll do real work in their codebase while explaining each step.
|
||||
|
||||
@@ -6,7 +6,7 @@ compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.0.2"
|
||||
generatedBy: "1.1.1"
|
||||
---
|
||||
|
||||
Sync delta specs from a change to main specs.
|
||||
|
||||
@@ -6,7 +6,7 @@ compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.0.2"
|
||||
generatedBy: "1.1.1"
|
||||
---
|
||||
|
||||
Verify that an implementation matches the change artifacts (specs, tasks, design).
|
||||
|
||||
@@ -6,7 +6,7 @@ compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.0.2"
|
||||
generatedBy: "1.1.1"
|
||||
---
|
||||
|
||||
Implement tasks from an OpenSpec change.
|
||||
|
||||
@@ -6,7 +6,7 @@ compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.0.2"
|
||||
generatedBy: "1.1.1"
|
||||
---
|
||||
|
||||
Archive a completed change in the experimental workflow.
|
||||
|
||||
@@ -6,7 +6,7 @@ compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.0.2"
|
||||
generatedBy: "1.1.1"
|
||||
---
|
||||
|
||||
Archive multiple completed changes in a single operation.
|
||||
|
||||
@@ -6,7 +6,7 @@ compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.0.2"
|
||||
generatedBy: "1.1.1"
|
||||
---
|
||||
|
||||
Continue working on a change by creating the next artifact.
|
||||
|
||||
@@ -6,7 +6,7 @@ compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.0.2"
|
||||
generatedBy: "1.1.1"
|
||||
---
|
||||
|
||||
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
|
||||
|
||||
@@ -6,7 +6,7 @@ compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.0.2"
|
||||
generatedBy: "1.1.1"
|
||||
---
|
||||
|
||||
Fast-forward through artifact creation - generate everything needed to start implementation in one go.
|
||||
|
||||
@@ -6,7 +6,7 @@ compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.0.2"
|
||||
generatedBy: "1.1.1"
|
||||
---
|
||||
|
||||
Start a new change using the experimental artifact-driven approach.
|
||||
|
||||
@@ -6,7 +6,7 @@ compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.0.2"
|
||||
generatedBy: "1.1.1"
|
||||
---
|
||||
|
||||
Guide the user through their first complete OpenSpec workflow cycle. This is a teaching experience—you'll do real work in their codebase while explaining each step.
|
||||
|
||||
@@ -6,7 +6,7 @@ compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.0.2"
|
||||
generatedBy: "1.1.1"
|
||||
---
|
||||
|
||||
Sync delta specs from a change to main specs.
|
||||
|
||||
@@ -6,7 +6,7 @@ compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.0.2"
|
||||
generatedBy: "1.1.1"
|
||||
---
|
||||
|
||||
Verify that an implementation matches the change artifacts (specs, tasks, design).
|
||||
|
||||
@@ -4,7 +4,7 @@ description: Implement tasks from an OpenSpec change (Experimental)
|
||||
|
||||
Implement tasks from an OpenSpec change.
|
||||
|
||||
**Input**: Optionally specify a change name (e.g., `/opsx:apply add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
**Input**: Optionally specify a change name (e.g., `/opsx-apply add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
|
||||
**Steps**
|
||||
|
||||
@@ -15,7 +15,7 @@ Implement tasks from an OpenSpec change.
|
||||
- Auto-select if only one active change exists
|
||||
- If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select
|
||||
|
||||
Always announce: "Using change: <name>" and how to override (e.g., `/opsx:apply <other>`).
|
||||
Always announce: "Using change: <name>" and how to override (e.g., `/opsx-apply <other>`).
|
||||
|
||||
2. **Check status to understand the schema**
|
||||
```bash
|
||||
@@ -38,7 +38,7 @@ Implement tasks from an OpenSpec change.
|
||||
- Dynamic instruction based on current state
|
||||
|
||||
**Handle states:**
|
||||
- If `state: "blocked"` (missing artifacts): show message, suggest using `/opsx:continue`
|
||||
- If `state: "blocked"` (missing artifacts): show message, suggest using `/opsx-continue`
|
||||
- If `state: "all_done"`: congratulate, suggest archive
|
||||
- Otherwise: proceed to implementation
|
||||
|
||||
@@ -108,7 +108,7 @@ Working on task 4/7: <task description>
|
||||
- [x] Task 2
|
||||
...
|
||||
|
||||
All tasks complete! Ready to archive this change.
|
||||
All tasks complete! You can archive this change with `/opsx-archive`.
|
||||
```
|
||||
|
||||
**Output On Pause (Issue Encountered)**
|
||||
|
||||
@@ -4,7 +4,7 @@ description: Archive a completed change in the experimental workflow
|
||||
|
||||
Archive a completed change in the experimental workflow.
|
||||
|
||||
**Input**: Optionally specify a change name after `/opsx:archive` (e.g., `/opsx:archive add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
**Input**: Optionally specify a change name after `/opsx-archive` (e.g., `/opsx-archive add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
|
||||
**Steps**
|
||||
|
||||
@@ -56,7 +56,7 @@ Archive a completed change in the experimental workflow.
|
||||
- If changes needed: "Sync now (recommended)", "Archive without syncing"
|
||||
- If already synced: "Archive now", "Sync anyway", "Cancel"
|
||||
|
||||
If user chooses sync, execute `/opsx:sync` logic. Proceed to archive regardless of choice.
|
||||
If user chooses sync, execute `/opsx-sync` logic. Proceed to archive regardless of choice.
|
||||
|
||||
5. **Perform the archive**
|
||||
|
||||
@@ -150,5 +150,5 @@ Target archive directory already exists.
|
||||
- Don't block archive on warnings - just inform and confirm
|
||||
- Preserve .openspec.yaml when moving to archive (it moves with the directory)
|
||||
- Show clear summary of what happened
|
||||
- If sync is requested, use /opsx:sync approach (agent-driven)
|
||||
- If sync is requested, use /opsx-sync approach (agent-driven)
|
||||
- If delta specs exist, always run the sync assessment and show the combined summary before prompting
|
||||
|
||||
@@ -222,7 +222,7 @@ Failed K changes:
|
||||
```
|
||||
## No Changes to Archive
|
||||
|
||||
No active changes found. Use `/opsx:new` to create a new change.
|
||||
No active changes found. Use `/opsx-new` to create a new change.
|
||||
```
|
||||
|
||||
**Guardrails**
|
||||
|
||||
@@ -4,7 +4,7 @@ description: Continue working on a change - create the next artifact (Experiment
|
||||
|
||||
Continue working on a change by creating the next artifact.
|
||||
|
||||
**Input**: Optionally specify a change name after `/opsx:continue` (e.g., `/opsx:continue add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
**Input**: Optionally specify a change name after `/opsx-continue` (e.g., `/opsx-continue add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
|
||||
**Steps**
|
||||
|
||||
@@ -38,7 +38,7 @@ Continue working on a change by creating the next artifact.
|
||||
**If all artifacts are complete (`isComplete: true`)**:
|
||||
- Congratulate the user
|
||||
- Show final status including the schema used
|
||||
- Suggest: "All artifacts created! You can now implement this change or archive it."
|
||||
- Suggest: "All artifacts created! You can now implement this change with `/opsx-apply` or archive it with `/opsx-archive`."
|
||||
- STOP
|
||||
|
||||
---
|
||||
@@ -82,7 +82,7 @@ After each invocation, show:
|
||||
- Schema workflow being used
|
||||
- Current progress (N/M complete)
|
||||
- What artifacts are now unlocked
|
||||
- Prompt: "Run `/opsx:continue` to create the next artifact"
|
||||
- Prompt: "Run `/opsx-continue` to create the next artifact"
|
||||
|
||||
**Artifact Creation Guidelines**
|
||||
|
||||
|
||||
@@ -4,11 +4,11 @@ description: Enter explore mode - think through ideas, investigate problems, cla
|
||||
|
||||
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
|
||||
|
||||
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first (e.g., start a change with `/opsx:new` or `/opsx:ff`). You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
|
||||
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first (e.g., start a change with `/opsx-new` or `/opsx-ff`). You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
|
||||
|
||||
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
|
||||
|
||||
**Input**: The argument after `/opsx:explore` is whatever the user wants to think about. Could be:
|
||||
**Input**: The argument after `/opsx-explore` is whatever the user wants to think about. Could be:
|
||||
- A vague idea: "real-time collaboration"
|
||||
- A specific problem: "the auth system is getting unwieldy"
|
||||
- A change name: "add-dark-mode" (to explore in context of that change)
|
||||
@@ -98,7 +98,7 @@ If the user mentioned a specific change name, read its artifacts for context.
|
||||
Think freely. When insights crystallize, you might offer:
|
||||
|
||||
- "This feels solid enough to start a change. Want me to create one?"
|
||||
→ Can transition to `/opsx:new` or `/opsx:ff`
|
||||
→ Can transition to `/opsx-new` or `/opsx-ff`
|
||||
- Or keep exploring - no pressure to formalize
|
||||
|
||||
### When a change exists
|
||||
@@ -150,7 +150,7 @@ If the user mentions a change or you detect one is relevant:
|
||||
|
||||
There's no required ending. Discovery might:
|
||||
|
||||
- **Flow into action**: "Ready to start? `/opsx:new` or `/opsx:ff`"
|
||||
- **Flow into action**: "Ready to start? `/opsx-new` or `/opsx-ff`"
|
||||
- **Result in artifact updates**: "Updated design.md with these decisions"
|
||||
- **Just provide clarity**: User has what they need, moves on
|
||||
- **Continue later**: "We can pick this up anytime"
|
||||
|
||||
@@ -4,7 +4,7 @@ description: Create a change and generate all artifacts needed for implementatio
|
||||
|
||||
Fast-forward through artifact creation - generate everything needed to start implementation.
|
||||
|
||||
**Input**: The argument after `/opsx:ff` is the change name (kebab-case), OR a description of what the user wants to build.
|
||||
**Input**: The argument after `/opsx-ff` is the change name (kebab-case), OR a description of what the user wants to build.
|
||||
|
||||
**Steps**
|
||||
|
||||
@@ -74,7 +74,7 @@ After completing all artifacts, summarize:
|
||||
- Change name and location
|
||||
- List of artifacts created with brief descriptions
|
||||
- What's ready: "All artifacts created! Ready for implementation."
|
||||
- Prompt: "Run `/opsx:apply` to start implementing."
|
||||
- Prompt: "Run `/opsx-apply` to start implementing."
|
||||
|
||||
**Artifact Creation Guidelines**
|
||||
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
---
|
||||
description: 从 Spec 的 Scenarios 和 Business Flows 自动生成验收测试和流程测试
|
||||
---
|
||||
|
||||
从 Spec 文档自动生成两类测试:
|
||||
1. **验收测试**(Acceptance Tests):从 Scenarios 生成,验证单 API 契约
|
||||
2. **流程测试**(Flow Tests):从 Business Flows 生成,验证多 API 业务场景
|
||||
|
||||
**Input**: 可选指定 change 名称(如 `/opsx:gen-tests add-auth`)。如果省略,从上下文推断或提示选择。
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **选择 change**
|
||||
|
||||
如果提供了名称,使用它。否则:
|
||||
- 从对话上下文推断
|
||||
- 如果只有一个活跃 change,自动选择
|
||||
- 如果模糊,运行 `openspec list --json` 让用户选择
|
||||
|
||||
2. **检查 change 状态**
|
||||
```bash
|
||||
openspec status --change "<name>" --json
|
||||
```
|
||||
确认 specs artifact 已完成(`status: "done"`)
|
||||
|
||||
3. **读取 spec 文件**
|
||||
|
||||
读取 `openspec/changes/<change-name>/specs/*/spec.md` 下的所有 spec 文件。
|
||||
|
||||
4. **解析 Scenarios**
|
||||
|
||||
从每个 spec 文件中提取 `#### Scenario:` 块:
|
||||
```markdown
|
||||
#### Scenario: 成功创建套餐
|
||||
- **GIVEN** 用户已登录且有创建权限
|
||||
- **WHEN** POST /api/admin/packages with valid data
|
||||
- **THEN** 返回 200 和套餐详情
|
||||
```
|
||||
|
||||
5. **解析 Business Flows**(如果存在)
|
||||
|
||||
从 spec 文件中提取 `### Flow:` 块,包含多步骤业务场景。
|
||||
|
||||
6. **生成验收测试**
|
||||
|
||||
输出路径:`tests/acceptance/<capability>_acceptance_test.go`
|
||||
|
||||
模板结构:
|
||||
```go
|
||||
func Test{Capability}_Acceptance(t *testing.T) {
|
||||
env := testutils.NewIntegrationTestEnv(t)
|
||||
|
||||
t.Run("Scenario_{name}", func(t *testing.T) {
|
||||
// GIVEN: ...
|
||||
// WHEN: ...
|
||||
// THEN: ...
|
||||
// 破坏点:...
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
7. **生成流程测试**
|
||||
|
||||
输出路径:`tests/flows/<capability>_<flow>_flow_test.go`
|
||||
|
||||
模板结构:
|
||||
```go
|
||||
func TestFlow_{FlowName}(t *testing.T) {
|
||||
env := testutils.NewIntegrationTestEnv(t)
|
||||
|
||||
var (
|
||||
// 流程级共享状态
|
||||
)
|
||||
|
||||
t.Run("Step1_{name}", func(t *testing.T) {
|
||||
// 依赖:...
|
||||
// 破坏点:...
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
8. **运行测试验证**
|
||||
|
||||
```bash
|
||||
source .env.local && go test -v ./tests/acceptance/... ./tests/flows/... 2>&1 | head -50
|
||||
```
|
||||
|
||||
**预期**:全部 FAIL(功能未实现,证明测试有效)
|
||||
|
||||
**如果测试 PASS**:说明测试写得太弱,需要加强
|
||||
|
||||
**Output**
|
||||
|
||||
```
|
||||
## 测试生成完成
|
||||
|
||||
**Change:** <change-name>
|
||||
**来源:** specs/<capability>/spec.md
|
||||
|
||||
### 生成的测试文件
|
||||
|
||||
**验收测试** (tests/acceptance/):
|
||||
- <capability>_acceptance_test.go
|
||||
- Scenario_xxx
|
||||
- Scenario_yyy
|
||||
|
||||
**流程测试** (tests/flows/):
|
||||
- <capability>_<flow>_flow_test.go
|
||||
- Step1_xxx
|
||||
- Step2_yyy
|
||||
|
||||
### 验证结果
|
||||
|
||||
$ source .env.local && go test -v ./tests/acceptance/... ./tests/flows/...
|
||||
|
||||
--- FAIL: TestXxx_Acceptance (0.00s)
|
||||
--- FAIL: TestXxx_Acceptance/Scenario_xxx (0.00s)
|
||||
xxx_acceptance_test.go:45: 404 != 200
|
||||
|
||||
✓ 所有测试预期 FAIL(功能未实现)
|
||||
✓ 测试生成完成
|
||||
|
||||
下一步: 开始实现 tasks,每完成一个功能单元运行相关测试验证
|
||||
```
|
||||
|
||||
**Guardrails**
|
||||
|
||||
- 每个 Scenario 必须生成一个测试用例(不要跳过)
|
||||
- 每个测试必须包含"破坏点"注释
|
||||
- 流程测试的 step 必须声明依赖
|
||||
- 使用 IntegrationTestEnv,不要 mock 依赖
|
||||
- 测试必须在功能缺失时 FAIL(不要写永远 PASS 的测试)
|
||||
- 详细模板参考:`.opencode/skills/openspec-generate-acceptance-tests/SKILL.md`
|
||||
@@ -4,7 +4,7 @@ description: Start a new change using the experimental artifact workflow (OPSX)
|
||||
|
||||
Start a new change using the experimental artifact-driven approach.
|
||||
|
||||
**Input**: The argument after `/opsx:new` is the change name (kebab-case), OR a description of what the user wants to build.
|
||||
**Input**: The argument after `/opsx-new` is the change name (kebab-case), OR a description of what the user wants to build.
|
||||
|
||||
**Steps**
|
||||
|
||||
@@ -56,11 +56,11 @@ After completing the steps, summarize:
|
||||
- Schema/workflow being used and its artifact sequence
|
||||
- Current status (0/N artifacts complete)
|
||||
- The template for the first artifact
|
||||
- Prompt: "Ready to create the first artifact? Run `/opsx:continue` or just describe what this change is about and I'll draft it."
|
||||
- Prompt: "Ready to create the first artifact? Run `/opsx-continue` or just describe what this change is about and I'll draft it."
|
||||
|
||||
**Guardrails**
|
||||
- Do NOT create any artifacts yet - just show the instructions
|
||||
- Do NOT advance beyond showing the first artifact template
|
||||
- If the name is invalid (not kebab-case), ask for a valid name
|
||||
- If a change with that name already exists, suggest using `/opsx:continue` instead
|
||||
- If a change with that name already exists, suggest using `/opsx-continue` instead
|
||||
- Pass --schema if using a non-default workflow
|
||||
|
||||
@@ -15,7 +15,7 @@ openspec status --json 2>&1 || echo "NOT_INITIALIZED"
|
||||
```
|
||||
|
||||
**If not initialized:**
|
||||
> OpenSpec isn't set up in this project yet. Run `openspec init` first, then come back to `/opsx:onboard`.
|
||||
> OpenSpec isn't set up in this project yet. Run `openspec init` first, then come back to `/opsx-onboard`.
|
||||
|
||||
Stop here if not initialized.
|
||||
|
||||
@@ -139,7 +139,7 @@ Spend 1-2 minutes investigating the relevant code:
|
||||
│ [Optional: ASCII diagram if helpful] │
|
||||
└─────────────────────────────────────────┘
|
||||
|
||||
Explore mode (`/opsx:explore`) is for this kind of thinking—investigating before implementing. You can use it anytime you need to think through a problem.
|
||||
Explore mode (`/opsx-explore`) is for this kind of thinking—investigating before implementing. You can use it anytime you need to think through a problem.
|
||||
|
||||
Now let's create a change to hold our work.
|
||||
```
|
||||
@@ -452,19 +452,19 @@ This same rhythm works for any size change—a small fix or a major feature.
|
||||
|
||||
| Command | What it does |
|
||||
|---------|--------------|
|
||||
| `/opsx:explore` | Think through problems before/during work |
|
||||
| `/opsx:new` | Start a new change, step through artifacts |
|
||||
| `/opsx:ff` | Fast-forward: create all artifacts at once |
|
||||
| `/opsx:continue` | Continue working on an existing change |
|
||||
| `/opsx:apply` | Implement tasks from a change |
|
||||
| `/opsx:verify` | Verify implementation matches artifacts |
|
||||
| `/opsx:archive` | Archive a completed change |
|
||||
| `/opsx-explore` | Think through problems before/during work |
|
||||
| `/opsx-new` | Start a new change, step through artifacts |
|
||||
| `/opsx-ff` | Fast-forward: create all artifacts at once |
|
||||
| `/opsx-continue` | Continue working on an existing change |
|
||||
| `/opsx-apply` | Implement tasks from a change |
|
||||
| `/opsx-verify` | Verify implementation matches artifacts |
|
||||
| `/opsx-archive` | Archive a completed change |
|
||||
|
||||
---
|
||||
|
||||
## What's Next?
|
||||
|
||||
Try `/opsx:new` or `/opsx:ff` on something you actually want to build. You've got the rhythm now!
|
||||
Try `/opsx-new` or `/opsx-ff` on something you actually want to build. You've got the rhythm now!
|
||||
```
|
||||
|
||||
---
|
||||
@@ -479,8 +479,8 @@ If the user says they need to stop, want to pause, or seem disengaged:
|
||||
No problem! Your change is saved at `openspec/changes/<name>/`.
|
||||
|
||||
To pick up where we left off later:
|
||||
- `/opsx:continue <name>` - Resume artifact creation
|
||||
- `/opsx:apply <name>` - Jump to implementation (if tasks exist)
|
||||
- `/opsx-continue <name>` - Resume artifact creation
|
||||
- `/opsx-apply <name>` - Jump to implementation (if tasks exist)
|
||||
|
||||
The work won't be lost. Come back whenever you're ready.
|
||||
```
|
||||
@@ -496,15 +496,15 @@ If the user says they just want to see the commands or skip the tutorial:
|
||||
|
||||
| Command | What it does |
|
||||
|---------|--------------|
|
||||
| `/opsx:explore` | Think through problems (no code changes) |
|
||||
| `/opsx:new <name>` | Start a new change, step by step |
|
||||
| `/opsx:ff <name>` | Fast-forward: all artifacts at once |
|
||||
| `/opsx:continue <name>` | Continue an existing change |
|
||||
| `/opsx:apply <name>` | Implement tasks |
|
||||
| `/opsx:verify <name>` | Verify implementation |
|
||||
| `/opsx:archive <name>` | Archive when done |
|
||||
| `/opsx-explore` | Think through problems (no code changes) |
|
||||
| `/opsx-new <name>` | Start a new change, step by step |
|
||||
| `/opsx-ff <name>` | Fast-forward: all artifacts at once |
|
||||
| `/opsx-continue <name>` | Continue an existing change |
|
||||
| `/opsx-apply <name>` | Implement tasks |
|
||||
| `/opsx-verify <name>` | Verify implementation |
|
||||
| `/opsx-archive <name>` | Archive when done |
|
||||
|
||||
Try `/opsx:new` to start your first change, or `/opsx:ff` if you want to move fast.
|
||||
Try `/opsx-new` to start your first change, or `/opsx-ff` if you want to move fast.
|
||||
```
|
||||
|
||||
Exit gracefully.
|
||||
|
||||
@@ -6,7 +6,7 @@ Sync delta specs from a change to main specs.
|
||||
|
||||
This is an **agent-driven** operation - you will read delta specs and directly edit main specs to apply the changes. This allows intelligent merging (e.g., adding a scenario without copying the entire requirement).
|
||||
|
||||
**Input**: Optionally specify a change name after `/opsx:sync` (e.g., `/opsx:sync add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
**Input**: Optionally specify a change name after `/opsx-sync` (e.g., `/opsx-sync add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
|
||||
**Steps**
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description: Verify implementation matches change artifacts before archiving
|
||||
|
||||
Verify that an implementation matches the change artifacts (specs, tasks, design).
|
||||
|
||||
**Input**: Optionally specify a change name after `/opsx:verify` (e.g., `/opsx:verify add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
**Input**: Optionally specify a change name after `/opsx-verify` (e.g., `/opsx-verify add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
|
||||
**Steps**
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.0.2"
|
||||
generatedBy: "1.1.1"
|
||||
---
|
||||
|
||||
Implement tasks from an OpenSpec change.
|
||||
@@ -22,7 +22,7 @@ Implement tasks from an OpenSpec change.
|
||||
- Auto-select if only one active change exists
|
||||
- If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select
|
||||
|
||||
Always announce: "Using change: <name>" and how to override (e.g., `/opsx:apply <other>`).
|
||||
Always announce: "Using change: <name>" and how to override (e.g., `/opsx-apply <other>`).
|
||||
|
||||
2. **Check status to understand the schema**
|
||||
```bash
|
||||
|
||||
@@ -6,7 +6,7 @@ compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.0.2"
|
||||
generatedBy: "1.1.1"
|
||||
---
|
||||
|
||||
Archive a completed change in the experimental workflow.
|
||||
@@ -63,7 +63,7 @@ Archive a completed change in the experimental workflow.
|
||||
- If changes needed: "Sync now (recommended)", "Archive without syncing"
|
||||
- If already synced: "Archive now", "Sync anyway", "Cancel"
|
||||
|
||||
If user chooses sync, execute /opsx:sync logic (use the openspec-sync-specs skill). Proceed to archive regardless of choice.
|
||||
If user chooses sync, execute /opsx-sync logic (use the openspec-sync-specs skill). Proceed to archive regardless of choice.
|
||||
|
||||
5. **Perform the archive**
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.0.2"
|
||||
generatedBy: "1.1.1"
|
||||
---
|
||||
|
||||
Archive multiple completed changes in a single operation.
|
||||
@@ -229,7 +229,7 @@ Failed K changes:
|
||||
```
|
||||
## No Changes to Archive
|
||||
|
||||
No active changes found. Use `/opsx:new` to create a new change.
|
||||
No active changes found. Use `/opsx-new` to create a new change.
|
||||
```
|
||||
|
||||
**Guardrails**
|
||||
|
||||
@@ -6,7 +6,7 @@ compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.0.2"
|
||||
generatedBy: "1.1.1"
|
||||
---
|
||||
|
||||
Continue working on a change by creating the next artifact.
|
||||
@@ -102,35 +102,7 @@ Common artifact patterns:
|
||||
- The Capabilities section is critical - each capability listed will need a spec file.
|
||||
- **specs/<capability>/spec.md**: Create one spec per capability listed in the proposal's Capabilities section (use the capability name, not the change name).
|
||||
- **design.md**: Document technical decisions, architecture, and implementation approach.
|
||||
- **tasks.md**: Break down implementation into checkboxed tasks, following TDD workflow structure:
|
||||
|
||||
**TDD Tasks Structure (MUST follow)**:
|
||||
```markdown
|
||||
## 0. 测试准备(实现前执行)
|
||||
- [ ] 0.1 生成验收测试和流程测试(/opsx:gen-tests)
|
||||
- [ ] 0.2 运行测试确认全部 FAIL(证明测试有效)
|
||||
|
||||
## 1. 基础设施(数据库 + Model)
|
||||
- [ ] 1.x 创建迁移、Model、DTO
|
||||
- [ ] 1.y 验证:编译通过
|
||||
|
||||
## 2. 功能单元 A(完整垂直切片)
|
||||
- [ ] 2.1 Store 层
|
||||
- [ ] 2.2 Service 层
|
||||
- [ ] 2.3 Handler 层 + 路由
|
||||
- [ ] 2.4 **验证:功能 A 相关验收测试 PASS**
|
||||
|
||||
## N. 最终验证
|
||||
- [ ] N.1 全部验收测试 PASS
|
||||
- [ ] N.2 全部流程测试 PASS
|
||||
- [ ] N.3 完整测试套件无回归
|
||||
```
|
||||
|
||||
**Key principles**:
|
||||
- Task group 0 MUST be test preparation (generate tests + confirm all FAIL)
|
||||
- Organize by functional units, NOT by technical layers (Store/Service/Handler)
|
||||
- Each functional unit MUST end with "verify related tests PASS"
|
||||
- Final validation MUST include all acceptance + flow tests passing
|
||||
- **tasks.md**: Break down implementation into checkboxed tasks.
|
||||
|
||||
For other schemas, follow the `instruction` field from the CLI output.
|
||||
|
||||
|
||||
@@ -6,12 +6,12 @@ compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.0.2"
|
||||
generatedBy: "1.1.1"
|
||||
---
|
||||
|
||||
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
|
||||
|
||||
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first (e.g., start a change with `/opsx:new` or `/opsx:ff`). You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
|
||||
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first (e.g., start a change with `/opsx-new` or `/opsx-ff`). You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
|
||||
|
||||
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
|
||||
|
||||
@@ -96,7 +96,7 @@ This tells you:
|
||||
Think freely. When insights crystallize, you might offer:
|
||||
|
||||
- "This feels solid enough to start a change. Want me to create one?"
|
||||
→ Can transition to `/opsx:new` or `/opsx:ff`
|
||||
→ Can transition to `/opsx-new` or `/opsx-ff`
|
||||
- Or keep exploring - no pressure to formalize
|
||||
|
||||
### When a change exists
|
||||
@@ -202,7 +202,7 @@ You: [reads codebase]
|
||||
|
||||
**User is stuck mid-implementation:**
|
||||
```
|
||||
User: /opsx:explore add-auth-system
|
||||
User: /opsx-explore add-auth-system
|
||||
The OAuth integration is more complex than expected
|
||||
|
||||
You: [reads change artifacts]
|
||||
@@ -252,28 +252,11 @@ You: That changes everything.
|
||||
|
||||
There's no required ending. Discovery might:
|
||||
|
||||
- **Lock consensus first**: "讨论已经比较清晰了,要锁定共识吗?" → `/opsx:lock <name>`
|
||||
- **Flow into action**: "Ready to start? /opsx:new or /opsx:ff"
|
||||
- **Flow into action**: "Ready to start? /opsx-new or /opsx-ff"
|
||||
- **Result in artifact updates**: "Updated design.md with these decisions"
|
||||
- **Just provide clarity**: User has what they need, moves on
|
||||
- **Continue later**: "We can pick this up anytime"
|
||||
|
||||
### 推荐流程
|
||||
|
||||
当讨论涉及重要决策时,**建议先锁定共识再创建变更**:
|
||||
|
||||
```
|
||||
探索讨论 → /opsx:lock → /opsx:new 或 /opsx:ff
|
||||
```
|
||||
|
||||
锁定共识会生成 `consensus.md`,记录:
|
||||
- 要做什么
|
||||
- 不做什么
|
||||
- 关键约束
|
||||
- 验收标准
|
||||
|
||||
后续生成 proposal 时会自动验证是否符合共识。
|
||||
|
||||
When it feels like things are crystallizing, you might summarize:
|
||||
|
||||
```
|
||||
@@ -286,9 +269,8 @@ When it feels like things are crystallizing, you might summarize:
|
||||
**Open questions**: [if any remain]
|
||||
|
||||
**Next steps** (if ready):
|
||||
- Lock consensus: /opsx:lock <name> (推荐先锁定)
|
||||
- Create a change: /opsx:new <name>
|
||||
- Fast-forward to tasks: /opsx:ff <name>
|
||||
- Create a change: /opsx-new <name>
|
||||
- Fast-forward to tasks: /opsx-ff <name>
|
||||
- Keep exploring: just keep talking
|
||||
```
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.0.2"
|
||||
generatedBy: "1.1.1"
|
||||
---
|
||||
|
||||
Fast-forward through artifact creation - generate everything needed to start implementation in one go.
|
||||
@@ -81,7 +81,7 @@ After completing all artifacts, summarize:
|
||||
- Change name and location
|
||||
- List of artifacts created with brief descriptions
|
||||
- What's ready: "All artifacts created! Ready for implementation."
|
||||
- Prompt: "Run `/opsx:apply` or ask me to implement to start working on the tasks."
|
||||
- Prompt: "Run `/opsx-apply` or ask me to implement to start working on the tasks."
|
||||
|
||||
**Artifact Creation Guidelines**
|
||||
|
||||
|
||||
@@ -1,442 +0,0 @@
|
||||
---
|
||||
name: openspec-generate-acceptance-tests
|
||||
description: 从 Spec 的 Scenarios 和 Business Flows 自动生成验收测试和流程测试。测试在实现前生成,预期全部 FAIL,证明测试有效。
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: junhong
|
||||
version: "1.0"
|
||||
---
|
||||
|
||||
# 测试生成 Skill
|
||||
|
||||
从 Spec 文档自动生成两类测试:
|
||||
1. **验收测试**(Acceptance Tests):从 Scenarios 生成,验证单 API 契约
|
||||
2. **流程测试**(Flow Tests):从 Business Flows 生成,验证多 API 业务场景
|
||||
|
||||
## 触发方式
|
||||
|
||||
```
|
||||
/opsx:gen-tests [change-name]
|
||||
```
|
||||
|
||||
如果不指定 change-name,自动检测当前活跃的 change。
|
||||
|
||||
---
|
||||
|
||||
## 前置条件
|
||||
|
||||
1. Change 必须存在且包含 spec 文件
|
||||
2. Spec 必须包含 `## Scenarios` 部分
|
||||
3. Spec 建议包含 `## Business Flows` 部分(如果有跨 API 场景)
|
||||
|
||||
检查命令:
|
||||
```bash
|
||||
openspec list --json
|
||||
# 确认 change 存在且有 specs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 工作流程
|
||||
|
||||
### Step 1: 读取 Spec 文件
|
||||
|
||||
```bash
|
||||
# 读取 change 的所有 spec 文件
|
||||
cat openspec/changes/<change-name>/specs/<capability>/spec.md
|
||||
```
|
||||
|
||||
### Step 2: 解析 Scenarios
|
||||
|
||||
从 Spec 中提取所有 Scenario:
|
||||
|
||||
```markdown
|
||||
#### Scenario: 成功创建套餐
|
||||
- **GIVEN** 用户已登录且有创建权限
|
||||
- **WHEN** POST /api/admin/packages with valid data
|
||||
- **THEN** 返回 201 和套餐详情
|
||||
- **AND** 数据库中存在该套餐记录
|
||||
```
|
||||
|
||||
解析为结构:
|
||||
```json
|
||||
{
|
||||
"name": "成功创建套餐",
|
||||
"given": ["用户已登录且有创建权限"],
|
||||
"when": {"method": "POST", "path": "/api/admin/packages", "condition": "valid data"},
|
||||
"then": ["返回 201 和套餐详情"],
|
||||
"and": ["数据库中存在该套餐记录"]
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: 解析 Business Flows
|
||||
|
||||
从 Spec 中提取 Business Flow:
|
||||
|
||||
```markdown
|
||||
### Flow: 套餐完整生命周期
|
||||
|
||||
**参与者**: 平台管理员, 代理商
|
||||
|
||||
**流程步骤**:
|
||||
|
||||
1. **创建套餐**
|
||||
- 角色: 平台管理员
|
||||
- 调用: POST /api/admin/packages
|
||||
- 预期: 返回套餐 ID
|
||||
|
||||
2. **分配给代理商**
|
||||
- 角色: 平台管理员
|
||||
- 调用: POST /api/admin/shop-packages
|
||||
- 输入: 套餐 ID + 店铺 ID
|
||||
- 预期: 分配成功
|
||||
|
||||
3. **代理商查看可售套餐**
|
||||
- 角色: 代理商
|
||||
- 调用: GET /api/admin/shop-packages
|
||||
- 预期: 列表包含刚分配的套餐
|
||||
```
|
||||
|
||||
### Step 4: 生成验收测试
|
||||
|
||||
**输出路径**: `tests/acceptance/<capability>_acceptance_test.go`
|
||||
|
||||
```go
|
||||
package acceptance
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"junhong_cmp_fiber/tests/testutils"
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
// 验收测试:套餐管理
|
||||
// 来源:openspec/changes/package-management/specs/package/spec.md
|
||||
// ============================================================
|
||||
|
||||
func TestPackage_Acceptance(t *testing.T) {
|
||||
env := testutils.NewIntegrationTestEnv(t)
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Scenario: 成功创建套餐
|
||||
// GIVEN: 用户已登录且有创建权限
|
||||
// WHEN: POST /api/admin/packages with valid data
|
||||
// THEN: 返回 201 和套餐详情
|
||||
// AND: 数据库中存在该套餐记录
|
||||
//
|
||||
// 破坏点:如果删除 handler.Create 中的 store.Create 调用,此测试将失败
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Scenario_成功创建套餐", func(t *testing.T) {
|
||||
// GIVEN: 用户已登录且有创建权限
|
||||
client := env.AsSuperAdmin()
|
||||
|
||||
// WHEN: POST /api/admin/packages with valid data
|
||||
body := map[string]interface{}{
|
||||
"name": "测试套餐",
|
||||
"description": "测试描述",
|
||||
"price": 9900,
|
||||
"duration": 30,
|
||||
}
|
||||
resp, err := client.Request("POST", "/api/admin/packages", body)
|
||||
require.NoError(t, err)
|
||||
|
||||
// THEN: 返回 201 和套餐详情
|
||||
assert.Equal(t, 201, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
err = resp.JSON(&result)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, int(result["code"].(float64)))
|
||||
|
||||
data := result["data"].(map[string]interface{})
|
||||
packageID := uint(data["id"].(float64))
|
||||
assert.NotZero(t, packageID)
|
||||
|
||||
// AND: 数据库中存在该套餐记录
|
||||
// TODO: 实现后取消注释
|
||||
// pkg, err := env.DB().Package.FindByID(ctx, packageID)
|
||||
// require.NoError(t, err)
|
||||
// assert.Equal(t, "测试套餐", pkg.Name)
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Scenario: 创建套餐参数校验失败
|
||||
// GIVEN: 用户已登录
|
||||
// WHEN: POST /api/admin/packages with invalid data (name empty)
|
||||
// THEN: 返回 400 和错误信息
|
||||
//
|
||||
// 破坏点:如果删除 handler 中的参数校验,此测试将失败
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Scenario_创建套餐参数校验失败", func(t *testing.T) {
|
||||
// GIVEN: 用户已登录
|
||||
client := env.AsSuperAdmin()
|
||||
|
||||
// WHEN: POST /api/admin/packages with invalid data
|
||||
body := map[string]interface{}{
|
||||
"name": "", // 空名称
|
||||
"price": -1, // 负价格
|
||||
}
|
||||
resp, err := client.Request("POST", "/api/admin/packages", body)
|
||||
require.NoError(t, err)
|
||||
|
||||
// THEN: 返回 400 和错误信息
|
||||
assert.Equal(t, 400, resp.StatusCode)
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Step 5: 生成流程测试
|
||||
|
||||
**输出路径**: `tests/flows/<capability>_<flow-name>_flow_test.go`
|
||||
|
||||
```go
|
||||
package flows
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"junhong_cmp_fiber/tests/testutils"
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
// 流程测试:套餐完整生命周期
|
||||
// 来源:openspec/changes/package-management/specs/package/spec.md
|
||||
// 参与者:平台管理员, 代理商
|
||||
// ============================================================
|
||||
|
||||
func TestFlow_PackageLifecycle(t *testing.T) {
|
||||
env := testutils.NewIntegrationTestEnv(t)
|
||||
|
||||
// 流程级共享状态
|
||||
var (
|
||||
packageID uint
|
||||
shopID uint = 1 // 测试店铺 ID
|
||||
)
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Step 1: 创建套餐
|
||||
// 角色: 平台管理员
|
||||
// 调用: POST /api/admin/packages
|
||||
// 预期: 返回套餐 ID
|
||||
//
|
||||
// 破坏点:如果套餐创建 API 不返回 ID,后续步骤无法执行
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Step1_平台管理员创建套餐", func(t *testing.T) {
|
||||
client := env.AsSuperAdmin()
|
||||
|
||||
body := map[string]interface{}{
|
||||
"name": "流程测试套餐",
|
||||
"description": "用于流程测试",
|
||||
"price": 19900,
|
||||
"duration": 30,
|
||||
}
|
||||
resp, err := client.Request("POST", "/api/admin/packages", body)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 201, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
err = resp.JSON(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
data := result["data"].(map[string]interface{})
|
||||
packageID = uint(data["id"].(float64))
|
||||
require.NotZero(t, packageID, "套餐 ID 不能为空")
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Step 2: 分配给代理商
|
||||
// 角色: 平台管理员
|
||||
// 调用: POST /api/admin/shop-packages
|
||||
// 输入: 套餐 ID + 店铺 ID
|
||||
// 预期: 分配成功
|
||||
//
|
||||
// 依赖: Step 1 的 packageID
|
||||
// 破坏点:如果分配 API 不检查套餐是否存在,可能分配无效套餐
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Step2_分配套餐给代理商", func(t *testing.T) {
|
||||
if packageID == 0 {
|
||||
t.Skip("依赖 Step 1 创建的 packageID")
|
||||
}
|
||||
|
||||
client := env.AsSuperAdmin()
|
||||
|
||||
body := map[string]interface{}{
|
||||
"package_id": packageID,
|
||||
"shop_id": shopID,
|
||||
}
|
||||
resp, err := client.Request("POST", "/api/admin/shop-packages", body)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 200, resp.StatusCode)
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Step 3: 代理商查看可售套餐
|
||||
// 角色: 代理商
|
||||
// 调用: GET /api/admin/shop-packages
|
||||
// 预期: 列表包含刚分配的套餐
|
||||
//
|
||||
// 依赖: Step 2 的分配操作
|
||||
// 破坏点:如果查询不按店铺过滤,代理商会看到其他店铺的套餐
|
||||
// ------------------------------------------------------------
|
||||
t.Run("Step3_代理商查看可售套餐", func(t *testing.T) {
|
||||
if packageID == 0 {
|
||||
t.Skip("依赖 Step 1 创建的 packageID")
|
||||
}
|
||||
|
||||
// 以代理商身份请求
|
||||
client := env.AsShopAgent(shopID)
|
||||
|
||||
resp, err := client.Request("GET", "/api/admin/shop-packages", nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
err = resp.JSON(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 验证列表包含刚分配的套餐
|
||||
data := result["data"].(map[string]interface{})
|
||||
list := data["list"].([]interface{})
|
||||
|
||||
found := false
|
||||
for _, item := range list {
|
||||
pkg := item.(map[string]interface{})
|
||||
if uint(pkg["package_id"].(float64)) == packageID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "代理商应该能看到刚分配的套餐")
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Step 6: 运行测试验证
|
||||
|
||||
生成测试后,立即运行验证:
|
||||
|
||||
```bash
|
||||
# 预期全部 FAIL(因为功能尚未实现)
|
||||
source .env.local && go test -v ./tests/acceptance/... ./tests/flows/... 2>&1 | head -50
|
||||
```
|
||||
|
||||
**如果测试 PASS**:
|
||||
- 说明测试写得太弱,没有真正验证功能
|
||||
- 需要加强测试或检查是否功能已存在
|
||||
|
||||
---
|
||||
|
||||
## 测试模板规范
|
||||
|
||||
### 验收测试必须包含
|
||||
|
||||
1. **来源注释**:标明从哪个 spec 文件生成
|
||||
2. **Scenario 注释**:完整的 GIVEN/WHEN/THEN/AND
|
||||
3. **破坏点注释**:说明什么代码变更会导致测试失败
|
||||
4. **清晰的结构**:GIVEN → WHEN → THEN → AND 分块
|
||||
|
||||
### 流程测试必须包含
|
||||
|
||||
1. **来源注释**:标明从哪个 spec 文件生成
|
||||
2. **参与者注释**:涉及哪些角色
|
||||
3. **共享状态声明**:流程中需要传递的数据
|
||||
4. **依赖声明**:每个 step 依赖哪些前置 step
|
||||
5. **破坏点注释**:说明什么代码变更会导致测试失败
|
||||
|
||||
### 破坏点注释示例
|
||||
|
||||
```go
|
||||
// 破坏点:如果删除 handler.Create 中的 store.Create 调用,此测试将失败
|
||||
// 破坏点:如果移除参数校验中的 name 必填检查,此测试将失败
|
||||
// 破坏点:如果查询不按 shop_id 过滤,此测试将失败(会返回其他店铺数据)
|
||||
// 破坏点:如果状态机允许 "已使用" → "未分配" 的转换,此测试将失败
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 输出文件
|
||||
|
||||
| 来源 | 输出 |
|
||||
|------|------|
|
||||
| Spec Scenarios | `tests/acceptance/<capability>_acceptance_test.go` |
|
||||
| Spec Business Flows | `tests/flows/<capability>_<flow>_flow_test.go` |
|
||||
|
||||
---
|
||||
|
||||
## Guardrails
|
||||
|
||||
- **不要跳过 Scenario** - 每个 Scenario 必须对应一个测试用例
|
||||
- **不要省略破坏点** - 每个测试必须有破坏点注释
|
||||
- **不要硬编码 ID** - 使用共享变量传递流程中的 ID
|
||||
- **不要忽略依赖** - 流程测试的 step 必须声明依赖
|
||||
- **不要写永远 PASS 的测试** - 测试必须在功能缺失时 FAIL
|
||||
|
||||
---
|
||||
|
||||
## 与其他 Skills 的关系
|
||||
|
||||
| Skill | 关系 |
|
||||
|-------|------|
|
||||
| `openspec-continue-change` | 生成 spec 后可触发 gen-tests |
|
||||
| `openspec-apply-change` | 实现时参考生成的测试 |
|
||||
| `openspec-verify-change` | 验证时运行生成的测试 |
|
||||
|
||||
---
|
||||
|
||||
## 示例交互
|
||||
|
||||
```
|
||||
User: /opsx:gen-tests package-management
|
||||
|
||||
AI: 正在分析 change: package-management
|
||||
|
||||
找到 spec 文件: openspec/changes/package-management/specs/package/spec.md
|
||||
|
||||
解析结果:
|
||||
- Scenarios: 5 个
|
||||
- Business Flows: 2 个
|
||||
|
||||
生成测试文件:
|
||||
|
||||
1. tests/acceptance/package_acceptance_test.go
|
||||
- Scenario_成功创建套餐
|
||||
- Scenario_创建套餐参数校验失败
|
||||
- Scenario_成功更新套餐
|
||||
- Scenario_成功删除套餐
|
||||
- Scenario_删除已分配套餐失败
|
||||
|
||||
2. tests/flows/package_lifecycle_flow_test.go
|
||||
- Step1_平台管理员创建套餐
|
||||
- Step2_分配套餐给代理商
|
||||
- Step3_代理商查看可售套餐
|
||||
- Step4_代理商创建订单
|
||||
- Step5_套餐使用统计更新
|
||||
|
||||
3. tests/flows/package_allocation_flow_test.go
|
||||
- Step1_批量分配套餐
|
||||
- Step2_验证分配结果
|
||||
- Step3_撤销部分分配
|
||||
|
||||
运行验证:
|
||||
$ source .env.local && go test -v ./tests/acceptance/package_... ./tests/flows/package_...
|
||||
|
||||
--- FAIL: TestPackage_Acceptance (0.00s)
|
||||
--- FAIL: TestPackage_Acceptance/Scenario_成功创建套餐 (0.00s)
|
||||
package_acceptance_test.go:45: 404 != 201
|
||||
--- FAIL: TestFlow_PackageLifecycle (0.00s)
|
||||
--- FAIL: TestFlow_PackageLifecycle/Step1_平台管理员创建套餐 (0.00s)
|
||||
package_lifecycle_flow_test.go:38: 404 != 201
|
||||
|
||||
✓ 所有测试预期 FAIL(功能未实现)
|
||||
✓ 测试生成完成
|
||||
|
||||
下一步: /opsx:continue 生成 design 和 tasks
|
||||
```
|
||||
@@ -6,7 +6,7 @@ compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.0.2"
|
||||
generatedBy: "1.1.1"
|
||||
---
|
||||
|
||||
Start a new change using the experimental artifact-driven approach.
|
||||
|
||||
@@ -6,7 +6,7 @@ compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.0.2"
|
||||
generatedBy: "1.1.1"
|
||||
---
|
||||
|
||||
Guide the user through their first complete OpenSpec workflow cycle. This is a teaching experience—you'll do real work in their codebase while explaining each step.
|
||||
@@ -22,7 +22,7 @@ openspec status --json 2>&1 || echo "NOT_INITIALIZED"
|
||||
```
|
||||
|
||||
**If not initialized:**
|
||||
> OpenSpec isn't set up in this project yet. Run `openspec init` first, then come back to `/opsx:onboard`.
|
||||
> OpenSpec isn't set up in this project yet. Run `openspec init` first, then come back to `/opsx-onboard`.
|
||||
|
||||
Stop here if not initialized.
|
||||
|
||||
@@ -146,7 +146,7 @@ Spend 1-2 minutes investigating the relevant code:
|
||||
│ [Optional: ASCII diagram if helpful] │
|
||||
└─────────────────────────────────────────┘
|
||||
|
||||
Explore mode (`/opsx:explore`) is for this kind of thinking—investigating before implementing. You can use it anytime you need to think through a problem.
|
||||
Explore mode (`/opsx-explore`) is for this kind of thinking—investigating before implementing. You can use it anytime you need to think through a problem.
|
||||
|
||||
Now let's create a change to hold our work.
|
||||
```
|
||||
@@ -459,19 +459,19 @@ This same rhythm works for any size change—a small fix or a major feature.
|
||||
|
||||
| Command | What it does |
|
||||
|---------|--------------|
|
||||
| `/opsx:explore` | Think through problems before/during work |
|
||||
| `/opsx:new` | Start a new change, step through artifacts |
|
||||
| `/opsx:ff` | Fast-forward: create all artifacts at once |
|
||||
| `/opsx:continue` | Continue working on an existing change |
|
||||
| `/opsx:apply` | Implement tasks from a change |
|
||||
| `/opsx:verify` | Verify implementation matches artifacts |
|
||||
| `/opsx:archive` | Archive a completed change |
|
||||
| `/opsx-explore` | Think through problems before/during work |
|
||||
| `/opsx-new` | Start a new change, step through artifacts |
|
||||
| `/opsx-ff` | Fast-forward: create all artifacts at once |
|
||||
| `/opsx-continue` | Continue working on an existing change |
|
||||
| `/opsx-apply` | Implement tasks from a change |
|
||||
| `/opsx-verify` | Verify implementation matches artifacts |
|
||||
| `/opsx-archive` | Archive a completed change |
|
||||
|
||||
---
|
||||
|
||||
## What's Next?
|
||||
|
||||
Try `/opsx:new` or `/opsx:ff` on something you actually want to build. You've got the rhythm now!
|
||||
Try `/opsx-new` or `/opsx-ff` on something you actually want to build. You've got the rhythm now!
|
||||
```
|
||||
|
||||
---
|
||||
@@ -486,8 +486,8 @@ If the user says they need to stop, want to pause, or seem disengaged:
|
||||
No problem! Your change is saved at `openspec/changes/<name>/`.
|
||||
|
||||
To pick up where we left off later:
|
||||
- `/opsx:continue <name>` - Resume artifact creation
|
||||
- `/opsx:apply <name>` - Jump to implementation (if tasks exist)
|
||||
- `/opsx-continue <name>` - Resume artifact creation
|
||||
- `/opsx-apply <name>` - Jump to implementation (if tasks exist)
|
||||
|
||||
The work won't be lost. Come back whenever you're ready.
|
||||
```
|
||||
@@ -503,15 +503,15 @@ If the user says they just want to see the commands or skip the tutorial:
|
||||
|
||||
| Command | What it does |
|
||||
|---------|--------------|
|
||||
| `/opsx:explore` | Think through problems (no code changes) |
|
||||
| `/opsx:new <name>` | Start a new change, step by step |
|
||||
| `/opsx:ff <name>` | Fast-forward: all artifacts at once |
|
||||
| `/opsx:continue <name>` | Continue an existing change |
|
||||
| `/opsx:apply <name>` | Implement tasks |
|
||||
| `/opsx:verify <name>` | Verify implementation |
|
||||
| `/opsx:archive <name>` | Archive when done |
|
||||
| `/opsx-explore` | Think through problems (no code changes) |
|
||||
| `/opsx-new <name>` | Start a new change, step by step |
|
||||
| `/opsx-ff <name>` | Fast-forward: all artifacts at once |
|
||||
| `/opsx-continue <name>` | Continue an existing change |
|
||||
| `/opsx-apply <name>` | Implement tasks |
|
||||
| `/opsx-verify <name>` | Verify implementation |
|
||||
| `/opsx-archive <name>` | Archive when done |
|
||||
|
||||
Try `/opsx:new` to start your first change, or `/opsx:ff` if you want to move fast.
|
||||
Try `/opsx-new` to start your first change, or `/opsx-ff` if you want to move fast.
|
||||
```
|
||||
|
||||
Exit gracefully.
|
||||
|
||||
@@ -6,7 +6,7 @@ compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.0.2"
|
||||
generatedBy: "1.1.1"
|
||||
---
|
||||
|
||||
Sync delta specs from a change to main specs.
|
||||
|
||||
@@ -6,7 +6,7 @@ compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.0.2"
|
||||
generatedBy: "1.1.1"
|
||||
---
|
||||
|
||||
Verify that an implementation matches the change artifacts (specs, tasks, design).
|
||||
|
||||
170
CLAUDE.md
170
CLAUDE.md
@@ -132,147 +132,33 @@ Handler → Service → Store → Model
|
||||
- 异常处理(panic/recover)
|
||||
- 类型前缀(IService、AbstractBase、ServiceImpl)
|
||||
|
||||
## 测试要求
|
||||
## ⚠️ 测试禁令(强制执行)
|
||||
|
||||
### 测试金字塔(新)
|
||||
**本项目不使用任何形式的自动化测试代码。**
|
||||
|
||||
```
|
||||
┌─────────────┐
|
||||
│ E2E 测试 │ ← 手动/自动化 UI(很少)
|
||||
─┴─────────────┴─
|
||||
┌─────────────────┐
|
||||
│ 业务流程测试 │ ← 15%:多 API 组合验证
|
||||
│ tests/flows/ │ 来源:Spec Business Flow
|
||||
─┴─────────────────┴─
|
||||
┌─────────────────────┐
|
||||
│ 验收测试 │ ← 30%:单 API 契约验证
|
||||
│ tests/acceptance/ │ 来源:Spec Scenario
|
||||
─┴─────────────────────┴─
|
||||
┌───────────────────────────┐
|
||||
│ 集成测试 │ ← 25%:组件集成
|
||||
─┴───────────────────────────┴─
|
||||
┌─────────────────────────────────┐
|
||||
│ 单元测试(精简) │ ← 30%:仅复杂逻辑
|
||||
└─────────────────────────────────┘
|
||||
```
|
||||
**绝对禁止:**
|
||||
- ❌ **禁止编写单元测试** - 无论任何场景
|
||||
- ❌ **禁止编写集成测试** - 无论任何场景
|
||||
- ❌ **禁止编写验收测试** - 无论任何场景
|
||||
- ❌ **禁止编写流程测试** - 无论任何场景
|
||||
- ❌ **禁止编写 E2E 测试** - 无论任何场景
|
||||
- ❌ **禁止创建 `*_test.go` 文件** - 除非用户明确要求
|
||||
- ❌ **禁止在任务中包含测试相关工作** - 规划和实现均不涉及测试
|
||||
- ❌ **禁止在文档中提及测试要求** - 规范、设计文档均不讨论测试
|
||||
|
||||
### 三层测试体系
|
||||
**唯一例外:**
|
||||
- ✅ **仅当用户明确要求**时才编写测试代码
|
||||
- ✅ 用户必须主动说明"请写测试"或"需要测试"
|
||||
|
||||
| 层级 | 测试类型 | 来源 | 验证什么 | 位置 |
|
||||
|------|---------|------|---------|------|
|
||||
| **L1** | 验收测试 | Spec Scenario | 单 API 契约 | `tests/acceptance/` |
|
||||
| **L2** | 流程测试 | Spec Business Flow | 业务场景完整性 | `tests/flows/` |
|
||||
| **L3** | 单元测试 | 复杂逻辑 | 算法/规则正确性 | 模块内 `*_test.go` |
|
||||
**原因说明:**
|
||||
- 业务系统的正确性通过人工验证和生产环境监控保证
|
||||
- 测试代码的维护成本高于价值
|
||||
- 快速迭代优先于测试覆盖率
|
||||
|
||||
### 验收测试规范
|
||||
|
||||
- **来源于 Spec**:每个 Scenario 对应一个测试用例
|
||||
- **测试先于实现**:在功能实现前生成,预期全部 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`
|
||||
**替代方案:**
|
||||
- 使用 PostgreSQL MCP 工具手动验证数据
|
||||
- 使用 Postman/curl 手动测试 API
|
||||
- 依赖生产环境日志和监控发现问题
|
||||
|
||||
## 性能要求
|
||||
|
||||
@@ -311,10 +197,9 @@ func TestAPI_Create(t *testing.T) {
|
||||
3. ✅ 使用统一错误处理
|
||||
4. ✅ 常量定义在 pkg/constants/
|
||||
5. ✅ Go 惯用法(非 Java 风格)
|
||||
6. ✅ 包含测试计划
|
||||
7. ✅ 性能考虑
|
||||
8. ✅ 文档更新计划
|
||||
9. ✅ 中文优先
|
||||
6. ✅ 性能考虑
|
||||
7. ✅ 文档更新计划
|
||||
8. ✅ 中文优先
|
||||
|
||||
## Code Review 检查清单
|
||||
|
||||
@@ -330,11 +215,6 @@ func TestAPI_Create(t *testing.T) {
|
||||
- [ ] 常量定义在 `pkg/constants/`
|
||||
- [ ] 使用 Go 惯用法(非 Java 风格)
|
||||
|
||||
### 测试覆盖
|
||||
- [ ] 核心业务逻辑测试覆盖率 ≥ 90%
|
||||
- [ ] 所有 API 端点有集成测试
|
||||
- [ ] 测试验证真实功能(不绕过核心逻辑)
|
||||
|
||||
### 文档和注释
|
||||
- [ ] 所有注释使用中文
|
||||
- [ ] 导出函数/类型有文档注释
|
||||
|
||||
@@ -1136,15 +1136,33 @@ components:
|
||||
type: object
|
||||
DtoCreatePackageRequest:
|
||||
properties:
|
||||
calendar_type:
|
||||
description: 套餐周期类型 (natural_month:自然月, by_day:按天)
|
||||
nullable: true
|
||||
type: string
|
||||
cost_price:
|
||||
description: 成本价(分)
|
||||
minimum: 0
|
||||
type: integer
|
||||
data_reset_cycle:
|
||||
description: 流量重置周期 (daily:每日, monthly:每月, yearly:每年, none:不重置)
|
||||
nullable: true
|
||||
type: string
|
||||
duration_days:
|
||||
description: 套餐天数(calendar_type=by_day时必填)
|
||||
maximum: 3650
|
||||
minimum: 1
|
||||
nullable: true
|
||||
type: integer
|
||||
duration_months:
|
||||
description: 套餐时长(月数)
|
||||
maximum: 120
|
||||
minimum: 1
|
||||
type: integer
|
||||
enable_realname_activation:
|
||||
description: 是否启用实名激活 (true:需实名后激活, false:立即激活)
|
||||
nullable: true
|
||||
type: boolean
|
||||
enable_virtual_data:
|
||||
description: 是否启用虚流量
|
||||
type: boolean
|
||||
@@ -3052,6 +3070,9 @@ components:
|
||||
type: object
|
||||
DtoPackageResponse:
|
||||
properties:
|
||||
calendar_type:
|
||||
description: 套餐周期类型 (natural_month:自然月, by_day:按天)
|
||||
type: string
|
||||
cost_price:
|
||||
description: 成本价(分)
|
||||
type: integer
|
||||
@@ -3061,9 +3082,19 @@ components:
|
||||
current_commission_rate:
|
||||
description: 当前返佣比例(仅代理用户可见)
|
||||
type: string
|
||||
data_reset_cycle:
|
||||
description: 流量重置周期 (daily:每日, monthly:每月, yearly:每年, none:不重置)
|
||||
type: string
|
||||
duration_days:
|
||||
description: 套餐天数(calendar_type=by_day时有值)
|
||||
nullable: true
|
||||
type: integer
|
||||
duration_months:
|
||||
description: 套餐时长(月数)
|
||||
type: integer
|
||||
enable_realname_activation:
|
||||
description: 是否启用实名激活 (true:需实名后激活, false:立即激活)
|
||||
type: boolean
|
||||
enable_virtual_data:
|
||||
description: 是否启用虚流量
|
||||
type: boolean
|
||||
@@ -3169,6 +3200,94 @@ components:
|
||||
description: 更新时间
|
||||
type: string
|
||||
type: object
|
||||
DtoPackageUsageCustomerViewResponse:
|
||||
properties:
|
||||
addon_packages:
|
||||
description: 加油包列表(按priority排序)
|
||||
items:
|
||||
$ref: '#/components/schemas/DtoPackageUsageItemResponse'
|
||||
nullable: true
|
||||
type: array
|
||||
main_package:
|
||||
$ref: '#/components/schemas/DtoPackageUsageItemResponse'
|
||||
total:
|
||||
$ref: '#/components/schemas/DtoPackageUsageTotalInfo'
|
||||
type: object
|
||||
DtoPackageUsageDailyRecordResponse:
|
||||
properties:
|
||||
cumulative_usage_mb:
|
||||
description: 截止当日的累计流量(MB)
|
||||
type: integer
|
||||
daily_usage_mb:
|
||||
description: 当日流量使用量(MB)
|
||||
type: integer
|
||||
date:
|
||||
description: 日期
|
||||
type: string
|
||||
type: object
|
||||
DtoPackageUsageDetailResponse:
|
||||
properties:
|
||||
package_name:
|
||||
description: 套餐名称
|
||||
type: string
|
||||
package_usage_id:
|
||||
description: 套餐使用记录ID
|
||||
minimum: 0
|
||||
type: integer
|
||||
records:
|
||||
description: 流量日记录列表
|
||||
items:
|
||||
$ref: '#/components/schemas/DtoPackageUsageDailyRecordResponse'
|
||||
nullable: true
|
||||
type: array
|
||||
total_usage_mb:
|
||||
description: 总使用流量(MB)
|
||||
type: integer
|
||||
type: object
|
||||
DtoPackageUsageItemResponse:
|
||||
properties:
|
||||
activated_at:
|
||||
description: 激活时间
|
||||
type: string
|
||||
expires_at:
|
||||
description: 过期时间
|
||||
type: string
|
||||
package_id:
|
||||
description: 套餐ID
|
||||
minimum: 0
|
||||
type: integer
|
||||
package_name:
|
||||
description: 套餐名称
|
||||
type: string
|
||||
package_usage_id:
|
||||
description: 套餐使用记录ID
|
||||
minimum: 0
|
||||
type: integer
|
||||
priority:
|
||||
description: 优先级(数字越小优先级越高)
|
||||
type: integer
|
||||
status:
|
||||
description: 状态 (0:待生效, 1:生效中, 2:已用完, 3:已过期, 4:已失效)
|
||||
type: integer
|
||||
status_text:
|
||||
description: 状态文本
|
||||
type: string
|
||||
total_mb:
|
||||
description: 总流量(MB)
|
||||
type: integer
|
||||
used_mb:
|
||||
description: 已使用流量(MB)
|
||||
type: integer
|
||||
type: object
|
||||
DtoPackageUsageTotalInfo:
|
||||
properties:
|
||||
total_mb:
|
||||
description: 总流量(MB)
|
||||
type: integer
|
||||
used_mb:
|
||||
description: 总已使用流量(MB)
|
||||
type: integer
|
||||
type: object
|
||||
DtoPermissionPageResult:
|
||||
properties:
|
||||
items:
|
||||
@@ -4938,17 +5057,35 @@ components:
|
||||
type: object
|
||||
DtoUpdatePackageParams:
|
||||
properties:
|
||||
calendar_type:
|
||||
description: 套餐周期类型 (natural_month:自然月, by_day:按天)
|
||||
nullable: true
|
||||
type: string
|
||||
cost_price:
|
||||
description: 成本价(分)
|
||||
minimum: 0
|
||||
nullable: true
|
||||
type: integer
|
||||
data_reset_cycle:
|
||||
description: 流量重置周期 (daily:每日, monthly:每月, yearly:每年, none:不重置)
|
||||
nullable: true
|
||||
type: string
|
||||
duration_days:
|
||||
description: 套餐天数(calendar_type=by_day时必填)
|
||||
maximum: 3650
|
||||
minimum: 1
|
||||
nullable: true
|
||||
type: integer
|
||||
duration_months:
|
||||
description: 套餐时长(月数)
|
||||
maximum: 120
|
||||
minimum: 1
|
||||
nullable: true
|
||||
type: integer
|
||||
enable_realname_activation:
|
||||
description: 是否启用实名激活 (true:需实名后激活, false:立即激活)
|
||||
nullable: true
|
||||
type: boolean
|
||||
enable_virtual_data:
|
||||
description: 是否启用虚流量
|
||||
nullable: true
|
||||
@@ -12800,6 +12937,73 @@ paths:
|
||||
summary: 更新套餐系列状态
|
||||
tags:
|
||||
- 套餐系列管理
|
||||
/api/admin/package-usage/{id}/daily-records:
|
||||
get:
|
||||
parameters:
|
||||
- description: ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
description: ID
|
||||
minimum: 0
|
||||
type: integer
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
code:
|
||||
description: 响应码
|
||||
example: 0
|
||||
type: integer
|
||||
data:
|
||||
$ref: '#/components/schemas/DtoPackageUsageDetailResponse'
|
||||
msg:
|
||||
description: 响应消息
|
||||
example: success
|
||||
type: string
|
||||
timestamp:
|
||||
description: 时间戳
|
||||
format: date-time
|
||||
type: string
|
||||
required:
|
||||
- code
|
||||
- msg
|
||||
- data
|
||||
- timestamp
|
||||
type: object
|
||||
description: 成功
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
description: 请求参数错误
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
description: 未认证或认证已过期
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
description: 无权访问
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
description: 服务器内部错误
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: 获取套餐流量详单
|
||||
tags:
|
||||
- 套餐使用记录
|
||||
/api/admin/packages:
|
||||
get:
|
||||
parameters:
|
||||
@@ -18863,6 +19067,64 @@ paths:
|
||||
summary: 微信 JSAPI 支付
|
||||
tags:
|
||||
- H5 订单
|
||||
/api/h5/packages/my-usage:
|
||||
get:
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
code:
|
||||
description: 响应码
|
||||
example: 0
|
||||
type: integer
|
||||
data:
|
||||
$ref: '#/components/schemas/DtoPackageUsageCustomerViewResponse'
|
||||
msg:
|
||||
description: 响应消息
|
||||
example: success
|
||||
type: string
|
||||
timestamp:
|
||||
description: 时间戳
|
||||
format: date-time
|
||||
type: string
|
||||
required:
|
||||
- code
|
||||
- msg
|
||||
- data
|
||||
- timestamp
|
||||
type: object
|
||||
description: 成功
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
description: 请求参数错误
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
description: 未认证或认证已过期
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
description: 无权访问
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
description: 服务器内部错误
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: 获取我的套餐使用情况
|
||||
tags:
|
||||
- H5-套餐
|
||||
/api/h5/wallets/recharge:
|
||||
post:
|
||||
requestBody:
|
||||
|
||||
@@ -40,6 +40,8 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
|
||||
Carrier: admin.NewCarrierHandler(svc.Carrier),
|
||||
PackageSeries: admin.NewPackageSeriesHandler(svc.PackageSeries),
|
||||
Package: admin.NewPackageHandler(svc.Package),
|
||||
PackageUsage: admin.NewPackageUsageHandler(svc.PackageDailyRecord),
|
||||
H5PackageUsage: h5.NewPackageUsageHandler(deps.DB, svc.PackageCustomerView),
|
||||
ShopSeriesAllocation: admin.NewShopSeriesAllocationHandler(svc.ShopSeriesAllocation),
|
||||
ShopPackageAllocation: admin.NewShopPackageAllocationHandler(svc.ShopPackageAllocation),
|
||||
ShopPackageBatchAllocation: admin.NewShopPackageBatchAllocationHandler(svc.ShopPackageBatchAllocation),
|
||||
|
||||
@@ -63,6 +63,8 @@ type services struct {
|
||||
Carrier *carrierSvc.Service
|
||||
PackageSeries *packageSeriesSvc.Service
|
||||
Package *packageSvc.Service
|
||||
PackageDailyRecord *packageSvc.DailyRecordService
|
||||
PackageCustomerView *packageSvc.CustomerViewService
|
||||
ShopSeriesAllocation *shopSeriesAllocationSvc.Service
|
||||
ShopPackageAllocation *shopPackageAllocationSvc.Service
|
||||
ShopPackageBatchAllocation *shopPackageBatchAllocationSvc.Service
|
||||
@@ -130,13 +132,15 @@ func initServices(s *stores, deps *Dependencies) *services {
|
||||
Carrier: carrierSvc.New(s.Carrier),
|
||||
PackageSeries: packageSeriesSvc.New(s.PackageSeries),
|
||||
Package: packageSvc.New(s.Package, s.PackageSeries, s.ShopPackageAllocation, s.ShopSeriesAllocation),
|
||||
PackageDailyRecord: packageSvc.NewDailyRecordService(deps.DB, deps.Redis, s.PackageUsageDailyRecord, deps.Logger),
|
||||
PackageCustomerView: packageSvc.NewCustomerViewService(deps.DB, deps.Redis, s.PackageUsage, deps.Logger),
|
||||
ShopSeriesAllocation: shopSeriesAllocationSvc.New(s.ShopSeriesAllocation, s.ShopPackageAllocation, s.Shop, s.PackageSeries),
|
||||
ShopPackageAllocation: shopPackageAllocationSvc.New(s.ShopPackageAllocation, s.ShopSeriesAllocation, s.ShopPackageAllocationPriceHistory, s.Shop, s.Package, s.PackageSeries),
|
||||
ShopPackageBatchAllocation: shopPackageBatchAllocationSvc.New(deps.DB, s.Package, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.Shop),
|
||||
ShopPackageBatchPricing: shopPackageBatchPricingSvc.New(deps.DB, s.ShopPackageAllocation, s.ShopPackageAllocationPriceHistory, s.Shop),
|
||||
CommissionStats: commissionStatsSvc.New(s.ShopSeriesCommissionStats),
|
||||
PurchaseValidation: purchaseValidation,
|
||||
Order: orderSvc.New(deps.DB, s.Order, s.OrderItem, s.Wallet, purchaseValidation, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.IotCard, s.Device, s.PackageSeries, deps.WechatPayment, deps.QueueClient, deps.Logger),
|
||||
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),
|
||||
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),
|
||||
PollingConcurrency: pollingSvc.NewConcurrencyService(s.PollingConcurrencyConfig, deps.Redis),
|
||||
|
||||
@@ -32,6 +32,8 @@ type stores struct {
|
||||
Carrier *postgres.CarrierStore
|
||||
PackageSeries *postgres.PackageSeriesStore
|
||||
Package *postgres.PackageStore
|
||||
PackageUsage *postgres.PackageUsageStore
|
||||
PackageUsageDailyRecord *postgres.PackageUsageDailyRecordStore
|
||||
ShopSeriesAllocation *postgres.ShopSeriesAllocationStore
|
||||
ShopPackageAllocation *postgres.ShopPackageAllocationStore
|
||||
ShopPackageAllocationPriceHistory *postgres.ShopPackageAllocationPriceHistoryStore
|
||||
@@ -77,6 +79,8 @@ func initStores(deps *Dependencies) *stores {
|
||||
Carrier: postgres.NewCarrierStore(deps.DB),
|
||||
PackageSeries: postgres.NewPackageSeriesStore(deps.DB),
|
||||
Package: postgres.NewPackageStore(deps.DB),
|
||||
PackageUsage: postgres.NewPackageUsageStore(deps.DB, deps.Redis),
|
||||
PackageUsageDailyRecord: postgres.NewPackageUsageDailyRecordStore(deps.DB, deps.Redis),
|
||||
ShopSeriesAllocation: postgres.NewShopSeriesAllocationStore(deps.DB),
|
||||
ShopPackageAllocation: postgres.NewShopPackageAllocationStore(deps.DB),
|
||||
ShopPackageAllocationPriceHistory: postgres.NewShopPackageAllocationPriceHistoryStore(deps.DB),
|
||||
|
||||
@@ -38,6 +38,8 @@ type Handlers struct {
|
||||
Carrier *admin.CarrierHandler
|
||||
PackageSeries *admin.PackageSeriesHandler
|
||||
Package *admin.PackageHandler
|
||||
PackageUsage *admin.PackageUsageHandler
|
||||
H5PackageUsage *h5.PackageUsageHandler
|
||||
ShopSeriesAllocation *admin.ShopSeriesAllocationHandler
|
||||
ShopPackageAllocation *admin.ShopPackageAllocationHandler
|
||||
ShopPackageBatchAllocation *admin.ShopPackageBatchAllocationHandler
|
||||
|
||||
@@ -1,323 +0,0 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNewClient(t *testing.T) {
|
||||
client := NewClient("https://test.example.com", "testAppID", "testSecret")
|
||||
|
||||
if client.baseURL != "https://test.example.com" {
|
||||
t.Errorf("baseURL = %s, want https://test.example.com", client.baseURL)
|
||||
}
|
||||
if client.appID != "testAppID" {
|
||||
t.Errorf("appID = %s, want testAppID", client.appID)
|
||||
}
|
||||
if client.appSecret != "testSecret" {
|
||||
t.Errorf("appSecret = %s, want testSecret", client.appSecret)
|
||||
}
|
||||
if client.timeout != 30*time.Second {
|
||||
t.Errorf("timeout = %v, want 30s", client.timeout)
|
||||
}
|
||||
if client.httpClient == nil {
|
||||
t.Error("httpClient should not be nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithTimeout(t *testing.T) {
|
||||
client := NewClient("https://test.example.com", "testAppID", "testSecret").
|
||||
WithTimeout(60 * time.Second)
|
||||
|
||||
if client.timeout != 60*time.Second {
|
||||
t.Errorf("timeout = %v, want 60s", client.timeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithTimeout_Chain(t *testing.T) {
|
||||
// 验证链式调用返回同一个 Client 实例
|
||||
client := NewClient("https://test.example.com", "testAppID", "testSecret")
|
||||
returned := client.WithTimeout(45 * time.Second)
|
||||
|
||||
if returned != client {
|
||||
t.Error("WithTimeout should return the same Client instance for chaining")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoRequest_Success(t *testing.T) {
|
||||
// 创建 mock HTTP 服务器
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// 验证请求方法
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("Method = %s, want POST", r.Method)
|
||||
}
|
||||
|
||||
// 验证 Content-Type
|
||||
if r.Header.Get("Content-Type") != "application/json;charset=utf-8" {
|
||||
t.Errorf("Content-Type = %s, want application/json;charset=utf-8", r.Header.Get("Content-Type"))
|
||||
}
|
||||
|
||||
// 验证请求体格式
|
||||
var reqBody map[string]interface{}
|
||||
if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
|
||||
t.Fatalf("解析请求体失败: %v", err)
|
||||
}
|
||||
|
||||
// 验证必需字段
|
||||
if _, ok := reqBody["appId"]; !ok {
|
||||
t.Error("请求体缺少 appId 字段")
|
||||
}
|
||||
if _, ok := reqBody["data"]; !ok {
|
||||
t.Error("请求体缺少 data 字段")
|
||||
}
|
||||
if _, ok := reqBody["sign"]; !ok {
|
||||
t.Error("请求体缺少 sign 字段")
|
||||
}
|
||||
if _, ok := reqBody["timestamp"]; !ok {
|
||||
t.Error("请求体缺少 timestamp 字段")
|
||||
}
|
||||
|
||||
// 返回 mock 响应
|
||||
resp := GatewayResponse{
|
||||
Code: 200,
|
||||
Msg: "成功",
|
||||
Data: json.RawMessage(`{"test":"data"}`),
|
||||
TraceID: "test-trace-id",
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "testAppID", "testSecret")
|
||||
|
||||
ctx := context.Background()
|
||||
businessData := map[string]interface{}{
|
||||
"params": map[string]string{
|
||||
"cardNo": "898608070422D0010269",
|
||||
},
|
||||
}
|
||||
|
||||
data, err := client.doRequest(ctx, "/test", businessData)
|
||||
if err != nil {
|
||||
t.Fatalf("doRequest() error = %v", err)
|
||||
}
|
||||
|
||||
if string(data) != `{"test":"data"}` {
|
||||
t.Errorf("data = %s, want {\"test\":\"data\"}", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoRequest_BusinessError(t *testing.T) {
|
||||
// 创建返回业务错误的 mock 服务器
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
resp := GatewayResponse{
|
||||
Code: 500,
|
||||
Msg: "业务处理失败",
|
||||
Data: nil,
|
||||
TraceID: "error-trace-id",
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "testAppID", "testSecret")
|
||||
|
||||
ctx := context.Background()
|
||||
_, err := client.doRequest(ctx, "/test", map[string]interface{}{})
|
||||
if err == nil {
|
||||
t.Fatal("doRequest() expected business error")
|
||||
}
|
||||
|
||||
// 验证错误信息包含业务错误内容
|
||||
if !strings.Contains(err.Error(), "业务错误") {
|
||||
t.Errorf("error should contain '业务错误', got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoRequest_Timeout(t *testing.T) {
|
||||
// 创建延迟响应的服务器
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(500 * time.Millisecond) // 延迟 500ms
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "testAppID", "testSecret").
|
||||
WithTimeout(100 * time.Millisecond) // 设置 100ms 超时
|
||||
|
||||
ctx := context.Background()
|
||||
_, err := client.doRequest(ctx, "/test", map[string]interface{}{})
|
||||
if err == nil {
|
||||
t.Fatal("doRequest() expected timeout error")
|
||||
}
|
||||
|
||||
// 验证是超时错误
|
||||
if !strings.Contains(err.Error(), "超时") {
|
||||
t.Errorf("error should contain '超时', got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoRequest_HTTPStatusError(t *testing.T) {
|
||||
// 创建返回 500 状态码的服务器
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte("Internal Server Error"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "testAppID", "testSecret")
|
||||
|
||||
ctx := context.Background()
|
||||
_, err := client.doRequest(ctx, "/test", map[string]interface{}{})
|
||||
if err == nil {
|
||||
t.Fatal("doRequest() expected HTTP status error")
|
||||
}
|
||||
|
||||
// 验证错误信息包含 HTTP 状态码
|
||||
if !strings.Contains(err.Error(), "500") {
|
||||
t.Errorf("error should contain '500', got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoRequest_InvalidResponse(t *testing.T) {
|
||||
// 创建返回无效 JSON 的服务器
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte("invalid json"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "testAppID", "testSecret")
|
||||
|
||||
ctx := context.Background()
|
||||
_, err := client.doRequest(ctx, "/test", map[string]interface{}{})
|
||||
if err == nil {
|
||||
t.Fatal("doRequest() expected JSON parse error")
|
||||
}
|
||||
|
||||
// 验证错误信息包含解析失败提示
|
||||
if !strings.Contains(err.Error(), "解析") {
|
||||
t.Errorf("error should contain '解析', got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoRequest_ContextCanceled(t *testing.T) {
|
||||
// 创建正常响应的服务器(但会延迟)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
resp := GatewayResponse{Code: 200, Msg: "成功"}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "testAppID", "testSecret")
|
||||
|
||||
// 创建已取消的 context
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // 立即取消
|
||||
|
||||
_, err := client.doRequest(ctx, "/test", map[string]interface{}{})
|
||||
if err == nil {
|
||||
t.Fatal("doRequest() expected context canceled error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoRequest_NetworkError(t *testing.T) {
|
||||
// 使用无效的服务器地址
|
||||
client := NewClient("http://127.0.0.1:1", "testAppID", "testSecret").
|
||||
WithTimeout(1 * time.Second)
|
||||
|
||||
ctx := context.Background()
|
||||
_, err := client.doRequest(ctx, "/test", map[string]interface{}{})
|
||||
if err == nil {
|
||||
t.Fatal("doRequest() expected network error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoRequest_EmptyBusinessData(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
resp := GatewayResponse{
|
||||
Code: 200,
|
||||
Msg: "成功",
|
||||
Data: json.RawMessage(`{}`),
|
||||
}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "testAppID", "testSecret")
|
||||
|
||||
ctx := context.Background()
|
||||
data, err := client.doRequest(ctx, "/test", map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatalf("doRequest() error = %v", err)
|
||||
}
|
||||
|
||||
if string(data) != `{}` {
|
||||
t.Errorf("data = %s, want {}", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_QueryCardStatus(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("跳过集成测试")
|
||||
}
|
||||
|
||||
baseURL := "https://lplan.whjhft.com/openapi"
|
||||
appID := "60bgt1X8i7AvXqkd"
|
||||
appSecret := "BZeQttaZQt0i73moF"
|
||||
|
||||
client := NewClient(baseURL, appID, appSecret).WithTimeout(30 * time.Second)
|
||||
ctx := context.Background()
|
||||
|
||||
resp, err := client.QueryCardStatus(ctx, &CardStatusReq{
|
||||
CardNo: "898608070422D0010269",
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("QueryCardStatus() error = %v", err)
|
||||
}
|
||||
|
||||
if resp.ICCID == "" {
|
||||
t.Error("ICCID should not be empty")
|
||||
}
|
||||
if resp.CardStatus == "" {
|
||||
t.Error("CardStatus should not be empty")
|
||||
}
|
||||
|
||||
t.Logf("Integration test passed: ICCID=%s, Status=%s", resp.ICCID, resp.CardStatus)
|
||||
}
|
||||
|
||||
func TestIntegration_QueryFlow(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("跳过集成测试")
|
||||
}
|
||||
|
||||
baseURL := "https://lplan.whjhft.com/openapi"
|
||||
appID := "60bgt1X8i7AvXqkd"
|
||||
appSecret := "BZeQttaZQt0i73moF"
|
||||
|
||||
client := NewClient(baseURL, appID, appSecret).WithTimeout(30 * time.Second)
|
||||
ctx := context.Background()
|
||||
|
||||
resp, err := client.QueryFlow(ctx, &FlowQueryReq{
|
||||
CardNo: "898608070422D0010269",
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("QueryFlow() error = %v", err)
|
||||
}
|
||||
|
||||
if resp.UsedFlow < 0 {
|
||||
t.Error("UsedFlow should not be negative")
|
||||
}
|
||||
|
||||
t.Logf("Integration test passed: UsedFlow=%d %s", resp.UsedFlow, resp.Unit)
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAESEncrypt(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
data []byte
|
||||
appSecret string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "正常加密",
|
||||
data: []byte(`{"params":{"cardNo":"898608070422D0010269"}}`),
|
||||
appSecret: "BZeQttaZQt0i73moF",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "空数据加密",
|
||||
data: []byte(""),
|
||||
appSecret: "BZeQttaZQt0i73moF",
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
encrypted, err := aesEncrypt(tt.data, tt.appSecret)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("aesEncrypt() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if !tt.wantErr && encrypted == "" {
|
||||
t.Error("aesEncrypt() 返回空字符串")
|
||||
}
|
||||
// 验证 Base64 格式
|
||||
if !tt.wantErr {
|
||||
_, err := base64.StdEncoding.DecodeString(encrypted)
|
||||
if err != nil {
|
||||
t.Errorf("aesEncrypt() 返回的不是有效的 Base64: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateSign(t *testing.T) {
|
||||
appID := "60bgt1X8i7AvXqkd"
|
||||
encryptedData := "test_encrypted_data"
|
||||
timestamp := int64(1704067200)
|
||||
appSecret := "BZeQttaZQt0i73moF"
|
||||
|
||||
sign := generateSign(appID, encryptedData, timestamp, appSecret)
|
||||
|
||||
// 验证签名格式(32 位大写十六进制)
|
||||
if len(sign) != 32 {
|
||||
t.Errorf("签名长度错误: got %d, want 32", len(sign))
|
||||
}
|
||||
|
||||
if sign != strings.ToUpper(sign) {
|
||||
t.Error("签名应为大写")
|
||||
}
|
||||
|
||||
// 验证签名可重现
|
||||
sign2 := generateSign(appID, encryptedData, timestamp, appSecret)
|
||||
if sign != sign2 {
|
||||
t.Error("相同参数应生成相同签名")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewECBEncrypterPanic(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("newECBEncrypter 期望触发 panic,但未触发")
|
||||
}
|
||||
}()
|
||||
|
||||
newECBEncrypter(nil)
|
||||
}
|
||||
|
||||
func TestECBEncrypterCryptBlocksPanic(t *testing.T) {
|
||||
block, err := aes.NewCipher(make([]byte, aesBlockSize))
|
||||
if err != nil {
|
||||
t.Fatalf("创建 AES cipher 失败: %v", err)
|
||||
}
|
||||
|
||||
encrypter := newECBEncrypter(block)
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("CryptBlocks 期望触发 panic,但未触发")
|
||||
}
|
||||
}()
|
||||
|
||||
// 传入非完整块长度,触发 panic
|
||||
src := []byte("short")
|
||||
dst := make([]byte, len(src))
|
||||
encrypter.CryptBlocks(dst, src)
|
||||
}
|
||||
@@ -1,404 +0,0 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGetDeviceInfo_ByCardNo_Success(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
resp := GatewayResponse{
|
||||
Code: 200,
|
||||
Msg: "成功",
|
||||
Data: json.RawMessage(`{"imei":"123456789012345","onlineStatus":1,"signalLevel":25,"wifiSsid":"TestWiFi","wifiEnabled":1,"uploadSpeed":100,"downloadSpeed":500}`),
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "testAppID", "testSecret")
|
||||
ctx := context.Background()
|
||||
|
||||
result, err := client.GetDeviceInfo(ctx, &DeviceInfoReq{
|
||||
CardNo: "898608070422D0010269",
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("GetDeviceInfo() error = %v", err)
|
||||
}
|
||||
|
||||
if result.IMEI != "123456789012345" {
|
||||
t.Errorf("IMEI = %s, want 123456789012345", result.IMEI)
|
||||
}
|
||||
if result.OnlineStatus != 1 {
|
||||
t.Errorf("OnlineStatus = %d, want 1", result.OnlineStatus)
|
||||
}
|
||||
if result.SignalLevel != 25 {
|
||||
t.Errorf("SignalLevel = %d, want 25", result.SignalLevel)
|
||||
}
|
||||
if result.WiFiSSID != "TestWiFi" {
|
||||
t.Errorf("WiFiSSID = %s, want TestWiFi", result.WiFiSSID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDeviceInfo_ByDeviceID_Success(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
resp := GatewayResponse{
|
||||
Code: 200,
|
||||
Msg: "成功",
|
||||
Data: json.RawMessage(`{"imei":"123456789012345","onlineStatus":0,"signalLevel":0}`),
|
||||
}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "testAppID", "testSecret")
|
||||
ctx := context.Background()
|
||||
|
||||
result, err := client.GetDeviceInfo(ctx, &DeviceInfoReq{
|
||||
DeviceID: "123456789012345",
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("GetDeviceInfo() error = %v", err)
|
||||
}
|
||||
|
||||
if result.IMEI != "123456789012345" {
|
||||
t.Errorf("IMEI = %s, want 123456789012345", result.IMEI)
|
||||
}
|
||||
if result.OnlineStatus != 0 {
|
||||
t.Errorf("OnlineStatus = %d, want 0", result.OnlineStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDeviceInfo_MissingParams(t *testing.T) {
|
||||
client := NewClient("https://test.example.com", "testAppID", "testSecret")
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := client.GetDeviceInfo(ctx, &DeviceInfoReq{})
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("GetDeviceInfo() expected validation error")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "至少需要一个") {
|
||||
t.Errorf("error should contain '至少需要一个', got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDeviceInfo_InvalidResponse(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
resp := GatewayResponse{
|
||||
Code: 200,
|
||||
Msg: "成功",
|
||||
Data: json.RawMessage(`invalid json`),
|
||||
}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "testAppID", "testSecret")
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := client.GetDeviceInfo(ctx, &DeviceInfoReq{CardNo: "test"})
|
||||
if err == nil {
|
||||
t.Fatal("GetDeviceInfo() expected JSON parse error")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "解析") {
|
||||
t.Errorf("error should contain '解析', got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSlotInfo_Success(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
resp := GatewayResponse{
|
||||
Code: 200,
|
||||
Msg: "成功",
|
||||
Data: json.RawMessage(`{"imei":"123456789012345","slots":[{"slotNo":1,"iccid":"898608070422D0010269","cardStatus":"正常","isActive":1},{"slotNo":2,"iccid":"898608070422D0010270","cardStatus":"停机","isActive":0}]}`),
|
||||
}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "testAppID", "testSecret")
|
||||
ctx := context.Background()
|
||||
|
||||
result, err := client.GetSlotInfo(ctx, &DeviceInfoReq{
|
||||
DeviceID: "123456789012345",
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("GetSlotInfo() error = %v", err)
|
||||
}
|
||||
|
||||
if result.IMEI != "123456789012345" {
|
||||
t.Errorf("IMEI = %s, want 123456789012345", result.IMEI)
|
||||
}
|
||||
if len(result.Slots) != 2 {
|
||||
t.Errorf("len(Slots) = %d, want 2", len(result.Slots))
|
||||
}
|
||||
if result.Slots[0].SlotNo != 1 {
|
||||
t.Errorf("Slots[0].SlotNo = %d, want 1", result.Slots[0].SlotNo)
|
||||
}
|
||||
if result.Slots[0].ICCID != "898608070422D0010269" {
|
||||
t.Errorf("Slots[0].ICCID = %s, want 898608070422D0010269", result.Slots[0].ICCID)
|
||||
}
|
||||
if result.Slots[0].IsActive != 1 {
|
||||
t.Errorf("Slots[0].IsActive = %d, want 1", result.Slots[0].IsActive)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSlotInfo_MissingParams(t *testing.T) {
|
||||
client := NewClient("https://test.example.com", "testAppID", "testSecret")
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := client.GetSlotInfo(ctx, &DeviceInfoReq{})
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("GetSlotInfo() expected validation error")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "至少需要一个") {
|
||||
t.Errorf("error should contain '至少需要一个', got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetSpeedLimit_Success(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
resp := GatewayResponse{Code: 200, Msg: "成功"}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "testAppID", "testSecret")
|
||||
ctx := context.Background()
|
||||
|
||||
err := client.SetSpeedLimit(ctx, &SpeedLimitReq{
|
||||
DeviceID: "123456789012345",
|
||||
UploadSpeed: 100,
|
||||
DownloadSpeed: 500,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("SetSpeedLimit() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetSpeedLimit_WithExtend(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
resp := GatewayResponse{Code: 200, Msg: "成功"}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "testAppID", "testSecret")
|
||||
ctx := context.Background()
|
||||
|
||||
err := client.SetSpeedLimit(ctx, &SpeedLimitReq{
|
||||
DeviceID: "123456789012345",
|
||||
UploadSpeed: 100,
|
||||
DownloadSpeed: 500,
|
||||
Extend: "test-extend",
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("SetSpeedLimit() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetSpeedLimit_BusinessError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
resp := GatewayResponse{Code: 500, Msg: "设置失败"}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "testAppID", "testSecret")
|
||||
ctx := context.Background()
|
||||
|
||||
err := client.SetSpeedLimit(ctx, &SpeedLimitReq{
|
||||
DeviceID: "123456789012345",
|
||||
UploadSpeed: 100,
|
||||
DownloadSpeed: 500,
|
||||
})
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("SetSpeedLimit() expected business error")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "业务错误") {
|
||||
t.Errorf("error should contain '业务错误', got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetWiFi_Success(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
resp := GatewayResponse{Code: 200, Msg: "成功"}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "testAppID", "testSecret")
|
||||
ctx := context.Background()
|
||||
|
||||
err := client.SetWiFi(ctx, &WiFiReq{
|
||||
DeviceID: "123456789012345",
|
||||
SSID: "TestWiFi",
|
||||
Password: "password123",
|
||||
Enabled: 1,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("SetWiFi() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetWiFi_WithExtend(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
resp := GatewayResponse{Code: 200, Msg: "成功"}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "testAppID", "testSecret")
|
||||
ctx := context.Background()
|
||||
|
||||
err := client.SetWiFi(ctx, &WiFiReq{
|
||||
DeviceID: "123456789012345",
|
||||
SSID: "TestWiFi",
|
||||
Password: "password123",
|
||||
Enabled: 0,
|
||||
Extend: "test-extend",
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("SetWiFi() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwitchCard_Success(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
resp := GatewayResponse{Code: 200, Msg: "成功"}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "testAppID", "testSecret")
|
||||
ctx := context.Background()
|
||||
|
||||
err := client.SwitchCard(ctx, &SwitchCardReq{
|
||||
DeviceID: "123456789012345",
|
||||
TargetICCID: "898608070422D0010270",
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("SwitchCard() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwitchCard_BusinessError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
resp := GatewayResponse{Code: 404, Msg: "目标卡不存在"}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "testAppID", "testSecret")
|
||||
ctx := context.Background()
|
||||
|
||||
err := client.SwitchCard(ctx, &SwitchCardReq{
|
||||
DeviceID: "123456789012345",
|
||||
TargetICCID: "invalid",
|
||||
})
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("SwitchCard() expected business error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetDevice_Success(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
resp := GatewayResponse{Code: 200, Msg: "成功"}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "testAppID", "testSecret")
|
||||
ctx := context.Background()
|
||||
|
||||
err := client.ResetDevice(ctx, &DeviceOperationReq{
|
||||
DeviceID: "123456789012345",
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("ResetDevice() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetDevice_WithExtend(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
resp := GatewayResponse{Code: 200, Msg: "成功"}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "testAppID", "testSecret")
|
||||
ctx := context.Background()
|
||||
|
||||
err := client.ResetDevice(ctx, &DeviceOperationReq{
|
||||
DeviceID: "123456789012345",
|
||||
Extend: "test-extend",
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("ResetDevice() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRebootDevice_Success(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
resp := GatewayResponse{Code: 200, Msg: "成功"}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "testAppID", "testSecret")
|
||||
ctx := context.Background()
|
||||
|
||||
err := client.RebootDevice(ctx, &DeviceOperationReq{
|
||||
DeviceID: "123456789012345",
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("RebootDevice() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRebootDevice_BusinessError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
resp := GatewayResponse{Code: 500, Msg: "设备离线"}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "testAppID", "testSecret")
|
||||
ctx := context.Background()
|
||||
|
||||
err := client.RebootDevice(ctx, &DeviceOperationReq{
|
||||
DeviceID: "123456789012345",
|
||||
})
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("RebootDevice() expected business error")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "业务错误") {
|
||||
t.Errorf("error should contain '业务错误', got: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,292 +0,0 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestQueryCardStatus_Success(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
resp := GatewayResponse{
|
||||
Code: 200,
|
||||
Msg: "成功",
|
||||
Data: json.RawMessage(`{"iccid":"898608070422D0010269","cardStatus":"正常"}`),
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "testAppID", "testSecret")
|
||||
ctx := context.Background()
|
||||
|
||||
result, err := client.QueryCardStatus(ctx, &CardStatusReq{
|
||||
CardNo: "898608070422D0010269",
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("QueryCardStatus() error = %v", err)
|
||||
}
|
||||
|
||||
if result.ICCID != "898608070422D0010269" {
|
||||
t.Errorf("ICCID = %s, want 898608070422D0010269", result.ICCID)
|
||||
}
|
||||
if result.CardStatus != "正常" {
|
||||
t.Errorf("CardStatus = %s, want 正常", result.CardStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryCardStatus_InvalidResponse(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
resp := GatewayResponse{
|
||||
Code: 200,
|
||||
Msg: "成功",
|
||||
Data: json.RawMessage(`invalid json`),
|
||||
}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "testAppID", "testSecret")
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := client.QueryCardStatus(ctx, &CardStatusReq{CardNo: "test"})
|
||||
if err == nil {
|
||||
t.Fatal("QueryCardStatus() expected JSON parse error")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "解析") {
|
||||
t.Errorf("error should contain '解析', got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryFlow_Success(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
resp := GatewayResponse{
|
||||
Code: 200,
|
||||
Msg: "成功",
|
||||
Data: json.RawMessage(`{"usedFlow":1024,"unit":"MB"}`),
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "testAppID", "testSecret")
|
||||
ctx := context.Background()
|
||||
|
||||
result, err := client.QueryFlow(ctx, &FlowQueryReq{
|
||||
CardNo: "898608070422D0010269",
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("QueryFlow() error = %v", err)
|
||||
}
|
||||
|
||||
if result.UsedFlow != 1024 {
|
||||
t.Errorf("UsedFlow = %d, want 1024", result.UsedFlow)
|
||||
}
|
||||
if result.Unit != "MB" {
|
||||
t.Errorf("Unit = %s, want MB", result.Unit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryFlow_BusinessError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
resp := GatewayResponse{
|
||||
Code: 404,
|
||||
Msg: "卡号不存在",
|
||||
}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "testAppID", "testSecret")
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := client.QueryFlow(ctx, &FlowQueryReq{CardNo: "invalid"})
|
||||
if err == nil {
|
||||
t.Fatal("QueryFlow() expected business error")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "业务错误") {
|
||||
t.Errorf("error should contain '业务错误', got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryRealnameStatus_Success(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
resp := GatewayResponse{
|
||||
Code: 200,
|
||||
Msg: "成功",
|
||||
Data: json.RawMessage(`{"status":"已实名"}`),
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "testAppID", "testSecret")
|
||||
ctx := context.Background()
|
||||
|
||||
result, err := client.QueryRealnameStatus(ctx, &CardStatusReq{
|
||||
CardNo: "898608070422D0010269",
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("QueryRealnameStatus() error = %v", err)
|
||||
}
|
||||
|
||||
if result.Status != "已实名" {
|
||||
t.Errorf("Status = %s, want 已实名", result.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopCard_Success(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
resp := GatewayResponse{
|
||||
Code: 200,
|
||||
Msg: "成功",
|
||||
}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "testAppID", "testSecret")
|
||||
ctx := context.Background()
|
||||
|
||||
err := client.StopCard(ctx, &CardOperationReq{
|
||||
CardNo: "898608070422D0010269",
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("StopCard() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopCard_WithExtend(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
resp := GatewayResponse{Code: 200, Msg: "成功"}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "testAppID", "testSecret")
|
||||
ctx := context.Background()
|
||||
|
||||
err := client.StopCard(ctx, &CardOperationReq{
|
||||
CardNo: "898608070422D0010269",
|
||||
Extend: "test-extend",
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("StopCard() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartCard_Success(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
resp := GatewayResponse{Code: 200, Msg: "成功"}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "testAppID", "testSecret")
|
||||
ctx := context.Background()
|
||||
|
||||
err := client.StartCard(ctx, &CardOperationReq{
|
||||
CardNo: "898608070422D0010269",
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("StartCard() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartCard_BusinessError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
resp := GatewayResponse{Code: 500, Msg: "操作失败"}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "testAppID", "testSecret")
|
||||
ctx := context.Background()
|
||||
|
||||
err := client.StartCard(ctx, &CardOperationReq{CardNo: "test"})
|
||||
if err == nil {
|
||||
t.Fatal("StartCard() expected business error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRealnameLink_Success(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
resp := GatewayResponse{
|
||||
Code: 200,
|
||||
Msg: "成功",
|
||||
Data: json.RawMessage(`{"link":"https://realname.example.com/verify?token=abc123"}`),
|
||||
}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "testAppID", "testSecret")
|
||||
ctx := context.Background()
|
||||
|
||||
result, err := client.GetRealnameLink(ctx, &CardStatusReq{
|
||||
CardNo: "898608070422D0010269",
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("GetRealnameLink() error = %v", err)
|
||||
}
|
||||
|
||||
if result.Link != "https://realname.example.com/verify?token=abc123" {
|
||||
t.Errorf("Link = %s, want https://realname.example.com/verify?token=abc123", result.Link)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRealnameLink_InvalidResponse(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
resp := GatewayResponse{
|
||||
Code: 200,
|
||||
Msg: "成功",
|
||||
Data: json.RawMessage(`{"invalid": "structure"}`),
|
||||
}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "testAppID", "testSecret")
|
||||
ctx := context.Background()
|
||||
|
||||
result, err := client.GetRealnameLink(ctx, &CardStatusReq{CardNo: "test"})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("GetRealnameLink() unexpected error = %v", err)
|
||||
}
|
||||
if result.Link != "" {
|
||||
t.Errorf("Link = %s, want empty string", result.Link)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchQuery_NotImplemented(t *testing.T) {
|
||||
client := NewClient("https://test.example.com", "testAppID", "testSecret")
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := client.BatchQuery(ctx, &BatchQueryReq{
|
||||
CardNos: []string{"test1", "test2"},
|
||||
})
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("BatchQuery() expected not implemented error")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "暂未实现") {
|
||||
t.Errorf("error should contain '暂未实现', got: %v", err)
|
||||
}
|
||||
}
|
||||
47
internal/handler/admin/package_usage.go
Normal file
47
internal/handler/admin/package_usage.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
|
||||
packageService "github.com/break/junhong_cmp_fiber/internal/service/package"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/response"
|
||||
)
|
||||
|
||||
// PackageUsageHandler 套餐使用记录 Handler
|
||||
type PackageUsageHandler struct {
|
||||
dailyRecordService *packageService.DailyRecordService
|
||||
}
|
||||
|
||||
// NewPackageUsageHandler 创建套餐使用记录 Handler
|
||||
func NewPackageUsageHandler(dailyRecordService *packageService.DailyRecordService) *PackageUsageHandler {
|
||||
return &PackageUsageHandler{
|
||||
dailyRecordService: dailyRecordService,
|
||||
}
|
||||
}
|
||||
|
||||
// GetDailyRecords 任务 16.2-16.5: 获取套餐流量详单
|
||||
// GET /api/admin/package-usage/:id/daily-records
|
||||
// 查询参数:start_date(开始日期,格式 YYYY-MM-DD), end_date(结束日期,格式 YYYY-MM-DD)
|
||||
func (h *PackageUsageHandler) GetDailyRecords(c *fiber.Ctx) error {
|
||||
// 解析套餐使用记录 ID
|
||||
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||
if err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "无效的套餐使用记录 ID")
|
||||
}
|
||||
|
||||
// 任务 16.3: 解析日期范围查询参数
|
||||
startDate := c.Query("start_date", "")
|
||||
endDate := c.Query("end_date", "")
|
||||
|
||||
// 任务 16.4: 调用 DailyRecordService.GetDailyRecords 获取日记录
|
||||
records, err := h.dailyRecordService.GetDailyRecords(c.UserContext(), uint(id), startDate, endDate)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 任务 16.5: 返回 PackageUsageDetailResponse 响应
|
||||
return response.Success(c, records)
|
||||
}
|
||||
93
internal/handler/h5/package_usage.go
Normal file
93
internal/handler/h5/package_usage.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package h5
|
||||
|
||||
import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
packageService "github.com/break/junhong_cmp_fiber/internal/service/package"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/response"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// PackageUsageHandler H5 端套餐使用情况 Handler
|
||||
type PackageUsageHandler struct {
|
||||
db *gorm.DB
|
||||
customerViewService *packageService.CustomerViewService
|
||||
}
|
||||
|
||||
// NewPackageUsageHandler 创建 H5 端套餐使用情况 Handler
|
||||
func NewPackageUsageHandler(db *gorm.DB, customerViewService *packageService.CustomerViewService) *PackageUsageHandler {
|
||||
return &PackageUsageHandler{
|
||||
db: db,
|
||||
customerViewService: customerViewService,
|
||||
}
|
||||
}
|
||||
|
||||
// GetMyUsage 任务 15.2-15.5: 获取我的套餐使用情况
|
||||
// GET /api/h5/packages/my-usage
|
||||
func (h *PackageUsageHandler) GetMyUsage(c *fiber.Ctx) error {
|
||||
ctx := c.UserContext()
|
||||
|
||||
// 任务 15.3: 从 JWT 上下文中提取用户信息
|
||||
userType := middleware.GetUserTypeFromContext(ctx)
|
||||
|
||||
var carrierType string
|
||||
var carrierID uint
|
||||
|
||||
// 根据用户类型获取载体信息
|
||||
switch userType {
|
||||
case constants.UserTypePersonalCustomer:
|
||||
// 个人客户:查询其订单关联的 IoT 卡或设备
|
||||
customerID := middleware.GetCustomerIDFromContext(ctx)
|
||||
if customerID == 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "未找到客户信息")
|
||||
}
|
||||
|
||||
// 查询该客户的套餐使用记录,获取载体信息
|
||||
var usage model.PackageUsage
|
||||
err := h.db.WithContext(ctx).
|
||||
Joins("JOIN tb_order ON tb_order.id = tb_package_usage.order_id").
|
||||
Where("tb_order.buyer_type = ? AND tb_order.buyer_id = ?", model.BuyerTypePersonal, customerID).
|
||||
Where("tb_package_usage.status IN ?", []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}).
|
||||
Order("tb_package_usage.activated_at DESC").
|
||||
First(&usage).Error
|
||||
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return errors.New(errors.CodeNotFound, "未找到套餐使用记录")
|
||||
}
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐使用记录失败")
|
||||
}
|
||||
|
||||
// 确定载体类型和 ID
|
||||
if usage.IotCardID > 0 {
|
||||
carrierType = "iot_card"
|
||||
carrierID = usage.IotCardID
|
||||
} else if usage.DeviceID > 0 {
|
||||
carrierType = "device"
|
||||
carrierID = usage.DeviceID
|
||||
} else {
|
||||
return errors.New(errors.CodeInvalidParam, "套餐使用记录未关联卡或设备")
|
||||
}
|
||||
|
||||
case constants.UserTypeAgent, constants.UserTypeEnterprise:
|
||||
// 代理和企业用户暂不支持通过此接口查询
|
||||
// 他们应该使用后台管理接口查询指定卡/设备的套餐情况
|
||||
return errors.New(errors.CodeForbidden, "此接口仅供个人客户使用")
|
||||
|
||||
default:
|
||||
return errors.New(errors.CodeForbidden, "不支持的用户类型")
|
||||
}
|
||||
|
||||
// 任务 15.4: 调用 CustomerViewService.GetMyUsage 获取流量数据
|
||||
usageData, err := h.customerViewService.GetMyUsage(ctx, carrierType, carrierID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 任务 15.5: 返回 PackageUsageCustomerViewResponse 响应
|
||||
return response.Success(c, usageData)
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/gofiber/fiber/v2/middleware/requestid"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/logger"
|
||||
)
|
||||
|
||||
// TestRecover_PanicCapture 测试 panic 捕获功能
|
||||
func TestRecover_PanicCapture(t *testing.T) {
|
||||
// 初始化日志器
|
||||
_ = logger.InitLoggers(
|
||||
"debug",
|
||||
true,
|
||||
logger.LogRotationConfig{
|
||||
Filename: "../../tests/integration/logs/recover_test.log",
|
||||
MaxSize: 10,
|
||||
MaxBackups: 3,
|
||||
MaxAge: 7,
|
||||
Compress: false,
|
||||
},
|
||||
logger.LogRotationConfig{
|
||||
Filename: "../../tests/integration/logs/access_test.log",
|
||||
MaxSize: 10,
|
||||
MaxBackups: 3,
|
||||
MaxAge: 7,
|
||||
Compress: false,
|
||||
},
|
||||
)
|
||||
|
||||
appLogger := logger.GetAppLogger()
|
||||
|
||||
app := fiber.New(fiber.Config{
|
||||
ErrorHandler: errors.SafeErrorHandler(appLogger),
|
||||
})
|
||||
|
||||
// 注册 recover 中间件
|
||||
app.Use(Recover(appLogger))
|
||||
app.Use(requestid.New(requestid.Config{
|
||||
Generator: func() string {
|
||||
return uuid.NewString()
|
||||
},
|
||||
}))
|
||||
|
||||
// 创建会触发 panic 的路由
|
||||
app.Get("/panic", func(c *fiber.Ctx) error {
|
||||
panic("测试 panic")
|
||||
})
|
||||
|
||||
// 发起请求
|
||||
req := httptest.NewRequest("GET", "/panic", nil)
|
||||
resp, err := app.Test(req, -1)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
// 验证响应状态码为 500 (内部错误)
|
||||
assert.Equal(t, 500, resp.StatusCode, "panic 应转换为 500 错误")
|
||||
|
||||
// 验证响应体不为空
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, body, "panic 响应体不应为空")
|
||||
|
||||
t.Log("✓ Panic 捕获测试通过")
|
||||
}
|
||||
|
||||
// TestRecover_NilPointerPanic 测试空指针 panic
|
||||
func TestRecover_NilPointerPanic(t *testing.T) {
|
||||
appLogger := logger.GetAppLogger()
|
||||
|
||||
app := fiber.New(fiber.Config{
|
||||
ErrorHandler: errors.SafeErrorHandler(appLogger),
|
||||
})
|
||||
|
||||
app.Use(Recover(appLogger))
|
||||
app.Use(requestid.New(requestid.Config{
|
||||
Generator: func() string {
|
||||
return uuid.NewString()
|
||||
},
|
||||
}))
|
||||
|
||||
// 创建会触发空指针 panic 的路由
|
||||
app.Get("/nil-panic", func(c *fiber.Ctx) error {
|
||||
var ptr *string
|
||||
_ = *ptr // 空指针引用会导致 panic
|
||||
return nil
|
||||
})
|
||||
|
||||
req := httptest.NewRequest("GET", "/nil-panic", nil)
|
||||
resp, err := app.Test(req, -1)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
assert.Equal(t, 500, resp.StatusCode, "空指针 panic 应转换为 500 错误")
|
||||
|
||||
t.Log("✓ 空指针 Panic 捕获测试通过")
|
||||
}
|
||||
|
||||
// TestRecover_NormalRequest 测试正常请求不受影响
|
||||
func TestRecover_NormalRequest(t *testing.T) {
|
||||
appLogger := logger.GetAppLogger()
|
||||
|
||||
app := fiber.New(fiber.Config{
|
||||
ErrorHandler: errors.SafeErrorHandler(appLogger),
|
||||
})
|
||||
|
||||
app.Use(Recover(appLogger))
|
||||
|
||||
// 创建正常的路由
|
||||
app.Get("/normal", func(c *fiber.Ctx) error {
|
||||
return c.JSON(fiber.Map{"status": "ok"})
|
||||
})
|
||||
|
||||
req := httptest.NewRequest("GET", "/normal", nil)
|
||||
resp, err := app.Test(req, -1)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
assert.Equal(t, 200, resp.StatusCode, "正常请求应返回 200")
|
||||
|
||||
t.Log("✓ 正常请求测试通过")
|
||||
}
|
||||
@@ -2,29 +2,37 @@ package dto
|
||||
|
||||
// CreatePackageRequest 创建套餐请求
|
||||
type CreatePackageRequest struct {
|
||||
PackageCode string `json:"package_code" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"套餐编码"`
|
||||
PackageName string `json:"package_name" validate:"required,min=1,max=255" required:"true" minLength:"1" maxLength:"255" description:"套餐名称"`
|
||||
SeriesID *uint `json:"series_id" validate:"omitempty" description:"套餐系列ID"`
|
||||
PackageType string `json:"package_type" validate:"required,oneof=formal addon" required:"true" description:"套餐类型 (formal:正式套餐, addon:附加套餐)"`
|
||||
DurationMonths int `json:"duration_months" validate:"required,min=1,max=120" required:"true" minimum:"1" maximum:"120" description:"套餐时长(月数)"`
|
||||
RealDataMB *int64 `json:"real_data_mb" validate:"omitempty,min=0" minimum:"0" description:"真流量额度(MB)"`
|
||||
VirtualDataMB *int64 `json:"virtual_data_mb" validate:"omitempty,min=0" minimum:"0" description:"虚流量额度(MB)"`
|
||||
EnableVirtualData bool `json:"enable_virtual_data" description:"是否启用虚流量"`
|
||||
SuggestedRetailPrice *int64 `json:"suggested_retail_price" validate:"omitempty,min=0" minimum:"0" description:"建议售价(分)"`
|
||||
CostPrice int64 `json:"cost_price" validate:"required,min=0" required:"true" minimum:"0" description:"成本价(分)"`
|
||||
PackageCode string `json:"package_code" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"套餐编码"`
|
||||
PackageName string `json:"package_name" validate:"required,min=1,max=255" required:"true" minLength:"1" maxLength:"255" description:"套餐名称"`
|
||||
SeriesID *uint `json:"series_id" validate:"omitempty" description:"套餐系列ID"`
|
||||
PackageType string `json:"package_type" validate:"required,oneof=formal addon" required:"true" description:"套餐类型 (formal:正式套餐, addon:附加套餐)"`
|
||||
DurationMonths int `json:"duration_months" validate:"required,min=1,max=120" required:"true" minimum:"1" maximum:"120" description:"套餐时长(月数)"`
|
||||
RealDataMB *int64 `json:"real_data_mb" validate:"omitempty,min=0" minimum:"0" description:"真流量额度(MB)"`
|
||||
VirtualDataMB *int64 `json:"virtual_data_mb" validate:"omitempty,min=0" minimum:"0" description:"虚流量额度(MB)"`
|
||||
EnableVirtualData bool `json:"enable_virtual_data" description:"是否启用虚流量"`
|
||||
SuggestedRetailPrice *int64 `json:"suggested_retail_price" validate:"omitempty,min=0" minimum:"0" description:"建议售价(分)"`
|
||||
CostPrice int64 `json:"cost_price" validate:"required,min=0" required:"true" minimum:"0" description:"成本价(分)"`
|
||||
CalendarType *string `json:"calendar_type" validate:"omitempty,oneof=natural_month by_day" description:"套餐周期类型 (natural_month:自然月, by_day:按天)"`
|
||||
DurationDays *int `json:"duration_days" validate:"omitempty,min=1,max=3650" minimum:"1" maximum:"3650" description:"套餐天数(calendar_type=by_day时必填)"`
|
||||
DataResetCycle *string `json:"data_reset_cycle" validate:"omitempty,oneof=daily monthly yearly none" description:"流量重置周期 (daily:每日, monthly:每月, yearly:每年, none:不重置)"`
|
||||
EnableRealnameActivation *bool `json:"enable_realname_activation" description:"是否启用实名激活 (true:需实名后激活, false:立即激活)"`
|
||||
}
|
||||
|
||||
// UpdatePackageRequest 更新套餐请求
|
||||
type UpdatePackageRequest struct {
|
||||
PackageName *string `json:"package_name" validate:"omitempty,min=1,max=255" minLength:"1" maxLength:"255" description:"套餐名称"`
|
||||
SeriesID *uint `json:"series_id" validate:"omitempty" description:"套餐系列ID"`
|
||||
PackageType *string `json:"package_type" validate:"omitempty,oneof=formal addon" description:"套餐类型 (formal:正式套餐, addon:附加套餐)"`
|
||||
DurationMonths *int `json:"duration_months" validate:"omitempty,min=1,max=120" minimum:"1" maximum:"120" description:"套餐时长(月数)"`
|
||||
RealDataMB *int64 `json:"real_data_mb" validate:"omitempty,min=0" minimum:"0" description:"真流量额度(MB)"`
|
||||
VirtualDataMB *int64 `json:"virtual_data_mb" validate:"omitempty,min=0" minimum:"0" description:"虚流量额度(MB)"`
|
||||
EnableVirtualData *bool `json:"enable_virtual_data" description:"是否启用虚流量"`
|
||||
SuggestedRetailPrice *int64 `json:"suggested_retail_price" validate:"omitempty,min=0" minimum:"0" description:"建议售价(分)"`
|
||||
CostPrice *int64 `json:"cost_price" validate:"omitempty,min=0" minimum:"0" description:"成本价(分)"`
|
||||
PackageName *string `json:"package_name" validate:"omitempty,min=1,max=255" minLength:"1" maxLength:"255" description:"套餐名称"`
|
||||
SeriesID *uint `json:"series_id" validate:"omitempty" description:"套餐系列ID"`
|
||||
PackageType *string `json:"package_type" validate:"omitempty,oneof=formal addon" description:"套餐类型 (formal:正式套餐, addon:附加套餐)"`
|
||||
DurationMonths *int `json:"duration_months" validate:"omitempty,min=1,max=120" minimum:"1" maximum:"120" description:"套餐时长(月数)"`
|
||||
RealDataMB *int64 `json:"real_data_mb" validate:"omitempty,min=0" minimum:"0" description:"真流量额度(MB)"`
|
||||
VirtualDataMB *int64 `json:"virtual_data_mb" validate:"omitempty,min=0" minimum:"0" description:"虚流量额度(MB)"`
|
||||
EnableVirtualData *bool `json:"enable_virtual_data" description:"是否启用虚流量"`
|
||||
SuggestedRetailPrice *int64 `json:"suggested_retail_price" validate:"omitempty,min=0" minimum:"0" description:"建议售价(分)"`
|
||||
CostPrice *int64 `json:"cost_price" validate:"omitempty,min=0" minimum:"0" description:"成本价(分)"`
|
||||
CalendarType *string `json:"calendar_type" validate:"omitempty,oneof=natural_month by_day" description:"套餐周期类型 (natural_month:自然月, by_day:按天)"`
|
||||
DurationDays *int `json:"duration_days" validate:"omitempty,min=1,max=3650" minimum:"1" maximum:"3650" description:"套餐天数(calendar_type=by_day时必填)"`
|
||||
DataResetCycle *string `json:"data_reset_cycle" validate:"omitempty,oneof=daily monthly yearly none" description:"流量重置周期 (daily:每日, monthly:每月, yearly:每年, none:不重置)"`
|
||||
EnableRealnameActivation *bool `json:"enable_realname_activation" description:"是否启用实名激活 (true:需实名后激活, false:立即激活)"`
|
||||
}
|
||||
|
||||
// PackageListRequest 套餐列表请求
|
||||
@@ -57,26 +65,30 @@ type CommissionTierInfo struct {
|
||||
|
||||
// PackageResponse 套餐响应
|
||||
type PackageResponse struct {
|
||||
ID uint `json:"id" description:"套餐ID"`
|
||||
PackageCode string `json:"package_code" description:"套餐编码"`
|
||||
PackageName string `json:"package_name" description:"套餐名称"`
|
||||
SeriesID *uint `json:"series_id" description:"套餐系列ID"`
|
||||
SeriesName *string `json:"series_name" description:"套餐系列名称"`
|
||||
PackageType string `json:"package_type" description:"套餐类型 (formal:正式套餐, addon:附加套餐)"`
|
||||
DurationMonths int `json:"duration_months" description:"套餐时长(月数)"`
|
||||
RealDataMB int64 `json:"real_data_mb" description:"真流量额度(MB)"`
|
||||
VirtualDataMB int64 `json:"virtual_data_mb" description:"虚流量额度(MB)"`
|
||||
EnableVirtualData bool `json:"enable_virtual_data" description:"是否启用虚流量"`
|
||||
SuggestedRetailPrice int64 `json:"suggested_retail_price" description:"建议售价(分)"`
|
||||
CostPrice int64 `json:"cost_price" description:"成本价(分)"`
|
||||
OneTimeCommissionAmount *int64 `json:"one_time_commission_amount,omitempty" description:"一次性佣金金额(分,代理视角)"`
|
||||
Status int `json:"status" description:"状态 (1:启用, 2:禁用)"`
|
||||
ShelfStatus int `json:"shelf_status" description:"上架状态 (1:上架, 2:下架)"`
|
||||
CreatedAt string `json:"created_at" description:"创建时间"`
|
||||
UpdatedAt string `json:"updated_at" description:"更新时间"`
|
||||
ProfitMargin *int64 `json:"profit_margin,omitempty" description:"利润空间(分,仅代理用户可见)"`
|
||||
CurrentCommissionRate string `json:"current_commission_rate,omitempty" description:"当前返佣比例(仅代理用户可见)"`
|
||||
TierInfo *CommissionTierInfo `json:"tier_info,omitempty" description:"梯度返佣信息(仅代理用户可见)"`
|
||||
ID uint `json:"id" description:"套餐ID"`
|
||||
PackageCode string `json:"package_code" description:"套餐编码"`
|
||||
PackageName string `json:"package_name" description:"套餐名称"`
|
||||
SeriesID *uint `json:"series_id" description:"套餐系列ID"`
|
||||
SeriesName *string `json:"series_name" description:"套餐系列名称"`
|
||||
PackageType string `json:"package_type" description:"套餐类型 (formal:正式套餐, addon:附加套餐)"`
|
||||
DurationMonths int `json:"duration_months" description:"套餐时长(月数)"`
|
||||
RealDataMB int64 `json:"real_data_mb" description:"真流量额度(MB)"`
|
||||
VirtualDataMB int64 `json:"virtual_data_mb" description:"虚流量额度(MB)"`
|
||||
EnableVirtualData bool `json:"enable_virtual_data" description:"是否启用虚流量"`
|
||||
SuggestedRetailPrice int64 `json:"suggested_retail_price" description:"建议售价(分)"`
|
||||
CostPrice int64 `json:"cost_price" description:"成本价(分)"`
|
||||
OneTimeCommissionAmount *int64 `json:"one_time_commission_amount,omitempty" description:"一次性佣金金额(分,代理视角)"`
|
||||
Status int `json:"status" description:"状态 (1:启用, 2:禁用)"`
|
||||
ShelfStatus int `json:"shelf_status" description:"上架状态 (1:上架, 2:下架)"`
|
||||
CreatedAt string `json:"created_at" description:"创建时间"`
|
||||
UpdatedAt string `json:"updated_at" description:"更新时间"`
|
||||
ProfitMargin *int64 `json:"profit_margin,omitempty" description:"利润空间(分,仅代理用户可见)"`
|
||||
CurrentCommissionRate string `json:"current_commission_rate,omitempty" description:"当前返佣比例(仅代理用户可见)"`
|
||||
TierInfo *CommissionTierInfo `json:"tier_info,omitempty" description:"梯度返佣信息(仅代理用户可见)"`
|
||||
CalendarType string `json:"calendar_type" description:"套餐周期类型 (natural_month:自然月, by_day:按天)"`
|
||||
DurationDays *int `json:"duration_days,omitempty" description:"套餐天数(calendar_type=by_day时有值)"`
|
||||
DataResetCycle string `json:"data_reset_cycle" description:"流量重置周期 (daily:每日, monthly:每月, yearly:每年, none:不重置)"`
|
||||
EnableRealnameActivation bool `json:"enable_realname_activation" description:"是否启用实名激活 (true:需实名后激活, false:立即激活)"`
|
||||
}
|
||||
|
||||
// UpdatePackageParams 更新套餐聚合参数
|
||||
@@ -105,3 +117,45 @@ type PackagePageResult struct {
|
||||
PageSize int `json:"page_size" description:"每页数量"`
|
||||
TotalPages int `json:"total_pages" description:"总页数"`
|
||||
}
|
||||
|
||||
// PackageUsageItemResponse 套餐使用项响应(客户视图)
|
||||
type PackageUsageItemResponse struct {
|
||||
PackageUsageID uint `json:"package_usage_id" description:"套餐使用记录ID"`
|
||||
PackageID uint `json:"package_id" description:"套餐ID"`
|
||||
PackageName string `json:"package_name" description:"套餐名称"`
|
||||
UsedMB int64 `json:"used_mb" description:"已使用流量(MB)"`
|
||||
TotalMB int64 `json:"total_mb" description:"总流量(MB)"`
|
||||
Status int `json:"status" description:"状态 (0:待生效, 1:生效中, 2:已用完, 3:已过期, 4:已失效)"`
|
||||
StatusText string `json:"status_text" description:"状态文本"`
|
||||
ExpiresAt string `json:"expires_at" description:"过期时间"`
|
||||
ActivatedAt string `json:"activated_at" description:"激活时间"`
|
||||
Priority int `json:"priority" description:"优先级(数字越小优先级越高)"`
|
||||
}
|
||||
|
||||
// PackageUsageTotalInfo 套餐流量总计信息
|
||||
type PackageUsageTotalInfo struct {
|
||||
UsedMB int64 `json:"used_mb" description:"总已使用流量(MB)"`
|
||||
TotalMB int64 `json:"total_mb" description:"总流量(MB)"`
|
||||
}
|
||||
|
||||
// PackageUsageCustomerViewResponse 客户视图流量查询响应
|
||||
type PackageUsageCustomerViewResponse struct {
|
||||
MainPackage *PackageUsageItemResponse `json:"main_package" description:"主套餐信息"`
|
||||
AddonPackages []PackageUsageItemResponse `json:"addon_packages" description:"加油包列表(按priority排序)"`
|
||||
Total PackageUsageTotalInfo `json:"total" description:"总计流量信息"`
|
||||
}
|
||||
|
||||
// PackageUsageDailyRecordResponse 套餐流量日记录响应
|
||||
type PackageUsageDailyRecordResponse struct {
|
||||
Date string `json:"date" description:"日期"`
|
||||
DailyUsageMB int `json:"daily_usage_mb" description:"当日流量使用量(MB)"`
|
||||
CumulativeUsageMB int64 `json:"cumulative_usage_mb" description:"截止当日的累计流量(MB)"`
|
||||
}
|
||||
|
||||
// PackageUsageDetailResponse 套餐流量详单响应
|
||||
type PackageUsageDetailResponse struct {
|
||||
PackageUsageID uint `json:"package_usage_id" description:"套餐使用记录ID"`
|
||||
PackageName string `json:"package_name" description:"套餐名称"`
|
||||
Records []PackageUsageDailyRecordResponse `json:"records" description:"流量日记录列表"`
|
||||
TotalUsageMB int64 `json:"total_usage_mb" description:"总使用流量(MB)"`
|
||||
}
|
||||
|
||||
@@ -44,6 +44,12 @@ type IotCard struct {
|
||||
AccumulatedRecharge int64 `gorm:"column:accumulated_recharge;type:bigint;default:0;comment:累计充值金额(分,废弃,使用按系列追踪)" json:"accumulated_recharge"`
|
||||
AccumulatedRechargeBySeriesJSON string `gorm:"column:accumulated_recharge_by_series;type:jsonb;default:'{}';comment:按套餐系列追踪的累计充值金额" json:"-"`
|
||||
FirstRechargeTriggeredBySeriesJSON string `gorm:"column:first_recharge_triggered_by_series;type:jsonb;default:'{}';comment:按套餐系列追踪的首充触发状态" json:"-"`
|
||||
|
||||
// 任务 24.1: 停复机相关字段
|
||||
FirstRealnameAt *time.Time `gorm:"column:first_realname_at;comment:首次实名时间(用于触发首次实名激活)" json:"first_realname_at,omitempty"`
|
||||
StoppedAt *time.Time `gorm:"column:stopped_at;comment:停机时间" json:"stopped_at,omitempty"`
|
||||
ResumedAt *time.Time `gorm:"column:resumed_at;comment:最近复机时间" json:"resumed_at,omitempty"`
|
||||
StopReason string `gorm:"column:stop_reason;type:varchar(50);comment:停机原因(traffic_exhausted=流量耗尽,manual=手动停机,arrears=欠费)" json:"stop_reason,omitempty"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
|
||||
@@ -29,19 +29,23 @@ func (PackageSeries) TableName() string {
|
||||
// 只适用于 IoT 卡,支持真流量/虚流量共存机制
|
||||
type Package struct {
|
||||
gorm.Model
|
||||
BaseModel `gorm:"embedded"`
|
||||
PackageCode string `gorm:"column:package_code;type:varchar(100);uniqueIndex:idx_package_code,where:deleted_at IS NULL;not null;comment:套餐编码" json:"package_code"`
|
||||
PackageName string `gorm:"column:package_name;type:varchar(255);not null;comment:套餐名称" json:"package_name"`
|
||||
SeriesID uint `gorm:"column:series_id;index;comment:套餐系列ID" json:"series_id"`
|
||||
PackageType string `gorm:"column:package_type;type:varchar(50);not null;comment:套餐类型 formal-正式套餐 addon-附加套餐" json:"package_type"`
|
||||
DurationMonths int `gorm:"column:duration_months;type:int;not null;comment:套餐时长(月数) 1-月套餐 12-年套餐" json:"duration_months"`
|
||||
RealDataMB int64 `gorm:"column:real_data_mb;type:bigint;default:0;comment:真流量额度(MB)" json:"real_data_mb"`
|
||||
VirtualDataMB int64 `gorm:"column:virtual_data_mb;type:bigint;default:0;comment:虚流量额度(MB,用于停机判断)" json:"virtual_data_mb"`
|
||||
EnableVirtualData bool `gorm:"column:enable_virtual_data;type:boolean;default:false;not null;comment:是否启用虚流量" json:"enable_virtual_data"`
|
||||
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-启用 2-禁用" json:"status"`
|
||||
CostPrice int64 `gorm:"column:cost_price;type:bigint;default:0;comment:成本价(分为单位)" json:"cost_price"`
|
||||
SuggestedRetailPrice int64 `gorm:"column:suggested_retail_price;type:bigint;default:0;comment:建议售价(分为单位)" json:"suggested_retail_price"`
|
||||
ShelfStatus int `gorm:"column:shelf_status;type:int;default:2;not null;comment:上架状态 1-上架 2-下架" json:"shelf_status"`
|
||||
BaseModel `gorm:"embedded"`
|
||||
PackageCode string `gorm:"column:package_code;type:varchar(100);uniqueIndex:idx_package_code,where:deleted_at IS NULL;not null;comment:套餐编码" json:"package_code"`
|
||||
PackageName string `gorm:"column:package_name;type:varchar(255);not null;comment:套餐名称" json:"package_name"`
|
||||
SeriesID uint `gorm:"column:series_id;index;comment:套餐系列ID" json:"series_id"`
|
||||
PackageType string `gorm:"column:package_type;type:varchar(50);not null;comment:套餐类型 formal-正式套餐 addon-附加套餐" json:"package_type"`
|
||||
DurationMonths int `gorm:"column:duration_months;type:int;not null;comment:套餐时长(月数) 1-月套餐 12-年套餐" json:"duration_months"`
|
||||
RealDataMB int64 `gorm:"column:real_data_mb;type:bigint;default:0;comment:真流量额度(MB)" json:"real_data_mb"`
|
||||
VirtualDataMB int64 `gorm:"column:virtual_data_mb;type:bigint;default:0;comment:虚流量额度(MB,用于停机判断)" json:"virtual_data_mb"`
|
||||
EnableVirtualData bool `gorm:"column:enable_virtual_data;type:boolean;default:false;not null;comment:是否启用虚流量" json:"enable_virtual_data"`
|
||||
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-启用 2-禁用" json:"status"`
|
||||
CostPrice int64 `gorm:"column:cost_price;type:bigint;default:0;comment:成本价(分为单位)" json:"cost_price"`
|
||||
SuggestedRetailPrice int64 `gorm:"column:suggested_retail_price;type:bigint;default:0;comment:建议售价(分为单位)" json:"suggested_retail_price"`
|
||||
ShelfStatus int `gorm:"column:shelf_status;type:int;default:2;not null;comment:上架状态 1-上架 2-下架" json:"shelf_status"`
|
||||
CalendarType string `gorm:"column:calendar_type;type:varchar(20);default:'by_day';comment:套餐周期类型 natural_month-自然月 by_day-按天" json:"calendar_type"`
|
||||
DurationDays int `gorm:"column:duration_days;type:int;comment:套餐天数(calendar_type=by_day时必填)" json:"duration_days"`
|
||||
DataResetCycle string `gorm:"column:data_reset_cycle;type:varchar(20);default:'monthly';comment:流量重置周期 daily-每日 monthly-每月 yearly-每年 none-不重置" json:"data_reset_cycle"`
|
||||
EnableRealnameActivation bool `gorm:"column:enable_realname_activation;type:boolean;default:true;comment:是否启用实名激活 true-需实名后激活 false-立即激活" json:"enable_realname_activation"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
@@ -53,20 +57,27 @@ func (Package) TableName() string {
|
||||
// 跟踪单卡套餐和设备级套餐的流量使用
|
||||
type PackageUsage struct {
|
||||
gorm.Model
|
||||
BaseModel `gorm:"embedded"`
|
||||
OrderID uint `gorm:"column:order_id;index;not null;comment:订单ID" json:"order_id"`
|
||||
PackageID uint `gorm:"column:package_id;index;not null;comment:套餐ID" json:"package_id"`
|
||||
UsageType string `gorm:"column:usage_type;type:varchar(20);not null;comment:使用类型 single_card-单卡套餐 device-设备级套餐" json:"usage_type"`
|
||||
IotCardID uint `gorm:"column:iot_card_id;index;comment:IoT卡ID(单卡套餐时有值)" json:"iot_card_id"`
|
||||
DeviceID uint `gorm:"column:device_id;index;comment:设备ID(设备级套餐时有值)" json:"device_id"`
|
||||
DataLimitMB int64 `gorm:"column:data_limit_mb;type:bigint;not null;comment:流量限额(MB)" json:"data_limit_mb"`
|
||||
DataUsageMB int64 `gorm:"column:data_usage_mb;type:bigint;default:0;comment:已使用流量(MB)" json:"data_usage_mb"`
|
||||
RealDataUsageMB int64 `gorm:"column:real_data_usage_mb;type:bigint;default:0;comment:真流量使用(MB)" json:"real_data_usage_mb"`
|
||||
VirtualDataUsageMB int64 `gorm:"column:virtual_data_usage_mb;type:bigint;default:0;comment:虚流量使用(MB)" json:"virtual_data_usage_mb"`
|
||||
ActivatedAt time.Time `gorm:"column:activated_at;not null;comment:套餐生效时间" json:"activated_at"`
|
||||
ExpiresAt time.Time `gorm:"column:expires_at;not null;comment:套餐过期时间" json:"expires_at"`
|
||||
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-生效中 2-已用完 3-已过期" json:"status"`
|
||||
LastPackageCheckAt *time.Time `gorm:"column:last_package_check_at;comment:最后一次套餐流量检查时间" json:"last_package_check_at"`
|
||||
BaseModel `gorm:"embedded"`
|
||||
OrderID uint `gorm:"column:order_id;index;not null;comment:订单ID" json:"order_id"`
|
||||
PackageID uint `gorm:"column:package_id;index;not null;comment:套餐ID" json:"package_id"`
|
||||
UsageType string `gorm:"column:usage_type;type:varchar(20);not null;comment:使用类型 single_card-单卡套餐 device-设备级套餐" json:"usage_type"`
|
||||
IotCardID uint `gorm:"column:iot_card_id;index;comment:IoT卡ID(单卡套餐时有值)" json:"iot_card_id"`
|
||||
DeviceID uint `gorm:"column:device_id;index;comment:设备ID(设备级套餐时有值)" json:"device_id"`
|
||||
DataLimitMB int64 `gorm:"column:data_limit_mb;type:bigint;not null;comment:流量限额(MB)" json:"data_limit_mb"`
|
||||
DataUsageMB int64 `gorm:"column:data_usage_mb;type:bigint;default:0;comment:已使用流量(MB)" json:"data_usage_mb"`
|
||||
RealDataUsageMB int64 `gorm:"column:real_data_usage_mb;type:bigint;default:0;comment:真流量使用(MB)" json:"real_data_usage_mb"`
|
||||
VirtualDataUsageMB int64 `gorm:"column:virtual_data_usage_mb;type:bigint;default:0;comment:虚流量使用(MB)" json:"virtual_data_usage_mb"`
|
||||
ActivatedAt time.Time `gorm:"column:activated_at;not null;comment:套餐生效时间" json:"activated_at"`
|
||||
ExpiresAt time.Time `gorm:"column:expires_at;not null;comment:套餐过期时间" json:"expires_at"`
|
||||
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 0-待生效 1-生效中 2-已用完 3-已过期 4-已失效" json:"status"`
|
||||
LastPackageCheckAt *time.Time `gorm:"column:last_package_check_at;comment:最后一次套餐流量检查时间" json:"last_package_check_at"`
|
||||
Priority int `gorm:"column:priority;type:int;default:1;index:idx_package_usage_priority;comment:优先级(主套餐和加油包按此字段排队,数字越小优先级越高)" json:"priority"`
|
||||
MasterUsageID *uint `gorm:"column:master_usage_id;type:bigint;index:idx_package_usage_master_usage_id;comment:主套餐使用记录ID(加油包关联主套餐,主套餐此字段为NULL)" json:"master_usage_id"`
|
||||
HasIndependentExpiry bool `gorm:"column:has_independent_expiry;type:boolean;default:false;comment:加油包是否有独立有效期(true-有独立到期时间 false-跟随主套餐)" json:"has_independent_expiry"`
|
||||
PendingRealnameActivation bool `gorm:"column:pending_realname_activation;type:boolean;default:false;comment:是否等待实名激活(true-待实名后激活 false-已激活或不需实名)" json:"pending_realname_activation"`
|
||||
DataResetCycle string `gorm:"column:data_reset_cycle;type:varchar(20);comment:流量重置周期(从Package复制,用于历史记录)" json:"data_reset_cycle"`
|
||||
LastResetAt *time.Time `gorm:"column:last_reset_at;comment:最后一次流量重置时间" json:"last_reset_at"`
|
||||
NextResetAt *time.Time `gorm:"column:next_reset_at;index:idx_package_usage_next_reset_at;comment:下次流量重置时间(用于定时任务查询)" json:"next_reset_at"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
@@ -74,6 +85,23 @@ func (PackageUsage) TableName() string {
|
||||
return "tb_package_usage"
|
||||
}
|
||||
|
||||
// PackageUsageDailyRecord 套餐流量日记录模型
|
||||
// 记录每个套餐每天的流量使用情况,用于流量详单查询
|
||||
type PackageUsageDailyRecord struct {
|
||||
ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
|
||||
PackageUsageID uint `gorm:"column:package_usage_id;not null;uniqueIndex:idx_package_usage_daily_record_unique;index:idx_package_usage_daily_record_date;comment:套餐使用记录ID" json:"package_usage_id"`
|
||||
Date time.Time `gorm:"column:date;type:date;not null;uniqueIndex:idx_package_usage_daily_record_unique;comment:日期" json:"date"`
|
||||
DailyUsageMB int `gorm:"column:daily_usage_mb;type:int;default:0;comment:当日流量使用量(MB)" json:"daily_usage_mb"`
|
||||
CumulativeUsageMB int64 `gorm:"column:cumulative_usage_mb;type:bigint;default:0;comment:截止当日的累计流量(MB)" json:"cumulative_usage_mb"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;default:CURRENT_TIMESTAMP" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at;default:CURRENT_TIMESTAMP" json:"updated_at"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (PackageUsageDailyRecord) TableName() string {
|
||||
return "tb_package_usage_daily_record"
|
||||
}
|
||||
|
||||
// OneTimeCommissionConfig 一次性佣金规则配置
|
||||
type OneTimeCommissionConfig struct {
|
||||
Enable bool `json:"enable"`
|
||||
|
||||
116
internal/polling/data_reset_handler.go
Normal file
116
internal/polling/data_reset_handler.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package polling
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
packagepkg "github.com/break/junhong_cmp_fiber/internal/service/package"
|
||||
)
|
||||
|
||||
// DataResetHandler 流量重置调度处理器
|
||||
// 任务 20: 定期检查需要重置的套餐并调用 ResetService 执行重置
|
||||
type DataResetHandler struct {
|
||||
resetService *packagepkg.ResetService
|
||||
logger *zap.Logger
|
||||
|
||||
// 上次执行时间(用于限流,避免重复执行)
|
||||
lastDailyReset time.Time
|
||||
lastMonthlyReset time.Time
|
||||
lastYearlyReset time.Time
|
||||
}
|
||||
|
||||
// NewDataResetHandler 创建流量重置调度处理器
|
||||
func NewDataResetHandler(
|
||||
resetService *packagepkg.ResetService,
|
||||
logger *zap.Logger,
|
||||
) *DataResetHandler {
|
||||
return &DataResetHandler{
|
||||
resetService: resetService,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// HandleDataReset 任务 20.2: 处理流量重置调度
|
||||
// 每 10 秒被 Scheduler 调用一次,检查是否需要执行日/月/年重置
|
||||
func (h *DataResetHandler) HandleDataReset(ctx context.Context) error {
|
||||
now := time.Now()
|
||||
|
||||
// 任务 20.3: 日重置调度(每分钟检查一次,避免频繁查询数据库)
|
||||
if now.Sub(h.lastDailyReset) >= time.Minute {
|
||||
if err := h.processDailyReset(ctx); err != nil {
|
||||
h.logger.Warn("日重置调度失败", zap.Error(err))
|
||||
}
|
||||
h.lastDailyReset = now
|
||||
}
|
||||
|
||||
// 任务 20.4: 月重置调度(每分钟检查一次)
|
||||
if now.Sub(h.lastMonthlyReset) >= time.Minute {
|
||||
if err := h.processMonthlyReset(ctx); err != nil {
|
||||
h.logger.Warn("月重置调度失败", zap.Error(err))
|
||||
}
|
||||
h.lastMonthlyReset = now
|
||||
}
|
||||
|
||||
// 任务 20.5: 年重置调度(每分钟检查一次)
|
||||
if now.Sub(h.lastYearlyReset) >= time.Minute {
|
||||
if err := h.processYearlyReset(ctx); err != nil {
|
||||
h.logger.Warn("年重置调度失败", zap.Error(err))
|
||||
}
|
||||
h.lastYearlyReset = now
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// processDailyReset 任务 20.3: 日重置调度
|
||||
func (h *DataResetHandler) processDailyReset(ctx context.Context) error {
|
||||
if h.resetService == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
if err := h.resetService.ResetDailyUsage(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
h.logger.Info("日重置调度完成",
|
||||
zap.Duration("duration", time.Since(startTime)))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// processMonthlyReset 任务 20.4: 月重置调度
|
||||
func (h *DataResetHandler) processMonthlyReset(ctx context.Context) error {
|
||||
if h.resetService == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
if err := h.resetService.ResetMonthlyUsage(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
h.logger.Info("月重置调度完成",
|
||||
zap.Duration("duration", time.Since(startTime)))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// processYearlyReset 任务 20.5: 年重置调度
|
||||
func (h *DataResetHandler) processYearlyReset(ctx context.Context) error {
|
||||
if h.resetService == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
if err := h.resetService.ResetYearlyUsage(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
h.logger.Info("年重置调度完成",
|
||||
zap.Duration("duration", time.Since(startTime)))
|
||||
|
||||
return nil
|
||||
}
|
||||
368
internal/polling/package_activation_handler.go
Normal file
368
internal/polling/package_activation_handler.go
Normal file
@@ -0,0 +1,368 @@
|
||||
package polling
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"github.com/hibiken/asynq"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
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/pkg/constants"
|
||||
)
|
||||
|
||||
// PackageActivationHandler 套餐激活检查处理器
|
||||
// 任务 19: 处理主套餐过期、加油包级联失效、待生效主套餐激活
|
||||
type PackageActivationHandler struct {
|
||||
db *gorm.DB
|
||||
redis *redis.Client
|
||||
queueClient *asynq.Client
|
||||
packageUsageStore *postgres.PackageUsageStore
|
||||
activationService *packagepkg.ActivationService
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// PackageActivationPayload 套餐激活任务载荷
|
||||
type PackageActivationPayload struct {
|
||||
PackageUsageID uint `json:"package_usage_id"`
|
||||
CarrierType string `json:"carrier_type"` // "iot_card" 或 "device"
|
||||
CarrierID uint `json:"carrier_id"`
|
||||
ActivationType string `json:"activation_type"` // "queue" 或 "realname"
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
|
||||
// NewPackageActivationHandler 创建套餐激活检查处理器
|
||||
func NewPackageActivationHandler(
|
||||
db *gorm.DB,
|
||||
redis *redis.Client,
|
||||
queueClient *asynq.Client,
|
||||
activationService *packagepkg.ActivationService,
|
||||
logger *zap.Logger,
|
||||
) *PackageActivationHandler {
|
||||
return &PackageActivationHandler{
|
||||
db: db,
|
||||
redis: redis,
|
||||
queueClient: queueClient,
|
||||
packageUsageStore: postgres.NewPackageUsageStore(db, redis),
|
||||
activationService: activationService,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// HandlePackageActivationCheck 任务 19.2-19.5: 处理套餐激活检查
|
||||
// 每 10 秒调度一次,检查过期主套餐并激活下一个待生效主套餐
|
||||
func (h *PackageActivationHandler) HandlePackageActivationCheck(ctx context.Context) error {
|
||||
startTime := time.Now()
|
||||
|
||||
// 任务 19.2: 查询已过期的主套餐(status=1 AND expires_at <= NOW)
|
||||
expiredPackages, err := h.findExpiredMainPackages(ctx)
|
||||
if err != nil {
|
||||
h.logger.Error("查询过期主套餐失败", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
if len(expiredPackages) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
h.logger.Info("发现过期主套餐",
|
||||
zap.Int("count", len(expiredPackages)),
|
||||
zap.Duration("check_duration", time.Since(startTime)))
|
||||
|
||||
// 处理每个过期的主套餐
|
||||
for _, pkg := range expiredPackages {
|
||||
if err := h.processExpiredPackage(ctx, pkg); err != nil {
|
||||
h.logger.Error("处理过期套餐失败",
|
||||
zap.Uint("package_usage_id", pkg.ID),
|
||||
zap.Error(err))
|
||||
// 继续处理下一个,不中断
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
h.logger.Info("套餐激活检查完成",
|
||||
zap.Int("processed", len(expiredPackages)),
|
||||
zap.Duration("total_duration", time.Since(startTime)))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// findExpiredMainPackages 任务 19.2: 查询已过期的主套餐
|
||||
func (h *PackageActivationHandler) findExpiredMainPackages(ctx context.Context) ([]*model.PackageUsage, error) {
|
||||
var packages []*model.PackageUsage
|
||||
now := time.Now()
|
||||
|
||||
// 查询 status=1 (生效中) AND expires_at <= NOW AND master_usage_id IS NULL (主套餐)
|
||||
err := h.db.WithContext(ctx).
|
||||
Where("status = ?", constants.PackageUsageStatusActive).
|
||||
Where("expires_at <= ?", now).
|
||||
Where("master_usage_id IS NULL"). // 主套餐没有 master_usage_id
|
||||
Limit(1000). // 每次最多处理 1000 个,避免长事务
|
||||
Find(&packages).Error
|
||||
|
||||
return packages, err
|
||||
}
|
||||
|
||||
// processExpiredPackage 处理单个过期套餐
|
||||
func (h *PackageActivationHandler) processExpiredPackage(ctx context.Context, pkg *model.PackageUsage) error {
|
||||
return h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// 任务 19.3: 更新过期主套餐状态为 Expired (status=3)
|
||||
if err := tx.Model(pkg).Update("status", constants.PackageUsageStatusExpired).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
h.logger.Info("主套餐已过期",
|
||||
zap.Uint("package_usage_id", pkg.ID),
|
||||
zap.Time("expires_at", pkg.ExpiresAt))
|
||||
|
||||
// 任务 19.4: 加油包级联失效
|
||||
if err := h.invalidateAddons(ctx, tx, pkg.ID); err != nil {
|
||||
h.logger.Warn("加油包级联失效失败",
|
||||
zap.Uint("master_usage_id", pkg.ID),
|
||||
zap.Error(err))
|
||||
// 不返回错误,继续处理
|
||||
}
|
||||
|
||||
// 任务 19.5: 查询并激活下一个待生效主套餐
|
||||
carrierType, carrierID := h.getCarrierInfo(pkg)
|
||||
if carrierType != "" && carrierID > 0 {
|
||||
if err := h.activateNextPackage(ctx, tx, carrierType, carrierID); err != nil {
|
||||
h.logger.Warn("激活下一个待生效套餐失败",
|
||||
zap.String("carrier_type", carrierType),
|
||||
zap.Uint("carrier_id", carrierID),
|
||||
zap.Error(err))
|
||||
// 不返回错误,继续处理
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// invalidateAddons 任务 19.4: 加油包级联失效
|
||||
func (h *PackageActivationHandler) invalidateAddons(ctx context.Context, tx *gorm.DB, masterUsageID uint) error {
|
||||
// 查询主套餐下的所有加油包(status IN (0,1,2) 的加油包)
|
||||
result := tx.Model(&model.PackageUsage{}).
|
||||
Where("master_usage_id = ?", masterUsageID).
|
||||
Where("status IN ?", []int{
|
||||
constants.PackageUsageStatusPending,
|
||||
constants.PackageUsageStatusActive,
|
||||
constants.PackageUsageStatusDepleted,
|
||||
}).
|
||||
Update("status", constants.PackageUsageStatusInvalidated)
|
||||
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
|
||||
if result.RowsAffected > 0 {
|
||||
h.logger.Info("加油包已级联失效",
|
||||
zap.Uint("master_usage_id", masterUsageID),
|
||||
zap.Int64("invalidated_count", result.RowsAffected))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getCarrierInfo 获取载体信息
|
||||
func (h *PackageActivationHandler) getCarrierInfo(pkg *model.PackageUsage) (string, uint) {
|
||||
if pkg.IotCardID > 0 {
|
||||
return "iot_card", pkg.IotCardID
|
||||
}
|
||||
if pkg.DeviceID > 0 {
|
||||
return "device", pkg.DeviceID
|
||||
}
|
||||
return "", 0
|
||||
}
|
||||
|
||||
// activateNextPackage 任务 19.5: 激活下一个待生效主套餐
|
||||
func (h *PackageActivationHandler) activateNextPackage(ctx context.Context, tx *gorm.DB, carrierType string, carrierID uint) error {
|
||||
// 查询下一个待生效主套餐
|
||||
// WHERE status=0 AND master_usage_id IS NULL ORDER BY priority ASC LIMIT 1
|
||||
var nextPkg model.PackageUsage
|
||||
query := tx.Where("status = ?", constants.PackageUsageStatusPending).
|
||||
Where("master_usage_id IS NULL"). // 主套餐
|
||||
Order("priority ASC").
|
||||
Limit(1)
|
||||
|
||||
if carrierType == "iot_card" {
|
||||
query = query.Where("iot_card_id = ?", carrierID)
|
||||
} else if carrierType == "device" {
|
||||
query = query.Where("device_id = ?", carrierID)
|
||||
}
|
||||
|
||||
if err := query.First(&nextPkg).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
// 没有待生效套餐,正常情况
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// 提交 Asynq 任务进行激活(避免长事务)
|
||||
return h.enqueueActivationTask(ctx, nextPkg.ID, carrierType, carrierID, "queue")
|
||||
}
|
||||
|
||||
// enqueueActivationTask 提交套餐激活任务到 Asynq
|
||||
func (h *PackageActivationHandler) enqueueActivationTask(ctx context.Context, packageUsageID uint, carrierType string, carrierID uint, activationType string) error {
|
||||
payload := PackageActivationPayload{
|
||||
PackageUsageID: packageUsageID,
|
||||
CarrierType: carrierType,
|
||||
CarrierID: carrierID,
|
||||
ActivationType: activationType,
|
||||
Timestamp: time.Now().Unix(),
|
||||
}
|
||||
|
||||
payloadBytes, err := sonic.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
task := asynq.NewTask(constants.TaskTypePackageQueueActivation, payloadBytes,
|
||||
asynq.MaxRetry(3),
|
||||
asynq.Timeout(30*time.Second),
|
||||
asynq.Queue(constants.QueueDefault),
|
||||
)
|
||||
|
||||
_, err = h.queueClient.Enqueue(task)
|
||||
if err != nil {
|
||||
h.logger.Error("提交套餐激活任务失败",
|
||||
zap.Uint("package_usage_id", packageUsageID),
|
||||
zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
h.logger.Info("已提交套餐激活任务",
|
||||
zap.Uint("package_usage_id", packageUsageID),
|
||||
zap.String("activation_type", activationType))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandlePackageQueueActivation 处理套餐排队激活任务(Asynq Handler)
|
||||
// 任务 23: 由 Asynq 调用,执行实际的套餐激活逻辑
|
||||
func (h *PackageActivationHandler) HandlePackageQueueActivation(ctx context.Context, t *asynq.Task) error {
|
||||
var payload PackageActivationPayload
|
||||
if err := sonic.Unmarshal(t.Payload(), &payload); err != nil {
|
||||
h.logger.Error("解析套餐激活任务载荷失败", zap.Error(err))
|
||||
return nil // 不重试
|
||||
}
|
||||
|
||||
h.logger.Info("开始执行套餐激活",
|
||||
zap.Uint("package_usage_id", payload.PackageUsageID),
|
||||
zap.String("activation_type", payload.ActivationType))
|
||||
|
||||
// 查询套餐使用记录
|
||||
var pkg model.PackageUsage
|
||||
if err := h.db.First(&pkg, payload.PackageUsageID).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
h.logger.Warn("套餐使用记录不存在", zap.Uint("package_usage_id", payload.PackageUsageID))
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// 幂等性检查:如果已经是生效状态,跳过
|
||||
if pkg.Status == constants.PackageUsageStatusActive {
|
||||
h.logger.Info("套餐已激活,跳过",
|
||||
zap.Uint("package_usage_id", payload.PackageUsageID))
|
||||
return nil
|
||||
}
|
||||
|
||||
// 调用 ActivationService 执行激活
|
||||
if h.activationService != nil {
|
||||
if err := h.activationService.ActivateQueuedPackage(ctx, payload.CarrierType, payload.CarrierID); err != nil {
|
||||
h.logger.Error("套餐激活失败",
|
||||
zap.Uint("package_usage_id", payload.PackageUsageID),
|
||||
zap.String("carrier_type", payload.CarrierType),
|
||||
zap.Uint("carrier_id", payload.CarrierID),
|
||||
zap.Error(err))
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// ActivationService 未注入,直接更新状态
|
||||
now := time.Now()
|
||||
if err := h.db.Model(&pkg).Updates(map[string]interface{}{
|
||||
"status": constants.PackageUsageStatusActive,
|
||||
"activated_at": now,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
h.logger.Info("套餐激活成功",
|
||||
zap.Uint("package_usage_id", payload.PackageUsageID))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandlePackageFirstActivation 处理首次实名激活任务(Asynq Handler)
|
||||
// 任务 22: 由 Asynq 调用,执行首次实名后的套餐激活
|
||||
func (h *PackageActivationHandler) HandlePackageFirstActivation(ctx context.Context, t *asynq.Task) error {
|
||||
var payload PackageActivationPayload
|
||||
if err := sonic.Unmarshal(t.Payload(), &payload); err != nil {
|
||||
h.logger.Error("解析首次实名激活任务载荷失败", zap.Error(err))
|
||||
return nil // 不重试
|
||||
}
|
||||
|
||||
h.logger.Info("开始执行首次实名激活",
|
||||
zap.Uint("package_usage_id", payload.PackageUsageID),
|
||||
zap.String("carrier_type", payload.CarrierType),
|
||||
zap.Uint("carrier_id", payload.CarrierID))
|
||||
|
||||
// 任务 22.4: 幂等性检查
|
||||
var pkg model.PackageUsage
|
||||
if err := h.db.First(&pkg, payload.PackageUsageID).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
h.logger.Warn("套餐使用记录不存在", zap.Uint("package_usage_id", payload.PackageUsageID))
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// 检查 pending_realname_activation 是否已为 false(已处理过)
|
||||
if !pkg.PendingRealnameActivation {
|
||||
h.logger.Info("套餐已处理过首次实名激活,跳过",
|
||||
zap.Uint("package_usage_id", payload.PackageUsageID))
|
||||
return nil
|
||||
}
|
||||
|
||||
// 如果已经是生效状态,跳过
|
||||
if pkg.Status == constants.PackageUsageStatusActive {
|
||||
h.logger.Info("套餐已激活,跳过",
|
||||
zap.Uint("package_usage_id", payload.PackageUsageID))
|
||||
return nil
|
||||
}
|
||||
|
||||
// 任务 22.3: 调用 ActivationService.ActivateByRealname 激活套餐
|
||||
if h.activationService != nil {
|
||||
if err := h.activationService.ActivateByRealname(ctx, payload.CarrierType, payload.CarrierID); err != nil {
|
||||
h.logger.Error("首次实名激活失败",
|
||||
zap.Uint("package_usage_id", payload.PackageUsageID),
|
||||
zap.String("carrier_type", payload.CarrierType),
|
||||
zap.Uint("carrier_id", payload.CarrierID),
|
||||
zap.Error(err))
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// ActivationService 未注入,直接更新状态(备用逻辑)
|
||||
now := time.Now()
|
||||
if err := h.db.Model(&pkg).Updates(map[string]any{
|
||||
"status": constants.PackageUsageStatusActive,
|
||||
"activated_at": now,
|
||||
"pending_realname_activation": false,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
h.logger.Info("首次实名激活成功",
|
||||
zap.Uint("package_usage_id", payload.PackageUsageID))
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -28,6 +28,11 @@ type Scheduler struct {
|
||||
iotCardStore *postgres.IotCardStore
|
||||
concurrencyStore *postgres.PollingConcurrencyConfigStore
|
||||
|
||||
// 任务 19: 套餐激活检查处理器
|
||||
packageActivationHandler *PackageActivationHandler
|
||||
// 任务 20: 流量重置调度处理器
|
||||
dataResetHandler *DataResetHandler
|
||||
|
||||
// 配置缓存
|
||||
configCache []*model.PollingConfig
|
||||
configCacheLock sync.RWMutex
|
||||
@@ -87,13 +92,15 @@ func NewScheduler(
|
||||
logger *zap.Logger,
|
||||
) *Scheduler {
|
||||
return &Scheduler{
|
||||
db: db,
|
||||
redis: redisClient,
|
||||
queueClient: queueClient,
|
||||
logger: logger,
|
||||
configStore: postgres.NewPollingConfigStore(db),
|
||||
iotCardStore: postgres.NewIotCardStore(db, redisClient),
|
||||
concurrencyStore: postgres.NewPollingConcurrencyConfigStore(db),
|
||||
db: db,
|
||||
redis: redisClient,
|
||||
queueClient: queueClient,
|
||||
logger: logger,
|
||||
configStore: postgres.NewPollingConfigStore(db),
|
||||
iotCardStore: postgres.NewIotCardStore(db, redisClient),
|
||||
concurrencyStore: postgres.NewPollingConcurrencyConfigStore(db),
|
||||
packageActivationHandler: NewPackageActivationHandler(db, redisClient, queueClient, nil, logger),
|
||||
dataResetHandler: NewDataResetHandler(nil, logger), // ResetService 需要通过 SetResetService 注入
|
||||
initProgress: &InitProgress{
|
||||
Status: "pending",
|
||||
},
|
||||
@@ -241,6 +248,20 @@ func (s *Scheduler) processSchedule(ctx context.Context) {
|
||||
s.processTimedQueue(ctx, constants.RedisPollingQueueRealnameKey(), constants.TaskTypePollingRealname, now)
|
||||
s.processTimedQueue(ctx, constants.RedisPollingQueueCarddataKey(), constants.TaskTypePollingCarddata, now)
|
||||
s.processTimedQueue(ctx, constants.RedisPollingQueuePackageKey(), constants.TaskTypePollingPackage, now)
|
||||
|
||||
// 任务 19.6: 套餐激活检查(每次调度都执行,内部会限流)
|
||||
if s.packageActivationHandler != nil {
|
||||
if err := s.packageActivationHandler.HandlePackageActivationCheck(ctx); err != nil {
|
||||
s.logger.Warn("套餐激活检查失败", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// 任务 20.6: 流量重置调度(每次调度都执行,内部会限流)
|
||||
if s.dataResetHandler != nil {
|
||||
if err := s.dataResetHandler.HandleDataReset(ctx); err != nil {
|
||||
s.logger.Warn("流量重置调度失败", zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// processManualQueue 处理手动触发队列
|
||||
@@ -709,3 +730,15 @@ func (s *Scheduler) IsInitCompleted() bool {
|
||||
func (s *Scheduler) RefreshConfigs(ctx context.Context) error {
|
||||
return s.loadConfigs(ctx)
|
||||
}
|
||||
|
||||
// SetResetService 设置流量重置服务(用于依赖注入)
|
||||
func (s *Scheduler) SetResetService(resetService interface{}) {
|
||||
if rs, ok := resetService.(*DataResetHandler); ok {
|
||||
s.dataResetHandler = rs
|
||||
}
|
||||
}
|
||||
|
||||
// SetActivationService 设置套餐激活服务(用于依赖注入)
|
||||
func (s *Scheduler) SetActivationService(activationHandler *PackageActivationHandler) {
|
||||
s.packageActivationHandler = activationHandler
|
||||
}
|
||||
|
||||
@@ -74,6 +74,9 @@ func RegisterAdminRoutes(router fiber.Router, handlers *bootstrap.Handlers, midd
|
||||
if handlers.Package != nil {
|
||||
registerPackageRoutes(authGroup, handlers.Package, doc, basePath)
|
||||
}
|
||||
if handlers.PackageUsage != nil {
|
||||
registerPackageUsageRoutes(authGroup, handlers.PackageUsage, doc, basePath)
|
||||
}
|
||||
if handlers.ShopSeriesAllocation != nil {
|
||||
registerShopSeriesAllocationRoutes(authGroup, handlers.ShopSeriesAllocation, doc, basePath)
|
||||
}
|
||||
|
||||
@@ -21,4 +21,7 @@ func RegisterH5Routes(router fiber.Router, handlers *bootstrap.Handlers, middlew
|
||||
if handlers.EnterpriseDeviceH5 != nil {
|
||||
registerH5EnterpriseDeviceRoutes(authGroup, handlers.EnterpriseDeviceH5, doc, basePath)
|
||||
}
|
||||
if handlers.H5PackageUsage != nil {
|
||||
registerH5PackageUsageRoutes(authGroup, handlers.H5PackageUsage, doc, basePath)
|
||||
}
|
||||
}
|
||||
|
||||
23
internal/routes/h5_package_usage.go
Normal file
23
internal/routes/h5_package_usage.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/handler/h5"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/openapi"
|
||||
)
|
||||
|
||||
// registerH5PackageUsageRoutes 注册 H5 端套餐使用情况路由
|
||||
func registerH5PackageUsageRoutes(router fiber.Router, handler *h5.PackageUsageHandler, doc *openapi.Generator, basePath string) {
|
||||
packages := router.Group("/packages")
|
||||
groupPath := basePath + "/packages"
|
||||
|
||||
Register(packages, doc, groupPath, "GET", "/my-usage", handler.GetMyUsage, RouteSpec{
|
||||
Summary: "获取我的套餐使用情况",
|
||||
Tags: []string{"H5-套餐"},
|
||||
Input: nil,
|
||||
Output: new(dto.PackageUsageCustomerViewResponse),
|
||||
Auth: true,
|
||||
})
|
||||
}
|
||||
23
internal/routes/package_usage.go
Normal file
23
internal/routes/package_usage.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/openapi"
|
||||
)
|
||||
|
||||
// registerPackageUsageRoutes 注册套餐使用记录相关路由
|
||||
func registerPackageUsageRoutes(router fiber.Router, handler *admin.PackageUsageHandler, doc *openapi.Generator, basePath string) {
|
||||
packageUsage := router.Group("/package-usage")
|
||||
groupPath := basePath + "/package-usage"
|
||||
|
||||
Register(packageUsage, doc, groupPath, "GET", "/:id/daily-records", handler.GetDailyRecords, RouteSpec{
|
||||
Summary: "获取套餐流量详单",
|
||||
Tags: []string{"套餐使用记录"},
|
||||
Input: new(dto.IDReq),
|
||||
Output: new(dto.PackageUsageDetailResponse),
|
||||
Auth: true,
|
||||
})
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/tests/testutils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGetRoleIDsForAccount(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
testutils.CleanTestRedisKeys(t, rdb)
|
||||
|
||||
accountStore := postgres.NewAccountStore(tx, rdb)
|
||||
roleStore := postgres.NewRoleStore(tx)
|
||||
accountRoleStore := postgres.NewAccountRoleStore(tx, rdb)
|
||||
shopRoleStore := postgres.NewShopRoleStore(tx, rdb)
|
||||
|
||||
service := New(
|
||||
accountStore,
|
||||
roleStore,
|
||||
accountRoleStore,
|
||||
shopRoleStore,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("超级管理员返回空数组", func(t *testing.T) {
|
||||
account := &model.Account{
|
||||
Username: "admin_roletest",
|
||||
Phone: "13800010001",
|
||||
Password: "hashed",
|
||||
UserType: constants.UserTypeSuperAdmin,
|
||||
Status: constants.StatusEnabled,
|
||||
}
|
||||
require.NoError(t, accountStore.Create(ctx, account))
|
||||
|
||||
roleIDs, err := service.GetRoleIDsForAccount(ctx, account.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, roleIDs)
|
||||
})
|
||||
|
||||
t.Run("平台用户返回账号级角色", func(t *testing.T) {
|
||||
account := &model.Account{
|
||||
Username: "platform_roletest",
|
||||
Phone: "13800010002",
|
||||
Password: "hashed",
|
||||
UserType: constants.UserTypePlatform,
|
||||
Status: constants.StatusEnabled,
|
||||
}
|
||||
require.NoError(t, accountStore.Create(ctx, account))
|
||||
|
||||
role := &model.Role{
|
||||
RoleName: "平台管理员",
|
||||
RoleType: constants.RoleTypePlatform,
|
||||
Status: constants.StatusEnabled,
|
||||
}
|
||||
require.NoError(t, roleStore.Create(ctx, role))
|
||||
|
||||
accountRole := &model.AccountRole{
|
||||
AccountID: account.ID,
|
||||
RoleID: role.ID,
|
||||
Status: constants.StatusEnabled,
|
||||
Creator: 1,
|
||||
Updater: 1,
|
||||
}
|
||||
require.NoError(t, accountRoleStore.Create(ctx, accountRole))
|
||||
|
||||
roleIDs, err := service.GetRoleIDsForAccount(ctx, account.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []uint{role.ID}, roleIDs)
|
||||
})
|
||||
|
||||
t.Run("代理账号有账号级角色,不继承店铺角色", func(t *testing.T) {
|
||||
shopID := uint(1)
|
||||
account := &model.Account{
|
||||
Username: "agent_with_roletest",
|
||||
Phone: "13800010003",
|
||||
Password: "hashed",
|
||||
UserType: constants.UserTypeAgent,
|
||||
ShopID: &shopID,
|
||||
Status: constants.StatusEnabled,
|
||||
}
|
||||
require.NoError(t, accountStore.Create(ctx, account))
|
||||
|
||||
accountRole := &model.Role{
|
||||
RoleName: "账号角色",
|
||||
RoleType: constants.RoleTypeCustomer,
|
||||
Status: constants.StatusEnabled,
|
||||
}
|
||||
require.NoError(t, roleStore.Create(ctx, accountRole))
|
||||
|
||||
shopRole := &model.Role{
|
||||
RoleName: "店铺角色",
|
||||
RoleType: constants.RoleTypeCustomer,
|
||||
Status: constants.StatusEnabled,
|
||||
}
|
||||
require.NoError(t, roleStore.Create(ctx, shopRole))
|
||||
|
||||
require.NoError(t, accountRoleStore.Create(ctx, &model.AccountRole{
|
||||
AccountID: account.ID,
|
||||
RoleID: accountRole.ID,
|
||||
Status: constants.StatusEnabled,
|
||||
Creator: 1,
|
||||
Updater: 1,
|
||||
}))
|
||||
|
||||
require.NoError(t, shopRoleStore.Create(ctx, &model.ShopRole{
|
||||
ShopID: shopID,
|
||||
RoleID: shopRole.ID,
|
||||
Status: constants.StatusEnabled,
|
||||
Creator: 1,
|
||||
Updater: 1,
|
||||
}))
|
||||
|
||||
roleIDs, err := service.GetRoleIDsForAccount(ctx, account.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []uint{accountRole.ID}, roleIDs)
|
||||
})
|
||||
|
||||
t.Run("代理账号无账号级角色,继承店铺角色", func(t *testing.T) {
|
||||
shopID := uint(2)
|
||||
account := &model.Account{
|
||||
Username: "agent_inheritest",
|
||||
Phone: "13800010004",
|
||||
Password: "hashed",
|
||||
UserType: constants.UserTypeAgent,
|
||||
ShopID: &shopID,
|
||||
Status: constants.StatusEnabled,
|
||||
}
|
||||
require.NoError(t, accountStore.Create(ctx, account))
|
||||
|
||||
shopRole := &model.Role{
|
||||
RoleName: "店铺默认角色",
|
||||
RoleType: constants.RoleTypeCustomer,
|
||||
Status: constants.StatusEnabled,
|
||||
}
|
||||
require.NoError(t, roleStore.Create(ctx, shopRole))
|
||||
|
||||
require.NoError(t, shopRoleStore.Create(ctx, &model.ShopRole{
|
||||
ShopID: shopID,
|
||||
RoleID: shopRole.ID,
|
||||
Status: constants.StatusEnabled,
|
||||
Creator: 1,
|
||||
Updater: 1,
|
||||
}))
|
||||
|
||||
roleIDs, err := service.GetRoleIDsForAccount(ctx, account.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []uint{shopRole.ID}, roleIDs)
|
||||
})
|
||||
|
||||
t.Run("代理账号无角色且店铺无角色,返回空数组", func(t *testing.T) {
|
||||
shopID := uint(3)
|
||||
account := &model.Account{
|
||||
Username: "agent_notest",
|
||||
Phone: "13800010005",
|
||||
Password: "hashed",
|
||||
UserType: constants.UserTypeAgent,
|
||||
ShopID: &shopID,
|
||||
Status: constants.StatusEnabled,
|
||||
}
|
||||
require.NoError(t, accountStore.Create(ctx, account))
|
||||
|
||||
roleIDs, err := service.GetRoleIDsForAccount(ctx, account.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, roleIDs)
|
||||
})
|
||||
|
||||
t.Run("企业账号返回账号级角色", func(t *testing.T) {
|
||||
enterpriseID := uint(1)
|
||||
account := &model.Account{
|
||||
Username: "enterprise_roletest",
|
||||
Phone: "13800010006",
|
||||
Password: "hashed",
|
||||
UserType: constants.UserTypeEnterprise,
|
||||
EnterpriseID: &enterpriseID,
|
||||
Status: constants.StatusEnabled,
|
||||
}
|
||||
require.NoError(t, accountStore.Create(ctx, account))
|
||||
|
||||
role := &model.Role{
|
||||
RoleName: "企业管理员",
|
||||
RoleType: constants.RoleTypeCustomer,
|
||||
Status: constants.StatusEnabled,
|
||||
}
|
||||
require.NoError(t, roleStore.Create(ctx, role))
|
||||
|
||||
accountRole := &model.AccountRole{
|
||||
AccountID: account.ID,
|
||||
RoleID: role.ID,
|
||||
Status: constants.StatusEnabled,
|
||||
Creator: 1,
|
||||
Updater: 1,
|
||||
}
|
||||
require.NoError(t, accountRoleStore.Create(ctx, accountRole))
|
||||
|
||||
roleIDs, err := service.GetRoleIDsForAccount(ctx, account.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []uint{role.ID}, roleIDs)
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,145 +0,0 @@
|
||||
package account_audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
type MockAccountOperationLogStore struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *MockAccountOperationLogStore) Create(ctx context.Context, log *model.AccountOperationLog) error {
|
||||
args := m.Called(ctx, log)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func TestLogOperation_Success(t *testing.T) {
|
||||
mockStore := new(MockAccountOperationLogStore)
|
||||
service := NewService(mockStore)
|
||||
|
||||
log := &model.AccountOperationLog{
|
||||
OperatorID: 1,
|
||||
OperatorType: 2,
|
||||
OperatorName: "admin",
|
||||
OperationType: "create",
|
||||
OperationDesc: "创建账号: testuser",
|
||||
}
|
||||
|
||||
mockStore.On("Create", mock.Anything, log).Return(nil)
|
||||
|
||||
ctx := context.Background()
|
||||
service.LogOperation(ctx, log)
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
mockStore.AssertCalled(t, "Create", mock.Anything, log)
|
||||
}
|
||||
|
||||
func TestLogOperation_Failure(t *testing.T) {
|
||||
mockStore := new(MockAccountOperationLogStore)
|
||||
service := NewService(mockStore)
|
||||
|
||||
log := &model.AccountOperationLog{
|
||||
OperatorID: 1,
|
||||
OperatorType: 2,
|
||||
OperatorName: "admin",
|
||||
OperationType: "create",
|
||||
OperationDesc: "创建账号: testuser",
|
||||
}
|
||||
|
||||
mockStore.On("Create", mock.Anything, log).Return(errors.New("database error"))
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
assert.NotPanics(t, func() {
|
||||
service.LogOperation(ctx, log)
|
||||
})
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
mockStore.AssertCalled(t, "Create", mock.Anything, log)
|
||||
}
|
||||
|
||||
func TestLogOperation_NonBlocking(t *testing.T) {
|
||||
mockStore := new(MockAccountOperationLogStore)
|
||||
service := NewService(mockStore)
|
||||
|
||||
log := &model.AccountOperationLog{
|
||||
OperatorID: 1,
|
||||
OperatorType: 2,
|
||||
OperatorName: "admin",
|
||||
OperationType: "create",
|
||||
OperationDesc: "创建账号: testuser",
|
||||
}
|
||||
|
||||
mockStore.On("Create", mock.Anything, log).Run(func(args mock.Arguments) {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}).Return(nil)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
start := time.Now()
|
||||
service.LogOperation(ctx, log)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
assert.Less(t, elapsed, 50*time.Millisecond, "LogOperation should return immediately")
|
||||
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
mockStore.AssertCalled(t, "Create", mock.Anything, log)
|
||||
}
|
||||
|
||||
func TestNewService(t *testing.T) {
|
||||
mockStore := new(MockAccountOperationLogStore)
|
||||
service := NewService(mockStore)
|
||||
|
||||
assert.NotNil(t, service)
|
||||
assert.Equal(t, mockStore, service.store)
|
||||
}
|
||||
|
||||
func TestLogOperation_WithAllFields(t *testing.T) {
|
||||
mockStore := new(MockAccountOperationLogStore)
|
||||
service := NewService(mockStore)
|
||||
|
||||
targetAccountID := uint(10)
|
||||
targetUsername := "targetuser"
|
||||
targetUserType := 3
|
||||
requestID := "req-12345"
|
||||
ipAddress := "127.0.0.1"
|
||||
userAgent := "Mozilla/5.0"
|
||||
|
||||
log := &model.AccountOperationLog{
|
||||
OperatorID: 1,
|
||||
OperatorType: 2,
|
||||
OperatorName: "admin",
|
||||
TargetAccountID: &targetAccountID,
|
||||
TargetUsername: &targetUsername,
|
||||
TargetUserType: &targetUserType,
|
||||
OperationType: "update",
|
||||
OperationDesc: "更新账号: targetuser",
|
||||
BeforeData: model.JSONB{
|
||||
"username": "oldname",
|
||||
},
|
||||
AfterData: model.JSONB{
|
||||
"username": "newname",
|
||||
},
|
||||
RequestID: &requestID,
|
||||
IPAddress: &ipAddress,
|
||||
UserAgent: &userAgent,
|
||||
}
|
||||
|
||||
mockStore.On("Create", mock.Anything, log).Return(nil)
|
||||
|
||||
ctx := context.Background()
|
||||
service.LogOperation(ctx, log)
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
mockStore.AssertCalled(t, "Create", mock.Anything, log)
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestClassifyPermissions_PlatformFilter(t *testing.T) {
|
||||
logger, _ := zap.NewDevelopment()
|
||||
service := &Service{logger: logger}
|
||||
|
||||
permissions := []*model.Permission{
|
||||
{
|
||||
Model: gorm.Model{ID: 1},
|
||||
PermCode: "dashboard:menu",
|
||||
PermName: "仪表盘",
|
||||
PermType: constants.PermissionTypeMenu,
|
||||
Platform: constants.PlatformAll,
|
||||
Status: constants.StatusEnabled,
|
||||
},
|
||||
{
|
||||
Model: gorm.Model{ID: 2},
|
||||
PermCode: "user:menu",
|
||||
PermName: "用户管理",
|
||||
PermType: constants.PermissionTypeMenu,
|
||||
Platform: constants.PlatformWeb,
|
||||
Status: constants.StatusEnabled,
|
||||
},
|
||||
{
|
||||
Model: gorm.Model{ID: 3},
|
||||
PermCode: "mobile:menu",
|
||||
PermName: "移动端菜单",
|
||||
PermType: constants.PermissionTypeMenu,
|
||||
Platform: constants.PlatformH5,
|
||||
Status: constants.StatusEnabled,
|
||||
},
|
||||
}
|
||||
|
||||
allCodes, menus, buttons, err := service.classifyPermissions(permissions, constants.PlatformWeb)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, allCodes, 2)
|
||||
assert.Contains(t, allCodes, "dashboard:menu")
|
||||
assert.Contains(t, allCodes, "user:menu")
|
||||
assert.NotContains(t, allCodes, "mobile:menu")
|
||||
assert.Len(t, menus, 2)
|
||||
assert.Empty(t, buttons)
|
||||
}
|
||||
|
||||
func TestClassifyPermissions_MenuAndButton(t *testing.T) {
|
||||
logger, _ := zap.NewDevelopment()
|
||||
service := &Service{logger: logger}
|
||||
|
||||
permissions := []*model.Permission{
|
||||
{
|
||||
Model: gorm.Model{ID: 1},
|
||||
PermCode: "user:menu",
|
||||
PermName: "用户管理",
|
||||
PermType: constants.PermissionTypeMenu,
|
||||
Platform: constants.PlatformAll,
|
||||
Status: constants.StatusEnabled,
|
||||
},
|
||||
{
|
||||
Model: gorm.Model{ID: 2},
|
||||
PermCode: "user:create",
|
||||
PermName: "创建用户",
|
||||
PermType: constants.PermissionTypeButton,
|
||||
Platform: constants.PlatformAll,
|
||||
Status: constants.StatusEnabled,
|
||||
},
|
||||
{
|
||||
Model: gorm.Model{ID: 3},
|
||||
PermCode: "user:delete",
|
||||
PermName: "删除用户",
|
||||
PermType: constants.PermissionTypeButton,
|
||||
Platform: constants.PlatformAll,
|
||||
Status: constants.StatusEnabled,
|
||||
},
|
||||
}
|
||||
|
||||
allCodes, menus, buttons, err := service.classifyPermissions(permissions, constants.PlatformWeb)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, allCodes, 3)
|
||||
assert.Len(t, menus, 1)
|
||||
assert.Equal(t, "user:menu", menus[0].PermCode)
|
||||
assert.Len(t, buttons, 2)
|
||||
assert.Contains(t, buttons, "user:create")
|
||||
assert.Contains(t, buttons, "user:delete")
|
||||
}
|
||||
|
||||
func TestClassifyPermissions_AllPermissions(t *testing.T) {
|
||||
logger, _ := zap.NewDevelopment()
|
||||
service := &Service{logger: logger}
|
||||
|
||||
permissions := []*model.Permission{
|
||||
{
|
||||
Model: gorm.Model{ID: 1},
|
||||
PermCode: "menu1",
|
||||
PermName: "菜单1",
|
||||
PermType: constants.PermissionTypeMenu,
|
||||
Platform: constants.PlatformAll,
|
||||
Status: constants.StatusEnabled,
|
||||
},
|
||||
{
|
||||
Model: gorm.Model{ID: 2},
|
||||
PermCode: "button1",
|
||||
PermName: "按钮1",
|
||||
PermType: constants.PermissionTypeButton,
|
||||
Platform: constants.PlatformAll,
|
||||
Status: constants.StatusEnabled,
|
||||
},
|
||||
}
|
||||
|
||||
allCodes, _, _, err := service.classifyPermissions(permissions, constants.PlatformWeb)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, allCodes, 2)
|
||||
assert.Contains(t, allCodes, "menu1")
|
||||
assert.Contains(t, allCodes, "button1")
|
||||
}
|
||||
|
||||
func TestClassifyPermissions_PlatformAll(t *testing.T) {
|
||||
logger, _ := zap.NewDevelopment()
|
||||
service := &Service{logger: logger}
|
||||
|
||||
permissions := []*model.Permission{
|
||||
{
|
||||
Model: gorm.Model{ID: 1},
|
||||
PermCode: "common:menu",
|
||||
PermName: "通用菜单",
|
||||
PermType: constants.PermissionTypeMenu,
|
||||
Platform: constants.PlatformAll,
|
||||
Status: constants.StatusEnabled,
|
||||
},
|
||||
}
|
||||
|
||||
allCodesWeb, menusWeb, _, errWeb := service.classifyPermissions(permissions, constants.PlatformWeb)
|
||||
allCodesH5, menusH5, _, errH5 := service.classifyPermissions(permissions, constants.PlatformH5)
|
||||
|
||||
assert.NoError(t, errWeb)
|
||||
assert.NoError(t, errH5)
|
||||
assert.Len(t, allCodesWeb, 1)
|
||||
assert.Len(t, allCodesH5, 1)
|
||||
assert.Len(t, menusWeb, 1)
|
||||
assert.Len(t, menusH5, 1)
|
||||
assert.Equal(t, "common:menu", menusWeb[0].PermCode)
|
||||
assert.Equal(t, "common:menu", menusH5[0].PermCode)
|
||||
}
|
||||
|
||||
func TestClassifyPermissions_DisabledPermissions(t *testing.T) {
|
||||
logger, _ := zap.NewDevelopment()
|
||||
service := &Service{logger: logger}
|
||||
|
||||
permissions := []*model.Permission{
|
||||
{
|
||||
Model: gorm.Model{ID: 1},
|
||||
PermCode: "enabled:menu",
|
||||
PermName: "启用菜单",
|
||||
PermType: constants.PermissionTypeMenu,
|
||||
Platform: constants.PlatformAll,
|
||||
Status: constants.StatusEnabled,
|
||||
},
|
||||
{
|
||||
Model: gorm.Model{ID: 2},
|
||||
PermCode: "disabled:menu",
|
||||
PermName: "禁用菜单",
|
||||
PermType: constants.PermissionTypeMenu,
|
||||
Platform: constants.PlatformAll,
|
||||
Status: constants.StatusDisabled,
|
||||
},
|
||||
}
|
||||
|
||||
allCodes, menus, _, err := service.classifyPermissions(permissions, constants.PlatformWeb)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, allCodes, 1)
|
||||
assert.Contains(t, allCodes, "enabled:menu")
|
||||
assert.NotContains(t, allCodes, "disabled:menu")
|
||||
assert.Len(t, menus, 1)
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestBuildMenuTree_RootNodes(t *testing.T) {
|
||||
logger, _ := zap.NewDevelopment()
|
||||
service := &Service{logger: logger}
|
||||
|
||||
permissions := []*model.Permission{
|
||||
{Model: gorm.Model{ID: 1}, PermCode: "user:menu", PermName: "用户管理", URL: "/users", Sort: 1, ParentID: nil},
|
||||
{Model: gorm.Model{ID: 2}, PermCode: "order:menu", PermName: "订单管理", URL: "/orders", Sort: 2, ParentID: nil},
|
||||
{Model: gorm.Model{ID: 3}, PermCode: "dashboard:menu", PermName: "仪表盘", URL: "/dashboard", Sort: 0, ParentID: nil},
|
||||
}
|
||||
|
||||
result := service.buildMenuTree(permissions)
|
||||
|
||||
assert.Len(t, result, 3)
|
||||
assert.Equal(t, "dashboard:menu", result[0].PermCode)
|
||||
assert.Equal(t, "user:menu", result[1].PermCode)
|
||||
assert.Equal(t, "order:menu", result[2].PermCode)
|
||||
assert.Empty(t, result[0].Children)
|
||||
}
|
||||
|
||||
func TestBuildMenuTree_MultiLevel(t *testing.T) {
|
||||
logger, _ := zap.NewDevelopment()
|
||||
service := &Service{logger: logger}
|
||||
|
||||
parentID1 := uint(1)
|
||||
parentID2 := uint(3)
|
||||
|
||||
permissions := []*model.Permission{
|
||||
{Model: gorm.Model{ID: 1}, PermCode: "user:menu", PermName: "用户管理", URL: "/users", Sort: 1, ParentID: nil},
|
||||
{Model: gorm.Model{ID: 2}, PermCode: "user:list:menu", PermName: "用户列表", URL: "/users/list", Sort: 10, ParentID: &parentID1},
|
||||
{Model: gorm.Model{ID: 3}, PermCode: "user:role:menu", PermName: "角色管理", URL: "/users/roles", Sort: 5, ParentID: &parentID1},
|
||||
{Model: gorm.Model{ID: 4}, PermCode: "user:role:detail:menu", PermName: "角色详情", URL: "/users/roles/detail", Sort: 1, ParentID: &parentID2},
|
||||
}
|
||||
|
||||
result := service.buildMenuTree(permissions)
|
||||
|
||||
assert.Len(t, result, 1)
|
||||
assert.Equal(t, "user:menu", result[0].PermCode)
|
||||
assert.Len(t, result[0].Children, 2)
|
||||
assert.Equal(t, "user:role:menu", result[0].Children[0].PermCode)
|
||||
assert.Equal(t, "user:list:menu", result[0].Children[1].PermCode)
|
||||
assert.Len(t, result[0].Children[0].Children, 1)
|
||||
assert.Equal(t, "user:role:detail:menu", result[0].Children[0].Children[0].PermCode)
|
||||
}
|
||||
|
||||
func TestBuildMenuTree_OrphanNodes(t *testing.T) {
|
||||
logger, _ := zap.NewDevelopment()
|
||||
service := &Service{logger: logger}
|
||||
|
||||
nonExistentParentID := uint(999)
|
||||
|
||||
permissions := []*model.Permission{
|
||||
{Model: gorm.Model{ID: 1}, PermCode: "user:menu", PermName: "用户管理", URL: "/users", Sort: 1, ParentID: nil},
|
||||
{Model: gorm.Model{ID: 2}, PermCode: "orphan:menu", PermName: "孤儿菜单", URL: "/orphan", Sort: 0, ParentID: &nonExistentParentID},
|
||||
}
|
||||
|
||||
result := service.buildMenuTree(permissions)
|
||||
|
||||
assert.Len(t, result, 2)
|
||||
assert.Equal(t, "orphan:menu", result[0].PermCode)
|
||||
assert.Equal(t, "user:menu", result[1].PermCode)
|
||||
assert.Empty(t, result[0].Children)
|
||||
}
|
||||
|
||||
func TestBuildMenuTree_Sorting(t *testing.T) {
|
||||
logger, _ := zap.NewDevelopment()
|
||||
service := &Service{logger: logger}
|
||||
|
||||
parentID := uint(1)
|
||||
|
||||
permissions := []*model.Permission{
|
||||
{Model: gorm.Model{ID: 1}, PermCode: "user:menu", PermName: "用户管理", URL: "/users", Sort: 1, ParentID: nil},
|
||||
{Model: gorm.Model{ID: 2}, PermCode: "user:list:menu", PermName: "用户列表", URL: "/users/list", Sort: 10, ParentID: &parentID},
|
||||
{Model: gorm.Model{ID: 3}, PermCode: "user:role:menu", PermName: "角色管理", URL: "/users/roles", Sort: 5, ParentID: &parentID},
|
||||
{Model: gorm.Model{ID: 4}, PermCode: "user:dept:menu", PermName: "部门管理", URL: "/users/depts", Sort: 8, ParentID: &parentID},
|
||||
}
|
||||
|
||||
result := service.buildMenuTree(permissions)
|
||||
|
||||
assert.Len(t, result, 1)
|
||||
assert.Len(t, result[0].Children, 3)
|
||||
assert.Equal(t, "user:role:menu", result[0].Children[0].PermCode)
|
||||
assert.Equal(t, 5, result[0].Children[0].Sort)
|
||||
assert.Equal(t, "user:dept:menu", result[0].Children[1].PermCode)
|
||||
assert.Equal(t, 8, result[0].Children[1].Sort)
|
||||
assert.Equal(t, "user:list:menu", result[0].Children[2].PermCode)
|
||||
assert.Equal(t, 10, result[0].Children[2].Sort)
|
||||
}
|
||||
|
||||
func TestBuildMenuTree_EmptyInput(t *testing.T) {
|
||||
logger, _ := zap.NewDevelopment()
|
||||
service := &Service{logger: logger}
|
||||
|
||||
result := service.buildMenuTree([]*model.Permission{})
|
||||
|
||||
assert.NotNil(t, result)
|
||||
assert.Empty(t, result)
|
||||
}
|
||||
|
||||
func TestSortMenuNodes(t *testing.T) {
|
||||
logger, _ := zap.NewDevelopment()
|
||||
service := &Service{logger: logger}
|
||||
|
||||
nodes := []dto.MenuNode{
|
||||
{ID: 3, PermCode: "c", Sort: 30, Children: []dto.MenuNode{}},
|
||||
{ID: 1, PermCode: "a", Sort: 10, Children: []dto.MenuNode{}},
|
||||
{ID: 2, PermCode: "b", Sort: 20, Children: []dto.MenuNode{}},
|
||||
}
|
||||
|
||||
service.sortMenuNodes(nodes)
|
||||
|
||||
assert.Equal(t, "a", nodes[0].PermCode)
|
||||
assert.Equal(t, "b", nodes[1].PermCode)
|
||||
assert.Equal(t, "c", nodes[2].PermCode)
|
||||
}
|
||||
@@ -1,268 +0,0 @@
|
||||
package carrier
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
"github.com/break/junhong_cmp_fiber/tests/testutils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCarrierService_Create(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
store := postgres.NewCarrierStore(tx)
|
||||
svc := New(store)
|
||||
|
||||
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
|
||||
UserID: 1,
|
||||
UserType: constants.UserTypePlatform,
|
||||
})
|
||||
|
||||
t.Run("创建成功", func(t *testing.T) {
|
||||
req := &dto.CreateCarrierRequest{
|
||||
CarrierCode: "SVC_CMCC_001",
|
||||
CarrierName: "中国移动-服务测试",
|
||||
CarrierType: constants.CarrierTypeCMCC,
|
||||
Description: "服务层测试",
|
||||
}
|
||||
|
||||
resp, err := svc.Create(ctx, req)
|
||||
require.NoError(t, err)
|
||||
assert.NotZero(t, resp.ID)
|
||||
assert.Equal(t, req.CarrierCode, resp.CarrierCode)
|
||||
assert.Equal(t, req.CarrierName, resp.CarrierName)
|
||||
assert.Equal(t, constants.StatusEnabled, resp.Status)
|
||||
})
|
||||
|
||||
t.Run("编码重复失败", func(t *testing.T) {
|
||||
req := &dto.CreateCarrierRequest{
|
||||
CarrierCode: "SVC_CMCC_001",
|
||||
CarrierName: "中国移动-重复",
|
||||
CarrierType: constants.CarrierTypeCMCC,
|
||||
}
|
||||
|
||||
_, err := svc.Create(ctx, req)
|
||||
require.Error(t, err)
|
||||
appErr, ok := err.(*errors.AppError)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, errors.CodeCarrierCodeExists, appErr.Code)
|
||||
})
|
||||
|
||||
t.Run("未授权失败", func(t *testing.T) {
|
||||
req := &dto.CreateCarrierRequest{
|
||||
CarrierCode: "SVC_CMCC_002",
|
||||
CarrierName: "未授权测试",
|
||||
CarrierType: constants.CarrierTypeCMCC,
|
||||
}
|
||||
|
||||
_, err := svc.Create(context.Background(), req)
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCarrierService_Get(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
store := postgres.NewCarrierStore(tx)
|
||||
svc := New(store)
|
||||
|
||||
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
|
||||
UserID: 1,
|
||||
UserType: constants.UserTypePlatform,
|
||||
})
|
||||
|
||||
req := &dto.CreateCarrierRequest{
|
||||
CarrierCode: "SVC_GET_001",
|
||||
CarrierName: "查询测试",
|
||||
CarrierType: constants.CarrierTypeCUCC,
|
||||
}
|
||||
created, err := svc.Create(ctx, req)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("查询存在的运营商", func(t *testing.T) {
|
||||
resp, err := svc.Get(ctx, created.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, created.CarrierCode, resp.CarrierCode)
|
||||
})
|
||||
|
||||
t.Run("查询不存在的运营商", func(t *testing.T) {
|
||||
_, err := svc.Get(ctx, 99999)
|
||||
require.Error(t, err)
|
||||
appErr, ok := err.(*errors.AppError)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, errors.CodeCarrierNotFound, appErr.Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCarrierService_Update(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
store := postgres.NewCarrierStore(tx)
|
||||
svc := New(store)
|
||||
|
||||
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
|
||||
UserID: 1,
|
||||
UserType: constants.UserTypePlatform,
|
||||
})
|
||||
|
||||
req := &dto.CreateCarrierRequest{
|
||||
CarrierCode: "SVC_UPD_001",
|
||||
CarrierName: "更新测试",
|
||||
CarrierType: constants.CarrierTypeCTCC,
|
||||
}
|
||||
created, err := svc.Create(ctx, req)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("更新成功", func(t *testing.T) {
|
||||
newName := "更新后的名称"
|
||||
newDesc := "更新后的描述"
|
||||
updateReq := &dto.UpdateCarrierRequest{
|
||||
CarrierName: &newName,
|
||||
Description: &newDesc,
|
||||
}
|
||||
|
||||
resp, err := svc.Update(ctx, created.ID, updateReq)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, newName, resp.CarrierName)
|
||||
assert.Equal(t, newDesc, resp.Description)
|
||||
})
|
||||
|
||||
t.Run("更新不存在的运营商", func(t *testing.T) {
|
||||
newName := "test"
|
||||
updateReq := &dto.UpdateCarrierRequest{
|
||||
CarrierName: &newName,
|
||||
}
|
||||
|
||||
_, err := svc.Update(ctx, 99999, updateReq)
|
||||
require.Error(t, err)
|
||||
appErr, ok := err.(*errors.AppError)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, errors.CodeCarrierNotFound, appErr.Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCarrierService_Delete(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
store := postgres.NewCarrierStore(tx)
|
||||
svc := New(store)
|
||||
|
||||
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
|
||||
UserID: 1,
|
||||
UserType: constants.UserTypePlatform,
|
||||
})
|
||||
|
||||
req := &dto.CreateCarrierRequest{
|
||||
CarrierCode: "SVC_DEL_001",
|
||||
CarrierName: "删除测试",
|
||||
CarrierType: constants.CarrierTypeCBN,
|
||||
}
|
||||
created, err := svc.Create(ctx, req)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("删除成功", func(t *testing.T) {
|
||||
err := svc.Delete(ctx, created.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = svc.Get(ctx, created.ID)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("删除不存在的运营商", func(t *testing.T) {
|
||||
err := svc.Delete(ctx, 99999)
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCarrierService_List(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
store := postgres.NewCarrierStore(tx)
|
||||
svc := New(store)
|
||||
|
||||
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
|
||||
UserID: 1,
|
||||
UserType: constants.UserTypePlatform,
|
||||
})
|
||||
|
||||
carriers := []dto.CreateCarrierRequest{
|
||||
{CarrierCode: "SVC_LIST_001", CarrierName: "移动列表", CarrierType: constants.CarrierTypeCMCC},
|
||||
{CarrierCode: "SVC_LIST_002", CarrierName: "联通列表", CarrierType: constants.CarrierTypeCUCC},
|
||||
{CarrierCode: "SVC_LIST_003", CarrierName: "电信列表", CarrierType: constants.CarrierTypeCTCC},
|
||||
}
|
||||
for _, c := range carriers {
|
||||
_, err := svc.Create(ctx, &c)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
t.Run("查询列表", func(t *testing.T) {
|
||||
req := &dto.CarrierListRequest{
|
||||
Page: 1,
|
||||
PageSize: 20,
|
||||
}
|
||||
result, total, err := svc.List(ctx, req)
|
||||
require.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, total, int64(3))
|
||||
assert.GreaterOrEqual(t, len(result), 3)
|
||||
})
|
||||
|
||||
t.Run("按类型过滤", func(t *testing.T) {
|
||||
carrierType := constants.CarrierTypeCMCC
|
||||
req := &dto.CarrierListRequest{
|
||||
Page: 1,
|
||||
PageSize: 20,
|
||||
CarrierType: &carrierType,
|
||||
}
|
||||
result, total, err := svc.List(ctx, req)
|
||||
require.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, total, int64(1))
|
||||
for _, c := range result {
|
||||
assert.Equal(t, constants.CarrierTypeCMCC, c.CarrierType)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCarrierService_UpdateStatus(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
store := postgres.NewCarrierStore(tx)
|
||||
svc := New(store)
|
||||
|
||||
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
|
||||
UserID: 1,
|
||||
UserType: constants.UserTypePlatform,
|
||||
})
|
||||
|
||||
req := &dto.CreateCarrierRequest{
|
||||
CarrierCode: "SVC_STATUS_001",
|
||||
CarrierName: "状态测试",
|
||||
CarrierType: constants.CarrierTypeCMCC,
|
||||
}
|
||||
created, err := svc.Create(ctx, req)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, constants.StatusEnabled, created.Status)
|
||||
|
||||
t.Run("禁用运营商", func(t *testing.T) {
|
||||
err := svc.UpdateStatus(ctx, created.ID, constants.StatusDisabled)
|
||||
require.NoError(t, err)
|
||||
|
||||
updated, err := svc.Get(ctx, created.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, constants.StatusDisabled, updated.Status)
|
||||
})
|
||||
|
||||
t.Run("启用运营商", func(t *testing.T) {
|
||||
err := svc.UpdateStatus(ctx, created.ID, constants.StatusEnabled)
|
||||
require.NoError(t, err)
|
||||
|
||||
updated, err := svc.Get(ctx, created.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, constants.StatusEnabled, updated.Status)
|
||||
})
|
||||
|
||||
t.Run("更新不存在的运营商状态", func(t *testing.T) {
|
||||
err := svc.UpdateStatus(ctx, 99999, 1)
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
package enterprise_card
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
"github.com/break/junhong_cmp_fiber/tests/testutils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestAuthorizationService_BatchAuthorize_BoundCardRejected(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
testutils.CleanTestRedisKeys(t, rdb)
|
||||
|
||||
logger, _ := zap.NewDevelopment()
|
||||
|
||||
enterpriseStore := postgres.NewEnterpriseStore(tx, rdb)
|
||||
iotCardStore := postgres.NewIotCardStore(tx, rdb)
|
||||
authStore := postgres.NewEnterpriseCardAuthorizationStore(tx, rdb)
|
||||
|
||||
service := NewAuthorizationService(enterpriseStore, iotCardStore, authStore, logger)
|
||||
|
||||
shop := &model.Shop{
|
||||
BaseModel: model.BaseModel{Creator: 1, Updater: 1},
|
||||
ShopName: "测试店铺",
|
||||
ShopCode: "TEST_SHOP_001",
|
||||
Level: 1,
|
||||
Status: 1,
|
||||
}
|
||||
require.NoError(t, tx.Create(shop).Error)
|
||||
|
||||
enterprise := &model.Enterprise{
|
||||
BaseModel: model.BaseModel{Creator: 1, Updater: 1},
|
||||
EnterpriseName: "测试企业",
|
||||
EnterpriseCode: "TEST_ENT_001",
|
||||
OwnerShopID: &shop.ID,
|
||||
Status: 1,
|
||||
}
|
||||
require.NoError(t, tx.Create(enterprise).Error)
|
||||
|
||||
carrier := &model.Carrier{CarrierName: "测试运营商", CarrierType: "CMCC", Status: 1}
|
||||
require.NoError(t, tx.Create(carrier).Error)
|
||||
|
||||
unboundCard := &model.IotCard{
|
||||
ICCID: "UNBOUND_CARD_001",
|
||||
CarrierID: carrier.ID,
|
||||
Status: 2,
|
||||
ShopID: &shop.ID,
|
||||
}
|
||||
require.NoError(t, tx.Create(unboundCard).Error)
|
||||
|
||||
boundCard := &model.IotCard{
|
||||
ICCID: "BOUND_CARD_001",
|
||||
CarrierID: carrier.ID,
|
||||
Status: 2,
|
||||
ShopID: &shop.ID,
|
||||
}
|
||||
require.NoError(t, tx.Create(boundCard).Error)
|
||||
|
||||
device := &model.Device{
|
||||
DeviceNo: "TEST_DEVICE_001",
|
||||
DeviceName: "测试设备",
|
||||
Status: 2,
|
||||
ShopID: &shop.ID,
|
||||
}
|
||||
require.NoError(t, tx.Create(device).Error)
|
||||
|
||||
now := time.Now()
|
||||
binding := &model.DeviceSimBinding{
|
||||
DeviceID: device.ID,
|
||||
IotCardID: boundCard.ID,
|
||||
SlotPosition: 1,
|
||||
BindStatus: 1,
|
||||
BindTime: &now,
|
||||
}
|
||||
require.NoError(t, tx.Create(binding).Error)
|
||||
|
||||
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
|
||||
UserID: 1,
|
||||
UserType: constants.UserTypePlatform,
|
||||
ShopID: shop.ID,
|
||||
})
|
||||
|
||||
t.Run("绑定设备的卡被拒绝授权", func(t *testing.T) {
|
||||
req := BatchAuthorizeRequest{
|
||||
EnterpriseID: enterprise.ID,
|
||||
CardIDs: []uint{boundCard.ID},
|
||||
AuthorizerID: 1,
|
||||
AuthorizerType: constants.UserTypePlatform,
|
||||
Remark: "测试授权",
|
||||
}
|
||||
|
||||
err := service.BatchAuthorize(ctx, req)
|
||||
|
||||
require.Error(t, err)
|
||||
appErr, ok := err.(*errors.AppError)
|
||||
require.True(t, ok, "应返回 AppError 类型")
|
||||
assert.Equal(t, errors.CodeCannotAuthorizeBoundCard, appErr.Code)
|
||||
assert.Contains(t, appErr.Message, "已绑定设备")
|
||||
})
|
||||
|
||||
t.Run("未绑定设备的卡可以授权", func(t *testing.T) {
|
||||
req := BatchAuthorizeRequest{
|
||||
EnterpriseID: enterprise.ID,
|
||||
CardIDs: []uint{unboundCard.ID},
|
||||
AuthorizerID: 1,
|
||||
AuthorizerType: constants.UserTypePlatform,
|
||||
Remark: "测试授权",
|
||||
}
|
||||
|
||||
err := service.BatchAuthorize(ctx, req)
|
||||
|
||||
require.NoError(t, err)
|
||||
|
||||
auths, err := authStore.ListByCards(ctx, []uint{unboundCard.ID}, false)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, auths, 1)
|
||||
assert.Equal(t, enterprise.ID, auths[0].EnterpriseID)
|
||||
})
|
||||
|
||||
t.Run("混合卡列表中有绑定卡时整体拒绝", func(t *testing.T) {
|
||||
unboundCard2 := &model.IotCard{
|
||||
ICCID: "UNBOUND_CARD_002",
|
||||
CarrierID: carrier.ID,
|
||||
Status: 2,
|
||||
ShopID: &shop.ID,
|
||||
}
|
||||
require.NoError(t, tx.Create(unboundCard2).Error)
|
||||
|
||||
req := BatchAuthorizeRequest{
|
||||
EnterpriseID: enterprise.ID,
|
||||
CardIDs: []uint{unboundCard2.ID, boundCard.ID},
|
||||
AuthorizerID: 1,
|
||||
AuthorizerType: constants.UserTypePlatform,
|
||||
Remark: "测试授权",
|
||||
}
|
||||
|
||||
err := service.BatchAuthorize(ctx, req)
|
||||
|
||||
require.Error(t, err)
|
||||
appErr, ok := err.(*errors.AppError)
|
||||
require.True(t, ok, "应返回 AppError 类型")
|
||||
assert.Equal(t, errors.CodeCannotAuthorizeBoundCard, appErr.Code)
|
||||
|
||||
auths, err := authStore.ListByCards(ctx, []uint{unboundCard2.ID}, false)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, auths, 0, "混合列表中的未绑定卡也不应被授权")
|
||||
})
|
||||
}
|
||||
@@ -1,913 +0,0 @@
|
||||
package enterprise_device
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
"github.com/break/junhong_cmp_fiber/tests/testutils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func uniqueServiceTestPrefix() string {
|
||||
return fmt.Sprintf("SVC%d", time.Now().UnixNano()%1000000000)
|
||||
}
|
||||
|
||||
func createTestContext(userID uint, userType int, shopID uint, enterpriseID uint) context.Context {
|
||||
ctx := context.Background()
|
||||
return middleware.SetUserContext(ctx, &middleware.UserContextInfo{
|
||||
UserID: userID,
|
||||
UserType: userType,
|
||||
ShopID: shopID,
|
||||
EnterpriseID: enterpriseID,
|
||||
})
|
||||
}
|
||||
|
||||
type testEnv struct {
|
||||
service *Service
|
||||
enterprise *model.Enterprise
|
||||
shop *model.Shop
|
||||
devices []*model.Device
|
||||
cards []*model.IotCard
|
||||
bindings []*model.DeviceSimBinding
|
||||
carrier *model.Carrier
|
||||
}
|
||||
|
||||
func setupTestEnv(t *testing.T, prefix string) *testEnv {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
testutils.CleanTestRedisKeys(t, rdb)
|
||||
|
||||
enterpriseStore := postgres.NewEnterpriseStore(tx, rdb)
|
||||
deviceStore := postgres.NewDeviceStore(tx, rdb)
|
||||
deviceSimBindingStore := postgres.NewDeviceSimBindingStore(tx, rdb)
|
||||
enterpriseDeviceAuthStore := postgres.NewEnterpriseDeviceAuthorizationStore(tx, rdb)
|
||||
enterpriseCardAuthStore := postgres.NewEnterpriseCardAuthorizationStore(tx, rdb)
|
||||
logger := zap.NewNop()
|
||||
|
||||
svc := New(tx, enterpriseStore, deviceStore, deviceSimBindingStore, enterpriseDeviceAuthStore, enterpriseCardAuthStore, logger)
|
||||
|
||||
shop := &model.Shop{
|
||||
ShopName: prefix + "_测试店铺",
|
||||
ShopCode: prefix,
|
||||
Level: 1,
|
||||
Status: 1,
|
||||
}
|
||||
require.NoError(t, tx.Create(shop).Error)
|
||||
|
||||
enterprise := &model.Enterprise{
|
||||
EnterpriseName: prefix + "_测试企业",
|
||||
EnterpriseCode: prefix,
|
||||
OwnerShopID: &shop.ID,
|
||||
Status: 1,
|
||||
}
|
||||
require.NoError(t, tx.Create(enterprise).Error)
|
||||
|
||||
carrier := &model.Carrier{
|
||||
CarrierName: "测试运营商",
|
||||
CarrierType: "CMCC",
|
||||
Status: 1,
|
||||
}
|
||||
require.NoError(t, tx.Create(carrier).Error)
|
||||
|
||||
devices := make([]*model.Device, 3)
|
||||
for i := 0; i < 3; i++ {
|
||||
devices[i] = &model.Device{
|
||||
DeviceNo: fmt.Sprintf("%s_D%03d", prefix, i+1),
|
||||
DeviceName: fmt.Sprintf("测试设备%d", i+1),
|
||||
Status: 2,
|
||||
ShopID: &shop.ID,
|
||||
}
|
||||
require.NoError(t, tx.Create(devices[i]).Error)
|
||||
}
|
||||
|
||||
cards := make([]*model.IotCard, 4)
|
||||
for i := 0; i < 4; i++ {
|
||||
cards[i] = &model.IotCard{
|
||||
ICCID: fmt.Sprintf("%s%04d", prefix, i+1),
|
||||
CarrierID: carrier.ID,
|
||||
Status: 2,
|
||||
ShopID: &shop.ID,
|
||||
}
|
||||
require.NoError(t, tx.Create(cards[i]).Error)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
bindings := []*model.DeviceSimBinding{
|
||||
{DeviceID: devices[0].ID, IotCardID: cards[0].ID, SlotPosition: 1, BindStatus: 1, BindTime: &now},
|
||||
{DeviceID: devices[0].ID, IotCardID: cards[1].ID, SlotPosition: 2, BindStatus: 1, BindTime: &now},
|
||||
{DeviceID: devices[1].ID, IotCardID: cards[2].ID, SlotPosition: 1, BindStatus: 1, BindTime: &now},
|
||||
}
|
||||
for _, b := range bindings {
|
||||
require.NoError(t, tx.Create(b).Error)
|
||||
}
|
||||
|
||||
return &testEnv{
|
||||
service: svc,
|
||||
enterprise: enterprise,
|
||||
shop: shop,
|
||||
devices: devices,
|
||||
cards: cards,
|
||||
bindings: bindings,
|
||||
carrier: carrier,
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_AllocateDevices(t *testing.T) {
|
||||
prefix := uniqueServiceTestPrefix()
|
||||
env := setupTestEnv(t, prefix)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
ctx context.Context
|
||||
req *dto.AllocateDevicesReq
|
||||
wantSuccess int
|
||||
wantFail int
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "平台用户成功授权设备",
|
||||
ctx: createTestContext(1, constants.UserTypePlatform, 0, 0),
|
||||
req: &dto.AllocateDevicesReq{
|
||||
DeviceNos: []string{env.devices[0].DeviceNo},
|
||||
Remark: "测试授权",
|
||||
},
|
||||
wantSuccess: 1,
|
||||
wantFail: 0,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "代理用户成功授权自己店铺的设备",
|
||||
ctx: createTestContext(2, constants.UserTypeAgent, env.shop.ID, 0),
|
||||
req: &dto.AllocateDevicesReq{
|
||||
DeviceNos: []string{env.devices[1].DeviceNo},
|
||||
},
|
||||
wantSuccess: 1,
|
||||
wantFail: 0,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "设备不存在时记录失败",
|
||||
ctx: createTestContext(1, constants.UserTypePlatform, 0, 0),
|
||||
req: &dto.AllocateDevicesReq{
|
||||
DeviceNos: []string{"NOT_EXIST_DEVICE"},
|
||||
},
|
||||
wantSuccess: 0,
|
||||
wantFail: 1,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "未授权用户返回错误",
|
||||
ctx: context.Background(),
|
||||
req: &dto.AllocateDevicesReq{
|
||||
DeviceNos: []string{env.devices[2].DeviceNo},
|
||||
},
|
||||
wantSuccess: 0,
|
||||
wantFail: 0,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
resp, err := env.service.AllocateDevices(tt.ctx, env.enterprise.ID, tt.req)
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.wantSuccess, resp.SuccessCount)
|
||||
assert.Equal(t, tt.wantFail, resp.FailCount)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_AllocateDevices_DeviceStatusValidation(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
testutils.CleanTestRedisKeys(t, rdb)
|
||||
|
||||
prefix := uniqueServiceTestPrefix()
|
||||
|
||||
enterpriseStore := postgres.NewEnterpriseStore(tx, rdb)
|
||||
deviceStore := postgres.NewDeviceStore(tx, rdb)
|
||||
deviceSimBindingStore := postgres.NewDeviceSimBindingStore(tx, rdb)
|
||||
enterpriseDeviceAuthStore := postgres.NewEnterpriseDeviceAuthorizationStore(tx, rdb)
|
||||
enterpriseCardAuthStore := postgres.NewEnterpriseCardAuthorizationStore(tx, rdb)
|
||||
logger := zap.NewNop()
|
||||
|
||||
svc := New(tx, enterpriseStore, deviceStore, deviceSimBindingStore, enterpriseDeviceAuthStore, enterpriseCardAuthStore, logger)
|
||||
|
||||
enterprise := &model.Enterprise{
|
||||
EnterpriseName: prefix + "_测试企业",
|
||||
EnterpriseCode: prefix,
|
||||
Status: 1,
|
||||
}
|
||||
require.NoError(t, tx.Create(enterprise).Error)
|
||||
|
||||
inStockDevice := &model.Device{
|
||||
DeviceNo: prefix + "_INSTOCK",
|
||||
DeviceName: "在库设备",
|
||||
Status: 1,
|
||||
}
|
||||
require.NoError(t, tx.Create(inStockDevice).Error)
|
||||
|
||||
ctx := createTestContext(1, constants.UserTypePlatform, 0, 0)
|
||||
|
||||
t.Run("设备状态不是已分销时失败", func(t *testing.T) {
|
||||
req := &dto.AllocateDevicesReq{
|
||||
DeviceNos: []string{inStockDevice.DeviceNo},
|
||||
}
|
||||
|
||||
resp, err := svc.AllocateDevices(ctx, enterprise.ID, req)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, resp.SuccessCount)
|
||||
assert.Equal(t, 1, resp.FailCount)
|
||||
assert.Contains(t, resp.FailedItems[0].Reason, "状态不正确")
|
||||
})
|
||||
}
|
||||
|
||||
func TestService_AllocateDevices_AgentPermission(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
testutils.CleanTestRedisKeys(t, rdb)
|
||||
|
||||
prefix := uniqueServiceTestPrefix()
|
||||
|
||||
enterpriseStore := postgres.NewEnterpriseStore(tx, rdb)
|
||||
deviceStore := postgres.NewDeviceStore(tx, rdb)
|
||||
deviceSimBindingStore := postgres.NewDeviceSimBindingStore(tx, rdb)
|
||||
enterpriseDeviceAuthStore := postgres.NewEnterpriseDeviceAuthorizationStore(tx, rdb)
|
||||
enterpriseCardAuthStore := postgres.NewEnterpriseCardAuthorizationStore(tx, rdb)
|
||||
logger := zap.NewNop()
|
||||
|
||||
svc := New(tx, enterpriseStore, deviceStore, deviceSimBindingStore, enterpriseDeviceAuthStore, enterpriseCardAuthStore, logger)
|
||||
|
||||
shop1 := &model.Shop{ShopName: prefix + "_店铺1", ShopCode: prefix + "1", Level: 1, Status: 1}
|
||||
require.NoError(t, tx.Create(shop1).Error)
|
||||
|
||||
shop2 := &model.Shop{ShopName: prefix + "_店铺2", ShopCode: prefix + "2", Level: 1, Status: 1}
|
||||
require.NoError(t, tx.Create(shop2).Error)
|
||||
|
||||
enterprise := &model.Enterprise{
|
||||
EnterpriseName: prefix + "_测试企业",
|
||||
EnterpriseCode: prefix,
|
||||
Status: 1,
|
||||
}
|
||||
require.NoError(t, tx.Create(enterprise).Error)
|
||||
|
||||
device := &model.Device{
|
||||
DeviceNo: prefix + "_D001",
|
||||
DeviceName: "测试设备",
|
||||
Status: 2,
|
||||
ShopID: &shop1.ID,
|
||||
}
|
||||
require.NoError(t, tx.Create(device).Error)
|
||||
|
||||
t.Run("代理用户无法授权其他店铺的设备", func(t *testing.T) {
|
||||
ctx := createTestContext(1, constants.UserTypeAgent, shop2.ID, 0)
|
||||
req := &dto.AllocateDevicesReq{
|
||||
DeviceNos: []string{device.DeviceNo},
|
||||
}
|
||||
|
||||
resp, err := svc.AllocateDevices(ctx, enterprise.ID, req)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, resp.SuccessCount)
|
||||
assert.Equal(t, 1, resp.FailCount)
|
||||
assert.Contains(t, resp.FailedItems[0].Reason, "无权操作")
|
||||
})
|
||||
}
|
||||
|
||||
func TestService_AllocateDevices_DuplicateAuthorization(t *testing.T) {
|
||||
prefix := uniqueServiceTestPrefix()
|
||||
env := setupTestEnv(t, prefix)
|
||||
|
||||
ctx := createTestContext(1, constants.UserTypePlatform, 0, 0)
|
||||
|
||||
req := &dto.AllocateDevicesReq{
|
||||
DeviceNos: []string{env.devices[0].DeviceNo},
|
||||
}
|
||||
resp, err := env.service.AllocateDevices(ctx, env.enterprise.ID, req)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, resp.SuccessCount)
|
||||
|
||||
t.Run("重复授权同一设备时失败", func(t *testing.T) {
|
||||
resp2, err := env.service.AllocateDevices(ctx, env.enterprise.ID, req)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, resp2.SuccessCount)
|
||||
assert.Equal(t, 1, resp2.FailCount)
|
||||
assert.Contains(t, resp2.FailedItems[0].Reason, "已授权")
|
||||
})
|
||||
}
|
||||
|
||||
func TestService_AllocateDevices_CascadeCardAuthorization(t *testing.T) {
|
||||
prefix := uniqueServiceTestPrefix()
|
||||
env := setupTestEnv(t, prefix)
|
||||
|
||||
ctx := createTestContext(1, constants.UserTypePlatform, 0, 0)
|
||||
|
||||
t.Run("授权设备时级联授权绑定的卡", func(t *testing.T) {
|
||||
req := &dto.AllocateDevicesReq{
|
||||
DeviceNos: []string{env.devices[0].DeviceNo},
|
||||
}
|
||||
|
||||
resp, err := env.service.AllocateDevices(ctx, env.enterprise.ID, req)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, resp.SuccessCount)
|
||||
assert.Len(t, resp.AuthorizedDevices, 1)
|
||||
assert.Equal(t, 2, resp.AuthorizedDevices[0].CardCount)
|
||||
})
|
||||
}
|
||||
|
||||
func TestService_RecallDevices(t *testing.T) {
|
||||
prefix := uniqueServiceTestPrefix()
|
||||
env := setupTestEnv(t, prefix)
|
||||
|
||||
ctx := createTestContext(1, constants.UserTypePlatform, 0, 0)
|
||||
|
||||
allocateReq := &dto.AllocateDevicesReq{
|
||||
DeviceNos: []string{env.devices[0].DeviceNo, env.devices[1].DeviceNo},
|
||||
}
|
||||
_, err := env.service.AllocateDevices(ctx, env.enterprise.ID, allocateReq)
|
||||
require.NoError(t, err)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
req *dto.RecallDevicesReq
|
||||
wantSuccess int
|
||||
wantFail int
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "成功撤销授权",
|
||||
req: &dto.RecallDevicesReq{
|
||||
DeviceNos: []string{env.devices[0].DeviceNo},
|
||||
},
|
||||
wantSuccess: 1,
|
||||
wantFail: 0,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "设备不存在时失败",
|
||||
req: &dto.RecallDevicesReq{
|
||||
DeviceNos: []string{"NOT_EXIST"},
|
||||
},
|
||||
wantSuccess: 0,
|
||||
wantFail: 1,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "设备未授权时失败",
|
||||
req: &dto.RecallDevicesReq{
|
||||
DeviceNos: []string{env.devices[2].DeviceNo},
|
||||
},
|
||||
wantSuccess: 0,
|
||||
wantFail: 1,
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
resp, err := env.service.RecallDevices(ctx, env.enterprise.ID, tt.req)
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.wantSuccess, resp.SuccessCount)
|
||||
assert.Equal(t, tt.wantFail, resp.FailCount)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_RecallDevices_Unauthorized(t *testing.T) {
|
||||
prefix := uniqueServiceTestPrefix()
|
||||
env := setupTestEnv(t, prefix)
|
||||
|
||||
t.Run("未授权用户返回错误", func(t *testing.T) {
|
||||
req := &dto.RecallDevicesReq{
|
||||
DeviceNos: []string{env.devices[0].DeviceNo},
|
||||
}
|
||||
|
||||
_, err := env.service.RecallDevices(context.Background(), env.enterprise.ID, req)
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestService_ListDevices(t *testing.T) {
|
||||
prefix := uniqueServiceTestPrefix()
|
||||
env := setupTestEnv(t, prefix)
|
||||
|
||||
ctx := createTestContext(1, constants.UserTypePlatform, 0, 0)
|
||||
|
||||
allocateReq := &dto.AllocateDevicesReq{
|
||||
DeviceNos: []string{env.devices[0].DeviceNo, env.devices[1].DeviceNo},
|
||||
}
|
||||
_, err := env.service.AllocateDevices(ctx, env.enterprise.ID, allocateReq)
|
||||
require.NoError(t, err)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
req *dto.EnterpriseDeviceListReq
|
||||
wantTotal int64
|
||||
wantLen int
|
||||
}{
|
||||
{
|
||||
name: "获取所有授权设备",
|
||||
req: &dto.EnterpriseDeviceListReq{Page: 1, PageSize: 10},
|
||||
wantTotal: 2,
|
||||
wantLen: 2,
|
||||
},
|
||||
{
|
||||
name: "分页查询",
|
||||
req: &dto.EnterpriseDeviceListReq{Page: 1, PageSize: 1},
|
||||
wantTotal: 2,
|
||||
wantLen: 1,
|
||||
},
|
||||
{
|
||||
name: "按设备号搜索",
|
||||
req: &dto.EnterpriseDeviceListReq{Page: 1, PageSize: 10, DeviceNo: env.devices[0].DeviceNo},
|
||||
wantTotal: 2,
|
||||
wantLen: 1,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
resp, err := env.service.ListDevices(ctx, env.enterprise.ID, tt.req)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.wantTotal, resp.Total)
|
||||
assert.Len(t, resp.List, tt.wantLen)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_ListDevices_EnterpriseNotFound(t *testing.T) {
|
||||
prefix := uniqueServiceTestPrefix()
|
||||
env := setupTestEnv(t, prefix)
|
||||
|
||||
ctx := createTestContext(1, constants.UserTypePlatform, 0, 0)
|
||||
|
||||
t.Run("企业不存在返回错误", func(t *testing.T) {
|
||||
req := &dto.EnterpriseDeviceListReq{Page: 1, PageSize: 10}
|
||||
_, err := env.service.ListDevices(ctx, 99999, req)
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestService_ListDevicesForEnterprise(t *testing.T) {
|
||||
prefix := uniqueServiceTestPrefix()
|
||||
env := setupTestEnv(t, prefix)
|
||||
|
||||
ctx := createTestContext(1, constants.UserTypePlatform, 0, 0)
|
||||
allocateReq := &dto.AllocateDevicesReq{
|
||||
DeviceNos: []string{env.devices[0].DeviceNo},
|
||||
}
|
||||
_, err := env.service.AllocateDevices(ctx, env.enterprise.ID, allocateReq)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("企业用户获取自己的授权设备", func(t *testing.T) {
|
||||
enterpriseCtx := createTestContext(1, constants.UserTypeEnterprise, 0, env.enterprise.ID)
|
||||
req := &dto.EnterpriseDeviceListReq{Page: 1, PageSize: 10}
|
||||
|
||||
resp, err := env.service.ListDevicesForEnterprise(enterpriseCtx, req)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), resp.Total)
|
||||
})
|
||||
|
||||
t.Run("未设置企业ID返回错误", func(t *testing.T) {
|
||||
req := &dto.EnterpriseDeviceListReq{Page: 1, PageSize: 10}
|
||||
_, err := env.service.ListDevicesForEnterprise(context.Background(), req)
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestService_GetDeviceDetail(t *testing.T) {
|
||||
prefix := uniqueServiceTestPrefix()
|
||||
env := setupTestEnv(t, prefix)
|
||||
|
||||
ctx := createTestContext(1, constants.UserTypePlatform, 0, 0)
|
||||
allocateReq := &dto.AllocateDevicesReq{
|
||||
DeviceNos: []string{env.devices[0].DeviceNo},
|
||||
}
|
||||
_, err := env.service.AllocateDevices(ctx, env.enterprise.ID, allocateReq)
|
||||
require.NoError(t, err)
|
||||
|
||||
enterpriseCtx := createTestContext(1, constants.UserTypeEnterprise, 0, env.enterprise.ID)
|
||||
|
||||
t.Run("成功获取设备详情", func(t *testing.T) {
|
||||
resp, err := env.service.GetDeviceDetail(enterpriseCtx, env.devices[0].ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, env.devices[0].ID, resp.Device.DeviceID)
|
||||
assert.Equal(t, env.devices[0].DeviceNo, resp.Device.DeviceNo)
|
||||
assert.Len(t, resp.Cards, 2)
|
||||
})
|
||||
|
||||
t.Run("设备未授权时返回错误", func(t *testing.T) {
|
||||
_, err := env.service.GetDeviceDetail(enterpriseCtx, env.devices[1].ID)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("未设置企业ID返回错误", func(t *testing.T) {
|
||||
_, err := env.service.GetDeviceDetail(context.Background(), env.devices[0].ID)
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestService_SuspendCard(t *testing.T) {
|
||||
prefix := uniqueServiceTestPrefix()
|
||||
env := setupTestEnv(t, prefix)
|
||||
|
||||
ctx := createTestContext(1, constants.UserTypePlatform, 0, 0)
|
||||
allocateReq := &dto.AllocateDevicesReq{
|
||||
DeviceNos: []string{env.devices[0].DeviceNo},
|
||||
}
|
||||
_, err := env.service.AllocateDevices(ctx, env.enterprise.ID, allocateReq)
|
||||
require.NoError(t, err)
|
||||
|
||||
enterpriseCtx := createTestContext(1, constants.UserTypeEnterprise, 0, env.enterprise.ID)
|
||||
|
||||
t.Run("成功停机", func(t *testing.T) {
|
||||
req := &dto.DeviceCardOperationReq{Reason: "测试停机"}
|
||||
resp, err := env.service.SuspendCard(enterpriseCtx, env.devices[0].ID, env.cards[0].ID, req)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, resp.Success)
|
||||
})
|
||||
|
||||
t.Run("卡不属于设备时返回错误", func(t *testing.T) {
|
||||
req := &dto.DeviceCardOperationReq{Reason: "测试停机"}
|
||||
_, err := env.service.SuspendCard(enterpriseCtx, env.devices[0].ID, env.cards[3].ID, req)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("设备未授权时返回错误", func(t *testing.T) {
|
||||
req := &dto.DeviceCardOperationReq{Reason: "测试停机"}
|
||||
_, err := env.service.SuspendCard(enterpriseCtx, env.devices[1].ID, env.cards[2].ID, req)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("未设置企业ID返回错误", func(t *testing.T) {
|
||||
req := &dto.DeviceCardOperationReq{Reason: "测试停机"}
|
||||
_, err := env.service.SuspendCard(context.Background(), env.devices[0].ID, env.cards[0].ID, req)
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestService_ResumeCard(t *testing.T) {
|
||||
prefix := uniqueServiceTestPrefix()
|
||||
env := setupTestEnv(t, prefix)
|
||||
|
||||
ctx := createTestContext(1, constants.UserTypePlatform, 0, 0)
|
||||
allocateReq := &dto.AllocateDevicesReq{
|
||||
DeviceNos: []string{env.devices[0].DeviceNo},
|
||||
}
|
||||
_, err := env.service.AllocateDevices(ctx, env.enterprise.ID, allocateReq)
|
||||
require.NoError(t, err)
|
||||
|
||||
enterpriseCtx := createTestContext(1, constants.UserTypeEnterprise, 0, env.enterprise.ID)
|
||||
|
||||
t.Run("成功复机", func(t *testing.T) {
|
||||
req := &dto.DeviceCardOperationReq{Reason: "测试复机"}
|
||||
resp, err := env.service.ResumeCard(enterpriseCtx, env.devices[0].ID, env.cards[0].ID, req)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, resp.Success)
|
||||
})
|
||||
|
||||
t.Run("卡不属于设备时返回错误", func(t *testing.T) {
|
||||
req := &dto.DeviceCardOperationReq{Reason: "测试复机"}
|
||||
_, err := env.service.ResumeCard(enterpriseCtx, env.devices[0].ID, env.cards[3].ID, req)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("设备未授权时返回错误", func(t *testing.T) {
|
||||
req := &dto.DeviceCardOperationReq{Reason: "测试复机"}
|
||||
_, err := env.service.ResumeCard(enterpriseCtx, env.devices[1].ID, env.cards[2].ID, req)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("未设置企业ID返回错误", func(t *testing.T) {
|
||||
req := &dto.DeviceCardOperationReq{Reason: "测试复机"}
|
||||
_, err := env.service.ResumeCard(context.Background(), env.devices[0].ID, env.cards[0].ID, req)
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestService_ListDevices_EmptyResult(t *testing.T) {
|
||||
prefix := uniqueServiceTestPrefix()
|
||||
env := setupTestEnv(t, prefix)
|
||||
|
||||
ctx := createTestContext(1, constants.UserTypePlatform, 0, 0)
|
||||
|
||||
t.Run("企业无授权设备时返回空列表", func(t *testing.T) {
|
||||
req := &dto.EnterpriseDeviceListReq{Page: 1, PageSize: 10}
|
||||
resp, err := env.service.ListDevices(ctx, env.enterprise.ID, req)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(0), resp.Total)
|
||||
assert.Empty(t, resp.List)
|
||||
})
|
||||
}
|
||||
|
||||
func TestService_GetDeviceDetail_WithCarrierInfo(t *testing.T) {
|
||||
prefix := uniqueServiceTestPrefix()
|
||||
env := setupTestEnv(t, prefix)
|
||||
|
||||
ctx := createTestContext(1, constants.UserTypePlatform, 0, 0)
|
||||
allocateReq := &dto.AllocateDevicesReq{
|
||||
DeviceNos: []string{env.devices[0].DeviceNo},
|
||||
}
|
||||
_, err := env.service.AllocateDevices(ctx, env.enterprise.ID, allocateReq)
|
||||
require.NoError(t, err)
|
||||
|
||||
enterpriseCtx := createTestContext(1, constants.UserTypeEnterprise, 0, env.enterprise.ID)
|
||||
|
||||
t.Run("获取设备详情包含运营商信息", func(t *testing.T) {
|
||||
resp, err := env.service.GetDeviceDetail(enterpriseCtx, env.devices[0].ID)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, resp.Cards, 2)
|
||||
for _, card := range resp.Cards {
|
||||
assert.NotEmpty(t, card.CarrierName)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestService_GetDeviceDetail_NetworkStatus(t *testing.T) {
|
||||
prefix := uniqueServiceTestPrefix()
|
||||
env := setupTestEnv(t, prefix)
|
||||
|
||||
ctx := createTestContext(1, constants.UserTypePlatform, 0, 0)
|
||||
allocateReq := &dto.AllocateDevicesReq{
|
||||
DeviceNos: []string{env.devices[0].DeviceNo},
|
||||
}
|
||||
_, err := env.service.AllocateDevices(ctx, env.enterprise.ID, allocateReq)
|
||||
require.NoError(t, err)
|
||||
|
||||
enterpriseCtx := createTestContext(1, constants.UserTypeEnterprise, 0, env.enterprise.ID)
|
||||
|
||||
t.Run("网络状态名称正确", func(t *testing.T) {
|
||||
resp, err := env.service.GetDeviceDetail(enterpriseCtx, env.devices[0].ID)
|
||||
require.NoError(t, err)
|
||||
for _, card := range resp.Cards {
|
||||
if card.NetworkStatus == 1 {
|
||||
assert.Equal(t, "开机", card.NetworkStatusName)
|
||||
} else {
|
||||
assert.Equal(t, "停机", card.NetworkStatusName)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestService_GetDeviceDetail_DeviceWithoutCards(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
testutils.CleanTestRedisKeys(t, rdb)
|
||||
|
||||
prefix := uniqueServiceTestPrefix()
|
||||
|
||||
enterpriseStore := postgres.NewEnterpriseStore(tx, rdb)
|
||||
deviceStore := postgres.NewDeviceStore(tx, rdb)
|
||||
deviceSimBindingStore := postgres.NewDeviceSimBindingStore(tx, rdb)
|
||||
enterpriseDeviceAuthStore := postgres.NewEnterpriseDeviceAuthorizationStore(tx, rdb)
|
||||
enterpriseCardAuthStore := postgres.NewEnterpriseCardAuthorizationStore(tx, rdb)
|
||||
logger := zap.NewNop()
|
||||
|
||||
svc := New(tx, enterpriseStore, deviceStore, deviceSimBindingStore, enterpriseDeviceAuthStore, enterpriseCardAuthStore, logger)
|
||||
|
||||
enterprise := &model.Enterprise{
|
||||
EnterpriseName: prefix + "_测试企业",
|
||||
EnterpriseCode: prefix,
|
||||
Status: 1,
|
||||
}
|
||||
require.NoError(t, tx.Create(enterprise).Error)
|
||||
|
||||
device := &model.Device{
|
||||
DeviceNo: prefix + "_D001",
|
||||
DeviceName: "无卡设备",
|
||||
Status: 2,
|
||||
}
|
||||
require.NoError(t, tx.Create(device).Error)
|
||||
|
||||
ctx := createTestContext(1, constants.UserTypePlatform, 0, 0)
|
||||
allocateReq := &dto.AllocateDevicesReq{
|
||||
DeviceNos: []string{device.DeviceNo},
|
||||
}
|
||||
_, err := svc.AllocateDevices(ctx, enterprise.ID, allocateReq)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("设备无绑定卡时返回空卡列表", func(t *testing.T) {
|
||||
enterpriseCtx := createTestContext(1, constants.UserTypeEnterprise, 0, enterprise.ID)
|
||||
resp, err := svc.GetDeviceDetail(enterpriseCtx, device.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, device.ID, resp.Device.DeviceID)
|
||||
assert.Empty(t, resp.Cards)
|
||||
})
|
||||
}
|
||||
|
||||
func TestService_RecallDevices_CascadeRevoke(t *testing.T) {
|
||||
prefix := uniqueServiceTestPrefix()
|
||||
env := setupTestEnv(t, prefix)
|
||||
|
||||
ctx := createTestContext(1, constants.UserTypePlatform, 0, 0)
|
||||
|
||||
allocateReq := &dto.AllocateDevicesReq{
|
||||
DeviceNos: []string{env.devices[0].DeviceNo},
|
||||
}
|
||||
resp, err := env.service.AllocateDevices(ctx, env.enterprise.ID, allocateReq)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 2, resp.AuthorizedDevices[0].CardCount)
|
||||
|
||||
t.Run("撤销设备授权时级联撤销卡授权", func(t *testing.T) {
|
||||
recallReq := &dto.RecallDevicesReq{
|
||||
DeviceNos: []string{env.devices[0].DeviceNo},
|
||||
}
|
||||
|
||||
recallResp, err := env.service.RecallDevices(ctx, env.enterprise.ID, recallReq)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, recallResp.SuccessCount)
|
||||
})
|
||||
}
|
||||
|
||||
func TestService_GetDeviceDetail_WithNetworkStatusOn(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
testutils.CleanTestRedisKeys(t, rdb)
|
||||
|
||||
prefix := uniqueServiceTestPrefix()
|
||||
|
||||
enterpriseStore := postgres.NewEnterpriseStore(tx, rdb)
|
||||
deviceStore := postgres.NewDeviceStore(tx, rdb)
|
||||
deviceSimBindingStore := postgres.NewDeviceSimBindingStore(tx, rdb)
|
||||
enterpriseDeviceAuthStore := postgres.NewEnterpriseDeviceAuthorizationStore(tx, rdb)
|
||||
enterpriseCardAuthStore := postgres.NewEnterpriseCardAuthorizationStore(tx, rdb)
|
||||
logger := zap.NewNop()
|
||||
|
||||
svc := New(tx, enterpriseStore, deviceStore, deviceSimBindingStore, enterpriseDeviceAuthStore, enterpriseCardAuthStore, logger)
|
||||
|
||||
enterprise := &model.Enterprise{
|
||||
EnterpriseName: prefix + "_测试企业",
|
||||
EnterpriseCode: prefix,
|
||||
Status: 1,
|
||||
}
|
||||
require.NoError(t, tx.Create(enterprise).Error)
|
||||
|
||||
carrier := &model.Carrier{
|
||||
CarrierName: "测试运营商",
|
||||
CarrierType: "CMCC",
|
||||
Status: 1,
|
||||
}
|
||||
require.NoError(t, tx.Create(carrier).Error)
|
||||
|
||||
device := &model.Device{
|
||||
DeviceNo: prefix + "_D001",
|
||||
DeviceName: "测试设备",
|
||||
Status: 2,
|
||||
}
|
||||
require.NoError(t, tx.Create(device).Error)
|
||||
|
||||
card := &model.IotCard{
|
||||
ICCID: prefix + "0001",
|
||||
CarrierID: carrier.ID,
|
||||
Status: 2,
|
||||
NetworkStatus: 1,
|
||||
}
|
||||
require.NoError(t, tx.Create(card).Error)
|
||||
|
||||
now := time.Now()
|
||||
binding := &model.DeviceSimBinding{
|
||||
DeviceID: device.ID,
|
||||
IotCardID: card.ID,
|
||||
SlotPosition: 1,
|
||||
BindStatus: 1,
|
||||
BindTime: &now,
|
||||
}
|
||||
require.NoError(t, tx.Create(binding).Error)
|
||||
|
||||
ctx := createTestContext(1, constants.UserTypePlatform, 0, 0)
|
||||
allocateReq := &dto.AllocateDevicesReq{
|
||||
DeviceNos: []string{device.DeviceNo},
|
||||
}
|
||||
_, err := svc.AllocateDevices(ctx, enterprise.ID, allocateReq)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("开机状态卡显示正确", func(t *testing.T) {
|
||||
enterpriseCtx := createTestContext(1, constants.UserTypeEnterprise, 0, enterprise.ID)
|
||||
resp, err := svc.GetDeviceDetail(enterpriseCtx, device.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, resp.Cards, 1)
|
||||
assert.Equal(t, 1, resp.Cards[0].NetworkStatus)
|
||||
assert.Equal(t, "开机", resp.Cards[0].NetworkStatusName)
|
||||
})
|
||||
}
|
||||
|
||||
func TestService_EnterpriseNotFound(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
testutils.CleanTestRedisKeys(t, rdb)
|
||||
|
||||
enterpriseStore := postgres.NewEnterpriseStore(tx, rdb)
|
||||
deviceStore := postgres.NewDeviceStore(tx, rdb)
|
||||
deviceSimBindingStore := postgres.NewDeviceSimBindingStore(tx, rdb)
|
||||
enterpriseDeviceAuthStore := postgres.NewEnterpriseDeviceAuthorizationStore(tx, rdb)
|
||||
enterpriseCardAuthStore := postgres.NewEnterpriseCardAuthorizationStore(tx, rdb)
|
||||
logger := zap.NewNop()
|
||||
|
||||
svc := New(tx, enterpriseStore, deviceStore, deviceSimBindingStore, enterpriseDeviceAuthStore, enterpriseCardAuthStore, logger)
|
||||
ctx := createTestContext(1, constants.UserTypePlatform, 0, 0)
|
||||
|
||||
t.Run("AllocateDevices企业不存在", func(t *testing.T) {
|
||||
req := &dto.AllocateDevicesReq{DeviceNos: []string{"D001"}}
|
||||
_, err := svc.AllocateDevices(ctx, 99999, req)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("RecallDevices企业不存在", func(t *testing.T) {
|
||||
req := &dto.RecallDevicesReq{DeviceNos: []string{"D001"}}
|
||||
_, err := svc.RecallDevices(ctx, 99999, req)
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestService_ValidateCardOperation_RevokedDeviceAuth(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
testutils.CleanTestRedisKeys(t, rdb)
|
||||
|
||||
prefix := uniqueServiceTestPrefix()
|
||||
|
||||
enterpriseStore := postgres.NewEnterpriseStore(tx, rdb)
|
||||
deviceStore := postgres.NewDeviceStore(tx, rdb)
|
||||
deviceSimBindingStore := postgres.NewDeviceSimBindingStore(tx, rdb)
|
||||
enterpriseDeviceAuthStore := postgres.NewEnterpriseDeviceAuthorizationStore(tx, rdb)
|
||||
enterpriseCardAuthStore := postgres.NewEnterpriseCardAuthorizationStore(tx, rdb)
|
||||
logger := zap.NewNop()
|
||||
|
||||
svc := New(tx, enterpriseStore, deviceStore, deviceSimBindingStore, enterpriseDeviceAuthStore, enterpriseCardAuthStore, logger)
|
||||
|
||||
enterprise := &model.Enterprise{
|
||||
EnterpriseName: prefix + "_测试企业",
|
||||
EnterpriseCode: prefix,
|
||||
Status: 1,
|
||||
}
|
||||
require.NoError(t, tx.Create(enterprise).Error)
|
||||
|
||||
carrier := &model.Carrier{
|
||||
CarrierName: "测试运营商",
|
||||
CarrierType: "CMCC",
|
||||
Status: 1,
|
||||
}
|
||||
require.NoError(t, tx.Create(carrier).Error)
|
||||
|
||||
device := &model.Device{
|
||||
DeviceNo: prefix + "_D001",
|
||||
DeviceName: "测试设备",
|
||||
Status: 2,
|
||||
}
|
||||
require.NoError(t, tx.Create(device).Error)
|
||||
|
||||
card := &model.IotCard{
|
||||
ICCID: prefix + "0001",
|
||||
CarrierID: carrier.ID,
|
||||
Status: 2,
|
||||
}
|
||||
require.NoError(t, tx.Create(card).Error)
|
||||
|
||||
now := time.Now()
|
||||
binding := &model.DeviceSimBinding{
|
||||
DeviceID: device.ID,
|
||||
IotCardID: card.ID,
|
||||
SlotPosition: 1,
|
||||
BindStatus: 1,
|
||||
BindTime: &now,
|
||||
}
|
||||
require.NoError(t, tx.Create(binding).Error)
|
||||
|
||||
deviceAuth := &model.EnterpriseDeviceAuthorization{
|
||||
EnterpriseID: enterprise.ID,
|
||||
DeviceID: device.ID,
|
||||
AuthorizedBy: 1,
|
||||
AuthorizedAt: now,
|
||||
AuthorizerType: 2,
|
||||
RevokedBy: ptrUintED(1),
|
||||
RevokedAt: &now,
|
||||
}
|
||||
require.NoError(t, tx.Create(deviceAuth).Error)
|
||||
|
||||
t.Run("已撤销的设备授权无法操作卡", func(t *testing.T) {
|
||||
enterpriseCtx := createTestContext(1, constants.UserTypeEnterprise, 0, enterprise.ID)
|
||||
req := &dto.DeviceCardOperationReq{Reason: "测试"}
|
||||
_, err := svc.SuspendCard(enterpriseCtx, device.ID, card.ID, req)
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func ptrUintED(v uint) *uint {
|
||||
return &v
|
||||
}
|
||||
235
internal/service/iot_card/stop_resume_service.go
Normal file
235
internal/service/iot_card/stop_resume_service.go
Normal file
@@ -0,0 +1,235 @@
|
||||
package iot_card
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"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/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
// StopResumeService 停复机服务
|
||||
// 任务 24.2: 处理 IoT 卡的自动停机和复机逻辑
|
||||
type StopResumeService struct {
|
||||
db *gorm.DB
|
||||
redis *redis.Client
|
||||
iotCardStore *postgres.IotCardStore
|
||||
gatewayClient *gateway.Client
|
||||
logger *zap.Logger
|
||||
|
||||
// 重试配置
|
||||
maxRetries int
|
||||
retryInterval time.Duration
|
||||
}
|
||||
|
||||
// NewStopResumeService 创建停复机服务
|
||||
func NewStopResumeService(
|
||||
db *gorm.DB,
|
||||
redis *redis.Client,
|
||||
iotCardStore *postgres.IotCardStore,
|
||||
gatewayClient *gateway.Client,
|
||||
logger *zap.Logger,
|
||||
) *StopResumeService {
|
||||
return &StopResumeService{
|
||||
db: db,
|
||||
redis: redis,
|
||||
iotCardStore: iotCardStore,
|
||||
gatewayClient: gatewayClient,
|
||||
logger: logger,
|
||||
maxRetries: 3, // 默认最多重试 3 次
|
||||
retryInterval: 2 * time.Second, // 默认重试间隔 2 秒
|
||||
}
|
||||
}
|
||||
|
||||
// CheckAndStopCard 任务 24.3: 检查流量耗尽并停机
|
||||
// 当所有套餐流量用完时,调用运营商接口停机
|
||||
func (s *StopResumeService) CheckAndStopCard(ctx context.Context, cardID uint) error {
|
||||
// 查询卡信息
|
||||
card, err := s.iotCardStore.GetByID(ctx, cardID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 如果已经是停机状态,跳过
|
||||
if card.NetworkStatus == constants.NetworkStatusOffline {
|
||||
s.logger.Debug("卡已处于停机状态,跳过",
|
||||
zap.Uint("card_id", cardID))
|
||||
return nil
|
||||
}
|
||||
|
||||
// 检查是否有可用套餐(status=1 生效中 或 status=0 待生效)
|
||||
hasAvailablePackage, err := s.hasAvailablePackage(ctx, cardID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 如果还有可用套餐,不停机
|
||||
if hasAvailablePackage {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 任务 24.5: 调用运营商停机接口(带重试机制)
|
||||
if err := s.stopCardWithRetry(ctx, card); err != nil {
|
||||
s.logger.Error("调用运营商停机接口失败",
|
||||
zap.Uint("card_id", cardID),
|
||||
zap.String("iccid", card.ICCID),
|
||||
zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
// 更新卡状态
|
||||
now := time.Now()
|
||||
if err := s.db.WithContext(ctx).Model(card).Updates(map[string]any{
|
||||
"network_status": constants.NetworkStatusOffline,
|
||||
"stopped_at": now,
|
||||
"stop_reason": constants.StopReasonTrafficExhausted,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.logger.Info("卡因流量耗尽已停机",
|
||||
zap.Uint("card_id", cardID),
|
||||
zap.String("iccid", card.ICCID))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResumeCardIfStopped 任务 24.4: 购买套餐后自动复机
|
||||
// 当购买新套餐且卡之前因流量耗尽停机时,自动复机
|
||||
func (s *StopResumeService) ResumeCardIfStopped(ctx context.Context, cardID uint) error {
|
||||
// 查询卡信息
|
||||
card, err := s.iotCardStore.GetByID(ctx, cardID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 幂等性检查:如果已经是开机状态,跳过
|
||||
if card.NetworkStatus == constants.NetworkStatusOnline {
|
||||
s.logger.Debug("卡已处于开机状态,跳过",
|
||||
zap.Uint("card_id", cardID))
|
||||
return nil
|
||||
}
|
||||
|
||||
// 只有因流量耗尽停机的卡才自动复机
|
||||
if card.StopReason != constants.StopReasonTrafficExhausted {
|
||||
s.logger.Debug("卡非流量耗尽停机,不自动复机",
|
||||
zap.Uint("card_id", cardID),
|
||||
zap.String("stop_reason", card.StopReason))
|
||||
return nil
|
||||
}
|
||||
|
||||
// 任务 24.5: 调用运营商复机接口(带重试机制)
|
||||
if err := s.resumeCardWithRetry(ctx, card); err != nil {
|
||||
s.logger.Error("调用运营商复机接口失败",
|
||||
zap.Uint("card_id", cardID),
|
||||
zap.String("iccid", card.ICCID),
|
||||
zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
// 更新卡状态
|
||||
now := time.Now()
|
||||
if err := s.db.WithContext(ctx).Model(card).Updates(map[string]any{
|
||||
"network_status": constants.NetworkStatusOnline,
|
||||
"resumed_at": now,
|
||||
"stop_reason": "", // 清空停机原因
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.logger.Info("卡购买套餐后已自动复机",
|
||||
zap.Uint("card_id", cardID),
|
||||
zap.String("iccid", card.ICCID))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// hasAvailablePackage 检查是否有可用套餐
|
||||
func (s *StopResumeService) hasAvailablePackage(ctx context.Context, cardID uint) (bool, error) {
|
||||
var count int64
|
||||
err := s.db.WithContext(ctx).Model(&model.PackageUsage{}).
|
||||
Where("iot_card_id = ?", cardID).
|
||||
Where("status IN ?", []int{
|
||||
constants.PackageUsageStatusPending, // 待生效
|
||||
constants.PackageUsageStatusActive, // 生效中
|
||||
}).
|
||||
Count(&count).Error
|
||||
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// stopCardWithRetry 任务 24.5: 调用运营商停机接口(带重试机制)
|
||||
func (s *StopResumeService) stopCardWithRetry(ctx context.Context, card *model.IotCard) error {
|
||||
if s.gatewayClient == nil {
|
||||
s.logger.Warn("Gateway 客户端未配置,跳过调用运营商接口",
|
||||
zap.Uint("card_id", card.ID))
|
||||
return nil
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for i := 0; i < s.maxRetries; i++ {
|
||||
if i > 0 {
|
||||
s.logger.Debug("重试调用停机接口",
|
||||
zap.Int("attempt", i+1),
|
||||
zap.String("iccid", card.ICCID))
|
||||
time.Sleep(s.retryInterval)
|
||||
}
|
||||
|
||||
err := s.gatewayClient.StopCard(ctx, &gateway.CardOperationReq{
|
||||
CardNo: card.ICCID,
|
||||
})
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
lastErr = err
|
||||
s.logger.Warn("调用停机接口失败,准备重试",
|
||||
zap.Int("attempt", i+1),
|
||||
zap.Error(err))
|
||||
}
|
||||
|
||||
return lastErr
|
||||
}
|
||||
|
||||
// resumeCardWithRetry 任务 24.5: 调用运营商复机接口(带重试机制)
|
||||
func (s *StopResumeService) resumeCardWithRetry(ctx context.Context, card *model.IotCard) error {
|
||||
if s.gatewayClient == nil {
|
||||
s.logger.Warn("Gateway 客户端未配置,跳过调用运营商接口",
|
||||
zap.Uint("card_id", card.ID))
|
||||
return nil
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for i := 0; i < s.maxRetries; i++ {
|
||||
if i > 0 {
|
||||
s.logger.Debug("重试调用复机接口",
|
||||
zap.Int("attempt", i+1),
|
||||
zap.String("iccid", card.ICCID))
|
||||
time.Sleep(s.retryInterval)
|
||||
}
|
||||
|
||||
err := s.gatewayClient.StartCard(ctx, &gateway.CardOperationReq{
|
||||
CardNo: card.ICCID,
|
||||
})
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
lastErr = err
|
||||
s.logger.Warn("调用复机接口失败,准备重试",
|
||||
zap.Int("attempt", i+1),
|
||||
zap.Error(err))
|
||||
}
|
||||
|
||||
return lastErr
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
packagepkg "github.com/break/junhong_cmp_fiber/internal/service/package"
|
||||
"github.com/break/junhong_cmp_fiber/internal/service/purchase_validation"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
@@ -30,6 +31,8 @@ type Service struct {
|
||||
iotCardStore *postgres.IotCardStore
|
||||
deviceStore *postgres.DeviceStore
|
||||
packageSeriesStore *postgres.PackageSeriesStore
|
||||
packageUsageStore *postgres.PackageUsageStore
|
||||
packageStore *postgres.PackageStore
|
||||
wechatPayment wechat.PaymentServiceInterface
|
||||
queueClient *queue.Client
|
||||
logger *zap.Logger
|
||||
@@ -46,6 +49,8 @@ func New(
|
||||
iotCardStore *postgres.IotCardStore,
|
||||
deviceStore *postgres.DeviceStore,
|
||||
packageSeriesStore *postgres.PackageSeriesStore,
|
||||
packageUsageStore *postgres.PackageUsageStore,
|
||||
packageStore *postgres.PackageStore,
|
||||
wechatPayment wechat.PaymentServiceInterface,
|
||||
queueClient *queue.Client,
|
||||
logger *zap.Logger,
|
||||
@@ -61,6 +66,8 @@ func New(
|
||||
iotCardStore: iotCardStore,
|
||||
deviceStore: deviceStore,
|
||||
packageSeriesStore: packageSeriesStore,
|
||||
packageUsageStore: packageUsageStore,
|
||||
packageStore: packageStore,
|
||||
wechatPayment: wechatPayment,
|
||||
queueClient: queueClient,
|
||||
logger: logger,
|
||||
@@ -517,8 +524,26 @@ func (s *Service) activatePackage(ctx context.Context, tx *gorm.DB, order *model
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询订单明细失败")
|
||||
}
|
||||
|
||||
// 任务 8.1: 检查混买限制 - 禁止同订单混买正式套餐和加油包
|
||||
if err := s.validatePackageTypeMix(tx, items); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 确定载体类型和ID
|
||||
carrierType := "iot_card"
|
||||
var carrierID uint
|
||||
if order.OrderType == model.OrderTypeSingleCard && order.IotCardID != nil {
|
||||
carrierID = *order.IotCardID
|
||||
} else if order.OrderType == model.OrderTypeDevice && order.DeviceID != nil {
|
||||
carrierType = "device"
|
||||
carrierID = *order.DeviceID
|
||||
} else {
|
||||
return errors.New(errors.CodeInvalidParam, "无效的订单类型或缺少载体ID")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
for _, item := range items {
|
||||
// 检查是否已存在使用记录
|
||||
var existingUsage model.PackageUsage
|
||||
err := tx.Where("order_id = ? AND package_id = ?", order.ID, item.PackageID).
|
||||
First(&existingUsage).Error
|
||||
@@ -532,39 +557,226 @@ func (s *Service) activatePackage(ctx context.Context, tx *gorm.DB, order *model
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "检查套餐使用记录失败")
|
||||
}
|
||||
|
||||
// 查询套餐信息
|
||||
var pkg model.Package
|
||||
if err := tx.First(&pkg, item.PackageID).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐信息失败")
|
||||
}
|
||||
|
||||
usage := &model.PackageUsage{
|
||||
BaseModel: model.BaseModel{
|
||||
Creator: order.Creator,
|
||||
Updater: order.Creator,
|
||||
},
|
||||
OrderID: order.ID,
|
||||
PackageID: item.PackageID,
|
||||
UsageType: order.OrderType,
|
||||
DataLimitMB: pkg.RealDataMB,
|
||||
ActivatedAt: now,
|
||||
ExpiresAt: now.AddDate(0, pkg.DurationMonths, 0),
|
||||
Status: 1,
|
||||
// 根据套餐类型分别处理
|
||||
if pkg.PackageType == "formal" {
|
||||
// 主套餐处理逻辑(任务 8.2-8.4)
|
||||
if err := s.activateMainPackage(ctx, tx, order, &pkg, carrierType, carrierID, now); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if pkg.PackageType == "addon" {
|
||||
// 加油包处理逻辑(任务 8.5-8.7)
|
||||
if err := s.activateAddonPackage(ctx, tx, order, &pkg, carrierType, carrierID, now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validatePackageTypeMix 任务 8.1: 检查混买限制
|
||||
func (s *Service) validatePackageTypeMix(tx *gorm.DB, items []*model.OrderItem) error {
|
||||
hasFormal := false
|
||||
hasAddon := false
|
||||
|
||||
for _, item := range items {
|
||||
var pkg model.Package
|
||||
if err := tx.First(&pkg, item.PackageID).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐信息失败")
|
||||
}
|
||||
|
||||
if order.OrderType == model.OrderTypeSingleCard && order.IotCardID != nil {
|
||||
usage.IotCardID = *order.IotCardID
|
||||
} else if order.OrderType == model.OrderTypeDevice && order.DeviceID != nil {
|
||||
usage.DeviceID = *order.DeviceID
|
||||
if pkg.PackageType == "formal" {
|
||||
hasFormal = true
|
||||
} else if pkg.PackageType == "addon" {
|
||||
hasAddon = true
|
||||
}
|
||||
|
||||
if err := tx.Create(usage).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建套餐使用记录失败")
|
||||
if hasFormal && hasAddon {
|
||||
return errors.New(errors.CodeInvalidParam, "不允许在同一订单中同时购买正式套餐和加油包")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// 检查是否有生效中主套餐
|
||||
var activeMainPackage model.PackageUsage
|
||||
err := tx.Where("status = ?", constants.PackageUsageStatusActive).
|
||||
Where("master_usage_id IS NULL").
|
||||
Where(carrierType+"_id = ?", carrierID).
|
||||
Order("priority ASC").
|
||||
First(&activeMainPackage).Error
|
||||
|
||||
hasActiveMain := err == nil
|
||||
|
||||
var status int
|
||||
var priority int
|
||||
var activatedAt time.Time
|
||||
var expiresAt time.Time
|
||||
var nextResetAt *time.Time
|
||||
var pendingRealnameActivation bool
|
||||
|
||||
if hasActiveMain {
|
||||
// 任务 8.3: 有生效中主套餐,新套餐排队
|
||||
status = constants.PackageUsageStatusPending
|
||||
// 查询当前最大优先级
|
||||
var maxPriority int
|
||||
tx.Model(&model.PackageUsage{}).
|
||||
Where(carrierType+"_id = ?", carrierID).
|
||||
Select("COALESCE(MAX(priority), 0)").
|
||||
Scan(&maxPriority)
|
||||
priority = maxPriority + 1
|
||||
// 排队套餐暂不设置激活时间和过期时间(由激活任务处理)
|
||||
} else {
|
||||
// 任务 8.4: 无生效中主套餐,立即激活
|
||||
status = constants.PackageUsageStatusActive
|
||||
priority = 1
|
||||
activatedAt = now
|
||||
// 使用工具函数计算过期时间
|
||||
expiresAt = packagepkg.CalculateExpiryTime(pkg.CalendarType, activatedAt, pkg.DurationMonths, pkg.DurationDays)
|
||||
// 计算下次重置时间
|
||||
// TODO: 从运营商表读取 billing_day(任务 1.5 待实现)
|
||||
// 暂时使用默认值:联通=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: 后台囤货场景
|
||||
if pkg.EnableRealnameActivation {
|
||||
// 需要实名后才能激活
|
||||
status = constants.PackageUsageStatusPending
|
||||
pendingRealnameActivation = true
|
||||
}
|
||||
|
||||
// 创建套餐使用记录
|
||||
usage := &model.PackageUsage{
|
||||
BaseModel: model.BaseModel{
|
||||
Creator: order.Creator,
|
||||
Updater: order.Creator,
|
||||
},
|
||||
OrderID: order.ID,
|
||||
PackageID: pkg.ID,
|
||||
UsageType: order.OrderType,
|
||||
DataLimitMB: pkg.RealDataMB,
|
||||
Status: status,
|
||||
Priority: priority,
|
||||
DataResetCycle: pkg.DataResetCycle,
|
||||
PendingRealnameActivation: pendingRealnameActivation,
|
||||
}
|
||||
|
||||
if carrierType == "iot_card" {
|
||||
usage.IotCardID = carrierID
|
||||
} else {
|
||||
usage.DeviceID = carrierID
|
||||
}
|
||||
|
||||
if status == constants.PackageUsageStatusActive {
|
||||
usage.ActivatedAt = activatedAt
|
||||
usage.ExpiresAt = expiresAt
|
||||
usage.NextResetAt = nextResetAt
|
||||
}
|
||||
|
||||
// 创建套餐使用记录(两步处理零值问题)
|
||||
if err := tx.Omit("status", "pending_realname_activation").Create(usage).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建主套餐使用记录失败")
|
||||
}
|
||||
// 明确更新零值字段
|
||||
if err := tx.Model(usage).Updates(map[string]interface{}{
|
||||
"status": usage.Status,
|
||||
"pending_realname_activation": usage.PendingRealnameActivation,
|
||||
}).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "更新主套餐状态失败")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// activateAddonPackage 任务 8.5-8.7: 加油包激活逻辑
|
||||
func (s *Service) activateAddonPackage(ctx context.Context, tx *gorm.DB, order *model.Order, pkg *model.Package, carrierType string, carrierID uint, now time.Time) error {
|
||||
// 任务 8.5-8.6: 检查是否有主套餐(status IN (0,1))
|
||||
var mainPackage model.PackageUsage
|
||||
err := tx.Where("status IN ?", []int{constants.PackageUsageStatusPending, constants.PackageUsageStatusActive}).
|
||||
Where("master_usage_id IS NULL").
|
||||
Where(carrierType+"_id = ?", carrierID).
|
||||
Order("priority ASC").
|
||||
First(&mainPackage).Error
|
||||
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return errors.New(errors.CodeInvalidParam, "必须有主套餐才能购买加油包")
|
||||
}
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询主套餐失败")
|
||||
}
|
||||
|
||||
// 任务 8.7: 创建加油包,绑定到主套餐
|
||||
// 查询当前最大优先级(加油包优先级低于主套餐)
|
||||
var maxPriority int
|
||||
tx.Model(&model.PackageUsage{}).
|
||||
Where(carrierType+"_id = ?", carrierID).
|
||||
Select("COALESCE(MAX(priority), 0)").
|
||||
Scan(&maxPriority)
|
||||
priority := maxPriority + 1
|
||||
|
||||
// 加油包立即生效
|
||||
status := constants.PackageUsageStatusActive
|
||||
activatedAt := now
|
||||
|
||||
// 计算过期时间(根据 has_independent_expiry)
|
||||
var expiresAt time.Time
|
||||
// 注意:has_independent_expiry 字段在 Package 模型中,暂时使用默认行为
|
||||
// 默认加油包跟随主套餐过期
|
||||
expiresAt = mainPackage.ExpiresAt
|
||||
|
||||
usage := &model.PackageUsage{
|
||||
BaseModel: model.BaseModel{
|
||||
Creator: order.Creator,
|
||||
Updater: order.Creator,
|
||||
},
|
||||
OrderID: order.ID,
|
||||
PackageID: pkg.ID,
|
||||
UsageType: order.OrderType,
|
||||
DataLimitMB: pkg.RealDataMB,
|
||||
Status: status,
|
||||
Priority: priority,
|
||||
MasterUsageID: &mainPackage.ID,
|
||||
ActivatedAt: activatedAt,
|
||||
ExpiresAt: expiresAt,
|
||||
DataResetCycle: pkg.DataResetCycle,
|
||||
}
|
||||
|
||||
if carrierType == "iot_card" {
|
||||
usage.IotCardID = carrierID
|
||||
} else {
|
||||
usage.DeviceID = carrierID
|
||||
}
|
||||
|
||||
// 创建加油包使用记录(加油包 status=1,不需要处理零值)
|
||||
if err := tx.Create(usage).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建加油包使用记录失败")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) enqueueCommissionCalculation(ctx context.Context, orderID uint) {
|
||||
if s.queueClient == nil {
|
||||
s.logger.Warn("队列客户端未初始化,跳过佣金计算任务入队", zap.Uint("order_id", orderID))
|
||||
|
||||
340
internal/service/package/activation_service.go
Normal file
340
internal/service/package/activation_service.go
Normal file
@@ -0,0 +1,340 @@
|
||||
package packagepkg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ResumeCallback 任务 24.7: 复机回调接口
|
||||
// 用于在套餐激活后触发自动复机
|
||||
type ResumeCallback interface {
|
||||
// ResumeCardIfStopped 购买套餐后自动复机
|
||||
ResumeCardIfStopped(ctx context.Context, cardID uint) error
|
||||
}
|
||||
|
||||
type ActivationService struct {
|
||||
db *gorm.DB
|
||||
redis *redis.Client
|
||||
packageUsageStore *postgres.PackageUsageStore
|
||||
packageStore *postgres.PackageStore
|
||||
packageUsageDailyRecord *postgres.PackageUsageDailyRecordStore
|
||||
logger *zap.Logger
|
||||
resumeCallback ResumeCallback // 复机回调,可选
|
||||
}
|
||||
|
||||
func NewActivationService(
|
||||
db *gorm.DB,
|
||||
redis *redis.Client,
|
||||
packageUsageStore *postgres.PackageUsageStore,
|
||||
packageStore *postgres.PackageStore,
|
||||
packageUsageDailyRecord *postgres.PackageUsageDailyRecordStore,
|
||||
logger *zap.Logger,
|
||||
) *ActivationService {
|
||||
return &ActivationService{
|
||||
db: db,
|
||||
redis: redis,
|
||||
packageUsageStore: packageUsageStore,
|
||||
packageStore: packageStore,
|
||||
packageUsageDailyRecord: packageUsageDailyRecord,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// SetResumeCallback 任务 24.7: 设置复机回调
|
||||
// 在应用启动时由 bootstrap 调用,注入停复机服务
|
||||
func (s *ActivationService) SetResumeCallback(callback ResumeCallback) {
|
||||
s.resumeCallback = callback
|
||||
}
|
||||
|
||||
// ActivateByRealname 任务 9.2-9.3: 首次实名激活
|
||||
// 当用户完成实名后,激活所有待实名激活的套餐
|
||||
func (s *ActivationService) ActivateByRealname(ctx context.Context, carrierType string, carrierID uint) error {
|
||||
// 查询待实名激活的套餐
|
||||
var pendingUsages []*model.PackageUsage
|
||||
query := s.db.WithContext(ctx).
|
||||
Where("pending_realname_activation = ?", true).
|
||||
Where("status = ?", constants.PackageUsageStatusPending)
|
||||
|
||||
if carrierType == "iot_card" {
|
||||
query = query.Where("iot_card_id = ?", carrierID)
|
||||
} else if carrierType == "device" {
|
||||
query = query.Where("device_id = ?", carrierID)
|
||||
} else {
|
||||
return errors.New(errors.CodeInvalidParam, "无效的载体类型")
|
||||
}
|
||||
|
||||
if err := query.Order("priority ASC").Find(&pendingUsages).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询待实名激活套餐失败")
|
||||
}
|
||||
|
||||
if len(pendingUsages) == 0 {
|
||||
s.logger.Info("没有待实名激活的套餐", zap.String("carrier_type", carrierType), zap.Uint("carrier_id", carrierID))
|
||||
return nil
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
// 在事务中激活套餐
|
||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
for _, usage := range pendingUsages {
|
||||
// 查询套餐信息
|
||||
var pkg model.Package
|
||||
if err := tx.First(&pkg, usage.PackageID).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐信息失败")
|
||||
}
|
||||
|
||||
// 检查是否是主套餐
|
||||
if usage.MasterUsageID == nil {
|
||||
// 主套餐:需要检查是否有已激活的主套餐
|
||||
var activeMain model.PackageUsage
|
||||
err := tx.Where("status = ?", constants.PackageUsageStatusActive).
|
||||
Where("master_usage_id IS NULL").
|
||||
Where(carrierType+"_id = ?", carrierID).
|
||||
Order("priority ASC").
|
||||
First(&activeMain).Error
|
||||
|
||||
if err == nil {
|
||||
// 已有激活的主套餐,保持排队状态
|
||||
s.logger.Warn("已有激活主套餐,跳过激活",
|
||||
zap.Uint("usage_id", usage.ID),
|
||||
zap.Uint("active_main_id", activeMain.ID))
|
||||
continue
|
||||
}
|
||||
if err != gorm.ErrRecordNotFound {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "检查生效中主套餐失败")
|
||||
}
|
||||
}
|
||||
|
||||
// 激活套餐
|
||||
activatedAt := now
|
||||
expiresAt := CalculateExpiryTime(pkg.CalendarType, activatedAt, pkg.DurationMonths, pkg.DurationDays)
|
||||
|
||||
// 计算下次重置时间
|
||||
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 := CalculateNextResetTime(pkg.DataResetCycle, now, billingDay)
|
||||
|
||||
// 更新套餐使用记录
|
||||
updates := map[string]interface{}{
|
||||
"status": constants.PackageUsageStatusActive,
|
||||
"pending_realname_activation": false,
|
||||
"activated_at": activatedAt,
|
||||
"expires_at": expiresAt,
|
||||
}
|
||||
if nextResetAt != nil {
|
||||
updates["next_reset_at"] = *nextResetAt
|
||||
}
|
||||
|
||||
if err := tx.Model(usage).Updates(updates).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "激活套餐失败")
|
||||
}
|
||||
|
||||
s.logger.Info("套餐已激活",
|
||||
zap.Uint("usage_id", usage.ID),
|
||||
zap.Uint("package_id", usage.PackageID),
|
||||
zap.Time("activated_at", activatedAt),
|
||||
zap.Time("expires_at", expiresAt))
|
||||
|
||||
// 任务 24.7: 在套餐激活后触发自动复机
|
||||
if s.resumeCallback != nil && carrierType == "iot_card" {
|
||||
go func(cardID uint) {
|
||||
resumeCtx := context.Background()
|
||||
if err := s.resumeCallback.ResumeCardIfStopped(resumeCtx, cardID); err != nil {
|
||||
s.logger.Error("自动复机失败",
|
||||
zap.Uint("card_id", cardID),
|
||||
zap.Error(err))
|
||||
}
|
||||
}(carrierID)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// ActivateQueuedPackage 任务 9.4-9.7: 排队主套餐激活
|
||||
// 当主套餐过期后,激活下一个待生效的主套餐
|
||||
func (s *ActivationService) ActivateQueuedPackage(ctx context.Context, carrierType string, carrierID uint) error {
|
||||
// 使用 Redis 分布式锁避免并发
|
||||
lockKey := constants.RedisPackageActivationLockKey(carrierType, carrierID)
|
||||
lockValue := time.Now().String()
|
||||
locked, err := s.redis.SetNX(ctx, lockKey, lockValue, 30*time.Second).Result()
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeRedisError, err, "获取分布式锁失败")
|
||||
}
|
||||
if !locked {
|
||||
s.logger.Warn("套餐激活正在进行中,跳过",
|
||||
zap.String("carrier_type", carrierType),
|
||||
zap.Uint("carrier_id", carrierID))
|
||||
return nil
|
||||
}
|
||||
defer s.redis.Del(ctx, lockKey)
|
||||
|
||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// 任务 9.5: 检测并标记过期的主套餐
|
||||
now := time.Now()
|
||||
var expiredMainUsages []*model.PackageUsage
|
||||
err := tx.Where("status = ?", constants.PackageUsageStatusActive).
|
||||
Where("master_usage_id IS NULL").
|
||||
Where("expires_at <= ?", now).
|
||||
Where(carrierType+"_id = ?", carrierID).
|
||||
Find(&expiredMainUsages).Error
|
||||
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询过期主套餐失败")
|
||||
}
|
||||
|
||||
for _, expiredMain := range expiredMainUsages {
|
||||
// 更新主套餐状态为已过期
|
||||
if err := tx.Model(expiredMain).Update("status", constants.PackageUsageStatusExpired).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "更新过期主套餐状态失败")
|
||||
}
|
||||
|
||||
s.logger.Info("主套餐已过期",
|
||||
zap.Uint("usage_id", expiredMain.ID),
|
||||
zap.Time("expires_at", expiredMain.ExpiresAt))
|
||||
|
||||
// 任务 9.7: 加油包级联失效
|
||||
if err := s.invalidateAddons(ctx, tx, expiredMain.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 任务 9.6: 激活下一个待生效主套餐
|
||||
if err := s.activateNextMainPackage(ctx, tx, carrierType, carrierID, now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// invalidateAddons 任务 9.7: 加油包级联失效
|
||||
func (s *ActivationService) invalidateAddons(ctx context.Context, tx *gorm.DB, masterUsageID uint) error {
|
||||
var addons []*model.PackageUsage
|
||||
if err := tx.Where("master_usage_id = ?", masterUsageID).
|
||||
Where("status IN ?", []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusPending}).
|
||||
Find(&addons).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询加油包失败")
|
||||
}
|
||||
|
||||
if len(addons) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
addonIDs := make([]uint, len(addons))
|
||||
for i, addon := range addons {
|
||||
addonIDs[i] = addon.ID
|
||||
}
|
||||
|
||||
// 批量更新加油包状态为已失效
|
||||
if err := tx.Model(&model.PackageUsage{}).
|
||||
Where("id IN ?", addonIDs).
|
||||
Update("status", constants.PackageUsageStatusInvalidated).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "批量失效加油包失败")
|
||||
}
|
||||
|
||||
s.logger.Info("加油包已级联失效",
|
||||
zap.Uint("master_usage_id", masterUsageID),
|
||||
zap.Int("addon_count", len(addons)))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// activateNextMainPackage 任务 9.6: 激活下一个待生效主套餐
|
||||
func (s *ActivationService) activateNextMainPackage(ctx context.Context, tx *gorm.DB, carrierType string, carrierID uint, now time.Time) error {
|
||||
// 查询下一个待生效主套餐
|
||||
var nextMain model.PackageUsage
|
||||
err := tx.Where("status = ?", constants.PackageUsageStatusPending).
|
||||
Where("master_usage_id IS NULL").
|
||||
Where(carrierType+"_id = ?", carrierID).
|
||||
Order("priority ASC").
|
||||
First(&nextMain).Error
|
||||
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
s.logger.Info("没有待生效的主套餐",
|
||||
zap.String("carrier_type", carrierType),
|
||||
zap.Uint("carrier_id", carrierID))
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询下一个待生效主套餐失败")
|
||||
}
|
||||
|
||||
// 查询套餐信息
|
||||
var pkg model.Package
|
||||
if err := tx.First(&pkg, nextMain.PackageID).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐信息失败")
|
||||
}
|
||||
|
||||
// 激活套餐
|
||||
activatedAt := now
|
||||
expiresAt := CalculateExpiryTime(pkg.CalendarType, activatedAt, pkg.DurationMonths, pkg.DurationDays)
|
||||
|
||||
// 计算下次重置时间
|
||||
billingDay := 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
nextResetAt := CalculateNextResetTime(pkg.DataResetCycle, now, billingDay)
|
||||
|
||||
// 更新套餐使用记录
|
||||
updates := map[string]interface{}{
|
||||
"status": constants.PackageUsageStatusActive,
|
||||
"activated_at": activatedAt,
|
||||
"expires_at": expiresAt,
|
||||
}
|
||||
if nextResetAt != nil {
|
||||
updates["next_reset_at"] = *nextResetAt
|
||||
}
|
||||
|
||||
if err := tx.Model(&nextMain).Updates(updates).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "激活排队主套餐失败")
|
||||
}
|
||||
|
||||
s.logger.Info("排队主套餐已激活",
|
||||
zap.Uint("usage_id", nextMain.ID),
|
||||
zap.Uint("package_id", nextMain.PackageID),
|
||||
zap.Time("activated_at", activatedAt),
|
||||
zap.Time("expires_at", expiresAt))
|
||||
|
||||
// 任务 24.7: 在套餐激活后触发自动复机
|
||||
if s.resumeCallback != nil && carrierType == "iot_card" {
|
||||
go func(cardID uint) {
|
||||
resumeCtx := context.Background()
|
||||
if err := s.resumeCallback.ResumeCardIfStopped(resumeCtx, cardID); err != nil {
|
||||
s.logger.Error("排队激活后自动复机失败",
|
||||
zap.Uint("card_id", cardID),
|
||||
zap.Error(err))
|
||||
}
|
||||
}(carrierID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
147
internal/service/package/customer_view_service.go
Normal file
147
internal/service/package/customer_view_service.go
Normal file
@@ -0,0 +1,147 @@
|
||||
package packagepkg
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type CustomerViewService struct {
|
||||
db *gorm.DB
|
||||
redis *redis.Client
|
||||
packageUsageStore *postgres.PackageUsageStore
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
func NewCustomerViewService(
|
||||
db *gorm.DB,
|
||||
redis *redis.Client,
|
||||
packageUsageStore *postgres.PackageUsageStore,
|
||||
logger *zap.Logger,
|
||||
) *CustomerViewService {
|
||||
return &CustomerViewService{
|
||||
db: db,
|
||||
redis: redis,
|
||||
packageUsageStore: packageUsageStore,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// GetMyUsage 任务 12.2-12.5: 获取客户套餐使用情况
|
||||
// 根据载体ID和类型查询生效中的套餐,计算总流量使用情况
|
||||
func (s *CustomerViewService) GetMyUsage(ctx context.Context, carrierType string, carrierID uint) (*dto.PackageUsageCustomerViewResponse, error) {
|
||||
// 任务 12.3: 查询生效套餐(status IN (1,2))
|
||||
var packages []*model.PackageUsage
|
||||
query := s.db.WithContext(ctx).
|
||||
Where("status IN ?", []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted})
|
||||
|
||||
if carrierType == "iot_card" {
|
||||
query = query.Where("iot_card_id = ?", carrierID)
|
||||
} else if carrierType == "device" {
|
||||
query = query.Where("device_id = ?", carrierID)
|
||||
} else {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "无效的载体类型")
|
||||
}
|
||||
|
||||
// 按优先级排序:主套餐在前,加油包在后
|
||||
if err := query.Order("CASE WHEN master_usage_id IS NULL THEN 0 ELSE 1 END, priority ASC").
|
||||
Find(&packages).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐使用记录失败")
|
||||
}
|
||||
|
||||
if len(packages) == 0 {
|
||||
return nil, errors.New(errors.CodeNotFound, "未找到套餐使用记录")
|
||||
}
|
||||
|
||||
// 任务 12.4: 区分主套餐和加油包,计算总流量
|
||||
var mainPackage *dto.PackageUsageItemResponse
|
||||
var addonPackages []dto.PackageUsageItemResponse
|
||||
var totalUsedMB int64
|
||||
var totalLimitMB int64
|
||||
|
||||
for _, pkg := range packages {
|
||||
// 查询套餐信息
|
||||
var packageInfo model.Package
|
||||
if err := s.db.First(&packageInfo, pkg.PackageID).Error; err != nil {
|
||||
s.logger.Warn("查询套餐信息失败",
|
||||
zap.Uint("package_id", pkg.PackageID),
|
||||
zap.Error(err))
|
||||
continue
|
||||
}
|
||||
|
||||
// 格式化状态文本
|
||||
statusText := getStatusText(pkg.Status)
|
||||
|
||||
// 格式化时间
|
||||
activatedAtStr := ""
|
||||
if pkg.ActivatedAt.Year() > 1 {
|
||||
activatedAtStr = pkg.ActivatedAt.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
expiresAtStr := ""
|
||||
if pkg.ExpiresAt.Year() > 1 {
|
||||
expiresAtStr = pkg.ExpiresAt.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
item := dto.PackageUsageItemResponse{
|
||||
PackageUsageID: pkg.ID,
|
||||
PackageID: pkg.PackageID,
|
||||
PackageName: packageInfo.PackageName,
|
||||
UsedMB: pkg.DataUsageMB,
|
||||
TotalMB: pkg.DataLimitMB,
|
||||
Status: pkg.Status,
|
||||
StatusText: statusText,
|
||||
ActivatedAt: activatedAtStr,
|
||||
ExpiresAt: expiresAtStr,
|
||||
Priority: pkg.Priority,
|
||||
}
|
||||
|
||||
// 累计总流量
|
||||
totalUsedMB += pkg.DataUsageMB
|
||||
totalLimitMB += pkg.DataLimitMB
|
||||
|
||||
// 区分主套餐和加油包
|
||||
if pkg.MasterUsageID == nil {
|
||||
mainPackage = &item
|
||||
} else {
|
||||
addonPackages = append(addonPackages, item)
|
||||
}
|
||||
}
|
||||
|
||||
// 任务 12.5: 组装响应 DTO
|
||||
response := &dto.PackageUsageCustomerViewResponse{
|
||||
MainPackage: mainPackage,
|
||||
AddonPackages: addonPackages,
|
||||
Total: dto.PackageUsageTotalInfo{
|
||||
UsedMB: totalUsedMB,
|
||||
TotalMB: totalLimitMB,
|
||||
},
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// getStatusText 获取状态文本
|
||||
func getStatusText(status int) string {
|
||||
switch status {
|
||||
case constants.PackageUsageStatusPending:
|
||||
return "待生效"
|
||||
case constants.PackageUsageStatusActive:
|
||||
return "生效中"
|
||||
case constants.PackageUsageStatusDepleted:
|
||||
return "已用完"
|
||||
case constants.PackageUsageStatusExpired:
|
||||
return "已过期"
|
||||
case constants.PackageUsageStatusInvalidated:
|
||||
return "已失效"
|
||||
default:
|
||||
return "未知"
|
||||
}
|
||||
}
|
||||
101
internal/service/package/daily_record_service.go
Normal file
101
internal/service/package/daily_record_service.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package packagepkg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type DailyRecordService struct {
|
||||
db *gorm.DB
|
||||
redis *redis.Client
|
||||
packageUsageDailyRecord *postgres.PackageUsageDailyRecordStore
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
func NewDailyRecordService(
|
||||
db *gorm.DB,
|
||||
redis *redis.Client,
|
||||
packageUsageDailyRecord *postgres.PackageUsageDailyRecordStore,
|
||||
logger *zap.Logger,
|
||||
) *DailyRecordService {
|
||||
return &DailyRecordService{
|
||||
db: db,
|
||||
redis: redis,
|
||||
packageUsageDailyRecord: packageUsageDailyRecord,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// GetDailyRecords 任务 13.2-13.5: 查询套餐流量详单
|
||||
// 查询指定套餐使用记录的日流量明细
|
||||
func (s *DailyRecordService) GetDailyRecords(ctx context.Context, packageUsageID uint, startDate, endDate string) (*dto.PackageUsageDetailResponse, error) {
|
||||
// 查询套餐使用记录
|
||||
var usage model.PackageUsage
|
||||
if err := s.db.WithContext(ctx).First(&usage, packageUsageID).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeNotFound, "套餐使用记录不存在")
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐使用记录失败")
|
||||
}
|
||||
|
||||
// 查询套餐信息
|
||||
var pkg model.Package
|
||||
if err := s.db.WithContext(ctx).First(&pkg, usage.PackageID).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐信息失败")
|
||||
}
|
||||
|
||||
// 任务 13.4: 查询日记录
|
||||
var records []*model.PackageUsageDailyRecord
|
||||
query := s.db.WithContext(ctx).Where("package_usage_id = ?", packageUsageID)
|
||||
|
||||
// 如果提供了日期范围,添加过滤条件
|
||||
if startDate != "" {
|
||||
start, err := time.Parse("2006-01-02", startDate)
|
||||
if err != nil {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "开始日期格式错误")
|
||||
}
|
||||
query = query.Where("date >= ?", start)
|
||||
}
|
||||
|
||||
if endDate != "" {
|
||||
end, err := time.Parse("2006-01-02", endDate)
|
||||
if err != nil {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "结束日期格式错误")
|
||||
}
|
||||
query = query.Where("date <= ?", end)
|
||||
}
|
||||
|
||||
if err := query.Order("date ASC").Find(&records).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询日流量记录失败")
|
||||
}
|
||||
|
||||
// 任务 13.5: 组装响应 DTO
|
||||
recordResponses := make([]dto.PackageUsageDailyRecordResponse, len(records))
|
||||
var totalUsageMB int64
|
||||
|
||||
for i, record := range records {
|
||||
recordResponses[i] = dto.PackageUsageDailyRecordResponse{
|
||||
Date: record.Date.Format("2006-01-02"),
|
||||
DailyUsageMB: record.DailyUsageMB,
|
||||
CumulativeUsageMB: record.CumulativeUsageMB,
|
||||
}
|
||||
totalUsageMB += int64(record.DailyUsageMB)
|
||||
}
|
||||
|
||||
response := &dto.PackageUsageDetailResponse{
|
||||
PackageUsageID: packageUsageID,
|
||||
PackageName: pkg.PackageName,
|
||||
Records: recordResponses,
|
||||
TotalUsageMB: totalUsageMB,
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
242
internal/service/package/reset_service.go
Normal file
242
internal/service/package/reset_service.go
Normal file
@@ -0,0 +1,242 @@
|
||||
package packagepkg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ResetService struct {
|
||||
db *gorm.DB
|
||||
redis *redis.Client
|
||||
packageUsageStore *postgres.PackageUsageStore
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
func NewResetService(
|
||||
db *gorm.DB,
|
||||
redis *redis.Client,
|
||||
packageUsageStore *postgres.PackageUsageStore,
|
||||
logger *zap.Logger,
|
||||
) *ResetService {
|
||||
return &ResetService{
|
||||
db: db,
|
||||
redis: redis,
|
||||
packageUsageStore: packageUsageStore,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// ResetDailyUsage 任务 11.2-11.3: 重置日流量
|
||||
func (s *ResetService) ResetDailyUsage(ctx context.Context) error {
|
||||
return s.resetDailyUsageWithDB(ctx, s.db)
|
||||
}
|
||||
|
||||
// resetDailyUsageWithDB 内部方法,支持传入 DB/TX
|
||||
func (s *ResetService) resetDailyUsageWithDB(ctx context.Context, db *gorm.DB) error {
|
||||
now := time.Now()
|
||||
|
||||
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// 查询需要重置的套餐
|
||||
var packages []*model.PackageUsage
|
||||
err := tx.Where("data_reset_cycle = ?", constants.PackageDataResetDaily).
|
||||
Where("next_reset_at <= ?", now).
|
||||
Where("status IN ?", []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}).
|
||||
Find(&packages).Error
|
||||
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询待重置套餐失败")
|
||||
}
|
||||
|
||||
if len(packages) == 0 {
|
||||
s.logger.Info("没有需要重置的日流量套餐")
|
||||
return nil
|
||||
}
|
||||
|
||||
// 批量重置
|
||||
packageIDs := make([]uint, len(packages))
|
||||
for i, pkg := range packages {
|
||||
packageIDs[i] = pkg.ID
|
||||
}
|
||||
|
||||
// 计算下次重置时间(明天 00:00:00)
|
||||
nextReset := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, now.Location())
|
||||
|
||||
// 批量更新
|
||||
updates := map[string]interface{}{
|
||||
"data_usage_mb": 0,
|
||||
"last_reset_at": now,
|
||||
"next_reset_at": nextReset,
|
||||
"status": constants.PackageUsageStatusActive, // 重置后恢复为生效中
|
||||
}
|
||||
|
||||
if err := tx.Model(&model.PackageUsage{}).
|
||||
Where("id IN ?", packageIDs).
|
||||
Updates(updates).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "批量重置日流量失败")
|
||||
}
|
||||
|
||||
s.logger.Info("日流量重置完成",
|
||||
zap.Int("count", len(packages)),
|
||||
zap.Time("next_reset_at", nextReset))
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// ResetMonthlyUsage 任务 11.4-11.5: 重置月流量
|
||||
func (s *ResetService) ResetMonthlyUsage(ctx context.Context) error {
|
||||
return s.resetMonthlyUsageWithDB(ctx, s.db)
|
||||
}
|
||||
|
||||
// resetMonthlyUsageWithDB 内部方法,支持传入 DB/TX
|
||||
func (s *ResetService) resetMonthlyUsageWithDB(ctx context.Context, db *gorm.DB) error {
|
||||
now := time.Now()
|
||||
|
||||
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// 查询需要重置的套餐
|
||||
var packages []*model.PackageUsage
|
||||
err := tx.Where("data_reset_cycle = ?", constants.PackageDataResetMonthly).
|
||||
Where("next_reset_at <= ?", now).
|
||||
Where("status IN ?", []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}).
|
||||
Find(&packages).Error
|
||||
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询待重置套餐失败")
|
||||
}
|
||||
|
||||
if len(packages) == 0 {
|
||||
s.logger.Info("没有需要重置的月流量套餐")
|
||||
return nil
|
||||
}
|
||||
|
||||
// 按套餐分组处理(因为需要区分联通27号 vs 其他1号)
|
||||
for _, pkg := range packages {
|
||||
// 查询运营商信息以确定计费日
|
||||
// 只有单卡套餐才根据运营商判断,设备级套餐统一使用1号计费
|
||||
billingDay := 1
|
||||
if pkg.IotCardID != 0 {
|
||||
var card model.IotCard
|
||||
if err := tx.First(&card, pkg.IotCardID).Error; err == nil {
|
||||
var carrier model.Carrier
|
||||
if err := tx.First(&carrier, card.CarrierID).Error; err == nil {
|
||||
if carrier.CarrierType == "CUCC" {
|
||||
billingDay = 27
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 设备级套餐默认使用1号计费(已在 billingDay := 1 初始化)
|
||||
|
||||
// 计算下次重置时间
|
||||
nextReset := calculateNextMonthlyResetTime(now, billingDay)
|
||||
|
||||
// 更新套餐
|
||||
updates := map[string]interface{}{
|
||||
"data_usage_mb": 0,
|
||||
"last_reset_at": now,
|
||||
"next_reset_at": nextReset,
|
||||
"status": constants.PackageUsageStatusActive, // 重置后恢复为生效中
|
||||
}
|
||||
|
||||
if err := tx.Model(pkg).Updates(updates).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "重置月流量失败")
|
||||
}
|
||||
|
||||
s.logger.Info("月流量已重置",
|
||||
zap.Uint("usage_id", pkg.ID),
|
||||
zap.Int("billing_day", billingDay),
|
||||
zap.Time("next_reset_at", nextReset))
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// ResetYearlyUsage 任务 11.6-11.7: 重置年流量
|
||||
func (s *ResetService) ResetYearlyUsage(ctx context.Context) error {
|
||||
return s.resetYearlyUsageWithDB(ctx, s.db)
|
||||
}
|
||||
|
||||
// resetYearlyUsageWithDB 内部方法,支持传入 DB/TX
|
||||
func (s *ResetService) resetYearlyUsageWithDB(ctx context.Context, db *gorm.DB) error {
|
||||
now := time.Now()
|
||||
|
||||
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// 查询需要重置的套餐
|
||||
var packages []*model.PackageUsage
|
||||
err := tx.Where("data_reset_cycle = ?", constants.PackageDataResetYearly).
|
||||
Where("next_reset_at <= ?", now).
|
||||
Where("status IN ?", []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}).
|
||||
Find(&packages).Error
|
||||
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询待重置套餐失败")
|
||||
}
|
||||
|
||||
if len(packages) == 0 {
|
||||
s.logger.Info("没有需要重置的年流量套餐")
|
||||
return nil
|
||||
}
|
||||
|
||||
// 批量重置
|
||||
packageIDs := make([]uint, len(packages))
|
||||
for i, pkg := range packages {
|
||||
packageIDs[i] = pkg.ID
|
||||
}
|
||||
|
||||
// 计算下次重置时间(明年 1月1日 00:00:00)
|
||||
nextReset := time.Date(now.Year()+1, 1, 1, 0, 0, 0, 0, now.Location())
|
||||
|
||||
// 批量更新
|
||||
updates := map[string]interface{}{
|
||||
"data_usage_mb": 0,
|
||||
"last_reset_at": now,
|
||||
"next_reset_at": nextReset,
|
||||
"status": constants.PackageUsageStatusActive, // 重置后恢复为生效中
|
||||
}
|
||||
|
||||
if err := tx.Model(&model.PackageUsage{}).
|
||||
Where("id IN ?", packageIDs).
|
||||
Updates(updates).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "批量重置年流量失败")
|
||||
}
|
||||
|
||||
s.logger.Info("年流量重置完成",
|
||||
zap.Int("count", len(packages)),
|
||||
zap.Time("next_reset_at", nextReset))
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// 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())
|
||||
}
|
||||
@@ -62,6 +62,23 @@ func (s *Service) Create(ctx context.Context, req *dto.CreatePackageRequest) (*d
|
||||
}
|
||||
}
|
||||
|
||||
// 校验套餐周期类型和时长配置
|
||||
calendarType := constants.PackageCalendarTypeByDay // 默认按天
|
||||
if req.CalendarType != nil {
|
||||
calendarType = *req.CalendarType
|
||||
}
|
||||
if calendarType == constants.PackageCalendarTypeNaturalMonth {
|
||||
// 自然月套餐:必须提供 duration_months
|
||||
if req.DurationMonths <= 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "自然月套餐必须提供有效的duration_months")
|
||||
}
|
||||
} else if calendarType == constants.PackageCalendarTypeByDay {
|
||||
// 按天套餐:必须提供 duration_days
|
||||
if req.DurationDays == nil || *req.DurationDays <= 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "按天套餐必须提供有效的duration_days")
|
||||
}
|
||||
}
|
||||
|
||||
var seriesName *string
|
||||
if req.SeriesID != nil && *req.SeriesID > 0 {
|
||||
series, err := s.packageSeriesStore.GetByID(ctx, *req.SeriesID)
|
||||
@@ -81,6 +98,7 @@ func (s *Service) Create(ctx context.Context, req *dto.CreatePackageRequest) (*d
|
||||
DurationMonths: req.DurationMonths,
|
||||
CostPrice: req.CostPrice,
|
||||
EnableVirtualData: req.EnableVirtualData,
|
||||
CalendarType: calendarType,
|
||||
Status: constants.StatusEnabled,
|
||||
ShelfStatus: 2,
|
||||
}
|
||||
@@ -96,6 +114,21 @@ func (s *Service) Create(ctx context.Context, req *dto.CreatePackageRequest) (*d
|
||||
if req.SuggestedRetailPrice != nil {
|
||||
pkg.SuggestedRetailPrice = *req.SuggestedRetailPrice
|
||||
}
|
||||
if req.DurationDays != nil {
|
||||
pkg.DurationDays = *req.DurationDays
|
||||
}
|
||||
if req.DataResetCycle != nil {
|
||||
pkg.DataResetCycle = *req.DataResetCycle
|
||||
} else {
|
||||
// 默认月重置
|
||||
pkg.DataResetCycle = constants.PackageDataResetMonthly
|
||||
}
|
||||
if req.EnableRealnameActivation != nil {
|
||||
pkg.EnableRealnameActivation = *req.EnableRealnameActivation
|
||||
} else {
|
||||
// 默认启用实名激活
|
||||
pkg.EnableRealnameActivation = true
|
||||
}
|
||||
pkg.Creator = currentUserID
|
||||
|
||||
if err := s.packageStore.Create(ctx, pkg); err != nil {
|
||||
@@ -183,6 +216,29 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdatePackageReq
|
||||
if req.SuggestedRetailPrice != nil {
|
||||
pkg.SuggestedRetailPrice = *req.SuggestedRetailPrice
|
||||
}
|
||||
if req.CalendarType != nil {
|
||||
pkg.CalendarType = *req.CalendarType
|
||||
}
|
||||
if req.DurationDays != nil {
|
||||
pkg.DurationDays = *req.DurationDays
|
||||
}
|
||||
if req.DataResetCycle != nil {
|
||||
pkg.DataResetCycle = *req.DataResetCycle
|
||||
}
|
||||
if req.EnableRealnameActivation != nil {
|
||||
pkg.EnableRealnameActivation = *req.EnableRealnameActivation
|
||||
}
|
||||
|
||||
// 校验套餐周期类型和时长配置
|
||||
if pkg.CalendarType == constants.PackageCalendarTypeNaturalMonth {
|
||||
if pkg.DurationMonths <= 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "自然月套餐必须提供有效的duration_months")
|
||||
}
|
||||
} else if pkg.CalendarType == constants.PackageCalendarTypeByDay {
|
||||
if pkg.DurationDays <= 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "按天套餐必须提供有效的duration_days")
|
||||
}
|
||||
}
|
||||
|
||||
// 校验虚流量配置
|
||||
if pkg.EnableVirtualData {
|
||||
@@ -397,22 +453,31 @@ func (s *Service) toResponse(ctx context.Context, pkg *model.Package) *dto.Packa
|
||||
seriesID = &pkg.SeriesID
|
||||
}
|
||||
|
||||
var durationDays *int
|
||||
if pkg.CalendarType == constants.PackageCalendarTypeByDay && pkg.DurationDays > 0 {
|
||||
durationDays = &pkg.DurationDays
|
||||
}
|
||||
|
||||
resp := &dto.PackageResponse{
|
||||
ID: pkg.ID,
|
||||
PackageCode: pkg.PackageCode,
|
||||
PackageName: pkg.PackageName,
|
||||
SeriesID: seriesID,
|
||||
PackageType: pkg.PackageType,
|
||||
DurationMonths: pkg.DurationMonths,
|
||||
RealDataMB: pkg.RealDataMB,
|
||||
VirtualDataMB: pkg.VirtualDataMB,
|
||||
EnableVirtualData: pkg.EnableVirtualData,
|
||||
CostPrice: pkg.CostPrice,
|
||||
SuggestedRetailPrice: pkg.SuggestedRetailPrice,
|
||||
Status: pkg.Status,
|
||||
ShelfStatus: pkg.ShelfStatus,
|
||||
CreatedAt: pkg.CreatedAt.Format(time.RFC3339),
|
||||
UpdatedAt: pkg.UpdatedAt.Format(time.RFC3339),
|
||||
ID: pkg.ID,
|
||||
PackageCode: pkg.PackageCode,
|
||||
PackageName: pkg.PackageName,
|
||||
SeriesID: seriesID,
|
||||
PackageType: pkg.PackageType,
|
||||
DurationMonths: pkg.DurationMonths,
|
||||
RealDataMB: pkg.RealDataMB,
|
||||
VirtualDataMB: pkg.VirtualDataMB,
|
||||
EnableVirtualData: pkg.EnableVirtualData,
|
||||
CostPrice: pkg.CostPrice,
|
||||
SuggestedRetailPrice: pkg.SuggestedRetailPrice,
|
||||
CalendarType: pkg.CalendarType,
|
||||
DurationDays: durationDays,
|
||||
DataResetCycle: pkg.DataResetCycle,
|
||||
EnableRealnameActivation: pkg.EnableRealnameActivation,
|
||||
Status: pkg.Status,
|
||||
ShelfStatus: pkg.ShelfStatus,
|
||||
CreatedAt: pkg.CreatedAt.Format(time.RFC3339),
|
||||
UpdatedAt: pkg.UpdatedAt.Format(time.RFC3339),
|
||||
}
|
||||
|
||||
userType := middleware.GetUserTypeFromContext(ctx)
|
||||
@@ -450,22 +515,31 @@ func (s *Service) toResponseWithAllocation(_ context.Context, pkg *model.Package
|
||||
seriesID = &pkg.SeriesID
|
||||
}
|
||||
|
||||
var durationDays *int
|
||||
if pkg.CalendarType == constants.PackageCalendarTypeByDay && pkg.DurationDays > 0 {
|
||||
durationDays = &pkg.DurationDays
|
||||
}
|
||||
|
||||
resp := &dto.PackageResponse{
|
||||
ID: pkg.ID,
|
||||
PackageCode: pkg.PackageCode,
|
||||
PackageName: pkg.PackageName,
|
||||
SeriesID: seriesID,
|
||||
PackageType: pkg.PackageType,
|
||||
DurationMonths: pkg.DurationMonths,
|
||||
RealDataMB: pkg.RealDataMB,
|
||||
VirtualDataMB: pkg.VirtualDataMB,
|
||||
EnableVirtualData: pkg.EnableVirtualData,
|
||||
CostPrice: pkg.CostPrice,
|
||||
SuggestedRetailPrice: pkg.SuggestedRetailPrice,
|
||||
Status: pkg.Status,
|
||||
ShelfStatus: pkg.ShelfStatus,
|
||||
CreatedAt: pkg.CreatedAt.Format(time.RFC3339),
|
||||
UpdatedAt: pkg.UpdatedAt.Format(time.RFC3339),
|
||||
ID: pkg.ID,
|
||||
PackageCode: pkg.PackageCode,
|
||||
PackageName: pkg.PackageName,
|
||||
SeriesID: seriesID,
|
||||
PackageType: pkg.PackageType,
|
||||
DurationMonths: pkg.DurationMonths,
|
||||
RealDataMB: pkg.RealDataMB,
|
||||
VirtualDataMB: pkg.VirtualDataMB,
|
||||
EnableVirtualData: pkg.EnableVirtualData,
|
||||
CostPrice: pkg.CostPrice,
|
||||
SuggestedRetailPrice: pkg.SuggestedRetailPrice,
|
||||
CalendarType: pkg.CalendarType,
|
||||
DurationDays: durationDays,
|
||||
DataResetCycle: pkg.DataResetCycle,
|
||||
EnableRealnameActivation: pkg.EnableRealnameActivation,
|
||||
Status: pkg.Status,
|
||||
ShelfStatus: pkg.ShelfStatus,
|
||||
CreatedAt: pkg.CreatedAt.Format(time.RFC3339),
|
||||
UpdatedAt: pkg.UpdatedAt.Format(time.RFC3339),
|
||||
}
|
||||
|
||||
if allocationMap != nil {
|
||||
|
||||
@@ -1,673 +0,0 @@
|
||||
package packagepkg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
"github.com/break/junhong_cmp_fiber/tests/testutils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func generateUniquePackageCode(prefix string) string {
|
||||
return fmt.Sprintf("%s_%d", prefix, time.Now().UnixNano())
|
||||
}
|
||||
|
||||
func TestPackageService_Create(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
packageStore := postgres.NewPackageStore(tx)
|
||||
packageSeriesStore := postgres.NewPackageSeriesStore(tx)
|
||||
svc := New(packageStore, packageSeriesStore, nil, nil)
|
||||
|
||||
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
|
||||
UserID: 1,
|
||||
UserType: constants.UserTypePlatform,
|
||||
})
|
||||
|
||||
t.Run("创建成功", func(t *testing.T) {
|
||||
req := &dto.CreatePackageRequest{
|
||||
PackageCode: generateUniquePackageCode("PKG_CREATE"),
|
||||
PackageName: "创建测试套餐",
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
}
|
||||
|
||||
resp, err := svc.Create(ctx, req)
|
||||
require.NoError(t, err)
|
||||
assert.NotZero(t, resp.ID)
|
||||
assert.Equal(t, req.PackageCode, resp.PackageCode)
|
||||
assert.Equal(t, req.PackageName, resp.PackageName)
|
||||
assert.Equal(t, constants.StatusEnabled, resp.Status)
|
||||
assert.Equal(t, 2, resp.ShelfStatus)
|
||||
})
|
||||
|
||||
t.Run("编码重复失败", func(t *testing.T) {
|
||||
code := generateUniquePackageCode("PKG_DUP")
|
||||
req1 := &dto.CreatePackageRequest{
|
||||
PackageCode: code,
|
||||
PackageName: "第一个套餐",
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
}
|
||||
_, err := svc.Create(ctx, req1)
|
||||
require.NoError(t, err)
|
||||
|
||||
req2 := &dto.CreatePackageRequest{
|
||||
PackageCode: code,
|
||||
PackageName: "第二个套餐",
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
}
|
||||
_, err = svc.Create(ctx, req2)
|
||||
require.Error(t, err)
|
||||
appErr, ok := err.(*errors.AppError)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, errors.CodeConflict, appErr.Code)
|
||||
})
|
||||
|
||||
t.Run("系列不存在失败", func(t *testing.T) {
|
||||
req := &dto.CreatePackageRequest{
|
||||
PackageCode: generateUniquePackageCode("PKG_SERIES"),
|
||||
PackageName: "系列测试套餐",
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
SeriesID: func() *uint { id := uint(99999); return &id }(),
|
||||
}
|
||||
|
||||
_, err := svc.Create(ctx, req)
|
||||
require.Error(t, err)
|
||||
appErr, ok := err.(*errors.AppError)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, errors.CodeNotFound, appErr.Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPackageService_UpdateStatus(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
packageStore := postgres.NewPackageStore(tx)
|
||||
packageSeriesStore := postgres.NewPackageSeriesStore(tx)
|
||||
svc := New(packageStore, packageSeriesStore, nil, nil)
|
||||
|
||||
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
|
||||
UserID: 1,
|
||||
UserType: constants.UserTypePlatform,
|
||||
})
|
||||
|
||||
req := &dto.CreatePackageRequest{
|
||||
PackageCode: generateUniquePackageCode("PKG_STATUS"),
|
||||
PackageName: "状态测试套餐",
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
}
|
||||
created, err := svc.Create(ctx, req)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("禁用套餐时自动强制下架", func(t *testing.T) {
|
||||
err := svc.UpdateShelfStatus(ctx, created.ID, 1)
|
||||
require.NoError(t, err)
|
||||
|
||||
pkg, err := svc.Get(ctx, created.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, pkg.ShelfStatus)
|
||||
|
||||
err = svc.UpdateStatus(ctx, created.ID, constants.StatusDisabled)
|
||||
require.NoError(t, err)
|
||||
|
||||
pkg, err = svc.Get(ctx, created.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, constants.StatusDisabled, pkg.Status)
|
||||
assert.Equal(t, 2, pkg.ShelfStatus)
|
||||
})
|
||||
|
||||
t.Run("启用套餐时保持原上架状态", func(t *testing.T) {
|
||||
req2 := &dto.CreatePackageRequest{
|
||||
PackageCode: generateUniquePackageCode("PKG_ENABLE"),
|
||||
PackageName: "启用测试套餐",
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
}
|
||||
created2, err := svc.Create(ctx, req2)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = svc.UpdateShelfStatus(ctx, created2.ID, 1)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = svc.UpdateStatus(ctx, created2.ID, constants.StatusDisabled)
|
||||
require.NoError(t, err)
|
||||
|
||||
pkg, err := svc.Get(ctx, created2.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, constants.StatusDisabled, pkg.Status)
|
||||
assert.Equal(t, 2, pkg.ShelfStatus)
|
||||
|
||||
err = svc.UpdateStatus(ctx, created2.ID, constants.StatusEnabled)
|
||||
require.NoError(t, err)
|
||||
|
||||
pkg, err = svc.Get(ctx, created2.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, constants.StatusEnabled, pkg.Status)
|
||||
assert.Equal(t, 2, pkg.ShelfStatus)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPackageService_UpdateShelfStatus(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
packageStore := postgres.NewPackageStore(tx)
|
||||
packageSeriesStore := postgres.NewPackageSeriesStore(tx)
|
||||
svc := New(packageStore, packageSeriesStore, nil, nil)
|
||||
|
||||
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
|
||||
UserID: 1,
|
||||
UserType: constants.UserTypePlatform,
|
||||
})
|
||||
|
||||
t.Run("启用状态的套餐可以上架", func(t *testing.T) {
|
||||
req := &dto.CreatePackageRequest{
|
||||
PackageCode: generateUniquePackageCode("PKG_SHELF_ENABLE"),
|
||||
PackageName: "上架测试-启用",
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
}
|
||||
created, err := svc.Create(ctx, req)
|
||||
require.NoError(t, err)
|
||||
|
||||
pkg, err := svc.Get(ctx, created.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, pkg.Status)
|
||||
assert.Equal(t, 2, pkg.ShelfStatus)
|
||||
|
||||
err = svc.UpdateShelfStatus(ctx, created.ID, 1)
|
||||
require.NoError(t, err)
|
||||
|
||||
pkg, err = svc.Get(ctx, created.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, pkg.ShelfStatus)
|
||||
})
|
||||
|
||||
t.Run("禁用状态的套餐不能上架", func(t *testing.T) {
|
||||
req := &dto.CreatePackageRequest{
|
||||
PackageCode: generateUniquePackageCode("PKG_SHELF_DISABLE"),
|
||||
PackageName: "上架测试-禁用",
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
}
|
||||
created, err := svc.Create(ctx, req)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = svc.UpdateStatus(ctx, created.ID, constants.StatusDisabled)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = svc.UpdateShelfStatus(ctx, created.ID, 1)
|
||||
require.Error(t, err)
|
||||
appErr, ok := err.(*errors.AppError)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, errors.CodeInvalidStatus, appErr.Code)
|
||||
|
||||
pkg, err := svc.Get(ctx, created.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 2, pkg.ShelfStatus)
|
||||
})
|
||||
|
||||
t.Run("下架成功", func(t *testing.T) {
|
||||
req := &dto.CreatePackageRequest{
|
||||
PackageCode: generateUniquePackageCode("PKG_SHELF_OFF"),
|
||||
PackageName: "下架测试",
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
}
|
||||
created, err := svc.Create(ctx, req)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = svc.UpdateShelfStatus(ctx, created.ID, 1)
|
||||
require.NoError(t, err)
|
||||
|
||||
pkg, err := svc.Get(ctx, created.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, pkg.ShelfStatus)
|
||||
|
||||
err = svc.UpdateShelfStatus(ctx, created.ID, 2)
|
||||
require.NoError(t, err)
|
||||
|
||||
pkg, err = svc.Get(ctx, created.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 2, pkg.ShelfStatus)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPackageService_Get(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
packageStore := postgres.NewPackageStore(tx)
|
||||
packageSeriesStore := postgres.NewPackageSeriesStore(tx)
|
||||
svc := New(packageStore, packageSeriesStore, nil, nil)
|
||||
|
||||
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
|
||||
UserID: 1,
|
||||
UserType: constants.UserTypePlatform,
|
||||
})
|
||||
|
||||
req := &dto.CreatePackageRequest{
|
||||
PackageCode: generateUniquePackageCode("PKG_GET"),
|
||||
PackageName: "查询测试套餐",
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
}
|
||||
created, err := svc.Create(ctx, req)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("获取成功", func(t *testing.T) {
|
||||
resp, err := svc.Get(ctx, created.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, created.PackageCode, resp.PackageCode)
|
||||
assert.Equal(t, created.PackageName, resp.PackageName)
|
||||
assert.Equal(t, created.ID, resp.ID)
|
||||
})
|
||||
|
||||
t.Run("不存在返回错误", func(t *testing.T) {
|
||||
_, err := svc.Get(ctx, 99999)
|
||||
require.Error(t, err)
|
||||
appErr, ok := err.(*errors.AppError)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, errors.CodeNotFound, appErr.Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPackageService_Update(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
packageStore := postgres.NewPackageStore(tx)
|
||||
packageSeriesStore := postgres.NewPackageSeriesStore(tx)
|
||||
svc := New(packageStore, packageSeriesStore, nil, nil)
|
||||
|
||||
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
|
||||
UserID: 1,
|
||||
UserType: constants.UserTypePlatform,
|
||||
})
|
||||
|
||||
req := &dto.CreatePackageRequest{
|
||||
PackageCode: generateUniquePackageCode("PKG_UPDATE"),
|
||||
PackageName: "更新测试套餐",
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
}
|
||||
created, err := svc.Create(ctx, req)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("更新成功", func(t *testing.T) {
|
||||
newName := "更新后的套餐名称"
|
||||
updateReq := &dto.UpdatePackageRequest{
|
||||
PackageName: &newName,
|
||||
}
|
||||
|
||||
resp, err := svc.Update(ctx, created.ID, updateReq)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, newName, resp.PackageName)
|
||||
})
|
||||
|
||||
t.Run("更新不存在的套餐", func(t *testing.T) {
|
||||
newName := "test"
|
||||
updateReq := &dto.UpdatePackageRequest{
|
||||
PackageName: &newName,
|
||||
}
|
||||
|
||||
_, err := svc.Update(ctx, 99999, updateReq)
|
||||
require.Error(t, err)
|
||||
appErr, ok := err.(*errors.AppError)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, errors.CodeNotFound, appErr.Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPackageService_Delete(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
packageStore := postgres.NewPackageStore(tx)
|
||||
packageSeriesStore := postgres.NewPackageSeriesStore(tx)
|
||||
svc := New(packageStore, packageSeriesStore, nil, nil)
|
||||
|
||||
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
|
||||
UserID: 1,
|
||||
UserType: constants.UserTypePlatform,
|
||||
})
|
||||
|
||||
req := &dto.CreatePackageRequest{
|
||||
PackageCode: generateUniquePackageCode("PKG_DELETE"),
|
||||
PackageName: "删除测试套餐",
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
}
|
||||
created, err := svc.Create(ctx, req)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("删除成功", func(t *testing.T) {
|
||||
err := svc.Delete(ctx, created.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = svc.Get(ctx, created.ID)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("删除不存在的套餐", func(t *testing.T) {
|
||||
err := svc.Delete(ctx, 99999)
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPackageService_List(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
packageStore := postgres.NewPackageStore(tx)
|
||||
packageSeriesStore := postgres.NewPackageSeriesStore(tx)
|
||||
svc := New(packageStore, packageSeriesStore, nil, nil)
|
||||
|
||||
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
|
||||
UserID: 1,
|
||||
UserType: constants.UserTypePlatform,
|
||||
})
|
||||
|
||||
packages := []dto.CreatePackageRequest{
|
||||
{
|
||||
PackageCode: generateUniquePackageCode("PKG_LIST_001"),
|
||||
PackageName: "列表测试套餐1",
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
},
|
||||
{
|
||||
PackageCode: generateUniquePackageCode("PKG_LIST_002"),
|
||||
PackageName: "列表测试套餐2",
|
||||
PackageType: "addon",
|
||||
DurationMonths: 1,
|
||||
},
|
||||
{
|
||||
PackageCode: generateUniquePackageCode("PKG_LIST_003"),
|
||||
PackageName: "列表测试套餐3",
|
||||
PackageType: "formal",
|
||||
DurationMonths: 12,
|
||||
},
|
||||
}
|
||||
|
||||
for _, p := range packages {
|
||||
_, err := svc.Create(ctx, &p)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
t.Run("列表查询", func(t *testing.T) {
|
||||
req := &dto.PackageListRequest{
|
||||
Page: 1,
|
||||
PageSize: 10,
|
||||
}
|
||||
|
||||
resp, total, err := svc.List(ctx, req)
|
||||
require.NoError(t, err)
|
||||
assert.Greater(t, total, int64(0))
|
||||
assert.Greater(t, len(resp), 0)
|
||||
})
|
||||
|
||||
t.Run("按套餐类型过滤", func(t *testing.T) {
|
||||
packageType := "formal"
|
||||
req := &dto.PackageListRequest{
|
||||
Page: 1,
|
||||
PageSize: 10,
|
||||
PackageType: &packageType,
|
||||
}
|
||||
|
||||
resp, _, err := svc.List(ctx, req)
|
||||
require.NoError(t, err)
|
||||
for _, p := range resp {
|
||||
assert.Equal(t, packageType, p.PackageType)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("按状态过滤", func(t *testing.T) {
|
||||
status := constants.StatusEnabled
|
||||
req := &dto.PackageListRequest{
|
||||
Page: 1,
|
||||
PageSize: 10,
|
||||
Status: &status,
|
||||
}
|
||||
|
||||
resp, _, err := svc.List(ctx, req)
|
||||
require.NoError(t, err)
|
||||
for _, p := range resp {
|
||||
assert.Equal(t, status, p.Status)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPackageService_VirtualDataValidation(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
packageStore := postgres.NewPackageStore(tx)
|
||||
packageSeriesStore := postgres.NewPackageSeriesStore(tx)
|
||||
svc := New(packageStore, packageSeriesStore, nil, nil)
|
||||
|
||||
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
|
||||
UserID: 1,
|
||||
UserType: constants.UserTypePlatform,
|
||||
})
|
||||
|
||||
t.Run("启用虚流量时虚流量必须大于0", func(t *testing.T) {
|
||||
req := &dto.CreatePackageRequest{
|
||||
PackageCode: generateUniquePackageCode("PKG_VDATA_1"),
|
||||
PackageName: "虚流量测试-零值",
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
EnableVirtualData: true,
|
||||
RealDataMB: func() *int64 { v := int64(1000); return &v }(),
|
||||
VirtualDataMB: func() *int64 { v := int64(0); return &v }(),
|
||||
}
|
||||
|
||||
_, err := svc.Create(ctx, req)
|
||||
require.Error(t, err)
|
||||
appErr, ok := err.(*errors.AppError)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, errors.CodeInvalidParam, appErr.Code)
|
||||
assert.Contains(t, appErr.Message, "虚流量额度必须大于0")
|
||||
})
|
||||
|
||||
t.Run("启用虚流量时虚流量不能超过真流量", func(t *testing.T) {
|
||||
req := &dto.CreatePackageRequest{
|
||||
PackageCode: generateUniquePackageCode("PKG_VDATA_2"),
|
||||
PackageName: "虚流量测试-超过",
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
EnableVirtualData: true,
|
||||
RealDataMB: func() *int64 { v := int64(1000); return &v }(),
|
||||
VirtualDataMB: func() *int64 { v := int64(2000); return &v }(),
|
||||
}
|
||||
|
||||
_, err := svc.Create(ctx, req)
|
||||
require.Error(t, err)
|
||||
appErr, ok := err.(*errors.AppError)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, errors.CodeInvalidParam, appErr.Code)
|
||||
assert.Contains(t, appErr.Message, "虚流量额度不能大于真流量额度")
|
||||
})
|
||||
|
||||
t.Run("启用虚流量时配置正确则创建成功", func(t *testing.T) {
|
||||
req := &dto.CreatePackageRequest{
|
||||
PackageCode: generateUniquePackageCode("PKG_VDATA_3"),
|
||||
PackageName: "虚流量测试-正确",
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
EnableVirtualData: true,
|
||||
RealDataMB: func() *int64 { v := int64(1000); return &v }(),
|
||||
VirtualDataMB: func() *int64 { v := int64(500); return &v }(),
|
||||
}
|
||||
|
||||
resp, err := svc.Create(ctx, req)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, resp.EnableVirtualData)
|
||||
assert.Equal(t, int64(500), resp.VirtualDataMB)
|
||||
})
|
||||
|
||||
t.Run("不启用虚流量时可以不填虚流量值", func(t *testing.T) {
|
||||
req := &dto.CreatePackageRequest{
|
||||
PackageCode: generateUniquePackageCode("PKG_VDATA_4"),
|
||||
PackageName: "虚流量测试-不启用",
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
EnableVirtualData: false,
|
||||
RealDataMB: func() *int64 { v := int64(1000); return &v }(),
|
||||
}
|
||||
|
||||
resp, err := svc.Create(ctx, req)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, resp.EnableVirtualData)
|
||||
})
|
||||
|
||||
t.Run("更新时校验虚流量配置", func(t *testing.T) {
|
||||
req := &dto.CreatePackageRequest{
|
||||
PackageCode: generateUniquePackageCode("PKG_VDATA_5"),
|
||||
PackageName: "虚流量测试-更新",
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
EnableVirtualData: false,
|
||||
RealDataMB: func() *int64 { v := int64(1000); return &v }(),
|
||||
}
|
||||
created, err := svc.Create(ctx, req)
|
||||
require.NoError(t, err)
|
||||
|
||||
enableVD := true
|
||||
virtualDataMB := int64(2000)
|
||||
updateReq := &dto.UpdatePackageRequest{
|
||||
EnableVirtualData: &enableVD,
|
||||
VirtualDataMB: &virtualDataMB,
|
||||
}
|
||||
_, err = svc.Update(ctx, created.ID, updateReq)
|
||||
require.Error(t, err)
|
||||
appErr, ok := err.(*errors.AppError)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, errors.CodeInvalidParam, appErr.Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPackageService_SeriesNameInResponse(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
packageStore := postgres.NewPackageStore(tx)
|
||||
packageSeriesStore := postgres.NewPackageSeriesStore(tx)
|
||||
svc := New(packageStore, packageSeriesStore, nil, nil)
|
||||
|
||||
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
|
||||
UserID: 1,
|
||||
UserType: constants.UserTypePlatform,
|
||||
})
|
||||
|
||||
// 创建套餐系列
|
||||
series := &model.PackageSeries{
|
||||
SeriesCode: fmt.Sprintf("SERIES_%d", time.Now().UnixNano()),
|
||||
SeriesName: "测试套餐系列",
|
||||
Description: "用于测试系列名称字段",
|
||||
Status: constants.StatusEnabled,
|
||||
}
|
||||
series.Creator = 1
|
||||
err := packageSeriesStore.Create(ctx, series)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("创建套餐时返回系列名称", func(t *testing.T) {
|
||||
req := &dto.CreatePackageRequest{
|
||||
PackageCode: generateUniquePackageCode("PKG_SERIES"),
|
||||
PackageName: "带系列的套餐",
|
||||
SeriesID: &series.ID,
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
}
|
||||
|
||||
resp, err := svc.Create(ctx, req)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, resp.SeriesName)
|
||||
assert.Equal(t, series.SeriesName, *resp.SeriesName)
|
||||
})
|
||||
|
||||
t.Run("获取套餐时返回系列名称", func(t *testing.T) {
|
||||
// 先创建一个套餐
|
||||
req := &dto.CreatePackageRequest{
|
||||
PackageCode: generateUniquePackageCode("PKG_GET_SERIES"),
|
||||
PackageName: "获取测试套餐",
|
||||
SeriesID: &series.ID,
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
}
|
||||
created, err := svc.Create(ctx, req)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 获取套餐
|
||||
resp, err := svc.Get(ctx, created.ID)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, resp.SeriesName)
|
||||
assert.Equal(t, series.SeriesName, *resp.SeriesName)
|
||||
})
|
||||
|
||||
t.Run("更新套餐时返回系列名称", func(t *testing.T) {
|
||||
// 先创建一个套餐
|
||||
req := &dto.CreatePackageRequest{
|
||||
PackageCode: generateUniquePackageCode("PKG_UPDATE_SERIES"),
|
||||
PackageName: "更新测试套餐",
|
||||
SeriesID: &series.ID,
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
}
|
||||
created, err := svc.Create(ctx, req)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 更新套餐
|
||||
newName := "更新后的套餐"
|
||||
updateReq := &dto.UpdatePackageRequest{
|
||||
PackageName: &newName,
|
||||
}
|
||||
resp, err := svc.Update(ctx, created.ID, updateReq)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, resp.SeriesName)
|
||||
assert.Equal(t, series.SeriesName, *resp.SeriesName)
|
||||
})
|
||||
|
||||
t.Run("列表查询时返回系列名称", func(t *testing.T) {
|
||||
// 创建多个带系列的套餐
|
||||
for i := 0; i < 3; i++ {
|
||||
req := &dto.CreatePackageRequest{
|
||||
PackageCode: generateUniquePackageCode(fmt.Sprintf("PKG_LIST_SERIES_%d", i)),
|
||||
PackageName: fmt.Sprintf("列表测试套餐%d", i),
|
||||
SeriesID: &series.ID,
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
}
|
||||
_, err := svc.Create(ctx, req)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// 查询列表
|
||||
listReq := &dto.PackageListRequest{
|
||||
Page: 1,
|
||||
PageSize: 10,
|
||||
SeriesID: &series.ID,
|
||||
}
|
||||
resp, _, err := svc.List(ctx, listReq)
|
||||
require.NoError(t, err)
|
||||
assert.Greater(t, len(resp), 0)
|
||||
|
||||
// 验证所有套餐都有系列名称
|
||||
for _, pkg := range resp {
|
||||
if pkg.SeriesID != nil && *pkg.SeriesID == series.ID {
|
||||
assert.NotNil(t, pkg.SeriesName)
|
||||
assert.Equal(t, series.SeriesName, *pkg.SeriesName)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("没有系列的套餐SeriesName为空", func(t *testing.T) {
|
||||
req := &dto.CreatePackageRequest{
|
||||
PackageCode: generateUniquePackageCode("PKG_NO_SERIES"),
|
||||
PackageName: "无系列套餐",
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
}
|
||||
|
||||
resp, err := svc.Create(ctx, req)
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, resp.SeriesID)
|
||||
assert.Nil(t, resp.SeriesName)
|
||||
})
|
||||
}
|
||||
238
internal/service/package/usage_service.go
Normal file
238
internal/service/package/usage_service.go
Normal file
@@ -0,0 +1,238 @@
|
||||
package packagepkg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// StopResumeCallback 任务 24.6: 停复机回调接口
|
||||
// 用于在流量用完时触发停机操作
|
||||
type StopResumeCallback interface {
|
||||
// CheckAndStopCard 检查流量耗尽并停机
|
||||
CheckAndStopCard(ctx context.Context, cardID uint) error
|
||||
}
|
||||
|
||||
type UsageService struct {
|
||||
db *gorm.DB
|
||||
redis *redis.Client
|
||||
packageUsageStore *postgres.PackageUsageStore
|
||||
packageUsageDailyRecord *postgres.PackageUsageDailyRecordStore
|
||||
logger *zap.Logger
|
||||
stopResumeCallback StopResumeCallback // 停复机回调,可选
|
||||
}
|
||||
|
||||
func NewUsageService(
|
||||
db *gorm.DB,
|
||||
redis *redis.Client,
|
||||
packageUsageStore *postgres.PackageUsageStore,
|
||||
packageUsageDailyRecord *postgres.PackageUsageDailyRecordStore,
|
||||
logger *zap.Logger,
|
||||
) *UsageService {
|
||||
return &UsageService{
|
||||
db: db,
|
||||
redis: redis,
|
||||
packageUsageStore: packageUsageStore,
|
||||
packageUsageDailyRecord: packageUsageDailyRecord,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// SetStopResumeCallback 任务 24.6: 设置停复机回调
|
||||
// 在应用启动时由 bootstrap 调用,注入停复机服务
|
||||
func (s *UsageService) SetStopResumeCallback(callback StopResumeCallback) {
|
||||
s.stopResumeCallback = callback
|
||||
}
|
||||
|
||||
// DeductDataUsage 任务 10.2-10.6: 按优先级扣减流量
|
||||
// 扣减顺序:加油包(按 priority ASC) → 主套餐
|
||||
// 流量用完时自动标记 status=2,所有套餐用完时触发停机
|
||||
func (s *UsageService) DeductDataUsage(ctx context.Context, carrierType string, carrierID uint, usageMB int64) error {
|
||||
if usageMB <= 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "扣减流量必须大于0")
|
||||
}
|
||||
|
||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// 查询所有生效中的套餐(按优先级排序)
|
||||
var packages []*model.PackageUsage
|
||||
query := tx.Where("status = ?", constants.PackageUsageStatusActive)
|
||||
|
||||
if carrierType == "iot_card" {
|
||||
query = query.Where("iot_card_id = ?", carrierID)
|
||||
} else if carrierType == "device" {
|
||||
query = query.Where("device_id = ?", carrierID)
|
||||
} else {
|
||||
return errors.New(errors.CodeInvalidParam, "无效的载体类型")
|
||||
}
|
||||
|
||||
// 加油包按 priority ASC 排序,主套餐在后
|
||||
if err := query.Order("CASE WHEN master_usage_id IS NOT NULL THEN 0 ELSE 1 END, priority ASC").
|
||||
Find(&packages).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询生效套餐失败")
|
||||
}
|
||||
|
||||
if len(packages) == 0 {
|
||||
return errors.New(errors.CodeNoAvailablePackage, "没有可用套餐")
|
||||
}
|
||||
|
||||
// 按优先级扣减流量
|
||||
remainingUsage := usageMB
|
||||
today := time.Now().Format("2006-01-02")
|
||||
|
||||
for _, pkg := range packages {
|
||||
if remainingUsage <= 0 {
|
||||
break
|
||||
}
|
||||
|
||||
// 计算当前套餐剩余额度
|
||||
remainingQuota := pkg.DataLimitMB - pkg.DataUsageMB
|
||||
if remainingQuota <= 0 {
|
||||
// 套餐已用完,标记为已用完
|
||||
if err := tx.Model(pkg).Update("status", constants.PackageUsageStatusDepleted).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "更新套餐状态失败")
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// 本次从该套餐扣减的流量
|
||||
var deductFromPkg int64
|
||||
if remainingUsage <= remainingQuota {
|
||||
deductFromPkg = remainingUsage
|
||||
} else {
|
||||
deductFromPkg = remainingQuota
|
||||
}
|
||||
|
||||
// 更新套餐使用量
|
||||
newUsage := pkg.DataUsageMB + deductFromPkg
|
||||
updates := map[string]interface{}{
|
||||
"data_usage_mb": newUsage,
|
||||
}
|
||||
|
||||
// 检查是否用完
|
||||
if newUsage >= pkg.DataLimitMB {
|
||||
updates["status"] = constants.PackageUsageStatusDepleted
|
||||
}
|
||||
|
||||
if err := tx.Model(pkg).Updates(updates).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "更新套餐使用量失败")
|
||||
}
|
||||
|
||||
// 任务 10.6: 写入日记录
|
||||
if err := s.updateDailyRecord(ctx, tx, pkg.ID, today, deductFromPkg, newUsage); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
remainingUsage -= deductFromPkg
|
||||
|
||||
s.logger.Info("扣减套餐流量",
|
||||
zap.Uint("usage_id", pkg.ID),
|
||||
zap.Int64("deduct_mb", deductFromPkg),
|
||||
zap.Int64("new_usage_mb", newUsage),
|
||||
zap.Int64("data_limit_mb", pkg.DataLimitMB))
|
||||
}
|
||||
|
||||
// 如果流量扣减未完成,说明所有套餐都不够
|
||||
if remainingUsage > 0 {
|
||||
s.logger.Warn("流量不足",
|
||||
zap.String("carrier_type", carrierType),
|
||||
zap.Uint("carrier_id", carrierID),
|
||||
zap.Int64("requested_mb", usageMB),
|
||||
zap.Int64("remaining_mb", remainingUsage))
|
||||
return errors.New(errors.CodeInsufficientQuota, "流量不足")
|
||||
}
|
||||
|
||||
// 任务 10.5: 检查是否所有套餐都用完(触发停机)
|
||||
if err := s.checkAndTriggerSuspension(ctx, tx, carrierType, carrierID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// updateDailyRecord 任务 10.6: 更新日流量记录
|
||||
func (s *UsageService) updateDailyRecord(ctx context.Context, tx *gorm.DB, packageUsageID uint, dateStr string, dailyUsageMB, cumulativeUsageMB int64) error {
|
||||
// 解析日期字符串
|
||||
date, err := time.Parse("2006-01-02", dateStr)
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeInvalidParam, err, "日期格式错误")
|
||||
}
|
||||
|
||||
// 查询是否已有今日记录
|
||||
var record model.PackageUsageDailyRecord
|
||||
err = tx.Where("package_usage_id = ? AND date = ?", packageUsageID, date).
|
||||
First(&record).Error
|
||||
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
// 创建新记录
|
||||
record = model.PackageUsageDailyRecord{
|
||||
PackageUsageID: packageUsageID,
|
||||
Date: date,
|
||||
DailyUsageMB: int(dailyUsageMB),
|
||||
CumulativeUsageMB: cumulativeUsageMB,
|
||||
}
|
||||
if err := tx.Create(&record).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建日流量记录失败")
|
||||
}
|
||||
} else if err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询日流量记录失败")
|
||||
} else {
|
||||
// 更新现有记录
|
||||
updates := map[string]interface{}{
|
||||
"daily_usage_mb": record.DailyUsageMB + int(dailyUsageMB),
|
||||
"cumulative_usage_mb": cumulativeUsageMB,
|
||||
}
|
||||
if err := tx.Model(&record).Updates(updates).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "更新日流量记录失败")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkAndTriggerSuspension 任务 10.5: 检查停机条件
|
||||
func (s *UsageService) checkAndTriggerSuspension(ctx context.Context, tx *gorm.DB, carrierType string, carrierID uint) error {
|
||||
// 查询是否还有生效中的套餐
|
||||
var activeCount int64
|
||||
query := tx.Model(&model.PackageUsage{}).
|
||||
Where("status = ?", constants.PackageUsageStatusActive)
|
||||
|
||||
if carrierType == "iot_card" {
|
||||
query = query.Where("iot_card_id = ?", carrierID)
|
||||
} else if carrierType == "device" {
|
||||
query = query.Where("device_id = ?", carrierID)
|
||||
}
|
||||
|
||||
if err := query.Count(&activeCount).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询生效套餐数量失败")
|
||||
}
|
||||
|
||||
// 如果没有生效中的套餐,触发停机操作
|
||||
if activeCount == 0 {
|
||||
s.logger.Warn("所有套餐已用完,触发停机",
|
||||
zap.String("carrier_type", carrierType),
|
||||
zap.Uint("carrier_id", carrierID))
|
||||
|
||||
// 任务 24.6: 调用停复机服务执行停机
|
||||
if s.stopResumeCallback != nil && carrierType == "iot_card" {
|
||||
// 在事务外异步执行停机,避免长事务
|
||||
go func() {
|
||||
stopCtx := context.Background()
|
||||
if err := s.stopResumeCallback.CheckAndStopCard(stopCtx, carrierID); err != nil {
|
||||
s.logger.Error("调用停机服务失败",
|
||||
zap.Uint("card_id", carrierID),
|
||||
zap.Error(err))
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
112
internal/service/package/utils.go
Normal file
112
internal/service/package/utils.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package packagepkg
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
// CalculateExpiryTime 计算套餐过期时间
|
||||
// calendarType: 套餐周期类型(natural_month=自然月,by_day=按天)
|
||||
// activatedAt: 激活时间
|
||||
// durationMonths: 套餐时长(月数,calendar_type=natural_month 时使用)
|
||||
// durationDays: 套餐天数(calendar_type=by_day 时使用)
|
||||
// 返回:过期时间(当天 23:59:59)
|
||||
func CalculateExpiryTime(calendarType string, activatedAt time.Time, durationMonths, durationDays int) time.Time {
|
||||
var expiryDate time.Time
|
||||
|
||||
if calendarType == constants.PackageCalendarTypeNaturalMonth {
|
||||
// 自然月套餐:activated_at 月份 + N 个月,月末 23:59:59
|
||||
// 计算目标年月
|
||||
targetYear := activatedAt.Year()
|
||||
targetMonth := activatedAt.Month() + time.Month(durationMonths)
|
||||
|
||||
// 处理月份溢出
|
||||
for targetMonth > 12 {
|
||||
targetMonth -= 12
|
||||
targetYear++
|
||||
}
|
||||
|
||||
// 获取目标月份的最后一天(下个月的第0天就是本月最后一天)
|
||||
expiryDate = time.Date(targetYear, targetMonth+1, 0, 23, 59, 59, 0, activatedAt.Location())
|
||||
} else {
|
||||
// 按天套餐:activated_at + N 天,23:59:59
|
||||
expiryDate = activatedAt.AddDate(0, 0, durationDays)
|
||||
expiryDate = time.Date(expiryDate.Year(), expiryDate.Month(), expiryDate.Day(), 23, 59, 59, 0, expiryDate.Location())
|
||||
}
|
||||
|
||||
return expiryDate
|
||||
}
|
||||
|
||||
// CalculateNextResetTime 计算下次流量重置时间
|
||||
// dataResetCycle: 流量重置周期(daily/monthly/yearly/none)
|
||||
// currentTime: 当前时间
|
||||
// billingDay: 计费日(月重置时使用,联通=27,其他=1)
|
||||
// 返回:下次重置时间(00:00:00)
|
||||
func CalculateNextResetTime(dataResetCycle string, currentTime time.Time, billingDay int) *time.Time {
|
||||
if dataResetCycle == constants.PackageDataResetNone {
|
||||
// 不重置
|
||||
return nil
|
||||
}
|
||||
|
||||
var nextResetTime time.Time
|
||||
|
||||
switch dataResetCycle {
|
||||
case constants.PackageDataResetDaily:
|
||||
// 日重置:明天 00:00:00
|
||||
nextResetTime = time.Date(
|
||||
currentTime.Year(),
|
||||
currentTime.Month(),
|
||||
currentTime.Day()+1,
|
||||
0, 0, 0, 0,
|
||||
currentTime.Location(),
|
||||
)
|
||||
|
||||
case constants.PackageDataResetMonthly:
|
||||
// 月重置:下月 billingDay 号 00:00:00
|
||||
year := currentTime.Year()
|
||||
month := currentTime.Month()
|
||||
|
||||
// 检查 billingDay 是否为当前月的最后一天(月末计费的特殊情况)
|
||||
currentMonthLastDay := time.Date(year, month+1, 0, 0, 0, 0, 0, currentTime.Location()).Day()
|
||||
isBillingDayMonthEnd := billingDay >= currentMonthLastDay
|
||||
|
||||
// 如果当前日期 >= billingDay,则重置时间为下个月的 billingDay
|
||||
// 否则,重置时间为本月的 billingDay
|
||||
// 特殊情况:如果 billingDay 是月末,并且当前日期已接近月末,则跳到下个月
|
||||
shouldUseNextMonth := currentTime.Day() >= billingDay || (isBillingDayMonthEnd && currentTime.Day() >= currentMonthLastDay-1)
|
||||
|
||||
if shouldUseNextMonth {
|
||||
// 下个月
|
||||
month++
|
||||
if month > 12 {
|
||||
month = 1
|
||||
year++
|
||||
}
|
||||
}
|
||||
|
||||
// 计算目标月份的最后一天(处理月末情况)
|
||||
lastDayOfMonth := time.Date(year, month+1, 0, 0, 0, 0, 0, currentTime.Location()).Day()
|
||||
resetDay := billingDay
|
||||
if billingDay > lastDayOfMonth {
|
||||
// 如果 billingDay 超过该月天数,使用月末
|
||||
resetDay = lastDayOfMonth
|
||||
}
|
||||
|
||||
nextResetTime = time.Date(year, month, resetDay, 0, 0, 0, 0, currentTime.Location())
|
||||
|
||||
case constants.PackageDataResetYearly:
|
||||
// 年重置:明年 1 月 1 日 00:00:00
|
||||
nextResetTime = time.Date(
|
||||
currentTime.Year()+1,
|
||||
1, 1,
|
||||
0, 0, 0, 0,
|
||||
currentTime.Location(),
|
||||
)
|
||||
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
return &nextResetTime
|
||||
}
|
||||
@@ -1,313 +0,0 @@
|
||||
package package_series
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
"github.com/break/junhong_cmp_fiber/tests/testutils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPackageSeriesService_Create(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
store := postgres.NewPackageSeriesStore(tx)
|
||||
svc := New(store)
|
||||
|
||||
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
|
||||
UserID: 1,
|
||||
UserType: constants.UserTypePlatform,
|
||||
})
|
||||
|
||||
t.Run("创建成功", func(t *testing.T) {
|
||||
seriesCode := fmt.Sprintf("SVC_CREATE_%d", time.Now().UnixNano())
|
||||
req := &dto.CreatePackageSeriesRequest{
|
||||
SeriesCode: seriesCode,
|
||||
SeriesName: "测试套餐系列",
|
||||
Description: "服务层测试",
|
||||
}
|
||||
|
||||
resp, err := svc.Create(ctx, req)
|
||||
require.NoError(t, err)
|
||||
assert.NotZero(t, resp.ID)
|
||||
assert.Equal(t, req.SeriesCode, resp.SeriesCode)
|
||||
assert.Equal(t, req.SeriesName, resp.SeriesName)
|
||||
assert.Equal(t, constants.StatusEnabled, resp.Status)
|
||||
})
|
||||
|
||||
t.Run("编码重复失败", func(t *testing.T) {
|
||||
seriesCode := fmt.Sprintf("SVC_DUP_%d", time.Now().UnixNano())
|
||||
req1 := &dto.CreatePackageSeriesRequest{
|
||||
SeriesCode: seriesCode,
|
||||
SeriesName: "第一个系列",
|
||||
Description: "测试重复",
|
||||
}
|
||||
|
||||
_, err := svc.Create(ctx, req1)
|
||||
require.NoError(t, err)
|
||||
|
||||
req2 := &dto.CreatePackageSeriesRequest{
|
||||
SeriesCode: seriesCode,
|
||||
SeriesName: "第二个系列",
|
||||
Description: "重复编码",
|
||||
}
|
||||
|
||||
_, err = svc.Create(ctx, req2)
|
||||
require.Error(t, err)
|
||||
appErr, ok := err.(*errors.AppError)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, errors.CodeConflict, appErr.Code)
|
||||
})
|
||||
|
||||
t.Run("未授权失败", func(t *testing.T) {
|
||||
req := &dto.CreatePackageSeriesRequest{
|
||||
SeriesCode: fmt.Sprintf("SVC_UNAUTH_%d", time.Now().UnixNano()),
|
||||
SeriesName: "未授权测试",
|
||||
Description: "无用户上下文",
|
||||
}
|
||||
|
||||
_, err := svc.Create(context.Background(), req)
|
||||
require.Error(t, err)
|
||||
appErr, ok := err.(*errors.AppError)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, errors.CodeUnauthorized, appErr.Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPackageSeriesService_Get(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
store := postgres.NewPackageSeriesStore(tx)
|
||||
svc := New(store)
|
||||
|
||||
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
|
||||
UserID: 1,
|
||||
UserType: constants.UserTypePlatform,
|
||||
})
|
||||
|
||||
seriesCode := fmt.Sprintf("SVC_GET_%d", time.Now().UnixNano())
|
||||
req := &dto.CreatePackageSeriesRequest{
|
||||
SeriesCode: seriesCode,
|
||||
SeriesName: "查询测试",
|
||||
Description: "用于查询测试",
|
||||
}
|
||||
created, err := svc.Create(ctx, req)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("获取存在的系列", func(t *testing.T) {
|
||||
resp, err := svc.Get(ctx, created.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, created.SeriesCode, resp.SeriesCode)
|
||||
assert.Equal(t, created.SeriesName, resp.SeriesName)
|
||||
})
|
||||
|
||||
t.Run("获取不存在的系列", func(t *testing.T) {
|
||||
_, err := svc.Get(ctx, 99999)
|
||||
require.Error(t, err)
|
||||
appErr, ok := err.(*errors.AppError)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, errors.CodeNotFound, appErr.Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPackageSeriesService_Update(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
store := postgres.NewPackageSeriesStore(tx)
|
||||
svc := New(store)
|
||||
|
||||
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
|
||||
UserID: 1,
|
||||
UserType: constants.UserTypePlatform,
|
||||
})
|
||||
|
||||
seriesCode := fmt.Sprintf("SVC_UPD_%d", time.Now().UnixNano())
|
||||
req := &dto.CreatePackageSeriesRequest{
|
||||
SeriesCode: seriesCode,
|
||||
SeriesName: "更新测试",
|
||||
Description: "原始描述",
|
||||
}
|
||||
created, err := svc.Create(ctx, req)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("更新成功", func(t *testing.T) {
|
||||
newName := "更新后的名称"
|
||||
newDesc := "更新后的描述"
|
||||
updateReq := &dto.UpdatePackageSeriesRequest{
|
||||
SeriesName: &newName,
|
||||
Description: &newDesc,
|
||||
}
|
||||
|
||||
resp, err := svc.Update(ctx, created.ID, updateReq)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, newName, resp.SeriesName)
|
||||
assert.Equal(t, newDesc, resp.Description)
|
||||
})
|
||||
|
||||
t.Run("更新不存在的系列", func(t *testing.T) {
|
||||
newName := "test"
|
||||
updateReq := &dto.UpdatePackageSeriesRequest{
|
||||
SeriesName: &newName,
|
||||
}
|
||||
|
||||
_, err := svc.Update(ctx, 99999, updateReq)
|
||||
require.Error(t, err)
|
||||
appErr, ok := err.(*errors.AppError)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, errors.CodeNotFound, appErr.Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPackageSeriesService_Delete(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
store := postgres.NewPackageSeriesStore(tx)
|
||||
svc := New(store)
|
||||
|
||||
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
|
||||
UserID: 1,
|
||||
UserType: constants.UserTypePlatform,
|
||||
})
|
||||
|
||||
seriesCode := fmt.Sprintf("SVC_DEL_%d", time.Now().UnixNano())
|
||||
req := &dto.CreatePackageSeriesRequest{
|
||||
SeriesCode: seriesCode,
|
||||
SeriesName: "删除测试",
|
||||
Description: "用于删除测试",
|
||||
}
|
||||
created, err := svc.Create(ctx, req)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("删除成功", func(t *testing.T) {
|
||||
err := svc.Delete(ctx, created.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = svc.Get(ctx, created.ID)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("删除不存在的系列", func(t *testing.T) {
|
||||
err := svc.Delete(ctx, 99999)
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPackageSeriesService_List(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
store := postgres.NewPackageSeriesStore(tx)
|
||||
svc := New(store)
|
||||
|
||||
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
|
||||
UserID: 1,
|
||||
UserType: constants.UserTypePlatform,
|
||||
})
|
||||
|
||||
seriesList := []dto.CreatePackageSeriesRequest{
|
||||
{
|
||||
SeriesCode: fmt.Sprintf("SVC_LIST_001_%d", time.Now().UnixNano()),
|
||||
SeriesName: "基础套餐",
|
||||
Description: "列表测试1",
|
||||
},
|
||||
{
|
||||
SeriesCode: fmt.Sprintf("SVC_LIST_002_%d", time.Now().UnixNano()),
|
||||
SeriesName: "高级套餐",
|
||||
Description: "列表测试2",
|
||||
},
|
||||
{
|
||||
SeriesCode: fmt.Sprintf("SVC_LIST_003_%d", time.Now().UnixNano()),
|
||||
SeriesName: "企业套餐",
|
||||
Description: "列表测试3",
|
||||
},
|
||||
}
|
||||
for _, s := range seriesList {
|
||||
_, err := svc.Create(ctx, &s)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
t.Run("查询列表", func(t *testing.T) {
|
||||
req := &dto.PackageSeriesListRequest{
|
||||
Page: 1,
|
||||
PageSize: 20,
|
||||
}
|
||||
result, total, err := svc.List(ctx, req)
|
||||
require.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, total, int64(3))
|
||||
assert.GreaterOrEqual(t, len(result), 3)
|
||||
})
|
||||
|
||||
t.Run("按状态过滤", func(t *testing.T) {
|
||||
status := constants.StatusEnabled
|
||||
req := &dto.PackageSeriesListRequest{
|
||||
Page: 1,
|
||||
PageSize: 20,
|
||||
Status: &status,
|
||||
}
|
||||
result, total, err := svc.List(ctx, req)
|
||||
require.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, total, int64(3))
|
||||
for _, s := range result {
|
||||
assert.Equal(t, constants.StatusEnabled, s.Status)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("按名称模糊搜索", func(t *testing.T) {
|
||||
seriesName := "高级"
|
||||
req := &dto.PackageSeriesListRequest{
|
||||
Page: 1,
|
||||
PageSize: 20,
|
||||
SeriesName: &seriesName,
|
||||
}
|
||||
result, total, err := svc.List(ctx, req)
|
||||
require.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, total, int64(1))
|
||||
assert.GreaterOrEqual(t, len(result), 1)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPackageSeriesService_UpdateStatus(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
store := postgres.NewPackageSeriesStore(tx)
|
||||
svc := New(store)
|
||||
|
||||
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
|
||||
UserID: 1,
|
||||
UserType: constants.UserTypePlatform,
|
||||
})
|
||||
|
||||
seriesCode := fmt.Sprintf("SVC_STATUS_%d", time.Now().UnixNano())
|
||||
req := &dto.CreatePackageSeriesRequest{
|
||||
SeriesCode: seriesCode,
|
||||
SeriesName: "状态测试",
|
||||
Description: "用于状态更新测试",
|
||||
}
|
||||
created, err := svc.Create(ctx, req)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, constants.StatusEnabled, created.Status)
|
||||
|
||||
t.Run("禁用系列", func(t *testing.T) {
|
||||
err := svc.UpdateStatus(ctx, created.ID, constants.StatusDisabled)
|
||||
require.NoError(t, err)
|
||||
|
||||
updated, err := svc.Get(ctx, created.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, constants.StatusDisabled, updated.Status)
|
||||
})
|
||||
|
||||
t.Run("启用系列", func(t *testing.T) {
|
||||
err := svc.UpdateStatus(ctx, created.ID, constants.StatusEnabled)
|
||||
require.NoError(t, err)
|
||||
|
||||
updated, err := svc.Get(ctx, created.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, constants.StatusEnabled, updated.Status)
|
||||
})
|
||||
|
||||
t.Run("更新不存在的系列状态", func(t *testing.T) {
|
||||
err := svc.UpdateStatus(ctx, 99999, constants.StatusDisabled)
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
@@ -1,243 +0,0 @@
|
||||
package shop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
"github.com/break/junhong_cmp_fiber/tests/testutils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAssignRolesToShop(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
testutils.CleanTestRedisKeys(t, rdb)
|
||||
|
||||
shopStore := postgres.NewShopStore(tx, rdb)
|
||||
accountStore := postgres.NewAccountStore(tx, rdb)
|
||||
shopRoleStore := postgres.NewShopRoleStore(tx, rdb)
|
||||
roleStore := postgres.NewRoleStore(tx)
|
||||
|
||||
service := New(shopStore, accountStore, shopRoleStore, roleStore)
|
||||
|
||||
shop := &model.Shop{
|
||||
ShopName: "测试店铺",
|
||||
ShopCode: "TEST_SHOP_001",
|
||||
Level: 1,
|
||||
Status: constants.StatusEnabled,
|
||||
}
|
||||
require.NoError(t, tx.Create(shop).Error)
|
||||
|
||||
role := &model.Role{
|
||||
RoleName: "代理店长",
|
||||
RoleType: constants.RoleTypeCustomer,
|
||||
Status: constants.StatusEnabled,
|
||||
}
|
||||
require.NoError(t, roleStore.Create(context.Background(), role))
|
||||
|
||||
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
|
||||
UserID: 1,
|
||||
UserType: constants.UserTypeSuperAdmin,
|
||||
})
|
||||
|
||||
t.Run("成功分配单个角色", func(t *testing.T) {
|
||||
result, err := service.AssignRolesToShop(ctx, shop.ID, []uint{role.ID})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, result, 1)
|
||||
assert.Equal(t, shop.ID, result[0].ShopID)
|
||||
assert.Equal(t, role.ID, result[0].RoleID)
|
||||
})
|
||||
|
||||
t.Run("清空所有角色", func(t *testing.T) {
|
||||
result, err := service.AssignRolesToShop(ctx, shop.ID, []uint{})
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, result)
|
||||
|
||||
roles, err := service.GetShopRoles(ctx, shop.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, roles.Roles)
|
||||
})
|
||||
|
||||
t.Run("替换现有角色", func(t *testing.T) {
|
||||
require.NoError(t, shopRoleStore.Create(ctx, &model.ShopRole{
|
||||
ShopID: shop.ID,
|
||||
RoleID: role.ID,
|
||||
Status: constants.StatusEnabled,
|
||||
Creator: 1,
|
||||
Updater: 1,
|
||||
}))
|
||||
|
||||
newRole := &model.Role{
|
||||
RoleName: "代理经理",
|
||||
RoleType: constants.RoleTypeCustomer,
|
||||
Status: constants.StatusEnabled,
|
||||
}
|
||||
require.NoError(t, roleStore.Create(ctx, newRole))
|
||||
|
||||
result, err := service.AssignRolesToShop(ctx, shop.ID, []uint{newRole.ID})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, result, 1)
|
||||
assert.Equal(t, newRole.ID, result[0].RoleID)
|
||||
})
|
||||
|
||||
t.Run("角色类型校验失败", func(t *testing.T) {
|
||||
platformRole := &model.Role{
|
||||
RoleName: "平台角色",
|
||||
RoleType: constants.RoleTypePlatform,
|
||||
Status: constants.StatusEnabled,
|
||||
}
|
||||
require.NoError(t, roleStore.Create(ctx, platformRole))
|
||||
|
||||
_, err := service.AssignRolesToShop(ctx, shop.ID, []uint{platformRole.ID})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "店铺只能分配客户角色")
|
||||
})
|
||||
|
||||
t.Run("角色不存在", func(t *testing.T) {
|
||||
_, err := service.AssignRolesToShop(ctx, shop.ID, []uint{99999})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "部分角色不存在")
|
||||
})
|
||||
|
||||
t.Run("店铺不存在", func(t *testing.T) {
|
||||
_, err := service.AssignRolesToShop(ctx, 99999, []uint{role.ID})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "店铺不存在")
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetShopRoles(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
testutils.CleanTestRedisKeys(t, rdb)
|
||||
|
||||
shopStore := postgres.NewShopStore(tx, rdb)
|
||||
accountStore := postgres.NewAccountStore(tx, rdb)
|
||||
shopRoleStore := postgres.NewShopRoleStore(tx, rdb)
|
||||
roleStore := postgres.NewRoleStore(tx)
|
||||
|
||||
service := New(shopStore, accountStore, shopRoleStore, roleStore)
|
||||
|
||||
shop := &model.Shop{
|
||||
ShopName: "测试店铺2",
|
||||
ShopCode: "TEST_SHOP_002",
|
||||
Level: 1,
|
||||
Status: constants.StatusEnabled,
|
||||
}
|
||||
require.NoError(t, tx.Create(shop).Error)
|
||||
|
||||
role := &model.Role{
|
||||
RoleName: "代理店长",
|
||||
RoleType: constants.RoleTypeCustomer,
|
||||
Status: constants.StatusEnabled,
|
||||
}
|
||||
require.NoError(t, roleStore.Create(context.Background(), role))
|
||||
|
||||
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
|
||||
UserID: 1,
|
||||
UserType: constants.UserTypeSuperAdmin,
|
||||
})
|
||||
|
||||
t.Run("查询已分配角色", func(t *testing.T) {
|
||||
require.NoError(t, shopRoleStore.Create(ctx, &model.ShopRole{
|
||||
ShopID: shop.ID,
|
||||
RoleID: role.ID,
|
||||
Status: constants.StatusEnabled,
|
||||
Creator: 1,
|
||||
Updater: 1,
|
||||
}))
|
||||
|
||||
result, err := service.GetShopRoles(ctx, shop.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, result.Roles, 1)
|
||||
assert.Equal(t, shop.ID, result.ShopID)
|
||||
assert.Equal(t, role.ID, result.Roles[0].RoleID)
|
||||
assert.Equal(t, "代理店长", result.Roles[0].RoleName)
|
||||
})
|
||||
|
||||
t.Run("查询未分配角色的店铺", func(t *testing.T) {
|
||||
emptyShop := &model.Shop{
|
||||
ShopName: "空店铺",
|
||||
ShopCode: "EMPTY_SHOP",
|
||||
Level: 1,
|
||||
Status: constants.StatusEnabled,
|
||||
}
|
||||
require.NoError(t, tx.Create(emptyShop).Error)
|
||||
|
||||
result, err := service.GetShopRoles(ctx, emptyShop.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, result.Roles)
|
||||
})
|
||||
|
||||
t.Run("店铺不存在", func(t *testing.T) {
|
||||
_, err := service.GetShopRoles(ctx, 99999)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "店铺不存在")
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeleteShopRole(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
testutils.CleanTestRedisKeys(t, rdb)
|
||||
|
||||
shopStore := postgres.NewShopStore(tx, rdb)
|
||||
accountStore := postgres.NewAccountStore(tx, rdb)
|
||||
shopRoleStore := postgres.NewShopRoleStore(tx, rdb)
|
||||
roleStore := postgres.NewRoleStore(tx)
|
||||
|
||||
service := New(shopStore, accountStore, shopRoleStore, roleStore)
|
||||
|
||||
shop := &model.Shop{
|
||||
ShopName: "测试店铺3",
|
||||
ShopCode: "TEST_SHOP_003",
|
||||
Level: 1,
|
||||
Status: constants.StatusEnabled,
|
||||
}
|
||||
require.NoError(t, tx.Create(shop).Error)
|
||||
|
||||
role := &model.Role{
|
||||
RoleName: "代理店长",
|
||||
RoleType: constants.RoleTypeCustomer,
|
||||
Status: constants.StatusEnabled,
|
||||
}
|
||||
require.NoError(t, roleStore.Create(context.Background(), role))
|
||||
|
||||
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
|
||||
UserID: 1,
|
||||
UserType: constants.UserTypeSuperAdmin,
|
||||
})
|
||||
|
||||
t.Run("成功删除角色", func(t *testing.T) {
|
||||
require.NoError(t, shopRoleStore.Create(ctx, &model.ShopRole{
|
||||
ShopID: shop.ID,
|
||||
RoleID: role.ID,
|
||||
Status: constants.StatusEnabled,
|
||||
Creator: 1,
|
||||
Updater: 1,
|
||||
}))
|
||||
|
||||
err := service.DeleteShopRole(ctx, shop.ID, role.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := service.GetShopRoles(ctx, shop.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, result.Roles)
|
||||
})
|
||||
|
||||
t.Run("删除不存在的角色关联(幂等)", func(t *testing.T) {
|
||||
err := service.DeleteShopRole(ctx, shop.ID, role.ID)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("店铺不存在", func(t *testing.T) {
|
||||
err := service.DeleteShopRole(ctx, 99999, role.ID)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "店铺不存在")
|
||||
})
|
||||
}
|
||||
@@ -1,232 +0,0 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/tests/testutils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAssetAllocationRecordStore_Create(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
testutils.CleanTestRedisKeys(t, rdb)
|
||||
|
||||
s := NewAssetAllocationRecordStore(tx, rdb)
|
||||
ctx := context.Background()
|
||||
|
||||
record := &model.AssetAllocationRecord{
|
||||
AllocationNo: "AL20260124100001",
|
||||
AllocationType: constants.AssetAllocationTypeAllocate,
|
||||
AssetType: constants.AssetTypeIotCard,
|
||||
AssetID: 1,
|
||||
AssetIdentifier: "89860012345678901234",
|
||||
FromOwnerType: constants.OwnerTypePlatform,
|
||||
ToOwnerType: constants.OwnerTypeShop,
|
||||
ToOwnerID: 10,
|
||||
OperatorID: 1,
|
||||
}
|
||||
|
||||
err := s.Create(ctx, record)
|
||||
require.NoError(t, err)
|
||||
assert.NotZero(t, record.ID)
|
||||
}
|
||||
|
||||
func TestAssetAllocationRecordStore_BatchCreate(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
testutils.CleanTestRedisKeys(t, rdb)
|
||||
|
||||
s := NewAssetAllocationRecordStore(tx, rdb)
|
||||
ctx := context.Background()
|
||||
|
||||
records := []*model.AssetAllocationRecord{
|
||||
{
|
||||
AllocationNo: "AL20260124100010",
|
||||
AllocationType: constants.AssetAllocationTypeAllocate,
|
||||
AssetType: constants.AssetTypeIotCard,
|
||||
AssetID: 1,
|
||||
AssetIdentifier: "89860012345678901001",
|
||||
FromOwnerType: constants.OwnerTypePlatform,
|
||||
ToOwnerType: constants.OwnerTypeShop,
|
||||
ToOwnerID: 10,
|
||||
OperatorID: 1,
|
||||
},
|
||||
{
|
||||
AllocationNo: "AL20260124100011",
|
||||
AllocationType: constants.AssetAllocationTypeAllocate,
|
||||
AssetType: constants.AssetTypeIotCard,
|
||||
AssetID: 2,
|
||||
AssetIdentifier: "89860012345678901002",
|
||||
FromOwnerType: constants.OwnerTypePlatform,
|
||||
ToOwnerType: constants.OwnerTypeShop,
|
||||
ToOwnerID: 10,
|
||||
OperatorID: 1,
|
||||
},
|
||||
}
|
||||
|
||||
err := s.BatchCreate(ctx, records)
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, record := range records {
|
||||
assert.NotZero(t, record.ID)
|
||||
}
|
||||
|
||||
t.Run("空列表不报错", func(t *testing.T) {
|
||||
err := s.BatchCreate(ctx, []*model.AssetAllocationRecord{})
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAssetAllocationRecordStore_GetByID(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
testutils.CleanTestRedisKeys(t, rdb)
|
||||
|
||||
s := NewAssetAllocationRecordStore(tx, rdb)
|
||||
ctx := context.Background()
|
||||
|
||||
record := &model.AssetAllocationRecord{
|
||||
AllocationNo: "AL20260124100003",
|
||||
AllocationType: constants.AssetAllocationTypeAllocate,
|
||||
AssetType: constants.AssetTypeIotCard,
|
||||
AssetID: 1,
|
||||
AssetIdentifier: "89860012345678903001",
|
||||
FromOwnerType: constants.OwnerTypePlatform,
|
||||
ToOwnerType: constants.OwnerTypeShop,
|
||||
ToOwnerID: 10,
|
||||
OperatorID: 1,
|
||||
Remark: "测试备注",
|
||||
}
|
||||
require.NoError(t, s.Create(ctx, record))
|
||||
|
||||
result, err := s.GetByID(ctx, record.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, record.AllocationNo, result.AllocationNo)
|
||||
assert.Equal(t, record.AssetIdentifier, result.AssetIdentifier)
|
||||
assert.Equal(t, "测试备注", result.Remark)
|
||||
}
|
||||
|
||||
func TestAssetAllocationRecordStore_List(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
testutils.CleanTestRedisKeys(t, rdb)
|
||||
|
||||
s := NewAssetAllocationRecordStore(tx, rdb)
|
||||
ctx := context.Background()
|
||||
|
||||
shopID := uint(100)
|
||||
records := []*model.AssetAllocationRecord{
|
||||
{
|
||||
AllocationNo: "AL20260124100004",
|
||||
AllocationType: constants.AssetAllocationTypeAllocate,
|
||||
AssetType: constants.AssetTypeIotCard,
|
||||
AssetID: 1,
|
||||
AssetIdentifier: "89860012345678904001",
|
||||
FromOwnerType: constants.OwnerTypePlatform,
|
||||
ToOwnerType: constants.OwnerTypeShop,
|
||||
ToOwnerID: shopID,
|
||||
OperatorID: 1,
|
||||
},
|
||||
{
|
||||
AllocationNo: "AL20260124100005",
|
||||
AllocationType: constants.AssetAllocationTypeAllocate,
|
||||
AssetType: constants.AssetTypeIotCard,
|
||||
AssetID: 2,
|
||||
AssetIdentifier: "89860012345678904002",
|
||||
FromOwnerType: constants.OwnerTypeShop,
|
||||
FromOwnerID: &shopID,
|
||||
ToOwnerType: constants.OwnerTypeShop,
|
||||
ToOwnerID: 200,
|
||||
OperatorID: 2,
|
||||
},
|
||||
{
|
||||
AllocationNo: "RC20260124100001",
|
||||
AllocationType: constants.AssetAllocationTypeRecall,
|
||||
AssetType: constants.AssetTypeIotCard,
|
||||
AssetID: 3,
|
||||
AssetIdentifier: "89860012345678904003",
|
||||
FromOwnerType: constants.OwnerTypeShop,
|
||||
FromOwnerID: &shopID,
|
||||
ToOwnerType: constants.OwnerTypePlatform,
|
||||
ToOwnerID: 0,
|
||||
OperatorID: 1,
|
||||
},
|
||||
}
|
||||
require.NoError(t, s.BatchCreate(ctx, records))
|
||||
|
||||
t.Run("查询所有记录", func(t *testing.T) {
|
||||
result, total, err := s.List(ctx, &store.QueryOptions{Page: 1, PageSize: 20}, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(3), total)
|
||||
assert.Len(t, result, 3)
|
||||
})
|
||||
|
||||
t.Run("按分配类型过滤", func(t *testing.T) {
|
||||
filters := map[string]any{"allocation_type": constants.AssetAllocationTypeAllocate}
|
||||
result, total, err := s.List(ctx, nil, filters)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(2), total)
|
||||
for _, r := range result {
|
||||
assert.Equal(t, constants.AssetAllocationTypeAllocate, r.AllocationType)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("按分配单号过滤", func(t *testing.T) {
|
||||
filters := map[string]any{"allocation_no": "AL20260124100004"}
|
||||
result, total, err := s.List(ctx, nil, filters)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), total)
|
||||
assert.Equal(t, "AL20260124100004", result[0].AllocationNo)
|
||||
})
|
||||
|
||||
t.Run("按资产标识模糊查询", func(t *testing.T) {
|
||||
filters := map[string]any{"asset_identifier": "904002"}
|
||||
result, total, err := s.List(ctx, nil, filters)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), total)
|
||||
assert.Contains(t, result[0].AssetIdentifier, "904002")
|
||||
})
|
||||
|
||||
t.Run("按目标店铺过滤", func(t *testing.T) {
|
||||
filters := map[string]any{"to_shop_id": shopID}
|
||||
result, total, err := s.List(ctx, nil, filters)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), total)
|
||||
assert.Equal(t, shopID, result[0].ToOwnerID)
|
||||
})
|
||||
|
||||
t.Run("按操作人过滤", func(t *testing.T) {
|
||||
filters := map[string]any{"operator_id": uint(2)}
|
||||
result, total, err := s.List(ctx, nil, filters)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), total)
|
||||
assert.Equal(t, uint(2), result[0].OperatorID)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAssetAllocationRecordStore_GenerateAllocationNo(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
testutils.CleanTestRedisKeys(t, rdb)
|
||||
|
||||
s := NewAssetAllocationRecordStore(tx, rdb)
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("分配单号前缀为AL", func(t *testing.T) {
|
||||
no := s.GenerateAllocationNo(ctx, constants.AssetAllocationTypeAllocate)
|
||||
assert.True(t, len(no) > 0)
|
||||
assert.Equal(t, "AL", no[:2])
|
||||
})
|
||||
|
||||
t.Run("回收单号前缀为RC", func(t *testing.T) {
|
||||
no := s.GenerateAllocationNo(ctx, constants.AssetAllocationTypeRecall)
|
||||
assert.True(t, len(no) > 0)
|
||||
assert.Equal(t, "RC", no[:2])
|
||||
})
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/tests/testutils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCarrierStore_Create(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
s := NewCarrierStore(tx)
|
||||
ctx := context.Background()
|
||||
|
||||
carrier := &model.Carrier{
|
||||
CarrierCode: "CMCC_TEST_001",
|
||||
CarrierName: "中国移动测试",
|
||||
CarrierType: constants.CarrierTypeCMCC,
|
||||
Description: "测试运营商",
|
||||
Status: constants.StatusEnabled,
|
||||
}
|
||||
|
||||
err := s.Create(ctx, carrier)
|
||||
require.NoError(t, err)
|
||||
assert.NotZero(t, carrier.ID)
|
||||
}
|
||||
|
||||
func TestCarrierStore_GetByID(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
s := NewCarrierStore(tx)
|
||||
ctx := context.Background()
|
||||
|
||||
carrier := &model.Carrier{
|
||||
CarrierCode: "CUCC_TEST_001",
|
||||
CarrierName: "中国联通测试",
|
||||
CarrierType: constants.CarrierTypeCUCC,
|
||||
Status: constants.StatusEnabled,
|
||||
}
|
||||
require.NoError(t, s.Create(ctx, carrier))
|
||||
|
||||
t.Run("查询存在的运营商", func(t *testing.T) {
|
||||
result, err := s.GetByID(ctx, carrier.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, carrier.CarrierCode, result.CarrierCode)
|
||||
assert.Equal(t, carrier.CarrierName, result.CarrierName)
|
||||
})
|
||||
|
||||
t.Run("查询不存在的运营商", func(t *testing.T) {
|
||||
_, err := s.GetByID(ctx, 99999)
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCarrierStore_GetByCode(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
s := NewCarrierStore(tx)
|
||||
ctx := context.Background()
|
||||
|
||||
carrier := &model.Carrier{
|
||||
CarrierCode: "CTCC_TEST_001",
|
||||
CarrierName: "中国电信测试",
|
||||
CarrierType: constants.CarrierTypeCTCC,
|
||||
Status: constants.StatusEnabled,
|
||||
}
|
||||
require.NoError(t, s.Create(ctx, carrier))
|
||||
|
||||
t.Run("查询存在的编码", func(t *testing.T) {
|
||||
result, err := s.GetByCode(ctx, "CTCC_TEST_001")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, carrier.ID, result.ID)
|
||||
})
|
||||
|
||||
t.Run("查询不存在的编码", func(t *testing.T) {
|
||||
_, err := s.GetByCode(ctx, "NOT_EXISTS")
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCarrierStore_Update(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
s := NewCarrierStore(tx)
|
||||
ctx := context.Background()
|
||||
|
||||
carrier := &model.Carrier{
|
||||
CarrierCode: "CBN_TEST_001",
|
||||
CarrierName: "中国广电测试",
|
||||
CarrierType: constants.CarrierTypeCBN,
|
||||
Status: constants.StatusEnabled,
|
||||
}
|
||||
require.NoError(t, s.Create(ctx, carrier))
|
||||
|
||||
carrier.CarrierName = "中国广电测试-更新"
|
||||
carrier.Description = "更新后的描述"
|
||||
err := s.Update(ctx, carrier)
|
||||
require.NoError(t, err)
|
||||
|
||||
updated, err := s.GetByID(ctx, carrier.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "中国广电测试-更新", updated.CarrierName)
|
||||
assert.Equal(t, "更新后的描述", updated.Description)
|
||||
}
|
||||
|
||||
func TestCarrierStore_Delete(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
s := NewCarrierStore(tx)
|
||||
ctx := context.Background()
|
||||
|
||||
carrier := &model.Carrier{
|
||||
CarrierCode: "DEL_TEST_001",
|
||||
CarrierName: "待删除运营商",
|
||||
CarrierType: constants.CarrierTypeCMCC,
|
||||
Status: constants.StatusEnabled,
|
||||
}
|
||||
require.NoError(t, s.Create(ctx, carrier))
|
||||
|
||||
err := s.Delete(ctx, carrier.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = s.GetByID(ctx, carrier.ID)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestCarrierStore_List(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
s := NewCarrierStore(tx)
|
||||
ctx := context.Background()
|
||||
|
||||
carriers := []*model.Carrier{
|
||||
{CarrierCode: "LIST_001", CarrierName: "移动1", CarrierType: constants.CarrierTypeCMCC, Status: constants.StatusEnabled},
|
||||
{CarrierCode: "LIST_002", CarrierName: "联通1", CarrierType: constants.CarrierTypeCUCC, Status: constants.StatusEnabled},
|
||||
{CarrierCode: "LIST_003", CarrierName: "电信1", CarrierType: constants.CarrierTypeCTCC, Status: constants.StatusEnabled},
|
||||
}
|
||||
for _, c := range carriers {
|
||||
require.NoError(t, s.Create(ctx, c))
|
||||
}
|
||||
// 显式更新第三个 carrier 为禁用状态(GORM 不会写入零值)
|
||||
carriers[2].Status = constants.StatusDisabled
|
||||
require.NoError(t, s.Update(ctx, carriers[2]))
|
||||
|
||||
t.Run("查询所有运营商", func(t *testing.T) {
|
||||
result, total, err := s.List(ctx, &store.QueryOptions{Page: 1, PageSize: 20}, nil)
|
||||
require.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, total, int64(3))
|
||||
assert.GreaterOrEqual(t, len(result), 3)
|
||||
})
|
||||
|
||||
t.Run("按类型过滤", func(t *testing.T) {
|
||||
filters := map[string]interface{}{"carrier_type": constants.CarrierTypeCMCC}
|
||||
result, total, err := s.List(ctx, &store.QueryOptions{Page: 1, PageSize: 20}, filters)
|
||||
require.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, total, int64(1))
|
||||
for _, c := range result {
|
||||
assert.Equal(t, constants.CarrierTypeCMCC, c.CarrierType)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("按名称模糊搜索", func(t *testing.T) {
|
||||
filters := map[string]interface{}{"carrier_name": "联通"}
|
||||
result, total, err := s.List(ctx, &store.QueryOptions{Page: 1, PageSize: 20}, filters)
|
||||
require.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, total, int64(1))
|
||||
for _, c := range result {
|
||||
assert.Contains(t, c.CarrierName, "联通")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("按状态过滤-禁用", func(t *testing.T) {
|
||||
filters := map[string]interface{}{"status": constants.StatusDisabled}
|
||||
result, total, err := s.List(ctx, &store.QueryOptions{Page: 1, PageSize: 20}, filters)
|
||||
require.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, total, int64(1))
|
||||
for _, c := range result {
|
||||
assert.Equal(t, constants.StatusDisabled, c.Status)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("按状态过滤-启用", func(t *testing.T) {
|
||||
filters := map[string]interface{}{"status": constants.StatusEnabled}
|
||||
result, total, err := s.List(ctx, &store.QueryOptions{Page: 1, PageSize: 20}, filters)
|
||||
require.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, total, int64(2))
|
||||
for _, c := range result {
|
||||
assert.Equal(t, constants.StatusEnabled, c.Status)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("分页查询", func(t *testing.T) {
|
||||
result, total, err := s.List(ctx, &store.QueryOptions{Page: 1, PageSize: 2}, nil)
|
||||
require.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, total, int64(3))
|
||||
assert.LessOrEqual(t, len(result), 2)
|
||||
})
|
||||
|
||||
t.Run("默认分页选项", func(t *testing.T) {
|
||||
result, _, err := s.List(ctx, nil, nil)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
})
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/tests/testutils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDeviceSimBindingStore_Create_DuplicateCard(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
testutils.CleanTestRedisKeys(t, rdb)
|
||||
|
||||
bindingStore := NewDeviceSimBindingStore(tx, rdb)
|
||||
deviceStore := NewDeviceStore(tx, rdb)
|
||||
cardStore := NewIotCardStore(tx, rdb)
|
||||
ctx := context.Background()
|
||||
|
||||
device1 := &model.Device{DeviceNo: "TEST-DEV-UC-001", Status: 1, MaxSimSlots: 4}
|
||||
device2 := &model.Device{DeviceNo: "TEST-DEV-UC-002", Status: 1, MaxSimSlots: 4}
|
||||
require.NoError(t, deviceStore.Create(ctx, device1))
|
||||
require.NoError(t, deviceStore.Create(ctx, device2))
|
||||
|
||||
card := &model.IotCard{ICCID: "89860012345678910001", CarrierID: 1, Status: 1}
|
||||
require.NoError(t, cardStore.Create(ctx, card))
|
||||
|
||||
now := time.Now()
|
||||
binding1 := &model.DeviceSimBinding{
|
||||
DeviceID: device1.ID,
|
||||
IotCardID: card.ID,
|
||||
SlotPosition: 1,
|
||||
BindStatus: 1,
|
||||
BindTime: &now,
|
||||
}
|
||||
require.NoError(t, bindingStore.Create(ctx, binding1))
|
||||
|
||||
binding2 := &model.DeviceSimBinding{
|
||||
DeviceID: device2.ID,
|
||||
IotCardID: card.ID,
|
||||
SlotPosition: 1,
|
||||
BindStatus: 1,
|
||||
BindTime: &now,
|
||||
}
|
||||
err := bindingStore.Create(ctx, binding2)
|
||||
require.Error(t, err)
|
||||
|
||||
appErr, ok := err.(*errors.AppError)
|
||||
require.True(t, ok, "错误应该是 AppError 类型")
|
||||
assert.Equal(t, errors.CodeIotCardBoundToDevice, appErr.Code)
|
||||
assert.Contains(t, appErr.Message, "该卡已绑定到其他设备")
|
||||
}
|
||||
|
||||
func TestDeviceSimBindingStore_Create_DuplicateSlot(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
testutils.CleanTestRedisKeys(t, rdb)
|
||||
|
||||
bindingStore := NewDeviceSimBindingStore(tx, rdb)
|
||||
deviceStore := NewDeviceStore(tx, rdb)
|
||||
cardStore := NewIotCardStore(tx, rdb)
|
||||
ctx := context.Background()
|
||||
|
||||
device := &model.Device{DeviceNo: "TEST-DEV-UC-003", Status: 1, MaxSimSlots: 4}
|
||||
require.NoError(t, deviceStore.Create(ctx, device))
|
||||
|
||||
card1 := &model.IotCard{ICCID: "89860012345678910011", CarrierID: 1, Status: 1}
|
||||
card2 := &model.IotCard{ICCID: "89860012345678910012", CarrierID: 1, Status: 1}
|
||||
require.NoError(t, cardStore.Create(ctx, card1))
|
||||
require.NoError(t, cardStore.Create(ctx, card2))
|
||||
|
||||
now := time.Now()
|
||||
binding1 := &model.DeviceSimBinding{
|
||||
DeviceID: device.ID,
|
||||
IotCardID: card1.ID,
|
||||
SlotPosition: 1,
|
||||
BindStatus: 1,
|
||||
BindTime: &now,
|
||||
}
|
||||
require.NoError(t, bindingStore.Create(ctx, binding1))
|
||||
|
||||
binding2 := &model.DeviceSimBinding{
|
||||
DeviceID: device.ID,
|
||||
IotCardID: card2.ID,
|
||||
SlotPosition: 1,
|
||||
BindStatus: 1,
|
||||
BindTime: &now,
|
||||
}
|
||||
err := bindingStore.Create(ctx, binding2)
|
||||
require.Error(t, err)
|
||||
|
||||
appErr, ok := err.(*errors.AppError)
|
||||
require.True(t, ok, "错误应该是 AppError 类型")
|
||||
assert.Equal(t, errors.CodeConflict, appErr.Code)
|
||||
assert.Contains(t, appErr.Message, "该插槽已有绑定的卡")
|
||||
}
|
||||
|
||||
func TestDeviceSimBindingStore_Create_DifferentSlots(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
testutils.CleanTestRedisKeys(t, rdb)
|
||||
|
||||
bindingStore := NewDeviceSimBindingStore(tx, rdb)
|
||||
deviceStore := NewDeviceStore(tx, rdb)
|
||||
cardStore := NewIotCardStore(tx, rdb)
|
||||
ctx := context.Background()
|
||||
|
||||
device := &model.Device{DeviceNo: "TEST-DEV-UC-004", Status: 1, MaxSimSlots: 4}
|
||||
require.NoError(t, deviceStore.Create(ctx, device))
|
||||
|
||||
card1 := &model.IotCard{ICCID: "89860012345678910021", CarrierID: 1, Status: 1}
|
||||
card2 := &model.IotCard{ICCID: "89860012345678910022", CarrierID: 1, Status: 1}
|
||||
require.NoError(t, cardStore.Create(ctx, card1))
|
||||
require.NoError(t, cardStore.Create(ctx, card2))
|
||||
|
||||
now := time.Now()
|
||||
binding1 := &model.DeviceSimBinding{
|
||||
DeviceID: device.ID,
|
||||
IotCardID: card1.ID,
|
||||
SlotPosition: 1,
|
||||
BindStatus: 1,
|
||||
BindTime: &now,
|
||||
}
|
||||
require.NoError(t, bindingStore.Create(ctx, binding1))
|
||||
assert.NotZero(t, binding1.ID)
|
||||
|
||||
binding2 := &model.DeviceSimBinding{
|
||||
DeviceID: device.ID,
|
||||
IotCardID: card2.ID,
|
||||
SlotPosition: 2,
|
||||
BindStatus: 1,
|
||||
BindTime: &now,
|
||||
}
|
||||
err := bindingStore.Create(ctx, binding2)
|
||||
require.NoError(t, err)
|
||||
assert.NotZero(t, binding2.ID)
|
||||
}
|
||||
|
||||
func TestDeviceSimBindingStore_ConcurrentBinding(t *testing.T) {
|
||||
db := testutils.GetTestDB(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
testutils.CleanTestRedisKeys(t, rdb)
|
||||
|
||||
deviceStore := NewDeviceStore(db, rdb)
|
||||
cardStore := NewIotCardStore(db, rdb)
|
||||
ctx := context.Background()
|
||||
|
||||
device1 := &model.Device{DeviceNo: "TEST-CONCURRENT-001", Status: 1, MaxSimSlots: 4}
|
||||
device2 := &model.Device{DeviceNo: "TEST-CONCURRENT-002", Status: 1, MaxSimSlots: 4}
|
||||
require.NoError(t, deviceStore.Create(ctx, device1))
|
||||
require.NoError(t, deviceStore.Create(ctx, device2))
|
||||
|
||||
card := &model.IotCard{ICCID: "89860012345678920001", CarrierID: 1, Status: 1}
|
||||
require.NoError(t, cardStore.Create(ctx, card))
|
||||
|
||||
t.Cleanup(func() {
|
||||
db.Where("device_id IN ?", []uint{device1.ID, device2.ID}).Delete(&model.DeviceSimBinding{})
|
||||
db.Delete(device1)
|
||||
db.Delete(device2)
|
||||
db.Delete(card)
|
||||
})
|
||||
|
||||
t.Run("并发绑定同一张卡到不同设备", func(t *testing.T) {
|
||||
bindingStore := NewDeviceSimBindingStore(db, rdb)
|
||||
var wg sync.WaitGroup
|
||||
results := make(chan error, 2)
|
||||
|
||||
for i, deviceID := range []uint{device1.ID, device2.ID} {
|
||||
wg.Add(1)
|
||||
go func(devID uint, slot int) {
|
||||
defer wg.Done()
|
||||
now := time.Now()
|
||||
binding := &model.DeviceSimBinding{
|
||||
DeviceID: devID,
|
||||
IotCardID: card.ID,
|
||||
SlotPosition: slot,
|
||||
BindStatus: 1,
|
||||
BindTime: &now,
|
||||
}
|
||||
results <- bindingStore.Create(ctx, binding)
|
||||
}(deviceID, i+1)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
close(results)
|
||||
|
||||
var successCount, errorCount int
|
||||
for err := range results {
|
||||
if err == nil {
|
||||
successCount++
|
||||
} else {
|
||||
errorCount++
|
||||
appErr, ok := err.(*errors.AppError)
|
||||
if ok {
|
||||
assert.Equal(t, errors.CodeIotCardBoundToDevice, appErr.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert.Equal(t, 1, successCount, "应该只有一个请求成功")
|
||||
assert.Equal(t, 1, errorCount, "应该有一个请求失败")
|
||||
})
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/tests/testutils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func uniqueDeviceNoPrefix() string {
|
||||
return fmt.Sprintf("D%d", time.Now().UnixNano()%1000000000)
|
||||
}
|
||||
|
||||
func TestDeviceStore_BatchUpdateSeriesID(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
testutils.CleanTestRedisKeys(t, rdb)
|
||||
|
||||
s := NewDeviceStore(tx, rdb)
|
||||
ctx := context.Background()
|
||||
|
||||
prefix := uniqueDeviceNoPrefix()
|
||||
devices := []*model.Device{
|
||||
{DeviceNo: prefix + "001", DeviceName: "测试设备1", Status: 1},
|
||||
{DeviceNo: prefix + "002", DeviceName: "测试设备2", Status: 1},
|
||||
}
|
||||
require.NoError(t, s.CreateBatch(ctx, devices))
|
||||
|
||||
t.Run("设置系列ID", func(t *testing.T) {
|
||||
seriesID := uint(100)
|
||||
deviceIDs := []uint{devices[0].ID, devices[1].ID}
|
||||
|
||||
err := s.BatchUpdateSeriesID(ctx, deviceIDs, &seriesID)
|
||||
require.NoError(t, err)
|
||||
|
||||
var updatedDevices []*model.Device
|
||||
require.NoError(t, tx.Where("id IN ?", deviceIDs).Find(&updatedDevices).Error)
|
||||
for _, device := range updatedDevices {
|
||||
require.NotNil(t, device.SeriesID)
|
||||
assert.Equal(t, seriesID, *device.SeriesID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("清除系列ID", func(t *testing.T) {
|
||||
deviceIDs := []uint{devices[0].ID}
|
||||
|
||||
err := s.BatchUpdateSeriesID(ctx, deviceIDs, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
var updatedDevice model.Device
|
||||
require.NoError(t, tx.First(&updatedDevice, devices[0].ID).Error)
|
||||
assert.Nil(t, updatedDevice.SeriesID)
|
||||
})
|
||||
|
||||
t.Run("空列表不报错", func(t *testing.T) {
|
||||
err := s.BatchUpdateSeriesID(ctx, []uint{}, nil)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeviceStore_ListBySeriesID(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
testutils.CleanTestRedisKeys(t, rdb)
|
||||
|
||||
s := NewDeviceStore(tx, rdb)
|
||||
ctx := context.Background()
|
||||
|
||||
prefix := uniqueDeviceNoPrefix()
|
||||
seriesID := uint(200)
|
||||
devices := []*model.Device{
|
||||
{DeviceNo: prefix + "001", DeviceName: "测试设备1", Status: 1, SeriesID: &seriesID},
|
||||
{DeviceNo: prefix + "002", DeviceName: "测试设备2", Status: 1, SeriesID: &seriesID},
|
||||
{DeviceNo: prefix + "003", DeviceName: "测试设备3", Status: 1, SeriesID: nil},
|
||||
}
|
||||
require.NoError(t, s.CreateBatch(ctx, devices))
|
||||
|
||||
result, err := s.ListBySeriesID(ctx, seriesID)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, result, 2)
|
||||
for _, device := range result {
|
||||
assert.Equal(t, seriesID, *device.SeriesID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceStore_List_SeriesIDFilter(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
testutils.CleanTestRedisKeys(t, rdb)
|
||||
|
||||
s := NewDeviceStore(tx, rdb)
|
||||
ctx := context.Background()
|
||||
|
||||
prefix := uniqueDeviceNoPrefix()
|
||||
seriesID := uint(300)
|
||||
devices := []*model.Device{
|
||||
{DeviceNo: prefix + "001", DeviceName: "测试设备1", Status: 1, SeriesID: &seriesID},
|
||||
{DeviceNo: prefix + "002", DeviceName: "测试设备2", Status: 1, SeriesID: &seriesID},
|
||||
{DeviceNo: prefix + "003", DeviceName: "测试设备3", Status: 1, SeriesID: nil},
|
||||
}
|
||||
require.NoError(t, s.CreateBatch(ctx, devices))
|
||||
|
||||
filters := map[string]interface{}{
|
||||
"series_id": seriesID,
|
||||
"device_no": prefix,
|
||||
}
|
||||
result, total, err := s.List(ctx, nil, filters)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(2), total)
|
||||
assert.Len(t, result, 2)
|
||||
for _, device := range result {
|
||||
assert.Equal(t, seriesID, *device.SeriesID)
|
||||
}
|
||||
}
|
||||
@@ -1,308 +0,0 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/tests/testutils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func uniqueCardAuthTestPrefix() string {
|
||||
return fmt.Sprintf("ECA%d", time.Now().UnixNano()%1000000000)
|
||||
}
|
||||
|
||||
func TestEnterpriseCardAuthorizationStore_RevokeByDeviceAuthID(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
testutils.CleanTestRedisKeys(t, rdb)
|
||||
|
||||
store := NewEnterpriseCardAuthorizationStore(tx, rdb)
|
||||
ctx := context.Background()
|
||||
|
||||
prefix := uniqueCardAuthTestPrefix()
|
||||
|
||||
enterprise := &model.Enterprise{
|
||||
EnterpriseName: prefix + "_测试企业",
|
||||
EnterpriseCode: prefix,
|
||||
Status: 1,
|
||||
}
|
||||
require.NoError(t, tx.Create(enterprise).Error)
|
||||
|
||||
carrier := &model.Carrier{
|
||||
CarrierName: "测试运营商",
|
||||
CarrierType: "CMCC",
|
||||
Status: 1,
|
||||
}
|
||||
require.NoError(t, tx.Create(carrier).Error)
|
||||
|
||||
cards := []*model.IotCard{
|
||||
{ICCID: prefix + "0001", CarrierID: carrier.ID, Status: 2},
|
||||
{ICCID: prefix + "0002", CarrierID: carrier.ID, Status: 2},
|
||||
{ICCID: prefix + "0003", CarrierID: carrier.ID, Status: 2},
|
||||
}
|
||||
for _, c := range cards {
|
||||
require.NoError(t, tx.Create(c).Error)
|
||||
}
|
||||
|
||||
deviceAuthID := uint(12345)
|
||||
now := time.Now()
|
||||
auths := []*model.EnterpriseCardAuthorization{
|
||||
{EnterpriseID: enterprise.ID, CardID: cards[0].ID, AuthorizedBy: 1, AuthorizedAt: now, AuthorizerType: 2, DeviceAuthID: &deviceAuthID},
|
||||
{EnterpriseID: enterprise.ID, CardID: cards[1].ID, AuthorizedBy: 1, AuthorizedAt: now, AuthorizerType: 2, DeviceAuthID: &deviceAuthID},
|
||||
{EnterpriseID: enterprise.ID, CardID: cards[2].ID, AuthorizedBy: 1, AuthorizedAt: now, AuthorizerType: 2, DeviceAuthID: nil},
|
||||
}
|
||||
for _, auth := range auths {
|
||||
require.NoError(t, store.Create(ctx, auth))
|
||||
}
|
||||
|
||||
t.Run("成功撤销指定设备授权ID关联的卡授权", func(t *testing.T) {
|
||||
revokerID := uint(2)
|
||||
err := store.RevokeByDeviceAuthID(ctx, deviceAuthID, revokerID)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := store.ListByEnterprise(ctx, enterprise.ID, false)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, result, 1)
|
||||
assert.Equal(t, cards[2].ID, result[0].CardID)
|
||||
|
||||
revokedResult, err := store.ListByEnterprise(ctx, enterprise.ID, true)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, revokedResult, 3)
|
||||
|
||||
for _, auth := range revokedResult {
|
||||
if auth.DeviceAuthID != nil && *auth.DeviceAuthID == deviceAuthID {
|
||||
assert.NotNil(t, auth.RevokedAt)
|
||||
assert.NotNil(t, auth.RevokedBy)
|
||||
assert.Equal(t, revokerID, *auth.RevokedBy)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("设备授权ID不存在时不报错", func(t *testing.T) {
|
||||
err := store.RevokeByDeviceAuthID(ctx, 99999, uint(1))
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestEnterpriseCardAuthorizationStore_Create(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
testutils.CleanTestRedisKeys(t, rdb)
|
||||
|
||||
store := NewEnterpriseCardAuthorizationStore(tx, rdb)
|
||||
ctx := context.Background()
|
||||
|
||||
prefix := uniqueCardAuthTestPrefix()
|
||||
|
||||
enterprise := &model.Enterprise{
|
||||
EnterpriseName: prefix + "_测试企业",
|
||||
EnterpriseCode: prefix,
|
||||
Status: 1,
|
||||
}
|
||||
require.NoError(t, tx.Create(enterprise).Error)
|
||||
|
||||
carrier := &model.Carrier{
|
||||
CarrierName: "测试运营商",
|
||||
CarrierType: "CMCC",
|
||||
Status: 1,
|
||||
}
|
||||
require.NoError(t, tx.Create(carrier).Error)
|
||||
|
||||
card := &model.IotCard{
|
||||
ICCID: prefix + "0001",
|
||||
CarrierID: carrier.ID,
|
||||
Status: 2,
|
||||
}
|
||||
require.NoError(t, tx.Create(card).Error)
|
||||
|
||||
t.Run("成功创建卡授权记录", func(t *testing.T) {
|
||||
auth := &model.EnterpriseCardAuthorization{
|
||||
EnterpriseID: enterprise.ID,
|
||||
CardID: card.ID,
|
||||
AuthorizedBy: 1,
|
||||
AuthorizedAt: time.Now(),
|
||||
AuthorizerType: 2,
|
||||
Remark: "测试授权",
|
||||
}
|
||||
|
||||
err := store.Create(ctx, auth)
|
||||
require.NoError(t, err)
|
||||
assert.NotZero(t, auth.ID)
|
||||
})
|
||||
}
|
||||
|
||||
func TestEnterpriseCardAuthorizationStore_BatchCreate(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
testutils.CleanTestRedisKeys(t, rdb)
|
||||
|
||||
store := NewEnterpriseCardAuthorizationStore(tx, rdb)
|
||||
ctx := context.Background()
|
||||
|
||||
prefix := uniqueCardAuthTestPrefix()
|
||||
|
||||
enterprise := &model.Enterprise{
|
||||
EnterpriseName: prefix + "_测试企业",
|
||||
EnterpriseCode: prefix,
|
||||
Status: 1,
|
||||
}
|
||||
require.NoError(t, tx.Create(enterprise).Error)
|
||||
|
||||
carrier := &model.Carrier{
|
||||
CarrierName: "测试运营商",
|
||||
CarrierType: "CMCC",
|
||||
Status: 1,
|
||||
}
|
||||
require.NoError(t, tx.Create(carrier).Error)
|
||||
|
||||
cards := []*model.IotCard{
|
||||
{ICCID: prefix + "0001", CarrierID: carrier.ID, Status: 2},
|
||||
{ICCID: prefix + "0002", CarrierID: carrier.ID, Status: 2},
|
||||
}
|
||||
for _, c := range cards {
|
||||
require.NoError(t, tx.Create(c).Error)
|
||||
}
|
||||
|
||||
t.Run("成功批量创建卡授权记录", func(t *testing.T) {
|
||||
now := time.Now()
|
||||
auths := []*model.EnterpriseCardAuthorization{
|
||||
{EnterpriseID: enterprise.ID, CardID: cards[0].ID, AuthorizedBy: 1, AuthorizedAt: now, AuthorizerType: 2},
|
||||
{EnterpriseID: enterprise.ID, CardID: cards[1].ID, AuthorizedBy: 1, AuthorizedAt: now, AuthorizerType: 2},
|
||||
}
|
||||
|
||||
err := store.BatchCreate(ctx, auths)
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, auth := range auths {
|
||||
assert.NotZero(t, auth.ID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("空列表不报错", func(t *testing.T) {
|
||||
err := store.BatchCreate(ctx, []*model.EnterpriseCardAuthorization{})
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestEnterpriseCardAuthorizationStore_ListByEnterprise(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
testutils.CleanTestRedisKeys(t, rdb)
|
||||
|
||||
store := NewEnterpriseCardAuthorizationStore(tx, rdb)
|
||||
ctx := context.Background()
|
||||
|
||||
prefix := uniqueCardAuthTestPrefix()
|
||||
|
||||
enterprise := &model.Enterprise{
|
||||
EnterpriseName: prefix + "_测试企业",
|
||||
EnterpriseCode: prefix,
|
||||
Status: 1,
|
||||
}
|
||||
require.NoError(t, tx.Create(enterprise).Error)
|
||||
|
||||
carrier := &model.Carrier{
|
||||
CarrierName: "测试运营商",
|
||||
CarrierType: "CMCC",
|
||||
Status: 1,
|
||||
}
|
||||
require.NoError(t, tx.Create(carrier).Error)
|
||||
|
||||
cards := []*model.IotCard{
|
||||
{ICCID: prefix + "0001", CarrierID: carrier.ID, Status: 2},
|
||||
{ICCID: prefix + "0002", CarrierID: carrier.ID, Status: 2},
|
||||
}
|
||||
for _, c := range cards {
|
||||
require.NoError(t, tx.Create(c).Error)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
auths := []*model.EnterpriseCardAuthorization{
|
||||
{EnterpriseID: enterprise.ID, CardID: cards[0].ID, AuthorizedBy: 1, AuthorizedAt: now, AuthorizerType: 2},
|
||||
{EnterpriseID: enterprise.ID, CardID: cards[1].ID, AuthorizedBy: 1, AuthorizedAt: now, AuthorizerType: 2, RevokedBy: ptrUintCA(1), RevokedAt: &now},
|
||||
}
|
||||
for _, auth := range auths {
|
||||
require.NoError(t, store.Create(ctx, auth))
|
||||
}
|
||||
|
||||
t.Run("获取未撤销的授权记录", func(t *testing.T) {
|
||||
result, err := store.ListByEnterprise(ctx, enterprise.ID, false)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, result, 1)
|
||||
assert.Equal(t, cards[0].ID, result[0].CardID)
|
||||
})
|
||||
|
||||
t.Run("获取所有授权记录包括已撤销", func(t *testing.T) {
|
||||
result, err := store.ListByEnterprise(ctx, enterprise.ID, true)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, result, 2)
|
||||
})
|
||||
}
|
||||
|
||||
func ptrUintCA(v uint) *uint {
|
||||
return &v
|
||||
}
|
||||
|
||||
func TestEnterpriseCardAuthorizationStore_GetActiveAuthsByCardIDs(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
testutils.CleanTestRedisKeys(t, rdb)
|
||||
|
||||
store := NewEnterpriseCardAuthorizationStore(tx, rdb)
|
||||
ctx := context.Background()
|
||||
|
||||
prefix := uniqueCardAuthTestPrefix()
|
||||
|
||||
enterprise := &model.Enterprise{
|
||||
EnterpriseName: prefix + "_测试企业",
|
||||
EnterpriseCode: prefix,
|
||||
Status: 1,
|
||||
}
|
||||
require.NoError(t, tx.Create(enterprise).Error)
|
||||
|
||||
carrier := &model.Carrier{
|
||||
CarrierName: "测试运营商",
|
||||
CarrierType: "CMCC",
|
||||
Status: 1,
|
||||
}
|
||||
require.NoError(t, tx.Create(carrier).Error)
|
||||
|
||||
cards := []*model.IotCard{
|
||||
{ICCID: prefix + "0001", CarrierID: carrier.ID, Status: 2},
|
||||
{ICCID: prefix + "0002", CarrierID: carrier.ID, Status: 2},
|
||||
{ICCID: prefix + "0003", CarrierID: carrier.ID, Status: 2},
|
||||
}
|
||||
for _, c := range cards {
|
||||
require.NoError(t, tx.Create(c).Error)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
auths := []*model.EnterpriseCardAuthorization{
|
||||
{EnterpriseID: enterprise.ID, CardID: cards[0].ID, AuthorizedBy: 1, AuthorizedAt: now, AuthorizerType: 2},
|
||||
{EnterpriseID: enterprise.ID, CardID: cards[1].ID, AuthorizedBy: 1, AuthorizedAt: now, AuthorizerType: 2, RevokedBy: ptrUintCA(1), RevokedAt: &now},
|
||||
}
|
||||
for _, auth := range auths {
|
||||
require.NoError(t, store.Create(ctx, auth))
|
||||
}
|
||||
|
||||
t.Run("获取有效授权的卡ID映射", func(t *testing.T) {
|
||||
cardIDs := []uint{cards[0].ID, cards[1].ID, cards[2].ID}
|
||||
result, err := store.GetActiveAuthsByCardIDs(ctx, enterprise.ID, cardIDs)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, result[cards[0].ID])
|
||||
assert.False(t, result[cards[1].ID])
|
||||
assert.False(t, result[cards[2].ID])
|
||||
})
|
||||
|
||||
t.Run("空卡ID列表返回空映射", func(t *testing.T) {
|
||||
result, err := store.GetActiveAuthsByCardIDs(ctx, enterprise.ID, []uint{})
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, result)
|
||||
})
|
||||
}
|
||||
@@ -1,517 +0,0 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/tests/testutils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func uniqueDeviceAuthTestPrefix() string {
|
||||
return fmt.Sprintf("EDA%d", time.Now().UnixNano()%1000000000)
|
||||
}
|
||||
|
||||
func TestEnterpriseDeviceAuthorizationStore_Create(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
testutils.CleanTestRedisKeys(t, rdb)
|
||||
|
||||
store := NewEnterpriseDeviceAuthorizationStore(tx, rdb)
|
||||
ctx := context.Background()
|
||||
|
||||
prefix := uniqueDeviceAuthTestPrefix()
|
||||
|
||||
enterprise := &model.Enterprise{
|
||||
EnterpriseName: prefix + "_测试企业",
|
||||
EnterpriseCode: prefix,
|
||||
Status: 1,
|
||||
}
|
||||
require.NoError(t, tx.Create(enterprise).Error)
|
||||
|
||||
device := &model.Device{
|
||||
DeviceNo: prefix + "_001",
|
||||
DeviceName: "测试设备1",
|
||||
Status: 2,
|
||||
}
|
||||
require.NoError(t, tx.Create(device).Error)
|
||||
|
||||
t.Run("成功创建授权记录", func(t *testing.T) {
|
||||
auth := &model.EnterpriseDeviceAuthorization{
|
||||
EnterpriseID: enterprise.ID,
|
||||
DeviceID: device.ID,
|
||||
AuthorizedBy: 1,
|
||||
AuthorizedAt: time.Now(),
|
||||
AuthorizerType: 2,
|
||||
Remark: "测试授权",
|
||||
}
|
||||
|
||||
err := store.Create(ctx, auth)
|
||||
require.NoError(t, err)
|
||||
assert.NotZero(t, auth.ID)
|
||||
assert.Equal(t, enterprise.ID, auth.EnterpriseID)
|
||||
assert.Equal(t, device.ID, auth.DeviceID)
|
||||
})
|
||||
}
|
||||
|
||||
func TestEnterpriseDeviceAuthorizationStore_BatchCreate(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
testutils.CleanTestRedisKeys(t, rdb)
|
||||
|
||||
store := NewEnterpriseDeviceAuthorizationStore(tx, rdb)
|
||||
ctx := context.Background()
|
||||
|
||||
prefix := uniqueDeviceAuthTestPrefix()
|
||||
|
||||
enterprise := &model.Enterprise{
|
||||
EnterpriseName: prefix + "_测试企业",
|
||||
EnterpriseCode: prefix,
|
||||
Status: 1,
|
||||
}
|
||||
require.NoError(t, tx.Create(enterprise).Error)
|
||||
|
||||
devices := []*model.Device{
|
||||
{DeviceNo: prefix + "_001", DeviceName: "测试设备1", Status: 2},
|
||||
{DeviceNo: prefix + "_002", DeviceName: "测试设备2", Status: 2},
|
||||
{DeviceNo: prefix + "_003", DeviceName: "测试设备3", Status: 2},
|
||||
}
|
||||
for _, d := range devices {
|
||||
require.NoError(t, tx.Create(d).Error)
|
||||
}
|
||||
|
||||
t.Run("成功批量创建授权记录", func(t *testing.T) {
|
||||
now := time.Now()
|
||||
auths := []*model.EnterpriseDeviceAuthorization{
|
||||
{EnterpriseID: enterprise.ID, DeviceID: devices[0].ID, AuthorizedBy: 1, AuthorizedAt: now, AuthorizerType: 2},
|
||||
{EnterpriseID: enterprise.ID, DeviceID: devices[1].ID, AuthorizedBy: 1, AuthorizedAt: now, AuthorizerType: 2},
|
||||
{EnterpriseID: enterprise.ID, DeviceID: devices[2].ID, AuthorizedBy: 1, AuthorizedAt: now, AuthorizerType: 2},
|
||||
}
|
||||
|
||||
err := store.BatchCreate(ctx, auths)
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, auth := range auths {
|
||||
assert.NotZero(t, auth.ID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("空列表不报错", func(t *testing.T) {
|
||||
err := store.BatchCreate(ctx, []*model.EnterpriseDeviceAuthorization{})
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestEnterpriseDeviceAuthorizationStore_GetByID(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
testutils.CleanTestRedisKeys(t, rdb)
|
||||
|
||||
store := NewEnterpriseDeviceAuthorizationStore(tx, rdb)
|
||||
ctx := context.Background()
|
||||
|
||||
prefix := uniqueDeviceAuthTestPrefix()
|
||||
|
||||
enterprise := &model.Enterprise{
|
||||
EnterpriseName: prefix + "_测试企业",
|
||||
EnterpriseCode: prefix,
|
||||
Status: 1,
|
||||
}
|
||||
require.NoError(t, tx.Create(enterprise).Error)
|
||||
|
||||
device := &model.Device{
|
||||
DeviceNo: prefix + "_001",
|
||||
DeviceName: "测试设备1",
|
||||
Status: 2,
|
||||
}
|
||||
require.NoError(t, tx.Create(device).Error)
|
||||
|
||||
auth := &model.EnterpriseDeviceAuthorization{
|
||||
EnterpriseID: enterprise.ID,
|
||||
DeviceID: device.ID,
|
||||
AuthorizedBy: 1,
|
||||
AuthorizedAt: time.Now(),
|
||||
AuthorizerType: 2,
|
||||
Remark: "测试备注",
|
||||
}
|
||||
require.NoError(t, store.Create(ctx, auth))
|
||||
|
||||
t.Run("成功获取授权记录", func(t *testing.T) {
|
||||
result, err := store.GetByID(ctx, auth.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, auth.ID, result.ID)
|
||||
assert.Equal(t, enterprise.ID, result.EnterpriseID)
|
||||
assert.Equal(t, device.ID, result.DeviceID)
|
||||
assert.Equal(t, "测试备注", result.Remark)
|
||||
})
|
||||
|
||||
t.Run("记录不存在返回错误", func(t *testing.T) {
|
||||
_, err := store.GetByID(ctx, 99999)
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestEnterpriseDeviceAuthorizationStore_GetByDeviceID(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
testutils.CleanTestRedisKeys(t, rdb)
|
||||
|
||||
store := NewEnterpriseDeviceAuthorizationStore(tx, rdb)
|
||||
ctx := context.Background()
|
||||
|
||||
prefix := uniqueDeviceAuthTestPrefix()
|
||||
|
||||
enterprise := &model.Enterprise{
|
||||
EnterpriseName: prefix + "_测试企业",
|
||||
EnterpriseCode: prefix,
|
||||
Status: 1,
|
||||
}
|
||||
require.NoError(t, tx.Create(enterprise).Error)
|
||||
|
||||
device := &model.Device{
|
||||
DeviceNo: prefix + "_001",
|
||||
DeviceName: "测试设备1",
|
||||
Status: 2,
|
||||
}
|
||||
require.NoError(t, tx.Create(device).Error)
|
||||
|
||||
auth := &model.EnterpriseDeviceAuthorization{
|
||||
EnterpriseID: enterprise.ID,
|
||||
DeviceID: device.ID,
|
||||
AuthorizedBy: 1,
|
||||
AuthorizedAt: time.Now(),
|
||||
AuthorizerType: 2,
|
||||
}
|
||||
require.NoError(t, store.Create(ctx, auth))
|
||||
|
||||
t.Run("成功通过设备ID获取授权记录", func(t *testing.T) {
|
||||
result, err := store.GetByDeviceID(ctx, device.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, auth.ID, result.ID)
|
||||
assert.Equal(t, enterprise.ID, result.EnterpriseID)
|
||||
})
|
||||
|
||||
t.Run("设备未授权返回错误", func(t *testing.T) {
|
||||
_, err := store.GetByDeviceID(ctx, 99999)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("已撤销的授权不返回", func(t *testing.T) {
|
||||
device2 := &model.Device{
|
||||
DeviceNo: prefix + "_002",
|
||||
DeviceName: "测试设备2",
|
||||
Status: 2,
|
||||
}
|
||||
require.NoError(t, tx.Create(device2).Error)
|
||||
|
||||
now := time.Now()
|
||||
revokedAuth := &model.EnterpriseDeviceAuthorization{
|
||||
EnterpriseID: enterprise.ID,
|
||||
DeviceID: device2.ID,
|
||||
AuthorizedBy: 1,
|
||||
AuthorizedAt: now,
|
||||
AuthorizerType: 2,
|
||||
RevokedBy: ptrUint(1),
|
||||
RevokedAt: &now,
|
||||
}
|
||||
require.NoError(t, store.Create(ctx, revokedAuth))
|
||||
|
||||
_, err := store.GetByDeviceID(ctx, device2.ID)
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestEnterpriseDeviceAuthorizationStore_GetByEnterpriseID(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
testutils.CleanTestRedisKeys(t, rdb)
|
||||
|
||||
store := NewEnterpriseDeviceAuthorizationStore(tx, rdb)
|
||||
ctx := context.Background()
|
||||
|
||||
prefix := uniqueDeviceAuthTestPrefix()
|
||||
|
||||
enterprise := &model.Enterprise{
|
||||
EnterpriseName: prefix + "_测试企业",
|
||||
EnterpriseCode: prefix,
|
||||
Status: 1,
|
||||
}
|
||||
require.NoError(t, tx.Create(enterprise).Error)
|
||||
|
||||
devices := []*model.Device{
|
||||
{DeviceNo: prefix + "_001", DeviceName: "测试设备1", Status: 2},
|
||||
{DeviceNo: prefix + "_002", DeviceName: "测试设备2", Status: 2},
|
||||
}
|
||||
for _, d := range devices {
|
||||
require.NoError(t, tx.Create(d).Error)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
auths := []*model.EnterpriseDeviceAuthorization{
|
||||
{EnterpriseID: enterprise.ID, DeviceID: devices[0].ID, AuthorizedBy: 1, AuthorizedAt: now, AuthorizerType: 2},
|
||||
{EnterpriseID: enterprise.ID, DeviceID: devices[1].ID, AuthorizedBy: 1, AuthorizedAt: now, AuthorizerType: 2, RevokedBy: ptrUint(1), RevokedAt: &now},
|
||||
}
|
||||
for _, auth := range auths {
|
||||
require.NoError(t, store.Create(ctx, auth))
|
||||
}
|
||||
|
||||
t.Run("获取未撤销的授权记录", func(t *testing.T) {
|
||||
result, err := store.GetByEnterpriseID(ctx, enterprise.ID, false)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, result, 1)
|
||||
assert.Equal(t, devices[0].ID, result[0].DeviceID)
|
||||
})
|
||||
|
||||
t.Run("获取所有授权记录包括已撤销", func(t *testing.T) {
|
||||
result, err := store.GetByEnterpriseID(ctx, enterprise.ID, true)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, result, 2)
|
||||
})
|
||||
}
|
||||
|
||||
func TestEnterpriseDeviceAuthorizationStore_ListByEnterprise(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
testutils.CleanTestRedisKeys(t, rdb)
|
||||
|
||||
store := NewEnterpriseDeviceAuthorizationStore(tx, rdb)
|
||||
ctx := context.Background()
|
||||
|
||||
prefix := uniqueDeviceAuthTestPrefix()
|
||||
|
||||
enterprise := &model.Enterprise{
|
||||
EnterpriseName: prefix + "_测试企业",
|
||||
EnterpriseCode: prefix,
|
||||
Status: 1,
|
||||
}
|
||||
require.NoError(t, tx.Create(enterprise).Error)
|
||||
|
||||
devices := make([]*model.Device, 5)
|
||||
for i := 0; i < 5; i++ {
|
||||
devices[i] = &model.Device{
|
||||
DeviceNo: fmt.Sprintf("%s_%03d", prefix, i+1),
|
||||
DeviceName: fmt.Sprintf("测试设备%d", i+1),
|
||||
Status: 2,
|
||||
}
|
||||
require.NoError(t, tx.Create(devices[i]).Error)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
for i, d := range devices {
|
||||
auth := &model.EnterpriseDeviceAuthorization{
|
||||
EnterpriseID: enterprise.ID,
|
||||
DeviceID: d.ID,
|
||||
AuthorizedBy: uint(i + 1),
|
||||
AuthorizedAt: now.Add(time.Duration(i) * time.Minute),
|
||||
AuthorizerType: 2,
|
||||
}
|
||||
require.NoError(t, store.Create(ctx, auth))
|
||||
}
|
||||
|
||||
t.Run("分页查询", func(t *testing.T) {
|
||||
opts := DeviceAuthListOptions{
|
||||
EnterpriseID: &enterprise.ID,
|
||||
Page: 1,
|
||||
PageSize: 2,
|
||||
}
|
||||
|
||||
result, total, err := store.ListByEnterprise(ctx, opts)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(5), total)
|
||||
assert.Len(t, result, 2)
|
||||
})
|
||||
|
||||
t.Run("按授权人过滤", func(t *testing.T) {
|
||||
authorizerID := uint(1)
|
||||
opts := DeviceAuthListOptions{
|
||||
EnterpriseID: &enterprise.ID,
|
||||
AuthorizerID: &authorizerID,
|
||||
Page: 1,
|
||||
PageSize: 10,
|
||||
}
|
||||
|
||||
result, total, err := store.ListByEnterprise(ctx, opts)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), total)
|
||||
assert.Len(t, result, 1)
|
||||
})
|
||||
|
||||
t.Run("按设备ID过滤", func(t *testing.T) {
|
||||
opts := DeviceAuthListOptions{
|
||||
EnterpriseID: &enterprise.ID,
|
||||
DeviceIDs: []uint{devices[0].ID, devices[1].ID},
|
||||
Page: 1,
|
||||
PageSize: 10,
|
||||
}
|
||||
|
||||
result, total, err := store.ListByEnterprise(ctx, opts)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(2), total)
|
||||
assert.Len(t, result, 2)
|
||||
})
|
||||
}
|
||||
|
||||
func TestEnterpriseDeviceAuthorizationStore_RevokeByIDs(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
testutils.CleanTestRedisKeys(t, rdb)
|
||||
|
||||
store := NewEnterpriseDeviceAuthorizationStore(tx, rdb)
|
||||
ctx := context.Background()
|
||||
|
||||
prefix := uniqueDeviceAuthTestPrefix()
|
||||
|
||||
enterprise := &model.Enterprise{
|
||||
EnterpriseName: prefix + "_测试企业",
|
||||
EnterpriseCode: prefix,
|
||||
Status: 1,
|
||||
}
|
||||
require.NoError(t, tx.Create(enterprise).Error)
|
||||
|
||||
devices := []*model.Device{
|
||||
{DeviceNo: prefix + "_001", DeviceName: "测试设备1", Status: 2},
|
||||
{DeviceNo: prefix + "_002", DeviceName: "测试设备2", Status: 2},
|
||||
}
|
||||
for _, d := range devices {
|
||||
require.NoError(t, tx.Create(d).Error)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
auths := []*model.EnterpriseDeviceAuthorization{
|
||||
{EnterpriseID: enterprise.ID, DeviceID: devices[0].ID, AuthorizedBy: 1, AuthorizedAt: now, AuthorizerType: 2},
|
||||
{EnterpriseID: enterprise.ID, DeviceID: devices[1].ID, AuthorizedBy: 1, AuthorizedAt: now, AuthorizerType: 2},
|
||||
}
|
||||
for _, auth := range auths {
|
||||
require.NoError(t, store.Create(ctx, auth))
|
||||
}
|
||||
|
||||
t.Run("成功撤销授权", func(t *testing.T) {
|
||||
revokerID := uint(2)
|
||||
err := store.RevokeByIDs(ctx, []uint{auths[0].ID}, revokerID)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := store.GetByID(ctx, auths[0].ID)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, result.RevokedAt)
|
||||
assert.NotNil(t, result.RevokedBy)
|
||||
assert.Equal(t, revokerID, *result.RevokedBy)
|
||||
})
|
||||
|
||||
t.Run("已撤销的记录不再被重复撤销", func(t *testing.T) {
|
||||
err := store.RevokeByIDs(ctx, []uint{auths[0].ID}, uint(3))
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := store.GetByID(ctx, auths[0].ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, uint(2), *result.RevokedBy)
|
||||
})
|
||||
}
|
||||
|
||||
func TestEnterpriseDeviceAuthorizationStore_GetActiveAuthsByDeviceIDs(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
testutils.CleanTestRedisKeys(t, rdb)
|
||||
|
||||
store := NewEnterpriseDeviceAuthorizationStore(tx, rdb)
|
||||
ctx := context.Background()
|
||||
|
||||
prefix := uniqueDeviceAuthTestPrefix()
|
||||
|
||||
enterprise := &model.Enterprise{
|
||||
EnterpriseName: prefix + "_测试企业",
|
||||
EnterpriseCode: prefix,
|
||||
Status: 1,
|
||||
}
|
||||
require.NoError(t, tx.Create(enterprise).Error)
|
||||
|
||||
devices := []*model.Device{
|
||||
{DeviceNo: prefix + "_001", DeviceName: "测试设备1", Status: 2},
|
||||
{DeviceNo: prefix + "_002", DeviceName: "测试设备2", Status: 2},
|
||||
{DeviceNo: prefix + "_003", DeviceName: "测试设备3", Status: 2},
|
||||
}
|
||||
for _, d := range devices {
|
||||
require.NoError(t, tx.Create(d).Error)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
auths := []*model.EnterpriseDeviceAuthorization{
|
||||
{EnterpriseID: enterprise.ID, DeviceID: devices[0].ID, AuthorizedBy: 1, AuthorizedAt: now, AuthorizerType: 2},
|
||||
{EnterpriseID: enterprise.ID, DeviceID: devices[1].ID, AuthorizedBy: 1, AuthorizedAt: now, AuthorizerType: 2, RevokedBy: ptrUint(1), RevokedAt: &now},
|
||||
}
|
||||
for _, auth := range auths {
|
||||
require.NoError(t, store.Create(ctx, auth))
|
||||
}
|
||||
|
||||
t.Run("获取有效授权的设备ID映射", func(t *testing.T) {
|
||||
deviceIDs := []uint{devices[0].ID, devices[1].ID, devices[2].ID}
|
||||
result, err := store.GetActiveAuthsByDeviceIDs(ctx, enterprise.ID, deviceIDs)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, result[devices[0].ID])
|
||||
assert.False(t, result[devices[1].ID])
|
||||
assert.False(t, result[devices[2].ID])
|
||||
})
|
||||
|
||||
t.Run("空设备ID列表返回空映射", func(t *testing.T) {
|
||||
result, err := store.GetActiveAuthsByDeviceIDs(ctx, enterprise.ID, []uint{})
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestEnterpriseDeviceAuthorizationStore_ListDeviceIDsByEnterprise(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
testutils.CleanTestRedisKeys(t, rdb)
|
||||
|
||||
store := NewEnterpriseDeviceAuthorizationStore(tx, rdb)
|
||||
ctx := context.Background()
|
||||
|
||||
prefix := uniqueDeviceAuthTestPrefix()
|
||||
|
||||
enterprise := &model.Enterprise{
|
||||
EnterpriseName: prefix + "_测试企业",
|
||||
EnterpriseCode: prefix,
|
||||
Status: 1,
|
||||
}
|
||||
require.NoError(t, tx.Create(enterprise).Error)
|
||||
|
||||
devices := []*model.Device{
|
||||
{DeviceNo: prefix + "_001", DeviceName: "测试设备1", Status: 2},
|
||||
{DeviceNo: prefix + "_002", DeviceName: "测试设备2", Status: 2},
|
||||
}
|
||||
for _, d := range devices {
|
||||
require.NoError(t, tx.Create(d).Error)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
auths := []*model.EnterpriseDeviceAuthorization{
|
||||
{EnterpriseID: enterprise.ID, DeviceID: devices[0].ID, AuthorizedBy: 1, AuthorizedAt: now, AuthorizerType: 2},
|
||||
{EnterpriseID: enterprise.ID, DeviceID: devices[1].ID, AuthorizedBy: 1, AuthorizedAt: now, AuthorizerType: 2},
|
||||
}
|
||||
for _, auth := range auths {
|
||||
require.NoError(t, store.Create(ctx, auth))
|
||||
}
|
||||
|
||||
t.Run("获取企业授权设备ID列表", func(t *testing.T) {
|
||||
result, err := store.ListDeviceIDsByEnterprise(ctx, enterprise.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, result, 2)
|
||||
assert.Contains(t, result, devices[0].ID)
|
||||
assert.Contains(t, result, devices[1].ID)
|
||||
})
|
||||
|
||||
t.Run("无授权记录返回空列表", func(t *testing.T) {
|
||||
result, err := store.ListDeviceIDsByEnterprise(ctx, 99999)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, result)
|
||||
})
|
||||
}
|
||||
|
||||
func ptrUint(v uint) *uint {
|
||||
return &v
|
||||
}
|
||||
@@ -1,524 +0,0 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store"
|
||||
"github.com/break/junhong_cmp_fiber/tests/testutils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func uniqueICCIDPrefix() string {
|
||||
return fmt.Sprintf("T%d", time.Now().UnixNano()%1000000000)
|
||||
}
|
||||
|
||||
func TestIotCardStore_Create(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
testutils.CleanTestRedisKeys(t, rdb)
|
||||
|
||||
s := NewIotCardStore(tx, rdb)
|
||||
ctx := context.Background()
|
||||
|
||||
card := &model.IotCard{
|
||||
ICCID: "89860012345678901234",
|
||||
CarrierID: 1,
|
||||
Status: 1,
|
||||
}
|
||||
|
||||
err := s.Create(ctx, card)
|
||||
require.NoError(t, err)
|
||||
assert.NotZero(t, card.ID)
|
||||
}
|
||||
|
||||
func TestIotCardStore_ExistsByICCID(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
testutils.CleanTestRedisKeys(t, rdb)
|
||||
|
||||
s := NewIotCardStore(tx, rdb)
|
||||
ctx := context.Background()
|
||||
|
||||
card := &model.IotCard{
|
||||
ICCID: "89860012345678901111",
|
||||
CarrierID: 1,
|
||||
Status: 1,
|
||||
}
|
||||
require.NoError(t, s.Create(ctx, card))
|
||||
|
||||
exists, err := s.ExistsByICCID(ctx, "89860012345678901111")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, exists)
|
||||
|
||||
exists, err = s.ExistsByICCID(ctx, "89860012345678909999")
|
||||
require.NoError(t, err)
|
||||
assert.False(t, exists)
|
||||
}
|
||||
|
||||
func TestIotCardStore_ExistsByICCIDBatch(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
testutils.CleanTestRedisKeys(t, rdb)
|
||||
|
||||
s := NewIotCardStore(tx, rdb)
|
||||
ctx := context.Background()
|
||||
|
||||
cards := []*model.IotCard{
|
||||
{ICCID: "89860012345678902001", CarrierID: 1, Status: 1},
|
||||
{ICCID: "89860012345678902002", CarrierID: 1, Status: 1},
|
||||
{ICCID: "89860012345678902003", CarrierID: 1, Status: 1},
|
||||
}
|
||||
require.NoError(t, s.CreateBatch(ctx, cards))
|
||||
|
||||
result, err := s.ExistsByICCIDBatch(ctx, []string{
|
||||
"89860012345678902001",
|
||||
"89860012345678902002",
|
||||
"89860012345678909999",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result["89860012345678902001"])
|
||||
assert.True(t, result["89860012345678902002"])
|
||||
assert.False(t, result["89860012345678909999"])
|
||||
|
||||
emptyResult, err := s.ExistsByICCIDBatch(ctx, []string{})
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, emptyResult)
|
||||
}
|
||||
|
||||
func TestIotCardStore_ListStandalone(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
testutils.CleanTestRedisKeys(t, rdb)
|
||||
|
||||
s := NewIotCardStore(tx, rdb)
|
||||
ctx := context.Background()
|
||||
|
||||
prefix := uniqueICCIDPrefix()
|
||||
standaloneCards := []*model.IotCard{
|
||||
{ICCID: prefix + "0001", CarrierID: 1, Status: 1},
|
||||
{ICCID: prefix + "0002", CarrierID: 1, Status: 1},
|
||||
{ICCID: prefix + "0003", CarrierID: 2, Status: 2},
|
||||
}
|
||||
require.NoError(t, s.CreateBatch(ctx, standaloneCards))
|
||||
|
||||
boundCard := &model.IotCard{
|
||||
ICCID: prefix + "0004",
|
||||
CarrierID: 1,
|
||||
Status: 1,
|
||||
}
|
||||
require.NoError(t, s.Create(ctx, boundCard))
|
||||
|
||||
binding := &model.DeviceSimBinding{
|
||||
DeviceID: 1,
|
||||
IotCardID: boundCard.ID,
|
||||
BindStatus: 1,
|
||||
}
|
||||
require.NoError(t, tx.Create(binding).Error)
|
||||
|
||||
t.Run("查询所有单卡", func(t *testing.T) {
|
||||
filters := map[string]interface{}{"iccid": prefix}
|
||||
cards, total, err := s.ListStandalone(ctx, &store.QueryOptions{Page: 1, PageSize: 20}, filters)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(3), total)
|
||||
assert.Len(t, cards, 3)
|
||||
|
||||
for _, card := range cards {
|
||||
assert.NotEqual(t, boundCard.ID, card.ID, "已绑定的卡不应出现在单卡列表中")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("按运营商ID过滤", func(t *testing.T) {
|
||||
filters := map[string]interface{}{"carrier_id": uint(1), "iccid": prefix}
|
||||
cards, total, err := s.ListStandalone(ctx, &store.QueryOptions{Page: 1, PageSize: 20}, filters)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(2), total)
|
||||
for _, card := range cards {
|
||||
assert.Equal(t, uint(1), card.CarrierID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("按状态过滤", func(t *testing.T) {
|
||||
filters := map[string]interface{}{"status": 2, "iccid": prefix}
|
||||
cards, total, err := s.ListStandalone(ctx, &store.QueryOptions{Page: 1, PageSize: 20}, filters)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), total)
|
||||
assert.Len(t, cards, 1)
|
||||
assert.Equal(t, 2, cards[0].Status)
|
||||
})
|
||||
|
||||
t.Run("按ICCID模糊查询", func(t *testing.T) {
|
||||
filters := map[string]interface{}{"iccid": prefix + "0001"}
|
||||
cards, total, err := s.ListStandalone(ctx, &store.QueryOptions{Page: 1, PageSize: 20}, filters)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), total)
|
||||
assert.Contains(t, cards[0].ICCID, prefix+"0001")
|
||||
})
|
||||
|
||||
t.Run("分页查询", func(t *testing.T) {
|
||||
filters := map[string]interface{}{"iccid": prefix}
|
||||
cards, total, err := s.ListStandalone(ctx, &store.QueryOptions{Page: 1, PageSize: 2}, filters)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(3), total)
|
||||
assert.Len(t, cards, 2)
|
||||
|
||||
cards2, _, err := s.ListStandalone(ctx, &store.QueryOptions{Page: 2, PageSize: 2}, filters)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, cards2, 1)
|
||||
})
|
||||
|
||||
t.Run("默认分页选项", func(t *testing.T) {
|
||||
filters := map[string]interface{}{"iccid": prefix}
|
||||
cards, total, err := s.ListStandalone(ctx, nil, filters)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(3), total)
|
||||
assert.Len(t, cards, 3)
|
||||
})
|
||||
}
|
||||
|
||||
func TestIotCardStore_ListStandalone_Filters(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
testutils.CleanTestRedisKeys(t, rdb)
|
||||
|
||||
s := NewIotCardStore(tx, rdb)
|
||||
ctx := context.Background()
|
||||
|
||||
prefix := uniqueICCIDPrefix()
|
||||
batchPrefix := "B" + prefix
|
||||
msisdnPrefix := "199" + prefix[1:8]
|
||||
shopID := uint(time.Now().UnixNano() % 1000000)
|
||||
|
||||
cards := []*model.IotCard{
|
||||
{ICCID: prefix + "A001", CarrierID: 1, Status: 1, ShopID: &shopID, BatchNo: batchPrefix + "01", MSISDN: msisdnPrefix + "01"},
|
||||
{ICCID: prefix + "A002", CarrierID: 1, Status: 1, ShopID: nil, BatchNo: batchPrefix + "01", MSISDN: msisdnPrefix + "02"},
|
||||
{ICCID: prefix + "A003", CarrierID: 1, Status: 1, ShopID: nil, BatchNo: batchPrefix + "02", MSISDN: msisdnPrefix + "03"},
|
||||
}
|
||||
require.NoError(t, s.CreateBatch(ctx, cards))
|
||||
|
||||
t.Run("按店铺ID过滤", func(t *testing.T) {
|
||||
filters := map[string]interface{}{"shop_id": shopID, "iccid": prefix}
|
||||
result, total, err := s.ListStandalone(ctx, nil, filters)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), total)
|
||||
assert.Equal(t, shopID, *result[0].ShopID)
|
||||
})
|
||||
|
||||
t.Run("按批次号过滤", func(t *testing.T) {
|
||||
filters := map[string]interface{}{"batch_no": batchPrefix + "01", "iccid": prefix}
|
||||
_, total, err := s.ListStandalone(ctx, nil, filters)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(2), total)
|
||||
})
|
||||
|
||||
t.Run("按MSISDN模糊查询", func(t *testing.T) {
|
||||
filters := map[string]interface{}{"msisdn": msisdnPrefix + "01"}
|
||||
result, total, err := s.ListStandalone(ctx, nil, filters)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), total)
|
||||
assert.Contains(t, result[0].MSISDN, msisdnPrefix+"01")
|
||||
})
|
||||
|
||||
t.Run("已分销过滤-true", func(t *testing.T) {
|
||||
filters := map[string]interface{}{"is_distributed": true, "iccid": prefix}
|
||||
result, total, err := s.ListStandalone(ctx, nil, filters)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), total)
|
||||
assert.NotNil(t, result[0].ShopID)
|
||||
})
|
||||
|
||||
t.Run("已分销过滤-false", func(t *testing.T) {
|
||||
filters := map[string]interface{}{"is_distributed": false, "iccid": prefix}
|
||||
result, total, err := s.ListStandalone(ctx, nil, filters)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(2), total)
|
||||
for _, card := range result {
|
||||
assert.Nil(t, card.ShopID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ICCID范围查询", func(t *testing.T) {
|
||||
filters := map[string]interface{}{
|
||||
"iccid_start": prefix + "A001",
|
||||
"iccid_end": prefix + "A002",
|
||||
}
|
||||
_, total, err := s.ListStandalone(ctx, nil, filters)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(2), total)
|
||||
})
|
||||
}
|
||||
|
||||
func TestIotCardStore_GetByICCIDs(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
testutils.CleanTestRedisKeys(t, rdb)
|
||||
|
||||
s := NewIotCardStore(tx, rdb)
|
||||
ctx := context.Background()
|
||||
|
||||
cards := []*model.IotCard{
|
||||
{ICCID: "89860012345678905001", CarrierID: 1, Status: 1},
|
||||
{ICCID: "89860012345678905002", CarrierID: 1, Status: 1},
|
||||
{ICCID: "89860012345678905003", CarrierID: 1, Status: 1},
|
||||
}
|
||||
require.NoError(t, s.CreateBatch(ctx, cards))
|
||||
|
||||
t.Run("查询存在的ICCID", func(t *testing.T) {
|
||||
result, err := s.GetByICCIDs(ctx, []string{"89860012345678905001", "89860012345678905002"})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, result, 2)
|
||||
})
|
||||
|
||||
t.Run("查询不存在的ICCID", func(t *testing.T) {
|
||||
result, err := s.GetByICCIDs(ctx, []string{"89860012345678909999"})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, result, 0)
|
||||
})
|
||||
|
||||
t.Run("空列表返回nil", func(t *testing.T) {
|
||||
result, err := s.GetByICCIDs(ctx, []string{})
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestIotCardStore_GetStandaloneByICCIDRange(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
testutils.CleanTestRedisKeys(t, rdb)
|
||||
|
||||
s := NewIotCardStore(tx, rdb)
|
||||
ctx := context.Background()
|
||||
|
||||
shopID := uint(100)
|
||||
cards := []*model.IotCard{
|
||||
{ICCID: "89860012345678906001", CarrierID: 1, Status: 1, ShopID: nil},
|
||||
{ICCID: "89860012345678906002", CarrierID: 1, Status: 1, ShopID: nil},
|
||||
{ICCID: "89860012345678906003", CarrierID: 1, Status: 1, ShopID: &shopID},
|
||||
{ICCID: "89860012345678906004", CarrierID: 1, Status: 1, ShopID: &shopID},
|
||||
}
|
||||
require.NoError(t, s.CreateBatch(ctx, cards))
|
||||
|
||||
t.Run("平台查询未分配的卡", func(t *testing.T) {
|
||||
result, err := s.GetStandaloneByICCIDRange(ctx, "89860012345678906001", "89860012345678906004", nil)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, result, 2)
|
||||
for _, card := range result {
|
||||
assert.Nil(t, card.ShopID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("店铺查询自己的卡", func(t *testing.T) {
|
||||
result, err := s.GetStandaloneByICCIDRange(ctx, "89860012345678906001", "89860012345678906004", &shopID)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, result, 2)
|
||||
for _, card := range result {
|
||||
assert.Equal(t, shopID, *card.ShopID)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestIotCardStore_GetStandaloneByFilters(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
testutils.CleanTestRedisKeys(t, rdb)
|
||||
|
||||
s := NewIotCardStore(tx, rdb)
|
||||
ctx := context.Background()
|
||||
|
||||
shopID := uint(100)
|
||||
cards := []*model.IotCard{
|
||||
{ICCID: "89860012345678907001", CarrierID: 1, Status: 1, ShopID: nil, BatchNo: "BATCH001"},
|
||||
{ICCID: "89860012345678907002", CarrierID: 2, Status: 1, ShopID: nil, BatchNo: "BATCH002"},
|
||||
{ICCID: "89860012345678907003", CarrierID: 1, Status: 2, ShopID: &shopID, BatchNo: "BATCH001"},
|
||||
}
|
||||
require.NoError(t, s.CreateBatch(ctx, cards))
|
||||
|
||||
t.Run("按运营商过滤", func(t *testing.T) {
|
||||
filters := map[string]any{"carrier_id": uint(1)}
|
||||
result, err := s.GetStandaloneByFilters(ctx, filters, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, result, 1)
|
||||
assert.Equal(t, uint(1), result[0].CarrierID)
|
||||
})
|
||||
|
||||
t.Run("按批次号过滤", func(t *testing.T) {
|
||||
filters := map[string]any{"batch_no": "BATCH001"}
|
||||
result, err := s.GetStandaloneByFilters(ctx, filters, &shopID)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, result, 1)
|
||||
assert.Equal(t, "BATCH001", result[0].BatchNo)
|
||||
})
|
||||
}
|
||||
|
||||
func TestIotCardStore_BatchUpdateShopIDAndStatus(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
testutils.CleanTestRedisKeys(t, rdb)
|
||||
|
||||
s := NewIotCardStore(tx, rdb)
|
||||
ctx := context.Background()
|
||||
|
||||
cards := []*model.IotCard{
|
||||
{ICCID: "89860012345678908001", CarrierID: 1, Status: 1},
|
||||
{ICCID: "89860012345678908002", CarrierID: 1, Status: 1},
|
||||
}
|
||||
require.NoError(t, s.CreateBatch(ctx, cards))
|
||||
|
||||
newShopID := uint(200)
|
||||
cardIDs := []uint{cards[0].ID, cards[1].ID}
|
||||
|
||||
err := s.BatchUpdateShopIDAndStatus(ctx, cardIDs, &newShopID, 2)
|
||||
require.NoError(t, err)
|
||||
|
||||
var updatedCards []*model.IotCard
|
||||
require.NoError(t, tx.Where("id IN ?", cardIDs).Find(&updatedCards).Error)
|
||||
for _, card := range updatedCards {
|
||||
assert.Equal(t, newShopID, *card.ShopID)
|
||||
assert.Equal(t, 2, card.Status)
|
||||
}
|
||||
|
||||
t.Run("空列表不报错", func(t *testing.T) {
|
||||
err := s.BatchUpdateShopIDAndStatus(ctx, []uint{}, nil, 1)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestIotCardStore_GetBoundCardIDs(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
testutils.CleanTestRedisKeys(t, rdb)
|
||||
|
||||
s := NewIotCardStore(tx, rdb)
|
||||
ctx := context.Background()
|
||||
|
||||
cards := []*model.IotCard{
|
||||
{ICCID: "89860012345678909001", CarrierID: 1, Status: 1},
|
||||
{ICCID: "89860012345678909002", CarrierID: 1, Status: 1},
|
||||
{ICCID: "89860012345678909003", CarrierID: 1, Status: 1},
|
||||
}
|
||||
require.NoError(t, s.CreateBatch(ctx, cards))
|
||||
|
||||
binding := &model.DeviceSimBinding{
|
||||
DeviceID: 1,
|
||||
IotCardID: cards[0].ID,
|
||||
BindStatus: 1,
|
||||
}
|
||||
require.NoError(t, tx.Create(binding).Error)
|
||||
|
||||
cardIDs := []uint{cards[0].ID, cards[1].ID, cards[2].ID}
|
||||
boundIDs, err := s.GetBoundCardIDs(ctx, cardIDs)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, boundIDs, 1)
|
||||
assert.Contains(t, boundIDs, cards[0].ID)
|
||||
|
||||
t.Run("空列表返回nil", func(t *testing.T) {
|
||||
result, err := s.GetBoundCardIDs(ctx, []uint{})
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestIotCardStore_BatchUpdateSeriesID(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
testutils.CleanTestRedisKeys(t, rdb)
|
||||
|
||||
s := NewIotCardStore(tx, rdb)
|
||||
ctx := context.Background()
|
||||
|
||||
cards := []*model.IotCard{
|
||||
{ICCID: "89860012345678910001", CarrierID: 1, Status: 1},
|
||||
{ICCID: "89860012345678910002", CarrierID: 1, Status: 1},
|
||||
}
|
||||
require.NoError(t, s.CreateBatch(ctx, cards))
|
||||
|
||||
t.Run("设置系列ID", func(t *testing.T) {
|
||||
seriesID := uint(100)
|
||||
cardIDs := []uint{cards[0].ID, cards[1].ID}
|
||||
|
||||
err := s.BatchUpdateSeriesID(ctx, cardIDs, &seriesID)
|
||||
require.NoError(t, err)
|
||||
|
||||
var updatedCards []*model.IotCard
|
||||
require.NoError(t, tx.Where("id IN ?", cardIDs).Find(&updatedCards).Error)
|
||||
for _, card := range updatedCards {
|
||||
require.NotNil(t, card.SeriesID)
|
||||
assert.Equal(t, seriesID, *card.SeriesID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("清除系列ID", func(t *testing.T) {
|
||||
cardIDs := []uint{cards[0].ID}
|
||||
|
||||
err := s.BatchUpdateSeriesID(ctx, cardIDs, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
var updatedCard model.IotCard
|
||||
require.NoError(t, tx.First(&updatedCard, cards[0].ID).Error)
|
||||
assert.Nil(t, updatedCard.SeriesID)
|
||||
})
|
||||
|
||||
t.Run("空列表不报错", func(t *testing.T) {
|
||||
err := s.BatchUpdateSeriesID(ctx, []uint{}, nil)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestIotCardStore_ListBySeriesID(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
testutils.CleanTestRedisKeys(t, rdb)
|
||||
|
||||
s := NewIotCardStore(tx, rdb)
|
||||
ctx := context.Background()
|
||||
|
||||
seriesID := uint(200)
|
||||
cards := []*model.IotCard{
|
||||
{ICCID: "89860012345678911001", CarrierID: 1, Status: 1, SeriesID: &seriesID},
|
||||
{ICCID: "89860012345678911002", CarrierID: 1, Status: 1, SeriesID: &seriesID},
|
||||
{ICCID: "89860012345678911003", CarrierID: 1, Status: 1, SeriesID: nil},
|
||||
}
|
||||
require.NoError(t, s.CreateBatch(ctx, cards))
|
||||
|
||||
result, err := s.ListBySeriesID(ctx, seriesID)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, result, 2)
|
||||
for _, card := range result {
|
||||
assert.Equal(t, seriesID, *card.SeriesID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIotCardStore_ListStandalone_SeriesIDFilter(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
testutils.CleanTestRedisKeys(t, rdb)
|
||||
|
||||
s := NewIotCardStore(tx, rdb)
|
||||
ctx := context.Background()
|
||||
|
||||
prefix := uniqueICCIDPrefix()
|
||||
seriesID := uint(300)
|
||||
cards := []*model.IotCard{
|
||||
{ICCID: prefix + "S001", CarrierID: 1, Status: 1, SeriesID: &seriesID},
|
||||
{ICCID: prefix + "S002", CarrierID: 1, Status: 1, SeriesID: &seriesID},
|
||||
{ICCID: prefix + "S003", CarrierID: 1, Status: 1, SeriesID: nil},
|
||||
}
|
||||
require.NoError(t, s.CreateBatch(ctx, cards))
|
||||
|
||||
filters := map[string]interface{}{
|
||||
"series_id": seriesID,
|
||||
"iccid": prefix,
|
||||
}
|
||||
result, total, err := s.ListStandalone(ctx, nil, filters)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(2), total)
|
||||
assert.Len(t, result, 2)
|
||||
for _, card := range result {
|
||||
assert.Equal(t, seriesID, *card.SeriesID)
|
||||
}
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/tests/testutils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestOrderItemStore_BatchCreate(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
testutils.CleanTestRedisKeys(t, rdb)
|
||||
orderStore := NewOrderStore(tx, rdb)
|
||||
itemStore := NewOrderItemStore(tx, rdb)
|
||||
ctx := context.Background()
|
||||
|
||||
order := &model.Order{
|
||||
OrderNo: orderStore.GenerateOrderNo(),
|
||||
OrderType: model.OrderTypeSingleCard,
|
||||
BuyerType: model.BuyerTypePersonal,
|
||||
BuyerID: 100,
|
||||
TotalAmount: 15000,
|
||||
PaymentStatus: model.PaymentStatusPending,
|
||||
}
|
||||
require.NoError(t, orderStore.Create(ctx, order, nil))
|
||||
|
||||
items := []*model.OrderItem{
|
||||
{OrderID: order.ID, PackageID: 1, PackageName: "套餐A", Quantity: 1, UnitPrice: 5000, Amount: 5000},
|
||||
{OrderID: order.ID, PackageID: 2, PackageName: "套餐B", Quantity: 2, UnitPrice: 5000, Amount: 10000},
|
||||
}
|
||||
|
||||
err := itemStore.BatchCreate(ctx, items)
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, item := range items {
|
||||
assert.NotZero(t, item.ID)
|
||||
assert.Equal(t, order.ID, item.OrderID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderItemStore_BatchCreate_Empty(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
testutils.CleanTestRedisKeys(t, rdb)
|
||||
itemStore := NewOrderItemStore(tx, rdb)
|
||||
ctx := context.Background()
|
||||
|
||||
err := itemStore.BatchCreate(ctx, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = itemStore.BatchCreate(ctx, []*model.OrderItem{})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestOrderItemStore_ListByOrderID(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
testutils.CleanTestRedisKeys(t, rdb)
|
||||
orderStore := NewOrderStore(tx, rdb)
|
||||
itemStore := NewOrderItemStore(tx, rdb)
|
||||
ctx := context.Background()
|
||||
|
||||
order := &model.Order{
|
||||
OrderNo: orderStore.GenerateOrderNo(),
|
||||
OrderType: model.OrderTypeDevice,
|
||||
BuyerType: model.BuyerTypeAgent,
|
||||
BuyerID: 200,
|
||||
TotalAmount: 20000,
|
||||
PaymentStatus: model.PaymentStatusPending,
|
||||
}
|
||||
items := []*model.OrderItem{
|
||||
{PackageID: 10, PackageName: "设备套餐1", Quantity: 1, UnitPrice: 10000, Amount: 10000},
|
||||
{PackageID: 11, PackageName: "设备套餐2", Quantity: 1, UnitPrice: 10000, Amount: 10000},
|
||||
}
|
||||
require.NoError(t, orderStore.Create(ctx, order, items))
|
||||
|
||||
result, err := itemStore.ListByOrderID(ctx, order.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, result, 2)
|
||||
|
||||
for _, item := range result {
|
||||
assert.Equal(t, order.ID, item.OrderID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderItemStore_ListByOrderIDs(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
testutils.CleanTestRedisKeys(t, rdb)
|
||||
orderStore := NewOrderStore(tx, rdb)
|
||||
itemStore := NewOrderItemStore(tx, rdb)
|
||||
ctx := context.Background()
|
||||
|
||||
order1 := &model.Order{
|
||||
OrderNo: orderStore.GenerateOrderNo(),
|
||||
OrderType: model.OrderTypeSingleCard,
|
||||
BuyerType: model.BuyerTypePersonal,
|
||||
BuyerID: 300,
|
||||
TotalAmount: 5000,
|
||||
PaymentStatus: model.PaymentStatusPending,
|
||||
}
|
||||
items1 := []*model.OrderItem{
|
||||
{PackageID: 20, PackageName: "套餐X", Quantity: 1, UnitPrice: 5000, Amount: 5000},
|
||||
}
|
||||
require.NoError(t, orderStore.Create(ctx, order1, items1))
|
||||
|
||||
order2 := &model.Order{
|
||||
OrderNo: orderStore.GenerateOrderNo(),
|
||||
OrderType: model.OrderTypeSingleCard,
|
||||
BuyerType: model.BuyerTypePersonal,
|
||||
BuyerID: 300,
|
||||
TotalAmount: 8000,
|
||||
PaymentStatus: model.PaymentStatusPending,
|
||||
}
|
||||
items2 := []*model.OrderItem{
|
||||
{PackageID: 21, PackageName: "套餐Y", Quantity: 1, UnitPrice: 3000, Amount: 3000},
|
||||
{PackageID: 22, PackageName: "套餐Z", Quantity: 1, UnitPrice: 5000, Amount: 5000},
|
||||
}
|
||||
require.NoError(t, orderStore.Create(ctx, order2, items2))
|
||||
|
||||
t.Run("查询多个订单的明细", func(t *testing.T) {
|
||||
result, err := itemStore.ListByOrderIDs(ctx, []uint{order1.ID, order2.ID})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, result, 3)
|
||||
})
|
||||
|
||||
t.Run("空订单ID列表", func(t *testing.T) {
|
||||
result, err := itemStore.ListByOrderIDs(ctx, []uint{})
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, result)
|
||||
})
|
||||
|
||||
t.Run("不存在的订单ID", func(t *testing.T) {
|
||||
result, err := itemStore.ListByOrderIDs(ctx, []uint{99999})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, result, 0)
|
||||
})
|
||||
}
|
||||
@@ -1,287 +0,0 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store"
|
||||
"github.com/break/junhong_cmp_fiber/tests/testutils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestOrderStore_Create(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
testutils.CleanTestRedisKeys(t, rdb)
|
||||
s := NewOrderStore(tx, rdb)
|
||||
ctx := context.Background()
|
||||
|
||||
cardID := uint(1001)
|
||||
order := &model.Order{
|
||||
OrderNo: s.GenerateOrderNo(),
|
||||
OrderType: model.OrderTypeSingleCard,
|
||||
BuyerType: model.BuyerTypePersonal,
|
||||
BuyerID: 100,
|
||||
IotCardID: &cardID,
|
||||
TotalAmount: 9900,
|
||||
PaymentStatus: model.PaymentStatusPending,
|
||||
}
|
||||
items := []*model.OrderItem{
|
||||
{
|
||||
PackageID: 1,
|
||||
PackageName: "测试套餐1",
|
||||
Quantity: 1,
|
||||
UnitPrice: 5000,
|
||||
Amount: 5000,
|
||||
},
|
||||
{
|
||||
PackageID: 2,
|
||||
PackageName: "测试套餐2",
|
||||
Quantity: 1,
|
||||
UnitPrice: 4900,
|
||||
Amount: 4900,
|
||||
},
|
||||
}
|
||||
|
||||
err := s.Create(ctx, order, items)
|
||||
require.NoError(t, err)
|
||||
assert.NotZero(t, order.ID)
|
||||
|
||||
for _, item := range items {
|
||||
assert.NotZero(t, item.ID)
|
||||
assert.Equal(t, order.ID, item.OrderID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderStore_GetByID(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
testutils.CleanTestRedisKeys(t, rdb)
|
||||
s := NewOrderStore(tx, rdb)
|
||||
ctx := context.Background()
|
||||
|
||||
order := &model.Order{
|
||||
OrderNo: s.GenerateOrderNo(),
|
||||
OrderType: model.OrderTypeSingleCard,
|
||||
BuyerType: model.BuyerTypeAgent,
|
||||
BuyerID: 200,
|
||||
TotalAmount: 19900,
|
||||
PaymentStatus: model.PaymentStatusPending,
|
||||
}
|
||||
require.NoError(t, s.Create(ctx, order, nil))
|
||||
|
||||
t.Run("查询存在的订单", func(t *testing.T) {
|
||||
result, err := s.GetByID(ctx, order.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, order.OrderNo, result.OrderNo)
|
||||
assert.Equal(t, order.BuyerType, result.BuyerType)
|
||||
assert.Equal(t, order.TotalAmount, result.TotalAmount)
|
||||
})
|
||||
|
||||
t.Run("查询不存在的订单", func(t *testing.T) {
|
||||
_, err := s.GetByID(ctx, 99999)
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestOrderStore_GetByIDWithItems(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
testutils.CleanTestRedisKeys(t, rdb)
|
||||
s := NewOrderStore(tx, rdb)
|
||||
ctx := context.Background()
|
||||
|
||||
deviceID := uint(2001)
|
||||
order := &model.Order{
|
||||
OrderNo: s.GenerateOrderNo(),
|
||||
OrderType: model.OrderTypeDevice,
|
||||
BuyerType: model.BuyerTypePersonal,
|
||||
BuyerID: 300,
|
||||
DeviceID: &deviceID,
|
||||
TotalAmount: 29900,
|
||||
PaymentStatus: model.PaymentStatusPending,
|
||||
}
|
||||
items := []*model.OrderItem{
|
||||
{PackageID: 10, PackageName: "设备套餐A", Quantity: 1, UnitPrice: 15000, Amount: 15000},
|
||||
{PackageID: 11, PackageName: "设备套餐B", Quantity: 1, UnitPrice: 14900, Amount: 14900},
|
||||
}
|
||||
require.NoError(t, s.Create(ctx, order, items))
|
||||
|
||||
resultOrder, resultItems, err := s.GetByIDWithItems(ctx, order.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, order.OrderNo, resultOrder.OrderNo)
|
||||
assert.Len(t, resultItems, 2)
|
||||
}
|
||||
|
||||
func TestOrderStore_GetByOrderNo(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
testutils.CleanTestRedisKeys(t, rdb)
|
||||
s := NewOrderStore(tx, rdb)
|
||||
ctx := context.Background()
|
||||
|
||||
orderNo := s.GenerateOrderNo()
|
||||
order := &model.Order{
|
||||
OrderNo: orderNo,
|
||||
OrderType: model.OrderTypeSingleCard,
|
||||
BuyerType: model.BuyerTypeAgent,
|
||||
BuyerID: 400,
|
||||
TotalAmount: 5000,
|
||||
PaymentStatus: model.PaymentStatusPending,
|
||||
}
|
||||
require.NoError(t, s.Create(ctx, order, nil))
|
||||
|
||||
t.Run("查询存在的订单号", func(t *testing.T) {
|
||||
result, err := s.GetByOrderNo(ctx, orderNo)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, order.ID, result.ID)
|
||||
})
|
||||
|
||||
t.Run("查询不存在的订单号", func(t *testing.T) {
|
||||
_, err := s.GetByOrderNo(ctx, "NOT_EXISTS_ORDER_NO")
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestOrderStore_Update(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
testutils.CleanTestRedisKeys(t, rdb)
|
||||
s := NewOrderStore(tx, rdb)
|
||||
ctx := context.Background()
|
||||
|
||||
order := &model.Order{
|
||||
OrderNo: s.GenerateOrderNo(),
|
||||
OrderType: model.OrderTypeSingleCard,
|
||||
BuyerType: model.BuyerTypePersonal,
|
||||
BuyerID: 500,
|
||||
TotalAmount: 10000,
|
||||
PaymentStatus: model.PaymentStatusPending,
|
||||
}
|
||||
require.NoError(t, s.Create(ctx, order, nil))
|
||||
|
||||
order.PaymentMethod = model.PaymentMethodWallet
|
||||
order.PaymentStatus = model.PaymentStatusPaid
|
||||
now := time.Now()
|
||||
order.PaidAt = &now
|
||||
err := s.Update(ctx, order)
|
||||
require.NoError(t, err)
|
||||
|
||||
updated, err := s.GetByID(ctx, order.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, model.PaymentMethodWallet, updated.PaymentMethod)
|
||||
assert.Equal(t, model.PaymentStatusPaid, updated.PaymentStatus)
|
||||
assert.NotNil(t, updated.PaidAt)
|
||||
}
|
||||
|
||||
func TestOrderStore_UpdatePaymentStatus(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
testutils.CleanTestRedisKeys(t, rdb)
|
||||
s := NewOrderStore(tx, rdb)
|
||||
ctx := context.Background()
|
||||
|
||||
order := &model.Order{
|
||||
OrderNo: s.GenerateOrderNo(),
|
||||
OrderType: model.OrderTypeSingleCard,
|
||||
BuyerType: model.BuyerTypeAgent,
|
||||
BuyerID: 600,
|
||||
TotalAmount: 8000,
|
||||
PaymentStatus: model.PaymentStatusPending,
|
||||
}
|
||||
require.NoError(t, s.Create(ctx, order, nil))
|
||||
|
||||
now := time.Now()
|
||||
err := s.UpdatePaymentStatus(ctx, order.ID, model.PaymentStatusPaid, &now)
|
||||
require.NoError(t, err)
|
||||
|
||||
updated, err := s.GetByID(ctx, order.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, model.PaymentStatusPaid, updated.PaymentStatus)
|
||||
assert.NotNil(t, updated.PaidAt)
|
||||
}
|
||||
|
||||
func TestOrderStore_List(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
testutils.CleanTestRedisKeys(t, rdb)
|
||||
s := NewOrderStore(tx, rdb)
|
||||
ctx := context.Background()
|
||||
|
||||
orders := []*model.Order{
|
||||
{OrderNo: s.GenerateOrderNo(), OrderType: model.OrderTypeSingleCard, BuyerType: model.BuyerTypePersonal, BuyerID: 700, TotalAmount: 1000, PaymentStatus: model.PaymentStatusPending},
|
||||
{OrderNo: s.GenerateOrderNo(), OrderType: model.OrderTypeDevice, BuyerType: model.BuyerTypeAgent, BuyerID: 701, TotalAmount: 2000, PaymentStatus: model.PaymentStatusPaid},
|
||||
{OrderNo: s.GenerateOrderNo(), OrderType: model.OrderTypeSingleCard, BuyerType: model.BuyerTypeAgent, BuyerID: 701, TotalAmount: 3000, PaymentStatus: model.PaymentStatusCancelled},
|
||||
}
|
||||
for _, o := range orders {
|
||||
require.NoError(t, s.Create(ctx, o, nil))
|
||||
}
|
||||
|
||||
t.Run("查询所有订单", func(t *testing.T) {
|
||||
result, total, err := s.List(ctx, &store.QueryOptions{Page: 1, PageSize: 20}, nil)
|
||||
require.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, total, int64(3))
|
||||
assert.GreaterOrEqual(t, len(result), 3)
|
||||
})
|
||||
|
||||
t.Run("按支付状态过滤", func(t *testing.T) {
|
||||
filters := map[string]any{"payment_status": model.PaymentStatusPending}
|
||||
result, total, err := s.List(ctx, &store.QueryOptions{Page: 1, PageSize: 20}, filters)
|
||||
require.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, total, int64(1))
|
||||
for _, o := range result {
|
||||
assert.Equal(t, model.PaymentStatusPending, o.PaymentStatus)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("按订单类型过滤", func(t *testing.T) {
|
||||
filters := map[string]any{"order_type": model.OrderTypeDevice}
|
||||
result, total, err := s.List(ctx, &store.QueryOptions{Page: 1, PageSize: 20}, filters)
|
||||
require.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, total, int64(1))
|
||||
for _, o := range result {
|
||||
assert.Equal(t, model.OrderTypeDevice, o.OrderType)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("按买家过滤", func(t *testing.T) {
|
||||
filters := map[string]any{"buyer_type": model.BuyerTypeAgent, "buyer_id": uint(701)}
|
||||
result, total, err := s.List(ctx, &store.QueryOptions{Page: 1, PageSize: 20}, filters)
|
||||
require.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, total, int64(2))
|
||||
for _, o := range result {
|
||||
assert.Equal(t, model.BuyerTypeAgent, o.BuyerType)
|
||||
assert.Equal(t, uint(701), o.BuyerID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("分页查询", func(t *testing.T) {
|
||||
result, total, err := s.List(ctx, &store.QueryOptions{Page: 1, PageSize: 2}, nil)
|
||||
require.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, total, int64(3))
|
||||
assert.LessOrEqual(t, len(result), 2)
|
||||
})
|
||||
|
||||
t.Run("默认分页选项", func(t *testing.T) {
|
||||
result, _, err := s.List(ctx, nil, nil)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestOrderStore_GenerateOrderNo(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
s := NewOrderStore(tx, rdb)
|
||||
|
||||
orderNo1 := s.GenerateOrderNo()
|
||||
orderNo2 := s.GenerateOrderNo()
|
||||
|
||||
assert.True(t, len(orderNo1) > 0)
|
||||
assert.True(t, len(orderNo1) <= 30)
|
||||
assert.Contains(t, orderNo1, "ORD")
|
||||
assert.NotEqual(t, orderNo1, orderNo2)
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/tests/testutils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPackageSeriesStore_Create(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
s := NewPackageSeriesStore(tx)
|
||||
ctx := context.Background()
|
||||
|
||||
series := &model.PackageSeries{
|
||||
SeriesCode: "SERIES_TEST_001",
|
||||
SeriesName: "测试系列",
|
||||
Description: "测试描述",
|
||||
Status: constants.StatusEnabled,
|
||||
}
|
||||
|
||||
err := s.Create(ctx, series)
|
||||
require.NoError(t, err)
|
||||
assert.NotZero(t, series.ID)
|
||||
}
|
||||
|
||||
func TestPackageSeriesStore_GetByID(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
s := NewPackageSeriesStore(tx)
|
||||
ctx := context.Background()
|
||||
|
||||
series := &model.PackageSeries{
|
||||
SeriesCode: "SERIES_TEST_002",
|
||||
SeriesName: "测试系列2",
|
||||
Status: constants.StatusEnabled,
|
||||
}
|
||||
require.NoError(t, s.Create(ctx, series))
|
||||
|
||||
t.Run("查询存在的系列", func(t *testing.T) {
|
||||
result, err := s.GetByID(ctx, series.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, series.SeriesCode, result.SeriesCode)
|
||||
})
|
||||
|
||||
t.Run("查询不存在的系列", func(t *testing.T) {
|
||||
_, err := s.GetByID(ctx, 99999)
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPackageSeriesStore_GetByCode(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
s := NewPackageSeriesStore(tx)
|
||||
ctx := context.Background()
|
||||
|
||||
series := &model.PackageSeries{
|
||||
SeriesCode: "SERIES_TEST_003",
|
||||
SeriesName: "测试系列3",
|
||||
Status: constants.StatusEnabled,
|
||||
}
|
||||
require.NoError(t, s.Create(ctx, series))
|
||||
|
||||
t.Run("查询存在的编码", func(t *testing.T) {
|
||||
result, err := s.GetByCode(ctx, "SERIES_TEST_003")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, series.ID, result.ID)
|
||||
})
|
||||
|
||||
t.Run("查询不存在的编码", func(t *testing.T) {
|
||||
_, err := s.GetByCode(ctx, "NOT_EXISTS")
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPackageSeriesStore_Update(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
s := NewPackageSeriesStore(tx)
|
||||
ctx := context.Background()
|
||||
|
||||
series := &model.PackageSeries{
|
||||
SeriesCode: "SERIES_TEST_004",
|
||||
SeriesName: "测试系列4",
|
||||
Status: constants.StatusEnabled,
|
||||
}
|
||||
require.NoError(t, s.Create(ctx, series))
|
||||
|
||||
series.SeriesName = "测试系列4-更新"
|
||||
series.Description = "更新后的描述"
|
||||
err := s.Update(ctx, series)
|
||||
require.NoError(t, err)
|
||||
|
||||
updated, err := s.GetByID(ctx, series.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "测试系列4-更新", updated.SeriesName)
|
||||
assert.Equal(t, "更新后的描述", updated.Description)
|
||||
}
|
||||
|
||||
func TestPackageSeriesStore_Delete(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
s := NewPackageSeriesStore(tx)
|
||||
ctx := context.Background()
|
||||
|
||||
series := &model.PackageSeries{
|
||||
SeriesCode: "SERIES_DEL_001",
|
||||
SeriesName: "待删除系列",
|
||||
Status: constants.StatusEnabled,
|
||||
}
|
||||
require.NoError(t, s.Create(ctx, series))
|
||||
|
||||
err := s.Delete(ctx, series.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = s.GetByID(ctx, series.ID)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestPackageSeriesStore_List(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
s := NewPackageSeriesStore(tx)
|
||||
ctx := context.Background()
|
||||
|
||||
seriesList := []*model.PackageSeries{
|
||||
{SeriesCode: "LIST_S_001", SeriesName: "基础套餐", Status: constants.StatusEnabled},
|
||||
{SeriesCode: "LIST_S_002", SeriesName: "高级套餐", Status: constants.StatusEnabled},
|
||||
{SeriesCode: "LIST_S_003", SeriesName: "企业套餐", Status: constants.StatusEnabled},
|
||||
}
|
||||
for _, series := range seriesList {
|
||||
require.NoError(t, s.Create(ctx, series))
|
||||
}
|
||||
seriesList[2].Status = constants.StatusDisabled
|
||||
require.NoError(t, s.Update(ctx, seriesList[2]))
|
||||
|
||||
t.Run("查询所有系列", func(t *testing.T) {
|
||||
result, total, err := s.List(ctx, &store.QueryOptions{Page: 1, PageSize: 20}, nil)
|
||||
require.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, total, int64(3))
|
||||
assert.GreaterOrEqual(t, len(result), 3)
|
||||
})
|
||||
|
||||
t.Run("按名称模糊搜索", func(t *testing.T) {
|
||||
filters := map[string]interface{}{"series_name": "高级"}
|
||||
result, total, err := s.List(ctx, &store.QueryOptions{Page: 1, PageSize: 20}, filters)
|
||||
require.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, total, int64(1))
|
||||
for _, series := range result {
|
||||
assert.Contains(t, series.SeriesName, "高级")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("按状态过滤", func(t *testing.T) {
|
||||
filters := map[string]interface{}{"status": constants.StatusDisabled}
|
||||
result, total, err := s.List(ctx, &store.QueryOptions{Page: 1, PageSize: 20}, filters)
|
||||
require.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, total, int64(1))
|
||||
for _, series := range result {
|
||||
assert.Equal(t, constants.StatusDisabled, series.Status)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("分页查询", func(t *testing.T) {
|
||||
result, total, err := s.List(ctx, &store.QueryOptions{Page: 1, PageSize: 2}, nil)
|
||||
require.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, total, int64(3))
|
||||
assert.LessOrEqual(t, len(result), 2)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPackageSeriesStore_UpdateStatus(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
s := NewPackageSeriesStore(tx)
|
||||
ctx := context.Background()
|
||||
|
||||
series := &model.PackageSeries{
|
||||
SeriesCode: "STATUS_TEST_001",
|
||||
SeriesName: "状态测试系列",
|
||||
Status: constants.StatusEnabled,
|
||||
}
|
||||
require.NoError(t, s.Create(ctx, series))
|
||||
|
||||
err := s.UpdateStatus(ctx, series.ID, constants.StatusDisabled)
|
||||
require.NoError(t, err)
|
||||
|
||||
updated, err := s.GetByID(ctx, series.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, constants.StatusDisabled, updated.Status)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user