This commit is contained in:
dzc committed 2026-10-22 19:08:46 +08:00
1 parent 7c20242ad7
commit 382b8d660f
8 files changed
+802 -21

No files matched your search

+27 -5
View File
@@ -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<ServiceOrderByAndroidCode?> 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<String, dynamic>.from(res.data as Map),
);
}
/// 创建硬件订单
static Future<String?> createHardwareOrder(dynamic formData) async {
final res = await HttpManage().dio.post(
@@ -266,6 +266,53 @@ class ServicePrintRecord {
Map<String, dynamic> 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<String, dynamic> 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 {
@@ -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<String?> recover({
required Future<ServiceOrderByAndroidCode?> 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<void>.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;
}
}
+54 -13
View File
@@ -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<FaceStream> pendingFaceRecognition = Rxn<FaceStream>();
@@ -227,8 +232,8 @@ mixin OrderMixin {
return false;
}
// 更新本地订单
Future<bool> updateSqlOrder() async {
/// 更新结算快照;只有已由扫码轮询确认支付时才提前写为 PAY,普通下单等待服务端确认。
Future<bool> 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<T?> createApiOrder<T>() async {
if (_creatingApiOrder) return null;
_creatingApiOrder = true;
try {
return await _createApiOrder<T>();
} finally {
_creatingApiOrder = false;
}
}
/// 准备并提交原协议订单;网络响应丢失时先只读对账,确认后统一完成本地状态。
Future<T?> _createApiOrder<T>() 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<Map<String, dynamic>> 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 <Map<String, dynamic>>[];
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) {
+2 -2
View File
@@ -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;
};
// |支付方式|点击|取消|
+1 -1
View File
@@ -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元");