docs: 新增实名时间与历史、二次实名及按资产号批量延期提案

This commit is contained in:
邹进
2026-11-08 18:44:44 +09:00
parent 0138a3fb9c
commit 76d0f38b9e
12 changed files with 552 additions and 0 deletions

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-11-08

View File

@@ -0,0 +1,77 @@
## Context
- 实名状态唯一写入口是 `internal/application/cardobservation/apply.go` 的 `ApplyCardObservation`(轮询、手动刷新、回调、业务观测、人工纠偏都汇到这里),领域判定在 `internal/domain/cardobservation/realname.go`。
- 网关 `RealnameStatusResp` 只有 `iccid`、`realStatus`。测试环境 worker 日志抽样(11-08,约 4900 条实名响应):移动 15 返回 `archiveTime`(已实名必带,未实名为 null);联通 31/22/50/2 返回 `extend` 文本无时间;电信 10/4 只有 `iccid`、`realStatus`。
- `first_realname_at` 由首次判为已实名时写入观测时间,被沉默期判定、资产展示、导出、首次实名激活使用。
- 实名状态 `real_name_status` 被停复机、C 端、沉默期、导出等 40 余处读取。
- 约束:不新增停复机路径;`enable_polling=false` 永不停复机;不查询 `tb_audit_event*` / `tb_integration_log`;只部署测试环境。
## Goals / Non-Goals
**Goals:**
- 记录准确的当前实名时间与来源,只在变化时写历史。
- 支持移动卡二次实名,C 端无感。
- 沉默期改用当前实名时间。
**Non-Goals:**
- 不迁移 `real_name_status`,不删除 `first_realname_at`。
- 不为联通、电信做二次实名(无实名时间无法区分新旧)。
- 不重算已判定的沉默期套餐。
- 不改实名逆转阈值、停复机规则。
## Decisions
### 1. 三张新表,不给 `tb_iot_card` 加列
- `tb_iot_card_realname`:`card_id` 唯一;`realname_at`、`realname_at_source`、`upstream_realname_at`(最近一次上游返回)、`first_observed_at`;`reset_status`(none / pending)、`reset_at`、`reset_baseline_at`、`reset_batch_id`;时间戳。
- `tb_iot_card_realname_history`:只追加;`card_id`、`iccid`、`before_at`、`after_at`、`after_source`、`change_type`、`observation_source`、`reset_batch_id`、`operator_id`、`observed_at`、`created_at`;索引 `(card_id, id desc)`。
- `tb_iot_card_realname_reset_batch`:`batch_no`、`import_file_key`、`reason`、`total/succeeded/skipped/reverted`、`items`(jsonb 行结果:行号、输入值、卡 ID、结果、原因)、`operator_id`。
理由:卡表是轮询高频更新的大表,二次实名状态与时间独立成表可避免加宽卡表、减少行锁竞争,并让历史与当前值语义清晰。备选"卡表加列"改动小,但二次实名状态字段多,否决。每卡状态在 `tb_iot_card_realname` 上,批次表不再拆明细表。
### 2. 观测携带可选上游实名时间
`RealnameStatusResp` 增加 `ArchiveTime *string`(`json:"archiveTime"`),解析为 `*time.Time`(`Asia/Shanghai`),解析失败视为空并打 warn 日志。`domain.RealnameObservation` 增加 `UpstreamRealnameAt *time.Time`,所有调用网关实名查询的入口(`polling_realname_handler.go`、`series_runner.go`、`iot_card/service.go` 手动刷新)透传;回调、人工入口为空。
### 3. 实名时间在同一事务内决定
在 `ApplyCardObservation` 锁卡事务内,读取 / 创建 `tb_iot_card_realname` 行(`SELECT ... FOR UPDATE`),由领域函数 `DecideRealnameTime(snapshot, observation, decision)` 输出:是否更新 `realname_at`、新值与来源、是否写历史、变化类型、是否拦截本次已实名(二次实名)。纯函数便于表驱动测试。
二次实名拦截发生在 `ApplyRealname` 之前:卡处于 pending 且观测为已实名时,若 `upstream > baseline` 则照常走已实名判定(StatusChanged=true,FirstVerified=false,因 `first_realname_at` 非空),否则把观测降级为"不改状态",只更新 `last_real_name_check_at`。
### 4. 迁移补录用 SQL 一次性插入
迁移文件中 `INSERT INTO tb_iot_card_realname (...) SELECT ... FROM tb_iot_card WHERE real_name_status=1 AND deleted_at IS NULL ON CONFLICT (card_id) DO NOTHING`,同时按 `first_realname_at IS NOT NULL` 插入 migrated 历史(用 `NOT EXISTS` 防重复)。数据量大时分批(按 id 区间)执行,避免长事务。
### 5. 沉默期与展示切换读取源
`packagesilence/resolve.go` 的 `realnameFacts` 改为 `LEFT JOIN tb_iot_card_realname` 取 `realname_at`;"已实名但时间为空"的补写改写 `tb_iot_card_realname`(来源 first_observed,写历史)而不是 `first_realname_at`。资产详情、卡列表、导出的"实名时间"同样改取 `realname_at`。只对上线后的判定生效,不做回算任务。
### 6. 二次实名执行
导入接口同步解析与校验(≤1000 行),逐卡独立事务执行清除;清除复用 `ApplyCardObservation` 的人工纠偏通道(Source=manual_override,置未实名)以保持实名变化事件、审计与停复机评估一致,并在同事务写 `tb_iot_card_realname` 的 pending 字段与历史。运营商是否支持按卡所属运营商 `carrier_type = CMCC` 判定(常量白名单,后续确认其他运营商返回时间后再放开);同时要求当前实名时间非空作为基准。
### 7. 权限与接口
- `GET /api/admin/iot-cards/:id/realname-history`(复用资产详情查看权限)。
- `POST /api/admin/realname-reset/batches`(导入)、`GET /api/admin/realname-reset/batches`、`GET /api/admin/realname-reset/batches/:id`、`POST /api/admin/realname-reset/batches/:id/revert`(撤销整批或指定卡);新增权限码 `iot_card:realname_reset`。
## Risks / Trade-offs
- [仅抽样一个移动账户返回 `archiveTime`] → 白名单按运营商类型 CMCC;导入时额外要求卡当前实名时间来源为 upstream 或有上游最近返回时间,否则跳过并提示,避免对不返回时间的账户误清。
- [清除后实名管控账户会停机] → 用户已确认按普通未实名处理;测试环境网关只读,不会真实下发。
- [上游 `archiveTime` 时区或格式变化] → 解析失败视为空,不影响状态,日志告警。
- [迁移补录数据量大] → 分批插入、可重复执行。
- [当前实名时间改为上游时间后,个别沉默期新判定结果与旧口径不同] → 用户确认采用新口径,已判定的不重算。
## Migration Plan
1. 部署迁移:建 3 张表 → 分批补录当前实名记录与迁移历史。
2. 部署 api / worker:开始解析 `archiveTime` 并写当前记录与历史;沉默期与展示切换读取源。
3. 管理端前端上线实名记录与二次实名页面,分配权限 `iot_card:realname_reset`。
4. 回滚:回滚 api / worker 镜像即可恢复旧读取(`first_realname_at` 一直保留且仍按旧规则写入);新表可保留。
## Open Questions
- 其他移动账户是否都返回 `archiveTime`、联通 / 电信能否提供实名时间,需与网关确认;确认后扩充二次实名支持白名单。
- 上游对已实名过的卡是否仍能正常生成实名链接,需上线后用一张测试卡验证。

View File

@@ -0,0 +1,37 @@
## Why
卡上的 `first_realname_at` 是系统轮询"第一次发现已实名"的时间,不是用户真实实名时间(如卡 89860885192590574640:上游实名 2026-07-16 19:43:39,库里记 20:03:25)。移动的实名查询其实已返回实名时间 `archiveTime`,但网关响应结构没解析;同时实名没有任何可查的历史。另外部分卡因上游原因需要二次实名(后一次实名覆盖前一次),C 端对已实名卡直接拒绝发放实名链接,无法办理,也无法区分上游返回的是旧实名还是新实名。
## What Changes
- 解析网关实名响应的 `archiveTime`(移动返回;联通、电信不返回)。
- 新增「卡当前实名记录」表(每卡一行):当前实名时间及来源(上游返回 / 首次读到 / 迁移沿用)、上游最近返回时间、首次读到已实名时间、二次实名状态与基准。
- 新增「卡实名时间历史」表:只在当前实名时间变化时追加一条,不是每次读到都写。
- 实名时间口径:有上游时间用上游时间;没有则用首次读到已实名的时间;已实名的老卡沿用库里现存实名时间(迁移补录);已实名但库里无时间的卡留空,等下次读到再填。
- 新增二次实名:管理端导入 ICCID / 虚拟号 / IMEI(设备则其全部绑定卡)形成批次,仅支持返回实名时间的运营商;系统清除卡实名状态与当前实名时间并记下清除前实名时间;之后上游返回的实名时间不晚于清除前时间时不覆盖,晚于时才算二次实名完成。C 端按普通未实名展示,不提示"二次实名"。
- 等待二次实名的卡按普通未实名卡处理:实名管控账户下按现有实名停机逻辑执行(关闭轮询的卡仍任何入口都不停复机),不新增停复机路径。
- 沉默期判定与资产实名时间展示改用当前实名时间;已判定过的套餐不重算。
- 管理端资产详情展示当前实名时间及来源、实名时间历史;二次实名批次导入与查询。
- `real_name_status` 保留在卡表,不迁移;`first_realname_at` 保留不删。
## Capabilities
### New Capabilities
- `card-realname-time`: 卡当前实名时间与来源、实名时间历史、上游实名时间解析与写入规则、老数据迁移补录、管理端实名记录展示。
- `card-realname-reset`: 二次实名批次导入、清除、新旧实名判定与完成、运营商支持范围、C 端无感展示、停机口径。
### Modified Capabilities
- `package-silence-period`(本迭代 `add-package-silence-period` 引入,尚未归档):沉默期"实名与过期判定"中的卡实名时间改取当前实名时间。以 ADDED 需求补充,不修改未归档变更的原文。
## Impact
- 数据库:新增 3 张表(当前实名记录、实名时间历史、二次实名批次)与迁移补录脚本;`tb_iot_card` 不加列。
- 网关:`internal/gateway/models.go` 实名响应结构新增 `archiveTime`。
- 卡观测:`internal/domain/cardobservation/realname.go`、`internal/application/cardobservation/apply.go` 增加实名时间判定与二次实名基准比较。
- 沉默期:`internal/application/packagesilence/resolve.go` 实名时间来源切换。
- 资产查询 / 导出:`internal/service/asset/service.go`、`internal/handler/admin/asset.go`、`internal/exporter/iot_card_scene.go` 实名时间字段来源切换。
- 新增管理端接口:资产实名记录查询、二次实名批次导入 / 列表 / 详情;新增权限码。
- 管理端前端(`iteration/8-11`):资产详情"实名记录"、二次实名批次页面(另行在前端仓库落地)。
- 只部署测试环境;测试环境网关只读开关保持开启。

View File

@@ -0,0 +1,70 @@
## ADDED Requirements
### Requirement: 二次实名批次导入
有 `iot_card:realname_reset` 权限的运营 SHALL 可导入二次实名名单(xlsx / csv,一列:ICCID、虚拟号或 IMEI,单批最多 1000 行),填写原因后提交形成一个二次实名批次。系统逐行解析:ICCID 直接定位卡;虚拟号或 IMEI 定位设备,取设备当前全部绑定卡。以下行 MUST 跳过并记录原因,不影响其他行:资产不存在、卡未实名、卡所属运营商不返回实名时间(不支持二次实名)、该卡从未读到过上游实名时间、卡已处于等待二次实名、卡当前实名时间为空(无法确定新旧基准)。批次记录导入文件、总条数、成功数、跳过数及每行结果、操作人与时间。
#### Scenario: 导入设备
- **WHEN** 导入一台绑定 3 张移动已实名卡的设备 IMEI
- **THEN** 3 张卡都进入等待二次实名
#### Scenario: 导入不支持的运营商
- **WHEN** 名单中有一张电信已实名卡
- **THEN** 该行跳过,原因"该运营商不返回实名时间,不支持二次实名",其他行照常处理
### Requirement: 二次实名清除
批次中每张合格卡 SHALL 在独立事务内执行清除:将卡 `real_name_status` 置为未实名;当前实名记录的二次实名状态置为"等待二次实名",记录清除时间、所属批次与清除前实名时间(基准);当前实名时间置空;写一条"二次实名清除"历史;按现有实名状态变化流程发出实名变化事件。已激活的套餐 MUST NOT 回退;`first_realname_at` MUST NOT 修改。
#### Scenario: 清除后资产显示未实名
- **WHEN** 卡(实名时间 07-16 19:43:39)被二次实名清除
- **THEN** 卡显示未实名、实名时间为空,历史有一条清除记录(原 07-16 19:43:39 → 空),已生效套餐不受影响
### Requirement: 二次实名新旧判定
卡处于等待二次实名时,读到"已实名"观测 SHALL 按上游实名时间判定:上游实名时间晚于基准时间 → 二次实名完成,卡置为已实名,当前实名时间取上游时间(来源 upstream),清除等待状态,写"二次实名完成"历史;上游实名时间不晚于基准时间、或未返回上游实名时间 → MUST NOT 改实名状态、MUST NOT 写当前实名时间与历史,仅更新最近检查时间。卡处于等待二次实名时读到未实名观测按现有逻辑处理(保持未实名)。
#### Scenario: 读到旧实名
- **WHEN** 基准 07-16 19:43:39,清除后轮询返回 `realStatus=true, archiveTime=2026-07-16 19:43:39`
- **THEN** 卡仍为未实名,不写历史
#### Scenario: 用户完成二次实名
- **WHEN** 基准 07-16 19:43:39,用户 11-09 10:00 重新实名,轮询返回 `archiveTime=2026-11-09 10:00:00`
- **THEN** 卡变为已实名,当前实名时间 11-09 10:00:00,写一条二次实名完成历史,触发现有实名后续流程(沉默期判定、激活等)
### Requirement: 二次实名撤销
有 `iot_card:realname_reset` 权限的运营 SHALL 可对仍处于等待二次实名的卡撤销(单张或整批):卡恢复为已实名、当前实名时间恢复为基准时间,清除等待状态,写"二次实名撤销"历史。已完成二次实名的卡不能撤销。
#### Scenario: 导错撤销
- **WHEN** 运营撤销一张等待二次实名的卡
- **THEN** 卡恢复已实名、实名时间恢复为清除前时间
### Requirement: C 端无感办理
C 端 MUST 按普通未实名卡展示等待二次实名的卡(显示"未实名"、正常发放实名链接),任何接口与页面 MUST NOT 出现"二次实名"相关字样或标记。
#### Scenario: 用户进入 C 端
- **WHEN** 等待二次实名卡的用户进入 H5 首页
- **THEN** 显示未实名,可点击去实名,与普通未实名卡一致
### Requirement: 等待二次实名卡的停复机口径
等待二次实名的卡 SHALL 与普通未实名卡同等参与现有实名停复机判定:所属运营商账户开启实名管控且套餐有效时按现有逻辑停机。系统 MUST NOT 为二次实名新增停复机路径;`enable_polling = false` 的卡 MUST NOT 被任何入口停机或复机。测试环境网关只读开关保持开启。
#### Scenario: 实名管控账户下的二次实名卡
- **WHEN** 卡所属账户开启实名管控、套餐有效,卡被二次实名清除
- **THEN** 按现有未实名停机逻辑处理,与普通未实名卡一致
#### Scenario: 关闭轮询的卡
- **WHEN** 被清除的卡 `enable_polling = false`
- **THEN** 不发起任何停机或复机

View File

@@ -0,0 +1,91 @@
## ADDED Requirements
### Requirement: 上游实名时间解析
系统 SHALL 解析网关实名查询响应中的 `archiveTime`(格式 `YYYY-MM-DD HH:mm:ss`,按北京时间解释)作为上游实名时间。字段缺失、为 null、为空串或无法解析时 MUST 视为"上游未返回实名时间",不得报错、不得影响实名状态判定。`realStatus = false` 时即使返回了 `archiveTime` 也 MUST 忽略。
#### Scenario: 移动返回实名时间
- **WHEN** 网关返回 `{"archiveTime":"2026-07-16 19:43:39","iccid":"89860885192590574640","realStatus":true}`
- **THEN** 本次观测的上游实名时间为 2026-07-16 19:43:39(+08:00)
#### Scenario: 联通、电信不返回实名时间
- **WHEN** 网关返回 `{"iccid":"8986112520903884400","realStatus":true}` 或带 `extend` 但无 `archiveTime`
- **THEN** 本次观测没有上游实名时间,实名状态照常按 `realStatus` 判定
#### Scenario: 未实名时的空实名时间
- **WHEN** 网关返回 `{"archiveTime":null,"realStatus":false}`
- **THEN** 不产生上游实名时间,按未实名观测处理
### Requirement: 卡当前实名记录
系统 SHALL 为每张卡维护至多一条当前实名记录,包含:当前实名时间、时间来源(`upstream` 上游返回 / `first_observed` 首次读到已实名 / `migrated` 迁移沿用库里时间)、上游最近一次返回的实名时间、首次读到已实名的时间、二次实名状态及基准(见 `card-realname-reset`)。卡的实名状态 `real_name_status` MUST 继续保存在卡表,当前实名记录不替代它。
#### Scenario: 查询卡当前实名时间
- **WHEN** 管理端查询一张已实名卡
- **THEN** 返回当前实名时间与来源;无当前实名记录或时间为空时实名时间返回空
### Requirement: 当前实名时间的确定与更新
卡读到"已实名"观测(轮询、手动刷新、运营商回调、业务观测)并被采纳为已实名时,系统 SHALL 按以下顺序确定实名时间:
1. 观测带上游实名时间 → 取上游实名时间,来源 `upstream`;
2. 否则当前记录已有实名时间 → 保持不变;
3. 否则 → 取本次观测时间(首次读到已实名),来源 `first_observed`。
只有确定出的实名时间与当前实名时间不同时,系统 MUST 在同一事务内更新当前实名记录并追加一条实名时间历史;相同则 MUST NOT 写当前记录的实名时间、MUST NOT 写历史。上游最近返回时间可随观测更新但不产生历史。卡被判定为未实名(含连续逆转、人工改未实名)时当前实名时间保持不变,不写历史;之后再次被判定为已实名时按上述顺序重新确定,变化才写历史。
#### Scenario: 每次轮询读到相同实名时间
- **WHEN** 移动卡当前实名时间为 2026-07-16 19:43:39(来源 upstream),本次轮询上游仍返回 2026-07-16 19:43:39
- **THEN** 不更新当前实名时间,不写历史
#### Scenario: 上游更正迁移时间
- **WHEN** 移动卡当前实名时间为迁移沿用的 2026-07-16 20:03:25(来源 migrated),本次轮询上游返回 2026-07-16 19:43:39
- **THEN** 当前实名时间更新为 19:43:39、来源改为 upstream,并写一条历史(原 20:03:25 → 新 19:43:39)
#### Scenario: 不返回时间的运营商首次实名
- **WHEN** 电信卡此前未实名、无当前实名时间,本次轮询于 11-08 10:00 首次读到已实名
- **THEN** 当前实名时间为 11-08 10:00、来源 first_observed,写一条历史;之后每次读到已实名都不再写
#### Scenario: 逆转为未实名后再次实名
- **WHEN** 电信卡当前实名时间为 09-01 10:00,被连续逆转判定为未实名,之后 11-08 12:00 再次读到已实名
- **THEN** 逆转时不写历史;再次实名时当前实名时间仍为 09-01 10:00(规则 2),不写历史
### Requirement: 实名时间历史
系统 SHALL 在当前实名时间发生变化时追加一条实名时间历史,记录:卡、ICCID、原实名时间、新实名时间、新时间来源、变化类型(首次实名 / 上游更正 / 二次实名清除 / 二次实名完成 / 二次实名撤销 / 迁移补录)、观测来源(轮询 / 手动刷新 / 回调 / 业务观测 / 人工 / 批次)、关联二次实名批次、操作人(人工时)、发现时间。历史 MUST 只追加不修改。
#### Scenario: 历史条数不随轮询增长
- **WHEN** 一张卡实名后被轮询 1000 次,上游实名时间始终相同
- **THEN** 该卡只有首次实名(或迁移补录)对应的历史,不随轮询增加
### Requirement: 实名时间老数据迁移
上线时系统 SHALL 对 `real_name_status = 已实名` 的卡补录当前实名记录:库里 `first_realname_at` 不为空的,当前实名时间取该值、来源 `migrated`,并写一条"迁移补录"历史;`first_realname_at` 为空的,当前实名时间留空、不写历史,等之后读到已实名时按"当前实名时间的确定与更新"规则填写。迁移 MUST 可重复执行且不产生重复记录。
#### Scenario: 已实名有时间的老卡
- **WHEN** 卡已实名、`first_realname_at = 2026-07-16 20:03:25`
- **THEN** 当前实名时间为 20:03:25、来源 migrated,有一条迁移补录历史
#### Scenario: 已实名无时间的老卡
- **WHEN** 卡已实名、`first_realname_at` 为空,之后移动轮询返回上游时间 2026-08-01 09:00:00
- **THEN** 迁移时不写;轮询后当前实名时间为 2026-08-01 09:00:00、来源 upstream,写一条历史
### Requirement: 资产实名时间展示
管理端资产详情、卡列表与导出中的"实名时间" SHALL 取当前实名时间;设备的实名时间沿用现有聚合口径(已绑定且已实名卡中 `max(卡实名时间, 绑定时间)` 的最小值),其中卡实名时间改取当前实名时间。管理端 SHALL 提供按卡查询实名时间历史的接口(分页、按时间倒序),资产详情展示当前实名时间、来源与历史。
#### Scenario: 资产详情查看实名记录
- **WHEN** 运营打开卡 89860885192590574640 的资产详情
- **THEN** 看到当前实名时间、来源(上游返回 / 首次读到 / 迁移沿用),以及实名时间变化历史

View File

@@ -0,0 +1,15 @@
## ADDED Requirements
### Requirement: 沉默期判定使用当前实名时间
沉默期"实名与过期判定"中卡的实名时间 SHALL 取卡当前实名记录的当前实名时间(见 `card-realname-time`),不再取 `first_realname_at`;设备聚合口径不变,其中卡实名时间同样取当前实名时间。卡已实名但当前实名时间为空时 MUST 按原规则视为期内已实名,并以判定时间作为首次读到时间写入当前实名记录(来源 `first_observed`,写历史)。本规则只作用于上线后发生的判定;已经是 `ended` 或 `expired` 的套餐 MUST NOT 因实名时间来源变化而重算。
#### Scenario: 上游实名时间早于发现时间
- **WHEN** 套餐截止日 11-08 23:59:59,移动卡上游实名时间 11-08 23:50,系统 11-09 00:10 才读到
- **THEN** 判定为期内已实名,沉默期结束
#### Scenario: 已判定的套餐不重算
- **WHEN** 某套餐上线前已按旧口径标记为 `expired`,上线后其卡的当前实名时间被上游更正为截止日之前
- **THEN** 该套餐保持 `expired`,不自动改判

View File

@@ -0,0 +1,38 @@
## 1. 数据模型与迁移
- [ ] 1.1 新增迁移:`tb_iot_card_realname`(card_id 唯一)、`tb_iot_card_realname_history`(索引 card_id,id desc)、`tb_iot_card_realname_reset_batch`,含 down 迁移
- [ ] 1.2 新增 model 与常量(时间来源、变化类型、二次实名状态、权限码 `iot_card:realname_reset`),scan 结构显式 gorm column 标签
- [ ] 1.3 迁移补录:已实名且 `first_realname_at` 非空的卡按 id 分批插入当前记录(migrated)与迁移历史,可重复执行;补测试验证幂等
## 2. 上游实名时间解析
- [ ] 2.1 `RealnameStatusResp` 增加 `archiveTime`,解析为北京时间 `*time.Time`,null/空/格式错视为空;表驱动测试覆盖移动、联通、电信三种响应
- [ ] 2.2 `RealnameObservation` 增加 `UpstreamRealnameAt`,轮询、观测序列、手动刷新入口透传
## 3. 当前实名时间与历史
- [ ] 3.1 领域纯函数 `DecideRealnameTime`:上游优先 → 保持 → 首次读到;相同不写;未实名不写;二次实名新旧判定;表驱动测试覆盖 spec 全部场景
- [ ] 3.2 `ApplyCardObservation` 事务内锁定 / 创建当前实名记录,按决定更新并追加历史;二次实名 pending 时拦截旧实名观测
- [ ] 3.3 集成测试:重复轮询不写历史、上游更正 migrated 时间、逆转后再实名不写历史
## 4. 沉默期与展示切换
- [ ] 4.1 `packagesilence/resolve.go` 卡实名时间改取当前实名时间;已实名无时间时写当前记录(first_observed)并写历史,不再补写 `first_realname_at`
- [ ] 4.2 资产详情、卡列表、导出"实名时间"改取当前实名时间;设备聚合口径不变
- [ ] 4.3 新增 `GET /api/admin/iot-cards/:id/realname-history` 分页查询,返回来源与变化类型中文名
- [ ] 4.4 回归测试:沉默期期内 / 截止后实名、已判定不重算
## 5. 二次实名
- [ ] 5.1 导入接口:解析 ICCID / 虚拟号 / IMEI(设备取全部绑定卡),逐行校验(不存在、未实名、运营商不支持、已在等待、无基准时间),形成批次与行结果
- [ ] 5.2 清除执行:每卡独立事务,经人工纠偏通道置未实名,写 pending 基准与清除历史;不改 `first_realname_at`、不回退套餐
- [ ] 5.3 撤销接口:等待中的卡恢复已实名与基准时间,写撤销历史
- [ ] 5.4 批次列表、详情接口;路由注册、文档生成器 DTO 与权限
- [ ] 5.5 测试:读到旧实名不覆盖、新实名完成、撤销、电信卡被跳过、设备展开为全部卡;确认 C 端接口无二次实名字段
## 6. 验证与部署
- [ ] 6.1 `go build ./...`、`go vet`、相关包测试通过
- [ ] 6.2 部署测试环境(网关只读保持开启),用 89860885192590574640 验证当前实名时间被更正为 2026-07-16 19:43:39 且只写一条历史
- [ ] 6.3 测试环境演练二次实名清除与撤销,确认 C 端显示未实名、无二次实名字样
- [ ] 6.4 更新 `docs/` 前端对接说明(实名记录、二次实名接口与权限)

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-11-08

View File

@@ -0,0 +1,63 @@
## Context
- 现有特殊延期 `SubmitSpecial`(`internal/application/packagesilence/extension_submit.go`)输入为 `items[{package_usage_id, days}]`,`validateSpecialItems` 只允许 `expired` 套餐,任一行不合格整批拒绝;审批字段只带首条资产与 `item_count`。
- 执行 `executeBatch` → `executeExtensionInTx` 逐条独立事务;校验套餐仍为 `expired`。
- 批次表已有 `import_file_key`、计数字段;审批附件由 `ApprovalAttachmentUploader` 从对象存储 Key 下载后上传企微。
- 已有 `excelize`(`pkg/utils/excel.go`)与对象存储服务。
- 管理端批量入口在 `SilenceExtendDialog.vue`(CSV 填 `package_usage_id,days`),无批次页面。
## Goals / Non-Goals
**Goals:** 按资产号、统一天数的批量特殊延期;预检与部分提交;待实名套餐过期前延期;> 5 个资产的审批 xlsx 附件;批次查询。
**Non-Goals:** 不改在线付费、线下付费延期;不改单资产特殊延期接口;不改审批回调分发;批量不支持线下付费延期。
## Decisions
### 1. 新接口而非改造旧接口
新增 `POST /api/admin/package-silence/batch-extensions/precheck` 与 `POST /api/admin/package-silence/batch-extensions`,入参 `{identifiers[], days, extension_type, reason, basis, evidence_refs, amount, 线下收款信息..., import_file_key}`。提交内部把可延期明细转换为现有 `specialRow` 后复用批次创建、审批创建、执行流程。旧 `SubmitSpecial` 保持兼容(单资产与旧前端)。理由:输入模型(资产号 + 统一天数)与旧接口(套餐 ID + 逐行天数)不同,改旧接口会破坏现有单资产弹窗。
### 2. 资产展开
复用资产标识解析(`asset_identifier_store` / `ResolveIdentifier`):ICCID → 卡,卡有有效设备绑定则换成设备;虚拟号 / IMEI → 设备。以 `(asset_type, asset_id)` 去重。再查询资产名下 `deleted_at IS NULL AND silence_status IN ('expired','waiting')` 的套餐。
### 3. 预检复用资格计算,放开 waiting
把 `validateSpecialItems` 的逐行判定抽成 `checkSpecialUsage(usage, days, free, allowWaiting)`,批量路径 `allowWaiting=true`;返回结构化原因而不是累积错误。预检与提交共用同一函数,提交时服务端重跑,保证与预检一致或更严格(期间状态变化以提交时为准)。
### 4. 过期前延期执行
明细增加来源沉默期状态快照 `source_silence_status`。`executeExtensionInTx` 的状态校验从"必须 expired"改为"必须 expired,或明细来源为 waiting 且当前仍为 waiting/expired";新截止日统一 `SilenceDeadline(当前截止日期, 延长天数)`;`waiting` 套餐延期后状态不变(不调用激活),`expired` 套餐沿用"改为 waiting 并重新判定"。`ended`、失效、退款 → 该条失败。
### 5. 明细 xlsx 生成时机与存储
资产数 > 5 时,在业务事务之前用 excelize 生成 xlsx,上传对象存储(Key 前缀 `silence/batch-detail/{batch_no}.xlsx`),批次新增 `detail_file_key` 记录。审批字段新增 `ApprovalFieldSilenceDetailKeys = "detail_file_keys"`(FileList),在 `scene.go` 沉默期业务字段中登记(非必须映射)。上传失败直接拒绝提交,不建批次。事务内不调用外部接口,符合现有约束。≤ 5 时 `asset_identifier` 字段写全部资产号(顿号分隔)。
未映射"延期明细"时附注批次号到原因:由表单构建阶段判断映射是否存在不便,改为提交时总是在原因后附注"(批次 {batch_no},共 N 个资产)"于 > 5 场景,规则简单且无副作用。
### 6. 跳过明细存储
批次新增 `skipped_items jsonb`(资产号、资产类型、套餐、原因)与 `skipped_count`;详情接口返回。
### 7. 管理端
新增「批量延期」弹窗(入口:沉默期管理 / 延期批次页):填写统一信息 → 选择 xlsx / csv(一列资产号,前端解析,≤1000)→ 调预检展示可 / 不可延期表 → 确认提交。新增「延期批次」列表与详情页,详情可下载明细 xlsx(`detail_file_key` 签名下载,≤ 5 个资产时由后端即时生成)。旧弹窗中的 CSV 批量入口移除。
## Risks / Trade-offs
- [过期前延期改变既有"只能延期已过期"规则] → 仅批量特殊延期路径放开,单资产与付费延期不变;明细记录来源状态便于审计。
- [预检与提交之间状态变化] → 提交时服务端重跑,以提交时结果为准,前端提示差异条数。
- [1000 个资产展开后套餐数更多] → 明细上限按套餐 3000 条控制,超出拒绝并提示拆分。
- [企微附件大小] → 1000 资产 xlsx 约数十 KB,远低于限制。
## Migration Plan
1. 迁移:批次表加 `detail_file_key`、`skipped_items`、`skipped_count`;明细表加 `source_silence_status`(默认 `expired`)。
2. 部署后端;企微模板加"延期明细"附件控件,管理端场景配置补映射(可后补)。
3. 部署管理端前端。
4. 回滚:回滚镜像;新增字段有默认值,不影响旧逻辑。
## Open Questions
- 批量是否需要按代理 / 店铺范围限制可延期资产(目前沿用延期权限的数据范围)。

View File

@@ -0,0 +1,32 @@
## Why
现有批量特殊延期藏在单个资产的延期弹窗里,导入 CSV 要填系统内部的 `package_usage_id` 且每行单独填天数,运营无法使用;名单有一条不合格就整批拒绝;企微审批单只显示首条资产和条数,审批人看不到 100 个资产的明细;管理端也没有批次查询页面。
## What Changes
- 管理端新增独立的「批量延期」弹窗:延期天数、延期类型、原因、依据、附件只填一次,所有资产统一天数。
- 导入文件只有一列:ICCID / 虚拟号 / IMEI;ICCID 对应卡绑定在设备上时按设备处理,同一资产重复只算一次。
- 资产名下所有处于沉默期的套餐(已过期、待实名)全部延期;**新增"过期前延期"**:待实名套餐也可通过特殊延期把截止日延后(原截止日 + 天数),状态保持待实名。
- 新增预检:逐资产逐套餐列出可延期 / 不可延期及原因、原截止日、新截止日;提交时只提交可延期的,不可延期的记入批次结果(不再整批拒绝)。
- 企微审批:资产数 ≤ 5 时审批单"资产"列出全部资产号;> 5 时系统生成 xlsx 明细作为附件上传到审批单(新增业务字段"延期明细",模板增加附件控件);明细文件同时可在管理端下载。
- 管理端新增延期批次列表与详情页:审批状态、成功 / 失败 / 跳过条数、逐条结果、下载明细。
- 原有按 `package_usage_id` 的单资产特殊延期保持不变。
## Capabilities
### New Capabilities
(无)
### Modified Capabilities
- `package-silence-extension`(本迭代 `add-package-silence-period` 引入,尚未归档):新增按资产号的批量特殊延期、预检、过期前延期、审批明细附件、批次查询。以 ADDED 需求补充,不修改未归档变更的原文。
## Impact
- 后端:`internal/application/packagesilence/`(新增批量资产解析、预检、过期前延期执行、明细 xlsx 生成)、审批字段常量与企微场景字段登记(`internal/application/wecom/scene.go`)、DTO 与路由。
- 依赖:复用已引入的 `github.com/xuri/excelize/v2` 与对象存储;企微附件复用现有审批附件上传。
- 数据库:延期批次增加明细文件 Key、跳过明细(jsonb)字段;延期明细允许来源状态为待实名。
- 企微:沉默期审批模板新增"延期明细"附件控件;场景配置新增映射。
- 管理端前端(`iteration/8-11`):批量延期弹窗、批次列表与详情页(另在前端仓库落地)。
- 只部署测试环境。

View File

@@ -0,0 +1,88 @@
## ADDED Requirements
### Requirement: 按资产号批量特殊延期
有 `package_silence:extend_batch` 权限的运营 SHALL 可按资产号提交批量特殊延期:一次填写延期类型(免费 / 优惠价 / 客诉补偿 / 系统异常补偿 / 特殊客户政策)、统一延期天数、原因、依据、申请附件(优惠价有收款时另填线下收款信息),资产名单为 ICCID / 虚拟号 / IMEI(单批最多 1000 个)。系统 MUST 按以下规则展开为套餐明细:
- ICCID 对应卡绑定在设备上时按设备处理;同一资产在名单中重复出现只算一次;
- 资产名下 `silence_status` 为 `expired` 或 `waiting` 的套餐全部纳入;
- 所有明细使用同一延期天数(按 30 天向上取整计费 / 延长)。
#### Scenario: ICCID 对应设备卡
- **WHEN** 名单中有设备 A 的一张绑定卡 ICCID,同时有设备 A 的 IMEI
- **THEN** 设备 A 只算一个资产,其名下沉默期套餐只延期一次
#### Scenario: 一个资产多笔套餐
- **WHEN** 设备 B 有 1 笔已过期、1 笔待实名、1 笔已结束沉默期的套餐
- **THEN** 已过期与待实名两笔纳入延期,已结束的不纳入
### Requirement: 批量延期预检
系统 SHALL 提供预检接口,输入与提交相同,返回逐资产、逐套餐结果:资产号、资产类型、套餐名称、沉默期状态、原截止日、新截止日、是否可延期及不可延期原因。不可延期原因包括:资产不存在或无权限、资产无沉默期套餐、套餐已有未结束的延期订单或申请、免费延期次数已用完、延期天数不足最小月数。预检 MUST NOT 产生任何数据变更。
#### Scenario: 名单含不存在资产
- **WHEN** 名单 100 个资产,其中 3 个不存在、2 个无沉默期套餐
- **THEN** 预检返回 95 个资产的可延期明细与 5 个不可延期资产及原因
### Requirement: 只提交可延期明细
提交时系统 SHALL 在服务端重新执行预检,只将可延期的套餐明细写入批次;不可延期的资产与套餐 MUST 记入批次的跳过明细(含原因),不得导致整批拒绝。可延期明细为 0 时 MUST 拒绝提交。审批、免审批与执行规则沿用现有特殊延期(政策 `batch` 免审批场景决定是否审批,审批通过后逐条执行,部分失败不影响其他行)。
#### Scenario: 部分不可延期
- **WHEN** 提交 100 个资产,预检 95 个可延期
- **THEN** 批次含 95 个资产的明细,5 个记为跳过及原因,审批与执行只针对 95 个
#### Scenario: 全部不可延期
- **WHEN** 名单中没有任何可延期套餐
- **THEN** 拒绝提交,提示没有可延期的套餐
### Requirement: 过期前延期
批量特殊延期中 `silence_status = waiting` 的套餐 SHALL 可执行延期:新截止日 = `SilenceDeadline(原截止日期, 延长天数)`,`silence_status` 保持 `waiting`,累计延期天数、延期次数、免费次数(免费延期)按现有规则累加,写明细、流水与审计。执行时 MUST 重读套餐:仍为 `waiting` 或已变为 `expired` 的照常延期;已变为 `ended`(期内已实名)或套餐已失效、退款的,该条记失败并注明原因,不影响其他行。
#### Scenario: 待实名套餐延期
- **WHEN** 待实名套餐截止日 11-20,批量延期 30 天
- **THEN** 截止日变为 12-20,状态仍为待实名
#### Scenario: 审批期间已实名
- **WHEN** 待实名套餐在审批期间完成实名、沉默期结束
- **THEN** 审批通过后该条记失败"沉默期已结束,无需延期",其他条照常执行
### Requirement: 审批单资产明细
批量特殊延期提交企微审批时,系统 SHALL 按资产数决定明细呈现:资产数 ≤ 5 时审批单"资产"字段列出全部资产号(顿号分隔);资产数 > 5 时"资产"字段为首个资产号并注明"等 N 个",同时系统 MUST 生成 xlsx 明细文件存入对象存储,作为新业务字段"延期明细"(附件)上传到审批单。明细列:序号、资产号、资产类型、套餐名称、沉默期状态、原截止日、延期天数、新截止日。明细文件 MUST 在审批创建前生成,生成失败则拒绝提交且不产生批次。
#### Scenario: 100 个资产的审批单
- **WHEN** 批量延期 100 个资产提交审批
- **THEN** 审批单"资产"显示"首个资产号 等 100 个",附件含 100 个资产全部套餐明细的 xlsx
#### Scenario: 3 个资产的审批单
- **WHEN** 批量延期 3 个资产
- **THEN** 审批单"资产"列出 3 个资产号,不生成附件
### Requirement: 沉默期审批场景新增延期明细字段
沉默期审批业务类型 SHALL 新增可映射业务字段"延期明细"(附件列表),企微场景配置可将其映射到模板附件控件;该字段非必须映射,未映射时 > 5 个资产的审批单只显示首个资产与条数并在"原因"后附注"明细见管理端批次 {批次号}"。
#### Scenario: 场景未映射延期明细
- **WHEN** 场景配置未映射"延期明细",提交 20 个资产的批量延期
- **THEN** 审批正常提交,原因字段附注批次号,管理端可下载明细
### Requirement: 延期批次查询
管理端 SHALL 提供延期批次列表(按批次号、路径、延期类型、审批状态、提交时间筛选,分页)与批次详情(基本信息、审批状态、成功 / 失败 / 跳过条数、逐条明细与失败原因、跳过资产及原因、下载明细 xlsx)。数据范围沿用现有延期权限。
#### Scenario: 查看批次结果
- **WHEN** 运营打开一个已执行的批量延期批次
- **THEN** 看到 95 条中成功 93、失败 2 及原因,以及跳过的 5 个资产及原因,可下载明细

View File

@@ -0,0 +1,37 @@
## 1. 数据模型
- [ ] 1.1 迁移:批次表加 `detail_file_key`、`skipped_items`(jsonb)、`skipped_count`;延期明细加 `source_silence_status`(默认 expired),含 down
- [ ] 1.2 model、常量:审批字段 `detail_file_keys`、明细上限、跳过原因编码
## 2. 资产展开与预检
- [ ] 2.1 资产号解析与去重:ICCID→卡(绑定设备则换设备)、虚拟号/IMEI→设备;查询 expired / waiting 套餐
- [ ] 2.2 抽取 `checkSpecialUsage`(支持 allowWaiting),旧 `validateSpecialItems` 改为调用它且行为不变
- [ ] 2.3 预检接口 DTO、handler、路由(`package_silence:extend_batch`),返回逐资产逐套餐结果
- [ ] 2.4 表驱动测试:设备去重、多套餐、无沉默期套餐、免费次数用完、已有进行中申请
## 3. 提交与执行
- [ ] 3.1 提交接口:服务端重跑预检,只写可延期明细,跳过明细入批次;0 条拒绝
- [ ] 3.2 复用批次创建、免审批判定与审批创建;审批字段 ≤5 列全部资产号、>5 写首个 + 条数并附注批次号
- [ ] 3.3 >5 时事务前生成 xlsx 明细并上传对象存储,记录 `detail_file_key`,作为 `detail_file_keys` 审批字段;失败拒绝提交
- [ ] 3.4 `executeExtensionInTx` 支持来源 waiting 的明细:仍 waiting 只延截止日,expired 走原流程,ended/失效/退款记失败
- [ ] 3.5 企微场景字段登记"延期明细"(FileList,非必须映射)
- [ ] 3.6 测试:部分跳过、全部跳过、waiting 延期、审批期间已实名失败、旧单资产特殊延期回归
## 4. 批次查询
- [ ] 4.1 批次列表筛选(批次号、路径、延期类型、审批状态、时间)与详情返回跳过明细、计数
- [ ] 4.2 明细下载接口:有 `detail_file_key` 签名下载,否则即时生成
## 5. 管理端前端(admin `iteration/8-11`)
- [ ] 5.1 批量延期弹窗:统一天数/类型/原因/依据/附件,导入一列资产号(xlsx/csv,≤1000),预检结果表,确认提交
- [ ] 5.2 延期批次列表与详情页,下载明细;菜单与权限
- [ ] 5.3 移除单资产弹窗中的旧 CSV 批量入口
## 6. 验证与部署
- [ ] 6.1 `go build ./...`、`go vet`、相关测试通过;前端构建与测试通过
- [ ] 6.2 部署测试环境,用 6 个以上过期 / 待实名设备验证预检、提交、xlsx 附件生成(企微场景配置后验证审批单附件)
- [ ] 6.3 更新 `docs/` 前端对接说明与企微模板配置说明