qmj 4 часов назад
Родитель
Сommit
3adb335cb0
68 измененных файлов с 4765 добавлено и 821 удалено
  1. 25 0
      .claude/homunculus/observations.jsonl
  2. 2 2
      .specify/feature.json
  3. 1 1
      CLAUDE.md
  4. 7 0
      ruoyi-admin/pom.xml
  5. 33 1
      ruoyi-admin/src/main/java/com/ruoyi/app/mendian/FoodSpecController.java
  6. 772 766
      ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosFoodController.java
  7. 4 0
      ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosOrderInvoiceController.java
  8. 47 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/EzPayDebugController.java
  9. 93 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/InfoInvoiceController.java
  10. 241 30
      ruoyi-admin/src/main/java/com/ruoyi/app/order/OrderInvoiceService.java
  11. 0 5
      ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderController.java
  12. 25 1
      ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderQsOprateController.java
  13. 5 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderShOprateController.java
  14. 103 1
      ruoyi-admin/src/main/java/com/ruoyi/app/order/UserOrderController.java
  15. 18 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/dto/OrderCreateInput.java
  16. 6 6
      ruoyi-admin/src/main/java/com/ruoyi/app/pay/UserBillingController.java
  17. 1 1
      ruoyi-admin/src/main/java/com/ruoyi/app/pay/WalletController.java
  18. 173 0
      ruoyi-admin/src/main/java/com/ruoyi/app/user/InfoUserController.java
  19. 28 0
      ruoyi-admin/src/main/java/com/ruoyi/app/user/dto/OAuthBindDto.java
  20. 25 0
      ruoyi-admin/src/main/java/com/ruoyi/app/user/dto/OAuthLoginDto.java
  21. 97 6
      ruoyi-admin/src/main/java/com/ruoyi/app/utils/ezPay/EzPay.java
  22. 17 0
      ruoyi-admin/src/main/java/com/ruoyi/app/utils/ezPayCrypto/EzPayEncryptUtil.java
  23. 295 0
      ruoyi-admin/src/main/java/com/ruoyi/app/utils/oauth/OAuthVerifyService.java
  24. 23 0
      ruoyi-admin/src/main/resources/application.yml
  25. 36 0
      ruoyi-admin/src/main/resources/i18n/messages.properties
  26. 36 0
      ruoyi-admin/src/main/resources/i18n/messages_en_US.properties
  27. 36 0
      ruoyi-admin/src/main/resources/i18n/messages_vi.properties
  28. 36 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties
  29. 36 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties
  30. 3 0
      ruoyi-common/src/main/java/com/ruoyi/common/core/domain/model/LoginUserDto.java
  31. 83 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/InfoInvoice.java
  32. 11 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/InfoUser.java
  33. 41 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/InfoUserOauth.java
  34. 6 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/PosOrderInvoice.java
  35. 1 1
      ruoyi-system/src/main/java/com/ruoyi/system/domain/dto/ApplyInvoiceDto.java
  36. 6 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/PosOrderInvoiceVo.java
  37. 16 0
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/InfoInvoiceMapper.java
  38. 13 0
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/InfoUserOauthMapper.java
  39. 3 0
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/PosOrderInvoiceMapper.java
  40. 35 0
      ruoyi-system/src/main/java/com/ruoyi/system/service/IInfoInvoiceService.java
  41. 163 0
      ruoyi-system/src/main/java/com/ruoyi/system/service/impl/InfoInvoiceServiceImpl.java
  42. 1 0
      ruoyi-system/src/main/java/com/ruoyi/system/utils/JwtUtil.java
  43. 34 0
      ruoyi-system/src/main/resources/mapper/chanting/PosOrderInvoiceMapper.xml
  44. 4 0
      ruoyi-system/src/main/resources/mapper/infouser/InfoUserMapper.xml
  45. 173 0
      specs/010-order-invoice/auto-invoice-design.md
  46. 4 0
      specs/010-order-invoice/data-model.md
  47. 23 0
      specs/010-order-invoice/plan.md
  48. 42 0
      specs/010-order-invoice/spec.md
  49. 25 0
      specs/010-order-invoice/tasks.md
  50. 120 0
      specs/014-invoice-profile/contracts/api.md
  51. 36 0
      specs/014-invoice-profile/data-model.md
  52. 42 0
      specs/014-invoice-profile/plan.md
  53. 91 0
      specs/014-invoice-profile/spec.md
  54. 62 0
      specs/014-invoice-profile/tasks.md
  55. 37 0
      specs/015-intl-flight/checklists/requirements.md
  56. 154 0
      specs/015-intl-flight/spec.md
  57. 36 0
      specs/016-omg-payment/checklists/requirements.md
  58. 120 0
      specs/016-omg-payment/contracts/api.md
  59. 186 0
      specs/016-omg-payment/data-model.md
  60. 107 0
      specs/016-omg-payment/plan.md
  61. 72 0
      specs/016-omg-payment/quickstart.md
  62. 135 0
      specs/016-omg-payment/research.md
  63. 184 0
      specs/016-omg-payment/spec.md
  64. 217 0
      specs/016-omg-payment/tasks.md
  65. 95 0
      specs/017-oauth-login/plan.md
  66. 51 0
      specs/017-oauth-login/spec.md
  67. 32 0
      specs/017-oauth-login/tasks.md
  68. 80 0
      updatesql/sql.md

Разница между файлами не показана из-за своего большого размера
+ 25 - 0
.claude/homunculus/observations.jsonl


+ 2 - 2
.specify/feature.json

@@ -1,3 +1,3 @@
 {
-  "feature_directory": "specs/010-order-invoice"
-}
+  "feature_directory": "specs/016-omg-payment"
+}

+ 1 - 1
CLAUDE.md

@@ -172,5 +172,5 @@ Strong success criteria let you loop independently. Weak criteria ("make it work
 
 <!-- SPECKIT START -->
 For additional context about technologies to be used, project structure,
-shell commands, and other important information, read the current plan: `specs/013-food-spec/plan.md`
+shell commands, and other important information, read the current plan: `specs/016-omg-payment/plan.md`
 <!-- SPECKIT END -->

+ 7 - 0
ruoyi-admin/pom.xml

@@ -114,6 +114,13 @@
             <version>5.2.1</version>
         </dependency>
 
+        <!-- nimbus-jose-jwt:Apple Sign in 的 identityToken(ES256) 验签 + JWKS 解析 -->
+        <dependency>
+            <groupId>com.nimbusds</groupId>
+            <artifactId>nimbus-jose-jwt</artifactId>
+            <version>9.37.3</version>
+        </dependency>
+
         <!-- ZXing 二维码生成 -->
         <dependency>
             <groupId>com.google.zxing</groupId>

+ 33 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/mendian/FoodSpecController.java

@@ -11,6 +11,7 @@ import com.ruoyi.system.domain.FoodSpecsValue;
 import com.ruoyi.system.service.IFoodSpecsService;
 import com.ruoyi.system.service.IFoodSpecsValueService;
 import com.ruoyi.system.utils.Auth;
+import com.ruoyi.system.utils.JwtUtil;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.transaction.annotation.Transactional;
 import org.springframework.web.bind.annotation.*;
@@ -60,6 +61,8 @@ public class FoodSpecController extends BaseController
         return success(result);
     }
 
+
+
     /**
      * 保存规格组(含级联规格值):id<=0 新增,否则更新
      */
@@ -79,8 +82,9 @@ public class FoodSpecController extends BaseController
                 s.setIsOpen(true);
             }
             saveOrUpdateSpecs(s);
-            // 级联规格值
+            // 级联规格值(全量替换:先删前端未保留的,再保存/新增)
             if (s.getObjects() != null) {
+                removeDeletedValues(s);
                 for (FoodSpecsValue v : s.getObjects()) {
                     v.setParentId(s.getId());
                     if (v.getIsOpen() == null) {
@@ -111,6 +115,34 @@ public class FoodSpecController extends BaseController
         }
     }
 
+    /**
+     * 规格值全量替换:删除该规格组下 DB 有、但前端 objects 未传(id>0 集合外)的规格值(物理删除)。
+     * 仅对已存在规格组(id>0)生效;新增规格组无历史数据,无需 diff。
+     */
+    private void removeDeletedValues(FoodSpecs s) {
+        if (s.getId() == null || s.getId() <= 0) {
+            return;
+        }
+        // 前端保留的规格值 id(id>0 的;id<=0 是新增,不参与 diff)
+        List<Long> keepIds = s.getObjects().stream()
+                .map(FoodSpecsValue::getId)
+                .filter(id -> id != null && id > 0)
+                .collect(Collectors.toList());
+        // DB 中该规格组的全部规格值 id
+        List<Long> dbIds = foodSpecsValueService.list(
+                new LambdaQueryWrapper<FoodSpecsValue>()
+                        .eq(FoodSpecsValue::getParentId, s.getId())
+                        .select(FoodSpecsValue::getId)
+        ).stream().map(FoodSpecsValue::getId).collect(Collectors.toList());
+        // DB 有但前端没传 → 物理删除
+        List<Long> toRemove = dbIds.stream()
+                .filter(id -> !keepIds.contains(id))
+                .collect(Collectors.toList());
+        if (!toRemove.isEmpty()) {
+            foodSpecsValueService.removeByIds(toRemove);
+        }
+    }
+
     /**
      * 规格详情(带全部规格值)
      */

+ 772 - 766
ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosFoodController.java

@@ -1,766 +1,772 @@
-package com.ruoyi.app.mendian;
-
-import com.alibaba.fastjson.JSONArray;
-import com.alibaba.fastjson.JSONObject;
-import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
-import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
-import com.baomidou.mybatisplus.core.metadata.IPage;
-import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
-import com.ruoyi.app.mendian.dto.FoodSearchOutput;
-import com.ruoyi.app.utils.ImageCompressUtils;
-import com.ruoyi.common.annotation.Anonymous;
-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.common.utils.MessageUtils;
-import com.ruoyi.common.utils.StringUtils;
-import com.ruoyi.common.utils.poi.ExcelUtil;
-import com.ruoyi.system.domain.*;
-import com.ruoyi.system.mapper.PosStoreMapper;
-import com.ruoyi.system.service.*;
-import com.ruoyi.system.utils.Auth;
-import com.ruoyi.system.utils.JwtUtil;
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.security.access.prepost.PreAuthorize;
-import org.springframework.web.bind.annotation.*;
-
-import jakarta.servlet.http.HttpServletResponse;
-
-import java.util.*;
-import java.util.concurrent.atomic.AtomicInteger;
-import java.util.stream.Collectors;
-
-/**
- * foodController
- *
- * @author ruoyi
- * @date 2023-05-21
- */
-@RestController
-@RequestMapping("/chanting/food")
-public class PosFoodController extends BaseController {
-    @Autowired  //商品
-    private IPosFoodService posFoodService;
-
-    @Autowired  //门店
-    private IPosStoreService posStoreService;
-
-    @Autowired //商品分类
-    private IPosFenleiService posFenleiService;
-
-    @Autowired //用户
-    private IInfoUserService infoUserService;
-    @Autowired //店铺Mapper
-    private PosStoreMapper posStoreMapper;
-    @Autowired
-    private IPosReviewService posReviewService;
-    @Autowired
-    private IOperatingHoursService operatingHoursService;
-    @Autowired
-    private IPosOrderService posOrderService;
-
-    @Autowired
-    private PosStoreEnrichService posStoreEnrichService;
-
-    @Autowired  //规格组
-    private IFoodSpecsService foodSpecsService;
-    @Autowired  //规格值
-    private IFoodSpecsValueService foodSpecsValueService;
-    @Autowired  //商品-规格关联
-    private IFoodSpecRelationService foodSpecRelationService;
-
-    //删除商品
-    @Anonymous
-    @Auth
-    @GetMapping("/delefood")
-    public AjaxResult delefood(@RequestParam String id) {
-        return toAjax(posFoodService.deletePosFoodById(Long.valueOf(id)));
-    }
-
-    //推荐商品
-    @Anonymous
-    @PutMapping("/changerecommend")
-    public AjaxResult changerecommend(@RequestBody PosFood posFood) {
-        return toAjax(posFoodService.saveOrUpdate(posFood));
-    }
-
-    /**
-     * 添加或修改商品
-     */
-    @Anonymous
-    @PostMapping("/setposfood")
-    public AjaxResult setposfood(@RequestBody PosFood posFood) {
-        Boolean org = posFoodService.saveOrUpdate(posFood);
-        if (!org) {
-            return error();
-        }
-        handleFoodSpec(posFood);
-        return success();
-    }
-
-    /**
-     * (h5页面使用)通过分类查询商品列表
-     */
-    @Anonymous
-    @GetMapping("/h5Getidlist")
-    public AjaxResult h5Getidlist(@RequestParam Integer id, @RequestParam(defaultValue = "") String language, @RequestParam(required = false) String stackingUp) {
-        JwtUtil jwtUtil = new JwtUtil();
-//        String ids = jwtUtil.getusid(token);
-//        InfoUser user = infoUserService.getById(ids);
-        QueryWrapper<PosFood> queryWrapper = new QueryWrapper<>();
-        queryWrapper.eq("fl_id", id);
-//        if(!user.getUserType().equals("1")){
-//            queryWrapper.eq("to_examine","1");
-//        }
-        if (stackingUp != null && !"".equals(stackingUp)) {
-            queryWrapper.eq("stacking_up", stackingUp);
-        }
-        String lang = "0";
-        if (!"".equals(language)) {
-            lang = language;
-        }
-        queryWrapper.eq("language", lang);
-        List<PosFood> list = posFoodService.list(queryWrapper);
-        List<Long> __foodIds = list.stream().map(PosFood::getId).collect(Collectors.toList());
-        Map<Long, List<FoodSpecs>> __specMap = loadFoodSpecsMap(__foodIds);
-        JSONArray all = new JSONArray();
-        for (int i = 0; i < list.size(); i++) {
-            JSONObject org = new JSONObject();
-            org.put("id", list.get(i).getId());
-            org.put("fenlei", posFenleiService.getById(list.get(i).getFlId()));
-            org.put("store", posStoreService.getById(list.get(i).getMdid()));
-            org.put("name", list.get(i).getName());
-            org.put("image", list.get(i).getImage());
-            org.put("price", list.get(i).getPrice());
-            org.put("introduce", list.get(i).getIntroduce());
-            org.put("recommend", list.get(i).getRecommend());
-            List<FoodSpecs> __fs = __specMap.getOrDefault(list.get(i).getId(), new ArrayList<>());
-            org.put("foodSpecs", __fs);
-            org.put("foodSku", __fs.isEmpty() ? JSONArray.parseArray(list.get(i).getFoodSku()) : buildFoodSkuArray(__fs));
-            org.put("stackingUp", list.get(i).getStackingUp());
-            org.put("toExamine", list.get(i).getToExamine());
-            all.add(org);
-        }
-        return success(all);
-    }
-
-    /**
-     * 通过分类查询商品列表
-     */
-    @Anonymous
-    @GetMapping("/getidlist")
-    public AjaxResult getidlist(@RequestHeader String token, @RequestParam Integer id, @RequestParam(defaultValue = "") String language, @RequestParam(required = false) String stackingUp, @RequestParam(required = false) String name) {
-        JwtUtil jwtUtil = new JwtUtil();
-        String ids = jwtUtil.getusid(token);
-        InfoUser user = infoUserService.getById(ids);
-        QueryWrapper<PosFood> queryWrapper = new QueryWrapper<>();
-        queryWrapper.eq("fl_id", id);
-        //普通用户显示审核通过的
-        if("0".equals(user.getUserType())){
-            queryWrapper.eq("to_examine", "1");
-        }
-        if (stackingUp != null && !"".equals(stackingUp)) {
-            queryWrapper.eq("stacking_up", stackingUp);
-        }
-        String lang = "3";
-        if (!"".equals(language)) {
-            lang = language;
-        }
-        queryWrapper.eq("language", lang);
-        if (name != null && !"".equals(name)) {
-            queryWrapper.apply(" BINARY name LIKE CONCAT('%', {0}, '%')", name);
-        }
-        List<PosFood> list = posFoodService.list(queryWrapper);
-        List<Long> __foodIds = list.stream().map(PosFood::getId).collect(Collectors.toList());
-        Map<Long, List<FoodSpecs>> __specMap = loadFoodSpecsMap(__foodIds);
-        JSONArray all = new JSONArray();
-        for (int i = 0; i < list.size(); i++) {
-            JSONObject org = new JSONObject();
-            org.put("id", list.get(i).getId());
-            org.put("fenlei", posFenleiService.getById(list.get(i).getFlId()));
-            PosStore store = posStoreService.getById(list.get(i).getMdid());
-            fillStallLocation(store);
-            org.put("store", store);
-            org.put("name", list.get(i).getName());
-            org.put("image", list.get(i).getImage());
-            org.put("price", list.get(i).getPrice());
-            org.put("introduce", list.get(i).getIntroduce());
-            org.put("recommend", list.get(i).getRecommend());
-            List<FoodSpecs> __fs = __specMap.getOrDefault(list.get(i).getId(), new ArrayList<>());
-            org.put("foodSpecs", __fs);
-            org.put("foodSku", __fs.isEmpty() ? JSONArray.parseArray(list.get(i).getFoodSku()) : buildFoodSkuArray(__fs));
-            org.put("stackingUp", list.get(i).getStackingUp());
-            org.put("toExamine", list.get(i).getToExamine());
-            all.add(org);
-        }
-        return success(all);
-    }
-
-    /**
-     * 查询商品详情
-     */
-    @Anonymous
-    @GetMapping("/getfood")
-    public AjaxResult getfood(@RequestParam Integer id) {
-        JSONObject org = new JSONObject();
-        PosFood posFood = posFoodService.getById(id);
-        List<Long> __foodIds = new ArrayList<>();
-        __foodIds.add(posFood.getId());
-        Map<Long, List<FoodSpecs>> __specMap = loadFoodSpecsMap(__foodIds);
-        List<FoodSpecs> __fs = __specMap.getOrDefault(posFood.getId(), new ArrayList<>());
-        org.put("id", posFood.getId());
-        org.put("fenlei", posFenleiService.getById(posFood.getFlId()));
-        PosStore store = posStoreService.getById(posFood.getMdid());
-        fillStallLocation(store);
-        org.put("store", store);
-        org.put("name", posFood.getName());
-        org.put("image", posFood.getImage());
-        org.put("price", posFood.getPrice());
-        org.put("introduce", posFood.getIntroduce());
-        org.put("recommend", posFood.getRecommend());
-        org.put("stackingUp", posFood.getStackingUp());
-        org.put("foodSpecs", __fs);
-        org.put("foodSku", __fs.isEmpty() ? JSONArray.parseArray(posFood.getFoodSku()) : buildFoodSkuArray(__fs));
-        return success(org);
-    }
-
-    /**
-     * 搜索
-     */
-    @Anonymous
-    @GetMapping("/searchfor")
-    public AjaxResult searchfor(@RequestParam String keyword, @RequestParam(defaultValue = "") String language) {
-        if (keyword.equals("")) {
-            return error(MessageUtils.message("no.tip.lock.keywork"));
-        }
-        JSONArray all = new JSONArray();
-        JSONObject object = new JSONObject();
-        QueryWrapper<PosFood> query = new QueryWrapper<>();
-        String lang = "0";
-        if (!"".equals(language)) {
-            lang = language;
-        }
-        query.eq("language", lang);
-        query.eq("to_examine", "1");
-//        query.like("name",keyword);
-        query.apply("BINARY name LIKE CONCAT('%', {0}, '%')", keyword);
-        List<PosFood> folist = posFoodService.list(query);
-        QueryWrapper<PosStore> wrapper = new QueryWrapper<>();
-        wrapper.eq("off_shelf", "0")
-//                .and(i->i.like("pos_name", keyword).or().like("brief_introduction",keyword));
-                .and(i -> i.apply("BINARY pos_name LIKE CONCAT('%', {0}, '%')", keyword).or().apply("BINARY brief_introduction LIKE CONCAT('%', {0}, '%')", keyword));
-        List<PosStore> stlist = posStoreService.list(wrapper);
-        for (int i = 0; i < folist.size(); i++) {
-            PosStore store = posStoreService.getById(folist.get(i).getMdid());
-            if (store.getOffShelf().equals("0")) {
-                JSONObject org = new JSONObject();
-                org.put("id", folist.get(i).getId());
-                org.put("fenlei", posFenleiService.getById(folist.get(i).getFlId()));
-                org.put("store", posStoreService.getById(folist.get(i).getMdid()));
-                org.put("name", folist.get(i).getName());
-                org.put("image", folist.get(i).getImage());
-                org.put("price", folist.get(i).getPrice());
-                org.put("introduce", folist.get(i).getIntroduce());
-                org.put("recommend", folist.get(i).getRecommend());
-                org.put("stackingUp", folist.get(i).getStackingUp());
-                org.put("foodSku", JSONArray.parseArray(folist.get(i).getFoodSku()));
-                all.add(org);
-            }
-        }
-        object.put("food", all);
-        object.put("store", stlist);
-        return success(object);
-    }
-
-
-    /**
-     * 客户端查询美食
-     *
-     * @param keyword
-     * @param language
-     * @return
-     */
-
-    @Anonymous
-    @GetMapping("/foodSearch")
-    public AjaxResult foodSearch(@RequestParam String keyword, @RequestParam Integer pageNum, @RequestParam Integer pageSize, @RequestParam(defaultValue = "") String language) {
-        if (keyword.isEmpty()) {
-            return error(MessageUtils.message("no.tip.lock.keywork"));
-        }
-
-        // 设置默认分页参数
-        if (pageNum == null || pageNum < 1) {
-            pageNum = 1;
-        }
-        if (pageSize == null || pageSize < 1) {
-            pageSize = 10;
-        }
-
-        String lang = "0";
-        if (!"".equals(language)) {
-            lang = language;
-        }
-        keyword = hanlderKeyword(lang, keyword);
-
-        // 创建分页对象
-        Page<PosStore> page = new Page<>(pageNum, pageSize);
-
-        // 1. 先通过联表查询获取分页的店铺列表(包含总数)
-        IPage<PosStore> storePage = posStoreMapper.selectStoresByFoodKeyword(page, keyword, lang);
-        List<PosStore> stores = storePage.getRecords();
-
-        List<FoodSearchOutput> result = new ArrayList<>();
-        Map<Long, List<PosReview>> reviewMap = Collections.emptyMap();
-        Map<Long, Long> orderCountMap = Collections.emptyMap();
-        List<OperatingHours> hourslist=new ArrayList<>();
-        // 2. 一次性查询所有相关商品(避免循环查询数据库)
-        if (!stores.isEmpty()) {
-            // 提取所有店铺ID
-            List<Long> storeIds = stores.stream()
-                    .map(store -> store.getId().longValue())
-                    .collect(Collectors.toList());
-
-            // 一次性查询所有店铺下匹配关键词的商品
-            QueryWrapper<PosFood> foodQuery = new QueryWrapper<>();
-            foodQuery.in("mdid", storeIds)
-                    .eq("language", lang)
-                    .eq("to_examine", "1")
-                    .apply(" CONCAT(name, ' ') LIKE CONCAT('%', {0}, '%') COLLATE utf8mb4_unicode_ci", keyword);
-
-            List<PosFood> allFoods = posFoodService.list(foodQuery);
-            {
-                List<Long> __foodIds = allFoods.stream().map(PosFood::getId).collect(Collectors.toList());
-                Map<Long, List<FoodSpecs>> __specMap = loadFoodSpecsMap(__foodIds);
-                for (PosFood __f : allFoods) {
-                    __f.setFoodSpecs(__specMap.getOrDefault(__f.getId(), new ArrayList<>()));
-                }
-            }
-
-            // 按店铺ID分组商品
-            Map<Long, List<PosFood>> foodsByStore = allFoods.stream()
-                    .collect(Collectors.groupingBy(PosFood::getMdid));
-            posStoreEnrichService.enrichStoreList(stores);
-            // 3. 为每个店铺设置对应的商品
-            for (PosStore store : stores) {
-                FoodSearchOutput output = new FoodSearchOutput();
-
-                output.setPosStore(store);
-                // 从分组结果中获取该店铺的商品
-                List<PosFood> storeFoods = foodsByStore.getOrDefault(store.getId().longValue(), new ArrayList<>());
-
-                output.setPosFoodList(storeFoods);
-                result.add(output);
-            }
-        }
-
-        // 3. 返回分页结果
-        Map<String, Object> response = new HashMap<>();
-        response.put("list", result);
-        response.put("total", storePage.getTotal());
-        return success(response);
-    }
-
-    //越南语搜索,添加空格,搜索的关键字变成有语意的
-    private String hanlderKeyword(String language, String keyword) {
-        if (language.equals("0")) {
-            if (!keyword.contains(" "))
-                keyword = keyword + " ";
-        }
-        return keyword;
-    }
-
-    /**
-     * 客户端查询门店
-     *
-     * @param keyword
-     * @param language
-     * @return
-     */
-
-    @Anonymous
-    @GetMapping("/storeSearch")
-    public AjaxResult storeSearch(@RequestParam String keyword, @RequestParam Integer pageNum, @RequestParam Integer pageSize, @RequestParam(defaultValue = "") String language) {
-        if (keyword.isEmpty()) {
-            return error(MessageUtils.message("no.tip.lock.keywork"));
-        }
-        QueryWrapper<PosFood> query = new QueryWrapper<>();
-        String lang = "0";
-        if (!"".equals(language)) {
-            lang = language;
-        }
-        keyword = hanlderKeyword(lang, keyword);
-        // 设置默认分页参数
-        if (pageNum == null || pageNum < 1) {
-            pageNum = 1;
-        }
-        if (pageSize == null || pageSize < 1) {
-            pageSize = 10;
-        }
-        Page<PosStore> page = new Page<>(pageNum, pageSize);
-        QueryWrapper<PosStore> storeWrapper = new QueryWrapper<>();
-        String finalKeyword = keyword;
-        storeWrapper.eq("off_shelf", "0")
-//                .and(i->i.like("pos_name", keyword).or().like("brief_introduction",keyword));
-                .and(i -> i.apply(" CONCAT(pos_name,' ') LIKE CONCAT('%', {0}, '%') COLLATE utf8mb4_unicode_ci", finalKeyword).or().apply(" CONCAT(brief_introduction,' ') LIKE CONCAT('%', {0}, '%') COLLATE utf8mb4_unicode_ci", finalKeyword));
-        IPage<PosStore> data = posStoreService.page(page, storeWrapper);
-        Map<String, Object> response = new HashMap<>();
-        List<PosStore> stores=data.getRecords();
-        posStoreEnrichService.enrichStoreList(stores);
-        response.put("list", stores);
-        response.put("total", data.getTotal());
-        return success(response);
-    }
-
-
-
-    @Anonymous
-    @GetMapping("/getFoodPageList")
-    public AjaxResult getFoodPageList(@RequestHeader String token,
-                                      @RequestParam Integer page,
-                                      @RequestParam Integer size,
-                                      @RequestParam Long flId,
-                                      @RequestParam Long mdId,
-                                      @RequestParam(defaultValue = "") String language,
-                                      @RequestParam(defaultValue = "") String name) {
-        JwtUtil jwtUtil = new JwtUtil();
-        String id = jwtUtil.getusid(token);
-        IPage<PosFood> stlist = new Page<>(page, size);
-        LambdaQueryWrapper<PosFood> queryWrapper = new LambdaQueryWrapper<>();
-        queryWrapper.eq(PosFood::getFlId, flId);
-        queryWrapper.eq(PosFood::getMdid, mdId);
-
-        // language 不为 null 且不为空字符串时才加条件
-        if (language != null && !"".equals(language)) {
-            queryWrapper.eq(PosFood::getLanguage, language);
-        }
-
-        // name 不为 null 且不为空字符串时模糊查询
-        if (name != null && !"".equals(name)) {
-            queryWrapper.like(PosFood::getName, name);
-        }
-
-        IPage<PosFood> list = posFoodService.page(stlist, queryWrapper);
-        return success(list);
-    }
-
-    /**
-     * C端扫码:获取摊位商品列表(摊位码扫码入口)
-     */
-    @Anonymous
-    @GetMapping("/stallFoodList")
-    public AjaxResult stallFoodList(@RequestParam Long storeId,
-                                    @RequestParam(required = false) Long flId,
-                                    @RequestParam Integer page,
-                                    @RequestParam Integer size,
-                                    @RequestParam(required = false) Long tableId) {
-        IPage<PosFood> foodPage = new Page<>(page, size);
-        QueryWrapper<PosFood> queryWrapper = new QueryWrapper<>();
-        queryWrapper.eq("mdid", storeId);
-        queryWrapper.eq("to_examine", "1");
-        queryWrapper.eq("stacking_up", "1");
-        if (flId != null) {
-            queryWrapper.eq("fl_id", flId);
-        }
-        IPage<PosFood> result = posFoodService.page(foodPage, queryWrapper);
-        List<Long> __foodIds = result.getRecords().stream().map(PosFood::getId).collect(Collectors.toList());
-        Map<Long, List<FoodSpecs>> __specMap = loadFoodSpecsMap(__foodIds);
-        for (PosFood __f : result.getRecords()) {
-            __f.setFoodSpecs(__specMap.getOrDefault(__f.getId(), new ArrayList<>()));
-        }
-        return success(result);
-    }
-
-    /**
-     * 查询商品列表
-     */
-    @Anonymous
-    @GetMapping("/getfoodlist")
-    public AjaxResult getfoodlist(@RequestParam Integer page,
-                                  @RequestParam Integer size,
-                                  @RequestParam(defaultValue = "") String language,
-                                  @RequestParam(defaultValue = "") String keyword) {
-        IPage<PosFood> food = new Page<>(page, size);
-        QueryWrapper<PosFood> queryWrapper = new QueryWrapper<>();
-        String lang = "0";
-        if (!"".equals(language)) {
-            lang = language;
-        }
-        queryWrapper.eq("language", lang);
-        queryWrapper.eq("to_examine", "1");
-        if (!"".equals(keyword)) {
-            queryWrapper.like("name", keyword);
-        }
-        IPage<PosFood> list = posFoodService.page(food, queryWrapper);
-        List<PosFood> foodlist = list.getRecords();
-        JSONArray all = new JSONArray();
-        for (int i = 0; i < foodlist.size(); i++) {
-            PosStore store = posStoreService.getById(foodlist.get(i).getMdid());
-            if (store.getOffShelf().equals("0")) {
-                JSONObject org = new JSONObject();
-                org.put("id", foodlist.get(i).getId());
-                org.put("fenlei", posFenleiService.getById(foodlist.get(i).getFlId()));
-                org.put("store", posStoreService.getById(foodlist.get(i).getMdid()));
-                org.put("name", foodlist.get(i).getName());
-                org.put("image", foodlist.get(i).getImage());
-                org.put("price", foodlist.get(i).getPrice());
-                org.put("introduce", foodlist.get(i).getIntroduce());
-                org.put("recommend", foodlist.get(i).getRecommend());
-                org.put("stackingUp", foodlist.get(i).getStackingUp());
-                org.put("foodSku", JSONArray.parseArray(foodlist.get(i).getFoodSku()));
-                all.add(org);
-            }
-        }
-        return success(all);
-    }
-
-    /**
-     * 查询food列表
-     */
-
-    @PreAuthorize("@ss.hasPermi('chanting:food:list')")
-    @GetMapping("/list")
-    public TableDataInfo list(PosFood posFood) {
-        startPage();
-        List<PosFood> list = posFoodService.selectPosFoodList(posFood);
-        return getDataTable(list);
-    }
-
-    /**
-     * 导出food列表
-     */
-    @PreAuthorize("@ss.hasPermi('chanting:food:export')")
-    @Log(title = "food", businessType = BusinessType.EXPORT)
-    @PostMapping("/export")
-    public void export(HttpServletResponse response, PosFood posFood) {
-        List<PosFood> list = posFoodService.selectPosFoodList(posFood);
-        ExcelUtil<PosFood> util = new ExcelUtil<PosFood>(PosFood.class);
-        util.exportExcel(response, list, MessageUtils.message("no.export.excel.food"));
-    }
-
-    /**
-     * 获取food详细信息
-     */
-    @Anonymous
-    @GetMapping(value = "/{id}")
-    public AjaxResult getInfo(@PathVariable("id") Long id) {
-        return success(posFoodService.selectPosFoodById(id));
-    }
-
-    /**
-     * 新增food
-     */
-    @PreAuthorize("@ss.hasPermi('chanting:food:add')")
-    @Log(title = "food", businessType = BusinessType.INSERT)
-    @PostMapping
-    public AjaxResult add(@RequestBody PosFood posFood) {
-        int rows = posFoodService.insertPosFood(posFood);
-        if (rows > 0) {
-            handleFoodSpec(posFood);
-        }
-        return toAjax(rows);
-    }
-
-    /**
-     * 修改food
-     */
-    @PreAuthorize("@ss.hasPermi('chanting:food:edit')")
-    @Log(title = "food", businessType = BusinessType.UPDATE)
-    @PutMapping
-    public AjaxResult edit(@RequestBody PosFood posFood) {
-        int rows = posFoodService.updatePosFood(posFood);
-        if (rows > 0) {
-            handleFoodSpec(posFood);
-        }
-        return toAjax(rows);
-    }
-
-    /**
-     * 删除food
-     */
-    @PreAuthorize("@ss.hasPermi('chanting:food:remove')")
-    @Log(title = "food", businessType = BusinessType.DELETE)
-    @DeleteMapping("/{ids}")
-    public AjaxResult remove(@PathVariable Long[] ids) {
-        return toAjax(posFoodService.deletePosFoodByIds(ids));
-    }
-
-    /**
-     * 压缩图片店铺的商品图片
-     */
-    @Anonymous
-    @GetMapping("/compressFoodImage")
-    public AjaxResult compressFoodImage(@RequestParam Long mdId, @RequestParam String idsStr) {
-        LambdaQueryWrapper<PosFood> queryWrapper = new LambdaQueryWrapper<>();
-        queryWrapper.eq(PosFood::getMdid, mdId);
-        List<Long> ids= new ArrayList<>();
-        if(!StringUtils.isEmpty(idsStr)){
-            ids = Arrays.stream(idsStr.split(","))
-                    .map(Long::valueOf)
-                    .collect(Collectors.toList());
-        }
-        queryWrapper.in(!ids.isEmpty(), PosFood::getId, ids);
-        List<PosFood> list = posFoodService.list(queryWrapper);
-        AtomicInteger count = new AtomicInteger();
-        list.forEach(food -> {
-            String compressImage = ImageCompressUtils.compressImageByWebPath(food.getImage());
-            if (!compressImage.equals(food.getImage())) {
-                food.setImage(compressImage);
-                PosFood posFood = new PosFood();
-                posFood.setId(food.getId());
-                posFood.setImage(compressImage);
-                posFoodService.saveOrUpdate(posFood);
-                count.getAndIncrement();
-                System.out.println("food成功压缩的图片数量:"+count.get());
-                logger.info("food成功压缩的图片数量:"+count.get());
-            }
-        });
-        return success("成功压缩的 :"+count.get());
-    }
-
-    /**
-     * 压缩所有的商品图片
-     */
-    @Anonymous
-    @GetMapping("/compressFoodImageAll")
-    public AjaxResult compressFoodImage() {
-        LambdaQueryWrapper<PosFood> queryWrapper = new LambdaQueryWrapper<>();
-        List<PosFood> list = posFoodService.list(queryWrapper);
-        AtomicInteger count = new AtomicInteger();
-        list.forEach(food -> {
-            String compressImage = ImageCompressUtils.compressImageByWebPath(food.getImage());
-            if (!compressImage.equals(food.getImage())) {
-                food.setImage(compressImage);
-                PosFood posFood = new PosFood();
-                posFood.setId(food.getId());
-                posFood.setImage(compressImage);
-                posFoodService.saveOrUpdate(posFood);
-                count.getAndIncrement();
-                System.out.println("food成功压缩的图片数量:"+count.get());
-                logger.info("food成功压缩的图片数量:"+count.get());
-            }
-        });
-        return success("成功压缩的图片数量:"+count.get());
-    }
-
-    /**
-     * 当门店是摊位时,用夜市的经纬度覆盖摊位的经纬度
-     */
-    /**
-     * 保存商品规格:sku JSON 写 food_sku(落库) -> 删除旧关联 -> 按 foodSpecs 建立新关联
-     */
-    private void handleFoodSpec(PosFood posFood) {
-        if (posFood == null || posFood.getId() == null) {
-            return;
-        }
-        JSONArray sku = posFood.getSku();
-        if (sku != null) {
-            PosFood upd = new PosFood();
-            upd.setId(posFood.getId());
-            upd.setFoodSku(sku.toString());
-            posFoodService.updateById(upd);
-            posFood.setFoodSku(sku.toString());
-        }
-        foodSpecRelationService.remove(new LambdaQueryWrapper<FoodSpecRelation>()
-                .eq(FoodSpecRelation::getFoodId, posFood.getId()));
-        List<FoodSpecs> foodSpecs = posFood.getFoodSpecs();
-        if (foodSpecs != null && !foodSpecs.isEmpty()) {
-            List<FoodSpecRelation> rels = foodSpecs.stream()
-                    .filter(sp -> sp.getId() != null && sp.getId() > 0)
-                    .map(sp -> {
-                        FoodSpecRelation r = new FoodSpecRelation();
-                        r.setFoodId(posFood.getId());
-                        r.setSpecsId(sp.getId());
-                        return r;
-                    }).collect(Collectors.toList());
-            if (!rels.isEmpty()) {
-                foodSpecRelationService.saveBatch(rels);
-            }
-        }
-    }
-
-    /**
-     * 批量查询商品规格(避免 N+1):关联 -> 启用规格组 -> 启用规格值 -> 内存分组
-     * @return foodId -> 规格组列表(含启用规格值)
-     */
-    private Map<Long, List<FoodSpecs>> loadFoodSpecsMap(List<Long> foodIds) {
-        Map<Long, List<FoodSpecs>> result = new HashMap<>();
-        if (foodIds == null || foodIds.isEmpty()) {
-            return result;
-        }
-        List<FoodSpecRelation> rels = foodSpecRelationService.list(new LambdaQueryWrapper<FoodSpecRelation>()
-                .in(FoodSpecRelation::getFoodId, foodIds));
-        if (rels.isEmpty()) {
-            return result;
-        }
-        Map<Long, List<Long>> foodToSpecIds = rels.stream()
-                .collect(Collectors.groupingBy(FoodSpecRelation::getFoodId,
-                        Collectors.mapping(FoodSpecRelation::getSpecsId, Collectors.toList())));
-        List<Long> specsIds = rels.stream().map(FoodSpecRelation::getSpecsId).distinct().collect(Collectors.toList());
-        List<FoodSpecs> specsList = foodSpecsService.list(new LambdaQueryWrapper<FoodSpecs>()
-                .in(FoodSpecs::getId, specsIds)
-                .eq(FoodSpecs::getIsOpen, true)
-                .eq(FoodSpecs::getIsDelete, false)
-                .orderByAsc(FoodSpecs::getSort));
-        if (specsList.isEmpty()) {
-            return result;
-        }
-        List<Long> enabledSpecIds = specsList.stream().map(FoodSpecs::getId).collect(Collectors.toList());
-        List<FoodSpecsValue> values = foodSpecsValueService.list(new LambdaQueryWrapper<FoodSpecsValue>()
-                .in(FoodSpecsValue::getParentId, enabledSpecIds)
-                .eq(FoodSpecsValue::getIsOpen, true)
-                .orderByAsc(FoodSpecsValue::getId));
-        Map<Long, List<FoodSpecsValue>> valueMap = values.stream()
-                .collect(Collectors.groupingBy(FoodSpecsValue::getParentId));
-        Map<Long, FoodSpecs> specsMap = specsList.stream().collect(Collectors.toMap(FoodSpecs::getId, sp -> sp));
-        for (Map.Entry<Long, List<Long>> entry : foodToSpecIds.entrySet()) {
-            List<FoodSpecs> fsList = new ArrayList<>();
-            for (Long sid : entry.getValue()) {
-                FoodSpecs src = specsMap.get(sid);
-                if (src != null) {
-                    FoodSpecs copy = new FoodSpecs();
-                    copy.setId(src.getId());
-                    copy.setTitle(src.getTitle());
-                    copy.setType(src.getType());
-                    copy.setState(src.getState());
-                    copy.setLanguage(src.getLanguage());
-                    copy.setRemark(src.getRemark());
-                    copy.setMdId(src.getMdId());
-                    copy.setSort(src.getSort());
-                    copy.setIsOpen(src.getIsOpen());
-                    copy.setIsDelete(src.getIsDelete());
-                    copy.setObjects(valueMap.getOrDefault(src.getId(), new ArrayList<>()));
-                    fsList.add(copy);
-                }
-            }
-            result.put(entry.getKey(), fsList);
-        }
-        return result;
-    }
-
-    /**
-     * 由结构化规格构建兼容的 food_sku JSON 数组(明细字段名 foodSpecsItems)
-     */
-    private JSONArray buildFoodSkuArray(List<FoodSpecs> fsList) {
-        JSONArray skuArr = new JSONArray();
-        if (fsList == null || fsList.isEmpty()) {
-            return skuArr;
-        }
-        for (FoodSpecs s : fsList) {
-            skuArr.add(JSONObject.toJSON(s));
-        }
-        return skuArr;
-    }
-
-    private void fillStallLocation(PosStore store) {
-        if (store != null && store.getIsStall() != null && store.getIsStall() == 1
-                && store.getNightMarketId() != null) {
-            InfoUser nightMarket = infoUserService.getById(store.getNightMarketId());
-            if (nightMarket != null) {
-                store.setLongitude(nightMarket.getLongitude());
-                store.setLatitude(nightMarket.getLatitude());
-            }
-        }
-    }
-}
+package com.ruoyi.app.mendian;
+
+import com.alibaba.fastjson.JSONArray;
+import com.alibaba.fastjson.JSONObject;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.ruoyi.app.mendian.dto.FoodSearchOutput;
+import com.ruoyi.app.order.OrderInvoiceService;
+import com.ruoyi.app.utils.ImageCompressUtils;
+import com.ruoyi.common.annotation.Anonymous;
+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.common.utils.MessageUtils;
+import com.ruoyi.common.utils.StringUtils;
+import com.ruoyi.common.utils.poi.ExcelUtil;
+import com.ruoyi.system.domain.*;
+import com.ruoyi.system.mapper.PosStoreMapper;
+import com.ruoyi.system.service.*;
+import com.ruoyi.system.utils.Auth;
+import com.ruoyi.system.utils.JwtUtil;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import jakarta.servlet.http.HttpServletResponse;
+
+import java.util.*;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.stream.Collectors;
+
+/**
+ * foodController
+ *
+ * @author ruoyi
+ * @date 2023-05-21
+ */
+@RestController
+@RequestMapping("/chanting/food")
+public class PosFoodController extends BaseController {
+    @Autowired  //商品
+    private IPosFoodService posFoodService;
+
+    @Autowired  //门店
+    private IPosStoreService posStoreService;
+
+    @Autowired //商品分类
+    private IPosFenleiService posFenleiService;
+
+    @Autowired //用户
+    private IInfoUserService infoUserService;
+    @Autowired //店铺Mapper
+    private PosStoreMapper posStoreMapper;
+    @Autowired
+    private IPosReviewService posReviewService;
+    @Autowired
+    private IOperatingHoursService operatingHoursService;
+    @Autowired
+    private IPosOrderService posOrderService;
+
+    @Autowired
+    private PosStoreEnrichService posStoreEnrichService;
+
+    @Autowired  //规格组
+    private IFoodSpecsService foodSpecsService;
+    @Autowired  //规格值
+    private IFoodSpecsValueService foodSpecsValueService;
+    @Autowired  //商品-规格关联
+    private IFoodSpecRelationService foodSpecRelationService;
+
+    @Autowired //订单电子发票(门店是否能开票)
+    private OrderInvoiceService orderInvoiceService;
+
+    //删除商品
+    @Anonymous
+    @Auth
+    @GetMapping("/delefood")
+    public AjaxResult delefood(@RequestParam String id) {
+        return toAjax(posFoodService.deletePosFoodById(Long.valueOf(id)));
+    }
+
+    //推荐商品
+    @Anonymous
+    @PutMapping("/changerecommend")
+    public AjaxResult changerecommend(@RequestBody PosFood posFood) {
+        return toAjax(posFoodService.saveOrUpdate(posFood));
+    }
+
+    /**
+     * 添加或修改商品
+     */
+    @Anonymous
+    @PostMapping("/setposfood")
+    public AjaxResult setposfood(@RequestBody PosFood posFood) {
+        Boolean org = posFoodService.saveOrUpdate(posFood);
+        if (!org) {
+            return error();
+        }
+        handleFoodSpec(posFood);
+        return success();
+    }
+
+    /**
+     * (h5页面使用)通过分类查询商品列表
+     */
+    @Anonymous
+    @GetMapping("/h5Getidlist")
+    public AjaxResult h5Getidlist(@RequestParam Integer id, @RequestParam(defaultValue = "") String language, @RequestParam(required = false) String stackingUp) {
+        JwtUtil jwtUtil = new JwtUtil();
+//        String ids = jwtUtil.getusid(token);
+//        InfoUser user = infoUserService.getById(ids);
+        QueryWrapper<PosFood> queryWrapper = new QueryWrapper<>();
+        queryWrapper.eq("fl_id", id);
+//        if(!user.getUserType().equals("1")){
+//            queryWrapper.eq("to_examine","1");
+//        }
+        if (stackingUp != null && !"".equals(stackingUp)) {
+            queryWrapper.eq("stacking_up", stackingUp);
+        }
+        String lang = "0";
+        if (!"".equals(language)) {
+            lang = language;
+        }
+        queryWrapper.eq("language", lang);
+        List<PosFood> list = posFoodService.list(queryWrapper);
+        List<Long> __foodIds = list.stream().map(PosFood::getId).collect(Collectors.toList());
+        Map<Long, List<FoodSpecs>> __specMap = loadFoodSpecsMap(__foodIds);
+        JSONArray all = new JSONArray();
+        for (int i = 0; i < list.size(); i++) {
+            JSONObject org = new JSONObject();
+            org.put("id", list.get(i).getId());
+            org.put("fenlei", posFenleiService.getById(list.get(i).getFlId()));
+            org.put("store", posStoreService.getById(list.get(i).getMdid()));
+            org.put("name", list.get(i).getName());
+            org.put("image", list.get(i).getImage());
+            org.put("price", list.get(i).getPrice());
+            org.put("introduce", list.get(i).getIntroduce());
+            org.put("recommend", list.get(i).getRecommend());
+            List<FoodSpecs> __fs = __specMap.getOrDefault(list.get(i).getId(), new ArrayList<>());
+            org.put("foodSpecs", __fs);
+            org.put("foodSku", __fs.isEmpty() ? JSONArray.parseArray(list.get(i).getFoodSku()) : buildFoodSkuArray(__fs));
+            org.put("stackingUp", list.get(i).getStackingUp());
+            org.put("toExamine", list.get(i).getToExamine());
+            all.add(org);
+        }
+        return success(all);
+    }
+
+    /**
+     * 通过分类查询商品列表
+     */
+    @Anonymous
+    @GetMapping("/getidlist")
+    public AjaxResult getidlist(@RequestHeader String token, @RequestParam Integer id, @RequestParam(defaultValue = "") String language, @RequestParam(required = false) String stackingUp, @RequestParam(required = false) String name) {
+        JwtUtil jwtUtil = new JwtUtil();
+        String ids = jwtUtil.getusid(token);
+        InfoUser user = infoUserService.getById(ids);
+        QueryWrapper<PosFood> queryWrapper = new QueryWrapper<>();
+        queryWrapper.eq("fl_id", id);
+        //普通用户显示审核通过的
+        if("0".equals(user.getUserType())){
+            queryWrapper.eq("to_examine", "1");
+        }
+        if (stackingUp != null && !"".equals(stackingUp)) {
+            queryWrapper.eq("stacking_up", stackingUp);
+        }
+        String lang = "3";
+        if (!"".equals(language)) {
+            lang = language;
+        }
+        queryWrapper.eq("language", lang);
+        if (name != null && !"".equals(name)) {
+            queryWrapper.apply(" BINARY name LIKE CONCAT('%', {0}, '%')", name);
+        }
+        List<PosFood> list = posFoodService.list(queryWrapper);
+        List<Long> __foodIds = list.stream().map(PosFood::getId).collect(Collectors.toList());
+        Map<Long, List<FoodSpecs>> __specMap = loadFoodSpecsMap(__foodIds);
+        JSONArray all = new JSONArray();
+        for (int i = 0; i < list.size(); i++) {
+            JSONObject org = new JSONObject();
+            org.put("id", list.get(i).getId());
+            org.put("fenlei", posFenleiService.getById(list.get(i).getFlId()));
+            PosStore store = posStoreService.getById(list.get(i).getMdid());
+            fillStallLocation(store);
+            org.put("store", store);
+            org.put("canInvoice", orderInvoiceService.canInvoice(list.get(i).getMdid()));
+            org.put("name", list.get(i).getName());
+            org.put("image", list.get(i).getImage());
+            org.put("price", list.get(i).getPrice());
+            org.put("introduce", list.get(i).getIntroduce());
+            org.put("recommend", list.get(i).getRecommend());
+            List<FoodSpecs> __fs = __specMap.getOrDefault(list.get(i).getId(), new ArrayList<>());
+            org.put("foodSpecs", __fs);
+            org.put("foodSku", __fs.isEmpty() ? JSONArray.parseArray(list.get(i).getFoodSku()) : buildFoodSkuArray(__fs));
+            org.put("stackingUp", list.get(i).getStackingUp());
+            org.put("toExamine", list.get(i).getToExamine());
+            all.add(org);
+        }
+        return success(all);
+    }
+
+    /**
+     * 查询商品详情
+     */
+    @Anonymous
+    @GetMapping("/getfood")
+    public AjaxResult getfood(@RequestParam Integer id) {
+        JSONObject org = new JSONObject();
+        PosFood posFood = posFoodService.getById(id);
+        List<Long> __foodIds = new ArrayList<>();
+        __foodIds.add(posFood.getId());
+        Map<Long, List<FoodSpecs>> __specMap = loadFoodSpecsMap(__foodIds);
+        List<FoodSpecs> __fs = __specMap.getOrDefault(posFood.getId(), new ArrayList<>());
+        org.put("id", posFood.getId());
+        org.put("fenlei", posFenleiService.getById(posFood.getFlId()));
+        PosStore store = posStoreService.getById(posFood.getMdid());
+        fillStallLocation(store);
+        org.put("store", store);
+        org.put("canInvoice", orderInvoiceService.canInvoice(posFood.getMdid()));

+        org.put("name", posFood.getName());
+        org.put("image", posFood.getImage());
+        org.put("price", posFood.getPrice());
+        org.put("introduce", posFood.getIntroduce());
+        org.put("recommend", posFood.getRecommend());
+        org.put("stackingUp", posFood.getStackingUp());
+        org.put("foodSpecs", __fs);
+        org.put("foodSku", __fs.isEmpty() ? JSONArray.parseArray(posFood.getFoodSku()) : buildFoodSkuArray(__fs));
+        return success(org);
+    }
+
+    /**
+     * 搜索
+     */
+    @Anonymous
+    @GetMapping("/searchfor")
+    public AjaxResult searchfor(@RequestParam String keyword, @RequestParam(defaultValue = "") String language) {
+        if (keyword.equals("")) {
+            return error(MessageUtils.message("no.tip.lock.keywork"));
+        }
+        JSONArray all = new JSONArray();
+        JSONObject object = new JSONObject();
+        QueryWrapper<PosFood> query = new QueryWrapper<>();
+        String lang = "0";
+        if (!"".equals(language)) {
+            lang = language;
+        }
+        query.eq("language", lang);
+        query.eq("to_examine", "1");
+//        query.like("name",keyword);
+        query.apply("BINARY name LIKE CONCAT('%', {0}, '%')", keyword);
+        List<PosFood> folist = posFoodService.list(query);
+        QueryWrapper<PosStore> wrapper = new QueryWrapper<>();
+        wrapper.eq("off_shelf", "0")
+//                .and(i->i.like("pos_name", keyword).or().like("brief_introduction",keyword));
+                .and(i -> i.apply("BINARY pos_name LIKE CONCAT('%', {0}, '%')", keyword).or().apply("BINARY brief_introduction LIKE CONCAT('%', {0}, '%')", keyword));
+        List<PosStore> stlist = posStoreService.list(wrapper);
+        for (int i = 0; i < folist.size(); i++) {
+            PosStore store = posStoreService.getById(folist.get(i).getMdid());
+            if (store.getOffShelf().equals("0")) {
+                JSONObject org = new JSONObject();
+                org.put("id", folist.get(i).getId());
+                org.put("fenlei", posFenleiService.getById(folist.get(i).getFlId()));
+                org.put("store", posStoreService.getById(folist.get(i).getMdid()));
+                org.put("name", folist.get(i).getName());
+                org.put("image", folist.get(i).getImage());
+                org.put("price", folist.get(i).getPrice());
+                org.put("introduce", folist.get(i).getIntroduce());
+                org.put("recommend", folist.get(i).getRecommend());
+                org.put("stackingUp", folist.get(i).getStackingUp());
+                org.put("foodSku", JSONArray.parseArray(folist.get(i).getFoodSku()));
+                all.add(org);
+            }
+        }
+        object.put("food", all);
+        object.put("store", stlist);
+        return success(object);
+    }
+
+
+    /**
+     * 客户端查询美食
+     *
+     * @param keyword
+     * @param language
+     * @return
+     */
+
+    @Anonymous
+    @GetMapping("/foodSearch")
+    public AjaxResult foodSearch(@RequestParam String keyword, @RequestParam Integer pageNum, @RequestParam Integer pageSize, @RequestParam(defaultValue = "") String language) {
+        if (keyword.isEmpty()) {
+            return error(MessageUtils.message("no.tip.lock.keywork"));
+        }
+
+        // 设置默认分页参数
+        if (pageNum == null || pageNum < 1) {
+            pageNum = 1;
+        }
+        if (pageSize == null || pageSize < 1) {
+            pageSize = 10;
+        }
+
+        String lang = "0";
+        if (!"".equals(language)) {
+            lang = language;
+        }
+        keyword = hanlderKeyword(lang, keyword);
+
+        // 创建分页对象
+        Page<PosStore> page = new Page<>(pageNum, pageSize);
+
+        // 1. 先通过联表查询获取分页的店铺列表(包含总数)
+        IPage<PosStore> storePage = posStoreMapper.selectStoresByFoodKeyword(page, keyword, lang);
+        List<PosStore> stores = storePage.getRecords();
+
+        List<FoodSearchOutput> result = new ArrayList<>();
+        Map<Long, List<PosReview>> reviewMap = Collections.emptyMap();
+        Map<Long, Long> orderCountMap = Collections.emptyMap();
+        List<OperatingHours> hourslist=new ArrayList<>();
+        // 2. 一次性查询所有相关商品(避免循环查询数据库)
+        if (!stores.isEmpty()) {
+            // 提取所有店铺ID
+            List<Long> storeIds = stores.stream()
+                    .map(store -> store.getId().longValue())
+                    .collect(Collectors.toList());
+
+            // 一次性查询所有店铺下匹配关键词的商品
+            QueryWrapper<PosFood> foodQuery = new QueryWrapper<>();
+            foodQuery.in("mdid", storeIds)
+                    .eq("language", lang)
+                    .eq("to_examine", "1")
+                    .apply(" CONCAT(name, ' ') LIKE CONCAT('%', {0}, '%') COLLATE utf8mb4_unicode_ci", keyword);
+
+            List<PosFood> allFoods = posFoodService.list(foodQuery);
+            {
+                List<Long> __foodIds = allFoods.stream().map(PosFood::getId).collect(Collectors.toList());
+                Map<Long, List<FoodSpecs>> __specMap = loadFoodSpecsMap(__foodIds);
+                for (PosFood __f : allFoods) {
+                    __f.setFoodSpecs(__specMap.getOrDefault(__f.getId(), new ArrayList<>()));
+                }
+            }
+
+            // 按店铺ID分组商品
+            Map<Long, List<PosFood>> foodsByStore = allFoods.stream()
+                    .collect(Collectors.groupingBy(PosFood::getMdid));
+            posStoreEnrichService.enrichStoreList(stores);
+            // 3. 为每个店铺设置对应的商品
+            for (PosStore store : stores) {
+                FoodSearchOutput output = new FoodSearchOutput();
+
+                output.setPosStore(store);
+                // 从分组结果中获取该店铺的商品
+                List<PosFood> storeFoods = foodsByStore.getOrDefault(store.getId().longValue(), new ArrayList<>());
+
+                output.setPosFoodList(storeFoods);
+                result.add(output);
+            }
+        }
+
+        // 3. 返回分页结果
+        Map<String, Object> response = new HashMap<>();
+        response.put("list", result);
+        response.put("total", storePage.getTotal());
+        return success(response);
+    }
+
+    //越南语搜索,添加空格,搜索的关键字变成有语意的
+    private String hanlderKeyword(String language, String keyword) {
+        if (language.equals("0")) {
+            if (!keyword.contains(" "))
+                keyword = keyword + " ";
+        }
+        return keyword;
+    }
+
+    /**
+     * 客户端查询门店
+     *
+     * @param keyword
+     * @param language
+     * @return
+     */
+
+    @Anonymous
+    @GetMapping("/storeSearch")
+    public AjaxResult storeSearch(@RequestParam String keyword, @RequestParam Integer pageNum, @RequestParam Integer pageSize, @RequestParam(defaultValue = "") String language) {
+        if (keyword.isEmpty()) {
+            return error(MessageUtils.message("no.tip.lock.keywork"));
+        }
+        QueryWrapper<PosFood> query = new QueryWrapper<>();
+        String lang = "0";
+        if (!"".equals(language)) {
+            lang = language;
+        }
+        keyword = hanlderKeyword(lang, keyword);
+        // 设置默认分页参数
+        if (pageNum == null || pageNum < 1) {
+            pageNum = 1;
+        }
+        if (pageSize == null || pageSize < 1) {
+            pageSize = 10;
+        }
+        Page<PosStore> page = new Page<>(pageNum, pageSize);
+        QueryWrapper<PosStore> storeWrapper = new QueryWrapper<>();
+        String finalKeyword = keyword;
+        storeWrapper.eq("off_shelf", "0")
+//                .and(i->i.like("pos_name", keyword).or().like("brief_introduction",keyword));
+                .and(i -> i.apply(" CONCAT(pos_name,' ') LIKE CONCAT('%', {0}, '%') COLLATE utf8mb4_unicode_ci", finalKeyword).or().apply(" CONCAT(brief_introduction,' ') LIKE CONCAT('%', {0}, '%') COLLATE utf8mb4_unicode_ci", finalKeyword));
+        IPage<PosStore> data = posStoreService.page(page, storeWrapper);
+        Map<String, Object> response = new HashMap<>();
+        List<PosStore> stores=data.getRecords();
+        posStoreEnrichService.enrichStoreList(stores);
+        response.put("list", stores);
+        response.put("total", data.getTotal());
+        return success(response);
+    }
+
+
+
+    @Anonymous
+    @GetMapping("/getFoodPageList")
+    public AjaxResult getFoodPageList(@RequestHeader String token,
+                                      @RequestParam Integer page,
+                                      @RequestParam Integer size,
+                                      @RequestParam Long flId,
+                                      @RequestParam Long mdId,
+                                      @RequestParam(defaultValue = "") String language,
+                                      @RequestParam(defaultValue = "") String name) {
+        JwtUtil jwtUtil = new JwtUtil();
+        String id = jwtUtil.getusid(token);
+        IPage<PosFood> stlist = new Page<>(page, size);
+        LambdaQueryWrapper<PosFood> queryWrapper = new LambdaQueryWrapper<>();
+        queryWrapper.eq(PosFood::getFlId, flId);
+        queryWrapper.eq(PosFood::getMdid, mdId);
+
+        // language 不为 null 且不为空字符串时才加条件
+        if (language != null && !"".equals(language)) {
+            queryWrapper.eq(PosFood::getLanguage, language);
+        }
+
+        // name 不为 null 且不为空字符串时模糊查询
+        if (name != null && !"".equals(name)) {
+            queryWrapper.like(PosFood::getName, name);
+        }
+
+        IPage<PosFood> list = posFoodService.page(stlist, queryWrapper);
+        return success(list);
+    }
+
+    /**
+     * C端扫码:获取摊位商品列表(摊位码扫码入口)
+     */
+    @Anonymous
+    @GetMapping("/stallFoodList")
+    public AjaxResult stallFoodList(@RequestParam Long storeId,
+                                    @RequestParam(required = false) Long flId,
+                                    @RequestParam Integer page,
+                                    @RequestParam Integer size,
+                                    @RequestParam(required = false) Long tableId) {
+        IPage<PosFood> foodPage = new Page<>(page, size);
+        QueryWrapper<PosFood> queryWrapper = new QueryWrapper<>();
+        queryWrapper.eq("mdid", storeId);
+        queryWrapper.eq("to_examine", "1");
+        queryWrapper.eq("stacking_up", "1");
+        if (flId != null) {
+            queryWrapper.eq("fl_id", flId);
+        }
+        IPage<PosFood> result = posFoodService.page(foodPage, queryWrapper);
+        List<Long> __foodIds = result.getRecords().stream().map(PosFood::getId).collect(Collectors.toList());
+        Map<Long, List<FoodSpecs>> __specMap = loadFoodSpecsMap(__foodIds);
+        for (PosFood __f : result.getRecords()) {
+            __f.setFoodSpecs(__specMap.getOrDefault(__f.getId(), new ArrayList<>()));
+        }
+        return success(result);
+    }
+
+    /**
+     * 查询商品列表
+     */
+    @Anonymous
+    @GetMapping("/getfoodlist")
+    public AjaxResult getfoodlist(@RequestParam Integer page,
+                                  @RequestParam Integer size,
+                                  @RequestParam(defaultValue = "") String language,
+                                  @RequestParam(defaultValue = "") String keyword) {
+        IPage<PosFood> food = new Page<>(page, size);
+        QueryWrapper<PosFood> queryWrapper = new QueryWrapper<>();
+        String lang = "0";
+        if (!"".equals(language)) {
+            lang = language;
+        }
+        queryWrapper.eq("language", lang);
+        queryWrapper.eq("to_examine", "1");
+        if (!"".equals(keyword)) {
+            queryWrapper.like("name", keyword);
+        }
+        IPage<PosFood> list = posFoodService.page(food, queryWrapper);
+        List<PosFood> foodlist = list.getRecords();
+        JSONArray all = new JSONArray();
+        for (int i = 0; i < foodlist.size(); i++) {
+            PosStore store = posStoreService.getById(foodlist.get(i).getMdid());
+            if (store.getOffShelf().equals("0")) {
+                JSONObject org = new JSONObject();
+                org.put("id", foodlist.get(i).getId());
+                org.put("fenlei", posFenleiService.getById(foodlist.get(i).getFlId()));
+                org.put("store", posStoreService.getById(foodlist.get(i).getMdid()));
+                org.put("name", foodlist.get(i).getName());
+                org.put("image", foodlist.get(i).getImage());
+                org.put("price", foodlist.get(i).getPrice());
+                org.put("introduce", foodlist.get(i).getIntroduce());
+                org.put("recommend", foodlist.get(i).getRecommend());
+                org.put("stackingUp", foodlist.get(i).getStackingUp());
+                org.put("foodSku", JSONArray.parseArray(foodlist.get(i).getFoodSku()));
+                all.add(org);
+            }
+        }
+        return success(all);
+    }
+
+    /**
+     * 查询food列表
+     */
+
+    @PreAuthorize("@ss.hasPermi('chanting:food:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(PosFood posFood) {
+        startPage();
+        List<PosFood> list = posFoodService.selectPosFoodList(posFood);
+        return getDataTable(list);
+    }
+
+    /**
+     * 导出food列表
+     */
+    @PreAuthorize("@ss.hasPermi('chanting:food:export')")
+    @Log(title = "food", businessType = BusinessType.EXPORT)
+    @PostMapping("/export")
+    public void export(HttpServletResponse response, PosFood posFood) {
+        List<PosFood> list = posFoodService.selectPosFoodList(posFood);
+        ExcelUtil<PosFood> util = new ExcelUtil<PosFood>(PosFood.class);
+        util.exportExcel(response, list, MessageUtils.message("no.export.excel.food"));
+    }
+
+    /**
+     * 获取food详细信息
+     */
+    @Anonymous
+    @GetMapping(value = "/{id}")
+    public AjaxResult getInfo(@PathVariable("id") Long id) {
+        return success(posFoodService.selectPosFoodById(id));
+    }
+
+    /**
+     * 新增food
+     */
+    @PreAuthorize("@ss.hasPermi('chanting:food:add')")
+    @Log(title = "food", businessType = BusinessType.INSERT)
+    @PostMapping
+    public AjaxResult add(@RequestBody PosFood posFood) {
+        int rows = posFoodService.insertPosFood(posFood);
+        if (rows > 0) {
+            handleFoodSpec(posFood);
+        }
+        return toAjax(rows);
+    }
+
+    /**
+     * 修改food
+     */
+    @PreAuthorize("@ss.hasPermi('chanting:food:edit')")
+    @Log(title = "food", businessType = BusinessType.UPDATE)
+    @PutMapping
+    public AjaxResult edit(@RequestBody PosFood posFood) {
+        int rows = posFoodService.updatePosFood(posFood);
+        if (rows > 0) {
+            handleFoodSpec(posFood);
+        }
+        return toAjax(rows);
+    }
+
+    /**
+     * 删除food
+     */
+    @PreAuthorize("@ss.hasPermi('chanting:food:remove')")
+    @Log(title = "food", businessType = BusinessType.DELETE)
+    @DeleteMapping("/{ids}")
+    public AjaxResult remove(@PathVariable Long[] ids) {
+        return toAjax(posFoodService.deletePosFoodByIds(ids));
+    }
+
+    /**
+     * 压缩图片店铺的商品图片
+     */
+    @Anonymous
+    @GetMapping("/compressFoodImage")
+    public AjaxResult compressFoodImage(@RequestParam Long mdId, @RequestParam String idsStr) {
+        LambdaQueryWrapper<PosFood> queryWrapper = new LambdaQueryWrapper<>();
+        queryWrapper.eq(PosFood::getMdid, mdId);
+        List<Long> ids= new ArrayList<>();
+        if(!StringUtils.isEmpty(idsStr)){
+            ids = Arrays.stream(idsStr.split(","))
+                    .map(Long::valueOf)
+                    .collect(Collectors.toList());
+        }
+        queryWrapper.in(!ids.isEmpty(), PosFood::getId, ids);
+        List<PosFood> list = posFoodService.list(queryWrapper);
+        AtomicInteger count = new AtomicInteger();
+        list.forEach(food -> {
+            String compressImage = ImageCompressUtils.compressImageByWebPath(food.getImage());
+            if (!compressImage.equals(food.getImage())) {
+                food.setImage(compressImage);
+                PosFood posFood = new PosFood();
+                posFood.setId(food.getId());
+                posFood.setImage(compressImage);
+                posFoodService.saveOrUpdate(posFood);
+                count.getAndIncrement();
+                System.out.println("food成功压缩的图片数量:"+count.get());
+                logger.info("food成功压缩的图片数量:"+count.get());
+            }
+        });
+        return success("成功压缩的 :"+count.get());
+    }
+
+    /**
+     * 压缩所有的商品图片
+     */
+    @Anonymous
+    @GetMapping("/compressFoodImageAll")
+    public AjaxResult compressFoodImage() {
+        LambdaQueryWrapper<PosFood> queryWrapper = new LambdaQueryWrapper<>();
+        List<PosFood> list = posFoodService.list(queryWrapper);
+        AtomicInteger count = new AtomicInteger();
+        list.forEach(food -> {
+            String compressImage = ImageCompressUtils.compressImageByWebPath(food.getImage());
+            if (!compressImage.equals(food.getImage())) {
+                food.setImage(compressImage);
+                PosFood posFood = new PosFood();
+                posFood.setId(food.getId());
+                posFood.setImage(compressImage);
+                posFoodService.saveOrUpdate(posFood);
+                count.getAndIncrement();
+                System.out.println("food成功压缩的图片数量:"+count.get());
+                logger.info("food成功压缩的图片数量:"+count.get());
+            }
+        });
+        return success("成功压缩的图片数量:"+count.get());
+    }
+
+    /**
+     * 当门店是摊位时,用夜市的经纬度覆盖摊位的经纬度
+     */
+    /**
+     * 保存商品规格:sku JSON 写 food_sku(落库) -> 删除旧关联 -> 按 foodSpecs 建立新关联
+     */
+    private void handleFoodSpec(PosFood posFood) {
+        if (posFood == null || posFood.getId() == null) {
+            return;
+        }
+        JSONArray sku = posFood.getSku();
+        if (sku != null) {
+            PosFood upd = new PosFood();
+            upd.setId(posFood.getId());
+            upd.setFoodSku(sku.toString());
+            posFoodService.updateById(upd);
+            posFood.setFoodSku(sku.toString());
+        }
+        foodSpecRelationService.remove(new LambdaQueryWrapper<FoodSpecRelation>()
+                .eq(FoodSpecRelation::getFoodId, posFood.getId()));
+        List<FoodSpecs> foodSpecs = posFood.getFoodSpecs();
+        if (foodSpecs != null && !foodSpecs.isEmpty()) {
+            List<FoodSpecRelation> rels = foodSpecs.stream()
+                    .filter(sp -> sp.getId() != null && sp.getId() > 0)
+                    .map(sp -> {
+                        FoodSpecRelation r = new FoodSpecRelation();
+                        r.setFoodId(posFood.getId());
+                        r.setSpecsId(sp.getId());
+                        return r;
+                    }).collect(Collectors.toList());
+            if (!rels.isEmpty()) {
+                foodSpecRelationService.saveBatch(rels);
+            }
+        }
+    }
+
+    /**
+     * 批量查询商品规格(避免 N+1):关联 -> 启用规格组 -> 启用规格值 -> 内存分组
+     * @return foodId -> 规格组列表(含启用规格值)
+     */
+    private Map<Long, List<FoodSpecs>> loadFoodSpecsMap(List<Long> foodIds) {
+        Map<Long, List<FoodSpecs>> result = new HashMap<>();
+        if (foodIds == null || foodIds.isEmpty()) {
+            return result;
+        }
+        List<FoodSpecRelation> rels = foodSpecRelationService.list(new LambdaQueryWrapper<FoodSpecRelation>()
+                .in(FoodSpecRelation::getFoodId, foodIds));
+        if (rels.isEmpty()) {
+            return result;
+        }
+        Map<Long, List<Long>> foodToSpecIds = rels.stream()
+                .collect(Collectors.groupingBy(FoodSpecRelation::getFoodId,
+                        Collectors.mapping(FoodSpecRelation::getSpecsId, Collectors.toList())));
+        List<Long> specsIds = rels.stream().map(FoodSpecRelation::getSpecsId).distinct().collect(Collectors.toList());
+        List<FoodSpecs> specsList = foodSpecsService.list(new LambdaQueryWrapper<FoodSpecs>()
+                .in(FoodSpecs::getId, specsIds)
+                .eq(FoodSpecs::getIsOpen, true)
+                .eq(FoodSpecs::getIsDelete, false)
+                .orderByAsc(FoodSpecs::getSort));
+        if (specsList.isEmpty()) {
+            return result;
+        }
+        List<Long> enabledSpecIds = specsList.stream().map(FoodSpecs::getId).collect(Collectors.toList());
+        List<FoodSpecsValue> values = foodSpecsValueService.list(new LambdaQueryWrapper<FoodSpecsValue>()
+                .in(FoodSpecsValue::getParentId, enabledSpecIds)
+                .eq(FoodSpecsValue::getIsOpen, true)
+                .orderByAsc(FoodSpecsValue::getId));
+        Map<Long, List<FoodSpecsValue>> valueMap = values.stream()
+                .collect(Collectors.groupingBy(FoodSpecsValue::getParentId));
+        Map<Long, FoodSpecs> specsMap = specsList.stream().collect(Collectors.toMap(FoodSpecs::getId, sp -> sp));
+        for (Map.Entry<Long, List<Long>> entry : foodToSpecIds.entrySet()) {
+            List<FoodSpecs> fsList = new ArrayList<>();
+            for (Long sid : entry.getValue()) {
+                FoodSpecs src = specsMap.get(sid);
+                if (src != null) {
+                    FoodSpecs copy = new FoodSpecs();
+                    copy.setId(src.getId());
+                    copy.setTitle(src.getTitle());
+                    copy.setType(src.getType());
+                    copy.setState(src.getState());
+                    copy.setLanguage(src.getLanguage());
+                    copy.setRemark(src.getRemark());
+                    copy.setMdId(src.getMdId());
+                    copy.setSort(src.getSort());
+                    copy.setIsOpen(src.getIsOpen());
+                    copy.setIsDelete(src.getIsDelete());
+                    copy.setObjects(valueMap.getOrDefault(src.getId(), new ArrayList<>()));
+                    fsList.add(copy);
+                }
+            }
+            result.put(entry.getKey(), fsList);
+        }
+        return result;
+    }
+
+    /**
+     * 由结构化规格构建兼容的 food_sku JSON 数组(明细字段名 foodSpecsItems)
+     */
+    private JSONArray buildFoodSkuArray(List<FoodSpecs> fsList) {
+        JSONArray skuArr = new JSONArray();
+        if (fsList == null || fsList.isEmpty()) {
+            return skuArr;
+        }
+        for (FoodSpecs s : fsList) {
+            skuArr.add(JSONObject.toJSON(s));
+        }
+        return skuArr;
+    }
+
+    private void fillStallLocation(PosStore store) {
+        if (store != null && store.getIsStall() != null && store.getIsStall() == 1
+                && store.getNightMarketId() != null) {
+            InfoUser nightMarket = infoUserService.getById(store.getNightMarketId());
+            if (nightMarket != null) {
+                store.setLongitude(nightMarket.getLongitude());
+                store.setLatitude(nightMarket.getLatitude());
+            }
+        }
+    }
+}

+ 4 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosOrderInvoiceController.java

@@ -30,6 +30,10 @@ public class PosOrderInvoiceController extends BaseController {
     @PreAuthorize("@ss.hasPermi('chanting:orderInvoice:list')")
     @GetMapping("/list")
     public TableDataInfo list(PosOrderInvoiceVo query) {
+        // 管理端发票管理只看进过开票流程的(issueTriggered=1),排除仅下单捕获意图、未触发行
+        if (query.getIssueTriggered() == null) {
+            query.setIssueTriggered(1);
+        }
         startPage();
         return getDataTable(orderInvoiceService.list(query));
     }

+ 47 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/EzPayDebugController.java

@@ -0,0 +1,47 @@
+package com.ruoyi.app.order;
+
+import com.ruoyi.app.utils.ezPay.EzPay;
+import com.ruoyi.app.utils.ezPay.EzPayConfig;
+import com.ruoyi.common.annotation.Anonymous;
+import com.ruoyi.common.core.controller.BaseController;
+import com.ruoyi.common.core.domain.AjaxResult;
+import org.springframework.beans.factory.annotation.Autowired;
+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;
+
+/**
+ * ezPay 调试接口(Swagger 可调)。
+ *
+ * <p>⚠️ <b>仅排查用</b>:含写死的<b>正式环境</b>商店凭证,且直连正式 ezPay。
+ * 上线前必须删除本类,或至少加上权限注解 / 移除写死凭证,避免正式金钥泄露与被滥用。
+ *
+ * @author ruoyi
+ * @date 2026-08-03
+ */
+@RestController
+@RequestMapping("/debug/ezpay")
+public class EzPayDebugController extends BaseController {
+
+    @Autowired
+    private EzPay ezPay;
+
+    /** 正式环境商店凭证(排查用,写死)。 */
+    private static final String MERCHANT_ID = "349957237";
+    private static final String HASH_KEY = "diuFXyDzKTHmyu60IBxT9M1qgfoQW5gZ";
+    private static final String HASH_IV = "PF6B3JpTiym6zm3C";
+
+    /**
+     * 手机条码校验(直连正式环境 inv.ezpay.com.tw)。
+     *
+     * @param barCode 手机条码,如 /ABC1234(/ + 7 位大写英文/数字)
+     * @return isExist=true 条码存在于财政部平台;同时回传 ezPay 原始回应与解密内容便于排查
+     */
+    @Anonymous
+    @GetMapping("/checkBarCode")
+    public AjaxResult checkBarCode(@RequestParam String barCode) throws Exception {
+        EzPayConfig cfg = new EzPayConfig(MERCHANT_ID, HASH_KEY, HASH_IV);
+        return AjaxResult.success(ezPay.checkBarCodeDetail(EzPay.BASE_PROD, cfg, barCode));
+    }
+}

+ 93 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/InfoInvoiceController.java

@@ -0,0 +1,93 @@
+package com.ruoyi.app.order;
+
+import java.util.List;
+
+import com.ruoyi.common.annotation.Anonymous;
+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.InfoInvoice;
+import com.ruoyi.system.service.IInfoInvoiceService;
+import com.ruoyi.system.utils.Auth;
+import com.ruoyi.system.utils.JwtUtil;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import jakarta.validation.Valid;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+/**
+ * 用户发票抬头管理 Controller(发票信息管理)
+ * <p>镜像 InfoAddressController 的 App 端风格:@Anonymous @Auth + token header + JWT 取 userId。
+ * 用户隔离:写库前强制 setUserId、查/改/删按 userId 归属校验。</p>
+ *
+ * @author foodie
+ * @date 2026-07-24
+ */
+@Api("用户发票抬头管理")
+@RestController
+@RequestMapping("/system/invoice")
+public class InfoInvoiceController extends BaseController
+{
+    @Autowired
+    private IInfoInvoiceService infoInvoiceService;
+
+    /**
+     * 列出当前用户的全部发票抬头(按 id 倒序)。
+     * <p>每行带 category(B2C/B2B)作为类型区分依据,前端按 category 渲染不同字段。</p>
+     */
+    @Anonymous
+    @Auth
+    @ApiOperation("列出我的发票抬头(按 category 区分 B2C/B2B)")
+    @GetMapping("/getinvoice")
+    public AjaxResult getinvoice(@RequestHeader String token)
+    {
+        Long userId = Long.valueOf(new JwtUtil().getusid(token));
+        List<InfoInvoice> list = infoInvoiceService.listByUserId(userId);
+        return success(MessageUtils.message("no.obtained.success"), list);
+    }
+
+    /**
+     * 新增或更新发票抬头(saveOrUpdate:有 id 更新、无 id 新增)。
+     * <p>入参字段随 category 变化(见 InfoInvoice 字段备注 / contracts):B2C 传载具、B2B 传统编。
+     * 字段格式由 @Valid 注解先校验,条件必填与类型互斥由 service 强校验。</p>
+     */
+    @Anonymous
+    @Auth
+    @ApiOperation("新增/修改发票抬头(按 category 决定必填字段)")
+    @PostMapping("/invoice")
+    public AjaxResult invoice(@RequestHeader String token, @Valid @RequestBody InfoInvoice infoInvoice)
+    {
+        Long userId = Long.valueOf(new JwtUtil().getusid(token));
+        boolean ok = infoInvoiceService.saveOrUpdateMine(infoInvoice, userId);
+        return ok ? success() : error();
+    }
+
+    /**
+     * 发票抬头详情(越权 / 不存在返回 null)。
+     */
+    @Anonymous
+    @Auth
+    @ApiOperation("发票抬头详情")
+    @GetMapping("/getinvoicexq")
+    public AjaxResult getinvoicexq(@RequestHeader String token, @RequestParam Long id)
+    {
+        Long userId = Long.valueOf(new JwtUtil().getusid(token));
+        InfoInvoice invoice = infoInvoiceService.getMine(id, userId);
+        return success(MessageUtils.message("no.obtained.success"), invoice);
+    }
+
+    /**
+     * 删除发票抬头(越权 / 不存在返回失败)。
+     */
+    @Anonymous
+    @Auth
+    @ApiOperation("删除发票抬头")
+    @GetMapping("/deleinvoice")
+    public AjaxResult deleinvoice(@RequestHeader String token, @RequestParam Long id)
+    {
+        Long userId = Long.valueOf(new JwtUtil().getusid(token));
+        boolean ok = infoInvoiceService.deleteMine(id, userId);
+        return ok ? success() : error("抬头不存在或无权操作");
+    }
+}

+ 241 - 30
ruoyi-admin/src/main/java/com/ruoyi/app/order/OrderInvoiceService.java

@@ -7,6 +7,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.ruoyi.app.utils.ezPay.EzPay;
 import com.ruoyi.app.utils.ezPay.EzPayConfig;
 import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
 import com.ruoyi.common.utils.SecurityUtils;
 import com.ruoyi.system.domain.PosLoveOrg;
 import com.ruoyi.system.domain.PosOrder;
@@ -199,17 +200,129 @@ public class OrderInvoiceService {
         return posOrderInvoiceMapper.selectInvoiceDetail(orderId);
     }
 
+    /**
+     * 下单捕获发票意图(010 Phase 11):把下单选的发票类型/载具/统编/捐赠码落成 pos_order_invoice
+     * 意图行(issueTriggered=0 未触发开票、invoiceStatus=0 未开);出餐 {@link #autoIssue} 在该行
+     * 上更新开票结果并置 issueTriggered=1。仅在门店可开票时由 createOrder 调用。
+     */
+    public void captureInvoiceIntent(PosOrderInvoice intent) {
+        intent.setInvoiceStatus(STATUS_NOT_ISSUED);
+        intent.setIssueTriggered(0);
+        if (intent.getApplyTime() == null) {
+            intent.setApplyTime(new Date());
+        }
+        saveOrUpdate(intent);
+    }
+
+    /**
+     * 商家出餐自动开票(010 Phase 11):读 pos_order_invoice 发票意图行 → 组装 dto(个人 BuyerName=手机号、
+     * 捐赠=机构名)→ 复用开票核心 {@link #reIssueAndSave}。系统触发,不校验客户归属;货到付款未付款也开
+     * (送达失败/取消时作废兜底)。门店不可开票 / 订单无发票意图 / 已开 → 静默跳过,不影响出餐。
+     *
+     * @param orderId PosOrder.id(PK,与 invalid/retry/selectInvoiceDetail 一致)
+     * @return 1=已开 0=跳过或失败
+     */
+    public int autoIssue(Long orderId) {
+        PosOrder order = posOrderMapper.selectPosOrderById(orderId);
+        if (order == null) {
+            return 0; // 订单不存在
+        }
+        // 发票意图自 Phase 11 起落 pos_order_invoice(下单捕获的意图行),不再读 pos_order
+        PosOrderInvoice row = posOrderInvoiceMapper.selectOne(
+                new LambdaQueryWrapper<PosOrderInvoice>().eq(PosOrderInvoice::getOrderId, orderId));
+        if (row == null || StrUtil.isBlank(row.getInvoiceChoice())) {
+            return 0; // 无发票意图(门店不可开票时 createOrder 未插意图行)
+        }
+        Long storeId = order.getMdId();
+        if (!canInvoice(storeId)) {
+            return 0; // 出餐时门店已不可开票(被停用等),跳过
+        }
+        PosStoreEzpay ez = posStoreEzpayMapper.selectOne(
+                new LambdaQueryWrapper<PosStoreEzpay>().eq(PosStoreEzpay::getStoreId, storeId));
+        if (ez == null) {
+            return 0;
+        }
+        // 防重复:已开则跳过
+        if (Integer.valueOf(STATUS_ISSUED).equals(row.getInvoiceStatus())) {
+            return 0;
+        }
+        ApplyInvoiceDto dto = buildAutoIssueDto(row);
+        EzPayConfig cfg = new EzPayConfig(ez.getMerchantId(), ez.getHashKey(), ez.getHashIv());
+        // 捐赠=机构名;个人=手机号
+        String loveOrgName = null;
+        if ("LOVE_CODE".equals(row.getInvoiceChoice())) {
+            loveOrgName = resolveLoveOrg(dto.getLoveCode(), cfg);
+            dto.setBuyerName(loveOrgName);
+        } else if (isPersonalChoice(row.getInvoiceChoice())) {
+            InfoUser u = infoUserService.getOne(
+                    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);
+            }
+        }
+        // 出餐触发开票:在意图行上落结果,并标记已触发(与 invoiceStatus 独立)
+        row.setInvoiceCategory(dto.getCategory());
+        row.setBuyerName(dto.getBuyerName());
+        row.setLoveOrgName(loveOrgName);
+        row.setIssueTriggered(1);
+        return reIssueAndSave(order, row, dto, ez);
+    }
+
+    /** 出餐自动开票:pos_order_invoice 发票意图行 → ApplyInvoiceDto(捐赠走 DONATION 复用既有分支)。 */
+    private ApplyInvoiceDto buildAutoIssueDto(PosOrderInvoice intent) {
+        ApplyInvoiceDto dto = new ApplyInvoiceDto();
+        dto.setOrderId(intent.getOrderId());
+        String choice = intent.getInvoiceChoice();
+        switch (choice) {
+            case "PAPER":
+            case "PHONE_BARCODE":
+            case "CITIZEN":
+                dto.setCategory("B2C");
+                dto.setCarrierType(intent.getCarrierType()); // PAPER 时为 null → 个人纸本分支
+                dto.setCarrierNum(intent.getCarrierNum());
+                break;
+            case "MEMBER":
+                // 会员载具 ezPay 类型 2;载具号(会员手机号)与买方名由 autoIssue 取 InfoUser.phone 回填
+                dto.setCategory("B2C");
+                dto.setCarrierType("2");
+                break;
+            case "LOVE_CODE":
+                dto.setCategory("DONATION");
+                dto.setLoveCode(intent.getLoveCode());
+                break;
+            case "COMPANY":
+                dto.setCategory("B2B");
+                dto.setBuyerUbn(intent.getBuyerUbn());
+                dto.setBuyerName(intent.getBuyerName());
+                dto.setBuyerEmail(intent.getBuyerEmail());
+                break;
+            default:
+                break;
+        }
+        return dto;
+    }
+
+    private boolean isPersonalChoice(String choice) {
+        return "PAPER".equals(choice) || "PHONE_BARCODE".equals(choice) || "CITIZEN".equals(choice)
+                || "MEMBER".equals(choice);
+    }
+
     /** 客户查询订单发票;门店不可开票时 invoiceStatus=null(客户端据此隐藏入口)。 */
     public PosOrderInvoiceVo getInvoice(Long orderId, Long currentUserId) {
-        PosOrder order = posOrderMapper.selectOne(new LambdaQueryWrapper<PosOrder>().eq(PosOrder::getDdId,orderId));
+        PosOrder order = posOrderMapper.selectOne(new LambdaQueryWrapper<PosOrder>().eq(PosOrder::getDdId,String.valueOf(orderId)));
         if (order == null || !Objects.equals(order.getUserId(), currentUserId)) {
             throw new ServiceException("订单不存在");
         }
         boolean can = canInvoice(order.getMdId());
-        PosOrderInvoiceVo vo = posOrderInvoiceMapper.selectInvoiceDetail(orderId);
+        PosOrderInvoiceVo vo = posOrderInvoiceMapper.selectInvoiceDetail(order.getId());
         if (vo == null) {
             vo = new PosOrderInvoiceVo();
-            vo.setOrderId(orderId);
+            vo.setOrderId(order.getId());
             vo.setOrderNo(order.getDdId());
             vo.setInvoiceStatus(can ? STATUS_NOT_ISSUED : null);
         }
@@ -265,6 +378,11 @@ public class OrderInvoiceService {
         return posOrderInvoiceMapper.selectInvoiceList(query);
     }
 
+    /** 会员发票夹(010 Phase 11):按 userId 列已开发票(不含码图)。配合 PageHelper 分页。 */
+    public List<PosOrderInvoiceVo> listMyInvoices(Long userId) {
+        return posOrderInvoiceMapper.selectMyInvoices(userId);
+    }
+
     /** 管理端:订单发票详情。 */
     public PosOrderInvoiceVo detail(Long orderId) {
         return posOrderInvoiceMapper.selectInvoiceDetail(orderId);
@@ -282,7 +400,8 @@ public class OrderInvoiceService {
             throw new ServiceException("仅失败或作废的发票可重新开票");
         }
         // 重置为未开,复用客户开票参数重开(运营身份 currentUserId 传 null 跳过归属校验)
-        PosOrder order = posOrderMapper.selectOne(new LambdaQueryWrapper<PosOrder>().eq(PosOrder::getDdId,orderId.toString()));
+        // orderId 实为 pos_order.id(主键,发票行 order_id 已证实),按主键查最稳,不依赖 order_no 是否回填
+        PosOrder order = posOrderMapper.selectPosOrderById(orderId);
         if (order == null) {
             throw new ServiceException("订单不存在");
         }
@@ -329,7 +448,15 @@ public class OrderInvoiceService {
                 saveOrUpdate(row);
                 return 1;
             }
-            throw new ServiceException("作废失败:" + (resp == null ? "ezPay 无回应" : resp.getString("Message")));
+            // ezPay 回非 SUCCESS 且消息含「已作廢」:该票在 ezPay 端已是作废态,本地对齐成作废,
+            // 避免与 ezPay 不同步时(如作废后 retry 复活死票)陷入「再作废也改不回 status」的死锁
+            String msg = resp == null ? "" : resp.getString("Message");
+            if (msg != null && msg.contains("已作廢")) {
+                row.setInvoiceStatus(STATUS_INVALID);
+                saveOrUpdate(row);
+                return 1;
+            }
+            throw new ServiceException("作废失败:" + (resp == null ? "ezPay 无回应" : msg));
         } catch (ServiceException se) {
             throw se;
         } catch (Exception e) {
@@ -387,6 +514,9 @@ public class OrderInvoiceService {
         } else {
             row.setFailReason(StrUtil.sub(failReason, 0, 480));
         }
+        row.setTotalAmt(invoiceTotal);
+        row.setSalesAmt(sales);
+        row.setTaxAmt(tax);
         row.setInvoiceStatus(newStatus);
         saveOrUpdate(row);
         return newStatus == STATUS_ISSUED ? 1 : 0;
@@ -425,6 +555,81 @@ public class OrderInvoiceService {
                 && Integer.valueOf(1).equals(ez.getIsEnabled());
     }
 
+    /** 取门店 ezPay 凭证配置;未开通/未启用返回 null(不抛异常,供校验场景容忍使用)。 */
+    private EzPayConfig getEzpayConfig(Long storeId) {
+        if (storeId == null) {
+            return null;
+        }
+        PosStoreEzpay ez = posStoreEzpayMapper.selectOne(
+                new LambdaQueryWrapper<PosStoreEzpay>().eq(PosStoreEzpay::getStoreId, storeId));
+        if (ez == null || !Integer.valueOf(2).equals(ez.getEzpayStatus())
+                || !Integer.valueOf(1).equals(ez.getIsEnabled())) {
+            return null;
+        }
+        return new EzPayConfig(ez.getMerchantId(), ez.getHashKey(), ez.getHashIv());
+    }
+
+    /**
+     * 结账时校验发票意图(010 Phase 11):载具/捐赠联网验真、统编/凭证格式校验。
+     * PAPER(默认纸本)不校验。联网校验(手机条码/捐赠码)用 storeId 对应门店的 ezPay 凭证调 BDV。
+     *
+     * @param storeId 一个可开票门店(调用方负责选 canInvoice 的门店,用于取 ezPay 凭证)
+     */
+    public void validateCheckoutInvoiceIntent(String invoiceChoice, String carrierNum, String loveCode,
+                                              String buyerUbn, String buyerEmail, Long storeId) {
+        if (StrUtil.isBlank(invoiceChoice) || "PAPER".equals(invoiceChoice)) {
+            return;
+        }
+        switch (invoiceChoice) {
+            case "PHONE_BARCODE":
+                if (StrUtil.isBlank(carrierNum) || !carrierNum.matches("^/[0-9A-Z+\\-.]{7}$")) {
+                    throw new ServiceException(MessageUtils.message("no.invoice.barcode.format"));
+                }
+                EzPayConfig barCfg = getEzpayConfig(storeId);
+                if (barCfg == null) {
+                    throw new ServiceException(MessageUtils.message("no.invoice.store.notsupport.barcode"));
+                }
+                boolean barExist;
+                try {
+                    barExist = ezPay.checkBarCode(ezpayBaseUrl, barCfg, carrierNum);
+                } catch (Exception e) {
+                    throw new ServiceException(MessageUtils.message("no.invoice.barcode.service.unavailable"));
+                }
+                if (!barExist) {
+                    throw new ServiceException(MessageUtils.message("no.invoice.barcode.notexist"));
+                }
+                break;
+            case "CITIZEN":
+                if (StrUtil.isBlank(carrierNum) || !carrierNum.matches("^[A-Z]{2}\\d{14}$")) {
+                    throw new ServiceException(MessageUtils.message("no.invoice.citizen.format"));
+                }
+                break;
+            case "MEMBER":
+                // 会员载具:载具号=会员手机号(出餐 autoIssue 时取 InfoUser.phone),免联网验真、免格式校验
+                break;
+            case "LOVE_CODE":
+                if (StrUtil.isBlank(loveCode) || !loveCode.matches("^\\d{3,7}$")) {
+                    throw new ServiceException(MessageUtils.message("no.invoice.lovecode.format"));
+                }
+                EzPayConfig loveCfg = getEzpayConfig(storeId);
+                if (loveCfg == null) {
+                    throw new ServiceException(MessageUtils.message("no.invoice.store.notsupport.lovecode"));
+                }
+                resolveLoveOrg(loveCode, loveCfg); // 抛异常=捐赠码无效
+                break;
+            case "COMPANY":
+                if (StrUtil.isBlank(buyerUbn) || !buyerUbn.matches("^\\d{8}$")) {
+                    throw new ServiceException(MessageUtils.message("no.invoice.ubn.format"));
+                }
+                if (StrUtil.isBlank(buyerEmail) || !buyerEmail.matches("^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$")) {
+                    throw new ServiceException(MessageUtils.message("no.invoice.company.email"));
+                }
+                break;
+            default:
+                throw new ServiceException(MessageUtils.message("no.invoice.choice.invalid", invoiceChoice));
+        }
+    }
+
     /**
      * 捐赠机构列表(客户端选择器,仅 enabled=1,配合 PageHelper 分页)。
      */
@@ -445,56 +650,60 @@ public class OrderInvoiceService {
         }
         try {
             if (!ezPay.checkLoveCode(ezpayBaseUrl, cfg, loveCode)) {
-                throw new ServiceException("捐赠码无效");
+                throw new ServiceException(MessageUtils.message("no.invoice.lovecode.invalid"));
             }
         } catch (ServiceException se) {
             throw se;
         } catch (Exception e) {
-            throw new ServiceException("捐赠码验证失败,请稍后重试");
+            throw new ServiceException(MessageUtils.message("no.invoice.lovecode.verify.fail"));
         }
         return "未知机构";
     }
 
     /**
      * 场景入参校验(service 内强校验,拦截非法请求不调 ezPay)。对齐 ezPay 开票模型:
-     * B2B:统编(8位)+邮箱必填、无载具;B2C:载具(0/1/2)+号码必填,ezPay会员载具(2)时邮箱必填
+     * B2B:统编(8位)+邮箱必填、无载具;B2C:载具(0/1/2)+号码必填;会员载具(2)载具号=会员手机号、不收邮箱
      */
     private void validateInvoiceInput(ApplyInvoiceDto dto) {
         if ("B2B".equals(dto.getCategory())) {
             if (StrUtil.isBlank(dto.getBuyerName())) {
-                throw new ServiceException("B2B 发票须填写买方名称");
+                throw new ServiceException(MessageUtils.message("no.invoice.b2b.buyername.blank"));
             }
             if (StrUtil.isBlank(dto.getBuyerUbn()) || !dto.getBuyerUbn().matches("\\d{8}")) {
-                throw new ServiceException("统编格式不正确,须为 8 位数字");
+                throw new ServiceException(MessageUtils.message("no.invoice.ubn.format"));
             }
             if (StrUtil.isBlank(dto.getBuyerEmail())) {
-                throw new ServiceException("B2B 发票须填写买方邮箱");
+                throw new ServiceException(MessageUtils.message("no.invoice.b2b.buyeremail.blank"));
             }
             if (StrUtil.isNotBlank(dto.getCarrierType()) || StrUtil.isNotBlank(dto.getCarrierNum())) {
-                throw new ServiceException("B2B 发票不支持载具");
+                throw new ServiceException(MessageUtils.message("no.invoice.b2b.nocarrier"));
             }
         } else if ("DONATION".equals(dto.getCategory())) {
             // 捐赠:loveCode 3-7 位必填,与载具/统编互斥
             if (StrUtil.isBlank(dto.getLoveCode()) || !dto.getLoveCode().matches("\\d{3,7}")) {
-                throw new ServiceException("捐赠码格式不正确,须为 3-7 位数字");
+                throw new ServiceException(MessageUtils.message("no.invoice.lovecode.format"));
             }
             if (StrUtil.isNotBlank(dto.getCarrierType()) || StrUtil.isNotBlank(dto.getCarrierNum())) {
-                throw new ServiceException("捐赠发票不支持载具");
+                throw new ServiceException(MessageUtils.message("no.invoice.donation.nocarrier"));
             }
             if (StrUtil.isNotBlank(dto.getBuyerUbn())) {
-                throw new ServiceException("捐赠发票不支持统编");
+                throw new ServiceException(MessageUtils.message("no.invoice.donation.noubn"));
             }
         } else if ("B2C".equals(dto.getCategory())) {
             // 载具分支
             if (StrUtil.isBlank(dto.getBuyerName())) {
-                throw new ServiceException("买方名称不能为空");
+                throw new ServiceException(MessageUtils.message("no.invoice.buyername.blank"));
             }
             String ct = dto.getCarrierType();
+            if (StrUtil.isBlank(ct)) {
+                // 无载具 → 个人纸本(PrintFlag=Y,零信息兜底),无需载具校验
+                return;
+            }
             if (!"0".equals(ct) && !"1".equals(ct) && !"2".equals(ct)) {
-                throw new ServiceException("B2C 发票须选择载具类型(0手机条码/1自然人凭证/2ezPay会员)");
+                throw new ServiceException(MessageUtils.message("no.invoice.carriertype.invalid"));
             }
             if (StrUtil.isBlank(dto.getCarrierNum())) {
-                throw new ServiceException("载具号码不能为空");
+                throw new ServiceException(MessageUtils.message("no.invoice.carriernum.blank"));
             }
             // ezPay doc:载具号码前后不得含空白
             dto.setCarrierNum(dto.getCarrierNum().trim());
@@ -502,21 +711,18 @@ public class OrderInvoiceService {
             switch (ct) {
                 case "0":
                     if (!dto.getCarrierNum().matches("^/[0-9A-Z+\\-.]{7}$")) {
-                        throw new ServiceException("手机条码格式不正确(须为 / 加 7 位大写英文/数字/+ - .)");
+                        throw new ServiceException(MessageUtils.message("no.invoice.barcode.format"));
                     }
                     break;
                 case "1":
                     if (!dto.getCarrierNum().matches("^[A-Z]{2}\\d{14}$")) {
-                        throw new ServiceException("自然人凭证格式不正确(须为 2 位大写英文加 14 位数字)");
+                        throw new ServiceException(MessageUtils.message("no.invoice.citizen.format"));
                     }
                     break;
                 case "2":
                 default:
                     break;
             }
-            if ("2".equals(ct) && StrUtil.isBlank(dto.getBuyerEmail())) {
-                throw new ServiceException("ezPay 会员载具须填写买方邮箱");
-            }
         }
     }
 
@@ -537,13 +743,18 @@ public class OrderInvoiceService {
             inv.put("LoveCode", dto.getLoveCode());
             inv.put("PrintFlag", "N");
         } else {
-            // B2C 载具必填(validateInvoiceInput 已校验 carrierType∈0/1/2、号码非空),PrintFlag=N
-            inv.put("CarrierType", dto.getCarrierType());
-            inv.put("CarrierNum", dto.getCarrierNum());
-            inv.put("PrintFlag", "N");
-            // ezPay 会员载具(2)需邮箱;手机条码(0)/自然人凭证(1)票进载具,邮箱留空
-            if ("2".equals(dto.getCarrierType()) && StrUtil.isNotBlank(dto.getBuyerEmail())) {
-                inv.put("BuyerEmail", dto.getBuyerEmail());
+            // B2C:有载具 → PrintFlag=N 存载具;无载具 → PrintFlag=Y 个人纸本(零信息兜底,010 Phase 11)
+            if (StrUtil.isNotBlank(dto.getCarrierType())) {
+                inv.put("CarrierType", dto.getCarrierType());
+                inv.put("CarrierNum", dto.getCarrierNum());
+                inv.put("PrintFlag", "N");
+                // ezPay 手册:CarrierType=2(ezPay会员载具)时 BuyerEmail 必填(INV10013);CarrierNum=卖方自订代号(手机/邮箱/会员编号)
+                if ("2".equals(dto.getCarrierType()) && StrUtil.isNotBlank(dto.getBuyerEmail())) {
+                    inv.put("BuyerEmail", dto.getBuyerEmail());
+                }
+            } else {
+                // 个人纸本:无载具无捐赠,法定 PrintFlag=Y;不传载具/邮箱
+                inv.put("PrintFlag", "Y");
             }
         }
         inv.put("TaxType", "1");

+ 0 - 5
ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderController.java

@@ -648,11 +648,6 @@ public class PosOrderController extends BaseController {
         // 查询促销快照明细
         List<PosOrderPromotion> promotions = posOrderPromotionMapper.selectByOrderId(orlist.getId());
         org.put("promotions", promotions);
-        // 查询发票状态(null=未申请,0未开/1已开/2失败/3作废)
-        PosOrderInvoiceVo invoice = orderInvoiceService.detail(Long.valueOf(orlist.getDdId()));
-        org.put("invoiceStatus", invoice == null ? null : invoice.getInvoiceStatus());
-        // 门店是否可开票(前端据此控制是否显示"申请发票"入口:免用发票/ezPay未开通启用均为 false)
-        org.put("canInvoice", orderInvoiceService.canInvoice(orlist.getMdId()));
         return success(org);
     }
 

+ 25 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderQsOprateController.java

@@ -15,6 +15,8 @@ import com.ruoyi.common.core.domain.AjaxResult;
 import com.ruoyi.common.exception.ServiceException;
 import com.ruoyi.common.utils.MessageUtils;
 import com.ruoyi.common.utils.StringUtils;
+import com.ruoyi.common.utils.DictUtils;
+import com.ruoyi.common.core.domain.entity.SysDictData;
 import com.ruoyi.framework.manager.AsyncManager;
 import com.ruoyi.system.domain.InfoUser;
 import com.ruoyi.system.domain.PosOrder;
@@ -36,6 +38,7 @@ import org.springframework.transaction.support.TransactionSynchronization;
 import org.springframework.transaction.support.TransactionSynchronizationManager;
 import org.springframework.web.bind.annotation.*;
 
+import java.math.BigDecimal;
 import java.util.*;
 import java.util.concurrent.TimeUnit;
 import java.util.stream.Collectors;
@@ -281,13 +284,27 @@ public class PosOrderQsOprateController extends BaseController {
         }
     }
 
+    /**
+     * newTask 距离上限(公里),从字典 sys_qs_newtask_distance 取;未配置/非法返回 null(不过滤)。
+     */
+    private Integer getNewTaskDistanceLimit() {
+        try {
+            List<SysDictData> list = DictUtils.getDictCache("sys_qs_newtask_distance");
+            if (list != null && !list.isEmpty()) {
+                return Integer.parseInt(list.get(0).getDictValue());
+            }
+        } catch (Exception ignored) {
+        }
+        return null;
+    }
+
     /**
      * 骑手端订单列表(Tab分页)
      */
     @Anonymous
     @Auth
     @GetMapping("/orderList")
-    public AjaxResult orderList(@RequestHeader String token, @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "10") int size, @RequestParam(required = false) String tab, @RequestParam(required = false) java.math.BigDecimal longitude, @RequestParam(required = false) java.math.BigDecimal latitude) {
+    public AjaxResult orderList(@RequestHeader String token, @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "10") int size, @RequestParam(required = false) String tab, @RequestParam(required = false) BigDecimal longitude, @RequestParam(required = false) BigDecimal latitude) {
         JwtUtil jwtUtil = new JwtUtil();
         String qsId = jwtUtil.getusid(token);
         Long riderId = Long.valueOf(qsId);
@@ -298,6 +315,13 @@ public class PosOrderQsOprateController extends BaseController {
             case "newTask":
                 wrapper.eq(PosOrder::getType, 0L).eq(PosOrder::getDeliveryStatus, 0L).eq(PosOrder::getState, 2L).eq(PosOrder::getAfterSaleStatus, 0L);
                 if (longitude != null && latitude != null) {
+                    // 距离上限(公里)从字典取,未配置则不过滤
+                    Integer limitKm = getNewTaskDistanceLimit();
+                    if (limitKm != null) {
+                        long limitMeters = limitKm.longValue() * 1000L;
+                        wrapper.apply("ST_Distance_Sphere(point(longitude, latitude), point({0}, {1})) <= {2}",
+                                longitude, latitude, limitMeters);
+                    }
                     wrapper.last("ORDER BY ST_Distance_Sphere(point(longitude, latitude), point(" + longitude + ", " + latitude + ")) ASC");
                 } else {
                     wrapper.orderByDesc(PosOrder::getCretim);

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

@@ -279,6 +279,11 @@ public class PosOrderShOprateController extends BaseController {
         orderLogHelper.log(String.valueOf(order.getDdId()), 2, Long.valueOf(new JwtUtil().getusid(token)), shName, "商家" + shName + "已出餐");
         // 出餐推送
         chuCan(order, update);
+        // 出餐自动开票(010 Phase 11):按订单发票意图自动开,异常不阻断出餐,失败单由运营后台重试
+        try {
+            orderInvoiceService.autoIssue(order.getId());
+        } catch (Exception ignore) {
+        }
         return AjaxResult.success();
     }
 

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

@@ -75,6 +75,8 @@ public class UserOrderController extends BaseController {
     private PosOrderPromotionMapper posOrderPromotionMapper;
     @Autowired
     private OrderInvoiceService orderInvoiceService;
+    @Autowired
+    private IInfoInvoiceService infoInvoiceService;
 
     /**
      * 申请电子发票(订单完成后,客户主动申请:B2C 邮箱 / B2B 统编 / 载具)
@@ -114,6 +116,21 @@ public class UserOrderController extends BaseController {
         return getDataTable(orderInvoiceService.listLoveOrg(keyword));
     }
 
+    /**
+     * 会员发票夹(010 Phase 11):我的发票列表,按 userId 查已开发票(不含码图)。
+     */
+    @GetMapping("/myInvoices")
+    @Auth
+    @Anonymous
+    public TableDataInfo myInvoices(@RequestHeader String token,
+                                    @RequestParam(defaultValue = "1") int pageNum,
+                                    @RequestParam(defaultValue = "10") int pageSize) {
+        Long userId = Long.valueOf(new JwtUtil().getusid(token));
+        PageHelper.startPage(pageNum, pageSize);
+        List<com.ruoyi.system.domain.vo.PosOrderInvoiceVo> list = orderInvoiceService.listMyInvoices(userId);
+        return getDataTable(list);
+    }
+
     /**
      * 创建订单
      */
@@ -133,8 +150,25 @@ public class UserOrderController extends BaseController {
             }
             shdz= JSON.toJSONString(usadd);
         }
+        // === 发票意图(010 Phase 11)===
+        // 校验:载具/捐赠/统编验真前置,不通过拦截下单(联网校验用第一个可开票门店的 ezPay 凭证)
+        if (!StringUtils.isEmpty(input.getInvoiceChoice()) && !"PAPER".equals(input.getInvoiceChoice())) {
+            Long invoiceStoreId = input.getItems() == null ? null : input.getItems().stream()
+                    .map(OrderCreatItem::getMdId)
+                    .filter(md -> orderInvoiceService.canInvoice(md))
+                    .findFirst().orElse(null);
+            if (invoiceStoreId == null) {
+                throw new ServiceException("所选门店暂不支持电子发票");
+            }
+            orderInvoiceService.validateCheckoutInvoiceIntent(input.getInvoiceChoice(), input.getCarrierNum(),
+                    input.getLoveCode(), input.getBuyerUbn(), input.getBuyerEmail(), invoiceStoreId);
+        }
         createOrderParent(input,Long.valueOf(id),shdz);
         createOrderChild(input,Long.valueOf(id),Long.valueOf(id),shdz);
+        // saveAsDefault:把载具/统编存为默认抬头(同一用户仅一条 is_default=1)
+        if (Boolean.TRUE.equals(input.getSaveAsDefault()) && !StringUtils.isEmpty(input.getInvoiceChoice())) {
+            saveDefaultInvoiceProfile(input, Long.valueOf(id));
+        }
 
         OrderParent result = orderParentService.getOne(new LambdaQueryWrapper<OrderParent>().eq(OrderParent::getDdId, input.getDdId().toString()));
         System.out.println("创建返回信息:"+result);
@@ -260,6 +294,31 @@ public class UserOrderController extends BaseController {
             createFootprint(userId, item.getMdId());
             setPickUpNum(posOrder);
             posOrderService.save(posOrder);
+            // 发票意图落 pos_order_invoice 意图行(010 Phase 11,方案B):仅开发票门店插,
+            // issueTriggered=0 未触发;出餐 autoIssue 在该行落结果并置 issueTriggered=1
+            if (!StringUtils.isEmpty(input.getInvoiceChoice()) && orderInvoiceService.canInvoice(item.getMdId())) {
+                String choice = input.getInvoiceChoice();
+                PosOrderInvoice intent = new PosOrderInvoice();
+                intent.setOrderId(posOrder.getId());
+                intent.setOrderNo(subddId);
+                intent.setStoreId(item.getMdId());
+                intent.setInvoiceChoice(choice);
+                intent.setCarrierNum(input.getCarrierNum());
+                intent.setBuyerUbn(input.getBuyerUbn());
+                intent.setBuyerName(input.getBuyerName());
+                intent.setBuyerEmail(input.getBuyerEmail());
+                intent.setLoveCode(input.getLoveCode());
+                if ("PHONE_BARCODE".equals(choice)) {
+                    intent.setCarrierType("0");
+                } else if ("CITIZEN".equals(choice)) {
+                    intent.setCarrierType("1");
+                } else if ("MEMBER".equals(choice)) {
+                    intent.setCarrierType("2");
+                } else {
+                    intent.setCarrierType(input.getCarrierType());
+                }
+                orderInvoiceService.captureInvoiceIntent(intent);
+            }
 
             // --- 促销:写入快照 + 标记券已使用(save之后,需要posOrder.id) ---
             if (calc != null) {
@@ -294,6 +353,48 @@ public class UserOrderController extends BaseController {
         }
     }
 
+    //保存默认发票抬头(010 Phase 11:结账勾「同意设置为默认」;同一用户仅一条 is_default=1)
+    private void saveDefaultInvoiceProfile(OrderCreateInput input, Long userId) {
+        String choice = input.getInvoiceChoice();
+        if ("PAPER".equals(choice) || "LOVE_CODE".equals(choice) || "MEMBER".equals(choice)) {
+            return; // 纸本/捐赠/会员载具不存抬头(会员载具=会员手机号,无需记忆)
+        }
+        InfoInvoice profile = new InfoInvoice();
+        profile.setUserId(userId);
+        profile.setIsDefault(1);
+        switch (choice) {
+            case "PHONE_BARCODE":
+                profile.setCategory("B2C");
+                profile.setTitleName("手机条码载具");
+                profile.setBuyerName("手机条码载具");
+                profile.setCarrierType("0");
+                profile.setCarrierNum(input.getCarrierNum());
+                break;
+            case "CITIZEN":
+                profile.setCategory("B2C");
+                profile.setTitleName("自然人凭证载具");
+                profile.setBuyerName("自然人凭证载具");
+                profile.setCarrierType("1");
+                profile.setCarrierNum(input.getCarrierNum());
+                break;
+            case "COMPANY":
+                profile.setCategory("B2B");
+                profile.setTitleName(input.getBuyerName());
+                profile.setBuyerName(input.getBuyerName());
+                profile.setBuyerUbn(input.getBuyerUbn());
+                break;
+            default:
+                return;
+        }
+        // 先清掉该用户其它默认(同一用户仅一条为 1),再存新的
+        InfoInvoice reset = new InfoInvoice();
+        reset.setIsDefault(0);
+        infoInvoiceService.update(reset, new LambdaQueryWrapper<InfoInvoice>()
+                .eq(InfoInvoice::getUserId, userId)
+                .eq(InfoInvoice::getIsDefault, 1));
+        infoInvoiceService.save(profile);
+    }
+
     //生成取餐码,根据门店id,当天取餐码从1开始累加
     private void setPickUpNum(PosOrder order) {
         if (order == null || order.getMdId() == null) {
@@ -444,7 +545,8 @@ public class UserOrderController extends BaseController {
 
         switch (tab) {
             case "unpaid":
-                wrapper.eq(PosOrder::getPayStatus, 0L).eq(PosOrder::getType, 0L);
+                // 排除已取消(state=4)和售后中(afterSaleStatus>0)的订单
+                wrapper.eq(PosOrder::getPayStatus, 0L).eq(PosOrder::getType, 0L).ne(PosOrder::getState, 4L).eq(PosOrder::getAfterSaleStatus, 0L);
                 break;
             case "active":
                 wrapper.in(PosOrder::getState, 0L, 1L, 2L)

+ 18 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/dto/OrderCreateInput.java

@@ -47,5 +47,23 @@ public class OrderCreateInput {
     /** 自取预留电话 */
     private String reservePhone;
 
+    /** === 发票意图(下单时选,出餐自动开票;010 Phase 11)=== */
+    /** 发票选择 PAPER个人纸本(默认)/PHONE_BARCODE手机条码/CITIZEN自然人凭证/MEMBER会员载具/LOVE_CODE捐赠/COMPANY公司 */
+    private String invoiceChoice;
+    /** 载具类型 0手机条码/1自然人凭证(PHONE_BARCODE/CITIZEN 时填) */
+    private String carrierType;
+    /** 载具号码(PHONE_BARCODE/CITIZEN 时填) */
+    private String carrierNum;
+    /** 公司统编 8位(COMPANY 时填) */
+    private String buyerUbn;
+    /** 公司名称(COMPANY 时填,作为发票买方名) */
+    private String buyerName;
+    /** 买方邮箱(COMPANY 公司发票必填,ezPay 发开立通知给买方) */
+    private String buyerEmail;
+    /** 捐赠码 3-7位(LOVE_CODE 时填) */
+    private String loveCode;
+    /** 是否将该载具/统编存为默认抬头(true 写 info_invoice 并标记默认) */
+    private Boolean saveAsDefault;
+
     private List<OrderCreatItem> items;
 }

+ 6 - 6
ruoyi-admin/src/main/java/com/ruoyi/app/pay/UserBillingController.java

@@ -358,18 +358,18 @@ public class UserBillingController extends BaseController {
     private void setShList(List<UserBilling> list, JSONObject org, InfoUser user) {
         List<ShBillingDto> result = new ArrayList<>();
         if (!list.isEmpty()) {
-            List<Long> ddIds = list.stream().map(x -> Long.valueOf(x.getDdId())).collect(Collectors.toList());
+            List<String> ddIds = list.stream().map(x -> x.getDdId()).collect(Collectors.toList());
             List<PosOrder> orders = posOrderService.list(new LambdaQueryWrapper<PosOrder>().in(PosOrder::getDdId, ddIds));
             List<PosOrderRating> ratings = posOrderRatingService.list(new LambdaQueryWrapper<PosOrderRating>().in(PosOrderRating::getDdId, ddIds));
             list.forEach(x -> {
                 ShBillingDto dto = Convert.convert(ShBillingDto.class, x);
                 //货到付款的订单金额
-                orders.stream().filter(o -> o.getDdId().equals(Long.valueOf(x.getDdId()))).findFirst().ifPresent(o -> {
+                orders.stream().filter(o -> o.getDdId().equals(x.getDdId())).findFirst().ifPresent(o -> {
                     dto.setFoodAmount(o.getFoodAmount());
                     int shYh = (o.getMdDiscountAmount() == null ? 0 : o.getMdDiscountAmount()) + (o.getMdSalesReduction() == null ? 0 : o.getMdSalesReduction());
                     dto.setShYh((double) shYh);
                 });
-                ratings.stream().filter(r -> r.getDdId().equals(Long.valueOf(x.getDdId()))).findFirst().ifPresent(r -> dto.setShStarts(r.getQsStars()));
+                ratings.stream().filter(r -> r.getDdId().equals(x.getDdId())).findFirst().ifPresent(r -> dto.setShStarts(r.getQsStars()));
                 result.add(dto);
             });
         }
@@ -383,20 +383,20 @@ public class UserBillingController extends BaseController {
     private void setQsList(List<UserBilling> list, JSONObject org) {
         List<QsBillingDto> result = new ArrayList<>();
         if (!list.isEmpty()) {
-            List<Long> ddIds = list.stream().map(x -> Long.valueOf(x.getDdId())).collect(Collectors.toList());
+            List<String> ddIds = list.stream().map(x -> x.getDdId()).collect(Collectors.toList());
             List<PosOrder> orders = posOrderService.list(new LambdaQueryWrapper<PosOrder>().in(PosOrder::getDdId, ddIds));
             List<PosOrderRating> ratings = posOrderRatingService.list(new LambdaQueryWrapper<PosOrderRating>().in(PosOrderRating::getDdId, ddIds));
             list.forEach(x -> {
                 QsBillingDto dto = Convert.convert(QsBillingDto.class, x);
                 //货到付款的订单金额
-                orders.stream().filter(o -> o.getDdId().equals(Long.valueOf(x.getDdId()))).findFirst().ifPresent(o -> {
+                orders.stream().filter(o -> o.getDdId().equals(x.getDdId())).findFirst().ifPresent(o -> {
                     if ("1".equals(o.getCollectPayment())) {
                         dto.setOrderAmount(o.getAmount());
                     }
                     dto.setFreight(o.getFreight());
                     dto.setJvli(o.getJvli());
                 });
-                ratings.stream().filter(r -> r.getDdId().equals(Long.valueOf(x.getDdId()))).findFirst().ifPresent(r -> dto.setQsStarts(r.getQsStars()));
+                ratings.stream().filter(r -> r.getDdId().equals(x.getDdId())).findFirst().ifPresent(r -> dto.setQsStarts(r.getQsStars()));
                 result.add(dto);
             });
         }

+ 1 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/pay/WalletController.java

@@ -85,7 +85,7 @@ public class WalletController extends BaseController {
         IPage<UserBilling> result = userBillingService.page(pageInput, queryWrapper);
 
         if (!result.getRecords().isEmpty()) {
-            List<Long> orderDdIds = result.getRecords().stream().filter(x -> x.getType().equals("0")).map(x -> Long.parseLong(x.getDdId())).collect(Collectors.toList());
+            List<String> orderDdIds = result.getRecords().stream().filter(x -> x.getType().equals("0")).map(x -> x.getDdId()).collect(Collectors.toList());
             List<String> withDrawalDdIds = result.getRecords().stream().filter(x -> x.getType().equals("2")).map(UserBilling::getDdId).collect(Collectors.toList());
             List<String> rechargeDdIds = result.getRecords().stream().filter(x -> x.getType().equals("1")).map(UserBilling::getDdId).collect(Collectors.toList());
 

+ 173 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/user/InfoUserController.java

@@ -36,9 +36,15 @@ import com.ruoyi.system.service.IVipUserService;
 import com.ruoyi.system.utils.Auth;
 import com.ruoyi.system.utils.JwtUtil;
 import com.ruoyi.system.utils.MobileSMS;
+import com.ruoyi.app.user.dto.OAuthBindDto;
+import com.ruoyi.app.user.dto.OAuthLoginDto;
+import com.ruoyi.app.utils.oauth.OAuthVerifyService;
+import com.ruoyi.system.mapper.InfoUserOauthMapper;
 
 import eu.bitwalker.useragentutils.UserAgent;
 import org.apache.commons.lang3.RandomStringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.transaction.annotation.Transactional;
@@ -50,6 +56,10 @@ import java.math.BigDecimal;
 import java.time.LocalDate;
 import java.time.format.DateTimeFormatter;
 import java.util.ArrayList;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.UUID;
 import java.util.Arrays;
 import java.util.List;
 import java.util.Optional;
@@ -65,6 +75,8 @@ import java.util.stream.Collectors;
 @RestController
 @RequestMapping("/infouser/user")
 public class InfoUserController extends BaseController {
+    private static final Logger log = LoggerFactory.getLogger(InfoUserController.class);
+
     @Autowired
     private IInfoUserService infoUserService;
     @Autowired
@@ -83,6 +95,12 @@ public class InfoUserController extends BaseController {
     private RedisCache redisCache;
     @Autowired
     private IPosOrderService posOrderService;
+    @Autowired
+    private InfoUserOauthMapper infoUserOauthMapper;
+    @Autowired
+    private OAuthVerifyService oauthVerifyService;
+    /** 三方登录首登临时凭证 Redis 前缀 */
+    private static final String OAUTH_TEMP_PREFIX = "oauth:bind:";
 
 
     @Anonymous
@@ -688,6 +706,7 @@ public class InfoUserController extends BaseController {
         user.setDrivingLicense(infoUser.getDrivingLicense());
         user.setVehiclePhotos(infoUser.getVehiclePhotos());
         user.setLicensePlate(infoUser.getLicensePlate());
+        user.setVehicleType(infoUser.getVehicleType());
         user.setFullName(infoUser.getFullName());
         user.setIdNumber(infoUser.getIdNumber());
         user.setMerchantType(infoUser.getMerchantType());
@@ -885,6 +904,160 @@ public class InfoUserController extends BaseController {
         return success(MessageUtils.message("no.user.login.success"), user, token);
     }
 
+    // ==================== 三方登录(017-oauth-login)====================
+
+    /**
+     * 三方登录第一步:校验 provider 凭证 → 已绑定直接登录;未绑定返回 needPhone + tempKey。
+     */
+    @Anonymous
+    @PostMapping("/oauthLogin")
+    public AjaxResult oauthLogin(@RequestBody OAuthLoginDto dto) {
+        if (dto == null || dto.getProvider() == null || dto.getProvider().isEmpty()) {
+            return error(MessageUtils.message("no.oauth.provider.blank"));
+        }
+        log.info("[OAuth] oauthLogin provider={}", dto.getProvider());
+        String providerUid = oauthVerifyService.verify(dto.getProvider(), dto.getCredential());
+
+        // 查是否已绑定
+        InfoUserOauth bind = infoUserOauthMapper.selectOne(
+                new LambdaQueryWrapper<InfoUserOauth>()
+                        .eq(InfoUserOauth::getProvider, dto.getProvider())
+                        .eq(InfoUserOauth::getProviderUid, providerUid));
+        if (bind != null) {
+            // 已绑定:校验用户正常后直接登录
+            log.info("[OAuth] 已绑定 provider={}, providerUid={}, userId={}", dto.getProvider(), providerUid, bind.getUserId());
+            QueryWrapper<InfoUser> q = new QueryWrapper<>();
+            q.eq("user_id", bind.getUserId()).eq("status", 0).eq("del_flag", "0");
+            InfoUser u = infoUserService.getOne(q);
+            if (u == null) {
+                log.warn("[OAuth] 已绑定但账号已停用 userId={}", bind.getUserId());
+                return error(MessageUtils.message("no.user.stop"));
+            }
+            u.setCid(dto.getCid());
+            u.setCidType(dto.getCidType());
+            u.setDeviceToken(dto.getDeviceToken());
+            u.setVoIPToken(dto.getVoIPToken());
+            infoUserService.saveOrUpdate(u);
+            return issueOauthToken(u, dto.getProvider());
+        }
+
+        // 未绑定:缓存 {provider, providerUid},返回 needPhone + tempKey
+        log.info("[OAuth] 未绑定,返回 needPhone provider={}, providerUid={}", dto.getProvider(), providerUid);
+        String tempKey = UUID.randomUUID().toString().replace("-", "");
+        redisCache.setCacheObject(OAUTH_TEMP_PREFIX + tempKey,
+                dto.getProvider() + "@" + providerUid, 5, TimeUnit.MINUTES);
+        Map<String, Object> data = new HashMap<>();
+        data.put("status", "needPhone");
+        data.put("tempKey", tempKey);
+        return AjaxResult.success(MessageUtils.message("no.oauth.needphone"), data);
+    }
+
+    /**
+     * 三方登录第二步:手机号 + 短信验证 → 已注册关联 / 未注册新建 → 写绑定 → 签 token。
+     */
+    @Anonymous
+    @PostMapping("/oauthBindPhone")
+    public AjaxResult oauthBindPhone(@RequestBody OAuthBindDto dto) {
+        if (dto == null || dto.getTempKey() == null || dto.getTempKey().isEmpty()) {
+            log.warn("[OAuth] oauthBindPhone 缺 tempKey");
+            return error(MessageUtils.message("no.oauth.tempkey.missing"));
+        }
+        String cached = redisCache.getCacheObject(OAUTH_TEMP_PREFIX + dto.getTempKey());
+        if (cached == null) {
+            log.warn("[OAuth] tempKey 已过期/不存在 tempKey={}", dto.getTempKey());
+            return error(MessageUtils.message("no.oauth.tempkey.expired"));
+        }
+        int at = cached.indexOf('@');
+        String provider = cached.substring(0, at);
+        String providerUid = cached.substring(at + 1);
+        log.info("[OAuth] 绑定流程 provider={}, providerUid={}", provider, providerUid);
+
+        // 容错:若期间已被绑定,直接登录
+        InfoUserOauth exist = infoUserOauthMapper.selectOne(
+                new LambdaQueryWrapper<InfoUserOauth>()
+                        .eq(InfoUserOauth::getProvider, provider)
+                        .eq(InfoUserOauth::getProviderUid, providerUid));
+        if (exist != null) {
+            log.info("[OAuth] 绑定期间已被绑定,直接登录 userId={}", exist.getUserId());
+            InfoUser u = infoUserService.getById(exist.getUserId());
+            return issueOauthToken(u, provider);
+        }
+
+        // 验短信(复用 lodeing 逻辑:redis code 或万能码 8888)
+        String phone = dto.getPhone();
+        String xcode = redisCache.getCacheObject(phone.trim().replaceAll("\\+", ""));
+        boolean codeOk = (xcode != null && xcode.equals(dto.getCode())) || "8888".equals(dto.getCode());
+        if (!codeOk) {
+            log.warn("[OAuth] 短信验证码错误 phone={}", maskPhone(phone));
+            return error(MessageUtils.message("no.user.jcaptcha.error"));
+        }
+
+        // 查/建用户(手机号始终为主键)
+        InfoUser user = infoUserService.getuser(phone);
+        if (user != null && !"0".equals(user.getStatus())) {
+            // 停用账号不允许绑定三方(与 lodeing 一致,防止绕过停用)
+            log.warn("[OAuth] 账号已停用 phone={}", maskPhone(phone));
+            return error(MessageUtils.message("no.user.stop"));
+        }
+        if (user == null) {
+            // 新建:昵称=手机号(与 createUser 一致)
+            InfoUser info = new InfoUser();
+            info.setPhone(phone);
+            info.setNickName(phone);
+            info.setCid(dto.getCid());
+            info.setCidType(dto.getCidType());
+            info.setDeviceToken(dto.getDeviceToken());
+            info.setVoIPToken(dto.getVoIPToken());
+            info.setUserType("0");
+            info.setMycode(new UUIDUtil().get8UUID());
+            infoUserService.saveOrUpdate(info);
+            user = infoUserService.getuser(phone);
+            createUserWallet(user.getUserId());
+            log.info("[OAuth] 新建账号 userId={}", user.getUserId());
+        } else {
+            user.setCid(dto.getCid());
+            user.setCidType(dto.getCidType());
+            user.setDeviceToken(dto.getDeviceToken());
+            user.setVoIPToken(dto.getVoIPToken());
+            infoUserService.saveOrUpdate(user);
+            log.info("[OAuth] 关联已有账号 userId={}", user.getUserId());
+        }
+
+        // 写三方绑定
+        InfoUserOauth bind = new InfoUserOauth();
+        bind.setUserId(user.getUserId());
+        bind.setProvider(provider);
+        bind.setProviderUid(providerUid);
+        bind.setCreateTime(new Date());
+        infoUserOauthMapper.insert(bind);
+        log.info("[OAuth] 写入绑定 provider={}, providerUid={}, userId={}", provider, providerUid, user.getUserId());
+
+        redisCache.deleteObject(OAUTH_TEMP_PREFIX + dto.getTempKey());
+        return issueOauthToken(user, provider);
+    }
+
+    /** 手机号脱敏:保留前 3 后 4,中间 ****(仅日志用)。 */
+    private static String maskPhone(String phone) {
+        if (phone == null || phone.length() < 8) {
+            return phone;
+        }
+        return phone.substring(0, 3) + "****" + phone.substring(phone.length() - 4);
+    }
+
+    /** 三方登录签发 token(claim provider=本次登录渠道),复用现有 JwtUtil + Redis 会话。 */
+    private AjaxResult issueOauthToken(InfoUser user, String provider) {
+        if (user == null) {
+            return error(MessageUtils.message("no.user.not.exist"));
+        }
+        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);
+        String token = JwtUtil.setToken(CacheConstants.USER_TOKEN_KEY, dto);
+        return success(MessageUtils.message("no.user.login.success"), user, token);
+    }
 
     /**
      * 查询用户信息列表

+ 28 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/user/dto/OAuthBindDto.java

@@ -0,0 +1,28 @@
+package com.ruoyi.app.user.dto;
+
+import lombok.Data;
+
+/**
+ * 三方登录第二步入参:手机号验证 + 绑定(017-oauth-login)。
+ *
+ * @author foodie
+ * @date 2026-07-30
+ */
+@Data
+public class OAuthBindDto {
+
+    /** oauthLogin 返回的临时凭证 */
+    private String tempKey;
+
+    /** 手机号 */
+    private String phone;
+
+    /** 短信验证码 */
+    private String code;
+
+    /** 推送字段(与现有登录一致) */
+    private String cid;
+    private String cidType;
+    private String deviceToken;
+    private String voIPToken;
+}

+ 25 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/user/dto/OAuthLoginDto.java

@@ -0,0 +1,25 @@
+package com.ruoyi.app.user.dto;
+
+import lombok.Data;
+
+/**
+ * 三方登录第一步入参(017-oauth-login)。
+ *
+ * @author foodie
+ * @date 2026-07-30
+ */
+@Data
+public class OAuthLoginDto {
+
+    /** 三方渠道 apple/google/line */
+    private String provider;
+
+    /** 凭证:Apple identityToken / Google idToken / LINE authorization code */
+    private String credential;
+
+    /** 推送字段(与现有登录一致) */
+    private String cid;
+    private String cidType;
+    private String deviceToken;
+    private String voIPToken;
+}

+ 97 - 6
ruoyi-admin/src/main/java/com/ruoyi/app/utils/ezPay/EzPay.java

@@ -11,6 +11,8 @@ 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.net.URLEncoder;
@@ -55,6 +57,8 @@ import java.util.Map;
 @Component
 public class EzPay {
 
+    private static final Logger log = LoggerFactory.getLogger(EzPay.class);
+
     /** 正式环境根地址。 */
     public static final String BASE_PROD = "https://inv.ezpay.com.tw";
     /** 测试环境根地址。 */
@@ -125,15 +129,14 @@ public class EzPay {
      * @return true=捐赠码存在于财政部电子发票整合服务平台
      */
     public boolean checkLoveCode(String baseUrl, EzPayConfig config, String loveCode) throws Exception {
+        // PostData_ 内含字段:仅 TimeStamp + LoveCode(Version/RespondType 是顶层表单字段,见 BDV 手册五-(一))
         Map<String, Object> postData = new LinkedHashMap<>();
-        postData.put("RespondType", "JSON");
-        postData.put("Version", "1.0");
         postData.put("TimeStamp", String.valueOf(System.currentTimeMillis() / 1000L));
         postData.put("LoveCode", loveCode);
         String query = buildQuery(postData);
         String postDataHex = EzPayEncryptUtil.encrypt(query, config.getHashKey(), config.getHashIV());
         String checkValue = EzPayEncryptUtil.genCheckValue(postDataHex, config.getHashKey(), config.getHashIV());
-        String resp = postFormWithCheck(baseUrl + URL_CHECK_LOVECODE, config.getMerchantId(), postDataHex, checkValue);
+        String resp = postFormWithCheck(baseUrl + URL_CHECK_LOVECODE, config.getMerchantId(), "1.0", "JSON", postDataHex, checkValue);
         JSONObject json = JSON.parseObject(resp);
         if (json == null || !"SUCCESS".equals(json.getString("Status"))) {
             return false;
@@ -147,6 +150,85 @@ public class EzPay {
         return decrypted != null && decrypted.matches(".*IsExist=Y(\\b|$).*");
     }
 
+    /**
+     * 手机条码验证。对应 POST /Api_inv_application/checkBarCode(BDV v1.0)。
+     *
+     * <p>校验手机条码是否真实注册于财政部电子发票整合服务平台(通用载具),用于结账时拦截无效条码、
+     * 避免发票存进不存在的载具(客户填错条码 → 发票丢失)。与 {@link #checkLoveCode} 同为 BDV 接口,
+     * 请求需带 CheckValue,回应 Result 为加密 hex,解密后形如 {@code CellphoneBarcode=%2FABC1234&IsExist=Y}。
+     *
+     * <p><b>文档勘误</b>:CheckValue 按手册「HashKey 在前」实现({@link EzPayEncryptUtil#genCheckValue}),
+     * 若测试环境 ezPay 回 CheckValue 校验失败,改为「HashIV 在前」重试(见加密工具 javadoc)。
+     *
+     * @param barCode 手机条码(/ + 7 位大写英文/数字/+-.)
+     * @return true=该手机条码存在于财政部平台
+     */
+    public boolean checkBarCode(String baseUrl, EzPayConfig config, String barCode) throws Exception {
+        // PostData_ 内含字段:仅 TimeStamp + CellphoneBarcode(Version/RespondType 是顶层表单字段,见 BDV 手册四-(一))
+        Map<String, Object> postData = new LinkedHashMap<>();
+        postData.put("TimeStamp", String.valueOf(System.currentTimeMillis() / 1000L));
+        postData.put("CellphoneBarcode", barCode);
+        String query = buildQuery(postData);
+        String postDataHex = EzPayEncryptUtil.encrypt(query, config.getHashKey(), config.getHashIV());
+        String checkValue = EzPayEncryptUtil.genCheckValue(postDataHex, config.getHashKey(), config.getHashIV());
+        log.info("[EzPay] checkBarCode 全量参数 >>> MerchantID={}, Version=1.0, RespondType=JSON, "
+                + "PostData明文={}, PostData密文={}, CheckValue={}, HashKey={}, HashIV={}",
+                config.getMerchantId(), postData, postDataHex, checkValue, config.getHashKey(), config.getHashIV());
+        String resp = postFormWithCheck(baseUrl + URL_CHECK_BARCODE, config.getMerchantId(), "1.0", "JSON", postDataHex, checkValue);
+
+        JSONObject json = JSON.parseObject(resp);
+        if (json == null || !"SUCCESS".equals(json.getString("Status"))) {
+            return false;
+        }
+        String resultHex = json.getString("Result");
+        if (resultHex == null || resultHex.isEmpty()) {
+            return false;
+        }
+        String decrypted = EzPayEncryptUtil.decrypt(resultHex, config.getHashKey(), config.getHashIV());
+        boolean exist = decrypted != null && decrypted.contains("IsExist=Y");
+        // decrypted 形如 CellphoneBarcode=%2FABC1234&IsExist=Y
+        log.info("[EzPay] checkBarCode decrypted={}, isExist={}", decrypted, exist);
+        return exist;
+    }
+
+    /**
+     * 手机条码验证(详细版,调试用):返回完整请求/回应/解密/真实 IsExist,供 Swagger 排查。
+     * 与 {@link #checkBarCode} 不同:本方法返回 ezPay 真实 IsExist(Y/N),不做任何兜底。
+     */
+    public JSONObject checkBarCodeDetail(String baseUrl, EzPayConfig config, String barCode) throws Exception {
+        Map<String, Object> postData = new LinkedHashMap<>();
+        postData.put("TimeStamp", String.valueOf(System.currentTimeMillis() / 1000L));
+        postData.put("CellphoneBarcode", barCode);
+        String query = buildQuery(postData);
+        String postDataHex = EzPayEncryptUtil.encrypt(query, config.getHashKey(), config.getHashIV());
+        String checkValue = EzPayEncryptUtil.genCheckValue(postDataHex, config.getHashKey(), config.getHashIV());
+        log.info("[EzPay] checkBarCodeDetail 全量参数 >>> MerchantID={}, Version=1.0, RespondType=JSON, "
+                + "PostData明文={}, PostData密文={}, CheckValue={}, HashKey={}, HashIV={}",
+                config.getMerchantId(), postData, postDataHex, checkValue, config.getHashKey(), config.getHashIV());
+        String resp = postFormWithCheck(baseUrl + URL_CHECK_BARCODE, config.getMerchantId(), "1.0", "JSON", postDataHex, checkValue);
+        JSONObject result = new JSONObject();
+        result.put("barCode", barCode);
+        result.put("merchantId", config.getMerchantId());
+        result.put("url", baseUrl + URL_CHECK_BARCODE);
+        result.put("rawResponse", resp);
+        JSONObject json = JSON.parseObject(resp);
+        String status = json == null ? "" : json.getString("Status");
+        String decrypted = null;
+        boolean exist = false;
+        if ("SUCCESS".equals(status)) {
+            String resultHex = json.getString("Result");
+            if (resultHex != null && !resultHex.isEmpty()) {
+                decrypted = EzPayEncryptUtil.decrypt(resultHex, config.getHashKey(), config.getHashIV());
+                exist = decrypted != null && decrypted.contains("IsExist=Y");
+            }
+        }
+        result.put("status", status);
+        result.put("decrypted", decrypted);
+        result.put("isExist", exist);
+        log.info("[EzPay] checkBarCodeDetail <<< decrypted={}, isExist={}", decrypted, exist);
+        return result;
+    }
+
     /**
      * 底层调用:补充 TimeStamp → http_build_query → PostData_ 加密 → Form Post → JSON。
      *
@@ -159,6 +241,7 @@ public class EzPay {
      */
     public JSONObject doPost(String fullUrl, EzPayConfig config, Map<String, Object> postData) throws Exception {
         postData.put("TimeStamp", String.valueOf(System.currentTimeMillis() / 1000L));
+        log.info("[EzPay] doPost 明文 >>> url={}, 明文参数={}", fullUrl, postData);
         String query = buildQuery(postData);
         String postDataEncrypted = EzPayEncryptUtil.encrypt(query, config.getHashKey(), config.getHashIV());
         String resp = postForm(fullUrl, config.getMerchantId(), postDataEncrypted);
@@ -194,9 +277,12 @@ public class EzPay {
         params.add(new BasicNameValuePair("PostData_", postData));
         HttpPost post = new HttpPost(url);
         post.setEntity(new UrlEncodedFormEntity(params, StandardCharsets.UTF_8));
+        log.info("[EzPay] postForm >>> url={}, body={}", url, EntityUtils.toString(post.getEntity(), StandardCharsets.UTF_8));
         try (CloseableHttpClient client = HttpClients.createDefault();
              CloseableHttpResponse res = client.execute(post)) {
-            return EntityUtils.toString(res.getEntity(), StandardCharsets.UTF_8);
+            String resp = EntityUtils.toString(res.getEntity(), StandardCharsets.UTF_8);
+            log.info("[EzPay] postForm <<< resp={}", resp);
+            return resp;
         }
     }
 
@@ -205,16 +291,21 @@ public class EzPay {
      *
      * <p>仅 BDV 验证类接口(checkBarCode / checkLoveCode)请求需要带 CheckValue。
      */
-    private static String postFormWithCheck(String url, String merchantId, String postData, String checkValue) throws Exception {
+    private static String postFormWithCheck(String url, String merchantId, String version, String respondType, String postData, String checkValue) throws Exception {
         List<NameValuePair> params = new ArrayList<>();
         params.add(new BasicNameValuePair("MerchantID_", merchantId));
+        params.add(new BasicNameValuePair("Version", version));
+        params.add(new BasicNameValuePair("RespondType", respondType));
         params.add(new BasicNameValuePair("PostData_", postData));
         params.add(new BasicNameValuePair("CheckValue", checkValue));
         HttpPost post = new HttpPost(url);
         post.setEntity(new UrlEncodedFormEntity(params, StandardCharsets.UTF_8));
+        log.info("[EzPay] postFormWithCheck >>> url={}, body={}", url, EntityUtils.toString(post.getEntity(), StandardCharsets.UTF_8));
         try (CloseableHttpClient client = HttpClients.createDefault();
              CloseableHttpResponse res = client.execute(post)) {
-            return EntityUtils.toString(res.getEntity(), StandardCharsets.UTF_8);
+            String resp = EntityUtils.toString(res.getEntity(), StandardCharsets.UTF_8);
+            log.info("[EzPay] postFormWithCheck <<< resp={}", resp);
+            return resp;
         }
     }
 }

+ 17 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/utils/ezPayCrypto/EzPayEncryptUtil.java

@@ -214,6 +214,23 @@ public class EzPayEncryptUtil {
             System.out.println("[FAIL] 满块补整块 dec=" + dec2); fail++;
         }
 
+        // 5) BDV 附件一完整测试向量(PAGE 18-19):加密/CheckValue/Result解密 三项全比对官方值
+        {
+            String bk = "rsBjnIvMG3VMMWsEynTK3PWiGccHuYiV";
+            String bv = "SdsatSdXm1vH7N3T";
+            String bPlain = "TimeStamp=1589261733&CellphoneBarcode=%2FAAA.CCC";
+            String expPost = "ed2d2219af6cc92b9504be630658c70bd5eea80358a675cfa2e2f110002b92af244814d24a937c339e7ce938b4a32653daf85ba9a871b8003b0a5aed4db5e9bf";
+            String gotPost = encrypt(bPlain, bk, bv);
+            if (expPost.equals(gotPost)) { System.out.println("[PASS] BDV PostData_ 加密"); pass++; } else { System.out.println("[FAIL] BDV PostData_ 加密 got=" + gotPost); fail++; }
+            String expCV = "5D7A25D069D176C187A5161B77064DB1E49C4A7BEBC56D2A1CEBF743D019820B";
+            String gotCV = genCheckValue(expPost, bk, bv);
+            if (expCV.equals(gotCV)) { System.out.println("[PASS] BDV CheckValue(请求)"); pass++; } else { System.out.println("[FAIL] BDV CheckValue(请求) got=" + gotCV); fail++; }
+            String bResult = "0628256c389824ce257c6e8796707aa3bc803d2245f4894261dbae64ff9a51f34f519b79c3fd8892157a8703779ab7639c62c3d078e7c8a09006a27f4f8aefbb";
+            String expDec = "CellphoneBarcode=%2FAAA.CCC&IsExist=N";
+            String gotDec = decrypt(bResult, bk, bv);
+            if (expDec.equals(gotDec)) { System.out.println("[PASS] BDV Result 解密 -> " + gotDec); pass++; } else { System.out.println("[FAIL] BDV Result 解密 got=" + gotDec); fail++; }
+        }
+
         System.out.println("\n结果: " + pass + " passed, " + fail + " failed");
     }
 }

+ 295 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/utils/oauth/OAuthVerifyService.java

@@ -0,0 +1,295 @@
+package com.ruoyi.app.utils.oauth;
+
+import com.alibaba.fastjson2.JSONObject;
+import com.nimbusds.jose.JWSVerifier;
+import com.nimbusds.jose.crypto.ECDSAVerifier;
+import com.nimbusds.jose.crypto.RSASSAVerifier;
+import com.nimbusds.jose.jwk.ECKey;
+import com.nimbusds.jose.jwk.JWK;
+import com.nimbusds.jose.jwk.JWKSet;
+import com.nimbusds.jose.jwk.RSAKey;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.SignedJWT;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import org.apache.http.client.config.RequestConfig;
+import org.apache.http.client.methods.CloseableHttpResponse;
+import org.apache.http.client.methods.HttpGet;
+import org.apache.http.client.methods.HttpPost;
+import org.apache.http.client.entity.UrlEncodedFormEntity;
+import org.apache.http.message.BasicNameValuePair;
+import org.apache.http.NameValuePair;
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.apache.http.impl.client.HttpClients;
+import org.apache.http.util.EntityUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+
+import java.net.URL;
+import java.net.URLEncoder;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+
+/**
+ * 三方登录凭证校验(017-oauth-login)。
+ * <p>Apple/Google/LINE 各自校验前端传来的凭证,返回稳定的 providerUid(用于绑定反查)。</p>
+ * <ul>
+ *   <li>Apple:nimbus 验 ES256 identityToken + 校 iss/aud/exp → sub(忽略 email)</li>
+ *   <li>Google:tokeninfo HTTP 验真 + 校 audience → sub</li>
+ *   <li>LINE:code 换 access_token → v2/profile → userId</li>
+ * </ul>
+ * <p>注:Google 走 tokeninfo 而非 firebase-admin,避免 FirebaseApp+服务账号初始化;各家真实 token + clientId 需联调验证。</p>
+ *
+ * @author foodie
+ * @date 2026-07-30
+ */
+@Service
+public class OAuthVerifyService {
+
+    private static final Logger log = LoggerFactory.getLogger(OAuthVerifyService.class);
+
+    @Value("${oauth.apple.client-id}")
+    private String appleClientId;
+    @Value("${oauth.apple.jwks-url}")
+    private String appleJwksUrl;
+    @Value("${oauth.google.client-id}")
+    private String googleClientId;
+    @Value("${oauth.google.tokeninfo-url}")
+    private String googleTokeninfoUrl;
+    @Value("${oauth.line.profile-url}")
+    private String lineProfileUrl;
+    @Value("${oauth.line.token-url}")
+    private String lineTokenUrl;
+    @Value("${oauth.line.client-id}")
+    private String lineClientId;
+    @Value("${oauth.line.client-secret}")
+    private String lineClientSecret;
+    @Value("${oauth.line.redirect-uri}")
+    private String lineRedirectUri;
+
+    /**
+     * 校验 provider 凭证,返回稳定的 providerUid。
+     *
+     * @param provider   apple/google/line
+     * @param credential Apple identityToken / Google idToken / LINE authorization code
+     */
+    public String verify(String provider, String credential) {
+        if (provider == null || provider.isEmpty()) {
+            throw new ServiceException(MessageUtils.message("no.oauth.provider.blank"));
+        }
+        if (credential == null || credential.isEmpty()) {
+            throw new ServiceException(MessageUtils.message("no.oauth.credential.blank"));
+        }
+        log.info("[OAuth] 校验凭证 provider={}", provider);
+        log.debug("[OAuth] {} credential={}", provider, credential);
+        switch (provider) {
+            case "apple":
+                return verifyApple(credential);
+            case "google":
+                return verifyGoogle(credential);
+            case "line":
+                return verifyLine(credential);
+            default:
+                throw new ServiceException(MessageUtils.message("no.oauth.provider.unsupported", provider));
+        }
+    }
+
+    /** Apple:验 ES256 identityToken,返回 sub(不使用邮箱信息)。 */
+    private String verifyApple(String idToken) {
+        try {
+            log.debug("[OAuth][Apple] identityToken={}", idToken);
+            SignedJWT jwt = SignedJWT.parse(idToken);
+            String kid = jwt.getHeader().getKeyID();
+            // 拉取 Apple 公钥(JWKS)并按 kid 匹配
+            JWKSet jwkSet = JWKSet.load(new URL(appleJwksUrl));
+            JWK jwk = jwkSet.getKeyByKeyId(kid);
+            if (jwk == null) {
+                log.warn("[OAuth][Apple] 公钥未匹配 kid={}", kid);
+                throw new ServiceException(MessageUtils.message("no.oauth.token.invalid", "Apple"));
+            }
+            // Apple identityToken 实测 alg=RS256(RSA);按 JWK 类型选验签器,兼容 EC
+            JWSVerifier verifier;
+            if (jwk instanceof RSAKey) {
+                verifier = new RSASSAVerifier(((RSAKey) jwk).toRSAPublicKey());
+            } else if (jwk instanceof ECKey) {
+                verifier = new ECDSAVerifier(((ECKey) jwk).toECPublicKey());
+            } else {
+                log.warn("[OAuth][Apple] 不支持的公钥类型 kid={}", kid);
+                throw new ServiceException(MessageUtils.message("no.oauth.token.invalid", "Apple"));
+            }
+            if (!jwt.verify(verifier)) {
+                log.warn("[OAuth][Apple] 验签失败 kid={}", kid);
+                throw new ServiceException(MessageUtils.message("no.oauth.token.invalid", "Apple"));
+            }
+            JWTClaimsSet claims = jwt.getJWTClaimsSet();
+            if (!"https://appleid.apple.com".equals(claims.getIssuer())) {
+                log.warn("[OAuth][Apple] iss 非法: {}", claims.getIssuer());
+                throw new ServiceException(MessageUtils.message("no.oauth.token.invalid", "Apple"));
+            }
+            List<String> aud = claims.getAudience();
+            if (aud == null || !aud.contains(appleClientId)) {
+                log.warn("[OAuth][Apple] aud 不匹配: expected={}, got={}", appleClientId, aud);
+                throw new ServiceException(MessageUtils.message("no.oauth.audience.mismatch", "Apple"));
+            }
+            Date exp = claims.getExpirationTime();
+            if (exp == null || exp.before(new Date())) {
+                log.warn("[OAuth][Apple] token 已过期: exp={}", exp);
+                throw new ServiceException(MessageUtils.message("no.oauth.token.expired", "Apple"));
+            }
+            String sub = claims.getSubject();
+            if (sub == null || sub.isEmpty()) {
+                log.warn("[OAuth][Apple] sub 为空");
+                throw new ServiceException(MessageUtils.message("no.oauth.token.invalid", "Apple"));
+            }
+            log.info("[OAuth][Apple] 校验通过 sub={}", sub);
+            return sub;
+        } catch (ServiceException se) {
+            throw se;
+        } catch (Exception e) {
+            log.error("[OAuth][Apple] 校验异常", e);
+            throw new ServiceException(MessageUtils.message("no.oauth.verify.fail", "Apple", e.getMessage()));
+        }
+    }
+
+    /** Google:tokeninfo HTTP 验真 + 校 audience,返回 sub。 */
+    private String verifyGoogle(String idToken) {
+        try {
+            log.debug("[OAuth][Google] idToken={}", idToken);
+            String url = googleTokeninfoUrl + "?id_token=" + URLEncoder.encode(idToken, "UTF-8");
+            JSONObject json = JSONObject.parseObject(httpGet(url, null));
+            log.debug("[OAuth][Google] tokeninfo 返回: {}", json);
+            if (json == null || json.containsKey("error") || json.containsKey("error_description")) {
+                log.warn("[OAuth][Google] tokeninfo 返回错误: {}", json);
+                throw new ServiceException(MessageUtils.message("no.oauth.token.invalid", "Google"));
+            }
+            String aud = json.getString("aud");
+            if (!googleClientId.equals(aud)) {
+                log.warn("[OAuth][Google] aud 不匹配: expected={}, got={}", googleClientId, aud);
+                throw new ServiceException(MessageUtils.message("no.oauth.audience.mismatch", "Google"));
+            }
+            String sub = json.getString("sub");
+            if (sub == null || sub.isEmpty()) {
+                log.warn("[OAuth][Google] sub 为空");
+                throw new ServiceException(MessageUtils.message("no.oauth.token.invalid", "Google"));
+            }
+            log.info("[OAuth][Google] 校验通过 sub={}", sub);
+            return sub;
+        } catch (ServiceException se) {
+            throw se;
+        } catch (Exception e) {
+            log.error("[OAuth][Google] 校验异常", e);
+            throw new ServiceException(MessageUtils.message("no.oauth.verify.fail", "Google", e.getMessage()));
+        }
+    }
+
+    /**
+     * LINE:authorization code → 换 access_token → v2/profile 取 userId。
+     *
+     * <p>标准 LINE Login 流程:前端拿到授权 code 后传给后端,后端用 code + clientSecret + clientId
+     * 向 {@code https://api.line.me/oauth2/v2.1/token} 换 access_token(form-urlencoded),
+     * 再用 access_token 调 v2/profile 取稳定的 userId 作为 providerUid。
+     *
+     * @param code LINE 授权码(前端授权后获得,一次性)
+     * @return LINE userId
+     */
+    private String verifyLine(String code) {
+        try {
+            // code 一次性、短期有效;client_secret 永不记录日志
+            log.info("[OAuth][LINE] 换 token 请求: grant_type=authorization_code, client_id={}, redirect_uri={}, code={}...(len={})",
+                    lineClientId, lineRedirectUri, safePrefix(code), code == null ? 0 : code.length());
+            // 1. code 换 access_token(httpPostForm 内会打印 HTTP 状态码 + LINE 原始响应体,含 error/error_description)
+            JSONObject token = JSONObject.parseObject(httpPostForm(lineTokenUrl, new String[][]{
+                    {"grant_type", "authorization_code"},
+                    {"code", code},
+                    {"redirect_uri", lineRedirectUri},
+                    {"client_id", lineClientId},
+                    {"client_secret", lineClientSecret}
+            }));
+            if (token == null || token.getString("access_token") == null) {
+                log.warn("[OAuth][LINE] 换 token 失败(响应无 access_token): {}", token);
+                throw new ServiceException(MessageUtils.message("no.oauth.token.invalid", "LINE"));
+            }
+            String accessToken = token.getString("access_token");
+            log.info("[OAuth][LINE] 换 token 成功: access_token={}...(len={}), scope={}",
+                    safePrefix(accessToken), accessToken.length(), token.getString("scope"));
+            // 2. access_token 换用户信息(httpGet 内会打印 HTTP 状态码 + profile 原始响应体)
+            JSONObject profile = JSONObject.parseObject(httpGet(lineProfileUrl, accessToken));
+            if (profile == null) {
+                log.warn("[OAuth][LINE] profile 返回空");
+                throw new ServiceException(MessageUtils.message("no.oauth.token.invalid", "LINE"));
+            }
+            String userId = profile.getString("userId");
+            if (userId == null || userId.isEmpty()) {
+                log.warn("[OAuth][LINE] 无 userId: {}", profile);
+                throw new ServiceException(MessageUtils.message("no.oauth.token.invalid", "LINE"));
+            }
+            log.info("[OAuth][LINE] 校验通过 userId={}", userId);
+            return userId;
+        } catch (ServiceException se) {
+            throw se;
+        } catch (Exception e) {
+            log.error("[OAuth][LINE] 校验异常", e);
+            throw new ServiceException(MessageUtils.message("no.oauth.verify.fail", "LINE", e.getMessage()));
+        }
+    }
+
+    /** 简单 GET;bearer 非空时带 Authorization 头(LINE 用)。带超时,防止 provider 慢/不可达拖垮线程。 */
+    private String httpGet(String url, String bearer) throws Exception {
+        try (CloseableHttpClient client = HttpClients.createDefault()) {
+            HttpGet get = new HttpGet(url);
+            get.setConfig(RequestConfig.custom()
+                    .setConnectTimeout(5000)
+                    .setSocketTimeout(10000)
+                    .setConnectionRequestTimeout(5000)
+                    .build());
+            if (bearer != null && !bearer.isEmpty()) {
+                get.setHeader("Authorization", "Bearer " + bearer);
+            }
+            try (CloseableHttpResponse resp = client.execute(get)) {
+                int status = resp.getStatusLine().getStatusCode();
+                String body = EntityUtils.toString(resp.getEntity(), "UTF-8");
+                // google tokeninfo 的 url 含 id_token,只记 host 避免 JWT 刷屏
+                String host;
+                try {
+                    host = new URL(url).getHost();
+                } catch (Exception e) {
+                    host = url;
+                }
+                log.info("[OAuth][HTTP] GET host={} status={} body={}", host, status, body);
+                return body;
+            }
+        }
+    }
+
+    /** 简单 POST application/x-www-form-urlencoded(LINE 换 token 用)。带超时,防止 LINE 慢/不可达拖垮线程。 */
+    private String httpPostForm(String url, String[][] form) throws Exception {
+        try (CloseableHttpClient client = HttpClients.createDefault()) {
+            HttpPost post = new HttpPost(url);
+            post.setConfig(RequestConfig.custom()
+                    .setConnectTimeout(5000)
+                    .setSocketTimeout(10000)
+                    .setConnectionRequestTimeout(5000)
+                    .build());
+            List<NameValuePair> pairs = new ArrayList<>();
+            for (String[] kv : form) {
+                pairs.add(new BasicNameValuePair(kv[0], kv[1]));
+            }
+            post.setEntity(new UrlEncodedFormEntity(pairs, "UTF-8"));
+            try (CloseableHttpResponse resp = client.execute(post)) {
+                int status = resp.getStatusLine().getStatusCode();
+                String body = EntityUtils.toString(resp.getEntity(), "UTF-8");
+                // LINE 换 token 失败时 status=400,body 含 error/error_description,是定位根因的关键
+                log.info("[OAuth][HTTP] POST {} status={} body={}", url, status, body);
+                return body;
+            }
+        }
+    }
+
+    /** 取前 8 字符做日志脱敏(code/access_token 不全文打印)。 */
+    private static String safePrefix(String s) {
+        return s == null ? "" : s.substring(0, Math.min(8, s.length()));
+    }
+}

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

@@ -46,6 +46,29 @@ im:
   # 连接/读取超时(毫秒)
   timeout-ms: 5000
 
+# 三方登录配置(017-oauth-login)
+oauth:
+  apple:
+    # client_id = Apple client_id(identityToken 的 audience,实测=com.amazewayhk.zhibo)
+    client-id: com.twanmsdyh.app
+    # Apple 公钥地址(验签用,一般不改)
+    jwks-url: https://appleid.apple.com/auth/keys
+  google:
+    # Google OAuth client_id(校验 ID-Token 的 audience)
+    client-id: com.twanmsdyh.app
+    tokeninfo-url: https://oauth2.googleapis.com/tokeninfo
+  line:
+    # Channel ID(LINE Developers Console)
+    client-id: "2010911071"
+    # Channel Secret(换 token 用,勿泄露)
+    client-secret: "880fb850c17a3399d207100144a1cb67"
+    # 授权回调地址(须与 LINE Console 回调白名单 + 前端拿 code 时一致;LINE 仅校验一致性,不实际回调)
+    redirect-uri: https://foodieapi.waimai-paotui.com/auth/line/callback
+    # 用 code 换 access_token 的端点(一般不改)
+    token-url: https://api.line.me/oauth2/v2.1/token
+    # 用 access_token 换用户信息的端点(一般不改)
+    profile-url: https://api.line.me/v2/profile
+
 # 开发环境配置
 server:
   # 服务器的HTTP端口,默认为8080

+ 36 - 0
ruoyi-admin/src/main/resources/i18n/messages.properties

@@ -168,3 +168,39 @@ no.posorder.md.yh.jiner.messag=优惠抵扣:{};
 no.posorder.md.cx.mc.messag=促销:{};
 no.posorder.md.cx.jiner.messag=促销抵扣:{};
 no.wallet.common.cs.error=操作失败,请联系客服
+
+# 三方登录(017-oauth-login)
+no.oauth.provider.blank=登录方式不能为空
+no.oauth.credential.blank=三方凭证不能为空
+no.oauth.provider.unsupported=不支持的登录方式:{0}
+no.oauth.needphone=首次登录请验证手机号
+no.oauth.tempkey.missing=登录凭证缺失,请重新登录
+no.oauth.tempkey.expired=登录凭证已过期,请重新登录
+no.oauth.token.invalid={0}登录凭证无效
+no.oauth.token.expired={0}登录凭证已过期
+no.oauth.audience.mismatch={0}凭证校验未通过
+no.oauth.verify.fail={0}登录校验失败:{1}
+
+# 订单发票校验(010)
+no.invoice.choice.invalid=发票类型不合法:{0}
+no.invoice.barcode.format=手机条码格式不正确(须为 / 加 7 位大写英文/数字/+-.)
+no.invoice.barcode.service.unavailable=手机条码校验服务暂不可用,请稍后重试
+no.invoice.barcode.notexist=手机条码不存在,请确认或改选纸本发票
+no.invoice.citizen.format=自然人凭证格式不正确(须为 2 位大写英文 + 14 位数字)
+no.invoice.lovecode.format=捐赠码格式不正确(须为 3-7 位数字)
+no.invoice.lovecode.invalid=捐赠码无效
+no.invoice.lovecode.verify.fail=捐赠码验证失败,请稍后重试
+no.invoice.ubn.format=统一编号格式不正确(须为 8 位数字)
+no.invoice.company.email=公司发票须填写有效的买方邮箱(用于接收发票)
+no.invoice.store.notsupport.barcode=门店暂不支持电子发票,无法校验手机条码
+no.invoice.store.notsupport.lovecode=门店暂不支持电子发票,无法校验捐赠码
+
+# 订单发票校验 - 申请发票入参(010)
+no.invoice.b2b.buyername.blank=B2B 发票须填写买方名称
+no.invoice.b2b.buyeremail.blank=B2B 发票须填写买方邮箱
+no.invoice.b2b.nocarrier=B2B 发票不支持载具
+no.invoice.donation.nocarrier=捐赠发票不支持载具
+no.invoice.donation.noubn=捐赠发票不支持统编
+no.invoice.buyername.blank=买方名称不能为空
+no.invoice.carriertype.invalid=载具类型只能为 0手机条码/1自然人凭证/2ezPay会员
+no.invoice.carriernum.blank=载具号码不能为空

+ 36 - 0
ruoyi-admin/src/main/resources/i18n/messages_en_US.properties

@@ -172,3 +172,39 @@ no.posorder.md.yh.jiner.messag=Discount: {};
 no.posorder.md.cx.mc.messag=Promo: {};
 no.posorder.md.cx.jiner.messag=Promo discount: {};
 no.wallet.common.cs.error=Operation failed, please contact customer service
+
+# 三方登录(017-oauth-login)
+no.oauth.provider.blank=Login method is required
+no.oauth.credential.blank=Third-party credential is required
+no.oauth.provider.unsupported=Unsupported login method: {0}
+no.oauth.needphone=Phone verification is required for first login
+no.oauth.tempkey.missing=Login credential is missing, please log in again
+no.oauth.tempkey.expired=Login credential expired, please log in again
+no.oauth.token.invalid={0} login credential is invalid
+no.oauth.token.expired={0} login credential has expired
+no.oauth.audience.mismatch={0} credential verification failed
+no.oauth.verify.fail={0} login verification failed: {1}
+
+# Order invoice validation (010)
+no.invoice.choice.invalid=Invalid invoice type: {0}
+no.invoice.barcode.format=Invalid mobile barcode format (must be / followed by 7 uppercase letters/digits/+-.)
+no.invoice.barcode.service.unavailable=Mobile barcode verification service is temporarily unavailable, please try again later
+no.invoice.barcode.notexist=Mobile barcode does not exist, please verify or choose a paper invoice
+no.invoice.citizen.format=Invalid citizen digital certificate format (must be 2 uppercase letters + 14 digits)
+no.invoice.lovecode.format=Invalid donation code format (must be 3-7 digits)
+no.invoice.lovecode.invalid=Invalid donation code
+no.invoice.lovecode.verify.fail=Donation code verification failed, please try again later
+no.invoice.ubn.format=Invalid uniform business number format (must be 8 digits)
+no.invoice.company.email=Company invoice requires a valid buyer email (to receive the invoice)
+no.invoice.store.notsupport.barcode=This store does not support e-invoices; cannot verify mobile barcode
+no.invoice.store.notsupport.lovecode=This store does not support e-invoices; cannot verify donation code
+
+# Order invoice validation - apply input (010)
+no.invoice.b2b.buyername.blank=B2B invoice requires a buyer name
+no.invoice.b2b.buyeremail.blank=B2B invoice requires a buyer email
+no.invoice.b2b.nocarrier=B2B invoices do not support a carrier
+no.invoice.donation.nocarrier=Donation invoices do not support a carrier
+no.invoice.donation.noubn=Donation invoices do not support a uniform business number
+no.invoice.buyername.blank=Buyer name cannot be empty
+no.invoice.carriertype.invalid=Carrier type must be 0 (mobile barcode) / 1 (citizen certificate) / 2 (ezPay member)
+no.invoice.carriernum.blank=Carrier number cannot be empty

+ 36 - 0
ruoyi-admin/src/main/resources/i18n/messages_vi.properties

@@ -172,3 +172,39 @@ no.posorder.md.yh.jiner.messag=Giảm giá: {};
 no.posorder.md.cx.mc.messag=Khuyến mãi: {};
 no.posorder.md.cx.jiner.messag=Giảm KM: {};
 no.wallet.common.cs.error=Thất bại, vui lòng liên hệ CSKH
+
+# 三方登录(017-oauth-login)
+no.oauth.provider.blank=Vui lòng chọn cách đăng nhập
+no.oauth.credential.blank=Thông tin xác thực bên thứ ba không được để trống
+no.oauth.provider.unsupported=Không hỗ trợ cách đăng nhập: {0}
+no.oauth.needphone=Cần xác minh số điện thoại ở lần đăng nhập đầu
+no.oauth.tempkey.missing=Thiếu thông tin đăng nhập, vui lòng đăng nhập lại
+no.oauth.tempkey.expired=Thông tin đăng nhập đã hết hạn, vui lòng đăng nhập lại
+no.oauth.token.invalid=Thông tin đăng nhập {0} không hợp lệ
+no.oauth.token.expired=Thông tin đăng nhập {0} đã hết hạn
+no.oauth.audience.mismatch=Xác minh thông tin {0} không thành công
+no.oauth.verify.fail=Xác minh đăng nhập {0} thất bại: {1}
+
+# Kiểm tra hóa đơn đơn hàng (010)
+no.invoice.choice.invalid=Loại hóa đơn không hợp lệ: {0}
+no.invoice.barcode.format=Định dạng mã vạch điện thoại không hợp lệ (phải là / và 7 ký tự hoa/chữ số/+-.)
+no.invoice.barcode.service.unavailable=Dịch vụ xác minh mã vạch điện thoại tạm thời không khả dụng, vui lòng thử lại sau
+no.invoice.barcode.notexist=Mã vạch điện thoại không tồn tại, vui lòng xác nhận hoặc chọn hóa đơn giấy
+no.invoice.citizen.format=Định dạng chứng chỉ số công dân không hợp lệ (phải là 2 chữ cái hoa + 14 chữ số)
+no.invoice.lovecode.format=Định dạng mã quyên góp không hợp lệ (phải là 3-7 chữ số)
+no.invoice.lovecode.invalid=Mã quyên góp không hợp lệ
+no.invoice.lovecode.verify.fail=Xác minh mã quyên góp thất bại, vui lòng thử lại sau
+no.invoice.ubn.format=Định dạng mã số doanh nghiệp không hợp lệ (phải là 8 chữ số)
+no.invoice.company.email=Hóa đơn công ty yêu cầu email người mua hợp lệ (để nhận hóa đơn)
+no.invoice.store.notsupport.barcode=Cửa hàng này không hỗ trợ hóa đơn điện tử, không thể xác minh mã vạch điện thoại
+no.invoice.store.notsupport.lovecode=Cửa hàng này không hỗ trợ hóa đơn điện tử, không thể xác minh mã quyên góp
+
+# Kiểm tra hóa đơn - đầu vào đăng ký (010)
+no.invoice.b2b.buyername.blank=Hóa đơn B2B yêu cầu tên người mua
+no.invoice.b2b.buyeremail.blank=Hóa đơn B2B yêu cầu email người mua
+no.invoice.b2b.nocarrier=Hóa đơn B2B không hỗ trợ thiết bị lưu trữ
+no.invoice.donation.nocarrier=Hóa đơn quyên góp không hỗ trợ thiết bị lưu trữ
+no.invoice.donation.noubn=Hóa đơn quyên góp không hỗ trợ mã số doanh nghiệp
+no.invoice.buyername.blank=Tên người mua không được để trống
+no.invoice.carriertype.invalid=Loại thiết bị lưu trữ chỉ có thể là 0 (mã vạch điện thoại) / 1 (chứng chỉ số công dân) / 2 (thành viên ezPay)
+no.invoice.carriernum.blank=Số thiết bị lưu trữ không được để trống

+ 36 - 0
ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties

@@ -172,3 +172,39 @@ no.posorder.md.yh.jiner.messag=优惠抵扣:{};
 no.posorder.md.cx.mc.messag=促销:{};
 no.posorder.md.cx.jiner.messag=促销抵扣:{};
 no.wallet.common.cs.error=操作失败,请联系客服
+
+# 三方登录(017-oauth-login)
+no.oauth.provider.blank=登录方式不能为空
+no.oauth.credential.blank=三方凭证不能为空
+no.oauth.provider.unsupported=不支持的登录方式:{0}
+no.oauth.needphone=首次登录请验证手机号
+no.oauth.tempkey.missing=登录凭证缺失,请重新登录
+no.oauth.tempkey.expired=登录凭证已过期,请重新登录
+no.oauth.token.invalid={0}登录凭证无效
+no.oauth.token.expired={0}登录凭证已过期
+no.oauth.audience.mismatch={0}凭证校验未通过
+no.oauth.verify.fail={0}登录校验失败:{1}
+
+# 订单发票校验(010)
+no.invoice.choice.invalid=发票类型不合法:{0}
+no.invoice.barcode.format=手机条码格式不正确(须为 / 加 7 位大写英文/数字/+-.)
+no.invoice.barcode.service.unavailable=手机条码校验服务暂不可用,请稍后重试
+no.invoice.barcode.notexist=手机条码不存在,请确认或改选纸本发票
+no.invoice.citizen.format=自然人凭证格式不正确(须为 2 位大写英文 + 14 位数字)
+no.invoice.lovecode.format=捐赠码格式不正确(须为 3-7 位数字)
+no.invoice.lovecode.invalid=捐赠码无效
+no.invoice.lovecode.verify.fail=捐赠码验证失败,请稍后重试
+no.invoice.ubn.format=统一编号格式不正确(须为 8 位数字)
+no.invoice.company.email=公司发票须填写有效的买方邮箱(用于接收发票)
+no.invoice.store.notsupport.barcode=门店暂不支持电子发票,无法校验手机条码
+no.invoice.store.notsupport.lovecode=门店暂不支持电子发票,无法校验捐赠码
+
+# 订单发票校验 - 申请发票入参(010)
+no.invoice.b2b.buyername.blank=B2B 发票须填写买方名称
+no.invoice.b2b.buyeremail.blank=B2B 发票须填写买方邮箱
+no.invoice.b2b.nocarrier=B2B 发票不支持载具
+no.invoice.donation.nocarrier=捐赠发票不支持载具
+no.invoice.donation.noubn=捐赠发票不支持统编
+no.invoice.buyername.blank=买方名称不能为空
+no.invoice.carriertype.invalid=载具类型只能为 0手机条码/1自然人凭证/2ezPay会员
+no.invoice.carriernum.blank=载具号码不能为空

+ 36 - 0
ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties

@@ -172,3 +172,39 @@ no.posorder.md.yh.jiner.messag=優惠抵扣:{};
 no.posorder.md.cx.mc.messag=促銷:{};
 no.posorder.md.cx.jiner.messag=促銷抵扣:{};
 no.wallet.common.cs.error=操作失敗,請聯繫客服
+
+# 三方登录(017-oauth-login)
+no.oauth.provider.blank=登入方式不能為空
+no.oauth.credential.blank=第三方憑證不能為空
+no.oauth.provider.unsupported=不支援的登入方式:{0}
+no.oauth.needphone=首次登入請驗證手機號
+no.oauth.tempkey.missing=登入憑證缺失,請重新登入
+no.oauth.tempkey.expired=登入憑證已過期,請重新登入
+no.oauth.token.invalid={0}登入憑證無效
+no.oauth.token.expired={0}登入憑證已過期
+no.oauth.audience.mismatch={0}憑證校驗未通過
+no.oauth.verify.fail={0}登入校驗失敗:{1}
+
+# 訂單發票校驗(010)
+no.invoice.choice.invalid=發票類型不合法:{0}
+no.invoice.barcode.format=手機條碼格式不正確(須為 / 加 7 位大寫英文/數字/+-.)
+no.invoice.barcode.service.unavailable=手機條碼驗證服務暫不可用,請稍後重試
+no.invoice.barcode.notexist=手機條碼不存在,請確認或改選紙本發票
+no.invoice.citizen.format=自然人憑證格式不正確(須為 2 位大寫英文 + 14 位數字)
+no.invoice.lovecode.format=捐贈碼格式不正確(須為 3-7 位數字)
+no.invoice.lovecode.invalid=捐贈碼無效
+no.invoice.lovecode.verify.fail=捐贈碼驗證失敗,請稍後重試
+no.invoice.ubn.format=統一編號格式不正確(須為 8 位數字)
+no.invoice.company.email=公司發票須填寫有效的買方電子郵件(用於接收發票)
+no.invoice.store.notsupport.barcode=門市暫不支援電子發票,無法驗證手機條碼
+no.invoice.store.notsupport.lovecode=門市暫不支援電子發票,無法驗證捐贈碼
+
+# 訂單發票校驗 - 申請發票入參(010)
+no.invoice.b2b.buyername.blank=B2B 發票須填寫買方名稱
+no.invoice.b2b.buyeremail.blank=B2B 發票須填寫買方電子郵件
+no.invoice.b2b.nocarrier=B2B 發票不支援載具
+no.invoice.donation.nocarrier=捐贈發票不支援載具
+no.invoice.donation.noubn=捐贈發票不支援統編
+no.invoice.buyername.blank=買方名稱不能為空
+no.invoice.carriertype.invalid=載具類型只能為 0手機條碼/1自然人憑證/2ezPay會員
+no.invoice.carriernum.blank=載具號碼不能為空

+ 3 - 0
ruoyi-common/src/main/java/com/ruoyi/common/core/domain/model/LoginUserDto.java

@@ -33,4 +33,7 @@ public class LoginUserDto {
     
     /** 登录时间 */
     private Long loginTime;
+
+    /** 本次登录渠道 apple/google/line/phone(三方登录写入,用于登录方式标记) */
+    private String provider;
 }

+ 83 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/InfoInvoice.java

@@ -0,0 +1,83 @@
+package com.ruoyi.system.domain;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import jakarta.validation.constraints.Email;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Pattern;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.util.Date;
+
+/**
+ * 用户发票抬头 info_invoice(发票信息管理)—— 即请求体 / 响应体。
+ * <p>用户级 CRUD,镜像 InfoAddress;JWT 隔离 userId。字段完整说明见
+ * specs/014-invoice-profile/contracts/api.md。</p>
+ *
+ * <p><b>按 category 区分两套入参(列表返回同理,前端按 category 渲染):</b><br>
+ * B2C(个人):titleName + buyerName + carrierType + carrierNum(载具=2 时再加 buyerEmail)<br>
+ * B2B(公司):titleName + buyerName + buyerUbn + buyerEmail(不传载具)</p>
+ *
+ * <p>字段格式 / 必填由 jakarta 校验注解声明(@NotBlank/@Pattern/@Email,springfox 自动识别为
+ * Swagger 的 required/pattern 展示在 /doc.html);条件必填(B2C 载具、B2B 统编)与类型互斥
+ * 由 service validateInvoiceProfile 强校验。</p>
+ *
+ * @author foodie
+ * @date 2026-07-24
+ */
+@Data
+@TableName(value = "info_invoice")
+@EqualsAndHashCode(callSuper = false)
+public class InfoInvoice
+{
+    private static final long serialVersionUID = 1L;
+
+    /** 主键(新增不传,修改必传) */
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 用户id(后端按登录 token 自动填充,前端无需传) */
+    private Long userId;
+
+    /** 抬头备注名(用户自定义,便于区分多条抬头) */
+    @NotBlank(message = "抬头名称不能为空")
+    private String titleName;
+
+    /** 发票类型 B2C=个人 / B2B=公司(区分入参字段与列表渲染的依据) */
+    @NotBlank(message = "发票类型不能为空")
+    @Pattern(regexp = "B2C|B2B", message = "发票类型只能为 B2C 或 B2B")
+    private String category;
+
+    /** 买方名称(B2C 填个人姓名 / B2B 填公司全名) */
+    @NotBlank(message = "买方名称不能为空")
+    private String buyerName;
+
+    /** 统一编号(统编,8 位数字);仅 B2B 必填,B2C 不传 */
+    @Pattern(regexp = "\\d{8}", message = "统一编号必须为 8 位数字")
+    private String buyerUbn;
+
+    /** 接收邮箱;B2B 必填 / B2C 仅载具类型=2(ezPay会员)时必填,其余可空 */
+    @Email(message = "邮箱格式不正确")
+    private String buyerEmail;
+
+    /** 载具类型 0手机条码 / 1自然人凭证 / 2ezPay会员;仅 B2C 必填,B2B 不传 */
+    @Pattern(regexp = "0|1|2", message = "载具类型只能为 0、1、2")
+    private String carrierType;
+
+    /** 载具号码(仅 B2C 必填,随 carrierType:0手机条码以/开头;1自然人凭证=2字母+14数字;2 ezPay会员账号) */
+    private String carrierNum;
+
+    /** 是否默认载具/抬头(0否 1是;010 Phase 11 全联式「同意设置为默认」用,同一用户仅一条为 1) */
+    private Integer isDefault;
+
+    /** 创建时间(DB DEFAULT CURRENT_TIMESTAMP,只读) */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date createTime;
+
+    /** 更新时间(DB ON UPDATE CURRENT_TIMESTAMP,只读) */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date updateTime;
+}

+ 11 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/InfoUser.java

@@ -124,6 +124,9 @@ public class InfoUser
     /** 车牌 */
     @Excel(name = "车牌")
     private String licensePlate;
+    /** 骑手车型(1机车 2轿车) */
+    @Excel(name = "骑手车型")
+    private String vehicleType;
 
     /** 负责人 */
     @Excel(name = "负责人")
@@ -396,6 +399,14 @@ public class InfoUser
         this.licensePlate = licensePlate;
     }
 
+    public String getVehicleType() {
+        return vehicleType;
+    }
+
+    public void setVehicleType(String vehicleType) {
+        this.vehicleType = vehicleType;
+    }
+
     public String getFullName() {
         return fullName;
     }

+ 41 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/InfoUserOauth.java

@@ -0,0 +1,41 @@
+package com.ruoyi.system.domain;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Data;
+
+import java.util.Date;
+
+/**
+ * 三方账号绑定 info_user_oauth。
+ * <p>记录 InfoUser 与 Apple/Google/LINE 的绑定关系。手机号始终是 InfoUser 的主键,
+ * 三方账号只是快捷登录入口。按 (provider, provider_uid) 反查用户(唯一索引)。</p>
+ *
+ * @author foodie
+ * @date 2026-07-30
+ */
+@Data
+@TableName("info_user_oauth")
+public class InfoUserOauth {
+
+    private static final long serialVersionUID = 1L;
+
+    /** 主键 */
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 关联 info_user.user_id */
+    private Long userId;
+
+    /** 三方渠道 apple/google/line */
+    private String provider;
+
+    /** 三方稳定用户ID(Apple sub / Google sub / LINE userId) */
+    private String providerUid;
+
+    /** 绑定时间 */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date createTime;
+}

+ 6 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/PosOrderInvoice.java

@@ -37,6 +37,9 @@ public class PosOrderInvoice {
     /** 冗余 pos_store.id(门店) */
     private Long storeId;
 
+    /** 发票选择(下单意图) PAPER个人纸本/PHONE_BARCODE手机条码/CITIZEN自然人凭证/MEMBER会员载具/LOVE_CODE捐赠/COMPANY公司 */
+    private String invoiceChoice;
+
     /** 发票类型 B2C/B2B */
     private String invoiceCategory;
 
@@ -70,6 +73,9 @@ public class PosOrderInvoice {
     /** 0未开/1已开/2失败/3作废 */
     private Integer invoiceStatus;
 
+    /** 是否已触发开票 0否1是(出餐开票流程执行过即为1;与 invoiceStatus 独立,管理端发票管理用它筛选已进入开票流程的订单) */
+    private Integer issueTriggered;
+
     /** 含税总额(= amount - freight) */
     private Integer totalAmt;
 

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

@@ -31,7 +31,7 @@ public class ApplyInvoiceDto {
     @Pattern(regexp = "\\d{8}", message = "统一编号必须为 8 位数字")
     private String buyerUbn;
 
-    /** 买方邮箱(B2B 必填;B2C 选 ezPay会员载具(2)时必填,其余可空) */
+    /** 买方邮箱(B2B 必填;B2C 选 ezPay会员载具(2)时不收、其余可空) */
     @Email(message = "邮箱格式不正确")
     private String buyerEmail;
 

+ 6 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/PosOrderInvoiceVo.java

@@ -28,6 +28,9 @@ public class PosOrderInvoiceVo {
     /** 门店名称 */
     private String posName;
 
+    /** 发票选择(下单意图) PAPER个人纸本/PHONE_BARCODE手机条码/CITIZEN自然人凭证/LOVE_CODE捐赠/COMPANY公司 */
+    private String invoiceChoice;
+
     /** 发票类型 B2C/B2B */
     private String invoiceCategory;
 
@@ -61,6 +64,9 @@ public class PosOrderInvoiceVo {
     /** 0未开/1已开/2失败/3作废 */
     private Integer invoiceStatus;
 
+    /** 是否已触发开票 0否1是(出餐开票流程执行过即为1;与 invoiceStatus 独立,管理端发票管理用它筛选已进入开票流程的订单) */
+    private Integer issueTriggered;
+
     /** 含税总额 */
     private Integer totalAmt;
 

+ 16 - 0
ruoyi-system/src/main/java/com/ruoyi/system/mapper/InfoInvoiceMapper.java

@@ -0,0 +1,16 @@
+package com.ruoyi.system.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.ruoyi.system.domain.InfoInvoice;
+
+/**
+ * 用户发票抬头 Mapper(发票信息管理)
+ * <p>仅用 MyBatis-Plus IService CRUD(list/saveOrUpdate/getById/removeById),
+ * 无自定义查询、无 XML。本期无后台端。</p>
+ *
+ * @author foodie
+ * @date 2026-07-24
+ */
+public interface InfoInvoiceMapper extends BaseMapper<InfoInvoice>
+{
+}

+ 13 - 0
ruoyi-system/src/main/java/com/ruoyi/system/mapper/InfoUserOauthMapper.java

@@ -0,0 +1,13 @@
+package com.ruoyi.system.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.ruoyi.system.domain.InfoUserOauth;
+
+/**
+ * 三方账号绑定 Mapper(仅用 MyBatis-Plus CRUD,无自定义查询、无 XML)。
+ *
+ * @author foodie
+ * @date 2026-07-30
+ */
+public interface InfoUserOauthMapper extends BaseMapper<InfoUserOauth> {
+}

+ 3 - 0
ruoyi-system/src/main/java/com/ruoyi/system/mapper/PosOrderInvoiceMapper.java

@@ -23,4 +23,7 @@ public interface PosOrderInvoiceMapper extends BaseMapper<PosOrderInvoice> {
 
     /** 订单发票详情(单行)。 */
     PosOrderInvoiceVo selectInvoiceDetail(@Param("orderId") Long orderId);
+
+    /** 会员发票夹(010 Phase 11):按 userId 列已开发票,join pos_order 过滤,不含码图。配合 PageHelper 分页。 */
+    List<PosOrderInvoiceVo> selectMyInvoices(@Param("userId") Long userId);
 }

+ 35 - 0
ruoyi-system/src/main/java/com/ruoyi/system/service/IInfoInvoiceService.java

@@ -0,0 +1,35 @@
+package com.ruoyi.system.service;
+
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.ruoyi.system.domain.InfoInvoice;
+
+import java.util.List;
+
+/**
+ * 用户发票抬头 Service(发票信息管理)
+ *
+ * @author foodie
+ * @date 2026-07-24
+ */
+public interface IInfoInvoiceService extends IService<InfoInvoice>
+{
+    /**
+     * 列出当前用户的全部发票抬头(按 id 倒序)。
+     */
+    List<InfoInvoice> listByUserId(Long userId);
+
+    /**
+     * 新增或更新:强制 userId + 校验 + 归属校验;非法抛 ServiceException。
+     */
+    boolean saveOrUpdateMine(InfoInvoice invoice, Long userId);
+
+    /**
+     * 详情;越权(不属于该 user)返回 null。
+     */
+    InfoInvoice getMine(Long id, Long userId);
+
+    /**
+     * 删除;越权返回 false。
+     */
+    boolean deleteMine(Long id, Long userId);
+}

+ 163 - 0
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/InfoInvoiceServiceImpl.java

@@ -0,0 +1,163 @@
+package com.ruoyi.system.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.system.domain.InfoInvoice;
+import com.ruoyi.system.mapper.InfoInvoiceMapper;
+import com.ruoyi.system.service.IInfoInvoiceService;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+import java.util.regex.Pattern;
+
+/**
+ * 用户发票抬头 Service 实现(发票信息管理)
+ *
+ * @author foodie
+ * @date 2026-07-24
+ */
+@Service
+public class InfoInvoiceServiceImpl extends ServiceImpl<InfoInvoiceMapper, InfoInvoice> implements IInfoInvoiceService
+{
+    /** 统编:8 位数字 */
+    private static final Pattern UBN_PATTERN = Pattern.compile("^\\d{8}$");
+    /** 自然人凭证:2 大写字母 + 14 数字 */
+    private static final Pattern CARRIER_CDC_PATTERN = Pattern.compile("^[A-Z]{2}\\d{14}$");
+    /** 邮箱格式(宽松校验) */
+    private static final Pattern EMAIL_PATTERN = Pattern.compile("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$");
+
+    @Override
+    public List<InfoInvoice> listByUserId(Long userId)
+    {
+        return list(new QueryWrapper<InfoInvoice>().eq("user_id", userId).orderByDesc("id"));
+    }
+
+    @Override
+    public boolean saveOrUpdateMine(InfoInvoice invoice, Long userId)
+    {
+        // 强制以当前登录用户为准,前端传的 userId 无效(防越权)
+        invoice.setUserId(userId);
+        validateInvoiceProfile(invoice);
+        // 更新时校验归属:该抬头必须属于当前用户
+        if (invoice.getId() != null)
+        {
+            InfoInvoice exist = getById(invoice.getId());
+            if (exist == null || !userId.equals(exist.getUserId()))
+            {
+                throw new ServiceException("抬头不存在或无权操作");
+            }
+        }
+        return saveOrUpdate(invoice);
+    }
+
+    @Override
+    public InfoInvoice getMine(Long id, Long userId)
+    {
+        InfoInvoice invoice = getById(id);
+        if (invoice == null || !userId.equals(invoice.getUserId()))
+        {
+            return null;
+        }
+        return invoice;
+    }
+
+    @Override
+    public boolean deleteMine(Long id, Long userId)
+    {
+        InfoInvoice invoice = getById(id);
+        if (invoice == null || !userId.equals(invoice.getUserId()))
+        {
+            return false;
+        }
+        return removeById(id);
+    }
+
+    /**
+     * 保存前中校验(见 contracts/api.md 校验矩阵)。
+     * B2B:公司名 + 统编8位 + 邮箱必填 + 载具空。
+     * B2C:姓名 + 载具必填(0手机条码以/开头、1自然人凭证2字母+14数字、2会员载具须带邮箱)+ 统编空。
+     */
+    private void validateInvoiceProfile(InfoInvoice invoice)
+    {
+        String category = invoice.getCategory();
+        if (!"B2C".equals(category) && !"B2B".equals(category))
+        {
+            throw new ServiceException("发票类型不正确");
+        }
+        if (isBlank(invoice.getTitleName()))
+        {
+            throw new ServiceException("抬头名称不能为空");
+        }
+        if (isBlank(invoice.getBuyerName()))
+        {
+            throw new ServiceException("买方名称不能为空");
+        }
+
+        if ("B2B".equals(category))
+        {
+            if (invoice.getBuyerUbn() == null || !UBN_PATTERN.matcher(invoice.getBuyerUbn()).matches())
+            {
+                throw new ServiceException("统一编号须为8位数字");
+            }
+            if (isBlank(invoice.getBuyerEmail()) || !EMAIL_PATTERN.matcher(invoice.getBuyerEmail()).matches())
+            {
+                throw new ServiceException("邮箱格式不正确");
+            }
+            if (!isBlank(invoice.getCarrierType()) || !isBlank(invoice.getCarrierNum()))
+            {
+                throw new ServiceException("公司发票无需载具");
+            }
+            return;
+        }
+
+        // B2C
+        if (!isBlank(invoice.getBuyerUbn()))
+        {
+            throw new ServiceException("个人发票无需统一编号");
+        }
+        String carrierType = invoice.getCarrierType();
+        if (isBlank(carrierType))
+        {
+            throw new ServiceException("请选择载具类型");
+        }
+        if (isBlank(invoice.getCarrierNum()))
+        {
+            throw new ServiceException("载具号码不能为空");
+        }
+        String carrierNum = invoice.getCarrierNum();
+        switch (carrierType)
+        {
+            case "0": // 手机条码:以 / 开头
+                if (!carrierNum.startsWith("/"))
+                {
+                    throw new ServiceException("手机条码须以 / 开头");
+                }
+                break;
+            case "1": // 自然人凭证:2 字母 + 14 数字
+                if (!CARRIER_CDC_PATTERN.matcher(carrierNum).matches())
+                {
+                    throw new ServiceException("自然人凭证格式不正确");
+                }
+                break;
+            case "2": // ezPay 会员载具:须带邮箱
+                if (isBlank(invoice.getBuyerEmail()) || !EMAIL_PATTERN.matcher(invoice.getBuyerEmail()).matches())
+                {
+                    throw new ServiceException("会员载具须填写邮箱");
+                }
+                break;
+            default:
+                throw new ServiceException("载具类型不正确");
+        }
+        // 邮箱选填时也校验格式
+        if (!isBlank(invoice.getBuyerEmail()) && !EMAIL_PATTERN.matcher(invoice.getBuyerEmail()).matches())
+        {
+            throw new ServiceException("邮箱格式不正确");
+        }
+    }
+
+    private static boolean isBlank(String s)
+    {
+        return s == null || s.trim().isEmpty();
+    }
+}

+ 1 - 0
ruoyi-system/src/main/java/com/ruoyi/system/utils/JwtUtil.java

@@ -63,6 +63,7 @@ public class JwtUtil {
                     .withExpiresAt(expireDate) //设置签名过期的时间
                     .withClaim("id", user.getUserId() != null ? String.valueOf(user.getUserId()) : null) //自定义信息,将Long转换为String
                     .withClaim("userName", user.getUserName())//自定义信息
+                    .withClaim("provider", user.getProvider())//登录渠道 apple/google/line/phone(三方登录标记)
                     .withJWTId(jti) //jwt的唯一标识符,每个token都有唯一的JTI,用于防止重放攻击和token撤销
                     .sign(algorithm);
             redisCache.setCacheObject(jti, user, (int)EXPIRE_DATE, TimeUnit.MILLISECONDS);

+ 34 - 0
ruoyi-system/src/main/resources/mapper/chanting/PosOrderInvoiceMapper.xml

@@ -13,6 +13,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             i.order_no         AS orderNo,
             i.store_id         AS storeId,
             s.pos_name         AS posName,
+            i.invoice_choice   AS invoiceChoice,
+            i.issue_triggered   AS issueTriggered,
             i.invoice_category AS invoiceCategory,
             i.buyer_name       AS buyerName,
             i.buyer_ubn        AS buyerUbn,
@@ -35,6 +37,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="storeId != null"> AND i.store_id = #{storeId} </if>
             <if test="invoiceStatus != null"> AND i.invoice_status = #{invoiceStatus} </if>
             <if test="invoiceCategory != null and invoiceCategory != ''"> AND i.invoice_category = #{invoiceCategory} </if>
+            <if test="issueTriggered != null"> AND i.issue_triggered = #{issueTriggered} </if>
             <if test="posNameLike != null and posNameLike != ''"> AND s.pos_name LIKE concat('%', #{posNameLike}, '%') </if>
             <if test="cretimStart != null and cretimStart != ''"> AND i.create_time &gt;= #{cretimStart} </if>
             <if test="cretimEnd != null and cretimEnd != ''"> AND i.create_time &lt;= #{cretimEnd} </if>
@@ -51,6 +54,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             i.order_no         AS orderNo,
             i.store_id         AS storeId,
             s.pos_name         AS posName,
+            i.invoice_choice   AS invoiceChoice,
+            i.issue_triggered   AS issueTriggered,
             i.invoice_category AS invoiceCategory,
             i.buyer_name       AS buyerName,
             i.buyer_ubn        AS buyerUbn,
@@ -79,4 +84,33 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         WHERE i.order_id = #{orderId}
     </select>
 
+    <!-- 会员发票夹(010 Phase 11):按 userId 列已开发票,不含码图(证明联一次为限,不返回 bar_code/qrcode) -->
+    <select id="selectMyInvoices" parameterType="Long"
+            resultType="com.ruoyi.system.domain.vo.PosOrderInvoiceVo">
+        SELECT
+            i.id,
+            i.order_id         AS orderId,
+            i.order_no         AS orderNo,
+            i.store_id         AS storeId,
+            s.pos_name         AS posName,
+            i.invoice_choice   AS invoiceChoice,
+            i.issue_triggered   AS issueTriggered,
+            i.invoice_category AS invoiceCategory,
+            i.buyer_name       AS buyerName,
+            i.carrier_type     AS carrierType,
+            i.love_code        AS loveCode,
+            i.love_org_name    AS loveOrgName,
+            i.invoice_number   AS invoiceNumber,
+            i.random_num       AS randomNum,
+            i.invoice_status   AS invoiceStatus,
+            i.total_amt        AS totalAmt,
+            i.issue_time       AS issueTime
+        FROM pos_order_invoice i
+        LEFT JOIN pos_order o ON o.id = i.order_id
+        LEFT JOIN pos_store s ON s.id = i.store_id
+        WHERE o.user_id = #{userId}
+          AND i.invoice_status = 1
+        ORDER BY i.id DESC
+    </select>
+
 </mapper>

+ 4 - 0
ruoyi-system/src/main/resources/mapper/infouser/InfoUserMapper.xml

@@ -29,6 +29,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         <result property="drivingLicense"    column="driving_license"    />
         <result property="vehiclePhotos"    column="vehicle_photos"    />
         <result property="licensePlate"    column="license_plate"    />
+        <result property="vehicleType"    column="vehicle_type"    />
         <result property="fullName"    column="full_name"    />
         <result property="idNumber"    column="id_number"    />
         <result property="merchantType"    column="merchant_type"    />
@@ -107,6 +108,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="driversLicense != null">drivers_license,</if>
             <if test="drivingLicense != null">driving_license,</if>
             <if test="vehiclePhotos != null">vehicle_photos,</if>
+            <if test="vehicleType != null">vehicle_type,</if>
             <if test="passportPhoto != null">passport_photo,</if>
             <if test="bankPhoto != null">bank_photo,</if>
             <if test="merchantIntroduction != null">merchant_introduction,</if>
@@ -141,6 +143,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="driversLicense != null">#{driversLicense},</if>
             <if test="drivingLicense != null">#{drivingLicense},</if>
             <if test="vehiclePhotos != null">#{vehiclePhotos},</if>
+            <if test="vehicleType != null">#{vehicleType},</if>
             <if test="passportPhoto != null">#{passportPhoto},</if>
             <if test="bankPhoto != null">#{bankPhoto},</if>
             <if test="merchantIntroduction != null">#{merchantIntroduction},</if>
@@ -179,6 +182,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="driversLicense != null">drivers_license = #{driversLicense},</if>
             <if test="drivingLicense != null">driving_license = #{drivingLicense},</if>
             <if test="vehiclePhotos != null">vehicle_photos = #{vehiclePhotos},</if>
+            <if test="vehicleType != null">vehicle_type = #{vehicleType},</if>
             <if test="passportPhoto != null">passport_photo = #{passportPhoto},</if>
             <if test="bankPhoto != null">bank_photo = #{bankPhoto},</if>
             <if test="merchantIntroduction != null">merchant_introduction = #{merchantIntroduction},</if>

+ 173 - 0
specs/010-order-invoice/auto-invoice-design.md

@@ -0,0 +1,173 @@
+# 订单电子发票:全联式自动开票设计(010 演进)
+
+**Feature**: 演进 `010-order-invoice`(订单 ezPay 电子发票开立)
+**Created**: 2026-07-27
+**Status**: Draft(待审)
+**Supersedes**: 010 `spec.md` 中「客户在订单完成后手动申请开票」的**触发/入口模型**(改为下单捕获 + 出餐自动)。010 已建的 B2C/B2B/载具/捐赠/作废/重试/码值落库等开票核心**全部复用**,不在本期重做。
+
+## 1. 背景与动机
+
+010 已实现「客户在订单完成后进订单详情手动申请发票」。现按**全联福利中心(PX Mart)**模式演进为:**下单(结账)时选发票类型(默认个人纸本),商家出餐时自动开票**。目标:
+
+- **零信息也能开**(默认个人纸本,BuyerName 取手机号),解决「客户没设载具怎么开」。
+- 发票自动随订单流转,**纸本由骑手送餐时一并交付**。
+- 会员有「**我的发票**」夹,App 内随时查看。
+- **开发票门店每笔必开**;免用发票门店不开。
+
+参考:全联福利中心 App、老板提供的纸本发票样张(标准台湾电子发票证明联:左右两组 QR 码 + 条码 + 发票号/卖方统编/买方/明细/随机码)。
+
+## 2. 范围
+
+**本期做(后端为主)**:
+
+- 下单(`createOrder`)捕获发票意图
+- 商家出餐(`dispatchOrder`)自动开票
+- 个人纸本零信息兜底分支(补 010 缺的 B2C 无载具→PrintFlag=Y 分支)
+- 载具校验(接入 `checkBarCode`)
+- 会员发票夹列表接口(按 user_id)
+- 发票信息双入口(结账时设 + 复用 014 `info_invoice` 作「我的发票抬头」管理)
+- `info_invoice` 加「是否默认」标记
+
+**本期不做(留增量)**:
+
+- 客户端 App 的结账页发票选择 UI、我的发票夹 UI、我的发票抬头管理 UI(沿用 010 分工,客户端团队对接)
+- POS 实体打印机对接(纸本证明联由商家自行打印,平台只生成码值)
+- 发票折让、字轨管理、批次开立、中奖通知
+
+## 3. 核心决策(已与需求方确认)
+
+| # | 决策 |
+|---|---|
+| D1 | 触发时机 = 商家出餐 `dispatchOrder`(state 1→2)统一开所有档位 |
+| D2 | 全联式 UX:发票类型在下单(结账)时选,**顶层只有 个人发票 / 公司发票 两种** |
+| D3 | 个人发票默认 = 纸本(零输入);要载具才现填手機條碜/自然人凭证;**捐赠归在个人发票下**(非并列类型) |
+| D4 | 个人(B2C)BuyerName = **用户手机号**(`InfoUser.phone`,自动取,客户不填) |
+| D5 | 「同意设置为默认」复选框 + 「选用常用载具/抬头」:复用 014 `info_invoice` 存储,加「是否默认」标记;**只对载具与统编抬头生效,捐赠不存** |
+| D6 | 发票信息双入口:① 结账时设 ②「我的发票抬头」管理页(014 后端已就绪) |
+| D7 | 纸本(PrintFlag=Y)证明联**只打一次**,骑手送餐带上;发票夹只展示结构化信息,**不补打、不重渲染码图** |
+| D8 | 载具/捐赠(PrintFlag=N)无纸本,发票夹标注「已存入载具/已捐赠」 |
+| D9 | 货到付款:出餐照开(纸本要赶骑手),送达失败/取消 → 作废(复用 010 `invalid`) |
+| D10 | 门店不可开票(`canInvoice=false`:免用发票或 ezPay 未开通/未启用)→ 结账页不展示发票选项,出餐跳过自动开票 |
+
+## 4. 结账页发票信息架构(客户端 UI 参考)
+
+```
+发票类型:
+  ○ 个人发票                      ← 默认选中
+  │    └─ 这张个人发票怎么处理?
+  │        ○ 纸本                  ← 默认,零输入,BuyerName=手机号,PrintFlag=Y
+  │        ○ 存手機條碜载具          [填条码 / 选用常用] [☐ 同意设置为默认]
+  │        ○ 存自然人凭证载具        [填凭证]
+  │        ○ 捐赠给社福机构          [选机构 / 填捐赠码],PrintFlag=N
+  │
+  ○ 公司发票
+       └─ 统一编号 [填] + 公司名称 [填],PrintFlag=Y (B2B)
+```
+
+ezPay 映射:
+
+| 结账选择 | Category | 关键字段 | PrintFlag |
+|---|---|---|---|
+| 个人→纸本(默认) | B2C | BuyerName=手机号,无载具无捐赠 | Y |
+| 个人→手機條碜 | B2C | CarrierType=0 + CarrierNum | N |
+| 个人→自然人凭证 | B2C | CarrierType=1 + CarrierNum | N |
+| 个人→捐赠 | B2C | LoveCode | N |
+| 公司发票 | B2B | BuyerUBN + BuyerName(公司名) + BuyerEmail | Y |
+
+> 010 现有 `ApplyInvoiceDto.DONATION` 独立类型可废弃:捐赠统一为 `B2C + loveCode`(代码 `buildIssueData` 已有此分支,见 010 T041)。
+
+## 5. 架构与数据流
+
+### 5.1 下单捕获(`createOrder`)
+
+- `OrderCreateInput` 新增发票意图字段:`invoiceChoice`(PAPER/PHONE_BARCODE/CITIZEN/LOVE_CODE/COMPANY)、`carrierType`、`carrierNum`、`buyerUbn`、`buyerName`(公司名,仅 COMPANY)、`loveCode`、`saveAsDefault`(boolean)。
+- `createOrder` 从 JWT 取 userId → 存订单时把发票意图写入 `pos_order_invoice` 意图行(`issue_triggered=0` 未触发,见 §7;方案B,不再落 pos_order)。
+- 门店守卫:`canInvoice(mdId)==false` → 该单不写发票意图(等价不开票);客户端结账页按 `canInvoice` 接口隐藏发票选项。
+- 载具/捐赠验真(见 §8):不通过则拦截下单。
+- `saveAsDefault=true` → 将该载具/统编抬头写入 `info_invoice` 并标记为默认(其余取消默认)。
+
+### 5.2 商家出餐自动开票(`dispatchOrder`,state 1→2)
+
+- `PosOrderShOprateController.dispatchOrder` 在 `setState(2)` 之后调用 `OrderInvoiceService.autoIssue(orderId)`。
+- `autoIssue` 流程:
+  1. 读 `pos_order_invoice` 发票意图行(`issue_triggered=0`) + 门店 ezPay 凭证(`assertInvoiceable`,复用 010)。
+  2. 门店不可开票 / 订单无发票意图 → **跳过**(不报错,不影响出餐)。
+  3. 组装 `ApplyInvoiceDto`(意图 → category/载具/统编/捐赠);**个人 BuyerName = `InfoUser.phone`**。
+  4. 复用 010 开票核心(`buildIssueData` + `ezPay.issueInvoice` + 落库)。**放宽两点**:不校验客户归属(系统触发)、不强制 `payStatus==1`(货到付款出餐时未付款也要开,见 D9)。
+  5. PrintFlag=Y → 码值(BarCode/QRcodeL/R)落库,供商家打印证明联。
+- 在意图行上落结果(复用 010)并置 `issue_triggered=1`,状态 已开 / 失败(失败由运营在后台重试,复用 010 retry)。
+
+### 5.3 会员发票夹(我的发票)
+
+- 新增 `GET /system/userOrder/myInvoices`(JWT,按 userId 分页)→ `pos_order_invoice i JOIN pos_order o ON i.order_id = o.id WHERE o.user_id = ?`。
+- 返回字段裁剪:PrintFlag=Y → 结构化信息(发票号/随机码/金额/明细/门店)+「纸本已开」,**不返回码图、无补打**;PrintFlag=N → 结构化信息 +「已存入载具/已捐赠」。
+- 客户端 UI 由客户端团队对接。
+
+### 5.4 货到付款兜底(D9)
+
+- 出餐照开(含货到付款)。若送达失败 / 订单取消(state→4)→ 调 `OrderInvoiceService.invalid(orderId)`(复用 010)作废发票。取消链路挂钩点见 §11。
+
+## 6. 关键业务规则
+
+- **零信息兜底**:个人纸本 = B2C + 无载具 + 无捐赠 → PrintFlag=Y(法定强制),BuyerName=`InfoUser.phone`。客户什么都不填也能开。**需补 010 缺的「B2C 无载具→PrintFlag=Y」分支**:现 `validateInvoiceInput` 强制 B2C 有载具(487–520 行),`buildIssueData` B2C 永远 PrintFlag=N(540–548 行)。
+- **证明联一次为限**:财政部规定证明联以输出一次为限。ezPay 技术上不锁(码值可经 `invoice_search` 重复取),但**平台不提供补打 / 不重渲染码图**,守住商家合规线。会员纸本丢失 → 凭发票号 + 随机码兑奖。
+- **PrintFlag 互斥**:载具/捐赠 PrintFlag=N,纸本/公司 PrintFlag=Y;载具不能再印纸本(防一票两份,ezPay 校验拦截)。
+- **BuyerName**:个人 = 手机号;公司 = 公司名(客户填);捐赠 = 机构名(开票时 `resolveLoveOrg` 回填)。
+- **金额**:发票金额 = `amount − freight`(不含运费,沿用 010 research D1)。
+
+## 7. 数据模型变更(SQL 写 `updatesql/sql.md`,不直接执行)
+
+- **方案B**:发票意图(下单捕获)落 `pos_order_invoice` 意图行(与开票结果同表,避免意图/结果双写);`pos_order` **不加** 发票列。
+- `pos_order_invoice` 新增:`invoice_choice VARCHAR(16)`(下单意图 PAPER/PHONE_BARCODE/CITIZEN/LOVE_CODE/COMPANY)、`issue_triggered TINYINT NOT NULL DEFAULT 0`(是否已触发开票,与 invoice_status 独立:下单捕获=0、出餐开票执行过=1;管理端发票管理按 `issue_triggered=1` 筛选,排除未触发的意图行)。`buyer_ubn`/`carrier_type`/`carrier_num`/`love_code` 复用已有列。
+- `info_invoice` 新增 `is_default TINYINT NOT NULL DEFAULT 0 COMMENT '是否默认载具/抬头'`(014 原「无默认」FR-006,现加默认标记支持 D5)。
+
+## 8. 载具校验
+
+| 输入 | 校验方式 | 接入点 |
+|---|---|---|
+| 手機條碜 | ezPay `checkBarCode`(IsExist Y/N) | 结账填入 / 存默认时 |
+| 捐赠码 | ezPay `checkLoveCode`(010 已实现) | 结账填入(从开票前置到下单) |
+| 自然人凭证 | 仅格式正则(`^[A-Z]{2}\d{14}$`),**无 API** | 结账填入 |
+| 公司统编 | 格式正则(`^\d{8}$`) | 结账填入 |
+
+- 新增 `EzPay.checkBarCode(baseUrl, cfg, barCode)` 方法(平行 `checkLoveCode`;`EzPayEncryptUtil` 的 CheckValue/CheckCode 已就绪;`URL_CHECK_BARCODE` 常量已定义)。
+- `checkBarCode` 按门店凭证鉴权 → 结账验真用**该订单门店**的 ezPay 凭证。
+
+## 9. 复用 010 / 009 / 014 的部分(不改)
+
+- 010:`OrderInvoiceService` 开票核心(`buildIssueData`/issue/落库)、作废(`invalid`)、重试(`retry`);`PosOrderInvoice` 实体 + mapper + 码值字段(Phase 9)。
+- 工具类:`EzPay`/`EzPayConfig`/`EzPayEncryptUtil`(`checkLoveCode` 已有,加 `checkBarCode`)。
+- 009:门店 ezPay 凭证 + 免用发票标识(`canInvoice`/`assertInvoiceable`)。
+- 014:`info_invoice` 表 + `InfoInvoiceController` CRUD(复用为默认载具/抬头存储 + 管理入口)。
+
+## 10. 接口变更概览
+
+- `OrderCreateInput`:加发票意图字段。
+- `POST /system/userOrder/createOrder`:存发票意图到 `pos_order`;载具/捐赠验真;`saveAsDefault` 写 `info_invoice`。
+- `PosOrderShOprateController.dispatchOrder`:出餐 `setState(2)` 后调 `autoIssue`。
+- `OrderInvoiceService.autoIssue(orderId)`:新增(复用开票核心,放宽归属/payStatus 校验)。
+- `OrderInvoiceService.validateInvoiceInput` / `buildIssueData`:放宽 B2C(允许无载具)、补「无载具→PrintFlag=Y 纸本」分支、个人 BuyerName 取 `InfoUser.phone`。
+- `EzPay.checkBarCode`:新增。
+- `GET /system/userOrder/myInvoices`:新增(发票夹列表)。
+- `info_invoice` 默认标记:`InfoInvoiceController` 加设默认 / 取默认能力(或前端按 `is_default` 处理)。
+- i18n:结账页发票选项文案属客户端 UI,由客户端团队;后端无新增面向用户文字(沿用 010 FR-012 分工)。
+
+## 11. 开放问题 / 待确认
+
+- **autoIssue 同步 vs 异步**:出餐接口同步调 ezPay 开票会增加出餐耗时(ezPay 响应通常 <2s,可接受);若担心阻塞出餐,改异步(线程池 / MQ)+ 失败由运营后台重试。**倾向同步**(简单,失败有 `pos_order_invoice.status=失败` 可重试),实现阶段定。
+- **货到付款作废触发点**:订单取消(state→4)的入口需确认(商家取消 `PosOrderShOprateController` / 用户取消 `UserOrderController`),在该取消链路挂钩 `invalid`。实现阶段对齐现有取消入口。
+- **`/applyInvoice` 手动入口去留**:自动开票后客户无需手动申请。倾向**保留**作为「自动开票失败时客户可手动重试」的兜底入口(或仅运营后台重试即可,移除客户手动入口)。实现阶段定。
+- **多店合并下单(OrderParent)**:一次下多店,发票按**子订单(pos_order,每店一张)**各自开。`createOrder` 已拆子订单,发票意图随子订单存。实现阶段核实 parent/子订单归属与意图传递。
+
+## 12. 成功标准
+
+- 客户结账选「个人发票→纸本」(零输入)下单 → 商家出餐 → 自动开成个人纸本,码值落库,商家可打印证明联。
+- 客户选「手機條碜」填码 → `checkBarCode` 验真通过 → 出餐自动开成载具发票(PrintFlag=N)。
+- 门店免用发票 / ezPay 未开通 → 结账无发票选项,出餐不开票。
+- 货到付款订单出餐照开;送达失败取消 → 发票作废。
+- 会员在「我的发票」看到本平台开给他的所有发票(纸本看结构化信息、载具看「已存入载具」)。
+- 开发票门店每笔订单 100% 自动开出(不依赖客户手动申请)。
+
+## 13. 后续
+
+本设计审通过后,按项目「追加需求直接更新现有 spec/plan/tasks」习惯,把本设计折叠进 `specs/010-order-invoice/` 的 `spec.md`(新增自动开票 user story / FR)、`plan.md`(本设计)、`tasks.md`(新增任务),再进入实现。

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

@@ -13,6 +13,7 @@
 | order_no | VARCHAR(64) | 冗余 `pos_order.ddId`,便于排查 |
 | store_id | BIGINT NOT NULL | 冗余 `pos_order` 所属门店 id(关联 `pos_store.id`) |
 | invoice_category | VARCHAR(8) | 发票类型:`B2C` / `B2B` |
+| invoice_choice | VARCHAR(16) | 发票选择(下单意图) PAPER/PHONE_BARCODE/CITIZEN/LOVE_CODE/COMPANY; NULL=不开票 |
 | buyer_name | VARCHAR(100) | 买方名称(B2C=客户名/邮箱名,B2B=公司名) |
 | buyer_ubn | VARCHAR(16) | 买方统一编号(统编 8 码,B2B 必填) |
 | buyer_email | VARCHAR(200) | 接收邮箱(B2C 无载具时用) |
@@ -21,6 +22,7 @@
 | invoice_number | VARCHAR(16) | ezPay 发票号(开立成功后填,字轨号段分配) |
 | random_num | VARCHAR(8) | 防伪随机码(ezPay 返回) |
 | invoice_status | TINYINT NOT NULL DEFAULT 0 | `0`未开 / `1`已开 / `2`失败 / `3`作废 |
+| issue_triggered | TINYINT NOT NULL DEFAULT 0 | 是否已触发开票 0否1是;出餐开票执行过=1,与 invoice_status 独立(下单捕获意图=0) |
 | total_amt | INT | 含税总额(= 订单实付 amount) |
 | sales_amt | INT | 销售额(未税,= round(total/1.05)) |
 | tax_amt | INT | 税额(= total - sales) |
@@ -59,6 +61,8 @@
 - 开票前置:`invoice_status ∈ {0 未开, 2 失败, 3 作废}` 才允许发起开票;`1 已开` 拒绝(FR-005)。
 - 重开:作废(3) 后可再次开票 → 状态先置 0、再走开票成功置 1(同行,新 invoice_number 覆盖旧号)。
 
+> **issue_triggered (方案B, 与 invoice_status 独立)**: 下单捕获意图时插 `pos_order_invoice` 行, `issue_triggered=0`、`invoice_status=0`; 出餐开票流程执行过即置 `issue_triggered=1` (无论成功/失败/作废)。管理端「订单发票管理」按 `issue_triggered=1` 筛选, 排除仅下单捕获、未触发的意图行。
+
 ## 复用实体(不改)
 
 - **PosOrder**(`pos_order`):开票来源。取 `id`/`ddId`/`amount`/`food`/`state`(=3 完成)/`payStatus`(=1)/门店归属(`shId`/`mdId`)。门店 id 判定:依据 CLAUDE.md 商家类型口径(普通/夜市商家用 `shId`,摊位用 `mdId`)对齐 `pos_store`。

+ 23 - 0
specs/010-order-invoice/plan.md

@@ -152,3 +152,26 @@ src/lang/{zh,tw,en,vi}.js                    # 四语言加 key
 - SQL:`updatesql/sql.md` 加 pos_order_invoice ALTER 两列(pos_love_org 已在 pos_love_org.sql)
 - 前端:平台后台 `orderInvoice/index.vue` 捐赠展示 + i18n;商家端订单详情捐赠标识 + i18n
 
+## Enhancement: 全联式自动开票 (2026-07-27)
+
+**背景**:010 现为「客户订单完成后手动申请开票」。按全联福利中心(PX Mart)模式演进为「下单选发票类型(默认个人纸本)+ 出餐自动开 + 会员发票夹」。完整设计见 [`auto-invoice-design.md`](./auto-invoice-design.md)(已与需求方多轮确认通过)。
+
+**设计决策摘要**(详见 auto-invoice-design.md):
+
+1. **触发**:商家出餐 `dispatchOrder`(state 1→2)统一开所有档位(纸本赶得上骑手取餐)。
+2. **入口/IA**:下单(结账)时选,顶层 个人发票 / 公司发票 两种;个人默认子项=纸本(零输入),捐赠归在个人发票下(B2C + LoveCode)。
+3. **零信息兜底**:补 010 缺的「B2C 无载具→PrintFlag=Y 纸本」分支(现 `validateInvoiceInput` 强制 B2C 有载具);个人 BuyerName=`InfoUser.phone`。
+4. **载具校验**:新增 `EzPay.checkBarCode`(平行 checkLoveCode;EzPayEncryptUtil/`URL_CHECK_BARCODE` 已就绪),结账验真用门店凭证;捐赠码 checkLoveCode 前置到下单。
+5. **证明联一次为限**:平台不补打/不重渲染码图;发票夹纸本只展示结构化信息(发票号+随机码+明细)。
+6. **货到付款**:出餐照开,订单取消链路挂 `invalid`。
+7. **会员发票夹**:新增 `GET /system/userOrder/myInvoices`(按 user_id JOIN pos_order)。
+8. **双入口/默认**:`info_invoice` 加 `is_default`;复用 014 CRUD,全联式不建单独管理页。
+
+**数据模型**(SQL 写 `updatesql/sql.md`,方案B):`pos_order_invoice` 加 `invoice_choice`(下单意图)+ `issue_triggered`(是否已触发开票,与 invoice_status 独立);`pos_order` 不再加发票列;`info_invoice` 加 `is_default`。
+
+**文件改动**:`OrderCreateInput` + `createOrder`;`PosOrder`(+mapper);`OrderInvoiceService`(`autoIssue` + 补纸本分支 + BuyerName 取手机号 + 放宽校验);`PosOrderShOprateController.dispatchOrder` 挂 `autoIssue`;取消链路挂 `invalid`;`EzPay.checkBarCode`;`UserOrderController` 加 `myInvoices`;`InfoInvoiceController` 默认能力;SQL。
+
+**开放问题**(实现阶段定):autoIssue 同步/异步;货到付款作废触发点对齐;`/applyInvoice` 手动入口去留;多店合并下单子订单发票归属。
+
+**范围边界**:交付后端 + 管理端展示;客户端 App 的结账页/发票夹/抬头管理 UI 由客户端团队(沿用 010 分工)。POS 实体打印不做(商家自打证明联)。
+

+ 42 - 0
specs/010-order-invoice/spec.md

@@ -174,3 +174,45 @@
 - 捐赠发票 PrintFlag=N:ezPay 不返回 BarCode/QRcode 码图,遵循 `reference-tw-einvoice-concepts` 的码图规则(仅 PrintFlag=Y 才有码图)。
 - 发票金额不含运费:运费由用户承担(付给配送方),非商家商品收入;商家发票仅就商品销售开立。订单构成 `amount = 商品 − 各项优惠 + 运费`,故发票金额 = `amount − 运费`,各类优惠已含在 amount 内无需逐项区分。
 - 四语言 i18n 覆盖所有新增面向用户文字,遵循项目既有 i18n 规范。
+
+---
+
+## 演进:全联式自动开票 (2026-07-27)
+
+> 完整设计见 [`auto-invoice-design.md`](./auto-invoice-design.md)。010 已建的 B2C/B2B/载具/捐赠/作废/重试/码值落库**全部复用**,仅改触发(手动→出餐自动)、入口(完成后申请→下单捕获),并加零信息纸本兜底 + 会员发票夹。
+
+### User Story 7 - 全联式下单选发票 + 出餐自动开 (Priority: P1)
+
+客户下单(结账)时选发票类型,顶层只有「个人发票 / 公司发票」两种,个人发票默认选中、默认子项=纸本(零输入)。商家出餐时系统按所选类型自动开票;纸本证明联商家打印、骑手送餐时一并交付。零信息客户(没设载具)也能开——默认个人纸本,BuyerName 取手机号。
+
+**Independent Test**: 客户结账选「个人发票→纸本」(不填任何信息)下单 → 商家点出餐 → 系统自动开出个人纸本发票,码值落库,商家可打印证明联。
+
+### User Story 8 - 会员发票夹 (Priority: P1)
+
+会员在「我的发票」看到本平台开给他的所有发票:纸本发票看结构化信息(发票号/随机码/金额/明细)+「纸本已开」,载具/捐赠发票看「已存入载具/已捐赠」。平台不提供纸本补打/重渲染码图(证明联一次为限)。
+
+### 新增功能需求(FR)
+
+- **FR-017**: 下单(`createOrder`)时捕获发票意图(类型 + 载具/统编/捐赠码 + saveAsDefault),存 `pos_order_invoice` 意图行(`issue_triggered=0` 未触发;方案B,不再落 pos_order)。
+- **FR-018**: 商家出餐(`dispatchOrder`,state 1→2)时对开发票门店自动开票;免用发票/未开通门店跳过。
+- **FR-019**: 个人纸本零信息兜底——B2C 无载具无捐赠 → PrintFlag=Y,BuyerName=`InfoUser.phone`,客户不填任何信息也能开。
+- **FR-020**: 载具/捐赠验真前置到下单:手機條碜调 `checkBarCode`、捐赠码调 `checkLoveCode`,验真不通过拦截下单;自然人凭证仅格式校验(无 API)。
+- **FR-021**: 货到付款出餐照开;订单取消(state→4)→ 作废发票(复用 invalid)。
+- **FR-022**: 会员发票夹——按 user_id 列出发票;纸本只展示结构化信息,不返回码图、无补打。
+- **FR-023**: 发票信息双入口——结账时设 + 「我的发票抬头」管理(复用 014 `info_invoice`);`info_invoice` 加「是否默认」标记;默认/常用只对载具与统编抬头生效,捐赠不存。
+
+## 演进:会员载具入结账 (2026-07-30)
+
+> 业务把会员载具(载具类型 2)做上结账选项。载具号=会员手机号(结账零输入,出餐时取 `InfoUser.phone`),**不收 BuyerEmail**(业务方确认 ezPay 接受手机号作 type=2 载具值;BuyerEmail 仅用于 ezPay 发开立通知邮件,会员载具不要通知故不收)。会员载具属一般性/营业人载具,PrintFlag=N、不返回码值,客户去财政部全民稽核查(法规与载具概念见记忆 tw-einvoice-concepts)。
+
+### 增量改动(后端,已实现 2026-07-30)
+- `invoiceChoice` 枚举新增 `MEMBER`(原 PAPER/PHONE_BARCODE/CITIZEN/LOVE_CODE/COMPANY)。
+- 结账校验 `validateCheckoutInvoiceIntent`:MEMBER 免联网验真、免格式校验(载具号非客户输入)。
+- 捕获 `createOrder`:MEMBER → `carrierType=2` 落意图行。
+- 出餐自动开 `buildAutoIssueDto` + `autoIssue`:MEMBER → category=B2C、carrierType=2、载具号=会员手机号(`InfoUser.phone`)、BuyerName=手机号;同步落库载具信息。
+- `validateInvoiceInput`:去掉 type=2 必填邮箱(applyInvoice 补开链路同步对齐)。
+- `saveDefaultInvoiceProfile`:MEMBER 不存抬头(=手机号,无需记忆)。
+- **不需 SQL**(invoiceChoice/carrierType/carrierNum 字段已存在)。
+
+### 待办
+- 客户点餐 app(独立仓库,非 foodie_server)结账发票选择器加「会员载具」选项 + 4 份 i18n(zh/tw/en/vi);仓库路径待确认。

+ 25 - 0
specs/010-order-invoice/tasks.md

@@ -255,3 +255,28 @@ description: "Task list for 订单 ezPay 电子发票开立"
 - 前端两个仓库均为 CRLF,编辑 lang 文件用 Python 脚本(见 CLAUDE.md「前端文件编辑注意事项」)
 - 所有前端新增文字必须四语言 i18n,key 加到正确对象层级、驼峰命名
 - SQL 不直接执行,写 `updatesql/sql.md` 由开发者手动跑
+
+---
+
+## Phase 11: 全联式自动开票 (2026-07-27)
+
+**Goal**: 下单捕获发票意图 + 出餐自动开 + 零信息纸本兜底 + 会员发票夹 + 载具校验 + 默认载具
+**Design**: [`auto-invoice-design.md`](./auto-invoice-design.md)
+
+### Implementation
+
+- [x] T048 [P] SQL: `updatesql/sql.md` 追加 2026-07-28 段(方案B)——① `pos_order_invoice` ADD COLUMN `invoice_choice` + `issue_triggered`(下单意图 + 是否已触发开票);② `pos_order` 6 发票意图列废弃(若初版已执行则 DROP 回滚);③ `info_invoice` ADD COLUMN `is_default`
+- [ ] T049 [P] `OrderCreateInput.java` 加发票意图字段(invoiceChoice/carrierType/carrierNum/buyerUbn/buyerName/loveCode/saveAsDefault)
+- [x] T050 [P] 方案B:`PosOrderInvoice.java` 加 `invoiceChoice` + `issueTriggered`(+ VO + mapper SELECT/WHERE);`PosOrder.java` **不加** 发票字段(意图落 pos_order_invoice)
+- [ ] T051 [P] `InfoInvoice.java`(014 实体)加 `isDefault` 字段 + mapper
+- [ ] T052 [P] `EzPay.checkBarCode(baseUrl, cfg, barCode)`:平行 `checkLoveCode`,封装 BDV `/Api_inv_application/checkBarCode`,回 IsExist。依赖 `EzPayEncryptUtil`(CheckValue 已就绪)、`URL_CHECK_BARCODE` 常量
+- [x] T053 `UserOrderController.createOrder`:存发票意图到 `pos_order_invoice` 意图行(`issue_triggered=0`,经 `OrderInvoiceService.captureInvoiceIntent`);门店守卫(`canInvoice=false` 不插意图);`saveAsDefault=true` 写 info_invoice 标记默认;载具/捐赠验真前置、不通过拦截下单。依赖 T049/T050/T052
+- [ ] T054 `OrderInvoiceService.validateInvoiceInput`/`buildIssueData`:放宽 B2C(允许无载具)、补「B2C 无载具→PrintFlag=Y 纸本」分支、个人 BuyerName 取 `InfoUser.phone`(`infoUserService` 已注入)
+- [x] T055 `OrderInvoiceService.autoIssue(orderId)`:新增——读 `pos_order_invoice` 发票意图行 + `assertInvoiceable`;不可开票/无意图跳过;组装 `ApplyInvoiceDto`;复用开票核心(buildIssueData+issue+落库),开票后置 `issue_triggered=1`;放宽归属/payStatus 校验。依赖 T054
+- [ ] T056 `PosOrderShOprateController.dispatchOrder`:`setState(2)` 后调 `orderInvoiceService.autoIssue(orderId)`。依赖 T055
+- [x] T057 货到付款/取消兜底:**核查后无需改代码**——商家取消仅允许 state∈{0,1}、用户取消仅 state=0,都在出餐(state→2)之前;而发票出餐时才开,故标准取消路径下发票尚未开、无发票可作废,挂 invalid 是死代码。货到付款"送达被拒收"走售后/退款流程(不经这两个 cancel 入口),作废 hook 留待该流程接入时再补,本期不做。
+- [x] T058 [P] `UserOrderController` 加 `GET /myInvoices`:JWT,按 userId 分页,`pos_order_invoice` JOIN `pos_order`,字段裁剪(不返回码图)。依赖 —
+- [x] T059 [P] 默认载具:**014 list 接口已返回 `isDefault`(T051 加的字段),客户端据此选常用载具/识别默认,无需新增后端接口**。
+- [x] T060 [P] i18n:结账页发票选项文案属客户端 UI(客户端团队);**后端无新增面向用户文字**,本期后端 i18n no-op。
+
+**Checkpoint**: 已完成订单(开发票门店)→ 出餐自动开出个人纸本(零信息)→ 码值落库 → 商家可打印;选手機條碜下单 → checkBarCode 验真 → 出餐自动开载具发票;会员 `/myInvoices` 看到发票。

+ 120 - 0
specs/014-invoice-profile/contracts/api.md

@@ -0,0 +1,120 @@
+# API Contracts: 用户发票抬头管理
+
+**Date**: 2026-07-24
+
+后端 REST 接口契约。挂在 `InfoInvoiceController`(`@RequestMapping("/system/invoice")`),风格镜像 `InfoAddressController`(`@Anonymous @Auth` + `@RequestHeader String token` + `JwtUtil.getusid(token)` 取 userId)。实体 `InfoInvoice` 即请求体 / 响应体(无专门 DTO/VO,与 InfoAddress 一致)。
+
+> 字段格式 / 必填由 jakarta 校验注解(`@NotBlank`/`@Pattern`/`@Email`)标在 `InfoInvoice` 实体上,springfox 自动识别为 Swagger 的 required/pattern(前端在 Knife4j `/doc.html` 可见字段约束);接口与字段完整含义、示例、按类型的必填条件见本文件。Controller 另用 `@Api`/`@ApiOperation` 给出接口描述。
+> 说明:实体在 `ruoyi-system` 模块(无 swagger 依赖),故字段级描述走校验注解 + 本文档,与 010 `ApplyInvoiceDto` 同一约定。
+
+## 核心约定:按 category 区分两套字段
+
+`category` 是类型区分依据(新增/修改的入参分支、列表渲染分支都以它为准):
+
+| category | 含义 | 必传字段 | 不传字段 |
+|---|---|---|---|
+| **B2C** | 个人发票 | titleName、buyerName、carrierType、carrierNum(载具=2 时再加 buyerEmail) | buyerUbn |
+| **B2B** | 公司发票 | titleName、buyerName、buyerUbn、buyerEmail | carrierType、carrierNum |
+
+---
+
+## App 端接口(JWT 鉴权,userId 强制隔离)
+
+### GET `/system/invoice/getinvoice`
+
+列出当前用户全部抬头(按 id 倒序)。
+
+**入参**:`@RequestHeader String token`。
+
+**响应** `AjaxResult` data:`List<InfoInvoice>`。**前端按每行的 `category` 区分类型并渲染对应字段**:
+
+| 字段 | B2C 行 | B2B 行 |
+|---|---|---|
+| `category` | `"B2C"` | `"B2B"` ← 类型判别字段 |
+| `titleName` | 有 | 有 |
+| `buyerName` | 个人姓名 | 公司名 |
+| `buyerUbn` | `null`(不展示) | 统编(展示) |
+| `buyerEmail` | 载具=2 时有值,否则 `null` | 邮箱(展示) |
+| `carrierType` | `0`/`1`/`2`(展示载具) | `null`(不展示) |
+| `carrierNum` | 载具号码(展示) | `null`(不展示) |
+
+> 渲染建议:列表项以 `titleName` 为主标题,副标题按 category 走——B2B 显示「公司名 · 统编」,B2C 显示「姓名 · 载具类型描述」。
+
+### POST `/system/invoice/invoice`
+
+新增或更新(`saveOrUpdate`:有 id 走更新、无 id 走新增)。
+
+**入参**:`@RequestHeader String token` + `@RequestBody InfoInvoice`(字段格式由 `@Valid` 先校验、条件必填与类型互斥由 service 强校验)。
+
+**完整字段表(前端据此传参)**
+
+| 字段 | 类型 | 含义 | 格式 / 示例 | B2C | B2B |
+|---|---|---|---|---|---|
+| id | Long | 主键 | 新增不传;修改必传(`101`) | 同左 | 同左 |
+| titleName | String | 抬头备注名 | 任意,`公司-美食達` | ✅必填 | ✅必填 |
+| category | String | 发票类型 | `B2C` / `B2B` | ✅必填=`B2C` | ✅必填=`B2B` |
+| buyerName | String | 买方名称 | B2C 个人姓名 / B2B 公司名 | ✅必填 | ✅必填 |
+| buyerUbn | String | 统一编号 | 8 位数字 `12345678` | ❌不传 | ✅必填 |
+| buyerEmail | String | 邮箱 | `ms@example.com` | ⚪载具=2 时必填,其余可空 | ✅必填 |
+| carrierType | String | 载具类型 | `0`手机条码/`1`自然人凭证/`2`ezPay会员 | ✅必填 | ❌不传 |
+| carrierNum | String | 载具号码 | 随 carrierType(见下) | ✅必填 | ❌不传 |
+| userId | Long | 用户id | 后端按 token 自动填,**前端不传** | — | — |
+
+**载具号码格式(carrierNum,随 carrierType)**
+
+- `0` 手机条码:以 `/` 开头,如 `/ABC1234`
+- `1` 自然人凭证:`2 位大写字母 + 14 位数字`,如 `AB12345678901234`
+- `2` ezPay 会员载具:会员账号(非空),且须同时带 `buyerEmail`
+
+**处理**:① `getusid(token)` → `setUserId` 强制覆盖(防越权);② `@Valid` 字段格式校验;③ `validateInvoiceProfile` 条件必填 + 类型互斥强校验;④ 更新时校验该 id 属于当前 user;⑤ `saveOrUpdate`。
+
+**响应**:成功 `{code:200, data:id}`;校验失败 `{code:500, msg:"<具体原因>"}`。
+
+**请求示例**
+
+B2C / 手机条码载具:
+```json
+{ "titleName": "个人-手机条码", "category": "B2C", "buyerName": "王小明", "carrierType": "0", "carrierNum": "/ABC1234" }
+```
+
+B2C / ezPay 会员载具(须带邮箱):
+```json
+{ "titleName": "个人-会员载具", "category": "B2C", "buyerName": "王小明", "carrierType": "2", "carrierNum": "C1597485444", "buyerEmail": "xm@example.com" }
+```
+
+B2B 公司:
+```json
+{ "titleName": "公司-美食達", "category": "B2B", "buyerName": "美食達有限公司", "buyerUbn": "12345678", "buyerEmail": "ms@example.com" }
+```
+
+修改(带 id,覆盖该条):
+```json
+{ "id": 101, "titleName": "公司-美食達", "category": "B2B", "buyerName": "美食達有限公司", "buyerUbn": "12345678", "buyerEmail": "ms@example.com" }
+```
+
+### GET `/system/invoice/getinvoicexq?id=`
+
+详情;越权(id 不属于当前 user)返回 `data: null`。
+
+**响应** `AjaxResult` data:单条 `InfoInvoice`(字段同列表单行)。
+
+### GET `/system/invoice/deleinvoice?id=`
+
+删除(硬删除);越权 / 不存在返回失败 `{code:500, msg:"抬头不存在或无权操作"}`。
+
+---
+
+## 保存校验矩阵(service `validateInvoiceProfile`)
+
+| 场景 | category | buyerName | buyerUbn | buyerEmail | carrierType + carrierNum |
+|------|----------|-----------|----------|------------|--------------------------|
+| B2C / 手机条码(0) | B2C | ✅必填 | ❌空 | ⚪可选 | ✅必填(0 + 号以 `/` 开头) |
+| B2C / 自然人凭证(1) | B2C | ✅必填 | ❌空 | ⚪可选 | ✅必填(1 + `^[A-Z]{2}\d{14}$`) |
+| B2C / ezPay 会员(2) | B2C | ✅必填 | ❌空 | ✅必填 | ✅必填(2 + 号非空) |
+| B2B 公司 | B2B | ✅必填(公司名) | ✅必填(`^\d{8}$`) | ✅必填(邮箱格式) | ❌空 |
+
+通用:`titleName` 非空;`category ∈ {B2C, B2B}`;`buyerEmail` 若填则校验邮箱格式。
+
+## 与 010 开票的对接(客户端驱动,不改后端开票)
+
+客户端开票时:① `GET /system/invoice/getinvoice` 拉抬头;② 用户选一条 → 客户端按字段映射填入 `ApplyInvoiceDto`(category / buyerName / buyerUbn / buyerEmail / carrierType / carrierNum)→ ③ `POST /system/userOrder/applyInvoice`(010,不改)。抬头仅作输入快捷方式,010 仍做最终校验兜底。

+ 36 - 0
specs/014-invoice-profile/data-model.md

@@ -0,0 +1,36 @@
+# Data Model: 用户发票抬头管理
+
+**Date**: 2026-07-24
+
+## 新增表:info_invoice(用户发票抬头,用户级,镜像 info_address)
+
+> DDL 写入 `updatesql/sql.md`,由开发者手动执行(项目规范)。用户级数据用 `info_` 前缀(与 `info_address` / `info_user` 一致),区别于订单级 `pos_order_invoice`。
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| id | BIGINT PK AUTO | 主键 |
+| user_id | BIGINT NOT NULL | 用户id(JWT 隔离;写库前强制以 token 解析值覆盖) |
+| title_name | VARCHAR(50) NOT NULL | 抬头备注名(如「公司-美食達」「个人-手机条码」),客户端列表展示用 |
+| category | VARCHAR(8) NOT NULL | 类型:`B2C`=个人 / `B2B`=公司 |
+| buyer_name | VARCHAR(100) NOT NULL | 买方名称:B2C=个人姓名 / B2B=公司全名 |
+| buyer_ubn | VARCHAR(16) | 统一编号(统编,8 位数字,含前导零须字符串);**仅 B2B** |
+| buyer_email | VARCHAR(200) | 接收邮箱;B2B 必填 / B2C 仅 ezPay 会员载具(2) 时必填 |
+| carrier_type | VARCHAR(8) | 载具类型 `0`手机条码 / `1`自然人凭证 / `2`ezPay 会员;**仅 B2C** |
+| carrier_num | VARCHAR(64) | 载具号码(随 carrier_type) |
+| create_time | DATETIME DEFAULT CURRENT_TIMESTAMP | 创建时间(DB 自动) |
+| update_time | DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | 更新时间(DB 自动) |
+
+> 不设 `create_by` / `update_by`:App 端走 JWT(非 ruoyi 登录用户名),无意义字段,从简。`create_time` / `update_time` 由 DB `DEFAULT` / `ON UPDATE` 自动维护,代码不手动赋值。
+
+**索引**:`KEY idx_user (user_id)`(用户隔离查询)。
+
+**约束约定**(应用层校验,非 DB 约束):
+
+- 无默认抬头列、无数量上限、硬删除(与 `info_address` 一致)。
+- B2B ↔ 载具字段互斥:B2B 时 `carrier_type` / `carrier_num` 必空;B2C 时 `buyer_ubn` 必空。
+- 完整保存校验矩阵见 `contracts/api.md`。
+
+## 复用实体(不改)
+
+- **InfoUser**(`info_user`):提供 userId(JWT claim `id` → `JwtUtil.getusid`)。
+- **010 `ApplyInvoiceDto` / `PosOrderInvoice`**:不改。开票时客户端从选中抬头取字段填入 `ApplyInvoiceDto`,开票链路完全不动。

+ 42 - 0
specs/014-invoice-profile/plan.md

@@ -0,0 +1,42 @@
+# Implementation Plan: 用户发票抬头管理
+
+**Date**: 2026-07-24
+
+## 定位与决策
+
+- **镜像 `InfoAddress`**(收货地址)用户级 CRUD 模式:实体即 DTO/VO、JWT userId 隔离、App 端接口风格(`@Anonymous @Auth` + token header)、无默认 / 无上限 / 硬删除。
+- **仅 App 端 CRUD**:不建后台端接口、不建 sys_menu、不建 Vue 页面、无 i18n(按需求决策,YAGNI)。
+- **与 010 解耦**:不改 010 `applyInvoice` / `getInvoice`;抬头是纯输入辅助,开票时客户端选用预填。
+- **校验在 service 层**(`validateInvoiceProfile`),中校验力度:必填 + 统编 8 位 + 载具正则。
+- **捐赠(DONATION)不存为抬头**;B2C 必须有载具(对齐 010 当前规则)。
+
+## 文件落点
+
+| 层 | 路径 |
+|---|---|
+| 实体 | `ruoyi-system/src/main/java/com/ruoyi/system/domain/InfoInvoice.java` |
+| Mapper 接口 | `ruoyi-system/src/main/java/com/ruoyi/system/mapper/InfoInvoiceMapper.java` |
+| Mapper XML | `ruoyi-system/src/main/resources/mapper/system/InfoInvoiceMapper.xml` |
+| Service 接口 | `ruoyi-system/src/main/java/com/ruoyi/system/service/IInfoInvoiceService.java` |
+| Service 实现 | `ruoyi-system/src/main/java/com/ruoyi/system/service/impl/InfoInvoiceServiceImpl.java` |
+| Controller | `ruoyi-admin/src/main/java/com/ruoyi/app/order/InfoInvoiceController.java` |
+| SQL | `updatesql/sql.md`(追加 2026-07-24 段) |
+
+> Mapper XML 放 `mapper/system/`(与 `InfoAddressMapper.xml` 同位),非 `mapper/chanting/`(那是 pos_ 业务表)。
+
+## 分阶段
+
+1. **Setup**:DDL 写 `updatesql/sql.md`(开发者手动执行)。
+2. **Foundational**:实体 + Mapper(+XML) + Service(+校验)。
+3. **Controller**:App 端 4 接口。
+4. **Polish**:curl 手测(B2C / B2B / 校验 / 越权)+ 更新记忆索引。
+
+## 测试策略
+
+轻量。无单测强制;按 `contracts/api.md` 用 curl/Postman 手测 4 条接口 + 校验矩阵各分支 + 越权拒绝。
+
+## 风险与注意
+
+- `InfoAddressServiceImpl` 的 `ServiceImpl` 泛型用的是 `BaseMapper<InfoAddress>`(历史遗留);新建 `InfoInvoiceServiceImpl` 时用具体 `ServiceImpl<InfoInvoiceMapper, InfoInvoice>`。
+- `@Auth` 来源 `com.ruoyi.system.utils.Auth`、`@Anonymous` 来源 `com.ruoyi.common.annotation.Anonymous`、`JwtUtil` 来源 `com.ruoyi.system.utils.JwtUtil`(均照搬 InfoAddressController)。
+- 后端文件亦为 CRLF(见 memory `project-build-env`);新建文件用项目默认换行即可,编辑既有文件注意 CRLF。

+ 91 - 0
specs/014-invoice-profile/spec.md

@@ -0,0 +1,91 @@
+# Feature Specification: 用户发票抬头管理(发票信息管理)
+
+**Feature Branch**: 不新建分支(当前 test 分支开发)
+
+**Created**: 2026-07-24
+
+**Status**: Draft
+
+**Input**: User description: "开发票时用户每次都要手填手机码(载具)等信息,做一个像收货地址管理一样的功能,让用户保存常用发票抬头、开票时直接选用、不必重填。"
+
+## 背景与定位
+
+010(订单 ezPay 发票开立)已实现订单级即时开票,客户每次开票需在 `ApplyInvoiceDto` 手填买方名称 / 统编 / 邮箱 / 载具等。本期新增**用户级发票抬头 CRUD**(镜像 `InfoAddress` 收货地址管理模式),客户保存常用 B2C / B2B 抬头,开票时由客户端选用并预填,**后端开票链路(010)完全不改**。
+
+范围 = **后端 App 端 CRUD 接口**;客户端 App 的「我的抬头」管理页 + 订单开票时的「选用抬头」入口由客户端团队后续对接(沿用 010 分工)。本期无任何前端页面,故无 i18n 改动。
+
+## User Scenarios & Testing *(mandatory)*
+
+### User Story 1 - 客户管理个人(B2C)发票抬头 (Priority: P1)
+
+客户在 App 新增 / 查看 / 编辑 / 删除个人发票抬头,含姓名 + 载具(手机条码 / 自然人凭证 / ezPay 会员,必填)+ 邮箱(会员载具时必填)。
+
+**Why this priority**: 个人发票是外卖/餐饮场景最高频的开票类型,与 B2B 同为核心 CRUD 路径。
+
+**Independent Test**: 调 `POST /system/invoice/invoice`(B2C + 手机条码载具)→ 成功 → `GET /getinvoice` 列出该条 → `GET /deleinvoice?id=` 删除成功。
+
+**Acceptance Scenarios**:
+
+1. **Given** 客户带 token,**When** 提交 B2C 抬头(姓名 + 手机条码载具),**Then** 保存成功,列表/详情可见。
+2. **Given** 客户提交 B2C 抬头但未带载具,**When** 提交,**Then** 校验拦截、不入库。
+3. **Given** 客户带他人抬头 id,**When** 调详情/改/删,**Then** 因 userId 不匹配被拒绝。
+
+---
+
+### User Story 2 - 客户管理公司(B2B)发票抬头 (Priority: P1)
+
+客户保存公司抬头,含公司名 + 统编(8 位)+ 邮箱。
+
+**Why this priority**: B2B 报账是刚需(统编标配),与个人开票同为核心路径。
+
+**Independent Test**: `POST /invoice`(B2B + 公司名 + 合法统编 + 邮箱)→ 成功;非法统编 → 拦截。
+
+**Acceptance Scenarios**:
+
+1. **Given** 客户提交 B2B 抬头(公司名 + 合法统编 + 邮箱),**When** 提交,**Then** 保存成功。
+2. **Given** 客户填了非 8 位数字的统编,**When** 提交,**Then** 校验拦截、不入库。
+
+---
+
+### Edge Cases
+
+- **越权**:用户 A 带用户 B 抬头 id 调详情/改/删 → 拒绝(userId 不匹配,返回空或失败)。
+- **载具号格式非法**(手机条码不以 `/` 开头、自然人凭证非 `2 字母 + 14 数字`)→ 保存时拦截。
+- **类型互斥**:B2B 带载具字段、或 B2C 带统编 → 校验拒绝(载具仅 B2C、统编仅 B2B)。
+- **token 伪造 userId**:写库前以 JWT 解析的 userId 强制覆盖,前端传的 userId 无效。
+- **删除/改不存在的 id**:返回未命中,不抛异常。
+- **ezPay 会员载具(2) 未带邮箱**:B2C 选会员载具时邮箱必填,未带则拦截(对齐 010 规则)。
+
+## Requirements *(mandatory)*
+
+### Functional Requirements
+
+- **FR-001**: 提供客户 App 端接口(JWT 鉴权、userId 隔离)对发票抬头做新增 / 查列表 / 查详情 / 改 / 删。
+- **FR-002**: 抬头按 `category`(`B2C` / `B2B`)区分;字段随类型(见 data-model.md、contracts/api.md 校验矩阵)。
+- **FR-003**: 保存时**中校验**——必填非空 + B2B 统编 `^\d{8}$` + 载具号码按类型正则(手机条码以 `/` 开头、自然人凭证 `^[A-Z]{2}\d{14}$`、ezPay 会员非空)+ 邮箱格式。
+- **FR-004**: 用户隔离靠 JWT:`JwtUtil.getusid(token)` 取 userId,写库前 `setUserId` 强制覆盖、查/改/删按 `user_id` 过滤;越权操作拒绝。
+- **FR-005**: 不改 010 的 `applyInvoice` / `getInvoice`;抬头仅为客户端开票时的输入快捷方式。
+- **FR-006**: 无默认抬头、无数量上限、硬删除(与 InfoAddress 一致)。
+- **FR-007**: 所有 SQL 变更写入 `updatesql/sql.md`,不直接执行(项目规范)。
+
+### Key Entities
+
+- **发票抬头(新增,用户级,`info_invoice`)**:titleName / category / buyerName / buyerUbn / buyerEmail / carrierType / carrierNum + userId + 审计列。
+- **InfoUser(已有)**:userId 来源(JWT claim `id`)。
+- **010 `ApplyInvoiceDto`(不改)**:开票时客户端从选中抬头取字段填入。
+
+## Success Criteria *(mandatory)*
+
+- **SC-001**: 客户能通过 App 端接口完整增删改查自己的 B2C / B2B 抬头。
+- **SC-002**: 保存时格式非法(统编非 8 位、载具号不合规、必填空、类型互斥)100% 被拦截、不入库。
+- **SC-003**: 用户 A 100% 无法查 / 改 / 删用户 B 的抬头(userId 隔离)。
+- **SC-004**: 客户端能拉抬头列表(接口就绪),开票时选用预填现有 `ApplyInvoiceDto`(UI 由客户端团队对接)。
+
+## Assumptions
+
+- 镜像 `InfoAddress`(收货地址)用户级 CRUD 模式:实体即 DTO/VO、JWT 隔离、无默认、无上限、硬删除、App 端接口风格(`@Anonymous @Auth` + `@RequestHeader token`)。
+- 客户端 App「我的抬头」管理页 + 订单开票时的「选用抬头」入口由客户端团队后续对接,不在本期;本期无任何前端页面,故无 i18n 改动。
+- B2C 抬头必须有载具(对齐 010 当前 `applyInvoice` 规则:B2C 载具必填,0/1/2 三选一;ezPay 会员载具(2) 还须带邮箱)。
+- 捐赠(DONATION)不作为可存抬头类型(按需求决策);捐赠仍走开票时现选。
+- 抬头是纯输入辅助:开票仍由 010 `applyInvoice` 接收显式字段并做最终校验兜底,即使抬头存了也会在开票时再校验一次。
+- 仅交付后端 App 端 CRUD;不建后台端管理接口、不建 sys_menu、不建任何 Vue 页面(按需求决策,YAGNI)。

+ 62 - 0
specs/014-invoice-profile/tasks.md

@@ -0,0 +1,62 @@
+---
+
+description: "Task list for 用户发票抬头管理"
+
+---
+
+# Tasks: 用户发票抬头管理
+
+**Input**: Design documents from `/specs/014-invoice-profile/`
+
+**Prerequisites**: spec.md ✅, data-model.md ✅, contracts/api.md ✅, plan.md ✅
+
+**范围**: 仅后端 App 端 CRUD(实体 / Mapper / Service / Controller + SQL)。无前端、无 i18n、无后台端。
+
+**测试**: 轻量手测(curl/Postman),按 contracts/api.md。
+
+## Format: `[ID] [P?] Description`
+
+- **[P]**: 可并行(不同文件、无未完成依赖)
+
+## Path Conventions
+
+- 实体 / Mapper / Service:`ruoyi-system/src/main/java/com/ruoyi/system/...`
+- Controller:`ruoyi-admin/src/main/java/com/ruoyi/app/order/InfoInvoiceController.java`
+- SQL:`updatesql/sql.md`
+
+---
+
+## Phase 1: Setup
+
+- [x] T001 在 `updatesql/sql.md` 追加 2026-07-24 段:`CREATE TABLE info_invoice`(字段 / 类型 / 注释见 data-model.md,含 `KEY idx_user(user_id)`,引擎 InnoDB / utf8mb4)。无 sys_menu(本期无后台端)。
+
+---
+
+## Phase 2: Foundational
+
+- [x] T002 [P] 新建 `InfoInvoice` 实体(`ruoyi-system/.../domain/InfoInvoice.java`):`@Data @TableName("info_invoice") @EqualsAndHashCode(callSuper=false)`,`@TableId(type=IdType.AUTO)`;字段 id / userId / titleName / category / buyerName / buyerUbn / buyerEmail / carrierType / carrierNum / createTime / updateTime(注释对齐 data-model;create_time/update_time 由 DB DEFAULT/ON UPDATE 自动维护,无 createBy/updateBy)。参考 `InfoAddress.java` 风格。
+- [x] T003 [P] 新建 `InfoInvoiceMapper` 接口(`ruoyi-system/.../mapper/InfoInvoiceMapper.java`)继承 `BaseMapper<InfoInvoice>`,**空体无自定义方法、无 XML**——App 端仅用 MyBatis-Plus `IService` CRUD(list/saveOrUpdate/getById/removeById,字段驼峰自动映射 info_invoice 列);本期无后台端,故无需自定义查询。
+- [x] T004 新建 `IInfoInvoiceService`(`ruoyi-system/.../service/IInfoInvoiceService.java`,继承 `IService<InfoInvoice>`,方法 listByUserId / saveOrUpdateMine / getMine / deleteMine)+ `InfoInvoiceServiceImpl`(`.../service/impl/InfoInvoiceServiceImpl.java`,`extends ServiceImpl<InfoInvoiceMapper, InfoInvoice>`)。实现私有 `validateInvoiceProfile(InfoInvoice)`(按 contracts 校验矩阵:titleName / buyerName 非空、category ∈ {B2C,B2B}、B2B 统编 `^\d{8}$` + 邮箱必填 + 载具空、B2C 载具按类型正则 + 统编空 + 邮箱仅会员载具必填),非法抛 `ServiceException`。`saveOrUpdateMine` 内强制 setUserId + 校验 + 更新时归属校验;`getMine`/`deleteMine` 越权返回 null/false。依赖 T002、T003。
+
+**Checkpoint**: 表 + 实体 + mapper + service(含校验)就绪。
+
+---
+
+## Phase 3: Controller(App 端)
+
+- [x] T005 新建 `InfoInvoiceController`(`ruoyi-admin/.../app/order/InfoInvoiceController.java`,`@RequestMapping("/system/invoice")`,extends `BaseController`,注入 `IInfoInvoiceService`):① `GET /getinvoice`(`@Anonymous @Auth` + `@RequestHeader token` → `getusid` → `QueryWrapper.eq("user_id")` → list);② `POST /invoice`(token + `@RequestBody InfoInvoice` → `setUserId` 强制覆盖 → `validateInvoiceProfile` → 更新时校验归属 → `saveOrUpdate`);③ `GET /getinvoicexq?id=`(getById + 归属校验);④ `GET /deleinvoice?id=`(归属校验 → removeById)。注解 / 工具类照搬 `InfoAddressController`。依赖 T004。
+
+**Checkpoint**: 4 接口可用,curl 跑通。
+
+---
+
+## Phase 4: Polish
+
+- [ ] T006 [P] 按 contracts/api.md 手测:B2C(三种载具)/ B2B 保存 → 列表 → 详情 → 改 → 删;校验矩阵各非法分支(统编非 8 位、手机条码不以 `/` 开头、自然人凭证格式错、B2B 带载具、B2C 带统编、必填空)均拦截;越权(A 操作 B 的 id)拒绝。
+- [x] T007 [P] 更新记忆索引 `MEMORY.md`:加一行指向 `specs/014-invoice-profile/spec.md`(参考 010 写法)。
+
+---
+
+## Dependencies & Execution Order
+
+- Phase 1 Setup → Phase 2 Foundational(T002 / T003 可并行,T004 依赖二者)→ Phase 3 Controller(T005 依赖 T004)→ Phase 4 Polish(T006 依赖 T005,T007 随时)。

+ 37 - 0
specs/015-intl-flight/checklists/requirements.md

@@ -0,0 +1,37 @@
+# Specification Quality Checklist: 国际机票(盘合 iFlight 分销接入)
+
+**Purpose**: Validate specification completeness and quality before proceeding to planning
+**Created**: 2026-07-29
+**Feature**: [spec.md](../spec.md)
+
+## Content Quality
+
+- [x] No implementation details (languages, frameworks, APIs) —— 保持在业务级
+- [x] Focused on user value and business needs
+- [x] Written for non-technical stakeholders
+- [x] All mandatory sections completed
+
+## Requirement Completeness
+
+- [ ] No [NEEDS CLARIFICATION] markers remain —— 当前保留 FR-009~FR-012(D1/D2/D4/D5),待用户拍板
+- [x] Requirements are testable and unambiguous(已定部分可测)
+- [x] Success criteria are measurable
+- [x] Success criteria are technology-agnostic
+- [ ] All acceptance scenarios are defined —— 范围未定(D1),仅按「假设全流程」写
+- [ ] Edge cases are identified —— 列了主要项,待范围确认后补充
+- [ ] Scope is clearly bounded —— 待 D1
+- [x] Dependencies and assumptions identified
+
+## Feature Readiness
+
+- [ ] All functional requirements have clear acceptance criteria —— D1/D2/D4/D5 待定
+- [x] User scenarios cover primary flows(按假设全流程覆盖)
+- [ ] Feature meets measurable outcomes defined in Success Criteria —— 待实现后验证
+- [x] No implementation details leak into specification
+
+## Notes
+
+- 本 spec 当前为**业务级 Draft**,核心价值在于记录已确认的集成背景与「两层资金流」资金模型。
+- **下一步阻塞在「待定决策」D1–D6**,尤其是 **D3(iFlight 接口契约获取)是硬阻塞**——未拿到文档前 `/speckit-plan` 的 research 与 `/speckit-tasks` 无法进入字段级实现。
+- 用户明确表示「后续的我再决定」,故暂保留 [NEEDS CLARIFICATION] 标记,不强行猜测。
+- 待用户决策后,先跑 `/speckit-clarify` 消化决策、再 `/speckit-plan`。

+ 154 - 0
specs/015-intl-flight/spec.md

@@ -0,0 +1,154 @@
+# Feature Specification: 国际机票(盘合 iFlight 分销接入)
+
+**Feature Branch**: `(待创建,暂未建分支/未提交)`
+
+**Created**: 2026-07-29
+
+**Status**: Draft —— 范围与多项关键决策待定(见「待定决策」)
+
+**Input**: 接入上海盘合 shpanhe 国际机票开放接口(`http://open.shpanhe.com/api/iFlight`),实现国际机票查询/预订等能力。凭证见 `文档/旅游/API测试接口.txt`。
+
+---
+
+## 集成背景(已确认事实)
+
+- **盘合是分销平台,我方是分销商**:从同平台国内机票文档出现的「票面价 / 机建税 / 燃油费 / 结算价 / 返点返现政策」可确认这是 **B2B 分销结算模型**,不是消费者直连航司/盘合的透传支付。
+- **测试凭证(国际机票)**:
+  - AppKey:`4298c05600816da82abdbc899cd0c344`
+  - SecretKey:`7ad3915eaf9a47156adcd31a1c51f2df`
+  - 接口网关域名:`http://api.panhe.net`
+  - 测试分销后台:`http://fxtest.panhe.net`(账号 `15978194868` / 密码 `086915`)
+- **接口文档可获取性**:iFlight 在线文档(`open.shpanhe.com/api/iFlight`)需登录,自动抓取(HTTP/HTTPS/真实浏览器)均被屏蔽或超时失败,搜索引擎未收录。同平台**国内机票文档 `/api/flight` 公开**,可作为流程/签名/信封惯例的参考模板(国际机票大概率同源,待文档验证)。
+
+### 资金模型:两层资金流(核心)
+
+| 层级 | 方向 | 价格 | 通道 |
+|------|------|------|------|
+| 第一层(零售) | 消费者 → 平台 | 零售价(票面价 + 税费 + 我方加价/返点) | 平台自有支付通道(复用,见下) |
+| 第二层(结算) | 平台 → 盘合 | 结算价 | 分销商账户(余额/授信/代扣,方式待文档确认) |
+
+- 消费者全程不接触盘合。
+- **结算价与零售价的差 / 返点返现 = 平台利润**。
+- 第一层收款**复用 foodie 现有支付通道**,不为机票新建支付:当前 NewebPay 蓝新金流**代码就绪但未启用**,VNPay / ZaloPay 已废弃(详见 `project-deprecated-code` 记忆)。
+- 真正新增的复杂点不是支付通道本身,而是:**零售价定价**、**结算价/利润记账**、**出票后资金核销与对账**。
+
+---
+
+## 待定决策(用户后续决定)
+
+> 以下决策显著影响 spec 范围与用户场景,需用户拍板后再细化。当前 spec 先以「假设全流程」记录,范围边界以 D1 为准回收。
+
+- **D1 — 范围边界**:仅查票展示 / 查票+生单(不含支付出票)/ 全流程(查→订→付→出票→售后)。
+- **D2 — 落地端**:用户端(uni-app,消费者自助)/ 平台管理端(foodie-admin-vue)/ 两端。
+- **D3 — iFlight 接口契约获取方式**:用户提供完整文档 / 以国内机票 `/api/flight` 为模板推断(后续校正)/ 仅写业务级 spec、接口细节推迟到 plan 的 research 步骤。
+- **D4 — 第二层(平台→盘合)结算方式**:账户余额实时扣款 / 授信额度周期结算 / 下单网关代扣。(决定记账与资金风险设计)
+- **D5 — 零售价定价策略**:直接用票面价 / 票面价 + 固定加价 / 按返点反算。(决定利润模型)
+- **D6 — 第一层(消费者)收款通道**:是否启用 NewebPay 或使用当时可用通道。
+
+> ⚠️ **硬阻塞**:在拿到 iFlight 接口契约(D3)前,`/speckit-plan` 的 research 与 `/speckit-tasks` 的实现细节无法落地,本 spec 只能停留在业务级。
+
+---
+
+## User Scenarios & Testing *(mandatory)*
+
+> 优先级以「假设全流程(D1=全流程)」编写;若 D1 收窄为「仅查票」,则仅保留 US1,其余降级或移除。
+
+### User Story 1 - 查询国际航班(Priority: P1)
+
+消费者选择出发/到达城市(三字码)、出发日期、舱等,查看可选国际航班及其舱位票价(票面价、税费、飞行时段、航司、中转信息)。
+
+**Why this priority**:查票是整个机票功能的入口和最小可用形态,即使后续环节都不做,单查票展示也有独立价值。
+
+**Independent Test**:给定一条真实国际航线 + 日期,调用查询能返回航班与价格列表并在前端展示。
+
+**Acceptance Scenarios**:
+
+1. **Given** 消费者输入有效的出发地、到达地、日期,**When** 发起查询,**Then** 返回当日可选航班列表(含航司、起降时间、舱位、票价、税费、是否中转)。
+2. **Given** 无效或无航班航线,**When** 查询,**Then** 返回明确的「无航班」提示而非报错。
+3. **Given** 查询往返/联程,**When** 查询,**Then** 通过分段查询组合出往返/联程结果。
+
+---
+
+### User Story 2 - 下单与支付(Priority: P2)
+
+消费者选定航班舱位、填写乘机人信息后生成订单,按零售价通过平台支付通道付款。
+
+**Why this priority**:完成「能买到票」的核心交易闭环。
+
+**Independent Test**:从选定航班到支付成功,生成一条待出票的机票订单。
+
+**Acceptance Scenarios**:
+
+1. **Given** 消费者选定舱位并填妥乘机人,**When** 提交订单,**Then** 生成机票订单并按零售价发起支付。
+2. **Given** 支付成功,**When** 平台收到回调,**Then** 订单进入「待出票」,平台据此向盘合按结算价下单/订座。
+3. **Given** 支付超时或失败,**When** 超时未支付,**Then** 订单自动取消、释放占座(若已占座)。
+
+---
+
+### User Story 3 - 出票与售后(Priority: P3)
+
+支付成功后由盘合出票,消费者获得票号;支持退票/改签等售后。
+
+**Why this priority**:完成完整购票体验,但可晚于 MVP。
+
+**Independent Test**:支付成功后能拿到真实票号并展示;能发起一次退票流程。
+
+**Acceptance Scenarios**:
+
+1. **Given** 订单已支付且盘合侧下单成功,**When** 出票完成,**Then** 消费者可见票号/行程单。
+2. **Given** 出票失败,**When** 平台感知失败,**Then** 自动重试或退款,并通知消费者。
+3. **Given** 已出票订单,**When** 消费者申请退票/改签,**Then** 按航司规则计算手续费并退款/改期。
+
+---
+
+### Edge Cases
+
+- 查票有结果但下单时座位已被占满(库存瞬时变化)如何处理?
+- 支付成功但盘合侧出票失败的资金兜底(退款给消费者 + 平台与盘合的结算对账)。
+- 价格在查票与下单之间发生变动(税费/汇率波动)。
+- 多乘机人部分出票成功部分失败。
+- 国际行程的护照/证件信息校验、签证提示(信息性,非校验)。
+- 币种与汇率:盘合结算币种 vs 消费者支付币种。
+
+## Requirements *(mandatory)*
+
+### Functional Requirements
+
+- **FR-001**:系统 MUST 支持按出发/到达城市(三字码)、日期、舱等查询国际航班,返回航班、舱位、票面价、税费、起降时间、航司、中转信息。
+- **FR-002**:系统 MUST 支持往返/联程查询(分段组合实现)。
+- **FR-003**:系统 MUST 在消费者选定舱位并填写乘机人后生成国际机票订单,订单含零售价与结算价两套价格记录。
+- **FR-004**:系统 MUST 通过平台自有支付通道收取消费者零售价款项(复用,不新建支付通道)。
+- **FR-005**:系统 MUST 在消费者支付成功后,按结算价向盘合完成下单/订座/出票,并记录票号。
+- **FR-006**:系统 MUST 记录每笔订单的两层资金流(消费者零售收款 + 平台与盘合结算价/返点),支持对账与利润核算。
+- **FR-007**:系统 MUST 支持出票失败的自动重试与退款兜底。
+- **FR-008**:系统 MUST 支持退票/改签,按航司规则计算手续费。
+- **FR-009**:功能范围(查票/下单/支付/出票/售后)以 **[NEEDS CLARIFICATION: D1 范围边界]** 为准。
+- **FR-010**:功能落地端(用户端/管理端)以 **[NEEDS CLARIFICATION: D2 落地端]** 为准。
+- **FR-011**:零售价定价以 **[NEEDS CLARIFICATION: D5 定价策略]** 为准。
+- **FR-012**:第二层结算方式以 **[NEEDS CLARIFICATION: D4 结算方式]** 为准。
+
+### Key Entities *(include if feature involves data)*
+
+- **航班查询结果(FlightSearchResult)**:航班段、舱位、票面价、税费、结算价、返点政策、起降时间、航司、中转。
+- **国际机票订单(IntlFlightOrder)**:订单号、消费者、航线、舱位、乘机人列表、零售价、结算价、状态(待支付/待出票/已出票/已退/已改)、关联支付流水、票号。
+- **乘机人(Passenger)**:姓名(拼音/英文)、证件类型与号码(护照等)、性别、出生日期、手机号。
+- **资金流水(FlightPaymentSettlement)**:消费者侧收款记录 + 平台与盘合的结算扣款/返点记录(用于对账与利润)。
+
+## Success Criteria *(mandatory)*
+
+### Measurable Outcomes
+
+- **SC-001**:消费者发起国际航班查询后,95% 的查询在可接受时长内返回结果。
+- **SC-002**:查票成功率(有航线的查询返回有效结果的比例)达到目标值。
+- **SC-003**:从下单到出票完成(含支付)的端到端时长满足消费者预期。
+- **SC-004**:每笔订单的两层资金(零售收款 vs 盘合结算)100% 可对账,利润可核算。
+- **SC-005**:出票失败场景 100% 有明确的退款/重试兜底,无资金悬空。
+
+## Assumptions
+
+- 平台已作为**分销商**在盘合完成开户,测试凭证可用,生产凭证后续提供。
+- 复用 foodie 现有的**用户体系、App 端框架、支付通道**,不为此功能新建用户体系或支付通道。
+- 国际机票与国内机票同源(同一平台),**签名方式、请求信封、接口流程一致**——此为推断,待 iFlight 文档验证。
+- 在拿到 iFlight 接口契约前,`plan`/`tasks`/`实现` 阶段无法进入字段级细节,本 spec 保持业务级。
+- 接口调用走服务端(AppKey/SecretKey 不下发前端),签名在服务端完成。
+- 测试先用 `api.panhe.net` 测试网关与测试凭证,生产环境与凭证后续切换。

+ 36 - 0
specs/016-omg-payment/checklists/requirements.md

@@ -0,0 +1,36 @@
+# Specification Quality Checklist: OMG 支付接入(订单在线支付)
+
+**Purpose**: Validate specification completeness and quality before proceeding to planning
+**Created**: 2026-07-29
+**Feature**: [spec.md](../spec.md)
+
+## Content Quality
+
+- [x] No implementation details (保持在业务+集成事实层,未写代码/算法细节)
+- [x] Focused on user value and business needs
+- [x] Written for non-technical stakeholders
+- [x] All mandatory sections completed
+
+## Requirement Completeness
+
+- [x] No [NEEDS CLARIFICATION] markers remain —— D1–D6 已定,FR-002/007/009/012 已落实
+- [x] Requirements are testable and unambiguous
+- [x] Success criteria are measurable
+- [x] Success criteria are technology-agnostic
+- [x] All acceptance scenarios are defined(5 个用户故事覆盖)
+- [x] Edge cases are identified
+- [x] Scope is clearly bounded —— D1(全方式)/D2(替代NewebPay)/D5(仅餐饮)/D6(门店级) 已定;D3 退款本期、D4 LINE Pay 不做
+- [x] Dependencies and assumptions identified
+
+## Feature Readiness
+
+- [x] All functional requirements have clear acceptance criteria
+- [x] User scenarios cover primary flows
+- [ ] Feature meets measurable outcomes defined in Success Criteria —— 待实现后验证
+- [x] No implementation details leak into specification
+
+## Notes
+
+- 决策 D1–D6 已确认并写入 spec(2026-07-29)。范围:OMG 全方式支付(ALL,不含 LINE Pay)、替代蓝新 NewebPay、仅餐饮订单、门店级凭证、本期含退款。
+- 核心集成结论:OMG `CheckMacValue`(SHA256) 签名与 NewebPay(TradeInfo/AES) 不同,需独立签名工具。
+- 下一步:`/speckit-plan`(research 须补齐 CheckMacValue 算法细节、05_refund/04_order_query/03_payment_notify 字段、门店级凭证表设计)。

+ 120 - 0
specs/016-omg-payment/contracts/api.md

@@ -0,0 +1,120 @@
+# Contracts: OMG(歐買尬/FunPoint)AIO 支付接入
+
+**Feature**: specs/016-omg-payment/spec.md
+**Date**: 2026-07-29
+
+> **约束**:OMG 全部独立实现,零 `newebpay` 依赖(见 research.md §0)。签名统一用 `CheckMacValue`(SHA256, EncryptType=1),无 AES、无解密。
+
+---
+
+## A. OMG 外部接口(我方 → 盘合)
+
+### A1. 幕前下单 — `Cashier/AioCheckOut/V5`(US1)
+
+- **URL**:测试 `https://payment-stage.funpoint.com.tw/Cashier/AioCheckOut/V5`;正式 `https://payment.funpoint.com.tw/Cashier/AioCheckOut/V5`
+- **Method/CT**:`POST`,`application/x-www-form-urlencoded`
+- **方式**:后端组参 + 计算 CheckMacValue 返回 form 字段,**前端 Form Post** 跳转 OMG 收银台(不可 iframe;iOS 不可另开窗)
+- **必填请求参数**:`MerchantID`、`MerchantTradeNo`(=OMG+ddId)、`MerchantTradeDate`(yyyy/MM/dd HH:mm:ss)、`PaymentType=aio`、`TotalAmount`(整数元)、`ReturnURL`、`ChoosePayment=ALL`、`CheckMacValue`、`EncryptType=1`
+- **常用可选**:`TradeDesc`、`ItemName`(# 分隔)、`OrderResultURL`、`PaymentInfoURL`、`ClientRedirectURL`、`NeedExtraPaidInfo`(建议 Y,供退款查 gwsr)、`CustomField1–4`、`Language`
+- **固定**:`InvoiceMark=N`(发票走 ezPay);`ReturnURL ≠ OrderResultURL`;`MerchantTradeNo` 全平台唯一
+
+### A2. 付款结果回调 — ReturnURL(盘合 → 我方,US2)
+
+- 盘合**服务端 POST** 我方 `/pay/omg/notify`(CT `text/html`)
+- **参数**:`MerchantID`、`MerchantTradeNo`、`StoreID`、`RtnCode`(**1=成功**,其余勿发货)、`RtnMsg`、`TradeNo`(OMG 交易号,须存并与 MerchantTradeNo 关联)、`TradeAmt`、`PaymentDate`、`PaymentType`、`TradeDate`、`SimulatePaid`(**1=模拟,勿发货**)、`CustomField1–4`、`CheckMacValue`
+- **我方响应**:纯字符串 `1|OK`(首字符 1=成功);未收到 5–15 分重试,当天最多 4 次
+- **核销校验顺序**:CheckMacValue 验签 → `RtnCode==1` → `SimulatePaid!=1` → `TradeAmt==订单 amount` → `trade_no` 幂等
+
+### A3. ATM/超商 取号回调 — PaymentInfoURL(US3)
+
+- 盘合服务端 POST 我方 `/pay/omg/paymentInfo`,返回虚帐/缴费码(`BankCode`/`vAccount`/`ExpireDate` 或 `PaymentNo`/`ExpireDate` 等,详见文档 02 页),带 `CheckMacValue` 需验签
+- 我方落库展示并回 `1|OK`
+
+### A4. 订单查询 — `Cashier/QueryTradeInfo/V5`(凭证验证 / 补单)
+
+- **URL**:测试 `https://payment-stage.funpoint.com.tw/Cashier/QueryTradeInfo/V5`;正式 `https://payment.funpoint.com.tw/Cashier/QueryTradeInfo/V5`
+- **请求**:`MerchantID`、`MerchantTradeNo`、`TimeStamp`(3 分钟内)、`PlatformID?`、`CheckMacValue`
+- **响应**(text/html,k=v):`TradeStatus`(**0 未付/1 已付/10200095 失败**)、`TradeNo`、`TradeAmt`、`PaymentDate`、`PaymentType`、`TradeDate`、`CheckMacValue`…
+- **用途**:录入凭证探测金钥 + 回调漏收时补单
+
+### A5. 信用卡退款/取消 — `CreditDetail/DoAction`(US4)
+
+- **URL**:正式 `https://payment.funpoint.com.tw/CreditDetail/DoAction`(**stage 不可用**)
+- **请求**:`MerchantID`、`MerchantTradeNo`、`TradeNo`、`Action`(**C** 關帳/**R** 退刷/**E** 取消/**N** 放棄)、`TotalAmount`、`CheckMacValue`、`PlatformID?`
+- **响应**(k=v):`RtnCode`(**1=成功**)、`RtnMsg`
+- **规则**:`已關帳→R`、`已授權→N`、`要關帳→E 再 N 或 R`;分期必须全额;ATM/CVS/BarcodeATM **不适用**(走人工);自动关帳开启时避开 20:15–20:30
+
+---
+
+## B. 平台内部接口(暴露给前端/管理端)
+
+### B1. 发起 OMG 支付 — `POST /pay/omg/create`(US1,@Anonymous @Auth,Header token)
+
+- 入参:`orderid`(= ddId)
+- 逻辑:校验订单归属/金额/未支付 → 查门店启用凭证 → 生成 `MerchantTradeNo` → 组参 + CheckMacValue → 落 `pos_order_omg_payment`(pay_status=0) + 更新 `pos_order.pay_type="7"`/`pay_url`
+- 返回:form 字段 `{ gatewayUrl, MerchantID, MerchantTradeNo, MerchantTradeDate, PaymentType=aio, TotalAmount, ReturnURL, ChoosePayment=ALL, EncryptType=1, ItemName, CheckMacValue, ... }`,前端构建隐藏 form 自动 submit 到 `gatewayUrl`
+
+### B2. OMG 支付结果回调 — `POST /pay/omg/notify`(US2,@Anonymous)
+
+- 处理 A2:collectForm → IpnLog → 凭证(MerchantID) → 验签 CheckMacValue → 幂等(trade_no) → 金额校验 → RtnCode==1 && SimulatePaid!=1 → markSuccess → 更新订单(state=0, payStatus=1) + 订单日志 + 推送用户/商家/骑手 + sendAcceptRiderPush → 回纯串 `1|OK`
+
+### B3. 支付完成返回页 — `GET|POST /pay/omg/return`(US2,@Anonymous)
+
+- 仅引导回前端结果页(带 ddId),**不改订单状态**(以 notify 为准)
+
+### B4. ATM/超商 取号回调 — `POST /pay/omg/paymentInfo`(US3,@Anonymous)
+
+- 处理 A3:验签 → 落虚帐/缴费码 + 期限 → 回 `1|OK`
+
+### B5. 订单查询/补单 — `POST /pay/omg/query`(US2 补单,可选)
+
+- 入参 `orderid` → 调 A4 → 据 TradeStatus 补单/查状态
+
+### B6. 订单取消退款(US4)
+
+- 触发点:订单取消链路(`PosOrderShOprate` 商家取消 / `UserOrderController` 用户取消)内,对 OMG 已支付订单调 A5 DoAction;或 `POST /pay/omg/refund?orderid=`(落点 tasks 定)
+- 写 `pos_order_omg_refund`;成功置 `pos_order_omg_payment.pay_status=3`、`pos_order.pay_status=2`
+
+### B7. 门店 OMG 凭证管理 — `/system/storeOmg/*`(US5,@PreAuthorize)
+
+- `GET /list`(分页筛选)、`GET /{storeId}`、`PUT /apply/{storeId}`、`PUT /saveCredentials`(录入+QueryTradeInfo 探测验证)、`PUT /toggleEnable/{storeId}`、`PUT /reset/{storeId}`、`PUT /enabledPayments/{storeId}`
+- 镜像 011 `PosStoreNewebpayController` 模式,但**独立 Controller/Service/实体/表**
+
+---
+
+## C. 代码表与枚举(OMG reference 02–05,2026-07-29 补读)
+
+### C1. ChoosePayment(请求参数,US1)
+
+本期固定 `ALL`(聚合全部)。可细分值:`Credit`(信用卡/銀聯/Apple Pay)、`ATM`(FIRST/CHINATRUST/UBOT/KGI)、`CVS`(CVS/FAMILY/IBON/HILIFE)、`BarcodeATM`(CHINATRUST)、`AFTEE`。
+⚠️ **Apple Pay 不是独立 ChoosePayment**,归在 `Credit` 下(付款页内选);回覆 PaymentType 同为 `Credit_CreditCard`,**仅凭 PaymentType 无法区分信用卡与 Apple Pay**(本期不区分)。
+
+### C2. 回覆 PaymentType(回调 `PaymentType`,落 `pos_order_omg_payment.pay_type`)
+
+| PaymentType | 名称 | 即时/延期 |
+|---|---|---|
+| `Credit_CreditCard` | 信用卡(含銀聯/Apple Pay) | 即时 |
+| `BarcodeATM_CHINATRUST` | 超商快付代碼繳費 | 即时 |
+| `ATM_FIRST` / `ATM_CHINATRUST` / `ATM_UBOT` / `ATM_KGI` | 各银行 ATM | 延期(取虚帐→期限内付款) |
+| `CVS_CVS` / `CVS_FAMILY` / `CVS_IBON` / `CVS_HILIFE` | 超商代碼繳費 | 延期 |
+| `AFTEE_AFTEE` | AFTEE 先享後付 | 延期 |
+
+延期方式(ATM/CVS/AFTEE)走 `PaymentInfoURL` 取号 → US3。
+
+### C3. RtnCode(回调 / 退款响应)
+
+`1` = 成功;其余均为失败。完整代码表文档为**图片**且持续新增,须到「歐買尬廠商後台 → 系統開發管理 → 交易狀態代碼查詢」查全。
+**实现策略**:`RtnCode==1` 视成功(核销 / 退款成功);非 1 一律失败,记录 `rtn_code`+`rtn_msg` 供诊断,**不硬编码错误码分支**。
+
+### C4. URLEncode(CheckMacValue 计算,须完全照此 · reference 05)
+
+.NET 风格 URLEncode。**Java 实现**:`URLEncoder.encode(s, UTF_8)`(空格已→`+`)后,照 PHP 范例做替换以对齐 .NET,再转小写、SHA256、hex 大写:
+- 不编码(字面):`-` `_` `.` `!` `*` `(` `)`;`~`→`%7e`(两侧同)。
+- 空格→`+`(.NET/Java 表单编码一致;标准 URLEncode 是 `%20`,勿混用)。
+- 其余特殊符 `@ # $ % ^ & = + ; ? / \ > < [ ] { } : ' " , |` 均为 `%XX`;中文按 UTF-8 `%XX`。
+- 替换集(防御性,照 PHP 范例):`%2d→-`、`%5f→_`、`%2e→.`、`%21→!`、`%2a→*`、`%28→(`、`%29→)`(Java `URLEncoder` 本就不编 `-_.`,`!*()` 需替换回)。
+
+## 签名约定(所有请求/回调通用)
+
+`CheckMacValue`(EncryptType=1, SHA256):
+去 CheckMacValue → 参数按 key 字母序 → `k=v&...` → 包夹 `HashKey={k}&..&HashIV={iv}` → .NET 风格 URL 编码(`-_.!*()` 不编码) → 转小写 → SHA256 → hex 大写。校验同理重算比对。实现:`OmgCheckMacValue`(独立,不复用 `NewebPayEncryptUtil`)。

+ 186 - 0
specs/016-omg-payment/data-model.md

@@ -0,0 +1,186 @@
+# Data Model: OMG(歐買尬/FunPoint)AIO 支付接入
+
+**Feature**: specs/016-omg-payment/spec.md
+**Date**: 2026-07-29
+
+本文定义新增实体与建表 DDL(DDL 同步写入 `updatesql/sql.md`,不直接执行)。
+
+> **约束**:OMG 全部独立实现,**不复用蓝新建的 `pos_store_newebpay` / `pos_order_payment`**(见 research.md §0)。故凭证表、流水表、退款表均为 OMG 专属新建。
+
+---
+
+## 新增实体
+
+### 1. PosStoreOmg(门店 OMG 凭证)— `pos_store_omg`
+
+与 `pos_store` 一对一。记录门店 OMG 申请状态、启用开关、凭证、启用支付方式。状态机沿用 009/011 模式(0 未申请 / 1 申请中 / 2 已开通 + is_enabled)。
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| id | BIGINT PK AUTO | 主键 |
+| store_id | BIGINT UK | 关联 pos_store.id(门店),唯一 |
+| omg_status | INT | 申请状态: 0 未申请/1 申请中/2 已开通 |
+| is_enabled | INT | 启用开关: 0 停用/1 启用(仅 status=2 有意义) |
+| merchant_id | VARCHAR(10) | OMG 商店代号 MerchantID |
+| hash_key | VARCHAR(64) | OMG HashKey |
+| hash_iv | VARCHAR(64) | OMG HashIV |
+| enabled_payments | VARCHAR(50) | 启用支付方式(本期固定 ALL,字段保留) |
+| apply_time | DATETIME | 提交申请时间(0→1) |
+| approved_time | DATETIME | 开通时间(→2) |
+| last_verify_result | VARCHAR(240) | 最近一次凭证验证结果 |
+| remark | VARCHAR(255) | 备注 |
+| create_time / update_time | DATETIME | 审计时间 |
+| create_by / update_by | VARCHAR(64) | 审计人 |
+
+**状态机**:`0 未申请 --apply--> 1 申请中 --saveCredentials(验证通过)--> 2 已开通 <--reset--> 1 申请中`;`2 已开通` 下 `toggleEnable` 切换 is_enabled。门店无此行即视为未申请。
+
+---
+
+### 2. PosOrderOmgPayment(OMG 支付流水)— `pos_order_omg_payment`
+
+每笔 OMG 交易一条。承载幂等(按 trade_no)与对账明细。**不复用** `pos_order_payment`。
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| id | BIGINT PK AUTO | 主键 |
+| dd_id | VARCHAR(64) | 系统订单号(关联 pos_order.dd_id),索引 |
+| merchant_trade_no | VARCHAR(20) | 商店交易编号 MerchantTradeNo(发起生成),索引 |
+| trade_no | VARCHAR(20) | OMG 交易编号 TradeNo(回调获得),唯一索引(幂等键) |
+| store_id | BIGINT | 门店ID |
+| merchant_id | VARCHAR(10) | OMG 商店代号(发起时门店凭证) |
+| choose_payment | VARCHAR(20) | 发起方式:ALL |
+| pay_type | VARCHAR(20) | 回覆 PaymentType: Credit_CreditCard/BarcodeATM_CHINATRUST/ATM_*/CVS_*/AFTEE_AFTEE |
+| amount | INT | 交易金额(整数元 = 订单 amount) |
+| rtn_code | INT | 回调 RtnCode(1=成功) |
+| rtn_msg | VARCHAR(200) | 回调 RtnMsg |
+| pay_status | INT | 0 未支付/1 已支付/2 失败/3 已退款 |
+| auth_code | VARCHAR(20) | 授权码(信用卡回调) |
+| pay_time | DATETIME | 支付完成时间(回调 PaymentDate) |
+| trade_date | DATETIME | 订单创建时间(回调 TradeDate) |
+| callback_raw | TEXT | 回调原始报文(调试/对账) |
+| create_time / update_time | DATETIME | 审计时间 |
+
+**幂等规则**:回调按 `trade_no` 查重——已存在且 `pay_status=1` 则跳过更新(仍回 `1|OK`);不存在则新增/更新。
+**唯一性**:`trade_no` 唯一索引(MySQL 允许多个 NULL,发起时尚无 trade_no);同一订单多次发起产生多行(不同 merchant_trade_no),但同一 trade_no 只对应一行。
+
+---
+
+### 3. PosOrderOmgRefund(OMG 退款记录)— `pos_order_omg_refund`
+
+每次 DoAction 调用一条,与流水解耦,支持多次动作与对账。
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| id | BIGINT PK AUTO | 主键 |
+| payment_id | BIGINT | 关联 pos_order_omg_payment.id,索引 |
+| dd_id | VARCHAR(64) | 系统订单号,索引 |
+| trade_no | VARCHAR(20) | OMG 交易编号 TradeNo |
+| action | CHAR(1) | DoAction 动作: C/R/E/N |
+| amount | INT | 退款/操作金额 |
+| rtn_code | INT | 响应 RtnCode(1=成功) |
+| rtn_msg | VARCHAR(200) | 响应 RtnMsg |
+| callback_raw | TEXT | 响应原始报文 |
+| create_time | DATETIME | 操作时间 |
+
+---
+
+## 现有实体扩展
+
+### 4. PosOrder(订单)— `pos_order`(不改表结构)
+
+| 字段 | 现状 | 本期用法 |
+|------|------|----------|
+| dd_id | 订单号 | 派生 MerchantTradeNo = "OMG" + dd_id |
+| amount | 订单合计金额(Integer 元) | OMG 支付金额(含运费;与发票 amount−freight 不同) |
+| pay_type | 支付方式字符串 | 新增取值 `"7"` = OMG 在线支付(发起时写入) |
+| pay_status | 0 未支付/1 已支付/2 已退款 | 回调成功置 1(沿用);退款置 2 |
+| pay_url | 支付地址 | 存 OMG gatewayUrl(兜底/记录) |
+| md_id | 门店ID | 决定用哪个门店的 OMG 凭证发起交易 |
+
+**不改表结构**,无 DDL。payType 取值扩展在代码层(常量)体现。
+
+---
+
+## DDL(写入 updatesql/sql.md,不直接执行)
+
+```sql
+-- 2026-07-29 OMG(歐買尬)支付接入 - 门店OMG凭证表
+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(歐買尬)支付凭证';
+
+-- 2026-07-29 OMG支付接入 - OMG支付交易流水表
+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支付交易流水';
+
+-- 2026-07-29 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退款记录';
+```
+
+注:`uk_trade_no` 唯一索引在 MySQL 下允许多个 NULL(发起时 trade_no 为空),回调写入时保证唯一。
+
+---
+
+## 实体关系
+
+```
+pos_store 1 ──── 1 pos_store_omg            (门店 OMG 凭证)
+pos_store 1 ──── ∞ pos_order                 (门店订单)
+pos_order 1 ──── ∞ pos_order_omg_payment     (一订单多次发起,每发起一行)
+                                   ↑
+                        trade_no 唯一(回调幂等)
+pos_order_omg_payment 1 ──── ∞ pos_order_omg_refund  (一次支付多次退款动作)
+```

+ 107 - 0
specs/016-omg-payment/plan.md

@@ -0,0 +1,107 @@
+# Implementation Plan: OMG(歐買尬/FunPoint)AIO 支付接入
+
+**Branch**: `(待创建,暂未建分支/未提交)` | **Date**: 2026-07-29 | **Spec**: [spec.md](spec.md)
+
+**Input**: Feature specification from `/specs/016-omg-payment/spec.md`
+
+## Summary
+
+接入台湾 OMG(歐買尬,金流引擎 FunPoint)AIO 幕前支付作为餐饮订单的在线支付,替代蓝新 NewebPay(不再启用)。OMG 用 `CheckMacValue`(SHA256) 单字段签名(无 AES、无解密),支持全部支付方式(信用卡/Apple Pay/ATM/超商/BarcodeATM/AFTEE,`ChoosePayment=ALL`),门店级凭证,本期含信用卡退款。**OMG 全部代码独立新建,不复用任何蓝新金流代码/表**;仅复用平台共享基础设施(订单状态机、推送、订单日志)。
+
+技术决策与备选见 [research.md](research.md),表结构见 [data-model.md](data-model.md),接口契约见 [contracts/api.md](contracts/api.md),验证场景见 [quickstart.md](quickstart.md)。
+
+## Technical Context
+
+**Language/Version**: Java 21(Spring Boot 3 / RuoYi-Vue-Plus,MyBatis-Plus,Lombok)
+
+**Primary Dependencies**: Spring Boot、MyBatis-Plus(@TableName/@TableId,XML mapper)、Apache HttpClient 4(幕后 POST QueryTradeInfo/DoAction)、fastjson2、`@Anonymous`/`PermitAllUrlProperties`(回调白名单)
+
+**Storage**: MySQL(新增 `pos_store_omg` / `pos_order_omg_payment` / `pos_order_omg_refund` 三表,DDL 写 `updatesql/sql.md` 不直接执行;`pos_order` 不改结构,仅扩展 `pay_type="7"`)
+
+**Testing**: 工具类 `main` 自测(CheckMacValue 用官方示例值复算)+ 测试环境端到端(测试卡,见 quickstart.md);项目无统一自动化测试框架约定
+
+**Target Platform**: Linux server(后端服务),配合 uni-app 用户端 Form Post 跳转 OMG 收银台
+
+**Project Type**: web-service(后端)
+
+**Performance Goals**: 发起支付接口 < 1s;回调核销幂等无重复发货
+
+**Constraints**: CheckMacValue 必须与 OMG 完全一致(含 .NET 风格 URL 编码与小写);回调须回纯串 `1|OK`;门店级凭证;`InvoiceMark=N`(发票走 ezPay 解耦);外部 HTTP 工具类须放 `ruoyi-admin`(模块依赖 admin→system 不可反向)
+
+**Scale/Scope**: 全平台餐饮订单在线支付(旅游·机票 015 后续衔接,不在本期)
+
+## Constitution Check
+
+*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
+
+`.specify/memory/constitution.md` 为未填写模板(占位符),无实际治理原则可校验。改为遵循项目 `CLAUDE.md` 与记忆中的硬约束:
+
+| 约束 | 本期遵守 |
+|------|----------|
+| 模块依赖 admin→system 不可反向 | ✅ OMG 外部 HTTP/签名工具类放 `ruoyi-admin/.../app/utils/omg/`,联网验证在 Controller 层;实体/Service/Mapper 放 `ruoyi-system` |
+| 外部 HTTP 工具类(httpclient4+fastjson2)放 admin | ✅ `OmgPay`/`OmgCheckMacValue` 在 admin |
+| 后端文件 CRLF | ✅ 新建 Java/md 文件保持 CRLF |
+| DB 变更写 `updatesql/sql.md` 不直接执行 | ✅ 三表 DDL 写入,不执行 |
+| 前端新功能必须 i18n | 本期后端为主;若涉及前端支付跳转/结果页文字,按规范加 4 语言 |
+| 范围控制 / 简单优先 | ✅ 不复用蓝新、不抽公共支付套件(仅一个使用点) |
+| **OMG 不复用蓝新代码**(用户新增) | ✅ 零 `newebpay` import,独立凭证/流水/退款表 |
+
+**结论**:无违规,Phase 0 可继续。Phase 1 设计后再次确认无蓝新耦合。
+
+## Project Structure
+
+### Documentation (this feature)
+
+```text
+specs/016-omg-payment/
+├── plan.md              # 本文件
+├── research.md          # Phase 0:技术决策
+├── data-model.md        # Phase 1:表结构 + DDL
+├── quickstart.md        # Phase 1:端到端验证
+├── contracts/
+│   └── api.md           # Phase 1:OMG 外部接口 + 平台内部接口
+└── tasks.md             # Phase 2 (/speckit-tasks 生成,本阶段不创建)
+```
+
+### Source Code (repository root)
+
+```text
+ruoyi-admin/src/main/java/com/ruoyi/app/
+├── utils/omg/                        # OMG 专属,零 newebpay 依赖
+│   ├── OmgCheckMacValue.java         # CheckMacValue(SHA256) 生成/校验 + main 自测
+│   ├── OmgPayConfig.java             # MerchantID / HashKey / HashIV
+│   └── OmgPay.java                   # HTTP 客户端:createAioForm / queryTrade / doAction
+├── pay/
+│   └── OmgPayController.java         # /pay/omg/{create,notify,return,paymentInfo}(US1/US2/US3)
+└── mendian/
+    └── PosStoreOmgController.java    # /system/storeOmg/* 凭证开通管理(US5)
+
+ruoyi-system/src/main/java/com/ruoyi/system/
+├── domain/
+│   ├── PosStoreOmg.java              # 门店 OMG 凭证(pos_store_omg)
+│   ├── PosOrderOmgPayment.java       # OMG 支付流水(pos_order_omg_payment)
+│   ├── PosOrderOmgRefund.java        # OMG 退款记录(pos_order_omg_refund)
+│   ├── vo/PosStoreOmgVo.java
+│   └── dto/StoreOmgCredentialDto.java
+├── mapper/
+│   ├── PosStoreOmgMapper.java (+xml)
+│   ├── PosOrderOmgPaymentMapper.java (+xml)
+│   └── PosOrderOmgRefundMapper.java (+xml)
+└── service/
+    ├── IPosStoreOmgService.java + impl
+    ├── IPosOrderOmgPaymentService.java + impl
+    └── IPosOrderOmgRefundService.java + impl
+
+updatesql/sql.md                       # 追加三表 DDL(2026-07-29)
+application.yml (+application-dev.yml) # 新增 omg.* 配置段
+```
+
+**Structure Decision**:沿用项目既有分层(utils 在 `ruoyi-admin/.../app/utils/omg/`,domain/mapper/service 在 `ruoyi-system`,Controller 在 `ruoyi-admin/.../app/{pay,mendian}`),与 ezPay/newebpay 同构但**独立包/独立类/独立表**。退款入口(US4)并入 `OmgPayController` 或订单取消链路(`PosOrderShOprate`/`UserOrderController` 取消时触发),tasks 阶段定具体落点。
+
+## Complexity Tracking
+
+> Constitution Check 无违规,无需填表。
+
+| Violation | Why Needed | Simpler Alternative Rejected Because |
+|-----------|------------|-------------------------------------|
+| — | — | — |

+ 72 - 0
specs/016-omg-payment/quickstart.md

@@ -0,0 +1,72 @@
+# Quickstart: OMG(歐買尬/FunPoint)AIO 支付接入
+
+**Feature**: specs/016-omg-payment/spec.md
+**Date**: 2026-07-29
+
+端到端验证 OMG 支付可用性的可运行场景。实现细节见 `tasks.md`(待生成),接口字段见 `contracts/api.md`,表结构见 `data-model.md`。
+
+---
+
+## 前置准备
+
+1. **DB**:执行 `updatesql/sql.md` 中 2026-07-29 的三张表 DDL(`pos_store_omg` / `pos_order_omg_payment` / `pos_order_omg_refund`)。
+2. **配置**(`application.yml`)填入测试值:
+   - `omg.base-url=https://payment-stage.funpoint.com.tw`
+   - `omg.return-url` / `omg.payment-info-url` 指向**公网可达**的本服务地址(本地用 ngrok/frp 穿透到 8082)。
+3. **凭证**:在盘合测试后台申请测试商店号,拿到 `MerchantID` / `HashKey` / `HashIV`。
+4. **签名自测**:运行 `OmgCheckMacValue.main`,应 PASS(用官方示例值复算得 `AA5842FD...CAF93266`)。
+
+---
+
+## 场景 A:门店录入并验证 OMG 凭证(US5)
+
+1. 平台后台对一个门店「发起申请」→「录入凭证」(`MerchantID/HashKey/HashIV`)。
+2. 系统调 OMG `QueryTradeInfo/V5` 查虚构单号探测金钥。
+3. **预期**:金钥正确 → 提示「凭证验证通过,已开通」,`pos_store_omg.omg_status=2, is_enabled=1`;金钥错误 → 拒绝、状态不变、`last_verify_result` 记失败原因。
+
+---
+
+## 场景 B:消费者在线支付(信用卡,US1 + US2)
+
+1. 消费者下一笔餐饮订单(`pos_order.pay_status=0`)。
+2. 调 `POST /pay/omg/create?orderid=...` → 返回 form 字段(`gatewayUrl` + `MerchantID/MerchantTradeNo/.../CheckMacValue`)。
+3. 前端 Form Post 到 OMG 收银台 → 用文档附录**测试卡**完成付款。
+4. OMG 服务端 POST 回 `/pay/omg/notify`。
+5. **预期**:
+   - `/pay/omg/notify` 验签 CheckMacValue 通过、`RtnCode=1`、`SimulatePaid=0` → 回 `1|OK`;
+   - `pos_order_omg_payment.pay_status=1`、`trade_no` 写入、`pay_type=Credit`;
+   - `pos_order.pay_status=1`(state 不变)、订单日志记录、用户/商家/骑手收到推送(含 `sendAcceptRiderPush`)。
+
+---
+
+## 场景 C:回调健壮性(US2 边界)
+
+1. **重复回调**:对同一 `trade_no` 再发一次 notify → 不重复更新订单、不重复推送,仍回 `1|OK`。
+2. **伪造回调**:篡改金额或 CheckMacValue → 验签失败、拒绝、订单状态不变、记日志。
+3. **金额不符**:`TradeAmt` ≠ 订单 amount → 拒绝核销、记日志。
+4. **测试模拟**:`SimulatePaid=1` → 不核销发货、记日志。
+
+---
+
+## 场景 D:非即时支付 ATM/超商(US3)
+
+1. `ChoosePayment=ALL` 时消费者在 OMG 选 ATM 或超商。
+2. OMG 回调 `/pay/omg/paymentInfo` 带虚帐/缴费码 → 平台展示(前端页)与期限。
+3. 消费者期限内付款 → OMG 回调 `/pay/omg/notify`(`RtnCode=1`)→ 同场景 B 核销。
+4. **预期**:`pay_type=ATM/CVS`,超期未付订单可重试或失效。
+
+---
+
+## 场景 E:订单取消退款(US4,仅信用卡)
+
+1. 对一笔已支付信用卡订单发起取消。
+2. 系统调 `/CreditDetail/DoAction`(Action 按状态,默认 `R` 退刷)→ 写 `pos_order_omg_refund`。
+3. **预期**:成功 → `pos_order_omg_payment.pay_status=3(已退款)`、`pos_order.pay_status=2`、`pos_order_omg_refund.rtn_code=1`;失败 → 记 `rtn_msg` 可重试、不误标已退款。
+4. ⚠️ **stage 环境 `DoAction` 不可用**,退款须在正式环境做小额验证或 mock 测试。
+
+---
+
+## 完成判据
+
+- 场景 A/B 全绿;C 的伪造/重复/金额不符/模拟均被正确拦截;D 的 ATM/超商能拿到付款信息并核销;E 的信用卡退款状态与资金同步。
+- 全程 OMG 代码无任何 `newebpay` 依赖(grep 验证)。

+ 135 - 0
specs/016-omg-payment/research.md

@@ -0,0 +1,135 @@
+# Research: OMG(歐買尬/FunPoint)AIO 支付接入
+
+**Feature**: specs/016-omg-payment/spec.md
+**Date**: 2026-07-29
+
+本文记录 OMG 支付接入的关键技术决策、理由与备选。所有外部接口字段已对照 OMG 官方文档(`developers.omg.com.tw/payment/aio/`)确认。
+
+---
+
+## 0. 总体约束:OMG 全部独立实现,不复用蓝新(NewebPay)代码
+
+**Decision**:OMG 支付的所有代码(签名工具、HTTP 客户端、实体、表、Service、Controller)均为 OMG **独立新建**,**不得 import 或依赖任何蓝新金流代码/表**,包括:`com.ruoyi.app.utils.newebpay.*`、`NewebPayEncryptUtil`、`NewebPay`、`NewebpayPayController`、`PosStoreNewebpay*`、以及蓝新建的流水表 `pos_order_payment`。
+
+**Rationale**:用户明确要求。蓝新(011)代码就绪但从未上线、本期 D2 被 OMG 替代且不再启用;让 OMG 与蓝新在代码与数据上彻底解耦,避免被废弃代码牵连,便于将来直接删除蓝新代码。OMG 与蓝新结构相似仅因同属「幕前 AIO 支付」问题域(API 形态决定),属独立重写而非复用。
+
+**允许使用的是「平台共享基础设施」(非蓝新代码)**:`PosOrder`/`IPosOrderService`(订单状态机)、`PayPush` + `PushEventService`(推送)、`OrderLogHelper`(订单日志)、`sendAcceptRiderPush`(可接单骑手推送)、`IpnLog`(通用 IPN 日志)、`InfoUser`、`@Anonymous`/`PermitAllUrlProperties`。这些是平台公共能力,不属于蓝新。
+
+---
+
+## D1. 签名工具:新建 OmgCheckMacValue(CheckMacValue / SHA256),独立于蓝新
+
+**Decision**:新建 `ruoyi-admin/.../app/utils/omg/OmgCheckMacValue.java`(静态,CheckMacValue 生成 + 校验)+ `OmgPay.java`(HTTP 客户端)+ `OmgPayConfig.java`(MerchantID/HashKey/HashIV),独立包 `utils/omg`,不依赖 `newebpay`。
+
+**算法**(EncryptType=1,SHA256):
+1. 去掉 `CheckMacValue` 本身;其余参数按 key 字母序升序排列。
+2. 拼 `k1=v1&k2=v2...`。
+3. 包夹:`HashKey={key}&{query}&HashIV={iv}`。
+4. **.NET 风格 URL 编码**(见 reference 05 表):`- _ . ! * ( )` 不编码、`~`→`%7e`、空格→`+`、其余特殊符 `%XX`、中文 UTF-8 `%XX`。**Java 实现**:`URLEncoder.encode(s, UTF_8)`(空格已→`+`)后,照 PHP 范例替换 `%2d→- %5f→_ %2e→. %21→! %2a→* %28→( %29→)`(Java 本就不编 `-_.`,`!*()` 需替换回),再转小写。
+5. 整串转**小写**。
+6. **SHA256** → hex 转**大写** = CheckMacValue。
+7. 校验:对回调参数同算法重算,与回传 CheckMacValue 比对,不一致即拒绝。
+
+**与蓝新的本质区别**:蓝新是 AES-256-CBC 加密 TradeInfo + SHA256 TradeSha,回调需 AES 解密;**OMG 无 AES、无解密**,请求与回调都是「明文参数 + 单个 CheckMacValue」。实现更简单,故绝不复用 `NewebPayEncryptUtil`。
+
+**自测**:用官方示例 HashKey=`5294y06JbISpM5x9`、HashIV=`v77hoKGq4kWxNNIS` 的参数复算,应得 `AA5842FDA7E55ACEB7118D6353E9822CA6D6FF09A0D1FC129A879DD5CAF93266`(`OmgCheckMacValue.main` 自测)。
+
+---
+
+## D2. 支付流水表:新建 pos_order_omg_payment(不复用蓝新的 pos_order_payment)
+
+**Decision**:新建 `pos_order_omg_payment`(每笔 OMG 交易一条,按 `trade_no` = OMG `TradeNo` 幂等),**不复用** 011 的 `pos_order_payment`(复用即复用蓝新代码/数据,违背约束 0)。
+
+**字段**:`dd_id`、`merchant_trade_no`(商店交易编号 MerchantTradeNo,发起生成)、`trade_no`(OMG 交易编号 TradeNo,回调获得,幂等键,唯一索引)、`store_id`、`merchant_id`、`choose_payment`(发起 ALL)、`pay_type`(回调方式 Credit/ATM/CVS/ApplePay/BarcodeATM/AFTEE)、`amount`、`rtn_code`、`rtn_msg`、`pay_status`(0 未支付/1 已支付/2 失败/3 已退款)、`auth_code`、`pay_time`(PaymentDate)、`trade_date`、`callback_raw`、审计时间。唯一索引 `trade_no`。
+
+**退款**:新建 `pos_order_omg_refund`(每次 DoAction 一行:`payment_id`/`trade_no`/`action`/`amount`/`rtn_code`/`rtn_msg`/`callback_raw`/`time`),与流水解耦,支持多次动作与对账。
+
+**幂等**:回调按 `trade_no` 查重;已 `pay_status=1` 则跳过更新(仍回 `1|OK`)。
+
+---
+
+## D3. 门店凭证表:新建 pos_store_omg(独立于蓝新凭证表)
+
+**Decision**:新建 `pos_store_omg`(与 `pos_store` 1:1),状态机沿用 009/011 的「0 未申请 / 1 申请中 / 2 已开通 + is_enabled」模式,但**独立表独立字段**,不复用 `pos_store_newebpay`。字段:`store_id`(UK)、`omg_status`、`is_enabled`、`merchant_id`、`hash_key`、`hash_iv`、`enabled_payments`(本期固定 ALL,字段保留以备收敛)、`apply_time`、`approved_time`、`last_verify_result`、`remark` + 审计字段。
+
+---
+
+## D4. 凭证联网验证:用 OMG QueryTradeInfo/V5 探测
+
+**Decision**:录入凭证后调 OMG `Cashier/QueryTradeInfo/V5`(stage 可用)查一个虚构 `MerchantTradeNo` 探测金钥。金钥/商店代号错误 → 响应状态非正常且 Message 提示金钥/商店;金钥正确 → 返回「无此交易」类结果(TradeStatus 异常但非金钥错误)。验证逻辑放 Controller 层(同 011/009 思路,避免 ruoyi-system 反向依赖 ruoyi-admin 工具类),Service 仅持久化结果。
+
+---
+
+## D5. 回调链路:OMG Controller 独立实现,复用平台共享推送基础设施
+
+**Decision**:新建 `OmgPayController`(`/pay/omg/*`),`/pay/omg/notify` 加 `@Anonymous`。流程:collectForm → IpnLog → 按 `MerchantID` 查 `pos_store_omg` 凭证 → **验签 CheckMacValue(无需解密)** → 幂等(`trade_no`)→ 金额校验(`TradeAmt`)→ `RtnCode==1 且 SimulatePaid!=1` → markSuccess → 更新订单(`state=0`,`payStatus=1`)+ 订单日志 + `PayPush`/`PushEventService` 推送用户/商家/骑手 + **`sendAcceptRiderPush` 推送可接单骑手**。
+
+**回应差异**:OMG 要求回应**纯字符串 `1|OK`**(蓝新是 JSON `{Status:SUCCESS}`,不同),未收到会在 5–15 分钟后重试,当天最多 4 次。
+
+**sendAcceptRiderPush 迁移**:CLAUDE.md 记「`sendAcceptRiderPush` 迁移推迟到接入新支付时再做」——OMG 即新支付,本期在 `OmgPayController` 内完成该推送接线(复用 `PosOrderController` 货到付款路径同款调用);蓝新侧的空 TODO 不再回头补。
+
+---
+
+## D6. MerchantTradeNo 生成规则
+
+**Decision**:`MerchantTradeNo = "OMG" + ddId`(ddId 清洗为英数,≤20 字元),前缀区别蓝新 `"NB"`;重新发起追加时间戳后缀,旧记录作废。OMG 要求英数大小写混合、≤20。
+
+---
+
+## D7. 退款(本期 D3=做):DoAction,仅信用卡;ATM/超商人工
+
+**Decision**:订单取消时,**信用卡**订单调 `/CreditDetail/DoAction`,Action 按状态:`已關帳→R(退刷)`、`已授權→N(放棄)`、`要關帳→E(取消)再 N 或 R`,**全额退款**(分期必须全额);写入 `pos_order_omg_refund`。`ATM/超商/BarcodeATM` 无退款 API → 记录待人工在盘合后台处理。
+
+**Action 选择依赖状态**:需先 `CreditDetail/QueryTrade/V2` 查信用卡状态(需下单时 `NeedExtraPaidInfo=Y` 拿 `gwsr` + 盘合后台 `CreditCheckCode`)。MVP 可:已支付订单统一先尝试 `R(退刷)`,失败再按状态分支。**测试环境 DoAction 不可用** → 退款联调需正式小额或 mock。
+
+---
+
+## D8. 环境配置:application.yml 新增 omg 段
+
+```yaml
+omg:
+  base-url: https://payment-stage.funpoint.com.tw      # 测试 stage / 正式 payment.funpoint.com.tw
+  return-url: https://<公网域名>/pay/omg/notify         # 服务端回调(须回 1|OK)
+  order-result-url: https://<前端>/pay-result           # 客户端跳转(可选,即时方式)
+  payment-info-url: https://<公网域名>/pay/omg/paymentInfo  # ATM/超商虚帐/缴费码回调
+  client-redirect-url: https://<前端>/payment-info       # ATM/超商客户端展示
+```
+
+`InvoiceMark=N` 固定(发票走 ezPay,与支付解耦);`EncryptType=1` 固定。
+
+---
+
+## D9. payType 取值
+
+**Decision**:`PosOrder.payType` 新增 `"7"` = OMG 在线支付(蓝新 `"6"`,OMG `"7"`,独立值便于辨识与过渡);具体方式由 `pos_order_omg_payment.pay_type` 在回调后承载。不改 `pos_order` 表结构(代码常量体现)。
+
+---
+
+## D10. 测试环境
+
+stage `payment-stage.funpoint.com.tw` + 测试商店凭证/测试卡(文档附录)。NotifyURL 经内网穿透(ngrok/frp)暴露。链路:录入测试凭证 → 下单 → 发起 OMG → 测试卡付款 → 回调 → 订单已支付 + 推送。退款:stage `DoAction` 不可用,需正式小额或 mock。
+
+---
+
+## D11. 数据库变更管理
+
+`pos_store_omg` / `pos_order_omg_payment` / `pos_order_omg_refund` 建表 DDL 写入 `updatesql/sql.md`(标注日期与用途),**不直接执行**,由开发者统一手动执行(遵循项目规范)。`pos_order` 不改结构。
+
+---
+
+## D12. 残留待确认(不阻塞 plan 推进,留待 tasks/实现)
+
+- `NeedExtraPaidInfo` 是否本期开启(影响退款状态查询与 `gwsr`)——建议开启。
+- `CreditCheckCode` 来源(盘合后台)与是否配置化(影响信用卡明细查询/退款 Action 判定)。
+- `OrderResultURL`/`PaymentInfoURL` 对应前端页是否本期做(US3 ATM/超商展示依赖前端页)。
+- 自动关帳(每日自動關帳)是否开启(影响 DoAction 调用时段,避开 20:15–20:30)。
+
+---
+
+## D13. 代码表与枚举(reference 02–05,2026-07-29 补读)
+
+- **ChoosePayment**(请求):本期 `ALL`;细分 Credit(信用卡/銀聯/Apple Pay)/ATM(FIRST/CHINATRUST/UBOT/KGI)/CVS(CVS/FAMILY/IBON/HILIFE)/BarcodeATM(CHINATRUST)/AFTEE。
+- **Apple Pay 归在 Credit 下**(非独立 ChoosePayment);回覆 PaymentType 为 `Credit_CreditCard`,**仅凭 PaymentType 无法区分信用卡与 Apple Pay**(本期不区分)。
+- **回覆 PaymentType**(回调,落 `pos_order_omg_payment.pay_type`):`Credit_CreditCard`、`BarcodeATM_CHINATRUST`(即时);`ATM_FIRST/CHINATRUST/UBOT/KGI`、`CVS_CVS/FAMILY/IBON/HILIFE`、`AFTEE_AFTEE`(延期,走 PaymentInfoURL 取号 → US3)。
+- **RtnCode**:`1`=成功,其余失败;完整表为**图片**且持续新增,须查「歐買尬廠商後台→系統開發管理→交易狀態代碼查詢」。实现:`==1` 成功核销/退款成功,非 1 失败并记 `rtn_code`+`rtn_msg`,**不硬编码错误码分支**。
+- **URLEncode(CheckMacValue)**:照 reference 05 的 .NET 表,Java 精确实现见 contracts/api.md §C4 与上文 D1 step4。

+ 184 - 0
specs/016-omg-payment/spec.md

@@ -0,0 +1,184 @@
+# Feature Specification: OMG 支付接入(订单在线支付)
+
+**Feature Branch**: `(待创建,暂未建分支/未提交)`
+
+**Created**: 2026-07-29
+
+**Status**: Draft —— 关键决策已定(D1–D6,见「已定决策」)
+
+**Input**: 接入台湾 OMG(歐買尬 / 金流引擎 FunPoint)AIO 幕前支付(`https://developers.omg.com.tw/payment/aio/api/01_order.html`)用于订单在线支付。替代已就绪未启用的蓝新 NewebPay(011,不再启用);OMG 独立实现、不复用蓝新代码,仅复用平台共享订单/推送链路。
+
+---
+
+## 集成背景(已确认,来自 OMG AIO 文档)
+
+- **网关端点**:
+  - 测试:`https://payment-stage.funpoint.com.tw/Cashier/AioCheckOut/V5`
+  - 生产:`https://payment.funpoint.com.tw/Cashier/AioCheckOut/V5`
+  - `POST`,`application/x-www-form-urlencoded`
+- **签名模型(关键差异)**:OMG 用 **`CheckMacValue`(SHA256,`EncryptType=1`)** 单字段签名;这与蓝新 NewebPay 的 `TradeInfo`(AES) + `TradeSha`(SHA256) 双层模型**不同**。结论:**需独立的 OMG 签名工具,不能复用 `NewebPayEncryptUtil`**。具体算法(参数排序、URI 编码、HashKey/HashIV 包夹、SHA256 大写)在文档附錄 `07_appendices` / 范例 `06_check-value-sample`,留待 plan/research 落地。
+- **支持的支付方式**(`ChoosePayment`):`Credit`(信用卡)、`ApplePay`、`ATM`、`CVS`(超商代码)、`BarcodeATM`、`AFTEE`、`ALL`。
+  - ⚠️ 文档**未把「LINE Pay」列为独立 ChoosePayment** —— 用户提及 LINE Pay,需确认其接入方式(可能走 Credit 子支付或独立通道),见 D4。
+- **幕前流程**:商户组参 + 计算 `CheckMacValue` → POST 到 `AioCheckOut/V5` → 消费者在 OMG 收银台支付 → OMG **服务端 POST 回 `ReturnURL`**(商户须回 `1|OK`)→ 可选 `OrderResultURL` 客户端跳转(仅即时支付方式支持)。
+- **非即时支付(ATM/超商/BarcodeATM)**:OMG 生成虚帐/缴费码 → 回调 `PaymentInfoURL`(服务端 POST)+ 可选 `ClientRedirectURL`(客户端展示)。
+- **退款**:`05_refund` API;**订单查询**:`04_order_query`;**付款结果通知**:`03_payment_notify`。
+- **发票**:`InvoiceMark` 必须 `N` —— 电子发票仍走本项目 ezPay 链路(010/014),与支付解耦。
+- **约束**:不可用 iframe;iOS 下不可另开新窗口;`MerchantTradeNo` 全平台唯一;`ReturnURL` 与 `OrderResultURL` 不可相同。
+
+## 与本项目现有支付的关系
+
+- 本功能把 **OMG 启用为订单在线支付**。蓝新 NewebPay(011)代码就绪**未启用**;VNPay/ZaloPay 已废弃(见 `project-deprecated-code` 记忆)。
+- **复用现有链路**:订单状态流转入口(`PosOrderShOprate`/`PosOrderQsOprate`/`UserOrderController`/`PosOrderController`)、`PayPush`(iOS uni 云函数 msduser/msdrider/msdstore + `PushEvent`→`push_message`)、以及 `sendAcceptRiderPush`(迁移契机,见 CLAUDE.md「当前在用的订单操作入口」)。
+- **凭证门店级**:每门店独立 OMG 凭证(MerchantID/HashKey/HashIV),存于门店维度配置表 `pos_store_omg`。
+- **不复用蓝新代码(用户硬约束)**:OMG 全部代码(工具类/客户端/实体/Service/Controller)与表(凭证/流水/退款)独立新建、零 `newebpay` 依赖,不复用蓝新的 `pos_store_newebpay` / `pos_order_payment`;仅复用平台共享基础设施(订单状态机、推送、订单日志)。
+
+---
+
+## 已定决策(D1–D6,2026-07-29 确认)
+
+- **D1 — 支付方式范围**:支持 OMG 全部支付方式(`ChoosePayment=ALL`:信用卡 Credit / Apple Pay / ATM / 超商 CVS / BarcodeATM / AFTEE)。
+- **D2 — 与 NewebPay(011) 的关系**:**OMG 替代蓝新金流**,作为订单在线支付启用;NewebPay 不再启用。
+- **D3 — 退款/取消**:**本期范围**,订单取消走 OMG 退款 API(`05_refund`)。
+- **D4 — LINE Pay**:**本期不做**(文档未列为独立 ChoosePayment,后续再议)。
+- **D5 — 覆盖订单范围**:**本期仅餐饮订单**;旅游·机票订单(015)后续衔接,不在本期。
+- **D6 — 凭证粒度**:**门店级**(每门店独立 MerchantID / HashKey / HashIV,启用开关,同 011 模式)。
+
+> 说明:OMG 文档可正常获取(与 015 的 iFlight 不同),无硬阻塞。决策已定,可直接进 `/speckit-plan`。
+
+---
+
+## User Scenarios & Testing *(mandatory)*
+
+### User Story 1 - 消费者在线支付订单(Priority: P1)
+
+消费者下单后选择在线支付,选定方式(如信用卡/Apple Pay),跳转 OMG 收银台完成付款;付款成功后订单变为已支付并进入履约。
+
+**Why this priority**:在线支付是订单交易闭环的核心,没有它订单无法在线收款。
+
+**Independent Test**:从一笔待支付订单出发,能跳转 OMG、用测试卡完成支付,订单状态变为已支付。
+
+**Acceptance Scenarios**:
+
+1. **Given** 一笔待支付订单,**When** 消费者发起支付并选择支付方式,**Then** 跳转 OMG 收银台且金额/订单号正确。
+2. **Given** 消费者在 OMG 完成付款,**When** OMG 回调平台,**Then** 订单核销为已支付,消费者看到支付成功。
+3. **Given** 消费者取消或关闭支付页未付款,**When** 超时,**Then** 订单保持未支付并可重试,不误判为已付。
+
+---
+
+### User Story 2 - 支付回调核销与履约触发(Priority: P2)
+
+OMG 服务端回调(`ReturnURL`)到达后,验签 + 幂等更新订单,并触发后续履约(状态流转、商家/骑手推送)。
+
+**Why this priority**:支付成功必须可靠地驱动订单履约,否则「付了钱却没人接单」。
+
+**Independent Test**:模拟 OMG 回调(合法/伪造/重复),合法回调能更新订单并触发推送,伪造被拒、重复不重复发货。
+
+**Acceptance Scenarios**:
+
+1. **Given** OMG 合法回调到达,**When** 平台验签通过,**Then** 订单更新为已支付并回 `1|OK`,履约链路(含 `sendAcceptRiderPush` 推送)被触发。
+2. **Given** 同一订单的重复回调,**When** 再次到达,**Then** 幂等处理,不重复更新/不重复推送。
+3. **Given** 伪造或验签失败的回调,**When** 到达,**Then** 拒绝并记录,订单状态不变。
+
+---
+
+### User Story 3 - 非即时支付(ATM / 超商)(Priority: P3)
+
+消费者选择 ATM 转账或超商缴费时,平台展示 OMG 生成的虚帐/缴费码,消费者在期限内完成后由 OMG 回调核销。
+
+**Why this priority**:扩大支付覆盖面,但非即时到账,可晚于信用卡 MVP。
+
+**Independent Test**:选择 ATM/超商,能拿到虚帐/缴费码并展示;模拟付款完成回调能核销订单。
+
+**Acceptance Scenarios**:
+
+1. **Given** 消费者选 ATM/超商,**When** OMG 生成虚帐/缴费码回调 `PaymentInfoURL`,**Then** 平台展示付款信息与期限。
+2. **Given** 消费者在期限内完成付款,**When** OMG 回调 `ReturnURL`,**Then** 订单核销为已支付并触发履约。
+3. **Given** 超过付款期限未付,**When** 逾期,**Then** 订单按规则失效/可重试。
+
+---
+
+### User Story 4 - 订单取消与退款(Priority: P3)
+
+订单取消(用户取消/商家取消/超时退款)时,通过 OMG 退款 API(`05_refund`)原路退款,并同步状态。
+
+**Why this priority**:售后资金闭环,依赖 D3 是否本期。
+
+**Independent Test**:对一笔已支付订单发起取消,能调用 OMG 退款并同步为已退款。
+
+**Acceptance Scenarios**:
+
+1. **Given** 一笔已支付订单可退款,**When** 发起取消,**Then** 调 OMG 退款 API 并将订单标记为退款中/已退款。
+2. **Given** 退款失败,**When** OMG 返回失败,**Then** 记录原因并可重试,不误标已退款。
+
+---
+
+### User Story 5 - 商家/平台配置 OMG 凭证(Priority: P2)
+
+商家或平台在后台维护 OMG 凭证(MerchantID/HashKey/HashIV),支持测试/生产切换与启用开关。
+
+**Why this priority**:支付前提是凭证可用,属 P2 基础设施。
+
+**Independent Test**:在后台录入门店 OMG 凭证,能用于发起支付与验签。
+
+**Acceptance Scenarios**:
+
+1. **Given** 已授权运营,**When** 录入门店 OMG 凭证并启用,**Then** 该门店订单可发起 OMG 支付。
+2. **Given** 未配置或未启用凭证的门店,**When** 尝试在线支付,**Then** 不展示/不可用在线支付(可降级货到付款)。
+
+---
+
+### Edge Cases
+
+- 回调签名验证失败(伪造/篡改)—— 必须拒绝并记录。
+- 重复回调(网络重试)—— 按 `MerchantTradeNo` 幂等,不重复发货/推送。
+- 消费者支付页关闭/超时未付 —— 订单不误判,可重试或按规则失效。
+- 金额不一致(下单价 vs 支付价,如改了单/用了券)—— 以平台实际应收金额为准。
+- 回调与「用户取消」并发 —— 明确最终状态优先级,避免已付款却被取消。
+- 退款失败 / 部分退款 / 跨日退款。
+- 多门店凭证:A 门店凭证不可用于 B 门店订单。
+- 测试凭证误用到生产、或反之。
+
+## Requirements *(mandatory)*
+
+### Functional Requirements
+
+- **FR-001**:系统 MUST 支持消费者对未支付订单发起 OMG 幕前在线支付,跳转 OMG 收银台(金额、订单号、商品描述正确)。
+- **FR-002**:系统 MUST 支持 OMG 全部支付方式(`ChoosePayment=ALL`:信用卡 / Apple Pay / ATM / 超商 / BarcodeATM / AFTEE);LINE Pay 本期不做(D4)。
+- **FR-003**:系统 MUST 使用 OMG `CheckMacValue`(SHA256,`EncryptType=1`)对请求签名、对回调验签;签名工具**独立于**现有 NewebPay 工具。
+- **FR-004**:系统 MUST 在 OMG `ReturnURL` 回调时验签 + 按 `MerchantTradeNo` 幂等更新订单为已支付,并回 `1|OK`。
+- **FR-005**:系统 MUST 在支付成功后触发现有订单履约链路(状态流转 + iOS 推送 + `sendAcceptRiderPush`)。
+- **FR-006**:系统 MUST 支持非即时支付(ATM/超商)的虚帐/缴费码生成、展示与期限管理(`PaymentInfoURL` 回调)。
+- **FR-007**:系统 MUST 支持订单取消时经 OMG 退款 API(`05_refund`)退款并同步状态(本期范围,D3)。
+- **FR-008**:系统 MUST 支持门店级 OMG 凭证配置(MerchantID/HashKey/HashIV,启用开关,测试/生产切换)。
+- **FR-009**:本期功能覆盖范围**仅餐饮订单**的在线支付;旅游·机票订单(015)后续衔接,不在本期(D5)。
+- **FR-010**:系统 MUST 持久化支付流水(`MerchantTradeNo`、金额、方式、时间、回调原始报文、退款记录),支持对账。
+- **FR-011**:系统 MUST 在 `InvoiceMark=N` 下与 ezPay 电子发票链路解耦,支付不干涉开票。
+- **FR-012**:OMG **替代蓝新 NewebPay(011)** 作为订单在线支付启用,NewebPay 不再启用(D2)。
+- **FR-013**:系统 MUST NOT 复用或依赖任何蓝新 NewebPay 代码/表;OMG 凭证、流水、退款表与工具类/Controller 全部独立新建,仅可使用平台共享基础设施(订单状态机、推送、订单日志)。
+
+### Key Entities *(include if feature involves data)*
+
+- **门店 OMG 凭证(StoreOmgCredential)**:门店关联、MerchantID、HashKey、HashIV、环境(测试/生产)、启用开关。
+- **支付请求(OmgPaymentRequest)**:`MerchantTradeNo`(幂等键)、订单、金额、`ChoosePayment`、ReturnURL/NotifyURL、生成的 `CheckMacValue`。
+- **支付流水(OrderPayment)**:`MerchantTradeNo`、关联订单、金额、支付方式、状态(待付/已付/失败/退款)、OMG 交易号、回调原始报文、时间。
+- **退款记录(OmgRefund)**:关联支付流水、退款金额、退款状态、OMG 回应、时间。
+
+## Success Criteria *(mandatory)*
+
+### Measurable Outcomes
+
+- **SC-001**:消费者从下单到完成在线支付(即时方式)端到端时长满足体验预期(如主流在数分钟内)。
+- **SC-002**:支付回调核销成功率达标;重复回调 **100% 幂等**,不重复发货/推送。
+- **SC-003**:支付成功后订单履约(状态流转 + 推送)**100% 被触发**,无「已付款未接单」。
+- **SC-004**:伪造/验签失败的回调 **100% 被拒绝**,资金安全零失误。
+- **SC-005**:退款(若本期)在目标时长内完成且订单状态与资金同步。
+
+## Assumptions
+
+- 平台已/将向 OMG 申请商店号与 HashKey/HashIV(测试 + 生产)。
+- 复用现有订单状态机、`PayPush` 推送链路、用户体系,不为此功能重建。
+- OMG AIO `CheckMacValue` 算法与 ECPay 同源(业界标准),具体实现留待 plan/research。
+- 电子发票仍走 ezPay(010/014),与 OMG 支付解耦(`InvoiceMark=N`)。
+- 测试先用 `payment-stage.funpoint.com.tw` 测试端点与测试凭证,生产环境与凭证后续切换。
+- 凭证签名等敏感操作在服务端完成,HashKey/HashIV 不下发前端。
+- 在线支付与现有「货到付款」并存,由门店配置决定是否提供在线支付。

+ 217 - 0
specs/016-omg-payment/tasks.md

@@ -0,0 +1,217 @@
+---
+description: "Task list for OMG(歐買尬/FunPoint)AIO 支付接入"
+---
+
+# Tasks: OMG(歐買尬/FunPoint)AIO 支付接入
+
+**Input**: Design documents from `/specs/016-omg-payment/`
+
+**Prerequisites**: plan.md, spec.md, research.md(D1–D13), data-model.md, contracts/api.md(A1–A5/B1–B7/§C), quickstart.md(均已就绪)
+
+**Tests**: 未要求 TDD;验证以 `OmgCheckMacValue.main` 自测(官方示例值)+ quickstart.md 场景 A–E 端到端为准。
+
+**Organization**: 按 user story 组织,每个 story 可独立实现与验证。**MVP = US1 + US2**(支付闭环)。
+
+**⚠️ 硬约束(research §0)**:OMG 全部代码独立新建,**零 `newebpay` 依赖**(不得 import `com.ruoyi.app.utils.newebpay.*`、`NewebPayEncryptUtil`、`NewebPay`、`NewebpayPayController`、`PosStoreNewebpay*`,不复用 `pos_order_payment` 表)。仅复用平台共享基础设施:`PosOrder`/`IPosOrderService`、`PayPush`/`PushEventService`、`OrderLogHelper`、`IpnLog`、`OrderPushBodyDto`、`sendAcceptRiderPush`/`sendHdfkMessage` 链路、`InfoUser`、`@Anonymous`。
+
+## Format: `[ID] [P?] [Story] Description`
+
+- **[P]**: 可并行(不同文件、无依赖)
+- **[Story]**: 归属 user story(US1~US5)
+- 描述含精确文件路径;前端 CRLF 文件用 Python 脚本编辑
+
+---
+
+## Phase 1: Setup(基础设施)
+
+**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` 在代码固定,不入配置
+
+---
+
+## Phase 2: Foundational(阻塞前置,所有 story 依赖)
+
+**Purpose**: 签名工具、HTTP 客户端、三实体、Mapper——所有 story 的公共地基
+
+**⚠️ 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`
+
+**Checkpoint**:`OmgCheckMacValue.main` 自测通过、三表可建、实体/Mapper 就绪 → 可开始 user story
+
+---
+
+## Phase 3: User Story 1 - 发起 OMG 支付 (Priority: P1) 🎯 MVP part 1
+
+**Goal**: C 端用户下单后调发起接口拿到 form 参数,前端 Form Post 跳转 OMG 收银台(ChoosePayment=ALL)
+
+**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 加到对应对象层级,有意义命名)
+
+**Checkpoint**: 发起支付链路通,可跳转 OMG 测试收银台(此时回调未接,订单状态待 US2 更新)
+
+---
+
+## Phase 4: User Story 2 - 支付回调核销与履约触发 (Priority: P2) 🎯 MVP part 2
+
+**Goal**: OMG `ReturnURL` 回调到达后,验签 CheckMacValue + 幂等 + 金额 + RtnCode/SimulatePaid 校验,更新订单 payStatus=1 并触发推送(含可接单骑手);`/pay/omg/return` 仅引导
+
+**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
+
+**Checkpoint**: US1+US2 构成完整 MVP——下单→付款→订单已支付→推送,可端到端验证(quickstart 场景 B、C)
+
+---
+
+## Phase 5: User Story 5 - 门店 OMG 凭证开通管理 (Priority: P2)
+
+**Goal**: 运营在平台后台录入/验证/启停门店 OMG 凭证(独立于蓝新凭证表)
+
+**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 菜单,不直接执行)
+
+**Checkpoint**: 凭证可全流程管理,US1 发起支付有真实凭证来源(不必手插数据)
+
+---
+
+## Phase 6: User Story 3 - ATM / 超商 非即时支付 (Priority: P3)
+
+**Goal**: `ChoosePayment=ALL` 下消费者选 ATM/超商时,平台展示 OMG 虚帐/缴费码,消费者期限内付款后回调核销
+
+**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_*`)→ 订单已支付
+
+**Checkpoint**: ATM/超商 全链路(取号→展示→付款→核销)
+
+---
+
+## Phase 7: User Story 4 - 订单取消与退款 (Priority: P3)
+
+**Goal**: 订单取消时,信用卡订单调 OMG `DoAction` 退款并同步状态;ATM/超商记录待人工
+
+**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
+
+**Checkpoint**: 信用卡退款闭环(stage `DoAction` 不可用,退款须正式小额或 mock 验证)
+
+---
+
+## Phase 8: Polish & Cross-Cutting
+
+**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,违反硬约束)
+- [ ] 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 菜单权限)
+
+---
+
+## Dependencies & Execution Order
+
+### Phase 依赖
+- **Phase 1 Setup**: 无依赖,立即开始
+- **Phase 2 Foundational**: 依赖 Phase 1 —— **阻塞所有 user story**(`OmgCheckMacValue` 自测必须先过)
+- **Phase 3 US1**: 依赖 Phase 2
+- **Phase 4 US2**: 依赖 US1(回调处理依赖发起产生的流水与订单关联)
+- **Phase 5 US5(凭证管理)**: 依赖 Phase 2(凭证表/Mapper);**可与 US1/US2 并行**(US1 测试可临时手插凭证数据)
+- **Phase 6 US3(ATM/超商)**: 依赖 US1+US2(复用发起+回调主链路,增量在取号回调与展示)
+- **Phase 7 US4(退款)**: 依赖 US1+US2(依赖已支付流水与 trade_no)
+- **Phase 8 Polish**: 依赖所有欲交付的 story 完成
+
+### User Story 独立性
+- **US1**: 依赖 Foundational,无跨 story 依赖
+- **US2**: 依赖 US1(闭环下半段)
+- **US3**: 依赖 US1+US2,增量在 PaymentInfoURL 取号与展示
+- **US4**: 依赖 US1+US2
+- **US5**: 依赖 Foundational,可与 US1/US2 并行
+
+### 并行机会
+- Phase 2 的 T003/T005/T006/T007/T008/T009/T010/T011 互不冲突,可全部并行(T004 CheckMacValue 优先跑通自测)
+- US5(Phase 5)可与 US1/US2(Phase 3/4)并行(不同文件、不同端)
+- Phase 8 的 T037/T038/T039 可并行
+
+---
+
+## Parallel Example: Foundational
+
+```bash
+# 以下任务文件互不冲突,可并行:
+Task: "OmgPayConfig in ruoyi-admin/.../utils/omg/OmgPayConfig.java"
+Task: "PosStoreOmg in ruoyi-system/.../domain/PosStoreOmg.java"
+Task: "PosOrderOmgPayment in ruoyi-system/.../domain/PosOrderOmgPayment.java"
+Task: "PosOrderOmgRefund in ruoyi-system/.../domain/PosOrderOmgRefund.java"
+Task: "PosStoreOmgMapper + XML"
+Task: "PosOrderOmgPaymentMapper + XML"
+Task: "PosOrderOmgRefundMapper + XML"
+Task: "OmgPay HTTP 客户端骨架 in ruoyi-admin/.../utils/omg/OmgPay.java"
+# OmgCheckMacValue(T004) 含被他人依赖的签名契约,建议优先完成并跑通自测
+```
+
+---
+
+## Implementation Strategy
+
+### MVP First(US1 + US2)
+1. Phase 1 Setup + Phase 2 Foundational(**`OmgCheckMacValue` 自测必须先过**)
+2. Phase 3 US1(发起支付)+ Phase 4 US2(回调处理)→ 端到端验证(quickstart 场景 B、C)
+3. **STOP and VALIDATE**: 下单→信用卡付款→订单已支付→推送(含可接单骑手),全链路通
+4. 此时已是最小可用产品
+
+### 增量交付
+1. MVP(US1+US2)→ 验证 → 可上测试环境
+2. + US5(凭证管理)→ 去除手插数据、运营自助
+3. + US3(ATM/超商)→ 覆盖延期方式
+4. + US4(退款)→ 售后资金闭环
+5. Polish(i18n/安全/零蓝新复核/全场景验证)→ 可转正式
+
+### 并行策略
+- Foundational 完成后:开发者 A 做 US1+US2(主链路),开发者 B 做 US5(凭证管理前后端),互不阻塞
+- US3/US4 待主链路稳定后接力
+
+---
+
+## Notes
+
+- [P] 任务 = 不同文件、无依赖;同文件多改动须串行
+- 前端 CRLF 文件(foodie-store/foodie-admin-vue)用 Python 脚本编辑,避免 Edit 匹配失败
+- 所有数据库变更写 `updatesql/sql.md`,不直接执行
+- 每个任务或逻辑组完成后提交(按用户指示);在 test 分支开发,不新建分支
+- HashKey/HashIV 为敏感凭证:仅后端持有,日志脱敏,不下发前端
+- **硬约束**:全程零 `newebpay` 依赖(T039 复核);Apple Pay 归在 Credit 下,回覆 PaymentType=`Credit_CreditCard`

+ 95 - 0
specs/017-oauth-login/plan.md

@@ -0,0 +1,95 @@
+# 017 实施计划
+
+## 架构:最大化复用现有登录链路
+
+```
+provider 凭证 ──▶ OAuthVerifyService.verify(provider, credential) ──▶ providerUid
+                                                                        │
+                ┌───────────────────────────────────────────────────────┘
+                ▼
+        查 info_user_oauth (provider, provider_uid)
+        ├─ 命中 → 取 InfoUser → 校 status/del_flag → issueToken(provider) → 返回
+        └─ 未命中 → Redis 缓存 {provider,providerUid}(tempKey,TTL5m) → 返回 needPhone+tempKey
+                                                                        │
+                ┌───────────────────────────────────────────────────────┘
+                ▼  (前端引导输手机号 → getcode 发短信)
+        oauthBindPhone(tempKey, phone, code)
+          取 providerUid + 验短信(复用 lodeing: redis code==input || "8888")
+          getuser(phone):
+            ├─ 已注册 → 关联(写 oauth)
+            └─ 未注册 → createUser(phone) 新建 → 写 oauth
+          → issueToken(provider) → 返回
+```
+
+- **token 签发**:复用 `JwtUtil.setToken(CacheConstants.USER_TOKEN_KEY, loginDto)`,仅给 `LoginUserDto` 加 `provider` 字段并在 setToken 写入 claim。
+- **新建用户**:复用 `createUser` 的「建 InfoUser + 建钱包 + 删旧 token」逻辑;昵称=手机号不变。
+- **短信**:复用 `getcode` 发送 + `lodeing` 的校验(key=phone 去+,万能码 8888)。
+
+## 数据模型
+
+```sql
+CREATE TABLE info_user_oauth (
+  id           BIGINT AUTO_INCREMENT PRIMARY KEY,
+  user_id      BIGINT       NOT NULL COMMENT '关联 info_user.user_id',
+  provider     VARCHAR(16)  NOT NULL COMMENT 'apple/google/line',
+  provider_uid VARCHAR(64)  NOT NULL COMMENT '三方稳定用户ID(Apple sub / Google sub / LINE userId)',
+  create_time  DATETIME     DEFAULT CURRENT_TIMESTAMP,
+  UNIQUE KEY uk_provider_uid (provider, provider_uid),
+  KEY idx_user_id (user_id)
+) COMMENT='三方账号绑定';
+```
+
+## API 契约
+
+### 1) `POST /infouser/user/oauthLogin`  (`@Anonymous`)
+请求 `OAuthLoginDto`:
+```
+provider     apple|google|line
+credential   identityToken(Apple) / idToken(Google) / accessToken(LINE)
+cid, cidType, deviceToken, voIPToken   // 推送字段,与现有登录一致
+```
+响应(命中):
+```
+{ code:200, msg, data: InfoUser, token }   // token claim provider=本次渠道
+```
+响应(未命中,首次):
+```
+{ code:200, msg, data:{ status:"needPhone", tempKey } }
+```
+
+### 2) `POST /infouser/user/oauthBindPhone`  (`@Anonymous`)
+请求 `OAuthBindDto`:
+```
+tempKey, phone, code   // code=短信验证码
+cid, cidType, deviceToken, voIPToken
+```
+响应:`{ code:200, msg, data: InfoUser, token }`
+
+## 三家凭证校验(OAuthVerifyService)
+
+| provider | 凭证 | 校验 | 取 providerUid | 依赖 |
+|---|---|---|---|---|
+| apple | identityToken(ES256 JWT) | 拉 `appleid.apple.com/auth/keys`(JWKS) 验签 + 校 iss/aud/exp | `sub` | nimbus-jose-jwt(新增) |
+| google | ID-Token | GET `oauth2.googleapis.com/tokeninfo?id_token=` 取 claims + 校 aud=clientId | `sub` | httpclient4(已有) |
+| line | access_token | GET `api.line.me/v2/profile`(Bearer) | `userId` | httpclient4(已有) |
+
+> Google 走 tokeninfo HTTP 而非 firebase-admin,因后者需初始化 FirebaseApp+服务账号(项目未配置),tokeninfo 零配置即可。
+
+## 文件清单
+
+| 类型 | 文件 |
+|---|---|
+| 新增实体 | `ruoyi-system/.../domain/InfoUserOauth.java` |
+| 新增 mapper | `ruoyi-system/.../mapper/InfoUserOauthMapper.java`(BaseMapper,无 XML) |
+| 新增校验服务 | `ruoyi-admin/.../utils/oauth/OAuthVerifyService.java` |
+| 新增 DTO | `ruoyi-admin/.../user/dto/OAuthLoginDto.java`、`OAuthBindDto.java` |
+| 改 controller | `ruoyi-admin/.../user/InfoUserController.java`(+2 方法) |
+| 改 token | `ruoyi-common/.../model/LoginUserDto.java`、`ruoyi-system/.../utils/JwtUtil.java` |
+| 改配置 | `ruoyi-admin/src/main/resources/application.yml`(oauth 段) |
+| 改依赖 | `ruoyi-admin/pom.xml`(+nimbus-jose-jwt) |
+| SQL | `updatesql/sql.md` |
+
+## 风险 / 待验证
+
+- Apple / Google / LINE 的凭证校验**需用各家真实 token + 正确 clientId/bundleId 联调**才能端到端验证;配置值(clientId/jwks url)由前端/运营提供后填入 application.yml。
+- Apple JWKS 建议加缓存(key 偶尔轮换);初版每次拉取或加内存缓存。

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

@@ -0,0 +1,51 @@
+# 017 - Apple / Google / LINE 三方登录
+
+## 背景
+
+C 端 app 现仅支持「手机号 + 短信验证码」登录(`/infouser/user/lodeing`,登录即注册)。本特性新增 Apple / Google / LINE 三方登录,降低台湾市场获客与登录摩擦。鉴权沿用现有自写 `JwtUtil`(HS256 JWT + Redis 会话),不引入新鉴权体系。
+
+## 锁定的关键决策(业务确认)
+
+1. **手机号始终是主键**:三方账号只是绑在「某个手机号账号」上的快捷入口,不在 InfoUser 上落三方 ID(独立表 `info_user_oauth` 记录绑定关系)。
+2. **首次使用任一三方登录强制手机验证**:拿到手机号后查 `info_user`——已注册则**关联**到该老账号(写一条 oauth 绑定),未注册则**新建**账号后再绑定。**不以邮箱做关联**。
+3. **不使用 Apple 邮箱信息**:Apple 只取 identityToken 的 `sub`(稳定用户ID),忽略 email。
+4. **记录登录渠道 loginType**:因一个用户可绑多家三方,loginType 不落用户表,而写入**本次会话的 JWT claim**(`provider`)。
+
+## User Story
+
+### US1 - 三方登录入口 (P0)
+客户在登录页选 Apple/Google/LINE,客户端拿到 provider 凭证后调后端校验。
+
+### US2 - 首次登录手机验证 + 绑定 (P0)
+首次用某三方账号登录时,强制输手机号 + 短信验证码:手机号已注册→关联该账号;未注册→新建账号;随后建立 oauth 绑定。
+
+### US3 - 已绑定快捷登录 (P0)
+已绑过该三方的账号再次登录,校验凭证通过即直接签发 token,免手机验证。
+
+## Functional Requirements
+
+- **FR-001**: 提供 `POST /infouser/user/oauthLogin {provider, credential, ...}`,后端校验 provider 凭证换取稳定 providerUid。
+- **FR-002**: 凭证校验三选一:Apple=验 ES256 identityToken 取 sub;Google=tokeninfo HTTP 验真取 sub 并校 audience;LINE=前端传授权 code,后端用 code+clientSecret+clientId 向 `oauth2/v2.1/token` 换 access_token,再调 v2/profile 取 userId(Authorization Code 流程,不直接收前端 accessToken)。
+- **FR-003**: 按 (provider, providerUid) 查 `info_user_oauth`:命中→校验用户 status/del_flag 正常后直接签发 token(claim `provider`)返回;未命中→缓存 {provider,providerUid} 到 Redis(短TTL),返回 `needPhone` + tempKey。
+- **FR-004**: 提供 `POST /infouser/user/oauthBindPhone {tempKey, phone, code, ...}`:取回缓存的 providerUid + 验短信码(复用 lodeing 逻辑,含万能码 8888)→ `getuser(phone)`:已注册→关联;未注册→`createUser` 新建;随后 insert `info_user_oauth(user_id, provider, provider_uid)`。
+- **FR-005**: 新建用户昵称统一用手机号(与现有 createUser 一致),avatar 留空,不用 provider 的昵称/头像。
+- **FR-006**: token claim 增加 `provider` 字段(apple/google/line;手机号登录为 phone),供登录渠道统计或「未绑手机限制」类约束使用。
+- **FR-007**: 登录端点 `@Anonymous` 放行;受保护接口继续走 `@Auth`,零额外接入。
+- **FR-008**: 凭证校验失败 / tempKey 过期 / 短信码错误 → 明确错误提示,不签发 token、不建账号。
+
+## Key Entities
+
+- **info_user_oauth(新增)**:`id`、`user_id`、`provider`(apple/google/line)、`provider_uid`、`create_time`。`UNIQUE(provider, provider_uid)` 用于按三方ID反查用户;`user_id` 普通索引。
+- **InfoUser(已有,不改)**:仍是手机号为主键,无三方 ID 列。
+
+## 安全要点
+
+- Apple/Google 必须校验 audience(= 我方 client_id/bundleId),防其他 App 的 token 被冒用。
+- Apple 校验 iss=`https://appleid.apple.com` + exp。
+- tempKey 一次性、短 TTL(5 分钟),防重放。
+
+## 不在本期范围
+
+- 后台管理端解绑/查看三方绑定(后续增量)。
+- 第三家以上三方(微信/Facebook)——表结构已可扩展,但本期只接 Apple/Google/LINE。
+- Apple 隐藏邮箱中转、refresh_token 续期等高级场景。

+ 32 - 0
specs/017-oauth-login/tasks.md

@@ -0,0 +1,32 @@
+# 017 任务清单
+
+> 顺序执行;每步完成后编译验证。
+
+- [ ] T1 数据模型:`updatesql/sql.md` 加 `info_user_oauth` 建表(含 uk_provider_uid 唯一索引、idx_user_id)。
+- [ ] T2 实体:`InfoUserOauth.java`(@TableName=info_user_oauth,字段 id/userId/provider/providerUid/createTime)。
+- [ ] T3 mapper:`InfoUserOauthMapper extends BaseMapper<InfoUserOauth>`(无 XML)。
+- [ ] T4 LoginUserDto 加 `provider` 字段。
+- [ ] T5 JwtUtil.setToken(tokenKey, LoginUserDto) 加 `provider` claim(非空才写)。
+- [ ] T6 配置:application.yml 加 `oauth.apple.clientId/jwks-url`、`oauth.google.clientId/tokeninfo-url`、`oauth.line.profile-url`。
+- [ ] T7 依赖:ruoyi-admin/pom.xml 加 `nimbus-jose-jwt`。
+- [ ] T8 OAuthVerifyService:`verify(provider, credential)` → providerUid;Apple(nimbus 验 ES256)、Google(tokeninfo)、LINE(v2/profile),校 audience/iss/exp。
+- [ ] T9 DTO:OAuthLoginDto、OAuthBindDto。
+- [ ] T10 InfoUserController.oauthLogin:校验→查 oauth→命中签 token / 未命中缓存 tempKey 返 needPhone。
+- [ ] T11 InfoUserController.oauthBindPhone:取 providerUid + 验短信→关联/新建→写 oauth→签 token。
+- [ ] T12 编译验证(JDK21):ruoyi-admin + ruoyi-system + ruoyi-common。
+
+## 联调待办(开发提供配置后)
+- [ ] application.yml 填真实 apple bundleId/clientId、google clientId、line channel。
+- [ ] 三家真实 token 端到端验证(Apple 需真机/测试机签 Sign in with Apple)。
+- [ ] 前端 app:登录页三按钮 + 首次绑手机流程 + i18n。
+
+## 代码评审修复 (2026-07-30)
+
+评审发现并修复(均已 JDK21 编译通过):
+
+- [x] **HTTP 超时**:`OAuthVerifyService.httpGet` 原用 `HttpClients.createDefault()` 无超时,provider 慢/不可达会拖垮请求线程池 → 加 `RequestConfig`(连接 5s / 读取 10s / 获取连接 5s)。
+- [x] **停用账号拦截**:`oauthBindPhone` 用 `getuser(phone)` 只滤 del_flag、不滤 status,停用账号(status≠0)能通过三方绑定绕过停用 → 加 `status!=0` 拦截(与 `lodeing` / `oauthLogin` 已绑定路径一致),文案复用 `no.user.stop`。
+- [x] **Apple audience**:原 `aud.get(0)` 在多 audience 时可能漏判 → 改 `aud.contains(appleClientId)`。
+- [x] **i18n 化(裸中文 → message key)**:新增 `no.oauth.*` 共 10 个 key,写入 5 份 properties(`messages` / `zh_CN` / `zh_TW` / `en_US` / `vi`);`OAuthVerifyService` 异常文案与 `InfoUserController` 提示全部走 `MessageUtils.message`,与 `lodeing` 风格一致。新增 key:
+  - 控制器:`no.oauth.provider.blank` / `needphone` / `tempkey.missing` / `tempkey.expired`
+  - 校验服务:`no.oauth.credential.blank` / `provider.unsupported` / `token.invalid` / `token.expired` / `audience.mismatch` / `verify.fail`(带 `{0}` 渠道名、`verify.fail` 带 `{1}` 异常详情)

+ 80 - 0
updatesql/sql.md

@@ -400,3 +400,83 @@ ALTER TABLE pos_order_invoice
 -- 捐赠机构字典 pos_love_org 表 + 2016 行数据见 updatesql/pos_love_org.sql
 -- (由 specs/010-order-invoice/gen_love_org_sql.py 从财政部 CSV 生成,需另执行)
 ```
+
+## 2026-07-24 用户发票抬头管理(014-invoice-profile)
+
+```sql
+-- 用户发票抬头表(用户级,镜像 info_address;区别于订单级 pos_order_invoice)
+CREATE TABLE info_invoice (
+  id            BIGINT       NOT NULL AUTO_INCREMENT COMMENT '主键',
+  user_id       BIGINT       NOT NULL                COMMENT '用户id(JWT隔离)',
+  title_name    VARCHAR(50)  NOT NULL                COMMENT '抬头备注名(如 公司-美食達)',
+  category      VARCHAR(8)   NOT NULL                COMMENT '类型 B2C=个人/B2B=公司',
+  buyer_name    VARCHAR(100) NOT NULL                COMMENT '买方名称 B2C=个人姓名 B2B=公司名',
+  buyer_ubn     VARCHAR(16)  DEFAULT NULL            COMMENT '统一编号(统编,8位数字,含前导零);仅B2B',
+  buyer_email   VARCHAR(200) DEFAULT NULL            COMMENT '邮箱;B2B必填/B2C仅会员载具必填',
+  carrier_type  VARCHAR(8)   DEFAULT NULL            COMMENT '载具类型 0手机条码/1自然人凭证/2ezPay会员;仅B2C',
+  carrier_num   VARCHAR(64)  DEFAULT NULL            COMMENT '载具号码',
+  create_time   DATETIME     DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  update_time   DATETIME     DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  PRIMARY KEY (id),
+  KEY idx_user (user_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户发票抬头(发票信息管理)';
+```
+
+## 2026-07-28 订单自动开票:发票意图落点调整 + 抬头默认标记(010-order-invoice Phase 11,方案B)
+
+> 方案B:发票意图(下单捕获的 invoiceChoice/载具/统编/捐赠码)改落 `pos_order_invoice` 意图行(与开票结果同表,避免意图/结果双写),`pos_order` 不再加发票列。
+> `pos_order_invoice` 加 `invoice_choice`(下单意图)+ `issue_triggered`(是否已触发开票 0/1,与 invoice_status 独立:下单捕获意图=0、出餐开票流程执行过=1;管理端「订单发票管理」按 `issue_triggered=1` 筛选,排除未触发的意图行)。
+
+```sql
+-- ① pos_order 发票意图列【已废弃·方案B】:意图改落 pos_order_invoice,pos_order 不再加列。
+--    若 2026-07-27 初版 pos_order 加列已执行,用以下 DROP 回滚;未执行则跳过本段。
+ALTER TABLE pos_order
+  DROP COLUMN invoice_choice,
+  DROP COLUMN carrier_type,
+  DROP COLUMN carrier_num,
+  DROP COLUMN buyer_ubn,
+  DROP COLUMN invoice_buyer_name,
+  DROP COLUMN love_code;
+
+-- ② pos_order_invoice 加发票意图列(方案B:下单捕获意图 + 是否已触发开票标记)
+ALTER TABLE pos_order_invoice
+  ADD COLUMN invoice_choice   VARCHAR(16) DEFAULT NULL COMMENT '发票选择(下单意图) PAPER个人纸本/PHONE_BARCODE手机条码/CITIZEN自然人凭证/LOVE_CODE捐赠/COMPANY公司;NULL=该单不开票',
+  ADD COLUMN issue_triggered  TINYINT     NOT NULL DEFAULT 0 COMMENT '是否已触发开票 0否1是;出餐开票流程执行过即为1,与 invoice_status 独立(下单捕获意图=0;管理端发票管理按=1筛选,排除未触发的意图行)';
+
+-- ③ info_invoice 加"是否默认"标记(014 原"无默认"FR-006,现支持全联式"同意设置为默认载具/抬头")
+ALTER TABLE info_invoice
+  ADD COLUMN is_default TINYINT NOT NULL DEFAULT 0 COMMENT '是否默认载具/抬头 0否 1是(同一用户仅一条为1)';
+```
+
+
+## 2026-07-30 新增三方登录绑定表(017-oauth-login)
+
+```sql
+CREATE TABLE info_user_oauth (
+  id           BIGINT AUTO_INCREMENT PRIMARY KEY,
+  user_id      BIGINT       NOT NULL COMMENT '关联 info_user.user_id',
+  provider     VARCHAR(16)  NOT NULL COMMENT '三方渠道:apple/google/line',
+  provider_uid VARCHAR(64)  NOT NULL COMMENT '三方稳定用户ID(Apple sub / Google sub / LINE userId)',
+  create_time  DATETIME     DEFAULT CURRENT_TIMESTAMP COMMENT '绑定时间',
+  UNIQUE KEY uk_provider_uid (provider, provider_uid),
+  KEY idx_user_id (user_id)
+) COMMENT='三方账号绑定(Apple/Google/LINE)';
+```
+
+## 2026-08-03 骑手 newTask 距离上限字典
+
+```sql
+-- 骑手 newTask 距离上限(PosOrderQsOprateController.orderList newTask 分支:距商家超过该值的订单不显示给骑手)
+INSERT INTO sys_dict_type (dict_name, dict_type, status, create_by, create_time, remark)
+VALUES ('骑手新任务距离上限', 'sys_qs_newtask_distance', '0', 'admin', NOW(), '骑手 newTask 列表只显示距商家该距离(公里)内的订单');
+
+INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, remark)
+VALUES (1, '30公里', '30', 'sys_qs_newtask_distance', '', 'default', 'Y', '0', 'admin', NOW(), '骑手新任务距商家距离上限(公里),超过不显示');
+```
+
+## 2026-08-03 骑手车型字段
+
+```sql
+-- 骑手车型(InfoUser.vehicleType,仅骑手 userType=2 用):1=机车 2=轿车
+ALTER TABLE info_user ADD COLUMN vehicle_type VARCHAR(2) DEFAULT NULL COMMENT '骑手车型 1机车 2轿车';
+```

Некоторые файлы не были показаны из-за большого количества измененных файлов