暂存一下,防止丢失
This commit is contained in:
File diff suppressed because it is too large
Load Diff
58
docs/tech-approval-core/功能总结.md
Normal file
58
docs/tech-approval-core/功能总结.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# 渠道无关审批核心功能总结
|
||||
|
||||
## 本次范围
|
||||
|
||||
任务 2.9 建立由业务侧拥有的 Approval Port 和通用审批实例。退款、线下充值及后续审批业务只依赖这套稳定契约,不依赖企业微信或其他渠道的 SDK、DTO、状态码和外部编号字段。
|
||||
|
||||
通用实例仅保存业务类型与业务 ID、真实提交人及其快照、`provider`、通用 `external_ref`、标准状态、申请/决策快照、关联 ID 和并发版本。数据库使用 `(business_type, business_id)` 保证一张业务单只有一个审批实例,并使用 `(provider, external_ref)` 的非空部分唯一索引防止同一渠道实例重复绑定。
|
||||
|
||||
## 标准决策
|
||||
|
||||
渠道 Adapter 只能向业务核心输出以下标准决策:
|
||||
|
||||
- `approved`:审批通过。
|
||||
- `rejected`:审批拒绝。
|
||||
- `cancelled`:审批撤销。
|
||||
- `deleted`:审批删除。
|
||||
- `revoked_after_approved`:审批通过后撤销。
|
||||
|
||||
业务消费者不得识别企微、钉钉或其他渠道状态。标准决策到通用终态的映射由审批领域统一维护。
|
||||
|
||||
## 事务与扩展边界
|
||||
|
||||
业务侧 Port 分为事务前 `Prepare` 和调用方事务内 `CreateInTx`。后续任务 2.12 将实现有效 Adapter、场景和发起身份的失败关闭检查,以及业务单、通用审批实例和提交 Outbox 的原子创建。
|
||||
|
||||
本任务不实现审批同步、终态 Outbox、处理租约、通用 Query 或任何渠道 Adapter;这些分别由 2.10~2.12 和 4.W1~4.W14 收口。当前没有外部调用,因此 Integration Log 为 N/A;没有提交后副作用,因此本任务不写 Outbox;通用审批表本身是审批 Domain Ledger。Audit Event 按总台账冻结到 6.5。
|
||||
|
||||
## 明确排除
|
||||
|
||||
- 不保存企微 Token、`sp_no`、模板 ID、控件 ID 或成员身份。
|
||||
- 不保存审批节点、审批人、意见、附件副本、会签或或签规则。
|
||||
- 不建设本地审批流引擎,也不定义退款、充值、钱包或佣金规则。
|
||||
- 不新增 API、Handler、Worker 或前端页面。
|
||||
|
||||
## 通用读取模型
|
||||
|
||||
任务 2.10 在通用实例之上增加单条和最多 100 条的批量 Query。Query 只读取当前页实例,并一次性调用业务权限 Adapter 复核当前账号对退款、充值等原业务资源的访问权;无权、资源不存在和引用失效对单条读取统一返回禁止访问。批量读取只返回有权项目,并保持调用方当前页顺序,不逐条查询业务表。
|
||||
|
||||
稳定投影包含业务引用与摘要、真实提交人、`provider`、标准状态及中文名称、状态时间和业务处理摘要。处理状态由对应业务 Adapter 提供,不以审批状态冒充退款或入账结果。
|
||||
|
||||
渠道扩展 Resolver 是可选接缝,只允许平台或超级管理员在通过原业务权限复核后读取已保存的本地快照,并明确禁止在 Query 请求中实时访问外部审批平台。代理和企业账号不会调用扩展 Resolver,因此不能获得审批节点、审批人、内部意见或渠道附件。
|
||||
|
||||
## 回调、轮询与标准决策分发
|
||||
|
||||
任务 2.11 提供回调、兜底轮询和受控人工同步共用的 `SyncDecision` 用例。具体渠道 Adapter 必须先读取渠道权威详情并翻译为五类标准决策,再以 `callback`、`polling` 或 `manual` 来源调用同一用例;回调载荷本身不能绕过权威详情直接修改业务状态。
|
||||
|
||||
用例在行锁事务内执行状态机校验和乐观锁条件更新,并以 `approval:{instance_id}:{decision}` 作为稳定事件 ID,同事务写标准决策投递事实和公共 Outbox。回调与轮询并发、重复回调或重复轮询只会由先到者写入一次;后到的相同决策正常幂等结束。`revoked_after_approved` 允许在 `approved` 之后形成独立事件,其他互相冲突的终态拒绝覆盖。
|
||||
|
||||
Outbox 消费后,分发器按业务类型调用退款、充值等业务消费者。每个“审批实例 + 标准决策”拥有独立处理记录和可过期租约,成功后永久幂等;失败释放租约并保留安全摘要等待重试。业务消费者仍必须以审批实例 ID 和决策作为自身幂等键,且不得导入任何第三方审批 SDK、DTO、状态码或模板字段。
|
||||
|
||||
本轮新增和此前公共基础迁移 `000165`~`000170` 已为每个新增表字段或新增列补齐中文数据库备注;不建立外键,关联继续由 Application/Domain 显式维护。
|
||||
|
||||
## 原子创建与失败关闭
|
||||
|
||||
任务 2.12 实现业务侧 Approval Port。业务用例先在事务外调用 `Prepare`,由具体 Provider Adapter 同时确认 Adapter 已装配、场景可用和真实发起身份可解析;任一条件不满足都会在业务单、审批实例和 Outbox 写入前返回服务不可用。准备结果只有 30 秒有效期,绑定业务类型、真实提交人和关联 ID,且内部字段不能由退款或充值业务包自行构造。
|
||||
|
||||
通过前置检查后,业务用例把自己的 GORM 事务传给 `CreateInTx`。该方法在同一事务内创建唯一通用审批实例、让 Provider Adapter 固化渠道专属安全上下文,并写 `approval.submission.requested` Outbox;任何一步失败都向调用方返回错误,调用方必须让包含业务单的整个事务回滚。事务内不调用 Redis、Asynq、对象存储或第三方审批网络。
|
||||
|
||||
当前测试环境尚未装配具体审批 Adapter 时使用失败关闭实现,因此退款和线下代充值核心可以围绕 Port 编译,但新提交入口不能被误开放。企微场景、模板和身份上下文由 4.W1~4.W5 实现,不进入通用实例。
|
||||
107
docs/tech-inapp-notifications/前端联调与验收契约.md
Normal file
107
docs/tech-inapp-notifications/前端联调与验收契约.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# 公共站内通知前端联调与验收契约
|
||||
|
||||
## 交付边界
|
||||
|
||||
本文是后台管理端、代理端和 C 端的框架无关契约。当前仓库不包含前端源码,因此页面组件、状态管理和浏览器联调必须在对应前端仓库实施;本文不把契约完成表述为前端实现或人工验收完成。
|
||||
|
||||
第一版固定使用 HTTP 轮询,不使用 WebSocket/SSE,也不维护 Redis 未读计数。
|
||||
|
||||
## 未读轮询与徽标
|
||||
|
||||
后台布局和 C 端消息入口挂载后立即请求各自的 `unread-count`,之后每 30 秒刷新:
|
||||
|
||||
1. 页面变为不可见时暂停计时器。
|
||||
2. 页面恢复可见时立即刷新一次,再恢复 30 秒周期。
|
||||
3. 请求失败时保留上一次成功值,不改写为 0;后续周期静默重试。
|
||||
4. 组件卸载时必须清理计时器,避免重复轮询。
|
||||
|
||||
徽标固定宽度,验收矩阵如下:
|
||||
|
||||
| `count` | `display_count` | 展示 |
|
||||
|---:|---|---|
|
||||
| 0 | `0` | 隐藏徽标 |
|
||||
| 1 | `1` | 显示 1 |
|
||||
| 99 | `99` | 显示 99 |
|
||||
| 100 | `99+` | 显示 99+ |
|
||||
|
||||
前端直接使用后端 `display_count`,不自行重复计算上限。
|
||||
|
||||
## 后台铃铛、抽屉和通知中心
|
||||
|
||||
铃铛点击后使用 `GET /api/admin/notifications?page=1&page_size=10` 加载最近 10 条。抽屉提供“全部、审批、临期、同步、系统”入口,对应 `category` 为空或 `approval/expiry/sync/system`。
|
||||
|
||||
完整通知中心使用服务端参数:
|
||||
|
||||
- `category`:`approval/expiry/sync/system`
|
||||
- `type`:稳定通知类型
|
||||
- `severity`:`info/warning/error/critical`
|
||||
- `is_read`:已读状态
|
||||
- `page/page_size`:服务端分页,默认 20、最大 50
|
||||
|
||||
“全部已读”调用 `PUT /api/admin/notifications/read-all`。当前分类为空时提交空对象;在分类视图中提交对应 `category`,成功后使用 `updated_count` 更新提示并重新拉取列表、汇总和未读数。
|
||||
|
||||
加载时保留已有内容并展示局部加载状态;首次空结果展示空态;请求失败展示重试入口,不把上一页数据伪装成新筛选结果。
|
||||
|
||||
## 点击、已读和受控目标
|
||||
|
||||
点击顺序固定为:
|
||||
|
||||
1. 立即进入已读视觉状态。
|
||||
2. 调用 `PUT /api/admin/notifications/:id/read`。
|
||||
3. 调用 `GET /api/admin/notifications/:id/target`。
|
||||
4. 只有 `available=true` 且 `target_type` 在前端白名单内时,使用结构化 `target_id/target_key` 构造站内路由。
|
||||
|
||||
已读请求失败时以下一次服务端刷新为准;目标解析失败或不可用不恢复未读。响应不包含 URL,前端禁止把 `target_id`、`target_key` 当作路径或完整地址直接跳转。
|
||||
|
||||
目标白名单:
|
||||
|
||||
| `target_type` | 结构化标识 | 页面语义 |
|
||||
|---|---|---|
|
||||
| `refund_detail` | `target_id` | 退款详情 |
|
||||
| `agent_recharge_detail` | `target_id` | 代理充值详情 |
|
||||
| `wecom_approval_detail` | `target_id` | 企微审批详情 |
|
||||
| `iot_card_detail` | `target_id` | 物联网卡详情 |
|
||||
| `device_detail` | `target_id` | 设备详情 |
|
||||
| `expiring_asset_list` | `target_id` | 指定店铺的临期资产列表 |
|
||||
| `shop_fund_summary` | `target_id` | 店铺资金概况 |
|
||||
| `integration_log` | `target_key` | 外部集成记录 |
|
||||
| `system_config` | `target_key` | 受控系统配置 |
|
||||
|
||||
未知类型、空 `target_type` 或 `available=false` 只展示正文,不跳转、不回退到自由 URL。
|
||||
|
||||
示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"target_type": "shop_fund_summary",
|
||||
"target_id": 42,
|
||||
"available": true
|
||||
},
|
||||
"timestamp": "2026-07-24T11:00:00+08:00"
|
||||
}
|
||||
```
|
||||
|
||||
## C 端简化通知中心
|
||||
|
||||
C 端只调用:
|
||||
|
||||
- `GET /api/c/v1/notifications/unread-count`
|
||||
- `GET /api/c/v1/notifications?page=&page_size=`
|
||||
- `PUT /api/c/v1/notifications/read-all`
|
||||
- `PUT /api/c/v1/notifications/:id/read`
|
||||
|
||||
C 端不展示后台筛选、分类汇总、同步或系统运维消息。列表只呈现当前个人客户可见的已开放审批、套餐、订单和资产业务通知。无通知时展示空态;网络失败保留上一成功结果并允许重试。
|
||||
|
||||
## 联调验收矩阵
|
||||
|
||||
- 后台账号只能读取和修改自己的通知,构造其他通知 ID 不泄露事实。
|
||||
- 个人客户之间完全隔离,C 端不能通过 ID 已读其他客户通知。
|
||||
- 过期通知不进入列表、未读数、分类汇总或受控目标解析。
|
||||
- 静态 `/read-all`、`/unread-count`、`/unread-summary` 不被 `/:id` 路由吞掉。
|
||||
- 筛选、翻页、全部已读后,列表、汇总和徽标最终一致。
|
||||
- 权限变化、目标删除和未知引用均显示正文但不跳转。
|
||||
- 0、1、99、100 四个徽标边界无布局抖动。
|
||||
- 页面隐藏时无轮询,恢复后立即刷新;失败期间未读数不闪回零。
|
||||
81
docs/tech-inapp-notifications/功能总结.md
Normal file
81
docs/tech-inapp-notifications/功能总结.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# 公共站内通知功能总结
|
||||
|
||||
> 当前状态:任务 2.1~2.4 后台、个人客户通知闭环和后台动态接收人已完成代码交付;受控跳转、保留清理和完整前端契约仍按后续任务实施。
|
||||
|
||||
## 本次交付范围
|
||||
|
||||
本阶段交付明确后台账号的通知中心纵向闭环:公共 Outbox 事件经现有 `outbox:deliver` Worker 消费后,为指定且仍启用的后台账号幂等写入一条纯文本通知。当前登录账号只能查询自己的未读数、固定分类汇总和筛选分页列表,并可把自己的一条、指定类别或全部通知幂等标记为已读。
|
||||
|
||||
本阶段不实现目标跳转解析、前端组件、WebSocket 或具体业务触发规则,也不复制公共 Outbox 与 Relay。
|
||||
|
||||
## 事件与幂等契约
|
||||
|
||||
- 稳定事件类型:`notification.admin.direct.requested`。
|
||||
- 个人客户稳定事件类型:`notification.personal_customer.direct.requested`。
|
||||
- 后台动态接收人事件类型:`notification.admin.dynamic.requested`。
|
||||
- 载荷版本:`1`。
|
||||
- 结构化载荷包含后台接收账号 ID、注册通知类型、模板数据、可选受控资源引用和过期时间;调用公共队列时保持 struct/map 载荷,不传预序列化 `[]byte`。
|
||||
- 当前内置通知类型为 `system.notice`,标题和正文均由代码内固定模板生成,不接受业务载荷传入任意正文;未知字段、HTML、任意 HTTP URL、超长文本和明显敏感内容会被拒绝。
|
||||
- 个人客户首个受控类型为 `package.expiring`,使用固定纯文本模板,只允许 `package` 或 `asset` 资源引用;`system.notice` 只向后台账号开放。
|
||||
- 接收账号必须启用且未软删除;不可用账号记录安全日志并跳过,不把通知写入失败反向传播到原业务事务。
|
||||
- `event_id + recipient_kind + recipient_id` 唯一约束与 `ON CONFLICT DO NOTHING` 共同保证重复或并发消费最多生成一条通知。
|
||||
|
||||
后台动态事件使用 `target_kind + target_id` 指定受控目标,当前支持:
|
||||
|
||||
- `account`:业务事件携带的稳定真实申请人账号 ID,消费时复核账号仍启用且未删除。
|
||||
- `platform_role`:批量解析当前启用的平台角色、有效账号角色关系,以及仍启用未删除的超管/平台账号。
|
||||
- `shop`:复用 UR#96 接缝,只解析目标店铺当前启用主账号和当前仍可用业务员,不读取父级、祖先、创建人或代理数据权限。
|
||||
|
||||
解析结果按稳定账号 ID 去重和排序。同一事件对每个接收人独立幂等写入;暂无可用接收人记录 `resolution=no_recipient` 并正常结束,数据库故障继续返回 Worker 错误。已经生成的通知保持原接收人事实,关系后续变化不会转移历史通知。
|
||||
|
||||
## 数据与查询
|
||||
|
||||
迁移 `000168_create_notification` 新建 `tb_notification`,不使用外键。表内固化通知类别、类型、级别、纯文本标题正文、受控引用、首次已读时间、过期时间和创建时间,并通过 CHECK 约束保护接收人、引用与已读状态一致性。
|
||||
|
||||
后台查询始终绑定当前认证账号和 `recipient_kind=account`:
|
||||
|
||||
- `GET /api/admin/notifications/unread-count`:返回准确 `count` 与徽标 `display_count`,超过 99 显示 `99+`。
|
||||
- `GET /api/admin/notifications/unread-summary`:使用单条 PostgreSQL 条件聚合返回 `total/approval/expiry/sync/system` 五个固定未读计数。
|
||||
- `GET /api/admin/notifications`:只返回未过期通知,支持类别、类型、级别和已读状态 AND 组合筛选,按 `created_at DESC, id DESC` 排序,默认每页 20、最大 50,页码最大 10000。
|
||||
- `PUT /api/admin/notifications/read-all`:类别为空时更新当前账号全部未过期未读通知,指定有效类别时只更新该类别并返回实际更新数;重复调用返回零更新且保持成功。
|
||||
- `PUT /api/admin/notifications/:id/read`:仅首次更新当前账号自己的未过期未读通知;不存在、属于别人或已经已读均幂等成功,不泄露通知是否存在,也不覆盖首次 `read_at`。
|
||||
- `GET /api/admin/notifications/:id/target`:先固定当前账号查询通知,再返回 `target_type/target_id/target_key/available` 结构化白名单目标;不返回 URL,别人通知、不存在通知和过期通知统一返回不可用。
|
||||
|
||||
目标注册表覆盖退款、代理充值、企微审批、物联网卡、设备、临期资产列表、店铺资金概况、外部集成和系统配置。退款、卡、设备、店铺、外部集成和系统配置会复核当前数据权限与资源存在性;尚未交付下游业务表的代理充值和企微审批目标先失败关闭为 `available=false`。`card_sync` 统一映射到外部集成目标,不指向不存在的同步执行页面。未知引用仅展示正文。
|
||||
|
||||
个人客户接口固定绑定认证上下文中的 `customer_id`,请求 DTO 不包含接收人字段:
|
||||
|
||||
- `GET /api/c/v1/notifications/unread-count`:返回当前客户可见业务通知的准确未读数和 `display_count`。
|
||||
- `GET /api/c/v1/notifications`:提供默认 20、最大 50 的简化分页并固定倒序,不暴露后台筛选或分类汇总参数。
|
||||
- `PUT /api/c/v1/notifications/read-all`:幂等更新当前客户可见的全部未过期未读业务通知并返回实际更新数。
|
||||
- `PUT /api/c/v1/notifications/:id/read`:幂等更新当前客户的一条可见通知,跨客户 ID 与不存在 ID 使用相同成功语义。
|
||||
|
||||
C 端查询和更新同时限制 `recipient_kind=personal_customer`、当前客户、未过期、业务类别及开放类型白名单;`sync/system` 和未对 C 端开放的类型不会进入结果,也不能被 C 端已读接口修改。
|
||||
|
||||
## 审计与安全边界
|
||||
|
||||
- `tb_notification` 是通知投递和已读状态的权威事实;通知不能替代资金、审批、套餐等业务 Domain Ledger。
|
||||
- 本阶段无外部系统调用,因此不写 Integration Log;可靠输入继续使用公共 Outbox。
|
||||
- 按测试环境 Change 的临时决定,本阶段不接入 Audit Event Writer。普通列表、未读数和已读进入 Access Log;生产发布前由任务 6.5 重新评审通知失败与系统告警治理。
|
||||
- 用户接口不接受 `recipient_id`,后台账号权限也不能查看或修改其他接收人的通知。
|
||||
|
||||
## 验证与发布
|
||||
|
||||
- 已执行 `gofmt`、`git diff --check`,并通过 `go build ./cmd/api ./cmd/worker ./cmd/gendocs`。
|
||||
- 按本次测试环境里程碑豁免,未新增或运行 `_test.go`,也未执行真实 PostgreSQL、Redis/Asynq 或 HTTP 集成验证;这些证据统一延期到任务 6.1 和 6.3,当前状态不得表述为生产验收通过。
|
||||
- `000168` 的 down 迁移只允许空表回滚;一旦产生通知事实,必须停止生产者并前向修复,不允许降级删表清除事实。
|
||||
|
||||
通知展示与保留策略由代码统一执行:审批结果不自动过期、保留 365 天;套餐临期必须携带业务到期时间并保留 180 天;同步异常默认最多展示 30 天、保留 180 天;系统告警默认展示 30 天且最长 365 天、保留 365 天。
|
||||
|
||||
Worker 每天 02:15 调度 `notification:cleanup`,按类别、创建时间和通知主键,每批最多 500 条、每类每次最多 20 批执行 PostgreSQL CTE 删除。任务可中断重跑,只删除 `tb_notification`,不级联业务资源、Outbox、Integration Log 或审计事实。
|
||||
|
||||
无接收人以 `resolution=no_recipient` 正常结束。接收人解析、模板和展示策略失败只记录事件 ID、通知类型、失败类别及安全目标标识,不记录模板数据、回调、Token、Secret 或 URL;瞬时错误由 Asynq 有限重试,残缺正文不会入库。统一 Audit Event 管理性写入按本 Change 冻结到任务 6.5。
|
||||
|
||||
## 后续任务
|
||||
|
||||
- 2.7~2.8:前端契约、OpenAPI/中文文档最终收口和测试环境发布检查。
|
||||
|
||||
后台与 C 端的完整交互、目标白名单、示例和验收矩阵见 [前端联调与验收契约](前端联调与验收契约.md)。
|
||||
|
||||
测试环境发布顺序、运行门禁、恢复策略和下游事件接入方式见 [发布与下游接入清单](发布与下游接入清单.md)。
|
||||
- 2.7~2.8:前端契约、OpenAPI/文档最终生成、Worker/模板与测试环境发布检查。
|
||||
47
docs/tech-inapp-notifications/发布与下游接入清单.md
Normal file
47
docs/tech-inapp-notifications/发布与下游接入清单.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# 公共站内通知发布与下游接入清单
|
||||
|
||||
## 下游生产者契约
|
||||
|
||||
业务事务必须先生成稳定 `event_id`,并与业务事实在同一事务写入公共 Outbox。载荷版本固定为 `1`,调用 `EnqueueTask` 时传 struct 或 map,禁止传预序列化 `[]byte`。
|
||||
|
||||
明确后台账号事件使用 `notification.admin.direct.requested`,载荷为 `AdminDirectPayload`;个人客户事件使用 `notification.personal_customer.direct.requested`;后台动态事件使用 `notification.admin.dynamic.requested`,目标只允许:
|
||||
|
||||
- `account + 稳定真实申请人账号 ID`
|
||||
- `platform_role + 平台角色 ID`
|
||||
- `shop + 目标店铺 ID`
|
||||
|
||||
动态店铺目标只产生当前启用主账号和当前可用业务员,不沿层级扩散。无接收人是正常终态。同一事件必须复用原 `event_id`,不得在重试时生成新 ID。
|
||||
|
||||
新增业务通知类型必须在代码注册表中明确:稳定类型、类别、级别、固定纯文本模板、允许模板字段、接收人类型和允许 `ref_type`。不得透传任意标题、正文、HTML、URL、Token、Secret、回调原文或长期附件地址。
|
||||
|
||||
## 目标与期限
|
||||
|
||||
通知只保存受控 `ref_type/ref_id/ref_key`。后台目标接口只返回前端白名单 `target_type` 和结构化 ID/Key,并再次复核当前权限;拥有通知不授予资源权限。
|
||||
|
||||
- 审批结果不自动过期,数据保留 365 天。
|
||||
- 套餐临期必须携带业务到期时间,数据保留 180 天。
|
||||
- 同步异常默认最多展示 30 天,数据保留 180 天。
|
||||
- 系统告警默认展示 30 天、最长 365 天,数据保留 365 天。
|
||||
|
||||
## 测试环境发布顺序
|
||||
|
||||
1. 进入维护窗口并确认下游生产者尚未启用。
|
||||
2. 执行迁移 `000168_create_notification` 和 `000169_add_shop_business_owner`,核对无外键表、唯一索引、查询索引及店铺业务员普通索引。
|
||||
3. 发布 Worker,确认三个通知 Outbox 事件消费者、公共 `outbox:deliver` Handler 和每天 02:15 的 `notification:cleanup` 已注册。
|
||||
4. 发布 API,核对后台、C 端路由和 OpenAPI;静态 `/read-all`、`/unread-count`、`/unread-summary` 必须可达。
|
||||
5. 发布匹配的前端版本并按前端联调契约验收。
|
||||
6. 最后启用 UR#33、UR#97、审批结果等下游生产者,避免消费者未就绪时制造不可见积压。
|
||||
|
||||
## 运行门禁与恢复
|
||||
|
||||
- 监控公共 Outbox pending/delivering/final failed、Asynq 重试与失败、通知 `no_recipient`、模板/解析失败和每日清理删除数。
|
||||
- 出现永久模板错误、持续数据库错误、Outbox 积压或清理长期失败时,先停止对应下游生产者,不删除业务事实和已写通知。
|
||||
- 已入队但未完成的事件继续使用原 `event_id` 恢复;不得要求用户重复提交或给同一业务生成新事件。
|
||||
- 通知表已有事实后禁止执行 down 删除;应用回滚保留 `tb_notification` 和店铺业务员字段,修复后前向恢复。
|
||||
- 不清理 Audit Event、Integration Log、Domain Ledger 或 Outbox,不使用通知列表替代业务审计。
|
||||
|
||||
## 当前验证状态
|
||||
|
||||
已完成路由、RouteSpec、集中式文档 Handler、Worker 消费者、模板、清理 Handler/调度和组合根的静态核对;已生成 `docs/admin-openapi.yaml`,并通过 `gofmt`、`git diff --check`、`go build ./...` 和 OpenSpec 校验。
|
||||
|
||||
按测试环境 Change 豁免,尚未执行真实 PostgreSQL 迁移、Redis/Relay/Asynq 端到端、真实认证 HTTP、并发重复消费或浏览器人工验收;这些门禁保持在任务 6.1、6.3、6.6,不能据此声明生产验收通过。
|
||||
149
docs/ur38-agent-main-wallet-credit/功能总结.md
Normal file
149
docs/ur38-agent-main-wallet-credit/功能总结.md
Normal file
@@ -0,0 +1,149 @@
|
||||
# UR#38 代理主钱包信用额度功能总结
|
||||
|
||||
## 当前完成范围
|
||||
|
||||
任务 2.23~2.32 已完成代理主钱包信用额度、订单扣款、资金预占、正向入账、退款回充、资金概况读取与测试环境切换收口:
|
||||
|
||||
- `tb_agent_wallet` 新增信用开关和分单位信用额度,历史主钱包与分佣钱包默认保持关闭、额度为零。
|
||||
- Wallet Domain 统一定义现金可用金额、有效信用额度、总可用金额、欠款状态和欠款金额,并拒绝非法配置与 `int64` 算术溢出。
|
||||
- 数据库 CHECK 保证只有主钱包可以启用信用、分佣钱包保持原现金边界、冻结金额与版本非负、总可用金额不为负。
|
||||
- 迁移运行时读取目标库真实 CHECK 定义;发现历史异常即中止,不静默修正任何钱包金额。
|
||||
|
||||
## 统一资金口径
|
||||
|
||||
```text
|
||||
effective_credit = credit_enabled ? credit_limit : 0
|
||||
cash_available = balance - frozen_balance
|
||||
available_balance = cash_available + effective_credit
|
||||
is_in_debt = balance < 0
|
||||
debt_amount = max(-balance, 0)
|
||||
```
|
||||
|
||||
冻结金额只降低现金及总可用金额,不直接形成欠款。信用额度仅属于代理主钱包,不扩展到分佣钱包、资产钱包或平台员工。
|
||||
|
||||
## 发布与回滚
|
||||
|
||||
执行迁移前必须保留目标库输出的真实约束定义与异常钱包清单。若已经启用信用、产生负余额或冻结金额超过账面余额,降级迁移会主动拒绝执行;此时必须继续使用理解信用边界的新钱包逻辑,不能删除字段或恢复旧 Writer。
|
||||
|
||||
本任务已经切换代理订单创建即支付和待支付订单的代理主钱包扣款入口,并交付统一冻结、释放、冻结资金完成扣除、正向入账和订单退款回充接缝;批量订购仍由 3.24、3.28 使用该接缝逐项实现。旧 AgentWallet Store 写接缝已删除零调用的主钱包扣款/冻结能力,保留能力强制限定为分佣钱包;旧非事务店铺创建实现和主钱包直写清理脚本也已停用。本测试环境里程碑未运行真实 PostgreSQL 或自动化测试,验证状态为“代码完成、验证延期”。
|
||||
|
||||
## 角色默认信用模板
|
||||
|
||||
客户角色可通过 `PUT /api/admin/roles/{id}/default-credit` 配置只作用于未来新建店铺的默认信用模板。接口明确返回 `scope=new_shops_only` 和 `affects_existing_wallets=false`;修改模板不会扫描既有店铺、修改既有钱包,也不会随店铺角色增删而级联。
|
||||
|
||||
超级管理员按既有规则放行;普通平台账号必须拥有独立权限 `role:default-credit:manage`;代理及企业账号始终拒绝。平台角色由数据库约束固定为关闭信用、额度为零。Audit Event 按本 Change 的测试环境冻结决策延期至 6.5,当前更新仍保留操作者字段,但不得据此宣称完成正式审计验收。
|
||||
|
||||
## 新建店铺信用快照
|
||||
|
||||
店铺创建事务会在事务内重新读取请求中的唯一启用客户角色,并把当时的 `default_credit_enabled/default_credit_limit` 复制到新主钱包;分佣钱包始终写入关闭/0。店铺、初始主账号、账号角色、店铺角色和两个钱包仍在同一 PostgreSQL 事务内全成全败。创建完成后修改角色模板或店铺角色关系,都不会追溯改变该钱包快照。
|
||||
|
||||
## 既有店铺实际额度调整
|
||||
|
||||
`PUT /api/admin/shops/{id}/credit-limit` 用于调整既有店铺主钱包的实际信用额度。按当前产品决定,后端不校验 `shop:credit-limit:manage` 或账号类型;该权限编码只供前端决定是否展示按钮,能够看到按钮的账号即可调用。请求携带钱包 `version`,更新同时约束主钱包类型、版本和调整后的总可用金额,成功后版本加一;降额或关闭信用无法覆盖当前欠款/冻结占用时保持原值。该动作不修改余额、冻结金额,也不创建金额为零的钱包流水。后端授权收紧留待未来单独实施。
|
||||
|
||||
## 统一订单扣款
|
||||
|
||||
代理自购、代理为下级代购以及后台代理钱包订购统一调用 Wallet Application 的 `DebitInTx`。用例按主钱包行锁读取最新状态,以 `balance - frozen_balance + effective_credit` 校验资金边界,同时保留版本条件更新;现金不足时可在额度内形成负余额,超过总可用金额时整笔事务回滚。
|
||||
|
||||
每个订单最多写入一条成功代理主钱包扣款流水,软删除也不能绕过该资金幂等键。钱包订单另保存 SHA-256 幂等指纹,并在事务内通过 PostgreSQL advisory lock 串行同一指纹;即使事务提交后的 Redis 标记写入失败,三分钟窗口内的重试也会返回原订单,不会创建新订单再次扣款或激活。Redis 锁使用随机 owner token 和比较后删除,缓存不可用时失败关闭。
|
||||
|
||||
订单、订单明细、钱包版本和余额、真实扣款流水、Payment、套餐处理以及 `wallet.agent_main.debited` Outbox 事件在同一事务提交,任何一步失败都不会留下已支付订单或部分资金事实。Worker 已注册该事件消费者,并在确认投递前复核权威扣款流水;后续余额预警在这个稳定消费接缝上扩展。流水继续保留自购/代购子类型、关联下级店铺及资产快照。冻结或关闭钱包会拒绝扣款;资产钱包、佣金钱包和订单无关创建流程保持原实现,不在本任务迁移。
|
||||
|
||||
迁移 `000174` 为订单幂等指纹字段和两个索引添加中文数据库备注,并在创建扣款唯一索引前主动扫描包含软删除记录在内的历史重复成功流水;发现异常会中止而不是自动删除资金事实。若已经产生统一扣款 Outbox 事实,降级迁移会拒绝移除防重字段和约束。
|
||||
|
||||
## 统一资金预占
|
||||
|
||||
Wallet Application 提供代理主钱包订单资金的冻结、释放和完成扣除能力。冻结只增加 `frozen_balance`,以总可用金额校验信用边界,不会直接形成欠款;释放只减少冻结金额;完成扣除同时减少账面余额和冻结金额,并创建真实扣款流水及 `wallet.agent_main.debited` 事件。
|
||||
|
||||
`tb_agent_wallet_reservation` 以订单业务引用唯一记录预占金额、付款钱包和唯一终态。释放与完成只接收订单引用,并从预占事实读取权威钱包与金额,因此代理代购取消不会把买方店铺误当付款钱包。重复冻结、重复释放或重复完成不会二次改变钱包;释放与完成互为排斥终态,钱包行锁、版本条件、预占状态条件、流水和 Outbox 均在调用方事务内维护。
|
||||
|
||||
迁移 `000175` 为预占表、全部字段和索引添加中文数据库备注,不建立外键。升级前若发现无法关联稳定业务引用的历史主钱包冻结金额或历史待支付代理钱包订单会中止;降级时在事务内取得预占表排他锁,存在任何预占事实就拒绝删表,避免检查与删除之间产生新事实。该能力只覆盖代理主钱包订单预占,佣金钱包提现和资产钱包冻结保持原边界。
|
||||
|
||||
## 统一充值与人工调整入账
|
||||
|
||||
Wallet Domain 的 `Credit` 只允许正常代理主钱包执行正金额入账,并使用安全加法拒绝 `int64` 溢出。入账只增加账面余额和版本,不修改冻结金额或信用额度;钱包原有负余额时会自然表现为欠款减少或清偿。
|
||||
|
||||
Wallet Application 的 `PostInTx` 仅接受 `topup/recharge` 和 `manual_adjustment/adjustment` 两组受控业务类型。调用方必须先持久化具有唯一业务键的充值或人工调整业务事实,并以该事实 ID 作为稳定 `reference_id`;充值单号等可读业务号作为 `correlation_id` 贯穿事件。用例锁定主钱包并校验可选钱包 ID 归属,以 `reference_type + reference_id` 查询成功流水幂等,随后在调用方事务内更新余额和版本、写真实金额流水及 `wallet.agent_main.credited` Outbox。
|
||||
|
||||
现有代理充值的线下确认和在线支付回调已改为调用统一入账能力,充值单状态、钱包、流水和 Outbox 全成全败。在线回调在资金写入前校验订单不是线下充值、创建时支付渠道、回调金额和非空第三方交易号;同一渠道的第三方交易号只能绑定一张代理充值单。重复回调或 Worker 重试不会二次入账;旧 Service 不再直接拼接代理主钱包 `balance + amount`。仓库当前没有独立的人工余额调整 Handler 或业务表,本任务不虚构管理入口,只交付供后续业务事实调用的稳定幂等接缝。支付查单、企微审批和到账通知由 UR#34 后续任务负责,退款回充仍由 2.30 迁移,佣金钱包和资产钱包保持原实现。
|
||||
|
||||
迁移 `000176` 更新代理钱包流水类型的中文数据库备注,扫描历史重复成功入账和重复渠道交易号后创建包含软删除事实的部分唯一索引;若已经产生统一入账 Outbox,降级会拒绝移除防重约束,并在同一事务排他锁定充值单、资金流水和 Outbox 后再删除索引。Worker 已注册入账事件消费者,并在确认投递前复核权威成功流水。
|
||||
|
||||
## 统一订单退款回充
|
||||
|
||||
代理钱包订单退款统一调用 Wallet Application 的 `RefundInTx`。正常订单必须从包含软删除记录的成功 `order/deduct` 流水读取实际付款主钱包、原扣款金额、代购关联店铺、交易子类型和资产快照;退款金额不得超过原扣款绝对值。信用扣款无需单独分支,退款只增加账面余额并自然减少或清偿欠款,不修改冻结金额和信用额度。
|
||||
|
||||
历史订单缺少扣款流水时,退款编排层才按旧订单字段推导付款店铺和代购关联店铺,并优先使用订单实付金额作为退款上限,缺失时兼容总金额。新路径不会查询当前店铺关系来猜测付款方,避免代购关系变化后退错钱包。
|
||||
|
||||
钱包行锁、Domain 安全加法、版本条件更新、以退款单 ID 为业务键的唯一成功流水及 `wallet.agent_main.refunded` Outbox 在退款审批事务内全成全败。重复审批、Worker 重试或并发请求不能重复回充;消费者确认事件前会同时复核退款流水、原扣款流水、金额上限和资产快照。迁移 `000177` 在创建包含软删除事实的部分唯一索引前扫描重复退款流水,并为涉及字段和索引保留中文数据库备注;降级在同一事务排他锁定资金流水和 Outbox,存在统一退款事实时拒绝移除防重约束。
|
||||
|
||||
代理退款调用点已不再直接执行余额加法。个人资产钱包退款、佣金回扣、套餐失效、退款后资产处理、渠道退款和审批终态编排均保持原边界,后续分别由对应任务处理。Audit Event 按测试环境冻结决定延期至 6.5,Domain Ledger 与 Outbox 不延期。
|
||||
|
||||
## 资金概况信用投影
|
||||
|
||||
`GET /api/admin/shops/fund-summary` 延续现有分页、店铺名称、主账号用户名和店铺层级数据范围,并保留 `main_balance/main_frozen_balance` 兼容字段。响应新增由服务端统一计算的 `cash_available_balance`、`credit_enabled`、`credit_limit`、`available_balance`、`is_in_debt`、`debt_amount` 和 `version`;前端不得自行重算金额或把读取能力解释为调额权限。
|
||||
|
||||
该用例已完整迁到 `internal/query/shop`,不经过 Wallet 聚合根、不执行写操作。Query 在 Count 和分页前应用店铺与主账号筛选,按 `created_at DESC, id DESC` 稳定排序,再以固定次数批量投影本页主钱包、佣金钱包、提现汇总和主账号,避免逐店铺查询。现金可用金额固定为账面余额减冻结金额,总可用金额只加启用后的额度,欠款只由负账面余额决定;冻结占用信用但余额非负时不会误报欠款。缺少主钱包的历史异常店铺暂按零值兼容,完整性告警由 UR#97 负责。
|
||||
|
||||
企业账号访问代理资金概况会使用资金功能专用提示返回 403;平台和代理仍只读取当前既有店铺数据范围。信用额度不会加入 UR#97 的现金低余额口径。资金概况属于普通受权读取,Audit Event 登记为 N/A;Access Log 和当前数据权限继续生效。
|
||||
|
||||
开放接口 `GET /api/open/v1/wallet/balance` 同步返回 `cash_available_balance`、`credit_enabled`、`credit_limit`、`available_balance`、`is_in_debt`、`debt_amount` 和 `version`。其中 `available_balance` 已统一为包含生效信用额度的总可用金额,避免开放接口仍按旧现金口径判断可支付金额。
|
||||
|
||||
## 测试环境停机切换清单
|
||||
|
||||
### 停机前
|
||||
|
||||
1. 停止代理钱包订单、充值、退款、店铺创建及相关 Worker 新写入。
|
||||
2. 记录 `tb_agent_wallet` 当前 CHECK 定义,核对迁移 `000171`~`000177` 的执行顺序。
|
||||
3. 查询并阻断以下异常:未知钱包类型、负冻结金额、负版本、历史信用非关闭/非零、主钱包现金可用为负、分佣钱包余额为负或冻结超过余额。
|
||||
4. 确认主钱包写入口仅为 Wallet Application:订单扣款、预占、充值/人工调整、退款回充与调额;旧 Store 方法只能写分佣钱包。
|
||||
5. 确认 API、Worker 与 OpenAPI 为同一构建版本,四类钱包 Outbox 消费者均已注册。
|
||||
|
||||
迁移前异常查询口径:
|
||||
|
||||
```sql
|
||||
SELECT id, shop_id, wallet_type, balance, frozen_balance,
|
||||
credit_enabled, credit_limit, version
|
||||
FROM tb_agent_wallet
|
||||
WHERE wallet_type NOT IN ('main', 'commission')
|
||||
OR frozen_balance < 0
|
||||
OR version < 0
|
||||
OR credit_enabled
|
||||
OR credit_limit <> 0
|
||||
OR (wallet_type = 'main' AND balance::numeric - frozen_balance::numeric < 0)
|
||||
OR (wallet_type = 'commission' AND (balance < 0 OR frozen_balance > balance));
|
||||
```
|
||||
|
||||
### 迁移后、开放访问前
|
||||
|
||||
1. 确认信用字段、六个资金 CHECK、信用启用部分索引、订单幂等索引、预占表、入账和退款唯一索引均存在。
|
||||
2. 确认全部历史钱包仍为 `credit_enabled=false, credit_limit=0`;只有授权平台人员在开放访问后按业务决定启用信用。
|
||||
3. 核对三个接口及真实路由:角色默认信用、店铺实际额度、后台资金概况;同时核对开放接口钱包余额的信用投影。
|
||||
4. 执行 `gofmt`、OpenAPI 生成和 `go build ./...`;自动化、并发与真实 PostgreSQL/Redis/Asynq 验收保持延期到任务 6.1、6.3,不能标记通过。
|
||||
|
||||
### 监控与异常处理
|
||||
|
||||
- 监控钱包条件更新 `RowsAffected=0`、Outbox 积压/失败、消费者权威流水不一致、钱包版本冲突和数据库 CHECK 拒绝。
|
||||
- 出现支付/退款/充值事实已提交但消费失败时保留 Domain Ledger 与 Outbox,暂停异常生产者并前向恢复,不回滚资金事实。
|
||||
- 发现未知旧写入口时保持维护状态;禁止临时恢复 Store 主钱包写方法或直接 SQL 改余额。
|
||||
|
||||
### 前端联调
|
||||
|
||||
- 角色页明确提示默认信用只影响未来新建店铺。
|
||||
- 调额按钮按 `shop:credit-limit:manage` 控制展示,后端行为仍以当前冻结产品决定为准。
|
||||
- 所有金额按分传输、按元展示;现金可用与总可用分别展示,前端不自行计算。
|
||||
- 降额失败保持原值;版本冲突后重新拉取资金概况和最新 `version`。
|
||||
- 代理无调额入口,资金概况和开放接口均正确显示信用、欠款与总可用金额。
|
||||
|
||||
## 回滚边界
|
||||
|
||||
- 尚未启用信用、未产生负余额且不存在冻结超过账面余额时,才可评估执行可逆降级。
|
||||
- 一旦启用信用、产生负余额或形成旧逻辑无法解释的冻结占用,禁止删除信用字段、关闭信用或恢复旧 Writer;必须先清偿欠款,或继续运行理解信用边界的新资金逻辑。
|
||||
- 已产生的钱包流水、订单、充值、退款、预占、Outbox 和消费事实不得清理或伪造回滚。
|
||||
|
||||
## 本批验证结果
|
||||
|
||||
- 已执行 OpenAPI 生成,`docs/admin-openapi.yaml` 与当前 DTO/路由同步。
|
||||
- 已执行静态写入口盘点:旧主钱包 Store 扣款/冻结方法已删除,保留方法均带 `wallet_type=commission` 条件;旧店铺创建和主钱包直写运维脚本已收缩。
|
||||
- 已执行 `go build ./...`,退出码为 0;Go 模块统计缓存出现只读警告,不影响构建结果。
|
||||
- 按本 Change 的测试环境豁免,未新增或运行 `_test.go`,未连接真实 PostgreSQL、Redis 或 Asynq;相关验证保留在 6.1、6.3。
|
||||
@@ -1,5 +1,7 @@
|
||||
# UR#45 换货资产快照与新旧资产独立搜索功能总结
|
||||
|
||||
> 交付状态:后端实现、OpenAPI 与前端联调契约已交付;前端页面实施和浏览器人工验收待完成。
|
||||
|
||||
## 本次交付范围
|
||||
|
||||
本次完成两张可独立发布的 Ticket:
|
||||
|
||||
119
docs/ur94-card-state-events-callbacks/功能总结.md
Normal file
119
docs/ur94-card-state-events-callbacks/功能总结.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# UR#94 卡状态公共写入与运营商回调功能总结
|
||||
|
||||
## 当前完成范围
|
||||
|
||||
任务 2.33 已完成运营商回调启用前的 ICCID 精确唯一性门禁,任务 2.34、2.35、2.36、2.37 已分别交付实名、流量、网络观测公共写入闭环及三个轮询入口切换。观测序列和运营商回调仍按 2.38~2.45 继续实施,不能因当前能力已落地而提前开放回调入口。
|
||||
|
||||
## 流量观测公共写入闭环
|
||||
|
||||
统一 `ApplyTrafficObservation` 已收口手动 Gateway 刷新、套餐失效前同步和周期流量轮询使用的流量写入规则。
|
||||
|
||||
- 应用用例使用 PostgreSQL `FOR UPDATE` 串行化同一卡观测,并以旧 Gateway 读数作条件更新;检查时间、可信基线、自然月累计、生命周期累计和正增量 Outbox 在同一事务提交。
|
||||
- 领域规则保留运营商重置日当天及前一天窗口。非重置窗口的下降读数不覆盖可信基线、不累计流量,也不发布扣减事件;零增量只更新时间。
|
||||
- 自然月切换时保存上月系统累计并初始化本月累计;运营商周期读数与系统自然月累计保持两个独立口径。
|
||||
- 正增量只发布一次 `card.traffic.incremented` v1 Outbox,不在请求事务内直接调用套餐服务,避免卡事实成功而扣减失败形成半事务。
|
||||
- Worker 先将增量写入既有 `traffic:daily:{cardID}:{date}` Redis 缓冲并保留 48 小时,再扣减套餐流量、执行停复机评估;每日落盘任务继续按原覆盖语义写 `tb_card_daily_usage`,不会与请求事务内的增量写互相覆盖。
|
||||
- `tb_card_observation_effect` 以 `event_id` 唯一记录日流量、套餐扣减和停复机评估阶段。重复投递在已完成阶段直接返回;副作用已发出但结果未知时停在处理中,不盲重试造成重复扣减。
|
||||
- 卡事实提交后才失效轮询缓存。统一 Audit Event 不在本次 Change 范围内;Outbox、套餐使用记录、流量事实和 Access Log 边界保持不变。
|
||||
|
||||
迁移 `000180` 新增无外键的卡观测副作用进度表,状态为 0-待处理、1-处理中或结果未知、2-日流量已记录、3-套餐流量已扣减、4-全部完成。真实 PostgreSQL、Redis/Asynq、并发与结果未知恢复验证按本轮豁免延期。
|
||||
|
||||
## 网络状态观测公共写入闭环
|
||||
|
||||
统一 `ApplyNetworkObservation` 已收口手动 Gateway 刷新和周期网络轮询使用的网络状态写入。
|
||||
|
||||
- 领域层集中维护 Gateway `正常/停机/准备/待激活` 到本地开停机状态的稳定映射;未知状态不以零值覆盖当前网络状态,但仍可安全保存本次扩展原因、IMEI、检查时间和同步时间。
|
||||
- 已知状态变化、Gateway 风险停机/销户扩展、运营商停机原因和网关卡 IMEI 由同一 PostgreSQL `FOR UPDATE` 事务写入;仅真实网络状态变化发布 `card.network.changed` v1 Outbox。
|
||||
- 独立卡命中“风险停机/已销户”时在同一事务关闭 `enable_polling`;绑定设备的卡和“机卡分离停机”不触发该独立卡终止规则。
|
||||
- Worker 消费网络变化事件后读取当前权威卡事实执行停复机评估,避免把 Gateway 成功响应直接当成本地停复机事实;已有 Integration Log、停复机资格和 Gateway 失败重排保持在原边界。
|
||||
- 真实 Gateway 状态映射、风险卡矩阵、未知状态、IMEI、重复事件和事务回滚验证按 6.4/6.1 延期,不能据此标记生产验收完成。
|
||||
|
||||
## 三个轮询入口切换
|
||||
|
||||
任务 2.37 已将实名、流量、网络三个 Gateway 轮询 Handler 的成功结果应用统一切换到 `CardObservation` 应用服务。Handler 仍保留原有卡资格判断、Redis 分片并发、卡流量互斥、配置间隔、失败重排和监控统计;旧的直接写库、直接扣套餐、直接停复机、直接缓存和风险卡分支已删除,避免新旧路径双写。每次 Gateway 查询前创建 Integration Log,查询失败或响应缺关键 ICCID 时记录失败并按原策略重排;请求关联使用 Integration Log ID 贯穿观测事件。手动刷新不生成额外 0/3/5 序列,后续序列由 2.38 负责。
|
||||
|
||||
## 实名观测公共写入闭环
|
||||
|
||||
统一 `ApplyCardObservation` 现在负责实名观测的唯一事务写入规则,手动 Gateway 刷新和后台人工纠偏已接入;现有实名轮询入口将在任务 2.37 与流量、网络轮询一起完成同批切换,期间不改变轮询配置、分片、间隔或失败重排。
|
||||
|
||||
- 标准观测使用类型明确的 `RealnameObservation` 和 `ObservationMetadata`,包含来源、场景、观测时间、观测 ID、请求/关联 ID 和脱敏摘要,不把 Gateway、Fiber、GORM、Redis 或 Asynq 类型带入领域层。
|
||||
- 应用用例使用 PostgreSQL `FOR UPDATE` 锁定卡,并以原实名状态作为条件更新;检查时间、实名状态、首次实名时间、激活派生字段、逆转确认状态和 Outbox 在一个事务内提交。
|
||||
- `first_realname_at` 仅在历史值为空且本次真实发生未实名到已实名时写入,重复成功或逆转后的再次实名不会覆盖首次时间。
|
||||
- 已实名卡出现未实名周期观测时,连续三次且处于同一 10 分钟窗口才落为未实名;前两次只更新检查时间和持久化确认窗口。逆转计数保存在 `tb_iot_card`,不再依赖可能与数据库回滚脱节的 Redis 计数。
|
||||
- 运营商解除实名回调不会增加或清空周期逆转计数,也不会修改本地实名状态;人工纠偏使用独立来源,可立即更正状态。
|
||||
- 状态真实变化时同事务写 `card.realname.changed` v1 Outbox。Worker 消费者以当前权威卡事实幂等执行首次实名卡/设备套餐激活和停复机评估;有效无变化观测不重复发布副作用。
|
||||
- 事务提交后才删除轮询卡缓存及遗留 Redis 逆转键;缓存删除失败只记录中文告警,不把已提交事实伪装成回滚。
|
||||
|
||||
迁移 `000179` 新增 `realname_reversal_count` 与 `realname_reversal_started_at`,并用 CHECK 约束计数只能保存 0~2;达到第三次时状态变化与计数清零在同一事务完成。down 迁移只删除该约束和两个字段。
|
||||
|
||||
统一 Audit Event 已按七月总 Change 的最新范围决策移出本次上线,不是 2.34 或生产上线阻塞项;Integration Log、Outbox、Domain Ledger 和 Access Log 仍分别承担外部交互恢复、可靠投递、状态事实和 HTTP 调试职责。
|
||||
|
||||
## ICCID 精确唯一性
|
||||
|
||||
现有迁移 `000131` 已建立 `tb_iot_card.iccid_19/iccid_20` 和普通部分索引。迁移 `000178` 在不改变 ICCID 展示、导入、模糊查询和卡识别规则的前提下,将两个索引升级为未删除数据范围内的部分唯一索引:
|
||||
|
||||
- `iccid_19`:全部未删除卡精确唯一;迁移前同时阻断空值、非 19 位和双列不一致。
|
||||
- `iccid_20`:未删除且非空时精确唯一;非空值必须是与原 ICCID 一致的 20 位值。
|
||||
- 软删除记录不阻塞相同 ICCID 的合法新记录。
|
||||
- 迁移不截断、不补位、不跨列匹配,也不自动删除、合并或修正冲突卡。
|
||||
|
||||
迁移在同一事务内锁定 `tb_iot_card`,先检查原 ICCID 长度、双列空值/长度及回填一致性,再按目标唯一索引相同的谓词扫描 19 位和 20 位冲突组,最后删除普通索引并以原名创建唯一索引。发现任一异常时只输出异常卡数和冲突组数的中文安全摘要并整体回滚,不留下半完成索引。
|
||||
|
||||
## 发布前异常清单
|
||||
|
||||
发布负责人在维护窗口执行迁移前,必须分别导出以下清单并指定数据修复责任人。查询结果包含完整 ICCID,只能存放在受控运维位置,不得写入应用日志或普通工单正文。
|
||||
|
||||
### 19 位冲突
|
||||
|
||||
```sql
|
||||
SELECT iccid_19, array_agg(id ORDER BY id) AS card_ids, COUNT(*) AS card_count
|
||||
FROM tb_iot_card
|
||||
WHERE deleted_at IS NULL
|
||||
AND iccid_19 IS NOT NULL
|
||||
GROUP BY iccid_19
|
||||
HAVING COUNT(*) > 1
|
||||
ORDER BY card_count DESC, iccid_19;
|
||||
```
|
||||
|
||||
### 20 位冲突
|
||||
|
||||
```sql
|
||||
SELECT iccid_20, array_agg(id ORDER BY id) AS card_ids, COUNT(*) AS card_count
|
||||
FROM tb_iot_card
|
||||
WHERE deleted_at IS NULL
|
||||
AND iccid_20 IS NOT NULL
|
||||
AND iccid_20 <> ''
|
||||
GROUP BY iccid_20
|
||||
HAVING COUNT(*) > 1
|
||||
ORDER BY card_count DESC, iccid_20;
|
||||
```
|
||||
|
||||
### 双列异常与不一致
|
||||
|
||||
```sql
|
||||
SELECT id, iccid, iccid_19, iccid_20, carrier_type
|
||||
FROM tb_iot_card
|
||||
WHERE deleted_at IS NULL
|
||||
AND (
|
||||
LENGTH(iccid) NOT IN (19, 20)
|
||||
OR iccid_19 IS NULL
|
||||
OR LENGTH(iccid_19) <> 19
|
||||
OR (LENGTH(iccid) = 19 AND (iccid_19 IS DISTINCT FROM iccid OR iccid_20 IS NOT NULL))
|
||||
OR (LENGTH(iccid) = 20 AND (iccid_19 IS DISTINCT FROM LEFT(iccid, 19) OR iccid_20 IS DISTINCT FROM iccid))
|
||||
)
|
||||
ORDER BY id;
|
||||
```
|
||||
|
||||
停止条件:任一查询返回记录时不得执行回调路由发布,也不得任取一张卡继续迁移。数据责任人必须核对运营商原始资料、资产归属和历史业务事实,按单独受控方案修复后重新扫描。仅软删除记录与有效卡重复允许存在,但必须单独登记为已确认的非阻塞项。
|
||||
|
||||
## 回滚
|
||||
|
||||
down 迁移只把 `idx_iot_card_iccid_19/20` 恢复为 `000131` 的普通部分索引,不删除双列、不修改卡数据,也不触碰其他表索引。若回调已经开放并依赖精确唯一语义,应先关闭回调入口并确认没有并发写入,再评估回滚。
|
||||
|
||||
## 验证状态
|
||||
|
||||
- 已静态核对 up/down 文件成对、索引名与 `000131` 一致、唯一索引谓词与冲突扫描谓词一致。
|
||||
- 已静态核对 `000179` up/down 成对、字段注释和 CHECK 约束一致,领域规则与持久化字段没有 Redis 事务依赖。
|
||||
- 已静态核对 `000180` up/down 成对、副作用状态 CHECK 与常量一致,流量用例不再直接覆盖日流量落盘表。
|
||||
- 已执行 `gofmt` 和 `go build ./...`;构建退出码为 0。
|
||||
- 按七月测试环境豁免,本轮未连接真实 PostgreSQL、未运行迁移测试;无冲突升级、19/20 位冲突、软删除重复和 down/up 重放验证延期到 6.1、6.3,不能标记为生产验收通过。
|
||||
58
docs/ur96-shop-business-owner/功能总结.md
Normal file
58
docs/ur96-shop-business-owner/功能总结.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# UR#96 店铺业务员归属功能总结
|
||||
|
||||
## 本次交付范围
|
||||
|
||||
本次完成店铺业务员归属的六个后端纵向切片:持久化、平台创建设置或继承、代理创建安全继承、独立编辑、列表/详情/候选 Query,以及通知接收人解析 Port/Adapter。
|
||||
|
||||
业务员归属是平台内部业务责任关系,只保存当前店铺自己的 `business_owner_account_id`。它不参与店铺层级、数据权限、佣金、分销或提现计算,也不会因父店铺后续修改而级联变化。
|
||||
|
||||
## 创建与编辑契约
|
||||
|
||||
- `POST /api/admin/shops` 支持存在性感知的 `business_owner_account_id`:平台/超管可显式设置、显式 `null` 清空;字段缺失时复制直属上级店铺当时保存的原始 ID。
|
||||
- 代理创建直属下级店铺时不得提交该字段;字段缺失时由服务端复制直属上级店铺当时保存的原始 ID,包括已停用或软删除账号的历史 ID。
|
||||
- `PUT /api/admin/shops/:id` 中字段缺失表示保持不变,显式 `null` 表示清空,正 ID 表示重新绑定。
|
||||
- 只有超级管理员和平台账号可以人工设置、清空或更换,并在事务内重新校验候选仍为启用、未删除的普通平台账号。
|
||||
- 店铺、初始主账号、账号角色、店铺角色和主/分佣钱包在同一 GORM 事务内创建,避免多表半成品。
|
||||
|
||||
Audit Event 写入按七月测试环境 Change 冻结到任务 6.5,本次没有把审计延期扩散到业务事务、权限或可靠性边界。
|
||||
|
||||
## Query 与前端契约
|
||||
|
||||
`GET /api/admin/shops` 新增 `business_owner_account_id` 精确筛选,并与其他筛选条件按 AND 组合。创建、编辑、列表和详情统一返回:
|
||||
|
||||
- `business_owner_account_id`
|
||||
- `business_owner_username`
|
||||
- `business_owner_phone_summary`
|
||||
- `business_owner_available`
|
||||
|
||||
列表只针对当前页收集业务员 ID,并通过一次批量查询投影账号名、前三后四手机号摘要和可用状态,不产生逐店铺 N+1。软删除账号使用只读历史投影保留摘要,并将 `business_owner_available` 标记为 `false`。
|
||||
|
||||
新增接口:
|
||||
|
||||
- `GET /api/admin/shops/:id`:返回与列表一致的店铺及业务员摘要,并继续应用现有店铺数据范围。
|
||||
- `GET /api/admin/shops/business-owner-candidates`:仅超级管理员和平台账号可调用,只返回启用、未删除的普通平台账号 ID、账号名和手机号摘要;支持用户名/手机号关键词、默认 20、最大 100 的分页。
|
||||
|
||||
代理端只读展示业务员摘要,不展示候选选择或清空控件。停用或删除账号应显示历史摘要和“不可用”,空归属显示为“-”。
|
||||
|
||||
## 通知接收人解析边界
|
||||
|
||||
`NotificationRecipientResolver` Port 由 PostgreSQL `RecipientResolver` Adapter 实现。它按目标店铺当前保存的业务员 ID 解析接收人,并同时返回当前启用、未删除的店铺主账号:
|
||||
|
||||
- 业务员只有仍为 `user_type=2`、启用且未删除时才返回。
|
||||
- 店铺主账号只有仍为代理类型、主账号、启用且未删除时才返回。
|
||||
- 同一账号按稳定账号 ID 去重并排序。
|
||||
- 店铺不存在、无归属或账号永久不可用时返回空集合,不作为无限重试错误。
|
||||
- 数据库故障仍返回可重试错误。
|
||||
- 解析不读取父店铺、祖先店铺或创建人,不会把代理数据权限误当作通知关系。
|
||||
|
||||
该接缝供公共站内通知的动态接收人解析复用;UR#96 本身不实现套餐临期、钱包低余额等业务触发规则,也不发送短信或企业微信通知。
|
||||
|
||||
## 迁移、发布与回滚
|
||||
|
||||
迁移 `000169_add_shop_business_owner` 为 `tb_shop` 增加 nullable bigint 字段和普通索引,不建立外键、不回填存量数据、不运行父子级联脚本。
|
||||
|
||||
发布前应只读核验店铺层级异常和平台账号状态;发布后抽查平台显式设置/清空、代理继承、父级修改不级联、历史不可用账号展示和接收人解析。应用回滚应保留字段和已产生的历史归属;down 迁移检测到任何非空归属时会拒绝删列,要求前向修复。
|
||||
|
||||
## 当前验证状态
|
||||
|
||||
已执行 `gofmt`、`git diff --check` 和 `go build ./...`。按本 Change 的测试环境豁免,本次未新增或运行 `_test.go`,也未连接真实 PostgreSQL/Redis;集成、HTTP、迁移演练和前端人工验收分别转任务 6.1、6.3 和 6.6。
|
||||
@@ -1,66 +1,12 @@
|
||||
BEGIN;
|
||||
-- 本脚本已停用。
|
||||
--
|
||||
-- 原实现会直接修改代理主钱包余额,绕过 Wallet Application、Domain Ledger、
|
||||
-- 幂等资金流水和 Outbox,因此在信用钱包切换后不再允许执行。
|
||||
-- 如需清理代理主钱包余额,必须先建立具有稳定业务单号的受控 Application 用例,
|
||||
-- 并在同一事务内写入权威资金流水与可靠事件;禁止恢复本文件中的历史直写逻辑。
|
||||
|
||||
-- 锁定代理主钱包,确认余额和冻结余额
|
||||
SELECT *
|
||||
FROM tb_agent_wallet
|
||||
WHERE shop_id = :shop_id
|
||||
AND wallet_type = 'main'
|
||||
AND deleted_at IS NULL
|
||||
FOR UPDATE;
|
||||
|
||||
-- 确认 frozen_balance = 0 且 balance > 0 后执行
|
||||
INSERT INTO tb_agent_wallet_transaction (
|
||||
agent_wallet_id,
|
||||
shop_id,
|
||||
user_id,
|
||||
transaction_type,
|
||||
amount,
|
||||
balance_before,
|
||||
balance_after,
|
||||
status,
|
||||
reference_type,
|
||||
reference_id,
|
||||
remark,
|
||||
metadata,
|
||||
creator,
|
||||
shop_id_tag,
|
||||
enterprise_id_tag,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
shop_id,
|
||||
:operator_user_id,
|
||||
'deduct',
|
||||
-balance,
|
||||
balance,
|
||||
0,
|
||||
1,
|
||||
NULL,
|
||||
NULL,
|
||||
'平台清理代理预充值剩余余额,历史消费流水保留',
|
||||
jsonb_build_object('reason', 'clear_agent_main_wallet_balance'),
|
||||
:operator_user_id,
|
||||
shop_id_tag,
|
||||
enterprise_id_tag,
|
||||
NOW(),
|
||||
NOW()
|
||||
FROM tb_agent_wallet
|
||||
WHERE shop_id = :shop_id
|
||||
AND wallet_type = 'main'
|
||||
AND deleted_at IS NULL
|
||||
AND balance > 0
|
||||
AND frozen_balance = 0;
|
||||
|
||||
UPDATE tb_agent_wallet
|
||||
SET balance = 0,
|
||||
version = version + 1,
|
||||
updated_at = NOW()
|
||||
WHERE shop_id = :shop_id
|
||||
AND wallet_type = 'main'
|
||||
AND deleted_at IS NULL
|
||||
AND balance > 0
|
||||
AND frozen_balance = 0;
|
||||
|
||||
COMMIT;
|
||||
DO $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION '代理主钱包直写清理脚本已停用,请使用受控 Wallet Application 用例';
|
||||
END
|
||||
$$;
|
||||
|
||||
Reference in New Issue
Block a user