## Context 系统存在一段注释掉的实名拦截代码(`REALNAME-03`,位于 `internal/service/client_order/service.go` 第 143 行),原逻辑为"普通卡(`card_category=normal`)需实名才能购买套餐,行业卡(`industry`)无需实名"。由于业务还未明确,该逻辑被整体注释,由网关侧临时托管。 现在业务明确了两种场景:**先实名后充值**(充值/购买前拦截未实名资产)与**先充值后实名**(充值/购买放行,实名链接在未充值前拦截)。且行业卡在特定业务下也可能需要实名,原来依赖 `card_category` 的硬编码逻辑不够灵活。 需要在资产层面(`IotCard` 和 `Device`)引入可配置的 `realname_policy` 字段,取代原有硬编码逻辑,支持导入时批量初始化策略、后台管理接口修改策略、所有查询接口返回策略字段、以及 C 端三接口按策略执行拦截。 ## Goals / Non-Goals **Goals:** - 在 `IotCard` 和 `Device` 模型上新增 `realname_policy` 字段(string 枚举) - IoT 卡导入和设备导入支持批量初始化 `realname_policy` - 后台新增 `PATCH /api/admin/assets/:identifier/realname-mode` 接口修改单资产策略 - C 端充值(C3/C4)、购买套餐/订单(D1/D4)、实名链接(E1)按策略拦截 - 所有涉及卡/设备/资产的查询接口返回 `realname_policy` 字段 - 存量数据迁移默认值为 `none`,不影响现有行为 **Non-Goals:** - 不修改登录层的实名检查(已有机制,由网关托管) - 不实现用户通知(after_order 实名提醒等) - 不修改 Carrier 层的 `realname_link_type`(运营商提供实名链接的方式,是另一个维度) - 不实现按套餐粒度的实名策略(只到资产粒度) ## Decisions **决策 1:`realname_policy` 使用 string 枚举而非 bool** 选 `string` 而非 `bool`(如 `realname_required`):业务存在三种状态(无需实名 / 先实名后充值 / 先充值后实名),bool 只能表达两种。枚举值:`none` / `before_order` / `after_order`。 **决策 2:设备卡场景以 Device.realname_policy 为准** 当一张卡绑定到设备时(设备卡),取 `Device.realname_policy`;未绑定设备时(单卡),取 `IotCard.realname_policy`。这与现有系统中"设备层面的验证规则高于卡层面"的设计一致(登录前的设备验证已体现此原则)。 判断逻辑封装在 Service 层公共方法 `GetEffectiveRealnamePolicy(card *model.IotCard, device *model.Device) string`,避免在多个 Service 中重复实现。 **决策 3:after_order 场景下实名链接的前置条件** `after_order` 模式下,用户调用 `GET /api/c/v1/realname/link` 时,系统检查该资产是否存在有效充值或已支付订单记录(`status=2` 的订单或 `status=2` 的充值单)。无记录则拦截;有记录则放行。 逻辑:先充值才有资格实名,避免用户跳过充值直接实名。检查范围:该资产当前 generation 内的充值记录或套餐订单记录。 **决策 4:后台更新接口复用现有资产解析器** 遵循 `UpdatePollingStatus` 的模式(`PATCH /api/admin/assets/:identifier/polling-status`): - 接口路径:`PATCH /api/admin/assets/:identifier/realname-mode` - 通过 `assetService.Resolve()` 将标识符解析为 asset_type + asset_id - asset_type=card → 更新 `IotCard.realname_policy` - asset_type=device → 更新 `Device.realname_policy` - 单接口覆盖两种资产类型,与现有模式完全一致 **决策 5:存量数据默认值为 `none`** 迁移时所有存量卡和设备的 `realname_policy` 默认值为 `none`(无需实名),保持与现有行为完全一致(目前实名拦截已被注释,等于放行)。运营方后续按需批量更新。 **决策 6:命名区分** `RealnamePolicy`(资产的实名策略:何时需要实名)与现有 `RealnameLinkType`(运营商提供实名链接的方式:none/template/gateway)是两个独立维度,字段名不同,不产生混淆。DTO 中 response 字段名为 `realname_policy`。 ## Risks / Trade-offs **[风险 1] after_order 拦截查询增加数据库压力** → 缓解:查询只取 `COUNT(*) > 0`,命中索引(generation + asset_id + status),可加 Redis 缓存(key 为 `realname:after_order_eligible:{asset_type}:{asset_id}:{generation}`,充值/购买成功时主动写入,TTL 24h)。初期数据量小,可不加缓存,待观察后决定。 **[风险 2] DTO 改动面大,遗漏字段** → 缓解:所有涉及卡/设备/资产的 DTO 已在探索阶段完整盘点,tasks.md 按文件逐一列举,不依赖记忆。 **[风险 3] 存量数据全为 `none`,初始化后无法区分"未配置"与"主动设为 none"** → 可接受:`none` 本身是有效业务值(无需实名),与未配置语义相同,不需要额外区分。 ## Migration Plan 1. 创建数据库迁移文件,为四张表新增 `realname_policy` 字段: ```sql ALTER TABLE tb_iot_card ADD COLUMN realname_policy VARCHAR(20) NOT NULL DEFAULT 'none'; ALTER TABLE tb_device ADD COLUMN realname_policy VARCHAR(20) NOT NULL DEFAULT 'none'; ALTER TABLE tb_iot_card_import_task ADD COLUMN realname_policy VARCHAR(20) NOT NULL DEFAULT 'none'; ALTER TABLE tb_device_import_task ADD COLUMN realname_policy VARCHAR(20) NOT NULL DEFAULT 'none'; ``` 2. 迁移前:REALNAME-03 注释代码继续保持注释(不影响线上) 3. 迁移后:代码上线,实名拦截以新字段为准;存量全为 `none`,等于放行,行为不变 4. 回滚策略:字段加 DEFAULT,删除新增字段即可,代码回滚到上一版本 ## Open Questions 无(所有关键问题已在探索阶段与业务方确认)