diff --git a/openspec/changes/add-realname-time-history/.openspec.yaml b/openspec/changes/add-realname-time-history/.openspec.yaml new file mode 100644 index 00000000..c0ad5ed4 --- /dev/null +++ b/openspec/changes/add-realname-time-history/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-11-08 diff --git a/openspec/changes/add-realname-time-history/design.md b/openspec/changes/add-realname-time-history/design.md new file mode 100644 index 00000000..8832640b --- /dev/null +++ b/openspec/changes/add-realname-time-history/design.md @@ -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`、联通 / 电信能否提供实名时间,需与网关确认;确认后扩充二次实名支持白名单。 +- 上游对已实名过的卡是否仍能正常生成实名链接,需上线后用一张测试卡验证。 diff --git a/openspec/changes/add-realname-time-history/proposal.md b/openspec/changes/add-realname-time-history/proposal.md new file mode 100644 index 00000000..2fa01e26 --- /dev/null +++ b/openspec/changes/add-realname-time-history/proposal.md @@ -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`):资产详情"实名记录"、二次实名批次页面(另行在前端仓库落地)。 +- 只部署测试环境;测试环境网关只读开关保持开启。 diff --git a/openspec/changes/add-realname-time-history/specs/card-realname-reset/spec.md b/openspec/changes/add-realname-time-history/specs/card-realname-reset/spec.md new file mode 100644 index 00000000..40c8af4c --- /dev/null +++ b/openspec/changes/add-realname-time-history/specs/card-realname-reset/spec.md @@ -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** 不发起任何停机或复机 diff --git a/openspec/changes/add-realname-time-history/specs/card-realname-time/spec.md b/openspec/changes/add-realname-time-history/specs/card-realname-time/spec.md new file mode 100644 index 00000000..a9d76b4c --- /dev/null +++ b/openspec/changes/add-realname-time-history/specs/card-realname-time/spec.md @@ -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** 看到当前实名时间、来源(上游返回 / 首次读到 / 迁移沿用),以及实名时间变化历史 diff --git a/openspec/changes/add-realname-time-history/specs/package-silence-period/spec.md b/openspec/changes/add-realname-time-history/specs/package-silence-period/spec.md new file mode 100644 index 00000000..20c5b7ab --- /dev/null +++ b/openspec/changes/add-realname-time-history/specs/package-silence-period/spec.md @@ -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`,不自动改判 diff --git a/openspec/changes/add-realname-time-history/tasks.md b/openspec/changes/add-realname-time-history/tasks.md new file mode 100644 index 00000000..cd34ebbb --- /dev/null +++ b/openspec/changes/add-realname-time-history/tasks.md @@ -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/` 前端对接说明(实名记录、二次实名接口与权限) diff --git a/openspec/changes/add-silence-batch-extension/.openspec.yaml b/openspec/changes/add-silence-batch-extension/.openspec.yaml new file mode 100644 index 00000000..c0ad5ed4 --- /dev/null +++ b/openspec/changes/add-silence-batch-extension/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-11-08 diff --git a/openspec/changes/add-silence-batch-extension/design.md b/openspec/changes/add-silence-batch-extension/design.md new file mode 100644 index 00000000..6fd24a1a --- /dev/null +++ b/openspec/changes/add-silence-batch-extension/design.md @@ -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 + +- 批量是否需要按代理 / 店铺范围限制可延期资产(目前沿用延期权限的数据范围)。 diff --git a/openspec/changes/add-silence-batch-extension/proposal.md b/openspec/changes/add-silence-batch-extension/proposal.md new file mode 100644 index 00000000..9754d82d --- /dev/null +++ b/openspec/changes/add-silence-batch-extension/proposal.md @@ -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`):批量延期弹窗、批次列表与详情页(另在前端仓库落地)。 +- 只部署测试环境。 diff --git a/openspec/changes/add-silence-batch-extension/specs/package-silence-extension/spec.md b/openspec/changes/add-silence-batch-extension/specs/package-silence-extension/spec.md new file mode 100644 index 00000000..3a56b815 --- /dev/null +++ b/openspec/changes/add-silence-batch-extension/specs/package-silence-extension/spec.md @@ -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 个资产及原因,可下载明细 diff --git a/openspec/changes/add-silence-batch-extension/tasks.md b/openspec/changes/add-silence-batch-extension/tasks.md new file mode 100644 index 00000000..22e3407d --- /dev/null +++ b/openspec/changes/add-silence-batch-extension/tasks.md @@ -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/` 前端对接说明与企微模板配置说明