更新一下

This commit is contained in:
2026-07-21 15:26:07 +09:00
parent 2823ff13bf
commit 4902a02c87
32 changed files with 4138 additions and 294 deletions

View File

@@ -0,0 +1,159 @@
# PRDTECH 全局多视角审计与外部集成追踪
Status: ready-for-agent
---
## Problem Statement
当前系统只有账号操作日志、资产操作日志和手动轮询日志等局部实现。它们的操作者、资源、结果和查询结构不一致,账号与资产审计使用裸 goroutine 写入,进程退出或数据库短暂失败时会丢失;资金、审批、配置和跨模块业务又缺少统一审计。现有记录无法从一次请求、一个业务链路或多个受影响资源串联完整过程。
Gateway、运营商、企业微信和支付等外部交互也没有通用 Integration Log。七月迭代的状态同步、审批和资金处理如果继续各建日志将无法解释“为什么没有请求上游”“哪次回调改变了业务状态”以及“某次资金变化对应哪个审批”。
Access Log 当前只递归脱敏请求体,响应体仍原样记录,登录 Token、个人数据和敏感配置存在泄漏风险回调和文件路由也缺少专门的正文记录策略。
## Solution
一次停机发布切换到四类边界清晰的记录Access Log 负责 HTTP 调试Audit Event 负责不可变业务审计,现有钱包流水/订单/退款等 Domain Ledger 继续作为领域事实Integration Log 负责外部交互和未实际发出的同步尝试。Audit Event 通过资源关系表关联一个操作涉及的多个资源,并用 `request_id/correlation_id/parent_event_id` 串联请求和跨任务业务链路。
本次切换覆盖全部现有敏感写操作:新旧业务统一使用 Audit Writer旧账号、资产和手动轮询审计表停止新增不双写历史数据保留原表并通过 Query 只读投影到新审计中心。切换准备或验证失败则本次版本整体不放量,不能以局部模块继续写旧表作为中间态。
## User Stories
1. 作为审计人员,我希望回答谁在什么入口对哪些资源做了什么,结果、风险和前后变化是什么。
2. 作为运维人员,我希望按 `request_id``correlation_id` 查看一次请求或完整业务链路中的审计、任务、外部交互和领域流水。
3. 作为资产运营人员,我希望从卡、设备、退款、订单、钱包等资源查看跨模块时间线,而不被普通无变化轮询淹没。
4. 作为财务人员,我希望资金审计能关联审批、业务单、钱包流水和资产处理,同时明确钱包流水才是金额事实。
5. 作为安全人员,我希望集中查看失败、拒绝、高风险和严重事件,并确保敏感字段默认脱敏。
6. 作为集成运维人员,我希望看到 Gateway、运营商、企微和支付的脱敏请求结果以及合并、限流、提前完成等未发请求原因。
7. 作为历史数据查询者,我希望旧账号、资产和手动轮询记录仍可只读检索,但发布后不再出现新旧两份不一致记录。
8. 作为普通代理或企业用户,我希望只能在原业务详情看到自己有权限资源的脱敏轨迹,不能进入平台全局审计中心。
## Implementation Decisions
### 四类记录的权威边界
- Access Log 存储在现有日志文件/日志平台,只用于 HTTP 调试、性能和 `request_id` 检索,不作为业务事实或业务审计权威。
- Audit Event 存储在 PostgreSQL回答操作者、动作、资源、结果、风险和字段变化。事件创建后不可更新或删除普通读操作不创建 Audit Event敏感读取例外。
- Domain Ledger 继续由钱包流水、订单、退款、充值、企微审批实例、套餐使用记录等业务表承担。审计 Query 可以链接或投影这些记录,但 Audit Event 不替代领域事实。
- Integration Log 存储 Gateway、运营商回调、企微、微信/支付宝及其他外部交互,也记录业务同步尝试在发请求前被 `merged/rate_limited/completed/cancelled` 的解释结果。
- 普通高频轮询成功且状态未变化只写 Integration Log状态变化、人工强制触发、连续失败或高风险异常再写 Audit Event。
- Outbox 和通用异步任务状态是可靠投递/执行事实,不塞入 Audit Event JSON。审计链路 Query 通过稳定 ID 关联它们。
### Audit Event 数据模型
- 新建 `tb_audit_event`,核心字段包括:不可变唯一 `event_id``occurred_at`、类别、动作编码/名称、操作者快照、入口来源、租户/店铺/企业标签、结果、风险、摘要、错误码/摘要、前后数据、元数据、请求/关联/父事件 ID、IP/User-Agent/路径/方法、`content_hash` 和创建时间。
- `result` 为 string 类型:`success/failed/denied/partial``risk_level` 为 string`normal/warning/high/critical`。操作者、来源、类别和动作均使用集中常量或注册表,不允许各 Service 自由拼接 magic string。
- `actor_kind` 至少支持 `admin_user/agent_user/enterprise_user/personal_customer/open_api_account/system_task/carrier_callback/wecom_callback`。系统和回调允许 `actor_id=NULL`,但 `actor_name` 必须是可读快照。
- `source` 表示入口而不是人员,至少支持 `admin_api/personal_api/open_api/asynq/scheduled_job/gateway/carrier_callback.* /wecom_callback/wecom_polling/data_migration`
- 新建 `tb_audit_event_resource`,每个事件可关联多个 `primary/affected/reference` 资源;字段为事件内部 ID、资源类型、可空资源 ID、资源键快照、关系和创建时间。一个事件至少有一个 `primary` 资源。
- 资源关系使用复合唯一索引,资源 ID 和资源键各有时间线索引;不建立数据库外键或 GORM 关联标签。
- `content_hash` 基于脱敏、标准化后的不可变事件内容生成,用于完整性核对,不包含数据库自增 ID。Repository 不提供 Update/Delete 方法。
- `before_data``after_data``metadata` 分别最多 16KB超限时保存截断标志、原字节数、摘要和受控任务/制品引用,批量明细留在对应业务任务表或对象存储。
### 动作注册表与写入可靠性
- 建立 Action Registry定义稳定动作编码、中文名称、类别、默认风险、允许的资源类型和敏感字段规则DTO 枚举说明、筛选项和前端名称都从同一注册表生成。
- 至少覆盖账号/角色/权限、店铺、资产、套餐、钱包/资金、订单/退款/充值、企微、支付与系统配置、数据同步、导入导出及登录安全的本期动作。未经注册的动作不得写入生产审计。
- 钱包余额、人工退款结果、代理钱包回退、线下充值入账、账号角色/权限、支付/企微/关键系统配置、人工卡状态、敏感店铺业务员归属及手工绑定企微审批号等成功事件必须与业务变更同事务 `AppendWithTx`;审计失败则业务事务回滚。
- 旧 MVC Service 未迁移为 DDD 时通过统一 Audit Writer Adapter 接入新模型,不要求为审计一次性重构全部业务;但当前触碰的复杂资金、审批和卡状态用例仍按各自 Spec 迁入 Application/Domain。
- 业务已经回滚的 `failed/denied` 事件使用独立短事务写入,禁止裸 goroutine。该审计再失败时保留原业务错误同时写 `critical` 应用日志和监控指标。
- 异步系统事件与状态变化通过原业务事务或 Outbox 可靠关联,不使用 `go func()`。Asynq/Outbox 载荷必须传递 `event_id/request_id/correlation_id/parent_event_id`
- `request_id` 由现有中间件生成并贯穿同一 HTTP 请求;`correlation_id` 在退款、充值、审批、钱包、卡状态等跨请求业务起点生成并贯穿 Outbox/Asynq/Integration Log`parent_event_id` 表示直接因果,不用于替代 correlation。
- 对全部旧审计调用建立切换清单和自动检查。发布产物中禁止继续调用旧 `account_audit/asset_audit` 写服务或直接 Create 旧日志模型;启动装配不再注入旧 Writer。
### Integration Log
- 新建 `tb_integration_log`,至少保存唯一 `integration_id`、provider、方向、operation、外部单号、资源、触发来源/场景/序列/尝试、计划/开始时间、结果、HTTP/渠道码及摘要、脱敏请求响应摘要、耗时、是否改变状态、元数据、请求/关联 ID、可空 Audit Event ID 和创建时间。
- `direction``inbound/outbound``result` 至少支持 `success/failed/not_found/invalid_payload/ignored/merged/rate_limited/completed/cancelled`;实施可增加内部 `pending` 执行态,但公开 DTO 必须返回中文结果名称并保持终态语义明确。
- 一次外部尝试使用稳定 `integration_id`。实际调用前先持久化可恢复的尝试事实,完成后条件更新终态;若发出请求后响应未知,必须记录“结果未知”,不得伪装为普通失败并盲目重发具有副作用的外部请求。
- 数据同步按 UR#94 记录 `trigger_source/scene/series/attempt`;同序列详情一次返回 0/3/5 全部尝试。合并、互斥、限频和已达预期即使没有 HTTP 状态也必须可解释。
- 运营商/支付/企微入站回调先保存脱敏摘要和幂等标识,再进入业务处理;原始加密报文、完整回调正文、签名和附件不进入普通审计详情。
- Integration Log 只保存外部交互事实,不存业务审批/支付/卡状态的权威状态;业务改变时通过 `audit_event_id/correlation_id` 关联。
### 脱敏与 Access Log 修正
- 永不进入 Audit/Integration/Access 正文的数据包括密码、操作密码、验证码、Access/Refresh Token、Secret、回调 Token/EncodingAESKey、支付私钥/公钥原文、完整身份证、对象存储签名 URL、企微 `media_id` 和 Authorization/Cookie。Sanitizer 直接删除或只保留字段存在/长度,不把原值保存成可逆掩码。
- 手机号、IP、ICCID、钱包金额和第三方交易号允许在受控审计存储中作为业务快照但普通 Query 默认脱敏;完整查看需要 `audit:sensitive:view`,导出需要独立 `audit:export` 和字段授权。
- 查看完整敏感值本身写高风险 Audit Event携带被查看事件/资源和操作者;不能因为已有全局查看权限跳过二次审计。
- Access Log 的响应体必须和请求体使用同一递归 Sanitizer再执行 50KB 限制;当前 `truncateBody(c.Response().Body())` 原样记录行为必须移除。
- 路由级策略:登录/Token/支付或企微配置只记字段名和长度;支付、企微、运营商回调只记摘要与哈希;文件上传下载不记文件内容和签名 URL普通 JSON 脱敏后最多 50KB。
- 非 JSON 解析失败不能直接原样记录敏感回调或文件应先应用路由策略和安全文本截断。Query 参数和 Header 同样覆盖 token、secret、sign、nonce、authorization、cookie 等字段。
### 一次性切换与历史投影
- 本需求是经用户确认的全局例外:在同一次停机发布中完成新表/索引、Audit Writer、Integration Writer、现有敏感写入口、Query API 和必要前端切换。不得按模块长期双轨运行。
-`tb_account_operation_log``tb_asset_operation_log``tb_polling_manual_trigger_log` 发布后停止新写入,不删除、不回填新表。数据库权限或运行时写入护栏应使意外旧写尽快暴露,不能静默继续。
- 旧手动轮询日志当前还承担进度状态。切换时其用户可见的手动触发、进度和监控接口保持原契约,但运行状态由七月公共异步任务状态承接,外部尝试由 Integration Log 承接;不得因停写旧表使运维功能消失。
- 历史 Query 使用 `UNION ALL` 把旧账号、资产和手动轮询记录规范化为只读投影,返回 `record_source=legacy_account/legacy_asset/legacy_polling` 和确定性历史事件键;旧记录不伪造不存在的 correlation、风险或多资源关系。
- 现有 `GET /api/admin/assets/{identifier}/operation-logs` 在过渡期保留兼容响应,但读取新 Audit Event 与旧资产投影,不再直接绑定旧表;新前端以全局资源时间线为准。
- 发布门禁要求旧写入口清单为零、新旧 Query 样本对账通过、关键事务审计失败回滚通过、Access Log 脱敏通过。任一失败则在开放流量前整体停止发布。
- 数据迁移全部为增量且可回滚。新系统一旦接收生产写入,已生成 Audit/Integration/Outbox 记录不得删除回滚;应用采用前向修复,不能回到旧 Writer 形成新的分裂历史。
### 查询 API 与权限
- 全局审计中心提供:
- `GET /api/admin/audit/events``/{event_id}`
- `GET /api/admin/audit/actors``/{kind}/{id}/summary``/{kind}/{id}/events`
- `GET /api/admin/audit/resources/search``/{type}/{id}/timeline`
- `GET /api/admin/audit/requests/{request_id}/timeline`
- `GET /api/admin/audit/correlations/{correlation_id}/timeline`
- `GET /api/admin/audit/risks/overview``/events`
- `GET /api/admin/audit/integrations``/{id}`
- `GET /api/admin/audit/finance/timeline`
- `POST /api/admin/audit/exports`
- 所有列表服务端分页,默认 20、最大 100稳定时间+ID排序。公共关键词只匹配已有索引支持的摘要、资源键、操作者和请求 ID不对 JSONB 做无索引模糊扫描。
- 资源搜索先查询业务读模型返回候选 `resource_type/id/key/display_name`,再打开时间线;静态 `/resources/search` 必须先于动态资源路由。`include_related=true` 只展开事件已直接关联资源,不递归遍历图。
- Request Timeline 组合数据库中可关联的 Audit Event、Outbox/任务摘要和 Integration LogAccess Log 仍在文件/日志平台API 只返回事件内已快照的 HTTP 摘要和 `request_id` 日志检索标识,不在请求时扫描本地日志文件。
- Finance Timeline 组合 Audit Event 与钱包流水、订单、退款、充值和企微实例,每条明确 `record_source`;金额结论以 Domain Ledger 为准。
- 权限码至少拆分 `audit:global:view/actor:view/resource:view/request:view/risk:view/integration:view/finance:view/sensitive:view/export`。超级管理员拥有全量;普通平台角色按授权和原数据范围取交集。
- 代理和企业账号不能进入全局、人员、风险、资金全局或外部集成中心在业务详情查看资源轨迹时Query 必须重新执行店铺/企业权限和字段脱敏。前端隐藏 Tab 不是授权边界。
- 审计导出复用统一 Export DataSource创建时快照过滤条件、数据范围、字段授权和脱敏级别敏感查看权限不自动授予敏感导出权限权限解析失败时拒绝而非回退全字段。
### 前端审计中心
- `/operations/audit` 使用工作台式 Tab全局事件、人员行为、资源轨迹、请求/业务链路、资金审计、风险事件、外部集成Tab 和字段以后端权限为准。
- 全局事件表展示时间、风险、操作者、操作、主要资源、来源、结果和 request ID行详情抽屉分为事件摘要、操作者/入口、关联资源、结构化字段差异、请求/业务链路和错误/外部交互。
- 资源轨迹先搜索候选再选择,普通无变化轮询不进入业务时间线;外部集成 Tab 可按 provider、operation、方向、结果、资源、触发来源/场景/序列筛选,并连续展示 0/3/5 尝试。
- 人员、风险、请求和业务链路使用服务端汇总/时间线,不由前端下载全量事件再聚合。第一版不做自动封禁、风险处置工单或自由拖拽关系图。
- 敏感字段默认掩码;有权限用户点击“显示敏感数据”后重新请求受控接口并产生敏感读取审计。无权限、历史字段不存在和数据已按策略删除均使用明确但不泄密的状态。
### 保留、清理与可观测性
- 资金、权限、审批和关键配置 Audit Event 保留 5 年;普通资产/业务 Audit Event 保留 2 年Audit Event 默认不由在线应用删除。
- Integration Log 默认 180 天Gateway 无变化成功记录 30 天,异常或状态变化记录 180 天Access Log 保留 30 天。清理按时间/主键分批执行并记录结果。
- 达到单表维护阈值后按月分区并归档超期分区,本期不为尚未达到阈值预建复杂分区管理,但表和 Query 必须支持后续演进。
- 应用运行账号不提供 Audit Event Update/Delete 能力;归档/清理由独立受控维护身份执行。Integration Log 只允许执行态到终态的受控条件更新,不允许事后改写请求结果。
- 监控 Audit/Integration 写入失败、失败短事务失败、旧表意外新增、Outbox 积压、Integration Log 增长/清理、敏感读取和导出次数。
## Testing Decisions
- 领域/Application 测试覆盖事件不可变、多资源至少一个 primary、动作注册、风险默认值、内容哈希稳定、16KB 截断和禁止字段删除。
- PostgreSQL 集成测试验证唯一/查询索引、关键业务与 Audit Event 同事务、审计写入失败回滚、失败/拒绝短事务、重复事件幂等和资源时间线。
- 对账号、角色权限、资产、套餐、钱包、退款、充值、配置、导入导出、登录安全和手动同步建立切换清单;自动测试或静态检查证明生产装配不再调用旧 Writer旧三表发布后无新增。
- 历史投影测试使用旧账号/资产/手动轮询样本,验证 `UNION ALL` 的字段映射、确定性历史键、分页排序、`record_source` 和新旧交界时间无重复/漏项。
- Integration 测试覆盖 outbound 成功/失败/响应未知、inbound 回调、未发送的 merged/rate_limited/completed、同序列 0/3/5、状态变化关联 Audit Event 和重复回调。
- Access Log 测试覆盖嵌套 JSON、数组、非 JSON、登录/Token、支付/企微/运营商回调、上传下载和响应体,证明 Token、Secret、操作密码、签名 URL、Authorization、Cookie 等不落盘且 50KB 生效。
- 权限测试覆盖超级管理员、不同平台角色、代理和企业;验证 Tab、API、字段、资源范围、敏感查看与导出权限相互独立越权资源不泄露存在性。
- Query 性能测试使用代表性事件/资源/Integration 数据,验证分页和常用过滤使用索引、无 JSONB 全表模糊扫描、无资源/操作者 N+1满足项目 P95/P99 目标。
- HTTP 集成测试穿过真实 Fiber 认证、Handler、Query/GORM 和统一错误响应;新 Handler 同步注册两个 OpenAPI 文档生成器并验证静态/动态路由顺序。
- 停机发布演练覆盖暂停 Worker、迁移、旧写护栏、新 Writer 切换、样本对账、恢复 Worker、放量前失败退出和放量后的前向修复已生成审计数据不得通过清表回滚。
- 前端验收覆盖七个视角、权限空态、历史投影、资源候选、request/correlation 跳转、0/3/5 序列、敏感二次查看、导出和错误状态。
## Out of Scope
- 不把 Access Log、Audit Event、Domain Ledger 和 Integration Log 合并成一张万能日志表。
- 不把普通列表、详情和未读数查询全部写成业务审计;只审计敏感读取。
- 不在线回填旧日志到新表,不长期双写新旧审计,不删除旧历史表。
- 不为数据同步另建 `tb_card_sync_execution` 或独立同步审计页面。
- 不把整个旧业务仓库一次性迁成 DDD只统一其审计 Adapter复杂用例按各自需求迁移。
- 不建设自动风控封禁、风险处置工单、自由关系图或实时行为分析平台。
- 不在 API 请求中扫描本地 Access Log 文件,也不把日志文件升级为业务权威存储。
- 不允许应用用户修改/删除 Audit Event不在普通审计详情暴露完整外部报文和密钥。
## Further Notes
- 用户已明确确认一次性全局切换,覆盖标准稿中的“旧写停止、不双写、历史只读投影”口径;这不是可由实现阶段改回渐进双写的建议项。
- 当前已核实旧账号/资产审计使用裸 goroutineAccess Log 响应体未脱敏;这两项是发布前必须消除的现存缺陷。
- 当前手动轮询日志兼做进度存储,切断旧表时必须先由公共异步任务状态承接,不得违反 UR#94“轮询管理外部行为保持现状”的确认结论。
- 本需求较大,进入实现前应依据本 Spec 拆成可独立验证的纵向切片,但不得按“先建表、再 Service、再 Handler”的水平层级拆分也不得改变一次停机切换这一最终发布门禁。

View File

@@ -0,0 +1,127 @@
# PRDTECH 公共站内通知与受控跳转
Status: ready-for-agent
---
## Problem Statement
七月迭代的套餐临期、钱包低余额、企微审批结果和系统异常都需要向系统用户发送消息,但当前代码没有统一的站内通知存储、未读状态、消息中心或受控跳转能力。若每个需求自行建表和接口,会产生不同的防重、接收人、已读和权限规则;若直接把任意 URL 放进消息,又会形成越权跳转和开放重定向风险。
站内通知不承担企业微信审批待办,也不等同于旧轮询告警。业务资金、审批和套餐事务不能因为通知投递暂时失败而回滚,但通知又必须在 Outbox/Asynq 至少一次投递下保持不重不漏。
## Solution
建立一套轻量站内通知写模型业务事务只可靠发布稳定事件Notification Worker 根据受控通知类型、模板和接收人生成每人一条通知,并以事件和接收人唯一键防重。后台账号与个人客户使用各自认证上下文查询自己的未读数、分页列表和已读状态,任何接口都不接受前端传入接收人 ID。
通知只保存受控 `ref_type/ref_id/ref_key`,目标解析接口把它转换为白名单 `target_type` 和结构化目标参数,不保存、不返回任意 URL。跳转后的业务详情继续执行原资源权限校验。
## User Stories
1. 作为后台或代理账号,我希望在顶部看到可靠的未读数,并在通知中心查看自己的审批、临期、同步和系统消息。
2. 作为个人客户,我希望看到与自己订单、套餐和资产有关的简化消息,不看到平台运维消息。
3. 作为用户,我希望重复 Worker 投递不会生成重复消息,重复点击已读也不会报错。
4. 作为用户,我希望点击通知只能进入系统允许的业务页面,权限变化后不能借通知越权查看资源。
5. 作为业务开发者,我希望新增消息场景只注册常量、模板、接收人和目标类型,不复制一套通知表和 Handler。
6. 作为运维人员,我希望没有接收人、模板错误和投递失败均可追踪,不会无限重试或影响原业务提交。
7. 作为前端用户,我希望铃铛、抽屉和通知中心的未读状态一致,请求失败时不会把已有未读数闪回零。
## Implementation Decisions
### 架构与可靠投递
- 站内通知是简单写模型,采用 `Application + Query + Infrastructure`,不创建无业务价值的通知聚合根。
- 关键业务在原事务中写 Outbox提交后由 Relay 投递结构化 Asynq 载荷Notification Worker 负责解析接收人、渲染模板并幂等写通知。非关键系统告警可直接入队,但必须携带预先生成的稳定 `event_id`
- 本需求复用七月公共 `tb_outbox_event`、Relay 和处理租约,不在通知模块复制一套 Outbox。调用项目 `EnqueueTask` 时传 struct 或 map禁止传预序列化 `[]byte`
- 业务提交只依赖 Outbox 同事务成功不等待通知表写入Worker 失败按队列策略重试,不回滚已完成的资金、审批或套餐事务。
- 每个最终接收人独立一行,唯一键为 `event_id + recipient_kind + recipient_id`。同一事件重复消费不重复写,同一事件的不同接收人互不影响已读状态。
- 接收人解析失败分为:暂无可用接收人记 `no_recipient` 并成功结束;数据库/模板等瞬时失败返回任务错误;模板字段永久缺失达到最大重试后进入失败监控,禁止生成残缺正文。
### 数据与常量
- 新建 `tb_notification`,字段至少包括:`id``event_id``recipient_kind``recipient_id``category``type``severity``title``body``ref_type``ref_id``ref_key``is_read``read_at``expires_at``created_at`
- `recipient_kind` 为类型字段,使用 string`account``personal_customer`
- `category` 为 string`approval``expiry``sync``system``severity` 为 string`info``warning``error``critical`。具体 `type` 使用点分业务常量,例如 `package.expiring``wallet.low_balance``wecom.approval.approved``card_sync.failed`
- 所有常量及中文说明统一放在 `pkg/constants/`;通知类型到类别、默认级别、模板和允许目标的映射使用代码注册表,前端不得自行猜测。
- `title/body` 是发送时的纯文本快照,不保存任意 HTML。正文不得包含密码、操作密码、Token、Secret、完整证件、完整敏感回调、长期对象存储 URL 或企微 `media_id`
- 唯一索引覆盖 `event_id, recipient_kind, recipient_id`;未读和分类索引均以 `recipient_kind, recipient_id` 开头并包含 `created_at DESC`;过期时间建立部分索引。禁止数据库外键。
- 第一版未读数直接查询 PostgreSQL不维护 Redis 未读计数,避免通知表与缓存双写不一致。
### 接收人规则
- Notification Worker 只接受稳定用户 ID不按用户名、手机号等可变文本投递。具体用户由业务事件携带角色类接收人在消费时批量解析当前启用账号。
- 审批结果发送给申请人;通过后撤销且资金已执行发送给申请人和当前可用财务角色账号;企微模板/系统配置异常发送给当前可用平台超管或指定运维角色。
- 套餐临期和钱包低余额的店铺接收人为当前启用的店铺主账号及当前仍可用的店铺业务员;去重后逐账号写通知。上级代理可查看下级数据不代表自动成为通知接收人。
- 个人套餐/订单/资产通知使用 `recipient_kind=personal_customer` 和客户 ID。个人客户接口不得返回 `sync/system` 运维消息。
- 账号停用、软删除或业务员关系失效时跳过;已经生成的历史通知仍按原接收人可读,不因后续关系变化转移给其他人。
### API 契约
- 后台、平台、代理和企业账号统一使用当前认证的 `/api/admin`
- `GET /api/admin/notifications/unread-count`
- `GET /api/admin/notifications/unread-summary`
- `GET /api/admin/notifications`
- `PUT /api/admin/notifications/read-all`
- `PUT /api/admin/notifications/{id}/read`
- `GET /api/admin/notifications/{id}/target`
- 个人客户使用:
- `GET /api/c/v1/notifications/unread-count`
- `GET /api/c/v1/notifications`
- `PUT /api/c/v1/notifications/read-all`
- `PUT /api/c/v1/notifications/{id}/read`
- 静态 `/read-all` 路由必须先于 `/{id}` 动态路由注册。后台分类汇总第一版返回 `total` 与四个固定类别C 端第一版只提供总未读数。
- `unread-count` 返回 `count:int64``display_count:string`0 返回 `"0"`199 返回十进制文本,超过 99 返回 `"99+"`
- 列表过滤为 `category``type``severity``is_read``page``page_size`;固定按 `created_at DESC, id DESC`,默认每页 20最大 50。过期通知不进入列表和未读统计。
- 通知项返回 `id/category/type/severity/title/body/ref_type/ref_id/ref_key/is_read/read_at/created_at`,使用统一响应外层和 ISO 8601 时间。
- 单条已读执行带接收人的条件更新。通知不存在、属于别人或已读均幂等返回成功,不泄露通知是否存在;首次成功写同一 `read_at`,重复请求不覆盖。
- `read-all` 的可选 `category` 为空时只更新当前接收人的全部未过期未读通知,返回实际更新数量;非法类别返回参数错误。
- `/target` 先固定当前接收人查询通知,再通过后端白名单返回 `target_type``target_id/target_key``available`;不得返回 URL。目标资源不存在或当前无权访问时 `available=false`,不得泄露更多资源信息。
### 受控目标
- 第一版白名单至少覆盖本期实际场景:退款详情、代理充值详情、企微审批详情、卡详情、设备详情、临期资产列表、店铺资金概况、审计外部集成和系统配置。
- `card_sync` 不指向不存在的独立同步执行页;平台运维消息解析为统一审计中心外部集成目标,并携带受控资源或 Integration Log 标识。
- 前端维护 `target_type -> route builder` 白名单;未知类型和 `available=false` 只展示消息正文,不跳转。拥有通知不等于拥有目标资源权限。
### 前端交互
- 登录布局挂载后立即请求未读数,每 30 秒刷新;页面不可见时暂停,恢复可见时立即刷新。失败保留上次成功数值并提供静默重试,不闪回 0。
- 顶部铃铛固定宽度0 时不显示徽标199 显示数字,超过 99 显示 `99+`。点击打开最近 10 条抽屉,支持全部、审批、临期、同步/系统分类及进入 `/notifications`
- 通知中心支持类别、类型、严重级别、已读状态和服务端分页,并提供当前筛选类别的全部已读。普通消息使用中性色,错误/严重消息才使用警告视觉。
- 点击通知先进入已读视觉状态并调用已读接口,再解析受控目标;已读调用失败时以下次服务端刷新为准。目标解析或跳转失败不把通知恢复为未读。
- C 端使用简化消息列表,只展示与当前客户有关的审批结果、套餐、订单和资产消息。
### 审计、保留与发布
- 普通通知读取和已读只进入 Access Log通知模板/接收人解析失败、系统告警生成和管理性排查进入统一 Audit/Integration Log。不得使用用户通知列表作为管理员查看他人消息的入口。
- 套餐临期展示至到期并保留数据 180 天;审批结果不自动过期并保留 365 天;同步异常展示 30 天、保留 180 天;系统告警按事件指定展示期限、最长保留 365 天。
- 低峰清理任务按主键/时间分批删除超出数据保留期限的通知;用户不提供删除接口,清理不修改业务审计和领域流水。
- 未来短信或企微消息使用独立 Delivery 消费同一业务事件;不得在 Notification Handler/Worker 写完站内消息后同步循环调用外部渠道。
- 新增管理端和 C 端 Handler 后同步注册路由和两个 OpenAPI 文档生成器;公共通知能力先于 UR#33、UR#97 和企微结果通知启用。
## Testing Decisions
- Application/Worker 测试覆盖重复事件、多个接收人、接收人去重、停用/删除接收人、无接收人、模板字段缺失、Outbox/Asynq 重试和过期时间。
- PostgreSQL 集成测试验证唯一索引、未读/分类查询、固定排序、最大分页、过期排除、单条/批量条件更新和并发重复消费。
- HTTP 集成测试穿过真实后台/C 端认证、Handler、Query、GORM 和统一响应;验证前端无法传入或篡改 `recipient_id`,管理员也不能从用户接口查看别人通知。
- 越权测试对“别人通知 ID”“已删除通知”“无权目标资源”返回相同安全语义重复已读保持成功且 `read_at` 不变。
- 目标解析契约测试覆盖全部白名单、未知 `ref_type`、目标删除、权限变化和审计外部集成目标,证明响应中不存在任意 URL。
- 前端测试/人工验收覆盖 0、1、99、100 条徽标30 秒刷新、隐藏页暂停、失败保留旧数、抽屉最近 10 条、筛选、全部已读和点击顺序。
- 使用开发 PostgreSQL/Redis 与测试 Outbox Relay/Asynq Worker 验证至少一次投递;不得给真实用户生成测试通知。
- 生成 OpenAPI 并核对静态 `read-all` 路由未被动态 ID 路由吞掉。
## Out of Scope
- 不建设 WebSocket/SSE 推送,第一版使用 30 秒未读轮询。
- 不在本系统复制企业微信“待我审批”待办,不发送套餐临期企业微信消息。
- 不实现短信、企微消息或邮件 Delivery只保留独立扩展边界。
- 不允许管理员从通知接口查看、修改或删除其他用户消息。
- 不保存富文本 HTML、任意 URL、永久附件链接或外部回调原文。
- 不用 Redis 维护未读数,不让用户自行删除通知。
- 不在本需求实现各业务场景的触发规则UR#33、UR#97 和企微审批需求分别负责发布业务事件。
## Further Notes
- 当前仓库没有通知模型、接口和消息中心,只有轮询告警等运维模型,不能把后者改名充当业务通知。
- 前端仓库不在当前工作区;本 Spec 的路由、状态和错误交互是跨仓契约,实际组件目录以对应前端仓库为准。
- 公共通知和公共 Outbox 是多个单需求的依赖,但不把这些单需求合并成一份整轮迭代 PRD。

View File

@@ -0,0 +1,129 @@
# PRDUR#33 套餐临期查询、Dashboard 与站内提醒
Status: ready-for-agent
---
## Problem Statement
系统尚无统一临期资产列表、Dashboard 汇总和站内提醒。各页面如果直接使用当前套餐到期时间,会忽略排队主套餐并产生不同的临期数量。现有代码也没有七月迭代要求的通用站内通知基础设施,旧轮询告警模型不能承担面向业务用户的消息中心职责。
平台和不同层级代理看到的数据范围不同临期列表、Dashboard 数量和通知接收人都必须严格复用现有店铺层级权限,不能建立绕过权限的全局统计。
## Solution
以 UR#46 的预计最终到期 Query 为唯一事实来源,按 `Asia/Shanghai` 自然日将剩余 015 天定义为临期。提供受权限约束的临期列表,并把临期卡数、设备数作为通用 Dashboard 的首个业务卡片。
每日任务只负责在 15/7/3 天节点产生站内通知和防重记录页面、Dashboard 和导出始终实时查询,不读取每日任务快照。
## User Stories
1. 作为平台人员,我希望查看当前权限范围内全部临期卡和设备。
2. 作为不同层级代理,我希望 Dashboard 数量和列表只包含自己有权查看的店铺层级数据。
3. 作为运营人员,我希望 03 天资产在临期页优先展示,而普通资产列表只高亮不改排序。
4. 作为店铺主账号或业务员,我希望在 15、7、3 天节点收到一次站内提醒。
5. 作为 C 端客户,我希望资产临期时看到续费入口。
6. 作为维护人员,我希望任务漏跑后只补一个最近节点,重试不会重复发消息。
7. 作为运营人员,我希望临期列表能沿用统一导出任务下载结果。
## Implementation Decisions
### 临期规则
- 唯一到期来源是 UR#46`estimated_final_expires_at``days_until_final_expiry`
-`expiry_estimate_status=exact` 且剩余上海自然日为 015 的资产属于临期。
- 已过期(负数)、`waiting_activation``none``invalid_data` 均不进入临期列表、Dashboard 数量和提醒扫描。
- 展示等级固定815 天 `pink=粉红色`47 天 `purple=紫色`03 天 `red=红色`;后端返回 `expiry_level``expiry_level_name`
- 临期独立列表先按“03 天优先”排序,再按预计最终到期时间升序、资产类型和资产 ID 稳定排序。
- 普通卡/设备列表只使用 UR#46 字段高亮,保持原排序。
### 临期列表 API
- 新增 `GET /api/admin/expiring-assets`
- 参数:`asset_type`(可选 `iot_card|device`)、`keyword``shop_id``package_id``days_min``days_max``expires_from``expires_to``page``size`
- `keyword` 按现有资产标识解析能力匹配卡 ICCID/MSISDN/虚拟号或设备稳定标识;其他筛选与 keyword 按 AND 组合。
- `days_min/days_max` 必须在 015 且最小值不大于最大值;日期按上海自然日解析;分页默认 20、最大 100。
- 每项返回 `asset_type``asset_id``identifier``shop_id``shop_name``package_usage_id``package_name``estimated_final_expires_at``days_until_final_expiry``expiry_level``expiry_level_name``can_renew`
- `total` 和 items 使用完全相同的最终到期、筛选和权限条件;不得先取一页资产再在内存过滤临期。
- `can_renew` 只表示当前登录主体是否有合法续费入口,具体可售性继续由 UR#40 的统一套餐可售策略在下单时复核。
### 通用 Dashboard
- 新增通用 `GET /api/admin/dashboard/overview`,本需求交付首个 `expiring_assets` 卡片,不建立无业务内容的抽象插件框架。
- 响应首期结构:`expiring_assets.card_count``device_count``total_count``window_days=15`,并返回服务端计算时间。
- 平台、代理及不同层级代理调用同一接口。统计 Query 必须使用当前账号既有数据权限:平台按平台范围,各级代理按自己被授权的店铺及下级范围,因此不同主体看到不同数量。
- Dashboard 数量与不带额外条件的临期列表使用相同 Query 和权限范围;三个数量必须可由列表结果复核。
- 点击卡数、设备数或合计数进入临期页并携带对应 `asset_type`,不新增第二套详情接口。
- 后续需求可以在 `overview` 响应增加其他业务卡片,但本期不预测未来字段。
- Dashboard 不做跨用户共享缓存如需缓存key 必须包含稳定权限范围版本并确保权限变化立即失效。首期优先实时聚合。
### 权限与接收人
- 临期列表、Dashboard、资产列表、详情和导出分别复用其执行时或任务创建时的权限快照不通过字段筛选扩大行范围。
- `shop_id` 只会缩小现有数据范围;越权店铺按“无权限或资源不存在”处理。
- 每个临期资产的站内接收人为所属店铺主账号及仍有效绑定的平台业务员;去重后逐账号生成通知。
- 不因上级代理可以在列表看到下级数据,就自动向所有上级代理逐级发送通知;接收人以店铺主账号和业务员规则为准。
- 没有可用接收人时记录可观测告警,不创建无接收人的消息。
### 站内通知与防重
- 本需求依赖七月公共站内通知能力:业务事务/任务通过 Outbox 发布通知事件Notification Worker 按 `event_id + recipient_kind + recipient_id` 幂等写 `tb_notification`
- 新增 `tb_expiry_push_record`,唯一键为 `package_usage_id + recipient_id + channel + push_node``channel` 本期固定为站内通知。
- `package_usage_id` 使用最终到期队列中最后一条主套餐使用记录,使新增续费套餐后能够形成新的 15/7/3 提醒周期。
- 每日任务按上海时区运行,扫描当前仍在 015 天内的资产。
- 节点为 15、7、3。命中或漏跑时每次只选择“当前最近且尚未发送”的一个节点剩 14 天补 15剩 6 天补 7剩 2 天补 3不得一次补发多个旧节点。
- 防重记录与 Outbox 在同一数据库事务内写入任务、Relay 和 Worker 至少一次重试不得重复通知。
- 通知使用受控 `ref_type/ref_id` 指向资产或临期列表,不保存任意 URL前端点击后先标记已读再通过受控路由跳转。
- 本期不发送企业微信、短信或邮件临期消息。
### 其他接口与前端
- 卡/设备列表、后台详情和 `GET /api/c/v1/asset/info` 使用 UR#46 的统一字段C 端 `is_expiring=true && can_renew=true` 时展示续费入口。
- 代理首页调用通用 Dashboard而不是临期专用 summary API。
- 通知中心使用公共通知 API展示临期分类、15/7/3 节点文案和受控跳转。
- 前端颜色只根据 `expiry_level`,不重新计算天数阈值。
- 页面加载、空态、权限错误和重试遵循七月公共交互规范。
### 导出
- `POST /api/admin/export-tasks` 新增/注册 `scene=expiring_asset`,查询 filters 与临期列表参数保持同语义。
- 使用现有 DataSource 框架;场景只负责 Count、Headers、Fetch不另建导出队列、文件或下载链路。
- Count 与 Fetch 必须应用同一临期条件和任务创建时保存的 `ScopeShopIDs` 权限快照。
- Fetch 使用稳定 offset/limit 排序,行字段与 Headers 一致Worker 不读取实时登录上下文扩大权限。
- 同步更新 scene 常量、创建 DTO 校验、Registry 和支持场景判断。
### 架构、索引与发布
- 临期列表和 Dashboard 是 Query 层投影,直接使用 GORM/DTO不经过聚合根通知扫描复用同一查询核心。
- 不创建临期状态快照表。`tb_expiry_push_record` 只保存通知防重事实。
- 为最终到期批量计算、临期范围和接收人解析建立必要索引,使用代表性数据验证;禁止按资产或接收人 N+1。
- 实施顺序UR#55 快照 → UR#46 Query → 公共站内通知 → 本需求列表/Dashboard/任务/导出/前端。
## Testing Decisions
- Query 测试覆盖 0、1、3、4、7、8、15、16 和负数天边界,以及所有不可预计状态。
- 验证临期页 03 天优先和稳定分页,普通资产列表排序不变。
- HTTP 集成测试使用真实开发 PostgreSQL、Redis、JWT 和进程内 Fiber App覆盖全部筛选、AND、非法范围、分页和统一错误。
- 创建平台、不同层级代理及不同店铺范围数据,验证临期列表和 Dashboard 数量分别受权限约束,且 Dashboard 可由列表复核。
- 验证越权 `shop_id`、任务创建时权限快照和后续权限变化不会导致导出扩大数据范围。
- 通知测试覆盖精确命中、14/6/2 天漏跑补偿、重复任务、Outbox 重试、多接收人、无接收人、新续费使用记录和已过期跳过。
- 验证同一使用记录/接收人/节点只生成一条通知,不同接收人均能收到。
- DataSource 验证 Count/Fetch 行数及筛选一致、单一表头、CSV/XLSX、分片稳定排序和字段权限。
- 对 100 项列表、Dashboard 和每日扫描验证固定批量查询数量并执行 `EXPLAIN ANALYZE`
- 前端验收临期颜色、置顶、Dashboard 跳转、C 端续费入口、通知已读与受控跳转。
## Out of Scope
- 不发送企业微信、短信或邮件临期提醒。
- 不提供可编辑临期阈值或颜色配置。
- 不维护临期状态或 Dashboard 数量快照。
- 不在本需求实现公共站内通知中心基础设施本身。
- 不提前设计 Dashboard 未来卡片或插件系统。
- 不改变套餐续费可售规则;由 UR#40 负责。
## Further Notes
- 当前仓库没有业务 Dashboard 路由;`GET /api/admin/dashboard/overview` 是通用 Dashboard 的首个正式契约。
- 旧轮询告警的通知字段不是业务通知中心,不得复用为 `tb_notification` 的替代品。
- 导出部分遵循项目 DataSource 体系:任务框架负责分片、文件、对象存储和下载,临期场景只负责数据语义。

View File

@@ -0,0 +1,257 @@
# PRDUR#37 企业微信审批公共能力
Status: ready-for-agent
---
## Problem Statement
当前退款和平台员工线下代充值分别维护本地审批动作,系统内存在通过、拒绝、退回、重提和确认入账等接口及页面。审批节点、审批人和业务终态混在各自 Service 中,无法复用企业微信已有的多级、会签、或签、意见、附件和待办能力,也容易在重复回调或异步失败时重复执行资金动作。
系统当前没有企业微信审批的稳定业务场景、模板版本、平台账号绑定、代理代提交身份、审批实例、回调解密、轮询补偿或统一运行查询。若直接在退款和充值代码中分别调用企微,将产生两套 Token、模板控件、回调、状态和幂等实现。
企微发起人还存在两类身份:平台账号发起业务时必须使用本人绑定的企微成员;代理账号不是企微成员,需要使用固定企微账号代提交。但无论实际调用企微的是谁,审批展示、通知、权限和审计中的申请人都必须是本系统真实业务提交人,不能把固定代提交账号误认为代理本人。
企微模板及控件 ID 会随模板编辑变化;远程申请又不能与本地数据库处于同一事务。系统必须解决模板安全切换、提交结果未知、重复回调、轮询并发、终态只处理一次、敏感配置保护和不同主体的信息可见性。
## Solution
建设一套企业微信审批公共能力,第一版固定服务退款审批和平台员工线下充值审批。企业微信负责模板、节点、审批人、会签/或签、通过/拒绝、意见、附件和企微待办;本系统只负责业务快照、真实业务提交人、企微发起身份、审批状态镜像、回调与轮询同步,以及把首次终态可靠交给对应业务用例。
业务代码只引用稳定场景码。模板 ID、控件 ID、模板详情和字段映射形成不可变版本模板维护必须先暂停场景、等待在途提交租约释放再发布新版本并恢复。场景不可用时退款或线下充值创建在写入任何业务单、审批实例和 Outbox 前直接失败。
平台/超级管理员通过官方企业微信 Web 登录二维码绑定本人 `userid`;代理不绑定企微,由部署配置中的固定成员代提交。审批实例同时冻结真实业务提交人快照、实际企微发起身份及其来源。平台完整查看审批资料,代理只能在原业务数据范围内查看最小化审批摘要。
本地业务单与审批实例同事务创建并写提交 Outbox由 Worker 上传附件副本并调用企微。回调和每 2 分钟轮询共同进入同一个状态同步用例,以 `getapprovaldetail` 为权威;首次进入终态时同事务写业务终态 Outbox业务完成标记保证资金动作只执行一次。
## User Stories
1. 作为超级管理员,我希望查看企微连接和代理固定代提交账号是否就绪,以便在开放业务前发现配置问题。
2. 作为超级管理员,我希望按稳定业务场景管理退款与线下充值模板,而不是让业务代码依赖易变的模板 ID。
3. 作为超级管理员,我希望暂停场景后确认没有旧模板提交仍在运行,再去企业微信编辑模板。
4. 作为超级管理员,我希望读取企微模板并可视化映射业务字段,发布前由后端重新验证控件存在、类型和必填项。
5. 作为平台或超级管理员账号,我希望扫码绑定本人企微身份,以便我创建的审批由本人企微账号发起。
6. 作为平台账号,我只能查看、换绑或解绑自己的企微身份,不能查看其他员工的绑定清单或代其修改 `userid`
7. 作为代理账号,我不需要也不能绑定企微;我创建退款时由系统固定账号代提交,但审批中仍显示我是业务提交人。
8. 作为业务提交人,我希望场景暂停、模板失效或企微身份不可用时在提交表单阶段立即得到明确失败,并保留已填写内容。
9. 作为审批人,我希望企微审批表单包含正确的业务编号、金额、原因、附件和真实业务提交人。
10. 作为运营人员,我希望在审批运行页按业务类型、状态、单号和处理结果定位审批,并查看最后一次权威同步的详情。
11. 作为有审批运营权限的平台人员,我希望手动触发一次详情同步,但不能在本系统执行通过、拒绝或退回。
12. 作为异常恢复人员,我希望对企微提交结果未知的记录安全地绑定已存在的 `sp_no`,或在确认企微未创建后重新发送,避免重复审批。
13. 作为真实业务提交人,我希望审批终态产生站内结果通知,不因实际企微发起身份是代理固定账号而把通知发错人。
14. 作为代理,我希望在有权查看的退款详情中看到申请人、审批状态、状态时间和业务处理结果,但看不到平台内部审批人、意见和审批附件。
15. 作为平台或超级管理员,我希望在具备原业务查看权限时查看审批人、意见、时间线和审批附件。
16. 作为财务人员,我希望企微通过与本地资金处理结果分开显示,避免把“审批通过但业务处理失败”误认为已经完成。
17. 作为审计人员,我希望模板发布、场景变更、账号绑定、异常恢复、回调和外部调用均有可追溯记录,且不泄漏密钥或附件内容。
18. 作为系统维护人员,我希望回调、轮询和重复任务共享同一幂等规则,避免一张业务单重复退款或重复入账。
19. 作为退款发起人,我希望企微拒绝后原退款单保持不可变;若纠正问题后仍需退款,我会创建一张全新的退款单和审批。
20. 作为历史数据查看者,我希望发布前已经结束的本地审批仍以只读历史事实展示,而不是伪造企微单号或审批节点。
## Implementation Decisions
### 范围与系统边界
- 第一版稳定场景固定为:
- `refund_approval`:退款审批。
- `offline_recharge_approval`:平台员工线下充值审批。
- 企业微信负责模板编辑、节点、审批人、会签/或签、通过、拒绝、意见、附件和企微待办。本系统不建立本地审批任务、候选审批人、节点配置或审批按钮。
- UR#37 交付公共企微配置、身份、场景/模板、审批实例、提交、回调、轮询、状态同步、运行 Query 和异常恢复能力。退款及线下充值的具体金额和资金终态分别由 UR#35、UR#34 的业务用例消费公共终态事件。
- 旧退款和线下充值本地审批入口不与企微长期并存;停机发布时由对应业务需求下线通过、拒绝、退回、重提、人工确认入账和操作密码入口。
- 在线微信/支付宝代理充值不进入企微审批;换货也不在本需求接入审批。
### 配置、Token 与敏感信息
- 企微连接通过 Viper 和环境变量配置 CorpID、AgentID、AgentSecret、回调 Token、EncodingAESKey、账号绑定回调地址、代理固定代提交 `userid`、请求超时、2 分钟审批轮询和 10 分钟模板验证间隔。
- Secret、Token、EncodingAESKey 和代理固定原始 `userid` 不进入通用配置表,不由后台在线编辑,也不得通过 API、日志、审计或文档返回。
- `GET /api/admin/wecom/status` 只返回配置就绪状态、最近连通时间、最近错误、回调最近成功时间、Token 最近获取时间、代理固定成员是否就绪及成员显示名。
- Access Token 缓存在 RedisTTL 使用企微 `expires_in - 300秒`,通过 Redis 锁避免多进程并发刷新。
- `auth/getuserinfo`、成员读取、模板读取、附件上传、发起审批和审批详情查询都写 Integration Log仅记录接口、耗时、企微错误码/摘要和请求关联信息,不记录令牌、密钥或完整附件。
- 正式接入前必须轮换曾出现在演示材料中的旧企微密钥,新密钥只能经部署配置提供。
### 审批场景与模板版本
- 场景状态是生命周期 `int``0=已暂停, 1=启用, 2=暂停中``暂停中` 由系统维护,前端不能直接写入。
- 模板版本状态是生命周期 `int``0=已停用, 1=启用, 2=失效`
- 场景保存稳定 `scene_code`、中文名、状态、当前模板版本 ID、暂停原因和乐观锁版本不建立数据库外键或 GORM 关联标签。
- 模板版本保存场景码、递增版本号、企微模板 ID、名称、控件映射、模板快照、指纹、验证结果、发布人和发布时间。历史版本不可修改每个场景只能有一个启用版本。
- 模板发布流程为:暂停场景并停止发放新提交租约,等待所有未过期租约释放,读取新模板,完成业务字段到控件的可视化映射,后端重新调用企微校验,事务内停用旧版本、写新版本、切换当前版本并恢复场景。
- 退款必填映射至少包含店铺、退款单号、订单号、资产标识、申请退款金额、退款原因、附件和真实业务提交人;线下充值至少包含店铺、充值单号、金额、备注、附件和真实业务提交人。
- 后端必须验证模板可访问、控件存在、控件类型匹配和所有必填业务字段已映射。前端不能提交控件类型或名称作为可信事实,也不直接编辑原始映射 JSON。
- 后台每 10 分钟验证启用模板。模板不可访问或指纹变化时暂停对应场景并告警;系统不能假设能从旧模板 ID 自动发现企微生成的新模板 ID。
- 场景处于暂停中、已暂停或当前模板失效时,创建退款或线下充值必须在任何业务单、审批实例或 Outbox 写入前失败。前端保留表单,恢复后用户重新发起创建请求;不留下待补提的孤儿业务单。
- 暂停前已成功创建的审批继续接收回调、参与轮询和终态处理,不因场景暂停而中止。
### 平台账号绑定与代理固定代提交
- 平台和超级管理员必须通过官方企微 Web 登录二维码绑定本人企微成员。只有已登录、启用的当前账号可为自己创建绑定会话;代理账号不能创建绑定会话。
- 绑定会话使用 Redis随机 `state` 有效 5 分钟且通过原子读取删除保证单次消费;独立 `session_id` 保存目标账号、发起账号、固定后台 Origin、状态和错误有效 10 分钟供原页面查询结果。
- 回调不接受前端传入的账号 ID。后端使用同一 CorpID 和自建应用 Access Token 调用 `auth/getuserinfo`;只返回 `openid` 的非本企业成员不能绑定。
- 账号 ID 与企微 `userid` 都一对一唯一。企微成员已绑定其他账号时拒绝覆盖,必须先由超级管理员强制解绑。换绑仍需本人重新扫码。
- 回调成功页只向服务端配置的后台 Origin 使用 `postMessage` 通知原窗口;通信失败时前端按 `session_id` 轮询兜底。Origin 不接受请求参数覆盖。
- 平台/超级管理员创建审批前,其本人绑定必须启用且成员可用;不满足时创建请求在业务落库前失败,并向前端返回可原地拉起绑定流程的错误状态。
- 代理账号不绑定企微。代理创建退款时使用部署配置中的固定成员代提交;该成员必须属于当前企业、启用并在应用可见范围内。未配置或失效时同样在业务落库前拒绝。
- 后台只展示固定成员是否就绪及显示名,不提供查看或修改原始固定 `userid` 的入口。
- 绑定、换绑和固定代提交成员变更只影响新审批。历史审批保留提交时的真实业务提交人、实际企微发起 `userid` 和身份来源快照。
### 真实业务提交人与身份语义
- `business_submitter` 是在本系统实际创建退款或线下充值的登录账号。它是列表、详情、通知、权限和审计中的“提交人/申请人”。
- `wecom_creator_userid` 是调用企微 `applyevent` 的实际成员。身份来源是方式类 `string``self_binding``agent_proxy`
- 业务创建事务和审批实例同时保存真实提交人的账号 ID、账号名称、角色/店铺显示快照,以及实际企微发起身份及来源;前端提交的申请人字段不可信。
- 企微模板的 `submitter` 必填字段始终写真实业务提交人名称和账号标识。代理审批单不能只显示固定代提交账号。
- Worker 使用审批实例在创建时冻结的企微身份,不在真正发送时按账号当前绑定或当前配置重新选择。
### 审批实例与一单一审批
- 审批实例保存业务类型、业务 ID/编号、场景码、模板版本及映射快照、真实业务提交人快照、实际企微发起身份及来源、`sp_no`、提交业务快照、企微详情/审批人快照、提交错误、租约、同步时间、业务处理结果和乐观锁版本。
- `(biz_type, biz_id)` 唯一,`sp_no` 非空时全局唯一;业务表使用唯一 `approval_instance_id` 指向实例。不使用 `round_no`,不存在从同一业务单推算“当前审批轮次”的逻辑。
- 审批状态是生命周期 `int`
- `0=提交中`
- `1=审批中`
- `2=已通过`
- `3=已驳回`
- `4=已撤销`
- `5=通过后撤销`
- `6=已删除`
- `7=提交失败`
- `8=提交结果未知`
- 各 DTO 的状态 description 必须从公共 constants 原文复制,并返回对应中文 `status_name`;不得在退款、充值或列表模块复制第二套审批枚举。
- 一张退款单只对应一条企微审批。企微拒绝同时终结审批和当前退款单,原退款单不可修改、不可重提。若处理拒绝原因后仍需退款,重新调用创建退款接口,生成新的退款 ID、退款单号、业务快照、提交人快照和审批实例。
- 已拒绝退款不视为活跃退款;新建仍需拒绝同一订单或资产存在其他审批中或业务处理未完成的退款。企微撤销、删除或通过后撤销属于异常处置,不自动作为新建退款的放行条件。
### 异步提交、租约与附件
- 业务单、唯一审批实例和 `WeComApprovalSubmissionRequested` Outbox 在同一 PostgreSQL 事务创建。业务 API 返回本地业务 ID和“提交中”不等待远程企微完成。
- Worker 通过状态和 `submit_lease_until` 条件更新领取短租约;租约覆盖对象存储下载、企微附件上传和 `applyevent`,处理期间续租,并通过 `defer` 释放。提交结果未知也必须先持久化再释放。
- 暂停场景先进入“暂停中”并停止新租约;只有未过期提交租约全部消失后才进入“已暂停”。
- 本地私有对象存储 Key 是附件权威引用。Worker 下载到受控临时文件后调用企微 `media/upload` 获得临时 `media_id`;不得把临时 `media_id`、预签名 URL 或敏感密钥写入业务快照作为永久依据。
- `applyevent` 固定使用企微模板审批人配置,不由本系统提交或计算审批节点。
### 提交结果未知与异常恢复
- 企微明确返回失败且确认未创建审批时,实例进入“提交失败”,修复配置后可对同一审批实例再次执行技术发送;这不是业务重提。
- 建连失败且可证明请求未发送时允许自动重试。
- 请求已发送后超时或连接中断时进入“提交结果未知”,禁止自动再次调用 `applyevent`
- 异常恢复只允许超级管理员,或具备独立“企微审批异常恢复”权限的平台账号执行,并提供两个动作:
- 绑定已有 `sp_no`:先查询企微详情并核对企业、模板版本、实际企微发起身份及来源、业务场景、真实业务提交人字段和业务快照,全部一致才允许绑定。
- 确认企微未创建后重新发送:保留原尝试,在同一审批实例下记录新的技术提交尝试,不创建业务审批轮次。
- 两种恢复都要求填写恢复原因,保存原请求尝试、校验依据、操作人、前后状态和结果的高风险 Audit EventIntegration Log 不被覆盖。
- 企微已经通过、拒绝或进入其他明确终态后,不允许再对原业务单执行提交恢复。退款拒绝后的再次退款只能创建新业务单。
- 管理 API 采用明确动作接口:
- `POST /api/admin/wecom/approvals/{id}/bind-sp-no`,请求包含 `sp_no``reason`
- `POST /api/admin/wecom/approvals/{id}/confirm-not-created-and-resend`,请求包含 `reason`
### 回调、轮询与状态同步
- `GET /api/callback/wecom/approval` 按企微协议完成 URL 校验;`POST /api/callback/wecom/approval` 校验 SHA1 签名、AES-CBC 解密并核对 `receiveID=CorpID`
- 回调只快速保存 Integration Log 和触发同步,然后按企微约定返回成功;回调事件中的状态不是最终业务权威。
- `getapprovaldetail` 是状态和审批详情的权威来源。回调和轮询都调用同一个 `SyncApprovalStatus` 用例。
- 轮询保持每 2 分钟扫描应轮询的审批中实例,单批最多 100 条并按最久未轮询优先。轮询通过租约或条件更新避免多实例重复占用。
- 状态同步保存审批详情、审批人名称快照、意见、附件元数据和时间线,以乐观锁更新状态。状态未变化时不产生第二个业务事件。
- 首次进入终态时,同一事务写对应业务终态 Outbox。业务 Worker 失败可重试,但 `business_processed_at` 非空后禁止再次执行资金动作。
- 审批状态和业务处理状态必须独立展示。审批已通过但退款或入账处理中/失败时,审批状态仍保持已通过。
- 通过后撤销且资金动作已经完成时不自动冲正,记录 `critical` Audit Event、站内告警并进入人工处理尚未执行时阻止后续资金任务。
### 权限与信息可见性
- 连接状态、场景、模板版本、模板读取/发布、暂停/恢复、平台账号绑定列表和强制解绑仅超级管理员访问。
- 平台账号只能查询、重新绑定和解绑本人企微身份;代理无绑定能力。
- 审批运行列表、详情和立即同步仅超级管理员,或具备独立“企微审批运营”权限的平台账号访问。该权限不包含配置、模板、绑定管理或异常恢复。
- 提交未知恢复仅超级管理员,或具备独立“企微审批异常恢复”权限的平台账号访问。
- 代理无企微配置、绑定、审批运行列表/详情及恢复接口权限,只能按现有店铺层级数据范围读取退款业务详情中的审批摘要。
- 代理摘要只包含真实业务提交人、审批状态、状态时间和业务处理结果,以及其原业务权限允许查看的退款资料与业务凭证。代理不得看到审批人、内部意见、审批人上传附件或企微内部标识。
- 平台/超级管理员仍必须具备对应业务查看权限,才可读取完整审批人、意见、时间线和审批附件;企微运营权限本身不能绕过退款/充值数据权限。
- 详情、附件解析、受保护下载和导出复用同一主体权限投影。持有附件引用或历史导出链接不能绕过当前权限。
- 所有越权响应使用统一禁止访问错误,不能区分资源不存在与无权限。
### API 契约
- 连接及场景:
- `GET /api/admin/wecom/status`
- `GET /api/admin/wecom/approval-scenes`
- `PUT /api/admin/wecom/approval-scenes/{scene_code}/status`,请求包含目标 `status`(只接受暂停或启用)与 `reason`
- 模板:
- `POST /api/admin/wecom/approval-templates/inspect`
- `POST /api/admin/wecom/approval-templates/publish`
- `GET /api/admin/wecom/approval-templates?scene_code=`
- 本人绑定:
- `GET /api/admin/wecom/account-binding/me`
- `POST /api/admin/wecom/account-binding/sessions`
- `GET /api/admin/wecom/account-binding/sessions/{session_id}`
- `DELETE /api/admin/wecom/account-binding/me`
- `GET /api/callback/wecom/account-binding?code=&state=`
- 超级管理员绑定管理:
- `GET /api/admin/wecom/account-bindings?page=&page_size=&binding_status=`
- `DELETE /api/admin/wecom/account-bindings/{account_id}`
- 审批运行:
- `GET /api/admin/wecom/approvals?biz_type=&status=&sp_no=&biz_no=&page=&page_size=`
- `GET /api/admin/wecom/approvals/{id}`
- `POST /api/admin/wecom/approvals/{id}/sync`
- 两个提交未知恢复接口见上文。
- 企微审批回调:
- `GET /api/callback/wecom/approval`
- `POST /api/callback/wecom/approval`
- 列表默认每页 20、最大 100查询保持稳定排序审批运行页默认按更新时间倒序并以 ID 作为并列排序键。
- 所有后台接口使用统一 `{code,msg,data,timestamp}` 响应,列表使用项目统一分页结构;参数验证失败返回统一参数错误,不向调用方泄漏底层企微、数据库或验证器错误。
### 前端页面与交互
- `/system/wecom` 仅超级管理员可见,包含连接状态、代理固定成员就绪状态、审批场景、模板版本、可视化控件映射、平台账号绑定清单和异常审批入口;不提供 Secret 或固定 `userid` 编辑。
- 平台/超级管理员个人中心展示本人绑定状态、成员名称、最近验证时间、绑定/换绑/解绑;代理不展示绑定入口。
- 平台/超级管理员创建需审批业务时若未绑定,在当前表单原地提供扫码入口;成功后继续填写。代理固定成员不可用、场景暂停或模板失效时保留表单并展示后端明确原因。
- `/operations/wecom-approvals` 及稳定详情路由用于运行监控,展示业务类型/编号、`sp_no`、审批状态、模板版本、真实业务提交人、实际企微身份来源、更新时间、业务处理结果和异常标识。
- “立即同步”只调用详情同步,不执行审批动作。提交未知记录按恢复权限展示两个高风险动作及二次确认;业务处理失败只展示错误摘要和任务状态。
- 业务详情复用统一只读 `approval` 区块。平台按业务权限显示完整详情;代理使用最小投影。所有页面均覆盖加载、空、失败、无权限、场景维护、绑定过期和高风险异常状态。
- 页面不得出现本地通过、拒绝、退回、审批金额修改或人工确认退款按钮。
### 架构与迁移
- 场景、模板版本、审批状态机、提交租约、身份绑定和终态幂等属于复杂写,采用 `Handler → Application UseCase → Domain → Repository/WeCom Adapter`
- 审批运行列表、详情、业务摘要和权限投影走 Query可直接使用 GORM 做分页、批量关联与 DTO 投影,不经过聚合根。
- 企业微信 Token、身份、模板、媒体、审批和回调加解密统一封装在 WeCom Adapter退款和充值不得各自调用企微 HTTP API。
- 不建立数据库外键,不使用 GORM 关联标签;关联 ID 在 Application/Domain 中显式维护。
- 停机发布时创建场景、模板版本、平台绑定和审批实例数据结构,配置真实企微回调/可信域名验证平台发起人绑定和代理固定成员再发布模板映射、Worker、业务接入和前端。
- 已结束的历史本地审批保留为 `legacy` 只读事实,不伪造企微实例。发布时仍未结束的退款/线下充值按创建人类型形成真实企微审批;缺少平台绑定、固定代理身份或创建人事实的记录进入迁移待处理清单,不能继续使用旧本地审批接口。
- 回滚应用时保留已经形成的企微实例、提交人/身份快照、Integration Log 和 Audit Event。已有企微申请进入运行后不得恢复旧本地审批动作只能继续同步或人工处置。
## Testing Decisions
- 主要自动化接缝采用最高公共行为边界Fiber HTTP 路由与认证 → Application/Domain/Query → GORM → 开发 PostgreSQL、Redis 和 Asynq 可控队列;企业微信网络统一替换为可编程 WeCom Adapter。测试不直接断言私有函数或目录结构。
- 回调测试从真实 HTTP 回调入口注入使用测试 Token/AES Key 生成的签名密文,验证 URL 校验、签名错误、解密错误、错误 CorpID、重复事件、快速响应和统一状态同步不绕过回调协议直接调用内部函数。
- 使用 `.env.local` 指向的开发 PostgreSQL 与 Redis 验证迁移、唯一约束、绑定会话、Token 锁、提交租约、轮询领取和幂等;测试及日志不得输出任何连接密码或企微密钥。
- WeCom Adapter 契约测试覆盖 Token 获取/刷新、成员身份、模板读取、附件上传、发起审批、详情查询,以及企微非零错误、超时、响应丢失和限流。自动化测试不依赖真实企微网络。
- 场景测试覆盖首次发布、暂停中停止发租约、租约自然释放/续租、已暂停、新版本原子切换、模板不可访问、指纹变化、必填映射缺失、控件类型不匹配和发布失败保持暂停。
- 身份测试覆盖平台本人扫码、代理禁止绑定、非企业成员、过期/重复 `state`、同一成员绑定第二账号、换绑、自助解绑、强制解绑、固定代理成员可用/失效,以及历史实例不随绑定变化。
- 业务创建前置测试证明场景暂停、模板失效、平台未绑定、代理固定身份不可用时,不产生业务单、审批实例或 Outbox前端重试成功后只产生一组事实。
- 提交测试覆盖 Outbox 重投、并发 Worker、租约过期恢复、附件上传部分失败、明确企微失败、确认未发送的连接失败、已发送响应未知和成功返回 `sp_no`
- 异常恢复测试覆盖无权限、错误状态、`sp_no` 不存在、企业/模板/身份/场景/提交人/业务快照任一不匹配、成功绑定、确认未创建后重新发送、重复操作和高风险审计完整性。
- 状态同步测试覆盖审批中、通过、拒绝、撤销、删除、通过后撤销、详情无变化、回调与轮询并发、重复终态以及乐观锁冲突;验证每个业务终态只产生一次 Outbox。
- 轮询测试覆盖 2 分钟资格、每批 100、最久未轮询优先、多实例并发领取、详情失败重排和回调先到后轮询不重复处理。
- 权限测试覆盖超级管理员、无专项权限平台、仅企微运营平台、仅异常恢复平台、同时具备权限平台和代理;证明企微运营不能维护模板或恢复未知,代理不能访问公共企微接口。
- 信息投影测试对同一退款分别以平台和代理身份读取:平台在具备业务权限时可见审批人/意见/审批附件,代理只见最小摘要;无业务权限的平台即使有企微运营权限也不能读取该业务资料或附件。
- 一单一审批测试验证 `(biz_type,biz_id)` 并发唯一性。企微拒绝后原退款详情只读、旧重提接口不存在;再次退款经创建接口生成新 ID、退款单号、提交人快照和独立审批新旧单互不继承审批资料。
- 日志与响应测试验证 Secret、Token、EncodingAESKey、对象 Key、临时 `media_id`、原始固定 `userid` 和完整附件不会出现在 API、日志或审计详情中。
- 停机迁移测试覆盖历史终态标为 `legacy`、待审批按平台/代理身份迁移、缺失绑定进入待处理、重复执行不重复创建实例,以及旧审批路由确实不再注册。
- 前端人工验收覆盖连接配置、模板维护、暂停等待、本人扫码绑定、代理代提交、审批运行筛选、立即同步、两种异常恢复、权限菜单、详情投影、通过后撤销红色告警以及所有加载/空/失败状态。
- 真实企微联调单独在受控环境完成两个模板的读取/发布、平台本人发起、代理固定账号代发、多级/会签/或签、意见附件、通过/拒绝、回调丢失后的轮询和提交结果未知处置;联调记录不得包含密钥或个人敏感信息。
## Out of Scope
- 不建设本地通用审批流引擎、BPMN、节点设计器、部门模型、直属领导推导、待我审批或本地审批按钮。
- 不让普通运营手工录入平台员工或代理固定账号的企微 `userid`
- 不为代理账号建立企微绑定,也不把固定代提交账号当成真实业务提交人。
- 不在 UR#37 内定义退款金额、代理钱包回溯或线下充值入账的完整业务规则;这些由 UR#35、UR#34 消费公共终态事件实现。
- 不把代理在线微信/支付宝充值或换货接入审批。
- 不调用微信、支付宝等支付渠道自动退款。
- 不在企微审批人端修改退款金额;企微只同意或拒绝提交时固定的业务快照。
- 不允许企微拒绝后的原退款单编辑或重提,不保留 `resubmit` 兼容接口。
- 不因通过后撤销自动冲正已经完成的资金动作。
- 不向代理公开平台内部审批人、意见、审批附件或企微内部标识。
- 不把企微临时 `media_id`、预签名 URL 或外部原始响应当作永久业务资料。
## Further Notes
- 当前代码尚无企微审批公共模块;退款仍注册本地 `approve/reject/return/resubmit`,旧线下充值也保留本地审核路径。实现必须以停机切换后的契约为准,不能把现状默认为最终产品决定。
- 当前退款活跃校验已把待审批和已通过视为活跃;新模型还需覆盖审批通过后的业务处理未完成。已拒绝退款明确不活跃,因此允许同一订单在纠正问题后重新创建新退款单。
- UR#44 列表审批摘要、UR#42 审批附件导出、UR#35 退款终态和 UR#34 线下充值终态都依赖本公共能力它们必须复用统一实例、状态常量、Query、附件权限和 WeCom Adapter不能各自建立替代模型。
- 该需求横跨配置、身份、模板、远程提交、回调、轮询、运行 Query 与安全边界,预计超过一个高质量实现上下文。进入实现前应基于本 Spec 评估并拆分窄的端到端 tracer-bullet tickets而不是按 Model/Service/Handler 水平切层。

View File

@@ -0,0 +1,183 @@
# PRDUR#38 代理主钱包信用额度与角色默认模板
Status: ready-for-agent
---
## Problem Statement
当前代理主钱包只有账面余额、冻结金额和版本,可用金额固定为 `balance - frozen_balance`,数据库还通过历史约束禁止余额为负、禁止冻结金额超过余额。系统因此无法表达平台给代理授信后允许在额度内继续支付的业务规则。
角色和店铺之间也需要区分两类不同事实:客户角色只能为未来新建代理提供默认信用模板;某个既有代理真正可使用的信用额度必须固化在该代理主钱包中。若运行时继续关联角色,角色调整、店铺增减角色或多角色组合都会追溯改变已有资金边界。
当前创建店铺会依次创建店铺、初始账号、角色关系和主/佣金钱包,但这些步骤并未处在同一 PostgreSQL 事务中。信用模板复制若直接追加在现有流程里,失败时可能形成半建店铺或没有正确信用快照的钱包。
权限上,代理虽然可以管理自己或部分下级店铺,但信用额度代表平台授信,不能由代理自行或相互调整;普通店铺管理权限也不能隐式授予额度修改能力。
## Solution
信用额度只落在代理主钱包。主钱包以统一公式计算总可用金额,允许账面余额在信用边界内为负;佣金钱包、资产钱包和平台员工不获得信用额度。
客户角色保存“新建代理默认信用模板”。创建店铺时读取请求中唯一的 `default_role_id`,在同一事务内把当时模板复制到新建主钱包。模板之后修改、店铺角色之后变化都不影响任何既有钱包。
既有店铺通过独立信用额度接口修改实际主钱包额度。代理账号无论店铺层级或数据范围如何都不能调用;只有超级管理员,或拥有独立信用额度管理权限的平台账号可以修改。变更使用钱包 `version` 乐观锁,并在同一事务内校验变更后的总可用金额不能为负。
所有会改变主钱包余额或冻结金额的完整用例统一进入 Wallet Domain/Application维护相同不变量、版本、资金流水和可靠事件资金概况等只读接口走 Query。
## User Stories
1. 作为角色管理员,我希望设置客户角色的新建代理默认信用,但明确知道它不会影响已有代理。
2. 作为有授信权限的平台员工,我希望在代理资金页查看并调整其实际信用额度,并在额度不足以覆盖当前资金占用时得到明确拒绝。
3. 作为代理,我可以查看权限范围内的资金概况,但不能给自己或下级代理调整信用额度。
4. 作为代理主钱包使用方,我希望订单扣款、批量订购、冻结、充值和退款都使用同一个可用金额口径。
5. 作为财务或审计人员,我希望区分账面余额、冻结金额、信用额度、总可用金额和实际欠款,并追溯每次授信变更。
## Implementation Decisions
### 领域语言与资金不变量
- 账面余额 `balance`:已经入账的代理主钱包余额,允许在信用额度内小于零。
- 冻结金额 `frozen_balance`:已经预占但尚未最终扣除的资金,始终大于等于零。
- 有效信用额度:
```text
effective_credit = credit_enabled ? credit_limit : 0
```
- 现金可用金额:
```text
cash_available = balance - frozen_balance
```
- 总可用金额,也是所有扣款、冻结和调额共同维护的边界:
```text
available_balance = balance - frozen_balance + effective_credit
available_balance >= 0
```
- 欠款只表示已经形成的负账面余额:
```text
is_in_debt = balance < 0
debt_amount = max(-balance, 0)
```
冻结金额会降低现金及总可用金额,但尚未最终扣除时不直接记为欠款。
- `credit_enabled=false` 时 `credit_limit` 必须为 0`credit_enabled=true` 时 `credit_limit` 必须大于 0。
- 信用额度不设置产品层固定上限。接口和数据库仍使用分为单位的 `int64`,任何负数、超出整数范围或计算 `balance - frozen_balance + effective_credit` 时发生溢出的请求都必须拒绝。
- 信用额度只属于 `wallet_type=main` 的代理钱包。佣金钱包、资产钱包、平台员工和客户角色本身都不是授信或负债主体。
### 角色默认模板与新建店铺
- `tb_role` 增加 `default_credit_enabled`、`default_credit_limit`,仅客户角色可以保存有效模板;平台角色固定关闭且额度为 0。
- 当前 `POST /api/admin/shops` 已要求唯一 `default_role_id`,不存在从多个角色中选择信用来源的歧义。
- 创建店铺时在事务内重新读取并校验该客户角色,将模板当时的值复制到新建代理主钱包;请求方不能在创建店铺请求中另外覆盖信用字段。
- 店铺、初始账号、账号角色、店铺角色、主钱包、佣金钱包和信用模板复制必须处于同一个 PostgreSQL 事务;任一步失败全部回滚。
- 修改角色模板只影响之后创建的店铺,不扫描、不更新既有钱包。店铺后续增加、删除或更换角色也不改变钱包信用额度。
- 角色模板变更是简单写 Application 事务脚本,并记录全局 Audit Event它不是钱包资金流水。
### 实际额度权限与 API
#### 修改角色默认模板
```text
PUT /api/admin/roles/{id}/default-credit
```
请求:
```json
{
"credit_enabled": true,
"credit_limit": 100000
}
```
- 只允许超级管理员,或具备相应角色管理权限的平台账号操作客户角色。
- 代理账号不能配置角色信用模板。
- 更新模板与 Audit Event 在同一事务内完成;审计失败则业务更新回滚。
#### 修改店铺实际额度
```text
PUT /api/admin/shops/{id}/credit-limit
```
请求:
```json
{
"credit_enabled": true,
"credit_limit": 100000,
"version": 3
}
```
- 代理账号无论操作目标是自己、直属下级还是更深层下级,都一律禁止。不得只调用现有 `CanManageShop` 后放行。
- 超级管理员可以修改;普通平台账号必须同时拥有独立信用额度管理权限,并满足目标店铺的既有查看/数据范围约束。
- `version` 必须与主钱包当前版本一致。更新条件同时约束 `wallet_type=main`、当前版本和新总可用金额不小于零;成功后版本加一。
- 受影响行数为零时重新读取,用统一错误区分钱包已经变化的并发冲突、目标无权/不存在和新额度不足以覆盖当前资金占用。并发冲突不能覆盖别人的新值。
- 调额只改变资金边界,不创建金额为零的伪钱包交易流水;变更前后开关、额度、余额、冻结金额、版本、操作人和请求上下文写入全局 Audit Event。
### 钱包复杂写用例
- `tb_agent_wallet` 增加 `credit_enabled`、`credit_limit`;历史主钱包和佣金钱包均迁移为关闭/0既有行为不变。
- 移除或替换历史 `balance >= 0`、`frozen_balance <= balance` 约束。新数据库约束至少保证冻结金额和信用额度非负、开关与额度组合一致,以及主钱包总可用金额不小于零;佣金钱包仍保持无信用和既有非负边界。
- 迁移前必须查询开发及生产目标库的真实约束名和异常数据,不能把归档迁移中的约束名当成当前数据库事实。
- 所有被本需求触碰的主钱包复杂写完整迁入统一 Wallet Domain/Application扣款、冻结、解冻、充值入账、退款回充、人工调整以及批量订购钱包支付。每个用例都在同一事务维护钱包版本、必要的业务单状态、真实金额钱包流水、Outbox/Audit Event。
- 不允许只修改模型的 `GetAvailableBalance()` 而保留 Store 中旧的现金条件;所有条件更新必须使用同一有效信用公式。
- 金额写入使用行锁或 `version` 条件更新防止并发超额。失败重试不得重复扣款、冻结、充值或退款,继续复用各业务单号/状态条件的幂等边界。
- UR#97 低余额预警继续只使用 `cash_available = balance - frozen_balance`,信用额度不得掩盖现金余额风险。
### 资金概况 Query 与前端
```text
GET /api/admin/shops/fund-summary
```
- 沿用当前列表/详情所需的分页、店铺筛选和数据权限,不新增绕过现有范围的全量资金接口。
- 对每个代理返回至少:`shop_id`、`balance`、`frozen_balance`、`cash_available_balance`、`credit_enabled`、`credit_limit`、`available_balance`、`is_in_debt`、`debt_amount`、`version`。
- 所有金额为分,前端显示为元;前端直接展示接口计算结果,不自行重算可用金额或欠款。
- 角色配置页只对客户角色展示“新建代理默认信用”,并提示“修改后不会影响已有店铺”。
- 店铺资金页按现有查看权限展示资金概况。代理账号和没有独立信用额度管理权限的平台账号不展示调额按钮,后端仍必须独立拒绝越权请求。
- 调额弹框展示当前余额、冻结金额、实际额度和服务端返回的当前总可用金额。前端可以做金额输入预览,但最终结果以后端为准;并发冲突后重新拉取最新资金概况和版本。
- 关闭信用时前端提交额度 0开启时额度必须大于 0。不设置产品层固定最大额度提示只做分/元精确转换和接口安全范围校验。
### 架构、审计与发布
- 代理主钱包扣款、冻结、解冻、充值、退款回充和调额属于复杂写:`Handler → Application UseCase → Wallet Domain → Repository/Infrastructure`。
- 角色默认模板属于简单写 Application 事务脚本;资金概况、列表、统计和导出属于 Query不经过聚合根。
- 只迁移本需求实际触碰的代理主钱包完整用例,不主动改造佣金钱包和资产钱包。
- 停机发布数据库约束/字段、所有主钱包写入端、查询/API 和前端,禁止只上线展示或部分扣款路径。
- 开放普通访问前,保持历史钱包信用关闭,验证普通现金支付、信用扣款、冻结、回充、调额和并发场景。之后再由授权平台人员配置角色模板和既有代理额度。
- 尚未启用信用且未产生负余额时可以回滚应用和可逆字段;一旦出现负余额,不得回滚到不理解信用额度的旧写入逻辑,也不得删除字段或强制关闭信用,必须先清偿欠款或继续保留新资金逻辑。
## Testing Decisions
- 领域测试覆盖关闭/0、开启/正数、负额度、开启/0、关闭/非0、普通现金、使用部分信用、用尽信用、超过信用以及每个算术溢出边界。
- 调额测试覆盖提高额度、降低但仍可覆盖、降低后总可用小于0、欠款时关闭、冻结占用时降低、正确版本和并发旧版本。
- 权限测试覆盖超级管理员、具备独立权限的平台账号、无独立权限的平台账号、代理本人、直属下级和更深下级;证明 `CanManageShop` 不能单独授予调额权。
- 角色模板测试覆盖客户/平台角色、开关组合、模板变更不级联、店铺后续角色变化不级联,以及创建店铺读取模板时与并发模板修改的事务快照语义。
- 创建店铺集成测试验证店铺、账号、角色关系、主/佣金钱包与信用快照全成全败,任一写入失败不会留下半成品。
- 对扣款、冻结、解冻、充值、退款回充、人工调整和批量订购逐一验证统一公式、版本递增、真实金额流水及业务幂等并发请求不能让总可用金额小于0。
- 资金概况 Query 验证现金余额、冻结金额、信用额度、现金可用、总可用和欠款口径;`balance >= 0` 但冻结占用信用时不误报为欠款,`balance < 0` 时欠款金额只取负余额绝对值。
- 数据库迁移先在 `.env.local` 指向的开发 PostgreSQL 核验真实约束和历史数据,再验证新 CHECK相关 HTTP 集成测试同时使用开发 PostgreSQL、Redis 和 Asynq 可控接缝,不在日志或测试产物中输出连接密钥。
- 前端验收覆盖角色提示、代理无调额入口、平台有/无权限、金额精确显示、降低额度失败、版本冲突刷新和停机发布后的历史钱包默认关闭状态。
## Out of Scope
- 不给平台员工、角色、佣金钱包或资产钱包建立信用额度。
- 不允许代理给自己或任何下级代理调额。
- 不让角色模板变化、店铺编辑或角色变化自动级联既有钱包。
- 不设置产品层统一或按角色固定的最大授信上限;风险定额由有权限的平台人员逐店铺决定。
- 不建设还款计划、利息、账期、催收、逾期等级或自动调额策略。
- 不把信用额度计入 UR#97 的现金低余额预警。
- 不为调额伪造金额为零的钱包交易流水。
## Further Notes
- 当前代码的 `AgentWallet.GetAvailableBalance()` 只返回 `balance - frozen_balance`,多个 Store/Service 还各自维护现金条件;实现时必须盘点并迁移所有被触碰的主钱包写入用例,不能以修改一个辅助函数代替完整收口。
- 当前店铺创建流程的多步写入不在统一事务内。本需求要求在复制角色默认信用模板时一并修复该完整创建用例的原子性,但不借机重构其他店铺查询或无关 CRUD。
- “没有固定业务上限”不代表允许整数溢出或浮点金额;后端和数据库必须保持 `int64` 分单位的技术安全边界。

View File

@@ -0,0 +1,142 @@
# PRDUR#40 下架套餐历史用户续费
Status: ready-for-agent
---
## Problem Statement
套餐目前一旦在销售渠道下架C 端可购列表和创建订单都会直接拒绝。该规则适合阻止新购,却也使已经在某个资产上真实购买过该套餐的当前客户无法继续续费,造成存量客户服务中断。
本需求需要严格区分“新购”和“原资产续费”:下架套餐不能重新进入套餐商城,也不能被代理或平台后台代购;只有当前客户本人,针对自己当前持有资产的当前世代、同一个套餐 ID存在真实支付且未作废的历史使用记录时才能从当前套餐或套餐历史记录进入续费。套餐被全局禁用时始终不可购买。
## Solution
建立唯一的套餐可售策略,统一判断套餐全局状态、当前销售渠道上下架状态、调用主体、资产当前归属与世代,以及同资产同套餐的有效历史使用记录。
正常在售套餐继续通过 `GET /api/c/v1/asset/packages` 展示和购买;下架套餐永远不回到该商城列表。`GET /api/c/v1/asset/info` 的当前套餐和 `GET /api/c/v1/asset/package-history` 的历史记录返回购买资格字段,符合条件的下架套餐以 `purchase_mode=renew_only` 提供“续费”入口。点击续费仍调用现有 `POST /api/c/v1/orders/create`,后端重新执行完整资格校验并按下单时当前销售渠道零售价计价。
## User Stories
1. 作为当前资产所有人,我希望继续续费这个资产以前真实购买过的下架套餐,避免存量服务中断。
2. 作为当前资产所有人,我希望待生效、生效中、已用完或已过期的有效记录都能提供下架续费资格。
3. 作为新客户,我不应在套餐商城看到或直接购买已经下架的套餐。
4. 作为代理或平台运营人员,我不能利用后台代购、批量订购或构造请求绕过下架限制。
5. 作为套餐运营人员,我希望禁用套餐无论是否存在历史都绝对不可购买。
6. 作为资产的新所有人,我不应继承上一世代客户的下架套餐续费资格。
7. 作为客户,我希望续费价格使用当前渠道现行零售价,而不是历史订单价格。
## Implementation Decisions
### 统一业务语义
- 套餐全局启用状态使用 `tb_package.status``0=禁用, 1=启用``status=0` 时无条件不可购买,不存在历史续费例外。
- 平台自营渠道的上下架状态读取 `tb_package.shelf_status``1=上架, 2=下架`
- 代理销售渠道的上下架状态读取资产当前 `shop_id` 对应的 `tb_shop_package_allocation.shelf_status``1=上架, 2=下架`;同时分配记录必须存在、未删除且 `status=1`。平台套餐的 `shelf_status` 不替代代理渠道自己的上下架事实。
- 正常在售且其他购买条件成立时返回 `purchase_mode=normal`;当前渠道下架但满足本需求历史续费资格时返回 `purchase_mode=renew_only`;其余情况返回 `purchase_mode=disabled`
- “当前资产所有人”由当前登录个人客户和有效资产绑定关系判断,不能信任前端传入的 `is_renewal`、客户 ID、资产类型或销售渠道。
- 续费资格严格绑定资产、当前世代和套餐 ID。卡 A 的历史不能给卡 B 使用,同系列其他套餐不等同于同一个套餐,资产转手后的新世代不继承上一世代资格。
### 有效历史使用记录
- 同一资产当前 `generation`、同一 `package_id` 存在至少一条未软删除的 `tb_package_usage`,且状态属于以下集合时,视为存在有效历史:
- `0=待生效`:已经真实购买但尚在队列中,计入资格。
- `1=生效中`:当前正在使用,计入资格。
- `2=已用完`:流量已耗尽,计入资格。
- `3=已过期`:使用周期结束,计入资格。
- `4=已失效` 不计入资格。软删除记录不计入资格。
- 使用记录关联订单必须是已经形成真实购买事实且未被撤销或退款的订单。`tb_package_usage.refund_id` 已有值、关联订单为取消/退款状态,或退款/撤销流程已使使用记录失效时均不得产生资格;实现不能只看一个仍未及时更新的状态字段而放过无效购买。
- 待支付订单本身不产生 `PackageUsage` 资格;赠送、后台发放或无真实客户购买事实的记录不得被当作本需求下架续费凭证。
- 查询应使用 `EXISTS` 或等价批量策略判断,不加载全部历史再在内存过滤。
### API 与响应契约
- `GET /api/c/v1/asset/packages?identifier=...` 保持普通套餐商城语义:
- 只返回当前销售渠道正常上架、启用且满足既有系列、赠送套餐、主套餐/加油包及价格规则的套餐。
- 下架套餐即使具备续费资格也不返回,防止续费例外重新变成新购入口。
- 正常返回项增加或统一返回 `can_purchase=true``purchase_mode=normal``disabled_reason=""`,便于前端只消费服务端策略。
- `GET /api/c/v1/asset/info?identifier=...` 的当前套餐投影增加:
- `can_purchase:bool`
- `purchase_mode:string`,固定值为 `normal|renew_only|disabled`
- `disabled_reason:string`
当前套餐处于下架且资格成立时返回 `can_purchase=true, purchase_mode=renew_only`
- `GET /api/c/v1/asset/package-history?identifier=...` 的每条历史套餐记录增加相同三个字段。状态为待生效、生效中、已用完或已过期的记录均按统一策略计算;因此已过期套餐也有实际可点击的续费入口。
- 同一个套餐存在多条历史使用记录时,各条记录返回相同的当前购买资格;续费命令只提交稳定 `package_id`,不把某个 `package_usage_id` 当作授权凭证。
- `disabled_reason` 使用稳定中文业务原因,至少区分“套餐已禁用”“套餐已下架,仅历史用户可续费”“当前资产无该套餐有效历史”“当前渠道未授权该套餐”“套餐价格配置异常”和既有主套餐/加油包限制。前端不得解析文案推导状态。
- 所有响应继续使用项目统一 `{code,msg,data,timestamp}` 包装,现有分页、排序和错误格式不变。
### 创建订单与强校验
- 继续使用 `POST /api/c/v1/orders/create`,请求仍为 `identifier + package_ids` 及现有支付相关字段;不新增续费接口,不新增可信 `renewal` 布尔值。
- 创建订单时必须重新解析登录客户、资产当前绑定、资产当前世代、销售渠道、套餐和历史资格,不能信任列表接口先前返回的资格,也不能因客户端缓存继续放行。
- 每一个请求套餐都进入同一可售策略。下架套餐只有个人客户本人在对应资产上满足历史资格时通过;后台普通订单、线下代购、代理代购、自动购买和批量订购均不得进入该例外。
- 正常在售套餐继续沿用现有购买规则;下架续费不能绕过同系列限制、赠送套餐限制、主套餐/加油包组合规则、价格不低于成本、实名顺序、支付、幂等和订单状态规则。
- 套餐在展示后被禁用、渠道分配被禁用/删除、资产转手、客户解绑、历史记录退款/失效或价格变为非法时,创建订单必须按最新事实拒绝。
- 下架续费价格不复用历史 `PackageUsage.paid_amount`、历史订单项价格或历史零售价快照:
- 平台自营按下单时套餐当前有效零售价计算。
- 代理渠道按下单时当前有效分配零售价计算,并继续校验不低于当前授权成本价。
- 当前订单创建防重机制继续生效;本需求不创建第二套续费订单类型或幂等表。
### 架构边界
- 这是购买策略变更。将“套餐是否可售”收口为一个可被 C 端 Query、C 端创建订单及其他购买入口复用的策略能力,禁止在 Handler、列表组装和订单 Service 各复制一套分支。
- C 端资产信息、套餐商城和套餐历史属于 Query/DTO 投影;可以批量读取套餐、渠道分配和历史资格,不经过聚合根。
- 创建订单属于现有订单写用例,在写入前调用同一策略并重新验证写时事实;不主动迁移未触碰的订单、套餐或资产模块。
- 需求 19 批量订购必须调用同一策略,但由于其操作者不是个人资产当前所有人,遇到渠道下架套餐固定拒绝;这项依赖不能被解释为批量订购也支持 `renew_only`
- 资格判断应显式携带 actor、asset、generation、package、sales channel 和 operation context不能依赖易被遗漏的隐式布尔开关。
### 权限、审计与数据
- 所有 C 端查询和创建订单先执行现有个人客户认证及 `OwnsAsset` 有效绑定校验;无权与资源不存在继续使用统一安全错误语义。
- 不新增数据库字段或资格快照表。资格从当前绑定、资产 `generation`、套餐、渠道分配、订单和 `PackageUsage` 实时判断,避免退款、转手或下架变化后保留过期授权。
- 现有套餐、渠道上下架和订单审计继续记录;成功创建下架续费订单时,统一 Audit Event 中增加 `purchase_mode=renew_only`、资产类型/ID/世代、套餐 ID、销售渠道及命中的历史资格摘要不记录客户敏感明细。
- 被拒绝的构造请求按公共失败审计规则记录失败原因;本需求不新增独立续费日志表。
-`tb_package_usage` 的资格查询核对现有索引;若生产库缺少覆盖 `asset + generation + package_id + status + deleted_at` 的有效索引,按真实查询计划补充普通/部分索引。禁止建立外键。
### 前端交互
- 套餐商城只渲染 `GET /asset/packages` 返回的正常在售套餐,不自行合并历史下架套餐。
- 当前套餐和套餐历史项根据 `can_purchase``purchase_mode` 展示:
- `normal`:显示原购买/续费动作。
- `renew_only`:显示“续费”,并明确该套餐已下架、仅限当前资产续费。
- `disabled`:隐藏或禁用按钮;需要解释时展示 `disabled_reason`
- 已过期套餐的续费入口位于套餐历史列表,因此不依赖“当前套餐”仍然存在。
- 点击“续费”复用现有套餐确认、下单和支付流程,只携带资产标识和套餐 ID不向后端提交自判的上下架状态或续费资格。
- 页面加载、空态和失败态继续保持原交互。提交期间禁止重复操作;资格在展示后失效时原地展示后端最新中文错误并刷新资产/套餐数据。
### 发布与回滚
- 无历史数据回填,不为存量资产预计算续费资格。
- 发布前只读核验:套餐及代理分配上下架/启用组合、当前世代使用记录状态、退款记录与 `PackageUsage` 失效的一致性,以及资格查询执行计划。
- 若发现退款完成但使用记录仍处于 `03` 的异常数据,先按订单/退款事实修复或让策略显式排除,不能把异常记录作为续费资格。
- 应用回滚后不删除已产生的正常订单、使用记录或审计。若需要关闭能力,通过回滚应用恢复原“下架全部拒绝”行为,不修改历史数据。
## Testing Decisions
- 可售策略单元测试覆盖套餐全局禁用、平台/代理渠道上架、渠道下架、分配缺失/禁用/软删除、价格低于成本和赠送套餐。
- 历史资格测试逐一覆盖 `0=待生效、1=生效中、2=已用完、3=已过期` 均允许,`4=已失效`、软删除、退款、撤销、待支付和非真实购买记录均不允许。
- 归属测试覆盖当前客户/其他客户、同资产/其他资产、同套餐/同系列其他套餐、当前世代/上一世代,以及解绑、转手和换货后的资格隔离。
- Query 集成测试验证:商城永不返回下架套餐;当前套餐和历史记录正确返回 `can_purchase/purchase_mode/disabled_reason`;已过期记录存在可用续费入口;同套餐多条历史无 N+1。
- HTTP 集成测试穿过真实 Fiber 认证、Handler、Query/Application、GORM/PostgreSQL 和统一响应,验证字段枚举、中文错误、分页和数据权限。
- 创建订单测试验证不接受伪造续费标志,展示后禁用/下架/转手/退款/改价的竞态会按写时事实拒绝;正常上架新购不回归。
- 入口矩阵验证个人 C 端下架续费成功,而平台后台普通订单、代理代购、自动购买和需求 19 批量订购对同一下架套餐全部拒绝。
- 价格测试验证使用当前平台或代理渠道零售价,不复用历史价格;代理当前零售价低于成本时拒绝。
- PostgreSQL 查询测试核对历史资格 `EXISTS` 使用适当索引,并确保列表批量投影无逐行查询。
- 前端验收覆盖在售购买、当前套餐下架续费、待生效/已用完/已过期历史续费、禁用、无历史资格、资格过期后的错误刷新及重复提交。
## Out of Scope
- 不把下架套餐重新加入普通套餐商城或搜索结果。
- 不允许平台、代理、批量任务或其他后台主体代购下架套餐。
- 不按套餐系列继承资格,不跨资产、不跨世代共享资格。
- 不复用历史价格,不承诺原价续费。
- 不允许全局禁用、赠送、已失效、退款或撤销记录产生续费资格。
- 不新增续费订单类型、续费 API、资格表、资格缓存或独立日志表。
- 不限制客户拥有多少个待生效主套餐;如未来需要限制囤积,应另立业务规则。
## Further Notes
- 当前 `GET /api/c/v1/asset/packages` 已按平台套餐或代理分配的渠道状态过滤,`POST /api/c/v1/orders/create` 最终进入 `purchase_validation.Service`;实现应深化这一公共策略接缝,而不是在 C 端 Handler 临时放行。
- 当前套餐历史已经按资产当前 `generation` 查询,这是隔离资产转手前后购买事实的现有基础。
- 当前 `PackageUsage` 已保存 `order_id``refund_id`、状态和世代,可在不增加资格字段的情况下判断;但必须联合订单/退款事实防止异常状态误放行。
- 用户已明确确认:待生效记录属于有效历史;下架续费入口同时覆盖当前套餐和套餐历史;价格采用下单时当前销售渠道零售价。

View File

@@ -0,0 +1,286 @@
# PRDUR#42 统一导出场景与角色字段权限
Status: ready-for-agent
---
## Problem Statement
当前系统已有 `tb_export_task + DataSource + Asynq` 异步导出框架,但只支持 `device``iot_card``order` 三个场景,导出列由各 DataSource 固定返回,角色无法控制可导出的字段。本期还需要补齐钱包流水、套餐、退款、换货、代理充值和临期资产导出,并扩展卡导出字段。
导出字段权限必须是后端权威,但不应再引入“用户创建任务时选择字段”这一层:用户只决定导出哪个列表、使用什么格式和哪些查询条件;实际导出列完全由代码支持字段目录与账号角色授权决定。字段授权只控制列,不能扩大账号原有页面、接口或数据行范围。
退款、充值业务凭证及审批附件可能是图片,也可能是普通文件,不能嵌入 CSV/XLSX。对象存储 Bucket 当前为私有,现有预签名 URL 只有短期有效期,也不能把对象 Key 或公开 Bucket 地址写入导出文件。导出需要提供长期稳定、每次访问重新鉴权的业务附件链接。
## Solution
保留现有导出任务、分片、文件合并、对象存储和下载流程,为每个 Scene 建立代码字段目录,并新增角色—场景—字段授权表。
创建任务时,后端根据当前账号的有效角色计算字段授权并集,与代码字段目录求交集,得到全部最终字段;前端不传 `fields`,也不展示导出列选择器。超级管理员拥有代码目录内全部字段。普通账号最终字段为空时不创建零列任务。
任务创建时一次性保存最终字段、实际表头、查询条件和数据权限快照。Worker 只消费快照,角色权限之后增加或收回都不会改变已经创建的文件列。
退款和充值导出的凭证及审批附件以长期稳定的后台前端落地页 URL 表示。落地页使用当前登录态调用后端附件解析接口;后端重新校验该业务单的数据权限,再为私有对象生成短期预签名地址。导出文件不包含文件内容、对象 Key、企微 `media_id` 或永久公开地址。
## User Stories
1. 作为运营人员,我只需按当前列表查询条件发起导出,不需要再次选择字段。
2. 作为角色管理员,我可以按导出场景配置一个角色能够导出的字段,并通过空列表收回该场景全部字段。
3. 作为拥有多个有效角色的账号,我可以导出这些角色授权字段的并集,但只能导出原本有权查看的数据行。
4. 作为超级管理员,我可以使用全部代码支持字段并在停机窗口内为普通角色完成权限配置。
5. 作为退款或充值数据的合法查看者,我可以从历史导出文件打开长期稳定的附件入口;打开时仍需登录且仍需具备当前数据权限。
6. 作为维护人员,我希望导出重试、分片和恢复始终使用任务创建时的字段、表头和权限快照,不因实时角色变化出现不同列或越权。
## Implementation Decisions
### 最终字段规则
- 创建请求不包含 `fields`。最终字段固定为:
```text
resolved_fields = 当前账号有效角色的字段授权并集
∩ 当前 Scene 的代码支持字段目录
```
- 超级管理员不读取角色字段授权表,直接获得当前 Scene 代码目录中的全部字段。
- 普通账号的有效角色解析必须复用现有账号权限服务的角色解析语义,包括账号角色与店铺角色的既有继承/回退规则;不得为导出另造一套角色归属算法。
- 多个有效角色的字段权限取并集。数据库中已经失效、软删除或不属于该 Scene 代码目录的授权记录不能生效。
- 代码目录只注册产品允许被导出的业务字段。密码、密钥、Token、对象存储 Key、企微 `media_id`、内部配置原文等确定不能导出的内容不进入目录,因此无法通过角色配置放开。
- 对已经进入代码目录的字段,不再按“敏感字段”等主观分类追加第二层业务限制;但字段授权不能突破来源业务本身按主体定义的信息可见性。角色字段配置是可见业务字段能否导出的最高依据,不是业务详情权限的替代品。
- `resolved_fields` 按代码目录的固定顺序输出,不能受角色记录插入顺序影响。
- 普通账号权限解析失败时返回错误,不能回退全字段;解析成功但最终字段为空时拒绝创建任务,提示“当前角色未配置该场景的导出字段”。
- 不再存在 `default_selected`、`required` 或后端自动补列。获授权字段全部导出,未授权字段全部不导出。
### 场景访问权与数据行权限
- 字段授权只控制列不授予菜单、按钮、API 或业务场景访问权。账号仍需先通过现有路由权限和对应列表/业务场景的访问校验。
- 保持当前导出服务允许的账号类型和数据范围,不借 UR#42 新增企业账号导出能力;后续如要开放企业导出应另行定义 Scene 的企业数据范围。
- 代理继续使用任务创建时保存的 `ScopeShopIDs`;平台代理和各层级代理的数据范围继续复用现有 GORM 权限及店铺层级语义。请求中的 `shop_id` 等筛选只能缩小范围,不能扩大范围。
- `Count` 与 `Fetch` 必须使用完全相同的业务筛选和权限范围;不能先 Count 全量、再在 Fetch 中过滤,也不能在 Worker 中读取实时登录上下文。
### 字段目录
- 每个字段定义至少包含稳定 `key`、中文展示 `label`、表头解析器和行值解析器。普通字段通常对应一个表头;动态组合字段允许一个字段 key 展开为多个实际表头。
- `scene + field_key` 一经发布就是角色配置契约,不得仅因中文文案调整而改 key。
- 代码目录与 DataSource 注册必须来自同一个 Registry避免 API 显示可授权字段但 Worker 不支持,或 Worker 能导出未进入权限目录的字段。
- 现有动态卡槽列作为一个逻辑字段组授权:
- `device.bound_cards` 展开为每个实际卡槽的 ICCID、接入号、运营商、使用流量和卡状态
- `order.bound_cards` 展开为每个实际卡槽的接入号和 ICCID。
- 动态字段组的实际列数在任务创建时依据同一查询条件和数据权限解析,并写入 `resolved_headers`;字段权限只控制整个字段组,不按“第几个卡槽”拆出易失效的权限 key。
各场景稳定字段目录如下;具体中文状态必须调用 `pkg/constants/` 的现有枚举名称函数,不能在导出源中另写一套枚举:
| Scene | 稳定字段 key |
|-------|---------------|
| `device` | `virtual_no`、`imei`、`device_name`、`shop_name`、`series_name`、`device_model`、`bound_cards`、`total_usage_mb`、`active_package_activated_at`、`active_package_expires_at`、`active_package_name`、`wallet_balance` |
| `iot_card` | `iccid`、`msisdn`、`device_virtual_no`、`carrier_name`、`shop_name`、`device_name`、`realname_status`、`first_realname_at`、`network_status`、`package_name`、`data_usage_mb`、`remaining_data_mb` |
| `order` | `order_no`、`shop_name`、`asset_identifier`、`package_names`、`seller_cost_price`、`actual_paid_amount`、`payment_status`、`payment_method`、`quantity`、`created_at`、`third_party_trade_no`、`bound_cards` |
| `agent_wallet_transaction` | `shop_name`、`transaction_type`、`amount`、`status`、`asset_type`、`asset_identifier`、`created_at`、`balance_before`、`balance_after`、`package_name`、`operator_name`、`transaction_id`、`business_order_no`、`payment_method` |
| `package` | `package_code`、`package_name`、`series_name`、`package_type`、`duration_months`、`duration_description`、`calendar_type`、`duration_days`、`real_data_mb`、`virtual_data_mb`、`virtual_data_enabled`、`virtual_ratio`、`data_reset_cycle`、`expiry_base`、`cost_price`、`suggested_retail_price`、`price_config_status`、`status`、`shelf_status`、`is_gift`、`creator_id`、`updater_id`、`created_at`、`updated_at`、`deleted_at` |
| `refund` | `refund_no`、`shop_name`、`payment_order_no`、`asset_type`、`asset_identifier`、`package_name`、`original_amount`、`actual_received_amount`、`refundable_amount`、`requested_refund_amount`、`actual_refund_amount`、`status`、`refund_reason`、`remark`、`approval_source`、`approval_status`、`processing_status`、`current_node_name`、`approval_records`、`applied_at`、`completed_at`、`submitter_name`、`voucher_links`、`approval_attachment_links` |
| `exchange` | `exchange_no`、`exchange_type`、`exchange_reason`、`problem_description`、`old_asset_type`、`old_asset_identifier`、`new_asset_identifier`、`receiver_name`、`receiver_phone`、`receiver_address`、`express_company`、`tracking_no`、`status`、`submitter_name`、`created_at` |
| `agent_recharge` | `recharge_no`、`shop_name`、`recharge_type`、`recharge_amount`、`actual_amount`、`balance_before`、`balance_after`、`status`、`payment_method`、`operation_remark`、`reject_reason`、`created_at`、`paid_at`、`completed_at`、`submitter_name`、`approval_source`、`approval_status`、`current_node_name`、`approval_records`、`voucher_links`、`approval_attachment_links`、`remark` |
| `expiring_asset` | `asset_type`、`asset_identifier`、`shop_name`、`package_name`、`estimated_final_expires_at`、`days_until_final_expiry`、`expiry_level`、`data_limit_mb`、`data_usage_mb`、`remaining_data_mb` |
- `package` 保持评审确认的 25 个字段 key。软删除套餐是否进入结果仍与套餐列表当前查询条件一致当列表不返回软删除数据时`deleted_at` 列为空,但字段契约仍保留。
- `agent_recharge` 保留“支付方式” `payment_method`,明确不注册或导出“支付通道” `payment_channel`。
- 钱包流水的套餐名称优先读取订单项不可变 `package_name` 快照;仅历史缺失时回退流水 `metadata.package_name`,禁止关联当前可修改套餐名。
- 金额数据库与内部查询使用分,导出统一格式化为带两位小数的元;流量统一使用 MB并避免剩余量出现因计算误差导致的负零。
### 字段权限数据模型与迁移
- 新增 `tb_role_export_field_permission`,至少包含项目统一主键/审计时间、`role_id`、`scene`、`field_key`、`creator`、`updater`;不建立数据库外键或 GORM 关联标签。
- 对未软删除记录建立 `(role_id, scene, field_key)` 唯一索引,并建立按 `role_id + scene` 查询的索引。
- 迁移不为任何普通角色初始化字段权限,也不根据现有菜单权限猜测默认列。
- 发布采用已确认的短暂停机流程部署数据库、API、Worker 和前端后,业务使用超级管理员为普通角色逐场景配置字段;完成配置与抽样验证后再恢复普通用户访问。
- 超级管理员不依赖本表,所以即使表为空也能打开角色配置和执行全字段导出。
- 回滚数据库结构前必须确保新 API/Worker 已回滚并处理或终止使用 `resolved_fields` 的待执行任务,不能让旧 Worker 把缺失字段快照理解为全字段。
### 字段权限 API
#### 查询当前账号最终字段
```text
GET /api/admin/export-fields?scene={scene}
```
- 返回当前账号在该 Scene 最终会导出的字段,不是“可供用户勾选”的候选集。
- 成功响应结构:
```json
{
"scene": "package",
"fields": [
{"key": "package_code", "label": "套餐编码"},
{"key": "package_name", "label": "套餐名称"}
]
}
```
- 普通账号没有字段授权时返回 `200` 和空 `fields`,便于前端隐藏/禁用导出按钮;这不代表账号获得场景访问权。
- Scene 不存在返回统一参数错误;角色权限解析失败返回服务错误,不能伪装成空列表。
#### 查询与保存角色字段
```text
GET /api/admin/roles/{role_id}/export-fields
PUT /api/admin/roles/{role_id}/export-fields
```
- GET 返回代码支持的全部 Scene 及该角色当前保存的字段 key供角色管理页分组展示。
- PUT 请求体固定为:
```json
{
"scene": "package",
"field_keys": ["package_code", "package_name"]
}
```
- PUT 是该角色单个 Scene 的全量替换;`field_keys=[]` 合法,表示收回该角色该 Scene 的全部字段。
- 后端验证角色存在且操作者有权管理该角色、Scene 已注册、每个 key 属于该 Scene未知 key 整单拒绝,不进行部分保存。重复 key 去重后保存。
- 全量替换在一个 PostgreSQL 事务内完成,成功后写全局 Audit Event记录角色、Scene、变更前后字段集合和操作者审计失败则权限变更回滚。
- 角色字段管理沿用角色管理的后台权限,不因能够查询自己的 `export-fields` 就允许修改角色。
### 创建任务 API 与快照
```text
POST /api/admin/export-tasks
```
请求体固定为:
```json
{
"scene": "refund",
"format": "csv",
"query": {
"filters": {
"status": 1,
"shop_id": 10
}
}
}
```
- `format` 继续支持 `csv|xlsx`;用户对批量导入选择 CSV 不改变导出同时支持两种格式的现有契约。
- `query.filters` 使用对应列表的同名、同类型、同边界筛选条件,各条件按 AND 组合。分页参数不进入导出筛选,导出所有匹配行。
- 请求体即使额外传入历史设计的 `fields` 也不能影响结果DTO/OpenAPI 不再声明该字段。实现按项目 JSON 解析既有兼容策略处理未知字段,但绝不能读取它决定列。
- Service 在写 `tb_export_task` 前完成:账号/场景访问校验、数据权限范围快照、角色字段解析、最终字段解析和实际表头解析。任何一步失败都不创建任务、不入队。
- `query_json` 保存原始规范化 `filters`、`resolved_fields`、`resolved_headers`;现有权限字段继续保存于任务专用快照列。表头和字段必须同时非空且映射一致。
- 任务创建与 dispatch 入队仍沿用现有失败闭环Asynq 载荷必须传 `{task_id}` struct禁止预序列化为 `[]byte`。
- Worker 不重新查询角色字段权限,也不能在缺失/解析失败时回退 DataSource 全字段。新版本 Worker 遇到没有 `resolved_fields` 的遗留待执行任务必须安全失败;停机发布前应先盘点并处理旧待处理/处理中任务。
### DataSource 与异步执行
- 复用现有 `DataSource.Scene/Count/Headers/Fetch` 和 dispatch → shard → finalize 流程,不另建导出队列。
- `ExportParams` 增加任务快照的 `ResolvedFields`,并继续携带 `ResolvedHeaders`、`Filters`、`ScopeShopIDs`、账号类型和创建店铺。
- `Headers` 只按 `ResolvedFields` 生成实际表头;动态字段组允许查询同一数据范围确定展开数量。任务创建后所有分片和 finalize 只使用已保存表头。
- `Fetch` 只查询并输出已解析字段,输出顺序与 `ResolvedHeaders` 严格一致。即使框架会兜底补齐/截断DataSource 也不得依赖该兜底掩盖字段错位。
- 每个 Scene 的 `Count` 和 `Fetch` 共享一个筛选构造函数JOIN 可能放大行数时必须显式保持“一条业务实体一行”的语义。
- `Fetch` 使用稳定排序,通常为业务表 `id ASC`;临期场景使用 UR#33 已确认的“03 天优先、最终到期升序、资产类型、资产 ID”稳定排序。
- 退款、充值的提交人和审批摘要复用 UR#44/企微审批 Query按当前分片业务 ID/实例 ID 批量读取;附件引用也批量读取,禁止逐行或逐附件 N+1。
- `device`、`order` 的动态卡槽组及新增字段权限改造不能改变未授权字段之外的现有行语义、筛选语义和金额/时间格式。
### 各新增/扩展场景规则
- `iot_card`:新增当前生效主套餐名称、已用流量、剩余流量;一个卡只输出一行。生效主套餐按 `status + master_usage_id + priority + id` 稳定选取,不能由 JOIN 产生重复卡行。剩余流量按权威额度减已用量计算。
- `iot_card` 新增 `final_expiry_within_days=30` 筛选,只接受本期定义的 30 天窗口:复用 UR#46 最终到期 Query按 `Asia/Shanghai` 自然日包含剩余 `030` 天,排除已过期和不可预计资产。它独立于 UR#33 页面 `015` 天临期定义。
- `agent_wallet_transaction`:与代理主钱包流水列表的筛选和店铺范围一致;交易 ID 是钱包流水 ID关联业务单号按流水引用类型批量解析。
- `package`:与套餐列表的筛选、可见范围和软删除语义一致,输出评审确定的 25 个稳定字段。
- `refund`:与退款列表的筛选、数据权限、提交人、审批来源、审批状态、本地处理状态一致;平台/超级管理员可按业务权限输出审批记录摘要和审批附件链接;代理即使角色获授相同字段 key也只能输出审批结论和自身业务资料`current_node_name`、`approval_records`、`approval_attachment_links` 不得包含平台内部审批人、意见或审批附件。业务退款凭证仍按退款查看权限输出。
- `exchange`:与换货列表筛选及数据权限一致;本期换货不接企微审批,不伪造审批列。
- `agent_recharge`:与代理充值列表筛选和数据权限一致;在线充值 `approval_source=none`,平台员工线下充值按企微/历史规则输出摘要;包含支付方式但不包含支付通道,另行输出业务支付凭证链接和审批附件链接。
- `expiring_asset`:完全复用 UR#33 `GET /api/admin/expiring-assets` 的 `015` 天定义、筛选、排序和数据范围,不以普通卡列表的 30 天筛选代替。
### 受保护的长期附件链接
- 覆盖旧评审“审批附件只输出数量”的结论:`refund` 和 `agent_recharge` 可分别配置 `voucher_links` 与 `approval_attachment_links` 字段,输出实际附件入口。审批摘要中的“附件 N 个”仍可保留为可读摘要,不与链接列冲突。
- `approval_attachment_links` 受来源业务的主体可见性约束:仅平台账号或超级管理员在具备对应业务查看权限且角色获授字段时输出;代理即使其角色配置包含该字段,也必须输出空值,且不能通过稳定引用解析平台内部审批附件。`voucher_links` 仍按代理已有退款业务资料权限处理。
- 附件文件继续存放在私有 Bucket严禁把 `file_key`、企微 `media_id`、当前预签名 URL或公开 Bucket URL写入导出文件。
- 每个本地附件拥有不可变、不可枚举的稳定 `attachment_ref`,并关联 `biz_type + biz_id + attachment_kind + private file_key + filename`。不建立数据库外键。
- 若企微公共审批能力已经提供等价的不可变本地附件模型UR#42 必须直接复用;不得再建仅供导出的第二套附件表。退款/充值业务凭证和企微审批附件都必须能够解析为同一种稳定引用。
- 现有退款、充值 JSONB 凭证在迁移/停机准备阶段建立稳定引用;后续新建或替换凭证时在同一业务事务维护引用。替换只改变业务单当前附件集合,不让已有 `attachment_ref` 改指另一文件。
- 企微审批人上传的附件必须在企微详情同步阶段保存到本地私有对象存储并建立稳定引用;临时 `media_id` 不能作为永久下载来源。历史记录确实没有本地附件事实时输出空,不伪造链接。
- 导出文件中的地址是后台前端落地页绝对 URL例如
```text
{admin_frontend_base_url}/export-attachments/{attachment_ref}
```
需要新增受控部署配置 `frontend.admin_base_url`(环境变量 `JUNHONG_FRONTEND_ADMIN_BASE_URL`)。当最终字段包含附件链接而该配置缺失/非法时,创建任务失败,不能生成相对地址或错误链接。
- 一个单元格有多个附件时,按附件创建顺序输出 `文件名: URL`使用换行分隔CSV 必须正确引用含换行单元格XLSX 开启单元格换行。文件名缺失时使用“附件1、附件2……”稳定展示。
- 前端落地页读取后台当前登录态;未登录先进入登录流程,并在登录成功后返回该附件页。因为后台 API 使用 Bearer Token导出文件不能直接依赖浏览器自动给 API URL 添加 Authorization。
- 落地页调用:
```text
GET /api/admin/attachments/{attachment_ref}/download-url
```
后端按附件关联的退款单、充值单或企微审批业务单重新执行当前账号的数据权限、业务查看权限和附件种类可见性校验,成功后返回短期 `download_url`、`expires_at`、`file_name`。代理可访问其业务范围内的退款业务凭证,但不得解析企微内部审批附件;前端随后打开该短期地址。
- 无权与不存在使用统一错误,防止探测附件引用;账号角色或数据范围后来被收回后,即使持有旧导出文件也不能下载。
- 附件落地页链接长期稳定不等于文件永久公开。对象删除、业务记录不可见或本地附件同步失败时必须展示明确失败状态,不得降级暴露 Key。
- 当某任务获授权导出审批附件链接而匹配记录存在尚未完成本地保存的企微附件时,任务应明确失败并提示先完成审批附件同步后重试,不能输出必然失效的伪链接。
### 前端
- 各对应列表页保留一个导出入口,只提交当前 Scene、格式和当前列表查询条件不展示字段复选框。
- 页面可在加载时调用 `GET /api/admin/export-fields?scene=`。空字段时禁用导出按钮并提示“当前角色未配置该场景的导出字段”;后端仍做相同校验。
- 角色管理页按 Scene 展示代码字段目录并全量保存该角色授权;允许清空某个 Scene。保存成功后重新读取服务端结果不做仅前端生效的乐观权限状态。
- 导出任务状态、进度、失败原因、取消和下载继续复用现有任务页面与轮询规则;失败时不自动重复创建任务。
- 新增受保护附件落地页,覆盖登录恢复、加载、无权限、文件不存在、对象存储失败和成功跳转状态。
### 架构与依赖
- 字段目录、角色字段解析和角色配置属于权限/Application 边界;导出列表与字段投影属于 Query/DataSource 边界,不创建无业务价值的聚合根。
- 角色字段全量替换是简单写事务脚本;成功事务写统一 Audit Event。
- 本需求复用而不重新实现UR#46 最终到期 Query、UR#33 临期 Query、UR#44 提交人/审批摘要,以及企微审批公共能力的审批实例和本地附件镜像。
- 若上述依赖未落地,对应 Scene 不得用临时简化查询或外部短期 URL冒充完成可以先实现不依赖它们的场景但整项 UR#42 验收必须覆盖全部 Scene。
### 发布与回滚
- 停机前盘点 `tb_export_task` 中待处理/处理中任务,完成、取消或明确失败后再切换,避免新旧 Worker 对 `query_json` 快照格式理解不同。
- 停机发布数据库迁移、后端、Worker 和前端超级管理员验证全字段目录、角色保存、任务创建、CSV/XLSX 和附件落地页。
- 业务在停机窗口内配置普通角色字段权限并按平台、不同层级代理抽样验证行列权限;不执行任何自动授权数据迁移。
- 恢复服务后监控权限解析失败、零字段拒绝、各 Scene 任务失败、附件本地化缺失及 Count/Fetch 行数差异。
- 回滚时先停止新任务,处理新版本未完成任务,再回滚 Worker/API/前端和数据库;已经生成的导出文件仍按附件落地页的当前鉴权访问。
## Testing Decisions
- 字段权限单元/集成测试覆盖超级管理员全目录、单角色、多角色并集、账号角色与店铺角色既有解析规则、软删除/禁用角色关联、空授权、陈旧字段 key 和权限存储异常。
- API 测试覆盖 `GET /export-fields` 的全部/部分/空结果,角色 GET/PUT 全量替换、清空、重复 key、未知 Scene、未知字段、越权管理角色及审计失败回滚。
- 创建任务测试验证请求不含 `fields`、伪造 `fields` 不影响结果、零字段不建任务、权限解析失败不回退、`resolved_fields/resolved_headers` 与权限/范围在入队前已经快照。
- 验证创建任务后增加或收回角色字段不会改变该任务Worker 遇到缺失/损坏字段快照安全失败,不导出全字段。
- 对每个 Scene 验证代码目录、表头、行值一一对齐;只授予单字段、多个离散字段和动态 `bound_cards` 字段组时均不串列。
- 每个 Scene 的 Count/Fetch 使用同一筛选和权限范围;对平台、上级代理、下级代理、越权 `shop_id` 和 0/1/多条结果验证总数及文件行数。
- 查询性能测试使用 100 行一页/分片,确认钱包业务单号、提交人、审批摘要、附件、卡套餐和临期数据均为批量查询,无逐行或逐附件 N+1代表性 SQL 执行 `EXPLAIN ANALYZE`。
- `iot_card` 覆盖无套餐、一个生效主套餐、多个异常候选、剩余流量、预计最终到期 0/30/31 天、已过期和不可预计状态。
- `expiring_asset` 覆盖 UR#33 的 0/3/4/7/8/15/16 天边界、03 天置顶和稳定分页。
- 金额、流量、时间和所有 int 状态字段验证中文名称来自 constantsCSV 与 XLSX 展示一致。
- 附件测试覆盖图片、PDF/Word/Excel 等普通文件、多附件换行、中文/特殊字符文件名、CSV 引用、XLSX 换行、业务凭证和企微审批附件。
- 附件安全测试验证导出文件不含 `file_key/media_id/预签名 URL`;未登录可登录后返回,当前有权可获取短期地址,权限后来收回、越权店铺、引用不存在和对象缺失均不能下载。
- 主体可见性测试验证同一退款在平台视角可按字段权限导出审批摘要和审批附件链接,代理视角即使角色获授这些字段也不能得到审批人、内部意见或审批附件链接;已知审批附件 `attachment_ref` 的代理仍无法解析。
- 附件替换测试验证旧 `attachment_ref` 不改指新文件,新导出只列当前业务附件,旧导出链接仍指原文件但每次访问重新校验当前业务权限。
- 企微附件未本地化时验证任务明确失败;完成同步后重试可生成有效稳定链接。
- 异步流程测试覆盖空结果文件、单分片、多分片、分片重试、取消、finalize、CSV/XLSX、对象存储失败和恢复最终文件只有一个表头且列顺序固定。
- HTTP 集成测试穿过 Fiber、认证、Application/Query、GORM、真实开发 PostgreSQL/Redis 和 Asynq 可控接缝,使用 `.env.local` 的开发环境配置但不在日志/测试产物中输出任何密钥。
- 前端验收覆盖无字段禁用、按角色显示实际导出列、查询条件透传、任务刷新恢复、失败原因、下载、附件登录恢复和无权状态。
## Out of Scope
- 不让用户在每次导出时选择字段,不接受前端 `fields[]` 作为列权限来源。
- 不建设字段级数据脱敏、按单字段追加“敏感/非敏感”判断或基于角色名称猜测权限。
- 不通过字段授权新增菜单、接口或数据行访问权。
- 不把对象存储改为公开 Bucket不生成永不过期的对象存储签名 URL也不把附件二进制嵌入 CSV/XLSX。
- 不为企业账号新增导出范围。
- 不替换现有导出任务、Asynq 分片、对象存储和最终下载框架。
- 不在本需求改变退款、充值、换货、套餐、钱包或临期业务状态机。
## Further Notes
- 本规格覆盖标准评审稿中两项旧口径:
1. `scene + format + query + fields`、用户勾选字段、`default_selected/required` 改为仅提交 `scene + format + query`,后端按角色字段权限自动导出全部授权列;
2. “审批附件只输出数量”改为业务凭证和审批附件均可通过字段权限导出受保护的长期业务链接,审批摘要仍可同时保留附件数量。
- 当前代码的创建 DTO 本来就没有 `fields`,因此第一项主要是冻结后续实现方向,不需要兼容已上线的字段选择客户端。
- 当前对象存储明确是私有 Bucket现有预签名下载 URL 有效期有限;“永久 URL”在本规格中专指稳定业务落地页地址任何实际文件访问都必须重新鉴权并换取短期对象地址。
- `GET /api/admin/storage/batch-download-urls` 只按 `file_key` 生成临时地址,不能直接作为导出附件入口,因为它既暴露内部 Key也没有按退款/充值业务单重新校验数据权限。

View File

@@ -0,0 +1,177 @@
# PRDUR#43 代理系列套餐批量授权
Status: ready-for-agent
---
## Problem Statement
代理系列授权及套餐授权的数据结构和接口已经存在,首次授权也已经能够在同一事务中创建系列授权与多条套餐授权。但前端没有把现有系列套餐列表和授权详情正确组合成批量选择视图,后续追加套餐时不容易区分未授权与已授权,也容易混淆上级当前成本、目标代理授权成本和建议零售价。
现有 `PUT /api/admin/shop-series-grants/{id}/packages` 还将新增、改价和移除混在同一请求中:已经授权的套餐再次提交不同价格会被直接改价。这会使一个看似“新增授权”的并发请求静默覆盖其他运营人员刚设置的成本价,业务意图和审计语义均不明确。
## Solution
复用现有套餐列表和系列授权详情,不新增候选 API。首次授权直接使用现有套餐列表选择多项并由 `POST /api/admin/shop-series-grants` 在同一事务创建系列及套餐授权;后续管理同时读取现有套餐列表与 `GET /api/admin/shop-series-grants/{id}`,按 `package_id` 合并成同一批量选择视图,已授权项置灰、未授权项可多选。
保留现有批量写路径,但新增必填 `operation_type=authorize|update_cost|remove`。单次请求只能表达一种命令,并在一个事务内全成全败:`authorize` 只新增并支持同价幂等,不同价重复整批冲突;`update_cost` 只明确修改已授权套餐价格;`remove` 只明确移除授权。
## User Stories
1. 作为平台或上级代理,我希望一次选择多个系列套餐并授权给目标代理。
2. 作为授权人员,我希望看到公司成本价、目标代理当前授权成本价和建议零售价,不再混淆单一 `cost_price` 的含义。
3. 作为授权人员,我希望已经授权的套餐明确置灰,避免重复选择。
4. 作为授权人员,我希望新增授权、调价和移除是三个明确动作,避免误操作。
5. 作为并发操作人员,我希望重复同价授权安全幂等,不同价并发请求明确冲突而不是覆盖。
6. 作为上级代理,我只能把自己有权销售的套餐授权给直属下级,不能借候选或构造请求越权。
7. 作为审计人员,我希望知道一次批量操作的命令、系列、目标代理、套餐及前后价格。
## Implementation Decisions
### 现有模型与范围
- 复用 `tb_shop_series_allocation``tb_shop_package_allocation`,不新建授权关系表。
- 系列授权记录是目标店铺获得该系列销售能力的入口;套餐授权记录保存目标店铺对具体套餐的成本价、零售价、状态和上下架状态。
- 本需求不重建系列佣金、强充配置、套餐零售价或价格继承规则,也不新增候选 Query只规范现有列表组合、首次批量授权和后续明确的批量套餐命令。
- 赠送套餐 `is_gift=true` 不属于代理授权候选,也不能通过构造请求加入。
- 金额统一使用分,类型为 `int64`,禁止浮点金额。
### 复用现有读取接口
- 不新增 `GET /api/admin/shop-series-grants/{id}/package-options`。七月评审稿中的该建议由当前用户决定覆盖,原因是现有接口已经分别提供候选来源和已授权套餐,新增接口会重复契约。
- `GET /api/admin/packages?series_id={series_id}&page=&page_size=&package_name=&status=&shelf_status=` 继续作为当前操作者可见的系列套餐列表:
- 平台/超级管理员视角的 `cost_price` 是公司成本价。
- 代理视角的 `cost_price` 是该上级代理自己的当前授权成本价,也就是其继续向下授权时的价格基线;不能将其标成公司的原始成本价。
- `suggested_retail_price` 继续作为建议零售价。
- 列表沿用现有数据权限和代理授权过滤,不能为了批量选择扩大可见范围。
- `GET /api/admin/shop-series-grants/{id}` 继续返回目标代理在该系列下已经授权的 `packages`;其中每项 `cost_price` 是目标代理当前授权成本价。
- 后续管理页面并行读取上述两个现有接口,以 `package_id` 合并:
- 出现在授权详情中的套餐标记为 `is_authorized=true`,显示目标代理授权成本价。
- 只出现在系列套餐列表中的套餐标记为 `is_authorized=false`,授权成本价显示“-”,不可用 0 代表未授权。
- 已授权但因上级权限变化而不再出现在普通套餐列表的存量项,仍从授权详情只读展示真实状态,不能从界面静默消失。
- 首次授权尚无授权 ID 时只需调用现有套餐列表;此时所有可选择项对新目标代理均视为未授权,不需要伪造 grant ID。
- 套餐列表目前只支持 `package_name`,若现有前端确实需要按编码搜索,应在同一个 `GET /api/admin/packages` 增加受控 `keyword``package_code` 筛选,不为此新增授权候选接口。
- 平台成本、上级自身成本、目标代理授权成本和建议零售价按实际调用者及接口来源明确标注;不能继续用没有视角说明的“成本价”文案。
- 成本价属于敏感数据,继续受现有套餐与系列授权管理权限约束。无权限不得通过调用另一个列表接口探测其他代理成本。
### 首次系列与套餐授权
- 继续使用 `POST /api/admin/shop-series-grants`,在一个事务内创建 `ShopSeriesAllocation` 与请求中的多条 `ShopPackageAllocation`;不拆成两步,不允许留下无套餐的空系列授权。
- `packages` 从当前可选字段收紧为必填,至少 1 项、最多 100 项;每项为 `package_id + cost_price`,同一请求内套餐 ID 必须唯一。
- 首次授权中的套餐必须满足与后续 `authorize` 相同的系列、赠送、状态、上级授权、价格和权限规则;任一套餐失败则系列授权、全部套餐授权、价格历史和成功审计均不落库。
- 首次授权不需要 `operation_type`,因为 `POST` 的业务语义已经唯一明确为“创建系列授权并首次授权套餐”;该接口不承担后续调价或移除。
### 批量写契约
- 保留 `PUT /api/admin/shop-series-grants/{id}/packages`,请求固定为:
```json
{
"operation_type": "authorize",
"packages": [
{"package_id": 1001, "cost_price": 6500},
{"package_id": 1002, "cost_price": 7000}
]
}
```
- `operation_type` 是必填稳定枚举:
- `authorize`:新增套餐授权。
- `update_cost`:明确修改已有授权成本价。
- `remove`:明确移除已有套餐授权。
- `packages` 必填,最少 1 项;单次上限 100 项。同一请求中的 `package_id` 必须唯一,重复 ID 返回参数错误,不能以“最后一个覆盖前一个”处理。
- `authorize``update_cost` 每项必须提供大于等于 0 的 `cost_price``remove` 只使用 `package_id`,如携带价格则拒绝,避免无效参数制造歧义。
- 不再使用每项 `remove=true` 混合命令;旧模糊请求不得继续触发新增、改价或删除。
- `operation_type` 缺失返回参数错误。该契约要求前后端同批发布,不提供会延续模糊语义的长期兼容层。
### `authorize` 规则
- 每个套餐必须未删除、属于当前系列且不是赠送套餐。套餐当前禁用或下架状态原样展示但不阻止授权,保持现有“可以先配置授权、销售时由可售策略拦截”的能力;本需求不把销售状态误当成授权状态。
- 代理操作者必须拥有该套餐的有效授权;目标店铺必须仍满足现有直属下级和系列授权管理权限。
- 目标代理尚未授权:创建 `tb_shop_package_allocation`,沿用现有初始零售价、状态和上架状态规则,并写价格历史“新增授权”。
- 目标代理已经存在有效授权且成本价相同:按幂等成功,不重复创建、不重复写价格历史。
- 目标代理已经存在有效授权但成本价不同:返回冲突,整批不做任何写入。不得借 `authorize` 静默改价。
- 已软删除的旧授权是否允许按新授权恢复,应复用项目现有唯一索引和新建授权语义:创建新的有效记录或按明确恢复操作处理,但不能把旧价格静默带回;审计必须标明恢复来源。
### `update_cost` 规则
- 每个套餐必须已经存在目标代理的当前有效授权;未授权项导致整批失败。
- 新价格与当前价格相同时按幂等成功,不写重复价格历史。
- 新价格不同时执行现有价格边界校验,并写 `ShopPackageAllocationPriceHistory`,变更原因明确为系列授权管理调价。
- 若目标代理已将该套餐继续授权给下级,沿用现有规则禁止修改成本价,返回“存在下级分配记录,请先回收后再修改成本价”;整批失败,不允许部分跳过。
- 此命令是按套餐指定绝对目标成本价,与现有 `/shop-package-batch-pricing` 按店铺/系列整体固定或比例调价不同,二者不能互相冒充。
### `remove` 规则
- 每个套餐必须已经存在目标代理的当前有效授权;同一授权已不存在时按幂等成功,不重复写删除审计。
- 若该套餐已经继续授权给下级、存在会被破坏的销售/授权不变量或项目现有回收前置条件,必须拒绝并要求先回收,不能留下下级拥有而上级无权的悬空链路。
- 移除使用软删除或项目现有撤销语义,不物理删除价格历史和审计事实。
- 本需求不自动取消存量客户已购买套餐,不修改 `PackageUsage` 或历史订单。
### 事务、并发与幂等
- 批量命令是轻量 Application 事务脚本。Application 负责权限、命令解析、批量加载、规则校验、条件写入、价格历史和审计;不创建无业务行为的聚合根。
- 一批请求在一个 PostgreSQL 事务内全成全败。必须先批量加载和校验所有套餐、目标授权及下级引用,再执行写入。
- 数据库保留目标店铺与套餐的有效授权唯一约束;`authorize` 创建应使用条件写入/唯一冲突后的重新读取,正确区分同价幂等与不同价冲突。
- 并发 `authorize` 同一批套餐时,一方成功后另一方重新核对当前价格;相同价格成功返回,不同价格冲突,不得将唯一约束错误暴露给客户端。
- 不需要 Redis 分布式锁;数据库事务、唯一约束和条件写入足以保护这一轻量授权用例。
### 权限与越权防护
- 超级管理员和平台账号沿用现有授权管理范围。
- 代理账号只能管理由自己店铺分配、且目标为其现有规则允许的直属下级系列授权;不得操作其他平台或代理创建的授权。
- 写接口不能只检查 `allocation.allocator_shop_id`,还要复用目标店铺、操作者店铺层级、系列授权和逐套餐上级授权的完整校验。
- 资源不存在或无权使用统一安全错误语义,防止探测其他代理的授权和成本价。
- 前端置灰仅是交互提示,所有归属、系列、赠送、状态、价格和重复规则由后端重新校验。
### 审计与响应
- 每次成功批量命令写统一 Audit Event至少包含 `operation_type`、系列授权 ID、目标店铺、系列 ID、套餐 ID 列表、前后成本价、操作者和幂等项摘要。
- 拒绝和冲突按公共失败审计规则记录;价格、账号等敏感信息遵循全局审计脱敏规则。
- 成功响应返回最新授权详情或结构化结果,至少包括请求数、实际新增/更新/移除数、幂等数及相关套餐 ID不得以“跳过”掩盖不同价格冲突。
### 前端交互
- 首次授权和后续管理复用同一个批量选择表格组件;数据来自现有套餐列表,后续管理再与现有授权详情按 `package_id` 合并。
- 分列展示当前上级成本价、目标代理授权成本价、建议零售价;平台作为上级时当前上级成本就是公司成本。未授权成本价显示“-”,不能显示 0 元造成误解。
- `is_authorized=true` 显示“已授权”并在新增授权模式置灰;切换到调价或移除模式时只允许选择已授权项。
- 一个弹窗/提交只能处于授权、调价或移除一种模式,请求提交相应 `operation_type`
- 提交前展示命令名称、目标代理、系列、套餐数量和价格摘要;提交期间禁止重复提交。
- 并发不同价冲突时保留用户输入,提示刷新候选数据后重新确认,不能自动覆盖。
### 发布与回滚
- 不迁移、不回填现有授权数据。发布前核对同一店铺/套餐有效授权重复、孤立下级授权及价格历史异常。
- 前后端同批切换必填 `operation_type`;旧前端流量应在发布窗口清空或阻断,不能让缺失命令类型的请求继续按旧逻辑执行。
- 回滚应用时保留新版本产生的授权、价格历史和审计;不得删除业务事实。
## Testing Decisions
- 读取组合测试覆盖平台与代理视角的现有套餐列表、授权详情、系列过滤、分页、赠送套餐排除、禁用/下架存量项只读展示,以及不同视角下成本价字段的准确文案。
- 字段权限测试验证无成本价查看权限的账号不能读取敏感价格,代理不能查询其他授权记录详情或借套餐列表扩大授权范围。
- 首次创建测试覆盖套餐数组必填、多项同事务成功、重复 ID、跨系列、赠送、无上级授权、非法价格及任一项失败时系列授权也不落库。
- `authorize` 测试覆盖多项成功、同价幂等、不同价冲突、同请求重复 ID、跨系列、赠送、禁用/下架、代理自身未授权和并发唯一冲突。
- `update_cost` 测试覆盖未授权、同价幂等、成功调价、价格历史、存在下级分配时整批失败,以及绝对价格不会被误解成固定/比例调整。
- `remove` 测试覆盖成功、已不存在幂等、存在下级授权拒绝和不影响客户历史订单/套餐使用。
- 事务测试验证任一套餐失败时整批没有新增、改价、删除、价格历史或成功审计残留。
- HTTP 集成测试穿过 Fiber 认证、Handler、Application/Query、GORM/PostgreSQL 和统一响应,验证必填枚举、错误码、分页与越权安全语义。
- 前端验收覆盖首次与后续共用批量表格、两个现有读取接口的合并、已授权置灰、三种明确模式、批量摘要、空态、失败态和并发冲突刷新。
## Out of Scope
- 不重建系列授权、套餐授权、佣金或强充模型。
- 不新增 `package-options` 或其他重复的候选套餐读取接口。
- 不允许创建没有任何套餐的空系列授权。
- 不允许一个请求混合授权、调价和移除。
- 不在 `authorize` 中修改已授权套餐价格。
- 不把赠送套餐授权给代理。
- 不自动级联调整或回收下级代理授权。
- 不修改存量客户订单、套餐使用记录或零售价配置。
- 不用 Redis 锁替代数据库唯一约束和事务。
## Further Notes
- 当前写接口已经接受 `packages:[{package_id,cost_price,remove}]`,并会在重复授权时直接改价;实现必须主动消除这段模糊语义。
- 仓库已有 `/shop-package-batch-pricing`,但它按整个店铺/系列进行固定或比例调整且允许逐项跳过,不能替代本需求按选中套餐设置绝对成本价、整批原子失败的 `update_cost`
- 用户已确认以必填 `operation_type` 将授权、调价和移除拆为明确命令,并接受前后端同批切换。
- 用户纠正并确认:首次授权保持现有 `POST` 同时授权系列和套餐;后续添加套餐由 `PUT /{id}/packages` 负责;读取复用现有套餐列表和授权详情,不新增候选接口。

View File

@@ -0,0 +1,161 @@
# PRDUR#44 退款、代理充值与换货列表补充提交人和审批摘要
Status: ready-for-agent
---
## Problem Statement
当前退款、代理充值和换货列表看不到创建业务单的账号名称,运营人员需要进入详情或查询日志才能判断是谁提交。退款与平台员工线下充值接入企业微信审批后,业务列表还需要同时区分“企微审批状态”和“审批通过后的本地业务处理状态”,否则会把已通过但仍在退款/入账中的业务误认为已经完成。
现有三张业务表只保留了提交人账号 ID没有不可变的提交人名称快照当前仓库也尚未实现七月企微审批实例。列表扩展必须依赖统一企微审批 Query 批量读取当前页摘要,不能创建另一套本地审批流、逐行查询企微或写死“部门领导/财务”等节点。
## Solution
在退款、代理充值和换货业务表增加 `submitter_name` 快照,新业务创建时与业务单同事务保存当前账号用户名。对存量记录按原提交人账号 ID 读取包括软删除记录在内的最后用户名并一次性回填,账号确实不存在时写“未知账号”。
复用现有三个列表接口。退款和代理充值列表按当前页业务记录批量读取统一企微审批实例摘要,返回审批来源、审批状态以及本地处理状态;当前审批人摘要按主体权限投影,只有平台/超级管理员按业务权限可见,代理响应必须为空。换货当前不走企微审批,只返回提交人,并以 `approval_source=none` 明确表示不适用。
## User Stories
1. 作为运营人员,我希望在退款、充值和换货列表直接看到最初提交账号。
2. 作为财务人员,我希望区分企微仍在审批、企微已经通过以及本地退款/入账是否完成。
3. 作为代理在线充值的查看者,我希望审批列明确显示不需要审批,而不是误显示为待审批。
4. 作为历史数据查看者,我希望老记录保留可解释的提交人和“历史审批”标识,但不出现伪造的企微节点。
5. 作为系统维护人员,我希望一页审批摘要使用批量查询,不因列表 100 条产生 100 次数据库或企微请求。
## Implementation Decisions
### 接口范围
- 扩展现有接口,不新增专门的列表或审批摘要接口:
- `GET /api/admin/refunds`
- `GET /api/admin/agent-recharges`
- `GET /api/admin/exchanges`
- 保持三个接口现有分页、筛选、排序和数据权限不变;新增展示字段不能扩大可见数据范围。
- 列表只返回展示所需的审批摘要。完整企微意见、附件元数据和时间线继续在业务详情或统一企微审批详情中查询。
### 提交人快照
- `tb_refund_request``tb_agent_recharge_record``tb_exchange_order` 增加非空 `submitter_name varchar(50)`,含义固定为“业务单首次创建时的系统账号用户名快照”。
- 新建业务单时从已认证上下文获取当前账号 ID 和用户名,在创建业务单的同一个 PostgreSQL 事务中保存;禁止依赖前端传值。
- 一张退款单只保存首次创建时的提交人快照,并只对应一条企微审批申请。企微拒绝后原退款单不可修改或重提;后续仍需退款时创建新退款单,新单按当次认证上下文保存新的提交人快照和独立企微审批事实。
- 后续账号改名、禁用、解绑企微或软删除都不修改已保存的业务提交人快照。
- 用户已确认存量数据执行回填:
- 退款按 `BaseModel.creator` 定位账号。
- 换货按 `BaseModel.creator` 定位账号。
- 代理充值按 `user_id` 定位账号。
- 查询账号必须包含软删除记录,并按账号主键精确匹配,不能按可能被重新注册的用户名反查。
- 找到账号时写其最后保存的 `username`;账号 ID 为 0、账号记录缺失或用户名为空时写固定文案“未知账号”。
- 回填脚本需先输出总数、成功匹配数和未知账号数供核对,再执行可重复的条件更新;已有非空合法快照不覆盖。
- 列表 `submitter_name` 始终返回非空字符串,新数据也不得以空字符串代替未知账号。
### 统一列表字段
- 三类列表项统一增加以下字段,均为必返字段:
```json
{
"submitter_name": "zhangsan",
"approval_source": "wecom",
"approval_status": 4,
"approval_status_name": "审批中",
"current_approver_summary": "财务审批:李四",
"processing_status": 0,
"processing_status_name": "未触发"
}
```
- `approval_source` 是方式类字符串枚举:
- `none`:该业务不需要审批。
- `wecom`:存在当前有效的企微审批实例。
- `legacy`:停机切换前已经结束、没有企微实例的历史本地审批。
- `approval_status``processing_status` 是生命周期状态 int具体数字和值名必须复用 UR#37 企业微信审批公共常量及 UR#34/35 充值、退款处理常量DTO description 从 constants 原文抄写,不在 UR#44 创建第二套枚举。
- `approval_source=none` 时:`approval_status=0``approval_status_name="无需审批"``current_approver_summary=""`
- `approval_source=legacy` 时返回可由原业务事实证明的历史终态名称,`current_approver_summary=""`;不得用旧 `processor_id` 伪造成企微当前审批人或多节点流程。
- `processing_status` 描述企微终态之后本地退款、钱包回溯或充值入账的结果,不能拿业务表原有总状态冒充。尚不适用或尚未触发时统一为 0并返回对应 `_name`
- 换货当前固定返回 `approval_source=none` 及上述不适用值;不因为字段结构统一而把换货接入审批。
### 不同业务的审批来源
- 退款:
- 七月切换后的退款使用 `wecom`,摘要按业务表明确保存的唯一 `approval_instance_id` 读取该退款单的审批申请;一张退款单只有一条审批,不存在按轮次或创建时间猜测“当前审批”的逻辑。
- 发布前已经结束且无企微实例的历史退款使用 `legacy`
- 发布时仍未结束的退款必须按企微方案在维护窗口形成真实企微实例,不能继续作为本地待审批。
- 代理充值:
- 代理本人发起的微信/支付宝在线扫码充值固定 `approval_source=none`
- 平台员工创建的线下代充值使用 `wecom`
- 发布前已经结束且无企微实例的历史线下审批使用 `legacy`
- 换货:固定 `none`,本需求只增加提交人。
- 判定审批来源必须基于业务类型、支付方式及真实实例关联,不能仅根据业务状态猜测。
### 当前审批人摘要
- `current_approver_summary` 只在企微实例处于审批中且存在未完成审批节点时返回,其余状态为空字符串。
- 对代理账号,无论企微实例状态如何,`current_approver_summary` 固定返回空字符串;代理不得从列表获取平台内部审批人信息。平台账号和超级管理员仍需先具备该业务单的查看权限,才能按以下规则得到摘要。
- 数据来自最后一次权威 `getapprovaldetail` 保存的节点与审批人快照,不在列表请求中实时调用企业微信。
- 摘要按企微节点顺序稳定生成,至少包含当前节点名称;能确认待处理成员时追加成员名称,多人使用“、”连接,并保留会签/或签语义。
- 多个当前节点并行时使用“;”连接。字段设定最大安全长度,超出时由后端返回确定性截断摘要并在末尾显示总人数,前端用省略号和悬浮展示完整返回值。
- 不展示企微 `userid`、手机号或其他内部标识;成员名称取审批详情保存的快照,不能用当前账号绑定覆盖历史名称。
### Query 与性能
- 三个列表属于读取 Query不经过聚合根。先按原条件分页查询业务数据再收集本页所有当前企微实例 ID一次批量读取实例及审批人摘要并在内存按业务键合并。
- 禁止对每条业务记录调用一次实例查询、账号查询或企业微信 API。
- `submitter_name` 直接读取业务表快照,列表运行时不再联表解析账号;账号表只用于一次性迁移和新建时兜底校验。
- 统一企微 Query 应提供按实例 ID 批量返回摘要的稳定接口,供退款列表、充值列表和 UR#42 导出复用,避免各模块复制 JSON 快照解析逻辑。
- 列表 `Count` 查询保持原过滤条件,不加入审批详情联表;数据查询的额外审批批量查询仅作用于当前页。
- 列表最大 100 条时仍应满足项目 P95/P99 目标,并通过查询计数测试证明不存在 N+1。
### 权限与敏感信息
- 业务列表沿用现有 GORM 数据范围和业务权限;企微摘要 Query 只能接收已经通过业务列表权限过滤的实例 ID。
- 无权查看某条业务单的账号不得通过实例 ID直接探测审批状态、审批人或业务处理结果。
- 列表不返回审批意见正文、附件 Key、附件 URL、企微 `userid`、外部原始响应或支付通道敏感信息。
- 代理列表除不返回上述内容外,也不返回当前审批人摘要;平台内部审批身份不能因统一 DTO 或批量 Query 泄露给代理。
- “未知账号”只表示原提交账号事实无法恢复,不暴露账号是否已物理删除或迁移异常。
### 前端展示
- 三个列表增加“提交人”列,直接展示 `submitter_name`
- 退款与充值列表增加“审批状态”“当前审批人”“业务处理状态”;换货列表不增加无意义的审批列,尽管后端为统一 DTO 返回 `none`
- `approval_source=none` 时审批区域显示“-”或隐藏;`legacy` 显示“历史审批”标签,只读且不提供企微操作;`wecom` 展示后端状态名称。只有平台/超级管理员响应中存在 `current_approver_summary` 时才展示当前审批人,代理不展示该列或固定显示“-”。
- 企微已通过但 `processing_status` 仍为处理中或失败时,审批列必须显示“已通过”,处理列独立显示“处理中/失败”,不能合并成“待审批”。
- 处理失败时列表提供进入详情或统一企微运行页面的受控入口,但不在列表增加本地通过、驳回、退回按钮。
### 发布与回滚
- UR#44 的企微摘要依赖 UR#37 统一企微实例 Query以及 UR#34/35 的充值、退款业务处理状态;依赖能力未上线时不得伪造静态摘要。
- 发布顺序为:增量字段与历史回填 → 企微公共能力和业务终态字段 → 后端列表 Query → 前端列表展示。
- 发布前核对三表记录总数、提交人回填命中率、未知账号清单、历史审批分类和发布时未结束业务单迁移结果。
- 回滚应用时保留 `submitter_name` 快照和已经产生的真实企微关联;不得删除或回写历史业务事实。旧版本忽略新增列即可。
## Testing Decisions
- 新建测试验证退款、在线充值、线下充值和换货均从认证上下文保存正确用户名,前端伪造字段无效,事务失败不留下半条快照。
- 回填测试覆盖正常账号、软删除账号、用户名后来修改、账号 ID 为 0、账号物理缺失、空用户名、已有快照以及重复执行迁移。
- 退款列表测试覆盖 `wecom` 审批中/通过/驳回/撤销/异常状态、`legacy` 历史终态和本地处理未触发/处理中/成功/失败。
- 充值列表测试覆盖在线微信/支付宝固定 `none`、员工线下充值 `wecom`、历史线下充值 `legacy`,以及审批通过但入账失败不会显示为全部完成。
- 换货列表测试验证提交人正确且审批来源固定 `none`,不会创建或查询企微实例。
- 当前审批人测试覆盖单节点、多人会签、多人或签、并行节点、已完成节点、成员名称快照、超长摘要和无当前节点。
- 性能测试统计一页 1、20、100 条数据的 SQL 次数,确认账号不逐行查询、企微实例与审批人按页批量查询、列表请求不调用企微网络接口。
- 越权测试验证平台代理、店铺层级和企业数据范围保持不变,不能用实例 ID 或摘要批量 Query 获取其他租户审批信息。
- 信息最小化测试验证同一退款列表记录在平台视角可返回当前审批人摘要,而代理视角字段固定为空;代理不能通过排序、筛选、导出或其他列表接口旁路获得审批人、意见或审批附件。
- HTTP 集成测试穿过 Fiber、认证、Query、GORM/PostgreSQL 和统一响应,验证所有新增字段必返、类型稳定、`status_name` 与 constants 一致。
- 前端验收覆盖三类列表、历史审批标签、无需审批空态、长审批人摘要、审批与处理状态分列以及错误/空列表状态。
## Out of Scope
- 不为换货增加企业微信审批。
- 不建设本地审批任务、审批人配置或列表审批按钮。
- 不在列表返回完整审批意见、附件和时间线。
- 不为历史业务伪造企微实例、企微单号、审批节点或审批人。
- 不改变退款、充值、换货原有分页筛选和数据权限。
- 不把账号当前用户名作为新业务单提交人快照的动态展示值。
## Further Notes
- 当前退款和换货分别已有 `creator`,代理充值已有 `user_id`,但三者均没有用户名快照;历史回填有可靠主键来源。
- `tb_account` 使用软删除,用户名唯一约束只覆盖未删除记录,因此回填必须按账号 ID 无作用域读取,不能按用户名反向猜测。
- 当前仓库没有七月企微审批实例表;审批摘要只能在 UR#37 公共能力落地后实现。
- 用户已明确确认历史提交人执行回填,缺失账号显示“未知账号”。

View File

@@ -0,0 +1,89 @@
# PRDUR#45 换货资产标识与新旧资产独立搜索
Status: ready-for-agent
---
## Problem Statement
换货单中的卡资产标识没有统一:旧资产可能保存虚拟号,新资产可能原样保存操作员输入的 ICCID、接入号或虚拟号导致同类记录展示不一致。现有列表又只有一个同时匹配新旧资产快照的 `identifier` 参数,无法明确查“旧资产”还是“新资产”,也无法稳定通过卡的其他标识找到对应换货单。
## Solution
新建换货单时,将卡的新旧资产标识统一规范为 ICCID设备使用稳定的设备号。换货列表用 `old_asset_keyword``new_asset_keyword` 替代通用 `identifier`,先将关键词解析为候选卡/设备 ID再按换货单的新旧资产主键筛选。两个参数可独立使用同时提供时按 AND 组合。
## User Stories
1. 作为运营人员,我希望换货列表分别展示旧资产和新资产,避免混淆换出和换入对象。
2. 作为运营人员,我希望通过 ICCID、接入号或虚拟号搜索卡对应的旧资产换货记录。
3. 作为运营人员,我希望用相同标识搜索新资产,但不会误命中旧资产。
4. 作为运营人员,我希望同时填写新旧资产条件,以定位一条明确的换货关系。
5. 作为维护人员,我希望新换货单保存规范化快照,历史展示不再取决于操作员当时输入了哪种标识。
6. 作为维护人员,我希望大结果集查询不逐条反查资产,也不绕过换货单数据权限。
## Implementation Decisions
### 快照规范
- 卡资产的 `old_asset_identifier``new_asset_identifier` 均保存已解析卡记录的完整 ICCID不保存请求原文、接入号或虚拟号。
- 设备资产优先保存虚拟号;虚拟号为空时依次使用 IMEI、SN确保保存稳定的设备标识。
- 规范化覆盖物流换货和直接换货:创建旧资产快照、直接换货的新资产快照、物流发货时的新资产快照均使用同一解析能力。
- 请求仍可使用接口当前支持的任一资产标识定位资产,但保存快照必须取解析后的权威字段。
- 历史换货单不回填、不改写;历史列表继续按原快照展示,新的搜索能力通过资产主键关联命中历史记录。
### 列表接口
- 复用 `GET /api/admin/exchanges`
- 新增可选 `old_asset_keyword``new_asset_keyword`,各自最长 100 个字符。
- 移除新契约中的通用 `identifier`;前后端在同一维护窗口切换,不再把一个参数解释为“新资产或旧资产”。
- 单独提供旧资产关键词时只过滤 `old_asset_type + old_asset_id`;单独提供新资产关键词时只过滤 `new_asset_type + new_asset_id`
- 两个关键词同时提供时按 AND 组合,并继续与状态、流程类型、创建时间和分页条件按 AND 组合。
- 空参数不增加对应条件;无候选资产或无换货单命中时返回成功空分页,不返回 404。
- 卡关键词对 ICCID、接入号、虚拟号做包含匹配设备关键词对虚拟号、IMEI、SN 做包含匹配。
- 候选资产查询必须排除软删除资产;换货单查询继续排除软删除记录并应用现有店铺数据范围。
- 候选 ID 解析和换货单过滤使用固定次数的批量查询或数据库子查询,不允许按换货单逐行读取卡或设备。
- `total``items` 必须使用完全相同的过滤条件,排序继续使用创建时间倒序。
- 响应继续分别返回新旧资产类型、ID、快照标识、状态及状态名称不改变统一响应外层结构。
### 校验、权限与架构
- 列表 Handler 对整个请求 DTO 执行校验;非法长度、分页、状态、流程类型或时间参数统一返回 HTTP 400、`code=1001``msg=参数验证失败`,详细原因仅写日志。
- 代理账号只能查询其现有店铺范围内的换货单;关键词解析不得扩大最终换货单范围。
- 无权限和不存在的换货单继续使用现有防枚举错误策略,不向客户端暴露候选资产数量或主键。
- 快照规范属于换货写用例的一部分,应由统一资产解析/规范化能力提供,不在多个 Handler 或 Service 分支复制。
- 列表属于读取用例,可在现有 Store 上做简单增量;若候选解析和分页组合已达到复杂查询程度,则收口到 Exchange Query但不得为此迁移整个换货模块。
- 数据库或候选查询失败返回脱敏的统一内部错误,不得降级为“无结果”。
### 前端与文档
- 换货列表筛选区将单一资产搜索拆为“旧资产”和“新资产”两个输入框。
- 空值不提交;两个非空值同时提交并展示 AND 查询结果。
- 表格分别显示旧资产类型/标识和新资产类型/标识;卡直接展示后端 ICCID设备展示后端设备号。
- 前端不把接入号或虚拟号转换为 ICCID也不在本地过滤当前页。
- 沿用列表加载、空态、失败反馈和分页交互。
- 更新 OpenAPI 参数及描述,重新生成文档;新增 UR#45 中文总结并更新 README 索引。
## Testing Decisions
- 写用例测试分别用 ICCID、接入号、虚拟号创建卡换货验证新旧快照始终为数据库 ICCID。
- 设备测试覆盖虚拟号、IMEI、SN 输入以及标识优先级。
- 列表 HTTP 集成测试覆盖仅旧关键词、仅新关键词、两个关键词 AND、与状态/时间组合、空参数、无匹配和非法参数。
- 覆盖卡 ICCID、接入号、虚拟号以及设备虚拟号、IMEI、SN 的候选映射。
- 构造历史非规范快照,验证不回填但仍可通过资产主键搜索命中。
- 代理、平台和超级管理员测试验证现有换货单数据范围不被关键词绕过。
- 使用真实 PostgreSQL 执行代表性大结果集测试,验证查询次数固定、无逐行反查,计数和分页结果一致并满足项目性能目标。
- 验证数据库错误返回脱敏 500不能返回空列表掩盖故障。
- 前端人工验收新旧字段不混列、组合搜索、清空、分页和错误状态。
## Out of Scope
- 不回填或清洗历史换货快照。
- 不新增独立搜索接口或换货关系表。
- 不改变换货状态机、归属继承、资料迁移或完成规则。
- 不做跨新旧资产 OR 搜索;旧通用 `identifier` 不保留为第二套长期语义。
- 不由前端解析或规范化资产标识。
## Further Notes
- UR#45 负责“快照规范与列表检索”;资产详情前代/后代关系由 UR#86 提供,店铺继承由 UR#98 提供。
- UR#86 应直接使用这里形成的不可变快照;历史非规范快照保持原样。

View File

@@ -0,0 +1,104 @@
# PRDUR#46 资产预计最终到期时间
Status: ready-for-agent
---
## Problem Statement
资产详情目前只能展示当前套餐自身的到期时间。排队主套餐通常尚未写入 `expires_at`,简单取最大到期时间无法回答“该资产按当前队列连续使用后最终何时到期”,也会让详情、列表、导出和临期提醒产生不同口径。
预计结果会随当前套餐到期、购买新套餐、退款失效和队列接续实时变化,不适合维护第二个资产汇总快照字段。
## Solution
`internal/query/packageexpiry`(或等价独立 Query 包)建立统一预计最终到期 Query。它读取当前生效主套餐和全部有效排队主套餐按稳定队列顺序使用 UR#55 的购买时长快照逐段推演,仅返回一个资产层预计最终到期结果。
资产详情、卡/设备列表、C 端、临期和导出都复用该 Query当前套餐自身到期时间只保留在套餐明细不再作为资产汇总口径。
## User Stories
1. 作为运营人员,我希望在卡和设备详情看到全部主套餐连续使用后的预计最终到期时间。
2. 作为运营人员,我希望列表与详情显示同一结果,且普通列表原排序不被改变。
3. 作为客户,我希望 C 端显示的到期时间与后台一致。
4. 作为运营人员,我希望无套餐和等待未知实名激活能展示明确状态,而不是伪造日期。
5. 作为维护人员,我希望退款、续费和队列变化后下一次查询立即得到新结果。
## Implementation Decisions
### 参与计算的记录
- 只计算 `master_usage_id IS NULL` 的主套餐;加油包不延长主套餐服务周期。
- 参与记录为未软删除且仍处于当前或排队生命周期的主套餐:待生效、生效中,以及仍占用当前有效周期的已用完记录。
- 已过期、已失效、已退款或软删除记录不参与。
- 使用记录按业务 `priority ASC, created_at ASC, id ASC` 稳定排序;相同优先级不得依赖数据库自然顺序。
- 当前生效记录的真实 `expires_at` 是游标起点;后续排队记录从前一段结束后的下一时刻连续接续,禁止重复计算同一自然日。
- 每个排队记录使用自身 `calendar_type_snapshot``duration_months_snapshot``duration_days_snapshot` 推演;只对 UR#55 之前形成的历史缺失快照记录兼容回退套餐当前值。
- 自然月和按天计算复用套餐生命周期现有日期函数,但必须统一修正边界语义并以 `Asia/Shanghai` 解释业务自然日。
### 推算状态
- `exact`:存在可确定的最终日期。
- `waiting_activation`:没有可作为起点的当前套餐,队首又因尚未满足实名等外部前置条件而无法确定激活时刻。
- `none`:没有任何参与计算的主套餐。
- `invalid_data`:队列、快照或日期存在无法安全推算的异常;不得伪造日期,也不得把异常当成 `none`
- `estimated_final_expires_at` 在非 `exact` 时为 `null``days_until_final_expiry` 同时为 `null`
- `days_until_final_expiry` 按上海时区的日期差计算,不按 24 小时向下取整;已过期可返回负数供普通详情显示,但不会被 UR#33 判为临期。
- `is_expiring` 是共享派生字段:仅当 `exact` 且剩余自然日为 015 时为 true。
### API 契约
- `GET /api/admin/assets/resolve/{identifier}` 增加 `estimated_final_expires_at``days_until_final_expiry``expiry_estimate_status``expiry_estimate_status_name``is_expiring`
- `GET /api/admin/iot-cards``GET /api/admin/devices` 的每项增加同组字段。
- `GET /api/c/v1/asset/info` 增加同组字段C 端仍保留套餐明细中的当前套餐到期时间,但不把它当成最终日期。
- 日期使用项目统一 RFC3339 序列化;所有 nullable 字段明确返回 `null`,不能通过缺字段表达状态。
- 卡、设备和 C 端不得各自实现一套推算函数。
- 本需求不在普通资产列表增加临期排序;列表只返回字段供 UR#33 高亮。
### Query 架构与性能
- 这是复杂只读逻辑,按触碰式 DDD 放入 Query 层,不经过聚合根,也不写资产汇总表。
- Query 提供单资产和批量资产两种入口,二者共享同一纯计算器。
- 列表批量加载本页所有资产的参与使用记录和必要历史套餐兜底数据,按资产分组计算;禁止逐资产 N+1。
- 详情查询也调用相同 Query不继续在旧 Asset Service 中拼装另一套规则;旧 Service 可作为只读门面转发。
-`iot_card_id/device_id + master_usage_id + status + priority` 的未软删除查询建立合适的非唯一/部分索引,具体列序以开发库 `EXPLAIN` 为准。
- Count 与 Fetch 不因新增投影产生不同过滤条件;批量结果必须与单资产结果一致。
- 查询错误向上返回统一业务/数据库错误,不能将失败降级成 `none`
### 前端
- 字段统一命名为“预计套餐到期时间”。
- `exact` 展示日期;`waiting_activation` 展示“待激活后起算”;`none` 展示“—”;`invalid_data` 展示“数据异常”,并允许运营排查。
- 当前套餐自身到期时间仅在套餐明细中展示,资产摘要不再并列展示第二个汇总到期字段。
- `is_expiring=true` 时使用 UR#33 的颜色;普通资产列表不改变原排序。
- 前端不得叠加套餐时长或自行计算剩余天数。
### 依赖与发布
- 本需求依赖 UR#55 对新购买记录写入完整计时快照;没有该前置不得以读取套餐当前配置作为新方案上线。
- 先完成 Query 与后端字段,再同步发布后台、代理端和 C 端展示;旧当前套餐字段保留给套餐明细兼容。
## Testing Decisions
- 纯计算测试覆盖无套餐、仅当前套餐、多个排队套餐、自然月、按天、跨月末、跨年和夏令时无关的上海自然日边界。
- 覆盖等待实名激活、历史快照回退、快照异常、重复优先级、退款/失效/软删除和加油包排除。
- 明确验证前一套餐结束后的下一时刻接续,不多算或少算一天。
- 单资产与批量 Query 对同一数据必须返回完全一致结果。
- HTTP 集成测试使用真实开发 PostgreSQL、Redis、JWT 和进程内 Fiber App覆盖后台详情、卡列表、设备列表和 C 端。
- 对本页 100 个资产验证固定查询数量,无逐资产 SQL使用代表性数据执行 `EXPLAIN ANALYZE` 并满足项目性能目标。
- 验证新增/退款/失效排队套餐后无需定时刷新,下一次查询立即变化。
- 前端验收四种状态、nullable 字段、当前套餐明细和普通列表不改排序。
## Out of Scope
- 不维护资产级最终到期快照字段。
- 不计算加油包的独立到期。
- 不在本需求创建临期独立列表、Dashboard 汇总或通知任务。
- 不修改套餐购买顺序和退款业务规则。
- 不为历史记录猜测并回填购买时配置。
## Further Notes
- UR#33`scene=expiring_asset``scene=iot_card` 的 30 天筛选必须复用本 Query。
- 预计结果是当前事实和购买承诺的实时投影,不是上游运营商承诺的绝对到期日期。

View File

@@ -0,0 +1,142 @@
# PRDUR#47 卡片手动限速
Status: ready-for-agent
---
## Problem Statement
当前后台只有设备维度的旧限速接口,且错误地把设备 IMEI 直接传给上游 `/device/speed-limit`;它既不能从设备定位当前卡,也不能支持独立卡资产。现有 Gateway 客户端还会对网络错误自动重试,无法区分请求未送达与上游已执行但响应丢失,可能产生不可控的重复副作用。
七月需求只要求运营人员对卡片执行一次手动限速,不涉及套餐级限速、流量阈值触发或自动恢复。系统需要用统一资产入口解析实际 ICCID再按 Gateway 账号与运营商把业务语义档位映射为真实上游编码,并完整记录调用事实。
## Solution
新增统一 CMP 操作接口 `POST /api/admin/assets/{identifier}/speed-limit`。请求只提交稳定业务枚举 `speed_level`;卡资产直接取得 ICCID设备资产必须先解析唯一的当前有效绑卡再用该卡 ICCID 调用上游。
应用层在权限与资产校验后调用 Gateway PortGateway Adapter 根据非敏感账号标识、运营商和业务档位读取受控部署映射,仅中国电信与中国广电使用 `/flow-card/speedLimit` 直连。该操作关闭共享 Gateway 客户端的自动重试,每次尝试写 Integration Log结果同时进入统一审计。
## User Stories
1. 作为有权管理资产的后台人员,我希望从卡详情或设备详情选择固定限速档位,而不用理解不同上游编码。
2. 作为设备管理人员,我希望设备限速实际作用于当前绑卡,而不是把 IMEI 错当成卡号。
3. 作为运营人员,我希望在运营商或映射不支持时得到明确拒绝,而不是发送猜测参数。
4. 作为审计人员,我希望查到操作者、资产、实际 ICCID、语义档位、上游编码及调用结果。
5. 作为排障人员,我希望网络结果不明时系统保留真实“未知”状态,不盲目自动重试或声称限速成功。
## Implementation Decisions
### API 契约
- 统一接口为 `POST /api/admin/assets/{identifier}/speed-limit`,同时接受当前统一资产解析器支持的卡与设备标识。
- 请求体只包含:
```json
{
"speed_level": "limit_1_mbps"
}
```
- `speed_level` 为稳定枚举,业务语义固定如下:
- `unlimited`:不限速,上游默认编码 `-1`
- `zero_kbps`0 Kbps上游默认编码 `0`
- `limit_128_kbps`128 Kbps上游默认编码 `1`
- `limit_512_kbps`512 Kbps上游默认编码 `2`
- `limit_1_mbps`1 Mbps上游默认编码 `3`
- `limit_2_mbps`2 Mbps上游默认编码 `4`
- `limit_10_mbps`10 Mbps上游默认编码 `5`
- `limit_20_mbps`20 Mbps上游默认编码 `6`
- `limit_50_mbps`50 Mbps上游默认编码 `7`
- `limit_100_mbps`100 Mbps上游默认编码 `8`
- 上述编码只是当前对接资料中的默认描述,不能在 Application 或 Handler 中硬编码为跨账号通用事实。最终发送值必须由 Gateway Adapter 按账号与运营商映射取得。
- 成功响应返回资产类型、资产 ID、实际卡号、`speed_level`、实际发送编码、上游返回的 `applied_speed``channel_raw_value`。不得把目标档位描述成已经查询到的“当前实际限速”。
- 失败使用统一错误码与中文消息;参数枚举非法、无当前卡、运营商不支持、映射缺失、上游明确失败和结果未知需要可区分,但不得向客户端泄露底层错误或密钥。
-`PUT /api/admin/devices/by-identifier/{identifier}/speed-limit` 与错误的 `/device/speed-limit` 调用在同批发布时下线,不保留继续传 IMEI 的兼容路径。
### 资产与卡号解析
- 卡资产直接使用该卡的 ICCID卡必须未删除且仍处于现有数据权限范围内。
- 设备资产只能使用唯一的当前有效绑定:`device_id` 匹配、`is_current=true`、绑定状态为有效且未软删除。上游 `cardNo` 必须是绑定卡的 ICCID绝不使用设备 IMEI、序列号或虚拟号。
- 未找到当前有效绑卡时拒绝操作;同时找到多条当前有效绑卡属于数据不变量破坏,也必须拒绝并记录错误,不能任取第一条。
- 数据库应以不含外键的 PostgreSQL 部分唯一索引保证同一设备最多一条当前有效绑卡。迁移前先检测重复数据,发现异常则中止发布并人工修复,不能自动删除绑定事实。
- 运营商取卡片已有运营商字段及同步后的权威值。缺失、未知或与受支持能力不一致时拒绝,不根据 ICCID 前缀猜测。
### 运营商能力
- 本迭代的直连手动限速仅支持中国电信与中国广电,调用统一上游 `POST /flow-card/speedLimit`
- 中国联通的资料要求通过通信计划调整,不等同于本接口的直接限速;在尚未形成独立通信计划适配契约前,本接口明确拒绝联通卡。
- 中国移动当前不支持本能力,明确拒绝。
- 能力校验必须发生在任何上游调用之前。前端是否隐藏入口不构成后端安全边界。
- 卡详情与设备详情可返回从后端能力判断得出的 `speed_limit_capability`,至少包含 `supported`、不支持原因和可选语义档位。设备详情同时显示实际当前 ICCID。该字段只是操作能力不代表上游当前限速状态。
### Gateway 映射与配置
- Gateway 配置增加非敏感的逻辑账号标识,例如 `account_code`;不得用 App Secret 作为映射键,也不得把完整凭据写入日志或审计。
- 限速映射属于部署级渠道适配配置,按 `account_code + carrier + speed_level` 唯一定位上游编码,由 Viper/Gateway Adapter 管理;不放入运营人员可随意编辑的 `tb_system_config`
- 缺少账号、运营商或档位映射时立即拒绝,禁止退回默认值、任选其他账号映射或把语义字符串原样发送给上游。
- 实现前必须用真实 Gateway 账号分别核实中国电信与中国广电各档位编码、请求签名和响应结构。未经核实的默认编码不能作为生产发布依据;映射核实是联调与发布门禁,不是让实现人员自行猜测的产品决策。
- Gateway Port 接收语义化命令并返回规范化结果;具体 `{params:{cardNo,code}}` 包装、签名、渠道响应解析和映射均封装在 Adapter 内,不泄漏进 Handler 或 Application。
### 调用可靠性与结果语义
- 限速是外部副作用操作,不沿用共享 Gateway 客户端当前的网络错误自动重试。一次 HTTP 请求只产生一次上游调用尝试。
- 每次尝试先写 Integration Log 的请求事实,再写终态。日志至少记录请求 ID、渠道、逻辑账号、运营商、脱敏卡号、语义档位、实际编码、耗时、响应摘要和结果。
- 上游返回明确业务成功时才向客户端返回成功;返回明确失败时记录失败并返回渠道操作失败。
- 超时、连接中断或无法判断上游是否执行时记录为 `unknown`,返回“渠道处理结果未知,请核实后重试”。不得自动补偿、自动重试或伪造成功。
- 用户可以在核实后再次提交同一目标档位。由于操作语义是设置目标值重复人工操作应被上游安全接受本迭代不新增本地操作表、Outbox 或补偿 Worker也不承诺跨独立请求的业务幂等。
- 沿用请求中间件生成的 `request_id` 做链路关联;请求体不新增第二套业务请求号。
### 权限、架构与审计
- 接口只开放在后台管理端,不开放 C 端。沿用现有资产操作权限与数据范围,不扩大任何账号可见或可操作的卡、设备。
- Handler 只负责绑定参数、调用用例和统一响应。参数校验详情写日志,对外返回统一参数错误。
- 该用例采用 `Handler → Application UseCase → Gateway Port/Infrastructure Adapter`。资产解析可复用 Query/Repository 能力;外部副作用、能力校验、调用结果与审计由同一完整用例编排,不创建无业务价值的聚合根。
- 每次成功或失败尝试均写统一 Audit Event至少包含操作者、请求 ID、资产类型与 ID、实际卡号、运营商、`speed_level`、实际渠道编码和结果;失败审计按全局审计方案使用独立短事务,不能被业务事务回滚掉。
- Integration Log 记录渠道交互Audit Event 记录谁对什么业务资产做了什么,二者职责不同但以同一请求 ID 关联。
- 卡号、凭据、原始响应中的敏感字段遵循公共脱敏规则。访问日志也不得保存未脱敏凭据或无限制的渠道原文。
### 前端交互
- 卡详情和设备详情都提供“手动限速”入口;设备入口明确显示本次将作用的当前 ICCID。
- 档位使用后端允许的固定枚举与中文标签,前端不得自行提交任意 Kbps 数值或上游编码。
- 不支持、无当前卡或映射未配置时入口禁用并显示后端原因;即使前端状态过期,后端仍重新校验。
- 提交前二次确认资产、当前 ICCID和目标档位提交期间禁止重复点击。
- 结果未知时不得显示成功,提示用户先通过渠道或后续卡信息核实,再决定是否人工重试。
- 页面不展示“当前实际限速”字段,除非未来存在独立、经验证的渠道查询能力。
### 发布与回滚
- 发布前完成当前绑定重复数据检查、部分唯一索引、真实账号映射核实、两家直连运营商联调和权限回归。
- 前后端同批切换统一接口;旧设备接口下线,避免新旧入口产生两套卡号语义。
- 通过配置开关可整体关闭手动限速入口和新接口,但不得用开关绕过权限或运营商能力校验。
- 回滚应用时保留 Audit Event 与 Integration Log这些是已经发生的外部调用事实不得删除或改写。
## Testing Decisions
- 枚举契约测试覆盖全部十个 `speed_level` 及未知值,验证前端永远不能直接提交上游编码或任意速率。
- 资产解析测试覆盖卡 ICCID、设备唯一当前绑卡、无当前绑卡、多条当前绑卡、软删除绑定和越权资产确认从不把 IMEI 作为 `cardNo`
- 运营商测试覆盖中国电信、中国广电成功进入 Adapter以及中国联通、中国移动、未知运营商在调用前被拒绝。
- 配置测试覆盖不同逻辑账号和运营商映射、档位缺失、账号缺失、非法映射及凭据不进入日志;缺失时绝不使用猜测默认值。
- Gateway 契约测试使用假渠道验证请求包装为 `{params:{cardNo,code}}`、响应规范化、明确成功、明确失败、格式异常、超时和连接中断。
- 重试测试验证每个 HTTP 操作最多调用上游一次,尤其验证共享客户端原有 `maxRetries=2` 不作用于限速方法。
- 审计测试验证成功、明确失败和未知结果都有 Audit Event 与 Integration Log并用同一请求 ID 关联;敏感字段均脱敏。
- 并发测试覆盖同一资产同时提交相同或不同档位,验证每个请求独立留下真实尝试记录且系统不谎报最终上游状态。
- PostgreSQL 迁移测试验证当前有效绑定部分唯一索引,并验证存量重复会使迁移门禁失败而不是被自动清理。
- HTTP 集成测试穿过 Fiber 认证、权限、Application、Gateway 假服务和统一错误处理,覆盖卡、设备、越权、结果未知及旧路由已下线。
- 联调验收必须用真实测试账号分别验证中国电信与中国广电的每个生产开放档位,并将核实后的映射作为受控部署配置。
## Out of Scope
- 不给套餐增加限速字段或套餐级限速策略。
- 不按流量、余额、到期时间或卡状态自动触发限速或恢复。
- 不改造现有轮询任务、队列频率或状态同步流程。
- 不实现中国联通通信计划调整,也不为中国移动伪造直连能力。
- 不新增限速操作业务表、Outbox、自动重试或补偿 Worker。
- 不查询或声称保存了上游当前实际限速状态。
- 不把上游编码暴露为公共 API 契约。
## Further Notes
- 当前代码的设备限速接口解析 IMEI并通过 `/device/speed-limit` 发送整数 KB/s该实现与本需求的 ICCID、语义档位和 `/flow-card/speedLimit` 契约冲突,不能作为兼容依据。
- 当前 Gateway 客户端默认对网络错误重试两次;本操作必须显式关闭,否则无法满足外部副作用“结果未知不盲重试”的要求。
- 真实 Gateway 映射和响应格式仍需外部联调确认,但产品范围、失败语义和安全边界已经确定,因此规格可进入实现代理。

View File

@@ -0,0 +1,170 @@
# PRDUR#48 按资产类型限制 C 端支付方式
Status: ready-for-agent
---
## Problem Statement
当前 C 端代码已经存在“卡仅允许支付宝、设备仅允许微信、钱包均可使用”的校验草稿,但相关校验被注释,支付页面和后端实际上没有统一读取一份可配置规则。用户可以绕过前端直接提交资产不允许的支付方式。
当前普通套餐订单还在创建时不保存支付方式,到后续 `/pay` 才临时选择;强充却必须在创建流程中立即决定拉起微信还是支付宝。这使普通下单和强充对同一个 `payment_method` 的含义不一致,也无法保证订单创建后支付方式不被替换。
## Solution
通过公共系统配置分别维护卡和设备允许的 C 端支付方式初始默认卡为支付宝与钱包、设备为微信与钱包钱包始终存在且不可取消。C 端资产信息返回 `allowed_payment_methods`,页面只展示后端返回的方式。
`POST /api/c/v1/orders/create` 必须提交 `payment_method`,后端在创建任何订单或强充单前按资产配置校验并固化选择。普通套餐订单后续支付只能执行订单上已经保存的方式,不能在 `/pay` 更换;强充则在创建流程中直接用该方式拉起相应第三方支付。创建与实际支付两个时点都重新校验当前配置。
## User Stories
1. 作为卡用户,我默认只看到支付宝和钱包,不会误选设备专用的微信渠道。
2. 作为设备用户,我默认只看到微信和钱包,不会误选卡专用的支付宝渠道。
3. 作为用户,我希望创建订单时选定支付方式,发生强充时系统能立即拉起正确渠道。
4. 作为用户,我希望待支付订单的支付方式保持不变,不会在支付时被换成另一种方式。
5. 作为平台超级管理员,我希望通过受控复选框调整卡、设备的允许方式,同时不能移除钱包。
6. 作为安全与审计人员,我希望前端隐藏和后端强校验使用同一规则,配置损坏时也不会放开全部渠道。
## Implementation Decisions
### 支付方式规则
- C 端业务支付方式固定为 `wallet``wechat``alipay``offline``bank` 等后台或线下方式不能加入本配置,也不能通过 C 端接口提交。
- 初始化安全默认值:
- 卡资产:`["alipay", "wallet"]`
- 设备资产:`["wechat", "wallet"]`
- `wallet` 对卡和设备始终允许。后台页面固定勾选且不可取消,后端更新配置时再次强制校验。
- 超级管理员可以在对应资产集合中增加或移除 `wechat``alipay`,因此默认限制不是写死的永久单通道规则。
- 允许集合只表达资产类型的业务许可不代表渠道此刻健康或支付参数一定完整。真正拉起微信或支付宝时仍执行已有渠道配置、OpenID、签名和可用性校验失败时不得自动切换支付方式。
### 公共系统配置
- 依赖公共 `tb_system_config` 基础能力,使用两个受控配置:
- `c2b.payment.card_allowed_methods`,模块 `c2b.payment`
- `c2b.payment.device_allowed_methods`,模块 `c2b.payment`
- `config_value` 继续按系统配置契约保存 JSON 数组文本;业务代码必须通过系统配置读取能力解析,不能在 Handler 中直接读表或自行解析另一套规则。
- 合法配置必须是非空数组、元素唯一、只包含 `wallet|wechat|alipay` 且包含 `wallet`。空数组、未知值、重复值、非法 JSON、缺少钱包或记录缺失均视为配置异常。
- 配置异常时记录错误并使用对应资产的完整安全默认集合,禁止“解析失败则允许全部”,也不能把部分损坏值与默认值随意拼接。
- 配置读取使用真实 Redis 缓存TTL 为 5 分钟Key 由 `pkg/constants/redis.go` 的函数生成。更新数据库成功后立即删除对应 Key下次读取回源 PostgreSQL。
- 只有平台超级管理员可以读取管理页并更新这两个值;普通平台账号、代理、企业和 C 端均不得修改。
- `GET /api/admin/system/config?module=c2b.payment` 复用公共配置列表;`PUT /api/admin/system/config/{config_key}` 复用公共更新接口。未知 Key 默认不可写,不能借通用接口创建任意配置。
- 管理页面必须将已知支付配置渲染为复选框,不向运营人员暴露 JSON 编辑框;钱包显示为已勾选且禁用。
### C 端资产信息契约
- `GET /api/c/v1/asset/info` 增加:
```json
{
"allowed_payment_methods": ["alipay", "wallet"]
}
```
- 返回值根据已经解析并确认归属的资产类型计算,顺序稳定为 `wallet``wechat``alipay` 中配置允许项的产品展示顺序;前后端冻结统一顺序,不能依赖数据库 JSON 原始顺序产生界面抖动。
- 未识别资产类型、无权资产或资产不存在沿用现有安全错误,不为探测配置而返回允许方式。
- 该字段是页面展示提示,不是授权凭证。创建充值、创建套餐订单和执行支付都必须自行重新读取并校验。
### 套餐订单创建契约
- `POST /api/c/v1/orders/create``payment_method` 从“强充可选传”改为所有下单必填,允许值为 `wallet|wechat|alipay`
- 后端完成资产解析、归属和套餐购买校验后,必须在产生订单、充值单、支付单或调用第三方前,按实际资产类型校验 `payment_method` 是否仍在当前允许集合。
- 普通套餐下单仍然是两阶段流程:创建接口生成待支付套餐订单,不在普通场景自动拉起第三方支付;但创建时必须把所选方式保存到订单 `payment_method`,作为不可变支付方式快照。
- 创建接口成功响应和订单详情返回已选 `payment_method`,让页面明确后续将执行哪一种方式。
- 若选择 `wechat`,普通下单时可以不提前提交 `app_type`,因为此时没有拉起支付;后续 `/pay` 执行微信支付时必须提交合法 `app_type`
- 若创建流程判定需要强充,必须立即按所选方式执行:
- `wechat`:创建强充充值单及支付单,并按 `app_type` 拉起微信;此时 `app_type` 必填。
- `alipay`:创建强充充值单及支付单并返回支付宝支付链接,不要求 `app_type`
- `wallet`:明确拒绝并提示“该套餐需要先充值,请选择当前资产允许的微信或支付宝方式”,不创建任何套餐订单、充值单或支付单。钱包不能给自身充值。
- 若资产配置只剩钱包,而所选套餐要求强充,则该套餐暂时无法完成购买;系统不得绕过配置启用第三方方式,也不得把钱包强充伪装为普通钱包支付。
### 待支付订单支付契约
- `POST /api/c/v1/orders/{id}/pay` 不再让客户端选择或修改 `payment_method`,必须读取订单创建时保存的方式。
- 新契约请求体只携带执行该固定方式所需的附加参数:订单方式为微信时 `app_type` 必填;钱包或支付宝不需要 `app_type`
- 如果前后端过渡期间仍收到 `payment_method` 字段,只能在其与订单快照完全相同时兼容执行;不同则返回冲突,绝不能覆盖订单字段。过渡结束后从 DTO 和 API 文档移除该字段。
- 支付前再次根据订单关联的实际资产类型读取最新允许集合:
- 快照方式仍允许:继续执行。
- 快照方式已经被管理员禁用:拒绝支付,不自动换渠道;用户取消原订单后用新的允许方式重新创建。
- 支付方式是订单不可变业务字段。任何 Service、支付回调或重试流程都不得修改待支付订单的 `payment_method`
- 支付记录的 `payment_method` 必须与订单快照一致。已有同订单、同方式的有效待支付记录按现有幂等规则复用;不得复用另一支付方式的支付记录。
### C 端主动取消
- 新增 `POST /api/c/v1/orders/{id}/cancel`,只允许当前 C 端用户取消属于自己的待支付套餐订单。
- 订单不存在和不属于当前用户使用统一安全错误,防止探测其他用户订单;已支付、已退款等非待支付状态不得取消。
- 取消使用 `WHERE id=? AND payment_status=待支付` 条件更新,保证与支付并发时只有一个终态成功。已经取消的同一订单重复请求按幂等成功返回当前状态。
- 取消成功后,用户才可以为相同资产和套餐重新选择支付方式创建订单;不能仅在旧订单上修改方式。
- 取消时复用现有订单取消用例,正确关闭或作废该订单尚未完成的支付记录,并释放现有流程中已经冻结的资源;不得复制一套状态流转。
- 现有 30 分钟待支付订单自动取消任务继续保留,作为用户未主动取消时的兜底。
### 普通资产钱包充值
- C 端创建资产钱包充值单的接口也必须按资产允许集合校验所选第三方方式,不能只在套餐支付处校验。
- 钱包充值接口本身只支持 `wechat|alipay`。页面展示方式应取“资产允许集合”与“充值接口支持集合”的交集,因此即使 `wallet` 始终在资产集合中,也绝不能显示为给钱包充值的支付手段。
- 强充和普通钱包充值使用相同的资产支付许可判断,但保留各自现有金额、归属、渠道和幂等规则。
- 若交集为空,充值页面禁止提交并明确提示当前资产没有可用充值渠道;后端仍必须拒绝构造请求。
### 配置变更、并发与幂等
- 配置采用支付动作发生时的最新有效值,不给订单保存配置版本快照。创建成功后配置发生变化,后续 `/pay` 按最新配置复核,因此可能要求用户取消并重新下单。
- 配置更新的数据库写入和统一 Audit Event 在同一事务完成;事务提交后删除 Redis 缓存。缓存删除失败需要记录错误并进行有限重试/告警,不能谎称各节点已经立即生效。
- 订单创建的现有 Redis 防重业务摘要必须包含所选 `payment_method`,或在命中旧摘要时比较完整请求摘要:相同请求返回同一结果,不同方式不能被误判为同一幂等请求。
- 同一资产、套餐组合已有待支付订单时,试图改用另一方式必须返回冲突并提示先取消原订单;取消后才能按新方式重新创建。不能靠更换方式并发生成多个有效待支付订单。
- Redis 只承担短时防重和缓存,订单支付方式与配置值的权威事实仍在 PostgreSQL。
### 架构、错误与审计
- 公共系统配置采用简单 Application 事务脚本;支付方式解析与校验提供一个可复用的应用能力,供资产信息 Query、套餐下单、订单支付、普通充值和强充调用。
- 禁止在各 Handler 复制卡/设备默认数组或各写一套 `if`;代码内安全默认值和合法枚举统一放在 `pkg/constants/` 并使用中文注释。
- 参数格式非法返回统一参数错误;方式合法但不在资产允许集合时返回统一禁止错误及中文提示;底层 Redis、PostgreSQL、JSON 或渠道错误只写日志并转换为统一错误。
- 配置更新必须记录统一 Audit Event包含 Key、变更前后允许集合、操作者和请求 ID支付拒绝及支付执行继续按订单/支付统一审计方案记录。
- 不在日志、访问日志或审计中保存支付密钥、完整渠道请求签名等敏感信息。
### 前端交互
- 支付页面完全根据 `allowed_payment_methods` 展示资产可选方式,不维护卡/设备固定规则副本。
- 创建套餐订单前必须选择一种方式,并随 `/orders/create` 提交;创建成功后在待支付订单上显示“已选支付方式”,不再提供切换控件。
- 如需切换,用户必须先明确取消待支付订单,再重新选择方式创建;前端不能仅修改本地选中项后调用 `/pay`
- 强充场景选微信或支付宝后由创建接口直接返回对应支付参数;选钱包被拒绝时保留套餐选择,并引导用户改选当前资产允许的第三方方式。
- 普通充值页面不展示钱包选项,只展示允许集合与 `wechat|alipay` 的交集。
- 后台更新支付方式后提示配置已保存C 端重新进入或刷新资产页获取最新集合。正在展示的旧集合不影响后端再次校验。
### 发布与回滚
- 先发布 `tb_system_config`、初始化值、Redis 缓存与后端双重校验,再同批发布创建订单必填支付方式和前端固定方式交互;不能只发布前端隐藏。
- 上线前核查历史待支付订单中空 `payment_method` 的数量。历史空值订单不能猜测支付方式,应要求取消并按新契约重建;已支付历史订单保持原样。
- 前后端同批切换 `/orders/create` 必填字段和 `/pay` 不可换方式契约。发布窗口应阻断旧前端继续创建缺少方式的新订单。
- 回滚应用时保留系统配置、订单快照、支付记录和审计事实;不得通过回滚迁移删除已产生数据。
## Testing Decisions
- 配置单元测试覆盖卡/设备默认集合、合法自定义、钱包不可移除、空数组、未知值、重复值、非法 JSON、缺失记录和未知资产类型。
- 使用真实测试 PostgreSQL 与 Redis 的集成测试验证初始化值、5 分钟缓存、更新后立即失效、缓存故障回源策略及配置异常安全默认;不以完全不连接 Redis 的假环境代替关键验收。
- 权限测试验证仅平台超级管理员能修改配置,代理、普通平台账号、企业及 C 端均不能越权读取管理信息或更新。
- 资产信息测试验证卡、设备分别返回最新允许集合,配置异常回退安全默认,资产不存在或越权不泄露配置。
- 创建订单测试覆盖三种方式必填校验、卡/设备允许与禁止组合、方式写入订单、普通订单不立即拉起支付,以及提交前配置变化。
- 强充测试覆盖微信立即拉起且要求 `app_type`、支付宝立即返回链接、钱包明确拒绝、配置只有钱包时不可强充,以及失败时不残留订单/充值单/支付单。
- `/pay` 测试覆盖读取订单快照、相同过渡字段兼容、不同字段冲突、请求不传方式、微信附加参数、管理员禁用后拒绝、取消重建后使用新方式。
- 支付记录测试验证其方式始终等于订单快照,不跨方式复用待支付记录,回调也不能改变订单方式。
- 普通钱包充值测试覆盖允许集合交集、钱包永不作为充值渠道、无第三方交集时拒绝以及前端构造非法方式时后端兜底。
- 幂等与并发测试覆盖同请求同方式复用、同资产套餐不同方式冲突、待支付订单取消后重建,以及 Redis 短时键不能取代 PostgreSQL 权威状态。
- C 端取消测试覆盖本人待支付订单成功、他人订单安全拒绝、非待支付拒绝、重复取消幂等、取消与支付并发只产生一个终态,以及取消后可按新方式重新创建。
- HTTP 集成测试穿过 Fiber、认证、Validator、Application、GORM/PostgreSQL、真实 Redis 与统一响应,验证接口文档与运行时契约一致。
- 前端验收覆盖资产支付页、普通两阶段支付、强充一步拉起、取消后换方式、配置刷新、空交集和后台受控复选框。
## Out of Scope
- 不改变代理后台订单、线下支付或银行转账规则。
- 不允许 `/pay` 修改订单创建时选定的支付方式。
- 不让钱包给自身充值,也不绕过强充要求直接钱包购包。
- 不自动在微信、支付宝、钱包之间降级或切换。
- 不根据渠道实时健康状态自动修改 `allowed_payment_methods`
- 不为每张卡或每台设备保存独立支付方式配置;本期粒度只有资产类型。
- 不把系统配置改造成可任意新增 Key 的无约束配置中心。
## Further Notes
- 当前 `ClientCreateOrderRequest.payment_method` 只允许微信/支付宝且仅强充必传,普通订单的 `Order.PaymentMethod` 创建时为空;实现需要按本规格改为创建必填并固化。
- 当前 `/orders/{id}/pay` 必填 `payment_method`,实现后它不再是可选业务决策,只能使用订单快照。
- 当前代码中卡/设备第三方支付限制已经以注释存在,但恢复时必须改为公共配置校验,不能简单解除注释并重新写死。
- 用户已明确确认:支付方式在创建订单时决定,后续不允许更换。

View File

@@ -0,0 +1,182 @@
# PRDUR#49 设备 CSV 批量分配代理或套餐系列
Status: ready-for-agent
---
## Problem Statement
设备号不连续现有按页面勾选、ID 列表、号段或筛选条件操作不适合运营人员手中已有的一批离散设备清单。七月需求需要通过上传文件异步完成两种批量操作:把设备分配给代理,或者为设备设置套餐系列。
原需求稿使用 Excel但本需求只有一列设备标识。Excel 文件更大、解析依赖更重,也容易让人误以为可以在同一表中混合多个业务动作。用户决定本期只使用 CSV同时要求新入口继承现有平台和代理权限不得因旧稿流程图写了“平台员工”而收窄为平台专用。
## Solution
设备管理页提供“批量分配代理”和“批量分配套餐系列”两个独立 CSV 入口。前端先通过现有对象存储预签名能力直传单列 UTF-8 CSV再分别向两个业务接口提交 `file_key` 与唯一目标 ID。接口创建批量任务并向 Asynq 仅发送结构化 `task_id`Worker 下载 CSV、完整校验、去重后按每批 200 个标识批量查询并执行对应命令。
批量用例复用现有设备分配和系列绑定业务规则:平台分配平台库存设备,代理只能向直属下级分配自己名下设备;代理设置系列时只能操作自己名下设备并使用自己已获授权的系列。一个任务只能修改 `shop_id``series_id` 之一,不能同时修改两者。
## User Stories
1. 作为平台员工,我希望上传一列离散设备号,将平台库存设备批量分配给目标代理。
2. 作为代理,我希望用相同入口将自己名下设备批量分配给直属下级代理。
3. 作为平台或代理,我希望上传设备清单批量设置套餐系列,并沿用现有系列权限。
4. 作为运营人员,我希望使用体积小、结构清晰的 CSV不需要上传 Excel。
5. 作为运营人员,我希望看到任务进度、成功数、幂等数和逐行失败原因,并能修正失败行后重新提交。
6. 作为审计人员,我希望批量任务、实际设备归属变更、绑定卡同步和系列变更都有可追溯记录。
## Implementation Decisions
### 两个独立业务命令
- 批量分配代理:`POST /api/admin/devices/batch-assign-shop`
- 批量分配套餐系列:`POST /api/admin/devices/batch-assign-series`
- 两个接口复用同一任务基础设施,但路径和 DTO 独立;请求中不得接受另一个命令的目标字段,也不增加能同时设置代理和系列的 `operation_type` 通用入口。
- `batch-assign-shop` 只允许更新设备归属及现有用例规定的绑定卡归属、设备状态和分配记录,不修改 `series_id`
- `batch-assign-series` 只允许更新 `series_id`,不修改 `shop_id`、设备归属状态或绑定卡归属。
- 现有 `POST /api/admin/devices/allocate``PATCH /api/admin/devices/series-binding` 继续服务页面选择、ID 列表、号段或筛选操作CSV 接口是新增入口,但底层必须收口到相同业务规则,不能复制一套宽松逻辑。
### CSV 契约
- 本需求只接受 `.csv`,不接受 `.xlsx``.xls` 或把 Excel 改扩展名后的文件;不维护 CSV 与 Excel 两套解析器。
- 文件编码固定为 UTF-8可带 UTF-8 BOM换行允许 LF 或 CRLF。其他编码明确拒绝并提示使用模板重新保存。
- 模板由前端作为静态资源发布,后端不增加模板下载接口。建议文件名为 `设备批量分配模板-v1.csv`
- CSV 只有一列,固定表头为 `设备号`;每个数据行一个设备标识,支持设备 `virtual_no` 或 IMEI 精确匹配不支持模糊匹配、号段、SN 或卡 ICCID。
- 标识去除首尾空白后参与匹配,最大 100 字符;空值、控制字符、公式前缀以及已经变成科学计数法或小数形式的长数字逐行失败,系统不得尝试还原猜测。
- 模板需提示用户不要用会改写长数字的格式保存;前端预览也必须按字符串展示。后端最终仍以收到的原始字符串严格校验。
- 单文件最大 10MB、最多 1000 个数据行。先完整完成结构与行数校验,再进行任何业务写入,避免解析到一半才发现文件整体非法。
- 表头缺失、列数不为 1、CSV 引号结构损坏、非法编码、文件过大或超过 1000 行属于文件级错误,任务直接失败且不处理任何设备。
- 数据行中的空值、非法标识和未找到设备属于行级失败。文件内重复标识保留第一次出现,后续重复行记录“文件内设备号重复”,不重复计为成功。
- 解析使用 Go 标准库 `encoding/csv`;本任务不需要 Excel 解析依赖。错误消息和失败明细必须使用中文。
### 对象存储上传
- 复用 `POST /api/admin/storage/upload-url`,新增受控用途 `device_batch_allocation`,前缀限定在设备批量分配目录,默认 Content-Type 为 `text/csv`,只允许生成 `.csv` Key。
- 前端取得预签名 URL 后直接把 CSV 上传对象存储,再调用业务接口提交 `file_key`;业务接口不使用 `multipart/form-data` 接收文件字节。
- 创建任务前校验 `file_key` 非空、属于该受控用途前缀、扩展名正确且对象存在。Worker 下载后再次校验实际大小、编码和 CSV 内容,不能信任扩展名或客户端 Content-Type。
- 本地临时路径和文件字节均不得写入数据库或 Asynq 载荷。Worker 如需临时文件,必须使用存储层受控临时目录并以 `defer` 清理。
- 源文件清理沿用统一对象存储生命周期;清理失败不得改变已经确定的业务任务结果,但需记录告警。
### 创建任务接口
- 分配代理请求:
```json
{
"file_key": "device-batch-allocations/2026/07/21/example.csv",
"shop_id": 123
}
```
- 分配系列请求:
```json
{
"file_key": "device-batch-allocations/2026/07/21/example.csv",
"series_id": 45
}
```
- `shop_id``series_id` 均为大于 0 的必填目标。CSV 新入口不承担回收设备或清除系列;这些动作继续走现有明确接口。
- 提交时先校验操作者身份、目标资源和目标权限,再创建任务。目标不存在或无权限使用统一安全错误,不能通过批量接口探测其他店铺或系列。
- 成功响应返回 `task_id``task_no``operation_type` 和任务状态。此时只代表任务已成功入队,不代表设备已经分配成功。
- 如果数据库创建任务成功但入队失败,将任务标记为失败并返回统一系统错误;不得留下永久“处理中”的假任务。
### 任务模型与状态
- 新建独立 `tb_device_batch_allocation_task`,不复用语义不同的设备导入任务或导出任务表,也不建立数据库外键。
- 任务至少保存:任务号、源文件 Key、`operation_type`、目标 ID、操作者账号 ID、操作者类型与店铺快照、总数、已处理数、成功数、幂等数、失败数、状态、失败明细、错误摘要、开始时间和完成时间。
- `operation_type` 固定为 `assign_shop|assign_series`
- 状态沿用项目异步任务的 int 生命周期枚举:`1:待处理, 2:处理中, 3:已完成, 4:失败`,响应必须同时返回 `status_name`
- 行级业务失败不把整个任务标为系统失败CSV 被完整处理后,即使全部行都因业务原因失败,任务仍是“已完成”并通过 `fail_count` 和明细表达结果。文件级解析错误、存储错误或不可恢复的基础设施错误才是“失败”。
- `total_count = success_count + fail_count``success_count` 包含实际变更与同目标幂等成功;`idempotent_count` 是成功数的子集。重复 CSV 行计入失败,避免汇总数字无法对齐上传行数。
- 失败明细最多 1000 条,与单文件行数上限一致;每项返回原始行号、脱敏或安全的输入设备号和中文原因。不得把数据库错误或其他租户资源信息原样返回。
### 任务查询契约
- `GET /api/admin/devices/batch-allocation/{task_id}` 返回任务号、命令、目标摘要、状态与名称、总数、已处理数、成功数、幂等数、失败数、进度、失败明细及起止时间。
- 进度只在 `total_count > 0` 时按 `processed_count / total_count` 计算;解析尚未完成时返回 0终态固定 100。
- 代理只能查询自己创建的任务;平台账号按现有设备批量管理权限查询平台范围任务,超级管理员可排障查看全部。无权与不存在使用相同错误语义。
- 前端轮询到完成或失败即停止;失败明细直接在页面展示,不要求另建结果文件下载接口。
### 权限与数据范围
- 用户已确认 CSV 入口继承现有权限,旧稿中的“平台员工”只是示意,不是平台专用限制。
- `assign_shop`
- 平台/超级管理员只能把平台库存设备分配给现有权限允许的目标店铺。
- 代理只能把自己店铺名下的设备分配给直属下级店铺,不能分给自己、旁系、上级或跨层级店铺。
- 已经属于目标店铺的设备按幂等成功,主要用于安全恢复 Worker 重试;不重复写归属记录。
- 已经属于其他店铺的设备逐行失败并提示先按现有流程回收,不能由批量任务直接横向转移。
- `assign_series`
- 平台/超级管理员沿用现有系列绑定范围。
- 代理只能操作自己店铺名下的设备,并且目标系列必须是该代理当前有效授权的系列。
- 已经是目标系列按幂等成功;绑定其他系列时沿用现有系列变更规则更新目标值。
- Worker 不能只信任提交时快照。执行前重新加载操作者账号、账号状态、当前店铺关系、目标资源和系列授权;账号已禁用或权限已撤销时,任务失败或对应行安全拒绝,不继续使用过期权限。
- 重新构造后台任务上下文后仍使用现有 GORM 数据范围与业务校验。未命中和无权操作在行级使用统一“设备不存在或无权限”语义,防止上传清单探测其他代理设备。
### 批量查询与执行
- 去重后的设备标识按每批最多 200 条,用 `virtual_no IN (...) OR imei IN (...)` 批量查询;禁止为每行调用一次 `GetByIdentifier` 形成 N+1。
- 同一输入若因历史脏数据命中多台设备,逐行失败为“设备标识不唯一”,不能使用 `First` 任取一台。实现前应核查 IMEI 重复数据,不能假设当前模型未声明的唯一性。
- 每批先构造标识到设备的唯一映射,再一次性加载目标店铺、系列授权、绑定卡和其他业务校验所需数据。
- 业务规则复用现有 Application 用例,但需提取可接受预加载设备批次的内部命令,不能让 Worker 直接执行裸 `UPDATE tb_device` 绕过绑定卡同步、直属关系、系列授权和审计。
- `assign_shop` 每个写批次在同一 PostgreSQL 事务内更新符合条件的设备、绑定卡归属与状态,并创建资产分配记录;任一数据库写失败则该批事务回滚并由任务机制安全重试。
- `assign_series` 对符合条件的设备批量条件更新 `series_id`,并记录统一审计;不需要更新绑定卡。
- Worker 重试时重新读取当前状态:已到目标值的行按幂等成功,不重复写资产分配记录或重复审计;未到目标值的行继续处理。
- Asynq 载荷必须使用结构体 `{"task_id":...}`,不得预先序列化为 `[]byte` 传给 `EnqueueTask`
### 并发、一致性与审计
- 设备归属和系列更新必须使用带期望当前值的条件更新。Worker 查询后若设备被其他请求改变,当前行返回冲突或按最新状态重新判断,不能覆盖并发操作。
- `assign_shop` 的设备、绑定卡与资产分配记录必须同事务提交;不能出现设备已到下级但绑定卡仍留在上级的中间成功。
- 批量任务允许部分成功,但单个写批次中的基础设施错误不得提交一半。行级业务失败在写事务前分离并记录。
- 创建任务、任务终态和实际批量命令均写统一 Audit Event实际归属变化继续写 `AssetAllocationRecord`。审计至少包含任务号、命令、目标、源文件 Key、安全摘要、总数、成功/幂等/失败数和操作者。
- 行级失败原因不得把其他店铺名称、内部 SQL 或底层错误暴露给无权操作者。源文件中的设备标识按公共审计脱敏规则处理。
### 前端交互
- 设备管理页提供两个独立按钮和弹框“批量分配代理”“批量分配套餐系列”。单个弹框只显示对应目标选择器、CSV 模板下载、文件上传和提交按钮。
- 代理的店铺选择器只展示直属下级;系列选择器只展示自己当前有效授权系列。平台沿用现有目标数据范围。
- 文件选择只接受 `.csv`,上传前展示文件名、大小和前几行文本预览;预览不能把长设备号转换成数字。
- 提交前展示命令名称、目标代理或系列以及 CSV 数据行数;上传和提交期间禁止重复点击。
- 任务提交后轮询详情,展示待处理、处理中、已完成、失败,及总数、成功数、幂等数、失败数和失败原因。
- 部分成功必须明确展示,不能把任务整体标成失败而隐藏已完成的设备。用户修正失败行后可新建任务重试。
### 发布与回滚
- 上线前检查目标环境 Redis、PostgreSQL、Asynq Worker 和对象存储均可用,并用真实开发环境完成 CSV 上传、异步执行和任务查询联调。
- 前后端同批增加 `device_batch_allocation` 上传用途、两个任务创建接口和任务详情。Worker 未部署前不得开放前端入口。
- 发布前核查重复 IMEI并验证平台、代理、直属下级和系列授权的数据范围不自动修改历史设备数据。
- 回滚应用时保留已经产生的任务、资产分配记录与审计事实;不得通过回滚删除已经完成的设备归属或系列变更。
## Testing Decisions
- CSV 解析测试覆盖 UTF-8、UTF-8 BOM、LF、CRLF、中文表头、引号、空行、空值、额外列、非法引号、非 UTF-8、控制字符、公式前缀、科学计数法、超长标识、1000/1001 行和 10MB 边界。
- 上传测试覆盖受控 purpose、`.csv` 扩展名、错误 Content-Type、伪装 Excel、越权或其他用途 `file_key`、对象不存在和 Worker 下载失败。
- 权限测试覆盖平台库存分配、平台非库存拒绝、代理分配自己设备给直属下级、分给自己/旁系/上级/跨级拒绝,以及旧稿“平台员工”不造成代理入口缺失。
- 系列测试覆盖平台代理范围、代理自己设备、代理无授权系列、设备不属于自己、同系列幂等、从其他系列切换和不修改 `shop_id`
- 归属一致性测试验证实际分配会同步所有绑定卡归属与状态、创建分配记录,并在任一写入失败时整批事务回滚。
- 批量查询测试验证每 200 条查询、无 N+1、虚拟号与 IMEI 精确匹配、未找到、无权限、跨字段重复命中和历史重复 IMEI不会任取设备。
- 汇总测试验证重复行、逐行失败、实际成功和幂等成功满足 `total=success+fail`,并正确计算 `idempotent_count` 与进度。
- 任务状态测试覆盖待处理、处理中、部分成功后完成、全部业务失败后完成、文件级失败、入队失败、Worker 崩溃重试和终态不重复执行。
- 并发测试覆盖同设备同时分配、系列同时变更、查询后状态被改变,以及条件更新不会覆盖并发结果。
- Asynq 测试验证载荷是结构体并且只包含 `task_id`,不包含文件字节、临时路径或敏感上下文。
- HTTP 集成测试穿过 Fiber 认证、对象存储、Application、真实 PostgreSQL、真实 Redis、Asynq Worker 和统一响应,验证平台与代理完整链路。
- 前端验收覆盖两个独立 CSV 弹框、静态模板、长数字文本预览、目标选择权限、上传失败、轮询、部分成功与失败明细。
## Out of Scope
- 不支持 Excel也不维护 CSV/Excel 双格式兼容。
- 不在同一任务同时分配代理和套餐系列。
- 不用 CSV 执行设备回收、横向转移或清除套餐系列。
- 不支持号段、模糊匹配、SN 或卡 ICCID这些不属于本期单列设备清单契约。
- 不新增后端模板下载接口或失败结果文件下载接口。
- 不改变现有页面勾选、ID 列表、号段和筛选操作入口。
- 不绕过代理直属下级、设备归属和系列授权规则。
## Further Notes
- 当前同步 `AllocateDevices` 已支持平台代理分配、设备与绑定卡归属同事务更新;当前 `BatchSetSeriesBinding` 已支持平台代理系列权限。这些是新 CSV 用例的业务权威,不应由 Worker 重写。
- 当前设备任意标识查询使用 `First` 且 IMEI 没有模型唯一约束;批量实现必须显式检测多命中,避免脏数据导致错误分配。
- 当前对象存储上传用途把 `iot_import` 写死为 Excel Content-Type本需求需要单独增加 CSV 用途,不能继续复用该错误类型。
- 用户已明确确认:新批量入口继承平台代理现有权限,并用 CSV 取代原稿 Excel。

View File

@@ -0,0 +1,108 @@
# PRDUR#53 卡和设备实名状态筛选
Status: ready-for-agent
---
## Problem Statement
卡列表和设备列表目前不能按实名状态筛选。卡已有实名字段,但列表未暴露过滤参数;设备没有稳定的设备级实名状态,页面或查询若临时遍历绑定卡会产生口径不一致和 N+1 风险。
设备实名状态还会随卡实名变化、绑卡、解绑和换卡改变,必须有统一刷新机制,避免快照残留。
## Solution
卡列表按卡自身 `real_name_status` 筛选。设备新增实名状态快照:只要存在一张有效绑定且未软删除的已实名卡,设备即为已实名,否则为未实名。
两个列表都支持可选 `real_name_status=0|1`,返回数值和中文名称,并与其他筛选按 AND 组合。设备列表直接查询快照;实名状态变化和绑定关系变化通过统一、可重算的投影刷新旧/新设备。
## User Stories
1. 作为运营人员,我希望分别查看全部、已实名和未实名的卡。
2. 作为运营人员,我希望分别查看全部、已实名和未实名的设备。
3. 作为运营人员,我希望设备状态反映全部有效绑定卡,而不是只看某个插槽或前端临时结果。
4. 作为运营人员,我希望实名筛选与归属、运营商、套餐系列等现有条件同时生效。
5. 作为维护人员,我希望列表不逐设备查询绑定卡,并且绑定变化后不会残留旧状态。
6. 作为代理账号,我希望筛选不能绕过现有店铺数据范围。
## Implementation Decisions
### 状态语义
- 实名状态继续使用项目常量:`0=未实名``1=已实名`,属于生命周期状态,类型为 int。
- 卡的列表状态直接读取卡自身 `real_name_status`
- 设备存在至少一条有效绑定,且关联卡未软删除、`real_name_status=1` 时,设备快照为 1否则为 0。
- “任意绑定卡已实名”包含所有有效插槽,不仅是 Gateway 当前使用卡。
- 已解绑、已软删除的绑定和已软删除卡不参与计算。
- `realname_link_type=none` 只表示业务无需实名,不会把卡或设备状态伪造为 1筛选展示实际实名快照。
### API 契约
- `GET /api/admin/iot-cards` 增加可选 `real_name_status=0|1`
- `GET /api/admin/devices` 增加可选 `real_name_status=0|1`
- 参数未传时不增加实名条件;传 0 必须正确过滤未实名,不能因零值被当成未提供。
- 新条件与所有已有条件按 AND 组合,并共同作用于 `total` 和分页数据。
- 卡列表继续返回现有 `real_name_status``real_name_status_name`
- 设备列表新增同名数值和中文字段description 必须与实名状态常量一致。
- 非法值、解析失败或列表 DTO 其他已声明校验失败,统一返回 HTTP 400、`code=1001``msg=参数验证失败`,详细原因只写日志。
- 两个列表 Handler 均执行完整请求 DTO 校验;分页默认值、最大 100、权限范围、现有排序和统一响应结构不变。
- 超级管理员和平台账号保持原范围;代理账号继续只看当前权限范围;本需求不得通过实名筛选扩大可见数据。
### 设备快照与迁移
- 设备增加非空 int 快照字段 `real_name_status`,默认 0并使用中文数据库注释。
- 迁移时按现有有效绑定关系一次性计算全部历史设备,不能简单依赖默认 0。
- 迁移向下删除新增快照及其专用索引,不修改卡实名数据或绑定历史。
- 设备列表使用快照直接过滤,不在分页查询中为每台设备执行 EXISTS、子查询循环或逐设备加载卡。
- 为设备实名筛选建立适合未软删除列表的非唯一索引;卡表是否补充同类索引以实际表结构和 EXPLAIN 为准,不得创建唯一约束。
- 快照刷新实现为可重入的批量投影:输入去重后的设备 ID 集合,按数据库真实绑定关系重新计算,而不是依赖“加一/减一”计数。
### 快照刷新时机与公共 DDD
- 卡实名状态变化必须刷新其当前有效绑定设备。上游实名状态只允许由数据同步公共 `ApplyCardObservation` 写入;快照刷新由同一事务的领域事件/可靠 Outbox 驱动,不在旧轮询 Handler 另写一套。
- 管理员人工实名纠偏也必须进入公共卡状态用例并产生相同状态变化事件,不能单独维护设备快照。
- 绑卡成功后刷新目标设备;解绑成功后刷新原设备。
- 卡从旧设备换到新设备或换货迁移绑定时,同时刷新旧设备和新设备;不得只刷新最终设备。
- 同一次操作涉及多个设备时批量去重刷新,禁止逐条 N+1。
- 重复事件和任务重试必须安全;投影按当前数据库事实重算,最终结果一致。
- 若状态事务成功但快照更新暂时失败,使用可靠任务重试并记录错误;不得永久吞掉失败。列表允许短暂最终一致,但必须有可观测的恢复路径。
### 前端
- 卡列表和设备列表筛选区增加“实名状态”:全部、已实名、未实名。
- 全部时不传参数;已实名传 1未实名传 0。
- 表格显示后端 `real_name_status_name`,前端不遍历绑定卡计算设备状态。
- 分页和重新加载继续携带当前筛选;实名条件与其他条件一并提交。
- 沿用页面现有加载、空态和失败反馈。
### 文档与发布
- 更新两个列表的 OpenAPI 请求和设备响应定义,并重新生成文档。
- 新增 UR#53 中文实施总结并更新 README 索引。
- 推荐在公共数据同步 DDD 的状态事件可用后接入设备投影数据库字段、历史初始化、API 和投影刷新需在同一维护窗口发布。
## Testing Decisions
- 领域/投影测试覆盖:无绑定、仅未实名卡、任一实名卡、多张混合卡、软删除卡、软删除绑定和全部解绑。
- 迁移测试验证历史设备正确初始化,向下迁移只移除新增结构,重新向上后结果可重建。
- HTTP 集成测试使用真实 PostgreSQL、Redis、JWT 和进程内 Fiber App覆盖卡/设备的全部、0、1、非法值、AND 条件、分页及代理隔离。
- 验证 `real_name_status=0` 不因 Go 零值丢失。
- 验证卡状态变化事件、人工纠偏、绑定、解绑、旧设备到新设备换卡分别刷新正确设备。
- 验证重复投影任务结果不变,暂时失败后可重试恢复。
- 对列表查询做 SQL 计数或日志断言,设备数量增加时不得产生逐设备实名查询。
- 使用代表性数据执行 EXPLAIN/耗时核查,满足项目数据库查询与 API 性能目标。
- 前端人工验收筛选值、状态名称、组合条件、分页保留和空态。
## Out of Scope
- 不改变实名状态枚举,不增加“部分实名”等第三种状态。
- 不把“运营商无需实名”展示为“已实名”。
- 不由前端计算设备实名状态。
- 不在列表实时调用 Gateway 或等待同步。
- 不为该读取需求迁移整个卡或设备模块。
- 不修改运营商实名回调规则和解除实名策略;它们属于公共数据同步需求。
## Further Notes
- 设备实名状态是可重建查询投影,不是新的业务真相;绑定卡状态和有效绑定关系仍是权威来源。
- 当前代码已有卡响应实名字段,但两个列表缺少过滤,设备模型也无快照;实现时不要误以为现有设备详情的临时派生值可以直接作为列表字段。

View File

@@ -0,0 +1,102 @@
# PRDUR#55 套餐分配生效条件覆盖与购买快照
Status: ready-for-agent
---
## Problem Statement
套餐目前只有全局 `expiry_base`,代理分配记录不能覆盖生效条件。套餐激活代码直接读取可修改的套餐当前值,`PackageUsage` 也没有生效条件、周期类型和购买时长快照,因此套餐购买后修改配置会改变历史订单的激活和预计到期语义。
现有套餐授权入口分散在系列授权、批量分配和购买链路中,如果只改某一个入口,会产生新旧订单行为不一致。
## Solution
建立“套餐默认值 → 代理分配可选覆盖 → 购买使用记录不可变快照”的完整链路。分配覆盖仅影响该分配下未来形成的购买记录;所有新 `PackageUsage` 在创建时写入有效生效条件及周期时长快照,激活、排队接续和预计最终到期只读取快照。
旧记录不批量回填,仅在快照缺失时兼容读取套餐当前值,并对该历史兼容路径保持可观测。
## User Stories
1. 作为平台运营人员,我希望给代理分配套餐时选择跟随默认、购买即生效或实名即生效。
2. 作为平台运营人员,我希望修改已分配套餐的覆盖值只影响后续购买。
3. 作为代理,我希望已购买套餐的计时规则不会因平台后来修改套餐或分配配置而改变。
4. 作为客户,我希望排队套餐和预计最终到期始终按购买时承诺的时长计算。
5. 作为维护人员,我希望所有创建 `PackageUsage` 的生产路径都写入同一组快照。
## Implementation Decisions
### 枚举与有效值
- 延用现有套餐常量:`from_purchase=购买即生效``from_activation=实名激活时生效`
- 禅道草稿中的 `from_realname` 不作为新值;前后端统一使用现有 `from_activation`
- `tb_shop_package_allocation.expiry_base_override` 为 nullable 字符串;`NULL` 表示跟随套餐当前默认值。
- 有效值计算为:分配覆盖非空时取覆盖,否则取套餐 `expiry_base`
- 分配覆盖不改变套餐自身默认配置,也不反向修改其他代理分配。
### 数据与快照
- `tb_package_usage` 新增 `expiry_base_snapshot``calendar_type_snapshot``duration_months_snapshot``duration_days_snapshot`
- 新使用记录必须在创建事务中写入四个快照,不允许先空值落库再异步补齐。
- 快照描述购买时承诺的计时规则,写入后不得因套餐、分配或系列授权修改而更新。
- 正式主套餐和加油包均保存快照确保历史一致UR#46 的最终到期只使用主套餐记录。
- 历史记录的快照保持空值/零值作为“旧数据未快照”标记,不批量伪造购买时配置。
- 旧记录兼容读取套餐当前值时记录结构化告警和指标,便于识别仍在依赖回退的数据;新记录不得进入回退。
- 迁移包含字段、中文注释和必要索引;向下迁移只删除本需求新增结构,不修改现有套餐使用状态。
### API 契约
- 创建或批量创建 `ShopPackageAllocation` 的现有业务入口增加显式 `expiry_base_override`,可为 `null|from_purchase|from_activation`
- 新增 `PATCH /api/admin/shop-package-allocations/{id}/expiry-base`,请求必须包含 `expiry_base_override` 字段。
- PATCH 中 JSON `null` 表示恢复跟随默认;字段缺失属于参数错误,不能与显式 `null` 混淆。
- 分配响应返回 `default_expiry_base``default_expiry_base_name``expiry_base_override``expiry_base_override_name``effective_expiry_base``effective_expiry_base_name`
- 批量系列授权和批量套餐分配若一次创建多条分配,使用同一个显式覆盖选择并写入每条记录;不能只有单条入口支持。
- 非法枚举、分配不存在、软删除或越权统一走项目错误规范;权限不足与不存在不做区分。
- 修改相同值按幂等成功,不触碰已购买使用记录。
### 套餐生命周期边界
- 购买快照和激活规则进入套餐生命周期 Domain/Application不能继续由订单 Service、自动购包任务和激活 Service 各自读取套餐当前值。
- 建立统一 `PackageTermsSnapshot` 值对象或等价的单一解析函数,负责校验并返回生效条件、周期类型、月数和天数。
- C 端订单、后台代购、代理囤货、自动购包及其他所有 `PackageUsage` 创建路径必须调用同一快照能力。
- 使用记录激活、首次实名激活、前一主套餐到期后的排队接续、退款后的接续都只读取使用记录快照;旧记录才允许显式回退。
- 当前代码中“行业卡永远直接激活”等旧分支不能绕过快照和 UR#62/UR#73 的统一实名规则,应在触碰的完整套餐激活用例内收口。
- 配置修改是简单写事务;购买和激活涉及不可变业务规则,使用复杂写 Application/Domain 通道。
### 前端
- 套餐授权/分配表单增加:跟随套餐默认、购买即生效、实名即生效。
- 跟随默认必须发送 JSON `null`,不能通过省略字段表达。
- 列表和详情同时展示默认值、覆盖值和最终值的后端中文名称。
- 编辑时明确提示“仅影响后续新订单,不影响已购买套餐”。
- 配置更新成功后重新拉取分配详情,不由前端自行计算有效值。
### 发布顺序
- 先发布兼容读取新字段的应用,再执行迁移并切换所有创建路径写快照;在同一维护窗口完成,避免产生新的空快照记录。
- UR#46 和 UR#33 依赖本需求的购买时长快照,实施顺序为 UR#55 → UR#46 → UR#33
## Testing Decisions
- 领域测试覆盖无覆盖、两个覆盖值、非法值以及套餐默认修改前后的有效值。
- 集成测试覆盖创建分配、批量分配、系列授权、修改覆盖、显式恢复默认、字段缺失和越权。
- 覆盖 C 端购买、后台代购、代理购买、自动购包等所有 `PackageUsage` 创建路径,断言四个快照完整。
- 验证购买后修改套餐周期、时长、生效条件或分配覆盖,不改变已有使用记录的激活和预计到期结果。
- 验证新记录快照缺失时拒绝或告警,不静默使用回退;旧记录仍可兼容运行。
- 使用真实开发 PostgreSQL、Redis、JWT 和进程内 Fiber App支付、Gateway 等外部副作用使用测试 Adapter。
- 验证事务失败不留下部分分配或部分使用记录,重复 PATCH 幂等。
- 前端验收三个选项、`null` 恢复、中文名称及“只影响未来购买”提示。
## Out of Scope
- 不修改已购买使用记录的快照。
- 不批量回填历史使用记录的购买时配置。
- 不新增第三个生效条件枚举。
- 不在本需求实现最终到期展示、临期列表或通知。
- 不迁移未触碰的套餐管理 CRUD。
## Further Notes
- 当前模型已保存流量相关快照,但没有计时快照;实现时应复用现有快照创建边界,而不是在查询阶段拼接可变套餐配置。
- `NULL=跟随默认` 与“购买时快照为空”是两个完全不同的概念:前者在购买时必须解析成非空有效值,后者只允许历史兼容。

View File

@@ -0,0 +1,89 @@
# PRDUR#57 活跃退款资产禁止换货
Status: ready-for-agent
---
## Problem Statement
换货创建目前没有检查旧资产是否存在未结束的退款。换货可能在退款审批或资金处理期间改变资产关系,造成资产状态、套餐、钱包和退款处理对象不一致。
七月退款方案又将企微审批状态与实际退款处理状态分离,因此不能只用“审批是否通过”判断退款是否已经结束。
## Solution
创建换货前,根据旧卡或旧设备主键批量查询活跃退款。企微审批中、历史已退回、或企微已通过但退款业务处理尚未成功时拒绝换货;已拒绝、已撤销/删除、已软删除、或退款处理已经成功时放行。
校验进入换货创建 Application 用例并与换货单创建保持事务一致。失败返回统一中文业务错误,前端只展示后端结论,不复制退款状态机。
## User Stories
1. 作为运营人员,我希望退款仍可能影响资产或资金时不能创建换货。
2. 作为运营人员,我希望退款被拒绝、撤销或完成后可以正常换货。
3. 作为运营人员,我希望拦截后已填写的换货资料仍保留,方便更换资产重试。
4. 作为维护人员,我希望审批状态和退款处理状态共同决定活跃性,避免把“审批通过但处理失败”误认为完成。
5. 作为维护人员,我希望校验失败不产生换货单、资产占用或迁移半成品。
## Implementation Decisions
### 活跃退款定义
- 退款 `status=1`(待审批/企微审批中)时拦截。
- 历史退款 `status=4`(已退回)时拦截;新企微流程不再产生该状态,但必须兼容存量数据。
- 退款 `status=2`(审批已通过)且 `processing_status!=2` 时拦截,包括待处理、处理中和处理失败。
- 退款 `status=2``processing_status=2`(业务处理成功)时放行。
- 退款 `status=3`(已拒绝)或 `status=5`(已撤销/审批已删除)时放行。
- 已软删除退款不参与判断。
- 同一资产只要存在任意一条活跃退款即拒绝;不因另有一条已结束退款而放行。
- 卡使用退款记录的 `iot_card_id`,设备使用 `device_id`;不得依赖可变化的资产展示标识符做关联。
### 换货创建与一致性
- 复用 `POST /api/admin/exchanges`,不新增预检查 API。
- 校验对象是请求中的旧资产;新资产是否可用继续由换货现有规则判断。
- 查询能力接受一组卡 ID 和设备 ID一次返回存在活跃退款的资产集合禁止逐资产 N+1。
- 活跃退款校验必须在换货创建事务内完成,并位于任何换货单、资产占用、状态修改、客户绑定或钱包迁移之前。
- 校验失败时整个创建用例不产生数据库副作用,也不调用外部系统。
- 拒绝返回 HTTP 403、`code=1005``msg=该资产存在退款申请``data=null`;服务端日志和审计记录具体资产与命中原因,客户端不暴露其他人的退款详情。
- 查询失败转换为统一内部错误;不得把数据库错误当作“没有活跃退款”继续创建。
- 这是换货创建的业务不变量。按渐进 DDD 规则,至少将完整“创建换货”用例收口到 Application/Domain旧 Exchange Service 只能作为过渡门面,不能在事务外追加一次易被绕过的检查。
- 后台、未来批量入口或其他调用创建换货的内部路径必须复用同一用例,不得只在 Handler 校验。
### 与 UR#35 的依赖
- `processing_status``status=5` 由 UR#35 退款企微终态模型提供UR#57 不创建另一套退款状态字段。
- UR#57 的活跃退款策略必须引用退款领域/Query 中的正式常量和语义,不硬编码一份与 UR#35 分叉的状态表。
- 推荐先完成 UR#35 的迁移和状态模型,再接入 UR#57;若并行开发,双方先共享同一数据库契约后再编码。
- 企微审批明细、审批人和意见不参与本次判断;本地退款业务状态与处理状态是判定依据。
### 前端
- 前端不增加退款预检查请求,也不自行维护审批/处理状态映射。
- 创建失败时在表单顶部或对应资产处展示“该资产存在退款申请”,并保留所有已填内容。
- 用户替换旧资产后可重新提交;提交期间保持现有防重复交互。
- 成功响应和换货后续流程不变。
## Testing Decisions
- 策略单元测试覆盖状态 1、2、3、4、5且状态 2 覆盖处理状态 0、1、2、3。
- PostgreSQL 集成测试分别创建卡退款和设备退款,验证软删除、多个历史退款和任一活跃记录命中的组合。
- HTTP 集成测试穿过认证、权限、Handler、Application、Store 和统一错误处理,验证拒绝响应及成功路径。
- 验证拒绝后换货单数量、旧/新资产状态、客户绑定、钱包和套餐均未变化。
- 验证退款查询按一组资产一次完成;批量能力不得随资产数线性增加查询次数。
- 验证数据库查询异常返回脱敏 500不能降级放行。
- 与 UR#35 联调验证:企微审批中拦截、驳回放行、撤销放行、审批通过但处理失败仍拦截、处理成功放行。
- 前端人工验收验证错误展示、表单保留和更换资产重试。
## Out of Scope
- 不改变退款创建、审批、退款金额或资金处理规则。
- 不新增本地审批动作或前端退款状态预判。
- 不阻止退款完成后的换货。
- 不清理或回填历史退款数据;仅兼容历史已退回状态。
- 不改变新资产选择、换货物流、迁移或完成规则。
- 不通过本需求新增批量换货 API。
## Further Notes
- 推荐实施顺序UR#35 退款状态模型 → UR#57 活跃退款策略 → 换货创建联调。
- “审批通过”不等于“退款完成”是本需求最重要的验收边界。

View File

@@ -0,0 +1,201 @@
# PRDUR#60 店铺联系电话精确查询
Status: ready-for-agent
---
## Problem Statement
后台店铺列表目前不能按店铺联系电话检索。运营人员知道完整联系电话时,仍需通过店铺名称、编号或翻页人工定位,效率低且容易选错店铺。
本次核查还确认了三个与该查询直接相关、必须在同一需求闭环的问题:
1. 店铺列表请求虽然声明了分页及筛选校验规则,但 Handler 当前没有执行整体验证,非法查询参数可能进入查询层。
2. 未传 `page``page_size` 时,数据库实际按第 1 页、每页 20 条查询,但响应元数据仍可能返回 `page=0``size=0`
3. 企业账号当前可能进入核心店铺管理路由;由于其没有代理店铺范围,列表查询可能失去预期的数据隔离。企业账号不应访问核心店铺管理功能。
目标是在不扩大店铺模块架构迁移范围、不改变现有响应结构的前提下,提供可验证、权限安全、性能可接受的 11 位联系电话精确查询,并完成前后端交付和文档闭环。
## Solution
在现有店铺列表接口 `GET /api/admin/shops` 增加可选查询参数 `contact_phone`。非空值必须由 11 个 ASCII 数字组成,查询使用等值匹配;它与店铺名称、店铺编号、上级店铺、层级、状态等已有筛选条件按 AND 组合。空值等同未传,不改变原查询结果。
同时完成以下配套改动:
1. 对店铺列表请求执行完整 DTO 校验,并通过统一错误响应隐藏具体校验细节。
2. 在请求进入查询前统一归一化分页默认值,使查询条件与响应中的分页元数据一致。
3. 在核心店铺管理路由增加企业账号访问限制,同时保留超级管理员、平台账号和代理账号原有权限及代理数据范围。
4. 为未软删除店铺的联系电话建立非唯一部分 B-tree 索引,支持重复联系电话并降低精确查询成本。
5. 前端店铺列表增加联系电话筛选、校验、查询和清空能力,并沿用列表已有的加载、空态和失败反馈。
6. 使用真实开发 PostgreSQL、Redis、JWT 配置完成 HTTP 集成验证,更新 OpenAPI 和需求总结文档后再进入联调与人工验收。
## User Stories
1. 作为有权限的后台运营人员,我希望输入完整的 11 位店铺联系电话并精确查找店铺,以便快速定位目标记录。
2. 作为有权限的后台运营人员,我希望联系电话筛选与名称、编号、上级店铺、层级、状态等筛选条件同时生效,以便逐步收窄结果,而不是扩大结果集合。
3. 作为有权限的后台运营人员,当多个可见店铺使用同一联系电话时,我希望看到全部符合其他筛选条件的记录,而不是只返回一条。
4. 作为有权限的后台运营人员,当联系电话没有匹配记录时,我希望得到正常的空分页结果,而不是“资源不存在”错误。
5. 作为有权限的后台运营人员,当我不填写联系电话时,我希望列表行为与改造前一致。
6. 作为有权限的后台运营人员,我希望前端在联系电话不是 11 位 ASCII 数字时直接提示并阻止请求,以便及时修正输入。
7. 作为 API 调用方,当我传入非法联系电话时,我希望后端仍独立拒绝请求,而不依赖前端校验保障数据安全。
8. 作为 API 调用方,当我传入空联系电话时,我希望后端将其视为未提供该条件,不影响已有筛选。
9. 作为 API 调用方,当我不传分页参数时,我希望查询和响应都明确使用第 1 页、每页 20 条。
10. 作为 API 调用方,当我显式传入合法分页参数时,我希望后端严格使用该页码和每页数量,不自行重置页码。
11. 作为 API 调用方,当店铺列表的任何已声明查询参数非法时,我希望收到统一的参数验证失败响应,而不是执行部分或错误查询。
12. 作为代理账号,我希望搜索结果始终只包含本店铺及下级店铺范围内的数据,不能通过联系电话探测其他代理的数据。
13. 作为超级管理员或平台账号,我希望在原有全局可见范围内使用联系电话筛选。
14. 作为企业账号,我不应访问核心店铺管理接口,并应收到明确且一致的禁止访问响应。
15. 作为维护人员,我希望联系电话查询使用适合软删除模型的数据库索引,同时允许多个店铺保存相同联系电话。
16. 作为维护人员,我希望参数错误、权限错误和数据库错误都使用现有统一响应协议,且服务端错误不会把数据库细节暴露给客户端。
17. 作为前端实现人员,我希望获得稳定的参数、响应和错误契约,以便无需猜测后端的分页、筛选或权限行为。
18. 作为验收人员,我希望通过真实 PostgreSQL、Redis 和认证链路验证该接口,以确认权限过滤和实际 SQL 行为,而不只是验证模拟对象。
## Implementation Decisions
### 1. 接口及筛选契约
- 复用现有 `GET /api/admin/shops`,不新增 Handler 或新接口。
- 新增可选字符串查询参数 `contact_phone`,含义为“店铺联系电话精确查询”。
- 非空 `contact_phone` 必须完整匹配 `^[0-9]{11}$`:恰好 11 个 ASCII 数字。
- 不接受前后空格、全角数字、`+86`、连字符或其他格式;后端不做 trim、格式修复或号码归一化。
- 不校验中国大陆手机号号段或首位规则,只校验 11 位 ASCII 数字。
- 参数未传或值为空字符串时视为没有联系电话筛选,不影响原查询。
- 联系电话使用数据库等值条件,不使用 LIKE、包含、前缀或后缀匹配。
- `contact_phone` 与所有已提供的已有筛选参数按 AND 组合;不是 OR 查询。
- 保留已有筛选语义:店铺名称模糊匹配;店铺编号精确匹配;上级店铺、层级、状态精确匹配。
- 允许联系电话重复;相同电话的所有可见匹配店铺均进入结果,再统一分页。
- 没有匹配项时返回成功空分页:`items` 为空数组、`total=0`,不返回 404。
- 结果继续按 `created_at DESC` 排序;本需求不增加次级排序规则。
- 继续排除已软删除店铺,不改变列表项字段和现有分页响应结构。
### 2. 请求校验与分页
- 店铺列表 Handler 在完成查询参数解析后,对整个列表请求 DTO 执行 Validator 校验,而不是仅单独校验联系电话。
- 完整校验规则为:`page` 可省略,提供时不小于 1`page_size` 可省略,提供时为 1 至 100`shop_name` 最长 100`shop_code` 最长 50`parent_id` 提供时不小于 1`level` 提供时为 1 至 7`status` 提供时只能为 0 或 1`contact_phone` 非空时满足 11 位 ASCII 数字规则。
- 参数解析失败或任一字段验证失败时,记录包含请求上下文和具体校验原因的服务端日志;客户端统一收到 HTTP 400、`code=1001``msg=参数验证失败``data=null` 和时间戳,不返回 Validator 或底层解析错误文本。
- 未提供 `page` 时,在查询前归一化为 1未提供 `page_size` 时,在查询前归一化为 20。
- 查询实际使用的页码、每页数量必须与响应 `data.page``data.size` 完全一致。
- 显式提供的合法 `page``page_size` 原样生效;后端绝不因新增或改变筛选条件而自行把页码重置为 1。
- 成功响应继续使用现有统一结构HTTP 200、`code=0``msg=success``data` 包含 `items``total``page``size`,并返回时间戳。
### 3. 权限边界
- 身份认证仍由现有后台认证中间件负责;本需求不创建新的认证机制。
- 核心店铺管理路由仅指店铺列表、创建、更新、删除和联级查询。
- 超级管理员和平台账号保持当前核心店铺管理访问能力;店铺列表可查看其原有范围内的数据。
- 代理账号保持当前访问能力,列表继续应用“本店铺及全部下级店铺”数据范围;联系电话和其他筛选只能在该范围内生效。
- 企业账号访问任一核心店铺管理路由时,统一返回 HTTP 403、`code=1005``msg=无权限访问店铺管理功能``data=null` 和时间戳。
- 企业账号拦截必须只挂在核心店铺管理路由组,不得因复用 `/shops` 前缀而误伤店铺角色接口、代理商资金概况、提现、佣金、钱包流水等独立路由。
- 本需求不重新定义这些独立路由各自已有的权限校验。
### 4. 查询实现与架构边界
- 这是现有列表的简单只读筛选,沿用当前 `Handler → Service → Store → GORM/DTO` 调用链。
- 不为该筛选迁移整个店铺模块不新建聚合根、Repository 抽象、Query 模块、工厂或其他无业务价值的层次。
- Service 将非空联系电话加入筛选集合Store 在同一 GORM 查询上追加等值条件。所有条件必须共同作用于计数查询和分页数据查询。
- 继续复用现有店铺数据权限过滤;不得在增加联系电话条件时绕过、覆盖或改写权限范围。
- 列表读取不新增缓存,不使用 Redis 缓存查询结果。Redis 仅按现有认证及代理范围链路参与测试和运行。
- 数据库查询错误转换为现有内部错误码并保留服务端上下文;客户端收到 HTTP 500、`code=2001``msg=内部服务器错误``data=null`,不得泄露 SQL、主机、库名或驱动错误。
- 这是只读操作,不新增幂等键、分布式锁、审计业务日志或异步任务;现有 HTTP 访问日志继续记录请求。
### 5. 数据库索引与迁移
- 新增名为 `idx_shop_contact_phone` 的非唯一 B-tree 索引,索引列为店铺表的 `contact_phone`
- 索引仅覆盖 `deleted_at IS NULL` 的记录,与列表默认排除软删除记录的查询条件一致。
- 索引不得设为唯一;业务允许不同店铺使用同一联系电话。
- 不新增字段,不修改字段类型,不回填、清洗或删除历史联系电话数据。
- 历史记录中不符合 11 位规则的值继续保留,只是无法被合法的本接口联系电话参数精确命中。
- 迁移回滚只删除该索引,不变更任何业务数据。
- 迁移随七月迭代维护窗口发布;上线和回滚均需核验索引状态。
### 6. 前端交付契约
- 后台店铺列表筛选区新增“联系电话”输入框,并提供现有风格的查询和清空操作。
- 前端仅允许提交 11 位 ASCII 数字;非法值不发起请求,并展示清晰的中文校验提示。
- 有值时以 `contact_phone` 查询参数提交;空值时不提交该参数。
- 联系电话与页面当前已有筛选参数一并提交,后端按 AND 查询。
- 清空联系电话后恢复为不含该条件的列表查询,同时保留产品现有的其他筛选交互规则。
- 前端如何维护或改变页码属于其现有页面状态策略;后端契约只要求尊重实际收到的 `page`,本需求不新增“改变筛选必须重置页码”的规则。
- 查询期间展示加载状态;成功无数据展示空态;请求失败展示可重试的错误反馈,不把旧结果伪装成新查询结果。
- 当前后端仓库不包含前端源码;后端接口和 OpenAPI 就绪后按项目既有交付方式通知前端仓库实施并联调。
### 7. 文档与发布闭环
- 更新店铺列表请求的 OpenAPI 定义,准确描述联系电话格式、精确匹配、分页限制和现有筛选字段;重新生成接口文档。
- 因为复用现有 Handler无需向文档生成器新增 Handler 实例,但必须确认生成结果中该参数真实可见。
- 实施阶段新增 UR#60 中文总结文档,并更新项目 README 的相关索引或说明。
- 后端代码、迁移、自动化测试、OpenAPI 和总结文档完成后才可交给前端联调。
- 前后端联调和人工验收通过后,才可在禅道中更新本需求状态;自动化测试通过不等同于整项需求完成。
## Testing Decisions
### 1. 最高价值测试接缝
- 主要验收采用 HTTP 集成测试:测试进程内启动 Fiber App通过真实路由进入认证中间件、权限中间件、Handler、Service、Store、GORM 和统一响应处理。
- 测试加载项目 `.env.local` 中的真实开发 PostgreSQL、Redis 和 JWT 配置;不把数据库或 Redis 替换为内存实现或 Mock。
- Fiber App 在测试进程内启动,不要求开发者事先在 `127.0.0.1:3000` 运行独立 API 进程;`:3000` 只是正常开发启动时的 API 监听地址,与远程 PostgreSQL、Redis 地址无关。
- 认证用例使用真实 JWT 配置和 Redis Token 状态穿过现有认证中间件,不能通过直接向 Fiber Context 填充用户信息绕过认证。
- 测试数据必须使用唯一标识创建并严格清理;数据库测试记录和 Redis Token/范围缓存均不得污染共享开发环境,也不得依赖环境中偶然存在的业务数据。
- 测试不接入网关、对象存储、短信等第三方系统,因为本需求链路不需要它们。
### 2. 必须覆盖的后端用例
1. 合法 11 位联系电话只返回联系电话完全相等且位于当前权限范围内的店铺。
2. 前缀、后缀和包含关系均不匹配,证明查询不是模糊查询。
3. 两个可见店铺联系电话相同均被返回,证明索引和查询没有引入唯一性假设。
4. 电话联系条件与店铺名称、店铺编号、上级店铺、层级、状态分别及组合使用时均为 AND`total``items` 使用相同条件。
5. 未传和传空 `contact_phone` 均保持原列表语义。
6. 空格、全角数字、`+86`、连字符、少于 11 位、超过 11 位、含字母等非空值均返回 HTTP 400 / `code=1001`,且没有执行店铺查询。
7. `page=0``page_size=0``page_size>100`、非法层级、非法状态、超长名称或编号等已有字段违反 DTO 约束时,也返回统一参数错误。
8. 未传分页参数时,实际查询第 1 页每页 20 条,响应同步为 `page=1``size=20`
9. 显式合法分页参数保持不变;后端不会因存在联系电话筛选而重置页码。
10. 无匹配数据时返回 HTTP 200、空 `items``total=0` 和正确分页元数据。
11. 返回顺序保持 `created_at DESC`,软删除店铺不出现在结果中。
12. 超级管理员和平台账号能在原有范围内查询;代理账号只能看到本店铺及下级店铺,即使范围外店铺使用相同电话也不可见。
13. 企业账号访问列表返回 HTTP 403 / `code=1005` / `msg=无权限访问店铺管理功能`
14. 企业账号访问创建、更新、删除、联级查询同样被核心路由组拒绝;独立的店铺角色及资金/佣金路由不因本次路由分组而被误拦截。
15. 未认证、Token 无效或 Redis Token 状态无效时继续遵循现有认证错误契约。
16. 数据库查询失败时返回脱敏的 HTTP 500 / `code=2001`,响应不出现底层错误文本。
17. 成功和失败响应均保持统一的 `code``msg``data``timestamp` 外层结构。
### 3. 迁移、文档与前端验证
- 在可控开发环境验证迁移向上执行后存在 `idx_shop_contact_phone`,它是非唯一、仅含 `contact_phone` 且带 `deleted_at IS NULL` 条件的 B-tree 索引。
- 验证迁移回滚只删除该索引,随后再次向上迁移恢复索引;全程不改变店铺业务数据。
- 运行目标包测试和全仓 Go 测试,执行格式化、静态检查及构建;任何失败必须在交付前处理。
- 重新生成 OpenAPI并核验店铺列表中出现 `contact_phone` 及正确约束,且生成结果无无关漂移。
- 前端验证合法输入发起正确请求、非法输入不发请求、清空后不携带联系电话、组合筛选参数完整、加载/空态/失败反馈正确。
- 联调以浏览器网络请求和真实接口响应共同核对;不得只凭页面展示判断 AND 条件、分页或权限是否正确。
### 4. 完成标准
- 后端实现、迁移、HTTP 集成测试、全仓回归、OpenAPI、中文总结文档和 README 更新全部完成。
- 前端实现与联调完成,超级管理员/平台/代理/企业四类账号的关键行为通过人工验收。
- 精确查询、AND 组合、默认分页、重复电话、空结果、非法参数、代理隔离和企业 403 均有可复现证据。
- 维护窗口的发布检查和索引回滚路径已演练或核验。
- 人工验收通过并更新禅道后UR#60 才能标记完成。
## Out of Scope
- 不新增独立的联系电话搜索接口。
- 不做模糊、前缀、后缀、分词或跨字段 OR 搜索。
- 不验证运营商号段、号码真实性、号码归属地,也不规范化 `+86`、空格、连字符或全角字符。
- 不修改店铺创建、更新接口的联系电话校验规则;这两个接口的历史验证差异不在 UR#60 内处理。
- 不清洗、回填或删除历史联系电话数据。
- 不禁止多个店铺使用相同联系电话,不建立唯一约束。
- 不改变已有列表项字段、排序、软删除语义或响应外层结构。
- 不调整前端自身的页码状态策略;后端只按收到的参数查询。
- 不重构整个店铺模块,不迁移未触碰用例到 DDD 或新的 Query 目录。
- 不为只读列表增加 Redis 结果缓存、审计业务日志、幂等控制或异步任务。
- 不借本次核心路由权限修复重新设计店铺角色、资金、佣金、提现、钱包流水等独立接口的授权规则。
- 不接入或改造网关、对象存储、短信等第三方系统。
- 不在本 Spec 阶段实施代码、生成整轮七月迭代 PRD、拆分 tickets 或创建 OpenSpec 产物。
## Further Notes
- 本 Spec 仅覆盖七月迭代中的 UR#60“店铺联系电话精确查询”,不代表整轮七月迭代范围。
- 需求事实以当前会话确认、七月迭代标准评审稿、当前独立方案和对应禅道草稿为顺序收敛;当前代码和开发环境仅用于核实系统现状。
- 当前代码已证明列表真实查询默认值与响应分页元数据存在偏差,也证明企业账号可能因无店铺范围而获得过宽列表结果,因此两项修复属于本需求的验收边界,不是额外优化。
- 本需求没有引入新的领域术语、业务状态或跨模块长期架构决策,因此无需修改领域词汇表或新增 ADR。
- 本地开发 PostgreSQL 和 Redis 已验证网络可达,但 API 进程是否已独立启动不影响测试设计;集成测试应自行构建并启动进程内 Fiber App。
- `.env.local` 含敏感配置实施和测试日志不得打印连接密码、JWT 密钥或其他凭据,交付文档也不得复制这些值。

View File

@@ -0,0 +1,104 @@
# PRDUR#62 H5 购买与实名顺序配置
Status: ready-for-agent
---
## Problem Statement
卡和设备已经保存 `realname_policy`,也已有单资产修改接口,但 C 端充值、购买、实名入口分别维护局部判断,接口仍只返回原始策略,无法稳定表达“运营商实名能力优先于资产顺序策略”。后台也缺少卡和设备的批量配置能力。
设备场景还存在两个容易混淆的概念:每张卡是否需要实名由其运营商 `realname_link_type` 决定;设备的 `realname_policy` 只决定购买和实名的先后顺序。本期设备数据前提是所有有效绑定卡都需要实名,不提前设计设备内混有无需实名卡的流程。
## Solution
建立统一的有效实名策略解析和购买资格规则。独立卡使用卡策略;设备以及从设备入口访问的卡使用设备策略。对每张卡,`realname_link_type=none` 表示无需实名,`template/gateway` 表示需要实名。独立卡据此计算有效策略;设备本期仅允许 `before_order/after_order`,并按全部有效绑定卡的实名结果决定购买资格。
保留现有单资产修改接口新增卡和设备批量配置接口。C 端初始化直接返回有效策略、是否需要实名和实际实名状态,前端只编排页面,不复制后端判断。
## User Stories
1. 作为 C 端客户,我希望无需实名的独立卡直接进入购买流程。
2. 作为 C 端客户,我希望“先实名后购买”的独立卡在未实名时先完成实名。
3. 作为设备客户,我希望设备配置为先实名时,全部有效绑定卡完成实名后才能购买。
4. 作为设备客户,我希望设备配置为先购买时,可以完成购买后再逐张处理未实名卡。
5. 作为运营人员,我希望在卡或设备列表一次修改最多 500 个资产的实名顺序,并保证全成全败。
6. 作为运营人员,我希望设备下卡的页面明确提示 H5 顺序由设备策略控制。
7. 作为审计人员,我希望单条和批量变更都留下统一审计记录。
## Implementation Decisions
### 领域语义
- 卡的 `realname_link_type` 始终决定该卡是否需要实名:`none` 为无需实名,`template/gateway` 为需要实名。
- `realname_policy` 只描述购买与实名顺序:`none``before_order``after_order`;默认值继续为 `after_order`
- 独立卡使用卡自身 `realname_policy`。若 `realname_link_type=none`,有效策略固定为 `none`;若为 `template/gateway` 而卡策略为 `none`,这是冲突数据,初始化和购买均明确报错,不得静默放行。
- 设备以及从设备入口解析出的卡使用设备 `realname_policy`,绑定卡自身策略不参与设备 H5 顺序。
- 本期确认设备的全部有效绑定卡都需要实名,因此设备策略只允许 `before_order``after_order`;设备配置 `none` 属于非法配置。
- 本期仍按每张卡的 `realname_link_type` 生成具体实名入口,但不设计设备内出现 `realname_link_type=none` 卡时的资格、展示和批量交互;未来出现该业务后另行扩展。
- 设备 `before_order` 的购买资格是所有有效绑定、未软删除的卡均 `real_name_status=1`;无绑定卡或任一卡未实名都不允许购买。
- 设备 `after_order` 允许先购买;购买完成后返回全部尚未实名的有效绑定卡,前端逐张引导实名。
- UR#53 的设备 `real_name_status` 是列表筛选投影,语义为“任一有效绑定卡已实名”;不得用该快照代替本需求的“全部有效绑定卡已实名”购买资格判断。
- 卡的 `realname_link_type`、实名状态及有效绑定关系由数据同步公共 DDD 提供;本需求不得在 C 端 Handler 复制第二套状态规则。
### API 契约
- 保留 `PATCH /api/admin/assets/{identifier}/realname-mode`,请求为 `{ "realname_policy": "none|before_order|after_order" }`
- 新增 `POST /api/admin/iot-cards/batch-update-realname-policy`
- 新增 `POST /api/admin/devices/batch-update-realname-policy`
- 两个批量接口统一请求:`asset_ids` 为去重后的正整数数组1500 项;`realname_policy` 使用上述枚举。
- 卡批量配置允许三个值,但每张卡都执行运营商能力冲突校验;设备批量配置只允许 `before_order/after_order`
- 任一资产不存在、软删除、越权、类型不符或策略冲突时整批失败,不更新任何资产;错误不得泄露越权资源是否存在。
- 单条和批量接口复用同一 Application 校验规则,不能出现单条可写、批量不可写或反向不一致。
- C 端 `GET /api/c/v1/asset/info` 增加 `effective_realname_policy``realname_required`,继续返回实际 `real_name_status`;原 `realname_policy` 在兼容期保留为配置原值,不再供新前端决定流程。
- 设备响应还应返回 `pending_realname_cards`,仅包含当前客户有权操作且尚未实名的有效绑定卡必要信息;不得返回身份证等敏感信息。
- `realname_required` 对独立卡按卡运营商能力返回;对设备本期固定为 `true`,但仍必须经过设备绑定完整性校验。
- 充值校验、创建充值、创建套餐订单和实名链接入口都调用统一有效策略/资格用例,禁止继续散落比较 `RealnamePolicy`
- 参数解析和完整 DTO 校验失败统一返回参数错误;所有枚举 description 从常量原文同步。
### 事务、权限与审计
- 批量写操作使用 Application 事务脚本:一次批量加载权限范围内资产及必要运营商能力,完成全量校验后批量更新。
- 不逐资产查询运营商、绑定或权限,最多 500 项仍必须批量读取。
- 单条和批量成功后写 Audit Event批量事件记录目标类型、数量、目标策略和请求 ID不在日志中写客户实名敏感数据。
- 代理和平台账号继续使用现有资产数据范围;批量更新不得绕过 GORM Callback 和业务权限检查。
- 重复设置相同策略按幂等成功处理,不制造多次业务副作用;审计中可标记实际变更数量。
### 前端
- 卡、设备列表分别增加批量“修改实名顺序”,显示已选择数量,最多 500 项。
- 卡可选无需实名、先实名后购买、先购买后实名;设备只展示后两项。
- 修改设备下卡策略时提示“实际 H5 流程由设备策略决定”。
- C 端只读取 `effective_realname_policy``realname_required` 和后端返回的待实名卡,不按资产类型或运营商类型自行覆盖。
- `before_order` 未满足时引导实名;`after_order` 在购买完成后引导未实名卡;冲突数据展示后端中文错误并停止流程。
### 发布与依赖
- 本需求依赖数据同步公共 DDD 提供统一卡事实和有效绑定读取边界;公共能力未落地前不得在旧 C 端 Handler 增加临时分支。
- 发布前扫描独立卡 `realname_link_type!=none && realname_policy=none` 以及设备 `realname_policy=none` 的冲突数据,形成修复清单;运行时仍保留拒绝保护。
- API 和对应 C 端、后台前端同一维护窗口发布;旧 `realname_policy` 响应字段只做兼容,不长期承载流程语义。
## Testing Decisions
- 领域测试覆盖独立卡的 `none/template/gateway × none/before_order/after_order × 实名0/1` 组合。
- 设备测试覆盖无绑定卡、单卡、多卡、全部已实名、部分实名、全部未实名、无效绑定和软删除卡。
- 明确验证设备资格使用“全部有效卡已实名”,而 UR#53 设备列表快照仍是“任一有效卡已实名”。
- 批量接口覆盖 1、500、501 项,重复 ID、零 ID、不存在、越权、软删除、策略冲突和整批回滚。
- HTTP 集成测试使用真实开发 PostgreSQL、Redis、JWT 和进程内 Fiber App实名 Gateway 使用测试 Adapter不调用真实上游。
- 回归充值校验、创建充值、套餐购买、实名链接和设备逐卡实名,证明所有入口使用同一策略。
- 验证相同策略重复提交幂等、审计实际变更数量正确,且批量查询无 N+1。
- 前端验收三种独立卡流程、两种设备流程、批量交互、冲突错误和购买后逐卡引导。
## Out of Scope
- 不设计设备内混有无需实名卡时的有效策略和 UI。
- 不修改 `realname_link_type` 的配置入口或实名链接生成协议。
- 不改变 UR#53 的设备实名筛选投影语义。
- 不新增全局实名策略默认配置。
- 不实现数据同步公共 DDD、运营商回调或实名轮询本身。
## Further Notes
- 当前代码已经具备字段、默认值、单条接口和部分前置校验,但这些属于迁移输入;目标是统一资格用例,而不是继续复制条件。
- `realname_link_type` 决定“这张卡是否需要实名”,`realname_policy` 决定“什么时候实名”,两者不得互相替代。

View File

@@ -0,0 +1,95 @@
# PRDUR#73 按运营商实名能力控制复机
Status: ready-for-agent
---
## Problem Statement
当前停复机代码同时存在“行业卡无需实名”和“所有卡都必须实名”的互相冲突分支。卡业务类别被错误当成实名能力,导致相同运营商能力下出现不同复机结果,也使手动复机、设备复机、自动复机和开放接口无法共享同一规则。
七月迭代的数据同步公共方案将卡实名、流量和网络状态收口到统一 DDD 边界。UR#73 不能继续在旧 Service 中补条件,否则会形成第二套卡状态规则和状态写入口。
## Solution
复机是否要求实名只由卡所属运营商的 `realname_link_type` 决定:`none` 不要求实名,`template``gateway` 要求卡已实名。`card_category` 仅保留分类展示用途,不再参与任何复机资格判断。
该规则进入数据同步公共 Asset/Card 领域模型,由统一复机 Application UseCase 供后台单卡、设备、自动任务和 OpenAPI 调用。Gateway 成功后的状态收敛、领域事件、Outbox、Audit Event 和同步触发全部复用公共 DDD不在旧 Service 直接更新卡状态。
## User Stories
1. 作为运营人员,我希望不支持实名能力的运营商卡即使本地仍为未实名,也可在满足其他条件时复机。
2. 作为运营人员,我希望需要实名的运营商卡在未实名时被明确拒绝。
3. 作为运营人员,我希望普通卡和行业卡遵循相同的运营商能力规则,卡类别不再造成例外。
4. 作为设备操作人员,我希望设备下每张卡独立按其运营商能力判断,不因设备中其他卡的状态被错误放行或拦截。
5. 作为 OpenAPI 调用方,我希望复机与后台入口使用同一资格规则。
6. 作为维护人员我希望所有入口只调用一个复机用例避免轮询、Service 和 Handler 各自维护规则。
7. 作为审计人员,我希望成功、拒绝和失败都进入统一审计链路并能关联后续同步观测。
## Implementation Decisions
### 业务规则
- `realname_link_type=none` 表示运营商不要求实名;复机资格不检查 `real_name_status`
- `realname_link_type=template``gateway` 表示运营商要求实名;仅 `real_name_status=1` 可继续复机。
- `card_category=normal/industry` 只用于分类和展示,不参与手动复机、设备复机、自动复机、套餐触发复机或 OpenAPI 复机判断。周期轮询资格不由 UR#73 调整。
- `realname_policy` 只描述实名与购买顺序,不替代运营商能力;本需求不得用它决定复机是否要求实名。
- 保留所有既有非实名约束,包括风险停机、已销户、机卡分离状态限定、停复机保护期、有效套餐、流量耗尽、网络状态和数据权限。
- 多卡设备逐卡判断。满足条件的卡正常处理,不满足条件的卡跳过或拒绝;不得因其中一张卡无需实名而放行全部卡。
- 未知 `realname_link_type`、运营商记录缺失或配置读取失败属于配置/内部错误,不得静默按 `none` 放行,也不得调用 Gateway。
- 未实名拒绝继续使用统一禁止访问业务错误,并向用户返回中文原因“卡未实名,无法操作”。
### DDD 与公共数据同步边界
- 本需求依赖七月“数据同步触发与轮询优化”公共能力先落地;公共能力尚未完成时,不允许先在旧 StopResume Service 中打临时补丁。
- “运营商是否要求实名”和“卡当前是否具备复机资格”属于 Asset/Card 领域规则。领域方法只接收已加载的运营商能力和卡状态,不直接查询 GORM、Redis、Fiber 或 Gateway。
- 后台单卡复机、后台设备复机、自动复机、套餐激活后的复机和 OpenAPI 复机统一调用 Application 复机用例;各入口不得自行判断 `card_category``real_name_status``realname_link_type`
- Application 用例负责加载并锁定必要状态、调用领域资格规则、执行 Gateway 端口、记录业务结果并发布可靠事件。
- 旧 Service 如需过渡,只能作为无业务判断的内部门面转发到新用例;不得继续直接写实名、网络状态、停机原因或复机时间。
- `ApplyCardObservation` 是上游观测写入卡状态的唯一入口。复机 Gateway 返回成功不授权旧 Service 直接修改本地网络状态。
- 复机成功边界发布网络状态同步触发按公共方案创建立即、3 分钟、5 分钟观测序列;观测结果通过 `ApplyCardObservation` 应用,达到开机预期后剩余任务直接完成。
- 状态变化产生统一领域事件和 Outbox触发其他业务联动不得由多个入口重复执行相同副作用。UR#94 本期保持现有轮询调度和重排策略。
- Audit Event 和 Integration Log 使用公共审计设施;不得继续扩展旧资产审计为第二套长期写入系统。
### 接口与前端
- 后台继续复用 `POST /api/admin/assets/{identifier}/start`;卡和设备均走统一复机用例。
- OpenAPI 继续复用现有卡复机接口;机卡分离限定仍先于一般复机规则生效。
- 本需求不新增 API不因规则修正改变成功响应外层结构。
- 前端不读取 `card_category` 预判,不在本地复制运营商能力规则。
- 提交期间禁用重复操作;失败原地展示后端中文业务原因;成功后重新拉取资产详情。
- 详情刷新读取本地状态快照;异步观测尚未收敛时沿用公共同步状态展示,不伪造 Gateway 已确认结果。
### 发布与兼容
- 删除或改造所有以 `card_category=industry` 跳过实名的生产分支和注释。
- 删除手动单卡、设备批量及自动复机中直接判断“必须已实名”的重复分支,统一转调领域规则。
- 发布前核查 `realname_link_type!=none` 但资产 `realname_policy=none` 的冲突数据;修正结果遵循公共数据同步方案,不在运行时静默选边。
- API、Worker 和公共卡状态能力在同一维护窗口切换,不保留新旧复机规则双写或双判定。
## Testing Decisions
- 领域单元测试覆盖 `none/template/gateway` 与实名 0/1、普通/行业卡的完整矩阵,并证明卡类别不影响结果。
- Application 测试使用可控 Gateway Adapter验证不合格时绝不调用 Gateway合格时只调用一次并正确发布审计和同步触发。
- HTTP 集成测试覆盖后台卡复机、设备复机和 OpenAPI 入口穿过真实认证、权限和统一错误处理PostgreSQL、Redis 使用开发配置Gateway 使用测试 Adapter禁止对真实业务卡执行停复机。
- 验证设备内混合运营商能力、混合实名状态时逐卡处理,不发生整设备误放行。
- 回归风险停机、销户、保护期、无有效套餐、流量耗尽、非机卡分离 OpenAPI 卡等既有限制。
- 验证 Gateway 成功后旧 Service 没有直接写状态,公共同步序列生成一次,观测确认后由 `ApplyCardObservation` 更新状态并提前完成剩余任务。
- 验证重复请求、重复事件和重复观测不造成重复 Gateway 调用、重复领域事件或重复审计。
- 人工验收使用明确隔离的联调卡,对三个运营商能力值分别验证;核对 PostgreSQL 状态、Outbox/Audit Event、Integration Log 和同步任务关联链路。
## Out of Scope
- 不修改 `card_category` 的存储和展示。
- 不新增实名能力字段,继续使用运营商 `realname_link_type`
- 不重新设计风险停机、销户、机卡分离、保护期、套餐或流量规则。
- 不新增显式同步按钮或同步 API。
- 不修改周期轮询的卡资格、卡类别过滤、配置、频率、队列或重排策略UR#94 只把轮询观测的状态应用收口到公共用例。
- 不在 UR#73 单独实现公共观测、Outbox、审计或 0/3/5 调度的另一份副本。
- 不通过本需求处理运营商回调、事件同步和全部数据同步入口;回调与事件由 UR#94 交付,周期轮询策略本轮保持现状并留待后续设计。
## Further Notes
- 推荐实施顺序:数据同步公共 DDD → UR#73 接入统一复机用例 → 前后端联调。
- 当前代码仍存在行业卡豁免、强制实名和直接写网络状态等多套实现;它们是迁移输入,不是目标架构。
- 本 Spec 对数据同步公共 DDD 的复用是硬约束,不得以“先快速修一行”替代。

View File

@@ -0,0 +1,96 @@
# PRDUR#86 资产详情前代与后代换货标识
Status: ready-for-agent
---
## Problem Statement
卡和设备详情目前无法说明当前资产是否由换货产生、是否已经换出,也无法从 A→B→C 的连续换货中查看当前资产的前代和后代。运营只能人工搜索换货单,且直接返回关联资产主键会造成越权跳转风险。
## Solution
在统一资产详情响应中增加 `exchange_trace`,基于已完成换货单分别查询当前资产作为新资产时的前代,以及作为旧资产时的后代。关联资产仍在当前权限范围内时返回可跳转 ID无权限时保留不可变换货快照标识但隐藏内部 ID 并禁止跳转。
## User Stories
1. 作为运营人员,我希望看到当前资产是否是换货后的新资产,并查看其前代。
2. 作为运营人员,我希望看到当前资产是否已经换出,并查看其后代。
3. 作为运营人员,我希望链路中间资产同时显示前代和后代。
4. 作为受限账号,我希望仍能理解换货历史,但不能借此跳转或枚举无权限资产主键。
5. 作为维护人员,我希望直接复用换货单,不再维护一张可能与换货状态不一致的关系表。
## Implementation Decisions
### 响应契约
- 复用 `GET /api/admin/assets/resolve/{identifier}`,在 `AssetResolveResponse` 中增加稳定的 `exchange_trace` 对象。
- `exchange_trace` 包含 `previous_asset``next_asset`;不存在对应关系时字段为 null不省略整个对象。
- 单个关联项包含 `asset_type`、可空 `asset_id``identifier``exchange_no``can_view`
- `asset_type` 使用换货模型现有枚举 `iot_card``device`
- `previous_asset` 来自“当前资产是新资产”的已完成换货单,返回该单旧资产快照。
- `next_asset` 来自“当前资产是旧资产”的已完成换货单,返回该单新资产快照。
- 只使用 `status=已完成` 且未软删除的换货单;待填写、待发货、已发货待确认和已取消均不形成资产链。
- A→B→C 时B 同时返回 A 和 C。若异常历史数据在同一方向存在多条完成记录选择完成时间与主键顺序最新的一条并记录异常日志。
- `identifier` 使用换货单保存的不可变快照不回查当前资产可变字段UR#45 上线后的卡快照为 ICCID历史快照保持原样。
### 权限与防泄露
- 当前资产仍必须先通过资产详情现有数据权限;无权查看当前资产时维持现有不存在/禁止访问行为。
- 找到前代或后代关系后,分别按关联资产当前数据权限判断 `can_view`
- 有权查看关联资产时返回真实 `asset_id``can_view=true`,前端可跳到对应卡或设备详情。
- 无权查看关联资产时返回 `asset_id=null``can_view=false`,但按标准评审稿保留换货单快照 `identifier``exchange_no`,前端仅作历史文本展示。
- 不返回关联资产店铺、客户、套餐、钱包、状态等额外信息。
- 不以“查不到受权限过滤的资产”抹掉换货关系;关系查询与关联资产可见性检查分开完成。
- 超级管理员、平台和代理继续使用现有资产权限规则,本需求不建立新的角色矩阵。
### Query 与索引
- 这是跨换货单和资产权限的读取用例,收口到 Asset/Exchange Query直接使用 GORM/DTO 投影,不经过聚合根。
- 复用换货单作为关系权威来源,不新增换货链表或冗余前代/后代字段。
- 分别提供“按新资产查已完成换货”和“按旧资产查已完成换货”的批量查询能力;资产详情一次完成,不逐节点递归整条历史链。
- 新增两个非唯一部分 B-tree 索引:已完成且未软删除范围内的旧资产类型+旧资产 ID以及新资产类型+新资产 ID索引支持选择最新记录。
- 卡和设备走同一 Query不为两种资产复制查询逻辑。
- 关联资产权限检查采用固定次数批量加载;详情增加换货信息后不得产生按列表或绑定卡数量增长的 N+1。
- 查询故障返回统一脱敏内部错误,不得把故障当作“没有换货关系”。
### 前端与文档
- `previous_asset` 存在时显示“换货新资产”标签、前代标识和换货单号。
- `next_asset` 存在时显示“已换出旧资产”标签、后代标识和换货单号。
- 两者同时存在时同时展示,不互相覆盖。
- `can_view=true``asset_id` 非空时允许点击进入关联卡或设备详情;否则只显示文本,不渲染链接或可点击样式。
- 点击目标是关联资产详情,不直接跳换货单;换货单号仅作辅助信息。
- 不根据当前资产状态、generation 或前端本地数据自行推导关系。
- 更新资产详情 OpenAPI、UR#86 中文总结和 README 索引。
### 发布与回滚
- 索引随维护窗口上线,回滚只删除新增索引和响应字段,不修改换货单业务数据。
- 不回填历史换货快照;上线前抽样核验历史新旧资产 ID 和标识快照完整性,并记录无法展示的异常。
- 与 UR#45、UR#98 同窗发布时,先完成数据库索引和写侧规范,再发布 Query 与前端。
## Testing Decisions
- Query 测试覆盖无关系、仅前代、仅后代、A→B→C 中间资产,以及卡/设备两种资产。
- 状态测试证明只有已完成换货进入链路,其他状态和软删除记录均忽略。
- 权限测试覆盖关联资产可见与不可见:不可见时仍有快照文本,但 ID 为 null 且不可跳转。
- 当前资产无权限时维持现有防枚举响应,不能因为换货查询泄露其存在。
- 构造同方向异常多条完成记录,验证确定性选择最新记录并产生可观测日志。
- HTTP 集成测试穿过真实认证、权限、资产解析、Query 和统一响应,使用真实 PostgreSQL/Redis/JWT 配置。
- 验证新增索引的结构、向下/向上迁移和代表性查询计划;查询满足项目性能目标且无 N+1。
- 前端人工验收四类页面:无换货、换货新资产、已换出旧资产、中间资产;另验收无权限不可点击。
## Out of Scope
- 不返回整条递归换货树或新增换货链列表接口。
- 不新建关系表,不修改换货状态机。
- 不回填或改写历史快照。
- 不向无权限用户返回关联资产内部 ID或其他业务详情。
- 不直接跳转换货单,不在前端自行拼接关系。
- 不改变资产详情现有数据权限。
## Further Notes
- 标准评审稿明确无权限时保留标识、隐藏可跳转 ID该口径优先于禅道草稿中未定义算法的“脱敏后标识”。
- UR#45 负责未来快照规范UR#98 保证完成换货后的归属一致;本需求只负责只读关系投影。

View File

@@ -0,0 +1,132 @@
# PRDUR#94 卡状态公共写入、事件触发与运营商实名回调
Status: ready-for-agent
---
## Problem Statement
当前卡实名、流量和网络状态分别由轮询 Handler、手动刷新及部分业务 Service 直接写入,状态变化后的首次实名激活、套餐流量扣减、停复机评估和缓存更新也散落在各入口。若直接增加业务事件和运营商回调,会形成更多状态写入口,同一上游结果可能产生不同本地状态和副作用。
现有轮询已经有 PostgreSQL 配置、Redis 分片 Sorted Set、Asynq Handler、并发控制、手动触发、监控和卡级开关。七月迭代尚未形成新的轮询策略因此 UR#94 不改造轮询调度,只解决公共状态写入、事件埋点和已知运营商实名回调。
## Solution
建立卡状态公共 DDD 边界:所有 Gateway 查询结果和运营商实名回调先转换成类型明确的标准观测,再由唯一的 `ApplyCardObservation` 用例锁定卡、应用实名/流量/网络规则并可靠发布副作用。现有实名、流量和卡状态轮询保持原调度行为,仅把查询结果的持久化和后续联动改为调用该公共用例。
在关键业务成功边界建立立即、事件发生后 3 分钟和 5 分钟的三次观测序列,提高本地状态收敛速度;读接口仍立即返回本地快照,开放接口同场景合并,避免被外部调用变成额外轮询器。移动、电信和联通只按当前已有报文能力建立回调 Translator不为没有已知协议的组合创建占位接口。
## User Stories
1. 作为运营人员,我希望停复机、实名和套餐等业务完成后能很快看到运营商侧状态收敛,而不必等待下一次常规轮询。
2. 作为开放接口调用方,我希望查询仍立即返回本地快照,不因后台补同步增加接口耗时或改变响应契约。
3. 作为系统维护人员,我希望轮询、事件、手动刷新和回调共用一套卡状态规则,避免多个入口分别写库和触发副作用。
4. 作为运营人员,我希望移动、电信实名成功回调能及时更新卡实名状态,重复回调不会重复激活套餐。
5. 作为风险控制人员,我希望解除实名回调本期只留痕,不因运营商误报直接把已实名卡改为未实名。
6. 作为轮询运维人员,我希望本轮改造不改变现有轮询配置、间隔、队列、开关、监控和重排结果。
7. 作为审计人员,我希望每次 Gateway 请求、事件尝试和运营商回调都能通过统一关联标识追踪。
## Implementation Decisions
### 本期边界
- 本需求交付三项能力:公共卡状态写入边界、业务事件 `0/3/5` 观测、运营商实名回调。
- 现有轮询调度完全保持:`tb_polling_config` 的条件匹配和间隔、Redis 分片 Sorted Set、任务类型、并发配置、卡级 `enable_polling`、失败重排、手动触发和监控接口均不改变。
- 不引入活跃/不活跃卡概念,不新增活跃状态字段或调度状态表,不调整轮询 QPS不把多套轮询配置合并成全局配置。
- 轮询哪些卡、哪些类型以及 Handler 查询前的现有资格判断,本需求不重新设计。轮询策略何时改造由后续独立需求决定。
- 只触碰 `PollingRealnameHandler``PollingCarddataHandler``PollingCardStatusHandler` 查询成功后的状态应用与业务联动;套餐轮询、保护期轮询和调度基础设施不迁移。
### 公共卡状态领域与应用用例
- 使用复杂写通道:`Handler/Worker -> Application -> Domain -> Repository/Infrastructure`。公共边界至少包含请求上游观测和应用标准观测两个职责Gateway、运营商报文、GORM、Redis 和 Asynq 类型不得进入领域模型。
- 标准观测使用类型明确的实名观测、流量观测和网络观测,不使用 `map[string]any` 表达领域输入。公共元数据至少包含来源、场景、观测时间、请求/关联标识和上游结果摘要。
- `ApplyCardObservation` 是实名、流量和网络上游观测写入 `tb_iot_card` 的唯一入口。它在事务中锁定卡并按当前值判断是否变化,保存卡状态及 Audit Event/Outbox缓存失效或回填在提交后执行。
- 原三个轮询 Handler 已有的业务语义必须完整迁入公共边界,而不是只迁移字段更新:
- 实名:更新检查时间;仅首次从未实名变为已实名时写 `first_realname_at`,触发卡/设备套餐首次激活及复机评估;重复已实名观测不重复触发。
- 实名逆转:周期查询仍保留当前连续三次确认及 10 分钟计数窗口,达到阈值后才应用未实名及停机评估;运营商解除实名回调不进入该计数。
- 流量:保留运营商重置日、跨月归档、非重置日读数下降保护、正增量累计、使用记录、套餐流量扣减和停复机评估;同一卡流量观测保持串行。
- 网络:保留 Gateway 状态映射、`gateway_extend`、IMEI、运营商停机原因、状态变化后的停复机评估以及独立风险卡停止后续轮询的当前行为。
- 无状态变化的有效观测仍更新对应最后检查/同步时间并记录 Integration Log但不得重复发布首次实名、流量扣减或网络变化事件。
- 未知 Gateway 状态、缺少关键字段或无效读数不得以零值覆盖本地状态;记录失败结果后由原事件后续尝试或现有轮询兜底。
- `ApplyCardObservation` 的领域事件由 Outbox 可靠投递。UR#73 的统一复机用例、UR#53 的设备实名投影和套餐激活等消费者复用这些事件,不允许轮询或回调自行再写一套副作用。
### 事件埋点与三次观测序列
- 事件不是新增公开同步 API而是业务 UseCase/Query 的内部端口。写操作在业务事务成功边界通过 Outbox 产生触发;读操作在返回本地快照前 Best Effort 入队,入队失败只记录日志,不改变原接口响应。
- 每个触发序列固定建立三个独立 Asynq 任务:立即、事件发生后 3 分钟、事件发生后 5 分钟;三次任务的 Asynq 自动重试均为 `0`,单次失败不取消后两次,结束后继续由现有轮询兜底。
- 任务使用 `series_id + attempt` 确定性幂等键。调用项目 `EnqueueTask` 时载荷必须传 struct 或 map禁止预先序列化成 `[]byte`
- 有明确预期状态时,每次执行前先查本地快照;已达到预期则把当前和剩余尝试标记完成,不再访问 Gateway。典型预期包括停机、复机、实名完成和设备切到目标 ICCID。
- 同一 `scene + resource_type + resource_id + sync_type` 存在未结束序列时合并新触发,不创建第二组三任务。不同业务场景互不压制,不建立全局五分钟冷却。
- 单卡、运营商接入和同步类型只在一次实际 Gateway 请求执行期间互斥;流量继续复用现有卡级锁。事件尝试命中执行中互斥时跳过本次,原定后续尝试仍保留。
- Gateway 返回超频时记录本次 `rate_limited`,不为事件任务建立 30/60/120 秒退避,也不改变现有轮询重排策略。
- 以下入口在成功边界创建序列:
- C 端资产详情、后台资产实时状态,以及 OpenAPI 卡/设备的流量、网络、实名查询:按被查询资源和同步类型触发,无明确预期;接口仍返回本地快照。
- C 端或后台获取实名入口:实名观测,预期已实名;`realname_link_type=none` 的卡不创建实名观测。
- 后台、C 端、OpenAPI 和自动任务的停复机:网络观测,预期停机或开机。
- 订单支付、钱包购包和套餐激活:实名、流量、网络观测,无明确预期。
- 设备切卡/切模式:设备信息、源卡/目标卡网络及目标卡流量观测,预期目标 ICCID。
- 设备重启、恢复出厂和 WiFi 设置:设备信息及绑定卡网络观测,无明确预期。
- 卡绑定/解绑、设备或卡分配/回收若没有实际调用上游,只记录业务事件,不创建上游同步序列。
- 现有后台和 C 端手动刷新继续直接执行一次同步并保持当前响应契约,不额外创建 `0/3/5` 序列。
- 轮询观测到状态变化以及回调成功均不得反向创建新的三次序列;实名成功回调应使同一卡未执行的实名序列提前完成。
- OpenAPI 的场景码稳定且按调用方无关的资源维度合并,连续查询同一卡不会不断续建序列;这项合并是防止外部调用变相形成轮询的强制约束。
### 运营商实名回调防腐层
- 只实现现有材料已经给出报文的三个接入能力:
- `POST /api/callback/carriers/ctcc/realname`:解析电信 XML`RESULTMSG=成功``ACCEPTMSG` 包含“已完成实名信息补录”视为实名成功;同一路由收到“已完成实名信息清除”只记录忽略。
- `POST /api/callback/carriers/cmcc/realname`:解析移动 JSON外层 `status=0``message=正确`、首个结果 `regStatus=00000` 且携带 ICCID 时视为实名成功。
- `POST /api/callback/carriers/cucc/realname/remove`:解析联通外层 JSON 字符串字段 `data` 中的 `iccid/dateChanged`,仅记录解除实名并忽略状态变更。
- 不为当前没有报文契约的“移动解除实名、联通实名成功、电信独立解除实名”创建占位路由;获得真实样例后再增加对应 Translator。
- 移动回调 ICCID 为空时不得登录旧管理平台按 MSISDN 补查,不保留材料中的账号密码和第三方抓取逻辑;记录 `invalid_payload` 后返回接入约定成功响应。
- 回调业务结论按当前评审口径直接信任,不验证来源真实性,也不再请求 Gateway 二次确认。每个运营商 Adapter 负责本方报文、成功条件和成功应答,领域层只接收标准实名观测。
- 实名成功调用 `ApplyCardObservation(verified=true)`;解除实名本期只写 Integration Log结果为 `ignored`,不修改卡、不触发停机,也不增加实名逆转计数。
- 回调 ICCID 去除首尾空白后只接受合法 19 或 20 位值19 位只精确查询 `iccid_19`20 位只精确查询 `iccid_20`;禁止截断、补位、模糊查找或跨列降级。
- 回调使用系统级 Repository不套用当前登录账号的店铺权限过滤。找不到卡记录 `not_found`;多条匹配记录 `conflict`,均不得任取一条写入。
- 发布前再次检查未删除卡的 `iccid_19` 重复并将现有普通部分索引升级为部分唯一索引;`iccid_20` 也必须保证精确唯一。开发库只读核验时 19 位重复组为 0但发布不得假设生产数据相同。
- 重复成功回调幂等返回成功,不重复写首次实名时间、不重复激活套餐。找不到卡、重复回调和能够安全识别的无效业务结果均按运营商协议返回成功,避免不可控重推;报文级解析失败仍记录 `invalid_payload`
- 禁止调用旧示例中的第三方推送、删除实名接口、旧平台登录或 `inner_callback`。日志不得保存完整敏感报文、Cookie、Authorization 或明文 ICCID只保留脱敏摘要和必要解析字段。
### 审计、接口与前端
- 每次实际 Gateway 请求、被合并/互斥/限频的事件尝试和每次运营商回调写统一 Integration Log卡业务状态变化同事务写 Audit Event。记录至少携带 `request_id``correlation_id``series_id``attempt`、来源、场景、资源、结果、耗时、是否变化和脱敏上游摘要。
- Integration Log、Audit Event 和 Outbox 是公共基础能力依赖;公共能力尚未可用时不得为 UR#94 新建第二套同步运行表或旧式文本日志。
- 本需求不新增显式同步按钮或同步 API不新增“活跃状态”“下次轮询时间”等字段也不改轮询监控接口。
- 资产详情可增加“查看同步轨迹”入口,跳转统一审计中心的外部集成视角;没有独立卡同步监控页面。
- 现有手动刷新接口、路由、权限和外层响应不变。新增回调 Handler 必须按项目规范注册路由,并同步更新两个 OpenAPI 文档生成器;回调协议样例作为契约测试 fixture 保存,敏感值脱敏。
### 发布与兼容
- 切换顺序为:公共 Audit/Integration Log 与 Outbox 可用 → 公共卡状态 Application/Domain → 三个轮询 Handler 改用公共应用入口 → 事件埋点 → 回调路由。
- 三个轮询 Handler 的公共入口切换必须一次完成,不允许同一观测既由 Handler 直接写库、又由公共用例双写。
- 发布前记录并对比当前启用的轮询配置、队列深度、卡级开关和监控结果;发布后这些调度事实应保持一致。
- 回调 URL 需在测试环境用运营商原始样例验证后再配置到运营商平台;正式启用顺序按运营商逐个灰度,单个 Adapter 可独立关闭而不影响事件和轮询兜底。
## Testing Decisions
- 领域单元测试覆盖实名首次成功/重复成功/逆转确认、流量正增量/跨月/运营商重置/异常下降、网络状态映射/未知状态/运营商停机原因,以及每类观测不变时不重复发布副作用。
- Application 并发测试验证同一卡观测串行、重复 `series_id + attempt` 幂等、Outbox 与状态同事务、事务失败不留下部分卡状态或业务事件。
- 轮询回归测试固定同一组 `tb_polling_config`、Redis 队列和卡级开关,验证改造前后下次入队时间、失败重排、并发控制、风险卡终止和监控统计保持现状;只替换查询成功后的状态应用路径。
- 事件测试验证一次触发恰好生成 0/3/5 三任务且 `MaxRetry(0)`,单次失败不取消后续任务,达到预期后跳过剩余任务,不同场景不被错误合并。
- OpenAPI 压测/集成测试连续查询同一资源,验证接口延迟和响应结构不变、未结束序列被合并、不会按每次请求新增三任务。
- 三个回调 Adapter 使用脱敏后的真实 XML/JSON fixture 做契约测试,覆盖成功、业务失败、空 ICCID、19/20 位精确命中、非法长度、找不到、多匹配、重复成功和解除实名忽略。
- HTTP 集成测试穿过真实 Fiber 路由和统一错误处理PostgreSQL、Redis 使用开发配置Gateway 与运营商请求使用测试 Adapter禁止操作真实业务卡或向运营商平台发送请求。
- 验证移动空 ICCID 不发起旧平台登录20 位 ICCID 不截成 19 位,回调日志及 Access Log 不泄露完整报文或凭证。
- 人工验收核对卡状态、套餐激活/扣减、停复机评估、Outbox、Audit Event、Integration Log、Asynq 序列和原轮询队列,确认轮询策略没有随本需求变化。
## Out of Scope
- 不做活跃/不活跃轮询、不调整轮询间隔、不合并或废弃现有轮询配置、不新增卡同步调度状态表。
- 不重新决定周期轮询的卡资格、卡类别过滤或不同同步类型覆盖范围。
- 不新增显式同步 API、第二个刷新按钮、独立同步监控页或同步执行事实表。
- 不处理运营商解除实名导致的自动回滚;本期一律留痕后忽略。
- 不为没有真实回调协议的运营商/动作组合预建六套空路由;广电继续依赖事件和现有轮询。
- 不验证运营商回调来源真实性不建设签名、IP 白名单或 Gateway 二次确认。
- 不迁移套餐轮询、保护期轮询以及 UR#94 未触碰的旧模块。
## Further Notes
- 当前开发库启用的轮询配置仍是现有全局条件与既有各类型间隔;该事实用于回归基线,不是本需求要修改的配置。
- 当前三个轮询 Handler 均直接写卡并执行后续业务,是本需求必须收口的已证实入口;当前代码尚无运营商回调路由和统一 `ApplyCardObservation`
- 原标准评审稿中“活跃/不活跃轮询”相关内容已被本次用户确认覆盖,不得带入 UR#94 实现。
- UR#94 先提供公共状态写入边界UR#73、UR#53 及后续套餐/停复机用例只能复用该边界,不各自复制状态规则。

View File

@@ -0,0 +1,129 @@
# PRDUR#96 店铺业务员归属、继承与筛选
Status: ready-for-agent
---
## Problem Statement
店铺目前只有上下级代理关系,没有明确的平台业务员归属。运营无法按负责员工筛选店铺,套餐临期和钱包低余额等通知也无法稳定找到对应平台员工。
代理账号可以发展下级代理,因此业务员归属不能只在平台创建店铺时人工填写。下级代理应默认继承直属上级店铺当时的业务员,但代理不得自行指定或更换平台员工;同时,平台后续调整上级店铺业务员不能自动覆盖已有下级店铺,否则会破坏平台对单个下级店铺的独立调整。
## Solution
`tb_shop` 增加可空 `business_owner_account_id`,表示该店铺当前负责业务员。代理创建下级店铺时由服务端复制所选直属上级店铺当时的业务员 ID代理请求不得提供该字段复制完成后父子店铺各自保存后续不做级联同步。
只有超级管理员和平台账号可以显式设置、清空或更换业务员,且只能选择当前启用、未删除的普通平台账号。店铺列表、详情和筛选 Query 批量投影业务员账号名及手机号摘要;该字段只用于平台内部业务归属和通知接收,不进入代理层级、数据权限、佣金或分销计算。
## User Stories
1. 作为平台运营人员,我希望创建或编辑店铺时可以选择一个启用的平台业务员,也可以清空归属。
2. 作为平台运营人员,我希望按业务员筛选我权限范围内的店铺,并在列表和详情看到业务员摘要。
3. 作为代理账号,我希望发展下级代理时,新店铺自动沿用我的店铺当前业务员,无需也不能自行选择。
4. 作为平台运营人员,我希望修改上级店铺业务员后,已有下级店铺不被级联覆盖,便于逐店铺独立管理。
5. 作为代理账号,我希望能查看有权限店铺的业务员归属,但不能通过构造请求篡改它。
6. 作为通知系统,我希望按店铺稳定找到当前业务员;账号停用或删除时保留归属历史,但不向不可用账号发送通知。
7. 作为审计人员,我希望区分平台人工设置、代理建店继承、清空和更换,并能看到变更前后账号。
## Implementation Decisions
### 领域含义与数据模型
- `tb_shop` 新增 `business_owner_account_id BIGINT NULL`,不建立数据库外键或 GORM 关联标签Model 只保存 ID关联账号由 Query 显式批量加载。
- `business_owner_account_id` 表示平台内部当前业务负责人和通知接收关系,不是“发展人”层级事实。它不参与 `parent_id` 店铺层级、数据权限、授权范围、代理分销、佣金、提现或客户归属计算。
- 可被人工选择的业务员必须满足 `tb_account.user_type=2``status=1``deleted_at IS NULL`。超级管理员可以管理该字段,但 `user_type=1` 的超级管理员账号本身不是业务员候选。
- 一个店铺本期最多一个业务员,可为空;一个平台业务员可以负责多个店铺。
- 业务员账号禁用或软删除后不清空店铺字段,不级联更新店铺;列表/详情仍尽可能按原 ID 展示历史账号摘要并标记当前不可用,通知接收人解析时跳过。账号重新启用后,该未变更关联重新成为可用通知关系。
-`business_owner_account_id` 建普通索引以支持筛选,不建立唯一约束。
### 创建时继承
- 创建店铺继续使用 `POST /api/admin/shops`,新增存在性感知的可空字段 `business_owner_account_id`
- 代理账号创建其现有权限允许的新下级店铺时,业务员只能由服务端从最终校验通过的 `parent_id` 店铺复制:
- 上级有业务员 ID原值复制到新店铺包括上级账号此刻已停用/软删除但关联仍保留的情况。
- 上级业务员为空:新店铺也为空。
- 请求 JSON 只要主动出现 `business_owner_account_id`,无论值为原值、其他 ID 或 `null`,都返回禁止操作,不静默忽略。
- 代理建店必须先复用现有店铺层级与管理权限校验,确保 `parent_id` 确实处于调用者允许发展的范围;不能通过选择无权上级间接复制归属或创建店铺。本需求不扩大代理原有发展层级权限。
- 超级管理员或平台账号创建店铺时:
- 显式传正整数 ID校验为当前可用平台业务员后设置。
- 显式传 `null`:创建为空业务员。
- 字段缺失且存在上级店铺:默认复制上级店铺当前业务员。
- 字段缺失且为顶级店铺:默认为空。
- 创建时复制是一次性快照关系。上级店铺以后被设置、清空或更换业务员,既有下级、孙级店铺均不自动更新;只有之后新创建的直属下级读取上级当时的当前值。
- 店铺、初始主账号、角色、主/分佣钱包、业务员归属和创建审计属于同一个完整创建用例,必须在同一数据库事务内成功或失败,避免当前多表创建留下半成品。
### 编辑权限和三态字段
- `PUT /api/admin/shops/{id}` 增加存在性感知的可空 `business_owner_account_id`,语义为:字段缺失保持不变,显式 `null` 清空,正整数校验后替换。
- DTO/解析必须真实区分“字段缺失”和“显式 null”不能用普通 `*uint` 把二者都解释成 nil可使用项目内明确的 Optional/Nullable 类型,但不得把这个差异留给 Handler 猜测。
- 超级管理员和平台账号可以设置、清空、更换;代理账号不得修改。代理更新其他店铺资料且字段缺失时保留原业务员,字段一旦出现则返回统一禁止访问错误。
- 企业账号不具备业务员候选或写权限。所有写操作除账号类型检查外,仍要执行目标店铺的既有资源权限校验;无权或不存在统一返回“无权限操作该资源或资源不存在”。
- 设置 ID 时在事务内重新校验业务员仍为启用平台账号,不能仅相信前端候选列表。禁用、删除、代理或企业账号 ID 均返回参数/业务错误且不更新店铺。
- 平台手工修改一个店铺只影响该店铺,不向上、向下或同级传播,也不修改历史通知和审计快照。
### Query 与 API
- `GET /api/admin/shops` 新增可选精确筛选 `business_owner_account_id`,与店铺名、编号、联系电话、层级、状态等现有条件按 AND 组合;分页、排序和调用者数据范围保持原契约。
- 店铺响应增加:
- `business_owner_account_id:uint|null`
- `business_owner_username:string`
- `business_owner_phone_summary:string`
- `business_owner_available:bool`
- 手机号摘要固定保留前三位和后四位,例如 `138****8000`;空值返回空字符串。普通店铺 Query 不返回业务员完整手机号。
- 列表按当前页业务员 ID 一次批量加载,包括必要的软删除只读投影,禁止每行查询账号。筛选按店铺保存的 ID 执行,因此账号停用/删除后仍可用该 ID 查到历史负责店铺。
- 若前端现有店铺详情没有独立接口,应补齐 `GET /api/admin/shops/{id}` 并返回同一 `ShopResponse`;动态路由必须排在 `/cascade``/fund-summary``/business-owner-candidates` 等静态路由之后,避免吞掉现有路径。
- 新增最小候选 Query`GET /api/admin/shops/business-owner-candidates?keyword=&page=&page_size=`,仅超级管理员和平台账号可调用。固定筛选当前启用、未删除的 `user_type=2` 账号,`keyword` 对用户名或手机号做受控查询,返回 `id/username/phone_summary`,默认 20、最大 100。
- 代理端不需要候选接口;其创建/编辑表单只展示继承或现有业务员摘要,不显示可搜索选择框。
- 新字段向后兼容:旧平台客户端不传字段时,创建子店铺按继承规则、编辑保持不变;旧代理客户端不传字段时正常继承/保留。
### Application、通知与审计
- 本需求使用 Application 事务脚本,不创建空洞 Shop 聚合。Application 负责操作者类型、资源权限、上级解析、继承/三态命令、候选账号校验、事务保存和 Audit EventQuery 负责列表、详情、候选及 DTO 投影。
- 店铺创建事件记录 `assignment_source=inherited/explicit/empty`、上级店铺 ID 和最终业务员 ID。编辑事件只有字段实际变化时记录业务员变更前后数据均使用 ID、账号名摘要和可用状态。
- 业务员归属变更属于平台内部敏感业务操作,接入统一 Audit Writer关键成功审计与店铺事务同事务失败/拒绝按公共审计规则记录。
- 公共通知接收人解析按店铺当前 `business_owner_account_id` 查账号并再次校验可用性;不得因为父店铺后来变更业务员而动态向上追溯,也不得沿代理层级向所有上级业务员发送。
- 账号禁用/删除不触发批量清空或改派。运营通过店铺列表按该业务员筛选后逐个或后续独立批量能力处理,本需求不建设自动转派。
- 新增/修改 DTO、Model、Handler 和迁移时遵循相应项目专项规范;新增 Handler 后同步两个 OpenAPI 文档生成器。
### 前端交互
- 超级管理员和平台账号的店铺创建/编辑表单增加可搜索业务员下拉:支持不选择、清空、加载、无候选和失败重试;候选显示账号名与手机号摘要。
- 创建下级店铺时字段初始显示继承到的上级业务员;平台可覆盖或清空。前端是否展示默认值不作为业务规则,最终继承由后端保证。
- 代理账号只读展示“业务员”,不显示选择或清空控件;创建下级时提示“默认继承上级店铺业务员”。
- 店铺列表增加业务员列和筛选。停用/删除账号显示原摘要及“已停用/不可用”,不误显示为空;清空归属显示“-”。
- 编辑提交期间禁用重复提交;权限拒绝、候选失效和并发变更原地展示后端中文错误,成功后重新请求店铺详情。
### 发布与历史数据
- 迁移新增可空字段和普通索引,不回填存量店铺,不根据祖先或创建人猜测历史业务员。所有存量店铺初始为空,由平台后续维护。
- 部署后新建店铺立即按新规则写入;不运行父子全量级联脚本。回滚应用时新增字段可以保留,已产生的业务员关联和审计不得清空。
- 发布前只读核验 `tb_shop` 层级异常和平台账号状态;发布后抽查平台显式设置、代理继承、父级修改不级联及通知接收人解析。
## Testing Decisions
- Application 测试覆盖超级管理员、平台、代理、企业四类操作者的创建和编辑矩阵;验证只有前两类可显式设置/清空/更换。
- 创建测试覆盖:代理上级有/无业务员、上级业务员可用/停用/软删除、代理主动传相同 ID/其他 ID/null、平台显式 ID/null/字段缺失和顶级店铺。
- 继承回归验证父店铺后续设置、清空和更换均不改变既有子孙店铺;新建直属下级只复制创建时父店铺当前值。
- 候选与写入测试验证只有 `user_type=2 + status=1 + 未删除` 可选择;超级管理员、代理、企业、停用和软删除账号均不能被人工新绑定。
- 权限测试验证代理不能利用无权 `parent_id` 创建店铺,不能通过字段缺失/null 混淆清空归属,企业和越权平台请求使用统一安全错误语义。
- PostgreSQL 集成测试验证创建多表事务原子性、业务员索引筛选、AND 条件、分页总数、软删除账号历史投影和批量查询无 N+1。
- HTTP 集成测试穿过真实 Fiber 认证、Handler、Application/Query、GORM 和统一响应,特别验证 JSON 字段缺失、null、0、正 ID 四种解析语义。
- API 测试覆盖店铺列表、详情和候选;验证手机号固定脱敏,代理不能调用候选,静态候选/资金概况/级联路由不被详情动态路由吞掉。
- Audit 测试验证继承、显式设置、清空、更换和拒绝结果,事务失败不留下店铺/账号/钱包半成品或成功审计。
- 前端验收覆盖平台选择/清空、代理只读、停用业务员展示、业务员筛选、空态、失败态和父级修改不级联。
## Out of Scope
- 不恢复需求 16 的分销码、发展关系、佣金、提现或 H5 代理申请;“业务员”不等于分销发展人。
- 不因业务员归属扩大账号的数据权限或店铺管理范围。
- 不自动级联上级业务员的后续变更,不批量回填存量子店铺。
- 不允许代理账号自行设置、清空或更换业务员。
- 不建设多业务员、团队、部门、区域或自动轮转分配。
- 不在业务员账号停用/删除时自动改派,也不发送外部渠道通知。
## Further Notes
- 当前代码的 `Shop`、创建/更新 DTO 和列表 Query 均没有业务员字段;店铺创建还连续创建店铺、主账号、角色和两个钱包但没有显式完整事务,接入继承时必须把该完整创建用例收口。
- 当前已有平台账号列表能力,但返回字段和权限范围大于下拉框所需;专用候选 Query 用于最小披露和明确写权限。
- 用户确认的核心口径是“创建时继承、之后独立、不自动级联”;实现不得把查询时动态向父级取值当作继承。

View File

@@ -0,0 +1,100 @@
# PRDUR#97 代理主钱包固定 100 元低余额预警
Status: ready-for-agent
---
## Problem Statement
代理主钱包现金余额不足会导致后续购包失败,但当前资金概况只返回总余额和冻结余额,没有统一的现金可用余额或低余额提示,也没有跨越阈值时的可靠通知。钱包余额可由扣款、冻结、解冻、充值和退款回充等多个入口改变;若只在某个 Service 增加通知,会漏掉其他资金路径,并在持续低余额时反复刷屏。
七月迭代还会加入信用额度,但预警表达的是代理实际现金不足,不能因信用可用额度而隐藏现金风险。
## Solution
在统一代理主钱包聚合的每次余额或冻结金额变更中,以 `cash_available = balance - frozen_balance` 比较事务前后快照。只有从 `>10000` 分跨越到 `<=10000` 分时产生一次低余额领域事件;持续低位不再产生,回升到阈值以上后下一次再次跌破会自然形成新事件。
事件与钱包事务同事务进入 Outbox公共 Notification Worker 向当前启用的店铺主账号和可用店铺业务员分别发送一条站内通知。现有资金概况接口增加服务端计算的现金可用余额和预警布尔值,前端不自行拼阈值规则。
## User Stories
1. 作为代理主账号,我希望主钱包现金可用余额首次降至 100 元及以下时收到一次站内提醒。
2. 作为店铺的平台业务员,我希望我负责的店铺发生低余额时收到同一提醒。
3. 作为代理用户,我希望持续低余额的多次扣款不会不断收到重复通知。
4. 作为代理用户,我希望充值恢复到 100 元以上后,再次跌破还能收到新一轮提醒。
5. 作为运营人员,我希望资金概况明确展示现金可用余额和低余额状态,且不把信用额度算进来。
6. 作为财务维护人员,我希望通知系统失败不回滚正确的钱包资金事务,且能从事件和审计追踪本次跨阈值。
## Implementation Decisions
### 精确业务规则
- 固定常量 `AgentWalletLowBalanceThresholdCents int64 = 10000` 放入 `pkg/constants/` 并添加中文注释;本期不提供系统、角色或店铺级阈值配置。
- 只检查 `wallet_type=main` 的代理主钱包。分佣钱包、资产钱包、个人客户钱包均不参与。
- `cash_available = balance - frozen_balance`,单位为分;信用额度、可用授信和佣金余额全部不参与预警计算。
- 触发条件严格为 `before_cash_available > 10000 && after_cash_available <= 10000`。等于 100 元属于预警范围;从 100 元以下继续扣款不重复触发。
- 充值、退款回充或解冻使现金可用余额回到 `>10000` 后,无需单独发送“余额恢复”通知;下一次由 `>10000` 降到 `<=10000` 会产生新的预警轮次。
- 新建时余额为 0 的钱包不补发低余额通知,因为没有发生从安全区跌破阈值的事件;未来首次回升并再次跌破时正常通知。
- 冻结可能降低可用现金并触发,解冻可能重新布防;从冻结余额正式扣除时若 `balance``frozen_balance` 同额减少、现金可用值不变,则不触发。
### 钱包领域与并发
- 本需求复用七月统一 `AgentWallet` 聚合和资金事务边界,不在 `AgentWalletStore` 的通用 SQL 更新方法中偷偷发送通知,也不在订单、充值、退款等 Service 各写一份判断。
- 所有主钱包余额/冻结金额变更必须加载并锁定同一钱包快照,由领域方法计算变更前后 `cash_available`、校验资金不变量、增加 `version` 并记录跨阈值事件。
- 既有扣款、冻结、解冻、充值、退款回充及其他触碰主钱包的完整用例都必须接入统一变更边界;若仍有入口直接执行 `balance +/-``frozen_balance +/-`,不得把 UR#97 标记完成。
- 不需要单独持久化 `low_balance_alert_active`:前后现金可用值已经完整表达跌破、持续低位和回升后再跌破。并发由行锁/乐观锁和钱包 `version` 保证只提交一条实际跨越。
- 低余额事件至少快照 `event_id`、钱包 ID、店铺 ID、变更类型/业务引用、余额和冻结金额前后值、现金可用前后值、钱包提交后版本、`request_id/correlation_id`
- 稳定事件 ID 使用本次已提交的钱包业务变更/版本生成;同一 Outbox 事件重放 ID 不变。不得在每次 Worker 重试时生成新 ID。
- 钱包状态、流水、Audit Event 和 Outbox 在同一数据库事务中写入Outbox 写失败回滚资金事务。后续通知写入失败不回滚资金,由 Relay/Worker 重试。
### 接收人和防重
- 接收人为该店铺当前 `status=1``is_primary=true` 的代理主账号,以及 `business_owner_account_id` 指向且当前仍启用的平台业务员。
- 软删除、禁用或关系已解除的账号在消费时跳过;主账号与业务员 ID 去重后逐人发送。没有可用接收人时记录 `no_recipient`,不无限重试。
- 使用公共通知类型 `wallet.low_balance`,类别为 `system` 或公共注册表确定的资金告警类别,级别为 `warning`。正文快照包含店铺名称和脱敏业务信息,可展示现金可用金额,不包含信用额度。
- 公共通知唯一键 `event_id + recipient_kind + recipient_id` 防止 Outbox/Asynq 重放;每次真正再次跌破产生不同事件 ID因此可再次通知。
- 受控目标使用店铺资金概况类型和店铺 ID不保存任意 URL目标解析后仍按当前账号的店铺数据权限检查。
### API 与前端
- 复用现有 `GET /api/admin/shops/fund-summary`,不另建单店铺的同名新接口。其每个 `ShopFundSummaryItem` 在保留 `main_balance``main_frozen_balance` 等兼容字段的基础上新增:
- `cash_available:int64``main_balance - main_frozen_balance`
- `low_balance_warning:bool`:主钱包存在且 `cash_available <= 10000`
- 店铺没有主钱包的异常数据不可伪装成“0 元正常钱包”;返回 `cash_available=0``low_balance_warning=true` 的同时记录数据完整性告警,或按统一 Query 错误策略失败,实施时必须保持同一接口所有页面一致。
- Query 继续应用现有店铺层级数据权限和服务端分页,批量加载主钱包,禁止逐店铺 N+1信用额度字段即使同时存在也不能影响这两个新字段。
- 资金概况在 `low_balance_warning=true` 时显示红色“现金余额不足 100 元”状态,并展示现金余额与冻结金额;金额均按分转元,前端不得自行用信用额度或阈值重新计算布尔值。
- 通知中心和顶部抽屉复用公共通知 API点击后受控跳转到对应店铺资金概况。目标已无权限时按统一不可用语义处理。
- 不展示阈值输入框、余额恢复消息开关或企业微信通知选项。
### 审计与发布
- 钱包跨阈值时的 Audit Event 记录钱包/店铺、业务来源、余额/冻结/现金可用前后快照、领域事件 ID 和接收人解析结果;信用额度不得出现在预警判断依据字段中。
- 普通资金变化继续以钱包流水为领域事实,低余额 Audit Event/通知只表达告警,不替代流水。
- 发布依赖统一钱包变更边界、公共 Outbox、公共站内通知和 UR#96 业务员关系;依赖尚未完成时可以先完成领域事件,但不得另建临时通知表或直接发消息。
- 存量低余额钱包不批量补发,避免上线瞬间产生大量无业务变更通知;上线后只监听新提交的跨阈值事务。
## Testing Decisions
- 领域单元测试覆盖 `10100→10000``10001→10000``10000→9999``9000→8000``9000→10100→10000`,验证只在严格跨越时发事件。
- 覆盖扣款、冻结、解冻、充值、退款回充和冻结余额正式扣除;验证冻结可触发、解冻可重新布防、总额与冻结额同减导致现金不变时不触发。
- 并发集成测试让两个事务同时尝试把同一钱包从阈值上方扣到下方,验证乐观锁/行锁后只有实际提交的跨越产生一个事件、流水和 Outbox。
- 对所有主钱包写入口做回归测试或静态清单检查,证明没有绕开统一聚合直接更新余额/冻结金额的生产路径。
- Notification Worker 测试覆盖主账号+业务员两接收人、同一账号去重、停用/删除跳过、无接收人、重复事件投递和再次跌破的新事件。
- HTTP 集成测试使用开发 PostgreSQL/Redis穿过真实认证、权限、Query 和统一响应,验证平台、不同层级代理看到的资金行范围不变,新字段在大于、等于、小于 100 元时正确。
- 验证信用额度为 0、正数以及已使用产生负现金余额时`cash_available/low_balance_warning` 与通知规则均只看现金余额和冻结金额。
- 前端验收覆盖红色提示、分转元、加载/空/失败状态、通知已读和受控跳转;禁止对真实钱包执行测试扣款或发送真实通知。
## Out of Scope
- 不支持自定义阈值、分级阈值、按角色/店铺配置或余额恢复通知。
- 不把信用额度、佣金、资产钱包或个人钱包纳入预警。
- 不发送企业微信、短信、邮件或其他外部渠道消息。
- 不补发上线前已经低于阈值的存量钱包通知。
- 不在本需求实现公共站内通知中心或公共 Outbox 的另一份副本。
- 不通过前端定时查询余额来推断和发送预警。
## Further Notes
- 当前仓库已有主钱包 `balance/frozen_balance/version` 和资金概况接口,但加减余额仍分散在多个 Service/Store完整接入统一钱包写边界是本需求可靠性的核心不是附带重构。
- 现有资金概况字段名是 `main_balance/main_frozen_balance`,禅道稿中笼统写的 `balance/frozen_balance` 不能覆盖真实兼容契约。
- “不足 100 元”在本需求产品文案中包含恰好 100 元,后端判断以 `<=10000` 为准。

View File

@@ -0,0 +1,105 @@
# PRDUR#98 换货新资产继承旧资产店铺
Status: ready-for-agent
---
## Problem Statement
当前换货要求新旧资产已经属于同一店铺,平台库存中的新资产不能直接换入;换货完成也没有统一维护新资产店铺归属、多租户字段和资产流转记录。这会迫使运营先做额外分配,或造成资产、钱包、标签、客户绑定和换货单之间的租户信息不一致。
换货完成同时修改多类资产数据,是必须原子完成的复杂写用例,不能继续由旧 Service 中分散的状态更新承担。
## Solution
换货新资产允许来自平台库存,或已经属于旧资产店铺;属于其他店铺时拒绝。换货完成时,新资产自动继承旧资产店铺,旧资产保留原归属。归属字段、多租户派生字段和资产分配记录始终同步;普通业务资料是否迁移继续由现有 `migrate_data` 决定。
完整完成流程迁入 Exchange Domain/Application在同一数据库事务内锁定换货单和新旧资产完成校验、归属继承、客户绑定、可选资料迁移、状态流转、分配记录和 Audit Event。
## User Stories
1. 作为运营人员,我希望直接选择平台库存中的新资产完成换货,无需提前人工分配。
2. 作为运营人员,我希望选择新资产时看到它完成后将归属哪个店铺,而不能手工指定目标店铺。
3. 作为运营人员,我希望其他店铺已拥有的资产不能被换入,防止跨租户侵占。
4. 作为店铺用户,我希望换货完成后能访问新资产,并继续查看保留原归属的旧资产历史。
5. 作为运营人员,我希望“不迁移资料”时仍正确继承归属,但不会意外迁移钱包、套餐或普通业务标签。
6. 作为维护人员,我希望重复完成请求不重复迁移、分配或写资金流水。
7. 作为审计人员,我希望一条换货链能追溯原店铺、目标归属、新旧资产和操作者。
## Implementation Decisions
### 归属规则
- 目标归属始终取旧资产当前 `shop_id`,请求不得携带目标店铺字段。
- 新资产 `shop_id` 为空(平台库存)时允许换入,并在完成时继承目标归属。
- 新资产已属于目标店铺时允许换入,完成时仍执行一致性校验和审计。
- 新资产属于任意其他店铺时拒绝;返回统一禁止访问错误,不透露其他店铺详情。
- 旧资产保留原 `shop_id` 和对应租户归属,用于历史订单、退款、换货链和权限追踪,仅将业务状态推进为“已换货”。
- 旧资产目标归属为空时,新资产完成后仍为平台库存;响应以 `inherited_shop_id=null`、空店铺名称表示。
- 卡只能换卡、设备只能换设备;新资产仍须处于可换入的在库业务状态、未被其他进行中换货占用且没有冲突客户绑定。
### 必做归属同步与可选资料迁移
- 无论 `migrate_data` 为 true 或 false新资产的 `shop_id`、归属状态以及所有直接由资产店铺归属派生的多租户字段必须与目标归属一致。
- 已存在的新资产钱包等租户隔离记录,其 `shop_id_tag` 必须同步;新资产已有资源标签关联的 `shop_id` 等租户字段也必须按目标归属校正。
- 不把“租户字段同步”实现成无条件复制旧资产全部普通业务标签。
- 每次换货完成写一条 `allocation_type=exchange` 的资产分配记录,记录新资产、原所有者、目标所有者、操作人和换货单号;使用换货单号作为可追踪且幂等的分配批次标识。
- 客户绑定沿用当前换货规则,完成时从旧资产迁移到新资产,不受 `migrate_data` 开关影响。
- `migrate_data=false` 时不迁移旧资产钱包余额、套餐使用记录、累计充值/首充数据和普通业务标签。
- `migrate_data=true` 时继续迁移上述既有资料;钱包、套餐、累计字段和标签迁移必须与归属同步使用同一事务。
- 新资产钱包余额等目标数据存在冲突时沿用现有安全校验,不覆盖或吞掉冲突数据。
### 完成用例与幂等
- 物流换货在确认完成时执行继承;直接换货在创建并立即完成的同一事务中执行。
- Application 用例先锁定换货单、旧资产和新资产,再基于锁内最新状态重新校验;不得只信任创建或发货阶段的旧校验结果。
- 推荐顺序为:锁定并校验 → 同步新资产归属/租户字段 → 写分配记录 → 迁移客户绑定 → 按开关迁移资料 → 更新新旧资产状态 → 完成换货单 → 写 Audit Event。
- 任一步失败回滚整笔事务,不允许留下已改归属但未完成换货、已迁移钱包但状态失败等半成品。
- 完成状态使用条件更新。相同换货单重复提交完成时返回幂等成功,不重复执行归属更新、客户迁移、资金/套餐迁移、分配记录或审计副作用。
- 并发完成只能有一个执行者取得状态推进权;其他请求读取已完成结果后按幂等成功处理。
- Exchange Domain 保存资产类型、状态、归属和迁移不变量Application 负责编排事务与端口。旧 Service 仅可作为转发门面,不保留第二套完成逻辑。
- 关键 Audit Event 必须与业务事务同成同败,记录换货单、新旧资产、原归属、继承归属、状态变化、`migrate_data` 和操作人。
### API 与前端
- 复用换货创建、发货和完成接口,不增加目标店铺参数或单独归属接口。
- `ExchangeOrderResponse` 增加 `inherited_shop_id``inherited_shop_name`;创建、发货、列表和详情中语义一致。
- 完成接口可保持现有成功外层结构;前端成功后重新读取详情获得最终继承结果。
- 列表返回店铺名称时必须批量加载,禁止逐条查询。
- 选择旧资产后展示其当前店铺;选择新资产后展示只读文案“换货完成后将归属:{店铺名称}”。平台库存目标使用明确的平台文案。
- 不提供目标店铺选择控件。其他店铺资产被拒绝时展示后端统一错误,并保留表单。
- 创建、发货确认和详情页面覆盖加载中、失败、重复提交和成功刷新状态。
- 更新 OpenAPI、UR#98 中文总结和 README 索引。
### 发布与迁移
- 新的分配类型常量定义在统一常量包并带中文注释;若数据库存在枚举或约束,迁移同步扩展 `exchange`
- 为分配记录建立足以按换货单号和资产追踪的索引或约束,确保同一换货单不会重复记录同一新资产。
- 发布前核查新资产现存钱包租户标签、资源标签租户字段和分配记录结构,列出无法自动校正的异常数据。
- API、Worker 和前端在维护窗口同步切换;不对历史已完成换货批量改归属。
## Testing Decisions
- Domain 测试覆盖新资产平台库存、同店铺、其他店铺以及旧资产平台库存四种归属组合。
- 事务集成测试覆盖卡和设备、物流和直接换货、`migrate_data` true/false。
- `migrate_data=false` 验证归属、多租户字段、分配记录和客户绑定更新,但钱包余额、套餐、累计字段、普通业务标签不迁移。
- `migrate_data=true` 验证全部既有资料与归属在同一事务完成。
- 验证旧资产店铺不变,新资产归属状态与 `shop_id` 一致,其他店铺资产返回 403 且无副作用。
- 在每个关键步骤注入失败,验证资产、绑定、钱包、套餐、标签、分配记录、换货状态和 Audit Event 全部回滚。
- 并发与重复完成测试验证只生成一条分配记录、一次资金/套餐迁移和一组业务审计。
- HTTP 集成测试穿过真实认证、权限、Handler、Application 和 PostgreSQLRedis 使用开发配置,外部 Gateway 不参与本需求。
- 前端人工验收创建、发货、完成、详情的继承提示及其他店铺拒绝。
## Out of Scope
- 不允许前端或调用方选择目标店铺。
- 不覆盖其他店铺资产的归属。
- 不把旧资产回收到平台,也不删除旧资产店铺历史。
- 不因归属继承而无条件迁移钱包、套餐、累计数据或普通业务标签。
- 不回填历史已完成换货的归属数据;异常历史数据另行核对。
- 不改变换货资产类型限制、物流流程或客户绑定业务规则。
## Further Notes
- “归属继承”与“资料迁移”是两个独立维度:前者始终执行,后者受 `migrate_data` 控制。
- UR#45 提供规范的新旧资产快照UR#86 使用完成后的换货单生成前代/后代链路。