“我的营养”付款码扫码识别实施计划
For agentic workers: REQUIRED SUB-SKILL: Use
subagent-driven-development(recommended) orexecuting-plansto execute this plan task by task.
接口契约基线:
paycode-user-info-api.htmlv1.2(2026-09-17)。
Goal: 在不破坏现有人脸识别和餐单打印流程的前提下,为首页“我的营养”增加 21.5 寸串口扫码与 27 寸 HID 键盘扫码能力,通过付款码接口获得人员身份后进入现有营养日报、周报页面。
Architecture: 使用统一的 NutritionIdentity 隔离人脸 SDK 与营养页面;扫码层由统一控制器、串口实现、HID 实现和设备模式解析器组成;付款码接口复用配对后保存的设备编号,将 paycode 与 equipment_code 一并提交,并对请求、响应日志做专项脱敏。人脸与扫码并行运行,通过一次性会话状态保证“首次成功者进入页面,后续结果失效”。
Tech Stack: Android Java 8、AndroidX Fragment、Retrofit 2.1、OkHttp 3.9、Gson、JUnit 4、MMKV、com.kongqw.serialportlibrary.SerialPortManager。
1. 实施边界与当前结论
1.1 本计划覆盖
- 读取现有配对流程保存的
Constants.SETTING_TERMINAL_CODE,作为接口请求体的equipment_code。 POST /api/consume/paycodeUserInfo的请求、响应、错误映射和日志脱敏。- 21.5 寸设备
/dev/ttyS4、9600串口扫码。 - 27 寸 HID 键盘扫码及非身份识别场景的数据屏蔽。
- 人脸与扫码统一身份模型、并发竞争控制和营养页面参数传递。
- 自动化测试、21.5/27 寸真机验收、回归验证及变更记录。
1.2 不在本计划内
- 不修改付款码生成、支付、核销、余额和订单逻辑。
- 不改变营养日报、周报接口和营养计算口径。
- 不把扫码扩展到餐单打印或其他首页入口。
- 不新增
api_auth、Android Keystore 或security-crypto依赖;MQTT 密钥和communicationkey均不参与付款码接口。 - 未经用户明确授权,不执行 Git 暂存、提交、推送、分支切换或合并。
1.3 已确认技术事实
| 事项 | 结论 | 实施影响 |
|---|---|---|
旧 ScanCodeUtils |
底层 BarCodeScanner.openBarCodeScanner(String) 固定使用 115200 |
禁止用于本次 9600 串口扫码 |
| 可配置串口 | 项目已有 SerialPortManager.openSerialPort(path, baudRate) |
21.5 寸复用该能力 |
| 当前营养入口 | MainActivity.nutritionCheck() 打开 FaceRecognitionPop(type=1) |
在现有弹框内并行增加扫码,不新增入口层级 |
| 当前身份传递 | FACE_RECOGNITION_RESULT Sticky Event 承载百度 User |
营养链路改为显式 Intent 参数;餐单打印继续兼容 Sticky Event |
| 当前网络日志 | NewLogInterceptor 记录请求头、请求体、响应体 |
接口上线前必须对付款码请求和人员响应完成专项脱敏 |
| 当前配对信息 | GetPairInfoResponse.terminalNumber 在配对成功后保存到 Constants.SETTING_TERMINAL_CODE |
直接作为 equipment_code,无需新增鉴权字段 |
2. 上线门禁
Gate 0:先冻结外部契约和硬件身份
目的: 禁止通过屏幕尺寸、输入速度或猜测型号选择扫码方式。
检查项:
- 冻结接口文档 v1.2 请求契约,确认不传
account/pwd/key,请求体固定包含:
{
"paycode": "C68ca123456789",
"equipment_code": "EQ-001"
}
- 目标环境已部署 MR #903 对应版本并可调用
POST /api/consume/paycodeUserInfo;成功响应满足code == 0且data.staff_uuid、data.staff_name非空,同时验证“缺少 equipment_code”和“设备不存在”失败分支。 - 在 21.5 寸样机记录
Build.MANUFACTURER、Build.MODEL、Android 版本,验证/dev/ttyS4可按9600打开并取得完整扫码帧。 - 27 寸代码参考
CusumptionMachine/QuotaNoKeyboardActivity与ScanManager:设备类型 5 的机型标识为RK3568,输入模式放行,非输入模式消费外接键盘事件,Enter 结束组帧;真机到位后记录Build.MANUFACTURER、Build.MODEL和实际按键序列。 - 将设备信息以脱敏形式补入本计划第 13 节 Task 10 验收记录;不得写入真实付款码、人员信息或生产设备编号。
门禁结果(2026-09-17): 用户确认 MR #903 已合并并部署;21.5 寸样机已识别为 rockchip / YF_029E / Android 11,/dev/ttyS4 可按 9600 打开。用户随后指定 27 寸参考项目设备类型 5 的实现方式,客户端 HID 切片据此放行;RK3568 以外的未知设备仍解析为 DISABLED。27 寸真机无法连接,发布验收继续阻塞。
3. 目标文件清单
3.1 新增生产代码
app/src/main/java/com/zhct/multipurpose/nutrition/identity/NutritionIdentity.java
app/src/main/java/com/zhct/multipurpose/nutrition/identity/IdentityResolutionSession.java
app/src/main/java/com/zhct/multipurpose/scanner/PaycodeScannerController.java
app/src/main/java/com/zhct/multipurpose/scanner/ScanFrameAssembler.java
app/src/main/java/com/zhct/multipurpose/scanner/SerialPaycodeScannerController.java
app/src/main/java/com/zhct/multipurpose/scanner/KeyboardScanFrameAssembler.java
app/src/main/java/com/zhct/multipurpose/scanner/KeyboardInputPolicy.java
app/src/main/java/com/zhct/multipurpose/scanner/KeyboardWedgeScannerHub.java
app/src/main/java/com/zhct/multipurpose/scanner/KeyboardWedgeScannerController.java
app/src/main/java/com/zhct/multipurpose/scanner/ScannerMode.java
app/src/main/java/com/zhct/multipurpose/scanner/ScannerModeResolver.java
app/src/main/java/com/zhct/multipurpose/network/request/PaycodeUserInfoRequest.java
app/src/main/java/com/zhct/multipurpose/network/response/PaycodeUserInfoResponse.java
app/src/main/java/com/zhct/multipurpose/network/PaycodeUserInfoRepository.java
app/src/main/java/com/zhct/multipurpose/network/interceptor/SensitiveNetworkLogPolicy.java
3.2 修改生产代码和资源
app/src/main/java/com/zhct/multipurpose/application/BaseActivity.java
app/src/main/java/com/zhct/multipurpose/widget/FaceRecognitionPop.java
app/src/main/java/com/zhct/multipurpose/nutrition/NutritionReportActivity.java
app/src/main/java/com/zhct/multipurpose/network/CPTNetworkRequest.java
app/src/main/java/com/zhct/multipurpose/network/NetworkService.java
app/src/main/java/com/zhct/multipurpose/network/CPTService.java
app/src/main/java/com/zhct/multipurpose/network/interceptor/NewLogInterceptor.java
app/src/main/res/layout/face_recognition_pop_layout.xml
app/src/main/res/values/strings.xml
3.3 新增测试
app/src/test/java/com/zhct/multipurpose/nutrition/identity/IdentityResolutionSessionTest.java
app/src/test/java/com/zhct/multipurpose/scanner/ScanFrameAssemblerTest.java
app/src/test/java/com/zhct/multipurpose/scanner/KeyboardScanFrameAssemblerTest.java
app/src/test/java/com/zhct/multipurpose/scanner/ScannerModeResolverTest.java
app/src/test/java/com/zhct/multipurpose/scanner/KeyboardInputPolicyTest.java
app/src/test/java/com/zhct/multipurpose/network/PaycodeUserInfoContractTest.java
app/src/test/java/com/zhct/multipurpose/network/interceptor/SensitiveNetworkLogPolicyTest.java
4. Task 1:建立统一身份对象和一次性会话状态
Files:
- Create:
app/src/main/java/com/zhct/multipurpose/nutrition/identity/NutritionIdentity.java - Create:
app/src/main/java/com/zhct/multipurpose/nutrition/identity/IdentityResolutionSession.java - Test:
app/src/test/java/com/zhct/multipurpose/nutrition/identity/IdentityResolutionSessionTest.java
Step 1:先写失败测试
测试必须覆盖:空 UUID/姓名拒绝、首次成功可提交、第二次成功被忽略、查询失败后可恢复监听、关闭后所有回调失效。
@Test
public void firstSuccessWinsAndLaterSuccessIsIgnored() {
IdentityResolutionSession session = new IdentityResolutionSession();
assertTrue(session.beginQuery());
assertTrue(session.resolve(NutritionIdentity.paycode("staff-1", "张三")));
assertFalse(session.resolve(NutritionIdentity.face("staff-2", "李四")));
assertEquals(IdentityResolutionSession.State.RESOLVED, session.getState());
}
@Test
public void queryFailureReturnsToListening() {
IdentityResolutionSession session = new IdentityResolutionSession();
assertTrue(session.beginQuery());
session.queryFailed();
assertEquals(IdentityResolutionSession.State.LISTENING, session.getState());
}
运行:
JAVA_HOME=$(/usr/libexec/java_home -v 1.8) ./gradlew :app:testDebugUnitTest --tests '*IdentityResolutionSessionTest'
预期:测试因类不存在而失败。
Step 2:实现最小模型和状态机
public final class NutritionIdentity implements Serializable {
public enum Source { FACE, PAYCODE }
private final String staffUuid;
private final String staffName;
private final Source source;
private NutritionIdentity(String staffUuid, String staffName, Source source) {
if (isBlank(staffUuid) || isBlank(staffName) || source == null) {
throw new IllegalArgumentException("人员身份字段不完整");
}
this.staffUuid = staffUuid.trim();
this.staffName = staffName.trim();
this.source = source;
}
public static NutritionIdentity face(String uuid, String name) {
return new NutritionIdentity(uuid, name, Source.FACE);
}
public static NutritionIdentity paycode(String uuid, String name) {
return new NutritionIdentity(uuid, name, Source.PAYCODE);
}
public String getStaffUuid() { return staffUuid; }
public String getStaffName() { return staffName; }
public Source getSource() { return source; }
private static boolean isBlank(String value) {
return value == null || value.trim().isEmpty();
}
}
IdentityResolutionSession 使用 LISTENING、QUERYING、RESOLVED、CLOSED 四态;所有状态变更集中在该类,不在 Dialog 中散落多个布尔值。
Step 3:跑测试并检查差异
JAVA_HOME=$(/usr/libexec/java_home -v 1.8) ./gradlew :app:testDebugUnitTest --tests '*IdentityResolutionSessionTest'
git diff --check
验收:测试通过;无敏感信息;不暂存、不提交。
5. Task 2:把营养页身份传递从 Sticky Event 改为显式参数
Files:
- Modify:
app/src/main/java/com/zhct/multipurpose/nutrition/NutritionReportActivity.java - Modify:
app/src/main/java/com/zhct/multipurpose/widget/FaceRecognitionPop.java - Regression:
app/src/main/java/com/zhct/multipurpose/menuprint/MenuPrintActivity.java
Step 1:为营养页增加唯一入口
private static final String EXTRA_NUTRITION_IDENTITY =
"com.zhct.multipurpose.extra.NUTRITION_IDENTITY";
public static Intent createIntent(Context context, NutritionIdentity identity) {
Intent intent = new Intent(context, NutritionReportActivity.class);
intent.putExtra(EXTRA_NUTRITION_IDENTITY, identity);
return intent;
}
在 NutritionReportActivity.onCreate() 中先校验 identity,再调用 initViews() 和首次营养请求;人员 UUID 统一改为 identity.getStaffUuid(),姓名统一改为 identity.getStaffName()。参数缺失时提示并 finish(),不得先发空 UUID 请求。
Step 2:仅改营养分支,保留餐单打印兼容
FaceRecognitionPop 人脸成功后按 mSkipType 分流:
if (mSkipType == 1) {
NutritionIdentity identity = NutritionIdentity.face(user.getUserId(), user.getUserName());
if (identitySession.resolve(identity)) {
startActivity(NutritionReportActivity.createIntent(requireContext(), identity));
dismissAllowingStateLoss();
}
} else if (mSkipType == 2) {
EventBus.getDefault().postSticky(
MessageEvent.create(EventName.FACE_RECOGNITION_RESULT, user));
startActivity(new Intent(requireContext(), MenuPrintActivity.class));
dismissAllowingStateLoss();
}
删除 NutritionReportActivity 对 FACE_RECOGNITION_RESULT 的订阅,但不删除事件常量,也不改变 MenuPrintActivity 的订阅逻辑。
Step 3:验证回归
JAVA_HOME=$(/usr/libexec/java_home -v 1.8) ./gradlew :app:testDebugUnitTest
JAVA_HOME=$(/usr/libexec/java_home -v 1.8) ./gradlew :app:assembleDebug
git diff --check
人工检查:人脸进入营养页姓名和日报正常;人脸进入餐单打印仍正常;营养页不再依赖 Sticky Event。
6. Task 3:复用并校验已配对设备编号
Files:
- Create:
app/src/main/java/com/zhct/multipurpose/network/paycode/EquipmentCodeProvider.java - Create:
app/src/main/java/com/zhct/multipurpose/network/paycode/MmkvEquipmentCodeProvider.java - Reference only:
app/src/main/java/com/zhct/multipurpose/setting/SetBasicFragment.java - Reference only:
app/src/main/java/com/zhct/multipurpose/utils/Constants.java
Step 1:确认现有数据来源,不改配对契约
现有链路已经满足接口 v1.2:
/p/api/matecode JSON 字段 code
→ GetPairInfoResponse.terminalNumber
→ MmkvUtils.put(Constants.SETTING_TERMINAL_CODE, terminalNumber)
不得新增 api_auth 字段、凭据存储或安全加密依赖,也不得使用 MQTT ClientId、设备型号或 Android 序列号代替服务端设备编号。
Step 2:建立可测试的设备编号提供者
public interface EquipmentCodeProvider {
String getEquipmentCode();
}
public final class MmkvEquipmentCodeProvider implements EquipmentCodeProvider {
@Override
public String getEquipmentCode() {
String value = MmkvUtils.getInstance().get(Constants.SETTING_TERMINAL_CODE, "");
return value == null ? "" : value.trim();
}
}
Repository 通过构造参数接收 EquipmentCodeProvider,单元测试使用内存 Fake;生产代码使用 MmkvEquipmentCodeProvider。这样既复用现有配对结果,又不让 Repository 直接依赖 MMKV 全局单例。
Step 3:验证现有清理边界
代码审查必须确认:
- 配对成功会更新
SETTING_TERMINAL_CODE。 - 切换 API 地址、配对失败和重新配对前的
clearPreferenceData()会清空SETTING_TERMINAL_CODE。 - Repository 每次请求前读取当前值,不长期缓存旧环境设备编号。
- 本地编号为空时直接返回
DEVICE_CODE_MISSING,不发网络请求。
运行:
JAVA_HOME=$(/usr/libexec/java_home -v 1.8) ./gradlew :app:testDebugUnitTest --tests '*PaycodeUserInfoRepositoryTest'
git diff --check
7. Task 4:接入付款码人员接口并先封住日志泄露
Files:
- Create:
app/src/main/java/com/zhct/multipurpose/network/request/PaycodeUserInfoRequest.java - Create:
app/src/main/java/com/zhct/multipurpose/network/response/PaycodeUserInfoResponse.java - Modify:
app/src/main/java/com/zhct/multipurpose/network/CPTNetworkRequest.java - Modify:
app/src/main/java/com/zhct/multipurpose/network/NetworkService.java - Modify:
app/src/main/java/com/zhct/multipurpose/network/CPTService.java - Create:
app/src/main/java/com/zhct/multipurpose/network/paycode/PaycodeUserInfoRepository.java - Create:
app/src/main/java/com/zhct/multipurpose/network/interceptor/SensitiveNetworkLogPolicy.java - Modify:
app/src/main/java/com/zhct/multipurpose/network/interceptor/NewLogInterceptor.java - Test:
app/src/test/java/com/zhct/multipurpose/network/paycode/PaycodeUserInfoRepositoryTest.java - Test:
app/src/test/java/com/zhct/multipurpose/network/interceptor/SensitiveNetworkLogPolicyTest.java
Step 1:先写日志脱敏测试
@Test
public void paycodeEndpointNeverLogsRequestOrPersonPayload() {
SensitiveNetworkLogPolicy policy = new SensitiveNetworkLogPolicy();
String output = policy.sanitize(
"/api/consume/paycodeUserInfo",
"Content-Type: application/json",
"{\"paycode\":\"raw-paycode-value\",\"equipment_code\":\"EQ-001\"}",
"{\"staff_uuid\":\"staff-1\",\"staff_name\":\"张三\"}");
assertFalse(output.contains("raw-paycode-value"));
assertFalse(output.contains("EQ-001"));
assertFalse(output.contains("staff-1"));
assertFalse(output.contains("张三"));
}
策略要求:仅对精确路径 /api/consume/paycodeUserInfo 使用固定摘要,例如 requestBody=[PAYCODE_REQUEST_REDACTED]、responseBody=[PERSON_REDACTED];普通 Content-Type 请求头可以保留,其他接口维持现有日志行为。
Step 2:定义接口契约
public final class PaycodeUserInfoRequest {
@SerializedName("paycode")
private final String paycode;
@SerializedName("equipment_code")
private final String equipmentCode;
public PaycodeUserInfoRequest(String paycode, String equipmentCode) {
this.paycode = paycode;
this.equipmentCode = equipmentCode;
}
}
public final class PaycodeUserInfoResponse {
@SerializedName("staff_uuid") private String staffUuid;
@SerializedName("staff_name") private String staffName;
public String getStaffUuid() { return staffUuid; }
public String getStaffName() { return staffName; }
@Override public String toString() {
return "PaycodeUserInfoResponse{personPresent="
+ (staffUuid != null && staffName != null) + "}";
}
}
Retrofit 方法只提交 JSON 请求体,不传 account、pwd、key:
@POST("/api/consume/paycodeUserInfo")
Call<NetworkResult<PaycodeUserInfoResponse>> getPaycodeUserInfo(
@Body PaycodeUserInfoRequest request);
NetworkService 和 CPTService 增加同名方法,继续复用 ApiResultUtils.transferCallToServiceCall();不得为旧契约保留三项鉴权 Header 的兼容分支。
Step 3:实现 Repository 校验与错误分类
PaycodeUserInfoRepository 职责:
- 对扫码原文
trim(),空值不请求。 - 通过
EquipmentCodeProvider读取并规范化已配对设备编号;为空时返回DEVICE_CODE_MISSING,不请求。 - 使用
new PaycodeUserInfoRequest(paycode, equipmentCode)组装请求体。 - 请求进行中拒绝重复提交。
- 成功数据转换为
NutritionIdentity.paycode(uuid, name)。 - 网络失败、付款码业务失败、设备不存在、协议字段缺失分别返回稳定错误类型;UI 文案由资源文件映射。
- Repository 不保存、打印或向异常消息拼接付款码原文、设备编号和人员结果。
Step 4:运行定向测试
JAVA_HOME=$(/usr/libexec/java_home -v 1.8) ./gradlew :app:testDebugUnitTest --tests '*SensitiveNetworkLogPolicyTest' --tests '*PaycodeUserInfoRepositoryTest'
git diff --check
验收:所有失败分支可恢复;请求体字段与 v1.2 一致;测试输出和本地日志不出现完整付款码、生产设备编号、人员 UUID 或姓名。
8. Task 5:建立扫码统一契约与分帧器
Files:
- Create:
app/src/main/java/com/zhct/multipurpose/scanner/PaycodeScannerController.java - Create:
app/src/main/java/com/zhct/multipurpose/scanner/ScanFrameAssembler.java - Test:
app/src/test/java/com/zhct/multipurpose/scanner/ScanFrameAssemblerTest.java
Step 1:定义生命周期契约
public interface PaycodeScannerController {
void start(Listener listener);
void stop();
}
Step 2:先测试分帧行为
覆盖以下场景:单块 PAYCODE\r\n、多块拼接、连续两帧、空帧、超长帧清空、停止后残留清空、HID Enter 结束。
@Test
public void fragmentedBytesAreJoinedUntilLineEnding() {
ScanFrameAssembler assembler = new ScanFrameAssembler(1024);
assertTrue(assembler.append("PAY".getBytes(UTF_8)).isEmpty());
assertEquals(Collections.singletonList("PAYCODE"),
assembler.append("CODE\r\n".getBytes(UTF_8)));
}
Step 3:实现共享分帧器
- 最大字符数使用命名常量
MAX_SCAN_LENGTH = 1024,最终值需用真实付款码上限校验。 \r、\n均可结束一帧,连续分隔符不产生空结果。- 收到超长数据时清空缓存并返回诊断错误,不截断后提交。
- 串口字节流与 HID 按键流分别使用适合自身边界的组帧器,但都遵守空值不提交、超长帧丢弃和完整帧一次回调。
Step 4:运行测试
JAVA_HOME=$(/usr/libexec/java_home -v 1.8) ./gradlew :app:testDebugUnitTest --tests '*ScanFrameAssemblerTest'
9. Task 6:实现 21.5 寸串口扫码控制器
Files:
- Create:
app/src/main/java/com/zhct/multipurpose/scanner/SerialPaycodeScannerController.java - Reference only:
app/src/main/java/com/zhct/multipurpose/setting/SerialPortTestActivity.java
Step 1:集中协议常量并注明来源
public final class SerialPaycodeScannerController implements PaycodeScannerController {
/** 21.5 寸样机硬件协议指定的扫码串口。 */
static final String SCANNER_PORT = "/dev/ttyS4";
/** 21.5 寸样机硬件协议指定的波特率。 */
static final int SCANNER_BAUD_RATE = 9600;
private static final int MAX_SCAN_LENGTH = 1024;
}
这些值属于外部硬件协议,允许作为有注释的集中常量存在,不得散落在 Dialog、Activity 或测试页。
Step 2:实现打开、接收和释放
serialPortManager.setOnOpenSerialPortListener(openListener);
serialPortManager.setOnSerialPortDataListener(dataListener);
boolean opened = serialPortManager.openSerialPort(
SCANNER_PORT, SCANNER_BAUD_RATE);
onDataReceived(byte[])只送入ScanFrameAssembler,在主线程回调完整帧。stop()必须移除监听、关闭串口、清空分帧缓存并置为非运行态;重复调用安全。- 打开失败仅回调设备不可用,不能关闭人脸识别。
- 不调用
ScanCodeUtils或BarCodeScanner。 - 不把原始字节、完整文本写入
LogUtils或LocalFileRecorder。
Step 3:真机验证
在 21.5 寸样机执行:打开弹框连续扫码 20 次、拆包场景、快速重复扫码、关闭弹框后扫码、重新打开弹框扫码。要求 20 次均只产生一次完整回调,离开弹框后不请求接口,串口可再次打开。
10. Task 7:建立设备档案与管理员覆盖配置
Files:
- Create:
app/src/main/java/com/zhct/multipurpose/scanner/ScannerMode.java - Create:
app/src/main/java/com/zhct/multipurpose/scanner/ScannerModeResolver.java - Test:
app/src/test/java/com/zhct/multipurpose/scanner/ScannerModeResolverTest.java
Step 1:定义模式
public enum ScannerMode {
SERIAL,
KEYBOARD_WEDGE,
DISABLED
}
Step 2:解析优先级
- 精确匹配
rockchip / YF_029E时返回SERIAL。 - 机型字符串包含参考设备类型 5 标识
RK3568时返回KEYBOARD_WEDGE。 - 未知型号返回
DISABLED,保留人脸识别并显示诊断提示。 - 禁止用
DisplayMetrics、屏幕分辨率、屏幕对角尺寸或输入速度推断设备类型。
测试覆盖两个设备档案、未知型号关闭、大小写和首尾空格规范化。
Step 3:未知设备失败关闭
当前版本不增加管理员手工覆盖项,避免错误配置导致串口冲突或全局键盘误拦截。新机型必须补充设备档案和测试后才能启用。
11. Task 8:实现 27 寸 HID 输入门和全局屏蔽
Files:
- Create:
app/src/main/java/com/zhct/multipurpose/scanner/KeyboardScanFrameAssembler.java - Create:
app/src/main/java/com/zhct/multipurpose/scanner/KeyboardInputPolicy.java - Create:
app/src/main/java/com/zhct/multipurpose/scanner/KeyboardWedgeScannerHub.java - Create:
app/src/main/java/com/zhct/multipurpose/scanner/KeyboardWedgeScannerController.java - Modify:
app/src/main/java/com/zhct/multipurpose/scanner/ScannerModeResolver.java - Modify:
app/src/main/java/com/zhct/multipurpose/application/BaseActivity.java - Modify:
app/src/main/java/com/zhct/multipurpose/widget/FaceRecognitionPop.java - Test:
app/src/test/java/com/zhct/multipurpose/scanner/KeyboardScanFrameAssemblerTest.java - Test:
app/src/test/java/com/zhct/multipurpose/scanner/KeyboardInputPolicyTest.java
Step 1:按参考设备类型和输入场景建立输入门
ScannerModeResolver 在机型包含 RK3568 时返回 KEYBOARD_WEDGE。KeyboardInputPolicy 仅在该模式、无文本输入焦点、事件来源为外接键盘且不是音量键时返回消费;输入控件聚焦时交回系统分发。
Step 2:在 BaseActivity 最前面拦截
@Override
public boolean dispatchKeyEvent(KeyEvent event) {
if (KeyboardInputPolicy.shouldConsume(
mode, inputFocused, hardwareKeyboard, volumeKey)) {
KeyboardWedgeScannerHub.getInstance().handleKeyEvent(event);
return true;
}
return super.dispatchKeyEvent(event);
}
BaseActivity.dispatchKeyEvent() 覆盖普通 Activity;FaceRecognitionPop 因具有独立 Window,还需在 Dialog 的 OnKeyListener 中执行同一策略。KeyboardWedgeScannerHub 只在营养身份弹框激活监听时组帧,弹框外仅消费而不产生业务动作。
组帧规则:500ms 超时复位,Enter/NUMPAD_ENTER 完成一帧,支持数字、字母和常见符号,最大 1024 字符;超长帧整帧丢弃。ACTION_UP 被消费但不进入缓存,音量键放行。
Step 3:测试输入隔离
@Test
public void keyboardDeviceIsConsumedOutsideInputScene() {
assertTrue(KeyboardInputPolicy.shouldConsume(
ScannerMode.KEYBOARD_WEDGE, false, true, false));
}
@Test
public void inputSceneAndVolumeKeysRemainAvailable() {
assertFalse(KeyboardInputPolicy.shouldConsume(
ScannerMode.KEYBOARD_WEDGE, true, true, false));
assertFalse(KeyboardInputPolicy.shouldConsume(
ScannerMode.KEYBOARD_WEDGE, false, true, true));
}
真机验收必须包含:设置页密码框或其他输入控件聚焦时键盘输入正常;首页、营养页等非输入场景扫码不触发功能按钮;身份弹框内扫码可形成完整数据;音量键仍可用。
12. Task 9:把扫码链路接入人脸弹框
Files:
- Modify:
app/src/main/java/com/zhct/multipurpose/widget/FaceRecognitionPop.java - Modify:
app/src/main/res/layout/face_recognition_pop_layout.xml - Modify:
app/src/main/res/values/strings.xml
Step 1:只在营养类型启用扫码
mSkipType == 1时,根据ScannerModeResolver创建一个扫码控制器。mSkipType == 2时不创建扫码控制器,餐单打印行为保持原样。onResume()开始扫码;onPause()、onDismiss()、onDestroyView()均执行幂等stop()。IdentityResolutionSession与视图生命周期绑定,关闭后所有晚到网络回调只丢弃,不再跳转或弹 Toast。
Step 2:接入查询状态
private void onScanFrame(String paycode) {
if (!identitySession.beginQuery()) {
return;
}
renderIdentityStatus(R.string.paycode_identifying);
paycodeRepository.resolve(paycode, new PaycodeUserInfoRepository.Callback() {
@Override public void onSuccess(NutritionIdentity identity) {
if (!isAdded() || !identitySession.resolve(identity)) {
return;
}
stopAllIdentityProviders();
startActivity(NutritionReportActivity.createIntent(requireContext(), identity));
dismissAllowingStateLoss();
}
@Override public void onFailure(PaycodeFailure failure) {
if (!isAdded()) {
return;
}
identitySession.queryFailed();
renderIdentityError(failure);
}
});
}
同一时刻若人脸先成功,扫码回调失效;若扫码先成功,人脸回调失效。查询失败恢复 LISTENING,用户可继续扫码或刷脸。
Step 3:补充状态 UI
在现有人脸弹框增加一个不遮挡摄像头的状态区,文案至少包含:
- “请刷脸,或打开个人付款码扫码”
- “正在识别付款码…”
- “扫码设备不可用,可继续刷脸”
- “设备编号未配置,请联系管理员重新配对”
- “当前设备未登记,请联系管理员检查配对”
- “付款码识别失败,请重新扫码”
错误文案不得展示付款码、设备编号、人员 UUID、姓名或服务端堆栈。
13. Task 10:全量验证、双设备验收与文档闭环
Step 1:自动化验证
JAVA_HOME=$(/usr/libexec/java_home -v 1.8) ./gradlew :app:testDebugUnitTest
JAVA_HOME=$(/usr/libexec/java_home -v 1.8) ./gradlew :app:assembleDebug
git diff --check
连接目标样机后执行:
adb devices -l
JAVA_HOME=$(/usr/libexec/java_home -v 1.8) ./gradlew :app:connectedDebugAndroidTest
Step 2:接口安全核查
rg -n 'paycode|equipment_code|staff_uuid|staff_name' app/src/main/java/com/zhct/multipurpose
逐项确认:请求模型只有 paycode 和 equipment_code;不存在 account/pwd/key 兼容分支;日志不输出完整请求或人员结果;异常信息不拼接扫码原文和设备编号。
Step 3:21.5 寸验收
- 连续扫码 20 次:每次恰好一次查询、一次跳转。
- 扫码与人脸同时成功:仅首次成功路径跳转。
- 弹框外扫码:不请求接口、不跳转、不影响其他控件。
- 退出再进入:串口可重新打开,无占用错误。
- 拔掉或占用串口:提示扫码不可用,人脸仍可用。
Step 4:27 寸验收
- 身份弹框内扫码 20 次:字符顺序、结束符、中文页面焦点均不影响结果。
- 首页、营养页等非输入场景扫码:字符不进入 UI,不触发按钮。
- 设置密码框、搜索框等输入控件聚焦时,扫码枪与人工物理键盘均按普通键盘输入放行。
- 扫码枪拔插后恢复可用;真机型号或事件行为与
RK3568参考方案不一致时阻止发布 HID 扫码。
Step 5:业务回归
- 人脸进入“我的营养”正常。
- 人脸进入餐单打印正常。
- 扫码接口业务失败、网络失败、设备编号为空或设备不存在均留在识别弹框;修正配置后允许重试。
- 日报、周报切换及日期选择保持原行为。
- 切换 API 地址会清空旧设备编号,重新配对后请求使用新设备编号。
Step 6:文档与变更记录
实施完成后更新:
/Users/liang/AndroidStudioProjects/zhct/zhctprompt/work_android/MultipurposeMachine/requirements/2026-09-17-paycode-nutrition-identification-requirement-solution.md
/Users/liang/AndroidStudioProjects/zhct/zhctprompt/work_android/MultipurposeMachine/requirements/2026-09-17-paycode-nutrition-identification-requirement-solution.html
/Users/liang/AndroidStudioProjects/zhct/zhctprompt/work_android/MultipurposeMachine/implementation_plans/2026-09-17-paycode-nutrition-identification-implementation-plan.md
/Users/liang/AndroidStudioProjects/zhct/zhctprompt/work_android/MultipurposeMachine/implementation_plans/2026-09-17-paycode-nutrition-identification-implementation-plan.html
/Users/liang/AndroidStudioProjects/zhct/zhctprompt/work_android/MultipurposeMachine/change_records/CHANGELOG.md
记录实际设备档案、接口环境、测试命令、构建结果、未通过项和回退方式;严禁写入真实付款码、生产设备编号和人员信息。
14. 验收完成定义
只有同时满足以下条件,任务才可标记完成:
- 配对成功后保存的
SETTING_TERMINAL_CODE能作为equipment_code使用,切换 API 和清理配对后无旧值残留。 - 付款码接口请求体只包含正确的
paycode、equipment_code,不发送account/pwd/key。 - 网络日志和业务日志均无完整付款码、生产设备编号、人员 UUID、姓名明文组合。
- 21.5 寸串口严格使用
/dev/ttyS4、9600,20 次扫码无丢帧、粘帧和重复请求。 - 27 寸
RK3568机型中,输入控件聚焦时键盘输入正常,非输入场景外接键盘数据被消费,身份弹框能完成付款码组帧。 - 人脸与扫码同时成功时只跳转一次。
- 扫码和人脸均能将正确人员 UUID 交给现有营养日报、周报接口。
- 餐单打印人脸链路、设置页输入和其他首页功能回归通过。
- 单元测试、仪器测试、Debug 构建和
git diff --check通过。 - Markdown、HTML 和变更记录已同步,未执行未经授权的 Git 提交。
15. 回退策略
- 功能级回退:高级设置将扫码模式设为
DISABLED,立即保留原人脸路径。 - 设备级回退:未知机型或 27 寸真机行为与参考不一致时解析为
DISABLED,不使用速度猜测。 - 接口级回退:设备编号缺失或接口不可用时,不发起无设备编号请求,提示重新配对并允许继续刷脸。
- 版本级回退:保留上一个已验证 APK;数据库和营养数据结构未变,不需要数据迁移回滚。
- 安全回退:发现日志泄密时先关闭扫码入口并清理日志,再修复脱敏策略,不允许带风险继续发布。
16. 实施检查点
每个 Task 完成后执行一次自检:
git status --short
git diff --check
只审查本任务目标文件,不覆盖工作区内已有的无关修改。所有 Git 暂存、提交和推送动作都必须由用户另行明确授权。
17. 2026-09-17 首批执行记录
17.1 已完成实现
- 新增
NutritionIdentity与IdentityResolutionSession,人脸和付款码首个成功结果生效,重复扫码请求保持 single-flight。 - 营养报告页改为显式
Intent身份参数;人脸进入餐单打印继续沿用原 Sticky Event,不扩大改动范围。 - 新增付款码请求/响应模型、Repository 和 Retrofit 调用,请求体仅包含
paycode、equipment_code。 equipment_code每次从Constants.SETTING_TERMINAL_CODE读取;空值时不发请求。- 新增 21.5 寸串口控制器,协议常量集中为
/dev/ttyS4、9600;支持 CR、LF、CRLF 和 120ms 突发结束兜底,弹框离开时关闭串口。 - 对
rockchip / YF_029E启用串口;对参考设备类型 5 的RK3568机型启用键盘扫码;其他未知机型关闭扫码并保留人脸。 - 新增 27 寸键盘组帧器、输入策略、事件 Hub 和生命周期控制器;500ms 超时复位、Enter 结束、1024 字符上限。输入控件聚焦与音量键放行,非输入场景消费;扫码弹窗独立 Window 也接入同一策略。
NewLogInterceptor对该接口请求体和响应体整段脱敏,响应对象toString()不输出 UUID 或姓名。
17.2 已完成验证
- TDD 红灯:生产类不存在时,目标测试出现 23 个“找不到符号”编译错误。
- 27 寸切片 TDD 红灯:新增键盘组帧与输入策略测试后出现 12 个预期的“找不到符号”编译错误,随后补齐生产实现。
:app:testDebugUnitTest共 45 项测试零失败,:app:assembleDebug构建通过,git diff --check通过;新增覆盖RK3568模式选择、键盘字符/Enter 组帧、500ms 超时、超长帧丢弃及输入场景放行策略。- APK 已覆盖安装到
YF_029E并保留配对配置;MMKV 文件中确认存在setting_terminal_code键。 - 21.5 寸回归:应用完成启动并进入
MainActivity;点击“我的营养”后,弹框同时显示摄像头和“扫码器已连接,请刷付款码或进行人脸识别”;/dev/ttyS4权限为可读写,应用进程存活且无FATAL EXCEPTION。
17.3 待完成验收
- 现场刷有效付款码,验证 MR #903 成功响应、人员姓名、营养日报/周报跳转与 UUID 一致性。
- 完成 21.5 寸连续 20 次、快速重复、关闭重开、串口异常以及与人脸竞态测试。
- 连接 27 寸设备后确认机型包含
RK3568,并验证 HID 字符、Enter 结束、输入场景放行、非输入场景屏蔽和扫码枪拔插恢复。 - 人脸营养入口、餐单打印及其他首页功能完成最终人工回归。