This commit is contained in:
@@ -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,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-04-17
|
||||
152
openspec/changes/fix-realname-activation-logic/design.md
Normal file
152
openspec/changes/fix-realname-activation-logic/design.md
Normal file
@@ -0,0 +1,152 @@
|
||||
## Context
|
||||
|
||||
现有的套餐实名激活逻辑存在两个关联缺陷:
|
||||
|
||||
**缺陷 A(购买时)**:`order/service.go` 的 `activateMainPackage` 在判断 `expiry_base=from_activation` 时,无条件将套餐标记为 `pending_realname_activation=true`(status=0),未检查载体当前是否已实名。这个缺陷造成**双重故障**:
|
||||
|
||||
```
|
||||
套餐 status=0(pending)
|
||||
↓
|
||||
tryResumeAfterPayment 检查 activatedCount = 0
|
||||
↓
|
||||
ResumeCardIfStopped 不触发
|
||||
↓
|
||||
① 套餐永久无法激活
|
||||
② 购买后卡/设备不会自动开机(即使之前因无套餐而停机)
|
||||
```
|
||||
|
||||
"先实名后购买"是代理商的典型囤货场景(如:设备出厂时已实名,用户激活时购买套餐),在此场景下套餐和开机均失效。
|
||||
|
||||
**缺陷 B(轮询时)**:`polling_realname_handler.go` 的 `triggerFirstRealnameActivation` 提交的 Asynq 任务硬编码 `carrier_type="iot_card"`,即使卡属于某个设备、该设备有 `pending_realname_activation=true` 的设备级套餐,也不会触发设备级激活,同样导致设备下的卡无法通过实名事件触发复机。
|
||||
|
||||
两个缺陷叠加导致:设备级套餐在"先实名后购买"和"购买后某张卡实名"两个场景均无法激活,且均无法触发自动复机。
|
||||
|
||||
**现有关键基础设施**:
|
||||
- `DeviceSimBindingStore.GetActiveBindingByCardID(ctx, cardID)` — 通过卡 ID 查关联设备
|
||||
- `DeviceSimBindingStore.ListByDeviceID(ctx, deviceID)` — 查设备绑定的所有卡
|
||||
- `h.workerResult.Stores.DeviceSimBinding` — Worker 中已可用
|
||||
- `ActivationService.ActivateByRealname(ctx, carrierType, carrierID)` — 已支持 `carrier_type="device"`,且**激活成功后内部会异步调用 `ResumeCardIfStopped(ct, cid)`**
|
||||
- `StopResumeService.ResumeCardIfStopped("device", deviceID)` → `resumeDeviceCards`:遍历设备下因轮询原因停机的卡,逐卡检查实名状态后决定是否复机
|
||||
- `tryResumeAfterPayment`(order service):支付回调成功后,若 `activatedCount > 0` 则异步调用 `ResumeCardIfStopped`,支持 `iot_card` 和 `device` 两种载体类型
|
||||
|
||||
**复机的 stop_reason 约束**(现有设计,本次不变):自动复机仅处理轮询系统引起的停机原因(`no_package`、`traffic_exhausted`、`not_realname`),`manual` 手动停机不会被自动复机——这是系统的有意设计,防止覆盖人工操作。
|
||||
|
||||
---
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- 购买 `from_activation` 套餐时,若载体当前已实名,直接激活(不进入 pending 状态),同时保障 `tryResumeAfterPayment` 的复机链路正常触发
|
||||
- 卡首次实名时,同时触发其所属设备的设备级套餐激活,进而触发设备下符合条件卡的复机
|
||||
- 修复后的逻辑统一、无歧义:`from_purchase` → 立即激活;`from_activation` → 检查实名再决策(已实名直接激活,未实名等待实名事件)
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- 不引入新的数据库字段或迁移
|
||||
- 不改变卡级套餐的现有激活流程
|
||||
- 不处理历史存量被卡住的套餐(需手动 SQL 修复)
|
||||
- 不改变孤儿套餐恢复机制(仅处理 `from_purchase` 场景)
|
||||
|
||||
---
|
||||
|
||||
## Decisions
|
||||
|
||||
### Decision 1:在 `activateMainPackage` 中内联实名状态检查
|
||||
|
||||
**方案 A(选择)**:在现有 `activateMainPackage` 的实名决策块(REALNAME-02 段)内扩展逻辑,直接用 `tx` 查询实名状态。
|
||||
|
||||
**方案 B**:抽取独立 `checkCarrierRealnamed(tx, carrierType, carrierID)` helper。
|
||||
|
||||
选 A 原因:修改点集中在一个函数的一个分支内,变更范围小,逻辑内聚,无需跨文件重构。
|
||||
|
||||
**实现细节**:
|
||||
- `iot_card`:扩展已有的卡查询,同时 `SELECT "card_category", "real_name_status"`,避免额外查询
|
||||
- `device`:新增一次子查询,利用已有的 `tb_device_sim_binding` 关系查询是否有已实名的绑定卡:
|
||||
```sql
|
||||
SELECT COUNT(*) FROM tb_iot_card
|
||||
WHERE id IN (
|
||||
SELECT iot_card_id FROM tb_device_sim_binding
|
||||
WHERE device_id = ? AND bind_status = 1 AND deleted_at IS NULL
|
||||
) AND real_name_status = 1
|
||||
```
|
||||
|
||||
### Decision 2:在 `triggerFirstRealnameActivation` 之后添加 `triggerDeviceRealnameActivation`
|
||||
|
||||
**方案 A(选择)**:`PollingRealnameHandler` 新增 `deviceSimBindingStore` 字段 + `triggerDeviceRealnameActivation` 方法,在卡 0→1 时调用。
|
||||
|
||||
**方案 B**:在 `PackageActivationHandler.HandlePackageFirstActivation` 内部,收到 `carrier_type=iot_card` 后自动查关联设备再激活设备级套餐。
|
||||
|
||||
选 A 原因:语义更清晰——"卡实名"触发"设备套餐激活"属于实名事件的副作用,应由实名 Handler 发起;方案 B 会使激活 Handler 承担实名业务上下文的感知,违反单一职责。
|
||||
|
||||
**实现细节**:`triggerDeviceRealnameActivation` 调用 `deviceSimBindingStore.GetActiveBindingByCardID` 获取设备 ID,若无绑定则静默跳过,若有则提交 `carrier_type="device"` 的 `TaskTypePackageFirstActivation` 任务。
|
||||
|
||||
### Decision 3:`NewPollingRealnameHandler` 构造函数新增参数
|
||||
|
||||
直接在参数列表末尾追加 `deviceSimBindingStore *postgres.DeviceSimBindingStore`,`pkg/queue/handler.go` 的 `registerPollingHandlers` 传入已可用的 `h.workerResult.Stores.DeviceSimBinding`。
|
||||
|
||||
---
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
| 风险 | 缓解措施 |
|
||||
|------|---------|
|
||||
| 购买时多一次 DB 查询(device 需子查询)| 查询走索引(`tb_device_sim_binding.device_id` 有索引),影响可忽略 |
|
||||
| 卡实名时提交多一个 Asynq 任务(device 任务)| 任务幂等:`ActivateByRealname` 查不到 pending 套餐直接返回 nil,无副作用 |
|
||||
| `GetActiveBindingByCardID` 返回 `ErrRecordNotFound` 时不应 log.Error | 已处理:独立卡不属于设备,应静默跳过(Debug/无日志即可) |
|
||||
| 存量已卡住的套餐(如本次触发排查的 id=1)不会被自动修复 | 在 Migration Plan 中给出手动 SQL |
|
||||
| 手动停机(`stop_reason=manual`)的卡不会被自动复机 | 这是现有系统的有意设计,Fix 1/2 不改变此行为;手动停机需人工操作复机 |
|
||||
| 历史存量套餐手动修复后,停机卡不会立即自动开机 | 需额外手动调用 Gateway 复机接口,或等待轮询系统下次 EvaluateAndAct 评估(最长等一个轮询周期) |
|
||||
|
||||
---
|
||||
|
||||
## Migration Plan
|
||||
|
||||
**存量问题修复**(代码部署后手动执行):
|
||||
|
||||
```sql
|
||||
-- 找出所有已实名载体下仍处于 pending_realname_activation=true 的套餐
|
||||
-- 设备级
|
||||
UPDATE tb_package_usage pu
|
||||
SET
|
||||
status = 1,
|
||||
pending_realname_activation = false,
|
||||
activated_at = NOW(),
|
||||
expires_at = NOW() + INTERVAL '12 months', -- 按实际套餐 duration 调整
|
||||
updated_at = NOW()
|
||||
WHERE pu.status = 0
|
||||
AND pu.pending_realname_activation = true
|
||||
AND pu.device_id > 0
|
||||
AND pu.device_id IN (
|
||||
SELECT DISTINCT dsb.device_id
|
||||
FROM tb_device_sim_binding dsb
|
||||
JOIN tb_iot_card ic ON ic.id = dsb.iot_card_id
|
||||
WHERE dsb.bind_status = 1 AND dsb.deleted_at IS NULL
|
||||
AND ic.real_name_status = 1 AND ic.deleted_at IS NULL
|
||||
);
|
||||
|
||||
-- 卡级
|
||||
UPDATE tb_package_usage pu
|
||||
SET
|
||||
status = 1,
|
||||
pending_realname_activation = false,
|
||||
activated_at = NOW(),
|
||||
expires_at = NOW() + INTERVAL '12 months', -- 按实际套餐 duration 调整
|
||||
updated_at = NOW()
|
||||
WHERE pu.status = 0
|
||||
AND pu.pending_realname_activation = true
|
||||
AND pu.iot_card_id > 0
|
||||
AND pu.iot_card_id IN (
|
||||
SELECT id FROM tb_iot_card WHERE real_name_status = 1 AND deleted_at IS NULL
|
||||
);
|
||||
```
|
||||
|
||||
> ⚠️ 以上 SQL 中 `expires_at` 使用了简化的固定值,生产执行前需根据 `CalculateExpiryTime` 逻辑按每条记录的套餐配置计算实际到期时间。
|
||||
|
||||
**回滚**:代码变更纯逻辑,无 schema 变更,直接回滚二进制即可。
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
无。
|
||||
45
openspec/changes/fix-realname-activation-logic/proposal.md
Normal file
45
openspec/changes/fix-realname-activation-logic/proposal.md
Normal file
@@ -0,0 +1,45 @@
|
||||
## Why
|
||||
|
||||
当前系统存在两个关联缺陷,且缺陷 A 的影响远不止套餐激活本身:
|
||||
|
||||
**缺陷 A**:代理商**先给设备/卡实名,再购买 `expiry_base=from_activation` 的套餐**,系统未检查当前实名状态,无条件将套餐标记为 `pending_realname_activation=true`(待生效,status=0)。这不仅导致套餐永久无法激活,还**连带阻断了购买后的自动复机链路**:`tryResumeAfterPayment` 在检查到 `activatedCount=0` 时直接跳过,即使卡此前因无套餐而停机,购买成功后也不会自动开机。
|
||||
|
||||
**缺陷 B**:卡首次实名(0→1)时,实名轮询处理器只触发卡级套餐激活,不处理该卡所属设备的设备级套餐,导致设备级套餐在"购买后某张卡实名"场景下永不激活,同样无法触发设备下绑定卡的复机。
|
||||
|
||||
修复后的规则:**`from_activation` 套餐在购买时检查实名状态,已实名则直接激活(同时保障复机链路正常触发);卡首次实名时同时触发其所属设备的设备级套餐激活与复机**。
|
||||
|
||||
## What Changes
|
||||
|
||||
- **购买时实名状态检查**:在 `activateMainPackage` 中,当判断 `expiry_base != "from_purchase"` 时,额外检查载体当前实名状态:
|
||||
- `iot_card`:查询该卡的 `real_name_status`
|
||||
- `device`:通过 `tb_device_sim_binding` 查询是否存在任意已实名的绑定卡
|
||||
- 已实名 → 直接激活(`status=Active`),`tryResumeAfterPayment` 检查到 `activatedCount>0` 后正常触发复机链路
|
||||
- 未实名 → 保持原有逻辑等待实名触发
|
||||
- **实名轮询触发设备级激活与复机**:在 `PollingRealnameHandler` 的卡首次实名(0→1)路径中,额外查询该卡所属设备,若设备存在则同时提交 `carrier_type=device` 的 `TaskTypePackageFirstActivation` 任务;任务执行后 `ActivateByRealname` 内部会调用 `ResumeCardIfStopped("device", deviceID)` 触发设备下符合条件卡的复机
|
||||
- `NewPollingRealnameHandler` 构造函数新增 `*postgres.DeviceSimBindingStore` 参数
|
||||
|
||||
**复机行为说明**:自动复机仅针对由轮询系统引起的停机(`stop_reason` 为 `no_package`、`traffic_exhausted`、`not_realname`),手动停机(`manual`)不受影响,设备下每张卡独立判断实名状态后再决定是否复机。
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
无新能力,均为现有逻辑的修正。
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `package-realname-activation`:补充"购买时载体已实名则直接激活"的决策分支,以及"设备下卡实名时触发设备级套餐激活"的联动规则
|
||||
- `polling-task-handlers`:`PollingRealnameHandler` 新增 `DeviceSimBindingStore` 依赖,新增 `triggerDeviceRealnameActivation` 函数
|
||||
|
||||
## Impact
|
||||
|
||||
**涉及文件**:
|
||||
- `internal/service/order/service.go`(`activateMainPackage` 函数,~1782-1807 行)
|
||||
- `internal/task/polling_realname_handler.go`(结构体、构造函数、新增函数)
|
||||
- `pkg/queue/handler.go`(`registerPollingHandlers` 中的构造调用)
|
||||
|
||||
**数据库**:无 schema 变更,无迁移
|
||||
|
||||
**API**:无接口变更,对外透明
|
||||
|
||||
**当前问题设备的直接影响**:设备 `862639076038233` 的套餐(`tb_package_usage.id=1`)需要手动修复(修改 `pending_realname_activation=false`、激活套餐),代码修复仅防止新订单再次陷入同样问题。手动修复套餐后,还需手动触发设备复机(或等待轮询系统下次评估),因为历史停机事件不会被追溯重放。
|
||||
@@ -0,0 +1,80 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 支持未实名状态购买套餐
|
||||
|
||||
系统 SHALL 允许后台管理端为未实名的载体(设备/卡)购买套餐,套餐状态为"待生效"(status=0)。对于 `expiry_base=from_activation` 的套餐,系统 SHALL 在创建 `PackageUsage` 时检查载体当前实名状态:若已实名则直接激活;若未实名则标记 `pending_realname_activation=true` 等待实名触发。
|
||||
|
||||
**实名状态判定规则**:
|
||||
- `iot_card` 载体:查询该卡的 `real_name_status` 字段
|
||||
- `device` 载体:通过 `tb_device_sim_binding`(`bind_status=1`)查询是否存在任意已实名(`real_name_status=1`)的绑定卡;存在任意一张即视为设备已实名
|
||||
|
||||
#### Scenario: 后台为未实名设备购买 from_activation 套餐——待生效
|
||||
- **GIVEN** 设备 ID=2,绑定的所有卡 `real_name_status=0`(均未实名)
|
||||
- **AND** 套餐 `expiry_base=from_activation`,购买者为代理商(非 C 端)
|
||||
- **WHEN** 管理员通过后台为该设备购买套餐
|
||||
- **THEN** 系统创建 PackageUsage:`status=0`,`pending_realname_activation=true`,`activated_at=NULL`,`expires_at=NULL`
|
||||
|
||||
#### Scenario: 后台为已有实名卡的设备购买 from_activation 套餐——直接激活并触发复机
|
||||
- **GIVEN** 设备 ID=2,至少一张绑定卡 `real_name_status=1`(已实名),且设备下有卡因无套餐停机(`stop_reason=no_package`)
|
||||
- **AND** 套餐 `expiry_base=from_activation`,购买者为代理商(非 C 端)
|
||||
- **WHEN** 管理员通过后台为该设备购买套餐
|
||||
- **THEN** 系统创建 PackageUsage:`status=1`,`pending_realname_activation=false`,`activated_at=购买时间`,`expires_at` 按套餐配置计算
|
||||
- **AND** `tryResumeAfterPayment` 检测到 `activatedCount=1`,异步调用 `ResumeCardIfStopped("device", 2)`
|
||||
- **AND** 设备下已实名且因 `no_package` 停机的卡自动开机
|
||||
|
||||
#### Scenario: 后台为已实名单卡购买 from_activation 套餐——直接激活
|
||||
- **GIVEN** IoT 卡 ID=10,`real_name_status=1`(已实名)
|
||||
- **AND** 套餐 `expiry_base=from_activation`,购买者为代理商(非 C 端)
|
||||
- **WHEN** 管理员通过后台为该卡购买套餐
|
||||
- **THEN** 系统创建 PackageUsage:`status=1`,`pending_realname_activation=false`,`activated_at=购买时间`,`expires_at` 按套餐配置计算
|
||||
|
||||
#### Scenario: 后台为未实名单卡购买 from_activation 套餐——待生效
|
||||
- **GIVEN** IoT 卡 ID=10,`real_name_status=0`(未实名)
|
||||
- **AND** 套餐 `expiry_base=from_activation`,购买者为代理商(非 C 端)
|
||||
- **WHEN** 管理员通过后台为该卡购买套餐
|
||||
- **THEN** 系统创建 PackageUsage:`status=0`,`pending_realname_activation=true`,`activated_at=NULL`,`expires_at=NULL`
|
||||
|
||||
#### Scenario: from_purchase 套餐不检查实名状态——始终直接激活
|
||||
- **GIVEN** 设备 ID=2,所有绑定卡均未实名(`real_name_status=0`)
|
||||
- **AND** 套餐 `expiry_base=from_purchase`
|
||||
- **WHEN** 管理员通过后台为该设备购买套餐
|
||||
- **THEN** 系统创建 PackageUsage:`status=1`,`pending_realname_activation=false`,立即生效
|
||||
|
||||
#### Scenario: 客户端购买套餐——必须先实名(不受本次变更影响)
|
||||
- **GIVEN** 设备 ID=2,购买者为个人客户(C 端)
|
||||
- **WHEN** 客户通过 H5/小程序购买套餐
|
||||
- **THEN** 系统前置检查已实名,行为不变
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 卡首次实名时同时触发所属设备的设备级套餐激活
|
||||
|
||||
当轮询系统检测到某张 IoT 卡的实名状态从 0 变为 1(首次实名),系统 SHALL 除提交该卡的 `TaskTypePackageFirstActivation` 任务外,额外查询该卡是否属于某个设备(通过 `tb_device_sim_binding`),若存在有效绑定则同时提交以该设备为载体(`carrier_type=device`)的 `TaskTypePackageFirstActivation` 任务。
|
||||
|
||||
**设计约束**:
|
||||
- 若卡未绑定任何设备(`GetActiveBindingByCardID` 返回 `ErrRecordNotFound`)则静默跳过,不报错
|
||||
- 提交任务失败记录 Warn 日志,不阻塞卡级激活流程
|
||||
- 任务幂等:`ActivateByRealname(ctx, "device", deviceID)` 若查不到 `pending_realname_activation=true` 的套餐则直接返回 nil
|
||||
|
||||
#### Scenario: 设备下某张卡首次实名——同时触发设备级套餐激活与复机
|
||||
- **GIVEN** IoT 卡 ID=4,绑定于设备 ID=2(`tb_device_sim_binding` 中存在有效绑定)
|
||||
- **AND** 设备 ID=2 存在 `status=0, pending_realname_activation=true` 的设备级 PackageUsage
|
||||
- **AND** 设备下有卡因 `not_realname` 停机(`stop_reason=not_realname`)
|
||||
- **WHEN** 轮询系统检测到卡 ID=4 实名状态 0→1
|
||||
- **THEN** 系统提交两个 Asynq 任务:
|
||||
1. `carrier_type=iot_card, carrier_id=4`(卡级激活)
|
||||
2. `carrier_type=device, carrier_id=2`(设备级激活)
|
||||
- **AND** 设备级 PackageUsage 最终 `status=1, pending_realname_activation=false, activated_at` 已设置
|
||||
- **AND** `ActivateByRealname` 激活成功后异步调用 `ResumeCardIfStopped("device", 2)`,设备下已实名的停机卡自动开机
|
||||
|
||||
#### Scenario: 独立卡(未绑定设备)首次实名——仅触发卡级激活
|
||||
- **GIVEN** IoT 卡 ID=10,未绑定任何设备(`tb_device_sim_binding` 中无有效记录)
|
||||
- **WHEN** 轮询系统检测到卡 ID=10 实名状态 0→1
|
||||
- **THEN** 系统仅提交卡级 Asynq 任务(`carrier_type=iot_card, carrier_id=10`)
|
||||
- **AND** 不报错,不记录 Error 日志
|
||||
|
||||
#### Scenario: 设备下某张卡实名但设备无待激活套餐——任务执行幂等返回
|
||||
- **GIVEN** IoT 卡 ID=4,绑定于设备 ID=2
|
||||
- **AND** 设备 ID=2 无 `pending_realname_activation=true` 的套餐
|
||||
- **WHEN** 轮询系统检测到卡 ID=4 实名状态 0→1,并提交设备级激活任务
|
||||
- **THEN** 任务 Worker 执行 `ActivateByRealname(ctx, "device", 2)`,查询结果为空,直接返回 nil(无错误)
|
||||
@@ -0,0 +1,69 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: polling_realname_handler.go——实名数据采集
|
||||
|
||||
**文件**:`internal/task/polling_realname_handler.go`(< 220行)
|
||||
|
||||
**构造函数依赖**(通过构造函数注入,禁止全局变量):
|
||||
```go
|
||||
func NewPollingRealnameHandler(
|
||||
base *PollingBase,
|
||||
gateway *gateway.Client,
|
||||
iotCardStore *postgres.IotCardStore,
|
||||
asynqClient *asynq.Client,
|
||||
stopResumeSvc iot_card_svc.StopResumeServiceInterface,
|
||||
deviceSimBindingStore *postgres.DeviceSimBindingStore, // 新增:用于查询卡所属设备
|
||||
) *PollingRealnameHandler
|
||||
```
|
||||
|
||||
**方法列表**:
|
||||
- `Handle(ctx, task) error`:Asynq 任务入口
|
||||
- `triggerFirstRealnameActivation(ctx, cardID)`:提交卡级首次实名激活任务(已有,不变)
|
||||
- `triggerDeviceRealnameActivation(ctx, cardID)`:**新增**,查询卡所属设备并提交设备级激活任务
|
||||
|
||||
**处理流程(0→1 路径新增步骤)**:
|
||||
1. 调 `base.acquireConcurrency("realname")`,获取失败则 requeue 后返回
|
||||
2. 调 `base.getCardWithCache(ctx, cardID)` 获取卡信息
|
||||
3. 调 Gateway 查询实名状态
|
||||
4. 写 Store(更新 `real_name_status`、`last_real_name_check_at`,首次实名时写 `first_realname_at`)
|
||||
5. 若实名状态变为已实名(0→1):
|
||||
a. 调 `triggerFirstRealnameActivation(ctx, cardID)` — 提交卡级激活任务
|
||||
b. **调 `triggerDeviceRealnameActivation(ctx, cardID)` — 提交设备级激活任务(新增)**
|
||||
c. 调 `stopResumeService.EvaluateAndAct(ctx, freshCard)` — 触发复机判断
|
||||
6. 调 `base.requeueCard(ctx, cardID, "realname")` 重新入队
|
||||
7. 调 `base.releaseConcurrency("realname")`
|
||||
|
||||
#### Scenario: 实名状态由未实名变为已实名——同时触发卡级和设备级激活
|
||||
- **GIVEN** cardID=4,`real_name_status=0`,该卡通过 `tb_device_sim_binding` 绑定于设备 ID=2
|
||||
- **WHEN** Gateway 返回实名已完成,Handler 检测到 0→1 变化
|
||||
- **THEN** Store 更新 `real_name_status=1, first_realname_at=NOW()`
|
||||
- **AND** 提交卡级 Asynq 任务(`carrier_type=iot_card, carrier_id=4`)
|
||||
- **AND** 提交设备级 Asynq 任务(`carrier_type=device, carrier_id=2`)
|
||||
- **AND** 调用 `EvaluateAndAct` 触发复机评估
|
||||
|
||||
#### Scenario: 独立卡实名——仅触发卡级激活
|
||||
- **GIVEN** cardID=10,`real_name_status=0`,未绑定任何设备
|
||||
- **WHEN** Gateway 返回实名已完成,Handler 检测到 0→1 变化
|
||||
- **THEN** 提交卡级 Asynq 任务(`carrier_type=iot_card, carrier_id=10`)
|
||||
- **AND** `triggerDeviceRealnameActivation` 调用 `GetActiveBindingByCardID` 返回 not found,静默跳过,不报错
|
||||
|
||||
#### Scenario: 实名状态未变化——不触发任何激活
|
||||
- **GIVEN** cardID=200,`real_name_status=1`(已实名),Gateway 返回仍已实名
|
||||
- **WHEN** Handler 执行
|
||||
- **THEN** Store 不执行实名更新;不提交任何激活任务;按正常间隔 requeue
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: PollingRealnameHandler 注入 DeviceSimBindingStore
|
||||
|
||||
`PollingRealnameHandler` 结构体 SHALL 包含 `deviceSimBindingStore *postgres.DeviceSimBindingStore` 字段,由 `NewPollingRealnameHandler` 构造函数注入。`pkg/queue/handler.go` 的 `registerPollingHandlers` SHALL 将已可用的 `h.workerResult.Stores.DeviceSimBinding` 作为最后一个参数传入。
|
||||
|
||||
#### Scenario: Worker 启动时 DeviceSimBindingStore 正确注入
|
||||
- **WHEN** Worker 进程启动,调用 `registerPollingHandlers()`
|
||||
- **THEN** `NewPollingRealnameHandler` 接收 6 个参数,第 6 个为 `h.workerResult.Stores.DeviceSimBinding`
|
||||
- **AND** handler 的 `deviceSimBindingStore` 字段非 nil
|
||||
|
||||
#### Scenario: DeviceSimBindingStore 为 nil 时 triggerDeviceRealnameActivation 静默跳过
|
||||
- **GIVEN** handler 的 `deviceSimBindingStore` 为 nil(测试或未注入场景)
|
||||
- **WHEN** 调用 `triggerDeviceRealnameActivation`
|
||||
- **THEN** 函数立即返回,不 panic,不报错
|
||||
24
openspec/changes/fix-realname-activation-logic/tasks.md
Normal file
24
openspec/changes/fix-realname-activation-logic/tasks.md
Normal file
@@ -0,0 +1,24 @@
|
||||
## 1. 修复购买时实名状态检查(order/service.go)
|
||||
|
||||
- [ ] 1.1 在 `activateMainPackage` 的 REALNAME-02 决策块中,扩展卡信息查询:将 `tx.Select("card_category")` 改为 `tx.Select("card_category", "real_name_status")`,并从结果读取 `currentlyRealnamed` 标志
|
||||
- [ ] 1.2 在 REALNAME-02 决策块中,新增 `device` 载体的实名状态检查:当 `carrierType == "device"` 时,通过 `tb_device_sim_binding`(`bind_status=1, deleted_at IS NULL`)子查询统计绑定卡中 `real_name_status=1` 的数量,`count > 0` 则 `currentlyRealnamed=true`
|
||||
- [ ] 1.3 修改 `expiry_base != "from_purchase"` 分支的决策逻辑:若 `currentlyRealnamed=true` 则保持 `status=Active`(不切换为 pending),记录 Info 日志"购买时载体已实名,直接激活套餐";若 `currentlyRealnamed=false` 则保持原有 `pending_realname_activation=true` 逻辑
|
||||
- [ ] 1.4 运行 `lsp_diagnostics` 确认 `internal/service/order/service.go` 无编译错误
|
||||
|
||||
## 2. 扩展 PollingRealnameHandler(polling_realname_handler.go)
|
||||
|
||||
- [ ] 2.1 在 `PollingRealnameHandler` 结构体中新增字段 `deviceSimBindingStore *postgres.DeviceSimBindingStore`
|
||||
- [ ] 2.2 更新 `NewPollingRealnameHandler` 构造函数签名,在参数列表末尾新增 `deviceSimBindingStore *postgres.DeviceSimBindingStore`,并在函数体中赋值到结构体字段
|
||||
- [ ] 2.3 新增 `triggerDeviceRealnameActivation(ctx context.Context, cardID uint)` 函数:调用 `h.deviceSimBindingStore.GetActiveBindingByCardID` 获取设备绑定;若无绑定(`ErrRecordNotFound`)静默返回;若有绑定则提交 `carrier_type="device", carrier_id=binding.DeviceID` 的 `TaskTypePackageFirstActivation` Asynq 任务(与卡级任务相同的 MaxRetry/Timeout/Queue 配置);失败记录 Warn 日志,不中断流程
|
||||
- [ ] 2.4 在 `isFirstRealname` 分支中,在调用 `triggerFirstRealnameActivation` 之后追加调用 `triggerDeviceRealnameActivation(ctx, cardID)`
|
||||
- [ ] 2.5 运行 `lsp_diagnostics` 确认 `internal/task/polling_realname_handler.go` 无编译错误
|
||||
|
||||
## 3. 更新 Worker 构造调用(pkg/queue/handler.go)
|
||||
|
||||
- [ ] 3.1 在 `registerPollingHandlers` 中,将 `task.NewPollingRealnameHandler(...)` 调用末尾追加参数 `h.workerResult.Stores.DeviceSimBinding`
|
||||
- [ ] 3.2 运行 `lsp_diagnostics` 确认 `pkg/queue/handler.go` 无编译错误
|
||||
|
||||
## 4. 整体编译验证
|
||||
|
||||
- [ ] 4.1 执行 `go build ./...` 确认整个项目编译通过,零错误零警告
|
||||
- [ ] 4.2 使用 PostgreSQL MCP 执行存量修复 SQL:将 `tb_package_usage.id=1` 的套餐手动激活(`status=1, pending_realname_activation=false, activated_at=NOW(), expires_at` 按套餐配置计算),并验证更新结果
|
||||
43
openspec/specs/agent-recharge-payment-voucher/spec.md
Normal file
43
openspec/specs/agent-recharge-payment-voucher/spec.md
Normal file
@@ -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(前端不展示)
|
||||
171
openspec/specs/asset-realname-policy/spec.md
Normal file
171
openspec/specs/asset-realname-policy/spec.md
Normal file
@@ -0,0 +1,171 @@
|
||||
# Capability: 资产实名策略
|
||||
|
||||
## 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 字段(前端可据此展示提示)
|
||||
@@ -4,8 +4,16 @@
|
||||
|
||||
### 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 查询 → 幂等检查 → 强充检查 → 分流创建。实名不满足时 MUST 返回 `NEED_REALNAME`。OpenID 缺失时 MUST 返回 `OPENID_NOT_FOUND`。幂等 MUST 使用 Redis 业务键 + 分布式锁。分流规则 MUST 为:
|
||||
系统 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`
|
||||
|
||||
@@ -45,6 +53,14 @@
|
||||
- **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** 系统正常创建订单,不检查实名状态
|
||||
|
||||
---
|
||||
|
||||
### Requirement: D2 套餐订单列表接口
|
||||
|
||||
@@ -10,14 +10,38 @@
|
||||
2. 标识符为设备且传 `iccid`:定位对应设备下卡
|
||||
3. 标识符为设备且未传 `iccid`:定位设备当前活跃卡
|
||||
|
||||
当 `real_name_status=1` 时 MUST 返回“该卡已完成实名”错误。运营商实名模式 MUST 支持:
|
||||
接口在确定目标卡后,MUST 获取生效实名策略(`GetEffectiveRealnamePolicy`),按以下顺序执行前置检查:
|
||||
|
||||
- `none`:不支持在线实名,直接报错
|
||||
- `template`:按模板替换占位符 `{iccid}` `{msisdn}` `{virtual_no}` 返回 URL
|
||||
- `gateway`:调用网关获取实名链接
|
||||
**实名策略检查**(新增,在实名状态检查之前执行):
|
||||
- 生效策略为 `after_order`:检查该资产当前 generation 内是否存在有效充值(`status=2`)或已支付订单(`payment_status=2`)
|
||||
- 无记录 → MUST 返回 `CodeRealnameNotAvailable`,消息"请先完成充值或购买套餐后再进行实名认证"
|
||||
- 有记录 → 继续执行后续检查
|
||||
- 生效策略为 `none` 或 `before_order`:不执行此检查,继续执行后续检查
|
||||
|
||||
响应体 SHALL 至少包含 `realname_mode`、`realname_url`、`card_info{iccid,msisdn,virtual_no}`、`expire_at`(可空)。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误`、`FORBIDDEN/无权限操作该资产或资源不存在`、`REALNAME_ALREADY_DONE/该卡已完成实名`、`REALNAME_NOT_SUPPORTED/该运营商暂不支持在线实名`、`GATEWAY_ERROR/获取实名链接失败`。
|
||||
**原有检查保留**:
|
||||
- 当 `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** 系统正常返回实名链接(无策略限制,允许用户自愿实名)
|
||||
|
||||
@@ -24,22 +24,46 @@
|
||||
|
||||
### Requirement: C3 充值预检接口
|
||||
|
||||
系统 SHALL 提供 `GET /api/c/v1/wallet/recharge-check?identifier=xxx`,并且 MUST 要求个人客户认证。接口 MUST 复用 `recharge.Service.GetRechargeCheck()` 计算强充规则。响应体 SHALL 包含 `need_force_recharge`、`force_recharge_amount`、`trigger_type`、`min_amount`、`max_amount`、`message`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误`、`FORBIDDEN/无权限操作该资产或资源不存在`。
|
||||
系统 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,并由后端按 `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/仅支持微信支付`。
|
||||
系统 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** 系统正常创建充值单,不检查实名状态
|
||||
|
||||
---
|
||||
|
||||
### Requirement: C5 充值订单列表接口
|
||||
|
||||
@@ -231,3 +231,35 @@ TBD - created by archiving change add-device-management. Update Purpose after ar
|
||||
- **WHEN** 平台管理员查询不存在的任务(ID=999)
|
||||
- **THEN** 系统返回 404 错误,提示"导入任务不存在"
|
||||
|
||||
---
|
||||
|
||||
### 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` 字段
|
||||
|
||||
|
||||
29
openspec/specs/fund-summary-primary-account/spec.md
Normal file
29
openspec/specs/fund-summary-primary-account/spec.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# fund-summary-primary-account Specification
|
||||
|
||||
## Purpose
|
||||
修复资金概况接口的主账号数据来源问题:确保新建店铺时初始账号被标记为主账号(`is_primary = true`),并通过数据迁移修复历史存量数据,使 `GET /api/admin/shops/fund-summary` 接口能够正确返回每个店铺的主账号用户名和手机号。
|
||||
|
||||
## 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` 已修复,接口返回正确用户名和手机号
|
||||
56
openspec/specs/global-operation-password/spec.md
Normal file
56
openspec/specs/global-operation-password/spec.md
Normal file
@@ -0,0 +1,56 @@
|
||||
### 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,错误消息"操作密码未设置,请联系超级管理员"
|
||||
@@ -336,3 +336,35 @@ TBD - created by archiving change iot-card-standalone-management. Update Purpose
|
||||
- **WHEN** 导入请求中 `card_category` = `unknown`(非枚举值)
|
||||
- **THEN** 系统返回参数校验错误,提示卡业务类型无效
|
||||
|
||||
---
|
||||
|
||||
### 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` 字段,与创建时传入值一致
|
||||
|
||||
|
||||
Reference in New Issue
Block a user