[FEAT] 強化交易上報正確性:手機支付路由鍵全程一致 + 結案發件箱防漏送
1. 手機支付 flowType 自按鈕起即為 10(原 4):BuyDialog 手機感應鈕、BuyDialog/ BuyPickupCodeDialog/InvoiceBarcodeDialog/InvoiceCustomerIdentifierDialog/ InvoiceDonationDialog 的支付路由 case 4→10,realPayment 判斷一併更新; DevSetStructure.canMobilePay 與 TransactionFinalizePayload 註解同步標為 10。 2. CheckoutNfcPhoneDialog 成功時不再覆寫 flowType(原 4→10),改為成功/失敗/取消 各出口全程一致上報 10,修正失敗手機感應誤標後台 4(紙鈔機/Bill Acceptor)的問題。 3. 新增 TransactionOutbox:結案 payload 先持久化(SharedPreferences per-key)再送出, broker PUBACK 才移除;連線恢復與心跳每 60s 自動補送,解決斷線/斷電/App 被殺時 finalize 漏送。 4. MqttManager.publishTransactionFinalize 改走 Outbox,斷線不再直接丟棄;新增 rawPublishTransaction(成功/失敗雙回呼)供 Outbox 實際送出。 5. Outbox 全程單一背景執行緒處理不卡 UI;in-flight 標記避免同筆重複送;連續失敗達 上限移入死信(保留資料、出聲告警);addConnectedListener 連線恢復清 in-flight 並補送。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
8d764ba388
commit
ebfa25da72
@ -110,6 +110,13 @@ public class MqttManager {
|
||||
} catch (Exception e) {
|
||||
logs.info("OTA resume on connect failed: " + e.getMessage());
|
||||
}
|
||||
// 連線就緒後補送未送出的交易結案(先清 in-flight,舊連線在途的送出已失效)。
|
||||
try {
|
||||
com.unibuy.smartdevice.tools.TransactionOutbox.onConnected(
|
||||
com.unibuy.smartdevice.MyApp.getInstance().getApplicationContext());
|
||||
} catch (Exception e) {
|
||||
logs.info("Transaction outbox resume on connect failed: " + e.getMessage());
|
||||
}
|
||||
})
|
||||
.buildAsync();
|
||||
|
||||
@ -149,6 +156,10 @@ public class MqttManager {
|
||||
}
|
||||
int temperature = DevXinYuanController.getTemperature();
|
||||
publishHeartbeat(ver, temperature);
|
||||
// 心跳順帶 drain 發件箱:即使連線健康時單筆 publish 曾失敗,也能在此定時補送,
|
||||
// 不必等到下次斷線重連(修正 outbox「只在重連才補送」的缺陷)。
|
||||
com.unibuy.smartdevice.tools.TransactionOutbox.resumeIfPending(
|
||||
MyApp.getInstance().getApplicationContext());
|
||||
} catch (Exception e) {
|
||||
logs.info("Error in heartbeat scheduler: " + e.getMessage());
|
||||
}
|
||||
@ -361,14 +372,26 @@ public class MqttManager {
|
||||
* @param payload 由 TransactionFinalizeBuilder.build() 產生的完整 payload
|
||||
*/
|
||||
public void publishTransactionFinalize(TransactionFinalizePayload payload) {
|
||||
// 改走本地發件箱(Outbox):先持久化、再嘗試送出;斷線 / App 被殺不再直接遺失,
|
||||
// 連線恢復時由 TransactionOutbox.resumeIfPending 補送。後台對同一 flow_id 完全冪等,重送安全。
|
||||
com.unibuy.smartdevice.tools.TransactionOutbox.submit(
|
||||
MyApp.getInstance().getApplicationContext(), payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* 實際把交易結案 JSON 送到 MQTT(由 {@link com.unibuy.smartdevice.tools.TransactionOutbox} 呼叫)。
|
||||
*
|
||||
* @param payloadJson 已序列化的 payload
|
||||
* @param onSuccess 送出成功(broker PUBACK)後的回呼,用來從發件箱移除該筆
|
||||
* @param onFailure 送出失敗(throwable)後的回呼,用來清 in-flight / 累計失敗次數
|
||||
* @return true=已交給 MQTT 送出(成敗待 callback);false=未連線(保留於發件箱待下次補送)
|
||||
*/
|
||||
public boolean rawPublishTransaction(String payloadJson, Runnable onSuccess, Runnable onFailure) {
|
||||
if (client == null || !client.getState().isConnected()) {
|
||||
logs.info("Cannot publish finalize: MQTT not connected");
|
||||
return;
|
||||
logs.info("rawPublishTransaction: MQTT not connected, kept in outbox for resend");
|
||||
return false;
|
||||
}
|
||||
|
||||
String payloadJson = gson.toJson(payload);
|
||||
String topic = "machine/" + serialNo + "/transaction";
|
||||
|
||||
client.publishWith()
|
||||
.topic(topic)
|
||||
.payload(payloadJson.getBytes(StandardCharsets.UTF_8))
|
||||
@ -376,11 +399,18 @@ public class MqttManager {
|
||||
.send()
|
||||
.whenComplete((publishResult, throwable) -> {
|
||||
if (throwable != null) {
|
||||
logs.info("Failed to publish transaction finalize: " + throwable.getMessage());
|
||||
logs.info("rawPublishTransaction failed (will retry): " + throwable.getMessage());
|
||||
if (onFailure != null) {
|
||||
onFailure.run();
|
||||
}
|
||||
} else {
|
||||
logs.info("Transaction finalize published to " + topic + ": " + payloadJson);
|
||||
logs.info("Transaction published to " + topic);
|
||||
if (onSuccess != null) {
|
||||
onSuccess.run();
|
||||
}
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
public interface MqttCommandListener {
|
||||
|
||||
@ -194,7 +194,7 @@ public class DevSetStructure {
|
||||
/** flowType 2 電子票證(卡片支付):刷卡機開啟且卡片支付子項開啟。 */
|
||||
public boolean canCardPay() { return isDevNFCPay && isDevCardPay; }
|
||||
|
||||
/** flowType 4 手機支付:刷卡機開啟且手機支付子項開啟。 */
|
||||
/** flowType 10 手機支付:刷卡機開啟且手機支付子項開啟。 */
|
||||
public boolean canMobilePay() { return isDevNFCPay && isDevMobilePay; }
|
||||
|
||||
/** flowType 3 玉山 EsunPay:掃碼開啟且玉山子項開啟。 */
|
||||
|
||||
@ -184,7 +184,7 @@ public class TransactionFinalizePayload {
|
||||
|
||||
/**
|
||||
* 支付類型代碼:
|
||||
* 1:信用卡, 2:悠遊卡/一卡通, 3:掃碼支付, 4:紙鈔機, 5:通行碼, 6:取貨碼,
|
||||
* 1:信用卡, 2:悠遊卡/一卡通, 3:掃碼支付, 4:紙鈔機, 10:手機支付, 5:通行碼, 6:取貨碼,
|
||||
* 7:來店禮, 8:問卷, 9:零錢, 30:LINE Pay, 31:街口, 32:悠遊付, 33:Pi,
|
||||
* 34:全盈+, 40:會員驗證取貨, 41:員工卡, 60:點數全額折抵。
|
||||
*/
|
||||
|
||||
@ -0,0 +1,217 @@
|
||||
package com.unibuy.smartdevice.tools;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.unibuy.smartdevice.exception.Logs;
|
||||
import com.unibuy.smartdevice.external.mqtt.MqttManager;
|
||||
import com.unibuy.smartdevice.structure.mqtt.TransactionFinalizePayload;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
/**
|
||||
* 交易結案上報的本地發件箱(Outbox)。
|
||||
*
|
||||
* 目的:解決「出貨完成但 MQTT 斷線 / App 被殺 → finalize 直接遺失」的漏送。
|
||||
*
|
||||
* 設計重點:
|
||||
* - 單一背景執行緒(outboxExecutor)處理所有落地 / 送出 / 移除:不卡 UI 主執行緒、
|
||||
* 天然序列化(不需 synchronized、無鎖競爭)。
|
||||
* - 每筆交易一個 SharedPreferences key("tx:" + entryId):O(1) 落地 / 移除,無整包重寫。
|
||||
* - 落地用 commit()(在背景緒,安全)→ 送出前確保已在磁碟,crash / 斷電不遺失。
|
||||
* - in-flight 防重送:正在送、等 PUBACK 的單會被標記,drain 不重複送同一筆(避免流量放大)。
|
||||
* - 送出成功(broker PUBACK)才移除;失敗則清 in-flight、累計失敗次數,留待下次 drain。
|
||||
* - 重試上限 / 死信:連續送出失敗達上限的「毒丸」單移入 dead: key(保留資料、不丟),
|
||||
* 停止反覆重送並出聲,交維運處理。
|
||||
* - 補送觸發:連線恢復(onConnected,順帶清 in-flight 防殘留)+ 心跳定時(每 60s)。
|
||||
*
|
||||
* 可靠性:QoS1 + broker PUBACK 後負責後續投遞;後台對同一 flow_id 完全冪等
|
||||
* (serial 前綴防撞號 + 終態短路 + lockForUpdate),故重送 / 亂序皆安全、不會重複扣庫存。
|
||||
*
|
||||
* entryId = flow_id + ":" + status,讓 pending / failed / completed 各自獨立不互蓋。
|
||||
*/
|
||||
public class TransactionOutbox {
|
||||
|
||||
private static final String PREFS = "TransactionOutboxPrefs";
|
||||
private static final String KEY_PREFIX = "tx:"; // 待送出
|
||||
private static final String ATT_PREFIX = "att:"; // 累計送出失敗次數
|
||||
private static final String DEAD_PREFIX = "dead:"; // 死信(達重試上限,保留待維運)
|
||||
/** 連續送出失敗(broker 層)達此上限即移入死信,停止反覆重送。 */
|
||||
private static final int MAX_ATTEMPTS = 100;
|
||||
/** 堆積警戒線:超過即出聲警告(不丟資料),代表網路 / broker 長時間異常。 */
|
||||
private static final int WARN_THRESHOLD = 200;
|
||||
|
||||
private static final Logs logs = new Logs(TransactionOutbox.class);
|
||||
private static final Gson gson = new Gson();
|
||||
|
||||
/** 單一背景執行緒:序列化所有 outbox 操作,不卡 UI、不需鎖。 */
|
||||
private static final ExecutorService outboxExecutor = Executors.newSingleThreadExecutor(r -> {
|
||||
Thread t = new Thread(r, "tx-outbox");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
|
||||
/** 正在送出、等待 PUBACK 的 entryId。只在 outboxExecutor 單一緒存取 → 免鎖。 */
|
||||
private static final Set<String> inflight = new HashSet<>();
|
||||
|
||||
/**
|
||||
* 結案上報:背景緒落地後嘗試送出(成功才移除)。
|
||||
* 呼叫端(出貨 / 付款結案的 UI 主執行緒)只做一次輕量序列化後立即返回,不阻塞、不做磁碟 I/O。
|
||||
*/
|
||||
public static void submit(Context context, TransactionFinalizePayload payload) {
|
||||
if (context == null || payload == null) {
|
||||
return;
|
||||
}
|
||||
final Context app = context.getApplicationContext();
|
||||
final String json;
|
||||
final String entryId;
|
||||
try {
|
||||
json = gson.toJson(payload);
|
||||
String flowId = payload.getFlow_id();
|
||||
String status = (payload.getOrder() != null && payload.getOrder().getStatus() != null)
|
||||
? payload.getOrder().getStatus() : "completed";
|
||||
entryId = (flowId != null ? flowId : "unknown") + ":" + status;
|
||||
} catch (Exception e) {
|
||||
logs.info("TransactionOutbox.submit serialize failed: " + e.getMessage());
|
||||
return;
|
||||
}
|
||||
outboxExecutor.execute(() -> {
|
||||
persist(app, entryId, json);
|
||||
drain(app);
|
||||
});
|
||||
}
|
||||
|
||||
/** 心跳定時呼叫:背景緒補送所有未送出的交易(保留 in-flight 狀態)。 */
|
||||
public static void resumeIfPending(Context context) {
|
||||
if (context == null) {
|
||||
return;
|
||||
}
|
||||
final Context app = context.getApplicationContext();
|
||||
outboxExecutor.execute(() -> drain(app));
|
||||
}
|
||||
|
||||
/** 連線恢復呼叫:先清 in-flight(舊連線在途的送出已失效,避免殘留卡住)再補送。 */
|
||||
public static void onConnected(Context context) {
|
||||
if (context == null) {
|
||||
return;
|
||||
}
|
||||
final Context app = context.getApplicationContext();
|
||||
outboxExecutor.execute(() -> {
|
||||
inflight.clear();
|
||||
drain(app);
|
||||
});
|
||||
}
|
||||
|
||||
// ==== 以下全部只在 outboxExecutor 單一背景緒執行,故無併發、不需鎖 ====
|
||||
|
||||
/** 落地單筆(commit 同步寫磁碟,確保 publish 前已在磁碟)。 */
|
||||
private static void persist(Context app, String entryId, String json) {
|
||||
try {
|
||||
prefs(app).edit().putString(KEY_PREFIX + entryId, json).commit();
|
||||
logs.info("TransactionOutbox persisted " + entryId);
|
||||
int count = countPending(prefs(app).getAll());
|
||||
if (count > WARN_THRESHOLD) {
|
||||
logs.info("TransactionOutbox WARNING: " + count
|
||||
+ " entries pending — MQTT/network likely down for a long time");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logs.info("TransactionOutbox.persist failed: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** 嘗試送出所有未送出且未在途的交易;未連線即停,留待下次 drain。 */
|
||||
private static void drain(Context app) {
|
||||
try {
|
||||
Map<String, ?> all = prefs(app).getAll();
|
||||
int pending = countPending(all);
|
||||
if (pending == 0) {
|
||||
return;
|
||||
}
|
||||
logs.info("TransactionOutbox.drain: " + pending + " pending, " + inflight.size() + " in-flight");
|
||||
for (Map.Entry<String, ?> e : all.entrySet()) {
|
||||
String key = e.getKey();
|
||||
if (key == null || !key.startsWith(KEY_PREFIX)) {
|
||||
continue;
|
||||
}
|
||||
final String entryId = key.substring(KEY_PREFIX.length());
|
||||
if (inflight.contains(entryId)) {
|
||||
continue; // A:已在送、等 PUBACK,不重送
|
||||
}
|
||||
Object v = e.getValue();
|
||||
if (!(v instanceof String)) {
|
||||
continue;
|
||||
}
|
||||
boolean sent = MqttManager.getInstance().rawPublishTransaction(
|
||||
(String) v,
|
||||
() -> outboxExecutor.execute(() -> onSendSuccess(app, entryId)),
|
||||
() -> outboxExecutor.execute(() -> onSendFailure(app, entryId)));
|
||||
if (sent) {
|
||||
inflight.add(entryId);
|
||||
} else {
|
||||
// 未連線:其餘也會失敗,留待下次 drain(連線恢復或下次心跳)。
|
||||
logs.info("TransactionOutbox.drain: not connected, keep pending for next drain");
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logs.info("TransactionOutbox.drain failed: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** 送出成功(PUBACK):清 in-flight、移除該筆與其失敗計數。 */
|
||||
private static void onSendSuccess(Context app, String entryId) {
|
||||
try {
|
||||
inflight.remove(entryId);
|
||||
prefs(app).edit().remove(KEY_PREFIX + entryId).remove(ATT_PREFIX + entryId).apply();
|
||||
logs.info("TransactionOutbox removed " + entryId + " (published)");
|
||||
} catch (Exception e) {
|
||||
logs.info("TransactionOutbox.onSendSuccess failed: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** 送出失敗:清 in-flight、累計失敗;達上限移入死信(保留資料、不丟、出聲)。 */
|
||||
private static void onSendFailure(Context app, String entryId) {
|
||||
try {
|
||||
inflight.remove(entryId);
|
||||
SharedPreferences p = prefs(app);
|
||||
int att = p.getInt(ATT_PREFIX + entryId, 0) + 1;
|
||||
if (att >= MAX_ATTEMPTS) {
|
||||
String json = p.getString(KEY_PREFIX + entryId, null);
|
||||
SharedPreferences.Editor ed = p.edit()
|
||||
.remove(KEY_PREFIX + entryId)
|
||||
.remove(ATT_PREFIX + entryId);
|
||||
if (json != null) {
|
||||
ed.putString(DEAD_PREFIX + entryId, json); // 保留資料供維運處理,不丟棄
|
||||
}
|
||||
ed.apply();
|
||||
logs.info("TransactionOutbox DEAD-LETTER " + entryId + " after " + att
|
||||
+ " failed attempts — moved to dead:, NEEDS OPS ATTENTION");
|
||||
} else {
|
||||
p.edit().putInt(ATT_PREFIX + entryId, att).apply();
|
||||
logs.info("TransactionOutbox send failed " + entryId + " attempt " + att + "/" + MAX_ATTEMPTS);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logs.info("TransactionOutbox.onSendFailure failed: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** 計算待送出(tx:)筆數;傳入已取得的快照避免重複 getAll。 */
|
||||
private static int countPending(Map<String, ?> all) {
|
||||
int n = 0;
|
||||
for (String key : all.keySet()) {
|
||||
if (key != null && key.startsWith(KEY_PREFIX)) {
|
||||
n++;
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
private static SharedPreferences prefs(Context app) {
|
||||
return app.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
|
||||
}
|
||||
}
|
||||
@ -150,7 +150,7 @@ public class BuyDialog extends DialogAbstract {
|
||||
});
|
||||
|
||||
this.binding.buttonNfcPhone.setOnClickListener(v -> {
|
||||
MyApp.getInstance().getReportFlowInfoData().setFlowType(4);
|
||||
MyApp.getInstance().getReportFlowInfoData().setFlowType(10); // 手機感應=payment_type 10,全程一致(避免失敗上報撞後台 4=紙鈔機)
|
||||
gotoDialog();
|
||||
});
|
||||
|
||||
@ -492,7 +492,7 @@ public class BuyDialog extends DialogAbstract {
|
||||
// basic 購物模式(本專案 general/demo) 且為實際付款方式(1/2/3/4/9/30/70) 時:
|
||||
// 為本筆生成新流水號,並上報 pending(「進付款一律留底」)。pending → completed/failed 全程共用此 flow_id。
|
||||
// 通行碼(5)/來店禮(7) 非付款,不在此處理;employee_card(晟崴)/pickup_sheet(中國醫) 無付款不送,後台資料不受影響。
|
||||
boolean realPayment = (flowType == 1 || flowType == 2 || flowType == 3 || flowType == 4
|
||||
boolean realPayment = (flowType == 1 || flowType == 2 || flowType == 3 || flowType == 10
|
||||
|| flowType == 9 || flowType == 30 || flowType == 70);
|
||||
if ("basic".equals(MyApp.getInstance().getShoppingMode()) && realPayment) {
|
||||
flowInfo.setMqttFlowId(com.unibuy.smartdevice.tools.FlowIdGenerator.next(MyApp.getInstance()));
|
||||
@ -509,7 +509,7 @@ public class BuyDialog extends DialogAbstract {
|
||||
case 3:
|
||||
changeDialog(CheckoutEsunPayDialog.class);
|
||||
break;
|
||||
case 4:
|
||||
case 10:
|
||||
changeDialog(CheckoutNfcPhoneDialog.class);
|
||||
break;
|
||||
case 5:
|
||||
@ -539,7 +539,7 @@ public class BuyDialog extends DialogAbstract {
|
||||
switch (flowType) {
|
||||
case 1: return d.canCreditCard(); // 信用卡(刷卡機)
|
||||
case 2: return d.canCardPay(); // 電子票證/卡片支付(刷卡機)
|
||||
case 4: return d.canMobilePay(); // 手機支付(刷卡機)
|
||||
case 10: return d.canMobilePay(); // 手機支付(刷卡機)
|
||||
case 3: return d.canEsunPay(); // 玉山(掃碼)
|
||||
case 30: return d.canTapPay(); // TapPay(掃碼)
|
||||
case 70: return d.canLinePay(); // Line 官方支付(獨立)
|
||||
|
||||
@ -218,7 +218,7 @@ public class BuyPickupCodeDialog extends DialogAbstract {
|
||||
case 3:
|
||||
changeDialog(CheckoutEsunPayDialog.class);
|
||||
break;
|
||||
case 4:
|
||||
case 10:
|
||||
changeDialog(CheckoutNfcPhoneDialog.class);
|
||||
break;
|
||||
case 5:
|
||||
|
||||
@ -108,10 +108,8 @@ public class CheckoutNfcPhoneDialog extends DialogAbstract {
|
||||
if (message.equals("0000")) {
|
||||
MyApp.getInstance().getReportFlowInfoData().setFlowStatusType(1);
|
||||
binding.textStatus.setText(DevNFCPay.recordMessage(message));
|
||||
// NfcPhone(手機支付):刷卡機 NCCC 電文無法區分手機錢包/實體卡,
|
||||
// 依使用者所選付款鈕歸類為 payment_type=10(手機支付)。
|
||||
// 路由鍵 flowType=4 僅用於 BuyDialog 開啟本 Dialog,這裡覆寫為實際上報值。
|
||||
MyApp.getInstance().getReportFlowInfoData().setFlowType(10);
|
||||
// NfcPhone(手機支付):flowType 自 BuyDialog 按鈕起即為 10(手機支付),
|
||||
// 成功/失敗/取消各出口全程一致上報,不再於此覆寫(4 已不再代表手機感應)。
|
||||
// [MQTT Finalize 重構] 支付成功不再呼叫舊 B600(reportFlowInfo);
|
||||
// 直接進出貨流程,由 DispatchDialog 以 MQTT Finalize 統一上報結案(含發票/扣庫存)。
|
||||
changeDispatch();
|
||||
|
||||
@ -375,7 +375,7 @@ public class InvoiceBarcodeDialog extends DialogAbstract {
|
||||
case 3:
|
||||
changeDialog(CheckoutEsunPayDialog.class);
|
||||
break;
|
||||
case 4:
|
||||
case 10:
|
||||
changeDialog(CheckoutNfcPhoneDialog.class);
|
||||
break;
|
||||
case 9:
|
||||
|
||||
@ -401,7 +401,7 @@ public class InvoiceCustomerIdentifierDialog extends DialogAbstract {
|
||||
case 3:
|
||||
changeDialog(CheckoutEsunPayDialog.class);
|
||||
break;
|
||||
case 4:
|
||||
case 10:
|
||||
changeDialog(CheckoutNfcPhoneDialog.class);
|
||||
break;
|
||||
case 9:
|
||||
|
||||
@ -274,7 +274,7 @@ public class InvoiceDonationDialog extends DialogAbstract {
|
||||
case 3:
|
||||
changeDialog(CheckoutEsunPayDialog.class);
|
||||
break;
|
||||
case 4:
|
||||
case 10:
|
||||
changeDialog(CheckoutNfcPhoneDialog.class);
|
||||
break;
|
||||
case 9:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user