diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..a7260cf --- /dev/null +++ b/TESTING.md @@ -0,0 +1,497 @@ +# 智慧后勤收银终端 · 测试角色手册 + +> 项目:`pj_supermarket_cloud`(智慧后勤 POS 收银终端,Flutter + Android 混合开发) +> 版本基线:v2.1.3+213 · Floor 数据库 v14 · minSdk 24 · Flutter 3.27.4(fvm) +> 本文档是本项目测试角色的**岗位职责 + 测试策略 + 发版流程**三位一体的工作手册。 + +--- + +## 0. 角色定位:你在为谁把守发布按钮 + +**每按下一次"发版",就是把一个新版本同时推进 100 多家商家的收银台。** + +收银终端不是普通 App: + +- 它是商家的**营业生命线**——收银页白屏 10 分钟,商家 10 分钟收不了钱; +- 它跑在**无人值守的真实硬件**上——双目摄像头、扫码枪、打印机、副屏,任何一个外设失联都是现场事故; +- 它一旦装上就**很难卸载**(Device Owner 模式),发出去的版本没有"撤回"按钮,只有"覆盖再发"; +- 它的 bug 大多有**资损属性**——少收钱、重复扣款、订单丢失、离线单不回传,都是直接的经济纠纷。 + +所以这个岗位的三个铁律: + +1. **把每一次发版当成"100 个商家同时投诉"的事故预演。** 测试报告里写的不是"用例通过率",而是"我为什么敢让它上生产的 100 台机器"。 +2. **P0 主链路(扫码→加购→结算→支付→出票)任何一条不过,版本就没有出门的资格。** 性能好看、UI 精致都不能换主链路的确定性。 +3. **灰度不是走过场。** 100+ 商家意味着你不可能手工覆盖每一家,灰度挑选 + 观察指标 + 回滚预案是你对全部商家负责的机制,而不是仪式。 + +--- + +## 1. 被测系统画像(必须烂熟于心) + +### 1.1 技术栈与架构要点 + +| 层 | 技术 | 测试相关要点 | +|---|---|---| +| 框架 | Flutter 3.27.4(**必须用 fvm**) | `~/fvm/versions/3.27.4/bin/flutter`,默认 3.29.2 会令 `flutter_libserialport` 编译失败(Registrar 未解析) | +| 状态/路由 | GetX 4.7.2 | 路由守卫、controller 生命周期竞态是历史 bug 高发区 | +| 网络 | dio 5.7.0 / http | 401/424 守卫、登录页 controller 重建竞态 | +| 消息 | mqtt_client 10.x | clientId/账密创建时固化;配置变化必须重建客户端,否则重连 notAuthorized | +| 本地库 | Floor 1.4.2 + sqflite | 库文件 `cash_register.db`,位于外部 Documents 目录 `pj_cashier/database/`(**非** sqflite 默认目录);版本 **v14 与原生侧 DatabaseHelper 双方锁定**,任何一侧动版本必须两边同改(Floor 遇更高版本会**静默跳过迁移**,这是数据丢失级陷阱) | +| 原生 | facediscern 插件(Java) | 人脸识别引擎、双目摄像头、人脸库、证据图写入、副屏插件 | +| 双屏 | flutter_subscreen_plugin | 副屏四模式(SubDisplayCoordinator);`sendMsgToViceScreen` 永不回包,`sendViceMessage` 由 MainActivity 解包转发立即回包 | +| 串口 | flutter_libserialport | 扫码枪 | +| 打印 | drago_pos_printer(项目携带修复版)+ printing | USB 小票打印机;项目内修复版是因 pub.dev 版 build.gradle 与 AGP 不兼容 | +| 定时任务 | cron 0.6.2 | 终端日志上传、定时探测等后台任务 | +| 语音 | flutter_tts 4.2.2(钉死,Kotlin 编译器约束) | 收银语音播报 | +| 更新 | background_downloader | APK 自动更新;**Device Owner 挡卸载**,只能覆盖安装 | +| 加密 | pointycastle | 通信加密 | + +### 1.2 业务模块地图 + +| 模块 | 路径 | 说明 | +|---|---|---| +| 登录 | `lib/pages/login` | http 守卫 + fenix 重建竞态历史坑 | +| 启动装载 | `lib/pages/load` | 5 步骤装载(含重试防 late final 二次赋值) | +| 收银主界面 | `lib/pages/panel` | 超市收银 + 食堂收银,购物车、结算弹框 | +| 超市订单 | `lib/pages/supermarket_order` | 订单列表/退款 | +| 食堂商品 | `lib/pages/canteen_product` | 商品卡网格(GridView 紧约束历史坑) | +| 食堂预订/取餐/汇总 | `canteen_reserve` / `take_food` / `canteen_order_total` | 预订取餐链路 | +| 卡激活 | `lib/pages/card_active` | 饭卡激活 | +| 订单历史 | `order_history`(+detail) | 本地+云端订单 | +| 设置 | `lib/pages/setting` | 人脸管理、离线用户、同步日志、基本设置(识别摄像头选择等) | +| 终端外壳 | `lib/pages/terminal` | `/terminal` 一级路由(顶栏+二级路由),面板经 arguments 进入 | + +### 1.3 本地数据(Floor entities) + +订单 `order`、挂单 `order_hold_bill`、超市 SPU/SKU/条码 `supermarket_spu/sku/code`、食堂 SPU `canteen_spu`、分类 `classify`、人脸档案 `face`、用户 `user`、打印历史 `print_history`、定时任务 `scheduled_task(+log)`、文件上传记录 `file_upload_record`、订单凭证上传任务 `order_evidence_upload_task`、设置 `setting`、商铺卡 `shop_card`、广播 `broadcast`。 + +测试升级/迁移时,这 16 张表**全部**要有存量数据。 + +### 1.4 外设矩阵 + +| 外设 | 接入方式 | 测试关键点 | +|---|---|---| +| 双目摄像头(彩色+红外) | 原生 facediscern | facing 元数据不可靠(排线装反过)→ 人工选择摄像头 + NV21 色度探测(U/V 偏离 128 阈值 2.0) | +| 扫码枪 | 串口 | 高频扫码、乱码、拔插 | +| 小票打印机 | USB | 出票内容与格式、断电重连 | +| 副屏 | flutter_subscreen_plugin | 四模式切换、购物车/人脸页副屏内容、10 秒清屏 | +| 语音 | TTS | 播报时机与音量 | + +--- + +## 2. 测试环境建设 + +### 2.1 设备矩阵(最低配置) + +| 档位 | 配置 | 用途 | +|---|---|---| +| **主力真机**(≥1 台) | 项目实际下发的 POS 整机(含双摄、扫码枪、打印机、副屏) | 一切外设/人脸/性能/稳定性测试的**唯一权威环境** | +| 次主力真机(≥1 台) | 另一批次 POS 整机 | 兼容性对照 | +| 低端 Android 平板 | Android 7.x,2GB RAM | 性能下限、兼容性 | +| 模拟器 | Android 14 | 仅限 UI 走查、普通功能回归;**人脸(无摄像头排线)、串口、副屏、Device Owner 在模拟器上不可测或行为失真** | + +> 铁律:**外设相关和人脸相关的结论,只认真机。** 模拟器通过 ≠ 真机通过。 + +### 2.2 构建红线 + +```bash +# 必须用 fvm 的 3.27.4,且注意 pub get 需国内镜像;gradle daemon 有历史代理坑 +~/fvm/versions/3.27.4/bin/flutter analyze # 静态分析,0 error 才准入 +~/fvm/versions/3.27.4/bin/flutter test # 单元/组件测试 +~/fvm/versions/3.27.4/bin/flutter build apk --release # 发版包 +``` + +- 改过 Floor DAO/Entity 后必须重跑 `build_runner`,否则 `.g.dart` 与 SQL 不一致(历史坑:DAO SQL 改了没重跑,行为与代码不符)。 +- 签名必须与线上一致(Device Owner 覆盖安装要求签名一致,否则装不上)。 + +### 2.3 测试数据准备(每次发版前刷新) + +1. **多商户**:至少 3 个测试商家,覆盖 超市模式 / 食堂模式 / 混合。 +2. **大商品量商家**:≥ 1 万 SPU(含多级分类、已删除分类下仍挂商品的"脏数据"商家——历史上"全部"标签空白就是这种孤儿分类导致)。 +3. **人脸量**:单商家 ≥ 3000 人脸档案(压人脸同步与引擎装载)。 +4. **存量库**:一台装着上一版生产数据(订单 ≥ 5000 条、挂单、打印历史齐全)的真机,专用于升级测试。 +5. **钱包类型**:饭卡/消费/充值三种钱包类型(walletTypeUniqueCode 1/2/3)都要有测试账户。 + +### 2.4 环境分层 + +| 环境 | 用途 | 要求 | +|---|---|---| +| dev | 日常功能测试 | 后端可随时重启,允许脏数据 | +| test | 集成回归 | 数据形态贴近生产 | +| 预发 | 发版前全量回归 | 后端为发版候选版本;**后端先行部署**(增量同步依赖后端 updateTime 契约,前后端版本必须配套验证) | +| 生产灰度 | 3–5 家真实商家 | 见 §6 发布流程 | + +--- + +## 3. 测试类型全景 + +### 3.1 功能测试(按模块的核心用例要点) + +**A. 登录与启动(P0)** + +- 冷启动 → Load 页 5 步骤装载(任何一步失败重试后可恢复;不出现 late final 二次赋值崩溃)。 +- 登录成功/失败/断网登录;token 过期后 401/424 守卫(登录页自我替换 + fenix 重建竞态:表单有字仍提示"请输入账号"就是复发了)。 +- 三处登录后跳转路径都带 currentRoute 守卫。 + +**B. 收银主链路(P0,最高优先级)** + +- 扫码枪扫码加购(串口数据流)、手工选商品加购、数量步进器 +/-、改价、折扣。 +- 食堂模式:餐段限制、菜谱日期范围、预订取餐核销。 +- 购物车边界:0 元单、大数量、清空、快速连续加购(连击去抖)。 + +**C. 结算弹框专项(本项目历史 bug 密集区,必须逐条过)** + +| 检查项 | 要点 | +|---|---| +| 金额快照 | 弹框金额是**冻结快照**;识别用户重算后必须 `updateCheckoutSnapshot` 推送(body 订阅 revision) | +| 会员恢复 | 重开弹框时会员必须恢复(initialUser 种子化+刷钱包);挂单恢复时会员也要恢复 | +| 钱包类型 | 饭卡(1)/消费(2)/充值(3) 三种类型展示正确;食堂内嵌按饭卡展示 | +| 数量步进器 | 只有传 `onQuantityChanged` 才有 +/−;回调必须返回**新快照** | +| 额外支付 | 额外支付金额计入快照;`_computedAmountMealPrice` 不得残留旧值 | +| 行内确认 | 破坏性操作是行内二次确认(5 秒超时还原),不是再套弹框 | +| 就地注册人脸 | 弹框内只做本地引擎装载(rebuildFaceLibrary + registrationStream 进度),**绝不拉取远端** | + +**D. 支付(P0)** + +- 现金、扫码支付(被扫/主扫)、人脸支付(识别→比对→扣款→凭证图留存)。 +- 支付成功→副屏成功页→10 秒清屏;打印小票。 +- 支付失败/超时/取消的三态展示与重试。 +- **资损专项**:同一单不重复扣款;断网瞬间支付结果未知时,重试不产生双单;退款金额 ≤ 原单。 + +**E. 人脸(P0,混合开发重点)** + +- 人脸同步三模式:full / incremental / surgical(带 spuIds 定向);同步进度条两阶段语义正确。 +- 人脸档案管理页:删除/停用后引擎库即时生效。 +- 人脸质量上报:V2 历史数据(updateTime=null)装新包后点一次 V3 同步即回填修复。 +- 识别链路:活体(RGB/IR)、防伪、识别后会员信息与钱包正确。 +- **副屏盲区专项**:结算弹框打开时副屏购物车间歇更新——人脸消息曾被 `isAvailable` 门禁静默丢弃,验证按需补探测生效。 + +**F. 商品同步(P0)** + +- 增量同步三模式 + 墓碑(逻辑删除):后台改价/上新/删除 → 终端 ≤ 1 个同步周期内生效(删除是逻辑删除,须先补 update_time 才能被增量感知)。 +- 超市"全部"标签不空白(孤儿分类口径:商品挂已删除分类时 INNER JOIN classify 后的行为)。 +- **扫码枪路径不过分类过滤**,条码直查必须命中已删除分类下的商品(这是口径例外,验证它没被"顺手修掉")。 +- 同步日志页可查每次同步的结果与耗时。 + +**G. 离线与回传(P0)** + +- 断网收银正常落本地 → 网络恢复后离线订单回传,不丢不重。 +- **sysType 陷阱回归**:设备切模式后,离线订单按订单自身 sysType 回传,不得按设备当前模式过滤(历史 bug:过滤导致查空假成功——回传"成功"但后台查无此单)。 + +**H. 挂单/取单** + +- 挂单(含会员)→ 取单恢复购物车+会员+额外支付;挂单列表分页。 + +**I. 订单与退款** + +- 订单历史(本地/云端)、订单详情、退款(整单/部分)、退款凭证。 + +**J. 设置与终端** + +- 基本设置:识别摄像头选择(自动/彩色/红外,CameraSelectionStore -1=自动,探测只在 FACE_EXECUTOR)、打印设置、语音设置。 +- 终端外壳 `/terminal`:顶栏状态栏、二级路由;panel 经 arguments 进终端(**内层路由不能用 Get.arguments**)。 +- 离线用户管理、同步日志查看。 + +### 3.2 性能测试 + +**指标与参考门槛**(基线由首次全量测量建立,此后每版对比,回归 > 20% 必须给出解释): + +| 指标 | 参考门槛 | 测量方法 | +|---|---|---| +| 冷启动 → 登录页可见 | ≤ 3 s | `adb shell am start -W` + 录屏逐帧 | +| 登录 → 可收银(含人脸库装载) | ≤ 10 s | 录屏 + 应用内日志时间戳 | +| 加购点击 → 购物车刷新 | ≤ 200 ms(P95) | 录屏逐帧 / DevTools timeline | +| 结算弹框打开 | ≤ 500 ms | 同上 | +| 人脸识别(刷脸→出会员) | ≤ 1.5 s | 现场秒表 + 日志 | +| 扫码→商品上屏 | ≤ 300 ms | 高频连扫 100 次取分布 | +| 打印指令→出纸 | ≤ 3 s | 现场实测 | +| 滚动流畅度 | FPS ≥ 50,无连续丢帧 | `adb shell dumpsys gfxinfo com.example.pj_supermarket_cloud framestats` | +| 内存 | 长跑 24h 无持续增长趋势 | `adb shell dumpsys meminfo com.example.pj_supermarket_cloud`,每小时采样 | +| ANR | **0 次** | logcat + `/data/anr/` | +| 1 万 SPU 增量同步 | ≤ 60 s(基线待建) | 同步日志页耗时 | + +**工具链**: + +```bash +# 内存 +adb shell dumpsys meminfo com.example.pj_supermarket_cloud +# 帧渲染 +adb shell dumpsys gfxinfo com.example.pj_supermarket_cloud framestats +# 帧率/卡顿(Perfetto) +~/Library/Android/sdk/cmdline-tools/latest/bin/adb shell perfetto ... # 或 Android Studio Profiler +# 真库拉取后用 sqlite3 实测慢查询(先 adb root) +adb root && adb pull /storage/emulated/0/Android/data/com.example.pj_supermarket_cloud/Documents/pj_cashier/database/cash_register.db /tmp/ +sqlite3 /tmp/cash_register.db ".tables" +sqlite3 /tmp/cash_register.db "EXPLAIN QUERY PLAN <待测SQL>" +``` + +外加:**Flutter DevTools**(Performance / Memory / CPU Profiler,重点看 build 帧与 Isolate 占用)。 + +**本项目性能地雷区**(新版本改动到这些区域,性能测试自动升级为 P0): + +1. **人脸链路**:历史上 getFaceStatus 在主线程全表扫描导致 ANR;现状契约是状态查询**绝不 SELECT faceModel** 大字段、重活进 FACE_EXECUTOR、占线短路(进度键 total/successCount)。凡动 face 相关 DAO/原生代码,必做 3000+ 档案真机压测。 +2. **结算链路**:后端 createOrder 历史上是 9 次串行 Feign 的大事务;前端虽已去重+内存缓存,但任何弹框重算逻辑的改动都可能把耗时打回来。结算弹框打开耗时每版必测。 +3. **同步链路**:拼音转 Isolate、详情分批 100、删除分批 500——改动分批参数/ Isolate 策略要回归 1 万 SPU 商家。 +4. **DB 迁移**:升级路径必然触发迁移,必须带存量库测启动耗时。 + +### 3.3 混合开发专项(Flutter ↔ Android 通道) + +这是本项目区别于普通 Flutter 项目的测试核心: + +- **MethodChannel 契约测试**:`FlutterFaceDiscernPlugin` / `FlutterFaceSubScreenPlugin` / `FlutterTool` / `FlutterFaceReceiver` 每个对外方法的入参、返回、异常路径逐一验证;协议字段名(如 updateCard 系列)**一字不能改**,通道两侧是弱契约,编译器不查。 +- **线程纪律**:原生重活必须走 FACE_EXECUTOR;任何"顺手"在主线程做的事(DB 查询、图片 IO)都是 ANR 候选。 +- **双库版本锁**:Floor v14 与原生 DatabaseHelper v14 必须一致。发版前 diff 两边版本号;**Floor 遇到更高版本号会静默跳过迁移**,这会直接造成数据结构错位。 +- **生命周期**:人脸页副屏自建 Texture 的创建/销毁;App 后台→前台、跳转打断识别(isRecognition 复用为锁 + dispatchId 令牌防迟到回调)。 +- **消息回路**:副屏 `sendViceMessage`(MainActivity 解包转发、立即回包)与 `sendMsgToViceScreen`(永不回包)行为不同,别混用验收标准。 +- **原生日志**:logcat 过滤 `facediscern` 标签,验证引擎状态迁移。 + +### 3.4 稳定性测试 + +- **长跑(Soak)**:真机收银页 24–48h:每 10 分钟一次自动加购+结算(可用 adb 脚本或人工节奏),期间跑 3 轮人脸同步。验收:零 crash、零 ANR、内存曲线平稳、副屏内容不漂移。 +- **Monkey**:`adb shell monkey -p com.example.pj_supermarket_cloud --throttle 300 -s 100000`,仅限冒烟后(人脸页、设置页单独跑),验收零 ANR、crash 可归因。 +- **异常注入**(每版必做一轮): + - 收银过程中拔网线 / 关 WiFi / 热点切换(弱网可用 `adb shell tc qdisc` 限速/加丢包); + - 扫码枪/打印机/副屏中途拔插; + - 支付进行中按返回/进后台/锁屏; + - 断电重启(非正常关机):重启后本地订单完好、离线单可回传、无 double init; + - 后端接口 500/超时/返回畸形 JSON。 +- **MQTT 稳定性**:改密/换地址后重连(历史坑:clientId 固化导致 notAuthorized 拒绝);后台 pull 指令 10 分钟内到达;**publish 断连时是静默 return 且恒返成功**,不能只看 App 日志判断发送成功,要以后台收到为准。 + +### 3.5 兼容性测试 + +- 机型:≥2 批次 POS 整机 + 1 台低端平板 + 1 台主流手机(用于老板/运维装 App 看后台的场景)。 +- 系统:Android 7 / 9 / 12+(minSdk 24 到 targetSdk 全区间至少抽 3 档)。 +- **副屏分辨率**:不同分辨率/长宽比副屏下布局不错位(副屏内容是独立渲染路径)。 +- **大字号**:主题字号运行时缩放 1.2–1.5 倍(forui 字号被 sizeScalar 缩放),全页面走查无裁切/溢出——本项目已有多起字号裁切历史(DropdownButton、商品卡、输入框)。 +- 深浅色/横竖屏(若支持)。 + +### 3.6 网络与离线测试 + +- 弱网矩阵:200ms 延迟 / 5% 丢包 / 30KB 带宽,核心链路(登录、加购、结算、同步)逐一过。 +- 断网下可用功能清单明确(本地商品、本地收银、挂单、订单历史),不可用功能有**明确友好提示**而非转圈。 +- 恢复网络后:离线订单自动回传(不丢/不重/顺序合理)、增量同步自动追平、MQTT 自动重连且**鉴权成功**。 +- 账密错误/设备未授权/token 过期的错误路径展示。 + +### 3.7 升级与数据迁移测试(100+ 商家场景的重中之重) + +- **覆盖安装**:上一生产版(带真实存量数据)→ 候选版,验证: + 1. 安装成功(Device Owner 下只能覆盖装,签名必须一致); + 2. 启动后 DB 迁移正确:16 张表数据完整(订单/挂单/人脸/设置逐一核对条数); + 3. Floor 与原生两侧版本一致,无静默跳迁移; + 4. 登录态/设置项保留。 +- **跨版本升级**:至少支持 n-2 → n(很多商家不会及时更新)。 +- **首启动修复逻辑**:V2 人脸数据(updateTime=null)在装新包后点一次 V3 同步被回填。 +- **降级禁止**:明确记录不支持降级;若需回滚,走"覆盖安装回旧版 + 旧版能读新库结构吗"的预研结论(通常不能,所以灰度观察窗口才是主防线)。 +- 自动更新链路:background_downloader 下载 → 安装提示 → 覆盖安装成功;下载中断续传、磁盘不足提示。 +- **旧 APK 窗口判别**:灰度观察期区分"设备没更新"(后台看在线设备 APK 版本)与"更新了但坏了"。 + +### 3.8 外设专项 + +| 外设 | 必测点 | +|---|---| +| 双目摄像头 | 彩色/红外自动探测标签正确(U/V 偏离 128 阈值 2.0);人工选择后重启仍生效;排线装反的机器上靠人工选择兜底 | +| 扫码枪 | 高频连扫、破损码、含中文码、扫码中拔出(不 crash,插回自动恢复) | +| 打印机 | 小票内容字段齐全(商家名/单号/明细/金额/时间)、缺纸提示、连续打印 100 单不掉 USB | +| 副屏 | 四模式切换、收银副屏跟车、支付成功页 10 秒清屏、副屏拔插 | +| TTS | 播报内容、与系统音源冲突(放音乐时播报) | + +### 3.9 安全测试 + +- 日志脱敏:人脸照片、密码、token 在日志中只出现脱敏格式(历史规范:密码只打脱敏格式)。 +- 本地 DB 中人脸特征/用户钱包数据的存储边界;DB 文件 pull 下来检查敏感字段。 +- 通信加密(pointycastle)生效性抽验(抓包不可读业务明文——脱敏字段除外)。 +- 证据图(人脸支付对比凭证、OSS 上传)不越权可访问;OSS 上无空文件/损坏图(历史坑:oss 有空文件图)。 +- 更新包完整性:APK 下载校验。 + +### 3.10 自动化与单元测试 + +现状:`test/` 下 3 个测试文件(terminal_toolbar、mq_supermarket_product_msg、search_highlight_tool)。 + +- CI 门槛:`flutter analyze` 0 error + `flutter test` 全绿,作为提测准入。 +- **建议逐步补齐**(按资损优先级): + 1. 金额计算纯函数(快照重算、额外支付、钱包优先级); + 2. 离线订单回传的 sysType 归类逻辑; + 3. 增量同步三模式的本地合并/墓碑逻辑; + 4. MethodChannel 消息的序列化/反序列化 round-trip(可用原生侧单测)。 +- 自动化是辅助,**主链路验收永远是真机手工**(外设决定了无法完全自动化)。 + +### 3.11 UI/UX 走查 + +- 大字号缩放 1.2/1.5 两档全页面走查(见 3.5)。 +- 弹框类:showAppDialog 内 controller 生命周期(历史上 await 返回后立刻 dispose 会崩,弹窗内输入框 controller 收进弹窗 StatefulWidget)。 +- 图片:appImg 有宽度契约(width 透传),Row 中图片无界会 "forces an infinite width" 崩溃——新页面必查。 +- 加载态/空态/错误态三态齐全;Toast 长文案不截断(需 Flexible)。 + +--- + +## 4. P0 回归红线(发版冒烟集,30 分钟内完成) + +> 每版发版前在一台**带全部外设的 POS 真机**上,用**生产形态的测试商家**过完以下清单。任何一条 FAIL 即停发。 + +1. 冷启动 → Load 装载 → 登录成功。 +2. 扫码枪扫 3 个不同商品 → 购物车正确(名称/价格/数量)。 +3. 修改数量(步进器+直输)→ 金额重算正确。 +4. 挂单 → 继续收银新单 → 取单 → 购物车+会员+额外支付完整恢复。 +5. 选会员(刷脸识别会员)→ 会员价生效 → 钱包余额正确显示(三种钱包类型各一次)。 +6. 现金支付一单 → 出票 → 订单历史可见。 +7. 扫码支付一单(真实支付通道/沙箱)→ 支付成功页 → 副屏 10 秒清屏 → 出票。 +8. 人脸支付一单 → 识别 ≤ 1.5s → 扣款 → 证据图留存可在后台看到。 +9. 断网收银一单 → 恢复网络 → 订单回传后台可查(**去后台亲眼确认,不信本地"成功"日志**)。 +10. 退款一单 → 金额正确 → 小票/后台同步。 +11. 后台改价一个商品 → 触发/等待同步 → 终端价格更新;后台删一个商品 → 终端不再可售。 +12. 超市"全部"标签有数据、食堂商品页正常。 +13. 重启 App → 登录态/设置/本地订单保留。 +14. 覆盖安装(上一版→本版,带存量库真机)→ 启动无迁移报错,订单/挂单/人脸档案条数不丢。 +15. 弱网(限速)下完成一次完整收银。 +16. 副屏四模式切换一遍,内容正确。 +17. logcat 过滤 30 分钟无未归因 crash/ANR。 +18. 终端日志上传到后台可见(本版日志)。 + +--- + +## 5. 缺陷管理与取证规范 + +### 5.1 分级 + +| 级别 | 定义 | 处置 | +|---|---|---| +| S1 | 资损风险(重复扣款/丢单/金额错)、无法收银(白屏/崩溃/主链路卡死)、数据丢失 | 立即停发/回滚;24h 内修复验证 | +| S2 | 主链路降级(某支付方式不可用、同步停摆、副屏黑屏)、ANR | 阻塞发版 | +| S3 | 次要功能缺陷、特定机型问题 | 可带病发版但需灰度期确认不恶化,记录在发版说明 | +| S4 | 文案/体验 | 排期修复 | + +### 5.2 取证标准(缺一不可) + +1. **构建指纹**:版本号 + git commit + 构建时间。复测前**核对堆栈行号/build 号确认装的是新包**——历史上有"修了没生效,其实装的是旧 APK"的事故。 +2. **录屏**:从进入页面到 bug 发生完整过程。 +3. **日志**:app 内日志文件(`pj_cashier/` 下日志目录,注意带 ANSI 色码需 `sed` 清洗)+ logcat 原始文件。 +4. **DB 副本**:事发后立刻 `adb root && adb pull` 真库(很多订单类 bug 的真相只在库里)。 +5. 复现步骤(含前置数据:商家/会员/商品)、机型/系统/网络环境。 +6. 偶现问题给出复现概率(n 次中出现 m 次)。 + +### 5.3 测试报告(每版必产出) + +- 版本号/commit/包来源; +- 冒烟集 18 条结果表; +- 性能对比表(本版 vs 上版基线,标注 >20% 回归项); +- 缺陷清单(级别/状态/是否阻塞); +- **发版建议**:Go / No-Go / Go with conditions(列条件与观察项); +- 灰度观察计划(挑哪些商家、看什么指标、多长时间)。 + +--- + +## 6. 发布流程:对 100+ 商家负责的机制 + +``` +提测准入(analyze+test 全绿) → 全量回归(§3+§4) → 测试报告(Go 建议) + → 灰度发布(3–5 家) → 观察窗口 24–48h → 达标 → 全量推送 + ↘ 异常 → 停止推送 + 回滚预案 +``` + +### 6.1 灰度商家挑选原则(覆盖风险面,不是随机) + +1. 1 家**关系友好、反馈快**的商家(首发哨兵); +2. 1 家**大商品量**(≥1 万 SPU)超市; +3. 1 家**食堂模式** + 1 家**人脸支付高频**商家; +4. 1 家**上一版就没及时更新**的商家(覆盖跨版本升级路径)。 + +### 6.2 观察窗口必看指标(每天早晚各巡一次) + +- **后台设备在线率与 APK 版本分布**(区分"没更新成功"和"更新了坏了"); +- 终端日志上传是否持续可见(日志断流 = 终端或 MQTT 出事); +- 后台订单量/金额曲线与该商家日常对比(掉量 = 收银受阻); +- 离线订单回传成功率; +- 主动联系哨兵商家 1 次:开机速度、扫码、刷脸、打印有无异常。 + +### 6.3 停发/回滚触发条件(事先约定,不临场辩论) + +- 任一 S1;或灰度商家中出现无法归因的 crash; +- 在线率异常下跌(对照历史同时段); +- 回滚手段:停止推送 + 向灰度设备覆盖安装上一稳定版 APK(签名一致可覆盖;注意降级后 DB 结构兼容性预研结论)。 + +### 6.4 版本节奏约定 + +- 后端先行:涉及同步契约(updateTime/ids/墓碑、MQTT topic 40 spuIds)的版本,**后端先部署,前端后发**; +- 收尾字段:发版说明必须写清"本版改动的外设/通道/DB 版本"三项,供测试和售后对照。 + +--- + +## 7. 历史事故库(本项目专属回归清单) + +> 这些坑都在本仓库真实发生过。改动相关区域时,对应条目自动进入本版回归范围。 + +| # | 事故 | 根因摘要 | 触发回归的条件 | +|---|---|---|---| +| 1 | 人脸页 ANR | getFaceStatus 主线程全表扫描 faceModel | 改 face DAO/原生查询 | +| 2 | 登录页"请输入账号"假提示 | http 守卫对 /login 自我替换 + fenix 重建空实例竞态 | 改 http 守卫/登录跳转 | +| 3 | MQTT 重连 notAuthorized | clientId/地址创建时固化,复用旧客户端带新账密 | 改 MQTT 配置/重连逻辑 | +| 4 | 离线单"回传成功"但后台查无 | 按设备当前 sysType 过滤致查空假成功 | 改离线回传/模式切换 | +| 5 | 离线单假成功(同上族) | 日志只落 warning+,本地看不到 | 排查手法:真库核对 | +| 6 | 超市"全部"标签空白 | 商品挂已删除分类,视图按活跃分类分组(口径不一致) | 改 classify/商品查询 SQL(**必须重跑 build_runner**) | +| 7 | 结算弹框金额不对 | 弹框是冻结快照,识别重算后未推送 | 改弹框重算逻辑 | +| 8 | 弹框会员丢失 | payUser 是运行时态被清空,业务层 payUser 才是事实来源 | 改弹框开关/挂单恢复 | +| 9 | 钱包类型显示错 | 本地 WalletType 枚举缺 TOPUP,未同步后端 code | 后端加钱包类型/改展示 | +| 10 | 副屏停在购物车间歇 | 人脸消息被 isAvailable 门禁静默丢弃(仅定时任务置 true) | 改副屏消息链路 | +| 11 | 人脸质量不上报 | V2 写入 updateTime=null 永不上报 | 改人脸同步/上报 | +| 12 | "修复没生效" | 装的其实是旧 APK(复测未核对 build 号) | **每次复测** | +| 13 | 双库静默跳迁移 | Floor 遇更高版本号跳过迁移,与原生版本错位 | 任何一侧动 DB 版本(两侧必须同改 v14→vN) | +| 14 | 覆盖安装失败 | 签名不一致 + Device Owner 挡卸载 | 换签名/发版渠道 | +| 15 | 商品卡溢出/字号裁切 | GridView 紧约束 + forui sizeScalar 运行时缩放 | 改卡片布局/字号 | +| 16 | 图片页崩溃 | appImg 无界 Row(width 契约被破坏) | 新增带图页面 | +| 17 | 编译失败(Registrar 未解析) | 用了非 fvm 的 Flutter 版本 | 本机/CI 环境变更 | +| 18 | 后台拉日志无反应 | 旧 APK 窗口 或 publish 断连静默 return 恒返成功 | 改 MQTT 发送/定时任务 | + +--- + +## 8. 附录 + +### 8.1 发版 Checklist(打印贴工位) + +- [ ] `flutter analyze` 0 error、`flutter test` 全绿 +- [ ] fvm 3.27.4 构建 release 包,签名与线上一致 +- [ ] P0 冒烟集 18 条全过(真机+全外设) +- [ ] 覆盖安装升级测试过(n-1 和 n-2 存量库) +- [ ] Floor/原生双库版本核对一致 +- [ ] 性能对比无 >20% 回归(或已归因说明) +- [ ] 24h 长跑零 crash/ANR +- [ ] 灰度商家名单已定(含哨兵/大商品量/食堂/人脸高频/滞后更新各 1) +- [ ] 回滚 APK 与预案就绪 +- [ ] 测试报告已发(含 Go/No-Go 结论) +- [ ] 发版说明含外设/通道/DB 版本三项变更声明 + +### 8.2 灰度观察日报模板 + +``` +日期: 版本: 观察人: +灰度商家在线情况:(设备数/版本分布/与昨日对比) +日志上传:正常/断流(断流设备:xxx) +订单与回传:总单数 xx,离线回传失败 x 笔(明细) +crash/ANR:0 起 / xx 起(归因:xxx) +哨兵商家回访:xxx +结论:继续观察 / 全量 / 停发回滚(理由) +``` + +### 8.3 常用命令速查 + +```bash +FLUTTER=~/fvm/versions/3.27.4/bin/flutter +PKG=com.example.pj_supermarket_cloud + +$FLUTTER analyze && $FLUTTER test && $FLUTTER build apk --release +adb install -r build/app/outputs/flutter-apk/app-release.apk + +adb shell dumpsys meminfo $PKG | head -30 # 内存 +adb shell dumpsys gfxinfo $PKG framestats # 帧渲染 +adb logcat -d | grep -E "FATAL|ANR|facediscern" # 崩溃/ANR/原生 +adb shell monkey -p $PKG --throttle 300 100000 # Monkey + +# 真库取证 +adb root && adb pull /storage/emulated/0/Android/data/$PKG/Documents/pj_cashier/database/cash_register.db /tmp/ + +# 弱网注入 +adb shell tc qdisc add dev wlan0 root netem delay 200ms loss 5% +adb shell tc qdisc del dev wlan0 root +``` + +--- + +*本文档随版本演进持续维护:每次事故复盘后更新 §7;每次性能全量测量后更新 §3.2 基线。文档本身过时 = 测试体系过时。* diff --git a/lib/common/api/hard_api.dart b/lib/common/api/hard_api.dart index 1689bfd..599002d 100644 --- a/lib/common/api/hard_api.dart +++ b/lib/common/api/hard_api.dart @@ -231,15 +231,37 @@ abstract class HardApi { required TerminalOrderRecordQueryRequest request, }) async { final res = await HttpManage().dio.post( - '/hard/hard-api/ad/order-record/page', - queryParameters: {'current': current, 'size': size}, - data: request.toJson(), - options: dio.Options(headers: {'DEVICE-ID': '${request.hardId}'}), - ); + '/hard/hard-api/ad/order-record/page', + queryParameters: {'current': current, 'size': size}, + data: request.toJson(), + options: dio.Options(headers: {'DEVICE-ID': '${request.hardId}'}), + ); if (res.data == null) return null; return ServiceTerminalOrderRecordPage.fromJson(res.data); } + /// 超时后按原本地订单号读取已提交结果;新接口失败时不得自动重新发起支付。 + static Future getOrderByAndroidCode({ + required String hardId, + required String androidOrderCode, + }) async { + final res = await HttpManage().dio.get( + '/hard/hard-api/ad/get-order-by-android-code', + queryParameters: { + 'hardId': hardId, + 'androidOrderCode': androidOrderCode, + }, + options: dio.Options( + headers: {'DEVICE-ID': hardId}, + extra: {HttpManage.silentRequestKey: true}, + ), + ); + if (res.data == null) return null; + return ServiceOrderByAndroidCode.fromJson( + Map.from(res.data as Map), + ); + } + /// 创建硬件订单 static Future createHardwareOrder(dynamic formData) async { final res = await HttpManage().dio.post( diff --git a/lib/common/models/api/hard_api_models.dart b/lib/common/models/api/hard_api_models.dart index 41e4b2f..795ec9d 100644 --- a/lib/common/models/api/hard_api_models.dart +++ b/lib/common/models/api/hard_api_models.dart @@ -266,6 +266,53 @@ class ServicePrintRecord { Map toJson() => _$ServicePrintRecordToJson(this); } +/// 下单响应丢失后的最小订单快照;仅用于确认本设备原订单,不触发再次支付。 +class ServiceOrderByAndroidCode { + /// 创建一份对账快照;缺失校验字段时由调用方保守地拒绝恢复为成功。 + const ServiceOrderByAndroidCode({ + required this.orderId, + required this.orderStatus, + required this.payType, + this.userId, + this.totalPrice, + this.extraPrice, + this.payPrice, + }); + + /// 服务端订单主键,数字及字符串响应统一保留为字符串。 + final String orderId; + + /// 服务端订单状态编码。 + final String orderStatus; + + /// 支付方式编码。 + final String payType; + + /// 订单用户;匿名现金单为 -1。 + final String? userId; + + /// 商品总额,单位分。 + final int? totalPrice; + + /// 额外餐费,单位分。 + final int? extraPrice; + + /// 实付金额,单位分;现金等渠道沿用后端记录为零的规则。 + final int? payPrice; + + /// 兼容 Long 和金额的既有 JSON 编码,不依赖本地数据库或生成代码。 + factory ServiceOrderByAndroidCode.fromJson(Map json) => + ServiceOrderByAndroidCode( + orderId: JsonTool.anyToRequiredString(json['orderId']), + orderStatus: JsonTool.anyToRequiredString(json['orderStatus']), + payType: JsonTool.anyToRequiredString(json['payType']), + userId: JsonTool.anyToString(json['userId']), + totalPrice: JsonTool.anyToNullableInt(json['totalPrice']), + extraPrice: JsonTool.anyToNullableInt(json['extraPrice']), + payPrice: JsonTool.anyToNullableInt(json['payPrice']), + ); +} + /// 按终端订单号对账返回的服务端订单和全部证据位置。 @JsonSerializable() class ServiceOrderEvidenceResolve { diff --git a/lib/common/utils/order/terminal_order_recovery.dart b/lib/common/utils/order/terminal_order_recovery.dart new file mode 100644 index 0000000..254f3a2 --- /dev/null +++ b/lib/common/utils/order/terminal_order_recovery.dart @@ -0,0 +1,51 @@ +import 'package:dio/dio.dart'; + +import 'package:pj_supermarket_cloud/common/models/api/hard_api_models.dart'; + +/// 处理下单响应丢失后的只读对账;调用方继续使用原本地订单号,不自动重放扣款请求。 +abstract class TerminalOrderRecovery { + /// 网络超时、断连及网关服务错误可能丢失已成功响应,明确业务拒绝无需轮询。 + static bool shouldQuery(DioException error) { + return error.type == DioExceptionType.connectionTimeout || + error.type == DioExceptionType.sendTimeout || + error.type == DioExceptionType.receiveTimeout || + error.type == DioExceptionType.connectionError || + (error.type == DioExceptionType.badResponse && + (error.response?.statusCode ?? 0) >= 500); + } + + /// 有限次读取原订单,只有状态、支付方式、金额及必要的人员均吻合才恢复成功。 + /// + /// 现金/第三方核销的实付值沿用后端旧约定,金额比对使用商品额与餐费,避免误判。 + /// 查询异常(包括后端尚未部署新接口的 404)只会消耗本次尝试,不会触发再次扣款。 + static Future recover({ + required Future Function() lookup, + required String payType, + required String? userId, + required int totalPrice, + required int extraPrice, + int attempts = 5, + Duration interval = const Duration(seconds: 2), + }) async { + for (var attempt = 0; attempt < attempts; attempt++) { + if (attempt > 0) await Future.delayed(interval); + ServiceOrderByAndroidCode? order; + try { + order = await lookup(); + } catch (_) { + continue; + } + if (order == null) continue; + if (order.orderId.isEmpty || + order.orderStatus != '5' || + order.payType != payType || + order.totalPrice != totalPrice || + order.extraPrice != extraPrice || + (userId != null && order.userId != userId)) { + return null; + } + return order.orderId; + } + return null; + } +} diff --git a/lib/common/widgets/order/mixin/order_mixin.dart b/lib/common/widgets/order/mixin/order_mixin.dart index 6857be6..29d39ec 100644 --- a/lib/common/widgets/order/mixin/order_mixin.dart +++ b/lib/common/widgets/order/mixin/order_mixin.dart @@ -3,11 +3,13 @@ import 'dart:convert'; import 'dart:math'; import 'package:common_utils/common_utils.dart'; +import 'package:dio/dio.dart'; import 'package:flutter_easyloading/flutter_easyloading.dart'; import 'package:flutter_subscreen_plugin/flutter_subscreen_plugin.dart'; import 'package:get/get.dart'; import 'package:pj_supermarket_cloud/common/index.dart'; +import 'package:pj_supermarket_cloud/common/utils/order/terminal_order_recovery.dart'; mixin OrderMixin { static const _tag = "OrderMixin"; @@ -15,6 +17,9 @@ mixin OrderMixin { /// 下单参数日志保留的最大字符数,与 HttpManage 请求日志的截断口径一致。 static const _maxOrderParamLogLength = 2000; + /// 覆盖参数准备、下单、超时对账和本地收尾,避免多种硬件回调同时提交本次订单。 + bool _creatingApiOrder = false; + /// 当前收银弹框成功的人脸证据结果,只有人脸支付下单时才会消费并绑定订单号。 Rxn pendingFaceRecognition = Rxn(); @@ -227,8 +232,8 @@ mixin OrderMixin { return false; } - // 更新本地订单 - Future updateSqlOrder() async { + /// 更新结算快照;只有已由扫码轮询确认支付时才提前写为 PAY,普通下单等待服务端确认。 + Future updateSqlOrder({bool paymentConfirmed = false}) async { if (ObjectUtil.isEmpty(sqlOrderId)) { logger.e("|$_tag|更新本地订单,失败本地订单id不存在"); return false; @@ -253,7 +258,7 @@ mixin OrderMixin { sqlOrderDetail.hourType = hourType.value.code; sqlOrderDetail.type = orderType; sqlOrderDetail.payType = orderPayType.value!; - sqlOrderDetail.status = OrderStatus.PAY; + if (paymentConfirmed) sqlOrderDetail.status = OrderStatus.PAY; await orderDao.edit(sqlOrderDetail); logger.i("|$_tag|更新本地订单,成功orderId:|$sqlOrderId|;"); await createSqlChildCar(sqlOrderDetail); @@ -294,8 +299,19 @@ mixin OrderMixin { return OrderApiPrepare(order: sqlOrderDetail!, setting: appSetting); } - // |接口|创建api订单 + /// 同一次结算只允许一个在途请求,重试始终沿用本地持久化的订单号。 Future createApiOrder() async { + if (_creatingApiOrder) return null; + _creatingApiOrder = true; + try { + return await _createApiOrder(); + } finally { + _creatingApiOrder = false; + } + } + + /// 准备并提交原协议订单;网络响应丢失时先只读对账,确认后统一完成本地状态。 + Future _createApiOrder() async { final prepare = await createApiOrderValidate(); if (prepare == null) return null; @@ -306,6 +322,11 @@ mixin OrderMixin { final orderDao = appC.dataBase.getOrderDao; + // 固定本次请求的支付方式,异步网络处理期间不读取可能被硬件回调更新的选择状态。 + final submittedPayType = orderPayType.value!; + final submittedDisplayType = orderPayDisplayType.value!; + final anonymousPayment = OrderPayDisplayType.isOffline(submittedDisplayType); + // 获取下单商品 List> shopList = []; if (_sysType == SysType.CANTEEN) { @@ -332,7 +353,7 @@ mixin OrderMixin { final isNetwork = AppNetworkTool.isAutoNetwork(); // 人脸支付订单在发起请求前把识别会话绑定到终端订单号;离线单同样绑定, // 联网同步并由服务端确认支付后,后台补传任务按订单号对账上传证据。 - final isFacePayment = orderPayType.value == OrderPayType.FACE; + final isFacePayment = submittedPayType == OrderPayType.FACE; final faceSessionId = pendingFaceRecognition.value?.sessionId?.trim(); var evidenceMetadataList = const >[]; String? evidenceAppVersion; @@ -355,19 +376,18 @@ mixin OrderMixin { "merchantId": serviceMerchantId!, "orderType": orderType.name, "orderSourceTypeId": getOrderSourceTypeId(), - "orderPayType": orderPayType.value?.name, + "orderPayType": submittedPayType.name, "list": shopList, "extraPrice": amountMealPrice.value, if (evidenceMetadataList.isNotEmpty) "evidenceMetadataList": evidenceMetadataList, if (evidenceAppVersion != null) "appVersion": evidenceAppVersion, }; - if (orderPayType.value != null && - appSetting.sysType == SysType.CANTEEN && - (orderPayType.value == OrderPayType.WALLET_QR_CODE)) { + if (appSetting.sysType == SysType.CANTEEN && + (submittedPayType == OrderPayType.WALLET_QR_CODE)) { pa["walletId"] = globalQrCode?.id; } else if (OrderPayDisplayType.isSelectWalletPayType( - orderPayDisplayType.value!)) { + submittedDisplayType)) { if (payUser.value?.walletId != null) { pa["walletId"] = payUser.value?.walletId; } @@ -382,7 +402,7 @@ mixin OrderMixin { T? result; try { if (isNetwork) { - if (orderPayType.value == OrderPayType.WALLET_QR_CODE_SCAN) { + if (submittedPayType == OrderPayType.WALLET_QR_CODE_SCAN) { final data = await HardApi.createHardQrCodeScanOrder(pa); if (data == null) throw Error(); if (ObjectUtil.isEmpty(data)) throw Error(); @@ -391,7 +411,28 @@ mixin OrderMixin { sqlOrderDetail.serviceUniqueCode = data.orderId; result = data as T; } else { - final data = await HardApi.createHardwareOrder(pa); + String? data; + try { + data = await HardApi.createHardwareOrder(pa); + } on DioException catch (error) { + if (!TerminalOrderRecovery.shouldQuery(error)) rethrow; + data = await TerminalOrderRecovery.recover( + lookup: () => HardApi.getOrderByAndroidCode( + hardId: serviceId!, + androidOrderCode: sqlOrderDetail.uniqueCode, + ), + // 旧 Flutter 枚举的消费卡 code 与人民医院重复;线上请求按名称编码, + // 对账使用后端既有消费卡编码 14,不改动本地历史枚举和存储协议。 + payType: submittedPayType == OrderPayType.CSXFK + ? '14' + : submittedPayType.code, + userId: anonymousPayment ? null : pa['userId']?.toString(), + totalPrice: sqlOrderDetail.price, + extraPrice: sqlOrderDetail.extraPrice, + ); + if (data == null) rethrow; + logger.i('|$_tag|超时对账确认订单已支付|orderId:$data|'); + } if (data == null) throw Error(); if (ObjectUtil.isEmpty(data)) throw Error(); logger.i("|$_tag||有网络|创建api订单,下单成功res:|$data|"); @@ -437,7 +478,7 @@ mixin OrderMixin { ); } } - if (orderPayType.value != OrderPayType.WALLET_QR_CODE_SCAN) { + if (submittedPayType != OrderPayType.WALLET_QR_CODE_SCAN) { // 支付结果已落库,副屏通知只能尽力发送,不能等待其回执阻塞主屏订单收尾。 unawaited(sendSubScreenPaySuccess()); if (sqlOrderDetail.payPrice != 0) { diff --git a/lib/pages/canteen_product/controller.dart b/lib/pages/canteen_product/controller.dart index 6f21a5c..1d36435 100644 --- a/lib/pages/canteen_product/controller.dart +++ b/lib/pages/canteen_product/controller.dart @@ -284,11 +284,11 @@ class CanteenProductController extends GetxController with OrderMixin { await updateSqlOrder(); final isNetwork = AppNetworkTool.isAutoNetwork(); final orderResult = await createApiOrder(); - putOffSendSubScreen(); - _clearShopCard(); EasyLoading.dismiss(); // 在线下单失败时保持弹框打开允许重试;离线订单入本地队列视为已受理。 if (isNetwork && orderResult == null) return false; + putOffSendSubScreen(); + _clearShopCard(); return true; }; // |支付方式|点击|取消| diff --git a/lib/pages/supermarket_order/controller.dart b/lib/pages/supermarket_order/controller.dart index c59bf52..62e8492 100644 --- a/lib/pages/supermarket_order/controller.dart +++ b/lib/pages/supermarket_order/controller.dart @@ -421,7 +421,7 @@ class SupermarketOrderController extends GetxController with OrderMixin { // 总金额 final price = AppPriceTool.fenToYuan(totalPrice.value); await createSqlOrder(); - await updateSqlOrder(); + await updateSqlOrder(paymentConfirmed: true); _clearShopCard(); putOffSendSubScreen(); TtsManage.speak("购买成功总金额$price元"); diff --git a/test/terminal_order_recovery_test.dart b/test/terminal_order_recovery_test.dart new file mode 100644 index 0000000..01b5428 --- /dev/null +++ b/test/terminal_order_recovery_test.dart @@ -0,0 +1,123 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pj_supermarket_cloud/common/models/api/hard_api_models.dart'; +import 'package:pj_supermarket_cloud/common/utils/order/terminal_order_recovery.dart'; + +void main() { + ServiceOrderByAndroidCode snapshot({ + String status = '5', + String payType = '4', + String userId = '7', + int total = 350, + int extra = 0, + int paid = 350, + }) => + ServiceOrderByAndroidCode( + orderId: '2109484384436817922', + orderStatus: status, + payType: payType, + userId: userId, + totalPrice: total, + extraPrice: extra, + payPrice: paid, + ); + + Future recover( + Future Function() lookup, { + String payType = '4', + String? userId = '7', + }) => + TerminalOrderRecovery.recover( + lookup: lookup, + payType: payType, + userId: userId, + totalPrice: 350, + extraPrice: 0, + interval: Duration.zero, + attempts: 3, + ); + + test('Long 订单号与数字金额按原有编码解析', () { + final parsed = ServiceOrderByAndroidCode.fromJson({ + 'orderId': '2109484384436817922', + 'orderStatus': '5', + 'payType': '4', + 'userId': 7, + 'totalPrice': '350', + 'extraPrice': 0, + 'payPrice': 350, + }); + expect(parsed.orderId, '2109484384436817922'); + expect(parsed.userId, '7'); + expect(parsed.totalPrice, 350); + }); + + test('请求超时和网关响应丢失可对账,明确业务拒绝及取消不轮询', () { + for (final type in [ + DioExceptionType.connectionTimeout, + DioExceptionType.sendTimeout, + DioExceptionType.receiveTimeout, + DioExceptionType.connectionError, + ]) { + expect( + TerminalOrderRecovery.shouldQuery( + DioException(requestOptions: RequestOptions(), type: type), + ), + isTrue, + ); + } + for (final status in [200, 400, 404, 502]) { + final options = RequestOptions(); + expect( + TerminalOrderRecovery.shouldQuery( + DioException( + requestOptions: options, + type: DioExceptionType.badResponse, + response: Response(requestOptions: options, statusCode: status), + ), + ), + status == 502, + ); + } + }); + + test('等待原订单提交后找回成功结果,查询次数有界', () async { + var calls = 0; + final result = await recover(() async => ++calls < 3 ? null : snapshot()); + expect(result, '2109484384436817922'); + expect(calls, 3); + }); + + test('后端未部署对账接口时查询失败保持未确认,不产生支付重放', () async { + var calls = 0; + final result = await recover(() async { + calls++; + throw StateError('404'); + }); + expect(result, isNull); + expect(calls, 3); + }); + + test('退款、人员、支付方式或金额不一致均不能恢复成功', () async { + for (final invalid in [ + snapshot(status: '10'), + snapshot(userId: '8'), + snapshot(payType: '3'), + snapshot(total: 351), + snapshot(extra: 1), + ]) { + expect(await recover(() async => invalid), isNull); + } + }); + + test('现金订单沿用实付零约定,使用商品额核对', () async { + expect( + await recover( + () async => snapshot(payType: '8', userId: '-1', paid: 0), + payType: '8', + userId: null, + ), + '2109484384436817922', + ); + }); +}