diff --git a/app/src/main/java/com/unibuy/smartdevice/external/mqtt/MqttManager.java b/app/src/main/java/com/unibuy/smartdevice/external/mqtt/MqttManager.java index 5965b65..6d2bdc7 100644 --- a/app/src/main/java/com/unibuy/smartdevice/external/mqtt/MqttManager.java +++ b/app/src/main/java/com/unibuy/smartdevice/external/mqtt/MqttManager.java @@ -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 { diff --git a/app/src/main/java/com/unibuy/smartdevice/structure/DevSetStructure.java b/app/src/main/java/com/unibuy/smartdevice/structure/DevSetStructure.java index a9e9436..70151fa 100644 --- a/app/src/main/java/com/unibuy/smartdevice/structure/DevSetStructure.java +++ b/app/src/main/java/com/unibuy/smartdevice/structure/DevSetStructure.java @@ -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:掃碼開啟且玉山子項開啟。 */ diff --git a/app/src/main/java/com/unibuy/smartdevice/structure/mqtt/TransactionFinalizePayload.java b/app/src/main/java/com/unibuy/smartdevice/structure/mqtt/TransactionFinalizePayload.java index 763ab4a..9ac991a 100644 --- a/app/src/main/java/com/unibuy/smartdevice/structure/mqtt/TransactionFinalizePayload.java +++ b/app/src/main/java/com/unibuy/smartdevice/structure/mqtt/TransactionFinalizePayload.java @@ -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:點數全額折抵。 */ diff --git a/app/src/main/java/com/unibuy/smartdevice/tools/TransactionOutbox.java b/app/src/main/java/com/unibuy/smartdevice/tools/TransactionOutbox.java new file mode 100644 index 0000000..27cc08f --- /dev/null +++ b/app/src/main/java/com/unibuy/smartdevice/tools/TransactionOutbox.java @@ -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 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 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 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 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); + } +} diff --git a/app/src/main/java/com/unibuy/smartdevice/ui/dialog/BuyDialog.java b/app/src/main/java/com/unibuy/smartdevice/ui/dialog/BuyDialog.java index 7004621..ab19e3a 100644 --- a/app/src/main/java/com/unibuy/smartdevice/ui/dialog/BuyDialog.java +++ b/app/src/main/java/com/unibuy/smartdevice/ui/dialog/BuyDialog.java @@ -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 官方支付(獨立) diff --git a/app/src/main/java/com/unibuy/smartdevice/ui/dialog/BuyPickupCodeDialog.java b/app/src/main/java/com/unibuy/smartdevice/ui/dialog/BuyPickupCodeDialog.java index ad3024d..c7992a8 100644 --- a/app/src/main/java/com/unibuy/smartdevice/ui/dialog/BuyPickupCodeDialog.java +++ b/app/src/main/java/com/unibuy/smartdevice/ui/dialog/BuyPickupCodeDialog.java @@ -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: diff --git a/app/src/main/java/com/unibuy/smartdevice/ui/dialog/CheckoutNfcPhoneDialog.java b/app/src/main/java/com/unibuy/smartdevice/ui/dialog/CheckoutNfcPhoneDialog.java index 2be368f..2e160ba 100644 --- a/app/src/main/java/com/unibuy/smartdevice/ui/dialog/CheckoutNfcPhoneDialog.java +++ b/app/src/main/java/com/unibuy/smartdevice/ui/dialog/CheckoutNfcPhoneDialog.java @@ -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(); diff --git a/app/src/main/java/com/unibuy/smartdevice/ui/dialog/InvoiceBarcodeDialog.java b/app/src/main/java/com/unibuy/smartdevice/ui/dialog/InvoiceBarcodeDialog.java index ecfbc19..98d8c7e 100644 --- a/app/src/main/java/com/unibuy/smartdevice/ui/dialog/InvoiceBarcodeDialog.java +++ b/app/src/main/java/com/unibuy/smartdevice/ui/dialog/InvoiceBarcodeDialog.java @@ -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: diff --git a/app/src/main/java/com/unibuy/smartdevice/ui/dialog/InvoiceCustomerIdentifierDialog.java b/app/src/main/java/com/unibuy/smartdevice/ui/dialog/InvoiceCustomerIdentifierDialog.java index e713aea..b67f1d0 100644 --- a/app/src/main/java/com/unibuy/smartdevice/ui/dialog/InvoiceCustomerIdentifierDialog.java +++ b/app/src/main/java/com/unibuy/smartdevice/ui/dialog/InvoiceCustomerIdentifierDialog.java @@ -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: diff --git a/app/src/main/java/com/unibuy/smartdevice/ui/dialog/InvoiceDonationDialog.java b/app/src/main/java/com/unibuy/smartdevice/ui/dialog/InvoiceDonationDialog.java index 938a702..bc23f96 100644 --- a/app/src/main/java/com/unibuy/smartdevice/ui/dialog/InvoiceDonationDialog.java +++ b/app/src/main/java/com/unibuy/smartdevice/ui/dialog/InvoiceDonationDialog.java @@ -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: