111
This commit is contained in:
1 parent
7c20242ad7
commit
382b8d660f
8 files changed
+802
-21
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
// |支付方式|点击|取消|
|
||||
|
||||
@@ -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元");
|
||||
|
||||
Reference in new issue
Block a user