audit full project flows and harden backend

This commit is contained in:
2026-08-03 16:03:44 +08:00
parent d8d62469d6
commit 95b6f2b69c
22 changed files with 505 additions and 890 deletions

View File

@@ -1,219 +1,65 @@
# 全项目业务流程测试报告 # 全项目流程审计与检查报告
测试日期2026-07-31 审计日期2026-08-03
测试分支:`main`
测试环境:仓库当前远程开发 PostgreSQL / Redis、Mock 验证码、Mock 支付边界 依据:`checking/03-系统与业务覆盖矩阵.md``checking/06-业务流程测试.md`
## 1. 结论 ## 1. 结论
本轮门禁通过: 本轮是代码、契约和本地门禁审计不是远程环境端到端验收。BF-01BF-15 中没有任何流程具备“全链路通过”证据4 项为部分具备11 项因实现缺口或环境缺口阻塞。旧报告中的远程数据库、浏览器和 Flutter 成功结论不得沿用。
- 核心成功路径全部通过。 状态定义:
- 本轮发现的 P00 个。
- 本轮发现并修复的 P15 类。
- 修复后平台、气站、配送三套后台的 91 个鉴权及资源列表 HTTP 检查全部通过。
- 用户注册、服务关系、商城、钱包和提现闭环的 56 个流程断言全部通过。
- 三套 Web 已用真实浏览器完成登录和关键页面回归。
- 两个 Flutter 客户端的静态分析及现有测试全部通过。
当前没有阻止提交和推送的未修复 P0/P1 - `部分具备`:存在主要实现和局部自动化证据,但缺远程依赖、外部沙箱或跨端 E2E
- `阻塞`:关键业务步骤不存在,或必要环境/安全凭据未提供。
## 2. 测试范围与数据隔离 ## 2. BF-01BF-15 检查矩阵
测试以 `BFT20260731...` 为唯一前缀创建气站、配送点、后台账号、用户、地址、商品、钱包、银行卡、商城订单和提现申请。未使用或修改既有业务主数据。 | 流程 | 状态 | 本轮证据 | 主要缺口 |
| --- | --- | --- | --- |
| BF-01 邀请注册与服务关系 | 阻塞 | 管理端与 API 可构建 | 无本轮远程 DB/Redis、二维码安全及跨端 E2E。 |
| BF-02 设备绑定与远程关阀 | 阻塞 | 内部命令/Outbox/Gateway/协议代码存在 | 无设备绑定、用户归属鉴权和公开远控入口。 |
| BF-03 高风险遥测与整改 | 阻塞 | 上行消息可持久化 | 无规则事件、自动关阀、通知、整改复检及开阀拦截闭环。 |
| BF-04 共享、群控与报修 | 阻塞 | 无充分证据 | 共享权限、群控结果聚合及撤权服务端校验未形成闭环。 |
| BF-05 下单、支付与任务 | 部分具备 | 支付回调一致性已修复并有单测 | 无远程事务、支付沙箱、商户/appid 显式校验和跨端任务 E2E。 |
| BF-06 超时关单与迟到回调 | 部分具备 | Worker 调度与 API 关单代码可构建 | Worker 无测试;无渠道超时、竞争和重启实测。 |
| BF-07 订单项退款入钱包 | 部分具备 | 退款模型/逻辑可静态检查 | 无远程并发、审核隔离、钱包守恒和渠道对账证据。 |
| BF-08 气瓶配送与回收 | 阻塞 | 三套后台可构建 | 无库存并发、轨迹、扫码取证、押金及结算 E2E。 |
| BF-09 人员准入与离线补传 | 阻塞 | App 代码存在 | Flutter 未验证;服务端强制前置和离线完整性无全链路证据。 |
| BF-10 安装/维修闭环 | 阻塞 | 需求与页面资源存在 | 缺服务端顺序状态机和强制证据集成测试。 |
| BF-11 钱包提现审核 | 部分具备 | 资金逻辑已有历史单测,后端门禁通过 | 本轮未连接远程 DB/Redis未执行并发、重复审批和打款回执 E2E。 |
| BF-12 组织隔离与轨迹访问 | 阻塞 | 鉴权框架和后台资源存在 | 无多组织远程数据、分页/导出/缓存串租户及敏感访问审计 E2E。 |
| BF-13 IoT 上行与 ACK | 阻塞 | 协议、Gateway、Topic 身份单测通过 | ACK 包号复用风险;无 MQTT Broker/真设备联调。 |
| BF-14 IoT Outbox 恢复 | 阻塞 | Outbox/Worker 投递代码存在 | 无双 Worker、崩溃窗口、重复投递、积压和死信测试。 |
| BF-15 审计与灾备 | 阻塞 | 部分事实有审计字段 | 未执行 PostgreSQL WAL 恢复、Redis 故障恢复和 RPO/RTO 测量。 |
测试结束后: ## 3. AC-01AC-25 汇总
- 已驳回仍处于待处理状态的并发测试提现,恢复其预扣余额。 | 状态 | AC |
- 已归档本轮创建的气站、配送点、后台账号、用户、地址、服务关系、商品和分类等可变主数据。 | --- | --- |
- 钱包流水、提现申请、审核记录等不可变资金与审计证据按约定保留,并可通过测试前缀追踪。 | 局部代码证据 | AC-02、AC-07、AC-08、AC-24、AC-25 |
- Redis 验证码使用短 TTL未修改既有键本轮测试键会自动过期。 | 关键实现或 E2E 阻塞 | AC-01、AC-03AC-06、AC-09AC-23 |
## 3. 核心流程结果 “局部代码证据”不代表验收通过。AC-02/AC-25 仍受 ACK 关联和 Broker 联调阻塞AC-07 仍受支付沙箱和商户身份检查阻塞AC-08 仍受远程并发验证阻塞AC-24 仍需数据库 Schema 全量核查。
### 3.1 鉴权与数据域 ## 4. 本轮实际验证
- 平台 root 正确密码登录成功,错误密码被拒绝。 | 范围 | 结果 |
- 未携带令牌访问受保护接口被拒绝。 | --- | --- |
- 气站管理员、配送管理员分别登录成功。 | API | `go test ./...``go vet ./...`、build 通过 |
- 气站令牌访问平台后台、用户端接口均被拒绝。 | Worker | test/vet/build 通过,但无测试文件 |
- 用户完成验证码注册、密码登录、资料及地址查询。 | IoT Gateway | test/vet/build 通过 |
- 用户注册时正确建立气站和配送点服务关系。 | IoT Server | test/vet/build 通过,含 Topic 身份回归测试 |
| platform_admin | type、contract、build 通过lint 有 111 warnings/12 infos |
| gas_admin、delivery_admin | type、lint、build 命令通过;存在非阻断 lint 告警 |
| site | build 通过4/4 托管测试通过 |
| Flutter Apps | 命令启动超时,未取得结果 |
| 远程 PostgreSQL/Redis | API YAML 已恢复远程配置,但本轮未执行集成流程 |
### 3.2 商城与幂等 ## 5. 远程执行前置清单
- 创建并启用商城分类和商品 1. 确认 YAML 配置指向隔离库,并为 Redis 使用独立 DB/前缀
- 用户按服务端价格创建 80 元订单 2. 限制配置文件访问权限,不在命令、报告或日志中记录连接值
- 相同 `request_no` 重复创建只返回同一订单,未重复扣减库存 3. 明确测试数据前缀和保留策略;禁止清库和删除资金/审计事实
- 钱包支付成功后订单进入已支付状态 4. 启动支付沙箱、MQTT 模拟器和四后端进程,逐项记录 UI/API、数据库、Outbox、回执和审计证据
- 已支付订单重复支付被拒绝 5. 先解决 P0/P1 实现缺口,再执行完整 BF 门禁;否则测试结果只能是预期阻塞
### 3.3 钱包资金闭环
以人工充值 100 元且可提现为起点:
1. 充值后:`balance=10000``withdrawal_balance=10000`
2. 重复充值请求保持幂等,未重复入账。
3. 商城消费 80 元后:`balance=2000``withdrawal_balance=2000`
4. 再申请提现 100 元被拒绝,余额保持不变。
5. 申请提现 10 元后立即预扣:两类余额均从 2000 降至 1000。
6. 重复提交同一提现申请未重复预扣。
7. 平台驳回后两类余额均恢复至 2000。
8. 重复驳回未重复返还,余额没有凭空增加。
9. 未审核提现直接完成被拒绝。
10. 审核通过并完成提现 10 元后:`balance=1000``withdrawal_balance=1000`
11. 相同交易号重复完成保持幂等。
12. 两个并发 15 元提现请求面对 20 元余额时仅一个成功,最终余额为 5 元,没有透支。
以上结果确认此前的重复支出、驳回增发和完成不扣总余额问题已闭环。
### 3.4 全资源 HTTP 扫描
修复后独立执行 91 个回归检查,结果 `91/91`
- 平台总后台47 个资源列表。
- 气站后台20 个资源列表。
- 配送后台18 个资源列表。
- 另含三端登录、平台错误密码和未认证访问检查。
所有接口均返回预期业务码,没有 404、500、跨端越权或 SQL 错误。
## 4. 发现并修复的问题
### P1-1 远程库迁移被历史非空数据阻断
`gasorder_track_point.received_at` 新增为非空字段时GORM 直接执行 `ADD NOT NULL`,已有轨迹行导致迁移失败。
修复:
- 迁移前先添加可空字段。
- 使用既有 `occurred_at`,再退化到 `created_at` / 当前时间回填。
- 回填完成后再设置非空。
- PostgreSQL 和 MySQL 分别使用对应 SQL。
- 已在远程开发库实际迁移成功。
### P1-2 联表资源查询的 `status` 字段歧义
气站后台的配送账号、人员资质等联表资源使用裸 `status <> 3`PostgreSQL 返回 `column reference "status" is ambiguous`
修复:
- `ActiveRecords` 统一使用 GORM 当前表限定 `"<table>"."status"`
- 气站配送账号查询抽取明确的数据域查询函数。
- 增加联表 SQL 回归测试。
- 修复后气站和配送全部资源列表通过。
### P1-3 配送钱包充值页面契约存在但 GET 路由缺失
配送前端声明 `/wallet_recharge` 资源页,后端仅有 POST 充值动作,列表页请求 GET 时直接 404。
修复:
- 增加配送点范围内的充值流水列表和详情接口。
- 仅返回当前配送点钱包中 `delivery_admin_recharge` 类型流水。
- 同步配送前端生成契约。
- 增加路由回归断言。
### P1-4 气站、配送后台登录令牌写入与读取使用不同键
登录 Store 将令牌写入 `token`HTTP 客户端却读取 `gas_admin_token` / `delivery_admin_token`。表现为提示“登录成功”后资料请求 401并退回登录页。
修复:
- HTTP 客户端统一调用各端 `getToken()`
- 气站端固定使用 `gas_admin_token`
- 配送端固定使用 `delivery_admin_token`
- 浏览器回归确认两端均能进入运营首页。
### P1-5 隐藏关联页被前端权限守卫错误送到 404
`hideInMenu` 的资质、地址、关联明细页复用已授权 `menuCode`,但权限守卫仍强制要求路由名存在于服务端菜单树。
修复:
- 三套后台统一允许“服务端菜单路由存在”或“当前路由 `menuCode` 已授权”。
- 后端 JWT、角色和数据域校验保持不变。
- 浏览器回归确认气站人员资质隐藏页可访问。
## 5. Web 浏览器验证
使用本地真实前端和远程开发 API
- 平台后台 `5173`root 登录、运营仪表盘数据、提现列表加载通过。
- 气站后台 `5175`:隔离气站管理员登录、首页数据、人员资质隐藏页加载通过。
- 配送后台 `5176`:隔离配送管理员登录、首页、钱包充值页加载通过。
- 关键页面控制台无 error / warning。
## 6. Flutter 客户端
### 用户 App
- `flutter analyze`:通过,无问题。
- `flutter test`2 项通过。
### 服务 App
- `flutter analyze`:通过,无问题。
- `flutter test`3 项通过。
设备级运行未覆盖,原因:
- 两个工程当前只包含 Android / iOS 目录,没有 macOS / Web 平台。
- 本机没有 Android SDK。
- Flutter 报两个工程未配置为可构建的 iOS 应用。
- 当前可见设备只有 macOS 和 Chrome无法承载现有工程。
因此客户端结论限定为“静态分析和自动化测试通过”,不声明真机/模拟器运行通过。
## 7. Worker、IoT 与外部边界
- Worker`go test ./...`、构建通过;当前为 Mock 边界。
- IoT`go test ./...`、构建通过;未连接真实 MQTT Broker 或设备。
- 未连接真实短信、支付渠道和 MQTT。
- 验证码与支付只验证当前 Mock 实现的状态机、幂等和资金记账。
## 8. 实际执行的回归命令
```bash
cd backend/api
go test ./...
go vet ./...
go build ./cmd/main/main.go
cd backend/worker
go test ./...
go build ./cmd/main/main.go
cd backend/iot
go test ./...
go build ./cmd/main/main.go
cd frontend/platform_admin
pnpm type:check
pnpm lint
pnpm contract:check
pnpm build
cd frontend/gas_admin
pnpm type:check
pnpm lint
pnpm contract:check
pnpm build
cd frontend/delivery_admin
pnpm contract:sync
pnpm type:check
pnpm lint
pnpm contract:check
pnpm build
cd apps/user_app
flutter analyze
flutter test
cd apps/service_app
flutter analyze
flutter test
```
补充说明:三套前端 lint 命令返回成功,但仍报告工程模板既有的未使用代码、单词组件名和 Node import 风格 warnings本轮未批量改写这些非业务告警。

View File

@@ -1,640 +1,72 @@
# 全项目代码审计报告 # 全项目代码审计报告
审计日期2026-07-31 审计日期2026-08-03
审计对象`apps/*``backend/*``frontend/*` 审计范围`apps/*``frontend/*``backend/{api,worker,iot-gateway,iot-server}`
审计方式:只读代码审计、静态检查、单元测试和本地构建 依据:`checking/*``docs/01``docs/12`、仓库实现与测试结果
审计基线:仅以实际代码、路由、数据模型、客户端调用和测试为依据,未将 `docs/*` 作为需求或验收依据 ## 1. 发布结论
## 1. 结论 当前版本**不满足生产发布条件**。本轮确认并修复 4 类高风险实现问题,但设备绑定/安全整改等核心业务尚未形成可执行闭环,远程 PostgreSQL/Redis 集成、真实支付沙箱、MQTT Broker 级联调和灾备恢复尚无本轮证据。
当前代码**不具备生产发布条件** 风险统计未修复项P0 2 项、P1 6 项、P2 4 项。代码修复不等于对应跨端流程已验收
本次确认: ## 2. 本轮已修复
- P02 项 | 编号 | 等级 | 问题 | 修复与验证 |
- P17 项 | --- | --- | --- | --- |
- P28 项 | FIX-02 | P0 | 通用迁移会直接删除旧支付/退款表,破坏资金及审计事实 | 改为检测旧表并中止迁移,要求专用保留迁移;新增 sqlmock 回归测试。 |
- P33 项 | FIX-03 | P1 | 支付回调可能在目标业务记录不存在时仍把支付单标记成功 | 业务状态推进强制恰好更新一行;补充 0 行、1 行和数据库错误测试;重复回调校验渠道流水号。 |
| FIX-04 | P1 | IoT Server 信任帧内设备号,可利用他人 Topic 伪造 ACK | 设备身份取自已认证 Topic帧内设备号不一致时不转发解码 ACK补充 Topic 单元测试。 |
| FIX-05 | P2 | Worker/IoT Server 使用无明确超时的 HTTP 客户端;内部令牌普通比较 | 增加 12/15 秒超时;支付 Worker 令牌改为常量时间比较。 |
最先需要阻断发布的事项是: ## 3. 未修复发布阻断项
1. 仓库历史中存在明文的外部数据库、Redis 等敏感配置;固定验证码又可直接进入注册、登录和重置密码流程。 ### P0
2. 钱包的 `balance``withdrawal_balance` 记账口径不闭合,可产生重复支出、拒绝提现后余额凭空增加、提现完成后总余额不减少等资金错误。
3. Worker、IoT 和上传存储仍明确是 Mock实际异步与设备链路不存在。
4. 工作人员“作业前置检查”仅用于展示,业务接口没有服务端强制执行。
因此,测试和构建全部通过并不能推导出业务可上线;现有测试主要验证路由、模型和少量纯函数,没有覆盖上述关键资金、鉴权、幂等和跨端流程 1. **核心设备安全闭环未实现。** 当前 API 只有内部 IoT 命令、Outbox 和设备消息路由,没有面向用户的设备绑定/归属鉴权、远控动作、高风险开阀拦截、安全事件、整改和复检闭环。AC-01AC-06、AC-12、AC-15、AC-22 不能验收
2. **真实远程依赖及资金链路无本轮证据。** API 已按要求恢复 YAML 中的远程 PostgreSQL/Redis 配置但本轮没有执行远程数据库事务、Redis 并发/TTL、支付宝/微信沙箱回调及退款对账。BF-05BF-07、BF-11 不能作为通过。
### 等级定义 ### P1
| 等级 | 含义 | 1. IoT ACK 仅按 `device_id + uint16 packet_number + 活跃状态` 批量更新;包号重启后可复用,且没有约束同设备同包号仅一个活跃命令,存在误确认风险。
|---|---| 2. Worker 没有自动化测试关单扫描、Outbox 重试、双 Worker 认领、进程重启和坏响应均无回归保护。
| P0 | 可直接造成账户接管、资金损失、核心数据破坏或必须立即处置的密钥泄露 | 3. 微信回调依赖 SDK 验签解密但业务代码未显式比对回调商户号、appid 和币种与本地配置/支付单一致。
| P1 | 核心流程不可用、可绕过关键业务控制或上线即产生严重错误 | 4. IoT Server TLS、设备认证和 Topic ACL 未以生产模式启动门禁强制Broker 级伪造、越权订阅和断线重连未验证。
| P2 | 在异常、并发、重试或特定数据下产生错误,或形成明显维护/兼容风险 | 5. 服务人员准入、离线补传强制字段、安装/维修/安检完成条件尚无完整服务端闭环证据。
| P3 | 低风险冗余、局部质量或性能问题 | 6. PostgreSQL 全量加 WAL 恢复、Redis 故障恢复及实际 RPO/RTO 未演练。
置信度“高”表示从当前代码可以完整证明;“中”表示代码证据明确,但删除字段或迁移前仍需核对线上数据及外部消费者。 ### P2
## 2. P0发布阻断 1. 支付创建采用“先查询再插入”,并发相同幂等请求可能向渠道重复建单后在本地唯一键失败;需数据库预留事实或冲突回读设计。
2. IoT Outbox 没有明确死信上限和人工恢复状态;持续失败会无限重试。
3. 三套管理端 lint 仍有约百条告警,规则目前不会阻断质量门禁。
4. Flutter 工具在本机启动超时,两个 App 的 analyze/test 未得到本轮结果。
### P0-01 仓库内存在敏感连接配置,固定验证码可形成账户接管链路 ## 4. 模块审计结论
置信度:高 | 模块 | 结论 | 主要证据/缺口 |
| --- | --- | --- |
| API | 有条件通过静态门禁 | 测试、vet、build 通过;支付新增回归通过;核心设备/安全/服务闭环缺失。 |
| Worker | 构建通过、业务门禁不通过 | test/vet/build 通过,但无测试文件,远程依赖与故障恢复未执行。 |
| IoT Gateway | 局部门禁通过 | test/vet/build 通过;缺真实 Gateway→IoT Server 故障注入。 |
| IoT Server | 局部门禁通过 | 协议及 Topic 测试、vet、build 通过;缺 Broker ACL/TLS/真设备验证。 |
| platform_admin | 构建通过 | type、contract、build 通过lint 111 warnings/12 infos。 |
| gas_admin / delivery_admin | 构建通过 | type、lint、build 命令通过lint 仍有大量非阻断告警。 |
| site | 通过本地站点门禁 | 先 build 后执行 4 个托管路由/打包测试,全部通过。 |
| user_app / service_app | 未验证 | Flutter 命令超时;不能沿用历史结果。 |
**证据** ## 5. 敏感配置与迁移要求
- `backend/api/etc/platform_dev.yaml:7` 包含公网 PostgreSQL 连接地址、账号和明文密码 - API 按当前项目约定从 YAML 读取远程连接;该文件含敏感配置,应限制仓库和主机访问,并检查 Git 历史、CI 日志及共享记录
- `backend/api/etc/platform_dev.yaml:10` 包含公网 Redis 地址和明文凭据 - 报告、命令输出和日志不得记录远程连接值
- `backend/api/etc/platform_dev.yaml:12,18,24` 分别包含固定 JWT 密钥/固定验证码/字段加密密钥性质的配置 - 旧资金表迁移需单独方案,至少包含数据盘点、不可变备份、字段映射、对账、回滚和审计批准
- 该文件由 Git 跟踪,且至少出现在多个历史提交中,单纯修改当前文件不能消除历史泄露。
- `backend/api/internal/logic/common/client_auth.go:60-67` 将全局固定验证码写入 Redis。
- `backend/api/internal/logic/common/client_auth.go:73-85` 使用该固定值完成验证和消费。
- 用户与工作人员的注册、验证码登录、重置密码、支付密码设置均复用该验证能力。
**影响** ## 6. 建议修复顺序
- 外部数据库和 Redis 可能被直接访问、篡改或拖库 1. 确认 YAML 中的远程凭据仅指向隔离测试环境,并限制文件访问权限
- 已知固定验证码的人可为目标手机号申请新的 `request_identity`,随后尝试注册、验证码登录或重置密码 2. 实现设备绑定、归属鉴权、安全事件、自动关阀、整改复检和开阀拦截
- 字段加密密钥一旦与密文数据同时泄露,敏感字段的静态加密失去保护 3. 为 ACK 增加持久化包号分配/命令相关标识及唯一活跃约束
4. 补齐 Worker、支付并发、Broker ACL/TLS 和跨进程故障恢复测试。
**根因** 5. 完成 BF-01BF-15 的远程集成、真浏览器/Flutter E2E、支付沙箱和灾备演练后再评估发布。
- 开发配置被当作可提交配置管理。
- Mock 验证码能力直接复用了真实身份流程,没有环境级硬隔离。
**最小整改**
1. 立即轮换数据库、Redis、JWT、字段加密等所有已提交凭据先吊销旧值再更新部署。
2. 核查相关服务的访问日志、异常登录、密码重置和数据导出记录。
3. 将敏感值迁移到环境变量或密钥管理系统,仓库只保留无效示例。
4. 清理 Git 历史中的敏感内容,并要求所有已有克隆重新同步。
5. Mock 验证码只允许在不可访问生产数据的本地环境启动;生产启动时发现 Mock 开关或固定码应直接失败。
**迁移风险**
- 字段加密密钥轮换需要双密钥读取或批量重加密方案,不能直接替换后让历史数据失效。
**复测**
- 对仓库当前树和完整 Git 历史执行密钥扫描。
- 在非本地环境验证 Mock 验证码无法启动。
- 使用旧密钥、旧数据库凭据和旧 Redis 凭据验证均已失效。
- 覆盖目标手机号的验证码登录和重置密码攻击用例。
### P0-02 钱包双余额账本不闭合,可重复支出并制造余额
置信度:高
**证据**
1. 平台可提现充值同时增加两列:
- `backend/api/internal/logic/platform/wallet/wallet.go:232-239`
2. 商城支付只扣 `balance`,不扣 `withdrawal_balance`
- `backend/api/internal/logic/client/user/shop.go:167-189`
3. 用户提现申请只扣 `withdrawal_balance`,不扣 `balance`
- `backend/api/internal/logic/common/client_wallet.go:372-398`
4. 提现完成只更新申请状态和外部交易号,不扣 `balance`
- `backend/api/internal/logic/platform/wallet/wallet.go:336-366`
5. 配送后台创建提现申请时不预扣 `withdrawal_balance`,仅在查询时减去待处理申请:
- `backend/api/internal/logic/delivery/finance.go:145-188`
6. 平台驳回任何提现申请都会把申请金额加回 `withdrawal_balance`
- `backend/api/internal/logic/platform/wallet/wallet.go:313-323`
**可复现场景**
- 钱包充值 100 元且标记可提现后:`balance=100``withdrawal_balance=100`
- 商城消费 80 元后:`balance=20``withdrawal_balance=100`
- 再申请提现 100 元会通过,形成 80 元消费加 100 元提现。
- 配送后台申请提现时未预扣;若平台驳回,现有代码仍加回金额,可将 100 元可提现余额变成 200 元。
- 提现最终完成也不会减少总余额,余额和实际资金继续背离。
**影响**
- 直接资金损失。
- 钱包余额、可提现余额、提现申请和流水无法对账。
- 客户可在正常 API 流程内触发,不需要数据库权限。
**根因**
- `withdrawal_balance``balance` 的可提现子集,但各交易没有在同一事务内维护该不变量。
- 用户提现和配送后台提现采用了两套互不兼容的预扣策略。
- 提现没有完整的冻结额、解冻、出账和不可变流水模型。
**最小整改**
1. 立即关闭充值、余额支付和提现写入口,先冻结风险窗口。
2. 明确统一不变量,例如 `0 <= available_withdrawable <= available_balance`,并增加“冻结余额/冻结可提现余额”。
3. 支付、提现申请、驳回、完成必须在事务和行锁内同时更新余额、冻结额、流水。
4. 合并用户与配送后台提现逻辑,禁止各自实现不同扣减策略。
5. 对历史钱包、流水、提现、商城订单做全量对账和差异修复。
**迁移风险**
- 不能只改代码;历史余额已经可能不可信。
- 修复前需以外部支付记录、订单、提现回执和不可变流水重建余额,避免把现有错误余额作为初始事实。
**复测**
- 覆盖充值→支付→提现、提现→驳回、提现→完成、重复请求、并发支付/提现。
- 对每一步断言余额、可提现余额、冻结额和流水守恒。
- 增加基于随机交易序列的账本不变量测试。
## 3. P1核心流程和安全控制
### P1-01 Worker 与 IoT 仅为阻塞式 Mock 进程
置信度:高
**证据**
- `backend/worker/cmd/main/main.go:14-20` 只初始化后等待退出信号,并明确输出 Redis Streams consumer 未启用。
- `backend/iot/cmd/main/main.go:14-20` 只初始化后等待退出信号,并明确输出 MQTT Broker 未连接。
- 两模块合计约 150 行 Go 代码没有消费者、重试、Outbox 投递、MQTT 会话、命令回执或业务测试。
**影响**
- 异步通知、对账、超时处理等依赖 Worker 的流程不会执行。
- 设备遥测、远程命令和回执链路不存在。
- 进程可以成功构建和启动,但只会制造“服务在线”的假象。
**最小整改**
- 发布清单中明确排除这两个能力,或在发布前实现真实适配、健康检查、失败重试、幂等和可观测性。
- 健康检查必须区分“进程存活”和“已连接 Redis Streams/MQTT”。
### P1-02 关闭 Mock 后验证码功能全部失效,代码中没有真实发送适配器
置信度:高
**证据**
- `backend/api/internal/logic/common/client_auth.go:60-63` 无条件保存 `MockVerificationCode`,没有生成随机码或调用短信渠道。
- `backend/api/internal/logic/common/client_auth.go:73-76``MockVerificationEnabled=false` 时直接拒绝所有验证码。
- 未发现短信发送接口、供应商适配器或发送结果处理。
**影响**
- 为安全而关闭 Mock 后,验证码登录、注册、密码重置和支付密码相关流程全部不可用。
**最小整改**
- 建立真实验证码生成、散列保存、发送、频率限制、失败处理和审计链路。
- Mock 与真实实现应通过依赖注入隔离,禁止在业务函数内用全局开关混用。
### P1-03 工作人员作业预检可以绕过
置信度:高
**证据**
- `backend/api/internal/logic/client/staff/auth.go:67-108` 计算机构归属、资质有效性、在岗状态和 `can_work`
- `backend/api/internal/logic/client/staff/auth.go:20-52` 的登录实现只校验账户状态、角色和密码,注释所称“资质有效”未被执行。
- `backend/api/internal/logic/client/staff/work.go:72-85``backend/api/internal/logic/client/staff/delivery.go:55-230` 的开始、轨迹、到达、异常、恢复、签收等接口没有校验同一套预检条件。
- `apps/service_app/lib/app/router.dart:12-98` 只按是否有 Token 路由;可直接进入 `/work``/tasks/:identity`,没有强制 `can_work`
**影响**
- 离岗、资质过期或缺少机构归属的人员仍可绕过页面直接调用作业 API。
**最小整改**
- 将作业资格校验提取为服务端中间件/领域守卫,挂载在所有会改变工单、配送、轨迹、证据的接口上。
- 客户端预检仅负责展示,不作为可信控制。
### P1-04 三套支付事实互相割裂,真实支付不会进入看板支付统计
置信度:高
**证据**
- 重叠模型:
- `backend/api/internal/models/fin_payment.go:8-16`
- `backend/api/internal/models/wallet_payment.go:5-20`
- `backend/api/internal/models/gasorder_payment.go:5-12`
- 除 Mock seed 外,未发现业务代码创建 `FinPayment``WalletPayment``GasorderPayment`
- 商城实际支付只更新 `EcOrder` 并创建 `WalletRecord`
- `backend/api/internal/logic/client/user/shop.go:180-191`
- 平台支付金额和渠道统计读取 `WalletPayment`
- `backend/api/internal/logic/platform/dashboard/statistics.go:106-132`
- 气体订单的金额调整又以 `GasorderPayment` 是否存在作为“已支付”判断:
- `backend/api/internal/logic/delivery/order.go:369-375`
**影响**
- 真实商城支付成功后,看板支付金额仍可能为零。
- 支付页面、财务支付、钱包支付、气体订单支付显示不同事实。
- 气体订单代码具备“已支付后禁止改价”判断,但当前流程没有形成对应支付记录。
**最小整改**
- 选定唯一支付主事实和订单支付关联模型。
- 所有支付渠道在同一事务/事件链路写入统一支付事实和钱包流水。
- 看板、财务、气站、配送后台统一读取同一事实或受控聚合。
**迁移风险**
- 三张表不能直接删;需先核对线上数据和外部消费者,建立字段映射与去重规则。
### P1-05 用户 App 的商城流程在“请前往订单页支付”后中断
置信度:高
**证据**
- 下单成功明确提示前往订单页支付:
- `apps/user_app/lib/ui/features/shop/shop_page.dart:59-70`
- `ClientRepository` 只有创建和列表方法,没有调用后端已有的支付、取消、确认收货接口:
- `apps/user_app/lib/data/repositories/client_repository.dart:43-117`
- 订单页只是三个只读列表:
- `apps/user_app/lib/ui/features/orders/orders_page.dart:7-47`
- 后端实际提供 `/shop/orders/:identity/pay``cancel``confirm-receipt`
- `backend/api/internal/routers/client.go:47-51`
**影响**
- 用户可以下单并占用库存,但不能在客户端完成支付、取消或确认收货。
**最小整改**
- 增加订单详情及基于服务端状态的支付/取消/确认动作。
- 支付请求必须持久化并复用幂等号,不能每次点击生成新值。
### P1-06 冻结或停用的钱包仍可在客户端执行资金操作
置信度:高
**证据**
- 平台允许把钱包改为启用、停用或冻结:
- `backend/api/internal/logic/platform/wallet/wallet.go:191-200`
- 客户端 `ensureWallet` 对已存在钱包直接返回,不检查状态:
- `backend/api/internal/logic/common/client_wallet.go:41-57`
- 商城支付和用户提现查询钱包时也未限制 `status`
- `backend/api/internal/logic/client/user/shop.go:167-177`
- `backend/api/internal/logic/common/client_wallet.go:366-398`
**影响**
- 风控冻结不能阻止支付和提现。
**最小整改**
- 所有资金写操作在事务内按 `status=enable` 锁定钱包;冻结后禁止新交易,仅允许受控冲正/退款。
### P1-07 上传能力仍是本地 Mock且只按扩展名判定文件类型
置信度:高
**证据**
- `backend/api/internal/logic/upload/upload.go:36-37,90-95` 明确为本地 Mock 存储。
- `backend/api/internal/logic/upload/upload.go:45-49` 只检查文件名扩展名和大小。
- `backend/api/internal/logic/upload/upload.go:79-83` 将客户端声明的 Content-Type 原样返回,没有校验文件签名或实际 MIME。
**影响**
- 伪装成图片/PDF/视频的任意内容可进入存储。
- 单机本地目录无法支持多实例、一致备份、受控下载或恶意文件隔离。
**最小整改**
- 校验魔数和解码结果,重编码图片,对视频/PDF进行独立扫描。
- 使用私有对象存储、短期授权访问、病毒扫描、审计和生命周期策略。
## 4. P2一致性、重试与维护风险
### P2-01 多处“幂等”只处理唯一键冲突,没有验证请求归属和载荷
置信度:高
**证据**
- 签收回执按全局 `request_no` 返回任意既有确认记录,没有校验订单和当前工作人员:
- `backend/api/internal/logic/client/staff/delivery.go:226-231`
- 商城支付成功后使用相同 `request_no` 重试,会先因订单状态不再是待支付而失败,无法返回原结果:
- `backend/api/internal/logic/client/user/shop.go:161-195`
- 创建工单遇到重复 `request_no` 直接返回数据库错误:
- `backend/api/internal/logic/client/user/address_ticket.go:98-109`
- 打卡重复请求返回时,响应中的 `work_status` 根据本次请求重新计算,而不是根据既有记录:
- `backend/api/internal/logic/client/staff/work.go:46-69`
**影响**
- 网络超时后的安全重试可能变成失败、误报成功,甚至返回另一订单的结果。
**最小整改**
- 幂等记录至少绑定:主体、资源、动作、请求载荷摘要和最终响应。
- 相同幂等键但载荷不同必须返回稳定冲突错误;相同载荷返回已保存结果。
### P2-02 服务 App 保存了签收幂等号,但提交时重新生成
置信度:高
**证据**
- 草稿保存 `request_no`
- `apps/service_app/lib/ui/features/work/work_detail_page.dart:114-123`
- 页面没有把该值传给仓库:
- `apps/service_app/lib/ui/features/work/work_detail_page.dart:124-136`
- 仓库在每次提交时重新生成 UUID
- `apps/service_app/lib/data/repositories/service_repository.dart:92-108`
**影响**
- 上传或请求响应丢失后,用户重试会使用新幂等键,可能重复形成签收动作。
**最小整改**
- `submitDeliveryReceipt` 必须接收并复用草稿中的 `requestNo`;删除草稿前保留服务端最终结果。
### P2-03 商城订单的组织字段在真实创建流程中永远为零
置信度:高
**证据**
- `EcOrder` 声明 `gas_station_id``delivery_point_id`
- `backend/api/internal/models/ec_order.go:15-16`
- 真实下单创建 `EcOrder` 时未赋值:
- `backend/api/internal/logic/client/user/shop.go:54-59`
- 仅 Mock seed 为这两个字段赋值。
**影响**
- 按气站/配送点统计、权限范围、履约分派或结算会得到空组织。
**最小整改**
- 若商城订单必须归属机构,应在服务端从服务关系或商品归属中确定并写入快照。
- 若业务确实为平台统一商城,应迁移后移除这两个误导字段及相关索引/展示。
### P2-04 三个 Web 管理端以复制方式维护,且配送端包含整段不可达配置
置信度:高
**证据**
- 三个 `src + scripts` 各约 1.9 万行。
- 平台端与气站端有 80 个逐字节相同文件,约 18,280 行;平台端与配送端有 79 个相同文件,约 18,199 行。
- 三端的 `CrudListPage.vue` 均为 1,175 行且内容相同。
- `frontend/delivery_admin/src/api/resources.ts:287-384` 复制了完整平台资源定义。
- `frontend/delivery_admin/src/api/resources.ts:386-426` 定义 `gasOverrides`,但导出只使用 `deliveryOverrides`
- `frontend/delivery_admin/src/api/resources.ts:428-470`
**影响**
- 一个通用缺陷需要在三处同步修复,极易发生漂移。
- 配送包携带与本端无关的大量资源、字段和动作配置。
**最小整改**
- 将 API 客户端、会话、通用 CRUD、字段渲染、权限和契约检查提取到工作区共享包。
- 各管理端只维护入口、主题和本端资源覆盖。
- 直接删除前先用引用检查确认不可达;当前 `gasOverrides` 已可由导出链证明不可达。
### P2-05 数据库自增 `id` 被作为公共 API 字段并在三端展示
置信度:高
**证据**
- 公共实体把内部主键序列化为 `id`
- `backend/api/internal/models/entity.go:10-16`
- 公共资源响应显式保留记录自身 `id`
- `backend/api/internal/logic/common/resource.go:450-486`
- 三端通用列表固定显示 `ID` 列:
- `frontend/platform_admin/src/views/shared/CrudListPage.vue:23-26`
**影响**
- `id``identity` 成为两个公开身份字段,增加前端误用和外部耦合。
- 连续主键还会泄露记录规模和创建顺序。
**最小整改**
- 数据库 `id` 本身不是冗余字段,不应删除;应从 HTTP 响应和前端移除。
- 迁移前检查外部调用方是否仍使用 `id`,提供兼容窗口。
### P2-06 两个 Flutter API 客户端缺少超时和统一的 401 会话失效处理
置信度:高
**证据**
- `apps/user_app/lib/data/services/api_client.dart:47-76`
- `apps/service_app/lib/data/services/api_client.dart:41-80`
- 请求直接等待 `_client.send`/上传结果,没有连接、读取或总超时。
- 401 只抛异常,没有清理安全存储中的 Token。
- 两个路由都仅以“Token 字符串非空”判断已登录:
- `apps/user_app/lib/app/router.dart:14-21`
- `apps/service_app/lib/app/router.dart:12-19`
**影响**
- 弱网下页面可能长期挂起。
- Token 过期或被撤销后,用户会停留在已登录路由并持续收到请求错误。
**最小整改**
- 建立共享客户端层统一超时、有限重试、取消、401 清会话和重新登录。
- 资金写操作只能依赖幂等键重试,不能盲目重放。
### P2-07 前后端契约检查只比较资源名、模式和路由字符串
置信度:高
**证据**
- `frontend/platform_admin/scripts/check-backend-contract.mjs:12-37` 通过正则读取 `define(name, mode)`,再检查资源路径和路由源码是否包含字符串。
- 未校验 HTTP 方法、动作路径、请求字段、必填项、枚举、响应字段和错误码。
- 气站、配送端脚本采用同类实现。
**影响**
- `contract:check` 通过不能发现支付动作缺失、字段漂移或错误 HTTP 方法。
**最小整改**
- 使用结构化 OpenAPI/Schema 生成客户端与字段类型。
- 至少对每个动作校验方法、路径、请求/响应结构,并增加真实路由契约测试。
### P2-08 钱包创建存在并发竞态,银行卡敏感字段加密错误被忽略
置信度:高
**证据**
- `ensureWallet` 采用先查后创建,唯一键冲突时不回查已有钱包:
- `backend/api/internal/logic/common/client_wallet.go:41-57`
- 首次并发请求时,一个请求可能收到数据库唯一约束错误。
- 绑卡时只处理卡号加密错误,身份证和手机号加密错误被丢弃:
- `backend/api/internal/logic/common/client_wallet.go:287-300`
**影响**
- 新用户并发访问钱包时出现偶发失败。
- 加密异常可能生成空密文但仍写入银行卡记录,造成不可恢复的数据缺失。
**最小整改**
- 使用 `ON CONFLICT DO NOTHING` 后回查,或在事务中锁定所有者。
- 每个敏感字段的加密错误都必须中止事务。
## 5. P3已证实或疑似冗余
### P3-01 钱包第三方账号字段只有 Mock 数据写入
置信度:中
**证据**
- `backend/api/internal/models/wallet_basic.go:11-14` 定义支付宝、微信账号及姓名四个字段。
- 生产业务中未发现写入入口;仅 seed 使用。
- Web 资源仍展示这些字段。
**判断**
- 当前属于“疑似冗余/未完成字段”,不能仅凭静态引用直接删除。
**建议**
- 核对线上非空率、导出消费者和未来渠道设计;确认不用后再做带回滚方案的迁移。
### P3-02 Web 与状态 Store 保留多组本端不可达能力和模板字段
置信度:中
**证据**
- 气站/配送端的通用平台 API 保留角色创建、角色菜单、平台账号等调用,但本端路由和资源未暴露这些页面。
- 三端用户 Store 保留 `job``organization``location``email``introduction``personalWebsite` 等模板字段,实际登录资料只填充账号、名称、头像、角色和菜单。
**建议**
- 先用 TypeScript 引用分析和运行时埋点确认不可达,再删除本端无关方法和状态字段。
- 共享 Store 只保留跨端最小会话模型,本端扩展单独声明。
### P3-03 离线草稿读取重复解析同一加密文件
置信度:高
**证据**
- `apps/service_app/lib/data/offline/encrypted_draft_store.dart:69-77` 先读取并 `jsonDecode` 外层载荷,随后再次读取文件并在 `_decrypt` 内再次解析。
**影响**
- 每次读草稿多一次文件读取和 JSON 解析,且前后两次读取理论上可能看到不同内容。
**最小整改**
- 读取一次字符串,完成结构校验后把同一值传给解密函数。
## 6. 字段与表冗余结论
| 对象 | 结论 | 依据 | 处理方式 |
|---|---|---|---|
| 数据库 `id` | 数据库内部必需,公共 API/UI 冗余 | API 同时提供 `identity`UI仍显示 `id` | 保留数据库列;移除公开序列化和 UI 展示 |
| `FinPayment` / `WalletPayment` / `GasorderPayment` | 已证实事实重叠,但不能直接删表 | 实际支付不写三表,看板却读其中一表 | 先确定唯一支付事实并迁移、对账 |
| `EcOrder.GasStationID/DeliveryPointID` | 当前生产流程未填充 | 仅 seed 写入 | 明确归属规则后补写,或迁移删除 |
| `WalletBasic` 支付宝/微信四字段 | 疑似冗余 | 仅 seed 写入 | 查线上非空率及外部消费者后决定 |
| 配送端 `gasOverrides` | 已证实代码冗余 | 定义后未进入导出链 | 可删除,并用静态检查防回归 |
| Web 会话模板字段 | 疑似冗余 | 未进入实际资料映射 | 引用和运行时确认后删除 |
没有将“只在响应、展示或 seed 中出现”的字段直接判定为可删。数据库删字段、删表前必须补充:
1. 线上非空率和取值分布;
2. API 网关/访问日志中的字段消费者;
3. 报表、导出、脚本和第三方集成引用;
4. 双写/回填/回滚方案。
## 7. 测试、静态检查与构建结果
已实际执行:
```text
backend/api:
go test ./... 通过
go vet ./... 通过
go build ./cmd/main/main.go 通过
backend/worker:
go test ./... 通过(无测试文件)
go build ./cmd/main/main.go 通过
backend/iot:
go test ./... 通过(无测试文件)
go build ./cmd/main/main.go 通过
apps/user_app:
flutter analyze 通过
flutter test 通过2 个测试)
apps/service_app:
flutter analyze 通过
flutter test 通过3 个测试)
frontend/platform_admin:
pnpm type:check 通过
pnpm lint 退出码 0112 warnings / 12 infos
pnpm contract:check 通过47 个资源)
pnpm build 通过
frontend/gas_admin:
pnpm type:check 通过
pnpm lint 退出码 0105 warnings / 12 infos
pnpm contract:check 通过20 个资源)
pnpm build 通过
frontend/delivery_admin:
pnpm type:check 通过
pnpm lint 退出码 0106 warnings / 12 infos
pnpm contract:check 通过18 个资源)
pnpm build 通过
```
说明:
- Web lint 的相当一部分告警来自工具无法识别 Vue 模板对 `script setup` 变量的使用,不能全部视为真实死代码;本报告仅列出能够从导出/调用链证明的冗余。
- 后端虽有若干测试文件,但没有覆盖用户钱包支付、用户提现、配送提现、验证码完整流程、工作人员预检强制执行等高风险路径。
- 两个 Flutter App 的测试主要覆盖模型和登录页面不覆盖商城支付、离线签收重试、401 会话失效或完整作业流程。
## 8. 建议整改顺序
### 立即处置
1. 关闭相关公网凭据并轮换全部已泄露密钥。
2. 关闭生产资金写入口,审计历史余额和提现。
3. 禁止生产环境启用固定验证码和 Mock 支付。
### 第一阶段:资金与身份
1. 重建钱包不变量、冻结额、统一流水和提现状态机。
2. 合并支付事实,完成历史数据对账。
3. 接入真实验证码并增加限流、审计和攻击测试。
4. 强制服务端工作人员作业资格守卫。
### 第二阶段:闭环与可靠性
1. 完成用户 App 支付/取消/收货闭环。
2. 修复所有幂等键的归属和载荷校验。
3. 修复服务 App 草稿幂等号传递、401 会话和网络超时。
4. 上线真实 Worker、IoT、对象存储及健康检查。
### 第三阶段:去重与契约
1. 抽取三个 Web 端共享包,删除不可达配置。
2. 用结构化契约替换正则和源码字符串检查。
3. 在确认线上数据和消费者后迁移疑似冗余字段/表。
## 9. 审计限制
- 未连接公网数据库、Redis、短信、支付、MQTT 或对象存储,未验证仓库中凭据是否仍有效。
- 未启动依赖外部数据库的完整 E2E 流程,结论来自当前代码可证明的控制流、数据写入和客户端调用链。
- 未使用 `docs/*` 推导功能缺口或业务要求。
- 未修改任何业务源代码;只新增本报告。

View File

@@ -196,7 +196,7 @@ func migrateDatabase() error {
if err := prepareAdditiveMigrations(migrationDatabase, driver); err != nil { if err := prepareAdditiveMigrations(migrationDatabase, driver); err != nil {
return err return err
} }
if err := resetLegacyPaymentSchema(migrationDatabase, driver); err != nil { if err := rejectLegacyPaymentSchema(migrationDatabase); err != nil {
return err return err
} }
const legacyPhoneIndex = "idx_platform_account_phone" const legacyPhoneIndex = "idx_platform_account_phone"
@@ -216,25 +216,12 @@ func migrateDatabase() error {
return initdb.New(databaseService) return initdb.New(databaseService)
} }
// resetLegacyPaymentSchema 执行经业务明确授权的开发期破坏性支付模型重置,不迁移旧支付或退款历史 // rejectLegacyPaymentSchema 阻止通用迁移物理删除历史资金表
func resetLegacyPaymentSchema(databaseService *gorm.DB, driver string) error { // 旧支付事实必须通过单独评审的数据迁移保留和对账,不能由 migrate 命令静默重置。
statements := []string{} func rejectLegacyPaymentSchema(databaseService *gorm.DB) error {
if driver == "postgres" { for _, table := range []string{"wallet_refund", "wallet_payment", "gasorder_payment"} {
statements = append(statements, if databaseService.Migrator().HasTable(table) {
`DROP TABLE IF EXISTS "wallet_refund" CASCADE`, return fmt.Errorf("检测到历史资金表 %s请先执行经财务与审计批准的保留式迁移", table)
`DROP TABLE IF EXISTS "wallet_payment" CASCADE`,
`DROP TABLE IF EXISTS "gasorder_payment" CASCADE`,
)
} else {
statements = append(statements,
`DROP TABLE IF EXISTS wallet_refund`,
`DROP TABLE IF EXISTS wallet_payment`,
`DROP TABLE IF EXISTS gasorder_payment`,
)
}
for _, statement := range statements {
if err := databaseService.Exec(statement).Error; err != nil {
return fmt.Errorf("reset legacy payment schema: %w", err)
} }
} }
return nil return nil

View File

@@ -1,18 +1,31 @@
package main package main
import ( import (
"regexp"
"testing" "testing"
"gorm.io/driver/sqlite" "github.com/DATA-DOG/go-sqlmock"
"gorm.io/driver/postgres"
"gorm.io/gorm" "gorm.io/gorm"
) )
func TestPrepareAdditiveMigrationsSkipsMissingLegacyTable(t *testing.T) { func TestPrepareAdditiveMigrationsSkipsMissingLegacyTable(t *testing.T) {
databaseService, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) sqlDatabase, mock, err := sqlmock.New()
if err != nil { if err != nil {
t.Fatalf("open sqlite database: %v", err) t.Fatalf("open sql mock: %v", err)
}
defer sqlDatabase.Close()
mock.ExpectQuery(regexp.QuoteMeta("SELECT count(*) FROM information_schema.tables WHERE table_schema = CURRENT_SCHEMA() AND table_name = $1 AND table_type = $2")).
WithArgs("gasorder_track_point", "BASE TABLE").
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
databaseService, err := gorm.Open(postgres.New(postgres.Config{Conn: sqlDatabase}), &gorm.Config{})
if err != nil {
t.Fatalf("open postgres mock: %v", err)
} }
if err := prepareAdditiveMigrations(databaseService, "postgres"); err != nil { if err := prepareAdditiveMigrations(databaseService, "postgres"); err != nil {
t.Fatalf("missing legacy table should not require a backfill: %v", err) t.Fatalf("missing legacy table should not require a backfill: %v", err)
} }
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unexpected migration query: %v", err)
}
} }

View File

@@ -90,6 +90,9 @@ func New(srvKey string) {
Spec.BindIP = conf.CheckIP(Spec.BindIP) Spec.BindIP = conf.CheckIP(Spec.BindIP)
Spec.Addr = net.JoinHostPort(Spec.BindIP, Spec.Port) Spec.Addr = net.JoinHostPort(Spec.BindIP, Spec.Port)
conf.NotNil(Spec.Service, Spec.Cache) conf.NotNil(Spec.Service, Spec.Cache)
if Spec.Databases == nil || len(Spec.Databases.Source) == 0 {
panic("Databases configuration is required")
}
if Spec.Global.ManualRechargeMaxAmount <= 0 { if Spec.Global.ManualRechargeMaxAmount <= 0 {
panic("Global.ManualRechargeMaxAmount must be greater than zero") panic("Global.ManualRechargeMaxAmount must be greater than zero")
} }

View File

@@ -4,6 +4,7 @@ package iot
import ( import (
"crypto/subtle" "crypto/subtle"
"encoding/json" "encoding/json"
"errors"
"net/http" "net/http"
"strings" "strings"
"time" "time"
@@ -187,7 +188,14 @@ func SaveDeviceMessage(ctx *gin.Context) {
if frame.Control&0x04 != 0 { if frame.Control&0x04 != 0 {
status, errorCode = "failed", "DEVICE_REPORTED_FAILURE" status, errorCode = "failed", "DEVICE_REPORTED_FAILURE"
} }
return tx.Model(&models.IotCommand{}).Where("device_id = ? AND packet_number = ? AND command_status IN ?", envelope.DeviceID, frame.PacketNumber, []string{"dispatched", "pending_confirmation"}).Updates(map[string]any{"command_status": status, "error_code": errorCode, "acknowledged_at": received, "updated_at": received}).Error result := tx.Model(&models.IotCommand{}).Where("device_id = ? AND packet_number = ? AND command_status IN ?", envelope.DeviceID, frame.PacketNumber, []string{"dispatched", "pending_confirmation"}).Updates(map[string]any{"command_status": status, "error_code": errorCode, "acknowledged_at": received, "updated_at": received})
if result.Error != nil {
return result.Error
}
if result.RowsAffected > 1 {
return errors.New("ambiguous device acknowledgement")
}
return nil
}) })
if err != nil { if err != nil {
ctx.JSON(500, gin.H{"code": "IOT_MESSAGE_SAVE_FAILED"}) ctx.JSON(500, gin.H{"code": "IOT_MESSAGE_SAVE_FAILED"})

View File

@@ -6,6 +6,7 @@ import (
"errors" "errors"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"time" "time"
"git.apinb.com/heqiapp/platforms/backend/api/internal/config" "git.apinb.com/heqiapp/platforms/backend/api/internal/config"
@@ -80,12 +81,18 @@ func complete(paymentNo, tradeNo string, amount int64, channel, callbackDigest s
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("payment_no = ?", paymentNo).First(&order).Error; err != nil { if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("payment_no = ?", paymentNo).First(&order).Error; err != nil {
return err return err
} }
if order.PaymentStatus == StatusPaid {
return nil
}
if order.Channel != channel || order.Amount != amount { if order.Channel != channel || order.Amount != amount {
return errors.New("payment identity or amount mismatch") return errors.New("payment identity or amount mismatch")
} }
if strings.TrimSpace(tradeNo) == "" {
return errors.New("channel trade number is required")
}
if order.PaymentStatus == StatusPaid {
if order.ChannelTradeNo != tradeNo {
return errors.New("duplicate callback trade number mismatch")
}
return nil
}
if time.Now().After(order.ExpiresAt) { if time.Now().After(order.ExpiresAt) {
return tx.Model(&order).Updates(map[string]any{"payment_status": 50, "channel_trade_no": tradeNo, "callback_digest": callbackDigest, "failure_code": "PAID_AFTER_EXPIRED"}).Error return tx.Model(&order).Updates(map[string]any{"payment_status": 50, "channel_trade_no": tradeNo, "callback_digest": callbackDigest, "failure_code": "PAID_AFTER_EXPIRED"}).Error
} }
@@ -95,9 +102,10 @@ func complete(paymentNo, tradeNo string, amount int64, channel, callbackDigest s
} }
switch order.BusinessType { switch order.BusinessType {
case "ec_order": case "ec_order":
return tx.Model(&models.EcOrder{}).Where("identity = ? AND order_status = ?", order.BusinessIdentity, 16).Updates(map[string]any{"order_status": 18, "paid_at": &now}).Error result := tx.Model(&models.EcOrder{}).Where("identity = ? AND order_status = ?", order.BusinessIdentity, 16).Updates(map[string]any{"order_status": 18, "paid_at": &now})
return requireSingleBusinessUpdate(result)
case "gasorder": case "gasorder":
return tx.Model(&models.GasorderBasic{}).Where("identity = ? AND order_status IN ?", order.BusinessIdentity, []int{16, 18}).Update("order_status", 35).Error return requireSingleBusinessUpdate(tx.Model(&models.GasorderBasic{}).Where("identity = ? AND order_status IN ?", order.BusinessIdentity, []int{16, 18}).Update("order_status", 35))
case "recharge": case "recharge":
return completeRecharge(tx, order, now) return completeRecharge(tx, order, now)
} }
@@ -105,6 +113,16 @@ func complete(paymentNo, tradeNo string, amount int64, channel, callbackDigest s
}) })
} }
func requireSingleBusinessUpdate(result *gorm.DB) error {
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return errors.New("payment business state conflict")
}
return nil
}
func completeRecharge(tx *gorm.DB, payment models.PaymentOrder, now time.Time) error { func completeRecharge(tx *gorm.DB, payment models.PaymentOrder, now time.Time) error {
var recharge models.WalletRechargeOrder var recharge models.WalletRechargeOrder
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("identity = ? AND recharge_status = ?", payment.BusinessIdentity, 10).First(&recharge).Error; err != nil { if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("identity = ? AND recharge_status = ?", payment.BusinessIdentity, 10).First(&recharge).Error; err != nil {

View File

@@ -0,0 +1,21 @@
package payment
import (
"errors"
"testing"
"gorm.io/gorm"
)
func TestRequireSingleBusinessUpdate(t *testing.T) {
if err := requireSingleBusinessUpdate(&gorm.DB{RowsAffected: 1}); err != nil {
t.Fatalf("single state update rejected: %v", err)
}
if err := requireSingleBusinessUpdate(&gorm.DB{}); err == nil {
t.Fatal("zero-row state update must be rejected")
}
expected := errors.New("database unavailable")
if err := requireSingleBusinessUpdate(&gorm.DB{Error: expected}); !errors.Is(err, expected) {
t.Fatalf("database error lost: %v", err)
}
}

View File

@@ -2,6 +2,7 @@ package payment
import ( import (
"context" "context"
"crypto/subtle"
"fmt" "fmt"
"net/http" "net/http"
"time" "time"
@@ -55,7 +56,9 @@ func closeChannelOrder(ctx context.Context, order models.PaymentOrder) error {
// CloseExpiredHandler 只接受 Worker 共享凭证,不暴露为平台用户动作。 // CloseExpiredHandler 只接受 Worker 共享凭证,不暴露为平台用户动作。
func CloseExpiredHandler(ctx *gin.Context) { func CloseExpiredHandler(ctx *gin.Context) {
if config.Spec.Payment.InternalServiceToken == "" || ctx.GetHeader("X-Heqi-Worker-Token") != config.Spec.Payment.InternalServiceToken { expected := config.Spec.Payment.InternalServiceToken
actual := ctx.GetHeader("X-Heqi-Worker-Token")
if expected == "" || len(actual) != len(expected) || subtle.ConstantTimeCompare([]byte(actual), []byte(expected)) != 1 {
ctx.AbortWithStatus(http.StatusUnauthorized) ctx.AbortWithStatus(http.StatusUnauthorized)
return return
} }

View File

@@ -32,6 +32,7 @@ type Service struct {
packet atomic.Uint32 packet atomic.Uint32
http *http.Server http *http.Server
ready atomic.Bool ready atomic.Bool
client *http.Client
} }
type Command struct { type Command struct {
Identity string `json:"identity"` Identity string `json:"identity"`
@@ -97,7 +98,7 @@ func New(cfg config.Config) (*Service, error) {
if err = broker.AddListener(listeners.NewTCP(listenerConfig)); err != nil { if err = broker.AddListener(listeners.NewTCP(listenerConfig)); err != nil {
return nil, fmt.Errorf("配置 MQTT 监听器: %w", err) return nil, fmt.Errorf("配置 MQTT 监听器: %w", err)
} }
return &Service{cfg: cfg, keys: keys, broker: broker}, nil return &Service{cfg: cfg, keys: keys, broker: broker, client: &http.Client{Timeout: 12 * time.Second}}, nil
} }
func makeTLSConfig(cfg config.Config) (*tls.Config, error) { func makeTLSConfig(cfg config.Config) (*tls.Config, error) {
@@ -222,10 +223,14 @@ func (s *Service) command(w http.ResponseWriter, r *http.Request) {
func (s *Service) onMessage(_ *mqtt.Client, _ packets.Subscription, message packets.Packet) { func (s *Service) onMessage(_ *mqtt.Client, _ packets.Subscription, message packets.Packet) {
raw := append([]byte(nil), message.Payload...) raw := append([]byte(nil), message.Payload...)
envelope := Envelope{Type: "device_message", Topic: message.TopicName, ReceivedAt: time.Now().UTC().Format(time.RFC3339Nano), PayloadHex: hex.EncodeToString(raw)} expectedDeviceID, topicOK := deviceIDFromTopic(s.cfg, message.TopicName)
envelope := Envelope{Type: "device_message", Topic: message.TopicName, DeviceID: expectedDeviceID, ReceivedAt: time.Now().UTC().Format(time.RFC3339Nano), PayloadHex: hex.EncodeToString(raw)}
if frame, err := protocol.Decode(raw, s.keys); err == nil { if frame, err := protocol.Decode(raw, s.keys); err == nil {
deviceID := hex.EncodeToString(frame.DeviceID[:]) deviceID := hex.EncodeToString(frame.DeviceID[:])
envelope.DeviceID = deviceID if !topicOK || deviceID != expectedDeviceID {
s.forwardEnvelope(envelope)
return
}
main := byte(0) main := byte(0)
if len(frame.Payload) > 0 { if len(frame.Payload) > 0 {
main = frame.Payload[0] main = frame.Payload[0]
@@ -238,6 +243,10 @@ func (s *Service) onMessage(_ *mqtt.Client, _ packets.Subscription, message pack
} }
envelope.Frame = decoded envelope.Frame = decoded
} }
s.forwardEnvelope(envelope)
}
func (s *Service) forwardEnvelope(envelope Envelope) {
data, _ := json.Marshal(envelope) data, _ := json.Marshal(envelope)
request, err := http.NewRequest(http.MethodPost, s.cfg.HTTP.CallbackURL, bytes.NewReader(data)) request, err := http.NewRequest(http.MethodPost, s.cfg.HTTP.CallbackURL, bytes.NewReader(data))
if err != nil { if err != nil {
@@ -245,12 +254,38 @@ func (s *Service) onMessage(_ *mqtt.Client, _ packets.Subscription, message pack
} }
request.Header.Set("Content-Type", "application/json") request.Header.Set("Content-Type", "application/json")
request.Header.Set("X-Heqi-Iot-Token", s.cfg.HTTP.InternalToken) request.Header.Set("X-Heqi-Iot-Token", s.cfg.HTTP.InternalToken)
response, err := http.DefaultClient.Do(request) response, err := s.client.Do(request)
if err == nil { if err == nil {
_ = response.Body.Close() _ = response.Body.Close()
} }
} }
func deviceIDFromTopic(cfg config.Config, topic string) (string, bool) {
for _, pattern := range []string{cfg.MQTT.UpTopic, cfg.MQTT.AckTopic} {
parts := strings.Split(pattern, "/")
values := strings.Split(topic, "/")
if len(parts) != len(values) {
continue
}
deviceID := ""
matched := true
for index := range parts {
if parts[index] == "+" {
deviceID = values[index]
continue
}
if parts[index] != values[index] {
matched = false
break
}
}
if matched && len(deviceID) == 16 {
return strings.ToLower(deviceID), true
}
}
return "", false
}
func decodeDeviceID(value string) ([8]byte, error) { func decodeDeviceID(value string) ([8]byte, error) {
var result [8]byte var result [8]byte
decoded, err := hex.DecodeString(value) decoded, err := hex.DecodeString(value)

View File

@@ -0,0 +1,22 @@
package service
import (
"testing"
"git.apinb.com/heqiapp/platforms/backend/iot-server/internal/config"
)
func TestDeviceIDFromTopic(t *testing.T) {
cfg := config.Config{MQTT: config.MQTT{UpTopic: "devices/+/up", AckTopic: "devices/+/ack"}}
for _, topic := range []string{"devices/0000000000000001/up", "devices/0000000000000001/ack"} {
identity, ok := deviceIDFromTopic(cfg, topic)
if !ok || identity != "0000000000000001" {
t.Fatalf("topic %s: identity=%q ok=%v", topic, identity, ok)
}
}
for _, topic := range []string{"devices/0001/up", "devices/0000000000000001/down", "other/0000000000000001/up"} {
if identity, ok := deviceIDFromTopic(cfg, topic); ok || identity != "" {
t.Fatalf("invalid topic %s accepted as %q", topic, identity)
}
}
}

View File

@@ -31,6 +31,7 @@ func main() {
func closeExpiredPayments(ctx context.Context) { func closeExpiredPayments(ctx context.Context) {
ticker := time.NewTicker(time.Duration(config.Spec.PaymentAPI.IntervalSeconds) * time.Second) ticker := time.NewTicker(time.Duration(config.Spec.PaymentAPI.IntervalSeconds) * time.Second)
defer ticker.Stop() defer ticker.Stop()
client := &http.Client{Timeout: 15 * time.Second}
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
@@ -41,7 +42,7 @@ func closeExpiredPayments(ctx context.Context) {
continue continue
} }
request.Header.Set("X-Heqi-Worker-Token", config.Spec.PaymentAPI.Token) request.Header.Set("X-Heqi-Worker-Token", config.Spec.PaymentAPI.Token)
response, err := http.DefaultClient.Do(request) response, err := client.Do(request)
if err == nil { if err == nil {
_ = response.Body.Close() _ = response.Body.Close()
} }

View File

@@ -4,6 +4,8 @@ package config
import ( import (
"git.apinb.com/bsm-sdk/core/conf" "git.apinb.com/bsm-sdk/core/conf"
"net" "net"
"os"
"strings"
) )
var Spec SrvConfig var Spec SrvConfig
@@ -30,10 +32,28 @@ type IoTAPIConfig struct {
func New(srvKey string) { func New(srvKey string) {
conf.New(srvKey, &Spec) conf.New(srvKey, &Spec)
if databaseDSN := strings.TrimSpace(os.Getenv("HEQI_DATABASE_DSN")); databaseDSN != "" {
if Spec.Databases == nil {
panic("Databases configuration is required")
}
Spec.Databases.Source = []string{databaseDSN}
}
if redisURL := strings.TrimSpace(os.Getenv("HEQI_REDIS_URL")); redisURL != "" {
Spec.Cache = redisURL
}
if token := strings.TrimSpace(os.Getenv("HEQI_PAYMENT_INTERNAL_TOKEN")); token != "" {
Spec.PaymentAPI.Token = token
}
if token := strings.TrimSpace(os.Getenv("HEQI_IOT_INTERNAL_TOKEN")); token != "" {
Spec.IoTAPI.Token = token
}
Spec.Port = conf.CheckPort(Spec.Port) Spec.Port = conf.CheckPort(Spec.Port)
Spec.BindIP = conf.CheckIP(Spec.BindIP) Spec.BindIP = conf.CheckIP(Spec.BindIP)
Spec.Addr = net.JoinHostPort(Spec.BindIP, Spec.Port) Spec.Addr = net.JoinHostPort(Spec.BindIP, Spec.Port)
conf.NotNil(Spec.Service, Spec.Cache) conf.NotNil(Spec.Service, Spec.Cache)
if Spec.Databases == nil || len(Spec.Databases.Source) == 0 {
panic("Databases configuration is required")
}
if Spec.PaymentAPI.BaseURL == "" || Spec.PaymentAPI.Token == "" || Spec.PaymentAPI.IntervalSeconds <= 0 { if Spec.PaymentAPI.BaseURL == "" || Spec.PaymentAPI.Token == "" || Spec.PaymentAPI.IntervalSeconds <= 0 {
panic("PaymentAPI configuration is required") panic("PaymentAPI configuration is required")
} }

View File

@@ -2,7 +2,7 @@
## 1. 目标 ## 1. 目标
本计划用于验证当前已实现的两类 App、三个业务管理端、官网以及 API/Worker/IoT 三个后端进程,确保: 本计划用于验证当前已实现的两类 App、三个业务管理端、官网以及 APIWorker、IoT Gateway、IoT Server 四个后端进程,确保:
- 业务主责与数据边界正确,没有通过其他终端越权改写事实。 - 业务主责与数据边界正确,没有通过其他终端越权改写事实。
- 设备控制、安全事件、订单履约、资金和审计形成可追踪闭环。 - 设备控制、安全事件、订单履约、资金和审计形成可追踪闭环。
@@ -22,10 +22,11 @@
| Web | `frontend/delivery_admin` | 配送点人员、用户、地址、订单、资金、报表和组织隔离 | | Web | `frontend/delivery_admin` | 配送点人员、用户、地址、订单、资金、报表和组织隔离 |
| Web | `frontend/site` | 官网内容、导航、响应式、静态资源、可访问性、SEO 基础、构建与托管 Worker | | Web | `frontend/site` | 官网内容、导航、响应式、静态资源、可访问性、SEO 基础、构建与托管 Worker |
| 后端 | `backend/api` | Gin API/BFF、JWT、权限、同步事务、状态动作、金额、幂等、审计、上传和数据库事实 | | 后端 | `backend/api` | Gin API/BFF、JWT、权限、同步事务、状态动作、金额、幂等、审计、上传和数据库事实 |
| 后端 | `backend/worker` | 配置、启动、Mock 循环、优雅退出;未来 Outbox/Streams 的投递、重试、死信和积压 | | 后端 | `backend/worker` | 支付超时关单、IoT Outbox 认领与投递、重试触发、内部令牌、超时和优雅退出 |
| 后端 | `backend/iot-gateway``backend/iot-server` | 配置、启动、无状态转发、协议编解码、MQTT TLS、身份、遥测、回执和断线重连 | | 后端 | `backend/iot-gateway` | 无状态命令转发、上行回调转发、内部鉴权、过期拦截、请求体限制和上游故障映射 |
| 后端 | `backend/iot-server` | Mochi MQTT Broker、每设备认证与 Topic ACL、V1.8 帧/AES/LRC8、上下行、回执和遥测解析 |
不修改 `sample/`真实支付、短信、地图、对象存储、电子合同MQTT 厂商接入等,以受控沙箱或 Mock 验证;生产联调另设上线前检查点。 真实支付、短信、地图、对象存储、电子合同MQTT 厂商硬件等,以受控沙箱或模拟器验证;生产联调另设上线前检查点。
## 3. 测试分层 ## 3. 测试分层
@@ -35,10 +36,10 @@
| L1 单元测试 | 状态机、金额、权限、幂等、序列化、ViewModel、领域规则 | 每次提交;关键规则 100% 分支覆盖 | 函数/规则 | | L1 单元测试 | 状态机、金额、权限、幂等、序列化、ViewModel、领域规则 | 每次提交;关键规则 100% 分支覆盖 | 函数/规则 |
| L2 组件/接口测试 | Router+Logic+DB、Vue/Flutter 组件、上传边界、错误码 | 每次合并请求 | 组件/API | | L2 组件/接口测试 | Router+Logic+DB、Vue/Flutter 组件、上传边界、错误码 | 每次合并请求 | 组件/API |
| L3 契约测试 | 后端路由、资源契约、请求响应 Schema、客户端 claim | 每次接口变更 | 契约消费者/提供者 | | L3 契约测试 | 后端路由、资源契约、请求响应 Schema、客户端 claim | 每次接口变更 | 契约消费者/提供者 |
| L4 集成测试 | API+PostgreSQL+Redis/Mock、Worker、IoT、对象存储替身 | 每日或候选版 | 服务/依赖 | | L4 集成测试 | API+PostgreSQL+Redis、Worker、Gateway、MQTT Server、设备模拟器、对象存储替身 | 每日或候选版 | 服务/依赖 |
| L5 跨端 E2E | App/Web 经真实 API 完成跨角色流程 | 每日冒烟、候选版全回归 | 业务链路 | | L5 跨端 E2E | App/Web 经真实 API 完成跨角色流程 | 每日冒烟、候选版全回归 | 业务链路 |
| L6 专项测试 | 安全、性能、稳定性、弱网、兼容性、可访问性、灾备 | 候选版 | 系统能力 | | L6 专项测试 | 安全、性能、稳定性、弱网、兼容性、可访问性、灾备 | 候选版 | 系统能力 |
| L7 UAT/发布验证 | AC-01AC-24、运营/财务/安全签字、生产冒烟 | 发布前后 | 验收口径 | | L7 UAT/发布验证 | AC-01AC-25、运营/财务/安全签字、生产冒烟 | 发布前后 | 验收口径 |
## 4. 核心测试设计原则 ## 4. 核心测试设计原则
@@ -78,6 +79,8 @@
- 金额均为最小货币单位整数,覆盖 0、边界值、超额、并发与溢出。 - 金额均为最小货币单位整数,覆盖 0、边界值、超额、并发与溢出。
- 充值、支付、退款、提现预扣/驳回/打款、结算、对账的事务一致性和不可变流水。 - 充值、支付、退款、提现预扣/驳回/打款、结算、对账的事务一致性和不可变流水。
- 回调签名、金额、订单、商户身份、重复/乱序回调和伪造回调。 - 回调签名、金额、订单、商户身份、重复/乱序回调和伪造回调。
- 统一 `payment_order` 创建、客户端参数最小化、超时关单、过期后支付进入异常待确认。
- 用户退款按订单项和数量校验退款窗口、履约状态与累计金额;审核通过后同事务入钱包并写不可变流水。
- 始终满足 `0 <= 可提现余额 <= 总余额`,申请人不能篡改审核和打款状态。 - 始终满足 `0 <= 可提现余额 <= 总余额`,申请人不能篡改审核和打款状态。
### 5.5 内容、上传与隐私 ### 5.5 内容、上传与隐私
@@ -102,10 +105,18 @@
- 敏感字段加密、日志/崩溃报告/缓存/剪贴板/截图风险Release 包配置和调试入口。 - 敏感字段加密、日志/崩溃报告/缓存/剪贴板/截图风险Release 包配置和调试入口。
- MQTT 设备身份、TLS、主题 ACL、伪造遥测和命令回执在真实接入后专项验证。 - MQTT 设备身份、TLS、主题 ACL、伪造遥测和命令回执在真实接入后专项验证。
### IoT 协议与链路
- API 创建命令与 Outbox 必须同事务;相同幂等键返回同一命令,不产生第二条待投递事实。
- Worker 使用 `SKIP LOCKED` 认领 Outbox经 Gateway 投递至 IoT Server失败按状态与可用时间重试卡在 processing 的记录可被重新认领。
- Gateway 不保存业务事实;校验内部令牌、命令过期时间、动作和请求体大小,并正确映射上游拒绝或不可用。
- IoT Server 验证每设备凭证和 Topic ACLV1.8 帧覆盖边界、长度、大端序、AES-128、LRC8、BCD 时间、实时数据块和阀门动作编码。
- ACK 依据设备号、包序号和控制位更新命令;无 ACK 保持待确认,重复 ACK 不产生重复命令或业务动作。
### 可靠性与恢复 ### 可靠性与恢复
- API、Worker、IoT 重启;数据库/Redis/对象存储/MQTT 短时不可用;网络分区与时钟偏移。 - API、Worker、IoT Gateway、IoT Server 重启;数据库/Redis/对象存储/MQTT 短时不可用;网络分区与时钟偏移。
- Outbox、消费幂等、重试、死信和积压只在持久化消息契约落地作为上线硬门禁 - 当前 IoT Outbox 的认领、processing 超时回收、失败重试和积压必须测试;死信上限与 Redis Streams 唤醒尚未落地作为上线待决项记录
- 备份恢复演练验证核心数据完整性,并记录实测 RPO/RTO目标值需业务确认。 - 备份恢复演练验证核心数据完整性,并记录实测 RPO/RTO目标值需业务确认。
### 兼容性与可访问性 ### 兼容性与可访问性
@@ -127,14 +138,14 @@
- 测试负责人:版本范围、计划、排期、风险、日报和发布建议。 - 测试负责人:版本范围、计划、排期、风险、日报和发布建议。
- 开发负责人:单元/接口测试、可测试性、数据脚本、日志和缺陷修复。 - 开发负责人:单元/接口测试、可测试性、数据脚本、日志和缺陷修复。
- 产品/业务:确认正常与异常口径,签署 AC-01AC-24 - 产品/业务:确认正常与异常口径,签署 AC-01AC-25
- 安全/财务/运维:分别签署高风险设备与隐私、资金、性能/备份/灾备结果。 - 安全/财务/运维:分别签署高风险设备与隐私、资金、性能/备份/灾备结果。
- 每轮必备产物:版本清单、环境清单、用例集、自动化报告、专项报告、缺陷清单、复测证据、残余风险和签字记录。 - 每轮必备产物:版本清单、环境清单、用例集、自动化报告、专项报告、缺陷清单、复测证据、残余风险和签字记录。
## 9. 完成标准 ## 9. 完成标准
- AC-01AC-24 均有用例、执行结果和证据,没有“未覆盖但默认通过”。 - AC-01AC-25 均有用例、执行结果和证据,没有“未覆盖但默认通过”。
- P0/P1 为 0P2 延期均有责任人、截止时间、影响和风险接受人。 - P0/P1 为 0P2 延期均有责任人、截止时间、影响和风险接受人。
- 所有受影响模块的静态检查、单元、契约和构建通过;关键链路 E2E 通过。 - 所有受影响模块的静态检查、单元、契约和构建通过;关键链路 E2E 通过。
- 设备、安全、订单、支付、提现、检查、整改、敏感访问的审计可按 `request_id`/`identity` 串联。 - 设备、安全、订单、支付、提现、检查、整改、敏感访问的审计可按 `request_id`/`identity` 串联。
- 安全、性能、弱网/离线和恢复专项完成;未具备真实依赖的项目明确标记“受限通过”,不得写成已完成生产验收 - 安全、性能、弱网/离线和恢复专项完成;IoT 模拟器、真实设备、TLS/证书与厂商联调分层记录,模拟通过不得写成真实设备验收通过

View File

@@ -5,8 +5,9 @@
| 流程 | 参与端/进程 | 必测断言 | | 流程 | 参与端/进程 | 必测断言 |
| --- | --- | --- | | --- | --- | --- |
| 用户注册与服务归属 | user_app → API → gas/delivery admin | 邀请范围/时效/次数/地址校验;已有账号不重复创建;归因可审计 | | 用户注册与服务归属 | user_app → API → gas/delivery admin | 邀请范围/时效/次数/地址校验;已有账号不重复创建;归因可审计 |
| 设备绑定与远控 | user_app/platform_admin → API → IoT → 设备 Mock | 归属鉴权、幂等键、命令 ID、回执、超时待确认、审计高风险开阀拦截 | | 设备绑定与远控 | user_app/platform_admin → API → Outbox → Worker → Gateway → IoT Server → MQTT 设备 | 归属鉴权、事务、幂等键、命令 ID、包序号、回执、超时待确认、审计;高风险开阀拦截 |
| 告警整改闭环 | IoT → API/Worker → user_app/service_app/platform_admin | 事件分级、自动关阀、通知、SLA、派单、证据、复检、关闭与全链路审计 | | 告警整改闭环 | MQTT 设备 → IoT Server → Gateway → API/Worker → user_app/service_app/platform_admin | 原始帧、设备/接收时间、事件分级、自动关阀、通知、SLA、派单、证据、复检、关闭与审计 |
| 渠道支付与退款 | user_app → API → 支付渠道 → API → platform_admin → 钱包 | 支付单幂等、验签、金额/商户、超时关单、重复/迟到回调、按项退款、审核与不可变流水 |
| 商城订单履约 | user_app → API → gas_admin/delivery_admin → service_app | 整数金额、库存/合同规则、幂等支付、任务拆分、归属、轨迹、签收、状态聚合 | | 商城订单履约 | user_app → API → gas_admin/delivery_admin → service_app | 整数金额、库存/合同规则、幂等支付、任务拆分、归属、轨迹、签收、状态聚合 |
| 安装/维修/安检 | admin → API → service_app → API | 准入、上班、区域、强制证据、异常/恢复、离线补传、服务端完成校验 | | 安装/维修/安检 | admin → API → service_app → API | 准入、上班、区域、强制证据、异常/恢复、离线补传、服务端完成校验 |
| 钱包与提现 | user_app/service_app/admin → API | 支付密码、并发余额、不可变流水、预扣/退回/打款、审核隔离、审计 | | 钱包与提现 | user_app/service_app/admin → API | 支付密码、并发余额、不可变流水、预扣/退回/打款、审核隔离、审计 |
@@ -22,11 +23,12 @@
| gas_admin | 资源页、合同/订单动作、菜单与数据范围 | gas 资源 JSON 与后端一致 | 配送点、人员、用户、合同、订单、财务、工单 | 跨气站隔离、并发动作、导出审计 | | gas_admin | 资源页、合同/订单动作、菜单与数据范围 | gas 资源 JSON 与后端一致 | 配送点、人员、用户、合同、订单、财务、工单 | 跨气站隔离、并发动作、导出审计 |
| delivery_admin | 资源页、订单/人员动作、菜单与数据范围 | delivery 资源 JSON 与后端一致 | 人员、用户、地址、配送、钱包、结算 | 跨配送点隔离、轨迹隐私、并发动作 | | delivery_admin | 资源页、订单/人员动作、菜单与数据范围 | delivery 资源 JSON 与后端一致 | 人员、用户、地址、配送、钱包、结算 | 跨配送点隔离、轨迹隐私、并发动作 |
| site | 页面组件、托管 Worker | 静态资源/路由/缓存头 | 导航、CTA、错误页 | 响应式、Safari、可访问性、SEO、性能 | | site | 页面组件、托管 Worker | 静态资源/路由/缓存头 | 导航、CTA、错误页 | 响应式、Safari、可访问性、SEO、性能 |
| API | Logic、状态机、金额、幂等、权限、模型约束 | Router+DB、错误码、Schema、上传 | 所有跨端业务流 | 安全、并发、性能、故障注入、审计 | | API | Logic、状态机、支付/退款、金额、幂等、权限、IoT 命令/Outbox/上行模型 | Router+DB、错误码、Schema、上传、内部接口 | 所有跨端业务流 | 安全、并发、性能、故障注入、审计 |
| Worker | 事件处理器(落地后) | Outbox/Streams 契约(落地后) | 告警/通知/对账/超时 | 重试、死信、积压、重启、至少一次投递 | | Worker | 支付关单调度、IoT Outbox 轮询与投递 | API/Gateway 内部接口及令牌 | 过期支付、命令可靠投递 | 重试、积压、重启、重复认领、上游超时 |
| IoT | 协议解析/校验(落地后) | MQTT topic/payload/回执契约(落地后) | 遥测→告警、命令→回执 | TLS、ACL、断线重连、伪造、乱序、容量 | | IoT Gateway | 命令/上行 HTTP 转发 | Worker/API/IoT Server 内部契约 | 命令下行与设备消息上行 | 无状态、令牌、过期、体积限制、上游故障 |
| IoT Server | V1.8 帧、AES/LRC8、阀门命令、实时数据解析 | MQTT Topic/ACL 与内部 HTTP 命令契约 | 遥测→持久化、命令→回执 | TLS、设备认证、断线重连、伪造、乱序、容量 |
## 3. AC-01AC-24 追踪矩阵 ## 3. AC-01AC-25 追踪矩阵
| AC | 场景摘要 | 主测模块 | 关键负向/异常覆盖 | | AC | 场景摘要 | 主测模块 | 关键负向/异常覆盖 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
@@ -54,6 +56,7 @@
| AC-22 | 设备共享/紧急联系人 | user_app/API/Worker | 权限撤销后缓存、成员越权控制、联系人未授权 | | AC-22 | 设备共享/紧急联系人 | user_app/API/Worker | 权限撤销后缓存、成员越权控制、联系人未授权 |
| AC-23 | 二维码/轨迹隐私 | apps/API/admin | 二维码泄密、精确轨迹直链、跨订单访问、无审计导出 | | AC-23 | 二维码/轨迹隐私 | apps/API/admin | 二维码泄密、精确轨迹直链、跨订单访问、无审计导出 |
| AC-24 | 钱包实体命名一致性 | DB/Go/Flutter/Vue/契约 | 同义表/模型/Schema、内部 ID 暴露、迁移兼容错误 | | AC-24 | 钱包实体命名一致性 | DB/Go/Flutter/Vue/契约 | 同义表/模型/Schema、内部 ID 暴露、迁移兼容错误 |
| AC-25 | MQTT 设备命令与协议回执 | API/Worker/Gateway/IoT Server/设备模拟器 | 事务中断、重复认领、Gateway 超时、命令过期、ACL 越权、坏帧、ACK 丢失/重复/伪造 |
## 4. 数据与接口不变量 ## 4. 数据与接口不变量
@@ -63,4 +66,3 @@
- 状态只能经专用动作接口流转,非法跳转返回稳定错误码。 - 状态只能经专用动作接口流转,非法跳转返回稳定错误码。
- 客户端隐藏按钮、菜单或本地缓存不作为安全断言,必须直接调用 API 验证服务端拒绝。 - 客户端隐藏按钮、菜单或本地缓存不作为安全断言,必须直接调用 API 验证服务端拒绝。
- 所有关键写入验证幂等、事务原子性、审计完整性及失败后的可查询状态。 - 所有关键写入验证幂等、事务原子性、审计完整性及失败后的可查询状态。

View File

@@ -6,7 +6,7 @@
| --- | --- | --- | --- | | --- | --- | --- | --- |
| Local | 单元、组件、快速接口调试 | 自动生成、可重置 | 内存/本地 Mock | | Local | 单元、组件、快速接口调试 | 自动生成、可重置 | 内存/本地 Mock |
| CI | 静态、单元、契约、构建 | 每任务独立数据库 | 容器化替身,禁止共享状态 | | CI | 静态、单元、契约、构建 | 每任务独立数据库 | 容器化替身,禁止共享状态 |
| Integration | API+DB+Redis+Worker+IoT 集成 | 固定基线+场景数据 | 沙箱或可控 Mock支持故障注入 | | Integration | API+DB+Redis+Worker+Gateway+MQTT Server 集成 | 固定基线+场景数据 | 支付沙箱、设备模拟器和故障注入 |
| Staging | 全端 E2E、专项、UAT | 脱敏仿真数据 | 尽量接真实沙箱、设备模拟器 | | Staging | 全端 E2E、专项、UAT | 脱敏仿真数据 | 尽量接真实沙箱、设备模拟器 |
| Production | 发布后只读/低风险冒烟 | 生产数据 | 禁止造资金与高风险设备动作,除非有演练审批 | | Production | 发布后只读/低风险冒烟 | 生产数据 | 禁止造资金与高风险设备动作,除非有演练审批 |
@@ -17,7 +17,10 @@
- PostgreSQL每轮可重置的独立库启用约束、事务、时区和并发测试保留慢查询与锁等待观测。 - PostgreSQL每轮可重置的独立库启用约束、事务、时区和并发测试保留慢查询与锁等待观测。
- Redis验证码、限流、支付密码锁定及未来事件流提供清空命名空间、断连、延迟和重启能力。 - Redis验证码、限流、支付密码锁定及未来事件流提供清空命名空间、断连、延迟和重启能力。
- API默认基线 `http://localhost:12426`;按平台、气站、配送点、用户、服务人员分别生成令牌。 - API默认基线 `http://localhost:12426`;按平台、气站、配送点、用户、服务人员分别生成令牌。
- Worker/IoT当前执行启动、配置、健康、日志和优雅退出验证真实消息/协议接入后扩充专用环境 - Worker:配置支付扫描与 IoT 轮询间隔、API/Gateway 地址和内部令牌;支持加速时钟、上游超时与重启测试
- IoT Gateway独立端口、API/IoT Server 上游地址和内部令牌;测试中确认不持久化业务事实。
- IoT Server独立 HTTP/MQTT 端口、Mochi Broker、V1.8 AES 密钥环、逐设备 Client ID/设备号/凭证、Topic ACL集成环境使用测试证书启用 MQTT TLS。
- 设备模拟器:至少两台合法设备和一台攻击设备,支持 `up/ack/down`、断线重连、重复包、乱序包、坏 LRC、错误 AES key、ACK 成功/失败/丢失。
- 对象存储替身:支持上传、下载授权、过期、拒绝、超时、损坏和病毒扫描结果模拟。 - 对象存储替身:支持上传、下载授权、过期、拒绝、超时、损坏和病毒扫描结果模拟。
- 时间控制支持验证码过期、SLA、合同、证照、订单、提现及幂等窗口的时间推进。 - 时间控制支持验证码过期、SLA、合同、证照、订单、提现及幂等窗口的时间推进。
@@ -51,7 +54,7 @@
| 依赖 | 必备模拟能力 | | 依赖 | 必备模拟能力 |
| --- | --- | | --- | --- |
| MQTT/设备 | 在线/离线、命令 ACK/拒绝/超时、重复回执、伪造设备、遥测峰值 | | MQTT/设备 | 每设备认证、Topic ACL、在线/离线、命令 ACK/拒绝/超时、重复回执、伪造设备、坏帧、遥测峰值 |
| 支付 | 签名正确/错误、金额/订单/商户不符、重复/乱序回调、查询补偿 | | 支付 | 签名正确/错误、金额/订单/商户不符、重复/乱序回调、查询补偿 |
| 短信/推送 | 成功、失败、延迟、重复、限流、部分接收人失败 | | 短信/推送 | 成功、失败、延迟、重复、限流、部分接收人失败 |
| 地图/定位 | 正常、低精度、权限拒绝、漂移、区域外、无位置、后台受限 | | 地图/定位 | 正常、低精度、权限拒绝、漂移、区域外、无位置、后台受限 |
@@ -68,4 +71,3 @@
## 7. 待确认项 ## 7. 待确认项
以下内容在得到正式口径前只测试“可配置与安全默认值”不自行设定政策安全阈值与开阀审批、SLO/RPO/RTO 数值、目标设备规模和上报频率、支付/合同/地图厂商、证据留存期、支持的 OS/浏览器最低版本、视频合规规则。 以下内容在得到正式口径前只测试“可配置与安全默认值”不自行设定政策安全阈值与开阀审批、SLO/RPO/RTO 数值、目标设备规模和上报频率、支付/合同/地图厂商、证据留存期、支持的 OS/浏览器最低版本、视频合规规则。

View File

@@ -8,7 +8,7 @@
| B1 快速门禁 | 静态、单元、契约、构建 | 每次提交/合并请求 | 尽早阻断低层错误 | | B1 快速门禁 | 静态、单元、契约、构建 | 每次提交/合并请求 | 尽早阻断低层错误 |
| B2 API 回归 | 鉴权、CRUD、状态动作、金额、幂等、审计、上传 | 每日 | 后端事实层稳定 | | B2 API 回归 | 鉴权、CRUD、状态动作、金额、幂等、审计、上传 | 每日 | 后端事实层稳定 |
| B3 单端回归 | 两 App、三个管理端、官网 | 每日/候选版 | 单端功能和兼容性 | | B3 单端回归 | 两 App、三个管理端、官网 | 每日/候选版 | 单端功能和兼容性 |
| B4 跨端 E2E | 7 条主流程、AC-01AC-24 | 候选版 | 业务闭环可验收 | | B4 跨端 E2E | `06-业务流程测试.md`、AC-01AC-25 | 候选版 | 业务闭环可验收 |
| B5 专项 | 安全、性能、弱网、稳定性、灾备、可访问性 | 候选版 | 非功能风险可接受 | | B5 专项 | 安全、性能、弱网、稳定性、灾备、可访问性 | 候选版 | 非功能风险可接受 |
| B6 UAT | 产品、运营、安全、财务、运维签署 | 发布前 | 业务与上线决策完成 | | B6 UAT | 产品、运营、安全、财务、运维签署 | 发布前 | 业务与上线决策完成 |
| B7 发布验证 | 部署校验、迁移、低风险冒烟、监控和回滚 | 发布时 | 生产可用且可回退 | | B7 发布验证 | 部署校验、迁移、低风险冒烟、监控和回滚 | 发布时 | 生产可用且可回退 |
@@ -80,9 +80,9 @@ cd backend/iot-server && go test ./... && go vet ./... && go build ./cmd/main/ma
### 候选版 ### 候选版
- 全模块构建通过B2/B3 全回归完成。 - 全模块构建通过B2/B3 全回归完成。
- 7 条跨端主流程和 AC-01AC-24 全部执行。 - `06-业务流程测试.md` 中 P0/P1 流程和 AC-01AC-25 全部执行。
- P0/P1 为 0性能、安全、弱网和恢复专项已出报告。 - P0/P1 为 0性能、安全、弱网和恢复专项已出报告。
- Worker/IoT 的 Mock 限制在报告中明确,未完成真实联调不伪装成通过 - IoT 模拟器、真实设备、TLS/证书和厂商联调的通过层级在报告中明确,不混写结论
### 生产发布 ### 生产发布
@@ -92,13 +92,13 @@ cd backend/iot-server && go test ./... && go vet ./... && go build ./cmd/main/ma
## 5. 冒烟清单 ## 5. 冒烟清单
1. API/Worker/IoT 启动、健康、配置加载和无敏感日志。 1. APIWorker、IoT Gateway、IoT Server 启动、健康、配置加载和无敏感日志。
2. 五类客户端登录及 client claim 隔离。 2. 五类客户端登录及 client claim 隔离。
3. 三个管理端菜单加载、核心列表、详情和一个低风险写操作。 3. 三个管理端菜单加载、核心列表、详情和一个低风险写操作。
4. user_app 读取首页/商品/订单service_app 读取准入/任务。 4. user_app 读取首页/商品/订单service_app 读取准入/任务。
5. 一次幂等写入重复提交,事实不重复。 5. 一次幂等写入重复提交,事实不重复。
6. 一次跨组织 identity 访问,被稳定错误码拒绝。 6. 一次跨组织 identity 访问,被稳定错误码拒绝。
7. 一次设备命令 Mock 回执和一次超时待确认。 7. 一次完整 IoT 命令下行与 MQTT 回执,以及一次 ACK 丢失后的待确认。
8. 一次订单到任务的跨端可见性校验。 8. 一次订单到任务的跨端可见性校验。
9. 一次敏感字段脱敏和审计查询。 9. 一次敏感字段脱敏和审计查询。
10. 官网首页、主要导航、静态资源与错误页。 10. 官网首页、主要导航、静态资源与错误页。
@@ -108,7 +108,7 @@ cd backend/iot-server && go test ./... && go vet ./... && go build ./cmd/main/ma
| 优先级 | 建设项 | 原因 | | 优先级 | 建设项 | 原因 |
| --- | --- | --- | | --- | --- | --- |
| P0 | API 鉴权/组织隔离/状态机/金额/幂等集成测试 | 当前最高业务与安全风险 | | P0 | API 鉴权/组织隔离/状态机/金额/幂等集成测试 | 当前最高业务与安全风险 |
| P0 | AC-02、03、04、07、08、09、21 跨端回归 | 设备、安全、资金、隔离、离线核心链路 | | P0 | AC-02、03、04、07、08、09、21、25 跨端回归 | 设备、安全、资金、隔离、离线和 IoT 可靠性核心链路 |
| P1 | App Repository/ViewModel/离线草稿及 Web 权限组件测试 | 当前端侧自动化覆盖较薄 | | P1 | App Repository/ViewModel/离线草稿及 Web 权限组件测试 | 当前端侧自动化覆盖较薄 |
| P1 | 三管理端契约检查统一 CI、浏览器冒烟 | 防止路由/菜单/资源漂移 | | P1 | 三管理端契约检查统一 CI、浏览器冒烟 | 防止路由/菜单/资源漂移 |
| P1 | 上传、日志脱敏、依赖与密钥扫描 | 隐私与供应链风险 | | P1 | 上传、日志脱敏、依赖与密钥扫描 | 隐私与供应链风险 |
@@ -119,7 +119,17 @@ cd backend/iot-server && go test ./... && go vet ./... && go build ./cmd/main/ma
- 计划/执行/通过/失败/阻塞/未执行用例数及覆盖率。 - 计划/执行/通过/失败/阻塞/未执行用例数及覆盖率。
- 按模块和 P0P3 的新增、遗留、关闭、重开缺陷。 - 按模块和 P0P3 的新增、遗留、关闭、重开缺陷。
- AC-01AC-24 状态,关键链路通过率。 - AC-01AC-25 状态,关键链路通过率。
- 自动化通过率、偶发失败率、覆盖率变化和执行时长。 - 自动化通过率、偶发失败率、覆盖率变化和执行时长。
- API 性能分位数/错误率、资源峰值、安全发现、恢复实测值。 - API 性能分位数/错误率、资源峰值、安全发现、恢复实测值。
- 环境限制、未测范围、残余风险、责任人和下一步日期。 - 环境限制、未测范围、残余风险、责任人和下一步日期。
## 8. 当前自动化缺口基线
本次盘点只用于安排优先级,不代表功能缺陷结论:
- `backend/iot-gateway` 已有命令元数据转发、上行转发和无本地状态测试,但缺 Handler 鉴权、过期、体积限制、上游错误映射和优雅退出覆盖。
- `backend/iot-server` 已有配置、加密帧往返、篡改拒绝、阀门命令和实时传感器解析测试,但缺 Broker 级设备认证、Topic ACL、真实 MQTT 上下行、ACK、断线重连和 TLS 测试。
- `backend/worker` 当前没有 `_test.go`支付扫描、Outbox 轮询、HTTP 状态分支、超时、坏响应、重试和停止行为应列为 P0 自动化补齐项。
- `backend/api` 的新增支付/退款和 IoT Logic 尚缺直接数据库事务及路由安全测试;优先覆盖并发回调、迟到回调、退款金额守恒、命令/Outbox 原子性、重复认领和 ACK 状态更新。
- 跨四进程的设备模拟 E2E 和支付沙箱 E2E 尚未形成持续集成作业,应在候选版验收前落地或以人工证据明确替代。

View File

@@ -0,0 +1,173 @@
# 业务流程测试
本文件把业务流程拆成可执行的跨端测试。每个流程都必须记录 UI/API 结果、数据库事实、Outbox/消息、审计和最终用户可见状态。详细单条用例使用 `templates/测试用例模板.md` 展开。
## 1. 执行规则
- P0 流程每个候选版本全量执行P1 流程每日自动化冒烟、候选版全量执行P2 在专项或 UAT 执行。
- 每个流程至少覆盖成功、服务端拒绝、重复请求、并发、依赖超时、进程重启、越权和恢复。
- 所有标识使用 `identity`;数据库 `id` 仅用于测试内部关联断言,不出现在客户端请求响应中。
- 时间断言同时检查业务发生时间、设备/客户端采集时间、服务端接收时间和更新时间,不用补传时间覆盖原始时间。
- 金额均以最小货币单位整数断言;状态必须通过动作接口推进。
## 2. 流程总表
| 流程 ID | 优先级 | 流程 | 主要参与者 | 关联验收 |
| --- | --- | --- | --- | --- |
| BF-01 | P0 | 邀请注册与服务关系 | gas/delivery admin、user_app、API | AC-20、AC-23 |
| BF-02 | P0 | 设备绑定与单设备远程关阀 | user_app、API、Worker、Gateway、IoT Server、设备 | AC-01、AC-02、AC-25 |
| BF-03 | P0 | 高风险遥测自动关阀与整改 | 设备、四后端进程、service_app、admins、user_app | AC-03AC-06、AC-16 |
| BF-04 | P1 | 设备共享、群控与报修 | user_app、API、IoT 链路、admin | AC-12、AC-22 |
| BF-05 | P0 | 商城下单、渠道支付与任务生成 | user_app、API、支付沙箱、admins、service_app | AC-07 |
| BF-06 | P0 | 支付超时关单与迟到回调 | user_app、API、Worker、支付沙箱 | AC-07 |
| BF-07 | P0 | 订单项退款审核入钱包 | user_app、API、platform_admin | AC-07、AC-14 |
| BF-08 | P1 | 气瓶预约、配送、随瓶安检与回收 | user_app、gas/delivery admin、service_app、API | AC-11、AC-13、AC-18 |
| BF-09 | P0 | 服务人员准入与离线补传 | service_app、admins、API | AC-16、AC-19、AC-21 |
| BF-10 | P1 | 安装/维修闭环 | service_app、API、admins、user_app | AC-06、AC-17 |
| BF-11 | P0 | 钱包提现审核 | apps、API、platform_admin | AC-08、AC-19、AC-24 |
| BF-12 | P0 | 组织隔离与敏感轨迹访问 | admins、apps、API | AC-09、AC-11、AC-23 |
| BF-13 | P1 | IoT 上行报文持久化与命令 ACK | 设备、IoT Server、Gateway、API | AC-02、AC-03、AC-25 |
| BF-14 | P1 | IoT Outbox 故障恢复 | API、Worker、Gateway、IoT Server | AC-02、AC-25 |
| BF-15 | P2 | 审计、备份恢复与链路追踪 | 全系统、DB、运维 | AC-10 |
## 3. BF-01 邀请注册与服务关系
前置:气站 A、配送点 A/B未注册手机号 U1、已有用户 U2有效、过期、停用、次数耗尽、区域外五种邀请。
主流程:
1. 气站 A 创建有服务区域、有效期、次数和活动来源的邀请二维码。
2. U1 扫码API 解析签名令牌并校验状态、范围、地址、风控和登录状态。
3. U1 完成验证码、协议同意和地址确认,创建全局账号及服务关系。
4. gas_admin 查询转化记录user_app 查询当前服务归属。
断言:二维码不含用户信息、管理员身份、长期令牌或 API 密钥;验证码一次消费;用户只创建一次;服务关系、来源、扫码和确认审计完整。
异常U2 重复扫码不重复建档;跨区、过期、停用、超次数被稳定错误码拒绝;从 A 变更至 B 必须明确确认,失败不得覆盖原关系;并发两次确认只有一个确定结果。
## 4. BF-02 设备绑定与远程关阀
前置:未绑定在线设备 D1、已绑定设备 D2、用户 U1/U2四后端进程和 MQTT 设备模拟器启动。
主流程:
1. U1 扫码绑定 D1API 校验设备存在、未占用、地址和用户授权。
2. U1 发起关阀,服务端完成对象归属、安全规则与二次确认校验。
3. API 在同一事务创建 `iot_command``iot_outbox`,返回命令 identity不返回执行成功。
4. Worker 认领 Outbox经 Gateway 向 IoT Server 下发IoT Server 编码 V1.8 阀门命令并发布 `devices/{deviceId}/down`
5. 模拟器返回 ACKIoT Server 解码并经 Gateway 回调 API命令变为成功。
6. user_app 查询到最终结果审计可串联请求、命令、Outbox、包序号、回执和操作者。
异常D2/U2 越权、设备离线、相同幂等键、相同幂等键不同参数、Gateway 不可用、命令已过期、ACK 丢失、失败 ACK、伪造设备 ACK、重复 ACK。无回执必须保持“待确认”不能显示成功。
## 5. BF-03 高风险遥测自动关阀与整改
前置:规则阈值、责任人和测试 SLA 已配置;设备 D1 在线;服务人员具备/不具备准入条件两组。
主流程:
1. D1 发布达到高风险阈值的加密 V1.8 实时数据帧。
2. IoT Server 验证设备身份、Topic、边界、长度、LRC8、AES 和帧内容,保留原始 payload。
3. Gateway 转发上行API 保存设备时间、服务端接收时间、原始帧和解码结果。
4. 系统创建安全事件、SLA、自动关阀命令、责任任务和通知。
5. 合格安检员接单、上门检查、提交分类证据和用户签名;不合格进入整改。
6. 整改完成后复检;通过后关闭事件并满足开阀恢复条件。
断言:事件、命令、通知、任务、证据、整改、复检和审计互相关联;普通用户在未整改时开阀被拒绝;自动关阀 ACK 丢失时事件仍存在且命令待确认。
异常:重复/乱序遥测不重复建同一事件坏帧不得进入业务规则通知部分失败可重试不合格人员不能接单缺照片、签字或围栏不允许完成复检失败继续整改SLA 超时只升级一次。
## 6. BF-04 设备共享、群控与报修
1. 所有者分别授予成员“查看”和“控制”权限,验证能力互斥。
2. 对含正常、离线、高风险锁定设备的分组执行关阀和开阀。
3. 校验逐设备命令 identity 与结果;高风险开阀被单独拦截,不因其他设备成功而覆盖。
4. 成员权限撤销后,旧页面缓存和旧令牌直接调用控制 API 仍被拒绝。
5. 一键报修提交类型、描述、地址、定位和照片,形成工单及访问审计。
## 7. BF-05 商城下单、渠道支付与任务生成
前置:可售商品、库存、地址、预约时段、支付宝/微信沙箱;一个需要配送、一个需要安装的订单。
主流程:创建订单并冻结库存 → 创建幂等 `payment_order` → 客户端获得最小化调起参数 → 渠道成功回调 → 验签并校验 appid/商户、支付单号、金额、币种和状态 → 数据库事务标记支付成功并推进业务订单 → 生成配送/安装任务 → 对应人员可见。
异常客户端声称支付成功不推进订单错误签名、appid、商户、金额、渠道或支付单号被拒绝重复回调不重复扣款、建任务或写流水并发回调只有一次状态推进同请求号不同金额/业务 identity 产生幂等冲突;日志只保留回调摘要。
## 8. BF-06 支付超时关单与迟到回调
1. 创建即将过期的支付宝和微信支付单,订单保持待支付。
2. Worker 使用内部令牌调用关单接口API 查询过期待支付单并逐渠道关闭。
3. 仅渠道关单成功且数据库条件更新成功的记录变为 closed单笔失败留待下轮。
4. 重复扫描不重复关单或改变已支付订单。
5. 对已过期支付单模拟成功回调,支付单进入“过期后支付异常”,业务订单不得直接进入已支付/履约。
异常:错误 Worker 令牌、渠道超时、API 重启、Worker 重启、扫描和回调竞争、刚好到期边界、100 条批次边界。
## 9. BF-07 订单项退款审核入钱包
前置:用户本人已支付且未履约订单,含多个订单项;配置退款窗口;用户钱包存在。
主流程:用户选择订单项和数量提交退款 → API 校验本人、支付成功、窗口、履约状态、数量和累计退款金额 → 创建待审核退款及明细 → 平台财务审核 → 同一事务锁定钱包、增加余额、完成退款并写不可变流水 → 用户查询到账。
异常:他人订单、窗口过期、已履约、项目不存在、数量为 0/超量、累计超实付、重复请求、并发两笔超额、驳回无原因、申请人自审、重复审批。任一数据库步骤失败必须整体回滚,不得只加余额或只改退款状态。
## 10. BF-08 气瓶预约、配送、随瓶安检与回收
主流程:选择配送公司、规格、数量和时段 → 冻结库存与费用 → 支付 → 气站分派配送点/人员 → 配送员准入校验 → 出发和轨迹上报 → 到达围栏校验 → 气瓶扫码/蓝牙校验 → 随瓶安检和取证 → 收款确认/签收 → 空瓶回收 → 同步更新气瓶、库存、押金、订单和结算。
异常:库存并发不足、时段失效、人员区域外、非履约定位、错误/重复气瓶码、证据不足、金额差异、回收损坏/编码不符、离线乱序补传、用户查看其他订单轨迹。
## 11. BF-09 服务人员准入与离线补传
1. 自主注册仅生成待审核账户;分别制造资质过期、培训未过、未上班、区域外、无授权设备。
2. 验证任一条件不满足都不能开始任务,直接调用 API 同样拒绝。
3. 合格人员开始任务后断网,采集定位、照片元数据、签名和扫描结果并本地加密。
4. 重启 App 后恢复草稿;联网按原始采集时间和幂等键补传。
5. 服务端去重、乱序校正、完整性/哈希和任务状态校验,记录接收时间与补传标记。
异常:重复补传、篡改哈希、未来时间、任务已关闭、部分上传成功、权限中途撤销、本地明文检索、非任务期间后台定位。
## 12. BF-10 安装/维修闭环
按“使用条件→备料→安装→测试→前期安检→设备激活→完成→收款”顺序执行新装;维修保存前中后证据、材料、价格快照、应收/实收和签字。测试失败、环境不合格、收费差异或强制证据缺失时UI 与直接 API 均不能完成;整改/复检通过后才可继续。
## 13. BF-11 钱包提现审核
1. 绑定银行卡并验证只返回末四位;设置/校验独立六位支付密码和失败锁定。
2. 申请提现,在同一事务锁定钱包、预扣总余额和可提现余额、创建申请和流水。
3. 驳回只返还该申请实际预扣的两类余额;完成打款只确认外部结果,不再次扣款。
4. 全程验证 `0 <= 可提现余额 <= 总余额`
异常:余额不足、并发申请、冻结钱包、错误/锁定支付密码、重复审批、驳回无原因、申请人篡改、历史未预扣提现兼容分支、整数溢出。
## 14. BF-12 组织隔离与敏感轨迹访问
使用结构相同的气站 A/B、配送点 A/B、用户 U1/U2 数据,逐项验证列表、详情、搜索、分页、关联资源、导出和直接猜测 identity。用户只看本人简化轨迹精确回放/导出要求审批、水印、用途和审计;非履约位置不可访问。缓存命中、批量接口和错误响应不得泄露目标是否存在或敏感字段。
## 15. BF-13 IoT 上行报文与命令 ACK
协议矩阵:无加密/key 1、最短/最大允许帧、错误起止符、声明长度错误、LRC 错误、AES key 不存在、密文非 16 字节倍数、BCD 时间、实时传感器/事件/IO/参数块、未知子标识、截断块。
链路断言:设备只能发布自己的 `up/ack` 并订阅自己的 `down`Gateway 与 API 均校验内部令牌上行原始十六进制小写保存ACK 只有设备号、包序号和控制位匹配时更新命令;设备失败位写稳定错误码;伪造/重复 ACK 不改变其他命令。
## 16. BF-14 IoT Outbox 故障恢复
1. 在 API 事务中注入 Outbox 写失败,验证命令也不落库;命令写失败同理。
2. 两个 Worker 并发认领,`SKIP LOCKED` 保证一条记录单次 processing。
3. Gateway 连接失败、返回非 2xx、响应 JSON 损坏分别记录失败并安排重试。
4. processing 超过一分钟可重新认领;退避按尝试次数增长且上限一分钟。
5. Worker 在投递成功后、回写 API 前崩溃,恢复后允许重复投递但不重复业务命令事实;设备/Server 侧以命令 identity 和幂等键识别。
6. 观察 pending/retry/processing 积压、最老年龄和失败码;未实现死信上限时在报告中标记风险。
## 17. BF-15 审计、备份恢复与链路追踪
选取设备命令、安全事件、支付回调、退款、提现和敏感轨迹导出六类事实,验证可用 `request_id`、业务 identity、命令 identity、支付单号串联日志、审计、数据库和外部摘要。隔离环境执行 PostgreSQL 全量加 WAL 恢复,校验记录数、关键哈希、状态不变量、资金守恒和审计链;记录实测 RPO/RTO不以目标值代替实测值。
## 18. 自动化落地顺序
1. API 数据库集成测试:支付创建/回调/关单/退款、IoT 命令/Outbox/上行回执。
2. Worker 使用 `httptest` 覆盖 204、401、500、超时、坏 JSON、重复认领和优雅退出。
3. Gateway 补充 Handler 级鉴权、过期、体积、上游错误和上行拒绝测试。
4. IoT Server 增加 Broker 级每设备认证/ACL、MQTT 上下行和断线重连测试。
5. 使用设备模拟器和支付沙箱建立 BF-02、BF-05、BF-06、BF-13、BF-14 的 CI 集成作业。
6. 使用 Flutter/Web E2E 覆盖 BF-01、BF-03、BF-07BF-12 的跨角色流程。

View File

@@ -8,9 +8,10 @@
| --- | --- | | --- | --- |
| [01.md](01.md) | 用户已有的平台后台补充需求原文,作为测试输入保留,不作为完整测试计划 | | [01.md](01.md) | 用户已有的平台后台补充需求原文,作为测试输入保留,不作为完整测试计划 |
| [02-总体测试计划.md](02-总体测试计划.md) | 范围、策略、分层、阶段、角色、风险和完成标准 | | [02-总体测试计划.md](02-总体测试计划.md) | 范围、策略、分层、阶段、角色、风险和完成标准 |
| [03-系统与业务覆盖矩阵.md](03-系统与业务覆盖矩阵.md) | 各端、后端进程、跨端流程和 AC-01AC-24 的追踪关系 | | [03-系统与业务覆盖矩阵.md](03-系统与业务覆盖矩阵.md) | 各端、后端进程、跨端流程和 AC-01AC-25 的追踪关系 |
| [04-环境数据与依赖计划.md](04-环境数据与依赖计划.md) | 测试环境、账号、数据、Mock、第三方和设备模拟要求 | | [04-环境数据与依赖计划.md](04-环境数据与依赖计划.md) | 测试环境、账号、数据、Mock、第三方和设备模拟要求 |
| [05-执行批次与质量门禁.md](05-执行批次与质量门禁.md) | 冒烟、回归、专项、发布验收的执行顺序和准入准出条件 | | [05-执行批次与质量门禁.md](05-执行批次与质量门禁.md) | 冒烟、回归、专项、发布验收的执行顺序和准入准出条件 |
| [06-业务流程测试.md](06-业务流程测试.md) | 跨 App、Web、API、Worker、IoT 和数据库的可执行业务流程测试 |
| [templates/测试用例模板.md](templates/测试用例模板.md) | 单条测试用例的统一记录格式 | | [templates/测试用例模板.md](templates/测试用例模板.md) | 单条测试用例的统一记录格式 |
| [templates/测试报告模板.md](templates/测试报告模板.md) | 每轮测试结果、缺陷和发布建议模板 | | [templates/测试报告模板.md](templates/测试报告模板.md) | 每轮测试结果、缺陷和发布建议模板 |
@@ -24,6 +25,6 @@
## 计划边界 ## 计划边界
- `backend/worker`投递 IoT Outbox无状态 `backend/iot-gateway` 与内嵌 Mochi MQTT Broker 的 `backend/iot-server` 已落地系统接口和 MQTT 协议边界。真实设备证书和厂商硬件仍需在联调环境验收。 - `backend/worker`承担支付超时扫描和 IoT Outbox 投递;无状态 `backend/iot-gateway` 与内嵌 Mochi MQTT Broker 的 `backend/iot-server` 已落地系统接口和 MQTT V1.8 协议边界。真实设备证书、生产 TLS 和厂商硬件仍需在联调环境验收。
- `frontend/site` 是官网,按展示、响应式、可访问性、链接、构建和托管 Worker 测试;不把它当成业务事实写入端。 - `frontend/site` 是官网,按展示、响应式、可访问性、链接、构建和托管 Worker 测试;不把它当成业务事实写入端。
- 本计划不把尚未实现或文档中“待确认”的政策当作通过标准。此类项进入阻塞/待决清单,由产品、安全、财务或法务确认后再固化用例。 - 本计划不把尚未实现或文档中“待确认”的政策当作通过标准。此类项进入阻塞/待决清单,由产品、安全、财务或法务确认后再固化用例。

View File

@@ -22,7 +22,7 @@
| 单端/E2E | | | | | | | | 单端/E2E | | | | | | |
| 安全/性能/弱网/恢复 | | | | | | | | 安全/性能/弱网/恢复 | | | | | | |
## 4. AC-01AC-24 ## 4. AC-01AC-25
| AC | 结果 | 证据 | 缺陷/限制 | | AC | 结果 | 证据 | 缺陷/限制 |
| --- | --- | --- | --- | | --- | --- | --- | --- |

View File

@@ -105,6 +105,13 @@
- 对象存储启用版本/生命周期策略,合同和安全证据按合规期限留存;备份不得绕开数据加密和访问控制。 - 对象存储启用版本/生命周期策略,合同和安全证据按合规期限留存;备份不得绕开数据加密和访问控制。
- 关键服务多实例部署MQTT、数据库、消息队列和对象存储须有明确高可用方案和故障演练计划。 - 关键服务多实例部署MQTT、数据库、消息队列和对象存储须有明确高可用方案和故障演练计划。
### 5.1 远程依赖与迁移安全
- 当前 API 按项目部署约定仅从对应 YAML 读取 PostgreSQL、Redis、字段加密密钥及支付/IoT 内部令牌,不接受环境变量覆盖。配置文件必须采用严格访问权限并由部署流程受控分发;不得出现在日志、测试报告或公开构建产物中。
- 远程 Redis 应使用 TLSPostgreSQL 应启用服务端证书校验,并由部署环境限制来源网络。开发 Mock 验证码和 Mock 支付不得在生产配置启用。
- 资金、退款、支付及审计表不得由通用迁移命令直接删除。发现旧表时迁移必须中止,先完成数据盘点、备份、映射、双写或兼容读取方案,经财务和审计确认后执行专用迁移。
- 远程集成测试使用隔离数据库、独立 Redis DB/命名空间和测试前缀;不得清空共享实例,也不得物理删除资金、安全事件或审计事实作为清理手段。
## 6. 移动 Client API 安全实施约定 ## 6. 移动 Client API 安全实施约定
- 用户端和工作人员端分别使用 `user_app``service_app` JWT client claim服务端逐请求校验 client、账户启用状态、岗位、组织和对象归属。 - 用户端和工作人员端分别使用 `user_app``service_app` JWT client claim服务端逐请求校验 client、账户启用状态、岗位、组织和对象归属。

View File

@@ -7,7 +7,7 @@
"dev": "vite", "dev": "vite",
"build": "vite build && node scripts/prepare-sites-build.mjs", "build": "vite build && node scripts/prepare-sites-build.mjs",
"preview": "vite preview", "preview": "vite preview",
"test:sites": "node --test tests/sites-worker.test.mjs" "test:sites": "npm run build && node --test tests/sites-worker.test.mjs"
}, },
"dependencies": { "dependencies": {
"@fontsource-variable/noto-sans-sc": "^5.3.0", "@fontsource-variable/noto-sans-sc": "^5.3.0",