Kaynağa Gözat

修改显示发票

qmj 2 hafta önce
ebeveyn
işleme
31cb16a431

Dosya farkı çok büyük olduğundan ihmal edildi
+ 2 - 0
.claude/homunculus/observations.jsonl


+ 20 - 0
CLAUDE.md

@@ -19,6 +19,26 @@ Auto-generated from all feature plans. Last updated: 2026-05-15
 
 <!-- MANUAL ADDITIONS START -->
 
+## 已废弃代码清单(订单/支付,勿作为改动基准)
+
+以下代码**仍存在于仓库中但已废弃**,排查问题或新增功能时**不要参考其逻辑、不要在其上做增量改动**,否则会改到死代码:
+
+| 文件 | 状态 | 备注 |
+|------|------|------|
+| `ZaloPayController.java` | 整体废弃 | ZaloPay 支付已下线 |
+| `PayController.java` | VNPay 回调及多数方法废弃 | **特例**:`sendAcceptRiderPush(...)` 仍被 `PosOrderController` 货到付款路径与 `NewebpayPayController`(未启用)引用;迁移推迟到接入新支付时 |
+| `TestTask.java` | 定时任务全部废弃 | 含 refundProcessing / 抽成返还 / 自动开店 / 超时退款;相关推送当前均不存在 |
+| `OrderAppealController.java` | 取消/申诉整片注释 | 仅留 `parseLocale`;取消/申诉推送全部失效 |
+
+**当前在用的订单操作入口(状态流转/推送以此为准):**
+- `PosOrderShOprateController` — 商家操作(出餐 chuCan、商家取消)
+- `PosOrderQsOprateController` — 骑手操作(接单/取餐/送达,按 `deliveryStatus` 1/2/3)
+- `UserOrderController` — 用户操作(下单、用户取消)
+- `NewebpayPayController` — 蓝新金流支付,**代码就绪但尚未启用**;支付链路含 `sendAcceptRiderPush` 迁移,等接入新支付时再做
+- `PosOrderController` — 仅 `/addorder`(下单,含货到付款 sendHdfkMessage)、`/setorderuzt`(平台改状态)及 list 查询在用;订单状态流转已迁出
+
+**推送通道:** `PayPush` 无 Android FCM(cte_server 有,本项目砍掉),仅 iOS uni 云函数(`msduser/msdrider/msdstore`)+ 入库(`PushEvent`→`push_message`);`cidType` 形同虚设(到处传 `""`)。详见 `specs/012-im-user-integration/code-status.md`。
+
 ## 前端多语言(i18n)添加规范
 
 **所有前端新功能必须实现多语言,不例外。** 平台管理端(foodie-admin-vue)和商家端(foodie-store) 新增的任何面向用户的文字都必须使用 `$t()` 而非硬编码中文。

+ 70 - 0
docs/order-push-overview.md

@@ -0,0 +1,70 @@
+# 訂單推送說明(測試參考)
+
+**最後更新**: 2026-06-25
+**用途**: 列出訂單流程中所有會觸發推送的場景,供測試驗證。
+
+> 本文推送文案以**繁體中文**展示。實際推送時文案會跟隨接收人的語言設置自動切換(支援 簡中 / 越南語 / 英文 / 繁中),但下方表格統一以繁中舉例。
+
+---
+
+## 一、推送基本說明
+
+- 每條推送會出現在**兩個地方**:① 手機通知欄(僅 iOS);② App 內「消息中心」列表(iOS / Android 都有)。
+- **Android 手機沒有通知欄推送**(暫未接入 Android 推送通道),只能在 App 內消息列表看到。
+- 推送語言**跟隨接收人的語言設置**(簡中 / 越南語 / 英文 / 繁中),同一場景不同語言賬號看到的文案不同。
+- 正文中的 `{訂單號}` 是實際訂單編號,會替換成真實號碼。
+
+---
+
+## 二、推送場景
+
+| # | 觸發操作 | 推送對象 | 標題 | 通知正文(繁中示例) |
+|---|----------|----------|------|---------------------|
+| 1 | 用戶下單成功 | 商家 | 消息 | 有新訂單,NO:{訂單號} |
+| 2a | 貨到付款下單成功 | 商家 | 消息 | 有新訂單 |
+| 2b | 貨到付款下單成功 | 附近騎手 | 消息 | 有新訂單 |
+| 3a | 商家點擊「出餐」 | 用戶 | 消息 | 商家已經出餐,訂單號:,NO:{訂單號} |
+| 3b | 商家點擊「出餐」 | 騎手 | 消息 | 商家已經出餐,訂單號:,NO:{訂單號} |
+| 4a | 騎手點擊「接單」 | 用戶 | 消息 | 騎手已接單,NO:{訂單號} |
+| 4a′ | 騎手點擊「接單」 | 商家 | 消息 | 騎手已接單,NO:{訂單號} |
+| 4b | 騎手點擊「取餐」(開始配送) | 用戶 | 消息 | 騎手配送中,NO:{訂單號} |
+| 4c | 騎手點擊「送達」 | 用戶 | 消息 | 騎手已送達,NO:{訂單號} |
+| 5a | 平台後台把訂單改為「騎手已接單」 | 用戶 | 消息 | 騎手已接單,NO:{訂單號} |
+| 5a′ | 平台後台把訂單改為「騎手已接單」 | 商家 | 消息 | 騎手已接單,NO:{訂單號} |
+| 5b | 平台後台把訂單改為「配送中」 | 用戶 | 消息 | 騎手配送中,NO:{訂單號} |
+| 5c | 平台後台把訂單改為「已送達」 | 用戶 | 消息 | 騎手已送達,NO:{訂單號} |
+
+> 說明:場景 4 是騎手在騎手端 App 操作;場景 5 是平台後台人工改訂單狀態。兩者推送的文案一樣。
+
+---
+
+## 三、推送文案對照(繁中)
+
+| 場景 / 含義 | 繁體中文(zh_TW) |
+|-------------|------------------|
+| 標題(所有推送通用) | 消息 |
+| 有新訂單 | 有新訂單 |
+| 商家已經出餐 | 商家已經出餐,訂單號: |
+| 騎手已接單 | 騎手已接單 |
+| 騎手配送中 | 騎手配送中 |
+| 騎手已送達 | 騎手已送達 |
+
+---
+
+## 四、測試驗證要點
+
+每個場景觸發後,按接收人登錄對應端(用戶 App / 商家端 / 騎手端)檢查:
+
+1. **iOS**:通知欄收到推送 + App 內消息列表有新記錄。
+2. **Android**:App 內消息列表有新記錄(通知欄**不會**有,屬正常)。
+3. **文案**:標題和正文與上表一致;正文末尾的訂單號與實際訂單一致。
+4. **多語言**:把接收賬號切到不同語言,文案應隨之變成對應語言。
+5. **接收人**:確認只有「推送對象」列裡的人收到,其他人不收到。
+
+---
+
+## 五、已知現狀(測試時知悉即可,不算 Bug)
+
+- **貨到付款下單(場景 2)的通知不帶訂單號**:商家 / 騎手通知欄只看到「有新訂單」,看不到具體訂單號(點擊進 App 仍可看到訂單詳情)。
+- **商家出餐文案略冗餘**:顯示為 `商家已經出餐,訂單號:,NO:123`(冒號和逗號連在一起),現狀如此。
+- **暫不存在的推送**(本次不在範圍,測試時不用驗證):取消訂單、退款、超時無騎手、抽成返還、自動恢復營業等通知,以及 Android 通知欄推送。

+ 26 - 10
ruoyi-admin/src/main/java/com/ruoyi/app/order/OrderInvoiceService.java

@@ -411,16 +411,32 @@ public class OrderInvoiceService {
                 && Integer.valueOf(1).equals(ez.getIsEnabled());
     }
 
-    /** 场景入参校验(service 内强校验,拦截非法请求不调 ezPay)。 */
+    /**
+     * 场景入参校验(service 内强校验,拦截非法请求不调 ezPay)。对齐 ezPay 开票模型:
+     * B2B:统编(8位)+邮箱必填、无载具;B2C:载具(0/1/2)+号码必填,ezPay会员载具(2)时邮箱必填。
+     */
     private void validateInvoiceInput(ApplyInvoiceDto dto) {
         if ("B2B".equals(dto.getCategory())) {
             if (StrUtil.isBlank(dto.getBuyerUbn()) || !dto.getBuyerUbn().matches("\\d{8}")) {
                 throw new ServiceException("统编格式不正确,须为 8 位数字");
             }
-        }
-        // B2C 无载具时邮箱可选(PrintFlag=Y,客户在系统查看发票);载具号码随 carrierType 必填
-        if (StrUtil.isNotBlank(dto.getCarrierType()) && StrUtil.isBlank(dto.getCarrierNum())) {
-            throw new ServiceException("载具号码不能为空");
+            if (StrUtil.isBlank(dto.getBuyerEmail())) {
+                throw new ServiceException("B2B 发票须填写买方邮箱");
+            }
+            if (StrUtil.isNotBlank(dto.getCarrierType()) || StrUtil.isNotBlank(dto.getCarrierNum())) {
+                throw new ServiceException("B2B 发票不支持载具");
+            }
+        } else if ("B2C".equals(dto.getCategory())) {
+            String ct = dto.getCarrierType();
+            if (!"0".equals(ct) && !"1".equals(ct) && !"2".equals(ct)) {
+                throw new ServiceException("B2C 发票须选择载具类型(0手机条码/1自然人凭证/2ezPay会员)");
+            }
+            if (StrUtil.isBlank(dto.getCarrierNum())) {
+                throw new ServiceException("载具号码不能为空");
+            }
+            if ("2".equals(ct) && StrUtil.isBlank(dto.getBuyerEmail())) {
+                throw new ServiceException("ezPay 会员载具须填写买方邮箱");
+            }
         }
     }
 
@@ -433,17 +449,17 @@ public class OrderInvoiceService {
         inv.put("BuyerName", dto.getBuyerName());
         if ("B2B".equals(dto.getCategory())) {
             inv.put("BuyerUBN", dto.getBuyerUbn());
+            inv.put("BuyerEmail", dto.getBuyerEmail());   // B2B 邮箱必填:ezPay 发开立通知给买方,凭此查看
             inv.put("PrintFlag", "Y");
-        } else if (StrUtil.isNotBlank(dto.getCarrierType())) {
+        } else {
+            // B2C 载具必填(validateInvoiceInput 已校验 carrierType∈0/1/2、号码非空),PrintFlag=N
             inv.put("CarrierType", dto.getCarrierType());
             inv.put("CarrierNum", dto.getCarrierNum());
             inv.put("PrintFlag", "N");
-        } else {
-            // B2C 无载具:有邮箱则发送,无邮箱则不传(PrintFlag=Y,客户在系统查看)
-            if (StrUtil.isNotBlank(dto.getBuyerEmail())) {
+            // ezPay 会员载具(2)需邮箱;手机条码(0)/自然人凭证(1)票进载具,邮箱留空
+            if ("2".equals(dto.getCarrierType()) && StrUtil.isNotBlank(dto.getBuyerEmail())) {
                 inv.put("BuyerEmail", dto.getBuyerEmail());
             }
-            inv.put("PrintFlag", "Y");
         }
         inv.put("TaxType", "1");
         inv.put("TaxRate", "5");

+ 2 - 2
ruoyi-system/src/main/java/com/ruoyi/system/domain/dto/ApplyInvoiceDto.java

@@ -32,11 +32,11 @@ public class ApplyInvoiceDto {
     @Pattern(regexp = "\\d{8}", message = "统一编号必须为 8 位数字")
     private String buyerUbn;
 
-    /** 接收邮箱(B2C 无载具时可选:填了发邮件,不填则客户在系统查看发票) */
+    /** 买方邮箱(B2B 必填;B2C 选 ezPay会员载具(2)时必填,其余可空) */
     @Email(message = "邮箱格式不正确")
     private String buyerEmail;
 
-    /** 载具类型 0手机条码/1自然人凭证/2ezPay会员(可空) */
+    /** 载具类型 0手机条码/1自然人凭证/2ezPay会员(B2C 必填,B2B 不传) */
     private String carrierType;
 
     /** 载具号码(carrierType 非空时必填,service 内校验) */

+ 16 - 21
specs/010-order-invoice/contracts/api.md

@@ -25,47 +25,42 @@
 | carrierType | String | 电子载具类型 | `0`=手机条码 / `1`=自然人凭证 / `2`=ezPay 会员载具;**留空 = 不用载具** |
 | carrierNum | String | 载具号码 | 随 carrierType:手机条码以 `/` 开头(`/ABC1234`);自然人凭证 `2字母+14数字`;ezPay 会员载具填会员账号 |
 
-**② 什么时候必填(按场景)**
+**② 什么时候必填(按场景,对齐 ezPay 开票模型)**
 
 | 场景 | buyerUbn | buyerEmail | carrierType + carrierNum |
 |------|----------|------------|--------------------------|
-| **B2B 公司发票** | ✅ 必填(8 位数字) | ⚪ 可不填(B2B 默认打印,填了不生效) | ⚪ 可不填(B2B 不走载具) |
-| **B2C 个人 / 邮箱接收** | ❌ 不填 | ✅ 填(发邮件) | ❌ 不填 |
-| **B2C 个人 / 用载具** | ❌ 不填 | ⚪ 可不填(走载具就不发邮箱) | ✅ 必填(类型 + 号码都要) |
-| **B2C 个人 / 系统查看** | ❌ 不填 | ❌ 不填 | ❌ 不填(PrintFlag=Y,靠发票号在系统查) |
+| **B2B 公司发票** | ✅ 必填(8 位数字) | ✅ 必填(ezPay 发开立通知给买方,凭此查看) | ❌ 不填(B2B 不走载具,传了报错) |
+| **B2C / 手机条码载具(0)** | ❌ 不填 | ⚪ 可不填(票进载具) | ✅ 必填(类型 0 + 号码) |
+| **B2C / 自然人凭证载具(1)** | ❌ 不填 | ⚪ 可不填(票进载具) | ✅ 必填(类型 1 + 号码) |
+| **B2C / ezPay会员载具(2)** | ❌ 不填 | ✅ 必填(ezPay 规则:会员载具须带邮箱) | ✅ 必填(类型 2 + 号码) |
 
 > 记忆口诀:
-> - `B2B` → 只要 **统编 buyerUbn**,其余(邮箱/载具)不用管
-> - `B2C` → 邮箱 / 载具 / 都不填 **三选一**:填邮箱→发邮件;填载具→存载具;都不填→客户在系统查看发票(PrintFlag=Y)
-> - 只要 `carrierType` 有值,`carrierNum` 就必须跟着填,否则报"载具号码不能为空"
-> - ⚠️ 「系统查看」模式依赖 ezPay 接受空 BuyerEmail,上线前需在测试环境验证;若 ezPay 拒绝则回落为必填邮箱
+> - `B2B` → **统编 + 邮箱**必填,**不传载具**
+> - `B2C` → **载具必填**(0/1/2 三选一,不再支持"都不填");其中 ezPay会员载具(2) 还须带邮箱,其余两种载具邮箱留空
+> - 只要 `carrierType` 有值,`carrierNum` 就必须跟着填。
+> - 已取消原「B2C 都不填 / 系统查看」模式(票无送达渠道、客户拿不到);原"ezPay 是否接受空 BuyerEmail"待办随之取消
 
-**③ 种场景请求示例**
+**③ 种场景请求示例**
 
-B2B 公司发票:
+B2B 公司发票(统编 + 邮箱)
 ```json
-{ "orderId": 1001, "category": "B2B", "buyerName": "美食達有限公司", "buyerUbn": "12345678" }
+{ "orderId": 1001, "category": "B2B", "buyerName": "美食達有限公司", "buyerUbn": "12345678", "buyerEmail": "ms@example.com" }
 ```
 
-B2C 个人 / 邮箱接收:
-```json
-{ "orderId": 1001, "category": "B2C", "buyerName": "王小明", "buyerEmail": "xm@example.com" }
-```
-
-B2C 个人 / 手机条码载具:
+B2C / 手机条码载具(载具 0,邮箱留空):
 ```json
 { "orderId": 1001, "category": "B2C", "buyerName": "王小明", "carrierType": "0", "carrierNum": "/ABC1234" }
 ```
 
-B2C 个人 / 系统查看(不填邮箱、不用载具):
+B2C / ezPay会员载具(载具 2 + 邮箱):
 ```json
-{ "orderId": 1001, "category": "B2C", "buyerName": "王小明" }
+{ "orderId": 1001, "category": "B2C", "buyerName": "王小明", "carrierType": "2", "carrierNum": "C1597485444", "buyerEmail": "xm@example.com" }
 ```
 
 **处理**:校验订单归属与可开票(门店 ezPay 已开通启用、非免用发票)→ 金额拆分 → 组装明细 → 调 `EzPay.issueInvoice` → 落库发票号/随机码/凭证。
 
 **响应** `AjaxResult`:
-- 成功 `{code:200, msg:"开票成功", data:{invoiceNumber, invoiceUrl}}`
+- 成功 `{code:200, msg:"开票成功", data:{invoiceNumber, randomNum, invoiceTransNo}}`
 - 失败 `{code:500, msg:"<可理解原因>"}`(统编格式非法 / 门店不可开票 / 已开票 / ezPay 失败原因)
 
 ### GET `/system/userOrder/getInvoice?orderId=`

+ 4 - 1
specs/010-order-invoice/data-model.md

@@ -25,7 +25,10 @@
 | sales_amt | INT | 销售额(未税,= round(total/1.05)) |
 | tax_amt | INT | 税额(= total - sales) |
 | fail_reason | VARCHAR(500) | 开票/作废失败原因 |
-| invoice_url | VARCHAR(500) | 发票查看凭证 / 链接 |
+| invoice_trans_no | VARCHAR(64) | ezPay 交易流水号 InvoiceTransNo(开立回应返回) |
+| invoice_bar_code | VARCHAR(255) | 发票条码码值(PrintFlag=Y 时 ezPay 返回,兑奖扫描用) |
+| invoice_qrcode_l | VARCHAR(255) | 发票左二维码码值(PrintFlag=Y 时返回,含发票核心信息+加密防伪) |
+| invoice_qrcode_r | VARCHAR(255) | 发票右二维码码值(PrintFlag=Y 时返回,含中文商品明细) |
 | apply_time | DATETIME | 客户申请开票时间 |
 | issue_time | DATETIME | 开立成功时间 |
 | invalid_time | DATETIME | 作废时间 |

+ 134 - 0
specs/010-order-invoice/research.md

@@ -118,3 +118,137 @@ ezPay `Amt`=销售额、`TaxAmt`=税额、`TotalAmt`=含税总额,三者满足
 - 否则(`Status != SUCCESS` 或含 KEY1/INV/LIB 错误前缀)→ 失败,记 `Message` 到 `fail_reason`,状态置失败,订单不变。
 
 **Rationale**: 与 009 凭证验证(看 KEY1)不同,开立以 `Status=SUCCESS` 为准。错误前缀见记忆规格(KEY1 加解密 / INV 发票业务 / LIB 重复状态 / NOR 网络)。
+
+## D10. 管理后台「发票明细」展示范围(不复制 ezPay 控制台)
+
+**Decision**: 后台发票明细页**只展示「开票成功已落库信息 + 订单信息」**,不复制截图(ezPay 商家后台控制台)的完整布局。展示字段集:
+
+| 区块 | 字段 | 来源 |
+|------|------|------|
+| 发票核心 | 发票号码、防伪随机码、发票种类(B2B/B2C)、开立时间、发票状态 | `pos_order_invoice` |
+| 买方 | 买方名称、买方统编(B2B)、接收邮箱、载具类型+号码 | `pos_order_invoice` |
+| 金额 | 销售额、税额、含税总额 | `pos_order_invoice` |
+| 商品明细 | 品名、数量、单位、单价、小计 | 开票时存 `ItemDetail` JSON 或重新解析 `pos_order.food` |
+
+**不展示**(截图有、本期不做):卖方营业名称/统编/会员编号、上传状态、中奖注记、目前可折让金额、折让单号、通知表、BarCode/QRcode、Kiosk列印、报关标记、发票备注。
+
+**Rationale**:
+- 截图是 **ezPay 自己的商家后台控制台**,服务于营业人管理发票全生命周期(折让/中奖/上传/通知),信息密度对我们平台运营是噪音。
+- 卖方信息冗余:运营按订单/门店 scoped 进明细页,卖方上下文已知。
+- 上传状态/中奖/折让/通知:要么 `invoice_search` 不返回(中奖/可折让/通知),要么低价值(上传状态隔日才更新),要么需额外基础设施。
+- 结果:**明细页纯本地查询,不调 `invoice_search`**,秒开、不依赖 ezPay 可用性。
+
+**Alternatives**: 完整复制控制台 → 需调 `invoice_search` + 折让/中奖额外接口,且多个关键字段拿不到,过度设计,不采用。
+
+**已定(暂不存 ItemDetail)**:当前明细页(`selectInvoiceDetail` → `PosOrderInvoiceVo`)不展示逐商品行项,故无需存。日后若明细页要展示商品明细,再考虑开票时存一份(避免优惠按比例分摊后、行项与总额对不上)。
+
+---
+
+## D11. ezPay 不返回发票图/URL;`invoice_url` 是空壳字段
+
+**Finding**: 核实官方手册 `EZP_INVI_1.2.1`——开立(`invoice_issue`)与查询(`invoice_search`)回应**均不含任何发票图片/PDF/URL 字段**。开立回应实际只返回:
+
+```
+MerchantID / InvoiceTransNo / MerchantOrderNo / TotalAmt / InvoiceNumber / RandomNum
+CreateTime / CheckCode / BarCode / QRcodeL / QRcodeR   (后三个仅 PrintFlag=Y 时返回)
+```
+
+**Consequence & 处置(2026-06-22 已落地)**:原 `invoice_url` 确属空壳(ezPay issue/search 回应均不返回 URL/图片)。团队已按「改存 ezPay 真返回码值」处理:`invoice_url` 改名为 `invoice_trans_no`(InvoiceTransNo),新增 `invoice_bar_code`/`invoice_qrcode_l`/`invoice_qrcode_r`(PrintFlag=Y 时 ezPay 返回的 BarCode/QRcodeL/R)。已核实实体 `PosOrderInvoice` + `OrderInvoiceService.applyInvoice`/`reIssueAndSave` 均从 issue 回应取值落库;`data-model.md` 已同步。明细页「凭证/链接」一行 → 改展示发票号/随机码/交易流水号。
+
+**客户查看发票的三条路径**(能否给可点链接见 D12,能否打印见 D13):
+
+| 方式 | 机制 | 我们能否给可点链接 |
+|------|------|--------------------|
+| 邮箱(无载具) | 开票填 `BuyerEmail` → ezPay 自动发通知邮件,发票在邮件里 | ❌ 邮件由 ezPay 发、链接用平台密钥,复刻不了(D12) |
+| 载具 | 选手机条码/自然人凭证/ezPay会员载具 → 票存进载具 → 客户自己去财政部/ezPay 查(见「附:载具概念」) | ❌ 票在客户账户,我们只存发票号 |
+| 都不填(PrintFlag=Y) | 无自动送达渠道,票只在 ezPay/财政部平台,商家需手动打印交付 | ❌ 且外卖场景建议禁用此模式(见下) |
+
+**⚠️ 「都不填」模式建议禁用**:客户远程收餐无法当面递纸票,而该模式 ezPay 不发邮件、不进载具,票「悬空」、客户拿不到。建议 B2C 开票强制「邮箱 或 载具 至少填一个」,顺带消除 `contracts/api.md` 那条「ezPay 是否接受空 BuyerEmail」的待验证项。B2B 不受影响(PrintFlag=Y、靠统编报账)。
+
+> 订正:本节早先版本写过「均无静态链接可存」,过于绝对——准确结论见 D12(邮件链接不可复刻,但 `DisplayFlag=1` 查询链接可用商户密钥生成)。
+
+---
+
+## D12. 发票「查看链接」能否自动生成(邮件链接 vs DisplayFlag=1)
+
+**背景**:客户收到的邮件里有一个 `https://cinv.ezpay.com.tw/invoice_index/search_platform?PostData=<密文>` 链接,点开即展示发票。问:我们能否自己生成同类链接?
+
+**结论**:**邮件那个链接复刻不了;但 `/Api/invoice_search` + `DisplayFlag=1` 这条我们能生成。**
+
+| 链接来源 | 加密密钥 | 我们能生成 | 效果 |
+|----------|----------|------------|------|
+| 邮件 `invoice_index/search_platform` | ezPay **平台**密钥(推测) | ❌ 不能 | GET URL,点开展示发票 |
+| `/Api/invoice_search` + `DisplayFlag=1` | **商户** HashKey/HashIV | ✅ 能 | Form POST 跳 ezPay 页面展示发票 |
+
+**验证(邮件链接不可复刻)**:邮件 PostData = 96 字节 = 3×32 块(ezPay AES block-32 格式特征)。用商户密钥(`J3ln4b6axMafUE1HMTF3boeqTCoD5Ia5` / `CgabhUpEbwpoATlP`)解密 → 高熵乱码(96 字节仅 37 个可打印 ASCII,pad=42 非法),CBC/ECB/零IV 三种变体可打印率均低;AES-CBC round-trip 自检通过 → **证明商户密钥解不开,邮件链接用的是另一把(平台)密钥**。
+
+**可生成路径(DisplayFlag=1)**:手册第八章,`invoice_search` 带 `DisplayFlag=1` 时 ezPay「以網頁顯示發票查詢結果」。明文示例:
+```
+RespondType=JSON&Version=1.3&TimeStamp=<ts>&SearchType=0
+&InvoiceNumber=<发票号>&RandomNum=<随机码>&DisplayFlag=1
+```
+用 `EzPayEncryptUtil.encrypt` + 商户密钥加密 → Form POST `MerchantID_`+`PostData_` 到 `/Api/invoice_search`。
+
+**落地形态**:是 **Form POST 不是 GET URL**,没法存成超链接直接发。方案:点「查看发票」时后端即时生成 PostData(TimeStamp 现生成、防过期)→ 返回自动提交的小页 → 跳 ezPay 展示。密钥留后端,前端只拿 URL。
+
+**⚠️ 待实测确认**(已生成测试表单 `%TEMP%\ezpay_view_test.html`,用测试票 `DK00000003/5071` + 商户密钥):
+1. 点开是否正常展示发票(不报错);
+2. 落地页是否与邮件链接同页/相似;
+3. 是否要求登 ezPay(决定能否当客户-facing 链接)。
+若要求登录或失效 → 此路仅限内部/商家用,客户-facing 仍走邮箱/载具。
+
+---
+
+## D13. 发票打印:无 API/模板 + B2C 列印限制 + 交付路径
+
+**来源**:官方手册 4 份 + ezPay QA 页(`cinv.ezpay.com.tw/Invoice_index/qa`)。
+
+**结论:ezPay 不提供打印 API、无可下载模板、无发票图片/PDF 接口。「拿样票装数据打印」这条路不存在。**
+
+**打印的实际形态(QA 第八章「列印發票作業」)**:
+- **纯手动后台操作**:商家登 ezPay 后台「列印電子發票」→ 勾选 → 点列印 → **输入登入密码** → 生成 PDF。非 API。
+- **B2C 列印 1 次限制(財政部規定)**:非首次列印必须加註「補印」,否则责任商家自负。
+- **B2C 买方不能列印**:平台「僅供買受人查詢發票資訊,無法提供買受人列印服務」(防重复兑奖)。B2B 买方可在「電子發票查詢專區」凭发票号/日期/随机码列印證明聯。
+- **「樣張」≠模板**:QA 第二章「發票證明聯樣張」是商家在测试区开票后用「熱感列印」产出的 PDF,用于**字轨申请交国税局**——是打印**输出**,非可填充的**输入模板**。数据开票时已存 ezPay,打印只渲染 ezPay 内数据。
+
+**前端实现现状(平台后台 orderInvoice 详情页)**:详情页对已开票会渲染发票防伪码——用存的 `invoiceBarCode/QRcodeL/R`(ezPay 开立回应返回、PrintFlag=Y 才有)画出二维码+条码供**查看**;已去掉下载 PNG / 打印按钮(避免 D13 担心的自行打印/補印合规风险)。
+- 因「B2C 必填载具 → PrintFlag=N」(见 D11),**B2C 票无码值** → 详情显示「已存入载具,无纸本凭证」,不画码。
+- **仅 B2B(PrintFlag=Y)**有码值 → 渲染 QR/条码展示。B2B 无法规列印次数限制,码值为 ezPay 官方防伪码,仅展示、不打印,合规。
+- 结论:自渲染**展示**可接受(只读、B2B 限定);自渲染**打印/下载**已移除。原 D13「不建议自行渲染打印」订正为此。
+
+**发票交付的现实路径(就这三条,无「自己打印」)**:
+
+| 场景 | 怎么把发票给到人 | 谁操作 |
+|------|------------------|--------|
+| B2C | 邮箱(ezPay 发邮件)/ 载具(客户去财政部·ezPay 查) | ezPay 自动 / 客户自取 |
+| B2B | 商家在 ezPay 后台手动印 PDF 交买方;或买方在查询专区自印 | 商家 / 买方 |
+| B2C 中奖票 | 商家中奖后在 ezPay 后台印熱感紙證明聯给买方兑奖 | 商家手动 |
+
+**对外卖平台的落地建议**:B2C 走邮箱/载具送达(不需、也不能随便打印);B2B 打印让商家去 ezPay 后台做(或我们做个「跳转 ezPay 后台」快捷入口,印的动作仍在 ezPay)。平台自渲染/打印发票不建议做。
+
+---
+
+## 附:载具(Carrier)业务概念(参考)
+
+**载具 = 电子发票的「钱包/账户」。** 发票不印纸、不发文件,而是像存钱一样「存」进客户的某个发票账户;客户看的不是「一张票」,而是「去自己账户翻这笔记录」。
+
+- **纸质发票**:商家印纸给你,票在手上(物理持有)。
+- **载具发票**:商家不给任何东西,把发票数据上报财政部电子发票平台、打上「属于载具号 XXX」标记;票以记录形式躺在该载具名下。
+
+ezPay `Category=B2C` 三种载具(`CarrierType`):
+
+| 载具 | 号码 | 票存于 |
+|------|------|--------|
+| `0` 手机条码 | `/ABC1234`(/开头) | 财政部电子发票平台,绑手机 |
+| `1` 自然人凭证 | `2字母+14数字` | 财政部平台,绑身份证 |
+| `2` ezPay会员载具 | ezPay会员号(如截图 `C1597…`) | ezPay 系统 |
+
+- **「存进载具」**:票上报到财政部/ezPay 平台、标记归属某载具号;选载具则 `PrintFlag=N`(不打印)。
+- **「客户自己去查」**:票存在财政部/ezPay、不在我们手里,我们只存了发票号、给不出票图。客户须登票据所在地查看:手机条码/自然人凭证 → 财政部电子发票整合服务平台(einvoice.nat.gov.tw)或「财政部发票夹」App;ezPay会员载具 → ezPay App/网站。
+- **连带好处(中奖)**:用载具的票,开奖时财政部自动比对、奖金拨入载具绑定账户,客户无需持纸票兑奖——这也是台湾主推载具的原因。
+
+> 一句话:载具=客户在政府/ezPay 的「发票账户」;存进载具=票打进该账户;自己去查=客户登该账户才能看到票。这是明细页「无发票链接可显示」的根本原因。
+
+### 补充:查询发票明细接口(invoice_search)能力备忘
+
+ezPay **有**查询发票明细的接口 `/Api/invoice_search`(v1.3),工具类已封装 `EzPay.searchInvoice(baseUrl, cfg, invoiceNumber, randomNum)`(`ruoyi-admin/.../utils/ezPay/EzPay.java`),按 D10 决策本期业务层不调用它。其回应字段(手册第八章)覆盖:MerchantID/InvoiceTransNo/MerchantOrderNo/InvoiceNumber/RandomNum/BuyerName/BuyerUBN/BuyerAddress/BuyerPhone/BuyerEmail/InvoiceType/Category/TaxType/TaxRate/Amt(含AmtSales/Zero/Free)/TaxAmt/TotalAmt/CarrierType/CarrierNum/LoveCode/PrintFlag/KioskPrintFlag/CreateTime/ItemDetail(JSON)/InvoiceStatus/UploadStatus/CheckCode/BarCode/QRcodeL/QRcodeR。**注意**:中奖注记、目前可折让金额、通知状态、折让单号该接口**不返回**(属 ezPay 控制台聚合 / 折让接口范畴)。日后若需「确认上传状态」等 live 字段再启用此接口。

+ 47 - 0
specs/012-im-user-integration/code-status.md

@@ -0,0 +1,47 @@
+# Code Status: 订单/支付代码现状(废弃与在用)
+
+**Date**: 2026-06-25
+**Scope**: 全局项目现状笔记(挂在 012-im-user-integration 目录下,供后续开发参考)
+
+排查问题或新增功能时,务必区分"已废弃代码"与"在用入口"。仓库里有一批支付/订单类**代码还在但已不再使用**,参考它们的逻辑或在其上做增量改动都会改到死代码。
+
+## 已废弃(勿参考、勿在其上改动)
+
+| 文件 | 状态 | 备注 |
+|------|------|------|
+| `ZaloPayController.java` | 整体废弃 | ZaloPay 支付已下线 |
+| `PayController.java` | VNPay 回调及多数方法废弃 | **特例**:`sendAcceptRiderPush(...)` 仍被 `PosOrderController` 货到付款路径与 `NewebpayPayController`(未启用)引用;迁移推迟到接入新支付时 |
+| `TestTask.java` | 定时任务全部废弃 | 含 refundProcessing / 抽成返还 / 自动开店 / 超时退款;相关推送当前均不存在 |
+| `OrderAppealController.java` | 取消/申诉逻辑整片注释 | 仅留 `parseLocale`;取消/申诉推送全部失效 |
+
+## 订单操作在用入口(状态流转/推送以此为准)
+
+- **`PosOrderShOprateController`** — 商家操作:出餐 `chuCan`(推用户+骑手)、商家取消
+- **`PosOrderQsOprateController`** — 骑手操作:接单/取餐/送达,按 `deliveryStatus` 1/2/3(推用户,接单时推商家)
+- **`UserOrderController`** — 用户操作:下单(推商家"新订单")、用户取消
+- **`PosOrderController`** — 仅 `/addorder`(下单,含货到付款 `sendHdfkMessage`)、`/setorderuzt`(平台改状态)及各类 list 查询在用;订单状态流转操作已迁出至上列三个 Controller
+- **`NewebpayPayController`** — 蓝新金流支付,**代码就绪但尚未真正启用**;启用前支付成功推送不触发,`sendAcceptRiderPush` 迁移等接入新支付时再做
+
+## 推送通道现状
+
+- `PayPush` 封装 `userPush / shPush / qsPush`(及 `*HandleLocal` 带 i18n 版)。
+- **无 Android FCM 通道**(cte_server 有 `FCMPush` + channelId qsRing/sjRing/yhRing,本项目已砍掉);仅 iOS uni 云函数(`msduser/msdrider/msdstore`)+ 入库(`PushEvent`→`push_message` 表,App 内消息中心)。
+- `cidType` 参数目前形同虚设,调用处一律传 `""`。
+- 异步推送用 `AsyncManager.me().execute(TimerTask)`(cte 用 `@Async` + `TransactionSynchronization.afterCommit`)。
+
+## 订单推送覆盖度(基于在用代码)
+
+**已实现(代码定稿,均走 uniapp 云函数 + 入库,无 FCM;2026-06-25 确认范围):**
+- 用户下单 → 商家(`UserOrderController:274`)
+- 货到付款下单 → 商家+骑手(`PosOrderController.sendHdfkMessage:1180`)
+- 商家出餐 → 用户+骑手(`PosOrderShOprateController.chuCan:341`)
+- 骑手接单/取餐/送达 → 用户,接单时同时推商家(`PosOrderQsOprateController:204-241`)
+- 平台改订单状态 → 用户,state=3 时同时推商家(`PosOrderController:326-363`)
+
+> 接单/改状态时推商家:业务合理,已确认保留。代码侧无待办,仅剩端到端验证(启动服务→触发各动作→查 `push_message` 表 + 真机收推送)。
+
+**缺口:**
+- 取消/申诉类通知(用户取消、商家取消、骑手取餐失败/取消、到付解冻)—— `OrderAppealController` 整片注释,全部缺失
+- 定时任务类通知(超时无骑手退款、抽成返还、自动恢复营业)—— `TestTask` 废弃,全部缺失
+- 管理员改派骑手通知(无 assignRider 推送)
+- Android FCM 通道未接入,Android 端无系统通知栏推送

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor