Compare commits
23 Commits
c083596ef6
...
4ff5dcf55e
| Author | SHA1 | Date | |
|---|---|---|---|
| 4ff5dcf55e | |||
| d3eaac0eb0 | |||
| 8ace759200 | |||
| 5432fb0513 | |||
| e1ec024904 | |||
| 754bbcdf3b | |||
| 47aa5f8d25 | |||
| facc766993 | |||
| 8553a46143 | |||
| 4be473c248 | |||
| 8a531237c3 | |||
| 922a3d0b49 | |||
| 60b43bb29e | |||
| bdac4ab7b2 | |||
| 0ebb3d75f3 | |||
| 7dd55630dc | |||
| 9ee2f0b466 | |||
| 244a2742c1 | |||
| 8f62496250 | |||
| 344850fe1d | |||
| 2a92ab64af | |||
| 62eb43a1cf | |||
| 84aca2fd63 |
@@ -14,6 +14,17 @@
|
||||
|
||||
*以下功能已在代码库中实现,视为已验证的基础能力:*
|
||||
|
||||
**Validated in Phase 2: PERM/FIN/REALNAME/POLL 修复**
|
||||
- ✓ 企业账号 Resolve 接口移除错误拦截,Refresh 接口新增拦截(只读)— PERM-01/02
|
||||
- ✓ 提现审批人字段补全(approved_by/approved_at)— FIN-01
|
||||
- ✓ 提现拒绝 remark 必填校验(Reject 补 Validate 调用)— FIN-02
|
||||
- ✓ 激活配置并发行锁保护(clause.Locking FOR UPDATE)— FIN-03
|
||||
- ✓ enable_realname_activation → expiry_base 全链路重构,三维激活决策(卡类型+购买路径+expiry_base)— REALNAME-01~05
|
||||
- ✓ polling:protect 接入调度器(processManualQueue + processTimedQueue)— POLL-01
|
||||
- ✓ gatewayClient=nil 时停复机返回业务错误而非 nil — POLL-02
|
||||
- ✓ GetPackages 接口支持分页(page/pageSize/total)— POLL-03
|
||||
- ✓ PackageSeries 删除前检查关联套餐(CountBySeriesID)— POLL-04
|
||||
|
||||
**Validated in Phase 1: P0 紧急修复**
|
||||
- ✓ 实名状态常量统一(DB 写入值统一为 constants.RealNameStatusVerified=1)— CRITICAL-01
|
||||
- ✓ C 端实名校验修复(依赖常量统一)— CRITICAL-02
|
||||
@@ -57,14 +68,14 @@
|
||||
- [x] A-6:设备导入补充 IMEI 字段(DeviceRow 缺 IMEI,导入后 Gateway 无法调用)
|
||||
- [x] A-7:提现冻结并发校验(只检查 Error 不检查 RowsAffected,并发创建重复提现单)
|
||||
|
||||
**方案 B — 企业端权限完善**
|
||||
- [ ] B-1:Resolve 接口错误拦截企业账号(应可查,需移除拦截)
|
||||
- [ ] B-2:Refresh 接口缺少企业账号拦截(应只读)
|
||||
**方案 B — 企业端权限完善** ✅ *Validated in Phase 2*
|
||||
- [x] B-1:Resolve 接口错误拦截企业账号(应可查,需移除拦截)
|
||||
- [x] B-2:Refresh 接口缺少企业账号拦截(应只读)
|
||||
|
||||
**方案 C — 提现/佣金流程完善**
|
||||
- [ ] C-1:审批人字段补全(approved_by/approved_at 永远为空)
|
||||
- [ ] C-2:提现拒绝 remark 必填校验(Reject 缺少 Validate 调用)
|
||||
- [ ] C-3:激活配置并发保护(两步 UPDATE 无锁,可出现两条 active=true)
|
||||
**方案 C — 提现/佣金流程完善** ✅ *Validated in Phase 2*
|
||||
- [x] C-1:审批人字段补全(approved_by/approved_at 永远为空)
|
||||
- [x] C-2:提现拒绝 remark 必填校验(Reject 缺少 Validate 调用)
|
||||
- [x] C-3:激活配置并发保护(两步 UPDATE 无锁,可出现两条 active=true)
|
||||
|
||||
**方案 D — 设备体系完善**
|
||||
- [ ] D-0:设备模型字段扩展(online_status / last_online_time / software_version 等)
|
||||
@@ -85,14 +96,14 @@
|
||||
- [ ] F-5:Handler/Service 权限不一致修复
|
||||
- [ ] F-6:StopResumeCallback / ResumeCallback 注入(bootstrap 未调用)
|
||||
|
||||
**方案 G — 实名激活架构重构**
|
||||
- [ ] G:enable_realname_activation 字段拆分为 expiry_base(from_activation/from_purchase)
|
||||
**方案 G — 实名激活架构重构** ✅ *Validated in Phase 2*
|
||||
- [x] G:enable_realname_activation 字段拆分为 expiry_base(from_activation/from_purchase)
|
||||
|
||||
**方案 H — 轮询系统小修**
|
||||
- [ ] H-1:polling:protect 接入调度(已注册未调度)
|
||||
- [ ] H-2:gatewayClient=nil 时停复机应返回错误(而非假装成功)
|
||||
- [ ] H-3:packages 接口加分页
|
||||
- [ ] H-4:Series 删除前检查关联套餐
|
||||
**方案 H — 轮询系统小修** ✅ *Validated in Phase 2*
|
||||
- [x] H-1:polling:protect 接入调度(已注册未调度)
|
||||
- [x] H-2:gatewayClient=nil 时停复机应返回错误(而非假装成功)
|
||||
- [x] H-3:packages 接口加分页
|
||||
- [x] H-4:Series 删除前检查关联套餐
|
||||
|
||||
**方案 I — 退款完整功能(新功能)**
|
||||
- [ ] I-1:新建 tb_refund_request 数据模型
|
||||
@@ -146,7 +157,7 @@
|
||||
| 流量体系改革独立发布 | 破坏性变更(DB 迁移 + Redis 架构),风险隔离 | — Pending |
|
||||
|
||||
---
|
||||
*Last updated: 2026-03-28 after Phase 1 (P0 紧急修复) completion — CRITICAL-01~08 all validated*
|
||||
*Last updated: 2026-03-28 after Phase 2 (perm-fin-realname-poll) completion — PERM-01/02, FIN-01/02/03, REALNAME-01~05, POLL-01~04 all validated*
|
||||
|
||||
## Evolution
|
||||
|
||||
|
||||
@@ -21,29 +21,29 @@
|
||||
|
||||
### PERM — 企业端权限完善(方案 B)
|
||||
|
||||
- [ ] **PERM-01**: 移除 Resolve 接口对企业账号的错误拦截 — 企业账号应能查询被授权的资产
|
||||
- [ ] **PERM-02**: Refresh 接口新增企业账号拦截 — 企业只读,不允许主动触发运营商刷新
|
||||
- [x] **PERM-01**: 移除 Resolve 接口对企业账号的错误拦截 — 企业账号应能查询被授权的资产
|
||||
- [x] **PERM-02**: Refresh 接口新增企业账号拦截 — 企业只读,不允许主动触发运营商刷新
|
||||
|
||||
### FIN — 财务/提现流程完善(方案 C)
|
||||
|
||||
- [ ] **FIN-01**: 提现审批人字段补全 — `Approve()` 写入 `approved_by` / `approved_at`(当前永远为空)
|
||||
- [ ] **FIN-02**: 提现拒绝 remark 必填校验 — `Reject()` 在 BodyParser 后补充 `validator.Validate()`
|
||||
- [ ] **FIN-03**: 激活配置并发保护 — 提现配置和微信配置激活时,两步 UPDATE 前加 `FOR UPDATE` 行锁
|
||||
- [x] **FIN-01**: 提现审批人字段补全 — `Approve()` 写入 `approved_by` / `approved_at`(当前永远为空)
|
||||
- [x] **FIN-02**: 提现拒绝 remark 必填校验 — `Reject()` 在 BodyParser 后补充 `validator.Validate()`
|
||||
- [x] **FIN-03**: 激活配置并发保护 — 提现配置和微信配置激活时,两步 UPDATE 前加 `FOR UPDATE` 行锁
|
||||
|
||||
### REALNAME — 实名激活架构重构(方案 G)
|
||||
|
||||
- [ ] **REALNAME-01**: 移除 `tb_package.enable_realname_activation` 字段,新增 `expiry_base VARCHAR(30)` 字段(`from_activation` / `from_purchase`)
|
||||
- [ ] **REALNAME-02**: `activateMainPackage` 改写激活决策逻辑 — 按卡类型(industry/normal)+ 购买路径(C端/后台囤货)+ `expiry_base` 三维决策
|
||||
- [ ] **REALNAME-03**: `client_order/service.go` 实名检查改为按卡类型判断(`card_category == "normal"`),删除 `packagesNeedRealname()` 函数
|
||||
- [ ] **REALNAME-04**: `ActivateByRealname()` 按 `ExpiryBase` 选择激活时间基准(`from_purchase` 用 CreatedAt,`from_activation` 用当前时刻)
|
||||
- [ ] **REALNAME-05**: 套餐管理 API DTO 更新 — 移除 `enable_realname_activation`,新增 `expiry_base` 枚举字段
|
||||
- [x] **REALNAME-01**: 移除 `tb_package.enable_realname_activation` 字段,新增 `expiry_base VARCHAR(30)` 字段(`from_activation` / `from_purchase`)
|
||||
- [x] **REALNAME-02**: `activateMainPackage` 改写激活决策逻辑 — 按卡类型(industry/normal)+ 购买路径(C端/后台囤货)+ `expiry_base` 三维决策
|
||||
- [x] **REALNAME-03**: `client_order/service.go` 实名检查改为按卡类型判断(`card_category == "normal"`),删除 `packagesNeedRealname()` 函数
|
||||
- [x] **REALNAME-04**: `ActivateByRealname()` 按 `ExpiryBase` 选择激活时间基准(`from_purchase` 用 CreatedAt,`from_activation` 用当前时刻)
|
||||
- [x] **REALNAME-05**: 套餐管理 API DTO 更新 — 移除 `enable_realname_activation`,新增 `expiry_base` 枚举字段
|
||||
|
||||
### POLL — 轮询系统小修(方案 H)
|
||||
|
||||
- [ ] **POLL-01**: `polling:protect` 接入调度 — `scheduler.go` 补充 processManualQueue + processTimedQueue 对 protect 任务的调度
|
||||
- [ ] **POLL-02**: gatewayClient=nil 时停复机返回错误 — `stopCardWithRetry` 和 `resumeCardWithRetry` 两处改为返回业务错误而非 nil
|
||||
- [ ] **POLL-03**: packages 接口新增分页 — `GetPackages()` 默认 page=1, pageSize=50,最大 100
|
||||
- [ ] **POLL-04**: Series 删除前检查关联套餐 — `package_series/service.go:Delete()` 前置 `CountBySeriesID()` 检查
|
||||
- [x] **POLL-01**: `polling:protect` 接入调度 — `scheduler.go` 补充 processManualQueue + processTimedQueue 对 protect 任务的调度
|
||||
- [x] **POLL-02**: gatewayClient=nil 时停复机返回错误 — `stopCardWithRetry` 和 `resumeCardWithRetry` 两处改为返回业务错误而非 nil
|
||||
- [x] **POLL-03**: packages 接口新增分页 — `GetPackages()` 默认 page=1, pageSize=50,最大 100
|
||||
- [x] **POLL-04**: Series 删除前检查关联套餐 — `package_series/service.go:Delete()` 前置 `CountBySeriesID()` 检查
|
||||
|
||||
### DEVICE — 设备体系完善(方案 D)
|
||||
|
||||
@@ -128,20 +128,20 @@
|
||||
| CRITICAL-06 | Phase 1 | Complete |
|
||||
| CRITICAL-07 | Phase 1 | Complete |
|
||||
| CRITICAL-08 | Phase 1 | Complete |
|
||||
| PERM-01 | Phase 2 | Pending |
|
||||
| PERM-02 | Phase 2 | Pending |
|
||||
| FIN-01 | Phase 2 | Pending |
|
||||
| FIN-02 | Phase 2 | Pending |
|
||||
| FIN-03 | Phase 2 | Pending |
|
||||
| REALNAME-01 | Phase 2 | Pending |
|
||||
| REALNAME-02 | Phase 2 | Pending |
|
||||
| REALNAME-03 | Phase 2 | Pending |
|
||||
| REALNAME-04 | Phase 2 | Pending |
|
||||
| REALNAME-05 | Phase 2 | Pending |
|
||||
| POLL-01 | Phase 2 | Pending |
|
||||
| POLL-02 | Phase 2 | Pending |
|
||||
| POLL-03 | Phase 2 | Pending |
|
||||
| POLL-04 | Phase 2 | Pending |
|
||||
| PERM-01 | Phase 2 | Complete |
|
||||
| PERM-02 | Phase 2 | Complete |
|
||||
| FIN-01 | Phase 2 | Complete |
|
||||
| FIN-02 | Phase 2 | Complete |
|
||||
| FIN-03 | Phase 2 | Complete |
|
||||
| REALNAME-01 | Phase 2 | Complete |
|
||||
| REALNAME-02 | Phase 2 | Complete |
|
||||
| REALNAME-03 | Phase 2 | Complete |
|
||||
| REALNAME-04 | Phase 2 | Complete |
|
||||
| REALNAME-05 | Phase 2 | Complete |
|
||||
| POLL-01 | Phase 2 | Complete |
|
||||
| POLL-02 | Phase 2 | Complete |
|
||||
| POLL-03 | Phase 2 | Complete |
|
||||
| POLL-04 | Phase 2 | Complete |
|
||||
| DEVICE-01 | Phase 3 | Pending |
|
||||
| DEVICE-02 | Phase 3 | Pending |
|
||||
| DEVICE-03 | Phase 3 | Pending |
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
## Phases
|
||||
|
||||
- [x] **Phase 1: P0 紧急修复** — 修复所有阻塞主链路的 P0 Bug,使充值→购包→佣金→实名激活全链路可跑通 (completed 2026-03-27)
|
||||
- [ ] **Phase 2: 权限/财务/实名/轮询修复** — 补全企业权限、提现财务流程、实名激活架构重构、轮询系统小修
|
||||
- [x] **Phase 2: 权限/财务/实名/轮询修复** — 补全企业权限、提现财务流程、实名激活架构重构、轮询系统小修 (completed 2026-03-28)
|
||||
- [ ] **Phase 3: 设备体系完善** — 扩展设备模型字段、对接 Gateway sync-info、完善绑卡标识
|
||||
- [ ] **Phase 4: 退款 + 支付 + 运营修复** — 实现退款完整流程、补全富友支付接口、修复运营逻辑
|
||||
- [ ] **Phase 5: 代码质量清理** — 消除技术债务:佣金断链可观测、删废弃代码、修复权限不一致、迁移分层
|
||||
@@ -53,7 +53,11 @@ Plans:
|
||||
4. 数据库中 tb_package 表不再有 enable_realname_activation 字段,改为 expiry_base 字段(值为 from_activation 或 from_purchase)
|
||||
5. C 端购买普通号卡套餐时,实名检查按卡类型(card_category == "normal")判断,逻辑简洁正确
|
||||
6. polling:protect 任务正确进入调度器,停复机时若 gatewayClient 为 nil 返回业务错误而非静默成功
|
||||
**Plans**: TBD
|
||||
**Plans**: 2 plans
|
||||
|
||||
Plans:
|
||||
- [x] 02-01-PLAN.md — PERM-01/02 + FIN-01/02/03 + POLL-01/02/03/04:权限/财务/轮询小修合并(Wave 1)
|
||||
- [x] 02-02-PLAN.md — REALNAME-01~05:实名激活架构重构,原子执行(Wave 1,parallel)
|
||||
|
||||
---
|
||||
|
||||
@@ -119,11 +123,11 @@ Plans:
|
||||
| Phase | Plans Complete | Status | Completed |
|
||||
|-------|----------------|--------|-----------|
|
||||
| 1. P0 紧急修复 | 5/5 | Complete | 2026-03-27 |
|
||||
| 2. 权限/财务/实名/轮询修复 | 0/1 | Not started | - |
|
||||
| 3. 设备体系完善 | 0/1 | Not started | - |
|
||||
| 4. 退款 + 支付 + 运营修复 | 0/1 | Not started | - |
|
||||
| 5. 代码质量清理 | 0/1 | Not started | - |
|
||||
| 6. 流量体系改革(低峰期)| 0/1 | Not started | - |
|
||||
| 2. 权限/财务/实名/轮询修复 | 2/2 | Complete | 2026-03-28 |
|
||||
| 3. 设备体系完善 | 0/TBD | Not started | - |
|
||||
| 4. 退款 + 支付 + 运营修复 | 0/TBD | Not started | - |
|
||||
| 5. 代码质量清理 | 0/TBD | Not started | - |
|
||||
| 6. 流量体系改革(低峰期)| 0/TBD | Not started | - |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -2,38 +2,36 @@
|
||||
gsd_state_version: 1.0
|
||||
milestone: v1.0
|
||||
milestone_name: milestone
|
||||
status: unknown
|
||||
last_updated: "2026-03-28T01:47:47.024Z"
|
||||
status: active
|
||||
last_updated: "2026-03-28T02:52:41.931Z"
|
||||
progress:
|
||||
total_phases: 6
|
||||
completed_phases: 1
|
||||
total_plans: 5
|
||||
completed_plans: 5
|
||||
completed_phases: 2
|
||||
total_plans: 7
|
||||
completed_plans: 7
|
||||
---
|
||||
|
||||
# Project State — 君鸿卡管系统
|
||||
|
||||
**Project:** 君鸿卡管系统 v1.0
|
||||
**Milestone:** v1.0 — MVP 上线准备
|
||||
**Last Updated:** 2026-03-27
|
||||
**Last Updated:** 2026-03-28
|
||||
|
||||
---
|
||||
|
||||
## Current Position
|
||||
|
||||
Phase: 2
|
||||
Plan: Not started
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Current Phase** | 1 — P0 紧急修复 |
|
||||
| **Current Plan** | None (planning phase) |
|
||||
| **Status** | Planning |
|
||||
| **Phase Progress** | 0/8 requirements complete |
|
||||
| **Current Phase** | 3 — 设备体系完善 |
|
||||
| **Current Plan** | None (awaiting planning) |
|
||||
| **Status** | Ready to plan Phase 3 |
|
||||
| **Phase Progress** | 0/4 requirements complete |
|
||||
|
||||
```
|
||||
Progress: [ 1 ]──[ 2 ]──[ 3 ]──[ 4 ]──[ 5 ]──[ 6 ]
|
||||
▲
|
||||
Planning
|
||||
✓ ✓ ▲
|
||||
Next
|
||||
```
|
||||
|
||||
---
|
||||
@@ -42,7 +40,7 @@ Progress: [ 1 ]──[ 2 ]──[ 3 ]──[ 4 ]──[ 5 ]──[ 6 ]
|
||||
|
||||
**Core Value:** 业务链路完整可用——代理能采购分配卡/设备,C端客户能充值购包激活套餐,佣金能正确计算并可提现。这条主链路必须端到端跑通。
|
||||
|
||||
**Current Focus:** Phase 01 — p0
|
||||
**Current Focus:** Phase 03 — 设备体系完善
|
||||
|
||||
---
|
||||
|
||||
@@ -50,12 +48,12 @@ Progress: [ 1 ]──[ 2 ]──[ 3 ]──[ 4 ]──[ 5 ]──[ 6 ]
|
||||
|
||||
| Phase | Name | Requirements | Status |
|
||||
|-------|------|--------------|--------|
|
||||
| 1 | P0 紧急修复 | CRITICAL-01~08(8项) | Not started |
|
||||
| 2 | 权限/财务/实名/轮询修复 | PERM-01~02, FIN-01~03, REALNAME-01~05, POLL-01~04(14项) | Not started |
|
||||
| 3 | 设备体系完善 | DEVICE-01~04(4项) | Not started |
|
||||
| 4 | 退款 + 支付 + 运营修复 | REFUND-01~03, PAY-01~04, OPS-01~02(9项) | Not started |
|
||||
| 5 | 代码质量清理 | CLEAN-01~06(6项) | Not started |
|
||||
| 6 | 流量体系改革(低峰期)| TRAFFIC-01~05(5项) | Not started |
|
||||
| 1 | P0 紧急修复 | CRITICAL-01~08(8项) | ✓ Complete (2026-03-28) |
|
||||
| 2 | 权限/财务/实名/轮询修复 | PERM-01~02, FIN-01~03, REALNAME-01~05, POLL-01~04(14项) | ✓ Complete (2026-03-28) |
|
||||
| 3 | 设备体系完善 | DEVICE-01~04(4项) | ○ Not started |
|
||||
| 4 | 退款 + 支付 + 运营修复 | REFUND-01~03, PAY-01~04, OPS-01~02(9项) | ○ Not started |
|
||||
| 5 | 代码质量清理 | CLEAN-01~06(6项) | ○ Not started |
|
||||
| 6 | 流量体系改革(低峰期)| TRAFFIC-01~05(5项) | ○ Not started |
|
||||
|
||||
---
|
||||
|
||||
@@ -66,16 +64,25 @@ Progress: [ 1 ]──[ 2 ]──[ 3 ]──[ 4 ]──[ 5 ]──[ 6 ]
|
||||
| Phases total | 6 |
|
||||
| Requirements v1 | 46 |
|
||||
| Requirements mapped | 46/46 (100%) |
|
||||
| Plans created | 0 |
|
||||
| Plans complete | 0 |
|
||||
| Phases complete | 0/6 |
|
||||
| Plans created | 7 |
|
||||
| Plans complete | 7/7 |
|
||||
| Phases complete | 2/6 |
|
||||
|
||||
---
|
||||
|
||||
## Execution Log
|
||||
|
||||
| Plan | Duration | Tasks | Files |
|
||||
|------|----------|-------|-------|
|
||||
| Phase 01 P01 | 15min | 2 tasks | 7 files |
|
||||
| Phase 01 P02 | 3min | 1 task | 2 files |
|
||||
| Phase 01 P03 | 8min | 2 tasks | 4 files |
|
||||
| Phase 01 P04 | 4min | 1 task | 1 file |
|
||||
| Phase 01 P05 | 8min | 2 tasks | 3 files |
|
||||
| Phase 02 P01 | 10min | 2 tasks | 14 files |
|
||||
| Phase 02 P02 | 25min | 2 tasks | 8 files |
|
||||
|
||||
---
|
||||
| Phase 01-p0 P05 | 8 | 2 tasks | 3 files |
|
||||
| Phase 01-p0 P01 | 15 | 2 tasks | 7 files |
|
||||
| Phase 01-p0 P04 | 4min | 1 tasks | 1 files |
|
||||
| Phase 01-p0 P02 | 3min | 1 tasks | 2 files |
|
||||
| Phase 01-p0 P03 | 8min | 2 tasks | 4 files |
|
||||
|
||||
## Accumulated Context
|
||||
|
||||
@@ -91,21 +98,23 @@ Progress: [ 1 ]──[ 2 ]──[ 3 ]──[ 4 ]──[ 5 ]──[ 6 ]
|
||||
### Architecture Notes
|
||||
|
||||
- 双进程(API + Worker),依赖集中在 bootstrap 装配
|
||||
- PollingHandler 缺 asynqClient(CRITICAL-03 修复),AutoPurchaseHandler 未注册(CRITICAL-04 修复)
|
||||
- StopResumeCallback / ResumeCallback 未注入(CLEAN-06 修复)
|
||||
- 数据权限:Store 层显式调用 ApplyShopFilter / ApplyEnterpriseFilter(非 GORM Callback)
|
||||
- StopResumeCallback / ResumeCallback 未注入 bootstrap(CLEAN-06 修复)
|
||||
- 流量体系当前用直接覆盖写,Phase 6 改为增量累加 + 日粒度 DB
|
||||
- expiry_base 枚举已替换 enable_realname_activation,激活决策为三维(卡类型+购买路径+expiry_base)
|
||||
|
||||
### Known Risks
|
||||
|
||||
- sellerCostPrice 错误赋值可能不止场景 5(CRITICAL-06 需逐一检查 6 处)
|
||||
- sellerCostPrice 错误赋值可能不止场景 5(CRITICAL-06 已处理 6 处)
|
||||
- 分佣树形遍历无循环检测(不在当前 scope,v2 考量)
|
||||
- 店铺软删除无级联检查(v2 考量)
|
||||
- pkg/openapi/handlers.go CommissionWithdrawalHandler 签名已在 02-01 中同步修复
|
||||
|
||||
---
|
||||
|
||||
## Session Continuity
|
||||
|
||||
**Next Action:** Run `/gsd-plan-phase 1` to create detailed plan for Phase 1 (P0 紧急修复)
|
||||
**Next Action:** Run `/gsd-plan-phase 3` to create detailed plan for Phase 3 (设备体系完善)
|
||||
|
||||
**Context Files:**
|
||||
|
||||
@@ -116,4 +125,4 @@ Progress: [ 1 ]──[ 2 ]──[ 3 ]──[ 4 ]──[ 5 ]──[ 6 ]
|
||||
- `.sisyphus/plans/修正业务-完整方案.md` — 每个 Bug 的完整修复规格(含代码片段)
|
||||
|
||||
---
|
||||
*State initialized: 2026-03-27*
|
||||
*State initialized: 2026-03-27 | Last phase completed: Phase 2 (2026-03-28)*
|
||||
|
||||
179
.planning/codebase/ARCHITECTURE.md
Normal file
179
.planning/codebase/ARCHITECTURE.md
Normal file
@@ -0,0 +1,179 @@
|
||||
# Architecture
|
||||
|
||||
**Analysis Date:** 2026-03-27
|
||||
|
||||
## Pattern Overview
|
||||
|
||||
**Overall:** 双进程分层单体(API + Worker),核心代码按 `Handler → Service → Store → Model` 组织。
|
||||
|
||||
**Key Characteristics:**
|
||||
- HTTP 入口与异步任务入口分离:`cmd/api/main.go` 负责 Fiber API,`cmd/worker/main.go` 负责 Asynq Worker 与轮询调度。
|
||||
- 依赖集中在 `internal/bootstrap/` 组装,再分发给 `internal/routes/`、`internal/handler/`、`internal/task/`。
|
||||
- 数据访问主要走 `internal/store/postgres/*.go`,模型与 DTO 分别位于 `internal/model/*.go` 与 `internal/model/dto/*.go`。
|
||||
- 路由注册统一经过 `internal/routes/registry.go` 的 `Register()`,同时驱动 Fiber 路由与 OpenAPI 元数据。
|
||||
- 多租户与权限控制主要依赖认证上下文 + Store 层显式过滤函数,而不是数据库外键或 ORM 关联。
|
||||
|
||||
## Layers
|
||||
|
||||
**Entrypoint / Composition Layer:**
|
||||
- Purpose: 进程启动、基础设施初始化、依赖装配、生命周期管理。
|
||||
- Location: `cmd/api/main.go`, `cmd/worker/main.go`, `internal/bootstrap/*.go`
|
||||
- Contains: 配置加载、日志、数据库、Redis、队列、对象存储、Gateway 客户端、Bootstrap 编排。
|
||||
- Depends on: `pkg/config`, `pkg/database`, `pkg/logger`, `pkg/queue`, `pkg/storage`, `internal/bootstrap`
|
||||
- Used by: 整个应用进程。
|
||||
|
||||
**Routing / Transport Layer:**
|
||||
- Purpose: 路由域划分、认证挂载、HTTP 入参与响应封装、OpenAPI 文档注册。
|
||||
- Location: `internal/routes/*.go`, `internal/handler/**/*.go`
|
||||
- Contains: `RegisterRoutesWithDoc()` 总入口、Admin/Auth/Personal 域路由、Fiber Handler。
|
||||
- Depends on: `internal/bootstrap.Handlers`, `internal/bootstrap.Middlewares`, `pkg/openapi`, `pkg/response`
|
||||
- Used by: `cmd/api/main.go`
|
||||
|
||||
**Service Layer:**
|
||||
- Purpose: 业务规则、权限判断、事务编排、幂等、异步任务提交、跨模块协作。
|
||||
- Location: `internal/service/**/*.go`
|
||||
- Contains: 账号、订单、套餐、轮询、设备、卡、认证、充值、佣金等服务。
|
||||
- Depends on: `internal/store/postgres`, `pkg/errors`, `pkg/middleware`, `pkg/queue`, `pkg/wechat`, `gorm.DB`, `redis.Client`
|
||||
- Used by: Handler 层、Worker Bootstrap、Task Handler。
|
||||
|
||||
**Store Layer:**
|
||||
- Purpose: SQL 查询、分页、显式数据权限过滤、缓存辅助、事务基础能力。
|
||||
- Location: `internal/store/store.go`, `internal/store/options.go`, `internal/store/postgres/*.go`
|
||||
- Contains: 每个聚合的 PostgreSQL Store,例如 `internal/store/postgres/account_store.go`, `internal/store/postgres/order_store.go`。
|
||||
- Depends on: `gorm`, `redis`, `pkg/middleware/data_scope.go`
|
||||
- Used by: Service 层、部分 Handler 组装期专用读模型。
|
||||
|
||||
**Model Layer:**
|
||||
- Purpose: 持久化模型、DTO、常量化状态值、表名映射。
|
||||
- Location: `internal/model/*.go`, `internal/model/dto/*.go`
|
||||
- Contains: `model.Account`, `model.Order`, `model.Shop`, `model.PollingConfig` 以及请求/响应 DTO。
|
||||
- Depends on: GORM tags、标准库类型。
|
||||
- Used by: Store、Service、Handler、OpenAPI 生成。
|
||||
|
||||
**Task / Worker Layer:**
|
||||
- Purpose: Asynq 任务处理、轮询调度、定时任务、批处理导入。
|
||||
- Location: `internal/task/*.go`, `internal/polling/*.go`, `pkg/queue/*.go`
|
||||
- Contains: `pkg/queue/handler.go` 任务注册器、`internal/task/polling_handler.go`、`internal/task/order_expire.go`、`internal/polling/scheduler.go`。
|
||||
- Depends on: Worker bootstrap 产出的 stores/services、Redis、Asynq、Gateway。
|
||||
- Used by: `cmd/worker/main.go`
|
||||
|
||||
## Data Flow
|
||||
|
||||
**API Request Flow:**
|
||||
|
||||
1. `cmd/api/main.go` 创建 Fiber 应用并注册 Recover、RequestID、访问日志、压缩中间件。
|
||||
2. `internal/routes/routes.go` 将请求分发到 `/api/auth`、`/api/admin`、`/api/c/v1`、`/api/callback` 四个域。
|
||||
3. `pkg/middleware/auth.go` 或 `internal/middleware/personal_auth` 将用户信息写入 Fiber `Locals` 和标准 `context.Context`。
|
||||
4. Handler 解析 DTO 并调用 Service,例如 `internal/handler/admin/account.go` → `internal/service/account/service.go`。
|
||||
5. Service 调用一个或多个 Store,必要时使用 `db.WithContext(ctx).Transaction(...)` 编排事务,例如 `internal/service/order/service.go`。
|
||||
6. Store 在查询中显式调用 `pkg/middleware/data_scope.go` 的过滤函数,例如 `internal/store/postgres/account_store.go`、`internal/store/postgres/order_store.go`。
|
||||
7. Handler 通过 `pkg/response/response.go` 返回统一响应;异常交由 `internal/middleware.ErrorHandler` 处理。
|
||||
|
||||
**Worker Flow:**
|
||||
|
||||
1. `cmd/worker/main.go` 初始化 PostgreSQL、Redis、Asynq Client、对象存储、Gateway。
|
||||
2. `internal/bootstrap/worker.go` 调用 `initWorkerStores()` 与 `initWorkerServices()`,生成 `queue.WorkerBootstrapResult`。
|
||||
3. `pkg/queue/handler.go` 基于 Bootstrap 结果注册导入、轮询、佣金、超时取消、告警、清理等任务处理器。
|
||||
4. `internal/polling/scheduler.go` 独立运行轮询调度循环:从 Redis Sorted Set / List 取卡片,再投递到 Asynq。
|
||||
5. `cmd/worker/main.go` 还额外创建 Asynq Scheduler,注册 `TaskTypeOrderExpire`、`TaskTypeAlertCheck`、`TaskTypeDataCleanup`。
|
||||
6. 具体任务处理器调用 Service 或直接使用 Store/DB 更新状态,例如 `internal/task/order_expire.go` → `order.Service.CancelExpiredOrders()`。
|
||||
|
||||
**State Management:**
|
||||
- HTTP 侧状态主要放在 PostgreSQL;请求态身份与数据权限范围放在 `context.Context`。
|
||||
- 高速状态、Token、轮询队列、并发信号量、下级店铺缓存放在 Redis,如 `pkg/auth/token.go`、`internal/polling/scheduler.go`、`internal/store/postgres/shop_store.go`。
|
||||
- 异步处理采用 Redis-backed Asynq 队列,由 `pkg/queue/client.go` 提交、`pkg/queue/server.go` 消费。
|
||||
|
||||
## Key Abstractions
|
||||
|
||||
**BootstrapResult / WorkerBootstrapResult:**
|
||||
- Purpose: 启动期依赖编排结果。
|
||||
- Examples: `internal/bootstrap/bootstrap.go`, `internal/bootstrap/worker.go`
|
||||
- Pattern: 显式依赖注入,不使用外部 DI 框架。
|
||||
|
||||
**Handlers / Middlewares 容器:**
|
||||
- Purpose: 路由注册时的装配清单。
|
||||
- Examples: `internal/bootstrap/types.go`, `internal/bootstrap/handlers.go`, `internal/bootstrap/middlewares.go`
|
||||
- Pattern: 结构体聚合所有已构造的 Handler 与 Middleware。
|
||||
|
||||
**RouteSpec + Register():**
|
||||
- Purpose: 单次声明同时完成真实路由注册和 OpenAPI 元数据注册。
|
||||
- Examples: `internal/routes/registry.go`, `internal/routes/account.go`, `internal/routes/personal.go`
|
||||
- Pattern: 代码即文档,DTO 驱动文档生成。
|
||||
|
||||
**QueryOptions:**
|
||||
- Purpose: Store 通用分页与排序选项。
|
||||
- Examples: `internal/store/options.go`, `internal/service/account/service.go`, `internal/service/order/service.go`
|
||||
- Pattern: Service 先构造查询选项,再下传到 Store。
|
||||
|
||||
**UserContextInfo + Data Scope Filters:**
|
||||
- Purpose: 承载用户身份与可管理店铺范围,并在 Store 查询时执行过滤。
|
||||
- Examples: `pkg/middleware/auth.go`, `pkg/middleware/data_scope.go`, `internal/store/postgres/shop_store.go`
|
||||
- Pattern: 中间件预计算 `SubordinateShopIDs`,Store 按字段类型显式调用 `ApplyShopFilter` / `ApplyEnterpriseFilter` / `ApplySellerShopFilter`。
|
||||
|
||||
## Entry Points
|
||||
|
||||
**API Main:**
|
||||
- Location: `cmd/api/main.go`
|
||||
- Triggers: `go run cmd/api/main.go`、API 容器启动。
|
||||
- Responsibilities: 加载配置、初始化基础设施、执行 `bootstrap.Bootstrap()`、创建 Fiber、注册中间件和路由、生成运行时 OpenAPI 文件 `logs/openapi.yaml`。
|
||||
|
||||
**Worker Main:**
|
||||
- Location: `cmd/worker/main.go`
|
||||
- Triggers: `go run cmd/worker/main.go`、Worker 容器启动。
|
||||
- Responsibilities: 初始化 Worker 依赖、执行 `bootstrap.BootstrapWorker()`、启动 Asynq Worker、启动轮询调度器和 Asynq Scheduler、优雅关闭。
|
||||
|
||||
**OpenAPI Generation CLI:**
|
||||
- Location: `cmd/gendocs/main.go`
|
||||
- Triggers: 手动执行文档生成。
|
||||
- Responsibilities: 通过 `openapi.BuildDocHandlers()` + `routes.RegisterRoutesWithDoc()` 输出 `docs/admin-openapi.yaml`。
|
||||
|
||||
## Routing Strategy
|
||||
|
||||
**Domain Split:**
|
||||
- `/api/auth`:统一后台/H5 认证,见 `internal/routes/auth.go`
|
||||
- `/api/admin`:管理后台业务域,见 `internal/routes/admin.go`
|
||||
- `/api/c/v1`:个人客户端域,见 `internal/routes/personal.go`
|
||||
- `/api/callback`:支付回调,无认证,见 `internal/routes/order.go` 与 `internal/handler/callback/payment.go`
|
||||
|
||||
**Registration Style:**
|
||||
- 所有 HTTP 接口使用 `internal/routes/registry.go` 的 `Register()`,文档规范见 `docs/api-documentation-guide.md`。
|
||||
- `internal/routes/admin.go` 以 handler 是否为 `nil` 决定是否挂载模块,便于文档生成和裁剪。
|
||||
- `internal/routes/personal.go` 明确区分公开路由与认证路由,并依赖注册顺序确保公开接口不被 `Use()` 拦截。
|
||||
|
||||
## Error Handling
|
||||
|
||||
**Strategy:** 统一错误码 + 全局 Fiber ErrorHandler。
|
||||
|
||||
**Patterns:**
|
||||
- Handler 层只做参数解析与转发,返回 `pkg/errors` 中的 `AppError`,如 `internal/handler/admin/account.go`。
|
||||
- Service 层封装业务错误,不直接向外泄露底层错误文本,如 `internal/service/account/service.go`、`internal/service/order/service.go`。
|
||||
- Worker 任务记录结构化日志,并将可重试与不可重试逻辑写在处理器内部,如 `internal/task/order_expire.go`、`internal/task/polling_handler.go`。
|
||||
|
||||
## Cross-Cutting Concerns
|
||||
|
||||
**Logging:**
|
||||
- `pkg/logger/logger.go` 初始化 App/Access 日志;`pkg/logger/middleware.go` 记录完整请求/响应摘要。
|
||||
|
||||
**Validation:**
|
||||
- Fiber `BodyParser` / `QueryParser` + `validator.v10`,示例见 `internal/handler/auth/handler.go`。
|
||||
|
||||
**Authentication:**
|
||||
- 后台/H5 使用 Redis Token + `pkg/middleware/auth.go`;个人客户使用单独中间件,由 `internal/bootstrap/middlewares.go` 创建。
|
||||
|
||||
**OpenAPI:**
|
||||
- 路由元数据写在 `RouteSpec`;文档生成器依赖 `cmd/api/docs.go`、`cmd/gendocs/main.go`、`pkg/openapi/handlers.go` 三处手工维护 Handler 清单。
|
||||
|
||||
**Persistence Rules:**
|
||||
- 模型显式声明 `TableName()` 与字段列名,如 `internal/model/account.go`, `internal/model/order.go`, `internal/model/shop.go`。
|
||||
- 数据库结构通过 `migrations/*.sql` 管理,`pkg/database/postgres.go` 明确禁用自动建表。
|
||||
|
||||
## Architectural Deviations
|
||||
|
||||
**README / 规范与当前实现存在的偏差:**
|
||||
- `README.md` 与 `openspec/config.yaml` 多处描述“GORM Callback 自动注入数据权限过滤”,但当前 `internal/bootstrap/bootstrap.go` 第 67 行明确写明“数据权限过滤已移至 Store 层显式调用 ApplyXxxFilter 函数”。当前保留的 GORM Callback 只有 `pkg/gorm/callback.go` 中的创建人/更新人自动填充。
|
||||
- `internal/bootstrap/handlers.go` 为客户端场景直接新建多个 Store 和一个 `client_order` Service,而不是完全只消费 `services` 聚合;这说明 Handler 装配阶段存在读模型/适配层级的额外拼装。
|
||||
- `internal/task/polling_handler.go` 在首次实名激活流程中先构造 Asynq 任务,但实际通过 Redis List `RPush` 提交,文件内注释明确写出“实际应该通过依赖注入 asynq.Client”;这是 Worker 流程中的特殊机制与待收敛点。
|
||||
|
||||
---
|
||||
|
||||
*Architecture analysis: 2026-03-27*
|
||||
173
.planning/codebase/CONCERNS.md
Normal file
173
.planning/codebase/CONCERNS.md
Normal file
@@ -0,0 +1,173 @@
|
||||
# Codebase Concerns
|
||||
|
||||
**Analysis Date:** 2026-03-27
|
||||
|
||||
## Tech Debt
|
||||
|
||||
**配置与凭据管理(严重级别:Critical):**
|
||||
- Issue: 仓库内存在已提交的生产/测试环境敏感配置与直连外部基础设施的写法,配置边界没有被代码、脚本和部署文件统一约束。
|
||||
- Files: `docker-compose.prod.yml`, `Makefile`, `pkg/config/defaults/config.yaml`, `scripts/verify_migration/main.go`, `scripts/verify_indexes/main.go`, `docs/deployment/deployment-guide.md`
|
||||
- Impact: 凭据轮换成本高;泄漏后影响数据库、Redis、对象存储、短信网关、Gateway 与部署链路;开发、测试、生产边界容易串用。
|
||||
- Supporting evidence:
|
||||
- `docker-compose.prod.yml` 直接写入数据库、Redis、JWT、对象存储、Gateway、短信服务配置。
|
||||
- `Makefile` 的 `DB_URL` 内嵌远程数据库连接串。
|
||||
- `pkg/config/defaults/config.yaml` 为 `gateway.app_id`、`gateway.app_secret` 提供非空默认值,服务在未显式覆盖时仍会初始化 Gateway 客户端,见 `cmd/api/main.go:366-383`。
|
||||
- `scripts/verify_migration/main.go` 与 `scripts/verify_indexes/main.go` 直接写死远程 PostgreSQL DSN。
|
||||
- `docs/deployment/deployment-guide.md` 把 Registry 凭据与数据库连接示例直接写进文档。
|
||||
- Fix approach: 把所有密钥迁移到部署平台 Secret/环境注入;默认配置只保留空值;脚本统一改为读取环境变量;把示例文档改成占位符而不是真实值。
|
||||
- Suggested follow-up investigation topics:
|
||||
- 盘点 `openspec/`, `docs/`, `scripts/` 中所有历史凭据残留。
|
||||
- 审查 Gitea Secrets、Docker Registry、数据库、Redis、对象存储、短信网关、Gateway 的轮换计划。
|
||||
- 检查 `.env`、`.env.local` 是否已在团队机器与 Runner 上扩散。
|
||||
|
||||
**支付与微信配置迁移未闭环(严重级别:High):**
|
||||
- Issue: 设计已经切到 `tb_wechat_config` + `payment_config_id`,但代码与文档仍保留环境变量和单例支付实现,处于半迁移状态。
|
||||
- Files: `docs/wechat-config-management/功能总结.md`, `internal/service/order/service.go`, `internal/service/recharge/service.go`, `internal/handler/callback/payment.go`, `docs/environment-variables.md`, `scripts/verify-wechat.sh`, `docker-compose.prod.yml`
|
||||
- Impact: 多支付配置切换、旧订单验签、客户端支付发起、OAuth 配置一致性存在行为不一致风险;运维很难判断哪些配置源仍然生效。
|
||||
- Supporting evidence:
|
||||
- `docs/wechat-config-management/功能总结.md:232-238` 明确写着客户端支付发起仍是留桩,OAuth 仍从环境变量读取,且旧 `JUNHONG_WECHAT_PAYMENT_*` “可清理”。
|
||||
- `internal/service/order/service.go:2107,2163,2356,2362` 与 `internal/service/recharge/service.go:271`、`internal/handler/callback/payment.go:66,143` 仍保留 TODO/留桩。
|
||||
- `docs/environment-variables.md:36-69` 与 `scripts/verify-wechat.sh` 仍把微信支付环境变量当成必填启动前置条件。
|
||||
- `docker-compose.prod.yml` 注释称“微信配置已迁移至数据库”,但部署文件仍维持部分旧式环境变量与其他支付相关外部配置模式。
|
||||
- Fix approach: 明确单一配置源;把“已迁移”“留桩”“仍依赖环境变量”的边界写入运维文档;在代码层补齐动态加载或删除旧入口。
|
||||
- Suggested follow-up investigation topics:
|
||||
- 核实当前线上 OAuth 与支付分别读哪一套配置。
|
||||
- 核查 `payment_config_id` 相关回调链路是否覆盖订单、资产充值、代理充值三条路径。
|
||||
- 为未实现的客户端支付入口建立显式禁用清单。
|
||||
|
||||
**核心服务体量过大(严重级别:High):**
|
||||
- Issue: 部分核心服务文件已经远超团队规范中的函数/文件规模要求,维护成本和回归风险高。
|
||||
- Files: `internal/service/order/service.go`, `AGENTS.md`
|
||||
- Impact: 改动难以局部验证;支付、幂等、库存/余额、回调逻辑耦合;新需求更容易引入回归。
|
||||
- Supporting evidence:
|
||||
- `AGENTS.md:371-375` 要求函数长度 ≤ 100 行,核心逻辑建议 ≤ 50 行。
|
||||
- `internal/service/order/service.go` 的 TODO 已出现在 2107、2163、2356、2362 行,说明文件至少超过 2362 行。
|
||||
- Fix approach: 按支付发起、支付回调、订单状态流转、幂等与锁、钱包扣款等职责拆分子服务/辅助组件。
|
||||
- Suggested follow-up investigation topics:
|
||||
- 统计 `internal/service/` 中超长文件与超长函数。
|
||||
- 识别最常改动的热点段落与共享依赖。
|
||||
|
||||
## Known Bugs
|
||||
|
||||
**健康检查接口对故障返回 200 成功包裹(严重级别:High):**
|
||||
- Symptoms: PostgreSQL 或 Redis 故障时,`/health` 仍通过 `response.Success` 返回成功包裹,只在 `data.status` 中标记 `degraded`。
|
||||
- Files: `internal/handler/health.go`, `docker-compose.prod.yml`, `Dockerfile.api`, `debug-deployment.sh`
|
||||
- Trigger: 数据库或 Redis Ping 失败。
|
||||
- Workaround: 监控端必须解析响应体里的 `data.status` 与 `services.*.status`,不能只看 HTTP 状态码。
|
||||
- Supporting evidence:
|
||||
- `internal/handler/health.go:101-111` 注释与实现都明确“端点本身能响应即视为成功”。
|
||||
- `docker-compose.prod.yml` 与 `Dockerfile.api` 的健康检查只执行 `wget --spider http://127.0.0.1:3000/health`,不会校验 JSON 内容。
|
||||
- Suggested follow-up investigation topics:
|
||||
- 确认当前容器健康检查、反向代理、告警系统是否只看 200。
|
||||
- 评估是否需要新增 `readyz`/`livez` 区分活性与依赖可用性。
|
||||
|
||||
**API 路径文档与实际路由不一致(严重级别:Medium):**
|
||||
- Symptoms: 文档仍混用 `/api/v1`、`/api/auth`、`/api/c/v1`,读者很难判断当前真实入口。
|
||||
- Files: `README.md`, `internal/routes/routes.go`, `scripts/check-comment-paths.sh`
|
||||
- Trigger: 新人按 README 试运行、按旧注释实现客户端、或依据文档回归接口。
|
||||
- Workaround: 以 `internal/routes/routes.go` 为准核对真实挂载路径。
|
||||
- Supporting evidence:
|
||||
- `internal/routes/routes.go:21-39` 当前真实路由为 `/api/auth`、`/api/admin`、`/api/c/v1`、`/api/callback`。
|
||||
- `README.md:577-583` 仍展示 `v1 := app.Group("/api/v1")` 的旧限流示例;`README.md:588-624` 仍以 `/api/v1/users` 讲解请求流。
|
||||
- `scripts/check-comment-paths.sh` 只检查 `internal/handler/` 中残留 `/api/v1`,并不覆盖 `README.md`、`docs/`、`openspec/`。
|
||||
- Suggested follow-up investigation topics:
|
||||
- 扫描所有 `docs/`, `README.md`, `openspec/`, `specs/` 中的旧路径。
|
||||
- 明确哪些旧路径仍需要兼容,哪些应统一下线。
|
||||
|
||||
## Security Considerations
|
||||
|
||||
**部署流水线绕过 TLS 校验(严重级别:High):**
|
||||
- Risk: 工作流检出步骤通过 `GIT_SSL_NO_VERIFY=1` 跳过证书校验,供应链完整性依赖内网信任而不是证书验证。
|
||||
- Files: `.gitea/workflows/deploy.yaml`
|
||||
- Current mitigation: 注释说明是“内网自签名证书”。
|
||||
- Recommendations: 为 Gitea/Registry 配置受信 CA;移除跳过校验;至少把跳过范围限制在明确的单一主机与受控 Runner。
|
||||
- Supporting evidence:
|
||||
- `.gitea/workflows/deploy.yaml:23-27` 设置 `GIT_SSL_NO_VERIFY=1` 后执行 `git clone`。
|
||||
|
||||
**对外/回调错误信息泄漏底层细节(严重级别:Medium):**
|
||||
- Risk: 健康检查与富友回调把底层错误或内部状态直接返回给调用方,不符合统一错误暴露规范。
|
||||
- Files: `internal/handler/health.go`, `internal/handler/callback/payment.go`, `AGENTS.md`
|
||||
- Current mitigation: 有统一错误处理规范,但没有覆盖这两个特殊出口。
|
||||
- Recommendations: 对外返回统一错误码/固定消息,把底层错误仅写日志。
|
||||
- Supporting evidence:
|
||||
- `AGENTS.md:124-129` 明确禁止直接暴露底层错误。
|
||||
- `internal/handler/health.go:50-60,82-85` 直接返回 `err.Error()`。
|
||||
- `internal/handler/callback/payment.go:178-191` 直接把 `err.Error()` 拼入富友回调失败响应。
|
||||
|
||||
## Performance Bottlenecks
|
||||
|
||||
**默认限流仍使用内存存储且默认关闭(严重级别:Medium):**
|
||||
- Problem: 默认配置既不开启限流,也默认使用 `memory` 存储;多实例部署时无法形成统一配额。
|
||||
- Files: `pkg/config/defaults/config.yaml`, `cmd/api/main.go`, `README.md`
|
||||
- Cause: 配置更偏向本地开发便利,缺少生产默认值与环境级约束。
|
||||
- Improvement path: 明确生产必须启用 Redis 存储限流;在部署文档里给出环境变量模板;增加启动日志或自检提示当前限流模式。
|
||||
- Supporting evidence:
|
||||
- `pkg/config/defaults/config.yaml:87-93` 默认 `enable_rate_limiter: false`、`storage: memory`。
|
||||
- `cmd/api/main.go:236-278` 只有显式开启时才挂载限流,并根据配置选择 Redis 或内存存储。
|
||||
|
||||
## Fragile Areas
|
||||
|
||||
**OpenAPI 文档生成依赖手工双点维护(严重级别:Medium):**
|
||||
- Files: `AGENTS.md`, `docs/api-documentation-guide.md`, `cmd/api/docs.go`, `cmd/gendocs/main.go`
|
||||
- Why fragile: 新增 Handler 必须同时更新两个文件;流程靠人工记忆,历史上已经出现清单不一致并专门做过修复。
|
||||
- Safe modification: 每次新增 Handler 时同时检查 `cmd/api/docs.go`、`cmd/gendocs/main.go` 与生成产物 `docs/admin-openapi.yaml`/`logs/openapi.yaml`。
|
||||
- Test coverage: 未发现自动化校验这两份清单一致性的 CI 步骤。
|
||||
- Supporting evidence:
|
||||
- `AGENTS.md:53-65` 明确要求手工更新两个文件。
|
||||
- `docs/api-documentation-guide.md` 多处把“忘记在两个文件中添加新 Handler”列为最常见问题。
|
||||
- `.gitea/workflows/deploy.yaml` 中未见 `go run cmd/gendocs/main.go`、diff 校验或文档一致性检查。
|
||||
|
||||
**部署脚本与运行时约定已漂移(严重级别:Medium):**
|
||||
- Files: `debug-deployment.sh`, `docker-compose.prod.yml`, `README.md`, `docs/deployment/deployment-guide.md`
|
||||
- Why fragile: 诊断脚本仍假定旧环境变量名与外置配置文件存在,文档与当前嵌入式配置实现不完全一致。
|
||||
- Safe modification: 先以 `docker-compose.prod.yml`、`pkg/config/defaults/config.yaml`、`cmd/api/main.go` 为准修正运维脚本,再统一更新部署文档。
|
||||
- Test coverage: 未发现对诊断脚本、部署文档、健康检查语义的自动验证。
|
||||
- Supporting evidence:
|
||||
- `debug-deployment.sh:52-57` 仍 grep `DB_|CONFIG_ENV`,并尝试读取 `/app/configs/config.yaml`;当前配置前缀实际是 `JUNHONG_`,配置默认嵌入二进制,见 `README.md:651-689`、`pkg/config/defaults/config.yaml`。
|
||||
- `docs/deployment/deployment-guide.md:302-305` 仍建议从 `/app/.env` 拼接迁移命令,但容器实际入口用环境变量构造 DSN,见 `docker/entrypoint-api.sh:8-17`。
|
||||
|
||||
## Scaling Limits
|
||||
|
||||
**CI/CD 质量闸门过窄(严重级别:High):**
|
||||
- Current capacity: 当前工作流只做 clone、build、push、main 分支部署。
|
||||
- Limit: 文档一致性、脚本检查、编译前规范、迁移安全、健康语义等问题都可能直接进入镜像与部署环境。
|
||||
- Scaling path: 在 `.gitea/workflows/deploy.yaml` 前置独立质量作业,至少执行 `bash scripts/check-all.sh`、`go build ./...`、OpenAPI 生成/校验,并在部署前验证 compose 文件与镜像健康策略。
|
||||
- Supporting evidence:
|
||||
- `README.md:941-957` 声称脚本检查会在 CI/CD 自动执行。
|
||||
- `.gitea/workflows/deploy.yaml` 未出现 `scripts/check-all.sh`、`go test`、`go vet`、`go build ./...`、OpenAPI 校验等步骤。
|
||||
|
||||
## Dependencies at Risk
|
||||
|
||||
**Go 版本与运行基础不统一(严重级别:Medium):**
|
||||
- Risk: 版本声明分散在多个位置,升级与排障时容易出现“本地能过、容器不一致”的问题。
|
||||
- Impact: 构建环境复现、依赖升级与 bug 排查成本上升。
|
||||
- Migration plan: 约定单一权威版本源(如 `go.mod` + `.tool-versions`/`.go-version`),并让 Dockerfile、README、部署文档同步引用。
|
||||
- Supporting evidence:
|
||||
- `go.mod:3` 使用 `go 1.25.0`。
|
||||
- `Dockerfile.api:4` 与 `Dockerfile.worker:4` 使用 `golang:1.25.6-alpine`。
|
||||
- `README.md:884` 写的是 `Go 1.25.1`。
|
||||
|
||||
## Missing Critical Features
|
||||
|
||||
**缺少可执行的自动化质量/回归入口(严重级别:High):**
|
||||
- Problem: README、历史设计与测试文档大量提到 `go test`、`IntegrationTestEnv`、`tests/integration/`,但仓库当前未发现对应测试文件或 `tests/` 目录。
|
||||
- Blocks: 无法快速验证重构、支付回调、权限过滤、部署脚本变更;文档中的测试命令无法直接落地。
|
||||
- Supporting evidence:
|
||||
- `AGENTS.md:324-354` 当前规范明确禁止自动化测试,和 `README.md:712-756`、`docs/testing/test-connection-guide.md` 的测试要求形成正面冲突。
|
||||
- 读取仓库根目录未发现 `tests/` 目录;`glob` 搜索 `tests/**/*_test.go` 与 `internal/**/*_test.go` 未返回结果。
|
||||
- `docs/testing/test-connection-guide.md` 仍宣称“204 个测试总耗时 ~10.5 秒”并要求所有新测试遵循该规范。
|
||||
|
||||
## Test Coverage Gaps
|
||||
|
||||
**部署、配置迁移、运维脚本与文档一致性未见自动验证(优先级:High):**
|
||||
- What's not tested: `docker-compose.prod.yml`、`debug-deployment.sh`、`scripts/migrate.sh`、OpenAPI 文档生成器双清单、README/部署文档与代码的同步性。
|
||||
- Files: `.gitea/workflows/deploy.yaml`, `docker-compose.prod.yml`, `debug-deployment.sh`, `scripts/migrate.sh`, `cmd/api/docs.go`, `cmd/gendocs/main.go`, `README.md`
|
||||
- Risk: 配置漂移、凭据泄漏、文档错误、健康检查误报、迁移行为变化都可能在上线后才暴露。
|
||||
- Priority: High
|
||||
- Suggested follow-up investigation topics:
|
||||
- 设计最小化“文档/脚本一致性检查”而不是恢复完整自动化测试体系。
|
||||
- 评估是否要为部署文件与文档生成引入静态检查或 smoke check。
|
||||
|
||||
---
|
||||
|
||||
*Concerns audit: 2026-03-27*
|
||||
168
.planning/codebase/CONVENTIONS.md
Normal file
168
.planning/codebase/CONVENTIONS.md
Normal file
@@ -0,0 +1,168 @@
|
||||
# Coding Conventions
|
||||
|
||||
**Analysis Date:** 2026-03-27
|
||||
|
||||
## Naming Patterns
|
||||
|
||||
**Files:**
|
||||
- Go 源码使用 `snake_case.go` 或按领域拆分的普通小写命名;路由文件按模块命名放在 `internal/routes/*.go`,Handler 按域放在 `internal/handler/admin/*.go`、`internal/handler/auth/*.go`、`internal/handler/app/*.go`。
|
||||
- OpenAPI 与规范文档使用明确用途命名,例如 `docs/api-documentation-guide.md`、`docs/admin-openapi.yaml`、`cmd/api/docs.go`、`cmd/gendocs/main.go`。
|
||||
- 规范脚本使用动词前缀命名,例如 `scripts/check-all.sh`、`scripts/check-service-errors.sh`、`scripts/check-comment-paths.sh`、`scripts/verify_migration/main.go`。
|
||||
|
||||
**Functions:**
|
||||
- 导出函数/方法使用 Go 的 `PascalCase`,例如 `NewAccountHandler()`、`Create()`、`Success()`、`RedisOrderIdempotencyKey()`,证据见 `internal/handler/admin/account.go`、`pkg/response/response.go`、`pkg/constants/redis.go`。
|
||||
- 未导出帮助函数使用 `camelCase`,例如 `generateOpenAPIDocs()`、`generateAdminDocs()`、`handleError()`、`safeLogWithLevel()`,证据见 `cmd/api/docs.go`、`cmd/gendocs/main.go`、`pkg/errors/handler.go`。
|
||||
|
||||
**Variables:**
|
||||
- 局部变量使用英文 `camelCase`,例如 `outputPath`、`httpStatus`、`roleID`、`groupPath`,证据见 `cmd/api/docs.go`、`pkg/errors/handler.go`、`internal/handler/admin/account.go`。
|
||||
- 错误变量统一使用 `err`,成功返回数据常用业务名,例如 `account`、`accounts`、`roles`,证据见 `internal/handler/admin/account.go`。
|
||||
|
||||
**Types:**
|
||||
- 结构体和接口使用 `PascalCase`,接口语义化命名,配合 `-er` 后缀规则;项目规范明确要求接口名使用 `-er`,见 `AGENTS.md`。
|
||||
- 路由文档元数据类型使用语义名,如 `RouteSpec`、`FileUploadField`,证据见 `internal/routes/registry.go`。
|
||||
|
||||
## Code Style
|
||||
|
||||
**Formatting:**
|
||||
- 使用 `gofmt`;项目在 `AGENTS.md` 明确要求“使用 gofmt 格式化”。
|
||||
- 代码风格遵循 Go 惯用法,避免 Java 式过度抽象;证据见 `AGENTS.md` 的“Go 惯用法 vs Java 风格”。
|
||||
|
||||
**Linting / Scripted Checks:**
|
||||
- 仓库未检测到 `.golangci.yml`、`golangci-lint`、`eslint`、`prettier` 之类自动 lint 配置;当前质量门主要依赖 shell 脚本和人工审查。
|
||||
- `scripts/check-service-errors.sh`:扫描 `internal/service/**/*.go` 中的 `fmt.Errorf`,要求改用 `errors.New()` / `errors.Wrap()`。
|
||||
- `scripts/check-comment-paths.sh`:扫描 `internal/handler/` 中残留的 `/api/v1` 注释,要求改成真实路径 `/api/admin`、`/api/h5`、`/api/c/v1`。
|
||||
- `scripts/check-all.sh`:串行执行以上两个检查。
|
||||
- 当前仓库与脚本规则存在冲突:`internal/service/polling/alert_service.go` 仍存在 4 处 `fmt.Errorf(...)`,按 `scripts/check-service-errors.sh` 的规则属于违规现状。
|
||||
|
||||
## Language & Comment Requirements
|
||||
|
||||
**语言要求:**
|
||||
- 用户可见内容、日志、注释、文档、提交信息都使用中文;变量名、函数名、类型名使用英文,证据见 `AGENTS.md` 的“语言要求”。
|
||||
|
||||
**注释要求:**
|
||||
- 导出包、结构体、接口、函数、方法、常量、变量必须有中文文档注释,证据见 `AGENTS.md`。
|
||||
- Handler 方法注释必须包含 HTTP 方法与真实路径;实际示例见 `internal/handler/admin/account.go`、`internal/handler/callback/payment.go`。
|
||||
- 注释解释“为什么”,不要复述代码;复杂逻辑必须有实现注释,证据见 `AGENTS.md`。
|
||||
- 常量必须带中文注释;实际示例见 `pkg/constants/constants.go`、`pkg/constants/redis.go`。
|
||||
|
||||
## Import Organization
|
||||
|
||||
**Order:**
|
||||
1. Go 标准库,例如 `strconv`、`time`、`regexp`。
|
||||
2. 第三方库,例如 `github.com/gofiber/fiber/v2`、`go.uber.org/zap`。
|
||||
3. 项目内包,例如 `github.com/break/junhong_cmp_fiber/pkg/errors`、`internal/service/account`。
|
||||
|
||||
**Pattern:**
|
||||
- 多数组件按“标准库 → 项目包/第三方包”分组,并保留空行分隔,证据见 `internal/handler/admin/account.go`、`pkg/errors/handler.go`、`cmd/gendocs/main.go`。
|
||||
- 项目使用完整 module import path,未体现短别名路径;必要时用语义别名,例如 `apphandler`、`accountService`,证据见 `cmd/gendocs/main.go`、`internal/handler/admin/account.go`。
|
||||
|
||||
**Path Aliases:**
|
||||
- 未检测到 TS/JS 式路径别名;Go 代码通过 module path `github.com/break/junhong_cmp_fiber/...` 引用。
|
||||
|
||||
## Layering Rules
|
||||
|
||||
**Required Architecture:**
|
||||
- 必须遵循 `Handler → Service → Store → Model`,证据见 `AGENTS.md` 与 `README.md`。
|
||||
|
||||
**How to apply it:**
|
||||
- Handler 只做参数解析、上下文读取、调用 service、返回统一响应;示例见 `internal/handler/admin/account.go`。
|
||||
- Service 承载业务逻辑,并向下调用 Store;项目规范在 `AGENTS.md` 明确禁止在 Handler 中写业务逻辑。
|
||||
- Store 负责数据访问和事务;规范说明见 `AGENTS.md`、`README.md`。
|
||||
- Model/DTO 定义请求、响应和持久化结构;OpenAPI 文档也依赖 DTO 元数据,证据见 `docs/api-documentation-guide.md`。
|
||||
|
||||
## Error Handling
|
||||
|
||||
**Centralized package:**
|
||||
- 所有错误码与应用错误类型集中在 `pkg/errors/`,证据见 `pkg/errors/codes.go`、`pkg/errors/errors.go`、`pkg/errors/handler.go`、`AGENTS.md`。
|
||||
|
||||
**Rules to follow:**
|
||||
- Handler 层不要把底层错误直接暴露给客户端;参数校验失败统一返回 `errors.New(errors.CodeInvalidParam)` 或其中文变体,证据见 `AGENTS.md`、`internal/handler/admin/account.go`。
|
||||
- Service 层对外不要返回 `fmt.Errorf(...)`,要返回 `errors.New(...)` 或 `errors.Wrap(...)`;脚本 `scripts/check-service-errors.sh` 专门检查这一点。
|
||||
- 全局错误处理使用 `pkg/errors/handler.go` 的 `SafeErrorHandler()` / `handleError()`,统一输出 `{code, data, msg, timestamp}`,并按错误码映射 HTTP 状态码与日志级别。
|
||||
- 5xx 响应统一走通用中文消息,避免泄露敏感信息,证据见 `pkg/errors/handler.go`。
|
||||
|
||||
**Error code system:**
|
||||
- `pkg/errors/codes.go` 定义 1000-1999 客户端错误、2000-2999 服务端错误。
|
||||
- `pkg/errors/codes.go` 在 `init()` 校验全部错误码都必须映射消息,新增错误码时要同步维护 `allErrorCodes` 和 `errorMessages`。
|
||||
|
||||
## Response Format
|
||||
|
||||
**Envelope:**
|
||||
- 所有 API 成功响应统一使用 `pkg/response/response.go`:`{code, data, msg, timestamp}`。
|
||||
- `Success()` 返回 `msg: "success"`;`SuccessWithMessage()` 允许覆盖消息;`SuccessWithPagination()` 将分页数据包进 `PaginationData`。
|
||||
|
||||
**How handlers should respond:**
|
||||
- Handler 成功路径直接调用 `response.Success()` / `response.SuccessWithPagination()`;实际示例见 `internal/handler/admin/account.go`、`internal/handler/admin/iot_card.go`、`internal/handler/admin/shop.go`。
|
||||
- 错误路径直接 `return err` 交给全局 ErrorHandler,不在 Handler 内手动拼接 JSON,证据见 `internal/handler/admin/account.go`、`pkg/errors/handler.go`。
|
||||
|
||||
## Constant Management
|
||||
|
||||
**Location:**
|
||||
- 所有常量统一放在 `pkg/constants/`,证据见 `AGENTS.md` 与 `pkg/constants/constants.go`、`pkg/constants/redis.go`。
|
||||
|
||||
**Rules to follow:**
|
||||
- 禁止硬编码字符串与 magic numbers;公共业务值放进 `pkg/constants/constants.go`。
|
||||
- Redis Key 必须通过函数生成,而不是手写字符串,命名格式使用 `Redis{Module}{Purpose}Key(...)`,证据见 `pkg/constants/redis.go` 与 `AGENTS.md`。
|
||||
- 常量要配中文注释;`pkg/constants/constants.go` 中的用户类型、任务类型、分页上限、默认管理员信息都按此方式组织。
|
||||
|
||||
## API / OpenAPI Conventions
|
||||
|
||||
**Route registration:**
|
||||
- 所有 HTTP 路由都应在 `internal/routes/*.go` 中通过 `Register()` 注册,不要直接 `router.Get/Post/...`,证据见 `docs/api-documentation-guide.md`、`internal/routes/registry.go`。
|
||||
- `Register()` 同时负责 Fiber 路由注册和 OpenAPI 生成;当 `doc != nil` 时,会把 `/:id` 转为 OpenAPI 的 `/{id}`,证据见 `internal/routes/registry.go`。
|
||||
|
||||
**RouteSpec requirements:**
|
||||
- 每个接口需要提供中文 `Summary`,可选 Markdown `Description`,并声明 `Input`、`Output`、`Tags`、`Auth`;证据见 `internal/routes/registry.go`、`docs/api-documentation-guide.md`。
|
||||
- DTO 字段必须使用 `description` 标签,不依赖行尾注释;枚举字段要在 `description` 中列出可选值,证据见 `docs/api-documentation-guide.md`。
|
||||
|
||||
**Handler + docs generator sync:**
|
||||
- 新增 Handler 时,除业务接线外,还必须同步更新 `internal/bootstrap/types.go`、`internal/bootstrap/handlers.go`、`internal/routes/admin.go`、`cmd/api/docs.go`、`cmd/gendocs/main.go`,证据见 `docs/api-documentation-guide.md` 与 `AGENTS.md`。
|
||||
- 文档生成命令使用 `go run cmd/gendocs/main.go` 或 `make docs`;代码证据见 `cmd/gendocs/main.go`、`Makefile`。
|
||||
|
||||
## Documentation Expectations
|
||||
|
||||
**Required docs workflow:**
|
||||
- 每个功能应在 `docs/{feature-id}/` 创建总结文档,并同步更新 `README.md`,证据见 `AGENTS.md`。
|
||||
- 文档和说明统一使用中文。
|
||||
- API 文档规范集中在 `docs/api-documentation-guide.md`;开发总规范集中在 `AGENTS.md`。
|
||||
|
||||
**Repository reality:**
|
||||
- `README.md` 仍保留旧的自动化测试与覆盖率章节、旧项目结构中的 `tests/` 目录描述、以及“CI/CD 自动执行检查”的表述。
|
||||
- 当前仓库未发现任何 `*_test.go` 文件,也未发现 `tests/` 目录内容;因此后续文档和执行应优先遵循 `AGENTS.md` 的现行规则,而不是 `README.md` 中这些过期段落。
|
||||
|
||||
## Operational Scripts
|
||||
|
||||
**Quality / convention scripts:**
|
||||
- `bash scripts/check-service-errors.sh`:检查 Service 层是否违规使用 `fmt.Errorf`。
|
||||
- `bash scripts/check-comment-paths.sh`:检查 Handler 注释路径是否残留 `/api/v1`。
|
||||
- `bash scripts/check-all.sh`:运行上述两个检查。
|
||||
|
||||
**Documentation script:**
|
||||
- `make docs` → `go run cmd/gendocs/main.go`,用于生成 `docs/admin-openapi.yaml`,证据见 `Makefile`、`cmd/gendocs/main.go`。
|
||||
|
||||
**Migration verification helper:**
|
||||
- `go run scripts/verify_migration/main.go`:这是一个面向数据库迁移结果的人工验证脚本,会直接连接数据库并查询字段,不是自动测试框架,证据见 `scripts/verify_migration/main.go`。
|
||||
|
||||
**Caution:**
|
||||
- `Makefile` 的 `test` 目标仍定义为 `go test -v ./...`,但这反映的是旧工具入口,不代表当前团队允许自动化测试。
|
||||
|
||||
## Module Design
|
||||
|
||||
**Exports:**
|
||||
- 构造函数使用 `NewXxx...`,例如 `NewAccountHandler()`、`NewGenerator()`。
|
||||
- Handler、response、errors、constants 都以小包单职责方式导出少量明确入口,证据见 `internal/handler/admin/account.go`、`pkg/response/response.go`、`pkg/errors/errors.go`。
|
||||
|
||||
**Barrel Files:**
|
||||
- 未检测到 JS/TS 式 barrel file;Go 通过包目录与导出符号组织模块。
|
||||
|
||||
## Prescriptive Summary
|
||||
|
||||
- 写新 Handler 时:在 `internal/handler/...` 保持中文注释 + 英文标识符,只解析请求并调用 Service,成功统一走 `pkg/response/response.go`,失败直接返回 `error`。
|
||||
- 写新业务错误时:先在 `pkg/errors/codes.go` 注册错误码与中文消息,再用 `errors.New()` / `errors.Wrap()`。
|
||||
- 写新常量或 Redis Key 时:放入 `pkg/constants/`,并补中文注释与 Key 生成函数。
|
||||
- 写新 API 时:在 `internal/routes/*.go` 用 `Register()` + `RouteSpec` 注册,并同步更新 `cmd/api/docs.go` 与 `cmd/gendocs/main.go` 的文档 Handler 装配。
|
||||
- 运行规范检查时:优先使用 `scripts/check-*.sh` 与 `make docs`;不要把 `README.md` 中的测试/覆盖率描述当成当前执行标准。
|
||||
|
||||
---
|
||||
|
||||
*Convention analysis: 2026-03-27*
|
||||
215
.planning/codebase/INTEGRATIONS.md
Normal file
215
.planning/codebase/INTEGRATIONS.md
Normal file
@@ -0,0 +1,215 @@
|
||||
# External Integrations
|
||||
|
||||
**Analysis Date:** 2026-03-27
|
||||
|
||||
## APIs & External Services
|
||||
|
||||
**Database / Cache Infrastructure:**
|
||||
- PostgreSQL - 主业务数据库,GORM 通过 DSN 建连并配置连接池,见 `pkg/database/postgres.go`
|
||||
- Client: `gorm.io/gorm` + `gorm.io/driver/postgres`
|
||||
- Config: `pkg/config/config.go` 的 `database.*`
|
||||
- Redis - Token、缓存、Asynq 后端、限流存储、微信配置缓存,见 `pkg/database/redis.go`、`pkg/queue/client.go`、`cmd/api/main.go`、`internal/service/wechat_config/service.go`
|
||||
- Client: `github.com/redis/go-redis/v9`
|
||||
- Config: `pkg/config/config.go` 的 `redis.*`
|
||||
|
||||
**Object Storage:**
|
||||
- S3-compatible object storage - 文件直传、下载、导入任务临时落盘,见 `pkg/storage/s3.go`、`pkg/storage/service.go`
|
||||
- SDK/Client: `github.com/aws/aws-sdk-go`
|
||||
- Config: `storage.provider`、`storage.s3.*`、`storage.presign.*` in `pkg/config/config.go`
|
||||
- Used by: `internal/handler/admin/storage.go`、`internal/routes/storage.go`、`internal/task/iot_card_import.go`、`internal/task/device_import.go`
|
||||
|
||||
**WeChat ecosystem:**
|
||||
- WeChat Official Account OAuth - 公众号登录与用户信息拉取,见 `pkg/wechat/official_account.go`、`internal/service/client_auth/service.go`
|
||||
- SDK/Client: `github.com/ArtisanCloud/PowerWeChat/v3`
|
||||
- Config source: `tb_wechat_config` via `internal/model/wechat_config.go`
|
||||
- WeChat Mini Program login - `code2session` 换取 openid/session_key,见 `pkg/wechat/miniapp.go`、`internal/service/client_auth/service.go`
|
||||
- Transport: 标准 `net/http`
|
||||
- Config source: `tb_wechat_config`
|
||||
- WeChat Pay - JSAPI/H5 下单、查单、关单、支付回调验签,见 `pkg/wechat/payment.go`、`pkg/wechat/config.go`、`internal/handler/callback/payment.go`
|
||||
- SDK/Client: `github.com/ArtisanCloud/PowerWeChat/v3`
|
||||
- Config source: `tb_wechat_config`
|
||||
|
||||
**Payment gateway:**
|
||||
- Fuiou - 预下单、回调解析、签名与验签 SDK 已存在,见 `pkg/fuiou/client.go`、`pkg/fuiou/wxprecreate.go`、`pkg/fuiou/notify.go`
|
||||
- SDK/Client: repo-local `pkg/fuiou`
|
||||
- Config source: `tb_wechat_config` provider=`fuiou`,字段位于 `internal/model/wechat_config.go`
|
||||
- Callback endpoint: `internal/handler/callback/payment.go`
|
||||
- Current state: 回调处理存在 `TODO`,当前路径解析 XML 但未按配置创建客户端做验签,见 `internal/handler/callback/payment.go`
|
||||
|
||||
**SMS gateway:**
|
||||
- SMS HTTP gateway - 验证码/消息发送,见 `pkg/sms/client.go`、`cmd/api/main.go`
|
||||
- Client: repo-local `pkg/sms`
|
||||
- Config: `sms.gateway_url`、`sms.username`、`sms.password`、`sms.signature` in `pkg/config/config.go`
|
||||
|
||||
**Business Gateway / IoT upstream:**
|
||||
- Gateway API - 设备信息、卡状态、流量、实名链接、设备限速/切卡等外部接口,见 `internal/gateway/client.go`、`internal/gateway/device.go`、`internal/gateway/flow_card.go`
|
||||
- Auth: appId + appSecret,自定义 AES-128-ECB 加密 + MD5 签名,见 `internal/gateway/client.go`
|
||||
- Config: `gateway.base_url`、`gateway.app_id`、`gateway.app_secret`、`gateway.timeout` in `pkg/config/config.go`
|
||||
- Used by: `internal/service/iot_card/gateway_service.go`、`internal/service/device/gateway_service.go`、`internal/task/polling_handler.go`
|
||||
|
||||
## Data Storage
|
||||
|
||||
**Databases:**
|
||||
- PostgreSQL
|
||||
- Connection keys: `JUNHONG_DATABASE_HOST`, `JUNHONG_DATABASE_PORT`, `JUNHONG_DATABASE_USER`, `JUNHONG_DATABASE_PASSWORD`, `JUNHONG_DATABASE_DBNAME`, `JUNHONG_DATABASE_SSLMODE`
|
||||
- Connection code: `pkg/database/postgres.go`
|
||||
- Runtime wiring: `cmd/api/main.go`, `cmd/worker/main.go`, `docker-compose.prod.yml`
|
||||
|
||||
**Redis:**
|
||||
- Redis is shared across auth, cache, rate limiting, WeChat config cache, and Asynq
|
||||
- Connection keys: `JUNHONG_REDIS_ADDRESS`, `JUNHONG_REDIS_PORT`, `JUNHONG_REDIS_PASSWORD`, `JUNHONG_REDIS_DB`
|
||||
- Connection code: `pkg/database/redis.go`
|
||||
- Asynq reuse: `pkg/queue/client.go`, `pkg/queue/server.go`
|
||||
- Config cache key example: `wechat:config:active` in `internal/service/wechat_config/service.go`
|
||||
|
||||
**File Storage:**
|
||||
- S3-compatible bucket storage
|
||||
- Provider implementation: `pkg/storage/s3.go`
|
||||
- Upload URL API: `internal/routes/storage.go`
|
||||
- Import consumers download to temp files before parsing: `internal/task/iot_card_import.go`, `internal/task/device_import.go`
|
||||
|
||||
**Local filesystem:**
|
||||
- Logs are persisted to `/app/logs` inside containers and mounted by Compose, see `pkg/config/defaults/config.yaml`, `Dockerfile.api`, `Dockerfile.worker`, `docker-compose.prod.yml`
|
||||
- OpenAPI runtime export writes to `logs/openapi.yaml`, see `cmd/api/main.go`, `cmd/api/docs.go`
|
||||
|
||||
## Authentication & Identity
|
||||
|
||||
**B-side auth:**
|
||||
- Redis-backed token management and JWT manager initialization occur in `cmd/api/main.go`
|
||||
- Components: `pkg/auth`, Redis token manager, JWT manager
|
||||
|
||||
**C-side identity via WeChat:**
|
||||
- 公众号 OAuth login path uses active config from `tb_wechat_config`, see `internal/service/client_auth/service.go`
|
||||
- 小程序 login path uses WeChat `code2session`, see `internal/service/client_auth/service.go`, `pkg/wechat/miniapp.go`
|
||||
|
||||
## Monitoring & Observability
|
||||
|
||||
**Application logs:**
|
||||
- Zap + Lumberjack app/access loggers, see `pkg/logger/logger.go`
|
||||
|
||||
**Request tracing:**
|
||||
- Access logger captures request metadata and request/response bodies, see `pkg/logger/middleware.go`
|
||||
|
||||
**SQL observability:**
|
||||
- GORM logger sends query errors and slow queries to Zap, see `pkg/database/postgres.go`
|
||||
|
||||
**Container health:**
|
||||
- API health endpoint is used by Docker health checks and Compose dependency ordering, see `Dockerfile.api`, `docker-compose.prod.yml`
|
||||
|
||||
## Queue, Scheduling, and Runtime Integrations
|
||||
|
||||
**Asynq runtime:**
|
||||
- Worker server consumes Redis-backed queues configured in `queue.*`, see `pkg/queue/server.go`, `cmd/worker/main.go`
|
||||
- Task submission is wrapped in `pkg/queue/client.go`
|
||||
|
||||
**Registered task domains:**
|
||||
- Email, data sync, SIM status sync, IoT card import, device import, commission stats, commission calculation, polling, package activation, order expiration, alert check, data cleanup, see `pkg/queue/handler.go`
|
||||
|
||||
**Schedulers:**
|
||||
- Worker process starts an internal polling scheduler plus an Asynq Scheduler for recurring jobs, see `cmd/worker/main.go`
|
||||
- Recurring jobs include order expiration, alert checks, and nightly cleanup, see `cmd/worker/main.go`
|
||||
|
||||
## Docs Generation Integrations
|
||||
|
||||
**OpenAPI generation:**
|
||||
- Repo-local generator wraps `swaggest/openapi-go` and exports YAML, see `pkg/openapi/generator.go`
|
||||
- Runtime generation during API boot writes `logs/openapi.yaml`, see `cmd/api/main.go`, `cmd/api/docs.go`
|
||||
- Manual generation command writes `docs/admin-openapi.yaml`, see `Makefile`, `cmd/gendocs/main.go`
|
||||
- Route registration for docs reuses production route registration, see `internal/routes/routes.go`, `pkg/openapi/handlers.go`
|
||||
|
||||
## CI/CD & Deployment
|
||||
|
||||
**Hosting / Runtime:**
|
||||
- Dockerized API and Worker services are the deployment target, see `Dockerfile.api`, `Dockerfile.worker`, `docker-compose.prod.yml`
|
||||
|
||||
**Registry:**
|
||||
- Images are pushed to a private registry declared in `.gitea/workflows/deploy.yaml`
|
||||
|
||||
**CI Pipeline:**
|
||||
- Gitea workflow builds both images, pushes tag + SHA tags, copies `docker-compose.prod.yml`, and executes `docker compose pull/up -d`, see `.gitea/workflows/deploy.yaml`
|
||||
|
||||
**Migration tooling:**
|
||||
- API image bundles `golang-migrate` and migration files, see `Dockerfile.api`
|
||||
- Local migration shortcuts are defined in `Makefile`
|
||||
|
||||
## Environment Configuration
|
||||
|
||||
**Required env vars:**
|
||||
- Database: `JUNHONG_DATABASE_HOST`, `JUNHONG_DATABASE_PORT`, `JUNHONG_DATABASE_USER`, `JUNHONG_DATABASE_PASSWORD`, `JUNHONG_DATABASE_DBNAME`
|
||||
- Redis: `JUNHONG_REDIS_ADDRESS` plus optional Redis port/password/db tuning vars
|
||||
- JWT: `JUNHONG_JWT_SECRET_KEY`
|
||||
|
||||
**Optional env vars by integration:**
|
||||
- Storage: `JUNHONG_STORAGE_PROVIDER`, `JUNHONG_STORAGE_S3_*`
|
||||
- SMS: `JUNHONG_SMS_*`
|
||||
- Gateway: `JUNHONG_GATEWAY_*`
|
||||
- Logging: `JUNHONG_LOGGING_*`
|
||||
|
||||
**Secrets location:**
|
||||
- Default app config is embedded from `pkg/config/defaults/config.yaml`
|
||||
- Most runtime secrets are expected through environment variables bound in `pkg/config/loader.go`
|
||||
- WeChat / payment provider secrets are stored in database table `tb_wechat_config`, see `internal/model/wechat_config.go`
|
||||
- Deployment wiring exists in `docker-compose.prod.yml` and `.gitea/workflows/deploy.yaml`; these files should be treated as sensitive and are not reproduced here
|
||||
|
||||
## Webhooks & Callbacks
|
||||
|
||||
**Incoming:**
|
||||
- WeChat Pay callback: `POST /api/callback/wechat-pay`, handler in `internal/handler/callback/payment.go`
|
||||
- Fuiou Pay callback: `POST /api/callback/fuiou-pay`, handler in `internal/handler/callback/payment.go`
|
||||
- Alipay callback stub: `POST /api/callback/alipay`, handler in `internal/handler/callback/payment.go`
|
||||
|
||||
**Outgoing:**
|
||||
- WeChat OAuth and payment API calls from `pkg/wechat/*.go`
|
||||
- Gateway API calls from `internal/gateway/*.go`
|
||||
- SMS API calls from `pkg/sms/client.go`
|
||||
- S3 object storage calls from `pkg/storage/s3.go`
|
||||
|
||||
## Evidence
|
||||
|
||||
- `go.mod`
|
||||
- `README.md`
|
||||
- `cmd/api/main.go`
|
||||
- `cmd/api/docs.go`
|
||||
- `cmd/worker/main.go`
|
||||
- `cmd/gendocs/main.go`
|
||||
- `pkg/config/config.go`
|
||||
- `pkg/config/loader.go`
|
||||
- `pkg/config/defaults/config.yaml`
|
||||
- `pkg/database/postgres.go`
|
||||
- `pkg/database/redis.go`
|
||||
- `pkg/queue/client.go`
|
||||
- `pkg/queue/server.go`
|
||||
- `pkg/queue/handler.go`
|
||||
- `pkg/logger/logger.go`
|
||||
- `pkg/openapi/generator.go`
|
||||
- `pkg/openapi/handlers.go`
|
||||
- `pkg/storage/s3.go`
|
||||
- `pkg/storage/service.go`
|
||||
- `pkg/wechat/config.go`
|
||||
- `pkg/wechat/official_account.go`
|
||||
- `pkg/wechat/miniapp.go`
|
||||
- `pkg/wechat/payment.go`
|
||||
- `pkg/fuiou/client.go`
|
||||
- `pkg/fuiou/wxprecreate.go`
|
||||
- `pkg/fuiou/notify.go`
|
||||
- `pkg/sms/client.go`
|
||||
- `internal/gateway/client.go`
|
||||
- `internal/gateway/device.go`
|
||||
- `internal/gateway/flow_card.go`
|
||||
- `internal/model/wechat_config.go`
|
||||
- `internal/service/wechat_config/service.go`
|
||||
- `internal/service/client_auth/service.go`
|
||||
- `internal/service/client_order/service.go`
|
||||
- `internal/routes/storage.go`
|
||||
- `internal/handler/callback/payment.go`
|
||||
- `internal/task/iot_card_import.go`
|
||||
- `internal/task/device_import.go`
|
||||
- `Makefile`
|
||||
- `Dockerfile.api`
|
||||
- `Dockerfile.worker`
|
||||
- `docker-compose.prod.yml`
|
||||
- `.gitea/workflows/deploy.yaml`
|
||||
|
||||
---
|
||||
|
||||
*Integration audit: 2026-03-27*
|
||||
177
.planning/codebase/STACK.md
Normal file
177
.planning/codebase/STACK.md
Normal file
@@ -0,0 +1,177 @@
|
||||
# Technology Stack
|
||||
|
||||
**Analysis Date:** 2026-03-27
|
||||
|
||||
## Languages
|
||||
|
||||
**Primary:**
|
||||
- Go 1.25.x - 应用代码、API、Worker、OpenAPI 生成和基础设施脚本,依据 `go.mod`、`cmd/api/main.go`、`cmd/worker/main.go`、`cmd/gendocs/main.go`
|
||||
|
||||
**Secondary:**
|
||||
- YAML - 配置与部署编排,见 `pkg/config/defaults/config.yaml`、`docker-compose.prod.yml`、`.gitea/workflows/deploy.yaml`
|
||||
- Dockerfile - API/Worker 容器构建,见 `Dockerfile.api`、`Dockerfile.worker`
|
||||
- Makefile - 本地构建、文档生成、迁移命令,见 `Makefile`
|
||||
|
||||
## Runtime
|
||||
|
||||
**Environment:**
|
||||
- Go runtime;API 服务基于 Fiber HTTP 服务器,见 `cmd/api/main.go`
|
||||
- 独立 Worker 进程处理异步任务与调度,见 `cmd/worker/main.go`
|
||||
- 生产容器基础镜像为 Alpine,构建镜像使用 Go 1.25.6 Alpine,见 `Dockerfile.api`、`Dockerfile.worker`
|
||||
|
||||
**Package Manager:**
|
||||
- Go Modules,见 `go.mod`
|
||||
- Lockfile: `go.sum` present
|
||||
|
||||
## Frameworks
|
||||
|
||||
**Core:**
|
||||
- Fiber v2.52.9 - HTTP 框架与路由宿主,见 `go.mod`、`cmd/api/main.go`
|
||||
- GORM v1.31.1 + `gorm.io/driver/postgres` v1.6.0 - PostgreSQL ORM 与连接层,见 `go.mod`、`pkg/database/postgres.go`
|
||||
- Viper v1.21.0 - 嵌入式默认配置 + 环境变量覆盖,见 `go.mod`、`pkg/config/loader.go`
|
||||
|
||||
**Queue / Scheduling:**
|
||||
- Asynq v0.25.1 - 任务提交、Worker 消费、定时任务,见 `go.mod`、`pkg/queue/client.go`、`pkg/queue/server.go`、`cmd/worker/main.go`
|
||||
|
||||
**Serialization / Validation:**
|
||||
- sonic v1.14.2 - Fiber JSON 编解码与任务载荷序列化,见 `go.mod`、`cmd/api/main.go`、`pkg/queue/client.go`
|
||||
- validator/v10 v10.28.0 - DTO 校验依赖,见 `go.mod`
|
||||
|
||||
**Logging:**
|
||||
- zap v1.27.1 - 应用日志与 SQL 日志,见 `go.mod`、`pkg/logger/logger.go`、`pkg/database/postgres.go`
|
||||
- lumberjack.v2 v2.2.1 - 日志轮转,见 `go.mod`、`pkg/logger/logger.go`
|
||||
|
||||
**Docs tooling:**
|
||||
- swaggest/openapi-go v0.2.60 - OpenAPI 3.0.3 文档生成,见 `go.mod`、`pkg/openapi/generator.go`
|
||||
|
||||
## Build / Dev / Deploy
|
||||
|
||||
**Build:**
|
||||
- `Makefile` 定义 `build`、`run`、`run-worker`、`docs`、`migrate-*` 目标,见 `Makefile`
|
||||
- API 与 Worker 使用多阶段 Docker 构建,编译产物分别来自 `./cmd/api` 与 `./cmd/worker`,见 `Dockerfile.api`、`Dockerfile.worker`
|
||||
|
||||
**Deploy:**
|
||||
- 生产编排使用 Docker Compose,运行 `api` 与 `worker` 两个服务,见 `docker-compose.prod.yml`
|
||||
- Gitea Actions 工作流构建镜像、推送私有镜像仓库并在主分支执行 `docker compose up -d`,见 `.gitea/workflows/deploy.yaml`
|
||||
|
||||
**Docs generation:**
|
||||
- API 启动时会生成 `logs/openapi.yaml`,见 `cmd/api/main.go`、`cmd/api/docs.go`
|
||||
- 手动文档命令 `make docs` 运行 `cmd/gendocs/main.go`,输出 `docs/admin-openapi.yaml`,见 `Makefile`、`cmd/gendocs/main.go`
|
||||
|
||||
## Storage, Data, Messaging
|
||||
|
||||
**Database:**
|
||||
- PostgreSQL 是唯一检测到的主数据库;连接、连接池、GORM logger、Ping 校验位于 `pkg/database/postgres.go`
|
||||
|
||||
**Cache / KV:**
|
||||
- Redis 用于认证、缓存、队列后端、限流存储和配置缓存,见 `pkg/database/redis.go`、`cmd/api/main.go`、`internal/service/wechat_config/service.go`
|
||||
|
||||
**Messaging / Queue:**
|
||||
- Asynq 直接复用 Redis 连接配置作为消息后端,见 `pkg/queue/client.go`、`pkg/queue/server.go`
|
||||
- Worker 内注册订单超时、告警检查、数据清理等调度任务,见 `cmd/worker/main.go`
|
||||
|
||||
**Object storage:**
|
||||
- S3 兼容对象存储通过 AWS SDK v1 实现,支持上传、下载、预签名 URL、临时文件下载,见 `pkg/storage/s3.go`、`pkg/storage/service.go`
|
||||
|
||||
## Configuration
|
||||
|
||||
**Loading model:**
|
||||
- 默认配置从嵌入文件 `pkg/config/defaults/config.yaml` 读取,见 `pkg/config/embedded.go`
|
||||
- 运行时使用 `JUNHONG_` 前缀环境变量覆盖,见 `pkg/config/loader.go`
|
||||
- 必填配置校验包括数据库、Redis、JWT,见 `pkg/config/config.go`
|
||||
|
||||
**Key config areas:**
|
||||
- 服务与超时:`server.*`,见 `pkg/config/config.go`
|
||||
- PostgreSQL:`database.*`,见 `pkg/config/config.go`
|
||||
- Redis:`redis.*`,见 `pkg/config/config.go`
|
||||
- Asynq:`queue.*`,见 `pkg/config/config.go`
|
||||
- 日志:`logging.*`,见 `pkg/config/config.go`
|
||||
- 短信:`sms.*`,见 `pkg/config/config.go`
|
||||
- 对象存储:`storage.*`,见 `pkg/config/config.go`
|
||||
- 外部 Gateway:`gateway.*`,见 `pkg/config/config.go`
|
||||
|
||||
**WeChat config exception:**
|
||||
- 微信公众号/小程序/支付配置不走 Viper 主配置,业务侧从数据库表 `tb_wechat_config` 读取并缓存到 Redis,见 `internal/model/wechat_config.go`、`internal/service/wechat_config/service.go`、`pkg/wechat/config.go`
|
||||
|
||||
## Logging & Observability
|
||||
|
||||
**Application logs:**
|
||||
- `pkg/logger/logger.go` 初始化 app/access 双 logger;生产模式写 JSON,开发模式 app logger 同时输出控制台
|
||||
|
||||
**Access logs:**
|
||||
- `pkg/logger/middleware.go` 记录 method、path、query、status、duration、request_id、ip、user_agent、user_id、请求/响应 body
|
||||
|
||||
**SQL logs:**
|
||||
- `pkg/database/postgres.go` 自定义 GORM logger,记录错误、慢查询和普通 SQL trace
|
||||
|
||||
**Health checks:**
|
||||
- `Dockerfile.api` 与 `docker-compose.prod.yml` 均通过 `GET /health` 做容器健康检查
|
||||
|
||||
## Notable Dependencies
|
||||
|
||||
**HTTP / App:**
|
||||
- `github.com/gofiber/fiber/v2` - API 服务核心框架,见 `go.mod`、`cmd/api/main.go`
|
||||
- `github.com/google/uuid` - 请求 ID 生成,见 `cmd/api/main.go`
|
||||
|
||||
**Persistence / Cache:**
|
||||
- `gorm.io/gorm`、`gorm.io/driver/postgres` - ORM 与 PostgreSQL 驱动,见 `go.mod`、`pkg/database/postgres.go`
|
||||
- `github.com/redis/go-redis/v9` - Redis 客户端,见 `go.mod`、`pkg/database/redis.go`
|
||||
|
||||
**Queue / Async:**
|
||||
- `github.com/hibiken/asynq` - 队列与调度器,见 `go.mod`、`pkg/queue/*.go`、`cmd/worker/main.go`
|
||||
|
||||
**Serialization / File processing:**
|
||||
- `github.com/bytedance/sonic` - JSON 编解码,见 `cmd/api/main.go`、`pkg/queue/client.go`
|
||||
- `github.com/xuri/excelize/v2` - Excel 导入处理依赖,见 `go.mod`; 任务消费方在 `internal/task/iot_card_import.go`、`internal/task/device_import.go` 调用 Excel 解析
|
||||
|
||||
**External SDKs:**
|
||||
- `github.com/ArtisanCloud/PowerWeChat/v3` - 微信公众号与微信支付 SDK,见 `go.mod`、`pkg/wechat/official_account.go`、`pkg/wechat/payment.go`
|
||||
- `github.com/aws/aws-sdk-go` - S3 兼容对象存储客户端,见 `go.mod`、`pkg/storage/s3.go`
|
||||
|
||||
## Platform Requirements
|
||||
|
||||
**Development:**
|
||||
- 需要 PostgreSQL、Redis、JWT 密钥与 `JUNHONG_` 环境变量;`config.Load()` 在启动时强校验,见 `pkg/config/config.go`
|
||||
- `make docs` 需要 Go 环境;`migrate-*` 目标需要系统安装 `migrate` 命令,见 `Makefile`
|
||||
|
||||
**Production:**
|
||||
- 目标运行形态是 Docker 化 API + Worker 双进程部署,见 `Dockerfile.api`、`Dockerfile.worker`、`docker-compose.prod.yml`
|
||||
- 镜像发布到私有仓库 `registry.boss160.cn`,见 `.gitea/workflows/deploy.yaml`
|
||||
|
||||
## Evidence
|
||||
|
||||
- `go.mod`
|
||||
- `README.md`
|
||||
- `cmd/api/main.go`
|
||||
- `cmd/api/docs.go`
|
||||
- `cmd/worker/main.go`
|
||||
- `cmd/gendocs/main.go`
|
||||
- `pkg/config/config.go`
|
||||
- `pkg/config/loader.go`
|
||||
- `pkg/config/embedded.go`
|
||||
- `pkg/config/defaults/config.yaml`
|
||||
- `pkg/database/postgres.go`
|
||||
- `pkg/database/redis.go`
|
||||
- `pkg/queue/client.go`
|
||||
- `pkg/queue/server.go`
|
||||
- `pkg/queue/handler.go`
|
||||
- `pkg/logger/logger.go`
|
||||
- `pkg/logger/middleware.go`
|
||||
- `pkg/openapi/generator.go`
|
||||
- `pkg/openapi/handlers.go`
|
||||
- `pkg/storage/s3.go`
|
||||
- `pkg/storage/service.go`
|
||||
- `pkg/wechat/config.go`
|
||||
- `pkg/wechat/official_account.go`
|
||||
- `pkg/wechat/payment.go`
|
||||
- `pkg/sms/client.go`
|
||||
- `internal/gateway/client.go`
|
||||
- `Makefile`
|
||||
- `Dockerfile.api`
|
||||
- `Dockerfile.worker`
|
||||
- `docker-compose.prod.yml`
|
||||
- `.gitea/workflows/deploy.yaml`
|
||||
|
||||
---
|
||||
|
||||
*Stack analysis: 2026-03-27*
|
||||
250
.planning/codebase/STRUCTURE.md
Normal file
250
.planning/codebase/STRUCTURE.md
Normal file
@@ -0,0 +1,250 @@
|
||||
# Codebase Structure
|
||||
|
||||
**Analysis Date:** 2026-03-27
|
||||
|
||||
## Directory Layout
|
||||
|
||||
```text
|
||||
junhong_cmp_fiber/
|
||||
├── cmd/ # API、Worker、文档生成入口
|
||||
├── internal/ # 核心业务代码(handler/service/store/model/routes/bootstrap/task)
|
||||
├── pkg/ # 可复用基础设施与跨模块工具
|
||||
├── migrations/ # SQL 迁移脚本
|
||||
├── docs/ # 业务文档、接入指南、架构说明
|
||||
├── openspec/ # 当前能力规格与变更档案
|
||||
├── specs/ # 早期 Speckit 规格文档
|
||||
├── scripts/ # 检查与初始化脚本
|
||||
├── docker/ # 部署相关辅助文件
|
||||
├── logs/ # 运行期日志输出目录
|
||||
└── .planning/codebase/ # 当前代码地图产物
|
||||
```
|
||||
|
||||
## Directory Purposes
|
||||
|
||||
**`cmd/`:**
|
||||
- Purpose: 进程级入口和独立命令。
|
||||
- Contains: `cmd/api/main.go`, `cmd/api/docs.go`, `cmd/worker/main.go`, `cmd/gendocs/main.go`
|
||||
- Key files: `cmd/api/main.go`, `cmd/worker/main.go`
|
||||
|
||||
**`internal/bootstrap/`:**
|
||||
- Purpose: 依赖装配中心。
|
||||
- Contains: API/Worker 的 stores、services、handlers、middlewares 初始化。
|
||||
- Key files: `internal/bootstrap/bootstrap.go`, `internal/bootstrap/services.go`, `internal/bootstrap/worker.go`, `internal/bootstrap/types.go`
|
||||
|
||||
**`internal/routes/`:**
|
||||
- Purpose: 路由域划分与 OpenAPI 绑定。
|
||||
- Contains: 域入口 `admin.go`, `auth.go`, `personal.go` 以及各模块路由文件。
|
||||
- Key files: `internal/routes/routes.go`, `internal/routes/registry.go`, `internal/routes/admin.go`, `internal/routes/personal.go`
|
||||
|
||||
**`internal/handler/`:**
|
||||
- Purpose: HTTP Handler 层。
|
||||
- Contains: `admin/`、`app/`、`auth/`、`callback/` 四个子域。
|
||||
- Key files: `internal/handler/admin/account.go`, `internal/handler/auth/handler.go`, `internal/handler/callback/payment.go`
|
||||
|
||||
**`internal/service/`:**
|
||||
- Purpose: 业务逻辑层。
|
||||
- Contains: 按业务模块拆分的子目录,每个模块通常有一个 `service.go`。
|
||||
- Key files: `internal/service/account/service.go`, `internal/service/order/service.go`, `internal/service/package/service.go`, `internal/service/polling/*.go`
|
||||
|
||||
**`internal/store/`:**
|
||||
- Purpose: 数据访问层。
|
||||
- Contains: 基础抽象 `store.go`、查询选项 `options.go`、以及 `postgres/` 下的具体实现。
|
||||
- Key files: `internal/store/store.go`, `internal/store/options.go`, `internal/store/postgres/account_store.go`, `internal/store/postgres/order_store.go`
|
||||
|
||||
**`internal/model/`:**
|
||||
- Purpose: 持久化模型与 DTO。
|
||||
- Contains: 业务实体模型、基础模型、DTO 子目录。
|
||||
- Key files: `internal/model/account.go`, `internal/model/order.go`, `internal/model/shop.go`, `internal/model/dto/account_dto.go`
|
||||
|
||||
**`internal/task/`:**
|
||||
- Purpose: Asynq 任务处理逻辑。
|
||||
- Contains: 轮询、导入、订单超时、邮件、佣金等任务处理器。
|
||||
- Key files: `internal/task/polling_handler.go`, `internal/task/order_expire.go`, `internal/task/device_import.go`
|
||||
|
||||
**`internal/polling/`:**
|
||||
- Purpose: 轮询调度器与专用运行时组件。
|
||||
- Contains: `Scheduler`、激活处理器、流量重置处理器。
|
||||
- Key files: `internal/polling/scheduler.go`
|
||||
|
||||
**`internal/gateway/`:**
|
||||
- Purpose: 对外 Gateway HTTP 客户端封装。
|
||||
- Contains: 请求签名、设备/卡查询与操作客户端。
|
||||
- Key files: `internal/gateway/client.go`(由 `cmd/api/main.go`、`cmd/worker/main.go` 初始化)
|
||||
|
||||
**`pkg/`:**
|
||||
- Purpose: 通用基础设施。
|
||||
- Contains: 配置、数据库、日志、认证、错误、响应、OpenAPI、队列、对象存储、微信、短信、中间件辅助。
|
||||
- Key files: `pkg/config/config.go`, `pkg/config/loader.go`, `pkg/database/postgres.go`, `pkg/middleware/auth.go`, `pkg/middleware/data_scope.go`, `pkg/openapi/generator.go`
|
||||
|
||||
**`migrations/`:**
|
||||
- Purpose: 数据库结构演进。
|
||||
- Contains: 成对的 `*.up.sql` / `*.down.sql` 与少量补数 SQL。
|
||||
- Key files: `migrations/000000_create_legacy_tables.up.sql`, `migrations/000055_package_system_upgrade.up.sql`, `migrations/000088_rename_card_wallet_id_to_asset_wallet_id.up.sql`
|
||||
|
||||
**`docs/`:**
|
||||
- Purpose: 面向开发和业务的说明文档。
|
||||
- Contains: 功能总结、架构说明、接入指南、运维说明、第三方资料。
|
||||
- Key files: `docs/api-documentation-guide.md`, `docs/auth-architecture.md`, `docs/polling-system/README.md`
|
||||
|
||||
**`openspec/`:**
|
||||
- Purpose: 规范驱动开发资产。
|
||||
- Contains: `config.yaml`、当前 specs、历史归档变更。
|
||||
- Key files: `openspec/config.yaml`, `openspec/specs/**/spec.md`, `openspec/changes/archive/**`
|
||||
|
||||
## Key File Locations
|
||||
|
||||
**Entry Points:**
|
||||
- `cmd/api/main.go`: API 服务主入口。
|
||||
- `cmd/worker/main.go`: Worker 服务主入口。
|
||||
- `cmd/gendocs/main.go`: 离线 OpenAPI 生成命令。
|
||||
|
||||
**Configuration:**
|
||||
- `pkg/config/config.go`: 配置结构与校验规则。
|
||||
- `pkg/config/loader.go`: 嵌入配置 + 环境变量覆盖加载。
|
||||
- `openspec/config.yaml`: 规范生成器上下文与规则。
|
||||
|
||||
**Core Logic:**
|
||||
- `internal/service/`: 所有业务服务主实现。
|
||||
- `internal/store/postgres/`: 所有 PostgreSQL 访问实现。
|
||||
- `internal/bootstrap/`: 依赖注入和装配。
|
||||
- `internal/polling/scheduler.go`: 轮询核心调度逻辑。
|
||||
|
||||
**API Surface:**
|
||||
- `internal/routes/`: 路由分组与注册。
|
||||
- `internal/handler/admin/`, `internal/handler/app/`, `internal/handler/auth/`, `internal/handler/callback/`: 传输层处理器。
|
||||
|
||||
**Data Definitions:**
|
||||
- `internal/model/*.go`: 表模型。
|
||||
- `internal/model/dto/*.go`: 请求/响应 DTO。
|
||||
|
||||
**Documentation / Planning:**
|
||||
- `docs/`: 功能文档。
|
||||
- `openspec/specs/`: 现行规格。
|
||||
- `openspec/changes/archive/`: 历史变更记录。
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
**Files:**
|
||||
- 模块按业务名命名:`internal/service/account/service.go`, `internal/handler/admin/account.go`
|
||||
- 大部分 Store 文件采用 `{resource}_store.go`:`internal/store/postgres/account_store.go`
|
||||
- DTO 文件采用 `{module}_dto.go`:`internal/model/dto/account_dto.go`
|
||||
- 路由文件按模块命名:`internal/routes/account.go`, `internal/routes/polling_config.go`
|
||||
|
||||
**Directories:**
|
||||
- 分层目录使用小写单数语义:`internal/service/`, `internal/store/`, `internal/model/`
|
||||
- 传输域用子目录表达边界:`internal/handler/admin/`, `internal/handler/app/`, `internal/handler/callback/`
|
||||
- 规格目录按能力名组织:`openspec/specs/account-management/`, `openspec/specs/order-payment/`
|
||||
|
||||
## Ownership / Responsibility Guide
|
||||
|
||||
**如果改接口路径或加新 API:**
|
||||
- 先看 `internal/routes/registry.go` 与对应模块路由文件。
|
||||
- 再同步检查 `docs/api-documentation-guide.md`、`cmd/api/docs.go`、`cmd/gendocs/main.go`、`pkg/openapi/handlers.go`。
|
||||
|
||||
**如果改业务规则:**
|
||||
- 主要落点在 `internal/service/{module}/service.go`。
|
||||
- 如涉及跨模块,先看 `internal/bootstrap/services.go` 中该服务依赖谁。
|
||||
|
||||
**如果改查询或权限过滤:**
|
||||
- 主要落点在 `internal/store/postgres/*.go`。
|
||||
- 数据范围辅助逻辑集中在 `pkg/middleware/data_scope.go` 与 `pkg/middleware/permission_helper.go`。
|
||||
|
||||
**如果改表结构或模型:**
|
||||
- SQL 先改 `migrations/`。
|
||||
- Go 模型同步改 `internal/model/*.go`。
|
||||
- DTO 变更再改 `internal/model/dto/*.go`。
|
||||
|
||||
**如果改异步任务:**
|
||||
- 任务实现放 `internal/task/*.go`。
|
||||
- 注册入口在 `pkg/queue/handler.go`。
|
||||
- Worker 依赖拼装在 `internal/bootstrap/worker*.go`。
|
||||
|
||||
**如果改轮询系统:**
|
||||
- 调度逻辑看 `internal/polling/scheduler.go`。
|
||||
- 执行逻辑看 `internal/task/polling_handler.go`。
|
||||
- 配置/监控/清理相关服务看 `internal/service/polling/*.go`。
|
||||
|
||||
## Where to Add New Code
|
||||
|
||||
**New Admin API Feature:**
|
||||
- Primary code: `internal/handler/admin/{module}.go`, `internal/service/{module}/service.go`, `internal/store/postgres/{module}_store.go`, `internal/routes/{module}.go`
|
||||
- Models / DTOs: `internal/model/{module}.go`, `internal/model/dto/{module}_dto.go`
|
||||
- Wiring: `internal/bootstrap/services.go`, `internal/bootstrap/handlers.go`, `internal/bootstrap/types.go`, `internal/routes/admin.go`
|
||||
|
||||
**New Personal Client Feature:**
|
||||
- Primary code: `internal/handler/app/{feature}.go`, `internal/service/{feature}/service.go` 或复用现有服务
|
||||
- Routes: `internal/routes/personal.go`
|
||||
- Auth-sensitive DTOs: `internal/model/dto/` 下新增对应 DTO 文件
|
||||
|
||||
**New Worker Task:**
|
||||
- Implementation: `internal/task/{task_name}.go`
|
||||
- Registration: `pkg/queue/handler.go`
|
||||
- Dependencies: `internal/bootstrap/worker_services.go` 或 `internal/bootstrap/worker_stores.go`
|
||||
- Constants: `pkg/constants/` 中对应任务类型定义
|
||||
|
||||
**New Shared Utility:**
|
||||
- Shared helpers: `pkg/{subsystem}/`
|
||||
- 仅限跨多个业务模块复用的基础能力,不要把业务逻辑塞进 `pkg/`
|
||||
|
||||
**New Business Spec / Plan Artifact:**
|
||||
- Current capability spec: `openspec/specs/{capability}/spec.md`
|
||||
- Historical change proposal: `openspec/changes/...`
|
||||
- Developer-facing long-form doc: `docs/{feature-id}/` 或现有专题目录
|
||||
|
||||
## Practical Navigation Guide
|
||||
|
||||
**从请求入口追代码:**
|
||||
1. 在 `internal/routes/*.go` 找路径。
|
||||
2. 跳到对应 `internal/handler/...` 方法。
|
||||
3. 看该 Handler 注入了哪个 Service。
|
||||
4. 到 `internal/service/...` 看业务编排。
|
||||
5. 再进入 `internal/store/postgres/...` 看 SQL 过滤与分页。
|
||||
6. 最后回到 `internal/model/` / `internal/model/dto/` 看字段定义。
|
||||
|
||||
**从一个模块反查装配方式:**
|
||||
1. 先找 `internal/bootstrap/services.go` 是否实例化该 Service。
|
||||
2. 再看 `internal/bootstrap/handlers.go` 是否把它交给 Handler。
|
||||
3. 再看 `internal/routes/admin.go` 或 `internal/routes/personal.go` 是否挂载了该模块。
|
||||
|
||||
**从一个异步任务反查来源:**
|
||||
1. 看 `pkg/queue/handler.go` 的 `registerXxxHandler()`。
|
||||
2. 跳到 `internal/task/{task}.go`。
|
||||
3. 若任务由调度器触发,再看 `internal/polling/scheduler.go` 或 `cmd/worker/main.go` 的 Asynq Scheduler 注册。
|
||||
|
||||
## Special Directories
|
||||
|
||||
**`logs/`:**
|
||||
- Purpose: 运行日志输出目录。
|
||||
- Generated: Yes
|
||||
- Committed: No(运行期产物)
|
||||
|
||||
**`migrations/`:**
|
||||
- Purpose: 数据库迁移脚本。
|
||||
- Generated: No
|
||||
- Committed: Yes
|
||||
|
||||
**`docs/第三方文档/`:**
|
||||
- Purpose: 外部供应商或接口资料归档。
|
||||
- Generated: No
|
||||
- Committed: Yes
|
||||
|
||||
**`openspec/changes/archive/`:**
|
||||
- Purpose: 已归档历史变更。
|
||||
- Generated: No
|
||||
- Committed: Yes
|
||||
|
||||
**`.planning/codebase/`:**
|
||||
- Purpose: 供规划/执行命令消费的代码地图文档。
|
||||
- Generated: Yes
|
||||
- Committed: Yes
|
||||
|
||||
## Structural Notes for Contributors
|
||||
|
||||
- 这个仓库的“物理结构”严格围绕 Go 后端单体展开;新增代码优先放进现有层级,不要新造平行架构。
|
||||
- `internal/bootstrap/handlers.go` 当前会为客户端能力额外 new 一批 Store,这意味着某些接口不是纯 Service 注入;新增客户端功能前先确认是否要复用现有 `services` 聚合,还是遵循当前适配方式。
|
||||
- OpenAPI 不是完全自动发现;新增 Handler、Route 之后,必须同步维护 `internal/bootstrap/types.go`、`internal/bootstrap/handlers.go`、`internal/routes/*.go`、`cmd/api/docs.go`、`cmd/gendocs/main.go`、`pkg/openapi/handlers.go`。
|
||||
- 数据权限过滤不在统一 GORM 查询回调中自动生效;新增 Store 查询时,要主动选对 `ApplyShopFilter`、`ApplyEnterpriseFilter`、`ApplySellerShopFilter`、`ApplyOwnerShopFilter` 等函数。
|
||||
|
||||
---
|
||||
|
||||
*Structure analysis: 2026-03-27*
|
||||
182
.planning/codebase/TESTING.md
Normal file
182
.planning/codebase/TESTING.md
Normal file
@@ -0,0 +1,182 @@
|
||||
# Testing Patterns
|
||||
|
||||
**Analysis Date:** 2026-03-27
|
||||
|
||||
## Test Framework
|
||||
|
||||
**Runner:**
|
||||
- 当前仓库未检测到有效的 Go 自动化测试文件:`glob("**/*_test.go")` 返回空,`glob("tests/**")` 返回空。
|
||||
- 因此,仓库当前状态下不存在可执行的项目内单元测试、集成测试或 E2E 测试代码。
|
||||
|
||||
**Assertion Library:**
|
||||
- 未检测到;因为当前仓库没有 `*_test.go` 文件。
|
||||
|
||||
**Configured / Documented commands:**
|
||||
```bash
|
||||
go test ./... # `README.md` 文档命令;当前仓库没有测试文件
|
||||
go test -cover ./... # `README.md` 文档命令;覆盖率描述与仓库现状不符
|
||||
go test -v ./... # `Makefile` 的 `test` 目标与 `README.md` 一致
|
||||
go test ./pkg/... # `README.md` 文档命令;当前没有对应测试文件
|
||||
go test ./tests/integration/... # `README.md` 文档命令;当前仓库不存在 `tests/` 目录内容
|
||||
go test -v ./internal/middleware -run TestKeyAuth # `README.md` 文档命令;当前未检测到对应测试
|
||||
```
|
||||
|
||||
## Policy Reality
|
||||
|
||||
**Current project rule (authoritative):**
|
||||
- `AGENTS.md` 明确规定:本项目不使用任何形式的自动化测试代码。
|
||||
- `AGENTS.md` 明确禁止单元测试、集成测试、验收测试、流程测试、E2E 测试,以及创建 `*_test.go` 文件;唯一例外是用户明确要求“请写测试”。
|
||||
|
||||
**Repository reality:**
|
||||
- 当前仓库与该规则一致:未发现任何 `*_test.go` 文件,也未发现 `tests/` 目录内容。
|
||||
|
||||
**Stale documentation conflicts:**
|
||||
- `README.md` 仍包含完整“测试”章节,列出 `go test ./...`、覆盖率、集成测试、`tests/integration/`、以及 `docs/testing/test-connection-guide.md` 的说明。
|
||||
- `README.md` 的项目结构段仍展示 `tests/integration/auth_test.go`、`tests/integration/ratelimit_test.go`。
|
||||
- `README.md` 的 “Speckit / 宪章” 段仍写有 “70%+ 测试覆盖率,核心业务 90%+”。
|
||||
- 这些内容与 `AGENTS.md` 的现行禁令、以及仓库中真实文件状态冲突。后续执行应以 `AGENTS.md` + 实际文件现状为准。
|
||||
|
||||
## Verification Approach Actually Used
|
||||
|
||||
**Primary approach:**
|
||||
- 人工验证 + 脚本检查 + 文档生成检查 + 生产日志/监控。
|
||||
|
||||
**Evidence from policy:**
|
||||
- `AGENTS.md` 将替代方案明确写为:
|
||||
- 使用 PostgreSQL MCP 工具手动验证数据
|
||||
- 使用 Postman/curl 手动测试 API
|
||||
- 依赖生产环境日志和监控发现问题
|
||||
|
||||
**Repository scripts that support this model:**
|
||||
- `scripts/check-service-errors.sh`:静态扫描 Service 层错误处理规则。
|
||||
- `scripts/check-comment-paths.sh`:静态扫描 Handler 注释中的 API 路径是否过时。
|
||||
- `scripts/check-all.sh`:聚合上述脚本。
|
||||
- `scripts/verify_migration/main.go`:直接连接数据库查询表和字段,验证迁移结果;这是一种手工校验辅助程序,不是自动测试框架。
|
||||
- `cmd/gendocs/main.go` / `make docs`:通过重新生成 OpenAPI 文档来验证路由注册和文档接线是否完整。
|
||||
|
||||
## Test File Organization
|
||||
|
||||
**Location:**
|
||||
- 当前无自动化测试文件。
|
||||
|
||||
**Naming:**
|
||||
- 按 Go 常规应为 `*_test.go`,但本项目现行规则禁止新增,除非用户明确要求。
|
||||
|
||||
**Structure:**
|
||||
```text
|
||||
自动化测试目录:未检测到
|
||||
README 中声明的 `tests/integration/`:当前不存在实际文件
|
||||
```
|
||||
|
||||
## Manual Validation Patterns
|
||||
|
||||
**API / behavior validation:**
|
||||
- 使用 Postman、curl 或 Swagger/OpenAPI 文档进行接口人工验证,依据见 `AGENTS.md` 与 `docs/api-documentation-guide.md`。
|
||||
- 生成 OpenAPI 文档后检查路径是否出现:`go run cmd/gendocs/main.go`,再检查 `docs/admin-openapi.yaml`,依据见 `docs/api-documentation-guide.md`。
|
||||
|
||||
**Database validation:**
|
||||
- 涉及数据正确性时,使用 PostgreSQL MCP 工具做手动查询验证,依据见 `AGENTS.md` 的 `db-validation` 说明。
|
||||
- 涉及迁移结果时,可运行 `go run scripts/verify_migration/main.go` 检查表与字段;该脚本读取真实数据库 schema,不产出测试报告。
|
||||
|
||||
**Convention validation:**
|
||||
- 改动 Service 层错误处理后运行 `bash scripts/check-service-errors.sh`。
|
||||
- 改动 Handler 注释或路由后运行 `bash scripts/check-comment-paths.sh`。
|
||||
- 需要一次性检查时运行 `bash scripts/check-all.sh`。
|
||||
|
||||
**Documentation / OpenAPI validation:**
|
||||
- 新增 Handler 后必须同步更新 `cmd/api/docs.go` 与 `cmd/gendocs/main.go`,然后重新生成文档并检查目标路径,依据见 `docs/api-documentation-guide.md`。
|
||||
|
||||
## Mocking
|
||||
|
||||
**Framework:**
|
||||
- Not applicable;当前没有自动化测试代码。
|
||||
|
||||
**Patterns:**
|
||||
```text
|
||||
未检测到 mock 框架、测试替身、fixture helper 或 testutil 包的实际使用。
|
||||
```
|
||||
|
||||
**What to Mock:**
|
||||
- 当前仓库没有现行自动化测试实践可供复用。
|
||||
|
||||
**What NOT to Mock:**
|
||||
- 当前仓库没有现行自动化测试实践可供复用。
|
||||
|
||||
## Fixtures and Factories
|
||||
|
||||
**Test Data:**
|
||||
```text
|
||||
未检测到 fixture/factory 测试数据目录或 `_test.go` 中的构造模式。
|
||||
```
|
||||
|
||||
**Reality check:**
|
||||
- `README.md` 仍引用 `docs/testing/test-connection-guide.md` 和 `testutils.NewTestTransaction(...)` 示例,但当前仓库没有对应测试文件作为实际落点。
|
||||
|
||||
## Coverage
|
||||
|
||||
**Requirements:**
|
||||
- 当前现行项目规则不要求覆盖率,且原则上禁止自动化测试,依据见 `AGENTS.md`。
|
||||
|
||||
**Conflict to document clearly:**
|
||||
- `README.md` 和其中引用的 Speckit 宪章仍声称需要覆盖率目标;这与 `AGENTS.md` 的测试禁令冲突,且与当前“零测试文件”现状不符。
|
||||
|
||||
**View Coverage:**
|
||||
```bash
|
||||
go test -cover ./... # 仅为 README 中遗留命令,不代表现行流程
|
||||
```
|
||||
|
||||
## Test Types
|
||||
|
||||
**Unit Tests:**
|
||||
- 当前未使用。
|
||||
|
||||
**Integration Tests:**
|
||||
- 当前未使用。
|
||||
- `README.md` 里提到的 `tests/integration/` 是过期描述,不是当前仓库事实。
|
||||
|
||||
**E2E Tests:**
|
||||
- 当前未使用。
|
||||
|
||||
## CI Reality
|
||||
|
||||
**Actual workflow present:**
|
||||
- `.gitea/workflows/deploy.yaml` 只有检出、Docker 构建、推送镜像、部署到测试环境的步骤。
|
||||
|
||||
**What is not in CI:**
|
||||
- 未发现 `go test`。
|
||||
- 未发现 `bash scripts/check-all.sh`。
|
||||
- 未发现 `bash scripts/check-service-errors.sh`。
|
||||
- 未发现 `bash scripts/check-comment-paths.sh`。
|
||||
- 未发现独立的质量门或覆盖率上传步骤。
|
||||
|
||||
**Conflict to document clearly:**
|
||||
- `README.md` 写着“这些检查会在 CI/CD 流程中自动执行”,但 `.gitea/workflows/deploy.yaml` 中没有相应步骤。当前 CI 现实是“构建/部署优先,无显式质量校验门”。
|
||||
|
||||
## Common Verification Commands
|
||||
|
||||
```bash
|
||||
bash scripts/check-service-errors.sh # 校验 Service 错误处理规则
|
||||
bash scripts/check-comment-paths.sh # 校验 Handler 注释路径是否过时
|
||||
bash scripts/check-all.sh # 运行当前已存在的全部静态脚本检查
|
||||
go run cmd/gendocs/main.go # 重新生成 OpenAPI 文档,验证路由接线
|
||||
make docs # 等价于生成 OpenAPI 文档
|
||||
go run scripts/verify_migration/main.go # 人工验证数据库迁移结果
|
||||
```
|
||||
|
||||
## Current Gaps and Caveats
|
||||
|
||||
- `Makefile` 仍保留 `test:` 目标并执行 `go test -v ./...`,这属于历史遗留入口,不应被误解为现行团队要求。
|
||||
- `README.md` 的测试章节、目录结构和覆盖率目标明显滞后于当前 `AGENTS.md` 规则。
|
||||
- `scripts/check-service-errors.sh` 虽然存在,但当前仓库 `internal/service/polling/alert_service.go` 仍有 `fmt.Errorf(...)` 违规点,说明现有脚本检查并未通过 CI 强制执行。
|
||||
- `scripts/verify_migration/main.go` 内含直接数据库连接字符串,反映的是临时人工验证脚本,而不是可复用、无环境依赖的测试体系。
|
||||
|
||||
## Prescriptive Summary
|
||||
|
||||
- 讨论“测试”时,先区分三件事:`README.md` 的历史文档、`AGENTS.md` 的现行规则、仓库当前真实文件状态。
|
||||
- 在当前仓库中,默认不要新增 `*_test.go`;只有用户明确要求时才编写测试代码。
|
||||
- 日常验证优先使用:静态检查脚本、OpenAPI 文档再生成、数据库手查、Postman/curl、人肉回归。
|
||||
- 判断 CI 是否有质量门时,以 `.gitea/workflows/deploy.yaml` 为准,而不是 `README.md` 的说明。
|
||||
|
||||
---
|
||||
|
||||
*Testing analysis: 2026-03-27*
|
||||
36
.planning/config.json
Normal file
36
.planning/config.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"model_profile": "balanced",
|
||||
"commit_docs": true,
|
||||
"parallelization": true,
|
||||
"search_gitignored": false,
|
||||
"brave_search": false,
|
||||
"firecrawl": false,
|
||||
"exa_search": false,
|
||||
"git": {
|
||||
"branching_strategy": "none",
|
||||
"phase_branch_template": "gsd/phase-{phase}-{slug}",
|
||||
"milestone_branch_template": "gsd/{milestone}-{slug}",
|
||||
"quick_branch_template": null
|
||||
},
|
||||
"workflow": {
|
||||
"research": true,
|
||||
"plan_check": true,
|
||||
"verifier": true,
|
||||
"nyquist_validation": true,
|
||||
"auto_advance": false,
|
||||
"node_repair": true,
|
||||
"node_repair_budget": 2,
|
||||
"ui_phase": false,
|
||||
"ui_safety_gate": false,
|
||||
"text_mode": false,
|
||||
"research_before_questions": false,
|
||||
"discuss_mode": "discuss",
|
||||
"skip_discuss": false,
|
||||
"_auto_chain_active": false
|
||||
},
|
||||
"hooks": {
|
||||
"context_warnings": true
|
||||
},
|
||||
"agent_skills": {},
|
||||
"resolve_model_ids": "omit"
|
||||
}
|
||||
434
.planning/phases/02-perm-fin-realname-poll/02-01-PLAN.md
Normal file
434
.planning/phases/02-perm-fin-realname-poll/02-01-PLAN.md
Normal file
@@ -0,0 +1,434 @@
|
||||
---
|
||||
phase: 02-perm-fin-realname-poll
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- internal/handler/admin/asset.go
|
||||
- internal/handler/admin/commission_withdrawal.go
|
||||
- internal/service/commission_withdrawal/service.go
|
||||
- internal/service/commission_withdrawal_setting/service.go
|
||||
- internal/store/postgres/wechat_config_store.go
|
||||
- internal/polling/scheduler.go
|
||||
- internal/task/polling_handler.go
|
||||
- internal/service/iot_card/stop_resume_service.go
|
||||
- internal/service/asset/service.go
|
||||
- internal/store/postgres/package_store.go
|
||||
- internal/service/package_series/service.go
|
||||
autonomous: true
|
||||
requirements:
|
||||
- PERM-01
|
||||
- PERM-02
|
||||
- FIN-01
|
||||
- FIN-02
|
||||
- FIN-03
|
||||
- POLL-01
|
||||
- POLL-02
|
||||
- POLL-03
|
||||
- POLL-04
|
||||
must_haves:
|
||||
truths:
|
||||
- "企业账号可调用 Resolve 接口查询被授权资产,不再被 403 拦截"
|
||||
- "企业账号调用 Refresh 接口时返回 403 业务错误"
|
||||
- "提现审批通过后,approved_by 和 approved_at 字段正确填充"
|
||||
- "拒绝提现时 remark 为空返回参数校验错误,申请状态不变"
|
||||
- "同时激活两个提现配置时,行锁生效,任何时刻最多一条 is_active=true"
|
||||
- "polling:protect 任务进入调度器,日志可见 protect 队列被处理"
|
||||
- "stopCardWithRetry/resumeCardWithRetry 在 gatewayClient==nil 时返回业务错误而非 nil"
|
||||
- "GetPackages 接口响应中包含 page/page_size/total 分页字段,默认 page=1 pageSize=50"
|
||||
- "删除有关联套餐的 PackageSeries 时返回错误提示而非成功"
|
||||
artifacts:
|
||||
- path: "internal/handler/admin/asset.go"
|
||||
provides: "PERM-01 删除 Resolve 企业拦截;PERM-02 新增 Refresh 企业拦截"
|
||||
- path: "internal/service/commission_withdrawal/service.go"
|
||||
provides: "FIN-01 Approve 补 approved_by/approved_at"
|
||||
- path: "internal/handler/admin/commission_withdrawal.go"
|
||||
provides: "FIN-02 Reject 补 validator.Validate"
|
||||
- path: "internal/service/commission_withdrawal_setting/service.go"
|
||||
provides: "FIN-03 激活配置前加 FOR UPDATE 行锁"
|
||||
- path: "internal/store/postgres/wechat_config_store.go"
|
||||
provides: "FIN-03 微信配置激活前加 FOR UPDATE 行锁"
|
||||
- path: "internal/polling/scheduler.go"
|
||||
provides: "POLL-01 补 polling:protect 调度"
|
||||
- path: "internal/service/iot_card/stop_resume_service.go"
|
||||
provides: "POLL-02 gatewayClient==nil 返回业务错误"
|
||||
- path: "internal/service/asset/service.go"
|
||||
provides: "POLL-03 GetPackages 支持分页"
|
||||
- path: "internal/service/package_series/service.go"
|
||||
provides: "POLL-04 Delete 前检查关联套餐"
|
||||
- path: "internal/store/postgres/package_store.go"
|
||||
provides: "POLL-04 新增 CountBySeriesID 方法"
|
||||
key_links:
|
||||
- from: "internal/handler/admin/asset.go:Resolve"
|
||||
to: "constants.UserTypeEnterprise"
|
||||
via: "middleware.GetUserTypeFromContext"
|
||||
pattern: "UserTypeEnterprise"
|
||||
- from: "internal/service/commission_withdrawal/service.go:Approve"
|
||||
to: "tb_commission_withdrawal_request.approved_by/approved_at"
|
||||
via: "updates map"
|
||||
pattern: "approved_by.*approved_at"
|
||||
- from: "internal/polling/scheduler.go"
|
||||
to: "constants.TaskTypePollingProtect"
|
||||
via: "processManualQueue+processTimedQueue"
|
||||
pattern: "TaskTypePollingProtect"
|
||||
---
|
||||
|
||||
<objective>
|
||||
修复 PERM(企业权限)+ FIN(财务流程)+ POLL(轮询小修)三个域共 9 个需求。
|
||||
|
||||
Purpose: 补全企业账号权限边界(可查不可刷新)、提现流程数据完整性(审批人/remark 必填/激活并发锁)、轮询调度和安全修复,全部为独立小修,无跨域依赖。
|
||||
Output: 9 个文件修改,每个独立 Bug 一个 commit。
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.config/opencode/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/02-perm-fin-realname-poll/02-CONTEXT.md
|
||||
|
||||
# 修复规格(核心参考)
|
||||
@.sisyphus/plans/修正业务-完整方案.md(方案 B 766-863 行,方案 C 864-944 行,方案 H 1612-1711 行)
|
||||
|
||||
<interfaces>
|
||||
<!-- 本 plan 执行时所需的关键接口,直接使用,无需探索 -->
|
||||
|
||||
**asset.go 当前 Resolve 企业拦截(第 40-44 行,PERM-01 要删除)**:
|
||||
```go
|
||||
func (h *AssetHandler) Resolve(c *fiber.Ctx) error {
|
||||
userType := middleware.GetUserTypeFromContext(c.UserContext())
|
||||
if userType == constants.UserTypeEnterprise {
|
||||
return errors.New(errors.CodeForbidden, "企业账号暂不支持此接口")
|
||||
}
|
||||
// ...
|
||||
```
|
||||
|
||||
**asset.go Refresh(第 78 行附近,PERM-02 在此处新增企业拦截)**:
|
||||
```go
|
||||
func (h *AssetHandler) Refresh(c *fiber.Ctx) error {
|
||||
assetType := c.Params("asset_type")
|
||||
// 需在此处开头插入企业拦截
|
||||
```
|
||||
|
||||
**commission_withdrawal/service.go:Approve updates map(FIN-01)**:
|
||||
```go
|
||||
updates := map[string]interface{}{
|
||||
"status": constants.WithdrawalStatusApproved,
|
||||
"processor_id": currentUserID,
|
||||
"processed_at": now,
|
||||
"payment_type": req.PaymentType,
|
||||
"remark": req.Remark,
|
||||
// 需补充:
|
||||
// "approved_by": currentUserID,
|
||||
// "approved_at": now,
|
||||
```
|
||||
|
||||
**commission_withdrawal.go:Reject(FIN-02)**:
|
||||
```go
|
||||
// 当前仅有 BodyParser,缺少 Validate:
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
// 需补充 validator.Validate(&req)
|
||||
```
|
||||
|
||||
**scheduler.go 当前调度(第 243-250 行,POLL-01 新增 protect)**:
|
||||
```go
|
||||
s.processManualQueue(ctx, constants.TaskTypePollingRealname)
|
||||
s.processManualQueue(ctx, constants.TaskTypePollingCarddata)
|
||||
s.processManualQueue(ctx, constants.TaskTypePollingPackage)
|
||||
|
||||
s.processTimedQueue(ctx, constants.RedisPollingQueueRealnameKey(), constants.TaskTypePollingRealname, now)
|
||||
s.processTimedQueue(ctx, constants.RedisPollingQueueCarddataKey(), constants.TaskTypePollingCarddata, now)
|
||||
s.processTimedQueue(ctx, constants.RedisPollingQueuePackageKey(), constants.TaskTypePollingPackage, now)
|
||||
// 需新增 TaskTypePollingProtect 两行
|
||||
```
|
||||
|
||||
**stop_resume_service.go:stopCardWithRetry(第 178 行,POLL-02)**:
|
||||
```go
|
||||
if s.gatewayClient == nil {
|
||||
s.logger.Warn("Gateway 客户端未配置,跳过调用运营商接口", zap.Uint("card_id", card.ID))
|
||||
return nil // ← 改为返回业务错误
|
||||
}
|
||||
```
|
||||
|
||||
**asset/service.go:GetPackages 当前签名(POLL-03 需改)**:
|
||||
```go
|
||||
func (s *Service) GetPackages(ctx context.Context, assetType string, id uint) ([]*dto.AssetPackageResponse, error)
|
||||
// 需新增 page, pageSize 参数,并在响应中返回分页信息
|
||||
```
|
||||
|
||||
**package_series/service.go:Delete(POLL-04 需在首行前添加检查)**:
|
||||
```go
|
||||
func (s *Service) Delete(ctx context.Context, id uint) error {
|
||||
// 需新增 CountBySeriesID 检查
|
||||
return s.packageSeriesStore.Delete(ctx, id)
|
||||
}
|
||||
```
|
||||
|
||||
**FIN-03 GORM v2 行锁写法(参考项目 clause 用法)**:
|
||||
```go
|
||||
import "gorm.io/gorm/clause"
|
||||
|
||||
tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("is_active = ?", true).
|
||||
First(¤t)
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: PERM + FIN 修复(权限/财务流程)</name>
|
||||
<files>
|
||||
internal/handler/admin/asset.go,
|
||||
internal/service/commission_withdrawal/service.go,
|
||||
internal/handler/admin/commission_withdrawal.go,
|
||||
internal/service/commission_withdrawal_setting/service.go,
|
||||
internal/store/postgres/wechat_config_store.go
|
||||
</files>
|
||||
<action>
|
||||
**PERM-01(per D-04)**:`internal/handler/admin/asset.go:Resolve`
|
||||
|
||||
删除第 40-44 行的企业拦截代码块(共 3 行):
|
||||
```go
|
||||
// 删除以下代码:
|
||||
userType := middleware.GetUserTypeFromContext(c.UserContext())
|
||||
if userType == constants.UserTypeEnterprise {
|
||||
return errors.New(errors.CodeForbidden, "企业账号暂不支持此接口")
|
||||
}
|
||||
```
|
||||
注意:若 `userType` 变量在删除后此函数不再使用,同步删除导入的 `constants` 包引用(如其他方法仍在用则保留)。
|
||||
|
||||
**PERM-02(per D-05)**:`internal/handler/admin/asset.go:Refresh`
|
||||
|
||||
在 `Refresh` 方法开头(`assetType := c.Params("asset_type")` 之前)插入企业拦截:
|
||||
```go
|
||||
// 企业账号只读,不允许主动触发运营商刷新
|
||||
userType := middleware.GetUserTypeFromContext(c.UserContext())
|
||||
if userType == constants.UserTypeEnterprise {
|
||||
return errors.New(errors.CodeForbidden, "企业账号无权主动刷新资产状态")
|
||||
}
|
||||
```
|
||||
|
||||
完成后 commit:`fix(perm): 修复企业账号资产权限边界 PERM-01/02`
|
||||
|
||||
**FIN-01(per D-07)**:`internal/service/commission_withdrawal/service.go:Approve`
|
||||
|
||||
在 `updates` map 中补充审批人字段(紧跟 `"processed_at": now` 之后):
|
||||
```go
|
||||
"approved_by": currentUserID,
|
||||
"approved_at": now,
|
||||
```
|
||||
|
||||
完成后 commit:`fix(fin): 补全提现审批人字段 FIN-01`
|
||||
|
||||
**FIN-02(per D-08)**:`internal/handler/admin/commission_withdrawal.go:Reject`
|
||||
|
||||
在 `BodyParser` 后补充参数验证:
|
||||
```go
|
||||
if err := validator.Validate(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
```
|
||||
`validator` 包引用参考同文件或同目录其他 Handler 的 import 写法。
|
||||
|
||||
完成后 commit:`fix(fin): 提现拒绝补 remark 必填校验 FIN-02`
|
||||
|
||||
**FIN-03(per D-09)**:`internal/service/commission_withdrawal_setting/service.go`
|
||||
|
||||
找到激活配置的事务逻辑(先全部置 false,再置目标为 true),在事务内第一步 UPDATE 前加行锁:
|
||||
```go
|
||||
// 先锁定当前活跃记录,防止并发激活
|
||||
var current model.CommissionWithdrawalSetting
|
||||
tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("is_active = ?", true).
|
||||
First(¤t) // 不管 err,只是获取锁
|
||||
|
||||
// 继续原有的 deactivate → activate 逻辑
|
||||
```
|
||||
同理修改 `internal/store/postgres/wechat_config_store.go` 微信配置激活逻辑(同一模式)。
|
||||
|
||||
需要导入:`"gorm.io/gorm/clause"`(检查项目是否已有此导入,有则直接用)。
|
||||
|
||||
完成后 commit:`fix(fin): 激活配置并发行锁保护 FIN-03`
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go build ./... 2>&1 | head -30</automated>
|
||||
</verify>
|
||||
<done>
|
||||
- Resolve 方法中企业拦截代码块已删除,Refresh 方法开头已新增企业拦截
|
||||
- commission_withdrawal/service.go Approve updates map 包含 approved_by/approved_at
|
||||
- commission_withdrawal.go Reject 有 validator.Validate 调用
|
||||
- commission_withdrawal_setting/service.go 和 wechat_config_store.go 激活事务内有 clause.Locking
|
||||
- go build ./... 无编译错误
|
||||
- 已产出 3 个独立 commit(PERM、FIN-01/02、FIN-03)
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: POLL 修复(轮询调度/安全/分页/系列删除保护)</name>
|
||||
<files>
|
||||
internal/polling/scheduler.go,
|
||||
internal/service/iot_card/stop_resume_service.go,
|
||||
internal/service/asset/service.go,
|
||||
internal/store/postgres/package_store.go,
|
||||
internal/service/package_series/service.go
|
||||
</files>
|
||||
<action>
|
||||
**POLL-01(per D-14)**:`internal/polling/scheduler.go`
|
||||
|
||||
在主调度循环第 243-250 行,紧跟现有 3 个 polling 类型之后新增 protect 调度:
|
||||
```go
|
||||
// 新增:保护期一致性检查调度
|
||||
s.processManualQueue(ctx, constants.TaskTypePollingProtect)
|
||||
s.processTimedQueue(ctx, constants.RedisPollingQueueProtectKey(), constants.TaskTypePollingProtect, now)
|
||||
```
|
||||
确认 `constants.RedisPollingQueueProtectKey()` 已在 `pkg/constants/redis.go:374` 定义(已存在)。
|
||||
如有 `initCardPolling()` 或类似初始化 protect 队列的函数不存在,参考 realname/carddata/package 的初始化方式补充。
|
||||
|
||||
完成后 commit:`fix(poll): polling:protect 接入调度器 POLL-01`
|
||||
|
||||
**POLL-02(per D-15)**:`internal/service/iot_card/stop_resume_service.go`
|
||||
|
||||
**两处都修改**:`stopCardWithRetry`(第 178 行)和 `resumeCardWithRetry`(第 211 行)
|
||||
|
||||
将 `return nil` 改为返回业务错误:
|
||||
```go
|
||||
// 修改前:
|
||||
if s.gatewayClient == nil {
|
||||
s.logger.Warn("Gateway 客户端未配置,跳过调用运营商接口", ...)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 修改后:
|
||||
if s.gatewayClient == nil {
|
||||
return errors.New(errors.CodeInternalError, "Gateway 未配置,停复机操作不可用")
|
||||
}
|
||||
```
|
||||
删除原有 `s.logger.Warn` 日志行(不再是"跳过",而是直接拒绝)。
|
||||
|
||||
完成后 commit:`fix(poll): gatewayClient=nil 时停复机返回错误而非成功 POLL-02`
|
||||
|
||||
**POLL-03(per D-16)**:`internal/service/asset/service.go:GetPackages`
|
||||
|
||||
当前签名为 `GetPackages(ctx, assetType, id)` 返回 `([]*dto.AssetPackageResponse, error)`。
|
||||
|
||||
改写为支持分页:
|
||||
1. 函数签名改为 `GetPackages(ctx context.Context, assetType string, id uint, page, pageSize int) (*dto.AssetPackagesResult, error)`
|
||||
2. 在函数开头处理参数边界:page 默认 1,pageSize 默认 50,pageSize 最大 100
|
||||
3. 新建 `dto.AssetPackagesResult` 结构体(在 `internal/model/dto/asset_dto.go`):
|
||||
```go
|
||||
// AssetPackagesResult 套餐列表分页结果
|
||||
type AssetPackagesResult struct {
|
||||
Total int64 `json:"total" description:"总条数"`
|
||||
Page int `json:"page" description:"当前页码"`
|
||||
PageSize int `json:"page_size" description:"每页条数"`
|
||||
Items []*AssetPackageResponse `json:"items" description:"套餐列表"`
|
||||
}
|
||||
```
|
||||
4. 在 DB 查询时添加 `.Count(&total).Offset((page-1)*pageSize).Limit(pageSize)`
|
||||
5. 更新 Handler 层 `asset.go:Packages` 从 Query 参数读取 page/pageSize(若 Query 参数为空使用默认值),Handler 调用改为传入分页参数
|
||||
|
||||
检查 `handler/app/client_asset.go` 中是否也调用了 `GetPackages`,若有则同步更新调用签名,传入默认分页参数(page=1, pageSize=50)。
|
||||
|
||||
完成后 commit:`fix(poll): GetPackages 接口新增分页 POLL-03`
|
||||
|
||||
**POLL-04(per D-17)**:`internal/service/package_series/service.go:Delete` + `internal/store/postgres/package_store.go`
|
||||
|
||||
第一步:在 `package_store.go` 中新增 `CountBySeriesID` 方法(参考同文件其他 Count 方法的写法):
|
||||
```go
|
||||
// CountBySeriesID 统计指定系列下的套餐数量(含软删除前的活跃记录)
|
||||
func (s *PackageStore) CountBySeriesID(ctx context.Context, seriesID uint) (int64, error) {
|
||||
var count int64
|
||||
err := s.db.WithContext(ctx).Model(&model.Package{}).
|
||||
Where("series_id = ? AND deleted_at IS NULL", seriesID).
|
||||
Count(&count).Error
|
||||
if err != nil {
|
||||
return 0, errors.Wrap(errors.CodeDatabaseError, err, "统计关联套餐失败")
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
```
|
||||
|
||||
第二步:在 `package_series/service.go:Delete` 中注入并调用 `packageStore.CountBySeriesID`:
|
||||
```go
|
||||
func (s *Service) Delete(ctx context.Context, id uint) error {
|
||||
// 前置检查:有关联套餐则拒绝删除
|
||||
count, err := s.packageStore.CountBySeriesID(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return errors.New(errors.CodeInvalidParam, fmt.Sprintf("该系列下有 %d 个关联套餐,请先处理后再删除", count))
|
||||
}
|
||||
return s.packageSeriesStore.Delete(ctx, id)
|
||||
}
|
||||
```
|
||||
|
||||
需确认 `Service` 结构体中有 `packageStore` 字段(若无则注入)。
|
||||
|
||||
完成后 commit:`fix(poll): Series 删除前检查关联套餐 POLL-04`
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go build ./... 2>&1 | head -30</automated>
|
||||
</verify>
|
||||
<done>
|
||||
- scheduler.go 在第 245-250 行区域新增了 protect 的 processManualQueue 和 processTimedQueue 调用
|
||||
- stop_resume_service.go 两处 gatewayClient==nil 均返回 errors.New 而非 nil
|
||||
- GetPackages 函数签名包含 page/pageSize 参数,响应结构体含 total/page/page_size/items
|
||||
- package_store.go 新增 CountBySeriesID 方法;package_series/service.go:Delete 首行检查关联套餐
|
||||
- go build ./... 无编译错误
|
||||
- 已产出 4 个独立 commit(POLL-01/02/03/04)
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
所有 PERM + FIN + POLL 修复完成后执行最终验收:
|
||||
|
||||
```bash
|
||||
# 编译验证
|
||||
go build ./...
|
||||
|
||||
# 搜索验证:PERM
|
||||
grep -n "UserTypeEnterprise" internal/handler/admin/asset.go
|
||||
# 预期:Resolve 中无企业拦截;Refresh 中有企业拦截
|
||||
|
||||
# 搜索验证:FIN-01
|
||||
grep -n "approved_by\|approved_at" internal/service/commission_withdrawal/service.go
|
||||
# 预期:updates map 中包含两个字段
|
||||
|
||||
# 搜索验证:POLL-01
|
||||
grep -n "TaskTypePollingProtect\|RedisPollingQueueProtectKey" internal/polling/scheduler.go
|
||||
# 预期:出现两行新增调用
|
||||
|
||||
# 搜索验证:POLL-02
|
||||
grep -n "return nil" internal/service/iot_card/stop_resume_service.go
|
||||
# 预期:stopCardWithRetry 和 resumeCardWithRetry 中 gatewayClient==nil 分支不再有 return nil
|
||||
```
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- 所有 9 个需求(PERM-01/02, FIN-01/02/03, POLL-01/02/03/04)均有对应代码变更
|
||||
- go build ./... 零错误
|
||||
- 每个独立 Bug 均有独立 commit(共 7 个 commit:PERM, FIN-01, FIN-02, FIN-03, POLL-01, POLL-02, POLL-03, POLL-04 → 实际按修复内容分组为 7 次提交)
|
||||
- Resolve 方法企业拦截已删除;Refresh 方法企业拦截已添加
|
||||
- commission_withdrawal:Approve 包含 approved_by/approved_at;Reject 包含 validator.Validate
|
||||
- 激活配置事务中包含 clause.Locking{Strength: "UPDATE"}
|
||||
- scheduler.go 包含 protect 的 processManualQueue+processTimedQueue 调用
|
||||
- 停复机函数 gatewayClient==nil 返回错误
|
||||
- GetPackages 签名含分页参数,响应含分页字段
|
||||
- 系列删除前有 CountBySeriesID 检查
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
完成后创建:`.planning/phases/02-perm-fin-realname-poll/02-01-SUMMARY.md`
|
||||
</output>
|
||||
148
.planning/phases/02-perm-fin-realname-poll/02-01-SUMMARY.md
Normal file
148
.planning/phases/02-perm-fin-realname-poll/02-01-SUMMARY.md
Normal file
@@ -0,0 +1,148 @@
|
||||
---
|
||||
phase: 02-perm-fin-realname-poll
|
||||
plan: "01"
|
||||
subsystem: perm-fin-poll
|
||||
tags:
|
||||
- permissions
|
||||
- finance
|
||||
- polling
|
||||
- enterprise
|
||||
- concurrency
|
||||
dependency_graph:
|
||||
requires: []
|
||||
provides:
|
||||
- PERM-01
|
||||
- PERM-02
|
||||
- FIN-01
|
||||
- FIN-02
|
||||
- FIN-03
|
||||
- POLL-01
|
||||
- POLL-02
|
||||
- POLL-03
|
||||
- POLL-04
|
||||
affects:
|
||||
- internal/handler/admin/asset.go
|
||||
- internal/service/commission_withdrawal/service.go
|
||||
- internal/handler/admin/commission_withdrawal.go
|
||||
- internal/service/commission_withdrawal_setting/service.go
|
||||
- internal/store/postgres/wechat_config_store.go
|
||||
- internal/polling/scheduler.go
|
||||
- internal/service/iot_card/stop_resume_service.go
|
||||
- internal/service/asset/service.go
|
||||
- internal/store/postgres/package_store.go
|
||||
- internal/service/package_series/service.go
|
||||
tech_stack:
|
||||
added: []
|
||||
patterns:
|
||||
- gorm.io/gorm/clause FOR UPDATE 行锁防并发
|
||||
- go-playground/validator 参数校验
|
||||
- 函数签名扩展(分页参数)
|
||||
key_files:
|
||||
created: []
|
||||
modified:
|
||||
- internal/handler/admin/asset.go
|
||||
- internal/service/commission_withdrawal/service.go
|
||||
- internal/handler/admin/commission_withdrawal.go
|
||||
- internal/service/commission_withdrawal_setting/service.go
|
||||
- internal/store/postgres/wechat_config_store.go
|
||||
- internal/polling/scheduler.go
|
||||
- internal/service/iot_card/stop_resume_service.go
|
||||
- internal/service/asset/service.go
|
||||
- internal/model/dto/asset_dto.go
|
||||
- internal/store/postgres/package_store.go
|
||||
- internal/service/package_series/service.go
|
||||
- internal/bootstrap/handlers.go
|
||||
- internal/bootstrap/services.go
|
||||
- pkg/openapi/handlers.go
|
||||
decisions:
|
||||
- 企业账号权限边界:Resolve 可查(删除拦截),Refresh 不可主动刷新(新增拦截)
|
||||
- GetPackages 分页在内存中排序后切片,避免改动 Store 层查询逻辑
|
||||
- Series 删除检查在 Service 层实现(非 DB 约束),符合项目禁止外键规范
|
||||
- CommissionWithdrawalHandler 新增 validator 字段注入,与其他 Handler 保持一致
|
||||
metrics:
|
||||
duration: "约 10 分钟"
|
||||
completed_date: "2026-03-28"
|
||||
tasks: 2
|
||||
files: 14
|
||||
---
|
||||
|
||||
# Phase 02 Plan 01: PERM + FIN + POLL 9项独立修复 Summary
|
||||
|
||||
企业账号权限边界(Resolve 可查/Refresh 只读)、提现流程数据完整性(审批人字段/remark 必填/激活并发锁)、轮询调度和停复机安全修复,全部为独立小修,共 9 个需求 8 个 commit。
|
||||
|
||||
## 执行结果
|
||||
|
||||
所有 9 个需求(PERM-01/02, FIN-01/02/03, POLL-01/02/03/04)均已修复,`go build ./...` 零新增错误(已有两个预存在的 `service/package` 字段缺失错误不在本 plan 范围内)。
|
||||
|
||||
## Task 1: PERM + FIN 修复
|
||||
|
||||
### PERM-01 (commit: 2a92ab6)
|
||||
- **文件**: `internal/handler/admin/asset.go`
|
||||
- **变更**: 删除 `Resolve` 方法中对企业账号的错误拦截(企业账号应可查询资产)
|
||||
|
||||
### PERM-02 (commit: 2a92ab6)
|
||||
- **文件**: `internal/handler/admin/asset.go`
|
||||
- **变更**: `Refresh` 方法开头新增企业账号拦截(企业账号只读,不允许主动触发运营商刷新)
|
||||
|
||||
### FIN-01 (commit: 8f62496)
|
||||
- **文件**: `internal/service/commission_withdrawal/service.go`
|
||||
- **变更**: `Approve` 的 `updates` map 补充 `approved_by` 和 `approved_at` 字段
|
||||
|
||||
### FIN-02 (commit: 9ee2f0b)
|
||||
- **文件**: `internal/handler/admin/commission_withdrawal.go`, `internal/bootstrap/handlers.go`, `pkg/openapi/handlers.go`
|
||||
- **变更**: `CommissionWithdrawalHandler` 新增 `validator` 字段;`RejectWithdrawal` 补充 `validator.Struct` 参数验证,确保 `remark` 必填
|
||||
|
||||
### FIN-03 (commit: bdac4ab)
|
||||
- **文件**: `internal/service/commission_withdrawal_setting/service.go`, `internal/store/postgres/wechat_config_store.go`
|
||||
- **变更**: 激活配置事务内首步加 `clause.Locking{Strength: "UPDATE"}` 行锁,防止并发时两个事务同时置 `is_active=true`
|
||||
|
||||
## Task 2: POLL 修复
|
||||
|
||||
### POLL-01 (commit: 922a3d0)
|
||||
- **文件**: `internal/polling/scheduler.go`
|
||||
- **变更**: `processSchedule` 新增 `TaskTypePollingProtect` 的 `processManualQueue` 和 `processTimedQueue` 调用
|
||||
|
||||
### POLL-02 (commit: 8a53123)
|
||||
- **文件**: `internal/service/iot_card/stop_resume_service.go`
|
||||
- **变更**: `stopCardWithRetry` 和 `resumeCardWithRetry` 中 `gatewayClient==nil` 时返回 `errors.New(CodeInternalError, ...)` 而非 `nil`(原来假装成功会误更新卡状态)
|
||||
|
||||
### POLL-03 (commit: 8553a46)
|
||||
- **文件**: `internal/model/dto/asset_dto.go`, `internal/service/asset/service.go`, `internal/handler/admin/asset.go`, `internal/handler/app/client_asset.go`
|
||||
- **变更**: 新增 `AssetPackagesResult` DTO;`GetPackages` 签名新增 `page/pageSize` 参数(默认 page=1, pageSize=50, max=100);admin handler 从 Query 参数读取;client_asset.go 调用处同步更新
|
||||
|
||||
### POLL-04 (commit: 47aa5f8)
|
||||
- **文件**: `internal/store/postgres/package_store.go`, `internal/service/package_series/service.go`, `internal/bootstrap/services.go`
|
||||
- **变更**: `PackageStore` 新增 `CountBySeriesID` 方法;`PackageSeries.Service` 注入 `packageStore` 字段;`Delete` 前检查关联套餐数量,有则拒绝删除
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 3 - Blocking] pkg/openapi/handlers.go 需同步更新签名**
|
||||
- **Found during**: Task 1 FIN-02 完成后最终编译验证
|
||||
- **Issue**: `CommissionWithdrawalHandler` 构造函数新增了 `validator` 参数,`pkg/openapi/handlers.go` 中文档生成器调用处仍使用旧签名导致编译失败
|
||||
- **Fix**: `admin.NewCommissionWithdrawalHandler(nil)` 改为 `admin.NewCommissionWithdrawalHandler(nil, nil)`
|
||||
- **Files modified**: `pkg/openapi/handlers.go`
|
||||
- **Commit**: 754bbcd
|
||||
|
||||
## Known Stubs
|
||||
|
||||
无 — 所有修复均为实际业务逻辑,无留桩或占位符。
|
||||
|
||||
## Commits
|
||||
|
||||
| Commit | Message |
|
||||
|--------|---------|
|
||||
| 2a92ab6 | fix(perm): 修复企业账号资产权限边界 PERM-01/02 |
|
||||
| 8f62496 | fix(fin): 补全提现审批人字段 FIN-01 |
|
||||
| 9ee2f0b | fix(fin): 提现拒绝补 remark 必填校验 FIN-02 |
|
||||
| bdac4ab | fix(fin): 激活配置并发行锁保护 FIN-03 |
|
||||
| 922a3d0 | fix(poll): polling:protect 接入调度器 POLL-01 |
|
||||
| 8a53123 | fix(poll): gatewayClient=nil 时停复机返回错误而非成功 POLL-02 |
|
||||
| 8553a46 | fix(poll): GetPackages 接口新增分页 POLL-03 |
|
||||
| 47aa5f8 | fix(poll): Series 删除前检查关联套餐 POLL-04 |
|
||||
| 754bbcd | fix(fin): 同步更新 openapi handlers 中 CommissionWithdrawalHandler 签名 |
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
所有关键文件存在,所有 commits 均可在 git log 中查到。
|
||||
444
.planning/phases/02-perm-fin-realname-poll/02-02-PLAN.md
Normal file
444
.planning/phases/02-perm-fin-realname-poll/02-02-PLAN.md
Normal file
@@ -0,0 +1,444 @@
|
||||
---
|
||||
phase: 02-perm-fin-realname-poll
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- migrations/000089_realname_expiry_base.up.sql
|
||||
- migrations/000089_realname_expiry_base.down.sql
|
||||
- internal/model/package.go
|
||||
- internal/model/dto/package_dto.go
|
||||
- internal/store/postgres/package_store.go
|
||||
- internal/service/order/service.go
|
||||
- internal/service/client_order/service.go
|
||||
- internal/service/package/activation_service.go
|
||||
autonomous: true
|
||||
requirements:
|
||||
- REALNAME-01
|
||||
- REALNAME-02
|
||||
- REALNAME-03
|
||||
- REALNAME-04
|
||||
- REALNAME-05
|
||||
must_haves:
|
||||
truths:
|
||||
- "数据库 tb_package 表不再有 enable_realname_activation 字段,已有 expiry_base 字段默认 from_activation"
|
||||
- "行业卡(card_category=industry)套餐永远直接激活,无视 expiry_base"
|
||||
- "C 端购买普通卡套餐前检查实名状态(card_category=normal),删除 packagesNeedRealname 函数"
|
||||
- "后台囤货 expiry_base=from_purchase 时直接激活,from_activation 时等实名"
|
||||
- "ActivateByRealname 按 ExpiryBase 选择计时基准(from_purchase 用 CreatedAt,from_activation 用当前时刻)"
|
||||
- "套餐管理 API 的 DTO 不再有 enable_realname_activation,改为 expiry_base 枚举字段"
|
||||
- "全仓库搜索 EnableRealnameActivation 零引用"
|
||||
artifacts:
|
||||
- path: "migrations/000089_realname_expiry_base.up.sql"
|
||||
provides: "REALNAME-01 DB 迁移:DROP enable_realname_activation + ADD expiry_base"
|
||||
- path: "migrations/000089_realname_expiry_base.down.sql"
|
||||
provides: "REALNAME-01 回滚迁移"
|
||||
- path: "internal/model/package.go"
|
||||
provides: "REALNAME-01 Model 同步:删旧字段,加 ExpiryBase"
|
||||
- path: "internal/model/dto/package_dto.go"
|
||||
provides: "REALNAME-05 DTO 更新:移除 EnableRealnameActivation,新增 ExpiryBase"
|
||||
- path: "internal/store/postgres/package_store.go"
|
||||
provides: "REALNAME-01 删除两步写入特殊处理"
|
||||
- path: "internal/service/order/service.go"
|
||||
provides: "REALNAME-02 activateMainPackage 三维决策重写"
|
||||
- path: "internal/service/client_order/service.go"
|
||||
provides: "REALNAME-03 实名检查改按卡类型,删 packagesNeedRealname"
|
||||
- path: "internal/service/package/activation_service.go"
|
||||
provides: "REALNAME-04 ActivateByRealname 按 ExpiryBase 选择激活基准"
|
||||
key_links:
|
||||
- from: "internal/service/order/service.go:activateMainPackage"
|
||||
to: "model.Package.ExpiryBase"
|
||||
via: "pkg.ExpiryBase == from_activation"
|
||||
pattern: "ExpiryBase.*from_activation"
|
||||
- from: "internal/service/client_order/service.go"
|
||||
to: "assetInfo.CardCategory"
|
||||
via: "card_category == normal"
|
||||
pattern: "CardCategory.*normal"
|
||||
- from: "internal/service/package/activation_service.go:ActivateByRealname"
|
||||
to: "packageUsage.CreatedAt"
|
||||
via: "pkg.ExpiryBase == from_purchase"
|
||||
pattern: "ExpiryBase.*from_purchase.*CreatedAt"
|
||||
---
|
||||
|
||||
<objective>
|
||||
原子性重构实名激活架构(REALNAME-01~05),将 enable_realname_activation 布尔字段替换为 expiry_base 枚举,重构激活决策为三维判断(卡类型+购买路径+expiry_base),修复 C 端实名检查按卡类型判断。
|
||||
|
||||
Purpose: 当前 enable_realname_activation 字段混淆了"是否等实名"和"计时起点"两个独立语义,架构重构后逻辑清晰分离,生产套餐全部默认 from_activation(运营后续按需手动调整)。
|
||||
Output: DB 迁移 + Model + Store + Service + DTO 全链路变更,原子完成,不可拆分。
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.config/opencode/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/02-perm-fin-realname-poll/02-CONTEXT.md
|
||||
|
||||
# 修复规格(核心参考)
|
||||
@.sisyphus/plans/修正业务-完整方案.md(方案 G 第 1492-1611 行)
|
||||
|
||||
<interfaces>
|
||||
<!-- 本 plan 执行时所需的关键接口,直接使用,无需探索 -->
|
||||
|
||||
**当前 Package Model(内含 EnableRealnameActivation,需替换)**:
|
||||
```go
|
||||
// internal/model/package.go
|
||||
EnableRealnameActivation bool `gorm:"column:enable_realname_activation;type:boolean;default:true;comment:是否启用实名激活"`
|
||||
// 改为:
|
||||
ExpiryBase string `gorm:"column:expiry_base;type:varchar(30);not null;default:'from_activation';comment:到期时间基准 from_activation-实名激活时起算 from_purchase-购买时起算"`
|
||||
```
|
||||
|
||||
**当前 package_store.go 两步写入(REALNAME-01 后需删除整个特殊处理块)**:
|
||||
```go
|
||||
// internal/store/postgres/package_store.go:23-28
|
||||
// GORM 对零值字段有特殊处理,先创建然后立即更新 enable_realname_activation 字段确保正确设置
|
||||
if err := s.db.WithContext(ctx).Omit("enable_realname_activation").Create(pkg).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// 明确更新 enable_realname_activation 字段(包括零值 false)
|
||||
return s.db.WithContext(ctx).Model(pkg).Update("enable_realname_activation", pkg.EnableRealnameActivation).Error
|
||||
```
|
||||
删除后改为直接 `s.db.WithContext(ctx).Create(pkg).Error`(ExpiryBase 是 string,无零值布尔问题)。
|
||||
|
||||
**当前 package_dto.go EnableRealnameActivation 三处(REALNAME-05)**:
|
||||
```go
|
||||
// 第 18 行(CreatePackageRequest)
|
||||
EnableRealnameActivation *bool `json:"enable_realname_activation" description:"是否启用实名激活"`
|
||||
// 第 35 行(UpdatePackageRequest)
|
||||
EnableRealnameActivation *bool `json:"enable_realname_activation" description:"是否启用实名激活"`
|
||||
// 第 98 行(PackageResponse)
|
||||
EnableRealnameActivation bool `json:"enable_realname_activation" description:"是否启用实名激活"`
|
||||
// 全部改为 ExpiryBase:
|
||||
ExpiryBase string `json:"expiry_base" validate:"omitempty,oneof=from_activation from_purchase" description:"到期时间基准 (from_activation:实名激活时起算, from_purchase:购买时起算)"`
|
||||
```
|
||||
|
||||
**当前 activateMainPackage 激活决策(第 1873 行,REALNAME-02)**:
|
||||
```go
|
||||
// 任务 8.9: 后台囤货场景
|
||||
if pkg.EnableRealnameActivation {
|
||||
// 需要实名后才能激活
|
||||
status = constants.PackageUsageStatusPending
|
||||
pendingRealnameActivation = true
|
||||
}
|
||||
```
|
||||
改写为三维决策(见 action 中详细说明)。
|
||||
|
||||
**当前 client_order/service.go 实名检查(第 115 行,REALNAME-03)**:
|
||||
```go
|
||||
if packagesNeedRealname(validationResult.Packages) && assetInfo.RealNameStatus != constants.RealNameStatusVerified {
|
||||
return nil, errors.New(errors.CodeNeedRealname)
|
||||
}
|
||||
```
|
||||
改为:
|
||||
```go
|
||||
if assetInfo.CardCategory == "normal" && assetInfo.RealNameStatus != constants.RealNameStatusVerified {
|
||||
return nil, errors.New(errors.CodeNeedRealname)
|
||||
}
|
||||
```
|
||||
然后删除 `packagesNeedRealname` 函数(第 619-626 行)。
|
||||
|
||||
**当前 ActivateByRealname 激活时间固定用 now(第 113 行附近,REALNAME-04)**:
|
||||
```go
|
||||
activatedAt := now
|
||||
expiresAt := CalculateExpiryTime(pkg.CalendarType, activatedAt, pkg.DurationMonths, pkg.DurationDays)
|
||||
```
|
||||
改为:
|
||||
```go
|
||||
var activatedAt time.Time
|
||||
if pkg.ExpiryBase == "from_purchase" {
|
||||
activatedAt = usage.CreatedAt // 用记录创建时间(购买时刻)
|
||||
} else {
|
||||
activatedAt = now // 实名被触发的当前时刻
|
||||
}
|
||||
```
|
||||
|
||||
**迁移命名参考(现有最新为 000088)**:
|
||||
新建 `migrations/000089_realname_expiry_base.up.sql` 和 `000089_realname_expiry_base.down.sql`
|
||||
|
||||
**card_category 访问路径(已确认)**:
|
||||
- `assetInfo.CardCategory` 来自 `dto.AssetResolveResponse.CardCategory`(第 49 行),值由 `asset/service.go:175` 填充
|
||||
- `activateMainPackage` 中通过 order 可以获取 `carrierType`/`carrierID`,card_category 需要从 `IotCard` 查询
|
||||
- 建议在 `activateMainPackage` 函数中通过 `tx.First(&card, carrierID)` 获取卡信息,再读 `card.CardCategory`
|
||||
|
||||
**BuyerTypePersonal 常量**:
|
||||
`model.BuyerTypePersonal` 已存在(参考 service.go 第 450 行 `orderBuyerType = model.BuyerTypePersonal`)
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: DB 迁移 + Model + Store 清理(REALNAME-01)</name>
|
||||
<files>
|
||||
migrations/000089_realname_expiry_base.up.sql,
|
||||
migrations/000089_realname_expiry_base.down.sql,
|
||||
internal/model/package.go,
|
||||
internal/store/postgres/package_store.go
|
||||
</files>
|
||||
<action>
|
||||
**步骤 1:创建迁移文件(per D-10)**
|
||||
|
||||
`migrations/000089_realname_expiry_base.up.sql`:
|
||||
```sql
|
||||
-- REALNAME-01: 实名激活架构重构
|
||||
-- 移除旧的布尔字段 enable_realname_activation,
|
||||
-- 新增 expiry_base 枚举字段控制套餐计时起点
|
||||
-- 注意:不做旧字段到新字段的数据迁移,现有记录全部默认 from_activation
|
||||
|
||||
-- 移除旧字段
|
||||
ALTER TABLE tb_package DROP COLUMN IF EXISTS enable_realname_activation;
|
||||
|
||||
-- 新增到期时间基准字段
|
||||
ALTER TABLE tb_package
|
||||
ADD COLUMN IF NOT EXISTS expiry_base VARCHAR(30) NOT NULL DEFAULT 'from_activation';
|
||||
|
||||
COMMENT ON COLUMN tb_package.expiry_base IS '到期时间基准 from_activation-实名激活时起算 from_purchase-购买时起算';
|
||||
```
|
||||
|
||||
`migrations/000089_realname_expiry_base.down.sql`:
|
||||
```sql
|
||||
-- 回滚:恢复旧字段,移除新字段
|
||||
ALTER TABLE tb_package DROP COLUMN IF EXISTS expiry_base;
|
||||
|
||||
ALTER TABLE tb_package
|
||||
ADD COLUMN IF NOT EXISTS enable_realname_activation BOOLEAN NOT NULL DEFAULT TRUE;
|
||||
|
||||
COMMENT ON COLUMN tb_package.enable_realname_activation IS '是否启用实名激活 true-需实名后激活 false-立即激活';
|
||||
```
|
||||
|
||||
执行迁移并验证:
|
||||
```bash
|
||||
make migrate-up
|
||||
make migrate-version
|
||||
```
|
||||
|
||||
**步骤 2:更新 Model(`internal/model/package.go`)**
|
||||
|
||||
删除 `EnableRealnameActivation` 字段:
|
||||
```go
|
||||
EnableRealnameActivation bool `gorm:"column:enable_realname_activation;type:boolean;..."`
|
||||
```
|
||||
|
||||
新增 `ExpiryBase` 字段(放在原来的位置附近):
|
||||
```go
|
||||
// ExpiryBase 到期时间基准:from_activation-实名激活时起算,from_purchase-购买时起算
|
||||
ExpiryBase string `gorm:"column:expiry_base;type:varchar(30);not null;default:'from_activation';comment:到期时间基准 from_activation-实名激活时起算 from_purchase-购买时起算" json:"expiry_base"`
|
||||
```
|
||||
|
||||
**步骤 3:清理 package_store.go 两步写入**
|
||||
|
||||
找到 `internal/store/postgres/package_store.go:23-28` 的特殊处理块:
|
||||
```go
|
||||
// GORM 对零值字段有特殊处理,先创建然后立即更新 enable_realname_activation 字段确保正确设置
|
||||
if err := s.db.WithContext(ctx).Omit("enable_realname_activation").Create(pkg).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// 明确更新 enable_realname_activation 字段(包括零值 false)
|
||||
return s.db.WithContext(ctx).Model(pkg).Update("enable_realname_activation", pkg.EnableRealnameActivation).Error
|
||||
```
|
||||
|
||||
替换为直接创建(ExpiryBase 是 string,无零值问题):
|
||||
```go
|
||||
return s.db.WithContext(ctx).Create(pkg).Error
|
||||
```
|
||||
同时删除其上方的注释说明(第 22-23 行注释块)。
|
||||
|
||||
完成后 commit:`fix(realname): DB 迁移 + Model + Store 清理 REALNAME-01`
|
||||
</action>
|
||||
<verify>
|
||||
<automated>make migrate-version && go build ./... 2>&1 | head -30</automated>
|
||||
</verify>
|
||||
<done>
|
||||
- make migrate-version 显示版本 89,dirty=false
|
||||
- tb_package 不再有 enable_realname_activation,已有 expiry_base 字段
|
||||
- package.go Package 结构体有 ExpiryBase 字段,无 EnableRealnameActivation 字段
|
||||
- package_store.go Create 函数已是直接一步 Create
|
||||
- go build ./... 无编译错误(此时 service 层还有旧引用,编译会报错,先记录,Task 2 修复)
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Service + DTO 重构(REALNAME-02/03/04/05)</name>
|
||||
<files>
|
||||
internal/model/dto/package_dto.go,
|
||||
internal/service/order/service.go,
|
||||
internal/service/client_order/service.go,
|
||||
internal/service/package/activation_service.go
|
||||
</files>
|
||||
<action>
|
||||
**REALNAME-05(per D-10)**:`internal/model/dto/package_dto.go`
|
||||
|
||||
替换三处 `EnableRealnameActivation` 为 `ExpiryBase`:
|
||||
|
||||
1. 第 18 行(CreatePackageRequest):
|
||||
```go
|
||||
// 删除:EnableRealnameActivation *bool `json:"enable_realname_activation" description:"是否启用实名激活 (true:需实名后激活, false:立即激活)"`
|
||||
// 新增:
|
||||
ExpiryBase string `json:"expiry_base" validate:"omitempty,oneof=from_activation from_purchase" description:"到期时间基准 (from_activation:实名激活时起算, from_purchase:购买时起算)" example:"from_activation"`
|
||||
```
|
||||
|
||||
2. 第 35 行(UpdatePackageRequest):同上写法
|
||||
|
||||
3. 第 98 行(PackageResponse):
|
||||
```go
|
||||
// 删除:EnableRealnameActivation bool `json:"enable_realname_activation" description:"是否启用实名激活 (true:需实名后激活, false:立即激活)"`
|
||||
// 新增:
|
||||
ExpiryBase string `json:"expiry_base" description:"到期时间基准 (from_activation:实名激活时起算, from_purchase:购买时起算)"`
|
||||
```
|
||||
|
||||
完成后 commit:`fix(realname): DTO 更新 expiry_base 替换 enable_realname_activation REALNAME-05`
|
||||
|
||||
**REALNAME-02(per D-11)**:`internal/service/order/service.go:activateMainPackage`
|
||||
|
||||
找到第 1873 行 `if pkg.EnableRealnameActivation` 块,替换为三维决策逻辑:
|
||||
|
||||
```go
|
||||
// REALNAME-02: 按卡类型 + 购买路径 + expiry_base 三维决策
|
||||
// 需要在 else 分支(无生效中主套餐,立即激活)之后插入以下逻辑:
|
||||
|
||||
// 获取卡类型(行业卡永远直接激活,不等实名)
|
||||
var cardCategory string
|
||||
if carrierType == "iot_card" {
|
||||
var card model.IotCard
|
||||
if err := tx.Select("card_category").First(&card, carrierID).Error; err == nil {
|
||||
cardCategory = card.CardCategory
|
||||
}
|
||||
}
|
||||
|
||||
// 判断是否 C 端购买(C 端购买前已做实名检查,直接激活)
|
||||
isCEndPurchase := order.BuyerType == model.BuyerTypePersonal
|
||||
|
||||
// 三维决策:覆盖 activateMainPackage 已有的 status 赋值
|
||||
if cardCategory == "industry" {
|
||||
// 行业卡:永远直接激活,不等实名
|
||||
// status 已在 else 分支设为 Active,保持不变
|
||||
} else if isCEndPurchase {
|
||||
// C 端购买:前置实名检查已完成,直接激活
|
||||
// status 已在 else 分支设为 Active,保持不变
|
||||
} else {
|
||||
// 后台囤货:按 expiry_base 决定
|
||||
if pkg.ExpiryBase == "from_purchase" {
|
||||
// 购买即激活,立即开始计时,status 保持 Active(已设置)
|
||||
} else {
|
||||
// from_activation(默认):等实名后激活
|
||||
status = constants.PackageUsageStatusPending
|
||||
pendingRealnameActivation = true
|
||||
// 清空激活时间(排队状态不设置)
|
||||
activatedAt = time.Time{}
|
||||
expiresAt = time.Time{}
|
||||
nextResetAt = nil
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
注意:上述三维决策逻辑用于替换原来第 1873-1876 行的 `if pkg.EnableRealnameActivation` 块。放置位置:在 `hasActiveMain` 分支结束之后、`usage := &model.PackageUsage` 创建之前(即原 8.9 任务注释区域)。
|
||||
|
||||
完成后 commit:`fix(realname): activateMainPackage 三维决策重构 REALNAME-02`
|
||||
|
||||
**REALNAME-03(per D-12)**:`internal/service/client_order/service.go`
|
||||
|
||||
第 115 行,替换实名检查逻辑:
|
||||
```go
|
||||
// 删除:
|
||||
if packagesNeedRealname(validationResult.Packages) && assetInfo.RealNameStatus != constants.RealNameStatusVerified {
|
||||
|
||||
// 替换为:
|
||||
// REALNAME-03: 按卡类型判断实名需求,普通卡需要实名,行业卡不需要
|
||||
if assetInfo.CardCategory == "normal" && assetInfo.RealNameStatus != constants.RealNameStatusVerified {
|
||||
```
|
||||
|
||||
然后删除 `packagesNeedRealname` 函数(第 619-626 行)。
|
||||
|
||||
检查 `validationResult.Packages` 是否仍被其他逻辑使用;若不再使用,保留 `validationResult` 结构但不必删除(其他字段如 `Packages` 本身可能仍用于后续订单创建)。
|
||||
|
||||
完成后 commit:`fix(realname): C 端实名检查改为按卡类型判断 REALNAME-03`
|
||||
|
||||
**REALNAME-04(per D-13)**:`internal/service/package/activation_service.go:ActivateByRealname`
|
||||
|
||||
找到循环内 `activatedAt := now` 的赋值(约第 113 行),替换为:
|
||||
```go
|
||||
// REALNAME-04: 按 ExpiryBase 选择激活计时基准
|
||||
var activatedAt time.Time
|
||||
if pkg.ExpiryBase == "from_purchase" {
|
||||
// 购买时起算:用 PackageUsage 记录的创建时间
|
||||
activatedAt = usage.CreatedAt
|
||||
} else {
|
||||
// 实名激活时起算(from_activation 或默认):用当前时刻
|
||||
activatedAt = now
|
||||
}
|
||||
```
|
||||
|
||||
注意:`usage` 变量在循环中已有(为 `*model.PackageUsage`),直接访问 `.CreatedAt` 即可。
|
||||
|
||||
完成后 commit:`fix(realname): ActivateByRealname 按 ExpiryBase 选择计时基准 REALNAME-04`
|
||||
|
||||
最终检查所有旧引用清零:
|
||||
```bash
|
||||
grep -rn "EnableRealnameActivation\|enable_realname_activation\|packagesNeedRealname" \
|
||||
internal/ --include="*.go"
|
||||
# 预期:零结果(DTO、Model、Service 全部清理完成)
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go build ./... 2>&1 | head -30</automated>
|
||||
</verify>
|
||||
<done>
|
||||
- package_dto.go 三处 EnableRealnameActivation 已替换为 ExpiryBase,description 使用中文枚举说明
|
||||
- order/service.go:activateMainPackage 已有三维决策逻辑(industry/C端/后台囤货+expiry_base)
|
||||
- client_order/service.go 实名检查用 CardCategory==normal;packagesNeedRealname 函数已删除
|
||||
- activation_service.go:ActivateByRealname 用 ExpiryBase 分支选择激活时间
|
||||
- grep 搜索 EnableRealnameActivation/enable_realname_activation/packagesNeedRealname 零结果
|
||||
- go build ./... 无编译错误
|
||||
- 已产出 4 个独立 commit(REALNAME-05、02、03、04)
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
REALNAME 重构完成后执行最终验收:
|
||||
|
||||
```bash
|
||||
# 编译验证
|
||||
go build ./...
|
||||
|
||||
# DB 迁移验证
|
||||
make migrate-version
|
||||
# 预期:version=89, dirty=false
|
||||
|
||||
# 旧字段引用清零
|
||||
grep -rn "EnableRealnameActivation\|enable_realname_activation\|packagesNeedRealname" \
|
||||
internal/ --include="*.go"
|
||||
# 预期:零结果
|
||||
|
||||
# 新字段引用确认
|
||||
grep -rn "ExpiryBase\|expiry_base" internal/ --include="*.go" | grep -v "_test.go"
|
||||
# 预期:至少在 package.go, package_dto.go, order/service.go,
|
||||
# client_order/service.go, activation_service.go 中出现
|
||||
|
||||
# Model 验证
|
||||
grep -n "ExpiryBase" internal/model/package.go
|
||||
# 预期:有 gorm column:expiry_base 标签的字段定义
|
||||
```
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- go build ./... 零错误
|
||||
- make migrate-version 显示 version=89, dirty=false
|
||||
- 全仓库 EnableRealnameActivation/enable_realname_activation/packagesNeedRealname 零引用
|
||||
- package.go 有 ExpiryBase 字段(gorm tag: expiry_base, varchar(30), default:from_activation)
|
||||
- package_store.go Create 函数为直接一步创建(无两步写入特殊处理)
|
||||
- activateMainPackage 包含三维决策(行业卡/C端/后台囤货+expiry_base)
|
||||
- client_order 实名检查用 CardCategory=="normal"
|
||||
- ActivateByRealname 按 ExpiryBase 分支选择 activatedAt 基准
|
||||
- 已产出 5 个独立 commit(REALNAME-01、05、02、03、04)
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
完成后创建:`.planning/phases/02-perm-fin-realname-poll/02-02-SUMMARY.md`
|
||||
</output>
|
||||
95
.planning/phases/02-perm-fin-realname-poll/02-02-SUMMARY.md
Normal file
95
.planning/phases/02-perm-fin-realname-poll/02-02-SUMMARY.md
Normal file
@@ -0,0 +1,95 @@
|
||||
---
|
||||
phase: 02-perm-fin-realname-poll
|
||||
plan: "02"
|
||||
subsystem: realname-activation
|
||||
tags: [realname, package, migration, refactor]
|
||||
dependency_graph:
|
||||
requires: []
|
||||
provides: [REALNAME-01, REALNAME-02, REALNAME-03, REALNAME-04, REALNAME-05]
|
||||
affects: [package, order, client_order, activation_service]
|
||||
tech_stack:
|
||||
added: []
|
||||
patterns: [three-dimensional-decision, expiry-base-enum]
|
||||
key_files:
|
||||
created:
|
||||
- migrations/000089_realname_expiry_base.up.sql
|
||||
- migrations/000089_realname_expiry_base.down.sql
|
||||
modified:
|
||||
- internal/model/package.go
|
||||
- internal/model/dto/package_dto.go
|
||||
- internal/store/postgres/package_store.go
|
||||
- internal/service/order/service.go
|
||||
- internal/service/client_order/service.go
|
||||
- internal/service/package/service.go
|
||||
- internal/service/package/activation_service.go
|
||||
- internal/task/auto_purchase.go
|
||||
decisions:
|
||||
- "expiry_base 枚举替代 enable_realname_activation 布尔字段,清晰分离等实名语义与计时起点语义"
|
||||
- "三维决策:行业卡永远直接激活,C 端购买直接激活,后台囤货按 expiry_base 决定"
|
||||
- "auto_purchase.go 被认定为 C 端路径,不适用 expiry_base 三维决策"
|
||||
metrics:
|
||||
duration: "~25min"
|
||||
completed_date: "2026-03-28"
|
||||
tasks: 2
|
||||
files: 8
|
||||
---
|
||||
|
||||
# Phase 02 Plan 02: 实名激活架构重构 Summary
|
||||
|
||||
将 `enable_realname_activation` 布尔字段替换为 `expiry_base` 枚举(from_activation/from_purchase),重构激活决策为三维判断(卡类型+购买路径+expiry_base),全链路原子完成。
|
||||
|
||||
## Tasks Completed
|
||||
|
||||
| Task | Description | Commits |
|
||||
|------|-------------|---------|
|
||||
| 1 | DB 迁移 + Model + Store 清理(REALNAME-01) | `344850f` |
|
||||
| 2a | DTO 更新(REALNAME-05) | `244a274` |
|
||||
| 2b | activateMainPackage 三维决策(REALNAME-02) | `7dd5563` |
|
||||
| 2c | C 端实名检查按卡类型(REALNAME-03) | `0ebb3d7` |
|
||||
| 2d | ActivateByRealname 按 ExpiryBase 选时间基准(REALNAME-04) | `60b43bb` |
|
||||
| 2e | auto_purchase.go 废弃引用清除(偏差修复) | `4be473c` |
|
||||
|
||||
## Verification
|
||||
|
||||
```
|
||||
migrate-version: 89, dirty=false ✓
|
||||
EnableRealnameActivation/enable_realname_activation/packagesNeedRealname 零引用 ✓
|
||||
ExpiryBase 引用出现在 package.go、package_dto.go、order/service.go、
|
||||
client_order/service.go、activation_service.go ✓
|
||||
go build ./internal/... 零错误 ✓
|
||||
```
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 1 - Bug] auto_purchase.go 含废弃 EnableRealnameActivation 引用**
|
||||
- **发现于:** Task 2 完成后编译验证
|
||||
- **问题:** `internal/task/auto_purchase.go:433` 引用已删除的 `pkg.EnableRealnameActivation` 字段,导致编译失败
|
||||
- **修复:** 识别 auto_purchase 为 C 端充值触发路径,不适用 expiry_base 三维决策,删除该分支并添加注释说明
|
||||
- **文件:** `internal/task/auto_purchase.go`
|
||||
- **Commit:** `4be473c`
|
||||
|
||||
**2. [Rule 1 - Bug] package/service.go DTO 映射引用 EnableRealnameActivation**
|
||||
- **发现于:** Task 2 DTO 替换后 LSP 报错
|
||||
- **问题:** `service.go` 的 Create/Update/toResponse 函数仍映射旧字段
|
||||
- **修复:** 同步更新三处映射为 `ExpiryBase`
|
||||
- **文件:** `internal/service/package/service.go`
|
||||
- **Commit:** 含于 `244a274`
|
||||
|
||||
## Known Stubs
|
||||
|
||||
无已知留桩。全链路变更已完整实现。
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- `migrations/000089_realname_expiry_base.up.sql` — FOUND ✓
|
||||
- `migrations/000089_realname_expiry_base.down.sql` — FOUND ✓
|
||||
- `internal/model/package.go` ExpiryBase 字段 — FOUND ✓
|
||||
- `internal/model/dto/package_dto.go` ExpiryBase 三处 — FOUND ✓
|
||||
- `internal/store/postgres/package_store.go` 直接一步 Create — FOUND ✓
|
||||
- Commit `344850f` (REALNAME-01) — FOUND ✓
|
||||
- Commit `244a274` (REALNAME-05) — FOUND ✓
|
||||
- Commit `7dd5563` (REALNAME-02) — FOUND ✓
|
||||
- Commit `0ebb3d7` (REALNAME-03) — FOUND ✓
|
||||
- Commit `60b43bb` (REALNAME-04) — FOUND ✓
|
||||
143
.planning/phases/02-perm-fin-realname-poll/02-CONTEXT.md
Normal file
143
.planning/phases/02-perm-fin-realname-poll/02-CONTEXT.md
Normal file
@@ -0,0 +1,143 @@
|
||||
# Phase 2: 权限/财务/实名/轮询修复 - Context
|
||||
|
||||
**Gathered:** 2026-03-28
|
||||
**Status:** Ready for planning
|
||||
|
||||
<domain>
|
||||
## Phase Boundary
|
||||
|
||||
修复和补全四个独立业务域的已知断点:企业账号权限边界(PERM)、提现财务流程完整性(FIN)、实名激活架构重构(REALNAME/G)、轮询调度小修(POLL)。共 14 个需求,全部为修复性变更,无新功能模块,REALNAME 涉及破坏性 DB 迁移(DROP 旧字段 + ADD 新字段)。
|
||||
|
||||
</domain>
|
||||
|
||||
<decisions>
|
||||
## Implementation Decisions
|
||||
|
||||
### 执行分组策略
|
||||
|
||||
- **D-01:** 拆分为 **2 个 Plan**,而非 1 个或 4 个:
|
||||
- **Plan 1(小修合并)**:B(PERM-01/02)+ H(POLL-01/02/03/04)+ C(FIN-01/02/03)
|
||||
- **Plan 2(REALNAME 独立)**:G(REALNAME-01~05)—— 单独 Plan,独立可回滚
|
||||
- **D-02:** Plan 1 内部执行顺序:B → H → C(权限修复最小化先上,轮询小修次之,财务流程收尾)
|
||||
- **D-03:** Plan 2(REALNAME)必须原子执行,不可拆分 —— 5 个需求在一个 Plan 的同一事务性推进中完成
|
||||
|
||||
### 企业权限(PERM)
|
||||
|
||||
- **D-04:** `asset.go:Resolve` —— **移除**企业账号拦截(企业应可查询被授权资产)
|
||||
- **D-05:** `asset.go:Refresh` —— **新增**企业账号拦截(企业只读,不可触发运营商刷新)
|
||||
- **D-06:** 其他 8 个 AssetHandler 方法维持现状(无拦截/已拦截均不动)
|
||||
|
||||
### 财务流程(FIN)
|
||||
|
||||
- **D-07:** FIN-01:`commission_withdrawal/service.go:Approve()` 补填 `approved_by` / `approved_at` 字段
|
||||
- **D-08:** FIN-02:`commission_withdrawal.go:Reject()` 在 BodyParser 后补 `validator.Validate(&req)`
|
||||
- **D-09:** FIN-03:激活配置并发保护使用 GORM v2 语法 `tx.Clauses(clause.Locking{Strength: "UPDATE"})` —— 规格书写的是 GORM v1 的 `gorm:query_option`,项目实际用 GORM v2,agent 使用正确语法
|
||||
|
||||
### REALNAME 架构重构(G)
|
||||
|
||||
- **D-10:** DB 迁移策略:**直接 ADD DEFAULT 'from_activation',旧字段 DROP**
|
||||
- 不做 `enable_realname_activation=false → from_purchase` 的数据迁移
|
||||
- 原因:旧字段控制的是"是否等实名",新字段 `expiry_base` 控制的是"计时起点",两者语义不同,无法直接映射;旧字段已无存在意义,直接清理
|
||||
- 现有生产套餐记录全部默认 `from_activation`,后续如需 `from_purchase` 由运营手动修改
|
||||
- **D-11:** `activateMainPackage` 激活决策逻辑改为按三维判断:
|
||||
- 行业卡(`card_category == "industry"`)→ 永远直接激活
|
||||
- C 端购买(`buyerType == "personal"` 或 `order.OrderType` 标记 C 端)→ 直接激活(已前置实名检查)
|
||||
- 后台囤货 → 按 `pkg.ExpiryBase`:`from_purchase` 直接激活,`from_activation` 进入等实名队列
|
||||
- **D-12:** C 端实名检查改为按卡类型(`card_category == "normal"`)判断,删除 `packagesNeedRealname()` 函数
|
||||
- **D-13:** `ActivateByRealname` 按 `ExpiryBase` 选择计时基准:`from_purchase` 用 `CreatedAt`,`from_activation` 用当前时刻
|
||||
|
||||
### 轮询系统小修(POLL)
|
||||
|
||||
- **D-14:** POLL-01:在 `scheduler.go` 主调度循环中,复用已有的 `processManualQueue` / `processTimedQueue`,补充 `polling:protect` 的调度接入
|
||||
- **D-15:** POLL-02:`stopCardWithRetry` / `resumeCardWithRetry` 两处,`gatewayClient == nil` 时改为返回明确业务错误
|
||||
- **D-16:** POLL-03:`GetPackages()` 接口新增分页参数,默认 `page=1, pageSize=50`,最大 100
|
||||
- **D-17:** POLL-04:`package_series/service.go:Delete()` 前置 `CountBySeriesID()` 检查,有关联套餐则拒绝删除
|
||||
|
||||
### Agent's Discretion
|
||||
|
||||
- FIN-03 事务内 `FOR UPDATE` 的具体 GORM v2 `clause.Locking` 写法,agent 参考项目现有 `clause.OnConflict` 用法对齐风格
|
||||
- `isCEndPurchase` 的具体推断字段(`buyerType == "personal"` 或 order 字段),agent 阅读 `activateMainPackage` 上下文确认最准确的判断路径
|
||||
- 每个子修复独立一个 commit(沿用 Phase 1 的粒度规范)
|
||||
|
||||
</decisions>
|
||||
|
||||
<canonical_refs>
|
||||
## Canonical References
|
||||
|
||||
**Downstream agents MUST read these before planning or implementing.**
|
||||
|
||||
### 修复规格(核心参考)
|
||||
- `.sisyphus/plans/修正业务-完整方案.md` — 完整修复规格书,含每个需求的文件路径、代码片段、人工验收清单:
|
||||
- 方案 B(PERM-01/02):第 766-863 行
|
||||
- 方案 C(FIN-01/02/03):第 864-944 行
|
||||
- 方案 G(REALNAME-01~05):第 1492-1611 行
|
||||
- 方案 H(POLL-01~04):第 1612-1711 行
|
||||
|
||||
### 核心修改文件
|
||||
- `internal/handler/admin/asset.go` — PERM-01(删 Resolve 企业拦截)、PERM-02(加 Refresh 企业拦截)
|
||||
- `internal/service/commission_withdrawal/service.go` — FIN-01(Approve 补审批人字段)
|
||||
- `internal/handler/admin/commission_withdrawal.go` — FIN-02(Reject 加 Validate)
|
||||
- `internal/service/commission_withdrawal_setting/service.go` — FIN-03(并发行锁)
|
||||
- `internal/store/postgres/wechat_config_store.go` — FIN-03(微信配置并发行锁)
|
||||
- `internal/model/package.go` — REALNAME-01(drop EnableRealnameActivation,add ExpiryBase)
|
||||
- `internal/model/dto/package_dto.go` — REALNAME-05(DTO 更新 expiry_base 字段)
|
||||
- `internal/service/order/service.go` — REALNAME-02(activateMainPackage 三维决策)
|
||||
- `internal/service/client_order/service.go` — REALNAME-03(按卡类型检查实名)
|
||||
- `internal/service/package/activation_service.go` — REALNAME-04(ActivateByRealname 计时基准)
|
||||
- `internal/store/postgres/package_store.go` — REALNAME-01(删 enable_realname_activation 两步写入特殊处理)
|
||||
- `internal/polling/scheduler.go` — POLL-01(补 protect 调度)
|
||||
- `internal/task/polling_handler.go` — POLL-02(nil gateway 改返错误)
|
||||
- `internal/service/asset/service.go` 或相关 package 接口 — POLL-03(GetPackages 分页)
|
||||
- `internal/service/package_series/service.go` — POLL-04(Delete 前检查关联套餐)
|
||||
|
||||
### DB 迁移
|
||||
- `migrations/` 目录 — 遵循现有迁移文件命名规范,REALNAME 迁移需新建一个 .sql 文件
|
||||
|
||||
### 项目规范
|
||||
- `AGENTS.md` — 语言要求、错误处理、分层规范
|
||||
- `docs/api-documentation-guide.md` — 若有 DTO 变更需同步更新文档
|
||||
|
||||
</canonical_refs>
|
||||
|
||||
<code_context>
|
||||
## Existing Code Insights
|
||||
|
||||
### 关键现状确认
|
||||
- `asset.go:Resolve`(第 41-44 行):企业拦截代码已确认存在,直接删除即可
|
||||
- `asset.go:Refresh`(第 78 行附近):当前无企业检查,需在方法开头新增
|
||||
- GORM v2 locking 语法:项目已有 `clause.OnConflict` 使用(`package_usage_daily_record_store.go`),FIN-03 应参照同样风格使用 `clause.Locking`
|
||||
- `enable_realname_activation`:在 `package.go`、`package_dto.go`(3处)、`package_store.go`(2处两步写入特殊处理)均有引用,全部需清理
|
||||
- `card_category`:在 `polling_handler.go:108` 已有 `if card.CardCategory == "industry"` 判断,REALNAME-02 决策逻辑与此对齐
|
||||
- `processManualQueue` / `processTimedQueue`:`scheduler.go:243-250` 已有 3 个 polling 类型,补 protect 参照同一模式
|
||||
|
||||
### Established Patterns
|
||||
- 行锁:`tx.Clauses(clause.Locking{...})` — 参考 `clause.OnConflict` 已有用法
|
||||
- 错误返回:Service 层用 `errors.New(code, msg)` / `errors.Wrap(code, err, msg)`
|
||||
- 分页:`QueryOptions{Page: page, PageSize: pageSize}` — 参考已有 List 接口
|
||||
- Commit 粒度:每个独立 Bug 修复一个 commit(沿用 Phase 1 规范)
|
||||
|
||||
### Integration Points
|
||||
- REALNAME:`package_store.go` 的两步写入特殊处理在 REALNAME-01 后需删除(字段删除后特殊处理失去意义)
|
||||
- POLL-03 分页:若 `GetPackages` 上层调用点(`handler/admin/asset.go:102`、`handler/app/client_asset.go:213`)无分页参数传入,需同时更新 Handler 层
|
||||
|
||||
</code_context>
|
||||
|
||||
<specifics>
|
||||
## Specific Ideas
|
||||
|
||||
- Plan 2(REALNAME)原子执行——规格书明确指出"REALNAME-01~05 是原子重构,必须在一个 plan 内完整执行,不可拆分"(STATE.md Accumulated Context)
|
||||
- 旧字段 `enable_realname_activation` 直接清理,不做 false→from_purchase 映射,现有生产套餐全部默认 from_activation,后续运营按需手动调整
|
||||
|
||||
</specifics>
|
||||
|
||||
<deferred>
|
||||
## Deferred Ideas
|
||||
|
||||
None — discussion stayed within phase scope
|
||||
|
||||
</deferred>
|
||||
|
||||
---
|
||||
|
||||
*Phase: 02-perm-fin-realname-poll*
|
||||
*Context gathered: 2026-03-28*
|
||||
@@ -0,0 +1,58 @@
|
||||
# Phase 2: 权限/财务/实名/轮询修复 - Discussion Log
|
||||
|
||||
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
|
||||
> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.
|
||||
|
||||
**Date:** 2026-03-28
|
||||
**Phase:** 02-perm-fin-realname-poll
|
||||
**Areas discussed:** 执行分组策略, REALNAME 数据迁移风险
|
||||
|
||||
---
|
||||
|
||||
## 执行分组策略
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| B+C+H 合一、G 独立 | 2 个 Plan:小修删掉合成一个,REALNAME 单独一个。风险隔离好,G 可以独立回滚 | ✓ |
|
||||
| B、C、G、H 各独立 | 4 个 Plan:最细粒度,但总费用较高(尤其 B 和 H 都很小) | |
|
||||
| 全部合并成一个 | 1 个 Plan:最快完成,但如果 G 失败就不知道是哪个需求的问题 | |
|
||||
|
||||
**User's choice:** B+C+H 合一、G 独立
|
||||
**Notes:** Plan 1 内部执行顺序确认为 B → H → C
|
||||
|
||||
---
|
||||
|
||||
## 执行顺序(Plan 1 内部)
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| B → H → C | 先权限修复(最小化验证),然后轮询小修,最后收尾财务流程。财务类改动放最后更安全 | ✓ |
|
||||
| H → B → C | 先轮询,再权限,最后财务 | |
|
||||
| Agent 自行决定 | 顺序不重要,由 agent 按依赖关系安排 | |
|
||||
|
||||
**User's choice:** B → H → C
|
||||
|
||||
---
|
||||
|
||||
## REALNAME 数据迁移风险
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| 直接默认 from_activation | 新字段 DEFAULT 'from_activation'。旧的 false 记录不做映射。expiry_base 控制计时基准,语义与旧字段不同,无法直接映射 | ✓ |
|
||||
| 脚本映射 false → from_purchase | UPDATE tb_package SET expiry_base='from_purchase' WHERE enable_realname_activation=false。但实际上旧字段的语义与新字段不完全对应 | |
|
||||
|
||||
**User's choice:** 直接默认 from_activation,旧字段清理掉
|
||||
**Notes:** "如果旧的字段没有用了就清理掉吧"
|
||||
|
||||
---
|
||||
|
||||
## Agent's Discretion
|
||||
|
||||
- FIN-03 GORM v2 行锁语法(`clause.Locking`)
|
||||
- `isCEndPurchase` 的具体推断字段
|
||||
- 每个子修复独立 commit 粒度
|
||||
- POLL-03 Handler 层分页参数传入方式
|
||||
|
||||
## Deferred Ideas
|
||||
|
||||
None mentioned during discussion.
|
||||
203
.planning/phases/02-perm-fin-realname-poll/02-VERIFICATION.md
Normal file
203
.planning/phases/02-perm-fin-realname-poll/02-VERIFICATION.md
Normal file
@@ -0,0 +1,203 @@
|
||||
---
|
||||
phase: 02-perm-fin-realname-poll
|
||||
verified: 2026-03-28T10:00:00Z
|
||||
status: passed
|
||||
score: 14/14 must-haves verified
|
||||
re_verification: null
|
||||
gaps: []
|
||||
human_verification: []
|
||||
---
|
||||
|
||||
# Phase 02: 权限/财务/实名/轮询修复 Verification Report
|
||||
|
||||
**Phase Goal:** 修复 PERM(企业权限)+ FIN(财务流程)+ REALNAME(实名激活架构重构)+ POLL(轮询小修)四个域共 14 个需求缺陷。
|
||||
**Verified:** 2026-03-28T10:00:00Z
|
||||
**Status:** ✅ PASSED
|
||||
**Re-verification:** No — initial verification
|
||||
|
||||
---
|
||||
|
||||
## Goal Achievement
|
||||
|
||||
### Observable Truths
|
||||
|
||||
| # | Truth | Status | Evidence |
|
||||
|---|-------|--------|---------|
|
||||
| 1 | 企业账号可调用 Resolve 接口查询被授权资产,不再被 403 拦截 | ✓ VERIFIED | `asset.go:Resolve` 方法中已删除企业账号拦截代码块(commit 2a92ab6) |
|
||||
| 2 | 企业账号调用 Refresh 接口时返回 403 业务错误 | ✓ VERIFIED | `asset.go:Refresh` 方法开头已插入企业拦截(L74-78,CodeForbidden) |
|
||||
| 3 | 提现审批通过后,approved_by 和 approved_at 字段正确填充 | ✓ VERIFIED | `commission_withdrawal/service.go:209-210` maps 包含两个字段 |
|
||||
| 4 | 拒绝提现时 remark 为空返回参数校验错误,申请状态不变 | ✓ VERIFIED | `commission_withdrawal.go:RejectWithdrawal` L69-71 有 `validator.Struct(&req)` 调用 |
|
||||
| 5 | 同时激活两个提现配置时,行锁生效,任何时刻最多一条 is_active=true | ✓ VERIFIED | `commission_withdrawal_setting/service.go:53-55` 有 `clause.Locking{Strength: "UPDATE"}`;`wechat_config_store.go:106` 同理 |
|
||||
| 6 | polling:protect 任务进入调度器,日志可见 protect 队列被处理 | ✓ VERIFIED | `scheduler.go:246,252` 新增了 `processManualQueue` 和 `processTimedQueue` 对 `TaskTypePollingProtect` 的调用 |
|
||||
| 7 | stopCardWithRetry/resumeCardWithRetry 在 gatewayClient==nil 时返回业务错误而非 nil | ✓ VERIFIED | `stop_resume_service.go:178-180` 和 `209-211` 均返回 `errors.New(CodeInternalError, "Gateway 未配置...")` |
|
||||
| 8 | GetPackages 接口响应中包含 page/page_size/total 分页字段,默认 page=1 pageSize=50 | ✓ VERIFIED | `asset/service.go:348` 签名含 page/pageSize,`asset_dto.go:101-106` 含 Total/Page/PageSize/Items |
|
||||
| 9 | 删除有关联套餐的 PackageSeries 时返回错误提示而非成功 | ✓ VERIFIED | `package_series/service.go:147-153` 先 CountBySeriesID,count>0 返回 CodeInvalidParam |
|
||||
| 10 | 数据库 tb_package 表不再有 enable_realname_activation 字段,已有 expiry_base 字段默认 from_activation | ✓ VERIFIED | `000089_realname_expiry_base.up.sql` DROP+ADD,`package.go:49` ExpiryBase 字段定义 |
|
||||
| 11 | 行业卡套餐永远直接激活,无视 expiry_base | ✓ VERIFIED | `order/service.go:1887` `if cardCategory != "industry" && !isCEndPurchase` 逻辑 |
|
||||
| 12 | C 端购买普通卡套餐前检查实名状态,删除 packagesNeedRealname 函数 | ✓ VERIFIED | `client_order/service.go:116` 用 `CardCategory == "normal"`;全仓库 `packagesNeedRealname` 零引用 |
|
||||
| 13 | ActivateByRealname 按 ExpiryBase 选择计时基准(from_purchase 用 CreatedAt,from_activation 用当前时刻)| ✓ VERIFIED | `activation_service.go:119-123` 按 ExpiryBase 分支选择 activatedAt |
|
||||
| 14 | 全仓库搜索 EnableRealnameActivation 零引用 | ✓ VERIFIED | grep 搜索 `EnableRealnameActivation\|enable_realname_activation\|packagesNeedRealname` 在 `internal/` 下零结果 |
|
||||
|
||||
**Score:** 14/14 truths verified
|
||||
|
||||
---
|
||||
|
||||
### Required Artifacts
|
||||
|
||||
#### Plan 01 Artifacts
|
||||
|
||||
| Artifact | Expected | Level 1 (Exists) | Level 2 (Substantive) | Level 3 (Wired) | Status |
|
||||
|----------|----------|------------------|-----------------------|-----------------|--------|
|
||||
| `internal/handler/admin/asset.go` | PERM-01 删除 Resolve 企业拦截;PERM-02 新增 Refresh 企业拦截 | ✓ | ✓ Resolve 无拦截,Refresh L74-78 有拦截 | ✓ 已注册路由 | ✓ VERIFIED |
|
||||
| `internal/service/commission_withdrawal/service.go` | FIN-01 Approve 补 approved_by/approved_at | ✓ | ✓ L209-210 更新字段 | ✓ Handler 调用 Approve | ✓ VERIFIED |
|
||||
| `internal/handler/admin/commission_withdrawal.go` | FIN-02 Reject 补 validator.Validate | ✓ | ✓ L69-71 validator.Struct 调用 | ✓ 路由已注册 | ✓ VERIFIED |
|
||||
| `internal/service/commission_withdrawal_setting/service.go` | FIN-03 激活配置前加 FOR UPDATE 行锁 | ✓ | ✓ L53-55 clause.Locking | ✓ Service 被 Handler 调用 | ✓ VERIFIED |
|
||||
| `internal/store/postgres/wechat_config_store.go` | FIN-03 微信配置激活前加 FOR UPDATE 行锁 | ✓ | ✓ L106 clause.Locking | ✓ Store 被 Service 调用 | ✓ VERIFIED |
|
||||
| `internal/polling/scheduler.go` | POLL-01 补 polling:protect 调度 | ✓ | ✓ L246,252 两行调度 | ✓ Scheduler 运行时执行 | ✓ VERIFIED |
|
||||
| `internal/service/iot_card/stop_resume_service.go` | POLL-02 gatewayClient==nil 返回业务错误 | ✓ | ✓ 两处均返回 errors.New | ✓ 被 StopCard/ResumeCard 调用 | ✓ VERIFIED |
|
||||
| `internal/service/asset/service.go` | POLL-03 GetPackages 支持分页 | ✓ | ✓ 函数签名含 page/pageSize,返回 AssetPackagesResult | ✓ Handler 传入 page/pageSize | ✓ VERIFIED |
|
||||
| `internal/service/package_series/service.go` | POLL-04 Delete 前检查关联套餐 | ✓ | ✓ L147-153 CountBySeriesID 检查 | ✓ 被 Handler 调用 | ✓ VERIFIED |
|
||||
| `internal/store/postgres/package_store.go` | POLL-04 新增 CountBySeriesID 方法 | ✓ | ✓ L140-141 方法定义 | ✓ 被 package_series.Service 调用 | ✓ VERIFIED |
|
||||
|
||||
#### Plan 02 Artifacts
|
||||
|
||||
| Artifact | Expected | Level 1 (Exists) | Level 2 (Substantive) | Level 3 (Wired) | Status |
|
||||
|----------|----------|------------------|-----------------------|-----------------|--------|
|
||||
| `migrations/000089_realname_expiry_base.up.sql` | REALNAME-01 DB 迁移:DROP enable_realname_activation + ADD expiry_base | ✓ | ✓ SQL 完整:DROP+ADD+COMMENT | ✓ 迁移文件可执行 | ✓ VERIFIED |
|
||||
| `migrations/000089_realname_expiry_base.down.sql` | REALNAME-01 回滚迁移 | ✓ | ✓ 逆向 DROP+ADD | ✓ 对应 up 文件 | ✓ VERIFIED |
|
||||
| `internal/model/package.go` | REALNAME-01 Model 同步:删旧字段,加 ExpiryBase | ✓ | ✓ L49 ExpiryBase string gorm 标签 | ✓ Model 被全仓库引用 | ✓ VERIFIED |
|
||||
| `internal/model/dto/package_dto.go` | REALNAME-05 DTO 更新:移除 EnableRealnameActivation,新增 ExpiryBase | ✓ | ✓ 三处 ExpiryBase(Create/Update/Response),无 EnableRealnameActivation | ✓ Handler 使用 DTO | ✓ VERIFIED |
|
||||
| `internal/store/postgres/package_store.go` | REALNAME-01 删除两步写入特殊处理 | ✓ | ✓ L24 直接 `Create(pkg).Error` | ✓ 被 package service 调用 | ✓ VERIFIED |
|
||||
| `internal/service/order/service.go` | REALNAME-02 activateMainPackage 三维决策重写 | ✓ | ✓ L1885-1898 行业卡/C端/后台囤货+ExpiryBase 逻辑 | ✓ 在订单流程中被调用 | ✓ VERIFIED |
|
||||
| `internal/service/client_order/service.go` | REALNAME-03 实名检查改按卡类型,删 packagesNeedRealname | ✓ | ✓ L116 `CardCategory == "normal"`,packagesNeedRealname 已删 | ✓ 在 C 端购买流程中被调用 | ✓ VERIFIED |
|
||||
| `internal/service/package/activation_service.go` | REALNAME-04 ActivateByRealname 按 ExpiryBase 选择激活基准 | ✓ | ✓ L119-123 ExpiryBase 分支 | ✓ 在实名激活流程中被调用 | ✓ VERIFIED |
|
||||
|
||||
---
|
||||
|
||||
### Key Link Verification
|
||||
|
||||
#### Plan 01 Key Links
|
||||
|
||||
| From | To | Via | Status | Details |
|
||||
|------|----|-----|--------|---------|
|
||||
| `asset.go:Resolve` | `constants.UserTypeEnterprise` | `middleware.GetUserTypeFromContext` | ✓ VERIFIED — 已删除,Resolve 中无企业拦截 | L40-51 直接跳过拦截 |
|
||||
| `asset.go:Refresh` | `constants.UserTypeEnterprise` | `middleware.GetUserTypeFromContext` | ✓ VERIFIED | L74-78 有 `UserTypeEnterprise` 检查 |
|
||||
| `commission_withdrawal/service.go:Approve` | `tb_commission_withdrawal_request.approved_by/approved_at` | updates map | ✓ VERIFIED | L209-210 有字段 |
|
||||
| `scheduler.go` | `constants.TaskTypePollingProtect` | processManualQueue+processTimedQueue | ✓ VERIFIED | L246,252 两行调用 |
|
||||
|
||||
#### Plan 02 Key Links
|
||||
|
||||
| From | To | Via | Status | Details |
|
||||
|------|----|-----|--------|---------|
|
||||
| `order/service.go:activateMainPackage` | `model.Package.ExpiryBase` | `pkg.ExpiryBase == "from_activation"` | ✓ VERIFIED | L1889 `pkg.ExpiryBase != "from_purchase"` 即 from_activation |
|
||||
| `client_order/service.go` | `assetInfo.CardCategory` | `card_category == "normal"` | ✓ VERIFIED | L116 直接比较 `"normal"` |
|
||||
| `activation_service.go:ActivateByRealname` | `packageUsage.CreatedAt` | `pkg.ExpiryBase == "from_purchase"` | ✓ VERIFIED | L119-120 用 `usage.CreatedAt` |
|
||||
|
||||
---
|
||||
|
||||
### Data-Flow Trace (Level 4)
|
||||
|
||||
| Artifact | Data Variable | Source | Produces Real Data | Status |
|
||||
|----------|---------------|--------|-------------------|--------|
|
||||
| `GetPackages` | `AssetPackagesResult` | `packageUsageStore.ListByCarrier` → DB 查询 | ✓ | ✓ FLOWING |
|
||||
| `activateMainPackage` 三维决策 | `status/pendingRealnameActivation` | `cardCategory`(DB first)、`order.BuyerType`、`pkg.ExpiryBase`(DB) | ✓ | ✓ FLOWING |
|
||||
| `ActivateByRealname` | `activatedAt` | `usage.CreatedAt`(DB PackageUsage.CreatedAt)或 `now` | ✓ | ✓ FLOWING |
|
||||
|
||||
---
|
||||
|
||||
### Behavioral Spot-Checks
|
||||
|
||||
| Behavior | Check | Result | Status |
|
||||
|----------|-------|--------|--------|
|
||||
| 编译通过 | `go build ./...` | 零输出(无错误) | ✓ PASS |
|
||||
| PERM-01 Resolve 无企业拦截 | `grep "UserTypeEnterprise" asset.go:Resolve` | 仅出现在 Refresh 中 | ✓ PASS |
|
||||
| PERM-02 Refresh 有企业拦截 | `grep "UserTypeEnterprise" asset.go:Refresh` | L76 存在 | ✓ PASS |
|
||||
| FIN-01 approved_by/at 字段 | `grep "approved_by\|approved_at" commission_withdrawal/service.go` | L209-210 存在 | ✓ PASS |
|
||||
| POLL-01 protect 调度 | `grep "TaskTypePollingProtect" scheduler.go` | L246,252 两行 | ✓ PASS |
|
||||
| REALNAME 旧字段零引用 | `grep "EnableRealnameActivation" internal/` | 零结果 | ✓ PASS |
|
||||
| 迁移文件存在 | `ls migrations/ \| grep 000089` | 两个文件存在 | ✓ PASS |
|
||||
| 所有 commits 存在 | `git log` 核验 13 个 commit hash | 全部找到 | ✓ PASS |
|
||||
|
||||
---
|
||||
|
||||
### Requirements Coverage
|
||||
|
||||
| Requirement | Source Plan | Description | Status | Evidence |
|
||||
|-------------|------------|-------------|--------|---------|
|
||||
| PERM-01 | 02-01 | 移除 Resolve 接口对企业账号的错误拦截 | ✓ SATISFIED | `asset.go:Resolve` L40-51,无企业拦截,commit 2a92ab6 |
|
||||
| PERM-02 | 02-01 | Refresh 接口新增企业账号拦截 | ✓ SATISFIED | `asset.go:Refresh` L74-78,CodeForbidden,commit 2a92ab6 |
|
||||
| FIN-01 | 02-01 | 提现审批人字段补全 | ✓ SATISFIED | `commission_withdrawal/service.go` L209-210,commit 8f62496 |
|
||||
| FIN-02 | 02-01 | 提现拒绝 remark 必填校验 | ✓ SATISFIED | `commission_withdrawal.go:RejectWithdrawal` L69-71,commit 9ee2f0b |
|
||||
| FIN-03 | 02-01 | 激活配置并发保护(FOR UPDATE 行锁) | ✓ SATISFIED | `commission_withdrawal_setting/service.go` L53-55 + `wechat_config_store.go` L106,commit bdac4ab |
|
||||
| REALNAME-01 | 02-02 | 移除 tb_package.enable_realname_activation,新增 expiry_base | ✓ SATISFIED | `000089_realname_expiry_base.up.sql` + `package.go` L49,commit 344850f |
|
||||
| REALNAME-02 | 02-02 | activateMainPackage 三维决策重写 | ✓ SATISFIED | `order/service.go` L1885-1898,commit 7dd5563 |
|
||||
| REALNAME-03 | 02-02 | C 端实名检查按卡类型,删 packagesNeedRealname | ✓ SATISFIED | `client_order/service.go` L116,commit 0ebb3d7 |
|
||||
| REALNAME-04 | 02-02 | ActivateByRealname 按 ExpiryBase 选择激活时间基准 | ✓ SATISFIED | `activation_service.go` L119-123,commit 60b43bb |
|
||||
| REALNAME-05 | 02-02 | 套餐管理 API DTO 更新(expiry_base 替换 enable_realname_activation) | ✓ SATISFIED | `package_dto.go` 三处 ExpiryBase,commit 244a274 |
|
||||
| POLL-01 | 02-01 | polling:protect 接入调度器 | ✓ SATISFIED | `scheduler.go` L246,252,commit 922a3d0 |
|
||||
| POLL-02 | 02-01 | gatewayClient=nil 时停复机返回错误 | ✓ SATISFIED | `stop_resume_service.go` L178-180,209-211,commit 8a53123 |
|
||||
| POLL-03 | 02-01 | packages 接口新增分页(page=1,pageSize=50,max=100) | ✓ SATISFIED | `asset/service.go` L348,`asset_dto.go` AssetPackagesResult,commit 8553a46 |
|
||||
| POLL-04 | 02-01 | Series 删除前检查关联套餐 | ✓ SATISFIED | `package_series/service.go` L147-153 + `package_store.go` L141,commit 47aa5f8 |
|
||||
|
||||
**所有 14 个 Phase 2 需求均已满足,无孤立需求。**
|
||||
|
||||
---
|
||||
|
||||
### Anti-Patterns Found
|
||||
|
||||
| File | Pattern | Severity | Impact |
|
||||
|------|---------|----------|--------|
|
||||
| 无 | — | — | — |
|
||||
|
||||
扫描结果:无 TODO/FIXME/PLACEHOLDER 留桩,无 `return null/[]` 空响应桩,无硬编码空数据流,所有修复均为实质性业务逻辑。
|
||||
|
||||
---
|
||||
|
||||
### Human Verification Required
|
||||
|
||||
**无** — 所有关键逻辑均可通过代码静态分析验证。以下为可选的线上联调验证(非阻塞):
|
||||
|
||||
1. **企业账号权限测试(PERM-01/02)**
|
||||
- **Test:** 用企业账号 Token 调用 `GET /api/admin/assets/resolve/:identifier`,验证返回资产数据而非 403
|
||||
- **Expected:** HTTP 200,返回资产信息
|
||||
- **Why human:** 需要有效的企业账号 Token 和真实资产数据,无法通过静态分析模拟
|
||||
|
||||
2. **并发行锁测试(FIN-03)**
|
||||
- **Test:** 并发同时调用两个提现配置激活接口,验证数据库中只有一条 is_active=true 记录
|
||||
- **Expected:** 只有一条 is_active=true
|
||||
- **Why human:** 并发场景需要实际数据库事务,无法静态验证
|
||||
|
||||
---
|
||||
|
||||
### Gaps Summary
|
||||
|
||||
**无 Gap** — Phase 2 所有 14 个需求均已完整实现,代码通过编译,关键模式全部可在代码中验证。
|
||||
|
||||
---
|
||||
|
||||
## Commit Inventory
|
||||
|
||||
| Commit | Message | Requirement |
|
||||
|--------|---------|-------------|
|
||||
| `2a92ab6` | fix(perm): 修复企业账号资产权限边界 PERM-01/02 | PERM-01, PERM-02 |
|
||||
| `8f62496` | fix(fin): 补全提现审批人字段 FIN-01 | FIN-01 |
|
||||
| `9ee2f0b` | fix(fin): 提现拒绝补 remark 必填校验 FIN-02 | FIN-02 |
|
||||
| `bdac4ab` | fix(fin): 激活配置并发行锁保护 FIN-03 | FIN-03 |
|
||||
| `344850f` | fix(realname): DB 迁移 + Model + Store 清理 REALNAME-01 | REALNAME-01 |
|
||||
| `244a274` | fix(realname): DTO 更新 expiry_base 替换 enable_realname_activation REALNAME-05 | REALNAME-05 |
|
||||
| `7dd5563` | fix(realname): activateMainPackage 三维决策重构 REALNAME-02 | REALNAME-02 |
|
||||
| `0ebb3d7` | fix(realname): C 端实名检查改为按卡类型判断 REALNAME-03 | REALNAME-03 |
|
||||
| `60b43bb` | fix(realname): ActivateByRealname 按 ExpiryBase 选择计时基准 REALNAME-04 | REALNAME-04 |
|
||||
| `922a3d0` | fix(poll): polling:protect 接入调度器 POLL-01 | POLL-01 |
|
||||
| `8a53123` | fix(poll): gatewayClient=nil 时停复机返回错误而非成功 POLL-02 | POLL-02 |
|
||||
| `8553a46` | fix(poll): GetPackages 接口新增分页 POLL-03 | POLL-03 |
|
||||
| `47aa5f8` | fix(poll): Series 删除前检查关联套餐 POLL-04 | POLL-04 |
|
||||
| `754bbcd` | fix(fin): 同步更新 openapi handlers 中 CommissionWithdrawalHandler 签名 | FIN-02(偏差修复) |
|
||||
| `4be473c` | fix(realname): 移除 auto_purchase.go 中废弃的 EnableRealnameActivation 引用 | REALNAME-01(偏差修复) |
|
||||
|
||||
所有 commit hash 均在 `git log` 中验证存在。
|
||||
|
||||
---
|
||||
|
||||
_Verified: 2026-03-28T10:00:00Z_
|
||||
_Verifier: the agent (gsd-verifier)_
|
||||
2130
.sisyphus/plans/修正业务-完整方案.md
Normal file
2130
.sisyphus/plans/修正业务-完整方案.md
Normal file
File diff suppressed because it is too large
Load Diff
31
AGENTS.md
31
AGENTS.md
@@ -18,6 +18,37 @@
|
||||
| 数据库迁移 | `db-migration` | 迁移命令、文件规范、执行流程、失败处理 |
|
||||
| 维护规范文档 | `doc-management` | 规范文档流程和维护规则 |
|
||||
| 调试 bug / 排查异常 | `systematic-debugging` | 四阶段根因分析流程、逐层诊断、场景速查表 |
|
||||
| 涉及业务逻辑的开发/修改 | `kb-sync` | 开发前查阅知识库、开发后更新知识库、业务决策记录 |
|
||||
|
||||
---
|
||||
|
||||
## 知识库规范(强制执行)
|
||||
|
||||
本项目的业务知识库在 Obsidian vault **「长沙鑫精」** 中,通过 `obsidian` CLI 访问。
|
||||
|
||||
**必须遵守:**
|
||||
|
||||
- 任何涉及业务逻辑的开发前,必须通过 `obsidian vault="长沙鑫精" search/read` 查阅知识库对应模块
|
||||
- 业务逻辑变更完成后,必须更新知识库中受影响的模块文档
|
||||
- 涉及业务决策变更时(为什么从 A 改成 B),必须在对应模块追加决策记录
|
||||
- 新增业务模块时,必须在知识库中创建对应文档
|
||||
|
||||
**禁止:**
|
||||
|
||||
- ❌ 不查阅知识库就开始开发业务功能
|
||||
- ❌ 改了业务逻辑但不更新知识库
|
||||
- ❌ 涉及业务决策变更但不记录原因
|
||||
|
||||
**知识库结构:**
|
||||
|
||||
| 文件夹 | 内容 |
|
||||
|--------|------|
|
||||
| `卡管业务整理/` | 正确的业务流程文档(流程图 + 接口字段速查 + 决策记录) |
|
||||
| `修正业务/` | 已知问题和修复方案 |
|
||||
|
||||
**详细操作规范**:加载 `kb-sync` Skill
|
||||
|
||||
---
|
||||
|
||||
### ⚠️ 新增 Handler 时必须同步更新文档生成器
|
||||
|
||||
|
||||
945
docs/system-audit-report.md
Normal file
945
docs/system-audit-report.md
Normal file
@@ -0,0 +1,945 @@
|
||||
# CMP 卡管平台 — 系统业务审计报告
|
||||
|
||||
> 基于代码实现(非规范设计)的全链路审计,2026-03-20
|
||||
> 产品 + 技术双视角,Mermaid 流程图标注业务断点
|
||||
|
||||
---
|
||||
|
||||
## 〇、系统角色与代理层级
|
||||
|
||||
### 角色一览
|
||||
|
||||
| 角色 | 说明 | 登录方式 | 可用接口 | 状态 |
|
||||
|------|------|---------|----------|------|
|
||||
| 平台管理员 | 运营全局 | `/api/auth/login` | `/api/admin/*` | ✅ |
|
||||
| 一级代理 | 平台发展的代理商 | 同上(RBAC 区分) | 同上(数据隔离) | ✅ |
|
||||
| N 级子代理 | 代理发展的下级,最多 7 级 | 同上 | 同上 | ✅ |
|
||||
| 企业客户 | 被授权管理卡/设备的企业 | 同上(能登录) | **无可用接口** | 🔴 |
|
||||
| C 端客户 | 终端用户 | 微信/小程序 `/api/c/v1/auth/*` | `/api/c/v1/*` | ✅ |
|
||||
|
||||
### 代理层级模型
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
P[平台] --> A1[一级代理 A<br/>Shop level=1, parent_id=NULL]
|
||||
P --> A2[一级代理 B<br/>Shop level=1]
|
||||
A1 --> B1[二级代理 C<br/>level=2, parent_id=A]
|
||||
A1 --> B2[二级代理 D<br/>level=2, parent_id=A]
|
||||
B1 --> C1[三级代理 E<br/>level=3, parent_id=C]
|
||||
|
||||
style P fill:#e1f5fe
|
||||
style A1 fill:#fff9c4
|
||||
style A2 fill:#fff9c4
|
||||
style B1 fill:#ffe0b2
|
||||
style B2 fill:#ffe0b2
|
||||
style C1 fill:#ffccbc
|
||||
```
|
||||
|
||||
**技术实现**:`model/shop.go` — `ParentID *uint` + `Level int`(1-7),创建时自动算层级。
|
||||
**数据权限**:登录时预算 `SubordinateShopIDs`(自己+所有下级),GORM Callback 全局过滤。
|
||||
|
||||
---
|
||||
|
||||
## 一、平台搭建 → 代理经营 → C 端消费
|
||||
|
||||
> 这是系统的**主干流程**:平台建好基础数据,代理拿到资源去经营,C 端客户最终消费。
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph 平台搭建["① 平台管理员搭建基础设施"]
|
||||
S1["创建运营商<br/>📡 中国移动/联通/电信<br/><code>POST /admin/carriers</code>"]
|
||||
S2["创建套餐系列<br/>📦 如'移动30G月租系列'<br/><code>POST /admin/package-series</code>"]
|
||||
S3["创建套餐<br/>💰 30G/月 ¥29.9<br/><code>POST /admin/packages</code>"]
|
||||
S4["导入 IoT 卡/设备<br/>📥 批量导入 ICCID/设备号<br/><code>POST /admin/iot-cards/import</code>"]
|
||||
S1 --> S2 --> S3
|
||||
S1 --> S4
|
||||
end
|
||||
|
||||
subgraph 代理发展["② 发展代理体系"]
|
||||
D1["创建一级代理<br/>🏪 开设店铺+管理员账号<br/><code>POST /admin/shops</code>"]
|
||||
D2["代理创建子代理<br/>🏪 parent_id=自己<br/>同一接口,RBAC控制"]
|
||||
D1 --> D2
|
||||
end
|
||||
|
||||
subgraph 代理授权["③ 给代理配货"]
|
||||
G1["分配资产给代理<br/>📦 IoT卡/设备 → 代理名下<br/><code>POST /admin/iot-cards/allocate</code><br/>status 1→2, 写入 shop_id"]
|
||||
G2["授权系列给代理<br/>🔑 代理可以卖哪些系列<br/><code>POST /admin/shop-series-grants</code><br/>配置:成本价、佣金金额"]
|
||||
G3["分配套餐<br/>📋 代理可卖哪些具体套餐<br/><code>POST /admin/shop-package-batch-allocations</code><br/>设置零售价、上架状态"]
|
||||
G2 --> G3
|
||||
end
|
||||
|
||||
subgraph 代理可向下级重复此过程["④ 代理给子代理配货(同样的流程)"]
|
||||
R1["代理也可以:<br/>• 分配资产给子代理<br/>• 授权系列给子代理<br/>• 设置子代理的成本价/佣金<br/><br/>⚠️ 子代理佣金 ≤ 自己佣金"]
|
||||
end
|
||||
|
||||
subgraph C端消费["⑤ C 端客户消费"]
|
||||
C1["扫码/输虚拟号验证资产<br/>📱 识别卡/设备<br/><code>POST /c/v1/auth/verify-asset</code><br/>asset_status 1→2(已销售)"]
|
||||
C2["微信授权登录<br/>👤 绑定客户身份<br/><code>POST /c/v1/auth/wechat-login</code>"]
|
||||
C3["查看可购套餐<br/>📋 根据资产所属代理展示<br/><code>GET /c/v1/asset/packages</code><br/>查 ShopPackageAllocation"]
|
||||
C4["下单支付<br/>💳 微信支付<br/><code>POST /c/v1/orders/create</code>"]
|
||||
C5["套餐生效<br/>✅ 立即激活或排队<br/>activateMainPackage()"]
|
||||
|
||||
C1 --> C2 --> C3 --> C4 --> C5
|
||||
end
|
||||
|
||||
平台搭建 --> 代理发展 --> 代理授权
|
||||
代理授权 --> 代理可向下级重复此过程
|
||||
代理授权 --> C端消费
|
||||
代理可向下级重复此过程 --> C端消费
|
||||
|
||||
classDef ok fill:#c8e6c9,stroke:#2e7d32
|
||||
classDef warn fill:#fff9c4,stroke:#f9a825
|
||||
classDef broken fill:#ffcdd2,stroke:#c62828
|
||||
```
|
||||
|
||||
### 这条链路的问题
|
||||
|
||||
| 步骤 | 问题 | 严重度 | 详情 |
|
||||
|------|------|--------|------|
|
||||
| ③ 创建套餐 | `enable_realname_activation` 默认 `true` | 🟡 | 不显式传 false 的套餐,全部变成"需实名后才激活"。`package/service.go:129` |
|
||||
| ⑤ 下单 | C 端实名校验值写错 | 🔴 **Bug** | 检查 `RealNameStatus != 1`,但已实名=2,已实名的卡反而不能买。`client_order/service.go:115` |
|
||||
| ④ 佣金链 | 某级缺授权则上级静默丢佣金 | 🟡 | 代理 B 没有系列授权 → A 的佣金直接不算,无告警。`commission_calculation/service.go:505-509` |
|
||||
|
||||
---
|
||||
|
||||
## 二、套餐激活的三条路径
|
||||
|
||||
> 客户买了套餐后,不一定能立刻生效。根据场景走不同路径。
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
PAY["客户支付成功<br/>order/service.go"]
|
||||
|
||||
PAY --> CHECK_REALNAME{"套餐需要实名激活?<br/>EnableRealnameActivation"}
|
||||
|
||||
CHECK_REALNAME -- "false(不需要)" --> CHECK_ACTIVE{"已有生效中主套餐?"}
|
||||
CHECK_REALNAME -- "true(需要实名)" --> PENDING_REALNAME["🟡 进入待实名状态<br/>status=pending<br/>pending_realname_activation=true<br/><br/>等卡实名后自动激活"]
|
||||
|
||||
CHECK_ACTIVE -- "没有" --> ACTIVATE["✅ 立即激活<br/>status=active<br/>设置 activated_at / expires_at<br/>如果卡被停机,自动复机"]
|
||||
CHECK_ACTIVE -- "有" --> QUEUE["🟡 排队等候<br/>status=pending<br/>priority=max+1<br/><br/>前一个套餐到期后自动接力"]
|
||||
|
||||
PENDING_REALNAME --> REALNAME_CHECK["轮询检测卡实名状态<br/>polling:realname 任务<br/>Gateway 返回实名=true"]
|
||||
REALNAME_CHECK --> TRIGGER["触发首次实名激活<br/>triggerFirstRealnameActivation()"]
|
||||
TRIGGER --> RPUSH["RPush 到 Redis 队列<br/>package:first:activation"]
|
||||
RPUSH --> BROKEN["🔴 断链!<br/>scheduler 不消费此队列<br/>套餐永远不会被激活"]
|
||||
|
||||
QUEUE --> EXPIRE["当前套餐到期/用完"]
|
||||
EXPIRE --> NEXT["激活排队中的下一个<br/>ActivateQueuedPackage()<br/>package:queue:activation"]
|
||||
NEXT --> ACTIVATE2["✅ 激活<br/>级联失效旧加油包"]
|
||||
|
||||
style BROKEN fill:#ffcdd2,stroke:#c62828,stroke-width:3px
|
||||
style ACTIVATE fill:#c8e6c9,stroke:#2e7d32
|
||||
style ACTIVATE2 fill:#c8e6c9,stroke:#2e7d32
|
||||
style PENDING_REALNAME fill:#fff9c4,stroke:#f9a825
|
||||
style QUEUE fill:#fff9c4,stroke:#f9a825
|
||||
```
|
||||
|
||||
### 问题
|
||||
|
||||
| 路径 | 状态 | 详情 |
|
||||
|------|------|------|
|
||||
| 立即激活 | ✅ 正常 | 无活跃主套餐时直接激活 |
|
||||
| 排队接力 | ✅ 正常 | 前一个到期后自动激活下一个 |
|
||||
| 待实名激活 | 🔴 **断链** | RPush 到无人消费的队列,**囤货待实名的套餐永远不会自动激活** |
|
||||
|
||||
---
|
||||
|
||||
## 三、套餐使用生命周期
|
||||
|
||||
> 套餐激活后,经历流量消耗、重置、耗尽、到期等阶段。
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
ACTIVE["✅ 生效中<br/>status=active"]
|
||||
|
||||
ACTIVE --> USE["日常使用消耗流量<br/>轮询 polling:carddata 检测用量<br/>Gateway 同步 current_month_usage_mb"]
|
||||
USE --> RESET_CHECK{"到重置周期?<br/>data_reset_cycle"}
|
||||
RESET_CHECK -- "是" --> RESET["流量重置<br/>package:data:reset 任务<br/>used_data_mb → 0"]
|
||||
RESET --> USE
|
||||
RESET_CHECK -- "否" --> EXHAUST_CHECK{"流量用完?"}
|
||||
EXHAUST_CHECK -- "否" --> EXPIRE_CHECK{"到期了?"}
|
||||
EXHAUST_CHECK -- "是" --> EXHAUSTED["⚡ 流量耗尽<br/>status=exhausted"]
|
||||
|
||||
EXPIRE_CHECK -- "否" --> USE
|
||||
EXPIRE_CHECK -- "是" --> EXPIRED["⏰ 到期<br/>status=expired"]
|
||||
|
||||
EXHAUSTED --> STOP["自动停机<br/>Gateway.StopCard()<br/>network_status=0<br/>stop_reason=traffic_exhausted"]
|
||||
EXHAUSTED --> NEXT_PKG
|
||||
|
||||
EXPIRED --> NEXT_PKG["激活排队套餐<br/>ActivateQueuedPackage()"]
|
||||
EXPIRED --> CASCADE["级联失效该主套餐的加油包"]
|
||||
|
||||
NEXT_PKG --> HAS_NEXT{"有排队套餐?"}
|
||||
HAS_NEXT -- "有" --> ACTIVATE_NEXT["✅ 激活下一个<br/>如被停机则自动复机"]
|
||||
HAS_NEXT -- "没有" --> NO_PACKAGE["❌ 无可用套餐<br/>保持停机状态<br/>等客户续购"]
|
||||
|
||||
NO_PACKAGE --> CUSTOMER_BUY["客户购买新套餐"]
|
||||
CUSTOMER_BUY --> ACTIVE
|
||||
|
||||
style ACTIVE fill:#c8e6c9
|
||||
style EXHAUSTED fill:#fff9c4
|
||||
style EXPIRED fill:#fff9c4
|
||||
style STOP fill:#ffcdd2
|
||||
style NO_PACKAGE fill:#ffcdd2
|
||||
style ACTIVATE_NEXT fill:#c8e6c9
|
||||
```
|
||||
|
||||
**技术链路验证**:✅ 这条完整链路都有对应的代码实现。停机→复机→套餐接力全部通了。
|
||||
|
||||
---
|
||||
|
||||
## 四、充值 → 佣金 → 提现
|
||||
|
||||
> 资金是系统的命脉。这里有三条独立的资金流。
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph 充值流["C 端客户充值"]
|
||||
R1["客户发起充值<br/>💰 选择金额<br/><code>POST /c/v1/wallet/recharge</code>"]
|
||||
R2["微信支付"]
|
||||
R3["支付回调<br/><code>POST /callback/wechat</code><br/>recharge/service.go"]
|
||||
R4["✅ 资产钱包到账<br/>AssetWallet.balance += amount"]
|
||||
R5["更新累计充值<br/>accumulated_recharge_by_series"]
|
||||
R6{"满足一次性佣金条件?"}
|
||||
R7["✅ 佣金直接发放<br/>(同步,不走队列)<br/>→ 店铺 commission 钱包"]
|
||||
R8["触发自动购包检查<br/>task:auto_purchase_after_recharge"]
|
||||
|
||||
R1 --> R2 --> R3 --> R4 --> R5 --> R6
|
||||
R6 -- "是" --> R7
|
||||
R6 -- "否" --> R8
|
||||
R4 --> R8
|
||||
end
|
||||
|
||||
subgraph 订单佣金流["订单支付触发佣金"]
|
||||
O1["客户购买套餐"]
|
||||
O2["支付成功"]
|
||||
O3["异步佣金任务入队<br/>commission:calculate"]
|
||||
O4["链式佣金计算<br/>沿代理层级往上"]
|
||||
O5["✅ 各级代理 commission 钱包到账"]
|
||||
|
||||
O1 --> O2 --> O3 --> O4 --> O5
|
||||
end
|
||||
|
||||
subgraph 提现流["代理佣金提现"]
|
||||
W1["代理发起提现<br/><code>POST /admin/my/commission/withdrawals</code>"]
|
||||
W2["冻结 commission 钱包余额"]
|
||||
W3["平台审批<br/><code>POST /admin/commission-withdrawals/:id/approve</code>"]
|
||||
W4["扣除冻结余额<br/>status=已通过"]
|
||||
W5["🟡 缺失:确认到账<br/>模型有 status=4(已到账)<br/>但没有代码把它设为 4<br/>实际靠线下打款"]
|
||||
|
||||
W1 --> W2 --> W3 --> W4 --> W5
|
||||
end
|
||||
|
||||
subgraph 预充值流["平台给代理预充值"]
|
||||
P1["平台充值<br/><code>POST /admin/agent-recharges</code>"]
|
||||
P2["✅ 代理 main 钱包到账<br/>用于钱包支付订单"]
|
||||
P1 --> P2
|
||||
end
|
||||
|
||||
style W5 fill:#fff9c4,stroke:#f9a825,stroke-width:2px
|
||||
```
|
||||
|
||||
### 问题
|
||||
|
||||
| 环节 | 状态 | 说明 |
|
||||
|------|------|------|
|
||||
| 充值 → 钱包到账 | ✅ | 链路完整 |
|
||||
| 充值 → 一次性佣金 | ✅ | 同步发放,但不走异步队列(与订单佣金路径不一致) |
|
||||
| 订单 → 佣金计算 | ✅ | 异步链路完整 |
|
||||
| 佣金提现 → 审批 | ✅ | 冻结/审批/拒绝都有 |
|
||||
| 审批 → 到账确认 | 🟡 **缺失** | 没有"已到账"标记入口,提现审批后就结束了 |
|
||||
|
||||
---
|
||||
|
||||
## 五、停机 / 复机
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph 自动停机["自动停机(流量耗尽)"]
|
||||
AS1["轮询检测到无活跃套餐"]
|
||||
AS2["调用 Gateway 停机<br/>Gateway.StopCard()"]
|
||||
AS3["更新状态<br/>network_status=0<br/>stopped_at=now<br/>stop_reason=traffic_exhausted"]
|
||||
AS1 --> AS2 --> AS3
|
||||
end
|
||||
|
||||
subgraph 自动复机["自动复机(新套餐激活)"]
|
||||
AR1["新套餐激活触发回调"]
|
||||
AR2{"停机原因是流量耗尽?"}
|
||||
AR2 -- "是" --> AR3["调用 Gateway 复机<br/>Gateway.StartCard()"]
|
||||
AR2 -- "否(手动停机)" --> AR4["❌ 不自动复机<br/>手动停的需要手动开"]
|
||||
AR3 --> AR5["更新状态<br/>network_status=1<br/>resumed_at=now"]
|
||||
AR1 --> AR2
|
||||
end
|
||||
|
||||
subgraph 手动停复机["后台手动操作"]
|
||||
M1["管理员/代理手动停机<br/><code>POST /admin/assets/card/:iccid/stop</code>"]
|
||||
M2["管理员/代理手动复机<br/><code>POST /admin/assets/card/:iccid/start</code>"]
|
||||
M1 --> M1A["stop_reason=manual"]
|
||||
M2 --> M2A["stop_reason 清空"]
|
||||
end
|
||||
|
||||
style AS3 fill:#ffcdd2
|
||||
style AR5 fill:#c8e6c9
|
||||
style AR4 fill:#fff9c4
|
||||
```
|
||||
|
||||
**状态**:✅ 全部链路完整,包括 Gateway 实际调用、保护期一致性校验。
|
||||
|
||||
---
|
||||
|
||||
## 六、换货流程
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph 发起["① 后台发起换货"]
|
||||
E1["代理创建换货单<br/><code>POST /admin/exchanges</code><br/>选择旧资产<br/>状态:待填写信息"]
|
||||
end
|
||||
|
||||
subgraph 客户["② 客户填写信息"]
|
||||
E2["C 端提交收货地址<br/><code>POST /c/v1/exchange/:id/shipping-info</code><br/>状态:待发货"]
|
||||
end
|
||||
|
||||
subgraph 发货["③ 后台发货"]
|
||||
E3["绑定新资产+物流<br/><code>POST /admin/exchanges/:id/ship</code><br/>新资产必须 asset_status=1<br/>状态:已发货"]
|
||||
end
|
||||
|
||||
subgraph 完成["④ 完成换货"]
|
||||
E4["确认完成<br/><code>POST /admin/exchanges/:id/complete</code>"]
|
||||
E5{"需要迁移数据?"}
|
||||
E5 -- "是" --> E6["迁移:<br/>• 钱包余额<br/>• 套餐记录<br/>• 累计充值/首充<br/>• 客户绑定<br/>• 资源标签"]
|
||||
E6 --> E7["旧资产 asset_status → 3(已换货)"]
|
||||
E5 -- "否" --> E7B["仅标记完成"]
|
||||
end
|
||||
|
||||
发起 --> 客户 --> 发货 --> 完成
|
||||
|
||||
style E7 fill:#fff9c4
|
||||
```
|
||||
|
||||
**状态**:✅ 链路完整。旧资产正确标记,数据迁移覆盖钱包/套餐/标签等。
|
||||
|
||||
---
|
||||
|
||||
## 七、企业客户流程
|
||||
|
||||
> 🔴 **这是系统最大的业务断裂**
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph 平台操作["平台/代理 操作(✅ 已实现)"]
|
||||
E1["创建企业<br/><code>POST /admin/enterprises</code>"]
|
||||
E2["创建企业账号<br/>user_type=4"]
|
||||
E3["授权卡给企业<br/><code>POST /admin/enterprises/:id/allocate-cards</code>"]
|
||||
E4["授权设备给企业<br/><code>POST /admin/enterprises/:id/allocate-devices</code>"]
|
||||
E1 --> E2
|
||||
E1 --> E3
|
||||
E1 --> E4
|
||||
end
|
||||
|
||||
subgraph 企业使用["企业自己管理(🔴 完全缺失)"]
|
||||
F1["❌ 企业登录后无可用接口"]
|
||||
F2["❌ 看不到被授权的卡列表"]
|
||||
F3["❌ 看不到被授权的设备列表"]
|
||||
F4["❌ 无法对卡/设备停复机"]
|
||||
F5["❌ 无法查看套餐/流量"]
|
||||
F6["❌ 无法为卡/设备续购套餐"]
|
||||
end
|
||||
|
||||
平台操作 --"🔴 断裂"--> 企业使用
|
||||
|
||||
subgraph 已有代码未接入["Service 层已写好但没接路由"]
|
||||
G1["ListDevicesForEnterprise()"]
|
||||
G2["GetDeviceDetail()"]
|
||||
G3["SuspendCard() / ResumeCard()"]
|
||||
end
|
||||
|
||||
style 企业使用 fill:#ffcdd2,stroke:#c62828,stroke-width:3px
|
||||
style 已有代码未接入 fill:#fff9c4
|
||||
```
|
||||
|
||||
**缺失清单**:
|
||||
- ❌ 企业认证中间件(只有 AdminAuth 和 PersonalAuth)
|
||||
- ❌ `/api/enterprise/*` 或 `/api/h5/*` 路由组
|
||||
- ❌ 企业端 Handler 层
|
||||
- ❌ 企业端可用的卡/设备/套餐/流量接口
|
||||
|
||||
**规范中的设计**:
|
||||
- `openspec/specs/enterprise-device-authorization/spec.md` — 明确写了企业端 H5 设备列表、详情、停复机
|
||||
- `openspec/changes/archive/2026-01-29-add-enterprise-device-authorization/proposal.md` — "新增企业端设备管理 API(H5)"
|
||||
|
||||
---
|
||||
|
||||
## 八、资产状态系统
|
||||
|
||||
> 当前系统有**三套并行的状态字段**,容易混淆。
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph status字段["status(分销链状态)"]
|
||||
S1["1 在库"] -- "AllocateCards()" --> S2["2 已分销"]
|
||||
S2 -. "❌ 无代码实现" .-> S3["3 已激活"]
|
||||
S3 -. "❌ 无代码实现" .-> S4["4 已停用"]
|
||||
end
|
||||
|
||||
subgraph asset_status字段["asset_status(业务生命周期)"]
|
||||
A1["1 在库"] -- "客户绑定资产" --> A2["2 已销售"]
|
||||
A2 -- "手动停用" --> A4["4 已停用"]
|
||||
A1 -- "手动停用" --> A4
|
||||
A2 -- "换货完成" --> A3["3 已换货"]
|
||||
end
|
||||
|
||||
subgraph network_status字段["network_status(网络状态)"]
|
||||
N1["1 开机"] -- "停机" --> N0["0 停机"]
|
||||
N0 -- "复机" --> N1
|
||||
end
|
||||
|
||||
style S3 fill:#e0e0e0,stroke:#9e9e9e,stroke-dasharray: 5 5
|
||||
style S4 fill:#e0e0e0,stroke:#9e9e9e,stroke-dasharray: 5 5
|
||||
```
|
||||
|
||||
**问题**:`status` 字段的 3(已激活)和 4(已停用)从未被任何代码使用。真正的业务状态走 `asset_status`,网络状态走 `network_status`。两个 status 字段语义重叠导致混淆。
|
||||
|
||||
---
|
||||
|
||||
## 九、实名状态不一致
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph 三处定义["同一个字段,三处定义不一致"]
|
||||
D1["Model 注释<br/>0=未实名 1=已实名"]
|
||||
D2["DTO 注释<br/>0=未实名 1=实名中 2=已实名"]
|
||||
D3["轮询代码实际写入<br/>true→2, false→0"]
|
||||
end
|
||||
|
||||
subgraph 影响["导致的问题"]
|
||||
I1["C 端下单校验<br/>RealNameStatus != 1<br/>只允许'实名中'通过?"]
|
||||
I2["已实名的卡 status=2<br/>2 != 1 为 true<br/>🔴 被拒单!"]
|
||||
end
|
||||
|
||||
D1 --> I1
|
||||
D2 --> I1
|
||||
D3 --> I2
|
||||
|
||||
style I2 fill:#ffcdd2,stroke:#c62828,stroke-width:3px
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十、代理佣金链式分配
|
||||
|
||||
> 当子代理的客户买了套餐,佣金怎么分?
|
||||
|
||||
```mermaid
|
||||
flowchart BT
|
||||
CUSTOMER["客户购买套餐<br/>💰 售价 ¥99"]
|
||||
|
||||
CUSTOMER --> C_AGENT["三级代理 C<br/>成本价 ¥60<br/>利润:¥99 - ¥60 = ¥39<br/>→ C 的 commission 钱包"]
|
||||
|
||||
C_AGENT --> B_AGENT["二级代理 B<br/>成本价 ¥40<br/>利润:¥60 - ¥40 = ¥20<br/>→ B 的 commission 钱包"]
|
||||
|
||||
B_AGENT --> A_AGENT["一级代理 A<br/>成本价 ¥25<br/>利润:¥40 - ¥25 = ¥15<br/>→ A 的 commission 钱包"]
|
||||
|
||||
A_AGENT --> PLATFORM["平台<br/>成本价 ¥25(A的成本价)<br/>不生成佣金记录<br/>平台收益在售价中"]
|
||||
|
||||
MISSING["⚠️ 如果 B 没有该系列授权<br/>→ B 和 A 都不会拿到佣金<br/>→ 无告警通知"]
|
||||
|
||||
style CUSTOMER fill:#e3f2fd
|
||||
style C_AGENT fill:#c8e6c9
|
||||
style B_AGENT fill:#c8e6c9
|
||||
style A_AGENT fill:#c8e6c9
|
||||
style PLATFORM fill:#e1f5fe
|
||||
style MISSING fill:#fff9c4,stroke:#f9a825
|
||||
```
|
||||
|
||||
**技术实现**:`commission_calculation/service.go` 的 `calculateChainOneTimeCommission()` 和差价佣金计算,沿 `Shop.ParentID` 循环向上,每级拿差额。
|
||||
|
||||
---
|
||||
|
||||
## 十一、异步任务完整性
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph 完整["✅ 链路完整"]
|
||||
T1["commission:calculate"]
|
||||
T2["polling:realname / carddata / package"]
|
||||
T3["polling:protect"]
|
||||
T4["package:data:reset"]
|
||||
T5["package:queue:activation"]
|
||||
T6["order:expire"]
|
||||
T7["auto_purchase_after_recharge"]
|
||||
T8["iot_card:import / device:import"]
|
||||
T9["commission:stats:* (3个)"]
|
||||
T10["alert:check / data:cleanup"]
|
||||
end
|
||||
|
||||
subgraph 断链["🔴 断链"]
|
||||
B1["package:first:activation<br/><br/>生产者:triggerFirstRealnameActivation()<br/>消费者:HandlePackageFirstActivation()<br/><br/>问题:RPush 到 Redis list<br/>但 scheduler 不消费这个队列"]
|
||||
end
|
||||
|
||||
subgraph 未确认["⚠️ 待确认"]
|
||||
U1["sim:status:sync<br/>有消费者,未确认生产者"]
|
||||
end
|
||||
|
||||
style B1 fill:#ffcdd2,stroke:#c62828,stroke-width:2px
|
||||
style U1 fill:#fff9c4
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十二、系统全景互联图
|
||||
|
||||
> **这是整个系统的"大地图"**,展示所有业务域之间的数据流和触发关系。
|
||||
> 红色=断链/缺失,黄色=有风险,绿色=正常。
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
%% ===== 平台搭建层 =====
|
||||
subgraph 平台搭建["🏗️ 平台搭建"]
|
||||
CARRIER["运营商"]
|
||||
SERIES["套餐系列"]
|
||||
PKG["套餐"]
|
||||
CARRIER --> SERIES --> PKG
|
||||
end
|
||||
|
||||
%% ===== 代理体系层 =====
|
||||
subgraph 代理体系["🏪 代理体系(多级)"]
|
||||
SHOP["创建店铺/子代理"]
|
||||
GRANT["授权系列给代理<br/>配置成本价/佣金"]
|
||||
ALLOC_PKG["分配套餐<br/>设置零售价/上架"]
|
||||
SHOP --> GRANT --> ALLOC_PKG
|
||||
end
|
||||
|
||||
%% ===== 资产层 =====
|
||||
subgraph 资产管理["📦 资产管理"]
|
||||
IMPORT["导入卡/设备"]
|
||||
ALLOC_ASSET["分配给代理<br/>status 1→2"]
|
||||
BIND["C端客户绑定<br/>asset_status 1→2"]
|
||||
IMPORT --> ALLOC_ASSET --> BIND
|
||||
end
|
||||
|
||||
%% ===== C端层 =====
|
||||
subgraph C端["📱 C 端客户"]
|
||||
LOGIN["微信登录"]
|
||||
SEE_PKG["查看可购套餐"]
|
||||
ORDER["下单购买"]
|
||||
PAY["微信支付"]
|
||||
USE["日常使用"]
|
||||
RECHARGE["充值"]
|
||||
LOGIN --> SEE_PKG --> ORDER --> PAY
|
||||
PAY --> USE
|
||||
USE --> RECHARGE
|
||||
end
|
||||
|
||||
%% ===== 套餐层 =====
|
||||
subgraph 套餐系统["📋 套餐生命周期"]
|
||||
ACTIVATE["激活"]
|
||||
QUEUE_WAIT["排队等候"]
|
||||
PENDING_REALNAME["待实名激活"]
|
||||
DATA_RESET["流量重置"]
|
||||
EXHAUST["流量耗尽"]
|
||||
EXPIRE["到期"]
|
||||
NEXT_ACTIVATE["激活下一个"]
|
||||
end
|
||||
|
||||
%% ===== 支付/财务层 =====
|
||||
subgraph 财务["💰 资金流"]
|
||||
WALLET_IN["资产钱包到账"]
|
||||
COMMISSION_CALC["佣金计算<br/>链式分账"]
|
||||
COMMISSION_WALLET["代理佣金钱包"]
|
||||
WITHDRAW["佣金提现"]
|
||||
AGENT_RECHARGE["平台给代理充值<br/>主钱包"]
|
||||
end
|
||||
|
||||
%% ===== 轮询层 =====
|
||||
subgraph 轮询["🔄 轮询系统"]
|
||||
POLL_REALNAME["实名检测"]
|
||||
POLL_CARDDATA["流量检测"]
|
||||
POLL_PACKAGE["套餐检测"]
|
||||
POLL_RESET["数据重置"]
|
||||
end
|
||||
|
||||
%% ===== 网关层 =====
|
||||
subgraph 网关["📡 Gateway"]
|
||||
GW_STOP["停机"]
|
||||
GW_START["复机"]
|
||||
GW_QUERY["查询实名/流量"]
|
||||
end
|
||||
|
||||
%% ===== 企业层 =====
|
||||
subgraph 企业["🏢 企业客户"]
|
||||
ENT_AUTH["授权卡/设备给企业"]
|
||||
ENT_USE["🔴 企业自己管理<br/>(完全缺失)"]
|
||||
end
|
||||
|
||||
%% ===== 换货层 =====
|
||||
subgraph 换货["🔄 换货"]
|
||||
EXCHANGE["发起→发货→完成"]
|
||||
MIGRATE["迁移钱包/套餐/标签"]
|
||||
end
|
||||
|
||||
%% ===== 自动任务 =====
|
||||
subgraph 自动任务["⚡ 自动任务"]
|
||||
AUTO_PURCHASE["🔴 自动购包<br/>(无人触发)"]
|
||||
ORDER_EXPIRE["订单超时取消"]
|
||||
ALERT["告警检查"]
|
||||
end
|
||||
|
||||
%% ========== 连线 ==========
|
||||
|
||||
%% 平台搭建 → 代理
|
||||
平台搭建 --> 代理体系
|
||||
平台搭建 --> 资产管理
|
||||
|
||||
%% 代理 → C端可见
|
||||
ALLOC_PKG --> SEE_PKG
|
||||
ALLOC_ASSET --> BIND
|
||||
|
||||
%% C端购买链路
|
||||
PAY -->|"支付成功"| ACTIVATE
|
||||
PAY -->|"有主套餐"| QUEUE_WAIT
|
||||
PAY -->|"⚠️ 需实名"| PENDING_REALNAME
|
||||
PAY -->|"异步"| COMMISSION_CALC
|
||||
|
||||
%% 充值链路
|
||||
RECHARGE -->|"回调"| WALLET_IN
|
||||
WALLET_IN -->|"满足条件"| COMMISSION_CALC
|
||||
RECHARGE -.->|"🔴 应触发但未接线"| AUTO_PURCHASE
|
||||
|
||||
%% 佣金链路
|
||||
COMMISSION_CALC -->|"各级差额"| COMMISSION_WALLET
|
||||
COMMISSION_WALLET --> WITHDRAW
|
||||
AGENT_RECHARGE -->|"主钱包"| ORDER
|
||||
|
||||
%% 套餐生命周期
|
||||
ACTIVATE --> USE
|
||||
USE --> DATA_RESET
|
||||
USE --> EXHAUST
|
||||
USE --> EXPIRE
|
||||
EXHAUST -->|"停机"| GW_STOP
|
||||
EXPIRE --> NEXT_ACTIVATE
|
||||
NEXT_ACTIVATE -->|"复机"| GW_START
|
||||
QUEUE_WAIT -->|"前一个到期"| NEXT_ACTIVATE
|
||||
|
||||
%% 实名激活断链
|
||||
POLL_REALNAME -->|"首次实名"| PENDING_REALNAME
|
||||
PENDING_REALNAME -.->|"🔴 断链"| ACTIVATE
|
||||
|
||||
%% 轮询 → 网关
|
||||
POLL_REALNAME --> GW_QUERY
|
||||
POLL_CARDDATA --> GW_QUERY
|
||||
POLL_CARDDATA -->|"扣减流量"| EXHAUST
|
||||
POLL_PACKAGE -->|"超额检测"| GW_STOP
|
||||
POLL_RESET --> DATA_RESET
|
||||
|
||||
%% 企业
|
||||
ALLOC_ASSET --> ENT_AUTH
|
||||
ENT_AUTH -.->|"🔴 断裂"| ENT_USE
|
||||
|
||||
%% 换货
|
||||
EXCHANGE --> MIGRATE
|
||||
MIGRATE -->|"旧资产 asset_status=3"| 资产管理
|
||||
|
||||
%% 自动任务
|
||||
ORDER -->|"超时"| ORDER_EXPIRE
|
||||
AUTO_PURCHASE -.->|"🔴 应连接"| ORDER
|
||||
|
||||
%% 样式
|
||||
style ENT_USE fill:#ffcdd2,stroke:#c62828,stroke-width:3px
|
||||
style PENDING_REALNAME fill:#ffcdd2,stroke:#c62828,stroke-width:2px
|
||||
style AUTO_PURCHASE fill:#ffcdd2,stroke:#c62828,stroke-width:2px
|
||||
style ACTIVATE fill:#c8e6c9
|
||||
style NEXT_ACTIVATE fill:#c8e6c9
|
||||
style COMMISSION_CALC fill:#c8e6c9
|
||||
style WALLET_IN fill:#c8e6c9
|
||||
```
|
||||
|
||||
### 图中的五条断链 / 缺失
|
||||
|
||||
| # | 断链位置 | 问题 | 影响 |
|
||||
|---|---------|------|------|
|
||||
| ① | 充值 → 自动购包 | `NewAutoPurchaseTask()` 无人调用 | 强充后不会自动购买套餐 |
|
||||
| ② | 实名检测 → 待实名套餐激活 | RPush 到无人消费的队列 | 囤货套餐永远不激活 |
|
||||
| ③ | 企业授权 → 企业管理 | 无企业端接口 | 企业用户无法使用系统 |
|
||||
| ④ | 自动购包 → 佣金 | 创建已支付订单但不触发佣金计算 | 自动购包的订单不产生佣金 |
|
||||
| ⑤ | C 端下单 → 实名校验 | `!= 1` 应为 `!= 2` | 已实名的卡被拒单 |
|
||||
|
||||
---
|
||||
|
||||
## 十三、自动购包完整链路
|
||||
|
||||
> 🔴 这条链路**有处理器但无人触发**
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph 预期流程["设计中的完整流程"]
|
||||
R1["C 端发起强充充值<br/><code>POST /c/v1/wallet/recharge</code><br/>关联套餐 LinkedPackageIDs"]
|
||||
R2["微信支付成功<br/>充值回调"]
|
||||
R3["资产钱包到账"]
|
||||
R4["🔴 应该触发自动购包<br/>Enqueue auto_purchase_after_recharge<br/><br/>但当前代码中<br/>NewAutoPurchaseTask() 无人调用"]
|
||||
R5["自动购包处理器<br/>auto_purchase.go:ProcessTask()"]
|
||||
R6["检查钱包余额"]
|
||||
R7["创建订单(已支付状态)"]
|
||||
R8["激活套餐"]
|
||||
R9["🟡 应触发佣金但未触发<br/>没有 enqueueCommissionCalculation"]
|
||||
|
||||
R1 --> R2 --> R3 --> R4
|
||||
R4 -.->|"未接线"| R5
|
||||
R5 --> R6 --> R7 --> R8
|
||||
R8 --> R9
|
||||
end
|
||||
|
||||
style R4 fill:#ffcdd2,stroke:#c62828,stroke-width:3px
|
||||
style R9 fill:#fff9c4,stroke:#f9a825,stroke-width:2px
|
||||
```
|
||||
|
||||
**技术详情**:
|
||||
- 生产者:`task/auto_purchase.go:202-214` — `NewAutoPurchaseTask()` 创建任务对象,但全项目无调用点
|
||||
- 消费者:`task/auto_purchase.go:76-199` — `ProcessTask()` 完整实现了购包逻辑
|
||||
- 数据前置:`client_order/service.go:370` — 充值记录的 `auto_purchase_status=pending` 已写入
|
||||
- 佣金缺失:`auto_purchase.go` 内部没有 `enqueueCommissionCalculation` 调用
|
||||
|
||||
---
|
||||
|
||||
## 十四、轮询系统互联图
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
SCHEDULER["轮询调度器<br/>每秒执行一次<br/>scheduler.go"]
|
||||
|
||||
SCHEDULER --> REALNAME["实名检测<br/>polling:realname"]
|
||||
SCHEDULER --> CARDDATA["流量检测<br/>polling:carddata"]
|
||||
SCHEDULER --> PACKAGE["套餐检测<br/>polling:package"]
|
||||
SCHEDULER --> RESET["数据重置<br/>每分钟检查"]
|
||||
SCHEDULER --> PKG_ACTIVATION["套餐过期检查<br/>→ 排队激活"]
|
||||
|
||||
SCHEDULER -.->|"🟡 未调度"| PROTECT["保护期一致性<br/>polling:protect"]
|
||||
|
||||
REALNAME --> R1["调 Gateway 查实名"]
|
||||
R1 --> R2["更新 real_name_status"]
|
||||
R2 --> R3{"首次实名?"}
|
||||
R3 -- "是" --> R4["🔴 触发待实名激活<br/>(断链:RPush 到无人消费队列)"]
|
||||
R3 -- "否" --> R5["重新入队"]
|
||||
|
||||
CARDDATA --> C1["调 Gateway 查流量"]
|
||||
C1 --> C2["更新卡月用量"]
|
||||
C2 --> C3["扣减套餐流量<br/>加油包优先"]
|
||||
C3 --> C4{"全部套餐耗尽?"}
|
||||
C4 -- "是" --> C5["调 Gateway 停机<br/>network_status=0"]
|
||||
C4 -- "否" --> C6["重新入队"]
|
||||
|
||||
PACKAGE --> P1["读套餐配置"]
|
||||
P1 --> P2{"超额?"}
|
||||
P2 -- "是" --> P3["调 Gateway 停机"]
|
||||
P2 -- "否" --> P4["重新入队"]
|
||||
|
||||
RESET --> RS1{"日/月/年重置时间到?"}
|
||||
RS1 -- "是" --> RS2["重置 data_usage_mb=0<br/>status→active<br/>计算 next_reset_at"]
|
||||
|
||||
PKG_ACTIVATION --> PA1["检查过期主套餐"]
|
||||
PA1 --> PA2["级联失效加油包"]
|
||||
PA2 --> PA3["激活排队主套餐"]
|
||||
PA3 --> PA4["触发自动复机回调"]
|
||||
|
||||
style R4 fill:#ffcdd2,stroke:#c62828,stroke-width:2px
|
||||
style PROTECT fill:#fff9c4,stroke:#f9a825,stroke-dasharray: 5 5
|
||||
style C5 fill:#ffcdd2
|
||||
```
|
||||
|
||||
**回调注入情况**:
|
||||
|
||||
| 回调接口 | 定义位置 | bootstrap 注入 | 状态 |
|
||||
|---------|---------|---------------|------|
|
||||
| `PollingCallback`(卡生命周期) | `iot_card/service.go` | `bootstrap/services.go` ✅ | ✅ 已接线 |
|
||||
| `StopResumeCallback`(停复机) | `package/usage_service.go` | ⚠️ 未确认 | 🟡 可能未注入 |
|
||||
| `ResumeCallback`(激活后复机) | `package/activation_service.go` | ⚠️ 未确认 | 🟡 可能未注入 |
|
||||
|
||||
---
|
||||
|
||||
## 十五、订单完整生命周期
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph 创建["创建订单"]
|
||||
CREATE_ADMIN["后台下单<br/><code>POST /admin/orders</code>"]
|
||||
CREATE_CLIENT["C端下单<br/><code>POST /c/v1/orders/create</code>"]
|
||||
CREATE_AUTO["自动购包创建<br/>🔴 未接线"]
|
||||
end
|
||||
|
||||
PENDING["待支付<br/>设置 expires_at<br/>开始超时倒计时"]
|
||||
|
||||
subgraph 支付["支付"]
|
||||
PAY_WECHAT["微信支付"]
|
||||
PAY_WALLET["钱包支付<br/>立即扣款"]
|
||||
PAY_CALLBACK["支付回调<br/><code>POST /callback/wechat</code>"]
|
||||
end
|
||||
|
||||
subgraph 支付后副作用["支付成功后的链式反应"]
|
||||
ACTIVATE_PKG["激活套餐<br/>(立即/排队/待实名)"]
|
||||
ENQUEUE_COMMISSION["入队佣金计算<br/>commission:calculate"]
|
||||
CLEAR_EXPIRE["清空 expires_at"]
|
||||
end
|
||||
|
||||
subgraph 超时["超时处理"]
|
||||
EXPIRE_CHECK["Asynq 每分钟扫描<br/>order_expire 任务"]
|
||||
CANCEL["取消订单<br/>payment_status=cancelled"]
|
||||
UNFREEZE["解冻钱包余额<br/>(如果冻结过)"]
|
||||
end
|
||||
|
||||
CREATE_ADMIN --> PENDING
|
||||
CREATE_CLIENT --> PENDING
|
||||
CREATE_AUTO -.-> PENDING
|
||||
|
||||
PENDING --> PAY_WECHAT --> PAY_CALLBACK
|
||||
PENDING --> PAY_WALLET
|
||||
|
||||
PAY_CALLBACK --> ACTIVATE_PKG
|
||||
PAY_CALLBACK --> ENQUEUE_COMMISSION
|
||||
PAY_CALLBACK --> CLEAR_EXPIRE
|
||||
|
||||
PAY_WALLET --> ACTIVATE_PKG
|
||||
PAY_WALLET --> ENQUEUE_COMMISSION
|
||||
|
||||
PENDING -->|"超过 expires_at"| EXPIRE_CHECK
|
||||
EXPIRE_CHECK --> CANCEL --> UNFREEZE
|
||||
|
||||
style CREATE_AUTO fill:#ffcdd2,stroke-dasharray: 5 5
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十六、C 端客户完整旅程
|
||||
|
||||
> 从扫码到日常使用的端到端闭环
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
SCAN["📱 扫码/输入虚拟号<br/>verify-asset"]
|
||||
LOGIN["🔑 微信授权登录<br/>wechat-login / miniapp-login"]
|
||||
BIND_PHONE["📞 绑定手机号<br/>bind-phone"]
|
||||
PROFILE["👤 个人资料<br/>get/update profile"]
|
||||
|
||||
SEE_ASSET["📊 查看资产信息<br/>asset/info"]
|
||||
SEE_PKG["📋 查看可购套餐<br/>asset/packages"]
|
||||
SEE_HISTORY["📜 套餐历史<br/>asset/package-history"]
|
||||
REFRESH["🔄 刷新资产<br/>asset/refresh"]
|
||||
|
||||
BUY["🛒 购买套餐<br/>orders/create → 微信支付"]
|
||||
ORDER_LIST["📑 我的订单<br/>orders / orders/:id"]
|
||||
|
||||
WALLET["💰 钱包详情<br/>wallet/detail"]
|
||||
RECHARGE["💳 充值<br/>wallet/recharge"]
|
||||
RECHARGE_LIST["📃 充值记录<br/>wallet/recharges"]
|
||||
TRANSACTIONS["📃 流水<br/>wallet/transactions"]
|
||||
|
||||
REALNAME["🪪 实名认证<br/>realname/link"]
|
||||
|
||||
DEVICE_CARDS["📡 设备卡列表<br/>device/cards"]
|
||||
DEVICE_OPS["⚙️ 设备操作<br/>reboot / factory-reset / wifi / switch-card"]
|
||||
|
||||
EXCHANGE_PENDING["🔄 查看换货单<br/>exchange/pending"]
|
||||
EXCHANGE_SHIP["📦 提交收货信息<br/>exchange/:id/shipping-info"]
|
||||
|
||||
SCAN --> LOGIN --> BIND_PHONE --> PROFILE
|
||||
PROFILE --> SEE_ASSET
|
||||
SEE_ASSET --> SEE_PKG --> BUY
|
||||
SEE_ASSET --> SEE_HISTORY
|
||||
SEE_ASSET --> REFRESH
|
||||
|
||||
BUY --> ORDER_LIST
|
||||
SEE_ASSET --> WALLET --> RECHARGE
|
||||
WALLET --> RECHARGE_LIST
|
||||
WALLET --> TRANSACTIONS
|
||||
|
||||
SEE_ASSET --> REALNAME
|
||||
SEE_ASSET --> DEVICE_CARDS --> DEVICE_OPS
|
||||
|
||||
SEE_ASSET --> EXCHANGE_PENDING --> EXCHANGE_SHIP
|
||||
|
||||
style SCAN fill:#e3f2fd
|
||||
style BUY fill:#c8e6c9
|
||||
style RECHARGE fill:#c8e6c9
|
||||
```
|
||||
|
||||
**这条旅程的完整性**:✅ C 端所有接口都已实现(在 `/api/c/v1/*` 下),功能闭环。主要问题在后端处理逻辑(实名校验 bug、激活断链),不在接口层。
|
||||
|
||||
---
|
||||
|
||||
## 十六、TODO 留桩清单
|
||||
|
||||
> 代码中明确标记了 TODO 但未实现的功能。
|
||||
|
||||
| 位置 | TODO 内容 | 影响 |
|
||||
|------|----------|------|
|
||||
| `order/service.go:2356` | 实现富友支付发起逻辑 | 富友支付渠道不可用 |
|
||||
| `order/service.go:2362` | 实现富友小程序支付发起逻辑 | 同上 |
|
||||
| `order/service.go:2107,2163` | 从 payment_config_id 动态加载支付配置 | 多商户支付不可用 |
|
||||
| `recharge/service.go:271` | 按 payment_config_id 加载配置验签 | 充值验签留桩 |
|
||||
| `customer/service.go:53,66` | 通过 PersonalCustomerPhoneStore 管理手机号 | 客户手机号 CRUD 不完整 |
|
||||
| `polling/alert_service.go:395,405` | 集成邮件/短信服务 | 告警只写日志,不发通知 |
|
||||
|
||||
---
|
||||
|
||||
## 📋 完整问题清单
|
||||
|
||||
### 🔴 生产 Bug(业务跑不通)
|
||||
|
||||
| # | 问题 | 影响 | 位置 |
|
||||
|---|------|------|------|
|
||||
| **P0-1** | C 端实名校验值写错 | 已实名的卡无法购买需实名套餐 | `client_order/service.go:115` |
|
||||
| **P0-2** | 实名激活任务断链 | 囤货待实名的套餐永远不会自动激活 | `polling_handler.go:1093` |
|
||||
| **P0-3** | 自动购包任务无生产者 | `NewAutoPurchaseTask()` 全项目无人调用,强充后不会自动购包 | `task/auto_purchase.go:202` |
|
||||
| **P0-4** | 自动购包不触发佣金 | 创建了已支付订单但没有 `enqueueCommissionCalculation` | `task/auto_purchase.go` |
|
||||
|
||||
### 🟠 功能缺失(设计了没实现)
|
||||
|
||||
| # | 问题 | 影响 | 出处 |
|
||||
|---|------|------|------|
|
||||
| **P1-1** | 企业端接口完全缺失 | 企业用户无法管理自己的资产 | enterprise-device-authorization spec |
|
||||
| **P1-2** | 企业认证中间件缺失 | 企业无独立登录鉴权 | b-end-auth spec |
|
||||
| **P1-3** | 提现无"已到账"确认 | 审批后无法标记打款完成 | commission_withdrawal model |
|
||||
| **P1-4** | 富友支付未实现 | 该支付渠道不可用 | order/service.go:2356 |
|
||||
| **P1-5** | 多商户支付配置未实现 | 无法按商户动态加载支付 | order/service.go:2107 |
|
||||
| **P1-6** | `polling:protect` 未被调度 | 保护期一致性检查定义了但 scheduler 不消费 | polling/scheduler.go |
|
||||
|
||||
### 🟡 设计缺陷 / 技术债
|
||||
|
||||
| # | 问题 | 影响 | 位置 |
|
||||
|---|------|------|------|
|
||||
| **P2-1** | EnableRealnameActivation 默认 true | 所有套餐默认需实名 | package/service.go:129 |
|
||||
| **P2-2** | 实名状态值三处不一致 | 校验逻辑混乱 | model / DTO / polling |
|
||||
| **P2-3** | status 与 asset_status 冗余 | status 的 3/4 是死状态 | iot_card.go / device.go |
|
||||
| **P2-4** | 佣金链断裂无告警 | 中间级缺授权则上级静默丢佣金 | commission_calculation |
|
||||
| **P2-5** | 废弃字段仍在并行写入 | first_commission_paid / accumulated_recharge | recharge/service.go |
|
||||
| **P2-6** | 告警只写日志不发通知 | 邮件/短信未集成 | alert_service.go |
|
||||
| **P2-7** | StopResumeCallback / ResumeCallback 可能未注入 | 停复机和激活后复机的回调可能未在 bootstrap 接线 | activation_service / usage_service |
|
||||
|
||||
### 🔵 待确认
|
||||
|
||||
| # | 问题 | 说明 |
|
||||
|---|------|------|
|
||||
| **Q-1** | `sim:status:sync` 任务生产者 | 有消费者无生产者 |
|
||||
| **Q-2** | `package:queue:activation` 完整性 | 生产→消费链待验证 |
|
||||
| **Q-3** | 充值验签留桩 | recharge 验签由外层处理,未确认 |
|
||||
|
||||
---
|
||||
|
||||
## 🔧 修复优先级建议
|
||||
|
||||
### 立即修复(P0)— 业务跑不通
|
||||
1. **P0-1** `client_order/service.go:115` — `!= 1` 改为 `!= 2`
|
||||
2. **P0-2** `polling_handler.go:1093` — RPush 改为 `queueClient.Enqueue()`,或 scheduler 添加消费者
|
||||
3. **P0-3** 充值回调中补充 `NewAutoPurchaseTask` 的 Enqueue 调用
|
||||
4. **P0-4** `auto_purchase.go` 处理成功后补充 `enqueueCommissionCalculation`
|
||||
|
||||
### 尽快修复(P1)— 功能缺失
|
||||
5. 统一实名状态值定义
|
||||
6. 确认 `EnableRealnameActivation` 默认值是否符合业务预期
|
||||
7. 补充提现"已到账"标记接口
|
||||
8. 企业端接口(根据业务优先级排期)
|
||||
9. `polling:protect` 保护期检查接入调度
|
||||
|
||||
### 技术债清理(P2)
|
||||
10. 评估统一 `status` 和 `asset_status`
|
||||
11. 停止废弃字段的并行写入
|
||||
12. 佣金链断裂时添加告警日志/通知
|
||||
13. 确认 StopResumeCallback / ResumeCallback 注入情况
|
||||
704
docs/前端接口变更说明.md
704
docs/前端接口变更说明.md
@@ -1,6 +1,6 @@
|
||||
# 前端接口变更说明
|
||||
|
||||
> 最后更新:2026-03-19
|
||||
> 最后更新:2026-03-20
|
||||
|
||||
---
|
||||
|
||||
@@ -37,7 +37,9 @@
|
||||
| 接口 | 变更内容 |
|
||||
|------|----------|
|
||||
| `POST /api/admin/shop-package-batch-pricing/batch-update` | 仅支持批量调整成本价(移除 `pricing_target`) |
|
||||
| 所有返回 `PackageResponse` 的套餐列表接口 | 响应新增 `retail_price` 字段 |
|
||||
| 所有返回 `PackageResponse` 的套餐接口 | 响应新增 `virtual_ratio`、`one_time_commission_amount`、`tier_info`、`calendar_type`、`data_reset_cycle`、`enable_realname_activation` 等字段 |
|
||||
| `GET /api/admin/iot-cards/standalone` | 响应新增 `virtual_no`(虚拟号)字段 |
|
||||
| `GET /api/admin/devices` | `device_no` 重命名为 `virtual_no`,新增 `bound_card_count`、`first_commission_paid`、`accumulated_recharge` 字段 |
|
||||
| 运营商创建/编辑接口 | 新增 `realname_link_type` 和 `realname_link_template` 字段 |
|
||||
|
||||
### 新增(后台)
|
||||
@@ -54,6 +56,9 @@
|
||||
| `POST /api/admin/exchanges/:id/complete` | 确认完成 |
|
||||
| `POST /api/admin/exchanges/:id/cancel` | 取消换货 |
|
||||
| `POST /api/admin/exchanges/:id/renew` | 旧资产转新(generation+1) |
|
||||
| 微信支付配置管理(8 个接口) | `GET/POST/PUT/DELETE /api/admin/wechat-configs` 及 `active`、`activate`、`deactivate` 子路由 |
|
||||
| 代理预充值(4 个接口) | `/api/admin/agent-recharges` 创建/列表/详情/线下确认 |
|
||||
| 资产管理(11 个接口) | `/api/admin/assets/*` 资产解析、状态刷新、套餐、停复机、钱包概况/流水 |
|
||||
|
||||
### 新增(C 端)
|
||||
|
||||
@@ -124,13 +129,57 @@ POST /api/admin/shop-package-batch-pricing/batch-update
|
||||
|
||||
---
|
||||
|
||||
### 套餐列表响应新增 `retail_price` 字段
|
||||
### 套餐响应新增字段(`PackageResponse`)
|
||||
|
||||
所有返回 `PackageResponse` 的套餐列表接口,响应体新增字段:
|
||||
所有返回 `PackageResponse` 的套餐接口,响应体新增字段:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `retail_price` | int64 | 零售价(单位:分),代理商可见 |
|
||||
| `retail_price` | int64 | 零售价(分),代理商可见 |
|
||||
| `profit_margin` | int64 | 利润空间(分),仅代理用户可见 |
|
||||
| `current_commission_rate` | string | 当前返佣比例,仅代理用户可见 |
|
||||
| `one_time_commission_amount` | int64 | 一次性佣金金额(分),代理视角 |
|
||||
| `tier_info` | object | 梯度返佣信息(含 `current_rate` / `next_threshold` / `next_rate`),仅代理可见 |
|
||||
| `virtual_ratio` | float64 | 虚流量比例(`real_data_mb/virtual_data_mb`) |
|
||||
| `calendar_type` | string | 套餐周期类型:`natural_month` / `by_day` |
|
||||
| `duration_days` | int | 套餐天数(`calendar_type=by_day` 时有值) |
|
||||
| `data_reset_cycle` | string | 流量重置周期:`daily` / `monthly` / `yearly` / `none` |
|
||||
| `enable_realname_activation` | bool | 是否启用实名激活 |
|
||||
|
||||
---
|
||||
|
||||
### IoT 卡列表响应新增 `virtual_no` 字段
|
||||
|
||||
接口:
|
||||
|
||||
```
|
||||
GET /api/admin/iot-cards/standalone
|
||||
```
|
||||
|
||||
响应体新增字段:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `virtual_no` | string | 卡虚拟号(用于客服查找资产) |
|
||||
|
||||
---
|
||||
|
||||
### 设备列表字段调整
|
||||
|
||||
接口:
|
||||
|
||||
```
|
||||
GET /api/admin/devices
|
||||
```
|
||||
|
||||
字段变更如下:
|
||||
|
||||
| 变更类型 | 字段 | 类型 | 说明 |
|
||||
|----------|------|------|------|
|
||||
| 重命名 | `device_no` → `virtual_no` | string | 设备虚拟号/别名 |
|
||||
| 新增 | `bound_card_count` | int | 绑定卡数量 |
|
||||
| 新增 | `first_commission_paid` | bool | 一次性佣金是否已发放 |
|
||||
| 新增 | `accumulated_recharge` | int64 | 累计充值金额(分) |
|
||||
|
||||
---
|
||||
|
||||
@@ -360,6 +409,651 @@ flowchart TD
|
||||
|
||||
---
|
||||
|
||||
### 微信支付配置管理
|
||||
|
||||
> 权限:仅超级管理员和平台用户可访问。
|
||||
|
||||
#### W1 获取当前生效的支付配置
|
||||
|
||||
```
|
||||
GET /api/admin/wechat-configs/active
|
||||
```
|
||||
|
||||
- **认证**:需要后台 Bearer Token
|
||||
- **请求参数**:无
|
||||
|
||||
**响应体(`WechatConfigResponse`):**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | uint | 配置ID |
|
||||
| `name` | string | 配置名称 |
|
||||
| `description` | string | 配置描述 |
|
||||
| `provider_type` | string | 支付渠道类型 (`wechat`:微信直连, `fuiou`:富友) |
|
||||
| `is_active` | bool | 是否激活 |
|
||||
| `oa_app_id` | string | 公众号AppID |
|
||||
| `oa_app_secret` | string | 公众号AppSecret(已脱敏) |
|
||||
| `oa_token` | string | 公众号Token(已脱敏) |
|
||||
| `oa_aes_key` | string | 公众号AES加密Key(已脱敏) |
|
||||
| `oa_oauth_redirect_url` | string | OAuth回调地址 |
|
||||
| `miniapp_app_id` | string | 小程序AppID |
|
||||
| `miniapp_app_secret` | string | 小程序AppSecret(已脱敏) |
|
||||
| `wx_mch_id` | string | 微信商户号 |
|
||||
| `wx_api_v3_key` | string | 微信APIv3密钥(已脱敏) |
|
||||
| `wx_api_v2_key` | string | 微信APIv2密钥(已脱敏) |
|
||||
| `wx_cert_content` | string | 微信支付证书内容(配置状态) |
|
||||
| `wx_key_content` | string | 微信支付密钥内容(配置状态) |
|
||||
| `wx_serial_no` | string | 微信证书序列号 |
|
||||
| `wx_notify_url` | string | 微信支付回调地址 |
|
||||
| `fy_ins_cd` | string | 富友机构号 |
|
||||
| `fy_mchnt_cd` | string | 富友商户号 |
|
||||
| `fy_term_id` | string | 富友终端号 |
|
||||
| `fy_private_key` | string | 富友私钥(配置状态) |
|
||||
| `fy_public_key` | string | 富友公钥(配置状态) |
|
||||
| `fy_api_url` | string | 富友API地址 |
|
||||
| `fy_notify_url` | string | 富友支付回调地址 |
|
||||
| `created_at` | string | 创建时间 |
|
||||
| `updated_at` | string | 更新时间 |
|
||||
|
||||
**敏感字段说明**:
|
||||
- 短密钥字段(如 `oa_app_secret`、`wx_api_v3_key`)返回 `xxxx***xxxx` 脱敏形式。
|
||||
- 长文本密钥/证书字段(如 `wx_cert_content`、`fy_private_key`)返回 `[已配置]` 或 `[未配置]`。
|
||||
|
||||
---
|
||||
|
||||
#### W2 支付配置列表
|
||||
|
||||
```
|
||||
GET /api/admin/wechat-configs
|
||||
```
|
||||
|
||||
- **认证**:需要后台 Bearer Token
|
||||
|
||||
**Query 参数:**
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `page` | int | 否 | 页码,最小 1 |
|
||||
| `page_size` | int | 否 | 每页数量,1~100 |
|
||||
| `provider_type` | string | 否 | 支付渠道类型 (`wechat`/`fuiou`) |
|
||||
| `is_active` | bool | 否 | 是否激活 (`true`:已激活, `false`:未激活) |
|
||||
|
||||
**响应体(`WechatConfigListResponse`):**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `list` | array | 配置列表(每项字段同 `WechatConfigResponse`) |
|
||||
| `total` | int64 | 总数 |
|
||||
| `page` | int | 当前页 |
|
||||
| `page_size` | int | 每页数量 |
|
||||
|
||||
---
|
||||
|
||||
#### W3 创建支付配置
|
||||
|
||||
```
|
||||
POST /api/admin/wechat-configs
|
||||
```
|
||||
|
||||
- **认证**:需要后台 Bearer Token
|
||||
|
||||
**请求体(`CreateWechatConfigRequest`):**
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `name` | string | 是 | 配置名称 |
|
||||
| `description` | string | 否 | 配置描述 |
|
||||
| `provider_type` | string | 是 | 支付渠道类型 (`wechat`:微信直连, `fuiou`:富友) |
|
||||
| `oa_app_id` | string | 否 | 公众号AppID |
|
||||
| `oa_app_secret` | string | 否 | 公众号AppSecret |
|
||||
| `oa_token` | string | 否 | 公众号Token |
|
||||
| `oa_aes_key` | string | 否 | 公众号AES加密Key |
|
||||
| `oa_oauth_redirect_url` | string | 否 | OAuth回调地址 |
|
||||
| `miniapp_app_id` | string | 否 | 小程序AppID |
|
||||
| `miniapp_app_secret` | string | 否 | 小程序AppSecret |
|
||||
| `wx_mch_id` | string | 否 | 微信商户号 |
|
||||
| `wx_api_v3_key` | string | 否 | 微信APIv3密钥 |
|
||||
| `wx_api_v2_key` | string | 否 | 微信APIv2密钥 |
|
||||
| `wx_cert_content` | string | 否 | 微信支付证书内容(PEM格式) |
|
||||
| `wx_key_content` | string | 否 | 微信支付密钥内容(PEM格式) |
|
||||
| `wx_serial_no` | string | 否 | 微信证书序列号 |
|
||||
| `wx_notify_url` | string | 否 | 微信支付回调地址 |
|
||||
| `fy_ins_cd` | string | 否 | 富友机构号 |
|
||||
| `fy_mchnt_cd` | string | 否 | 富友商户号 |
|
||||
| `fy_term_id` | string | 否 | 富友终端号 |
|
||||
| `fy_private_key` | string | 否 | 富友私钥(PEM格式) |
|
||||
| `fy_public_key` | string | 否 | 富友公钥(PEM格式) |
|
||||
| `fy_api_url` | string | 否 | 富友API地址 |
|
||||
| `fy_notify_url` | string | 否 | 富友支付回调地址 |
|
||||
|
||||
**响应体**:`WechatConfigResponse`(字段同 W1,敏感字段脱敏返回)
|
||||
|
||||
---
|
||||
|
||||
#### W4 支付配置详情
|
||||
|
||||
```
|
||||
GET /api/admin/wechat-configs/:id
|
||||
```
|
||||
|
||||
- **认证**:需要后台 Bearer Token
|
||||
- **路径参数**:`id`(配置ID)
|
||||
- **响应体**:`WechatConfigResponse`(字段同 W1,敏感字段脱敏返回)
|
||||
|
||||
---
|
||||
|
||||
#### W5 更新支付配置
|
||||
|
||||
```
|
||||
PUT /api/admin/wechat-configs/:id
|
||||
```
|
||||
|
||||
- **认证**:需要后台 Bearer Token
|
||||
|
||||
**路径参数:** `id`(配置ID)
|
||||
|
||||
**请求体(`UpdateWechatConfigRequest`,全字段可选):**
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `name` | string | 否 | 配置名称 |
|
||||
| `description` | string | 否 | 配置描述 |
|
||||
| `provider_type` | string | 否 | 支付渠道类型 (`wechat`:微信直连, `fuiou`:富友) |
|
||||
| `oa_app_id` | string | 否 | 公众号AppID |
|
||||
| `oa_app_secret` | string | 否 | 公众号AppSecret |
|
||||
| `oa_token` | string | 否 | 公众号Token |
|
||||
| `oa_aes_key` | string | 否 | 公众号AES加密Key |
|
||||
| `oa_oauth_redirect_url` | string | 否 | OAuth回调地址 |
|
||||
| `miniapp_app_id` | string | 否 | 小程序AppID |
|
||||
| `miniapp_app_secret` | string | 否 | 小程序AppSecret |
|
||||
| `wx_mch_id` | string | 否 | 微信商户号 |
|
||||
| `wx_api_v3_key` | string | 否 | 微信APIv3密钥 |
|
||||
| `wx_api_v2_key` | string | 否 | 微信APIv2密钥 |
|
||||
| `wx_cert_content` | string | 否 | 微信支付证书内容(PEM格式) |
|
||||
| `wx_key_content` | string | 否 | 微信支付密钥内容(PEM格式) |
|
||||
| `wx_serial_no` | string | 否 | 微信证书序列号 |
|
||||
| `wx_notify_url` | string | 否 | 微信支付回调地址 |
|
||||
| `fy_ins_cd` | string | 否 | 富友机构号 |
|
||||
| `fy_mchnt_cd` | string | 否 | 富友商户号 |
|
||||
| `fy_term_id` | string | 否 | 富友终端号 |
|
||||
| `fy_private_key` | string | 否 | 富友私钥(PEM格式) |
|
||||
| `fy_public_key` | string | 否 | 富友公钥(PEM格式) |
|
||||
| `fy_api_url` | string | 否 | 富友API地址 |
|
||||
| `fy_notify_url` | string | 否 | 富友支付回调地址 |
|
||||
|
||||
**响应体**:`WechatConfigResponse`(字段同 W1,敏感字段脱敏返回)
|
||||
|
||||
---
|
||||
|
||||
#### W6 删除支付配置
|
||||
|
||||
```
|
||||
DELETE /api/admin/wechat-configs/:id
|
||||
```
|
||||
|
||||
- **认证**:需要后台 Bearer Token
|
||||
- **路径参数**:`id`(配置ID)
|
||||
- **请求体**:无
|
||||
- **响应体**:无(仅返回统一成功响应)
|
||||
|
||||
---
|
||||
|
||||
#### W7 激活支付配置
|
||||
|
||||
```
|
||||
POST /api/admin/wechat-configs/:id/activate
|
||||
```
|
||||
|
||||
- **认证**:需要后台 Bearer Token
|
||||
- **路径参数**:`id`(配置ID)
|
||||
- **请求体**:无
|
||||
- **响应体**:`WechatConfigResponse`(字段同 W1,敏感字段脱敏返回)
|
||||
|
||||
---
|
||||
|
||||
#### W8 停用支付配置
|
||||
|
||||
```
|
||||
POST /api/admin/wechat-configs/:id/deactivate
|
||||
```
|
||||
|
||||
- **认证**:需要后台 Bearer Token
|
||||
- **路径参数**:`id`(配置ID)
|
||||
- **请求体**:无
|
||||
- **响应体**:`WechatConfigResponse`(字段同 W1,敏感字段脱敏返回)
|
||||
|
||||
---
|
||||
|
||||
### 代理预充值
|
||||
|
||||
> 权限:企业账号无权访问。
|
||||
|
||||
#### R1 创建代理充值订单
|
||||
|
||||
```
|
||||
POST /api/admin/agent-recharges
|
||||
```
|
||||
|
||||
- **认证**:需要后台 Bearer Token
|
||||
|
||||
**请求体(`CreateAgentRechargeRequest`):**
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `shop_id` | uint | 是 | 目标店铺ID,代理只能填自己店铺 |
|
||||
| `amount` | int64 | 是 | 充值金额(分),范围100元~100万元 |
|
||||
| `payment_method` | string | 是 | 支付方式 (`wechat`:微信在线支付, `offline`:线下转账仅平台可用) |
|
||||
|
||||
**响应体(`AgentRechargeResponse`):**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | uint | 充值记录ID |
|
||||
| `recharge_no` | string | 充值单号(ARCH前缀) |
|
||||
| `shop_id` | uint | 店铺ID |
|
||||
| `shop_name` | string | 店铺名称 |
|
||||
| `agent_wallet_id` | uint | 代理钱包ID |
|
||||
| `amount` | int64 | 充值金额(分) |
|
||||
| `payment_method` | string | 支付方式 (`wechat`:微信在线支付, `offline`:线下转账) |
|
||||
| `payment_channel` | string | 实际支付通道 (`wechat_direct`:微信直连, `fuyou`:富友, `offline`:线下转账) |
|
||||
| `payment_config_id` | uint | 关联支付配置ID,线下充值为 `null` |
|
||||
| `payment_transaction_id` | string | 第三方支付流水号 |
|
||||
| `status` | int | 状态 (1:待支付, 2:已完成, 3:已取消) |
|
||||
| `paid_at` | string | 支付时间 |
|
||||
| `completed_at` | string | 完成时间 |
|
||||
| `created_at` | string | 创建时间 |
|
||||
| `updated_at` | string | 更新时间 |
|
||||
|
||||
---
|
||||
|
||||
#### R2 代理充值订单列表
|
||||
|
||||
```
|
||||
GET /api/admin/agent-recharges
|
||||
```
|
||||
|
||||
- **认证**:需要后台 Bearer Token
|
||||
|
||||
**Query 参数(`AgentRechargeListRequest`):**
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `page` | int | 否 | 页码,默认1 |
|
||||
| `page_size` | int | 否 | 每页条数,默认20,最大100 |
|
||||
| `shop_id` | uint | 否 | 按店铺ID过滤 |
|
||||
| `status` | int | 否 | 按状态过滤 (1:待支付, 2:已完成, 3:已取消) |
|
||||
| `start_date` | string | 否 | 创建时间起始日期(YYYY-MM-DD) |
|
||||
| `end_date` | string | 否 | 创建时间截止日期(YYYY-MM-DD) |
|
||||
|
||||
**响应体(`AgentRechargeListResponse`):**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `total` | int64 | 总记录数 |
|
||||
| `page` | int | 当前页码 |
|
||||
| `page_size` | int | 每页条数 |
|
||||
| `list` | array | 充值记录列表(每项字段同 `AgentRechargeResponse`) |
|
||||
|
||||
---
|
||||
|
||||
#### R3 代理充值订单详情
|
||||
|
||||
```
|
||||
GET /api/admin/agent-recharges/:id
|
||||
```
|
||||
|
||||
- **认证**:需要后台 Bearer Token
|
||||
- **路径参数**:`id`(充值记录ID)
|
||||
- **响应体**:`AgentRechargeResponse`(字段同 R1)
|
||||
|
||||
---
|
||||
|
||||
#### R4 确认线下充值
|
||||
|
||||
```
|
||||
POST /api/admin/agent-recharges/:id/offline-pay
|
||||
```
|
||||
|
||||
- **认证**:需要后台 Bearer Token
|
||||
|
||||
**路径参数:** `id`(充值记录ID)
|
||||
|
||||
**请求体(`AgentOfflinePayRequest`):**
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `operation_password` | string | 是 | 操作密码 |
|
||||
|
||||
**响应体**:`AgentRechargeResponse`(字段同 R1)
|
||||
|
||||
---
|
||||
|
||||
### 资产管理
|
||||
|
||||
#### Z1 通过标识符解析资产
|
||||
|
||||
```
|
||||
GET /api/admin/assets/resolve/:identifier
|
||||
```
|
||||
|
||||
- **认证**:需要后台 Bearer Token
|
||||
- **路径参数**:`identifier`(资产标识符,支持虚拟号/ICCID/IMEI/SN/MSISDN)
|
||||
|
||||
**响应体(`AssetResolveResponse`):**
|
||||
|
||||
**基础字段**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `asset_type` | string | 资产类型:`card` 或 `device` |
|
||||
| `asset_id` | uint | 资产数据库ID |
|
||||
| `virtual_no` | string | 虚拟号 |
|
||||
| `status` | int | 资产状态 |
|
||||
| `batch_no` | string | 批次号 |
|
||||
| `shop_id` | uint | 所属店铺ID |
|
||||
| `shop_name` | string | 所属店铺名称 |
|
||||
| `series_id` | uint | 套餐系列ID |
|
||||
| `series_name` | string | 套餐系列名称 |
|
||||
| `first_commission_paid` | bool | 一次性佣金是否已发放 |
|
||||
| `accumulated_recharge` | int64 | 累计充值金额(分) |
|
||||
| `activated_at` | string | 激活时间 |
|
||||
| `created_at` | string | 创建时间 |
|
||||
| `updated_at` | string | 更新时间 |
|
||||
|
||||
**状态聚合字段**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `real_name_status` | int | 实名状态:0未实名 1实名中 2已实名 |
|
||||
| `current_package` | string | 当前套餐名称(无套餐时为空) |
|
||||
| `package_total_mb` | int64 | 当前套餐总虚流量(MB),已按 `virtual_ratio` 换算 |
|
||||
| `package_used_mb` | float64 | 当前已用虚流量(MB),已按 `virtual_ratio` 换算 |
|
||||
| `package_remain_mb` | float64 | 当前套餐剩余虚流量(MB),已按 `virtual_ratio` 换算 |
|
||||
| `device_protect_status` | string | 设备保护期状态:`none`/`stop`/`start`(仅 `asset_type=device`) |
|
||||
|
||||
**绑定关系字段**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `iccid` | string | 卡ICCID(`asset_type=card`) |
|
||||
| `bound_device_id` | uint | 绑定设备ID(`asset_type=card`) |
|
||||
| `bound_device_no` | string | 绑定设备虚拟号(`asset_type=card`) |
|
||||
| `bound_device_name` | string | 绑定设备名称(`asset_type=card`) |
|
||||
| `bound_card_count` | int | 绑定卡数量(`asset_type=device`) |
|
||||
| `cards` | array | 绑定卡列表(`asset_type=device`,每项字段见下) |
|
||||
|
||||
**`cards` 子项(`BoundCardInfo`)**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `card_id` | uint | 卡ID |
|
||||
| `iccid` | string | ICCID |
|
||||
| `msisdn` | string | 手机号 |
|
||||
| `network_status` | int | 网络状态:0停机 1开机 |
|
||||
| `real_name_status` | int | 实名状态 |
|
||||
| `slot_position` | int | 插槽位置 |
|
||||
|
||||
**设备专属字段**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `device_name` | string | 设备名称 |
|
||||
| `imei` | string | 设备IMEI |
|
||||
| `sn` | string | 设备序列号 |
|
||||
| `device_model` | string | 设备型号 |
|
||||
| `device_type` | string | 设备类型 |
|
||||
| `max_sim_slots` | int | 最大插槽数 |
|
||||
| `manufacturer` | string | 制造商 |
|
||||
|
||||
**卡专属字段**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `carrier_id` | uint | 运营商ID |
|
||||
| `carrier_type` | string | 运营商类型 |
|
||||
| `carrier_name` | string | 运营商名称 |
|
||||
| `msisdn` | string | 手机号 |
|
||||
| `imsi` | string | IMSI |
|
||||
| `card_category` | string | 卡业务类型 |
|
||||
| `supplier` | string | 供应商 |
|
||||
| `activation_status` | int | 激活状态 |
|
||||
| `enable_polling` | bool | 是否参与轮询 |
|
||||
| `network_status` | int | 网络状态:0停机 1开机(仅 `asset_type=card`) |
|
||||
|
||||
---
|
||||
|
||||
#### Z2 资产实时状态
|
||||
|
||||
```
|
||||
GET /api/admin/assets/:asset_type/:id/realtime-status
|
||||
```
|
||||
|
||||
- **认证**:需要后台 Bearer Token
|
||||
|
||||
**路径参数:**
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `asset_type` | string | 是 | 资产类型:`card` 或 `device` |
|
||||
| `id` | uint | 是 | 资产ID |
|
||||
|
||||
**响应体(`AssetRealtimeStatusResponse`):**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `asset_type` | string | 资产类型:`card` 或 `device` |
|
||||
| `asset_id` | uint | 资产ID |
|
||||
| `network_status` | int | 网络状态(`asset_type=card`):0停机 1开机 |
|
||||
| `real_name_status` | int | 实名状态(`asset_type=card`) |
|
||||
| `current_month_usage_mb` | float64 | 本月已用流量MB(`asset_type=card`) |
|
||||
| `last_sync_time` | string | 最后同步时间(`asset_type=card`) |
|
||||
| `device_protect_status` | string | 保护期状态(`asset_type=device`):`none`/`stop`/`start` |
|
||||
| `cards` | array | 绑定卡状态列表(`asset_type=device`,每项字段同 `BoundCardInfo`) |
|
||||
|
||||
---
|
||||
|
||||
#### Z3 刷新资产状态
|
||||
|
||||
```
|
||||
POST /api/admin/assets/:asset_type/:id/refresh
|
||||
```
|
||||
|
||||
- **认证**:需要后台 Bearer Token
|
||||
- **路径参数**:同 Z2
|
||||
- **请求体**:无
|
||||
- **响应体**:`AssetRealtimeStatusResponse`(字段同 Z2)
|
||||
|
||||
---
|
||||
|
||||
#### Z4 资产套餐列表
|
||||
|
||||
```
|
||||
GET /api/admin/assets/:asset_type/:id/packages
|
||||
```
|
||||
|
||||
- **认证**:需要后台 Bearer Token
|
||||
- **路径参数**:同 Z2
|
||||
|
||||
**响应体(数组,每项为 `AssetPackageResponse`):**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `package_usage_id` | uint | 套餐使用记录ID |
|
||||
| `package_id` | uint | 套餐ID |
|
||||
| `package_name` | string | 套餐名称 |
|
||||
| `package_type` | string | 套餐类型:`formal`/`addon` |
|
||||
| `usage_type` | string | 使用类型:`single_card`/`device` |
|
||||
| `status` | int | 状态:0待生效 1生效中 2已用完 3已过期 4已失效 |
|
||||
| `status_name` | string | 状态名称 |
|
||||
| `data_limit_mb` | int64 | 套餐真流量总量(MB) |
|
||||
| `virtual_limit_mb` | int64 | 套餐虚流量总量(MB),按 `virtual_ratio` 换算 |
|
||||
| `data_usage_mb` | int64 | 已用真流量(MB) |
|
||||
| `virtual_used_mb` | float64 | 已用虚流量(MB),按 `virtual_ratio` 换算 |
|
||||
| `virtual_remain_mb` | float64 | 剩余虚流量(MB),按 `virtual_ratio` 换算 |
|
||||
| `virtual_ratio` | float64 | 虚流量比例(real/virtual) |
|
||||
| `activated_at` | string | 激活时间(待生效套餐为空) |
|
||||
| `expires_at` | string | 到期时间(待生效套餐为空) |
|
||||
| `master_usage_id` | uint | 主套餐ID(加油包时有值) |
|
||||
| `priority` | int | 优先级 |
|
||||
| `created_at` | string | 创建时间 |
|
||||
|
||||
---
|
||||
|
||||
#### Z5 当前生效套餐
|
||||
|
||||
```
|
||||
GET /api/admin/assets/:asset_type/:id/current-package
|
||||
```
|
||||
|
||||
- **认证**:需要后台 Bearer Token
|
||||
- **路径参数**:同 Z2
|
||||
- **响应体**:`AssetPackageResponse`(字段同 Z4)
|
||||
|
||||
---
|
||||
|
||||
#### Z6 设备停机
|
||||
|
||||
```
|
||||
POST /api/admin/assets/device/:device_id/stop
|
||||
```
|
||||
|
||||
- **认证**:需要后台 Bearer Token
|
||||
|
||||
**路径参数:**
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `device_id` | uint | 是 | 设备ID |
|
||||
|
||||
**响应体(`DeviceSuspendResponse`):**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `success_count` | int | 成功停机卡数 |
|
||||
| `fail_count` | int | 失败卡数 |
|
||||
| `skip_count` | int | 跳过卡数(未实名或已停机) |
|
||||
| `failed_items` | array | 失败详情(`iccid` / `reason`) |
|
||||
|
||||
---
|
||||
|
||||
#### Z7 设备复机
|
||||
|
||||
```
|
||||
POST /api/admin/assets/device/:device_id/start
|
||||
```
|
||||
|
||||
- **认证**:需要后台 Bearer Token
|
||||
- **路径参数**:`device_id`(设备ID)
|
||||
- **请求体**:无
|
||||
- **响应体**:无(仅返回统一成功响应)
|
||||
|
||||
---
|
||||
|
||||
#### Z8 单卡停机
|
||||
|
||||
```
|
||||
POST /api/admin/assets/card/:iccid/stop
|
||||
```
|
||||
|
||||
- **认证**:需要后台 Bearer Token
|
||||
- **路径参数**:`iccid`(卡ICCID)
|
||||
- **请求体**:无
|
||||
- **响应体**:无(仅返回统一成功响应)
|
||||
|
||||
---
|
||||
|
||||
#### Z9 单卡复机
|
||||
|
||||
```
|
||||
POST /api/admin/assets/card/:iccid/start
|
||||
```
|
||||
|
||||
- **认证**:需要后台 Bearer Token
|
||||
- **路径参数**:`iccid`(卡ICCID)
|
||||
- **请求体**:无
|
||||
- **响应体**:无(仅返回统一成功响应)
|
||||
|
||||
---
|
||||
|
||||
#### Z10 资产钱包概况
|
||||
|
||||
```
|
||||
GET /api/admin/assets/:asset_type/:id/wallet
|
||||
```
|
||||
|
||||
- **认证**:需要后台 Bearer Token
|
||||
- **路径参数**:同 Z2
|
||||
|
||||
**响应体(`AssetWalletResponse`):**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `wallet_id` | uint | 钱包数据库ID |
|
||||
| `resource_type` | string | 资源类型:`iot_card` 或 `device` |
|
||||
| `resource_id` | uint | 对应卡或设备的数据库ID |
|
||||
| `balance` | int64 | 总余额(分) |
|
||||
| `frozen_balance` | int64 | 冻结余额(分) |
|
||||
| `available_balance` | int64 | 可用余额 = `balance - frozen_balance`(分) |
|
||||
| `currency` | string | 币种,目前固定 CNY |
|
||||
| `status` | int | 钱包状态:1-正常 2-冻结 3-关闭 |
|
||||
| `status_text` | string | 状态文本 |
|
||||
| `created_at` | string | 创建时间(RFC3339) |
|
||||
| `updated_at` | string | 更新时间(RFC3339) |
|
||||
|
||||
---
|
||||
|
||||
#### Z11 资产钱包流水列表
|
||||
|
||||
```
|
||||
GET /api/admin/assets/:asset_type/:id/wallet/transactions
|
||||
```
|
||||
|
||||
- **认证**:需要后台 Bearer Token
|
||||
|
||||
**路径参数:**
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `asset_type` | string | 是 | 资产类型:`card` 或 `device` |
|
||||
| `id` | uint | 是 | 资产ID |
|
||||
|
||||
**Query 参数:**
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `page` | int | 否 | 页码,默认1 |
|
||||
| `page_size` | int | 否 | 每页数量,默认20,最大100 |
|
||||
| `transaction_type` | string | 否 | 交易类型过滤:`recharge`/`deduct`/`refund` |
|
||||
| `start_time` | string | 否 | 开始时间(RFC3339) |
|
||||
| `end_time` | string | 否 | 结束时间(RFC3339) |
|
||||
|
||||
**响应体(`AssetWalletTransactionListResponse`):**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `list` | array | 流水列表(每项字段见下) |
|
||||
| `total` | int64 | 总记录数 |
|
||||
| `page` | int | 当前页码 |
|
||||
| `page_size` | int | 每页数量 |
|
||||
| `total_pages` | int | 总页数 |
|
||||
|
||||
**`list` 子项(`AssetWalletTransactionItem`):**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | uint | 流水记录ID |
|
||||
| `transaction_type` | string | 交易类型:`recharge`/`deduct`/`refund` |
|
||||
| `transaction_type_text` | string | 交易类型文本:充值/扣款/退款 |
|
||||
| `amount` | int64 | 变动金额(分),充值为正数,扣款/退款为负数 |
|
||||
| `balance_before` | int64 | 变动前余额(分) |
|
||||
| `balance_after` | int64 | 变动后余额(分) |
|
||||
| `reference_type` | string | 关联业务类型:`recharge` 或 `order`(可空) |
|
||||
| `reference_no` | string | 关联业务编号:充值单号(CRCH…)或订单号(ORD…)(可空) |
|
||||
| `remark` | string | 备注(可空) |
|
||||
| `created_at` | string | 流水创建时间(RFC3339) |
|
||||
|
||||
---
|
||||
|
||||
## 五、新增的 C 端接口
|
||||
|
||||
所有接口位于 `/api/c/v1/` 下,**认证接口(`/auth/`)无需登录,其余全部需要 JWT 认证**(`Authorization: Bearer <token>`)。
|
||||
|
||||
191
docs/第三方文档/gateway设备详情同步接口.md
Normal file
191
docs/第三方文档/gateway设备详情同步接口.md
Normal file
@@ -0,0 +1,191 @@
|
||||
# 设备信息同步查询接口
|
||||
|
||||
## 基本信息
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| 接口路径 | `POST /v1/iot/openApi/device/sync-info` |
|
||||
| 请求方式 | POST |
|
||||
| Content-Type | application/json |
|
||||
| 认证方式 | appId + sign 签名验证 |
|
||||
|
||||
## 接口说明
|
||||
|
||||
同步查询设备信息,直接返回设备完整数据。与异步接口 `/device/info` 的区别:
|
||||
|
||||
| | 异步 `/device/info` | 同步 `/device/sync-info` |
|
||||
|---|---|---|
|
||||
| 返回方式 | 立即返回 `requestId`,结果通过 `callbackUrl` 回调 | 直接返回设备信息 |
|
||||
| `callbackUrl` | 必传 | 不需要 |
|
||||
| 响应字段 | 部分字段(经 v1 映射) | 全量字段 |
|
||||
|
||||
## 请求参数
|
||||
|
||||
### 请求体
|
||||
|
||||
```json
|
||||
{
|
||||
"appId": "your_app_id",
|
||||
"sign": "签名字符串",
|
||||
"timestamp": "时间戳",
|
||||
"data": "加密后的业务数据"
|
||||
}
|
||||
```
|
||||
|
||||
### data 解密后结构
|
||||
|
||||
```json
|
||||
{
|
||||
"params": {
|
||||
"cardNo": "设备标识"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### cardNo 说明
|
||||
|
||||
| 长度 | 类型 | 示例 |
|
||||
|------|------|------|
|
||||
| > 15 位 | ICCID | `89860624590014720038` |
|
||||
| 15 位 | IMEI(设备号) | `868347081100410` |
|
||||
| 11 位 | SN(序列号) | `90001473913` |
|
||||
|
||||
## 响应参数
|
||||
|
||||
### 响应结构
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": { ... },
|
||||
"trace_id": "请求追踪ID"
|
||||
}
|
||||
```
|
||||
|
||||
### data 字段说明
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `device_id` | string | 设备ID(IMEI/SN) |
|
||||
| `device_name` | string | 设备名称 |
|
||||
| `imei` | string | IMEI号 |
|
||||
| `imsi` | string | IMSI用户标识码 |
|
||||
| `current_iccid` | string | 当前使用的ICCID |
|
||||
| `device_type` | string | 设备类型:`1`=诺行,`2`=玺龙,`3`=迎势达 |
|
||||
| `software_version` | string | 软件版本号 |
|
||||
| `mac_address` | string | MAC地址 |
|
||||
| `ip_address` | string | IP地址 |
|
||||
| `ssid` | string | WiFi热点名称 |
|
||||
| `wifi_password` | string | WiFi密码 |
|
||||
| `wifi_enabled` | bool | WiFi开关状态 |
|
||||
| `max_clients` | int | 最大连接客户端数 |
|
||||
| `limit_speed` | int | 限速速率(KB/s) |
|
||||
| `sync_interval` | int | 信息上报周期(秒) |
|
||||
| `switch_mode` | string | 切卡模式:`0`=自动,`1`=手动 |
|
||||
| `ul_stats` | string | 本次开机上传流量(字节) |
|
||||
| `dl_stats` | string | 本次开机下载流量(字节) |
|
||||
| `daily_usage` | string | 日使用流量(字节) |
|
||||
| `rsrp` | int | 参考信号接收功率(dBm) |
|
||||
| `rsrq` | int | 参考信号接收质量(dB) |
|
||||
| `sinr` | int | 信噪比(dB) |
|
||||
| `rssi` | string | 接收信号强度 |
|
||||
| `lan_ip` | string | 局域网网关IP地址 |
|
||||
| `wan_ip` | string | 基站分配IPv4地址 |
|
||||
| `run_time` | string | 设备本次开机运行时间(秒) |
|
||||
| `connect_time` | string | 设备本次联网时间(秒) |
|
||||
| `battery_level` | int | 电池电量百分比 |
|
||||
| `online_status` | int | 在线状态:`1`=在线,`2`=离线 |
|
||||
| `status` | int | 设备状态:`1`=正常,`0`=禁用 |
|
||||
| `last_update_time` | string | 设备信息最后更新时间(ISO 8601) |
|
||||
| `last_online_time` | string | 设备最后在线时间(ISO 8601) |
|
||||
| `created_at` | int | 创建时间(Unix时间戳) |
|
||||
| `updated_at` | int | 更新时间(Unix时间戳) |
|
||||
|
||||
> 所有字段均可能为 `null`,表示该字段暂无数据。
|
||||
|
||||
## 响应示例
|
||||
|
||||
### 成功响应
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"device_id": "90001473913",
|
||||
"device_name": "868347081100410",
|
||||
"imei": "868347081100410",
|
||||
"imsi": null,
|
||||
"current_iccid": "89860865192590302917",
|
||||
"device_type": "3",
|
||||
"software_version": "TZ103W_CN1_YSDPTZ12_TH_P42U28_U_17_PRO_20260121",
|
||||
"mac_address": "00904D8486DE",
|
||||
"ip_address": null,
|
||||
"ssid": "ZJ-8486DE",
|
||||
"wifi_password": null,
|
||||
"wifi_enabled": true,
|
||||
"max_clients": null,
|
||||
"limit_speed": 0,
|
||||
"sync_interval": 600,
|
||||
"switch_mode": "0",
|
||||
"ul_stats": null,
|
||||
"dl_stats": null,
|
||||
"daily_usage": "150980",
|
||||
"rsrp": null,
|
||||
"rsrq": null,
|
||||
"sinr": null,
|
||||
"rssi": "-72",
|
||||
"lan_ip": null,
|
||||
"wan_ip": null,
|
||||
"run_time": "2099",
|
||||
"connect_time": null,
|
||||
"battery_level": null,
|
||||
"online_status": 2,
|
||||
"status": 1,
|
||||
"last_update_time": "2026-03-23T16:53:18+08:00",
|
||||
"last_online_time": "2026-03-23T16:53:18+08:00",
|
||||
"created_at": null,
|
||||
"updated_at": null
|
||||
},
|
||||
"trace_id": "trace_xxx"
|
||||
}
|
||||
```
|
||||
|
||||
### 错误响应
|
||||
|
||||
**cardNo 为空:**
|
||||
```json
|
||||
{
|
||||
"code": 401,
|
||||
"msg": "cardNo nil",
|
||||
"data": null
|
||||
}
|
||||
```
|
||||
|
||||
**设备不存在:**
|
||||
```json
|
||||
{
|
||||
"code": 401,
|
||||
"msg": "获取设备信息错误,无效设备号",
|
||||
"data": null
|
||||
}
|
||||
```
|
||||
|
||||
**服务调用失败:**
|
||||
```json
|
||||
{
|
||||
"code": 500,
|
||||
"msg": "服务调用失败",
|
||||
"data": null
|
||||
}
|
||||
```
|
||||
|
||||
## 数据来源说明
|
||||
|
||||
接口数据按以下优先级获取:
|
||||
|
||||
1. **Redis 缓存**(设备最近一次上报的实时数据,TTL 10 分钟)
|
||||
2. **数据库**(缓存过期后 fallback,字段可能不完整)
|
||||
|
||||
设备在线时优先返回缓存中的实时上报数据;设备离线后缓存过期,返回数据库中最后一次同步的数据。部分字段(如 `wifi_password`、`battery_level` 等)在离线 fallback 场景下可能为 `null`。
|
||||
@@ -58,7 +58,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
|
||||
ShopRole: admin.NewShopRoleHandler(svc.Shop),
|
||||
AdminAuth: admin.NewAuthHandler(svc.Auth, validate),
|
||||
ShopCommission: admin.NewShopCommissionHandler(svc.ShopCommission),
|
||||
CommissionWithdrawal: admin.NewCommissionWithdrawalHandler(svc.CommissionWithdrawal),
|
||||
CommissionWithdrawal: admin.NewCommissionWithdrawalHandler(svc.CommissionWithdrawal, validate),
|
||||
CommissionWithdrawalSetting: admin.NewCommissionWithdrawalSettingHandler(svc.CommissionWithdrawalSetting),
|
||||
Enterprise: admin.NewEnterpriseHandler(svc.Enterprise),
|
||||
EnterpriseCard: admin.NewEnterpriseCardHandler(svc.EnterpriseCard),
|
||||
|
||||
@@ -159,7 +159,7 @@ func initServices(s *stores, deps *Dependencies) *services {
|
||||
DeviceImport: deviceImportSvc.New(deps.DB, s.DeviceImportTask, deps.QueueClient),
|
||||
AssetAllocationRecord: assetAllocationRecordSvc.New(deps.DB, s.AssetAllocationRecord, s.Shop, s.Account),
|
||||
Carrier: carrierSvc.New(s.Carrier),
|
||||
PackageSeries: packageSeriesSvc.New(s.PackageSeries, s.ShopSeriesAllocation),
|
||||
PackageSeries: packageSeriesSvc.New(s.PackageSeries, s.ShopSeriesAllocation, s.Package),
|
||||
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),
|
||||
|
||||
@@ -38,11 +38,6 @@ func NewAssetHandler(
|
||||
// Resolve 通过任意标识符解析资产(设备或卡)
|
||||
// GET /api/admin/assets/resolve/:identifier
|
||||
func (h *AssetHandler) Resolve(c *fiber.Ctx) error {
|
||||
userType := middleware.GetUserTypeFromContext(c.UserContext())
|
||||
if userType == constants.UserTypeEnterprise {
|
||||
return errors.New(errors.CodeForbidden, "企业账号暂不支持此接口")
|
||||
}
|
||||
|
||||
identifier := c.Params("identifier")
|
||||
if identifier == "" {
|
||||
return errors.New(errors.CodeInvalidParam, "标识符不能为空")
|
||||
@@ -76,6 +71,11 @@ func (h *AssetHandler) RealtimeStatus(c *fiber.Ctx) error {
|
||||
// Refresh 刷新资产状态(调网关同步)
|
||||
// POST /api/admin/assets/:asset_type/:id/refresh
|
||||
func (h *AssetHandler) Refresh(c *fiber.Ctx) error {
|
||||
// 企业账号只读,不允许主动触发运营商刷新
|
||||
userType := middleware.GetUserTypeFromContext(c.UserContext())
|
||||
if userType == constants.UserTypeEnterprise {
|
||||
return errors.New(errors.CodeForbidden, "企业账号无权主动刷新资产状态")
|
||||
}
|
||||
assetType := c.Params("asset_type")
|
||||
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||
if err != nil {
|
||||
@@ -90,7 +90,7 @@ func (h *AssetHandler) Refresh(c *fiber.Ctx) error {
|
||||
return response.Success(c, result)
|
||||
}
|
||||
|
||||
// Packages 获取资产所有套餐列表
|
||||
// Packages 获取资产所有套餐列表(支持分页,默认 page=1 pageSize=50)
|
||||
// GET /api/admin/assets/:asset_type/:id/packages
|
||||
func (h *AssetHandler) Packages(c *fiber.Ctx) error {
|
||||
assetType := c.Params("asset_type")
|
||||
@@ -99,7 +99,10 @@ func (h *AssetHandler) Packages(c *fiber.Ctx) error {
|
||||
return errors.New(errors.CodeInvalidParam, "无效的资产ID")
|
||||
}
|
||||
|
||||
result, err := h.assetService.GetPackages(c.UserContext(), assetType, uint(id))
|
||||
page := c.QueryInt("page", 1)
|
||||
pageSize := c.QueryInt("page_size", 50)
|
||||
|
||||
result, err := h.assetService.GetPackages(c.UserContext(), assetType, uint(id), page, pageSize)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package admin
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
@@ -11,12 +12,15 @@ import (
|
||||
"github.com/break/junhong_cmp_fiber/pkg/response"
|
||||
)
|
||||
|
||||
// CommissionWithdrawalHandler 提现申请管理处理器
|
||||
type CommissionWithdrawalHandler struct {
|
||||
service *commissionWithdrawalService.Service
|
||||
service *commissionWithdrawalService.Service
|
||||
validator *validator.Validate
|
||||
}
|
||||
|
||||
func NewCommissionWithdrawalHandler(service *commissionWithdrawalService.Service) *CommissionWithdrawalHandler {
|
||||
return &CommissionWithdrawalHandler{service: service}
|
||||
// NewCommissionWithdrawalHandler 创建提现申请管理处理器
|
||||
func NewCommissionWithdrawalHandler(service *commissionWithdrawalService.Service, validator *validator.Validate) *CommissionWithdrawalHandler {
|
||||
return &CommissionWithdrawalHandler{service: service, validator: validator}
|
||||
}
|
||||
|
||||
func (h *CommissionWithdrawalHandler) ListWithdrawalRequests(c *fiber.Ctx) error {
|
||||
@@ -62,6 +66,9 @@ func (h *CommissionWithdrawalHandler) RejectWithdrawal(c *fiber.Ctx) error {
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||
}
|
||||
if err := h.validator.Struct(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
|
||||
result, err := h.service.Reject(c.UserContext(), uint(id), &req)
|
||||
if err != nil {
|
||||
|
||||
@@ -210,11 +210,11 @@ func (h *ClientAssetHandler) GetAvailablePackages(c *fiber.Ctx) error {
|
||||
return errors.New(errors.CodeNoAvailablePackage, "当前无可购买套餐")
|
||||
}
|
||||
|
||||
allUsages, err := h.assetService.GetPackages(resolved.SkipPermissionCtx, resolved.Asset.AssetType, resolved.Asset.AssetID)
|
||||
packagesResult, err := h.assetService.GetPackages(resolved.SkipPermissionCtx, resolved.Asset.AssetType, resolved.Asset.AssetID, 1, 50)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resolved.MainPackageActived = hasActiveMainPackage(allUsages)
|
||||
resolved.MainPackageActived = hasActiveMainPackage(packagesResult.Items)
|
||||
|
||||
listCtx := resolved.SkipPermissionCtx
|
||||
if resolved.IsAgentChannel {
|
||||
|
||||
@@ -97,6 +97,14 @@ type AssetPackageResponse struct {
|
||||
CreatedAt time.Time `json:"created_at" description:"创建时间"`
|
||||
}
|
||||
|
||||
// AssetPackagesResult 套餐列表分页结果
|
||||
type AssetPackagesResult struct {
|
||||
Total int64 `json:"total" description:"总条数"`
|
||||
Page int `json:"page" description:"当前页码"`
|
||||
PageSize int `json:"page_size" description:"每页条数"`
|
||||
Items []*AssetPackageResponse `json:"items" description:"套餐列表"`
|
||||
}
|
||||
|
||||
// AssetResolveRequest 资产解析请求(路径参数)
|
||||
type AssetResolveRequest struct {
|
||||
Identifier string `path:"identifier" description:"资产标识符(虚拟号/ICCID/IMEI/SN/MSISDN)" required:"true"`
|
||||
|
||||
@@ -2,37 +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:"成本价(分)"`
|
||||
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:立即激活)"`
|
||||
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:不重置)"`
|
||||
ExpiryBase string `json:"expiry_base" validate:"omitempty,oneof=from_activation from_purchase" description:"到期时间基准 (from_activation:实名激活时起算, from_purchase:购买时起算)" example:"from_activation"`
|
||||
}
|
||||
|
||||
// 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:"成本价(分)"`
|
||||
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:立即激活)"`
|
||||
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:不重置)"`
|
||||
ExpiryBase *string `json:"expiry_base" validate:"omitempty,oneof=from_activation from_purchase" description:"到期时间基准 (from_activation:实名激活时起算, from_purchase:购买时起算)"`
|
||||
}
|
||||
|
||||
// PackageListRequest 套餐列表请求
|
||||
@@ -70,32 +70,32 @@ 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:"是否启用虚流量"`
|
||||
VirtualRatio float64 `json:"virtual_ratio" description:"虚流量比例(real_data_mb/virtual_data_mb),启用虚流量时计算,否则为1.0"`
|
||||
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:"更新时间"`
|
||||
RetailPrice *int64 `json:"retail_price,omitempty" 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:立即激活)"`
|
||||
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:"是否启用虚流量"`
|
||||
VirtualRatio float64 `json:"virtual_ratio" description:"虚流量比例(real_data_mb/virtual_data_mb),启用虚流量时计算,否则为1.0"`
|
||||
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:"更新时间"`
|
||||
RetailPrice *int64 `json:"retail_price,omitempty" 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:不重置)"`
|
||||
ExpiryBase string `json:"expiry_base" description:"到期时间基准 (from_activation:实名激活时起算, from_purchase:购买时起算)"`
|
||||
}
|
||||
|
||||
// UpdatePackageParams 更新套餐聚合参数
|
||||
|
||||
@@ -29,24 +29,25 @@ 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"`
|
||||
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"`
|
||||
VirtualRatio float64 `gorm:"column:virtual_ratio;type:decimal(18,6);default:1.0;comment:虚流量比例(real_data_mb/virtual_data_mb),创建套餐时计算存储" json:"virtual_ratio"`
|
||||
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"`
|
||||
// ExpiryBase 到期时间基准:from_activation-实名激活时起算,from_purchase-购买时起算
|
||||
ExpiryBase string `gorm:"column:expiry_base;type:varchar(30);not null;default:'from_activation';comment:到期时间基准 from_activation-实名激活时起算 from_purchase-购买时起算" json:"expiry_base"`
|
||||
VirtualRatio float64 `gorm:"column:virtual_ratio;type:decimal(18,6);default:1.0;comment:虚流量比例(real_data_mb/virtual_data_mb),创建套餐时计算存储" json:"virtual_ratio"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
|
||||
@@ -243,11 +243,13 @@ func (s *Scheduler) processSchedule(ctx context.Context) {
|
||||
s.processManualQueue(ctx, constants.TaskTypePollingRealname)
|
||||
s.processManualQueue(ctx, constants.TaskTypePollingCarddata)
|
||||
s.processManualQueue(ctx, constants.TaskTypePollingPackage)
|
||||
s.processManualQueue(ctx, constants.TaskTypePollingProtect)
|
||||
|
||||
// 2. 处理定时队列
|
||||
s.processTimedQueue(ctx, constants.RedisPollingQueueRealnameKey(), constants.TaskTypePollingRealname, now)
|
||||
s.processTimedQueue(ctx, constants.RedisPollingQueueCarddataKey(), constants.TaskTypePollingCarddata, now)
|
||||
s.processTimedQueue(ctx, constants.RedisPollingQueuePackageKey(), constants.TaskTypePollingPackage, now)
|
||||
s.processTimedQueue(ctx, constants.RedisPollingQueueProtectKey(), constants.TaskTypePollingProtect, now)
|
||||
|
||||
// 任务 19.6: 套餐激活检查(每次调度都执行,内部会限流)
|
||||
if s.packageActivationHandler != nil {
|
||||
|
||||
@@ -343,8 +343,20 @@ func (s *Service) Refresh(ctx context.Context, assetType string, id uint) (*dto.
|
||||
}
|
||||
}
|
||||
|
||||
// GetPackages 获取资产的所有套餐列表
|
||||
func (s *Service) GetPackages(ctx context.Context, assetType string, id uint) ([]*dto.AssetPackageResponse, error) {
|
||||
// GetPackages 获取资产的所有套餐列表(支持分页)
|
||||
// page 默认 1,pageSize 默认 50,pageSize 最大 100
|
||||
func (s *Service) GetPackages(ctx context.Context, assetType string, id uint, page, pageSize int) (*dto.AssetPackagesResult, error) {
|
||||
// 分页参数边界处理
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 {
|
||||
pageSize = 50
|
||||
}
|
||||
if pageSize > 100 {
|
||||
pageSize = 100
|
||||
}
|
||||
|
||||
// assetType 对应 Store 中的 carrierType:card→iot_card, device→device
|
||||
carrierType := assetType
|
||||
if assetType == "card" {
|
||||
@@ -371,7 +383,7 @@ func (s *Service) GetPackages(ctx context.Context, assetType string, id uint) ([
|
||||
pkgMap[p.ID] = p
|
||||
}
|
||||
|
||||
result := make([]*dto.AssetPackageResponse, 0, len(usages))
|
||||
all := make([]*dto.AssetPackageResponse, 0, len(usages))
|
||||
for _, u := range usages {
|
||||
pkg := pkgMap[u.PackageID]
|
||||
ratio := 1.0
|
||||
@@ -403,15 +415,31 @@ func (s *Service) GetPackages(ctx context.Context, assetType string, id uint) ([
|
||||
Priority: u.Priority,
|
||||
CreatedAt: u.CreatedAt,
|
||||
}
|
||||
result = append(result, item)
|
||||
all = append(all, item)
|
||||
}
|
||||
|
||||
// 按 created_at DESC 排序
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
return result[i].CreatedAt.After(result[j].CreatedAt)
|
||||
sort.Slice(all, func(i, j int) bool {
|
||||
return all[i].CreatedAt.After(all[j].CreatedAt)
|
||||
})
|
||||
|
||||
return result, nil
|
||||
total := int64(len(all))
|
||||
offset := (page - 1) * pageSize
|
||||
end := offset + pageSize
|
||||
if offset >= len(all) {
|
||||
offset = len(all)
|
||||
}
|
||||
if end > len(all) {
|
||||
end = len(all)
|
||||
}
|
||||
items := all[offset:end]
|
||||
|
||||
return &dto.AssetPackagesResult{
|
||||
Total: total,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
Items: items,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetCurrentPackage 获取资产当前生效的主套餐
|
||||
|
||||
@@ -112,7 +112,8 @@ func (s *Service) CreateOrder(ctx context.Context, customerID uint, req *dto.Cli
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if packagesNeedRealname(validationResult.Packages) && assetInfo.RealNameStatus != constants.RealNameStatusVerified {
|
||||
// REALNAME-03: 按卡类型判断实名需求,普通卡需要实名,行业卡不需要
|
||||
if assetInfo.CardCategory == "normal" && assetInfo.RealNameStatus != constants.RealNameStatusVerified {
|
||||
return nil, errors.New(errors.CodeNeedRealname)
|
||||
}
|
||||
|
||||
@@ -616,15 +617,6 @@ func resolveSellerShopID(result *purchase_validation.PurchaseValidationResult) u
|
||||
return 0
|
||||
}
|
||||
|
||||
func packagesNeedRealname(packages []*model.Package) bool {
|
||||
for _, pkg := range packages {
|
||||
if pkg != nil && pkg.EnableRealnameActivation {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func extractPackageIDs(packages []*model.Package) []uint {
|
||||
ids := make([]uint, 0, len(packages))
|
||||
for _, pkg := range packages {
|
||||
|
||||
@@ -206,6 +206,8 @@ func (s *Service) Approve(ctx context.Context, id uint, req *dto.ApproveWithdraw
|
||||
"processed_at": now,
|
||||
"payment_type": req.PaymentType,
|
||||
"remark": req.Remark,
|
||||
"approved_by": currentUserID,
|
||||
"approved_at": now,
|
||||
}
|
||||
|
||||
if req.Amount != nil {
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
@@ -47,6 +48,12 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateWithdrawalSettingRe
|
||||
setting.Updater = currentUserID
|
||||
|
||||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
// 先锁定当前活跃记录,防止并发激活导致多条 is_active=true
|
||||
var current model.CommissionWithdrawalSetting
|
||||
tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("is_active = ?", true).
|
||||
First(¤t)
|
||||
|
||||
if err := s.commissionWithdrawalSettingStore.DeactivateCurrentWithTx(ctx, tx); err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "失效旧配置失败")
|
||||
}
|
||||
|
||||
@@ -176,9 +176,7 @@ func (s *StopResumeService) hasAvailablePackage(ctx context.Context, cardID uint
|
||||
// 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
|
||||
return errors.New(errors.CodeInternalError, "Gateway 未配置,停复机操作不可用")
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
@@ -209,9 +207,7 @@ func (s *StopResumeService) stopCardWithRetry(ctx context.Context, card *model.I
|
||||
// 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
|
||||
return errors.New(errors.CodeInternalError, "Gateway 未配置,停复机操作不可用")
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
|
||||
@@ -1869,11 +1869,34 @@ func (s *Service) activateMainPackage(ctx context.Context, tx *gorm.DB, order *m
|
||||
nextResetAt = packagepkg.CalculateNextResetTime(pkg.DataResetCycle, pkg.CalendarType, now, activatedAt)
|
||||
}
|
||||
|
||||
// 任务 8.9: 后台囤货场景
|
||||
if pkg.EnableRealnameActivation {
|
||||
// 需要实名后才能激活
|
||||
status = constants.PackageUsageStatusPending
|
||||
pendingRealnameActivation = true
|
||||
// REALNAME-02: 后台囤货场景三维决策(卡类型 + 购买路径 + expiry_base)
|
||||
// 注意:仅在 else 分支(立即激活)时才需要判断是否切换为等实名
|
||||
if !hasActiveMain {
|
||||
// 查询卡类型(行业卡永远直接激活,不等实名)
|
||||
var cardCategory string
|
||||
if carrierType == "iot_card" {
|
||||
var card model.IotCard
|
||||
if err := tx.Select("card_category").First(&card, carrierID).Error; err == nil {
|
||||
cardCategory = card.CardCategory
|
||||
}
|
||||
}
|
||||
|
||||
// 判断是否 C 端购买(C 端购买前已做实名前置检查,直接激活)
|
||||
isCEndPurchase := order.BuyerType == model.BuyerTypePersonal
|
||||
|
||||
if cardCategory != "industry" && !isCEndPurchase {
|
||||
// 后台囤货路径:按 expiry_base 决定是否等实名
|
||||
if pkg.ExpiryBase != "from_purchase" {
|
||||
// from_activation(默认):等实名后激活
|
||||
status = constants.PackageUsageStatusPending
|
||||
pendingRealnameActivation = true
|
||||
activatedAt = time.Time{}
|
||||
expiresAt = time.Time{}
|
||||
nextResetAt = nil
|
||||
}
|
||||
// from_purchase:立即激活,status 已为 Active,保持不变
|
||||
}
|
||||
// 行业卡或 C 端购买:直接激活,status 已为 Active,保持不变
|
||||
}
|
||||
|
||||
// 创建套餐使用记录
|
||||
|
||||
@@ -113,8 +113,14 @@ func (s *ActivationService) ActivateByRealname(ctx context.Context, carrierType
|
||||
}
|
||||
}
|
||||
|
||||
// 激活套餐
|
||||
activatedAt := now
|
||||
// REALNAME-04: 按 ExpiryBase 选择激活计时基准
|
||||
// from_purchase:购买时起算,用 PackageUsage 创建时间;否则用实名触发当前时刻
|
||||
var activatedAt time.Time
|
||||
if pkg.ExpiryBase == "from_purchase" {
|
||||
activatedAt = usage.CreatedAt
|
||||
} else {
|
||||
activatedAt = now
|
||||
}
|
||||
expiresAt := CalculateExpiryTime(pkg.CalendarType, activatedAt, pkg.DurationMonths, pkg.DurationDays)
|
||||
|
||||
// 计算下次重置时间
|
||||
|
||||
@@ -123,10 +123,10 @@ func (s *Service) Create(ctx context.Context, req *dto.CreatePackageRequest) (*d
|
||||
// 默认月重置
|
||||
pkg.DataResetCycle = constants.PackageDataResetMonthly
|
||||
}
|
||||
if req.EnableRealnameActivation != nil {
|
||||
pkg.EnableRealnameActivation = *req.EnableRealnameActivation
|
||||
if req.ExpiryBase != "" {
|
||||
pkg.ExpiryBase = req.ExpiryBase
|
||||
} else {
|
||||
pkg.EnableRealnameActivation = true
|
||||
pkg.ExpiryBase = "from_activation"
|
||||
}
|
||||
pkg.VirtualRatio = calculateVirtualRatio(pkg.EnableVirtualData, pkg.RealDataMB, pkg.VirtualDataMB)
|
||||
pkg.Creator = currentUserID
|
||||
@@ -225,8 +225,8 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdatePackageReq
|
||||
if req.DataResetCycle != nil {
|
||||
pkg.DataResetCycle = *req.DataResetCycle
|
||||
}
|
||||
if req.EnableRealnameActivation != nil {
|
||||
pkg.EnableRealnameActivation = *req.EnableRealnameActivation
|
||||
if req.ExpiryBase != nil {
|
||||
pkg.ExpiryBase = *req.ExpiryBase
|
||||
}
|
||||
|
||||
// 校验套餐周期类型和时长配置
|
||||
@@ -541,26 +541,26 @@ func (s *Service) toResponse(ctx context.Context, pkg *model.Package) *dto.Packa
|
||||
}
|
||||
|
||||
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,
|
||||
VirtualRatio: calculateVirtualRatio(pkg.EnableVirtualData, pkg.RealDataMB, pkg.VirtualDataMB),
|
||||
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),
|
||||
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,
|
||||
VirtualRatio: calculateVirtualRatio(pkg.EnableVirtualData, pkg.RealDataMB, pkg.VirtualDataMB),
|
||||
CostPrice: pkg.CostPrice,
|
||||
SuggestedRetailPrice: pkg.SuggestedRetailPrice,
|
||||
CalendarType: pkg.CalendarType,
|
||||
DurationDays: durationDays,
|
||||
DataResetCycle: pkg.DataResetCycle,
|
||||
ExpiryBase: pkg.ExpiryBase,
|
||||
Status: pkg.Status,
|
||||
ShelfStatus: pkg.ShelfStatus,
|
||||
CreatedAt: pkg.CreatedAt.Format(time.RFC3339),
|
||||
UpdatedAt: pkg.UpdatedAt.Format(time.RFC3339),
|
||||
}
|
||||
|
||||
userType := middleware.GetUserTypeFromContext(ctx)
|
||||
@@ -606,26 +606,26 @@ func (s *Service) toResponseWithAllocation(_ context.Context, pkg *model.Package
|
||||
}
|
||||
|
||||
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,
|
||||
VirtualRatio: calculateVirtualRatio(pkg.EnableVirtualData, pkg.RealDataMB, pkg.VirtualDataMB),
|
||||
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),
|
||||
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,
|
||||
VirtualRatio: calculateVirtualRatio(pkg.EnableVirtualData, pkg.RealDataMB, pkg.VirtualDataMB),
|
||||
CostPrice: pkg.CostPrice,
|
||||
SuggestedRetailPrice: pkg.SuggestedRetailPrice,
|
||||
CalendarType: pkg.CalendarType,
|
||||
DurationDays: durationDays,
|
||||
DataResetCycle: pkg.DataResetCycle,
|
||||
ExpiryBase: pkg.ExpiryBase,
|
||||
Status: pkg.Status,
|
||||
ShelfStatus: pkg.ShelfStatus,
|
||||
CreatedAt: pkg.CreatedAt.Format(time.RFC3339),
|
||||
UpdatedAt: pkg.UpdatedAt.Format(time.RFC3339),
|
||||
}
|
||||
|
||||
if allocationMap != nil {
|
||||
|
||||
@@ -2,6 +2,7 @@ package package_series
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -15,15 +16,19 @@ import (
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
)
|
||||
|
||||
// Service 套餐系列业务服务
|
||||
type Service struct {
|
||||
packageSeriesStore *postgres.PackageSeriesStore
|
||||
shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore
|
||||
packageStore *postgres.PackageStore
|
||||
}
|
||||
|
||||
func New(packageSeriesStore *postgres.PackageSeriesStore, shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore) *Service {
|
||||
// New 创建套餐系列服务实例
|
||||
func New(packageSeriesStore *postgres.PackageSeriesStore, shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore, packageStore *postgres.PackageStore) *Service {
|
||||
return &Service{
|
||||
packageSeriesStore: packageSeriesStore,
|
||||
shopSeriesAllocationStore: shopSeriesAllocationStore,
|
||||
packageStore: packageStore,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,6 +144,14 @@ func (s *Service) Delete(ctx context.Context, id uint) error {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "获取套餐系列失败")
|
||||
}
|
||||
|
||||
count, err := s.packageStore.CountBySeriesID(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return errors.New(errors.CodeInvalidParam, fmt.Sprintf("该系列下有 %d 个关联套餐,请先处理后再删除", count))
|
||||
}
|
||||
|
||||
if err := s.packageSeriesStore.Delete(ctx, id); err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "删除套餐系列失败")
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"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/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
)
|
||||
|
||||
@@ -20,12 +21,7 @@ func NewPackageStore(db *gorm.DB) *PackageStore {
|
||||
}
|
||||
|
||||
func (s *PackageStore) Create(ctx context.Context, pkg *model.Package) error {
|
||||
// GORM 对零值字段有特殊处理,先创建然后立即更新 enable_realname_activation 字段确保正确设置
|
||||
if err := s.db.WithContext(ctx).Omit("enable_realname_activation").Create(pkg).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// 明确更新 enable_realname_activation 字段(包括零值 false)
|
||||
return s.db.WithContext(ctx).Model(pkg).Update("enable_realname_activation", pkg.EnableRealnameActivation).Error
|
||||
return s.db.WithContext(ctx).Create(pkg).Error
|
||||
}
|
||||
|
||||
func (s *PackageStore) GetByID(ctx context.Context, id uint) (*model.Package, error) {
|
||||
@@ -140,3 +136,15 @@ func (s *PackageStore) GetByIDsUnscoped(ctx context.Context, ids []uint) ([]*mod
|
||||
}
|
||||
return packages, nil
|
||||
}
|
||||
|
||||
// CountBySeriesID 统计指定系列下的活跃套餐数量(未软删除)
|
||||
func (s *PackageStore) CountBySeriesID(ctx context.Context, seriesID uint) (int64, error) {
|
||||
var count int64
|
||||
err := s.db.WithContext(ctx).Model(&model.Package{}).
|
||||
Where("series_id = ? AND deleted_at IS NULL", seriesID).
|
||||
Count(&count).Error
|
||||
if err != nil {
|
||||
return 0, errors.Wrap(errors.CodeDatabaseError, err, "统计关联套餐失败")
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/break/junhong_cmp_fiber/internal/store"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// WechatConfigStore 微信参数配置数据访问层
|
||||
@@ -100,6 +101,12 @@ func (s *WechatConfigStore) GetActive(ctx context.Context) (*model.WechatConfig,
|
||||
|
||||
// ActivateInTx 在事务内激活指定配置(先停用所有,再激活指定记录)
|
||||
func (s *WechatConfigStore) ActivateInTx(ctx context.Context, tx *gorm.DB, id uint) error {
|
||||
// 先锁定当前活跃记录,防止并发激活导致多条 is_active=true
|
||||
var current model.WechatConfig
|
||||
tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("is_active = ?", true).
|
||||
First(¤t)
|
||||
|
||||
// 先停用所有激活的配置
|
||||
if err := tx.WithContext(ctx).Model(&model.WechatConfig{}).
|
||||
Where("is_active = ?", true).
|
||||
|
||||
@@ -430,10 +430,8 @@ func (h *AutoPurchaseHandler) activateMainPackage(
|
||||
nextResetAt = packagepkg.CalculateNextResetTime(pkg.DataResetCycle, pkg.CalendarType, now, activatedAt)
|
||||
}
|
||||
|
||||
if pkg.EnableRealnameActivation {
|
||||
status = constants.PackageUsageStatusPending
|
||||
pendingRealnameActivation = true
|
||||
}
|
||||
// REALNAME-02: 自动购包属于 C 端充值触发,不需要等实名(C 端已做前置实名检查)
|
||||
// ExpiryBase 仅影响后台囤货路径,此处无需判断
|
||||
|
||||
usage := &model.PackageUsage{
|
||||
BaseModel: model.BaseModel{
|
||||
|
||||
7
migrations/000089_realname_expiry_base.down.sql
Normal file
7
migrations/000089_realname_expiry_base.down.sql
Normal file
@@ -0,0 +1,7 @@
|
||||
-- 回滚:恢复旧字段,移除新字段
|
||||
ALTER TABLE tb_package DROP COLUMN IF EXISTS expiry_base;
|
||||
|
||||
ALTER TABLE tb_package
|
||||
ADD COLUMN IF NOT EXISTS enable_realname_activation BOOLEAN NOT NULL DEFAULT TRUE;
|
||||
|
||||
COMMENT ON COLUMN tb_package.enable_realname_activation IS '是否启用实名激活 true-需实名后激活 false-立即激活';
|
||||
13
migrations/000089_realname_expiry_base.up.sql
Normal file
13
migrations/000089_realname_expiry_base.up.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
-- REALNAME-01: 实名激活架构重构
|
||||
-- 移除旧的布尔字段 enable_realname_activation,
|
||||
-- 新增 expiry_base 枚举字段控制套餐计时起点
|
||||
-- 注意:不做旧字段到新字段的数据迁移,现有记录全部默认 from_activation
|
||||
|
||||
-- 移除旧字段
|
||||
ALTER TABLE tb_package DROP COLUMN IF EXISTS enable_realname_activation;
|
||||
|
||||
-- 新增到期时间基准字段
|
||||
ALTER TABLE tb_package
|
||||
ADD COLUMN IF NOT EXISTS expiry_base VARCHAR(30) NOT NULL DEFAULT 'from_activation';
|
||||
|
||||
COMMENT ON COLUMN tb_package.expiry_base IS '到期时间基准 from_activation-实名激活时起算 from_purchase-购买时起算';
|
||||
@@ -26,7 +26,7 @@
|
||||
"command": [
|
||||
"npx",
|
||||
"-y",
|
||||
"@bytebase/dbhub@latest",
|
||||
"@bytebase/dbhub@0.19.1",
|
||||
"--transport", "stdio",
|
||||
"--config", ".config/dbhub.toml"
|
||||
]
|
||||
|
||||
@@ -25,7 +25,7 @@ func BuildDocHandlers() *bootstrap.Handlers {
|
||||
Shop: admin.NewShopHandler(nil),
|
||||
ShopRole: admin.NewShopRoleHandler(nil),
|
||||
ShopCommission: admin.NewShopCommissionHandler(nil),
|
||||
CommissionWithdrawal: admin.NewCommissionWithdrawalHandler(nil),
|
||||
CommissionWithdrawal: admin.NewCommissionWithdrawalHandler(nil, nil),
|
||||
CommissionWithdrawalSetting: admin.NewCommissionWithdrawalSettingHandler(nil),
|
||||
Enterprise: admin.NewEnterpriseHandler(nil),
|
||||
EnterpriseCard: admin.NewEnterpriseCardHandler(nil),
|
||||
|
||||
Reference in New Issue
Block a user