10 Commits

Author SHA1 Message Date
43bcb0e4ae 测试环境自动部署
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 11m35s
2026-07-22 15:18:56 +09:00
2fd11daaf0 实现店铺联系电话精确查询与索引 2026-07-22 15:06:06 +09:00
751cb46079 修复店铺列表参数校验与企业权限 2026-07-22 13:13:38 +09:00
d4d6e91256 本地测试应该走7的db 2026-07-22 13:04:17 +09:00
696d272cb5 一些规则 2026-07-22 12:48:21 +09:00
21702da413 创建相关issues 2026-07-22 12:37:05 +09:00
841ed1ceb0 同步相关内容 2026-07-22 11:34:20 +09:00
da9c805d89 更新一下prd 2026-07-22 11:08:04 +09:00
4902a02c87 更新一下 2026-07-21 15:26:07 +09:00
2823ff13bf fix: 修正排队顺延套餐激活时错误按下单时间计算生效日期
activatePendingUsage 被"前一个主套餐到期后顺延激活下一个待生效套餐"和
"等待实名认证后激活"两种场景共用,但其中 ExpiryBase=from_purchase 计时
基准分支(REALNAME-04)本来只为后者设计,却被无差别套用到前者。

导致主套餐配置为 from_purchase 且需要排队等待前一个套餐到期才能生效的
套餐,激活时错误地把生效时间算成下单时间,而不是真正开始生效的那一刻,
使到期时间提前了排队等待的天数,客户少享受了相应天数的服务。

现改为只有当 usage.PendingRealnameActivation 为 true(确实是在等实名)
时才按 ExpiryBase 选择计时基准,纯排队顺延场景一律使用当前时刻,即顺延
语义。
2026-07-20 11:58:01 +09:00
73 changed files with 7659 additions and 692 deletions

View File

@@ -24,7 +24,7 @@ export JUNHONG_DATABASE_SSLMODE="disable"
export JUNHONG_REDIS_ADDRESS="cxd.whcxd.cn"
export JUNHONG_REDIS_PORT="16299"
export JUNHONG_REDIS_PASSWORD="cpNbWtAaqgo1YJmbMp3h"
export JUNHONG_REDIS_DB="6"
export JUNHONG_REDIS_DB="7"
# ----------------------------------------------------------------------------
# JWT 配置(必填)

View File

@@ -3,7 +3,7 @@ name: 构建并部署到测试环境(无 SSH
on:
push:
branches:
- main
- Iteration/7-11
- dev
- test
@@ -30,7 +30,7 @@ jobs:
- name: 设置镜像标签
id: tag
run: |
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
if [ "${{ github.ref }}" = "refs/heads/Iteration/7-11" ]; then
echo "tag=latest" >> $GITHUB_OUTPUT
elif [ "${{ github.ref }}" = "refs/heads/dev" ]; then
echo "tag=dev" >> $GITHUB_OUTPUT
@@ -61,8 +61,8 @@ jobs:
docker push ${{ env.WORKER_IMAGE }}:${{ steps.tag.outputs.tag }}
docker push ${{ env.WORKER_IMAGE }}:${{ github.sha }}
- name: 部署到本地(仅 main 分支)
if: github.ref == 'refs/heads/main'
- name: 部署到本地(仅 Iteration/7-11 分支)
if: github.ref == 'refs/heads/Iteration/7-11'
run: |
# 确保部署目录存在(仅需日志目录,配置已嵌入二进制文件)
mkdir -p ${{ env.DEPLOY_DIR }}/logs

View File

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

View File

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

View File

@@ -0,0 +1,291 @@
# PRDTECH 七月迭代公共开发基础
Status: ready-for-agent
## Problem Statement
七月迭代同时包含企微审批、支付与充值、钱包入账、站内通知、卡状态事件、批量订购、设备批量分配、导出和低余额预警等需求。这些需求都需要可靠事件投递、重复请求防护、异步任务状态、动态配置、增量迁移和日志脱敏,但它们不应各自实现一套互不兼容的基础设施。
当前仓库已经具备可复用的基础GORM 显式事务、PostgreSQL 唯一约束和条件更新、钱包 `version` 乐观锁、Fiber `request_id`、Redis、Asynq 客户端与 Handler、部分任务的五态常量及进度字段以及请求 JSON 的递归脱敏。与此同时,公共 `tb_outbox_event`、受控 `tb_system_config` 尚未落地已有异步任务状态并不完全一致Redis 防重、状态条件更新、乐观锁和 Worker 抢占的职责没有形成统一契约Access Log 的响应体仍可能原样记录敏感字段,非 JSON 和敏感接口也缺少明确策略。
如果没有先冻结公共责任边界,将产生以下风险:
- 同一业务事务可能只写业务事实却丢失审计或异步事件,或者在事务内直接调用外部系统,造成不可恢复的不一致。
- 各业务建立不同 Outbox 表、Relay、重试状态和 Asynq 载荷,重复投递时缺乏稳定 `event_id`,消费者无法可靠幂等。
- 将 Redis 锁、`request_id`、状态条件更新、钱包版本号和 Worker 租约误当成同一种幂等机制,甚至把 Redis 当作最终业务事实。
- 批量订购、设备分配和导出各自创造不同的“部分成功”状态、错误结构和轮询语义,前端无法形成统一交互。
- 动态配置演变成任意 Key-Value 数据库,未经注册的 Key 可被写入,类型、值域、权限、缓存失效和审计无法保证。
- Access Log 在登录、Token、支付、企微回调和文件接口中泄露凭证、签名、支付链接、回调原文或文件内容。
- 公共基础设施需求侵入 Audit Event、Integration Log、站内通知或各业务领域最终重新合成一个无法独立交付的巨型需求。
本 PRD 的目标不是重新探索业务需求,也不是统一重构全仓库,而是把已评审通过的跨需求约定归拢成一个可先行交付、可被下游复用、所有权清晰的公共基础设施边界。
## Solution
交付一个边界明确的 `tech-public-foundation`,覆盖以下八类已确认能力:
1. 公共表、索引和约束的增量迁移护栏,以及上线前检查、失败退出和数据安全回滚边界。
2. 基于现有 GORM 用法的显式事务契约,使业务事实与契约要求的 Audit Event 或 Outbox 在同一事务提交。
3. 权威公共 `tb_outbox_event`、Relay、领取租约、重试、监控、恢复及结构化 Asynq 投递契约。
4. `request_id + 请求指纹`、PostgreSQL 唯一约束、状态条件更新、钱包 `version`、Worker 租约和 Redis 防并发的公共幂等原语与选择规则。
5. 面向业务任务和前端的统一五态、结果计数、错误摘要、失败明细、恢复与轮询语义,但不建设万能任务表。
6. 受控系统配置壳层,包括 Key 注册、校验、缓存、权限、API 和统一审计接缝。
7. Access Log 请求/响应递归脱敏和敏感接口安全摘要。
8. 加载、空态、权限不足、失败重试和异步任务恢复的前端公共交互契约;当前仓库不实现前端代码。
责任边界固定如下:
| 责任方 | 拥有内容 | 复用但不拥有 |
|---|---|---|
| `tech-public-foundation` | 公共 Outbox 与 Relay、公共幂等原语、系统配置壳层、统一异步状态、迁移护栏、Access Log 脱敏 | Audit Event、Integration Log、通知和业务消费者 |
| `tech-global-audit` | Audit Event、Integration Log、多视角 Query、审计前端、旧审计 Writer 一次性切换 | 公共事务接缝、迁移护栏和共享脱敏策略 |
| `tech-inapp-notifications` | 通知表、模板、接收人解析、Notification Worker、通知 API 和前端通知中心 | 公共 Outbox、Relay、Worker 租约和统一审计 |
| 各业务 PRD | 业务事件定义、业务状态机、业务唯一键、业务任务表、失败明细和消费者行为 | 公共 Outbox、幂等选择规则、五态任务契约和系统配置壳层 |
以上三项公共需求保持独立,不把公共基础、全局审计和站内通知重新合并为一个巨型基础设施需求。
## User Stories
1. 作为发布负责人,我希望每张公共表、每个公共索引和约束都有唯一迁移所有者,以便避免多个下游 PRD 重复创建或互相回滚。
2. 作为发布负责人,我希望迁移前检查能发现重复业务键、非法状态、空值、类型不兼容和未完成任务,并在不满足前置条件时明确失败退出,而不是带病上线。
3. 作为运维人员,我希望数据迁移可重入、可观测,并能区分“可安全回滚结构”与“只能停止生产者后向前修复的数据事实”。
4. 作为业务开发者,我希望继续使用现有 GORM 显式事务在一个清晰边界内提交业务事实、Audit Event 或 Outbox而不必引入 UnitOfWork、工厂层或迁移未触碰模块。
5. 作为业务开发者,我希望通过一个权威 Outbox 模型发布事件,稳定携带 `event_id``event_type`、聚合/资源定位、`request_id``correlation_id` 和结构化载荷。
6. 作为业务开发者,我希望 Outbox 写入失败会令业务事务整体回滚,事务成功后即使 Asynq 暂时不可用,事件仍可恢复投递。
7. 作为 Relay 运维人员,我希望多个 Worker 能并发领取事件但不会长期重复处理同一行Worker 崩溃后过期租约可以自动恢复。
8. 作为 Relay 运维人员,我希望看到待投递量、最老积压时长、投递速率、重试次数、租约过期数和最终失败数,并能按受控流程恢复失败事件。
9. 作为事件消费者,我希望重复投递始终携带同一个 `event_id` 和业务关联标识,以便用业务状态、唯一约束或消费记录实现自己的幂等。
10. 作为 API 调用方,我希望同一作用域内相同 `request_id` 和相同请求指纹返回原结果,而同一 `request_id` 携带不同业务内容时获得明确冲突。
11. 作为资金业务开发者,我希望钱包 `version`、唯一流水和事务边界继续作为资金正确性来源,公共幂等能力不会用 Redis 锁替代资金约束。
12. 作为状态机业务开发者,我希望状态条件更新负责状态流转幂等,更新未命中时按当前状态判断“已处理、冲突或资源不可见”。
13. 作为批量任务开发者,我希望继续拥有本业务的任务表和逐项失败明细,同时复用固定的五态、结果计数、租约恢复和轮询语义。
14. 作为前端用户,我希望任务出现部分成功时仍显示“已完成”,并通过总数、成功数、失败数和失败明细了解结果,而不是看到一个新的“部分成功”状态。
15. 作为前端用户,我希望刷新页面后可以通过 `task_id` 恢复任务进度,页面隐藏时停止轮询,重新可见时立即刷新。
16. 作为超级管理员,我希望查询按模块组织的受控系统配置,并通过与类型匹配的控件更新已注册且允许修改的 Key。
17. 作为安全负责人,我希望未注册配置默认不可写,错误类型、越界值和只读配置更新均被拒绝并留下统一审计。
18. 作为业务模块开发者,我希望只注册本模块的配置 Key、类型和值域公共基础负责存储、缓存、权限和审计但不接管具体业务校验。
19. 作为运维人员我希望系统配置缓存失效失败会告警Redis 故障时读取可回退 PostgreSQL而不会把缓存当作唯一事实。
20. 作为安全负责人,我希望 Access Log 对请求和响应进行同一套递归脱敏,且敏感接口无法因解析失败而回退记录原文。
21. 作为排障人员,我希望脱敏后仍保留 `request_id`、方法、路径、安全查询参数、状态码、耗时、用户与终端信息及截断标记,以便关联问题。
22. 作为前端用户,我希望公共页面都有一致的加载、空态、权限不足、失败和重试反馈,不把权限不足伪装成空数据。
23. 作为测试人员,我希望通过真实 Fiber、GORM、PostgreSQL、Redis、Relay 和 Asynq Handler 验证公共外部行为,而不是依赖私有函数或目录结构。
24. 作为下游需求负责人,我希望清楚知道公共基础提供什么、不提供什么,以及哪些契约必须先冻结,避免为赶进度复制临时基础设施。
## Implementation Decisions
### 1. 架构与责任边界
- 采用触碰式渐进迁移。公共能力放在可复用的 Application、Persistence、Queue 和 Middleware 接缝中,但不主动迁移未被七月需求触碰的旧 Service。
- 复杂写操作仍由各业务 UseCase 和 Domain 收口业务不变量;简单写操作可使用 Application 事务脚本;读取继续由 Query 负责。公共基础不创建新的业务聚合。
- 不引入 Java 风格 UnitOfWork、事务工厂、Repository 工厂或全仓事务抽象。现有 GORM `Transaction` 用法和显式传递事务句柄是权威基础。
- 公共基础提供契约、模型和运行机制;事件含义、业务状态、消费者副作用、业务唯一键和失败明细始终由业务 PRD 所有。
- 数据库关联使用 ID 显式维护,不建立外键约束,不通过 GORM 关联标签扩大耦合。
### 2. 增量迁移与发布基础
- `tech-public-foundation``tb_outbox_event``tb_system_config` 及其公共索引、唯一约束和必要初始化数据的唯一迁移所有者。
- `tech-global-audit``tech-inapp-notifications` 和各业务 PRD 分别拥有自己的表、字段、业务索引、业务唯一约束和数据迁移。公共基础不得接管所有业务迁移。
- 发布清单必须维护数据库对象所有权。同一表、字段、索引或约束只能在一个迁移中创建或修改;下游只能声明依赖,不得复制公共 DDL。
- 每个增量迁移包含正向和回滚边界。结构创建、兼容字段和未产生业务数据的初始化可按验证结果回滚已经产生的业务事实、Audit Event、Outbox、通知和任务结果不得通过降级删除。
- 上线前检查至少覆盖:目标对象是否存在且定义一致、唯一键冲突、必填字段空值、枚举非法值、待处理/处理中任务、未投递 Outbox、长租约和依赖版本。任何破坏正确性的异常都必须非零退出并阻断发布。
- 数据回填按稳定主键分批、可重复执行并记录进度;重复执行不能生成重复事实。`IF NOT EXISTS` 只能用于安全重入,不能掩盖已有对象定义不一致。
- 正向迁移完成后执行后置校验,包括约束生效、行数守恒、异常计数归零、关键索引可用和读写冒烟。检查输出只包含计数与安全标识,不泄露敏感数据。
- 推荐发布顺序为:迁移及前后置检查、兼容 API、Outbox Relay/Worker、依赖消费者、前端。生产者不得早于消费者和监控就绪而开始制造不可见积压。
- 回滚优先顺序为:停止新写入和 Relay 领取、保留已有事实与 Outbox、回滚无数据风险的应用版本、修复后向前恢复。公共表已有生产数据后不允许通过删除表完成回滚。
- 发布必须设置停止条件迁移异常、Outbox 持续积压或租约大量过期、系统配置读写不一致、Access Log 脱敏回归失败、关键任务无法恢复时停止继续放量。
### 3. GORM 事务边界
- 事务由 Application UseCase 或简单写事务脚本开启Handler 不拼接事务逻辑Domain 不依赖 GORM。
- 一次事务只包含需要原子提交的 PostgreSQL 写入。业务事实与契约要求的 Audit Event、Outbox 必须使用同一事务句柄;任一写入失败,整体回滚。
- 是否写 Audit Event、Outbox 或两者,由业务契约决定:需要同步查询的不可变审计事实写 Audit Event需要跨进程消费的可靠事件写 Outbox同一用例同时需要时两者同事务写入。Audit Event 的模型和 Writer 仍归 `tech-global-audit`
- Redis、Asynq、HTTP、企微、支付、Gateway、运营商和对象存储调用不得放进数据库事务。事务提交后由 Relay 或后置动作执行;缓存失效在提交成功后发生。
- 事务内生成并持久化稳定 `event_id`、业务唯一键和必要快照,禁止由 Relay 或消费者在重试时重新生成身份标识。
- 事务应短小,避免在事务内解析大文件、渲染报表或执行慢查询。并发正确性由唯一约束、条件更新、行锁或版本号承担,而不是扩大事务范围。
### 4. 公共 Outbox
#### 权威模型
- 全仓只使用公共 `tb_outbox_event` 表承载需要跨进程可靠投递的领域/应用事件。业务模块不得再创建自己的 Outbox 表或 Relay。
- 权威字段语义至少包括:内部主键;全局稳定且唯一的 `event_id`;稳定的 `event_type`;载荷版本;来源聚合类型与标识;主要资源类型、标识和可选业务键;`request_id``correlation_id`;结构化 JSON payload投递状态重试次数下次可领取时间租约所有者和过期时间最后错误码与脱敏摘要创建、更新和成功投递时间。
- Outbox 状态是 Relay 内部生命周期,使用整数:`1=待投递、2=投递中、3=已投递、4=投递失败`。它不等同于面向用户的五态业务任务,也不产生“部分成功”状态。
- `event_id` 建立最终唯一约束;领取路径按状态、下次可领取时间和租约到期时间建立索引;聚合/资源和关联标识建立满足排障与恢复的查询索引。所有索引归公共基础所有。
- `event_type` 和 payload schema 归发布事件的业务 PRD 所有公共基础只要求事件类型稳定、payload 带版本且能够向后兼容。禁止把任意业务对象完整序列化后无约束写入。
- `request_id` 表示本次入口请求或命令标识;`correlation_id` 表示跨事务、跨队列和外部交互的业务链路。没有 HTTP 请求的定时任务使用稳定命令标识,并以其或 `event_id` 建立关联链路。Relay 和消费者必须原样传播这些标识。
#### 写入、Relay 与至少一次投递
- 业务 UseCase 在保存业务事实的同一 GORM 事务内插入 Outbox。不能先提交业务再补写 Outbox也不能在事务内直接入 Asynq。
- Relay 按小批量领取到期的待投递或可重试事件,通过数据库条件更新/跳锁机制取得有期限的处理权。领取、续租、完成和失败都必须校验当前状态与租约所有者。
- Relay 将公共事件信封作为 struct 或 map 调用项目 `EnqueueTask`;禁止传入预序列化 `[]byte`,避免二次序列化成为 Base64 字符串。直接使用 Asynq 原生任务构造器的既有代码不属于该调用方式,必须自行且只序列化一次。
- Asynq 入队成功后将 Outbox 标记为已投递。若进程在“入队成功、数据库标记前”崩溃,同一事件会再次投递,这是被接受的至少一次语义。
- 入队失败记录安全错误码与摘要,按有上限的指数退避设置下次领取时间;达到最大重试或判定永久错误后保持失败并告警,不删除记录。
- 处理中 Worker 超过租约未完成时,可由恢复扫描重新置为可领取;恢复保留原 `event_id`、payload 和关联标识,不生成新事件。
- 受控重放只能作用于明确选择的失败/滞留事件,记录操作者、原因和批次,不修改已投递事件内容。重放仍使用原 `event_id`,因此消费者必须幂等。
- 监控至少提供待投递量、最老待投递年龄、处理中与过期租约数、投递成功率、重试分布、最终失败数和按 `event_type` 的积压。阈值越界进入现有告警通道。
- 公共 Outbox 只保证“事件最终至少被送达队列”不保证业务副作用只发生一次。消费者仍需用业务状态、PostgreSQL 唯一约束、消费记录或稳定业务键实现自己的幂等。
### 5. 公共幂等原语
- 公共基础提供选择规则和可复用构件,不建设要求所有请求进入同一张表的万能幂等平台。
- 创建类命令采用调用方稳定提供的 `request_id`作用域至少包含调用主体与操作类型。请求指纹由会影响业务结果的规范化字段计算排除时间戳、签名、Token 等易变传输字段,并带算法版本。
- 同一作用域内,`request_id + 相同指纹` 返回已存在结果;`request_id + 不同指纹` 返回幂等冲突,不覆盖原事实;并发首次写入由 PostgreSQL 唯一约束裁决。
- PostgreSQL 唯一约束、业务状态和账务流水是最终正确性来源。应用层预查只用于友好返回,不能替代数据库约束。
- 状态流转使用 `WHERE 当前状态=预期状态` 的条件更新;影响行数为零时重新读取当前状态,区分已完成、非法转换和统一资源不可见语义。
- 钱包余额、冻结金额和其他并发数值写使用 `version` 乐观锁或业务明确要求的行锁并与唯一流水、业务事实、Audit Event/Outbox 同事务。公共基础不拥有钱包规则。
- Worker 租约只解决“谁在这一时刻处理”,不证明业务副作用未发生。领取条件、租约所有者、过期时间和完成条件必须落在 PostgreSQL消费者仍执行自己的业务幂等检查。
- Redis `SETNX`、分布式锁和短期防重键只用于减少重复并发和热点压力。Redis 缺失、过期、故障或主从切换不能造成重复业务事实;锁必须设置过期并确保释放。
- 稳定 `event_id` 解决事件身份,稳定业务唯一键解决副作用身份,`request_id` 解决入口命令身份,三者不得混为一个万能键。
### 6. 统一异步任务契约
- 面向业务和前端的任务状态固定为:`1=待处理、2=处理中、3=已完成、4=已失败、5=已取消`。响应同时提供对应中文状态名。
- “已完成”表示任务已到达处理终点,不表示每个业务项都成功。部分成功由 `total_count``success_count``failed_count` 表达,禁止增加“部分成功”状态。
- 业务数据逐项完成但存在业务校验失败时,任务状态为已完成;只有文件无法解析、任务无法建立、关键基础设施持续失败或整体执行无法到达业务终点时,任务状态才为已失败。
- 每类任务继续拥有自己的业务任务表、业务项表和失败明细。批量订购、设备批量分配和导出不得为了统一状态而迁移到一张万能任务表。
- 公共查询语义至少统一 `task_id`、状态与状态名、总数/成功数/失败数、进度、脱敏失败摘要、开始时间、完成时间和更新时间。失败明细由业务定义字段并分页或受限返回。
- 失败摘要使用稳定错误码与用户可见中文说明,不暴露 SQL、堆栈、外部密钥、回调原文或内部网络信息。可下载失败文件时只返回受控短期访问能力不写永久公开地址。
- 待处理任务通过条件更新领取为处理中处理中任务必须具有租约或等价的可恢复执行记录。Worker 崩溃、进程重启或队列重复投递后,过期任务可以重新领取并从业务事实恢复。
- 终态只能通过满足预期状态的条件更新进入。重复 Handler 看到已完成、已失败或已取消时不得重新制造业务副作用。
- 取消只适用于业务明确支持取消的任务;不支持取消的业务仍返回五态中的实际状态,不能把失败伪装成取消。
- Asynq 载荷使用最小结构化标识,通常只包含 `task_id`、必要分片标识和关联标识;调用 `EnqueueTask` 时必须传 struct 或 map禁止传 `[]byte`
### 7. 公共系统配置
- 新建公共 `tb_system_config`,至少保存唯一 `config_key`、字符串化 `config_value``value_type`、所属模块、中文说明、只读标记、创建/更新人与时间。`value_type` 限定为 `string``int``bool``json`
- Key 使用稳定的 `module.group.name` 命名。代码中的受控 Key 注册表是可写配置的权威来源,定义 Key、模块、类型、值域/枚举、默认值、是否只读、是否敏感和前端控件提示。
- 未注册 Key 默认不可写;数据库中已存在但未注册的 Key 最多按只读、可诊断方式展示。禁止通过 API 创建任意 Key禁止提供原始 JSON 自由编辑器把它扩展成通用 Key-Value 配置中心。
- 注册表重复 Key、类型冲突或不合法默认值必须在启动或验证阶段失败不能以后注册者静默覆盖前者。
- 查询 API 为已认证超级管理员提供按模块过滤的配置列表,返回脱敏后的值、类型、值域、说明、只读状态和更新时间。更新 API 按 Key 修改单项配置,不提供无约束批量覆盖。
- 更新流程依次执行认证与超级管理员授权、Key 注册检查、类型解析、值域/业务边界校验、GORM 事务更新和统一 Audit Event。审计模型与 Writer 复用 `tech-global-audit`,公共基础不另建配置审计表。
- Redis Key 固定按配置 Key 生成默认缓存五分钟。PostgreSQL 是唯一事实来源;读取缓存未命中或 Redis 不可用时查询数据库并尝试回填。
- 配置更新提交成功后立即失效对应 Redis 缓存。失效失败不回滚已提交事实,但必须告警;短 TTL 限制旧值持续时间,后续读取可按版本/更新时间避免回填旧值。
- 数据库值无法解析、越界或读取失败时不得静默使用错误值。按注册策略使用最后一个已验证值或代码安全默认值,并产生可定位告警。
- UR#48 负责注册具体支付方式 Key、支付业务值域和启停校验本 PRD 只负责存储、注册、权限、缓存、API 和审计壳层。
### 8. Access Log 脱敏
- Access Log 对 query、请求体和响应体执行同一套递归脱敏覆盖嵌套对象与数组。字段匹配大小写不敏感并支持公共敏感字段注册表与路由级策略。
- 通用敏感字段至少覆盖密码/口令、Token、Authorization、Cookie、密钥/Secret、签名、Nonce、验证码、支付凭证和私密 URL。脱敏值不可逆不允许只遮盖中间几位后保留可复用凭证。
- 登录和 Token 接口:请求中的密码、验证码全部替换;响应中的访问令牌、刷新令牌、会话标识全部替换,只保留成功状态和必要主体标识。
- 支付接口:不记录支付凭证、银行卡敏感信息、二维码原文、支付跳转链接、渠道密钥和完整签名;保留安全订单号、渠道类型、结果码和金额等排障摘要。
- 企微回调不记录原始加密包、解密正文、签名、Nonce、通讯录敏感字段或完整回调响应保留事件类型、安全资源标识、载荷大小、摘要哈希和处理结果。
- 文件上传、下载和导出接口:不记录 multipart/binary、Base64 内容、文件字节、临时凭证和签名下载地址;只记录脱敏文件名、类型、大小、数量、任务标识和结果。
- 敏感路由的 JSON/XML/表单解析失败时采用“字段存在性、长度、内容类型、安全哈希和截断标记”的摘要策略,禁止回退记录原始 body。普通非敏感文本接口也必须经过路由策略后才可记录。
- 保留方法、路径、脱敏 query、状态码、耗时、`request_id`、IP、User-Agent、用户标识以及请求/响应摘要。请求体和响应体分别遵守现有 50KB 上限,先脱敏再截断,并明确记录截断状态。
- 脱敏器是公共可复用能力Access Log 中间件归本 PRD。Audit Event 和 Integration Log 的模型、Writer、查询、保留策略及其字段级脱敏仍归 `tech-global-audit`,本 PRD 不重复实现。
### 9. 前端公共交互契约
- 当前仓库没有前端源码,本 PRD 只冻结跨仓 API 与交互验收契约,不虚构前端目录、组件名或状态管理实现。
- 加载态:首次加载展示明确占位并阻止重复提交;已有数据刷新时保留可辨识的旧内容和刷新提示,不闪回空白或零值。
- 空态:只有成功请求且确实无数据时展示空态;筛选无结果与系统暂无数据使用不同文案,并提供清除筛选或返回入口。
- 权限不足:按统一 403 语义展示无权限,不展示重试按钮,不用 404/空列表泄露资源是否存在。
- 失败与重试:瞬时网络或服务错误保留用户输入和已有结果,展示安全错误摘要与显式重试;参数错误定位可修正字段,不自动无限重试。
- 创建异步任务成功后,前端保存 `task_id` 到刷新后可恢复的页面状态,不能只存在内存。重新进入页面时通过 `task_id` 或业务任务列表恢复最新状态。
- 轮询状态为待处理或处理中时继续;建议间隔按 2 秒、3 秒、5 秒逐步退避,最长 10 秒。到达已完成、已失败或已取消后停止。
- 页面进入隐藏状态时暂停轮询;恢复可见时立即刷新一次,再按当前状态恢复退避。网络恢复或页面刷新不能创建重复任务。
- 已完成且 `failed_count > 0` 时展示部分成功摘要和失败明细入口;已失败展示失败摘要与业务允许的重试/重建入口;已取消展示取消原因且不自动重建。
### 10. 可观测性与故障恢复
- 公共日志和指标使用 `request_id``correlation_id``event_id``task_id` 和安全资源标识串联,但不得把完整 payload 或敏感配置值作为标签或日志字段。
- Outbox、系统配置缓存和 Access Log 脱敏失败均需提供中文、可操作告警;告警内容包含组件、错误码、时间窗口和安全标识。
- Worker 和 Relay 重启后以 PostgreSQL 状态和租约恢复,不依赖进程内内存或 Redis 锁推断任务是否完成。
- 恢复工具只暴露受控的查询、重试和租约释放能力,所有人工操作写统一 Audit Event禁止直接删除 Outbox、篡改业务终态或跳过消费者幂等检查。
## Testing Decisions
### 1. 测试层级与真实接缝
- 系统配置通过真实 Fiber 路由、认证、Application、GORM 和 Redis 验证,覆盖查询、更新、超级管理员权限、未注册 Key、只读 Key、类型/值域错误、事务回滚、缓存命中与更新后失效。
- Outbox 通过“业务事务写入 → Relay → Asynq Handler → 可观察消费结果”的完整链路验证。断言基于数据库事实、队列可观察结果和消费者公开结果,不直接调用 Relay 私有函数完成测试。
- PostgreSQL 集成测试验证事务回滚、`event_id`/业务键唯一约束、状态条件领取、并发领取、租约恢复、重试调度和重复投递。
- Access Log 使用真实 Fiber 测试请求和响应,捕获最终 JSON 日志检查递归脱敏、敏感接口特殊策略、解析失败安全降级、50KB 截断及 `request_id`/状态/耗时保留。
- Redis 和 PostgreSQL 使用隔离测试数据、唯一前缀或独立测试空间,测试只清理自己创建的数据,不执行全库/全缓存清空。
- 不依赖真实支付、企微、Gateway 或运营商网络;外部边界使用可观察的测试 Adapter。公共链路仍使用真实 PostgreSQL、Redis 和 Asynq 组件。
- 只测试公共外部行为和稳定契约,不绑定私有函数、未导出类型或内部文件组织。
### 2. 迁移与事务测试
- 在空数据库和带兼容存量数据的数据库分别执行正向迁移、后置校验和允许的回滚,验证公共对象只创建一次、定义一致且迁移版本可继续前进。
- 构造重复 `event_id`、非法配置、唯一键冲突、处理中任务和未投递事件,验证前置检查明确失败退出且不执行破坏性写入。
- 数据回填中断后重复运行,验证已完成批次不重复、未完成批次继续、行数守恒且错误报告不含敏感值。
- 在业务事实、Audit Event、Outbox 任一步注入失败,验证同一 GORM 事务完全回滚;提交成功后再模拟 Redis、Asynq 或外部 Adapter 失败,验证业务事实不回滚且可恢复。
### 3. Outbox 与幂等测试
- 验证业务提交成功时 Outbox 与业务事实同时可见业务回滚时二者均不可见Relay 不读取未提交事务中的事件。
- 并发启动多个 Relay 领取同一批事件,验证同一时刻只有租约所有者可完成该行;终止 Worker 后等待租约过期,验证其他 Worker 使用原 `event_id` 恢复。
- 模拟入队成功但 Outbox 未标记成功,验证再次投递同一 `event_id`,消费者通过业务唯一约束或状态检查只产生一次业务副作用。
- 覆盖瞬时失败退避、最大重试、永久失败告警、受控重放、积压年龄和不同 `event_type` 的监控聚合。
- 直接向 `EnqueueTask``[]byte` 必须失败;传 struct/map 能由公开 Handler 正确解析。重复 Marshal 造成 Base64 的回归必须被测试阻止。
-`request_id + 指纹` 覆盖相同请求重放、同 ID 不同内容冲突、不同主体同 ID、并发首次提交和 Redis 不可用,验证 PostgreSQL 约束始终裁决最终结果。
- 分别验证状态条件更新、钱包版本冲突和 Worker 租约,证明三者只承担各自并发职责,不能相互替代。
### 4. 异步任务契约测试
- 对所有新接入的批量订购、设备分配和导出公开 DTO 做契约测试,状态只能为 1 至 5 且状态名一致。
- 覆盖全成功、部分成功、全部业务项失败、整体执行失败和取消:部分/全部业务项处理完均为已完成并由计数表达;整体无法执行才为已失败。
- 验证 `total_count = success_count + failed_count` 的业务终态守恒;若业务存在明确跳过项,必须在业务 PRD 中定义其归类,不能由公共层凭空新增状态。
- 验证失败摘要不泄露内部错误,失败明细分页/受限,终态重复消费不改变计数,过期处理中任务能够恢复。
- 前端契约验收覆盖加载、真实空态、筛选空态、权限不足、失败重试、2/3/5 秒退避、页面隐藏暂停、恢复立即刷新和通过 `task_id` 恢复。
### 5. 系统配置与脱敏测试
- 系统配置测试覆盖 `string/int/bool/json` 类型、枚举/范围校验、未注册和只读 Key、超级管理员与非授权用户、并发更新、审计事实和安全默认值。
- 模拟 Redis 未命中、超时、写入失败和失效失败,验证 PostgreSQL 仍为事实来源、更新不丢失、旧缓存有期限且告警可观察。
- Access Log 使用嵌套对象、数组、大小写变体、query、JSON/XML/表单、二进制、超长 body 和无法解析内容建立测试矩阵。
- 登录/Token、支付、企微回调和文件接口分别有固定回归样例日志中不得出现测试密码、Token、签名、Nonce、支付链接、回调原文、文件字节或临时凭证。
- 验证脱敏后仍保留方法、路径、安全 query、状态、耗时、`request_id`、用户标识、body 摘要和截断标记,可用于从 Access Log 关联到 Audit/Integration 记录。
## Out of Scope
- Audit Event 和 Integration Log 的具体模型、Writer、查询、前端、保留策略及旧审计 Writer 一次性切换;这些归 `tech-global-audit`
- 站内通知业务包括通知表、模板、接收人、Notification Worker、通知 API 和前端通知中心;这些归 `tech-inapp-notifications`
- 企微、支付、Gateway 和运营商 Adapter以及其签名、协议映射、回调业务和外部补偿逻辑。
- 钱包、退款、充值、支付、套餐订购、卡状态、低余额预警等领域规则、状态机、金额校验和业务消费者行为。
- 各业务表、业务索引、业务唯一键和业务数据迁移;它们继续由对应业务 PRD 所有。
- 所有旧模块的全仓事务重构、DDD 重构、Repository 重写或异步任务迁移。
- 万能任务表、万能幂等表、全请求幂等平台和任意 Key-Value 配置中心。
- 强制所有任务使用同一个业务任务模型,或把批量订购、设备分配、导出迁移到公共表。
- 精确一次投递承诺。公共 Outbox 提供至少一次投递,消费者幂等由业务负责。
- 在当前仓库实现前端组件或虚构前端目录;这里只冻结跨仓交互和验收契约。
- 真实支付、企微、Gateway、运营商网络或生产数据上的集成测试。
## Further Notes
### 已确认的仓库基础
- GORM 已关闭默认事务并普遍使用显式事务;公共实现应沿用这一方式,不新增平行事务框架。
- 项目 Asynq 客户端已经统一使用 Redis 配置,并拒绝 `EnqueueTask` 接收 `[]byte`;仍存在直接使用 Asynq 原生客户端并自行序列化的业务代码,迁移时需区分两种调用契约。
- Fiber 已生成并传播 `request_id`;仓库已有 PostgreSQL 条件更新、钱包 `version`、Redis 防并发和导出任务五态的可复用实践,但尚未形成完整公共契约。
- 当前没有公共 Outbox 和受控系统配置实现。Access Log 已对 JSON 请求递归脱敏并限制 50KB但响应体仍直接截断记录敏感非 JSON/文件接口也缺少安全降级策略。
- 当前仓库没有前端源码,前端交付由对应前端仓库按本 PRD 的公开契约验收。
### 下游依赖与阻塞关系
本 PRD 是公共契约和发布顺序的前置项,但只阻塞下游对公共接缝的集成与上线,不接管下游业务设计:
| 下游 PRD | 被本 PRD 阻塞的公共接缝 | 下游仍自行负责 |
|---|---|---|
| `tech-global-audit` | 公共迁移所有权、同事务写入接缝、关联标识和共享脱敏策略 | Audit Event、Integration Log、Query、前端和旧 Writer 切换 |
| `tech-inapp-notifications` | `tb_outbox_event`、Relay、结构化载荷和租约恢复 | 通知模型、模板、接收人、Worker、API 和前端 |
| UR#37 企微审批基础 | Outbox、至少一次投递、Worker 租约、关联标识 | 企微场景、实例状态机、扫码绑定和 Adapter |
| UR#34 代理充值 | `request_id + 指纹`、Outbox、两阶段投递和任务恢复 | 支付事实、充值状态机、钱包入账和唯一流水 |
| UR#94 卡状态事件与回调 | 公共事件信封、Outbox、结构化 Asynq 载荷 | 卡状态规则、事件类型、消费者和外部回调行为 |
| UR#97 钱包低余额预警 | Outbox 与可靠消费接缝 | 阈值规则、钱包变更事件、接收人和通知内容 |
| UR#36 批量订购 | 五态任务、计数、失败语义、幂等和租约契约 | 批量任务/明细表、逐行校验、订购行为 |
| UR#42 统一导出字段权限 | 五态、结构化任务载荷、轮询和恢复语义 | 导出 DataSource、字段权限、文件生成和业务任务表 |
| UR#49 设备批量分配 CSV | 五态、部分成功、失败明细和租约契约 | 设备分配规则、CSV 校验和业务任务表 |
| UR#38 代理主钱包授信 | GORM 事务、PostgreSQL 最终幂等和 `version` 职责边界 | 钱包规则、授信/扣款、流水与领域事件 |
| UR#48 支付方式配置 | `tb_system_config`、受控 Key 注册、缓存、权限、API 和审计壳层 | 具体支付 Key、值域、支付业务校验和生效规则 |
上述下游可以在公共契约稳定后并行开发自己的领域部分;在公共 Outbox、任务状态或系统配置接缝尚未可用时不得复制临时 Outbox、万能任务表、幂等表、通知表或配置中心作为替代。
### 交付判定
- 公共能力的公开契约、迁移所有权、故障恢复和可观测性全部通过本 PRD 的真实接缝测试后,才可认为公共基础就绪。
- `tech-global-audit``tech-inapp-notifications` 保持独立交付;公共基础就绪不等于审计或通知业务已经完成。
- 下游业务 PRD 的领域测试、外部 Adapter 测试和业务前端验收仍是各自发布门槛,不能用公共基础测试替代。

View File

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

View File

@@ -0,0 +1,285 @@
# PRDUR#34 代理在线扫码充值与平台线下代充值
Status: ready-for-agent
---
## Problem Statement
当前代理充值接口只创建本地充值记录,没有真正创建微信 Native 或支付宝 PreCreate 支付单并返回可供前端渲染的付款内容。在线回调直接在一次事务中尝试完成钱包入账,支付事实与钱包处理结果无法独立表达;回调也没有完整校验支付配置、金额、第三方交易号和业务关联。网络超时、回调丢失、重复回调、钱包事务失败和第三方迟到成功都缺少可靠恢复边界。
当前同一个创建接口还允许代理或平台提交目标 `shop_id`,平台可以替任意代理创建在线支付,容易混淆真实付款人和受益钱包。线下代充值仍通过本地 `offline-pay``reject` 和全局操作密码完成人工入账,没有接入已经冻结的企业微信审批公共能力;附件仍是字符串 Key 列表,审批结论、充值状态和钱包入账状态也未独立建模。
本需求必须把代理自主在线充值与平台线下代充值分成两条不可混用的路径,复用现有支付和钱包基础能力,保证第三方真实收款、企微审批结论、钱包实际入账和通知都可独立追踪,并在重复回调、重复 Worker、进程中断、查单未知和审批异常下保持资金不重不漏。
## Solution
保留代理充值资源,建立两条严格隔离的创建路径:代理只为当前店铺主钱包选择 `wechat``alipay` 发起在线充值,不提交目标店铺,也不进入审批;平台或超级管理员只为指定代理店铺发起 `offline` 线下代充值,提交固定金额、备注和 15 个结构化付款凭证,并接入 UR#37 的真实企业微信审批。
在线充值为每次用户主动创建生成新的充值单和支付单。微信 Native 或支付宝 PreCreate 返回的字符串或 HTTPS URL 通过 `qr_content` 原样返回,前端自行渲染二维码,后端不生成二维码图片。前端支付状态轮询只读取本地状态;后端通过支付回调和受控查单任务同步第三方真实状态。支付成功先可靠固化收款事实,再由独立 Worker 幂等增加代理主钱包并写唯一流水,钱包失败不回滚支付成功事实。
线下充值创建时把充值单、唯一企微审批实例和提交 Outbox 原子落库。企微只能同意或拒绝固定金额;同意后复用与在线充值相同的钱包入账 Worker拒绝后原单终结。无论在线还是线下只要主钱包实际入账成功都向目标代理发送防重的站内到账通知。
## User Stories
1. 作为代理账号,我希望只为当前所属店铺的主钱包充值,不需要也不能选择目标店铺。
2. 作为代理付款人,我希望在创建时选择微信或支付宝,并在订单创建后不能切换支付方式。
3. 作为代理付款人,我希望每次主动点击创建支付都得到一张新的充值单和支付单,而不是复用相同金额的旧单。
4. 作为代理付款人,我希望网络重试不会为同一次提交创建多张充值单。
5. 作为代理付款人,我希望后端返回支付平台给出的付款字符串,由前端稳定渲染二维码。
6. 作为代理付款人,我希望页面不展示一个本地猜测的精确失效倒计时,以免把第三方实际已经失效的码显示为有效。
7. 作为代理付款人,我希望关闭页面后旧支付单继续等待回调或查单收敛,不因页面关闭而被取消。
8. 作为代理付款人,我希望即使创建了多张支付单,每张真实付款的订单都能分别准确入账。
9. 作为代理付款人,我希望付款完成后页面先显示第三方已收款,再准确展示钱包是否已经到账。
10. 作为代理付款人,我希望第三方已经收款但钱包暂时失败时,系统明确显示“支付成功、入账重试中”,而不是显示支付失败。
11. 作为代理付款人,我希望第三方成功回调晚于本地失败或关闭状态时,系统仍能核实并补充入账,不吞掉已付资金。
12. 作为代理付款人,我希望回调丢失时系统能够通过第三方查单补偿,而不要求我再次付款。
13. 作为代理付款人,我希望微信或支付宝不可用时页面只展示真实可用的支付方式。
14. 作为代理付款人,我希望钱包实际到账后收到一条站内到账通知。
15. 作为非主账号的在线充值提交人,我希望自己和目标店铺主账号都能收到到账通知。
16. 作为平台员工,我希望选择目标代理店铺、金额、备注和付款凭证发起线下代充值。
17. 作为平台员工,我希望线下代充值金额大于 0 即可,不受代理在线充值 100 元起付限制。
18. 作为平台员工,我希望使用本人绑定的企微身份提交线下充值审批,业务系统同时保存我是真实提交人。
19. 作为审批人,我希望企微表单展示充值单号、目标代理、固定金额、真实提交人、备注和付款凭证。
20. 作为审批人,我只需要同意或拒绝线下代充值,不能修改金额,也不需要回业务系统输入操作密码。
21. 作为平台提交人,我希望企微拒绝后原充值单只读终结;修正资料后通过创建入口提交一张新单。
22. 作为平台提交人,我希望审批通过和钱包实际到账是两个独立状态,不会把审批通过误认为余额已增加。
23. 作为目标代理主账号,我希望平台线下代充值实际到账后收到站内到账通知。
24. 作为真实业务提交人,我希望线下审批通过或拒绝继续收到 UR#37 的审批结果通知。
25. 作为代理管理者,我希望按既有店铺层级和充值查看权限查看本店及可管理下级店铺的充值记录,而不是只看自己创建的记录。
26. 作为代理查看者,我希望看到充值金额、付款方式、业务凭证、真实提交人、审批状态和入账结果,但看不到平台内部审批人、意见、审批附件或支付配置。
27. 作为平台财务,我希望钱包入账持续失败或审批通过后撤销时收到内部异常通知,以便人工处理。
28. 作为财务人员,我希望已经入账后发生企微通过后撤销时系统不自动扣减代理余额,避免未经核实的反向资金动作。
29. 作为系统维护人员,我希望支付回调、第三方查单、企微回调、企微轮询和重复 Worker 都进入统一幂等用例,不重复加钱。
30. 作为审计人员,我希望充值单、支付单、企微审批实例、代理主钱包、钱包流水、通知和外部交互能够完整关联。
31. 作为前端开发者,我希望在线支付、线下审批和钱包处理状态分别返回,不需要通过单个状态猜测业务阶段。
32. 作为验收人员,我希望本地自动化覆盖完整后台业务和异常恢复,真实微信/支付宝留到部署测试环境人工扫码,真实企微仍在实现阶段参与线下充值验收。
## Implementation Decisions
### 范围、依赖与架构边界
- UR#34 只负责代理在线充值、平台线下代充值、支付状态同步、企微终态消费、钱包入账、充值 Query 和前端业务页面。企微连接、平台账号绑定、模板版本、附件上传企微、提交、加密回调、轮询和审批详情复用 UR#37,不实现第二套企微客户端或审批状态机。
- 站内到账通知复用 TECH 公共站内通知;支付和钱包关键事实使用全局 Audit Event第三方支付与企微调用使用 Integration Log不新建充值私有审计或通知体系。
- 在线创建、支付确认、线下创建、企微终态消费和钱包入账均涉及远程调用、状态机、金额、并发与可靠事件,采用 `Handler → Application UseCase → Domain → Repository/Infrastructure` 的复杂写通道。充值列表、详情和轻量支付状态采用 Query 通道。
- 只迁移完成 UR#34 所需的最小完整充值用例。现有旧 Service 可以暂时作为迁移门面,但支付确认和钱包入账不变量不得一半留在旧 Service、一半放在新 Domain。
- 当前支付配置通常只有一套微信参数和一套支付宝参数。本期不建设多通道池、优先级、权重、自动切换或故障转移;代理选择的是支付方式,不选择也不感知具体支付通道。
### 角色与两条创建路径
- 代理账号只能创建 `payment_method=wechat|alipay` 的在线充值。目标店铺和主钱包从当前认证上下文确定;请求不接受 `shop_id`,代理不能给本店下级或其他店铺创建在线支付。
- 平台账号和超级管理员只能创建 `payment_method=offline` 的线下代充值,必须提交目标 `shop_id`,并受既有目标店铺数据范围和管理权限约束。平台与超级管理员不能替代理创建微信或支付宝支付码。
- 代理账号不能创建线下代充值;企业账号不能创建或访问任一代理充值路径。
- 创建权限和查看权限分开处理。创建路径使用上述严格角色规则;列表、详情和支付状态继续使用既有充值业务权限与店铺层级数据范围。
- 新创建不接受历史 `bank` 支付方式;历史 `bank` 记录只读兼容,不改变原事实。
### 可用支付方式接口
- `GET /api/admin/agent-recharges/payment-methods` 只供代理在线充值页面使用,返回当前真正配置完整且能够创建支付的 `wechat` 和/或 `alipay`
- `data` 固定包含:`methods:string[]``min_amount:int64=10000``max_amount:int64=100000000`。没有可用方式时 `methods=[]`,接口本身仍成功。
- 接口不返回商户号、应用私钥、支付通道、支付配置 ID或具体缺失的敏感配置。支付方式顺序固定为 `wechat``alipay`,仅保留可用项。
- 创建接口必须再次校验所选支付方式。配置在查询列表后失效时拒绝创建,不能只信任前端先前取得的列表。
- 微信只有在当前微信支付配置支持本需求所需的扫码预下单、回调验签和查单能力时才视为可用;支付宝只有在 PreCreate 所需参数完整时才视为可用。
### 创建接口与请求幂等
- 保留 `POST /api/admin/agent-recharges`,根据 `payment_method` 使用严格的判别请求契约。
- 在线请求只接受:`amount``payment_method=wechat|alipay``request_id``amount` 为分,范围 `10000100000000`;不接受 `shop_id`、付款凭证或备注。
- 线下请求只接受:`shop_id``amount``payment_method=offline``attachments`、可选 `remark``request_id``amount` 为分,范围 `1100000000``remark` 最多 500 字。
- 线下 `attachments` 必传 15 项,每项固定包含 `file_key``file_name``file_size`。后端使用现有真实对象存储元数据能力校验对象存在、当前上传主体、允许的文件类型和实际大小;请求元数据作为业务快照,不能替代对象事实。
- `request_id` 只防止同一次用户提交因超时、重试或重复点击产生重复业务单。幂等范围为真实提交账号与 `request_id`,并保存请求指纹;同账号、同 ID、同载荷返回原业务结果载荷变化返回冲突。
- 用户每次主动点击创建或拉起支付必须生成新的 `request_id`。后端每次创建全新的充值单、支付单和 `qr_content`,不按相同金额、支付方式或既有待支付记录复用旧单。
- 在线和线下创建都必须通过数据库唯一约束和请求指纹保证持久化幂等Redis 只能减少并发,不能成为唯一正确性依据。
### 在线充值创建与付款内容
- 在线创建先在同一 PostgreSQL 事务写充值单和 `tb_payment`。支付单增加稳定 `order_type=agent_recharge`,关联充值 ID、支付方式、金额、创建时支付配置 ID和支付单号充值单初始 `status=1`,支付单初始 `status=0`,处理状态为 0。
- 微信使用 Native 扫码预下单;支付宝使用 `alipay.trade.precreate`。支付宝不再返回 WAP 支付链接作为本需求的扫码实现。
- 微信或支付宝返回的二维码码串、协议字符串或 HTTPS URL 原样保存为 `qr_content` 并返回。前端使用二维码组件渲染;后端不生成、上传或返回二维码图片,也不新增“生成二维码”“重新生成二维码”接口。
- 创建成功的在线 `data` 至少包含:`recharge_id``recharge_no``amount``payment_method``qr_content``status/status_name``payment_status/payment_status_name``processing_status/processing_status_name``approval_source=none`。不返回 `expires_at``payment_channel` 或支付配置 ID。
- 本地计算时间不能代表第三方支付订单真实有效期。后台在线充值接口不返回 `expires_at`,前端不展示精确倒计时,也不根据本地时间判定二维码仍然有效。
- 用户关闭页面只停止当前页面展示,不调用支付取消或关单。此前支付单继续等待支付回调或后端查单自然收敛;用户再次主动创建得到独立新单。
- 多张在线支付单若都被第三方确认真实付款,每张分别进入幂等钱包入账,不能因为存在更新的支付单而吞掉旧单资金。
### 预下单失败和结果未知
- 第三方明确返回预下单失败时,支付单改为 `2=已失败`并保存稳定失败码/脱敏摘要,充值单改为 `4=已关闭`;创建接口返回统一支付错误。用户再次主动尝试使用新的 `request_id` 创建新单。
- 网络超时、连接中断或响应丢失导致预下单结果未知时,不能直接把原支付单判失败,也不能以同一个 `request_id` 创建另一张支付单。必须保留原业务事实,并使用创建时支付配置和原支付单号执行安全恢复。
- 结果未知恢复先向第三方查单:明确已支付则进入统一支付确认;明确不存在时可用同一支付单号重新预下单;明确未支付且第三方支持幂等恢复付款内容时保存并返回原支付单的 `qr_content`;仍无法判断时保持未知并继续可靠恢复,不伪造失败或成功。
- 同一 `request_id` 重试只能返回原充值/支付事实或当前稳定错误,不能创建新单。用户主动使用新 `request_id` 创建的新单不取消未知旧单,旧单仍需查单收敛。
- 预下单结果未知、恢复尝试和最终结论写 Integration Log外部原始响应、商户密钥和完整付款内容不得进入普通日志或审计详情。
### 支付状态、充值状态与处理状态
- `tb_payment` 状态统一沿用当前 Go 常量:`0=待支付``1=已支付``2=已失败``3=已退款`。本需求不新增“已关闭”支付状态;第三方明确关闭或失效时使用 `2=已失败`并保存结构化失败原因。
- 充值业务状态沿用 `16``1=待支付/线下待审批``2=已支付或审批通过后正在入账``3=已完成``4=已关闭``5=已退款``6=已驳回`。不新增 `7=已退回`
- 钱包处理状态统一为:`0=未触发``1=处理中``2=处理成功``3=处理失败`。所有响应返回对应 `processing_status_name`,不再提供同义字段 `wallet_posting_status`
- 在线记录另返回支付状态及 `payment_status_name`;线下记录没有第三方支付单,支付状态字段为不适用,不用伪造“已支付”。审批状态完全复用 UR#37 的公共企微状态。
- 状态类字段使用 `int`,方式/来源类使用 `string`。DTO description 必须从公共 constants 原文复制,不能按迁移注释或旧文档重新编号。
- 历史 `tb_payment` 建表迁移的注释和默认值与当前 Go `03` 常量不一致。实施前必须按状态、业务类型和创建入口盘点存量值;新迁移统一默认值、注释和约束到 `03`,遇到无法证明含义的存量值必须中止并输出异常清单,禁止盲目整体加减 1。
### 支付回调、查单补偿与迟到成功
- 微信、支付宝回调先完成渠道协议验签/解密,再按支付单号和 `order_type=agent_recharge` 分发到统一支付确认用例,不再只依赖充值单号前缀猜测业务类型。
- 支付确认必须核验:原支付方式、创建时支付配置、商户订单号、业务关联、第三方交易号、支付金额和第三方成功状态。金额或关联不一致时拒绝入账并记录严重 Integration Log/Audit Event不能把异常回调当成功。
- 第三方交易号必须具备持久化唯一性或等价防重;同一支付单重复回调、回调与查单并发、第三方重复通知都只固化一次支付成功事实。
- 前端每 3 秒调用轻量支付状态接口时只查询本地数据库,不直接请求微信或支付宝。页面不可见时暂停,恢复时立即刷新;到达本地终态或离开页面时停止。
- 后端可靠任务按受控、可运维调整的频率查询仍待支付或预下单结果未知的支付单。具体频率是实现配置,不成为前端或业务契约;任务必须有限流、租约和批次上限,不能形成无限并发查单。
- 查单与回调进入同一支付确认用例。只有第三方明确返回已支付、已关闭/失效或订单不存在时才推进对应本地结论;超时、限流和未知状态继续保持原状态并重试。
- 第三方明确关闭或失效且未支付时,支付单改为已失败,充值单改为已关闭。不得仅根据本地 `expire_at` 或页面停留时间关闭第三方订单。
- 本地曾因第三方明确关闭而标记失败后,如果又收到可验证的迟到成功回调,以真实收款事实为准:支付单改为已支付,原充值单进入入账流程。已关闭业务状态不能吞掉已付资金。
### 两阶段钱包入账与资金幂等
- 在线支付确认采用两阶段处理。第一阶段在一个事务中把支付单条件更新为已支付,保存第三方交易号和支付时间,把充值单更新为 `2=已支付``processing_status=1`,并写钱包入账 Outbox。事务成功后即可向支付渠道返回成功不等待钱包余额更新。
- 线下企微首次同意同样只把充值单推进到 `2``processing_status=1`并写钱包入账 Outbox企微同步线程不直接修改钱包。
- 钱包入账 Worker 在独立事务中锁定目标代理主钱包,使用钱包版本/条件更新增加余额,创建唯一充值流水,把充值单改为 `3=已完成``processing_status=2`并写资金 Audit Event。
- 钱包流水以充值单号或等价稳定业务键建立唯一约束。若流水已经存在而充值单尚未完成,重试只核验并补齐充值单状态和审计关联,不再次增加余额。
- Worker 通过处理状态、条件更新和有期限租约领取。失败时支付或审批事实保持不变,充值单 `processing_status=3`并保存脱敏错误摘要,由可靠任务重试;前端没有人工“再次入账”按钮。
- 重复支付回调、重复企微终态、重复 Outbox、Asynq 至少一次投递、租约过期和进程中断均不能重复加钱、重复流水或重复到账通知。
- 充值增加主钱包账面余额;钱包原余额为负时允许正常入账并自然冲减欠款,不因欠款状态拒绝充值。
### 线下代充值与企微终态
- 创建线下充值前必须确认 `offline_recharge_approval` 场景、当前模板和当前平台/超级管理员本人的企微绑定可用。任一前置不可用时,在充值单、审批实例和 Outbox 落库前失败,前端保留表单。
- 线下充值单、真实业务提交人快照、结构化申请附件、唯一企微审批实例和提交 Outbox 在同一 PostgreSQL 事务创建。远程企微异步提交;创建成功返回充值详情及 `approval_source=wecom`、审批“提交中”。
- 企微表单至少展示充值单号、目标代理店铺、充值金额、真实业务提交人、申请备注和 15 个付款凭证。金额提交后固定;企微只允许同意或拒绝,不提供金额编辑、退回修改或本地审批动作。
- 企微拒绝把原充值单条件更新为 `6=已驳回`,处理状态保持未触发,保存拒绝原因和审批快照。原单、凭证和拒绝原因只读保留;修正后必须通过创建接口生成新的充值 ID、单号、资料和企微审批。
- 系统不新增 `7=已退回`,不提供 `resubmit`,也不注册旧 `offline-pay` 或业务单级 `reject`。企微同意后自动入账,不校验全局操作密码。
- 企微在通过前撤销或删除时,充值单改为 `4=已关闭`且不入账,可以重新创建。企微通过后、钱包尚未入账时发生撤销,终止后续入账并关闭充值单。
- 钱包已经入账后再收到通过后撤销时,不自动扣减代理余额,充值单保持已完成;系统记录 `critical` Audit Event并向平台财务/运维发送异常通知,交由人工处理。
### 到账通知与异常通知
- 无论在线扫码还是平台线下代充值,只有钱包入账事务成功后才产生“充值到账”可靠事件;支付成功或企微同意本身不提前发送到账通知。
- 目标代理店铺主账号必须收到到账通知。在线充值的真实提交人若不是店铺主账号,也收到到账通知;同一账号同时符合多种身份时按“充值单 + 接收人”唯一键只生成一条。
- 平台线下充值提交人不因为提交身份收到代理到账通知,但继续通过 UR#37 收到审批通过/拒绝结果通知。若平台提交人同时也是目标代理接收账号,则仍按接收人防重。
- 通知至少展示充值单号、金额、支付方式/线下代充来源、到账时间和受控充值详情引用,不保存任意 URL。通知投递失败不回滚资金事务并由公共通知 Worker可靠重试。
- 入账持续失败、金额异常、重复第三方交易号和通过后撤销只通知有权的平台财务/运维,代理页面显示业务化处理状态,不暴露数据库、支付配置或第三方原始错误。
### 查询、权限与信息投影
- 保留 `GET /api/admin/agent-recharges``GET /api/admin/agent-recharges/{id}`,新增 `GET /api/admin/agent-recharges/{id}/payment-status`。列表、详情和支付状态必须使用相同认证、充值业务权限和店铺层级数据范围。
- 充值记录不按创建账号隔离。代理在具备充值查看权限时,可查看本店及有权管理的下级店铺记录;平台和超级管理员沿用既有数据范围;企业账号统一拒绝。
- 代理可见充值金额、支付方式、业务付款凭证、真实提交人、充值状态、企微状态和钱包处理结果;不可见企微审批人、内部意见、审批人附件、企微成员 ID、支付配置 ID、商户号、支付通道和内部失败详情。
- 平台和超级管理员只有同时具备充值业务查看权限时,才能读取 UR#37 提供的完整审批详情。企微运营权限不能绕过充值业务权限。
- 资源不存在与无权访问统一返回禁止访问语义,不能借列表、详情、支付状态或附件下载的差异探测其他店铺充值。
- 列表沿用 `page/page_size`,默认第 1 页、每页 20、最大 100默认 `created_at DESC, id DESC`。店铺、充值状态、支付方式、提交人和起止时间等筛选按 AND 组合,空参数不改变查询。
- 列表项与 UR#44 对齐,至少返回充值 ID/单号、店铺、金额、支付方式、真实提交人、充值状态及名称、`approval_source`、审批状态及名称、代理投影为空的当前审批人摘要、处理状态及名称、创建和完成时间。
- 详情返回完整充值业务资料、结构化申请附件、支付摘要、根级处理状态以及按主体投影的 `approval` 对象。在线固定 `approval_source=none``approval=null`;新线下固定 `approval_source=wecom`;历史本地审批使用 `legacy`,不伪造企微时间线。
- 轻量支付状态接口只适用于在线充值,至少返回:充值 ID/单号、充值 `status/status_name``payment_status/payment_status_name``processing_status/processing_status_name`、可空 `paid_at/completed_at`。不返回 `qr_content`、钱包余额、审批详情、`expires_at`、支付通道或配置。
### 数据模型、索引与迁移
- `tb_agent_recharge_record` 保留现有业务主表,并补充:`request_id`、请求指纹、结构化申请附件、真实提交人显示快照、`approval_instance_id`、处理状态/错误/开始/完成时间、处理租约、乐观锁版本及可靠执行所需字段。数据库不建立外键,也不使用 GORM 关联标签。
- 新线下附件使用结构化 `{file_key,file_name,file_size}` 列表保存。历史 `payment_voucher_key` 字符串或字符串数组只读兼容;迁移不能丢失旧凭证,也不能把短期预签名 URL 写成业务事实。
- `tb_payment` 增加 `order_type=agent_recharge` 常量及本需求需要的 `qr_content`、稳定失败码/摘要和预下单恢复事实。付款内容不得写普通访问日志API 仅在在线创建成功响应中返回。
- 充值记录对 `(user_id,request_id)` 建有效记录唯一约束并保存请求指纹;`approval_instance_id` 对新线下充值保持一单一审批唯一性;为待处理状态/租约、店铺+创建时间和支付单业务关联建立必要索引。
- 支付单号、第三方交易号和钱包充值流水分别建立符合其语义的唯一约束。软删除不能允许相同第三方资金事实再次入账。
- 数据库金额约束继续允许历史及线下 `amount>=1`;在线 `amount>=10000` 和两条路径的 `amount<=100000000` 必须在 Domain 中按支付方式重校验。不得用一个全局最低 100 元约束破坏线下小额代充。
- 发布前盘点充值状态、支付状态、重复第三方交易号、重复充值流水、孤立支付单、待处理线下单和历史附件格式。任何不确定资金记录进入异常清单,不由迁移脚本猜测完成状态。
### 停机发布、存量迁移与回滚
- 采用已确认的停机发布同时切换迁移、API、Worker、支付回调分发、企微终态消费和前端。新版本不保留旧本地审批兼容窗口。
- 历史已完成、已关闭、已退款和已驳回的线下充值只读保留并标记/投影为 `approval_source=legacy`,不补企微审批,也不伪造审批人或时间线。
- 发布时仍待处理的线下充值,只有在真实提交人可确认、平台账号已绑定企微且金额/付款凭证等资料完整时,才幂等创建真实企微审批继续处理;其余进入明确迁移异常清单,不自动入账,也不能再调用旧 `offline-pay`
- 上线前必须确认 UR#37 的线下充值场景和模板已发布、平台绑定可用、回调/轮询可达、钱包入账 Worker 和通知 Worker 已部署;条件不满足时不得开放线下创建入口。
- 回滚应用时保留已经创建的充值单、支付单、真实支付事实、企微实例、Outbox、Integration Log、Audit Event、钱包流水和通知。已进入第三方支付或真实企微的业务不能恢复旧本地按钮只能继续安全同步、重试或人工处置。
- 已经实际入账的资金不通过迁移或应用回滚自动反向删除;任何资金冲正必须是后续独立、受审计的业务流程。
### 统一错误与安全响应
- 所有接口使用统一 `{code,msg,data,timestamp}`;分页使用项目公共结构。金额始终使用分,时间使用项目统一时区/RFC3339格式。
- 请求字段、在线/线下金额、支付方式、备注或附件元数据非法使用 `CodeInvalidParam=1001`;未登录使用 `CodeUnauthorized=1004`;企业账号、错误创建角色、越权店铺及无权资源统一使用 `CodeForbidden=1005`
- 相同 `request_id` 的请求指纹不一致使用 `CodeConflict=1007`;状态不允许的业务动作使用 `CodeInvalidStatus=1050`;无可用支付配置使用 `CodeNoPaymentConfig=1175`;企微场景/模板/本人绑定不可用复用 UR#37 的稳定错误语义。
- 微信/支付宝明确预下单失败或恢复失败使用统一、可区分支付方式的支付错误;若现有错误码不足,新增公共错误码而不是在 Handler 拼接底层错误。结果未知返回稳定“支付创建结果确认中/请稍后重试”语义,不透传 SDK、HTTP、数据库或 Redis 错误。
- Handler 参数验证统一返回公共参数错误Application/Domain/Query 不向调用方返回 `fmt.Errorf`。日志、审计和响应不得包含私钥、Secret、商户签名、完整回调报文、对象存储永久凭证或企微敏感标识。
### 前端页面与交互
- 代理资金页保留充值入口。打开时获取可用支付方式,金额输入单位为元并明确最低 100 元、最高 100 万元;只展示后端返回的微信/支付宝选项,无可用方式时禁止提交并显示统一业务提示。
- 用户选择支付方式并点击创建时生成新的 `request_id`。同一次请求超时重试复用原 ID用户再次主动点击“创建支付”生成新 ID和新订单不搜索或复用旧待支付单。
- 创建成功后用 `qr_content` 渲染二维码,展示充值单号、金额、支付方式和本地支付/入账状态。不展示精确过期倒计时,不请求二维码图片,也不显示“取消支付”或调用“重新生成二维码”接口。
- 在线页面可提供“再次创建支付”用户动作,但它本质上重新调用创建接口并形成独立业务单;旧单在充值记录中继续自然收敛。
- 页面可见时每 3 秒读取本地轻量状态,隐藏时暂停、恢复时立即刷新。支付成功但入账处理中/失败时保留明确提示;入账完成后停止轮询、刷新现有钱包资金概况并展示成功。
- 平台线下创建页只展示目标店铺、金额、可选备注和 15 个付款凭证。提交中禁止重复操作;场景、绑定、附件或企微提交前置失败时保留表单内容。
- 线下创建成功进入充值详情,分为业务资料、企微审批和钱包处理结果三个区域。不展示本地确认、拒绝、退回、重提或操作密码输入框。
- 列表和详情按 `approval_source` 展示:`none` 审批列为“-”;`wecom` 展示只读企微状态;`legacy` 展示“历史审批”且无操作按钮。代理界面不展示审批人列或内部意见。
- 加载、空、网络失败、支付方式为空、支付创建失败、支付已成功但入账失败、企微提交中/失败、审批拒绝和异常撤销均有独立展示,不由前端自行推导或改写服务端状态。
## Testing Decisions
### 最高公共测试接缝
- 主要自动化接缝为Fiber 真实路由与认证 → Recharge Application/Domain/Query → GORM → 现有测试 PostgreSQL → Outbox/公开 Worker Handler → 代理主钱包、唯一流水、通知和统一审计。测试只断言公开响应和持久化业务事实,不断言私有函数或目录结构。
- 微信/支付宝网络边界使用可编程 Payment Adapter覆盖预下单、查单、回调确认和异常不在本地或 Agent 自动化中调用真实微信、支付宝。支付协议验签与基础能力优先复用 C 端已经验证的实现,本需求重点验证后台充值分发和业务闭环。
- 企微网络边界复用 UR#37 的可编程 WeCom Adapter并保留真实企微线下充值验收对象存储直接使用现有真实 S3 Provider不建立内存替身。
- 实现时沉淀可复用测试 Harness环境守卫、唯一运行标识、真实认证/Fiber 请求、受控支付与企微 Adapter、Outbox 捕获、公开 Worker 驱动、真实 S3、精确资源台账和失败清理并提供不含敏感信息的 HTTP/curl 冒烟模板。
### 环境隔离
- 本地开发和 Agent 自动化测试固定使用 Redis DB 7已部署测试环境继续使用 Redis DB 6。测试启动前必须核验普通 Redis Client、Asynq Client和 Worker Server 实际 DB任一不是 7 时立即失败。
- 自动化不得向 DB 6 投递任务,不执行 `FLUSHDB`、全前缀删除或清空队列。每次运行生成唯一 `run_id`,只清理本次实际创建的精确 Key和任务事实。
- PostgreSQL 继续使用现有测试数据库,不新建 Agent 专用数据库。所有账号、店铺、钱包、充值、支付、审批、流水和通知夹具使用唯一运行标识,只按实际主键精确清理;禁止 `TRUNCATE`、模糊删除或改写既有业务数据。
- S3 测试对象 Key包含唯一运行标识覆盖真实上传、Head/元数据、读取及精确删除。网络、鉴权或删除失败使测试失败;禁止按目录前缀或 Bucket 级清理。
- 自动化捕获待投递任务后直接调用公开 Worker Handler不通过 `sleep` 等待测试环境 Worker 抢任务。生产仍使用真实 Asynq 投递。
### 在线创建、支付和查单场景
- HTTP 创建覆盖:代理成功、平台/超管在线拒绝、企业拒绝、请求携带 `shop_id` 拒绝、微信/支付宝可用性、配置在列表后失效、金额 9999/10000/100000000/100000001、支付方式非法和支付方式创建后不可切换。
- 幂等覆盖:同账号同 `request_id` 同载荷返回原单,不同载荷冲突,并发重复只有一张充值单和支付单;新 `request_id` 在金额相同且旧单待支付时仍创建新单。
- Adapter 预下单覆盖微信 Native 与支付宝 PreCreate 成功、明确失败、超时但未创建、超时后第三方已创建、响应丢失、恢复仍未知和重复恢复;断言不会因同一请求再建支付单。
- 响应覆盖原样 `qr_content`、前端可渲染字符串/HTTPS URL、不返回 `expires_at`、支付通道或配置,不创建二维码图片文件。
- 回调覆盖签名/验签失败、订单不存在、业务类型错误、支付方式不符、配置不符、金额不符、第三方交易号冲突、业务关联错误、成功、重复成功和乱序通知。
- 查单覆盖明确待支付、已支付、已关闭/失效、不存在、未知、超时和限流;证明前端状态接口不调用 Adapter查单与回调并发只固化一次支付成功。
- 迟到成功覆盖支付单已失败、充值单已关闭后收到有效成功回调,最终仍只入账一次;多张独立支付单均成功时分别入账。
### 钱包、通知和审计场景
- 两阶段测试证明支付成功事务完成后即使钱包事务失败,支付单仍已支付、充值单为已支付、处理状态失败/重试,不能回到待支付或向渠道返回失败。
- 钱包覆盖正常余额、负余额自然冲减、乐观锁冲突、数据库失败、流水已存在但状态未完成、Worker 崩溃前/后、租约过期、重复 Outbox和两个 Worker 并发;余额、版本、唯一流水和完成审计只变化一次。
- 通知覆盖在线主账号、在线非主账号提交人、线下目标代理主账号、同一接收人身份重合、重复 Worker和通知投递失败每个充值单与接收人最多一条到账通知通知失败不回滚钱包。
- 审计覆盖在线创建、线下创建、明确预下单失败、结果未知、支付确认、第三方状态异常、钱包成功/失败、审批拒绝、撤销和通过后撤销;验证金额、状态前后值和关联 ID完整且不泄漏敏感配置。
### 线下企微场景
- 创建覆盖平台/超级管理员本人绑定成功、未绑定、场景暂停、模板失效、企业/代理拒绝、目标店铺越权、金额 0/1/100000000/100000001、备注边界、附件 0/1/5/6、对象不存在、其他主体对象和元数据不符。
- 事务测试证明充值单、结构化附件、唯一企微实例和提交 Outbox要么全部存在要么全部不存在相同 `request_id` 不产生第二条审批。
- 状态自动化覆盖提交中、审批中、同意、拒绝、撤销、删除、通过后撤销、重复/乱序回调和轮询并发;拒绝后原单不可编辑或重提,新申请产生全新业务事实。
- 入账前撤销验证任务被阻止且充值关闭;入账后撤销验证余额不自动扣回、充值保持完成,并产生严重审计和平台异常通知。
- 真实企微至少使用平台本人绑定身份创建一笔独立线下代充值,上传真实 S3付款凭证完成 `applyevent`、人工同意、加密回调或轮询同步、钱包入账、唯一流水、到账通知和审计核对。回调沿用“企业微信 → 用户中转应用 → 本地服务”原样转发,后端仍完成验签解密。
- 真实企微不要求每次人工制造撤销、删除或通过后撤销,这些异常由可编程 WeCom Adapter 自动化稳定覆盖。
### 查询、权限、迁移和前端验收
- 查询覆盖默认分页、最大页大小、稳定排序、多个筛选 AND组合、代理本店/下级范围、平台范围、企业拒绝,以及列表/详情/支付状态/附件使用同一权限投影。
- 信息投影分别以平台和代理读取同一线下充值:平台在具备业务权限时可见完整审批资料,代理只能看到业务资料和最小审批摘要;任何响应不返回支付配置、通道或内部错误。
- 迁移演练覆盖支付状态盘点、默认值/注释/约束不一致、历史终态 legacy、可迁移待处理线下单、缺少平台绑定或附件的异常清单、重复执行幂等以及旧 `offline-pay``reject``resubmit` 路由确实不存在。
- 前端验收覆盖可用方式加载、每次主动新建、二维码渲染、无倒计时、3 秒本地轮询、页面隐藏暂停、支付成功/入账处理中/失败、余额刷新、线下表单、企微只读详情、审批与到账两类通知、加载/空/失败状态。
- 本地自动化不要求真实微信或支付宝。部署到测试环境后由用户手工完成一笔微信 Native 和一笔支付宝 PreCreate 最低金额扫码,验证真实付款内容、回调/查单、本地支付状态、钱包入账和到账通知;该人工联调不阻塞本地实现完成门禁,但属于上线前验收。
- 完成门禁至少包括目标单元/集成测试、相关包测试、全量 Go 测试、并发/竞态专项、静态检查、迁移演练、OpenAPI 生成校验、真实 S3和真实企微线下充值。新增 Handler 时同步两个接口文档生成入口。
## Out of Scope
- 不建设多支付通道池、通道优先级、权重轮询、自动切换或故障转移。
- 不让前端选择或提交支付通道、支付配置 ID、商户号或目标在线充值店铺。
- 不生成二维码图片,不新增二维码生成、重新生成、主动取消支付或手工关单接口。
- 不向后台在线充值返回 `expires_at`,不展示本地推算的精确倒计时,也不以本地时间直接关闭第三方支付单。
- 不复用相同金额或未过期支付单;也不因新单创建而自动取消旧单。
- 不在本地或 Agent 自动化中调用真实微信、支付宝;真实扫码由部署测试环境人工验收。
- 不接入微信/支付宝自动退款,不因充值状态 5 扩展本期退款流程。
- 不允许代理线下代充值,不允许平台/超级管理员替代理创建在线支付,不允许企业账号访问。
- 不为新充值支持 `bank`,不允许创建后修改金额、目标店铺或支付方式。
- 不保留本地确认入账、拒绝、退回、重提、操作密码或人工重复入账按钮。
- 不新增 `7=已退回`、支付“已关闭”状态或 `wallet_posting_status` 同义字段。
- 不向代理公开企微审批人、内部意见、审批人附件、支付配置或内部异常详情。
- 不借 UR#34 重构 C 端全部支付、全仓钱包、全局支付配置或未触碰的历史订单模块。
## Further Notes
- 当前代码已经有代理充值表、主钱包和流水、统一 `tb_payment`、微信回调基础、支付宝 WAP 支付基础及支付配置,但代理充值创建只支持 `wechat/offline`,未创建支付单或返回付款内容;支付宝 PreCreate、微信 Native 后台充值、支付查单补偿和两阶段入账需要补齐。
- 当前支付配置模型把微信提供方和支付宝参数放在同一条全局生效配置中。UR#34 按现状复用,不把它扩成多通道路由系统。
- 当前微信 v3 已有查单/关单能力,微信 v2适配器缺少查单支付宝 SDK具备 PreCreate、TradeQuery和TradeClose。支付方式可用性必须以本需求实际需要的 Adapter能力为准不能只判断某个字段非空。
- 当前 `tb_payment` 迁移写的是 `14`,运行代码和 DTO使用 `03`;这是实施前必须通过存量核查解决的历史一致性问题,不是新 Agent可以忽略的文档差异。
- 当前旧线下充值使用字符串 Key列表、操作密码和本地 `offline-pay/reject`。新实现以本 PRD 和 UR#37公共企微契约为准,旧实现仅用于迁移事实核对,不代表目标行为。

View File

@@ -0,0 +1,219 @@
# PRDUR#35 退款企微审批与整单终结
Status: ready-for-agent
---
## Problem Statement
当前退款流程把退款申请、平台内审批、退款金额修改、钱包回款、佣金回扣和套餐失效混在同一个旧 Service 中。系统仍提供本地通过、拒绝、退回和重提接口,审批人能够修改实际退款金额;审批通过后又用进程内 Goroutine 分别处理佣金与套餐,失败后既不能可靠恢复,也可能错误地把未完成的处理标记为完成。
现有退款创建接口还要求调用方提交订单实收金额,并允许用单条套餐使用记录限定退款范围。这样会把本应由订单事实决定的数据交给前端,也与本期确认的“金额可以小于实收,但一旦通过就按整张订单终结”相冲突。代理退款查询当前按创建账号隔离,上级代理无法在既有店铺层级数据范围内管理下级退款;平台内部审批资料又缺少按主体投影,存在向代理泄漏审批人、意见或审批附件的风险。
退款涉及真实资金、负余额、已发放佣金、套餐队列、资产状态和外部企微终态。系统必须明确区分申请金额、实际完成金额、企微审批状态和本地业务处理状态,并保证重复回调、重复 Worker、进程中断和局部失败不会造成重复回款、重复扣佣或伪造成功。
## Solution
保留现有退款创建、列表和详情资源,但把新退款申请接入 UR#37 提供的企业微信审批公共能力。创建时后端从订单读取实收金额及相关快照,只接收申请金额、原因、备注和 15 个本地对象存储附件;金额提交后固定,企微审批人只能同意或拒绝。退款单、唯一企微审批实例和提交 Outbox 在同一 PostgreSQL 事务创建,接口立即返回本地退款及“企微提交中”状态。
企微同意后,无论申请金额是否等于订单实收金额,都启动整单退款终结:订单变为已退款,该订单产生的有效套餐全部失效,相关佣金全部失效;已发放佣金从对应佣金钱包全额扣回,余额允许为负。只有代理主钱包支付订单自动按原扣款流水回溯原钱包;微信、支付宝、线下及个人资产钱包订单均由财务先在系统外完成退款,再在企微同意,系统不调用支付渠道退款,也不回充资产钱包。
审批结论和本地业务处理分开保存与展示。企微已经同意后,即使资金、佣金或套餐处理失败,审批状态和退款状态仍保持已通过,失败由独立处理状态、错误摘要和可靠 Worker 重试表达。企微拒绝即终结当前退款;业务人员修正问题后若仍需退款,必须新建退款单,不能编辑或重提原单。
## User Stories
1. 作为代理退款发起人,我希望使用订单事实创建退款,而不需要自行填写订单实收金额或选择某条套餐使用记录。
2. 作为平台退款发起人,我希望填写固定的申请退款金额、原因、备注和业务凭证,并在提交后立即获得本地退款单号。
3. 作为退款发起人,我希望系统在申请金额不大于订单实收金额时允许提交,并明确告诉我本次通过后会整单终结。
4. 作为退款发起人,我希望同一订单存在审批中或业务处理未完成的退款时不能再次创建,避免并发退款。
5. 作为平台账号,我希望通过本人绑定的企微成员发起审批,确保企微中的实际发起身份可追溯。
6. 作为代理账号,我希望无需绑定企微,由配置的固定成员代提交,同时审批表单仍展示我才是真实业务提交人。
7. 作为审批人,我希望在企微看到退款单号、订单实收金额、可退款区间、本次申请金额、原因、备注、附件和真实提交人。
8. 作为审批人,我只能同意或拒绝固定金额,不能在审批时修改退款金额。
9. 作为业务人员,我希望金额填写错误时拒绝当前单并重新创建,而不是改写已经提交的业务事实。
10. 作为财务人员,我希望微信、支付宝、线下及资产钱包订单先在线下完成人工退款,再通过企微表达已确认完成,无需回到系统点击第二次确认。
11. 作为代理主钱包所有者,我希望企微同意后资金准确退回当时真实扣款的钱包,而不是按当前代理关系猜测退款钱包。
12. 作为欠款代理,我希望退款可以自然冲减主钱包负余额,而不是因余额为负而拒绝入账。
13. 作为个人客户,我希望资产钱包支付订单仍可申请退款,但系统不会错误地把资金自动充回资产钱包。
14. 作为佣金归属代理,我希望订单退款时未发放佣金停止发放,已发放佣金被完整扣回,且每笔变化可追溯。
15. 作为佣金归属代理,我接受退款回扣后佣金钱包余额为负,以真实反映已经支取但现应追回的佣金。
16. 作为套餐使用者,我希望退款通过后该订单生成的有效套餐全部失效,主套餐失效时其加油包也一并失效。
17. 作为资产使用者,我希望退款套餐失效后系统尝试激活下一条排队主套餐;没有可激活套餐时停止资产使用。
18. 作为退款发起人,我希望看到申请金额与实际退款金额的区别,避免把审批通过误认为所有本地处理都已完成。
19. 作为退款发起人,我希望企微拒绝后原退款单保持只读,并能从订单重新进入创建流程发起一张新退款。
20. 作为代理管理者,我希望按既有店铺层级和退款查看权限看到本店及有权管理的下级店铺退款,而不是只能看到自己创建的记录。
21. 作为代理查看者,我希望查看退款凭证、真实业务提交人、审批状态和业务处理结果,但看不到平台内部审批人、意见或审批人附件。
22. 作为平台查看者,我希望只有在具备退款业务查看权限时才能读取完整企微审批时间线和附件,企微运营权限不能绕过业务权限。
23. 作为运维人员,我希望审批状态、退款状态和业务处理状态分别展示,能准确识别“企微已通过但本地处理失败”。
24. 作为运维人员,我希望资金、佣金、订单和套餐步骤失败后可以可靠重试,且已经完成的步骤不会重复执行。
25. 作为审计人员,我希望退款、订单、钱包、资金流水、佣金、套餐和企微实例之间可以完整关联,并保留金额与状态的前后事实。
26. 作为审计人员,我希望自动退款、佣金失效、通过后撤销和人工异常处置都有明确原因与操作者,而不是只依赖自由文本备注。
27. 作为系统维护人员我希望企微重复回调、回调与轮询并发、Worker 重投和进程中断都不会重复回款或重复扣佣。
28. 作为系统维护人员,我希望企微撤销、删除和通过后撤销进入明确异常处置,不会自动放行新的退款或自动冲正已经完成的资金。
29. 作为前端开发者,我希望创建、列表和详情返回稳定的退款、审批及处理契约,不需要在页面推断资金是否已经完成。
30. 作为验收人员,我希望实现阶段同时通过可重复的企微 Adapter 自动化和真实企微同意/拒绝链路,而不是把真实企微验证推迟到后续联调。
## Implementation Decisions
### 范围、依赖与领域口径
- UR#35 负责退款申请、退款终态消费、整单退款终结、退款 Query 和前端业务页面;企微连接、身份绑定、模板版本、附件上传、提交、加密回调、轮询和审批详情由 UR#37 的公共能力提供,退款模块不得自行实现第二套企微客户端或审批状态机。
- “申请退款金额”是提交企微的固定金额;“实际退款金额”只表示资金已经实际完成。两者不因为整单终结而自动改成订单实收金额。
- “整单退款终结”表示订单、该订单生成的套餐和该订单佣金资格全部终结,不表示必须按订单全额向客户退款。即使申请金额小于订单实收金额,也不保留差额的第二次退款权利。
- 一张退款单只对应一条企微审批实例。企微拒绝后的再次退款是新的业务事实,必须有新的退款 ID、退款单号、申请资料、提交人快照和企微审批实例。
- 退款创建、企微终态消费、代理钱包回溯、佣金失效和套餐失效属于复杂写用例,采用 Application UseCase、Domain、Repository 与 Infrastructure Adapter 分层;退款列表和详情采用 Query 通道。只迁移完成本需求所需的最小完整退款用例,不主动迁移未触碰的订单、钱包或套餐模块。
### 创建退款契约
- 保留 `POST /api/admin/refunds`。允许超级管理员、平台账号和代理账号发起;企业账号不允许发起。所有主体仍受既有认证、退款业务权限、订单数据范围和越权防护约束。
- 请求只包含:`order_id``requested_refund_amount`;必填 `refund_reason`(最多 1000 字);可选 `remark`(最多 500 字);必填 `attachments`15 项)。
- 每个附件项固定包含 `file_key``file_name``file_size`。附件必须来自当前本地私有对象存储授权范围,后端按现有存储规则校验对象存在、上传归属、文件类型和实际大小;请求中的名称与大小作为申请快照,不能替代对象元数据校验。
- 新请求不再接受 `actual_received_amount``package_usage_id`。订单实收金额、订单号、资产、店铺、买卖方、支付方式和必要的资金关联事实由后端从有权限访问的订单读取并固化快照。
- 申请金额必须满足 `0 < requested_refund_amount <= 订单实收金额`。只允许对已支付且尚未整单退款的订单创建。
- 创建前必须确认 `refund_approval` 场景、当前模板和本次企微发起身份可用。平台/超级管理员使用本人有效企微绑定;代理使用配置的固定代提交成员。任一前置不可用时,在写入退款、审批实例或 Outbox 前失败,前端保留当前表单。
- 同一订单存在提交中、审批中、已通过但业务处理未成功,或尚未完成异常人工处置的退款时拒绝创建。已拒绝退款不再占用活跃名额;已撤销、已删除或通过后撤销不能自动放行新建。
- 退款单、唯一审批实例和 `WeComApprovalSubmissionRequested` Outbox 必须在同一 PostgreSQL 事务创建。创建接口不等待远程企微,成功响应返回退款详情以及 `approval.status=0` 的“提交中”状态。
- 创建并发通过数据库唯一约束、状态条件或等价的持久化业务约束保证同一订单不会产生两张活跃退款Redis 只能作为快速防重,不能成为唯一正确性依据。
### 固定金额与企微表单
- 退款企微模板至少展示:退款单号、订单号、店铺、真实业务提交人、订单实收金额、可退款区间、本次申请退款金额、退款原因、申请备注和申请附件。
- 可退款区间在提交时固定为大于 0 且不超过订单实收金额;本次申请金额只读。企微审批人只能使用模板配置的同意或拒绝动作,不提供金额修改控件。
- 系统不提供本地审批按钮,也不向企微回写或伪造审批结论。认为金额错误时必须拒绝当前退款,再创建新退款。
- 申请附件的本地对象 Key 是权威资料;上传企微的 `media_id` 只是审批副本。审批人后续上传的附件属于企微审批资料,不能替代退款申请附件。
### 三套独立状态
- 退款业务状态为生命周期 `int``1=待审批``2=已通过``3=已拒绝``4=已退回``5=已撤销/审批已删除`。状态 4 只保留历史兼容,新企微审批不再产生“退回”。
- 本地业务处理状态为生命周期 `int``0=未触发``1=处理中``2=处理成功``3=处理失败`。各响应同时返回对应的 `processing_status_name`
- 企微审批状态完全复用 UR#37 公共常量:`0=提交中``1=审批中``2=已通过``3=已驳回``4=已撤销``5=通过后撤销``6=已删除``7=提交失败``8=提交结果未知`。退款模块不得复制或重新编号。
- 三套状态分别返回,禁止用退款状态或处理状态覆盖企微状态。所有状态 DTO 的 description 必须从公共 constants 原文复制,并提供对应中文名称字段。
- 企微驳回把退款状态从待审批条件更新为已拒绝,处理状态保持未触发;拒绝原因和企微时间线来自公共审批快照。
- 企微同意把退款状态条件更新为已通过,并触发可靠的退款终态 Outbox。即使后续本地处理失败退款状态和企微状态也不得降级或回改。
- 企微撤销或删除把退款状态置为 5记录异常原因并进入人工处置不自动执行资金动作也不自动允许新退款。
- 通过后撤销时,资金尚未执行则阻止后续资金任务;资金已执行则不自动冲正,保留实际退款金额,写 `critical` Audit Event 和站内告警,交由人工处理。
### 资金处理与实际退款金额
- `requested_refund_amount` 始终是申请金额。新增 `actual_refund_amount`,只在资金已实际完成时写入本次申请金额;未完成时为 `null`
- 微信、支付宝、线下及其他非代理主钱包支付订单,由财务在系统外完成退款后再同意企微。企微同意被视为财务已经确认完成,终态 Worker 写入 `actual_refund_amount=requested_refund_amount`;系统不调用支付渠道退款 API也不提供本地 `manual-complete` 二次确认。
- 个人客户使用资产钱包支付的订单同样由财务在系统外退款。系统不得调用现有资产钱包自动回充逻辑,不创建资产钱包退款流水;企微同意时记录实际退款金额并继续整单终结。
- 代理主钱包支付订单只按原订单扣款流水定位原主钱包和原资金关系,退回本次申请金额并写唯一退款流水。禁止根据当前店铺上下级关系、当前买卖方或当前钱包猜测退款目标。
- 如果代理订单缺少可核验的原扣款流水,资金步骤失败,`actual_refund_amount` 保持为空,处理状态为失败并记录可运维错误摘要;不得走历史兼容猜测分支。
- 代理主钱包余额增加与钱包版本、唯一退款流水、`actual_refund_amount` 和资金 Audit Event 在同一事务提交。钱包原余额为负时允许正常增加,结果自然冲减欠款。
- 资金已经完成但后续佣金或套餐处理失败时,`actual_refund_amount` 必须保留,不能因整单处理未成功而清空或重复退款。
-`approved_refund_amount` 只保留历史读取和迁移兼容,不再是新创建、企微表单或新公共响应中的业务概念。
### 订单、佣金与套餐整单终结
- 一旦企微同意,本次处理以整张订单为边界。资金步骤完成后,订单支付状态条件更新为已退款;申请金额小于实收金额时也执行同样更新,并禁止该订单再申请差额退款。
- 查找该订单产生的全部佣金记录。处于已冻结、解冻中、尚未发放或待人工修正的记录不移动钱包,直接改为已失效;处于已发放的记录先从对应佣金钱包全额扣回,再改为已失效,钱包允许变为负数。
- 佣金记录增加结构化失效事实:`invalid_reason` 至少支持 `order_refund``manual_resolution`;退款自动失效时保存 `invalid_refund_id``invalidated_at`,人工失效时另存人工操作者。不能只在 `remark` 中描述失效。
- 每条已发放佣金以“退款单 + 佣金记录”为业务防重键。佣金状态、佣金钱包余额和版本、回扣流水及统一 Audit Event 在同一事务提交;命中已完成防重事实时直接返回成功,不重复扣款或重复审计。
- 退款失效后的佣金不得被原有解冻、发放或补算任务重新发放。订单佣金流程的派生结论必须与全部佣金已失效保持一致。
- 失效该订单生成的所有仍有效套餐,不接受 `package_usage_id` 精确失效。命中主套餐时级联失效其加油包,并为套餐保存退款 ID、退款单号及失效时间等退款快照。
- 套餐失效完成后,按现有套餐队列规则尝试激活下一条待生效主套餐;没有下一条主套餐时按公共卡/设备状态写入能力停止资产。该过程复用套餐与卡状态领域能力,不在退款模块复制状态判断。
- 资金、订单、每条佣金和套餐步骤均需可单独识别已完成事实。任何步骤失败都把处理状态置为失败并由可靠 Worker 重试;只有全部必要步骤成功后才把处理状态置为处理成功。
### 可靠执行、幂等与审计
- 不再使用进程内 Goroutine 处理佣金或套餐。企微首次进入终态时由公共审批能力写业务 Outbox退款终态 Worker 使用 Asynq 执行;载荷只传结构化退款标识,不传预序列化字节、附件内容或密钥。
- Worker 通过处理状态、条件更新和有期限租约领取任务。未过期的处理中任务不能被第二个消费者重复执行;租约过期可恢复。失败摘要不得包含数据库连接、对象存储签名 URL、企微密钥或原始第三方响应。
- 退款终态事件、资金回款、佣金回扣、套餐失效和处理成功都必须各自具备持久化业务幂等事实。Redis 锁可以减少并发,但不能代替数据库条件更新、唯一约束和钱包乐观锁。
- 审计使用全局统一 Audit Event不新建退款私有审计表。至少关联退款、订单、审批实例、钱包、钱包流水、佣金记录和套餐使用记录并记录操作来源、真实业务提交人、系统执行身份、失效原因、金额和状态前后值。
- 钱包余额/版本/流水与其资金 Audit Event 同事务;佣金状态/钱包/回扣流水与其 Audit Event 同事务;订单及关键退款状态变更与对应 Audit Event 同事务。重复任务命中已完成事实时不新增第二条等价审计。
- 外部企微调用、附件上传、回调和详情同步使用公共 Integration LogIntegration Log 只记录接口、耗时、企微错误码/摘要和关联标识,不记录 Token、Secret、EncodingAESKey、完整对象 Key、临时 `media_id` 或文件内容。
### 权限、数据范围与信息投影
- 移除当前代理按 `creator` 隔离退款的专属规则。代理主账号及店铺内具备退款查看权限的账号按既有店铺层级数据范围读取本店及可管理下级店铺退款。
- 列表、详情、业务附件下载和导出必须复用同一主体权限投影。资源不存在与无权访问统一返回禁止访问语义,不能借错误差异探测其他店铺退款。
- 代理可见:退款业务资料、申请附件、订单及金额快照、真实业务提交人、企微审批状态和时间、业务处理状态及面向业务的失败提示。
- 代理不可见:企微审批人、内部意见、审批人上传附件、企微内部成员标识、模板内部映射及运维错误详情。
- 平台账号和超级管理员仍必须具备退款业务查看权限,才可读取完整审批人、意见、时间线和审批附件。企微审批运营或异常恢复权限本身不能绕过退款业务数据权限。
- 附件返回受保护的业务附件引用及下载能力,不把对象存储签名 URL 或企微临时 `media_id` 作为永久字段。历史导出或已获得的附件引用也不能绕过当前权限。
### API 与查询契约
- 保留 `POST /api/admin/refunds``GET /api/admin/refunds``GET /api/admin/refunds/{id}`
- 下线并不再注册:`POST /api/admin/refunds/{id}/approve``/reject``/return``/resubmit`,以及任何本地人工退款确认接口。不能保留隐藏兼容入口。
- 列表继续使用 `page``page_size`、退款状态、订单、店铺和资产标识等既有筛选;默认第 1 页、每页 20、最大 100默认按创建时间倒序并以 ID 作为并列排序键。所有筛选按 AND 组合,空参数不改变原查询。
- 创建成功的 `data` 与详情使用同一退款详情结构。列表项固定返回退款 ID/单号、订单与资产快照、店铺、真实业务提交人、订单实收金额、申请金额、实际退款金额、退款状态及名称、企微来源/状态及名称、当前审批人摘要和处理状态及名称。代理投影中的当前审批人摘要固定为空。
- 详情返回完整退款业务资料、申请附件、订单/支付快照、`approval` 对象以及根级处理状态。申请附件项返回 `file_key``file_name``file_size` 和受保护下载能力;不能返回对象存储永久地址或企微 `media_id`
- `approval` 固定包含 `source``approval_instance_id`、可空 `sp_no``status``status_name``template_version`、真实业务提交人、状态更新时间和 `business_process_result`;平台完整投影另包含审批人、意见、审批附件和时间线,代理投影不返回这些内部字段。
- 详情根级处理字段至少包含 `processing_status``processing_status_name`、面向当前主体脱敏后的 `processing_error`、开始时间和完成时间。审批状态、退款状态和处理状态不得合并成单个前端状态。
- 历史本地审批返回 `approval.source=legacy`,不伪造 `sp_no`、审批节点或企微时间线。新企微退款固定返回 `approval.source=wecom`
- 所有接口使用统一 `{code,msg,data,timestamp}` 响应和项目分页结构。错误码固定复用当前公共语义:请求字段、金额或附件元数据非法使用 `CodeInvalidParam=1001`;未登录使用 `CodeUnauthorized=1004`;订单或退款不存在与越权统一使用 `CodeForbidden=1005`;订单并非已支付、已经整单退款或退款状态不允许使用 `CodeInvalidStatus=1050`;活跃退款及并发重复创建使用 `CodeConflict=1007`;对象不存在/类型非法分别使用 `CodeStorageFileNotFound=1093``CodeStorageInvalidFileType=1095`;场景或模板不可用、代理固定成员不可用使用 `CodeServiceUnavailable=2004`。平台本人未绑定使用 `CodeInvalidStatus=1050` 和稳定消息“请先绑定企业微信”前端结合本人绑定查询显示绑定入口。任何错误均不得透传底层企微、数据库、Redis、对象存储或验证器信息。
### 前端页面与交互
- 退款创建表单只展示订单、申请退款金额、必填原因、可选备注和 15 个附件。订单实收金额及“通过后整单终结”的提示由后端订单/退款契约展示,不允许前端提交或覆盖实收金额。
- 提交中禁用重复提交;创建成功后进入退款详情,显示企微“提交中”。场景暂停、平台账号未绑定、代理固定成员不可用或附件校验失败时保留全部表单内容,并展示后端明确原因;平台账号未绑定时可原地进入 UR#37 的本人绑定流程。
- 退款详情分为“退款业务信息”“企微审批信息”“业务处理结果”三个稳定区域。审批与处理状态各自有加载、空、失败和刷新表现。
- 页面不显示本地通过、拒绝、退回、重提、审批金额修改或人工退款确认按钮。企微拒绝后只读展示原因;若仍需退款,从订单重新进入创建页,不在旧退款详情中编辑。
- 对非代理钱包订单明确提示财务必须先在系统外完成退款再同意企微;对代理钱包订单提示同意后系统自动回溯原主钱包。
- 处理失败时代理只看到可行动的业务提示,平台按权限看到脱敏错误摘要和“系统重试中/联系管理员”;不得提供会重复执行资金动作的前端按钮。
- 通过后撤销等高风险异常使用明显告警,并说明资金不会自动冲正。代理与平台页面严格遵守各自审批资料投影。
### 数据迁移、发布与回滚
- 退款记录补充唯一审批实例引用、真实提交人显示快照、结构化申请附件、申请备注、实际退款金额、处理状态、错误摘要、处理开始/完成时间及处理租约/版本等可靠执行字段。数据库不建立外键,也不使用 GORM 关联标签。
- 现有 `actual_received_amount` 继续作为后端生成的订单实收快照;新接口不再接受客户端值。现有 `package_usage_id``approved_refund_amount` 仅保留历史兼容,新退款不写入且整单处理不读取。
- 现有 `remark` 若包含历史审批备注,不直接改写语义;新申请备注使用明确的申请备注字段。新附件使用结构化元数据保存,旧 `refund_voucher_key` 只读兼容,并在对象仍存在时投影为历史业务附件。
- 佣金记录补充结构化失效原因、关联退款、失效时间和必要的人工操作者字段;已发放佣金回扣流水建立退款与佣金记录级唯一业务约束。
- 停机发布前盘点待审批、已通过但旧异步标记未完整、已拒绝、已退回及历史终态退款。历史终态保留为 `legacy`;待审批记录按 UR#37 迁移规则创建真实企微审批,缺少平台绑定、代理固定身份、附件或真实提交人事实的记录进入明确的迁移待处理清单。
- 历史状态 4 保持“已退回”只读,不自动创建新审批或改为拒绝。历史已通过但资金、佣金或套餐事实不一致的记录先进入对账与人工处置,不允许迁移脚本猜测已完成。
- 发布顺序必须先具备 UR#37 公共企微能力、真实模板映射、平台绑定和代理固定成员,再启用退款创建与终态 Worker旧本地审批路由在同一停机窗口移除。
- 应用回滚必须保留已形成的退款、企微实例、Outbox、Integration Log、Audit Event、资金流水和失效事实。已经进入真实企微的退款不得恢复旧本地审批按钮只能继续同步、重试安全的本地步骤或人工处置。
## Testing Decisions
- 最高公共自动化接缝为Fiber HTTP 路由与真实认证 → Refund Application/Domain/Query → GORM → 现有测试 PostgreSQL → Outbox/公开 Worker Handler → 钱包、佣金、套餐和统一审计;企业微信网络边界使用可编程 WeCom Adapter。测试只断言公开响应、数据库业务事实、资金流水、状态和审计不断言私有函数或目录结构。
- 本地开发和 Agent 自动化测试必须使用 Redis DB 7已部署测试环境继续使用 DB 6。测试入口在启动前读取并验证 Redis Client、Asynq Client 与 Worker Server 的实际 DB任一不是 7 就立即失败。禁止向 DB 6 投递任务,禁止对 DB 7 执行 `FLUSHDB`
- PostgreSQL 沿用现有测试库,不新建数据库。每次运行使用唯一标识创建隔离订单、店铺、钱包、佣金、套餐和退款夹具,只按实际创建 ID 精确清理;禁止 `TRUNCATE`、清表、模糊删除或修改既有业务数据。
- 对象存储直接使用现有真实 S3不建立内存替身。测试使用唯一 Key 上传申请附件,验证元数据、下载及企微附件提交,结束时只删除本次创建对象;真实上传、读取或删除失败均使测试失败。
- 自动化测试捕获待投递任务后直接调用公开 Worker Handler不通过 `sleep` 等待后台 Worker。实现需沉淀可复用的环境守卫、唯一夹具、精确清理、真实 S3 和 Worker 驱动 Harness并提供真实 HTTP/curl 冒烟模板curl 不替代 Go 自动化断言。
- 创建 HTTP 测试覆盖三类允许账号、企业账号拒绝、订单越权、订单不存在、未支付/已退款订单、金额为零/负数/超过实收、缺少原因、备注超长、附件数量/元数据/归属错误、场景不可用、平台未绑定、代理固定成员失效和并发重复创建。
- 创建事务测试证明退款、唯一企微实例和提交 Outbox 要么全部成功,要么全部不存在;远程企微尚未响应时接口仍只产生一组本地事实。
- 固定金额测试证明申请金额写入后不会被企微详情、重复回调或终态 Worker 修改;企微表单包含实收金额、可退款区间和申请金额,审批动作不接受金额字段。
- 状态测试覆盖企微提交中、审批中、通过、拒绝、撤销、删除、通过后撤销、提交失败和结果未知,并证明退款状态、企微状态与处理状态独立。历史已退回只读且旧重提路由不存在。
- 非代理钱包测试覆盖微信、支付宝、线下和个人资产钱包:企微通过后记录实际退款金额,不调用任何渠道退款或资产钱包回充,不生成对应自动退款流水,但仍执行订单、佣金和套餐整单终结。
- 代理钱包测试使用真实钱包与版本字段,覆盖按原扣款流水退款、负余额冲减、缺失原扣款流水、重复 Worker、并发 Worker、余额更新后崩溃恢复和唯一退款流水证明系统不按当前代理关系猜测钱包。
- 整单语义测试至少包含“申请金额小于订单实收金额”,验证只退申请金额但订单仍标记已退款、该订单全部有效套餐及佣金均终结,且不能再申请剩余差额。
- 佣金测试覆盖已冻结、解冻中、已发放、已失效和待人工修正;已发放记录全额扣佣金钱包并允许负数,其他未发放状态不动钱包;验证结构化失效字段、唯一回扣流水、同事务 Audit Event 和原发放任务不能复活记录。
- 套餐测试覆盖订单生成的待生效/生效中等有效主套餐、加油包级联、下一主套餐激活、无下一套餐时停止资产、重复处理、处理中崩溃和状态写入失败恢复;不得使用单个 `package_usage_id` 缩小范围。
- 局部失败测试依次制造资金失败、佣金中途失败、套餐失效失败和资产状态失败。资金未完成时实际退款金额为空;资金完成后的下游失败保留实际退款金额;修复后重试只补未完成步骤,最终不产生重复资金或审计。
- 权限测试对同一退款使用本店代理、上级代理、无管理关系代理、具备业务权限平台、仅具备企微运营权限平台和超级管理员读取,验证店铺层级范围及平台业务权限。列表、详情、附件下载和导出必须给出一致投影。
- 信息投影测试证明代理可见业务凭证、真实提交人、审批状态和处理结果,但看不到审批人、内部意见或审批人附件;有退款业务权限的平台可以看到完整审批详情,无业务权限的平台即使有企微运营权限也不能读取。
- 可靠性测试覆盖 Outbox 重投、Asynq 重投、租约过期、回调与轮询并发、重复终态、乱序状态、乐观锁冲突和处理成功后再次消费。每个外部终态只触发一次业务处理,完成步骤不重复。
- 可编程 WeCom Adapter 自动化必须覆盖真实企微难以稳定制造的超时、明确失败、响应丢失、重复/乱序回调、撤销、删除、通过后撤销、并发轮询和限流。加密回调自动化仍从真实 HTTP 回调入口进入,使用测试 Token/AES Key 生成协议密文并验证签名、解密和 CorpID不能绕过协议直接调用内部同步函数。
- 真实企微验收是 UR#35 实现完成门禁,不得推迟到 INT-06。真实参数、模板和密钥只通过环境变量或安全配置提供不写入 Spec、源码、日志或报告回调按“企业微信 → 用户提供的中转应用 → 本地服务”进入,中转应用原样转发企微查询参数与请求体,后端仍执行完整验签、解密和 CorpID 校验。
- 真实企微至少执行两张独立退款:其一由代理创建,使用固定企微成员代提交并人工同意,建议选择代理主钱包订单,同时验证真实附件上传、`applyevent`、加密回调、钱包回溯、佣金、套餐、订单和审计;其二由平台账号创建,使用本人绑定企微身份并人工拒绝,验证轮询兜底、退款终结且不触发资金或套餐处理。
- 真实企微验收采用可复用两阶段流程:阶段一创建唯一隔离夹具并通过真实 HTTP 创建退款,输出运行 ID、退款号与 `sp_no`;人工在企微同意或拒绝;阶段二等待或主动驱动公共同步/轮询和退款 Worker再自动核对数据库、钱包流水、佣金、套餐、审批实例、Audit Event 与幂等结果,并生成不含敏感信息的通过/失败报告。
- 真实企微每次不强制人工制造撤销、删除或通过后撤销,这些异常由可编程 Adapter 自动化覆盖。真实验收必须证明同意、拒绝、真实附件、两类发起身份、真实加密回调和回调缺失时的轮询兜底。
- 前端人工验收覆盖创建表单保留、本人绑定入口、代理代提交、列表和详情三状态分区、代理/平台投影、拒绝后新建、处理失败、通过后撤销高风险告警以及所有加载、空、失败和无权限状态。
- 完成门禁同时要求:相关 Go 自动化、数据库和真实 S3 测试通过;可编程 Adapter 异常矩阵通过;两条真实企微验收通过;迁移演练、旧路由不存在、生成的 OpenAPI、前端接入和数据核对均通过。INT-06 只做跨需求与前后端复验,不能替代本需求真实企微门禁。
## Out of Scope
- 不建设本地审批流、审批任务、审批节点配置、审批按钮或本地“待我审批”。
- 不允许企微审批人修改退款金额,也不保留 `approved_refund_amount` 作为新业务字段。
- 不提供原退款单编辑、退回修改、重提或拒绝后的复用;再次退款必须新建。
- 不调用微信、支付宝或其他支付渠道的自动退款 API。
- 不自动回充个人资产钱包,不把资产钱包退款扩展为资金渠道能力。
- 不提供非代理钱包的本地人工退款确认按钮或 `manual-complete` 接口。
- 不自动冲正通过后撤销前已经完成的资金、佣金或套餐动作。
- 不允许一张订单通过多张退款单拆分退款,也不保留申请金额之外差额的后续退款权利。
- 不向代理公开审批人、内部意见、审批人附件或企微内部标识。
- 不在 UR#35 重复实现 UR#37 的连接配置、账号绑定、模板发布、回调协议、轮询调度或异常恢复后台。
- 不在本需求迁移无关订单、钱包、佣金、套餐或卡状态模块的全部旧代码。
## Further Notes
- 当前代码仍要求前端提交实收金额和可选套餐使用记录,仍注册本地通过、拒绝、退回与重提接口;实现必须以本 Spec 为准删除这些新流程入口,不能把当前行为当成兼容要求。
- 当前代理退款查询按创建账号过滤与已确认的店铺层级查看范围冲突Query 改造必须覆盖列表、详情、附件和导出,不能只改列表。
- 当前代理钱包退款在找不到原扣款流水时会按关系猜测钱包,个人资产钱包会自动回充;两条兼容分支都与本 Spec 冲突,必须从新退款终态用例中移除或隔离。
- 当前佣金和套餐后处理使用进程内 Goroutine并可能在部分失败后写完成布尔值这些布尔值只能用于历史对账不能作为新处理链路的可靠幂等事实。
- 当前套餐失效能力在不传 `package_usage_id` 时已经能够按订单查找目标并级联主套餐加油包,可作为迁移时的行为参考,但必须纳入可靠 Worker、状态条件、审计和下一套餐/资产状态闭环。
- UR#37 必须先提供可调用的公共企微契约UR#44 列表摘要、UR#42 退款附件导出和 UR#57 退款中禁止换货应消费本 Spec 的状态与权限投影,不得自行定义另一套“活跃退款”或审批信息。
- 本需求同时触及企微、资金、佣金、套餐、权限、迁移和真实环境验收,预计超过一个高质量实现上下文。进入实现前应基于本 Spec 评估并拆分窄的端到端 tracer-bullet tickets拆分不能按 Model、Service、Handler 和测试做水平切层。

View File

@@ -0,0 +1,285 @@
# PRDUR#36 批量订购套餐
Status: ready-for-agent
---
## Problem Statement
运营人员需要根据一份离散资产清单批量为卡或设备订购套餐。现有后台单笔下单只能一次处理一个资产,无法提供批次进度、逐行结果、部分成功恢复和稳定幂等;原始需求中把整批理解为单一代理,也无法覆盖同一文件包含多个代理名下资产的真实场景。
本需求需要在不弱化现有单笔订单规则的前提下,增加 CSV 直传、异步解析和逐行下单能力。批次只统一支付方式,不统一代理:系统在处理每一行时根据资产当前归属确定结算代理,再校验该代理的套餐授权、成本价和主钱包。文件结构错误必须整批失败且不产生订单;资产、套餐、归属、重复行和余额等业务错误允许逐行失败。
## Solution
新增“批量订购套餐”入口。操作员在创建批次时选择一个套餐和一种支付方式,前端通过现有对象存储预签名接口把只含资产标识的 CSV 和线下凭证直传真实私有 S3再提交唯一 `request_id``package_id`、整批支付方式和稳定对象 Key 创建任务;请求不包含 `shop_id`,也不上传文件字节。
创建接口完成对象归属、类型和 10MB 大小校验后立即返回任务。Worker 下载完整 CSV先完成文件级校验并持久化逐行明细再严格按 CSV 行号执行。每个有效业务行在独立事务中解析当前结算代理、复用统一套餐可售策略和订单领域,以 `wallet` 扣结算代理主钱包,或以 `offline` 创建已支付订单。任务和明细使用状态条件、处理租约及稳定幂等键保证重复消费不会重复下单或扣款。
## User Stories
1. 作为平台运营人员,我希望上传一份 CSV 为多张卡或设备订购套餐,而不是逐笔创建订单。
2. 作为平台运营人员,我希望同一 CSV 可以包含不同代理名下的资产,不需要预先按代理拆文件或在页面选择代理。
3. 作为平台运营人员,我希望整批明确选择钱包或线下支付,避免一份文件混合不同支付语义。
4. 作为运营人员,我希望 CSV 只填写资产标识,由系统统一识别卡或设备及其支持的标识形式。
5. 作为平台运营人员,我希望文件结构错误整批失败且不产生订单,而单行业务错误不影响其他合规行。
6. 作为平台运营人员,我希望任务刷新后仍能恢复进度,并按行查看结算代理、金额、订单和中文失败原因。
7. 作为财务人员,我希望线下凭证作为批次业务资料永久保留,但本期不把它扩展为财务核销系统。
8. 作为代理,我希望批量钱包订购只扣我的主钱包,并继续遵守我的套餐授权、成本价和信用额度规则。
9. 作为审计人员,我希望每一行都能追溯原始标识、规范资产、结算代理、套餐、金额和最终订单。
10. 作为系统维护人员,我希望重复 HTTP 请求、重复 Worker 消费和进程中断恢复都不会产生重复订单或重复扣款。
## Implementation Decisions
### 范围与领域口径
- “批量订购批次”是一份选择单个套餐并使用统一支付方式的 CSV 订购任务,不绑定单一代理。
- “结算代理”是每一行处理时根据资产当前归属解析出的代理;套餐授权、成本价、钱包、订单买卖方快照和失败原因均以该代理为准。
- `shop_id` 不是创建参数、CSV 字段或前端隐含参数。后端即使收到未知字段也不能据此改变结算代理。
- 批量用例复用 Order 领域和统一 Wallet Domain只迁移完成本用例所需的最小完整复杂写边界列表、任务详情和明细使用 Query 通道。
- 不复制现有巨大订单 Service 的宽松分支。需要把资产、套餐、可售策略、定价、订单、套餐生效和钱包扣款收口为可由单笔后台下单与批量单行命令共同调用的领域/Application 能力。
- 本需求不改变现有单笔后台下单 HTTP 接口,也不把未触碰的订单模块一次性整体迁移。
### 入口与现有认证边界
- 不新增 `order:bulk_purchase` 或其他批量订购权限码,不在后端增加超级管理员、平台、代理或企业类型的显式拦截。
- 页面是否展示入口由前端现有菜单与可见性规则决定;能够通过现有后台认证调用接口的主体,后端视为可以使用该能力。
- 创建、任务详情和明细接口只复用现有后台路由认证及公共数据范围机制,不额外建立任务创建人隔离或新的 RBAC 判断。
- “无新增后端权限拦截”不等于跳过业务规则。每一行仍必须校验资产当前归属、结算代理、所选套餐授权、可售状态、定价和钱包。
- 创建任务成功、任务终态及每个成功订单均写公共审计。审计包含任务号、支付方式、文件安全摘要、凭证数量、汇总、操作者及逐行关联,不记录预签名 URL、鉴权令牌或环境密钥。
### CSV 契约
- 只接受 `.csv`,不接受 `.xlsx``.xls` 或把 Excel 文件改扩展名后的内容。
- 编码固定为 UTF-8可带 UTF-8 BOM换行允许 LF 或 CRLF。
- 前端随版本发布静态模板,后端不增加模板下载接口。建议模板文件名为 `批量订购套餐模板-v1.csv`
- 固定且唯一的表头为单列 `资产标识`。缺列、多列、重复列或未知列均为文件级错误CSV 不包含资产类型、套餐编码或套餐名称。
- 套餐由创建任务请求中的单个 `package_id` 决定,整个批次的每一行都订购该套餐。
- 资产标识去除首尾空白后交给系统统一资产解析能力,解析结果包含资产类型、资产 ID 和规范标识。批量订购不得另写一套卡/设备识别分支或维护自己的标识白名单。
- 当前统一解析能力支持 ICCID、卡 `virtual_no`、MSISDN、设备 `virtual_no`、IMEI 和 SN未来统一解析能力新增或修正标识规则时批量订购自动复用。
- 空资产标识、未命中或无法唯一解析属于行级失败;禁止猜测或自动修复科学计数法、控制字符等被破坏的数据。
- 单个文件最大 10MB数据行最多 1000 行。空文件、只有表头、非法 UTF-8、CSV 引号语法错误、表头错误或第 1001 行出现,均使任务整体失败且不创建任何订单。
- Worker 必须先完成整个文件的结构和行数校验,再开始任何订单写入;不能边解析边下单后才发现文件级错误。
- 文件有效后,每一个数据行都持久化为明细。业务无效行、空字段行和重复行仍占用原始行号并计入失败,确保 `total_count = success_count + fail_count`
### 资产解析与文件内判重
- Worker 通过统一资产解析能力得到唯一资产类型、资产 ID 和规范标识后再判定文件内重复,不能直接按用户填写的字符串判重。
- 明细同时保留用户原始资产标识和统一解析结果;规范标识的选取规则属于统一资产能力,批量订购不自行决定卡或设备的标识优先级。
- 未命中或命中多个资产时该行失败;统一资产解析能力必须保证唯一结果,不能使用 `First` 任取一条。
- 文件内重复键为统一解析得到的 `资产类型 + 资产ID`。整个批次只有一个套餐,因此重复键不再包含套餐。
- 重复键首次出现的行正常进入业务处理,后续行失败并在 `failure_reason` 中指出首次出现的 CSV 行号。
- 本期不把重复行解释为数量;未来需要多份订购时再增加明确数量字段。
- 统一资产解析能力必须提供适用于批量调用的接口,避免逐行跨表查询形成 N+1历史脏数据多命中必须明确失败不能由批量用例任取一条。
### 对象存储与上传归属
- 复用 `POST /api/admin/storage/upload-url`,新增用途 `bulk_purchase`。该用途只生成批量订购目录下的 `.csv` KeyContent-Type 固定为允许的 CSV 类型。
- 线下凭证继续使用 `attachment` 用途;允许项目现有支持的图片或文件类型,数量沿用后台订单凭证上限,当前为 1 至 5 个。
- 前端使用预签名 URL 直接 `PUT` 到当前真实私有 S3业务接口只接收稳定 `file_key``voucher_keys`,不接收 multipart 或字节流。
- 现有存储 Provider 缺少对象元数据和上传主体证明。实现必须补充对象元数据查询能力,至少返回对象是否存在、实际大小和 Content-Type同时保存预签名上传授权记录包含 Key、purpose、申请人账号、声明文件名/类型和签发时间。
- 创建任务时校验 CSV Key 来自 `bulk_purchase` 用途、上传授权属于当前账号、对象真实存在、实际大小不超过 10MB、扩展名和 Content-Type 合法。凭证 Key 也必须属于当前账号的附件上传授权并真实存在。
- 上传授权在任务创建事务中绑定到该任务。同一 Key 可以随相同 `request_id` 的幂等重试返回原任务,但不能被另一个批次或另一个账号再次绑定。
- 预签名 URL 和永久对象 Key 是不同概念。任务只保存稳定私有 Key不保存会过期的上传 URL查询时按现有受权下载机制展示凭证。
- 源 CSV 和凭证按对象存储统一生命周期保留。Worker 下载失败属于任务级基础设施失败;已经确定的订单结果不因后续对象清理失败而回滚,但必须记录告警。
- 自动化测试直接调用真实 S3不实现或保留内存 Provider 替身。测试使用本次运行的唯一对象 Key并在结束时只删除自己创建的对象真实上传、Head、下载或删除失败都必须使相应测试失败。
### 创建任务 API
- `POST /api/admin/bulk-purchases` 使用 JSON 请求:
```json
{
"request_id": "01J...",
"package_id": 1001,
"payment_method": "wallet",
"file_key": "bulk-purchase/2026/07/unique.csv",
"voucher_keys": []
}
```
- `request_id` 必填,最大 64 字符,由前端为一次用户提交生成全局唯一值。`package_id` 必填且大于 0一个任务只接受一个套餐。`payment_method` 只允许复用现有后台订单枚举 `wallet|offline`,不新增 `agent_wallet`
- 创建任务时加载并快照所选套餐的 ID、编码和名称确认套餐存在各结算代理是否拥有授权、价格是否有效及当前是否可售仍在逐行处理时按最新事实校验。
- `wallet``voucher_keys` 必须为空;`offline` 时必须提供 1 至 5 个凭证 Key。一个批次不能混合支付方式。
- 请求 DTO 不声明 `shop_id`,创建接口若检测到显式提交 `shop_id` 必须返回参数错误,不能忽略后让调用方误以为它参与了结算;其他未知字段延续项目统一 JSON 兼容策略,但不参与业务决策。
- `request_id` 建立数据库唯一约束。相同 `request_id`、相同操作者和相同请求指纹重复提交时返回原任务,不重复绑定文件、创建任务或投递消息。
- 相同 `request_id``package_id`、支付方式、文件 Key、凭证 Key 或操作者不同,返回冲突错误,不能静默返回语义不同的旧任务。
- 创建事务保存任务、上传授权绑定和可靠任务事件。Asynq 不可用时不能丢失已提交任务;数据库中的待处理任务/事件是事实源,由投递器重试发送。
- 成功响应至少返回 `task_id``task_no``request_id`、所选套餐快照、`payment_method``status``status_name``created_at`。成功只表示任务已接收,不表示 CSV 已通过或订单已创建。
### 任务与明细查询 API
- `GET /api/admin/bulk-purchases/{task_id}` 返回任务汇总,至少包括:
- 任务 ID、任务号、请求 ID、所选套餐 ID/编码/名称快照、支付方式。
- 状态与中文状态名、源文件名、凭证数量和有权预览所需的附件引用。
- 总行数、已处理数、成功数、失败数、涉及代理数。
- 全部有效成功行金额合计;金额单位固定为分,类型为 `int64`
- 任务级错误码与中文原因、操作者快照、创建/开始/完成时间。
- `GET /api/admin/bulk-purchases/{task_id}/items` 支持 `page``page_size``status``asset_identifier`
- 默认第 1 页、每页 20 条,`page_size` 最大 100。
- 默认按 `row_no ASC`,不允许前端改变业务处理顺序。
- `status` 只允许明细状态枚举;多个筛选参数使用 AND 组合。
- `asset_identifier` 去除首尾空白后,对该任务内的用户原始标识或规范标识做精确匹配,不做跨任务搜索。
- 明细响应至少包括:行号、解析后的资产类型、原始资产标识、解析资产 ID、规范标识、结算代理 ID/名称快照、金额、状态与中文名、订单 ID、失败码、中文失败原因和处理时间套餐信息统一来自任务快照不保存不存在的逐行套餐输入。
- 查询仅返回业务安全信息,不返回 SQL、底层错误、对象存储永久凭证、Redis Key 或其他代理的钱包余额。
- 所有接口使用统一 `{code,msg,data,timestamp}` 响应。参数校验统一返回参数错误;任务不存在返回统一不存在错误;现有后台认证失败沿用公共认证错误;同一请求 ID 的不同载荷、文件已绑定等返回冲突;对象不存在、类型错误、文件过大和存储失败复用或补齐统一存储错误码。
### 数据模型与索引
- 新建独立任务表和逐行明细表,不复用语义不同的导入、导出或设备批量分配表;不建立数据库外键或 GORM 关联标签。
- 任务至少保存:任务号、`request_id`、请求指纹、源文件 Key/名称/类型/大小、所选套餐 ID/编码/名称快照、支付方式、凭证 Key 快照、操作者账号/类型/名称快照、涉及代理数、总数/已处理数/成功数/失败数、成功金额、状态、任务级错误码/原因、任务租约持有者/到期时间、开始/完成时间和公共审计字段。
- 明细至少保存:任务 ID、CSV 行号、统一解析得到的资产类型、原始资产标识、解析资产 ID、规范标识快照、结算代理 ID/名称快照、金额、状态、订单 ID、失败码/原因、幂等键、处理租约和处理时间。
- 上传授权记录需要稳定保存 Key、purpose、申请账号、声明元数据、绑定业务类型/ID和绑定时间以便创建接口证明“属于当前上传主体”不通过可猜测目录前缀代替归属校验。
- 任务号、`request_id` 和上传授权 Key 使用有效记录唯一索引;明细使用 `(task_id,row_no)``idempotency_key` 唯一索引,并为 `(task_id,status,row_no)` 建查询索引。
- `request_id` 是全局唯一;行幂等键固定为 `bulk_purchase:{task_id}:{row_no}`
- 状态类字段使用 `int`,类型/方式类使用 `string`。任务复用全局异步任务状态常量,不定义 Bulk 私有任务状态;逐行明细使用公共行处理状态。支付方式、资产类型、失败码和 Redis Key 生成函数定义到公共常量包,并添加中文注释。
- 不需要迁移或回填历史订单。新表上线前为空;已成功生成的标准订单继续按现有订单事实保留。
### 状态机与任务恢复
- 全局异步任务状态统一为:`1:待处理, 2:处理中, 3:已完成, 4:已失败, 5:已取消`;任务响应必须包含对应 `status_name`
- 文件有效并完成全部行业务处理后,任务固定为 `3:已完成`。全部成功、部分成功或全部业务行失败由 `success_count``fail_count` 表达,不创建“部分成功”状态。
- 文件级校验失败、源对象下载失败或无法恢复的任务级基础设施错误进入 `4:已失败`。文件级失败不得创建订单;是否保留零条明细由错误发生阶段决定,并通过任务错误说明。
- 逐行明细不是独立异步任务,使用公共行处理状态:`1:待处理, 2:处理中, 3:成功, 4:失败`,并返回对应 `status_name`;不为明细增加没有业务语义的取消状态。
- 本期任务没有取消入口,但保留全局任务状态码 `5:已取消`,不把它改作其他含义。
- Worker 使用状态条件和带过期时间的租约领取任务。只有待处理或租约已过期的处理中任务可被领取;终态不可被重新执行。
- 文件校验通过后在开始下单前一次性持久化全部明细。Worker 重启时读取现有明细继续,而不是重新生成不同的行号或幂等键。
- 每个明细也通过条件更新或行锁领取。成功/失败终态不可被另一消费者覆盖;租约过期的处理中明细根据事务事实安全恢复。
- 任务汇总始终从明细表重新聚合,不信任进程内累加值。`processed_count = success_count + fail_count`,终态时等于 `total_count`
- Asynq 载荷只传结构化 `task_id`,不得传预序列化 `[]byte`、CSV 字节、临时路径、凭证内容或认证上下文。
### 严格行序与逐行业务流程
- 文件通过后严格按 `row_no ASC` 串行推进业务结算。可以批量预加载只读数据,但不得并发执行钱包扣款或改变行序结果。
- 每行处理时重新读取资产当前归属,不使用创建任务时的归属快照;结算代理不存在、归属异常或没有有效主钱包时只失败当前行。
- 解析出的结算代理必须拥有对应套餐当前有效授权;金额使用该代理当前授权成本价和现有订单定价规则,不使用 CSV 套餐名称或前端金额。
- 每行复用统一套餐可售策略。批量订购不是个人本人续费入口,不能利用后台或批量身份绕过下架限制;渠道下架套餐固定拒绝。
- 继续执行现有后台订单中适用于该支付方式的资产状态、套餐组合、互斥、使用期、生效、赠送、强充等不变量。批量入口不得复制一套较宽规则。
- `offline` 把整批凭证快照关联到每个成功订单,直接创建符合现有后台线下语义的已支付订单并激活套餐;不扣代理钱包。
- `wallet` 逐行锁定该资产结算代理的有效主钱包,使用统一公式计算可用金额:只有启用信用时才计入有效信用额度。
- `wallet` 不预占整批或某一代理的全部金额。当前行余额不足只失败当前行并继续;后续金额更小的行在当时可用金额足够时仍可成功。因此,同一代理资金不足时 CSV 行序就是订购优先级。
- 单个成功行的事务必须原子提交:订单、订单明细、套餐使用/激活、结算代理与价格快照、钱包余额与版本、钱包真实金额流水、明细成功状态,以及本用例要求的可靠事件/审计。
- 单行事务失败不得留下已扣钱包但无订单、已有订单但明细仍可重复执行,或套餐已生效但订单回滚的中间事实。
- 行级业务失败记录稳定 `failure_code` 和中文原因后继续下一行底层数据库、S3 或 Redis 错误不能原样写给用户。
### 幂等、并发与失败码
- HTTP 幂等由 `request_id` 唯一约束和请求指纹保护;任务投递幂等由任务 ID 和任务状态/租约保护;单行幂等由 `(task_id,row_no)`、稳定幂等键、明细行锁/条件状态和单行事务共同保护。
- 同一任务的两个 Worker、Worker 崩溃后重试以及 Asynq 至少一次投递都不能重复创建订单、扣款、写钱包流水、激活套餐或写成功审计。
- 钱包扣款使用统一 Wallet Domain 的版本/条件更新或等价并发保护,不能只在内存判断余额;并发扣款后总可用金额不得小于 0。
- 与普通订单并发购买同一资产时仍要由 Order Domain 保护套餐和资产不变量,不能只依赖批量任务自身租约。
- 推荐稳定任务失败码包括:`storage_download_failed``invalid_encoding``invalid_header``unknown_column``invalid_csv``empty_file``row_limit_exceeded`
- 推荐稳定行失败码包括:`invalid_asset_identifier``asset_not_found``asset_identifier_ambiguous``duplicate_row``package_not_authorized``package_not_purchasable``settlement_agent_invalid``main_wallet_not_found``insufficient_balance``order_create_failed`
- 失败码供前端稳定展示和筛选,`failure_reason` 使用用户可理解中文并可补充首次重复行号等上下文;不把整个中文文案当作程序判断条件。
### 线下凭证语义
- 线下凭证是整个批次的业务资料快照,所有成功的线下订单都能追溯到该批次及凭证。
- 本期不校验同一凭证是否在其他批次使用,不建立凭证金额与订单金额的自动核销,也不因重复文件内容拒绝批次。
- 凭证可以是图片或文件,保存私有对象 Key页面通过受权下载地址预览或下载不把附件字节写入 CSV、数据库大字段或任务载荷。
- 对象存储中的凭证按现有附件保留策略长期可访问;不能把创建时的短期预签名 URL 当作永久业务地址。
### 前端交互
- 页面采用“参数确认 → 上传 → 处理中 → 结果”四个稳定阶段,可放在批量订购独立页或现有订单页入口。
- 参数阶段选择单个套餐和整批支付方式,不展示代理选择器。`offline` 显示 1 至 5 个凭证上传;`wallet` 不显示或清空凭证。
- CSV 模板是前端静态资源,只有 `资产标识` 一列,说明标识复用系统统一识别能力,当前支持 ICCID、卡虚拟号、MSISDN、设备虚拟号、IMEI 和 SN并明确不接受 Excel。
- 整个 CSV 使用参数阶段选择的套餐,前端不让用户在行内填写或为不同资产选择不同套餐。
- 前端先申请上传 URL并直传真实 S3再以同一个用户动作生成的 `request_id` 创建任务。网络超时重试必须复用原 `request_id`,用户主动新建批次才生成新值。
- 提交前展示所选套餐、支付方式、CSV 文件名和凭证数量,不展示虚构的单一代理或整批钱包余额;提交期间禁止重复点击。
- 创建成功后保存任务 ID并刷新详情。页面刷新、关闭后重开或网络恢复时可以根据任务 ID恢复不依赖持续驻留的轮询内存状态。
- 处理中显示任务号、操作员、总数、已处理数、成功数、失败数、成功金额和涉及代理数。解析尚未完成时允许总数为 0并显示“正在校验文件”。
- 结果页默认筛选失败明细,可切换全部/成功/失败,并按资产标识精确搜索;展示行号、原始资产、规范资产、批次套餐、结算代理、金额、订单和中文原因。
- 任务完成后根据成功数和失败数展示“全部成功/部分成功/全部业务行失败”的结果摘要,不把“部分成功”当成状态码。本期没有单行重试接口;用户复制失败行、修正后重新上传会创建全新任务,旧任务历史不改变。
- 文件级失败展示任务错误和模板修正建议;行级失败展示逐行原因。前端不得自行推断或改写后端失败码。
### 发布、回滚与依赖
- 钱包路径依赖统一 Wallet Domain 已具备信用启用开关、有效信用额度公式、版本并发保护和真实金额流水。若 UR#38 尚未落地,必须先完成这段共享能力,禁止在批量代码中复制旧余额判断。
- 套餐校验依赖统一可售策略能够区分 C 端本人续费与后台/批量入口。若 UR#40 尚未落地,必须先具备该策略,批量不能暂时放宽下架限制。
- 发布包含新表、索引、权限、上传用途、存储元数据能力、API、任务投递器和 Worker。Worker 尚未部署或数据库迁移未完成时不得开放前端入口。
- 上线前验证 PostgreSQL、Redis、Asynq 和真实 S3 配置,生成接口文档,并完成上传、任务、钱包、线下、部分成功和现有后台认证联调。
- 发布窗口内短暂停止新批量任务,先部署兼容迁移和 Worker再部署 API/前端。旧版本不会读取新表,不需要历史回填。
- 回滚时先关闭前端入口和任务创建,等待或人工处置已领取任务,再回滚应用。已经创建的标准订单、钱包流水、套餐使用、任务和审计均作为业务事实保留,不做反向删除。
- 不在仍有待处理或处理中任务时删除新表或上传用途;数据库降级迁移不是常规应用回滚步骤。
## Testing Decisions
### 可复用 Agent 集成测试规范与工具
- 本需求实现时同时沉淀一份“Agent 集成测试规范”,覆盖环境防误连、夹具命名、唯一运行标识、精确清理、失败后清理、敏感信息保护和禁止事项,供后续批量任务复用。
- 提供可复用 Go 测试 Harness至少封装配置守卫、真实 PostgreSQL/Redis/S3 连接、Fiber 请求、测试账号和真实认证令牌、受控任务投递捕获、公开 Worker Handler 驱动、统一响应解码、资源清理台账。
- 为本需求提供 UTF-8、BOM、非法表头、重复资产、多代理、钱包不足和线下凭证等固定 CSV `testdata`,并提供穿过公开边界的完整示例测试。
- Go 集成测试是主要自动化入口;`curl` 只作为已部署环境的真实网络冒烟模板验证登录、预签名上传、PUT、创建、查询和终态不承担并发、幂等和数据库断言。
- 测试公共行为,不直接测试私有函数。创建接口使用 Fiber `app.Test` 穿过真实路由、认证、Handler、Application/Domain、GORM 和统一响应Worker 通过公开任务 Handler 驱动,不依赖私有解析方法。
- 为避免后台 Worker 抢任务,集成测试在应用注入点捕获待投递 `task_id`,然后直接调用公开 Worker Handler不通过 `sleep` 等待异步碰运气。生产仍使用真实 Asynq 投递器。
### Redis 与 Asynq 隔离
- 已部署测试环境固定使用 Redis DB 6本地开发和 Agent 自动化测试固定使用 Redis DB 7。
- 每次自动化测试启动前必须读取实际生效配置,并确认普通 Redis Client、Asynq Client 和 Worker Server 的 DB 都等于 7。任一不是 7 时立即失败,绝不向 DB 6 写普通 Key或投递任务。
- 测试不得执行 `FLUSHDB`、全前缀扫描删除或清空队列。每次运行生成唯一 `run_id`Redis 业务键、任务标识和清理名单只指向本次实际创建的精确 Key。
- 测试结束和失败清理都按资源台账删除精确 Key其他本地进程在 DB 7 的数据不受影响。
### PostgreSQL 共享测试库
- 自动化测试继续使用当前现有测试数据库,不创建新的数据库或专用数据库。
- 每次运行创建带唯一 `run_id` 的平台账号、权限、代理、钱包、资产、套餐、授权和其他夹具,只使用本次创建记录的实际主键执行业务。
- 清理按依赖顺序和实际主键精确删除本次创建的记录;禁止 `TRUNCATE`、整表删除、模糊条件删除、复用后篡改现有业务数据或假设测试库为空。
- 测试开始前记录资源清理台账;任何中途失败仍执行清理,并将未清理的精确 ID 输出为诊断信息但不得输出密码、Token、S3 密钥或完整敏感业务数据。
- 钱包并发测试也在该共享测试库内使用完全独立的本次夹具,不能锁定或扣减已有代理钱包。
### 真实 S3
- 自动化测试直接使用当前可调用的真实 S3 Provider不使用内存替身、临时本地对象模拟或绕过预签名协议。
- 每个测试对象 Key 都包含唯一 `run_id`CSV 和凭证走真实上传;测试覆盖对象 Head/元数据、下载和精确删除。
- 清理只删除本次资源台账中记录的对象 Key禁止删除目录前缀或执行 Bucket 级清理。
- 真实 S3 网络、鉴权、上传、下载、Head 或删除失败均使集成测试失败,便于及早发现环境和 Provider 契约问题。
### 后端场景
- 上传契约覆盖 `bulk_purchase` purpose、`.csv`、实际 Content-Type、10MB 边界、对象不存在、其他账号 Key、其他用途 Key、Key 已绑定、凭证类型和 1/5/6 个凭证。
- CSV 解析覆盖 UTF-8、UTF-8 BOM、LF、CRLF、单列中文固定表头、缺失/额外/重复列、非法引号、空文件、只有表头、1000/1001 行、空字段、控制字符和伪装 Excel。
- 资产解析通过统一公共接缝覆盖卡 ICCID、卡虚拟号、唯一 MSISDN、MSISDN 未命中/多命中、设备虚拟号、IMEI、SN、未命中和历史多命中断言批量用例没有另一套识别规则。
- 判重覆盖同一资产使用不同受支持标识只首行处理、后续指出首行号,以及无法解析资产的行不会误判成同一个空资产。
- 认证测试覆盖现有后台认证有效和失效;断言没有新增 `order:bulk_purchase`、账号类型拦截或任务创建人隔离。
- 请求幂等覆盖相同 `request_id` 同载荷返回原任务、改变 `package_id` 或其他载荷/操作者时冲突、并发创建只有一个任务和一个可靠事件。
- 文件级错误验证任务失败且订单、钱包流水、套餐使用均为零;行业务错误验证部分成功且已成功行不被回滚;全部行业务失败仍是任务已完成且 `success_count=0``fail_count=total_count`
- `wallet` 覆盖多代理各扣自己的主钱包、信用启用/禁用、无主钱包、余额不足、行序优先、前一行不足但后一便宜行成功,以及不产生整批预占。
- `offline` 覆盖凭证必填、成功订单立即支付和激活、不扣钱包、凭证可跨批复用且保留批次关联。
- 套餐规则覆盖创建时套餐不存在、任务套餐快照、各结算代理的授权成本、未授权、禁用、下架不能被批量绕过,以及现有组合/资产状态/强充规则;任何价格都不能由前端或 CSV 提交。
- 重复消费覆盖两个 Worker 同时领取、任务租约过期、单行处理中崩溃、事务提交前/后故障、重复 Asynq 消息,断言订单、扣款、流水和激活各只有一次。
- 查询覆盖默认分页、最大页大小、行号排序、状态和资产标识筛选使用 AND、任务汇总、全局状态名称和失败码。
- 审计覆盖任务创建/终态、逐行订单和钱包事实,并验证日志和响应不包含环境密钥、认证令牌、预签名 URL 或底层错误。
### 前端、联调和完成命令
- 前端验收覆盖入口可见性、单套餐选择、四阶段页面、单列静态 CSV 模板、真实直传、支付方式切换、线下凭证、重复点击、刷新恢复、解析中空进度、计数表达部分成功、失败默认筛选、精确搜索和新任务重试。
- 部署联调使用 `curl` 模板完成真实 HTTP 和真实 S3 PUT但凭据只从环境读取不写入脚本、文档或终端回显验证部署环境明确连接 Redis DB 6。
- 自动化完成门禁至少包括目标单元/集成测试、相关包测试、全量 Go 测试、竞态或并发专项测试、静态检查、OpenAPI 生成校验和数据库迁移验证。
- 新 Handler 必须同步两个接口文档生成入口DTO 枚举描述从公共常量原文复制,所有状态响应包含中文 `status_name`
## Out of Scope
- 不支持 Excel不维护 CSV/Excel 双解析器。
- 不在创建请求或 CSV 中接受 `shop_id`,不让前端选择或推断结算代理。
- 不允许一批混合 `wallet``offline`,也不新增 `agent_wallet` 支付枚举。
- 不在批量订购内维护资产标识白名单或另写卡/设备识别逻辑;不支持模糊搜索或资产号段匹配。
- 不用重复行表达套餐数量,不提供单行重试或修改旧任务接口。
- 不为钱包支付预占整批或某代理全部金额,不因一行余额不足回滚其他成功行。
- 不允许批量订购绕过下架、授权、资产状态、定价、强充或订单领域规则。
- 不对线下凭证做跨批唯一、金额核销或自动财务对账。
- 不新增后端 CSV 模板下载或失败结果文件下载接口。
- 不创建新的 PostgreSQL 测试数据库,不对共享测试库或 Redis DB 7 执行破坏性清理。
- 不为对象存储实现内存替身;自动化和联调均验证当前真实 S3。
- 不新增批量订购后端权限码、账号类型拦截或任务创建人隔离。
- 不重构当前需求未触碰的整个订单、钱包或存储模块。
## Further Notes
- 当前仓库没有批量订购 API、任务模型或 Worker需要作为新用例实现。
- 当前后台订单已使用 `wallet|offline`,批量必须复用这两个常量;部分现有 DTO 对线下凭证和支付枚举的描述不完全一致,实现时以公共常量和本规格为准并同步修正触碰处。
- 当前已有统一 `Asset.Resolve` 和全局资产标识注册表,但 fallback 对历史多命中仍可能任取第一条。本需求必须复用并完善这一个统一解析边界,使单个和批量解析都能返回唯一资产类型与 ID不能在批量模块内再实现一套。
- 当前存储接口只有存在性、上传/下载和预签名能力,没有对象元数据与上传主体归属事实;这两项是安全接收 `file_key` 的必要实现,不得只检查字符串前缀。
- 当前 Redis 普通客户端、Asynq Client 和 Worker Server使用同一个 Redis DB 配置来源,测试守卫必须验证最终生效值为 7而不是只检查某个环境变量字符串。
- 用户已最终确认CSV 只有 `资产标识`,资产类型由统一解析能力识别;创建任务选择单个 `package_id`;后端不新增显性权限拦截;任务状态复用全局五态且部分成功只通过计数表达;自动化测试直接使用真实 S3PostgreSQL 沿用现有测试库。

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,22 @@
# 01 — 统一换货资产解析与权威快照
**What to build:** 运营人员继续使用接口已支持的任一资产标识发起或执行换货但系统在物流换货创建、直接换货创建和物流发货三个入口统一解析真实资产并保存权威快照。IoT 卡的新旧资产快照始终保存数据库中的完整 ICCID设备快照依次选择虚拟号、IMEI、SN 中首个非空标识,不保存请求原文。三个入口必须共享同一套解析与规范化规则,自动化测试、接口文档和中文功能说明同步证明该行为可以独立发布和验收。
**Blocked by:** None — can start immediately.
**Status:** ready-for-agent
**架构通道:** 主通道为复杂写,辅助通道为 Infrastructure Adapter。资产解析和权威标识选择作为换货写用例复用能力收口既有 Service 可以作为迁移门面调用该能力,但规则不得继续散落在多个流程分支。
**完整业务边界:** 本票收口资产标识解析、权威快照选择以及物流创建、直接创建、物流发货三个快照写入入口并包含对应自动化测试、OpenAPI 契约和中文发布说明。明确不迁移换货状态机、确认完成、取消、资料迁移、旧资产转新、客户绑定切换等旧逻辑;不回填或改写历史换货单,不依赖 UR#86 或 UR#98 的实现。
- [ ] 使用 ICCID、接入号或虚拟号定位旧 IoT 卡时,新建物流换货单的旧资产快照均为该卡数据库中的完整 ICCID。
- [ ] 直接换货的新旧资产和物流换货发货时的新资产均复用相同规范化能力IoT 卡快照不保存请求原文、接入号或虚拟号。
- [ ] 设备无论通过虚拟号、IMEI 或 SN 定位,快照都按“虚拟号 → IMEI → SN”的优先级选择首个非空稳定标识。
- [ ] 标识解析仍执行既有资产权限、资产类型、状态和并发校验,不扩大可操作资产范围,也不改变换货生命周期规则。
- [ ] 资产不存在、类型不匹配或数据库失败时返回既有统一错误体系中的脱敏错误,不向客户端透出底层错误。
- [ ] 自动化测试覆盖卡的三种输入标识、设备的三种输入标识及设备标识优先级,并分别验证物流创建、直接创建和物流发货的持久化快照。
- [ ] 端到端回归验证三个写入入口产生的快照可由现有换货详情或列表响应读取,且构造的历史非规范快照保持原值、不被自动回填。
- [ ] OpenAPI 中创建换货和物流发货的请求标识说明、响应快照语义与实际实现一致,并完成文档重新生成验证。
- [ ] UR#45 中文功能总结记录卡与设备快照规则、历史数据不回填策略、错误边界、发布与回滚注意事项README 增加对应索引。
- [ ] 所有新增或修改的导出符号、复杂逻辑注释和日志均使用中文,并通过相关 Go 测试与格式检查。

View File

@@ -0,0 +1,27 @@
# 02 — 提供新旧资产独立搜索的换货列表契约
**What to build:** 运营人员在换货列表中可以分别提交 `old_asset_keyword``new_asset_keyword`,通过 IoT 卡的 ICCID、接入号、虚拟号或设备的虚拟号、IMEI、SN 搜索对应一侧的换货资产。两个关键词同时提交时按 AND 组合,并继续与状态、流程类型、创建时间、分页和现有店铺数据范围共同生效;旧通用 `identifier` 不再属于新接口契约。该列表能力包含接口发布、真实 PostgreSQL 性能证据和前端同维护窗口切换所需的完整验收契约,可独立交付。
**Blocked by:** None — can start immediately.
**Status:** ready-for-agent
**架构通道:** 主通道为 Query。仅将本次明显复杂化的换货列表读取用例收口为查询能力可直接使用 GORM、子查询或固定次数批量查询完成候选解析、权限过滤和 DTO 投影;不得让列表读取经过聚合根或执行写操作。
**完整业务边界:** 本票收口列表请求校验、候选资产解析、新旧资产主键过滤、权限、分页计数、排序、响应投影、错误转换、OpenAPI 发布、性能验证和前端联调验收说明。明确不迁移换货详情及其他读取接口,不创建换货聚合根,不修改换货写侧状态规则,不保留通用 `identifier` 的第二套长期搜索语义;不实现 UR#86 资产前代/后代关系或 UR#98 店铺继承。当前仓库未包含可实施该页面的前端工程,因此前端工作以接口契约和人工验收清单交付,不虚构前端代码改动。
- [ ] 列表请求新增最长 100 字符的 `old_asset_keyword``new_asset_keyword`,并从新契约移除通用 `identifier`;空值不增加对应过滤条件。
- [ ] 仅提供旧资产关键词时只按 `old_asset_type + old_asset_id` 过滤,绝不因新资产命中而返回;仅提供新资产关键词时规则对称。
- [ ] 两个关键词同时提供时按 AND 组合,并与状态、流程类型、创建时间范围和分页条件按 AND 组合。
- [ ] IoT 卡候选支持对 ICCID、接入号和虚拟号做包含匹配设备候选支持对虚拟号、IMEI 和 SN 做包含匹配;候选资产必须排除软删除记录。
- [ ] 换货单通过资产类型和资产主键命中,因此历史非规范快照不回填、不改写,但仍能通过所关联资产的任一受支持标识搜索到。
- [ ] 无候选资产或无换货单命中时返回成功的空分页;候选查询或换货单查询发生数据库错误时返回脱敏 500不得降级为空结果。
- [ ] 最终换货单查询继续排除软删除记录并应用现有店铺数据范围;平台、超级管理员和代理账号的既有可见范围不被候选资产解析绕过。
- [ ] 候选解析和换货单过滤使用数据库子查询或固定次数批量查询,不按换货单逐行反查资产;`total``items` 使用完全相同的过滤条件,结果按创建时间倒序。
- [ ] Handler 对完整请求 DTO 执行校验;非法关键词长度、分页、状态、流程类型或时间参数统一返回 HTTP 400、`code=1001``msg=参数验证失败`,详细原因仅记录中文日志。
- [ ] HTTP 集成测试覆盖仅旧关键词、仅新关键词、双关键词 AND、状态与时间组合、空参数、无匹配、非法参数、历史快照、数据库故障和各类账号数据权限。
- [ ] 响应保持统一外层结构并继续分别返回新旧资产类型、ID、快照标识、状态及状态名称。
- [ ] OpenAPI 中的换货列表只公开 `old_asset_keyword``new_asset_keyword`包含长度限制、AND 语义和中文说明,不再公开通用 `identifier`;重新生成文档并验证请求、响应和错误契约与实现一致。
- [ ] 使用真实 PostgreSQL 和代表性大结果集验证查询次数固定、无逐行资产反查、`total` 与分页结果一致,并记录查询计划或等价证据;性能满足项目列表接口目标。
- [ ] UR#45 中文功能总结补充搜索契约、权限与脱敏错误边界、性能结果、发布回滚方式和前端联调注意事项README 中的 UR#45 索引可以定位该说明。
- [ ] 前端人工验收清单明确要求:将单一资产输入框拆为旧资产和新资产输入框;空值不提交;双条件按 AND 提交;表格不混列新旧资产;前端不解析标识、不本地过滤当前页;清空、分页、空态和失败反馈沿用现有交互。

View File

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

View File

@@ -0,0 +1,21 @@
# 01 — 后台资产详情统一展示预计最终到期
**What to build:** 运营人员通过统一资产解析查看卡或设备详情时,可以看到全部有效主套餐按稳定队列连续使用后的“预计套餐到期时间”。系统以统一 Query 计算 `exact``waiting_activation``none``invalid_data` 四种状态,并返回预计日期、上海自然日剩余天数和临期派生结果;后台详情按状态展示明确文案,不再把当前套餐自身到期时间当作资产汇总口径。
**Blocked by:** `.scratch/ur55-package-expiry-base/issues/05-activate-and-queue-from-package-snapshots.md` — 05 — 套餐激活与队列接续只消费购买快照
**Status:** ready-for-agent
- [ ] 架构主通道为 QueryInfrastructure 仅负责 GORM 批量读取、历史套餐兜底和索引;完整收口预计最终到期的读取与投影边界,不迁移套餐激活、购买、退款或队列接续写逻辑。
- [ ] 建立可供单资产和批量资产入口共同调用的纯计算核心;两种入口对同一组使用记录返回完全一致的状态、预计日期、剩余自然日和临期标记,卡、设备及各端不得各自实现推算规则。
- [ ] 仅纳入未软删除、仍处于当前或排队生命周期且不属于加油包的主套餐;排除已过期、已失效和已退款记录,并按 `priority ASC, created_at ASC, id ASC` 稳定计算。
- [ ] 当前生效主套餐的真实到期时间作为游标起点,后续套餐从前一段结束后的下一时刻接续;自然月和按天时长统一使用项目套餐生命周期日期语义,并以 `Asia/Shanghai` 解释业务自然日。
- [ ] 排队记录优先使用各自购买时计时快照;仅允许 UR#55 上线前形成的历史缺失快照记录显式回退套餐当前值,非法队列、非法日期或无法安全解析的快照返回 `invalid_data`,不得伪造日期或降级为 `none`
- [ ] 结果完整区分 `exact``waiting_activation``none``invalid_data`;非 `exact` 时预计日期和剩余天数明确序列化为 `null`,不能省略字段表达状态。
- [ ] 剩余天数按上海时区日期差计算,允许普通详情返回负数;仅 `exact` 且剩余 015 个自然日时 `is_expiring=true`
- [ ] 后台统一资产解析接口为卡和设备返回预计日期、剩余天数、状态、状态中文名称及临期标记;查询失败向上返回统一错误,不得静默返回 `none`
- [ ] 后台卡和设备详情统一显示“预计套餐到期时间”:精确日期、待激活后起算、—、数据异常分别对应四种状态;当前套餐自身到期时间只保留在套餐明细中。
- [ ] 为未软删除主套餐的资产、状态和队列读取建立经开发库 `EXPLAIN ANALYZE` 验证的非唯一或部分索引;索引迁移包含中文注释、可逆向下迁移,代表性查询满足项目性能目标。
- [ ] 纯计算测试覆盖无套餐、仅当前套餐、多段队列、自然月、按天、月末、跨年、等待实名激活、历史回退、非法快照、重复优先级、退款、失效、软删除和加油包排除,并明确验证接续边界不多算或少算一天。
- [ ] 使用真实开发 PostgreSQL、Redis、JWT 和进程内 Fiber App 完成后台卡/设备详情集成验证;新增、退款或失效排队套餐后无需刷新快照,下一次查询立即返回新结果。
- [ ] 补充本功能中文总结文档并同步更新 README不实现 UR#33 临期列表、Dashboard、提醒不实现 UR#42 导出,也不维护资产级最终到期快照字段。

View File

@@ -0,0 +1,17 @@
# 02 — 卡列表批量展示预计最终到期
**What to build:** 运营人员和代理人员查看卡列表时,每张卡都能看到与资产详情完全一致的“预计套餐到期时间”、剩余自然日、推算状态和临期标记。列表按本页资产一次批量计算,不因新增字段产生逐卡查询,也不改变普通卡列表既有筛选、分页和排序。
**Blocked by:** `.scratch/ur46-estimated-final-expiry/issues/01-admin-asset-detail-estimated-final-expiry.md` — 01 — 后台资产详情统一展示预计最终到期
**Status:** ready-for-agent
- [ ] 架构主通道为 Query复用 01 已建立的批量入口和纯计算核心,完整收口普通卡列表的预计最终到期投影,不迁移卡管理写操作、套餐生命周期或未触碰的列表逻辑。
- [ ] 卡列表每项明确返回预计日期、剩余自然日、推算状态、状态中文名称和临期标记;非 `exact` 时 nullable 字段返回 `null`,查询错误遵循统一错误规范。
- [ ] 一次批量加载本页全部卡的参与使用记录和必要历史套餐兜底数据,再按资产分组计算;不得逐卡调用单资产 Query 或产生其他 N+1 查询。
- [ ] 同一张卡在列表批量入口和详情单资产入口得到完全一致的结果,包含历史快照回退、等待激活、异常数据和已过期负数天数场景。
- [ ] Count 与 Fetch 保持原有相同过滤条件,新增投影不改变总数;普通卡列表继续使用原有稳定排序,不按预计日期或临期程度重新排序。
- [ ] 后台和代理端卡列表统一显示“预计套餐到期时间”,四种状态分别展示精确日期、待激活后起算、—、数据异常;前端不叠加套餐时长或自行计算剩余天数。
- [ ] `is_expiring=true` 时仅应用约定临期颜色,不引入 UR#33 的独立临期列表、03 天置顶、Dashboard 或通知行为。
- [ ] HTTP 集成测试覆盖四种状态、nullable 字段、详情与列表一致性、原筛选和分页不变;使用每页 100 张卡的代表性数据验证固定查询数量并满足项目性能目标。
- [ ] 前端验收普通卡列表排序不变、翻页稳定,当前套餐自身到期时间不再作为资产摘要中的第二个汇总到期字段。

View File

@@ -0,0 +1,17 @@
# 03 — 设备列表批量展示预计最终到期
**What to build:** 运营人员和代理人员查看设备列表时,每台设备都能看到与资产详情完全一致的“预计套餐到期时间”、剩余自然日、推算状态和临期标记。列表复用统一批量 Query不按设备逐条读取套餐也不改变普通设备列表既有筛选、分页和排序。
**Blocked by:** `.scratch/ur46-estimated-final-expiry/issues/01-admin-asset-detail-estimated-final-expiry.md` — 01 — 后台资产详情统一展示预计最终到期
**Status:** ready-for-agent
- [ ] 架构主通道为 Query复用 01 已建立的批量入口和纯计算核心,完整收口普通设备列表的预计最终到期投影,不迁移设备绑定、设备管理写操作、套餐生命周期或未触碰的列表逻辑。
- [ ] 设备列表每项明确返回预计日期、剩余自然日、推算状态、状态中文名称和临期标记;非 `exact` 时 nullable 字段返回 `null`,查询错误遵循统一错误规范。
- [ ] 一次批量加载本页全部设备的参与使用记录和必要历史套餐兜底数据,再按资产分组计算;不得逐设备调用单资产 Query 或产生其他 N+1 查询。
- [ ] 同一台设备在列表批量入口和详情单资产入口得到完全一致的结果,包含历史快照回退、等待激活、异常数据和已过期负数天数场景。
- [ ] Count 与 Fetch 保持原有相同过滤条件,新增投影不改变总数;普通设备列表继续使用原有稳定排序,不按预计日期或临期程度重新排序。
- [ ] 后台和代理端设备列表统一显示“预计套餐到期时间”,四种状态分别展示精确日期、待激活后起算、—、数据异常;前端不叠加套餐时长或自行计算剩余天数。
- [ ] `is_expiring=true` 时仅应用约定临期颜色,不引入 UR#33 的独立临期列表、03 天置顶、Dashboard 或通知行为。
- [ ] HTTP 集成测试覆盖四种状态、nullable 字段、详情与列表一致性、原筛选和分页不变;使用每页 100 台设备的代表性数据验证固定查询数量并满足项目性能目标。
- [ ] 前端验收普通设备列表排序不变、翻页稳定,当前套餐自身到期时间不再作为资产摘要中的第二个汇总到期字段。

View File

@@ -0,0 +1,16 @@
# 04 — C 端资产信息统一展示预计最终到期
**What to build:** 客户在 C 端查看卡或设备资产信息时可以看到与后台一致的“预计套餐到期时间”、剩余自然日、推算状态和临期标记。C 端继续在套餐明细中保留当前套餐自身到期时间,但资产摘要只使用统一 Query 给出的最终到期口径,前端不自行推算。
**Blocked by:** `.scratch/ur46-estimated-final-expiry/issues/01-admin-asset-detail-estimated-final-expiry.md` — 01 — 后台资产详情统一展示预计最终到期
**Status:** ready-for-agent
- [ ] 架构主通道为 Query辅助通道为 C 端接口投影;复用 01 的单资产入口和纯计算核心,完整收口 C 端资产信息展示,不迁移购买、实名、续费、退款或套餐激活写逻辑。
- [ ] C 端卡和设备资产信息明确返回预计日期、剩余自然日、推算状态、状态中文名称和临期标记字段语义、RFC3339 序列化及 nullable 行为与后台完全一致。
- [ ] 同一资产、同一时刻和同一套餐队列下C 端与后台详情返回相同的预计结果;不得在 C 端 Service、Handler 或前端建立另一套套餐时长累加规则。
- [ ] C 端资产摘要统一显示“预计套餐到期时间”,四种状态分别展示精确日期、待激活后起算、—、数据异常;异常不得伪装成无套餐或空白日期。
- [ ] 套餐明细中的当前套餐到期时间继续保留用于解释当前周期,但不得与预计最终到期并列为两个资产汇总口径。
- [ ] `is_expiring=true` 时仅应用约定临期颜色;本票不增加 UR#33 的续费入口、临期列表、通知或其他临期业务行为。
- [ ] 使用真实开发 PostgreSQL、Redis、JWT 和进程内 Fiber App 完成 C 端卡、设备集成测试覆盖四种状态、nullable 字段、后台一致性以及套餐新增、退款或失效后下一次查询实时变化。
- [ ] 前端验收确认 C 端不叠加套餐时长、不自行计算剩余天数,并保持现有套餐明细信息可见。

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,17 @@
# 01 — 创建套餐分配时固化生效条件选择
**What to build:** 运营人员通过系列授权、后续追加授权或批量套餐分配创建代理套餐分配时,可以显式选择跟随套餐默认、购买即生效或实名即生效。系统为本次创建的每条分配保存相同选择,并在列表、详情和操作结果中返回套餐默认值、分配覆盖值及最终有效值的中文名称。任一记录校验或写入失败时,整次操作不留下部分分配。
**Blocked by:** None — can start immediately
**Status:** ready-for-agent
- [ ] 架构主通道为简单写,读取投影使用 Query 或现有只读门面;完整收口创建分配时解析和保存覆盖值的业务边界,不迁移套餐管理 CRUD、价格、佣金或历史购买记录。
- [ ] 数据库迁移为套餐分配增加可空的生效条件覆盖字段,`NULL` 明确表示跟随套餐当前默认值,并包含中文字段注释及可逆的向下迁移。
- [ ] 系列首次授权、系列后续追加套餐和批量套餐分配均要求显式提交同一个覆盖选择,并写入本次创建的每条分配;跟随默认必须提交 JSON `null`,不能依靠省略字段表达。
- [ ] 仅接受 `null``from_purchase``from_activation`非法值或字段缺失统一返回参数错误DTO 枚举说明与套餐常量原文一致。
- [ ] 分配列表、详情及创建结果返回默认值、覆盖值、最终有效值及其中文名称;最终有效值由后端计算,前端不得自行合并。
- [ ] 任一套餐不存在、跨系列、越权、不可授权或写入失败时整批回滚,不产生部分系列授权、套餐分配或成功副作用。
- [ ] 后台分配表单提供“跟随套餐默认、购买即生效、实名即生效”三个选项,并正确发送显式 `null`
- [ ] HTTP 集成测试覆盖全部创建入口、三个选择、整批一致性、字段缺失、非法枚举、越权及事务回滚;前端验收列表和详情的默认值、覆盖值、最终值显示一致。

View File

@@ -0,0 +1,18 @@
# 02 — 编辑已有分配的生效条件覆盖
**What to build:** 运营人员可以修改已有套餐分配的生效条件覆盖,或通过显式 JSON `null` 恢复跟随套餐默认。重复设置相同值按幂等成功处理;修改完成后重新读取并展示后端计算的有效值,同时明确告知该操作只影响后续购买,不改变任何已购买套餐。
**Blocked by:** `.scratch/ur55-package-expiry-base/issues/01-create-allocation-expiry-base-override.md` — 01 — 创建套餐分配时固化生效条件选择
**Status:** ready-for-agent
- [ ] 架构主通道为简单写,读取投影使用 Query 或现有只读门面;完整收口单条分配覆盖值修改,不扩展为批量修改或迁移套餐默认配置接口。
- [ ] 提供修改单条套餐分配生效条件覆盖的后台 PATCH 接口,请求必须包含覆盖字段,并能区分字段缺失与显式 JSON `null`
- [ ] 接口仅接受 `null``from_purchase``from_activation``null` 恢复跟随默认,非法值和字段缺失统一返回参数错误。
- [ ] 分配不存在、已软删除或越权时遵守统一防越权错误语义,不向调用方区分无权限与资源不存在。
- [ ] 重复提交当前值按幂等成功处理,不修改已有套餐使用记录,也不产生与实际变更不符的业务副作用。
- [ ] 修改成功后响应或重新查询结果包含默认值、覆盖值、最终有效值及其中文名称,最终值始终由后端计算。
- [ ] 敏感配置变更写入统一审计,记录操作人、变更前后覆盖值及分配标识,但不把底层错误或无权资源信息暴露给客户端。
- [ ] 前端编辑区域显示“仅影响后续新订单,不影响已购买套餐”,成功后重新拉取分配详情,不在本地推导最终有效值。
- [ ] HTTP 集成测试覆盖两个覆盖值、显式恢复默认、字段缺失、非法值、不存在、软删除、越权、重复 PATCH 幂等及已购买记录不变。

View File

@@ -0,0 +1,18 @@
# 03 — 同步购买链路创建不可变套餐计时快照
**What to build:** C 端购买、后台代购和代理囤货等同步订单路径,在创建正式主套餐或加油包使用记录时,通过同一套餐生命周期能力解析购买时有效的生效条件、周期类型和时长,并在创建事务中一次性保存完整快照。购买完成后再修改套餐或分配配置,不会改变这些使用记录承诺的计时规则。
**Blocked by:** `.scratch/ur55-package-expiry-base/issues/01-create-allocation-expiry-base-override.md` — 01 — 创建套餐分配时固化生效条件选择
**Status:** ready-for-agent
- [ ] 架构主通道为复杂写Infrastructure 负责持久化;完整收口同步购买形成套餐使用记录时的计时条款解析,不迁移自动购包、激活接续、最终到期 Query 或未触碰的订单用例。
- [ ] 套餐使用记录增加生效条件、周期类型、月数和天数四个购买快照字段;迁移包含中文注释和可逆向下迁移,不回填或猜测历史购买配置。
- [ ] 套餐生命周期 Domain 提供 `PackageTermsSnapshot` 值对象或等价单一能力,校验并返回有效生效条件、周期类型、月数和天数;领域规则不依赖 Fiber、GORM 或 Redis。
- [ ] 有分配覆盖时使用覆盖值,无覆盖时使用套餐默认值;解析结果必须是非空有效枚举,不能把 `NULL=跟随默认` 写成空快照。
- [ ] C 端购买、后台代购、代理囤货及仓库中其他同步生产入口都调用同一快照能力,正式主套餐和加油包均保存四个字段。
- [ ] 四个快照与套餐使用记录在同一数据库事务写入,不允许先创建空记录再异步补齐;解析、校验或持久化失败时不留下订单后处理产生的部分使用记录。
- [ ] 快照写入后不因套餐默认值、套餐周期时长、分配覆盖或系列授权变化而更新。
- [ ] 新同步购买记录若无法生成完整有效快照必须拒绝创建并记录中文上下文日志,不得静默退回读取套餐当前值。
- [ ] 领域测试覆盖无覆盖、两个覆盖值、非法值、自然月和按天时长;集成测试逐一覆盖所有同步创建路径、主套餐、加油包、事务回滚及购买后配置变化不修改快照。

View File

@@ -0,0 +1,17 @@
# 04 — 自动购包链路复用不可变套餐计时快照
**What to build:** 自动购包任务创建正式主套餐和加油包使用记录时,复用同步购买已经建立的套餐计时快照能力,在同一业务事务中保存购买时承诺。任务重试或重复消费不会重复创建使用记录,也不会产生缺少快照的新记录。
**Blocked by:** `.scratch/ur55-package-expiry-base/issues/03-snapshot-synchronous-package-purchases.md` — 03 — 同步购买链路创建不可变套餐计时快照
**Status:** ready-for-agent
- [ ] 架构主通道为复杂写Application 通过 Port/Adapter 编排自动任务和持久化;只迁移自动购包形成套餐使用记录的完整用例,不迁移其调度、定价、支付或其他订单规则。
- [ ] 自动购包创建主套餐和加油包时调用与同步购买相同的计时条款解析能力,不再自行读取套餐当前生效条件和时长决定使用记录语义。
- [ ] 自动任务能够解析对应代理分配的覆盖值;无覆盖时使用套餐默认值,并为每条新使用记录写入四个非空、有效的计时快照。
- [ ] 使用记录及其快照在同一业务事务提交,任一步骤失败均不留下部分订单、部分主套餐、部分加油包或空快照记录。
- [ ] 重复任务和并发消费依赖数据库业务幂等事实或状态条件,不以 Redis 锁代替权威幂等;同一业务只生成一组套餐使用记录。
- [ ] 队列载荷使用结构体或 map不向统一入队能力传入预序列化字节。
- [ ] 新自动购包记录无法生成完整快照时明确失败并记录中文上下文日志,不允许静默使用历史兼容回退。
- [ ] 自动任务集成测试覆盖两个生效条件、跟随默认、主套餐、加油包、重复消费、并发、事务中途失败和购买后配置变化不修改快照。

View File

@@ -0,0 +1,21 @@
# 05 — 套餐激活与队列接续只消费购买快照
**What to build:** 套餐首次激活、实名后激活、前一主套餐结束后的排队接续及退款后的下一套餐接续都只使用套餐使用记录保存的购买快照决定起算条件和周期时长。历史旧记录缺少快照时可以通过明确、可观测的兼容路径继续运行UR#55 上线后产生的新记录缺少快照则被识别为数据异常,不能静默读取可变套餐配置。
**Blocked by:**
- `.scratch/ur55-package-expiry-base/issues/03-snapshot-synchronous-package-purchases.md` — 03 — 同步购买链路创建不可变套餐计时快照
- `.scratch/ur55-package-expiry-base/issues/04-snapshot-auto-purchase-package-usages.md` — 04 — 自动购包链路复用不可变套餐计时快照
**Status:** ready-for-agent
- [ ] 架构主通道为复杂写Infrastructure 负责历史数据读取和可观测设施;完整收口套餐激活与接续用例,不实现 UR#46 最终到期展示、UR#33 临期能力或未触碰的套餐管理 CRUD。
- [ ] 首次激活、实名后激活、前一主套餐到期后的排队接续和退款后的接续,共享同一套餐生命周期规则,并只从使用记录快照读取生效条件、周期类型、月数和天数。
- [ ] `from_purchase``from_activation` 的起算语义由使用记录快照决定;购买后修改套餐默认值、周期、时长或分配覆盖,不改变已有记录的激活时间、到期计算或队列接续结果。
- [ ] 正式主套餐和加油包的激活均消费各自快照;加油包继续遵守既有主套餐关联规则,不借本票改变其生命周期范围。
- [ ] 仅 UR#55 上线前形成且快照缺失的历史记录允许显式回退套餐当前值;每次回退都记录可定位使用记录的结构化告警和指标。
- [ ] UR#55 上线后形成的新记录缺少或包含非法快照时,必须拒绝或标记异常并告警,不得静默进入历史回退路径。
- [ ] 移除触碰用例中直接读取套餐当前 `expiry_base`、周期或时长的生产分支;不得保留“行业卡永远直接激活”等绕过统一实名规则的特殊判断。
- [ ] 重复激活和重复接续通过状态条件保证幂等;事务失败不会留下部分状态、错误到期时间或重复激活下一套餐。
- [ ] 领域与集成测试覆盖首次购买即生效、等待实名后生效、连续排队、退款接续、历史回退、新记录缺失快照、重复执行及购买后修改全部相关配置。
- [ ] 发布验证遵循“兼容读取应用先就绪,再执行迁移并在同一维护窗口切换所有写入路径”;窗口结束后确认没有新增空快照,并能观测历史回退数量。

View File

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

View File

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

View File

@@ -0,0 +1,22 @@
# 01 — 修复店铺列表参数校验与默认分页
**What to build:** 让 API 调用方通过现有店铺列表接口获得可信的参数校验和分页行为:所有已声明查询参数在进入查询前完成统一解析与完整校验,非法参数只返回脱敏的统一参数错误;未提供分页参数时,数据库查询与响应元数据都明确使用第 1 页、每页 20 条。该行为通过真实认证链路和数据服务独立验收,并同步固化到接口契约与需求文档。
**Blocked by:** None — can start immediately.
**Status:** completed
**架构通道:** 主通道为现有读取链路 `Handler → Service → Store → GORM/DTO`;不迁移到新的 Query 或 DDD 模块。
**完整业务边界:** 仅收口店铺列表请求参数校验、错误脱敏和分页一致性。明确不修改其他 Handler 的校验方式,不迁移店铺模块,不改变列表响应结构、已有筛选语义、排序或数据权限。
- [x] 店铺列表在查询参数解析后对整个请求 DTO 执行校验,覆盖分页、名称、编号、上级店铺、层级和状态的既有约束。
- [x] 查询参数解析失败或任一字段校验失败时,不执行店铺查询;客户端收到 HTTP 400、`code=1001``msg=参数验证失败``data=null` 和时间戳,响应不包含解析器或 Validator 的具体错误文本。
- [x] 服务端日志保留请求上下文和具体失败原因但不记录数据库密码、JWT 密钥或其他敏感配置。
- [x] 未传 `page``page_size` 时,查询前归一化为第 1 页、每页 20 条,并在响应中返回 `page=1``size=20`
- [x] 显式提供的合法页码和每页数量原样生效,不因任何筛选条件被后端重置。
- [x] HTTP 集成测试通过进程内 Fiber App、真实后台认证中间件、真实 PostgreSQL、Redis 和 JWT 配置验证成功及失败响应;测试数据和 Token 状态使用唯一标识并严格清理。
- [x] 集成测试覆盖非法分页、非法层级、非法状态、超长名称或编号、默认分页、显式分页、未认证及无效 Token 等关键行为。
- [x] OpenAPI 准确描述店铺列表已有字段的校验限制和分页契约,生成结果中不出现与本切片无关的漂移。
- [x] UR#60 中文总结及 README 索引记录本切片的参数错误协议、默认分页行为、验证证据和明确不迁移的旧代码范围。
- [x] 运行本切片目标测试、格式化、静态检查和相关构建,确认该行为可独立交付且不破坏原店铺列表语义。

View File

@@ -0,0 +1,27 @@
# 02 — 交付联系电话精确查询与数据库索引
**What to build:** 让有权限的后台运营人员能够在店铺列表页面输入完整的 11 位 ASCII 联系电话并精确定位所有可见匹配店铺。该切片贯通前端筛选交互、现有列表 API、数据权限、数据库等值查询和性能索引并用真实请求完成加载、空态、失败、组合筛选和重复号码验收。
**Blocked by:** 01 — 修复店铺列表参数校验与默认分页。
**Status:** ready-for-human
**架构通道:** 主通道为现有读取链路 `Handler → Service → Store → GORM/DTO`;辅助通道为 Infrastructure 数据库迁移和跨仓前端交付。
**完整业务边界:** 完整收口店铺列表联系电话精确查询这一用户行为包括后端、数据库、前端、接口契约和验收证据。明确不修改店铺创建或更新接口的电话校验不清洗历史号码不建立唯一约束不新增缓存、聚合、Repository 抽象或 Query 目录,也不改变前端既有页码状态策略。
- [x] 店铺列表接受可选 `contact_phone` 参数;非空值必须完整满足 11 位 ASCII 数字规则,空字符串与未传参数均视为不启用电话筛选。
- [x] 联系电话使用数据库等值匹配,不接受空格、全角数字、国家码、连字符、字母或长度不符的输入,也不执行 trim 或号码格式修复。
- [x] 联系电话与所有已有筛选条件按 AND 组合,计数查询与分页数据查询使用完全一致的过滤条件。
- [x] 相同联系电话的多个可见店铺均可返回;无匹配项返回成功空分页,不返回资源不存在错误。
- [x] 查询保持 `created_at DESC` 排序并排除软删除记录;代理账号无法通过相同联系电话看到本店铺及下级范围之外的店铺。
- [x] 超级管理员、平台账号和代理账号的真实 HTTP 集成测试覆盖精确匹配、非模糊匹配、重复号码、组合筛选、空参数、非法格式、空结果、分页、排序、软删除和数据隔离。
- [x] 数据库查询失败时返回脱敏的 HTTP 500、`code=2001``msg=内部服务器错误`,响应不泄露 SQL、主机、库名或驱动错误。
- [x] 数据库迁移创建名为 `idx_shop_contact_phone` 的非唯一部分 B-tree 索引,仅覆盖未软删除店铺的 `contact_phone`;回滚只删除该索引,不修改业务数据。
- [x] 在可控开发环境验证向上迁移、向下回滚和再次向上迁移,确认索引类型、列、非唯一属性及 `deleted_at IS NULL` 条件均符合契约。
- [x] OpenAPI 出现 `contact_phone`,准确描述 11 位 ASCII 数字、精确匹配、空值行为、AND 组合和分页限制;复用现有 Handler不新增文档生成器实例。
- [ ] 后台店铺列表筛选区提供“联系电话”输入、查询和清空能力;非法值展示中文提示并阻止请求,空值不提交该参数,合法值与其他筛选一并提交。
- [ ] 前端查询期间展示加载状态,无匹配项展示空态,请求失败展示可重试反馈且不保留伪装成新结果的旧数据。
- [ ] 浏览器网络请求与真实接口响应共同证明精确匹配、AND 组合、重复号码、空结果、合法分页和代理隔离,而不是只依据页面展示验收。
- [x] UR#60 中文总结及 README 索引记录本切片的前后端契约、索引上线与回滚核验、测试证据以及明确排除的历史数据清理范围。
- [ ] 运行前后端各自的目标测试、格式化、静态检查和构建;本切片通过人工验收后可独立演示完整联系电话查询行为。(后端已完成;当前仓库无前端源码,待前端仓库执行并人工验收。)

View File

@@ -0,0 +1,22 @@
# 03 — 禁止企业账号访问核心店铺管理路由
**What to build:** 让企业账号在调用店铺列表、创建、更新、删除和联级查询五个核心店铺管理入口时始终得到一致的禁止访问响应,同时保持超级管理员、平台账号和代理账号的现有能力,并证明其他 `/shops` 前缀业务入口未被误拦截。该权限行为通过真实认证请求独立验收,并同步固化到接口与需求文档。
**Blocked by:** None — can start immediately.
**Status:** completed
**架构通道:** 路由与中间件权限通道;列表读取仍沿用现有读取链路。
**完整业务边界:** 只收口五个核心店铺管理路由的企业账号访问限制。明确不重新设计店铺角色、代理商资金概况、提现、佣金、钱包流水等独立路由的权限,也不改变认证机制或其他账号类型的数据范围。
- [x] 企业账号访问店铺列表、创建、更新、删除或联级查询时,统一收到 HTTP 403、`code=1005``msg=无权限访问店铺管理功能``data=null` 和时间戳。
- [x] 企业账号在进入核心 Handler 和业务查询前被拒绝,不能因没有代理店铺范围而获得全局店铺数据。
- [x] 超级管理员和平台账号继续拥有原有核心店铺管理能力;代理账号继续遵循本店铺及全部下级店铺的数据范围。
- [x] 访问限制只作用于五个核心店铺管理路由,不误拦截店铺角色、代理商资金概况、提现、佣金、钱包流水等独立路由。
- [x] 真实 HTTP 集成测试覆盖四类账号的核心列表行为,以及企业账号访问其余四个核心路由的 403 行为。
- [x] 回归测试证明独立 `/shops` 前缀路由仍由各自已有权限规则处理,未因路由分组调整产生权限漂移。
- [x] 成功和拒绝响应均保持统一 `code``msg``data``timestamp` 外层结构,认证失败仍沿用既有认证错误契约。
- [x] OpenAPI 或对应接口说明准确记录核心店铺管理的账号权限边界,不改变店铺角色、资金、佣金、提现和钱包流水的既有契约。
- [x] UR#60 中文总结及 README 索引记录本切片的五个受限入口、未受影响路由、四类账号验证证据和明确不重新设计的授权范围。
- [x] 运行本切片目标测试、格式化、静态检查和相关构建,确认权限切片能够独立发布和回滚。

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,21 @@
# 01 — 资产详情展示可控前代换货信息
**What to build:** 在统一资产详情中增加稳定的 `exchange_trace` 对象,并打通当前资产作为换货新资产时的前代查询。关系以未软删除的已完成换货单为权威来源,展示旧资产的不可变标识快照和换货单号;关联资产仍在调用方现有数据权限内时返回真实 ID 并允许跳转,无权限时保留历史文本但隐藏 ID。主架构通道为 Query数据库索引为辅助 Infrastructure完整边界止于“当前资产 → 前代”的只读投影,不迁移换货状态机、换货写侧或资产详情的其他旧读取逻辑,也不新建关系表。
**Blocked by:** None — can start immediately
**Status:** ready-for-agent
- [ ] 卡和设备通过统一资产详情查询时始终返回 `exchange_trace` 对象;没有前代时 `previous_asset``null`,不省略整个对象。
- [ ] 当前资产作为新资产出现在未软删除且状态为已完成的换货单中时,`previous_asset` 返回旧资产类型、换货单快照标识、换货单号、可空资产 ID 和可见性标记。
- [ ] 前代标识直接使用换货单保存的不可变快照,不回查或改写关联资产的当前标识。
- [ ] 关联前代资产通过现有卡或设备数据权限检查;有权限时返回真实 ID 和 `can_view=true`,无权限时返回 `asset_id=null``can_view=false`,且不返回店铺、客户、套餐、钱包、状态等额外信息。
- [ ] 关系查询不应用关联资产的数据权限过滤,不会因关联资产不可见而抹掉换货关系;当前资产本身仍先经过现有权限校验,未授权请求维持原有防枚举响应。
- [ ] 待填写、待发货、已发货待确认、已取消及软删除换货单均不会形成前代关系。
- [ ] 查询存储故障返回统一脱敏内部错误,不会被降级为“无换货关系”,并记录包含查询方向和当前资产上下文的中文错误日志。
- [ ] 增加已完成且未软删除范围内支持“新资产类型 + 新资产 ID”最新记录查询的非唯一部分 B-tree 索引,并验证向上迁移、向下迁移和代表性查询计划。
- [ ] 同一资产存在多条“当前资产为新资产”的异常完成记录时,按 `completed_at` 降序、主键降序确定性选择最新一条,并记录包含当前资产、候选数量和最终换货单的中文异常日志。
- [ ] Query 测试覆盖无关系、卡前代、设备前代、可见前代、不可见前代、非完成状态、软删除记录和异常重复完成记录。
- [ ] HTTP 集成测试使用真实 PostgreSQL、Redis、JWT 和认证中间件,贯穿当前资产权限、资产解析、前代 Query 与统一响应格式,并证明当前资产无权限时不会因换货关系泄露其存在。
- [ ] 更新资产详情 OpenAPI 的前代响应契约,明确 `exchange_trace` 始终存在、`previous_asset` 可为 `null`,以及 `can_view` 与可空 `asset_id` 的跳转规则。
- [ ] 在 UR#86 中文总结中记录前代查询的架构通道、权限防泄露、异常选择规则、索引发布与回滚方式,并准备无换货、前代可见和前代不可见的人工验收步骤。

View File

@@ -0,0 +1,23 @@
# 02 — 资产详情展示后代并形成双向换货链
**What to build:** 扩展统一资产详情换货投影,打通当前资产作为换货旧资产时的后代查询,使连续换货 A→B→C 中的中间资产 B 可以同时展示前代 A 和后代 C。后代同样使用换货单不可变快照并按关联资产当前权限决定是否返回可跳转 ID。主架构通道为 Query数据库索引为辅助 Infrastructure完整边界止于单节点前代和后代的固定次数查询不递归整条换货树、不新增换货链列表接口、不改变资产权限矩阵。
**Blocked by:** `.scratch/ur86-asset-exchange-trace/issues/01-asset-previous-exchange-trace.md` — 01 — 资产详情展示可控前代换货信息
**Status:** ready-for-agent
- [ ] 当前资产作为旧资产出现在未软删除且状态为已完成的换货单中时,`next_asset` 返回新资产类型、换货单快照标识、换货单号、可空资产 ID 和可见性标记;没有后代时为 `null`
- [ ] 卡和设备共用同一套 Query 投影逻辑,不为两种资产复制换货链查询流程。
- [ ] 后代资产有权限时返回真实 ID 和 `can_view=true`;无权限时保留快照标识及换货单号、返回 `asset_id=null``can_view=false`,不泄露其他业务信息。
- [ ] A→B→C 场景查询 B 时同时返回 A 和 C`previous_asset``next_asset` 互不覆盖;查询 A 和 C 时分别只返回存在的方向。
- [ ] 待填写、待发货、已发货待确认、已取消及软删除换货单均不会形成后代关系。
- [ ] 增加已完成且未软删除范围内支持“旧资产类型 + 旧资产 ID”最新记录查询的非唯一部分 B-tree 索引,并验证向上迁移、向下迁移和代表性查询计划。
- [ ] 前代和后代的关联资产可见性采用固定次数批量加载;查询次数不随设备绑定卡数量或其他列表数据增长,不产生 N+1。
- [ ] 同一资产存在多条“当前资产为旧资产”的异常完成记录时,按 `completed_at` 降序、主键降序确定性选择最新一条,并记录包含当前资产、候选数量和最终换货单的中文异常日志。
- [ ] Query 测试覆盖仅后代、A→B→C 中间资产、卡链、设备链、关联后代可见与不可见、非完成状态、软删除记录和异常重复完成记录。
- [ ] HTTP 集成测试使用真实 PostgreSQL、Redis、JWT 和认证中间件,贯穿当前资产权限、资产解析、双向换货 Query 与统一响应格式,覆盖无换货、仅前代、仅后代、中间资产及关联资产不可见场景。
- [ ] 使用代表性 PostgreSQL 数据验证两条部分索引的结构和查询计划,资产详情增加双向换货投影后仍满足项目数据库查询与 API 性能目标,且无 N+1。
- [ ] 完成资产详情 OpenAPI 双向契约,明确 `previous_asset``next_asset` 的空值语义,以及仅在 `can_view=true``asset_id` 非空时允许跳转。
- [ ] 完成 UR#86 中文总结并更新 README 索引,记录完整权限边界、异常选择规则、发布顺序和回滚方式;上线前抽样核验历史新旧资产 ID 与快照完整性,只记录异常、不回填数据。
- [ ] 前端人工验收覆盖无换货、换货新资产、已换出旧资产和 A→B→C 中间资产;无权限关联项只显示快照标识和换货单号,不渲染链接或可点击样式。
- [ ] 与 UR#45、UR#98 同窗发布时,仅将已实际完成的对应 Ticket 纳入发布前置检查;若形成跨 PRD 实施阻塞,必须先引用其已发布的具体 issue 文件路径和标题更新本票,不能使用模糊依赖描述。

View File

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

View File

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

View File

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

View File

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

View File

@@ -90,6 +90,18 @@ handlers := &bootstrap.Handlers{
4. 当前需求触碰复杂只读逻辑时,只迁移该查询到 `internal/query/<context>`Query 可直接使用 GORM 做联表、聚合、权限过滤和 DTO 投影。
5. 不为追求 DDD 形式创建无业务价值的接口、工厂和目录;架构选择不明确时先阅读 DDD 规范并写明判断依据。
### 规划与任务拆分约束
创建或修改 PRD、OpenSpec proposal/design/tasks、实施计划或本地 Issue/Ticket 前,必须完整阅读 `docs/7月迭代/独立方案/基础规范/DDD规范.md`,并遵守以下规则:
1. 每个可实施单元必须标明适用的架构通道复杂写、简单写、Query、Infrastructure 或 Application + Port/Adapter同一纵向切片涉及多条通道时标明主通道和辅助通道。
2. 每个任务必须说明其收口的完整业务边界及明确不迁移的旧代码范围,禁止借任务拆分扩大为模块级或全仓重构。
3. Ticket 必须按可独立验证的纵向切片拆分,不得按“先建表、再 Service、再 Handler”生成水平分层任务宽范围机械迁移按 expandmigratecontract 拆分并保持各阶段可验证。
4. 复杂写的不变量、状态机、金额、并发和可靠事件必须完整进入 Domain/Application 边界;简单写不得强行创建聚合;读取不得经过聚合根或修改状态。
5. 已评审 PRD 或 OpenSpec 已确定的架构选择是实施契约,拆票和实现阶段不得自行改变;确需调整时必须先说明影响并获得用户确认。
6. 跨 PRD 或跨 Change 的依赖必须引用具体任务或 Ticket禁止只写“依赖公共基础设施”等无法判定完成状态的模糊阻塞项。
7. 任务正文只记录本切片适用的架构约束,不机械复制整份 DDD 规范;实现和评审仍以本文件及完整 DDD 规范为准。
## 核心原则
### 错误处理
@@ -179,12 +191,6 @@ StatusName string `json:"status_name" description:"状态名称(中文)"` //
- 异常处理panic/recover
- 类型前缀IService、AbstractBase、ServiceImpl
## ⚠️ 测试禁令(强制执行)
**本项目禁止任何形式的自动化测试**(单元/集成/E2E/`*_test.go` 文件),规划和文档中也不讨论测试。
**唯一例外**:用户明确说"请写测试"时。
**替代验证**PostgreSQL MCP 手动验证数据、Postman/curl 手动测试 API。
## 性能要求
- API P95 响应时间 < 200ms

View File

@@ -17,3 +17,97 @@
**有效授权**`revoked_at IS NULL AND deleted_at IS NULL` 的授权记录。撤回授权后(`revoked_at` 有值)视为历史记录,不计入当前授权关系。
**设备企业授权**:将设备(含其绑定卡)授权给企业使用的操作。同一台设备同一时间只能授权给一个企业(`uq_active_device_auth` 唯一约束)。
## 代理资金Agent Funds
**代理主钱包 / Agent Main Wallet**:代理店铺承担订单结算和债务的唯一钱包;信用额度只属于该钱包,不属于角色、平台员工、佣金钱包或资产钱包。
**信用额度 / Credit Limit**:平台授予某个代理主钱包、允许其现金不足时继续使用的最高资金边界。额度本身不是余额,也不是一笔充值。
**角色默认信用模板 / Role Default Credit Template**:客户角色为未来新建代理提供的信用开关和额度默认值;创建主钱包时复制一次,之后与该钱包实际信用额度相互独立。
**现金可用金额 / Cash Available Amount**:代理主钱包的账面余额减冻结金额,不包含信用额度。
**总可用金额 / Total Available Amount**:现金可用金额加当前启用的信用额度,是主钱包扣款和冻结可否执行的资金边界。
**欠款 / Debt**:代理主钱包账面余额低于零的部分;冻结信用不直接形成欠款。
**代理自主在线充值 / Agent Self-service Online Recharge**:代理只能为当前登录账号所属店铺的主钱包发起在线充值,请求不接受目标 `shop_id`;代理选择支付方式 `wechat``alipay`,无需审批。平台、超级管理员和企业账号不能用该路径替代理创建在线支付二维码。
**代理充值金额边界 / Agent Recharge Amount Boundary**:代理在线扫码充值金额为 `10000100000000`100 元至 100 万元);平台或超级管理员线下代充值保持现有 `1100000000` 分,即金额必须大于 0 且最高 100 万元。两条路径使用各自的金额校验,不能把在线起付金额套在线下代充值上。
**平台线下代充值 / Platform Offline Recharge**:平台或超级管理员为指定代理店铺主钱包发起的 `offline` 充值,必须明确目标 `shop_id` 并进入企业微信审批;代理和企业账号不能发起该路径。
**线下代充值申请资料 / Offline Recharge Application Materials**:平台或超级管理员创建线下代充值时,目标 `shop_id` 和充值金额必填;付款凭证必传 15 个,每项提交本地对象存储的 `file_key`、原始 `file_name``file_size`;备注选填且最多 500 字。充值金额在创建时固定,企微审批人只能同意或拒绝,不能修改金额。
**线下代充值驳回终结 / Offline Recharge Rejection Finalization**:企微拒绝后,原线下充值单以 `6=已驳回`终结,原申请资料、审批结果和拒绝原因保留用于审计。系统不提供退回修改、`7=已退回`或原单重新提交;修正金额、凭证或备注后仍需充值时,必须创建新的充值单和企微审批。
**线下代充值审批撤销 / Offline Recharge Approval Cancellation**:企微在通过前撤销或删除时,充值单改为 `4=已关闭`且不入账,可以重新创建;企微通过后、钱包尚未入账时发生撤销,终止入账并关闭充值单;钱包已经入账后再收到撤销时不自动扣减代理余额,充值单保持已完成,同时记录严重 Audit Event 并通知财务人工处理。
**线下代充值停机迁移 / Offline Recharge Cutover Migration**:停机发布时,历史已完成、已关闭、已退款和已驳回的线下充值只读保留,不补企微审批。仍待处理的线下充值仅在真实提交人平台账号已绑定企微且申请资料完整时创建真实企微审批继续处理;其余记录进入明确的迁移异常清单,不自动入账,也不能再通过旧 `offline-pay` 处理。
**代理充值支付配置 / Agent Recharge Payment Configuration**:代理只选择支付方式 `wechat``alipay`,前端不提交、不选择支付通道。后端分别使用当前配置好的微信或支付宝支付参数创建支付单;本期不建设多通道池、优先级、自动切换或故障转移。支付单仍需固化创建时使用的支付配置,以便后续查询、回调验签和对账使用原配置。
**代理充值可用支付方式 / Available Agent Recharge Payment Methods**`GET /api/admin/agent-recharges/payment-methods` 只返回配置完整且当前能够创建支付的 `wechat` 和/或 `alipay`,不向前端暴露商户号、支付通道或敏感配置诊断。创建充值时必须再次校验所选方式;配置在列表查询后失效时直接拒绝创建。
**代理充值付款码内容 / Agent Recharge QR Content**:微信或支付宝预下单返回的字符串或 HTTPS URL 由后端通过 `qr_content` 原样交给前端,前端自行渲染二维码;后端不生成二维码图片,也不提供独立的二维码生成接口。创建接口不返回 `expires_at`,前端不展示精确倒计时;用户重新拉起支付时创建全新的充值单和支付单,不复用原单。
**代理充值第三方失效 / Agent Recharge Third-party Expiration**:本地计算时间不能代表微信或支付宝订单的真实有效期,也不能据此直接关闭业务单。第三方查单确认订单未支付且已经关闭或失效后,不新增支付单状态码,现有 `tb_payment` 使用 `2=已失败`并记录第三方失效原因,对应充值单使用 `4=已关闭`。用户后续重新创建新充值单和新支付单。
**代理充值迟到成功回调 / Late Successful Recharge Callback**:本地因付款码过期而标记失败或关闭后,若收到微信或支付宝的有效成功回调,并完成第三方交易号、金额、创建时支付配置及业务关联核验,则以支付平台真实收款事实为准,将支付单恢复为已支付并对原充值单执行幂等钱包入账,不能吞掉用户已经支付的资金。
**代理充值预下单结果未知 / Unknown Recharge Pre-order Result**:微信或支付宝明确返回预下单失败时,支付单记为已失败、充值单记为已关闭,用户使用新的 `request_id` 创建新单。请求超时或结果未知时,不得直接关闭或另建支付;必须先按原支付单号向原支付配置查单,确认失败后才能关闭,确认成功则返回原 `qr_content`。同一提交账号重复使用相同 `request_id` 时始终返回原业务结果;参数发生变化则拒绝幂等冲突。
**代理充值主动新建 / Deliberate New Agent Recharge**`request_id` 只防止同一次提交因网络重试重复创建。代理每次主动点击创建或拉起支付,都必须使用新的 `request_id`,后端创建全新的充值单、支付单和 `qr_content`,不根据相同金额或既有未过期支付单复用旧单。此前未付款的支付单继续等待各自自然过期;多张支付单若均真实付款,则分别幂等入账。
**代理在线充值支付验收 / Agent Online Recharge Payment Acceptance**:本地和 Agent 自动化不调用真实微信或支付宝,复用 C 端已经验证的支付基础能力,并在支付网络边界使用可控替身验证后台充值的预下单契约、回调分发、金额与业务关联校验、状态推进、幂等入账及异常恢复。真实微信 Native 和支付宝 PreCreate 扫码支付在部署测试环境后由用户手工验收,不作为本地实现完成门禁;线下充值依赖的真实企微验收仍按企微公共能力执行。
**代理充值支付状态同步 / Agent Recharge Payment Status Synchronization**:前端每 3 秒调用轻量支付状态接口时只读取本地状态,不直接触发微信或支付宝查单。后端可靠任务按受控频率查询仍待支付的第三方订单,用于补偿回调丢失并确认第三方关闭或失效;查单结果和支付回调进入同一个幂等支付确认用例。只有第三方明确返回支付成功或已关闭时才推进本地状态,查询超时或未知状态继续保持待支付;用户主动创建新支付不影响旧单继续同步和自然收敛。
**充值支付状态与入账状态 / Recharge Payment and Processing Status**`payment_status` 只表达第三方支付是否完成;`processing_status` 统一表达在线支付成功或线下审批通过后的钱包入账进度,固定为 `0=未触发, 1=处理中, 2=处理成功, 3=处理失败`,不再提供同义字段 `wallet_posting_status`。第三方已经收款但钱包入账失败时,支付状态仍为成功,处理状态为失败并由可靠任务重试;前端不得提供人工重复入账按钮。
**代理充值两阶段入账 / Two-stage Agent Recharge Posting**:在线支付回调事务只固化真实收款事实:支付单变为已支付、充值单变为 `2=已支付``processing_status=1`,并可靠写入入账 Outbox随后由 Worker 在独立事务中更新代理主钱包余额和版本、创建唯一充值流水、把充值单改为 `3=已完成``processing_status=2`,并写资金 Audit Event。Worker 失败不回滚支付事实,改为 `processing_status=3`并可靠重试;支付回调在收款事实成功落库后即可向渠道返回成功,不等待钱包入账。
**代理充值到账通知 / Agent Recharge Posted Notification**:无论代理在线扫码充值还是平台线下代充值,只要目标代理主钱包实际入账成功,都必须向目标代理主账号发送一条站内“充值到账”通知;在线充值的实际提交账号与目标代理主账号不同时,实际提交账号也接收一条。线下充值的真实业务提交人只接收企微公共能力的审批结果通知,除非其本身也是到账通知接收人。审批结果与实际到账是两类不同通知;通知投递失败不回滚资金事务,并按充值单与接收人防重。入账持续失败、通过后撤销等内部异常只通知平台财务或运维,不向代理暴露内部错误。
**代理充值查看范围 / Agent Recharge Visibility**:充值记录不按创建账号隔离。代理账号在具备充值查看权限时,按既有店铺层级数据范围查看本店及有权管理的下级店铺充值,可读取充值金额、支付方式、付款凭证、真实提交人、审批状态和钱包入账结果,但不能读取企微审批人、内部意见、审批人附件或支付配置等内部信息。平台和超级管理员沿用既有数据范围,企业账号不可访问;列表、详情和支付状态接口使用完全相同的数据范围。
## 企业微信审批WeCom Approval
**业务提交人 / Business Submitter**:在本系统实际创建退款或线下充值申请的登录账号,是审批单展示、通知、数据权限和审计中的真实发起人。
**企微发起身份 / WeCom Creator Identity**:调用企微 `applyevent` 时使用的成员 `userid`。平台/超级管理员使用当前系统账号扫码绑定的本人 `userid`;代理使用部署配置中的固定企微账号代提交。无论身份来源如何,审批实例和表单都必须独立保存真实业务提交人。
**单次退款申请 / Single Refund Application**:一张退款单只对应一条企微审批申请。企微拒绝后,审批和该退款单同时终结,原退款单不可修改、不可重提;业务人员纠正问题后若仍需退款,必须重新创建退款单,生成新的退款 ID、退款单号、业务快照和企微审批申请。
**整单退款终结 / Whole-order Refund Finalization**:退款创建接口只接受订单 ID、申请退款金额、原因、备注和附件订单实收金额由后端读取并固化快照不接受前端提交的 `actual_received_amount`,也不再接受 `package_usage_id`。申请退款金额必须大于 0 且小于等于订单实收金额,提交后不可由企微审批修改。无论申请金额是否等于实收金额,企微通过后的业务处理都按整张订单终结:订单标记已退款、该订单产生的套餐整体失效、该订单已入账佣金整体回扣;该订单不能再对未退差额发起第二次退款。
**资产钱包订单退款 / Asset-wallet Order Refund**:个人客户使用资产钱包余额购买的订单允许创建退款申请,但退款资金不自动回充资产钱包。财务必须先在系统外向客户完成人工退款,再在企微同意;企微同意后系统只执行整单终结,不调用支付渠道,也不产生资产钱包退款入账。只有代理主钱包支付的订单才按原扣款流水自动回溯原代理主钱包。
**退款申请资料 / Refund Application Materials**:退款原因必填,最多 1000 字;申请备注选填,最多 500 字。退款业务凭证必传 15 个,每项提交本地对象存储的 `file_key`、原始 `file_name``file_size`;本地业务资料是权威事实,上传企微的文件仅为审批副本,不能用审批人附件替代申请凭证。
**退款佣金失效 / Refund Commission Invalidation**:订单整单退款时,该订单尚未发放、解冻中、冻结中或待人工处理的佣金不再具备发放资格,直接改为已失效;已经发放的佣金先从对应佣金钱包全额扣回,钱包余额允许为负,再将佣金记录改为已失效。佣金记录使用结构化 `invalid_reason` 区分 `order_refund``manual_resolution`,因退款失效时同时保存退款单引用和失效时间,不能只在备注中写原因。每条已发放佣金以退款单和佣金记录组成业务防重键;佣金状态、钱包余额、扣回流水与统一 Audit Event 在同一事务提交,审计关联退款、订单、佣金、钱包和流水并保存变更前后事实。
**固定退款金额审批 / Fixed Refund Amount Approval**:创建退款时后端校验 `0 < requested_refund_amount <= actual_received_amount` 并固化金额。企微审批单只读展示订单实收金额、可退款区间和本次申请金额;审批人只能同意或拒绝,不能修改金额,认为金额有误时应拒绝,由业务人员创建新退款单。本系统不回改企微审批结论:企微已通过后即使本地资金或整单终结处理失败,审批状态和退款状态仍保持已通过,以独立 `processing_status=失败`、失败摘要和可靠重试表达本地失败。
**实际退款金额 / Actual Refund Amount**`actual_refund_amount` 只表示资金已经实际完成的金额,不等同于审批金额。非代理钱包由财务先在系统外退款再同意企微,企微通过时写入申请金额;代理主钱包只有余额回溯与唯一退款流水事务成功后才写入申请金额。驳回、撤销、删除及尚未完成代理钱包回溯时为空;资金已完成但佣金或套餐处理失败时保留该金额,并由业务处理状态说明整单终结尚未完成。旧 `approved_refund_amount` 只保留历史兼容,不作为新业务对外概念。
**代理退款查看范围 / Agent Refund Visibility**:退款申请向代理开放后,不再按创建账号隔离。代理主账号及店铺内具备退款查看权限的账号按既有店铺层级数据范围查看本店及有权管理的下级店铺退款;退款资料、业务凭证、真实提交人、审批状态和业务处理结果仍受业务权限与数据范围约束。任何代理均不得看到企微审批人、内部意见或审批人附件;平台和超级管理员只有同时具备退款业务查看权限时才可读取完整审批详情。
## 批量订购Bulk Purchase
**批量订购批次 / Bulk Purchase Batch**:内部员工选择一个套餐和一种统一支付方式后提交的一份 CSV 订购任务。CSV 只提供资产标识;批次不绑定单一代理,同一批次可以包含不同代理名下的资产。
**结算代理 / Settlement Agent**:批量订购每一行根据资产处理时的当前归属解析出的代理。该行的套餐授权、成本价和代理主钱包均以结算代理为准;结算代理不是前端提交的批次参数。
**批量订购重复行 / Duplicate Bulk Purchase Row**Worker 通过统一资产解析能力将输入标识解析为唯一的资产类型和资产 ID 后,同一 CSV 中再次指向该资产的行。一个批次只有一个套餐,因此无需再把套餐作为重复键;同一资产使用不同可识别标识仍属于重复,首次出现的行正常处理,后续重复行失败并指向首次出现的行号。
**批量订购资产标识 / Bulk Purchase Asset Identifier**:批量订购 CSV 中唯一由用户填写的业务字段。Worker 复用系统统一资产解析能力,由标识识别资产类型和资产 ID当前统一能力可识别 ICCID、卡 `virtual_no`、MSISDN、设备 `virtual_no`、IMEI 和 SN。未命中或无法唯一解析时该行失败。
**批量订购钱包支付 / Bulk Purchase Wallet Payment**:批量订购的 `payment_method=wallet` 复用现有后台订单枚举,逐行扣该资产结算代理的主钱包;不使用同义值 `agent_wallet`
**批量订购文件级失败 / Bulk Purchase File-level Failure**:创建接口只检查对象存储对象、上传主体、文件类型和 10MB 大小。Worker 发现编码、表头、未知列、CSV 语法、空文件或超过 1000 行时,任务整体失败且不创建订单;可部分成功仅适用于文件有效后的逐行业务校验。
**批量订购钱包行序 / Bulk Purchase Wallet Row Order**`wallet` 批次按 CSV 行号逐行结算不预占整批或某一代理全部行的金额。余额不足只失败当前行并继续后续行所以同一代理资金不足时CSV 行序就是订购优先级。
**异步任务完成 / Async Task Completed**:异步任务已经把其可处理工作执行到终点,不等于每个业务项都成功。全部成功、部分成功和全部业务项失败由成功数与失败数表达;只有任务无法完成解析或执行时才属于任务失败。

View File

@@ -224,6 +224,7 @@ default:
- **资产操作审计日志**:新增 `tb_asset_operation_log`,统一覆盖卡/设备敏感写操作与统一资产入口,记录 `success/failed/denied`、前后镜像、请求上下文、批量统计并支持敏感字段脱敏;详见 [功能总结](docs/add-asset-operation-audit-log/功能总结.md)、[接口回放示例](docs/add-asset-operation-audit-log/接口回放示例.md) 与 [SQL 验收脚本](docs/add-asset-operation-audit-log/手工验收脚本.sql)
- **RBAC 权限系统**:完整的基于角色的访问控制,支持账号、角色、权限的多对多关联和层级关系;基于店铺层级的自动数据权限过滤,实现多租户数据隔离;使用 PostgreSQL WITH RECURSIVE 查询下级店铺并通过 Redis 缓存优化性能完整的权限检查功能支持路由级别的细粒度权限控制支持平台过滤web/h5/all和超级管理员自动跳过详见 [功能总结](docs/004-rbac-data-permission/功能总结.md)、[使用指南](docs/004-rbac-data-permission/使用指南.md) 和 [权限检查使用指南](docs/permission-check-usage.md)
- **商户管理**完整的商户Shop和商户账号管理功能支持商户创建时自动创建初始坐席账号、删除商户时批量禁用关联账号、账号密码重置等功能详见 [使用指南](docs/shop-management/使用指南.md) 和 [API 文档](docs/shop-management/API文档.md)
- **UR#60 店铺联系电话精确查询**:店铺列表支持 11 位 ASCII 联系电话精确筛选,统一执行查询参数校验并返回一致的默认分页元数据,企业账号禁止访问五个核心店铺管理入口;详见 [功能总结](docs/ur60-shop-phone-search/功能总结.md)。
- **B 端认证系统**:完整的后台和 H5 认证功能,支持基于 Redis 的 Token 管理和双令牌机制Access Token 24h + Refresh Token 7天包含登录、登出、Token 刷新、用户信息查询和密码修改功能通过用户类型隔离确保后台SuperAdmin、Platform、Agent和 H5Agent、Enterprise的访问控制**登录响应包含菜单树和按钮权限**menus/buttons前端无需二次处理直接渲染侧边栏和控制按钮显示详见 [API 文档](docs/api/auth.md)、[使用指南](docs/auth-usage-guide.md)、[架构说明](docs/auth-architecture.md) 和 [菜单权限使用指南](docs/login-menu-button-response/使用指南.md)
- **B 端认证系统**:完整的后台和 H5 认证功能,支持基于 Redis 的 Token 管理和双令牌机制Access Token 24h + Refresh Token 7天包含登录、登出、Token 刷新、用户信息查询和密码修改功能通过用户类型隔离确保后台SuperAdmin、Platform、Agent和 H5Agent、Enterprise的访问控制详见 [API 文档](docs/api/auth.md)、[使用指南](docs/auth-usage-guide.md) 和 [架构说明](docs/auth-architecture.md)
- **生命周期管理**:物联网卡/号卡的开卡、激活、停机、复机、销户
@@ -917,6 +918,7 @@ rdb.Set(ctx, key, status, time.Hour)
- **[API 文档生成规范](docs/api-documentation-guide.md)**路由注册规范、DTO 规范、OpenAPI 文档生成流程
- **[数据库验证规范](AGENTS.md#数据库验证规范)**:使用 PostgreSQL MCP 验证接口逻辑和业务数据的正确性
- **[开发规范总览](AGENTS.md)**:完整的项目开发规范(必读)
- **[七月迭代 AI 实施与验收操作手册](docs/7月迭代/七月迭代-AI实施与验收操作手册.md)**PRD 拆票、Issues 实现、测试、双轴评审与验收流程
### 功能指南

View File

@@ -1,7 +1,7 @@
# 7月迭代技术方案标准评审稿
> 状态:评审
> 最后更新2026-07-17
> 状态:评审
> 最后更新2026-07-20
> 分支:`Iteration/7-11`
> 系统:`junhong_cmp_fiber` 及配套后台、代理端、C 端前端
> 负责人:待指定
@@ -38,7 +38,7 @@
- 套餐临期企业微信消息推送;本期先完成站内通知和防重记录。
- 全仓 MVC/贫血模型一次性重构。
- 为旧退款和线下充值审批接口建设长期兼容层。
- 后端提供 Excel 模板下载接口;模板由前端静态资源随版本发布。
- 后端提供批量导入模板下载接口;CSV 模板由前端静态资源随版本发布。
- 微信/支付宝自动退款,以及基于套餐规则的自动限速。
- 运营商解除实名后自动回滚本地实名状态;第一版只保留防腐层入口和集成记录。
- 禅道草稿 #41“代理查询限制”和 #51“不同品类资产换货”;草稿不进入本期开发、工时和验收。
@@ -52,18 +52,18 @@
| 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-06 | 批量订购使用 CSV支付方式整批统一,CSV 不包含支付方式 | 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-13 | 设备批量分配代理与套餐系列拆为两个独立 CSV 命令,文件先直传私有对象存储,业务接口只接收 `file_key` 和唯一目标 | 08 |
| D-14 | 排队套餐使用购买时长快照推算预计最终到期时间;资产详情、临期和导出共用实时 Query定时任务仅发送通知 | 06、11、22 |
| D-15 | 行业卡是否需要实名由运营商 `realname_link_type` 决定,`card_category` 不参与实名复机和轮询判断 | 01、02、数据同步 |
| D-15 | 卡是否需要实名由运营商 `realname_link_type` 决定,`card_category` 不参与实名复机判断;本期不重新设计现有周期轮询资格 | 01、02、数据同步 |
| D-16 | 数据同步保留轮询兜底关键业务事件按立即、3 分钟、5 分钟触发;超频不建立退避状态 | 数据同步 |
| D-17 | 企微账号由已登录平台员工扫码自助绑定,普通运营不录入 `userid` | 企微审批 |
| D-17 | 企微发起身份按账号类型分流:平台/超级管理员必须扫码绑定并使用本人 `userid`,代理使用部署配置中的固定企微账号代提交;真实业务提交人始终独立进入审批表单、通知和审计 | 企微审批 |
| D-18 | 全系统审计本次一次性切换到 Audit Event + Integration Log多视角 API 和前端同时发布 | 全局 |
| D-19 | 代理在线充值最低 100 元,支持微信 Native 和支付宝 PreCreate支付成功直接入主钱包且不审批 | 21、新增充值 |
| D-20 | 资产层只展示一个预计最终到期时间;当前套餐和全部排队主套餐共同参与推算,临期也使用同一结果 | 06、11、22 |
@@ -72,10 +72,11 @@
| D-23 | 代理主钱包现金可用余额降至 100 元及以下时,向代理主账号和店铺业务员发送一次站内通知;信用额度不参与预警计算 | 禅道 #97 |
| D-24 | 客户角色只提供新建店铺的默认信用配置;修改角色配置不更新任何已有店铺,已有店铺额度只能单独调整 | 17 |
| D-25 | 代理系列套餐授权复用现有批量接口;前端批量选择,已授权套餐明确标记并置灰 | 禅道 #43 |
| D-26 | 一张退款单只对应一条企微审批;企微拒绝同时终结审批和退款单,原单不可修改或重提,后续仍需退款时必须重新创建退款单 | 20、企微审批 |
### 1.4 本期边界
本期包含企业微信审批、账号扫码绑定、回调与轮询补偿;不再保留“先做站内审批、以后切企微”的中间态。套餐临期的企业微信消息推送仍属于后续扩展,本期只实现站内通知及外部渠道可扩展边界。
本期包含企业微信审批、平台账号扫码绑定、代理固定企微账号代提交、回调与轮询补偿;不再保留“先做站内审批、以后切企微”的中间态。套餐临期的企业微信消息推送仍属于后续扩展,本期只实现站内通知及外部渠道可扩展边界。
---
@@ -252,8 +253,8 @@ sequenceDiagram
| 类型 | 接收人 |
|------|--------|
| `wecom.approval.approved/rejected/cancelled` | 申请人账号 |
| `wecom.approval.revoked_after_approved` | 申请人和财务角色账号 |
| `wecom.approval.approved/rejected/cancelled` | 真实业务提交人账号 |
| `wecom.approval.revoked_after_approved` | 真实业务提交人和财务角色账号 |
| `wecom.template.invalid` | 平台超管 |
| `package.expiring` | 代理/企业相关账号 |
| `card_sync.failed` | 平台运维角色 |
@@ -281,18 +282,18 @@ API
企业微信负责模板、审批节点、审批人、会签/或签、通过、驳回、撤销、审批意见、审批附件和企业微信端待办。本系统不保存本地审批任务,也不提供审批按钮,只负责:
- 创建退款和平台员工线下充值业务单。
- 创建退款(允许现有平台/超级管理员/代理发起)和平台员工线下充值业务单。
- 保存提交时业务快照和本地业务资料,上传企微附件副本。
- 以稳定场景码映射企微模板版本和控件 ID。
- 保存 `sp_no`、审批状态、审批人/意见/附件详情快照。
- 接收回调并以 `getapprovaldetail` 查询作为状态权威来源。
- 审批终态后幂等执行退款或线下充值业务处理。
Viper 配置包含 `corp_id``agent_id``agent_secret`、回调 Token/EncodingAESKey、审批回调路径、账号绑定回调 URL、2 分钟审批轮询和 10 分钟模板验证间隔。Secret 只允许环境变量覆盖,后台只返回“是否配置”和最近连通结果。Access Token 缓存在 RedisTTL 使用 `expires_in-300秒` 并通过锁避免并发刷新。
Viper 配置包含 `corp_id``agent_id``agent_secret`、回调 Token/EncodingAESKey、审批回调路径、账号绑定回调 URL、代理代提交固定成员 `agent_approval_creator_userid`、2 分钟审批轮询和 10 分钟模板验证间隔。Secret 和代理代提交身份只允许通过部署配置提供,后台只返回“是否就绪”、固定成员显示名和最近连通结果,不提供在线修改固定 `userid`。Access Token 缓存在 RedisTTL 使用 `expires_in-300秒` 并通过锁避免并发刷新。
```mermaid
sequenceDiagram
actor Staff as 平台员工
actor Submitter as 业务提交人
participant App as Refund/Recharge Application
participant DB as PostgreSQL
participant Outbox as Outbox
@@ -301,10 +302,10 @@ sequenceDiagram
participant Sync as Approval Sync
participant Biz as 业务终态用例
Staff->>App: 创建退款/线下充值
Submitter->>App: 创建退款/线下充值
App->>DB: 业务单+企微实例(submitting)
App->>Outbox: SubmissionRequested
Worker->>WeCom: 上传附件并applyevent
Worker->>WeCom: 按账号类型解析userid并applyevent
WeCom-->>Worker: sp_no
WeCom->>Sync: 加密状态回调
Sync->>WeCom: getapprovaldetail
@@ -335,36 +336,27 @@ sequenceDiagram
→ 原子发布新版本并恢复场景
```
退款至少映射店铺、退款单号、申请金额、原因、附件和提交人;线下充值至少映射店铺、充值单号、金额、备注、附件和提交人。后台每 10 分钟验证启用版本,模板不可访问或 fingerprint 变化时暂停场景并发送系统告警。
退款至少映射店铺、退款单号、订单实收金额、可退款区间、固定申请金额、原因、备注、附件和真实业务提交人;线下充值至少映射店铺、充值单号、金额、备注、附件和提交人。退款金额在提交后只读,企微审批人只能同意或拒绝。后台每 10 分钟验证启用版本,模板不可访问或 fingerprint 变化时暂停场景并发送系统告警。
#### 3.3.3 系统账号扫码绑定企微成员
场景处于暂停中、已暂停,或当前模板版本失效时,新的退款/线下充值创建请求必须在写入任何业务单、审批实例或 Outbox 之前失败,并返回明确的“审批场景当前不可用”。前端保留用户已经填写的表单,待场景恢复后由用户重新提交;后端不为失败请求保留待补提的孤儿业务单。暂停前已经成功创建的审批实例继续接收回调、执行 2 分钟兜底同步和终态业务处理,不受场景暂停影响。
普通运营不录入 `userid`。员工先登录本系统,再点击“绑定企业微信”,后端生成一次性 `state` 和企微 Web 登录 URL扫码回调后使用自建应用 Access Token 调用 `auth/getuserinfo` 获取 `userid`
#### 3.3.3 平台账号绑定、代理固定代提交与真实业务提交人
```text
系统登录账号
→ 创建5分钟绑定会话
→ 打开企微Web登录二维码
→ 回调code+state
→ 原子消费state
→ 换取userid并校验企业成员
→ 保存一对一绑定
```
企微 `applyevent.creator_userid` 按当前登录账号类型解析:平台账号和超级管理员必须使用其系统账号扫码绑定的本人企微 `userid`;代理账号发起退款时使用部署配置中的 `agent_approval_creator_userid` 代提交。退款或线下充值的真实业务提交人始终取当前登录账号,并独立保存账号 ID、名称、角色和店铺快照。
约束:
- `state` 与前端查询 `session_id` 分开存 Redis`state` 单次使用,绑定结果会话保留 10 分钟
- 回调不接受 `account_id`,目标账号只能来自服务端绑定会话
- `account_id``wecom_userid` 均唯一;成员已绑定其他账号时拒绝覆盖
- 自助解绑和管理员强制解绑不影响历史审批快照,但会阻止新审批
- 管理员只查看绑定状态和强制解绑,不提供 `userid` 输入框
- 企微自建应用可信域名和可见范围必须覆盖需要发起审批的平台员工
`tb_account_wecom_mapping` 保存账号、`userid`、成员名称、CorpID、AgentID、绑定来源和验证时间。
- 平台/超级管理员通过 5 分钟一次性会话扫码绑定;`account_id``wecom_userid` 均一对一唯一,普通运营不手工录入 `userid`
- 平台/超级管理员未绑定、绑定失效或成员不可用时,拒绝其新申请并原地提供绑定入口;代理账号不要求绑定企微
- 代理代提交固定成员必须属于当前 CorpID、处于可用状态并在自建应用可见范围内未配置或失效时拒绝代理新申请。以上校验都在任何业务单、审批实例或 Outbox 落库前完成
- 企微模板中的 `submitter` 必填字段始终写入真实业务提交人名称和账号标识;代理审批单不能只展示固定代提交账号
- 审批实例同时保存真实业务提交人快照、本次实际使用的企微 `userid` 快照及身份来源 `self_binding/agent_proxy`;列表、详情、通知、权限与审计中的“提交人/申请人”均指真实业务提交人
- 平台账号换绑、解绑以及代理固定成员变更只影响新审批;历史实例保留提交时身份快照,不改写历史记录
- 后台账号绑定列表只允许查看和强制解绑;代理固定身份只展示就绪状态及成员显示名,不提供在线修改 `userid`
#### 3.3.4 审批实例与状态同步
`tb_wecom_approval_instance` 保存:业务类型/ID/编号、轮次、场景、模板版本和映射快照、创建人本地账号与企微 `userid``sp_no`、提交业务快照、企微详情和审批人快照、业务处理结果、轮询时间、乐观锁版本
`tb_wecom_approval_instance` 的每条记录表示一次独立审批申请,保存:业务类型/ID/编号、场景、模板版本和映射快照、真实业务提交人账号及显示快照、实际企微发起 `userid` 快照、身份来源 `self_binding/agent_proxy``sp_no`、提交业务快照、企微详情和审批人快照、业务处理结果、轮询时间、乐观锁版本。退款和线下充值业务表使用 `approval_instance_id` 明确指向唯一审批申请,审批实例以 `(biz_type, biz_id)` 唯一约束保证一张业务单只有一条审批;不使用 `round_no`,也不存在从同一业务单推算“当前轮次”的逻辑
状态:
@@ -378,16 +370,45 @@ sequenceDiagram
| 通过后撤销 | `sp_status=6` |
| 已删除 | `sp_status=7` |
`applyevent` 请求已发送但响应超时必须标记“提交结果未知”,禁止盲目重试产生重复审批。管理员在企微核对后选择“确认未创建并重新提交”或手工绑定已有 `sp_no`,后者记录高风险审计。
`applyevent` 请求已发送但响应超时必须标记“提交结果未知”,禁止盲目重试产生重复审批。异常恢复同时提供两个受控动作:
- “绑定已有 `sp_no`”:先调用 `getapprovaldetail`,核对企业、模板版本、实例保存的实际企微发起 `userid`、身份来源、业务场景、真实业务提交人字段和提交业务快照;全部匹配后才允许绑定,禁止只校验编号存在。
- “确认企微未创建并重新发送”:保留原未知尝试和 Integration Log在同一审批申请下记录新的技术提交尝试它不是一条新的业务审批申请。企微已经拒绝或进入其他明确业务终态后原业务单不得再次发送后续确有业务需要时只能重新创建新的业务单和审批申请。
两个动作只允许超级管理员或具备独立“企微审批异常恢复”权限的平台账号执行,并记录操作人、依据、审批申请、技术尝试或绑定 `sp_no`、校验结果和前后状态的高风险 Audit Event。
回调只负责验签、解密、保存 Integration Log 并触发统一 `SyncApprovalStatus`。审批中实例每 2 分钟兜底查询,回调和轮询共用状态同步用例;首次进入终态时同事务写业务 Outbox`business_processed_at` 保证资金动作只执行一次。
#### 3.3.5 DDD 边界与 API
#### 3.3.5 权限边界
企微能力拆分授权,禁止用一个泛化权限同时覆盖配置和异常操作:
| 能力 | 超级管理员 | 平台账号 | 代理账号 |
|------|------------|----------|----------|
| 查询连接、场景和模板版本 | 允许 | 不允许 | 不允许 |
| 暂停/恢复场景、读取/发布模板映射 | 允许 | 不允许 | 不允许 |
| 管理平台账号绑定列表、强制解绑 | 允许 | 不允许 | 不允许 |
| 查询、重新绑定、解绑本人企微 | 允许 | 仅本人 | 不提供绑定能力 |
| 审批运行列表和详情 | 允许 | 仅具备独立“企微审批运营”权限 | 不允许 |
| 立即同步审批详情 | 允许 | 仅具备独立“企微审批运营”权限 | 不允许 |
| 提交未知异常恢复 | 允许 | 仅具备独立“企微审批异常恢复”权限 | 不允许 |
| 业务退款详情中的审批区块 | 按业务查看权限 | 按业务查看权限 | 按现有店铺层级数据范围 |
平台账号的“企微审批运营”权限只允许查看运行记录和触发 `getapprovaldetail` 同步,不包含场景、模板、账号绑定管理或提交未知恢复。所有接口以后端角色与权限校验为准,前端隐藏入口不能替代鉴权。
审批详情按查看主体投影,禁止把平台内部审批资料随业务详情或导出泄露给代理:
- 代理在其店铺层级数据范围内只能看到真实业务提交人、审批状态、状态更新时间、业务处理结果,以及代理自己提交的退款资料和业务凭证。
- 代理不得看到企微审批人名单、内部审批意见和审批人在企微上传的附件。
- 平台账号和超级管理员在具备对应退款查看权限时可查看完整审批详情,包括审批人、意见、时间线和审批附件。
- 审批附件详情、受保护下载解析与导出必须复用同一主体权限投影;不得因持有 `attachment_ref` 或历史导出文件而绕过当前权限。
#### 3.3.6 DDD 边界与 API
```text
domain/wecomapproval 场景、模板版本、审批状态和终态幂等
application/wecomapproval 发布模板、提交审批、回调、同步和异常恢复
domain/wecomidentity 系统账号与企微成员一对一绑定
domain/wecomidentity 平台系统账号与企微成员一对一绑定
application/wecomidentity 创建绑定会话、完成绑定和解绑
infrastructure/adapter/wecom Token、模板、附件、审批、身份和回调加解密
query/wecomapproval 审批运行列表、详情和业务摘要
@@ -402,7 +423,7 @@ query/wecomapproval 审批运行列表、详情和业务摘要
| 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` | 当前账号创建扫码绑定会话 |
| 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` | 超管查询绑定列表 |
@@ -411,8 +432,10 @@ query/wecomapproval 审批运行列表、详情和业务摘要
| GET/POST | `/api/callback/wecom/approval` | 企微审批回调校验和事件 |
| GET | `/api/admin/wecom/approvals*` | 审批运行列表和详情 |
| POST | `/api/admin/wecom/approvals/{id}/sync` | 立即同步详情 |
| POST | `/api/admin/wecom/approvals/{id}/bind-sp-no` | 提交结果未知时校验并绑定已有审批单 |
| POST | `/api/admin/wecom/approvals/{id}/confirm-not-created-and-resend` | 确认企微未创建后重新发送同一审批申请 |
### 3.4 数据同步触发与轮询优化
### 3.4 卡状态公共写入、事件触发与运营商回调
#### 3.4.1 三条自动通道
@@ -420,7 +443,7 @@ query/wecomapproval 审批运行列表、详情和业务摘要
```mermaid
flowchart LR
Polling[活跃/不活跃轮询] --> Request[RequestCardObservation]
Polling[现有周期轮询] --> Request[RequestCardObservation]
Event[关键业务埋点] --> Series[立即/3分钟/5分钟]
Series --> Request
Manual[现有手动刷新] --> Request
@@ -434,11 +457,11 @@ flowchart LR
ACL --> Integration
```
- 轮询始终兜底,只保留 `enable_polling` 总开关,按活跃/不活跃使用不同间隔
- 现有周期轮询继续兜底,其 PostgreSQL 配置、Redis 分片队列、同步类型间隔、卡级开关、失败重排、并发控制和监控保持现状;本期只把查询成功后的状态应用收口到公共用例
- 业务事件不是新接口,而是在查询资产、获取实名链接、停复机、支付、套餐激活、切卡、重启等用例成功边界埋点。
- 每个事件序列默认创建立即、3 分钟、5 分钟三个无自动重试任务;达到预期状态后剩余任务提前完成。
- 现有手动刷新接口保持响应契约并直接同步,不再额外创建阶梯任务。
- Gateway 超频只记录当前 `rate_limited`,不维护 `blocked_until` 或 30/60/120 秒退避后续阶梯任务和轮询保持原计划。
- 事件阶梯任务遇到 Gateway 超频只记录当前 `rate_limited`,不为事件序列维护 `blocked_until` 或 30/60/120 秒退避后续阶梯任务和现有周期轮询保持各自原计划。
#### 3.4.2 实名判断与状态应用
@@ -454,11 +477,9 @@ flowchart LR
`ApplyCardObservation` 是唯一允许写入实名、流量和网络状态的入口:加载并锁定卡聚合,应用标准观测值,状态变化时同事务保存领域事件和 Audit Event外部调用始终写 Integration Log。旧轮询 Handler、手动实名修改和刷新 Service 必须改为调用该用例,不保留双实现。
#### 3.4.3 活跃调频和请求协调
#### 3.4.3 事件序列请求协调
卡维护活跃级别、最后活跃时间/场景和最后上游变化时间。C 端/后台/OpenAPI 查询、实名、停复机、支付、套餐、切卡、回调和轮询发现变化均标记活跃;持续无业务活动且轮询无变化后转为不活跃。
第一版建议 `inactive_after=30m`;活跃卡沿用现有实名/流量/状态默认间隔,不活跃卡三类查询初始均为 15 分钟。上线前按生产卡量只读测算 QPS 后再调整,数值进入轮询配置而不是写死代码。
本期不引入活跃/不活跃卡、不增加活跃状态字段、不调整周期轮询间隔或 QPS也不合并现有多套轮询配置。以下协调规则只作用于业务事件产生的 `0/3/5` 观测序列:
重复控制拆为:
@@ -472,7 +493,7 @@ flowchart LR
ICCID 必须按原始长度精确路由19 位只查 `iccid_19`20 位只查 `iccid_20`,禁止截断、补位或跨列降级。发布前检查 `iccid_19` 重复并建立未删除数据范围内的部分唯一索引。解除实名回调第一版只写 Integration Log 并返回约定成功报文,不修改卡实名状态。
本需求不新增同步按钮显式同步 API。资产详情只增加自动轮询状态和“查看同步轨迹”,跳转 `/operations/audit?tab=integrations&resource_type=iot_card&resource_key=...`
本需求不新增同步按钮显式同步 API、活跃状态或下次轮询字段。资产详情只增加“查看同步轨迹”,跳转 `/operations/audit?tab=integrations&resource_type=iot_card&resource_key=...`;现有手动刷新和轮询监控页面保持原契约
### 3.5 全局多视角审计
@@ -532,8 +553,8 @@ POST /api/admin/audit/exports
| 模块/页面 | 建议路由 | 主要能力 |
|-----------|----------|----------|
| 企微审批运行 | `/operations/wecom-approvals` | 状态、业务单号、模板版本、异常恢复和详情抽屉 |
| 企微配置 | `/system/wecom` | 连接状态、模板映射版本、账号绑定和异常审批 |
| 账号企微绑定 | 当前用户个人中心 | 扫码绑定、查看成员名称、重新绑定和解绑 |
| 企微配置 | `/system/wecom` | 连接状态、代理固定代提交账号状态、模板映射版本、平台账号绑定和异常审批 |
| 账号企微绑定 | 当前平台用户个人中心 | 扫码绑定、查看成员名称、重新绑定和解绑;代理不展示绑定入口 |
| 站内消息 | `/notifications` | 未读数、列表、已读和受控业务跳转 |
| 全局审计 | `/operations/audit` | 全局、人员、资源、链路、资金、风险和外部集成多视角 |
| 系统配置 | `/settings/system-config` | 受控单选、复选和开关 |
@@ -564,7 +585,7 @@ stateDiagram-v2
Submitting --> Processing: 创建任务成功
Submitting --> Editing: 参数或上传失败
Processing --> Processing: 轮询进度
Processing --> Completed: 全部或部分完成
Processing --> Completed: 业务处理完成
Processing --> Failed: 任务失败
Completed --> [*]
Failed --> Editing: 修正后创建新任务
@@ -573,19 +594,22 @@ stateDiagram-v2
- 创建成功后立即请求一次详情,再按 2、3、5 秒退避,最大间隔 10 秒。
- 页面不可见时暂停轮询,恢复可见时立即刷新。
- 网络错误不等于业务失败,展示“状态获取失败,点击重试”。
- 必须展示总数、成功数、失败数和部分成功状态。
- 全局异步任务状态固定为 `1=待处理, 2=处理中, 3=已完成, 4=已失败, 5=已取消`;各业务不得另占状态码或定义“部分成功状态。
- 必须展示总数、成功数和失败数;全部成功、部分成功和全部业务项失败是完成任务的结果摘要,由计数推导。
- 页面刷新后根据任务 ID 恢复进度。
- Excel 模板由前端静态资源提供;后端仍严格校验表头、版本和内容。
- 批量文件模板由前端静态资源提供;设备批量分配和批量订购均只接受 CSV但各自使用独立表头契约。后端仍严格校验表头、编码、文件大小和内容。
### 4.4 企业微信审批交互
- 删除本系统待我审批、流程节点配置、审批人配置、通过/驳回/退回按钮,审批操作全部在企业微信完成。
- 退款和线下充值创建成功后先显示“正在提交企业微信审批”,取得 `sp_no` 后显示“企业微信审批中”。
- 业务详情企微审批区块展示审批单号、状态、模板版本、申请人、审批人、意见、审批附件、状态时间线和本地业务处理结果。
- 平台/超级管理员的业务详情企微区块展示审批单号、状态、模板版本、真实业务提交人、审批人、意见、审批附件、状态时间线和本地业务处理结果;代理视图只展示真实业务提交人、审批状态、状态时间和业务处理结果,不返回平台内部审批人、意见或审批附件
- “立即同步”只调用 `getapprovaldetail`,不提供任何本地审批动作。
- 通过后撤销且资金动作已执行时使用高风险异常提示,明确说明不会自动冲正。
- 未绑定企微账号时,创建页原地展示“绑定企业微信”按钮;扫码成功后继续当前表单,不要求用户先跳转配置页
- 平台/超级管理员未绑定企微时,创建页原地展示“绑定企业微信”按钮,绑定成功后继续当前表单;代理不展示绑定入口,后端使用固定企微账号代提交
- 代理固定代提交账号未配置、失效或不在应用可见范围时,代理创建页展示后端返回的场景不可用原因并保留当前表单。
- 模板发布使用业务字段与企微控件的可视化映射,不向运营暴露 JSON场景暂停时创建页提前展示维护原因。
- `/system/wecom` 仅超级管理员可访问;平台账号只在个人中心维护本人绑定,具备“企微审批运营”权限时才显示审批运行页;代理只在自己有权查看的退款详情中查看审批区块。
### 4.5 审批与业务处理状态
@@ -601,7 +625,8 @@ stateDiagram-v2
### 4.6 权限与敏感数据
- 企微配置、账号绑定、导出字段、审计视角和数据范围均以后端为准。
- 企微连接、平台账号绑定与代理固定代提交账号状态、导出字段、审计视角和数据范围均以后端为准。
- 审批附件的详情展示、下载解析和导出沿用审批详情主体权限:代理提交的业务凭证可按业务权限访问,平台内部审批附件仅平台/超级管理员按权限访问。
- 通知跳转使用前端受控 `ref_type` 路由表,不接受任意 URL。
- 退款凭证、充值凭证、身份证、营业执照和审批附件均走对象存储,不保存永久公开地址。
- 角色导出字段配置只控制列,不扩大已有店铺或企业数据范围。
@@ -621,7 +646,7 @@ stateDiagram-v2
| 需求 06/11 套餐到期时间 | 资产层只展示当前生效及排队主套餐全部接续后的预计最终到期时间;当前套餐自身到期时间只保留在套餐明细 | 资产详情 Query 按队列顺序使用购买时长快照推演;无套餐返回 `null`,等待未知实名激活时返回不可预计状态 | 文案使用“预计套餐到期时间”;高亮和临期统一使用最终剩余天数,不维护第二套资产汇总字段 |
| 需求 07 实名筛选 | 卡按自身实名状态;设备任一有效绑定卡实名即视为设备实名 | 设备增加 `real_name_status` 快照;实名变化、绑定、解绑、换卡均刷新旧/新设备 | 卡和设备列表增加全部/已实名/未实名筛选 |
| 需求 12 换货显示与搜索 | 卡的新旧资产标识统一快照 ICCID设备维持设备号历史数据不回填 | 创建快照逻辑修正;列表增加 `old_asset_keyword/new_asset_keyword`,支持 ICCID、接入号、虚拟号 | 搜索框拆为旧资产和新资产;验收大结果集查询性能 |
| 需求 13 列表字段 | 提交人写业务快照;企微审批摘要动态读取 | 换货、退款、充值增加 `submitter_name`;按本页企微实例批量查询状态和审批人摘要,禁止 N+1 | 展示企微状态、当前审批人摘要、处理状态和历史审批标识 |
| 需求 13 列表字段 | 提交人写业务快照;企微审批摘要动态读取 | 换货、退款、充值增加 `submitter_name`;按本页企微实例批量查询状态,平台视角可查询审批人摘要,代理视角摘要固定为空,禁止 N+1 | 展示企微状态和处理状态;当前审批人仅平台/超级管理员按业务权限展示 |
| 需求 15 下架套餐续费 | 禁用套餐始终不可购买;下架套餐只允许资产所有人基于有效历史使用记录续费,禁止代理代购 | 复用统一套餐可售策略;后端根据资产和登录主体判定续费资格 | C 端当前套餐旁展示“续费”,复用购买流程;新购入口隐藏,后台代购禁用 |
### 5.2 需求 02H5 实名与充值顺序配置
@@ -699,18 +724,18 @@ PATCH /api/admin/shop-package-allocations/{id}/expiry-base
### 5.4 需求 08设备批量分配
“分配代理”和“分配套餐系列”是两个业务命令,不允许一个任务同时修改两个字段。两者复用 Excel 解析、任务轮询和失败明细基础设施,但每个任务只携带一种 `operation_type`一个目标值。
“分配代理”和“分配套餐系列”是两个业务命令,不允许一个任务同时修改两个字段。两者只接受单列 UTF-8 CSV复用任务轮询和失败明细基础设施但使用两个独立创建接口且每个任务只携带一个目标值。
```mermaid
sequenceDiagram
actor User as 平台员工
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
User->>Web: 选择一种操作并上传 CSV 到对象存储
Web->>API: 提交 file_key 和 shop_id 或 series_id
API->>DB: 保存任务和文件 Key
API-->>Web: task_id
Worker->>DB: 分批查询、条件更新、记录失败
@@ -722,8 +747,12 @@ sequenceDiagram
处理规则:
- 设备号去重后批量查询,禁止逐行查询。
- CSV 固定一列“设备号”,支持虚拟号或 IMEI 精确匹配;不接受 Excel、multipart 文件字节、号段或模糊搜索。
- 前端复用受控预签名上传,业务接口只接收 `file_key` 和对应目标 IDAsynq 载荷只传结构化 `task_id`
- `assign_shop`:已属于其他代理的设备失败并提示先回收;已属于目标代理的按幂等成功。
- `assign_shop`:平台只能分配平台库存设备;代理只能把自己名下设备分配给直属下级。
- `assign_series`:已属于目标套餐系列的按幂等成功;不修改 `shop_id`
- `assign_series`:代理只能操作自己名下设备并选择自己当前有效授权的系列。
- 临时本地路径和文件字节不得进入 Asynq 载荷。
- 模板由前端提供,后端不实现下载接口。
- 限制:文件最大 10MB、最多 1000 行、Worker 每批 200 条、失败明细最多保存 1000 条。
@@ -739,7 +768,9 @@ sequenceDiagram
| 设备 | 微信、钱包 |
- C 端支付页只展示接口返回的允许方式。
- 创建订单和支付接口必须再次校验,不能依赖前端隐藏
- 创建订单`payment_method` 必填,后端校验后固化为订单不可变快照;强充在创建流程中立即按该方式拉起渠道,钱包不能用于给自身强充
- 后续支付接口不允许重新选择或替换 `payment_method`,只能执行订单快照方式,并在真正支付前按最新资产配置再次校验。需要换方式时先取消待支付订单再重新创建。
- 普通资产钱包充值只展示并接受资产允许集合与 `wechat|alipay` 的交集,不能把 `wallet` 当成充值渠道。
- 配置异常时使用上述安全默认值并记录错误,不得放开全部方式。
- 后台配置使用复选框,不直接编辑 JSON。
@@ -757,17 +788,155 @@ flowchart TD
Binding --> Exists{当前卡有效?}
Exists -->|否| Reject[拒绝操作并记录原因]
Exists -->|是| ICCID
ICCID --> Gateway[SetSpeedLimit cardNo, speedKbps]
ICCID --> Gateway[SetSpeedLimit cardNo, speedLevel]
Gateway --> Audit[记录操作审计和 Gateway 结果]
```
#### 5.6.1 CMP 业务契约
数据和契约:
- 应用层只接收 `speed_kbps`:正数为设置,`0` 为取消;内部单位固定为 `kbps`
- 应用层只接收语义化 `speed_level`,前端只能从固定档位中选择,禁止输入任意速率,也不得接触 Gateway 渠道 `code`
- 统一接口:`POST /api/admin/assets/{identifier}/speed-limit`。资产为设备时解析当前绑定卡,不存在当前卡则拒绝,绝不把设备 IMEI 传给 Gateway。
- Gateway 端口只有 `SetSpeedLimit(cardNo, speedKbps)`;取消仍调用同一端口,适配器按上游最终契约转换取消参数
- Gateway 端口只有 `SetSpeedLimit(cardNo, speedLevel)`Infrastructure Adapter 根据 Gateway 账户和运营商把业务等级映射为上游字符串 `code`,映射不得进入 Handler、前端或领域模型
- “恢复不限速”和“限到 0kbps”是两个不同业务等级必须分别映射为上游 `code=-1``code=0`,禁止继续用数值 `0` 表示取消限速。
- 上游说明只有广电和电信直接支持限速接口,联通通过通信计划调整,移动不支持限速。调用前必须按卡的运营商能力校验;不支持或缺少账户档位映射时返回明确业务错误,不得猜测 `code` 或盲目调用。
- 不新增 `tb_package.speed_limit_kbps``SpeedLimitApplyRequested` Outbox 或自动补偿 Worker。本期也不宣称能展示 Gateway 当前实际限速,除非上游另提供查询接口。
- 卡详情和设备详情都提供设置/取消入口;设备入口明确显示“当前使用卡 ICCID”。每次操作记录资产、最终 `cardNo`目标值、操作人、请求结果和错误摘要。
- 卡详情和设备详情都提供固定档位选择及恢复不限速入口;设备入口明确显示“当前使用卡 ICCID”。每次操作记录资产、最终 `cardNo`业务 `speed_level`、实际发送的渠道 `code`、Gateway 返回的 `appliedSpeed/channelRawValue`、操作人、请求结果和错误摘要。
CMP 固定业务等级如下。枚举名称属于本系统稳定契约;展示文案和上游默认 `code` 仅用于表达当前已知映射,实际调用仍须按 Gateway 账户和运营商查找映射。
| `speed_level` | 展示文案 | 当前上游默认 `code` |
|---------------|----------|----------------------|
| `unlimited` | 恢复不限速 | `-1` |
| `zero_kbps` | 限到 0kbps | `0` |
| `limit_128_kbps` | 128Kbps | `1` |
| `limit_512_kbps` | 512Kbps | `2` |
| `limit_1_mbps` | 1Mbps | `3` |
| `limit_2_mbps` | 2Mbps | `4` |
| `limit_10_mbps` | 10Mbps | `5` |
| `limit_20_mbps` | 20Mbps | `6` |
| `limit_50_mbps` | 50Mbps | `7` |
| `limit_100_mbps` | 100Mbps | `8` |
#### 5.6.2 Gateway 上游接口文档
上游正式环境地址为 `https://open.whjhft.com/openapi`,限速接口为 `POST /flow-card/speedLimit`。统一 Gateway 客户端发送时使用 `params` 包装业务参数:
```json
{
"params": {
"cardNo": "89861124221081232235",
"code": "-1"
}
}
```
以下为上游提供的原始 OpenAPI 文档。文档中的成功响应 `example` 实际是错误页面文案,不是有效 JSON实现和自动化测试不得据此构造成功响应响应字段以 schema 及真实环境联调结果为准。
```yaml
openapi: 3.0.1
info:
title: ''
version: 1.0.0
paths:
/flow-card/speedLimit:
post:
summary: 流量卡限速接口
deprecated: false
description: |
只有广电接口和电信接口存在限速 联通是通过通信计划调整 不同账户相同速率的编码不一致 移动无限速
| 字段名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| cardNo | string | 是 | 流量卡ICCID号码 |
| code | string | 是 | 限速档位,取值见下表 |
**code 档位对照表:**
| code | 速率 |
|------|------|
| -1 | 限速恢复(取消限速) |
| 0 | 0kbps |
| 1 | 128Kbps |
| 2 | 512Kbps |
| 3 | 1Mbps |
| 4 | 2Mbps |
| 5 | 10Mbps |
| 6 | 20Mbps |
| 7 | 50Mbps |
| 8 | 100Mbps |
tags:
- 流量卡
- flow-card
parameters: []
requestBody:
content:
application/json:
schema:
type: object
properties: {}
example:
params:
cardNo: '89861124221081232235'
code: '-1'
required: true
responses:
'200':
description: ''
content:
application/json:
schema:
type: object
properties:
code:
type: integer
description: 200成功
msg:
type: string
data:
type: object
properties:
appliedSpeed:
type: string
description: 实际限速值
channelRawValue:
type: string
description: 对应运营商过去的值
iccid:
type: string
required:
- appliedSpeed
- channelRawValue
- iccid
x-apifox-orders:
- appliedSpeed
- channelRawValue
- iccid
required:
- code
- msg
- data
x-apifox-orders:
- code
- msg
- data
example: 抱歉,您访问的页面不存在。
headers: {}
x-apifox-name: 成功
x-apifox-ordering: 0
security: []
x-apifox-folder: 流量卡
x-apifox-status: developing
x-run-in-apifox: https://app.apifox.com/web/project/6930706/apis/api-487496525-run
components:
schemas: {}
responses: {}
securitySchemes: {}
servers:
- url: https://open.whjhft.com/openapi
description: 正式环境
security: []
```
### 5.7 需求 14统一导出与字段权限
@@ -787,18 +956,17 @@ flowchart TD
```text
POST /api/admin/export-tasks
body: scene + format + query + fields
body: scene + format + query
```
字段权限:
```text
resolved_fields = 用户申请字段
∩ 角色授权字段并集
resolved_fields = 角色授权字段并集
∩ 场景支持字段
```
新增 `tb_role_export_field_permission(role_id, scene, field_key)`,三列唯一。超级管理员拥有全部字段;普通角色授权并集计算。数据行范围继续使用现有权限,字段授权不能扩大数据范围。
新增 `tb_role_export_field_permission(role_id, scene, field_key)`,三列唯一。超级管理员拥有代码目录内全部字段;普通账号从当前有效角色授权并集计算。数据行范围继续使用现有权限,字段授权不能扩大菜单、接口、场景或数据范围。前端不得提交 `fields`,也不提供导出字段选择器;账号获授权的代码目录字段全部导出。
API
@@ -810,11 +978,32 @@ PUT /api/admin/roles/{role_id}/export-fields
- 创建任务时快照最终字段和表头Worker 不能因后续角色变化扩大字段。
- 权限解析失败时拒绝导出,不能回退到全字段。
- 普通账号解析成功但最终字段为空时不创建任务;查询当前账号字段时返回空数组,前端据此禁用导出入口。
- 不存在 `default_selected``required` 或后端自动补列。角色配置是代码目录内字段能否导出的权威来源密码、密钥、Token、对象存储 Key、企微 `media_id` 等永久禁止导出的字段不得注册进代码目录。
- Count 和 Fetch 必须使用相同权限及查询条件。
- 退款和充值审批摘要按本批实例批量查询,禁止 N+1。
- 导出审批附件只输出数量,不输出对象 Key 或永久 URL
- 退款/充值业务凭证和企微审批附件可按字段权限导出受保护的稳定业务链接,但字段权限不能突破审批主体可见性:代理可导出其有权查看的业务凭证,不能导出或解析平台内部企微审批附件;不得输出对象 Key、企微 `media_id`、当前预签名 URL 或公开 Bucket URL。平台审批摘要仍可保留附件数量
- `scene=iot_card` 支持按预计最终到期时间筛选 30 天内资产;该导出条件独立于页面 15 天临期定义,复用需求 06/11/22 的最终到期 Query。
受保护附件访问流程:
```text
导出单元格中的后台前端绝对 URL
→ /export-attachments/{attachment_ref}
→ 未登录时进入后台登录并保存当前站内返回地址
→ 登录成功返回附件落地页
→ 前端携带 Bearer Token 请求 GET /api/admin/attachments/{attachment_ref}/download-url
→ 后端按附件所关联退款单、充值单或审批业务重新校验当前查看权限、数据范围和附件种类可见性
→ 返回短期 download_url、expires_at、file_name
→ 前端在当前页跳转短期地址,浏览器打开或下载私有文件
```
- `attachment_ref` 是不可变、不可枚举的稳定引用;业务附件被替换时旧引用不得改指新文件。
- 附件仍保存在私有 Bucket。稳定的是后台前端落地页地址不是对象存储地址每次访问都重新鉴权并生成短期地址。
- 无权与引用不存在对外使用统一错误,避免探测;角色或数据范围在导出后被收回时,旧导出文件中的链接也不能继续下载。
- 企微审批附件必须先保存到本地私有对象存储并建立稳定引用,不能依赖临时 `media_id`。历史确无本地附件事实时输出空;匹配记录仍处于应有附件但尚未本地化的异常状态时,导出任务明确失败。
- 前端附件落地页必须覆盖登录恢复、加载中、无权限/不存在、对象文件不可用、解析失败和成功跳转状态;不得把后端 API URL直接写进导出文件因为浏览器无法为普通文件链接自动附加后台 Bearer Token。
### 5.8 需求 22套餐临期提醒
临期定义:按 `Asia/Shanghai` 自然日计算,资产当前生效主套餐与全部排队主套餐连续接续后的**预计最终剩余天数**为 `015` 天。已过期资产不按 0 天计入临期;没有生效套餐且队首仍等待无法确定时间的实名激活时,不伪造到期日期,也不进入临期。需求 06、11、22 共用同一个最终到期 Query。
@@ -906,19 +1095,13 @@ cash_available = balance - frozen_balance
#### 5.9.5 #43 代理系列套餐批量授权
现有 `PUT /api/admin/shop-series-grants/{id}/packages` 已接受套餐数组,继续作为批量写接口。新增套餐候选 Query
不新增套餐候选 API。首次授权使用现有 `GET /api/admin/packages?series_id=...` 选择套餐,并由 `POST /api/admin/shop-series-grants` 在同一事务创建系列授权和至少一条套餐授权;不允许创建没有套餐的空系列授权。
```text
GET /api/admin/shop-series-grants/{id}/package-options
```
后续管理并行读取现有套餐列表和 `GET /api/admin/shop-series-grants/{id}`,由前端按 `package_id` 合并:已授权项置灰,未授权项可多选,存量已授权但当前不再可售/可见的项目仍通过授权详情只读展示。
返回 `package_id`、名称、编码、公司成本价、当前代理授权成本价、建议售价和 `is_authorized`。首次授权系列和后续管理套餐都使用同一候选列表:
保留 `PUT /api/admin/shop-series-grants/{id}/packages`,但请求必须用 `operation_type=authorize|update_cost|remove` 明确表达一种批量命令,单次最多 100 个套餐、事务内全成全败。新增授权同价重复幂等,不同价重复冲突;调价和移除不能伪装成新增授权。前后端同批切换,不保留旧的混合新增/改价/移除语义。
- 已授权套餐显示“已授权”并置灰,不可重复选择
- 未授权套餐支持复选框多选并一次提交。
- 后端对重复套餐按幂等处理,不能依赖前端置灰保证一致性。
- `company_cost_price``authorized_cost_price``suggested_retail_price` 分字段返回,禁止继续使用含义不明确的单一 `cost_price` 展示。
- 套餐候选属于 Query批量授权属于轻量 Application 事务脚本,不为此创建空洞聚合。
页面按调用视角分别标注上级当前成本价、目标代理授权成本价和建议零售价;平台视角的上级成本才是公司成本。所有系列、套餐、价格和直属下级权限由后端重新校验,不能依赖前端置灰
---
@@ -942,6 +1125,9 @@ GET /api/admin/shop-series-grants/{id}/package-options
- 修改角色默认信用配置只影响以后新建的店铺,不更新任何已有店铺。
- 已有店铺通过独立资金接口直接修改实际额度,之后也不跟随角色变化。
- 店铺后续增加其他角色不改变钱包信用额度,避免多角色组合影响资金事实。
- 代理账号不能调整自己或任何下级代理的实际信用额度;现有店铺管理权限和数据范围不推导信用额度修改权。
- 实际额度只能由超级管理员,或具备独立信用额度管理权限的平台账号修改。角色默认模板也只允许超级管理员或具备相应角色管理权限的平台账号配置。
- 信用额度不设置产品层固定上限;接口仍须使用分为单位的 `int64` 安全范围并拒绝负数和算术溢出。
#### 不变量
@@ -998,7 +1184,7 @@ CHECK (
|------|------|
| 修改角色默认额度 | 只更新角色模板和审计,不扫描、不修改任何已有店铺钱包 |
| 创建店铺 | 在创建事务中读取默认角色模板并初始化代理主钱包实际额度 |
| 修改额度 | 校验权限、加载主钱包、校验新可用金额、按 `version` 条件更新、写信用变更审计 |
| 修改额度 | 拒绝代理账号;校验平台独立权限、加载主钱包、校验新可用金额、按 `version` 条件更新、写信用变更审计 |
| 钱包扣款 | 使用有效额度计算可用金额,同事务更新余额、版本和资金流水 |
| 查询/导出 | Query 返回 `credit_enabled``credit_limit``available_balance``is_in_debt``debt_amount` |
@@ -1011,6 +1197,8 @@ GET /api/admin/shops/fund-summary 返回信用和可用金额
角色页面显示“新建代理默认信用额度”,并明确提示“修改后不会影响已有店铺”。店铺资金页面独立显示和修改实际信用额度。前端只展示接口返回的可用金额,不自行重新计算;额度调整弹框显示修改前后金额预览,并发冲突时刷新最新钱包版本。
资金概况中的 `is_in_debt` 表示 `balance < 0``debt_amount = max(-balance, 0)`;冻结金额只影响现金可用金额和总可用金额,不直接记为欠款。额度调整只改变资金边界,不伪造一条金额为零的钱包交易流水;变更前后值进入全局 Audit Event。
### 6.3 需求 18多人审批业务映射
多人审批由企业微信模板负责,本系统不解析审批角色、账号、部门或会签规则:
@@ -1020,25 +1208,29 @@ GET /api/admin/shops/fund-summary 返回信用和可用金额
| 多级、会签、或签 | 在企微模板中配置,本系统使用 `use_template_approver=1` |
| “部门领导→财务” | 可以作为企微模板中的组织规则,本系统不建立部门模型也不推导审批人 |
| 当前节点待办 | 由企业微信自身提醒,本系统不重复生成站内待审批任务 |
| 通过/驳回/撤销通知 | 状态同步后向申请人生成站内结果通知 |
| 通过/驳回/撤销通知 | 状态同步后向真实业务提交人生成站内结果通知 |
| 意见和附件 | 从 `getapprovaldetail` 保存审批详情快照并在业务详情只读展示 |
| 历史版本 | 本地保存模板 ID、控件映射和提交快照历史实例不受新模板影响 |
平台员工必须先完成系统账号与企微成员扫码绑定,发起审批使用绑定的 `userid`;普通运营不查询或录入成员 ID
平台/超级管理员必须扫码绑定并使用本人企微 `userid` 发起;代理退款使用部署配置中的固定企微成员代提交。两类路径都把真实业务提交人写入模板 `submitter` 字段和本地快照,通知与审计均以真实业务提交人为准
### 6.4 需求 19批量订购套餐
#### 规则和流程
- 支付方式整批选择 `offline` `agent_wallet`Excel 不包含支付方式
- 创建批次时选择单个 `package_id` 和整批支付方式 `offline|wallet`CSV 不包含套餐或支付方式。`wallet` 复用现有后台订单支付枚举,在本批次中表示逐行扣结算代理主钱包,不新增同义枚举 `agent_wallet`
- 混合支付必须拆成不同批次。
- 模板字段:资产类型、资产标识、套餐编码、套餐名称;实际匹配使用套餐编码
- 批次不选择代理、不接收 `shop_id`;同一 CSV 可以包含不同代理的资产。Worker 逐行以资产当前归属解析结算代理,套餐授权、成本价、钱包和数据权限均以该行解析结果为准
- 不新增批量订购后端权限码、账号类型拦截或任务创建人隔离。页面是否展示入口沿用前端现有可见性规则;能够通过现有后台认证调用接口的主体视为可以使用。此决定不跳过逐行资产、结算代理、套餐授权、成本价或钱包校验。
- CSV 固定 UTF-8允许 BOM只有一列表头 `资产标识`,不接受 Excel。Worker 复用系统统一资产解析能力识别资产类型和资产 ID不在批量用例维护标识白名单当前统一能力支持 ICCID、卡 `virtual_no`、MSISDN、设备 `virtual_no`、IMEI 和 SN。
- Worker 按统一解析得到的“资产类型 + 资产 ID”识别重复行。一个批次只有一个套餐因此重复键不包含套餐同一资产即使使用不同受支持标识仍属于重复首次出现的行正常处理后续重复行失败并返回首次行号。本期不通过复制相同行表达购买多份。
- 资产标识未命中或无法唯一解析时只失败对应行,禁止任意选择资产。明细保留用户原始资产标识、解析后的资产类型/ID和规范标识快照便于审计和跨标识判重。
- 任务允许部分成功,每行是独立、可重试、可审计的业务单元。
- 文件最大 10MB、最多 1000 行;失败明细最多保存 1000 条。
- 文件最大 10MB、最多 1000 行;失败明细最多保存 1000 条。创建接口只校验对象存在、属于当前上传主体、扩展名/Content-Type 和 10MB 大小不同步解析文件Worker 校验 UTF-8允许 BOM、固定表头、未知列、CSV 语法、空文件和 1000 行上限。任一文件级校验失败时任务整体失败且不创建订单;资产、套餐、归属、钱包和重复行等业务错误才进入逐行失败并允许部分成功。
```mermaid
flowchart TD
Submit[选择代理、支付方式、凭证并上传] --> Task[创建任务]
Submit[选择单个套餐和支付方式CSV及凭证直传私有对象存储] --> Task[提交package_id、file_key和voucher_keys创建任务]
Task --> Parse[解析并持久化逐行明细]
Parse --> Item{处理下一行}
Item -->|钱包| Wallet[锁钱包并校验有效可用金额]
@@ -1055,11 +1247,10 @@ flowchart TD
| 表 | 关键字段 |
|----|----------|
| `tb_bulk_purchase_task` | 任务号、`request_id`、文件 Key、代理、支付方式、凭证快照、金额和数量汇总、状态、处理租约 |
| `tb_bulk_purchase_item` | 行号、资产、套餐快照、金额、状态、订单 ID、错误码、错误原因、幂等键 |
| `tb_bulk_purchase_task` | 任务号、`request_id`、文件 Key、所选套餐 ID/编码/名称快照、支付方式、凭证快照、涉及代理数、金额和数量汇总、状态、处理租约 |
| `tb_bulk_purchase_item` | 行号、解析后的资产类型、用户原始资产标识、解析后资产 ID、规范标识快照、结算代理快照、金额、状态、订单 ID、错误码、错误原因、幂等键 |
任务状态:`1=待处理, 2=处理中, 3=完成, 4=部分成功, 5=失败`
明细状态:`1=待处理, 2=处理中, 3=成功, 4=失败`
任务复用全局异步任务状态:`1=待处理, 2=处理中, 3=完成, 4=已失败, 5=已取消`。文件有效且逐行处理结束时任务为已完成,部分成功只通过 `success_count``fail_count` 表达;本期没有取消入口,但保留状态码 5。逐行明细不是独立任务使用 `1=待处理, 2=处理中, 3=成功, 4=失败`
必须具备:
@@ -1068,25 +1259,48 @@ flowchart TD
- 行幂等键:`bulk_purchase:{task_id}:{row_no}`
- 钱包可用金额统一使用 `credit_enabled` 后的有效信用额度,禁止直接无条件加 `credit_limit`
- 钱包余额、版本、订单、资金流水和明细成功状态在同一事务。
- 钱包支付逐行扣该资产当前所属代理的主钱包;资产无代理归属、归属异常或该代理没有有效主钱包时只将该行记为失败。同一批次可依次锁定不同代理钱包,不存在整批共享钱包。
- `wallet` 批次严格按 CSV 行号顺序逐行结算不预占整批或某一代理全部行的金额。当前行余额不足只失败该行并继续处理后续行后续金额较小的行若当时余额足够仍可成功。因此同一代理资金不足时CSV 行序就是订购优先级。
- 单行失败不回滚其他成功行,任务统计从明细表重新聚合。
- 批量订购必须复用需求 15 的套餐可售策略,不能绕过下架续费限制。
- 任务详情和明细复用现有后台认证及公共数据范围,不新增基于账号类型、权限码或任务创建人的过滤。
API
```text
POST /api/admin/storage/upload-url
POST /api/admin/bulk-purchases
GET /api/admin/bulk-purchases/{task_id}
GET /api/admin/bulk-purchases/{task_id}/items
```
CSV 先使用 `purpose=bulk_purchase` 获取预签名地址并直传私有对象存储;线下凭证使用附件用途直传。`POST /api/admin/bulk-purchases` 只接收 JSON`request_id`、单个 `package_id``payment_method``file_key``voucher_keys`,不接收 `shop_id`、multipart 或文件字节。创建任务前校验套餐存在以及对象归属、类型和 10MB 大小并立即返回任务逐行按结算代理的当前套餐授权、价格和可售规则再次校验。Worker 按稳定 `file_key` 下载并执行文件级解析校验。
线下凭证仅作为本批次业务资料和审计快照,本期不校验跨批次唯一性或建立财务核销规则。
#### 自动化测试环境隔离
- 已部署测试环境的 API/Worker 使用 Redis DB 6本地开发和 Agent 自动化测试统一使用 Redis DB 7。自动化测试禁止向 DB 6 写入普通 Redis Key 或 Asynq 任务。
- 测试入口必须在启动前校验实际生效的 Redis DB只有 DB 7 才允许继续;不能只依赖调用方记得覆盖环境变量。当前 Redis 客户端、Asynq Client 和 Worker Server均从同一 `Redis.DB` 配置取值,测试必须保持三者一致。
- DB 7 也可能被多个本地进程共用,测试不得执行 `FLUSHDB`;测试数据、业务键和清理由每次运行的唯一标识及实际创建 ID 限定。
- 自动化测试直接使用当前可调用的真实 S3不建立内存对象存储替身每次运行使用唯一对象 Key并只删除本次创建的测试对象。真实上传、下载和删除失败都必须作为测试失败暴露。
- PostgreSQL 继续使用现有测试库,不新增专用数据库。测试夹具必须带唯一运行标识并按实际创建记录 ID 精确清理,禁止 `TRUNCATE`、清整表、模糊条件删除或修改既有业务数据;并发钱包测试也在该测试库内使用隔离夹具执行。
- 自动化以 Go HTTP 集成测试为主:通过 Fiber `app.Test` 穿过真实路由、认证、Handler、Application/Domain、GORM 和统一响应;测试接缝捕获待投递任务后直接调用公开 Worker Handler不依赖 `sleep` 等待后台 Worker生产仍使用真实 Asynq。
- 本需求实现时必须沉淀可供后续 Agent 复用的集成测试规范、环境守卫与资源清理 Harness、典型 CSV `testdata` 和完整示例;另提供真实部署环境的 `curl` 冒烟模板,但 `curl` 不替代 Go 自动化测试。
### 6.5 需求 20退款审批
退款金额在申请时完成合法性校验并固定,企微审批不允许修改金额。退款凭证先保存本地对象存储,再上传企微副本;审批人可在企微看到业务字段、提交备注和附件
退款创建只接收 `order_id``requested_refund_amount`、必填且最多 1000 字的 `refund_reason`、可选且最多 500 字的 `remark`,以及 15 个 `{file_key,file_name,file_size}` 附件。订单实收金额由后端读取并固化快照,不接受前端提交 `actual_received_amount`,也不再接受 `package_usage_id`
申请金额必须满足 `0 < requested_refund_amount <= 订单实收金额`,提交后固定。企微展示订单实收金额、可退款区间和申请金额,审批人只能同意或拒绝,不能修改金额。退款凭证先保存本地对象存储,再上传企微副本;本地对象 Key 是权威资料,企微 `media_id` 只是临时审批副本。
退款状态在历史枚举基础上追加 `5=已撤销/审批已删除`,保留历史 `4=已退回` 语义但新企微审批不再产生退回状态。独立保存业务处理结果,避免“企微已通过但代理钱包回溯或资产处理失败”被展示为全部完成。
```text
退款状态1=待审批 2=已通过 3=已拒绝 4=已退回(仅历史) 5=已撤销/审批已删除
处理状态0=未触发 1=处理中 2=处理成功 3=处理失败
```
```mermaid
sequenceDiagram
actor User as 平台员工/财务
@@ -1099,31 +1313,34 @@ sequenceDiagram
User->>Refund: 创建退款申请
Refund->>DB: 保存退款单、业务快照和企微实例
Refund-->>WeCom: 异步提交申请和附件
User->>WeCom: 审批;非代理钱包先完成人工退款
User->>WeCom: 非代理钱包先人工退款;审批只能同意或拒绝
WeCom-->>Sync: 回调/轮询同步终态
Sync->>DB: 保存审批详情并写终态Outbox
DB-->>Worker: 首次通过时处理业务终态
Worker->>DB: 代理钱包回溯、订单/佣金/资产处理
Worker->>DB: 资金确认、订单/佣金/套餐整单终结
```
关键规则:
- 微信、支付宝、线下等非代理钱包支付由财务在系统外人工退款后再通过企微;企微通过即表示人工退款已确认,本系统不调用渠道退款 API也不再提供本地 `manual-complete` 二次确认
- 代理钱包支付订单在企微通过后按原扣款流水定位原代理主钱包,幂等回溯并写退款流水;可自然冲减负余额
- 个人客户资产钱包不自动回款,现有 `BuyerTypePersonal` 自动资产钱包退款分支在迁移时删除或隔离。
- 驳回时本地状态改为已拒绝;撤销/删除时改为已撤销,可修改业务资料后重新申请
- 无论申请金额是否等于订单实收金额,企微同意后的业务处理都按整张订单终结:订单标记已退款、该订单产生的全部有效套餐失效、全部佣金失效,且该订单不能再申请剩余差额。实际向客户退款的金额仍为本次申请金额
- 微信、支付宝、线下及其他非代理钱包支付由财务在系统外人工退款后再通过企微;企微通过即表示人工退款已确认。本系统不调用渠道退款 API也不提供本地 `manual-complete` 二次确认
- 个人客户资产钱包支付同样由财务系统外退款,本系统不自动回充资产钱包、不写资产钱包退款流水;现有 `BuyerTypePersonal` 自动资产钱包退款分支在迁移时删除或隔离。
- 代理主钱包支付订单在企微通过后必须按原扣款流水定位原代理主钱包,幂等回溯并写唯一退款流水;可自然冲减负余额。缺失原扣款流水时处理失败,禁止按当前店铺关系或买卖方猜测钱包
- `actual_refund_amount` 只表示资金已经完成:非代理钱包在企微同意时写申请金额;代理主钱包在余额和退款流水事务成功时写申请金额。驳回、撤销、删除及代理钱包尚未回溯时为空;资金完成但佣金或套餐失败时保留。旧 `approved_refund_amount` 仅作历史兼容,不再是新业务概念。
- 已冻结、解冻中、未发放或待人工修正的佣金直接失效;已发放佣金先从对应佣金钱包全额扣回,再失效,钱包允许为负。佣金保存结构化 `invalid_reason``invalid_refund_id``invalidated_at`,每条已发放佣金以退款单和佣金记录组成防重键;状态、钱包、回扣流水和 Audit Event 同事务。
- 该订单产生的全部有效套餐失效;主套餐失效级联加油包,随后尝试激活下一条待生效主套餐,没有下一条时通过公共卡/设备状态能力停止资产。
- 驳回时本地状态改为已拒绝,审批和该退款单同时终结;原退款单不可编辑、不可再次提交。业务人员纠正驳回原因后仍需退款时,重新走 `POST /api/admin/refunds` 创建新退款单。撤销/删除时进入异常状态和人工处置,同样不开放原单重新提交。
- 通过后撤销且资金已执行时不自动冲正,记录 `critical` 审计、站内告警并人工处理;资金尚未执行时终止后续任务。
- 原退款业务单级 `approve/reject/return` 路由下线,不存在本地审批动作 API。
- 原进程内佣金和套餐 Goroutine 改为 Outbox + 可靠 Worker。资金、佣金、套餐、资产状态各自保存持久化幂等事实局部失败只重试未完成步骤全部完成后才把处理状态置为成功。
重新申请:
一张退款单只对应一条企微审批申请。企微同意或拒绝后,该审批申请和退款单均形成不可变终态;本期下线既有 `POST /api/admin/refunds/{id}/resubmit`,不提供任何原退款单编辑或重提接口。拒绝后再次退款属于新的业务事实:前端重新进入退款创建流程,用户根据拒绝原因重新填写金额、凭证和原因,后端生成新的退款 ID、退款单号、业务快照、提交人快照和企微审批申请。新旧退款单只因指向同一订单或资产而具有关联不继承审批节点、意见、附件、状态或企微发起身份已拒绝退款不计入该订单或资产的活跃退款但仍须阻止与其他活跃退款并存。企微意外返回撤销、删除或通过后撤销时只进入异常处置不作为创建新退款的自动放行依据。
```text
POST /api/admin/refunds/{id}/resubmit
```
代理退款查询不再按创建账号隔离,改为既有店铺层级和退款业务权限范围;代理可看业务凭证、真实提交人、审批状态和处理结果,但看不到审批人、内部意见和审批人附件。平台和超级管理员也必须具备退款业务查看权限才可读取完整审批详情。列表、详情、附件下载和导出复用同一权限投影。
只允许已驳回、已撤销或已删除且尚未完成退款的申请按原退款规则修改申请金额、凭证和原因,并创建 `round_no + 1` 的企微实例;订单、资产快照、提交人和历史审批不可修改
前端只读展示退款状态、企微审批状态和业务处理结果,不显示本地审批、金额修改、重提或人工退款确认按钮。详情固定分为退款业务信息、企微审批信息和业务处理结果;处理失败展示脱敏错误摘要及系统重试状态
前端只读展示企微审批状态、意见/附件和业务处理结果,不显示本地审批或人工退款确认按钮
本需求实现完成必须同时通过两类门禁:可编程 WeCom Adapter 的可重复自动化,以及真实企微验收。真实企微至少完成“代理固定成员代提交并同意”和“平台本人绑定提交并拒绝”两张独立退款,覆盖真实附件、`applyevent`、加密回调、轮询兜底及本地资金/佣金/套餐/审计核对。回调按“企业微信 → 用户提供的中转应用 → 本地服务”原样转发,后端仍完整验签解密;真实参数只通过安全配置提供。撤销、删除和通过后撤销由 Adapter 自动化稳定覆盖,不强制每次真实企微人工制造
### 6.6 需求 21代理在线充值与员工线下充值审批
@@ -1133,10 +1350,12 @@ POST /api/admin/refunds/{id}/resubmit
- 代理只能为当前店铺主钱包充值,最低 `10000`100 元)。
- 支持微信 Native 和支付宝 `alipay.trade.precreate`,创建本地充值单和 `tb_payment(order_type=agent_recharge)` 后再预下单。
- 后端统一返回 `qr_content` 和过期时间,前端使用二维码组件渲染,不生成后端图片文件。
- 每次主动创建都生成新的充值单和支付单;`request_id` 只防同一次 HTTP 提交重试,不按金额或已有待支付单复用。后端原样返回第三方 `qr_content`,前端使用二维码组件渲染,不生成后端图片文件。
- 不返回 `expires_at`,也不展示本地推算的精确倒计时;支付是否成功、关闭或失效以第三方回调和受控查单结果为准。前端每 3 秒轮询的轻量接口只读取本地状态,不直接触发第三方查单。
- 微信/支付宝回调按支付单类型分发,校验渠道、支付配置、金额、第三方交易号和业务单关联。
- 支付单、充值单、代理主钱包、钱包版本、唯一钱包流水和 Audit Event 在同一事务推进;重复回调只返回渠道成功,不重复加钱。
- 支付成功但钱包事务失败时保持已支付并由可靠任务补齐;二维码过期后关闭原充值单,重新生成必须创建新支付单
- 支付确认和钱包入账分成两个可靠事务:第一阶段固化支付单已支付、充值单已支付、处理状态和入账 Outbox第二阶段由 Worker 原子更新代理主钱包、版本、唯一流水、充值完成状态和资金 Audit Event。重复回调或任务不重复加钱。
- 支付成功但钱包事务失败时保持已支付并由可靠任务补齐;第三方明确关闭或失效时支付单沿用 `2=已失败`、充值单改为已关闭,不新增支付“已关闭”状态。已关闭后收到可核验的迟到成功回调仍按真实收款事实幂等入账
- 钱包实际入账成功后向目标代理店铺主账号发送站内到账通知;在线真实提交人不是主账号时也接收,按充值单和接收人防重。
```text
GET /api/admin/agent-recharges/payment-methods
@@ -1144,7 +1363,7 @@ POST /api/admin/agent-recharges
GET /api/admin/agent-recharges/{id}/payment-status
```
代理在线充值详情固定 `approval_source=none`前端每 3 秒轮询轻量支付状态,页面不可见时暂停。
代理在线充值详情固定 `approval_source=none`前端每 3 秒轮询本地轻量支付状态,页面不可见时暂停;响应分别返回支付状态和统一 `processing_status`,不提供同义字段 `wallet_posting_status`
#### 平台员工线下代充值
@@ -1182,11 +1401,13 @@ sequenceDiagram
关键规则:
- 企微通过后自动增加代理主钱包余额并写钱包流水,无操作密码。
- 线下金额只要求大于 0且不超过 100 万元;目标店铺、固定金额和 15 个结构化付款凭证必填,备注选填且最多 500 字。企微只能同意或拒绝,不能修改金额。
- Worker 使用 `recharge:{recharge_no}` 幂等键和处理租约。
- 钱包余额、版本、充值单和资金流水必须在同一事务更新。
- 若资金流水已存在但充值单状态未完成,重试只补齐状态,不再次入账。
- 驳回改为已驳回撤销/删除改为已关闭,可重新创建充值申请。
- 驳回改为已驳回并终结原单;不增加已退回状态或原单重提接口,修正后必须重新创建。撤销/删除改为已关闭,可重新创建充值申请。
- 通过后撤销且已经入账时不自动扣回,记录严重异常并通知财务;尚未入账时终止任务。
- 钱包实际入账成功后向目标代理店铺主账号发送站内到账通知;企微审批结果另行通知真实业务提交人。
旧接口在停机发布后不再注册:
@@ -1205,14 +1426,14 @@ POST /api/admin/agent-recharges/{id}/reject
- 检查同一设备多条 `is_current=true` 绑定并先修复。
- 检查未删除卡 `iccid_19` 重复,解决冲突后才能建立部分唯一索引。
- 检查 `realname_link_type!=none` 但资产 `realname_policy=none` 的冲突数据并明确修正结果。
- 使用生产卡量测算活跃/不活跃轮询间隔对应的 Gateway QPS 和最长兜底延迟
- 记录现有轮询配置、队列深度、卡级开关和监控基线,发布后验证调度行为未被公共写入改造改变
- 核对存量退款的支付方式和买家类型;仅将代理钱包订单接入自动回退,个人资产钱包订单不得误入该处理器。
- 统计待审批退款、平台员工线下充值和历史终态记录数量。
- 轮换用户 demo 中泄露的企微 Secret、Token 和 EncodingAESKey配置可信域名、应用可见范围和回调地址。
- 发布并验证退款、线下充值企微模板控件映射要求会发起审批的平台员完成扫码绑定。
- 发布并验证退款、线下充值企微模板控件映射,验证代理固定代提交成员可用,并要求会发起审批的平台/超级管理员完成扫码绑定。
- 验证微信 Native、支付宝 PreCreate 配置和回调地址,确认代理充值支付渠道可用。
- 盘点旧账号、资产、轮询日志的写入口和查询入口,确认统一审计切换清单。
- 初始化角色导出字段的最小权限集,禁止默认放开敏感字段
- 停机窗口内由业务使用超级管理员配置普通角色导出字段并抽样验证;不运行默认授权迁移,永久禁止导出的字段不进入代码目录
### 7.2 迁移清单
@@ -1221,7 +1442,7 @@ POST /api/admin/agent-recharges/{id}/reject
| 新建 | `tb_system_config` | 受控动态配置 |
| 新建 | `tb_notification` | 分类、级别、受控跳转和接收人幂等 |
| 新建 | `tb_wecom_approval_scene``tb_wecom_approval_template_version` | 稳定场景和不可变模板映射版本 |
| 新建 | `tb_wecom_approval_instance``tb_account_wecom_mapping` | 企微审批镜像账号扫码绑定 |
| 新建 | `tb_wecom_approval_instance``tb_account_wecom_mapping` | 企微审批镜像、平台账号扫码绑定、真实业务提交人和企微发起身份快照 |
| 新建 | `tb_audit_event``tb_audit_event_resource` | 全局不可变业务审计和多资源关联 |
| 新建 | `tb_integration_log` | Gateway、运营商、企微和支付渠道交互 |
| 新建/确认 | `tb_outbox_event` | 可靠事件投递 |
@@ -1230,8 +1451,7 @@ POST /api/admin/agent-recharges/{id}/reject
| 新建 | `tb_expiry_push_record` | 15/7/3 天临期通知防重 |
| 新建 | `tb_bulk_purchase_task``tb_bulk_purchase_item` | 批量订购任务和逐行明细 |
| 修改 | `tb_device` | 实名状态快照 |
| 修改 | `tb_iot_card`/轮询调度状态 | 活跃级别、最后活跃/上游变化信息;高频调度心跳不写核心表 |
| 修改 | `tb_polling_config` | 收口为全局活跃/不活跃间隔,不再按卡类别形成轮询资格 |
| 修改 | 现有实名/流量/网络轮询 Handler | 保持调度不变,只将查询结果交给公共卡状态写入用例 |
| 修改 | `tb_iot_card.iccid_19` | 未删除数据范围 Partial Unique Index |
| 修改 | `tb_shop_package_allocation``tb_package_usage` | 生效条件、周期和时长快照 |
| 修改 | `tb_exchange_order` | 提交人快照、资产标识快照和新旧资产查询索引 |
@@ -1239,7 +1459,7 @@ POST /api/admin/agent-recharges/{id}/reject
| 修改 | `tb_shop` | 可空平台业务员字段 `business_owner_account_id` |
| 修改 | `tb_role` | 新建店铺默认信用开关和额度模板 |
| 修改 | `tb_agent_wallet` | 实际信用开关、额度和 CHECK 约束 |
| 修改 | `tb_refund_request` | 提交人、企微实例轮次、撤销状态和终态处理结果 |
| 修改 | `tb_refund_request` | 提交人、唯一企微审批申请引用、撤销状态和终态处理结果 |
| 修改 | `tb_agent_recharge_record` | 提交人、企微实例、支付状态和入账处理结果 |
| 修改 | `tb_payment` | 支持 `order_type=agent_recharge` 和创建时支付配置快照 |
@@ -1251,7 +1471,7 @@ POST /api/admin/agent-recharges/{id}/reject
flowchart LR
M[进入维护模式并停止写入] --> DB[执行增量迁移]
DB --> Deploy[同时发布 API、Relay/Worker、前端]
Deploy --> WeCom[发布企微模板映射和扫码绑定]
Deploy --> WeCom[发布企微模板映射、验证代理固定账号并完成平台账号绑定]
WeCom --> Backfill[提交存量待审批单到企微]
Backfill --> Verify[人工验证核心链路]
Verify --> Open[解除维护模式]
@@ -1262,8 +1482,8 @@ flowchart LR
1. 停止订单、退款、充值、资产状态和配置相关写入,暂停旧 Worker。
2. 创建企微、审计、通知、批量任务、权限和同步调度所需表/索引,并增加业务关联字段。
3. 同时发布 API、Relay/Worker 和前端,统一切换 Audit Writer、Integration Log 和 Access Log 脱敏。
4. 发布两个企微模板映射版本,验证连接、回调状态查询和员工扫码绑定
5. 使用一次性 Application 命令为存量待审批退款和平台员工线下充值创建企微实例;未绑定创建人的记录进入迁移待处理列表。
4. 发布两个企微模板映射版本,验证连接、代理固定代提交账号、平台账号扫码绑定、回调状态查询。
5. 使用一次性 Application 命令为存量待审批记录创建企微实例:代理创建的退款使用固定代提交账号;平台/超级管理员创建的记录仅在创建人已绑定企微时提交,未绑定或真实创建人缺失的记录进入迁移待处理列表。
6. 历史终态记录保留 `approval_source=legacy`,不得伪造企微实例或审批时间线。
7. 验证数据同步三通道、19/20 位 ICCID 回调、代理微信/支付宝扫码充值和统一审计查询。
8. 确认旧退款审批、充值 `offline-pay/reject`、旧审计写入口和重复卡状态写逻辑不可访问。
@@ -1287,7 +1507,7 @@ flowchart LR
| 编号 | 已确认结论 |
|------|------------|
| C-01 | 限速内部单位固定 `kbps`;本期仅人工设置/取消Gateway 取消参数仅由适配器处理。 |
| C-01 | 限速使用固定语义化 `speed_level`;前端不得输入任意速率或渠道 `code`;恢复不限速与限到 0kbps 严格区分,上游编码由适配器按 Gateway 账户和运营商映射。 |
| C-02 | 实名策略批量单次最多 500 条,事务内全成全败。 |
| C-03 | 设备批量分配文件最大 10MB、最多 1000 行、每批 200 条、失败明细最多 1000 条,且一任务只做一种分配操作。 |
| C-04 | 批量订购线下凭证仅保存业务资料快照,不校验跨批次唯一性或财务核销。 |
@@ -1301,7 +1521,7 @@ flowchart LR
| C-12 | H5 不增加全局实名策略默认值,新建卡/设备继续默认 `after_order`。 |
| C-13 | 行业卡实名由 `realname_link_type` 决定19/20 位 ICCID 精确查对应列,不截断、不补位、不跨列降级。 |
| C-14 | 事件同步固定立即、3 分钟、5 分钟三次Gateway 超频不退避,不删除后续任务。 |
| C-15 | 企微账号只允许扫码绑定一个 `userid`能覆盖绑定到第二个系统账号。 |
| C-15 | 平台/超级管理员企微账号只允许扫码绑定一个 `userid`得绑定多个系统账号;代理审批使用部署配置中的固定企微账号代提交,真实业务提交人始终独立保存和展示。 |
| C-16 | 代理在线充值最低 100 元,支付成功直接入主钱包,不因金额大进入审批。 |
| C-17 | 全局审计本次停止旧表新写入;历史只读投影,不双写、不在线回填。 |
| C-18 | 角色默认信用配置只作用于以后新建的店铺;修改角色不更新已有店铺。 |
@@ -1309,7 +1529,7 @@ flowchart LR
| C-20 | 临期只发站内通知3 天内使用红色,且只在临期独立列表置顶。 |
| C-21 | 换货新资产继承旧资产店铺;旧资产保留原归属,已属于其他店铺的新资产不得换入。 |
Gateway 的取消限速具体报文不是产品决策:上线前由上游接口契约确定,业务层始终只传 `speed_kbps=0`
Gateway 上游已确认使用 `POST /flow-card/speedLimit`,请求核心参数为 `cardNo + code` 并由统一客户端包装在 `params` 中。业务层始终只传语义化 `speed_level``code=-1` 表示恢复不限速,`code=0` 表示限到 0kbps二者禁止混用。不同 Gateway 账户相同速率编码可能不同,实施前必须完成账户/运营商映射和真实环境联调
### 8.1 主要运行风险
@@ -1318,11 +1538,12 @@ Gateway 的取消限速具体报文不是产品决策:上线前由上游接口
| 企微模板失效 | 模板或控件 ID 编辑后变化 | 场景暂停、模板版本、定期验证和原子切换 |
| 企微提交重复 | `applyevent` 超时后盲目重试 | 提交结果未知状态和人工绑定 `sp_no` |
| 审批回调漏失 | 网络或企微回调异常 | 审批中实例每 2 分钟查询详情兜底 |
| 账号绑定冲突 | 同一企微成员绑定多个账号 | `userid` 唯一约束、扫码会话和强制解绑审计 |
| 平台账号绑定冲突 | 同一企微成员绑定多个平台账号 | `userid` 唯一约束、扫码会话和强制解绑审计 |
| 代理固定代提交账号失效 | 成员离职、停用或移出应用可见范围 | 周期验证、提交前就绪校验、暂停代理新申请和系统告警 |
| 重复代理钱包回退或入账 | Outbox/Asynq 至少一次投递 | 稳定业务幂等键、资金流水唯一业务号、处理租约 |
| 通过后撤销 | 企微通过且资金已执行后撤销 | 不自动冲正critical 审计、通知和人工处理 |
| 支付重复回调 | 微信/支付宝多次通知 | 支付单状态条件、钱包流水唯一键和业务幂等键 |
| 同步请求放大 | 查询、轮询和业务事件同时请求同一卡 | 同场景合并、单互斥、运营商最小间隔和活跃调频 |
| 同步请求放大 | 查询、轮询和业务事件同时请求同一卡 | 事件序列按同场景合并、单次请求互斥、运营商最小间隔;现有周期轮询调度保持不变 |
| ICCID 错配 | 20 位截断或 19 位补位命中错误卡 | 双列精确路由和 `iccid_19` 部分唯一索引 |
| 审计数据量过大 | 高频轮询和全局写操作持续增长 | 时间索引、分批清理、Integration Log 短保留和后续月分区 |
| 批量重复下单 | 重复提交或并发 Worker | 创建请求幂等、任务/明细条件领取、行级唯一键 |
@@ -1345,8 +1566,8 @@ Gateway 的取消限速具体报文不是产品决策:上线前由上游接口
- Outbox 待投递数、最早积压时间和失败次数。
- 企微 Token 刷新、模板失效、提交结果未知、回调失败、待审批轮询积压和业务处理失败。
- 账号扫码绑定成功/失败/冲突未绑定申请人数量。
- 活跃/不活跃卡数量、各同步类型 QPS、Gateway 超频、连续失败和事件序列完成率。
- 平台账号扫码绑定成功/失败/冲突未绑定平台申请人数、代理固定代提交账号验证结果和提交失败数量。
- 现有轮询队列/配置基线、各同步类型 QPS、Gateway 超频、连续失败和事件序列完成率。
- Audit Event/Integration Log 写入失败、增长速度、清理积压和敏感读取次数。
- 微信/支付宝预下单、回调失败、已支付未入账和重复回调数量。
- 批量任务成功/失败/部分成功数量。
@@ -1358,8 +1579,8 @@ Gateway 的取消限速具体报文不是产品决策:上线前由上游接口
| 范围 | 必测场景 |
|------|----------|
| 企微审批 | 模板发布/失效、扫码绑定、提交、回调、2分钟轮询、驳回/撤销/删除、提交未知恢复、意见附件和业务快照 |
| 数据同步 | 活跃调频、0/3/5 阶梯、同场景合并、单互斥、超频不退避、19/20位回调、解除实名忽略、旧入口收口 |
| 企微审批 | 模板发布/失效、平台账号扫码绑定、代理固定账号有效/失效、两类发起身份与真实业务提交人展示、提交、回调、2分钟轮询、驳回/撤销/删除、提交未知恢复、意见附件和业务快照 |
| 数据同步 | 现有轮询调度回归、0/3/5 阶梯、同场景合并、单次请求互斥、事件超频不退避、19/20位回调、解除实名忽略、旧入口收口 |
| 全局审计 | 关键事务失败回滚、人员/资源/请求/资金/风险/集成视角、历史投影、脱敏、旧表停止新写入 |
| 钱包 | 普通余额扣款、信用扣款、额度降低失败、并发版本冲突、负余额回充 |
| 角色默认额度 | 新建店铺继承默认值、修改角色不影响已有店铺、店铺独立调额 |
@@ -1367,14 +1588,14 @@ Gateway 的取消限速具体报文不是产品决策:上线前由上游接口
| 批量订购 | 钱包/线下两种支付、部分成功、重复提交、Worker 重试、失败明细、模板错误 |
| 退款 | 金额发起时固定、财务人工退款企微确认、代理钱包回退原主钱包、个人资产钱包不误入、通过后撤销不冲正 |
| 充值 | 微信/支付宝最低100元扫码、重复回调不重复入账、已支付恢复、在线不审批、线下企微通过自动入账且无操作密码 |
| 导出 | 普通角色、敏感字段角色、超级管理员、权限为空、审批摘要批量查询 |
| 限速 | 单卡、设备当前卡、无当前卡、设置、取消、Gateway 失败和审计记录 |
| 导出 | 普通角色字段并集、超级管理员全代码目录、权限为空、行权限、附件受保护链接、审批摘要和附件批量查询 |
| 限速 | 单卡、设备当前卡、无/多当前卡、固定档位、恢复不限速、限到0kbps、运营商能力、Gateway 失败/结果未知和审计记录 |
| 临期 | 当前与排队套餐最终到期推算、等待实名不可预计、15/7/3 天、3天红色和临期页置顶、漏跑补发、列表/详情/C端一致 |
| 换货补充 | 新资产继承店铺、其他店铺资产拒绝、旧资产保留归属、前代/后代标识和受控跳转 |
| 系列授权 | 首次和后续批量选择、已授权置灰、重复提交幂等、三类价格含义正确 |
| 发布 | 存量退款和充值回填、历史 `legacy`、旧路由不可访问、Worker 暂停后恢复 |
验收使用接口调用、PostgreSQL 数据核对、日志检查和页面操作,不以自动化测试作为本项目交付前提
验收同时使用自动化测试、真实接口调用、PostgreSQL 数据核对、日志检查和页面操作;资金、状态机、权限、异步幂等和第三方 Adapter 必须有可重复的自动化公共行为测试,真实第三方联调与人工验收不能被测试替代
---
@@ -1383,8 +1604,8 @@ Gateway 的取消限速具体报文不是产品决策:上线前由上游接口
```text
1. 增量迁移、事务管理、Outbox、统一审计模型和 Access Log 脱敏
2. 站内通知与审计多视角 Query/前端
3. 卡状态领域、轮询活跃调频、事件阶梯和运营商回调防腐层
4. 企微连接、模板版本、账号扫码绑定、提交/回调/轮询
3. 卡状态公共写入、现有轮询接入、事件阶梯和运营商回调防腐层
4. 企微连接、模板版本、平台账号扫码绑定、代理固定代提交、提交/回调/轮询
5. 退款和平台员工线下充值接入企微,代理微信/支付宝扫码充值
6. 钱包信用额度、业务员和余额预警、批量订购、导出权限、设备批量分配、系列套餐批量授权、限速和临期提醒
7. 换货归属与换货链、其他查询和显示修复、存量回填及停机发布演练
@@ -1413,8 +1634,8 @@ Gateway 的取消限速具体报文不是产品决策:上线前由上游接口
| 工作包 | 后端人日 | 前端人日 | 快速交付说明 |
|--------|----------|----------|--------------|
| 迁移、Outbox、全局审计和站内通知 | 2 | 1.52 | 复用现有日志、列表和抽屉组件,先完成核心多视角 |
| 数据同步领域收口、活跃轮询和运营商回调 | 23 | 0.51 | 后端为主,前端只补状态和审计跳转 |
| 企微模板、扫码绑定、提交/回调/轮询 | 23 | 1.52 | 复用企微官方页面和现有业务详情布局 |
| 数据同步领域收口、事件触发和运营商回调 | 23 | 0.51 | 现有轮询调度不改,前端只补审计轨迹跳转 |
| 企微模板、平台绑定、代理固定代提交、提交/回调/轮询 | 23 | 1.52 | 复用企微官方页面和现有业务详情布局 |
| 退款、线下充值终态和代理扫码充值 | 23 | 1.52 | 复用现有钱包、支付回调和充值页面 |
| 信用、业务员预警、换货、系列授权、批量、导出、限速和临期 | 2.53.5 | 2.53 | 系列授权和换货链已有后端基础,只补增量能力 |
| 联调、数据核对、回填和停机发布 | 11.5 | 0.51 | 随开发持续联调,最后集中验证核心链路 |
@@ -1428,8 +1649,8 @@ Gateway 的取消限速具体报文不是产品决策:上线前由上游接口
```text
第 12 天迁移、Outbox、审计/通知骨架,前端同步搭建页面框架
第 35 天:数据同步收口、活跃轮询、回调防腐层和审计外部集成视角
第 47 天:企微模板、扫码绑定、审批提交/回调/轮询及前端配置页面
第 35 天:数据同步写入收口、事件阶梯、回调防腐层和审计外部集成视角
第 47 天:企微模板、平台账号扫码绑定、代理固定代提交、审批提交/回调/轮询及前端配置页面
第 69 天:退款、线下充值终态、代理微信/支付宝扫码充值
第 812 天:信用、业务员预警、换货归属与标识、系列批量授权、批量、导出、限速和临期
第 1214 天:全链路联调、数据核对、旧入口清理和存量回填演练
@@ -1445,7 +1666,7 @@ Gateway 的取消限速具体报文不是产品决策:上线前由上游接口
| 本期范围与移出项 | 待评审 | | |
| 渐进式 DDD 边界 | 待评审 | | |
| 数据同步三通道与运营商回调 | 待评审 | | |
| 企业微信审批、模板版本账号绑定 | 待评审 | | |
| 企业微信审批、模板版本、平台账号绑定与代理固定代提交 | 待评审 | | |
| 全局多视角审计与历史投影 | 待评审 | | |
| 站内通知与受控跳转 | 待评审 | | |
| 资金与信用额度 | 待评审 | | |
@@ -1455,4 +1676,4 @@ Gateway 的取消限速具体报文不是产品决策:上线前由上游接口
| 前端交互和权限 | 待评审 | | |
| 停机发布和回滚 | 待评审 | | |
评审通过条件:第八章约束全部纳入实施任务;除 Gateway 上游取消参数和真实第三方联调结果外,不存在需要实施人员自行猜测的数据模型、接口、状态语义或资金规则。
评审通过条件:第八章约束全部纳入实施任务;除真实第三方联调结果和不同 Gateway 账户/运营商的档位映射配置外,不存在需要实施人员自行猜测的数据模型、接口、状态语义或资金规则。

View File

@@ -57,7 +57,7 @@ FE/BE 研发需求开发完成
| #38 不同渠道额度处理 | `[FE][UR#38] 角色默认信用和店铺实际额度管理` | `[BE][UR#38] 代理主钱包信用额度与并发资金不变量` | INT-05 钱包支付 |
| #37 审核流转 | `[FE][UR#37] 企微审批状态、意见附件和结果通知展示` | `[BE][UR#37] 企微审批模板、账号绑定、回调和轮询补偿` | INT-06 企微审批 |
| #36 批量订购套餐 | `[FE][UR#36] 批量订购上传、支付方式、进度和失败明细` | `[BE][UR#36] 批量订购任务、逐行幂等下单和钱包扣款` | INT-04 批量与导出、INT-05 钱包支付 |
| #35 退款审核 | `[FE][UR#35] 退款企微审批详情与业务处理状态` | `[BE][UR#35] 退款企微终态、人工退款和代理钱包回溯` | INT-06 企微审批 |
| #35 退款审核 | `[FE][UR#35] 退款企微审批详情与业务处理状态` | `[BE][UR#35] 退款企微终态与整单退款终结` | INT-06 企微审批 |
| #34 充值审核流程 | `[FE][UR#34] 代理扫码充值与员工线下审批状态页面` | `[BE][UR#34] 微信/支付宝充值入账和线下充值企微终态` | INT-05 钱包支付、INT-06 企微审批 |
| #33 套餐临期提醒 | `[FE][UR#33] 临期列表、各端高亮、3天置顶和续费入口` | `[BE][UR#33] 最终到期临期Query与15/7/3站内通知` | INT-03 套餐生命周期 |
@@ -112,9 +112,9 @@ FE/BE 研发需求开发完成
| #40 下架套餐续费 | **标题:**C端当前套餐续费入口。<br>**页面:**当前套餐旁显示续费;下架套餐不出现在新购列表。 | **标题:**下架套餐续费资格。<br>**接口:**`GET /api/c/v1/asset/packages` 返回 `can_purchase/purchase_mode/disabled_reason``POST /api/c/v1/orders/create` 强校验资产所有人和历史使用记录。 |
| #38 代理信用额度 | **标题:**角色默认信用和店铺实际额度管理。<br>**页面:**客户角色配置新建默认额度并提示不影响存量;店铺资金页单独调整实际额度。 | **标题:**代理主钱包信用额度。<br>**接口:**`PUT /api/admin/roles/{id}/default-credit``PUT /api/admin/shops/{id}/credit-limit``GET /api/admin/shops/fund-summary`。<br>**入参:**开关、额度、钱包版本。<br>**返回:**额度、可用金额、欠款和版本。 |
| #37 企微审核流转 | **标题:**企微配置、账号绑定和审批详情。<br>**页面:**企微配置页、个人扫码绑定、审批运行列表;业务详情只读展示意见和附件。 | **标题:**企业微信审批接入。<br>**接口:**`GET /api/admin/wecom/status``POST /api/admin/wecom/account-binding/sessions``GET /api/admin/wecom/approvals``POST /api/admin/wecom/approvals/{id}/sync`。<br>**业务详情:**统一返回 `approval` 对象。 |
| #36 批量订购套餐 | **标题:**批量订购上传、支付、进度和失败明细。<br>**页面:**选择代理和整批支付方式上传Excel及线下凭证展示部分成功。 | **标题:**批量订购任务。<br>**接口:**`POST /api/admin/bulk-purchases``GET /api/admin/bulk-purchases/{task_id}``GET /api/admin/bulk-purchases/{task_id}/items`。<br>**入参:**代理、支付方式、文件、凭证。<br>**返回:**任务和逐行结果。 |
| #35 退款审核 | **标题:**退款企微审批和退款处理状态。<br>**页面:**创建时上传备注附件;详情展示审批、人工退款说明和业务处理结果。 | **标题:**退款企微终态处理。<br>**接口:**`POST /api/admin/refunds``GET /api/admin/refunds/{id}``POST /api/admin/refunds/{id}/resubmit`。<br>**返回**退款数据、`approval``processing_status`代理钱包通过后幂等回溯。 |
| #34 充值审核流程 | **标题:**代理扫码充值与员工线下充值审批。<br>**页面:**在线充值展示支付方式、二维码支付状态;线下充值展示只读企微审批状态。 | **标题:**代理在线充值和员工线下审批。<br>**接口:**`GET /api/admin/agent-recharges/payment-methods``POST /api/admin/agent-recharges``GET /api/admin/agent-recharges/{id}/payment-status``GET /api/admin/agent-recharges/{id}`。<br>**返回:**二维码、过期时间、支付/审批/入账状态。 |
| #36 批量订购套餐 | **标题:**批量订购上传、支付、进度和失败明细。<br>**页面:**沿用现有前端入口可见性选择单个套餐和整批支付方式CSV及线下凭证先直传对象存储不选择代理同一CSV可含不同代理资产不接受Excel。 | **标题:**批量订购任务。<br>**接口:**`POST /api/admin/storage/upload-url``POST /api/admin/bulk-purchases``GET /api/admin/bulk-purchases/{task_id}``GET /api/admin/bulk-purchases/{task_id}/items`。<br>**权限:**后端仅复用现有后台认证,不新增权限码、账号类型拦截或任务创建人隔离。<br>**入参:**单个`package_id``payment_method=wallet|offline``file_key``voucher_keys`,无`shop_id``wallet`按CSV行号逐行扣结算代理主钱包余额不足只失败当前行并继续。<br>**文件:**CSV仅一列“资产标识”创建接口校验套餐和对象/类型/10MBWorker校验编码、表头、语法、空文件及1000行文件级错误整批失败且不下单。<br>**资产:**复用统一资产解析能力识别类型和ID当前支持ICCID、卡`virtual_no`、MSISDN、设备`virtual_no`、IMEI和SN。<br>**判重:**按解析后的资产类型和资产ID判重同一资产不同标识也仅首行处理。<br>**状态:**统一五态,部分成功由计数表达。<br>**返回:**任务和逐行结算代理结果。 |
| #35 退款审核 | **标题:**退款企微审批和整单处理状态。<br>**页面:**创建只提交订单、固定申请金额、必填原因、可选备注及15个结构化附件不提交实收金额或套餐使用记录详情分区展示退款、审批和处理结果拒绝后重新退款必须新建退款单代理隐藏审批人、内部意见和审批附件。 | **标题:**退款企微终态与整单终结。<br>**接口:**`POST /api/admin/refunds``GET /api/admin/refunds``GET /api/admin/refunds/{id}`;下线原 `approve/reject/return/resubmit` 及任何 `manual-complete`。<br>**规则**后端读取实收并校验固定申请金额;无论是否全额,通过后订单、该订单套餐和佣金均整单终结且不可再退差额。只有代理钱包按原扣款流水自动回溯;其他方式系统外退款,资产钱包不回充。佣金结构化失效并可靠回扣,处理状态独立且可重试。代理按店铺层级权限查看。<br>**门禁:**可编程Adapter自动化和真实企微代理同意/平台拒绝两条链路均必过。 |
| #34 充值审核流程 | **标题:**代理扫码充值与员工线下充值审批。<br>**页面:**在线充值展示支付方式、二维码支付状态和钱包入账状态,不展示本地推算倒计时;线下充值展示只读企微审批状态。 | **标题:**代理在线充值和员工线下审批。<br>**接口:**`GET /api/admin/agent-recharges/payment-methods``POST /api/admin/agent-recharges``GET /api/admin/agent-recharges/{id}/payment-status``GET /api/admin/agent-recharges/{id}`。<br>**返回:**`qr_content`、支付/审批/统一处理状态,不返回支付 `expires_at`。支付与钱包两阶段可靠推进,到账后发送代理站内通知。 |
| #33 套餐临期提醒 | **标题:**临期列表、各端高亮和续费入口。<br>**页面:**临期页3天内置顶普通资产列表只高亮代理首页显示数量C端显示续费按钮。 | **标题:**预计最终到期临期Query和站内通知。<br>**接口:**`GET /api/admin/expiring-assets`、资产列表/详情增加临期字段、`GET /api/c/v1/asset/info`、通知接口。<br>**返回:**最终到期、剩余天数、颜色节点15/7/3天通知防重。 |
## 五、前端Mock公共样例
@@ -197,7 +197,7 @@ FE/BE 研发需求开发完成
| INT-01 | 资产实名、复机与状态同步联调 | #94#73#62#53 | FE 11.5h / BE 11.5h,已含 | 实名策略接口、同步任务和H5页面完成 | 不同运营商实名能力、先实名/先购买、0/3/5同步、回调后状态和筛选一致 |
| INT-02 | 换货完整链路联调 | #98#86#57#45 | FE 0.51h / BE 0.51h已含 | 换货接口、详情和列表页面完成 | 退款拦截、新资产继承店铺、完成换货、列表搜索、前代/后代跳转 |
| INT-03 | 套餐授权、购买、续费、到期与临期联调 | #55#46#43#40#33 | FE 11.5h / BE 11.5h,已含 | 套餐快照、授权候选和各端到期字段完成 | 批量授权、购买生效条件、下架续费、排队套餐最终到期、临期高亮和通知 |
| INT-04 | Excel批量任务与导出联调 | #49#36#42 | FE 11.5h / BE 11.5h,已含 | 上传、任务详情、Worker和导出场景完成 | 模板校验、部分成功、失败明细、任务恢复、字段权限和文件下载 |
| INT-04 | CSV批量任务与导出联调 | #49#36#42 | FE 11.5h / BE 11.5h,已含 | 上传、任务详情、Worker和导出场景完成 | 模板校验、部分成功、失败明细、任务恢复、字段权限和文件下载 |
| INT-05 | 支付、代理钱包、信用和余额预警联调 | #48#38#34#36#96#97 | FE 11.5h / BE 11.5h,已含 | 支付配置、钱包领域和业务员接口完成 | 支付方式限制、扫码充值、信用扣款、角色默认额度、店铺调额、100元预警 |
| INT-06 | 企业微信审批、退款和线下充值联调 | #37#35#34#44 | FE 1.52h / BE 1.52h已含 | 企微模板、绑定、回调、轮询和业务详情完成 | 扫码绑定、发起审批、意见附件、通过/驳回/撤销、退款/充值终态和列表摘要 |
| INT-07 | Gateway卡限速联调 | #47 | FE 0.51h / BE 0.51h已含 | Gateway联调配置和卡/设备入口完成 | 单卡限速、设备解析当前卡、取消限速、无当前卡、失败审计 |

View File

@@ -157,10 +157,11 @@
页面入口:店铺新建、店铺编辑、店铺列表、店铺详情。
页面结构:
1. 新建编辑表单增加“平台业务员”可搜索下拉,可为空。
1. 超级管理员和平台账号的新建编辑表单增加“平台业务员”可搜索下拉,可为空;代理账号只读展示,不出现选择或清空控件
2. 候选项展示账号名和手机号摘要,只显示启用的平台账号。
3. 店铺列表增加业务员列和业务员筛选项。
4. 店铺详情展示业务员名称和手机号摘要。
5. 代理创建下级店铺时提示“默认继承直属上级当前业务员”;继承由后端保证。
接口约定:
- POST /api/admin/shops、PUT /api/admin/shops/{id} 增加 business_owner_account_id:int64|null。
@@ -168,7 +169,7 @@
- 业务员候选复用 GET /api/admin/accounts?account_type=platform&status=1items至少返回 account_id、account_name、phone_masked。
- 列表和详情返回 business_owner_account_id:int64|null、business_owner_name:string、business_owner_phone_masked:string。
交互规则:不出现分销、佣金或发展层级文案;已停用业务员仍可在历史详情显示,但编辑候选中不可选。
交互规则:不出现分销、佣金或发展层级文案;代理不能通过构造请求修改业务员;已停用业务员仍可在历史详情显示,但编辑候选中不可选;上级店铺后续变更不自动级联既有下级
完成标准:创建、编辑、筛选、详情展示一致,并覆盖清空业务员和无可选账号状态。
```
@@ -197,6 +198,8 @@
2. 字段不参与店铺层级、数据权限、佣金或分销关系计算。
3. 账号停用或删除后保留历史关联,通知时跳过不可用账号。
4. 变更前后值写入审计。
5. 代理创建下级店铺时由服务端复制所选直属上级店铺当时的业务员;代理请求只要出现该字段即拒绝。
6. 只有超级管理员和平台账号可以显式设置、清空或更换;父店铺后续变更不向既有子孙店铺级联。
完成标准:创建、修改、清空和筛选均生效,不能绑定代理账号或停用平台账号。
```
@@ -214,24 +217,22 @@
**描述**
```markdown
目标:让运营能看懂资产当前轮询策略和最近同步情况,不新增第二个同步按钮。
目标:让运营通过统一审计查看业务事件和回调的同步轨迹;保留现有手动刷新和轮询展示,不新增第二个同步按钮或活跃轮询字段
预计工时前端23小时。
页面入口:卡详情、设备详情、全局审计外部集成页。
页面结构:
1. 详情页同步区域展示轮询是否启用、活跃级别、最后活跃时间、最后活跃场景下次轮询时间
2. 保留现有手动刷新按钮
3. 增加“查看同步轨迹”,跳转全局审计并自动带入资产筛选
4. 同步轨迹按立即、3分钟、5分钟连续展示结果。
1. 保留现有手动刷新按钮及现有轮询状态展示,不新增活跃级别、最后活跃场景下次轮询字段
2. 增加“查看同步轨迹”,跳转全局审计并自动带入资产筛选
3. 同步轨迹按立即、3分钟、5分钟连续展示结果并显示运营商回调和现有周期轮询的Integration Log
接口约定:
- GET /api/admin/assets/resolve/{identifier} 返回 polling:{enabled:bool,activity_level:string,last_activity_at:string|null,last_activity_scene:string,next_poll_at:string|null}。
- 现有手动刷新接口保持原契约。
- GET /api/admin/audit/integrations 支持 resource_type、resource_key、correlation_id 查询。
交互规则rate_limited 只展示本次失败,不显示自动退避倒计时;无下次轮询时显示“-”
交互规则:事件序列的rate_limited只展示本次失败不显示自动退避倒计时前端不根据事件轨迹推算或展示新的周期轮询时间
完成标准:卡和设备均能展示同步状态并跳转到过滤后的轨迹页,加载、空记录和失败状态完整。
```
@@ -241,28 +242,28 @@
**标题**
```text
[BE][UR#94] 资产状态同步DDD收口与轮询优化
[BE][UR#94] 卡状态公共写入、事件触发与运营商实名回调
```
**描述**
```markdown
目标:将轮询、业务事件、手动刷新和运营商回调统一进入卡状态应用用例。
目标:将现有轮询查询结果、业务事件、手动刷新和运营商回调统一进入卡状态应用用例,同时保持现有轮询调度不变
预计工时后端79小时。
规则:
1. 只保留全局 enable_polling 开关,按活跃和不活跃卡使用不同轮询间隔
1. 现有tb_polling_config、Redis分片队列、各同步类型间隔、卡级开关、失败重排、并发控制和监控全部保持现状只替换查询成功后的状态写入及联动
2. 关键业务成功边界创建立即、3分钟、5分钟三个无自动重试任务达到预期状态后后续任务提前完成。
3. Gateway 超频只记录 rate_limited不做 blocked_until 或指数退避。
4. ApplyCardObservation 是实名、流量和网络状态的唯一写入口。
5. 行业卡是否实名由运营商 realname_link_type 决定。
接口:不新增显式同步接口;资产详情 Query 返回 polling;同步轨迹进入统一 Integration Log 和审计 Query。
接口:不新增显式同步接口或活跃轮询字段;现有手动刷新保持原契约同步轨迹进入统一Integration Log和审计Query;只为已掌握真实报文的移动/电信实名成功及联通解除实名增加回调Translator
架构:迁移到 card Domain、同步 Application、Gateway Adapter 和 Query旧轮询及刷新逻辑改为调用统一用例。
完成标准:三条自动通道和手动刷新结果一致,事件序列可追踪,旧逻辑不再直接修改卡状态。
完成标准:现有轮询、事件序列、手动刷新和回调结果一致,事件序列可追踪,旧逻辑不再直接修改卡状态;改造前后轮询配置、下次入队、失败重排和监控统计保持一致
```
## UR#86 资产详情中换货标识
@@ -677,18 +678,19 @@
页面结构:
1. 两个独立按钮:批量分配代理、批量分配套餐系列。
2. 两个弹框都包含前端静态模板下载、目标选择、Excel上传和提交按钮。
2. 两个弹框都包含前端静态CSV模板下载、目标选择、CSV上传和提交按钮不接受Excel
3. 创建成功后进入任务进度区,展示状态、总数、成功数、失败数和失败明细。
4. 页面刷新后根据 task_id 恢复进度。
接口约定:
- POST /api/admin/devices/batch-assign-shopmultipart包含 file、shop_id、request_id
- POST /api/admin/devices/batch-assign-seriesmultipart包含 file、series_id、request_id。
- 前端先调用现有对象存储预签名上传接口purpose=device_batch_allocation将UTF-8 CSV直传私有对象存储
- POST /api/admin/devices/batch-assign-shopJSON body={file_key,shop_id}
- POST /api/admin/devices/batch-assign-seriesJSON body={file_key,series_id}。
- GET /api/admin/devices/batch-allocation/{task_id} 返回 task_id、operation_type、status、status_name、total_count、success_count、failed_count、failed_items。
交互规则一个任务只能选择一种操作任务处理中按2、3、5秒后最大10秒轮询页面不可见时暂停。
完成标准:两个入口互不混淆,部分成功和失败原因可查看,模板由前端静态文件提供。
完成标准:两个入口互不混淆,平台代理权限与现有同步用例一致,长设备号按文本预览,部分成功和失败原因可查看,CSV模板由前端静态文件提供。
```
### 后端研发需求
@@ -710,12 +712,13 @@
规则:
1. operation_type 只能是 assign_shop 或 assign_series一个任务只修改一个目标字段。
2. 文件最大10MB、最多1000行、Worker每批200条。
2. 只接受单列UTF-8 CSV固定表头“设备号”文件最大10MB、最多1000行、Worker每批200条。
3. 设备号去重后批量查询失败明细最多保存1000条。
4. 已属于目标值按幂等成功assign_shop 遇到其他代理资产失败assign_series 不修改 shop_id
5. Asynq载荷只传结构化ID和对象存储Key不传本地路径或文件字节
4. 平台只能分配平台库存代理只能把自己名下设备分给直属下级。assign_series时代理只能操作自己设备并使用自己当前授权系列
5. 已属于目标值按幂等成功assign_shop遇到其他代理资产失败assign_series不修改shop_id
6. 业务接口只接收file_key不接收multipart字节Asynq载荷只传结构化task_id不传对象Key、本地路径或文件字节。
完成标准:任务支持部分成功进度恢复和request_id防重,两种命令的数据边界清晰。
完成标准:任务支持部分成功进度恢复Worker重试幂等平台及代理数据边界正确,两种命令的数据边界清晰。
```
## UR#48 不同资产使用不同支付方式
@@ -744,10 +747,10 @@
接口约定:
- GET /api/c/v1/asset/info 返回 allowed_payment_methods:string[],值为 alipay、wechat、wallet。
- POST /api/c/v1/orders/create POST /api/c/v1/orders/{id}/pay 入参 payment_method:string
- POST /api/c/v1/orders/create 必须提交payment_method:string并固化POST /api/c/v1/orders/{id}/pay 不允许选择或修改payment_method,只提交固定方式执行所需附加参数
- GET /api/admin/system/config?module=payment 查询配置PUT /api/admin/system/config/{config_key} 保存卡和设备允许的支付方式。
交互规则:钱包是否可选完全使用后端返回;创建或支付时后端拒绝的方式直接展示错误,不由前端兜底放行
交互规则:钱包是否可选完全使用后端返回;创建成功后展示订单已选方式且不提供切换。需要换方式时先取消待支付订单再重新创建;强充由创建接口按所选微信/支付宝立即拉起,钱包不能用于给自身强充
完成标准:卡、设备、配置异常和无可用方式四类场景展示正确。
```
@@ -763,13 +766,13 @@
**描述**
```markdown
目标:按资产类型返回允许支付方式,在订单创建及支付阶段强校验。
目标:按资产类型返回允许支付方式,在订单创建时决定并固化支付方式,并在后续实际支付时再次强校验。
预计工时后端23小时。
接口GET /api/c/v1/asset/info 增加 allowed_payment_methods订单创建和支付接口校验 payment_method后台复用系统配置接口。
规则:卡默认支付宝和钱包;设备默认微信和钱包;钱包始终允许;配置异常时使用安全默认值并记录错误,不能放开全部方式。
规则:卡默认支付宝和钱包;设备默认微信和钱包;钱包始终允许;配置异常时使用安全默认值并记录错误,不能放开全部方式。普通订单创建保存不可变payment_method/pay只能读取订单快照执行管理员后来禁用该方式时拒绝支付不自动换渠道。强充在创建流程立即使用所选方式wallet强充拒绝。
完成标准:前端隐藏不能绕过后端校验,订单创建和支付使用同一策略,配置变更写审计。
```
@@ -794,14 +797,14 @@
页面入口:卡详情、设备详情。
页面结构:
1.设置限速”弹框包含 speed_kbps 正整数输入框和确认按钮
2.取消限速”使用独立确认操作,提交 speed_kbps=0
1.手动限速”弹框只展示后端返回的固定语义档位不允许输入任意速率或上游code
2.恢复不限速”提交speed_level=unlimited“限到0kbps”提交speed_level=zero_kbps两者严格区分
3. 设备详情必须显示本次实际作用的当前卡ICCID无当前卡时禁用操作并展示原因。
4. 不展示“Gateway当前实际限速”除非接口未来明确返回查询结果。
接口约定POST /api/admin/assets/{identifier}/speed-limitbody={speed_kbps:int};返回 asset_type、asset_identifier、card_no、speed_kbps、result、message。
接口约定POST /api/admin/assets/{identifier}/speed-limitbody={speed_level:string}返回asset_type、asset_identifier、card_no、speed_level、channel_code、applied_speed、channel_raw_value、result、message。
交互规则:单位固定展示kbps提交中禁用按钮;失败保留输入值;成功后展示后端结果
交互规则:提交中禁用按钮;失败保留所选档位;中国电信/广电按后端能力展示,联通/移动或映射缺失时禁用并展示原因;结果未知不能显示成功
完成标准:卡限速、设备解析当前卡、取消限速和无当前卡四类场景完整。
```
@@ -821,13 +824,13 @@
预计工时后端23小时。
接口POST /api/admin/assets/{identifier}/speed-limit入参 speed_kbps正数为设置0为取消
接口POST /api/admin/assets/{identifier}/speed-limit入参为固定语义枚举speed_level恢复不限速与限到0kbps是不同值前端和业务层不得接触渠道code
规则资产为卡时读取ICCID资产为设备时解析 is_current=true 的有效当前卡;不存在当前卡拒绝绝不把设备号传给Gateway。取消仍调用同一Gateway端口由适配器转换上游取消参数
规则资产为卡时读取ICCID资产为设备时解析is_current=true的唯一有效当前卡;不存在或多条当前卡拒绝绝不把设备号传给Gateway。仅中国电信和中国广电直连POST /flow-card/speedLimitAdapter按Gateway账号+运营商+speed_level映射code缺失映射不猜测。外部副作用关闭自动重试超时等结果不明记录unknown
非目标不建立套餐固定限速不因激活、到期、停机或切卡自动限速不增加自动补偿Worker。
完成标准返回最终card_no和调用结果,每次请求记录操作审计及Integration Log。
完成标准返回最终card_no、语义档位和规范化调用结果;电信、广电、联通、移动、映射缺失、明确失败和结果未知均有正确行为;每次请求记录操作审计及Integration Log。
```
## UR#46 资产信息详情字段新增
@@ -960,7 +963,7 @@
接口约定GET /api/admin/refunds、GET /api/admin/agent-recharges、GET /api/admin/exchanges 的items增加 submitter_name、approval_source、approval_status、approval_status_name、current_approver_summary、processing_status、processing_status_name。
交互规则approval_source=none时审批列显示“-”legacy只读展示wecom展示企微状态。审批人摘要使用省略和悬浮完整文本。
交互规则approval_source=none时审批列显示“-”legacy只读展示wecom展示企微状态。平台/超级管理员按业务权限展示当前审批人摘要,长摘要使用省略和悬浮完整文本;代理不展示审批人列,接口摘要固定为空
完成标准:三类列表字段和空值规则一致,分页切换不会额外逐行请求审批详情。
```
@@ -982,7 +985,7 @@
接口:扩展现有三类列表返回 submitter_name、approval_source、approval_status_name、current_approver_summary、processing_status_name。
规则提交人保存业务快照企微摘要按当前页实例ID批量查询禁止N+1历史本地审批返回approval_source=legacy无审批返回none。
规则提交人保存业务快照企微摘要按当前页实例ID批量查询禁止N+1历史本地审批返回approval_source=legacy无审批返回none。当前审批人属于平台内部信息,仅平台/超级管理员按业务权限返回代理响应中的current_approver_summary固定为空。
完成标准:列表查询次数稳定,历史数据可读,审批摘要与详情状态一致。
```
@@ -1008,13 +1011,15 @@
页面结构:
1. 两个入口复用同一套餐候选表格。
2. 列包含套餐名称、编码、公司成本价、当前授权成本价、建议售价、授权状态
3. is_authorized=true 的行显示“已授权”、复选框置灰且不可全选
4. 未授权套餐支持多选并一次提交
2. 首次授权读取现有套餐列表后续管理再读取现有系列授权详情前端按package_id合并不新增候选接口
3. 列按当前调用视角区分上级当前成本价、目标代理授权成本价、建议售价和授权状态;平台代理视角不得都误标为公司成本
4. is_authorized=true的行在新增模式置灰授权、调价、移除分别使用独立操作模式
5. 未授权套餐支持多选并一次提交。
接口约定:
- GET /api/admin/shop-series-grants/{id}/package-options 返回 items:{package_id,package_name,package_code,company_cost_price,authorized_cost_price,suggested_retail_price,is_authorized}
- PUT /api/admin/shop-series-grants/{id}/packagesbody={package_ids:int64[]}。
- 首次授权复用GET /api/admin/packages?series_id=...和POST /api/admin/shop-series-grantsPOST必须同时提交至少一个packages项
- 后续管理复用GET /api/admin/packages?series_id=...与GET /api/admin/shop-series-grants/{id}。
- PUT /api/admin/shop-series-grants/{id}/packagesbody={operation_type:authorize|update_cost|remove,packages:[{package_id,cost_price?}]}。
交互规则:三类价格按分转元;提交成功后重新加载候选列表;空候选和全部已授权状态有明确提示。
@@ -1026,25 +1031,25 @@
**标题**
```text
[BE][UR#43] 系列套餐候选Query与重复授权幂等
[BE][UR#43] 首次套餐授权与后续批量命令明确化
```
**描述**
```markdown
目标:复用现有批量授权接口,新增可供前端一次选择的套餐候选Query。
目标:首次授权继续同时创建系列与套餐;后续复用现有批量接口,并把新增、调价、移除拆成明确命令;不新增候选Query。
预计工时后端0.51小时。
接口GET /api/admin/shop-series-grants/{id}/package-options复用 PUT /api/admin/shop-series-grants/{id}/packages。
接口:复用GET /api/admin/packages、GET /api/admin/shop-series-grants/{id}、POST /api/admin/shop-series-grants和PUT /api/admin/shop-series-grants/{id}/packages。
返回package_id、名称、编码、company_cost_price、authorized_cost_price、suggested_retail_price、is_authorized
首次授权POST中的packages改为必填且至少1项系列与套餐在同一事务全成全败不允许空系列授权
规则:重复套餐按幂等处理;后端不依赖前端置灰;查询遵守代理、系列套餐可见范围;三个价格字段语义分开
后续规则:PUT必须提交operation_type=authorize|update_cost|remove一次只执行一种命令、最多100项、事务内全成全败authorize同价重复幂等、不同价重复冲突不能静默改价所有系列套餐、直属下级与价格边界由后端校验
架构候选列表走Query批量授权走轻量Application事务脚本
读取:前端组合现有套餐列表和授权详情;已授权但当前不在普通列表中的存量项仍从详情只读展示。成本字段按操作者视角准确命名
完成标准:首次和后续授权共用契约,重复提交不生成重复关系
完成标准:首次授权不会产生空系列;新增、调价和移除无语义混用;重复提交不生成重复关系或静默覆盖并发价格;前后端同批切换
```
## UR#42 导出功能
@@ -1054,33 +1059,47 @@
**标题**
```text
[FE][UR#42] 导出字段选择权限配置与任务进度
[FE][UR#42] 自动权限导出、角色字段配置与受保护附件访问
```
**描述**
```markdown
目标:用户按权限选择导出字段管理员可给角色配置各场景可导出字段。
目标:用户按当前查询条件直接导出角色已授权的全部字段管理员可给角色配置各场景可导出字段;导出文件中的退款/充值凭证和企微审批附件可通过后台登录态安全访问
预计工时:前端34小时
预计工时:按本冻结范围重新评估原34小时估算未包含受保护附件落地页不能继续沿用
页面入口:卡、钱包流水、套餐、退款、换货、充值、临期列表的导出弹框;角色权限配置页。
页面入口:卡、设备、订单、钱包流水、套餐、退款、换货、充值、临期列表的导出入口;角色权限配置页;后台附件落地页 `/export-attachments/:attachment_ref`
页面结构:
1. 导出弹框先加载当前scene可选字段使用复选框选择未授权字段不展示
2. 支持xlsx/csv格式及当前列表查询条件
3. 创建后展示任务状态、进度、失败原因和下载按钮。
4. 角色配置按scene分组展示字段复选框并保存
1. 列表导出只选择xlsx/csv格式并沿用当前查询条件不展示字段复选框实际列完全由后端权限解析
2. 页面可加载当前scene最终字段返回空数组时禁用导出并提示“当前角色未配置该场景的导出字段”
3. 创建后展示任务状态、进度、失败原因、取消和下载按钮页面刷新后按任务ID恢复轮询
4. 角色配置按scene分组展示代码支持字段全量保存该角色单个scene的字段允许清空保存后重新读取服务端结果
5. 新增受保护附件落地页,展示加载中、登录恢复、无权限/不存在、文件不可用、解析失败和成功跳转状态。
接口约定:
- GET /api/admin/export-fields?scene= 返回 fields:{field_key,label,selected_by_default}[]
- POST /api/admin/export-tasksbody={scene,format,query,fields:string[]},返回 task_id。
- GET /api/admin/export-fields?scene= 返回当前账号最终会导出的 fields:{key,label}[];它不是字段选择候选集
- POST /api/admin/export-tasksbody={scene,format,query},返回 task_id前端不得提交fields
- GET /api/admin/export-tasks/{id} 返回 status、status_name、progress、download_url、error_message。
- GET/PUT /api/admin/roles/{role_id}/export-fields 查询和保存场景字段。
- GET/PUT /api/admin/roles/{role_id}/export-fields 查询代码目录及保存角色场景字段PUT按单个scene全量替换空数组表示收回全部字段。
- GET /api/admin/attachments/{attachment_ref}/download-url 返回 data:{download_url,expires_at,file_name}请求必须携带后台当前Bearer Token。
交互规则:下载地址为空时禁用下载;任务轮询复用统一异步任务规则;字段为空时禁止提交。
导出流程:
1. 用户在列表页发起导出前端提交scene、format和当前筛选条件分页参数不进入导出条件。
2. 后端创建任务后前端进入现有任务进度流程download_url为空时禁用任务文件下载。
3. 导出文件中的附件地址是后台前端绝对地址 `{admin_frontend_base_url}/export-attachments/{attachment_ref}`,不是后端接口地址或对象存储地址。
完成标准:字段权限、任务恢复、失败重试和文件下载流程完整。
附件访问流程:
1. 浏览器打开附件落地页;未登录时保存当前站内路径并进入后台登录,登录成功后返回该附件页。
2. 页面从路由读取attachment_ref使用Bearer Token调用附件解析接口禁止把attachment_ref替换为file_key也禁止接收外部returnUrl。
3. 后端根据附件关联的退款、充值或审批业务重新检查当前账号权限,成功返回短期私有对象地址。
4. 前端在当前页跳转download_url避免异步后新开窗口被浏览器拦截地址过期时用户重新打开稳定落地页即可重新解析。
5. 401按后台统一登录失效流程处理并保留当前附件页无权与不存在展示统一不可访问状态对象文件不可用或解析失败展示可重试错误不展示file_key、media_id或底层存储错误。
交互规则:用户不能选择导出字段;角色权限后来变化不改变已经创建任务的文件列,但打开附件时始终按当前权限重新鉴权;任务失败时展示后端原因,不自动重复创建任务。
完成标准平台、不同层级代理分别验证最终列和数据行范围无字段状态、任务刷新恢复、CSV/XLSX下载、附件未登录返回、当前有权、权限后来收回、引用不存在和对象不可用流程完整。后端接口发布后前端只需按上述固定契约接入不再等待字段选择或永久对象URL方案。
```
### 后端研发需求
@@ -1088,23 +1107,29 @@
**标题**
```text
[BE][UR#42] 统一导出场景与角色字段权限
[BE][UR#42] 统一导出角色字段权限与受保护附件解析
```
**描述**
```markdown
目标:复用现有导出任务体系,增加本期场景和角色字段级权限。
目标:复用现有导出任务体系,增加本期场景和角色字段级权限,并为退款/充值业务凭证和企微审批附件提供稳定引用、当前权限复核及短期私有下载地址解析
预计工时:后端46小时
预计工时:按本冻结范围重新评估原46小时估算未包含全部新增场景、附件本地化与鉴权解析不能继续沿用
接口GET /api/admin/export-fieldsPOST /api/admin/export-tasksGET /api/admin/export-tasks/{id}GET/PUT /api/admin/roles/{role_id}/export-fields。
接口GET /api/admin/export-fieldsPOST /api/admin/export-tasksGET /api/admin/export-tasks/{id}GET/PUT /api/admin/roles/{role_id}/export-fieldsGET /api/admin/attachments/{attachment_ref}/download-url
规则:最终字段=用户申请字段∩角色授权字段并集∩场景支持字段;超级管理员拥有全部字段;字段授权不能扩大数据行范围;创建任务时快照字段表头;权限解析失败直接拒绝
规则:最终字段=当前账号有效角色授权字段并集∩场景代码支持字段;超级管理员拥有全部代码目录字段前端不传fields账号获授权字段全部导出字段授权不能扩大菜单、接口、场景或数据行范围;创建任务时快照字段表头、查询条件和数据范围Worker不重读实时角色权限权限解析失败或普通账号最终字段为空时不创建任务
场景:iot_card、agent_wallet_transaction、package、refund、exchange、agent_recharge、expiring_asset。审批附件只导出数量不导出对象Key或永久URL
场景:扩展device、iot_card、order并增加agent_wallet_transaction、package、refund、exchange、agent_recharge、expiring_asset。agent_recharge保留支付方式、不导出支付通道
完成标准Count与Fetch条件一致审批摘要批量查询导出任务可恢复且无越权字段
字段目录密码、密钥、Token、对象Key、企微media_id等永不允许导出的内容不注册对已注册字段角色配置是权威不再增加主观敏感字段层。发布不迁移普通角色默认授权停机窗口由业务使用超级管理员完成配置后再恢复服务
附件规则:退款/充值业务凭证与企微审批附件按字段权限导出稳定前端落地页URL但字段授权不能突破审批主体可见性代理可导出业务范围内的退款凭证不能导出或解析平台内部企微审批附件。每个本地附件使用不可变、不可枚举attachment_ref私有file_key不出后端。企微附件须先保存到本地私有对象存储不得把临时media_id或预签名URL写入导出文件。
解析流程前端携带Bearer Token请求attachment_ref后端解析其关联业务在每次访问时重新校验当前业务查看权限、数据范围和附件种类可见性成功返回短期download_url、expires_at、file_name。无权与不存在返回统一错误权限后来收回后旧导出链接也不可下载已知引用的代理也不能解析企微内部审批附件。
完成标准Count与Fetch条件一致审批摘要和附件引用批量查询无N+1字段/表头/权限快照可恢复且无越权列或越权行导出文件不含file_key、media_id或预签名URL前后端按未登录、有权、权限收回、引用不存在、对象不存在和附件未本地化完成联调。
```
## UR#40 套餐设计
@@ -1185,15 +1210,16 @@
2. 店铺资金页展示现金余额、冻结金额、实际信用额度、可用金额、欠款金额和版本。
3. 店铺实际额度使用独立调整弹框,展示修改前后金额预览。
4. 平台员工角色不展示信用配置。
5. 代理账号即使能查看自己或下级代理资金,也不展示实际额度修改入口;平台账号只有取得独立信用额度管理权限后才展示。
接口约定:
- PUT /api/admin/roles/{id}/default-creditbody={credit_enabled:bool,credit_limit:int64}。
- PUT /api/admin/shops/{id}/credit-limitbody={credit_enabled:bool,credit_limit:int64,version:int64}。
- GET /api/admin/shops/fund-summary 返回 balance、frozen_balance、credit_enabled、credit_limit、available_balance、is_in_debt、debt_amount、version。
交互规则:金额不在前端重新计算;并发冲突时刷新最新资金概况;关闭信用时额度输入归零。
交互规则:金额不在前端重新计算;并发冲突时刷新最新资金概况;关闭信用时额度输入归零;不设置产品层固定额度上限,只执行分/元精确转换和接口整数安全范围校验。欠款只按接口is_in_debt/debt_amount展示冻结金额不由前端换算为欠款
完成标准:角色默认、创建店铺初始化、已有店铺调额和并发冲突提示完整。
完成标准:角色默认、创建店铺初始化、已有店铺调额、代理禁止调额、平台有/无独立权限、降低额度失败和并发冲突提示完整。
```
### 后端研发需求
@@ -1217,9 +1243,11 @@
规则修改角色不更新已有店铺店铺后续角色变化不影响钱包余额、冻结、信用、版本和资金流水在同一事务维护调额按version乐观锁更新。
权限代理账号不得调整自己或任何下级代理额度不能复用CanManageShop或普通店铺管理权限推导调额权只有超级管理员或具备独立信用额度管理权限的平台账号可以修改实际额度。角色默认模板只允许超级管理员或具备相应角色管理权限的平台账号配置。信用额度不设产品层固定上限但必须在int64范围内并拒绝负数与算术溢出。
架构钱包资金规则迁入Wallet Domain查询走资金Query。
完成标准:扣款、冻结、解冻、充值、退款回充和调额都维护同一不变量并写资金审计。
完成标准:扣款、冻结、解冻、充值、退款回充和调额都维护同一不变量并写资金审计代理调额请求始终拒绝额度变更写Audit Event但不伪造金额为0的钱包流水
```
## UR#37 审核流转
@@ -1229,34 +1257,40 @@
**标题**
```text
[FE][UR#37] 企微配置账号绑定与审批只读详情
[FE][UR#37] 平台企微绑定、代理固定代提交与审批只读详情
```
**描述**
```markdown
目标:使用企业微信完成审批,本系统负责配置、账号扫码绑定、状态查看和异常恢复。
目标:平台/超级管理员绑定本人企微发起审批,代理使用固定企微账号代提交;本系统负责配置、状态查看和异常恢复,页面展示的申请人始终是本系统真实业务提交人
预计工时前端68.5小时,包含多人审批业务映射展示。
预计工时前端68.5小时,包含平台账号扫码绑定、代理固定代提交状态和多人审批业务映射展示。
页面入口:/system/wecom、个人中心、/operations/wecom-approvals、退款和线下充值详情。
页面入口:/system/wecom、平台用户个人中心、/operations/wecom-approvals、退款和线下充值详情。
页面结构:
1. 企微配置页:连接状态、审批场景状态、模板版本列表、模板读取和业务字段到控件的可视化映射发布。
2. 个人中心:绑定状态、成员名称、扫码绑定、重新绑定、解绑;不提供userid输入框
3. 审批运行页业务类型、业务单号、sp_no、状态、模板版本、申请人、更新时间和异常标识;详情抽屉展示审批人、意见、附件、时间线和业务处理结果。
4. 业务详情复用统一approval区块只读展示不提供通过、驳回、退回按钮。
1. 企微配置页:连接状态、代理固定代提交账号就绪状态与成员显示名、审批场景状态、模板版本列表、模板读取和业务字段到控件的可视化映射发布不提供固定userid在线修改入口
2. 平台用户个人中心:绑定状态、成员名称、扫码绑定、重新绑定、解绑;代理账号不展示绑定入口
3. 审批运行页业务类型、业务单号、sp_no、状态、模板版本、真实业务提交人、企微发起身份来源、更新时间和异常标识;平台详情抽屉展示审批人、意见、附件、时间线和业务处理结果。
4. 业务详情复用统一approval区块只读展示不提供通过、驳回、退回按钮;代理视图只展示真实业务提交人、审批状态、状态时间和业务处理结果,不展示平台内部审批人、意见或审批附件
接口约定:
- 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返回 session_id、login_url、expires_atGET /api/admin/wecom/account-binding/sessions/{session_id} 查询结果GET/DELETE /api/admin/wecom/account-binding/me。
- POST /api/admin/wecom/account-binding/sessions返回session_id、login_url、expires_atGET /api/admin/wecom/account-binding/sessions/{session_id}查询结果GET/DELETE /api/admin/wecom/account-binding/me。
- GET /api/admin/wecom/approvals、GET /api/admin/wecom/approvals/{id}、POST /api/admin/wecom/approvals/{id}/sync。
- POST /api/admin/wecom/approvals/{id}/bind-sp-no提交sp_no和必填恢复原因由后端完成全量业务快照核对后绑定。
- POST /api/admin/wecom/approvals/{id}/confirm-not-created-and-resend提交必填恢复原因仅用于人工确认企微未创建后重新发送同一审批申请。
交互规则:扫码绑定页面轮询会话;未绑定创建审批时原地提供绑定入口;立即同步只拉取企微详情;通过后撤销且业务已执行时显示高风险提示。
交互规则:平台/超级管理员未绑定时原地提供绑定入口,绑定成功后继续当前表单;代理不要求绑定,固定代提交账号不可用时按后端错误保留表单。审批列表和详情的“申请人”展示真实业务提交人,并可只读标识企微发起身份来源;立即同步只拉取企微详情;通过后撤销且业务已执行时显示高风险提示。
完成标准:配置、绑定列表、详情、同步和异常状态均有加载、空、失败及权限状态
权限约定:/system/wecom、场景/模板维护、账号绑定列表和强制解绑仅超级管理员可见;平台账号只能管理本人绑定,具备独立“企微审批运营”权限后才显示审批运行页和立即同步;“企微审批运营”不包含模板配置、绑定管理或异常恢复。代理不展示企微配置、绑定和运行页,只在其业务数据范围内的退款详情查看审批区块
信息可见性:代理自己提交的退款资料和业务凭证仍按退款查看权限展示,但企微审批人、内部意见及审批人上传的附件仅平台/超级管理员按退款查看权限访问。详情接口、受保护附件下载和导出执行同一主体权限投影不能通过attachment_ref或历史导出链接绕过当前权限。
完成标准:配置、平台绑定、代理固定身份状态、列表、详情、同步和异常状态均有加载、空、失败及权限状态。
```
### 后端研发需求
@@ -1264,7 +1298,7 @@
**标题**
```text
[BE][UR#37] 企业微信审批模板账号绑定回调与补偿
[BE][UR#37] 企业微信审批模板、平台绑定、代理代提交、回调与补偿
```
**描述**
@@ -1272,16 +1306,20 @@
```markdown
目标:以稳定业务场景码接入企微审批,替代本地审批流。
预计工时后端911.5小时,包含多人审批场景映射。
预计工时后端911.5小时,包含平台账号扫码绑定、代理固定代提交和多人审批场景映射。
接口:实现企微状态、场景暂停恢复、模板读取发布、扫码绑定绑定管理、审批列表详情、立即同步及企微回调接口。
接口:实现企微状态、场景暂停恢复、模板读取发布、平台账号扫码绑定绑定管理、审批列表详情、立即同步及企微回调接口。
规则:
1. 场景码固定为 refund_approval、offline_recharge_approval模板ID和控件ID按不可变版本映射。
2. 模板编辑前暂停场景,发布新映射后恢复;历史实例保留模板和提交快照。
3. 系统账号通过5分钟一次性会话扫码绑定企微useridaccount_id和userid均唯一
3. applyevent发起身份按账号类型分流平台/超级管理员必须使用当前账号扫码绑定的本人userid代理使用部署配置中的固定企微userid。真实业务提交人以本地账号和显示快照独立保存并写入模板必填submitter字段审批实例保存实际userid与self_binding/agent_proxy来源列表、详情、通知、权限和审计均以真实业务提交人为准
4. 回调只验签解密并触发统一SyncApprovalStatusgetapprovaldetail为状态权威来源审批中实例每2分钟兜底轮询。
5. 首次终态同事务写业务Outboxbusiness_processed_at保证只执行一次。
6. applyevent已发出但结果未知时禁止自动重试超级管理员或具备独立异常恢复权限的平台账号可选择“校验并绑定已有sp_no”或“确认未创建后重新发送”。重新发送属于同一审批申请的技术尝试不创建业务审批轮次绑定前必须核对企业、模板、实例中的实际企微userid及来源、业务场景、真实业务提交人字段和业务快照两种动作均保留原尝试并写高风险审计。
7. 场景暂停或模板失效时,新退款/线下充值在任何业务单、审批实例和Outbox落库前直接拒绝前端保留表单恢复后由用户重新提交。暂停前已经创建的审批继续回调、轮询同步和处理终态不受暂停影响。
8. 权限必须拆分:场景暂停恢复、模板读取发布、绑定列表和强制解绑仅超级管理员;平台账号只能管理本人绑定;审批运行列表/详情/立即同步要求独立“企微审批运营”权限;提交未知恢复要求独立“企微审批异常恢复”权限;代理无公共企微管理接口权限,只能按现有业务数据范围查看退款详情中的审批摘要。
9. 审批详情按主体投影:代理只能读取真实业务提交人、审批状态/时间、业务处理结果及其业务范围内的退款资料和业务凭证;审批人、内部意见和审批人上传附件仅平台/超级管理员按业务查看权限读取。附件解析和导出必须复用当前主体权限,禁止旁路访问。
架构使用wecomapproval、wecomidentity Domain/Application外部API和加解密进入WeCom Adapter列表详情走Query。
@@ -1301,24 +1339,29 @@
**描述**
```markdown
目标:运营按一个代理和一种支付方式批量导入套餐订单,并查看逐行结果。
目标:运营按一种支付方式批量导入套餐订单同一CSV可包含不同代理的资产系统逐行解析结算代理并展示结果。
预计工时前端34小时。
页面入口:批量订购套餐页或现有订单页批量入口。
入口:页面是否展示沿用前端现有可见性规则;后端不新增批量订购权限码或账号类型拦截,调用复用现有后台认证。
页面结构:
1. 选择代理、整批支付方式(线下或代理钱包)上传Excel线下支付时上传整批凭证。
2. 模板下载使用前端静态文件。
1. 选择一个套餐和整批支付方式(线下或代理钱包)上传CSV不选择代理线下支付时上传整批凭证不接受Excel
2. CSV模板下载使用前端静态文件编码为UTF-8并允许BOM唯一表头为“资产标识”
3. 创建后展示任务号、状态、总数、成功数、失败数、金额汇总和失败明细表。
4. 失败明细包含行号、资产、套餐编码、错误原因支持按任务ID恢复页面。
4. 失败明细包含行号、原始/规范资产标识和错误原因支持按任务ID恢复页面。资产类型由统一资产解析能力识别当前支持ICCID、卡virtual_no、MSISDN、设备virtual_no、IMEI和SN前端不要求用户填写资产类型。
接口约定:
- POST /api/admin/bulk-purchasesmultipart包含 shop_id、payment_method、file、voucher_file、request_id
- CSV先调用POST /api/admin/storage/upload-urlpurpose=bulk_purchase使用返回的upload_url直传后取得file_key线下凭证按附件用途直传取得voucher_keys
- POST /api/admin/bulk-purchases只接收JSONrequest_id、单个package_id、payment_method、file_key、voucher_keys不接收shop_id、multipart或文件字节。
- GET /api/admin/bulk-purchases/{task_id} 返回任务汇总。
- GET /api/admin/bulk-purchases/{task_id}/items?page=&size=&status= 返回逐行结果。
- GET /api/admin/bulk-purchases/{task_id}/items?page=&page_size=&status= 返回逐行结果。
交互规则:一个批次不能混合支付方式;部分成功视为任务终态;钱包余额不足只影响对应行或后续行,不回滚已成功行。
交互规则:一个批次固定一个套餐且不能混合支付方式;同一CSV允许不同代理资产代理归属由后端逐行解析前端不提交或猜测任务状态统一为1待处理、2处理中、3已完成、4已失败、5已取消部分成功只由成功数/失败数表达wallet批次严格按CSV行号逐行结算行序就是同一代理余额不足时的订购优先级当前行余额不足只失败该行并继续尝试后续行不预占或回滚该代理全部行。
文件级错误异步展示创建接口通过后不代表CSV内容有效Worker发现编码、表头、未知列、CSV语法、空文件或超过1000行时任务整体失败且不会创建任何订单。资产、套餐、归属、钱包和重复行错误才展示为逐行失败。MSISDN未命中或命中多张卡时只失败该行前端展示后端原因。
完成标准:上传、进度恢复、部分成功、失败筛选和凭证展示完整。
```
@@ -1340,11 +1383,13 @@
接口POST /api/admin/bulk-purchasesGET /api/admin/bulk-purchases/{task_id}GET /api/admin/bulk-purchases/{task_id}/items。
规则整批选择offline或agent_wallet模板按套餐编码匹配文件最大10MB、最多1000行request_id唯一返回原任务行幂等键为bulk_purchase:{task_id}:{row_no}
入口与规则后端不新增批量订购权限码、账号类型拦截或任务创建人隔离复用现有后台认证创建任务选择单个package_id和整批offline|walletwallet表示逐行扣结算代理主钱包不新增agent_wallet不接收shop_id同一CSV可以包含不同代理资产逐行以资产当前归属解析结算代理CSV只有“资产标识”一列资产类型和ID由系统统一资产解析能力识别当前支持ICCID、卡virtual_no、MSISDN、设备virtual_no、IMEI和SN批量模块不另写识别规则CSV和凭证先直传私有对象存储业务接口只接收稳定file_key/voucher_keys创建接口校验套餐存在以及对象、上传归属、类型和10MB大小Worker校验UTF-8允许BOM、单列表头、未知列、CSV语法、空文件和1000行上限文件级错误使任务失败且不创建订单不接受Excel标识未命中或无法唯一解析只失败该行同一文件按解析后的“资产类型+资产ID”判重同一资产使用不同标识仍只处理首行后续失败并指出首行号request_id唯一返回原任务行幂等键为bulk_purchase:{task_id}:{row_no}。任务统一状态为1待处理、2处理中、3已完成、4已失败、5已取消部分成功只由success_count/fail_count表达逐行明细状态为1待处理、2处理中、3成功、4失败
钱包行事务:主钱包,按信用不变量校验,订单、扣款、流水和明细成功状态同事务;单行失败不回滚其他行;任务统计从明细重新聚合。线下凭证只做本批资料,不校验跨批唯一。
钱包行事务:严格按CSV行号逐行处理并锁该行资产所属代理的主钱包,按信用不变量校验,订单、扣款、流水、结算代理快照和明细成功状态同事务;不预占整批或某一代理全部行金额,无代理归属、无有效主钱包或余额不足只失败当前行并继续后续行,后续较小金额若余额足够仍可成功;任务统计从明细重新聚合。线下凭证只做本批资料,不校验跨批唯一。
完成标准Worker处理租约、重复消费、进程中断恢复和部分成功均不产生重复订单或重复扣款。
测试环境约定已部署测试环境使用Redis DB 6本地开发和Agent自动化测试强制使用DB 7测试启动时必须校验实际生效DB并在不是7时失败禁止向DB 6投递任务也禁止对DB 7执行FLUSHDB。自动化测试直连当前真实S3并按唯一Key精确清理不做内存替身PostgreSQL沿用现有测试库夹具按唯一运行标识和实际记录ID精确清理禁止TRUNCATE、清表、模糊删除或修改既有业务数据。Go HTTP集成测试使用Fiber app.Test穿过真实认证和业务链路捕获任务后直接调用公开Worker Handler不通过sleep等待后台Worker实现时沉淀可复用的测试规范、环境守卫/清理Harness、CSV testdata和完整示例并提供只用于部署冒烟的curl模板。
```
## UR#35 退款审核
@@ -1367,22 +1412,21 @@
页面入口:退款创建、退款列表、退款详情。
页面结构:
1. 创建表单包含退款金额、原因、备注和附件;金额提交后企微审批不可修改
1. 创建表单包含订单、退款金额、必填原因、可选备注和15个附件附件提交file_key、file_name、file_size金额提交后企微审批不可修改前端不提交实收金额或套餐使用记录
2. 详情分为退款业务信息、企微审批信息、业务处理结果三个区域。
3. 审批区展示sp_no、状态、申请人、审批人、意见、附件和时间线。
3. 审批区按主体权限展示:代理只见真实申请人、审批状态和业务处理结果;平台/超级管理员具备退款查看权限时才展示sp_no、审批人、意见、审批附件和时间线。
4. 处理区展示processing_status、失败摘要和“系统重试中/联系管理员”。
5. 不显示本地通过、驳回、退回或人工退款确认按钮。
接口约定:
- POST /api/admin/refunds 创建退款。
- POST /api/admin/refunds 创建退款请求只包含order_id、requested_refund_amount、refund_reason、remark和attachments
- GET /api/admin/refunds/{id} 返回退款数据、approval对象和processing_status。
- POST /api/admin/refunds/{id}/resubmit仅已驳回、已撤销或已删除可重新申请。
- attachments使用现有对象存储上传结果提交结构为 {file_key,file_name,file_size}[]。
- approval结构至少包含 source、sp_no、status、status_name、template_version、applicant、approvers、comments、attachments、timeline、business_process_result。
交互规则:非代理钱包由财务在系统外人工退款后再在企微通过;重新申请可改金额、凭证和原因,不可改订单、资产和提交人快照
交互规则:非代理钱包由财务在系统外人工退款后再在企微通过。企微拒绝后当前退款单终结详情只读且不提供编辑或重提业务人员处理拒绝原因后仍需退款时重新进入创建退款流程并填写金额、凭证和原因成功后展示新的退款ID、退款单号和审批信息
完成标准:审批中、通过处理中、处理成功、驳回、撤销和通过后撤销异常状态均展示明确。
完成标准:审批中、通过处理中、处理成功、处理失败、驳回、撤销和通过后撤销异常状态均展示明确;申请金额小于实收金额时明确提示通过后仍按整单终结
```
### 后端研发需求
@@ -1390,26 +1434,30 @@
**标题**
```text
[BE][UR#35] 退款企微终态与人工退款处理
[BE][UR#35] 退款企微终态与整单退款终结
```
**描述**
```markdown
目标:退款通过企微审批驱动业务终态,系统只自动回溯代理主钱包支付。
目标:退款通过企微审批驱动整单业务终态,系统只自动回溯代理主钱包支付。
预计工时后端45小时。
接口POST /api/admin/refundsGET /api/admin/refunds/{id}POST /api/admin/refunds/{id}/resubmit下线原approve/reject/return路由。
接口POST /api/admin/refundsGET /api/admin/refundsGET /api/admin/refunds/{id}下线原approve/reject/return/resubmit及任何manual-complete路由。
规则:
1. 非代理钱包支付由财务人工退款企微通过代表人工退款已确认不调用渠道退款API
2. 代理钱包订单通过后按原扣款流水幂等回溯原代理主钱包并写退款流水
3. 个人客户或资产钱包不自动回款
4. 驳回更新为已拒绝;撤销/删除更新为已撤销通过后撤销且资金已执行不自动冲正记录critical审计
5. 重新申请创建round_no+1企微实例并保留历史
1. 创建请求只接受order_id、requested_refund_amount、必填refund_reason、可选remark和15个结构化attachments后端读取订单实收金额并校验0<申请金额<=实收金额不接受actual_received_amount或package_usage_id
2. 申请金额在企微只读,审批人只能同意或拒绝。无论申请金额是否等于实收金额,通过后都把订单标记已退款、该订单全部有效套餐失效、全部佣金失效,并禁止再退剩余差额
3. 非代理主钱包支付由财务系统外退款企微通过代表人工退款已确认不调用渠道退款API、不回充个人资产钱包也不再二次人工确认actual_refund_amount在资金已完成时写申请金额
4. 代理钱包订单通过后只按原扣款流水幂等回溯原代理主钱包并写唯一退款流水;缺少原流水则失败,不按当前关系猜测钱包
5. 已发放佣金全额从佣金钱包扣回并允许负余额,其他未发放佣金直接失效;佣金记录保存结构化失效原因、退款引用和失效时间。订单套餐按整单失效,主套餐级联加油包并尝试下一排队主套餐
6. 驳回更新为已拒绝;撤销/删除更新为已撤销通过后撤销且资金已执行不自动冲正记录critical审计和站内告警。
7. 一张退款单只创建一条企微审批申请业务表保存唯一approval_instance_id并由(biz_type,biz_id)唯一约束防重。正常结果只有同意或拒绝任一结果产生后审批与退款单同时完结拒绝后原退款单不可修改、不可重提。若仍需退款必须重新调用创建接口生成新的退款ID、退款单号、业务快照、提交人快照和企微审批旧单只保留为历史事实已拒绝退款不阻止新建但仍需阻止存在其他活跃退款时重复创建。撤销、删除或通过后撤销只作外部异常处置不作为自动放行新退款的依据。
8. 代理按既有店铺层级和退款业务权限查看本店及可管理下级退款不再按creator隔离代理不见审批人、内部意见或审批人附件平台/超级管理员也须有退款业务查看权限。
9. 企微终态通过Outbox和可靠Worker处理processing_status固定0未触发、1处理中、2处理成功、3处理失败局部失败可安全重试不使用进程内Goroutine。
完成标准:审批状态与业务处理状态分离,重复终态不重复回款,失败任务可可靠重试。
完成标准:审批状态与业务处理状态分离,重复终态不重复回款/扣佣/失效套餐,失败任务可可靠重试实现期必须通过可编程Adapter自动化和真实企微两张独立退款验收代理代提交同意、平台本人提交拒绝INT-06不能替代
```
## UR#34 充值审核流程
@@ -1432,22 +1480,22 @@
页面入口:代理资金充值页、后台线下代充值创建页、充值列表和详情。
页面结构:
1. 在线充值金额输入最低100元、支付方式选择、二维码、过期倒计时和支付状态;不显示审批区域
1. 在线充值金额输入最低100元、支付方式选择、二维码、支付状态和钱包入账状态;不显示审批区域,也不展示本地推算的精确过期倒计时。
2. 线下代充值:选择店铺、金额、备注、附件;提交后显示企微审批和入账处理状态。
3. 充值详情按approval_source显示none隐藏审批区wecom显示只读企微详情。
4. 不显示本地确认入账、驳回按钮和操作密码输入框。
接口约定:
- GET /api/admin/agent-recharges/payment-methods 返回 methods:string[]、min_amount:int64。
- GET /api/admin/agent-recharges/payment-methods 返回真正可用的 methods:string[]、min_amount:int64、max_amount:int64不暴露支付通道或配置
- POST /api/admin/agent-recharges在线入参 amount、payment_method、request_id线下入参 shop_id、amount、payment_method=offline、remark、attachments、request_id。
- 在线返回 recharge_id、recharge_no、qr_content、expires_at、payment_status、approval_source=none。
- GET /api/admin/agent-recharges/{id}/payment-status 返回 payment_status、payment_status_name、wallet_posting_status。
- 在线返回 recharge_id、recharge_no、qr_content、payment_status、processing_status、approval_source=none,不返回 expires_at
- GET /api/admin/agent-recharges/{id}/payment-status 只读取本地状态,返回 payment_status、payment_status_name、processing_status、processing_status_name
- GET /api/admin/agent-recharges/{id} 返回充值详情、approval和processing_status。
- 线下attachments使用现有对象存储上传结果结构为 {file_key,file_name,file_size}[]。
交互规则在线状态每3秒轮询页面不可见暂停二维码过期后重新创建新支付单;线下提交失败保留表单。
交互规则在线状态每3秒轮询本地状态,页面不可见暂停;每次用户主动创建支付都使用新的request_id创建全新充值单和支付单旧单等待回调或后端查单自然收敛;线下提交失败保留表单。
完成标准:微信、支付宝、二维码过期、重复回调后的最终状态、线下审批通过入账和驳回状态均可展示。
完成标准:微信、支付宝、第三方关闭/迟到成功、重复回调后的最终状态、支付成功但入账失败补偿、线下审批通过入账和驳回状态均可展示;在线和线下实际到账后均发送代理站内到账通知
```
### 后端研发需求
@@ -1467,11 +1515,11 @@
接口GET /api/admin/agent-recharges/payment-methodsPOST /api/admin/agent-rechargesGET /api/admin/agent-recharges/{id}/payment-statusGET /api/admin/agent-recharges/{id}下线offline-pay和reject旧接口。
在线规则最低10000分支持微信Native和支付宝预下单统一返回qr_content支付回调校验渠道、金额、配置、交易号和业务单支付单、充值单、主钱包、版本、唯一流水和审计同事务推进重复回调不重复入账。
在线规则:代理只充当前店铺且不提交shop_id最低10000分支持微信Native和支付宝PreCreate每次主动创建都是全新充值单和支付单统一返回qr_content且不返回expires_at。支付回调和受控查单进入同一幂等确认用例并校验金额、配置、交易号和业务单先固化支付成功与入账Outbox再由可靠Worker独立事务更新主钱包、版本、唯一流水、充值完成状态和审计。重复回调或任务不重复入账,迟到成功不能吞掉已付资金
线下规则:创建后提交企微;通过后自动增加代理主钱包并写流水无操作密码recharge:{recharge_no}防重;驳回为已驳回,撤销/删除为已关闭;通过后撤销且已入账不自动扣回。
线下规则:仅平台/超管创建金额大于0目标店铺和15个结构化付款凭证必填金额提交后固定创建后提交企微审批只能同意或拒绝。通过后自动增加代理主钱包并写流水无操作密码recharge:{recharge_no}防重;驳回终结原单且不支持退回/重提,撤销/删除为已关闭;通过后撤销且已入账不自动扣回。
完成标准:两条路径状态隔离,支付成功但入账失败可补偿,企微终态重复同步不重复加钱。
完成标准:两条路径和权限严格隔离,支付/审批/钱包处理状态独立,支付成功但入账失败可补偿,支付查单与企微终态重复同步不重复加钱钱包到账后向目标代理发送防重站内通知。本地支付网络使用可控Adapter真实微信/支付宝在测试环境手工验收,真实企微线下充值为实现门禁
```
## UR#33 套餐临期提醒
@@ -1556,7 +1604,7 @@
交付内容:
1. 统一加载、空数据、权限不足、接口失败和重试状态。
2. 统一异步任务进度结构:任务状态、总数、成功数、失败数、部分成功和失败明细
2. 统一异步任务进度结构:状态固定为1待处理、2处理中、3已完成、4已失败、5已取消总数、成功数、失败数和失败明细独立返回部分成功只由计数表达不占状态码
3. 创建任务后按2秒、3秒、5秒递增轮询最大间隔10秒页面不可见暂停恢复后立即刷新。
4. 页面刷新后通过task_id恢复任务详情。
@@ -1778,12 +1826,12 @@
完成标准后台、C端、临期列表、通知和导出使用同一套餐生命周期结果。
```
### INT-04 Excel批量任务与导出联调
### INT-04 CSV批量任务与导出联调
**标题**
```text
[INT-04] Excel批量任务与导出联调
[INT-04] CSV批量任务与导出联调
```
**描述**
@@ -1837,9 +1885,9 @@
参与工时参考前端1.52小时、后端1.52小时从关联FE/BE研发需求工时中拆出不新增总工时。
进入条件:模板映射、扫码绑定、审批提交、回调、2分钟轮询、退款和线下充值终态处理完成。
进入条件:模板映射、平台账号绑定、代理固定代提交账号验证、审批提交、回调、2分钟轮询、退款和线下充值终态处理完成。
联调范围:账号扫码绑定未绑定拦截、模板发布、发起审批、意见附件、通过/驳回/撤销/删除、回调重复、立即同步、退款人工处理、代理钱包回溯、线下充值入账和列表审批摘要。
联调范围:平台账号扫码绑定未绑定拦截、代理固定代提交账号有效/失效拦截、两类企微发起身份、真实业务提交人展示、模板发布、发起审批、意见附件、通过/驳回/撤销/删除、回调重复、立即同步、退款人工处理、代理钱包回溯、线下充值入账和列表审批摘要。
完成标准:本系统无审批按钮,企微状态与业务处理状态分离,终态重复同步不重复执行资金动作。
```
@@ -1861,7 +1909,7 @@
进入条件统一限速接口、卡和设备详情入口、Gateway联调配置完成。
联调范围:单卡设置、设备解析当前卡、speed_kbps单位、0取消限速、设备无当前卡、Gateway失败及审计记录。
联调范围:单卡固定speed_level、设备解析当前卡、恢复不限速与限到0kbps区分、设备无当前卡、电信/广电档位映射、联通/移动不支持、Gateway失败/结果未知及审计记录。
完成标准Gateway收到的cardNo始终为卡ICCID设备号不会被发送上游结果在页面和审计中可追踪。
```

View File

@@ -8,6 +8,7 @@
- [7月迭代前后端任务工时表](./7月迭代前后端任务工时表.md)
- [7月迭代禅道研发需求拆分表](./7月迭代禅道研发需求拆分表.md)
- [7月迭代禅道研发需求逐条录入稿](./7月迭代禅道研发需求逐条录入稿.md)
- [七月迭代 AI 实施与验收操作手册](./七月迭代-AI实施与验收操作手册.md)
评审、开发和验收均以标准评审稿为准。独立稿用于解释方案来源;与标准稿冲突时,标准稿优先。

View File

@@ -0,0 +1,408 @@
# 七月迭代 AI 实施与验收操作手册
> 适用范围:`.scratch/<feature-slug>/PRD.md` 及其 `issues/` 的拆票、实现、评审和验收。
> 权威约束:`AGENTS.md`、父 PRD、Issue 和 `docs/7月迭代/独立方案/基础规范/DDD规范.md`。
> 核心原则:按依赖 frontier 工作;小需求可整目录执行,高风险需求优先逐票执行;测试强度由 PRD 风险和已确认接缝决定。
## 一、完整流程总览
```text
已评审 PRD
/to-tickets草拟纵向切片和阻塞边
↓ 用户确认拆分
发布 .scratch/<feature-slug>/issues/*.md
记录实现基线 commit
/implement逐票或整目录实现
├─ 在已确认接缝上按 TDD 做 red → green
├─ 开发中持续运行局部测试和编译检查
├─ 本组工作结束时运行一次全量测试
├─ 对基线至 HEAD 的累计 diff 执行 /code-review
└─ 中文 commit
独立验收PRD、Issues、代码、测试证据和外部验收矩阵
跨 PRD 联调与 INT-0108 发布验收
```
## 二、每个需求开始前的固定检查
假设当前需求目录为:
```text
.scratch/<feature-slug>/
├── PRD.md
└── issues/
```
开始前检查:
1. `PRD.md` 状态为 `ready-for-agent`
2. PRD 已完成评审,没有尚未决定的业务问题。
3. 工作区没有来源不明的未提交修改。
4. 当前需求的跨 PRD 前置能力已经发布为具体 Issue不能只写“依赖公共基础”。
5. 记录拆票前或实现前的 commit供后续双轴评审使用
```bash
git status --short --branch
git rev-parse HEAD
```
将第二条命令输出记作 `BASE_COMMIT`。不要依赖 `HEAD~N` 猜测基线。
## 三、生成 Issues
### 3.1 通用命令
在一个新会话中执行:
```text
/to-tickets .scratch/<feature-slug>/PRD.md
```
`AGENTS.md` 已要求规划会话自动读取 DDD 规范,因此无需每次重复粘贴 DDD 提示词。
### 3.2 拆票确认
`/to-tickets` 应先展示草稿,而不是直接写文件。确认每张票都具备:
- 一个可独立演示或验证的纵向行为;
- 明确的 `Blocked by`
- 可观察的验收标准;
- 适用的 DDD 架构通道;
- 当前票收口的完整业务边界;
- 明确不迁移的旧代码范围。
出现以下拆法时应要求重拆:
- “先建表 → 再写 Service → 再写 Handler”的水平分层
- 一张票包含多个相互独立的业务流程;
- 阻塞项只写“公共基础设施”而没有具体 Issue
- 为简单 Query 或单表写操作强行创建聚合、工厂或多层接口;
- 在拆票阶段改变已评审 PRD 的业务或架构决定。
确认草稿后回复:
```text
拆分粒度和阻塞关系确认,可以发布 Issues。
```
### 3.3 拆票完成检查
发布后检查:
```bash
find .scratch/<feature-slug>/issues -maxdepth 1 -type f -name '*.md' -print | sort
```
逐票确认 `Status: ready-for-agent`、验收条件和阻塞边存在。父 PRD 不应被关闭或改写。
## 四、七月迭代建议拆票顺序
拆票顺序用于让后续 PRD 能引用已经存在的具体上游 Issue。它不代表所有票必须拆完后才能开始实现没有阻塞的 frontier 可以先做。
### 第一批:独立小需求
```text
UR#60 店铺联系电话精确查询
UR#45 换货资产标识与独立搜索
UR#86 资产详情换货链
UR#55 套餐生效条件与购买快照
UR#46 预计最终到期时间(依赖 UR#55
```
### 第二批:公共基础
```text
TECH 七月迭代公共开发基础
TECH 全局多视角审计与外部集成追踪
TECH 公共站内通知与受控跳转
```
### 第三批:共享业务核心
```text
UR#94 卡状态公共写入、事件触发与运营商回调
UR#37 企业微信审批公共能力
UR#38 代理主钱包信用额度与统一钱包边界
```
### 第四批:依赖公共基础的中小需求
```text
UR#40 下架套餐历史用户续费
UR#43 代理系列套餐批量授权
UR#47 卡片手动限速
UR#48 按资产类型限制 C 端支付方式
UR#49 设备 CSV 批量分配
UR#96 店铺业务员归属
UR#98 换货新资产继承店铺
```
### 第五批:卡状态、钱包与临期链
```text
UR#73 按运营商实名能力控制复机
UR#53 卡和设备实名状态筛选
UR#62 H5 购买与实名顺序配置
UR#97 代理主钱包低余额预警
UR#33 套餐临期查询与提醒
```
主要依赖链:
```text
UR#94 → UR#73、UR#53、UR#62
UR#38 + UR#96 + 公共通知 → UR#97
UR#55 → UR#46
UR#40 + UR#46 + UR#96 + 公共通知 → UR#33
```
### 第六批:复杂核心业务
```text
UR#35 退款企微审批与整单终结
UR#57 活跃退款资产禁止换货
UR#34 代理扫码充值与平台线下代充值
UR#36 批量订购套餐
UR#44 提交人和审批摘要
UR#42 统一导出与字段权限
```
主要依赖链:
```text
UR#37 + UR#38 + 审计 + 通知 → UR#35
UR#35 → UR#57
UR#37 + UR#38 + 审计 + 通知 → UR#34
UR#38 + UR#40 + 公共异步任务 → UR#36
UR#37 + UR#34 + UR#35 → UR#44
UR#33 + UR#46 + UR#44 + UR#37 → UR#42
```
最终以发布后的具体 Issue 阻塞边为准;上述关系只是跨 PRD 排序基线。
## 五、让 AI 实现 Issues
### 5.1 标准模式:一张票一个新会话
这是 `/to-tickets` 推荐的默认方式,适合复杂写、资金、状态机、外部系统、宽范围迁移和单票改动较大的需求:
```text
/implement .scratch/<feature-slug>/issues/<NN>-<ticket-slug>.md
```
优点是上下文干净、范围稳定、失败容易定位。完成后选择下一个所有 blocker 均已完成的 ticket。
### 5.2 省事模式:整目录执行
对于票数少、改动集中、依赖链清晰的小需求,可以执行:
```text
/implement .scratch/<feature-slug>/issues/
```
`/implement` 支持一组 tickets但整目录模式不改变依赖规则。AI 必须先读取全部票,只执行 blocker 已完成的 frontier并按依赖顺序推进。
可附加以下通用说明;它不包含任何特定需求内容:
```text
完整实现该目录下所有当前可执行的 tickets。先读取父 PRD、全部 tickets、AGENTS.md 和 CONTEXT.md建立依赖图只处理 Blocked by 已完成的 frontier不跳过、不合并、不改变已评审范围。
使用父 PRD 或 ticket 中已经确认的测试接缝;如果没有确认测试接缝,在写测试前先询问。开发中持续运行最相关的局部测试和编译检查,整组工作完成后运行一次全量测试,再对实现前固定基线到 HEAD 的累计 diff 执行 code-review最后按项目规范提交。
如果上下文不足、外部 blocker 未完成、必须改变 PRD/架构或无法安全继续,请停在 ticket 边界,报告已完成项和下一张可执行 ticket不要猜测或跨范围实现。
```
### 5.3 何时不要整目录执行
出现任一情况时,改用逐票模式:
- 涉及金额、余额、退款、充值或佣金;
- 涉及复杂状态机、并发不变量或可靠事件;
- 涉及企微、支付、Gateway、运营商等外部副作用
- 涉及全局审计切换或 expandmigratecontract
- issues 较多,无法合理放进一个清晰上下文;
- 多张票会同时大范围修改同一模块;
- AI 已出现遗忘前置条件、重复实现或范围漂移。
若整目录会话因上下文或阻塞停止,不需要从头再跑。新会话指定剩余 ticket 或再次指定目录,并要求先识别已经完成的票。
## 六、正确的测试节奏
测试要求以 `/tdd`、父 PRD 的 `Testing Decisions`、ticket 验收条件和项目现有测试方式为准。
### 6.1 开始实现前
- 使用 PRD/ticket 已经确认的公共测试接缝。
- 如果没有确认接缝AI 必须在写测试前询问一次。
- 测试面向公共行为,不测试私有函数或内部调用次数。
- 不为了满足 TDD 给简单字段映射制造无价值的内部单元测试。
### 6.2 实现过程中
按纵向切片执行:
```text
一个失败测试 → 最小实现 → 测试通过 → 下一个行为
```
持续运行最相关的包测试或指定测试,例如:
```bash
go test ./internal/<package>/...
go test ./internal/<package>/... -run TestName
```
Go 的相关包测试同时承担本范围的编译检查。不要每修改一个文件就运行 `go test ./...`,也不要先写完全部测试再集中实现。
### 6.3 本组实现结束时
在提交前运行一次全量测试:
```bash
go test ./...
```
如果全量测试失败,必须区分:
- 本次修改引入的失败:修复后才能完成;
- 可复现的存量失败:记录命令、失败测试和与本次 diff 无关的证据,不得谎报全绿;
- 缺少数据库、Redis 或外部配置:标记为环境阻塞,并执行仍可运行的测试。
只有项目或 ticket 明确要求时才额外运行迁移、真实接口、生成文档或专项静态检查。不要凭空增加仓库没有约定的通用门禁。
## 七、Code Review 与提交
### 7.1 固定评审起点
使用实现前记录的 `BASE_COMMIT`
```text
/code-review <BASE_COMMIT>
```
如果自动识别不到规格来源,同时提供:
```text
规格来源是 .scratch/<feature-slug>/PRD.md 和 .scratch/<feature-slug>/issues/。
```
`/code-review` 分开检查:
- Standards是否符合 `AGENTS.md`、DDD 规范和代码标准;
- Spec是否完整实现父 PRD 和 tickets是否遗漏或越界。
阻断性问题修复后,应重新运行受影响的局部测试;若修复可能影响全局,重新运行全量测试。修复后的 diff 需要再次复核相关发现。
### 7.2 提交粒度
`/implement` 的硬要求是完成工作后提交当前分支,并不强制每张 ticket 都单独提交。选择原则:
- 逐票模式:通常一票一个中文 commit便于回溯
- 整目录模式:可以按独立可回滚切片提交,也可以在整个小需求完成后统一提交;
- 不要为了形式制造无法单独构建或测试的中间 commit
- 不要把其他需求或用户已有修改混入提交。
## 八、独立验证
实现会话结束后,推荐开启一个新会话做只读验证,避免实现上下文影响判断。
### 8.1 通用验证提示词
```text
请独立验证 .scratch/<feature-slug>/PRD.md 及其 issues/ 的实现结果。固定评审起点是 <BASE_COMMIT>。
本轮先只审查和验证,不修改代码。完整读取 AGENTS.md、父 PRD、全部 tickets、相关 commits 和从基线到 HEAD 的累计 diff。
核对父 PRD、每张 ticket、DDD 架构通道、迁移边界和测试证据;运行最能证明验收条件的相关测试。确认实现会话已经在最终代码上成功运行过 go test ./...;如果没有可信证据、最终代码后来又变化,或本次验证发现可能影响全局的问题,再运行全量测试。
输出:通过项及证据、未通过项、范围外问题、环境或外部阻塞、仍需人工验收的项目,以及最终结论(通过/未通过/外部阻塞)。不要把代码存在当作行为验证,也不要把未执行的外部联调标记为通过。
```
### 8.2 验证失败后的处理
- 实现遗漏:回到对应 ticket`/implement <ticket-path>` 修复。
- 拆票遗漏:新增补救 ticket确认后实现不要静默扩大旧票。
- PRD 发生变化:先更新并重新评审 PRD再调整 tickets。
- 纯环境阻塞:保留自动化证据,列入联调或发布前验收,不伪造完成。
## 九、完成状态判定
### 9.1 Ticket 完成
- 所有 blocker 已完成;
- 验收条件有行为或测试证据;
- 没有擅自改变架构或扩大迁移范围;
- 相关测试通过,或存量/环境失败已准确记录。
### 9.2 PRD 完成
- 该 PRD 的所有 tickets 完成;
- PRD 的 User Stories、Implementation Decisions 和 Testing Decisions 无遗漏;
- 累计 diff 通过 Standards + Spec 双轴 review
- 最终代码已经完成约定的全量测试;
- 文档、迁移和人工验收项按 PRD 处理。
### 9.3 七月迭代完成
单个仓库代码完成不等于整轮迭代完成。所有 PRD 完成后,还需按标准评审稿执行:
```text
INT-01 资产实名、复机与状态同步
INT-02 换货完整链路
INT-03 套餐授权、购买、续费、到期与临期
INT-04 CSV 批量任务与导出
INT-05 支付、代理钱包、信用和余额预警
INT-06 企业微信审批、退款和线下充值
INT-07 Gateway 卡限速
INT-08 七月迭代全链路与停机发布验收
```
最终结论必须区分:
- 本地自动化完成;
- 后端实现完成;
- 前后端联调完成;
- 外部测试环境验收完成;
- 存量数据回填和停机发布演练完成。
只有标准评审稿要求的全部门禁通过,才能标记“七月迭代完成”。
## 十、日常最简操作卡
### 生成 Issues
```text
/to-tickets .scratch/<feature-slug>/PRD.md
```
确认草稿后:
```text
拆分粒度和阻塞关系确认,可以发布 Issues。
```
### 实现单票
```text
/implement .scratch/<feature-slug>/issues/<NN>-<ticket-slug>.md
```
### 实现整个小需求
```text
/implement .scratch/<feature-slug>/issues/
```
### 双轴评审
```text
/code-review <BASE_COMMIT>
```
### 独立验收
使用第八章的通用验证提示词,将 `<feature-slug>``<BASE_COMMIT>` 替换为实际值。

View File

@@ -1,6 +1,6 @@
# 需求15/16/18/19/20/21 技术方案
> 状态:原需求来源稿;其中本地审批流、审批页面操作密码方案已废弃,最终以企微审批方案和标准评审稿为准。
> 状态:原需求来源稿;其中本地审批流、审批页面操作密码以及基于原退款单 `resubmit` 的方案已废弃,最终以企微审批方案和标准评审稿为准。
> 评审建议:需求 15/16、需求 18/20/21、需求 19 分三组评审,不在一次会议中混合确认。
---
@@ -166,15 +166,15 @@ sequenceDiagram
评审结论:支付方式按**整批统一**设计:
- 页面选择 `offline``agent_wallet`Excel 不再重复填写支付方式。
- 页面选择 `offline``wallet`CSV 不再重复填写支付方式。`wallet` 复用现有后台订单支付枚举,在批量任务中表示逐行扣结算代理主钱包,不新增同义枚举 `agent_wallet`
- 混合支付拆成两个批次,避免一份凭证对应多种支付语义。
- Excel 不包含支付方式列,后端拒绝同一批次混合支付。
- CSV 不包含支付方式列,后端拒绝同一批次混合支付。
### 业务流程
```mermaid
flowchart TD
Start[员工选择代理和支付方式] --> Upload[上传 Excel]
Start[员工选择整批支付方式] --> Upload[上传可含不同代理资产的 CSV]
Upload --> Parse[解析并持久化逐行明细]
Parse --> Validate[校验资产、套餐、归属和重复行]
Validate --> Item{处理下一条有效明细}
@@ -193,6 +193,12 @@ flowchart TD
任务允许部分成功。每一行是独立、可重试、可审计的业务单元,不能只保存一段失败 JSON。
Worker 复用系统统一资产解析能力,把资产标识解析为唯一资产类型和资产 ID再按“资产类型 + 资产 ID”判断重复。一个批次在创建时只选择一个套餐因此重复键不包含套餐同一资产使用不同受支持标识仍是重复首次出现的行正常处理后续重复行失败并记录首次出现的行号。本期不把重复行解释为购买多份未来如有多份订购需求应增加明确数量字段。
统一资产解析当前支持 ICCID、卡 `virtual_no`、MSISDN、设备 `virtual_no`、IMEI 和 SN未命中或无法唯一解析时只失败该行禁止任意选择资产。明细保存用户原始资产标识、解析后的资产类型/ID和规范标识快照便于审计和跨标识判重。
本页面入口沿用前端现有可见性规则;后端不新增批量订购权限码、账号类型拦截或任务创建人隔离,能够通过现有后台认证调用接口的主体视为可以使用。该入口决定不能替代逐行的资产归属、套餐授权、成本价和钱包业务校验。
### 数据库变更
```sql
@@ -204,11 +210,15 @@ CREATE TABLE tb_bulk_purchase_task (
creator BIGINT NOT NULL DEFAULT 0,
updater BIGINT NOT NULL DEFAULT 0,
task_no VARCHAR(30) NOT NULL,
request_id VARCHAR(64) NOT NULL,
source_file_key VARCHAR(500) NOT NULL,
shop_id BIGINT NOT NULL,
operator_id BIGINT NOT NULL,
package_id BIGINT NOT NULL,
package_code_snapshot VARCHAR(50) NOT NULL DEFAULT '',
package_name_snapshot VARCHAR(200) NOT NULL DEFAULT '',
payment_method VARCHAR(20) NOT NULL,
voucher_keys JSONB NOT NULL DEFAULT '[]',
involved_shop_count INT NOT NULL DEFAULT 0,
total_amount BIGINT NOT NULL DEFAULT 0,
total_count INT NOT NULL DEFAULT 0,
success_count INT NOT NULL DEFAULT 0,
@@ -223,17 +233,22 @@ CREATE UNIQUE INDEX idx_bulk_purchase_task_no
ON tb_bulk_purchase_task(task_no)
WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX idx_bulk_purchase_task_request_id
ON tb_bulk_purchase_task(request_id)
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,
asset_type VARCHAR(20),
input_asset_identifier VARCHAR(100) NOT NULL,
resolved_asset_id BIGINT,
canonical_identifier VARCHAR(100) NOT NULL DEFAULT '',
resolved_shop_id BIGINT,
resolved_shop_name VARCHAR(200) NOT NULL DEFAULT '',
amount BIGINT NOT NULL DEFAULT 0,
status INT NOT NULL DEFAULT 1,
order_id BIGINT,
@@ -255,48 +270,51 @@ CREATE INDEX idx_bulk_purchase_item_task_status
状态建议:
- 任务:`1=待处理, 2=处理中, 3=已完成, 4=部分成功, 5=失败`
- 明细:`1=待处理, 2=处理中, 3=成功, 4=失败`
- 任务统一复用全局异步任务状态`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` 后,在同一事务扣款、创建订单、资金流水并更新明细
1. 前端选择单个套餐和整批支付方式,通过对象存储预签名地址直传 CSV 和线下凭证,再由 API 校验 `package_id``file_key`、支付方式和 `voucher_keys` 并创建任务,返回 `task_id`;业务接口不接收 `shop_id` 或文件字节。创建接口校验套餐存在以及对象归属、扩展名/Content-Type 和 10MB 大小,不同步解析 CSV逐行处理时再校验结算代理当前套餐授权、价格和可售规则
2. Worker 根据 `source_file_key` 下载并解析 UTF-8 CSV允许 BOM校验固定表头、未知列、CSV 语法、空文件和 1000 行上限;任一文件级校验失败时任务整体失败且不创建明细订单。文件有效后将每一行先写入明细表,再开始业务处理;不接受 Excel
3. 同一任务严格按 CSV 行号顺序处理;每行按资产当前归属解析结算代理,同一任务可以依次处理不同代理钱包`wallet` 不预占整批或某一代理全部行的金额,当前行余额不足只失败该行并继续后续行;后续金额较小且当时余额足够的行仍可成功,因此行序就是同一代理资金不足时的订购优先级
4. 每行使用独立事务。代理钱包支付时锁定该行结算代理的主钱包,按有效信用额度校验总可用金额后,在同一事务扣款、创建订单、资金流水并更新明细;无归属、归属异常、无主钱包或余额不足只失败该行
5. `idempotency_key` 使用 `bulk_purchase:{task_id}:{row_no}`。Worker 重试时,已存在成功订单的明细直接跳过。
6. 单行失败不回滚已成功行;失败原因写结构化错误码和用户可见中文原因。
7. 任务汇总从明细表计算,不信任内存计数。
Asynq 载荷只传任务 ID不传文件字节或临时路径。源文件保留周期按对象存储统一策略处理确保 Worker 重试期间仍可读取。
建议单文件上限 1000 行;超过上限在 API 层拒绝,避免长事务和过长处理时间
单文件上限 1000 行;超过上限由 Worker 将任务标记为整体失败,不创建任何订单
### API 设计
**前端静态 Excel 模板**
**前端静态 CSV 模板**
模板由前端项目随版本发布,后端不提供下载接口。按已确认的“整批统一支付方式”,模板字段为
模板由前端项目随版本发布,后端不提供下载接口。模板只有一列
```text
资产类型 | 资产标识 | 套餐编码 | 套餐名称
资产标识
```
`套餐名称`用于人工核对,实际匹配以稳定的 `套餐编码` 为准。后端必须校验表头并对未知列给出明确错误,不能依赖模板一定来自当前前端版本。
套餐由创建任务请求中的单个 `package_id` 决定。资产标识统一交给系统资产解析能力识别资产类型和 ID批量模块不维护自己的标识白名单。后端必须校验单列表头并对未知列给出明确错误,不能依赖模板一定来自当前前端版本。
**上传并提交**
```text
POST /api/admin/bulk-purchases
Content-Type: multipart/form-data
Content-Type: application/json
shop_id: 123
payment_method: offline | agent_wallet
voucher_keys: ["key1","key2"]
file: <Excel文件>
{
"request_id": "01J...",
"package_id": 1001,
"payment_method": "offline",
"file_key": "bulk-purchase/2026/07/xxx.csv",
"voucher_keys": ["attachment/2026/07/key1"]
}
```
`offline``voucher_keys` 必填,`agent_wallet`忽略该字段
CSV 先调用 `POST /api/admin/storage/upload-url` 并使用独立 `purpose=bulk_purchase` 获取预签名地址后直传;线下凭证使用附件用途直传。`package_id` 为大于 0 的单个套餐 ID`offline``voucher_keys` 必填,`wallet`必须为空。创建任务前校验套餐存在、对象归属、CSV 类型和 10MB 大小限制;编码、表头、语法、空文件和行数由 Worker 校验
**查询任务状态**
@@ -313,275 +331,116 @@ GET /api/admin/bulk-purchases/{task_id}/items?status=4&page=1&page_size=50
### 前端技术方案
- 页面分为“参数确认 → 文件上传 → 处理中 → 结果”四个稳定步骤,刷新页面后可根据任务 ID 恢复进度。
- 提交前展示代理、支付方式、凭证数量和文件名的二次确认;钱包支付额外展示当前可用余额,但最终以 Worker 扣款时校验为准
- 提交前展示所选单个套餐、支付方式、凭证数量和文件名;不展示整批目标代理或单一钱包余额。任务结果按行展示后端解析的结算代理及金额
- 任务处理中展示总数、已处理数、成功数和失败数,轮询规则复用统一异步任务方案。
- 结果页默认显示失败明细,可切换全部/成功/失败,并可按资产标识搜索。
- 部分成功使用明确状态,不弹“全部成功”提示;再次上传失败行会创建新任务,不修改旧任务历史。
- 任务状态为已完成后,根据成功数和失败数显示全部成功、部分成功或全部业务行失败摘要;再次上传失败行会创建新任务,不修改旧任务历史。
- 操作员、任务号和处理时间在页面固定展示,便于财务和运营追溯。
### 自动化测试环境隔离
- 已部署测试环境继续使用 Redis DB 6本地开发和 Agent 自动化测试统一使用 Redis DB 7禁止自动化测试向 DB 6 投递 Asynq 任务。
- 测试入口必须读取并校验实际 Redis Client 配置DB 不是 7 时立即失败。普通 Redis Client、Asynq Client 和 Worker Handler 使用同一 DB 配置,不能只隔离普通 Key 而把任务送到 DB 6。
- 不允许对 DB 7 执行 `FLUSHDB`;每次运行使用唯一测试标识,并只清理本次运行实际创建的 Key 和数据。
- 自动化测试直接连接当前真实 S3使用唯一对象 Key 上传 CSV 和凭证,结束时只删除本次创建的对象;不提供内存对象存储替身。
- PostgreSQL 继续使用现有测试库,不新增 Agent 专用库。测试只创建带唯一运行标识的夹具,并按实际记录 ID 精确清理;禁止清表、`TRUNCATE`、模糊删除和修改既有业务数据,并发钱包场景也遵守相同隔离规则。
- Go HTTP 集成测试是主要自动化入口:使用 Fiber `app.Test` 穿过真实认证和业务链路,捕获待投递任务后直接驱动公开 Worker Handler不通过 `sleep` 等待测试环境 Worker 抢取任务。
- 实现时同步保留可复用的 Agent 集成测试规范、环境守卫与精确清理 Harness、典型 CSV `testdata` 和完整示例;真实部署环境另提供 `curl` 冒烟模板,但不以 `curl` 代替自动化断言。
---
## 需求20退款审批
### 依赖
段历史设计基于 [已废弃的本地审批流](../历史方案/本地通用审批流-已废弃方案.md)
需求依赖七月统一企业微信审批公共能力,以稳定场景码 `refund_approval` 复用真实提交人、平台本人绑定、代理固定成员代提交、模板版本、附件上传、加密回调、轮询和终态 Outbox。退款模块不再依赖已废弃的本地审批流也不自行实现审批节点、候选审批人或审批按钮
### 退款单现有状态(不变)
### 创建契约与固定金额
```
1=待审批 2=已通过 3=已拒绝 4=已退回
```
- 保留 `POST /api/admin/refunds`,允许超级管理员、平台和代理发起;企业账号不允许发起。
- 请求只接受 `order_id``requested_refund_amount`、必填且最多 1000 字的 `refund_reason`、可选且最多 500 字的 `remark`,以及 15 个 `{file_key,file_name,file_size}` 附件。
- 不再接受前端提交的 `actual_received_amount``package_usage_id`。后端读取订单实收金额、支付方式、买卖方、资产和店铺事实并固化快照。
- 申请金额校验 `0 < requested_refund_amount <= 订单实收金额`,提交后固定。企微表单只读展示订单实收金额、可退款区间和本次申请金额,审批人只能同意或拒绝,不能修改金额。
- 退款单、唯一企微审批实例和提交 Outbox 同一 PostgreSQL 事务创建;远程企微异步提交,业务接口返回本地退款和“企微提交中”。场景、模板、平台本人绑定或代理固定成员不可用时,在任何业务事实落库前失败。
- 同一订单存在提交中、审批中、已通过但处理未成功或异常人工处置未完成的退款时禁止新建。已拒绝退款不占活跃名额;撤销、删除和通过后撤销不自动放行。
退款单状态值的原有语义不改;审批进度由 `tb_approval_process_instance` 管理,两者通过 `approval_instance_id` 关联。审批通过后,代理钱包退款自动回退到原扣款代理主钱包;微信、支付宝和线下退款由财务人工完成。业务表增加独立处理状态:
### 独立状态与数据事实
```text
processing_status0=待处理 1=处理中 2=已完成 3=处理失败
退款状态1=待审批 2=已通过 3=已拒绝 4=已退回(仅历史) 5=已撤销/审批已删除
处理状态0=未触发 1=处理中 2=处理成功 3=处理失败
```
`status=2` 表示审批结论已通过,`processing_status` 表示实际退款动作是否完成。接口和前端必须同时展示两者
- 企微审批状态独立复用公共枚举:`0=提交中, 1=审批中, 2=已通过, 3=已驳回, 4=已撤销, 5=通过后撤销, 6=已删除, 7=提交失败, 8=提交结果未知`。退款、审批和业务处理状态不得互相覆盖
- 退款记录保存唯一 `approval_instance_id`、真实提交人显示快照、结构化申请附件、申请备注、处理状态/错误/开始与完成时间,以及可靠 Worker 所需的租约或版本事实。
- `actual_received_amount` 继续作为后端生成的订单实收快照;`actual_refund_amount` 只表示资金已经实际完成的金额。旧 `approved_refund_amount``package_usage_id` 和字符串 Key 列表只作历史兼容,新退款不再依赖。
- 非代理钱包在财务已系统外退款并同意企微时写 `actual_refund_amount=申请金额`;代理主钱包只在原钱包回溯与唯一退款流水事务成功后写。驳回、撤销、删除及尚未完成代理回溯时为空;资金完成后即使佣金或套餐失败也保留。
- 佣金记录增加结构化 `invalid_reason``invalid_refund_id``invalidated_at` 和必要的人工操作者事实;已发放佣金回扣以“退款单 + 佣金记录”唯一防重。
### 数据库变更
### 企微终态和资金处理
```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 '[]';
- 企微拒绝把退款状态置为已拒绝,处理状态保持未触发;该审批和退款单均终结。若修正问题后仍需退款,重新调用创建接口生成新退款 ID、退款单号、快照、附件和企微实例原单不可编辑或重提。
- 微信、支付宝、线下及其他非代理主钱包支付由财务先在系统外退款,再同意企微;企微同意即代表退款完成。本系统不调用渠道退款 API也不提供 `manual-complete` 二次确认。
- 个人客户资产钱包支付同样走系统外人工退款。本系统不自动回充资产钱包、不创建资产钱包退款流水;现有个人钱包自动回充分支必须从新终态用例移除或隔离。
- 代理主钱包支付在企微同意后只按原订单扣款流水定位原主钱包,增加本次申请金额并写唯一退款流水。余额允许为负,退款自然冲减欠款;缺少可核验原扣款流水时处理失败,禁止根据当前代理关系、买卖方或当前钱包猜测。
- 企微撤销或删除进入异常人工处置,不执行资金动作也不自动放行新退款。通过后撤销且资金尚未执行时阻止后续资金步骤;资金已执行时不自动冲正,保留实际退款金额,记录 `critical` Audit Event 和站内告警。
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` 仍是申请业务资料,不能混用为财务完成凭证
- 无论申请金额是否等于订单实收金额,企微同意后都以整张订单为边界:订单标记已退款,该订单全部有效套餐和佣金资格终结,该订单不允许再申请未退差额。向客户实际退款的金额仍是本次申请金额
- 已冻结、解冻中、尚未发放或待人工修正的佣金不移动钱包,直接改为已失效;已发放佣金先从对应佣金钱包全额扣回,再改为已失效,钱包允许为负。佣金状态、钱包余额/版本、回扣流水和统一 Audit Event 同事务。
- 该订单生成的全部有效套餐失效,不按单个套餐使用记录缩小范围。主套餐失效级联其加油包,然后尝试激活下一条待生效主套餐;没有下一条时通过公共卡/设备状态能力停止资产。
- 企微首次终态通过公共业务 Outbox 触发可靠 Asynq Worker不使用进程内 Goroutine。Worker 通过状态条件和租约领取任务;资金、订单、每条佣金和套餐分别保存持久化幂等事实,局部失败只重试未完成步骤,全部完成后才置处理成功。
- 代理主钱包余额/版本/退款流水与实际退款金额、已发放佣金回扣、订单状态和关键套餐状态分别按最小一致性边界写统一 Audit Event。重复回调、轮询和 Worker 命中已完成事实时不产生第二笔资金、第二次失效或第二条等价审计。
现有退款审批允许确认 `approved_refund_amount`,切换到通用审批后必须保留:配置为最终决策的节点在完成时返回金额动作字段,审批人可确认实际退款金额;省略时使用申请金额。退款动作适配器在审批事务内校验金额大于 0且不超过申请退款金额和订单实收金额并写入退款单和审批操作日志。会签需要指定金额决策人时流程定义增加其专属最终节点禁止第一位会签人预先锁定金额。
### API、权限与前端
### 流程
- 保留 `POST /api/admin/refunds``GET /api/admin/refunds``GET /api/admin/refunds/{id}`。下线并不再注册原 `approve/reject/return/resubmit` 及任何 `manual-complete` 路由。
- 列表默认每页 20、最大 100按创建时间倒序并以 ID 作并列排序键;筛选参数按 AND 组合。详情和列表同时返回退款状态、`approval` 对象和独立处理状态,历史本地审批使用 `approval.source=legacy`,新企微退款使用 `wecom`
- 代理退款查询不再按创建账号隔离,改为既有店铺层级数据范围和退款业务权限。代理可见申请资料、业务凭证、真实提交人、审批状态和处理结果,但看不到审批人、内部意见和审批人附件。平台/超级管理员也必须有退款业务查看权限,才可读取完整企微详情。
- 列表、详情、附件下载和导出复用同一权限投影;企微运营权限不能绕过退款业务权限。
- 创建页不提交实收金额或套餐使用记录,明确提示“申请金额可以小于实收金额,但通过后仍按整单终结”。详情固定分为退款业务信息、企微审批信息和业务处理结果,不显示本地审批、金额修改、重提或人工确认按钮。
- 非代理钱包提示财务先系统外退款再同意企微;代理钱包提示同意后系统自动回溯。处理失败只展示脱敏摘要和系统重试状态;通过后撤销使用高风险告警并说明不会自动冲正。
```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=1status=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_idstatus 回到 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` 首节点创建流程实例和任务;历史终态退款不伪造流程实例。
- 自动化最高接缝为 Fiber HTTP 与真实认证,经退款 Application/Domain/Query、GORM、现有测试 PostgreSQL、Outbox 和公开 Worker Handler最终核对钱包、佣金、套餐和统一审计企微网络边界使用可编程 Adapter 覆盖超时、重复、乱序、撤销、删除、通过后撤销和并发重试。
- 本地/Agent 测试强制 Redis DB 7已部署测试环境继续使用 DB 6测试启动前校验普通 Redis、Asynq Client 和 Worker 的实际 DB禁止向 DB 6 投递、禁止 `FLUSHDB`。PostgreSQL 沿用现有测试库并按唯一运行标识精确清理;对象存储使用真实 S3 和唯一 Key不做内存替身。
- 真实企微验收是实现完成门禁,不得推迟到 INT-06。至少执行两张独立退款代理使用固定成员代提交并同意验证真实附件、加密回调及代理钱包/佣金/套餐/审计;平台账号使用本人绑定发起并拒绝,验证轮询兜底且不触发资金处理。
- 回调按“企业微信 → 用户提供的中转应用 → 本地服务”原样转发查询参数和请求体,后端仍完整验签、解密并核对 CorpID。真实参数只通过安全配置提供。撤销、删除和通过后撤销由可编程 Adapter 稳定覆盖,不强制每次真实企微人工制造。
- 停机发布时先具备真实企微公共能力、模板映射、平台绑定和代理固定成员,再迁移待审批退款并启用终态 Worker历史终态保留 `legacy`,历史已退回只读。旧状态不一致记录进入对账/人工处置,迁移脚本不得猜测资金、佣金或套餐已完成。
---
## 需求21充值审核流程
### 充值单现有状态
> 本节已按 UR#34 最终评审结论重写。完整可执行契约见 [UR#34 PRD](../../../../.scratch/ur34-agent-recharge/PRD.md),如有细节差异以该 PRD 和标准评审稿为准。
```
tb_agent_recharge_record1=待支付 2=已支付 3=已完成 4=已关闭 5=已退款
```
### 业务分流
代码中已经存在 `6=已驳回`,不能改写其含义。员工线下充值走审批流时,在现有状态基础上追加 `7=已退回`
- 代理只能为当前登录店铺主钱包创建 `wechat``alipay` 在线充值,请求不接受 `shop_id`;平台、超级管理员和企业账号不能替代理创建在线支付。
- 平台和超级管理员只能为指定 `shop_id` 创建 `offline` 线下代充值必须携带固定金额、15 个结构化付款凭证和可选备注,并进入真实企业微信审批;代理和企业账号不能创建线下代充值。
- 在线充值金额为 `10000100000000` 分,线下代充值为 `1100000000` 分。
- 平台和超级管理员的线下提交人使用本人绑定的企微账号;审批人只能同意或拒绝,不能修改金额。拒绝后原充值单以 `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 '';
- 继续使用现有支付配置,只按 `wechat``alipay` 选择当前可用配置,不建设多通道自动路由、优先级或故障转移。
- 微信使用 Native支付宝使用 `alipay.trade.precreate`。后端把第三方返回的字符串或 HTTPS URL 原样映射为 `qr_content`,前端渲染二维码;后端不生成二维码图片或新增二维码生成接口。
- 创建接口不返回 `expires_at`,前端不展示本地推算的精确倒计时。本地时间不能判定第三方支付单是否失效,支付成功或关闭以回调和后端受控查单为准。
- `request_id` 只防止同一次提交重试。代理每次主动创建或再次拉起支付都使用新 `request_id` 并产生新的充值单和支付单,旧单等待第三方自然收敛,不复用、不主动取消。
- 支付回调或查单先在事务中固化真实收款事实:支付单已支付、充值单 `2=已支付``processing_status=1`并可靠写入钱包入账 Outbox随后 Worker 在独立事务中更新钱包和版本、创建唯一流水、将充值单改为 `3=已完成``processing_status=2`并写资金审计。入账失败使用 `processing_status=3`可靠重试,不回滚支付事实。
- 支付状态继续使用 `0=待支付, 1=已支付, 2=已失败, 3=已退款`,不新增“已关闭”;第三方明确关闭或失效时支付记为已失败、充值记为 `4=已关闭`。有效的迟到成功回调仍必须恢复支付事实并幂等入账。
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=已退回`:审批人退回给提交人修改
- 企微同意后按与在线充值相同的钱包入账 Worker 幂等入账;企微拒绝时终结为已驳回,不触发钱包。
- 企微在通过前撤销或删除时关闭充值单;通过后、入账前撤销时终止入账并关闭;已经到账后再撤销时不得自动扣款,保持已完成并写严重 Audit Event、通知财务人工处理。
- 停机发布时,下线旧 `offline-pay` 和本地 `reject` 路由。历史终态只读保留;历史待处理线下单只有在真实提交人已绑定企微且申请资料完整时才迁移为真实企微审批,其余进入异常清单,禁止猜测入账
`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 更新为 2processing_status=1
Worker->>DB: 幂等增加钱包余额并写流水
Worker->>DB: status 从 2 更新为 3processing_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_idstatus 回到 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` 时不允许前端再次点击入账,只展示系统重试状态和管理员排查入口。
- `payment_status``processing_status` 分离;处理状态统一为 `0=未触发, 1=处理中, 2=处理成功, 3=处理失败`,不提供同义字段 `wallet_posting_status`
- 代理按既有充值业务权限和店铺层级范围查看本店及有权管理的下级店铺记录,不按创建账号隔离;平台和超级管理员也受业务权限与数据范围约束,企业账号不可访问。
- 主钱包实际入账后,必须向目标代理主账号发送“充值到账”站内通知;在线实际提交账号不同于主账号时也接收一条。通知按充值单与接收人防重,失败不回滚资金。
- 本地自动化使用真实 PostgreSQL、Redis DB 7、真实 S3 和可编程支付 Adapter测试环境 Redis 保持 DB 6微信/支付宝真实扫码由人工验证。真实企微审批链路是实现完成门禁,回调可经用户中转应用原样转发到本地。

View File

@@ -14,10 +14,10 @@
6. 回调是主通道,每 2 分钟查询审批详情作为待审批单兜底。
7. 回调与轮询必须进入同一个状态同步用例,业务终态处理只能执行一次。
8. 企微模板 ID 和控件 ID 都可能因管理员编辑模板而变化,必须使用稳定业务场景码、不可变模板版本和控件映射快照。
9. 本次触碰的退款、线下充值审批逻辑迁移到 Domain/Application旧业务单级通过、驳回、退回和确认入账接口下线不保留两套审批入口。
9. 本次触碰的退款、线下充值审批逻辑迁移到 Domain/Application旧业务单级通过、驳回、退回、重新提交和确认入账接口下线,不保留两套审批入口。
10. 系统无法从旧模板 ID 自动发现编辑后生成的新模板 ID模板变更必须先暂停业务场景再发布新映射并原子切换避免继续向旧模板提交。
11. 退款仍为财务人工退款,本系统不调用微信、支付宝等支付渠道退款接口;只有代理钱包支付订单自动回溯原扣款代理主钱包,不向个人客户或资产钱包自动回款。
12. 系统账号与企微成员使用 Web 登录二维码自助绑定,普通运营不手工查找或录入 `userid`管理端只查看状态和强制解绑
12. 平台账号与企微成员使用 Web 登录二维码自助绑定,普通运营不手工查找或录入 `userid`代理账号不绑定企微,统一使用部署配置中的固定企微账号代提交。两类路径都独立保存真实业务提交人
## 二、系统边界
@@ -45,7 +45,7 @@
```mermaid
sequenceDiagram
actor User as 平台员工
actor User as 业务提交人
participant API as Refund/Recharge Application
participant DB as PostgreSQL
participant Outbox as Outbox
@@ -60,7 +60,7 @@ sequenceDiagram
API->>Outbox: 同事务写 SubmissionRequested
API-->>User: 返回业务单和提交中状态
Outbox->>Worker: 投递提交任务
Worker->>WeCom: 上传附件、applyevent
Worker->>WeCom: 按账号类型选择企微身份并applyevent
WeCom-->>Worker: sp_no
Worker->>DB: 保存 sp_no状态改为 pending
@@ -87,6 +87,7 @@ wecom:
callback_encoding_aes_key: ""
callback_path: "/api/callback/wecom/approval"
account_binding_redirect_url: "https://后台域名/api/callback/wecom/account-binding"
agent_approval_creator_userid: ""
approval_poll_interval: 2m
template_verify_interval: 10m
request_timeout: 10s
@@ -101,14 +102,15 @@ JUNHONG_WECOM_AGENT_SECRET
JUNHONG_WECOM_CALLBACK_TOKEN
JUNHONG_WECOM_CALLBACK_ENCODING_AES_KEY
JUNHONG_WECOM_ACCOUNT_BINDING_REDIRECT_URL
JUNHONG_WECOM_AGENT_APPROVAL_CREATOR_USERID
```
后台只能查询“是否已配置、最近连通时间、最近错误”,不能返回 Secret、TokenEncodingAESKey。
后台只能查询“是否已配置、最近连通时间、最近错误”以及代理固定代提交成员是否就绪和成员显示名,不能返回 Secret、TokenEncodingAESKey 或原始固定 `userid`
企微自建应用还必须配置:
- Web 登录回调可信域名与 `account_binding_redirect_url` 域名一致。
- 应用可见范围覆盖需要发起退款或线下充值审批的平台员工,否则扫码时企微会提示无权限
- 应用可见范围覆盖需要本人发起审批的平台/超级管理员以及代理固定代提交成员;代理业务账号本身不要求成为企微成员
- `CorpID``AgentID`、用于换取身份的 Access Token 必须属于同一个自建应用配置。
用户提供的 demo 中已经出现完整密钥正式接入前必须在企业微信后台轮换并禁止将新值写入代码、Markdown、日志或审计快照。
@@ -277,15 +279,15 @@ shop_name, recharge_no, amount, remark, attachment, submitter
提交审批前,如果 `last_verified_at` 超过验证间隔Worker 先同步验证一次。验证失败不调用 `applyevent`
## 六、内部账号与企微成员绑定
## 六、平台账号绑定、代理固定代提交与真实业务提交人
创建人必须有明确的企微 `userid`,不使用固定手机号冒充所有申请人,也不要求运营人员进入企微后台查找成员 ID
企微发起身份按账号类型解析:平台账号和超级管理员必须有本人企微 `userid`;代理账号发起退款时使用部署配置中的固定企微成员 `agent_approval_creator_userid`。两类路径都把当前登录账号作为真实业务提交人写入业务快照和模板 `submitter` 字段
### 6.1 绑定流程
```mermaid
sequenceDiagram
actor User as 已登录平台员
actor User as 已登录平台/超级管理
participant FE as 管理后台
participant API as WeComIdentity Application
participant Redis as Redis绑定会话
@@ -351,7 +353,7 @@ value:
created_at
```
- 只有已登录且启用的超级管理员、平台用户可以为自己创建绑定会话。
- 只有已登录且启用的超级管理员、平台用户可以为自己创建绑定会话;代理账号不提供绑定会话
- `state` 使用密码学安全随机数,回调时通过 Lua 原子读取并删除;后续成功或失败结果写入独立的 `session_id` 会话。
- 回调不接受前端传入的 `account_id`,绑定目标只能来自服务端会话。
- 同一账号再次创建会话时,旧会话立即失效。
@@ -377,7 +379,7 @@ CREATE TABLE tb_account_wecom_mapping (
);
```
- 平台员创建退款或线下充值时必须存在启用绑定,否则拒绝创建并返回可直接拉起绑定窗口的错误状态。
- 平台/超级管理员创建退款或线下充值时必须存在启用绑定,否则拒绝创建并返回可直接拉起绑定窗口的错误状态;代理创建退款不检查个人绑定,改用固定代提交身份
- `account_id``wecom_userid` 都是一对一唯一;企微成员已绑定其他账号时拒绝覆盖,必须先由超级管理员解除旧绑定。
- 重新绑定同一账号必须再次扫码,成功后在事务内替换旧身份并记录前后值审计。
- 自助解绑不影响历史审批;历史实例继续使用提交时保存的 `creator_wecom_userid` 快照,新审批在重新绑定前禁止创建。
@@ -387,6 +389,20 @@ CREATE TABLE tb_account_wecom_mapping (
普通运营界面不提供手工录入 `userid`。超级管理员仅能查看、强制解绑并要求员工重新扫码,不提供直接改写成员 ID 的入口。
代理固定代提交身份只通过部署配置维护。其 `userid` 必须属于当前 CorpID、处于启用状态且在自建应用可见范围内后台只返回是否就绪及成员显示名不返回或修改原始 `userid`。固定身份未配置或失效时,代理新申请必须在业务单、审批实例和 Outbox 落库前拒绝。固定身份变更只影响新审批,历史实例保留原发起身份快照。
### 6.4 权限边界
- 连接状态、场景状态、模板版本、模板读取/发布、场景暂停/恢复仅超级管理员可访问。
- 平台账号只能查询、重新绑定和解绑自己的企微身份;绑定列表和强制解绑仅超级管理员可访问。
- 审批运行列表、详情和“立即同步”仅超级管理员或具备独立“企微审批运营”权限的平台账号访问。
- “企微审批运营”不包含场景/模板维护、账号绑定管理和提交结果未知恢复。
- 提交结果未知恢复仅超级管理员或具备独立“企微审批异常恢复”权限的平台账号访问。
- 代理不访问企微配置、绑定和审批运行接口,只能按现有店铺层级数据范围查看退款详情中的只读审批摘要。
- 所有接口均执行后端鉴权,前端隐藏按钮或路由不构成权限控制。
审批内容继续按主体最小化投影:代理只读取真实业务提交人、审批状态、状态时间、业务处理结果,以及其业务范围内的退款资料和业务凭证;企微审批人、内部意见和审批人上传附件只允许具备对应业务查看权限的平台账号或超级管理员读取。审批详情、附件解析和导出必须共享同一权限判定,历史导出中的稳定附件引用也要在每次访问时重新校验当前权限。
## 七、审批实例
```sql
@@ -395,13 +411,13 @@ CREATE TABLE tb_wecom_approval_instance (
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,
creator_identity_source VARCHAR(32) NOT NULL,
sp_no VARCHAR(128),
status INT NOT NULL DEFAULT 0,
submitted_snapshot JSONB NOT NULL,
@@ -418,7 +434,7 @@ CREATE TABLE tb_wecom_approval_instance (
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)
UNIQUE (biz_type, biz_id)
);
CREATE UNIQUE INDEX uq_wecom_approval_sp_no
@@ -440,7 +456,7 @@ CREATE UNIQUE INDEX uq_wecom_approval_sp_no
| 7 | 提交失败 | 明确未创建审批 |
| 8 | 提交结果未知 | 请求超时,无法确认是否创建 |
`submitted_snapshot` 保存提交时的业务展示数据和本地附件 Key不保存临时 `media_id`、签名 URL 或敏感密钥。
每条实例表示一张业务单唯一的审批申请,不使用 `round_no`。业务表通过 `approval_instance_id` 明确引用该申请,`(biz_type, biz_id)` 唯一约束保证退款或线下充值业务单不能产生第二条审批申请。`creator_account_id` 是真实业务提交人;`creator_wecom_userid` 是本次实际调用企微的成员快照,`creator_identity_source` 固定为 `self_binding``agent_proxy``submitted_snapshot` 保存提交时的业务展示数据和本地附件 Key不保存临时 `media_id`、签名 URL 或敏感密钥。
## 八、DDD 设计
@@ -459,7 +475,7 @@ internal/
│ ├── sync_approval_status.go
│ ├── handle_callback.go
│ ├── poll_pending.go
│ └── resubmit_approval.go
│ └── recover_unknown_submission.go
├── domain/wecomidentity/
│ ├── binding.go 账号与企微成员一对一绑定规则
│ └── repository.go
@@ -512,7 +528,7 @@ Worker 处理:
5. 逐个调用 `media/upload` 获取临时 `media_id`
6. 按控件映射构建 `apply_data`
7. 使用模板审批人配置,固定 `use_template_approver=1`
8. 调用 `applyevent`
8. 按实例身份来源使用平台账号绑定的本人 `userid` 或代理固定代提交 `userid` 调用 `applyevent`;不得在 Worker 执行时重新按账号当前状态选择身份
9. 保存 `sp_no`,状态变为审批中并释放租约。
附件的本地对象存储 Key 是权威记录;企微 `media_id` 只是提交期间使用的临时值。
@@ -524,7 +540,7 @@ Worker 处理:
- 收到明确 `errcode != 0`:状态改为提交失败,可修正配置后重新提交。
- 建连失败且确认请求未发送:允许自动重试。
- 请求已发送但响应超时/连接中断:状态改为提交结果未知,不自动再次调用 `applyevent`
- 提交结果未知进入异常页面,由管理员在企微核对后选择“确认未创建并重新提交”或“绑定已有 sp_no”。
- 提交结果未知进入异常页面,由管理员在企微核对后选择“确认未创建并重新发送”或“绑定已有 sp_no”。重新发送只是同一审批申请的新技术尝试,不创建新的业务审批申请;旧尝试继续保留在 Integration Log。
## 十、回调和轮询
@@ -594,18 +610,12 @@ AND (last_polled_at IS NULL OR last_polled_at <= NOW() - INTERVAL '2 minutes')
|---|---|
| 通过 | 记录人工退款完成;代理钱包支付订单回溯原扣款代理主钱包;更新订单状态,随后异步佣金回扣和资产处理 |
| 驳回 | 退款状态改为已拒绝,保存审批详情摘要 |
| 撤销/删除 | 退款状态改为已撤销;允许申请人修改后创建下一轮审批 |
| 撤销/删除 | 非正常业务结果,记录异常、停止自动业务处理并进入人工处置;不开放正常重新提交入口 |
| 通过后撤销 | 已退款则不冲正,记录严重异常并通知财务;尚未执行则阻止退款 |
`POST /api/admin/refunds/{id}/approve``reject``return` 下线。
`POST /api/admin/refunds/{id}/approve``reject``return``resubmit` 全部下线。
重新申请使用:
```http
POST /api/admin/refunds/{id}/resubmit
```
它只允许已驳回、已撤销或已删除且业务尚未退款的申请,修改业务资料后创建 `round_no + 1` 的企微审批。
一张退款单只产生一条审批申请,正常业务结果只有同意或拒绝,任一结果产生后审批和退款单即同时完结。企微拒绝时保存拒绝原因和审批详情,原退款单进入不可编辑、不可重提的已拒绝终态。业务人员纠正问题后若仍需退款,必须重新调用 `POST /api/admin/refunds`,系统生成新的退款 ID、退款单号、业务快照、提交人快照和企微审批申请新单不继承旧审批节点、意见、附件、状态或企微发起身份。旧单只作为历史与审计事实保留。创建校验不把已拒绝退款视为活跃退款但存在其他审批中或处理未完成的退款时仍拒绝新建。撤销、删除和通过后撤销属于外部异常防御不作为自动放行新退款的依据。
审批通过后的业务 Worker 处理失败时,审批实例保持“已通过”,`business_process_result=failed`,由任务重试;前端明确区分“审批已通过”和“退款终态处理失败”。只有退款终态事务成功后才写 `business_processed_at`
@@ -633,10 +643,10 @@ POST /api/admin/refunds/{id}/resubmit
### 12.1 基础配置状态
```http
GET /api/admin/wecom/config/status
GET /api/admin/wecom/status
```
返回是否配置、Token 最近获取时间、回调最近成功时间、最近错误,不返回任何密钥
返回是否配置、Token 最近获取时间、回调最近成功时间、最近错误、代理固定代提交成员是否就绪及成员显示名,不返回任何密钥或原始固定 `userid`
### 12.2 审批场景状态
@@ -735,9 +745,10 @@ GET /api/admin/wecom/approvals
GET /api/admin/wecom/approvals/{id}
POST /api/admin/wecom/approvals/{id}/sync
POST /api/admin/wecom/approvals/{id}/bind-sp-no
POST /api/admin/wecom/approvals/{id}/confirm-not-created-and-resend
```
`bind-sp-no` 仅用于提交结果未知的人工恢复,必须写高等级审计日志。
`bind-sp-no` 请求包含 `sp_no` 和必填恢复原因;后端调用 `getapprovaldetail` 完成企业、模板、发起身份、业务场景、真实业务提交人和业务快照核对后才能绑定。`confirm-not-created-and-resend` 请求包含必填恢复原因,只在操作人已在企微确认未创建审批时重新发送同一审批申请。两者仅用于提交结果未知的人工恢复,必须二次确认并写高等级审计日志。
### 12.8 业务详情响应
@@ -752,7 +763,6 @@ POST /api/admin/wecom/approvals/{id}/bind-sp-no
"status": 2,
"status_name": "已通过",
"template_name": "退款审批",
"round_no": 1,
"creator_name": "张三",
"approvers": [],
"submitted_at": "2026-07-15T10:00:00+08:00",
@@ -773,16 +783,18 @@ POST /api/admin/wecom/approvals/{id}/bind-sp-no
### 13.2 业务详情
退款和充值详情增加只读“企业微信审批”区块:
退款和充值详情增加只读“企业微信审批”区块。平台/超级管理员在具备业务查看权限时展示
- 审批单号。
- 当前状态和状态更新时间。
- 模板名称和模板版本。
- 申请人。
- 真实业务提交人;代理代提交时可只读标识“企微由固定账号代提交”,但不能把固定账号展示为业务申请人。
- 审批人、审批结果、意见和时间线。
- 业务处理结果。
- “立即同步”图标按钮,仅触发 `getapprovaldetail`,不提供审批按钮。
代理退款详情只展示真实业务提交人、审批状态、状态时间和业务处理结果,以及其原有业务权限允许查看的退款资料和业务凭证;接口不向代理返回审批人、内部审批意见和审批人上传附件。
通过后撤销且业务已执行时使用红色异常状态条,明确显示“资金动作未自动冲正,需人工处理”。
### 13.3 企微配置页
@@ -793,7 +805,7 @@ Tab
1. **连接状态**:只显示配置状态和最近连通结果。
2. **审批模板**:按业务场景展示当前版本、模板 ID、验证状态和历史版本。
3. **账号绑定**:查看平台员绑定状态、企微名称和最近验证时间;不允许编辑 userid。
3. **账号绑定**:查看平台/超级管理员绑定状态、企微名称和最近验证时间;不允许编辑 userid;代理固定代提交账号只在连接状态中展示就绪状态和成员显示名
4. **异常审批**:提交未知、模板失效、终态业务处理失败、通过后撤销。
模板发布交互:
@@ -814,10 +826,10 @@ Tab
账号绑定交互:
- 当前用户个人中心显示“未绑定/已绑定/已失效”、企微成员名称和最近验证时间。
- 平台/超级管理员个人中心显示“未绑定/已绑定/已失效”、企微成员名称和最近验证时间;代理个人中心不展示企微绑定能力
- 点击“绑定企业微信”后打开官方企微二维码窗口,原页面显示 5 分钟倒计时。
- 绑定成功后窗口自动关闭,原页面刷新绑定状态;失败时显示企微返回的可操作原因。
- 创建退款或线下充值时发现未绑定,页面原地展示“绑定企业微信”按钮,绑定成功后继续填写,不要求用户先去配置页。
- 平台/超级管理员创建退款或线下充值时发现未绑定,页面原地展示“绑定企业微信”按钮,绑定成功后继续填写,不要求用户先去配置页;代理创建退款不检查个人绑定
- 管理员的账号绑定列表只提供筛选、查看和强制解绑;不提供 userid 输入框。
### 13.4 审批运行页
@@ -829,10 +841,10 @@ Tab
页面用于运行监控,不提供审批动作:
- 按业务类型、企微状态、提交状态和业务处理结果筛选。
- 查询 `sp_no`、业务单号、申请人和模板版本。
- 查询 `sp_no`、业务单号、真实业务提交人、企微发起身份来源和模板版本。
- 查看企微审批详情快照和本地业务处理结果。
- 对审批中记录执行“立即同步”。
- 对提交结果未知记录执行“绑定已有 sp_no”或“确认未创建并重新提交”
- 对提交结果未知记录执行“绑定已有 sp_no”或“确认未创建并重新发送”;后者仍属于同一审批申请的技术恢复
- 对业务处理失败记录查看错误和任务重试状态。
## 十四、Token、日志和限流
@@ -852,11 +864,11 @@ Tab
1. 在企微自建应用配置 Web 登录可信域名和平台员工可见范围。
2. 创建企微模板版本、账号绑定和审批实例表。
3. 发布企微身份 Adapter、账号绑定回调、审批回调、轮询 Worker 和业务终态用例。
4. 要求会发起审批的平台员工完成扫码绑定
4. 要求会发起审批的平台/超级管理员完成扫码绑定,并验证代理固定代提交成员可用
5. 发布并验证退款、线下充值两个模板映射版本。
6. 下线本地审批动作路由和前端按钮。
7. 存量已通过/已拒绝记录保留原业务审批快照,展示 `approval.source=legacy`
8. 存量待审批退款和线下充值仅在创建人已绑定企微时提交;未绑定记录进入迁移待处理列表。
8. 存量待审批记录按创建人类型处理:代理创建的退款使用固定代提交成员;平台/超级管理员创建的退款和线下充值仅在创建人已绑定企微时提交;未绑定或创建人缺失的记录进入迁移待处理列表。
9. 提交失败的存量记录进入异常审批页面,不允许继续走旧接口处理。
## 十六、人工验证
@@ -872,7 +884,8 @@ Tab
9. 回调和轮询同时到达时,只有一个处理器执行业务动作。
10. 附件始终保留本地对象存储 Key企微 media_id 失效不影响历史资料下载。
11. Secret、Token、EncodingAESKey 和附件内容不出现在 API、日志和审计详情中。
12. 已登录平台员扫码后自动获得企微 userid不需要人工查询或录入成员 ID。
12. 已登录平台/超级管理员扫码后自动获得本人企微 userid不需要人工查询或录入成员 ID;代理不展示绑定入口并使用固定账号代提交
13. 绑定 `state` 过期、重复回调、非企业成员扫码时均不会产生绑定。
14. 同一企微 userid 尝试绑定第二个系统账号时被拒绝,原绑定不被覆盖。
15. 自助解绑和管理员强制解绑不影响历史审批快照,但会阻止该账号发起新审批。
16. 同一份退款分别由平台账号和代理账号创建时,前者使用本人绑定身份,后者使用固定代提交身份;两份审批单都正确展示真实业务提交人,通知和审计不会把固定账号当成代理本人。

View File

@@ -1,7 +1,8 @@
# 新增需求 05代理钱包扫码充值
> 状态:已合并至标准评审稿,本文保留为实施明细。
> 状态:已冻结,本文保留为实施明细;如有冲突,以标准评审稿和 UR#34 PRD 为准
> 评审主文档:`../../7月迭代技术方案-标准评审稿.md`
> 实施 PRD`../../../../.scratch/ur34-agent-recharge/PRD.md`
> 范围:代理在后台使用微信或支付宝扫码充值代理主钱包。
## 一、已确认决策
@@ -9,7 +10,7 @@
1. 代理在后台为自己的店铺主钱包充值。
2. 支付方式支持微信扫码和支付宝扫码。
3. 单笔最低充值金额为 100 元,即 `10000` 分。
4. 代理在线充值不进入企业微信审批支付成功后直接幂等增加代理主钱包余额。
4. 代理在线充值不进入企业微信审批支付成功事实先落库,再由可靠 Worker 幂等增加代理主钱包余额。
5. 平台员工线下代充值仍按 `02-企业微信审批接入.md` 走企微审批,与本方案隔离。
6. 后端返回支付二维码内容,前端使用现有二维码组件渲染,不由后端生成或保存二维码图片文件。
7. 微信使用 Native 支付,支付宝使用 `alipay.trade.precreate` 当面付预创建。
@@ -26,7 +27,7 @@
- 微信支付仅保存当前生效支付配置,响应中没有 `code_url`
- 支付宝回调只分发套餐订单和客户资产钱包充值,没有代理充值订单类型。
- `HandlePaymentCallback` 在旧 Service 中直接更新充值单、钱包和流水,业务状态和幂等边界没有收口为独立用例。
- 前端技术方案只写了“保留收款码和支付状态页面”,没有支付方式选择、最低金额、二维码过期和支付成功状态细节。
- 前端技术方案只写了“保留收款码和支付状态页面”,没有支付方式选择、最低金额、第三方状态收敛和支付/入账状态细节。
现有评审中“代理在线充值不审批”的结论保持不变,本次补齐的是扫码支付和最低金额的完整技术方案。
@@ -47,15 +48,15 @@ sequenceDiagram
API->>DB: 创建充值单和支付单
API->>Pay: Native/PreCreate 预下单
Pay-->>API: 二维码内容
API-->>Web: 返回二维码和过期时间
API-->>Web: 原样返回 qr_content
Web-->>Agent: 展示二维码并轮询支付状态
Agent->>Pay: 扫码完成支付
Pay->>Callback: 异步支付通知
Callback->>API: ConfirmAgentRechargePayment
API->>DB: 校验支付单、金额和状态
API->>Wallet: CreditRecharge
Wallet->>DB: 同事务增加余额、写流水、完成充值单
API->>DB: 固化支付成功并写入账 Outbox
API-->>Pay: 返回成功
DB-->>Wallet: Worker 消费入账任务
Wallet->>DB: 独立事务增加余额、写流水、完成充值单
Web->>API: 查询到已完成
Web-->>Agent: 展示最新钱包余额
```
@@ -82,8 +83,9 @@ internal/
│ └── repository.go 钱包与充值聚合仓储接口
├── application/agentrecharge/
│ ├── create_qr_recharge.go 创建充值单和扫码支付
│ ├── confirm_payment.go 支付回调确认并入账
│ ├── close_expired.go 关闭过期未支付充值单
│ ├── confirm_payment.go 支付回调或查单确认支付事实
│ ├── post_wallet.go 可靠任务执行钱包入账
│ ├── sync_pending.go 受控查询待支付第三方订单
│ └── get_payment_status.go 轻量支付状态查询
├── infrastructure/adapter/payment/
│ ├── wechat_native.go 微信 Native 预下单
@@ -104,11 +106,11 @@ internal/
| 1 | 待支付 | 已创建二维码,等待扫码 |
| 2 | 已支付 | 支付已确认,钱包入账事务处理中 |
| 3 | 已完成 | 钱包余额和流水已完成 |
| 4 | 已关闭 | 超时未支付或主动取消 |
| 4 | 已关闭 | 第三方明确未支付且已关闭/失效,或线下审批撤销/删除 |
| 5 | 已退款 | 历史或后续人工退款结果 |
| 6 | 已驳回 | 仅旧数据或线下审批兼容,在线充值不产生 |
`status=2`短暂业务处理状态。支付回调事务正常完成时直接推进到 `3`若钱包入账发生可恢复错误,则保持 `2` 并由可靠任务继续处理。
`status=2`支付事实或审批通过已经固化、钱包正在处理的业务状态。只有独立钱包入账事务成功后才推进到 `3`;发生可恢复错误保持 `2`,使用 `processing_status=3`表达失败并由可靠任务继续处理。
### 4.3 钱包入账不变量
@@ -117,7 +119,7 @@ internal/
- 只允许向目标店铺的 `wallet_type=main` 钱包入账。
- 入账金额必须等于充值单金额和支付单金额。
- 同一充值单只能生成一条成功钱包流水。
- 钱包余额、`version`、充值单状态和钱包流水在同一数据库事务更新
- Worker 在同一数据库事务中更新钱包余额、`version`、充值单状态和钱包流水;支付事实由更早的独立事务固化,钱包失败不得回滚真实收款
- 钱包乐观锁冲突时由 Application 重新加载后有限重试,不能重复创建流水。
- 支付渠道成功不等于业务已经完成;只有钱包事务成功后充值单才变为已完成。
@@ -196,15 +198,20 @@ ali_notify_url
"payment_method": "wechat",
"amount": 10000,
"qr_content": "weixin://wxpay/bizpayurl?...",
"expires_at": "2026-07-15T15:00:00+08:00",
"status": 1,
"status_name": "待支付"
"status_name": "待支付",
"payment_status": 0,
"payment_status_name": "待支付",
"processing_status": 0,
"processing_status_name": "未触发"
}
```
微信返回 `code_url`、支付宝返回 `qr_code`Application 统一映射为 `qr_content`
## 六、支付回调与直接入账
本地无法准确知道第三方订单的真实失效时间,因此接口不返回 `expires_at`,前端不展示本地推算的精确倒计时。第三方支付成功或关闭以回调和后端受控查单为准。
## 六、支付确认与可靠入账
### 6.1 回调校验
@@ -225,16 +232,20 @@ payment.order_type = agent_recharge
### 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
```
支付事实事务:
1.`payment_no` 加载并核验支付单。
2. 支付单从待支付条件更新为已支付。
3. 充值单从 `1=待支付` 更新为 `2=已支付``processing_status=1`
4. 可靠写入钱包入账 Outbox 后向支付渠道返回成功。
钱包入账 Worker 的独立事务:
1. 加载代理主钱包并执行 `CreditRecharge`
2. 更新钱包余额和 `version`
3. 创建唯一钱包流水。
4. 充值单从 `2=已支付` 更新为 `3=已完成``processing_status=2`
5. 写资金 Audit Event 和到账通知事件。
幂等键:
@@ -247,7 +258,7 @@ agent_recharge:{recharge_id}:credit
### 6.3 回调失败恢复
- 第三方校验失败:拒绝回调,不改变业务状态。
- 支付状态已成功、钱包事务失败:充值单保持已支付,写 Outbox/恢复任务继续入账。
- 支付状态已成功、钱包事务失败:充值单保持已支付,`processing_status=3`,由可靠任务继续入账。
- 钱包流水已存在但充值单未完成:恢复任务只补齐充值单状态。
- 重复回调:查询到支付单或充值单已完成后直接返回渠道成功报文。
- 本功能不引入审批,也不会因为支付金额较大转入审批。
@@ -263,16 +274,16 @@ const AgentRechargeMinAmount int64 = 10000
- 单位固定为分。
- DTO 使用 `min=10000`Service/Application 必须再次校验。
- 前端输入单位为元,提交前转换为分。
- 最大金额继续沿用现有系统上限,后续调整单独配置
- 最大金额固定为 `100000000`
- 禁止前端通过浮点数直接计算金额,元转分使用字符串或十进制定点处理。
### 7.2 权限
- 代理只能为当前登录账号所属店铺的主钱包充值。
- 后端从登录上下文校验 `shop_id`,不能只相信请求参数
- 平台和超级管理员可以查看全部充值记录;是否允许代代理发起在线扫码充值保持现有权限
- 后端从登录上下文确定当前代理店铺,在线请求不接受 `shop_id`
- 平台和超级管理员不能替代理发起在线扫码充值,只能为明确的目标 `shop_id` 发起 `offline` 线下代充值
- 企业账号无权访问代理充值接口。
- 代理不能查看其他店铺的充值单、支付状态或二维码
- 创建权限与查看权限分离。代理在具备充值查看权限时,按既有店铺层级数据范围查看本店及有权管理的下级店铺充值;列表、详情和支付状态使用同一范围
## 八、API 设计
@@ -307,7 +318,6 @@ POST /api/admin/agent-recharges
```json
{
"shop_id": 101,
"amount": 10000,
"payment_method": "wechat",
"request_id": "01J2RECHARGE..."
@@ -322,7 +332,7 @@ wechat / alipay / offline
其中 `offline` 仅平台员工线下代充值使用,并继续走企微审批;代理用户只能选择 `wechat/alipay`
`request_id` 用于防止前端重复点击创建多个二维码订单,同一店铺下建立业务唯一约束或 Redis 防重键
`request_id` 用于防止同一次提交的重试重复创建。代理每次主动创建或再次拉起支付都必须使用新的 `request_id`,并创建新的充值单和支付单;此前未付款订单继续等待第三方自然收敛,不按金额复用旧单
### 8.3 查询支付状态
@@ -338,7 +348,8 @@ GET /api/admin/agent-recharges/{id}/payment-status
"payment_status_name": "已支付",
"paid_at": "2026-07-15T14:35:00+08:00",
"completed_at": "2026-07-15T14:35:01+08:00",
"wallet_balance": 510000
"processing_status": 2,
"processing_status_name": "处理成功"
}
```
@@ -355,7 +366,7 @@ GET /api/admin/agent-recharges/{id}/payment-status
- 金额使用数字输入框,单位为元,明确最低 100 元。
- 支付方式使用微信/支付宝分段控件,带对应图标。
- 不可用渠道禁用并显示简短原因。
- 主按钮为“生成支付二维码”
- 主按钮为“立即充值”。按钮提交创建充值请求,后端不提供独立的二维码生成接口
### 9.2 二维码状态
@@ -365,16 +376,15 @@ GET /api/admin/agent-recharges/{id}/payment-status
充值金额
支付方式
二维码
二维码剩余有效时间
支付状态
取消/重新生成
钱包入账状态
```
- 前端使用 `qr_content` 生成二维码,不请求后端图片文件。
- 页面可见时每 3 秒查询一次轻量支付状态。
- 页面隐藏时暂停轮询,恢复可见时立即查询。
- 状态变为已完成、已关闭或离开页面时停止轮询。
- 二维码过期后禁用原二维码,提供“重新生成”命令,重新创建充值单和支付单
- 不显示本地推算的精确过期时间,也不提供后端“取消/重新生成二维码”接口;用户再次主动拉起时按一次全新的充值创建处理
- 支付完成后关闭二维码区域,刷新钱包余额并展示充值成功结果。
- 全流程不展示审批状态或企微审批区块。
@@ -398,12 +408,12 @@ GET /api/admin/agent-recharges/{id}/payment-status
微信/支付宝支付回调成功/失败
钱包入账成功/失败
重复回调被幂等忽略
充值单超时关闭
第三方查单确认支付关闭或失效
```
支付渠道交互写 `tb_integration_log`,钱包余额变化写关键 `Audit Event`,并关联充值单、支付单、代理钱包和钱包流水。
在线充值不产生审批通知。充值成功后可以生成普通资金结果站内通知,但不能显示“审批通过”
在线充值不产生审批通知。目标代理主钱包实际入账后必须生成“充值到账”站内通知;在线实际提交账号与目标代理主账号不同时,两者分别通知并按充值单与接收人防重。平台线下代充值的真实提交人只接收 UR#37 的审批结果通知,除非其本身也是到账通知接收人
## 十一、代码迁移范围
@@ -438,9 +448,9 @@ GET /api/admin/agent-recharges/{id}/payment-status
4. 验证支付宝 PreCreate 返回有效 `qr_code`,前端能够扫码支付。
5. 验证支付回调通过支付单类型分发到代理充值用例。
6. 验证微信、支付宝回调金额不一致时不会增加钱包余额。
7. 验证支付成功后不创建企微审批实例,直接完成钱包入账。
7. 验证支付成功后不创建企微审批实例,先固化支付事实,再由可靠 Worker 完成钱包入账。
8. 验证重复回调只产生一条钱包流水,余额只增加一次。
9. 验证支付成功但钱包事务暂时失败时能够恢复完成,不需要代理重复支付。
10. 验证二维码过期后充值单关闭,旧二维码不能继续显示为有效
10. 验证接口不返回 `expires_at`,前端不展示本地倒计时;第三方明确关闭后充值单关闭,迟到成功回调仍能幂等入账
11. 验证代理充值详情固定返回 `approval_source=none`,不展示审批区域。
12. 验证平台员工线下代充值仍按原企微审批方案执行,不受在线充值改造影响。

View File

@@ -399,7 +399,7 @@ components:
DtoAgentOpenAPIWalletPackageOrderRequest:
properties:
card_nos:
description: 卡标识列表(支持 ICCID、虚拟号、MSISDN
description: 卡标识列表(支持 ICCID、虚拟号、MSISDN、设备 IMEI传入设备 IMEI 时自动转为设备维度购买
items:
type: string
maxItems: 100
@@ -460,6 +460,14 @@ components:
description: 总记录数
type: integer
type: object
DtoAgentRechargeRejectParams:
properties:
rejection_reason:
description: 驳回原因必填最多500字
type: string
required:
- rejection_reason
type: object
DtoAgentRechargeResponse:
properties:
agent_wallet_id:
@@ -499,11 +507,21 @@ components:
description: 第三方支付流水号
type: string
payment_voucher_key:
description: 支付凭证对象存储Key线下支付时存在
type: string
description: 支付凭证对象存储Key列表(线下支付时存在最多5个
items:
type: string
nullable: true
type: array
recharge_no:
description: 充值单号(ARCH前缀)
type: string
rejection_reason:
description: 驳回原因,仅 status=6 时有值
nullable: true
type: string
remark:
description: 运营备注
type: string
shop_id:
description: 店铺ID
minimum: 0
@@ -512,7 +530,7 @@ components:
description: 店铺名称
type: string
status:
description: 状态 (1:待支付, 2:已支付, 3:已完成, 4:已关闭, 5:已退款)
description: 状态 (1:待支付, 2:已支付, 3:已完成, 4:已关闭, 5:已退款, 6:已驳回)
type: integer
status_name:
description: 状态名称(中文)
@@ -1722,6 +1740,14 @@ components:
switch_mode:
description: 切卡模式0=自动1=手动(设备类型时有效)
type: string
total_virtual_remaining_mb:
description: 当前世代所有套餐虚剩余之和MB未请求时为 null
nullable: true
type: number
total_virtual_used_mb:
description: 当前世代所有套餐虚已用之和MB未请求时为 null
nullable: true
type: number
updated_at:
description: 更新时间
format: date-time
@@ -3112,9 +3138,12 @@ components:
description: 支付方式 (wallet:钱包支付, offline:线下支付)
type: string
payment_voucher_key:
description: 线下支付凭证对象存储file_keypayment_method=offline时必填,通过/storage/upload-url上传图片后获得
maxLength: 500
type: string
description: 线下支付凭证对象存储file_key列表payment_method=offline时至少1个最多5个,通过/storage/upload-url上传图片后获得
items:
type: string
maxItems: 5
nullable: true
type: array
required:
- identifier
- package_ids
@@ -3131,7 +3160,15 @@ components:
description: 支付方式 (wechat:微信在线支付, offline:线下转账仅平台可用)
type: string
payment_voucher_key:
description: 支付凭证对象存储Keypayment_method=offline 时必填,微信支付时忽略)
description: 支付凭证对象存储Key列表payment_method=offline 时至少1个最多5个,微信支付时忽略)
items:
type: string
maxItems: 5
nullable: true
type: array
remark:
description: 运营备注(可选,创建后只读)
maxLength: 1000
type: string
shop_id:
description: 目标店铺ID代理只能填自己店铺
@@ -3399,6 +3436,26 @@ components:
description: 提现单号
type: string
type: object
DtoCreateOrderPackageInvalidateTaskRequest:
properties:
file_key:
description: CSV文件对象存储Key通过/storage/upload-url上传后获得CSV单列 order_no
maxLength: 500
type: string
remark:
description: 运营备注(可选,创建后只读)
maxLength: 1000
type: string
voucher_keys:
description: 支付凭证对象存储Key列表可选最多5个
items:
type: string
maxItems: 5
nullable: true
type: array
required:
- file_key
type: object
DtoCreatePackageRequest:
properties:
calendar_type:
@@ -3641,10 +3698,13 @@ components:
maxLength: 1000
type: string
refund_voucher_key:
description: 退款凭证对象存储file_key通过/storage/upload-url上传图片后获得
maxLength: 500
minLength: 1
type: string
description: 退款凭证对象存储file_key列表至少1个最多5个通过/storage/upload-url上传图片后获得
items:
type: string
maxItems: 5
minItems: 1
nullable: true
type: array
requested_refund_amount:
description: 申请退款金额(分)
minimum: 1
@@ -4368,6 +4428,9 @@ components:
description: 创建时间
format: date-time
type: string
creator_name:
description: 操作人姓名
type: string
error_message:
description: 错误信息
type: string
@@ -4443,6 +4506,9 @@ components:
description: 创建时间
format: date-time
type: string
creator_name:
description: 操作人姓名
type: string
error_message:
description: 错误信息
type: string
@@ -5600,6 +5666,9 @@ components:
description: 创建时间
format: date-time
type: string
creator_name:
description: 操作人姓名
type: string
error_message:
description: 错误信息
type: string
@@ -5679,6 +5748,9 @@ components:
description: 创建时间
format: date-time
type: string
creator_name:
description: 操作人姓名
type: string
error_message:
description: 错误信息
type: string
@@ -5719,6 +5791,18 @@ components:
description: 总数
type: integer
type: object
DtoInvalidateFailedItem:
properties:
line:
description: CSV 行号
type: integer
order_no:
description: 订单号
type: string
reason:
description: 失败原因
type: string
type: object
DtoJSSDKConfigResponse:
properties:
app_id:
@@ -6179,6 +6263,144 @@ components:
description: 总数
type: integer
type: object
DtoOrderPackageInvalidateTaskDetailResponse:
properties:
completed_at:
description: 完成时间
nullable: true
type: string
created_at:
description: 创建时间
type: string
creator_name:
description: 操作人姓名
type: string
error_message:
description: 任务级错误信息
type: string
fail_count:
description: 失败数
type: integer
failed_items:
description: 失败记录列表
items:
$ref: '#/components/schemas/DtoInvalidateFailedItem'
nullable: true
type: array
file_name:
description: 原始文件名
type: string
id:
description: 任务ID
minimum: 0
type: integer
remark:
description: 运营备注
type: string
started_at:
description: 开始处理时间
nullable: true
type: string
status:
description: 任务状态 (1:待处理, 2:处理中, 3:已完成, 4:失败)
type: integer
status_name:
description: 状态名称(中文)
type: string
success_count:
description: 成功数
type: integer
task_no:
description: 任务编号
type: string
total_count:
description: 总行数
type: integer
updated_at:
description: 更新时间
type: string
voucher_keys:
description: 支付凭证Key列表
items:
type: string
nullable: true
type: array
type: object
DtoOrderPackageInvalidateTaskListResponse:
properties:
items:
description: 任务列表
items:
$ref: '#/components/schemas/DtoOrderPackageInvalidateTaskResponse'
nullable: true
type: array
page:
description: 当前页码
type: integer
size:
description: 每页条数
type: integer
total:
description: 总记录数
type: integer
type: object
DtoOrderPackageInvalidateTaskResponse:
properties:
completed_at:
description: 完成时间
nullable: true
type: string
created_at:
description: 创建时间
type: string
creator_name:
description: 操作人姓名
type: string
error_message:
description: 任务级错误信息
type: string
fail_count:
description: 失败数
type: integer
file_name:
description: 原始文件名
type: string
id:
description: 任务ID
minimum: 0
type: integer
remark:
description: 运营备注
type: string
started_at:
description: 开始处理时间
nullable: true
type: string
status:
description: 任务状态 (1:待处理, 2:处理中, 3:已完成, 4:失败)
type: integer
status_name:
description: 状态名称(中文)
type: string
success_count:
description: 成功数
type: integer
task_no:
description: 任务编号
type: string
total_count:
description: 总行数
type: integer
updated_at:
description: 更新时间
type: string
voucher_keys:
description: 支付凭证Key列表
items:
type: string
nullable: true
type: array
type: object
DtoOrderResponse:
properties:
actual_paid_amount:
@@ -6291,8 +6513,11 @@ components:
description: 支付状态文本
type: string
payment_voucher_key:
description: 线下支付凭证对象存储file_key线下支付订单有值)
type: string
description: 线下支付凭证对象存储file_key列表(线下支付订单有值最多5个
items:
type: string
nullable: true
type: array
purchase_remark:
description: 购买备注
type: string
@@ -7355,8 +7580,11 @@ components:
description: 退款原因
type: string
refund_voucher_key:
description: 退款凭证对象存储file_key
type: string
description: 退款凭证对象存储file_key列表最多5个
items:
type: string
nullable: true
type: array
reject_reason:
description: 拒绝原因
type: string
@@ -7425,10 +7653,12 @@ components:
nullable: true
type: string
refund_voucher_key:
description: 退款凭证对象存储file_key重新提交时可替换历史记录缺失时必填
maxLength: 500
description: 退款凭证对象存储file_key列表(重新提交时可替换;历史记录缺失时必填最多5个
items:
type: string
maxItems: 5
nullable: true
type: string
type: array
requested_refund_amount:
description: 申请退款金额(分)
minimum: 1
@@ -10356,11 +10586,11 @@ paths:
minimum: 0
nullable: true
type: integer
- description: 按状态过滤 (1:待支付, 2:已支付, 3:已完成, 4:已关闭, 5:已退款)
- description: 按状态过滤 (1:待支付, 2:已支付, 3:已完成, 4:已关闭, 5:已退款, 6:已驳回)
in: query
name: status
schema:
description: 按状态过滤 (1:待支付, 2:已支付, 3:已完成, 4:已关闭, 5:已退款)
description: 按状态过滤 (1:待支付, 2:已支付, 3:已完成, 4:已关闭, 5:已退款, 6:已驳回)
nullable: true
type: integer
- description: 创建时间起始日期(YYYY-MM-DD)
@@ -10632,6 +10862,52 @@ paths:
summary: 确认线下充值
tags:
- 代理预充值
/api/admin/agent-recharges/{id}/reject:
post:
parameters:
- description: ID
in: path
name: id
required: true
schema:
description: ID
minimum: 0
type: integer
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/DtoAgentRechargeRejectParams'
responses:
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 请求参数错误
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 未认证或认证已过期
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 无权访问
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 服务器内部错误
security:
- BearerAuth: []
summary: 驳回代理充值订单
tags:
- 代理预充值
/api/admin/asset-allocation-records:
get:
parameters:
@@ -11990,6 +12266,12 @@ paths:
get:
description: 通过虚拟号/ICCID/IMEI/SN/MSISDN 解析设备或卡的完整详情。企业账号禁止调用。
parameters:
- description: 是否返回当前世代流量汇总字段total_virtual_used_mb / total_virtual_remaining_mb默认 false
in: query
name: include_usage_summary
schema:
description: 是否返回当前世代流量汇总字段total_virtual_used_mb / total_virtual_remaining_mb默认 false
type: boolean
- description: 资产标识符(虚拟号/ICCID/IMEI/SN/MSISDN
in: path
name: identifier
@@ -17426,6 +17708,219 @@ paths:
summary: 批量回收单卡
tags:
- IoT卡管理
/api/admin/order-package-invalidate-tasks:
get:
parameters:
- description: 页码默认1
in: query
name: page
schema:
description: 页码默认1
minimum: 1
type: integer
- description: 每页条数默认20最大100
in: query
name: page_size
schema:
description: 每页条数默认20最大100
maximum: 100
minimum: 1
type: integer
- description: 按状态过滤 (1:待处理, 2:处理中, 3:已完成, 4:失败)
in: query
name: status
schema:
description: 按状态过滤 (1:待处理, 2:处理中, 3:已完成, 4:失败)
maximum: 4
minimum: 1
nullable: true
type: integer
responses:
"200":
content:
application/json:
schema:
properties:
code:
description: 响应码
example: 0
type: integer
data:
$ref: '#/components/schemas/DtoOrderPackageInvalidateTaskListResponse'
msg:
description: 响应消息
example: success
type: string
timestamp:
description: 时间戳
format: date-time
type: string
required:
- code
- msg
- data
- timestamp
type: object
description: 成功
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 请求参数错误
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 未认证或认证已过期
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 无权访问
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 服务器内部错误
security:
- BearerAuth: []
summary: 查询订单套餐失效任务列表
tags:
- 订单套餐失效
post:
description: 上传单列 CSV 文件(列名 order_no批量将订单下的非终态套餐标记为已失效status=4
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/DtoCreateOrderPackageInvalidateTaskRequest'
responses:
"200":
content:
application/json:
schema:
properties:
code:
description: 响应码
example: 0
type: integer
data:
$ref: '#/components/schemas/DtoOrderPackageInvalidateTaskResponse'
msg:
description: 响应消息
example: success
type: string
timestamp:
description: 时间戳
format: date-time
type: string
required:
- code
- msg
- data
- timestamp
type: object
description: 成功
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 请求参数错误
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 未认证或认证已过期
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 无权访问
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 服务器内部错误
security:
- BearerAuth: []
summary: 创建订单套餐批量失效任务
tags:
- 订单套餐失效
/api/admin/order-package-invalidate-tasks/{id}:
get:
parameters:
- description: ID
in: path
name: id
required: true
schema:
description: ID
minimum: 0
type: integer
responses:
"200":
content:
application/json:
schema:
properties:
code:
description: 响应码
example: 0
type: integer
data:
$ref: '#/components/schemas/DtoOrderPackageInvalidateTaskDetailResponse'
msg:
description: 响应消息
example: success
type: string
timestamp:
description: 时间戳
format: date-time
type: string
required:
- code
- msg
- data
- timestamp
type: object
description: 成功
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 请求参数错误
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 未认证或认证已过期
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 无权访问
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 服务器内部错误
security:
- BearerAuth: []
summary: 查询订单套餐失效任务详情
tags:
- 订单套餐失效
/api/admin/orders:
get:
parameters:
@@ -22310,6 +22805,7 @@ paths:
- 代理系列授权
/api/admin/shops:
get:
description: 仅超级管理员、平台账号和代理账号可访问,企业账号返回 403。分页默认第 1 页、每页 20 条page 最小为 1page_size 范围为 1 至 100。所有筛选条件在查询前统一校验。
parameters:
- description: 页码
in: query
@@ -22333,13 +22829,22 @@ paths:
description: 店铺名称模糊查询
maxLength: 100
type: string
- description: 店铺编号模糊查询
- description: 店铺编号精确查询
in: query
name: shop_code
schema:
description: 店铺编号模糊查询
description: 店铺编号精确查询
maxLength: 50
type: string
- description: 联系电话精确查询11位 ASCII 数字;空值不启用筛选;与其他条件按 AND 组合)
in: query
name: contact_phone
schema:
description: 联系电话精确查询11位 ASCII 数字;空值不启用筛选;与其他条件按 AND 组合)
maxLength: 11
minLength: 11
pattern: ^[0-9]{11}$
type: string
- description: 上级店铺ID
in: query
name: parent_id
@@ -22421,6 +22926,7 @@ paths:
tags:
- 店铺管理
post:
description: 仅超级管理员、平台账号和代理账号可访问,企业账号返回 403。
requestBody:
content:
application/json:
@@ -22484,6 +22990,7 @@ paths:
- 店铺管理
/api/admin/shops/{id}:
delete:
description: 仅超级管理员、平台账号和代理账号可访问,企业账号返回 403。
parameters:
- description: ID
in: path
@@ -22524,6 +23031,7 @@ paths:
tags:
- 店铺管理
put:
description: 仅超级管理员、平台账号和代理账号可访问,企业账号返回 403。
parameters:
- description: ID
in: path
@@ -23345,6 +23853,7 @@ paths:
- 代理商资金管理
/api/admin/shops/cascade:
get:
description: 仅超级管理员、平台账号和代理账号可访问,企业账号返回 403。
parameters:
- description: 店铺名称(模糊查询)
in: query

View File

@@ -0,0 +1,89 @@
# UR#60 店铺联系电话精确查询功能总结
## 本次交付范围
本次完成当前后端仓库内可执行的三个纵向切片:
- Ticket 01修复 `GET /api/admin/shops` 的完整查询参数校验与默认分页一致性。
- Ticket 02交付联系电话精确查询、数据库部分索引、OpenAPI 和真实 HTTP 集成验证。
- Ticket 03禁止企业账号访问店铺列表、创建、更新、删除和联级查询五个核心店铺管理入口。
Ticket 02 中的后台页面筛选区、浏览器联调和人工验收需要前端仓库。本仓库未包含前端源码,因此后端交付完成后该票转为 `ready-for-human`,仅等待跨仓前端实施与人工验收。
## 联系电话查询契约
店铺列表新增可选查询参数 `contact_phone`
- 非空值必须完整匹配 `^[0-9]{11}$`,只接受 11 位 ASCII 数字。
- 空字符串和未传参数均不启用电话筛选。
- 不 trim不接受空格、全角数字、`+86`、连字符、字母或长度不符的输入。
- Store 使用 `contact_phone = ?` 等值条件,与店铺名称、编号、上级店铺、层级和状态按 AND 组合。
- 相同电话允许返回多个可见店铺;无匹配项返回成功空分页。
- 保持 `created_at DESC`、软删除排除和代理店铺层级数据范围。
前端接入时应新增“联系电话”输入框。非法值应在前端阻止请求并显示中文提示;合法值以 `contact_phone` 与其他筛选参数一并提交;空值不提交该参数。加载、空态和失败重试沿用现有列表交互,失败后不得将旧结果伪装成新查询结果。
## 参数与分页契约
店铺列表在查询参数解析后执行完整 DTO 校验。解析失败或任一字段违反约束时,客户端统一收到:
```json
{
"code": 1001,
"msg": "参数验证失败",
"data": null,
"timestamp": "RFC3339 时间"
}
```
具体解析或 Validator 错误只写入服务端日志,不返回给客户端。未传分页参数时,请求在进入 Service 前归一化为 `page=1``page_size=20`,数据库查询参数与响应中的 `page``size` 保持一致;显式合法分页值原样生效。
## 核心店铺管理权限
企业账号访问以下入口统一收到 HTTP 403、`code=1005``msg=无权限访问店铺管理功能`
- `GET /api/admin/shops`
- `POST /api/admin/shops`
- `PUT /api/admin/shops/:id`
- `DELETE /api/admin/shops/:id`
- `GET /api/admin/shops/cascade`
限制通过逐路由 Handler 包装实现,不使用 `/shops` 前缀组中间件,避免误伤店铺角色、资金、佣金、提现和钱包流水等独立路由。超级管理员、平台账号和代理账号保留原有核心列表访问能力,代理账号继续使用既有店铺及下级范围过滤。
## 架构与迁移边界
实现继续沿用现有 `Handler → Service → Store → GORM/DTO` 读取链路,只修改请求校验、筛选条件、分页归一化和路由权限边界。未迁移店铺模块到 DDD 或 Query 目录,未修改店铺创建/更新电话规则,也未新增缓存、审计业务日志、幂等控制或异步任务。
数据库迁移 `000160_add_shop_contact_phone_index` 创建非唯一部分 B-tree 索引 `idx_shop_contact_phone`
```sql
CREATE INDEX idx_shop_contact_phone
ON tb_shop USING btree (contact_phone)
WHERE deleted_at IS NULL;
```
回滚只删除该索引,不清洗、回填或删除历史联系电话,也不改变业务数据。
## 验证证据
HTTP 集成测试在进程内启动 Fiber App通过真实 Redis Token 状态、后台认证中间件、真实 PostgreSQL、Handler、Service、Store、GORM 和统一错误处理验证:
- 非法分页、层级、状态、超长名称或编号及解析失败均返回统一参数错误。
- 默认分页返回第 1 页、每页 20 条,显式合法分页不被重置。
- 未认证和无效 Token 保持既有认证错误契约。
- 企业账号在五个核心入口进入 Handler 前被拒绝。
- 超级管理员、平台账号和代理账号保留列表访问能力。
- 代理账号的联系电话结果只包含自身及全部下级店铺,范围外同电话店铺不可见。
- 店铺角色、资金概况、提现、佣金和钱包流水路由未被核心店铺管理权限包装误拦截。
- 联系电话精确匹配、重复号码、AND 组合、空参数、非法格式、空结果、显式分页、排序和软删除语义均通过真实 HTTP 验证。
- 非法联系电话在执行店铺查询前被拒绝;数据库查询失败返回脱敏的 HTTP 500。
- 索引已在开发库完成 `160 up → 159 down → 160 up` 演练,并通过 PostgreSQL 系统目录确认 B-tree、非唯一、列和 `deleted_at IS NULL` 条件。
- OpenAPI 由 `go run ./cmd/gendocs` 生成并核验 `contact_phone`、正则、精确查询、AND/空值和权限说明。
测试使用唯一 Redis Token 并在结束后清理,不打印 `.env.local` 中的数据库、Redis 或 JWT 敏感配置。
## 剩余人工交付
- 在后台前端仓库实现联系电话输入、校验、查询、清空、加载、空态和失败重试。
- 使用浏览器网络面板与真实接口共同核验请求参数和响应。
- 完成超级管理员、平台、代理和企业四类账号人工验收后更新需求状态。

View File

@@ -84,7 +84,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
ClientRealname: app.NewClientRealnameHandler(svc.Asset, svc.CustomerBinding, iotCardStore, deviceSimBindingStore, carrierStore, deps.GatewayClient, deps.Logger, svc.PollingManualTrigger),
ClientDevice: app.NewClientDeviceHandler(svc.Asset, svc.CustomerBinding, deviceStore, deviceSimBindingStore, iotCardStore, deps.GatewayClient, deps.Logger),
ClientRechargeOrder: app.NewClientRechargeOrderHandler(rechargeOrderStore, paymentStore, deps.Logger),
Shop: admin.NewShopHandler(svc.Shop),
Shop: admin.NewShopHandler(svc.Shop, validate),
ShopRole: admin.NewShopRoleHandler(svc.Shop),
AdminAuth: admin.NewAuthHandler(svc.Auth, validate),
ShopCommission: admin.NewShopCommissionHandler(svc.ShopCommission),

View File

@@ -3,27 +3,51 @@ package admin
import (
"strconv"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"go.uber.org/zap"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
shopService "github.com/break/junhong_cmp_fiber/internal/service/shop"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/logger"
"github.com/break/junhong_cmp_fiber/pkg/response"
)
// ShopHandler 店铺管理处理器。
type ShopHandler struct {
service *shopService.Service
service *shopService.Service
validator *validator.Validate
}
func NewShopHandler(service *shopService.Service) *ShopHandler {
return &ShopHandler{service: service}
// NewShopHandler 创建店铺管理处理器。
func NewShopHandler(service *shopService.Service, validator *validator.Validate) *ShopHandler {
return &ShopHandler{service: service, validator: validator}
}
// List 查询店铺列表。
// GET /api/admin/shops
func (h *ShopHandler) List(c *fiber.Ctx) error {
var req dto.ShopListRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
h.logListValidationFailure(c, err)
return errors.New(errors.CodeInvalidParam)
}
if hasExplicitZeroPagination(c, &req) {
err := errors.New(errors.CodeInvalidParam)
h.logListValidationFailure(c, err)
return err
}
if h.validator == nil {
return errors.New(errors.CodeInternalError, "店铺列表校验器未配置")
}
if err := h.validator.Struct(&req); err != nil {
h.logListValidationFailure(c, err)
return errors.New(errors.CodeInvalidParam)
}
normalizeShopListPagination(&req)
shops, total, err := h.service.ListShopResponses(c.UserContext(), &req)
if err != nil {
@@ -33,6 +57,31 @@ func (h *ShopHandler) List(c *fiber.Ctx) error {
return response.SuccessWithPagination(c, shops, total, req.Page, req.PageSize)
}
func hasExplicitZeroPagination(c *fiber.Ctx, req *dto.ShopListRequest) bool {
queryArgs := c.Context().QueryArgs()
return queryArgs.Has("page") && req.Page == 0 || queryArgs.Has("page_size") && req.PageSize == 0
}
func (h *ShopHandler) logListValidationFailure(c *fiber.Ctx, err error) {
logger.GetAppLogger().Warn("店铺列表参数验证失败",
zap.String("method", c.Method()),
zap.String("path", c.Path()),
zap.String("query", c.Context().QueryArgs().String()),
zap.Error(err),
)
}
func normalizeShopListPagination(req *dto.ShopListRequest) {
if req.Page == 0 {
req.Page = constants.DefaultPage
}
if req.PageSize == 0 {
req.PageSize = constants.DefaultPageSize
}
}
// Create 创建店铺。
// POST /api/admin/shops
func (h *ShopHandler) Create(c *fiber.Ctx) error {
var req dto.CreateShopRequest
if err := c.BodyParser(&req); err != nil {
@@ -47,6 +96,8 @@ func (h *ShopHandler) Create(c *fiber.Ctx) error {
return response.Success(c, shop)
}
// Update 更新店铺。
// PUT /api/admin/shops/:id
func (h *ShopHandler) Update(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
@@ -66,6 +117,8 @@ func (h *ShopHandler) Update(c *fiber.Ctx) error {
return response.Success(c, shop)
}
// Delete 删除店铺。
// DELETE /api/admin/shops/:id
func (h *ShopHandler) Delete(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {

View File

@@ -1,13 +1,14 @@
package dto
type ShopListRequest struct {
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"`
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"`
ShopName string `json:"shop_name" query:"shop_name" validate:"omitempty,max=100" maxLength:"100" description:"店铺名称模糊查询"`
ShopCode string `json:"shop_code" query:"shop_code" validate:"omitempty,max=50" maxLength:"50" description:"店铺编号模糊查询"`
ParentID *uint `json:"parent_id" query:"parent_id" validate:"omitempty,min=1" minimum:"1" description:"上级店铺ID"`
Level *int `json:"level" query:"level" validate:"omitempty,min=1,max=7" minimum:"1" maximum:"7" description:"店铺层级 (1-7级)"`
Status *int `json:"status" query:"status" validate:"omitempty,oneof=0 1" description:"状态 (0:禁用, 1:启用)"`
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"`
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"`
ShopName string `json:"shop_name" query:"shop_name" validate:"omitempty,max=100" maxLength:"100" description:"店铺名称模糊查询"`
ShopCode string `json:"shop_code" query:"shop_code" validate:"omitempty,max=50" maxLength:"50" description:"店铺编号精确查询"`
ContactPhone string `json:"contact_phone" query:"contact_phone" validate:"omitempty,len=11,numeric,ascii" minLength:"11" maxLength:"11" pattern:"^[0-9]{11}$" description:"联系电话精确查询11位 ASCII 数字;空值不启用筛选;与其他条件按 AND 组合)"`
ParentID *uint `json:"parent_id" query:"parent_id" validate:"omitempty,min=1" minimum:"1" description:"上级店铺ID"`
Level *int `json:"level" query:"level" validate:"omitempty,min=1,max=7" minimum:"1" maximum:"7" description:"店铺层级 (1-7级)"`
Status *int `json:"status" query:"status" validate:"omitempty,oneof=0 1" description:"状态 (0:禁用, 1:启用)"`
}
type CreateShopRequest struct {

View File

@@ -5,6 +5,9 @@ import (
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/pkg/openapi"
)
@@ -12,47 +15,61 @@ func registerShopRoutes(router fiber.Router, handler *admin.ShopHandler, doc *op
shops := router.Group("/shops")
groupPath := basePath + "/shops"
Register(shops, doc, groupPath, "GET", "", handler.List, RouteSpec{
Summary: "店铺列表",
Tags: []string{"店铺管理"},
Input: new(dto.ShopListRequest),
Output: new(dto.ShopPageResult),
Auth: true,
Register(shops, doc, groupPath, "GET", "", coreShopManagement(handler.List), RouteSpec{
Summary: "店铺列表",
Description: constants.ShopManagementAccessDescription + constants.ShopListPaginationDescription,
Tags: []string{"店铺管理"},
Input: new(dto.ShopListRequest),
Output: new(dto.ShopPageResult),
Auth: true,
})
Register(shops, doc, groupPath, "POST", "", handler.Create, RouteSpec{
Summary: "创建店铺",
Tags: []string{"店铺管理"},
Input: new(dto.CreateShopRequest),
Output: new(dto.ShopResponse),
Auth: true,
Register(shops, doc, groupPath, "POST", "", coreShopManagement(handler.Create), RouteSpec{
Summary: "创建店铺",
Description: constants.ShopManagementAccessDescription,
Tags: []string{"店铺管理"},
Input: new(dto.CreateShopRequest),
Output: new(dto.ShopResponse),
Auth: true,
})
Register(shops, doc, groupPath, "PUT", "/:id", handler.Update, RouteSpec{
Summary: "更新店铺",
Tags: []string{"店铺管理"},
Input: new(dto.UpdateShopParams),
Output: new(dto.ShopResponse),
Auth: true,
Register(shops, doc, groupPath, "PUT", "/:id", coreShopManagement(handler.Update), RouteSpec{
Summary: "更新店铺",
Description: constants.ShopManagementAccessDescription,
Tags: []string{"店铺管理"},
Input: new(dto.UpdateShopParams),
Output: new(dto.ShopResponse),
Auth: true,
})
Register(shops, doc, groupPath, "DELETE", "/:id", handler.Delete, RouteSpec{
Summary: "删除店铺",
Tags: []string{"店铺管理"},
Input: new(dto.IDReq),
Output: nil,
Auth: true,
Register(shops, doc, groupPath, "DELETE", "/:id", coreShopManagement(handler.Delete), RouteSpec{
Summary: "删除店铺",
Description: constants.ShopManagementAccessDescription,
Tags: []string{"店铺管理"},
Input: new(dto.IDReq),
Output: nil,
Auth: true,
})
Register(shops, doc, groupPath, "GET", "/cascade", handler.Cascade, RouteSpec{
Summary: "店铺联级查询",
Tags: []string{"店铺管理"},
Input: new(dto.ShopCascadeRequest),
Output: new([]dto.ShopCascadeItem),
Auth: true,
Register(shops, doc, groupPath, "GET", "/cascade", coreShopManagement(handler.Cascade), RouteSpec{
Summary: "店铺联级查询",
Description: constants.ShopManagementAccessDescription,
Tags: []string{"店铺管理"},
Input: new(dto.ShopCascadeRequest),
Output: new([]dto.ShopCascadeItem),
Auth: true,
})
}
func coreShopManagement(handler fiber.Handler) fiber.Handler {
return func(c *fiber.Ctx) error {
if middleware.GetUserTypeFromContext(c.UserContext()) == constants.UserTypeEnterprise {
return errors.New(errors.CodeForbidden, constants.ShopManagementForbiddenMessage)
}
return handler(c)
}
}
func registerShopRoleRoutes(router fiber.Router, handler *admin.ShopRoleHandler, doc *openapi.Generator, basePath string) {
shops := router.Group("/shops")
groupPath := basePath + "/shops"

View File

@@ -0,0 +1,637 @@
package routes
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/bytedance/sonic"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/bootstrap"
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
internalMiddleware "github.com/break/junhong_cmp_fiber/internal/middleware"
"github.com/break/junhong_cmp_fiber/internal/model"
shopService "github.com/break/junhong_cmp_fiber/internal/service/shop"
shopCommissionService "github.com/break/junhong_cmp_fiber/internal/service/shop_commission"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/auth"
"github.com/break/junhong_cmp_fiber/pkg/config"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/database"
"github.com/break/junhong_cmp_fiber/pkg/errors"
pkgMiddleware "github.com/break/junhong_cmp_fiber/pkg/middleware"
)
type shopTestResponse struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data shopTestPageData `json:"data"`
}
type shopTestPageData struct {
Items []map[string]any `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
Size int `json:"size"`
}
type shopTestEnv struct {
app *fiber.App
db *gorm.DB
redis *redis.Client
tokenManager *auth.TokenManager
}
// TestShopListValidatesAllQueryParameters 验证店铺列表会完整校验所有查询参数。
func TestShopListValidatesAllQueryParameters(t *testing.T) {
env := newShopTestEnv(t)
token := env.newToken(t, auth.TokenInfo{
UserID: uniqueTestID(),
UserType: constants.UserTypeSuperAdmin,
Username: "ur60-super-admin",
})
testCases := []struct {
name string
query string
}{
{name: "页码小于一", query: "page=0"},
{name: "每页数量小于一", query: "page_size=0"},
{name: "每页数量超过上限", query: "page_size=101"},
{name: "店铺层级非法", query: "level=8"},
{name: "店铺状态非法", query: "status=2"},
{name: "店铺名称过长", query: "shop_name=" + strings.Repeat("店", 101)},
{name: "店铺编号过长", query: "shop_code=" + strings.Repeat("A", 51)},
{name: "参数解析失败", query: "parent_id=invalid"},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
status, body := env.request(t, http.MethodGet, "/api/admin/shops?"+testCase.query, token)
if status != http.StatusBadRequest {
t.Fatalf("期望 HTTP 400实际为 %d响应%s", status, body)
}
var response struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data any `json:"data"`
}
if err := sonic.Unmarshal(body, &response); err != nil {
t.Fatalf("解析响应失败:%v", err)
}
if response.Code != errors.CodeInvalidParam || response.Msg != "参数验证失败" || response.Data != nil {
t.Fatalf("参数错误响应不符合契约:%s", body)
}
if !strings.Contains(string(body), "timestamp") {
t.Fatalf("参数错误响应缺少时间戳:%s", body)
}
if strings.Contains(string(body), "validation") || strings.Contains(string(body), "strconv") {
t.Fatalf("参数错误响应泄露底层细节:%s", body)
}
})
}
}
// TestShopListUsesNormalizedAndExplicitPagination 验证默认分页归一化且显式分页保持不变。
func TestShopListUsesNormalizedAndExplicitPagination(t *testing.T) {
env := newShopTestEnv(t)
token := env.newToken(t, auth.TokenInfo{
UserID: uniqueTestID(),
UserType: constants.UserTypeSuperAdmin,
Username: "ur60-super-admin",
})
testCases := []struct {
name string
query string
expectedPage int
expectedSize int
}{
{name: "默认分页", expectedPage: constants.DefaultPage, expectedSize: constants.DefaultPageSize},
{name: "显式分页", query: "?page=2&page_size=7&shop_name=ur60-no-match", expectedPage: 2, expectedSize: 7},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
status, body := env.request(t, http.MethodGet, "/api/admin/shops"+testCase.query, token)
if status != http.StatusOK {
t.Fatalf("期望 HTTP 200实际为 %d响应%s", status, body)
}
var response shopTestResponse
if err := sonic.Unmarshal(body, &response); err != nil {
t.Fatalf("解析响应失败:%v", err)
}
if response.Code != errors.CodeSuccess || response.Data.Page != testCase.expectedPage || response.Data.Size != testCase.expectedSize {
t.Fatalf("分页响应不符合契约:%s", body)
}
})
}
}
// TestShopListKeepsAuthenticationContract 验证店铺列表保持既有认证错误契约。
func TestShopListKeepsAuthenticationContract(t *testing.T) {
env := newShopTestEnv(t)
testCases := []struct {
name string
token string
}{
{name: "未认证"},
{name: "无效令牌", token: "ur60-invalid-token"},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
status, body := env.request(t, http.MethodGet, "/api/admin/shops", testCase.token)
if status != http.StatusUnauthorized {
t.Fatalf("期望 HTTP 401实际为 %d响应%s", status, body)
}
if !strings.Contains(string(body), "timestamp") {
t.Fatalf("认证错误响应缺少时间戳:%s", body)
}
})
}
}
// TestEnterpriseCannotAccessCoreShopManagementRoutes 验证企业账号无法访问五个核心店铺管理入口。
func TestEnterpriseCannotAccessCoreShopManagementRoutes(t *testing.T) {
env := newShopTestEnv(t)
token := env.newToken(t, auth.TokenInfo{
UserID: uniqueTestID(),
UserType: constants.UserTypeEnterprise,
EnterpriseID: uniqueTestID(),
Username: "ur60-enterprise",
})
testCases := []struct {
name string
method string
path string
}{
{name: "店铺列表", method: http.MethodGet, path: "/api/admin/shops"},
{name: "创建店铺", method: http.MethodPost, path: "/api/admin/shops"},
{name: "更新店铺", method: http.MethodPut, path: "/api/admin/shops/1"},
{name: "删除店铺", method: http.MethodDelete, path: "/api/admin/shops/1"},
{name: "联级查询", method: http.MethodGet, path: "/api/admin/shops/cascade"},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
status, body := env.request(t, testCase.method, testCase.path, token)
if status != http.StatusForbidden {
t.Fatalf("期望 HTTP 403实际为 %d响应%s", status, body)
}
var response struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data any `json:"data"`
}
if err := sonic.Unmarshal(body, &response); err != nil {
t.Fatalf("解析响应失败:%v", err)
}
if response.Code != errors.CodeForbidden || response.Msg != constants.ShopManagementForbiddenMessage || response.Data != nil {
t.Fatalf("企业账号禁止响应不符合契约:%s", body)
}
if !strings.Contains(string(body), "timestamp") {
t.Fatalf("企业账号禁止响应缺少时间戳:%s", body)
}
})
}
}
// TestCoreShopRestrictionDoesNotAffectOtherShopRoutes 验证核心店铺权限限制不影响其他店铺前缀路由。
func TestCoreShopRestrictionDoesNotAffectOtherShopRoutes(t *testing.T) {
env := newShopTestEnv(t)
token := env.newToken(t, auth.TokenInfo{
UserID: uniqueTestID(),
UserType: constants.UserTypeEnterprise,
EnterpriseID: uniqueTestID(),
Username: "ur60-enterprise",
})
paths := []string{
"/api/admin/shops/1/roles",
"/api/admin/shops/fund-summary",
"/api/admin/shops/1/withdrawal-requests",
"/api/admin/shops/1/commission-records",
"/api/admin/shops/1/main-wallet/transactions",
}
for _, path := range paths {
status, body := env.request(t, http.MethodGet, path, token)
if status == http.StatusForbidden && strings.Contains(string(body), constants.ShopManagementForbiddenMessage) {
t.Fatalf("核心店铺管理拦截误伤独立路由 %s%s", path, body)
}
}
}
// TestNonEnterpriseAccountsKeepCoreShopListAccess 验证非企业账号保留核心店铺列表访问能力。
func TestNonEnterpriseAccountsKeepCoreShopListAccess(t *testing.T) {
env := newShopTestEnv(t)
testCases := []struct {
name string
userType int
shopID uint
}{
{name: "超级管理员", userType: constants.UserTypeSuperAdmin},
{name: "平台账号", userType: constants.UserTypePlatform},
{name: "代理账号", userType: constants.UserTypeAgent, shopID: 1},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
token := env.newToken(t, auth.TokenInfo{
UserID: uniqueTestID(),
UserType: testCase.userType,
ShopID: testCase.shopID,
Username: "ur60-allowed-user",
})
status, body := env.request(t, http.MethodGet, "/api/admin/shops?page=1&page_size=1", token)
if status != http.StatusOK {
t.Fatalf("期望保留列表访问能力,实际 HTTP %d响应%s", status, body)
}
})
}
}
// TestShopListFiltersContactPhoneExactly 验证联系电话精确匹配、AND 组合、重复号码、排序及软删除语义。
func TestShopListFiltersContactPhoneExactly(t *testing.T) {
env := newShopTestEnv(t)
phone := "13800000000"
parent := env.createShop(t, "UR60父店", phone, nil, 1, time.Now().Add(-4*time.Minute))
newer := env.createShop(t, "UR60目标新店", phone, &parent.ID, 2, time.Now().Add(-time.Minute))
older := env.createShop(t, "UR60目标旧店", phone, &parent.ID, 2, time.Now().Add(-2*time.Minute))
env.createShop(t, "UR60相似号码", "13800000001", &parent.ID, 2, time.Now())
deleted := env.createShop(t, "UR60软删除店", phone, &parent.ID, 2, time.Now().Add(time.Minute))
if err := env.db.Delete(deleted).Error; err != nil {
t.Fatalf("软删除测试店铺失败:%v", err)
}
token := env.newToken(t, auth.TokenInfo{
UserID: uniqueTestID(),
UserType: constants.UserTypeSuperAdmin,
Username: "ur60-super-admin",
})
status, body := env.request(t, http.MethodGet, "/api/admin/shops?contact_phone="+phone+"&shop_name=UR60目标&parent_id="+strconv.FormatUint(uint64(parent.ID), 10)+"&level=2&status=1", token)
if status != http.StatusOK {
t.Fatalf("联系电话组合查询失败HTTP %d%s", status, body)
}
var response shopTestResponse
if err := sonic.Unmarshal(body, &response); err != nil {
t.Fatalf("解析联系电话查询响应失败:%v", err)
}
if response.Data.Total != 2 || len(response.Data.Items) != 2 {
t.Fatalf("联系电话组合查询应返回两条可见记录:%s", body)
}
if uint(response.Data.Items[0]["id"].(float64)) != newer.ID || uint(response.Data.Items[1]["id"].(float64)) != older.ID {
t.Fatalf("联系电话查询未保持 created_at DESC%s", body)
}
status, body = env.request(t, http.MethodGet, "/api/admin/shops?contact_phone=13800000001&shop_code="+newer.ShopCode, token)
if status != http.StatusOK {
t.Fatalf("联系电话精确性查询失败HTTP %d%s", status, body)
}
if err := sonic.Unmarshal(body, &response); err != nil {
t.Fatalf("解析联系电话精确性响应失败:%v", err)
}
if response.Data.Total != 0 {
t.Fatalf("联系电话必须与其他条件按 AND 精确匹配:%s", body)
}
platformToken := env.newToken(t, auth.TokenInfo{
UserID: uniqueTestID(),
UserType: constants.UserTypePlatform,
Username: "ur60-platform",
})
status, body = env.request(t, http.MethodGet, "/api/admin/shops?contact_phone="+phone+"&shop_name=UR60目标", platformToken)
if status != http.StatusOK {
t.Fatalf("平台账号联系电话查询失败HTTP %d%s", status, body)
}
if err := sonic.Unmarshal(body, &response); err != nil {
t.Fatalf("解析平台账号联系电话响应失败:%v", err)
}
if response.Data.Total != 2 {
t.Fatalf("平台账号联系电话查询结果不符合契约:%s", body)
}
}
// TestShopListContactPhoneKeepsEmptyAndPaginationSemantics 验证空电话、空结果和显式分页契约。
func TestShopListContactPhoneKeepsEmptyAndPaginationSemantics(t *testing.T) {
env := newShopTestEnv(t)
token := env.newToken(t, auth.TokenInfo{UserID: uniqueTestID(), UserType: constants.UserTypeSuperAdmin, Username: "ur60-super-admin"})
status, body := env.request(t, http.MethodGet, "/api/admin/shops?contact_phone=&page=2&page_size=3", token)
if status != http.StatusOK {
t.Fatalf("空联系电话不应改变列表语义HTTP %d%s", status, body)
}
var response shopTestResponse
if err := sonic.Unmarshal(body, &response); err != nil {
t.Fatalf("解析空联系电话响应失败:%v", err)
}
if response.Data.Page != 2 || response.Data.Size != 3 {
t.Fatalf("联系电话筛选不应重置显式分页:%s", body)
}
status, body = env.request(t, http.MethodGet, "/api/admin/shops?contact_phone=19999999999", token)
if status != http.StatusOK {
t.Fatalf("无匹配联系电话应返回成功空分页HTTP %d%s", status, body)
}
if err := sonic.Unmarshal(body, &response); err != nil {
t.Fatalf("解析空结果响应失败:%v", err)
}
if response.Data.Total != 0 || len(response.Data.Items) != 0 {
t.Fatalf("无匹配联系电话响应不符合空分页契约:%s", body)
}
}
// TestShopListRejectsInvalidContactPhoneBeforeQuery 验证非法联系电话在执行店铺查询前被拒绝。
func TestShopListRejectsInvalidContactPhoneBeforeQuery(t *testing.T) {
env := newShopTestEnv(t)
token := env.newToken(t, auth.TokenInfo{UserID: uniqueTestID(), UserType: constants.UserTypeSuperAdmin, Username: "ur60-super-admin"})
var shopQueryCount atomic.Int64
callbackName := fmt.Sprintf("ur60:count_shop_queries:%d", time.Now().UnixNano())
if err := env.db.Callback().Query().Before("gorm:query").Register(callbackName, func(db *gorm.DB) {
if db.Statement != nil && db.Statement.Table == (model.Shop{}).TableName() {
shopQueryCount.Add(1)
}
}); err != nil {
t.Fatalf("注册店铺查询计数回调失败:%v", err)
}
t.Cleanup(func() { _ = env.db.Callback().Query().Remove(callbackName) })
invalidPhones := []string{
"1380000000", "138000000000", "1380000000A", "+8613800000000",
"138-0000-0000", " 13800000000", "",
}
for _, phone := range invalidPhones {
shopQueryCount.Store(0)
status, body := env.request(t, http.MethodGet, "/api/admin/shops?contact_phone="+url.QueryEscape(phone), token)
if status != http.StatusBadRequest || shopQueryCount.Load() != 0 {
t.Fatalf("非法联系电话应在查询前返回 400phone=%q, queries=%d, body=%s", phone, shopQueryCount.Load(), body)
}
}
}
// TestAgentContactPhoneSearchKeepsShopScope 验证代理账号无法通过联系电话越过店铺层级范围。
func TestAgentContactPhoneSearchKeepsShopScope(t *testing.T) {
env := newShopTestEnv(t)
phone := "13700000000"
root := env.createShop(t, "UR60代理根店", phone, nil, 1, time.Now().Add(-3*time.Minute))
child := env.createShop(t, "UR60代理下级", phone, &root.ID, 2, time.Now().Add(-2*time.Minute))
env.createShop(t, "UR60范围外店铺", phone, nil, 1, time.Now().Add(-time.Minute))
token := env.newToken(t, auth.TokenInfo{UserID: uniqueTestID(), UserType: constants.UserTypeAgent, ShopID: root.ID, Username: "ur60-agent"})
status, body := env.request(t, http.MethodGet, "/api/admin/shops?contact_phone="+phone, token)
if status != http.StatusOK {
t.Fatalf("代理联系电话查询失败HTTP %d%s", status, body)
}
var response shopTestResponse
if err := sonic.Unmarshal(body, &response); err != nil {
t.Fatalf("解析代理联系电话响应失败:%v", err)
}
if response.Data.Total != 2 {
t.Fatalf("代理联系电话查询应只返回自身及下级:%s", body)
}
returned := map[uint]bool{}
for _, item := range response.Data.Items {
returned[uint(item["id"].(float64))] = true
}
if !returned[root.ID] || !returned[child.ID] {
t.Fatalf("代理联系电话查询缺少范围内店铺:%s", body)
}
}
// TestShopListDatabaseFailureIsSanitized 验证数据库错误通过统一 500 响应脱敏。
func TestShopListDatabaseFailureIsSanitized(t *testing.T) {
env := newShopTestEnv(t)
token := env.newToken(t, auth.TokenInfo{UserID: uniqueTestID(), UserType: constants.UserTypeSuperAdmin, Username: "ur60-super-admin"})
sqlDB, err := env.db.DB()
if err != nil {
t.Fatalf("获取测试数据库连接失败:%v", err)
}
if err := sqlDB.Close(); err != nil {
t.Fatalf("关闭测试数据库连接失败:%v", err)
}
status, body := env.request(t, http.MethodGet, "/api/admin/shops?contact_phone=13800000000", token)
if status != http.StatusInternalServerError {
t.Fatalf("数据库失败应返回 HTTP 500实际 %d%s", status, body)
}
var response struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data any `json:"data"`
}
if err := sonic.Unmarshal(body, &response); err != nil {
t.Fatalf("解析数据库错误响应失败:%v", err)
}
if response.Code != errors.CodeInternalError || response.Msg != "内部服务器错误" || response.Data != nil {
t.Fatalf("数据库错误响应不符合脱敏契约:%s", body)
}
for _, leaked := range []string{"sql", "postgres", "host", "driver", "database is closed"} {
if strings.Contains(strings.ToLower(string(body)), leaked) {
t.Fatalf("数据库错误响应泄露底层信息 %q%s", leaked, body)
}
}
}
// TestShopContactPhoneIndexDefinition 验证联系电话索引的类型、唯一性和部分条件。
func TestShopContactPhoneIndexDefinition(t *testing.T) {
env := newShopTestEnv(t)
var indexCount int64
if err := env.db.Raw(`
SELECT COUNT(*)
FROM pg_class
WHERE relname = ?
`, "idx_shop_contact_phone").Scan(&indexCount).Error; err != nil {
t.Fatalf("查询联系电话索引数量失败:%v", err)
}
if os.Getenv("UR60_EXPECT_INDEX_ABSENT") == "1" {
if indexCount != 0 {
t.Fatalf("回滚后联系电话索引仍然存在")
}
return
}
if indexCount != 1 {
t.Fatalf("联系电话索引数量异常:%d", indexCount)
}
var index struct {
AccessMethod string `gorm:"column:access_method"`
IsUnique bool `gorm:"column:is_unique"`
Definition string `gorm:"column:definition"`
Predicate string `gorm:"column:predicate"`
}
err := env.db.Raw(`
SELECT am.amname AS access_method,
ix.indisunique AS is_unique,
pg_get_indexdef(ix.indexrelid) AS definition,
pg_get_expr(ix.indpred, ix.indrelid) AS predicate
FROM pg_index ix
JOIN pg_class i ON i.oid = ix.indexrelid
JOIN pg_am am ON am.oid = i.relam
WHERE i.relname = ?
`, "idx_shop_contact_phone").Scan(&index).Error
if err != nil {
t.Fatalf("查询联系电话索引定义失败:%v", err)
}
if index.AccessMethod != "btree" || index.IsUnique || !strings.Contains(index.Definition, "contact_phone") || !strings.Contains(index.Predicate, "deleted_at IS NULL") {
t.Fatalf("联系电话索引定义不符合契约:%+v", index)
}
}
func newShopTestEnv(t *testing.T) *shopTestEnv {
t.Helper()
if os.Getenv("JUNHONG_DATABASE_HOST") == "" || os.Getenv("JUNHONG_REDIS_ADDRESS") == "" {
t.Skip("未加载 .env.local跳过依赖真实 PostgreSQL 和 Redis 的店铺 HTTP 集成测试")
}
cfg, err := config.Load()
if err != nil {
t.Fatalf("加载测试配置失败:%v", err)
}
logger := zap.NewNop()
db, err := database.InitPostgreSQL(&cfg.Database, logger)
if err != nil {
t.Fatalf("连接 PostgreSQL 失败:%v", err)
}
redisClient, err := database.NewRedisClient(database.RedisConfig{
Address: cfg.Redis.Address + ":" + strconv.Itoa(cfg.Redis.Port),
Password: cfg.Redis.Password,
DB: cfg.Redis.DB,
PoolSize: cfg.Redis.PoolSize,
MinIdleConns: cfg.Redis.MinIdleConns,
DialTimeout: cfg.Redis.DialTimeout,
ReadTimeout: cfg.Redis.ReadTimeout,
WriteTimeout: cfg.Redis.WriteTimeout,
}, logger)
if err != nil {
t.Fatalf("连接 Redis 失败:%v", err)
}
t.Cleanup(func() {
_ = redisClient.Close()
if sqlDB, dbErr := db.DB(); dbErr == nil {
_ = sqlDB.Close()
}
})
shopStore := postgres.NewShopStore(db, redisClient)
service := shopService.New(shopStore, nil, nil, nil, nil, nil)
shopCommission := shopCommissionService.New(
shopStore,
postgres.NewAccountStore(db, redisClient),
postgres.NewAgentWalletStore(db, redisClient),
postgres.NewCommissionWithdrawalRequestStore(db, redisClient),
postgres.NewCommissionWithdrawalSettingStore(db, redisClient),
postgres.NewCommissionRecordStore(db, redisClient),
postgres.NewAgentWalletTransactionStore(db, redisClient),
db,
logger,
)
tokenManager := auth.NewTokenManager(redisClient, cfg.JWT.AccessTokenTTL, cfg.JWT.RefreshTokenTTL)
authMiddleware := pkgMiddleware.Auth(pkgMiddleware.AuthConfig{
TokenValidator: func(token string) (*pkgMiddleware.UserContextInfo, error) {
info, validateErr := tokenManager.ValidateAccessToken(context.Background(), token)
if validateErr != nil {
return nil, errors.New(errors.CodeInvalidToken, "认证令牌无效或已过期")
}
return &pkgMiddleware.UserContextInfo{
UserID: info.UserID,
UserType: info.UserType,
Username: info.Username,
ShopID: info.ShopID,
EnterpriseID: info.EnterpriseID,
}, nil
},
ShopStore: shopStore,
})
app := fiber.New(fiber.Config{
JSONEncoder: sonic.Marshal,
JSONDecoder: sonic.Unmarshal,
ErrorHandler: internalMiddleware.ErrorHandler(logger),
})
RegisterAdminRoutes(app.Group("/api/admin"), &bootstrap.Handlers{
Shop: admin.NewShopHandler(service, validator.New()),
ShopRole: admin.NewShopRoleHandler(service),
ShopCommission: admin.NewShopCommissionHandler(shopCommission),
}, &bootstrap.Middlewares{AdminAuth: authMiddleware}, nil, "/api/admin")
return &shopTestEnv{app: app, db: db, redis: redisClient, tokenManager: tokenManager}
}
func (e *shopTestEnv) createShop(t *testing.T, name, phone string, parentID *uint, level int, createdAt time.Time) *model.Shop {
t.Helper()
shop := &model.Shop{
ShopName: name,
ShopCode: fmt.Sprintf("UR60-%d", time.Now().UnixNano()),
ParentID: parentID,
Level: level,
ContactPhone: phone,
Status: constants.ShopStatusEnabled,
}
shop.CreatedAt = createdAt
shop.UpdatedAt = createdAt
shop.Creator = uniqueTestID()
shop.Updater = shop.Creator
if err := e.db.Create(shop).Error; err != nil {
t.Fatalf("创建测试店铺失败:%v", err)
}
t.Cleanup(func() { _ = e.db.Unscoped().Delete(shop).Error })
return shop
}
func (e *shopTestEnv) newToken(t *testing.T, info auth.TokenInfo) string {
t.Helper()
accessToken, refreshToken, err := e.tokenManager.GenerateTokenPair(context.Background(), &info)
if err != nil {
t.Fatalf("创建测试令牌失败:%v", err)
}
t.Cleanup(func() {
_ = e.tokenManager.RevokeToken(context.Background(), accessToken)
_ = e.tokenManager.RevokeToken(context.Background(), refreshToken)
_ = e.redis.Del(context.Background(), constants.RedisUserTokensKey(info.UserID)).Err()
})
return accessToken
}
func (e *shopTestEnv) request(t *testing.T, method, path, token string) (int, []byte) {
t.Helper()
request, err := http.NewRequest(method, path, nil)
if err != nil {
t.Fatalf("创建 HTTP 请求失败:%v", err)
}
if token != "" {
request.Header.Set(fiber.HeaderAuthorization, "Bearer "+token)
}
response, err := e.app.Test(request, -1)
if err != nil {
t.Fatalf("执行 HTTP 请求失败:%v", err)
}
defer response.Body.Close()
body, err := io.ReadAll(response.Body)
if err != nil {
t.Fatalf("读取 HTTP 响应失败:%v", err)
}
return response.StatusCode, body
}
func uniqueTestID() uint {
return uint(time.Now().UnixNano() % 1_000_000_000)
}

View File

@@ -552,8 +552,12 @@ func (s *ActivationService) isCarrierRealnamed(ctx context.Context, tx *gorm.DB,
}
func (s *ActivationService) activatePendingUsage(ctx context.Context, tx *gorm.DB, usage *model.PackageUsage, pkg *model.Package, carrierType string, carrierID uint, now time.Time, logMessage string) error {
// ExpiryBase=from_purchase 只用于"等待实名激活"场景REALNAME-04套餐已购买但资产未实名
// 计时基准按购买时间算,实名只是解锁使用权。此函数同时被"前一个主套餐到期后排队顺延"场景复用,
// 这种情况下 usage.PendingRealnameActivation 为 false不应该套用购买时间否则排队等待的天数会
// 从到期时间里被扣掉。只有当这条记录确实是因为等实名才被搁置时,才按 ExpiryBase 选基准。
var activatedAt time.Time
if pkg.ExpiryBase == "from_purchase" {
if usage.PendingRealnameActivation && pkg.ExpiryBase == "from_purchase" {
activatedAt = usage.CreatedAt
} else {
activatedAt = now

View File

@@ -323,6 +323,9 @@ func (s *Service) ListShopResponses(ctx context.Context, req *dto.ShopListReques
if req.ShopCode != "" {
filters["shop_code"] = req.ShopCode
}
if req.ContactPhone != "" {
filters["contact_phone"] = req.ContactPhone
}
if req.ParentID != nil {
filters["parent_id"] = *req.ParentID
}

View File

@@ -114,6 +114,9 @@ func (s *ShopStore) List(ctx context.Context, opts *store.QueryOptions, filters
if shopCode, ok := filters["shop_code"].(string); ok && shopCode != "" {
query = query.Where("shop_code = ?", shopCode)
}
if contactPhone, ok := filters["contact_phone"].(string); ok && contactPhone != "" {
query = query.Where("contact_phone = ?", contactPhone)
}
if parentID, ok := filters["parent_id"].(uint); ok {
query = query.Where("parent_id = ?", parentID)
}

View File

@@ -0,0 +1,2 @@
-- 回滚联系电话查询索引,不修改店铺业务数据。
DROP INDEX IF EXISTS idx_shop_contact_phone;

View File

@@ -0,0 +1,4 @@
-- 为未软删除店铺的联系电话精确查询增加非唯一部分索引。
CREATE INDEX idx_shop_contact_phone
ON tb_shop USING btree (contact_phone)
WHERE deleted_at IS NULL;

View File

@@ -37,6 +37,7 @@ const (
DefaultMaxOpenConns = 25
DefaultMaxIdleConns = 10
DefaultConnMaxLifetime = 5 * time.Minute
DefaultPage = 1 // 默认页码
DefaultPageSize = 20
MaxPageSize = 100
SlowQueryThreshold = 500 * time.Millisecond

View File

@@ -9,3 +9,10 @@ const (
ShopMinLevel = 1
ShopMaxLevel = 7
)
// 店铺管理权限提示常量。
const (
ShopManagementForbiddenMessage = "无权限访问店铺管理功能" // 企业账号访问核心店铺管理功能时的提示
ShopManagementAccessDescription = "仅超级管理员、平台账号和代理账号可访问,企业账号返回 403。" // 核心店铺管理接口权限说明
ShopListPaginationDescription = "分页默认第 1 页、每页 20 条page 最小为 1page_size 范围为 1 至 100。所有筛选条件在查询前统一校验。" // 店铺列表分页与校验说明
)

View File

@@ -25,7 +25,7 @@ func BuildDocHandlers() *bootstrap.Handlers {
ClientRealname: app.NewClientRealnameHandler(nil, nil, nil, nil, nil, nil, nil, nil),
ClientDevice: app.NewClientDeviceHandler(nil, nil, nil, nil, nil, nil, nil),
ClientRechargeOrder: app.NewClientRechargeOrderHandler(nil, nil, nil),
Shop: admin.NewShopHandler(nil),
Shop: admin.NewShopHandler(nil, nil),
ShopRole: admin.NewShopRoleHandler(nil),
AdminAuth: admin.NewAuthHandler(nil, nil),
ShopCommission: admin.NewShopCommissionHandler(nil),