diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md
index 1b6a0e8..82b20f1 100644
--- a/.planning/REQUIREMENTS.md
+++ b/.planning/REQUIREMENTS.md
@@ -21,14 +21,14 @@
### 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)
@@ -40,10 +40,10 @@
### 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 |
+| 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 | Pending |
-| POLL-02 | Phase 2 | Pending |
-| POLL-03 | Phase 2 | Pending |
-| POLL-04 | Phase 2 | Pending |
+| 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 |
diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md
index 106ff2c..455848f 100644
--- a/.planning/ROADMAP.md
+++ b/.planning/ROADMAP.md
@@ -56,7 +56,7 @@ Plans:
**Plans**: 2 plans
Plans:
-- [ ] 02-01-PLAN.md — PERM-01/02 + FIN-01/02/03 + POLL-01/02/03/04:权限/财务/轮询小修合并(Wave 1)
+- [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)
---
diff --git a/.planning/STATE.md b/.planning/STATE.md
index 744e0db..abe4dc7 100644
--- a/.planning/STATE.md
+++ b/.planning/STATE.md
@@ -3,12 +3,12 @@ gsd_state_version: 1.0
milestone: v1.0
milestone_name: milestone
status: unknown
-last_updated: "2026-03-28T02:41:33.151Z"
+last_updated: "2026-03-28T02:45:27.917Z"
progress:
total_phases: 6
- completed_phases: 1
+ completed_phases: 2
total_plans: 7
- completed_plans: 6
+ completed_plans: 7
---
# Project State — 君鸿卡管系统
@@ -77,6 +77,7 @@ Progress: [ 1 ]──[ 2 ]──[ 3 ]──[ 4 ]──[ 5 ]──[ 6 ]
| Phase 01-p0 P02 | 3min | 1 tasks | 2 files |
| Phase 01-p0 P03 | 8min | 2 tasks | 4 files |
| Phase 02-perm-fin-realname-poll P02 | 25min | 2 tasks | 8 files |
+| Phase 02-perm-fin-realname-poll P01 | 10min | 2 tasks | 14 files |
## Accumulated Context
diff --git a/.planning/codebase/ARCHITECTURE.md b/.planning/codebase/ARCHITECTURE.md
new file mode 100644
index 0000000..7f8f2ca
--- /dev/null
+++ b/.planning/codebase/ARCHITECTURE.md
@@ -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*
diff --git a/.planning/codebase/CONCERNS.md b/.planning/codebase/CONCERNS.md
new file mode 100644
index 0000000..d23cc6a
--- /dev/null
+++ b/.planning/codebase/CONCERNS.md
@@ -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*
diff --git a/.planning/codebase/CONVENTIONS.md b/.planning/codebase/CONVENTIONS.md
new file mode 100644
index 0000000..d175a61
--- /dev/null
+++ b/.planning/codebase/CONVENTIONS.md
@@ -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*
diff --git a/.planning/codebase/INTEGRATIONS.md b/.planning/codebase/INTEGRATIONS.md
new file mode 100644
index 0000000..3f4e74a
--- /dev/null
+++ b/.planning/codebase/INTEGRATIONS.md
@@ -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*
diff --git a/.planning/codebase/STACK.md b/.planning/codebase/STACK.md
new file mode 100644
index 0000000..2262c8c
--- /dev/null
+++ b/.planning/codebase/STACK.md
@@ -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*
diff --git a/.planning/codebase/STRUCTURE.md b/.planning/codebase/STRUCTURE.md
new file mode 100644
index 0000000..a0583af
--- /dev/null
+++ b/.planning/codebase/STRUCTURE.md
@@ -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*
diff --git a/.planning/codebase/TESTING.md b/.planning/codebase/TESTING.md
new file mode 100644
index 0000000..4901d4c
--- /dev/null
+++ b/.planning/codebase/TESTING.md
@@ -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*
diff --git a/.planning/config.json b/.planning/config.json
new file mode 100644
index 0000000..1bf18e8
--- /dev/null
+++ b/.planning/config.json
@@ -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"
+}
\ No newline at end of file
diff --git a/.planning/phases/02-perm-fin-realname-poll/02-01-SUMMARY.md b/.planning/phases/02-perm-fin-realname-poll/02-01-SUMMARY.md
new file mode 100644
index 0000000..774c439
--- /dev/null
+++ b/.planning/phases/02-perm-fin-realname-poll/02-01-SUMMARY.md
@@ -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 中查到。
diff --git a/.sisyphus/plans/修正业务-完整方案.md b/.sisyphus/plans/修正业务-完整方案.md
new file mode 100644
index 0000000..f7886ec
--- /dev/null
+++ b/.sisyphus/plans/修正业务-完整方案.md
@@ -0,0 +1,2130 @@
+# 修正业务 · 完整修复方案 v5(完整规格书)
+
+> **写给另一个 AI 看的规格书**。本文档包含每一个修复的完整上下文:
+> - **业务背景**:为什么存在这个功能、为什么会出这个问题
+> - **代码现状**:问题在哪个文件哪一行,现在的代码是什么
+> - **修复方式**:精确到文件+行号的改法,含完整代码片段
+>
+> 技术栈:Go + Fiber v2 + GORM + Asynq + PostgreSQL + Redis
+> 架构:Handler → Service → Store → Model 严格分层
+> 仓库根目录:`/Users/break/csxjProject/junhong_cmp_fiber`
+
+---
+
+## 系统概览
+
+这是一个物联网卡(IoT SIM Card)+ 设备全生命周期管理平台,支持多级代理商体系和分佣结算。
+
+**核心实体**:
+- **IoT 卡**:物联网 SIM 卡,绑定运营商,有实名状态、网络状态、套餐使用情况
+- **设备**:物联网设备(GPS、传感器等),可绑定 1-4 张 IoT 卡
+- **套餐**:用于 IoT 卡的流量套餐,有时长、流量额度、计费模式
+- **代理商(Shop)**:多级分销体系(最多 7 级),有佣金钱包、成本价体系
+- **个人客户(PersonalCustomer)**:C 端用户,有资产钱包(跟着卡/设备走,转手后钱包一起转)
+- **企业账号**:B 端大客户,被分配卡/设备,可管控但不能购买套餐
+
+**关键路径**:
+```
+代理/平台 → 采购 IoT 卡 + 设备 → 分配给下级代理/企业 → C端客户购买套餐 → 充值/激活/使用
+ ↓
+ 佣金链:逐级向上分差额(成本价之差)
+```
+
+## 全文统一验收约定(适用于所有方案)
+
+> 本文档**不要求新增自动化测试代码**,默认采用:`rg/grep`、`go build ./...`、DBHub、Worker/API 日志四类手段做人工验收。
+
+**统一工具**:
+- **代码结构核对**:`rg` / `grep`
+- **编译验收**:`go build ./...`
+- **表结构 / 数据核对**:DBHub(`search_objects` + `execute_sql`)
+- **异步链路核对**:Worker / API 日志关键字
+
+**统一通过标准**:
+1. 相关代码路径存在且引用关系正确
+2. 涉及 schema 的任务,DBHub 能查到新增字段 / 新表 / 新索引
+3. 涉及业务数据流转的任务,DBHub 能查到状态变化或新增记录
+4. 涉及异步任务的任务,日志能看到"入队 / 消费 / 成功 / 失败"关键节点
+5. 最后执行一次 `go build ./...`,必须能通过
+
+---
+
+## 方案 A:紧急 Bug 修复(P0 全部 + P2-2 前置)
+
+> 这 7 个 P0 是**生产直接跑不通的 Bug**,优先级最高。
+> A-1 是其他所有 A 系修复的前置条件,必须第一个做。
+
+---
+
+### A-1 · 实名状态常量统一(P2-2,前置)
+
+**业务背景**
+
+IoT 卡需要实名认证(普通卡)。系统通过网关(Gateway)轮询获取卡的实名状态,结果写入 `iot_card.real_name_status` 字段。购买需实名套餐时检查此字段。
+
+**问题**
+
+三处代码对同一个字段的含义定义不一致,造成整条链路断裂:
+
+| 位置 | 文件 | 定义 |
+|------|------|------|
+| 常量定义 | `pkg/constants/iot.go:47-48` | `RealNameStatusVerified = 1` |
+| 轮询实际写入 | `internal/task/polling_handler.go:696-700` | `true → return 2` |
+| Model 注释 | `internal/model/iot_card.go:30` | `0-未实名 1-已实名` |
+
+结果:已实名的卡(DB 里值=2)用常量(=1)校验,逻辑全错。
+
+**正确定义**(以常量为准):`0 = 未实名,1 = 已实名`
+
+**修改清单**
+
+**① `internal/task/polling_handler.go:696-700`**(轮询写入值,改为 1)
+
+```go
+// 修改前
+func (h *PollingHandler) parseRealnameStatus(realStatus bool) int {
+ if realStatus {
+ return 2 // 已实名
+ }
+ return 0 // 未实名
+}
+
+// 修改后
+func (h *PollingHandler) parseRealnameStatus(realStatus bool) int {
+ if realStatus {
+ return constants.RealNameStatusVerified // = 1
+ }
+ return constants.RealNameStatusNotVerified // = 0
+}
+```
+
+**② `internal/task/polling_handler.go:170`**(isFirstRealname 判断,改用常量)
+
+```go
+// 修改前
+isFirstRealname := (card.RealNameStatus == 0 || card.RealNameStatus == 1) && newRealnameStatus == 2
+
+// 修改后
+isFirstRealname := card.RealNameStatus != constants.RealNameStatusVerified &&
+ newRealnameStatus == constants.RealNameStatusVerified
+```
+
+**③ `internal/model/iot_card.go:30`**(Model 注释)
+
+```go
+// 修改前
+RealNameStatus int `gorm:"...; comment:实名状态 0-未实名 1-已实名(行业卡可以保持0)"`
+
+// 修改后(注释统一)
+RealNameStatus int `gorm:"...; comment:实名状态 0-未实名 1-已实名"`
+```
+
+**④ 全局搜索并修正** DTO 注释(不影响运行,但影响文档):
+
+```bash
+grep -rn "real_name_status\|RealNameStatus" internal/model/dto/ --include="*.go"
+```
+
+凡是注释写 `2=已实名` 或 `1实名中` 的,统一改为 `0=未实名, 1=已实名`。
+
+---
+
+### A-2 · C端实名校验修复(P0-1)
+
+**业务背景**
+
+C 端客户购买"需实名套餐"前,系统检查卡的实名状态。普通卡(`card_category = "normal"`)必须已实名才能购买。
+
+**问题**
+
+`internal/service/client_order/service.go:115`(依赖 A-1 修完后自动正确):
+
+```go
+// 当前错误(硬编码 1,但 DB 里实际是 2,A-1 修完前暂时改这里)
+if packagesNeedRealname(validationResult.Packages) && assetInfo.RealNameStatus != 1 {
+ return errors.New(errors.CodeForbidden, "请先完成实名认证")
+}
+```
+
+**修改方式**
+
+```go
+// 修改后(使用常量,A-1 先修好后此行自动正确)
+if packagesNeedRealname(validationResult.Packages) && assetInfo.RealNameStatus != constants.RealNameStatusVerified {
+ return errors.New(errors.CodeForbidden, "请先完成实名认证")
+}
+```
+
+同时检查 `internal/task/polling_handler.go:982`:
+```go
+if card.RealNameStatus != constants.RealNameStatusVerified {
+```
+A-1 修完后此行也自动正确,无需单独改。
+
+---
+
+### A-3 · 实名激活任务断链修复(P0-2)
+
+**业务背景**
+
+后台代理/平台可以"囤货"——预购套餐时套餐不立即激活(`PackageUsage.status = pending, pending_realname_activation = true`),等客户完成实名认证后自动激活。这是"先备货,客户实名后开始计费"的业务模式。
+
+完整链路:
+```
+1. 后台囤货购买 → PackageUsage(status=0=pending, pending_realname_activation=true)
+2. 轮询 polling:realname 定期调 Gateway 检测卡实名状态
+3. 检测到首次实名(card.RealNameStatus 从 0 变为 1)
+4. triggerFirstRealnameActivation(cardID) 查找该卡所有 pending 套餐
+5. 对每个套餐 Enqueue Asynq 任务 → 消费者激活套餐(设定激活时间、到期时间)
+```
+
+**问题**
+
+步骤 5 没有接入 Asynq。`PollingHandler` 没有注入 `asynq.Client`,开发者临时用 `RPush` 到 Redis List 降级,然后用 `_ = task` 把正确的 Asynq task 对象扔掉。
+
+关键代码 `internal/task/polling_handler.go:1100-1115`(当前状态):
+
+```go
+task := asynq.NewTask(constants.TaskTypePackageFirstActivation, payloadBytes, ...)
+
+// 这里需要访问 Asynq Client,暂时使用 Redis 队列
+activationKey := constants.RedisPollingManualQueueKey(constants.TaskTypePackageFirstActivation)
+h.redis.RPush(ctx, activationKey, string(payloadBytes))
+
+_ = task // ← task 被扔掉!
+```
+
+而 `pkg/queue/handler.go:Handler` 结构体里已经有 `asynqClient *asynq.Client`,只需传进去。
+
+**修改清单**
+
+**① `internal/task/polling_handler.go`:PollingHandler struct 新增字段**
+
+```go
+// PollingHandler struct(第 31-42 行附近)
+type PollingHandler struct {
+ db *gorm.DB
+ redis *redis.Client
+ asynqClient *asynq.Client // 新增此行
+ gatewayClient *gateway.Client
+ // ... 其余字段不变
+}
+```
+
+**② `internal/task/polling_handler.go:NewPollingHandler`(第 44 行)**
+
+```go
+// 修改前
+func NewPollingHandler(
+ db *gorm.DB,
+ redis *redis.Client,
+ gatewayClient *gateway.Client,
+ usageService *packagepkg.UsageService,
+ logger *zap.Logger,
+) *PollingHandler {
+ return &PollingHandler{
+ db: db,
+ redis: redis,
+ gatewayClient: gatewayClient,
+ // ...
+
+// 修改后(新增 asynqClient 参数)
+func NewPollingHandler(
+ db *gorm.DB,
+ redis *redis.Client,
+ asynqClient *asynq.Client, // 新增
+ gatewayClient *gateway.Client,
+ usageService *packagepkg.UsageService,
+ logger *zap.Logger,
+) *PollingHandler {
+ return &PollingHandler{
+ db: db,
+ redis: redis,
+ asynqClient: asynqClient, // 新增
+ gatewayClient: gatewayClient,
+ // ...
+```
+
+**③ `pkg/queue/handler.go:registerPollingHandlers`(第 151 行)**
+
+```go
+// 修改前
+pollingHandler := task.NewPollingHandler(
+ h.db,
+ h.redis,
+ h.gatewayClient,
+ h.workerResult.Services.UsageService,
+ h.logger,
+)
+
+// 修改后(传入 asynqClient)
+pollingHandler := task.NewPollingHandler(
+ h.db,
+ h.redis,
+ h.asynqClient, // 新增,Handler 已有此字段
+ h.gatewayClient,
+ h.workerResult.Services.UsageService,
+ h.logger,
+)
+```
+
+**④ `internal/task/polling_handler.go:triggerFirstRealnameActivation`(第 1100 行附近)**
+
+```go
+// 修改前(RPush 降级 + _ = task)
+task := asynq.NewTask(constants.TaskTypePackageFirstActivation, payloadBytes,
+ asynq.MaxRetry(3),
+ asynq.Timeout(30*time.Second),
+ asynq.Queue(constants.QueueDefault),
+)
+activationKey := constants.RedisPollingManualQueueKey(constants.TaskTypePackageFirstActivation)
+if err := h.redis.RPush(ctx, activationKey, string(payloadBytes)).Err(); err != nil {
+ // ...
+}
+_ = task // 删掉
+
+// 修改后(直接 Enqueue)
+task := asynq.NewTask(constants.TaskTypePackageFirstActivation, payloadBytes,
+ asynq.MaxRetry(3),
+ asynq.Timeout(30*time.Second),
+ asynq.Queue(constants.QueueDefault),
+)
+if _, err := h.asynqClient.EnqueueContext(ctx, task); err != nil {
+ h.logger.Warn("提交首次实名激活任务失败",
+ zap.Uint("package_usage_id", pkg.ID),
+ zap.Error(err))
+ continue
+}
+```
+
+---
+
+### A-4 · 自动购包触发 + 佣金补全(P0-3 + P0-4)
+
+**业务背景**
+
+C 端客户"强充"(ForceRecharge)时,可以同时指定想自动购买的套餐 IDs(存在 `AssetRechargeRecord.LinkedPackageIDs` JSONB 字段里)。充值成功到账后,系统应自动从资产钱包扣款购买这些套餐。这是"充值即购包"一步操作,省去客户手动购买。
+
+**两个断点**
+
+- **P0-3**:`recharge/service.go:HandlePaymentCallback` 充值回调成功后,完全没有触发自动购包的代码。`task.NewAutoPurchaseTask` 函数全项目只有定义,**零调用点**。
+- **P0-4**:`AutoPurchaseHandler.ProcessTask()` 创建订单并激活套餐后,没有调用 `enqueueCommissionCalculation()`,自动购包产生的订单不计佣金。
+
+**涉及的关键事实**
+
+- `AssetRechargeRecord.LinkedPackageIDs` 字段:`internal/model/asset_wallet.go:91`(JSONB,存放 package IDs 数组)
+- `recharge/service.go` 当前没有 `queueClient` 依赖,需要新增
+- `pkg/queue/Client` 有 `EnqueueTask(ctx, taskType, payload, opts...)` 方法(`pkg/queue/client.go:38`),适合 **Service 层** 调用
+- `pkg/queue/handler.go:Handler` 当前持有的是 `asynqClient *asynq.Client`,适合 **Worker 任务处理器** 在消费完成后继续提交下游任务
+- `TaskTypeAutoPurchaseAfterRecharge = "task:auto_purchase_after_recharge"` 常量已存在(`pkg/constants/constants.go:68`),`task.NewAutoPurchaseTask()` 也已使用该常量,无需新增第二个自动购包任务类型
+- `AutoPurchaseHandler` 目前没有在 `pkg/queue/handler.go` 里注册,需要新增
+
+**调用链路(修正版)**
+
+```text
+充值支付回调成功
+ │
+ ▼
+recharge.Service.HandlePaymentCallback()
+ │ Service 层:使用 *queue.Client 提交任务
+ ▼
+TaskTypeAutoPurchaseAfterRecharge
+ │
+ ▼
+Asynq Worker → registerAutoPurchaseHandler()
+ │
+ ▼
+AutoPurchaseHandler.ProcessTask()
+ │ 事务内:扣钱包 → 创建订单 → 激活套餐 → 更新 auto_purchase_status=success
+ │ 事务提交成功后:使用 *asynq.Client 提交下游任务
+ ▼
+TaskTypeCommission
+ │
+ ▼
+CommissionCalculationHandler
+```
+
+**一句话结论**
+
+- **Service 层(充值回调)**:用 `*queue.Client`,复用已有 `TaskTypeAutoPurchaseAfterRecharge`
+- **Worker 层(AutoPurchaseHandler)**:用 `*asynq.Client`,在事务提交成功后再投递 `TaskTypeCommission`
+- **禁止做法**:新增第二套自动购包常量、在同一段代码里混用 `queueClient` / `asynqClient`、在事务未提交前先投递佣金任务
+
+**修改清单(P0-3)**
+
+**P0-3 目标**:充值支付回调成功后,自动把 `RechargeRecordID` 投递到自动购包任务队列。
+
+**① `internal/service/recharge/service.go`:Service struct 新增 queueClient**
+
+```go
+type Service struct {
+ db *gorm.DB
+ // ... 其余字段 ...
+ queueClient *queue.Client // 新增
+}
+
+func New(
+ db *gorm.DB,
+ // ... 其余参数 ...
+ queueClient *queue.Client, // 新增
+) *Service {
+ return &Service{
+ // ... 其余字段 ...
+ queueClient: queueClient,
+ }
+}
+```
+
+**② `internal/service/recharge/service.go:HandlePaymentCallback`(第 272 行附近,事务成功后)**
+
+在 `if err != nil { return err }` 之后,`s.logger.Info("充值支付回调处理成功"...)` 之前,添加:
+
+```go
+// 充值成功后,如有关联套餐则触发自动购包(强充绑套餐功能)
+linkedIDs := recharge.LinkedPackageIDs
+if len(linkedIDs) > 0 && string(linkedIDs) != "[]" && string(linkedIDs) != "null" {
+ if s.queueClient != nil {
+ taskPayload := task.AutoPurchasePayload{RechargeRecordID: recharge.ID}
+ if err := s.queueClient.EnqueueTask(ctx, constants.TaskTypeAutoPurchaseAfterRecharge, taskPayload,
+ asynq.Queue(constants.QueueDefault),
+ asynq.MaxRetry(3),
+ ); err != nil {
+ s.logger.Error("自动购包任务入队失败",
+ zap.Uint("recharge_id", recharge.ID),
+ zap.Error(err))
+ // 不影响充值成功,仅记录日志
+ }
+ }
+}
+```
+
+**③ `pkg/constants/constants.go`:复用现有任务类型常量,不新增第二套名字**
+
+```go
+// 已存在,直接复用
+TaskTypeAutoPurchaseAfterRecharge = "task:auto_purchase_after_recharge" // 充值后自动购包
+```
+
+不要新增 `TaskTypeAutoPurchase = "auto_purchase"` 这类新常量,否则会出现:
+- producer 用新常量入队
+- `task.NewAutoPurchaseTask()` 和 worker handler 仍使用旧常量
+
+最终导致生产者 / 消费者任务类型对不上。
+
+**④ `pkg/queue/handler.go`:新增 registerAutoPurchaseHandler()**
+
+在 `RegisterHandlers()` 中添加 `h.registerAutoPurchaseHandler()` 调用,并新增方法。
+
+同时,`internal/task/auto_purchase.go` 中的 `AutoPurchaseHandler struct` 和 `NewAutoPurchaseHandler(...)` 需要新增 `asynqClient *asynq.Client` 字段 / 参数,供自动购包完成后继续提交佣金任务:
+
+```go
+func (h *Handler) registerAutoPurchaseHandler() {
+ autoPurchaseHandler := task.NewAutoPurchaseHandler(
+ h.db,
+ nil, // orderStore:让 handler 内部初始化
+ nil, // rechargeRecordStore
+ nil, // walletStore
+ nil, // walletTransactionStore
+ nil, // packageUsageStore
+ h.redis,
+ h.asynqClient, // 新增:传入裸 asynq client,供 Worker 内继续投递下游任务
+ h.logger,
+ )
+ h.mux.HandleFunc(constants.TaskTypeAutoPurchaseAfterRecharge, autoPurchaseHandler.ProcessTask)
+ h.logger.Info("注册自动购包任务处理器", zap.String("task_type", constants.TaskTypeAutoPurchaseAfterRecharge))
+}
+```
+
+**⑤ `internal/bootstrap/` 中找到 recharge service 的初始化,传入 queueClient**
+
+在 `internal/bootstrap/services.go` 或 `worker_services.go` 中找到 `recharge.New(...)` 的调用位置,补充 `queueClient` 参数传入。
+
+**修改清单(P0-4)**
+
+**P0-4 目标**:AutoPurchaseHandler 在订单创建 + 套餐激活成功后,把 `order_id` 投递到佣金计算队列,确保自动购包订单与普通订单一样进入佣金链路。
+
+**`internal/task/auto_purchase.go`:ProcessTask 事务提交成功后触发佣金**
+
+这里要明确分层:
+- `recharge.Service` 属于 Service 层,用 `*queue.Client`
+- `AutoPurchaseHandler` 属于 Worker 消费者,直接持有并使用 `*asynq.Client`
+
+不要在同一段代码里写成 `if h.asynqClient != nil { h.queueClient.EnqueueTask(...) }`,这会把两套依赖混在一起。
+
+建议写法:在 `ProcessTask()` 里先声明 `var createdOrderID uint`,事务内创建订单后赋值,事务成功返回后再入队佣金任务:
+
+```go
+var createdOrderID uint
+
+err = h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
+ // ... 扣钱包、创建订单、创建流水 ...
+ if err = tx.Create(order).Error; err != nil {
+ return err
+ }
+ createdOrderID = order.ID
+
+ if err = h.activatePackages(ctx, tx, order, packages, now); err != nil {
+ return err
+ }
+
+ if err = tx.Model(&model.AssetRechargeRecord{}).
+ Where("id = ?", rechargeRecord.ID).
+ Update("auto_purchase_status", constants.AutoPurchaseStatusSuccess).Error; err != nil {
+ return err
+ }
+
+ return nil
+})
+if err != nil {
+ return err
+}
+
+// 事务提交成功后,触发佣金计算
+if h.asynqClient != nil && createdOrderID > 0 {
+ payloadBytes, err := sonic.Marshal(map[string]any{"order_id": createdOrderID})
+ if err != nil {
+ h.logger.Warn("佣金任务载荷序列化失败",
+ zap.Uint("order_id", createdOrderID),
+ zap.Error(err))
+ } else {
+ commissionTask := asynq.NewTask(constants.TaskTypeCommission, payloadBytes,
+ asynq.Queue(constants.QueueLow),
+ asynq.MaxRetry(3),
+ )
+ if _, err := h.asynqClient.EnqueueContext(ctx, commissionTask); err != nil {
+ h.logger.Warn("自动购包后提交佣金任务失败",
+ zap.Uint("order_id", createdOrderID),
+ zap.Error(err))
+ }
+ }
+}
+```
+
+注意:`AutoPurchaseHandler` 这里新增的是 `asynqClient *asynq.Client`,不是 `queueClient *queue.Client`。
+
+**常见误区(必须避免)**
+
+1. **不要新增第二套自动购包常量**
+ - 错误:`TaskTypeAutoPurchase = "auto_purchase"`
+ - 正确:复用 `TaskTypeAutoPurchaseAfterRecharge`
+
+2. **不要在 Worker 里再注入 `*queue.Client`**
+ - 当前 `pkg/queue/handler.go:Handler` 已经持有 `asynqClient *asynq.Client`
+ - 对 Worker 来说,直接 `EnqueueContext()` 更直接,也与现有 `PackageActivationHandler` 注入方式一致
+
+3. **不要在事务内先投递佣金任务**
+ - 如果事务后续回滚,就会出现:佣金任务已提交,但订单 / 套餐激活其实未落库成功
+ - 所以必须先 `Transaction(...)` 成功返回,再提交 `TaskTypeCommission`
+
+4. **不要漏掉 handler 注册**
+ - `internal/task/auto_purchase.go` 里虽然已有 `AutoPurchaseHandler`
+ - 但如果 `pkg/queue/handler.go:RegisterHandlers()` 不调用 `h.registerAutoPurchaseHandler()`,任务仍然不会被消费
+
+5. **Service 层调用 `queueClient.EnqueueTask(...)` 时,传的是对象,不是预先 Marshal 后的 `[]byte`**
+ - `queue.Client` 内部会自行 `Marshal`
+ - 这里应直接传 `task.AutoPurchasePayload{RechargeRecordID: recharge.ID}`
+
+**验收要点(不打接口,只看日志 + DBHub)**
+
+> 不要求额外写测试代码。按下面方式人工校验即可。
+
+**日志关键点**
+
+- 支付回调成功后,应看到自动购包任务入队成功相关日志(至少能看到 Asynq 客户端的任务提交日志,`task_type=task:auto_purchase_after_recharge`)
+- Worker 消费成功后,应看到:`自动购包任务执行成功`
+- 如佣金任务入队失败,应看到:`自动购包后提交佣金任务失败`
+
+**DBHub 核对顺序**
+
+1. 查 `tb_asset_recharge_record`
+ - 目标记录的 `auto_purchase_status` 应从 `pending` 变成 `success`
+
+2. 查 `tb_order` / `tb_order_item`
+ - 应新增一笔由自动购包生成的订单
+ - `payment_method=wallet`
+ - `payment_status=paid`
+ - `buyer_type=personal`
+
+3. 查 `tb_package_usage`
+ - 应存在该订单对应的套餐使用记录
+ - 正式套餐 / 加油包状态应与现有激活规则一致
+
+4. 查 `tb_commission_record`
+ - 在 worker 正常消费佣金任务后,应出现该 `order_id` 的佣金记录
+ - 如果没有记录,再回看 worker 日志是否出现佣金任务提交失败或佣金计算失败
+
+**建议的 DBHub 查询顺序图**
+
+```text
+tb_asset_recharge_record
+ │ auto_purchase_status = success ?
+ ├─ 否 → 先查自动购包 worker 日志
+ ▼
+tb_order / tb_order_item
+ │ 自动购包订单是否已生成?
+ ├─ 否 → 看 auto_purchase handler 事务内失败点
+ ▼
+tb_package_usage
+ │ 套餐是否已激活 / 排队?
+ ├─ 否 → 看 activatePackages() 相关日志
+ ▼
+tb_commission_record
+ 佣金记录是否生成?
+ ├─ 否 → 看佣金任务入队 / 佣金计算日志
+ └─ 是 → A-4 验收通过
+```
+
+---
+
+### A-5 · 代购订单金额修正(P0-5)
+
+**业务背景:五种购买场景**
+
+系统支持以下五种套餐购买场景,每种有不同的金额和佣金逻辑:
+
+| # | 操作方 | 资产所属 | 付款来源 | 订单面值(totalAmount) | 实际扣款(actualPaidAmount) | 佣金产生 |
+|---|--------|---------|---------|----------------------|----------------------------|---------|
+| 1 | 平台 | 平台自己客户 | 线下(无钱包) | 建议售价 | N/A | 不产生(应有标记) |
+| 2 | 平台 | 某代理客户 | 该代理钱包 | 该代理成本价 | 该代理成本价 | 如有上级代理产生差额 |
+| 3 | 平台 | 某代理客户 | 线下 | 该代理成本价 | N/A | 如有上级代理产生差额 |
+| 4 | 代理 | 自己客户 | 自己钱包 | 自己成本价 | 自己成本价 | 如有上级代理产生差额 |
+| 5 | 代理 | 下级代理客户 | 自己钱包 | **下级代理成本价(面值)** | **自己(上级)成本价** | 如有更上级代理产生差额 |
+
+**梯度式佣金**:所有场景都计入梯度条件计数。一次性固定佣金只在走充值场景才产生,不走充值不产生。
+
+**Bug 位置**
+
+`internal/service/order/service.go:525-548`(场景 5 分支,代理代购下级):
+
+```go
+// 当前代码
+buyerCostPrice, _ := s.getCostPrice(ctx, *resourceShopID, firstPackageID) // 下级成本价
+operatorCostPrice, _ := s.getCostPrice(ctx, operatorShopID, firstPackageID) // 自己成本价
+
+totalAmount = buyerCostPrice // 下级成本价作为面值(正确)
+actualPaidAmount = &operatorCostPrice // 实际扣自己钱包(正确)
+sellerCostPrice = buyerCostPrice // ← ❌ 应是 operatorCostPrice
+```
+
+`sellerCostPrice` 用于佣金差额计算基准。错误地使用下级成本价,导致整个佣金链的差额计算为 0,更上级代理无法获得应有的佣金。
+
+**修改方式**
+
+```go
+sellerCostPrice = operatorCostPrice // ✅ 改为自己的成本价
+```
+
+同时检查同文件里是否有相同模式的第二处(搜索 `sellerCostPrice = buyerCostPrice`,共修复所有出现位置)。
+
+---
+
+### A-6 · 设备导入补充 IMEI 字段(P0-6)
+
+**业务背景**
+
+设备(Device)Model 有 `imei` 字段(`internal/model/device.go:19`),用于调用 Gateway API。但设备批量导入(Excel)的解析结构 `DeviceRow` 没有 IMEI,导入后 IMEI 始终为空,无法通过 IMEI 调 Gateway。
+
+**修改清单**
+
+**① `pkg/utils/excel.go:35-43`(DeviceRow struct)**
+
+```go
+// 修改前
+type DeviceRow struct {
+ Line int
+ VirtualNo string
+ DeviceName string
+ DeviceModel string
+ DeviceType string
+ MaxSimSlots int
+ Manufacturer string
+ ICCIDs []string
+}
+
+// 修改后(新增 IMEI 字段)
+type DeviceRow struct {
+ Line int
+ VirtualNo string
+ IMEI string // 新增
+ DeviceName string
+ DeviceModel string
+ DeviceType string
+ MaxSimSlots int
+ Manufacturer string
+ ICCIDs []string
+}
+```
+
+**② `pkg/utils/excel.go`(Excel 列名映射处)**
+
+找到 `ParseDeviceExcel` 中处理表头映射的代码(大约在 290-320 行),在已有的列名 map 中新增:
+
+```go
+case "imei", "IMEI", "设备IMEI", "IMEI号":
+ row.IMEI = cellValue
+```
+
+**③ `internal/task/device_import.go`(processBatch 函数,约 178 行)**
+
+在创建 `model.Device{}` 时新增 `IMEI: row.IMEI`。
+
+---
+
+### A-7 · 提现冻结并发校验(P0-7)
+
+**业务背景**
+
+代理申请提现时,先冻结佣金钱包余额(`balance -= amount, frozen_balance += amount`),再创建提现申请单。问题:两个并发请求可以同时通过 `balance >= amount` 的 WHERE 检查,第二个 UPDATE 实际 `RowsAffected=0`(余额已不够),但代码只检查 `.Error` 不检查 `RowsAffected`,导致冻结失败却继续创建了提现单。
+
+**修改位置**:`internal/service/my_commission/service.go:162-170`
+
+```go
+// 修改前(只检查 Error)
+if err := tx.WithContext(ctx).Model(&model.AgentWallet{}).
+ Where("id = ? AND balance >= ?", wallet.ID, req.Amount).
+ Updates(map[string]interface{}{
+ "balance": gorm.Expr("balance - ?", req.Amount),
+ "frozen_balance": gorm.Expr("frozen_balance + ?", req.Amount),
+ }).Error; err != nil {
+ return errors.Wrap(errors.CodeInternalError, err, "冻结余额失败")
+}
+
+// 修改后(新增 RowsAffected 校验)
+result := tx.WithContext(ctx).Model(&model.AgentWallet{}).
+ Where("id = ? AND balance >= ?", wallet.ID, req.Amount).
+ Updates(map[string]interface{}{
+ "balance": gorm.Expr("balance - ?", req.Amount),
+ "frozen_balance": gorm.Expr("frozen_balance + ?", req.Amount),
+ })
+if result.Error != nil {
+ return errors.Wrap(errors.CodeInternalError, result.Error, "冻结余额失败")
+}
+if result.RowsAffected == 0 {
+ return errors.New(errors.CodeInsufficientBalance, "余额不足或并发冲突,请稍后重试")
+}
+```
+
+### 方案 A · 人工验收清单
+
+- **A-1 实名状态常量统一**
+ - 工具:`rg` + DBHub + polling 日志
+ - 步骤:
+ 1. 搜索 `return 2`、`newRealnameStatus == 2`、`2=已实名` 是否仍残留在实名链路相关文件
+ 2. DBHub 执行:`SELECT DISTINCT real_name_status FROM tb_iot_card ORDER BY 1;`
+ 3. 观察一轮实名轮询日志,确认不再写入值 `2`
+ - 预期结果:代码 / 注释统一为 `0=未实名, 1=已实名`;DB 中实名状态最终只有 `0/1`
+
+- **A-2 C端实名校验修复**
+ - 工具:`rg` + DBHub
+ - 步骤:
+ 1. 搜索 `internal/service/client_order/service.go` 中实名判断是否改为 `constants.RealNameStatusVerified`
+ 2. DBHub 抽查普通卡记录:`SELECT id, card_category, real_name_status FROM tb_iot_card WHERE card_category='normal' LIMIT 20;`
+ - 预期结果:普通卡实名判断不再依赖硬编码;未实名普通卡会被拦截,已实名普通卡可继续下单
+
+- **A-3 实名激活任务断链修复**
+ - 工具:`rg` + Worker 日志 + DBHub
+ - 步骤:
+ 1. 搜索 `internal/task/polling_handler.go`,确认不再走 `RPush` 降级人工队列
+ 2. 观察首次实名触发日志,应出现首次实名激活任务入队 / 消费日志
+ 3. DBHub 抽查:`SELECT id, status, pending_realname_activation, activated_at FROM tb_package_usage WHERE pending_realname_activation=true ORDER BY id DESC LIMIT 20;`
+ - 预期结果:首次实名后相关套餐进入 Asynq 正常链路;待实名套餐能被激活,`activated_at` 被写入
+
+- **A-4 自动购包触发 + 佣金补全**
+ - 详见本节上方 **A-4 详细验收要点**
+ - 最低预期:`tb_asset_recharge_record.auto_purchase_status=success`、自动购包订单生成、套餐激活成功、对应 `order_id` 出现佣金记录
+
+- **A-5 代购订单金额修正**
+ - 工具:`rg` + DBHub
+ - 步骤:
+ 1. 搜索 `sellerCostPrice = buyerCostPrice`,确认场景 5 的错误赋值已消失
+ 2. DBHub 选一笔“代理代购下级代理客户”的订单及其佣金记录,核对 `total_amount`、`actual_paid_amount` 与上级佣金金额是否不再全为 0
+ - 预期结果:代购下级场景里,订单面值仍是下级成本价,但佣金链能继续向上计算,不再整链归零
+
+- **A-6 设备导入补充 IMEI 字段**
+ - 工具:`rg` + DBHub + 导入日志
+ - 步骤:
+ 1. 搜索 `DeviceRow`、`ParseDeviceExcel`、`device_import.go`,确认 IMEI 已贯通到导入流程
+ 2. 完成一次设备导入后,DBHub 执行:`SELECT id, virtual_no, imei FROM tb_device ORDER BY id DESC LIMIT 20;`
+ - 预期结果:导入后的设备 `imei` 不再长期为空;日志中无 IMEI 丢失相关告警
+
+- **A-7 提现冻结并发校验**
+ - 工具:日志 + DBHub
+ - 步骤:
+ 1. 人工制造一次重复提交提现的场景(后台重复点击或相邻两次提交)
+ 2. DBHub 查询对应钱包和提现单:
+ - `SELECT id, balance, frozen_balance, version FROM tb_agent_wallet WHERE id = ?;`
+ - `SELECT id, amount, status, created_at FROM tb_commission_withdrawal_request WHERE shop_id = ? ORDER BY id DESC LIMIT 10;`
+ 3. 观察日志是否出现“余额不足或并发冲突”
+ - 预期结果:余额冻结成功次数与提现单创建次数一致;失败的并发请求不会继续生成提现单
+
+- **方案 A 收尾检查**
+ - 工具:`go build ./...`
+ - 预期结果:编译通过,无新增类型错误或引用错误
+
+---
+
+## 方案 B:企业端权限完善(P1-1 + P2-11)
+
+**业务背景**
+
+企业账号(`user_type = 4`)通过 AdminAuth JWT 登录,复用 `/api/admin/*` 路由(无需新建中间件)。企业被分配了代理的卡/设备后,可以查看资产信息,可以对被授权的资产执行停复机,但**不能购买套餐**,**没有钱包**。
+
+**现状**
+
+`internal/handler/admin/asset.go` 有 10 个方法,企业拦截现状:
+
+| 方法 | 功能 | 当前状态 | 应有状态 |
+|------|------|---------|---------|
+| `Resolve` | 通过 ICCID/虚拟号查询资产 | ✅ 有企业拦截(**应移除**,企业应能查) | ✅ 可访问 |
+| `RealtimeStatus` | 查看实时状态 | 无拦截 | ✅ 可访问 |
+| `Packages` | 查看套餐列表 | 无拦截 | ✅ 可访问 |
+| `CurrentPackage` | 查看当前套餐 | 无拦截 | ✅ 可访问 |
+| `Refresh` | 主动触发运营商刷新 | 无拦截 | ❌ 需拦截 |
+| `StopDevice` | 设备停机 | 无拦截 | ✅ 企业有权管控 |
+| `StartDevice` | 设备复机 | 无拦截 | ✅ 企业有权管控 |
+| `StopCard` | 卡停机 | 无拦截 | ✅ 企业有权管控 |
+| `StartCard` | 卡复机 | 无拦截 | ✅ 企业有权管控 |
+| Wallet 相关 (2个) | 钱包查看 | ✅ 已拦截(企业无钱包) | ✅ 已正确拦截 |
+
+**修改清单**
+
+**① `internal/handler/admin/asset.go:Resolve`(第 40-44 行)**
+
+```go
+// 移除错误的企业拦截(企业应该能查询被授权的资产)
+// 删除以下代码:
+if userType == constants.UserTypeEnterprise {
+ return errors.New(errors.CodeForbidden, "企业账号暂不支持此接口")
+}
+```
+
+**② `internal/handler/admin/asset.go:Refresh`(第 78 行附近)**
+
+在 `Refresh` 方法开头新增企业拦截:
+
+```go
+func (h *AssetHandler) Refresh(c *fiber.Ctx) error {
+ // 企业账号不允许主动触发刷新(只读访问)
+ userType := middleware.GetUserTypeFromContext(c.UserContext())
+ if userType == constants.UserTypeEnterprise {
+ return errors.New(errors.CodeForbidden, "企业账号无权主动刷新资产状态")
+ }
+ // ... 其余代码不变
+```
+
+**③ 资源标签覆盖度(修正后的真实落点)**
+
+当前仓库**不存在** `internal/service/tag/` 目录,不要再按这个路径搜索。标签相关的真实代码入口是:
+
+- `internal/store/postgres/resource_tag_store.go:ListByResource()`
+- `internal/model/tag.go`(`Tag` / `ResourceTag` 模型定义)
+- 当前上层调用点:`internal/service/exchange/migration.go:copyResourceTagsWithTx()`
+
+如果企业资产详情 / 查询接口后续需要返回标签,优先修改 `resource_tag_store.go:ListByResource()` 这一层,确保企业账号读取资源标签时不会越权。
+
+建议搜索:
+
+```bash
+grep -rn "ResourceTagStore\|ListByResource\|enterprise_id\|shop_id" internal/store/postgres internal/service --include="*.go"
+```
+
+建议补法:
+
+```go
+query := s.db.WithContext(ctx).
+ Where("resource_type = ? AND resource_id = ?", resourceType, resourceID)
+
+query = middleware.ApplyShopFilter(ctx, query)
+
+// 注意:pkg/middleware/data_scope.go:ApplyEnterpriseFilter() 只会加 enterprise_id = ?
+// 如果企业账号还应看到全局标签(enterprise_id IS NULL),这里需要显式补 OR IS NULL 条件
+if middleware.GetUserTypeFromContext(ctx) == constants.UserTypeEnterprise {
+ enterpriseID := middleware.GetEnterpriseIDFromContext(ctx)
+ query = query.Where("enterprise_id = ? OR enterprise_id IS NULL", enterpriseID)
+} else {
+ query = middleware.ApplyEnterpriseFilter(ctx, query)
+}
+```
+
+如果当前企业资产接口根本还**没有**返回标签,这一项就作为“防未来越权回归”的检查项保留,不要求为了这次修复额外新增标签接口。
+
+### 方案 B · 人工验收清单
+
+- 工具:`rg` + DBHub + 管理端 / API 日志 + `go build ./...`
+- 步骤:
+ 1. 搜索 `internal/handler/admin/asset.go`,确认 `Resolve` 中已删除企业账号禁用逻辑,`Refresh` 中新增企业账号拦截
+ 2. 搜索 `internal/store/postgres/resource_tag_store.go` 与其上层调用点,确认资源标签读取路径已定位到真实文件,而不是不存在的 `internal/service/tag/`
+ 3. 如当前资产接口会返回标签,则检查标签读取逻辑是否包含 `enterprise_id = ? OR enterprise_id IS NULL` 一类条件;如当前不返回标签,则仅记录“当前无标签读取链路,无需额外改接口”
+ 4. DBHub 抽查一条企业资产数据 / 资源标签数据,确认企业侧仅能命中本企业可见资产 / 标签
+ 5. 查看日志关键字:企业调用 `Refresh` 时应被拒绝;调用 `Resolve / RealtimeStatus / StopCard / StartCard / StopDevice / StartDevice` 不应被通用禁止逻辑拦住
+- 预期结果:企业账号“可查看、可停复机、不可主动刷新、无钱包”,且标签查询不越权
+
+---
+
+## 方案 C:提现/佣金流程完善(P2-19 + P2-20 + P2-21 + P2-22)
+
+> P1-3(已到账确认接口)已确认不需要实现,系统无法感知到账。
+
+---
+
+### C-1 · 审批人字段补全(P2-20)
+
+**问题**:`internal/model/financial.go:27-30` 定义了 `ApprovedBy` 和 `ApprovedAt`,但 `commission_withdrawal/service.go:Approve()` 只写了 `processor_id` 和 `processed_at`,`approved_by`/`approved_at` 永远为空。
+
+**修改位置**:`internal/service/commission_withdrawal/service.go:Approve()` 中 updates map,补充:
+
+```go
+updates["approved_by"] = currentUserID
+updates["approved_at"] = now
+```
+
+---
+
+### C-2 · 提现拒绝 remark 必填校验(P2-19)
+
+**问题**:`internal/model/dto/commission_withdrawal_dto.go:61-65` 的 DTO 标注了 `validate:"required"`,但 `internal/handler/admin/commission_withdrawal.go:Reject()` 只 `BodyParser` 没有 `Validate`。
+
+**修改位置**:`commission_withdrawal.go:Reject()` 中 BodyParser 后补充:
+
+```go
+if err := validator.Validate(&req); err != nil {
+ return errors.New(errors.CodeInvalidParam)
+}
+```
+
+(参考同文件或其他 Handler 的 Validate 调用方式)
+
+---
+
+### C-3 · 激活配置并发保护(P2-21 + P2-22)
+
+**问题**:`commission_withdrawal_setting/service.go` 和 `wechat_config_store.go` 激活新配置时,是两步 UPDATE(先全部置 false,再置目标为 true),无锁保护,并发可产生两条 active=true 的记录。
+
+**修改方式**:在事务内,第一步 UPDATE 前加 `FOR UPDATE` 锁:
+
+```go
+// commission_withdrawal_setting/service.go(两处都改)
+err := s.db.Transaction(func(tx *gorm.DB) error {
+ // 先锁定当前 active 记录
+ var current model.CommissionWithdrawalSetting
+ tx.Set("gorm:query_option", "FOR UPDATE").
+ Where("is_active = ?", true).
+ First(¤t) // 不管 err,只是锁住
+
+ // 再执行原有的 deactivate → activate 逻辑
+ ...
+})
+
+// wechat_config_store.go 同理
+```
+
+### 方案 C · 人工验收清单
+
+- **C-1 审批人字段补全**
+ - 工具:DBHub + 日志
+ - 步骤:审批一笔提现后,执行:`SELECT id, processor_id, processed_at, approved_by, approved_at FROM tb_commission_withdrawal_request ORDER BY id DESC LIMIT 10;`
+ - 预期结果:`approved_by` / `approved_at` 不再为空,且与 `processor_id` / `processed_at` 对应
+
+- **C-2 拒绝提现 remark 必填**
+ - 工具:`rg` + 管理端 / API 日志
+ - 步骤:
+ 1. 搜索 `commission_withdrawal.go:Reject()`,确认存在 `validator.Validate(&req)`
+ 2. 人工提交一次空 remark 的拒绝操作
+ - 预期结果:请求被拦截,返回参数错误;提现记录状态不发生变化
+
+- **C-3 激活配置并发保护**
+ - 工具:DBHub + 日志
+ - 步骤:
+ 1. 连续切换一次佣金提现配置、一次微信支付配置
+ 2. DBHub 分别执行:
+ - `SELECT COUNT(*) FROM tb_commission_withdrawal_setting WHERE is_active=true;`
+ - `SELECT COUNT(*) FROM tb_wechat_config WHERE is_active=true;`
+ - 预期结果:两个表任意时刻都最多只有一条 `is_active=true`
+
+- **方案 C 收尾检查**
+ - 工具:`go build ./...`
+ - 预期结果:编译通过
+
+---
+
+## 方案 D:设备体系完善(P1-10 + P2-28 + P2-29 + 字段扩展)
+
+> 设备定期轮询(P1-9)已确认为伪需求,取消。改为查详情时按需实时拉取。
+> P2-30(轮询配置通用化)也随之取消。
+
+---
+
+### D-0 · 设备模型字段扩展
+
+**业务背景**
+
+Gateway `sync-info` 接口返回大量实时设备信息,当前 Device Model 几乎无对应字段。需要扩展存储"有离线意义"的字段,"只在线有意义"的字段只实时返回不存库。
+
+**Gateway 接口文档**:`docs/第三方文档/gateway设备详情同步接口.md`
+- URL:`POST /v1/iot/openApi/device/sync-info`
+- 请求:`{"params": {"cardNo": "设备标识"}}` (cardNo > 15 位用 ICCID,= 15 位用 IMEI,= 11 位用 SN)
+- 认证:`appId + sign` 签名(与现有 Gateway Client 一致)
+
+**字段策略(不用 JSONB,用独立字段)**
+
+需存 DB(有离线意义):
+
+| 字段名 | 类型 | Gateway 对应字段 | 说明 |
+|--------|------|-----------------|------|
+| `online_status` | INT DEFAULT 0 | `online_status` | 1=在线,2=离线 |
+| `last_online_time` | TIMESTAMP NULL | `last_online_time` | 最后在线时间 |
+| `software_version` | VARCHAR(100) DEFAULT '' | `software_version` | 固件版本 |
+| `switch_mode` | VARCHAR(10) DEFAULT '0' | `switch_mode` | 切卡模式:0=自动,1=手动 |
+| `last_gateway_sync_at` | TIMESTAMP NULL | — | 最后一次 sync-info 同步时间 |
+
+只实时返回(不存 DB):`rssi`, `battery_level`, `ssid`, `wifi_enabled`, `rsrp`, `sinr`, `run_time`, `connect_time` 等
+
+**修改清单**
+
+**① DB 迁移(`migrations/` 目录新建迁移文件)**
+
+```sql
+ALTER TABLE tb_device
+ ADD COLUMN online_status INT NOT NULL DEFAULT 0,
+ ADD COLUMN last_online_time TIMESTAMP NULL,
+ ADD COLUMN software_version VARCHAR(100) NOT NULL DEFAULT '',
+ ADD COLUMN switch_mode VARCHAR(10) NOT NULL DEFAULT '0',
+ ADD COLUMN last_gateway_sync_at TIMESTAMP NULL;
+```
+
+**② `internal/model/device.go`**:新增以上 5 个 GORM 字段(参考现有字段的标签格式)。
+
+**③ `internal/model/dto/device_dto.go`**:
+- `DeviceResponse`(列表/详情基础信息):新增 5 个 DB 字段
+- `DeviceRealtimeInfo`(实时详情,从 sync-info 直接返回):新增非存储字段(rssi, battery_level, ssid, wifi_enabled 等),标注 `description:"实时字段,设备在线时有效"`
+
+---
+
+### D-1 · Gateway sync-info 接口对接(P1-10,前置)
+
+**为什么现在没有**:早期只对接了旧的异步接口 `/device/info`(调用后通过 callbackUrl 异步返回)。同步接口 `sync-info` 是 Gateway 后来提供的,直接返回全量数据,未同步接入。`internal/model/dto/client_asset_dto.go:102` 有 `CurrentIccid` 字段但注释写着"当前 Gateway 同步接口尚未对接,预留结构待后续填充"。
+
+**修改清单**
+
+**① `internal/gateway/device.go`**(当前只有 `GetDeviceInfo()` 和 `GetSlotInfo()`):
+
+新增方法:
+
+```go
+// SyncDeviceInfoReq sync-info 请求
+type SyncDeviceInfoReq struct {
+ CardNo string `json:"card_no"` // ICCID / IMEI / SN
+}
+
+// SyncDeviceInfoResp sync-info 响应(对应文档 data 字段)
+type SyncDeviceInfoResp struct {
+ DeviceID string `json:"device_id"`
+ DeviceName string `json:"device_name"`
+ IMEI string `json:"imei"`
+ CurrentIccid string `json:"current_iccid"`
+ DeviceType string `json:"device_type"`
+ SoftwareVersion string `json:"software_version"`
+ MacAddress string `json:"mac_address"`
+ SSID string `json:"ssid"`
+ WifiEnabled bool `json:"wifi_enabled"`
+ SwitchMode string `json:"switch_mode"`
+ RSSI string `json:"rssi"`
+ BatteryLevel *int `json:"battery_level"`
+ OnlineStatus int `json:"online_status"` // 1=在线, 2=离线
+ LastUpdateTime string `json:"last_update_time"`
+ LastOnlineTime string `json:"last_online_time"`
+ RunTime string `json:"run_time"`
+ DailyUsage string `json:"daily_usage"`
+}
+
+// SyncDeviceInfo 同步查询设备信息(直接返回,无需回调)
+func (c *Client) SyncDeviceInfo(ctx context.Context, req *SyncDeviceInfoReq) (*SyncDeviceInfoResp, error) {
+ // 参考现有 doRequestWithResponse 的调用方式
+ return doRequestWithResponse[SyncDeviceInfoResp](c, ctx, "/device/sync-info", req)
+}
+```
+
+---
+
+### D-2 · device_sim_binding 加 is_current(P2-28)
+
+**业务背景**:设备绑定多张卡,需要知道"当前设备用哪张卡在传输数据"。现在用 `slot_position == 1` 猜测,不准。
+
+**修改清单**
+
+**① DB 迁移**:
+```sql
+ALTER TABLE tb_device_sim_binding ADD COLUMN is_current BOOLEAN NOT NULL DEFAULT FALSE;
+```
+
+**② `internal/model/device_sim_binding.go`**:新增字段:
+```go
+IsCurrent bool `gorm:"column:is_current;type:boolean;not null;default:false;comment:是否为当前使用的卡" json:"is_current"`
+```
+
+**③ 更新逻辑**(在 D-3 的 `updateDeviceFromSyncInfo` 函数中):
+- sync-info 返回 `current_iccid` → 在 `tb_device_sim_binding` 中找到对应行,设为 `is_current=true`,其余行设为 `is_current=false`
+- 使用事务,两步原子更新
+
+**④ DTO 补充**:`internal/model/dto/device_dto.go` 的 `BoundCardInfo` 中新增 `IsCurrent bool`。
+
+---
+
+### D-3 · 设备 Refresh + 详情接入 sync-info(P2-29)
+
+**当前问题**:`POST /assets/device/:id/refresh` 只遍历绑定卡调卡网关,`gateway/device.go` 的 `SyncDeviceInfo` 方法(D-1 新增的)没有被使用。
+
+**修改位置**:`internal/service/asset/service.go:RefreshDevice()` 分支(约 307-339 行)
+
+在刷新卡数据之后,追加设备自身的 sync-info 调用:
+
+```go
+// 刷新设备自身信息(在线状态、当前卡、固件版本等)
+if s.gatewayClient != nil {
+ cardNo := device.IMEI // 优先用 IMEI,无 IMEI 则用 SN
+ if cardNo == "" {
+ cardNo = device.SN
+ }
+ if cardNo != "" {
+ if syncResp, err := s.gatewayClient.SyncDeviceInfo(ctx, &gateway.SyncDeviceInfoReq{
+ CardNo: cardNo,
+ }); err == nil {
+ s.updateDeviceFromSyncInfo(ctx, device.ID, syncResp)
+ } else {
+ s.logger.Warn("sync-info 调用失败", zap.Uint("device_id", device.ID), zap.Error(err))
+ }
+ }
+}
+```
+
+新增私有函数 `updateDeviceFromSyncInfo(ctx, deviceID, syncResp)`:
+1. 更新 device 表的 5 个 DB 字段(online_status, last_online_time, software_version, switch_mode, last_gateway_sync_at)
+2. 根据 `syncResp.CurrentIccid` 更新 `device_sim_binding.is_current`(D-2 逻辑)
+
+同样,查看设备详情接口(`GetDeviceDetail` 或 `RealtimeStatus`)中,如果调用了 `SyncDeviceInfo`,将 `syncResp` 中的实时字段追加到响应中(不存 DB 的字段)。
+
+### 方案 D · 人工验收清单
+
+- **D-0 设备模型字段扩展**
+ - 工具:DBHub + `rg`
+ - 步骤:
+ 1. DBHub 搜索 `tb_device` 列,确认新增 `online_status / last_online_time / software_version / switch_mode / last_gateway_sync_at`
+ 2. 搜索 `internal/model/device.go` 和 `internal/model/dto/device_dto.go`,确认模型 / DTO 已同步
+ - 预期结果:库表、模型、DTO 三处字段一致
+
+- **D-1 sync-info 接口对接**
+ - 工具:`rg` + `go build ./...`
+ - 步骤:搜索 `SyncDeviceInfoReq`、`SyncDeviceInfoResp`、`SyncDeviceInfo(` 是否已在 `internal/gateway/device.go` 定义并编译通过
+ - 预期结果:Gateway Client 已具备同步查询设备详情的能力
+
+- **D-2 is_current 字段**
+ - 工具:DBHub
+ - 步骤:
+ 1. DBHub 搜索 `tb_device_sim_binding` 列,确认存在 `is_current`
+ 2. 抽查一台绑定多卡设备:`SELECT device_id, iot_card_id, is_current FROM tb_device_sim_binding WHERE device_id = ? ORDER BY id;`
+ - 预期结果:同一设备最多一条 `is_current=true`
+
+- **D-3 Refresh / 详情接入 sync-info**
+ - 工具:Worker / API 日志 + DBHub
+ - 步骤:
+ 1. 人工触发一次设备刷新
+ 2. 日志应出现 `sync-info` 调用成功或失败关键字
+ 3. DBHub 查询 `tb_device` 与 `tb_device_sim_binding`,确认设备状态字段和当前卡标识已更新
+ - 预期结果:设备刷新不再只刷新卡,还会刷新设备自身在线状态 / 固件版本 / 当前使用卡
+
+- **方案 D 收尾检查**
+ - 工具:`go build ./...`
+ - 预期结果:编译通过
+
+---
+
+## 方案 E:流量体系改革(P2-26 + P2-27 + P2-31)
+
+**注意**:这是架构改造,涉及 DB 迁移,建议低峰期发布。
+
+---
+
+### E-1 · 流量详单改为日粒度缓冲(P2-26)
+
+**问题**:`internal/task/polling_handler.go:268` 每次轮询无条件调用 `insertDataUsageRecord()`,即便 `data_increase_mb=0` 也写记录(函数内部计算增量=0 后仍然 Create)。大量卡每日产生无意义记录。
+
+**新设计**:
+1. 新建 `tb_card_daily_usage` 表(每张卡每天一条)
+2. 轮询时流量变化写 Redis 增量缓存(INCRBYFLOAT)
+3. 每日落盘:定时任务 SCAN 昨日 key → UPSERT 到 `tb_card_daily_usage` → 删 key
+
+**DB 迁移**:
+
+```sql
+CREATE TABLE tb_card_daily_usage (
+ id BIGSERIAL PRIMARY KEY,
+ iot_card_id BIGINT NOT NULL,
+ date DATE NOT NULL,
+ usage_mb BIGINT NOT NULL DEFAULT 0,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ CONSTRAINT uk_card_daily UNIQUE (iot_card_id, date)
+);
+CREATE INDEX idx_card_daily_date ON tb_card_daily_usage (date);
+```
+
+**Redis Key 格式**(在 `pkg/constants/redis.go` 新增):
+
+```go
+// RedisCardDailyTrafficKey 卡日流量缓存 key
+// 格式: traffic:daily:{card_id}:{YYYY-MM-DD}
+func RedisCardDailyTrafficKey(cardID uint, date string) string {
+ return fmt.Sprintf("traffic:daily:%d:%s", cardID, date)
+}
+```
+
+**`insertDataUsageRecord` 改造**(`polling_handler.go:812`):
+
+```go
+// 修改前:无条件写 DB
+// 修改后:只在有增量时更新 Redis
+func (h *PollingHandler) insertDataUsageRecord(ctx context.Context, cardID uint, currentUsageMB, previousUsageMB float64, ...) {
+ increment := currentUsageMB - previousUsageMB
+ if increment <= 0 {
+ return // 无增量,不写
+ }
+
+ today := time.Now().Format("2006-01-02")
+ key := constants.RedisCardDailyTrafficKey(cardID, today)
+ h.redis.IncrByFloat(ctx, key, increment)
+ h.redis.Expire(ctx, key, 48*time.Hour) // 保留 48h,落盘任务有时间消费
+}
+```
+
+**新增每日落盘任务**(`pkg/constants/constants.go` 新增 `TaskTypeDailyTrafficFlush`):
+- Asynq Scheduler 每天凌晨 2 点触发
+- SCAN `traffic:daily:*:{昨日}` → 批量 UPSERT `tb_card_daily_usage` → DELETE key
+
+---
+
+### E-2 · current_month_usage_mb 改增量累加(P2-27)
+
+**问题**:`calculateFlowUpdates()` 直接 `current_month_usage_mb = gatewayValue` 覆盖。上游运营商按自然月重置(联通 27 号,移动/电信/广电 1 号),重置后 gateway 返回 0,我们的值也归零,但我们的套餐周期未必与自然月一致。
+
+**运营商重置日规则**:存在 `tb_carrier.data_reset_day` 字段(方案 E-2 新增,运营建档时手动填写):
+- 中国联通:27
+- 中国移动 / 中国电信 / 广电:1
+
+**DB 迁移**:
+
+```sql
+-- 运营商表新增重置日
+ALTER TABLE tb_carrier ADD COLUMN data_reset_day INT NOT NULL DEFAULT 1;
+
+-- IoT 卡表新增上次网关读数(用于算增量)
+ALTER TABLE tb_iot_card ADD COLUMN last_gateway_reading_mb FLOAT NOT NULL DEFAULT 0;
+```
+
+**运营商管理接口**:创建/编辑运营商时新增 `data_reset_day` 字段(1-28,前端显示为"每月 N 日重置流量")。IoT 卡创建/导入时 `carrier_id` 改为必填(原来可为空)。
+
+**`calculateFlowUpdates()` 改写**(`polling_handler.go:388`):
+
+```go
+func (h *PollingHandler) calculateFlowUpdates(card *model.IotCard, gatewayFlowMB float64, now time.Time) map[string]any {
+ updates := make(map[string]any)
+
+ increment := gatewayFlowMB - card.LastGatewayReadingMB
+
+ // 检测上游重置:当前值比上次读数小
+ if increment < 0 {
+ resetDay := h.getCarrierResetDay(card.CarrierID) // 从缓存/DB 读运营商重置日
+ today := now.Day()
+ // 在重置日窗口内(重置日 ±1 天容错)才认为是正常重置
+ if isResetWindow(today, resetDay) {
+ increment = gatewayFlowMB // 上游重置,本次原始值就是增量
+ } else {
+ h.logger.Warn("流量异常:非重置日出现值下降", ...)
+ increment = 0
+ }
+ }
+
+ if increment > 0 {
+ updates["current_month_usage_mb"] = gorm.Expr("current_month_usage_mb + ?", increment)
+ updates["data_usage_mb"] = gorm.Expr("data_usage_mb + ?", int64(increment))
+ }
+ updates["last_gateway_reading_mb"] = gatewayFlowMB
+
+ // 跨自然月重置(我们系统的重置跟套餐 data_reset_cycle 走)
+ // ... 保留现有的 isCrossMonth 逻辑,但改为 +=,不再覆盖
+
+ return updates
+}
+```
+
+---
+
+### E-3 · 流量查询层适配(P2-31)
+
+**问题**:改造后数据在 Redis(今日)+ `tb_card_daily_usage`(历史),现有 `DataUsageRecordStore.ListByCardID()` 查旧表,需适配。
+
+**新建 `TrafficQueryService`**(`internal/service/traffic/query_service.go`):
+
+```go
+func (s *TrafficQueryService) GetDailyUsage(ctx context.Context, cardID uint, startDate, endDate time.Time) ([]*DailyUsageEntry, error) {
+ var result []*DailyUsageEntry
+
+ // 今天的数据 → Redis
+ today := time.Now().Format("2006-01-02")
+ if !endDate.Before(time.Now().Truncate(24*time.Hour)) {
+ key := constants.RedisCardDailyTrafficKey(cardID, today)
+ val, _ := s.redis.Get(ctx, key).Float64()
+ result = append(result, &DailyUsageEntry{Date: today, UsageMB: val})
+ }
+
+ // 历史数据 → DB
+ dbRecords, err := s.dailyUsageStore.ListByCardIDAndDateRange(ctx, cardID, startDate, endDate.AddDate(0, 0, -1))
+ if err == nil {
+ result = append(result, dbRecords...)
+ }
+
+ return sortAndMerge(result), nil
+}
+```
+
+更新 `GetRealtimeStatus`、C 端流量详情接口等,改为调用 `TrafficQueryService`。
+
+### 方案 E · 人工验收清单
+
+- **E-1 日粒度缓冲**
+ - 工具:DBHub + `rg` + Worker 日志
+ - 步骤:
+ 1. DBHub 确认存在 `tb_card_daily_usage`
+ 2. 搜索 `RedisCardDailyTrafficKey` 与 `TaskTypeDailyTrafficFlush`
+ 3. 观察日落盘任务日志,确认执行了 flush / upsert
+ - 预期结果:今日增量先缓存在 Redis,历史日流量落到 `tb_card_daily_usage`;不再无差别写 0 增量详单
+
+- **E-2 流量增量累加**
+ - 工具:DBHub + `rg` + polling 日志
+ - 步骤:
+ 1. DBHub 确认新增 `tb_carrier.data_reset_day`、`tb_iot_card.last_gateway_reading_mb`
+ 2. 搜索 `calculateFlowUpdates()`,确认 `current_month_usage_mb` 改成 `gorm.Expr("current_month_usage_mb + ?", increment)` 一类累加写法
+ 3. 观察异常下降日志是否带“非重置日出现值下降”告警
+ - 预期结果:系统不再被上游自然月清零直接覆盖;异常下降只告警不误扣
+
+- **E-3 查询层适配**
+ - 工具:`rg` + DBHub
+ - 步骤:
+ 1. 搜索 `TrafficQueryService`、`GetDailyUsage`
+ 2. 搜索 `GetRealtimeStatus` / C 端流量详情是否改为依赖新查询服务
+ 3. DBHub 抽查历史日数据是否可查
+ - 预期结果:查询层能同时兼容“今日 Redis + 历史 DB”两段数据
+
+- **方案 E 收尾检查**
+ - 工具:`go build ./...`
+ - 预期结果:编译通过
+
+---
+
+## 方案 F:代码质量清理
+
+---
+
+### F-1 · 佣金链断裂改为零额待审记录(P2-4)
+
+**业务规则**:一个代理卖出资产后,套餐系列未分配给该代理(无佣金配置),不应静默跳过,而是创建金额=0、状态=待人工修正的佣金记录,让平台管理员能发现并手动处理。
+
+**修改清单**
+
+**① `pkg/constants/` 中新增佣金状态常量**:
+
+```go
+CommissionStatusPendingReview = 99 // 待人工修正(链路断裂,需平台处理)
+```
+
+**② `internal/service/commission_calculation/service.go`** 断链处(`break` 之前):
+
+```go
+// 原来:静默 break
+// 修改后:创建零额待审记录 + 记日志
+s.logger.Warn("佣金链断裂:代理缺少套餐系列授权,创建待审记录",
+ zap.Uint("shop_id", shopID),
+ zap.Uint("series_id", seriesID),
+ zap.Uint("order_id", orderID))
+
+pendingRecord := &model.CommissionRecord{
+ OrderID: orderID,
+ ShopID: shopID,
+ SeriesID: &seriesID,
+ Amount: 0,
+ Status: constants.CommissionStatusPendingReview,
+ Remark: fmt.Sprintf("套餐系列[%d]未分配给该代理,请人工核查", seriesID),
+}
+_ = s.commissionRecordStore.Create(ctx, pendingRecord) // 失败不阻断主流程
+break
+```
+
+**③ 管理端新增筛选**:佣金记录列表接口支持按 `status=99` 筛选,让平台管理员能看到所有待人工修正的记录。
+
+---
+
+### F-2 · 废弃代码删除(P2-8)
+
+**背景**:`sim:status:sync` 任务功能已被 Refresh 接口 + 轮询系统完整覆盖,但留了一套完全无效的代码(消费者核心是 `time.Sleep`,生产者全局无调用点)。
+
+**删除清单**(顺序执行,防止编译错误):
+
+1. `pkg/queue/handler.go:62-63`:删除注册行
+ ```go
+ // 删除这两行
+ h.mux.HandleFunc(constants.TaskTypeSIMStatusSync, simHandler.HandleSIMStatusSync)
+ h.logger.Info("注册 SIM 状态同步任务处理器", ...)
+ ```
+
+2. `pkg/queue/handler.go` 的 `import` 中,检查 `simHandler` 引用,如果只有这里用,也删掉 import
+
+3. `pkg/constants/constants.go`:删除 `TaskTypeSIMStatusSync` 常量
+
+4. `internal/task/sim.go`:整个文件删除
+
+5. `internal/service/sync/service.go`:整个文件删除(检查是否有外部引用,无则直接删)
+
+6. `go build ./...` 确认无编译错误
+
+---
+
+### F-3 · 错误不应被吞(P2-14)
+
+`internal/service/asset/service.go:124` 和 `:273`:
+
+```go
+// 修改前(错误被 _ 忽略)
+cards, _ := s.iotCardStore.GetByIDs(ctx, cardIDs)
+
+// 修改后(记录日志,不中断主流程)
+cards, err := s.iotCardStore.GetByIDs(ctx, cardIDs)
+if err != nil {
+ s.logger.Warn("查询绑定卡信息失败,结果可能不完整",
+ zap.Uints("card_ids", cardIDs),
+ zap.Error(err))
+}
+```
+
+---
+
+### F-4 · C端 Handler 迁移到 Service 层(P2-15)
+
+**问题**:`internal/handler/app/client_order.go:103` 直接 `h.db.WithContext(resolved.SkipPermissionCtx)` 查数据库,绕过了 Service 层。
+
+**修改方式**:
+1. 在 `internal/service/client_order/service.go` 新增 `ListOrders(ctx, req)` 和 `GetOrderDetail(ctx, orderID)` 方法(迁移 Handler 里的 DB 查询逻辑)
+2. `client_order.go` Handler 改为调用 Service 方法,删除直接 DB 访问和 `SkipPermissionCtx`
+
+---
+
+### F-5 · Handler/Service 权限不一致(P2-16)
+
+`internal/handler/admin/order.go`(创建订单 Handler)中补充:
+
+```go
+if req.PaymentMethod == model.PaymentMethodWallet {
+ userType := middleware.GetUserTypeFromContext(c.UserContext())
+ if userType != constants.UserTypeAgent {
+ return errors.New(errors.CodeInvalidParam, "仅代理账号可使用钱包支付")
+ }
+}
+```
+
+---
+
+### F-6 · StopResumeCallback / ResumeCallback 注入(P2-7)
+
+**问题**:`SetStopResumeCallback` / `SetResumeCallback` 方法定义了但没有被 bootstrap 调用,停复机后套餐相关回调不触发。
+
+**修改位置**:`internal/bootstrap/services.go`(或 `worker_services.go`,找到 `StopResumeService` 和 `ActivationService` 初始化处)
+
+搜索:
+```bash
+grep -rn "NewStopResumeService\|NewActivationService\|ActivationService" internal/bootstrap/ --include="*.go"
+```
+
+在两者初始化完成后,追加:
+
+```go
+usageService.SetStopResumeCallback(stopResumeService)
+activationService.SetResumeCallback(stopResumeService)
+```
+
+### 方案 F · 人工验收清单
+
+- **F-1 零额待审佣金记录**
+ - 工具:DBHub + 日志
+ - 步骤:人工制造一笔“套餐系列未分配给代理”的订单后,执行:`SELECT order_id, shop_id, amount, status, remark FROM tb_commission_record WHERE status=99 ORDER BY id DESC LIMIT 20;`
+ - 预期结果:出现 `status=99`、`amount=0` 的待审佣金记录,日志有“佣金链断裂”告警
+
+- **F-2 废弃代码删除**
+ - 工具:`rg` + `go build ./...`
+ - 步骤:搜索 `TaskTypeSIMStatusSync`、`HandleSIMStatusSync`、`internal/task/sim.go`、`internal/service/sync/service.go`
+ - 预期结果:无残留引用,相关文件已删,编译通过
+
+- **F-3 错误不应被吞**
+ - 工具:`rg` + 日志
+ - 步骤:搜索 `cards, _ :=` 是否已替换;观察查询失败时日志是否出现“查询绑定卡信息失败,结果可能不完整”
+ - 预期结果:错误被记录但不误中断主流程
+
+- **F-4 C端 Handler 迁移到 Service**
+ - 工具:`rg` + `go build ./...`
+ - 步骤:
+ 1. 搜索 `internal/handler/app/client_order.go`,确认不再直接 `h.db.WithContext(...)`
+ 2. 搜索 `internal/service/client_order/service.go`,确认存在 `ListOrders` / `GetOrderDetail`
+ - 预期结果:C 端订单查询重新回到 Handler → Service 分层
+
+- **F-5 Handler/Service 权限一致**
+ - 工具:`rg`
+ - 步骤:搜索 `internal/handler/admin/order.go`,确认钱包支付前置校验存在“仅代理账号可使用钱包支付”
+ - 预期结果:Handler 层和 Service 层权限前置一致,不再出现一个放行一个拒绝
+
+- **F-6 StopResume / Resume 回调注入**
+ - 工具:`rg` + 日志
+ - 步骤:搜索 `SetStopResumeCallback`、`SetResumeCallback` 在 bootstrap 中的调用;观察停复机后相关联动日志
+ - 预期结果:停机 / 复机后的套餐联动回调真正被触发
+
+- **方案 F 收尾检查**
+ - 工具:`go build ./...`
+ - 预期结果:编译通过
+
+---
+
+## 方案 G:实名激活架构重构(P2-25 + P2-1 + P1-8)
+
+**业务背景**
+
+现有 `Package.enable_realname_activation` 字段同时混淆了两个独立的业务概念:
+
+1. **"是否需要等实名才激活"** → 这应该由**卡的类型**决定,不是套餐决定
+ - `industry` 卡(行业卡):永远不需要实名,直接激活
+ - `normal` 卡(普通卡):C 端客户自购前就要求实名;后台囤货则等实名后激活
+
+2. **"到期时间从哪里开始算"** → 这才是**套餐级配置**,需要新增字段
+
+原字段的本来意图:平台/代理"囤货"给资产预购套餐时,套餐激活时间有两种模式:
+- `from_activation`:等客户实名后激活,激活那一刻开始计时(当前行为)
+- `from_purchase`:购买即开始计时,实名只是"解锁使用权",不影响计时
+
+**修改清单**
+
+**① DB 迁移**:
+
+```sql
+-- 移除旧字段
+ALTER TABLE tb_package DROP COLUMN enable_realname_activation;
+
+-- 新增到期时间基准字段
+ALTER TABLE tb_package ADD COLUMN expiry_base VARCHAR(30) NOT NULL DEFAULT 'from_activation';
+-- 枚举值:from_activation(激活时起算)| from_purchase(购买时起算)
+```
+
+**② `internal/model/package.go:Package`**:
+
+```go
+// 删除
+EnableRealnameActivation bool `gorm:"column:enable_realname_activation;..."`
+
+// 新增
+ExpiryBase string `gorm:"column:expiry_base;type:varchar(30);not null;default:'from_activation';comment:到期时间基准 from_activation-实名激活时起算 from_purchase-购买时起算"`
+```
+
+**③ `internal/service/order/service.go:activateMainPackage`(约 1869 行)**
+
+改写套餐激活决策:
+
+```go
+// 修改前(只看套餐字段)
+if pkg.EnableRealnameActivation {
+ status = pending
+ pendingRealnameActivation = true
+} else {
+ status = active; activatedAt = now
+}
+
+// 修改后(按卡类型 + 购买路径决定)
+isCEndPurchase := 购买路径判断(可从 order.OrderType 或 buyerType 推断,C端 = "personal")
+if card.CardCategory == "industry" {
+ // 行业卡永远直接激活
+ status = constants.PackageUsageStatusActive
+ activatedAt = now
+} else if isCEndPurchase {
+ // C端客户购买前已检查实名,直接激活
+ status = constants.PackageUsageStatusActive
+ activatedAt = now
+} else {
+ // 后台囤货(平台/代理)
+ if pkg.ExpiryBase == "from_purchase" {
+ // 购买即激活,立即开始计时
+ status = constants.PackageUsageStatusActive
+ activatedAt = now
+ } else {
+ // from_activation:等实名后激活
+ status = constants.PackageUsageStatusPending
+ pendingRealnameActivation = true
+ }
+}
+```
+
+**④ `internal/service/client_order/service.go`**(C端购买实名检查)
+
+```go
+// 修改前(依赖套餐字段)
+if packagesNeedRealname(validationResult.Packages) && assetInfo.RealNameStatus != constants.RealNameStatusVerified {
+
+// 修改后(改为按卡类型判断)
+if assetInfo.CardCategory == "normal" && assetInfo.RealNameStatus != constants.RealNameStatusVerified {
+ return errors.New(errors.CodeForbidden, "请先完成实名认证")
+}
+// 删除 packagesNeedRealname() 函数(不再需要)
+```
+
+**⑤ `internal/service/package/activation_service.go:ActivateByRealname`(约 117 行)**
+
+```go
+// 修改前(到期时间总是从当前时刻算)
+activatedAt := now
+expiresAt := CalculateExpiryTime(pkg.CalendarType, activatedAt, ...)
+
+// 修改后(根据 ExpiryBase 选择基准时间)
+var activatedAt time.Time
+if pkg.ExpiryBase == "from_purchase" {
+ activatedAt = packageUsage.CreatedAt // 用订单/记录创建时间
+} else {
+ activatedAt = now // 实名检测到的时刻
+}
+expiresAt := CalculateExpiryTime(pkg.CalendarType, activatedAt, ...)
+```
+
+**⑥ 套餐管理 API**:创建/更新套餐时,移除 `enable_realname_activation` 字段,新增 `expiry_base` 字段(枚举:`from_activation | from_purchase`,默认 `from_activation`)。更新 DTO 和接口文档。
+
+### 方案 G · 人工验收清单
+
+- 工具:DBHub + `rg` + polling / activation 日志 + `go build ./...`
+- 步骤:
+ 1. DBHub 确认 `tb_package` 已删除 `enable_realname_activation`、新增 `expiry_base`
+ 2. 搜索全仓 `EnableRealnameActivation`,确认旧字段引用清空;搜索 `ExpiryBase`,确认至少在 `order/service.go`、`client_order/service.go`、`package/activation_service.go` 被使用
+ 3. 抽查普通卡 / 行业卡 / 囤货套餐的 `tb_package_usage`,核对 `status`、`pending_realname_activation`、`activated_at`、`expires_at`
+ 4. 观察首次实名激活日志,确认 `from_purchase` / `from_activation` 的到期时间基准已分流
+- 预期结果:实名需求回归卡类型;套餐只负责到期时间基准;旧字段完全退出主流程
+
+---
+
+## 方案 H:轮询系统小修(P1-6 + P2-10 + P2-12 + P2-23)
+
+---
+
+### H-1 · polling:protect 接入调度(P1-6)
+
+**问题**:`pkg/constants/constants.go:59` 定义了 `TaskTypePollingProtect = "polling:protect"`,处理器 `HandleProtectConsistencyCheck` 也已注册(`pkg/queue/handler.go:registerPollingHandlers`),但 Scheduler 从未调度此任务。
+
+**修改位置**:`internal/polling/scheduler.go`(找到主调度循环,约 243-250 行)
+
+```go
+// 当前(缺少 protect 的调度)
+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)
+
+// 修改后(新增 protect 的调度)
+s.processManualQueue(ctx, constants.TaskTypePollingProtect) // 新增
+s.processTimedQueue(ctx, constants.RedisPollingQueueProtectKey(), constants.TaskTypePollingProtect, now) // 新增
+```
+
+同时确认 `constants.RedisPollingQueueProtectKey()` 函数存在,以及 `initCardPolling()` 中是否已初始化 protect 队列(如没有则补充)。
+
+---
+
+### H-2 · gatewayClient=nil 停复机拒绝(P2-12)
+
+**问题**:`internal/service/iot_card/stop_resume_service.go:178, 211`,`gatewayClient==nil` 时 `return nil`(成功),调用方继续更新本地 DB,导致 DB 状态与运营商不一致。
+
+```go
+// 修改前
+if s.gatewayClient == nil {
+ s.logger.Warn("Gateway 客户端未配置,跳过调用运营商接口", ...)
+ return nil // ← 假装成功
+
+// 修改后
+if s.gatewayClient == nil {
+ return errors.New(errors.CodeInternalError, "Gateway 未配置,停复机操作不可用")
+```
+
+两处都改:`stopCardWithRetry` 和 `resumeCardWithRetry`。
+
+---
+
+### H-3 · packages 接口加分页(P2-10)
+
+`internal/service/asset/service.go:GetPackages()`:新增 `page, pageSize` 参数,默认 page=1,pageSize=50,最大 100。对应 DTO 新增 `Page int`, `PageSize int`, `Total int64` 字段。
+
+---
+
+### H-4 · Series 删除前检查关联套餐(P2-23)
+
+`internal/service/package_series/service.go:Delete()`:
+
+```go
+func (s *Service) Delete(ctx context.Context, id uint) error {
+ // 新增:检查是否有关联套餐
+ count, err := s.packageStore.CountBySeriesID(ctx, id)
+ if err != nil {
+ return errors.Wrap(errors.CodeDatabaseError, err, "查询关联套餐失败")
+ }
+ if count > 0 {
+ return errors.New(errors.CodeInvalidParam, fmt.Sprintf("该系列下有 %d 个关联套餐,请先处理", count))
+ }
+ return s.packageSeriesStore.Delete(ctx, id)
+}
+```
+
+`PackageStore` 需要新增 `CountBySeriesID(ctx, seriesID uint) (int64, error)` 方法。
+
+### 方案 H · 人工验收清单
+
+- **H-1 protect 调度接入**
+ - 工具:`rg` + Worker 日志
+ - 步骤:搜索 `scheduler.go` 是否已调度 `TaskTypePollingProtect`;观察调度日志是否出现 protect 队列处理
+ - 预期结果:protect 任务不再只注册不调度
+
+- **H-2 gatewayClient=nil 拒绝停复机**
+ - 工具:`rg` + 日志
+ - 步骤:搜索 `stop_resume_service.go`,确认 `gatewayClient==nil` 时返回业务错误而非 `nil`
+ - 预期结果:未配置 Gateway 时操作失败并报错,本地 DB 不会误更新为成功
+
+- **H-3 packages 接口分页**
+ - 工具:`rg` + `go build ./...`
+ - 步骤:搜索 `GetPackages()` 参数与 DTO,确认有 `page/pageSize/total`
+ - 预期结果:套餐列表接口默认分页,单页最大 100
+
+- **H-4 Series 删除前检查**
+ - 工具:DBHub + 日志
+ - 步骤:
+ 1. DBHub 查询某个有套餐关联的系列:`SELECT COUNT(*) FROM tb_package WHERE series_id = ?;`
+ 2. 人工触发删除
+ - 预期结果:若 count > 0,则删除被拒绝并提示“请先处理关联套餐”
+
+- **方案 H 收尾检查**
+ - 工具:`go build ./...`
+ - 预期结果:编译通过
+
+---
+
+## 方案 I:退款完整功能(P2-18 升级为完整功能)
+
+**业务背景**
+
+需要对资产套餐订单发起退款申请。退款不走自动化,由人工填写实收金额和申请退款金额,经审批后线下打款。审批通过后,系统自动从获得过佣金的代理佣金钱包中按比例扣减(允许为负数)。
+
+---
+
+### I-1 · 数据模型
+
+新建 `tb_refund_request` 表(`migrations/` 新迁移文件):
+
+```sql
+CREATE TABLE tb_refund_request (
+ id BIGSERIAL PRIMARY KEY,
+ refund_no VARCHAR(50) NOT NULL UNIQUE,
+ order_id BIGINT NOT NULL,
+ package_usage_id BIGINT, -- 关联的套餐使用记录
+ applicant_id BIGINT NOT NULL,
+ shop_id BIGINT,
+ actual_received_amount BIGINT NOT NULL, -- 实收金额(分,人工填写)
+ requested_refund_amount BIGINT NOT NULL, -- 申请退款金额(分)
+ approved_refund_amount BIGINT, -- 审批后实际退款金额(分)
+ refund_reason TEXT NOT NULL,
+ status INT NOT NULL DEFAULT 1, -- 1-待审批 2-已通过 3-已拒绝 4-已退回(可重提)
+ processor_id BIGINT, -- 审批人
+ processed_at TIMESTAMP WITH TIME ZONE,
+ commission_deducted BOOLEAN NOT NULL DEFAULT FALSE,
+ remark TEXT,
+ creator BIGINT NOT NULL,
+ updater BIGINT NOT NULL,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ deleted_at TIMESTAMP WITH TIME ZONE
+);
+```
+
+新建 `internal/model/refund.go` 定义 GORM Model(参考 `financial.go` 的风格)。
+
+---
+
+### I-2 · 接口设计
+
+新建 Handler + Service + Store(参考 `commission_withdrawal` 的完整实现):
+
+| 接口 | 方法 | Handler 方法 |
+|------|------|------------|
+| `POST /api/admin/refunds` | Create | 发起退款申请 |
+| `GET /api/admin/refunds` | List | 列表(分页+按状态筛选) |
+| `GET /api/admin/refunds/:id` | Get | 详情 |
+| `POST /api/admin/refunds/:id/approve` | Approve | 审批通过(可修改金额) |
+| `POST /api/admin/refunds/:id/reject` | Reject | 审批拒绝(必填拒绝原因) |
+| `POST /api/admin/refunds/:id/return` | Return | 退回申请(申请人可重提) |
+| `POST /api/admin/refunds/:id/resubmit` | Resubmit | 重新提交(被退回后) |
+
+状态流转:
+```
+1(待审批) → 2(已通过) [Approve] → 触发佣金回扣
+1(待审批) → 3(已拒绝) [Reject]
+1(待审批) → 4(已退回) [Return] → 可重提
+4(已退回) → 1(待审批) [Resubmit]
+```
+
+---
+
+### I-3 · 佣金回扣逻辑
+
+`refund/service.go:deductCommissionAfterApprove(ctx, refundID uint)`:
+
+```go
+func (s *Service) deductCommissionAfterApprove(ctx context.Context, refundID uint) error {
+ refund, _ := s.refundStore.GetByID(ctx, refundID)
+
+ // 查找该订单的所有已结算佣金记录
+ commissions, _ := s.commissionRecordStore.ListByOrderID(ctx, refund.OrderID)
+ if len(commissions) == 0 {
+ return nil // 没有佣金,直接标记
+ }
+
+ // 退款比例
+ ratio := float64(refund.ApprovedRefundAmount) / float64(refund.ActualReceivedAmount)
+
+ return s.db.Transaction(func(tx *gorm.DB) error {
+ for _, commission := range commissions {
+ deductAmount := int64(float64(commission.Amount) * ratio)
+ if deductAmount == 0 {
+ continue
+ }
+
+ // 从代理佣金钱包扣减(允许负数,直接 UPDATE,不检查余额)
+ tx.Model(&model.AgentWallet{}).
+ Where("shop_id = ? AND wallet_type = 'commission'", commission.ShopID).
+ Updates(map[string]any{
+ "balance": gorm.Expr("balance - ?", deductAmount),
+ })
+
+ // 写交易流水
+ refType := "refund"
+ refID := refundID
+ tx.Create(&model.AgentWalletTransaction{
+ ShopID: commission.ShopID,
+ TransactionType: "commission_deduct",
+ Amount: -deductAmount,
+ ReferenceType: &refType,
+ ReferenceID: &refID,
+ Remark: "退款佣金回扣",
+ })
+ }
+
+ // 标记退款佣金已回扣
+ tx.Model(&model.RefundRequest{}).
+ Where("id = ?", refundID).
+ Update("commission_deducted", true)
+
+ return nil
+ })
+}
+```
+
+`Approve()` 方法在更新 status=2 后,异步调用此函数(Goroutine,失败记日志但不影响审批结果)。
+
+### 方案 I · 人工验收清单
+
+- **I-1 数据模型**
+ - 工具:DBHub
+ - 步骤:搜索 `tb_refund_request` 表及其列
+ - 预期结果:退款表存在,核心字段(`refund_no / order_id / actual_received_amount / requested_refund_amount / approved_refund_amount / commission_deducted`)齐全
+
+- **I-2 接口设计**
+ - 工具:`rg` + `go build ./...`
+ - 步骤:搜索 refund 的 handler / service / store / route 注册,确认 7 个接口都已落地
+ - 预期结果:退款功能具备完整 CRUD + 审批流转能力
+
+- **I-3 佣金回扣逻辑**
+ - 工具:DBHub + 日志
+ - 步骤:审批通过一笔退款后,执行:
+ - `SELECT id, status, approved_refund_amount, commission_deducted FROM tb_refund_request ORDER BY id DESC LIMIT 10;`
+ - `SELECT shop_id, amount, transaction_type, reference_id FROM tb_agent_wallet_transaction WHERE transaction_type='commission_deduct' ORDER BY id DESC LIMIT 20;`
+ - 预期结果:退款单变为已通过、`commission_deducted=true`,并产生负向佣金钱包流水
+
+- **方案 I 收尾检查**
+ - 工具:`go build ./...`
+ - 预期结果:编译通过
+
+---
+
+## 方案 J:其他修复项
+
+---
+
+### J-1 · 富友支付实现(P1-4 + P1-5)
+
+**背景**:`pkg/fuiou/` SDK 已完整实现,`tb_wechat_config` 模型已有富友字段,回调 `FuiouPayCallback` 已实现。唯一缺失:`FuiouPayJSAPI()` 和 `FuiouPayMiniApp()` 是留桩,返回"暂未实现"。
+
+**凭证数据**(插入 `tb_wechat_config` 表,`provider_type='fuiou'`,`is_active=true`):
+
+| 字段 | 值 |
+|------|----|
+| `fy_ins_cd` | `08K0079562` |
+| `fy_mchnt_cd` | `0005210F8785904` |
+| `fy_term_id` | `88888888` |
+| `fy_api_url` | `https://fundwx.fuiou.com` |
+| `fy_notify_url` | `{你们服务器地址}/api/callback/fuiou-pay` |
+| `fy_private_key` | `MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQC5CM5aXbwDQkP8Stvq85lIeOy/kEogjlT1K8nS+ytQ5ipaT2tnj45rCw/+1mCvh+EmnfLNPIcW7lJxQai0IPtKfLR17kBd01nmDaelLZAFHSAjqQc4ksyDHGj8A8Fe96kaPcK0j+he6ei2j6LGrDNkEqATgZKSohdgV5ntG57F6lOEjY+3g8qpLmCSX1dv7jngLk3qT65zf5RWjqbQ/KU7VXo/GapVvEig2dzgI9RmIQ8yOzECBK9kJC3e5hklx98gz5sRkxASa9IH49jotwCXpKX5DiLKohnlP5xFmYJIt4htsiRjRvMVPZJRnlmAYW7bv4NAn6FFNH8L+lw7SG5lAgMBAAECggEBAKTRtEYIYrYga8CqydQyYuKMXI6Sr4TqY8Dz3VYix0XLkARb5BceZ8Tv2LKuMPeKOMMWRLYOaWLCrQsXanfxPQXvqSu3Kvyoi9aBaUiYGkaD2CILqVP6Z1OOlfGOQsweHTIzu2DtIxaQkuszbNI9h5VnhdF6RJ565gm6XnE3filZ+jFk4nvjLbJV7hF/nNpal+QUDfJ1oliIZjiETl0iL6aCP/6Lh/h/YYuMeHMDdE/9VfDSE4GFVyaWiVg8SdVBEW2EAMNOTmO7BD2mCbNsWjkwHcud0LwmPpAFj1muE1n5o/bzoqXpYPVq0eXvtAfF/440/01fqrcI1ip5OG7uSS0CgYEA4u+X8s7MeJ7AnJLHVjXETC0/WlOYhwHrNYz21i7JqNtpAp8WCZNpMpyN+525BPyaZS0dlHJgxsCbq5fNeDjZ9k3bCXFvLiV90kOO2ImwTkDF3+1Suk8EHek47Lyy2VpZDjgXHkCgfSi0glJXeNa8Q05N9Ih7lXxjSA3SvS13FX8CgYEA0Ltp5w1X8BjyGmQfn2i6rVMJQR5OV5bKNISeb/HfgQkQkbSMVStzmA3WtrqR9wtkmXpddJ8kl5O5w8cDJjAKz1aso98SuWag4Si5JZHP3DLtSNwznbqte7Ix4i8NqHquR/gL8Cmj6LJW1h3nzN/JLSFcgGHVhc1RimdtIya/1hsCgYEAreeJe6p6Cp0tYU8hrrD5Qp8SA3g4VI1l392scqncI6gwKrAaxS/P19cc/wr49BdXgd0248Fa5DRJlw93h3+ZmCRFjFD/ME/Owci/uLSbBPyiJl3JnbhboUhONSzNqb6QrFLTdH11/zOoUI4lNhboonNpTdEhU4bE1jyxmAM1VKUCgYEAjGE+/DGxLrzYNn+X9PHOersZwj3LmoTDQUbf95HIK1QZXKT8rFsoxt6nxQT9HhT/d2kgaUqOpZKooM67g3dUDdXRDfT89sva7xMgUfAax5FInHPcEvx1qHdTrTbQDLtVcvmTrdWTcvBeDmrWdqca+csyFvW1UOOhL2AXukhZRHkCgYBsRh01f7TE1c77RJ7QxBEZbyA1A3gcvCSQITJDmpM6NaREg3+Uh7vfJh1yY6SNvyCLvabFQMKw6BluWXs1f7LUVNE79r/0w/acjbCpOzHxEJQKS5RYmY/mzpU2AA/55vJmtxaB8dAFCl68qnXv9r144d0WSOtyNbFZbYJDbKf2QQ==` |
+| `fy_public_key` | `MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuQjOWl28A0JD/Erb6vOZSHjsv5BKII5U9SvJ0vsrUOYqWk9rZ4+OawsP/tZgr4fhJp3yzTyHFu5ScUGotCD7Sny0de5AXdNZ5g2npS2QBR0gI6kHOJLMgxxo/APBXvepGj3CtI/oXunoto+ixqwzZBKgE4GSkqIXYFeZ7RuexepThI2Pt4PKqS5gkl9Xb+454C5N6k+uc3+UVo6m0PylO1V6PxmqVbxIoNnc4CPUZiEPMjsxAgSvZCQt3uYZJcffIM+bEZMQEmvSB+PY6LcAl6Sl+Q4iyqIZ5T+cRZmCSLeIbbIkY0bzFT2SUZ5ZgGFu27+DQJ+hRTR/C/pcO0huZQIDAQAB` |
+
+**修改清单**
+
+**① `internal/service/order/service.go:FuiouPayJSAPI`(第 2355 行,替换留桩)**
+
+```go
+func (s *Service) FuiouPayJSAPI(ctx context.Context, orderID uint, openID string, buyerType string, buyerID uint) (*dto.FuiouPayJSAPIResponse, error) {
+ // 1. 查询并校验订单
+ order, err := s.orderStore.GetByID(ctx, orderID)
+ if err != nil {
+ return nil, errors.New(errors.CodeNotFound, "订单不存在")
+ }
+ if order.BuyerType != buyerType || order.BuyerID != buyerID {
+ return nil, errors.New(errors.CodeForbidden, "无权操作此订单")
+ }
+ if order.PaymentStatus != model.PaymentStatusPending {
+ return nil, errors.New(errors.CodeInvalidStatus, "订单状态不允许支付")
+ }
+
+ // 2. 加载激活的富友配置
+ config, err := s.wechatConfigStore.GetActive(ctx) // GetActive 已存在,只取 is_active=true 的那条
+ if err != nil || config.ProviderType != model.ProviderTypeFuiou {
+ return nil, errors.New(errors.CodeFuiouPayFailed, "富友支付未配置或未激活")
+ }
+
+ // 3. 构造 fuiou.Client
+ client, err := fuiou.NewClient(
+ config.FyInsCd, config.FyMchntCd, config.FyTermID,
+ config.FyAPIURL, config.FyNotifyURL,
+ config.FyPrivateKey, config.FyPublicKey,
+ s.logger,
+ )
+ if err != nil {
+ return nil, errors.Wrap(errors.CodeFuiouPayFailed, err, "初始化富友客户端失败")
+ }
+
+ // 4. 获取商品描述
+ items, _ := s.orderItemStore.ListByOrderIDs(ctx, []uint{orderID})
+ goodsDesc := "套餐购买"
+ if len(items) > 0 && items[0] != nil {
+ goodsDesc = items[0].PackageName
+ }
+
+ // 5. 调用富友预下单
+ // subAppid = 公众号 AppID(config.OaAppID)
+ // subOpenid = 用户在公众号下的 openID(入参 openID)
+ termIP := middleware.GetIPFromContext(ctx)
+ if termIP == "" {
+ termIP = "127.0.0.1"
+ }
+
+ resp, err := client.WxPreCreate(
+ order.OrderNo,
+ strconv.FormatInt(order.TotalAmount, 10), // 单位:分
+ goodsDesc,
+ termIP,
+ "JSAPI", // 公众号支付
+ config.OaAppID, // 公众号 AppID
+ openID,
+ )
+ if err != nil {
+ s.logger.Error("富友 JSAPI 预下单失败",
+ zap.Uint("order_id", orderID),
+ zap.String("order_no", order.OrderNo),
+ zap.Error(err))
+ return nil, errors.Wrap(errors.CodeFuiouPayFailed, err, "富友预下单失败")
+ }
+
+ // 6. 返回前端调起支付所需参数
+ return &dto.FuiouPayJSAPIResponse{
+ AppID: resp.SdkAppid,
+ TimeStamp: resp.SdkTimestamp,
+ NonceStr: resp.SdkNoncestr,
+ Package: "prepay_id=" + resp.SdkPrepayid,
+ SignType: resp.SdkSigntype,
+ PaySign: resp.SdkPaysign,
+ }, nil
+}
+```
+
+**② `internal/service/order/service.go:FuiouPayMiniApp`(第 2361 行,替换留桩)**
+
+与 FuiouPayJSAPI 完全相同,两处不同:
+- `tradeType` 传 `"LETPAY"`(小程序支付)
+- `subAppid` 传 `config.MiniappAppID`(小程序 AppID)
+
+**③ 新增 DTO `FuiouPayJSAPIResponse`**(`internal/model/dto/`):
+
+```go
+type FuiouPayJSAPIResponse struct {
+ AppID string `json:"appId" description:"应用ID"`
+ TimeStamp string `json:"timeStamp" description:"时间戳"`
+ NonceStr string `json:"nonceStr" description:"随机字符串"`
+ Package string `json:"package" description:"prepay_id=xxx"`
+ SignType string `json:"signType" description:"签名类型"`
+ PaySign string `json:"paySign" description:"支付签名"`
+}
+// 注:字段名与微信官方 wx.requestPayment() 参数一致,前端无需区分富友/微信
+```
+
+**④ 回调链路(已实现,无需修改)**
+
+`POST /api/callback/fuiou-pay` → `FuiouPayCallback()` → 验签 → 解析订单号前缀 → 路由到对应 `HandlePaymentCallback()` → 更新支付状态 → 触发套餐激活/佣金
+
+---
+
+### J-2 · 平台钱包代购(P1-7)
+
+**业务背景**:平台操作人员可以用某代理的钱包,以该代理的成本价,给该代理名下的资产购买套餐。当前 Service 层拒绝了平台使用钱包支付(要求 `buyerType == agent`)。
+
+**修改位置**:`internal/service/order/service.go:CreateLegacy`(约 195 行)
+
+```go
+// 修改前(直接拒绝非代理的钱包支付)
+} else if req.PaymentMethod == model.PaymentMethodWallet {
+ if buyerType != model.BuyerTypeAgent {
+ return nil, errors.New(errors.CodeInvalidParam, "只有代理账号可以使用钱包支付")
+ }
+ ...
+
+// 修改后(平台用代理钱包代购:buyerType="" 但指定了 resourceShopID)
+} else if req.PaymentMethod == model.PaymentMethodWallet {
+ if buyerType == model.BuyerTypeAgent {
+ // 代理用自己钱包,正常逻辑
+ ...
+ } else if buyerType == "" && resourceShopID != nil {
+ // 平台用代理钱包代购:钱包来自资产所属代理
+ orderBuyerType = model.BuyerTypeAgent
+ // 成本价、钱包都使用 *resourceShopID 对应的代理
+ costPrice, _ := s.getCostPrice(ctx, *resourceShopID, firstPackageID)
+ totalAmount = costPrice
+ actualPaidAmount = &costPrice
+ // 钱包扣款指向 *resourceShopID 的代理钱包
+ } else {
+ return nil, errors.New(errors.CodeInvalidParam, "不支持的钱包支付场景")
+ }
+```
+
+---
+
+### J-3 · status 字段 3/4 业务触发(P2-3)
+
+**业务含义澄清**:`status` 字段(在 `iot_card` 和 `device` 表上)跟踪客户使用状态:
+- 1 = 在库(未分配)
+- 2 = 已分销(分配给代理,等待客户)
+- 3 = 已激活(有客户在使用,有 active 套餐)
+- 4 = 已停用(套餐全部到期,无续套餐)
+
+`asset_status` 是另一个维度(业务生命周期:在库/已销售/已换货/已停用),两者不合并。
+
+**需要补充的触发逻辑**:
+
+- **→ 3(已激活)**:在 `PackageActivationHandler` 激活套餐成功后,如果 `card.status < 3`,更新为 3
+- **→ 4(已停用)**:在套餐到期处理逻辑中(`OrderExpireHandler` 或类似),当某资产最后一个 active 套餐过期时,更新为 4
+
+**换货流程不影响**:换货代码 `exchange/service.go` 只操作 `asset_status`,不碰 `status` 字段。
+
+---
+
+### J-4 · payment_status 枚举统一(P2-17)
+
+**现状**:管理端 `payment_status` 是 1/2/3/4(pending/paid/cancelled/refunded),C 端通过 `orderStatusToClientStatus()` 映射成 0/1/2。
+
+**修改清单**:
+1. `internal/service/client_order/service.go:652-659`:删除 `orderStatusToClientStatus()` 函数
+2. C 端 DTO `ClientOrderResponse.PaymentStatus`:直接用 `int`,值与管理端一致(1/2/3/4)
+3. **通知前端**:C 端解析状态枚举从 `0/1/2` 改为 `1/2/3`(4=退款,后续退款功能实现后前端处理)
+
+### 方案 J · 人工验收清单
+
+- **J-1 富友支付实现**
+ - 工具:DBHub + 日志 + `rg` + `go build ./...`
+ - 步骤:
+ 1. DBHub 查询 `tb_wechat_config`,确认存在 `provider_type='fuiou' AND is_active=true` 配置
+ 2. 搜索 `FuiouPayJSAPI` / `FuiouPayMiniApp`,确认不再返回“暂未实现”
+ 3. 联调一次预下单 / 回调,观察日志关键字“富友 JSAPI 预下单失败 / 成功”“FuiouPayCallback”
+ - 预期结果:富友支付可正常走预下单 + 回调链路
+
+- **J-2 平台钱包代购**
+ - 工具:`rg` + DBHub
+ - 步骤:
+ 1. 搜索 `CreateLegacy` 钱包支付分支,确认支持 `buyerType=="" && resourceShopID != nil`
+ 2. DBHub 抽查一笔平台代代理钱包订单,核对 `buyer_type / total_amount / actual_paid_amount / seller_shop_id`
+ - 预期结果:平台可用代理钱包代购,金额口径与代理成本价体系一致
+
+- **J-3 status 3/4 业务触发**
+ - 工具:DBHub + 日志
+ - 步骤:
+ 1. 激活一笔套餐后,查询 `tb_iot_card.status` / `tb_device.status`
+ 2. 最后一个 active 套餐到期后,再次查询状态
+ - 预期结果:激活后状态进入 3;最后套餐过期后进入 4;换货流程不误改该字段
+
+- **J-4 payment_status 枚举统一**
+ - 工具:`rg` + `go build ./...`
+ - 步骤:
+ 1. 搜索 `orderStatusToClientStatus()`,确认已删除
+ 2. 搜索 `ClientOrderResponse.PaymentStatus`,确认直接输出管理端同款整型枚举
+ - 预期结果:C 端 / 管理端支付状态枚举语义统一
+
+- **方案 J 收尾检查**
+ - 工具:`go build ./...`
+ - 预期结果:编译通过
+
+---
+
+## 附:取消 / 不做的项目
+
+| 条目 | 原因 |
+|------|------|
+| P1-2 企业认证中间件 | 复用 AdminAuth,不需要新中间件 |
+| P1-3 提现"已到账"接口 | 无法感知,不需要实现 |
+| P1-9 设备定期轮询 | 伪需求,改为按需查询(D-3) |
+| P2-6 告警通知渠道 | 无关紧要,暂不做 |
+| P2-9 asset_type 校验 | 误判,Service 层已有校验兜底 |
+| P2-24 Carrier→Series 层级 | 系列可用于任意运营商的卡,不需要强绑定 |
+| P2-30 轮询配置通用化 | 设备轮询取消后,此问题不复存在 |
+| P2-5 废弃字段写入 | 有意的向后兼容,不清理 |
+
+---
+
+## 附:优先级排序(最终)
+
+```
+立即(1-2天)
+ A-1 常量统一 ← 所有 A 系修复的前置
+ A-2 实名校验 ← 依赖 A-1
+ A-3 实名激活断链
+ A-4 自动购包触发+佣金
+ A-5 代购金额
+ A-6 设备导入IMEI
+ A-7 提现并发校验
+
+近期(3-5天)
+ B 企业端权限
+ C 提现/佣金流程(P2-19/20/21/22)
+ G 实名激活重构(破坏性变更,单独提案)
+ H 轮询小修(P1-6 / P2-10 / P2-12 / P2-23)
+ J-4 payment_status 枚举统一
+
+中期(1-2周)
+ D 设备体系(D0→D1→D2→D3)
+ I 退款完整功能
+ J-1 富友支付(凭证已有,SDK 已有,写两个方法即可)
+ J-2 平台钱包代购
+ J-3 status 3/4 触发逻辑
+ F 代码清理(废弃代码删除、错误处理补全等)
+
+长期/谨慎(需低峰期发布)
+ E 流量体系改革(DB 迁移 + Redis 架构)
+```
diff --git a/AGENTS.md b/AGENTS.md
index 8769946..301789c 100644
--- a/AGENTS.md
+++ b/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 时必须同步更新文档生成器
diff --git a/docs/system-audit-report.md b/docs/system-audit-report.md
new file mode 100644
index 0000000..6fe2bd5
--- /dev/null
+++ b/docs/system-audit-report.md
@@ -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
Shop level=1, parent_id=NULL]
+ P --> A2[一级代理 B
Shop level=1]
+ A1 --> B1[二级代理 C
level=2, parent_id=A]
+ A1 --> B2[二级代理 D
level=2, parent_id=A]
+ B1 --> C1[三级代理 E
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["创建运营商
📡 中国移动/联通/电信
POST /admin/carriers"]
+ S2["创建套餐系列
📦 如'移动30G月租系列'
POST /admin/package-series"]
+ S3["创建套餐
💰 30G/月 ¥29.9
POST /admin/packages"]
+ S4["导入 IoT 卡/设备
📥 批量导入 ICCID/设备号
POST /admin/iot-cards/import"]
+ S1 --> S2 --> S3
+ S1 --> S4
+ end
+
+ subgraph 代理发展["② 发展代理体系"]
+ D1["创建一级代理
🏪 开设店铺+管理员账号
POST /admin/shops"]
+ D2["代理创建子代理
🏪 parent_id=自己
同一接口,RBAC控制"]
+ D1 --> D2
+ end
+
+ subgraph 代理授权["③ 给代理配货"]
+ G1["分配资产给代理
📦 IoT卡/设备 → 代理名下
POST /admin/iot-cards/allocate
status 1→2, 写入 shop_id"]
+ G2["授权系列给代理
🔑 代理可以卖哪些系列
POST /admin/shop-series-grants
配置:成本价、佣金金额"]
+ G3["分配套餐
📋 代理可卖哪些具体套餐
POST /admin/shop-package-batch-allocations
设置零售价、上架状态"]
+ G2 --> G3
+ end
+
+ subgraph 代理可向下级重复此过程["④ 代理给子代理配货(同样的流程)"]
+ R1["代理也可以:
• 分配资产给子代理
• 授权系列给子代理
• 设置子代理的成本价/佣金
⚠️ 子代理佣金 ≤ 自己佣金"]
+ end
+
+ subgraph C端消费["⑤ C 端客户消费"]
+ C1["扫码/输虚拟号验证资产
📱 识别卡/设备
POST /c/v1/auth/verify-asset
asset_status 1→2(已销售)"]
+ C2["微信授权登录
👤 绑定客户身份
POST /c/v1/auth/wechat-login"]
+ C3["查看可购套餐
📋 根据资产所属代理展示
GET /c/v1/asset/packages
查 ShopPackageAllocation"]
+ C4["下单支付
💳 微信支付
POST /c/v1/orders/create"]
+ C5["套餐生效
✅ 立即激活或排队
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["客户支付成功
order/service.go"]
+
+ PAY --> CHECK_REALNAME{"套餐需要实名激活?
EnableRealnameActivation"}
+
+ CHECK_REALNAME -- "false(不需要)" --> CHECK_ACTIVE{"已有生效中主套餐?"}
+ CHECK_REALNAME -- "true(需要实名)" --> PENDING_REALNAME["🟡 进入待实名状态
status=pending
pending_realname_activation=true
等卡实名后自动激活"]
+
+ CHECK_ACTIVE -- "没有" --> ACTIVATE["✅ 立即激活
status=active
设置 activated_at / expires_at
如果卡被停机,自动复机"]
+ CHECK_ACTIVE -- "有" --> QUEUE["🟡 排队等候
status=pending
priority=max+1
前一个套餐到期后自动接力"]
+
+ PENDING_REALNAME --> REALNAME_CHECK["轮询检测卡实名状态
polling:realname 任务
Gateway 返回实名=true"]
+ REALNAME_CHECK --> TRIGGER["触发首次实名激活
triggerFirstRealnameActivation()"]
+ TRIGGER --> RPUSH["RPush 到 Redis 队列
package:first:activation"]
+ RPUSH --> BROKEN["🔴 断链!
scheduler 不消费此队列
套餐永远不会被激活"]
+
+ QUEUE --> EXPIRE["当前套餐到期/用完"]
+ EXPIRE --> NEXT["激活排队中的下一个
ActivateQueuedPackage()
package:queue:activation"]
+ NEXT --> ACTIVATE2["✅ 激活
级联失效旧加油包"]
+
+ 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["✅ 生效中
status=active"]
+
+ ACTIVE --> USE["日常使用消耗流量
轮询 polling:carddata 检测用量
Gateway 同步 current_month_usage_mb"]
+ USE --> RESET_CHECK{"到重置周期?
data_reset_cycle"}
+ RESET_CHECK -- "是" --> RESET["流量重置
package:data:reset 任务
used_data_mb → 0"]
+ RESET --> USE
+ RESET_CHECK -- "否" --> EXHAUST_CHECK{"流量用完?"}
+ EXHAUST_CHECK -- "否" --> EXPIRE_CHECK{"到期了?"}
+ EXHAUST_CHECK -- "是" --> EXHAUSTED["⚡ 流量耗尽
status=exhausted"]
+
+ EXPIRE_CHECK -- "否" --> USE
+ EXPIRE_CHECK -- "是" --> EXPIRED["⏰ 到期
status=expired"]
+
+ EXHAUSTED --> STOP["自动停机
Gateway.StopCard()
network_status=0
stop_reason=traffic_exhausted"]
+ EXHAUSTED --> NEXT_PKG
+
+ EXPIRED --> NEXT_PKG["激活排队套餐
ActivateQueuedPackage()"]
+ EXPIRED --> CASCADE["级联失效该主套餐的加油包"]
+
+ NEXT_PKG --> HAS_NEXT{"有排队套餐?"}
+ HAS_NEXT -- "有" --> ACTIVATE_NEXT["✅ 激活下一个
如被停机则自动复机"]
+ HAS_NEXT -- "没有" --> NO_PACKAGE["❌ 无可用套餐
保持停机状态
等客户续购"]
+
+ 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["客户发起充值
💰 选择金额
POST /c/v1/wallet/recharge"]
+ R2["微信支付"]
+ R3["支付回调
POST /callback/wechat
recharge/service.go"]
+ R4["✅ 资产钱包到账
AssetWallet.balance += amount"]
+ R5["更新累计充值
accumulated_recharge_by_series"]
+ R6{"满足一次性佣金条件?"}
+ R7["✅ 佣金直接发放
(同步,不走队列)
→ 店铺 commission 钱包"]
+ R8["触发自动购包检查
task:auto_purchase_after_recharge"]
+
+ R1 --> R2 --> R3 --> R4 --> R5 --> R6
+ R6 -- "是" --> R7
+ R6 -- "否" --> R8
+ R4 --> R8
+ end
+
+ subgraph 订单佣金流["订单支付触发佣金"]
+ O1["客户购买套餐"]
+ O2["支付成功"]
+ O3["异步佣金任务入队
commission:calculate"]
+ O4["链式佣金计算
沿代理层级往上"]
+ O5["✅ 各级代理 commission 钱包到账"]
+
+ O1 --> O2 --> O3 --> O4 --> O5
+ end
+
+ subgraph 提现流["代理佣金提现"]
+ W1["代理发起提现
POST /admin/my/commission/withdrawals"]
+ W2["冻结 commission 钱包余额"]
+ W3["平台审批
POST /admin/commission-withdrawals/:id/approve"]
+ W4["扣除冻结余额
status=已通过"]
+ W5["🟡 缺失:确认到账
模型有 status=4(已到账)
但没有代码把它设为 4
实际靠线下打款"]
+
+ W1 --> W2 --> W3 --> W4 --> W5
+ end
+
+ subgraph 预充值流["平台给代理预充值"]
+ P1["平台充值
POST /admin/agent-recharges"]
+ P2["✅ 代理 main 钱包到账
用于钱包支付订单"]
+ P1 --> P2
+ end
+
+ style W5 fill:#fff9c4,stroke:#f9a825,stroke-width:2px
+```
+
+### 问题
+
+| 环节 | 状态 | 说明 |
+|------|------|------|
+| 充值 → 钱包到账 | ✅ | 链路完整 |
+| 充值 → 一次性佣金 | ✅ | 同步发放,但不走异步队列(与订单佣金路径不一致) |
+| 订单 → 佣金计算 | ✅ | 异步链路完整 |
+| 佣金提现 → 审批 | ✅ | 冻结/审批/拒绝都有 |
+| 审批 → 到账确认 | 🟡 **缺失** | 没有"已到账"标记入口,提现审批后就结束了 |
+
+---
+
+## 五、停机 / 复机
+
+```mermaid
+flowchart TD
+ subgraph 自动停机["自动停机(流量耗尽)"]
+ AS1["轮询检测到无活跃套餐"]
+ AS2["调用 Gateway 停机
Gateway.StopCard()"]
+ AS3["更新状态
network_status=0
stopped_at=now
stop_reason=traffic_exhausted"]
+ AS1 --> AS2 --> AS3
+ end
+
+ subgraph 自动复机["自动复机(新套餐激活)"]
+ AR1["新套餐激活触发回调"]
+ AR2{"停机原因是流量耗尽?"}
+ AR2 -- "是" --> AR3["调用 Gateway 复机
Gateway.StartCard()"]
+ AR2 -- "否(手动停机)" --> AR4["❌ 不自动复机
手动停的需要手动开"]
+ AR3 --> AR5["更新状态
network_status=1
resumed_at=now"]
+ AR1 --> AR2
+ end
+
+ subgraph 手动停复机["后台手动操作"]
+ M1["管理员/代理手动停机
POST /admin/assets/card/:iccid/stop"]
+ M2["管理员/代理手动复机
POST /admin/assets/card/:iccid/start"]
+ 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["代理创建换货单
POST /admin/exchanges
选择旧资产
状态:待填写信息"]
+ end
+
+ subgraph 客户["② 客户填写信息"]
+ E2["C 端提交收货地址
POST /c/v1/exchange/:id/shipping-info
状态:待发货"]
+ end
+
+ subgraph 发货["③ 后台发货"]
+ E3["绑定新资产+物流
POST /admin/exchanges/:id/ship
新资产必须 asset_status=1
状态:已发货"]
+ end
+
+ subgraph 完成["④ 完成换货"]
+ E4["确认完成
POST /admin/exchanges/:id/complete"]
+ E5{"需要迁移数据?"}
+ E5 -- "是" --> E6["迁移:
• 钱包余额
• 套餐记录
• 累计充值/首充
• 客户绑定
• 资源标签"]
+ E6 --> E7["旧资产 asset_status → 3(已换货)"]
+ E5 -- "否" --> E7B["仅标记完成"]
+ end
+
+ 发起 --> 客户 --> 发货 --> 完成
+
+ style E7 fill:#fff9c4
+```
+
+**状态**:✅ 链路完整。旧资产正确标记,数据迁移覆盖钱包/套餐/标签等。
+
+---
+
+## 七、企业客户流程
+
+> 🔴 **这是系统最大的业务断裂**
+
+```mermaid
+flowchart TD
+ subgraph 平台操作["平台/代理 操作(✅ 已实现)"]
+ E1["创建企业
POST /admin/enterprises"]
+ E2["创建企业账号
user_type=4"]
+ E3["授权卡给企业
POST /admin/enterprises/:id/allocate-cards"]
+ E4["授权设备给企业
POST /admin/enterprises/:id/allocate-devices"]
+ 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 注释
0=未实名 1=已实名"]
+ D2["DTO 注释
0=未实名 1=实名中 2=已实名"]
+ D3["轮询代码实际写入
true→2, false→0"]
+ end
+
+ subgraph 影响["导致的问题"]
+ I1["C 端下单校验
RealNameStatus != 1
只允许'实名中'通过?"]
+ I2["已实名的卡 status=2
2 != 1 为 true
🔴 被拒单!"]
+ end
+
+ D1 --> I1
+ D2 --> I1
+ D3 --> I2
+
+ style I2 fill:#ffcdd2,stroke:#c62828,stroke-width:3px
+```
+
+---
+
+## 十、代理佣金链式分配
+
+> 当子代理的客户买了套餐,佣金怎么分?
+
+```mermaid
+flowchart BT
+ CUSTOMER["客户购买套餐
💰 售价 ¥99"]
+
+ CUSTOMER --> C_AGENT["三级代理 C
成本价 ¥60
利润:¥99 - ¥60 = ¥39
→ C 的 commission 钱包"]
+
+ C_AGENT --> B_AGENT["二级代理 B
成本价 ¥40
利润:¥60 - ¥40 = ¥20
→ B 的 commission 钱包"]
+
+ B_AGENT --> A_AGENT["一级代理 A
成本价 ¥25
利润:¥40 - ¥25 = ¥15
→ A 的 commission 钱包"]
+
+ A_AGENT --> PLATFORM["平台
成本价 ¥25(A的成本价)
不生成佣金记录
平台收益在售价中"]
+
+ MISSING["⚠️ 如果 B 没有该系列授权
→ B 和 A 都不会拿到佣金
→ 无告警通知"]
+
+ 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
生产者:triggerFirstRealnameActivation()
消费者:HandlePackageFirstActivation()
问题:RPush 到 Redis list
但 scheduler 不消费这个队列"]
+ end
+
+ subgraph 未确认["⚠️ 待确认"]
+ U1["sim:status:sync
有消费者,未确认生产者"]
+ 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["授权系列给代理
配置成本价/佣金"]
+ ALLOC_PKG["分配套餐
设置零售价/上架"]
+ SHOP --> GRANT --> ALLOC_PKG
+ end
+
+ %% ===== 资产层 =====
+ subgraph 资产管理["📦 资产管理"]
+ IMPORT["导入卡/设备"]
+ ALLOC_ASSET["分配给代理
status 1→2"]
+ BIND["C端客户绑定
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["佣金计算
链式分账"]
+ COMMISSION_WALLET["代理佣金钱包"]
+ WITHDRAW["佣金提现"]
+ AGENT_RECHARGE["平台给代理充值
主钱包"]
+ 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["🔴 企业自己管理
(完全缺失)"]
+ end
+
+ %% ===== 换货层 =====
+ subgraph 换货["🔄 换货"]
+ EXCHANGE["发起→发货→完成"]
+ MIGRATE["迁移钱包/套餐/标签"]
+ end
+
+ %% ===== 自动任务 =====
+ subgraph 自动任务["⚡ 自动任务"]
+ AUTO_PURCHASE["🔴 自动购包
(无人触发)"]
+ 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 端发起强充充值
POST /c/v1/wallet/recharge
关联套餐 LinkedPackageIDs"]
+ R2["微信支付成功
充值回调"]
+ R3["资产钱包到账"]
+ R4["🔴 应该触发自动购包
Enqueue auto_purchase_after_recharge
但当前代码中
NewAutoPurchaseTask() 无人调用"]
+ R5["自动购包处理器
auto_purchase.go:ProcessTask()"]
+ R6["检查钱包余额"]
+ R7["创建订单(已支付状态)"]
+ R8["激活套餐"]
+ R9["🟡 应触发佣金但未触发
没有 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["轮询调度器
每秒执行一次
scheduler.go"]
+
+ SCHEDULER --> REALNAME["实名检测
polling:realname"]
+ SCHEDULER --> CARDDATA["流量检测
polling:carddata"]
+ SCHEDULER --> PACKAGE["套餐检测
polling:package"]
+ SCHEDULER --> RESET["数据重置
每分钟检查"]
+ SCHEDULER --> PKG_ACTIVATION["套餐过期检查
→ 排队激活"]
+
+ SCHEDULER -.->|"🟡 未调度"| PROTECT["保护期一致性
polling:protect"]
+
+ REALNAME --> R1["调 Gateway 查实名"]
+ R1 --> R2["更新 real_name_status"]
+ R2 --> R3{"首次实名?"}
+ R3 -- "是" --> R4["🔴 触发待实名激活
(断链:RPush 到无人消费队列)"]
+ R3 -- "否" --> R5["重新入队"]
+
+ CARDDATA --> C1["调 Gateway 查流量"]
+ C1 --> C2["更新卡月用量"]
+ C2 --> C3["扣减套餐流量
加油包优先"]
+ C3 --> C4{"全部套餐耗尽?"}
+ C4 -- "是" --> C5["调 Gateway 停机
network_status=0"]
+ C4 -- "否" --> C6["重新入队"]
+
+ PACKAGE --> P1["读套餐配置"]
+ P1 --> P2{"超额?"}
+ P2 -- "是" --> P3["调 Gateway 停机"]
+ P2 -- "否" --> P4["重新入队"]
+
+ RESET --> RS1{"日/月/年重置时间到?"}
+ RS1 -- "是" --> RS2["重置 data_usage_mb=0
status→active
计算 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["后台下单
POST /admin/orders"]
+ CREATE_CLIENT["C端下单
POST /c/v1/orders/create"]
+ CREATE_AUTO["自动购包创建
🔴 未接线"]
+ end
+
+ PENDING["待支付
设置 expires_at
开始超时倒计时"]
+
+ subgraph 支付["支付"]
+ PAY_WECHAT["微信支付"]
+ PAY_WALLET["钱包支付
立即扣款"]
+ PAY_CALLBACK["支付回调
POST /callback/wechat"]
+ end
+
+ subgraph 支付后副作用["支付成功后的链式反应"]
+ ACTIVATE_PKG["激活套餐
(立即/排队/待实名)"]
+ ENQUEUE_COMMISSION["入队佣金计算
commission:calculate"]
+ CLEAR_EXPIRE["清空 expires_at"]
+ end
+
+ subgraph 超时["超时处理"]
+ EXPIRE_CHECK["Asynq 每分钟扫描
order_expire 任务"]
+ CANCEL["取消订单
payment_status=cancelled"]
+ UNFREEZE["解冻钱包余额
(如果冻结过)"]
+ 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["📱 扫码/输入虚拟号
verify-asset"]
+ LOGIN["🔑 微信授权登录
wechat-login / miniapp-login"]
+ BIND_PHONE["📞 绑定手机号
bind-phone"]
+ PROFILE["👤 个人资料
get/update profile"]
+
+ SEE_ASSET["📊 查看资产信息
asset/info"]
+ SEE_PKG["📋 查看可购套餐
asset/packages"]
+ SEE_HISTORY["📜 套餐历史
asset/package-history"]
+ REFRESH["🔄 刷新资产
asset/refresh"]
+
+ BUY["🛒 购买套餐
orders/create → 微信支付"]
+ ORDER_LIST["📑 我的订单
orders / orders/:id"]
+
+ WALLET["💰 钱包详情
wallet/detail"]
+ RECHARGE["💳 充值
wallet/recharge"]
+ RECHARGE_LIST["📃 充值记录
wallet/recharges"]
+ TRANSACTIONS["📃 流水
wallet/transactions"]
+
+ REALNAME["🪪 实名认证
realname/link"]
+
+ DEVICE_CARDS["📡 设备卡列表
device/cards"]
+ DEVICE_OPS["⚙️ 设备操作
reboot / factory-reset / wifi / switch-card"]
+
+ EXCHANGE_PENDING["🔄 查看换货单
exchange/pending"]
+ EXCHANGE_SHIP["📦 提交收货信息
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 注入情况
diff --git a/docs/前端接口变更说明.md b/docs/前端接口变更说明.md
index d9f41ec..cd3fdf9 100644
--- a/docs/前端接口变更说明.md
+++ b/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 `)。
diff --git a/docs/第三方文档/gateway设备详情同步接口.md b/docs/第三方文档/gateway设备详情同步接口.md
new file mode 100644
index 0000000..294a668
--- /dev/null
+++ b/docs/第三方文档/gateway设备详情同步接口.md
@@ -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`。
diff --git a/opencode.json b/opencode.json
index e4c0434..0319118 100644
--- a/opencode.json
+++ b/opencode.json
@@ -26,7 +26,7 @@
"command": [
"npx",
"-y",
- "@bytebase/dbhub@latest",
+ "@bytebase/dbhub@0.19.1",
"--transport", "stdio",
"--config", ".config/dbhub.toml"
]