[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:
sky121113 2026-06-23 09:09:10 +08:00
parent 8d764ba388
commit ebfa25da72
10 changed files with 266 additions and 21 deletions

View File

@ -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 送出成敗待 callbackfalse=未連線保留於發件箱待下次補送
*/
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 {

View File

@ -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掃碼開啟且玉山子項開啟。 */

View File

@ -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:點數全額折抵
*/

View File

@ -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:" + entryIdO(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);
}
}

View File

@ -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 官方支付獨立

View File

@ -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:

View File

@ -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();

View File

@ -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:

View File

@ -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:

View File

@ -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: