This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-04-17
|
||||
@@ -0,0 +1,57 @@
|
||||
## Context
|
||||
|
||||
`POST /api/admin/agent-recharges` 是平台管理员为代理商创建线下充值订单的接口。当 `payment_method=offline` 时,代表平台已收到代理商的线下转账,此时应同步录入支付凭证(如银行转账截图的对象存储 key),以便后续对账审计。
|
||||
|
||||
充值流程分两个阶段:
|
||||
1. **创建阶段**(`POST /api/admin/agent-recharges`):平台录入充值单 + 上传凭证
|
||||
2. **确认阶段**(`POST /api/admin/agent-recharges/:id/offline-pay`):财务二次确认,凭证已在创建时录入,无需重复上传
|
||||
|
||||
凭证文件本身通过已有的 `/storage/upload-url` 预签名 URL 上传至对象存储(OSS),后端仅存储 `file_key`。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- `Create` 接口在 `payment_method=offline` 时强制要求传入 `payment_voucher_key`
|
||||
- 凭证 key 随充值记录一起写入数据库
|
||||
- 响应 DTO 返回凭证 key 供前端展示
|
||||
- `OfflinePay` 接口不做任何凭证相关改动
|
||||
|
||||
**Non-Goals:**
|
||||
- 不涉及文件上传本身(OSS 预签名链路已有)
|
||||
- 不修改 `OfflinePay` 接口(财务确认流程独立于凭证管理)
|
||||
- 不做凭证有效性验证(文件是否真实存在于 OSS)
|
||||
- `payment_method=wechat` 时 `payment_voucher_key` 无需传,应忽略
|
||||
|
||||
## Decisions
|
||||
|
||||
### 决策1:凭证在创建时收集,而非确认时
|
||||
|
||||
**选择**:在 `Create` 时收集凭证,因为平台管理员在录入线下充值单时就已持有转账凭证。
|
||||
|
||||
**理由**:线下充值的业务含义是"平台已收到转账,现在录入系统"。凭证是这次录入的必要信息。`OfflinePay` 是财务的二次确认操作,凭证应已在录入时保存,财务只负责审核确认。
|
||||
|
||||
### 决策2:字段命名与 orders 保持一致
|
||||
|
||||
**选择**:使用 `payment_voucher_key`,与 `order.payment_voucher_key` 完全一致。
|
||||
|
||||
**理由**:统一命名降低认知负担,前端可复用同一组件。
|
||||
|
||||
### 决策3:`wechat` 支付方式时字段可选
|
||||
|
||||
**选择**:`payment_voucher_key` 使用 `validate:"omitempty,max=500"`,仅在 Service 层对 `offline` 方式做非空校验。
|
||||
|
||||
**理由**:微信在线支付无需凭证,不应因为加了字段就破坏微信支付的调用契约。
|
||||
|
||||
### 决策4:凭证随 Create 直接写入
|
||||
|
||||
**选择**:在 `Create` 方法构建 `AgentRechargeRecord` 时直接赋值 `PaymentVoucherKey`,与记录创建同一个 `agentRechargeStore.Create()` 调用写入。
|
||||
|
||||
**理由**:凭证是充值记录的组成部分,不需要单独事务,最简路径。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
**[风险] 历史记录无凭证**
|
||||
→ 历史已创建的线下充值记录 `payment_voucher_key` 为空,属正常情况,不补录。
|
||||
|
||||
**[风险] 数据库迁移**
|
||||
→ 新列 `VARCHAR(500)` 允许 NULL(兼容历史数据),新接口 Service 层对 `offline` 方式强制非空。迁移简单,无需锁表。
|
||||
@@ -0,0 +1,32 @@
|
||||
## Why
|
||||
|
||||
`/api/admin/agent-recharges` 线下充值确认接口(`OfflinePay`)未要求上传支付凭证,而同类的 `/api/admin/orders` 线下支付已强制上传凭证。两个业务场景均涉及线下资金流转,审计和对账需要同等的凭证留存能力。
|
||||
|
||||
## What Changes
|
||||
|
||||
- `CreateAgentRechargeRequest` DTO 新增 `payment_voucher_key` 字段(`payment_method=offline` 时必填)
|
||||
- `AgentRechargeResponse` DTO 新增 `payment_voucher_key` 响应字段
|
||||
- `AgentRechargeRecord` Model 新增 `payment_voucher_key` 数据库列
|
||||
- `Create` Service 方法新增:当 `payment_method=offline` 时校验凭证非空,并将凭证写入充值记录
|
||||
- 数据库迁移:`tb_agent_recharge_record` 表新增 `payment_voucher_key` 列
|
||||
- `OfflinePay` 接口不涉及凭证(财务确认流程,凭证已在创建时录入)
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `agent-recharge-payment-voucher`:代理充值线下支付凭证上传与存储能力,对齐 orders 接口的凭证管理标准
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
(无已有 spec 变更)
|
||||
|
||||
## Impact
|
||||
|
||||
| 层级 | 文件 | 变更类型 |
|
||||
|------|------|---------|
|
||||
| DTO | `internal/model/dto/agent_recharge_dto.go` | `CreateAgentRechargeRequest` + `AgentRechargeResponse` 新增字段 |
|
||||
| Model | `internal/model/agent_wallet.go` | `AgentRechargeRecord` 新增字段 |
|
||||
| Service | `internal/service/agent_recharge/service.go` | `Create` 方法新增校验与保存逻辑 |
|
||||
| DB | `migrations/` | 新增迁移文件 |
|
||||
| Handler | `internal/handler/admin/agent_recharge.go` | 无需改动(透传) |
|
||||
@@ -0,0 +1,43 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 创建线下充值订单时必须上传支付凭证
|
||||
|
||||
系统 SHALL 在 `POST /api/admin/agent-recharges` 接口中,当 `payment_method=offline` 时强制要求 `payment_voucher_key` 字段非空。凭证 key 为通过 `/storage/upload-url` 上传至对象存储后获得的文件标识符。当 `payment_method=wechat` 时,`payment_voucher_key` 字段忽略。
|
||||
|
||||
#### Scenario: 线下支付缺少凭证时拒绝创建
|
||||
|
||||
- **WHEN** 调用 `POST /api/admin/agent-recharges`,`payment_method=offline` 且 `payment_voucher_key` 为空或未传
|
||||
- **THEN** 系统返回 400,错误码 `CodeInvalidParam`,消息"线下充值必须上传支付凭证"
|
||||
|
||||
#### Scenario: 线下支付有凭证时创建成功
|
||||
|
||||
- **WHEN** 调用 `POST /api/admin/agent-recharges`,`payment_method=offline` 且 `payment_voucher_key` 非空
|
||||
- **THEN** 系统创建充值记录,凭证 key 写入 `tb_agent_recharge_record.payment_voucher_key`,返回 200
|
||||
|
||||
#### Scenario: 微信支付不需要凭证
|
||||
|
||||
- **WHEN** 调用 `POST /api/admin/agent-recharges`,`payment_method=wechat`,不传 `payment_voucher_key`
|
||||
- **THEN** 系统正常创建充值记录,不做凭证校验,返回 200
|
||||
|
||||
### Requirement: 充值记录存储支付凭证
|
||||
|
||||
系统 SHALL 将 `payment_voucher_key` 持久化到 `tb_agent_recharge_record` 表的 `payment_voucher_key` 列,与充值记录创建在同一个数据库操作内完成。
|
||||
|
||||
#### Scenario: 凭证随记录创建写入
|
||||
|
||||
- **WHEN** `Create` 方法执行创建充值记录
|
||||
- **THEN** `payment_voucher_key` 与其他字段一同写入数据库,不可分割
|
||||
|
||||
### Requirement: 充值记录响应包含凭证信息
|
||||
|
||||
系统 SHALL 在 `AgentRechargeResponse` 中返回 `payment_voucher_key` 字段,允许为空(历史记录及微信支付无凭证)。
|
||||
|
||||
#### Scenario: 已上传凭证的线下充值记录查询
|
||||
|
||||
- **WHEN** 查询已创建的线下充值记录
|
||||
- **THEN** 响应 `payment_voucher_key` 字段返回非空的对象存储 key
|
||||
|
||||
#### Scenario: 微信支付记录无凭证字段
|
||||
|
||||
- **WHEN** 查询微信支付的充值记录
|
||||
- **THEN** 响应 `payment_voucher_key` 字段为空字符串或 omitempty(前端不展示)
|
||||
@@ -0,0 +1,32 @@
|
||||
## 1. 数据库迁移
|
||||
|
||||
- [x] 1.1 在 `migrations/` 目录创建迁移文件,为 `tb_agent_recharge_record` 表添加 `payment_voucher_key VARCHAR(500)` 列(允许 NULL,兼容历史数据)
|
||||
- [x] 1.2 执行迁移,验证 `tb_agent_recharge_record` 表已包含 `payment_voucher_key` 列(使用 PostgreSQL MCP 查询 `information_schema.columns` 确认)
|
||||
|
||||
## 2. Model 层
|
||||
|
||||
- [x] 2.1 在 `internal/model/agent_wallet.go` 的 `AgentRechargeRecord` 结构体中添加 `PaymentVoucherKey string` 字段,包含 gorm tag 和中文注释
|
||||
- [x] 2.2 运行 `lsp_diagnostics` 确认 `agent_wallet.go` 无错误
|
||||
|
||||
## 3. DTO 层
|
||||
|
||||
- [x] 3.1 在 `internal/model/dto/agent_recharge_dto.go` 的 `CreateAgentRechargeRequest` 中添加 `PaymentVoucherKey` 字段(`validate:"omitempty,max=500"`,含 description 标签,注明 `payment_method=offline` 时必填)
|
||||
- [x] 3.2 在 `AgentRechargeResponse` 中添加 `PaymentVoucherKey` 响应字段(`omitempty`)
|
||||
- [x] 3.3 运行 `lsp_diagnostics` 确认 `agent_recharge_dto.go` 无错误
|
||||
|
||||
## 4. Service 层
|
||||
|
||||
- [x] 4.1 在 `internal/service/agent_recharge/service.go` 的 `Create` 方法中,在支付方式判断逻辑后添加凭证非空检查:当 `req.PaymentMethod == "offline"` 且 `strings.TrimSpace(req.PaymentVoucherKey) == ""` 时返回 `errors.New(errors.CodeInvalidParam, "线下充值必须上传支付凭证")`
|
||||
- [x] 4.2 在 `Create` 方法构建 `AgentRechargeRecord` 的赋值块中添加 `PaymentVoucherKey: strings.TrimSpace(req.PaymentVoucherKey)` 字段
|
||||
- [x] 4.3 在 `toResponse`(或等价的响应构建函数)中将 `record.PaymentVoucherKey` 映射到 `AgentRechargeResponse.PaymentVoucherKey`
|
||||
- [x] 4.4 运行 `lsp_diagnostics` 确认 `agent_recharge/service.go` 无错误
|
||||
|
||||
## 5. 文档生成器更新
|
||||
|
||||
- [x] 5.1 检查 `cmd/api/docs.go` 和 `cmd/gendocs/main.go` 中 `AgentRechargeHandler` 的注册方式(本次未新增 Handler,仅修改已有请求体,确认无需额外改动)
|
||||
|
||||
## 6. 接口验证
|
||||
|
||||
- [x] 6.1 调用 `POST /api/admin/agent-recharges`(`payment_method=offline`,不传凭证),验证返回 400"线下充值必须上传支付凭证"
|
||||
- [x] 6.2 调用 `POST /api/admin/agent-recharges`(`payment_method=offline`,传入合法凭证 key),验证返回 200,使用 PostgreSQL MCP 查询 `tb_agent_recharge_record` 确认 `payment_voucher_key` 字段已写入正确值
|
||||
- [x] 6.3 调用 `POST /api/admin/agent-recharges`(`payment_method=wechat`,不传凭证),验证正常创建成功,无凭证校验报错
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-04-17
|
||||
@@ -0,0 +1,86 @@
|
||||
## Context
|
||||
|
||||
系统存在一段注释掉的实名拦截代码(`REALNAME-03`,位于 `internal/service/client_order/service.go` 第 143 行),原逻辑为"普通卡(`card_category=normal`)需实名才能购买套餐,行业卡(`industry`)无需实名"。由于业务还未明确,该逻辑被整体注释,由网关侧临时托管。
|
||||
|
||||
现在业务明确了两种场景:**先实名后充值**(充值/购买前拦截未实名资产)与**先充值后实名**(充值/购买放行,实名链接在未充值前拦截)。且行业卡在特定业务下也可能需要实名,原来依赖 `card_category` 的硬编码逻辑不够灵活。
|
||||
|
||||
需要在资产层面(`IotCard` 和 `Device`)引入可配置的 `realname_policy` 字段,取代原有硬编码逻辑,支持导入时批量初始化策略、后台管理接口修改策略、所有查询接口返回策略字段、以及 C 端三接口按策略执行拦截。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- 在 `IotCard` 和 `Device` 模型上新增 `realname_policy` 字段(string 枚举)
|
||||
- IoT 卡导入和设备导入支持批量初始化 `realname_policy`
|
||||
- 后台新增 `PATCH /api/admin/assets/:identifier/realname-mode` 接口修改单资产策略
|
||||
- C 端充值(C3/C4)、购买套餐/订单(D1/D4)、实名链接(E1)按策略拦截
|
||||
- 所有涉及卡/设备/资产的查询接口返回 `realname_policy` 字段
|
||||
- 存量数据迁移默认值为 `none`,不影响现有行为
|
||||
|
||||
**Non-Goals:**
|
||||
- 不修改登录层的实名检查(已有机制,由网关托管)
|
||||
- 不实现用户通知(after_order 实名提醒等)
|
||||
- 不修改 Carrier 层的 `realname_link_type`(运营商提供实名链接的方式,是另一个维度)
|
||||
- 不实现按套餐粒度的实名策略(只到资产粒度)
|
||||
|
||||
## Decisions
|
||||
|
||||
**决策 1:`realname_policy` 使用 string 枚举而非 bool**
|
||||
|
||||
选 `string` 而非 `bool`(如 `realname_required`):业务存在三种状态(无需实名 / 先实名后充值 / 先充值后实名),bool 只能表达两种。枚举值:`none` / `before_order` / `after_order`。
|
||||
|
||||
**决策 2:设备卡场景以 Device.realname_policy 为准**
|
||||
|
||||
当一张卡绑定到设备时(设备卡),取 `Device.realname_policy`;未绑定设备时(单卡),取 `IotCard.realname_policy`。这与现有系统中"设备层面的验证规则高于卡层面"的设计一致(登录前的设备验证已体现此原则)。
|
||||
|
||||
判断逻辑封装在 Service 层公共方法 `GetEffectiveRealnamePolicy(card *model.IotCard, device *model.Device) string`,避免在多个 Service 中重复实现。
|
||||
|
||||
**决策 3:after_order 场景下实名链接的前置条件**
|
||||
|
||||
`after_order` 模式下,用户调用 `GET /api/c/v1/realname/link` 时,系统检查该资产是否存在有效充值或已支付订单记录(`status=2` 的订单或 `status=2` 的充值单)。无记录则拦截;有记录则放行。
|
||||
|
||||
逻辑:先充值才有资格实名,避免用户跳过充值直接实名。检查范围:该资产当前 generation 内的充值记录或套餐订单记录。
|
||||
|
||||
**决策 4:后台更新接口复用现有资产解析器**
|
||||
|
||||
遵循 `UpdatePollingStatus` 的模式(`PATCH /api/admin/assets/:identifier/polling-status`):
|
||||
- 接口路径:`PATCH /api/admin/assets/:identifier/realname-mode`
|
||||
- 通过 `assetService.Resolve()` 将标识符解析为 asset_type + asset_id
|
||||
- asset_type=card → 更新 `IotCard.realname_policy`
|
||||
- asset_type=device → 更新 `Device.realname_policy`
|
||||
- 单接口覆盖两种资产类型,与现有模式完全一致
|
||||
|
||||
**决策 5:存量数据默认值为 `none`**
|
||||
|
||||
迁移时所有存量卡和设备的 `realname_policy` 默认值为 `none`(无需实名),保持与现有行为完全一致(目前实名拦截已被注释,等于放行)。运营方后续按需批量更新。
|
||||
|
||||
**决策 6:命名区分**
|
||||
|
||||
`RealnamePolicy`(资产的实名策略:何时需要实名)与现有 `RealnameLinkType`(运营商提供实名链接的方式:none/template/gateway)是两个独立维度,字段名不同,不产生混淆。DTO 中 response 字段名为 `realname_policy`。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
**[风险 1] after_order 拦截查询增加数据库压力**
|
||||
→ 缓解:查询只取 `COUNT(*) > 0`,命中索引(generation + asset_id + status),可加 Redis 缓存(key 为 `realname:after_order_eligible:{asset_type}:{asset_id}:{generation}`,充值/购买成功时主动写入,TTL 24h)。初期数据量小,可不加缓存,待观察后决定。
|
||||
|
||||
**[风险 2] DTO 改动面大,遗漏字段**
|
||||
→ 缓解:所有涉及卡/设备/资产的 DTO 已在探索阶段完整盘点,tasks.md 按文件逐一列举,不依赖记忆。
|
||||
|
||||
**[风险 3] 存量数据全为 `none`,初始化后无法区分"未配置"与"主动设为 none"**
|
||||
→ 可接受:`none` 本身是有效业务值(无需实名),与未配置语义相同,不需要额外区分。
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. 创建数据库迁移文件,为四张表新增 `realname_policy` 字段:
|
||||
```sql
|
||||
ALTER TABLE tb_iot_card ADD COLUMN realname_policy VARCHAR(20) NOT NULL DEFAULT 'none';
|
||||
ALTER TABLE tb_device ADD COLUMN realname_policy VARCHAR(20) NOT NULL DEFAULT 'none';
|
||||
ALTER TABLE tb_iot_card_import_task ADD COLUMN realname_policy VARCHAR(20) NOT NULL DEFAULT 'none';
|
||||
ALTER TABLE tb_device_import_task ADD COLUMN realname_policy VARCHAR(20) NOT NULL DEFAULT 'none';
|
||||
```
|
||||
2. 迁移前:REALNAME-03 注释代码继续保持注释(不影响线上)
|
||||
3. 迁移后:代码上线,实名拦截以新字段为准;存量全为 `none`,等于放行,行为不变
|
||||
4. 回滚策略:字段加 DEFAULT,删除新增字段即可,代码回滚到上一版本
|
||||
|
||||
## Open Questions
|
||||
|
||||
无(所有关键问题已在探索阶段与业务方确认)
|
||||
@@ -0,0 +1,48 @@
|
||||
## Why
|
||||
|
||||
现有系统对实名认证的要求硬编码在卡业务类型(`card_category`)上,且当前拦截逻辑已被注释(`REALNAME-03`),由网关侧临时托管。业务侧实际存在两种截然不同的场景:**先实名后充值/购买**(实名是充值的前置条件)与**先充值/购买后实名**(充值不受限,实名在事后完成),且行业卡在特定业务下也需要实名。需要在资产(卡和设备)层面引入可配置的实名策略字段,彻底替代原有的硬编码逻辑,支持灵活的运营策略配置。
|
||||
|
||||
## What Changes
|
||||
|
||||
- **新增 `realname_policy` 字段**(`IotCard` 和 `Device` 模型),枚举值:
|
||||
- `none`:无需实名
|
||||
- `before_order`:先实名后充值/购买(充值/购买前拦截未实名资产)
|
||||
- `after_order`:先充值/购买后实名(充值/购买放行,实名链接在有订单前拦截)
|
||||
- **生效优先级**:卡绑定设备时(设备卡)使用 `Device.realname_policy`,单卡使用 `IotCard.realname_policy`
|
||||
- **C 端拦截**:充值(C3/C4)、购买套餐/订单(D1/D4)按策略决定是否拦截;实名链接(E1)在 `after_order` 模式下要求先有充值/订单记录才能获取链接
|
||||
- **IoT 卡导入** 支持在导入时批量设置 `realname_policy`,并写入每张卡
|
||||
- **设备导入** 支持在导入时设置 `realname_policy`,并写入每台设备
|
||||
- **后台管理** 新增接口 `PATCH /api/admin/assets/:identifier/realname-mode` 支持逐资产修改策略
|
||||
- **所有涉及卡/设备/资产的查询接口**(后台管理端 + C 端)返回 `realname_policy` 字段,便于前端展示和逻辑判断
|
||||
- **废弃** `REALNAME-03` 注释代码,由新的策略逻辑替代
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `asset-realname-policy`:资产实名认证策略——定义策略枚举、生效规则(单卡 vs 设备卡优先级)、C 端三接口拦截逻辑、后台管理更新接口、以及所有查询接口返回该字段的规范
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `iot-card-import-task`:IoT 卡导入请求新增 `realname_policy` 参数,导入任务模型记录该字段,批量初始化卡的策略值
|
||||
- `device-import`:设备导入请求新增 `realname_policy` 参数,导入任务模型记录该字段,批量初始化设备的策略值
|
||||
- `client-realname-link`:E1 接口在 `after_order` 模式下新增"是否有有效充值/订单"的前置检查;`none` 模式正常返回链接
|
||||
- `client-order-purchase`:D1/D4 接口启用实名策略拦截(替代 REALNAME-03 注释逻辑),`before_order` + 未实名 → 返回 `CodeNeedRealname`
|
||||
- `client-wallet-recharge`:C3/C4 接口新增实名策略拦截,`before_order` + 未实名 → 返回 `CodeNeedRealname`
|
||||
|
||||
## Impact
|
||||
|
||||
**数据库**:`tb_iot_card`、`tb_device`、`tb_iot_card_import_task`、`tb_device_import_task` 各新增 `realname_policy` 字段,迁移默认值 `none`(存量数据不影响现有行为)
|
||||
|
||||
**Model 层**:`internal/model/iot_card.go`、`internal/model/device.go`、`internal/model/iot_card_import_task.go`、`internal/model/device_import_task.go`
|
||||
|
||||
**DTO 层**(新增 `realname_policy` 字段):
|
||||
- 后台管理:`StandaloneIotCardResponse`、`DeviceResponse`、`AssetResolveResponse`、`AssetRealtimeStatusResponse`、`DeviceCardBindingResponse`、`BoundCardInfo`、`ImportTaskResponse`(iot)、`DeviceImportTaskResponse`
|
||||
- C 端:`AssetInfoResponse`、`BoundCardInfo`(C端)、`DeviceCardItem`
|
||||
- 导入请求:`ImportIotCardRequest`、`ImportDeviceRequest`
|
||||
|
||||
**Service 层**:`client_order`、`client_wallet`、`client_realname` 新增策略检查逻辑;`iot_card`、`device` 新增 `UpdateRealnamePolicy` 方法;`iot_card_import`、`device_import` 传播策略字段至各资产记录
|
||||
|
||||
**常量**:`pkg/constants/iot.go`(或新文件)新增 `RealnamePolicy*` 枚举常量
|
||||
|
||||
**路由 + 文档生成器**:新增 1 个后台管理接口,需同步更新 `cmd/api/docs.go` 和 `cmd/gendocs/main.go`
|
||||
@@ -0,0 +1,169 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 资产实名策略字段定义
|
||||
|
||||
系统 SHALL 在 `IotCard` 和 `Device` 模型上各新增 `realname_policy` 字段(VARCHAR(20),NOT NULL,DEFAULT 'none'),用于控制该资产的实名认证要求。
|
||||
|
||||
**枚举值**:
|
||||
- `none`:无需实名,充值/购买/实名链接均不受限
|
||||
- `before_order`:先实名后充值/购买,充值/购买前若未实名则拦截并返回 `CodeNeedRealname`
|
||||
- `after_order`:先充值/购买后实名,充值/购买放行;实名链接在无有效充值/订单记录前拦截
|
||||
|
||||
**常量定义**(`pkg/constants/iot.go` 或 `pkg/constants/realname.go`):
|
||||
```go
|
||||
const (
|
||||
RealnmePolicyNone = "none" // 无需实名
|
||||
RealnmePolicyBeforeOrder = "before_order" // 先实名后充值/购买
|
||||
RealnmePolicyAfterOrder = "after_order" // 先充值/购买后实名
|
||||
)
|
||||
```
|
||||
|
||||
#### Scenario: 默认值为 none
|
||||
- **WHEN** 新建 IotCard 或 Device 时未传入 realname_policy
|
||||
- **THEN** 系统自动填充 `realname_policy = "none"`
|
||||
|
||||
#### Scenario: 存量数据迁移后行为不变
|
||||
- **WHEN** 数据库迁移执行后,存量 IotCard 和 Device 的 realname_policy 均为 "none"
|
||||
- **THEN** 充值、购买、实名链接行为与迁移前完全相同(均放行)
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 生效策略优先级(设备卡 vs 单卡)
|
||||
|
||||
系统 SHALL 按以下规则确定一张卡的生效实名策略:
|
||||
|
||||
- **单卡**(该卡未绑定任何设备):使用 `IotCard.realname_policy`
|
||||
- **设备卡**(该卡已绑定设备):使用 `Device.realname_policy`,忽略 `IotCard.realname_policy`
|
||||
|
||||
此规则 SHALL 封装为 Service 层公共方法 `GetEffectiveRealnamePolicy`,所有需要策略判断的场景均调用该方法,不得在多处重复实现。
|
||||
|
||||
#### Scenario: 设备卡使用设备策略
|
||||
- **WHEN** IotCard.realname_policy="none" 且该卡绑定了 Device.realname_policy="before_order"
|
||||
- **THEN** 生效策略为 "before_order"(设备策略覆盖卡策略)
|
||||
|
||||
#### Scenario: 单卡使用卡策略
|
||||
- **WHEN** IotCard.realname_policy="before_order" 且该卡未绑定任何设备
|
||||
- **THEN** 生效策略为 "before_order"
|
||||
|
||||
---
|
||||
|
||||
### Requirement: C 端充值接口实名策略拦截
|
||||
|
||||
系统 SHALL 在 C 端充值预检接口(C3 `GET /api/c/v1/wallet/recharge-check`)和充值下单接口(C4 `POST /api/c/v1/wallet/recharge`)中,按生效实名策略执行以下逻辑:
|
||||
|
||||
- `before_order` + `real_name_status=0`(未实名):MUST 拦截,返回 `CodeNeedRealname`(错误码 1187)
|
||||
- `before_order` + `real_name_status=1`(已实名):放行
|
||||
- `after_order`:放行(不检查实名状态)
|
||||
- `none`:放行
|
||||
|
||||
#### Scenario: before_order 模式未实名时充值被拦截
|
||||
- **WHEN** 资产 realname_policy="before_order" 且 real_name_status=0,用户调用充值下单接口
|
||||
- **THEN** 系统返回错误码 1187(CodeNeedRealname)
|
||||
|
||||
#### Scenario: before_order 模式已实名时充值放行
|
||||
- **WHEN** 资产 realname_policy="before_order" 且 real_name_status=1,用户调用充值下单接口
|
||||
- **THEN** 系统正常创建充值订单
|
||||
|
||||
#### Scenario: after_order 模式充值放行
|
||||
- **WHEN** 资产 realname_policy="after_order" 且 real_name_status=0,用户调用充值下单接口
|
||||
- **THEN** 系统正常创建充值订单,不检查实名状态
|
||||
|
||||
---
|
||||
|
||||
### Requirement: C 端购买套餐/订单接口实名策略拦截
|
||||
|
||||
系统 SHALL 在 C 端创建订单接口(D1 `POST /api/c/v1/orders/create`)和支付接口(D4 `POST /api/c/v1/orders/:id/pay`)中,按生效实名策略执行与充值相同的拦截规则。D1 中原 `REALNAME-03` 注释代码 SHALL 被删除,由新策略逻辑替代。
|
||||
|
||||
#### Scenario: before_order 模式未实名时购买套餐被拦截
|
||||
- **WHEN** 资产 realname_policy="before_order" 且 real_name_status=0,用户调用创建订单接口
|
||||
- **THEN** 系统返回错误码 1187(CodeNeedRealname),订单不创建
|
||||
|
||||
#### Scenario: none 模式购买套餐放行
|
||||
- **WHEN** 资产 realname_policy="none",用户调用创建订单接口
|
||||
- **THEN** 系统正常创建订单,不检查实名状态
|
||||
|
||||
---
|
||||
|
||||
### Requirement: C 端实名链接接口策略拦截
|
||||
|
||||
系统 SHALL 在 C 端实名链接接口(E1 `GET /api/c/v1/realname/link`)中,按生效实名策略执行以下逻辑:
|
||||
|
||||
- `none`:放行,正常返回实名链接(用户可自愿实名)
|
||||
- `before_order`:放行,正常返回实名链接(引导用户先实名再充值)
|
||||
- `after_order`:检查该资产当前 generation 内是否存在有效充值(`status=2`)或已支付订单(`payment_status=2`)
|
||||
- 有记录 → 放行
|
||||
- 无记录 → 拦截,返回错误码(新错误码 `CodeRealnameNotAvailable`),消息为"请先完成充值或购买套餐后再进行实名认证"
|
||||
|
||||
#### Scenario: after_order 模式有充值记录时实名链接放行
|
||||
- **WHEN** 资产 realname_policy="after_order" 且当前 generation 内存在 status=2 的充值记录
|
||||
- **THEN** 系统正常返回实名链接
|
||||
|
||||
#### Scenario: after_order 模式无充值/订单记录时实名链接被拦截
|
||||
- **WHEN** 资产 realname_policy="after_order" 且当前 generation 内无任何有效充值或已支付订单
|
||||
- **THEN** 系统返回错误,消息为"请先完成充值或购买套餐后再进行实名认证"
|
||||
|
||||
#### Scenario: before_order 模式正常返回实名链接
|
||||
- **WHEN** 资产 realname_policy="before_order"
|
||||
- **THEN** 系统正常返回实名链接(引导用户完成实名)
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 后台管理更新资产实名策略接口
|
||||
|
||||
系统 SHALL 提供 `PATCH /api/admin/assets/:identifier/realname-mode`,仅限后台管理端认证用户访问。接口通过 `assetService.Resolve()` 将标识符(ICCID/虚拟号)解析为具体资产,按 asset_type 分别更新 `IotCard.realname_policy` 或 `Device.realname_policy`。
|
||||
|
||||
**请求体**:
|
||||
```json
|
||||
{ "realname_policy": "none | before_order | after_order" }
|
||||
```
|
||||
|
||||
**响应体**:
|
||||
```json
|
||||
{ "asset_type": "card | device", "asset_id": 123, "realname_policy": "before_order" }
|
||||
```
|
||||
|
||||
#### Scenario: 通过 ICCID 更新单卡策略
|
||||
- **WHEN** 管理员传入 identifier=ICCID,realname_policy="before_order"
|
||||
- **THEN** 系统更新对应 IotCard 的 realname_policy 为 "before_order",返回 asset_type="card"
|
||||
|
||||
#### Scenario: 通过设备号更新设备策略
|
||||
- **WHEN** 管理员传入 identifier=设备虚拟号,realname_policy="after_order"
|
||||
- **THEN** 系统更新对应 Device 的 realname_policy 为 "after_order",返回 asset_type="device"
|
||||
|
||||
#### Scenario: 传入无效枚举值被拒绝
|
||||
- **WHEN** 管理员传入 realname_policy="invalid_value"
|
||||
- **THEN** 系统返回参数校验错误,提示实名策略值无效
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 所有查询接口返回 realname_policy 字段
|
||||
|
||||
以下所有 DTO SHALL 新增 `realname_policy` 字段(string)及对应的 description 标签:
|
||||
|
||||
**后台管理端 DTO**:
|
||||
- `StandaloneIotCardResponse`(IoT 卡详情/列表)
|
||||
- `DeviceResponse`(设备详情/列表)
|
||||
- `AssetResolveResponse`(统一资产解析)
|
||||
- `AssetRealtimeStatusResponse`(资产实时状态)
|
||||
- `DeviceCardBindingResponse`(设备绑卡记录)
|
||||
- `BoundCardInfo`(asset_dto.go 中的通用子结构)
|
||||
- `ImportTaskResponse`(iot 卡导入任务)
|
||||
- `DeviceImportTaskResponse`(设备导入任务)
|
||||
|
||||
**C 端 DTO**:
|
||||
- `AssetInfoResponse`(B1 资产信息)
|
||||
- `BoundCardInfo`(client_asset_dto.go 中的 C 端子结构)
|
||||
- `DeviceCardItem`(F1 设备卡列表项)
|
||||
|
||||
**description 标签统一格式**:
|
||||
```go
|
||||
RealnamePolicy string `json:"realname_policy" description:"实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名)"`
|
||||
```
|
||||
|
||||
#### Scenario: 资产详情接口返回 realname_policy
|
||||
- **WHEN** 后台管理员查询 IoT 卡详情或资产解析
|
||||
- **THEN** 响应中包含 realname_policy 字段
|
||||
|
||||
#### Scenario: C 端资产信息接口返回 realname_policy
|
||||
- **WHEN** C 端用户调用 GET /api/c/v1/asset/info
|
||||
- **THEN** 响应中包含 realname_policy 字段(前端可据此展示提示)
|
||||
@@ -0,0 +1,60 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: D1 创建套餐购买订单接口
|
||||
|
||||
系统 SHALL 提供 `POST /api/c/v1/orders/create`,并且 MUST 要求个人客户认证。**[BREAKING]** 请求体改为使用资产标识符(identifier),废弃 `iot_card_id`/`device_id` 主键字段和 `order_type` 字段。请求体 MUST 包含 `identifier`、`package_ids[]`、`payment_method`。接口流程 MUST 按顺序执行:归属校验 → 套餐校验(含加油包前置)→ **实名策略检查(新增)** → OpenID 查询 → 幂等检查 → 强充检查 → 分流创建。
|
||||
|
||||
**实名策略检查(新增,替代 REALNAME-03 注释代码)**:
|
||||
- 获取生效实名策略(`GetEffectiveRealnamePolicy`)
|
||||
- 生效策略为 `before_order` 且 `real_name_status=0`(未实名):MUST 返回 `CodeNeedRealname`(错误码 1187),消息"该套餐需实名认证后购买",订单不创建
|
||||
- 生效策略为 `after_order` 或 `none`:跳过实名检查,继续后续流程
|
||||
|
||||
原 `REALNAME-03` 注释代码 SHALL 被删除,由此策略检查替代。
|
||||
|
||||
实名不满足时 MUST 返回 `NEED_REALNAME`。OpenID 缺失时 MUST 返回 `OPENID_NOT_FOUND`。幂等 MUST 使用 Redis 业务键 + 分布式锁。分流规则 MUST 为:
|
||||
- 无强充:创建套餐订单并返回 `order_type="package"`、`order`、`pay_config`
|
||||
- 需强充:创建充值单并返回 `order_type="recharge"`、`recharge`、`pay_config`、`linked_package_info`
|
||||
|
||||
**客户端订单创建请求(CreateOrderRequest)**:
|
||||
```json
|
||||
{
|
||||
"identifier": "string(资产标识符,ICCID 或 VirtualNo,必填,1-50字符)",
|
||||
"package_ids": "[uint](套餐 ID 列表,必填,1-10 个)",
|
||||
"payment_method": "string(wallet|wechat|alipay,必填)"
|
||||
}
|
||||
```
|
||||
|
||||
**废弃字段**:
|
||||
- `iot_card_id`(原单卡购买时必填)
|
||||
- `device_id`(原设备购买时必填)
|
||||
- `order_type`(改为系统根据 identifier 解析结果自动填入)
|
||||
|
||||
响应体 MUST 包含前端可直接渲染字段。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误`、`FORBIDDEN/无权限操作该资产或资源不存在`、`NEED_REALNAME/该套餐需实名认证后购买`、`OPENID_NOT_FOUND/未找到微信授权信息,请先完成授权`、`IDEMPOTENT_CONFLICT/请求处理中,请勿重复提交`、`PACKAGE_NOT_AVAILABLE/套餐不可购买`。
|
||||
|
||||
#### Scenario: 命中强充返回 recharge 结构
|
||||
- **WHEN** 客户购买套餐触发强充要求
|
||||
- **THEN** 系统返回 `order_type="recharge"`,包含充值单与关联套餐信息
|
||||
|
||||
#### Scenario: 个人客户使用 VirtualNo 购买设备套餐
|
||||
- **WHEN** 个人客户发送 `{ identifier: "DEV-001", package_ids: [5], payment_method: "wechat" }`
|
||||
- **THEN** 系统解析 identifier 为设备,创建设备购买订单,微信支付流程正常触发
|
||||
|
||||
#### Scenario: 个人客户使用 ICCID 购买单卡套餐
|
||||
- **WHEN** 个人客户发送 `{ identifier: "898600XXXXX", package_ids: [3], payment_method: "wallet" }`
|
||||
- **THEN** 系统解析为独立 IoT 卡(`is_standalone = true`),创建单卡购买订单
|
||||
|
||||
#### Scenario: 个人客户尝试为绑定设备的卡购买套餐
|
||||
- **WHEN** 个人客户发送 identifier 对应一张 `is_standalone = false` 的卡
|
||||
- **THEN** 系统返回错误"该卡已绑定设备,请前往设备页面购买套餐"
|
||||
|
||||
#### Scenario: 个人客户只能操作自己绑定的资产
|
||||
- **WHEN** 个人客户发送的 identifier 对应的资产不属于该客户
|
||||
- **THEN** 系统返回 HTTP 403"无权限操作该资源"
|
||||
|
||||
#### Scenario: before_order 策略未实名时购买被拦截
|
||||
- **WHEN** 生效策略为 before_order 且 real_name_status=0,用户调用创建订单接口
|
||||
- **THEN** 系统返回 CodeNeedRealname(1187),订单不创建
|
||||
|
||||
#### Scenario: after_order 策略购买放行
|
||||
- **WHEN** 生效策略为 after_order 且 real_name_status=0,用户调用创建订单接口
|
||||
- **THEN** 系统正常创建订单,不检查实名状态
|
||||
@@ -0,0 +1,45 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: E1 获取实名跳转链接接口
|
||||
|
||||
系统 SHALL 提供 `GET /api/c/v1/realname/link?identifier=xxx&iccid=xxx`,并且 MUST 要求个人客户认证。该接口 MUST 支持两类入口:购买拦截入口与设备卡列表主动入口。目标卡定位 MUST 支持三种路径:
|
||||
|
||||
1. 标识符直达卡:直接使用该卡
|
||||
2. 标识符为设备且传 `iccid`:定位对应设备下卡
|
||||
3. 标识符为设备且未传 `iccid`:定位设备当前活跃卡
|
||||
|
||||
接口在确定目标卡后,MUST 获取生效实名策略(`GetEffectiveRealnamePolicy`),按以下顺序执行前置检查:
|
||||
|
||||
**实名策略检查**(新增,在实名状态检查之前执行):
|
||||
- 生效策略为 `after_order`:检查该资产当前 generation 内是否存在有效充值(`status=2`)或已支付订单(`payment_status=2`)
|
||||
- 无记录 → MUST 返回 `CodeRealnameNotAvailable`,消息"请先完成充值或购买套餐后再进行实名认证"
|
||||
- 有记录 → 继续执行后续检查
|
||||
- 生效策略为 `none` 或 `before_order`:不执行此检查,继续执行后续检查
|
||||
|
||||
**原有检查保留**:
|
||||
- 当 `real_name_status=1` 时 MUST 返回"该卡已完成实名"错误
|
||||
- 运营商实名模式 MUST 支持:`none`(不支持在线实名)、`template`(模板替换)、`gateway`(调用网关)
|
||||
|
||||
响应体 SHALL 至少包含 `realname_mode`(运营商链接类型)、`realname_url`、`card_info{iccid,msisdn,virtual_no}`、`expire_at`(可空)。
|
||||
|
||||
错误码/消息 MUST 至少包含(新增 `REALNAME_NOT_AVAILABLE`):`INVALID_PARAM/参数错误`、`FORBIDDEN/无权限操作该资产或资源不存在`、`REALNAME_ALREADY_DONE/该卡已完成实名`、`REALNAME_NOT_SUPPORTED/该运营商暂不支持在线实名`、`GATEWAY_ERROR/获取实名链接失败`、`REALNAME_NOT_AVAILABLE/请先完成充值或购买套餐后再进行实名认证`。
|
||||
|
||||
#### Scenario: 设备未传 iccid 自动选活跃卡
|
||||
- **WHEN** 客户传入设备标识符且不传 `iccid`
|
||||
- **THEN** 系统自动选择设备活跃卡并返回实名跳转链接
|
||||
|
||||
#### Scenario: after_order 模式有充值记录时放行
|
||||
- **WHEN** 生效策略为 after_order 且当前 generation 内存在 status=2 的充值记录
|
||||
- **THEN** 系统正常返回实名链接
|
||||
|
||||
#### Scenario: after_order 模式无记录时拦截
|
||||
- **WHEN** 生效策略为 after_order 且当前 generation 内无任何有效充值或已支付订单
|
||||
- **THEN** 系统返回错误码 CodeRealnameNotAvailable,消息"请先完成充值或购买套餐后再进行实名认证"
|
||||
|
||||
#### Scenario: before_order 模式正常返回链接
|
||||
- **WHEN** 生效策略为 before_order 且该卡尚未实名
|
||||
- **THEN** 系统正常返回实名链接(引导用户先实名)
|
||||
|
||||
#### Scenario: none 模式正常返回链接
|
||||
- **WHEN** 生效策略为 none
|
||||
- **THEN** 系统正常返回实名链接(无策略限制,允许用户自愿实名)
|
||||
@@ -0,0 +1,43 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: C3 充值预检接口
|
||||
|
||||
系统 SHALL 提供 `GET /api/c/v1/wallet/recharge-check?identifier=xxx`,并且 MUST 要求个人客户认证。接口 MUST 在资产解析与归属校验后,**新增实名策略检查**:
|
||||
|
||||
- 获取生效实名策略(`GetEffectiveRealnamePolicy`)
|
||||
- 生效策略为 `before_order` 且 `real_name_status=0`:MUST 返回 `CodeNeedRealname`
|
||||
- 生效策略为 `after_order` 或 `none`:跳过实名检查,继续执行强充规则计算
|
||||
|
||||
通过实名检查后,接口 MUST 复用 `recharge.Service.GetRechargeCheck()` 计算强充规则。响应体 SHALL 包含 `need_force_recharge`、`force_recharge_amount`、`trigger_type`、`min_amount`、`max_amount`、`message`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误`、`FORBIDDEN/无权限操作该资产或资源不存在`、`NEED_REALNAME/该套餐需实名认证后充值`。
|
||||
|
||||
#### Scenario: 返回强充预检结果
|
||||
- **WHEN** 资产命中强充规则
|
||||
- **THEN** 系统返回 `need_force_recharge=true` 与对应强充金额和触发类型
|
||||
|
||||
#### Scenario: before_order 未实名时充值预检被拦截
|
||||
- **WHEN** 生效策略为 before_order 且 real_name_status=0
|
||||
- **THEN** 系统返回 CodeNeedRealname,不返回强充预检结果
|
||||
|
||||
---
|
||||
|
||||
### Requirement: C4 创建充值订单接口
|
||||
|
||||
系统 SHALL 提供 `POST /api/c/v1/wallet/recharge`,并且 MUST 要求个人客户认证。请求体 MUST 包含:`identifier`、`amount`(100~10000000 分)、`payment_method=wechat`、`app_type`。
|
||||
|
||||
接口 MUST 在归属校验后、OpenID 查询前,**新增实名策略检查**:
|
||||
- 生效策略为 `before_order` 且 `real_name_status=0`:MUST 返回 `CodeNeedRealname`,充值订单不创建
|
||||
- 生效策略为 `after_order` 或 `none`:继续执行后续流程
|
||||
|
||||
接口 MUST 禁止客户端传入 OpenID,并由后端按 `customer_id + app_type` 查询 OpenID。订单创建时 MUST 写入:`operator_type=personal_customer` 与资产当前 `generation` 快照。响应体 SHALL 返回 `recharge` 与 `pay_config`,其中 `recharge` 至少含 `recharge_id`、`recharge_no`、`amount`、`status`,`pay_config` 为微信 JSAPI 拉起参数。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误`、`OPENID_NOT_FOUND/未找到微信授权信息,请先完成授权`、`FORBIDDEN/无权限操作该资产或资源不存在`、`PAYMENT_NOT_SUPPORTED/仅支持微信支付`、`NEED_REALNAME/该套餐需实名认证后充值`。
|
||||
|
||||
#### Scenario: 后端查 OpenID 并返回支付参数
|
||||
- **WHEN** 客户传入合法参数且后端成功查询到 OpenID
|
||||
- **THEN** 系统创建充值单并返回 `recharge + pay_config`
|
||||
|
||||
#### Scenario: before_order 未实名时充值订单创建被拦截
|
||||
- **WHEN** 生效策略为 before_order 且 real_name_status=0,用户调用充值下单接口
|
||||
- **THEN** 系统返回 CodeNeedRealname(1187),充值单不创建
|
||||
|
||||
#### Scenario: after_order 模式充值放行
|
||||
- **WHEN** 生效策略为 after_order 且 real_name_status=0,用户调用充值下单接口
|
||||
- **THEN** 系统正常创建充值单,不检查实名状态
|
||||
@@ -0,0 +1,31 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 设备导入批次支持实名策略配置
|
||||
|
||||
系统 SHALL 在设备导入请求(`ImportDeviceRequest`)中支持 `realname_policy` 参数,以批次为单位指定导入设备的实名策略,默认为 `none`。
|
||||
|
||||
**请求字段新增**:
|
||||
- `realname_policy`:实名认证策略(枚举:`none` / `before_order` / `after_order`,可选,默认 `none`)
|
||||
|
||||
**任务字段新增**:
|
||||
- `DeviceImportTask` 新增 `realname_policy` 字段(VARCHAR(20) NOT NULL DEFAULT 'none')
|
||||
|
||||
**导入行为**:
|
||||
- 该批次导入的所有设备统一使用 `realname_policy` 的值写入 `Device.realname_policy`,不支持同一批次混用
|
||||
- 导入任务记录该字段用于审计
|
||||
|
||||
#### Scenario: 不传 realname_policy 时默认为 none
|
||||
- **WHEN** 设备导入请求未包含 `realname_policy` 字段
|
||||
- **THEN** 导入的所有设备 `realname_policy` 为 `none`
|
||||
|
||||
#### Scenario: 指定 before_order 导入设备
|
||||
- **WHEN** 设备导入请求中 `realname_policy` = `before_order`
|
||||
- **THEN** 该批次导入的所有设备 `realname_policy` 均为 `before_order`
|
||||
|
||||
#### Scenario: 传入无效 realname_policy 被拒绝
|
||||
- **WHEN** 设备导入请求中 `realname_policy` = `unknown`
|
||||
- **THEN** 系统返回参数校验错误,提示实名策略值无效
|
||||
|
||||
#### Scenario: 设备导入任务响应返回 realname_policy
|
||||
- **WHEN** 管理员查询设备导入任务列表或详情
|
||||
- **THEN** 响应中包含 `realname_policy` 字段
|
||||
@@ -0,0 +1,31 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 导入批次支持实名策略配置
|
||||
|
||||
系统 SHALL 在 IoT 卡导入请求(`ImportIotCardRequest`)中支持 `realname_policy` 参数,以批次为单位指定导入卡的实名策略,默认为 `none`。
|
||||
|
||||
**请求字段新增**:
|
||||
- `realname_policy`:实名认证策略(枚举:`none` / `before_order` / `after_order`,可选,默认 `none`)
|
||||
|
||||
**任务字段新增**:
|
||||
- `IotCardImportTask` 新增 `realname_policy` 字段(VARCHAR(20) NOT NULL DEFAULT 'none')
|
||||
|
||||
**导入行为**:
|
||||
- 该批次导入的所有卡统一使用 `realname_policy` 的值写入 `IotCard.realname_policy`,不支持同一批次混用
|
||||
- 导入任务记录该字段用于审计
|
||||
|
||||
#### Scenario: 不传 realname_policy 时默认为 none
|
||||
- **WHEN** 导入请求未包含 `realname_policy` 字段
|
||||
- **THEN** 导入的所有卡 `realname_policy` 为 `none`,导入任务 `realname_policy` 为 `none`
|
||||
|
||||
#### Scenario: 指定 before_order 导入
|
||||
- **WHEN** 导入请求中 `realname_policy` = `before_order`
|
||||
- **THEN** 该批次导入的所有卡 `realname_policy` 均为 `before_order`
|
||||
|
||||
#### Scenario: 传入无效 realname_policy
|
||||
- **WHEN** 导入请求中 `realname_policy` = `unknown`(非枚举值)
|
||||
- **THEN** 系统返回参数校验错误,提示实名策略值无效
|
||||
|
||||
#### Scenario: 导入任务响应返回 realname_policy
|
||||
- **WHEN** 管理员查询导入任务详情或列表
|
||||
- **THEN** 响应中包含 `realname_policy` 字段,与创建时传入值一致
|
||||
@@ -0,0 +1,83 @@
|
||||
## 1. 基础设施(常量 + 迁移 + Model)
|
||||
|
||||
- [x] 1.1 在 `pkg/constants/` 中定义 `RealnamePolicy` 枚举常量(none / before_order / after_order)及新增错误码 `CodeRealnameNotAvailable`(`pkg/errors/codes.go`)
|
||||
- [x] 1.2 创建数据库迁移文件,为 `tb_iot_card` 新增 `realname_policy VARCHAR(20) NOT NULL DEFAULT 'none'`
|
||||
- [x] 1.3 创建数据库迁移文件,为 `tb_device` 新增 `realname_policy VARCHAR(20) NOT NULL DEFAULT 'none'`
|
||||
- [x] 1.4 创建数据库迁移文件,为 `tb_iot_card_import_task` 新增 `realname_policy VARCHAR(20) NOT NULL DEFAULT 'none'`
|
||||
- [x] 1.5 创建数据库迁移文件,为 `tb_device_import_task` 新增 `realname_policy VARCHAR(20) NOT NULL DEFAULT 'none'`
|
||||
- [x] 1.6 在 `internal/model/iot_card.go` 的 `IotCard` 结构体新增 `RealnamePolicy` 字段(含 gorm 标签和中文注释)
|
||||
- [x] 1.7 在 `internal/model/device.go` 的 `Device` 结构体新增 `RealnamePolicy` 字段(含 gorm 标签和中文注释)
|
||||
- [x] 1.8 在 `internal/model/iot_card_import_task.go` 的 `IotCardImportTask` 结构体新增 `RealnamePolicy` 字段
|
||||
- [x] 1.9 在 `internal/model/device_import_task.go` 的 `DeviceImportTask` 结构体新增 `RealnamePolicy` 字段
|
||||
- [x] 1.10 执行迁移并验证四张表字段已正确添加
|
||||
|
||||
## 2. 公共策略判断逻辑(Service 层)
|
||||
|
||||
- [x] 2.1 在合适的公共 Service 或工具包中实现 `GetEffectiveRealnamePolicy(card *model.IotCard, device *model.Device) string`:设备卡取 device.RealnamePolicy,单卡取 card.RealnamePolicy
|
||||
- [x] 2.2 实现检查 `after_order` 生效条件的方法:查询该资产当前 generation 内是否存在有效充值(status=2)或已支付订单(payment_status=2),返回 bool
|
||||
|
||||
## 3. IoT 卡导入模块改造
|
||||
|
||||
- [x] 3.1 在 `ImportIotCardRequest` DTO 中新增 `realname_policy` 字段(含 validate、description 标签)
|
||||
- [x] 3.2 更新 `iot_card_import` Service 的 CreateImportTask 方法:将请求中的 `realname_policy` 保存到 `IotCardImportTask.realname_policy`
|
||||
- [x] 3.3 更新 IoT 卡导入 Worker 处理逻辑:创建 `IotCard` 记录时从任务的 `realname_policy` 写入卡的 `realname_policy`
|
||||
- [x] 3.4 在 `ImportTaskResponse`(IoT 卡)中新增 `realname_policy` 字段(含 description 标签)
|
||||
|
||||
## 4. 设备导入模块改造
|
||||
|
||||
- [x] 4.1 在 `ImportDeviceRequest` DTO 中新增 `realname_policy` 字段(含 validate、description 标签)
|
||||
- [x] 4.2 更新 `device_import` Service 的 CreateImportTask 方法:将请求中的 `realname_policy` 保存到 `DeviceImportTask.realname_policy`
|
||||
- [x] 4.3 更新设备导入 Worker 处理逻辑:创建 `Device` 记录时从任务的 `realname_policy` 写入设备的 `realname_policy`
|
||||
- [x] 4.4 在 `DeviceImportTaskResponse` 中新增 `realname_policy` 字段(含 description 标签)
|
||||
|
||||
## 5. 后台管理查询接口 DTO 添加字段
|
||||
|
||||
- [x] 5.1 在 `StandaloneIotCardResponse`(`iot_card_dto.go`)中新增 `realname_policy` 字段,更新 Store/Service 中的 DTO 填充逻辑
|
||||
- [x] 5.2 在 `DeviceResponse`(`device_dto.go`)中新增 `realname_policy` 字段,更新填充逻辑
|
||||
- [x] 5.3 在 `AssetResolveResponse`(`asset_dto.go`)中新增 `realname_policy` 字段,更新 `asset` Service 的 Resolve 方法填充逻辑
|
||||
- [x] 5.4 在 `AssetRealtimeStatusResponse`(`asset_dto.go`)中新增 `realname_policy` 字段,更新填充逻辑
|
||||
- [x] 5.5 在 `DeviceCardBindingResponse`(`device_dto.go`)中新增 `realname_policy` 字段,更新填充逻辑
|
||||
- [x] 5.6 在 `BoundCardInfo`(`asset_dto.go`,后台管理端公共子结构)中新增 `realname_policy` 字段,更新填充逻辑
|
||||
|
||||
## 6. C 端查询接口 DTO 添加字段
|
||||
|
||||
- [x] 6.1 在 `AssetInfoResponse`(`client_asset_dto.go`)中新增 `realname_policy` 字段,更新 `client_asset` Service 的填充逻辑
|
||||
- [x] 6.2 在 C 端 `BoundCardInfo`(`client_asset_dto.go` 内嵌子结构)中新增 `realname_policy` 字段,更新填充逻辑
|
||||
- [x] 6.3 在 `DeviceCardItem`(`client_realname_device_dto.go`)中新增 `realname_policy` 字段,更新 `GetDeviceCards` 接口填充逻辑
|
||||
|
||||
## 7. C 端充值接口拦截(C3/C4)
|
||||
|
||||
- [x] 7.1 在 `client_wallet` Service 的充值预检方法(C3 对应逻辑)中,在归属校验后插入实名策略检查:before_order + 未实名 → 返回 CodeNeedRealname
|
||||
- [x] 7.2 在 `client_wallet` Service 的充值下单方法(C4 对应逻辑)中,在归属校验后、OpenID 查询前插入实名策略检查:before_order + 未实名 → 返回 CodeNeedRealname
|
||||
|
||||
## 8. C 端购买套餐/订单接口拦截(D1/D4)
|
||||
|
||||
- [x] 8.1 删除 `client_order` Service 中的 `REALNAME-03` 注释代码
|
||||
- [x] 8.2 在 `client_order` Service 的 CreateOrder 方法(D1)中,套餐校验之后、OpenID 查询之前插入实名策略检查
|
||||
- [x] 8.3 在 `client_order` Service 的 PayOrder 方法(D4)中,在执行支付前插入实名策略检查
|
||||
|
||||
## 9. C 端实名链接接口改造(E1)
|
||||
|
||||
- [x] 9.1 在 `client_realname` Service 的 GetRealnameLink 方法中,目标卡定位后,`real_name_status=1` 检查前,插入策略检查:after_order + 无有效充值/订单 → 返回 CodeRealnameNotAvailable
|
||||
- [ ] 9.2 验证 before_order 和 none 模式下实名链接接口行为不受影响
|
||||
|
||||
## 10. 后台管理更新接口(UpdateRealnameMode)
|
||||
|
||||
- [x] 10.1 在 `iot_card` Store 中新增 `UpdateRealnamePolicy(ctx, cardID uint, policy string) error` 方法
|
||||
- [x] 10.2 在 `device` Store 中新增 `UpdateRealnamePolicy(ctx, deviceID uint, policy string) error` 方法
|
||||
- [x] 10.3 在 `iot_card` Service 中新增 `UpdateRealnamePolicy(ctx, cardID uint, policy string) error` 方法(含幂等检查)
|
||||
- [x] 10.4 在 `device` Service 中新增 `UpdateRealnamePolicy(ctx, deviceID uint, policy string) error` 方法(含幂等检查)
|
||||
- [x] 10.5 在 `asset_dto.go` 中新增 `UpdateAssetRealnameModRequest` 和 `UpdateAssetRealnameModResponse` DTO
|
||||
- [x] 10.6 在 `asset` Handler(`internal/handler/admin/asset.go`)中新增 `UpdateRealnameMode` 方法,遵循 `UpdatePollingStatus` 模式
|
||||
- [x] 10.7 在路由注册文件中注册新接口 `PATCH /api/admin/assets/:identifier/realname-mode`
|
||||
- [x] 10.8 更新文档生成器 `cmd/api/docs.go` 和 `cmd/gendocs/main.go`,添加新 Handler
|
||||
|
||||
## 11. 验证与收尾
|
||||
|
||||
- [ ] 11.1 运行 `lsp_diagnostics` 检查所有修改文件,确保无编译错误
|
||||
- [x] 11.2 使用 PostgreSQL MCP 验证四张表的字段已正确添加,存量数据 realname_policy 均为 'none'
|
||||
- [ ] 11.3 手动验证 before_order 拦截:充值/购买接口在未实名时返回 CodeNeedRealname
|
||||
- [ ] 11.4 手动验证 after_order 拦截:实名链接接口在无充值/订单时被拦截,有充值后放行
|
||||
- [ ] 11.5 手动验证 none 模式:充值/购买/实名链接均正常放行
|
||||
- [ ] 11.6 验证设备卡策略优先级:设备 before_order + 卡 none → 充值被拦截
|
||||
- [ ] 11.7 验证后台更新接口:通过 ICCID 更新卡策略,通过设备号更新设备策略
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-04-17
|
||||
@@ -0,0 +1,62 @@
|
||||
## Context
|
||||
|
||||
`/api/admin/shops/fund-summary` 通过 `GetPrimaryAccountsByShopIDs` 查询 `is_primary = true` 的账号来获取店铺主账号信息(用户名、手机号)。
|
||||
|
||||
问题根因:`shop/service.go` 创建店铺初始账号时从未设置 `IsPrimary: true`,导致所有账号的 `is_primary` 均为默认值 `false`。数据库验证确认:系统中唯一一个店铺账号(id=128)的 `is_primary = false`。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- 修复店铺创建逻辑,初始账号设 `IsPrimary: true`
|
||||
- 数据迁移修复存量数据(每个店铺选最早创建的代理账号设为主账号)
|
||||
- fund-summary 接口返回正确的用户名和手机号
|
||||
|
||||
**Non-Goals:**
|
||||
- 不修改 `GetPrimaryAccountsByShopIDs` 查询逻辑(查询本身是正确的)
|
||||
- 不实现"切换主账号"功能(超出范围)
|
||||
- 不修改 fund-summary 的其他字段
|
||||
|
||||
## Decisions
|
||||
|
||||
### 决策1:修代码,不改查询
|
||||
|
||||
**选择**:在 `shop/service.go` 创建账号时加 `IsPrimary: true`,而非修改 `GetPrimaryAccountsByShopIDs` 为"查最早账号"。
|
||||
|
||||
**理由**:`is_primary` 字段本身语义明确("是否为主账号"),查询逻辑是正确的。改查询是绕过问题而非解决问题,且未来如果引入"多账号切换主账号"功能会与改查询的方式冲突。
|
||||
|
||||
### 决策2:历史数据修复策略
|
||||
|
||||
**选择**:迁移 SQL 对每个 shop 取 `created_at` 最早的代理账号(`user_type = 3`)设为 `is_primary = true`。
|
||||
|
||||
**理由**:最早创建的账号最可能是初始主账号。这个规则简单确定,无歧义。
|
||||
|
||||
**SQL**:
|
||||
```sql
|
||||
UPDATE tb_account a
|
||||
SET is_primary = true
|
||||
FROM (
|
||||
SELECT DISTINCT ON (shop_id) id
|
||||
FROM tb_account
|
||||
WHERE shop_id IS NOT NULL
|
||||
AND user_type = 3
|
||||
AND deleted_at IS NULL
|
||||
ORDER BY shop_id, created_at ASC
|
||||
) earliest
|
||||
WHERE a.id = earliest.id
|
||||
AND a.is_primary = false;
|
||||
```
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
**[风险] 一个店铺有多个账号时选错主账号**
|
||||
→ 当前系统只有 1 个店铺且 1 个账号,风险极低。取最早账号的策略合理。
|
||||
→ 如有异议,超级管理员可手动修正(未来可开放"设置主账号"接口)。
|
||||
|
||||
**[风险] 迁移 SQL 误更新已手动设置的 is_primary = true 记录**
|
||||
→ SQL 加了 `AND a.is_primary = false` 条件,不会覆盖已正确的数据。
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. 执行代码变更(shop/service.go 加 `IsPrimary: true`)
|
||||
2. 执行数据库迁移文件(UPDATE SQL)
|
||||
3. 验证:调用 fund-summary 接口,确认返回正确用户名和手机号
|
||||
@@ -0,0 +1,27 @@
|
||||
## Why
|
||||
|
||||
`/api/admin/shops/fund-summary` 接口返回的用户名和手机号字段全为空。根因是店铺初始账号创建时从未设置 `is_primary = true`,导致 `GetPrimaryAccountsByShopIDs` 查询条件 `is_primary = true` 永远查不到任何账号。
|
||||
|
||||
## What Changes
|
||||
|
||||
- `shop/service.go` 创建店铺初始账号时补充设置 `IsPrimary: true`
|
||||
- 数据库迁移:对已存在的店铺账号,将每个 shop 下按 `created_at` 最早的代理账号设为 `is_primary = true`(修复历史数据)
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
(无,纯 bug 修复)
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
(无 spec 层面变更,属于实现层修复)
|
||||
|
||||
## Impact
|
||||
|
||||
| 层级 | 文件 | 变更类型 |
|
||||
|------|------|---------|
|
||||
| Service | `internal/service/shop/service.go` | 补充 `IsPrimary: true` |
|
||||
| DB | `migrations/` | 历史数据修复迁移 |
|
||||
|
||||
**影响范围**:极小。仅影响店铺创建逻辑(加一个字段赋值)和一次历史数据迁移,不影响任何接口契约。
|
||||
@@ -0,0 +1,24 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 新建店铺时初始账号标记为主账号
|
||||
|
||||
系统 SHALL 在创建店铺的同时创建初始账号时,将该账号的 `is_primary` 字段设置为 `true`。
|
||||
|
||||
#### Scenario: 创建店铺生成主账号
|
||||
|
||||
- **WHEN** 调用 `POST /api/admin/shops` 创建新店铺
|
||||
- **THEN** 自动创建的初始代理账号 `is_primary = true`,可被 `GetPrimaryAccountsByShopIDs` 查询返回
|
||||
|
||||
### Requirement: 资金概况接口返回正确的用户名和手机号
|
||||
|
||||
系统 SHALL 在 `GET /api/admin/shops/fund-summary` 接口响应中,对每个店铺正确返回主账号的 `username` 和 `phone` 字段(非空)。
|
||||
|
||||
#### Scenario: 有主账号的店铺返回用户信息
|
||||
|
||||
- **WHEN** 调用 `/api/admin/shops/fund-summary`,店铺存在 `is_primary = true` 的账号
|
||||
- **THEN** 响应中每个店铺的 `username` 和 `phone` 为该主账号的真实值,不为空字符串
|
||||
|
||||
#### Scenario: 历史存量数据修复后正确返回
|
||||
|
||||
- **WHEN** 数据迁移执行后,调用 `/api/admin/shops/fund-summary`
|
||||
- **THEN** 现有店铺的账号 `is_primary = true` 已修复,接口返回正确用户名和手机号
|
||||
@@ -0,0 +1,14 @@
|
||||
## 1. 修复店铺创建逻辑
|
||||
|
||||
- [x] 1.1 在 `internal/service/shop/service.go` 的 `Create` 方法中,找到 `account := &model.Account{...}` 的赋值块,添加 `IsPrimary: true` 字段
|
||||
- [x] 1.2 运行 `lsp_diagnostics` 确认 `shop/service.go` 无错误
|
||||
|
||||
## 2. 数据库迁移(历史数据修复)
|
||||
|
||||
- [x] 2.1 在 `migrations/` 目录创建迁移文件,包含以下 UP SQL:对每个 shop 取 `created_at` 最早的代理账号(`user_type = 3, deleted_at IS NULL`)执行 `UPDATE tb_account SET is_primary = true WHERE id = <earliest_id> AND is_primary = false`
|
||||
- [x] 2.2 执行迁移,使用 PostgreSQL MCP 验证:查询 `SELECT shop_id, COUNT(*) as primary_count FROM tb_account WHERE is_primary = true AND deleted_at IS NULL GROUP BY shop_id`,确认每个 shop 有且仅有一条 `is_primary = true` 的账号
|
||||
|
||||
## 3. 接口验证
|
||||
|
||||
- [x] 3.1 使用 PostgreSQL MCP 验证:`SELECT username, phone FROM tb_account WHERE is_primary = true AND deleted_at IS NULL`,确认用户名和手机号均非空
|
||||
- [x] 3.2 调用 `GET /api/admin/shops/fund-summary?page=1&page_size=20`,验证响应中每条记录的 `username` 和 `phone` 字段均返回正确值(非空)
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-04-17
|
||||
@@ -0,0 +1,74 @@
|
||||
## Context
|
||||
|
||||
当前代理充值线下确认接口(`OfflinePay`)通过将 `operation_password` 与操作人自己的登录密码(`account.Password`)做 bcrypt 对比来实现操作密码验证。这个机制存在两个问题:
|
||||
|
||||
1. **缺乏独立性**:操作密码与登录密码耦合,修改登录密码即等于修改操作密码,无法独立管控。
|
||||
2. **安全边界不清**:需要操作密码的敏感操作(如确认大额资金流转)应由超级管理员统一管控,而非各操作人分散管理。
|
||||
|
||||
设计决策:操作密码存 Redis,不开新表。原因:操作密码是全局单一配置,没有历史版本需求,Redis 的 SET/GET 完全满足需求,避免引入新表的维护成本。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- 超级管理员可以设置/修改全局操作密码(存 Redis,bcrypt 哈希)
|
||||
- 提供查询接口返回操作密码是否已设置(不暴露密码本身)
|
||||
- `OfflinePay` 的操作密码验证从"当前用户登录密码"改为"全局操作密码"
|
||||
- 封装 `VerifyOperationPassword` 工具函数,供后续接口统一复用
|
||||
|
||||
**Non-Goals:**
|
||||
- 不做操作密码的过期机制(非当前需求)
|
||||
- 不做操作密码历史记录
|
||||
- 不修改除 `agent_recharge.OfflinePay` 以外的其他接口(其他接口暂无此需求)
|
||||
|
||||
## Decisions
|
||||
|
||||
### 决策1:存储介质选 Redis 而非数据库新表
|
||||
|
||||
**选择**:Redis `SET system:operation_password <bcrypt_hash>` 永久存储(无 TTL)。
|
||||
|
||||
**理由**:全局单一配置,无历史记录需求,Redis 的简单 KV 足够。Key 命名遵循项目规范,定义在 `pkg/constants/redis.go`。
|
||||
|
||||
**备选**:新建 `tb_system_config` 表 → 引入表管理、迁移、GORM 操作,过度设计。
|
||||
|
||||
### 决策2:新接口归属路径
|
||||
|
||||
**选择**:`POST /api/admin/super-admin/operation-password`(设置/修改)和 `GET /api/admin/super-admin/operation-password/status`(查询状态)。
|
||||
|
||||
**理由**:`/super-admin/` 路径前缀清晰表达"仅超级管理员"语义,与现有路由命名惯例一致。Handler 层检查 `userType == UserTypeSuperAdmin`,非超级管理员返回 403。
|
||||
|
||||
### 决策3:Service 层封装独立的 OperationPassword 服务
|
||||
|
||||
**选择**:新建 `internal/service/operation_password/service.go`,提供 `Set(ctx, password)` 和 `Verify(ctx, inputPassword) error` 两个方法。
|
||||
|
||||
**理由**:将操作密码逻辑从 `agent_recharge` Service 中解耦,后续接口可直接注入复用,避免逻辑散落多处。
|
||||
|
||||
### 决策4:未设置时的行为
|
||||
|
||||
**选择**:操作密码未设置时,`Verify` 返回"操作密码未设置,请联系超级管理员"错误,拒绝操作。
|
||||
|
||||
**理由**:不降级为登录密码(安全边界清晰)。初次上线前超级管理员需先设置操作密码。
|
||||
|
||||
### 决策5:bcrypt 哈希存 Redis
|
||||
|
||||
**选择**:Set 时用 `bcrypt.GenerateFromPassword` 哈希后存储,Verify 时用 `bcrypt.CompareHashAndPassword` 对比。
|
||||
|
||||
**理由**:即使 Redis 被访问,也无法还原明文密码。与现有登录密码处理方式一致。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
**[风险] 初次上线前操作密码未设置**
|
||||
→ `OfflinePay` 接口会返回"操作密码未设置"错误。需在上线前由超级管理员设置初始操作密码。
|
||||
|
||||
**[风险] Redis 数据丢失**
|
||||
→ 如 Redis 数据被清空,操作密码丢失,所有需要操作密码的接口将拒绝服务。
|
||||
→ 缓解:Redis 持久化(AOF/RDB)已在生产环境启用;超级管理员可随时重设。
|
||||
|
||||
**[风险] bcrypt 计算开销**
|
||||
→ bcrypt 默认 cost=10,约 100ms/次。此接口调用频率极低(管理操作),不影响系统整体性能。
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. 部署代码变更(新接口 + Redis 验证逻辑)
|
||||
2. 超级管理员登录后台,调用 `POST /api/admin/super-admin/operation-password` 设置初始操作密码
|
||||
3. 验证 `GET /api/admin/super-admin/operation-password/status` 返回已设置
|
||||
4. 验证 `OfflinePay` 接口使用新操作密码可正常通过
|
||||
@@ -0,0 +1,34 @@
|
||||
## Why
|
||||
|
||||
系统中需要操作密码验证的接口(如代理充值线下确认)目前使用**当前登录用户自己的登录密码**作为操作密码,缺乏独立的操作层安全机制。需要引入一个**全局操作密码**,仅由超级管理员设置和修改,所有需要操作密码的接口统一验证这一密码,实现"操作密码"与"登录密码"的职责分离。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 新增 `POST /api/admin/super-admin/operation-password` 接口:超级管理员设置/修改全局操作密码
|
||||
- 新增 `GET /api/admin/super-admin/operation-password/status` 接口:查询操作密码是否已设置
|
||||
- 全局操作密码以 bcrypt 哈希值存储于 Redis,Key 定义在 `pkg/constants/redis.go`
|
||||
- `agent_recharge` `OfflinePay` 的操作密码验证逻辑从"当前用户登录密码"改为"全局操作密码"
|
||||
- 新增统一工具函数 `VerifyOperationPassword(ctx, inputPassword)` 供后续接口复用
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `global-operation-password`:全局操作密码的设置、修改、验证能力,存储于 Redis,仅超级管理员可维护
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
(无已有 spec 变更)
|
||||
|
||||
## Impact
|
||||
|
||||
| 层级 | 文件 | 变更类型 |
|
||||
|------|------|---------|
|
||||
| Handler | `internal/handler/admin/`(新建 `super_admin.go`) | 新建 |
|
||||
| Service | `internal/service/`(新建 `operation_password/service.go`) | 新建 |
|
||||
| Service | `internal/service/agent_recharge/service.go` | 修改验证逻辑 |
|
||||
| Constants | `pkg/constants/redis.go` | 新增 Redis Key 常量 |
|
||||
| Bootstrap | `internal/bootstrap/handlers.go`、`services.go` | 注册新组件 |
|
||||
| Routes | 路由注册文件 | 新增路由 |
|
||||
|
||||
**依赖**:Redis(已有,无需新增依赖)
|
||||
@@ -0,0 +1,58 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 超级管理员可设置和修改全局操作密码
|
||||
|
||||
系统 SHALL 提供 `POST /api/admin/super-admin/operation-password` 接口,仅允许 `user_type=1`(超级管理员)调用,用于设置或修改全局操作密码。密码以 bcrypt 哈希值存储于 Redis。
|
||||
|
||||
#### Scenario: 超级管理员首次设置操作密码
|
||||
|
||||
- **WHEN** 超级管理员调用 `POST /api/admin/super-admin/operation-password`,传入合法的 `password` 和 `confirm_password`(两者一致)
|
||||
- **THEN** 系统将 bcrypt 哈希后的密码写入 Redis,返回 200 成功
|
||||
|
||||
#### Scenario: 超级管理员修改已有操作密码
|
||||
|
||||
- **WHEN** 操作密码已存在,超级管理员重新调用设置接口传入新密码
|
||||
- **THEN** 系统覆盖 Redis 中的旧哈希值,返回 200 成功
|
||||
|
||||
#### Scenario: 非超级管理员调用被拒绝
|
||||
|
||||
- **WHEN** `user_type != 1` 的账号调用 `POST /api/admin/super-admin/operation-password`
|
||||
- **THEN** 系统返回 403,错误消息"仅超级管理员可设置操作密码"
|
||||
|
||||
#### Scenario: 两次密码不一致
|
||||
|
||||
- **WHEN** 超级管理员传入的 `password` 与 `confirm_password` 不一致
|
||||
- **THEN** 系统返回 400,错误消息"两次输入的密码不一致"
|
||||
|
||||
### Requirement: 可查询操作密码设置状态
|
||||
|
||||
系统 SHALL 提供 `GET /api/admin/super-admin/operation-password/status` 接口,返回操作密码是否已设置(布尔值),不返回密码本身,仅超级管理员可调用。
|
||||
|
||||
#### Scenario: 查询已设置状态
|
||||
|
||||
- **WHEN** 操作密码已设置,超级管理员调用状态查询接口
|
||||
- **THEN** 返回 `{"is_set": true}`
|
||||
|
||||
#### Scenario: 查询未设置状态
|
||||
|
||||
- **WHEN** 操作密码未设置(Redis 中无对应 key),超级管理员调用状态查询接口
|
||||
- **THEN** 返回 `{"is_set": false}`
|
||||
|
||||
### Requirement: 操作密码验证使用全局操作密码
|
||||
|
||||
系统 SHALL 将所有需要操作密码验证的接口(目前为 `agent-recharges` 线下充值确认)改为验证全局操作密码,而非当前登录用户的登录密码。
|
||||
|
||||
#### Scenario: 操作密码正确时通过验证
|
||||
|
||||
- **WHEN** 调用需要操作密码的接口,传入正确的全局操作密码
|
||||
- **THEN** 验证通过,接口正常执行后续业务逻辑
|
||||
|
||||
#### Scenario: 操作密码错误时拒绝
|
||||
|
||||
- **WHEN** 调用需要操作密码的接口,传入错误的密码
|
||||
- **THEN** 系统返回 400,错误消息"操作密码错误"
|
||||
|
||||
#### Scenario: 操作密码未设置时拒绝
|
||||
|
||||
- **WHEN** Redis 中无操作密码,调用需要操作密码的接口
|
||||
- **THEN** 系统返回 400,错误消息"操作密码未设置,请联系超级管理员"
|
||||
@@ -0,0 +1,55 @@
|
||||
## 1. Redis Key 常量
|
||||
|
||||
- [x] 1.1 在 `pkg/constants/redis.go` 中添加全局操作密码 Redis Key 生成函数 `RedisSystemOperationPasswordKey() string`,返回固定字符串 `"system:operation_password"`
|
||||
- [x] 1.2 运行 `lsp_diagnostics` 确认 `redis.go` 无错误
|
||||
|
||||
## 2. DTO 层
|
||||
|
||||
- [x] 2.1 新建 `internal/model/dto/super_admin_dto.go`,定义以下结构体:
|
||||
- `SetOperationPasswordRequest`:字段 `Password`(`validate:"required,min=6,max=50"`)、`ConfirmPassword`(`validate:"required"`),含 description 标签
|
||||
- `OperationPasswordStatusResponse`:字段 `IsSet bool`,含 description 标签
|
||||
- [x] 2.2 运行 `lsp_diagnostics` 确认 `super_admin_dto.go` 无错误
|
||||
|
||||
## 3. OperationPassword Service
|
||||
|
||||
- [x] 3.1 新建 `internal/service/operation_password/service.go`,实现以下方法:
|
||||
- `Set(ctx, password string) error`:bcrypt 哈希后写入 Redis(永久存储,无 TTL)
|
||||
- `IsSet(ctx) bool`:查询 Redis key 是否存在
|
||||
- `Verify(ctx, inputPassword string) error`:从 Redis 读取哈希值,用 `bcrypt.CompareHashAndPassword` 对比;key 不存在时返回"操作密码未设置,请联系超级管理员",密码错误时返回"操作密码错误"
|
||||
- Service 结构体通过构造函数注入 `*redis.Client`
|
||||
- [x] 3.2 运行 `lsp_diagnostics` 确认 `operation_password/service.go` 无错误
|
||||
|
||||
## 4. SuperAdmin Handler
|
||||
|
||||
- [x] 4.1 新建 `internal/handler/admin/super_admin.go`,实现以下接口:
|
||||
- `SetOperationPassword`(`POST /api/admin/super-admin/operation-password`):检查 `user_type == UserTypeSuperAdmin`,验证两次密码一致,调用 `operationPasswordService.Set()`
|
||||
- `GetOperationPasswordStatus`(`GET /api/admin/super-admin/operation-password/status`):检查 `user_type == UserTypeSuperAdmin`,调用 `IsSet()` 返回状态
|
||||
- 添加 Handler 方法中文注释(含 HTTP 方法和路径)
|
||||
- [x] 4.2 运行 `lsp_diagnostics` 确认 `super_admin.go` 无错误
|
||||
|
||||
## 5. Bootstrap 注册
|
||||
|
||||
- [x] 5.1 在 `internal/bootstrap/services.go` 中注册 `OperationPasswordService`(注入 Redis 客户端)
|
||||
- [x] 5.2 在 `internal/bootstrap/handlers.go` 中注册 `SuperAdminHandler`(注入 `OperationPasswordService`)
|
||||
- [x] 5.3 在路由文件中注册两个新接口(`POST /api/admin/super-admin/operation-password` 和 `GET /api/admin/super-admin/operation-password/status`),路由需经过 admin 认证中间件
|
||||
- [x] 5.4 运行 `lsp_diagnostics` 确认 bootstrap 和路由文件无错误
|
||||
|
||||
## 6. 文档生成器更新
|
||||
|
||||
- [x] 6.1 在 `cmd/api/docs.go` 和 `cmd/gendocs/main.go` 的 `Handlers` 结构体中注册 `SuperAdminHandler`(参考现有 handler 注册方式)
|
||||
|
||||
## 7. 修改 AgentRecharge 操作密码验证逻辑
|
||||
|
||||
- [x] 7.1 在 `internal/service/agent_recharge/service.go` 的 `OfflinePay` 方法中,将操作密码验证从"查询当前用户账号 + bcrypt 对比登录密码"改为"调用 `operationPasswordService.Verify(ctx, req.OperationPassword)`"
|
||||
- [x] 7.2 在 `agent_recharge/service.go` 的 Service 结构体中注入 `operationPasswordService`,在构造函数中传入
|
||||
- [x] 7.3 在 `internal/bootstrap/services.go` 中更新 `AgentRechargeService` 的构造调用,传入 `OperationPasswordService`
|
||||
- [x] 7.4 运行 `lsp_diagnostics` 确认 `agent_recharge/service.go` 和 bootstrap 相关文件无错误
|
||||
|
||||
## 8. 接口验证
|
||||
|
||||
- [ ] 8.1 调用 `GET /api/admin/super-admin/operation-password/status`,验证返回 `{"is_set": false}`(初始状态)
|
||||
- [ ] 8.2 调用 `POST /api/admin/super-admin/operation-password`(传入合法密码),验证返回 200
|
||||
- [ ] 8.3 调用 `GET /api/admin/super-admin/operation-password/status`,验证返回 `{"is_set": true}`
|
||||
- [ ] 8.4 调用 `OfflinePay` 接口传入旧登录密码,验证返回"操作密码错误"(旧密码已失效)
|
||||
- [ ] 8.5 调用 `OfflinePay` 接口传入新设置的操作密码,验证正常通过
|
||||
- [ ] 8.6 使用非超级管理员账号调用设置接口,验证返回 403
|
||||
Reference in New Issue
Block a user