diff --git a/docs/7月迭代/7月迭代完整技术方案-评审稿.md b/docs/7月迭代/7月迭代完整技术方案-评审稿.md new file mode 100644 index 0000000..1dbb3bc --- /dev/null +++ b/docs/7月迭代/7月迭代完整技术方案-评审稿.md @@ -0,0 +1,6457 @@ +# 7月迭代完整技术方案(单文件归档稿) + +> 状态:待评审 +> 最后更新:2026-07-14 +> 代码分支:`Iteration/7-11` +> 适用系统:`junhong_cmp_fiber` 及配套后台、代理端、C 端前端 +> 文档用途:完整汇编归档;正式评审优先使用《7月迭代技术方案-标准评审稿》 + +--- + +## 一、范围说明 + +7 月迭代原编号为需求 01~22,其中需求 16“代理分销码与佣金提现”已于 2026-07-14 整体移出本期,后续独立立项。本期实际实施 21 项需求。 + +需求 16 移出后,本期不建设分销码、H5 代理申请、代理申请审批、自动开店、发展人关系和佣金提现材料;通用审批流只接入退款和平台员工线下充值。 + +## 二、已确认关键决策 + +1. 采用触碰式渐进 DDD,不一次性重构旧模块。 +2. 审批人只支持角色或指定账号,不引入部门模型。 +3. 审批详情固化业务快照,并分开展示业务资料、审批意见和审批附件。 +4. 平台员工不建立信用额度;信用额度只属于代理主钱包。 +5. 批量订购支付方式整批统一;Excel 不包含支付方式。 +6. 设备批量分配代理与套餐系列拆成两个独立命令。 +7. Gateway 仅提供手动卡限速,统一按 `cardNo` 和 `kbps`,不做套餐自动限速。 +8. 代理钱包退款只回退原扣款代理主钱包;其他支付方式由财务人工退款,本期不接第三方自动退款。 +9. 排队主套餐按购买时长快照推算预计最后到期时间;临期页面实时查询,定时任务只发送通知。 +10. 模板由前端静态资源提供,后端不提供模板下载接口。 +11. 本次采用停机发布,不保留旧退款和线下充值审批接口兼容窗口。 + +## 三、文档结构 + +本文按以下顺序完整内嵌源文档:总览、DDD、系统配置、站内消息、审批流、前端方案、需求专项方案和原始业务需求。需求 16 的原始内容仅作为移出记录保存,不属于本期实施契约。 + +## 四、评审结论记录 + +| 评审项 | 结论 | 调整项 | 负责人 | +|--------|------|--------|--------| +| 范围与需求16移出 | 待评审 | | | +| 渐进式 DDD | 待评审 | | | +| 通用审批流 | 待评审 | | | +| 资金和批量任务 | 待评审 | | | +| 前端、迁移和发布 | 待评审 | | | + +--- + + + + +--- + +> 汇编来源:`docs/7月迭代/00-总览.md` + +## 7月迭代技术方案总览 + +> 状态:待评审 +> 负责人:待指定 +> 评审人:后端、前端、产品、验收负责人、运维待指定 +> 讨论日期:2026-07-11 +> 最后更新:2026-07-14 +> 分支:Iteration/7-11 +> 系统:junhong_cmp_fiber(已上线,渐进迭代) + +--- + +### 一、评审结论 + +当前方案的技术方向没有跑偏:渐进式 DDD、通用审批流、Outbox、异步任务复用和查询侧独立都符合当前系统约束。 + +此前版本缺少流程图、前端方案、发布方案和若干关键业务决策。本轮已经补齐这些评审材料,并确认以下口径: + +- 平台员工不建立信用额度;信用额度只属于代理主钱包。 +- 批量订购的支付方式按整批统一,Excel 不携带支付方式。 +- 角色级导出字段配置属于本期必做能力。 +- Gateway 限速只面向卡号,设备场景先解析当前卡再调用 `cardNo`;本期仅人工设置/取消,不做套餐自动限速。 +- 需求 16“代理分销码与佣金提现”已移出 7 月迭代,后续独立立项。 +- 审批结论与退款、充值等业务处理结果分别建模和展示。 +- 审批详情必须展示业务快照、业务资料、历史意见和审批附件;业务资料与审批附件分开存储和展示。 +- 非代理钱包退款由财务人工处理;代理钱包退款仅回退原扣款代理主钱包。 +- 本次采用停机发布,不保留旧审批接口兼容窗口。 + +本目录按 技术方案评审规范 整理。当前范围为 21 项实施需求,需求 16 仅保留移出记录。评审建议按“基础设施 → 资金与审批 → 批量任务 → 展示类需求”分组。 + +--- + +### 二、评审材料导航 + +| 材料 | 作用 | +|------|------| +| DDD 规范 | 判断复杂写、简单写和 Query 通道,以及旧模块如何渐进迁移 | +| 前端技术方案 | 跨需求的页面、状态、轮询、权限和停机发布约定 | +| 审批流 | 流程定义、实例、任务、状态机、并发、事件和业务接入 | +| 站内消息 | 审批和临期提醒的站内通知基础设施 | +| 系统配置 | C 端支付方式的受控动态配置 | +| 各需求专项文档 | 业务规则、接口、数据改动和专项前端差异 | + +--- + +### 三、系统上下文 + +```mermaid +flowchart LR + Admin[后台管理端] --> API[Junhong API] + Client[C端 H5/公众号] --> API + API --> PG[(PostgreSQL)] + API --> Redis[(Redis)] + API --> Gateway[Gateway] + API --> Outbox[(Outbox)] + Relay[Outbox Relay] --> Outbox + Relay --> Asynq[Asynq] + Asynq --> Worker[Worker] + Worker --> Notification[站内消息] + Worker --> Business[退款/充值/临期等业务处理器] + Worker -. Phase 2 .-> WeCom[企业微信] +``` + +关键边界: + +- API 负责同步校验、事务提交和返回可追踪的业务状态。 +- Worker 负责通知、导出、批量任务和审批后的业务处理。 +- Gateway 是本迭代唯一已确认可以直接接入的外部业务系统。 +- 企业微信相关能力属于 Phase 2,不得阻塞 Phase 1 的站内审批闭环。 + +--- + +### 四、依赖关系 + +```mermaid +flowchart TD + DDD[渐进式 DDD 规范] + Config[系统配置] + Notice[站内消息] + Approval[通用审批流] + Export[统一导出任务] + + DDD --> Approval + Notice --> Approval + Approval --> R18[需求18 多人审批] + Approval --> R20[需求20 退款审批] + Approval --> R21[需求21 充值审批] + Config --> R09[需求09 支付限制] + Notice --> R22[需求22 临期提醒] + Export --> R14[需求14 导出] + Approval --> R14 +``` + +--- + +### 五、阶段划分 + +| 阶段 | 范围 | 说明 | +|------|------|------| +| Phase 1 | 需求 1-22 中除需求16外的本系统和 Gateway 能力 | 必须形成站内闭环、人工可追踪和可回滚的后台流程 | +| Phase 2 | 需求 18 APR-009、需求 22 EXP-003 | 企业微信审批/推送与状态同步,单独评审外部 API、安全和补偿策略 | + +Phase 1 不建设 BPMN 全规范、拖拽流程设计器、任意回退、子流程和复杂规则引擎。 + +--- + +### 六、需求总览 + +#### 6.1 简单改动 + +| # | 需求 | 文档 | 实现要点 | +|---|------|------|----------| +| 1 | 行业卡复机允许未实名 | 需求01 | 复机规则修正,无新状态 | +| 3 | 店铺列表加联系电话搜索 | 需求03 | Query 增加过滤条件 | +| 4 | 退款中资产禁止换货 | 需求04 | 创建换货前校验活跃退款 | +| 7 | IoT卡/设备加实名筛选 | 需求07 | 列表筛选和索引评估 | +| 11 | 当前套餐到期高亮 | 需求11 | 复用后端日期字段,前端统一颜色规则 | +| 12 | 换货显示修复 | 需求12 | 字段和检索口径对齐 | +| 13 | 列表字段新增 | 需求13 | 审批人改为动态摘要 | + +简单改动不要求单独绘制系统图;涉及条件分支时用短流程图或规则表即可。 + +#### 6.2 标准方案 + +| # | 需求 | 文档 | 关键评审点 | +|---|------|------|------------| +| 2 | H5流程顺序配置化 | 需求02 | 资产视角、配置优先级、C端跳转 | +| 5 | 套餐分配生效条件 | 需求05 | 覆盖值到使用记录的快照链 | +| 6 | 资产最后到期时间 | 需求06 | Query 聚合与排队套餐口径 | +| 8 | 设备批量分配 Excel | 需求08 | 异步任务、部分成功、失败明细 | +| 9 | C端支付限制配置化 | 需求09 | 前端隐藏与后端强校验一致 | +| 10 | 限速规则 | 需求10 | 手动卡限速、`kbps` 单位和 Gateway 审计 | +| 14 | 导出功能 | 需求14 | 复用 ExportTask、动态字段权限 | +| 15 | 下架套餐允许续费 | 需求15 | 续费身份和历史使用判断 | +| 22 | 套餐临期提醒 | 需求22 | 15/7/3 天规则、去重、三端展示 | + +#### 6.3 完整方案 + +| # | 需求 | 文档 | 关键评审点 | +|---|------|------|------------| +| 17 | 信用额度 | 需求17 | 钱包不变量、主体模型、角色权限 | +| 18 | 多人审批 | 需求18 | 动态审批人、串行节点、通知 | +| 19 | 批量订购套餐 | 需求19 | 逐行审计、钱包幂等、部分成功 | +| 20 | 退款审批 | 需求20 | 审批与实际退款的最终一致性 | +| 21 | 充值审批 | 需求21 | 审批与钱包入账分离、停机切换旧接口 | + +#### 6.4 已移出本期 + +| # | 需求 | 状态 | 后续处理 | +|---|------|------|----------| +| 16 | 代理分销码与佣金提现 | 2026-07-14 移出 7 月迭代 | 独立立项,重新评审范围、技术方案和工时 | + +--- + +### 七、跨需求技术决策 + +- 架构迁移按完整用例触碰式进行,禁止一次性重构旧模块。 +- 管理端 API 使用 `/api/admin/*`,C 端 API 使用 `/api/c/v1/*`。 +- 复杂查询进入 Query 模块,不通过聚合根拼装报表或导出。 +- 审批人只支持角色或指定账号,当前系统不引入部门模型。 +- 审批完成事件使用 Outbox + Asynq,通知和业务处理均按至少一次投递设计。 +- 导入、批量购买和导出必须有服务端任务状态,前端轮询只是展示手段。 +- 资金、审批、导出和敏感配置必须记录操作人和审计信息。 +- 信用额度只属于代理主钱包;平台员工是操作主体,不是结算和负债主体。 +- 批量订购支付方式整批统一;线下支付按整批上传凭证,代理钱包按整批选择代理钱包。 +- 角色管理必须提供导出字段配置,导出请求字段与角色授权字段取交集。 +- 限速最终目标始终是卡,Gateway 请求只传 `cardNo`;设备入口必须解析当前绑定卡,本期仅手动设置或取消。 +- 退款和员工线下充值统一通过任务级审批接口处理。 +- 审批状态与实际退款、钱包入账等处理状态分别返回;审批详情返回业务快照、业务资料和审批时间线。 +- 本次停机发布并同时切换 API、Worker 和前端,不建设旧审批接口兼容门面。 + +--- + +### 八、已确认技术决策 + +| 编号 | 已确认结论 | 影响需求 | +|------|------------|----------| +| D-01 | 不实现平台员工信用额度,只保留代理主钱包授信 | 17、19 | +| D-02 | 角色级导出字段配置本期落地,服务端执行字段授权 | 14 | +| D-03 | 批量订购支付方式整批统一,不允许 Excel 行级混合支付 | 19 | +| D-04 | Gateway 限速统一按 `cardNo`;设备套餐取 `tb_device_sim_binding.is_current=true` 对应卡 | 10 | +| D-05 | 审批结论和实际业务处理状态分离 | 20、21 | +| D-06 | 采用停机发布,旧退款审批和线下充值直接入账/驳回接口同步下线 | 20、21 | +| D-07 | 需求16移出本期,不创建相关表、接口、流程和前端页面 | 16 | +| D-08 | 第三方退款本期由财务人工完成;代理钱包仅回退原扣款代理主钱包,客户资产钱包不走自动回退 | 20 | +| D-09 | 设备批量分配代理与套餐系列拆为两个命令和两个前端入口 | 8 | +| D-10 | 当前套餐与排队主套餐按购买时长快照推算预计最后到期时间;临期页面实时查询,定时任务只做通知 | 06、22 | + +前端源码不在当前仓库。接口评审后仍需在前端仓库完成路由、权限指令、上传组件、任务轮询组件和动态表格能力的实现映射,但这不再是业务方案决策项。 + +--- + +### 九、发布与回滚总策略 + +```mermaid +flowchart LR + S[进入维护模式并停止写入] --> M[执行数据库迁移] + M --> D[同时发布 API、Worker 和前端] + D --> C[初始化流程定义和业务绑定] + C --> L[回填存量待审批单] + L --> V[人工验证核心链路] + V --> O[开放系统访问] +``` + +发布原则: + +1. 发布前进入维护模式,停止创建订单、退款、充值和审批操作。 +2. 维护窗口内执行数据库迁移,并同时发布 API、Worker/Relay 和前端静态资源。 +3. 初始化并发布 `refund_approval`、`recharge_approval`,完成业务绑定后再验证。 +4. 使用一次性 Application 命令为存量待审批退款和员工线下充值创建流程实例;历史终态记录保持只读,不伪造审批时间线。 +5. 旧退款业务单审批接口和线下充值 `offline-pay/reject` 在新版本中不再注册,不保留兼容门面。 +6. 人工验证发起审批、存量审批、任务处理、业务回调、通知和查询后再开放访问。 +7. 开放前验证失败可回滚应用版本和可逆迁移;已经形成的审批历史、资金流水、Outbox 和审计日志不得通过清表回滚。 +8. Worker 暂停时保留 Outbox 事件,恢复后继续消费。 + +--- + +### 十、可观测性与人工验收 + +关键日志和查询维度: + +- `request_id`、`event_id`、`task_id`、`process_instance_id`、`biz_type`、`biz_id`。 +- Outbox 待投递数量、失败次数和最早积压时间。 +- 审批业务处理失败、批量任务失败和导出任务失败。 +- 钱包扣款前后余额、版本冲突和业务幂等键。 + +人工验收至少覆盖: + +- 正常路径、无权限、重复点击、并发审批、退回重提。 +- Worker 暂停后恢复,事件和任务可继续处理。 +- 维护模式下不能产生新写入;开放前确认 API、Worker 和前端版本一致。 +- 所有仍需审批的存量退款和员工线下充值均已生成流程实例,历史终态记录只读可查。 +- 旧退款审批和线下充值 `offline-pay/reject` 路由不可访问。 +- PostgreSQL 中业务状态、审批状态、任务状态、操作日志和资金流水一致。 + +--- + +### 十一、执行顺序建议 + +```text +评审批次 A:DDD 边界、系统配置、站内消息、审批流 +评审批次 B:退款、充值、信用额度、批量订购、导出权限 +评审批次 C:H5 流程、限速、临期提醒、批量分配 +评审批次 D:简单字段、筛选和显示修复 + +实施顺序: +1. 增量迁移、TxManager/Outbox、系统配置和站内消息 +2. 审批定义、实例、任务和前端待办 +3. 退款和充值接入通用审批 +4. 批量任务、代理信用额度和导出权限 +5. 其他中小需求 +``` + +每个评审批次独立形成结论,未通过的复杂需求不阻塞已经闭环的简单需求实施。 + + +--- + +> 汇编来源:`docs/7月迭代/DDD规范.md` + +## 项目 DDD 设计规范 + +> 状态:待评审 +> 务实型 DDD——不是学院派,不强制 Event Sourcing,不追求完美,追求可维护、可扩展、AI 辅助友好。 +> 基准文档:`docs/改造方向.md`(绞杀者模式) + +--- + +### 一、为什么用 DDD / 什么不是 DDD + +#### 现状问题 + +- `order/service.go` 3031 行:订单创建、支付、佣金计算、代购、库存扣减全混一起 +- Model 只是 GORM 映射,没有业务行为(贫血模型) +- 新增业务联动必须修改原有 Service,牵一发动全身 + +#### 目标 + +不是重写,是**渐进替换**: + +1. **复杂写功能**:进入 Application + Domain,不继续堆入旧 Service +2. **简单写功能**:使用 Application 事务脚本,不强行创建聚合 +3. **读取功能**:进入 Query 读取模型,不通过聚合根 +4. **旧功能迁移**:只在需求触碰时迁移一个完整用例,不做模块级重写 +5. **AI 辅助**:结构清晰,AI 生成代码时自动落入正确位置 + +--- + +### 二、什么时候进入 DDD + +DDD 是解决复杂业务边界的手段,不是所有功能的默认目录模板。开始设计前,先判断该需求属于“局部数据操作”还是“独立业务能力”。 + +#### 2.1 优先使用 DDD 的场景 + +当前需求正在修改的用例满足以下任一条件时,应优先建立或扩展领域模块: + +1. 有明确生命周期、状态机、状态转换限制或并发不变量。 +2. 一个操作包含多个业务规则、跨模块协作、事务边界或可靠事件。 +3. 同一业务能力会被多个业务场景复用,需要稳定的领域接口。 +4. 需要策略扩展、规则配置、版本快照、审计追溯或幂等处理。 +5. 继续向旧 Service 增加分支,会导致职责继续膨胀或修改相互影响。 + +审批流、钱包额度、订单支付、佣金结算等属于典型 DDD 场景。 + +#### 2.2 通常不迁移的场景 + +以下需求通常保持原有 `Handler → Service → Store → Model`: + +1. 单表 CRUD、字段增删、简单列表筛选和格式转换。 +2. 局部缺陷修复,且不改变业务边界或核心规则。 +3. 没有独立领域语言、生命周期或跨模块不变量。 +4. 引入聚合、仓储接口和领域事件后只有样板代码,没有业务收益。 + +#### 2.3 触碰式迁移 + +- 默认不迁移当前需求未触碰的旧代码,禁止借功能修改之名扩大为模块级重构。 +- 迁移单位是**完整用例**,不是整个模块、文件或数据表。 +- 简单字段、筛选、格式转换和局部修复默认沿用旧结构。 +- 触碰复杂写逻辑时,只迁移完成当前需求所需的最小完整业务边界。 +- 一旦迁移某个用例,其状态规则和业务不变量必须完整收口,禁止一半留在旧 Service、一半进入 Domain。 +- 旧 Service 可以暂时作为**内部代码迁移门面**调用新 UseCase;调用方迁完后再删除。该规则不代表必须保留旧 HTTP 接口,七月迭代审批相关接口按停机方案直接切换。 +- 新旧模块通过 Application 接口、领域事件或防腐层交互,禁止绕过边界直接修改聚合数据。 + +#### 2.4 查询侧规则 + +复杂查询不通过聚合根,统一采用轻量 CQRS 读取模型: + +```text +Handler → Query → GORM/DTO +``` + +- 列表、详情、联表、统计、报表和导出属于 Query。 +- Query 可以直接使用 GORM、CTE、子查询和批量查询,并负责读取权限与分页。 +- Query 返回专用 DTO/Projection,禁止返回后用于业务写入。 +- 只有查询结果参与写操作判定时,Domain 必须重新校验,不能信任读取快照。 +- 当前需求未触碰的旧查询不迁移;复杂度明显增加时,只迁移该查询到 `internal/query/`。 +- Aggregate Repository 只负责加载和保存聚合,不承载多表列表、报表或导出查询。 + +--- + +### 三、目录结构 + +``` +internal/ +├── domain/ ← 领域层(不依赖 Fiber/Redis/GORM) +│ ├── wallet/ +│ │ ├── wallet.go ← 聚合根(含业务方法) +│ │ ├── events.go ← 领域事件定义 +│ │ ├── repository.go ← 仓储接口(interface) +│ │ └── vo.go ← 值对象(Money, CreditLimit) +│ ├── approval/ ← 新:审批流领域 +│ ├── notification/ ← 新:通知领域 +│ ├── distribution/ ← 后续预留:需求16独立立项后再创建 +│ └── package/ ← 套餐领域(迁移中) +│ +├── application/ ← 应用层(用例,只做编排,不含业务判断) +│ ├── wallet/ +│ │ ├── debit_wallet.go ← 一个文件 = 一个用例 +│ │ ├── credit_wallet.go +│ │ └── grant_credit.go ← 新:授信 +│ ├── approval/ +│ │ ├── submit_approval.go +│ │ ├── advance_step.go +│ │ └── reject_approval.go +│ └── notification/ +│ └── send_notification.go +│ +├── query/ ← 读取侧(可直接使用 GORM,返回 DTO/Projection) +│ ├── order/ +│ ├── refund/ +│ ├── approval/ +│ ├── dashboard/ +│ └── report/ +│ +├── infrastructure/ ← 基础设施层(实现 domain 里的 interface) +│ ├── persistence/ ← GORM Repository,可委托现有 Store +│ ├── messaging/ ← Outbox Relay、Asynq 发布与消费 +│ └── adapter/ ← 外部系统和旧模块防腐层 +│ +├── handler/ ← 原有 handler(保持不动) +├── service/ ← 原有 service(保持不动,新模块不在这里) +└── store/ ← 原有 store(保持不动) +``` + +**过渡期规则**: +- 旧模块:`internal/service/xxx` + `internal/store/postgres/xxx` +- 复杂写用例:`internal/domain/xxx` + `internal/application/xxx` +- 简单写用例:`internal/application/xxx` +- 读取用例:`internal/query/xxx` +- Handler 按用例调用 Application、Query 或尚未迁移的旧 Service +- 全新领域优先使用纯领域对象;迁移旧模块时可临时复用现有 GORM Model,但不得让 Fiber、Redis 或 GORM 查询进入业务方法 + +--- + +### 四、领域模块划分 + +| 领域 | 聚合根 | 核心业务规则 | 状态 | +|------|--------|------------|------| +| **Asset(资产)** | `IotCard`, `Device` | 停复机条件、实名策略 | 迁移中 | +| **Order(订单)** | `Order` | 支付、退款、佣金触发 | 迁移中 | +| **Package(套餐)** | `Package`, `PackageUsage` | 生效条件、临期计算 | 迁移中 | +| **Wallet(钱包)** | `AgentWallet` | 余额扣减、信用额度、负余额 | **新功能用 DDD** | +| **Shop(代理)** | `Shop` | 层级关系、信用策略 | 部分迁移 | +| **Approval(审批)** | `ProcessDefinition`, `ProcessInstance` | 流程版本、审批人解析、状态推进、驳回 | **全新 DDD** | +| **Notification(通知)** | `Notification` | 分发、已读状态 | **全新 DDD** | +| **Distribution(分销)** | `DistributionRelation` | 发展人体系、二维码注册 | 后续独立立项,本期不创建 | + +--- + +### 五、聚合根设计原则(富模型) + +#### 5.1 核心原则 + +业务规则住在聚合根里,外部只通过方法操作,不能直接改字段。 + +```go +// ❌ 贫血模型(禁止)——所有判断在 Service 里 +func (s *WalletService) Debit(ctx context.Context, walletID uint, amount int64) error { + wallet, _ := s.store.Get(ctx, walletID) + if wallet.Balance-wallet.FrozenBalance < amount { + return errors.New(errors.CodeInsufficientBalance) + } + wallet.Balance -= amount + s.store.Update(ctx, wallet) +} + +// ✅ 富模型(推荐)——判断逻辑在聚合根里 +func (w *AgentWallet) Debit(amount Money) error { + available := w.Balance - w.FrozenBalance + w.CreditLimit // 含信用额度 + if available < amount.Cents() { + return ErrInsufficientBalance + } + w.Balance -= amount.Cents() + w.recordEvent(WalletDebitedEvent{Amount: amount, BalanceAfter: w.Balance}) + return nil +} + +// Application 层只做编排 +func (uc *DebitWalletUseCase) Execute(ctx context.Context, cmd DebitCommand) error { + wallet, err := uc.repo.GetByID(ctx, cmd.WalletID) + if err != nil { return err } + if err := wallet.Debit(cmd.Amount); err != nil { return err } + // 完整事务和 Outbox 写入方式见“应用服务(用例)”章节 + return uc.repo.Save(ctx, wallet) +} +``` + +#### 5.2 聚合根必须包含 + +```go +type AggregateRoot struct { + // 业务字段... + + // 未发布领域事件,由 Application 在事务内写入 Outbox + domainEvents []DomainEvent +} + +// PopEvents 获取并清空领域事件 +func (a *AggregateRoot) PopEvents() []DomainEvent { + events := a.domainEvents + a.domainEvents = nil + return events +} + +func (a *AggregateRoot) recordEvent(e DomainEvent) { + a.domainEvents = append(a.domainEvents, e) +} +``` + +#### 5.3 与现有 GORM 的共存 + +迁移旧模块时,聚合根可以临时包装现有 GORM Model,减少一次性重构成本: + +```go +// internal/domain/wallet/wallet.go +// AgentWallet 钱包聚合根 +// 直接复用并扩展现有 model.AgentWallet +type AgentWallet struct { + model.AgentWallet // 迁移期复用持久化字段 + domainEvents []DomainEvent +} + +// Debit 从钱包扣款(含信用额度) +func (w *AgentWallet) Debit(amount Money) error { + available := w.Balance - w.FrozenBalance + w.CreditLimit + if available < amount.Cents() { + return ErrInsufficientBalance + } + w.Balance -= amount.Cents() + w.recordEvent(WalletDebitedEvent{...}) + return nil +} +``` + +这只是迁移期折中,不是新领域的默认形式。审批流等全新领域应优先使用纯领域对象,由 Infrastructure 负责领域对象与 GORM Model 的转换。 + +--- + +### 六、值对象设计 + +值对象:没有 ID,靠值来判断相等,不可变。 + +```go +// internal/domain/wallet/vo.go + +// Money 金额值对象(分为单位,防止浮点数精度问题) +type Money struct { + cents int64 +} + +func NewMoney(cents int64) (Money, error) { + if cents < 0 { + return Money{}, ErrNegativeMoney + } + return Money{cents: cents}, nil +} + +func (m Money) Cents() int64 { return m.cents } +func (m Money) Yuan() float64 { return float64(m.cents) / 100 } +func (m Money) Add(o Money) Money { return Money{cents: m.cents + o.cents} } + +// CreditLimit 信用额度值对象 +type CreditLimit struct { + maxCents int64 // 最大授信额度(分) + allowNegative bool // 是否允许负余额 +} +``` + +--- + +### 七、领域事件与可靠投递 + +领域对象只记录已经发生的业务事实,不直接调用 Asynq。Application 在保存聚合的同一数据库事务中写入 Outbox,再由 Relay 异步投递到 Asynq。 + +```go +// internal/domain/wallet/events.go + +// DomainEvent 领域事件接口 +type DomainEvent interface { + EventType() string + OccurredAt() time.Time +} + +// WalletDebitedEvent 钱包扣款事件 +type WalletDebitedEvent struct { + WalletID uint + ShopID uint + Amount Money + BalanceBefore int64 + BalanceAfter int64 + RefType string + RefID uint + occurredAt time.Time +} + +func (e WalletDebitedEvent) EventType() string { return "wallet.debited" } +func (e WalletDebitedEvent) OccurredAt() time.Time { return e.occurredAt } +``` + +**事务边界**: + +```text +数据库事务 + ├── 保存聚合 + ├── 保存操作日志 + └── 保存 Outbox 事件 +提交事务 + ↓ +Outbox Relay → Asynq → 事件处理器 +``` + +**Application UseCase 保存事件**: + +```go +func (uc *DebitWalletUseCase) Execute(ctx context.Context, cmd DebitCommand) error { + return uc.txManager.RunInTx(ctx, func(txCtx context.Context) error { + wallet, err := uc.walletRepo.GetByID(txCtx, cmd.WalletID) + if err != nil { + return err + } + if err := wallet.Debit(cmd.Amount); err != nil { + return err + } + if err := uc.walletRepo.Save(txCtx, wallet); err != nil { + return err + } + return uc.outboxRepo.Append(txCtx, wallet.PopEvents()) + }) +} +``` + +- Outbox 写入失败:事务回滚,避免“业务成功但关键事件永久丢失”。 +- Asynq 暂时不可用:不回滚已提交业务,Relay 后续重试。 +- 消费者必须幂等;涉及余额、退款、充值时使用业务键或状态条件更新。 + +--- + +### 八、仓储接口 + +```go +// internal/domain/wallet/repository.go + +// WalletRepository 钱包仓储接口 +type WalletRepository interface { + GetByShopID(ctx context.Context, shopID uint, walletType string) (*AgentWallet, error) + GetByID(ctx context.Context, id uint) (*AgentWallet, error) + Save(ctx context.Context, wallet *AgentWallet) error +} +``` + +实现在 `internal/infrastructure/persistence/wallet_repo.go`,可以复用现有 Store 逻辑。事务由 Application 注入的 `TxManager` 管理,Repository 从事务上下文获取 GORM `tx`,领域接口不得暴露 `*gorm.DB`。 + +--- + +### 九、应用服务(用例) + +一个文件 = 一个用例。 + +- 复杂写用例:Application 只做事务和跨聚合编排,业务不变量位于 Domain。 +- 简单写用例:Application 可以使用事务脚本完成基础校验和单表写入,不要求创建聚合。 +- Application 不负责列表、报表和复杂 DTO 拼装,这些职责属于 Query。 + +```go +// internal/application/wallet/debit_wallet.go + +// DebitCommand 扣款命令 +type DebitCommand struct { + WalletID uint + Amount domainWallet.Money + RefType string + RefID uint + OperatorID uint +} + +// DebitWalletUseCase 扣款用例 +type DebitWalletUseCase struct { + walletRepo domainWallet.WalletRepository + outboxRepo OutboxRepository + txManager TxManager +} + +// Execute 执行扣款 +func (uc *DebitWalletUseCase) Execute(ctx context.Context, cmd DebitCommand) error { + return uc.txManager.RunInTx(ctx, func(txCtx context.Context) error { + wallet, err := uc.walletRepo.GetByID(txCtx, cmd.WalletID) + if err != nil { + return err + } + if err := wallet.Debit(cmd.Amount); err != nil { + return err + } + if err := uc.walletRepo.Save(txCtx, wallet); err != nil { + return err + } + return uc.outboxRepo.Append(txCtx, wallet.PopEvents()) + }) +} +``` + +--- + +### 十、Handler 层调用方式 + +- 复杂写、简单写:Handler → Application UseCase。 +- 读取:Handler → Query。 +- 尚未迁移的旧用例:Handler → Service。 +- Handler 不直接访问 GORM,也不承载业务规则或复杂 DTO 拼装。 + +```go +// internal/handler/admin/wallet_handler.go +func (h *WalletHandler) GrantCredit(c *fiber.Ctx) error { + var req dto.GrantCreditRequest + // ... 参数解析 + + cmd := walletApp.GrantCreditCommand{ + ShopID: req.ShopID, + MaxCredit: domainWallet.NewCreditLimit(req.MaxCreditCents), + OperatorID: middleware.GetUserID(c), + } + if err := h.grantCreditUseCase.Execute(c.UserContext(), cmd); err != nil { + return err + } + return response.OK(c, nil) +} +``` + +--- + +### 十一、禁止事项 + +| 禁止 | 原因 | +|------|------| +| 在 Application 层实现复杂业务不变量 | 状态机、金额、库存等规则必须在领域对象里 | +| 跨聚合直接访问另一聚合的字段 | 只能通过 ID 引用,运行时通过 Repository 加载 | +| 在领域层 import Fiber/GORM/Redis | 领域层不依赖基础设施 | +| 在一个用例文件里实现多个业务流程 | 一文件一用例 | +| Repository 保存后直接发布关键事件 | 数据已提交但消息可能丢失,必须事务写 Outbox | +| Outbox 未落库仍提交业务事务 | 会造成业务成功但关键回调永久缺失 | +| 在聚合根里调用 Repository | 聚合根不依赖仓储 | +| Query 执行写操作 | Query 只负责读取模型,写入必须进入 Application | +| 使用 Query 快照替代写侧校验 | 查询结果可能已过期,Domain 必须重新验证不变量 | +| 在 Aggregate Repository 中堆叠报表联查 | 聚合仓储负责加载和保存聚合,复杂读取属于 Query | +| 只创建 domain/application 目录但业务规则仍在旧 Service | 这是目录搬迁,不是 DDD 迁移 | + +--- + +### 十二、本次迭代 DDD 落地计划 + +| 新模块 | 领域路径 | 用例路径 | +|--------|---------|---------| +| 信用额度(需求17) | `internal/domain/wallet/` | `internal/application/wallet/` | +| 审批流(需求18/20/21) | `internal/domain/approval/` | `internal/application/approval/`,使用版本快照和 Outbox | +| 站内消息(需求18/22) | `internal/domain/notification/` | `internal/application/notification/` | +| 批量订购(需求19) | 复用 Order 领域 | `internal/application/order/bulk_purchase.go` | + +需求 16 已于 2026-07-14 移出 7 月迭代,本期不创建 `distribution` 领域目录、聚合和应用用例;后续独立立项时再按本规范判断领域边界。 + + +--- + +> 汇编来源:`docs/7月迭代/基础设施/系统配置.md` + +## 基础设施:系统配置(tb_system_config) + +> 状态:待评审 +> 被依赖:需求 09(C端支付限制) + +--- + +### 一、设计目标 + +将散落在代码里的"写死配置"提取到数据库,平台管理员可通过后台页面修改,无需重新部署。 + +```mermaid +sequenceDiagram + actor Admin as 平台超管 + participant Web as 后台配置页 + participant API as SystemConfig Application + participant DB as PostgreSQL + participant Redis as Redis + participant Biz as 业务读取方 + + Admin->>Web: 修改受控配置表单 + Web->>API: PUT /api/admin/system/config/{config_key} + API->>API: 按配置 key 注册规则校验类型和值域 + API->>DB: 更新值并写审计日志 + API->>Redis: 删除对应缓存 + API-->>Web: 返回最新配置和更新时间 + Biz->>Redis: 下次读取缓存未命中 + Biz->>DB: 读取最新配置并回填缓存 +``` + +--- + +### 二、数据库 + +```sql +-- 迁移文件:YYYYMMDD_create_tb_system_config.sql +CREATE TABLE tb_system_config ( + id BIGSERIAL PRIMARY KEY, + config_key VARCHAR(100) NOT NULL, -- 唯一键,格式:module.group.name + config_value TEXT NOT NULL DEFAULT '', -- 值(string/number/json字符串) + value_type VARCHAR(20) NOT NULL DEFAULT 'string', -- string | int | bool | json + module VARCHAR(50) NOT NULL DEFAULT 'general', -- 所属模块(便于按模块查询) + description TEXT, -- 中文说明(前端展示用) + is_readonly BOOLEAN NOT NULL DEFAULT FALSE, -- 是否只读(代码内部,不允许后台改) + creator BIGINT NOT NULL DEFAULT 0, + updater BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT uq_system_config_key UNIQUE (config_key) +); + +COMMENT ON TABLE tb_system_config IS '系统全局配置表'; + +-- 初始化数据 +INSERT INTO tb_system_config (config_key, config_value, value_type, module, description) VALUES +-- C端支付限制(需求9) +('c2b.payment.card_allowed_methods', '["alipay","wallet"]', 'json', 'c2b.payment', '卡资产C端允许的支付方式(alipay/wechat/wallet)'), +('c2b.payment.device_allowed_methods', '["wechat","wallet"]', 'json', 'c2b.payment', '设备C端允许的支付方式'); +``` + +需求02不写入全局默认实名策略。H5 始终读取卡或设备自身的 `realname_policy`;新建资产使用模型默认 `after_order`,避免全局 Key 与资产字段产生两套优先级。 + +--- + +### 三、Model + +```go +// internal/model/system_config.go + +// SystemConfig 系统全局配置模型 +type SystemConfig struct { + ID uint `gorm:"column:id;primaryKey" json:"id"` + ConfigKey string `gorm:"column:config_key;uniqueIndex;not null" json:"config_key"` + ConfigValue string `gorm:"column:config_value;type:text;not null;default:''" json:"config_value"` + ValueType string `gorm:"column:value_type;type:varchar(20);not null;default:'string'" json:"value_type"` + Module string `gorm:"column:module;type:varchar(50);not null;default:'general';index" json:"module"` + Description string `gorm:"column:description;type:text" json:"description"` + IsReadonly bool `gorm:"column:is_readonly;not null;default:false" json:"is_readonly"` + Creator uint `gorm:"column:creator;not null;default:0" json:"creator"` + Updater uint `gorm:"column:updater;not null;default:0" json:"updater"` + CreatedAt time.Time `gorm:"column:created_at" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"` +} + +func (SystemConfig) TableName() string { return "tb_system_config" } +``` + +--- + +### 四、Store + +```go +// internal/store/postgres/system_config_store.go + +type SystemConfigStore struct { + db *gorm.DB +} + +func (s *SystemConfigStore) GetByKey(ctx context.Context, key string) (*model.SystemConfig, error) +func (s *SystemConfigStore) GetByModule(ctx context.Context, module string) ([]model.SystemConfig, error) +func (s *SystemConfigStore) UpdateValue(ctx context.Context, key string, value string, updaterID uint) error +func (s *SystemConfigStore) BatchGet(ctx context.Context, keys []string) (map[string]*model.SystemConfig, error) +``` + +--- + +### 五、配置读取辅助包 + +业务代码不直接操作 Store,通过辅助函数读取,带 Redis 缓存(5分钟TTL): + +```go +// pkg/sysconfig/config.go + +// GetString 读取字符串配置,返回默认值 +func GetString(ctx context.Context, key string, defaultVal string) string + +// GetStringSlice 读取 JSON 数组配置 +func GetStringSlice(ctx context.Context, key string) ([]string, error) + +// GetBool 读取布尔配置 +func GetBool(ctx context.Context, key string, defaultVal bool) bool + +// InvalidateCache 更新配置后清缓存(在 UpdateValue 后调用) +func InvalidateCache(ctx context.Context, key string) +``` + +Redis Key:`sys:config:{config_key}`,TTL 5分钟。 + +业务代码用法: +```go +// 读取卡的允许支付方式 +allowedMethods, _ := sysconfig.GetStringSlice(ctx, "c2b.payment.card_allowed_methods") +// 返回 ["alipay","wallet"] +``` + +--- + +### 六、API 设计 + +#### 6.1 获取配置列表 + +``` +GET /api/admin/system/config?module=c2b.payment +``` + +响应: +```json +{ + "code": 0, + "data": { + "list": [ + { + "config_key": "c2b.payment.card_allowed_methods", + "config_value": "[\"alipay\",\"wallet\"]", + "value_type": "json", + "description": "卡资产C端允许的支付方式", + "is_readonly": false, + "updated_at": "2026-07-11T10:00:00Z" + } + ] + } +} +``` + +#### 6.2 更新配置 + +``` +PUT /api/admin/system/config/{config_key} +``` + +请求体: +```json +{ + "config_value": "[\"alipay\",\"wallet\",\"wechat\"]" +} +``` + +权限:仅平台超管可操作。 + +#### DTO + +```go +// internal/model/dto/system_config_dto.go + +// SystemConfigListRequest 配置列表查询请求 +type SystemConfigListRequest struct { + Module string `query:"module" description:"按模块过滤(可选)"` +} + +// SystemConfigItem 配置项响应 +type SystemConfigItem struct { + ConfigKey string `json:"config_key"` + ConfigValue string `json:"config_value"` + ValueType string `json:"value_type" description:"值类型 (string/int/bool/json)"` + Module string `json:"module"` + Description string `json:"description"` + IsReadonly bool `json:"is_readonly"` + UpdatedAt time.Time `json:"updated_at"` +} + +// UpdateSystemConfigRequest 更新配置请求 +type UpdateSystemConfigRequest struct { + ConfigValue string `json:"config_value" validate:"required" description:"新配置值"` +} +``` + +更新接口不能只校验 `value_type`。Application 需要按 `config_key` 注册允许值,例如支付方式只能来自 `alipay/wechat/wallet`,实名策略只能来自 `none/before_order/after_order`。未知 key 默认只读,禁止通过通用页面写入任意系统配置。 + +--- + +### 七、前端对接 + +#### 页面:系统设置 > 系统配置 + +**初期可以做一个通用的 Key-Value 管理页面,按 module 分组展示。** + +调用流程: +1. 进入页面 → `GET /api/admin/system/config`(不传 module = 返回全部) +2. 按 module 分组展示,`is_readonly=true` 的配置只读 +3. 修改某项 → `PUT /api/admin/system/config/{config_key}`,body: `{config_value}` +4. 更新成功后后端立即删除该 key 的缓存,前端提示“配置已更新”并刷新当前值 + +**C端支付限制配置展示建议**(针对 `c2b.payment` 模块): + +不要让后台用户手动填 JSON,前端渲染成 CheckboxGroup: + +``` +卡资产允许支付方式: + ☑ 支付宝 ☑ 钱包 ☐ 微信 + +设备允许支付方式: + ☐ 支付宝 ☑ 钱包 ☑ 微信 +``` + +前端把选中项序列化成 `["alipay","wallet"]` 提交。 + +通用 Key-Value 页面只作为管理壳层,已知业务配置必须使用受控组件(单选、复选或开关),不向运营人员暴露 JSON 文本框。 + + +--- + +> 汇编来源:`docs/7月迭代/基础设施/站内消息.md` + +## 基础设施:站内消息(Notification) + +> 状态:待评审 +> 被依赖:需求 18/20/21(审批流通知)、需求 22(临期提醒) +> Phase 2 扩展:企业微信推送、短信通知(预留插拔接口) + +--- + +### 一、设计原则 + +- **业务代码不直接写通知表**:统一通过通知发布器或领域事件异步处理 +- **关键领域事件先写 Outbox**:审批、退款、充值等事务内事件由 Outbox Relay 投递 Asynq,禁止事务提交后直接入队 +- **通知消费必须幂等**:使用稳定的 `event_id` 和接收人唯一约束,Asynq 重试不得重复生成站内消息 +- **不过度抽象**:不用 interface,用具体的 `NotificationPublisher`(后续加渠道 = 在 handler 里加代码) +- **前端轮询**:30秒一次 `/api/admin/notifications/unread-count`,不用 WebSocket + +```mermaid +sequenceDiagram + participant Biz as 业务事务 + participant Outbox as Outbox + participant Relay as Relay + participant Worker as Notification Worker + participant DB as tb_notification + participant Web as 后台前端 + + Biz->>Outbox: 同事务写领域事件 + Relay->>Outbox: 拉取待投递事件 + Relay->>Worker: 至少一次投递 + Worker->>DB: 按 event_id + recipient 幂等插入 + Web->>DB: 经 API 轮询未读数/通知列表 + Web->>DB: 经 API 标记已读 +``` + +--- + +### 二、数据库 + +```sql +-- 迁移文件:YYYYMMDD_create_tb_notification.sql +CREATE TABLE tb_notification ( + id BIGSERIAL PRIMARY KEY, + event_id VARCHAR(64) NOT NULL, -- 领域事件ID或调用方请求ID,用于幂等 + recipient_id BIGINT NOT NULL, -- 用户ID(admin user 或 agent user) + recipient_type VARCHAR(20) NOT NULL DEFAULT 'admin', -- admin | agent + type VARCHAR(50) NOT NULL, -- 通知类型(见常量定义) + title VARCHAR(200) NOT NULL, -- 标题 + body TEXT NOT NULL DEFAULT '', -- 纯文本正文 + ref_type VARCHAR(50), -- 关联业务类型 approval | recharge | refund | iot_card | device + ref_id BIGINT, -- 关联业务ID + is_read BOOLEAN NOT NULL DEFAULT FALSE, + read_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ -- 过期自动不展示(可选,临期提醒用) +); + +CREATE INDEX idx_notification_recipient + ON tb_notification (recipient_id, recipient_type, is_read, created_at DESC); +CREATE INDEX idx_notification_created ON tb_notification (created_at DESC); +CREATE UNIQUE INDEX uq_notification_event_recipient + ON tb_notification (event_id, recipient_id, recipient_type); + +COMMENT ON TABLE tb_notification IS '站内消息通知表'; +``` + +--- + +### 三、常量定义 + +```go +// pkg/constants/notification.go + +// 通知类型 +const ( + NotifyTypeApprovalPending = "approval.pending" // 待我审批 + NotifyTypeApprovalDone = "approval.done" // 审批完成(申请人收) + NotifyTypeApprovalRejected = "approval.rejected" // 审批驳回(申请人收,流程终止) + NotifyTypeApprovalReturned = "approval.returned" // 审批退回(申请人收,可修改后重新提交) + NotifyTypePackageExpiring = "package.expiring" // 套餐临期 + NotifyTypeSystemAlert = "system.alert" // 系统通知 +) + +// 通知接收者类型 +const ( + NotifyRecipientAdmin = "admin" // 平台用户 + NotifyRecipientAgent = "agent" // 代理用户(预留) +) +``` + +--- + +### 四、Model + +```go +// internal/model/notification.go + +// Notification 站内消息模型 +type Notification struct { + ID uint `gorm:"column:id;primaryKey" json:"id"` + EventID string `gorm:"column:event_id;type:varchar(64);not null;uniqueIndex:uq_notification_event_recipient,priority:1" json:"event_id"` + RecipientID uint `gorm:"column:recipient_id;not null;index;uniqueIndex:uq_notification_event_recipient,priority:2" json:"recipient_id"` + RecipientType string `gorm:"column:recipient_type;type:varchar(20);not null;default:'admin';uniqueIndex:uq_notification_event_recipient,priority:3" json:"recipient_type"` + Type string `gorm:"column:type;type:varchar(50);not null" json:"type"` + Title string `gorm:"column:title;type:varchar(200);not null" json:"title"` + Body string `gorm:"column:body;type:text;not null;default:''" json:"body"` + RefType *string `gorm:"column:ref_type;type:varchar(50)" json:"ref_type,omitempty"` + RefID *uint `gorm:"column:ref_id" json:"ref_id,omitempty"` + IsRead bool `gorm:"column:is_read;not null;default:false" json:"is_read"` + ReadAt *time.Time `gorm:"column:read_at" json:"read_at,omitempty"` + CreatedAt time.Time `gorm:"column:created_at;not null" json:"created_at"` + ExpiresAt *time.Time `gorm:"column:expires_at" json:"expires_at,omitempty"` +} + +func (Notification) TableName() string { return "tb_notification" } +``` + +--- + +### 五、发布侧:NotificationPublisher + +非事务性、允许调用方直接发起的通知使用发布器异步入队。审批等领域事件不直接调用该发布器,而由 `TaskCreated`、`ProcessApproved` 等事件处理器转换为通知载荷。 + +领域事件通知使用领域事件自身的 `event_id`;非领域事件调用方使用稳定的业务请求 ID。网络重试或 Asynq 重试时禁止重新生成 ID。 + +```go +// internal/infrastructure/messaging/notification_publisher.go + +// SendPayload 发送通知的参数 +type SendPayload struct { + EventID string // 领域事件ID或调用方请求ID,同一次重试必须保持不变 + RecipientIDs []uint // 接收人ID列表 + RecipientType string // admin | agent + Type string // 通知类型常量 + Title string // 标题 + Body string // 正文 + RefType string // 关联业务类型(可选) + RefID uint // 关联业务ID(可选) + ExpiresAt *time.Time // 过期时间(可选) +} + +// NotificationPublisher 通知发布器(非接口,简单具体实现) +type NotificationPublisher struct { + queueClient *queue.Client + logger *zap.Logger +} + +// Publish 发布通知(异步) +// 返回错误供调用方或 Asynq Handler 决定重试,禁止吞掉关键通知错误。 +func (p *NotificationPublisher) Publish(ctx context.Context, payload SendPayload) error { + if payload.EventID == "" { + return errors.New(errors.CodeInvalidParam, "通知事件ID不能为空") + } + if err := p.queueClient.EnqueueTask(ctx, constants.TaskTypeNotification, payload); err != nil { + p.logger.Error("通知入队失败", zap.Error(err), zap.String("type", payload.Type)) + return err + } + return nil +} +``` + +审批事件处理器调用示例(节点激活后通知候选审批人): + +```go +return h.notifyPublisher.Publish(ctx, notification.SendPayload{ + EventID: event.EventID, + RecipientIDs: []uint{nextApproverID}, + RecipientType: constants.NotifyRecipientAdmin, + Type: constants.NotifyTypeApprovalPending, + Title: "您有一条待审批记录", + Body: fmt.Sprintf("代理「%s」提交了充值申请,请及时审批。", shopName), + RefType: "approval", + RefID: processInstanceID, +}) +``` + +审批事务中只写 `TaskCreated` Outbox 事件。即使 Redis/Asynq 暂时不可用,事件仍保留在数据库,由 Relay 重试;通知处理失败则由 Asynq 重试当前任务。 + +--- + +### 六、消费侧:Asynq Task Handler + +```go +// internal/task/notification_handler.go + +// HandleNotification 处理通知发送任务 +func (h *NotificationHandler) HandleNotification(ctx context.Context, t *asynq.Task) error { + var payload notification.SendPayload + if err := sonic.Unmarshal(t.Payload(), &payload); err != nil { + return fmt.Errorf("反序列化通知载荷失败: %w", err) + } + + // 批量写入 tb_notification + records := make([]model.Notification, 0, len(payload.RecipientIDs)) + for _, uid := range payload.RecipientIDs { + ref_type := (*string)(nil) + ref_id := (*uint)(nil) + if payload.RefType != "" { + ref_type = &payload.RefType + } + if payload.RefID != 0 { + ref_id = &payload.RefID + } + records = append(records, model.Notification{ + EventID: payload.EventID, + RecipientID: uid, + RecipientType: payload.RecipientType, + Type: payload.Type, + Title: payload.Title, + Body: payload.Body, + RefType: ref_type, + RefID: ref_id, + ExpiresAt: payload.ExpiresAt, + }) + } + + if err := h.db.WithContext(ctx). + Clauses(clause.OnConflict{DoNothing: true}). + CreateInBatches(records, 100).Error; err != nil { + return fmt.Errorf("批量写入通知失败: %w", err) + } + + // Phase 2:在此处加企微/短信调用 + // for _, sender := range h.extraSenders { sender.Send(ctx, payload) } + + return nil +} +``` + +--- + +### 七、API 设计 + +#### 7.1 未读数量(前端轮询用,30秒一次) + +``` +GET /api/admin/notifications/unread-count +``` + +响应: +```json +{ "code": 0, "data": { "count": 5 } } +``` + +#### 7.2 通知列表 + +``` +GET /api/admin/notifications?is_read=false&type=approval.pending&page=1&page_size=20 +``` + +请求参数: +```go +type NotificationListRequest struct { + IsRead *bool `query:"is_read" description:"是否已读(不传=全部)"` + Type string `query:"type" description:"通知类型过滤(可选)"` + Page int `query:"page" description:"页码"` + PageSize int `query:"page_size" description:"每页数量(最大50)"` +} +``` + +响应: +```json +{ + "code": 0, + "data": { + "list": [ + { + "id": 1, + "type": "approval.pending", + "title": "您有一条待审批记录", + "body": "代理「XX店」提交了充值申请,请及时审批。", + "ref_type": "approval", + "ref_id": 42, + "is_read": false, + "created_at": "2026-07-11T10:00:00Z" + } + ], + "total": 3, + "page": 1, + "page_size": 20 + } +} +``` + +#### 7.3 标记已读 + +``` +PUT /api/admin/notifications/{id}/read +``` + +#### 7.4 全部标记已读 + +``` +PUT /api/admin/notifications/read-all +``` + +可传 `type` 过滤(只把某类型全部已读): +```json +{ "type": "approval.pending" } +``` + +#### DTO + +```go +// internal/model/dto/notification_dto.go + +type NotificationListRequest struct { + IsRead *bool `query:"is_read"` + Type string `query:"type"` + Page int `query:"page" default:"1"` + PageSize int `query:"page_size" default:"20"` +} + +type NotificationItem struct { + ID uint `json:"id"` + Type string `json:"type" description:"通知类型"` + Title string `json:"title"` + Body string `json:"body"` + RefType *string `json:"ref_type,omitempty"` + RefID *uint `json:"ref_id,omitempty"` + IsRead bool `json:"is_read"` + ReadAt *time.Time `json:"read_at,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +type UnreadCountResponse struct { + Count int64 `json:"count"` +} + +type ReadAllRequest struct { + Type string `json:"type" description:"通知类型(为空则全部已读)"` +} +``` + +--- + +### 八、前端对接 + +#### 顶部导航栏铃铛 + +``` +组件挂载 → 轮询 GET /api/admin/notifications/unread-count(30秒一次) +→ count > 0 时铃铛显示红点 + 数字 +→ 点击铃铛 → 弹出通知抽屉 or 跳转 /notifications 页面 +→ 打开时调 GET /api/admin/notifications?is_read=false +→ 点击某条通知 → PUT /api/admin/notifications/{id}/read → 根据 ref_type+ref_id 跳转对应业务页 +``` + +#### 跳转逻辑(ref_type) + +| ref_type | 跳转页面 | +|----------|---------| +| `approval` | `/approvals/instances/{ref_id}` 审批详情 | +| `recharge` | `/agent-recharges/{ref_id}` 充值单详情 | +| `refund` | `/refunds/{ref_id}` 退款单详情 | +| `iot_card` | `/iot-cards/{ref_id}` IoT卡详情(临期提醒) | +| `device` | `/devices/{ref_id}` 设备详情(临期提醒) | + +#### 通知列表页(/notifications) + +筛选:通知类型(下拉)、已读状态 +操作:全部已读按钮 +列表字段:类型、标题、时间、已读状态 +点击行:跳转关联业务详情 + +前端页面不可见时暂停未读数轮询,恢复可见时立即刷新。通知正文按纯文本渲染;未来需要富文本时使用受控模板和统一净化,禁止直接渲染业务方提交的 HTML。 + +--- + +### 九、Phase 2 扩展预留 + +当需要接入企微通知时,只需在 `HandleNotification` 里追加: + +```go +// 企微通知(Phase 2) +if h.wecomClient != nil { + for _, uid := range payload.RecipientIDs { + wecomOpenID := h.userStore.GetWecomOpenID(ctx, uid) + h.wecomClient.SendMessage(wecomOpenID, payload.Title, payload.Body) + } +} +``` + +业务代码零修改,Handler 里加一段即可。 + + +--- + +> 汇编来源:`docs/7月迭代/基础设施/审批流.md` + +## 基础设施:通用审批流领域 + +> 状态:待评审 +> 被依赖:需求 18(多人审批)、需求 20(退款审批)、需求 21(充值审核) +> 范围变更:需求16已于2026-07-14移出本期,代理申请不在第一版接入范围 +> 架构:DDD,`internal/domain/approval/` + `internal/application/approval/` +> 核心原则:流程结构、审批人和审批规则通过已发布的流程定义决定,禁止写死在业务代码中。 + +--- + +### 一、背景与目标 + +当前系统没有“部门”组织模型,只有账号、角色以及账号与角色的关联。因此审批引擎不能假设“提交人的部门领导”,也不能在代码中固定“第一步部门领导、第二步财务”。 + +审批流领域只认识以下概念: + +- 业务类型和业务单号 +- 流程定义及版本 +- 流程实例 +- 审批节点和流转 +- 审批任务和候选审批人 +- 业务快照和业务资料 +- 审批动作、状态和操作日志 +- 领域事件和业务回调 + +退款、充值等业务负责说明“什么业务需要审批”,审批领域负责说明“流程怎么走、谁可以审批、什么时候完成”。 + +#### 整体运行视图 + +```mermaid +flowchart LR + BizUI[退款/充值/待办前端] --> Handler[Approval Handler] + Handler --> App[Application UseCase] + App --> Definition[ProcessDefinition 聚合] + App --> Instance[ProcessInstance 聚合] + App --> Resolver[审批人解析器] + App --> Attachment[审批附件管理端口] + App --> Repo[(GORM Repository)] + App --> Outbox[(Outbox)] + Relay[Outbox Relay] --> Outbox + Relay --> Asynq[Asynq] + Asynq --> Notice[站内消息消费者] + Asynq --> Adapter[退款/充值业务处理器] + Attachment --> Storage[(私有对象存储)] +``` + +审批事务只负责形成可靠的审批事实。通知和审批后的业务动作异步执行,并依靠稳定事件 ID 和业务幂等键承受重复投递。 + +#### 第一版支持 + +- 流程定义草稿、发布、停用和版本管理 +- 串行审批节点 +- 按角色选择审批人 +- 直接指定一个或多个审批账号 +- 或签:任意一人通过,当前节点完成 +- 会签:所有人通过,当前节点完成 +- 通过、驳回、退回修改 +- 每次审批动作的审批意见和审批附件 +- 流程定义快照、审批人快照和操作日志 +- 并发控制、请求幂等 +- Outbox + Asynq 可靠事件投递 +- 退款、充值业务通过适配器接入 +- 受控的审批动作业务字段;第一版用于退款金额确认和线下充值操作密码校验 + +#### 第一版不支持 + +- 条件表达式和复杂分支 +- 循环、子流程和 BPMN 全规范 +- 任意节点回退、动态跳转和加签 +- 超时自动通过或自动拒绝 +- 运行中修改流程定义或审批人 + +数据结构保留 `nodes + transitions`,但第一版发布校验只允许 `start → approval... → end` 的无环串行流程。 + +--- + +### 二、DDD 边界 + +```mermaid +flowchart TB + H[Handler] --> A[Application UseCase] + A --> PD[ProcessDefinition 聚合] + A --> PI[ProcessInstance 聚合] + PI --> T[ApprovalTask 实体] + A --> AR[ApproverResolver 领域端口] + A --> AT[ApprovalAttachmentStore 应用端口] + A --> PR[Repository 端口] + A --> OR[OutboxRepository 端口] + Infra[Infrastructure] --> PR + Infra --> OR + Infra --> AR + Infra --> AT + Infra --> BA[BusinessApprovalHandler 应用端口] + Infra --> GORM[GORM Repository] + Infra --> Queue[Outbox Relay + Asynq] +``` + +#### 2.1 ProcessDefinition 聚合 + +负责流程定义的合法性和版本规则: + +- 草稿可以修改。 +- 发布时校验节点、连线、审批人配置和无环约束。 +- 已发布版本不可修改。 +- 修改流程必须基于旧版本创建新版本。 +- 停用只影响新流程发起,不影响已运行实例。 + +```mermaid +stateDiagram-v2 + [*] --> Draft: 新建定义 + Draft --> Draft: 修改节点/审批人 + Draft --> Published: 发布校验通过 + Published --> Disabled: 停用 + Published --> Draft: 基于当前版本复制新草稿 + Disabled --> Draft: 基于历史版本复制新草稿 + Published --> [*] + Disabled --> [*] +``` + +`Published → Draft` 表示创建一个新版本草稿,不是把已发布记录原地改回草稿。 + +#### 2.2 ProcessInstance 聚合 + +负责运行期业务不变量: + +- 只有运行中的流程可以审批。 +- 只有待审批任务的候选审批人可以操作。 +- 同一个任务只能完成一次。 +- 或签任意一人通过后,其他候选人的待处理资格取消。 +- 会签全部通过后才推进下一节点。 +- 任意有效审批人驳回或退回时,流程终止,并取消当前节点其他待处理任务。 +- 流程推进必须使用实例保存的定义快照,不能读取后来发布的新版本。 +- 每个审批人的意见属于其唯一审批动作,提交后不可修改;会签和或签都不能用后续意见覆盖先前记录。 +- 审批附件必须与对应操作日志同时落库,不能出现任务已完成但附件关联缺失的半成品。 +- 发起时固化的业务快照是审批详情的权威展示数据;实时业务详情仅作为受原业务数据范围保护的补充。 + +#### 2.3 Application UseCase + +Application 只负责: + +- 加载流程定义、实例和账号数据。 +- 开启事务并调用聚合方法。 +- 调用审批人解析器并激活下一节点。 +- 在审批事务内调用受控的业务动作适配器,校验退款金额或充值操作密码等业务字段。 +- 保存聚合、审批日志和 Outbox 事件。 +- 编排退款、充值等业务与审批领域的接入。 + +状态是否允许转换、节点是否完成等判断必须位于 Domain。 + +--- + +### 三、流程定义 + +#### 3.1 定义示例 + +```json +{ + "code": "refund_approval", + "version": 2, + "start_node_id": "start", + "nodes": [ + { + "id": "start", + "type": "start", + "name": "开始" + }, + { + "id": "business_review", + "type": "approval", + "name": "业务审核", + "approver": { + "type": "role", + "role_id": 12, + "approval_mode": "any" + } + }, + { + "id": "finance_review", + "type": "approval", + "name": "财务审核", + "approver": { + "type": "user", + "user_ids": [101, 102], + "approval_mode": "all" + }, + "action_config": { + "require_operation_password": true + } + }, + { + "id": "end", + "type": "end", + "name": "结束" + } + ], + "transitions": [ + {"from": "start", "to": "business_review"}, + {"from": "business_review", "to": "finance_review"}, + {"from": "finance_review", "to": "end"} + ] +} +``` + +#### 3.2 审批人配置 + +第一版支持两种审批人来源: + +```go +// ApproverType 审批人来源类型 +const ( + ApproverTypeRole = "role" // 按角色解析 + ApproverTypeUser = "user" // 直接指定账号 +) + +// ApprovalMode 节点完成方式 +const ( + ApprovalModeAny = "any" // 或签,任意一人通过 + ApprovalModeAll = "all" // 会签,全部人员通过 +) +``` + +所有常量实际定义在 `pkg/constants/approval.go`,状态值使用 `int`,类型和方式使用 `string`。 + +角色审批配置存储稳定的 `role_id`,同时在定义和任务中保存角色名称快照。禁止依赖可修改的角色名称执行权限判断。 + +`action_config` 是节点定义快照的一部分。第一版只支持 `require_operation_password`:配置后,仅触发节点完成的审批人输入密码;或签由实际通过者输入,会签由最后一位完成会签者输入。密码只参与本次内存校验,不进入业务表、审批日志、Outbox、访问日志或错误日志。 + +#### 3.3 审批人解析时机 + +审批人在**节点激活时**解析: + +1. `role`:查询该角色下当前启用的账号。 +2. `user`:校验配置中的账号存在且启用。 +3. 将解析结果写入 `tb_approval_task_assignee`,形成运行期快照。 +4. 没有可用审批人时,节点激活失败,当前事务回滚。 + +角色成员后续变化不影响已经激活的任务。后续节点尚未激活时,使用激活时最新的角色成员。 + +后台审批场景只允许配置启用的平台角色以及超级管理员/平台用户账号。客户角色、代理账号和企业账号不能成为后台审批任务候选人,除非后续业务明确扩展审批主体范围。 + +#### 3.4 业务绑定 + +业务类型通过绑定表选择流程定义,业务代码不得写死具体步骤: + +```text +refund → refund_approval 的当前已发布版本 +recharge → recharge_approval 的当前已发布版本 +``` + +发起流程时解析当前已发布版本,并将完整 `definition_json` 保存到流程实例快照。 + +#### 3.5 运行时节点推进 + +```mermaid +flowchart TD + Start[读取实例中的定义快照] --> Node{当前节点类型} + Node -->|start| Next[查唯一下一节点] + Node -->|approval| Resolve[解析角色或指定账号] + Resolve --> Empty{存在可用审批人?} + Empty -->|否| Fail[事务失败并返回配置错误] + Empty -->|是| Task[创建任务和审批人快照] + Task --> Wait[等待审批] + Wait --> Decision{节点审批结果} + Decision -->|等待| Wait + Decision -->|通过| Next + Decision -->|驳回| Rejected[流程已驳回] + Decision -->|退回| Returned[流程已退回] + Next --> EndCheck{下一节点是否 end?} + EndCheck -->|否| Node + EndCheck -->|是| Approved[流程已通过] +``` + +第一版发布校验保证每个非结束节点只有一个后继节点,因此运行期不执行条件表达式,也不会出现多分支选择。 + +--- + +### 四、领域状态机 + +#### 4.1 流程实例状态 + +```text +1=审批中 +2=已通过 +3=已驳回 +4=已退回 +``` + +允许的状态转换: + +```mermaid +stateDiagram-v2 + [*] --> Running: 发起成功 + Running --> Approved: 到达结束节点 + Running --> Rejected: 审批人驳回 + Running --> Returned: 审批人退回修改 + Approved --> [*] + Rejected --> [*] + Returned --> [*] +``` + +已通过、已驳回、已退回都是终态。退回后重新提交必须创建新的流程实例,旧实例永久保留。 + +#### 4.2 审批任务状态 + +```text +1=待审批 +2=已通过 +3=已驳回 +4=已退回 +5=已取消 +``` + +任务只在节点激活时创建,不预先创建后续节点的“待审批”任务。 + +#### 4.3 审批人处理状态 + +```text +1=待处理 +2=已通过 +3=已驳回 +4=已退回 +5=已取消 +``` + +- 或签:任意审批人通过后,任务通过,其余待处理记录变为已取消。 +- 会签:全部审批人通过后,任务通过。 +- 驳回或退回:任务和流程立即进入对应终态,其余待处理记录变为已取消。 + +```mermaid +stateDiagram-v2 + [*] --> Pending + Pending --> Approved: 节点规则满足 + Pending --> Rejected: 任一有效审批人驳回 + Pending --> Returned: 任一有效审批人退回 + Pending --> Cancelled: 同节点已由他人完成/流程终止 + Approved --> [*] + Rejected --> [*] + Returned --> [*] + Cancelled --> [*] +``` + +#### 4.4 审批意见与附件 + +“审批建议”“审批备注”统一建模为 `ApprovalOpinion`,它属于某个审批人执行的一次通过、驳回或退回动作,不属于流程实例的可修改公共备注: + +- 通过:审批意见可选,最多 1000 字。 +- 驳回、退回:审批意见必填,去除首尾空白后至少 1 字,最多 1000 字。 +- 意见按纯文本保存和展示,不接受 HTML、Markdown 或富文本。 +- 审批动作提交成功后,意见和附件不可编辑、覆盖或删除;需要纠正时只能由后续审批动作形成新的审计记录。 +- 第一版不建设独立评论区或聊天式追加备注,避免绕开审批动作权限和审计语义。 + +审批附件属于本次审批意见的补充材料: + +- 每次动作可选上传 0~5 个附件。 +- 附件与审批意见、任务状态、操作日志在同一数据库事务内建立关联。 +- 退款凭证、充值凭证等仍属于业务单资料;审批附件不能替代业务必填凭证,也不能反向修改业务资料。 +- 或签、会签场景下,每个审批人拥有各自独立的意见和附件,不能把多人意见合并覆盖到任务级字段。 + +--- + +### 五、数据库设计 + +禁止建立数据库外键,关联通过 ID 维护。 + +#### 5.1 流程定义表 + +```sql +CREATE TABLE tb_approval_process_definition ( + id BIGSERIAL PRIMARY KEY, + process_code VARCHAR(50) NOT NULL, + process_name VARCHAR(100) NOT NULL, + version INT NOT NULL, + status INT NOT NULL DEFAULT 1, -- 1=草稿 2=已发布 3=已停用 + definition_json JSONB NOT NULL, + published_at TIMESTAMPTZ, + creator BIGINT NOT NULL DEFAULT 0, + updater BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ +); + +CREATE UNIQUE INDEX uq_approval_definition_code_version + ON tb_approval_process_definition (process_code, version) + WHERE deleted_at IS NULL; +``` + +#### 5.2 业务流程绑定表 + +```sql +CREATE TABLE tb_approval_process_binding ( + id BIGSERIAL PRIMARY KEY, + biz_type VARCHAR(50) NOT NULL, + process_code VARCHAR(50) NOT NULL, + status INT NOT NULL DEFAULT 1, -- 0=禁用 1=启用 + creator BIGINT NOT NULL DEFAULT 0, + updater BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX uq_approval_binding_biz_type + ON tb_approval_process_binding (biz_type); +``` + +#### 5.3 流程实例表 + +```sql +CREATE TABLE tb_approval_process_instance ( + id BIGSERIAL PRIMARY KEY, + flow_no VARCHAR(30) NOT NULL, + process_code VARCHAR(50) NOT NULL, + process_version INT NOT NULL, + definition_snapshot JSONB NOT NULL, + business_snapshot JSONB NOT NULL DEFAULT '{}', + biz_type VARCHAR(50) NOT NULL, + biz_id BIGINT NOT NULL, + biz_no VARCHAR(50) NOT NULL DEFAULT '', + submitter_type VARCHAR(30) NOT NULL, + submitter_id BIGINT, + submitter_name VARCHAR(50) NOT NULL DEFAULT '', + current_node_id VARCHAR(64) NOT NULL, + current_node_name VARCHAR(100) NOT NULL DEFAULT '', + status INT NOT NULL DEFAULT 1, + version BIGINT NOT NULL DEFAULT 1, + finish_reason TEXT, + completed_at TIMESTAMPTZ, + creator BIGINT NOT NULL DEFAULT 0, + updater BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX uq_approval_instance_flow_no + ON tb_approval_process_instance (flow_no); +CREATE INDEX idx_approval_instance_biz + ON tb_approval_process_instance (biz_type, biz_id, created_at DESC); +CREATE UNIQUE INDEX uq_approval_instance_active_biz + ON tb_approval_process_instance (biz_type, biz_id) + WHERE status = 1; +``` + +`business_snapshot` 固化业务标题、业务单号、提交人、审批关键字段和业务资料元数据。退款申请凭证、充值凭证等在其中标记为 `business_materials`;审批人动作附件仍只写入 `tb_approval_operation_attachment`,两类文件不能混用。 + +第一版业务快照最低字段: + +| 业务 | 字段和资料 | +|------|------------| +| 退款 | 退款单号、订单号、资产类型和标识、代理店铺、支付方式、订单实收、申请退款金额、退款原因、退款申请凭证 | +| 员工线下充值 | 充值单号、目标代理店铺、充值金额、支付方式、提交人、备注、支付凭证 | + +`submitter_type` 第一版支持 `admin_account`、`shop_account`。外部申请人属于需求16后续扩展,本期不引入 `external_applicant` 语义。 + +#### 5.4 审批任务表 + +```sql +CREATE TABLE tb_approval_task ( + id BIGSERIAL PRIMARY KEY, + process_instance_id BIGINT NOT NULL, + node_id VARCHAR(64) NOT NULL, + node_name VARCHAR(100) NOT NULL, + node_sequence INT NOT NULL, + approver_type VARCHAR(20) NOT NULL, + approver_config JSONB NOT NULL, + approval_mode VARCHAR(20) NOT NULL, + status INT NOT NULL DEFAULT 1, + completed_by BIGINT, + completed_by_name VARCHAR(50), + completed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX uq_approval_task_instance_node + ON tb_approval_task (process_instance_id, node_id); +CREATE INDEX idx_approval_task_status + ON tb_approval_task (status, created_at DESC); +``` + +任务表只保存节点结果。`completed_by` 表示触发节点完成的最后操作人,不代表节点只有一个审批人;人类审批意见统一保存在审批人记录和不可变操作日志中。 + +#### 5.5 任务审批人表 + +```sql +CREATE TABLE tb_approval_task_assignee ( + id BIGSERIAL PRIMARY KEY, + task_id BIGINT NOT NULL, + assignee_id BIGINT NOT NULL, + assignee_name VARCHAR(50) NOT NULL DEFAULT '', + source_type VARCHAR(20) NOT NULL, + source_id BIGINT, + source_name VARCHAR(100) NOT NULL DEFAULT '', + status INT NOT NULL DEFAULT 1, + comment TEXT NOT NULL DEFAULT '', + operation_log_id BIGINT, + operated_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX uq_approval_task_assignee + ON tb_approval_task_assignee (task_id, assignee_id); +CREATE INDEX idx_approval_assignee_pending + ON tb_approval_task_assignee (assignee_id, status, created_at DESC); +``` + +#### 5.6 操作日志表 + +```sql +CREATE TABLE tb_approval_operation_log ( + id BIGSERIAL PRIMARY KEY, + request_id VARCHAR(64) NOT NULL, + process_instance_id BIGINT NOT NULL, + task_id BIGINT, + node_id VARCHAR(64), + operator_type VARCHAR(30) NOT NULL, + operator_id BIGINT, + operator_name VARCHAR(50) NOT NULL DEFAULT '', + action VARCHAR(30) NOT NULL, + request_digest CHAR(64) NOT NULL, + before_status INT NOT NULL, + after_status INT NOT NULL, + comment TEXT NOT NULL DEFAULT '', + business_data JSONB NOT NULL DEFAULT '{}', + client_ip VARCHAR(64) NOT NULL DEFAULT '', + user_agent VARCHAR(500) NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX uq_approval_operation_request_id + ON tb_approval_operation_log (request_id); +CREATE INDEX idx_approval_operation_instance + ON tb_approval_operation_log (process_instance_id, created_at); +``` + +操作日志只允许新增,禁止更新或删除。相同 `request_id` 仅在 `task_id`、操作人、动作和规范化请求摘要 SHA-256 均一致时返回第一次操作结果;任一字段不一致返回冲突,不能把误复用的请求 ID 当成成功重试。 + +#### 5.7 审批操作附件表 + +```sql +CREATE TABLE tb_approval_operation_attachment ( + id BIGSERIAL PRIMARY KEY, + operation_log_id BIGINT NOT NULL, + file_key VARCHAR(500) NOT NULL, + file_name_snapshot VARCHAR(255) NOT NULL, + content_type_snapshot VARCHAR(100) NOT NULL, + size_bytes BIGINT NOT NULL, + object_etag VARCHAR(128) NOT NULL, + attached_by BIGINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX uq_approval_attachment_operation_file + ON tb_approval_operation_attachment(operation_log_id, file_key); +CREATE INDEX idx_approval_attachment_operation + ON tb_approval_operation_attachment(operation_log_id); +``` + +附件表不建立数据库外键。`operation_log_id` 对应不可变操作日志;审批人记录通过 `operation_log_id` 定位该次操作的意见和附件。 + +附件提交规则: + +1. 前端生成本次动作 `request_id` 后,通过现有上传能力申请 `purpose=approval_attachment` 的一次性上传凭证;上传记录必须固化上传人账号、用途、`request_id`、临时 Key 和过期时间,临时 Key 位于 `attachments/` 前缀。 +2. 审批动作提交 `attachments=[{file_key,file_name}]`;每个临时 Key 最长 500,必须以 `attachments/` 开头且不能重复。`file_name` 清理后长度为 1~255,只作为显示快照,不作为对象定位依据。 +3. Application 在状态转换前先校验临时 Key 的上传记录属于当前账号、用途为 `approval_attachment` 且 `request_id` 一致,再调用对象存储 `Stat/HeadObject` 确认文件存在,并读取对象大小、Content-Type 和 ETag;后端同时校验文件扩展名与 Content-Type 组合,不能只信任请求体声明。 +4. 第一版每个文件最大 20MB,只允许 `.jpg/.jpeg/.png/.pdf/.doc/.docx/.xls/.xlsx`;拒绝可执行文件、脚本和普通压缩包。扩展名与 MIME 白名单定义在 `pkg/constants/approval.go`,不由前端决定。 +5. 校验通过后,后端按 `request_id` 将临时对象服务端复制到 `approval-attachments/{request_id}/` 审计前缀,并返回最终 Key、元数据和 ETag。该前缀不提供前端上传 URL,避免附件提交后被覆盖。 +6. 文件名只作为展示快照,必须去除路径和控制字符;下载时使用安全的 `Content-Disposition`。 +7. 数据库事务只保存最终审计 Key。事务提交后异步删除临时对象;事务失败产生的未引用审计对象和普通临时对象由统一生命周期清理。 + +#### 5.8 Outbox 表 + +`tb_outbox_event` 是通用基础设施表,不只服务审批流: + +```sql +CREATE TABLE tb_outbox_event ( + id BIGSERIAL PRIMARY KEY, + event_id VARCHAR(64) NOT NULL, + aggregate_type VARCHAR(50) NOT NULL, + aggregate_id VARCHAR(64) NOT NULL, + event_type VARCHAR(100) NOT NULL, + payload JSONB NOT NULL, + status INT NOT NULL DEFAULT 1, -- 1=待发布 2=已发布 3=发布失败 + retry_count INT NOT NULL DEFAULT 0, + available_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + published_at TIMESTAMPTZ, + last_error TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX uq_outbox_event_id ON tb_outbox_event (event_id); +CREATE INDEX idx_outbox_pending + ON tb_outbox_event (status, available_at) + WHERE status IN (1, 3); +``` + +--- + +### 六、核心扩展接口 + +#### 6.1 审批人解析器 + +```go +// ApproverResolver 根据节点配置解析候选审批人 +type ApproverResolver interface { + Resolve(ctx context.Context, rule ApproverRule) ([]Approver, error) +} +``` + +Infrastructure 提供: + +- `RoleApproverResolver` +- `UserApproverResolver` +- `ApproverResolverRegistry` + +注册表只是按 `approver.type` 选择策略,不承担流程推进逻辑。 + +#### 6.2 业务审批处理器 + +```go +// BusinessApprovalHandler 处理审批结果对应的业务动作 +type BusinessApprovalHandler interface { + BusinessType() string + BuildSnapshot(ctx context.Context, bizID uint) (BusinessSnapshot, error) + OnApproved(ctx context.Context, event ProcessApprovedEvent) error + OnRejected(ctx context.Context, event ProcessRejectedEvent) error + OnReturned(ctx context.Context, event ProcessReturnedEvent) error +} +``` + +`BuildSnapshot` 在业务单创建、重新提交并启动流程的同一事务前读取业务事实,返回标题、编号、提交人、关键字段和业务资料元数据;写入实例后不随业务表后续变化而变化。退款和充值分别实现处理器,并注册到 `BusinessApprovalHandlerRegistry`。异步处理器必须使用状态条件更新或业务幂等键,允许 Asynq 重试。 + +#### 6.3 审批动作业务扩展 + +少数业务需要在审批动作中确认业务字段,例如退款最终审批需要确认实际退款金额。审批引擎不解析这些字段,只提供受控扩展端口: + +```go +// BusinessApprovalActionAdapter 处理审批动作中的业务字段。 +type BusinessApprovalActionAdapter interface { + BusinessType() string + ResolveActionForm(ctx context.Context, action ActionContext) ([]ActionField, error) + ApplyBeforeApprove(ctx context.Context, tx Transaction, action ActionContext, fields map[string]any) (map[string]any, error) +} +``` + +`Transaction` 是 Application 层的事务上下文抽象,由现有 GORM `TxManager` 实现;领域对象不直接依赖 GORM。 + +- `ResolveActionForm` 返回当前任务允许提交的字段定义,前端按接口渲染,不能按节点名称写死。 +- `ApplyBeforeApprove` 在审批状态转换的同一事务中校验并保存业务字段,返回标准化结果。 +- 标准化结果写入 `tb_approval_operation_log.business_data`,便于审计和幂等重放。 +- 没有注册适配器的业务拒绝非空 `business_fields`,不能静默忽略未知字段。 +- 第一版退款和员工线下充值实现该端口:节点通过 `action_config` 声明 `approved_refund_amount` 或 `operation_password`。字段只在当前操作会完成该节点时返回。 +- 金额必须大于 0,且不能超过申请退款金额和订单实收金额。 +- 会签节点的退款金额由最后一位完成会签的审批人确认;如需让指定人员确定金额,应在流程定义中增加其专属的最终决策节点,不能由第一位会签人预先锁定。 +- `operation_password` 由现有 `OperationPasswordService` 校验,只用于本次请求,不写业务表、操作日志、Outbox、错误日志或访问日志;适配器返回的标准化审计数据必须排除敏感字段。 + +`ProcessApproved` 消费者只读取已经持久化的业务字段执行后续退款,不能在异步阶段重新接受审批人输入。 + +#### 6.4 审批附件存储端口 + +```go +// ApprovalAttachmentStore 校验临时对象并固化为不可覆盖的审计附件。 +type ApprovalAttachmentStore interface { + PrepareImmutable(ctx context.Context, requestID string, refs []AttachmentRef) ([]PreparedAttachment, error) +} +``` + +Infrastructure 使用对象存储 `HeadObject + CopyObject` 实现,并要求复制目标 Key 对同一 `request_id + source_key` 幂等:目标已存在时必须校验 ETag 一致并直接复用,禁止覆盖为不同内容。输入中的文件名只作显示候选值;大小、Content-Type 和 ETag 来自对象存储。该端口不生成下载 URL,下载授权由审批实例查询用例单独处理。 + +--- + +### 七、领域事件与事务 + +核心事件: + +```text +ProcessStarted +TaskCreated +TaskApproved +TaskRejected +TaskReturned +ProcessApproved +ProcessRejected +ProcessReturned +``` + +任务结果事件只携带 `operation_log_id` 和必要的意见摘要,不携带对象存储签名 URL。附件 Key 保留在审批附件表;站内消息第一版只提示“包含附件”,用户进入有权限的审批详情后再获取下载 URL。 + +审批动作先执行事务外预检: + +```text +1. 按 request_id 查询既有操作日志;只有任务、操作人、动作和规范化请求摘要均一致时才返回原结果,否则返回冲突 +2. 规范化并校验审批意见 +3. 调用 ApprovalAttachmentStore 检查附件并复制到不可覆盖的审计前缀 +4. 形成仅供本次请求使用的最终 Key 和附件元数据快照 +``` + +对象存储检查不得在持有流程实例行锁的数据库事务中执行。预检通过后,审批数据库事务必须同时完成: + +```text +1. 锁定或按 version 加载流程实例 +2. 校验 request_id 幂等及请求摘要 +3. 再次校验任务仍属于当前账号且状态未变化 +4. 调用业务动作适配器校验并保存扩展字段(如有) +5. 聚合执行状态转换 +6. 更新任务和审批人状态,保存该审批人的意见 +7. 创建下一节点任务和审批人快照(如需推进) +8. 使用预检快照写操作日志和审批附件记录,并回写审批人的 operation_log_id +9. 写 Outbox 事件 +10. 提交事务 +``` + +对象一旦写入审批附件记录即视为不可变引用,未引用文件清理任务不得删除它;如果预检后任务状态发生变化,数据库事务回滚,固化对象按未引用审计对象处理。 + +Outbox Relay 提交 Asynq 后,消费者负责: + +- 创建站内消息 +- 通知下一节点候选审批人 +- 通知有站内账号的申请人审批结果;外部申请人由业务投影提供结果查询 +- 调用退款、充值等业务处理器 +- 后续扩展企业微信通知 + +通知失败不能阻塞审批,但事件不能丢失。业务处理失败由 Asynq 重试并保留错误日志。 + +#### 7.1 发起流程时序 + +```mermaid +sequenceDiagram + actor User as 提交人 + participant Biz as 退款/充值 Application + participant Approval as StartProcessUseCase + participant DB as PostgreSQL + participant Relay as Outbox Relay + participant Worker as Asynq Worker + + User->>Biz: 提交业务申请 + Biz->>DB: 开启同一数据库事务 + Biz->>DB: 创建业务单 + Biz->>Approval: 发起审批(biz_type, biz_id) + Approval->>DB: 读取已发布定义和业务绑定 + Approval->>DB: 创建实例、首任务、审批人快照、操作日志 + Approval->>DB: 写 ProcessStarted/TaskCreated Outbox + Biz->>DB: 回写 approval_instance_id + DB-->>Biz: 提交成功 + Biz-->>User: 返回业务单和审批摘要 + Relay->>DB: 拉取未投递事件 + Relay->>Worker: 投递通知任务 +``` + +业务单和首个审批任务必须同事务创建。任何一步失败都不能留下“有业务单但没有审批任务”的半成品。 + +#### 7.2 审批与业务回调时序 + +```mermaid +sequenceDiagram + actor Approver as 审批人 + participant API as Approval API + participant Domain as ProcessInstance + participant DB as PostgreSQL + participant Relay as Outbox Relay + participant Worker as Asynq Worker + participant Biz as 退款/充值处理器 + + Approver->>API: approve/reject/return(task_id, request_id) + API->>DB: 开启事务并加载实例/任务/审批人 + API->>Domain: 执行状态转换 + Domain-->>API: 节点等待/推进/流程终态 + API->>DB: 保存状态、日志、下一任务和 Outbox + DB-->>API: 提交成功 + API-->>Approver: 返回最新审批摘要 + Relay->>Worker: 至少一次投递领域事件 + Worker->>Biz: 按 biz_type 调业务处理器 + Biz-->>Worker: 幂等成功或可重试错误 +``` + +审批接口成功只证明审批事实已提交,不证明退款到账或充值入账已经完成。关联业务接口必须返回独立的业务处理状态。 + +--- + +### 八、并发与幂等 + +#### 8.1 流程并发 + +流程实例使用 `version` 乐观锁: + +```sql +UPDATE tb_approval_process_instance +SET current_node_id = ?, + current_node_name = ?, + status = ?, + version = version + 1, + updated_at = NOW() +WHERE id = ? + AND status = 1 + AND version = ?; +``` + +受影响行数为 0 时,返回“审批状态已变化,请刷新后重试”。 + +#### 8.2 任务幂等 + +任务只能从待审批状态更新: + +```sql +UPDATE tb_approval_task_assignee +SET status = ?, operated_at = NOW() +WHERE task_id = ? + AND assignee_id = ? + AND status = 1; +``` + +所有审批动作必须携带 `request_id`,并由操作日志唯一索引防止重复请求。 + +--- + +### 九、应用用例 + +```text +internal/application/approval/ +├── create_definition.go +├── update_definition.go +├── publish_definition.go +├── disable_definition.go +├── bind_business_process.go +├── start_process.go +├── approve_task.go +├── reject_task.go +├── return_task.go +├── list_my_tasks.go +└── get_process_detail.go +``` + +#### 9.1 发起流程 + +```text +1. 根据 biz_type 查询启用的流程绑定 +2. 查询 process_code 当前已发布版本 +3. 校验业务单不存在运行中的审批实例 +4. 由业务处理器构建业务快照,创建定义快照和流程实例 +5. 从 start 节点找到首个 approval 节点 +6. 解析角色或指定账号 +7. 创建审批任务和审批人快照 +8. 保存业务单、流程实例、任务、操作日志和 Outbox +``` + +业务单创建与流程创建必须共享同一个事务,禁止先创建业务单后忽略审批流创建失败。 + +#### 9.2 审批通过 + +```text +1. 根据 task_id 加载流程实例、任务和当前审批人记录 +2. 校验任务属于当前账号且仍为待处理 +3. 聚合执行 Approve +4. 或签/会签策略判断节点是否完成 +5. 节点未完成:保存当前审批结果并等待其他人 +6. 节点完成:根据定义快照激活下一节点 +7. 到达 end:流程变为已通过 +8. 保存操作日志和 Outbox 事件 +``` + +#### 9.3 驳回和退回 + +- 驳回:当前流程永久终止,业务进入已拒绝状态。 +- 退回:当前流程永久终止,业务进入可编辑状态。 +- 重新提交:业务创建新的审批实例,旧实例仅用于历史追溯。 + +--- + +### 十、API 设计 + +#### 10.1 流程定义管理 + +```text +POST /api/admin/approval-definitions +PUT /api/admin/approval-definitions/{id} +POST /api/admin/approval-definitions/{id}/publish +POST /api/admin/approval-definitions/{id}/disable +GET /api/admin/approval-definitions +GET /api/admin/approval-definitions/{id} +PUT /api/admin/approval-bindings/{biz_type} +``` + +只有草稿可以修改。发布接口必须完成完整定义校验。 + +#### 10.2 运行时接口 + +```text +GET /api/admin/approval-tasks?status=1&biz_type=refund&page=1&page_size=20 +POST /api/admin/approval-tasks/{task_id}/approve +POST /api/admin/approval-tasks/{task_id}/reject +POST /api/admin/approval-tasks/{task_id}/return +GET /api/admin/approval-instances/{instance_id} +GET /api/admin/approval-instances/by-business?biz_type=refund&biz_id=123 +POST /api/admin/approval-instances/{instance_id}/attachment-download-urls +POST /api/admin/approval-instances/{instance_id}/business-material-download-urls +``` + +审批动作请求: + +```json +{ + "request_id": "0190f6c9-2a4e-7f19-b8ab-ec11d63e9970", + "comment": "同意", + "attachments": [ + { + "file_key": "attachments/2026/07/13/0190f6c9-evidence.pdf", + "file_name": "退款核对说明.pdf" + } + ], + "business_fields": { + "approved_refund_amount": 10000 + } +} +``` + +`attachments` 可省略,最多 5 个;后端先校验临时对象的上传人、用途和 `request_id`,再以 `file_key` 定位对象,文件类型和大小从对象存储读取,`file_name` 经清理后作为显示快照。`business_fields` 可省略;仅当前节点 `action_config` 声明且本次操作会完成节点的字段允许提交。其他业务或其他节点传入未声明字段时,后端返回参数错误。 + +意见规则由三个动作 DTO 分别校验:`approve` 的 `comment` 可为空,`reject/return` 的 `comment` 必填。后端必须统一去除首尾空白并限制最多 1000 字,不能只依赖前端校验。 + +附件下载请求使用附件 ID,不接受客户端直接提交任意 `file_key`: + +```json +{ + "attachment_ids": [9001, 9002] +} +``` + +单次支持 1~50 个附件 ID。后端先校验当前账号有权查看该流程实例,并确认所有附件都属于该实例的操作日志,再调用对象存储生成短期下载 URL。 + +业务资料下载请求只接受 `business_snapshot.business_materials` 中的 `file_id`,不接受任意 `file_key`。后端先校验流程详情权限,再从实例快照映射到原业务资料对象并生成短期 URL;业务资料不会被伪装成审批附件。 + +审批接口操作 `task_id`,不再直接操作 `flow_id`。 + +#### 10.3 详情响应 + +详情必须动态返回节点和审批人,不使用固定的“部门领导审批人”“财务审批人”字段。`action_form` 只在当前账号拥有对应审批动作时返回: + +```json +{ + "instance_id": 1001, + "process_name": "退款审批", + "process_version": 2, + "status": 1, + "current_node_name": "财务审核", + "business_snapshot": { + "title": "退款申请", + "biz_no": "RF202607140001", + "submitter_name": "张三", + "fields": [ + {"key": "asset_identifier", "label": "资产标识", "value": "8986..."}, + {"key": "requested_refund_amount", "label": "申请退款金额", "value": 10000} + ], + "business_materials": [ + {"file_id": "refund-voucher-1", "file_name": "退款凭证.pdf", "content_type": "application/pdf", "size_bytes": 245760} + ] + }, + "available_actions": ["approve", "reject", "return"], + "action_form": { + "fields": [ + { + "key": "approved_refund_amount", + "type": "money", + "required": false, + "default_value": 10000, + "label": "实际退款金额" + } + ] + }, + "tasks": [ + { + "node_id": "business_review", + "node_name": "业务审核", + "approval_mode": "any", + "status": 2, + "current_user_assignee_status": 2, + "assignees": [ + { + "account_id": 88, + "account_name": "zhangsan", + "status": 2, + "comment": "同意", + "attachments": [ + { + "attachment_id": 9001, + "file_name": "退款核对说明.pdf", + "content_type": "application/pdf", + "size_bytes": 245760 + } + ] + } + ] + } + ] +} +``` + +#### 10.4 权限控制 + +- 流程定义创建、修改、发布、停用和业务绑定:仅流程管理员或系统管理员。 +- 审批任务操作:当前账号必须存在于 `tb_approval_task_assignee` 且状态为待处理。 +- 待我审批列表:按当前账号 ID 查询任务审批人表,不根据前端传入角色判断。 +- 流程详情:有站内账号的申请人、当前审批候选人、业务管理员和系统管理员可以查看。上述人员可查看本实例的业务快照、业务资料、审批意见和审批附件;跳转到实时业务详情时仍受现有店铺/企业数据范围过滤。 +- 审批附件下载:继承流程详情权限,并逐个校验附件归属;响应只返回短期签名 URL,不暴露 Bucket、公网永久地址或未授权 `file_key`。 +- 角色或账号配置在发布时校验存在性;节点激活时再次校验账号启用状态。 +- 所有权限校验在后端完成,前端隐藏按钮不能作为授权依据。 +- `available_actions` 由后端根据当前账号、实例状态和审批人快照计算,前端不得用角色名称自行拼装。 + +--- + +### 十一、业务接入协议 + +#### 11.1 业务表关联 + +退款单和充值单保留当前审批实例 ID: + +```sql +ALTER TABLE tb_xxx ADD COLUMN approval_instance_id BIGINT; +``` + +历史审批通过 `biz_type + biz_id` 查询全部流程实例。业务表只保存当前实例 ID,重新提交时更新为新实例。 + +#### 11.2 业务状态归属 + +- 审批领域只维护流程和任务状态。 +- 退款、充值领域维护各自业务状态。 +- `ProcessApproved/Rejected/Returned` 通过 Outbox 可靠投递。 +- 业务处理器消费事件后,使用条件更新改变业务状态。 +- 审批通过不等同于业务处理成功;代理钱包回退和充值入账具备各自处理状态、业务幂等键和失败重试。第三方/线下退款本期由财务人工确认,不接入支付渠道自动退款。 +- 审批意见和审批附件的权威记录属于审批操作日志。业务表可以为列表、H5 或历史兼容快照最终驳回/退回原因,但不能反向覆盖审批日志,也不重复保存全部附件。 +- 退款的资金边界:仅代理钱包支付订单在审批通过后自动回退到原扣款代理主钱包;个人/客户资产钱包不纳入本期自动回退,微信、支付宝和线下退款均由财务在系统外完成后人工确认。 + +#### 11.3 默认流程 + +本迭代预置两个流程定义: + +- `refund_approval`:退款审批 +- `recharge_approval`:员工线下代充值审批 + +默认节点可配置为两个串行审批节点,但审批节点名称、角色 ID、指定账号和审批方式必须来自流程定义,禁止在代码中使用固定步骤编号或固定角色名称。 + +#### 11.4 停机切换与旧接口下线 + +本次发布明确采用停机切换,不建设新旧审批接口兼容门面: + +1. 维护窗口内停止退款、线下充值和审批写操作。 +2. 完成迁移后同时发布 API、Worker/Relay 和前端。 +3. 初始化并发布两个默认流程定义,绑定 `refund` 和 `recharge`。 +4. 退款按业务单 ID 直接审批接口、`POST /api/admin/agent-recharges/{id}/offline-pay`、`POST /api/admin/agent-recharges/{id}/reject` 在新版本中不再注册。 +5. 所有审批动作统一调用 `/api/admin/approval-tasks/{task_id}/*`。 +6. 验证流程发起、任务审批、结果通知和业务处理状态后再开放系统。 + +停机发布降低了同时维护两套状态转换语义的风险,也避免旧接口绕过任务审批人快照、请求幂等和操作日志。 + +#### 11.5 存量审批数据切换 + +停机期间必须处理存量单据,不能在旧接口下线后留下无法审批的业务记录: + +1. 已通过、已拒绝、已退回等历史终态记录不伪造流程实例,`approval_instance_id` 保持为空;查询接口返回 `approval_source=legacy`,页面只读展示原业务状态、旧审批字段和审计日志。 +2. `status=1` 的待审批退款单,使用发布后的 `refund_approval` 从首节点创建流程实例、任务和审批人快照,并回写 `approval_instance_id`。 +3. 仅对符合“平台员工创建 + `payment_method=offline` + 尚未入账”的存量充值记录创建 `recharge_approval` 实例;代理在线充值不进入审批流。 +4. 回填使用一次性 Application 命令执行,不用裸 SQL 拼装任务;命令按 `biz_type + biz_id` 幂等,已有关联实例时跳过。 +5. 开放访问前必须确认所有需要继续审批的存量业务记录都已关联运行中的流程实例,不允许前端回退到旧审批按钮。 + +历史终态记录没有完整节点、候选人和操作意见时,宁可明确显示“历史审批记录”,也不能根据角色名称或当前账号关系反推并生成虚假的审批时间线。 + +--- + +### 十二、前端技术方案 + +跨需求共性规则见 7月迭代前端技术方案,本节只描述审批流专项。 + +#### 12.1 页面与路由 + +| 页面 | 建议路由 | 说明 | +|------|----------|------| +| 待我审批 | `/approvals/tasks` | 默认仅查询当前账号待处理任务,支持业务类型和时间筛选 | +| 审批详情 | `/approvals/instances/:instance_id` | 业务快照、业务资料、流程时间线、候选人状态和审批意见 | +| 流程定义列表 | `/settings/approval-flows` | 草稿、已发布、已停用、当前业务绑定 | +| 流程定义编辑 | `/settings/approval-flows/:id` | 串行节点有序编辑、角色/账号选择、或签/会签 | + +#### 12.2 流程定义编辑器 + +第一版不做拖拽流程画布。前端使用有序节点列表: + +```text +开始 + ↓ +[业务审核] 审批人=角色:运营主管 方式=或签 + ↓ +[财务审核] 审批人=账号:张三/李四 方式=会签 + ↓ +结束 +``` + +- 开始和结束节点固定且只读。 +- 审批节点支持新增、删除、上移、下移。 +- 角色选择器提交稳定的 `role_id`,账号选择器提交 `user_ids`。 +- 发布前先做前端基础校验,再以发布接口的后端校验结果为准。 +- 已发布版本只读;修改操作创建新版本草稿。 + +#### 12.3 待办与详情 + +- 待办列表按 `task_id` 操作,行点击进入 `instance_id` 详情。 +- 详情首屏显示业务标题、单号、提交人、关键字段和业务资料;审批人可在不跳转业务页面的前提下判断审批对象。 +- 时间线按 `tasks[]` 顺序动态渲染,不固定节点数量和名称。 +- 会签节点展示“已完成人数/总人数”;或签节点展示实际完成人和被取消候选人。 +- 每位已操作审批人分别展示动作、审批意见和附件;多人意见不得合并成一段任务级备注。 +- 通过意见可选,驳回和退回意见必填;附件最多 5 个,上传完成前禁止提交。 +- 附件使用权限校验后的短期下载 URL,页面过期后重新获取,不缓存永久地址。 +- 业务资料和审批附件使用不同下载接口和区域展示;前者来自流程发起快照,后者属于具体审批动作。 +- 操作成功后重新请求实例详情和待办列表,不做乐观推进。 +- 返回并发冲突时提示“审批状态已变化,已为你刷新”,随后重新加载详情。 +- 驳回和退回意见必填规则以后端 DTO 为准,提交期间三个操作按钮统一锁定。 + +#### 12.4 通知跳转 + +`approval.pending` 通知的 `ref_id` 使用审批实例 ID,跳转审批详情。页面加载后再由 `available_actions` 判断当前账号是否可操作,不能因通知到达过就默认拥有审批权限。 + +--- + +### 十三、发布、停用与回滚 + +1. 进入维护模式并确认相关写入口已经停止。 +2. 执行增量迁移,创建定义、实例、任务、审批人、操作日志、审批附件和 Outbox 表,并为退款、充值两个业务表增加审批关联字段。 +3. 同时发布 API、Worker/Relay 和前端,旧审批路由不再注册。 +4. 创建并发布默认流程定义,配置真实角色 ID 或账号 ID,完成退款、充值两个业务绑定。 +5. 执行存量待审批退款和员工线下充值回填命令,并核对不存在应审批但 `approval_instance_id` 为空的记录。 +6. 人工验证退款、线下充值的发起、审批、退回重提、历史记录展示和业务处理状态。 +7. 验证通过后解除维护模式。 + +开放访问前失败时,回滚应用版本和可逆迁移;开放后不得删除已发布定义、历史实例、任务、日志和 Outbox。正在处理的业务事件通过管理查询确认后再决定重试或人工处理。 + +--- + +### 十四、可观测性与风险 + +#### 14.1 必备查询维度 + +- `process_instance_id`、`task_id`、`request_id`、`operation_log_id`、`attachment_id`、`event_id`。 +- `biz_type + biz_id` 对应的全部历史实例。 +- 当前节点、候选审批人和每个审批人的操作状态。 +- Outbox 投递次数、下次重试时间和最后错误。 + +#### 14.2 关键风险 + +| 风险 | 触发条件 | 处理 | +|------|----------|------| +| 节点无审批人 | 角色无人或账号被停用 | 节点激活事务失败,记录流程配置错误,不创建空任务 | +| 两人同时完成节点 | 或签/会签临界并发 | 实例 version + 审批人条件更新,冲突方刷新 | +| 审批成功但业务未完成 | Worker 或业务处理器失败 | 业务处理状态独立展示,Asynq 重试,禁止回滚审批事实 | +| 重复事件 | Relay 或 Worker 至少一次投递 | `event_id`、业务幂等键和状态条件更新 | +| 附件越权下载 | 用户猜测或获得其他流程的附件 ID | 按流程详情权限校验,并验证附件、操作日志和流程实例的完整归属链 | +| 临时附件误用 | 猜测或获得他人临时 `file_key` | 上传记录绑定上传人、用途和 `request_id`,提交时三者必须一致 | +| 非法或孤立附件 | 上传恶意文件,或上传后未提交审批 | 服务端读取对象元数据执行白名单/大小校验;未引用对象按生命周期清理 | +| 旧路由误保留 | 发布包仍注册业务单审批或直接入账接口 | 停机验收中逐个验证旧路由为不可用,审批写操作只允许任务级 API | + +--- + +### 十五、设计模式边界 + +| 能力 | 使用方式 | +|------|---------| +| 状态机 | `ProcessInstance` 聚合维护流程和任务状态转换 | +| 策略模式 | 角色/指定账号解析、或签/会签判断 | +| 适配器模式 | 退款、充值业务处理器和后续业务接入 | +| 领域事件 | 表达任务创建、流程通过、驳回、退回等业务事实 | +| Outbox Pattern | 保证领域事件不会因 Asynq 短暂故障丢失 | +| 命令模式 | Application Command DTO 即可,不额外建立命令类体系 | +| 责任链模式 | 第一版不使用,避免把校验拆成大量小 Handler | +| 条件规则引擎 | 第一版不实现,出现真实条件分支需求后再扩展 | + +审批流的核心不是堆叠设计模式,而是保持流程定义、实例状态、审批任务和业务回调之间的边界稳定。 + + +--- + +> 汇编来源:`docs/7月迭代/前端技术方案.md` + +## 7月迭代前端技术方案 + +> 状态:待评审 +> 适用端:后台管理端、代理端、C 端 H5/公众号 +> 最后更新:2026-07-14 +> 说明:当前仓库不包含前端源码,本文定义页面、交互和接口契约;实际目录、状态库和组件名称由前端仓库现状映射,禁止据此凭空更换前端技术栈。 + +--- + +### 一、目标与边界 + +本方案解决七月迭代中跨需求的前端共性问题: + +- 审批任务、退款和充值使用同一套动态审批展示。 +- Excel 导入、批量订购和导出使用统一的异步任务交互。 +- 站内消息统一未读数、列表、已读和业务跳转。 +- 配置类页面不让用户直接编辑 JSON 或依赖前端自行校验业务规则。 +- 金额、状态、时间、权限和错误展示使用统一口径。 + +本文不指定 Vue、React、Pinia、Redux 或具体 UI 组件库。实现时必须优先复用前端仓库现有的请求封装、权限指令、上传组件、表格和轮询 Hook。 + +--- + +### 二、系统上下文 + +```mermaid +flowchart LR + AdminUser[平台/代理后台用户] --> AdminWeb[后台管理前端] + Customer[C端客户] --> ClientWeb[C端 H5/公众号] + AdminWeb -->|/api/admin/*| API[Junhong API] + ClientWeb -->|/api/c/v1/*| API + API --> DB[(PostgreSQL)] + API --> Redis[(Redis)] + API --> Queue[Asynq] + Queue --> Worker[Worker] + Worker --> APIData[业务数据/对象存储/Gateway] + AdminWeb -->|轮询未读数、任务状态| API +``` + +前端只根据 API 返回的权限、状态和可操作项渲染,不自行推导“谁能审批”“是否允许扣款”或“当前流程下一步是谁”。 + +--- + +### 三、模块边界 + +建议在现有前端目录结构中映射以下模块,不要求创建新的全局架构: + +| 模块 | 负责内容 | 不负责内容 | +|------|----------|------------| +| Approval | 待办列表、流程详情、流程定义配置、审批动作 | 退款或充值的业务表单 | +| Notification | 未读数、通知列表、标记已读、业务跳转 | 审批状态计算 | +| AsyncTask | 导入、批量订购、导出的轮询与结果展示 | 各业务文件解析 | +| Refund | 退款申请、编辑、重新提交、业务处理状态 | 动态审批节点渲染的内部规则 | +| Recharge | 代理在线充值、员工线下代充值、重新提交 | 钱包入账和审批人判断 | +| SystemConfig | 配置表单和版本刷新 | 直接编辑任意 JSON | + +全局状态只保留跨页面共享的数据:登录用户、菜单/按钮权限、通知未读数。列表数据、详情数据和表单草稿默认留在页面或模块级状态,避免把服务端状态复制到全局 Store 后长期失真。 + +--- + +### 四、接口与类型约定 + +#### 4.1 路径前缀 + +- 后台管理:`/api/admin/*` +- C 端:`/api/c/v1/*` +- 回调接口不由前端调用。 + +专项文档出现省略 `/api` 的路径时,以本节和真实路由注册为准,并应在评审前修正。 + +#### 4.2 枚举和显示 + +- 生命周期状态使用后端返回的 `status` 做逻辑判断,使用 `status_name` 做中文展示。 +- 前端不得维护另一份与后端重复的中文状态映射;只有颜色、图标等纯展示映射可以留在前端。 +- 金额 API 统一使用“分”,输入组件展示“元”,提交前做整数转换,禁止浮点数直接乘除后提交。 +- 时间统一使用后端 ISO 8601 值,展示层按现有项目时区和格式化工具处理。 + +#### 4.3 请求幂等 + +审批等敏感写操作由前端生成 `request_id`。一次用户操作从首次提交到网络重试必须复用同一个值;用户明确重新发起操作时才生成新值。 + +按钮提交后进入 loading 并禁止重复点击。前端防重只是体验控制,后端仍必须执行状态条件更新和唯一约束。 + +--- + +### 五、统一异步任务交互 + +适用:设备批量分配、批量订购、导出任务。 + +不适用于临期状态:临期列表、详情和首页数量由接口实时 SQL 计算;每日任务只生成 15/7/3 天通知,前端不轮询或维护临期快照。 + +```mermaid +stateDiagram-v2 + [*] --> Editing: 填写参数/选择文件 + Editing --> Submitting: 提交 + Submitting --> Processing: 创建任务成功 + Submitting --> Editing: 参数或上传失败 + Processing --> Processing: 轮询进度 + Processing --> Completed: 全部或部分完成 + Processing --> Failed: 任务失败 + Processing --> Cancelled: 用户取消且后端确认 + Completed --> [*] + Failed --> Editing: 修正后重新提交 + Cancelled --> [*] +``` + +#### 5.1 轮询规则 + +- 创建成功后立即请求一次详情,再按 2 秒、3 秒、5 秒逐步退避,最大间隔 10 秒。 +- 页面不可见时暂停轮询,恢复可见时立即刷新。 +- 达到终态、离开页面或组件销毁时停止轮询。 +- 连续网络失败不把业务任务标记为失败,展示“状态获取失败,点击重试”。 +- 服务端返回 `retry_after_seconds` 时优先采用服务端建议。 + +#### 5.2 结果展示 + +- 必须同时展示总数、成功数、失败数和任务状态。 +- 部分成功不能只弹一个成功 Toast;失败明细要留在页面,并支持下载或复制,具体能力按专项方案。 +- 导出任务完成后展示下载按钮和链接过期时间;链接过期时重新获取任务详情,不重新创建导出任务。 + +#### 5.3 Excel 模板 + +- 设备批量分配、批量订购等 Excel 模板由前端作为静态资源维护,后端不提供模板下载 API。 +- 模板文件名包含版本号,下载入口与对应上传表单放在同一页面。 +- 后端仍必须严格校验表头和内容,不能因为模板由前端提供就信任文件结构。 +- 模板字段变更需要前后端同批发布,并保留对用户本地旧模板的可理解错误提示。 + +--- + +### 六、统一审批交互 + +```mermaid +stateDiagram-v2 + [*] --> Loading + Loading --> ReadOnly: 当前用户无待处理资格 + Loading --> Actionable: 当前用户是待处理审批人 + Actionable --> EditingAction: 打开通过/驳回/退回弹框 + EditingAction --> Uploading: 上传附件 + Uploading --> EditingAction: 上传成功或失败后返回 + EditingAction --> Submitting: 意见和附件校验通过 + Submitting --> EditingAction: 请求失败且任务仍可操作 + Submitting --> ReadOnly: 操作成功或状态已被他人改变 + ReadOnly --> [*] +``` + +#### 6.1 页面建议 + +| 页面 | 建议路由 | 主要能力 | +|------|----------|----------| +| 待我审批 | `/approvals/tasks` | 状态、业务类型、提交人、当前节点、提交时间筛选 | +| 审批详情 | `/approvals/instances/:instance_id` | 业务快照、业务资料、动态节点时间线、审批人和意见、当前可操作按钮 | +| 流程定义 | `/settings/approval-flows` | 草稿、发布版本、停用、业务绑定 | +| 流程定义编辑 | `/settings/approval-flows/:id` | 串行节点配置、角色/账号选择、或签/会签、发布前校验 | + +第一版只支持串行节点,前端使用“有序节点列表编辑器”,不建设拖拽 DAG/BPMN 设计器。节点可上移、下移、增加和删除;开始、结束节点由系统生成且不可删除。 + +#### 6.2 动态详情 + +审批详情按接口返回的 `tasks[]` 和 `assignees[]` 渲染,禁止写死“部门领导”和“财务”两个字段。 + +详情首屏必须先渲染 `business_snapshot`:业务标题、业务单号、提交人、审批关键字段和业务资料。业务资料来自发起时快照,审批附件来自某位审批人的操作记录,页面用两个区域展示;审批人不需要跳转到实时业务页才能知道正在审批什么。 + +操作按钮显示条件同时满足: + +- 流程状态为审批中。 +- 当前任务状态为待审批。 +- 当前账号对应的审批人状态为待处理。 +- 接口返回相应操作权限。 + +操作成功后重新请求审批实例和关联业务详情,不做乐观状态推进。 + +审批详情可返回 `action_form.fields`。前端只渲染后端声明的受控字段类型;退款金额和操作密码均由流程节点配置决定,且只有当前动作会完成该节点时才出现。金额展示元、提交分;操作密码不写入全局 Store、本地存储或重试缓存,请求结束立即清空。节点没有动作字段时不显示额外表单,禁止根据“财务审核”等节点名称自行添加输入项。 + +每个通过、驳回、退回弹框统一包含“审批意见”和“附件”: + +- 通过意见可选;驳回、退回意见必填,最多 1000 字。 +- 意见使用普通多行文本框,不提供富文本编辑器。 +- 附件最多 5 个、单个默认不超过 20MB,允许图片、PDF、Word、Excel;打开动作弹框时先生成 `request_id`,申请 `purpose=approval_attachment` 且绑定该 `request_id` 的上传凭证,上传完成后提交 `file_key + file_name`。前端校验只用于体验,最终以后端对象元数据和上传归属校验为准。 +- 文件仍在上传时禁止提交审批;删除尚未提交的附件只影响本地表单,不调用删除历史审批附件。 +- 网络重试复用原 `request_id`、意见和附件集合;操作成功后清空本地表单。 +- 时间线按审批人展示各自意见和附件。审批附件和业务资料分别通过审批实例权限校验接口换取短期 URL,不直接拼对象存储地址。 + +#### 6.3 业务处理中的展示 + +审批通过与退款到账、钱包入账不是同一时刻。退款和充值详情必须同时展示: + +- 审批状态。 +- 业务处理状态。 +- 业务处理失败原因和“系统重试中/联系管理员”的提示;非代理钱包退款审批通过后显示“待人工退款”,仅财务确认权限可见确认完成入口。 + +不能仅根据业务单原状态显示“待审批”。 + +#### 6.4 存量历史记录 + +业务详情接口增加 `approval_source`: + +- `none`:当前业务不需要审批,例如代理在线充值;隐藏审批区域。 +- `workflow`:存在通用审批实例,展示动态时间线和 `available_actions`。 +- `legacy`:发布前已经结束的历史记录,没有完整流程实例;只读展示原业务状态、旧审批摘要和审计信息,不生成虚假节点。 + +如果一条仍需审批的退款或员工线下充值返回 `approval_instance_id=null`,前端按数据迁移异常展示并禁止任何审批操作,不能退回旧业务单审批接口。 + +--- + +### 七、页面与需求映射 + +| 需求 | 端 | 页面/入口 | 关键交互 | +|------|----|-----------|----------| +| 01 | 后台 | 资产详情复机操作 | 界面不变,展示后端真实结果 | +| 02 | 后台 + C端 | 卡/设备列表实名策略;C端流程页 | 单条/批量设置,C端按接口策略跳转 | +| 03/07/11/12/13 | 后台 | 原有列表和详情 | 新筛选、新字段、动态审批摘要 | +| 05 | 后台 | 套餐分配弹框、已分配列表 | 生效条件覆盖和修改提示 | +| 08 | 后台 | 设备管理批量操作 | “批量分配代理”和“批量分配套餐系列”两个入口,上传、任务轮询、失败明细 | +| 09 | 后台 + C端 | 系统配置;支付页 | 后台配置支付方式,C端隐藏并由后端兜底拦截 | +| 10 | 后台 | 卡/设备资产详情 | 手动设置或取消当前卡限速,单位 `kbps`,不提供套餐限速配置 | +| 14 | 后台 | 各业务列表导出 | 字段选择、权限过滤、统一导出任务 | +| 15 | C端 + 后台 | 当前套餐卡片、后台代购 | 当前套餐旁“续费”复用购买流程;下架套餐仅在合法续费入口展示 | +| 16 | - | 已移出 7 月迭代 | 不建设分销码、代理申请、提现材料和相关审批页面 | +| 17 | 后台/代理端 | 代理信用、钱包详情 | 元/分转换、欠款状态、额度权限 | +| 18/20/21 | 后台 | 待办、退款、充值详情 | 动态审批时间线、处理状态、退回重提 | +| 19 | 后台 | 批量订购 | 参数确认、上传、逐行结果和金额汇总 | +| 22 | 后台 + 代理端 + C端 | 临期列表、首页提醒 | 15/7/3 天分级、续费跳转、到期后自动消失 | + +--- + +### 八、站内消息 + +- 登录后和进入后台布局时立即获取未读数,之后每 30 秒轮询。 +- 页面不可见时暂停,恢复时立即刷新。 +- 铃铛数字超过 99 显示 `99+`。 +- 通知点击只按受控的 `ref_type + ref_id` 路由表跳转,不接受后端返回任意 URL。 +- 标记已读失败不阻止查看业务详情,但需要在下次轮询时恢复真实未读状态。 +- 通知正文按纯文本展示;若未来支持富文本,必须使用受控模板和统一净化,不直接渲染任意 HTML。 + +--- + +### 九、权限与敏感数据 + +- 菜单和按钮根据登录返回的权限控制可见性,但后端权限校验是最终依据。 +- 审批人资格由任务接口返回,前端不根据角色名称推导。 +- 导出字段由后端返回允许字段集合;前端只能在允许集合中选择。 +- 退款凭证、充值凭证、身份证、营业执照和审批附件使用现有对象存储上传流程,不提交本地路径或长期公开 URL。审批附件额外提交清理后的原文件名作为展示快照。 +- 列表和详情对无权限与不存在统一展示,避免通过前端文案泄露资源存在性。 + +--- + +### 十、停机发布 + +1. 发布前进入维护模式,前端统一展示维护页并停止提交写请求。 +2. 维护窗口内执行数据库迁移,同时发布 API、Worker/Relay 和前端静态资源。 +3. 初始化并启用退款、充值流程定义和业务绑定,执行存量待审批单回填。 +4. 前端只调用任务级审批 API,不保留退款或线下充值的业务单级通过/驳回按钮,也不保留线下充值“确认入账”按钮。 +5. 人工验证登录、菜单权限、流程发起、存量历史展示、待办、审批详情、退回重提和业务处理状态。 +6. 验证通过后解除维护模式;失败则在开放访问前回滚整套应用版本。 + +前端必须容忍新增响应字段且不依赖字段顺序,但本次不要求支持旧后端与新前端或新后端与旧前端交叉运行。 + +--- + +### 十一、待前端仓库确认 + +以下内容不影响当前接口和交互评审,但实施前必须在前端仓库确认: + +- 后台、代理端和 C 端分别使用的框架版本与目录结构。 +- 现有权限指令、请求封装、上传组件和导出 Hook 的真实名称。 +- 是否已有通用任务轮询组件和流程时间线组件。 +- 实际菜单路由和按钮权限编码。 +- 表格是否支持服务端返回的动态导出字段配置。 + +确认后只补充实现映射,不改变本文已经评审通过的业务状态和 API 契约。 + + +--- + +> 汇编来源:`docs/7月迭代/需求01-复机实名规则.md` + +## 需求01:行业卡后台手动复机允许未实名 + +> 状态:待评审 + +--- + +### 背景 + +**当前代码**:`internal/service/iot_card/stop_resume_service.go:938` + +```go +// ManualStartCard - 当前写法(错误) +if card.RealNameStatus != constants.RealNameStatusVerified { + return errors.New(errors.CodeForbidden, "卡未实名,无法操作") +} +``` + +`isRealnameOK()` 已经正确处理行业卡豁免: + +```go +// 第234行:行业卡无需实名 +func (s *StopResumeService) isRealnameOK(card *model.IotCard) bool { + return card.CardCategory == constants.CardCategoryIndustry || + card.RealNameStatus == constants.RealNameStatusVerified +} +``` + +但 `ManualStartCard` 没有走这个函数,直接判断了 `RealNameStatus`,导致行业卡手动复机也被拦截。 + +--- + +### 修改范围 + +**只改一行**,影响范围极小。 + +**文件**:`internal/service/iot_card/stop_resume_service.go` + +```go +// 修改前(第938行) +if card.RealNameStatus != constants.RealNameStatusVerified { + denyErr := errors.New(errors.CodeForbidden, "卡未实名,无法操作") + ... +} + +// 修改后 +if !s.isRealnameOK(card) { + denyErr := errors.New(errors.CodeForbidden, "卡未实名,无法操作") + ... +} +``` + +--- + +### 前端对接 + +无需前端改动。复机操作界面不变,行业卡原先会报错"卡未实名,无法操作",修复后直接成功。 + + +--- + +> 汇编来源:`docs/7月迭代/需求02-H5流程配置.md` + +## 需求02:H5 流程顺序配置化 + +> 状态:待评审 + +--- + +### 背景 + +H5 用户进入后:绑定手机号 → 充值 → 实名(当前顺序写死) + +需求:允许**按资产个体**配置充值和实名的顺序,支持后台单条或批量改。 + +--- + +### 流程图 + +#### 图一:H5 登录后资产视角判断与策略读取 + +```mermaid +flowchart TD + A[用户扫码 / 输入虚拟号] --> B[解析 identifier] + B --> C{资产类型} + + C -->|card| D{该卡是否绑定设备?} + D -->|否 独立卡| E[卡视角\n读 IotCard.realname_policy] + D -->|是| F[设备视角\n读 Device.realname_policy\n卡自身策略忽略] + + C -->|device| F + + E --> G{realname_policy} + F --> G + + G -->|none| H[无需实名\n直接进充值页] + G -->|before_order| I[先进实名页\n实名完成后才能充值] + G -->|after_order| J[先进充值页\n充值完成后提示实名] +``` + +#### 图二:H5 充值/购买前的策略拦截逻辑 + +```mermaid +flowchart TD + A[用户发起充值/购买] --> B[读取 resolved.Asset.RealnamePolicy] + B --> C{策略是 before_order?} + C -->|否| D[放行,正常创建订单] + C -->|是| E{当前资产 RealNameStatus == 1?} + E -->|已实名| D + E -->|未实名| F[返回 CodeNeedRealname\nH5 跳转实名页] +``` + +#### 图三:GetEffectiveRealnamePolicy 取值逻辑 + +```mermaid +flowchart TD + A[GetEffectiveRealnamePolicy\ncard, device] --> B{device != nil?} + B -->|是| C[返回 device.RealnamePolicy] + B -->|否| D{card != nil?} + D -->|是| E[返回 card.RealnamePolicy] + D -->|否| F[返回 none] +``` + +--- + +### 资产类型与策略归属 + +系统有两类资产:**独立卡** 和 **设备**。 + +| 资产类型 | realname_policy 归属 | H5 视角 | +|---------|---------------------|---------| +| 独立卡(无设备绑定) | 卡自身的 `realname_policy` | 卡视角 | +| 设备 | 设备自身的 `realname_policy` | 设备视角 | +| 设备下的卡 | **设备**的 `realname_policy`(卡自身策略无效) | 设备视角 | + +**关键规则**:设备下的卡无法以卡视角独立登录 H5,登录后自动进入设备视角,因此实名流程策略由设备决定,卡自身的 `realname_policy` 字段对 H5 流程无影响(但字段保留,仅作记录)。 + +该逻辑已在 `internal/service/asset/service.go:GetEffectiveRealnamePolicy()` 实现:设备不为 nil 时取设备策略,否则取卡策略。 + +--- + +### 当前字段状态 + +两个模型都已有 `realname_policy` 字段,无需迁移: + +```go +// internal/model/iot_card.go +RealnamePolicy string `gorm:"column:realname_policy;type:varchar(20);default:'after_order';not null; + comment:实名认证策略(none=无需实名,before_order=先实名后充值/购买,after_order=先充值/购买后实名)"` + +// internal/model/device.go +RealnamePolicy string `gorm:"column:realname_policy;type:varchar(20);default:'after_order';not null; + comment:实名认证策略(none=无需实名,before_order=先实名后充值/购买,after_order=先充值/购买后实名)"` +``` + +值含义: +- `none` — 无需实名 +- `before_order` — 先实名后充值 +- `after_order` — 先充值后实名(**当前默认**) + +--- + +### 后端实现 + +#### 1. 单条修改接口(已有,无需新建) + +``` +PATCH /api/admin/assets/:identifier/realname-mode +``` + +该接口已在 `internal/handler/admin/asset.go:UpdateRealnamePolicy()` 实现,通过 identifier 自动解析资产类型,卡和设备都走这里,**不需要再建卡专属或设备专属路由**。 + +请求体(已有 DTO): +```go +type UpdateAssetRealnamePolicyRequest struct { + RealnamePolicy string `json:"realname_policy" validate:"required,oneof=none before_order after_order" + description:"实名策略 (none:无需实名, before_order:先实名后充值, after_order:先充值后实名)"` +} +``` + +#### 2. 新增批量修改接口(需新建) + +卡和设备分开批量接口,因为两者在后台是不同的列表页。 + +##### 2a. 批量修改卡实名策略 + +``` +POST /api/admin/iot-cards/batch-update-realname-policy +``` + +请求体: +```go +type BatchUpdateIotCardRealnamePolicy struct { + IotCardIDs []uint `json:"iot_card_ids" validate:"required,min=1,max=500,dive,gt=0" description:"卡ID列表(最多500条)"` + RealnamePolicy string `json:"realname_policy" validate:"required,oneof=none before_order after_order" + description:"实名策略 (none:无需实名, before_order:先实名后充值, after_order:先充值后实名)"` +} +``` + +Service:在一个事务中校验最多 500 条 ID 均存在且均在当前账号数据范围内,再执行 `UPDATE tb_iot_card SET realname_policy = ? WHERE id IN (...)`。任一记录不合法则整批回滚,并写一条包含目标策略和 ID 数量的批量审计日志。 + +##### 2b. 批量修改设备实名策略 + +``` +POST /api/admin/devices/batch-update-realname-policy +``` + +请求体: +```go +type BatchUpdateDeviceRealnamePolicy struct { + DeviceIDs []uint `json:"device_ids" validate:"required,min=1,max=500,dive,gt=0" description:"设备ID列表(最多500条)"` + RealnamePolicy string `json:"realname_policy" validate:"required,oneof=none before_order after_order" + description:"实名策略 (none:无需实名, before_order:先实名后充值, after_order:先充值后实名)"` +} +``` + +Service:与卡批量接口相同,单次最多 500 条、事务内全成全败;任一设备不存在或越权则不更新任何记录。 + +#### 3. 全局默认值(兜底) + +新建卡/设备时默认 `after_order`,通过 GORM default 标签保证,不需要读 `tb_system_config`。 + +--- + +### 前端对接 + +#### 卡列表页 + +"操作"列或批量操作下拉增加"设置实名策略": + +- **单条**:弹框选策略 → `PATCH /api/admin/assets/{iccid}/realname-mode` +- **批量**:勾选多条 → 批量操作 → "设置实名策略" → `POST /api/admin/iot-cards/batch-update-realname-policy` + +> 注意:设备下的卡即使在卡列表中修改了策略,对 H5 流程也无效(H5 取设备策略)。建议在卡列表展示"所属设备"列,提示运营该卡已属于某设备,实名策略需到设备处修改。 + +#### 设备列表页 + +"操作"列或批量操作下拉增加"设置实名策略": + +- **单条**:弹框选策略 → `PATCH /api/admin/assets/{sn}/realname-mode` +- **批量**:勾选多条 → 批量操作 → "设置实名策略" → `POST /api/admin/devices/batch-update-realname-policy` + +#### 字段展示 + +卡列表/详情、设备列表/详情均展示"实名策略"字段: + +| realname_policy | 展示文案 | +|----------------|---------| +| `none` | 无需实名 | +| `before_order` | 先实名后充值 | +| `after_order` | 先充值后实名 | + +#### H5 侧(C端) + +H5 读取资产初始化接口返回的 `realname_policy` 字段,决定先跳充值页还是先跳实名页。 + +- 独立卡登录 → 读卡的 `realname_policy` +- 设备/设备下的卡登录 → 读设备的 `realname_policy` + +该逻辑由 `GetEffectiveRealnamePolicy()` 统一处理,H5 无需区分资产类型,直接用接口返回值即可。 + +--- + +### 实施范围汇总 + +| 项目 | 状态 | 说明 | +|------|------|------| +| `IotCard.realname_policy` 字段 | ✅ 已有 | 无需迁移 | +| `Device.realname_policy` 字段 | ✅ 已有 | 无需迁移 | +| 单条修改接口 | ✅ 已有 | `PATCH /api/admin/assets/:identifier/realname-mode` | +| `GetEffectiveRealnamePolicy()` | ✅ 已有 | 设备视角取设备策略 | +| H5 充值前校验 | ✅ 已有 | `client_wallet.go` 已正确读取 | +| 批量修改卡接口 | ❌ 待建 | `POST /api/admin/iot-cards/batch-update-realname-policy` | +| 批量修改设备接口 | ❌ 待建 | `POST /api/admin/devices/batch-update-realname-policy` | +| 后台卡列表操作入口 | ❌ 待建(前端) | 单条+批量 | +| 后台设备列表操作入口 | ❌ 待建(前端) | 单条+批量 | + + +--- + +> 汇编来源:`docs/7月迭代/需求03-07-11-12-13-简单改动.md` + +## 需求03/07/11/12/13:简单改动合集 + +> 状态:待评审 + +--- + +### 需求03:店铺列表搜索新增联系电话 + +#### 后端 + +`Shop` 表已有 `contact_phone` 字段。仅需在列表查询接口新增过滤条件。 + +**文件**:`internal/store/postgres/shop_store.go`(列表查询 Store 方法) + +```go +// 现有过滤条件基础上追加 +if req.ContactPhone != "" { + query = query.Where("contact_phone = ?", req.ContactPhone) +} +``` + +**DTO 变更**:`internal/model/dto/shop_dto.go` 的 `ShopListRequest` 新增: + +```go +ContactPhone string `json:"contact_phone" query:"contact_phone" validate:"omitempty,len=11" minLength:"11" maxLength:"11" description:"联系人电话(精确匹配,11位)"` +``` + +#### 前端 + +店铺列表搜索栏新增"联系电话"输入框,填入后带入 `contact_phone` 参数请求。 + +--- + +### 需求07:IoT卡/设备管理新增已实名/未实名筛选 + +#### IoT 卡 + +`IotCard.real_name_status` 已有(0=未实名, 1=已实名),`ListStandaloneIotCardRequest` 无该过滤字段,需新增。 + +**DTO 变更**(`internal/model/dto/iot_card_dto.go` → `ListStandaloneIotCardRequest` 新增): + +```go +RealNameStatus *int `json:"real_name_status" query:"real_name_status" validate:"omitempty,oneof=0 1" description:"实名状态 (0:未实名, 1:已实名)"` +``` + +**Store 追加**(`internal/store/postgres/iot_card_store.go`): +```go +if req.RealNameStatus != nil { + query = query.Where("real_name_status = ?", *req.RealNameStatus) +} +``` + +#### 设备 + +设备本身目前无 `real_name_status` 字段。语义为:任意一张绑定卡已实名 = 设备已实名。 + +为避免列表查询时走 EXISTS 子查询,改为**快照方案**:在 `Device` 表落盘,轮询时维护。 + +##### 迁移 + +`tb_device` 新增字段: + +```sql +ALTER TABLE tb_device + ADD COLUMN real_name_status INT NOT NULL DEFAULT 0; + +COMMENT ON COLUMN tb_device.real_name_status + IS '实名状态快照(0=未实名,1=已实名),任意绑定卡已实名则为1,由轮询异步维护'; +``` + +**Model**(`internal/model/device.go`): +```go +RealNameStatus int `gorm:"column:real_name_status;type:int;default:0;not null;comment:实名状态快照(0=未实名,1=已实名),任意绑定卡已实名则为1" json:"real_name_status"` +``` + +##### 快照更新时机 + +以下两处卡实名状态变化时,需同步更新所属设备的快照: + +**1. 轮询实名处理**(`internal/task/polling_realname_handler.go`) + +卡状态变化后,已有 `triggerDeviceRealnameActivation` 查出 `deviceID`,在此同步更新设备快照: + +```go +// statusChanged 时,如果卡属于某设备,重新计算并写入设备快照 +if statusChanged { + if binding, err := h.deviceSimBindingStore.GetActiveBindingByCardID(ctx, cardID); err == nil { + h.deviceStore.RefreshRealnameSnapshot(ctx, binding.DeviceID) + } +} +``` + +**2. 管理员手动修改卡实名状态**(`internal/service/iot_card/service.go:ManualUpdateRealnameStatus`) + +更新卡状态成功后,查所属设备并更新快照(同上逻辑)。 + +##### 快照计算 + +`DeviceStore.RefreshRealnameSnapshot`: + +```go +// RefreshRealnameSnapshot 重新计算并写入设备实名状态快照 +func (s *DeviceStore) RefreshRealnameSnapshot(ctx context.Context, deviceID uint) error { + var count int64 + s.db.WithContext(ctx).Raw(` + SELECT COUNT(*) FROM tb_device_sim_binding dsb + JOIN tb_iot_card ic ON ic.id = dsb.iot_card_id + WHERE dsb.device_id = ? AND dsb.deleted_at IS NULL + AND ic.real_name_status = 1 AND ic.deleted_at IS NULL + `, deviceID).Scan(&count) + status := 0 + if count > 0 { + status = 1 + } + return s.db.WithContext(ctx).Model(&model.Device{}). + Where("id = ?", deviceID). + Update("real_name_status", status).Error +} +``` + +##### DTO 变更 + +**请求**(`internal/model/dto/device_dto.go` → `ListDeviceRequest` 新增): +```go +RealNameStatus *int `json:"real_name_status" query:"real_name_status" validate:"omitempty,oneof=0 1" description:"实名状态 (0:未实名, 1:已实名)"` +``` + +**响应**(`DeviceResponse` 新增): +```go +RealNameStatus int `json:"real_name_status" description:"实名状态 (0:未实名, 1:已实名)"` +RealNameStatusName string `json:"real_name_status_name" description:"实名状态名称(中文)"` +``` + +**Store 过滤**(直接 WHERE,无需 EXISTS): +```go +if req.RealNameStatus != nil { + query = query.Where("real_name_status = ?", *req.RealNameStatus) +} +``` + +#### 前端 + +IoT卡管理筛选栏新增"实名状态"下拉(全部/已实名/未实名)→ 传 `real_name_status=0|1`。 +设备管理同上,列表展示 `real_name_status_name` 字段。 + +--- + +### 需求11:资产详情-套餐到期时间字段 + 15天高亮 + +#### 后端 + +**无需改动。** + +后台资产详情页实际调用的是: + +``` +GET /api/admin/assets/resolve/:identifier +``` + +该接口的 `AssetResolveResponse`(`internal/model/dto/asset_dto.go`)已包含: + +```go +CurrentPackage string `json:"current_package"` // 当前套餐名称 +CurrentPackageActivatedAt *time.Time `json:"current_package_activated_at"` // 开始时间 +CurrentPackageExpiresAt *time.Time `json:"current_package_expires_at"` // 到期时间(无套餐为 null) +``` + +到期时间字段已有,前端直接读 `current_package_expires_at` 即可,不需要新增后端字段。 + +#### 前端 + +资产详情页"套餐信息"板块展示到期时间,并在剩余 ≤15 天时高亮: + +- 读取 `resolve` 接口返回的 `current_package_expires_at` +- 若为 `null`:展示"暂无套餐" +- 剩余天数由前端计算:`Math.ceil((expiresAt - now) / 86400000)` +- 剩余 ≤15 天:**红色/高亮**展示(建议红色文字 + 标签) + +--- + +### 需求12:换货管理显示修复 + +#### 背景 + +换货单表 `tb_exchange_order`: +- `old_asset_identifier` — 旧资产标识符快照 +- `new_asset_identifier` — 新资产标识符快照 +- `old_asset_id` / `new_asset_id` — 旧/新资产主键 + +#### EXC-001/EXC-002:旧/新资产标识显示不一致 + +**根本原因**:后端创建换货单时快照逻辑有误(`internal/service/exchange/service.go`)。 + +- 卡的旧资产:快照了 `card.VirtualNo`(虚拟号),**应为 `card.ICCID`** +- 卡的新资产:快照了操作员输入的 identifier 原值,未规范化,**应统一为 `card.ICCID`** +- 设备:快照 `VirtualNo` 优先,没有则 `IMEI`,**逻辑正确,无需改动** + +**修复**(`internal/service/exchange/service.go`): + +`resolveAssetByIdentifierWithTx` 及锁定资产路径中,卡的 `Identifier` 改为 `card.ICCID`: + +```go +// 修复前 +return &resolvedExchangeAsset{..., Identifier: card.VirtualNo, ...} + +// 修复后 +return &resolvedExchangeAsset{..., Identifier: card.ICCID, ...} +``` + +历史数据不回填,仅修正后续新建换货单的快照行为。 + +#### EXC-003/EXC-004:旧/新资产搜索支持 ICCID/接入号/虚拟号 + +**方案**:拆分为独立的旧资产和新资产搜索,搜索逻辑用**两步查询**,不用 JOIN。 + +**DTO 变更**(`internal/model/dto/exchange_dto.go` → `ExchangeListRequest`): + +废弃原有 `Identifier` 字段,改为: +```go +OldAssetKeyword string `json:"old_asset_keyword" query:"old_asset_keyword" validate:"omitempty,max=100" description:"旧资产搜索(ICCID/接入号/虚拟号)"` +NewAssetKeyword string `json:"new_asset_keyword" query:"new_asset_keyword" validate:"omitempty,max=100" description:"新资产搜索(ICCID/接入号/虚拟号)"` +``` + +**Store 修改**(`internal/store/postgres/exchange_order_store.go`): + +两步查询——先在资产表搜出 ID,再过滤换货表: + +```go +// 步骤1:旧资产关键词搜索 +if req.OldAssetKeyword != "" { + kw := "%" + req.OldAssetKeyword + "%" + var cardIDs []uint + s.db.WithContext(ctx).Table("tb_iot_card"). + Where("(iccid LIKE ? OR virtual_no LIKE ? OR msisdn LIKE ?) AND deleted_at IS NULL", kw, kw, kw). + Pluck("id", &cardIDs) + var deviceIDs []uint + s.db.WithContext(ctx).Table("tb_device"). + Where("(virtual_no LIKE ? OR imei LIKE ?) AND deleted_at IS NULL", kw, kw). + Pluck("id", &deviceIDs) + + if len(cardIDs) == 0 && len(deviceIDs) == 0 { + return &ExchangeListResult{}, nil // 无匹配,直接返回空 + } + query = query.Where( + "(old_asset_type = 'iot_card' AND old_asset_id IN ?) OR (old_asset_type = 'device' AND old_asset_id IN ?)", + cardIDs, deviceIDs, + ) +} +// new_asset_keyword 同理,过滤 new_asset_id +``` + +#### 前端 + +- EXC-001/002:后端修复后,`old_asset_identifier` 和 `new_asset_identifier` 均为 ICCID(卡)或设备号(设备),展示直接读这两个字段即可 +- EXC-003/004:搜索栏拆分为"旧资产"和"新资产"两个独立输入框,分别传 `old_asset_keyword` 和 `new_asset_keyword` + +--- + +### 需求13:列表字段新增 + +#### 核心原则 + +- 提交人账号名在业务单创建时快照到业务表。 +- 审批节点、候选审批人和实际操作人快照统一保存在审批流任务表,不在业务表写死具体节点字段。 +- 列表查询审批信息时,根据本页全部 `approval_instance_id` 批量查询并在内存分组,禁止逐条查询造成 N+1。 + +--- + +#### COL-003:换货管理列表新增提交人(待建) + +> 需求文档原写"换号管理",确认为"换货管理"(系统无"换号"概念)。 + +**迁移**:`tb_exchange_order` 新增字段: +```sql +ALTER TABLE tb_exchange_order ADD COLUMN submitter_name varchar(50) NOT NULL DEFAULT ''; +``` + +**Model**(`internal/model/exchange_order.go`): +```go +SubmitterName string `gorm:"column:submitter_name;type:varchar(50);not null;default:'';comment:提交人账号名快照" json:"submitter_name"` +``` + +**创建换货单时**(`internal/service/exchange/service.go`)快照当前操作人 username: +```go +SubmitterName: middleware.GetUsername(ctx), // 从 ctx 取当前登录账号的 username +``` + +**响应 DTO**(`internal/model/dto/exchange_dto.go` → `ExchangeOrderResponse` 新增): +```go +SubmitterName string `json:"submitter_name" description:"提交人账号名"` +``` + +--- + +#### COL-001:退款管理列表新增提交人、审批人(依赖审批流) + +**迁移**:`tb_refund_request` 新增提交人快照字段;`approval_instance_id` 由需求18/20统一增加: +```sql +ALTER TABLE tb_refund_request + ADD COLUMN submitter_name varchar(50) NOT NULL DEFAULT ''; +``` + +- `submitter_name`:创建退款单时快照操作人 username +- 审批状态、当前节点和审批记录:从审批实例、任务和任务审批人快照批量读取 + +> **实施依赖**:动态审批摘要依赖审批流(需求20);`submitter_name` 可独立实现。 + +**响应 DTO**(退款列表响应新增): +```go +SubmitterName string `json:"submitter_name" description:"提交人账号名"` +ApprovalSource string `json:"approval_source" description:"审批来源 (none:无需审批, workflow:通用审批流, legacy:历史业务审批)"` +ApprovalStatus int `json:"approval_status" description:"审批状态 (1:审批中, 2:已通过, 3:已驳回, 4:已退回)"` +ApprovalStatusName string `json:"approval_status_name" description:"审批状态名称(中文)"` +CurrentApprovalNode string `json:"current_approval_node" description:"当前审批节点名称"` +ApprovalRecords []ApprovalRecordSummary `json:"approval_records" description:"审批节点和审批人摘要"` +ProcessingStatus int `json:"processing_status" description:"审批通过后的业务处理状态"` +ProcessingStatusName string `json:"processing_status_name" description:"业务处理状态名称(中文)"` +``` + +`ApprovalRecordSummary` 动态返回 `node_name`、`approval_mode`、`status` 和审批人列表;每位已操作审批人包含动作、审批意见和 `attachment_count`,但列表接口不返回完整附件元数据。不假设固定存在“部门领导”或“财务”节点。 + +停机发布前已经结束且没有流程实例的退款记录返回 `approval_source=legacy`。这类记录可以使用原 `processor_id`、`processed_at` 和审计日志组成只读历史摘要,但不得伪造多节点审批时间线;发布时仍待审批的记录必须先回填通用审批实例。 + +--- + +#### COL-002:代理充值列表新增提交人、审批人(依赖审批流) + +与 COL-001 同理,`tb_agent_recharge_record` 仅新增 `submitter_name` 快照字段;`approval_instance_id` 由需求18/21统一增加。审批摘要从审批流批量读取。历史终态充值返回 `approval_source=legacy` 并只读展示原状态和审计信息。 + +> **实施依赖**:`submitter_name` 本迭代可实现;动态审批摘要依赖需求21(充值审批流)。 + +--- + +#### 前端 + +退款和充值列表增加“审批状态 / 当前节点 / 业务处理状态 / 审批记录”展示。审批记录按节点动态渲染,不能固定绑定两个审批人字段;审批已通过后的代理钱包退款可显示“回退处理中”,其他支付方式显示“待人工退款”,都不能显示成“待审批”。`approval_source=legacy` 时显示“历史审批”标识且不提供操作按钮。 + + +--- + +> 汇编来源:`docs/7月迭代/需求04-06-退款拦截与最后到期时间.md` + +## 需求04:退款中资产禁止换货 +## 需求06:资产最后到期时间 + +> 状态:待评审 + +--- + +### 需求04:退款中禁止换货 + +#### 业务规则 + +资产存在**未结束**的退款申请时,不允许操作换货,提示"该资产存在退款申请,无法操作换货"。 + +拦截范围: + +- `status=1` 待审批。 +- `status=4` 已退回,等待提交人修改。 +- `status=2` 已通过但 `processing_status!=2`,实际退款仍在处理、等待处理或失败重试。 + +不拦截:`status=3` 已拒绝,或 `status=2 AND processing_status=2` 已完成实际退款。`processing_status` 由需求20新增。 + +#### 数据模型 + +退款模型:`RefundRequest`(表 `tb_refund_request`) + +资产字段为两个独立字段(无 asset_type/asset_id): +- `iot_card_id *uint`:IoT卡ID(卡类资产) +- `device_id *uint`:设备ID(设备类资产) + +状态常量(`internal/model/refund.go`): +```go +RefundStatusPending = 1 // 待审批 +RefundStatusApproved = 2 // 已通过 +RefundStatusRejected = 3 // 已拒绝 +RefundStatusReturned = 4 // 已退回(退回给提交人,仍拦截换货) +``` + +#### 实现位置 + +换货单创建入口:`internal/service/exchange/service.go` 创建前校验。 + +#### 后端 + +**Store 新增方法**(`internal/store/postgres/refund_store.go`): + +```go +// HasActiveRefundByCard 检查指定IoT卡是否存在未结束的退款申请 +func (s *RefundStore) HasActiveRefundByCard(ctx context.Context, cardID uint) (bool, error) { + var count int64 + err := s.db.WithContext(ctx).Model(&model.RefundRequest{}). + Where(`iot_card_id = ? + AND deleted_at IS NULL + AND ( + status IN (?, ?) + OR (status = ? AND processing_status <> ?) + )`, + cardID, + model.RefundStatusPending, // 1=待审批 + model.RefundStatusReturned, // 4=已退回(拦截) + model.RefundStatusApproved, // 2=审批已通过 + constants.ProcessingStatusSucceeded, + ).Count(&count).Error + return count > 0, err +} + +// HasActiveRefundByDevice 检查指定设备是否存在未结束的退款申请 +func (s *RefundStore) HasActiveRefundByDevice(ctx context.Context, deviceID uint) (bool, error) { + var count int64 + err := s.db.WithContext(ctx).Model(&model.RefundRequest{}). + Where(`device_id = ? + AND deleted_at IS NULL + AND ( + status IN (?, ?) + OR (status = ? AND processing_status <> ?) + )`, + deviceID, + model.RefundStatusPending, // 1=待审批 + model.RefundStatusReturned, // 4=已退回(拦截) + model.RefundStatusApproved, // 2=审批已通过 + constants.ProcessingStatusSucceeded, + ).Count(&count).Error + return count > 0, err +} +``` + +**Service 校验**(`internal/service/exchange/service.go` 创建换货单前调用): + +```go +// validateNoActiveRefund 校验资产是否有未结束的退款申请 +func (s *ExchangeService) validateNoActiveRefund(ctx context.Context, asset *resolvedExchangeAsset) error { + var hasActive bool + var err error + if asset.CardID != nil { + hasActive, err = s.refundStore.HasActiveRefundByCard(ctx, *asset.CardID) + } else if asset.DeviceID != nil { + hasActive, err = s.refundStore.HasActiveRefundByDevice(ctx, *asset.DeviceID) + } + if err != nil { + return err + } + if hasActive { + return errors.New(errors.CodeForbidden, "该资产存在退款申请,无法操作换货") + } + return nil +} +``` + +#### 前端 + +无需改动。换货申请时后端返回错误,前端展示错误信息即可。 + +--- + +### 需求06:资产详情-所有套餐的最后到期时间 + +#### 业务规则 + +资产详情页展示:**当前生效主套餐 + 所有待生效主套餐按队列接续后的预计最后到期时间**。 + +不能只取已经写入 `expires_at` 的最大值。排队套餐通常尚未激活,`expires_at` 为空,但其购买时的周期和时长已经确定,正常情况下仍可推算最终到期时间。 + +加油包不延长主套餐服务周期,不参与本字段计算。已失效、已退款或已过期的使用记录不参与。 + +#### 接口 + +后台资产详情页实际调用的是: + +``` +GET /api/admin/assets/resolve/:identifier +``` + +响应 DTO:`AssetResolveResponse`(`internal/model/dto/asset_dto.go`) + +该 DTO 目前**不含** `last_package_expires_at` 字段,需新增。 + +#### 后端 + +**DTO 新增字段**(`internal/model/dto/asset_dto.go` → `AssetResolveResponse`): + +```go +// 当前主套餐及排队主套餐接续后的预计最后到期时间,无可推算套餐时为 null +LastPackageExpiresAt *time.Time `json:"last_package_expires_at" description:"当前主套餐及排队主套餐接续后的预计最后到期时间,无可推算套餐时为null"` +``` + +**Store 新增方法**(`internal/store/postgres/package_usage_store.go`): + +```go +// GetProjectableMainPackagesByCardID 获取可推算的当前/排队主套餐。 +func (s *PackageUsageStore) GetProjectableMainPackagesByCardID(ctx context.Context, cardID uint) ([]*model.PackageUsage, error) { + var usages []*model.PackageUsage + err := s.db.WithContext(ctx). + Where("iot_card_id = ? AND master_usage_id IS NULL AND status IN (?, ?, ?) AND deleted_at IS NULL", cardID, + constants.PackageUsageStatusActive, // 1=生效中 + constants.PackageUsageStatusPending, // 0=待生效 + constants.PackageUsageStatusDepleted, // 2=已用完但仍占用当前周期 + ). + Order("priority ASC, created_at ASC, id ASC"). + Find(&usages).Error + return usages, err +} + +// GetProjectableMainPackagesByDeviceID 获取可推算的当前/排队主套餐。 +func (s *PackageUsageStore) GetProjectableMainPackagesByDeviceID(ctx context.Context, deviceID uint) ([]*model.PackageUsage, error) { + var usages []*model.PackageUsage + err := s.db.WithContext(ctx). + Where("device_id = ? AND master_usage_id IS NULL AND status IN (?, ?, ?) AND deleted_at IS NULL", deviceID, + constants.PackageUsageStatusActive, // 1=生效中 + constants.PackageUsageStatusPending, // 0=待生效 + constants.PackageUsageStatusDepleted, // 2=已用完但仍占用当前周期 + ). + Order("priority ASC, created_at ASC, id ASC"). + Find(&usages).Error + return usages, err +} +``` + +新建 `PackageUsage` 必须快照 `calendar_type_snapshot`、`duration_months_snapshot`、`duration_days_snapshot`,与需求05的 `expiry_base_snapshot` 一起在购买时写入。旧记录没有时长快照时才回退读取当前套餐,且仅作为历史兼容。 + +**Service 计算逻辑**(在 `ResolveAsset` 结果组装处添加): + +```go +// 1. 找当前主套餐的 expires_at 作为 cursor。 +// 2. 按 priority、created_at、id 遍历排队主套餐。 +// 3. 每个排队套餐以 cursor 为预计激活点,使用其购买时长快照计算新的 cursor。 +// 4. cursor 即预计最后到期时间。 +lastExpiry, err := s.packageUsageStore.ProjectLastMainPackageExpiry(ctx, assetType, assetID, now) +if err != nil { + return nil, err +} +resp.LastPackageExpiresAt = lastExpiry +``` + +`ProjectLastMainPackageExpiry` 是 Query 计算,不写快照表:当前主套餐到期时间变化、排队套餐新增/退款失效后,下一次详情查询立即反映。若资产没有当前套餐且队首套餐仍等待无法预测的外部前置条件(例如尚未实名),返回 `null`,前端显示“—”,不伪造日期。 + +#### 前端 + +资产详情"套餐信息"板块新增展示: + +``` +预计最后到期时间:2027-01-01 +``` + +读取 `resolve` 接口返回的 `last_package_expires_at`,有值则展示,无值(无套餐)展示"—"。 + + +--- + +> 汇编来源:`docs/7月迭代/需求05-套餐分配生效条件.md` + +## 需求05:套餐分配生效条件(ExpiryBase 覆盖) + +> 状态:待评审 + +--- + +### 背景 + +`Package.ExpiryBase` 已存在(`from_activation` / `from_purchase`),在套餐创建时设定,控制套餐何时开始计时。 + +需求:分配套餐给代理时,可以对单条分配记录二次覆盖这个值。 + +--- + +### 快照链设计 + +```mermaid +flowchart TD + Package[套餐默认 ExpiryBase] --> Effective{分配记录是否覆盖?} + Allocation[ShopPackageAllocation.expiry_base_override] --> Effective + Effective -->|有覆盖| Override[使用分配覆盖值] + Effective -->|无覆盖| Default[使用套餐默认值] + Override --> Snapshot[订单创建时写入 PackageUsage.expiry_base_snapshot] + Default --> Snapshot + Snapshot --> Activation[套餐激活只读快照] + Legacy[旧数据快照为空] --> Fallback[兜底读取套餐默认值] + Fallback --> Activation +``` + +遗留数据兜底:`ExpiryBaseSnapshot` 为空(旧数据)时,回退读 `pkg.ExpiryBase`,行为不变。 + +--- + +### 数据库变更 + +#### 1. ShopPackageAllocation 新增覆盖字段 + +```sql +ALTER TABLE tb_shop_package_allocation + ADD COLUMN expiry_base_override VARCHAR(30); + +COMMENT ON COLUMN tb_shop_package_allocation.expiry_base_override + IS '生效条件覆盖(NULL=使用套餐默认值, from_activation=实名即生效, from_purchase=购买即生效)'; +``` + +#### 2. PackageUsage 新增快照字段 + +```sql +ALTER TABLE tb_package_usage + ADD COLUMN expiry_base_snapshot VARCHAR(30) NOT NULL DEFAULT '', + ADD COLUMN calendar_type_snapshot VARCHAR(20) NOT NULL DEFAULT '', + ADD COLUMN duration_months_snapshot INT NOT NULL DEFAULT 0, + ADD COLUMN duration_days_snapshot INT NOT NULL DEFAULT 0; + +COMMENT ON COLUMN tb_package_usage.expiry_base_snapshot + IS '生效条件快照(创建时从分配记录取有效值写入,空字符串=旧数据兜底读套餐原值)'; +COMMENT ON COLUMN tb_package_usage.calendar_type_snapshot + IS '周期类型快照(空字符串=旧数据兜底读套餐原值)'; +COMMENT ON COLUMN tb_package_usage.duration_months_snapshot + IS '月数快照(0=旧数据兜底读套餐原值)'; +COMMENT ON COLUMN tb_package_usage.duration_days_snapshot + IS '天数快照(0=旧数据兜底读套餐原值)'; +``` + +旧数据不回填,默认空字符串,激活时自动兜底。 + +--- + +### Model 变更 + +#### ShopPackageAllocation(`internal/model/shop_package_allocation.go`) + +```go +// ExpiryBaseOverride 生效条件覆盖 +// NULL = 使用宿主套餐的 ExpiryBase;有值 = 分配时指定,不受套餐后续修改影响 +ExpiryBaseOverride *string `gorm:"column:expiry_base_override;type:varchar(30);comment:生效条件覆盖 NULL=使用套餐默认 from_activation=实名即生效 from_purchase=购买即生效" json:"expiry_base_override"` +``` + +#### PackageUsage(`internal/model/package.go`) + +```go +// ExpiryBaseSnapshot 生效条件快照(创建订单时写入,空字符串=旧数据兜底读套餐原值) +ExpiryBaseSnapshot string `gorm:"column:expiry_base_snapshot;type:varchar(30);not null;default:'';comment:生效条件快照 创建时从分配记录取有效值" json:"expiry_base_snapshot"` + +// 以下三个字段和 ExpiryBaseSnapshot 一起固化,供激活和排队最终到期时间计算使用。 +CalendarTypeSnapshot string `gorm:"column:calendar_type_snapshot;type:varchar(20);not null;default:'';comment:套餐周期类型快照" json:"calendar_type_snapshot"` +DurationMonthsSnapshot int `gorm:"column:duration_months_snapshot;not null;default:0;comment:套餐月数快照" json:"duration_months_snapshot"` +DurationDaysSnapshot int `gorm:"column:duration_days_snapshot;not null;default:0;comment:套餐天数快照" json:"duration_days_snapshot"` +``` + +--- + +### 业务逻辑变更 + +#### 1. 订单创建时快照(`internal/service/order/service.go`) + +订单创建已通过 `GetByShopAndPackage` 查询分配记录(现有逻辑),在此基础上追加: + +```go +// 取生效条件有效值:分配覆盖 > 套餐默认 +expiryBase := pkg.ExpiryBase +if allocation.ExpiryBaseOverride != nil && *allocation.ExpiryBaseOverride != "" { + expiryBase = *allocation.ExpiryBaseOverride +} +// 创建 PackageUsage 时一次性写入计时快照 +usage.ExpiryBaseSnapshot = expiryBase +usage.CalendarTypeSnapshot = pkg.CalendarType +usage.DurationMonthsSnapshot = pkg.DurationMonths +usage.DurationDaysSnapshot = pkg.DurationDays +``` + +#### 2. 激活时读快照(`internal/service/package/activation_service.go`) + +```go +// 新订单只读购买快照;旧记录兼容回退套餐当前值。 +expiryBase := usage.ExpiryBaseSnapshot +if expiryBase == "" { + expiryBase = pkg.ExpiryBase +} +calendarType := usage.CalendarTypeSnapshot +if calendarType == "" { + calendarType = pkg.CalendarType +} +durationMonths := usage.DurationMonthsSnapshot +if durationMonths == 0 { + durationMonths = pkg.DurationMonths +} +durationDays := usage.DurationDaysSnapshot +if durationDays == 0 { + durationDays = pkg.DurationDays +} +``` + +同文件所有激活和排队接续位置都使用同一快照解析函数,禁止某一处重新读取可修改的 `Package` 字段。 + +`internal/service/order/service.go` 中后台囤货路径的 `ExpiryBase` 判断也使用已创建的使用记录快照;需求06的“预计最后到期时间”同样只读这组快照,保证购买后套餐配置变更不会改写历史预测。 + +--- + +### API 变更 + +#### 1. 分配套餐接口(新增参数) + +``` +POST /api/admin/shop-package-allocations +``` + +请求 DTO 新增字段: + +```go +ExpiryBaseOverride *string `json:"expiry_base_override" validate:"omitempty,oneof=from_activation from_purchase" description:"生效条件覆盖(不传=使用套餐默认, from_activation=实名即生效, from_purchase=购买即生效)"` +``` + +#### 2. 修改已分配套餐的生效条件(新接口) + +``` +PATCH /api/admin/shop-package-allocations/{id}/expiry-base +``` + +请求 DTO: + +```go +type UpdateAllocationExpiryBaseRequest struct { + ExpiryBaseOverride *string `json:"expiry_base_override" validate:"omitempty,oneof=from_activation from_purchase" description:"生效条件(null=恢复套餐默认, from_activation=实名即生效, from_purchase=购买即生效)"` +} +``` + +> 注意:修改已有分配记录的覆盖值,**不影响**已创建的 PackageUsage(快照已定),只影响后续新建的订单。 + +--- + +### 前端对接 + +#### 套餐分配弹框 + +新增"生效条件"选择项: + +``` +生效条件: + ○ 跟随套餐默认(默认选中,不传 expiry_base_override) + ○ 购买即生效(from_purchase) + ○ 实名即生效(from_activation) +``` + +#### 已分配套餐列表 + +列表新增"生效条件"列: + +| 值 | 展示 | +|----|------| +| NULL | 套餐默认 | +| `from_activation` | 实名即生效(已覆盖) | +| `from_purchase` | 购买即生效(已覆盖) | + +操作列增加"修改生效条件"按钮,调用 `PATCH /api/admin/shop-package-allocations/{id}/expiry-base`。 + + +--- + +> 汇编来源:`docs/7月迭代/需求08-设备批量分配Excel.md` + +## 需求08:设备批量分配代理或套餐系列(Excel导入) + +> 状态:待评审 +> 模板:前端静态资源,后端仅负责上传校验和异步处理。 + +--- + +### 背景 + +设备号不连续,无法通过号段批量分配。需要通过上传 Excel 表(表头:设备号)完成两项独立操作: + +1. 批量分配设备给代理。 +2. 批量分配设备给套餐系列。 + +两项操作可以复用解析、异步任务和失败明细基础设施,但**一个任务只能执行一个业务命令**,不能在一次提交中同时修改 `shop_id` 和 `series_id`。 + +`Device` 表已有 `shop_id *uint` 和 `series_id *uint` 字段,分配即更新这两个字段。 + +```mermaid +sequenceDiagram + actor User as 平台员工 + participant Web as 设备管理前端 + participant API as BatchAllocation API + participant DB as PostgreSQL + participant Worker as Asynq Worker + + User->>Web: 选择“分配代理”或“分配套餐系列”并上传 Excel + Web->>API: POST /api/admin/devices/batch-assign-shop 或 batch-assign-series + API->>DB: 创建任务并保存上传文件引用 + API-->>Web: task_id + API->>Worker: 入队处理任务 + Worker->>DB: 按设备号批量查询并条件更新 + Worker->>DB: 写成功数、失败数和失败明细 + Web->>API: 轮询任务详情 + API-->>Web: 处理结果 +``` + +--- + +### 数据库变更 + +新建批量分配任务表(不复用 `DeviceImportTask`,业务语义不同): + +```sql +CREATE TABLE tb_device_batch_allocation_task ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + creator BIGINT NOT NULL DEFAULT 0, + updater BIGINT NOT NULL DEFAULT 0, + task_no VARCHAR(30) NOT NULL, + source_file_key VARCHAR(500) NOT NULL, + operation_type VARCHAR(30) NOT NULL, + target_id BIGINT NOT NULL, + operator_id BIGINT NOT NULL, + total_count INT NOT NULL DEFAULT 0, + success_count INT NOT NULL DEFAULT 0, + fail_count INT NOT NULL DEFAULT 0, + status INT NOT NULL DEFAULT 1, + failed_items JSONB, + started_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ +); + +CREATE UNIQUE INDEX idx_device_batch_allocation_task_no ON tb_device_batch_allocation_task(task_no) WHERE deleted_at IS NULL; +CREATE INDEX idx_device_batch_allocation_task_operation ON tb_device_batch_allocation_task(operation_type, status, created_at DESC); +``` + +状态常量:1=处理中,2=已完成,3=失败。 + +`operation_type`:`assign_shop`(分配代理)或 `assign_series`(分配套餐系列)。`target_id` 随操作类型分别指向代理店铺或套餐系列;不建立数据库外键。 + +失败明细存 JSONB(`failed_items`),失败条数有限,无需单独明细表。 + +--- + +### Model(`internal/model/device_batch_allocation_task.go`) + +```go +// DeviceBatchAllocationTask 设备批量分配任务模型 +type DeviceBatchAllocationTask struct { + gorm.Model + BaseModel `gorm:"embedded"` + TaskNo string `gorm:"column:task_no;type:varchar(30);uniqueIndex:idx_device_batch_allocation_task_no,where:deleted_at IS NULL;not null" json:"task_no"` + SourceFileKey string `gorm:"column:source_file_key;type:varchar(500);not null;comment:待处理Excel对象存储Key" json:"source_file_key"` + OperationType string `gorm:"column:operation_type;type:varchar(30);not null;comment:操作类型 assign_shop=分配代理 assign_series=分配套餐系列" json:"operation_type"` + TargetID uint `gorm:"column:target_id;not null;comment:操作目标ID(代理店铺或套餐系列)" json:"target_id"` + OperatorID uint `gorm:"column:operator_id;not null;comment:操作人ID" json:"operator_id"` + TotalCount int `gorm:"column:total_count;default:0;comment:总记录数" json:"total_count"` + SuccessCount int `gorm:"column:success_count;default:0;comment:成功数" json:"success_count"` + FailCount int `gorm:"column:fail_count;default:0;comment:失败数" json:"fail_count"` + Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1=处理中 2=已完成 3=失败" json:"status"` + FailedItems ImportResultItems `gorm:"column:failed_items;type:jsonb;comment:失败记录详情" json:"failed_items"` + StartedAt *time.Time `gorm:"column:started_at" json:"started_at"` + CompletedAt *time.Time `gorm:"column:completed_at" json:"completed_at"` +} + +func (DeviceBatchAllocationTask) TableName() string { + return "tb_device_batch_allocation_task" +} +``` + +> `ImportResultItems` 复用 `internal/model/device_import_task.go` 中已定义的类型。 + +--- + +### API 设计 + +#### 1. 前端静态 Excel 模板 + +模板由前端项目作为静态资源随版本发布,后端不提供模板下载接口。 + +- 文件建议命名:`设备批量分配模板-v1.xlsx` +- 表头:`设备号`(一列) +- 前端“下载模板”按钮直接下载静态文件。 +- 后端只校验上传文件的表头、行数和内容,不读取或生成前端模板文件。 + +模板字段发生变化时,前后端必须在同一发布批次升级;旧模板仍可能被用户保存在本地,因此后端错误需要明确指出缺失或未知表头。 + +#### 2. 上传并提交 + +``` +POST /api/admin/devices/batch-assign-shop +Content-Type: multipart/form-data + +字段: + shop_id: 123 (必填,分配给哪个代理) + file: +``` + +```text +POST /api/admin/devices/batch-assign-series +Content-Type: multipart/form-data + +字段: + series_id: 45 (必填,分配给哪个套餐系列) + file: +``` + +两个接口内部创建同一类任务表记录,但分别写入 `operation_type=assign_shop|assign_series`。请求中不接受另一个目标字段,避免前端通过隐藏参数把两个业务动作合并。 + +**处理逻辑(Asynq 异步)**: + +1. API 将上传文件保存到对象存储,把 `source_file_key` 写入任务;Worker 从对象存储下载并解析 Excel +2. 对设备号去重后分批查询 `tb_device`(按 `virtual_no` 或 `imei`),禁止逐行查询 +3. `assign_shop` 仅按状态/归属条件批量更新 `shop_id`;`assign_series` 仅更新 `series_id`。 +4. 未找到的:记录失败原因"设备号不存在"。 +5. `assign_shop` 遇到已分配给其他代理的设备:记录"该设备已分配,请先回收";`assign_series` 不改变代理归属。 +6. Worker 重试时只处理仍未满足目标结果的设备;已经分配到同一目标的记录按幂等成功处理。 + +临时本地文件路径不能进入 Asynq 载荷。任务结束后源文件按统一对象存储生命周期清理,清理失败不影响任务结果。 + +限制:单文件最大 10MB、去重前最多 1000 行、Worker 每批最多 200 条、任务最多保留 1000 条失败明细。超过限制在 API 或解析阶段返回明确错误,不创建无限时长任务。 + +#### 3. 查询任务状态 + +``` +GET /api/admin/devices/batch-allocation/{task_id} +``` + +响应: + +```json +{ + "task_no": "DBA20240101001", + "operation_type": "assign_shop", + "target_id": 123, + "status": 2, + "status_name": "已完成", + "total_count": 100, + "success_count": 95, + "fail_count": 5, + "failed_items": [ + { "line": 3, "virtual_no": "12345", "reason": "设备号不存在" }, + { "line": 7, "virtual_no": "67890", "reason": "该设备已分配,请先回收" } + ], + "started_at": "2024-01-01T10:00:00Z", + "completed_at": "2024-01-01T10:00:05Z" +} +``` + +--- + +### 前端对接 + +#### 入口 + +设备管理页 > 批量操作: + +- "批量分配代理" +- "批量分配套餐系列" + +#### 操作流程 + +1. 点击其中一个批量操作入口。 +2. "批量分配代理"弹框只显示代理选择器和 Excel 上传区域;"批量分配套餐系列"弹框只显示套餐系列选择器和 Excel 上传区域。 +3. 上传后点击"提交",分别调用 `POST /api/admin/devices/batch-assign-shop` 或 `POST /api/admin/devices/batch-assign-series`。 +4. 提示"任务提交成功,正在处理..." +5. 轮询 `GET /api/admin/devices/batch-allocation/{task_id}` 直到 `status != 1` +6. 展示结果:成功X条,失败X条,失败明细在页面展示 + +#### Excel 规范 + +- 表头:`设备号` +- 每行一个设备号(支持虚拟号或IMEI) + + +--- + +> 汇编来源:`docs/7月迭代/需求09-C端支付限制配置化.md` + +## 需求09:C端支付方式限制配置化 + +> 状态:待评审 +> 依赖:系统配置 + +--- + +### 背景 + +代码已经写好但被注释,注释原因是**微信支付参数未申请下来**,临时注释。 + +注释位置: +- `internal/handler/app/client_wallet.go`(钱包充值入口) +- `internal/service/client_order/service.go`(订单支付入口) + +两处均有注释:`// 第三方支付方式与资产类型必须匹配:单卡只允许支付宝,设备只允许微信(已暂时注释)` + +--- + +### 业务规则 + +| 资产类型 | 允许的第三方支付 | 禁止的第三方支付 | +|---------|----------------|----------------| +| IoT 卡 | 支付宝、钱包 | 微信 | +| 设备 | 微信、钱包 | 支付宝 | + +**钱包支付对所有资产类型均允许。** + +```mermaid +flowchart TD + Pay[用户选择支付方式] --> Asset{资产类型} + Asset -->|IoT卡| Card[读取卡允许方式] + Asset -->|设备| Device[读取设备允许方式] + Asset -->|未知| RejectUnknown[拒绝:资产类型无效] + Card --> ConfigOK{配置可用?} + Device --> ConfigOK + ConfigOK -->|是| Match{支付方式在允许集合?} + ConfigOK -->|否| SafeDefault[使用代码内安全默认集合] + SafeDefault --> Match + Match -->|是| Continue[继续支付] + Match -->|否| Reject[拒绝并返回对应中文提示] +``` + +--- + +### 实现方案 + +#### 1. 系统配置初始化(已在系统配置文档中定义) + +```go +// tb_system_config 初始数据 +config_key: "c2b.payment.card_allowed_methods" → ["alipay","wallet"] +config_key: "c2b.payment.device_allowed_methods" → ["wechat","wallet"] +``` + +#### 2. 恢复注释代码,改为读取配置 + +**文件**:`internal/service/client_order/service.go` + +```go +// validatePaymentMethod 校验资产类型与支付方式是否匹配 +func (s *Service) validatePaymentMethod(ctx context.Context, assetType string, paymentMethod string) error { + // 钱包支付始终允许 + if paymentMethod == model.PaymentMethodWallet { + return nil + } + + var configKey string + switch assetType { + case model.AssetTypeIotCard: // "iot_card",定义在 internal/model/asset_identifier.go + configKey = "c2b.payment.card_allowed_methods" + case model.AssetTypeDevice: // "device",定义在 internal/model/asset_identifier.go + configKey = "c2b.payment.device_allowed_methods" + default: + return errors.New(errors.CodeInvalidParam, "资产类型无效") + } + + allowedMethods, err := sysconfig.GetStringSlice(ctx, configKey) + if err != nil || len(allowedMethods) == 0 { + // 配置异常时回退到代码内安全默认值,禁止放开全部支付方式。 + allowedMethods = defaultAllowedMethods(assetType) + s.logger.Error("读取支付方式配置失败,已使用安全默认值", + zap.String("config_key", configKey), + zap.Error(err)) + } + + for _, m := range allowedMethods { + if m == paymentMethod { + return nil + } + } + return errors.New(errors.CodeForbidden, "该资产类型不支持此支付方式") +} +``` + +在 `client_wallet.go`(充值)和 `client_order/service.go`(订单支付)的对应位置恢复调用。 + +系统配置更新时必须保证 `wallet` 始终存在于两个允许集合中;前端将钱包选项显示为勾选且不可取消,后端再次校验,避免配置破坏业务规则。 + +#### 3. 错误信息 + +用户端错误提示(友好文案): + +```go +// 根据资产类型给出具体提示 +switch assetType { +case model.AssetTypeIotCard: + return errors.New(errors.CodeForbidden, "卡资产仅支持支付宝或余额支付") +case model.AssetTypeDevice: + return errors.New(errors.CodeForbidden, "设备仅支持微信或余额支付") +} +``` + +--- + +### 前端对接 + +#### C端支付页面 + +前端不维护另一份固定规则。资产初始化/详情接口返回后端已经计算好的: + +```go +AllowedPaymentMethods []string `json:"allowed_payment_methods" description:"当前资产允许的支付方式"` +``` + +支付页只展示该集合中的方式,后端支付接口再次执行相同校验。配置变化后重新进入支付页或刷新资产信息即可获得新集合。 + +``` +allowed_payment_methods = ["alipay", "wallet"] → 展示支付宝、余额 +allowed_payment_methods = ["wechat", "wallet"] → 展示微信、余额 +``` + +#### 后台配置页面 + +在系统配置(系统设置 > 系统配置 > `c2b.payment` 模块)中,用 CheckboxGroup 展示: + +``` +卡资产允许支付方式:☑ 支付宝 ☑ 余额 ☐ 微信 +设备允许支付方式: ☐ 支付宝 ☑ 余额 ☑ 微信 +``` + +余额选项固定勾选且禁用,不允许管理员取消。 + +修改后调用 `PUT /api/admin/system/config/c2b.payment.card_allowed_methods`。 + +> 注意:**微信支付参数申请下来后**,直接在系统配置里把对应资产类型勾上微信即可生效,无需改代码。 + + +--- + +> 汇编来源:`docs/7月迭代/需求10-限速规则.md` + +## 需求10:Gateway 手动卡限速 + +> 状态:待评审 +> 已确认边界:本期只提供手动设置/取消限速;Gateway 最终对象始终是卡,统一使用 `cardNo`。 + +--- + +### 一、范围 + +本期提供一个统一的后台能力:对一张实际联网卡设置或取消限速。 + +- 单卡资产:使用 `IotCard.ICCID` 作为 `cardNo`。 +- 设备资产:查询 `tb_device_sim_binding.is_current=true` 的当前绑定卡,再使用该卡 ICCID。 +- `speed_kbps > 0` 表示设置限速;`speed_kbps = 0` 表示取消限速。 +- 内部业务单位固定为 `kbps`,前端也按 `kbps` 输入和展示。 + +本期不做: + +- 套餐限速字段或套餐编辑页限速配置。 +- 套餐激活、到期、续费、停机、切卡后的自动限速或自动取消。 +- 设备 IMEI 限速。 +- “Gateway 当前实际限速”查询展示。上游未提供查询接口时,页面只能展示最近一次本系统操作记录。 + +取消限速仍调用同一个 Gateway 方法。`speed_kbps=0` 是本系统语义,Gateway 所需的取消报文仅在 Infrastructure 适配器中转换,不散落在 Handler、Service 或前端。 + +--- + +### 二、流程 + +```mermaid +sequenceDiagram + actor User as 平台员工 + participant Web as 资产详情页 + participant API as SpeedLimit Application + participant DB as PostgreSQL + participant Resolver as 当前卡解析器 + participant Gateway as Gateway Adapter + + User->>Web: 输入限速值或点击取消 + Web->>API: asset identifier + speed_kbps + request_id + API->>Resolver: 解析实际 cardNo + Resolver-->>API: ICCID 或无当前卡错误 + API->>DB: 创建或复用限速操作记录 + API->>Gateway: SetSpeedLimit(cardNo, speedKbps) + Gateway-->>API: 成功或失败 + API->>DB: 写结果、错误摘要和操作审计 + API-->>Web: 返回本次操作结果 +``` + +设备不存在当前绑定卡时,接口返回业务错误并记录失败原因;绝不把设备 IMEI、设备 ID 或空字符串发送给 Gateway。 + +--- + +### 三、应用端口与数据 + +```go +// GatewaySpeedLimitPort 设置或取消卡限速。 +// speedKbps=0 表示取消,具体上游参数由适配器转换。 +type GatewaySpeedLimitPort interface { + SetSpeedLimit(ctx context.Context, cardNo string, speedKbps int) error +} + +// SpeedLimitCardResolver 将卡或设备资产解析为实际联网卡 ICCID。 +type SpeedLimitCardResolver interface { + ResolveCardNo(ctx context.Context, assetType string, assetID uint) (string, error) +} +``` + +新建操作记录表,既用于审计,也用于同一 `request_id` 的幂等重试: + +```sql +CREATE TABLE tb_gateway_speed_limit_operation ( + id BIGSERIAL PRIMARY KEY, + request_id VARCHAR(64) NOT NULL, + asset_type VARCHAR(20) NOT NULL, + asset_id BIGINT NOT NULL, + card_no VARCHAR(100) NOT NULL, + desired_speed_kbps INT NOT NULL, + status INT NOT NULL DEFAULT 1, + error_summary TEXT NOT NULL DEFAULT '', + operator_id BIGINT NOT NULL, + completed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX uq_gateway_speed_limit_operation_request + ON tb_gateway_speed_limit_operation(request_id); +CREATE INDEX idx_gateway_speed_limit_operation_asset + ON tb_gateway_speed_limit_operation(asset_type, asset_id, created_at DESC); +``` + +状态:`1=处理中, 2=成功, 3=失败`。同一 `request_id` 重试时,任务、操作人、资产和目标限速一致才返回或继续原记录;不一致返回冲突。Gateway 网络超时后允许使用相同目标值重试,因为 `SetSpeedLimit` 是“设为目标状态”的幂等操作。 + +--- + +### 四、接口与前端 + +```text +POST /api/admin/assets/{identifier}/speed-limit +``` + +```json +{ + "request_id": "01JZ...", + "speed_kbps": 1024 +} +``` + +- `speed_kbps` 必须为整数且大于等于 0。 +- 取消操作提交 `speed_kbps=0`,不新增第二个取消接口。 +- 后端根据 `identifier` 解析资产类型和数据范围,前端不得传 `cardNo`、设备 ID 或资产类型作为权威依据。 + +资产详情页提供两个独立命令: + +- 数字输入框加“保存”用于设置限速,单位固定显示 `kbps`。 +- 取消按钮用于提交 `speed_kbps=0`,并使用确认弹窗避免误操作。 + +设备详情页显示“当前使用卡 ICCID”。没有当前卡时禁用两个命令并展示后端返回的原因。页面可展示最近一次操作的目标限速、结果、操作人和时间,但不能把该记录描述为 Gateway 的实时状态。 + +--- + +### 五、审计与人工验收 + +每次请求记录:资产类型/ID、最终 `cardNo`、目标 `speed_kbps`、设置或取消、操作人、`request_id`、Gateway 结果和脱敏错误摘要。 + +人工验收覆盖: + +1. 单卡按 ICCID 设置限速。 +2. 设备按 `is_current=true` 的绑定卡设置限速,请求中不出现设备 IMEI。 +3. 设备无当前卡时拒绝调用 Gateway。 +4. `speed_kbps=0` 经同一 Gateway 方法取消限速。 +5. 同一 `request_id` 重试不重复生成操作记录;不同请求复用 ID 返回冲突。 +6. Gateway 失败记录错误并允许相同目标值重试。 + +上线前置条件:Gateway 提供设置和取消所需的最终报文字段约定。业务层只保持 `cardNo + speedKbps` 契约。 + + +--- + +> 汇编来源:`docs/7月迭代/需求14-导出功能.md` + +## 需求14:导出功能 + +> 状态:待评审 +> 复用现有 ExportTask 体系(`tb_export_task` + Asynq),不为每个业务模块新增一套导出路由。 + +--- + +### 导出模块总览 + +| 编号 | 模块 | 新增/修改 | +|------|------|---------| +| EXPD-001~003 | IoT卡导出 | 新增套餐名称、使用流量、剩余流量字段 | +| 6.8.2 | 代理资金概况-预充值钱包流水导出 | **全新** | +| 6.8.3 | 套餐列表导出 | **全新** | +| 6.8.4 | 退款管理退款列表导出 | **全新** | +| 6.8.5 | 换货管理导出 | **全新** | +| 6.8.6 | 代理充值导出 | **全新**(去掉"支付通道"字段) | +| 需求22 | 临期资产列表导出 | **全新**,`scene=expiring_asset` | + +--- + +### 现有导出体系说明 + +系统已有异步导出框架(`internal/exporter/`): +- `tb_export_task` 表记录导出任务 +- 导出逻辑通过 `DataSource` 接口实现,每个场景一个文件(如 `iot_card_scene.go`) +- `registry.go` 的 `NewDefaultRegistry()` 统一注册所有场景 +- Asynq Worker 根据任务里的 `scene` 字段,从 Registry 取对应 DataSource 执行 +- 前端轮询任务状态后下载 + +```mermaid +sequenceDiagram + actor User as 后台用户 + participant Web as 前端 + participant API as ExportTask API + participant DB as PostgreSQL + participant Worker as Asynq Worker + participant Storage as 对象存储 + + User->>Web: 选择导出字段并确认 + Web->>API: POST /api/admin/export-tasks + API->>API: 计算数据范围和字段权限交集 + API->>DB: 保存查询、范围和字段快照 + API-->>Web: task_id + API->>Worker: 入队导出任务 + Worker->>DB: 按 scene 分片查询 + Worker->>Storage: 上传导出文件 + Worker->>DB: 更新进度和 download file_key + Web->>API: 轮询 GET /api/admin/export-tasks/{id} + API-->>Web: 状态、进度、download_url +``` + +新增导出模块需要: +1. 在 `pkg/constants/constants.go` 新增 `ExportTaskSceneXxx` 场景常量 +2. 在 `internal/exporter/` 新建 `xxx_scene.go`,实现 `DataSource` 接口(`Scene()`/`Count()`/`Headers()`/`Fetch()`) +3. 在 `registry.go` 的 `NewDefaultRegistry()` 中注册,并更新 `IsSupportedScene()` +4. 扩展统一 `CreateExportTaskRequest.Scene` 的允许值,通过 `POST /api/admin/export-tasks` 创建任务 +5. 在字段目录中注册稳定字段 key、中文表头、默认选择和所需导出权限 + +统一创建请求扩展为: + +```go +type CreateExportTaskRequest struct { + Scene string `json:"scene" validate:"required" description:"导出场景"` + Format string `json:"format" validate:"required,oneof=xlsx csv" description:"导出格式"` + Query map[string]any `json:"query,omitempty" description:"与列表一致的筛选条件"` + Fields []string `json:"fields" validate:"required,min=1" description:"申请导出的字段key"` +} +``` + +新增场景:`agent_wallet_transaction`、`package`、`refund`、`exchange`、`agent_recharge`、`expiring_asset`。DTO description 和 `pkg/constants/` 必须同步维护。 + +--- + +### EXPD-001~003:IoT卡导出字段新增 + +**修改文件**:`internal/exporter/iot_card_scene.go` + +在 `Headers()` 末尾追加三列,`Fetch()` 的 `Select` 追加字段,`iotCardExportRow` 追加字段: + +```go +// Headers() 新增 +"套餐名称", "使用流量(MB)", "剩余流量(MB)" + +// baseQuery() 或 Fetch() 新增 JOIN +LEFT JOIN LATERAL ( + SELECT pu.package_id, pu.data_usage_mb, pu.data_limit_mb, p.package_name + FROM tb_package_usage pu + JOIN tb_package p ON p.id = pu.package_id AND p.deleted_at IS NULL + WHERE pu.iot_card_id = c.id AND pu.status = 1 + AND pu.master_usage_id IS NULL AND pu.deleted_at IS NULL + LIMIT 1 +) AS pkg ON TRUE + +// iotCardExportRow 新增 +PackageName string `gorm:"column:package_name"` +DataUsageMB int64 `gorm:"column:data_usage_mb"` +DataLimitMB int64 `gorm:"column:data_limit_mb"` +``` + +剩余流量 = `DataLimitMB - DataUsageMB`(在行转换时计算) + +--- + +### 6.8.2:代理资金概况-预充值钱包流水导出 + +**新增场景**:`scene=agent_wallet_transaction` + +支持与现有钱包流水列表相同的筛选条件,异步生成 Excel。 + +导出字段映射: + +| 字段 | 数据来源 | +|------|---------| +| 店铺名称 | JOIN `tb_shop` | +| 交易类型 | `transaction_type`(充值/扣款/退款等,中文化) | +| 交易金额 | `amount / 100` 转元 | +| 状态 | `status` 中文化 | +| 资产类型 | `asset_type` 中文化 | +| 资产标识 | `asset_identifier` | +| 交易时间 | `created_at` | +| 交易前金额 | `balance_before / 100` | +| 交易后金额 | `balance_after / 100` | +| 购买套餐名称 | 新数据读取 `tb_order_item.package_name` 不可变快照;历史缺失时才回退 `metadata.package_name`,禁止关联当前套餐名称 | +| 操作人 | JOIN `tb_account`(`creator` 字段关联 `tb_account.id`,取 `username`) | +| 交易 ID | `id` | +| 关联业务订单号 | `reference_id` 对应的单号(JOIN 对应表) | +| 交易渠道/支付方式 | `metadata` 中 `payment_method` 字段 | + +--- + +### 6.8.3:套餐列表导出 + +**新增场景**:`scene=package` + +支持现有套餐列表筛选条件。 + +导出字段(按实际列举的 25 个稳定字段 Key): + +```go +type PackageExportRow struct { + PackageCode string `xlsx:"套餐编码"` + PackageName string `xlsx:"套餐名称"` + SeriesName string `xlsx:"套餐系列名称"` // JOIN tb_package_series + PackageType string `xlsx:"套餐类型"` // formal/addon 中文化 + DurationMonths int `xlsx:"套餐时长(月)"` + DurationDaysDesc string `xlsx:"套餐时长说明"` // 剩余天数说明 + CalendarType string `xlsx:"套餐周期类型"` + DurationDays int `xlsx:"套餐天数"` + RealDataMB int64 `xlsx:"真流量额度(MB)"` + VirtualDataMB int64 `xlsx:"虚流量额度(MB)"` + EnableVirtualData string `xlsx:"是否启用虚流量"` // 是/否 + VirtualRatio float64 `xlsx:"虚流量比例"` + DataResetCycle string `xlsx:"流量重置周期"` + ExpiryBase string `xlsx:"到期时间基准"` + CostPrice string `xlsx:"成本价(元)"` // 分→元 + SuggestedRetailPrice string `xlsx:"建议售价(元)"` + PriceConfigStatus string `xlsx:"价格配置状态"` + Status string `xlsx:"状态"` + ShelfStatus string `xlsx:"上架状态"` + IsGift string `xlsx:"是否赠送套餐"` + CreatorID uint `xlsx:"创建人ID"` + UpdaterID uint `xlsx:"更新人ID"` + CreatedAt string `xlsx:"创建时间"` + UpdatedAt string `xlsx:"更新时间"` + DeletedAt string `xlsx:"删除时间"` +} +``` + +--- + +### 6.8.4:退款管理退款列表导出 + +**新增场景**:`scene=refund` + +导出字段(去掉“退款到账方式”,审批信息使用动态摘要,不固定具体节点): + +```go +type RefundExportRow struct { + RefundNo string `xlsx:"退款单号"` + ShopName string `xlsx:"代理店铺名称"` + PaymentOrderNo string `xlsx:"关联的支付订单号"` + AssetType string `xlsx:"资产类型"` + AssetIdentifier string `xlsx:"资产标识"` + PackageName string `xlsx:"套餐名称"` + OriginalAmount string `xlsx:"原订单金额"` + ActualAmount string `xlsx:"实收金额"` + RefundableAmount string `xlsx:"可退金额"` + AppliedAmount string `xlsx:"申请退款金额"` + ActualRefundAmount string `xlsx:"实际退款金额"` + Status string `xlsx:"状态"` + RefundReason string `xlsx:"退款原因"` + Remark string `xlsx:"备注"` + ApprovalSource string `xlsx:"审批来源"` + ApprovalStatus string `xlsx:"审批状态"` + ProcessingStatus string `xlsx:"退款处理状态"` + CurrentNodeName string `xlsx:"当前审批节点"` + ApprovalRecords string `xlsx:"审批记录"` + AppliedAt string `xlsx:"退款申请时间"` + CompletedAt string `xlsx:"退款完成时间"` + SubmitterName string `xlsx:"提交人"` + VoucherURLs string `xlsx:"退款凭证"` +} +``` + +`ApprovalRecords` 格式示例:`业务审核:张三(通过,同意,附件2个);财务审核:李四(待处理)`。导出只记录附件数量,不导出对象存储 URL 或 file_key。`ProcessingStatus` 区分待人工退款、代理钱包回退处理中、已完成和处理失败。数据源按本批业务单的 `approval_instance_id` 批量查询审批任务、审批人和附件计数,禁止逐行查询。历史终态单据没有流程实例时,`ApprovalSource` 输出“历史审批”,审批记录仅使用原业务审批字段和审计日志,不伪造多节点记录。 + +--- + +### 6.8.5:换货管理导出 + +**新增场景**:`scene=exchange` + +```go +type ExchangeExportRow struct { + ExchangeNo string `xlsx:"换货单号"` + ExchangeType string `xlsx:"换货类型"` + ExchangeReason string `xlsx:"换货原因"` + ProblemDesc string `xlsx:"问题描述"` + OldAssetType string `xlsx:"旧资产类型"` + OldAssetIdentifier string `xlsx:"旧资产标识符"` + NewAssetIdentifier string `xlsx:"新资产标识符"` + ReceiverName string `xlsx:"收货人姓名"` + ReceiverPhone string `xlsx:"收货人电话"` + ReceiverAddress string `xlsx:"收货地址"` + ExpressCompany string `xlsx:"快递公司"` + TrackingNo string `xlsx:"快递单号"` + Status string `xlsx:"状态"` + CreatorName string `xlsx:"创建人"` + CreatedAt string `xlsx:"创建时间"` +} +``` + +--- + +### 6.8.6:代理充值导出 + +**新增场景**:`scene=agent_recharge` + +去掉"支付通道"字段(需求文档中明确去掉),保留其他字段: + +```go +type AgentRechargeExportRow struct { + RechargeNo string `xlsx:"充值单号"` + ShopName string `xlsx:"店铺名称"` + RechargeType string `xlsx:"充值类型"` + RechargeAmount string `xlsx:"充值金额"` + ActualAmount string `xlsx:"实付金额"` + BalanceBefore string `xlsx:"充值前余额"` + BalanceAfter string `xlsx:"充值后余额"` + Status string `xlsx:"状态"` + PaymentMethod string `xlsx:"支付方式"` + // 去掉支付通道 + OperationRemark string `xlsx:"运营备注"` + RejectReason string `xlsx:"驳回原因"` + CreatedAt string `xlsx:"创建时间"` + PaidAt string `xlsx:"支付时间"` + CompletedAt string `xlsx:"完成时间"` + SubmitterName string `xlsx:"提交人"` + ApprovalSource string `xlsx:"审批来源"` + ApprovalStatus string `xlsx:"审批状态"` + CurrentNodeName string `xlsx:"当前审批节点"` + ApprovalRecords string `xlsx:"审批记录"` + VoucherURLs string `xlsx:"支付凭证"` + Remark string `xlsx:"备注"` +} +``` + +充值导出的审批记录格式和批量查询规则与退款导出一致。 + +--- + +### 前端对接(通用模式) + +各导出入口:对应列表页右上角"导出"按钮(与现有导出按钮样式一致)。 + +调用流程: +1. 点击“导出”后请求当前账号在该场景可导出的字段目录。 +2. 弹框只展示后端允许的字段,默认勾选 `default_selected=true` 的字段。 +3. 提交 `scene + format + query + fields` 到 `POST /api/admin/export-tasks`。 +4. 返回 `task_id` 后,前端轮询 `GET /api/admin/export-tasks/{id}` 直到终态。 +5. `status=3` 时下载 `download_url`;失败时展示任务错误摘要,禁止自动重复创建任务。 + +(与现有导出体系完全一致,复用现有前端导出 Hook) + +--- + +### 导出字段权限 + +需求提到"不同权限显示的字段不同,角色管理中新增导出字段配置"。 + +评审结论:本迭代必须实现角色级导出字段配置,不作为后续预留。 + +该要求涉及真流量、成本价、身份材料等敏感数据,不能以“本期先全量导出”代替。字段权限必须由后端强制执行,前端复选框只负责展示。 + +#### 1. 字段目录 + +每个场景在代码中注册稳定字段 key: + +```go +// ExportFieldDefinition 导出字段定义 +type ExportFieldDefinition struct { + Key string + Header string + DefaultSelected bool + Required bool +} +``` + +示例: + +```text +scene=package +package_code 套餐编码 默认选择 +package_name 套餐名称 默认选择 +real_data_mb 真流量额度 敏感字段 +virtual_data_mb 虚流量额度 默认选择 +cost_price 成本价 敏感字段 +``` + +表头中文可以调整,但 `scene + field_key` 一经发布不得随意改名,否则历史角色配置会失效。 + +#### 2. 角色字段授权表 + +```sql +CREATE TABLE tb_role_export_field_permission ( + id BIGSERIAL PRIMARY KEY, + role_id BIGINT NOT NULL, + scene VARCHAR(50) NOT NULL, + field_key VARCHAR(100) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + creator BIGINT NOT NULL DEFAULT 0 +); + +CREATE UNIQUE INDEX idx_role_export_field_permission + ON tb_role_export_field_permission(role_id, scene, field_key); +``` + +账号拥有多个角色时,字段权限按 RBAC 常规规则取并集;超级管理员拥有全部字段。数据行范围仍使用现有数据权限快照,字段权限不能扩大店铺或企业数据范围。 + +#### 3. 权限 API + +```text +GET /api/admin/export-fields?scene=package +``` + +返回当前账号可选择的字段: + +```json +{ + "scene": "package", + "fields": [ + { + "key": "package_code", + "header": "套餐编码", + "default_selected": true, + "required": true + } + ] +} +``` + +角色管理: + +```text +GET /api/admin/roles/{role_id}/export-fields +PUT /api/admin/roles/{role_id}/export-fields +``` + +更新请求按场景提交字段 key 列表,后端校验字段存在并记录审计日志。 + +#### 4. 创建任务时的权限快照 + +创建任务时计算: + +```text +resolved_fields = requested_fields ∩ role_allowed_fields ∩ scene_supported_fields +``` + +- 必选字段由后端自动补齐。 +- 交集为空时拒绝创建任务。 +- `resolved_fields` 和对应 `resolved_headers` 写入任务的 `query_json`,Worker 不重新根据后来变化的角色权限扩大字段。 +- Worker 只按照快照字段输出,未知字段直接失败,不允许静默回退到全量字段。 + +#### 5. 前端角色配置 + +- 角色编辑页增加“导出字段权限”页签,按场景分组展示复选框。 +- 普通列表的导出弹框只显示当前账号被授权的字段。 +- 敏感字段可以增加“敏感”标记,但标记不能替代后端权限。 +- 用户取消所有可选字段时禁用提交按钮,并提示至少选择一个字段。 + +--- + +### 查询与性能约束 + +- 退款和充值审批记录必须先按本批 `approval_instance_id` 批量查询,再在内存按实例分组,禁止逐行查审批任务。 +- 关联业务单号、套餐名称和操作人必须使用批量 JOIN 或批量查询,禁止 N+1。 +- DataSource 的 Count 和 Fetch 必须使用同一份权限过滤和查询条件。 +- 动态字段不代表动态拼接不受控 SQL;字段 key 必须通过服务端白名单映射到固定查询列。 + +--- + +### 发布与回滚 + +本需求随七月迭代停机发布: + +1. 维护窗口内先执行角色字段权限表迁移,并初始化现有角色的最小可用字段集。 +2. 同一发布窗口部署场景常量、DataSource、管理 API、Worker 和前端字段选择/角色配置页面。 +3. 开放访问前验证普通角色、敏感字段角色和超级管理员的字段集合及实际导出文件。 +4. 回滚时可关闭新增场景入口,但保留任务、文件和字段权限历史。 + +禁止在角色权限尚未初始化时默认放开全部敏感字段;无法解析权限时应拒绝导出并记录错误。 + + +--- + +> 汇编来源:`docs/7月迭代/需求15-16-18-19-20-21-复杂需求.md` + +## 需求15/16/18/19/20/21 技术方案 + +> 状态:待评审 +> 评审建议:需求 15/16、需求 18/20/21、需求 19 分三组评审,不在一次会议中混合确认。 + +--- + +### 需求15:套餐下架后允许续费 + +#### 业务规则 + +- 下架套餐(`shelf_status=2`)不可被**新购** +- 下架套餐**可以续费**(已在使用该套餐的客户) +- 续费仅支持**客户自己购买**(不允许代理代购下架套餐给新客户) + +```mermaid +flowchart TD + Start[请求购买套餐] --> Enabled{套餐是否启用?} + Enabled -->|否| RejectDisabled[拒绝:套餐已禁用] + Enabled -->|是| Shelf{是否已下架?} + Shelf -->|否| Allow[允许继续下单] + Shelf -->|是| Renewal{当前客户资产是否有该套餐历史使用记录?} + Renewal -->|否| RejectNew[拒绝:下架套餐不可新购] + Renewal -->|是| Actor{是否客户本人续费?} + Actor -->|是| Allow + Actor -->|否| RejectProxy[拒绝:下架套餐不可代购] +``` + +“客户本人”必须由后端登录主体与资产归属关系判断,不能信任前端传入 `is_renewal=true`。 + +#### 后端 + +**当前逻辑**:下架套餐在购买时被拦截。 + +修改:在订单创建校验中由后端根据资产、历史使用记录和登录主体判定“新购/续费”,不能接收或信任前端 `is_renewal`: + +```go +// internal/service/order/service.go 或 client_order/service.go +func (s *Service) validatePackageAvailability( + ctx context.Context, + pkg *model.Package, + asset *ResolvedAsset, + actor *PurchaseActor, +) error { + if pkg.Status == constants.StatusDisabled { // 0=禁用,定义在 pkg/constants/constants.go + return errors.New(errors.CodeForbidden, "套餐已禁用") + } + if pkg.ShelfStatus == constants.ShelfStatusOff { // 2=下架 + if !s.hasRenewalEligibility(ctx, asset, actor, pkg.ID) { + return errors.New(errors.CodeForbidden, "套餐已下架,仅支持资产所有人续费") + } + } + return nil +} +``` + +**续费资格判断**:查当前资产是否有该套餐的有效历史使用记录,并确认当前登录主体就是资产所有人。已失效、已退款或仅创建未生效的记录不能作为续费资格: + +```go +func (s *Service) hasRenewalEligibility(ctx context.Context, asset *ResolvedAsset, actor *PurchaseActor, packageID uint) bool { + return actor.OwnsAsset(asset) && + s.packageUsageStore.HasValidHistory(ctx, asset.Type, asset.ID, packageID) +} +``` + +#### 前端 + +C端资产详情/当前套餐卡片:在当前套餐旁提供“续费”按钮。点击后复用现有购买套餐流程,并携带资产和当前套餐上下文;后端重新判定资格,前端传入的上下文不构成授权依据。下架套餐不出现在普通“新购套餐”列表,也不额外建设第二个续费套餐列表。 + +后台代购时:下架套餐的"代购"按钮禁用,tooltip 提示"套餐已下架,不可代购"。 + +--- + +### 需求16:代理分销码与佣金提现(已移出7月迭代) + +> 状态:已移出本期 +> 决策日期:2026-07-14 +> 后续处理:作为独立需求重新评审和排期,不纳入本次开发、迁移和发布 + +本次范围调整包含整个需求 16: + +- 代理/员工分销码和推广二维码。 +- H5 代理申请、进度查询和退回重提。 +- 代理申请接入通用审批及审批后自动开店。 +- 店铺发展人关系和代理申请来源字段。 +- 佣金提现合同、营业执照、法人身份证、门头照、发票及主体一致性校验。 + +因此 7 月迭代不创建 `tb_distribution_code`、`tb_agent_application`,不修改 `tb_shop` 发展人字段和提现材料字段,不注册代理申请相关 API,不发布 `agent_application_approval`,也不建设对应前端页面。 + +通用审批流本期只接入退款和平台员工线下充值。需求 16 后续重新立项时,可以复用本期审批流、站内消息、对象存储和 Outbox 能力,但必须重新评审其数据模型、H5 安全、开店幂等、提现材料及工时。 + +--- + +### 需求18:多人审批(APR-001~009) + +#### 依赖 + +基于 审批流基础设施 和 站内消息。 + +APR-009(企微审批对接)= Phase 2。 + +```mermaid +sequenceDiagram + actor Applicant as 提交人 + participant Biz as 退款/充值业务 + participant Approval as 审批流 + participant Notice as 站内消息 + actor Approver1 as 当前节点审批人 + actor Approver2 as 下一节点审批人 + + Applicant->>Biz: 提交申请 + Biz->>Approval: 同事务创建流程实例和首任务 + Approval->>Notice: TaskCreated + Notice-->>Approver1: 待审批提醒 + Approver1->>Approval: 审批通过 + Approval->>Notice: 下一节点 TaskCreated + Notice-->>Approver2: 待审批提醒 + Approver2->>Approval: 通过/驳回/退回 + Approval->>Notice: 流程结果事件 + Notice-->>Applicant: 结果和原因 +``` + +#### 实现要点 + +| 编号 | 需求 | 实现 | +|------|------|------| +| APR-001~003 | 充值/退款多级审核 | 见需求20/21;需求16已移出本期 | +| APR-004 | 审核环节:部门领导→财务 | 作为默认流程定义的两个串行节点;系统无部门模型,节点审批人由角色或指定账号配置,禁止按步骤编号或角色名称写死 | +| APR-005 | 待审核有消息提示 | 站内消息 `NotifyTypeApprovalPending` | +| APR-006 | 上一级完成后才提示下一级 | `ProcessInstance` 完成当前任务并激活下一节点,写入 `TaskCreated` Outbox 事件 | +| APR-007 | 通过后通知申请人 | `ProcessApproved` 事件处理器 | +| APR-008 | 驳回/退回后通知申请人含原因 | `ProcessRejected` / `ProcessReturned` 事件处理器 | +| APR-009 | 对接企微 | **Phase 2** | + +#### 默认流程与可配置边界 + +- 本迭代可以预置“业务审核 → 财务审核”两个节点,但这只是初始流程定义,不是引擎固定规则。 +- 每个节点可配置 `role` 或 `user` 审批人来源,以及 `any` 或 `all` 完成方式。 +- 节点可配置是否要求操作密码;仅触发该节点完成的审批人输入,不能按“财务节点”等名称写死。 +- 角色审批在节点激活时解析当前启用账号,并将候选审批人快照到任务审批人表。 +- 流程定义发布后不可修改;调整节点或审批人配置时创建新版本。 +- 已发起实例始终使用发起时保存的流程定义快照。 + +#### 业务表与审批流的关联 + +充值单和退款单接入审批流,各自新增 `approval_instance_id` 字段。具体增量 DDL 与业务处理状态字段分别在需求 20、需求 21 中定义,迁移文件只能创建一次。 + +业务表只保存当前审批实例 ID,历史实例通过 `biz_type + biz_id` 查询。接入协议详见 审批流文档 - 业务接入协议。 + +退款和充值详情统一返回 `approval_source=none|workflow|legacy`。代理在线充值等无需审批的记录返回 `none`;发布前已经结束且没有流程实例的历史审批记录返回 `legacy` 并只读展示;发布时仍待审批的退款和员工线下充值必须在维护窗口回填流程实例。 + +#### 前端技术方案 + +- 新增“待我审批”和动态审批详情,具体页面、状态和请求幂等规则见 前端技术方案。 +- 退款、充值列表只展示审批摘要;审批详情首屏展示发起时固化的业务关键字段和业务资料,随后展示完整审批人、意见和历史实例。 +- 每次通过、驳回、退回分别保存审批意见和可选附件;驳回、退回意见必填,附件不与退款/充值业务凭证混用。 +- 时间线必须能查看此前审批人的动作、时间、意见和审批附件;业务资料与审批附件分区展示。 +- 所有节点名称和审批人来自接口,不保留“部门领导审批人”“财务审批人”固定字段。 +- APR-009 不在 Phase 1 前端中展示不可用入口;企业微信接入完成后再增加来源标识和跳转。 + +--- + +### 需求19:批量订购套餐(BPO-001~008) + +#### 支付方式粒度 + +评审结论:支付方式按**整批统一**设计: + +- 页面选择 `offline` 或 `agent_wallet`,Excel 不再重复填写支付方式。 +- 混合支付拆成两个批次,避免一份凭证对应多种支付语义。 +- Excel 不包含支付方式列,后端拒绝同一批次混合支付。 + +#### 业务流程 + +```mermaid +flowchart TD + Start[员工选择代理和支付方式] --> Upload[上传 Excel] + Upload --> Parse[解析并持久化逐行明细] + Parse --> Validate[校验资产、套餐、归属和重复行] + Validate --> Item{处理下一条有效明细} + Item -->|代理钱包| Lock[锁定钱包并校验可用余额] + Item -->|线下支付| Voucher[校验整批支付凭证] + Lock --> Order[单条事务创建订单并扣款] + Voucher --> OrderOffline[单条事务创建已支付订单] + Order --> Result[记录订单 ID 和成功状态] + OrderOffline --> Result + Result --> More{还有待处理明细?} + More -->|是| Item + More -->|否| Summary[汇总任务结果] + Validate -->|校验失败| Failed[记录结构化失败原因] + Failed --> More +``` + +任务允许部分成功。每一行是独立、可重试、可审计的业务单元,不能只保存一段失败 JSON。 + +#### 数据库变更 + +```sql +CREATE TABLE tb_bulk_purchase_task ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + creator BIGINT NOT NULL DEFAULT 0, + updater BIGINT NOT NULL DEFAULT 0, + task_no VARCHAR(30) NOT NULL, + source_file_key VARCHAR(500) NOT NULL, + shop_id BIGINT NOT NULL, + operator_id BIGINT NOT NULL, + payment_method VARCHAR(20) NOT NULL, + voucher_keys JSONB NOT NULL DEFAULT '[]', + total_amount BIGINT NOT NULL DEFAULT 0, + total_count INT NOT NULL DEFAULT 0, + success_count INT NOT NULL DEFAULT 0, + fail_count INT NOT NULL DEFAULT 0, + status INT NOT NULL DEFAULT 1, + error_message TEXT NOT NULL DEFAULT '', + started_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ +); + +CREATE UNIQUE INDEX idx_bulk_purchase_task_no + ON tb_bulk_purchase_task(task_no) + WHERE deleted_at IS NULL; + +CREATE TABLE tb_bulk_purchase_item ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + task_id BIGINT NOT NULL, + row_no INT NOT NULL, + asset_type VARCHAR(20) NOT NULL, + asset_identifier VARCHAR(100) NOT NULL, + package_code VARCHAR(50) NOT NULL, + package_name_snapshot VARCHAR(200) NOT NULL DEFAULT '', + package_id BIGINT, + amount BIGINT NOT NULL DEFAULT 0, + status INT NOT NULL DEFAULT 1, + order_id BIGINT, + failure_code VARCHAR(50) NOT NULL DEFAULT '', + failure_reason VARCHAR(500) NOT NULL DEFAULT '', + idempotency_key VARCHAR(100) NOT NULL, + processed_at TIMESTAMPTZ +); + +CREATE UNIQUE INDEX idx_bulk_purchase_item_task_row + ON tb_bulk_purchase_item(task_id, row_no); + +CREATE UNIQUE INDEX idx_bulk_purchase_item_idempotency + ON tb_bulk_purchase_item(idempotency_key); + +CREATE INDEX idx_bulk_purchase_item_task_status + ON tb_bulk_purchase_item(task_id, status); +``` + +状态建议: + +- 任务:`1=待处理, 2=处理中, 3=已完成, 4=部分成功, 5=失败`。 +- 明细:`1=待处理, 2=处理中, 3=成功, 4=失败`。 + +#### 处理与幂等 + +1. API 校验文件、代理、支付方式和凭证,将 Excel 保存到对象存储并创建任务,返回 `task_id`。 +2. Worker 根据 `source_file_key` 下载并解析 Excel,将每一行先写入明细表,再开始业务处理。 +3. 同一任务按行顺序处理,避免对同一代理钱包制造不必要的乐观锁冲突。 +4. 每行使用独立事务。代理钱包支付时锁定钱包记录,校验 `balance - frozen_balance + credit_limit` 后,在同一事务扣款、创建订单、资金流水并更新明细。 +5. `idempotency_key` 使用 `bulk_purchase:{task_id}:{row_no}`。Worker 重试时,已存在成功订单的明细直接跳过。 +6. 单行失败不回滚已成功行;失败原因写结构化错误码和用户可见中文原因。 +7. 任务汇总从明细表计算,不信任内存计数。 + +Asynq 载荷只传任务 ID,不传文件字节或临时路径。源文件保留周期按对象存储统一策略处理,确保 Worker 重试期间仍可读取。 + +建议单文件上限 1000 行;超过上限在 API 层拒绝,避免长事务和过长处理时间。 + +#### API 设计 + +**前端静态 Excel 模板**: + +模板由前端项目随版本发布,后端不提供下载接口。按已确认的“整批统一支付方式”,模板字段为: + +```text +资产类型 | 资产标识 | 套餐编码 | 套餐名称 +``` + +`套餐名称`用于人工核对,实际匹配以稳定的 `套餐编码` 为准。后端必须校验表头并对未知列给出明确错误,不能依赖模板一定来自当前前端版本。 + +**上传并提交**: + +```text +POST /api/admin/bulk-purchases +Content-Type: multipart/form-data + +shop_id: 123 +payment_method: offline | agent_wallet +voucher_keys: ["key1","key2"] +file: +``` + +`offline` 时 `voucher_keys` 必填,`agent_wallet` 时忽略该字段。 + +**查询任务状态**: + +```text +GET /api/admin/bulk-purchases/{task_id} +``` + +**分页查询任务明细**: + +```text +GET /api/admin/bulk-purchases/{task_id}/items?status=4&page=1&page_size=50 +``` + +#### 前端技术方案 + +- 页面分为“参数确认 → 文件上传 → 处理中 → 结果”四个稳定步骤,刷新页面后可根据任务 ID 恢复进度。 +- 提交前展示代理、支付方式、凭证数量和文件名的二次确认;钱包支付额外展示当前可用余额,但最终以 Worker 扣款时校验为准。 +- 任务处理中展示总数、已处理数、成功数和失败数,轮询规则复用统一异步任务方案。 +- 结果页默认显示失败明细,可切换全部/成功/失败,并可按资产标识搜索。 +- 部分成功使用明确状态,不弹“全部成功”提示;再次上传失败行会创建新任务,不修改旧任务历史。 +- 操作员、任务号和处理时间在页面固定展示,便于财务和运营追溯。 + +--- + +### 需求20:退款审批 + +#### 依赖 + +基于 审批流基础设施。 + +#### 退款单现有状态(不变) + +``` +1=待审批 2=已通过 3=已拒绝 4=已退回 +``` + +退款单状态值的原有语义不改;审批进度由 `tb_approval_process_instance` 管理,两者通过 `approval_instance_id` 关联。审批通过后,代理钱包退款自动回退到原扣款代理主钱包;微信、支付宝和线下退款由财务人工完成。业务表增加独立处理状态: + +```text +processing_status:0=待处理 1=处理中 2=已完成 3=处理失败 +``` + +`status=2` 表示审批结论已通过,`processing_status` 表示实际退款动作是否完成。接口和前端必须同时展示两者。 + +#### 数据库变更 + +```sql +ALTER TABLE tb_refund_request + ADD COLUMN approval_instance_id BIGINT, + ADD COLUMN processing_status INT NOT NULL DEFAULT 0, + ADD COLUMN processing_error TEXT NOT NULL DEFAULT '', + ADD COLUMN processing_started_at TIMESTAMPTZ, + ADD COLUMN processing_completed_at TIMESTAMPTZ, + ADD COLUMN manual_refund_operator_id BIGINT, + ADD COLUMN manual_refund_completed_at TIMESTAMPTZ, + ADD COLUMN manual_refund_remark TEXT NOT NULL DEFAULT '', + ADD COLUMN manual_refund_voucher_key JSONB NOT NULL DEFAULT '[]'; + +CREATE INDEX idx_refund_request_approval_instance + ON tb_refund_request(approval_instance_id) + WHERE approval_instance_id IS NOT NULL; +``` + +`processing_error` 只保存可运维排查的摘要。`manual_refund_*` 只在人工退款确认时写入,退款申请时提交的 `refund_voucher_key` 仍是申请业务资料,不能混用为财务完成凭证。 + +现有退款审批允许确认 `approved_refund_amount`,切换到通用审批后必须保留:配置为最终决策的节点在完成时返回金额动作字段,审批人可确认实际退款金额;省略时使用申请金额。退款动作适配器在审批事务内校验金额大于 0,且不超过申请退款金额和订单实收金额,并写入退款单和审批操作日志。会签需要指定金额决策人时,流程定义增加其专属最终节点,禁止第一位会签人预先锁定金额。 + +#### 流程 + +```mermaid +sequenceDiagram + actor Applicant as 提交人 + actor Approver as 审批人 + participant Refund as Refund Application + participant Approval as Approval Application + participant DB as PostgreSQL + participant Worker as AgentWalletRefundHandler + actor Finance as 财务人员 + + Applicant->>Refund: POST /api/admin/refunds + Refund->>DB: 同事务创建退款单(status=1) + Refund->>Approval: StartProcess(refund, refund_id) + Approval->>DB: 创建实例、首任务、审批人、Outbox + Refund->>DB: 回写 approval_instance_id + Approver->>Approval: 按 task_id 审批,决策节点可提交实际退款金额 + Approval->>DB: 提交 ProcessApproved/Rejected/Returned + alt 代理钱包支付且审批通过 + DB-->>Worker: Outbox + Asynq 至少一次投递 + Worker->>DB: claim processing_status=1,status=2 + Worker->>DB: 幂等回退原扣款代理主钱包并写资金流水 + Worker->>DB: processing_status=2 + else 非代理钱包支付且审批通过 + Refund->>DB: status=2, processing_status=0(待人工退款) + Finance->>Refund: POST manual-complete + Refund->>DB: 条件更新处理状态并记录确认信息 + else 审批拒绝 + Worker->>DB: status 从 1 更新为 3 + else 退回修改 + Worker->>DB: status 从 1 更新为 4 + end +``` + +`AgentWalletRefundHandler` 仅处理代理钱包订单,使用 `refund:{refund_id}` 作为业务幂等键,并读取审批事务已经持久化的 `approved_refund_amount`。它必须按原扣款资金流水定位原代理主钱包,余额、版本、钱包退款流水和处理状态在同一事务更新。处理失败时单独更新 `processing_status=3` 和错误摘要后返回可重试错误;不得回滚已经完成的审批实例,也不得重复回退。 + +处理器通过条件更新领取任务:`processing_status IN (0,3)`,或状态为处理中但 `processing_started_at` 已超过约定租约。重复消费者看到未过期的处理中状态时不重复执行;进程在副作用完成后崩溃时,下一次重试依靠业务幂等键恢复并补写成功状态。 + +本期不调用第三方退款 API,也不增加商户退款号、渠道退款号或渠道结果字段。个人/客户资产钱包退款不属于本期自动回退范围,现有对应分支必须在实施时隔离或拒绝进入本流程。 + +人工退款确认接口: + +```text +POST /api/admin/refunds/{id}/manual-complete +``` + +请求包含 `request_id`、可选 `remark` 和最多 5 个完成凭证。后端仅允许具备财务确认权限的账号对 `status=2 AND processing_status IN (0,3)` 的非代理钱包退款操作;实际金额沿用审批金额,不允许在确认时再次改价。确认记录操作人、时间、备注和凭证后将处理状态置为已完成。 + +#### 退回后重新提交 + +``` +POST /api/admin/refunds/{id}/resubmit + → 校验 status=4 + → 请求体可修改 actual_received_amount、requested_refund_amount、refund_voucher_key、refund_reason + → 在同一事务新建 ProcessInstance + → 更新 approval_instance_id,status 回到 1(待审批) + → processing_status 重置为 0,清空本次处理错误和人工确认记录 + → 旧审批实例保留为历史记录 +``` + +复用当前真实路由 `POST /api/admin/refunds/{id}/resubmit`,不新增单独 `PUT`。重提命令沿用现有 `ResubmitRefundRequest` 字段范围;禁止修改订单 ID、资产快照、提交人或已形成的历史审批记录。 + +#### API 响应与前端 + +退款列表和详情增加: + +```json +{ + "approval_instance_id": 1001, + "approval_source": "workflow", + "approval_status": 2, + "approval_status_name": "已通过", + "current_node_name": "", + "processing_status": 0, + "processing_status_name": "待人工退款", + "processing_error": "" +} +``` + +前端展示规则: + +| 审批状态 | 处理状态 | 展示 | +|----------|----------|------| +| 审批中 | 待处理 | 待审批 + 当前节点 | +| 已通过 + 代理钱包 | 处理中 | 审批已通过,代理钱包回退处理中 | +| 已通过 + 非代理钱包 | 待处理 | 审批已通过,待人工退款;财务可确认完成 | +| 已通过 | 已完成 | 退款已完成 | +| 已通过 + 代理钱包 | 处理失败 | 系统重试中;管理员可查看错误摘要 | +| 已拒绝 | 待处理 | 已拒绝 + 原因 | +| 已退回 | 待处理 | 已退回,可编辑并重新提交 | + +列表页不直接放固定审批按钮。点击进入详情后,根据审批接口返回的 `available_actions` 渲染通过、驳回和退回操作。 + +决策节点根据 `action_form` 展示“实际退款金额”输入,默认等于申请金额。审批通过后的非代理钱包退款展示“确认人工退款”入口,仅具备财务确认权限时显示;完成凭证与审批附件分区展示。前端只负责元/分转换和基础格式校验,金额上限以后端在审批事务中的校验为准。 + +停机发布后,现有按退款业务单 ID 直接通过、驳回或退回的路由不再注册;所有退款审批动作统一操作 `task_id`,避免绕过审批人快照、并发控制和操作日志。 + +维护窗口内需要为 `status=1 AND approval_instance_id IS NULL` 的存量退款单执行幂等回填,从 `refund_approval` 首节点创建流程实例和任务;历史终态退款不伪造流程实例。 + +--- + +### 需求21:充值审核流程 + +#### 充值单现有状态 + +``` +tb_agent_recharge_record:1=待支付 2=已支付 3=已完成 4=已关闭 5=已退款 +``` + +代码中已经存在 `6=已驳回`,不能改写其含义。员工线下充值走审批流时,在现有状态基础上追加 `7=已退回`: + +```sql +-- 现有:1=待支付 2=已支付 3=已完成 4=已关闭 5=已退款 6=已驳回 +-- 新增:7=已退回 + +ALTER TABLE tb_agent_recharge_record + ADD COLUMN approval_instance_id BIGINT, + ADD COLUMN processing_status INT NOT NULL DEFAULT 0, + ADD COLUMN processing_error TEXT NOT NULL DEFAULT '', + ADD COLUMN processing_started_at TIMESTAMPTZ, + ADD COLUMN processing_completed_at TIMESTAMPTZ, + ADD COLUMN return_reason VARCHAR(500) NOT NULL DEFAULT ''; + +CREATE INDEX idx_agent_recharge_approval_instance + ON tb_agent_recharge_record(approval_instance_id) + WHERE approval_instance_id IS NOT NULL; +``` + +充值业务的状态语义: +- `1=待支付`:创建未支付(线下充值等待审批时也停在这里,由 `approval_instance_id` 查询审批状态) +- `2=已支付`:在线支付已确认,或线下充值审批通过后正在执行钱包入账 +- `3=已完成`:充值到账 +- `4=已关闭`:取消/超时 +- `5=已退款`:退款 +- `6=已驳回`:审批流程拒绝 +- `7=已退回`:审批人退回给提交人修改 + +`rejection_reason` 只保存驳回原因,新增 `return_reason` 保存退回修改原因,禁止复用一个字段导致前端无法区分终止和可重提。 + +充值处理状态保持:`0=未触发, 1=处理中, 2=处理成功, 3=处理失败`。退款的 `0` 已收口为“待处理”,两者不要共用中文状态名称常量。 + +#### 流程 + +**代理自行充值(不走审批)**: + +```mermaid +flowchart LR + A[代理提交充值申请] --> B[系统生成收款码] + B --> C[代理扫码支付] + C --> D[支付回调幂等入账] +``` + +**员工线下代充值(走审批)**: + +现有 `offline-pay` 的全局操作密码校验必须保留。通用审批详情在最后一个审批节点返回 `operation_password` 动作字段;审批动作适配器调用现有 `OperationPasswordService` 校验通过后才允许流程完成。密码只在内存中参与本次校验,不落库、不写审批日志、不进入 Outbox。 + +```mermaid +sequenceDiagram + actor Staff as 平台员工 + actor Approver as 审批人 + participant Recharge as Recharge Application + participant Approval as Approval Application + participant DB as PostgreSQL + participant Worker as RechargeApprovalHandler + + Staff->>Recharge: POST /api/admin/agent-recharges(payment_method=offline) + Recharge->>DB: 同事务创建充值单(status=1) + Recharge->>Approval: StartProcess(recharge, recharge_id) + Approval->>DB: 创建实例、首任务、审批人、Outbox + Recharge->>DB: 回写 approval_instance_id + Approver->>Approval: 按 task_id 审批 + DB-->>Worker: 投递流程结果事件 + alt 审批通过 + Worker->>DB: status 从 1 更新为 2,processing_status=1 + Worker->>DB: 幂等增加钱包余额并写流水 + Worker->>DB: status 从 2 更新为 3,processing_status=2 + else 审批拒绝 + Worker->>DB: status 从 1 更新为 6,写 rejection_reason + else 退回修改 + Worker->>DB: status 从 1 更新为 7,写 return_reason + end +``` + +充值接口独立返回审批状态和业务处理状态。`RechargeApprovalHandler` 使用 `recharge:{recharge_no}` 作为幂等键;钱包余额、版本、充值单和交易流水必须在同一事务更新。 + +充值处理同样使用 `processing_started_at` 作为可恢复租约。重复事件不能再次增加余额;若钱包流水已经存在而充值单状态未完成,重试只补齐充值单状态。 + +#### 退回后重新提交 + +``` +POST /api/admin/agent-recharges/{id}/resubmit + → 校验 status=7 + → 请求体可修改 amount、payment_voucher_key、remark + → 在同一事务新建 ProcessInstance + → 更新 approval_instance_id,status 回到 1(待支付/待审批) + → processing_status 重置为 0,清空处理错误和 return_reason + → 旧审批实例保留为历史记录 +``` + +新增 `resubmit` 路由时沿用现有 `/api/admin/agent-recharges` 资源名,不另建 `/agent-recharge-records` 路径。店铺、支付方式和提交人不可修改;编辑与新流程创建必须同事务完成。 + +#### 停机切换 + +现有 `POST /api/admin/agent-recharges/{id}/offline-pay` 和 `POST /api/admin/agent-recharges/{id}/reject` 都会绕过通用审批任务,本次不保留兼容窗口: + +1. 发布前进入维护模式,停止创建和处理线下充值。 +2. 执行审批关联字段迁移,同时发布新 API、Worker 和前端。 +3. 初始化并启用 `recharge → recharge_approval` 绑定。 +4. 为存量“平台员工创建 + 线下支付 + 尚未入账”的充值记录幂等创建流程实例,代理在线充值不回填审批。 +5. 新前端创建线下充值后直接进入审批详情,不再展示“确认线下充值”按钮。 +6. 新版本不注册 `offline-pay` 和业务单级 `reject` 路由;线下充值只能由 `ProcessApproved` 事件触发幂等入账,驳回统一由任务级审批接口产生 `ProcessRejected`。 +7. 验证审批通过、驳回、退回、处理失败重试和钱包流水后再解除维护模式。 + +#### 前端技术方案 + +- 代理自行充值保留现有收款码和支付状态页面,不显示审批信息。 +- 平台员工选择 `offline` 时,提交成功进入充值详情并展示审批时间线。 +- `status=6` 展示“已驳回”,`status=7` 展示“已退回”;两者按钮不同,只有已退回可编辑和重新提交。 +- 审批通过但 `processing_status=1` 时显示“充值处理中”;状态为 3 且处理成功后才显示最新钱包余额。 +- `processing_status=3` 时不允许前端再次点击入账,只展示系统重试状态和管理员排查入口。 + + +--- + +> 汇编来源:`docs/7月迭代/需求17-信用额度.md` + +## 需求17:信用额度 + +> 状态:待评审 +> DDD 范围:仅迁移代理主钱包的复杂写用例,资金列表和统计继续走 Query +> 关联需求:BPO-009~012、需求19批量订购 + +--- + +### 一、评审结论 + +代理信用额度在现有 `AgentWallet` 基础上落地,属于典型资金聚合:余额、冻结金额、信用额度、版本和流水必须在同一事务内保持不变量。 + +平台员工信用额度不实施,原因如下: + +- 平台员工是操作主体,不是订单结算主体;实际付款方只能是代理钱包或线下支付主体。 +- 角色表达权限范围,不表达资产、余额和债务,给角色配置资金会混淆 RBAC 与财务账本。 +- 系统不存在员工钱包、员工充值、员工还款和离职债务交接链路,负余额无法对账和追责。 +- 批量订购已经明确由代理钱包扣款或使用线下支付,不存在必须从员工个人额度扣款的业务场景。 +- 引入员工信用会与代理钱包形成两套资金来源,增加订单归属、退款去向和审计解释成本,但不产生实际业务价值。 + +因此信用额度只属于代理主钱包,平台员工仅通过权限决定是否可以查看或调整代理额度。 + +--- + +### 二、已确认范围:代理主钱包授信 + +#### 2.1 业务规则 + +- 信用额度只作用于代理主钱包,不作用于佣金钱包和资产钱包。 +- 可用金额:`balance - frozen_balance + effective_credit_limit`。 +- `credit_enabled=false` 时,`effective_credit_limit=0`。 +- 余额可以为负,最低不能小于 `-(credit_limit - frozen_balance)`。 +- 扣款、冻结、解冻、充值和调额都必须维护同一钱包不变量。 +- 存在欠款或冻结金额导致可用金额不足时,禁止降低额度或关闭信用。 +- 金额统一使用分,禁止浮点数入库。 + +```mermaid +flowchart TD + Debit[请求扣款] --> Lock[按钱包ID和version加载] + Lock --> Calc[计算 balance - frozen + effective_credit] + Calc --> Enough{可用金额足够?} + Enough -->|否| Reject[拒绝:可用余额不足] + Enough -->|是| Update[条件更新余额和version] + Update --> Tx[同事务写资金流水] + Tx --> Success[返回扣款后余额] +``` + +#### 2.2 信用开关 + +- 创建代理时可以提交 `enable_credit` 和 `credit_limit`。 +- 关闭开关时额度必须为 0。 +- 打开开关时额度必须大于 0。 +- 修改额度前必须校验修改后的可用金额不为负,而不是只判断 `balance >= 0`。 + +--- + +### 三、数据库变更 + +信用额度属于钱包,不在 `tb_shop` 和 `tb_agent_wallet` 各保存一份,避免双写失真。创建/编辑代理接口可以接收信用参数,但最终权威数据写入代理主钱包。 + +```sql +ALTER TABLE tb_agent_wallet + ADD COLUMN credit_enabled BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN credit_limit BIGINT NOT NULL DEFAULT 0; + +COMMENT ON COLUMN tb_agent_wallet.credit_enabled IS '是否启用信用额度,仅主钱包有效'; +COMMENT ON COLUMN tb_agent_wallet.credit_limit IS '信用额度上限(分),仅主钱包有效'; + +ALTER TABLE tb_agent_wallet + DROP CONSTRAINT IF EXISTS chk_agent_wallet_frozen_balance; + +ALTER TABLE tb_agent_wallet + ADD CONSTRAINT chk_agent_wallet_frozen_nonnegative + CHECK (frozen_balance >= 0), + ADD CONSTRAINT chk_agent_wallet_credit_nonnegative + CHECK (credit_limit >= 0), + ADD CONSTRAINT chk_agent_wallet_available_nonnegative + CHECK ( + balance - frozen_balance + + CASE WHEN credit_enabled THEN credit_limit ELSE 0 END >= 0 + ); +``` + +必须先检查生产库真实约束名;`DROP CONSTRAINT IF EXISTS` 不能替代迁移前核对。历史钱包默认关闭信用,行为不变。 + +--- + +### 四、领域模型 + +```go +// AgentWallet 代理主钱包聚合根 +type AgentWallet struct { + ID uint + WalletType string + Balance int64 + FrozenBalance int64 + CreditEnabled bool + CreditLimit int64 + Version int64 +} + +// AvailableBalance 返回当前可用金额。 +func (w *AgentWallet) AvailableBalance() int64 { + credit := int64(0) + if w.CreditEnabled { + credit = w.CreditLimit + } + return w.Balance - w.FrozenBalance + credit +} + +// Debit 执行钱包扣款并维护信用边界。 +func (w *AgentWallet) Debit(amount int64) error { + if amount <= 0 { + return ErrInvalidAmount + } + if w.AvailableBalance() < amount { + return ErrInsufficientAvailableBalance + } + w.Balance -= amount + return nil +} + +// ChangeCredit 修改信用配置。 +func (w *AgentWallet) ChangeCredit(enabled bool, limit int64) error { + if limit < 0 || (!enabled && limit != 0) || (enabled && limit == 0) { + return ErrInvalidCreditConfig + } + nextCredit := int64(0) + if enabled { + nextCredit = limit + } + if w.Balance-w.FrozenBalance+nextCredit < 0 { + return ErrCreditLimitBelowDebt + } + w.CreditEnabled = enabled + w.CreditLimit = limit + return nil +} +``` + +领域层只维护资金不变量,不查询角色、店铺名称或页面权限。角色能否授信由 Application 在调用聚合前校验。 + +--- + +### 五、应用用例与持久化 + +#### 5.1 修改代理信用额度 + +```text +Handler + → ChangeShopCreditUseCase + → 校验操作人和代理数据权限 + → 加载代理主钱包 + → 调用 wallet.ChangeCredit() + → 按 version 条件更新钱包 + → 写操作日志和信用变更流水 +``` + +条件更新示例: + +```sql +UPDATE tb_agent_wallet +SET credit_enabled = ?, + credit_limit = ?, + version = version + 1, + updated_at = NOW() +WHERE id = ? + AND wallet_type = 'main' + AND version = ? + AND balance - frozen_balance + + CASE WHEN ? THEN ? ELSE 0 END >= 0; +``` + +受影响行数为 0 时,重新读取钱包以区分并发冲突和额度低于当前欠款。 + +#### 5.2 钱包扣款 + +所有现有主钱包扣款语句必须从: + +```text +balance - frozen_balance >= amount +``` + +统一改为: + +```text +balance - frozen_balance + +CASE WHEN credit_enabled THEN credit_limit ELSE 0 END >= amount +``` + +扣款、版本递增和钱包流水必须在同一事务。禁止只修改 `GetAvailableBalance()` 而遗漏 Store 中的 SQL 条件,否则页面显示可用但实际仍无法扣款。 + +#### 5.3 受影响用例 + +- 后台订单和批量订购的代理钱包支付。 +- C端/代理端使用代理主钱包的订单支付。 +- 钱包冻结与解冻。 +- 退款回充和员工线下代充值。 +- 资金概况、钱包详情和导出 Query。 + +实施时只迁移这些被信用额度触碰的完整资金用例,不主动改造佣金钱包和资产钱包。 + +--- + +### 六、查询方案 + +资金概况、钱包详情、列表和导出使用 Query 直接读取: + +```sql +balance - frozen_balance + +CASE WHEN credit_enabled THEN credit_limit ELSE 0 END AS available_balance +``` + +响应统一增加: + +```go +CreditEnabled bool `json:"credit_enabled" description:"是否启用信用额度"` +CreditLimit int64 `json:"credit_limit" description:"信用额度(分)"` +AvailableBalance int64 `json:"available_balance" description:"可用金额(分)"` +IsInDebt bool `json:"is_in_debt" description:"余额是否为负"` +DebtAmount int64 `json:"debt_amount" description:"欠款金额(分)"` +``` + +`debt_amount = max(-balance, 0)`,不包含冻结金额。 + +--- + +### 七、API 设计 + +#### 7.1 创建代理 + +现有: + +```text +POST /api/admin/shops +``` + +新增参数: + +```go +EnableCredit bool `json:"enable_credit" description:"是否开启信用额度"` +CreditLimit int64 `json:"credit_limit" validate:"min=0" description:"信用额度(分)"` +``` + +Application 创建店铺和主钱包时,将信用配置写入钱包;任何一步失败整笔事务回滚。 + +#### 7.2 修改信用额度 + +```text +PUT /api/admin/shops/{id}/credit-limit +``` + +```go +type UpdateCreditLimitRequest struct { + EnableCredit bool `json:"enable_credit" description:"是否开启信用额度"` + CreditLimit int64 `json:"credit_limit" validate:"min=0" description:"信用额度(分)"` + Version int64 `json:"version" validate:"min=0" description:"钱包版本,用于并发控制"` +} +``` + +#### 7.3 查询展示 + +现有 `GET /api/admin/shops/fund-summary` 和代理详情响应增加信用字段;不新增不存在的 `/agent-wallets/{shop_id}` 路由。 + +--- + +### 八、前端技术方案 + +#### 8.1 创建/编辑代理 + +```text +信用额度 +[开关] 允许使用信用额度 +授信上限 [金额输入,单位元] +``` + +- 开关关闭时清空输入并提交 `credit_limit=0`。 +- 金额输入使用分/元安全转换,不允许负数和小数精度超过两位。 +- 编辑代理基础资料不自动覆盖信用额度;信用调整使用独立权限和独立接口。 + +#### 8.2 资金概况和详情 + +```text +账面余额:-¥200.00 +冻结金额:¥0.00 +信用额度:¥1,000.00 +可用金额:¥800.00 +``` + +- `balance < 0` 时账面余额显示欠款样式。 +- 可用金额以接口值为准,不在前端自行重复计算。 +- 修改额度弹框展示当前余额、冻结金额、额度和修改后可用金额预览;提交后仍以后端校验为准。 +- 后端返回并发冲突时刷新钱包版本和最新金额,不保留旧计算结果。 + +平台员工端不展示个人余额或个人信用额度。拥有信用额度管理权限的员工,只能在代理资金页面查看和调整代理主钱包额度。 + +--- + +### 九、审计与可观测性 + +每次信用配置变更记录: + +- 操作人、店铺、钱包 ID。 +- 变更前后开关、额度、余额、冻结金额和版本。 +- `request_id`、IP、设备信息和时间。 + +关键日志: + +- 扣款因信用额度不足被拒绝。 +- 钱包 version 冲突。 +- 数据库信用边界约束失败。 +- 信用额度调整失败和旧值/新值。 + +资金流水必须能够通过订单号、批量任务号或充值/退款业务号反查,不以普通操作日志替代钱包流水。 + +--- + +### 十、发布与回滚 + +本需求随七月迭代停机发布: + +1. 维护窗口内先核对并调整钱包现有 CHECK 约束,再增加信用字段,历史数据默认关闭。 +2. 同时发布钱包聚合、全部主钱包扣款条件、查询字段和管理端信用配置页面,禁止只改余额展示而遗漏真实扣款 SQL。 +3. 开放访问前保持所有代理信用开关关闭,人工验证普通余额、信用扣款、并发冲突和额度调整。 +4. 系统开放后再由有权限的管理员对指定代理逐个开启额度。 + +尚未开启任何信用额度时可以回滚应用版本和可逆迁移。额度启用并产生负余额后,不能直接关闭信用或删除字段;必须先完成还款或保留当前资金逻辑,数据库字段和资金流水不做破坏性回滚。 + + +--- + +> 汇编来源:`docs/7月迭代/需求22-套餐临期提醒.md` + +## 需求22:套餐临期提醒 + +> 状态:待评审 + +--- + +### 业务规则 + +#### 临期定义 + +当资产的**当前生效主套餐**(`PackageUsage.status=1`)按 `Asia/Shanghai` 自然日计算的剩余天数在 `0~15` 天时,该资产进入临期状态。 + +资产有可接续的排队主套餐时不进入临期提醒,因为服务不会在当前套餐到期后中断;需求06仍会单独展示排队套餐接续后的“预计最后到期时间”。已过期资产不属于临期。 + +#### 查询与通知职责 + +- 后台列表、详情、临期列表、代理首页和 C 端展示均通过 SQL 在查询时实时计算,不建立临期状态快照表,也不由前端轮询生成临期数据。 +- 每日任务只负责扫描 15/7/3 天阈值并创建通知记录;它不维护列表数据、不决定前端高亮状态。 +- `tb_expiry_push_record` 仅用于防止同一资产、接收人、渠道、阈值重复通知。 + +#### 颜色规则(Version 2) + +| 剩余天数 | 颜色 | +|---------|------| +| ≤ 15天 | 粉红色 | +| ≤ 7天 | 紫色 | +| ≤ 3天 | 黄色 | + +#### 各端提醒规则 + +| 场景 | 提醒方式 | 触发节点 | +|------|---------|---------| +| **后台管理** | 列表加临期天数列 + 高亮;详情加临期字段(≤15天高亮) | 实时计算 | +| **企业客户** | 企微推送(Phase 2);后台每日生成临期列表 | 15天/7天/3天节点 | +| **代理端** | 首页展示临期卡/设备数量;列表高亮 | 实时计算 | +| **C端公众号** | 套餐到期提醒模块(≤15天展示) | 实时展示 | + +```mermaid +flowchart TD + Schedule[每日定时任务] --> Query[批量查询实时临期候选] + Query --> QueueCheck{存在排队套餐?} + QueueCheck -->|是| Skip[不进入临期提醒] + QueueCheck -->|否| Days[计算剩余自然日] + Days --> Node{命中 15/7/3 天节点?} + Node -->|否| End[本次不发送] + Node -->|是| Upsert[按资产+节点+接收人幂等写通知记录] + Upsert --> Notification[站内消息] + Upsert -. Phase 2 .-> WeCom[企业微信推送] +``` + +--- + +### 数据库变更 + +临期状态实时计算,不建立临期快照表,避免数据陈旧。为保证通知幂等,单独保存发送记录。 + +每日临期通知记录需防重,用一个 key 记录已发送或已创建的通知: + +```sql +-- 临期推送记录(防重) +CREATE TABLE tb_expiry_push_record ( + id BIGSERIAL PRIMARY KEY, + package_usage_id BIGINT NOT NULL, + asset_type VARCHAR(20) NOT NULL, -- iot_card | device + asset_id BIGINT NOT NULL, + recipient_id BIGINT NOT NULL, + channel VARCHAR(20) NOT NULL, -- notification | wecom + push_node INT NOT NULL, -- 推送节点(3/7/15天) + event_id VARCHAR(64) NOT NULL, + pushed_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX idx_expiry_push_idempotency + ON tb_expiry_push_record( + package_usage_id, recipient_id, channel, push_node + ); + +CREATE UNIQUE INDEX idx_expiry_push_event + ON tb_expiry_push_record(event_id); +``` + +同一资产可能对应多个业务员,唯一约束必须包含接收人和渠道;否则第一位业务员写入记录后会错误拦截其他接收人。`package_usage_id` 让同一资产续费生成新使用记录后可以再次触发 15/7/3 天提醒。站内消息和 Phase 2 企业微信都使用该表防重。 + +--- + +### 后端实现 + +#### 1. 现有接口新增临期字段 + +##### 资产列表接口(`GET /api/admin/iot-cards` / `GET /api/admin/devices`) + +响应新增字段: +```go +type IotCardListItem struct { + // ...原有字段... + DaysUntilExpiry *int `json:"days_until_expiry" description:"当前套餐剩余天数(无生效套餐为null)"` + IsExpiring bool `json:"is_expiring" description:"是否临期(≤15天且无排队套餐)"` +} +``` + +**计算方式**(在 SQL 层计算,避免 N+1): + +```sql +-- 列表查询时 JOIN PackageUsage 获取剩余天数 +SELECT + ic.*, + CASE + WHEN pu.expires_at IS NULL THEN NULL + WHEN (pu.expires_at AT TIME ZONE 'Asia/Shanghai')::date + < (NOW() AT TIME ZONE 'Asia/Shanghai')::date THEN NULL + WHEN EXISTS ( + SELECT 1 FROM tb_package_usage q + WHERE q.iot_card_id = ic.id AND q.status = 0 AND q.deleted_at IS NULL + ) THEN NULL -- 有排队套餐,不临期 + ELSE (pu.expires_at AT TIME ZONE 'Asia/Shanghai')::date + - (NOW() AT TIME ZONE 'Asia/Shanghai')::date + END AS days_until_expiry +FROM tb_iot_card ic +LEFT JOIN tb_package_usage pu + ON pu.iot_card_id = ic.id AND pu.status = 1 AND pu.deleted_at IS NULL +``` + +`is_expiring` 由同一 SQL 表达式派生:`days_until_expiry IS NOT NULL AND days_until_expiry BETWEEN 0 AND 15`。设备查询复用同一规则,不能另写一套日期计算。 + +##### 资产列表筛选条件新增 + +```go +type IotCardListRequest struct { + // ...原有字段... + ExpiringWithinDays *int `query:"expiring_within_days" description:"临期筛选(值=15表示查剩余≤15天)"` +} +``` + +##### 资产详情接口 + +后台资产详情走 `GET /api/admin/assets/resolve/:identifier`,响应 DTO 为 `AssetResolveResponse`(`internal/model/dto/asset_dto.go`)。 + +新增字段: +```go +// AssetResolveResponse 追加 +DaysUntilExpiry *int `json:"days_until_expiry" description:"当前套餐剩余天数(无生效套餐或有排队套餐时为null)"` +IsExpiring bool `json:"is_expiring" description:"是否临期(≤15天且无排队套餐)"` +``` + +#### 2. 新增临期列表接口(独立页面) + +``` +GET /api/admin/expiring-assets +``` + +查询参数: +```go +type ExpiringAssetsRequest struct { + AssetType string `query:"asset_type" description:"资产类型 (iot_card/device)"` + AssetIdentifier string `query:"asset_identifier" description:"资产标识(ICCID/设备号)"` + PackageName string `query:"package_name" description:"套餐名称"` + ShopID *uint `query:"shop_id" description:"店铺ID"` + ExpiresAtStart string `query:"expires_at_start" description:"到期时间起"` + ExpiresAtEnd string `query:"expires_at_end" description:"到期时间止"` + MaxDaysUntilExpiry *int `query:"max_days_until_expiry" description:"最大剩余天数(如15)"` + Page int `query:"page" default:"1"` + PageSize int `query:"page_size" default:"20"` +} +``` + +响应: +```go +type ExpiringAssetItem struct { + AssetType string `json:"asset_type"` + AssetIdentifier string `json:"asset_identifier"` + AssetStatus int `json:"asset_status"` + ShopName string `json:"shop_name"` + PackageName string `json:"package_name"` + DaysUntilExpiry int `json:"days_until_expiry"` + ExpiresAt time.Time `json:"expires_at"` + DataUsageMB int64 `json:"data_usage_mb"` + RemainingDataMB int64 `json:"remaining_data_mb"` +} +``` + +#### 3. 每日定时任务(仅通知) + +```go +// internal/task/expiry_reminder_handler.go + +// HandleExpiryReminder 每日03:00按中国自然日扫描通知阈值。 +func (h *ExpiryReminderHandler) HandleExpiryReminder(ctx context.Context, t *asynq.Task) error { + assets, err := h.packageUsageStore.GetExpiringAssetsForNotification(ctx, 15) + if err != nil { return err } + + for _, asset := range assets { + node := h.selectNearestUnsentNode(ctx, asset, []int{15, 7, 3}) + if node == 0 { + continue + } + today := time.Now().In(shanghaiLocation).Format("2006-01-02") // shanghaiLocation 由 time.LoadLocation("Asia/Shanghai") 初始化 + for _, recipientID := range asset.SalesmanIDs { + eventID := fmt.Sprintf( + "expiry:%d:%d:%d:%d:%s", + asset.PackageUsageID, node, recipientID, asset.AssetID, today, + ) + + // 在事务内先写 expiry_push_record,再通过 Outbox 发布站内消息。 + if err := h.notifyPublisher.Publish(ctx, notification.SendPayload{ + EventID: eventID, + RecipientIDs: []uint{recipientID}, + RecipientType: constants.NotifyRecipientAdmin, + Type: constants.NotifyTypePackageExpiring, + Title: fmt.Sprintf("套餐临期提醒(%d天节点)", node), + Body: fmt.Sprintf("资产 %s 的套餐剩余 %d 天", asset.Identifier, asset.DaysUntilExpiry), + RefType: asset.AssetType, + RefID: asset.AssetID, + }); err != nil { + return err + } + } + } + return nil +} +``` + +定时任务每日执行一次即可,页面不参与轮询。漏跑恢复后,对每个当前仍在 `0~15` 天范围内的资产,仅补发一个“当前最近且尚未发送”的阈值:例如第 15 天漏跑、剩余 14 天时补发 15 天通知;剩余 6 天时补发 7 天通知,不补发多条过期阈值。 + +--- + +### 导出功能(临期列表) + +使用统一导出任务: + +```text +POST /api/admin/export-tasks +scene=expiring_asset +``` + +导出字段: + +| 字段 | 说明 | +|------|------| +| 资产标识 | ICCID/设备号 | +| 资产类型 | 卡/设备 | +| 店铺名称 | | +| 套餐名称 | | +| 套餐到期时间 | | +| 剩余天数 | | +| 资产状态 | | +| 已用流量(MB) | | +| 剩余流量(MB) | | + +--- + +### C端接口变更 + +#### 公众号首页 + +现有接口(`GET /api/c/v1/asset/info`,通过 query param 传资产标识)的响应 DTO `AssetInfoResponse`(`internal/model/dto/client_asset_dto.go`)新增字段: + +```go +DaysUntilExpiry *int `json:"days_until_expiry" description:"当前套餐剩余天数(≤15天时有值,有排队套餐时为null)"` +IsExpiring bool `json:"is_expiring" description:"是否临期"` +``` + +前端逻辑:`is_expiring=true` 时展示续费提醒模块: + +``` +您的套餐即将到期 + +卡号/设备号:XXXX +剩余有效期:XX 天 + +为避免到期后影响正常使用,请您提前完成续费。 + +[立即续费] +``` + +--- + +### 前端对接(后台管理) + +#### 资产列表(IoT卡管理 / 设备管理) + +1. 列表新增"剩余天数"列 +2. 根据 `days_until_expiry` 高亮行: + - ≤ 15天:行背景粉红色 + - ≤ 7天:行背景紫色 + - ≤ 3天:行背景黄色 +3. 筛选条件新增"临期天数"(下拉:≤15天/≤7天/≤3天) + - 选中后传 `expiring_within_days=15` + +#### 临期资产独立列表页 + +路由:`/expiring-assets` + +``` +筛选栏:资产标识 | 资产类型(卡/设备) | 套餐名称 | 到期时间范围 | 剩余天数 | 店铺 + +列表:资产标识 | 资产类型 | 店铺 | 套餐名称 | 剩余天数 | 到期时间 | 资产状态 | 已用流量 | 剩余流量 + +操作:导出按钮 → `POST /api/admin/export-tasks`,`scene=expiring_asset` +``` + +#### 代理端首页 + +在首页数据接口新增字段(需要确认代理端首页接口): + +```go +type AgentDashboardResponse struct { + // ...原有字段... + ExpiringCardCount int `json:"expiring_card_count" description:"临期卡数量(≤15天)"` + ExpiringDeviceCount int `json:"expiring_device_count" description:"临期设备数量(≤15天)"` +} +``` + +前端展示快捷入口:"xx张卡即将到期" → 跳转资产列表并过滤 `expiring_within_days=15` + + +--- + +> 汇编来源:`docs/7月迭代/业务需求.md` + +> 本文保留原始业务需求措辞。涉及架构、字段和接口的最终技术口径,以同目录的专项技术方案和 `基础设施/` 文档为准。 + +其中有一些需要对接第三方系统的,除了gateway,都可以划分阶段 + + + +1.当前所有的卡在后台手动操作复机必须要实名,实际上行业卡应当允许未实名复机 +2. 用户进入H5后需要先绑定手机号后强制先充值后强制实名。这个功能能否进行后台设置,例如这一批设备需要强制先充值后实名,这一批资产可以先实名后充值? +3.店铺列表搜索栏新增一项:联系电话,便于搜索 +4.操作拦截:若当前资产存在退款申请时(但为通过审批时),该资产不允许操作换货。且出现提示:该资产存在退款申请 +5. 套餐延续创建时选择购买即生效或实名即生效的条件,同时在套餐分配时提供修改条件的功能,并以变更后的条件为最终版本,已经分配出去的套餐不会被后续的宿主套餐修改影响,需要回收后重新分配才能生效 +6. 在资产详情页增加所有待生效套餐加上生效套餐加起来的最后到期时间 +7.lot卡管理和设备管理新增已实名/未实名的筛选查询条件 +8.设备批量分配代理和套餐系列:因设备号不是连号,故需要提供导入excel表的方式进行批量分配代理和套餐系列。excle表头为:设备号 + +> 评审结论:拆成“批量分配代理”和“批量分配套餐系列”两个独立命令、两个前端入口和两个任务类型;可复用 Excel 解析与任务基础设施,但单个任务不得同时修改两个字段。 +9.C端支付时,卡资产只允许支付宝支付以及钱包支付,如果用微信支付就拒绝,设备只允许微信支付以及钱包支付,如果用支付宝支付就拒绝 +10.限速规则:根据不同运营商限速规则,基于套餐流量设置不同的卡/设备的限速规则,限速接口由gateway提供 + +> 技术口径说明:Gateway 只支持按 `cardNo` 限速,不存在设备级限速。单卡直接使用 ICCID;设备场景先查 `tb_device_sim_binding.is_current=true` 的当前卡,再使用该卡 ICCID。取消限速仍重复调用同一个限速接口,只是发送取消参数。本期仅提供后台手动设置/取消,内部单位为 `kbps`;不做套餐字段和自动限速规则。 + +11. 资产信息详情字段新增:资产信息页面卡信息/设备信息板块,将当前生效套餐的过期时间作为一个字段显示且套餐还剩15天到期时该字段高亮显示。 +12. 换货管理: +| 编号 | 需求 | +| ------- | ---------------------------------------------------- | +| EXC-001 | 修正换货列表旧资产标识和新资产标识显示混乱问题。 | +| EXC-002 | 换货列表中旧资产标识符和新资产标识符均显示为 ICCID。 | +| EXC-003 | 旧资产查询支持 ICCID、接入号、虚拟号。 | +| EXC-004 | 新资产查询支持 ICCID、接入号、虚拟号。 | +13.列表字段新增: +| 编号 | 模块 | 新增字段 | +| ------- | ------------ | -------------- | +| COL-001 | 退款管理列表 | 提交人、审批人 | +| COL-002 | 代理充值列表 | 提交人、审批人 | +| COL-003 | 换号管理列表 | 提交人 | +14. 导出功能: +### 6.8.1 lot 卡导出 + +#### 支持套餐临期30天内所有资产的导出。字段按照卡/设备的导出表进行导出。 + +| 编号 | 需求 | +| -------- | ---------------------------- | +| EXPD-001 | lot 卡导出字段新增套餐名称。 | +| EXPD-002 | lot 卡导出字段新增使用流量。 | +| EXPD-003 | lot 卡导出字段新增剩余流量。 | + +### 6.8.2 代理资金概况-预充值钱包流水导出 + +导出字段: + +| 字段 | 说明 | +| ------------------- | ------------------------------------------------------------ | +| 店铺名称 | 代理店铺名称 | +| 交易类型 | 充值、扣款、退款等 | +| 交易金额 | 以元为单位 | +| 状态 | 交易状态 | +| 资产类型 | 卡/设备等 | +| 资产标识 | ICCID/设备号等 | +| 交易时间 | 流水生成时间 | +| 交易前金额 | 交易前余额 | +| 交易后金额 | 交易后余额 | +| 购买套餐名称 | 资产此条扣款记录对应的套餐名称 | +| 操作人 | 明确交易执行主体:代理账号 / 平台账号(明确账号名称) | +| 交易 ID | 每一笔流水的全局唯一主键,彻底避免重复流水、对账串号、精准定位单条交易 | +| 关联业务订单号 | 充值、扣款、退款等交易对应的原始业务订单编号 | +| 交易渠道 / 支付方式 | 明确交易来源:余额支付等 | + +### 6.8.3 套餐列表导出 + +导出字段: + +| 字段 | +| -------------- | +| 套餐编码 | +| 套餐名称 | +| 套餐系列名称 | +| 套餐类型 | +| 套餐时长(月) | +| 套餐时长说明 | +| 套餐周期类型 | +| 套餐天数 | +| 真流量额度(MB) | +| 虚流量额度(MB) | +| 是否启用虚流量 | +| 虚流量比例 | +| 流量重置周期 | +| 到期时间基准 | +| 成本价(元) | +| 建议售价(元) | +| 价格配置状态 | +| 状态 | +| 上架状态 | +| 是否赠送套餐 | +| 创建人ID | +| 更新人ID | +| 创建时间 | +| 更新时间 | +| 删除时间 | + +### 6.8.4 退款管理退款列表导出 + +导出字段: + +| 字段 | +| ---------------- | +| 退款单号 | +| 代理店铺名称 | +| 关联的支付订单号 | +| 资产类型 | +| 资产标识 | +| 套餐名称 | +| 原订单金额 | +| 实收金额 | +| 可退金额 | +| 申请退款金额 | +| 实际退款金额 | +| 退款到账方式 | +| 状态 | +| 退款原因 | +| 备注 | +| 审批备注 | +| 退款申请时间 | +| 退款完成时间 | +| 提交人 | +| 部门领导审批人 | +| 财务审批人 | +| 退款凭证 | + +### 6.8.5 换货管理导出 + +#### C端客户有自己的唯一标识码。换货管理可以针对该C端客户记录该客户换过几次设备或卡。同时资产本身也做换货标识。 + +导出字段: + +| 字段 | +| ------------ | +| 换货单号 | +| 换货类型 | +| 换货原因 | +| 问题描述 | +| 旧资产类型 | +| 旧资产标识符 | +| 新资产标识符 | +| 收货人姓名 | +| 收货人电话 | +| 收货地址 | +| 快递公司 | +| 快递单号 | +| 状态 | +| 创建人 | +| 创建时间 | + +### 6.8.6 代理充值导出 + +导出字段: + +| 字段 | +| -------------- | +| 充值单号 | +| 店铺名称 | +| 充值类型 | +| 充值金额 | +| 实付金额 | +| 充值前余额 | +| 充值后余额 | +| 状态 | +| 支付方式 | +| 支付通道 | +| 运营备注 | +| 驳回原因 | +| 创建时间 | +| 支付时间 | +| 完成时间 | +| 提交人 | +| 部门领导审批人 | +| 财务审批人 | +| 支付凭证 | +| 备注 | + +| 套餐时长说明 | :这是剩余天数 + +| 退款到账方式 | + +这个好像没有 : + +#### 之后是否需要增加?因加入财务审批操作退款,可能有原路退回或不同的退款方式如支付宝退款或微信退款? + +| 部门领导审批人 | + +| 财务审批人 | + +这两个好像也没有 + +#### 目前是没有呀,但是之前不是说了审批流程需要加上吗?列表字段同时需要新增 + +| 支付通道 | + +这是啥玩意 + +#### 去掉 + +"导出功能可以根据不同权限显示的字段不同且可以自己选择需要导出的字段。像现在这样全量导出,大家都可以看到虚流量等不想让所有人都看到的字段。" 什么叫不同权限,这个不同权限显示字段不同是固定的吗,平台就是固定的,代理就是固定的等等,如果是这样的话需要标记对应的导出字段的权限划分 + +#### 因为不同的角色,权限不一致,列表的字段不能给每个用户看到类似真流量这样的字段,比如客服只能看到虚流量跟客户看到的一样,应当在角色管理中新增一个导出字段配置 + +> 评审结论:角色级导出字段配置属于本期范围。后端返回当前角色允许导出的字段,最终导出字段取“角色授权字段”与“用户本次选择字段”的交集,前端不能绕过服务端授权。 + + +15. 套餐相关: 套餐下架后,正在使用该套餐的客户仍可续费。,下架套餐续费仅支持客户自己购买。 ,下架套餐不可被新购买。 +16. 代理分销码与佣金提现 + +> 迭代范围变更(2026-07-14):需求 16 整体移出 7 月迭代,后续独立立项和评审。以下内容仅保留原始需求记录,本期不开发、不迁移、不发布。 + +#### 员工可作为代理发展人进行标识。(在系统中如何体现?) + +| 编号 | 需求 | +| ------- | ---------------------------------------------------- | +| DST-001 | 新建代理时自动建立分销归属关系。 | +| DST-002 | 员工可作为代理发展人进行标识。(在系统中如何体现?) | +| DST-003 | 佣金提现前,代理必须签署合同。(必填) | +| DST-004 | 佣金提现需上传营业执照。(可选) | +| DST-005 | 佣金提现需上传法人身份证。(必填) | +| DST-006 | 佣金提现可选上传门头照。(可选) | +| DST-007 | 佣金提现需上传发票,且公司主体需与合同一致。(可选) | + +> 当前结论:原技术方案不再属于 7 月迭代范围。后续重新立项时再评审分销关系、代理申请审批、开店幂等和提现材料。 + +17. 不同渠道信用额度,钱包支持信用额度支付 +| 编号 | 需求 | +| ------- | ------------------------------------------------------------ | +| BPO-009 | 新建代理时新增“是否可授权额度”开关。平台用户账号默认拥有授权额度。 | +| BPO-010 | 不同代理可设置不同额度下限。平台用户可根据不同的角色设置不同的额度下限。 | +| BPO-011 | 授权额度用于订购套餐、代理余额充值等需要涉及金额的所有模块。 | +| BPO-012 | 授权额度代理可显示负数余额。平台用户也可显示负数余额。 | + +> 评审结论:信用额度只属于代理主钱包。平台员工是操作主体而不是结算主体,不建立员工钱包、不配置员工信用额度,也不展示员工负余额;角色仅控制谁可以查看或调整代理额度。 + +18. 系统原先对于审核都是单人审核,现在希望增加多人审批以及相关设置 +| 编号 | 需求 | +| ------- | ------------------------------------------------------------ | +| APR-001 | 代理可在系统提交充值申请。 | +| APR-002 | 提交充值申请后系统展示收款二维码,代理扫码支付。 | +| APR-003 | 充值和退款均支持多级审核。 | +| APR-004 | 审核环节包括提交人部门领导审核和财务审核。 | +| APR-005 | 待审核订单需有消息提示。 | +| APR-006 | 审核提醒按流程环节触发,上一审批人完成审批后才提示下一审批人。 | +| APR-007 | 审核通过后通知申请人。 | +| APR-008 | 审核驳回后通知申请人,并附带驳回原因。 | +| APR-009 | 审核流程需对接企业微信审批流程。 | + +> 技术口径说明:当前系统没有部门组织模型。“部门领导审核、财务审核”作为默认流程节点名称处理,实际审批人由流程定义配置的角色或指定账号产生,不根据提交人部门自动推导,也不在代码中固定角色名称。每次通过、驳回、退回都形成不可修改的审批意见记录,并可附带最多 5 个审批附件;驳回和退回意见必填。 + +> 审批详情必须展示业务单号、提交人、审批关键字段、业务资料、此前审批人的意见和审批附件。业务资料与审批附件分开存储和展示;流程启动时固化业务快照,实时业务页仍按原业务数据范围校验。 + +19.批量订购套餐 +内部员工进入批量订购页面。 +2. 选择代理、ICCID号段/设备号、订购套餐。或上传 Excel 文件,资产标识支持 ICCID/设备号 +3. Excel表头:跳转至6.3会显示。 +4. 系统校验导入文件和资产状态。 +5. 校验通过的资产从代理余额扣款并完成订购。 +6. 校验失败的明细在页面展示失败原因。 +7. 系统记录操作员、导入明细、导入数量和导入时间。 + +| 编号 | 需求 | +| ------- | ---------------------------------------------------- | +| BPO-001 | 批量订购由内部员工操作。 | +| BPO-002 | 支持代理自行充值钱包后,由员工批量订购并从余额扣款。 | +| BPO-003 | 支持员工代充值至代理余额后,再批量订购并从余额扣款。 | +| BPO-004 | 批量订购无需审核。 | +| BPO-005 | 资产标识支持 ICCID/设备号。 | +| BPO-006 | 支持 Excel 模板导入。 | +| BPO-007 | 需记录操作员、导入明细、导入数量和导入时间。 | +| BPO-008 | 导入失败明细需展示在页面,并显示失败原因。 | + +excel表导入字段: +| 字段 | +| ----------------------------- | +| 资产类型 | +| 资产标识 | +| 套餐系列名称 | +| 套餐名称 | +| 代理名称 | +| 支付方式:代理商账户/员工账户 | + +excel表导入字段修改为以下: + +| 字段 | + +| ----------------------------- | + +| 资产类型 | + +| 资产标识 | + +| 套餐编码 | + +| 套餐名称| + +| 支付方式:线下支付/代理钱包支付 | + +如果用批量订购应当上传对应凭证 + +> 评审结论:支付方式按整批统一。页面选择“线下支付”或“代理钱包支付”,Excel 不再包含支付方式列;线下支付按整批上传凭证,混合支付必须拆成不同批次。 + +20.退款审批 +(审批均可通过企微进行提醒和显示并将最新状态同步至卡管) +1. 员工提交退款申请。 +2. 退款单进入多级审核流程。 +3. 按部门领导、财务顺序审批。 +4. 当前环节审批完成后,下一环节审批人收到消息提示。 +5. 审批通过或驳回后通知申请人。 + +> 评审结论:微信、支付宝和线下退款由财务在系统外人工完成后确认;本期不接入第三方自动退款。代理钱包支付的退款仅自动回退原扣款代理主钱包,客户资产钱包不在本期自动回退范围。 +21. 充值审核流程 +代理自己充值: +1. 代理在系统提交充值申请。 +2. 系统展示收款二维码。 +3. 代理扫码支付。 + +员工代充值:(审批均可通过企微进行提醒和显示并将最新状态同步至卡管) +1. 充值单进入多级审核流程。 +2. 提交人部门领导先审批。 +3. 财务在上一审批人通过后收到待办提醒并审批。 +4. 审批通过后通知申请人。 +5. 审批驳回后通知申请人,并展示驳回原因。 + +> 技术口径说明:审批通过只表示审批结论成立,不表示退款已经到账或充值已经入账。退款、充值分别维护业务处理状态并支持异步重试。此次采用停机发布,旧退款业务单审批接口和线下充值 `offline-pay/reject` 不保留兼容窗口。 + +22. 套餐临期提醒 +1. 系统每日计算卡/设备套餐剩余有效期。 +2. 命中临期规则后生成临期数据。临期提醒规则:按 15 天、7 天、3 天节点分别进行不同方式的提醒。 +3. 企业客户场景:按 15 天、7 天、3 天节点生成临期列表,并通过企业微信推送给对应业务员。 +4. 代理端场景(展示):首页展示卡、设备临期数量;资产列表按临期天数高亮。 +5. C 端场景(展示):公众号首页在套餐剩余有效期小于或等于 15天时展示续费提醒,并显示立即续费按钮。 +6. 当资产续费成功或不再满足临期条件时,临期提醒自动取消。 +### 6.1.1 企业客户临期提醒 +| 编号 | 需求 | +| ------- | ------------------------------------------------------------ | +| EXP-001 | 后台每日生成企业客户临期列表。 | +| EXP-002 | 临期节点包括 15 天、7 天、3 天。 | +| EXP-003 | 系统需对接企业微信,将临期列表推送给对应业务员。 | +| EXP-004 | 同一资产在不同临期节点可重复触发对应节点提醒,但同一节点每日不可重复推送给同一业务员。 | + +### 6.1.2 代理端临期提醒 + +| 编号 | 需求 | +| ------- | ------------------------------------------------------------ | +| EXP-005 | 代理端首页分别展示临期卡数量和临期设备数量。 | +| EXP-006 | 代理端资产列表对临期资产进行颜色标记。 | +| EXP-007 | 剩余 15 天标记为粉色,剩余 7 天标记为紫色,剩余 3 天标记为红色。 | +| EXP-008 | 剩余 3 天资产在列表中置顶优先展示。 | + +### 6.1.3 C 端公众号首页提醒 + +| 编号 | 需求 | +| ------- | ------------------------------------------------------------ | +| EXP-009 | 当客户套餐剩余有效期小于或等于 15 天时,公众号首页展示套餐到期提醒。 | +| EXP-010 | 提醒展示卡号/设备号、剩余有效期和续费引导文案。 | +| EXP-011 | 按钮文案为“立即续费”。 | +| EXP-012 | 剩余天数需要按日期每天自动更新。 | +| EXP-013 | 当套餐已续费或不再满足临期条件时,提醒不再展示。 | +推荐展示文案: +您的套餐即将到期 + +卡号/设备号:xxx +剩余有效期:xx 天 + +为避免到期后影响正常使用,请您提前完成续费。 + +当一个资产拥有排队中的套餐时不属于临期,不参与临期提醒 + +后台管理只需要在列表检索中新增临期时间字段 条件为小于等于15天的 + +后台管理中资产详情需要新增一个字段临期时间从15天开始显示,前端应当高亮或者变红 + +后台管理中资产列表需要新增返回字段,套餐还有多少天过期 + +临期列表页面需要确认列表展示什么,检索有什么,导出要什么 + + ##### 展示:资产标识、资产类型、店铺、套餐名称、剩余天数、到期时间、资产状态、已用流量、剩余流量 + + ##### 检索条件:资产标识、资产类型、套餐名称、到期时间范围、剩余天数、店铺 + + ##### 导出:资产标识、资产类型、店铺名称、套餐名称、套餐到期时间、剩余天数、资产状态、已用流量、剩余流量 + +临期规则 +企业客户 15,7,3天 +代理 15,7,3天 +C端公众号 小于等于15天 + +颜色规则 +临期天数小于等于15天时,显示粉红色; +临期天数小于等于7天时,显示紫色; +临期天数小于等于3天时,显示黄色; diff --git a/docs/7月迭代/7月迭代技术方案-标准评审稿.md b/docs/7月迭代/7月迭代技术方案-标准评审稿.md new file mode 100644 index 0000000..af164a7 --- /dev/null +++ b/docs/7月迭代/7月迭代技术方案-标准评审稿.md @@ -0,0 +1,1339 @@ +# 7月迭代技术方案(标准评审稿) + +> 状态:待评审 +> 最后更新:2026-07-15 +> 分支:`Iteration/7-11` +> 系统:`junhong_cmp_fiber` 及配套后台、代理端、C 端前端 +> 负责人:待指定 +> 评审人:后端、前端、产品、验收负责人、运维 +> 关联需求:7月迭代需求 01~22(需求16已移出)及新增的数据同步、企微审批、站内通知、全局审计、代理钱包扫码充值 +> 说明:本文是技术评审主文档;完整版和专项文档保留为设计素材,不作为会议逐页评审内容。 + +--- + +## 一、评审摘要 + +### 1.1 背景与目标 + +当前退款、线下充值审批各自维护,卡实名、流量和网络状态又被轮询、手动刷新及业务操作分别写入,容易出现同一业务动作只更新部分状态。现有账号和资产审计也没有统一模型,无法按人员、资源、请求、资金链路和外部交互追踪。复杂资金和状态业务仍集中在旧 Service 中,配置、批量任务、通知和导出权限缺少统一契约。 + +本次评审覆盖原需求 01~22(需求16移出)及五项新增技术需求,主要解决以下问题: + +- 退款和平台员工线下充值直接接入企业微信审批,本系统只维护业务快照、审批状态镜像和终态处理。 +- 将卡实名、流量和网络状态写入收口到统一 DDD 用例,以轮询兜底、业务事件阶梯触发和运营商回调提高同步及时性。 +- 一次性切换全局 Audit Event、Integration Log 和多视角审计中心,旧审计表停止新写入。 +- 补齐代理后台微信/支付宝扫码充值,在线支付成功后直接幂等入代理主钱包,不进入审批。 +- 将信用额度、批量订购、退款和充值中的资金规则收敛为可并发校验的不变量。 +- 补齐异步任务、站内消息、动态配置、导出字段权限和 Gateway 限速等基础能力。 +- 统一后台、代理端和 C 端的页面状态、异步反馈和异常展示。 +- 在不一次性重构旧系统的前提下,按完整用例渐进迁移 DDD。 + +### 1.2 非目标 + +本期明确不做: + +- 本地通用审批流引擎、审批节点配置、审批人解析、待我审批和本地审批动作页面。 +- BPMN 全规范、拖拽流程设计器、部门组织模型和本地直属领导计算。 +- 套餐临期企业微信消息推送;本期先完成站内通知和防重记录。 +- 全仓 MVC/贫血模型一次性重构。 +- 为旧退款和线下充值审批接口建设长期兼容层。 +- 后端提供 Excel 模板下载接口;模板由前端静态资源随版本发布。 +- 微信/支付宝自动退款,以及基于套餐规则的自动限速。 +- 运营商解除实名后自动回滚本地实名状态;第一版只保留防腐层入口和集成记录。 + +### 1.3 已确认决策 + +| 编号 | 决策 | 影响范围 | +|------|------|----------| +| D-01 | 采用触碰式渐进 DDD,以完整用例为迁移单位,不以整个模块为单位重构 | 全局 | +| D-02 | 复杂写进入 Domain;简单写使用 Application 事务脚本;复杂查询走独立 Query 通道 | 全局 | +| D-03 | 不建设本地审批流;节点、审批人、意见和审批附件全部由企微模板及审批详情负责 | 18、20、21 | +| D-04 | 本系统保存提交业务快照、本地业务资料和企微审批详情快照,审批人能够看到审批对象、备注和附件 | 18、20、21 | +| D-05 | 平台员工不建立钱包信用额度,信用额度只属于代理主钱包 | 17、19 | +| D-06 | 批量订购支付方式整批统一,Excel 不包含支付方式 | 19 | +| D-07 | 角色级导出字段权限本期落地,服务端取角色授权与用户选择的交集 | 14 | +| D-08 | Gateway 只按 `cardNo` 手动设置/取消限速;设备入口先解析当前绑定卡,不做套餐自动限速 | 10 | +| D-09 | 需求16整体移出本期,不创建相关表、API、流程、页面和迁移 | 16 | +| D-10 | 退款金额在发起时固定;非代理钱包退款由财务人工处理,代理钱包仅回退原扣款主钱包;线下充值企微通过后自动入账 | 20、21 | +| D-11 | 采用停机发布,同时切换数据库、API、Worker 和前端,旧审批接口同步下线 | 20、21 | +| D-12 | 企微模板 ID 和控件 ID 均按不可变版本映射;编辑模板前暂停场景,发布新映射后原子切换 | 企微审批 | +| D-13 | 设备批量分配代理与套餐系列拆为两个独立命令 | 08 | +| D-14 | 排队套餐使用购买时长快照推算预计最后到期时间;临期页面实时查询,定时任务仅发送通知 | 06、22 | +| D-15 | 行业卡是否需要实名由运营商 `realname_link_type` 决定,`card_category` 不参与实名、复机和轮询判断 | 01、02、数据同步 | +| D-16 | 数据同步保留轮询兜底,关键业务事件按立即、3 分钟、5 分钟触发;超频不建立退避状态 | 数据同步 | +| D-17 | 企微账号由已登录平台员工扫码自助绑定,普通运营不录入 `userid` | 企微审批 | +| D-18 | 全系统审计本次一次性切换到 Audit Event + Integration Log,多视角 API 和前端同时发布 | 全局 | +| D-19 | 代理在线充值最低 100 元,支持微信 Native 和支付宝 PreCreate,支付成功直接入主钱包且不审批 | 21、新增充值 | + +### 1.4 本期边界 + +本期包含企业微信审批、账号扫码绑定、回调与轮询补偿;不再保留“先做站内审批、以后切企微”的中间态。套餐临期的企业微信消息推送仍属于后续扩展,本期只实现站内通知及外部渠道可扩展边界。 + +--- + +## 二、系统架构与渐进式 DDD + +### 2.1 系统上下文 + +```mermaid +flowchart LR + Admin[后台管理端] --> API[Junhong API] + Agent[代理端] --> API + Client[C端 H5/公众号] --> API + API --> PG[(PostgreSQL)] + API --> Redis[(Redis)] + API --> Gateway[Gateway] + API --> Outbox[(Outbox)] + Outbox --> Relay[Outbox Relay] + Relay --> Asynq[Asynq] + Asynq --> Worker[Worker] + Worker --> Notice[站内消息] + Worker --> Biz[退款/充值/批量任务] + Worker --> WeCom[企业微信] + Worker --> Pay[微信/支付宝] + Worker --> Gateway[Gateway] + Carrier[运营商回调] --> API + Pay --> API + API --> Audit[(Audit/Integration Log)] +``` + +边界约束: + +- API 负责同步校验、数据库事务和返回可追踪状态。 +- Worker 负责通知、导出、批量处理及审批后的业务动作。 +- Redis/Asynq 不参与核心业务事实的唯一持久化;关键事件先写 Outbox。 +- Gateway、运营商回调、微信/支付宝和企业微信均通过 Infrastructure Adapter 接入,外部字段不得直接进入领域模型。 + +### 2.2 渐进迁移规则 + +```text +复杂写:Handler -> Application -> Domain -> Repository/Infrastructure +简单写:Handler -> Application -> Store/GORM +复杂查询:Handler -> Query -> GORM/DTO +旧用例未触碰:继续 Handler -> Service -> Store -> Model +``` + +| 场景 | 选择 | 判断依据 | +|------|------|----------| +| 钱包扣款、授信、卡状态应用、退款状态转换 | Domain | 有不变量、状态机、并发或跨表一致性 | +| 单字段修改、简单开关、局部 CRUD | Application 事务脚本 | 规则少且不会形成复杂状态 | +| 列表、报表、导出、跨聚合详情 | Query | 只读、字段多、需要 JOIN/聚合/分页 | +| 未被本次用例触碰的旧逻辑 | 保持旧架构 | 避免扩大认知和回归范围 | + +站内消息只是幂等写入和已读状态管理,采用轻量 Application/Infrastructure 即可,不为满足目录形式强行创建空洞聚合。 + +迁移单位必须是一个完整用例。例如修改“钱包扣款”时,应同时迁移该用例的规则、事务、仓储和事件,不要求顺带迁移所有钱包查询或后台 CRUD。 + +### 2.3 查询侧规则 + +- Query 可以直接使用 GORM、JOIN、聚合和只读 DTO,不要求经过聚合根。 +- Query 不修改状态、不发布领域事件、不承担业务不变量。 +- 列表默认分页 20、最大 100;导出和批量详情使用批量查询,禁止 N+1。 +- 企微审批摘要、审计多视角、导出字段和套餐临期列表均属于 Query 场景。 + +### 2.4 跨需求技术约定 + +| 项目 | 约定 | +|------|------| +| 管理端 API | `/api/admin/*` | +| C 端 API | `/api/c/v1/*` | +| 金额 | 数据库与接口使用分,前端显示元 | +| 时间 | 后端返回 ISO 8601,前端统一本地化显示 | +| 状态 | 生命周期使用 `int`,类型/方式使用 `string` | +| 创建幂等 | 稳定业务键或 `request_id` + 唯一索引 | +| 状态幂等 | `WHERE status = expected` 条件更新 | +| 余额并发 | 版本号或行锁,并在同一事务写资金流水 | +| 异步事件 | 业务事务内写 Outbox,Relay 投递 Asynq,消费者按至少一次设计 | +| 权限 | 后端强校验,前端隐藏按钮不能作为权限边界 | + +--- + +## 三、跨需求基础设施 + +### 3.1 系统配置 + +#### 目标 + +将支付方式等受控配置从代码迁移到数据库,平台超管修改后无需重新部署;H5 实名流程继续由资产自身字段决定,不增加全局默认 Key。 + +#### 数据模型 + +`tb_system_config` 关键字段: + +| 字段 | 说明 | +|------|------| +| `config_key` | 唯一键,格式 `module.group.name` | +| `config_value` | 字符串形式的配置值 | +| `value_type` | `string/int/bool/json` | +| `module` | 前端分组和查询维度 | +| `description` | 中文说明 | +| `is_readonly` | 只读配置不能从后台修改 | +| `updater/updated_at` | 审计信息 | + +唯一约束:`UNIQUE(config_key)`。 + +首批配置: + +| Key | 默认值 | 用途 | +|-----|--------|------| +| `c2b.payment.card_allowed_methods` | `["alipay","wallet"]` | 卡资产 C 端支付方式 | +| `c2b.payment.device_allowed_methods` | `["wechat","wallet"]` | 设备 C 端支付方式 | + +#### 更新流程 + +```mermaid +sequenceDiagram + actor Admin as 平台超管 + participant Web as 系统配置页 + participant API as Config Application + participant DB as PostgreSQL + participant Redis as Redis + + Admin->>Web: 修改受控表单 + Web->>API: PUT /api/admin/system/config/{key} + API->>API: 按 key 校验允许值 + API->>DB: 更新并记录操作人 + API->>Redis: 删除 sys:config:{key} + API-->>Web: 返回最新配置 +``` + +- 配置读取缓存 5 分钟,更新后立即失效。 +- 不能只按 `value_type` 校验;每个已注册 Key 必须限制枚举和值域。 +- 未注册 Key 默认只读,禁止通用接口写入任意配置。 +- 配置解析失败、Redis/数据库异常时使用最近一次有效值或代码安全默认值并告警,禁止静默放开未知支付方式。 +- 前端使用复选框、单选或开关,不向运营人员暴露 JSON 文本框。 + +#### API + +| 方法 | 路径 | 权限 | +|------|------|------| +| GET | `/api/admin/system/config?module=` | 系统配置查看 | +| PUT | `/api/admin/system/config/{config_key}` | 平台超管 | + +### 3.2 站内消息 + +#### 设计 + +```mermaid +sequenceDiagram + participant Biz as 业务事务 + participant Outbox as Outbox + participant Relay as Relay + participant Worker as Notification Worker + participant DB as tb_notification + participant Web as 前端 + + Biz->>Outbox: 同事务写领域事件 + Relay->>Worker: 至少一次投递 + Worker->>DB: event_id + recipient 幂等插入 + Web->>DB: 经 API 轮询和标记已读 +``` + +`tb_notification` 关键字段:`event_id`、`recipient_kind`、`recipient_id`、`category`、`type`、`severity`、`title`、`body`、`ref_type`、`ref_id/ref_key`、`is_read`、`read_at`、`expires_at`。 + +关键约束: + +- 唯一索引:`event_id + recipient_kind + recipient_id`,Outbox/Asynq 重试不得重复建消息。 +- 未读和分类索引均以 `recipient_kind + recipient_id` 开头,所有 Query 和已读更新从登录上下文固定接收人。 +- 正文使用受控纯文本模板,不保存任意 HTML、密钥、操作密码、完整个人信息或长期附件 URL。 +- 企微审批待办由企业微信负责,本系统只发送审批结果、通过后撤销异常、模板失效和业务处理结果通知。 +- 前端每 30 秒轮询未读数,不使用 WebSocket;页面不可见时暂停,恢复时立即刷新。 +- `ref_type/ref_id/ref_key` 只用于受控目标解析,跳转后仍需业务权限校验。 + +通知类型: + +| 类型 | 接收人 | +|------|--------| +| `wecom.approval.approved/rejected/cancelled` | 申请人账号 | +| `wecom.approval.revoked_after_approved` | 申请人和财务角色账号 | +| `wecom.template.invalid` | 平台超管 | +| `package.expiring` | 代理/企业相关账号 | +| `card_sync.failed` | 平台运维角色 | +| `carrier_callback.card_not_found` | 平台运维角色 | +| `system.alert` | 平台超管或指定运维角色 | + +API: + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/admin/notifications/unread-count` | 未读总数 | +| GET | `/api/admin/notifications/unread-summary` | 分类未读数 | +| GET | `/api/admin/notifications` | 按分类、类型、级别和已读状态分页 | +| PUT | `/api/admin/notifications/{id}/read` | 幂等标记单条已读 | +| PUT | `/api/admin/notifications/read-all` | 全部或按分类已读 | +| GET | `/api/admin/notifications/{id}/target` | 返回受控目标类型,不返回任意 URL | +| GET | `/api/c/v1/notifications/unread-count` | C 端当前客户未读数 | +| GET | `/api/c/v1/notifications` | C 端简化消息列表 | +| PUT | `/api/c/v1/notifications/{id}/read` | C 端幂等已读 | +| PUT | `/api/c/v1/notifications/read-all` | C 端全部已读 | + +### 3.3 企业微信审批接入 + +#### 3.3.1 系统边界 + +企业微信负责模板、审批节点、审批人、会签/或签、通过、驳回、撤销、审批意见、审批附件和企业微信端待办。本系统不保存本地审批任务,也不提供审批按钮,只负责: + +- 创建退款和平台员工线下充值业务单。 +- 保存提交时业务快照和本地业务资料,上传企微附件副本。 +- 以稳定场景码映射企微模板版本和控件 ID。 +- 保存 `sp_no`、审批状态、审批人/意见/附件详情快照。 +- 接收回调并以 `getapprovaldetail` 查询作为状态权威来源。 +- 审批终态后幂等执行退款或线下充值业务处理。 + +Viper 配置包含 `corp_id`、`agent_id`、`agent_secret`、回调 Token/EncodingAESKey、审批回调路径、账号绑定回调 URL、2 分钟审批轮询和 10 分钟模板验证间隔。Secret 只允许环境变量覆盖,后台只返回“是否配置”和最近连通结果。Access Token 缓存在 Redis,TTL 使用 `expires_in-300秒` 并通过锁避免并发刷新。 + +```mermaid +sequenceDiagram + actor Staff as 平台员工 + participant App as Refund/Recharge Application + participant DB as PostgreSQL + participant Outbox as Outbox + participant Worker as WeCom Worker + participant WeCom as 企业微信 + participant Sync as Approval Sync + participant Biz as 业务终态用例 + + Staff->>App: 创建退款/线下充值 + App->>DB: 业务单+企微实例(submitting) + App->>Outbox: SubmissionRequested + Worker->>WeCom: 上传附件并applyevent + WeCom-->>Worker: sp_no + WeCom->>Sync: 加密状态回调 + Sync->>WeCom: getapprovaldetail + Sync->>DB: 保存详情快照并幂等更新状态 + Sync->>Outbox: 首次终态写业务事件 + Outbox->>Biz: 退款/充值终态处理 +``` + +#### 3.3.2 模板版本与暂停切换 + +业务代码只引用稳定场景码: + +| `scene_code` | 业务 | +|--------------|------| +| `refund_approval` | 退款审批 | +| `offline_recharge_approval` | 平台员工线下充值审批 | + +`tb_wecom_approval_scene` 保存场景启用、暂停中、已暂停和当前模板版本;`tb_wecom_approval_template_version` 保存不可变的 `template_id`、控件映射、模板快照、fingerprint、验证状态和发布时间。 + +企微编辑模板可能更换模板 ID,删除再添加控件也会更换控件 ID,因此发布流程必须是: + +```text +暂停业务场景并等待提交租约清空 +→ 在企微编辑模板 +→ 输入新的 template_id 并读取模板 +→ 将稳定业务字段映射到当前控件 ID +→ 后端重新调用 gettemplatedetail 校验 +→ 原子发布新版本并恢复场景 +``` + +退款至少映射店铺、退款单号、申请金额、原因、附件和提交人;线下充值至少映射店铺、充值单号、金额、备注、附件和提交人。后台每 10 分钟验证启用版本,模板不可访问或 fingerprint 变化时暂停场景并发送系统告警。 + +#### 3.3.3 系统账号扫码绑定企微成员 + +普通运营不录入 `userid`。员工先登录本系统,再点击“绑定企业微信”,后端生成一次性 `state` 和企微 Web 登录 URL;扫码回调后使用自建应用 Access Token 调用 `auth/getuserinfo` 获取 `userid`。 + +```text +系统登录账号 +→ 创建5分钟绑定会话 +→ 打开企微Web登录二维码 +→ 回调code+state +→ 原子消费state +→ 换取userid并校验企业成员 +→ 保存一对一绑定 +``` + +约束: + +- `state` 与前端查询 `session_id` 分开存 Redis;`state` 单次使用,绑定结果会话保留 10 分钟。 +- 回调不接受 `account_id`,目标账号只能来自服务端绑定会话。 +- `account_id` 和 `wecom_userid` 均唯一;成员已绑定其他账号时拒绝覆盖。 +- 自助解绑和管理员强制解绑不影响历史审批快照,但会阻止新审批。 +- 管理员只查看绑定状态和强制解绑,不提供 `userid` 输入框。 +- 企微自建应用可信域名和可见范围必须覆盖需要发起审批的平台员工。 + +`tb_account_wecom_mapping` 保存账号、`userid`、成员名称、CorpID、AgentID、绑定来源和验证时间。 + +#### 3.3.4 审批实例与状态同步 + +`tb_wecom_approval_instance` 保存:业务类型/ID/编号、轮次、场景、模板版本和映射快照、创建人本地账号与企微 `userid`、`sp_no`、提交业务快照、企微详情和审批人快照、业务处理结果、轮询时间、乐观锁版本。 + +状态: + +| 本地状态 | 企微状态 | +|----------|----------| +| 提交中/提交失败/提交结果未知 | 尚未取得明确审批单结果 | +| 审批中 | `sp_status=1` | +| 已通过 | `sp_status=2` | +| 已驳回 | `sp_status=3` | +| 已撤销 | `sp_status=4` | +| 通过后撤销 | `sp_status=6` | +| 已删除 | `sp_status=7` | + +`applyevent` 请求已发送但响应超时必须标记“提交结果未知”,禁止盲目重试产生重复审批。管理员在企微核对后选择“确认未创建并重新提交”或手工绑定已有 `sp_no`,后者记录高风险审计。 + +回调只负责验签、解密、保存 Integration Log 并触发统一 `SyncApprovalStatus`。审批中实例每 2 分钟兜底查询,回调和轮询共用状态同步用例;首次进入终态时同事务写业务 Outbox,`business_processed_at` 保证资金动作只执行一次。 + +#### 3.3.5 DDD 边界与 API + +```text +domain/wecomapproval 场景、模板版本、审批状态和终态幂等 +application/wecomapproval 发布模板、提交审批、回调、同步和异常恢复 +domain/wecomidentity 系统账号与企微成员一对一绑定 +application/wecomidentity 创建绑定会话、完成绑定和解绑 +infrastructure/adapter/wecom Token、模板、附件、审批、身份和回调加解密 +query/wecomapproval 审批运行列表、详情和业务摘要 +``` + +主要 API: + +| 方法 | 路径 | 用途 | +|------|------|------| +| GET | `/api/admin/wecom/status` | 查询连接配置状态 | +| PUT | `/api/admin/wecom/approval-scenes/{scene_code}/status` | 暂停或恢复场景 | +| POST | `/api/admin/wecom/approval-templates/inspect` | 读取企微模板 | +| POST | `/api/admin/wecom/approval-templates/publish` | 发布控件映射版本 | +| GET | `/api/admin/wecom/approval-templates` | 模板版本列表 | +| POST | `/api/admin/wecom/account-binding/sessions` | 当前账号创建扫码绑定会话 | +| GET | `/api/admin/wecom/account-binding/sessions/{session_id}` | 查询扫码绑定结果 | +| GET/DELETE | `/api/admin/wecom/account-binding/me` | 查询或解绑自己 | +| GET | `/api/admin/wecom/account-bindings` | 超管查询绑定列表 | +| DELETE | `/api/admin/wecom/account-bindings/{account_id}` | 超管强制解绑 | +| GET | `/api/callback/wecom/account-binding` | Web 登录回调 | +| GET/POST | `/api/callback/wecom/approval` | 企微审批回调校验和事件 | +| GET | `/api/admin/wecom/approvals*` | 审批运行列表和详情 | +| POST | `/api/admin/wecom/approvals/{id}/sync` | 立即同步详情 | + +### 3.4 数据同步触发与轮询优化 + +#### 3.4.1 三条自动通道 + +轮询、业务事件触发和运营商回调相互隔离,但最终都进入统一的卡状态应用用例: + +```mermaid +flowchart LR + Polling[活跃/不活跃轮询] --> Request[RequestCardObservation] + Event[关键业务埋点] --> Series[立即/3分钟/5分钟] + Series --> Request + Manual[现有手动刷新] --> Request + Request --> Gateway[Gateway Adapter] + Gateway --> Apply[ApplyCardObservation] + Callback[运营商实名回调] --> ACL[Carrier ACL] + ACL --> Apply + Apply --> Card[卡状态聚合] + Apply --> Outbox[套餐/停复机等事件] + Request --> Integration[Integration Log] + ACL --> Integration +``` + +- 轮询始终兜底,只保留 `enable_polling` 总开关,按活跃/不活跃使用不同间隔。 +- 业务事件不是新接口,而是在查询资产、获取实名链接、停复机、支付、套餐激活、切卡、重启等用例成功边界埋点。 +- 每个事件序列默认创建立即、3 分钟、5 分钟三个无自动重试任务;达到预期状态后剩余任务提前完成。 +- 现有手动刷新接口保持响应契约并直接同步,不再额外创建阶梯任务。 +- Gateway 超频只记录当前 `rate_limited`,不维护 `blocked_until` 或 30/60/120 秒退避,后续阶梯任务和轮询保持原计划。 + +#### 3.4.2 实名判断与状态应用 + +行业卡不能统一跳过实名。运营商 `realname_link_type` 决定实名要求和链接方式: + +| 值 | 规则 | +|----|------| +| `none` | 不需要实名,不创建实名查询任务 | +| `template` | 需要实名,按模板生成链接 | +| `gateway` | 需要实名,通过 Gateway 获取链接 | + +`card_category` 只用于分类和展示,不参与实名、复机、套餐激活和轮询资格。资产 `realname_policy` 只决定实名与购买顺序;`realname_link_type=none` 时有效策略统一视为 `none`。 + +`ApplyCardObservation` 是唯一允许写入实名、流量和网络状态的入口:加载并锁定卡聚合,应用标准观测值,状态变化时同事务保存领域事件和 Audit Event,外部调用始终写 Integration Log。旧轮询 Handler、手动实名修改和刷新 Service 必须改为调用该用例,不保留双实现。 + +#### 3.4.3 活跃调频和请求协调 + +卡维护活跃级别、最后活跃时间/场景和最后上游变化时间。C 端/后台/OpenAPI 查询、实名、停复机、支付、套餐、切卡、回调和轮询发现变化均标记活跃;持续无业务活动且轮询无变化后转为不活跃。 + +第一版建议 `inactive_after=30m`;活跃卡沿用现有实名/流量/状态默认间隔,不活跃卡三类查询初始均为 15 分钟。上线前按生产卡量只读测算 QPS 后再调整,数值进入轮询配置而不是写死代码。 + +重复控制拆为: + +- 同场景、同资源、同同步类型合并未结束的 `0/3/5` 序列。 +- 单卡、运营商、接口类型只在一次 Gateway 请求执行期间互斥。 +- 运营商最小请求间隔按接入和接口类型配置,默认兜底 10 秒,不使用统一五分钟冷却。 + +#### 3.4.4 运营商实名回调防腐层 + +移动、联通、电信分别实现报文 Translator,把 JSON/XML/表单、状态码和成功应答转换为标准实名观测。业务结论百分百信任,不验证来源真实性,也不反查 Gateway。 + +ICCID 必须按原始长度精确路由:19 位只查 `iccid_19`,20 位只查 `iccid_20`,禁止截断、补位或跨列降级。发布前检查 `iccid_19` 重复并建立未删除数据范围内的部分唯一索引。解除实名回调第一版只写 Integration Log 并返回约定成功报文,不修改卡实名状态。 + +本需求不新增同步按钮和显式同步 API。资产详情只增加自动轮询状态和“查看同步轨迹”,跳转 `/operations/audit?tab=integrations&resource_type=iot_card&resource_key=...`。 + +### 3.5 全局多视角审计 + +#### 3.5.1 四类记录边界 + +| 类型 | 权威存储 | 用途 | +|------|----------|------| +| Access Log | 日志文件/平台 | HTTP 调试、性能和 request_id | +| Audit Event | `tb_audit_event` | 谁对哪些资源做了什么及结果 | +| Domain Ledger | 钱包流水、订单、退款、充值、企微实例等业务表 | 领域事实 | +| Integration Log | `tb_integration_log` | Gateway、运营商、企微、支付渠道的外部交互 | + +`tb_audit_event` 保存不可变 `event_id`、操作注册编码、操作者快照、来源、结果、风险、前后数据、`request_id/correlation_id/parent_event_id`、请求摘要和内容哈希。`tb_audit_event_resource` 支持一个事件关联主要、影响和引用资源,解决退款同时影响订单、钱包、流水和资产的问题。 + +`tb_integration_log` 保存 provider、方向、operation、外部单号、资源、触发来源/场景/序列/尝试、结果、脱敏请求响应摘要、耗时和关联链路。数据同步的合并、限流、提前完成即使没有真正请求上游,也必须写可解释结果。 + +#### 3.5.2 写入可靠性和脱敏 + +- 钱包余额、人工退款结果、代理钱包回溯、线下充值入账、角色权限和关键配置变更必须与业务事务同事务写 Audit Event;审计失败则业务回滚。 +- 外部轮询和系统任务写 Integration Log;状态变化、连续失败、人工触发或高风险异常再追加 Audit Event。 +- 失败和拒绝在业务回滚后用独立短事务记录,不使用裸 goroutine。 +- 密码、操作密码、Token、Secret、企微密钥、支付私钥、验证码、完整身份证、签名 URL 和 `media_id` 永不进入审计。 +- Access Log 请求体和响应体使用同一递归脱敏;登录、Token、回调和文件路由只记录字段摘要/哈希。 + +#### 3.5.3 多视角 API 与前端 + +主要 Query: + +```text +GET /api/admin/audit/events +GET /api/admin/audit/events/{event_id} +GET /api/admin/audit/actors* 人员行为 +GET /api/admin/audit/resources/search +GET /api/admin/audit/resources/{type}/{id}/timeline +GET /api/admin/audit/requests/{request_id}/timeline +GET /api/admin/audit/correlations/{correlation_id}/timeline +GET /api/admin/audit/risks/* +GET /api/admin/audit/integrations* +GET /api/admin/audit/finance/timeline +POST /api/admin/audit/exports +``` + +前端 `/operations/audit` 使用工作台式 Tab:全局事件、人员行为、资源轨迹、请求/业务链路、资金审计、风险事件、外部集成。资源搜索先返回候选资源再打开时间线;Gateway 同步序列按立即、3 分钟、5 分钟连续展示。敏感字段默认脱敏,查看完整敏感值的动作本身再次审计。 + +本需求在一次停机发布中全局切换:新功能和现有敏感写操作全部改用统一 Audit Writer,旧账号/资产/轮询日志表停止新写入,不做双写;历史数据通过 Query `UNION ALL` 只读投影到新审计页面。 + +保留策略:资金、权限、审批和关键配置 Audit Event 保留 5 年,普通业务审计 2 年,Integration Log 默认 180 天,Gateway 无变化成功记录 30 天,Access Log 30 天;达到单表维护阈值后按月分区和归档。 + +--- + +## 四、统一前端技术方案 + +当前仓库不包含前端源码。本节定义框架无关的页面、状态和接口契约;实际目录、状态库和组件名称以各前端仓库现状为准。 + +### 4.1 页面与模块 + +| 模块/页面 | 建议路由 | 主要能力 | +|-----------|----------|----------| +| 企微审批运行 | `/operations/wecom-approvals` | 状态、业务单号、模板版本、异常恢复和详情抽屉 | +| 企微配置 | `/system/wecom` | 连接状态、模板映射版本、账号绑定和异常审批 | +| 账号企微绑定 | 当前用户个人中心 | 扫码绑定、查看成员名称、重新绑定和解绑 | +| 站内消息 | `/notifications` | 未读数、列表、已读和受控业务跳转 | +| 全局审计 | `/operations/audit` | 全局、人员、资源、链路、资金、风险和外部集成多视角 | +| 系统配置 | `/settings/system-config` | 受控单选、复选和开关 | +| 异步任务 | 复用各业务页面 | 导入、批量订购、导出进度和失败明细 | + +全局状态只保存登录用户、权限和通知未读数。列表、详情和表单草稿保留在页面或模块级状态,避免复制服务端状态后长期失真。 + +顶部铃铛固定宽度显示 `0/1~99/99+`,点击打开最近 10 条通知抽屉,按全部、审批、临期、同步/系统分类;通知中心提供分类、已读和严重级别筛选及全部已读。点击通知先幂等标记已读,再解析受控目标,未知目标只展示正文不跳转。 + +### 4.2 通用显示规则 + +- 前端使用 `status` 判断逻辑,使用后端返回的 `status_name` 展示中文。 +- 金额接口使用分,金额组件显示元;转换结果必须是整数。 +- 时间按后端 ISO 8601 和现有时区工具展示。 +- 无权限和资源不存在使用统一页面语义,避免泄露资源存在性。 +- 操作成功后重新请求服务端详情,不在本地自行推进审批或资金状态。 + +### 4.3 异步任务交互 + +```mermaid +stateDiagram-v2 + [*] --> Editing + Editing --> Submitting: 提交文件和参数 + Submitting --> Processing: 创建任务成功 + Submitting --> Editing: 参数或上传失败 + Processing --> Processing: 轮询进度 + Processing --> Completed: 全部或部分完成 + Processing --> Failed: 任务失败 + Completed --> [*] + Failed --> Editing: 修正后创建新任务 +``` + +- 创建成功后立即请求一次详情,再按 2、3、5 秒退避,最大间隔 10 秒。 +- 页面不可见时暂停轮询,恢复可见时立即刷新。 +- 网络错误不等于业务失败,展示“状态获取失败,点击重试”。 +- 必须展示总数、成功数、失败数和部分成功状态。 +- 页面刷新后根据任务 ID 恢复进度。 +- Excel 模板由前端静态资源提供;后端仍严格校验表头、版本和内容。 + +### 4.4 企业微信审批交互 + +- 删除本系统待我审批、流程节点配置、审批人配置、通过/驳回/退回按钮,审批操作全部在企业微信完成。 +- 退款和线下充值创建成功后先显示“正在提交企业微信审批”,取得 `sp_no` 后显示“企业微信审批中”。 +- 业务详情的企微审批区块展示审批单号、状态、模板版本、申请人、审批人、意见、审批附件、状态时间线和本地业务处理结果。 +- “立即同步”只调用 `getapprovaldetail`,不提供任何本地审批动作。 +- 通过后撤销且资金动作已执行时使用高风险异常提示,明确说明不会自动冲正。 +- 未绑定企微账号时,创建页原地展示“绑定企业微信”按钮;扫码成功后继续当前表单,不要求用户先跳转配置页。 +- 模板发布使用业务字段与企微控件的可视化映射,不向运营暴露 JSON;场景暂停时创建页提前展示维护原因。 + +### 4.5 审批与业务处理状态 + +退款和充值详情必须分别展示审批状态、企微状态更新时间、业务处理状态,以及失败摘要和“系统重试中/联系管理员”提示。 + +`approval_source` 展示规则: + +| 值 | 前端行为 | +|----|----------| +| `none` | 不显示审批区域 | +| `wecom` | 展示只读企微详情和本地业务处理结果 | +| `legacy` | 只读展示历史摘要,不伪造节点,不提供审批按钮 | + +### 4.6 权限与敏感数据 + +- 企微配置、账号绑定、导出字段、审计视角和数据范围均以后端为准。 +- 通知跳转使用前端受控 `ref_type` 路由表,不接受任意 URL。 +- 退款凭证、充值凭证、身份证、营业执照和审批附件均走对象存储,不保存永久公开地址。 +- 角色导出字段配置只控制列,不扩大已有店铺或企业数据范围。 +- 审计前后数据和外部交互摘要默认脱敏;敏感查看权限和审计导出权限分开控制。 + +--- + +## 五、需求 01~15、22 专项方案 + +### 5.1 简单改动汇总 + +| 需求 | 业务规则 | 后端/数据 | 前端与验收 | +|------|----------|-----------|------------| +| 需求 01 行业卡复机 | 是否要求实名只看运营商 `realname_link_type`;`none` 可未实名复机,`template/gateway` 仍需实名 | 删除按 `card_category=industry` 放行或跳过实名的逻辑,复用卡状态领域规则 | 验证同为行业卡但不同运营商实名能力时行为不同,卡类别不再影响结果 | +| 需求 03 联系电话搜索 | 店铺联系电话 11 位精确查询 | 店铺列表 Query 增加 `contact_phone` | 增加搜索框;空参数不影响原查询,非法号码返回参数错误 | +| 需求 04 退款中禁止换货 | 企微审批中、历史已退回、企微已通过但退款终态未完成的资产禁止换货;已拒绝、已撤销或退款完成后放行 | 创建换货前批量校验活跃退款,返回“该资产存在退款申请” | 前端直接展示后端错误,不自行判断退款状态 | +| 需求 06 最后到期时间 | 返回当前生效及排队主套餐全部接续后的预计最终到期时间 | 资产详情 Query 按队列顺序使用购买时长快照推演;无套餐返回 `null` | 文案使用“预计最后到期时间”,实时查询,不保存冗余结果 | +| 需求 07 实名筛选 | 卡按自身实名状态;设备任一有效绑定卡实名即视为设备实名 | 设备增加 `real_name_status` 快照;实名变化、绑定、解绑、换卡均刷新旧/新设备 | 卡和设备列表增加全部/已实名/未实名筛选 | +| 需求 11 当前套餐到期 | 统一并入需求 22 的剩余天数和颜色规则 | 不再单独维护第二套计算 | 资产详情直接使用需求 22 字段 | +| 需求 12 换货显示与搜索 | 卡的新旧资产标识统一快照 ICCID;设备维持设备号;历史数据不回填 | 创建快照逻辑修正;列表增加 `old_asset_keyword/new_asset_keyword`,支持 ICCID、接入号、虚拟号 | 搜索框拆为旧资产和新资产;验收大结果集查询性能 | +| 需求 13 列表字段 | 提交人写业务快照;企微审批摘要动态读取 | 换货、退款、充值增加 `submitter_name`;按本页企微实例批量查询状态和审批人摘要,禁止 N+1 | 展示企微状态、当前审批人摘要、处理状态和历史审批标识 | +| 需求 15 下架套餐续费 | 禁用套餐始终不可购买;下架套餐只允许资产所有人基于有效历史使用记录续费,禁止代理代购 | 复用统一套餐可售策略;后端根据资产和登录主体判定续费资格 | C 端当前套餐旁展示“续费”,复用购买流程;新购入口隐藏,后台代购禁用 | + +### 5.2 需求 02:H5 实名与充值顺序配置 + +#### 规则 + +| 资产视角 | 策略来源 | +|----------|----------| +| 独立卡 | 卡的 `realname_policy` | +| 设备 | 设备的 `realname_policy` | +| 设备下的卡 | 设备的 `realname_policy`,卡自身值不参与 H5 流程 | + +资产顺序策略:`none`、`before_order`、`after_order`,默认 `after_order`。运营商能力先于资产顺序策略:`realname_link_type=none` 时有效策略固定为 `none`;其他值下如果资产策略为 `none`,属于待修复冲突数据,禁止运行时静默放行。 + +```mermaid +flowchart TD + Login[用户进入 H5] --> Resolve[解析资产视角] + Resolve --> Policy{realname_policy} + Policy -->|none| Pay[直接进入充值/购买] + Policy -->|before_order| Realname[先实名] + Realname --> Pay + Policy -->|after_order| PayFirst[先充值/购买] + PayFirst --> Prompt[完成后提示实名] +``` + +现有字段和单条接口继续使用: + +```text +PATCH /api/admin/assets/{identifier}/realname-mode +``` + +新增批量接口: + +```text +POST /api/admin/iot-cards/batch-update-realname-policy +POST /api/admin/devices/batch-update-realname-policy +``` + +- 单次最多 500 条;事务内全成全败,任一资产不合法则返回明确失败原因并不更新任何记录。 +- 设备下的卡在后台修改卡策略时,前端提示“实际 H5 流程由设备策略决定”。 +- C 端只使用初始化接口返回的有效策略,不自行判断资产类型;本期不增加全局默认配置,新建卡/设备继续使用模型默认 `after_order`。 + +### 5.3 需求 05:套餐分配生效条件 + +完整生命周期: + +```text +套餐默认规则 + -> 代理分配可选覆盖 + -> 客户购买时计算有效值并写入 PackageUsage 快照 + -> 套餐激活、排队接续和最后到期时间只读取快照 +``` + +覆盖的含义是“该代理未来购买此套餐时采用的规则”,不是修改套餐本身。客户购买后,`PackageUsage` 同时快照 `expiry_base`、周期类型和时长;之后修改套餐或分配配置都不影响已经购买的套餐。旧记录缺少快照时仅兼容回退到当前套餐值,不能把回退逻辑用于新订单。 + +数据变更: + +| 表 | 字段 | 说明 | +|----|------|------| +| `tb_shop_package_allocation` | `expiry_base_override` nullable | `NULL` 表示跟随套餐默认 | +| `tb_package_usage` | `expiry_base_snapshot`、`calendar_type_snapshot`、`duration_months_snapshot`、`duration_days_snapshot` | 新订单写入有效值和时长;用于激活和排队到期推算 | + +API: + +```text +POST /api/admin/shop-package-allocations +PATCH /api/admin/shop-package-allocations/{id}/expiry-base +``` + +- 修改分配覆盖值只影响后续新订单,不修改已经形成的使用记录快照。 +- 前端提供“跟随套餐默认、购买即生效、实名即生效”三选一。 +- PATCH 必须能区分“字段缺失”和“显式恢复默认”;实现时使用可识别 JSON `null` 的 DTO,不能用普通指针静默混淆。 + +示例:套餐默认“实名即生效”,代理 A 覆盖为“购买即生效”。代理 A 的客户购买后,使用记录写入 `from_purchase`;后来将套餐默认改为“实名即生效”或删除代理覆盖,已购买记录的计时方式和预计到期时间均不变化。 + +### 5.4 需求 08:设备批量分配 + +“分配代理”和“分配套餐系列”是两个业务命令,不允许一个任务同时修改两个字段。两者复用 Excel 解析、任务轮询和失败明细基础设施,但每个任务只携带一种 `operation_type` 和一个目标值。 + +```mermaid +sequenceDiagram + actor User as 平台员工 + participant Web as 设备管理页 + participant API as Batch Allocation API + participant DB as PostgreSQL + participant Worker as Asynq Worker + + User->>Web: 选择一种分配操作并上传 Excel + Web->>API: POST batch-assign-shop 或 batch-assign-series + API->>DB: 保存任务和文件 Key + API-->>Web: task_id + Worker->>DB: 分批查询、条件更新、记录失败 + Web->>API: GET /api/admin/devices/batch-allocation/{id} +``` + +任务字段:任务号、源文件 Key、`operation_type=assign_shop|assign_series`、目标 ID、操作人、总数、成功数、失败数、状态、失败明细、开始/完成时间。 + +处理规则: + +- 设备号去重后批量查询,禁止逐行查询。 +- `assign_shop`:已属于其他代理的设备失败并提示先回收;已属于目标代理的按幂等成功。 +- `assign_series`:已属于目标套餐系列的按幂等成功;不修改 `shop_id`。 +- 临时本地路径和文件字节不得进入 Asynq 载荷。 +- 模板由前端提供,后端不实现下载接口。 +- 限制:文件最大 10MB、最多 1000 行、Worker 每批 200 条、失败明细最多保存 1000 条。 +- 前端提供两个独立入口和两个表单:批量分配代理、批量分配套餐系列。 + +### 5.5 需求 09:C 端支付方式限制 + +卡和设备的允许支付方式读取系统配置,钱包始终允许。初始默认值: + +| 资产 | 默认允许方式 | +|------|--------------| +| 卡 | 支付宝、钱包 | +| 设备 | 微信、钱包 | + +- C 端支付页只展示接口返回的允许方式。 +- 创建订单和支付接口必须再次校验,不能依赖前端隐藏。 +- 配置异常时使用上述安全默认值并记录错误,不得放开全部方式。 +- 后台配置使用复选框,不直接编辑 JSON。 + +### 5.6 需求 10:Gateway 限速 + +本期只提供统一的**手动卡限速**能力,不在套餐上配置固定限速,也不因套餐激活、到期、切卡或停机自动调用 Gateway。 + +限速最终对象始终是卡 ICCID: + +```mermaid +flowchart TD + Trigger[后台手动设置或取消] --> Asset{资产类型} + Asset -->|卡| ICCID[读取卡 ICCID] + Asset -->|设备| Binding[查询 is_current=true 的当前卡] + Binding --> Exists{当前卡有效?} + Exists -->|否| Reject[拒绝操作并记录原因] + Exists -->|是| ICCID + ICCID --> Gateway[SetSpeedLimit cardNo, speedKbps] + Gateway --> Audit[记录操作审计和 Gateway 结果] +``` + +数据和契约: + +- 应用层只接收 `speed_kbps`:正数为设置,`0` 为取消;内部单位固定为 `kbps`。 +- 统一接口:`POST /api/admin/assets/{identifier}/speed-limit`。资产为设备时解析当前绑定卡,不存在当前卡则拒绝,绝不把设备 IMEI 传给 Gateway。 +- Gateway 端口只有 `SetSpeedLimit(cardNo, speedKbps)`;取消仍调用同一端口,适配器按上游最终契约转换取消参数。 +- 不新增 `tb_package.speed_limit_kbps`、`SpeedLimitApplyRequested` Outbox 或自动补偿 Worker。本期也不宣称能展示 Gateway 当前实际限速,除非上游另提供查询接口。 +- 卡详情和设备详情都提供设置/取消入口;设备入口明确显示“当前使用卡 ICCID”。每次操作记录资产、最终 `cardNo`、目标值、操作人、请求结果和错误摘要。 + +### 5.7 需求 14:统一导出与字段权限 + +复用现有 `tb_export_task + DataSource + Asynq`,新增或扩展以下场景: + +| Scene | 内容 | +|-------|------| +| `iot_card` | 增加套餐名称、已用流量、剩余流量 | +| `agent_wallet_transaction` | 代理主钱包流水 | +| `package` | 套餐基础、流量、价格、状态和审计字段 | +| `refund` | 退款业务字段、动态审批摘要和凭证 | +| `exchange` | 换货新旧资产、收货和物流字段 | +| `agent_recharge` | 充值、余额、状态、审批摘要和凭证,不含支付通道 | +| `expiring_asset` | 临期资产、套餐、到期和流量字段 | + +统一创建接口: + +```text +POST /api/admin/export-tasks +body: scene + format + query + fields +``` + +字段权限: + +```text +resolved_fields = 用户申请字段 + ∩ 角色授权字段并集 + ∩ 场景支持字段 +``` + +新增 `tb_role_export_field_permission(role_id, scene, field_key)`,三列唯一。超级管理员拥有全部字段;普通角色从授权并集计算。数据行范围继续使用现有权限,字段授权不能扩大数据范围。 + +API: + +```text +GET /api/admin/export-fields?scene={scene} +GET /api/admin/roles/{role_id}/export-fields +PUT /api/admin/roles/{role_id}/export-fields +``` + +- 创建任务时快照最终字段和表头,Worker 不能因后续角色变化扩大字段。 +- 权限解析失败时拒绝导出,不能回退到全字段。 +- Count 和 Fetch 必须使用相同权限及查询条件。 +- 退款和充值审批摘要按本批实例批量查询,禁止 N+1。 +- 导出审批附件只输出数量,不输出对象 Key 或永久 URL。 + +### 5.8 需求 22:套餐临期提醒 + +临期定义:按 `Asia/Shanghai` 自然日计算,当前生效套餐剩余 `0~15` 天,且资产没有可接续排队套餐。已过期资产不按 0 天计入临期。需求 06、11、22 共用查询侧日期计算,但需求06另行推算排队套餐接续后的预计最后到期时间。 + +| 剩余天数 | 展示颜色 | 通知节点 | +|----------|----------|----------| +| 8~15 天 | 粉红色 | 15 天 | +| 4~7 天 | 紫色 | 7 天 | +| 0~3 天 | 黄色 | 3 天 | + +```mermaid +flowchart TD + Schedule[每日定时任务] --> Query[批量查询实时临期候选] + Query --> Queue{存在排队套餐?} + Queue -->|是| Skip[不临期、不通知] + Queue -->|否| Days[计算剩余自然日] + Days --> Node{存在最近未发送阈值?} + Node -->|是| Notify[按使用记录+节点+接收人幂等通知] + Node -->|否| End[结束] +``` + +后端: + +- 卡/设备列表和详情增加 `days_until_expiry`、`is_expiring`。 +- 列表、详情、临期页、代理首页和 C 端均实时 SQL 计算,不读取每日任务快照,也不需要前端轮询临期状态。 +- 新增 `GET /api/admin/expiring-assets`,支持资产、套餐、店铺、到期范围和剩余天数筛选。 +- 代理首页增加临期卡/设备数量。 +- C 端 `GET /api/c/v1/asset/info` 增加临期字段并提供续费入口。 +- 临期导出复用 `scene=expiring_asset`。 +- `tb_expiry_push_record` 从本期起按 `package_usage_id + recipient + channel + node` 防重;漏跑后只补发当前最近且尚未发送的阈值,避免一次补发多条旧通知。 + +--- + +## 六、需求16移出记录与需求17~21方案 + +### 6.1 需求 16:代理分销码与佣金提现(已移出本期) + +> 状态:2026-07-14 移出 7 月迭代 + +本期不建设分销码、H5 代理申请、代理申请审批、自动开店、发展人关系和佣金提现材料。本需求后续独立立项时重新评审数据模型、H5 安全、审批接入、开店幂等、提现材料和工时。 + +本期审批流仅接入退款和平台员工线下充值;迁移、API、前端、发布及工时均不包含需求 16。 + +### 6.2 需求 17:代理主钱包信用额度 + +平台员工不是结算和负债主体,不建立员工钱包或信用额度。角色只表达权限,不能承载财务余额和债务。 + +#### 不变量 + +```text +effective_credit = credit_enabled ? credit_limit : 0 +available = balance - frozen_balance + effective_credit +available >= 0 +``` + +- 信用额度只作用于代理主钱包,不作用于佣金钱包和资产钱包。 +- 余额允许为负,但不能突破可用额度边界。 +- 关闭信用时额度必须为 0;开启信用时额度必须大于 0。 +- 存在欠款或冻结金额导致修改后可用金额小于 0 时,禁止降低额度或关闭信用。 +- 扣款、冻结、解冻、充值、退款回充、调额、版本和资金流水必须维护同一不变量。 + +#### 数据约束 + +`tb_agent_wallet` 增加: + +```text +credit_enabled BOOLEAN NOT NULL DEFAULT FALSE +credit_limit BIGINT NOT NULL DEFAULT 0 +``` + +数据库 CHECK 至少保证: + +```sql +CHECK (frozen_balance >= 0), +CHECK (credit_limit >= 0), +CHECK ( + (credit_enabled AND credit_limit > 0) + OR (NOT credit_enabled AND credit_limit = 0) +), +CHECK ( + balance - frozen_balance + + CASE WHEN credit_enabled THEN credit_limit ELSE 0 END >= 0 +) +``` + +迁移前必须核对生产库现有约束名和历史异常数据,不能仅依赖 `DROP CONSTRAINT IF EXISTS`。 + +#### 用例与 API + +| 用例 | 规则 | +|------|------| +| 修改额度 | 校验权限、加载主钱包、校验新可用金额、按 `version` 条件更新、写信用变更审计 | +| 钱包扣款 | 使用有效额度计算可用金额,同事务更新余额、版本和资金流水 | +| 查询/导出 | Query 返回 `credit_enabled`、`credit_limit`、`available_balance`、`is_in_debt`、`debt_amount` | + +```text +POST /api/admin/shops 创建代理时可初始化信用配置 +PUT /api/admin/shops/{id}/credit-limit 独立调整信用额度 +GET /api/admin/shops/fund-summary 返回信用和可用金额 +``` + +前端只展示接口返回的可用金额,不自行重新计算。额度调整弹框显示修改前后金额预览;并发冲突时刷新最新钱包版本。 + +### 6.3 需求 18:多人审批业务映射 + +多人审批由企业微信模板负责,本系统不解析审批角色、账号、部门或会签规则: + +| 需求 | 方案 | +|------|------| +| 多级、会签、或签 | 在企微模板中配置,本系统使用 `use_template_approver=1` | +| “部门领导→财务” | 可以作为企微模板中的组织规则,本系统不建立部门模型也不推导审批人 | +| 当前节点待办 | 由企业微信自身提醒,本系统不重复生成站内待审批任务 | +| 通过/驳回/撤销通知 | 状态同步后向申请人生成站内结果通知 | +| 意见和附件 | 从 `getapprovaldetail` 保存审批详情快照并在业务详情只读展示 | +| 历史版本 | 本地保存模板 ID、控件映射和提交快照,历史实例不受新模板影响 | + +平台员工必须先完成系统账号与企微成员扫码绑定,发起审批使用绑定的 `userid`;普通运营不查询或录入成员 ID。 + +### 6.4 需求 19:批量订购套餐 + +#### 规则和流程 + +- 支付方式整批选择 `offline` 或 `agent_wallet`,Excel 不包含支付方式。 +- 混合支付必须拆成不同批次。 +- 模板字段:资产类型、资产标识、套餐编码、套餐名称;实际匹配使用套餐编码。 +- 任务允许部分成功,每行是独立、可重试、可审计的业务单元。 +- 文件最大 10MB、最多 1000 行;失败明细最多保存 1000 条。 + +```mermaid +flowchart TD + Submit[选择代理、支付方式、凭证并上传] --> Task[创建任务] + Task --> Parse[解析并持久化逐行明细] + Parse --> Item{处理下一行} + Item -->|钱包| Wallet[锁钱包并校验有效可用金额] + Item -->|线下| Voucher[校验整批凭证快照] + Wallet --> Order[单行事务:订单+扣款+流水+明细] + Voucher --> OfflineOrder[单行事务:已支付订单+明细] + Order --> More{还有明细?} + OfflineOrder --> More + More -->|是| Item + More -->|否| Summary[从明细汇总任务结果] +``` + +#### 数据模型 + +| 表 | 关键字段 | +|----|----------| +| `tb_bulk_purchase_task` | 任务号、`request_id`、文件 Key、代理、支付方式、凭证快照、金额和数量汇总、状态、处理租约 | +| `tb_bulk_purchase_item` | 行号、资产、套餐快照、金额、状态、订单 ID、错误码、错误原因、幂等键 | + +任务状态:`1=待处理, 2=处理中, 3=完成, 4=部分成功, 5=失败`。 +明细状态:`1=待处理, 2=处理中, 3=成功, 4=失败`。 + +必须具备: + +- 创建接口使用 `request_id` 唯一约束,重复提交返回原任务。 +- Worker 通过状态条件和处理租约领取任务/明细,防止并发消费者重复执行。 +- 行幂等键:`bulk_purchase:{task_id}:{row_no}`。 +- 钱包可用金额统一使用 `credit_enabled` 后的有效信用额度,禁止直接无条件加 `credit_limit`。 +- 钱包余额、版本、订单、资金流水和明细成功状态在同一事务。 +- 单行失败不回滚其他成功行,任务统计从明细表重新聚合。 +- 批量订购必须复用需求 15 的套餐可售策略,不能绕过下架续费限制。 + +API: + +```text +POST /api/admin/bulk-purchases +GET /api/admin/bulk-purchases/{task_id} +GET /api/admin/bulk-purchases/{task_id}/items +``` + +线下凭证仅作为本批次业务资料和审计快照,本期不校验跨批次唯一性或建立财务核销规则。 + +### 6.5 需求 20:退款审批 + +退款金额在申请时完成合法性校验并固定,企微审批不允许修改金额。退款凭证先保存本地对象存储,再上传企微副本;审批人可在企微看到业务字段、提交备注和附件。 + +退款状态在历史枚举基础上追加 `5=已撤销/审批已删除`,保留历史 `4=已退回` 语义但新企微审批不再产生退回状态。独立保存业务处理结果,避免“企微已通过但代理钱包回溯或资产处理失败”被展示为全部完成。 + +```mermaid +sequenceDiagram + actor User as 平台员工/财务 + participant Refund as Refund Application + participant WeCom as 企业微信审批 + participant Sync as WeCom Approval Sync + participant DB as PostgreSQL + participant Worker as Refund Terminal Worker + + User->>Refund: 创建退款申请 + Refund->>DB: 保存退款单、业务快照和企微实例 + Refund-->>WeCom: 异步提交申请和附件 + User->>WeCom: 审批;非代理钱包先完成人工退款 + WeCom-->>Sync: 回调/轮询同步终态 + Sync->>DB: 保存审批详情并写终态Outbox + DB-->>Worker: 首次通过时处理业务终态 + Worker->>DB: 代理钱包回溯、订单/佣金/资产处理 +``` + +关键规则: + +- 微信、支付宝、线下等非代理钱包支付由财务在系统外人工退款后再通过企微;企微通过即表示人工退款已确认,本系统不调用渠道退款 API,也不再提供本地 `manual-complete` 二次确认。 +- 代理钱包支付订单在企微通过后按原扣款流水定位原代理主钱包,幂等回溯并写退款流水;可自然冲减负余额。 +- 个人客户或资产钱包不自动回款,现有 `BuyerTypePersonal` 自动资产钱包退款分支在迁移时删除或隔离。 +- 驳回时本地状态改为已拒绝;撤销/删除时改为已撤销,可修改业务资料后重新申请。 +- 通过后撤销且资金已执行时不自动冲正,记录 `critical` 审计、站内告警并人工处理;资金尚未执行时终止后续任务。 +- 原退款业务单级 `approve/reject/return` 路由下线,不存在本地审批动作 API。 + +重新申请: + +```text +POST /api/admin/refunds/{id}/resubmit +``` + +只允许已驳回、已撤销或已删除且尚未完成退款的申请按原退款规则修改申请金额、凭证和原因,并创建 `round_no + 1` 的企微实例;订单、资产快照、提交人和历史审批不可修改。 + +前端只读展示企微审批状态、意见/附件和业务处理结果,不显示本地审批或人工退款确认按钮。 + +### 6.6 需求 21:代理在线充值与员工线下充值审批 + +本需求分为完全隔离的两条路径:代理在线扫码充值不审批;平台员工线下代充值走企业微信审批。 + +#### 代理在线扫码充值 + +- 代理只能为当前店铺主钱包充值,最低 `10000` 分(100 元)。 +- 支持微信 Native 和支付宝 `alipay.trade.precreate`,创建本地充值单和 `tb_payment(order_type=agent_recharge)` 后再预下单。 +- 后端统一返回 `qr_content` 和过期时间,前端使用二维码组件渲染,不生成后端图片文件。 +- 微信/支付宝回调按支付单类型分发,校验渠道、支付配置、金额、第三方交易号和业务单关联。 +- 支付单、充值单、代理主钱包、钱包版本、唯一钱包流水和 Audit Event 在同一事务推进;重复回调只返回渠道成功,不重复加钱。 +- 支付成功但钱包事务失败时保持已支付并由可靠任务补齐;二维码过期后关闭原充值单,重新生成必须创建新支付单。 + +```text +GET /api/admin/agent-recharges/payment-methods +POST /api/admin/agent-recharges +GET /api/admin/agent-recharges/{id}/payment-status +``` + +代理在线充值详情固定 `approval_source=none`,前端每 3 秒轮询轻量支付状态,页面不可见时暂停。 + +#### 平台员工线下代充值 + +创建线下充值后立即创建企微审批实例,不再暴露本地确认入账和驳回按钮,也不再校验操作密码。充值状态沿用现有 `1~6`: + +| 状态 | 语义 | +|------|------| +| 1 | 待支付/线下待审批 | +| 2 | 已支付或审批通过后正在入账 | +| 3 | 已完成 | +| 4 | 已关闭 | +| 5 | 已退款 | +| 6 | 已驳回 | + +充值处理状态:`0=未触发, 1=处理中, 2=成功, 3=失败`。 + +```mermaid +sequenceDiagram + actor Staff as 平台员工 + participant Recharge as Recharge Application + participant WeCom as 企业微信审批 + participant Sync as WeCom Approval Sync + participant DB as PostgreSQL + participant Worker as Recharge Worker + + Staff->>Recharge: 创建线下充值 + Recharge->>DB: 保存充值单、业务快照和企微实例 + Recharge-->>WeCom: 异步提交审批 + WeCom-->>Sync: 回调/轮询同步终态 + Sync->>DB: 首次通过时写入账Outbox + DB-->>Worker: RechargeApproved + Worker->>DB: 同事务更新钱包、版本、流水和充值单 +``` + +关键规则: + +- 企微通过后自动增加代理主钱包余额并写钱包流水,无操作密码。 +- Worker 使用 `recharge:{recharge_no}` 幂等键和处理租约。 +- 钱包余额、版本、充值单和资金流水必须在同一事务更新。 +- 若资金流水已存在但充值单状态未完成,重试只补齐状态,不再次入账。 +- 驳回改为已驳回;撤销/删除改为已关闭,可重新创建充值申请。 +- 通过后撤销且已经入账时不自动扣回,记录严重异常并通知财务;尚未入账时终止任务。 + +旧接口在停机发布后不再注册: + +```text +POST /api/admin/agent-recharges/{id}/offline-pay +POST /api/admin/agent-recharges/{id}/reject +``` + +--- + +## 七、数据迁移、发布与回滚 + +### 7.1 迁移前检查 + +- 核对钱包现有 CHECK 约束名、负余额、冻结金额和异常数据。 +- 检查同一设备多条 `is_current=true` 绑定并先修复。 +- 检查未删除卡 `iccid_19` 重复,解决冲突后才能建立部分唯一索引。 +- 检查 `realname_link_type!=none` 但资产 `realname_policy=none` 的冲突数据并明确修正结果。 +- 使用生产卡量测算活跃/不活跃轮询间隔对应的 Gateway QPS 和最长兜底延迟。 +- 核对存量退款的支付方式和买家类型;仅将代理钱包订单接入自动回退,个人资产钱包订单不得误入该处理器。 +- 统计待审批退款、平台员工线下充值和历史终态记录数量。 +- 轮换用户 demo 中泄露的企微 Secret、Token 和 EncodingAESKey,配置可信域名、应用可见范围和回调地址。 +- 发布并验证退款、线下充值企微模板控件映射;要求会发起审批的平台员工完成扫码绑定。 +- 验证微信 Native、支付宝 PreCreate 配置和回调地址,确认代理充值支付渠道可用。 +- 盘点旧账号、资产、轮询日志的写入口和查询入口,确认统一审计切换清单。 +- 初始化角色导出字段的最小权限集,禁止默认放开敏感字段。 + +### 7.2 迁移清单 + +| 类型 | 表/对象 | 变更 | +|------|---------|------| +| 新建 | `tb_system_config` | 受控动态配置 | +| 新建 | `tb_notification` | 分类、级别、受控跳转和接收人幂等 | +| 新建 | `tb_wecom_approval_scene`、`tb_wecom_approval_template_version` | 稳定场景和不可变模板映射版本 | +| 新建 | `tb_wecom_approval_instance`、`tb_account_wecom_mapping` | 企微审批镜像和账号扫码绑定 | +| 新建 | `tb_audit_event`、`tb_audit_event_resource` | 全局不可变业务审计和多资源关联 | +| 新建 | `tb_integration_log` | Gateway、运营商、企微和支付渠道交互 | +| 新建/确认 | `tb_outbox_event` | 可靠事件投递 | +| 新建 | `tb_device_batch_allocation_task` | 设备批量分配任务 | +| 新建 | `tb_role_export_field_permission` | 角色导出字段权限 | +| 新建 | `tb_expiry_push_record` | 15/7/3 天临期通知防重 | +| 新建 | `tb_bulk_purchase_task`、`tb_bulk_purchase_item` | 批量订购任务和逐行明细 | +| 修改 | `tb_device` | 实名状态快照 | +| 修改 | `tb_iot_card`/轮询调度状态 | 活跃级别、最后活跃/上游变化信息;高频调度心跳不写核心表 | +| 修改 | `tb_polling_config` | 收口为全局活跃/不活跃间隔,不再按卡类别形成轮询资格 | +| 修改 | `tb_iot_card.iccid_19` | 未删除数据范围 Partial Unique Index | +| 修改 | `tb_shop_package_allocation`、`tb_package_usage` | 生效条件、周期和时长快照 | +| 修改 | `tb_exchange_order` | 提交人快照;新建记录修正资产标识快照 | +| 修改 | `tb_agent_wallet` | 信用开关、额度和 CHECK 约束 | +| 修改 | `tb_refund_request` | 提交人、企微实例轮次、撤销状态和终态处理结果 | +| 修改 | `tb_agent_recharge_record` | 提交人、企微实例、支付状态和入账处理结果 | +| 修改 | `tb_payment` | 支持 `order_type=agent_recharge` 和创建时支付配置快照 | + +迁移必须拆成可回滚的增量文件;同一表和同一索引只由一个迁移创建。旧账号、资产和轮询日志表保留只读,不回填新表、不再产生新写入。 + +### 7.3 发布顺序 + +```mermaid +flowchart LR + M[进入维护模式并停止写入] --> DB[执行增量迁移] + DB --> Deploy[同时发布 API、Relay/Worker、前端] + Deploy --> WeCom[发布企微模板映射和扫码绑定] + WeCom --> Backfill[提交存量待审批单到企微] + Backfill --> Verify[人工验证核心链路] + Verify --> Open[解除维护模式] +``` + +详细步骤: + +1. 停止订单、退款、充值、资产状态和配置相关写入,暂停旧 Worker。 +2. 创建企微、审计、通知、批量任务、权限和同步调度所需表/索引,并增加业务关联字段。 +3. 同时发布 API、Relay/Worker 和前端,统一切换 Audit Writer、Integration Log 和 Access Log 脱敏。 +4. 发布两个企微模板映射版本,验证连接、回调、状态查询和员工扫码绑定。 +5. 使用一次性 Application 命令为存量待审批退款和平台员工线下充值创建企微实例;未绑定创建人的记录进入迁移待处理列表。 +6. 历史终态记录保留 `approval_source=legacy`,不得伪造企微实例或审批时间线。 +7. 验证数据同步三通道、19/20 位 ICCID 回调、代理微信/支付宝扫码充值和统一审计查询。 +8. 确认旧退款审批、充值 `offline-pay/reject`、旧审计写入口和重复卡状态写逻辑不可访问。 +9. 完成人工验证后恢复 Worker 并解除维护模式。 + +### 7.4 回滚边界 + +- 开放访问前验证失败:可以回滚应用版本和可逆迁移。 +- 已提交到企业微信的审批无法通过回滚本地版本撤销;必须继续保留实例并同步终态。 +- 已形成的企微实例、审计、Integration Log、资金流水、Outbox 和通知不得清表回滚。 +- Worker 暂停时保留 Outbox,恢复后继续消费。 +- 信用额度启用并产生负余额后,不能删除字段或直接关闭信用;必须先处理欠款或继续保留新资金逻辑。 +- 已部分成功的批量任务不能整体回滚;通过明细和资金流水逐条追踪。 +- 支付渠道已经成功但钱包尚未入账的充值单必须由恢复任务完成,不能要求代理重新支付。 + +--- + +## 八、风险与已确认约束 + +本轮评审结论已经收口,以下约束直接进入实施,不再要求实施人员自行猜测: + +| 编号 | 已确认结论 | +|------|------------| +| C-01 | 限速内部单位固定 `kbps`;本期仅人工设置/取消,Gateway 取消参数仅由适配器处理。 | +| C-02 | 实名策略批量单次最多 500 条,事务内全成全败。 | +| C-03 | 设备批量分配文件最大 10MB、最多 1000 行、每批 200 条、失败明细最多 1000 条,且一任务只做一种分配操作。 | +| C-04 | 批量订购线下凭证仅保存业务资料快照,不校验跨批次唯一性或财务核销。 | +| C-05 | 套餐导出固定采用实际列举的 25 个稳定字段 Key。 | +| C-06 | 钱包流水套餐名称优先使用订单项不可变快照,历史缺失时才读取 `metadata`;禁止关联可修改的当前套餐名称。 | +| C-07 | 临期按 `Asia/Shanghai` 自然日计算,`0~15` 天为临期;漏跑后仅补发当前最近且未发送的阈值通知。 | +| C-08 | 本系统不校验线下充值审批操作密码;审批人与流程规则由企微模板决定。 | +| C-09 | 退款、充值业务资料以本地对象存储 Key 为权威,企微 `media_id` 仅作为提交副本且不进入日志审计。 | +| C-10 | 企微 `applyevent` 请求已发送但结果未知时禁止盲目重试,必须人工核对后恢复。 | +| C-11 | 第三方退款本期人工处理;不增加渠道退款号、渠道结果或自动退款适配器。代理钱包退款仅回退原扣款代理主钱包。 | +| C-12 | H5 不增加全局实名策略默认值,新建卡/设备继续默认 `after_order`。 | +| C-13 | 行业卡实名由 `realname_link_type` 决定;19/20 位 ICCID 精确查对应列,不截断、不补位、不跨列降级。 | +| C-14 | 事件同步固定立即、3 分钟、5 分钟三次;Gateway 超频不退避,不删除后续任务。 | +| C-15 | 企微账号只允许扫码绑定;一个 `userid` 不能覆盖绑定到第二个系统账号。 | +| C-16 | 代理在线充值最低 100 元,支付成功直接入主钱包,不因金额大进入审批。 | +| C-17 | 全局审计本次停止旧表新写入;历史只读投影,不双写、不在线回填。 | + +Gateway 的取消限速具体报文不是产品决策:上线前由上游接口契约确定,业务层始终只传 `speed_kbps=0`。 + +### 8.1 主要运行风险 + +| 风险 | 触发条件 | 应对 | +|------|----------|------| +| 企微模板失效 | 模板或控件 ID 编辑后变化 | 场景暂停、模板版本、定期验证和原子切换 | +| 企微提交重复 | `applyevent` 超时后盲目重试 | 提交结果未知状态和人工绑定 `sp_no` | +| 审批回调漏失 | 网络或企微回调异常 | 审批中实例每 2 分钟查询详情兜底 | +| 账号绑定冲突 | 同一企微成员绑定多个账号 | `userid` 唯一约束、扫码会话和强制解绑审计 | +| 重复代理钱包回退或入账 | Outbox/Asynq 至少一次投递 | 稳定业务幂等键、资金流水唯一业务号、处理租约 | +| 通过后撤销 | 企微通过且资金已执行后撤销 | 不自动冲正,critical 审计、通知和人工处理 | +| 支付重复回调 | 微信/支付宝多次通知 | 支付单状态条件、钱包流水唯一键和业务幂等键 | +| 同步请求放大 | 查询、轮询和业务事件同时请求同一卡 | 同场景合并、单卡互斥、运营商最小间隔和活跃调频 | +| ICCID 错配 | 20 位截断或 19 位补位命中错误卡 | 双列精确路由和 `iccid_19` 部分唯一索引 | +| 审计数据量过大 | 高频轮询和全局写操作持续增长 | 时间索引、分批清理、Integration Log 短保留和后续月分区 | +| 批量重复下单 | 重复提交或并发 Worker | 创建请求幂等、任务/明细条件领取、行级唯一键 | +| 限速对象错误 | 设备无当前卡或错误把 IMEI 当卡号 | 统一解析当前卡 ICCID;无卡拒绝调用并记录审计 | +| 导出敏感字段泄露 | 权限缺失或 Worker 回退全字段 | 服务端字段交集、任务快照、失败关闭 | +| 临期重复通知 | 定时任务重试 | 稳定事件 ID 和接收人维度唯一约束 | + +--- + +## 九、可观测性与人工验收 + +### 9.1 日志和查询维度 + +统一记录:`request_id`、`correlation_id`、`event_id`、`integration_id`、`task_id`、`wecom_approval_instance_id`、`sp_no`、`biz_type`、`biz_id`、`operator_id`。 + +关键监控: + +- Outbox 待投递数、最早积压时间和失败次数。 +- 企微 Token 刷新、模板失效、提交结果未知、回调失败、待审批轮询积压和业务处理失败。 +- 账号扫码绑定成功/失败/冲突和未绑定申请人数量。 +- 活跃/不活跃卡数量、各同步类型 QPS、Gateway 超频、连续失败和事件序列完成率。 +- Audit Event/Integration Log 写入失败、增长速度、清理积压和敏感读取次数。 +- 微信/支付宝预下单、回调失败、已支付未入账和重复回调数量。 +- 批量任务成功/失败/部分成功数量。 +- 钱包版本冲突、可用金额不足和数据库约束失败。 +- Gateway cardNo、目标限速、请求结果和错误摘要。 +- 导出字段快照、数据范围和文件生成失败。 + +### 9.2 人工验收矩阵 + +| 范围 | 必测场景 | +|------|----------| +| 企微审批 | 模板发布/失效、扫码绑定、提交、回调、2分钟轮询、驳回/撤销/删除、提交未知恢复、意见附件和业务快照 | +| 数据同步 | 活跃调频、0/3/5 阶梯、同场景合并、单卡互斥、超频不退避、19/20位回调、解除实名忽略、旧入口收口 | +| 全局审计 | 关键事务失败回滚、人员/资源/请求/资金/风险/集成视角、历史投影、脱敏、旧表停止新写入 | +| 钱包 | 普通余额扣款、信用扣款、额度降低失败、并发版本冲突、负余额回充 | +| 批量订购 | 钱包/线下两种支付、部分成功、重复提交、Worker 重试、失败明细、模板错误 | +| 退款 | 金额发起时固定、财务人工退款企微确认、代理钱包回退原主钱包、个人资产钱包不误入、通过后撤销不冲正 | +| 充值 | 微信/支付宝最低100元扫码、重复回调不重复入账、已支付恢复、在线不审批、线下企微通过自动入账且无操作密码 | +| 导出 | 普通角色、敏感字段角色、超级管理员、权限为空、审批摘要批量查询 | +| 限速 | 单卡、设备当前卡、无当前卡、设置、取消、Gateway 失败和审计记录 | +| 临期 | 有/无排队套餐、15/7/3 天、漏跑补发、重复定时任务、不同接收人、列表/详情/C端一致 | +| 发布 | 存量退款和充值回填、历史 `legacy`、旧路由不可访问、Worker 暂停后恢复 | + +验收使用接口调用、PostgreSQL 数据核对、日志检查和页面操作,不以自动化测试作为本项目交付前提。 + +--- + +## 十、实施顺序 + +```text +1. 增量迁移、事务管理、Outbox、统一审计模型和 Access Log 脱敏 +2. 站内通知与审计多视角 Query/前端 +3. 卡状态领域、轮询活跃调频、事件阶梯和运营商回调防腐层 +4. 企微连接、模板版本、账号扫码绑定、提交/回调/轮询 +5. 退款和平台员工线下充值接入企微,代理微信/支付宝扫码充值 +6. 钱包信用额度、批量订购、导出权限、设备批量分配、限速和临期提醒 +7. 其他查询、字段和显示修复、存量回填和停机发布演练 +``` + +每个复杂用例按“迁移 → Application/Domain → Infrastructure → Handler → 前端 → 人工验收”形成完整闭环,不在同一任务中顺带重构未触碰模块。 + +--- + +## 十一、工时估算与排期 + +### 11.1 估算口径 + +- 1 人日按 8 小时计算。 +- 当前固定投入为 1 名后端和 1 名前端,两人并行开发,目标自然周期为 11~15 个工作日。 +- 包含本期企业微信审批、数据同步重构、全局审计一次性切换、代理微信/支付宝扫码充值及原 7 月迭代范围。 +- 不包含套餐临期企业微信消息推送、自动第三方退款、运营商解除实名回滚和需求16。 +- 包含后端、前端、第三方联调、人工接口验证、PostgreSQL 数据核对、存量回填和停机发布;不单独预留长期缓冲。 +- 假设对象存储、ExportTask、Asynq、钱包、退款、充值、微信支付和支付宝 SDK 基础代码可以复用,但企微和支付渠道仍需要真实环境联调。 +- 采用快速交付口径:优先复用现有 Store、支付、Asynq、导出和页面组件,DDD 只迁移本次触碰的复杂写用例,不追加非必要重构。 +- 全局审计仍一次性切换写入口,但第一版页面和 Query 只实现评审稿列出的核心视角,不增加额外分析报表。 +- AI 用于代码生成、批量迁移和文档核对;验收优先覆盖资金、企微、回调、同步和权限主链路。 + +### 11.2 人日估算 + +| 工作包 | 后端人日 | 前端人日 | 快速交付说明 | +|--------|----------|----------|--------------| +| 迁移、Outbox、全局审计和站内通知 | 2 | 2 | 复用现有日志、列表和抽屉组件,先完成核心多视角 | +| 数据同步领域收口、活跃轮询和运营商回调 | 2~3 | 1 | 后端为主,前端只补状态和审计跳转 | +| 企微模板、扫码绑定、提交/回调/轮询 | 2~3 | 2~3 | 复用企微官方页面和现有业务详情布局 | +| 退款、线下充值终态和代理扫码充值 | 2~3 | 2~3 | 复用现有钱包、支付回调和充值页面 | +| 信用、批量、导出、限速、临期及其他需求 | 2~2.5 | 2~3 | 多个小需求并行处理,避免额外抽象 | +| 联调、数据核对、回填和停机发布 | 1~1.5 | 1~2 | 随开发持续联调,最后集中验证核心链路 | +| **合计投入** | **11~15** | **10~14** | **总投入约 21~29 人日,两人并行 11~15 个工作日** | + +排期基线按 **13 个工作日**,对外承诺范围为 **11~15 个工作日**。如真实企微/支付配置无法按时提供、生产 ICCID 或审计旧入口存在大量异常,问题记录为外部阻塞,不在开发阶段扩展额外重构范围。 + +### 11.3 当前团队排期 + +当前团队固定为 **1 后端 + 1 前端**,两人从第一天开始并行,预计 **11~15 个工作日**。推荐节奏: + +```text +第 1~2 天:迁移、Outbox、审计/通知骨架,前端同步搭建页面框架 +第 3~5 天:数据同步收口、活跃轮询、回调防腐层和审计外部集成视角 +第 4~7 天:企微模板、扫码绑定、审批提交/回调/轮询及前端配置页面 +第 6~9 天:退款、线下充值终态、代理微信/支付宝扫码充值 +第 8~11 天:信用、批量、导出、限速、临期和其他小需求 +第 11~13 天:全链路联调、数据核对、旧入口清理和存量回填演练 +第 14~15 天:问题修复和停机发布缓冲;进度稳定时可提前 +``` + +--- + +## 十二、评审结论记录 + +| 评审项 | 结论 | 调整项 | 负责人 | +|--------|------|--------|--------| +| 本期范围与移出项 | 待评审 | | | +| 渐进式 DDD 边界 | 待评审 | | | +| 数据同步三通道与运营商回调 | 待评审 | | | +| 企业微信审批、模板版本与账号绑定 | 待评审 | | | +| 全局多视角审计与历史投影 | 待评审 | | | +| 站内通知与受控跳转 | 待评审 | | | +| 资金与信用额度 | 待评审 | | | +| 代理微信/支付宝扫码充值 | 待评审 | | | +| 批量任务和导出权限 | 待评审 | | | +| Gateway 限速和临期提醒 | 待评审 | | | +| 前端交互和权限 | 待评审 | | | +| 停机发布和回滚 | 待评审 | | | + +评审通过条件:第八章约束全部纳入实施任务;除 Gateway 上游取消参数和真实第三方联调结果外,不存在需要实施人员自行猜测的数据模型、接口、状态语义或资金规则。 diff --git a/docs/7月迭代/前端技术方案.md b/docs/7月迭代/前端技术方案.md new file mode 100644 index 0000000..dd6cbb2 --- /dev/null +++ b/docs/7月迭代/前端技术方案.md @@ -0,0 +1,270 @@ +# 7月迭代前端技术方案 + +> 状态:待评审 +> 适用端:后台管理端、代理端、C 端 H5/公众号 +> 最后更新:2026-07-14 +> 说明:当前仓库不包含前端源码,本文定义页面、交互和接口契约;实际目录、状态库和组件名称由前端仓库现状映射,禁止据此凭空更换前端技术栈。 + +--- + +## 一、目标与边界 + +本方案解决七月迭代中跨需求的前端共性问题: + +- 审批任务、退款和充值使用同一套动态审批展示。 +- Excel 导入、批量订购和导出使用统一的异步任务交互。 +- 站内消息统一未读数、列表、已读和业务跳转。 +- 配置类页面不让用户直接编辑 JSON 或依赖前端自行校验业务规则。 +- 金额、状态、时间、权限和错误展示使用统一口径。 + +本文不指定 Vue、React、Pinia、Redux 或具体 UI 组件库。实现时必须优先复用前端仓库现有的请求封装、权限指令、上传组件、表格和轮询 Hook。 + +--- + +## 二、系统上下文 + +```mermaid +flowchart LR + AdminUser[平台/代理后台用户] --> AdminWeb[后台管理前端] + Customer[C端客户] --> ClientWeb[C端 H5/公众号] + AdminWeb -->|/api/admin/*| API[Junhong API] + ClientWeb -->|/api/c/v1/*| API + API --> DB[(PostgreSQL)] + API --> Redis[(Redis)] + API --> Queue[Asynq] + Queue --> Worker[Worker] + Worker --> APIData[业务数据/对象存储/Gateway] + AdminWeb -->|轮询未读数、任务状态| API +``` + +前端只根据 API 返回的权限、状态和可操作项渲染,不自行推导“谁能审批”“是否允许扣款”或“当前流程下一步是谁”。 + +--- + +## 三、模块边界 + +建议在现有前端目录结构中映射以下模块,不要求创建新的全局架构: + +| 模块 | 负责内容 | 不负责内容 | +|------|----------|------------| +| Approval | 待办列表、流程详情、流程定义配置、审批动作 | 退款或充值的业务表单 | +| Notification | 未读数、通知列表、标记已读、业务跳转 | 审批状态计算 | +| AsyncTask | 导入、批量订购、导出的轮询与结果展示 | 各业务文件解析 | +| Refund | 退款申请、编辑、重新提交、业务处理状态 | 动态审批节点渲染的内部规则 | +| Recharge | 代理在线充值、员工线下代充值、重新提交 | 钱包入账和审批人判断 | +| SystemConfig | 配置表单和版本刷新 | 直接编辑任意 JSON | + +全局状态只保留跨页面共享的数据:登录用户、菜单/按钮权限、通知未读数。列表数据、详情数据和表单草稿默认留在页面或模块级状态,避免把服务端状态复制到全局 Store 后长期失真。 + +--- + +## 四、接口与类型约定 + +### 4.1 路径前缀 + +- 后台管理:`/api/admin/*` +- C 端:`/api/c/v1/*` +- 回调接口不由前端调用。 + +专项文档出现省略 `/api` 的路径时,以本节和真实路由注册为准,并应在评审前修正。 + +### 4.2 枚举和显示 + +- 生命周期状态使用后端返回的 `status` 做逻辑判断,使用 `status_name` 做中文展示。 +- 前端不得维护另一份与后端重复的中文状态映射;只有颜色、图标等纯展示映射可以留在前端。 +- 金额 API 统一使用“分”,输入组件展示“元”,提交前做整数转换,禁止浮点数直接乘除后提交。 +- 时间统一使用后端 ISO 8601 值,展示层按现有项目时区和格式化工具处理。 + +### 4.3 请求幂等 + +审批等敏感写操作由前端生成 `request_id`。一次用户操作从首次提交到网络重试必须复用同一个值;用户明确重新发起操作时才生成新值。 + +按钮提交后进入 loading 并禁止重复点击。前端防重只是体验控制,后端仍必须执行状态条件更新和唯一约束。 + +--- + +## 五、统一异步任务交互 + +适用:设备批量分配、批量订购、导出任务。 + +不适用于临期状态:临期列表、详情和首页数量由接口实时 SQL 计算;每日任务只生成 15/7/3 天通知,前端不轮询或维护临期快照。 + +```mermaid +stateDiagram-v2 + [*] --> Editing: 填写参数/选择文件 + Editing --> Submitting: 提交 + Submitting --> Processing: 创建任务成功 + Submitting --> Editing: 参数或上传失败 + Processing --> Processing: 轮询进度 + Processing --> Completed: 全部或部分完成 + Processing --> Failed: 任务失败 + Processing --> Cancelled: 用户取消且后端确认 + Completed --> [*] + Failed --> Editing: 修正后重新提交 + Cancelled --> [*] +``` + +### 5.1 轮询规则 + +- 创建成功后立即请求一次详情,再按 2 秒、3 秒、5 秒逐步退避,最大间隔 10 秒。 +- 页面不可见时暂停轮询,恢复可见时立即刷新。 +- 达到终态、离开页面或组件销毁时停止轮询。 +- 连续网络失败不把业务任务标记为失败,展示“状态获取失败,点击重试”。 +- 服务端返回 `retry_after_seconds` 时优先采用服务端建议。 + +### 5.2 结果展示 + +- 必须同时展示总数、成功数、失败数和任务状态。 +- 部分成功不能只弹一个成功 Toast;失败明细要留在页面,并支持下载或复制,具体能力按专项方案。 +- 导出任务完成后展示下载按钮和链接过期时间;链接过期时重新获取任务详情,不重新创建导出任务。 + +### 5.3 Excel 模板 + +- 设备批量分配、批量订购等 Excel 模板由前端作为静态资源维护,后端不提供模板下载 API。 +- 模板文件名包含版本号,下载入口与对应上传表单放在同一页面。 +- 后端仍必须严格校验表头和内容,不能因为模板由前端提供就信任文件结构。 +- 模板字段变更需要前后端同批发布,并保留对用户本地旧模板的可理解错误提示。 + +--- + +## 六、统一审批交互 + +```mermaid +stateDiagram-v2 + [*] --> Loading + Loading --> ReadOnly: 当前用户无待处理资格 + Loading --> Actionable: 当前用户是待处理审批人 + Actionable --> EditingAction: 打开通过/驳回/退回弹框 + EditingAction --> Uploading: 上传附件 + Uploading --> EditingAction: 上传成功或失败后返回 + EditingAction --> Submitting: 意见和附件校验通过 + Submitting --> EditingAction: 请求失败且任务仍可操作 + Submitting --> ReadOnly: 操作成功或状态已被他人改变 + ReadOnly --> [*] +``` + +### 6.1 页面建议 + +| 页面 | 建议路由 | 主要能力 | +|------|----------|----------| +| 待我审批 | `/approvals/tasks` | 状态、业务类型、提交人、当前节点、提交时间筛选 | +| 审批详情 | `/approvals/instances/:instance_id` | 业务快照、业务资料、动态节点时间线、审批人和意见、当前可操作按钮 | +| 流程定义 | `/settings/approval-flows` | 草稿、发布版本、停用、业务绑定 | +| 流程定义编辑 | `/settings/approval-flows/:id` | 串行节点配置、角色/账号选择、或签/会签、发布前校验 | + +第一版只支持串行节点,前端使用“有序节点列表编辑器”,不建设拖拽 DAG/BPMN 设计器。节点可上移、下移、增加和删除;开始、结束节点由系统生成且不可删除。 + +### 6.2 动态详情 + +审批详情按接口返回的 `tasks[]` 和 `assignees[]` 渲染,禁止写死“部门领导”和“财务”两个字段。 + +详情首屏必须先渲染 `business_snapshot`:业务标题、业务单号、提交人、审批关键字段和业务资料。业务资料来自发起时快照,审批附件来自某位审批人的操作记录,页面用两个区域展示;审批人不需要跳转到实时业务页才能知道正在审批什么。 + +操作按钮显示条件同时满足: + +- 流程状态为审批中。 +- 当前任务状态为待审批。 +- 当前账号对应的审批人状态为待处理。 +- 接口返回相应操作权限。 + +操作成功后重新请求审批实例和关联业务详情,不做乐观状态推进。 + +审批详情可返回 `action_form.fields`。前端只渲染后端声明的受控字段类型;退款金额和操作密码均由流程节点配置决定,且只有当前动作会完成该节点时才出现。金额展示元、提交分;操作密码不写入全局 Store、本地存储或重试缓存,请求结束立即清空。节点没有动作字段时不显示额外表单,禁止根据“财务审核”等节点名称自行添加输入项。 + +每个通过、驳回、退回弹框统一包含“审批意见”和“附件”: + +- 通过意见可选;驳回、退回意见必填,最多 1000 字。 +- 意见使用普通多行文本框,不提供富文本编辑器。 +- 附件最多 5 个、单个默认不超过 20MB,允许图片、PDF、Word、Excel;打开动作弹框时先生成 `request_id`,申请 `purpose=approval_attachment` 且绑定该 `request_id` 的上传凭证,上传完成后提交 `file_key + file_name`。前端校验只用于体验,最终以后端对象元数据和上传归属校验为准。 +- 文件仍在上传时禁止提交审批;删除尚未提交的附件只影响本地表单,不调用删除历史审批附件。 +- 网络重试复用原 `request_id`、意见和附件集合;操作成功后清空本地表单。 +- 时间线按审批人展示各自意见和附件。审批附件和业务资料分别通过审批实例权限校验接口换取短期 URL,不直接拼对象存储地址。 + +### 6.3 业务处理中的展示 + +审批通过与退款到账、钱包入账不是同一时刻。退款和充值详情必须同时展示: + +- 审批状态。 +- 业务处理状态。 +- 业务处理失败原因和“系统重试中/联系管理员”的提示;非代理钱包退款审批通过后显示“待人工退款”,仅财务确认权限可见确认完成入口。 + +不能仅根据业务单原状态显示“待审批”。 + +### 6.4 存量历史记录 + +业务详情接口增加 `approval_source`: + +- `none`:当前业务不需要审批,例如代理在线充值;隐藏审批区域。 +- `workflow`:存在通用审批实例,展示动态时间线和 `available_actions`。 +- `legacy`:发布前已经结束的历史记录,没有完整流程实例;只读展示原业务状态、旧审批摘要和审计信息,不生成虚假节点。 + +如果一条仍需审批的退款或员工线下充值返回 `approval_instance_id=null`,前端按数据迁移异常展示并禁止任何审批操作,不能退回旧业务单审批接口。 + +--- + +## 七、页面与需求映射 + +| 需求 | 端 | 页面/入口 | 关键交互 | +|------|----|-----------|----------| +| 01 | 后台 | 资产详情复机操作 | 界面不变,展示后端真实结果 | +| 02 | 后台 + C端 | 卡/设备列表实名策略;C端流程页 | 单条/批量设置,C端按接口策略跳转 | +| 03/07/11/12/13 | 后台 | 原有列表和详情 | 新筛选、新字段、动态审批摘要 | +| 05 | 后台 | 套餐分配弹框、已分配列表 | 生效条件覆盖和修改提示 | +| 08 | 后台 | 设备管理批量操作 | “批量分配代理”和“批量分配套餐系列”两个入口,上传、任务轮询、失败明细 | +| 09 | 后台 + C端 | 系统配置;支付页 | 后台配置支付方式,C端隐藏并由后端兜底拦截 | +| 10 | 后台 | 卡/设备资产详情 | 手动设置或取消当前卡限速,单位 `kbps`,不提供套餐限速配置 | +| 14 | 后台 | 各业务列表导出 | 字段选择、权限过滤、统一导出任务 | +| 15 | C端 + 后台 | 当前套餐卡片、后台代购 | 当前套餐旁“续费”复用购买流程;下架套餐仅在合法续费入口展示 | +| 16 | - | 已移出 7 月迭代 | 不建设分销码、代理申请、提现材料和相关审批页面 | +| 17 | 后台/代理端 | 代理信用、钱包详情 | 元/分转换、欠款状态、额度权限 | +| 18/20/21 | 后台 | 待办、退款、充值详情 | 动态审批时间线、处理状态、退回重提 | +| 19 | 后台 | 批量订购 | 参数确认、上传、逐行结果和金额汇总 | +| 22 | 后台 + 代理端 + C端 | 临期列表、首页提醒 | 15/7/3 天分级、续费跳转、到期后自动消失 | + +--- + +## 八、站内消息 + +- 登录后和进入后台布局时立即获取未读数,之后每 30 秒轮询。 +- 页面不可见时暂停,恢复时立即刷新。 +- 铃铛数字超过 99 显示 `99+`。 +- 通知点击只按受控的 `ref_type + ref_id` 路由表跳转,不接受后端返回任意 URL。 +- 标记已读失败不阻止查看业务详情,但需要在下次轮询时恢复真实未读状态。 +- 通知正文按纯文本展示;若未来支持富文本,必须使用受控模板和统一净化,不直接渲染任意 HTML。 + +--- + +## 九、权限与敏感数据 + +- 菜单和按钮根据登录返回的权限控制可见性,但后端权限校验是最终依据。 +- 审批人资格由任务接口返回,前端不根据角色名称推导。 +- 导出字段由后端返回允许字段集合;前端只能在允许集合中选择。 +- 退款凭证、充值凭证、身份证、营业执照和审批附件使用现有对象存储上传流程,不提交本地路径或长期公开 URL。审批附件额外提交清理后的原文件名作为展示快照。 +- 列表和详情对无权限与不存在统一展示,避免通过前端文案泄露资源存在性。 + +--- + +## 十、停机发布 + +1. 发布前进入维护模式,前端统一展示维护页并停止提交写请求。 +2. 维护窗口内执行数据库迁移,同时发布 API、Worker/Relay 和前端静态资源。 +3. 初始化并启用退款、充值流程定义和业务绑定,执行存量待审批单回填。 +4. 前端只调用任务级审批 API,不保留退款或线下充值的业务单级通过/驳回按钮,也不保留线下充值“确认入账”按钮。 +5. 人工验证登录、菜单权限、流程发起、存量历史展示、待办、审批详情、退回重提和业务处理状态。 +6. 验证通过后解除维护模式;失败则在开放访问前回滚整套应用版本。 + +前端必须容忍新增响应字段且不依赖字段顺序,但本次不要求支持旧后端与新前端或新后端与旧前端交叉运行。 + +--- + +## 十一、待前端仓库确认 + +以下内容不影响当前接口和交互评审,但实施前必须在前端仓库确认: + +- 后台、代理端和 C 端分别使用的框架版本与目录结构。 +- 现有权限指令、请求封装、上传组件和导出 Hook 的真实名称。 +- 是否已有通用任务轮询组件和流程时间线组件。 +- 实际菜单路由和按钮权限编码。 +- 表格是否支持服务端返回的动态导出字段配置。 + +确认后只补充实现映射,不改变本文已经评审通过的业务状态和 API 契约。 diff --git a/docs/7月迭代/新增需求/01-数据同步触发与轮询优化.md b/docs/7月迭代/新增需求/01-数据同步触发与轮询优化.md new file mode 100644 index 0000000..d861adb --- /dev/null +++ b/docs/7月迭代/新增需求/01-数据同步触发与轮询优化.md @@ -0,0 +1,625 @@ +# 新增需求 01:数据同步触发与轮询优化 + +> 状态:已合并至标准评审稿,本文保留为实施明细。 +> 评审主文档:`../7月迭代技术方案-标准评审稿.md` +> 范围:IoT 卡实名、流量、网络状态和设备实时数据同步。 + +## 一、已确认决策 + +1. 轮询、运营商回调、业务事件触发是三条相互隔离的自动同步通道。 +2. 轮询始终保留,负责回调失效、事件漏网和上游偶发失败时的最终兜底。 +3. 资产是否轮询只保留现有全局开关 `enable_polling`;不再设计“实名资格、流量资格、状态资格”等业务资格开关。 +4. 轮询优化只区分活跃卡和不活跃卡,通过不同轮询间隔降低无效请求,不因业务类型直接永久排除某类卡。 +5. 行业卡是否需要实名不能由 `card_category=industry` 推断,统一以运营商 `realname_link_type` 判断。 +6. 业务事件触发不是新增一个显式同步接口,而是埋点到关键查询、停复机、支付、实名和设备操作用例中。 +7. 每次事件默认形成三个独立尝试:立即、事件发生后 3 分钟、事件发生后 5 分钟;三次结束后由常规轮询继续兜底。 +8. 不设置统一五分钟冷却。重复请求控制拆成同场景触发合并、单卡请求互斥和运营商最小请求间隔;Gateway 超频只记录当前失败,不建立退避状态。 +9. 运营商实名回调百分百信任,不做来源真实性校验;但必须经过防腐层完成报文解析、标识转换和状态语义转换。 +10. 解除实名第一版不修改业务状态,只保留回调入口、写入统一集成审计并返回运营商要求的成功报文。 +11. 三条自动通道和现有手动刷新接口共享同一套“获取上游观测并应用到业务”的 DDD 用例,禁止各入口直接更新卡字段。 +12. 同步执行记录属于全局审计的 Integration Log,不在本方案新建 `tb_card_sync_execution` 或独立同步监控系统。 +13. 本次迁移触碰到的旧实名、流量和状态写逻辑必须删除或改为调用新用例,不保留长期双实现。 + +## 二、现状问题 + +当前代码存在以下明确问题: + +- `internal/task/polling_realname_handler.go` 直接跳过所有行业卡,与“是否实名由运营商特性决定”冲突。 +- `PollingRealnameHandler`、`PollingCarddataHandler` 和 `PollingCardStatusHandler` 同时承担 Gateway 查询、字段更新和业务联动。 +- `internal/service/iot_card/service.go:RefreshCardDataFromGateway` 又实现一套实名、流量和网络状态同步。 +- `ManualUpdateRealnameStatus` 单独实现实名状态变更联动,无法保证与轮询、回调行为一致。 +- `ClientRealnameHandler`、部分设备 Handler 直接调用 Gateway,业务事件无法在统一用例边界埋点。 +- 现有获取实名链接后的自动检查借用了 `ManualTriggerService`,把系统事件伪装成平台用户手工操作。 +- 调度任务载荷只有 `card_id`,缺少触发场景、来源、序列和关联链路。 +- `last_sync_time` 被实名、流量和状态共同覆盖,无法说明最后同步了什么。 +- 现有显式刷新接口已经存在,再增加 `/card-sync/requests` 只会形成重复入口。 + +因此,本次不是增加更多同步 Service,而是先收口写侧用例,再把轮询、回调和事件触发接入同一边界。 + +## 三、目标架构 + +```mermaid +flowchart LR + Polling[轮询调度] --> Activity[按活跃度计算下次间隔] + Activity --> Request[RequestCardObservation] + + Business[关键业务用例] --> Trigger[CreateSyncTriggerSeries] + Query[关键查询用例] --> Trigger + Trigger --> A1[立即尝试] + Trigger --> A2[3分钟后尝试] + Trigger --> A3[5分钟后尝试] + A1 --> Gate[请求协调器] + A2 --> Gate + A3 --> Gate + Gate --> Request + + Manual[现有手动刷新接口] --> Request + + Callback[运营商实名回调] --> ACL[运营商防腐层] + ACL --> RealObs[标准实名观测] + + Request --> Gateway[Gateway Adapter] + Gateway --> Observation[标准化观测值] + RealObs --> Apply[ApplyCardObservation] + Observation --> Apply + + Apply --> Card[保存卡状态] + Apply --> Events[记录领域事件和 Outbox] + Events --> Package[套餐激活与流量扣减] + Events --> StopResume[停复机评估] + Events --> ActivityMark[更新活跃标记] + Request --> Integration[统一 Integration Log] + ACL --> Integration + Events --> Audit[关键业务 Audit Event] +``` + +各入口边界: + +| 入口 | 是否调用 Gateway | 执行方式 | 作用 | +|---|---:|---|---| +| 轮询 | 是 | 异步、持续重排 | 最终一致性兜底 | +| 运营商回调 | 否 | 同步应用状态,联动异步可靠投递 | 实名成功急速通道 | +| 业务事件触发 | 是 | 异步 `0/3/5` 三次 Best Effort | 提高正在操作资产的命中概率 | +| 现有手动刷新 | 是 | 保持现有响应契约 | 用户明确要求立即刷新 | + +手动刷新不是第四种自动策略,也不能被业务埋点调用。 + +## 四、DDD 设计 + +### 4.1 目录 + +```text +internal/ +├── domain/cardstate/ +│ ├── card.go 卡状态聚合及状态转换 +│ ├── observation.go 实名、流量、网络状态观测值 +│ ├── events.go 状态变化领域事件 +│ └── repository.go 聚合仓储接口 +├── application/cardsync/ +│ ├── request_observation.go 查询 Gateway 并获取标准观测 +│ ├── apply_observation.go 在事务内应用观测和领域事件 +│ ├── create_trigger_series.go 创建事件触发序列 +│ ├── execute_trigger_attempt.go 执行单次阶梯尝试 +│ ├── mark_activity.go 更新活跃标记 +│ └── refresh_asset.go 承接现有显式刷新接口 +├── infrastructure/adapter/gateway/ +│ └── card_observation_adapter.go 复用现有 Gateway Client +├── infrastructure/adapter/carrier_callback/ +│ ├── translator.go 防腐层统一接口 +│ ├── cmcc.go +│ ├── cucc.go +│ └── ctcc.go +├── infrastructure/messaging/cardsync/ +│ ├── trigger_publisher.go Outbox/Asynq 任务发布 +│ └── trigger_handler.go 阶梯任务处理器 +└── query/cardsync/ + └── activity_query.go 活跃状态和下次轮询查询 +``` + +### 4.2 实名要求判断 + +不得继续使用以下判断: + +```go +if card.CardCategory == constants.CardCategoryIndustry { + // 跳过实名 +} +``` + +现有 `tb_carrier.realname_link_type` 可以直接决定该运营商接入是否需要实名,不新增重复能力字段: + +| `realname_link_type` | 实名要求 | 实名入口 | +|---|---|---| +| `none` | 不需要实名 | 不提供实名链接,不创建实名查询任务 | +| `template` | 需要实名 | 使用模板生成实名链接 | +| `gateway` | 需要实名 | 调用 Gateway 获取实名链接 | + +- 资产 `realname_policy` 只决定“先实名后购买”或“先购买后实名”的业务顺序。 +- `realname_link_type=none` 时,资产有效实名策略统一视为 `none`。 +- `realname_link_type!=none` 时,资产 `realname_policy=none` 属于冲突数据,发布前列出并修正,禁止运行时静默选择其中一方。 +- `card_category` 只保留业务分类和展示用途,不参与实名轮询、复机或套餐激活判断。 + +运营商是否配置回调 Adapter 只影响有没有回调急速通道,不改变 `realname_link_type` 对实名要求的判断。 + +### 4.3 标准化观测值 + +```go +type SyncSource string + +const ( + SyncSourcePolling SyncSource = "polling" + SyncSourceBusinessEvent SyncSource = "business_event" + SyncSourceOpenAPI SyncSource = "open_api" + SyncSourceManualRefresh SyncSource = "manual_refresh" + SyncSourceCarrierCallback SyncSource = "carrier_callback" +) + +type ObservationMeta struct { + Source SyncSource + TriggerScene string + TriggerSeries string + Attempt int + Provider string + ObservedAt time.Time + RequestID string + CorrelationID string +} + +type RealnameObservation struct { + CardID uint + ICCID string + Verified bool + Meta ObservationMeta +} + +type TrafficObservation struct { + CardID uint + GatewayReadingMB float64 + Meta ObservationMeta +} + +type NetworkObservation struct { + CardID uint + CardStatus string + Extend string + GatewayIMEI string + Meta ObservationMeta +} +``` + +领域层不使用 `map[string]any` 传递核心状态,防止不同入口遗漏字段或混淆单位。 + +### 4.4 观测应用规则 + +`ApplyCardObservation` 是唯一允许把上游数据写入卡领域状态的入口: + +1. 加载并锁定卡聚合。 +2. 根据观测类型执行实名、流量或网络状态规则。 +3. 状态未变化时只更新时间和观测来源,不重复产生业务事件。 +4. 状态变化时保存聚合,并在同一事务写 Outbox 和关键 Audit Event。 +5. 实名 `0 -> 1` 产生 `CardRealnamed`,触发卡级/设备级套餐激活和停复机评估。 +6. 流量正增量产生 `CardTrafficIncreased`,由套餐应用服务扣减套餐流量。 +7. 网络状态变化产生 `CardNetworkStatusChanged`,由停复机策略重新评估。 +8. 状态变化或流量增加时更新卡活跃标记。 + +解除实名回调不构造 `Verified=false` 观测,不修改卡状态。 + +## 五、轮询优化:只做活跃度调频 + +### 5.1 全局开关 + +`enable_polling` 是资产是否参与自动轮询的唯一业务开关: + +- `false`:从所有轮询队列移除,事件触发也不因此失效;用户主动操作仍可触发 Best Effort 同步。 +- `true`:按照活跃度和运营商能力进入对应 Gateway 查询队列。 +- 设备关闭轮询时,继续沿用现有设备与绑定卡级联规则。 + +不再提供“实名轮询开关、流量轮询开关、状态轮询开关”给业务人员配置。 + +### 5.2 活跃标记 + +卡增加或维护以下轮询调度字段: + +```text +polling_activity_level active / inactive +last_polling_activity_at 最后活跃时间 +last_polling_activity_scene 最后活跃场景 +last_upstream_change_at 上游观测最后发生变化时间 +``` + +以下情况标记为活跃: + +| 场景 | 说明 | +|---|---| +| C 端、后台或开放接口查询资产实时信息 | 表明当前有人关心该资产 | +| 获取实名链接 | 表明即将发生实名操作 | +| 停机、复机、切卡、重启、恢复出厂设置 | 表明上游状态正在变化 | +| 套餐支付、激活、失效或流量重置 | 可能影响流量和停复机 | +| 运营商回调 | 上游已发生变化 | +| 轮询发现实名、流量或网络状态变化 | 卡当前确实活跃 | + +卡在配置时间内没有业务活动,且连续轮询未发现上游变化时转为 `inactive`。该判断在每次重排队时完成,不新增全表扫描任务。 + +第一版建议配置而非写死: + +```text +inactive_after = 30m +active_realname_interval = 现有实名默认间隔 +active_traffic_interval = 现有流量默认间隔 +active_network_interval = 现有状态默认间隔 +inactive_realname_interval = 15m +inactive_traffic_interval = 15m +inactive_network_interval = 15m +``` + +现有 `tb_polling_config` 的有效间隔迁移为活跃卡默认值,但不再按 `card_condition/card_category` 匹配多套业务资格。第一版调整为一套全局活跃/不活跃间隔;运营商配置只描述接口能力和上游限频,不再决定某张卡是否“有资格”轮询。上线前使用生产数据做只读测算,确认 Gateway QPS 和最长兜底延迟后再调整数值。 + +### 5.3 调度规则 + +```text +enable_polling=false + -> 不入自动轮询队列 + +enable_polling=true + active + -> 使用全局活跃间隔 + +enable_polling=true + inactive + -> 使用全局不活跃间隔 + +realname_link_type=none + -> 不创建实名查询任务 +``` + +已实名卡仍可低频查询实名状态,以发现上游实名逆转;未实名行业卡也不能因卡类别被排除。 + +## 六、业务事件触发 + +### 6.1 不是新增接口 + +本需求不新增 `POST /api/admin/card-sync/requests`,也不新增独立“事件同步”按钮。 + +现有接口保持: + +```http +POST /api/admin/assets/:identifier/refresh +POST /api/c/v1/asset/refresh +``` + +现有批量轮询运维接口可以保留,但必须改为调用新的 Application 用例,不再自行维护另一套同步逻辑。 + +业务事件触发由 Application UseCase 或 Query 完成后调用内部端口: + +```go +type SyncTriggerCommand struct { + Scene string + ResourceType string + ResourceID uint + CardIDs []uint + SyncTypes []string + ExpectedState map[string]any + Source SyncSource + RequestID string + CorrelationID string +} +``` + +写操作在业务成功后触发;有数据库事务的关键写操作通过 Outbox 发布触发事件,避免提交成功后进程退出造成埋点丢失。读操作只查询本地快照,不等待 Gateway;在返回前 Best Effort 写入 Asynq,入队失败不得改变原接口响应。 + +### 6.2 阶梯式尝试 + +每个触发序列默认创建三个 Asynq 任务: + +| 尝试 | 计划时间 | Asynq 自动重试 | +|---:|---:|---:| +| 1 | 立即 | 0 | +| 2 | 事件发生后 3 分钟 | 0 | +| 3 | 事件发生后 5 分钟 | 0 | + +每次任务都有确定性的 `series_id + attempt` 幂等键。单次失败不会额外重试,也不会取消后续两次;三次结束后由常规轮询兜底。 + +存在明确预期状态时允许提前完成序列: + +- 复机后已观测为开机。 +- 停机后已观测为停机。 +- 获取实名链接后已通过回调或查询确认实名。 +- 切卡后设备当前卡已经是目标 ICCID。 + +序列提前完成后,剩余任务启动时检查状态并直接记为 `completed`,不再请求 Gateway。 + +### 6.3 关键埋点 + +埋点放在应用用例成功边界,不散落在 Handler 的 `response.Success` 前后。现有 Handler 直接调用 Gateway 的路径在迁移时收口到 Application。 + +| 现有入口/用例 | 触发内容 | 预期状态 | +|---|---|---| +| `GET /api/c/v1/asset/info` | 资产绑定卡的实名、流量、网络状态 | 无 | +| `GET /api/admin/assets/:identifier/realtime-status` | 对应卡或设备绑定卡的实时数据 | 无 | +| `GET /api/open/v1/cards/traffic` | 流量 | 无 | +| `GET /api/open/v1/cards/status` | 网络状态 | 无 | +| `GET /api/open/v1/cards/realname-status` | 实名 | 无 | +| `GET /api/open/v1/devices/traffic` | 设备信息、绑定卡流量 | 无 | +| C 端和后台获取实名链接 | 实名 | 已实名 | +| 后台、C 端、开放接口停机/复机成功 | 网络状态 | 停机或开机 | +| 自动停复机 Gateway 调用成功 | 网络状态 | 停机或开机 | +| 订单支付、钱包购买套餐、套餐激活成功 | 实名、流量、网络状态 | 无 | +| 设备切卡或切换模式成功 | 设备信息、源卡和目标卡网络状态、目标卡流量 | 目标 ICCID | +| 设备重启、恢复出厂设置、WiFi 设置成功 | 设备信息、绑定卡网络状态 | 无 | +| 卡绑定/解绑、设备或卡分配/回收 | 只标记活跃;确有上游操作时再创建同步序列 | 无 | + +补充规则: + +- `POST /api/admin/assets/:identifier/refresh` 和 `POST /api/c/v1/asset/refresh` 已经直接同步,不再额外创建 `0/3/5` 序列。 +- 运营商实名回调直接应用观测,不再反查 Gateway;回调成功后终止相同卡的待执行实名序列。 +- 轮询发现状态变化只更新活跃标记,不反向创建新的事件序列。 +- 同一次设备操作涉及多张绑定卡时使用同一 `correlation_id`,但每张卡独立限流和记录结果。 + +### 6.4 冷却、合并与限流 + +不使用一个固定五分钟 `SET NX` 键拦截所有事件。请求协调器只处理同场景合并、单卡互斥和最小请求间隔;Gateway 返回超频后不建立额外退避状态。 + +#### 同场景触发合并 + +```text +cardsync:series:{scene}:{resource_type}:{resource_id}:{sync_type} +``` + +- 同一场景、同一资源、同一同步类型已有未结束序列时,新触发合并到原序列。 +- 合并只防止页面轮询、开放接口重试等重复创建大量 `0/3/5` 任务。 +- 停复机、支付、切卡等不同业务场景不会被一个查询场景长期压制。 +- 序列键只覆盖最后一次计划任务和短暂缓冲,不作为全局五分钟冷却。 + +#### 单卡请求互斥 + +```text +cardsync:inflight:{provider}:{sync_type}:{card_id} +``` + +- 只覆盖一次 Gateway 请求的执行时间,TTL 为请求超时加安全余量。 +- 防止轮询、手动刷新和事件任务同时请求并重复应用同一观测。 +- 轮询命中互斥时延迟短时间重排;事件尝试命中互斥时只跳过当前尝试,后续阶梯任务仍存在。 + +#### 运营商最小请求间隔 + +最小间隔按运营商接入和接口类型配置,例如实名、流量、状态可以不同,不能统一写死为五分钟。 + +- 默认兜底值建议为 10 秒,仅用于阻止近乎同时的重复调用。 +- 如果上游明确给出更严格限制,以运营商配置为准。 +- 高价值状态变更事件命中最小间隔时,延迟到最近允许时间,不直接丢弃整个序列。 + +#### 超频结果处理 + +- Gateway 返回超频时,当前尝试记录为 `rate_limited` 后直接结束。 +- 不记录 `blocked_until`,不读取 `Retry-After`,也不执行 `30s/60s/120s` 退避。 +- 不为当前尝试额外补发任务,原定 3 分钟、5 分钟尝试保持不变。 +- 后续轮询仍按正常调度时间继续,是否再次超频由当时上游实际情况决定。 +- 轮询、事件和手动刷新仍共享本系统的并发控制,避免本系统自身在同一时刻并发打满 Gateway。 + +因此,“5 分钟”是第三次尝试的计划时间,不是禁止新业务事件同步的冷却时间。 + +### 6.5 开放接口行为 + +开放接口继续返回本地快照,不等待 Gateway: + +```mermaid +sequenceDiagram + participant Agent as 代理系统 + participant API as Open API + participant DB as PostgreSQL + participant Trigger as CreateSyncTriggerSeries + participant Worker as Sync Attempt Worker + + Agent->>API: 查询实名/状态/流量 + API->>DB: 查询当前本地快照 + DB-->>API: 当前数据 + API-->>Agent: 按现有结构立即返回 + API->>Trigger: Best Effort 创建 0/3/5 序列 + Trigger-->>Worker: 同场景重复请求自动合并 +``` + +## 七、运营商实名回调防腐层 + +### 7.1 防腐层职责 + +“百分百信任回调”表示信任其业务结论,不表示让运营商报文直接进入领域模型。 + +```go +type CarrierRealnameCallbackTranslator interface { + TranslateSuccess(body []byte) (CarrierRealnameNotice, error) + TranslateRemoval(body []byte) (CarrierRealnameRemovalNotice, error) + SuccessResponse() CallbackResponse + FailureResponse(err error) CallbackResponse +} +``` + +各运营商 Adapter 负责: + +- 解析 JSON、XML 或表单字段。 +- 提取并校验 19/20 位 ICCID、MSISDN 等标识。 +- 把运营商状态码翻译为“实名成功”或“解除实名通知”。 +- 屏蔽运营商字段名、状态码和成功响应格式。 +- 生成脱敏 Integration Log 摘要。 + +领域层只接收标准 `RealnameObservation`,不依赖移动、联通、电信的报文结构。 + +#### ICCID 19/20 位解析 + +回调必须复用现有双列存储和按长度路由规则: + +```text +收到 19 位 ICCID + -> 原值查询 tb_iot_card.iccid_19 + +收到 20 位 ICCID + -> 原值查询 tb_iot_card.iccid_20 + +收到其他长度 + -> invalid_payload,不进入实名用例 +``` + +- 先去除首尾空白,再调用现有 ICCID Validator,只接受 19 位或 20 位字母数字值。 +- 禁止把 20 位回调值截成 19 位后降级查询。 +- 禁止为 19 位值自行补第 20 位校验位。 +- 查询 Miss 时不切换另一列重试,记录原始长度、运营商和脱敏 ICCID 摘要。 +- 复用 `IotCardStore.GetByICCID` 的长度路由语义;防腐层只负责提取和校验,不自己拼接模糊 SQL。 +- `iccid_19` 必须在未删除卡中保持唯一。发布前检查重复前缀并建立 Partial Unique Index,避免 19 位回调错误命中任意一张卡。 +- Repository 查询若发现多条匹配必须返回冲突,不允许使用 `First` 静默选择。 +- Integration Log 的 `resource_key` 保存回调原始 19/20 位值,展示时按审计权限脱敏。 + +### 7.2 路由 + +```http +POST /api/callback/carriers/cmcc/realname +POST /api/callback/carriers/cucc/realname +POST /api/callback/carriers/ctcc/realname + +POST /api/callback/carriers/cmcc/realname/remove +POST /api/callback/carriers/cucc/realname/remove +POST /api/callback/carriers/ctcc/realname/remove +``` + +- 成功回调:防腐层转换后直接调用 `ApplyCardObservation(Verified=true)`。 +- 解除回调:只写统一 Integration Log,状态记为 `ignored`,不修改卡状态。 +- 广电或其他没有回调能力的接入继续依赖事件和轮询。 +- 不验证回调是否真的来自运营商,不执行 Gateway 二次查询。 +- 不调用旧平台 `inner_callback`、第三方推送或删除实名接口。 + +### 7.3 响应原则 + +- 找到卡并应用成功:返回运营商要求的成功报文。 +- 重复成功回调:幂等返回成功,不重复激活套餐。 +- 找不到卡:Integration Log 记 `not_found`,仍返回成功,避免无意义高频重推。 +- 报文无法解析:记录 `invalid_payload`;若运营商有固定成功应答要求,仍按接入约定返回,避免不可控重试风暴。 +- 状态已落库但异步联动失败:返回成功,联动通过 Outbox 重试。 + +## 八、统一审计契约 + +同步运行数据不在本方案定义独立表和独立页面,统一由 `04-全局多视角审计方案.md` 管理。 + +本模块需要向审计系统提供: + +| 记录 | 进入位置 | +|---|---| +| 每次 Gateway 实际请求或被限流、合并的尝试 | Integration Log | +| 每次运营商实名/解除实名回调 | Integration Log | +| 实名、流量、网络状态发生业务变化 | Audit Event + Integration Log | +| 手动刷新、手动实名修改 | Audit Event + Integration Log | +| 连续失败达到阈值、超频持续异常 | 风险 Audit Event | +| 普通高频轮询成功且数据未变化 | 仅 Integration Log | + +必须携带: + +```text +provider / operation / resource / trigger_scene / trigger_series +attempt / result / duration / request_id / correlation_id +state_changed / provider_code / error_summary +``` + +资产详情的“查看同步轨迹”跳转审计中心外部集成视角,不再建设 `/operations/card-sync` 独立监控页。 + +## 九、接口和前端调整 + +### 9.1 后端接口 + +本需求不新增显式同步接口。 + +现有手动刷新接口内部改为调用 `RefreshAssetUseCase`,响应结构和权限保持不变。现有轮询监控接口增加: + +- 活跃卡数量、不活跃卡数量。 +- 各活跃级别的实际轮询 QPS。 +- 因互斥或运营商最小间隔而延迟的任务数,以及 Gateway 超频失败次数。 + +资产实时状态或详情 Query 增加可选调度信息: + +```json +{ + "polling": { + "enabled": true, + "activity_level": "active", + "last_activity_at": "2026-07-15T10:00:00+08:00", + "last_activity_scene": "client_asset_info", + "next_poll_at": "2026-07-15T10:01:00+08:00" + } +} +``` + +同步明细查询复用全局审计接口: + +```http +GET /api/admin/audit/integrations + ?provider=gateway + &resource_type=iot_card + &resource_key=89860... + &trigger_scene= + &trigger_series= +``` + +### 9.2 前端 + +保留现有资产刷新按钮,不新增第二个同步按钮。 + +资产详情增加紧凑的轮询状态展示: + +```text +自动轮询:已开启 +活跃状态:活跃 +最后活跃:2分钟前,C端资产详情 +下次兜底:约1分钟后 +同步轨迹:查看 +``` + +“查看”跳转: + +```text +/operations/audit?tab=integrations&resource_type=iot_card&resource_key={identifier} +``` + +轮询监控页只增加活跃/不活跃分布和限流状态,不重复实现 Integration Log 表格、详情抽屉和导出。 + +## 十、代码迁移范围 + +### 10.1 必须删除或收口 + +- 删除 `PollingRealnameHandler` 按 `CardCategoryIndustry` 跳过实名的判断。 +- 修正 `pkg/constants/iot.go` 中“普通卡必需实名、行业卡无需实名”的绝对化注释和依赖逻辑。 +- `PollingRealnameHandler`、`PollingCarddataHandler`、`PollingCardStatusHandler` 只调用 Application 用例,不再直接更新卡或执行业务联动。 +- 删除重复流量增量算法,只保留领域实现。 +- `RefreshCardDataFromGateway` 改为调用 `RequestCardObservation + ApplyCardObservation`。 +- `ManualUpdateRealnameStatus` 改为调用领域用例。 +- 获取实名链接后的 `ManualTriggerService.TriggerSingle` 改为 `CreateSyncTriggerSeries`。 +- C 端和后台设备操作中直接调用 Gateway 的路径迁入 Application,再在用例成功边界创建触发序列。 + +### 10.2 保留并复用 + +- Gateway Client 的加密、签名和 HTTP 封装。 +- Redis 分片 Sorted Set 调度基础设施。 +- Asynq Worker。 +- 卡流量同步锁,迁移为通用请求互斥实现。 +- 现有资产手动刷新接口和权限契约。 +- 现有批量轮询运维能力;其执行逻辑和记录迁入新用例与统一审计后,再下线旧专用日志表。 + +### 10.3 数据变更 + +- 不新增实名要求字段,继续以 `tb_carrier.realname_link_type` 判断是否需要实名及链接生成方式。 +- 发布前检查 `iccid_19` 重复数据并建立未删除数据范围内的唯一索引,保证 19 位回调精确命中。 +- 卡增加轮询活跃标记字段,或由独立调度状态表保存;实现阶段根据写入频率决定,不能把高频调度心跳写入核心卡表。 +- 现有 `tb_polling_config` 条件匹配迁移为单一全局活跃/不活跃策略,不再使用 `card_condition/card_category` 形成隐式轮询资格。 +- 事件任务载荷增加 `scene/series_id/attempt/source/request_id/correlation_id/expected_state`。 +- 不创建 `tb_card_sync_execution`。 + +## 十一、发布与人工验证 + +停机发布,API 与 Worker 同时切换: + +1. 验证 `realname_link_type=none/template/gateway` 分别对应无需实名、模板实名和 Gateway 实名,不再按行业卡统一跳过。 +2. 验证 `enable_polling=false` 会移除自动轮询,但业务事件和手动刷新仍能按各自规则工作。 +3. 验证活跃卡使用活跃间隔,不活跃卡使用低频间隔,重新活跃后立即恢复。 +4. 验证一次业务事件形成立即、3 分钟、5 分钟三个任务。 +5. 验证停复机、实名和切卡达到预期状态后,剩余任务不再请求 Gateway。 +6. 验证同场景高频查询只合并重复序列,不压制新的停复机或支付事件。 +7. 验证单卡互斥只覆盖请求执行时间,不形成五分钟全局冷却。 +8. 验证 Gateway 超频时当前尝试直接记为 `rate_limited`,不创建退避状态,后续阶梯任务仍保留。 +9. 验证现有后台和 C 端刷新接口契约不变,且不额外创建阶梯序列。 +10. 验证移动、联通、电信回调均先经过防腐层,19 位和 20 位 ICCID 分别按双列精确路由,重复成功回调不重复激活套餐。 +11. 验证解除实名回调只写 Integration Log,不修改卡实名状态。 +12. 验证开放接口仍立即返回本地数据,事件触发失败不改变响应。 +13. 验证普通同步、状态变化、手动刷新和连续失败能够在审计中心按资源和触发序列查询。 diff --git a/docs/7月迭代/新增需求/02-企业微信审批接入.md b/docs/7月迭代/新增需求/02-企业微信审批接入.md new file mode 100644 index 0000000..895712b --- /dev/null +++ b/docs/7月迭代/新增需求/02-企业微信审批接入.md @@ -0,0 +1,878 @@ +# 新增需求 02:企业微信审批接入 + +> 状态:已合并至标准评审稿,本文保留为实施明细。 +> 评审主文档:`../7月迭代技术方案-标准评审稿.md` +> 范围:退款审批、平台员工线下充值审批,以及企业微信模板配置、回调和轮询补偿。 + +## 一、已确认决策 + +1. 不建设通用审批流引擎,审批节点、审批人、会签/或签和流程配置全部由企业微信审批模板负责。 +2. 本系统只建设企业微信审批接入、状态镜像和审批终态后的业务处理。 +3. 退款金额在申请时固定,企微只决定通过或驳回;不允许审批人在终态修改退款金额。 +4. 线下充值不再要求操作密码,企微审批通过后自动入账。 +5. 企微审批通过后又撤销,不自动冲正已经完成的退款或充值,只记录高等级异常、发送通知并人工处理。 +6. 回调是主通道,每 2 分钟查询审批详情作为待审批单兜底。 +7. 回调与轮询必须进入同一个状态同步用例,业务终态处理只能执行一次。 +8. 企微模板 ID 和控件 ID 都可能因管理员编辑模板而变化,必须使用稳定业务场景码、不可变模板版本和控件映射快照。 +9. 本次触碰的退款、线下充值审批逻辑迁移到 Domain/Application,旧业务单级通过、驳回、退回和确认入账接口下线,不保留两套审批入口。 +10. 系统无法从旧模板 ID 自动发现编辑后生成的新模板 ID;模板变更必须先暂停业务场景,再发布新映射并原子切换,避免继续向旧模板提交。 +11. 退款仍为财务人工退款,本系统不调用微信、支付宝等支付渠道退款接口;只有代理钱包支付订单自动回溯原扣款代理主钱包,不向个人客户或资产钱包自动回款。 +12. 系统账号与企微成员使用 Web 登录二维码自助绑定,普通运营不手工查找或录入 `userid`;管理端只查看状态和强制解绑。 + +## 二、系统边界 + +企业微信负责: + +- 审批模板编辑。 +- 审批节点和审批人配置。 +- 审批中的通过、驳回、撤销和删除。 +- 审批意见和审批附件展示。 +- 企业微信端待办提醒。 + +本系统负责: + +- 退款和线下充值业务单创建。 +- 把本地业务快照和附件提交到企微。 +- 保存企微审批单号和状态镜像。 +- 接收、解密企微回调并查询审批详情。 +- 审批通过后记录人工退款终态、按规则回溯代理主钱包,或执行线下充值入账。 +- 审批驳回、撤销、删除后的本地业务状态更新。 +- 站内结果通知、异常告警和审计。 + +本系统不再保存审批节点、审批任务、审批人候选集合或本地审批动作。 + +## 三、总体流程 + +```mermaid +sequenceDiagram + actor User as 平台员工 + participant API as Refund/Recharge Application + participant DB as PostgreSQL + participant Outbox as Outbox + participant Worker as WeCom Submit Worker + participant WeCom as 企业微信审批 + participant Callback as 企微回调 + participant Sync as SyncApprovalStatus + participant Biz as 业务终态用例 + + User->>API: 创建退款/线下充值申请 + API->>DB: 保存业务单和企微审批实例(submitting) + API->>Outbox: 同事务写 SubmissionRequested + API-->>User: 返回业务单和提交中状态 + Outbox->>Worker: 投递提交任务 + Worker->>WeCom: 上传附件、applyevent + WeCom-->>Worker: sp_no + Worker->>DB: 保存 sp_no,状态改为 pending + + WeCom->>Callback: 加密审批事件 + Callback->>Callback: 验签、解密、保存事件 + Callback->>Sync: 提交状态同步 + Sync->>WeCom: getapprovaldetail + WeCom-->>Sync: 审批详情 + Sync->>DB: 幂等更新审批状态和详情快照 + Sync->>Outbox: 首次进入终态时写业务处理事件 + Outbox->>Biz: 可靠执行退款/充值终态用例 +``` + +## 四、基础配置 + +企微基础连接配置使用现有 Viper 统一配置体系,不通过通用 Key-Value 页面展示明文密钥: + +```yaml +wecom: + corp_id: "" + agent_id: 0 + agent_secret: "" + callback_token: "" + callback_encoding_aes_key: "" + callback_path: "/api/callback/wecom/approval" + account_binding_redirect_url: "https://后台域名/api/callback/wecom/account-binding" + approval_poll_interval: 2m + template_verify_interval: 10m + request_timeout: 10s +``` + +环境变量覆盖敏感项: + +```text +JUNHONG_WECOM_CORP_ID +JUNHONG_WECOM_AGENT_ID +JUNHONG_WECOM_AGENT_SECRET +JUNHONG_WECOM_CALLBACK_TOKEN +JUNHONG_WECOM_CALLBACK_ENCODING_AES_KEY +JUNHONG_WECOM_ACCOUNT_BINDING_REDIRECT_URL +``` + +后台只能查询“是否已配置、最近连通时间、最近错误”,不能返回 Secret、Token 或 EncodingAESKey。 + +企微自建应用还必须配置: + +- Web 登录回调可信域名与 `account_binding_redirect_url` 域名一致。 +- 应用可见范围覆盖需要发起退款或线下充值审批的平台员工,否则扫码时企微会提示无权限。 +- `CorpID`、`AgentID`、用于换取身份的 Access Token 必须属于同一个自建应用配置。 + +用户提供的 demo 中已经出现完整密钥,正式接入前必须在企业微信后台轮换,并禁止将新值写入代码、Markdown、日志或审计快照。 + +## 五、模板映射与版本 + +### 5.1 为什么不能只配置 template_id + +企业微信模板编辑后可能生成新的模板 ID;删除并重新增加控件时,控件 ID 也会变化。业务代码如果写死: + +```go +templateID = "..." +amountControlID = "Text-..." +``` + +模板一旦编辑,新审批立即提交失败,历史审批也无法说明当时使用了哪套字段。 + +因此分为三层: + +```text +稳定业务场景 scene_code + ↓ 当前启用 +不可变模板版本 template_version + ↓ 包含 +template_id + control_mapping + template_snapshot +``` + +### 5.2 稳定业务场景 + +第一版固定两个场景: + +| scene_code | 中文名称 | 业务类型 | +|---|---|---| +| `refund_approval` | 退款审批 | refund | +| `offline_recharge_approval` | 线下充值审批 | agent_recharge | + +业务代码只引用 `scene_code`,不直接引用企微模板 ID。 + +### 5.3 场景运行状态 + +```sql +CREATE TABLE tb_wecom_approval_scene ( + scene_code VARCHAR(64) PRIMARY KEY, + scene_name VARCHAR(255) NOT NULL, + status INT NOT NULL DEFAULT 0, + current_template_version_id BIGINT, + paused_reason VARCHAR(255) NOT NULL DEFAULT '', + version BIGINT NOT NULL DEFAULT 0, + updated_by BIGINT, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +``` + +`status`:`0=已暂停, 1=启用, 2=暂停中`。不建立数据库外键,`current_template_version_id` 由领域规则保证引用有效版本。 + +企微不会根据旧模板 ID 告诉本系统“这个模板已经产生了一个新 ID”。如果旧 ID 仍可访问,仅轮询 `gettemplatedetail` 也无法发现新模板。因此模板编辑采用明确的维护流程: + +```text +将 scene_code 改为暂停中,立即阻止新申请和新提交租约 +→ 等待已经取得提交租约的 Worker 完成或释放 +→ 场景进入已暂停 +→ 在企微编辑模板并取得新 template_id +→ 本系统读取新模板并完成控件映射 +→ 发布不可变模板版本 +→ 同一事务切换 current_template_version_id 并恢复场景 +``` + +场景暂停后,退款或线下充值创建接口在写业务单前直接拒绝新申请,并返回“审批模板维护中”;已取得 `sp_no` 的历史审批继续接收回调和轮询,不受影响。 + +提交 Worker 调用企微前通过条件更新取得短期 `submit_lease_until`。暂停操作先把场景改为 `暂停中`,此后不再发放新租约;租约全部释放或到期后才显示“已暂停”。这样运营人员看到“已暂停”时,可以确认没有旧模板提交正在进行。 + +租约覆盖附件上传和 `applyevent` 调用并由 Worker 定时续期;处理结束时使用 `defer` 释放,提交结果未知也必须先持久化状态再释放。暂停流程只认可未过期租约已经全部消失,不能仅按任务进程是否存活判断。 + +### 5.4 模板版本表 + +```sql +CREATE TABLE tb_wecom_approval_template_version ( + id BIGSERIAL PRIMARY KEY, + scene_code VARCHAR(64) NOT NULL, + version_no INT NOT NULL, + template_id VARCHAR(128) NOT NULL, + template_name VARCHAR(255) NOT NULL DEFAULT '', + status INT NOT NULL DEFAULT 1, + control_mapping JSONB NOT NULL, + template_snapshot JSONB NOT NULL, + template_fingerprint VARCHAR(64) NOT NULL, + last_verified_at TIMESTAMPTZ, + last_verify_error TEXT, + published_by BIGINT NOT NULL, + published_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + disabled_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (scene_code, version_no) +); + +CREATE UNIQUE INDEX uq_wecom_template_active_scene + ON tb_wecom_approval_template_version (scene_code) + WHERE status = 1; +``` + +`status`:`0=已停用, 1=启用, 2=失效`。 + +发布新版本时,在一个事务内停用旧版本并创建新版本。旧审批实例继续引用旧版本记录,不更新历史快照。 + +### 5.5 控件映射 + +稳定业务字段映射到当前模板控件: + +```json +{ + "shop_name": { + "control": "Text", + "control_id": "Text-xxx", + "required": true + }, + "business_no": { + "control": "Text", + "control_id": "Text-yyy", + "required": true + }, + "amount": { + "control": "Money", + "control_id": "Money-zzz", + "required": true + }, + "attachment": { + "control": "File", + "control_id": "File-aaa", + "required": true + } +} +``` + +退款场景必填业务字段: + +```text +shop_name, refund_no, order_no, asset_identifier, +requested_refund_amount, refund_reason, attachment, submitter +``` + +线下充值场景必填业务字段: + +```text +shop_name, recharge_no, amount, remark, attachment, submitter +``` + +模板发布时必须调用 `gettemplatedetail` 校验: + +- `template_id` 可访问。 +- 映射的每个控件 ID 确实存在。 +- 控件类型与业务字段要求一致。 +- 必填业务字段全部完成映射。 +- 同一个控件不能绑定两个业务字段。 + +### 5.6 模板失效检测 + +后台任务每 10 分钟验证所有启用模板: + +1. 调用 `gettemplatedetail`。 +2. 使用控件 ID、类型和名称生成 SHA-256 fingerprint。 +3. 模板不可访问或 fingerprint 变化时,将版本标记为 `status=2`。 +4. 模板失效时同时暂停对应场景,禁止创建和提交新审批,但不影响已提交审批的回调和状态查询。 +5. 发送站内系统告警,提示管理员发布新的模板映射版本。 + +该检测只能发现“当前模板 ID 已失效或内容发生变化”,不能自动发现企微生成的新模板 ID,所以不能替代上述暂停和发布流程。 + +提交审批前,如果 `last_verified_at` 超过验证间隔,Worker 先同步验证一次。验证失败不调用 `applyevent`。 + +## 六、内部账号与企微成员绑定 + +创建人必须有明确的企微 `userid`,不使用固定手机号冒充所有申请人,也不要求运营人员进入企微后台查找成员 ID。 + +### 6.1 绑定流程 + +```mermaid +sequenceDiagram + actor User as 已登录平台员工 + participant FE as 管理后台 + participant API as WeComIdentity Application + participant Redis as Redis绑定会话 + participant Login as 企业微信Web登录 + participant WeCom as 企业微信API + participant DB as PostgreSQL + + User->>FE: 点击绑定企业微信 + FE->>API: 创建绑定会话 + API->>Redis: 保存一次性state,TTL 5分钟 + API-->>FE: 返回企微登录URL和session_id + FE->>Login: 新窗口打开企微二维码 + User->>Login: 使用企业微信扫码确认 + Login->>API: 回调code + state + API->>Redis: 原子消费state + API->>WeCom: auth/getuserinfo(code) + WeCom-->>API: userid + API->>DB: 校验唯一性并保存绑定 + API-->>FE: postMessage通知绑定结果 +``` + +后端构造官方 Web 登录地址: + +```text +https://login.work.weixin.qq.com/wwlogin/sso/login + ?login_type=CorpApp + &appid={CorpID} + &agentid={AgentID} + &redirect_uri={URLEncode后的回调地址} + &state={一次性随机值} +``` + +回调取得的 `code` 只能使用一次且 5 分钟过期。后端使用同一自建应用的 Access Token 调用: + +```http +GET https://qyapi.weixin.qq.com/cgi-bin/auth/getuserinfo + ?access_token={access_token} + &code={code} +``` + +返回 `userid` 才允许绑定;返回 `openid` 表示不是本企业成员,直接拒绝。企微身份接口不返回 `corp_id`,企业归属由登录 URL 中的 `CorpID` 和用于换取身份的自建应用 Access Token 共同限定。 + +第一版使用官方登录页面新窗口,不引入前端 SDK。回调成功页使用固定后台 Origin 的 `window.opener.postMessage` 通知原页面并关闭窗口;窗口通信失败时,原页面通过 `session_id` 轮询会话状态兜底。 + +### 6.2 绑定会话 + +绑定会话只存 Redis,不建长期数据库表。`state` 与前端查询会话分开保存,避免回调消费 `state` 后无法查询结果: + +```text +key: RedisWecomAccountBindingStateKey(state) +ttl: 5分钟 +value: + session_id + +key: RedisWecomAccountBindingSessionKey(session_id) +ttl: 10分钟 +value: + target_account_id + initiator_account_id + allowed_origin + status + error_code + created_at +``` + +- 只有已登录且启用的超级管理员、平台用户可以为自己创建绑定会话。 +- `state` 使用密码学安全随机数,回调时通过 Lua 原子读取并删除;后续成功或失败结果写入独立的 `session_id` 会话。 +- 回调不接受前端传入的 `account_id`,绑定目标只能来自服务端会话。 +- 同一账号再次创建会话时,旧会话立即失效。 +- `allowed_origin` 从服务端后台域名配置生成,不能接受请求参数覆盖。 +- `session_id` 仅用于当前登录用户查询结果,不能作为绑定凭证。 + +### 6.3 绑定数据 + +```sql +CREATE TABLE tb_account_wecom_mapping ( + id BIGSERIAL PRIMARY KEY, + account_id BIGINT NOT NULL UNIQUE, + wecom_userid VARCHAR(128) NOT NULL UNIQUE, + display_name VARCHAR(255) NOT NULL DEFAULT '', + corp_id VARCHAR(64) NOT NULL, + agent_id BIGINT NOT NULL, + bind_source VARCHAR(32) NOT NULL DEFAULT 'self_scan', + bound_by BIGINT NOT NULL, + status INT NOT NULL DEFAULT 1, + verified_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +``` + +- 平台员工创建退款或线下充值时必须存在启用绑定,否则拒绝创建并返回可直接拉起绑定窗口的错误状态。 +- `account_id` 与 `wecom_userid` 都是一对一唯一;企微成员已绑定其他账号时拒绝覆盖,必须先由超级管理员解除旧绑定。 +- 重新绑定同一账号必须再次扫码,成功后在事务内替换旧身份并记录前后值审计。 +- 自助解绑不影响历史审批;历史实例继续使用提交时保存的 `creator_wecom_userid` 快照,新审批在重新绑定前禁止创建。 +- `display_name` 通过读取成员接口获取;权限不足时允许为空,但不影响以 `userid` 发起审批。 +- 企微审批详情中的审批人 `userid` 原样保存为快照;能匹配本地账号时额外返回本地账号 ID。 +- 用户离职或映射失效不修改历史审批人快照。 + +普通运营界面不提供手工录入 `userid`。超级管理员仅能查看、强制解绑并要求员工重新扫码,不提供直接改写成员 ID 的入口。 + +## 七、审批实例 + +```sql +CREATE TABLE tb_wecom_approval_instance ( + id BIGSERIAL PRIMARY KEY, + biz_type VARCHAR(64) NOT NULL, + biz_id BIGINT NOT NULL, + biz_no VARCHAR(64) NOT NULL DEFAULT '', + round_no INT NOT NULL DEFAULT 1, + scene_code VARCHAR(64) NOT NULL, + template_version_id BIGINT NOT NULL, + template_id_snapshot VARCHAR(128) NOT NULL, + control_mapping_snapshot JSONB NOT NULL, + creator_account_id BIGINT NOT NULL, + creator_wecom_userid VARCHAR(128) NOT NULL, + sp_no VARCHAR(128), + status INT NOT NULL DEFAULT 0, + submitted_snapshot JSONB NOT NULL, + approval_detail_snapshot JSONB, + approver_snapshot JSONB, + apply_error TEXT, + submit_lease_until TIMESTAMPTZ, + callback_at TIMESTAMPTZ, + last_polled_at TIMESTAMPTZ, + status_changed_at TIMESTAMPTZ, + business_processed_at TIMESTAMPTZ, + business_process_result VARCHAR(20), + business_process_error TEXT, + version BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (biz_type, biz_id, round_no) +); + +CREATE UNIQUE INDEX uq_wecom_approval_sp_no + ON tb_wecom_approval_instance (sp_no) + WHERE sp_no IS NOT NULL; +``` + +内部状态: + +| status | 含义 | 对应企微状态 | +|---:|---|---| +| 0 | 提交中 | 尚未取得 sp_no | +| 1 | 审批中 | `sp_status=1` | +| 2 | 已通过 | `sp_status=2` | +| 3 | 已驳回 | `sp_status=3` | +| 4 | 已撤销 | `sp_status=4` | +| 5 | 通过后撤销 | `sp_status=6` | +| 6 | 已删除 | `sp_status=7` | +| 7 | 提交失败 | 明确未创建审批 | +| 8 | 提交结果未知 | 请求超时,无法确认是否创建 | + +`submitted_snapshot` 保存提交时的业务展示数据和本地附件 Key,不保存临时 `media_id`、签名 URL 或敏感密钥。 + +## 八、DDD 设计 + +```text +internal/ +├── domain/wecomapproval/ +│ ├── approval.go 审批状态机和终态幂等规则 +│ ├── scene.go 场景暂停、启用和版本切换不变量 +│ ├── template.go 不可变模板版本和控件映射 +│ ├── events.go 审批通过/驳回/异常事件 +│ └── repository.go +├── application/wecomapproval/ +│ ├── change_scene_status.go +│ ├── publish_template_version.go +│ ├── submit_approval.go +│ ├── sync_approval_status.go +│ ├── handle_callback.go +│ ├── poll_pending.go +│ └── resubmit_approval.go +├── domain/wecomidentity/ +│ ├── binding.go 账号与企微成员一对一绑定规则 +│ └── repository.go +├── application/wecomidentity/ +│ ├── create_binding_session.go +│ ├── complete_binding.go +│ ├── get_my_binding.go +│ └── unbind_account.go +├── domain/refund/ +│ ├── refund.go 退款金额和状态不变量 +│ └── events.go +├── application/refund/ +│ ├── create_refund.go +│ ├── complete_wecom_approved.go +│ └── reject_from_wecom.go +├── application/recharge/ +│ ├── create_offline_recharge.go +│ ├── complete_wecom_approved.go +│ └── reject_from_wecom.go +└── infrastructure/adapter/wecom/ + ├── client.go + ├── token_provider.go + ├── web_login.go + ├── identity.go + ├── approval.go + ├── media.go + └── callback_crypto.go +``` + +退款和充值的旧 Service 不再作为审批业务入口。列表查询进入 `internal/query/refund`、`internal/query/recharge` 和 `internal/query/wecomapproval`。 + +## 九、审批提交 + +### 9.1 为什么异步提交 + +本地业务单与企微远程审批无法放在同一个数据库事务中,因此采用本地事务 + Outbox: + +```text +业务单创建成功 +审批实例 status=0 +Outbox: WeComApprovalSubmissionRequested +``` + +Worker 处理: + +1. 仅在场景启用且租约为空或已过期时,通过条件更新取得提交租约。 +2. 加载不可变模板版本和提交快照。 +3. 确认实例引用的模板版本有效且最近验证成功。 +4. 从对象存储下载本地附件到临时文件。 +5. 逐个调用 `media/upload` 获取临时 `media_id`。 +6. 按控件映射构建 `apply_data`。 +7. 使用模板审批人配置,固定 `use_template_approver=1`。 +8. 调用 `applyevent`。 +9. 保存 `sp_no`,状态变为审批中并释放租约。 + +附件的本地对象存储 Key 是权威记录;企微 `media_id` 只是提交期间使用的临时值。 + +### 9.2 提交结果未知 + +`applyevent` 网络超时后可能已经在企微创建审批,因此不能无脑重试,否则可能产生重复审批: + +- 收到明确 `errcode != 0`:状态改为提交失败,可修正配置后重新提交。 +- 建连失败且确认请求未发送:允许自动重试。 +- 请求已发送但响应超时/连接中断:状态改为提交结果未知,不自动再次调用 `applyevent`。 +- 提交结果未知进入异常页面,由管理员在企微核对后选择“确认未创建并重新提交”或“绑定已有 sp_no”。 + +## 十、回调和轮询 + +### 10.1 回调路由 + +```http +GET /api/callback/wecom/approval +POST /api/callback/wecom/approval +``` + +- GET 按企微协议完成 URL 校验。 +- POST 使用 SHA1 签名校验和 AES-CBC 解密。 +- 解密后的 `receiveID` 必须等于配置的 CorpID。 +- 回调原始密文、解密结果摘要和处理状态写 Integration Log;不写文件系统。 +- 回调快速保存事件并返回 `success`,实际状态以 `getapprovaldetail` 为准。 + +### 10.2 轮询补偿 + +每 2 分钟查询: + +```sql +status = 1 +AND (last_polled_at IS NULL OR last_polled_at <= NOW() - INTERVAL '2 minutes') +``` + +单批限制 100 条,按 `last_polled_at` 排序。回调和轮询都调用 `SyncApprovalStatus`。 + +### 10.3 状态同步幂等 + +```text +1. 根据 sp_no 加载审批实例 +2. 调 getapprovaldetail +3. 保存审批详情和审批人快照 +4. 使用 version 乐观锁更新状态 +5. 状态没有变化时结束 +6. 首次进入终态时在同一事务写对应业务终态 Outbox 事件 +7. 业务 Worker 调用对应终态用例,失败按错误类型重试 +8. business_processed_at 非空时不得重复执行资金动作 +``` + +## 十一、业务状态处理 + +### 11.1 退款 + +创建退款时: + +- `requested_refund_amount` 已完成合法性校验并固定。 +- `approved_refund_amount` 不再由审批接口输入;企微通过时写为申请金额。 +- 退款凭证必须先保存本地对象存储,再上传企微副本。 +- 财务在系统外完成人工退款并通过企微审批确认结果;本系统不请求任何支付渠道退款 API。 + +退款状态在现有枚举基础上追加: + +| status | 含义 | +|---:|---| +| 1 | 待审批 | +| 2 | 已通过/人工退款已确认 | +| 3 | 已拒绝 | +| 4 | 已退回,仅保留历史兼容,新企微审批不再产生 | +| 5 | 已撤销/审批已删除 | + +不得将历史 `4=已退回` 改写为撤销,避免旧数据语义变化。 + +企微状态处理: + +| 企微状态 | 本地退款动作 | +|---|---| +| 通过 | 记录人工退款完成;代理钱包支付订单回溯原扣款代理主钱包;更新订单状态,随后异步佣金回扣和资产处理 | +| 驳回 | 退款状态改为已拒绝,保存审批详情摘要 | +| 撤销/删除 | 退款状态改为已撤销;允许申请人修改后创建下一轮审批 | +| 通过后撤销 | 已退款则不冲正,记录严重异常并通知财务;尚未执行则阻止退款 | + +原 `POST /api/admin/refunds/{id}/approve`、`reject`、`return` 下线。 + +重新申请使用: + +```http +POST /api/admin/refunds/{id}/resubmit +``` + +它只允许已驳回、已撤销或已删除且业务尚未退款的申请,修改业务资料后创建 `round_no + 1` 的企微审批。 + +审批通过后的业务 Worker 处理失败时,审批实例保持“已通过”,`business_process_result=failed`,由任务重试;前端明确区分“审批已通过”和“退款终态处理失败”。只有退款终态事务成功后才写 `business_processed_at`。 + +现有 `internal/service/refund/service.go` 中 `BuyerTypePersonal -> refundAssetWalletPayment` 与本次确认规则冲突,迁移退款用例时删除该分支及其专用退款回款逻辑。个人客户或资产钱包相关订单只记录人工退款结果,不自动增加资产钱包余额;代理钱包支付订单继续按原扣款流水定位并回溯原代理主钱包。 + +### 11.2 平台员工线下充值 + +创建线下充值后立即创建企微审批实例,不再暴露本地“确认入账”和“驳回”按钮。 + +| 企微状态 | 本地充值动作 | +|---|---| +| 通过 | 无操作密码,自动增加代理主钱包余额并写钱包流水 | +| 驳回 | 状态改为已驳回,保存企微审批摘要 | +| 撤销/删除 | 状态改为已关闭,可重新创建充值申请 | +| 通过后撤销 | 已入账不自动扣回,记录严重异常并通知财务 | + +原 `POST /api/admin/agent-recharges/{id}/offline-pay` 和 `reject` 下线。 + +线上微信/支付宝充值不进入企微审批,保持支付回调流程。 + +审批通过后的入账任务失败时按相同规则重试;只有钱包余额和流水事务成功后才写 `business_processed_at`。若审批先变为通过后撤销且入账任务尚未成功,后续入账任务检测当前状态后终止,不再加钱。 + +## 十二、API 设计 + +### 12.1 基础配置状态 + +```http +GET /api/admin/wecom/config/status +``` + +返回是否配置、Token 最近获取时间、回调最近成功时间、最近错误,不返回任何密钥。 + +### 12.2 审批场景状态 + +```http +GET /api/admin/wecom/approval-scenes +PUT /api/admin/wecom/approval-scenes/{scene_code}/status +``` + +```json +{ + "status": 0, + "reason": "企微模板编辑中" +} +``` + +暂停和恢复必须写高等级审计。请求暂停后可能先返回 `status=2`,前端轮询场景列表,只有进入 `status=0` 才允许提示运营人员开始编辑企微模板。已有启用版本的场景只允许在暂停状态发布新版本;首次初始化没有当前版本时可直接发布。 + +### 12.3 读取企微模板 + +```http +POST /api/admin/wecom/approval-templates/inspect +``` + +```json +{ + "scene_code": "refund_approval", + "template_id": "企微模板ID" +} +``` + +返回模板名称和可映射控件列表。 + +### 12.4 发布模板映射 + +```http +POST /api/admin/wecom/approval-templates/publish +``` + +```json +{ + "scene_code": "refund_approval", + "template_id": "企微模板ID", + "control_mapping": { + "shop_name": "Text-xxx", + "refund_no": "Text-yyy", + "requested_refund_amount": "Money-zzz", + "attachment": "File-aaa" + } +} +``` + +后端重新读取模板并校验,不能信任前端提交的控件类型和名称。发布成功后在同一事务内停用旧版本、写入新版本、把尚未取得 `sp_no` 且仍为 `status=0` 的实例改绑到新版本、更新场景当前版本并恢复场景;任一步失败都保持暂停,不允许部分切换。 + +### 12.5 模板版本列表 + +```http +GET /api/admin/wecom/approval-templates?scene_code=refund_approval +``` + +### 12.6 账号绑定 + +```http +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/admin/wecom/account-bindings?page=1&page_size=20&binding_status= +DELETE /api/admin/wecom/account-bindings/{account_id} + +GET /api/callback/wecom/account-binding?code={code}&state={state} +``` + +创建会话返回: + +```json +{ + "session_id": "01J...", + "login_url": "https://login.work.weixin.qq.com/wwlogin/sso/login?...", + "expires_at": "2026-07-15T15:05:00+08:00" +} +``` + +列表接口不返回 Secret 或完整企微配置。普通平台用户只能查询和解绑自己的绑定;绑定列表和强制解绑仅超级管理员可用,强制解绑必须记录高等级审计。 + +### 12.7 审批记录 + +```http +GET /api/admin/wecom/approvals + ?biz_type=refund + &status=1 + &sp_no= + &biz_no= + &page=1&page_size=20 + +GET /api/admin/wecom/approvals/{id} +POST /api/admin/wecom/approvals/{id}/sync +POST /api/admin/wecom/approvals/{id}/bind-sp-no +``` + +`bind-sp-no` 仅用于提交结果未知的人工恢复,必须写高等级审计日志。 + +### 12.8 业务详情响应 + +退款和充值详情统一增加: + +```json +{ + "approval": { + "source": "wecom", + "instance_id": 123, + "sp_no": "202607150001", + "status": 2, + "status_name": "已通过", + "template_name": "退款审批", + "round_no": 1, + "creator_name": "张三", + "approvers": [], + "submitted_at": "2026-07-15T10:00:00+08:00", + "status_changed_at": "2026-07-15T10:05:00+08:00", + "business_process_result": "success" + } +} +``` + +## 十三、前端方案 + +### 13.1 审批入口变化 + +- 删除本系统待审批任务、审批节点配置、审批人配置和审批动作页面。 +- 退款和线下充值列表不再显示“通过、驳回、退回、确认入账”按钮。 +- 创建成功后展示“正在提交企业微信审批”;取得 `sp_no` 后展示“企业微信审批中”。 +- 审批操作全部在企业微信完成。 + +### 13.2 业务详情 + +退款和充值详情增加只读“企业微信审批”区块: + +- 审批单号。 +- 当前状态和状态更新时间。 +- 模板名称和模板版本。 +- 申请人。 +- 审批人、审批结果、意见和时间线。 +- 业务处理结果。 +- “立即同步”图标按钮,仅触发 `getapprovaldetail`,不提供审批按钮。 + +通过后撤销且业务已执行时使用红色异常状态条,明确显示“资金动作未自动冲正,需人工处理”。 + +### 13.3 企微配置页 + +路由:`/system/wecom` + +Tab: + +1. **连接状态**:只显示配置状态和最近连通结果。 +2. **审批模板**:按业务场景展示当前版本、模板 ID、验证状态和历史版本。 +3. **账号绑定**:查看平台员工绑定状态、企微名称和最近验证时间;不允许编辑 userid。 +4. **异常审批**:提交未知、模板失效、终态业务处理失败、通过后撤销。 + +模板发布交互: + +```text +暂停业务场景并等待状态变为“已暂停” +→ 在企微完成模板编辑并取得新 template_id +→ 选择业务场景 +→ 输入新 template_id +→ 点击“读取模板” +→ 左侧显示业务字段,右侧下拉选择企微控件 +→ 后端校验 +→ 发布新版本并自动恢复场景 +``` + +不允许运营人员直接编辑 `control_mapping` JSON。 +场景处于暂停状态时必须在退款和线下充值创建页明确显示维护原因,不能等异步提交后才暴露错误。 + +账号绑定交互: + +- 当前用户个人中心显示“未绑定/已绑定/已失效”、企微成员名称和最近验证时间。 +- 点击“绑定企业微信”后打开官方企微二维码窗口,原页面显示 5 分钟倒计时。 +- 绑定成功后窗口自动关闭,原页面刷新绑定状态;失败时显示企微返回的可操作原因。 +- 创建退款或线下充值时发现未绑定,页面原地展示“绑定企业微信”按钮,绑定成功后继续填写,不要求用户先去配置页。 +- 管理员的账号绑定列表只提供筛选、查看和强制解绑;不提供 userid 输入框。 + +### 13.4 审批运行页 + +列表路由:`/operations/wecom-approvals` + +详情路由:`/operations/wecom-approvals/{id}`,复用同一页面并打开详情抽屉,供业务详情和站内通知稳定跳转。 + +页面用于运行监控,不提供审批动作: + +- 按业务类型、企微状态、提交状态和业务处理结果筛选。 +- 查询 `sp_no`、业务单号、申请人和模板版本。 +- 查看企微审批详情快照和本地业务处理结果。 +- 对审批中记录执行“立即同步”。 +- 对提交结果未知记录执行“绑定已有 sp_no”或“确认未创建并重新提交”。 +- 对业务处理失败记录查看错误和任务重试状态。 + +## 十四、Token、日志和限流 + +- Access Token 缓存在 Redis,TTL 使用 `expires_in - 300秒`。 +- 使用 Redis 锁避免多个进程同时刷新 Token。 +- `auth/getuserinfo`、读取成员、`gettemplatedetail`、`applyevent`、`getapprovaldetail` 和 `media/upload` 分别记录接口名称、耗时、errcode、errmsg 和 request_id。 +- 日志禁止记录 access_token、Secret、EncodingAESKey、解密密钥和完整附件内容。 +- 绑定、换绑、自助解绑、管理员强制解绑写统一审计日志;Access Token 获取和企微身份查询写 Integration Log。 +- `applyevent` 不做网络层盲目重试;详情查询允许指数退避重试。 +- 审批轮询与回调同步共享企微 API 并发限制。 + +## 十五、存量数据和发布 + +停机发布: + +1. 在企微自建应用配置 Web 登录可信域名和平台员工可见范围。 +2. 创建企微模板版本、账号绑定和审批实例表。 +3. 发布企微身份 Adapter、账号绑定回调、审批回调、轮询 Worker 和业务终态用例。 +4. 要求会发起审批的平台员工完成扫码绑定。 +5. 发布并验证退款、线下充值两个模板映射版本。 +6. 下线本地审批动作路由和前端按钮。 +7. 存量已通过/已拒绝记录保留原业务审批快照,展示 `approval.source=legacy`。 +8. 存量待审批退款和线下充值仅在创建人已绑定企微时提交;未绑定记录进入迁移待处理列表。 +9. 提交失败的存量记录进入异常审批页面,不允许继续走旧接口处理。 + +## 十六、人工验证 + +1. 场景进入暂停中后,新退款或充值申请和新提交租约被阻止;进入已暂停时没有旧模板提交仍在执行,历史审批仍可正常同步。 +2. 编辑企微模板导致 template_id 变化后,旧模板版本仍可展示,发布新映射时原子切换并恢复场景。 +3. 删除再新增控件后,旧 control ID 不会被继续使用。 +4. 退款审批通过后按申请金额确认人工退款结果,重复回调和轮询不会重复处理退款终态。 +5. 线下充值审批通过后无需操作密码自动入账,重复终态不会重复加钱。 +6. 驳回、撤销、删除状态正确同步。 +7. 通过后撤销不自动冲正,异常页面、站内消息和审计记录完整。 +8. 回调失效时,2 分钟轮询可以推进终态。 +9. 回调和轮询同时到达时,只有一个处理器执行业务动作。 +10. 附件始终保留本地对象存储 Key,企微 media_id 失效不影响历史资料下载。 +11. Secret、Token、EncodingAESKey 和附件内容不出现在 API、日志和审计详情中。 +12. 已登录平台员工扫码后自动获得企微 userid,不需要人工查询或录入成员 ID。 +13. 绑定 `state` 过期、重复回调、非企业成员扫码时均不会产生绑定。 +14. 同一企微 userid 尝试绑定第二个系统账号时被拒绝,原绑定不被覆盖。 +15. 自助解绑和管理员强制解绑不影响历史审批快照,但会阻止该账号发起新审批。 diff --git a/docs/7月迭代/新增需求/03-站内通知详细方案.md b/docs/7月迭代/新增需求/03-站内通知详细方案.md new file mode 100644 index 0000000..9ee90e0 --- /dev/null +++ b/docs/7月迭代/新增需求/03-站内通知详细方案.md @@ -0,0 +1,457 @@ +# 新增需求 03:站内通知详细方案 + +> 状态:已合并至标准评审稿,本文保留为实施明细。 +> 评审主文档:`../7月迭代技术方案-标准评审稿.md` +> 范围:后台账号、代理账号、企业账号和个人客户的站内通知。 + +## 一、定位 + +站内通知只负责“用户登录本系统后可以看到的消息”,不承担企业微信审批流程,也不直接发送企业微信消息。 + +第一版消息来源: + +| 来源 | 接收人 | 示例 | +|---|---|---| +| 企微审批结果 | 业务申请人、相关平台角色 | 退款审批通过、线下充值审批驳回 | +| 套餐临期 | 代理账号、企业业务员、个人客户 | 套餐剩余 15/7/3 天 | +| 数据同步异常 | 平台运维角色 | 模板失效、回调找不到资产、连续同步失败 | +| 系统配置异常 | 平台超管 | 企微配置不可用、支付配置失效 | + +企微审批中的“待我审批”由企业微信自身提醒,不在本系统重复创建审批待办。 + +## 二、设计原则 + +1. 业务事务不直接写 `tb_notification`,关键事件通过 Outbox 可靠投递。 +2. 一条事件向多个接收人发送时,每个接收人保存一条独立通知。 +3. 使用 `event_id + recipient_kind + recipient_id` 保证重复消费不生成重复消息。 +4. 通知正文是发送时快照,使用受控纯文本模板,不保存任意 HTML。 +5. 通知只能跳转到受控业务类型,前端不接受后端返回任意 URL。 +6. 通知已读状态只属于当前接收人,任何查询和更新都必须带接收人条件。 +7. 过期通知不进入列表和未读数,但历史数据按保留策略清理,不由用户删除。 +8. 站内通知是简单写模型,使用 Application + Query + Infrastructure,不为形式强行创建领域聚合。 +9. 后续新增短信、企微消息等外部渠道时使用独立 Delivery,不在站内通知 Handler 中追加外部调用。 + +## 三、流程 + +```mermaid +sequenceDiagram + participant Biz as 业务 Application + participant DB as 业务数据库 + participant Outbox as Outbox + participant Relay as Outbox Relay + participant Worker as Notification Worker + participant Notice as tb_notification + participant Web as 前端 + + Biz->>DB: 提交业务状态 + Biz->>Outbox: 同事务写通知事件 + Relay->>Worker: 至少一次投递 + Worker->>Worker: 解析接收人和受控模板 + Worker->>Notice: 幂等批量插入 + Web->>Notice: 查询未读数/列表 + Web->>Notice: 当前接收人标记已读 +``` + +非关键系统告警允许直接入 Asynq,但必须携带稳定 `event_id`;不能在重试时重新生成。 + +## 四、数据模型 + +```sql +CREATE TABLE tb_notification ( + id BIGSERIAL PRIMARY KEY, + event_id VARCHAR(64) NOT NULL, + recipient_kind VARCHAR(32) NOT NULL, + recipient_id BIGINT NOT NULL, + category VARCHAR(32) NOT NULL, + type VARCHAR(64) NOT NULL, + severity VARCHAR(16) NOT NULL DEFAULT 'info', + title VARCHAR(200) NOT NULL, + body TEXT NOT NULL DEFAULT '', + ref_type VARCHAR(64), + ref_id BIGINT, + ref_key VARCHAR(128), + is_read BOOLEAN NOT NULL DEFAULT FALSE, + read_at TIMESTAMPTZ, + expires_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (event_id, recipient_kind, recipient_id) +); + +CREATE INDEX idx_notification_recipient_unread + ON tb_notification (recipient_kind, recipient_id, is_read, created_at DESC); + +CREATE INDEX idx_notification_recipient_category + ON tb_notification (recipient_kind, recipient_id, category, created_at DESC); + +CREATE INDEX idx_notification_expired + ON tb_notification (expires_at) + WHERE expires_at IS NOT NULL; +``` + +字段说明: + +| 字段 | 说明 | +|---|---| +| `recipient_kind` | `account` 或 `personal_customer` | +| `category` | `approval / expiry / sync / system` | +| `type` | 具体通知类型常量 | +| `severity` | `info / warning / error / critical` | +| `ref_type` | 受控业务引用类型 | +| `ref_id` | 业务主键,可为空 | +| `ref_key` | 业务编号或资产标识快照,用于列表展示和资源删除后的追溯 | + +不在通知表保存接收人名称、店铺名称等实时关联字段;正文已经是发送时快照,查询时也不需要联表才能展示。 + +## 五、通知类型 + +```go +const ( + NotifyTypeWeComApprovalApproved = "wecom.approval.approved" + NotifyTypeWeComApprovalRejected = "wecom.approval.rejected" + NotifyTypeWeComApprovalCancelled = "wecom.approval.cancelled" + NotifyTypeWeComApprovalRevoked = "wecom.approval.revoked_after_approved" + NotifyTypeWeComTemplateInvalid = "wecom.template.invalid" + NotifyTypePackageExpiring = "package.expiring" + NotifyTypeCardSyncFailed = "card_sync.failed" + NotifyTypeCarrierCallbackCardNotFound = "carrier_callback.card_not_found" + NotifyTypeSystemAlert = "system.alert" +) +``` + +类别映射由后端常量维护,前端只按返回值展示,不自行猜测: + +```go +func NotificationCategory(notifyType string) string +``` + +## 六、发布命令 + +```go +type Recipient struct { + Kind string + ID uint +} + +type PublishNotificationCommand struct { + EventID string + Recipients []Recipient + Type string + Severity string + TemplateData map[string]any + RefType string + RefID uint + RefKey string + ExpiresAt *time.Time +} +``` + +Application 只提交通知类型和受控模板数据,不直接提交最终 HTML。Notification Worker 通过代码内模板注册表渲染: + +```go +type TemplateRenderer func(data map[string]any) (title string, body string, err error) + +var notificationTemplates = map[string]TemplateRenderer{ + constants.NotifyTypeWeComApprovalApproved: renderWeComApprovalApproved, + constants.NotifyTypePackageExpiring: renderPackageExpiring, +} +``` + +模板字段不足时任务失败并重试,禁止生成标题为空或只有业务编号的残缺通知。 + +## 七、接收人解析 + +业务 Application 在发布事件前确定稳定业务接收人: + +| 场景 | 接收人来源 | +|---|---| +| 退款/充值审批结果 | 申请人账号 ID | +| 通过后撤销 | 申请人 + 财务角色当前启用账号 | +| 企微模板失效 | 平台超管角色账号 | +| 数据同步连续失败 | 平台运维角色账号 | +| 代理套餐临期 | 资产所属代理及配置的业务员账号 | +| 企业套餐临期 | 企业关联业务员账号 | +| 个人客户套餐临期 | 资产当前绑定的个人客户 ID | + +角色接收人应在事件消费时批量解析当前启用账号,具体用户接收人直接使用事件快照中的 ID。 + +不存在接收人时记录消费结果 `no_recipient`,不能无限重试。 + +## 八、API + +后台账号、代理账号和企业账号统一使用 `/api/admin`,后端从登录上下文取得当前账号 ID。 + +### 8.1 未读总数 + +```http +GET /api/admin/notifications/unread-count +GET /api/c/v1/notifications/unread-count +``` + +响应: + +```json +{ + "code": 0, + "data": { + "count": 12, + "display_count": "12" + } +} +``` + +超过 99 时 `display_count` 返回 `99+`,前端也必须兼容直接按 count 计算。 + +查询条件: + +```sql +recipient_kind = ? +AND recipient_id = ? +AND is_read = FALSE +AND (expires_at IS NULL OR expires_at > NOW()) +``` + +第一版直接查询 PostgreSQL,不额外维护 Redis 未读计数,避免数据库与缓存双写不一致。 + +### 8.2 分类未读数 + +```http +GET /api/admin/notifications/unread-summary +``` + +```json +{ + "code": 0, + "data": { + "total": 12, + "categories": { + "approval": 3, + "expiry": 5, + "sync": 2, + "system": 2 + } + } +} +``` + +个人客户第一版只返回总数,不开放运维类分类。 + +### 8.3 通知列表 + +```http +GET /api/admin/notifications + ?category=approval + &type= + &severity= + &is_read=false + &page=1&page_size=20 + +GET /api/c/v1/notifications?page=1&page_size=20 +``` + +响应项: + +```json +{ + "id": 1001, + "category": "approval", + "type": "wecom.approval.approved", + "severity": "info", + "title": "退款审批已通过", + "body": "退款单 RF202607150001 已通过企业微信审批。", + "ref_type": "refund", + "ref_id": 42, + "ref_key": "RF202607150001", + "is_read": false, + "read_at": null, + "created_at": "2026-07-15T10:00:00+08:00" +} +``` + +列表固定按 `created_at DESC, id DESC` 排序并服务端分页,最大 `page_size=50`。 + +### 8.4 单条标记已读 + +```http +PUT /api/admin/notifications/{id}/read +PUT /api/c/v1/notifications/{id}/read +``` + +条件更新: + +```sql +UPDATE tb_notification +SET is_read = TRUE, read_at = NOW() +WHERE id = ? + AND recipient_kind = ? + AND recipient_id = ? + AND is_read = FALSE; +``` + +记录不存在、无权访问或已经已读时统一返回成功,保持幂等且不泄露其他用户通知是否存在。 + +### 8.5 批量标记已读 + +```http +PUT /api/admin/notifications/read-all +PUT /api/c/v1/notifications/read-all +``` + +```json +{ + "category": "approval" +} +``` + +`category` 为空表示当前接收人的全部未过期通知。接口返回本次实际更新数量。 + +### 8.6 点击并读取跳转信息 + +前端通常可直接使用列表中的 `ref_type/ref_id`。为了处理业务被删除、权限变化等情况,允许: + +```http +GET /api/admin/notifications/{id}/target +``` + +后端先校验通知属于当前用户,再返回受控目标: + +```json +{ + "target_type": "refund_detail", + "target_id": 42, + "available": true +} +``` + +该接口不返回任意 URL。 + +## 九、受控跳转 + +| ref_type | target_type | 后台页面 | +|---|---|---| +| `refund` | `refund_detail` | `/refunds/{id}` | +| `agent_recharge` | `agent_recharge_detail` | `/agent-recharges/{id}` | +| `wecom_approval` | `wecom_approval_detail` | `/operations/wecom-approvals/{id}` | +| `iot_card` | `iot_card_detail` | `/iot-cards/{id}` | +| `device` | `device_detail` | `/devices/{id}` | +| `card_sync` | `card_sync_execution` | `/operations/card-sync?execution_id={id}` | +| `system_config` | `system_config` | `/system/config` | + +前端维护 `target_type -> route` 映射。未知类型只展示通知,不执行跳转。 + +跳转目标页面仍按原业务权限重新校验,拥有通知不等于永久拥有业务数据访问权。 + +## 十、前端方案 + +### 10.1 顶部铃铛 + +```text +登录成功/布局挂载 +→ 立即请求 unread-count +→ 每 30 秒轮询 +→ 页面不可见时暂停 +→ 恢复可见时立即刷新 +``` + +- 未读为 0 时不显示数字。 +- 1~99 显示实际数字,超过 99 显示 `99+`。 +- 请求失败保留上一次数字,不闪回 0。 +- 铃铛区域固定宽度,数字变化不得造成导航布局位移。 + +### 10.2 通知抽屉 + +点击铃铛打开右侧抽屉: + +- 顶部显示“全部、审批、临期、系统”分段控件。 +- 默认加载最近 10 条,提供“查看全部消息”。 +- 未读消息使用左侧状态点,不用整行高饱和背景。 +- 点击消息先本地进入已读视觉状态,再并行调用标记已读和目标解析。 +- 标记已读失败时,下次未读轮询恢复服务端真实状态。 + +### 10.3 通知中心 + +后台路由:`/notifications` + +页面结构: + +```text +分类 Tab + 已读状态筛选 + 严重级别筛选 +通知时间线列表 +全部已读按钮 +服务端分页 +``` + +错误和严重通知显示级别图标;普通通知不使用警告色。 + +个人客户使用简化通知列表,只显示套餐临期、订单和资产相关消息,不显示平台运维消息。 + +## 十一、权限与数据安全 + +- Notification Query 从登录 Context 固定注入接收人,不接收前端传入 `recipient_id`。 +- 后台账号只能查看自己的消息,平台超管也不能通过通知接口查看其他人的消息。 +- 运维需要排查通知投递时,使用全局审计/运维查询接口,不复用用户通知列表接口。 +- 正文禁止包含操作密码、支付密钥、身份证完整号码、长期对象存储 URL 和完整企微回调正文。 +- 附件只提示“包含附件”,用户进入有权限的业务详情后获取短期下载 URL。 +- 所有已读接口不区分“通知不存在”和“通知不属于当前用户”。 + +## 十二、保留与清理 + +| 通知类型 | 展示期限 | 数据保留期限 | +|---|---:|---:| +| 套餐临期 | 到期后自动不展示 | 180 天 | +| 审批结果 | 不自动过期 | 365 天 | +| 同步异常 | 30 天 | 180 天 | +| 系统告警 | 由事件指定 | 365 天 | + +每日低峰期使用现有数据清理任务按批次删除超过保留期限的数据。清理不修改业务审计和领域流水。 + +## 十三、外部渠道扩展 + +未来如果需要企微消息或短信,新增独立投递表: + +```text +tb_notification_delivery +notification_event_id +recipient +channel +status +attempts +provider_message_id +last_error +``` + +站内通知写入和外部渠道发送分别消费同一业务事件,互不影响。不能在 `NotificationHandler` 写入站内消息后直接循环调用企微或短信接口,否则某个外部渠道故障会拖慢所有站内消息。 + +## 十四、代码结构 + +```text +internal/ +├── application/notification/ +│ ├── publish_notification.go +│ ├── mark_read.go +│ └── mark_all_read.go +├── query/notification/ +│ ├── list_notifications.go +│ ├── unread_count.go +│ └── resolve_target.go +├── infrastructure/messaging/ +│ └── notification_event_handler.go +├── infrastructure/persistence/ +│ └── notification_repository.go +├── handler/admin/notification.go +└── handler/app/client_notification.go +``` + +通知没有复杂状态机,不创建空洞的 `domain/notification` 聚合。Application 负责写操作和幂等,Query 直接使用 GORM 查询读取模型。 + +## 十五、人工验证 + +1. 同一事件重复消费不会为同一接收人生成重复通知。 +2. 同一事件的两个接收人各自生成一条通知,已读状态互不影响。 +3. 用户无法读取或标记其他用户通知。 +4. 过期通知不进入未读数和列表。 +5. 全部已读只影响当前用户和指定分类。 +6. 业务权限变化后,通知仍可展示,但目标接口重新校验权限。 +7. 前端隐藏页签时暂停轮询,恢复后立即刷新。 +8. 企微审批待办不重复生成站内待办,审批结果可以正常通知申请人。 +9. 外部消息渠道失败不影响站内通知生成。 +10. 通知正文、日志和 API 不包含密钥、操作密码或长期附件 URL。 diff --git a/docs/7月迭代/新增需求/04-全局多视角审计方案.md b/docs/7月迭代/新增需求/04-全局多视角审计方案.md new file mode 100644 index 0000000..e1b728d --- /dev/null +++ b/docs/7月迭代/新增需求/04-全局多视角审计方案.md @@ -0,0 +1,809 @@ +# 新增需求 04:全局多视角审计方案 + +> 状态:已合并至标准评审稿,本文保留为实施明细。 +> 评审主文档:`../7月迭代技术方案-标准评审稿.md` +> 范围:全系统业务写操作、安全操作、外部集成和审计查询前端。 + +## 一、目标 + +审计系统必须能够从不同视角回答: + +```text +谁做了什么? +某个资源经历了什么? +一次请求引发了哪些跨模块变化? +哪些操作失败、被拒绝或存在风险? +外部 Gateway、运营商和企业微信交互发生了什么? +资金变化对应哪个业务动作和审批结果? +``` + +不是把所有日志塞进一张表,而是建立统一审计事件,并通过 Query 组合访问日志、领域流水和外部集成记录。 + +## 二、当前代码问题 + +现有实现已经有账号审计和资产审计,但没有形成全系统能力: + +- `tb_account_operation_log` 和 `tb_asset_operation_log` 字段、操作类型和查询入口不一致。 +- 账号、资产审计通过 goroutine Best Effort 写入,进程退出或数据库短暂失败时会丢失。 +- 部分调用方又在审计服务外增加一层 goroutine,形成双重异步。 +- 退款审批等资金操作没有统一业务审计。 +- 线下充值借用账号操作日志,资源类型和关联关系不准确。 +- 访问日志只脱敏请求体,响应体仍按原文记录,可能泄露 Token、个人信息和敏感配置。 +- 现有审计表只能从单个账号或单个资产查看,无法按请求、关联业务、异常等级和外部交互串联。 + +## 三、四类记录边界 + +| 类型 | 权威性 | 存储 | 用途 | +|---|---|---|---| +| Access Log | 非业务权威 | 文件/日志平台 | HTTP 调试、性能和请求追踪 | +| Audit Event | 业务审计权威 | PostgreSQL | 谁对什么做了什么、结果如何 | +| Domain Ledger | 领域事实权威 | 现有业务表 | 钱包流水、退款单、审批实例、订单状态 | +| Integration Log | 外部交互权威 | PostgreSQL | Gateway、运营商回调、企微 API、回调和同步尝试 | + +规则: + +- Audit Event 不替代钱包流水、退款记录和企微审批详情。 +- Access Log 不作为业务审计依据。 +- 数据同步的轮询、事件阶梯尝试、手动刷新和运营商回调统一进入 `tb_integration_log`,不再新建 `tb_card_sync_execution`。 +- 高频轮询只有在状态变化、人工强制触发、连续失败或高风险异常时生成 Audit Event。 +- Audit Query 可以把四类记录组合成时间线,但必须标明数据来源。 + +## 四、审计事件模型 + +### 4.1 主表 + +```sql +CREATE TABLE tb_audit_event ( + id BIGSERIAL PRIMARY KEY, + event_id VARCHAR(64) NOT NULL UNIQUE, + occurred_at TIMESTAMPTZ NOT NULL, + category VARCHAR(32) NOT NULL, + action VARCHAR(128) NOT NULL, + action_name VARCHAR(255) NOT NULL, + + actor_kind VARCHAR(32) NOT NULL, + actor_id BIGINT, + actor_name VARCHAR(255) NOT NULL DEFAULT '', + actor_user_type INT, + + source VARCHAR(64) NOT NULL, + tenant_type VARCHAR(32), + tenant_id BIGINT, + shop_id BIGINT, + enterprise_id BIGINT, + + result VARCHAR(16) NOT NULL, + risk_level VARCHAR(16) NOT NULL DEFAULT 'normal', + summary TEXT NOT NULL, + error_code VARCHAR(64), + error_message TEXT, + + before_data JSONB, + after_data JSONB, + metadata JSONB, + + request_id VARCHAR(64), + correlation_id VARCHAR(64), + parent_event_id VARCHAR(64), + + ip_address VARCHAR(64), + user_agent TEXT, + request_path VARCHAR(255), + request_method VARCHAR(16), + + content_hash VARCHAR(64) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_audit_event_time + ON tb_audit_event (occurred_at DESC, id DESC); +CREATE INDEX idx_audit_event_actor + ON tb_audit_event (actor_kind, actor_id, occurred_at DESC); +CREATE INDEX idx_audit_event_action + ON tb_audit_event (category, action, occurred_at DESC); +CREATE INDEX idx_audit_event_result + ON tb_audit_event (risk_level, result, occurred_at DESC); +CREATE INDEX idx_audit_event_request + ON tb_audit_event (request_id, occurred_at ASC); +CREATE INDEX idx_audit_event_correlation + ON tb_audit_event (correlation_id, occurred_at ASC); +``` + +### 4.2 资源关系表 + +一次退款审批通过会同时影响退款单、订单、钱包、钱包流水、套餐和资产,只在主表保存一个 `resource_id` 无法完整查询。 + +```sql +CREATE TABLE tb_audit_event_resource ( + id BIGSERIAL PRIMARY KEY, + audit_event_id BIGINT NOT NULL, + resource_type VARCHAR(64) NOT NULL, + resource_id BIGINT, + resource_key VARCHAR(128) NOT NULL DEFAULT '', + relation VARCHAR(20) NOT NULL DEFAULT 'affected', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX uq_audit_event_resource + ON tb_audit_event_resource ( + audit_event_id, + resource_type, + COALESCE(resource_id, 0), + resource_key, + relation + ); + +CREATE INDEX idx_audit_resource_timeline + ON tb_audit_event_resource (resource_type, resource_id, audit_event_id DESC); +CREATE INDEX idx_audit_resource_key + ON tb_audit_event_resource (resource_type, resource_key, audit_event_id DESC); +``` + +不建立数据库外键。`relation`: + +| relation | 含义 | +|---|---| +| `primary` | 本次操作的主要对象 | +| `affected` | 被本次操作修改的对象 | +| `reference` | 只用于解释上下文的关联对象 | + +### 4.3 操作者 + +`actor_kind`: + +```text +admin_user +agent_user +enterprise_user +personal_customer +open_api_account +system_task +carrier_callback +wecom_callback +``` + +系统和外部回调允许 `actor_id=NULL`,但必须保存清晰的 `actor_name`,例如“移动实名回调”“企微审批状态同步”“套餐到期任务”。 + +### 4.4 来源 + +`source` 表示入口,不表示操作者: + +```text +admin_api +personal_api +open_api +asynq +scheduled_job +gateway +carrier_callback.cmcc +carrier_callback.cucc +carrier_callback.ctcc +wecom_callback +wecom_polling +data_migration +``` + +### 4.5 结果与风险 + +`result`:`success / failed / denied / partial`。 + +`risk_level`:`normal / warning / high / critical`。 + +默认风险示例: + +| 操作 | 风险等级 | +|---|---| +| 普通字段修改成功 | normal | +| 批量部分失败 | warning | +| 权限拒绝、重复回调冲突 | warning | +| 钱包余额、退款、角色权限、支付配置修改 | high | +| 企微通过后撤销但资金已执行、审计写入失败 | critical | + +## 五、领域模型和代码结构 + +```text +internal/ +├── domain/audit/ +│ ├── event.go 不可变 AuditEvent 实体及不变量 +│ ├── actor.go 操作者值对象 +│ ├── resource.go 多资源关联 +│ ├── sanitizer.go 审计数据脱敏规则 +│ ├── action_registry.go 操作编码、名称、分类和默认风险 +│ └── repository.go +├── application/audit/ +│ ├── append_event.go 成功/失败审计写入 +│ ├── append_with_tx.go 关键业务事务内写入 +│ └── append_system_event.go +├── query/audit/ +│ ├── event_list.go +│ ├── actor_view.go +│ ├── resource_view.go +│ ├── request_view.go +│ ├── correlation_view.go +│ ├── risk_view.go +│ ├── integration_view.go +│ └── finance_view.go +└── infrastructure/persistence/ + └── audit_repository.go +``` + +Audit Event 具有独立 `event_id`,因此是不可变领域实体,不是值对象;Actor、Resource 和字段差异是值对象。事件创建后不提供 Update/Delete Repository 方法。 + +## 六、操作注册表 + +操作编码禁止在各 Service 中自由拼字符串: + +```go +type ActionDefinition struct { + Code string + Name string + Category string + DefaultRiskLevel string + SensitiveFields []string +} + +var ActionRegistry = map[string]ActionDefinition{ + "account.role.assign": { + Name: "分配账号角色", Category: "security", DefaultRiskLevel: "high", + }, + "refund.wecom.approved": { + Name: "企微审批通过并确认人工退款", Category: "finance", DefaultRiskLevel: "high", + }, + "recharge.wecom.approved": { + Name: "企微审批通过并完成线下充值", Category: "finance", DefaultRiskLevel: "high", + }, + "iot_card.realname.callback": { + Name: "运营商回调更新实名状态", Category: "asset", DefaultRiskLevel: "normal", + }, +} +``` + +DTO `description`、前端中文名称和筛选项都从同一注册表生成,避免操作编码与展示名称不一致。 + +## 七、写入可靠性 + +### 7.1 关键成功事件 + +以下事件必须与业务变更同事务写入: + +- 钱包余额变化。 +- 人工退款结果确认和代理钱包回溯。 +- 线下充值入账。 +- 账号角色和权限变化。 +- 支付、企微和系统关键配置变化。 +- 卡实名、网络状态被人工修改。 +- 手工绑定企微 `sp_no`。 + +Application 在事务内调用: + +```go +auditWriter.AppendWithTx(ctx, tx, event) +``` + +审计写入失败时事务回滚,禁止业务成功但关键审计缺失。 + +### 7.2 异步系统事件 + +轮询、事件阶梯同步、运营商回调和企微轮询等外部交互统一进入 Integration Log。只有以下情况同时写 Audit Event: + +- 状态发生业务变化。 +- 连续失败达到阈值。 +- 人工强制触发。 +- 出现高风险异常。 + +系统事件通过同一业务事务或 Outbox 可靠写入,不启动裸 goroutine。 + +### 7.3 失败和拒绝事件 + +业务事务已经回滚时,使用独立短事务写 `failed/denied` 审计。审计写入失败不能把原业务错误改成成功,但必须记录 `critical` 应用日志和监控指标。 + +## 八、关联链路 + +### 8.1 request_id + +表示一次 HTTP 请求,由现有 Request ID 中间件产生。一次请求触发的所有审计事件使用同一个 `request_id`。 + +### 8.2 correlation_id + +表示跨请求、跨任务的完整业务链路: + +```text +退款申请创建 +→ 企微审批提交 +→ 企微回调 +→ 人工退款结果入账 +→ 代理钱包退款流水(仅代理钱包支付订单) +→ 佣金回扣 +→ 套餐失效和停机 +``` + +以上事件共享退款申请创建时生成的 `correlation_id`。Asynq、Outbox、企微实例和同步任务载荷都必须传递该值。 + +### 8.3 parent_event_id + +表示直接因果关系。例如“套餐失效”事件的父事件是“退款终态处理成功”,用于前端画出树状链路。 + +## 九、数据脱敏 + +### 9.1 永不进入审计的数据 + +```text +password +operation_password +access_token / refresh_token +agent_secret / app_secret +callback_token / encoding_aes_key +支付私钥、公钥原文 +短信验证码 +完整身份证号 +对象存储签名 URL +企微 media_id +``` + +这些字段直接删除,不保存为 `******`,避免字段存在本身造成误用。 + +### 9.2 按权限展示的数据 + +手机、IP、ICCID、钱包余额和第三方交易号可以存储受控快照,但 Query 根据权限返回: + +- 普通审计权限:脱敏展示。 +- 敏感审计权限:展示完整值。 +- 导出权限单独控制,不能因为可查看页面就默认可导出完整值。 + +### 9.3 大字段 + +`before_data`、`after_data` 和 `metadata` 分别限制 16KB。超过后保存: + +```json +{ + "_truncated": true, + "_original_bytes": 38210, + "_summary": "批量修改500条资产", + "_artifact_ref": "batch_task:123" +} +``` + +批量明细保存在对应任务结果表或对象存储,不塞入审计 JSON。 + +## 十、外部集成日志 + +企业微信、运营商回调、Gateway 请求和事件同步尝试使用通用 Integration Log。同步尝试即使在真正发送 HTTP 前因合并、限流或预期状态已满足而结束,也写一条结果记录,保证能够解释“为什么没有请求上游”。 + +```sql +CREATE TABLE tb_integration_log ( + id BIGSERIAL PRIMARY KEY, + integration_id VARCHAR(64) NOT NULL UNIQUE, + provider VARCHAR(32) NOT NULL, + direction VARCHAR(16) NOT NULL, + operation VARCHAR(64) NOT NULL, + external_id VARCHAR(128), + + resource_type VARCHAR(64), + resource_id BIGINT, + resource_key VARCHAR(128), + + trigger_source VARCHAR(32), + trigger_scene VARCHAR(128), + trigger_series VARCHAR(64), + scheduled_at TIMESTAMPTZ, + started_at TIMESTAMPTZ, + attempt INT NOT NULL DEFAULT 1, + + result VARCHAR(20) NOT NULL, + http_status INT, + provider_code VARCHAR(64), + provider_message TEXT, + request_summary JSONB, + response_summary JSONB, + duration_ms BIGINT NOT NULL DEFAULT 0, + state_changed BOOLEAN NOT NULL DEFAULT FALSE, + metadata JSONB, + + request_id VARCHAR(64), + correlation_id VARCHAR(64), + audit_event_id BIGINT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_integration_log_time + ON tb_integration_log (created_at DESC, id DESC); +CREATE INDEX idx_integration_log_provider + ON tb_integration_log (provider, operation, result, created_at DESC); +CREATE INDEX idx_integration_log_resource + ON tb_integration_log (resource_type, resource_id, created_at DESC); +CREATE INDEX idx_integration_log_resource_key + ON tb_integration_log (resource_type, resource_key, created_at DESC); +CREATE INDEX idx_integration_log_trigger + ON tb_integration_log (trigger_series, attempt); +CREATE INDEX idx_integration_log_correlation + ON tb_integration_log (correlation_id, created_at ASC); +``` + +`direction`:`inbound / outbound`。 + +`result` 至少支持: + +```text +success +failed +not_found +invalid_payload +ignored +merged +rate_limited +completed +cancelled +``` + +数据同步字段约定: + +- `provider=gateway`:实名、流量、卡状态和设备信息查询。 +- `provider=cmcc/cucc/ctcc` 且 `direction=inbound`:运营商实名或解除实名回调。 +- `trigger_source`:`polling/business_event/open_api/manual_refresh/carrier_callback`。 +- `trigger_scene`:触发埋点场景,例如 `client_asset_info`、`card_resume`、`device_switch_card`。 +- `trigger_series + attempt`:关联一次 `0/3/5` 阶梯同步。 +- `result=merged/rate_limited/completed` 时允许没有 HTTP 状态和响应摘要,因为没有真正请求上游。 +- `state_changed=true` 时通过 `audit_event_id` 关联对应业务状态变化事件。 + +普通高频轮询成功也写 Integration Log,但不生成 Audit Event。查询和清理必须使用时间索引及分批删除,不能在业务请求中同步聚合全量历史。 + +外部交互正文只保存脱敏摘要。企微加密报文、运营商原始回调和 Gateway 加密请求不进入普通审计详情。 + +## 十一、审计范围 + +### 11.1 必须审计的写操作 + +| 模块 | 操作 | +|---|---| +| 账号权限 | 创建、禁用、删除、角色分配、密码重置 | +| 资产 | 分配、回收、绑定、解绑、停复机、实名策略、手工实名、限速 | +| 套餐 | 创建、修改、上下架、分配、价格、已用量和到期时间调整 | +| 钱包资金 | 充值、扣款、退款、信用变化、人工调整 | +| 订单退款 | 创建退款、企微终态、人工退款确认、代理钱包回溯、资产后处理 | +| 线下充值 | 创建、企微终态、入账、通过后撤销异常 | +| 系统配置 | 支付配置、企微配置状态、模板版本发布、导出字段权限 | +| 数据同步 | 人工强制同步、回调更新业务状态、连续失败告警 | +| 导入导出 | 创建任务、下载敏感导出、批量任务结果 | +| 登录安全 | 登录成功/失败、Token 撤销、开放接口签名失败和重放拒绝 | + +### 11.2 不进入业务审计的普通读操作 + +- 普通列表、详情和未读数查询只进入 Access Log。 +- 下载敏感附件、导出、查看完整密钥状态、查看完整个人信息属于敏感读取,需要 Audit Event。 + +## 十二、多视角 API + +### 12.1 全局事件视角 + +```http +GET /api/admin/audit/events + ?category=finance + &action= + &result=success + &risk_level=high + &source=wecom_callback + &start_at= + &end_at= + &keyword= + &page=1&page_size=50 +``` + +`keyword` 只匹配事件摘要、资源编号、操作者名称和 request_id,不对 JSONB 做无索引模糊扫描。 + +### 12.2 事件详情 + +```http +GET /api/admin/audit/events/{event_id} +``` + +返回: + +- 操作者快照。 +- 入口、租户和请求信息。 +- 主要资源、影响资源和引用资源。 +- 前后数据差异。 +- 错误信息和风险等级。 +- request/correlation/parent 关联。 +- 可访问的领域流水和 Integration Log 链接。 + +### 12.3 操作者视角 + +```http +GET /api/admin/audit/actors + ?actor_kind=admin_user + &keyword= + &range=30d + &page=1&page_size=20 + +GET /api/admin/audit/actors/{actor_kind}/{actor_id}/summary?range=30d +GET /api/admin/audit/actors/{actor_kind}/{actor_id}/events?page=1&page_size=50 +``` + +人员列表返回操作者 ID、名称、类型、最近操作时间、操作量、失败量和最高风险等级;`keyword` 只做 ID、账号和名称的前缀匹配,不扫描审计 JSON。Summary 返回操作量、失败量、拒绝量、高风险量、常用操作、常操作资源和最近登录 IP。 + +### 12.4 资源视角 + +```http +GET /api/admin/audit/resources/search + ?resource_type= + &keyword=RF202607150001 + &page=1&page_size=20 + +GET /api/admin/audit/resources/{resource_type}/{resource_id}/timeline + ?include_related=true + &page=1&page_size=50 +``` + +示例资源:`iot_card / device / refund / order / agent_wallet / wecom_approval / account / system_config`。 + +资源搜索属于 Query 模块,允许直接查询退款、订单、充值、账号和资产等读模型,不要求加载领域聚合。返回候选项的 `resource_type/resource_id/resource_key/display_name`,解决同一关键词命中多个资源的问题。静态 `resources/search` 路由必须先于动态资源路由注册。 + +`include_related=true` 时通过资源关系表返回影响该资源的跨模块事件,但不无限递归加载关联资源。 + +### 12.5 请求视角 + +```http +GET /api/admin/audit/requests/{request_id}/timeline +``` + +按时间展示一次 HTTP 请求产生的 Access Log 摘要、Audit Event、Outbox 和 Integration Log。 + +### 12.6 业务链路视角 + +```http +GET /api/admin/audit/correlations/{correlation_id}/timeline +``` + +用于查看退款、企微审批、钱包和资产处理等跨请求链路。返回节点包含 `parent_event_id`,前端可展示因果树。 + +### 12.7 风险视角 + +```http +GET /api/admin/audit/risks/overview?range=24h +GET /api/admin/audit/risks/events?risk_level=critical&page=1&page_size=50 +``` + +Overview: + +- 高风险和严重事件数量。 +- 权限拒绝、开放接口重放、资金冲突和外部回调异常趋势。 +- 按操作人、来源和资源类型聚合。 + +### 12.8 外部集成视角 + +```http +GET /api/admin/audit/integrations + ?provider=wecom + &direction=inbound + &operation=getapprovaldetail + &result=failed + &external_id= + &resource_type= + &resource_key= + &trigger_source= + &trigger_scene= + &trigger_series= + &page=1&page_size=50 + +GET /api/admin/audit/integrations/{integration_log_id} +``` + +列表查询 `tb_integration_log`,并返回关联审计事件和业务链路 ID;详情返回脱敏后的请求摘要、响应摘要、耗时、错误、尝试次数、计划时间、触发场景和关联资源,不返回密钥、完整回调正文或附件内容。 + +当 `trigger_series` 有值时,详情同时返回该序列的全部尝试,前端按“立即、3 分钟、5 分钟”展示,不要求用户逐条搜索。同步记录不再通过独立 `/card-sync/executions` 接口查询。 + +### 12.9 资金视角 + +```http +GET /api/admin/audit/finance/timeline + ?business_no= + &wallet_id= + &transaction_no= + &page=1&page_size=50 +``` + +Finance Query 组合: + +- Audit Event。 +- 代理钱包和资产钱包流水。 +- 退款单、充值单和订单。 +- 企微审批实例。 + +每条记录明确 `record_source`,钱包流水仍是金额变化的权威数据。 + +### 12.10 导出 + +```http +POST /api/admin/audit/exports +``` + +复用现有导出任务系统。导出字段按角色配置,默认不允许导出完整 IP、手机号、ICCID、前后 JSON 和外部响应摘要。 + +## 十三、权限 + +建议权限码: + +```text +audit:global:view +audit:actor:view +audit:resource:view +audit:request:view +audit:risk:view +audit:integration:view +audit:finance:view +audit:sensitive:view +audit:export +``` + +第一版审计中心只开放给超级管理员和具有对应权限的平台角色。 + +代理、企业账号不进入全局审计中心;它们在资产、订单、充值等业务详情页只能查看自己有权限资源的资源时间线,且返回字段经过脱敏。 + +Service/Query 必须重新应用资源权限,前端隐藏 Tab 不能替代后端授权。 + +## 十四、前端多视角界面 + +### 14.1 审计中心 + +路由:`/operations/audit` + +使用工作台式紧凑布局,不做营销式卡片页面。顶部为时间范围、关键词和常用过滤器,下方使用 Tab: + +1. **全局事件**:按时间倒序的审计事件表。 +2. **人员行为**:操作者列表、风险统计和行为时间线。 +3. **资源轨迹**:输入资源类型和标识,展示资源生命周期。 +4. **请求链路**:按 request_id/correlation_id 展示跨模块时间线。 +5. **资金审计**:订单、审批、钱包流水和退款/充值组合视图。 +6. **风险事件**:失败、拒绝、高风险和严重事件。 +7. **外部集成**:Gateway、企微、运营商回调和事件同步阶梯尝试。 + +Tab 根据权限返回,前端不展示无权限入口。 + +外部集成 Tab 增加紧凑的来源切换:`全部 / Gateway 同步 / 运营商回调 / 企业微信`。选择 Gateway 同步后展示触发来源、触发场景、资源、序列、尝试、结果、耗时和状态是否变化;点击序列打开三次尝试时间线。 + +### 14.2 全局事件表 + +字段: + +```text +时间 | 风险 | 操作者 | 操作 | 主要资源 | 来源 | 结果 | request_id +``` + +- 支持服务端分页和列筛选。 +- 点击行打开右侧详情抽屉。 +- 风险仅用图标和有限颜色表达,普通成功事件保持中性色。 +- `request_id`、资源编号可点击进入对应视角。 + +### 14.3 事件详情抽屉 + +分区: + +```text +事件摘要 +操作者与入口 +关联资源 +字段变更对比 +请求与业务链路 +错误和外部交互 +``` + +字段变更使用结构化键值对比,不直接把整段 JSON 原样堆在页面。未知字段可以折叠显示原始 JSON。 + +敏感字段默认脱敏;有 `audit:sensitive:view` 权限时提供“显示敏感数据”按钮,并对该次查看再写一条敏感读取审计。 + +### 14.4 人员行为视角 + +左侧为操作者搜索和筛选,右侧显示: + +- 30 天操作量、失败率、高风险操作数。 +- 常用来源 IP。 +- 操作类型分布。 +- 最近行为时间线。 +- 涉及资源列表。 + +不做基于行为的自动封禁,只提供审计判断依据。 +左侧人员列表调用 `/api/admin/audit/actors`,选择人员后再并行加载 Summary 和事件列表,避免前端从全局事件自行聚合。 + +### 14.5 资源轨迹视角 + +支持输入 ICCID、设备虚拟号、退款单号、订单号、充值单号、账号名称等,先调用 `/api/admin/audit/resources/search` 返回候选资源;只有一个候选时直接打开时间线,多个候选时由用户选择,前端不自行猜测资源类型。 + +时间线同时展示: + +- 业务状态变化。 +- 人工操作。 +- 企微审批结果。 +- 资金流水链接。 +- 重要 Gateway/回调事件。 + +普通高频轮询成功不进入资源审计时间线,避免有效信息被淹没。 + +### 14.6 请求和业务链路视角 + +使用纵向时间线展示节点,节点固定包含时间、来源、动作、结果和耗时。父子事件使用缩进和连接线表达,不使用复杂自由拖拽图编辑器。 + +### 14.7 风险视角 + +顶部显示 24 小时趋势和风险分类,下方是风险事件表。支持一键跳转操作者、资源和 correlation 链路,但第一版不建设风险处置工单。 + +## 十五、Access Log 修正 + +当前访问日志的响应体需要使用与请求体相同的递归脱敏逻辑: + +```go +responseBody := sanitizeBody(c.Response().Body()) +``` + +并增加路由级策略: + +| 路由 | Body 记录策略 | +|---|---| +| 登录、Token、支付配置 | 只记录字段名和长度,不记录值 | +| 企微/支付/运营商回调 | 记录摘要和哈希,不记录完整正文 | +| 文件上传下载 | 不记录文件内容和签名 URL | +| 普通 JSON API | 脱敏后最多 50KB | + +Access Log 建议保留 30 天;不得因为 Access Log 已存在而省略关键业务 Audit Event。 + +## 十六、保留策略 + +| 数据 | 保留期限 | +|---|---:| +| 资金、权限、审批和关键配置 Audit Event | 5 年 | +| 普通资产和业务操作 Audit Event | 2 年 | +| Integration Log | 180 天 | +| Gateway 高频同步 Integration Log | 正常 30 天,异常或状态变化 180 天 | +| Access Log | 30 天 | + +Audit Event 默认不在线删除。数据量达到单表维护阈值后按月分区,超期分区归档到低成本存储,再由专用维护流程删除;应用账号不具备 Update/Delete 审计表权限。 + +## 十七、一次性审计切换 + +本需求与普通 DDD 触碰式迁移不同:审计基础设施、写入入口、Query API 和前端多视角界面在同一次停机发布中全局切换,不能让新旧审计长期并行。 + +### 17.1 新写入 + +- 数据同步、企微审批、站内通知和后续新功能只写统一 `tb_audit_event` 与 `tb_integration_log`。 +- 账号、角色、权限、资产、套餐、订单、退款、充值、钱包、系统配置、导入导出和登录安全等现有敏感操作同时切换到统一 Audit Writer。 +- 卡同步不创建 `tb_card_sync_execution`;轮询、手动刷新、事件阶梯尝试和运营商回调统一写 Integration Log。 +- 发布后旧 `tb_account_operation_log`、`tb_asset_operation_log`、手动轮询触发日志等表不再产生新记录。 +- 禁止双写新旧审计表,避免同一操作产生两条不一致记录。 + +一次性切换审计不等于一次性把所有旧业务模块迁移为 DDD。未迁移的旧 Service 通过统一审计 Adapter 写入新模型;退款、资金、卡状态等复杂且本次触碰的用例按 DDD 规范迁入 Application/Domain。 + +### 17.2 历史数据 + +- 旧审计表保留只读,不在线回填到新表。 +- 审计 Query 使用 `UNION ALL` 把旧账号、资产和手动触发日志标准化为历史投影,返回 `record_source=legacy_account/legacy_asset/legacy_polling`。 +- 历史记录只用于查询,不允许更新或补写。 +- 后续达到归档条件后离线迁移或归档旧表,不影响本次新写入一次性切换。 + +## 十八、代码迁移重点 + +- 删除 `account_audit.Service.LogOperation` 和 `asset_audit.Service.LogOperation` 内部裸 goroutine。 +- 删除调用方外围 `go auditService.LogOperation(...)`。 +- 为尚未迁入 DDD 的旧 Service 提供统一 Audit Writer Adapter,并在本次发布中替换全部敏感操作旧写入口。 +- 退款、线下充值资金事务使用 `AppendWithTx`。 +- 卡实名领域用例在状态变化事务内写审计。 +- 轮询、事件同步、手动刷新和运营商回调统一写 Integration Log;只有状态变化、连续失败、手动操作和高风险异常追加 Audit Event。 +- 企微模板发布、手工绑定 sp_no、通过后撤销写高风险事件。 +- `pkg/logger/middleware.go` 对响应体和特殊路由实施脱敏策略。 +- 现有账号、资产和业务日志详情接口统一改为调用 `query/audit` 的资源时间线或历史投影。 + +## 十九、人工验证 + +1. 关键资金事务在审计写入失败时整体回滚。 +2. 同一事件不会因 Outbox/Asynq 重试生成重复审计。 +3. 一次退款能够从退款单、订单、钱包和资产四个资源视角查到同一事件。 +4. request_id 能串联一次 API 请求产生的多条审计记录。 +5. correlation_id 能串联退款申请、企微审批、退款、佣金和资产处理。 +6. 普通用户不能访问全局、人员、风险和集成视角。 +7. 敏感字段默认脱敏,查看完整敏感字段的动作本身再次被审计。 +8. Access Log 请求和响应均不泄露 Token、Secret、操作密码和签名 URL。 +9. 高频轮询成功只进入外部集成视角,不会淹没业务资源时间线,状态变化和连续失败仍可见。 +10. 企微通过后撤销且资金已执行时生成 critical 事件,不自动冲正。 +11. 历史账号/资产日志可以通过统一审计页面查询,发布后的新写入不再双写旧表。 +12. 审计导出字段受角色权限控制,页面可见不等于可导出完整敏感值。 +13. 人员、资源和集成视角都有独立查询接口,前端不通过下载全局事件后自行聚合。 +14. 同一次事件同步的立即、3 分钟、5 分钟尝试能通过 `trigger_series` 连续展示,合并、限流和提前完成都有可解释结果。 +15. 发布后旧账号、资产和轮询日志表不再新增记录,新旧历史仍能在同一审计界面查询。 diff --git a/docs/7月迭代/新增需求/05-代理钱包扫码充值.md b/docs/7月迭代/新增需求/05-代理钱包扫码充值.md new file mode 100644 index 0000000..37862be --- /dev/null +++ b/docs/7月迭代/新增需求/05-代理钱包扫码充值.md @@ -0,0 +1,446 @@ +# 新增需求 05:代理钱包扫码充值 + +> 状态:已合并至标准评审稿,本文保留为实施明细。 +> 评审主文档:`../7月迭代技术方案-标准评审稿.md` +> 范围:代理在后台使用微信或支付宝扫码充值代理主钱包。 + +## 一、已确认决策 + +1. 代理在后台为自己的店铺主钱包充值。 +2. 支付方式支持微信扫码和支付宝扫码。 +3. 单笔最低充值金额为 100 元,即 `10000` 分。 +4. 代理在线充值不进入企业微信审批,支付成功后直接幂等增加代理主钱包余额。 +5. 平台员工线下代充值仍按 `02-企业微信审批接入.md` 走企微审批,与本方案隔离。 +6. 后端返回支付二维码内容,前端使用现有二维码组件渲染,不由后端生成或保存二维码图片文件。 +7. 微信使用 Native 支付,支付宝使用 `alipay.trade.precreate` 当面付预创建。 +8. 支付回调、钱包入账、钱包流水和审计必须幂等,重复回调不能重复加钱。 +9. 当前代理充值复杂写逻辑迁移到 Application/Domain,旧 Service 不再保留另一套在线入账逻辑。 + +## 二、现状与缺口 + +现有代码已经具备代理充值记录、微信/富友回调和钱包入账骨架,但还不能满足本需求: + +- `CreateAgentRechargeRequest` 只允许 `wechat/offline`,没有支付宝。 +- `AgentRechargeMinAmount=1`,当前最低金额是 1 分。 +- 创建接口只生成充值记录,没有创建统一 `tb_payment` 支付单,也没有真正向支付渠道预下单获取二维码。 +- 微信支付仅保存当前生效支付配置,响应中没有 `code_url`。 +- 支付宝回调只分发套餐订单和客户资产钱包充值,没有代理充值订单类型。 +- `HandlePaymentCallback` 在旧 Service 中直接更新充值单、钱包和流水,业务状态和幂等边界没有收口为独立用例。 +- 前端技术方案只写了“保留收款码和支付状态页面”,没有支付方式选择、最低金额、二维码过期和支付成功状态细节。 + +现有评审中“代理在线充值不审批”的结论保持不变,本次补齐的是扫码支付和最低金额的完整技术方案。 + +## 三、业务流程 + +```mermaid +sequenceDiagram + actor Agent as 代理用户 + participant Web as 代理后台 + participant API as Recharge Application + participant DB as PostgreSQL + participant Pay as 微信/支付宝 + participant Callback as 支付回调 + participant Wallet as AgentWallet Domain + + Agent->>Web: 输入金额并选择支付方式 + Web->>API: 创建扫码充值单 + API->>DB: 创建充值单和支付单 + API->>Pay: Native/PreCreate 预下单 + Pay-->>API: 二维码内容 + API-->>Web: 返回二维码和过期时间 + Web-->>Agent: 展示二维码并轮询支付状态 + Agent->>Pay: 扫码完成支付 + Pay->>Callback: 异步支付通知 + Callback->>API: ConfirmAgentRechargePayment + API->>DB: 校验支付单、金额和状态 + API->>Wallet: CreditRecharge + Wallet->>DB: 同事务增加余额、写流水、完成充值单 + API-->>Pay: 返回成功 + Web->>API: 查询到已完成 + Web-->>Agent: 展示最新钱包余额 +``` + +支付成功不创建 `wecom_approval_instance`,充值详情固定返回: + +```json +{ + "approval_source": "none", + "approval": null +} +``` + +## 四、DDD 设计 + +### 4.1 目录 + +```text +internal/ +├── domain/agentwallet/ +│ ├── wallet.go 代理主钱包聚合及余额不变量 +│ ├── recharge.go 充值单状态转换 +│ ├── events.go 充值到账领域事件 +│ └── repository.go 钱包与充值聚合仓储接口 +├── application/agentrecharge/ +│ ├── create_qr_recharge.go 创建充值单和扫码支付 +│ ├── confirm_payment.go 支付回调确认并入账 +│ ├── close_expired.go 关闭过期未支付充值单 +│ └── get_payment_status.go 轻量支付状态查询 +├── infrastructure/adapter/payment/ +│ ├── wechat_native.go 微信 Native 预下单 +│ └── alipay_precreate.go 支付宝当面付预创建 +├── infrastructure/persistence/ +│ └── agent_recharge_repository.go +└── query/agentrecharge/ + ├── list.go + └── detail.go +``` + +### 4.2 领域状态 + +继续使用现有充值状态,不为在线充值增加审批状态: + +| 状态 | 含义 | 在线充值使用方式 | +|---:|---|---| +| 1 | 待支付 | 已创建二维码,等待扫码 | +| 2 | 已支付 | 支付已确认,钱包入账事务处理中 | +| 3 | 已完成 | 钱包余额和流水已完成 | +| 4 | 已关闭 | 超时未支付或主动取消 | +| 5 | 已退款 | 历史或后续人工退款结果 | +| 6 | 已驳回 | 仅旧数据或线下审批兼容,在线充值不产生 | + +`status=2` 是短暂业务处理状态。支付回调事务正常完成时直接推进到 `3`;若钱包入账发生可恢复错误,则保持 `2` 并由可靠任务继续处理。 + +### 4.3 钱包入账不变量 + +`AgentWallet.CreditRecharge` 必须保证: + +- 只允许向目标店铺的 `wallet_type=main` 钱包入账。 +- 入账金额必须等于充值单金额和支付单金额。 +- 同一充值单只能生成一条成功钱包流水。 +- 钱包余额、`version`、充值单状态和钱包流水在同一数据库事务更新。 +- 钱包乐观锁冲突时由 Application 重新加载后有限重试,不能重复创建流水。 +- 支付渠道成功不等于业务已经完成;只有钱包事务成功后充值单才变为已完成。 + +## 五、支付预下单 + +### 5.1 统一支付单 + +代理在线充值必须创建 `tb_payment` 记录,不能只依赖 `ARCH` 单号前缀判断支付渠道。 + +新增支付业务类型: + +```go +const PaymentOrderTypeAgentRecharge = "agent_recharge" +``` + +充值单和支付单在同一事务创建: + +```text +tb_agent_recharge_record.status = 1 +tb_payment.status = 0 +tb_payment.order_type = agent_recharge +tb_payment.order_id = recharge_id +tb_payment.payment_method = wechat / alipay +tb_payment.amount = recharge_amount +tb_payment.payment_config_id = 创建时使用的配置ID +``` + +先完成本地事务,再调用第三方预下单。预下单失败时把支付单标记为失败并关闭本次充值单,代理重新创建,不复用来源不明确的旧二维码。 + +### 5.2 微信扫码 + +微信使用 Native 下单: + +```text +微信支付 v3 TransactionNative + -> 返回 code_url +``` + +现有微信 SDK 已包含 `TransactionNative`,需要在项目支付 Adapter 中封装,不在 Handler 直接调用 SDK。 + +若当前生效支付配置为: + +- `wechat`:使用微信 v3 Native。 +- `wechat_v2`:补充 v2 Native 统一下单实现。 +- `fuiou`:只有现有富友配置明确支持后台扫码产品时才返回微信可用;不支持时前端隐藏微信扫码入口,不擅自用 JSAPI 代替。 + +### 5.3 支付宝扫码 + +支付宝使用当前 SDK 已提供的: + +```text +alipay.trade.precreate + -> 返回 qr_code +``` + +不复用现有 WAP 支付 URL。创建时校验当前支付配置中的: + +```text +ali_app_id +ali_private_key +ali_public_key +ali_notify_url +``` + +配置不完整时支付宝方式显示为不可用,不能创建只有本地记录而没有有效二维码的充值单。 + +### 5.4 二维码响应 + +后端统一返回二维码内容,不返回二维码图片: + +```json +{ + "recharge_id": 88, + "recharge_no": "ARCH20260715143000000001", + "payment_no": "ARCH20260715143000000001", + "payment_method": "wechat", + "amount": 10000, + "qr_content": "weixin://wxpay/bizpayurl?...", + "expires_at": "2026-07-15T15:00:00+08:00", + "status": 1, + "status_name": "待支付" +} +``` + +微信返回 `code_url`、支付宝返回 `qr_code`,Application 统一映射为 `qr_content`。 + +## 六、支付回调与直接入账 + +### 6.1 回调校验 + +微信和支付宝回调继续执行现有签名、安全和金额校验,并增加代理充值分发: + +```text +payment.order_type = agent_recharge + -> ConfirmAgentRechargePayment +``` + +必须校验: + +- 支付单存在且支付方式与回调渠道一致。 +- `payment_config_id` 与创建支付单时配置一致。 +- 回调金额等于支付单和充值单金额。 +- 第三方交易号没有被其他支付单占用。 +- 充值单属于支付单中的 `order_id`,不能只依赖订单号前缀。 + +### 6.2 回调事务 + +```text +1. 按 payment_no 加载支付单 +2. 支付单 pending -> paid 条件更新 +3. 充值单 1 -> 2 条件更新 +4. 加载代理主钱包并执行 CreditRecharge +5. 钱包余额和 version 更新 +6. 创建唯一钱包流水 +7. 充值单 2 -> 3,写 paid_at/completed_at +8. 写 Audit Event +``` + +幂等键: + +```text +agent_recharge:{recharge_id}:credit +``` + +数据库还需要保证钱包流水 `reference_type=agent_recharge + reference_id=recharge_id` 唯一。Redis 只用于削减重复并发,不能替代数据库幂等。 + +### 6.3 回调失败恢复 + +- 第三方校验失败:拒绝回调,不改变业务状态。 +- 支付状态已成功、钱包事务失败:充值单保持已支付,写 Outbox/恢复任务继续入账。 +- 钱包流水已存在但充值单未完成:恢复任务只补齐充值单状态。 +- 重复回调:查询到支付单或充值单已完成后直接返回渠道成功报文。 +- 本功能不引入审批,也不会因为支付金额较大转入审批。 + +## 七、金额与权限 + +### 7.1 金额 + +```go +const AgentRechargeMinAmount int64 = 10000 +``` + +- 单位固定为分。 +- DTO 使用 `min=10000`,Service/Application 必须再次校验。 +- 前端输入单位为元,提交前转换为分。 +- 最大金额继续沿用现有系统上限,后续调整单独配置。 +- 禁止前端通过浮点数直接计算金额,元转分使用字符串或十进制定点处理。 + +### 7.2 权限 + +- 代理只能为当前登录账号所属店铺的主钱包充值。 +- 后端从登录上下文校验 `shop_id`,不能只相信请求参数。 +- 平台和超级管理员可以查看全部充值记录;是否允许代代理发起在线扫码充值保持现有权限。 +- 企业账号无权访问代理充值接口。 +- 代理不能查看其他店铺的充值单、支付状态或二维码。 + +## 八、API 设计 + +### 8.1 可用支付方式 + +```http +GET /api/admin/agent-recharges/payment-methods +``` + +响应: + +```json +{ + "items": [ + {"method": "wechat", "name": "微信支付", "enabled": true}, + {"method": "alipay", "name": "支付宝", "enabled": true} + ], + "min_amount": 10000, + "max_amount": 100000000 +} +``` + +该路由必须先于 `/:id` 动态路由注册。 + +### 8.2 创建扫码充值 + +沿用现有接口: + +```http +POST /api/admin/agent-recharges +``` + +```json +{ + "shop_id": 101, + "amount": 10000, + "payment_method": "wechat", + "request_id": "01J2RECHARGE..." +} +``` + +`payment_method` 调整为: + +```text +wechat / alipay / offline +``` + +其中 `offline` 仅平台员工线下代充值使用,并继续走企微审批;代理用户只能选择 `wechat/alipay`。 + +`request_id` 用于防止前端重复点击创建多个二维码订单,同一店铺下建立业务唯一约束或 Redis 防重键。 + +### 8.3 查询支付状态 + +```http +GET /api/admin/agent-recharges/{id}/payment-status +``` + +```json +{ + "status": 3, + "status_name": "已完成", + "payment_status": 1, + "payment_status_name": "已支付", + "paid_at": "2026-07-15T14:35:00+08:00", + "completed_at": "2026-07-15T14:35:01+08:00", + "wallet_balance": 510000 +} +``` + +该接口只返回当前登录代理有权访问的充值单,不返回支付密钥、签名参数或其他店铺余额。 + +## 九、前端方案 + +### 9.1 入口 + +代理后台钱包页面保留“充值”按钮,点击后打开充值弹窗或抽屉,不新增营销页面。 + +控件: + +- 金额使用数字输入框,单位为元,明确最低 100 元。 +- 支付方式使用微信/支付宝分段控件,带对应图标。 +- 不可用渠道禁用并显示简短原因。 +- 主按钮为“生成支付二维码”。 + +### 9.2 二维码状态 + +创建成功后展示: + +```text +充值金额 +支付方式 +二维码 +二维码剩余有效时间 +支付状态 +取消/重新生成 +``` + +- 前端使用 `qr_content` 生成二维码,不请求后端图片文件。 +- 页面可见时每 3 秒查询一次轻量支付状态。 +- 页面隐藏时暂停轮询,恢复可见时立即查询。 +- 状态变为已完成、已关闭或离开页面时停止轮询。 +- 二维码过期后禁用原二维码,提供“重新生成”命令,重新创建充值单和支付单。 +- 支付完成后关闭二维码区域,刷新钱包余额并展示充值成功结果。 +- 全流程不展示审批状态或企微审批区块。 + +### 9.3 列表和详情 + +代理充值列表增加支付方式和支付状态: + +```text +充值单号 | 金额 | 支付方式 | 支付状态 | 充值状态 | 创建时间 | 完成时间 +``` + +在线充值详情返回 `approval_source=none`。平台员工线下代充值详情继续展示企微审批信息,两类记录按 `payment_method` 区分。 + +## 十、审计与通知 + +统一审计至少记录: + +```text +代理创建充值单 +支付预下单成功/失败 +微信/支付宝支付回调成功/失败 +钱包入账成功/失败 +重复回调被幂等忽略 +充值单超时关闭 +``` + +支付渠道交互写 `tb_integration_log`,钱包余额变化写关键 `Audit Event`,并关联充值单、支付单、代理钱包和钱包流水。 + +在线充值不产生审批通知。充值成功后可以生成普通资金结果站内通知,但不能显示“审批通过”。 + +## 十一、代码迁移范围 + +### 11.1 必须修改 + +- `AgentRechargeMinAmount` 从 `1` 调整为 `10000`。 +- `CreateAgentRechargeRequest.payment_method` 增加 `alipay`,金额校验改为 `min=10000`。 +- 创建代理在线充值时同时创建 `tb_payment` 记录。 +- 新增 `PaymentOrderTypeAgentRecharge`。 +- 微信支付 Adapter 增加 Native 预下单。 +- 支付宝 Adapter 增加 `TradePreCreate`。 +- 支付宝回调增加代理充值分发。 +- 微信/富友代理充值回调统一改为按支付单分发,不只依赖 `ARCH` 前缀。 +- 原 `agent_recharge.Service.HandlePaymentCallback` 迁入 `ConfirmAgentRechargePayment` 用例。 +- 前端增加支付方式查询、二维码展示和支付状态轮询。 + +### 11.2 保持不变 + +- 代理在线充值不进入企微审批。 +- 平台员工线下代充值继续走企微审批。 +- 代理只能充值自己的店铺主钱包。 +- 钱包流水仍是资金变化权威记录。 +- 现有支付回调验签和金额校验原则保持不变。 + +## 十二、发布与人工验证 + +停机发布,API 与回调服务同时切换: + +1. 验证 99.99 元被后端拒绝,100 元可以创建充值单。 +2. 验证代理只能为自己的店铺创建微信或支付宝充值。 +3. 验证微信 Native 返回有效 `code_url`,前端能够扫码支付。 +4. 验证支付宝 PreCreate 返回有效 `qr_code`,前端能够扫码支付。 +5. 验证支付回调通过支付单类型分发到代理充值用例。 +6. 验证微信、支付宝回调金额不一致时不会增加钱包余额。 +7. 验证支付成功后不创建企微审批实例,直接完成钱包入账。 +8. 验证重复回调只产生一条钱包流水,余额只增加一次。 +9. 验证支付成功但钱包事务暂时失败时能够恢复完成,不需要代理重复支付。 +10. 验证二维码过期后充值单关闭,旧二维码不能继续显示为有效。 +11. 验证代理充值详情固定返回 `approval_source=none`,不展示审批区域。 +12. 验证平台员工线下代充值仍按原企微审批方案执行,不受在线充值改造影响。 diff --git a/docs/7月迭代/新需求.md b/docs/7月迭代/新需求.md new file mode 100644 index 0000000..bb2505f --- /dev/null +++ b/docs/7月迭代/新需求.md @@ -0,0 +1,727 @@ +处理回调的代码 +```golang +package main + +import ( + "bytes" + "encoding/json" + "encoding/xml" + "fmt" + "io" + "io/ioutil" + "log" + "mime/multipart" + "net/http" + "os" + "path/filepath" + "strconv" + + "strings" + "time" + + ranNumLib "math/rand" + + "github.com/google/uuid" +) + +// XML请求体结构 +type ContractRoot struct { + XMLName xml.Name `xml:"ContractRoot"` + Type string `xml:"TYPE"` + GroupTransactionID string `xml:"GROUP_TRANSACTIONID"` + StatusInfo string `xml:"STATUSINFO"` + AccNbr string `xml:"ACCNBR"` + ICCID string `xml:"ICCID"` + SendDt string `xml:"SENDDT"` + AcceptType string `xml:"ACCEPTTYPE"` + AcceptMsg string `xml:"ACCEPTMSG"` + StatusDt string `xml:"STATUSDT"` + ResultMsg string `xml:"RESULTMSG"` +} + +// 第三方推送数据结构 +type PushData struct { + Msg string `json:"msg"` + Code int `json:"code"` + Data struct { + RequestID string `json:"requestId"` + RealStatus bool `json:"realStatus"` + ICCID string `json:"iccid"` + } `json:"data"` +} + +func parseCallback(jsonStr []byte) (string, string, error) { + // 定义匿名结构体用于解析外层JSON + var cb struct { + Data string `json:"data"` + } + err := json.Unmarshal(jsonStr, &cb) + if err != nil { + return "", "", fmt.Errorf("failed to unmarshal outer JSON: %v", err) + } + + // 定义匿名结构体用于解析内层数据 + var inner struct { + DateChanged string `json:"dateChanged"` + ICCID string `json:"iccid"` + } + err = json.Unmarshal([]byte(cb.Data), &inner) + if err != nil { + return "", "", fmt.Errorf("failed to unmarshal inner data JSON: %v", err) + } + + return inner.ICCID, inner.DateChanged, nil +} + +// 5GCMP实名后推送到第三方平台 +func pushToThirdParty(iccid string) (string, error) { + // 构建推送数据 + pushData := PushData{ + Msg: "查询成功", + Code: 200, + } + pushData.Data.RequestID = uuid.New().String() + pushData.Data.RealStatus = true + pushData.Data.ICCID = iccid + + // 序列化为JSON + jsonData, err := json.Marshal(pushData) + if err != nil { + return "", fmt.Errorf("序列化推送数据失败: %v", err) + } + + // 发送HTTP POST请求 + pushURL := "http://jh.whjhft.com/gswlpushapi/recv.do?type=3" + client := &http.Client{ + Timeout: 10 * time.Second, + } + + resp, err := client.Post(pushURL, "application/json", bytes.NewBuffer(jsonData)) + if err != nil { + return "", fmt.Errorf("HTTP请求失败: %v", err) + } + defer resp.Body.Close() + + // 读取响应 + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("读取响应失败: %v", err) + } + + return string(respBody), nil +} + +// 获取日志文件路径 +func getLogFilePath() string { + // 创建logs目录 + logsDir := "logs" + if err := os.MkdirAll(logsDir, 0755); err != nil { + log.Printf("创建日志目录失败: %v", err) + } + + // 按日期生成文件名 + dateStr := time.Now().Format("2006-01-02") + fileName := fmt.Sprintf("5gcmp_callback_%s.log", dateStr) + return filepath.Join(logsDir, fileName) +} + +func GetQcRandNum() (ranNum string) { + randomFloat := ranNumLib.Float64() + if randomFloat < 0.5 { + randomFloat = 1 - randomFloat + } + ranNum = fmt.Sprintf("%.16f", randomFloat) + return +} + +// 写入日志 +func writeLog(content string) { + logFile := getLogFilePath() + + // 打开或创建日志文件 + file, err := os.OpenFile(logFile, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) + if err != nil { + log.Printf("打开日志文件失败: %v", err) + return + } + defer file.Close() + + // 写入日志内容 + if _, err := file.WriteString(content); err != nil { + log.Printf("写入日志失败: %v", err) + } +} + +// 向管理平台发送删除实名请求 +func DelRealName(iccid string) (res string) { + account := Account{UserName: "18627991016", Password: "y123456"} + sessionid := account.Login() + realnameId := getRealnameIdByIccid(iccid, sessionid) + if realnameId == "" { + return + } + log.Printf("sessinid:%s,realnameId:%s", sessionid, realnameId) + url := "http://jh.whjhft.com/realnamerecord/deleteById.do?responseFunction=initUpdate&id=" + realnameId + "&rfm=" + GetQcRandNum() + data := fmt.Sprintf("status=1&iccidMark=%s", iccid) + req, err := http.NewRequest("POST", url, strings.NewReader(data)) + if err != nil { + log.Printf("创建请求失败: %v", err) + return "" + } + client := &http.Client{ + Timeout: 10 * time.Second, + } + req.Header.Set("Cookie", fmt.Sprintf("JSESSIONID=%s", sessionid)) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8") + req.Header.Set("user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.37 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36 Edg/123.0.0.0") + resp, err := client.Do(req) + if err != nil { + log.Printf("HTTP请求失败: %v", err) + return + } + defer resp.Body.Close() + respBody, err := io.ReadAll(resp.Body) + if err != nil { + log.Printf("读取响应失败: %v", err) + return + } + log.Printf("删除实名响应: %s", respBody) + res = string(respBody) + return +} + +func getRealnameIdByIccid(iccid, sessionid string) (realnameId string) { + url := "http://jh.whjhft.com/realnamerecord/grid.do?responseFunction=grid&pageSize=15&pageNo=1&rfm=0." + GetQcRandNum() + client := &http.Client{ + Timeout: 10 * time.Second, + } + + data := fmt.Sprintf("status=1&iccidMark=%s", iccid) + + req, err := http.NewRequest("POST", url, strings.NewReader(data)) + if err != nil { + log.Printf("创建请求失败: %v", err) + return + } + req.Header.Set("Cookie", fmt.Sprintf("JSESSIONID=%s", sessionid)) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8") + req.Header.Set("user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.37 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36 Edg/123.0.0.0") + resp, err := client.Do(req) + if err != nil { + log.Printf("HTTP请求失败: %v", err) + return + } + defer resp.Body.Close() + respBody, err := io.ReadAll(resp.Body) + if err != nil { + log.Printf("读取响应失败: %v", err) + return + } + + var JSONData struct { + Code string `json:"code"` + Data struct { + PageNo int `json:"pageNo"` + PageCount int `json:"pageCount"` + PageSize int `json:"pageSize"` + PageStartOffset int `json:"pageStartOffset"` + Total int `json:"total"` + Rows []struct { + MybatisRecordCount int `json:"mybatisRecordCount"` + OrderNo string `json:"orderNo"` + JSONUpdateFlag string `json:"jsonUpdateFlag"` + ID string `json:"id"` + IccidMark string `json:"iccidMark"` + Phone string `json:"phone"` + AccountID string `json:"accountId"` + AccountName string `json:"accountName"` + Status int `json:"status"` + CreateName string `json:"createName"` + CreateDate string `json:"createDate"` + StatusStr string `json:"statusStr"` + } `json:"rows"` + Framework string `json:"framework"` + Data string `json:"data"` + Count int `json:"count"` + Limit int `json:"limit"` + Page int `json:"page"` + Layui bool `json:"layui"` + } `json:"data"` + CurrentSessionUserResourceIdsIndex []string `json:"current_session_user_resource_ids_index"` + AppResultKey string `json:"app_result_key"` + SystemResultKey string `json:"system_result_key"` + } + json.Unmarshal(respBody, &JSONData) + if JSONData.AppResultKey == "0" && JSONData.SystemResultKey == "0" && JSONData.Data.Count > 0 { + realnameId = JSONData.Data.Rows[0].ID + } + + log.Printf("响应体: %s", respBody) + return +} + +func getIccidByMsisdn(msisdn, sessionid string) (iccid string) { + url := "http://jh.whjhft.com/realnamerecord/grid.do?responseFunction=grid&pageSize=15&pageNo=1&rfm=" + GetQcRandNum() + client := &http.Client{ + Timeout: 10 * time.Second, + } + + data := fmt.Sprintf("status=1&phone=%s", msisdn) + + req, err := http.NewRequest("POST", url, strings.NewReader(data)) + if err != nil { + log.Printf("创建请求失败: %v", err) + return + } + req.Header.Set("Cookie", fmt.Sprintf("JSESSIONID=%s", sessionid)) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8") + req.Header.Set("user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.37 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36 Edg/123.0.0.0") + resp, err := client.Do(req) + if err != nil { + log.Printf("HTTP请求失败: %v", err) + return + } + defer resp.Body.Close() + respBody, err := io.ReadAll(resp.Body) + if err != nil { + log.Printf("读取响应失败: %v", err) + return + } + var JSONData struct { + Code string `json:"code"` + Data struct { + Rows []struct { + IccidMark string `json:"iccidMark"` + } `json:"rows"` + Count int `json:"count"` + } `json:"data"` + AppResultKey string `json:"app_result_key"` + SystemResultKey string `json:"system_result_key"` + } + json.Unmarshal(respBody, &JSONData) + if JSONData.AppResultKey == "0" && JSONData.SystemResultKey == "0" && JSONData.Data.Count > 0 { + iccid = JSONData.Data.Rows[0].IccidMark + } + log.Printf("响应体: %s", respBody) + return +} + +func ModifyDate(iccid, dateChanged string) { + var jsonData = map[string]interface{}{ + "iccid": iccid, + "dateChanged": dateChanged, + } + jsonDataBs, _ := json.Marshal(jsonData) + + // 创建请求 + req, err := http.NewRequest("POST", "http://127.0.0.1:3000/api/v1/inventory/realname/inner_callback", bytes.NewReader(jsonDataBs)) + if err != nil { + writeLog(fmt.Sprintf("创建请求失败:[%s] [%s]实名时间[%s]失败\r\n", "http://127.0.0.1:3000/api/v1/inventory/realname/inner_callback", iccid, dateChanged)) + return + } + + // 设置Content-Type为x-www-form-urlencoded + req.Header.Set("Content-Type", "application/json") + + // 发送请求 + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + writeLog(fmt.Sprintf("请求接口:[%s] [%s]实名时间[%s]失败\r\n", "http://127.0.0.1:3000/api/v1/inventory/realname/inner_callback", iccid, dateChanged)) + return + } + defer resp.Body.Close() + + // 读取响应内容 + respBody, err := io.ReadAll(resp.Body) + if err != nil { + writeLog(fmt.Sprintf("请求接口:[%s] [%s]实名时间[%s]失败\r\n", "http://127.0.0.1:3000/api/v1/inventory/realname/inner_callback", iccid, dateChanged)) + return + } + + // 检查响应状态 + if resp.StatusCode != http.StatusOK { + fmt.Printf("请求失败,状态码: %d, 响应: %s\n", resp.StatusCode, string(respBody)) + writeLog(fmt.Sprintf("请求接口:[%s] [%s]实名时间[%s]失败\r\n", "http://127.0.0.1:3000/api/v1/inventory/realname/inner_callback", iccid, dateChanged)) + return + } + + writeLog(fmt.Sprintf("请求接口:[%s] [%s]实名时间[%s]成功,[%s]\r\n", "http://127.0.0.1:3000/api/v1/inventory/realname/inner_callback", iccid, dateChanged, string(respBody))) + +} + +// 处理回调请求 +func handleCallback(w http.ResponseWriter, r *http.Request) { + // 记录请求开始时间 + startTime := time.Now() + timestamp := startTime.Format("2006-01-02 15:04:05") + + // 构建日志内容 + var logBuilder strings.Builder + logBuilder.WriteString("\n========================================\n") + logBuilder.WriteString(fmt.Sprintf("请求时间: %s\n", timestamp)) + logBuilder.WriteString(fmt.Sprintf("请求方法: %s\n", r.Method)) + logBuilder.WriteString(fmt.Sprintf("完整URL: %s\n", r.URL.String())) + logBuilder.WriteString(fmt.Sprintf("请求路径: %s\n", r.URL.Path)) + logBuilder.WriteString(fmt.Sprintf("查询参数: %s\n", r.URL.RawQuery)) + logBuilder.WriteString(fmt.Sprintf("客户端IP: %s\n", r.RemoteAddr)) + + // 记录请求头 + logBuilder.WriteString("--- 请求头 ---\n") + for name, values := range r.Header { + for _, value := range values { + logBuilder.WriteString(fmt.Sprintf("%s: %s\n", name, value)) + } + } + + // 记录请求体 + logBuilder.WriteString("--- 请求体 ---\n") + body, err := io.ReadAll(r.Body) + if err != nil { + logBuilder.WriteString(fmt.Sprintf("读取请求体失败: %v\n", err)) + } else { + if len(body) > 0 { + logBuilder.WriteString(fmt.Sprintf("%s\n", string(body))) + } else { + logBuilder.WriteString("(空请求体)\n") + } + } + + // 处理XML请求体和第三方推送 + var pushResponse string + + log.Printf("body:%s\r\n", string(body)) + + if len(body) > 0 { + // 尝试解析XML + var contractRoot ContractRoot + if err := xml.Unmarshal(body, &contractRoot); err == nil { + logBuilder.WriteString("--- XML解析结果 ---\n") + logBuilder.WriteString(fmt.Sprintf("TYPE: %s\n", contractRoot.Type)) + logBuilder.WriteString(fmt.Sprintf("ICCID: %s\n", contractRoot.ICCID)) + logBuilder.WriteString(fmt.Sprintf("STATUSINFO: %s\n", contractRoot.StatusInfo)) + + // 如果TYPE=1,表示实名认证成功,需要推送到第三方 + if strings.Contains(contractRoot.AcceptMsg, "已完成实名信息补录") && contractRoot.ICCID != "" && contractRoot.ResultMsg == "成功" { + logBuilder.WriteString("--- 第三方推送 ---\n") + logBuilder.WriteString(fmt.Sprintf("触发条件: TYPE=%s (实名认证成功)\n", contractRoot.Type)) + logBuilder.WriteString(fmt.Sprintf("推送ICCID: %s\n", contractRoot.ICCID)) + logBuilder.WriteString("推送地址: http://jh.whjhft.com/gswlpushapi/recv.do?type=3\n") + + timestampStr := strconv.FormatInt(time.Now().Unix(), 10) + ModifyDate(contractRoot.ICCID, timestampStr) + + // 执行推送 + if resp, err := pushToThirdParty(contractRoot.ICCID); err != nil { + logBuilder.WriteString(fmt.Sprintf("推送失败: %v\n", err)) + pushResponse = fmt.Sprintf("推送失败: %v", err) + } else { + logBuilder.WriteString(fmt.Sprintf("推送成功,响应: %s\n", resp)) + pushResponse = resp + } + } else if strings.Contains(contractRoot.AcceptMsg, "已完成实名信息清除") && contractRoot.ICCID != "" && contractRoot.ResultMsg == "成功" { + logBuilder.WriteString("--- 第三方推送删除实名 ---\n") + res := DelRealName(contractRoot.ICCID) + logBuilder.WriteString(fmt.Sprintf("删除实名响应: %s\n", res)) + } else { + logBuilder.WriteString("--- 第三方推送 ---\n") + logBuilder.WriteString(fmt.Sprintf("跳过推送: TYPE=%s,%s,%s (非实名认证成功)\n", contractRoot.Type, contractRoot.AcceptMsg, contractRoot.ResultMsg)) + } + } else { + logBuilder.WriteString(fmt.Sprintf("XML解析失败: %v\n", err)) + } + } + + // 记录处理时间 + processTime := time.Since(startTime) + logBuilder.WriteString(fmt.Sprintf("处理耗时: %v\n", processTime)) + logBuilder.WriteString("========================================\n") + + // 写入日志文件 + writeLog(logBuilder.String()) + + // 同时输出到控制台 + fmt.Print(logBuilder.String()) + + // 返回成功响应 + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + // 构建响应数据 + responseData := map[string]interface{}{ + "code": 200, + "msg": "success", + "timestamp": timestamp, + } + + // 如果有推送响应,添加到响应中 + if pushResponse != "" { + responseData["pushResponse"] = pushResponse + } + + respJSON, _ := json.Marshal(responseData) + w.Write(respJSON) +} + +// 获取管理平台登录凭证 +func (ac Account) Login() (sessionid string) { + var password string + password = ac.Password + var requestBody bytes.Buffer + multipartWriter := multipart.NewWriter(&requestBody) + multipartWriter.WriteField("username", ac.UserName) + multipartWriter.WriteField("password", password) + multipartWriter.Close() + req, _ := http.NewRequest("POST", "http://jh.whjhft.com/pages/login.do", &requestBody) + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36 Edg/123.0.0.0") + req.Header.Set("Content-Type", multipartWriter.FormDataContentType()) + //client1 := http.DefaultClient + client1 := &http.Client{ + CheckRedirect: func(req1 *http.Request, via []*http.Request) error { + + strs := strings.Split(req1.URL.Path, ";") + log.Printf("%s\r\n", req1.URL.Path) + if len(strs) == 2 { + strs1 := strings.Split(strs[1], "=") + if len(strs1) == 2 { + sessionid = strs1[1] + } + } + // fmt.Printf("Redirect from '%s' to '%s'\n", via[0].URL, req1.URL.Path) + return nil + }, + } + resp, err := client1.Do(req) + if err != nil { + fmt.Println("Failed to send request:", err) + return + } + defer resp.Body.Close() + + // 处理响应 + _, err = ioutil.ReadAll(resp.Body) + + if err != nil { + fmt.Println("Failed to read response:", err) + return + } + //fmt.Println("Response:", string(respBody)) + return +} + +// 移动实名回调 +func ChinaMobileCallback(w http.ResponseWriter, r *http.Request) { + // 记录请求开始时间 + startTime := time.Now() + timestamp := startTime.Format("2006-01-02 15:04:05") + + // 构建日志内容 + var logBuilder strings.Builder + logBuilder.WriteString("\n========================================\n") + logBuilder.WriteString("【移动实名回调】\n") + logBuilder.WriteString(fmt.Sprintf("请求时间: %s\n", timestamp)) + logBuilder.WriteString(fmt.Sprintf("请求方法: %s\n", r.Method)) + logBuilder.WriteString(fmt.Sprintf("完整URL: %s\n", r.URL.String())) + logBuilder.WriteString(fmt.Sprintf("请求路径: %s\n", r.URL.Path)) + logBuilder.WriteString(fmt.Sprintf("查询参数: %s\n", r.URL.RawQuery)) + logBuilder.WriteString(fmt.Sprintf("客户端IP: %s\n", r.RemoteAddr)) + + // 记录请求头 + logBuilder.WriteString("--- 请求头 ---\n") + for name, values := range r.Header { + for _, value := range values { + logBuilder.WriteString(fmt.Sprintf("%s: %s\n", name, value)) + } + } + + // 记录请求体 + logBuilder.WriteString("--- 请求体 ---\n") + body, err := io.ReadAll(r.Body) + if err != nil { + logBuilder.WriteString(fmt.Sprintf("读取请求体失败: %v\n", err)) + } else { + if len(body) > 0 { + logBuilder.WriteString(fmt.Sprintf("%s\n", string(body))) + var JSONData struct { + Status string `json:"status"` + Message string `json:"message"` + Result []struct { + RegStatus string `json:"regStatus"` + BusiSeq string `json:"busiSeq"` + Msisdn string `json:"msisdn"` + Iccid string `json:"iccid"` + } `json:"result"` + } + json.Unmarshal(body, &JSONData) + if JSONData.Status == "0" && JSONData.Message == "正确" && len(JSONData.Result) > 0 && JSONData.Result[0].RegStatus == "00000" { + iccid := JSONData.Result[0].Iccid + if iccid == "" { + msisdn := JSONData.Result[0].Msisdn //接入号 + account := Account{UserName: "18627991016", Password: "y123456"} + sessionid := account.Login() + iccid = getIccidByMsisdn(msisdn, sessionid) + } + if iccid == "" { + return + } + + //推送修改过期时间 + timestampStr := strconv.FormatInt(time.Now().Unix(), 10) + ModifyDate(iccid, timestampStr) + + logBuilder.WriteString("--- 第三方推送 ---\n") + logBuilder.WriteString(fmt.Sprintf("推送ICCID: %s\n", iccid)) + logBuilder.WriteString("推送地址: http://jh.whjhft.com/gswlpushapi/recv.do?type=3\n") + + // 执行推送实名状态 + if resp, err := pushToThirdParty(iccid); err != nil { + logBuilder.WriteString(fmt.Sprintf("推送失败: %v\n", err)) + } else { + logBuilder.WriteString(fmt.Sprintf("推送成功,响应: %s\n", resp)) + } + logBuilder.WriteString("--- 第三方推送 ---\n") + + } else { + logBuilder.WriteString("(实名认证失败)\n") + } + + } else { + logBuilder.WriteString("(空请求体)\n") + } + } + defer r.Body.Close() + + // 记录处理时间 + processTime := time.Since(startTime) + logBuilder.WriteString(fmt.Sprintf("处理耗时: %v\n", processTime)) + logBuilder.WriteString("========================================\n") + + // 写入日志文件 + writeLog(logBuilder.String()) + + // 同时输出到控制台 + fmt.Print(logBuilder.String()) + + // 返回200 OK响应 + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + // 构建响应数据 + responseData := map[string]interface{}{ + "code": 200, + "msg": "success", + "timestamp": timestamp, + } + + respJSON, _ := json.Marshal(responseData) + w.Write(respJSON) +} + +// 联通解除实名回调 +func UniRealnameRemove(w http.ResponseWriter, r *http.Request) { + // 记录请求开始时间 + startTime := time.Now() + timestamp := startTime.Format("2006-01-02 15:04:05") + + // 构建日志内容 + var logBuilder strings.Builder + logBuilder.WriteString("\n========================================\n") + logBuilder.WriteString(fmt.Sprintf("请求时间: %s\n", timestamp)) + logBuilder.WriteString(fmt.Sprintf("请求方法: %s\n", r.Method)) + logBuilder.WriteString(fmt.Sprintf("完整URL: %s\n", r.URL.String())) + logBuilder.WriteString(fmt.Sprintf("请求路径: %s\n", r.URL.Path)) + logBuilder.WriteString(fmt.Sprintf("查询参数: %s\n", r.URL.RawQuery)) + logBuilder.WriteString(fmt.Sprintf("客户端IP: %s\n", r.RemoteAddr)) + + // 记录请求头 + logBuilder.WriteString("--- 请求头 ---\n") + for name, values := range r.Header { + for _, value := range values { + logBuilder.WriteString(fmt.Sprintf("%s: %s\n", name, value)) + } + } + + // 记录请求体 + logBuilder.WriteString("--- 请求体 ---\n") + body, err := io.ReadAll(r.Body) + if err != nil { + logBuilder.WriteString(fmt.Sprintf("读取请求体失败: %v\n", err)) + } else { + if len(body) > 0 { + logBuilder.WriteString(fmt.Sprintf("%s\n", string(body))) + } else { + logBuilder.WriteString("(空请求体)\n") + } + } + defer r.Body.Close() + + // 解析回调数据 + iccid, dateChanged, err := parseCallback(body) + if err != nil { + logBuilder.WriteString(fmt.Sprintf("解析回调数据失败: %v\n", err)) + } else { + if len(iccid) == 20 { + //取前面19位 + iccid = iccid[:19] + //删除实名 + res := DelRealName(iccid) + logBuilder.WriteString(fmt.Sprintf("删除实名响应: %s\n", res)) + } + logBuilder.WriteString(fmt.Sprintf("解析回调数据成功: ICCID=%s, DateChanged=%s\n", iccid, dateChanged)) + } + + // 写入日志文件 + writeLog(logBuilder.String()) + + // 同时输出到控制台 + fmt.Print(logBuilder.String()) + + // 构建响应数据 + responseData := map[string]interface{}{ + "code": 200, + "msg": "success", + "timestamp": timestamp, + } + + respJSON, _ := json.Marshal(responseData) + w.Write(respJSON) +} + +func main() { + // 注册路由 + // res := DelRealName("8986112422108176397") + // log.Printf("删除实名响应: %s", res) + + http.HandleFunc("/5gcmp/callback/realname", handleCallback) + http.HandleFunc("/unicom/callback/realname/remove", UniRealnameRemove) + http.HandleFunc("/mobile/callback/realname", ChinaMobileCallback) + + // 启动服务器 + port := ":16159" + fmt.Printf("5GCMP回调服务器启动成功\n") + fmt.Printf("监听端口: %s\n", port) + fmt.Printf("电信回调地址: %s/5gcmp/callback/realname\n", port) + fmt.Printf("联通回调地址: %s/unicom/callback/realname/remove\n", port) + fmt.Printf("移动回调地址: %s/mobile/callback/realname\n", port) + fmt.Printf("日志目录: logs/\n") + fmt.Printf("按 Ctrl+C 停止服务器\n\n") + + if err := http.ListenAndServe(port, nil); err != nil { + log.Fatalf("启动服务器失败: %v", err) + } +} + +type Account struct { + UserName string + Password string +} + +``` + + +优化轮询以及添加事件/触发式 数据同步 + +目前我们系统过于依赖轮询系统,且轮询系统的黑盒属性过于严重 +所以现在想加入事件触发以及实名回调,目前已知的可配置实名回调只有移动,联通,电信三个运营商,广电是没有实名回调的,上方是之前已经实现过的实名回调 + +需求差不多是这么个需求,主要就是想让数据同步这一块的及时率达到一种很快的地步,看是怎么埋点,而且开放接口的埋点还需要特殊处理,不然的话代理拿着我们的开放接口乱调用的话就等于外置了一个轮询系统了 diff --git a/docs/批量购买套餐脚本/功能总结.md b/docs/批量购买套餐脚本/功能总结.md new file mode 100644 index 0000000..a6a1662 --- /dev/null +++ b/docs/批量购买套餐脚本/功能总结.md @@ -0,0 +1,33 @@ +# 批量购买套餐脚本功能总结 + +## 功能说明 + +新增 Python 运维脚本 `scripts/batch_package_purchase/batch_purchase.py`,用于读取单列 CSV,并逐资产调用后台 `POST /api/admin/orders` 接口购买同一个套餐。 + +## 输入与配置 + +- CSV 每行一个 ICCID 或虚拟号,首行表头可选。 +- `--base-url` 配置接口地址。 +- `--package-id` 指定本批次统一购买的套餐 ID。 +- 认证支持已有 Access Token,或使用后台账号自动登录获取 Token。 +- 支付方式固定为 `wallet`。 + +## 安全控制 + +- 默认仅预演,显式增加 `--execute` 后才会真实下单。 +- 下单前统一校验 CSV,重复资产会阻断整批执行。 +- 每个资产独立调用一次接口,结果逐条刷新到 CSV。 +- POST 请求不自动重试,避免响应丢失造成重复购买。 +- Token 和密码支持环境变量传入,不写入结果文件。 + +## 输出 + +结果 CSV 包含: + +- CSV 原始行号和资产标识。 +- 成功或失败状态。 +- HTTP 状态码和业务错误码。 +- 接口消息。 +- 成功订单的订单 ID、订单号和订单金额。 + +脚本执行结束时,成功数量与输入总数一致则退出码为 `0`;存在失败或中途因认证失效停止时退出码为 `2`;参数、CSV 或登录错误时退出码为 `1`。 diff --git a/docs/技术方案评审规范.md b/docs/技术方案评审规范.md new file mode 100644 index 0000000..d4331b1 --- /dev/null +++ b/docs/技术方案评审规范.md @@ -0,0 +1,182 @@ +# 技术方案评审规范 + +> 适用范围:新功能、复杂业务改造、跨模块变更、基础设施建设和需要多人评审的技术方案。 +> 核心目标:让评审人能够判断“为什么这样做、是否能落地、失败后怎么办”,而不是仅看到表结构和代码清单。 + +--- + +## 一、方案分级 + +技术方案不要求统一篇幅,也不要求每个需求都强行画图。 + +| 等级 | 典型场景 | 最低要求 | +|------|----------|----------| +| 简化方案 | 单字段、单表 CRUD、局部筛选、纯展示修复 | 背景、改动范围、接口/字段、前端影响、兼容性、人工验收点 | +| 标准方案 | 新接口、异步任务、跨表写入、配置化、第三方调用 | 简化方案全部内容 + 关键流程图 + 数据与异常处理 + 发布回滚 | +| 完整方案 | 金额、审批、状态机、并发、跨模块事务、可靠事件、公共基础设施 | 标准方案全部内容 + 系统上下文图 + 状态图/时序图 + 前后端方案 + 风险与待决策项 | + +以下场景即使代码量不大,也必须使用完整方案: + +- 涉及资金、余额、信用额度、退款或充值。 +- 涉及审批、权限、审计或敏感数据导出。 +- 涉及异步任务、消息投递、最终一致性或第三方回调。 +- 修改公共基础设施或多个业务模块共同依赖的契约。 + +--- + +## 二、文档状态 + +需要进入评审的文档,开头必须标明状态: + +```text +状态:草稿 | 待评审 | 评审通过 | 已废弃 +负责人:姓名或角色 +评审人:后端、前端、产品、验收负责人、运维(按实际参与方) +最后更新:YYYY-MM-DD +关联需求:需求编号或 OpenSpec change +``` + +状态含义: + +- `草稿`:允许存在未收敛内容,不可直接进入开发。 +- `待评审`:方案作者认为信息已经完整,待评审人确认。 +- `评审通过`:关键决策和待办已有结论,可以进入实施。 +- `已废弃`:保留历史原因,并链接替代方案。 + +存在评审阻塞项时,禁止标记为“评审通过”。 + +--- + +## 三、必备内容 + +### 3.1 背景、目标与非目标 + +必须回答: + +- 当前问题是什么,谁受到影响。 +- 本次要达成的业务结果和可观察结果是什么。 +- 本次明确不做什么,为什么不做。 +- 当前系统有哪些不能忽略的约束。 + +### 3.2 现状与改造范围 + +- 标出当前入口、主要调用链、现有表和已有接口。 +- 列出新增、修改、废弃的模块和接口。 +- 说明是否触发 DDD 迁移,以及选择复杂写、简单写或 Query 通道的依据。 +- 禁止只写理想结构而不说明与旧代码如何共存。 + +### 3.3 架构与关键流程 + +按问题选择图,不为凑数量画图: + +| 图 | 适用问题 | +|----|----------| +| 系统上下文图 | 谁调用谁、系统边界和外部依赖是什么 | +| 组件图 | Handler、Application、Domain、Repository、队列等如何协作 | +| 流程图 | 条件分支、人工操作和业务步骤如何推进 | +| 状态图 | 哪些状态允许转换,哪些是终态 | +| 时序图 | 事务边界、异步事件、回调和失败重试的先后顺序 | +| 数据关系图 | 新增多张表且关系仅靠文字难以理解 | + +图使用 Mermaid,图下必须补充关键约束。图不能替代状态枚举、接口字段和异常规则。 + +### 3.4 后端技术方案 + +至少包含: + +- 领域或模块边界,以及核心不变量归属。 +- 数据模型、索引、唯一约束、迁移和历史数据处理。 +- API 请求、响应、错误码、权限、分页和兼容策略。 +- 事务边界、并发控制、幂等策略和失败重试。 +- 异步任务的载荷、状态、重试、死信或人工补偿入口。 +- 查询性能、N+1 风险和必要的批量查询方案。 +- 审批类方案必须明确审批意见、附件、必填规则、不可修改审计和下载权限,不能只设计状态字段。 + +### 3.5 前端技术方案 + +不能只写“前端新增按钮”或“前端展示字段”。至少说明: + +- 涉及哪些端:后台管理、代理端、企业端、C 端 H5/公众号。 +- 页面入口、建议路由、页面间跳转和权限控制。 +- API 模块、请求参数、响应状态和刷新策略。 +- 表单、列表、详情、弹框、上传和异步任务的交互状态。 +- 加载、空数据、重复提交、部分成功、失败、重试和过期状态。 +- 金额单位、时间格式、枚举显示和后端字段的唯一口径。 +- 滚动发布时说明新旧前后端兼容方式;停机发布时说明维护页、版本一致性和开放访问前检查。 + +当前仓库不包含前端源码时,方案可以保持框架无关,但必须标明需要前端仓库确认的实现映射,不能凭空指定状态库或组件库。 + +### 3.6 非功能设计 + +按实际风险覆盖: + +- 权限和数据范围。 +- 敏感字段与导出控制。 +- 审计日志。 +- 性能目标和容量假设。 +- 日志、指标、告警和人工排查入口。 +- 外部依赖不可用时的降级策略。 + +### 3.7 发布、回滚与兼容 + +至少回答: + +- 数据库、后端、Worker、前端和配置按什么顺序发布。 +- 是否需要先加字段、双写、回填、迁移存量进行中业务或兼容旧接口。 +- 如何关闭新入口或停用新流程。 +- 回滚时哪些数据不能删除,哪些状态需要人工处理。 +- 滚动发布时说明新旧版本并存风险;停机发布时说明如何阻止新写入、迁移存量任务并确认整套版本一致。 + +### 3.8 风险、待决策与验收 + +- 风险必须写触发条件、影响和应对方式。 +- 待决策项必须给出推荐方案、备选方案和最晚确认时间。 +- 验收使用本项目允许的人工方式:接口调用、PostgreSQL 数据核对、日志检查和页面操作。 +- 不得用“后续再看”掩盖会影响数据模型、接口契约或安全边界的问题。 + +--- + +## 四、评审检查清单 + +### 业务与范围 + +- [ ] 目标、非目标和验收结果明确。 +- [ ] 原始需求中的歧义已经决策或明确列为阻塞项。 +- [ ] Phase 1 与后续阶段边界清晰。 + +### 架构与数据 + +- [ ] 现状调用链和目标调用链都能解释。 +- [ ] DDD 使用有业务理由,没有为简单 CRUD 强行建模。 +- [ ] 状态、事务、并发、幂等和最终一致性闭环。 +- [ ] 审批意见、附件和业务凭证边界明确,历史操作不可被覆盖。 +- [ ] PostgreSQL DDL、索引和历史数据策略可执行。 +- [ ] 存量进行中业务有明确的回填、结束或人工处理方案,不会因旧入口下线而悬空。 + +### 前端与接口 + +- [ ] 前端页面、状态和异常分支完整。 +- [ ] API 路径使用项目真实前缀,字段和枚举一致。 +- [ ] 权限不依赖前端隐藏按钮。 +- [ ] 异步任务和审批完成后有明确刷新或轮询策略。 + +### 上线与运维 + +- [ ] 发布顺序、功能开关或停用方式明确。 +- [ ] 回滚不会删除历史审批、资金流水或审计记录。 +- [ ] 关键失败能够通过日志、任务状态或管理页面定位。 + +--- + +## 五、合格标准 + +“业内合格”不等于文档很长,而是评审人能基于文档回答以下问题: + +1. 方案是否解决了正确的问题。 +2. 前后端和外部系统的边界是否一致。 +3. 正常路径、异常路径和并发路径是否闭环。 +4. 数据是否可追溯,资金和权限是否安全。 +5. 能否分阶段发布,失败后能否止损和恢复。 +6. 实施人员是否还需要自行猜测关键业务规则。 + +任一关键问题仍需实施人员自行猜测时,方案只能保持“待评审”,不能视为评审通过。