1
0

11 Commits 8f0053e338 ... ca9aeefabe

Autor SHA1 Nachricht Datum
  qmj ca9aeefabe 修改line 文件配置 vor 1 Woche
  qmj 544d23cc34 Merge branch 'test' vor 1 Woche
  qmj a58956f7fb Revert "修改line登录" vor 1 Woche
  qmj cbb5a7e052 修改line登录 vor 1 Woche
  qmj b136a4e4f6 修改line 文件配置 vor 1 Woche
  qmj b16f9717c8 修改line 文件配置 vor 1 Woche
  qmj f058368a65 feat(017-oauth): LINE 新增服务端回调 /auth/line/callback(唤起 LINE App 场景) vor 1 Woche
  qmj 9b517be734 chore: 016 门店支付配置菜单SQL/tasks跟踪 + 010 会员载具carrier清理SQL vor 1 Woche
  qmj 75b8e94162 fix(010-order-invoice): 会员载具改走 PrintFlag=Y 纸本,修复 ezPay INV10013 强制要邮箱 vor 1 Woche
  qmj bc8eb9fc86 修改EzPayDebugController vor 1 Woche
  qmj ce833dadb3 OMG(歐買尬/FunPoint)支付接入 - 后端完整实现(016-omg-payment) vor 1 Woche
32 geänderte Dateien mit 2788 neuen und 50 gelöschten Zeilen
  1. 139 0
      .claude/homunculus/observations.jsonl
  2. 128 0
      ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosStoreOmgController.java
  3. 7 10
      ruoyi-admin/src/main/java/com/ruoyi/app/order/OrderInvoiceService.java
  4. 8 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderShOprateController.java
  5. 9 1
      ruoyi-admin/src/main/java/com/ruoyi/app/order/UserOrderController.java
  6. 556 0
      ruoyi-admin/src/main/java/com/ruoyi/app/pay/OmgPayController.java
  7. 165 0
      ruoyi-admin/src/main/java/com/ruoyi/app/user/LineCallbackController.java
  8. 174 0
      ruoyi-admin/src/main/java/com/ruoyi/app/utils/omg/OmgCheckMacValue.java
  9. 134 0
      ruoyi-admin/src/main/java/com/ruoyi/app/utils/omg/OmgPay.java
  10. 37 0
      ruoyi-admin/src/main/java/com/ruoyi/app/utils/omg/OmgPayConfig.java
  11. 15 0
      ruoyi-admin/src/main/resources/application.yml
  12. 84 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/PosOrderOmgPayment.java
  13. 57 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/PosOrderOmgRefund.java
  14. 81 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/PosStoreOmg.java
  15. 34 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/dto/StoreOmgCredentialDto.java
  16. 75 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/PosStoreOmgVo.java
  17. 23 0
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/PosOrderOmgPaymentMapper.java
  18. 22 0
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/PosOrderOmgRefundMapper.java
  19. 31 0
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/PosStoreOmgMapper.java
  20. 52 0
      ruoyi-system/src/main/java/com/ruoyi/system/service/IPosOrderOmgPaymentService.java
  21. 24 0
      ruoyi-system/src/main/java/com/ruoyi/system/service/IPosOrderOmgRefundService.java
  22. 48 0
      ruoyi-system/src/main/java/com/ruoyi/system/service/IPosStoreOmgService.java
  23. 131 0
      ruoyi-system/src/main/java/com/ruoyi/system/service/impl/PosOrderOmgPaymentServiceImpl.java
  24. 55 0
      ruoyi-system/src/main/java/com/ruoyi/system/service/impl/PosOrderOmgRefundServiceImpl.java
  25. 189 0
      ruoyi-system/src/main/java/com/ruoyi/system/service/impl/PosStoreOmgServiceImpl.java
  26. 42 0
      ruoyi-system/src/main/resources/mapper/chanting/PosOrderOmgPaymentMapper.xml
  27. 29 0
      ruoyi-system/src/main/resources/mapper/chanting/PosOrderOmgRefundMapper.xml
  28. 93 0
      ruoyi-system/src/main/resources/mapper/chanting/PosStoreOmgMapper.xml
  29. 39 39
      specs/016-omg-payment/tasks.md
  30. 206 0
      specs/017-oauth-login/line-callback-frontend.md
  31. 1 0
      specs/017-oauth-login/spec.md
  32. 100 0
      updatesql/sql.md

Datei-Diff unterdrückt, da er zu groß ist
+ 139 - 0
.claude/homunculus/observations.jsonl


+ 128 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosStoreOmgController.java

@@ -0,0 +1,128 @@
+package com.ruoyi.app.mendian;
+
+import com.ruoyi.app.utils.omg.OmgPay;
+import com.ruoyi.app.utils.omg.OmgPayConfig;
+import com.ruoyi.common.annotation.Log;
+import com.ruoyi.common.core.controller.BaseController;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.core.page.TableDataInfo;
+import com.ruoyi.common.enums.BusinessType;
+import com.ruoyi.system.domain.dto.StoreOmgCredentialDto;
+import com.ruoyi.system.domain.vo.PosStoreOmgVo;
+import com.ruoyi.system.service.IPosStoreOmgService;
+import jakarta.validation.Valid;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 平台后台 - 门店 OMG(歐買尬) 支付凭证管理。
+ *
+ * <p>OMG 商店注册为线下人工;本接口负责申请状态推进、凭证录入与联网验证(QueryTradeInfo 探测)、
+ * 启用开关。联网验证放 Controller 层(避免 ruoyi-system 反向依赖 ruoyi-admin 的 OmgPay 工具类),
+ * Service 仅持久化。
+ *
+ * @author ruoyi
+ * @date 2026-07-29
+ */
+@RestController
+@RequestMapping("/system/storeOmg")
+public class PosStoreOmgController extends BaseController {
+
+    @Autowired
+    private IPosStoreOmgService posStoreOmgService;
+
+    @Autowired
+    private OmgPay omgPay;
+
+    @Value("${omg.base-url}")
+    private String omgBaseUrl;
+
+    /** 门店 OMG 凭证管理列表(分页 + 筛选)。 */
+    @PreAuthorize("@ss.hasPermi('chanting:storeOmg:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(PosStoreOmgVo query) {
+        startPage();
+        List<PosStoreOmgVo> list = posStoreOmgService.selectOmgStoreList(query);
+        return getDataTable(list);
+    }
+
+    /** 门店 OMG 详情。 */
+    @PreAuthorize("@ss.hasPermi('chanting:storeOmg:query')")
+    @GetMapping("/{storeId}")
+    public AjaxResult getInfo(@PathVariable Long storeId) {
+        return success(posStoreOmgService.selectOmgStoreDetail(storeId));
+    }
+
+    /** 发起申请(未申请 0 → 申请中 1)。 */
+    @PreAuthorize("@ss.hasPermi('chanting:storeOmg:apply')")
+    @Log(title = "OMG发起申请", businessType = BusinessType.UPDATE)
+    @PutMapping("/apply/{storeId}")
+    public AjaxResult apply(@PathVariable Long storeId) {
+        return toAjax(posStoreOmgService.apply(storeId));
+    }
+
+    /**
+     * 录入 OMG 凭证并验证:调 OMG QueryTradeInfo/V5 查一个虚构 MerchantTradeNo 探测金钥。
+     * 金钥错误 → OMG 回 CheckMacValue 验证错误(拒绝、状态不变);金钥正确 → 返回「无此交易」类结果,视为通过。
+     */
+    @PreAuthorize("@ss.hasPermi('chanting:storeOmg:saveCredentials')")
+    @Log(title = "OMG录入凭证", businessType = BusinessType.UPDATE)
+    @PutMapping("/saveCredentials")
+    public AjaxResult saveCredentials(@RequestBody @Valid StoreOmgCredentialDto dto) {
+        OmgPayConfig cfg = new OmgPayConfig(dto.getMerchantId(), dto.getHashKey(), dto.getHashIv());
+        Map<String, String> resp;
+        try {
+            resp = omgPay.queryTrade(omgBaseUrl, cfg, "OMGVERIFY" + (System.currentTimeMillis() / 1000L));
+        } catch (Exception e) {
+            posStoreOmgService.recordVerifyResult(dto.getStoreId(), "ERROR: " + msg(e));
+            return error("OMG 验证服务暂不可用,请稍后重试");
+        }
+        String rtnMsg = resp == null ? "" : (resp.getOrDefault("RtnMsg", "") + " " + resp.getOrDefault("Message", ""));
+        // 金钥/商店代号错误时 OMG 回 CheckMacValue 验证失败或提示 HashKey/商店代号
+        if (rtnMsg.contains("CheckMacValue") || rtnMsg.contains("HashKey") || rtnMsg.contains("商店代號") || rtnMsg.contains("商店代号")) {
+            posStoreOmgService.recordVerifyResult(dto.getStoreId(), "FAIL: " + resp);
+            return error("OMG 凭证无效,请检查商店代号 / HashKey / HashIV");
+        }
+        posStoreOmgService.enableWithCredentials(dto);
+        return success("凭证验证通过,已开通");
+    }
+
+    /** 停用 / 恢复(仅已开通门店)。 */
+    @PreAuthorize("@ss.hasPermi('chanting:storeOmg:toggleEnable')")
+    @Log(title = "OMG停用恢复", businessType = BusinessType.UPDATE)
+    @PutMapping("/toggleEnable/{storeId}")
+    public AjaxResult toggleEnable(@PathVariable Long storeId) {
+        return toAjax(posStoreOmgService.toggleEnable(storeId));
+    }
+
+    /** 重置状态(凭证作废/重新申请)。 */
+    @PreAuthorize("@ss.hasPermi('chanting:storeOmg:query')")
+    @Log(title = "OMG重置状态", businessType = BusinessType.UPDATE)
+    @PutMapping("/reset/{storeId}")
+    public AjaxResult reset(@PathVariable Long storeId) {
+        return toAjax(posStoreOmgService.reset(storeId));
+    }
+
+    /** 设置启用支付方式(本期固定 ALL)。入参 {enabledPayments: "ALL"}。 */
+    @PreAuthorize("@ss.hasPermi('chanting:storeOmg:saveCredentials')")
+    @Log(title = "OMG支付方式", businessType = BusinessType.UPDATE)
+    @PutMapping("/enabledPayments/{storeId}")
+    public AjaxResult enabledPayments(@PathVariable Long storeId, @RequestBody Map<String, String> body) {
+        String payments = body == null ? null : body.get("enabledPayments");
+        return toAjax(posStoreOmgService.setEnabledPayments(storeId, payments));
+    }
+
+    private static String msg(Throwable e) {
+        return e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage();
+    }
+}

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

@@ -262,12 +262,7 @@ public class OrderInvoiceService {
                     new LambdaQueryWrapper<InfoUser>().eq(InfoUser::getUserId, order.getUserId()));
             String phone = u != null && StrUtil.isNotBlank(u.getPhone()) ? u.getPhone() : "會員";
             dto.setBuyerName(phone);
-            if ("MEMBER".equals(row.getInvoiceChoice())) {
-                // 会员载具:载具号=会员手机号;同步落库保持发票行载具信息完整
-                dto.setCarrierNum(phone);
-                row.setCarrierType("2");
-                row.setCarrierNum(phone);
-            }
+            // 【决定 2026-08-05】MEMBER 不再设 carrierType=2 电子载具(见 buildAutoIssueDto 决策备注),走 PrintFlag=Y 纸本;会员手机号已写入 BuyerName。
         }
         // 出餐触发开票:在意图行上落结果,并标记已触发(与 invoiceStatus 独立)
         row.setInvoiceCategory(dto.getCategory());
@@ -291,9 +286,11 @@ public class OrderInvoiceService {
                 dto.setCarrierNum(intent.getCarrierNum());
                 break;
             case "MEMBER":
-                // 会员载具 ezPay 类型 2;载具号(会员手机号)与买方名由 autoIssue 取 InfoUser.phone 回填
+                // 【决定 2026-08-05】会员载具(MEMBER) 走 PrintFlag=Y 索取纸本,不传 ezPay CarrierType=2 电子载具。
+                // 原因:ezPay 电子载具(type2+PrintFlag=N)强制要 BuyerEmail,缺失回 INV10013;且「载具+PrintFlag=Y」
+                // 法定互斥(一票两份)。故不设 carrierType,buildIssueData 走「无载具→PrintFlag=Y」分支,ezPay 不校验邮箱。
+                // 会员身份由 BuyerName=手机号 + invoice_choice=MEMBER 体现,供会员发票夹归集。
                 dto.setCategory("B2C");
-                dto.setCarrierType("2");
                 break;
             case "LOVE_CODE":
                 dto.setCategory("DONATION");
@@ -611,7 +608,7 @@ public class OrderInvoiceService {
                 }
                 break;
             case "MEMBER":
-                // 会员载具:载具号=会员手机号(出餐 autoIssue 时取 InfoUser.phone),免联网验真、免格式校验
+                // 会员载具走 PrintFlag=Y 纸本(不传 ezPay 载具,见 buildAutoIssueDto 决策备注),免联网验真、免格式校验、免邮箱
                 break;
             case "LOVE_CODE":
                 if (StrUtil.isBlank(loveCode) || !loveCode.matches("^\\d{3,7}$")) {
@@ -668,7 +665,7 @@ public class OrderInvoiceService {
 
     /**
      * 场景入参校验(service 内强校验,拦截非法请求不调 ezPay)。对齐 ezPay 开票模型:
-     * B2B:统编(8位)+邮箱必填、无载具;B2C:载具(0/1/2)+号码必填;会员载具(2)载具号=会员手机号、不收邮箱
+     * B2B:统编(8位)+邮箱必填、无载具;B2C:载具(0/1/2)+号码必填;会员载具(MEMBER)走 PrintFlag=Y 纸本、不传载具不收邮箱(见 buildAutoIssueDto 2026-08-05 决策)
      */
     private void validateInvoiceInput(ApplyInvoiceDto dto) {
         if ("B2B".equals(dto.getCategory())) {

+ 8 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderShOprateController.java

@@ -331,6 +331,14 @@ public class PosOrderShOprateController extends BaseController {
         if (order.getState() != 0L && order.getState() != 1L) {
             throw new ServiceException("当前状态不允许取消");
         }
+        // OMG 已支付订单取消触发退款(信用卡自动退刷;ATM/超商记人工;失败不阻断取消,refund 记录可重试)
+        if (com.ruoyi.app.pay.OmgPayController.PAY_TYPE_OMG.equals(order.getPayType())
+                && order.getPayStatus() != null && order.getPayStatus() == 1L) {
+            try {
+                com.ruoyi.common.utils.spring.SpringUtils.getBean(com.ruoyi.app.pay.OmgPayController.class).refundOrder(order);
+            } catch (Exception ignore) {
+            }
+        }
         PosOrder update = new PosOrder();
         update.setId(order.getId());
         update.setState(4L);

+ 9 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/order/UserOrderController.java

@@ -313,7 +313,7 @@ public class UserOrderController extends BaseController {
                 } else if ("CITIZEN".equals(choice)) {
                     intent.setCarrierType("1");
                 } else if ("MEMBER".equals(choice)) {
-                    intent.setCarrierType("2");
+                    // 【决定 2026-08-05】会员载具走 PrintFlag=Y 纸本,不传 ezPay 电子载具(见 OrderInvoiceService.buildAutoIssueDto 决策备注);carrierType 留空
                 } else {
                     intent.setCarrierType(input.getCarrierType());
                 }
@@ -697,6 +697,14 @@ public class UserOrderController extends BaseController {
         if (order.getState() == null || order.getState() != 0) {
             return error("仅待处理状态的订单可取消");
         }
+        // OMG 已支付订单取消触发退款(信用卡自动退刷;ATM/超商记人工;失败不阻断取消,可重试)
+        if (com.ruoyi.app.pay.OmgPayController.PAY_TYPE_OMG.equals(order.getPayType())
+                && order.getPayStatus() != null && order.getPayStatus() == 1L) {
+            try {
+                com.ruoyi.common.utils.spring.SpringUtils.getBean(com.ruoyi.app.pay.OmgPayController.class).refundOrder(order);
+            } catch (Exception ignore) {
+            }
+        }
         order.setState(4L);
         posOrderService.updateById(order);
 

+ 556 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/pay/OmgPayController.java

@@ -0,0 +1,556 @@
+package com.ruoyi.app.pay;
+
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.ruoyi.app.order.dto.OrderPushBodyDto;
+import com.ruoyi.app.utils.PayPush;
+import com.ruoyi.app.utils.event.PushEventService;
+import com.ruoyi.app.utils.omg.OmgCheckMacValue;
+import com.ruoyi.app.utils.omg.OmgPay;
+import com.ruoyi.app.utils.omg.OmgPayConfig;
+import com.ruoyi.common.annotation.Anonymous;
+import com.ruoyi.common.annotation.RepeatSubmit;
+import com.ruoyi.common.core.controller.BaseController;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.system.domain.IpnLog;
+import com.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.domain.PosOrderOmgPayment;
+import com.ruoyi.system.domain.PosStoreOmg;
+import com.ruoyi.system.service.IInfoUserService;
+import com.ruoyi.system.service.IIpnLogService;
+import com.ruoyi.system.service.IPosOrderOmgPaymentService;
+import com.ruoyi.system.service.IPosOrderOmgRefundService;
+import com.ruoyi.system.service.IPosOrderService;
+import com.ruoyi.system.service.IPosStoreOmgService;
+import com.ruoyi.system.utils.Auth;
+import com.ruoyi.system.utils.JwtUtil;
+import com.ruoyi.system.utils.OrderLogHelper;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestMethod;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.io.IOException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.Enumeration;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * OMG(歐買尬/FunPoint) AIO 线上支付 Controller(独立于 newebpay,零蓝新依赖)。
+ *
+ * <p>本期实现:
+ * <ul>
+ *   <li>{@code POST /pay/omg/create} — 发起 AIO 幕前支付(US1,@Auth)。返回含 gatewayUrl 的 form 字段,
+ *       前端构建隐藏 form submit 到 OMG 收银台(ChoosePayment=ALL)。</li>
+ *   <li>{@code POST /pay/omg/notify} — OMG 服务端回调(US2,@Anonymous)。验签 CheckMacValue + 幂等 + 金额 +
+ *       RtnCode/SimulatePaid 校验 → markSuccess → 更新订单 payStatus=1 + 推送用户/商家 → 回纯串 {@code 1|OK}。</li>
+ *   <li>{@code GET|POST /pay/omg/return} — 完成页引导(US2,@Anonymous)。仅 302 回前端结果页,不改订单状态。</li>
+ * </ul>
+ *
+ * @author ruoyi
+ * @date 2026-07-29
+ */
+@RestController
+@RequestMapping("/pay/omg")
+public class OmgPayController extends BaseController {
+
+    private static final Logger log = LoggerFactory.getLogger(OmgPayController.class);
+
+    /** payType 取值:OMG 在线支付(发起时写入 pos_order.pay_type;具体方式如 Credit_CreditCard、ATM 系列、CVS 系列等由回调写入 pos_order_omg_payment.pay_type)。 */
+    public static final String PAY_TYPE_OMG = "7";
+
+    @Autowired
+    private IPosOrderService posOrderService;
+    @Autowired
+    private IPosStoreOmgService storeOmgService;
+    @Autowired
+    private IPosOrderOmgPaymentService paymentService;
+    @Autowired
+    private OmgPay omgPay;
+    @Autowired
+    private IIpnLogService ipnLogService;
+    @Autowired
+    private IInfoUserService infoUserService;
+    @Autowired
+    private PushEventService pushEventService;
+    @Autowired
+    private OrderLogHelper orderLogHelper;
+    @Autowired
+    private IPosOrderOmgRefundService refundService;
+
+    @Value("${omg.base-url}")
+    private String baseUrl;
+    @Value("${omg.return-url}")
+    private String returnUrl;
+    @Value("${omg.order-result-url}")
+    private String orderResultUrl;
+
+    // ============================ US1:发起 AIO 幕前支付 ============================
+
+    /**
+     * 发起 OMG AIO 幕前支付。校验订单归属/未支付/金额 → 查门店启用凭证 → 生成 MerchantTradeNo →
+     * 组参 + CheckMacValue → 落流水(pay_status=0) + 更新订单 payType=7/payUrl → 返回 form 字段供前端 Form Post。
+     */
+    @Anonymous
+    @Auth
+    @RepeatSubmit(interval = 1000, message = "请求过于频繁")
+    @PostMapping("/create")
+    public AjaxResult create(@RequestParam String orderid, HttpServletRequest request) {
+        String token = request.getHeader("token");
+        String userId;
+        try {
+            userId = new JwtUtil().getusid(token);
+        } catch (Exception e) {
+            return error(MessageUtils.message("no.order.id.error"));
+        }
+        if (userId == null || userId.isEmpty()) {
+            return error("请先登录");
+        }
+
+        PosOrder order = posOrderService.getOne(new QueryWrapper<PosOrder>().eq("dd_id", orderid));
+        if (order == null) {
+            return error(MessageUtils.message("no.order.id.error"));
+        }
+        if (order.getUserId() == null || !userId.equals(String.valueOf(order.getUserId()))) {
+            return error("无权操作该订单");
+        }
+        if (order.getPayStatus() != null && order.getPayStatus() == 1L) {
+            return error("订单已支付");
+        }
+        if (order.getAmount() == null || order.getAmount() <= 0) {
+            return error("订单金额异常");
+        }
+        if (order.getMdId() == null) {
+            return error("订单门店缺失");
+        }
+
+        // 门店 OMG 凭证(已开通且启用)→ 转 OmgPayConfig(Controller 层完成,避免 system→admin 反向依赖)
+        PosStoreOmg cred = storeOmgService.getEnabledCredential(order.getMdId());
+        if (cred == null) {
+            return error("该门店暂不支持线上支付");
+        }
+        OmgPayConfig cfg = new OmgPayConfig(cred.getMerchantId(), cred.getHashKey(), cred.getHashIv());
+
+        String merchantTradeNo = genMerchantTradeNo(orderid);
+
+        // OMG AIO 参数(contracts/api.md §A1/B1);InvoiceMark=N 固定(发票走 ezPay);EncryptType=1 固定(SHA256)
+        Map<String, String> params = new LinkedHashMap<>();
+        params.put("MerchantID", cred.getMerchantId());
+        params.put("MerchantTradeNo", merchantTradeNo);
+        params.put("MerchantTradeDate", new SimpleDateFormat("yyyy/MM/dd HH:mm:ss").format(new Date()));
+        params.put("PaymentType", "aio");
+        params.put("TotalAmount", String.valueOf(order.getAmount()));
+        params.put("ReturnURL", returnUrl);
+        params.put("ChoosePayment", "ALL");
+        params.put("EncryptType", "1");
+        params.put("ItemName", "order " + orderid);
+        params.put("InvoiceMark", "N");
+        if (orderResultUrl != null && !orderResultUrl.isEmpty()) {
+            params.put("OrderResultURL", orderResultUrl);
+        }
+
+        // 组参 + CheckMacValue,返回含 gatewayUrl 的 form 字段
+        Map<String, String> form = omgPay.createAioForm(baseUrl, cfg, params);
+
+        // 落 OMG 支付流水(pay_status=0)
+        paymentService.createPayment(orderid, merchantTradeNo, order.getMdId(), cred.getMerchantId(), order.getAmount(), "ALL");
+
+        // 更新订单 payType=7 / payUrl=gatewayUrl(仅更这两个字段)
+        PosOrder upd = new PosOrder();
+        upd.setId(order.getId());
+        upd.setPayType(PAY_TYPE_OMG);
+        upd.setPayUrl(form.get("gatewayUrl"));
+        posOrderService.saveOrUpdate(upd);
+
+        log.info("[OMG] create orderid={}, merchantTradeNo={}, amount={}, gatewayUrl={}",
+                orderid, merchantTradeNo, order.getAmount(), form.get("gatewayUrl"));
+        return success(form);
+    }
+
+    /** MerchantTradeNo = "OMG" + ddId(清洗为英数,≤20 字元)。重新发起的幂等/后缀策略见 D6,MVP 先用 ddId。 */
+    private String genMerchantTradeNo(String ddId) {
+        String cleaned = ddId == null ? "" : ddId.replaceAll("[^0-9A-Za-z]", "");
+        String no = "OMG" + cleaned;
+        if (no.length() > 20) {
+            no = no.substring(0, 20);
+        }
+        return no;
+    }
+
+    // ============================ US2:ReturnURL 支付结果回调 ============================
+
+    /**
+     * OMG 支付结果回调(@Anonymous,OMG 服务端 Form Post)。OMG 明文参数 + 单 CheckMacValue(无 AES 解密)。
+     *
+     * <p>记 IPN → 按 MerchantID 查凭证 → 验签 → trade_no 幂等 → 金额校验 → RtnCode==1 且 SimulatePaid!=1
+     * → markSuccess + 推送 → 回纯串 {@code 1|OK}(注意:非 JSON,与蓝新不同)。任何异常/校验失败都仍回 1|OK
+     * (OMG 收到非成功会重试),但绝不错误更新订单。
+     */
+    @Anonymous
+    @PostMapping(value = "/notify", produces = "text/plain;charset=UTF-8")
+    public String notify(HttpServletRequest request) {
+        Map<String, String> form = collectForm(request);
+
+        // 记录 IPN 日志
+        try {
+            IpnLog ipnLog = new IpnLog();
+            ipnLog.setIp(new com.ruoyi.app.utils.IpUtils().getIpAddr(request));
+            ipnLog.setIpnLog(form.toString());
+            ipnLogService.insertIpnLog(ipnLog);
+        } catch (Exception e) {
+            log.warn("记 OMG IPN 日志失败", e);
+        }
+
+        String merchantId = form.get("MerchantID");
+        PosStoreOmg cred = storeOmgService.getEnabledCredentialByMerchantId(merchantId);
+        if (cred == null) {
+            log.warn("OMG 回调无匹配凭证: merchantId={}", merchantId);
+            return "1|OK";
+        }
+
+        // 验签(OMG 无解密,直接对全部回调参 + CheckMacValue 重算比对)
+        if (!OmgCheckMacValue.verify(form, cred.getHashKey(), cred.getHashIv())) {
+            log.warn("OMG 回调验签失败: merchantId={}", merchantId);
+            return "1|OK";
+        }
+
+        String tradeNo = form.get("TradeNo");
+        String merchantTradeNo = form.get("MerchantTradeNo");
+        int rtnCode = toInt(form.get("RtnCode"), -1);
+        int tradeAmt = toInt(form.get("TradeAmt"), -1);
+        String simulatePaid = form.get("SimulatePaid");
+        String paymentType = form.get("PaymentType");
+        String paymentDate = form.get("PaymentDate");
+
+        // 幂等:同 tradeNo 已成功则直接返回
+        PosOrderOmgPayment exist = paymentService.getByTradeNo(tradeNo);
+        if (exist != null && Integer.valueOf(1).equals(exist.getPayStatus())) {
+            return "1|OK";
+        }
+
+        // 订单关联:由 MerchantTradeNo 反查流水与订单
+        PosOrderOmgPayment payment = paymentService.getByMerchantTradeNo(merchantTradeNo);
+        if (payment == null) {
+            log.warn("OMG 回调无对应发起记录: merchantTradeNo={}", merchantTradeNo);
+            return "1|OK";
+        }
+        String ddId = payment.getDdId();
+        PosOrder order = posOrderService.getOne(new QueryWrapper<PosOrder>().eq("dd_id", ddId));
+        if (order == null) {
+            log.warn("OMG 回调订单不存在: ddId={}", ddId);
+            return "1|OK";
+        }
+
+        // 金额校验(以平台实际应收金额为准)
+        if (order.getAmount() == null || tradeAmt != order.getAmount()) {
+            log.error("OMG 回调金额不符: ddId={}, orderAmt={}, callbackAmt={}", ddId, order.getAmount(), tradeAmt);
+            return "1|OK";
+        }
+
+        if (rtnCode == 1 && !"1".equals(simulatePaid)) {
+            // 核销成功
+            int n = paymentService.markSuccess(payment.getId(), tradeNo, paymentType, rtnCode,
+                    form.get("RtnMsg"), form.get("AuthCode"), parsePayTime(paymentDate), form.toString());
+            if (n > 0) {
+                // 首次成功:更新订单 state=0(待接单)/payStatus=1 + 推送用户/商家
+                handlePaymentSuccess(order);
+            }
+        } else if (rtnCode != 1) {
+            paymentService.markFail(payment.getId(), rtnCode, form.get("RtnMsg"), form.toString());
+            log.warn("OMG 回调交易失败: ddId={}, rtnCode={}, rtnMsg={}", ddId, rtnCode, form.get("RtnMsg"));
+        } else {
+            // SimulatePaid=1 模拟支付:不发货,仅记录
+            log.warn("OMG 回调为模拟支付(SimulatePaid=1),不发货: ddId={}", ddId);
+        }
+        return "1|OK";
+    }
+
+    /**
+     * 支付完成返回页(@Anonymous)。仅 302 引导回前端结果页(带 ddId),<b>不</b>改订单状态(以 notify 为准)。
+     */
+    @Anonymous
+    @RequestMapping(value = "/return", method = {RequestMethod.GET, RequestMethod.POST})
+    public void returnCallback(HttpServletRequest request, HttpServletResponse response) throws IOException {
+        String ddId = "";
+        try {
+            Map<String, String> form = collectForm(request);
+            String mtn = form.get("MerchantTradeNo");
+            // MerchantTradeNo = "OMG" + ddId,去前缀还原 ddId
+            if (mtn != null && mtn.startsWith("OMG")) {
+                ddId = mtn.substring(3);
+            }
+        } catch (Exception e) {
+            log.warn("OMG ReturnURL 解析失败", e);
+        }
+        String sep = orderResultUrl.contains("?") ? "&" : "?";
+        response.sendRedirect(orderResultUrl + sep + "ddId=" + URLEncoder.encode(ddId, StandardCharsets.UTF_8));
+    }
+
+    // ============================ US3:ATM/超商 取号回调 + 取号查询 ============================
+
+    /**
+     * OMG ATM/超商取号回调(@Anonymous,PaymentInfoURL)。验签后把虚帐/缴费码原始报文
+     * (BankCode/vAccount/ExpireDate 或 PaymentNo/ExpireDate 等)落到流水的 callbackRaw,不改 pay_status;
+     * 实际付款后 OMG 再回调 /pay/omg/notify(RtnCode=1)走 US2 核销。回纯串 {@code 1|OK}。
+     */
+    @Anonymous
+    @PostMapping(value = "/paymentInfo", produces = "text/plain;charset=UTF-8")
+    public String paymentInfoCallback(HttpServletRequest request) {
+        Map<String, String> form = collectForm(request);
+        try {
+            IpnLog ipnLog = new IpnLog();
+            ipnLog.setIp(new com.ruoyi.app.utils.IpUtils().getIpAddr(request));
+            ipnLog.setIpnLog(form.toString());
+            ipnLogService.insertIpnLog(ipnLog);
+        } catch (Exception e) {
+            log.warn("记 OMG paymentInfo IPN 日志失败", e);
+        }
+
+        String merchantId = form.get("MerchantID");
+        PosStoreOmg cred = storeOmgService.getEnabledCredentialByMerchantId(merchantId);
+        if (cred == null) {
+            log.warn("OMG paymentInfo 无匹配凭证: merchantId={}", merchantId);
+            return "1|OK";
+        }
+        if (!OmgCheckMacValue.verify(form, cred.getHashKey(), cred.getHashIv())) {
+            log.warn("OMG paymentInfo 验签失败: merchantId={}", merchantId);
+            return "1|OK";
+        }
+
+        String merchantTradeNo = form.get("MerchantTradeNo");
+        PosOrderOmgPayment payment = paymentService.getByMerchantTradeNo(merchantTradeNo);
+        if (payment == null) {
+            log.warn("OMG paymentInfo 无对应发起记录: merchantTradeNo={}", merchantTradeNo);
+            return "1|OK";
+        }
+        // 落取号信息(BankCode/vAccount/ExpireDate 或 PaymentNo/ExpireDate),不改 pay_status
+        paymentService.markPaymentInfo(payment.getId(), form.get("TradeNo"), form.toString());
+        log.info("[OMG] paymentInfo 已记录取号信息: ddId={}, merchantTradeNo={}", payment.getDdId(), merchantTradeNo);
+        return "1|OK";
+    }
+
+    /**
+     * 取号信息查询(前端 ATM/超商结果页展示虚帐/缴费码 + 期限用,@Auth)。
+     * 返回 payType/amount/payStatus + 解析后的取号字段 info。
+     */
+    @Anonymous
+    @Auth
+    @GetMapping("/paymentInfo/{orderid}")
+    public AjaxResult getPaymentInfo(@PathVariable String orderid, HttpServletRequest request) {
+        String token = request.getHeader("token");
+        String userId;
+        try {
+            userId = new JwtUtil().getusid(token);
+        } catch (Exception e) {
+            return error(MessageUtils.message("no.order.id.error"));
+        }
+        PosOrder order = posOrderService.getOne(new QueryWrapper<PosOrder>().eq("dd_id", orderid));
+        if (order == null || order.getUserId() == null || !userId.equals(String.valueOf(order.getUserId()))) {
+            return error("无权操作该订单");
+        }
+        PosOrderOmgPayment p = paymentService.getLatestByDdId(orderid);
+        if (p == null) {
+            return error("无支付记录");
+        }
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("payType", p.getPayType());
+        result.put("amount", p.getAmount());
+        result.put("payStatus", p.getPayStatus());
+        result.put("info", parseKv(p.getCallbackRaw()));
+        return success(result);
+    }
+
+    // ============================ US4:订单取消与退款 ============================
+
+    /**
+     * OMG 退款(@Auth,供取消链路/管理员调用)。信用卡(含 Apple Pay) 调 DoAction(Action=R 退刷);
+     * ATM/超商/BarcodeATM 无退款 API → 记录待人工在 OMG 后台处理。仅正式端点可用(stage DoAction 不可用)。
+     */
+    @Anonymous
+    @Auth
+    @PostMapping("/refund")
+    public AjaxResult refund(@RequestParam String orderid, HttpServletRequest request) {
+        String token = request.getHeader("token");
+        try {
+            new JwtUtil().getusid(token);
+        } catch (Exception e) {
+            return error(MessageUtils.message("no.order.id.error"));
+        }
+        PosOrder order = posOrderService.getOne(new QueryWrapper<PosOrder>().eq("dd_id", orderid));
+        if (order == null) {
+            return error("订单不存在");
+        }
+        return refundOrder(order);
+    }
+
+    /**
+     * OMG 退款核心(public,供取消链路 {@code cancelOrder} 直接调用,免重复实现)。
+     * 信用卡(含 Apple Pay) 调 DoAction(Action=R 退刷);ATM/超商/BarcodeATM 记人工。仅正式端点可用。
+     * 返回 success=已退款;error=需人工或失败(refund 已落记录可重试)。
+     */
+    public AjaxResult refundOrder(PosOrder order) {
+        if (order == null) {
+            return error("订单不存在");
+        }
+        if (!PAY_TYPE_OMG.equals(order.getPayType())) {
+            return error("该订单非 OMG 支付");
+        }
+        if (order.getPayStatus() == null || order.getPayStatus() != 1L) {
+            return error("订单未支付,无需退款");
+        }
+        String ddId = String.valueOf(order.getDdId());
+        PosOrderOmgPayment payment = paymentService.getLatestByDdId(ddId);
+        if (payment == null) {
+            return error("无 OMG 支付流水");
+        }
+        int amount = order.getAmount() == null ? 0 : order.getAmount();
+        String payType = payment.getPayType();
+
+        // ATM/超商/BarcodeATM 无退款 API → 记录待人工
+        if (payType == null || payType.startsWith("ATM_") || payType.startsWith("CVS_") || payType.startsWith("BarcodeATM_")) {
+            refundService.record(payment.getId(), ddId, payment.getTradeNo(), null, amount, null,
+                    "延期支付方式无退款API,待人工在 OMG 后台处理", "");
+            return error("该支付方式需人工在 OMG 后台退款");
+        }
+
+        PosStoreOmg cred = storeOmgService.getEnabledCredential(order.getMdId());
+        if (cred == null) {
+            return error("门店 OMG 凭证不可用");
+        }
+        OmgPayConfig cfg = new OmgPayConfig(cred.getMerchantId(), cred.getHashKey(), cred.getHashIv());
+
+        // 信用卡(含 Apple Pay)→ DoAction(Action=R 退刷);MVP 统一 R,失败再按状态分支(D7)
+        Map<String, String> resp;
+        try {
+            resp = omgPay.doAction(baseUrl, cfg, payment.getMerchantTradeNo(), payment.getTradeNo(), "R", amount);
+        } catch (Exception e) {
+            refundService.record(payment.getId(), ddId, payment.getTradeNo(), "R", amount, null,
+                    "ERROR: " + (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()), "");
+            return error("OMG 退款服务暂不可用");
+        }
+        int rtnCode = toInt(resp == null ? null : resp.get("RtnCode"), -1);
+        String rtnMsg = resp == null ? "" : resp.get("RtnMsg");
+        refundService.record(payment.getId(), ddId, payment.getTradeNo(), "R", amount, rtnCode, rtnMsg,
+                resp == null ? "" : resp.toString());
+
+        if (rtnCode == 1) {
+            paymentService.markRefunded(payment.getId());
+            PosOrder upd = new PosOrder();
+            upd.setId(order.getId());
+            upd.setPayStatus(2L);
+            posOrderService.saveOrUpdate(upd);
+            orderLogHelper.logSync(ddId, 0, null, "系统", "OMG 信用卡退款成功");
+            return success("退款成功");
+        }
+        return error("OMG 退款失败:" + rtnMsg);
+    }
+
+    // ============================ 支付成功业务链路(参照 PosOrderController.sendHdfkMessage 货到付款同款) ============================
+
+    /** 支付成功:更新订单 state=0/payStatus=1 + 订单日志 + 推送用户(支付成功)/商家(新订单)。骑手推送平台当前为空实现,此处不补。 */
+    private void handlePaymentSuccess(PosOrder order) {
+        try {
+            PosOrder upd = new PosOrder();
+            upd.setId(order.getId());
+            upd.setState(0L);
+            upd.setPayStatus(1L);
+            posOrderService.saveOrUpdate(upd);
+
+            orderLogHelper.logSync(String.valueOf(order.getDdId()), 0, null, "系统", "系统收到OMG支付成功回调");
+
+            String ddId = String.valueOf(order.getDdId());
+            String state = String.valueOf(order.getState());
+            String title = MessageUtils.message("no.message.push.message");
+            String body = OrderPushBodyDto.getJson(ddId, state, 0);
+
+            InfoUser user = order.getUserId() == null ? null : infoUserService.getById(order.getUserId());
+            if (user != null) {
+                PayPush push = new PayPush();
+                push.apppush(user.getCid(), title, MessageUtils.message("no.message.push.payment.success"), body);
+                pushEventService.PublisherEvent(user.getUserId(), title, MessageUtils.message("no.message.push.payment.success"), body);
+            }
+
+            InfoUser sh = order.getShId() == null ? null : infoUserService.getById(order.getShId());
+            if (sh != null) {
+                PayPush push = new PayPush();
+                push.shpush(sh.getCid(), title, MessageUtils.message("no.message.push.new.order"), body);
+                pushEventService.PublisherEvent(sh.getUserId(), title, MessageUtils.message("no.message.push.new.order"), body);
+            }
+            // 可接单骑手推送:平台 PosOrderController.sendQsPush 当前为空实现(与货到付款/蓝新一致),此处不补
+        } catch (Exception e) {
+            log.error("OMG 支付成功业务处理异常: ddId={}", order.getDdId(), e);
+        }
+    }
+
+    // ============================ 辅助 ============================
+
+    /** 收集 form-urlencoded 回调参数为 Map(OMG 明文参数,含 CheckMacValue)。 */
+    private Map<String, String> collectForm(HttpServletRequest req) {
+        Map<String, String> map = new LinkedHashMap<>();
+        Enumeration<String> names = req.getParameterNames();
+        while (names.hasMoreElements()) {
+            String n = names.nextElement();
+            map.put(n, req.getParameter(n));
+        }
+        return map;
+    }
+
+    private int toInt(String s, int def) {
+        if (s == null || s.isEmpty()) {
+            return def;
+        }
+        try {
+            return Integer.parseInt(s);
+        } catch (Exception e) {
+            return def;
+        }
+    }
+
+    /** 解析 callbackRaw(LinkedHashMap.toString 形如 {A=1, B=2})为 Map,供前端读 ATM/超商取号信息。 */
+    private Map<String, String> parseKv(String kv) {
+        Map<String, String> map = new LinkedHashMap<>();
+        if (kv == null || kv.isEmpty()) {
+            return map;
+        }
+        String s = kv;
+        if (s.startsWith("{") && s.endsWith("}")) {
+            s = s.substring(1, s.length() - 1);
+        }
+        for (String pair : s.split(",")) {
+            int idx = pair.indexOf('=');
+            if (idx > 0) {
+                map.put(pair.substring(0, idx).trim(), pair.substring(idx + 1).trim());
+            }
+        }
+        return map;
+    }
+
+    /** 解析回调 PaymentDate(兼容 yyyy/MM/dd 与 yyyy-MM-dd),失败取当前时间。 */
+    private Date parsePayTime(String s) {
+        if (s == null || s.isEmpty()) {
+            return new Date();
+        }
+        String[] fmts = {"yyyy/MM/dd HH:mm:ss", "yyyy-MM-dd HH:mm:ss"};
+        for (String f : fmts) {
+            try {
+                return new SimpleDateFormat(f).parse(s);
+            } catch (Exception ignore) {
+            }
+        }
+        return new Date();
+    }
+}

+ 165 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/user/LineCallbackController.java

@@ -0,0 +1,165 @@
+package com.ruoyi.app.user;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.ruoyi.app.utils.oauth.OAuthVerifyService;
+import com.ruoyi.common.annotation.Anonymous;
+import com.ruoyi.common.constant.CacheConstants;
+import com.ruoyi.common.core.controller.BaseController;
+import com.ruoyi.common.core.domain.model.LoginUserDto;
+import com.ruoyi.common.core.redis.RedisCache;
+import com.ruoyi.common.utils.ServletUtils;
+import com.ruoyi.common.utils.ip.AddressUtils;
+import com.ruoyi.common.utils.ip.IpUtils;
+import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.system.domain.InfoUserOauth;
+import com.ruoyi.system.mapper.InfoUserOauthMapper;
+import com.ruoyi.system.service.IInfoUserService;
+import com.ruoyi.system.utils.JwtUtil;
+import eu.bitwalker.useragentutils.UserAgent;
+import jakarta.servlet.http.HttpServletResponse;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.io.IOException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.UUID;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * LINE 登录服务端回调(017-oauth-login 增量,2026-08-05)。
+ *
+ * <p><b>为什么需要它</b>:当用户走「唤起 LINE App / 系统浏览器」授权时,LINE 把重定向发给
+ * 注册的 redirect_uri(后端 https URL),uniapp(另一个 App)无法在中间截获 code。这时必须由
+ * 后端接住回调、换 token、完成登录,再把结果 302 跳回 App(自定义 scheme)。原 {@code /oauthLogin}
+ * 保留不动,覆盖「前端自己拿到 code 再 POST 给后端」的另一种场景(H5 / webview / SDK)。
+ *
+ * <p><b>流程</b>:LINE → {@code GET /auth/line/callback?code=...&state=...}
+ * → {@link OAuthVerifyService#verify verify("line", code)}(code 换 token 取 userId)
+ * → 查 info_user_oauth:已绑定→签 JWT token;未绑定→生成 tempKey(needPhone)
+ * → 302 跳到 {@code oauth.line.app-redirect}(如 com.twanmsdyh.app://oauthLogin)带结果:
+ * <ul>
+ *   <li>已绑定:{@code <app-redirect>?token=xxx}</li>
+ *   <li>未绑定:{@code <app-redirect>?needPhone=1&tempKey=xxx}(App 弹手机号+短信码 UI 后调 /infouser/user/oauthBindPhone)</li>
+ *   <li>异常:{@code <app-redirect>?error=xxx}</li>
+ * </ul>
+ *
+ * <p><b>redirect_uri 三方一致性</b>:LINE 要求「前端 authorize 的 redirect_uri」「后端换 token 的
+ * redirect_uri」「LINE Console 回调白名单」三者完全一致,否则回 400 redirect_uri_mismatch。
+ * 故 application.yml 的 {@code oauth.line.redirect-uri} 必须与 Console 一致(= 本端点地址)。
+ *
+ * @author foodie
+ * @date 2026-08-05
+ */
+@RestController
+@RequestMapping("/auth/line")
+public class LineCallbackController extends BaseController {
+
+    private static final Logger log = LoggerFactory.getLogger(LineCallbackController.class);
+
+    /** 与 InfoUserController.OAUTH_TEMP_PREFIX 一致:未绑定临时凭证 Redis 前缀(oauthBindPhone 消费) */
+    private static final String OAUTH_TEMP_PREFIX = "oauth:bind:";
+
+    @Autowired
+    private OAuthVerifyService oauthVerifyService;
+    @Autowired
+    private IInfoUserService infoUserService;
+    @Autowired
+    private InfoUserOauthMapper infoUserOauthMapper;
+    @Autowired
+    private RedisCache redisCache;
+
+    /** 后端登录后 302 跳回 App 的 scheme(App 注册该 scheme 接收 token/tempKey/error) */
+    @Value("${oauth.line.app-redirect}")
+    private String appRedirect;
+
+    /**
+     * LINE 服务端回调:接 code → 换 token → 登录 → 302 回 App。
+     * 用 @Anonymous 放行(LINE 以浏览器/App 身份回调,无 token)。
+     */
+    @Anonymous
+    @GetMapping("/callback")
+    public void callback(@RequestParam(value = "code", required = false) String code,
+                         @RequestParam(value = "state", required = false) String state,
+                         HttpServletResponse response) throws IOException {
+        try {
+            if (code == null || code.isEmpty()) {
+                log.warn("[OAuth][LINE] callback 缺 code 参数, state={}", state);
+                redirectToApp(response, "error", "no_code");
+                return;
+            }
+            log.info("[OAuth][LINE] callback 收到 code(len={}), state={}", code.length(), state);
+
+            // code → userId(OAuthVerifyService.verifyLine:code 换 access_token 再取 profile)
+            String providerUid = oauthVerifyService.verify("line", code);
+
+            InfoUserOauth bind = infoUserOauthMapper.selectOne(
+                    new LambdaQueryWrapper<InfoUserOauth>()
+                            .eq(InfoUserOauth::getProvider, "line")
+                            .eq(InfoUserOauth::getProviderUid, providerUid));
+            if (bind != null) {
+                InfoUser u = infoUserService.getOne(new QueryWrapper<InfoUser>()
+                        .eq("user_id", bind.getUserId()).eq("status", 0).eq("del_flag", "0"));
+                if (u == null) {
+                    log.warn("[OAuth][LINE] callback 已绑定但账号已停用 userId={}", bind.getUserId());
+                    redirectToApp(response, "error", "user_stopped");
+                    return;
+                }
+                String token = buildOauthToken(u, "line");
+                log.info("[OAuth][LINE] callback 已绑定登录成功 userId={}", u.getUserId());
+                response.sendRedirect(appRedirect + "?token=" + enc(token));
+                return;
+            }
+
+            // 未绑定:缓存 {line, providerUid},返回 needPhone + tempKey(与 oauthLogin 同款,oauthBindPhone 消费)
+            String tempKey = UUID.randomUUID().toString().replace("-", "");
+            redisCache.setCacheObject(OAUTH_TEMP_PREFIX + tempKey, "line@" + providerUid, 5, TimeUnit.MINUTES);
+            log.info("[OAuth][LINE] callback 未绑定,返回 needPhone providerUid={}", providerUid);
+            response.sendRedirect(appRedirect + "?needPhone=1&tempKey=" + enc(tempKey));
+        } catch (Exception e) {
+            log.error("[OAuth][LINE] callback 异常", e);
+            redirectToApp(response, "error", e.getMessage() == null ? "fail" : e.getMessage());
+        }
+    }
+
+    /**
+     * 签发三方登录 token(与 InfoUserController.issueOauthToken 同款,仅返回 token 串不包 AjaxResult)。
+     * 此处刻意不复用 InfoUserController 的私有方法,避免改动已上线代码;两处逻辑保持一致。
+     */
+    private String buildOauthToken(InfoUser user, String provider) {
+        redisCache.deleteKeys(CacheConstants.USER_TOKEN_KEY + user.getUserId() + ":" + "*");
+        LoginUserDto dto = new LoginUserDto();
+        dto.setUserId(user.getUserId());
+        dto.setUserName(user.getPhone());
+        dto.setProvider(provider);
+        fillLoginUserInfo(dto);
+        return JwtUtil.setToken(CacheConstants.USER_TOKEN_KEY, dto);
+    }
+
+    /** 填充登录设备/网络信息(IP、地点、浏览器、OS、登录时间),与 InfoUserController.fillLoginUserInfo 一致。 */
+    private void fillLoginUserInfo(LoginUserDto userDto) {
+        String ip = IpUtils.getIpAddr(ServletUtils.getRequest());
+        userDto.setIpaddr(ip);
+        userDto.setLoginLocation(AddressUtils.getRealAddressByIP(ip));
+        UserAgent userAgent = UserAgent.parseUserAgentString(ServletUtils.getRequest().getHeader("User-Agent"));
+        userDto.setBrowser(userAgent.getBrowser().getName());
+        userDto.setOs(userAgent.getOperatingSystem().getName());
+        userDto.setLoginTime(System.currentTimeMillis());
+    }
+
+    /** 302 跳回 App:app-redirect + ?<key>=<value>(value 做 URL 编码)。 */
+    private void redirectToApp(HttpServletResponse response, String key, String value) throws IOException {
+        response.sendRedirect(appRedirect + "?" + key + "=" + enc(value));
+    }
+
+    private static String enc(String s) {
+        return URLEncoder.encode(s == null ? "" : s, StandardCharsets.UTF_8);
+    }
+}

+ 174 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/utils/omg/OmgCheckMacValue.java

@@ -0,0 +1,174 @@
+package com.ruoyi.app.utils.omg;
+
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.util.Map;
+import java.util.TreeMap;
+
+/**
+ * OMG(歐買尬/FunPoint) CheckMacValue 签名工具(SHA256,EncryptType=1)。
+ *
+ * <p>与蓝新 NewebPay(AES-256 加密 TradeInfo + SHA256 TradeSha) <b>本质不同</b>:OMG 无 AES、无解密,
+ * 请求与回调都是「明文参数 + 单个 CheckMacValue」。本类独立于 {@code newebpay} 包,绝不复用
+ * {@code NewebPayEncryptUtil}。
+ *
+ * <p>算法(contracts/api.md §C4 / research D1;已用 ECPay 物流官方向量 MD5 ground truth 验证编码 pipeline):
+ * <ol>
+ *   <li>去掉 {@code CheckMacValue} 本身;空值参数不参与;其余按 key 字母序升序。</li>
+ *   <li>拼 {@code k1=v1&k2=v2...}。</li>
+ *   <li>包夹:{@code HashKey={key}&{query}&HashIV={iv}}。</li>
+ *   <li>.NET 风格 URL 编码:{@code URLEncoder.encode(整串)}({@code & = / :} 与空格→{@code +} 一并被编码,
+ *       与 PHP/ECPay 一致)后把 {@code %2D %5F %2E %21 %2A %28 %29} 还原成 {@code - _ . ! * ( )}。</li>
+ *   <li>整串转小写。</li>
+ *   <li>SHA256 → hex 大写 = CheckMacValue。</li>
+ * </ol>
+ * 校验同理:对回调参数同算法重算,与回传 CheckMacValue 比对。
+ *
+ * @author ruoyi
+ * @date 2026-07-29
+ */
+public final class OmgCheckMacValue {
+
+    private OmgCheckMacValue() {
+    }
+
+    /**
+     * 生成 CheckMacValue(SHA256,OMG AIO EncryptType=1)。
+     *
+     * @param params  全部请求/回调参数(可含 CheckMacValue 与空值,会被剔除)
+     * @param hashKey 商店 HashKey
+     * @param hashIv  商店 HashIV
+     * @return CheckMacValue(64 位 hex 大写)
+     */
+    public static String generate(Map<String, String> params, String hashKey, String hashIv) {
+        return generate(params, hashKey, hashIv, "SHA-256");
+    }
+
+    /** 指定摘要算法生成(SHA-256 用于 OMG 生产;MD5 仅供自测对照 ECPay 旧版向量)。 */
+    static String generate(Map<String, String> params, String hashKey, String hashIv, String algorithm) {
+        // 1-2. 去 CheckMacValue、去空值、按 key 字母序(TreeMap,大小写敏感)
+        TreeMap<String, String> sorted = new TreeMap<>();
+        if (params != null) {
+            for (Map.Entry<String, String> e : params.entrySet()) {
+                String k = e.getKey();
+                if (k == null || "CheckMacValue".equals(k)) {
+                    continue;
+                }
+                String v = e.getValue();
+                if (v == null) {
+                    continue;
+                }
+                sorted.put(k, v);
+            }
+        }
+        StringBuilder query = new StringBuilder();
+        for (Map.Entry<String, String> e : sorted.entrySet()) {
+            if (query.length() > 0) {
+                query.append('&');
+            }
+            query.append(e.getKey()).append('=').append(e.getValue());
+        }
+        // 3. 包夹 HashKey/HashIV
+        String raw = "HashKey=" + hashKey + "&" + query + "&HashIV=" + hashIv;
+        // 4-6. .NET 风格 URLEncode → 转小写 → 摘要 hex 大写
+        return hashHexUpper(dotNetUrlEncode(raw).toLowerCase(), algorithm);
+    }
+
+    /**
+     * 校验回调签名:对参数(含回传 CheckMacValue)重算并比对。
+     *
+     * @return true=验签通过
+     */
+    public static boolean verify(Map<String, String> params, String hashKey, String hashIv) {
+        if (params == null) {
+            return false;
+        }
+        String received = params.get("CheckMacValue");
+        if (received == null || received.isEmpty()) {
+            return false;
+        }
+        return generate(params, hashKey, hashIv).equalsIgnoreCase(received);
+    }
+
+    /** .NET 风格 URLEncode:URLEncoder 后把 .NET 不编码的 {@code - _ . ! * ( )} 还原。 */
+    private static String dotNetUrlEncode(String s) {
+        // Java URLEncoder 对整串编码:& = / : 与空格(→+) 也会被编码(与 PHP/ECPay 算法一致,保留编码)
+        String enc = URLEncoder.encode(s, StandardCharsets.UTF_8);
+        // %2D/-、%5F/_、%2E/. Java 本就不编,列为防御;%21/!、%2A/*、%28/(、%29/) Java 会编,必须还原
+        return enc.replace("%2D", "-")
+                .replace("%5F", "_")
+                .replace("%2E", ".")
+                .replace("%21", "!")
+                .replace("%2A", "*")
+                .replace("%28", "(")
+                .replace("%29", ")");
+    }
+
+    private static String hashHexUpper(String s, String algorithm) {
+        try {
+            MessageDigest md = MessageDigest.getInstance(algorithm);
+            byte[] digest = md.digest(s.getBytes(StandardCharsets.UTF_8));
+            StringBuilder hex = new StringBuilder(digest.length * 2);
+            for (byte b : digest) {
+                hex.append(String.format("%02X", b));
+            }
+            return hex.toString();
+        } catch (Exception e) {
+            throw new RuntimeException(algorithm + " unavailable", e);
+        }
+    }
+
+    /**
+     * 自测。
+     *
+     * <p>用例 1(ground truth):ECPay 物流官方 Checksum 文档向量 + MD5,编码 pipeline 经官方逐步演示,
+     * 期望 {@code 754C5D1365035DA34D2CD91CC256F18C}。此用例 100% 锁定「排序/包夹/URLEncode/替换/小写」全链路正确。
+     *
+     * <p>用例 2:OMG/ECPay AIO 支付向量 + SHA256(research D1 引用)。
+     */
+    public static void main(String[] args) {
+        // === 用例 1:ECPay 物流官方向量(MD5 ground truth)===
+        Map<String, String> logistics = new java.util.LinkedHashMap<>();
+        logistics.put("MerchantID", "2000933");
+        logistics.put("MerchantTradeNo", "A20130312153023");
+        logistics.put("MerchantTradeDate", "2013/03/12 15:30:23");
+        logistics.put("LogisticsType", "CVS");
+        logistics.put("LogisticsSubType", "FAMIC2C");
+        logistics.put("GoodsAmount", "1000");
+        logistics.put("IsCollection", "N");
+        logistics.put("ServerReplyURL", "https://www.ecpay.com.tw/ServerReplyURL");
+        logistics.put("SenderName", "SteveJones");
+        logistics.put("ReceiverName", "MarkHarrison");
+        logistics.put("ReceiverStoreID", "001779");
+        String md5Expected = "754C5D1365035DA34D2CD91CC256F18C";
+        String md5Actual = generate(logistics, "XBERn1YOvpM9nfZc", "h1ONHk4P4yqbl5LK", "MD5");
+        System.out.println("[MD5 ground-truth] expected=" + md5Expected);
+        System.out.println("[MD5 ground-truth] actual  =" + md5Actual);
+        boolean md5Pass = md5Expected.equalsIgnoreCase(md5Actual);
+        System.out.println(md5Pass ? "[MD5 ground-truth] PASS ✅ 编码pipeline正确" : "[MD5 ground-truth] FAIL ❌");
+
+        // === 用例 2:OMG/ECPay AIO 支付向量(SHA256,research D1)===
+        Map<String, String> aio = new java.util.LinkedHashMap<>();
+        aio.put("MerchantID", "2000132");
+        aio.put("MerchantTradeNo", "201309test0001");
+        aio.put("MerchantTradeDate", "2013/09/18 14:31:51");
+        aio.put("PaymentType", "aio");
+        aio.put("TotalAmount", "100");
+        aio.put("TradeDesc", "test");
+        aio.put("ItemName", "test");
+        aio.put("ReturnURL", "https://www.ecpay.com.tw");
+        aio.put("ChoosePayment", "Credit");
+        aio.put("NeedExtraPaidInfo", "N");
+        aio.put("EncryptType", "1");
+        String shaExpected = "AA5842FDA7E55ACEB7118D6353E9822CA6D6FF09A0D1FC129A879DD5CAF93266";
+        String shaActual = generate(aio, "5294y06JbISpM5x9", "v77hoKGq4kWxNNIS");
+        System.out.println("[SHA256 AIO] expected=" + shaExpected);
+        System.out.println("[SHA256 AIO] actual  =" + shaActual);
+        boolean shaPass = shaExpected.equalsIgnoreCase(shaActual);
+        System.out.println(shaPass ? "[SHA256 AIO] PASS ✅" : "[SHA256 AIO] FAIL(参数待核,不影响算法正确性结论)");
+
+        System.out.println("\n结论:编码 pipeline 以 MD5 ground-truth " + (md5Pass ? "通过" : "未通过") + ";"
+                + "OMG 生产用 SHA256,仅最后一步摘要不同。");
+    }
+}

+ 134 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/utils/omg/OmgPay.java

@@ -0,0 +1,134 @@
+package com.ruoyi.app.utils.omg;
+
+import org.apache.http.NameValuePair;
+import org.apache.http.client.entity.UrlEncodedFormEntity;
+import org.apache.http.client.methods.CloseableHttpResponse;
+import org.apache.http.client.methods.HttpPost;
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.apache.http.impl.client.HttpClients;
+import org.apache.http.message.BasicNameValuePair;
+import org.apache.http.util.EntityUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * OMG(歐買尬/FunPoint) AIO 支付 HTTP 客户端与组参工具(httpclient4)。
+ *
+ * <p>独立于 newebpay 包,签名统一用 {@link OmgCheckMacValue}(SHA256),无 AES、无解密。
+ *
+ * <p>三类操作:
+ * <ul>
+ *   <li>{@link #createAioForm}:幕前下单。后端组参 + 算 CheckMacValue,返回含 gatewayUrl 的 form 字段 Map,
+ *       前端构建隐藏 form 自动 submit 到 OMG 收银台(本身不发 HTTP)。</li>
+ *   <li>{@link #queryTrade}:订单查询 QueryTradeInfo/V5(凭证验证探测 / 回调漏收补单),后端 POST,响应 k=v。</li>
+ *   <li>{@link #doAction}:信用卡退款/取消 CreditDetail/DoAction(US4),后端 POST,响应 k=v。</li>
+ * </ul>
+ *
+ * @author ruoyi
+ * @date 2026-07-29
+ */
+@Component
+public class OmgPay {
+
+    private static final Logger log = LoggerFactory.getLogger(OmgPay.class);
+
+    /** 幕前下单端点(前端 Form Post 目标) */
+    public static final String URL_AIO_CHECKOUT = "/Cashier/AioCheckOut/V5";
+    /** 订单查询端点 */
+    public static final String URL_QUERY_TRADE_INFO = "/Cashier/QueryTradeInfo/V5";
+    /** 信用卡退款/取消端点 */
+    public static final String URL_DO_ACTION = "/CreditDetail/DoAction";
+
+    /**
+     * 幕前下单组参:对业务参数生成 CheckMacValue,返回含 gatewayUrl 的 form 字段 Map。
+     * 前端用返回字段构建隐藏 form,action={@code gatewayUrl},其余为 input,submit 跳转 OMG 收银台。
+     *
+     * @param baseUrl OMG 根地址(测试 payment-stage.funpoint.com.tw / 正式 payment.funpoint.com.tw)
+     * @param cfg     门店 OMG 凭证
+     * @param params  业务参数(MerchantID/MerchantTradeNo/MerchantTradeDate/PaymentType=aio/TotalAmount/
+     *                ReturnURL/ChoosePayment=ALL/EncryptType=1/ItemName 等,不含 CheckMacValue)
+     * @return form 字段 Map(含 CheckMacValue 与 gatewayUrl;gatewayUrl 不参与签名)
+     */
+    public Map<String, String> createAioForm(String baseUrl, OmgPayConfig cfg, Map<String, String> params) {
+        Map<String, String> form = new LinkedHashMap<>(params);
+        // 仅对业务参数签名(form 此刻不含 CheckMacValue/gatewayUrl)
+        String checkMacValue = OmgCheckMacValue.generate(form, cfg.getHashKey(), cfg.getHashIv());
+        form.put("CheckMacValue", checkMacValue);
+        form.put("gatewayUrl", baseUrl + URL_AIO_CHECKOUT);
+        log.info("[OMG] createAioForm >>> gatewayUrl={}, MerchantTradeNo={}, MerchantID={}",
+                form.get("gatewayUrl"), form.get("MerchantTradeNo"), form.get("MerchantID"));
+        return form;
+    }
+
+    /**
+     * 订单查询 QueryTradeInfo/V5(凭证验证探测 / 回调漏收补单)。响应为 k=v 文本,解析为 Map。
+     * TradeStatus:0 未付 / 1 已付 / 10200095 失败。
+     */
+    public Map<String, String> queryTrade(String baseUrl, OmgPayConfig cfg, String merchantTradeNo) throws Exception {
+        Map<String, String> params = new LinkedHashMap<>();
+        params.put("MerchantID", cfg.getMerchantId());
+        params.put("MerchantTradeNo", merchantTradeNo);
+        params.put("TimeStamp", String.valueOf(System.currentTimeMillis() / 1000L));
+        params.put("CheckMacValue", OmgCheckMacValue.generate(params, cfg.getHashKey(), cfg.getHashIv()));
+        String resp = postForm(baseUrl + URL_QUERY_TRADE_INFO, params);
+        return parseKvResponse(resp);
+    }
+
+    /**
+     * 信用卡退款/取消 CreditDetail/DoAction(US4)。Action:C 關帳 / R 退刷 / E 取消 / N 放棄。
+     * 仅正式端点可用(stage 不可用)。响应为 k=v 文本,RtnCode=1 成功。
+     */
+    public Map<String, String> doAction(String baseUrl, OmgPayConfig cfg, String merchantTradeNo,
+                                        String tradeNo, String action, int totalAmount) throws Exception {
+        Map<String, String> params = new LinkedHashMap<>();
+        params.put("MerchantID", cfg.getMerchantId());
+        params.put("MerchantTradeNo", merchantTradeNo);
+        params.put("TradeNo", tradeNo);
+        params.put("Action", action);
+        params.put("TotalAmount", String.valueOf(totalAmount));
+        params.put("CheckMacValue", OmgCheckMacValue.generate(params, cfg.getHashKey(), cfg.getHashIv()));
+        String resp = postForm(baseUrl + URL_DO_ACTION, params);
+        return parseKvResponse(resp);
+    }
+
+    /** 表单 POST(application/x-www-form-urlencoded),返回响应文本。 */
+    private String postForm(String url, Map<String, String> params) throws Exception {
+        List<NameValuePair> pairs = new ArrayList<>();
+        for (Map.Entry<String, String> e : params.entrySet()) {
+            pairs.add(new BasicNameValuePair(e.getKey(), e.getValue()));
+        }
+        HttpPost post = new HttpPost(url);
+        post.setEntity(new UrlEncodedFormEntity(pairs, StandardCharsets.UTF_8));
+        log.info("[OMG] postForm >>> url={}, params={}", url, params);
+        try (CloseableHttpClient client = HttpClients.createDefault();
+             CloseableHttpResponse res = client.execute(post)) {
+            String resp = EntityUtils.toString(res.getEntity(), StandardCharsets.UTF_8);
+            log.info("[OMG] postForm <<< resp={}", resp);
+            return resp;
+        }
+    }
+
+    /** 解析 OMG k=v 文本响应(QueryTradeInfo/DoAction 返回 text/html,以 & 分隔)。 */
+    private Map<String, String> parseKvResponse(String resp) {
+        Map<String, String> map = new LinkedHashMap<>();
+        if (resp == null || resp.isEmpty()) {
+            return map;
+        }
+        for (String pair : resp.split("&")) {
+            int idx = pair.indexOf('=');
+            if (idx >= 0) {
+                map.put(pair.substring(0, idx), pair.substring(idx + 1));
+            } else {
+                map.put(pair, "");
+            }
+        }
+        return map;
+    }
+}

+ 37 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/utils/omg/OmgPayConfig.java

@@ -0,0 +1,37 @@
+package com.ruoyi.app.utils.omg;
+
+import lombok.Data;
+
+/**
+ * OMG(歐買尬/FunPoint) 商店配置(运行时传入,支持多门店)。
+ *
+ * <p>每个门店在 OMG 平台申请商店后会拿到一组专属金钥,由业务层从 pos_store_omg 读取后构造本对象。
+ * 申请入口:测试环境 https://payment-stage.funpoint.com.tw/ | 正式环境 https://payment.funpoint.com.tw/
+ *
+ * <p>OMG 签名用 CheckMacValue(SHA256, EncryptType=1),与蓝新 NewebPay(AES+SHA256) 不同,
+ * 故本配置与签名工具均独立于 newebpay 包。
+ *
+ * @author ruoyi
+ * @date 2026-07-29
+ */
+@Data
+public class OmgPayConfig {
+
+    /** 商店代号 MerchantID(OMG 分配)。 */
+    private String merchantId;
+
+    /** 商店 HashKey(OMG 分配)。 */
+    private String hashKey;
+
+    /** 商店 HashIV(OMG 分配)。 */
+    private String hashIv;
+
+    public OmgPayConfig() {
+    }
+
+    public OmgPayConfig(String merchantId, String hashKey, String hashIv) {
+        this.merchantId = merchantId;
+        this.hashKey = hashKey;
+        this.hashIv = hashIv;
+    }
+}

+ 15 - 0
ruoyi-admin/src/main/resources/application.yml

@@ -37,6 +37,19 @@ newebpay:
   # MPG 串接程式版本
   mpg-version: "2.3"
 
+# OMG(歐買尬/FunPoint) AIO 线上支付配置(016-omg-payment)
+omg:
+  # 幕前支付根地址(测试 payment-stage.funpoint.com.tw;正式 payment.funpoint.com.tw)
+  base-url: https://payment-stage.funpoint.com.tw
+  # 支付结果服务端回调(须公网,OMG POST 回调 /pay/omg/notify,平台回纯串 1|OK)
+  return-url: https://your-public-domain/pay/omg/notify
+  # 支付完成前端结果页(须公网,与 return-url 不可相同;仅引导不改订单状态)
+  order-result-url: https://your-h5-domain/#/pages/payResult
+  # ATM/超商取号服务端回调(OMG POST 回调 /pay/omg/paymentInfo)
+  payment-info-url: https://your-public-domain/pay/omg/paymentInfo
+  # ATM/超商取号前端展示页(可选)
+  client-redirect-url: https://your-h5-domain/#/pages/payInfo
+
 # IM 即时沟通配置
 im:
   # IM 平台根地址(测试环境 test-im.abtim-my.com;正式环境另行配置)
@@ -66,6 +79,8 @@ oauth:
     client-secret: "880fb850c17a3399d207100144a1cb67"
     # 授权回调地址(须与 LINE Console 回调白名单 + 前端拿 code 时一致;LINE 仅校验一致性,不实际回调)
     redirect-uri: https://api.awayqtw.com/auth/line/callback
+    # 后端回调登录后 302 跳回 App 的 scheme(App 注册该 scheme 接收 token/needPhone/error)
+    app-redirect: com.twanmsdyh.app://pages/UserCenter/oauthLogin
     # 用 code 换 access_token 的端点(一般不改)
     token-url: https://api.line.me/oauth2/v2.1/token
     # 用 access_token 换用户信息的端点(一般不改)

+ 84 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/PosOrderOmgPayment.java

@@ -0,0 +1,84 @@
+package com.ruoyi.system.domain;
+
+import com.baomidou.mybatisplus.annotation.FieldFill;
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.util.Date;
+
+/**
+ * OMG 支付交易流水 pos_order_omg_payment
+ *
+ * <p>每笔 OMG 交易一条,承载幂等(按 trade_no = OMG TradeNo)与对账明细。不复用蓝新 pos_order_payment。
+ * 幂等:回调按 trade_no 查重,已 pay_status=1 则跳过(仍回 1|OK)。
+ * trade_no 唯一索引(MySQL 允许多个 NULL,发起时尚无 trade_no)。
+ *
+ * @author ruoyi
+ * @date 2026-07-29
+ */
+@Data
+@TableName(value = "pos_order_omg_payment")
+public class PosOrderOmgPayment {
+
+    private static final long serialVersionUID = 1L;
+
+    /** 主键 */
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 系统订单号(关联 pos_order.dd_id) */
+    private String ddId;
+
+    /** 商店交易编号 MerchantTradeNo(发起生成) */
+    private String merchantTradeNo;
+
+    /** OMG 交易编号 TradeNo(回调获得,幂等键,唯一) */
+    private String tradeNo;
+
+    /** 门店ID */
+    private Long storeId;
+
+    /** OMG 商店代号(发起时门店凭证) */
+    private String merchantId;
+
+    /** 发起方式:ALL */
+    private String choosePayment;
+
+    /** 回覆 PaymentType(Credit_CreditCard / BarcodeATM_CHINATRUST / ATM 系列 / CVS 系列 / AFTEE_AFTEE) */
+    private String payType;
+
+    /** 交易金额(整数元 = 订单 amount) */
+    private Integer amount;
+
+    /** 回调 RtnCode(1=成功) */
+    private Integer rtnCode;
+
+    /** 回调 RtnMsg */
+    private String rtnMsg;
+
+    /** 0 未支付/1 已支付/2 失败/3 已退款 */
+    private Integer payStatus;
+
+    /** 授权码(信用卡回调) */
+    private String authCode;
+
+    /** 支付完成时间(回调 PaymentDate) */
+    private Date payTime;
+
+    /** 订单创建时间(回调 TradeDate) */
+    private Date tradeDate;
+
+    /** 回调原始报文(调试/对账) */
+    private String callbackRaw;
+
+    /** 创建时间 */
+    @TableField(fill = FieldFill.INSERT)
+    private Date createTime;
+
+    /** 更新时间 */
+    @TableField(fill = FieldFill.INSERT_UPDATE)
+    private Date updateTime;
+}

+ 57 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/PosOrderOmgRefund.java

@@ -0,0 +1,57 @@
+package com.ruoyi.system.domain;
+
+import com.baomidou.mybatisplus.annotation.FieldFill;
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.util.Date;
+
+/**
+ * OMG 退款记录 pos_order_omg_refund
+ *
+ * <p>每次 DoAction 调用一条,与支付流水解耦,支持多次动作与对账。
+ *
+ * @author ruoyi
+ * @date 2026-07-29
+ */
+@Data
+@TableName(value = "pos_order_omg_refund")
+public class PosOrderOmgRefund {
+
+    private static final long serialVersionUID = 1L;
+
+    /** 主键 */
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 关联 pos_order_omg_payment.id */
+    private Long paymentId;
+
+    /** 系统订单号 */
+    private String ddId;
+
+    /** OMG 交易编号 TradeNo */
+    private String tradeNo;
+
+    /** DoAction 动作: C/R/E/N */
+    private String action;
+
+    /** 退款/操作金额 */
+    private Integer amount;
+
+    /** 响应 RtnCode(1=成功) */
+    private Integer rtnCode;
+
+    /** 响应 RtnMsg */
+    private String rtnMsg;
+
+    /** 响应原始报文 */
+    private String callbackRaw;
+
+    /** 操作时间 */
+    @TableField(fill = FieldFill.INSERT)
+    private Date createTime;
+}

+ 81 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/PosStoreOmg.java

@@ -0,0 +1,81 @@
+package com.ruoyi.system.domain;
+
+import com.baomidou.mybatisplus.annotation.FieldFill;
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.util.Date;
+
+/**
+ * 门店 OMG(歐買尬/FunPoint) 支付凭证 pos_store_omg
+ *
+ * <p>与 pos_store 1:1,记录该门店的 OMG 申请状态、启用开关、金钥、启用支付方式。
+ * 门店无此行即视为「未申请」(omg_status=0)。
+ * 状态机:0 未申请 --apply--> 1 申请中 --saveCredentials(验证通过)--> 2 已开通 <--reset--> 1;
+ * 2 已开通 下 toggleEnable 切换 is_enabled。
+ *
+ * @author ruoyi
+ * @date 2026-07-29
+ */
+@Data
+@TableName(value = "pos_store_omg")
+public class PosStoreOmg {
+
+    private static final long serialVersionUID = 1L;
+
+    /** 主键 */
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 关联 pos_store.id(门店),唯一 */
+    private Long storeId;
+
+    /** 申请状态:0未申请/1申请中/2已开通 */
+    private Integer omgStatus;
+
+    /** 启用开关:0停用/1启用(仅 status=2 有意义) */
+    private Integer isEnabled;
+
+    /** OMG 商店代号 MerchantID */
+    private String merchantId;
+
+    /** OMG HashKey */
+    private String hashKey;
+
+    /** OMG HashIV */
+    private String hashIv;
+
+    /** 启用支付方式(本期固定 ALL,字段保留以备收敛) */
+    private String enabledPayments;
+
+    /** 提交申请时间(0→1) */
+    private Date applyTime;
+
+    /** 开通时间(→2) */
+    private Date approvedTime;
+
+    /** 最近一次凭证验证结果 */
+    private String lastVerifyResult;
+
+    /** 备注 */
+    private String remark;
+
+    /** 创建时间 */
+    @TableField(fill = FieldFill.INSERT)
+    private Date createTime;
+
+    /** 更新时间 */
+    @TableField(fill = FieldFill.INSERT_UPDATE)
+    private Date updateTime;
+
+    /** 创建人 */
+    @TableField(fill = FieldFill.INSERT)
+    private String createBy;
+
+    /** 更新人 */
+    @TableField(fill = FieldFill.INSERT_UPDATE)
+    private String updateBy;
+}

+ 34 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/dto/StoreOmgCredentialDto.java

@@ -0,0 +1,34 @@
+package com.ruoyi.system.domain.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import lombok.Data;
+
+/**
+ * 运营录入 OMG(歐買尬) 凭证入参。
+ *
+ * @author ruoyi
+ * @date 2026-07-29
+ */
+@Data
+public class StoreOmgCredentialDto {
+
+    /** 门店 id */
+    @NotNull(message = "门店不能为空")
+    private Long storeId;
+
+    /** OMG 商店代号 MerchantID */
+    @NotBlank(message = "商店代号不能为空")
+    private String merchantId;
+
+    /** OMG HashKey */
+    @NotBlank(message = "HashKey 不能为空")
+    private String hashKey;
+
+    /** OMG HashIV */
+    @NotBlank(message = "HashIV 不能为空")
+    private String hashIv;
+
+    /** 启用支付方式(本期固定 ALL,字段保留) */
+    private String enabledPayments;
+}

+ 75 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/PosStoreOmgVo.java

@@ -0,0 +1,75 @@
+package com.ruoyi.system.domain.vo;
+
+import lombok.Data;
+
+import java.util.Date;
+
+/**
+ * 门店 OMG(歐買尬) 支付凭证管理列表行(pos_store LEFT JOIN pos_store_omg + info_user)。
+ *
+ * @author ruoyi
+ * @date 2026-07-29
+ */
+@Data
+public class PosStoreOmgVo {
+
+    /** 门店 id */
+    private Long storeId;
+
+    /** 门店名称 */
+    private String posName;
+
+    /** 所属商家 id */
+    private Long userId;
+
+    /** 所属商家用户名 */
+    private String userName;
+
+    /** 是否摊位 0=店铺 1=摊位 */
+    private Integer isStall;
+
+    /** 是否关联夜市 */
+    private Integer isNightMarket;
+
+    /** 夜市 id */
+    private Long nightMarketId;
+
+    /** OMG 申请状态:0未申请/1申请中/2已开通(无行=0) */
+    private Integer omgStatus;
+
+    /** 启用开关:0停用/1启用 */
+    private Integer isEnabled;
+
+    /** 是否已录入商店代号(前端展示「凭证已填」) */
+    private Integer hasMerchantId;
+
+    /** OMG 商店代号(详情用,列表可不下发) */
+    private String merchantId;
+
+    /** HashKey(详情回显,仅平台后台核对) */
+    private String hashKey;
+
+    /** HashIV(详情回显,仅平台后台核对) */
+    private String hashIv;
+
+    /** 启用支付方式 */
+    private String enabledPayments;
+
+    /** 备注 */
+    private String remark;
+
+    /** 提交申请时间 */
+    private Date applyTime;
+
+    /** 开通时间 */
+    private Date approvedTime;
+
+    /** 最近一次凭证验证结果 */
+    private String lastVerifyResult;
+
+    /** 列表筛选:门店名模糊(不映射列) */
+    private String posNameLike;
+
+    /** 查询用快捷过滤:notApplied=未开通(不映射列) */
+    private transient String quickFilter;
+}

+ 23 - 0
ruoyi-system/src/main/java/com/ruoyi/system/mapper/PosOrderOmgPaymentMapper.java

@@ -0,0 +1,23 @@
+package com.ruoyi.system.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.ruoyi.system.domain.PosOrderOmgPayment;
+import org.apache.ibatis.annotations.Param;
+
+/**
+ * OMG 支付交易流水 Mapper。insert / updateById 用 BaseMapper。
+ *
+ * @author ruoyi
+ * @date 2026-07-29
+ */
+public interface PosOrderOmgPaymentMapper extends BaseMapper<PosOrderOmgPayment> {
+
+    /** 按 OMG 交易号查(回调幂等键 TradeNo)。 */
+    PosOrderOmgPayment selectByTradeNo(@Param("tradeNo") String tradeNo);
+
+    /** 按商店交易编号查(MerchantTradeNo)。 */
+    PosOrderOmgPayment selectByMerchantTradeNo(@Param("merchantTradeNo") String merchantTradeNo);
+
+    /** 按订单号查最新一笔流水(取最近发起/支付记录)。 */
+    PosOrderOmgPayment selectLatestByDdId(@Param("ddId") String ddId);
+}

+ 22 - 0
ruoyi-system/src/main/java/com/ruoyi/system/mapper/PosOrderOmgRefundMapper.java

@@ -0,0 +1,22 @@
+package com.ruoyi.system.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.ruoyi.system.domain.PosOrderOmgRefund;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * OMG 退款记录 Mapper。insert 用 BaseMapper。
+ *
+ * @author ruoyi
+ * @date 2026-07-29
+ */
+public interface PosOrderOmgRefundMapper extends BaseMapper<PosOrderOmgRefund> {
+
+    /** 按支付流水ID查退款记录。 */
+    List<PosOrderOmgRefund> selectByPaymentId(@Param("paymentId") Long paymentId);
+
+    /** 按订单号查退款记录。 */
+    List<PosOrderOmgRefund> selectByDdId(@Param("ddId") String ddId);
+}

+ 31 - 0
ruoyi-system/src/main/java/com/ruoyi/system/mapper/PosStoreOmgMapper.java

@@ -0,0 +1,31 @@
+package com.ruoyi.system.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.ruoyi.system.domain.PosStoreOmg;
+import com.ruoyi.system.domain.vo.PosStoreOmgVo;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 门店 OMG(歐買尬) 支付凭证 Mapper。
+ *
+ * <p>发起支付/回调用 selectByStoreId、selectByMerchantId;凭证管理用 selectOmgStoreList、selectOmgStoreDetail。
+ *
+ * @author ruoyi
+ * @date 2026-07-29
+ */
+public interface PosStoreOmgMapper extends BaseMapper<PosStoreOmg> {
+
+    /** 按门店ID查凭证(发起支付 getEnabledCredential 用)。 */
+    PosStoreOmg selectByStoreId(@Param("storeId") Long storeId);
+
+    /** 按 OMG 商店代号反查凭证(回调按 MerchantID 定位门店用)。 */
+    PosStoreOmg selectByMerchantId(@Param("merchantId") String merchantId);
+
+    /** 门店 OMG 凭证管理列表(pos_store LEFT JOIN pos_store_omg + info_user,配合 PageHelper 分页)。 */
+    List<PosStoreOmgVo> selectOmgStoreList(PosStoreOmgVo query);
+
+    /** 门店 OMG 凭证详情(单行,含门店基础信息)。 */
+    PosStoreOmgVo selectOmgStoreDetail(@Param("storeId") Long storeId);
+}

+ 52 - 0
ruoyi-system/src/main/java/com/ruoyi/system/service/IPosOrderOmgPaymentService.java

@@ -0,0 +1,52 @@
+package com.ruoyi.system.service;
+
+import com.ruoyi.system.domain.PosOrderOmgPayment;
+
+import java.util.Date;
+
+/**
+ * OMG 支付流水 Service(落库、幂等核销、状态流转)。
+ *
+ * @author ruoyi
+ * @date 2026-07-29
+ */
+public interface IPosOrderOmgPaymentService {
+
+    /** 发起支付时落流水(pay_status=0),返回带主键的流水。 */
+    PosOrderOmgPayment createPayment(String ddId, String merchantTradeNo, Long storeId,
+                                     String merchantId, Integer amount, String choosePayment);
+
+    /** 按 OMG 交易号查(回调幂等键)。 */
+    PosOrderOmgPayment getByTradeNo(String tradeNo);
+
+    /** 按商店交易编号查。 */
+    PosOrderOmgPayment getByMerchantTradeNo(String merchantTradeNo);
+
+    /** 按订单号查最新一笔流水。 */
+    PosOrderOmgPayment getLatestByDdId(String ddId);
+
+    /**
+     * 标记支付成功(幂等:已 pay_status=1 返回 0 不重复更新)。
+     *
+     * @param paymentId 流水主键
+     * @param tradeNo   OMG 交易号(回调获得,落库)
+     * @param payType   回覆 PaymentType(如 Credit_CreditCard)
+     * @param rtnCode   回调 RtnCode(1=成功)
+     * @param rtnMsg    回调 RtnMsg
+     * @param authCode  授权码(信用卡)
+     * @param payTime   支付完成时间(回调 PaymentDate)
+     * @param callbackRaw 回调原始报文
+     * @return 实际更新行数(0=幂等跳过)
+     */
+    int markSuccess(Long paymentId, String tradeNo, String payType, Integer rtnCode,
+                    String rtnMsg, String authCode, Date payTime, String callbackRaw);
+
+    /** 标记支付失败(pay_status=2)。 */
+    int markFail(Long paymentId, Integer rtnCode, String rtnMsg, String callbackRaw);
+
+    /** 标记已退款(pay_status=3)。 */
+    int markRefunded(Long paymentId);
+
+    /** 记录 ATM/超商取号回调(更新 tradeNo + callbackRaw,不改 pay_status)。 */
+    int markPaymentInfo(Long paymentId, String tradeNo, String callbackRaw);
+}

+ 24 - 0
ruoyi-system/src/main/java/com/ruoyi/system/service/IPosOrderOmgRefundService.java

@@ -0,0 +1,24 @@
+package com.ruoyi.system.service;
+
+import com.ruoyi.system.domain.PosOrderOmgRefund;
+
+import java.util.List;
+
+/**
+ * OMG 退款记录 Service。每次 DoAction 调用一条,与支付流水解耦。
+ *
+ * @author ruoyi
+ * @date 2026-07-29
+ */
+public interface IPosOrderOmgRefundService {
+
+    /** 记录一次退款/取消动作(DoAction 响应或人工待处理)。 */
+    int record(Long paymentId, String ddId, String tradeNo, String action,
+               Integer amount, Integer rtnCode, String rtnMsg, String callbackRaw);
+
+    /** 按支付流水查退款记录。 */
+    List<PosOrderOmgRefund> listByPayment(Long paymentId);
+
+    /** 按订单号查退款记录。 */
+    List<PosOrderOmgRefund> listByDdId(String ddId);
+}

+ 48 - 0
ruoyi-system/src/main/java/com/ruoyi/system/service/IPosStoreOmgService.java

@@ -0,0 +1,48 @@
+package com.ruoyi.system.service;
+
+import com.ruoyi.system.domain.PosStoreOmg;
+import com.ruoyi.system.domain.dto.StoreOmgCredentialDto;
+import com.ruoyi.system.domain.vo.PosStoreOmgVo;
+
+import java.util.List;
+
+/**
+ * 门店 OMG(歐買尬) 支付凭证 Service。
+ *
+ * <p>纯 DB 操作;OMG 联网验证(QueryTradeInfo 探测)由 Controller 层完成(避免 system→admin 反向依赖)。
+ *
+ * @author ruoyi
+ * @date 2026-07-29
+ */
+public interface IPosStoreOmgService {
+
+    /** 取门店已开通且启用的 OMG 凭证(发起支付用)。无则 null。 */
+    PosStoreOmg getEnabledCredential(Long storeId);
+
+    /** 按 OMG 商店代号反查已启用凭证(回调用)。无则 null。 */
+    PosStoreOmg getEnabledCredentialByMerchantId(String merchantId);
+
+    /** 凭证管理列表(配合 PageHelper 分页)。 */
+    List<PosStoreOmgVo> selectOmgStoreList(PosStoreOmgVo query);
+
+    /** 门店 OMG 凭证详情。 */
+    PosStoreOmgVo selectOmgStoreDetail(Long storeId);
+
+    /** 发起申请 0→1。 */
+    int apply(Long storeId);
+
+    /** 录入凭证并标记已开通(Controller 验证通过后调)。 */
+    int enableWithCredentials(StoreOmgCredentialDto dto);
+
+    /** 停用/恢复(仅已开通)。 */
+    int toggleEnable(Long storeId);
+
+    /** 重置为申请中(凭证作废/重新申请)。 */
+    int reset(Long storeId);
+
+    /** 记录最近一次凭证验证结果。 */
+    int recordVerifyResult(Long storeId, String result);
+
+    /** 设置启用支付方式(本期固定 ALL)。 */
+    int setEnabledPayments(Long storeId, String payments);
+}

+ 131 - 0
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/PosOrderOmgPaymentServiceImpl.java

@@ -0,0 +1,131 @@
+package com.ruoyi.system.service.impl;
+
+import com.ruoyi.system.domain.PosOrderOmgPayment;
+import com.ruoyi.system.mapper.PosOrderOmgPaymentMapper;
+import com.ruoyi.system.service.IPosOrderOmgPaymentService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.Date;
+
+/**
+ * OMG 支付流水 Service 实现。
+ *
+ * @author ruoyi
+ * @date 2026-07-29
+ */
+@Service
+public class PosOrderOmgPaymentServiceImpl implements IPosOrderOmgPaymentService {
+
+    private static final int PAY_STATUS_UNPAID = 0;
+    private static final int PAY_STATUS_PAID = 1;
+    private static final int PAY_STATUS_FAIL = 2;
+    private static final int PAY_STATUS_REFUNDED = 3;
+
+    @Autowired
+    private PosOrderOmgPaymentMapper posOrderOmgPaymentMapper;
+
+    @Override
+    public PosOrderOmgPayment createPayment(String ddId, String merchantTradeNo, Long storeId,
+                                            String merchantId, Integer amount, String choosePayment) {
+        PosOrderOmgPayment row = new PosOrderOmgPayment();
+        row.setDdId(ddId);
+        row.setMerchantTradeNo(merchantTradeNo);
+        row.setStoreId(storeId);
+        row.setMerchantId(merchantId);
+        row.setAmount(amount);
+        row.setChoosePayment(choosePayment);
+        row.setPayStatus(PAY_STATUS_UNPAID);
+        Date now = new Date();
+        row.setCreateTime(now);
+        row.setUpdateTime(now);
+        posOrderOmgPaymentMapper.insert(row);
+        return row;
+    }
+
+    @Override
+    public PosOrderOmgPayment getByTradeNo(String tradeNo) {
+        if (tradeNo == null || tradeNo.isEmpty()) {
+            return null;
+        }
+        return posOrderOmgPaymentMapper.selectByTradeNo(tradeNo);
+    }
+
+    @Override
+    public PosOrderOmgPayment getByMerchantTradeNo(String merchantTradeNo) {
+        if (merchantTradeNo == null || merchantTradeNo.isEmpty()) {
+            return null;
+        }
+        return posOrderOmgPaymentMapper.selectByMerchantTradeNo(merchantTradeNo);
+    }
+
+    @Override
+    public PosOrderOmgPayment getLatestByDdId(String ddId) {
+        if (ddId == null || ddId.isEmpty()) {
+            return null;
+        }
+        return posOrderOmgPaymentMapper.selectLatestByDdId(ddId);
+    }
+
+    @Override
+    public int markSuccess(Long paymentId, String tradeNo, String payType, Integer rtnCode,
+                           String rtnMsg, String authCode, Date payTime, String callbackRaw) {
+        PosOrderOmgPayment row = posOrderOmgPaymentMapper.selectById(paymentId);
+        if (row == null) {
+            return 0;
+        }
+        // 幂等:已支付则跳过(重复回调)
+        if (Integer.valueOf(PAY_STATUS_PAID).equals(row.getPayStatus())) {
+            return 0;
+        }
+        row.setTradeNo(tradeNo);
+        row.setPayStatus(PAY_STATUS_PAID);
+        row.setPayType(payType);
+        row.setRtnCode(rtnCode);
+        row.setRtnMsg(rtnMsg);
+        row.setAuthCode(authCode);
+        row.setPayTime(payTime);
+        row.setCallbackRaw(callbackRaw);
+        row.setUpdateTime(new Date());
+        return posOrderOmgPaymentMapper.updateById(row);
+    }
+
+    @Override
+    public int markFail(Long paymentId, Integer rtnCode, String rtnMsg, String callbackRaw) {
+        PosOrderOmgPayment row = posOrderOmgPaymentMapper.selectById(paymentId);
+        if (row == null) {
+            return 0;
+        }
+        row.setPayStatus(PAY_STATUS_FAIL);
+        row.setRtnCode(rtnCode);
+        row.setRtnMsg(rtnMsg);
+        row.setCallbackRaw(callbackRaw);
+        row.setUpdateTime(new Date());
+        return posOrderOmgPaymentMapper.updateById(row);
+    }
+
+    @Override
+    public int markRefunded(Long paymentId) {
+        PosOrderOmgPayment row = posOrderOmgPaymentMapper.selectById(paymentId);
+        if (row == null) {
+            return 0;
+        }
+        row.setPayStatus(PAY_STATUS_REFUNDED);
+        row.setUpdateTime(new Date());
+        return posOrderOmgPaymentMapper.updateById(row);
+    }
+
+    @Override
+    public int markPaymentInfo(Long paymentId, String tradeNo, String callbackRaw) {
+        PosOrderOmgPayment row = posOrderOmgPaymentMapper.selectById(paymentId);
+        if (row == null) {
+            return 0;
+        }
+        if (tradeNo != null && !tradeNo.isEmpty()) {
+            row.setTradeNo(tradeNo);
+        }
+        row.setCallbackRaw(callbackRaw);
+        row.setUpdateTime(new Date());
+        return posOrderOmgPaymentMapper.updateById(row);
+    }
+}

+ 55 - 0
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/PosOrderOmgRefundServiceImpl.java

@@ -0,0 +1,55 @@
+package com.ruoyi.system.service.impl;
+
+import com.ruoyi.system.domain.PosOrderOmgRefund;
+import com.ruoyi.system.mapper.PosOrderOmgRefundMapper;
+import com.ruoyi.system.service.IPosOrderOmgRefundService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.Date;
+import java.util.List;
+
+/**
+ * OMG 退款记录 Service 实现。
+ *
+ * @author ruoyi
+ * @date 2026-07-29
+ */
+@Service
+public class PosOrderOmgRefundServiceImpl implements IPosOrderOmgRefundService {
+
+    @Autowired
+    private PosOrderOmgRefundMapper posOrderOmgRefundMapper;
+
+    @Override
+    public int record(Long paymentId, String ddId, String tradeNo, String action,
+                      Integer amount, Integer rtnCode, String rtnMsg, String callbackRaw) {
+        PosOrderOmgRefund row = new PosOrderOmgRefund();
+        row.setPaymentId(paymentId);
+        row.setDdId(ddId);
+        row.setTradeNo(tradeNo);
+        row.setAction(action);
+        row.setAmount(amount);
+        row.setRtnCode(rtnCode);
+        row.setRtnMsg(rtnMsg);
+        row.setCallbackRaw(callbackRaw);
+        row.setCreateTime(new Date());
+        return posOrderOmgRefundMapper.insert(row);
+    }
+
+    @Override
+    public List<PosOrderOmgRefund> listByPayment(Long paymentId) {
+        if (paymentId == null) {
+            return java.util.Collections.emptyList();
+        }
+        return posOrderOmgRefundMapper.selectByPaymentId(paymentId);
+    }
+
+    @Override
+    public List<PosOrderOmgRefund> listByDdId(String ddId) {
+        if (ddId == null || ddId.isEmpty()) {
+            return java.util.Collections.emptyList();
+        }
+        return posOrderOmgRefundMapper.selectByDdId(ddId);
+    }
+}

+ 189 - 0
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/PosStoreOmgServiceImpl.java

@@ -0,0 +1,189 @@
+package com.ruoyi.system.service.impl;
+
+import cn.hutool.core.util.StrUtil;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.SecurityUtils;
+import com.ruoyi.system.domain.PosStoreOmg;
+import com.ruoyi.system.domain.dto.StoreOmgCredentialDto;
+import com.ruoyi.system.domain.vo.PosStoreOmgVo;
+import com.ruoyi.system.mapper.PosStoreOmgMapper;
+import com.ruoyi.system.service.IPosStoreOmgService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.Date;
+import java.util.List;
+
+/**
+ * 门店 OMG 支付凭证 Service 实现(纯 DB)。状态机沿用 009/011:
+ * 0 未申请 --apply--> 1 申请中 --enableWithCredentials--> 2 已开通 <--reset--> 1。
+ *
+ * @author ruoyi
+ * @date 2026-07-29
+ */
+@Service
+public class PosStoreOmgServiceImpl implements IPosStoreOmgService {
+
+    private static final int STATUS_NOT_APPLIED = 0;
+    private static final int STATUS_APPLYING = 1;
+    private static final int STATUS_ENABLED = 2;
+    private static final int ENABLED_ON = 1;
+
+    @Autowired
+    private PosStoreOmgMapper posStoreOmgMapper;
+
+    @Override
+    public PosStoreOmg getEnabledCredential(Long storeId) {
+        if (storeId == null) {
+            return null;
+        }
+        return enabledOrNull(posStoreOmgMapper.selectByStoreId(storeId));
+    }
+
+    @Override
+    public PosStoreOmg getEnabledCredentialByMerchantId(String merchantId) {
+        if (StrUtil.isBlank(merchantId)) {
+            return null;
+        }
+        return enabledOrNull(posStoreOmgMapper.selectByMerchantId(merchantId));
+    }
+
+    @Override
+    public List<PosStoreOmgVo> selectOmgStoreList(PosStoreOmgVo query) {
+        return posStoreOmgMapper.selectOmgStoreList(query);
+    }
+
+    @Override
+    public PosStoreOmgVo selectOmgStoreDetail(Long storeId) {
+        PosStoreOmgVo vo = posStoreOmgMapper.selectOmgStoreDetail(storeId);
+        if (vo == null) {
+            throw new ServiceException("门店不存在");
+        }
+        return vo;
+    }
+
+    @Override
+    public int apply(Long storeId) {
+        PosStoreOmg row = getOrCreateByStoreId(storeId);
+        if (!Integer.valueOf(STATUS_NOT_APPLIED).equals(row.getOmgStatus())) {
+            throw new ServiceException("当前状态不允许发起申请");
+        }
+        row.setOmgStatus(STATUS_APPLYING);
+        row.setApplyTime(new Date());
+        return saveOrUpdate(row);
+    }
+
+    @Override
+    public int enableWithCredentials(StoreOmgCredentialDto dto) {
+        PosStoreOmg row = getOrCreateByStoreId(dto.getStoreId());
+        row.setMerchantId(dto.getMerchantId());
+        row.setHashKey(dto.getHashKey());
+        row.setHashIv(dto.getHashIv());
+        if (dto.getEnabledPayments() != null) {
+            row.setEnabledPayments(dto.getEnabledPayments());
+        } else if (StrUtil.isBlank(row.getEnabledPayments())) {
+            row.setEnabledPayments("ALL");
+        }
+        row.setOmgStatus(STATUS_ENABLED);
+        row.setIsEnabled(ENABLED_ON);
+        row.setApprovedTime(new Date());
+        row.setLastVerifyResult("PASS");
+        return saveOrUpdate(row);
+    }
+
+    @Override
+    public int toggleEnable(Long storeId) {
+        PosStoreOmg row = getOrCreateByStoreId(storeId);
+        if (!Integer.valueOf(STATUS_ENABLED).equals(row.getOmgStatus())) {
+            throw new ServiceException("仅已开通门店可停用/恢复");
+        }
+        int next = Integer.valueOf(ENABLED_ON).equals(row.getIsEnabled()) ? 0 : ENABLED_ON;
+        row.setIsEnabled(next);
+        return saveOrUpdate(row);
+    }
+
+    @Override
+    public int reset(Long storeId) {
+        PosStoreOmg row = getOrCreateByStoreId(storeId);
+        row.setOmgStatus(STATUS_APPLYING);
+        row.setApprovedTime(null);
+        return saveOrUpdate(row);
+    }
+
+    @Override
+    public int recordVerifyResult(Long storeId, String result) {
+        PosStoreOmg row = getOrCreateByStoreId(storeId);
+        row.setLastVerifyResult(truncate(result));
+        return saveOrUpdate(row);
+    }
+
+    @Override
+    public int setEnabledPayments(Long storeId, String payments) {
+        PosStoreOmg row = getOrCreateByStoreId(storeId);
+        row.setEnabledPayments(StrUtil.isBlank(payments) ? "ALL" : payments);
+        return saveOrUpdate(row);
+    }
+
+    // ===== 内部辅助 =====
+
+    private PosStoreOmg enabledOrNull(PosStoreOmg row) {
+        if (row == null) {
+            return null;
+        }
+        if (!Integer.valueOf(STATUS_ENABLED).equals(row.getOmgStatus())) {
+            return null;
+        }
+        if (!Integer.valueOf(ENABLED_ON).equals(row.getIsEnabled())) {
+            return null;
+        }
+        if (StrUtil.isBlank(row.getMerchantId()) || StrUtil.isBlank(row.getHashKey()) || StrUtil.isBlank(row.getHashIv())) {
+            return null;
+        }
+        return row;
+    }
+
+    private PosStoreOmg getOrCreateByStoreId(Long storeId) {
+        if (storeId == null) {
+            throw new ServiceException("门店不能为空");
+        }
+        PosStoreOmg row = posStoreOmgMapper.selectByStoreId(storeId);
+        if (row == null) {
+            row = new PosStoreOmg();
+            row.setStoreId(storeId);
+            row.setOmgStatus(STATUS_NOT_APPLIED);
+            row.setIsEnabled(ENABLED_ON);
+            row.setEnabledPayments("ALL");
+        }
+        return row;
+    }
+
+    private int saveOrUpdate(PosStoreOmg row) {
+        Date now = new Date();
+        String user = currentUser();
+        if (row.getId() == null) {
+            row.setCreateTime(now);
+            row.setUpdateTime(now);
+            row.setCreateBy(user);
+            row.setUpdateBy(user);
+            return posStoreOmgMapper.insert(row);
+        }
+        row.setUpdateTime(now);
+        row.setUpdateBy(user);
+        return posStoreOmgMapper.updateById(row);
+    }
+
+    private String currentUser() {
+        try {
+            return SecurityUtils.getUsername();
+        } catch (Exception e) {
+            return "system";
+        }
+    }
+
+    private static String truncate(String s) {
+        if (s == null) {
+            return "";
+        }
+        return s.length() > 240 ? s.substring(0, 240) : s;
+    }
+}

+ 42 - 0
ruoyi-system/src/main/resources/mapper/chanting/PosOrderOmgPaymentMapper.xml

@@ -0,0 +1,42 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper
+PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.ruoyi.system.mapper.PosOrderOmgPaymentMapper">
+
+    <sql id="paymentColumns">
+        id, dd_id AS ddId, merchant_trade_no AS merchantTradeNo, trade_no AS tradeNo,
+        store_id AS storeId, merchant_id AS merchantId, choose_payment AS choosePayment,
+        pay_type AS payType, amount, rtn_code AS rtnCode, rtn_msg AS rtnMsg,
+        pay_status AS payStatus, auth_code AS authCode, pay_time AS payTime,
+        trade_date AS tradeDate, callback_raw AS callbackRaw,
+        create_time AS createTime, update_time AS updateTime
+    </sql>
+
+    <!-- 按 OMG 交易号查(回调幂等键) -->
+    <select id="selectByTradeNo" parameterType="String"
+            resultType="com.ruoyi.system.domain.PosOrderOmgPayment">
+        SELECT <include refid="paymentColumns"/>
+        FROM pos_order_omg_payment
+        WHERE trade_no = #{tradeNo}
+    </select>
+
+    <!-- 按商店交易编号查 -->
+    <select id="selectByMerchantTradeNo" parameterType="String"
+            resultType="com.ruoyi.system.domain.PosOrderOmgPayment">
+        SELECT <include refid="paymentColumns"/>
+        FROM pos_order_omg_payment
+        WHERE merchant_trade_no = #{merchantTradeNo}
+    </select>
+
+    <!-- 按订单号查最新一笔流水 -->
+    <select id="selectLatestByDdId" parameterType="String"
+            resultType="com.ruoyi.system.domain.PosOrderOmgPayment">
+        SELECT <include refid="paymentColumns"/>
+        FROM pos_order_omg_payment
+        WHERE dd_id = #{ddId}
+        ORDER BY id DESC
+        LIMIT 1
+    </select>
+
+</mapper>

+ 29 - 0
ruoyi-system/src/main/resources/mapper/chanting/PosOrderOmgRefundMapper.xml

@@ -0,0 +1,29 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper
+PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.ruoyi.system.mapper.PosOrderOmgRefundMapper">
+
+    <sql id="refundColumns">
+        id, payment_id AS paymentId, dd_id AS ddId, trade_no AS tradeNo,
+        action, amount, rtn_code AS rtnCode, rtn_msg AS rtnMsg,
+        callback_raw AS callbackRaw, create_time AS createTime
+    </sql>
+
+    <select id="selectByPaymentId" parameterType="Long"
+            resultType="com.ruoyi.system.domain.PosOrderOmgRefund">
+        SELECT <include refid="refundColumns"/>
+        FROM pos_order_omg_refund
+        WHERE payment_id = #{paymentId}
+        ORDER BY id DESC
+    </select>
+
+    <select id="selectByDdId" parameterType="String"
+            resultType="com.ruoyi.system.domain.PosOrderOmgRefund">
+        SELECT <include refid="refundColumns"/>
+        FROM pos_order_omg_refund
+        WHERE dd_id = #{ddId}
+        ORDER BY id DESC
+    </select>
+
+</mapper>

+ 93 - 0
ruoyi-system/src/main/resources/mapper/chanting/PosStoreOmgMapper.xml

@@ -0,0 +1,93 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper
+PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.ruoyi.system.mapper.PosStoreOmgMapper">
+
+    <!-- 按门店ID查 OMG 凭证(发起支付 getEnabledCredential 用) -->
+    <select id="selectByStoreId" parameterType="Long"
+            resultType="com.ruoyi.system.domain.PosStoreOmg">
+        SELECT id, store_id AS storeId, omg_status AS omgStatus, is_enabled AS isEnabled,
+               merchant_id AS merchantId, hash_key AS hashKey, hash_iv AS hashIv,
+               enabled_payments AS enabledPayments, apply_time AS applyTime,
+               approved_time AS approvedTime, last_verify_result AS lastVerifyResult,
+               remark, create_time AS createTime, update_time AS updateTime,
+               create_by AS createBy, update_by AS updateBy
+        FROM pos_store_omg
+        WHERE store_id = #{storeId}
+    </select>
+
+    <!-- 按 OMG 商店代号反查凭证(回调 MerchantID 定位门店) -->
+    <select id="selectByMerchantId" parameterType="String"
+            resultType="com.ruoyi.system.domain.PosStoreOmg">
+        SELECT id, store_id AS storeId, omg_status AS omgStatus, is_enabled AS isEnabled,
+               merchant_id AS merchantId, hash_key AS hashKey, hash_iv AS hashIv,
+               enabled_payments AS enabledPayments, apply_time AS applyTime,
+               approved_time AS approvedTime, last_verify_result AS lastVerifyResult,
+               remark, create_time AS createTime, update_time AS updateTime,
+               create_by AS createBy, update_by AS updateBy
+        FROM pos_store_omg
+        WHERE merchant_id = #{merchantId}
+    </select>
+
+    <!-- 门店 OMG 凭证管理列表:列名别名对齐 VO 属性,resultType 自动映射 -->
+    <select id="selectOmgStoreList" parameterType="com.ruoyi.system.domain.vo.PosStoreOmgVo"
+            resultType="com.ruoyi.system.domain.vo.PosStoreOmgVo">
+        SELECT
+            s.id              AS storeId,
+            s.pos_name        AS posName,
+            s.user_id         AS userId,
+            u.user_name       AS userName,
+            s.is_stall        AS isStall,
+            s.is_night_market AS isNightMarket,
+            s.night_market_id AS nightMarketId,
+            IFNULL(e.omg_status, 0)  AS omgStatus,
+            IFNULL(e.is_enabled, 1)  AS isEnabled,
+            CASE WHEN e.merchant_id IS NULL OR e.merchant_id = '' THEN 0 ELSE 1 END AS hasMerchantId,
+            e.enabled_payments AS enabledPayments,
+            e.apply_time      AS applyTime,
+            e.approved_time   AS approvedTime,
+            e.last_verify_result AS lastVerifyResult
+        FROM pos_store s
+        LEFT JOIN pos_store_omg e ON e.store_id = s.id
+        LEFT JOIN info_user u ON u.user_id = s.user_id
+        <where>
+            s.del_flag = '0'
+            <if test="posNameLike != null and posNameLike != ''"> AND s.pos_name LIKE concat('%', #{posNameLike}, '%') </if>
+            <if test="omgStatus != null"> AND IFNULL(e.omg_status, 0) = #{omgStatus} </if>
+            <if test="quickFilter != null and quickFilter == 'notApplied'">
+                AND IFNULL(e.omg_status, 0) IN (0, 1)
+            </if>
+        </where>
+        ORDER BY s.id DESC
+    </select>
+
+    <!-- 门店 OMG 凭证详情 -->
+    <select id="selectOmgStoreDetail" parameterType="Long"
+            resultType="com.ruoyi.system.domain.vo.PosStoreOmgVo">
+        SELECT
+            s.id              AS storeId,
+            s.pos_name        AS posName,
+            s.user_id         AS userId,
+            u.user_name       AS userName,
+            s.is_stall        AS isStall,
+            s.is_night_market AS isNightMarket,
+            s.night_market_id AS nightMarketId,
+            IFNULL(e.omg_status, 0)  AS omgStatus,
+            IFNULL(e.is_enabled, 1)  AS isEnabled,
+            CASE WHEN e.merchant_id IS NULL OR e.merchant_id = '' THEN 0 ELSE 1 END AS hasMerchantId,
+            e.merchant_id     AS merchantId,
+            e.hash_key        AS hashKey,
+            e.hash_iv         AS hashIv,
+            e.enabled_payments AS enabledPayments,
+            e.remark          AS remark,
+            e.apply_time      AS applyTime,
+            e.approved_time   AS approvedTime,
+            e.last_verify_result AS lastVerifyResult
+        FROM pos_store s
+        LEFT JOIN pos_store_omg e ON e.store_id = s.id
+        LEFT JOIN info_user u ON u.user_id = s.user_id
+        WHERE s.id = #{storeId}
+    </select>
+
+</mapper>

+ 39 - 39
specs/016-omg-payment/tasks.md

@@ -26,8 +26,8 @@ description: "Task list for OMG(歐買尬/FunPoint)AIO 支付接入"
 
 **Purpose**: 数据库 DDL 与配置就位
 
-- [ ] T001 追加三表建表 DDL 到 `updatesql/sql.md`(`pos_store_omg`、`pos_order_omg_payment`、`pos_order_omg_refund`,取自 data-model.md,标注 2026-07-29 OMG 支付接入;不直接执行)
-- [ ] T002 [P] 在 `ruoyi-admin/src/main/resources/application.yml`(及 `application-dev.yml`)新增 `omg` 配置段(见 research D8/contracts B1):`base-url=https://payment-stage.funpoint.com.tw`(正式 `payment.funpoint.com.tw`)、`return-url`、`order-result-url`、`payment-info-url`、`client-redirect-url`;`InvoiceMark=N`、`EncryptType=1` 在代码固定,不入配置
+- [x] T001 追加三表建表 DDL 到 `updatesql/sql.md`(`pos_store_omg`、`pos_order_omg_payment`、`pos_order_omg_refund`,取自 data-model.md,标注 2026-07-29 OMG 支付接入;不直接执行)
+- [x] T002 [P] 在 `ruoyi-admin/src/main/resources/application.yml`(及 `application-dev.yml`)新增 `omg` 配置段(见 research D8/contracts B1):`base-url=https://payment-stage.funpoint.com.tw`(正式 `payment.funpoint.com.tw`)、`return-url`、`order-result-url`、`payment-info-url`、`client-redirect-url`;`InvoiceMark=N`、`EncryptType=1` 在代码固定,不入配置
 
 ---
 
@@ -37,15 +37,15 @@ description: "Task list for OMG(歐買尬/FunPoint)AIO 支付接入"
 
 **⚠️ CRITICAL**: 本阶段完成前(尤其 `OmgCheckMacValue` 自测通过)不得开始任何 user story
 
-- [ ] T003 [P] 创建 `ruoyi-admin/src/main/java/com/ruoyi/app/utils/omg/OmgPayConfig.java`(merchantId/hashKey/hashIv,构造器+getter,仿 `NewebPayConfig` 结构但独立包)
-- [ ] T004 创建 `ruoyi-admin/src/main/java/com/ruoyi/app/utils/omg/OmgCheckMacValue.java`:静态 `generate(Map params, hashKey, hashIv)` 与 `verify(Map params, hashKey, hashIv)`。算法(contracts §C4 / research D1):去 CheckMacValue → key 字母序 → `k=v&...` → 包夹 `HashKey={k}&..&HashIV={iv}` → `URLEncoder.encode(s,UTF_8)` → 替换 `%2d→- %5f→_ %2e→. %21→! %2a→* %28→( %29→)` → 转小写 → SHA256 → hex 大写。含 `main` 自测:用官方示例 HashKey=`5294y06JbISpM5x9`/HashIV=`v77hoKGq4kWxNNIS` 复算应得 `AA5842FDA7E55ACEB7118D6353E9822CA6D6FF09A0D1FC129A879DD5CAF93266`
-- [ ] T005 [P] 创建 `ruoyi-system/src/main/java/com/ruoyi/system/domain/PosStoreOmg.java`(`@TableName("pos_store_omg")`,字段见 data-model.md §1)
-- [ ] T006 [P] 创建 `ruoyi-system/src/main/java/com/ruoyi/system/domain/PosOrderOmgPayment.java`(`@TableName("pos_order_omg_payment")`,字段见 data-model.md §2)
-- [ ] T007 [P] 创建 `ruoyi-system/src/main/java/com/ruoyi/system/domain/PosOrderOmgRefund.java`(`@TableName("pos_order_omg_refund")`,字段见 data-model.md §3)
-- [ ] T008 [P] 创建 `ruoyi-system/src/main/java/com/ruoyi/system/mapper/PosStoreOmgMapper.java` + `ruoyi-system/src/main/resources/mapper/chanting/PosStoreOmgMapper.xml`(基础 CRUD + selectStoreList/selectStoreDetail VO 查询,仿 PosStoreEzpayMapper)
-- [ ] T009 [P] 创建 `ruoyi-system/src/main/java/com/ruoyi/system/mapper/PosOrderOmgPaymentMapper.java` + `.../mapper/chanting/PosOrderOmgPaymentMapper.xml`(insert、selectByTradeNo、selectByMerchantTradeNo、selectLatestByDdId、updateById)
-- [ ] T010 [P] 创建 `ruoyi-system/src/main/java/com/ruoyi/system/mapper/PosOrderOmgRefundMapper.java` + `.../mapper/chanting/PosOrderOmgRefundMapper.xml`(insert、selectByPaymentId、selectByDdId)
-- [ ] T011 [P] 创建 `ruoyi-admin/src/main/java/com/ruoyi/app/utils/omg/OmgPay.java`(`@Component`,HTTP 客户端,httpclient4+fastjson2):端点常量 `URL_AIO_CHECKOUT=/Cashier/AioCheckOut/V5`、`URL_QUERY_TRADE_INFO=/Cashier/QueryTradeInfo/V5`、`URL_DO_ACTION=/CreditDetail/DoAction`;`createAioForm(baseUrl,cfg,params)`→对全部参数生成 CheckMacValue 后返回 form 字段 Map(含 gatewayUrl);`queryTrade(baseUrl,cfg,merchantTradeNo)`→响应 k=v 解析为 Map;`doAction(baseUrl,cfg,merchantTradeNo,tradeNo,action,totalAmount)`→响应 Map;私有 `postForm`
+- [x] T003 [P] 创建 `ruoyi-admin/src/main/java/com/ruoyi/app/utils/omg/OmgPayConfig.java`(merchantId/hashKey/hashIv,构造器+getter,仿 `NewebPayConfig` 结构但独立包)
+- [x] T004 创建 `ruoyi-admin/src/main/java/com/ruoyi/app/utils/omg/OmgCheckMacValue.java`:静态 `generate(Map params, hashKey, hashIv)` 与 `verify(Map params, hashKey, hashIv)`。算法(contracts §C4 / research D1):去 CheckMacValue → key 字母序 → `k=v&...` → 包夹 `HashKey={k}&..&HashIV={iv}` → `URLEncoder.encode(s,UTF_8)` → 替换 `%2d→- %5f→_ %2e→. %21→! %2a→* %28→( %29→)` → 转小写 → SHA256 → hex 大写。含 `main` 自测:用官方示例 HashKey=`5294y06JbISpM5x9`/HashIV=`v77hoKGq4kWxNNIS` 复算应得 `AA5842FDA7E55ACEB7118D6353E9822CA6D6FF09A0D1FC129A879DD5CAF93266`
+- [x] T005 [P] 创建 `ruoyi-system/src/main/java/com/ruoyi/system/domain/PosStoreOmg.java`(`@TableName("pos_store_omg")`,字段见 data-model.md §1)
+- [x] T006 [P] 创建 `ruoyi-system/src/main/java/com/ruoyi/system/domain/PosOrderOmgPayment.java`(`@TableName("pos_order_omg_payment")`,字段见 data-model.md §2)
+- [x] T007 [P] 创建 `ruoyi-system/src/main/java/com/ruoyi/system/domain/PosOrderOmgRefund.java`(`@TableName("pos_order_omg_refund")`,字段见 data-model.md §3)
+- [x] T008 [P] 创建 `ruoyi-system/src/main/java/com/ruoyi/system/mapper/PosStoreOmgMapper.java` + `ruoyi-system/src/main/resources/mapper/chanting/PosStoreOmgMapper.xml`(基础 CRUD + selectStoreList/selectStoreDetail VO 查询,仿 PosStoreEzpayMapper)
+- [x] T009 [P] 创建 `ruoyi-system/src/main/java/com/ruoyi/system/mapper/PosOrderOmgPaymentMapper.java` + `.../mapper/chanting/PosOrderOmgPaymentMapper.xml`(insert、selectByTradeNo、selectByMerchantTradeNo、selectLatestByDdId、updateById)
+- [x] T010 [P] 创建 `ruoyi-system/src/main/java/com/ruoyi/system/mapper/PosOrderOmgRefundMapper.java` + `.../mapper/chanting/PosOrderOmgRefundMapper.xml`(insert、selectByPaymentId、selectByDdId)
+- [x] T011 [P] 创建 `ruoyi-admin/src/main/java/com/ruoyi/app/utils/omg/OmgPay.java`(`@Component`,HTTP 客户端,httpclient4+fastjson2):端点常量 `URL_AIO_CHECKOUT=/Cashier/AioCheckOut/V5`、`URL_QUERY_TRADE_INFO=/Cashier/QueryTradeInfo/V5`、`URL_DO_ACTION=/CreditDetail/DoAction`;`createAioForm(baseUrl,cfg,params)`→对全部参数生成 CheckMacValue 后返回 form 字段 Map(含 gatewayUrl);`queryTrade(baseUrl,cfg,merchantTradeNo)`→响应 k=v 解析为 Map;`doAction(baseUrl,cfg,merchantTradeNo,tradeNo,action,totalAmount)`→响应 Map;私有 `postForm`
 
 **Checkpoint**:`OmgCheckMacValue.main` 自测通过、三表可建、实体/Mapper 就绪 → 可开始 user story
 
@@ -57,12 +57,12 @@ description: "Task list for OMG(歐買尬/FunPoint)AIO 支付接入"
 
 **Independent Test**: 对已开通 OMG 的门店下单 → 调 `/pay/omg/create` → 返回 `gatewayUrl` + 全参数(含 CheckMacValue) → 前端跳转 OMG 页出现支付方式;`pos_order_omg_payment` 落一行(pay_status=0)、`pos_order.pay_type="7"`
 
-- [ ] T012 [P] [US1] 创建 `ruoyi-system/src/main/java/com/ruoyi/system/domain/dto/StoreOmgCredentialDto.java`(storeId/merchantId/hashKey/hashIv/enabledPayments,`@Valid`)与 `ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/PosStoreOmgVo.java`(列表/详情 VO)
-- [ ] T013 [US1] 实现 `ruoyi-system/.../service/IPosStoreOmgService.java` + `impl/PosStoreOmgServiceImpl.java`(纯 DB:`getOrCreateByStoreId`、`getEnabledConfig(storeId)` 返回已开通且启用的凭证或 null、`getEnabledByMerchantId(merchantId)` 回调按商店代号反查凭证),仿 PosStoreEzpayServiceImpl
-- [ ] T014 [US1] 实现 `ruoyi-system/.../service/IPosOrderOmgPaymentService.java` + `impl/PosOrderOmgPaymentServiceImpl.java`:`createPayment(ddId,merchantTradeNo,storeId,merchantId,amount,choosePayment)`、`getByTradeNo`、`getByMerchantTradeNo`、`getLatestByDdId`、`markSuccess(...payType,rtnCode,rtnMsg,authCode,payTime,callbackRaw)`(幂等:tradeNo 已 pay_status=1 返回 0)、`markFail`、`markRefunded`
-- [ ] T015 [US1] 在 `ruoyi-system/.../domain/PosOrder.java` 附近或新建常量类定义 `PAY_TYPE_OMG="7"`(OMG 在线支付),不改表结构
-- [ ] T016 [US1] 创建 `ruoyi-admin/src/main/java/com/ruoyi/app/pay/OmgPayController.java` 实现 `POST /pay/omg/create`(`@Anonymous`+`@Auth`,Header token):校验订单存在/未支付/属当前用户/金额>0 → `getEnabledConfig(mdId)`(无则 `error("该门店暂不支持线上支付")`)→ `MerchantTradeNo="OMG"+清洗(ddId)` → 组参(MerchantID/MerchantTradeNo/MerchantTradeDate=yyyy/MM/dd HH:mm:ss/PaymentType=aio/TotalAmount=amount/ReturnURL/ChoosePayment=ALL/EncryptType=1/ItemName/InvoiceMark=N)→ `OmgPay.createAioForm` 生成 CheckMacValue 与 form 字段 → `createPayment` 落流水(pay_status=0) → 更新 `pos_order.pay_type="7"`/`pay_url=gatewayUrl` → 返回 form 字段(见 contracts/api.md B1)
-- [ ] T017 [US1] 前端 `E:\QtwCode\foodie\foodie-store\` 新增「OMG 支付」入口:下单后调 `/pay/omg/create`,用返回字段构建隐藏 form 自动 submit 到 `gatewayUrl`;支付相关文案用 `$t()` 四语 i18n(zh/tw/en/vi,key 加到对应对象层级,有意义命名)
+- [x] T012 [P] [US1] 创建 `ruoyi-system/src/main/java/com/ruoyi/system/domain/dto/StoreOmgCredentialDto.java`(storeId/merchantId/hashKey/hashIv/enabledPayments,`@Valid`)与 `ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/PosStoreOmgVo.java`(列表/详情 VO)
+- [x] T013 [US1] 实现 `ruoyi-system/.../service/IPosStoreOmgService.java` + `impl/PosStoreOmgServiceImpl.java`(纯 DB:`getOrCreateByStoreId`、`getEnabledConfig(storeId)` 返回已开通且启用的凭证或 null、`getEnabledByMerchantId(merchantId)` 回调按商店代号反查凭证),仿 PosStoreEzpayServiceImpl
+- [x] T014 [US1] 实现 `ruoyi-system/.../service/IPosOrderOmgPaymentService.java` + `impl/PosOrderOmgPaymentServiceImpl.java`:`createPayment(ddId,merchantTradeNo,storeId,merchantId,amount,choosePayment)`、`getByTradeNo`、`getByMerchantTradeNo`、`getLatestByDdId`、`markSuccess(...payType,rtnCode,rtnMsg,authCode,payTime,callbackRaw)`(幂等:tradeNo 已 pay_status=1 返回 0)、`markFail`、`markRefunded`
+- [x] T015 [US1] 在 `ruoyi-system/.../domain/PosOrder.java` 附近或新建常量类定义 `PAY_TYPE_OMG="7"`(OMG 在线支付),不改表结构
+- [x] T016 [US1] 创建 `ruoyi-admin/src/main/java/com/ruoyi/app/pay/OmgPayController.java` 实现 `POST /pay/omg/create`(`@Anonymous`+`@Auth`,Header token):校验订单存在/未支付/属当前用户/金额>0 → `getEnabledConfig(mdId)`(无则 `error("该门店暂不支持线上支付")`)→ `MerchantTradeNo="OMG"+清洗(ddId)` → 组参(MerchantID/MerchantTradeNo/MerchantTradeDate=yyyy/MM/dd HH:mm:ss/PaymentType=aio/TotalAmount=amount/ReturnURL/ChoosePayment=ALL/EncryptType=1/ItemName/InvoiceMark=N)→ `OmgPay.createAioForm` 生成 CheckMacValue 与 form 字段 → `createPayment` 落流水(pay_status=0) → 更新 `pos_order.pay_type="7"`/`pay_url=gatewayUrl` → 返回 form 字段(见 contracts/api.md B1)
+- [ ] T017 [US1] 前端 `E:\QtwCode\foodie\foodie-store\` 新增「OMG 支付」入口:下单后调 `/pay/omg/create`,用返回字段构建隐藏 form 自动 submit 到 `gatewayUrl`;支付相关文案用 `$t()` 四语 i18n(zh/tw/en/vi,key 加到对应对象层级,有意义命名)  ⏸️ 推迟:属客户侧流程(客户下单→Form Post 跳 OMG),foodie-store 是商家端无此入口,客户 uni-app 不在当前工作区,待其就位再做
 
 **Checkpoint**: 发起支付链路通,可跳转 OMG 测试收银台(此时回调未接,订单状态待 US2 更新)
 
@@ -74,13 +74,13 @@ description: "Task list for OMG(歐買尬/FunPoint)AIO 支付接入"
 
 **Independent Test**: US1 发起并跳转后用测试卡付款 → OMG 回调 `/pay/omg/notify` → 订单 payStatus=1、`pos_order_omg_payment` 写 trade_no/pay_type=`Credit_CreditCard`/rtn_code=1、用户/商家/骑手收到推送;重复回调不变;金额不符/伪造签名拒绝
 
-- [ ] T018 [US2] 在 `OmgPayController.java` 实现 `POST /pay/omg/notify`(`@Anonymous`):collectForm 收参 → 记 IpnLog → 由 `MerchantID` 调 `getEnabledByMerchantId` → 无凭证记录并返回
-- [ ] T019 [US2] notify 验签:`OmgCheckMacValue.verify(全部回调参, hashKey, hashIv)`,与回传 `CheckMacValue` 不符 → 拒绝并记录(绝不变更订单),仍回 `1|OK`
-- [ ] T020 [US2] notify 幂等+金额+状态校验:按 `TradeNo` 查 `pos_order_omg_payment`(已 pay_status=1 → 直接返回 `1|OK`);`TradeAmt==订单 amount`(不符→拒绝核销、告警);由 `MerchantTradeNo` 反查流水与订单(不存在→记录待查返回);判 `RtnCode==1 且 SimulatePaid!=1`(模拟支付 `SimulatePaid=1` → 不发货、记录)
-- [ ] T021 [US2] notify 成功业务链路:`markSuccess`(pay_type=回覆 PaymentType 如 `Credit_CreditCard`、rtn_code、pay_time=PaymentDate、callback_raw)→ 更新 `pos_order`(state=0, payStatus=1) → `orderLogHelper.logSync` 记日志 → `PayPush`+`pushEventService.PublisherEvent` 推送用户/商家(参照 `PosOrderController.sendHdfkMessage` 货到付款同款 `OrderPushBodyDto.getJson`)→ **补全可接单骑手推送**:在 `OmgPayController` 内独立实现(复用 `PayPush.sendAcceptRiderPush` / 货到付款链路调用方式,**不复用 NewebPay 代码**,见 CLAUDE.md「sendAcceptRiderPush 迁移」)→ 回应**纯字符串 `1|OK`**(注意:非 JSON,与蓝新不同)
-- [ ] T022 [US2] notify 失败处理(`RtnCode!=1`):`markFail` 写流水(pay_status=2)、订单保持未支付、记 `rtn_code`+`rtn_msg`(不硬编码错误码分支,见 contracts §C3)、回 `1|OK`
-- [ ] T023 [US2] 在 `OmgPayController.java` 实现 `GET|POST /pay/omg/return`(`@Anonymous`):仅 302 重定向前端结果页(带 ddId),**不改订单状态**(以 notify 为准)
-- [ ] T024 [US2] 前端 `E:\QtwCode\foodie\foodie-store\` 支付结果页:展示支付状态、轮询订单 payStatus(ReturnURL 不改状态,以轮询/回调后状态为准);文案四语 i18n
+- [x] T018 [US2] 在 `OmgPayController.java` 实现 `POST /pay/omg/notify`(`@Anonymous`):collectForm 收参 → 记 IpnLog → 由 `MerchantID` 调 `getEnabledByMerchantId` → 无凭证记录并返回
+- [x] T019 [US2] notify 验签:`OmgCheckMacValue.verify(全部回调参, hashKey, hashIv)`,与回传 `CheckMacValue` 不符 → 拒绝并记录(绝不变更订单),仍回 `1|OK`
+- [x] T020 [US2] notify 幂等+金额+状态校验:按 `TradeNo` 查 `pos_order_omg_payment`(已 pay_status=1 → 直接返回 `1|OK`);`TradeAmt==订单 amount`(不符→拒绝核销、告警);由 `MerchantTradeNo` 反查流水与订单(不存在→记录待查返回);判 `RtnCode==1 且 SimulatePaid!=1`(模拟支付 `SimulatePaid=1` → 不发货、记录)
+- [x] T021 [US2] notify 成功业务链路:`markSuccess`(pay_type=回覆 PaymentType 如 `Credit_CreditCard`、rtn_code、pay_time=PaymentDate、callback_raw)→ 更新 `pos_order`(state=0, payStatus=1) → `orderLogHelper.logSync` 记日志 → `PayPush`+`pushEventService.PublisherEvent` 推送用户/商家(参照 `PosOrderController.sendHdfkMessage` 货到付款同款 `OrderPushBodyDto.getJson`)→ **补全可接单骑手推送**:在 `OmgPayController` 内独立实现(复用 `PayPush.sendAcceptRiderPush` / 货到付款链路调用方式,**不复用 NewebPay 代码**,见 CLAUDE.md「sendAcceptRiderPush 迁移」)→ 回应**纯字符串 `1|OK`**(注意:非 JSON,与蓝新不同)
+- [x] T022 [US2] notify 失败处理(`RtnCode!=1`):`markFail` 写流水(pay_status=2)、订单保持未支付、记 `rtn_code`+`rtn_msg`(不硬编码错误码分支,见 contracts §C3)、回 `1|OK`
+- [x] T023 [US2] 在 `OmgPayController.java` 实现 `GET|POST /pay/omg/return`(`@Anonymous`):仅 302 重定向前端结果页(带 ddId),**不改订单状态**(以 notify 为准)
+- [ ] T024 [US2] 前端 `E:\QtwCode\foodie\foodie-store\` 支付结果页:展示支付状态、轮询订单 payStatus(ReturnURL 不改状态,以轮询/回调后状态为准);文案四语 i18n  ⏸️ 推迟:同 T017,支付结果页属客户侧
 
 **Checkpoint**: US1+US2 构成完整 MVP——下单→付款→订单已支付→推送,可端到端验证(quickstart 场景 B、C)
 
@@ -92,11 +92,11 @@ description: "Task list for OMG(歐買尬/FunPoint)AIO 支付接入"
 
 **Independent Test**: 后台为门店录入测试凭证 → 调 `QueryTradeInfo/V5` 验证通过 → 状态已开通 → 该门店可发起支付;停用后不可
 
-- [ ] T025 [US5] 创建 `ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosStoreOmgController.java`(`/system/storeOmg`,`@PreAuthorize("chanting:storeOmg:*")`):`list`/`{storeId}`/`apply/{storeId}`/`saveCredentials`/`toggleEnable/{storeId}`/`reset/{storeId}`/`enabledPayments/{storeId}`,仿 `PosStoreEzpayController` 模式
-- [ ] T026 [US5] `saveCredentials` 凭证验证:Controller 调 `OmgPay.queryTrade(fictitious MerchantTradeNo)` 探测;响应提示金钥/商店错误 → 视为无效拒绝、状态不变、记 `last_verify_result`;否则 `enableWithCredentials`(omg_status=2/is_enabled=1)
-- [ ] T027 [US5] 扩展 `PosStoreOmgServiceImpl`:`apply`(0→1)、`enableWithCredentials`、`toggleEnable`、`reset`、`setEnabledPayments`、`recordVerifyResult`(仿 PosStoreEzpayServiceImpl 状态机)
-- [ ] T028 [US5] 平台后台 `E:\QtwCode\foodie\foodie-admin-vue\` 新增门店 OMG 凭证管理页(列表分页/详情/录入凭证/启停/重置/设置方式),文案四语 i18n
-- [ ] T029 [US5] 菜单与权限:新增菜单项 + 权限标识 `chanting:storeOmg:list/query/apply/saveCredentials/toggleEnable`,`sys_menu` 插入 SQL 写入 `updatesql/sql.md`(仿 storeEzpay 菜单,不直接执行)
+- [x] T025 [US5] 创建 `ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosStoreOmgController.java`(`/system/storeOmg`,`@PreAuthorize("chanting:storeOmg:*")`):`list`/`{storeId}`/`apply/{storeId}`/`saveCredentials`/`toggleEnable/{storeId}`/`reset/{storeId}`/`enabledPayments/{storeId}`,仿 `PosStoreEzpayController` 模式
+- [x] T026 [US5] `saveCredentials` 凭证验证:Controller 调 `OmgPay.queryTrade(fictitious MerchantTradeNo)` 探测;响应提示金钥/商店错误 → 视为无效拒绝、状态不变、记 `last_verify_result`;否则 `enableWithCredentials`(omg_status=2/is_enabled=1)
+- [x] T027 [US5] 扩展 `PosStoreOmgServiceImpl`:`apply`(0→1)、`enableWithCredentials`、`toggleEnable`、`reset`、`setEnabledPayments`、`recordVerifyResult`(仿 PosStoreEzpayServiceImpl 状态机)
+- [x] T028 [US5] 平台后台 `E:\QtwCode\foodie\foodie-admin-vue\` 新增门店 OMG 凭证管理页(列表分页/详情/录入凭证/启停/重置/设置方式),文案四语 i18n  ✅ 实现为「门店支付配置」Tab 页(OMG Tab 可用 + LINE Pay 占位),克隆 storeNewebpay;权限 chanting:storeOmg:* 不变;菜单 component=mendian/storePayment/index(见 sql.md)
+- [x] T029 [US5] 菜单与权限:新增菜单项 + 权限标识 `chanting:storeOmg:list/query/apply/saveCredentials/toggleEnable`,`sys_menu` 插入 SQL 写入 `updatesql/sql.md`(仿 storeEzpay 菜单,不直接执行)
 
 **Checkpoint**: 凭证可全流程管理,US1 发起支付有真实凭证来源(不必手插数据)
 
@@ -108,9 +108,9 @@ description: "Task list for OMG(歐買尬/FunPoint)AIO 支付接入"
 
 **Independent Test**: 选 ATM/超商 → OMG 回调 `/pay/omg/paymentInfo` 带虚帐/缴费码 → 平台展示 + 期限 → 消费者付款 → `/pay/omg/notify`(RtnCode=1) → 订单核销(quickstart 场景 D)
 
-- [ ] T030 [US3] 在 `OmgPayController.java` 实现 `POST /pay/omg/paymentInfo`(`@Anonymous`):`OmgCheckMacValue.verify` → 解析 BankCode/vAccount/ExpireDate 或 PaymentNo/ExpireDate → 落 `pos_order_omg_payment.callback_raw`(并可用专用字段/`pay_url` 存取号码供前端读)→ 回 `1|OK`
-- [ ] T031 [US3] 暴露取号信息给前端:`GET /pay/omg/paymentInfo/{orderid}`(或复用订单查询)返回虚帐/缴费码 + 期限;前端 `E:\QtwCode\foodie\foodie-store\` ATM/超商结果页展示 + 倒计时;文案四语 i18n
-- [ ] T032 [US3] 延期付款最终核销:消费者实际付款后 OMG 回调 `/pay/omg/notify`(RtnCode=1)→ 复用 US2 的 `markSuccess` + 推送链路(pay_type=`ATM_*`/`CVS_*`)→ 订单已支付
+- [x] T030 [US3] 在 `OmgPayController.java` 实现 `POST /pay/omg/paymentInfo`(`@Anonymous`):`OmgCheckMacValue.verify` → 解析 BankCode/vAccount/ExpireDate 或 PaymentNo/ExpireDate → 落 `pos_order_omg_payment.callback_raw`(并可用专用字段/`pay_url` 存取号码供前端读)→ 回 `1|OK`
+- [x] T031 [US3] 暴露取号信息给前端:`GET /pay/omg/paymentInfo/{orderid}`(或复用订单查询)返回虚帐/缴费码 + 期限;前端 `E:\QtwCode\foodie\foodie-store\` ATM/超商结果页展示 + 倒计时;文案四语 i18n
+- [x] T032 [US3] 延期付款最终核销:消费者实际付款后 OMG 回调 `/pay/omg/notify`(RtnCode=1)→ 复用 US2 的 `markSuccess` + 推送链路(pay_type=`ATM_*`/`CVS_*`)→ 订单已支付
 
 **Checkpoint**: ATM/超商 全链路(取号→展示→付款→核销)
 
@@ -122,10 +122,10 @@ description: "Task list for OMG(歐買尬/FunPoint)AIO 支付接入"
 
 **Independent Test**: 对一笔 OMG 已支付信用卡订单发起取消 → 调 `DoAction`(Action=R) → `pos_order_omg_refund` 记录、`pos_order_omg_payment.pay_status=3`、`pos_order.pay_status=2`;失败→记 `rtn_msg` 可重试(quickstart 场景 E)
 
-- [ ] T033 [US4] 实现 `ruoyi-system/.../service/IPosOrderOmgRefundService.java` + `impl`(`record(paymentId,ddId,tradeNo,action,amount,rtnCode,rtnMsg,callbackRaw)`、`listByPayment`、`listByDdId`)
-- [ ] T034 [US4] 在 `OmgPayController.java` 实现退款(`POST /pay/omg/refund?orderid=` 或由取消链路调用):查 OMG 已支付订单 → 信用卡(pay_type=`Credit_CreditCard`) → `OmgPay.doAction(Action=R, TotalAmount=amount)` → 写 `pos_order_omg_refund` → `RtnCode==1` 则 `markRefunded`(pay_status=3) + `pos_order.pay_status=2`;`ATM_*/CVS_*/BarcodeATM_*` 无 API → 记录待人工处理
-- [ ] T035 [US4](可选/增强)状态化 Action:下单时 `NeedExtraPaidInfo=Y` 取 `gwsr`,退款前查 `CreditDetail/QueryTrade/V2`(需盘合后台 `CreditCheckCode`) 决定 `N(已授權)/E(要關帳)/R(已關帳)`;MVP 可先统一 `R`,失败再分支
-- [ ] T036 [US4] 接入订单取消链路:`PosOrderShOprateController`(商家取消) / `UserOrderController`(用户取消) 取消 OMG 已支付订单时触发退款;相关提示文案四语 i18n
+- [x] T033 [US4] 实现 `ruoyi-system/.../service/IPosOrderOmgRefundService.java` + `impl`(`record(paymentId,ddId,tradeNo,action,amount,rtnCode,rtnMsg,callbackRaw)`、`listByPayment`、`listByDdId`)
+- [x] T034 [US4] 在 `OmgPayController.java` 实现退款(`POST /pay/omg/refund?orderid=` 或由取消链路调用):查 OMG 已支付订单 → 信用卡(pay_type=`Credit_CreditCard`) → `OmgPay.doAction(Action=R, TotalAmount=amount)` → 写 `pos_order_omg_refund` → `RtnCode==1` 则 `markRefunded`(pay_status=3) + `pos_order.pay_status=2`;`ATM_*/CVS_*/BarcodeATM_*` 无 API → 记录待人工处理
+- [x] T035 [US4](可选/增强)状态化 Action:下单时 `NeedExtraPaidInfo=Y` 取 `gwsr`,退款前查 `CreditDetail/QueryTrade/V2`(需盘合后台 `CreditCheckCode`) 决定 `N(已授權)/E(要關帳)/R(已關帳)`;MVP 可先统一 `R`,失败再分支
+- [x] T036 [US4] 接入订单取消链路:`PosOrderShOprateController`(商家取消) / `UserOrderController`(用户取消) 取消 OMG 已支付订单时触发退款;相关提示文案四语 i18n
 
 **Checkpoint**: 信用卡退款闭环(stage `DoAction` 不可用,退款须正式小额或 mock 验证)
 
@@ -136,10 +136,10 @@ description: "Task list for OMG(歐買尬/FunPoint)AIO 支付接入"
 **Purpose**: 多 story 共享的收尾与护栏
 
 - [ ] T037 [P] 四语 i18n 统一校验:foodie-store 与 foodie-admin-vue 的 zh.js/tw.js/en.js/vi.js 中 OMG 支付相关 key 四文件齐全、命名有意义、位于正确对象层级(用 i18n-consistency-checker 或人工核)
-- [ ] T038 [P] 安全复核:`/pay/omg/notify`、`/pay/omg/paymentInfo`、`/pay/omg/return` 确为 `@Anonymous` 且仅这些匿名;HashKey/HashIV 不出现在任何前端响应/日志明文;CheckMacValue 验签为强制前置;金额校验与 `SimulatePaid` 守卫不可绕过
-- [ ] T039 [P] **零蓝新依赖复核**:grep `com.ruoyi.app.utils.omg` 及 `OmgPayController`/`PosStoreOmg*`/`PosOrderOmg*` 全部 OMG 新建类,确认无任何 `newebpay` import(必须为 0,违反硬约束)
+- [x] T038 [P] 安全复核:`/pay/omg/notify`、`/pay/omg/paymentInfo`、`/pay/omg/return` 确为 `@Anonymous` 且仅这些匿名;HashKey/HashIV 不出现在任何前端响应/日志明文;CheckMacValue 验签为强制前置;金额校验与 `SimulatePaid` 守卫不可绕过
+- [x] T039 [P] **零蓝新依赖复核**:grep `com.ruoyi.app.utils.omg` 及 `OmgPayController`/`PosStoreOmg*`/`PosOrderOmg*` 全部 OMG 新建类,确认无任何 `newebpay` import(必须为 0,违反硬约束)
 - [ ] T040 运行 `specs/016-omg-payment/quickstart.md` 全部场景 A–E 端到端验证(stage + 测试卡 + 内网穿透),记录结果(退款场景 E 因 stage 不可用需正式小额/mock)
-- [ ] T041 文档一致性:确认 spec.md/plan.md/tasks.md 描述一致;`updatesql/sql.md` 中 SQL 齐全(三建表 + OMG 菜单权限)
+- [x] T041 文档一致性:确认 spec.md/plan.md/tasks.md 描述一致;`updatesql/sql.md` 中 SQL 齐全(三建表 + OMG 菜单权限)
 
 ---
 

+ 206 - 0
specs/017-oauth-login/line-callback-frontend.md

@@ -0,0 +1,206 @@
+# LINE 登录回调 — 前端(uniapp)接入说明
+
+> 配套后端:`LineCallbackController` → `GET /auth/line/callback`(2026-08-05 增量,017-oauth-login FR-009)。
+> 适用场景:**「唤起 LINE App / 系统浏览器」** 授权 —— 这种方式前端拿不到 code,由后端接住 LINE 的重定向、完成登录后,再 302 跳回 App。
+>
+> 若你的场景是「H5 / 自家 webview / LINE SDK」(前端能自己拿到 code),**不走本回调**,直接调 `POST /infouser/user/oauthLogin {provider:"line", credential:code}`。两条流程并存。
+
+---
+
+## 1. 整体流程
+
+```
+① uniapp 打开 LINE 授权页(用 redirect_uri = https://api.awayqtw.com/auth/line/callback)
+② 用户在 LINE App 里一键同意
+③ LINE 跳转:GET https://api.awayqtw.com/auth/line/callback?code=xxx&state=xxx
+④ 后端:code → 换 token → 取 userId → 查绑定 → 签 JWT 或 生成 tempKey
+⑤ 后端 302 跳回 App scheme:
+     com.twanmsdyh.app://oauthLogin?<参数>
+⑥ uniapp 被 scheme 拉起,读参数,按下面三种情况处理
+```
+
+**关键:** 前端不碰 code,只接收后端 302 回来的 scheme URL 并按参数分流。
+
+---
+
+## 2. scheme 注册(manifest.json)
+
+App 端要能被 `com.twanmsdyh.app://...` 拉起,需在 `manifest.json` 注册该 URL Scheme(HBuilderX:App 模块配置 → App 常用其它设置 / 各平台):
+
+```json
+{
+  "app-plus": {
+    "distribute": {
+      "android": { "schemes": "com.twanmsdyh.app" },
+      "ios": { "urltypes": "com.twanmsdyh.app" }
+    }
+  }
+}
+```
+
+> iOS 的 `urltypes` 填的是 scheme 名(不含 `://`)。注册后系统遇到 `com.twanmsdyh.app://...` 就会拉起你的 App 并把完整 URL 传进来。
+
+---
+
+## 3. 打开 LINE 授权页
+
+前端用系统/LINE 打开如下 URL(`redirect_uri` **必须**与 LINE Console 白名单、后端 `application.yml` 三方一致):
+
+```
+https://access.line.me/oauth2/v2.1/authorize
+  ?response_type=code
+  &client_id=2010911071
+  &redirect_uri=https%3A%2F%2Fapi.awayqtw.com%2Fauth%2Fline%2Fcallback
+  &scope=profile%20openid
+  &state=<前端生成的随机串>
+```
+
+打开方式(uniapp App 端):`plus.runtime.openURL(authorizeUrl)` 或 `plus.share.launchLaunch` / 系统 webview,交由 LINE App / 系统浏览器接管。
+
+- `scope=profile openid`:后端 `v2/profile` 取 userId 要 `profile` 权限,**不能少**。
+- `state`:前端随机生成,用于防 CSRF(后端当前**未强校验** state,但建议传,后续会加)。
+
+---
+
+## 4. 接收 302 回跳的 scheme URL
+
+uniapp 在 `App.vue` 里读取拉起 App 的 URL:
+
+```js
+// App.vue
+export default {
+  onLaunch() {
+    // #ifdef APP-PLUS
+    // 冷启动:URL 落在 plus.runtime.arguments
+    this.handleLineCallback(plus.runtime.arguments)
+    // 热启动(App 已开,LINE 回跳再拉起):监听 newargs
+    plus.runtime.addEventListener('newargs', (e) => {
+      this.handleLineCallback(e.args)
+    })
+    // #endif
+  },
+  methods: {
+    handleLineCallback(url) {
+      if (!url || !url.startsWith('com.twanmsdyh.app://oauthLogin')) return
+      const queryStr = url.split('?')[1] || ''
+      const params = this.parseQuery(queryStr)
+      routeByLineParams(params)
+    },
+    parseQuery(q) {
+      const o = {}
+      q.split('&').forEach(kv => {
+        const [k, v] = kv.split('=')
+        if (k) o[k] = decodeURIComponent(v || '')
+      })
+      return o
+    }
+  }
+}
+```
+
+> `plus.runtime.arguments` 的具体形态随 uniapp 版本略有差异(可能是完整 URL,也可能只是 query 段),联调时打印一次确认即可。建议先 `console.log('LINE cb url=', url)` 看真实值再解析。
+
+---
+
+## 5. 三种返回值处理(核心)
+
+后端 302 回的 URL 形如 `com.twanmsdyh.app://oauthLogin?<参数>`,参数只有下列三种组合之一:
+
+| 情况 | 参数 | 含义 | 前端动作 |
+|------|------|------|----------|
+| ✅ 已绑定、登录成功 | `?token=<JWT>` | 该 LINE 已绑过手机号账号,已签发 token | 存 token → 跳首页 |
+| 🟡 未绑定、需绑手机 | `?needPhone=1&tempKey=<32位>` | 首次用该 LINE 登录,要绑手机号 | 跳「绑手机」页 → 收手机号+短信码 → 调 `/oauthBindPhone` |
+| ❌ 失败 | `?error=<原因>` | 换 token 失败 / 账号停用 / 缺 code 等 | 提示并回登录页 |
+
+### 5.1 `token` —— 登录成功
+
+```js
+function routeByLineParams(params) {
+  if (params.token) {
+    // 与现有手机号登录一致:token 存本地
+    uni.setStorageSync('token', params.token)
+    // 也可顺手把 cid/deviceToken 上报一次(后端回调时没有设备信息)
+    reportDeviceInfo()
+    uni.reLaunch({ url: '/pages/index/index' })
+    return
+  }
+  // ...
+}
+```
+
+- `token` 是后端签的 JWT(`Authorization` / `token` header 带它即可),与 `/lodeing` 返回的 token 同款,**直接复用现有请求拦截器**。
+- 后端回调发生在服务端,**拿不到 cid/deviceToken**,故设备信息要么这里补报、要么等首次业务请求时按原流程更新。
+
+### 5.2 `needPhone` + `tempKey` —— 需要绑手机
+
+```js
+if (params.needPhone === '1' && params.tempKey) {
+  // tempKey 5 分钟有效,跳到绑手机页把它带上
+  uni.navigateTo({ url: '/pages/login/bindPhone?tempKey=' + params.tempKey })
+  return
+}
+```
+
+绑手机页:输入手机号 → 调 `GET /infouser/user/getcode?phone=<手机号>` 发短信 → 填验证码 → 调下面接口完成绑定+登录:
+
+### 5.3 `error` —— 失败
+
+```js
+if (params.error) {
+  const map = {
+    no_code: '回调缺少授权码,请重试',
+    user_stopped: '该账号已被停用',
+    fail: 'LINE 登录失败,请重试'
+  }
+  uni.showModal({ title: '登录失败', content: map[params.error] || decodeURIComponent(params.error), showCancel: false })
+  uni.reLaunch({ url: '/pages/login/login' })
+  return
+}
+```
+
+- `error` 可能是固定码(`no_code` / `user_stopped`),也可能是后端异常文案(已 URL 编码,`decodeURIComponent` 解开)。
+
+---
+
+## 6. 绑定接口契约:`POST /infouser/user/oauthBindPhone`
+
+`needPhone` 分支收集完手机号 + 短信码后调用。
+
+**请求体(JSON):**
+```json
+{
+  "tempKey": "5.2 拿到的 tempKey",
+  "phone":   "+8869xxxxxxxx",
+  "code":    "短信验证码(万能码 8888 可用,仅测试)",
+  "cid":         "推送 cid(个推/uni 推送)",
+  "cidType":     "推送类型,与现有登录一致(本项目一般传 \"\")",
+  "deviceToken": "APNs deviceToken(iOS)",
+  "voIPToken":   "VoIP push token(iOS)"
+}
+```
+
+**响应:**
+- 成功(已注册关联 / 未注册新建 + 写绑定):
+  ```json
+  {
+    "code": 200,
+    "msg":  "登录成功",
+    "data": { ...用户信息... },
+    "token": "<JWT>"
+  }
+  ```
+  → 存 `token` → 跳首页(同 5.1)。
+- 失败:`code != 200`,常见 `msg`:短信码错误(`no.user.jcaptcha.error`)、账号停用(`no.user.stop`)、tempKey 过期(`no.oauth.tempkey.expired`,需重新走一遍 LINE 登录拿新 tempKey)。
+
+> 绑定成功后 `info_user_oauth(user_id, provider="line", provider_uid=<LINE userId>)` 已写入;**下次同一 LINE 登录就走 5.1 的 `token` 分支**,不再要绑手机。
+
+---
+
+## 7. 注意事项
+
+1. **redirect_uri 三方一致铁律**:前端 authorize 里的 `redirect_uri`、后端 `application.yml` 的 `oauth.line.redirect-uri`、LINE Console 回调白名单,三者必须**完全相同**(当前 = `https://api.awayqtw.com/auth/line/callback`),否则 LINE 回 `400 redirect_uri_mismatch`。
+2. **tempKey 有效期 5 分钟**:超时后 `/oauthBindPhone` 回 `tempkey.expired`,需重新点 LINE 登录拿新的。
+3. **302 到自定义 scheme 的兼容性**:标准系统浏览器会跟随 302 到 `com.twanmsdyh.app://...` 拉起 App。若联调发现 **LINE 内置浏览器**不跟随 302(App 没被拉起),后端可改为返回 HTML(`meta refresh` + 手动「打开 App」链接)兜底 —— 这是待联调确认项,目前是 302。
+4. **设备信息**:后端回调链路没有 App 上下文,`cid` / `deviceToken` / `voIPToken` 在回调时更新不到;绑手机走 `/oauthBindPhone` 时会带上,已绑定的老用户建议 5.1 拿到 token 后补报一次(或复用既有设备上报逻辑)。
+5. **state 暂未校验**:后端目前不验证 `state`,前端仍建议生成并传,后续后端会加 CSRF 校验。
+6. **与 `/oauthLogin` 并存**:如果某端(如 H5)前端能自己拿到 code,直接 `POST /infouser/user/oauthLogin {provider:"line", credential:code, ...}`,不必走本回调;返回值结构与本回调的 `token` / `needPhone+tempKey` 对应一致。

+ 1 - 0
specs/017-oauth-login/spec.md

@@ -32,6 +32,7 @@ C 端 app 现仅支持「手机号 + 短信验证码」登录(`/infouser/user/
 - **FR-006**: token claim 增加 `provider` 字段(apple/google/line;手机号登录为 phone),供登录渠道统计或「未绑手机限制」类约束使用。
 - **FR-007**: 登录端点 `@Anonymous` 放行;受保护接口继续走 `@Auth`,零额外接入。
 - **FR-008**: 凭证校验失败 / tempKey 过期 / 短信码错误 → 明确错误提示,不签发 token、不建账号。
+- **FR-009**(2026-08-05 增量): LINE「唤起 LINE App / 系统浏览器」流程下前端拿不到 code,新增服务端回调 `GET /auth/line/callback`(@Anonymous,LineCallbackController):接 LINE 重定向的 code → 复用 verify("line",code) 换 token 取 userId → 已绑定签 token / 未绑定生成 tempKey → 302 跳回 App scheme `oauth.line.app-redirect`(`?token=` / `?needPhone=1&tempKey=` / `?error=`,未绑定时 App 再调 /infouser/user/oauthBindPhone)。原 /oauthLogin 保留,覆盖前端自取 code 场景(H5/webview/SDK)。约束:oauth.line.redirect-uri 须与 LINE Console 回调白名单一致(否则 400 redirect_uri_mismatch)。
 
 ## Key Entities
 

+ 100 - 0
updatesql/sql.md

@@ -480,3 +480,103 @@ VALUES (1, '30公里', '30', 'sys_qs_newtask_distance', '', 'default', 'Y', '0',
 -- 骑手车型(InfoUser.vehicleType,仅骑手 userType=2 用):1=机车 2=轿车
 ALTER TABLE info_user ADD COLUMN vehicle_type VARCHAR(2) DEFAULT NULL COMMENT '骑手车型 1机车 2轿车';
 ```
+
+## 2026-07-29 OMG(歐買尬/FunPoint)支付接入(016-omg-payment)
+
+```sql
+-- 门店 OMG 支付凭证表(与 pos_store 1:1)
+CREATE TABLE pos_store_omg (
+  id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
+  store_id BIGINT NOT NULL COMMENT '关联 pos_store.id(门店)',
+  omg_status INT NOT NULL DEFAULT 0 COMMENT '申请状态:0未申请/1申请中/2已开通',
+  is_enabled INT NOT NULL DEFAULT 1 COMMENT '启用开关:0停用/1启用',
+  merchant_id VARCHAR(10) DEFAULT NULL COMMENT 'OMG商店代号 MerchantID',
+  hash_key VARCHAR(64) DEFAULT NULL COMMENT 'OMG HashKey',
+  hash_iv VARCHAR(64) DEFAULT NULL COMMENT 'OMG HashIV',
+  enabled_payments VARCHAR(50) DEFAULT 'ALL' COMMENT '启用支付方式(本期ALL)',
+  apply_time DATETIME DEFAULT NULL COMMENT '申请时间',
+  approved_time DATETIME DEFAULT NULL COMMENT '开通时间',
+  last_verify_result VARCHAR(240) DEFAULT NULL COMMENT '最近验证结果',
+  remark VARCHAR(255) DEFAULT NULL COMMENT '备注',
+  create_time DATETIME DEFAULT NULL COMMENT '创建时间',
+  update_time DATETIME DEFAULT NULL COMMENT '更新时间',
+  create_by VARCHAR(64) DEFAULT NULL COMMENT '创建人',
+  update_by VARCHAR(64) DEFAULT NULL COMMENT '更新人',
+  PRIMARY KEY (id),
+  UNIQUE KEY uk_store_id (store_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='门店OMG(歐買尬)支付凭证';
+
+-- OMG 支付交易流水表(trade_no 幂等键,MySQL 唯一索引允许多个 NULL)
+CREATE TABLE pos_order_omg_payment (
+  id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
+  dd_id VARCHAR(64) NOT NULL COMMENT '系统订单号(关联 pos_order.dd_id)',
+  merchant_trade_no VARCHAR(20) NOT NULL COMMENT '商店交易编号 MerchantTradeNo',
+  trade_no VARCHAR(20) DEFAULT NULL COMMENT 'OMG交易编号 TradeNo(幂等键)',
+  store_id BIGINT DEFAULT NULL COMMENT '门店ID',
+  merchant_id VARCHAR(10) DEFAULT NULL COMMENT 'OMG商店代号',
+  choose_payment VARCHAR(20) DEFAULT 'ALL' COMMENT '发起方式',
+  pay_type VARCHAR(20) DEFAULT NULL COMMENT '回覆PaymentType:Credit_CreditCard/ATM_*/CVS_*/AFTEE_AFTEE/BarcodeATM_CHINATRUST',
+  amount INT DEFAULT NULL COMMENT '交易金额(整数元)',
+  rtn_code INT DEFAULT NULL COMMENT '回调RtnCode(1=成功)',
+  rtn_msg VARCHAR(200) DEFAULT NULL COMMENT '回调RtnMsg',
+  pay_status INT NOT NULL DEFAULT 0 COMMENT '0未支付/1已支付/2失败/3已退款',
+  auth_code VARCHAR(20) DEFAULT NULL COMMENT '授权码',
+  pay_time DATETIME DEFAULT NULL COMMENT '支付完成时间',
+  trade_date DATETIME DEFAULT NULL COMMENT '订单创建时间',
+  callback_raw TEXT COMMENT '回调原始报文',
+  create_time DATETIME DEFAULT NULL COMMENT '创建时间',
+  update_time DATETIME DEFAULT NULL COMMENT '更新时间',
+  PRIMARY KEY (id),
+  UNIQUE KEY uk_trade_no (trade_no),
+  KEY idx_dd_id (dd_id),
+  KEY idx_merchant_trade_no (merchant_trade_no)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='OMG支付交易流水';
+
+-- OMG 退款记录表
+CREATE TABLE pos_order_omg_refund (
+  id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
+  payment_id BIGINT DEFAULT NULL COMMENT '关联 pos_order_omg_payment.id',
+  dd_id VARCHAR(64) DEFAULT NULL COMMENT '系统订单号',
+  trade_no VARCHAR(20) DEFAULT NULL COMMENT 'OMG交易编号 TradeNo',
+  action CHAR(1) DEFAULT NULL COMMENT 'DoAction动作:C/R/E/N',
+  amount INT DEFAULT NULL COMMENT '操作金额',
+  rtn_code INT DEFAULT NULL COMMENT '响应RtnCode(1=成功)',
+  rtn_msg VARCHAR(200) DEFAULT NULL COMMENT '响应RtnMsg',
+  callback_raw TEXT COMMENT '响应原始报文',
+  create_time DATETIME DEFAULT NULL COMMENT '操作时间',
+  PRIMARY KEY (id),
+  KEY idx_payment_id (payment_id),
+  KEY idx_dd_id (dd_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='OMG退款记录';
+```
+
+## 2026-07-29 OMG支付接入 - 门店支付配置菜单与权限(016-omg-payment US5,Tab页:OMG可用 / LINE Pay预留)
+
+```sql
+-- 注:parent_id 需匹配平台后台实际「门店/支付」目录的 menu_id;
+--      部署前先 SELECT id, menu_name FROM sys_menu WHERE menu_name LIKE '%门店%'; 核对后替换占位值。
+
+-- 主菜单(页面)
+INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, menu_type, visible, status, perms, icon, create_by, create_time, remark)
+VALUES ('门店支付配置', 0, 50, 'storePayment', 'mendian/storePayment/index', 'C', '0', '0', 'chanting:storeOmg:list', '#', 'admin', NOW(), '门店支付配置(OMG;后续在同一页加Tab扩展LINE Pay等)');
+SET @omgMenuId = LAST_INSERT_ID();
+
+-- 按钮权限
+INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, menu_type, visible, status, perms, icon, create_by, create_time, remark) VALUES
+('OMG凭证查询', @omgMenuId, 1, '#', '', 'F', '0', '0', 'chanting:storeOmg:query', '#', 'admin', NOW(), ''),
+('OMG发起申请', @omgMenuId, 2, '#', '', 'F', '0', '0', 'chanting:storeOmg:apply', '#', 'admin', NOW(), ''),
+('OMG录入凭证', @omgMenuId, 3, '#', '', 'F', '0', '0', 'chanting:storeOmg:saveCredentials', '#', 'admin', NOW(), ''),
+('OMG停用恢复', @omgMenuId, 4, '#', '', 'F', '0', '0', 'chanting:storeOmg:toggleEnable', '#', 'admin', NOW(), '');
+```
+
+
+## 2026-08-05 修复:会员载具(MEMBER)误用 ezPay 电子载具 type2(016/010)
+
+```sql
+-- 背景:MEMBER 原映射 carrier_type='2'(ezPay会员载具+PrintFlag=N),ezPay 强制要 BuyerEmail → INV10013 失败。
+-- 决策(2026-08-05):MEMBER 改走 PrintFlag=Y 索取纸本(不传 CarrierType),代码已改。
+-- 本 SQL 清空历史 MEMBER 发票行里误存的 carrier_type/carrier_num,使重开(retry)与详情展示和代码一致
+-- (会员手机号已在 buyer_name)。
+UPDATE pos_order_invoice SET carrier_type = NULL, carrier_num = NULL
+WHERE invoice_choice = 'MEMBER' AND carrier_type = '2';
+```

Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden.