feat:优化下单速度

feat:处理读卡密码
This commit is contained in:
dzc committed 2026-10-14 13:51:12 +08:00
1 parent ba7e31dbd8
commit aa4eb7b86e
13 files changed
+189 -559

No files matched your search

+1 -1
View File
@@ -7,7 +7,7 @@ flutter pub run build_runner build --delete-conflicting-outputs~
## flutter打包
```
flutter build apk --build-name 1.5.3 --build-number 153
flutter build apk --build-name 1.5.4 --build-number 154
--build-name为版本号(展示)
--build-number为版本号(内部比较)
```
@@ -397,6 +397,9 @@ class ServiceHardware extends ServiceBase {
/// 人脸activeKey
final String? faceActiveKey;
/// 商户饭卡密码 B(mch_manage.card_key,6 字节 hex);由 /hard-api/ad 终端接口下发。
final String? cardKey;
ServiceHardware({
this.id,
this.name,
@@ -417,6 +420,7 @@ class ServiceHardware extends ServiceBase {
this.faceAppId,
this.faceSdkKey,
this.faceActiveKey,
this.cardKey,
});
factory ServiceHardware.fromJson(Map<String, dynamic> json) =>
@@ -217,6 +217,7 @@ ServiceHardware _$ServiceHardwareFromJson(Map<String, dynamic> json) =>
faceAppId: json['faceAppId'] as String?,
faceSdkKey: json['faceSdkKey'] as String?,
faceActiveKey: json['faceActiveKey'] as String?,
cardKey: json['cardKey'] as String?,
)
..msg = json['msg'] as String?
..code = (json['code'] as num?)?.toInt()
@@ -246,6 +247,7 @@ Map<String, dynamic> _$ServiceHardwareToJson(ServiceHardware instance) =>
'faceAppId': instance.faceAppId,
'faceSdkKey': instance.faceSdkKey,
'faceActiveKey': instance.faceActiveKey,
'cardKey': instance.cardKey,
};
ServiceCar _$ServiceCarFromJson(Map<String, dynamic> json) => ServiceCar(
+11 -49
View File
@@ -2,7 +2,8 @@ import 'package:json_annotation/json_annotation.dart';
part 'serial_card_models.g.dart';
const String _serialCardDefaultKeyHex = 'FFFFFFFFFFFF';
/// 服务端 mch_manage.card_key 的建库默认值;终端尚未同步到商户密码时按此兜底认证。
const String _serialCardDefaultKeyHex = '424324548800';
/// DK25-ST 可以识别的卡片类型。
enum SerialCardType {
@@ -24,20 +25,7 @@ enum SerialCardType {
}
}
/// 业务卡号读取策略,旧饭卡默认保留 M1 数据块优先的行为。
@JsonEnum(valueField: 'code')
enum SerialCardReadMode {
smart('smart', '智能识别'),
uidOnly('uid_only', '仅读取 UID'),
mifareBlockOnly('mifare_block_only', '仅 M1 业务块');
const SerialCardReadMode(this.code, this.title);
final String code;
final String title;
}
/// M1 卡扇区认证使用的密钥类型。
/// M1 卡扇区认证使用的密钥类型;新规范固定使用 KEY B,枚举仅供协议层组包。
@JsonEnum(valueField: 'code')
enum SerialMifareKeyType {
keyA('key_a', 'KEY A', 0x0A),
@@ -51,34 +39,23 @@ enum SerialMifareKeyType {
}
/// 读写卡配置保存在已有 setting.sysJson 扩展键中,不改变数据库结构。
///
/// 新规范(与后台开卡管理一致):业务卡号固定存扇区 1 的第二个数据块(绝对块 5),
/// 认证固定密码 B;密钥来自服务端 mch_manage.card_key,由设备信息同步链路写入
/// [SerialCardConfig.keyBHex] 缓存,设置页不再手工编辑。读不出业务块时回退返回 UID。
@JsonSerializable()
class SerialCardConfig {
static const String defaultKeyHex = _serialCardDefaultKeyHex;
/// 饭卡业务卡号所在的 M1 绝对块地址:扇区 1 的扇区内块 1(与 Web 端 area*4+1 对齐)。
static const int mifareBusinessBlockAddress = 5;
const SerialCardConfig({
this.readMode = SerialCardReadMode.smart,
this.mifareBlockAddress = 1,
this.mifareKeyType = SerialMifareKeyType.keyA,
this.keyAHex = _serialCardDefaultKeyHex,
this.keyBHex = _serialCardDefaultKeyHex,
this.showMifareScanButton = false,
});
@JsonKey(unknownEnumValue: SerialCardReadMode.smart)
final SerialCardReadMode readMode;
@JsonKey(fromJson: _serialCardBlockAddressFromJson)
final int mifareBlockAddress;
@JsonKey(unknownEnumValue: SerialMifareKeyType.keyA)
final SerialMifareKeyType mifareKeyType;
@JsonKey(
fromJson: _serialCardKeyFromJson,
toJson: _serialCardKeyToJson,
)
final String keyAHex;
/// 当前商户的饭卡密码 B(6 字节 hex);同步失败或旧配置非法时回退服务端默认值。
@JsonKey(
fromJson: _serialCardKeyFromJson,
toJson: _serialCardKeyToJson,
@@ -88,22 +65,11 @@ class SerialCardConfig {
/// M1 全块扫描属于现场诊断能力,默认隐藏,避免普通业务读卡误触耗时扫描。
final bool showMifareScanButton;
String get selectedKeyHex =>
mifareKeyType == SerialMifareKeyType.keyA ? keyAHex : keyBHex;
SerialCardConfig copyWith({
SerialCardReadMode? readMode,
int? mifareBlockAddress,
SerialMifareKeyType? mifareKeyType,
String? keyAHex,
String? keyBHex,
bool? showMifareScanButton,
}) {
return SerialCardConfig(
readMode: readMode ?? this.readMode,
mifareBlockAddress: mifareBlockAddress ?? this.mifareBlockAddress,
mifareKeyType: mifareKeyType ?? this.mifareKeyType,
keyAHex: keyAHex ?? this.keyAHex,
keyBHex: keyBHex ?? this.keyBHex,
showMifareScanButton: showMifareScanButton ?? this.showMifareScanButton,
);
@@ -181,10 +147,6 @@ class SerialMifareScanProgress {
: (processedBlocks / totalBlocks).clamp(0, 1).toDouble();
}
int _serialCardBlockAddressFromJson(Object? value) {
return value is int && value >= 0 && value <= 255 ? value : 1;
}
String _serialCardKeyFromJson(Object? value) {
final key = value?.toString().replaceAll(RegExp(r'\s+'), '').toUpperCase();
return key != null && RegExp(r'^[0-9A-F]{12}$').hasMatch(key)
@@ -8,20 +8,6 @@ part of 'serial_card_models.dart';
SerialCardConfig _$SerialCardConfigFromJson(Map<String, dynamic> json) =>
SerialCardConfig(
readMode: $enumDecodeNullable(
_$SerialCardReadModeEnumMap, json['readMode'],
unknownValue: SerialCardReadMode.smart) ??
SerialCardReadMode.smart,
mifareBlockAddress: json['mifareBlockAddress'] == null
? 1
: _serialCardBlockAddressFromJson(json['mifareBlockAddress']),
mifareKeyType: $enumDecodeNullable(
_$SerialMifareKeyTypeEnumMap, json['mifareKeyType'],
unknownValue: SerialMifareKeyType.keyA) ??
SerialMifareKeyType.keyA,
keyAHex: json['keyAHex'] == null
? _serialCardDefaultKeyHex
: _serialCardKeyFromJson(json['keyAHex']),
keyBHex: json['keyBHex'] == null
? _serialCardDefaultKeyHex
: _serialCardKeyFromJson(json['keyBHex']),
@@ -30,21 +16,6 @@ SerialCardConfig _$SerialCardConfigFromJson(Map<String, dynamic> json) =>
Map<String, dynamic> _$SerialCardConfigToJson(SerialCardConfig instance) =>
<String, dynamic>{
'readMode': _$SerialCardReadModeEnumMap[instance.readMode]!,
'mifareBlockAddress': instance.mifareBlockAddress,
'mifareKeyType': _$SerialMifareKeyTypeEnumMap[instance.mifareKeyType]!,
'keyAHex': _serialCardKeyToJson(instance.keyAHex),
'keyBHex': _serialCardKeyToJson(instance.keyBHex),
'showMifareScanButton': instance.showMifareScanButton,
};
const _$SerialCardReadModeEnumMap = {
SerialCardReadMode.smart: 'smart',
SerialCardReadMode.uidOnly: 'uid_only',
SerialCardReadMode.mifareBlockOnly: 'mifare_block_only',
};
const _$SerialMifareKeyTypeEnumMap = {
SerialMifareKeyType.keyA: 'key_a',
SerialMifareKeyType.keyB: 'key_b',
};
@@ -945,6 +945,20 @@ abstract class AppSettingTool {
}
sysSetting.syncTime = DateTime.now();
await _setSetting(sysJson: sysSetting);
// 商户饭卡密码 B 单独保存在读写卡配置里;服务端未返回或值非法时不覆盖本机缓存,
// 保证离线场景仍能用上一次同步成功的密钥。
final cardKey = data.cardKey?.trim();
if (cardKey != null &&
RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(cardKey)) {
final cardConfig = await getSerialCardConfig();
final normalizedKey = cardKey.toUpperCase();
if (cardConfig.keyBHex != normalizedKey) {
await setSerialCardConfig(
cardConfig.copyWith(keyBHex: normalizedKey),
);
logger.i('同步商户饭卡密码B成功');
}
}
}
/// 将当前业务终端的打印机绑定和打印参数持久化到本机;空响应表示尚未绑定。
+42 -117
View File
@@ -18,7 +18,8 @@ enum _SerialCardStep {
/// DK25-ST 串口读写管理。
///
/// 串口帧可能被拆包或粘包,因此先按协议长度字段解帧,再根据卡类型执行 UID 或 M1 块操作。
/// M1 密钥仅用于扇区访问认证,不适用于 Ultralight、CPU、ISO14443-B 或 ISO15693。
/// 新规范:M1 业务卡号固定读写扇区 1 的块 5,认证固定密码 B(mch_manage.card_key 同步缓存),
/// 业务块读不出时回退返回 UID;密钥仅用于扇区访问认证,不适用于其他卡型。
class SerialCardManage extends SerialBase {
/// 读卡器保持响应但在单轮等待期内没有读取到卡片时返回的稳定提示,用于业务层区分设备故障。
static const String cardWaitTimeoutMessage = '等待饭卡超时';
@@ -56,12 +57,6 @@ class SerialCardManage extends SerialBase {
int _scanConsecutiveNoResponseBlocks = 0;
int _currentRequestAttemptCount = 0;
/// 当前实际下发给模块的 M1 密钥类型;认证失败后可在不修改用户配置的前提下切换一次备用类型。
SerialMifareKeyType _activeMifareKeyType = SerialMifareKeyType.keyA;
/// 当前数据块是否已经尝试过另一种密钥类型,防止 KEY A/B 都失败时来回切换形成死循环。
bool _alternateMifareKeyAttempted = false;
SerialCardManage() {
name = '读卡器';
portName = '/dev/ttyS4';
@@ -102,8 +97,6 @@ class SerialCardManage extends SerialBase {
_scanFailedBlocks = 0;
_scanConsecutiveNoResponseBlocks = 0;
_currentRequestAttemptCount = 0;
_activeMifareKeyType = SerialMifareKeyType.keyA;
_alternateMifareKeyAttempted = false;
}
@override
@@ -142,7 +135,7 @@ class SerialCardManage extends SerialBase {
));
}
/// 智能读取默认兼容旧 M1 业务块,空块和其他卡型返回完整 UID。
/// 读卡:M1 用密码 B 读扇区 1 业务块,读不出或其他卡型返回完整 UID。
void sendRead() {
unawaited(_startOperation(command: SerialCardCmdEnum.read));
}
@@ -169,11 +162,9 @@ class SerialCardManage extends SerialBase {
return;
}
_config = await AppSettingTool.getSerialCardConfig();
_activeMifareKeyType = _config.mifareKeyType;
_alternateMifareKeyAttempted = false;
// 诊断配置是否真正传到硬件层时只记录模式、块地址和 KEY 类型,严禁记录密钥内容。
// 只记录非敏感协议参数,严禁记录密钥内容。
logger.i(logMsg(
'加载读写卡配置mode:|${_config.readMode.code}|block:|${_config.mifareBlockAddress}|keyType:|${_config.mifareKeyType.title}|',
'加载读写卡配置block:|${SerialCardConfig.mifareBusinessBlockAddress}|keyType:|B|',
));
// 用户可能在读取本地设置期间关闭弹窗或重新发起操作,旧异步任务不得重新占用串口。
if (operationEpoch != _operationEpoch ||
@@ -279,8 +270,7 @@ class SerialCardManage extends SerialBase {
if (currentCmd == SerialCardCmdEnum.read) {
_requestMifareBlockRead();
} else if (currentCmd == SerialCardCmdEnum.scan) {
// KEY A/B 备用重试仍是同一个块,不能在密钥切换 ACK 后清空本块的重试标记。
_requestNextMifareScanBlock(resetKeyAttempt: false);
_requestNextMifareScanBlock();
} else {
_requestMifareBlockWrite();
}
@@ -288,7 +278,8 @@ class SerialCardManage extends SerialBase {
case _SerialCardStep.readBlock:
if (frame.command != SerialReadCardTool.mifareReadBlockCommand ||
frame.payload.length != 17 ||
frame.payload.first != _config.mifareBlockAddress) {
frame.payload.first !=
SerialCardConfig.mifareBusinessBlockAddress) {
return;
}
_finishMifareBlockRead(frame.payload.sublist(1));
@@ -313,7 +304,7 @@ class SerialCardManage extends SerialBase {
}
}
/// 按当前协议步骤处理设备错误,并在 M1 认证失败时执行一次 KEY A/B 兼容重试。
/// 按当前协议步骤处理设备错误;新规范下读卡失败回退 UID,写卡认证失败给出初始化引导。
void _dispatchError(SerialCardFrame frame) {
if (frame.command == 0xE1) {
if (currentCmd == SerialCardCmdEnum.scan) {
@@ -330,13 +321,6 @@ class SerialCardManage extends SerialBase {
return;
}
if (frame.command == 0xE2 &&
(_step == _SerialCardStep.readBlock ||
_step == _SerialCardStep.scanBlock) &&
_tryAlternateMifareKey()) {
return;
}
if (currentCmd == SerialCardCmdEnum.scan &&
_step == _SerialCardStep.scanBlock &&
const {0xE2, 0xE3}.contains(frame.command)) {
@@ -344,11 +328,20 @@ class SerialCardManage extends SerialBase {
return;
}
if (currentCmd == SerialCardCmdEnum.write && frame.command == 0xE2) {
logger.w(logMsg('写卡认证失败,卡片未初始化或密码不匹配'));
_notify(
SerialCardResStatus.error,
'卡片未初始化或饭卡密码不匹配,请先在后台开卡管理中初始化此卡',
);
close();
return;
}
if (currentCmd == SerialCardCmdEnum.read &&
_config.readMode == SerialCardReadMode.smart &&
_step == _SerialCardStep.readBlock &&
const {0xE0, 0xE2, 0xE3}.contains(frame.command)) {
// 智能模式优先兼容旧 M1 业务块,但密钥不同、块不可读或卡型边界异常时仍可安全读取 UID。
// 读不出业务块(密码不匹配、块不可读或卡型边界异常)时安全回退读取 UID。
logger.w(logMsg('M1 业务块不可用,回退读取 UID reason:|${frame.errorMessage}|'));
_requestUid();
return;
@@ -371,7 +364,7 @@ class SerialCardManage extends SerialBase {
close();
return;
}
_requestMifareKey();
_requestActiveMifareKey();
return;
}
@@ -391,32 +384,15 @@ class SerialCardManage extends SerialBase {
_scanFailedBlocks = 0;
_scanConsecutiveNoResponseBlocks = 0;
_notifyScanProgress();
_requestMifareKey();
_requestActiveMifareKey();
return;
}
switch (_config.readMode) {
case SerialCardReadMode.uidOnly:
_requestUid();
break;
case SerialCardReadMode.mifareBlockOnly:
if (_cardType != SerialCardType.mifare) {
_notify(
SerialCardResStatus.error,
'当前设置仅允许读取 M1 业务块,检测到 ${_cardType.title}',
);
close();
return;
}
_requestMifareKey();
break;
case SerialCardReadMode.smart:
if (_cardType == SerialCardType.mifare) {
_requestMifareKey();
} else {
_requestUid();
}
break;
// 读卡固定行为:M1 先读扇区 1 业务块,其余卡型直接读取 UID。
if (_cardType == SerialCardType.mifare) {
_requestActiveMifareKey();
} else {
_requestUid();
}
}
@@ -428,13 +404,8 @@ class SerialCardManage extends SerialBase {
close();
return;
}
if (_config.readMode == SerialCardReadMode.smart) {
logger.d(logMsg('M1 业务块为空或不是文本,回退读取 UID'));
_requestUid();
return;
}
_notify(SerialCardResStatus.error, 'M1 业务块为空或不是有效文本');
close();
logger.d(logMsg('M1 业务块为空或不是文本,回退读取 UID'));
_requestUid();
}
void _finishReadWithUid(List<int> uid) {
@@ -468,72 +439,27 @@ class SerialCardManage extends SerialBase {
);
}
/// 校验业务块地址并以用户首选 KEY 启动 M1 认证配置流程。
void _requestMifareKey() {
if (currentCmd != SerialCardCmdEnum.scan &&
!SerialReadCardTool.isMifareBusinessDataBlock(
_config.mifareBlockAddress,
)) {
if (currentCmd == SerialCardCmdEnum.read &&
_config.readMode == SerialCardReadMode.smart) {
logger.w(logMsg('M1 业务块配置无效,智能模式回退读取 UID'));
_requestUid();
return;
}
final reason = _config.mifareBlockAddress == 0
? '配置的 M1 第 0 块是厂商/UID 保留块,禁止读写业务卡号'
: '配置的 M1 块是扇区密钥块,禁止读写业务卡号';
_notify(SerialCardResStatus.error, reason);
close();
return;
}
_activeMifareKeyType = _config.mifareKeyType;
_alternateMifareKeyAttempted = false;
_requestActiveMifareKey();
}
/// 把当前活动 KEY 的 6 字节密码写入模块;备用 KEY 重试复用本方法且不会改变持久化配置。
/// 把服务端同步的商户饭卡密码作为 KEY B 写入模块,供扇区 1 业务块认证使用。
void _requestActiveMifareKey() {
final keyHex = _activeMifareKeyType == SerialMifareKeyType.keyA
? _config.keyAHex
: _config.keyBHex;
final keyBytes = SerialTool.stringToBytes(keyHex);
final keyBytes = SerialTool.stringToBytes(_config.keyBHex);
_setRequest(
step: _SerialCardStep.saveKey,
request: SerialReadCardTool.saveMifareKeyRequest(
keyType: _activeMifareKeyType,
keyType: SerialMifareKeyType.keyB,
keyBytes: keyBytes,
),
label: '配置 M1 ${_activeMifareKeyType.title}',
label: '配置 M1 KEY B',
);
}
/// E2 认证失败时切换 KEY A/B 并重试当前块一次。
///
/// M1 访问位可以规定数据块只能由某一种 KEY 读取,即使 KEY A/B 密码内容相同也不能互相替代。
bool _tryAlternateMifareKey() {
if (_alternateMifareKeyAttempted || currentCmd == SerialCardCmdEnum.write) {
return false;
}
_alternateMifareKeyAttempted = true;
_activeMifareKeyType = _activeMifareKeyType == SerialMifareKeyType.keyA
? SerialMifareKeyType.keyB
: SerialMifareKeyType.keyA;
logger.w(logMsg(
'M1 当前密钥类型认证失败,尝试 ${_activeMifareKeyType.title}',
));
_requestActiveMifareKey();
return true;
}
/// 通知模块后续块操作使用当前活动的 KEY A 或 KEY B。
/// 通知模块后续块操作固定使用 KEY B。
void _requestKeyType() {
_setRequest(
step: _SerialCardStep.keyType,
request: SerialReadCardTool.setMifareKeyTypeRequest(
_activeMifareKeyType,
SerialMifareKeyType.keyB,
),
label: '选择 M1 ${_activeMifareKeyType.title}',
label: '选择 M1 KEY B',
);
}
@@ -541,9 +467,9 @@ class SerialCardManage extends SerialBase {
_setRequest(
step: _SerialCardStep.readBlock,
request: SerialReadCardTool.readMifareBlockRequest(
_config.mifareBlockAddress,
SerialCardConfig.mifareBusinessBlockAddress,
),
label: '读取 M1 块 ${_config.mifareBlockAddress}',
label: '读取 M1 块 ${SerialCardConfig.mifareBusinessBlockAddress}',
);
}
@@ -559,10 +485,10 @@ class SerialCardManage extends SerialBase {
_setRequest(
step: _SerialCardStep.writeBlock,
request: SerialReadCardTool.writeMifareBlockRequest(
blockAddress: _config.mifareBlockAddress,
blockAddress: SerialCardConfig.mifareBusinessBlockAddress,
blockData: blockData,
),
label: '写入 M1 块 ${_config.mifareBlockAddress}',
label: '写入 M1 块 ${SerialCardConfig.mifareBusinessBlockAddress}',
);
}
@@ -571,14 +497,13 @@ class SerialCardManage extends SerialBase {
? _scanBlockAddresses[_scanBlockIndex]
: null;
/// 请求当前扫描地址;进入新块时重置 KEY A/B 备用尝试状态。
void _requestNextMifareScanBlock({bool resetKeyAttempt = true}) {
/// 请求当前扫描地址。
void _requestNextMifareScanBlock() {
final blockAddress = _currentScanBlockAddress;
if (blockAddress == null) {
_finishMifareScan();
return;
}
if (resetKeyAttempt) _alternateMifareKeyAttempted = false;
_setRequest(
step: _SerialCardStep.scanBlock,
request: SerialReadCardTool.readMifareBlockRequest(blockAddress),
@@ -76,7 +76,7 @@ abstract class SerialReadCardTool {
}.contains(request[2]);
}
/// 业务卡号按 UTF-8 写入一个 M1 数据块,剩余位置使用 0x00 补齐。
/// 业务卡号按 UTF-8 写入一个 M1 数据块,剩余位置使用 0x20 空格补齐(与后台开卡管理一致)。
static List<int>? encodeMifareBlockData(String cardNumber) {
final bytes = utf8.encode(cardNumber.trim());
if (bytes.isEmpty ||
@@ -84,10 +84,10 @@ abstract class SerialReadCardTool {
bytes.any((byte) => byte < 0x20 || byte > 0x7E)) {
return null;
}
return [...bytes, ...List<int>.filled(16 - bytes.length, 0)];
return [...bytes, ...List<int>.filled(16 - bytes.length, 0x20)];
}
/// 只接受可打印的业务文本;空块或二进制块交由智能模式回退 UID。
/// 只接受可打印的业务文本;兼容空格补齐与历史 0x00 补齐两种块格式。
static String? decodeMifareBlockData(List<int> blockData) {
if (blockData.length != 16) return null;
final end = blockData.indexOf(0);
+2 -2
View File
@@ -15,8 +15,8 @@ class Constants {
static const pwdEncKey = 'pigxpigxpigxpigx';
/// 服务 url 线上
static const apiUrl = 'https://dzc-test-1.fy.takin.cc';
// static const apiUrl = 'https://www.zianxin.com/api';
// static const apiUrl = 'https://dzc-test-1.fy.takin.cc';
static const apiUrl = 'https://www.zianxin.com/api';
// static const apiUrl = 'https://dzc-test-1.fy.takin.cc';
+1 -1
View File
@@ -331,7 +331,7 @@ String _menuDescription(SettingMenuType type) {
SettingMenuType.printerInfo => '查看实际 USB 参数,手动上报并输出测试小票',
SettingMenuType.subScreenRoutes => '选择顾客屏演示状态并在当前应用窗口中全屏检查',
SettingMenuType.versionSetting => '查看可用终端版本、更新内容和下载状态',
SettingMenuType.cardReaderSetting => '配置 DK25-ST 读取策略、M1 数据块和 KEY A/B 密钥',
SettingMenuType.cardReaderSetting => '同步商户饭卡密码 B,查看固定读取规范和读卡器诊断',
};
}
@@ -2,8 +2,6 @@ import 'dart:async';
import 'package:ducafe_ui_core/ducafe_ui_core.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_easyloading/flutter_easyloading.dart';
import 'package:forui/forui.dart';
import 'package:get/get.dart';
@@ -11,7 +9,8 @@ import 'package:pj_supermarket_cloud/common/index.dart';
import 'setting_components.dart';
/// DK25-ST 配置页只维护设备级协议参数;实际读写仍由 SerialCardManage 统一执行。
/// 读/写卡设置页:新规范下扇区、业务块和密钥类型固定(扇区 1 块 5、密码 B),
/// 密钥来自服务端 mch_manage.card_key 同步缓存,本页只维护同步入口和诊断工具。
class SettingCardReader extends StatefulWidget {
const SettingCardReader({super.key});
@@ -20,26 +19,18 @@ class SettingCardReader extends StatefulWidget {
}
class _SettingCardReaderState extends State<SettingCardReader> {
final _blockController = TextEditingController();
final _keyAController = TextEditingController();
final _keyBController = TextEditingController();
SerialCardConfig _config = const SerialCardConfig();
bool _loading = true;
bool _saving = false;
bool _showKeyA = false;
bool _syncingKey = false;
bool _showKeyB = false;
bool _testOperationActive = false;
/// 枚举和开关配置是否正在写入本地 setting.sysJson,用于合并连续点击产生的保存任务。
/// 开关配置是否正在写入本地 setting.sysJson,用于合并连续点击产生的保存任务。
bool _configPersisting = false;
/// 当前持久化期间是否又发生了配置变化;为 true 时完成本轮后继续保存最新界面状态。
bool _configPersistPending = false;
String? _blockError;
String? _keyAError;
String? _keyBError;
final RxBool _testReadLoading = false.obs;
final Rx<ServiceOpenCard?> _testDetail = Rx(null);
final Rx<SerialMifareScanProgress?> _testScanProgress = Rx(null);
@@ -56,22 +47,16 @@ class _SettingCardReaderState extends State<SettingCardReader> {
@override
void dispose() {
if (_testOperationActive) _closeTestSerialOperation();
_blockController.dispose();
_keyAController.dispose();
_keyBController.dispose();
super.dispose();
}
Future<void> _load() async {
try {
// 打开页面时顺带从服务端刷新一次设备信息,尽快拿到最新商户饭卡密码。
unawaited(_syncCardKeyFromService(showSuccess: false));
final config = await AppSettingTool.getSerialCardConfig();
if (!mounted) return;
setState(() {
_config = config;
_blockController.text = '${config.mifareBlockAddress}';
_keyAController.text = config.keyAHex;
_keyBController.text = config.keyBHex;
});
setState(() => _config = config);
} catch (e, stack) {
logger.e('加载读写卡设置失败error:|$e|stack:|$stack|');
Toast.error(msg: '读写卡设置加载失败');
@@ -80,113 +65,43 @@ class _SettingCardReaderState extends State<SettingCardReader> {
}
}
/// 校验并保存完整读写卡配置;返回值表示界面当前配置是否已经成功落盘。
Future<bool> _save({bool showSuccess = true}) async {
if (_saving || !_validate()) return false;
final blockAddress = int.parse(_blockController.text.trim());
final nextConfig = _config.copyWith(
mifareBlockAddress: blockAddress,
keyAHex: _normalizeKey(_keyAController.text),
keyBHex: _normalizeKey(_keyBController.text),
);
// 完整保存已经包含当前所有选择和文本输入,取消尚未执行的旧自动保存快照;同时先更新
// _config,确保正在执行的自动保存下一轮也只能取得这份最新配置,不能再把块地址覆盖回旧值。
_configPersistPending = false;
setState(() {
_config = nextConfig;
_saving = true;
});
EasyLoading.show(status: '保存中...', maskType: EasyLoadingMaskType.black);
/// 从服务端同步商户饭卡密码 B 并刷新展示;同步失败时保留本机缓存继续离线可用。
Future<void> _syncCardKeyFromService({bool showSuccess = true}) async {
if (_syncingKey) return;
setState(() => _syncingKey = true);
try {
await AppSettingTool.setSerialCardConfig(nextConfig);
final storedConfig = await AppSettingTool.getSerialCardConfig();
if (!_isSamePersistedConfig(nextConfig, storedConfig)) {
throw StateError('读写卡设置保存后回读不一致');
final previousKey = _config.keyBHex;
// autoSyncSysInfo 失败时不抛异常而是返回 null,必须按返回值区分成败。
final result = await SyncConfigTool.autoSyncSysInfo();
if (result == null) {
if (showSuccess) Toast.error(msg: '饭卡密码同步失败,请检查网络后重试');
return;
}
final config = await AppSettingTool.getSerialCardConfig();
if (!mounted) return;
setState(() => _config = config);
if (showSuccess) {
Toast.success(
msg: config.keyBHex == previousKey ? '饭卡密码已是最新' : '饭卡密码已同步',
);
}
// 只记录非敏感协议选择,KEY A/B 的密码内容禁止写入日志。
logger.i(
'读写卡设置保存成功mode:|${storedConfig.readMode.code}|block:|${storedConfig.mifareBlockAddress}|keyType:|${storedConfig.mifareKeyType.title}|',
);
if (!mounted) return true;
if (showSuccess) Toast.success(msg: '读写卡设置已保存');
return true;
} catch (e, stack) {
logger.e('保存读写卡设置失败error:|$e|stack:|$stack|');
Toast.error(msg: '读写卡设置保存失败,请重试');
return false;
logger.e('同步饭卡密码失败error:|$e|stack:|$stack|');
if (showSuccess) Toast.error(msg: '饭卡密码同步失败,请检查网络后重试');
} finally {
EasyLoading.dismiss();
if (mounted) setState(() => _saving = false);
if (mounted) setState(() => _syncingKey = false);
}
}
/// 比对写入前后的完整读写卡配置;密钥仅在内存中参与比较,不输出到日志或页面。
bool _isSamePersistedConfig(
SerialCardConfig expected,
SerialCardConfig actual,
) {
return expected.readMode == actual.readMode &&
expected.mifareBlockAddress == actual.mifareBlockAddress &&
expected.mifareKeyType == actual.mifareKeyType &&
expected.keyAHex == actual.keyAHex &&
expected.keyBHex == actual.keyBHex &&
expected.showMifareScanButton == actual.showMifareScanButton;
/// 密钥展示与后台一致使用前 2 位 + 掩码 + 后 2 位;完整值仅在本机核对时按需展开。
String _maskKey(String key) {
if (key.length < 4) return '*' * key.length;
return '${key.substring(0, 2)}********${key.substring(key.length - 2)}';
}
bool _validate() {
final blockAddress = int.tryParse(_blockController.text.trim());
final keyA = _normalizeKey(_keyAController.text);
final keyB = _normalizeKey(_keyBController.text);
setState(() {
_blockError =
blockAddress == null || blockAddress < 1 || blockAddress > 255
? '请输入 1 到 255 的块地址'
: SerialReadCardTool.isMifareTrailerBlock(blockAddress)
? '该地址是扇区密钥块,不能保存业务卡号'
: null;
_keyAError = _isValidKey(keyA) ? null : '请输入 12 位十六进制 KEY A';
_keyBError = _isValidKey(keyB) ? null : '请输入 12 位十六进制 KEY B';
});
return _blockError == null && _keyAError == null && _keyBError == null;
}
String _normalizeKey(String value) {
return value.replaceAll(RegExp(r'\s+'), '').toUpperCase();
}
bool _isValidKey(String value) => RegExp(r'^[0-9A-F]{12}$').hasMatch(value);
/// 恢复出厂读取参数并立即持久化,避免离开页面后重新出现旧选择。
void _restoreDefaults() {
setState(() {
_config = const SerialCardConfig();
_blockController.text = '1';
_keyAController.text = SerialCardConfig.defaultKeyHex;
_keyBController.text = SerialCardConfig.defaultKeyHex;
_blockError = null;
_keyAError = null;
_keyBError = null;
});
_scheduleConfigPersist();
}
/// 更新业务卡号来源并立即安排持久化,使分段选择不依赖页面底部保存按钮。
void _setReadMode(SerialCardReadMode value) {
if (_saving || _config.readMode == value) return;
setState(() => _config = _config.copyWith(readMode: value));
_scheduleConfigPersist();
}
/// 更新首选 M1 密钥类型并立即安排持久化;认证失败时管理器仍会尝试另一种类型一次。
void _setMifareKeyType(SerialMifareKeyType value) {
if (_saving || _config.mifareKeyType == value) return;
setState(() => _config = _config.copyWith(mifareKeyType: value));
_scheduleConfigPersist();
}
/// 更新扫描入口显隐并立即安排持久化,保证再次进入设置页时保持当前开关状态。
/// 更新扫描入口显隐并立即持久化,保证再次进入设置页时保持当前开关状态。
void _setMifareScanButtonVisible(bool value) {
if (_saving || _config.showMifareScanButton == value) return;
if (_config.showMifareScanButton == value) return;
setState(() => _config = _config.copyWith(showMifareScanButton: value));
_scheduleConfigPersist();
}
@@ -197,7 +112,7 @@ class _SettingCardReaderState extends State<SettingCardReader> {
if (!_configPersisting) unawaited(_flushConfigPersist());
}
/// 串行保存枚举和开关配置;文本输入仍由“保存设置”统一校验后写入。
/// 串行保存开关配置;密码 B 由服务端同步链路单独写入,不经本页手工编辑。
Future<void> _flushConfigPersist() async {
_configPersisting = true;
while (_configPersistPending) {
@@ -207,7 +122,7 @@ class _SettingCardReaderState extends State<SettingCardReader> {
await AppSettingTool.setSerialCardConfig(config);
} catch (e, stack) {
logger.e('自动保存读写卡设置失败error:|$e|stack:|$stack|');
Toast.error(msg: '读写卡设置自动保存失败,请点击保存设置重试');
Toast.error(msg: '读写卡设置自动保存失败,请稍后重试');
}
}
_configPersisting = false;
@@ -218,8 +133,6 @@ class _SettingCardReaderState extends State<SettingCardReader> {
/// 串口管理器沿用 AppControl 的应用级实例,避免创建第二个实例争用同一端口。
Future<void> _openReadCardTest() async {
if (_testOperationActive) return;
// 测试必须使用页面正在显示的块地址和密钥,不能继续读取上一次已保存配置。
if (!await _save(showSuccess: false)) return;
setState(() => _testOperationActive = true);
_testReadLoading.value = true;
_testDetail.value = null;
@@ -366,21 +279,23 @@ class _SettingCardReaderState extends State<SettingCardReader> {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SettingGroup(
title: '读取策略',
title: '饭卡读取规范',
child: Column(
children: [
SettingSegmentedSelector<SerialCardReadMode>(
label: '业务卡号来源',
value: _config.readMode,
options: const {
SerialCardReadMode.smart: '智能识别',
SerialCardReadMode.uidOnly: '仅 UID',
SerialCardReadMode.mifareBlockOnly: '仅 M1 业务块',
},
onChanged: _setReadMode,
),
SettingInfoRow(
icon: FAssets.icons.nfc,
label: '数据位置',
subtitle: '与后台开卡管理一致,固定读写扇区 1 的第二个数据块。',
value: '扇区 1 · 块 5',
),
SettingInfoRow(
icon: FAssets.icons.lock,
label: '认证密钥',
subtitle: '固定使用密码 B 认证扇区,不支持在本机修改。',
value: '密码 B',
),
SettingInfoRow(
icon: FAssets.icons.scan,
label: '支持卡型',
subtitle:
'M1、Ultralight、ISO14443-A CPU、ISO14443-B、ISO15693,以及其他 4-8 字节 UID 卡',
@@ -392,68 +307,33 @@ class _SettingCardReaderState extends State<SettingCardReader> {
),
SizedBox(height: XfjDesignTokens.cardGap.h),
SettingGroup(
title: 'M1 业务块',
title: '饭卡密码(密码 B)',
child: Column(
children: [
_SettingInputRow(
label: '数据块地址',
description: '旧卡默认读取第 1 块;第 0 块和每个扇区最后的密钥块不可使用。',
controller: _blockController,
errorText: _blockError,
softKeyboard: AppSoftKeyboardMode.digits,
softKeyboardAllowPattern: RegExp('[0-9]'),
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
SettingInfoRow(
icon: FAssets.icons.keyRound,
label: '当前密钥',
subtitle: '来源为后台商户资料的饭卡密码,随设备信息同步自动更新,离线沿用本机缓存。',
value: _showKeyB ? _config.keyBHex : _maskKey(_config.keyBHex),
trailing: InkWell(
onTap: () => setState(() => _showKeyB = !_showKeyB),
child: FIcon(
_showKeyB ? FAssets.icons.eye : FAssets.icons.eyeOff,
color: XfjDesignTokens.muted,
size: 22.sp,
).center().width(48.w).height(48.h),
),
),
SettingSegmentedSelector<SerialMifareKeyType>(
label: '认证密钥类型',
value: _config.mifareKeyType,
options: const {
SerialMifareKeyType.keyA: 'KEY A',
SerialMifareKeyType.keyB: 'KEY B',
},
onChanged: _setMifareKeyType,
showDivider: false,
),
],
),
),
SizedBox(height: XfjDesignTokens.cardGap.h),
SettingGroup(
title: 'M1 密钥',
child: Column(
children: [
_SettingInputRow(
label: 'KEY A',
description: '6 字节十六进制密钥,模块出厂默认 FFFFFFFFFFFF。',
controller: _keyAController,
errorText: _keyAError,
softKeyboard: AppSoftKeyboardMode.letters,
softKeyboardAllowPattern: RegExp('[0-9a-fA-F]'),
softKeyboardMaxLength: 12,
obscureText: !_showKeyA,
onToggleVisibility: () =>
setState(() => _showKeyA = !_showKeyA),
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'[0-9a-fA-F]')),
LengthLimitingTextInputFormatter(12),
],
),
_SettingInputRow(
label: 'KEY B',
description: '只有选择 KEY B 认证时才会发送到模块。',
controller: _keyBController,
errorText: _keyBError,
softKeyboard: AppSoftKeyboardMode.letters,
softKeyboardAllowPattern: RegExp('[0-9a-fA-F]'),
softKeyboardMaxLength: 12,
obscureText: !_showKeyB,
onToggleVisibility: () =>
setState(() => _showKeyB = !_showKeyB),
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'[0-9a-fA-F]')),
LengthLimitingTextInputFormatter(12),
],
SettingInfoRow(
icon: FAssets.icons.refreshCcw,
label: '从服务端同步',
subtitle: '立即拉取后台最新的商户饭卡密码并更新本机缓存。',
trailing: SettingActionButton(
label: _syncingKey ? '同步中...' : '立即同步',
icon: FAssets.icons.refreshCcw,
enabled: !_syncingKey,
onTap: () => unawaited(_syncCardKeyFromService()),
),
showDivider: false,
),
],
@@ -472,14 +352,14 @@ class _SettingCardReaderState extends State<SettingCardReader> {
trailing: FSwitch(
semanticLabel: '在读卡弹框显示 M1 数据块扫描',
value: _config.showMifareScanButton,
enabled: !_saving && !_testOperationActive,
enabled: !_testOperationActive,
onChange: _setMifareScanButtonVisible,
),
),
SettingInfoRow(
icon: FAssets.icons.nfc,
label: '读卡器测试',
subtitle: '打开前会保存当前块地址、读取策略和密钥;上方开关开启时可同时执行 M1 扫描。',
subtitle: '按当前规范读取饭卡:M1 读扇区 1 业务块,读不出时返回 8 位 UID;上方开关开启时可同时执行 M1 扫描。',
trailing: SettingActionButton(
label: _testOperationActive ? '测试中...' : '打开测试',
icon: FAssets.icons.nfc,
@@ -494,154 +374,14 @@ class _SettingCardReaderState extends State<SettingCardReader> {
SizedBox(height: XfjDesignTokens.cardGap.h),
SettingNotice(
text:
'KEY A/B 是 M1 扇区访问认证,不是卡号内容加密。非 M1 卡只读取 UID;写业务卡号仅支持 M1,且最多 16 字节。密钥不会输出到日志。',
'饭卡密码 B 是 M1 扇区访问认证,由服务端统一下发,不在本机编辑、不输出到日志。读卡读不出业务块时自动返回 8 位 UID;写卡仅支持已初始化的 M1 卡。切换商户后请重新从服务端同步密码。',
background: Color(0xFFFFF9E8),
borderColor: Color(0x4DF2A817),
icon: FAssets.icons.triangleAlert,
),
SizedBox(height: 20.h),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
SettingActionButton(
label: '恢复默认',
icon: FAssets.icons.rotateCcw,
enabled: !_saving,
onTap: _restoreDefaults,
),
SizedBox(width: 12.w),
SettingActionButton(
label: _saving ? '保存中...' : '保存设置',
icon: FAssets.icons.save,
primary: true,
enabled: !_saving,
onTap: () => unawaited(_save()),
),
],
),
],
),
);
}
}
class _SettingInputRow extends StatelessWidget {
const _SettingInputRow({
required this.label,
required this.description,
required this.controller,
this.errorText,
this.keyboardType,
this.inputFormatters,
this.obscureText = false,
this.onToggleVisibility,
this.showDivider = true,
this.softKeyboard,
this.softKeyboardAllowPattern,
this.softKeyboardMaxLength,
});
final String label;
final String description;
final TextEditingController controller;
final String? errorText;
final TextInputType? keyboardType;
final List<TextInputFormatter>? inputFormatters;
final bool obscureText;
final VoidCallback? onToggleVisibility;
final bool showDivider;
/// 自研软键盘初始面板;为空时不接管点击(保持既有行为)。
final AppSoftKeyboardMode? softKeyboard;
/// 自研键盘字符白名单;为空时不限制。
final RegExp? softKeyboardAllowPattern;
/// 自研键盘长度上限;为空时不限制。
final int? softKeyboardMaxLength;
@override
Widget build(BuildContext context) {
final theme = FTheme.of(context);
return <Widget>[
<Widget>[
Text(
label,
style: theme.typography.base.copyWith(
color: XfjDesignTokens.ink,
fontWeight: FontWeight.w700,
),
),
SizedBox(height: 4.h),
Text(
description,
style: theme.typography.sm.copyWith(color: XfjDesignTokens.muted),
),
].toColumn(crossAxisAlignment: CrossAxisAlignment.start).width(520.w),
<Widget>[
onToggleVisibility == null
? FTextField(
controller: controller,
maxLines: 1,
keyboardType:
softKeyboard != null ? TextInputType.none : keyboardType,
inputFormatters: inputFormatters,
onTap: softKeyboard == null
? null
: () => AppSoftKeyboard.show(
context,
controller: controller,
mode: softKeyboard!,
allowPattern: softKeyboardAllowPattern,
maxLength: softKeyboardMaxLength,
semanticLabel: '$label 输入键盘',
),
)
: FTextField.password(
controller: controller,
maxLines: 1,
obscureText: obscureText,
keyboardType:
softKeyboard != null ? TextInputType.none : keyboardType,
onTap: softKeyboard == null
? null
: () => AppSoftKeyboard.show(
context,
controller: controller,
mode: softKeyboard!,
allowPattern: softKeyboardAllowPattern,
maxLength: softKeyboardMaxLength,
semanticLabel: '$label 输入键盘',
),
inputFormatters: inputFormatters,
suffixBuilder: (_, __, ___) => InkWell(
onTap: onToggleVisibility,
child: FIcon(
obscureText ? FAssets.icons.eye : FAssets.icons.eyeOff,
color: XfjDesignTokens.muted,
size: 22.sp,
).center().width(48.w).height(48.h),
),
),
if (errorText != null) ...[
SizedBox(height: 6.h),
Text(
errorText!,
style: theme.typography.sm.copyWith(
color: XfjDesignTokens.danger,
fontWeight: FontWeight.w600,
),
),
],
].toColumn(crossAxisAlignment: CrossAxisAlignment.start).expanded(),
]
.toRowSpace(space: 20.w)
.padding(horizontal: 20.w, vertical: 14.h)
.constrained(minHeight: 84.h)
.decorated(
border: showDivider
? const Border(bottom: BorderSide(color: XfjDesignTokens.line))
: null,
);
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
name: pj_supermarket_cloud
description: "资安信智慧后勤-测试"
publish_to: "none"
version: 1.5.3+153
version: 1.5.4+154
environment:
sdk: ^3.6.0
+32 -20
View File
@@ -107,13 +107,13 @@ void main() {
);
});
test('M1 业务卡号保留完整长度并使用 0x00 补齐', () {
test('M1 业务卡号保留完整长度并使用 0x20 空格补齐(对齐后台)', () {
final block = SerialReadCardTool.encodeMifareBlockData('12345678901234');
expect(block, hasLength(16));
expect(
SerialReadCardTool.decodeMifareBlockData(block!), '12345678901234');
expect(block.sublist(14), [0, 0]);
expect(block.sublist(14), [0x20, 0x20]);
expect(
SerialReadCardTool.encodeMifareBlockData('12345678901234567'),
isNull,
@@ -121,6 +121,17 @@ void main() {
expect(SerialReadCardTool.encodeMifareBlockData('中文卡号'), isNull);
expect(
SerialReadCardTool.decodeMifareBlockData(List.filled(16, 0)), isNull);
// 历史 0x00 补齐块仍按截断解析,后台空格补齐块按 trim 解析。
expect(
SerialReadCardTool
.decodeMifareBlockData([...'123456'.codeUnits, ...List.filled(10, 0)]),
'123456',
);
expect(
SerialReadCardTool
.decodeMifareBlockData([...'123456'.codeUnits, ...List.filled(10, 0x20)]),
'123456',
);
});
test('显式 UID 与自动寻卡带类型 UID 均保留完整位数', () {
@@ -220,37 +231,38 @@ void main() {
});
group('读写卡配置', () {
test('旧设置缺失时使用兼容默认值并支持 JSON 往返', () {
test('固定规范常量与服务端默认密钥', () {
expect(SerialCardConfig.mifareBusinessBlockAddress, 5);
expect(SerialCardConfig.defaultKeyHex, '424324548800');
expect(SerialCardType.mifare.uidLength, 4);
});
test('设置缺失时使用服务端默认密钥并支持 JSON 往返', () {
const config = SerialCardConfig(
readMode: SerialCardReadMode.uidOnly,
mifareBlockAddress: 4,
mifareKeyType: SerialMifareKeyType.keyB,
keyAHex: 'A0A1A2A3A4A5',
keyBHex: 'B0B1B2B3B4B5',
showMifareScanButton: true,
);
final restored = SerialCardConfig.fromJson(config.toJson());
expect(restored.readMode, SerialCardReadMode.uidOnly);
expect(restored.mifareBlockAddress, 4);
expect(restored.mifareKeyType, SerialMifareKeyType.keyB);
expect(restored.selectedKeyHex, 'B0B1B2B3B4B5');
expect(restored.keyBHex, 'B0B1B2B3B4B5');
expect(restored.showMifareScanButton, isTrue);
final defaults = SerialCardConfig.fromJson(const {});
expect(defaults.readMode, SerialCardReadMode.smart);
expect(defaults.mifareBlockAddress, 1);
expect(defaults.keyAHex, SerialCardConfig.defaultKeyHex);
expect(defaults.keyBHex, SerialCardConfig.defaultKeyHex);
expect(defaults.showMifareScanButton, isFalse);
});
test('旧版本 JSON 的多余字段被忽略且非法密钥回退默认值', () {
final compatible = SerialCardConfig.fromJson(const {
'readMode': 'future_mode',
'mifareKeyType': 'future_key',
'keyAHex': 'invalid',
'readMode': 'uid_only',
'mifareBlockAddress': 1,
'mifareKeyType': 'key_a',
'keyAHex': 'A0A1A2A3A4A5',
'keyBHex': 'invalid',
'showMifareScanButton': true,
});
expect(compatible.readMode, SerialCardReadMode.smart);
expect(compatible.mifareKeyType, SerialMifareKeyType.keyA);
expect(compatible.keyAHex, SerialCardConfig.defaultKeyHex);
expect(compatible.keyBHex, SerialCardConfig.defaultKeyHex);
expect(compatible.showMifareScanButton, isTrue);
});
test('不同卡型保留手册定义的 UID 长度', () {