Review状态:PENDING_REVIEW。用户要求先审本方案,再真实运行。当前不构建/拉取镜像、不运行Java/Gradle、不修改业务代码、不安装APK。技术READY不等于用户已批准。
For agentic workers:用户review通过后,使用已安装的subagent-driven-development或executing-plans按任务推进;项目执行模式由task-execution-mode-router决定。共享HomeActivity只有一个writer。精确接口与门禁以contracts.md为准。
Goal: 修复可证实的SDK/应用错误,让二维码短失读不误作物理离盘;再在已确认的传感/业务契约下完成挪盘连续性、换盘归属及夹重计量。
Architecture: 保留二维码扫码和现有PC681 SDK入口,先在应用层保留并分类可见信息;SDK内部丢帧由厂家修包优先解决。逐步建立数值会话模型,新内核先旁观,满足物理与业务门禁后才接管结算。
Tech Stack: Docker内JDK8 + Gradle5.1.1/AGP3.4.1/Android API30;Docker内JDK17用于SDK/应用隔离审计;Java8源码、现有JUnit4.12、SQLite/MMKV/WorkManager。Mac不安装或调用任务JDK,不复用Mac版Android工具到Linux容器。
1. 哪些运行在哪个环境
| 对象 | 环境 | 用户能看到的结果 | 不能代表什么 |
|---|---|---|---|
| Android源码构建、JUnit单测 | zhct-qr-jdk8:20260909,Linux/amd64;容器内Linux AndroidSDK/API30/build-tools30.0.0 |
构建日志、JUnit XML/HTML、Debug APK及SHA | 不代表真实扫码或称重已验收 |
| SDK字节码/旧11项parser特征 | zhct-qr-jdk17:20260909,Linux/amd64;旧harness使用Java17 HexFormat |
每条输入、输出、断言及结果报告 | 不执行native串口、不证明硬件采样或JNI关闭正常 |
| 应用11项+A02/A03十项隔离审计 | JDK17容器内Python+Java,读取冻结源码 | 21项真实类/抽取方法结果与hash | 不等于完整Activity/数据库/生命周期测试 |
| 最终APK | 410 Android真机的Android Runtime,项目minSdk26、ARM ABI | 二维码/挪盘/夹子动作视频、界面、串口与业务记录 | APK不能当Java服务直接在JDK容器里运行;普通x86模拟器不能代替本机硬件 |
Docker Desktop后台已可连接;本轮仅做客户端/daemon/manifest/源码静态核验。指定项目镜像尚未构建,Java/Gradle与系统尚未运行。旧版“在Mac安装JDK”的步骤被本合同覆盖。
2. 范围和实施阶段
| 阶段 | 本阶段结果 | READY边界 |
|---|---|---|
| E00 环境与基线 | 两个Docker环境、原系统构建/特征报告 | 用户review后先执行;依赖网络失败归环境问题 |
| A01–A04 明确缺陷 | 异常不计空、迟到响应不串人、A02观察正确、勺重能保存 | 不依赖未知红外/夹子接口,可局部修复并验证 |
| A05–A06 会话准备 | 数值来源统一、UI恢复保留基线、新内核旁观 | 旁观无订单/查询/告警副作用;不改最终物理判据 |
| B01 SDK | 冻结厂家修复要求与正确断言、新包独立验收 | 资料准备可做;没有候选包/入口不声称修复通过 |
| B02 SDK替代 | 仅厂家方案无法满足时选唯一串口owner | 端口、协议、native关闭未验证前不启用真实接管 |
| C01–C03 接管与真机 | 已确认离盘/归属/工具策略,事务收尾与现场验收 | 条件逐项关闭才执行;不把它们作为A阶段全停理由 |
当前选择:先A,SDK走B01;B02不默认开发。 不创建无用的双后端,不修改服务端,不替换二维码介质,不整体重构UI。
3. E00:Docker环境、隔离基线和原系统验证
先读: START_HERE.md、contracts.md、Docker说明、源仓库根/app build.gradle、wrapper properties、AndroidManifest.xml、项目协作指南。
- [ ] 第1步:检查
review-decision.json。状态PENDING_REVIEW时停止执行,展示本方案;只能在用户实际确认后写入批准原话/范围与时间。 - [ ] 第2步:运行只读预检,保存JSON。源码路径、origin、基线SHA/AAR不匹配先审差异,禁止清理/覆盖。
QR_PACK="/Users/jack/code/010-cpt/008-zhct/zhctprompt/work_android/ZhctWeightingTableYoukate/audits/2026-09-09-scan-interruption/execution"
QR_SOURCE="/Users/jack/code/010-cpt/008-zhct/zhctproject/android/ZhctWeightingTableYoukate"
python3 "$QR_PACK/scripts/preflight.py" --repo "$QR_SOURCE"
- [ ] 第3步:创建或复用独立任务worktree。默认建议路径
/Users/jack/code/010-cpt/008-zhct/zhctworktrees/ZhctWeightingTableYoukate-qr-scan-20260909,任务分支codex/qr-scan-continuity-20260909。先读worktree列表;已有隔离工作区不再套第二层。没有原生worktree工具时才使用以下Git fallback,目录/分支已存在则核验复用、不强制重建:
git -C "$QR_SOURCE" worktree list
git -C "$QR_SOURCE" worktree add -b codex/qr-scan-continuity-20260909 \
/Users/jack/code/010-cpt/008-zhct/zhctworktrees/ZhctWeightingTableYoukate-qr-scan-20260909 \
2f759f9415b46a7166215fa670d5b4fafeef615e
- [ ] 第4步:用
docker/run.sh prepare --image-set all构建两个镜像。固定官方JDK镜像和Android命令行工具版本/校验;SDK下载/安装发生在Linux容器内。基础镜像最终digest、平台、工具版本写回证据,不以浮动tag当长期复现标识。 - [ ] 第5步:
docker/run.sh versions --image-set all核对容器内java/javac。Gradle镜像必须Java8;审计镜像必须Java17。不得用Mac java_home或host JAVA_HOME兜底。 - [ ] 第6步:运行原SDK和原应用特征,保留输入/错误行为的旧基线:
bash "$QR_PACK/docker/run.sh" sdkaudit --repo "$QR_SOURCE" --evidence-dir "$QR_PACK/results/baseline-sdk"
bash "$QR_PACK/docker/run.sh" appaudit --repo "$QR_SOURCE" --evidence-dir "$QR_PACK/results/baseline-app"
bash "$QR_PACK/docker/run.sh" a02audit --repo "$QR_SOURCE" --evidence-dir "$QR_PACK/results/baseline-a02"
- [ ] 第7步:用Gradle镜像对冻结源做首次真实构建与测试。runner在容器临时副本构建,排除宿主local.properties/.gradle/build;不往当前master写构建产物。
bash "$QR_PACK/docker/run.sh" gradle --repo "$QR_SOURCE" \
--evidence-dir "$QR_PACK/results/baseline-gradle" -- \
:app:testDebugUnitTest :app:assembleDebug
- [ ] 第8步:读取JUnit XML的tests/failures/errors/skipped与APK SHA,保存全部失败日志;现有23测试类/78处@Test只是静态数,运行结果以XML为准。依赖失败单列BUILD_ENV,不升级AGP/删RUM掩盖;旧缺陷特征PASS不计为产品正确。
E00完成条件: 两镜像版本可回读;原SDK/应用特征可重现;Gradle实际成功或有完整环境失败清单。环境失败只限制相应运行,不阻止计划/纯文本完善,但不能在基线不明时声称候选回归通过。
4. A01:SDK观察保真与二维码质量分类
修改: hardware/YoukateHardwareManager.java的scaleDataCallback/cleanScanCode/HardwareListener;activity/HomeActivity.java硬件监听、普通/A02/A03路由;antiescape/A02TrayClaimCodePolicy.java新增候选失效方法。同步所有HardwareListener实现。
新增: hardware/ScaleObservation.java、SdkScaleObservationAdapter.java;测试hardware/SdkScaleObservationAdapterTest.java、ScanObservationRoutingTest.java。接口严格遵守contracts §3。
- [ ] 写QR-01–07失败测试:
len7+空、5字符、内嵌控制、真正SDK零长度空、6字符、SDK trim兼容、onError。集成测试验证码异常时重量仍更新。 - [ ] 执行候选测试观察失败,保存日志;不修改旧特征测试。
- [ ] 在回调入口立即复制SDK公开字段,单调时钟只记录callbackAt。
rfidLen>0 && 内容空可明确分型;不伪造raw/frameLen/源时间。 - [ ] Home覆盖类型化回调。INVALID/SOURCE_ERROR清空候选证据但保留会话/基线,不计离盘、不触发报警补单;零长度SDK报空仍走旧N规则,记录物理未知。
// Home类型化路由的关键顺序;完整字段与方法在contracts中定义。
handleWeightObservation(observation); // 检查weightAvailable并屏蔽异常的在位推断
switch (observation.codeKind) {
case VALID_QR:
case SDK_REPORTED_NO_CODE:
handleYoukateScanCode(observation.normalizedCode);
break;
case INVALID_QR:
case SOURCE_ERROR:
scanEmptyFrameCount = 0;
mFirstEmptyScanElapsedMs = 0L;
mA02TrayClaimCodePolicy.invalidateEmptyCandidate();
if (mNoTrayPickDetector != null) mNoTrayPickDetector.resetBaseline();
break;
}
新增入口按以下逻辑接线,不把异常空串喂给原三参函数:
private void handleWeightObservation(ScaleObservation o) {
if (!o.weightAvailable) return;
boolean allowPresenceInference =
o.codeKind == ScaleObservation.CodeKind.VALID_QR
|| o.codeKind == ScaleObservation.CodeKind.SDK_REPORTED_NO_CODE;
handleWeightData(o.weightState, o.weightGrams, o.normalizedCode, allowPresenceInference);
}
四参handleWeightData保留旧重量显示、序号、DataProcessor和后续重量事件的次序,仅把A02/A03/NoTrayPickDetector在位相关分支包在if(allowPresenceInference)内;否则跳过它们并对非null的mNoTrayPickDetector调用resetBaseline,防止跨异常累积无盘证据。Home必须走上述入口;三参兼容入口只给旧诊断。增加“无当前盘,1000→非法码900→合法空900”测试:异常时重量显示可变,不能触发A02或在下一空码将该100g补算为无盘取菜。SOURCE_ERROR工厂不生成可信0重量。
- [ ] 测试A/空/空/INVALID/空在N3时不结束;N3/7/10均覆盖;管理码仍交现有UnlockCodePolicy;不把异常恢复误当扫码新身份。
- [ ] 完整编译Home、WeightingActivity、DebugWeigherPop,旧兼容listener不得把异常空值回灌Home。
完成条件: QR测试通过,旧有效码/SDK报空语义保留,来源未知如实表示;A01只防可识别异常误离盘,不声称已解决真实漏扫或SDK内部丢帧。
5. A02:查人请求代次,防止晚包覆盖新会话
修改: HomeActivity.getStaffInfo(895起),handleScanResult、scanCodeResult结束分支、onDestroy。新增: session/StaffQueryGuard.java及session/StaffQueryGuardTest.java,Ticket定义见contracts §4。
- [ ] 写ASYNC-01–03:A1/B2乱序、同码两请求乱序、A结束又A新会话、菜品变化;记录期望人员/推荐EventBus/告警副作用计数。
- [ ] 写guard纯Java测试失败后实现owner+epoch+generation+code+dish联合匹配。条码相同不等于同一次请求。
- [ ] 请求开始捕获Ticket,在UI线程回包第一行验证,之后才允许任何人员赋值、声音、推荐和报警:
final StaffQueryGuard.RequestTicket ticket = staffQueryGuard.begin(epoch, trayCode, dishUuid);
// 在现有主线程回包入口内,所有业务副作用之前:
if (!staffQueryGuard.isCurrent(ticket, activeEpoch, activeTrayCode, activeDishUuid)) {
return; // 仅允许另行记录脱敏丢弃原因
}
- [ ] 结束/明确新会话/owner销毁使旧Ticket失效;同实例短暂UI遮挡不自动当结束。A02既有requestToken保留。
- [ ] 使用受控网络响应测试全部副作用,而不只断言字符串比较函数。当前请求空response正常失败,不NPE。
完成条件: 旧包不能改变新会话任何人员/推荐/报警状态;后台API入参和A02请求控制不变。
6. A03:A02观察状态先更新,查询去重后判断
修改: A02TrayClaimCodePolicy、HomeActivity.handleA02ScanDuringAlarm/claimA02AlarmByTray;扩展原A02TrayClaimCodePolicyTest与路由测试。
- [ ] 写A02-01–03失败测试,首条为N3、查询进行中、空/空/A/空:不清盘,查询仍一条。
- [ ] policy新增两个小方法,不改lastQueriedTrayCode:
public void onValidTrayObserved() { emptyFrameCount = 0; }
public void invalidateEmptyCandidate() { emptyFrameCount = 0; }
- [ ] 保留A03原盘未离开与WAIT_MANAGER_CARD前置分流;只在WAIT_TRAY_CLAIM、普通有效二维码时,在inflight判断前调用
onValidTrayObserved()并置mA02TrayClaimCodePresent=true。 - [ ]
shouldQuery仍管理“已实际查询的码”;A请求中看到B不要写lastQueried=B,否则可能压掉后续B查询。管理解除码不能当普通餐盘。 - [ ] 重跑原3个A02测试,增加N3/7/10、配置同值/变值、B排队、A03不越过旧动作路由。
完成条件: 网络inflight不再决定传感器空计数是否清零;没有并发重复查人或多余补单。A03原盘身份规则由C01独立处理。
7. A04:勺重真实保存,计价暂不补偿
修改: SetActivity的setting_item_spoon_weight(660–678);读取MmkvUtils、SingleInputDialog。验证: TOOL-01 +实际设置页回读。
- [ ] 写复现:旧20→设35→重新打开仍旧值;保留失败证据。
- [ ] 将676行错误的
get改为put,解析一次整数;空/溢出提示且保留旧值,不关闭输入。保留非负整数/g单位/默认20,不发明最大业务夹重。
final int spoonWeight;
try {
spoonWeight = spoonDialog.getCurrentInputValue();
} catch (NumberFormatException e) {
// 沿用该设置页的无效输入提示方式;不保存、不dismiss。
return;
}
MmkvUtils.getInstance().put(Constants.SPOON_WEIGHT, spoonWeight);
- [ ] 保存后真实MMKV回读、关页重开及重启验证35;取消、空值、溢出均不污染旧值。当前取菜100g不自动减35g,证明补偿未夹带进入。
完成条件: 持久化和界面一致;不把保存修复报告成夹子计量已解决。
8. A05:数值等价迁移与无观测缺口的UI恢复
修改: Home的switchToOngoingFragment、scanCodeResult、onResume/onStop;两种DarkPickOngoing*Fragment.showCurrentWeight/weightChangeWithSerial。新增: session/PickingSnapshot.java、PickingWeightPolicy.java及测试。
- [ ] WEIGHT-01建立旧算法序列黄金值:起跳过滤、IGNORE_TIMES=3、负差显示0、>5g保存门槛,以及1/5/6g边界。两个样式分别覆盖。
- [ ] 用纯数值snapshot保存epoch/tray/dish/startWeight/lastWeight/displayWeight/config。先保持旧算法等价,不夹带新的稳定滤波或夹重补偿。
- [ ] UI与旧收尾都读取同一数值,不从TextView parseInt作为权威值;UI延迟一帧不能改变已确定计量。
- [ ] 仅同Activity实例且SDK观察持续、没有onStop解绑/观测缺口时恢复既有视图,不重新调用开餐/写起始重量;无会话才进Before。
- [ ] 真正onStop→设置→返回的恢复/收尾策略留到C01。A05只记录缺口,不擅自恢复旧基线参与该路径结算,也不自选冻结/丢弃重量规则;保持旧行为并标未解决。进程杀死/重建依赖C03。
- [ ] UI-01a验证无onStop/无缺口的短暂遮挡恢复:A基线1000→900后历史100g不丢、不新建记录。UI-01b冻结反例A1000→900→onStop期间变800→返回A→空N:不能声称A05已解决,旁观应报缺口;LIVE归属由C01策略决定。保持A02/A03和目标线UI差异。
完成条件: 等价回放一致、两样式数值一致、无缺口UI恢复不重置会话。真实后台缺口恢复、新稳定阈值与未知缺口归属未接管。
9. A06:新会话内核旁观与有界诊断
新增: session/QrSessionReducer.java、SessionSnapshot.java、SessionEvent.java、SessionEffect.java、SessionPolicy.java、Transition.java、diagnostics/ScanTraceBuffer.java;纯JUnit对应测试。修改: Home将同一不可变观察送入旁观adapter。
- [ ] 先用test-matrix中的SESSION-01–04构造事件序列;方法/状态遵守contracts §4,不依赖Android、MMKV、网络或数据库。
- [ ] 实现IDLE/ACTIVE/GAP_PENDING/CONFLICT/FINALIZE_PENDING/CLOSED;有效报空不直接变物理ABSENT,A→B→A保留冲突,质量未知不伪造来源时间。
- [ ] effects旁观只记录差异。给FakeEffectSink验证查人/告警/insert/upload均为0次;不能因同时运行旧/新逻辑产生双单。
- [ ] 环形诊断256条、每条摘要512字节上限;不落完整二维码/人员数据。注入100000条,条数/字节上限不增长;记录丢弃计数。
- [ ] SessionPolicy未批准或缺时间值只能OBSERVE;LIVE启动校验失败,不能把null当0。UiDetached/Attached仅改展示/质量,不结束会话。
完成条件: 纯内核和真实Home双路无业务副作用验证通过;新引擎仍未接管订单。
10. B01:SDK厂家修包优先,不在回调后假装修parser
准备资产: SDK修复合同、SDK-01–07字节输入/正确输出、旧11项输入来源与AAR SHA。外发需当次明确授权。
- [ ] V1合包循环排空;V2按长度找尾而非首个0x3C;V3非法长度与合法空分流;V4短响应不吞后续帧;V5可测试raw/诊断入口;V6安全close/reopen;V7采样/回报/ACK/新鲜度说明。
- [ ] 厂家提供新文件名/版本/校验、变更表、ABI和设备范围。未交付真实注入入口时标WAIT_VENDOR_ENTRY,不以“版本更新”验收。
- [ ] 候选用公开入口或授权串口回放台跑正确断言;旧反射harness仅冻结取证,不要求生产反射或候选私有方法名不变。
- [ ] Gradle只引用一份目标AAR;旧制品外部保留。不能同名改写class塞回原包,也不能源码定义同包同类遮蔽SDK。
- [ ] 新包通过纯解析后再在批准设备验证线程、重量符号/单位、灯/置零/频率、初始化/暂停/关闭/重连,最后才进真实取餐。
现有callback没有raw/frameLen/checksum/readTime;App无法排SDK私有缓存,也不能恢复已丢帧。rfidLen>0+空的A01防护可先做,但不是SDK内部完全修复。
11. B02:唯一串口owner备用路线
只有B01无法满足且方案选择已确认后才开展;当前不默认创建这些类。拟新增Pc681FrameParser、ScaleTransport、JocatSerialTransport、ScaleCommandCodec,都在hardware目录。
- [ ] 先冻结真实端口/波特率/响应帧与close/reopen协议,再选择低层
new SerialPort(File,int,int)和getInput/getOutput。构造会触发JNI,不允许在Mac/JVM冒充设备验证。 - [ ] fake transport验证单读线程、串行写、重复start/close、晚回调丢弃;pure parser feed返回全部完整事件、只留半帧,按长度定位checksum/尾。
- [ ] 不使用SerialCommImpl作raw输入,它按CRLF拆删字节;不复用SDK正在读的输入流,不第二次打开同端口,不反射私有cache/thread。
- [ ] 选新后端时PC681不能初始化/监听,零点/频率/灯/红外命令全部走同一个owner;首版禁止运行中热切换。
- [ ] 在指定410验证安全关闭解除阻塞、FD/线程归零、重开100次。未证明native安全关闭,真实接管保持关闭。
完成条件: 只有协议、所有命令与生命周期都通过才可替换旧SDK;raw接管仍不能知道固件内部码值年龄,设备源序号缺失继续标UNKNOWN。
12. C01–C03:完整接管、夹重与真实演示
这些步骤保留精确门禁,不让Codex凭空选900/1200ms或猜夹子状态。C03本地存储/事务/迁移测试可在A06后以合成结束快照准备,不需要发布权限,也不自动启用LIVE;正式接入与发布分别过门禁。
- [ ] C01:批准SessionPolicy,包含独立presence或明确接受的单二维码启发式、允许活动区、gap/exit时限、换盘/管理卡/身份缺口归属。无独立在位时必须明确是业务推定,不能声称物理拿走已知。
- [ ] C02:选择夹子机械隔离或可靠状态观测;按TOOL-02/03四组合验证。UNKNOWN→UNRESOLVED,不按每单固定减20g,也不从重量差猜工具状态。
- [ ] C03:新增本地session_snapshot/session_finalization与现有pick_record同事务保存;相同finalizationId仅一条,失败不丢pending。先检查目标DB最新版本再分配增量迁移版本,不清库、不覆写其它迁移。
- [ ] 保留NORMAL/A02/A03来源与旧上传字段,保留现有code0/code1终止策略;客户端去重不冒充服务器幂等。
- [ ] 在同一410真机上录制二维码静放/平移/转动/边缘/短抬/遮挡/遮挡换盘/邻台/夹子四状态/断流/切页/报警,逐条对齐原始帧、App观察、会话和最终重量。
- [ ] 用户查看:基线与修复对比HTML、JUnit/SDK回放明细、APK SHA、动作视频与每条记录。所有结论标清Docker纯逻辑、Android运行、真实硬件三层。
C阶段完成条件: G-POLICY始终必需;选择独立在位判据才需G-PRESENCE,批准的二维码启发式不伪造presence。自动补偿需G-TOOL;机械隔离并校准可不开发补偿模块,但仍须证明计量合格。本地持久化测试不依赖发布授权;最终现场发布另需G-RELEASE。相关时延/误差/动作矩阵通过,回退不在活动会话中混用两套收尾,才可说指定设备/工况的问题已关闭。
13. 提交、验证与交接
- 每切片执行“失败测试→最小修改→候选测试→受影响回归→diff review”;具体候选类名/输入/预期见test-matrix.csv。
- Gradle在Docker runner中执行
--tests 'com.cpt.zhct.weighting.hardware.*' --tests 'com.cpt.zhct.weighting.antiescape.*' --tests 'com.cpt.zhct.weighting.session.*'作为相关回归;再全量:app:testDebugUnitTest :app:assembleDebug。不存在的测试类不得当作通过,先创建本切片测试。 - 当前master保持只读,候选源码修改只在选定worktree。只有本轮用户已授予的提交/推送权限才执行;控制项目自动同步不扩大业务仓库权限。
- 不运行客户网络API、不部署生产、不把构造帧/JVM验证当真机结果。A阶段未改API/DB时标不适用;C03涉及DB必须专项迁移与失败恢复验收。
- 本包交付状态是“方案及工具资产可review”。镜像构建、基线/候选运行、APK和真机演示在用户review通过后按阶段实施。