2 Commits acde9ced79 ... 13dc573f46

Author SHA1 Message Date
  qmj 13dc573f46 注销冷静期登录改为二次确认后恢复账号 4 days ago
  qmj 948825fe5f 校验订单整数运费、修正账单汇总精度并删除旧addorder 4 days ago
41 changed files with 973 additions and 449 deletions
  1. 1 1
      .specify/feature.json
  2. 1 1
      AGENTS.md
  3. 1 1
      CLAUDE.md
  4. 4 2
      docs/order-api-usage.md
  5. 29 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/OrderMoneyValidator.java
  6. 2 343
      ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderController.java
  7. 2 11
      ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderShOprateController.java
  8. 1 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/UserOrderController.java
  9. 1 1
      ruoyi-admin/src/main/java/com/ruoyi/app/order/dto/OrderCreatItem.java
  10. 2 2
      ruoyi-admin/src/main/java/com/ruoyi/app/order/dto/OrderCreateInput.java
  11. 11 3
      ruoyi-admin/src/main/java/com/ruoyi/app/pay/UserBillingController.java
  12. 20 0
      ruoyi-admin/src/main/java/com/ruoyi/app/user/CancellationLoginContext.java
  13. 83 11
      ruoyi-admin/src/main/java/com/ruoyi/app/user/InfoUserController.java
  14. 13 2
      ruoyi-admin/src/main/java/com/ruoyi/app/user/LineCallbackController.java
  15. 69 7
      ruoyi-admin/src/main/java/com/ruoyi/app/user/UserCancellationService.java
  16. 4 0
      ruoyi-admin/src/main/java/com/ruoyi/app/user/dto/CancellationLoginConfirmDto.java
  17. 5 0
      ruoyi-admin/src/main/resources/i18n/messages.properties
  18. 5 0
      ruoyi-admin/src/main/resources/i18n/messages_en_US.properties
  19. 5 0
      ruoyi-admin/src/main/resources/i18n/messages_th_TH.properties
  20. 5 0
      ruoyi-admin/src/main/resources/i18n/messages_vi.properties
  21. 5 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties
  22. 5 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties
  23. 74 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/OrderWholeYuanFreightTest.java
  24. 29 0
      ruoyi-admin/src/test/java/com/ruoyi/app/pay/StoreBillingSubaccountAccessTest.java
  25. 217 0
      ruoyi-admin/src/test/java/com/ruoyi/app/user/CancellationLoginConfirmationTest.java
  26. 36 4
      ruoyi-admin/src/test/java/com/ruoyi/app/user/DeviceTrustFlowTest.java
  27. 11 4
      ruoyi-admin/src/test/java/com/ruoyi/app/user/InfoUserControllerTest.java
  28. 13 5
      ruoyi-admin/src/test/java/com/ruoyi/app/user/LineCallbackDeviceTrustTest.java
  29. 1 1
      ruoyi-admin/src/test/java/com/ruoyi/app/user/MerchantSessionAuthAnnotationTest.java
  30. 133 24
      ruoyi-admin/src/test/java/com/ruoyi/app/user/UserCancellationServiceTest.java
  31. 10 0
      specs/003-merchant-billing/plan.md
  32. 15 0
      specs/003-merchant-billing/spec.md
  33. 23 0
      specs/003-merchant-billing/tasks.md
  34. 1 1
      specs/012-im-user-integration/code-status.md
  35. 1 2
      specs/031-pay-method-config/contracts/api.md
  36. 1 1
      specs/032-self-hosted-map-integration/spec.md
  37. 1 1
      specs/034-stall-qualification/quickstart.md
  38. 99 19
      specs/040-user-cancellation-grace/frontend-app-integration.md
  39. 8 0
      specs/040-user-cancellation-grace/plan.md
  40. 13 2
      specs/040-user-cancellation-grace/spec.md
  41. 13 0
      specs/040-user-cancellation-grace/tasks.md

+ 1 - 1
.specify/feature.json

@@ -1 +1 @@
-{"feature_directory": "specs/038-self-delivery-completion"}
+{"feature_directory":"specs/003-merchant-billing"}

+ 1 - 1
AGENTS.md

@@ -67,7 +67,7 @@
 - `PosOrderShOprateController`:商家出餐和取消。
 - `PosOrderShOprateController`:商家出餐和取消。
 - `PosOrderQsOprateController`:骑手接单、取餐、送达,使用 `deliveryStatus` 1/2/3。
 - `PosOrderQsOprateController`:骑手接单、取餐、送达,使用 `deliveryStatus` 1/2/3。
 - `UserOrderController`:用户下单和取消。
 - `UserOrderController`:用户下单和取消。
-- `PosOrderController`:通用管理接口 `POST /system/order` 的 `add(...)`(新增订单)和 `PUT /system/order` 的 `edit(...)`(修改订单信息)已废弃;代码仅保留备查,不要作为订单创建或修改入口,也不要在其上做增量修改。`/addorder`、`/setorderuzt` 和 list 查询仍在使用,主要订单状态流转已经迁出。
+- `PosOrderController`:通用管理接口 `POST /system/order` 的 `add(...)`(新增订单)和 `PUT /system/order` 的 `edit(...)`(修改订单信息)已废弃;代码仅保留备查,不要作为订单创建或修改入口,也不要在其上做增量修改。`/addorder` 已按用户要求于2026-11-04移除,商家下单使用 `/system/orderShOprate/createOrder`;`/setorderuzt` 和 list 查询仍在使用,主要订单状态流转已经迁出。
 
 
 推送以当前真实通道为准:`PayPush` 没有 Android FCM;本项目仅有 iOS uni 云函数 `msduser`、`msdrider`、`msdstore`,以及 `PushEvent -> push_message` 入库。`cidType` 当前没有实际区分作用。相关状态见 `specs/012-im-user-integration/code-status.md`。
 推送以当前真实通道为准:`PayPush` 没有 Android FCM;本项目仅有 iOS uni 云函数 `msduser`、`msdrider`、`msdstore`,以及 `PushEvent -> push_message` 入库。`cidType` 当前没有实际区分作用。相关状态见 `specs/012-im-user-integration/code-status.md`。
 
 

+ 1 - 1
CLAUDE.md

@@ -36,7 +36,7 @@ Auto-generated from all feature plans. Last updated: 2026-08-18
 - `PosOrderShOprateController` — 商家操作(出餐 chuCan、商家取消)
 - `PosOrderShOprateController` — 商家操作(出餐 chuCan、商家取消)
 - `PosOrderQsOprateController` — 骑手操作(接单/取餐/送达,按 `deliveryStatus` 1/2/3)
 - `PosOrderQsOprateController` — 骑手操作(接单/取餐/送达,按 `deliveryStatus` 1/2/3)
 - `UserOrderController` — 用户操作(下单、用户取消)
 - `UserOrderController` — 用户操作(下单、用户取消)
-- `PosOrderController` — 通用管理接口 `POST /system/order` 的 `add(...)`、`PUT /system/order` 的 `edit(...)` 及 `/addorder`(旧代客外送下单,货到付款)均已废弃(2026-09-29 确认),代码仅保留备查,不要作为业务入口或在其上增量修改;商家端下单走 `PosOrderShOprateController` 的 `/createOrder`;`/setorderuzt`(平台改状态)及 list 查询仍在用;订单状态流转已迁出。详见 docs/order-api-usage.md
+- `PosOrderController` — 通用管理接口 `POST /system/order` 的 `add(...)`、`PUT /system/order` 的 `edit(...)` 均已废弃(2026-09-29 确认),代码仅保留备查,不要作为业务入口或在其上增量修改;`/addorder` 已于2026-11-04移除;商家端下单走 `PosOrderShOprateController` 的 `/createOrder`;`/setorderuzt`(平台改状态)及 list 查询仍在用;订单状态流转已迁出。详见 docs/order-api-usage.md
 
 
 **推送通道:** `PayPush` 无 Android FCM(cte_server 有,本项目砍掉),仅 iOS uni 云函数(`msduser/msdrider/msdstore`)+ 入库(`PushEvent`→`push_message`);`cidType` 形同虚设(到处传 `""`)。详见 `specs/012-im-user-integration/code-status.md`。
 **推送通道:** `PayPush` 无 Android FCM(cte_server 有,本项目砍掉),仅 iOS uni 云函数(`msduser/msdrider/msdstore`)+ 入库(`PushEvent`→`push_message`);`cidType` 形同虚设(到处传 `""`)。详见 `specs/012-im-user-integration/code-status.md`。
 
 

+ 4 - 2
docs/order-api-usage.md

@@ -26,15 +26,17 @@
 
 
 | 接口 | 说明 |
 | 接口 | 说明 |
 |---|---|
 |---|---|
-| `/addorder` | 旧代客外送下单流程(货到付款 + sendHdfkMessage 推送);2026-09-29 确认废弃,商家端下单走 `PosOrderShOprateController /createOrder`。内含 031 支付闸门 `assertUsable` 调用点,随入口一并废弃,勿再以此为基准增量开发 |
+
 | `POST /system/order`(`add`) | RuoYi CRUD 新增订单;用户下单走 `UserOrderController.createOrder` |
 | `POST /system/order`(`add`) | RuoYi CRUD 新增订单;用户下单走 `UserOrderController.createOrder` |
 | `PUT /system/order`(`edit`) | 修改订单信息入口不再使用;状态流转已迁出 |
 | `PUT /system/order`(`edit`) | 修改订单信息入口不再使用;状态流转已迁出 |
 | `/testYjsdTime` | 随 `setYjsdTime` 停用(依赖已停用的 Mapbox 接口,见 map-api-usage.md) |
 | `/testYjsdTime` | 随 `setYjsdTime` 停用(依赖已停用的 Mapbox 接口,见 map-api-usage.md) |
 
 
+`POST /system/order/addorder` 已于2026-11-04删除方法及路由,不再接收旧请求;商家代客下单使用 `POST /system/orderShOprate/createOrder`。
+
 ## 新改动规则
 ## 新改动规则
 
 
 1. 状态流转类新功能(改 state / deliveryStatus / payStatus / afterSaleStatus 的操作)一律实现到对应角色控制器,不进 `PosOrderController`。
 1. 状态流转类新功能(改 state / deliveryStatus / payStatus / afterSaleStatus 的操作)一律实现到对应角色控制器,不进 `PosOrderController`。
-2. `PosOrderController` 仅维护查询接口、平台管理接口与既有的 `/addorder`、`/setorderuzt`。
+2. `PosOrderController` 仅维护查询接口、平台管理接口与既有的 `/setorderuzt`。
 3. 废弃方法不做增量修改;排查问题时也不得以其为基准(同 CLAUDE.md 已废弃代码清单约定)。
 3. 废弃方法不做增量修改;排查问题时也不得以其为基准(同 CLAUDE.md 已废弃代码清单约定)。
 
 
 ## 关联
 ## 关联

+ 29 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/OrderMoneyValidator.java

@@ -0,0 +1,29 @@
+package com.ruoyi.app.order;
+
+import com.ruoyi.app.order.dto.OrderCreateInput;
+import com.ruoyi.app.order.dto.OrderCreatItem;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+
+/** 新订单金额以新台币元为单位;兼容 Double 运费字段,但不接收分角金额。 */
+final class OrderMoneyValidator {
+    private OrderMoneyValidator() {
+    }
+
+    static void validateFreight(OrderCreateInput input) {
+        if (input != null && input.getItems() != null) {
+            for (OrderCreatItem item : input.getItems()) {
+                if (item != null) {
+                    requireWholeFreight(item.getFreight());
+                }
+            }
+        }
+    }
+
+    static void requireWholeFreight(Double freight) {
+        // 保留原有未传运费场景;不对客户端金额静默四舍五入,避免与订单应付不一致。
+        if (freight != null && (!Double.isFinite(freight) || freight < 0 || freight != Math.rint(freight))) {
+            throw new ServiceException(MessageUtils.message("order.freight.wholeYuan.required"));
+        }
+    }
+}

+ 2 - 343
ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderController.java

@@ -8,8 +8,6 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
-import com.fasterxml.jackson.databind.ObjectMapper;
-import com.fasterxml.jackson.databind.SerializationFeature;
 import com.ruoyi.app.order.dto.OrderPositionInfo;
 import com.ruoyi.app.order.dto.OrderPositionInfo;
 import com.ruoyi.app.order.dto.OrderPushBodyDto;
 import com.ruoyi.app.order.dto.OrderPushBodyDto;
 import com.ruoyi.app.order.dto.PositionDto;
 import com.ruoyi.app.order.dto.PositionDto;
@@ -25,7 +23,6 @@ import com.ruoyi.app.service.UserService;
 import com.ruoyi.app.service.WalletService;
 import com.ruoyi.app.service.WalletService;
 import com.ruoyi.app.user.dto.StoreOutput;
 import com.ruoyi.app.user.dto.StoreOutput;
 import com.ruoyi.app.utils.DateUtil;
 import com.ruoyi.app.utils.DateUtil;
-import com.ruoyi.app.utils.OperatingUtil;
 import com.ruoyi.app.utils.PayPush;
 import com.ruoyi.app.utils.PayPush;
 import com.ruoyi.app.utils.event.PushEventService;
 import com.ruoyi.app.utils.event.PushEventService;
 import com.ruoyi.common.annotation.Anonymous;
 import com.ruoyi.common.annotation.Anonymous;
@@ -41,7 +38,6 @@ import com.ruoyi.common.utils.StringUtils;
 import com.ruoyi.framework.manager.AsyncManager;
 import com.ruoyi.framework.manager.AsyncManager;
 import com.ruoyi.common.utils.poi.ExcelUtil;
 import com.ruoyi.common.utils.poi.ExcelUtil;
 import com.ruoyi.system.domain.*;
 import com.ruoyi.system.domain.*;
-import com.ruoyi.system.domain.vo.OrderDTO;
 import com.ruoyi.system.domain.vo.PosOrderInvoiceVo;
 import com.ruoyi.system.domain.vo.PosOrderInvoiceVo;
 import com.ruoyi.system.mapper.PosFoodMapper;
 import com.ruoyi.system.mapper.PosFoodMapper;
 import com.ruoyi.system.mapper.PosOrderMapper;
 import com.ruoyi.system.mapper.PosOrderMapper;
@@ -58,14 +54,12 @@ import com.ruoyi.system.utils.OrderLogHelper;
 import com.ruoyi.app.push.UserNotificationScene;
 import com.ruoyi.app.push.UserNotificationScene;
 import com.ruoyi.common.utils.SecurityUtils;
 import com.ruoyi.common.utils.SecurityUtils;
 import com.ruoyi.common.core.domain.entity.SysUser;
 import com.ruoyi.common.core.domain.entity.SysUser;
-import lombok.SneakyThrows;
 import org.redisson.api.RLock;
 import org.redisson.api.RLock;
 import org.redisson.api.RedissonClient;
 import org.redisson.api.RedissonClient;
 import org.springframework.beans.BeanUtils;
 import org.springframework.beans.BeanUtils;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.context.i18n.LocaleContextHolder;
 import org.springframework.context.i18n.LocaleContextHolder;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.security.access.prepost.PreAuthorize;
-import org.springframework.transaction.annotation.Isolation;
 import org.springframework.transaction.annotation.Transactional;
 import org.springframework.transaction.annotation.Transactional;
 import org.springframework.transaction.support.TransactionSynchronization;
 import org.springframework.transaction.support.TransactionSynchronization;
 import org.springframework.transaction.support.TransactionSynchronizationManager;
 import org.springframework.transaction.support.TransactionSynchronizationManager;
@@ -91,7 +85,7 @@ import java.util.stream.Stream;
  * <p>本类仍在用的:列表/详情查询(/getorderlist、/getstoreorderlist、/getqsorderlist、/getorderxq 等)、
  * <p>本类仍在用的:列表/详情查询(/getorderlist、/getstoreorderlist、/getqsorderlist、/getorderxq 等)、
  * 平台管理操作(/{id}/status、/{id}/line-refund、/{id}/offline-payment/confirm、/{id}/offline-refund/confirm 等
  * 平台管理操作(/{id}/status、/{id}/line-refund、/{id}/offline-payment/confirm、/{id}/offline-refund/confirm 等
  * Spring Security 权限接口)、平台改状态 /setorderuzt;
  * Spring Security 权限接口)、平台改状态 /setorderuzt;
- * add/edit 及 /addorder 均已废弃(商家端下单走 PosOrderShOprateController /createOrder),仅保留备查,勿增量修改。
+ * add/edit 已废弃并仅保留备查;/addorder 已移除,商家端下单走 PosOrderShOprateController /createOrder。
  *
  *
  * @author ruoyi
  * @author ruoyi
  * @date 2023-05-23
  * @date 2023-05-23
@@ -108,11 +102,6 @@ public class PosOrderController extends BaseController {
     @Autowired
     @Autowired
     private IPosStoreService posStoreService;
     private IPosStoreService posStoreService;
     @Autowired
     @Autowired
-    private StallQualificationGateService stallQualificationGateService;
-    /** 支付方式闸门(031):商家建单与用户下单共用同一判定入口。 */
-    @Autowired
-    private com.ruoyi.system.service.PaymentMethodGateService paymentMethodGateService;
-    @Autowired
     private IInfoAddressService infoAddressService;
     private IInfoAddressService infoAddressService;
     @Autowired
     @Autowired
     private IUserFootprintService userFootprintService;
     private IUserFootprintService userFootprintService;
@@ -130,15 +119,9 @@ public class PosOrderController extends BaseController {
     private ISalesPromotionService salesPromotionService;
     private ISalesPromotionService salesPromotionService;
     @Autowired
     @Autowired
     private IPosAppealService posAppealService;
     private IPosAppealService posAppealService;
-    @Autowired  //商品
-    private IPosFoodService posFoodService;
-    @Autowired  //经营时间
-    private IOperatingHoursService operatingHoursService;
     @Autowired
     @Autowired
     private IVipUserQuanyiService userQuanyiService;
     private IVipUserQuanyiService userQuanyiService;
     @Autowired
     @Autowired
-    private ISysPointControlService pointControlService;
-    @Autowired
     private IUserWalletService userWalletService;
     private IUserWalletService userWalletService;
     @Autowired
     @Autowired
     private IPointsTransactionService pointsTransactionService;
     private IPointsTransactionService pointsTransactionService;
@@ -154,8 +137,6 @@ public class PosOrderController extends BaseController {
     @Autowired
     @Autowired
     private IPosOrderRatingService posOrderRatingService;
     private IPosOrderRatingService posOrderRatingService;
     @Autowired
     @Autowired
-    private IFoodStatisticsService foodStatisticsService;
-    @Autowired
     private IOrderParentService orderParentService;
     private IOrderParentService orderParentService;
     @Autowired
     @Autowired
     private WalletService walletService;
     private WalletService walletService;
@@ -1100,278 +1081,7 @@ public class PosOrderController extends BaseController {
 //        Boolean sbsj = dateUtil.isLegalTime(aa,bb);
 //        Boolean sbsj = dateUtil.isLegalTime(aa,bb);
 //        return success(sbsj);
 //        return success(sbsj);
 //    }
 //    }
-    //已废弃(2026-09-29 标注):商家端下单走 PosOrderShOprateController /createOrder;
-    //本入口为旧代客外送下单流程(货到付款),保留备查勿增量修改(内含 031 支付闸门调用点,随入口一并废弃)
-    @Deprecated
-    @SneakyThrows
-    @Anonymous
-    @Auth
-    @RepeatSubmit(interval = 1000, message = "请求过于频繁")
-    @Transactional(isolation = Isolation.READ_COMMITTED, rollbackFor = Exception.class)
-    @PostMapping("/addorder")
-    public AjaxResult addorder(@RequestHeader String token, @RequestBody OrderDTO orderDTO) {
-        long methodStart = System.currentTimeMillis(); // 记录方法开始时间
-        ObjectMapper mapper = new ObjectMapper();
-        mapper.enable(SerializationFeature.INDENT_OUTPUT);
-        Integer xiadanjiaoyan = orderDTO.getXiadanjiaoyan();
-        if (xiadanjiaoyan == null || !xiadanjiaoyan.equals(2)) {
-            return error(MessageUtils.message("no.xiadanyz.message"));
-        }
-
-        System.out.println("========== 接收到的orderDTO ==========");
-        System.out.println(mapper.writeValueAsString(orderDTO));
-        JwtUtil jwtUtil = new JwtUtil();
-        DateUtil dateUtil = new DateUtil();
-        String id = jwtUtil.getusid(token);
-        QueryWrapper<OperatingHours> wrapper = new QueryWrapper<>();
-        wrapper.eq("md_id", orderDTO.getMdId());
-        List<OperatingHours> hourslist = operatingHoursService.list(wrapper);
-        PosStore store = posStoreService.getById(orderDTO.getMdId());
-        // 旧路由仍可调用,最小门禁防止从这里绕过摊位材料审核。
-        stallQualificationGateService.requireApproved(store);
-
-        JSONObject usdizhi = new JSONObject();
-        if (orderDTO.getType() != null && orderDTO.getType().equals(0L)) {
-            InfoAddress usadd = infoAddressService.getById(orderDTO.getShdzId());
-            if (usadd == null) {
-                return error(MessageUtils.message("no.address.not.exist"));
-            } else {
-                usdizhi.put("name", usadd.getName());
-                usdizhi.put("phone", usadd.getPhone());
-                usdizhi.put("address", usadd.getAddress());
-                usdizhi.put("country", usadd.getCountry());
-                usdizhi.put("province", usadd.getProvince());
-                usdizhi.put("city", usadd.getCity());
-                usdizhi.put("area", usadd.getArea());
-                usdizhi.put("longitude", String.valueOf(usadd.getLongitude()));
-                usdizhi.put("latitude", String.valueOf(usadd.getLatitude()));
-            }
-        }
-        if (hourslist.size() == 0) {
-            return error(MessageUtils.message("no.mendian.not.set.business.hours"));
-        }
-        if (store.getState() == 1) {
-            return error(MessageUtils.message("no.mendian.is.closed"));
-        }
-        //是否在上班时间内(按星期几和是否营业过滤)
-        boolean isOperating = OperatingUtil.storeIsOperatingOfDay(hourslist);
-        if (!isOperating) {
-            return error(MessageUtils.message("no.mendian.is.closed"));
-        }
-        //订单金额限制100万改为200万
-        if (orderDTO.getCollectPayment().equals("1") && orderDTO.getAmount() > 2000000) {
-            return error(MessageUtils.message("no.cash_on_delivery_amount.exceed.limit_amount"));
-        }
-        QueryWrapper<PosOrder> query = new QueryWrapper<>();
-        query.eq("user_id", id);
-        query.eq("collect_payment", "1");
-        query.notIn("state", 5, 10, 11);
-        List<PosOrder> posOrders = posOrderService.list(query);
-        System.out.println("到付信息:user_id=" + id + " ,到付数量:" + posOrders.size());
-        //到付能存在2单未完成,第3单弹出提示
-        if (posOrders.size() >= 2 && orderDTO.getCollectPayment().equals("1")) {
-            return error(MessageUtils.message("no.exist.cash_on_delivery_order.incomplete"));
-        }
-        JSONArray jsonArray = orderDTO.getFoodlist();
-        Map<Long, Long> map = new HashMap<>();
-        for (int i = 0; i < jsonArray.size(); i++) {
-            Long foodid = jsonArray.getJSONObject(i).getLong("id");
-            PosFood food = posFoodService.getById(foodid);
-            if (food == null) {
-                return error(jsonArray.getJSONObject(i).getString("name") + MessageUtils.message("no.goods.not.exist"));
-            } else {
-                if (food.getStackingUp().equals("1")) {
-                    return error(food.getName() + MessageUtils.message("no.goods.is.off.shelf"));
-                }
-            }
-            Long num = jsonArray.getJSONObject(i).getLong("number");
-            map.put(foodid, num);
-        }
-        VipUserQuanyi yh = null;
-        VipUserQuanyi mdYh = null;
-        if (ObjectUtil.isNotNull(orderDTO.getYhId()) && ObjectUtil.notEqual(orderDTO.getYhId(), 0L)) {
-            LambdaQueryWrapper<VipUserQuanyi> yhQuery = new LambdaQueryWrapper<>();
-            yhQuery.eq(VipUserQuanyi::getId, orderDTO.getYhId());
-            yh = userQuanyiService.getOne(yhQuery);
-            if (yh == null) {
-                throw new ServiceException("no.userquanyi.not.exist");
-            }
-            if (yh.getState().equals("1")) {
-                throw new ServiceException("userquanyi.isUsed");
-            }
-        }
-        if (ObjectUtil.isNotNull(orderDTO.getMdYhId()) && ObjectUtil.notEqual(orderDTO.getMdYhId(), 0L)) {
-            LambdaQueryWrapper<VipUserQuanyi> mdYhquery = new LambdaQueryWrapper<>();
-            mdYhquery.eq(VipUserQuanyi::getId, orderDTO.getMdYhId());
-            mdYh = userQuanyiService.getOne(mdYhquery);
-            if (mdYh == null) {
-                throw new ServiceException("no.userquanyi.not.exist");
-            }
-            if (mdYh.getState().equals("1")) {
-                throw new ServiceException("userquanyi.isUsed");
-            }
-        }
-        SalesPromotion activity = null;
-        SalesPromotion mdActivity = null;
-        if (ObjectUtil.isNotNull(orderDTO.getActivity()) && ObjectUtil.notEqual(orderDTO.getActivity(), 0L)) {
-            LambdaQueryWrapper<SalesPromotion> saleQuery = new LambdaQueryWrapper<>();
-            saleQuery.eq(SalesPromotion::getId, orderDTO.getActivity());
-            activity = salesPromotionService.getOne(saleQuery);
-            if (activity == null) {
-                throw new ServiceException("no.activity.not.exist");
-            }
-//            if (yh.getState().equals("1")) {
-//                throw new ServiceException("userquanyi.isUsed");
-//            }
-        }
-        if (ObjectUtil.isNotNull(orderDTO.getMdActivity()) && ObjectUtil.notEqual(orderDTO.getMdActivity(), 0L)) {
-            LambdaQueryWrapper<SalesPromotion> saleQuery = new LambdaQueryWrapper<>();
-            saleQuery.eq(SalesPromotion::getId, orderDTO.getMdActivity());
-            mdActivity = salesPromotionService.getOne(saleQuery);
-
-            if (mdActivity == null) {
-                throw new ServiceException("no.activity.not.exist");
-            }
-
-        }
-        PosOrder posOrder = new PosOrder();
-        AjaxResult posOrderResult = redeePoints(orderDTO, Long.valueOf(id));
-        if (!posOrderResult.get("code").equals(200)) {
-            return posOrderResult;
-        } else {
-            posOrder.setPoints(orderDTO.getPoints());
-            posOrder.setPointsReduction(orderDTO.getPointsReduction());
-        }
-        posOrder.setDdId(orderDTO.getDdId());
-        posOrder.setShId(orderDTO.getShId());
-        posOrder.setMdId(orderDTO.getMdId());
-        posOrder.setShdzId(orderDTO.getShdzId());
-        posOrder.setShAddress(JSON.toJSONString(usdizhi));
-        posOrder.setUserId(Long.valueOf(id));
-        posOrder.setAmount(orderDTO.getAmount());
-        posOrder.setRemarks(orderDTO.getRemarks());
-        posOrder.setType(orderDTO.getType());
-        posOrder.setDelryTime(orderDTO.getDelryTime());
-        posOrder.setFood(jsonArray.toString());
-        posOrder.setState(0L);
-
-        posOrder.setJvli(orderDTO.getJvli());
-        posOrder.setFreight(orderDTO.getFreight());
-        posOrder.setLongitude(orderDTO.getLongitude());
-        posOrder.setLatitude(orderDTO.getLatitude());
-        posOrder.setDiningStatus(orderDTO.getDiningStatus());
-        posOrder.setQsId(orderDTO.getQsId());
-        posOrder.setJuli(orderDTO.getJuli());
-        posOrder.setPayUrl(orderDTO.getPayUrl());
-        posOrder.setCollectPayment(orderDTO.getCollectPayment());
-        posOrder.setAfterSaleStatus(0L);
-        posOrder.setDeliveryStatus(Long.valueOf(0L).equals(orderDTO.getType()) ? 0L : null);
-        posOrder.setPayStatus(Long.valueOf(0L).equals(orderDTO.getType())
-                && "1".equals(orderDTO.getCollectPayment()) ? 1L : 0L);
-
-        // posOrder.setCretim(dateUtil.getDatetim(orderDTO.getCretim()));
-        //创建时间改成后端生成
-        posOrder.setCretim(new Date());
-
-        if (yh != null) {
-            posOrder.setYhName(yh.getName());
-            posOrder.setYhId(orderDTO.getYhId());
-            posOrder.setDiscountAmount(orderDTO.getDiscountAmount());
-        }
-
-        if (mdYh != null) {
-            posOrder.setMdYhId(orderDTO.getMdYhId());
-            posOrder.setMdYhName(mdYh.getName());
-            posOrder.setMdDiscountAmount(orderDTO.getMdDiscountAmount());
-        }
-
-        if (mdActivity != null) {
-            posOrder.setMdActivity(orderDTO.getMdActivity());
-            posOrder.setMdSalesName(mdActivity.getSalesName());
-            posOrder.setMdSalesReduction(orderDTO.getMdSalesReduction());
-        }
-        if (activity != null) {
-            posOrder.setActivity(orderDTO.getActivity());
-            posOrder.setSalesName(activity.getSalesName());
-            posOrder.setSalesReduction(orderDTO.getSalesReduction());
-        }
-        //设置支付类型为货到付款
-        if ("1".equals(posOrder.getCollectPayment())) {
-            // 031 闸门对称校验:平台关闭到付时商家建单同样被拒,与用户下单同口径(spec FR-004)。
-            com.ruoyi.system.domain.PosStore payStore = posStoreService.getById(posOrder.getMdId());
-            if (payStore != null) {
-                paymentMethodGateService.assertUsable("1",
-                        com.ruoyi.system.service.PaymentMethodGateService.GateScope.MERCHANT,
-                        payStore.getId().longValue(), payStore.getUserId());
-            }
-            posOrder.setPayType("1");
-        }
-        Boolean org = posOrderService.saveOrUpdate(posOrder);
-        foodStatistics(map);
-        if (org) {
-            QueryWrapper<UserBilling> queryWrapper = new QueryWrapper<>();
-            queryWrapper.eq("user_id", id);
-            queryWrapper.eq("dd_id", posOrder.getDdId());
-            UserBilling billing = userBillingService.getOne(queryWrapper);
-            if (billing == null) {
-                UserBilling usb = new UserBilling();
-                usb.setUserId(Long.valueOf(id));
-                usb.setDdId(String.valueOf(posOrder.getDdId()));
-                usb.setMdId(posOrder.getMdId());
-                usb.setState("3");
-                usb.setType("3");
-                userBillingService.saveOrUpdate(usb);
-            }
-            QueryWrapper<UserFootprint> Wrapper = new QueryWrapper<>();
-            Wrapper.eq("user_id", id);
-            Wrapper.eq("md_id", posOrder.getMdId());
-            UserFootprint foot = userFootprintService.getOne(Wrapper);
-            DateUtil dateString = new DateUtil();
-            if (foot == null) {
-                UserFootprint usf = new UserFootprint();
-                usf.setUserId(Long.valueOf(id));
-                usf.setMdId(posOrder.getMdId());
-                usf.setCretim(dateString.GetDate());
-                userFootprintService.saveOrUpdate(usf);
-            } else {
-                foot.setCretim(dateString.GetDate());
-                userFootprintService.saveOrUpdate(foot);
-            }
-            QueryWrapper<PosOrder> querywra = new QueryWrapper<>();
-            querywra.eq("dd_id", posOrder.getDdId());
-            PosOrder order = posOrderService.getOne(querywra);
-            //货到付款、类型外卖,推送给骑手有新订单了
-            if (order.getCollectPayment().equals("1") && order.getType() == 0) {
-                sendHdfkMessage(order);
-            }
-
-            //存在使用优惠券的话,核销优惠券
-            redeemCoupons(yh, mdYh, order.getDdId());
-            System.out.println("========== 输出的order ==========");
-            System.out.println(mapper.writeValueAsString(order));
-            long methodEnd = System.currentTimeMillis(); // 方法结束时间
-            System.out.println("addorder 方法总执行耗时: " + (methodEnd - methodStart) + " ms");
-            return success(MessageUtils.message("no.order.submit.success"), order);
-        } else {
-            long methodEnd = System.currentTimeMillis(); // 方法结束时间
-            System.out.println("addorder 方法总执行耗时: " + (methodEnd - methodStart) + " ms");
-            return error();
-        }
-    }
-
-
-
-
-    //统计美食销售量
-    private void foodStatistics(Map<Long, Long> map) {
-        for (Map.Entry<Long, Long> entry : map.entrySet()) {
-            Long key = entry.getKey();
-            Long value = entry.getValue();
-            // 使用数据库原子UPSERT,避免并发下的竞态问题
-            foodStatisticsService.increaseNumber(key, value);
-        }
-    }
-
+    // 旧 /addorder 创建入口已移除;商家代客下单统一使用 /system/orderShOprate/createOrder。
     /**
     /**
      * 货到付款发送推送
      * 货到付款发送推送
      *
      *
@@ -1381,37 +1091,6 @@ public class PosOrderController extends BaseController {
         deliveryOrderNotificationService.notifyOrderAvailable(order);
         deliveryOrderNotificationService.notifyOrderAvailable(order);
     }
     }
 
 
-    //核销积分
-    private AjaxResult redeePoints(OrderDTO orderDTO, Long userId) {
-        if (ObjectUtil.isNotNull(orderDTO.getPoints()) && ObjectUtil.notEqual(orderDTO.getPoints(), 0) && ObjectUtil.isNotNull(orderDTO.getPointsReduction()) && ObjectUtil.notEqual(orderDTO.getPointsReduction(), 0)) {
-            SysPointControl control = pointControlService.getById(1L);
-            if (!control.getOrderUseEnable().equals(1L)) {
-                throw new ServiceException(MessageUtils.message("no.points.use.enable"));
-            }
-            LambdaQueryWrapper<UserWallet> walletQuery = new LambdaQueryWrapper<>();
-            walletQuery.eq(UserWallet::getUserId, userId);
-            walletQuery.isNull(UserWallet::getStoreId);
-            UserWallet userWallet = userWalletService.getOne(walletQuery);
-            if (ObjectUtil.isNotNull(userWallet)) {
-                if (userWallet.getPointsWallet().compareTo(Long.valueOf(orderDTO.getPoints())) >= 0) {
-                    walletQuery.eq(UserWallet::getVersion, userWallet.getVersion());
-                    userWallet.setPointsWallet(userWallet.getPointsWallet() - orderDTO.getPoints());
-                    userWallet.setVersion(userWallet.getVersion() + 1);
-                    boolean update = userWalletService.update(userWallet, walletQuery);
-                    createPointTransaction(userId, Long.valueOf(orderDTO.getPoints()), userWallet.getPointsWallet(), orderDTO.getDdId().toString(), "1");
-                    if (!update) {
-                        throw new ServiceException(MessageUtils.message("no.points.update.fail"));
-                    }
-                } else {
-                    throw new ServiceException(MessageUtils.message("no.points.insufficient"));
-                }
-            } else {
-                throw new ServiceException(MessageUtils.message("no.points.not.exist"));
-            }
-        }
-        return success();
-    }
-
     /**
     /**
      * 创建积分流水
      * 创建积分流水
      */
      */
@@ -1454,26 +1133,6 @@ public class PosOrderController extends BaseController {
     }
     }
 
 
 
 
-    /**
-     * 核销优惠券
-     *
-     * @param orderId
-     */
-    private void redeemCoupons(VipUserQuanyi yh, VipUserQuanyi mdYh, String orderId) {
-        if (ObjectUtil.isNotNull(yh)) {
-            yh.setState("1");
-            yh.setOrderId(orderId);
-            yh.setUpdateTime(new Date());
-            userQuanyiService.saveOrUpdate(yh);
-        }
-        if (ObjectUtil.isNotNull(mdYh)) {
-            mdYh.setState("1");
-            mdYh.setOrderId(orderId);
-            mdYh.setUpdateTime(new Date());
-            userQuanyiService.saveOrUpdate(mdYh);
-        }
-    }
-
     /**
     /**
      * 查询posorder列表
      * 查询posorder列表
      */
      */

+ 2 - 11
ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderShOprateController.java

@@ -314,6 +314,7 @@ public class PosOrderShOprateController extends BaseController {
                 || item.getAmount() <= 0) {
                 || item.getAmount() <= 0) {
             throw new ServiceException(MessageUtils.message("line.pay.offline.order.invalid"));
             throw new ServiceException(MessageUtils.message("line.pay.offline.order.invalid"));
         }
         }
+        OrderMoneyValidator.requireWholeFreight(item.getFreight());
         if ("5".equals(merchant.getUserType())) {
         if ("5".equals(merchant.getUserType())) {
             MerchantAccessContext context = merchantStoreAccessService.resolve(merchant.getUserId());
             MerchantAccessContext context = merchantStoreAccessService.resolve(merchant.getUserId());
             merchantStoreAccessService.requireStoreAccess(merchant.getUserId(), item.getMdId());
             merchantStoreAccessService.requireStoreAccess(merchant.getUserId(), item.getMdId());
@@ -984,16 +985,6 @@ public class PosOrderShOprateController extends BaseController {
         return success(result);
         return success(result);
     }
     }
 
 
-    @Anonymous
-    @Auth
-    @GetMapping("/orderDetail")
-    public AjaxResult orderDetail(@RequestHeader String token, @RequestParam Long id) {
-        requireManagedOrder(id);
-        PosOrder order = posOrderService.getById(id);
-        if (order == null) {
-            throw new ServiceException(MessageUtils.message("merchant.order.not.found"));
-        }
-        return success(order);
-    }
+
 
 
 }
 }

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

@@ -168,6 +168,7 @@ public class UserOrderController extends BaseController {
     @Transactional(rollbackFor = Exception.class)
     @Transactional(rollbackFor = Exception.class)
     public AjaxResult createOrder(@RequestHeader String token, @RequestBody OrderCreateInput input)
     public AjaxResult createOrder(@RequestHeader String token, @RequestBody OrderCreateInput input)
     {
     {
+        OrderMoneyValidator.validateFreight(input);
         JwtUtil jwtUtil = new JwtUtil();
         JwtUtil jwtUtil = new JwtUtil();
         String id = jwtUtil.getusid(token);
         String id = jwtUtil.getusid(token);
         String shdz=null;
         String shdz=null;

+ 1 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/order/dto/OrderCreatItem.java

@@ -54,7 +54,7 @@ public class OrderCreatItem {
     private Double jvli;
     private Double jvli;
 
 
     /**
     /**
-     * 运费
+     * 运费(非负整数新台币元;保留 Double 类型兼容已有请求)
      */
      */
 
 
     private Double freight;
     private Double freight;

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

@@ -18,7 +18,7 @@ public class OrderCreateInput {
      * 订单类型:0外送,1自取,2堂食
      * 订单类型:0外送,1自取,2堂食
      */
      */
     private Long type;
     private Long type;
-    /** 订单总金额(分) */
+    /** 订单总金额(整数新台币元) */
 
 
     private Long totalAmount;
     private Long totalAmount;
 
 
@@ -27,7 +27,7 @@ public class OrderCreateInput {
 
 
     //private Long totalDiscount;
     //private Long totalDiscount;
 
 
-    /** 实际支付金额(分) */
+    /** 实际支付金额(整数新台币元) */
 
 
     private Long actualPayAmount;
     private Long actualPayAmount;
 
 

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

@@ -196,9 +196,11 @@ public class UserBillingController extends BaseController {
         );
         );
 
 
         UserBilling summary = userBillingService.getOne(wrapper);
         UserBilling summary = userBillingService.getOne(wrapper);
-        double totalAmount = summary != null && summary.getAmount() != null ? summary.getAmount() : 0;
-        double totalDivvy = summary != null && summary.getDivvy() != null ? summary.getDivvy() : 0;
-        double netIncome = totalAmount - totalDivvy;
+        // 历史账单仍保留实际分角;仅消除 SUM/Double 的二进制尾差,新整数金额不附带 .0。
+        BigDecimal totalAmount = displayAmount(summary == null ? null : summary.getAmount());
+        BigDecimal totalDivvy = displayAmount(summary == null ? null : summary.getDivvy());
+        // amount 已是实际入账金额;历史抽成只展示,不得重复扣减。
+        BigDecimal netIncome = totalAmount;
 
 
         JSONObject org = new JSONObject();
         JSONObject org = new JSONObject();
         org.put("totalAmount", totalAmount);
         org.put("totalAmount", totalAmount);
@@ -207,6 +209,12 @@ public class UserBillingController extends BaseController {
         return success(MessageUtils.message("no.success"), org);
         return success(MessageUtils.message("no.success"), org);
     }
     }
 
 
+    private BigDecimal displayAmount(Double value) {
+        BigDecimal amount = (value == null ? BigDecimal.ZERO : BigDecimal.valueOf(value))
+                .setScale(2, java.math.RoundingMode.HALF_UP).stripTrailingZeros();
+        return amount.scale() < 0 ? amount.setScale(0) : amount;
+    }
+
     /** 列表和汇总共用分管范围:账单属于主账号,门店权限必须每次重新解析。 */
     /** 列表和汇总共用分管范围:账单属于主账号,门店权限必须每次重新解析。 */
     private void scopeSubaccountBilling(QueryWrapper<UserBilling> wrapper, Long loginUserId, Long mdId) {
     private void scopeSubaccountBilling(QueryWrapper<UserBilling> wrapper, Long loginUserId, Long mdId) {
         MerchantAccessContext context = merchantStoreAccessService.resolve(loginUserId);
         MerchantAccessContext context = merchantStoreAccessService.resolve(loginUserId);

+ 20 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/user/CancellationLoginContext.java

@@ -0,0 +1,20 @@
+package com.ruoyi.app.user;
+
+import com.ruoyi.system.domain.vo.UserDTO;
+
+/** 身份校验通过后保存的登录上下文;仅服务端写入,不接受客户端直接提交。 */
+public record CancellationLoginContext(Flow flow, Long userId, String phone, String provider,
+                                       String providerUid, String displayName, Device device) {
+    public enum Flow { PHONE, OAUTH, BIND, DEVICE }
+
+    /** 只保留后续登录所需的设备信息,不缓存短信码、密码或第三方授权凭证。 */
+    public record Device(String deviceId, String cid, String cidType, String deviceToken,
+                         String voIPToken, boolean updatePush) {
+        public UserDTO phoneLogin(String phone) {
+            UserDTO dto = new UserDTO();
+            dto.setPhone(phone); dto.setDeviceId(deviceId); dto.setCid(cid); dto.setCidType(cidType);
+            dto.setDeviceToken(deviceToken); dto.setVoIPToken(voIPToken);
+            return dto;
+        }
+    }
+}

+ 83 - 11
ruoyi-admin/src/main/java/com/ruoyi/app/user/InfoUserController.java

@@ -43,6 +43,9 @@ import com.ruoyi.app.user.dto.InfoUserResetPwdDto;
 import com.ruoyi.app.user.dto.OAuthBindDto;
 import com.ruoyi.app.user.dto.OAuthBindDto;
 import com.ruoyi.app.user.dto.OAuthDeviceConfirmDto;
 import com.ruoyi.app.user.dto.OAuthDeviceConfirmDto;
 import com.ruoyi.app.user.dto.OAuthLoginDto;
 import com.ruoyi.app.user.dto.OAuthLoginDto;
+import com.ruoyi.app.user.dto.CancellationLoginConfirmDto;
+import com.ruoyi.app.user.CancellationLoginContext.Flow;
+import com.ruoyi.app.user.CancellationLoginContext.Device;
 import com.ruoyi.app.utils.oauth.OAuthAccountType;
 import com.ruoyi.app.utils.oauth.OAuthAccountType;
 import com.ruoyi.app.utils.oauth.OAuthIdentity;
 import com.ruoyi.app.utils.oauth.OAuthIdentity;
 import com.ruoyi.app.utils.oauth.LineOAuthStateService;
 import com.ruoyi.app.utils.oauth.LineOAuthStateService;
@@ -1088,8 +1091,11 @@ public class InfoUserController extends BaseController {
      * @return
      * @return
      */
      */
     public AjaxResult userLoginUpdateInfo(UserDTO userDTO, InfoUser infoUser) {
     public AjaxResult userLoginUpdateInfo(UserDTO userDTO, InfoUser infoUser) {
-        // 仅在短信验证成功后恢复;普通用户的行锁由 lodeing 外层事务持有至新会话签发。
-        infoUser = recoverCancelledUser(infoUser);
+        AjaxResult confirmation = cancellationPrompt(infoUser, new CancellationLoginContext(Flow.PHONE,
+                infoUser.getUserId(), infoUser.getPhone(), "phone", null, null,
+                new Device(userDTO.getDeviceId(), userDTO.getCid(), userDTO.getCidType(),
+                        userDTO.getDeviceToken(), userDTO.getVoIPToken(), true)));
+        if (confirmation != null) return confirmation;
         DateUtil date = new DateUtil();
         DateUtil date = new DateUtil();
         QueryWrapper<InfoUser> query = new QueryWrapper<>();
         QueryWrapper<InfoUser> query = new QueryWrapper<>();
         query.eq("user_id", infoUser.getUserId());
         query.eq("user_id", infoUser.getUserId());
@@ -1174,8 +1180,13 @@ public class InfoUserController extends BaseController {
             }
             }
             if (accountType == null || accountType == OAuthAccountType.USER) q.last("FOR UPDATE");
             if (accountType == null || accountType == OAuthAccountType.USER) q.last("FOR UPDATE");
             InfoUser u = infoUserService.getOne(q);
             InfoUser u = infoUserService.getOne(q);
-            if (u != null && (accountType == null || accountType == OAuthAccountType.USER))
-                u = recoverCancelledUser(u);
+            if (u != null && (accountType == null || accountType == OAuthAccountType.USER)) {
+                AjaxResult confirmation = cancellationPrompt(u, new CancellationLoginContext(Flow.OAUTH,
+                        u.getUserId(), u.getPhone(), dto.getProvider(), providerUid, displayName,
+                        new Device(dto.getDeviceId(), dto.getCid(), dto.getCidType(),
+                                dto.getDeviceToken(), dto.getVoIPToken(), true)));
+                if (confirmation != null) return confirmation;
+            }
             if (u == null) {
             if (u == null) {
                 log.warn("[OAuth] 已绑定但账号已停用 userId={}", bind.getUserId());
                 log.warn("[OAuth] 已绑定但账号已停用 userId={}", bind.getUserId());
                 return error(MessageUtils.message("no.user.stop"));
                 return error(MessageUtils.message("no.user.stop"));
@@ -1326,7 +1337,17 @@ public class InfoUserController extends BaseController {
             // getuser 不带 userType 过滤,同手机号命中商家/骑手账号会错绑或多行异常。
             // getuser 不带 userType 过滤,同手机号命中商家/骑手账号会错绑或多行异常。
             OAuthAccountType bindingChannel = accountType != null ? accountType : OAuthAccountType.USER;
             OAuthAccountType bindingChannel = accountType != null ? accountType : OAuthAccountType.USER;
             user = findOauthBindingUser(bindingChannel, phone);
             user = findOauthBindingUser(bindingChannel, phone);
-            if (user != null) user = recoverCancelledUser(user);
+            if (user != null) {
+                AjaxResult confirmation = cancellationPrompt(user, new CancellationLoginContext(Flow.BIND,
+                        user.getUserId(), user.getPhone(), provider, providerUid, displayName,
+                        new Device(dto.getDeviceId(), dto.getCid(), dto.getCidType(),
+                                dto.getDeviceToken(), dto.getVoIPToken(), true)));
+                if (confirmation != null) {
+                    redisCache.deleteObject(OAUTH_TEMP_DEVICE_PREFIX + dto.getTempKey());
+                    redisCache.deleteObject(OAUTH_TEMP_USER_PREFIX + dto.getTempKey());
+                    return confirmation;
+                }
+            }
             if (user != null && !"0".equals(user.getStatus())) {
             if (user != null && !"0".equals(user.getStatus())) {
                 // 停用账号不允许绑定三方(与 lodeing 一致,防止绕过停用)
                 // 停用账号不允许绑定三方(与 lodeing 一致,防止绕过停用)
                 log.warn("[OAuth] 账号已停用 phone={}", maskPhone(phone));
                 log.warn("[OAuth] 账号已停用 phone={}", maskPhone(phone));
@@ -1415,9 +1436,18 @@ public class InfoUserController extends BaseController {
         if (user == null || !java.util.Objects.equals(user.getUserId(), trust.getUserId()))
         if (user == null || !java.util.Objects.equals(user.getUserId(), trust.getUserId()))
             return error(MessageUtils.message("no.oauth.device.mismatch"));
             return error(MessageUtils.message("no.oauth.device.mismatch"));
         if (!"0".equals(user.getStatus())) return error(MessageUtils.message("no.user.stop"));
         if (!"0".equals(user.getStatus())) return error(MessageUtils.message("no.user.stop"));
-        user = recoverCancelledUser(user);
-        if (!canOauthLogin(OAuthAccountType.USER, user)) throw new ServiceException(MessageUtils.message("no.user.stop"));
         deviceTrustService.requireConfirmation(dto.getDeviceId(), user.getUserId());
         deviceTrustService.requireConfirmation(dto.getDeviceId(), user.getUserId());
+        AjaxResult confirmation = cancellationPrompt(user, new CancellationLoginContext(Flow.DEVICE,
+                user.getUserId(), user.getPhone(), provider, providerUid, displayName,
+                new Device(dto.getDeviceId(), null, null, null, null, false)));
+        if (confirmation != null) {
+            if (!redisCache.deleteObject(OAUTH_TEMP_PREFIX + dto.getTempKey()))
+                throw new ServiceException(MessageUtils.message("no.oauth.tempkey.expired"));
+            redisCache.deleteObject(OAUTH_TEMP_DEVICE_PREFIX + dto.getTempKey());
+            redisCache.deleteObject(OAUTH_TEMP_USER_PREFIX + dto.getTempKey());
+            return confirmation;
+        }
+        if (!canOauthLogin(OAuthAccountType.USER, user)) throw new ServiceException(MessageUtils.message("no.user.stop"));
         if (!redisCache.deleteObject(OAUTH_TEMP_PREFIX + dto.getTempKey()))
         if (!redisCache.deleteObject(OAUTH_TEMP_PREFIX + dto.getTempKey()))
             throw new ServiceException(MessageUtils.message("no.oauth.tempkey.expired"));
             throw new ServiceException(MessageUtils.message("no.oauth.tempkey.expired"));
         InfoUserOauth bind = bindVerifiedIdentity(user, provider, providerUid, displayName);
         InfoUserOauth bind = bindVerifiedIdentity(user, provider, providerUid, displayName);
@@ -1493,10 +1523,52 @@ public class InfoUserController extends BaseController {
         return success(MessageUtils.message("no.user.login.success"), loginUser, token);
         return success(MessageUtils.message("no.user.login.success"), loginUser, token);
     }
     }
 
 
-    /** 候选查询已锁定账号;只有身份认证成功的用户登录入口调用,刷新token不调用。 */
-    private InfoUser recoverCancelledUser(InfoUser user) {
-        return user != null && "0".equals(user.getUserType()) && "1".equals(user.getDelFlag())
-                ? userCancellationService.authenticatedLogin(user.getUserId()) : user;
+    /** 身份已验证且账号已锁定;返回提示时不得继续写入登录资料或签发会话。 */
+    private AjaxResult cancellationPrompt(InfoUser user, CancellationLoginContext context) {
+        if (user == null || !"0".equals(user.getUserType()) || !"1".equals(user.getDelFlag())) return null;
+        return success(userCancellationService.prepareLogin(context));
+    }
+
+    @Anonymous
+    @PostMapping("/confirmCancellationLogin")
+    @Transactional(rollbackFor = Exception.class)
+    public AjaxResult confirmCancellationLogin(@RequestBody CancellationLoginConfirmDto request) {
+        var confirmed = userCancellationService.confirmLogin(request == null ? null : request.confirmationKey());
+        InfoUser user = confirmed.user();
+        CancellationLoginContext context = confirmed.context();
+        Device device = context.device();
+        if (context.flow() == Flow.PHONE) return userLoginUpdateInfo(device.phoneLogin(user.getPhone()), user);
+
+        if (context.flow() == Flow.DEVICE) {
+            deviceTrustService.requireConfirmation(device.deviceId(), user.getUserId());
+        } else if (context.flow() == Flow.BIND) {
+            deviceTrustService.recordLogin(device.deviceId(), user.getPhone(), user.getUserId());
+        }
+        InfoUserOauth bind;
+        if (context.flow() == Flow.OAUTH) {
+            // 已绑定登录不允许在等待确认期间解绑后自动重建关系。
+            bind = infoUserOauthMapper.selectOne(new LambdaQueryWrapper<InfoUserOauth>()
+                    .in(InfoUserOauth::getProvider, OAuthAccountType.bindingProviders(context.provider()))
+                    .eq(InfoUserOauth::getProviderUid, context.providerUid()));
+            if (bind == null || !java.util.Objects.equals(user.getUserId(), bind.getUserId()))
+                throw new ServiceException(MessageUtils.message("no.oauth.binding.conflict"));
+            deviceTrustService.recordOauthLogin(device.deviceId(), user.getPhone(), user.getUserId(), bind);
+            InfoUserOauth locked = bind.getId() == null ? null : infoUserOauthMapper.selectByIdForUpdate(bind.getId());
+            if (locked == null || !java.util.Objects.equals(user.getUserId(), locked.getUserId())
+                    || !java.util.Objects.equals(bind.getProvider(), locked.getProvider())
+                    || !java.util.Objects.equals(context.providerUid(), locked.getProviderUid()))
+                throw new ServiceException(MessageUtils.message("no.oauth.binding.conflict"));
+            refreshOauthDisplayName(locked, context.displayName());
+        } else {
+            bind = bindVerifiedIdentity(user, context.provider(), context.providerUid(), context.displayName());
+            deviceTrustService.recordOauthLogin(device.deviceId(), user.getPhone(), user.getUserId(), bind);
+        }
+        if (device.updatePush()) {
+            user.setCid(device.cid()); user.setCidType(device.cidType());
+            user.setDeviceToken(device.deviceToken()); user.setVoIPToken(device.voIPToken());
+            if (!infoUserService.saveOrUpdate(user)) throw new ServiceException(MessageUtils.message("no.system.error"));
+        }
+        return issueOauthToken(user, context.provider(), context.providerUid());
     }
     }
 
 
     private boolean canOauthLogin(OAuthAccountType channel, InfoUser user) {
     private boolean canOauthLogin(OAuthAccountType channel, InfoUser user) {

+ 13 - 2
ruoyi-admin/src/main/java/com/ruoyi/app/user/LineCallbackController.java

@@ -126,8 +126,19 @@ public class LineCallbackController extends BaseController {
                                 .eq(InfoUser::getUserType, "0").eq(InfoUser::getDelFlag, "1")
                                 .eq(InfoUser::getUserType, "0").eq(InfoUser::getDelFlag, "1")
                                 .gt(InfoUser::getCancellationDeadlineAt, new java.util.Date())))
                                 .gt(InfoUser::getCancellationDeadlineAt, new java.util.Date())))
                         .last(!businessLine, "FOR UPDATE"));
                         .last(!businessLine, "FOR UPDATE"));
-                if (!businessLine && user != null && "0".equals(user.getUserType()) && "1".equals(user.getDelFlag()))
-                    user = userCancellationService.authenticatedLogin(user.getUserId());
+                if (!businessLine && user != null && "0".equals(user.getUserType()) && "1".equals(user.getDelFlag())) {
+                    var confirmation = userCancellationService.prepareLogin(new CancellationLoginContext(
+                            CancellationLoginContext.Flow.OAUTH, user.getUserId(), user.getPhone(), provider,
+                            providerUid, identity.displayName(),
+                            new CancellationLoginContext.Device(state, null, null, null, null, false)));
+                    String deadline = java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
+                            .withZone(java.time.ZoneOffset.ofHours(8)).format(confirmation.cancellationDeadlineAt().toInstant());
+                    response.sendRedirect(channel.appRedirect() + "?cancellationConfirm=1&confirmationKey="
+                            + enc(confirmation.confirmationKey()) + "&remainingDays=" + confirmation.remainingDays()
+                            + "&cancellationDeadlineAt=" + enc(deadline)
+                            + "&expiresInSeconds=" + confirmation.expiresInSeconds());
+                    return;
+                }
                 if (!canLogin(channel, user)) {
                 if (!canLogin(channel, user)) {
                     response.sendRedirect(channel.appRedirect() + "?error=user_stopped" + stateSuffix);
                     response.sendRedirect(channel.appRedirect() + "?error=user_stopped" + stateSuffix);
                     return;
                     return;

+ 69 - 7
ruoyi-admin/src/main/java/com/ruoyi/app/user/UserCancellationService.java

@@ -9,6 +9,10 @@ import com.ruoyi.system.mapper.InfoUserMapper;
 import com.ruoyi.common.utils.DictUtils;
 import com.ruoyi.common.utils.DictUtils;
 import com.ruoyi.common.core.domain.entity.SysDictData;
 import com.ruoyi.common.core.domain.entity.SysDictData;
 import java.util.List;
 import java.util.List;
+import java.util.Objects;
+import java.util.UUID;
+import java.util.concurrent.TimeUnit;
+import com.alibaba.fastjson2.JSON;
 import org.springframework.stereotype.Service;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 import org.springframework.transaction.annotation.Transactional;
 import java.time.Instant;
 import java.time.Instant;
@@ -37,20 +41,78 @@ public class UserCancellationService {
         int graceDays = graceDays();
         int graceDays = graceDays();
         Date deadline = Date.from(Instant.now().plus(graceDays, ChronoUnit.DAYS));
         Date deadline = Date.from(Instant.now().plus(graceDays, ChronoUnit.DAYS));
         if (users.requestUserCancellation(userId, deadline) != 1) throw unavailable();
         if (users.requestUserCancellation(userId, deadline) != 1) throw unavailable();
+        redis.deleteObject(CYCLE_PREFIX + userId);
         redis.deleteKeys(CacheConstants.USER_TOKEN_KEY + userId + ":*");
         redis.deleteKeys(CacheConstants.USER_TOKEN_KEY + userId + ":*");
         return new CancellationView(deadline, graceDays);
         return new CancellationView(deadline, graceDays);
     }
     }
 
 
+    static final String CONFIRM_PREFIX = "user:cancellation:confirm:";
+    static final String CYCLE_PREFIX = "user:cancellation:cycle:";
+
+    public record LoginConfirmationView(String status, int remainingDays,
+            @com.fasterxml.jackson.annotation.JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+            Date cancellationDeadlineAt, String confirmationKey, int expiresInSeconds) { }
+    public record PendingLogin(CancellationLoginContext context, long cancellationDeadline, long expiresAt, String cycle) { }
+    public record ConfirmedLogin(InfoUser user, CancellationLoginContext context) { }
+
+    /** 调用方必须先完成身份验证;此方法不恢复账号、不签发会话。 */
     @Transactional(rollbackFor = Exception.class)
     @Transactional(rollbackFor = Exception.class)
-    public InfoUser authenticatedLogin(Long userId) {
-        // 登录入口的外层事务持有此锁至签发新会话完成,防止并发注销覆盖恢复。
-        InfoUser user = users.selectCancellationUserForUpdate(userId);
-        requireUser(user);
-        if ("0".equals(user.getDelFlag())) return user;
+    public LoginConfirmationView prepareLogin(CancellationLoginContext context) {
+        InfoUser user = users.selectCancellationUserForUpdate(context.userId());
+        Date now = new Date();
+        if (!canRecover(user, now) || !Objects.equals(user.getPhone(), context.phone())) throw unavailable();
+        long remaining = user.getCancellationDeadlineAt().getTime() - now.getTime();
+        // 旧记录可按需建立周期标识;账号行锁保证并发提示共用同一周期。
+        String cycle = redis.getCacheObject(CYCLE_PREFIX + user.getUserId());
+        if (cycle == null) {
+            cycle = UUID.randomUUID().toString();
+            redis.setCacheObject(CYCLE_PREFIX + user.getUserId(), cycle,
+                    (int) ((remaining + 999L) / 1000L), TimeUnit.SECONDS);
+        }
+        long validMillis = Math.min(300000L, remaining);
+        String key = UUID.randomUUID().toString().replace("-", "");
+        PendingLogin pending = new PendingLogin(context, user.getCancellationDeadlineAt().getTime(),
+                now.getTime() + validMillis, cycle);
+        int expiresInSeconds = (int) ((validMillis + 999L) / 1000L);
+        redis.setCacheObject(CONFIRM_PREFIX + key, JSON.toJSONString(pending), expiresInSeconds, TimeUnit.SECONDS);
+        return new LoginConfirmationView("cancellationConfirm", (int) ((remaining + 86399999L) / 86400000L),
+                user.getCancellationDeadlineAt(), key, expiresInSeconds);
+    }
+
+    /** 必须由外层登录事务覆盖至签发会话完成;Redis凭证一次性消费,失败须重新认证。 */
+    @Transactional(rollbackFor = Exception.class)
+    public ConfirmedLogin confirmLogin(String key) {
+        if (key == null || !key.matches("[0-9a-f]{32}")) throw confirmationExpired();
+        String cached = redis.getCacheObject(CONFIRM_PREFIX + key);
+        if (cached == null) throw confirmationExpired();
+        PendingLogin pending;
+        try {
+            pending = JSON.parseObject(cached, PendingLogin.class);
+        } catch (RuntimeException exception) {
+            throw confirmationExpired();
+        }
+        if (pending == null || pending.context() == null || pending.context().userId() == null)
+            throw confirmationExpired();
+        CancellationLoginContext context = pending.context();
+        InfoUser user = users.selectCancellationUserForUpdate(context.userId());
         Date now = new Date();
         Date now = new Date();
-        if (!canRecover(user, now) || users.restoreCancelledUser(userId, now) != 1) throw unavailable();
+        if (now.getTime() >= pending.expiresAt()) throw confirmationExpired();
+        if (!canRecover(user, now)) throw unavailable();
+        if (pending.cycle() == null || !pending.cycle().equals(redis.getCacheObject(CYCLE_PREFIX + user.getUserId())))
+            throw confirmationExpired();
+        if (user.getCancellationDeadlineAt().getTime() != pending.cancellationDeadline()
+                || !Objects.equals(user.getPhone(), context.phone())) throw confirmationExpired();
+        // 两个并发请求即使读到同一缓存,只有成功删除凭证的请求能恢复及签发会话。
+        if (!redis.deleteObject(CONFIRM_PREFIX + key)) throw confirmationExpired();
+        if (users.restoreCancelledUser(user.getUserId(), now) != 1) throw unavailable();
+        // 恢复后其他等待确认的凭证同时失效,避免秒精度截止时间重复时跨申请恢复。
+        redis.deleteObject(CYCLE_PREFIX + user.getUserId());
         user.setDelFlag("0"); user.setCancellationDeadlineAt(null);
         user.setDelFlag("0"); user.setCancellationDeadlineAt(null);
-        return user;
+        return new ConfirmedLogin(user, context);
+    }
+
+    private ServiceException confirmationExpired() {
+        return new ServiceException(MessageUtils.message("no.user.cancellation.confirm.expired"));
     }
     }
 
 
     /** 复用平台字典缓存;后台修改并刷新后对新申请生效,读取不到或非法均默认7天。 */
     /** 复用平台字典缓存;后台修改并刷新后对新申请生效,读取不到或非法均默认7天。 */

+ 4 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/user/dto/CancellationLoginConfirmDto.java

@@ -0,0 +1,4 @@
+package com.ruoyi.app.user.dto;
+
+/** 用户明确确认恢复并登录;凭证由身份验证成功后的登录响应提供。 */
+public record CancellationLoginConfirmDto(String confirmationKey) { }

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

@@ -529,10 +529,15 @@ invoice.print.preparing=发票打印文件正在准备,请稍后重试
 
 
 app.dict.type.invalid=字典类型不能为空且不能超过100个字符
 app.dict.type.invalid=字典类型不能为空且不能超过100个字符
 
 
+# New orders use whole TWD yuan; existing billing snapshots are unchanged.
+order.freight.wholeYuan.required=Delivery fee must be a non-negative whole number in TWD.
+
 # Rider income statements
 # Rider income statements
 rider.income.query.invalid=查詢參數無效,請檢查起止時間(年月日時分秒)、訂單類型及分頁
 rider.income.query.invalid=查詢參數無效,請檢查起止時間(年月日時分秒)、訂單類型及分頁
 rider.income.access.denied=僅啟用的騎手帳號可查看收入帳單
 rider.income.access.denied=僅啟用的騎手帳號可查看收入帳單
 rider.income.not.found=收入帳單不存在
 rider.income.not.found=收入帳單不存在
 
 
+no.user.cancellation.confirm.expired=確認已失效,請重新登入並確認是否恢復帳號
+
 merchant.subaccount.username.required=分管账号用户名不能为空
 merchant.subaccount.username.required=分管账号用户名不能为空
 merchant.subaccount.username.exists=该用户名已被使用
 merchant.subaccount.username.exists=该用户名已被使用

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

@@ -515,10 +515,15 @@ invoice.print.preparing=Invoice print files are being prepared. Please try again
 
 
 app.dict.type.invalid=Dictionary type is required and must not exceed 100 characters
 app.dict.type.invalid=Dictionary type is required and must not exceed 100 characters
 
 
+# New orders use whole TWD yuan; existing billing snapshots are unchanged.
+order.freight.wholeYuan.required=Delivery fee must be a non-negative whole number in TWD.
+
 # Rider income statements
 # Rider income statements
 rider.income.query.invalid=Invalid query. Check start and end times (yyyy-MM-dd HH:mm:ss), order type and pagination.
 rider.income.query.invalid=Invalid query. Check start and end times (yyyy-MM-dd HH:mm:ss), order type and pagination.
 rider.income.access.denied=Only active rider accounts can view income statements.
 rider.income.access.denied=Only active rider accounts can view income statements.
 rider.income.not.found=Income statement not found.
 rider.income.not.found=Income statement not found.
 
 
+no.user.cancellation.confirm.expired=Confirmation has expired. Please sign in again and confirm account recovery.
+
 merchant.subaccount.username.required=Subaccount username is required
 merchant.subaccount.username.required=Subaccount username is required
 merchant.subaccount.username.exists=This username is already in use
 merchant.subaccount.username.exists=This username is already in use

+ 5 - 0
ruoyi-admin/src/main/resources/i18n/messages_th_TH.properties

@@ -498,10 +498,15 @@ no.oauth.binding.conflict=การเชื่อมโยงบัญชีภ
 
 
 app.dict.type.invalid=ต้องระบุประเภทพจนานุกรมและต้องไม่เกิน 100 อักขระ
 app.dict.type.invalid=ต้องระบุประเภทพจนานุกรมและต้องไม่เกิน 100 อักขระ
 
 
+# New orders use whole TWD yuan; existing billing snapshots are unchanged.
+order.freight.wholeYuan.required=ค่าจัดส่งต้องเป็นจำนวนเต็มที่ไม่ติดลบในหน่วยดอลลาร์ไต้หวัน
+
 # Rider income statements
 # Rider income statements
 rider.income.query.invalid=ข้อมูลค้นหาไม่ถูกต้อง โปรดตรวจสอบเวลาเริ่มต้นและสิ้นสุด (yyyy-MM-dd HH:mm:ss) ประเภทคำสั่งซื้อ และการแบ่งหน้า
 rider.income.query.invalid=ข้อมูลค้นหาไม่ถูกต้อง โปรดตรวจสอบเวลาเริ่มต้นและสิ้นสุด (yyyy-MM-dd HH:mm:ss) ประเภทคำสั่งซื้อ และการแบ่งหน้า
 rider.income.access.denied=เฉพาะบัญชีไรเดอร์ที่ใช้งานอยู่เท่านั้นที่ดูรายการรายได้ได้
 rider.income.access.denied=เฉพาะบัญชีไรเดอร์ที่ใช้งานอยู่เท่านั้นที่ดูรายการรายได้ได้
 rider.income.not.found=ไม่พบรายการรายได้
 rider.income.not.found=ไม่พบรายการรายได้
 
 
+no.user.cancellation.confirm.expired=การยืนยันหมดอายุแล้ว โปรดเข้าสู่ระบบอีกครั้งและยืนยันการกู้คืนบัญชี
+
 merchant.subaccount.username.required=กรุณาระบุชื่อผู้ใช้บัญชีย่อย
 merchant.subaccount.username.required=กรุณาระบุชื่อผู้ใช้บัญชีย่อย
 merchant.subaccount.username.exists=ชื่อผู้ใช้นี้ถูกใช้งานแล้ว
 merchant.subaccount.username.exists=ชื่อผู้ใช้นี้ถูกใช้งานแล้ว

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

@@ -53,10 +53,15 @@ invoice.print.preparing=Tệp in hóa đơn đang được chuẩn bị. Vui lò
 
 
 app.dict.type.invalid=Loại từ điển là bắt buộc và không được vượt quá 100 ký tự
 app.dict.type.invalid=Loại từ điển là bắt buộc và không được vượt quá 100 ký tự
 
 
+# New orders use whole TWD yuan; existing billing snapshots are unchanged.
+order.freight.wholeYuan.required=Phí giao hàng phải là số nguyên không âm, tính bằng TWD.
+
 # Rider income statements
 # Rider income statements
 rider.income.query.invalid=Tham số không hợp lệ. Vui lòng kiểm tra thời gian bắt đầu và kết thúc (yyyy-MM-dd HH:mm:ss), loại đơn và phân trang.
 rider.income.query.invalid=Tham số không hợp lệ. Vui lòng kiểm tra thời gian bắt đầu và kết thúc (yyyy-MM-dd HH:mm:ss), loại đơn và phân trang.
 rider.income.access.denied=Chỉ tài khoản tài xế đang hoạt động mới được xem thu nhập.
 rider.income.access.denied=Chỉ tài khoản tài xế đang hoạt động mới được xem thu nhập.
 rider.income.not.found=Không tìm thấy bản ghi thu nhập.
 rider.income.not.found=Không tìm thấy bản ghi thu nhập.
 
 
+no.user.cancellation.confirm.expired=Xác nhận đã hết hạn. Vui lòng đăng nhập lại và xác nhận khôi phục tài khoản.
+
 merchant.subaccount.username.required=Tên đăng nhập tài khoản phụ là bắt buộc
 merchant.subaccount.username.required=Tên đăng nhập tài khoản phụ là bắt buộc
 merchant.subaccount.username.exists=Tên đăng nhập này đã được sử dụng
 merchant.subaccount.username.exists=Tên đăng nhập này đã được sử dụng

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

@@ -517,10 +517,15 @@ invoice.print.preparing=发票打印文件正在准备,请稍后重试
 
 
 app.dict.type.invalid=字典类型不能为空且不能超过100个字符
 app.dict.type.invalid=字典类型不能为空且不能超过100个字符
 
 
+# New orders use whole TWD yuan; existing billing snapshots are unchanged.
+order.freight.wholeYuan.required=运费必须为非负整数新台币金额
+
 # Rider income statements
 # Rider income statements
 rider.income.query.invalid=查询参数无效,请检查起止时间(年月日时分秒)、订单类型及分页
 rider.income.query.invalid=查询参数无效,请检查起止时间(年月日时分秒)、订单类型及分页
 rider.income.access.denied=仅启用的骑手账号可查看收入账单
 rider.income.access.denied=仅启用的骑手账号可查看收入账单
 rider.income.not.found=收入账单不存在
 rider.income.not.found=收入账单不存在
 
 
+no.user.cancellation.confirm.expired=确认已失效,请重新登录并确认是否恢复账号
+
 merchant.subaccount.username.required=分管账号用户名不能为空
 merchant.subaccount.username.required=分管账号用户名不能为空
 merchant.subaccount.username.exists=该用户名已被使用
 merchant.subaccount.username.exists=该用户名已被使用

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

@@ -516,10 +516,15 @@ invoice.print.preparing=發票列印檔案正在準備,請稍後重試
 
 
 app.dict.type.invalid=字典類型不能為空且不能超過100個字元
 app.dict.type.invalid=字典類型不能為空且不能超過100個字元
 
 
+# New orders use whole TWD yuan; existing billing snapshots are unchanged.
+order.freight.wholeYuan.required=運費必須為非負整數新臺幣金額
+
 # Rider income statements
 # Rider income statements
 rider.income.query.invalid=查詢參數無效,請檢查起止時間(年月日時分秒)、訂單類型及分頁
 rider.income.query.invalid=查詢參數無效,請檢查起止時間(年月日時分秒)、訂單類型及分頁
 rider.income.access.denied=僅啟用的騎手帳號可查看收入帳單
 rider.income.access.denied=僅啟用的騎手帳號可查看收入帳單
 rider.income.not.found=收入帳單不存在
 rider.income.not.found=收入帳單不存在
 
 
+no.user.cancellation.confirm.expired=確認已失效,請重新登入並確認是否恢復帳號
+
 merchant.subaccount.username.required=分管帳號使用者名稱不能為空
 merchant.subaccount.username.required=分管帳號使用者名稱不能為空
 merchant.subaccount.username.exists=此使用者名稱已被使用
 merchant.subaccount.username.exists=此使用者名稱已被使用

+ 74 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/OrderWholeYuanFreightTest.java

@@ -0,0 +1,74 @@
+package com.ruoyi.app.order;
+
+import com.ruoyi.app.order.dto.OrderCreateInput;
+import com.ruoyi.app.order.dto.OrderCreatItem;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.system.domain.PosStore;
+import com.ruoyi.system.service.IInfoAddressService;
+import com.ruoyi.system.service.IPosStoreService;
+import com.ruoyi.system.utils.JwtUtil;
+import org.junit.jupiter.api.*;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.mockito.*;
+import org.springframework.test.util.ReflectionTestUtils;
+import java.util.List;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+/** 新订单不得把带分角的运费写进订单,再流入骑手/商家账单。 */
+class OrderWholeYuanFreightTest {
+    MockedStatic<MessageUtils> messages;
+    MockedConstruction<JwtUtil> tokens;
+    @BeforeEach void setup() {
+        messages=mockStatic(MessageUtils.class);
+        messages.when(() -> MessageUtils.message(anyString())).thenAnswer(i -> i.getArgument(0));
+        tokens=mockConstruction(JwtUtil.class,(jwt, ctx)->when(jwt.getusid("token")).thenReturn("9"));
+    }
+    @AfterEach void cleanup() { tokens.close(); messages.close(); }
+    OrderCreateInput input(double freight) {
+        OrderCreatItem item=new OrderCreatItem(); item.setMdId(101L); item.setShId(9L);
+        item.setAmount(100); item.setFreight(freight); item.setType(0L);
+        OrderCreateInput input=new OrderCreateInput(); input.setType(0L); input.setItems(List.of(item));
+        return input;
+    }
+    InfoUser merchant() { InfoUser user=new InfoUser(); user.setUserId(9L); user.setUserType("1"); return user; }
+    PosOrderShOprateController merchantController() {
+        PosOrderShOprateController controller=new PosOrderShOprateController();
+        IPosStoreService stores=mock(IPosStoreService.class);
+        PosStore store=new PosStore(); store.setUserId(9L);
+        when(stores.getById(101L)).thenReturn(store);
+        ReflectionTestUtils.setField(controller,"posStoreService",stores);
+        return controller;
+    }
+    @ParameterizedTest @ValueSource(doubles={40.5,0.01,-1,Double.NaN,Double.POSITIVE_INFINITY})
+    void merchantCannotCreateFractionalOrInvalidFreight(double freight) {
+        ServiceException error=assertThrows(ServiceException.class,
+                ()->merchantController().validateMerchantOrderInput(merchant(),input(freight)));
+        assertEquals("order.freight.wholeYuan.required",error.getMessage());
+    }
+    @ParameterizedTest @ValueSource(doubles={0,40,100})
+    void wholeYuanFreightKeepsExistingMerchantFlow(double freight) {
+        assertDoesNotThrow(()->merchantController().validateMerchantOrderInput(merchant(),input(freight)));
+    }
+    @Test void userRejectsFractionalFreightBeforeAddressAndOrderWrites() {
+        UserOrderController controller=new UserOrderController();
+        IInfoAddressService addresses=mock(IInfoAddressService.class);
+        ReflectionTestUtils.setField(controller,"infoAddressService",addresses);
+        ServiceException error=assertThrows(ServiceException.class,()->controller.createOrder("token",input(40.5)));
+        assertEquals("order.freight.wholeYuan.required",error.getMessage());
+        verifyNoInteractions(addresses);
+    }
+    @Test void removedLegacyCreateEntryHasNoControllerMethodOrPostMapping() {
+        for (java.lang.reflect.Method method : PosOrderController.class.getDeclaredMethods()) {
+            assertNotEquals("addorder", method.getName());
+            var mapping = method.getAnnotation(org.springframework.web.bind.annotation.PostMapping.class);
+            if (mapping != null) {
+                assertFalse(java.util.Arrays.asList(mapping.value()).contains("/addorder"));
+                assertFalse(java.util.Arrays.asList(mapping.path()).contains("/addorder"));
+            }
+        }
+    }
+}

+ 29 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/pay/StoreBillingSubaccountAccessTest.java

@@ -46,6 +46,35 @@ class StoreBillingSubaccountAccessTest {
     }
     }
     @AfterEach void cleanup() throws Exception { messages.close(); tokens.close(); mocks.close(); }
     @AfterEach void cleanup() throws Exception { messages.close(); tokens.close(); mocks.close(); }
 
 
+    @Test void summaryDoesNotExposeBinaryFloatingPointTail() {
+        UserBilling summary = new UserBilling();
+        summary.setAmount(3393.6000000000004); summary.setDivvy(0.0);
+        when(bills.getOne(any())).thenReturn(summary);
+        var data = (com.alibaba.fastjson.JSONObject) request(true, null).get("data");
+        assertEquals(0, new java.math.BigDecimal(data.get("totalAmount").toString())
+                .compareTo(new java.math.BigDecimal("3393.60")));
+        assertEquals(0, new java.math.BigDecimal(data.get("netIncome").toString())
+                .compareTo(new java.math.BigDecimal("3393.60")));
+    }
+
+    @Test void wholeYuanSummaryHasNoFractionAndHistoricalAmountIsNotRoundedToWholeYuan() {
+        UserBilling summary = new UserBilling(); summary.setAmount(100.0); summary.setDivvy(0.0);
+        when(bills.getOne(any())).thenReturn(summary);
+        var data = (com.alibaba.fastjson.JSONObject) request(true, null).get("data");
+        assertEquals("100", data.get("totalAmount").toString());
+        summary.setAmount(80.75);
+        data = (com.alibaba.fastjson.JSONObject) request(true, null).get("data");
+        assertEquals("80.75", data.get("totalAmount").toString());
+    }
+
+    @Test void summaryDoesNotDeductCommissionAgainFromAlreadyCreditedIncome() {
+        UserBilling summary = new UserBilling(); summary.setAmount(80.75); summary.setDivvy(14.25);
+        when(bills.getOne(any())).thenReturn(summary);
+        var data = (com.alibaba.fastjson.JSONObject) request(true, null).get("data");
+        assertEquals("80.75", data.get("netIncome").toString());
+        assertEquals("14.25", data.get("totalDivvy").toString());
+    }
+
     private AjaxResult request(boolean summary, Long store) {
     private AjaxResult request(boolean summary, Long store) {
         return summary ? controller.storeBillingSummary("token", store, null, null, null)
         return summary ? controller.storeBillingSummary("token", store, null, null, null)
                 : controller.storeBillingList("token", 1, 10, store, null, null, null);
                 : controller.storeBillingList("token", 1, 10, store, null, null, null);

+ 217 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/user/CancellationLoginConfirmationTest.java

@@ -0,0 +1,217 @@
+package com.ruoyi.app.user;
+
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.ruoyi.app.user.dto.CancellationLoginConfirmDto;
+import com.ruoyi.app.user.dto.OAuthBindDto;
+import com.ruoyi.app.user.dto.OAuthDeviceConfirmDto;
+import com.ruoyi.app.user.dto.OAuthLoginDto;
+import com.ruoyi.app.user.service.DeviceTrustService;
+import com.ruoyi.app.utils.oauth.OAuthIdentity;
+import com.ruoyi.app.utils.oauth.OAuthVerifyService;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.core.domain.model.LoginUserDto;
+import com.ruoyi.common.core.redis.RedisCache;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.system.domain.InfoUserDevice;
+import com.ruoyi.system.domain.InfoUserOauth;
+import com.ruoyi.system.domain.vo.UserDTO;
+import com.ruoyi.system.mapper.InfoUserMapper;
+import com.ruoyi.system.mapper.InfoUserOauthMapper;
+import com.ruoyi.system.service.IInfoUserService;
+import com.ruoyi.system.service.IVipUserService;
+import com.ruoyi.system.utils.JwtUtil;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
+import org.mockito.MockedStatic;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.test.util.ReflectionTestUtils;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.context.request.RequestContextHolder;
+import org.springframework.web.context.request.ServletRequestAttributes;
+
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+class CancellationLoginConfirmationTest {
+    private final InfoUserController controller = new InfoUserController();
+    private final InfoUserMapper accounts = mock(InfoUserMapper.class);
+    private final IInfoUserService users = mock(IInfoUserService.class);
+    private final RedisCache redis = mock(RedisCache.class);
+    private final DeviceTrustService devices = mock(DeviceTrustService.class);
+    private final InfoUserOauthMapper bindings = mock(InfoUserOauthMapper.class);
+    private final OAuthVerifyService verifier = mock(OAuthVerifyService.class);
+    private final Map<String, String> cache = new HashMap<>();
+    private final InfoUser user = new InfoUser();
+    private final InfoUserOauth binding = new InfoUserOauth();
+    private MockedStatic<MessageUtils> messages;
+    private MockedStatic<JwtUtil> jwt;
+
+    @BeforeEach void setUp() {
+        messages = mockStatic(MessageUtils.class, c -> c.getArgument(0));
+        jwt = mockStatic(JwtUtil.class);
+        jwt.when(() -> JwtUtil.setToken(anyString(), any(LoginUserDto.class))).thenReturn("new-session");
+        var request = new MockHttpServletRequest(); request.setRemoteAddr("127.0.0.1");
+        RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request));
+        user.setUserId(7L); user.setUserType("0"); user.setStatus("0"); user.setDelFlag("1");
+        user.setPhone("0912345678"); user.setCancellationDeadlineAt(new Date(System.currentTimeMillis() + 3600000));
+        binding.setId(77L); binding.setUserId(7L); binding.setProvider("google"); binding.setProviderUid("verified-id");
+        when(accounts.selectCancellationUserForUpdate(7L)).thenReturn(user);
+        when(accounts.restoreCancelledUser(eq(7L), any())).thenReturn(1);
+        when(users.getOne(any(Wrapper.class))).thenReturn(user);
+        when(users.list(any(Wrapper.class))).thenReturn(List.of(user));
+        when(users.saveOrUpdate(any(InfoUser.class))).thenReturn(true);
+        when(bindings.selectOne(any(Wrapper.class))).thenReturn(binding);
+        when(bindings.selectByIdForUpdate(77L)).thenReturn(binding);
+        when(bindings.insert(any(InfoUserOauth.class))).thenReturn(1);
+        when(verifier.verifyIdentity("google", "one-use-code")).thenReturn(new OAuthIdentity("verified-id", "Name"));
+        var trust = new InfoUserDevice(); trust.setDeviceId("device"); trust.setPhone(user.getPhone()); trust.setUserId(7L);
+        when(devices.getTrust("device")).thenReturn(trust);
+        cache.put(user.getPhone(), "123456");
+        cache.put("oauth:bind:original", "google@verified-id@Name");
+        cache.put("oauth:bind:dev:original", "device"); cache.put("oauth:bind:user:original", "7");
+        when(redis.getCacheObject(anyString())).thenAnswer(call -> cache.get(call.getArgument(0)));
+        when(redis.deleteObject(anyString())).thenAnswer(call -> cache.remove(call.getArgument(0)) != null);
+        doAnswer(call -> { cache.put(call.getArgument(0), call.getArgument(1)); return null; })
+                .when(redis).setCacheObject(anyString(), anyString(), anyInt(), eq(TimeUnit.SECONDS));
+        ReflectionTestUtils.setField(controller, "userCancellationService", new UserCancellationService(accounts, redis));
+        ReflectionTestUtils.setField(controller, "infoUserService", users);
+        ReflectionTestUtils.setField(controller, "redisCache", redis);
+        ReflectionTestUtils.setField(controller, "deviceTrustService", devices);
+        ReflectionTestUtils.setField(controller, "infoUserOauthMapper", bindings);
+        ReflectionTestUtils.setField(controller, "oauthVerifyService", verifier);
+        ReflectionTestUtils.setField(controller, "vipUserService", mock(IVipUserService.class));
+    }
+    @AfterEach void tearDown() { jwt.close(); messages.close(); RequestContextHolder.resetRequestAttributes(); }
+
+    private UserCancellationService.LoginConfirmationView prompt(CancellationLoginContext.Flow flow) {
+        AjaxResult response;
+        switch (flow) {
+            case PHONE -> {
+                UserDTO dto = new UserDTO(); dto.setPhone(user.getPhone()); dto.setCode("123456");
+                dto.setDeviceId("device"); dto.setCid("new-cid");
+                response = controller.lodeing(dto);
+            }
+            case OAUTH -> {
+                OAuthLoginDto dto = new OAuthLoginDto(); dto.setProvider("google"); dto.setCredential("one-use-code");
+                dto.setDeviceId("device"); dto.setCid("new-cid"); response = controller.oauthLogin(dto);
+            }
+            case BIND -> {
+                OAuthBindDto dto = new OAuthBindDto(); dto.setTempKey("original"); dto.setPhone(user.getPhone());
+                dto.setCode("123456"); dto.setDeviceId("device"); dto.setCid("new-cid");
+                response = controller.oauthBindPhone(dto);
+            }
+            default -> {
+                OAuthDeviceConfirmDto dto = new OAuthDeviceConfirmDto(); dto.setTempKey("original"); dto.setDeviceId("device");
+                response = controller.oauthDeviceConfirm(dto);
+            }
+        }
+        assertEquals(200, response.get("code")); assertNull(response.get("token"));
+        var view = (UserCancellationService.LoginConfirmationView) response.get("data");
+        assertEquals("cancellationConfirm", view.status()); assertEquals(1, view.remainingDays());
+        assertEquals("1", user.getDelFlag()); assertNull(user.getCid());
+        verify(accounts, never()).restoreCancelledUser(anyLong(), any());
+        verify(users, never()).saveOrUpdate(any(InfoUser.class));
+        verify(bindings, never()).insert(any(InfoUserOauth.class));
+        verify(bindings, never()).updateById(any(InfoUserOauth.class));
+        verify(devices, never()).recordLogin(any(), any(), any());
+        verify(devices, never()).recordOauthLogin(any(), any(), any(), any());
+        jwt.verifyNoInteractions();
+        return view;
+    }
+
+    @ParameterizedTest @EnumSource(CancellationLoginContext.Flow.class)
+    void everyFlowWaitsForConfirmationAndResumesWithoutReusingOriginalCredential(CancellationLoginContext.Flow flow) {
+        var view = prompt(flow);
+        cache.remove(user.getPhone()); cache.remove("oauth:bind:original");
+        AjaxResult response = controller.confirmCancellationLogin(new CancellationLoginConfirmDto(view.confirmationKey()));
+        assertEquals("new-session", response.get("token")); assertSame(user, response.get("data"));
+        assertEquals("0", user.getDelFlag()); assertNull(user.getCancellationDeadlineAt());
+        if (flow != CancellationLoginContext.Flow.DEVICE) assertEquals("new-cid", user.getCid());
+        verify(accounts, times(1)).restoreCancelledUser(eq(7L), any());
+        if (flow == CancellationLoginContext.Flow.OAUTH) verify(verifier, times(1)).verifyIdentity(any(), any());
+        assertThrows(ServiceException.class, () -> controller.confirmCancellationLogin(new CancellationLoginConfirmDto(view.confirmationKey())));
+        jwt.verify(() -> JwtUtil.setToken(anyString(), any(LoginUserDto.class)), times(1));
+    }
+
+    @Test void cancellingPromptLeavesLifecycleAndSessionsUnchanged() { prompt(CancellationLoginContext.Flow.PHONE); }
+
+    @Test void failedAuthenticationNeverReachesCancellationPrompt() {
+        UserDTO dto = new UserDTO(); dto.setPhone(user.getPhone()); dto.setCode("wrong");
+        assertEquals(500, controller.lodeing(dto).get("code"));
+        verifyNoInteractions(accounts); jwt.verifyNoInteractions();
+    }
+
+    @Test void removedOauthBindingCannotBeRecreatedByConfirmation() {
+        var view = prompt(CancellationLoginContext.Flow.OAUTH);
+        when(bindings.selectOne(any(Wrapper.class))).thenReturn(null);
+        assertThrows(ServiceException.class, () -> controller.confirmCancellationLogin(new CancellationLoginConfirmDto(view.confirmationKey())));
+        verify(bindings, never()).insert(any(InfoUserOauth.class)); jwt.verifyNoInteractions();
+    }
+
+    @Test void changedTrustedDeviceCannotCompleteConfirmation() {
+        var view = prompt(CancellationLoginContext.Flow.DEVICE);
+        doThrow(new ServiceException("changed")).when(devices).requireConfirmation("device", 7L);
+        assertThrows(ServiceException.class, () -> controller.confirmCancellationLogin(new CancellationLoginConfirmDto(view.confirmationKey())));
+        jwt.verifyNoInteractions();
+    }
+
+    @Test void tokenFailureThrowsAndConfirmationEndpointHasRollbackTransaction() throws Exception {
+        var view = prompt(CancellationLoginContext.Flow.PHONE);
+        jwt.when(() -> JwtUtil.setToken(anyString(), any(LoginUserDto.class))).thenReturn(null);
+        assertThrows(ServiceException.class, () -> controller.confirmCancellationLogin(new CancellationLoginConfirmDto(view.confirmationKey())));
+        var annotation = InfoUserController.class.getMethod("confirmCancellationLogin", CancellationLoginConfirmDto.class)
+                .getAnnotation(Transactional.class);
+        assertNotNull(annotation); assertTrue(List.of(annotation.rollbackFor()).contains(Exception.class));
+    }
+
+    @ParameterizedTest
+    @org.junit.jupiter.params.provider.ValueSource(booleans = {false, true})
+    void databaseRecoveryCommitsOnlyWhenLoginSessionSucceeds(boolean failSession) {
+        var source = new org.h2.jdbcx.JdbcDataSource();
+        source.setURL("jdbc:h2:mem:" + java.util.UUID.randomUUID() + ";DB_CLOSE_DELAY=-1");
+        var jdbc = new org.springframework.jdbc.core.JdbcTemplate(source);
+        jdbc.execute("CREATE TABLE cancellation_account (id BIGINT PRIMARY KEY, deleted VARCHAR(1), deadline TIMESTAMP)");
+        jdbc.update("INSERT INTO cancellation_account VALUES (7, '1', ?)", user.getCancellationDeadlineAt());
+        when(accounts.selectCancellationUserForUpdate(7L)).thenAnswer(call -> jdbc.queryForObject(
+                "SELECT deleted, deadline FROM cancellation_account WHERE id=7 FOR UPDATE", (row, index) -> {
+                    InfoUser account = new InfoUser(); account.setUserId(7L); account.setPhone(user.getPhone());
+                    account.setUserType("0"); account.setStatus("0"); account.setDelFlag(row.getString(1));
+                    account.setCancellationDeadlineAt(row.getTimestamp(2)); return account;
+                }));
+        when(accounts.restoreCancelledUser(eq(7L), any())).thenAnswer(call -> jdbc.update(
+                "UPDATE cancellation_account SET deleted='0', deadline=NULL WHERE id=7 AND deleted='1' AND deadline>?",
+                call.getArgument(1, Date.class)));
+        var view = prompt(CancellationLoginContext.Flow.PHONE);
+        when(users.getOne(any(Wrapper.class))).thenAnswer(call -> accounts.selectCancellationUserForUpdate(7L));
+        var transactionManager = new org.springframework.jdbc.datasource.DataSourceTransactionManager(source);
+        var interceptor = new org.springframework.transaction.interceptor.TransactionInterceptor(transactionManager,
+                new org.springframework.transaction.annotation.AnnotationTransactionAttributeSource());
+        var factory = new org.springframework.aop.framework.ProxyFactory(controller);
+        factory.setProxyTargetClass(true); factory.addAdvice(interceptor);
+        var transactionalController = (InfoUserController) factory.getProxy();
+        if (failSession) {
+            jwt.when(() -> JwtUtil.setToken(anyString(), any(LoginUserDto.class))).thenReturn(null);
+            assertThrows(ServiceException.class, () -> transactionalController.confirmCancellationLogin(
+                    new CancellationLoginConfirmDto(view.confirmationKey())));
+            assertEquals("1", jdbc.queryForObject("SELECT deleted FROM cancellation_account WHERE id=7", String.class));
+            assertNotNull(jdbc.queryForObject("SELECT deadline FROM cancellation_account WHERE id=7", Date.class));
+        } else {
+            assertEquals("new-session", transactionalController.confirmCancellationLogin(
+                    new CancellationLoginConfirmDto(view.confirmationKey())).get("token"));
+            assertEquals("0", jdbc.queryForObject("SELECT deleted FROM cancellation_account WHERE id=7", String.class));
+            assertNull(jdbc.queryForObject("SELECT deadline FROM cancellation_account WHERE id=7", Date.class));
+        }
+    }
+}

+ 36 - 4
ruoyi-admin/src/test/java/com/ruoyi/app/user/DeviceTrustFlowTest.java

@@ -60,13 +60,12 @@ class DeviceTrustFlowTest {
         if (pending) {
         if (pending) {
             account.setDelFlag("1");
             account.setDelFlag("1");
             account.setCancellationDeadlineAt(new java.util.Date(System.currentTimeMillis() + 60000));
             account.setCancellationDeadlineAt(new java.util.Date(System.currentTimeMillis() + 60000));
-            when(cancellationService.authenticatedLogin(account.getUserId())).thenAnswer(call -> {
-                account.setDelFlag("0"); account.setCancellationDeadlineAt(null); return account;
-            });
+            when(cancellationService.prepareLogin(any())).thenReturn(new UserCancellationService.LoginConfirmationView(
+                    "cancellationConfirm", 1, account.getCancellationDeadlineAt(), "a".repeat(32), 60));
         }
         }
     }
     }
     private void verifyRecovery(boolean pending) {
     private void verifyRecovery(boolean pending) {
-        if (pending) verify(cancellationService).authenticatedLogin(100L);
+        if (pending) verify(cancellationService).prepareLogin(argThat(context -> context.userId().equals(100L)));
         else verifyNoInteractions(cancellationService);
         else verifyNoInteractions(cancellationService);
     }
     }
 
 
@@ -323,6 +322,17 @@ class DeviceTrustFlowTest {
         when(infoUserService.list(any(com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper.class))).thenReturn(java.util.List.of(existing));
         when(infoUserService.list(any(com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper.class))).thenReturn(java.util.List.of(existing));
 
 
         AjaxResult result = controller.oauthDeviceConfirm(confirmDto("tk-8", "dev-1"));
         AjaxResult result = controller.oauthDeviceConfirm(confirmDto("tk-8", "dev-1"));
+        if (pending) {
+            assertNull(result.get("token"));
+            assertEquals("cancellationConfirm", ((UserCancellationService.LoginConfirmationView) result.get("data")).status());
+            assertEquals("1", existing.getDelFlag());
+            verify(infoUserService, never()).saveOrUpdate(any(InfoUser.class));
+            verify(infoUserOauthMapper, never()).insert(any(InfoUserOauth.class));
+            verify(deviceMapper, never()).updateById(any(InfoUserDevice.class));
+            verifyRecovery(true);
+            return;
+        }
+
 
 
         assertEquals(200, result.get("code"));
         assertEquals(200, result.get("code"));
         assertNotNull(result.get("token"));
         assertNotNull(result.get("token"));
@@ -393,6 +403,17 @@ class DeviceTrustFlowTest {
         dto.setCode("8888");
         dto.setCode("8888");
         dto.setDeviceId("dev-1");
         dto.setDeviceId("dev-1");
         AjaxResult result = controller.oauthBindPhone(dto);
         AjaxResult result = controller.oauthBindPhone(dto);
+        if (pending) {
+            assertNull(result.get("token"));
+            assertEquals("cancellationConfirm", ((UserCancellationService.LoginConfirmationView) result.get("data")).status());
+            assertEquals("1", existing.getDelFlag());
+            verify(infoUserService, never()).saveOrUpdate(any(InfoUser.class));
+            verify(infoUserOauthMapper, never()).insert(any(InfoUserOauth.class));
+            verify(deviceMapper, never()).updateById(any(InfoUserDevice.class));
+            verifyRecovery(true);
+            return;
+        }
+
 
 
         assertEquals(200, result.get("code"));
         assertEquals(200, result.get("code"));
         assertNotNull(result.get("token"));
         assertNotNull(result.get("token"));
@@ -420,6 +441,17 @@ class DeviceTrustFlowTest {
         dto.setCredential("cred-3");
         dto.setCredential("cred-3");
         dto.setDeviceId("dev-1");
         dto.setDeviceId("dev-1");
         AjaxResult result = controller.oauthLogin(dto);
         AjaxResult result = controller.oauthLogin(dto);
+        if (pending) {
+            assertNull(result.get("token"));
+            assertEquals("cancellationConfirm", ((UserCancellationService.LoginConfirmationView) result.get("data")).status());
+            assertEquals("1", existing.getDelFlag());
+            verify(infoUserService, never()).saveOrUpdate(any(InfoUser.class));
+            verify(infoUserOauthMapper, never()).insert(any(InfoUserOauth.class));
+            verify(deviceMapper, never()).updateById(any(InfoUserDevice.class));
+            verifyRecovery(true);
+            return;
+        }
+
 
 
         assertEquals(200, result.get("code"));
         assertEquals(200, result.get("code"));
         assertNotNull(result.get("token"));
         assertNotNull(result.get("token"));

+ 11 - 4
ruoyi-admin/src/test/java/com/ruoyi/app/user/InfoUserControllerTest.java

@@ -64,6 +64,7 @@ import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.argThat;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.mockConstruction;
 import static org.mockito.Mockito.mockConstruction;
 import static org.mockito.Mockito.mockStatic;
 import static org.mockito.Mockito.mockStatic;
@@ -1004,7 +1005,7 @@ class InfoUserControllerTest {
 
 
     @ParameterizedTest
     @ParameterizedTest
     @CsvSource({"apple", "google", "line", "line_user"})
     @CsvSource({"apple", "google", "line", "line_user"})
-    void verifiedBoundUserRestoresGraceAccountBeforeIssuingToken(String provider) {
+    void verifiedBoundUserRequiresConfirmationBeforeIssuingToken(String provider) {
         OAuthLoginDto request = new OAuthLoginDto();
         OAuthLoginDto request = new OAuthLoginDto();
         request.setProvider(provider); request.setCredential("verified-credential");
         request.setProvider(provider); request.setCredential("verified-credential");
         when(oauthVerifyService.verifyIdentity(provider, "verified-credential"))
         when(oauthVerifyService.verifyIdentity(provider, "verified-credential"))
@@ -1015,13 +1016,19 @@ class InfoUserControllerTest {
         when(infoUserService.getOne(any(Wrapper.class))).thenReturn(user);
         when(infoUserService.getOne(any(Wrapper.class))).thenReturn(user);
         UserCancellationService cancellation = mock(UserCancellationService.class);
         UserCancellationService cancellation = mock(UserCancellationService.class);
         ReflectionTestUtils.setField(controller, "userCancellationService", cancellation);
         ReflectionTestUtils.setField(controller, "userCancellationService", cancellation);
-        when(cancellation.authenticatedLogin(72L)).thenAnswer(call -> { user.setDelFlag("0"); return user; });
+        var view = new UserCancellationService.LoginConfirmationView("cancellationConfirm", 1,
+                new java.util.Date(System.currentTimeMillis() + 60000), "a".repeat(32), 60);
+        when(cancellation.prepareLogin(any())).thenReturn(view);
 
 
         AjaxResult result = controller.oauthLogin(request);
         AjaxResult result = controller.oauthLogin(request);
 
 
         assertEquals(HttpStatus.SUCCESS, result.get(AjaxResult.CODE_TAG));
         assertEquals(HttpStatus.SUCCESS, result.get(AjaxResult.CODE_TAG));
-        verify(cancellation).authenticatedLogin(72L);
-        assertEquals("72", JWT.decode((String) result.get("token")).getClaim("id").asString());
+        verify(cancellation).prepareLogin(argThat(context -> context.userId().equals(72L)
+                && context.provider().equals(provider) && context.providerUid().equals("provider-uid")));
+        assertEquals(view, result.get("data"));
+        assertEquals("1", user.getDelFlag());
+        verify(infoUserService, never()).saveOrUpdate(any(InfoUser.class));
+        assertNull(result.get("token"), "Pending cancellation must wait for explicit confirmation");
     }
     }
 
 
     @Test
     @Test

+ 13 - 5
ruoyi-admin/src/test/java/com/ruoyi/app/user/LineCallbackDeviceTrustTest.java

@@ -165,20 +165,28 @@ class LineCallbackDeviceTrustTest {
         u.setDelFlag(pending ? "1" : "0");
         u.setDelFlag(pending ? "1" : "0");
         UserCancellationService cancellation = mock(UserCancellationService.class);
         UserCancellationService cancellation = mock(UserCancellationService.class);
         ReflectionTestUtils.setField(controller, "userCancellationService", cancellation);
         ReflectionTestUtils.setField(controller, "userCancellationService", cancellation);
-        if (pending) when(cancellation.authenticatedLogin(100L)).thenAnswer(call -> {
-            u.setDelFlag("0"); return u;
-        });
+        if (pending) when(cancellation.prepareLogin(any())).thenReturn(new UserCancellationService.LoginConfirmationView(
+                "cancellationConfirm", 1, new java.util.Date(System.currentTimeMillis() + 60000), "a".repeat(32), 60));
         when(infoUserService.getOne(any())).thenReturn(u);
         when(infoUserService.getOne(any())).thenReturn(u);
         when(deviceMapper.selectById("dev-1")).thenReturn(null);
         when(deviceMapper.selectById("dev-1")).thenReturn(null);
 
 
         controller.callback("line", "code-3", "dev-1", response);
         controller.callback("line", "code-3", "dev-1", response);
+        if (pending) {
+            assertTrue(response.getRedirectedUrl().contains("cancellationConfirm=1"));
+            assertTrue(response.getRedirectedUrl().contains("remainingDays=1"));
+            org.junit.jupiter.api.Assertions.assertFalse(response.getRedirectedUrl().contains("token="));
+            assertEquals("1", u.getDelFlag());
+            verify(cancellation).prepareLogin(any());
+            verify(deviceMapper, never()).updateById(any(InfoUserDevice.class));
+            return;
+        }
+
 
 
         String location = response.getRedirectedUrl();
         String location = response.getRedirectedUrl();
         assertTrue(location != null && location.startsWith("com.test.app://oauthLogin?token="),
         assertTrue(location != null && location.startsWith("com.test.app://oauthLogin?token="),
                 "已绑定应直接回跳 token,实际: " + location);
                 "已绑定应直接回跳 token,实际: " + location);
         verify(deviceMapper).ensureExists("dev-1", "0987654321", 100L);
         verify(deviceMapper).ensureExists("dev-1", "0987654321", 100L);
-        if (pending) verify(cancellation).authenticatedLogin(100L);
-        else org.mockito.Mockito.verifyNoInteractions(cancellation);
+        org.mockito.Mockito.verifyNoInteractions(cancellation);
     }
     }
 
 
     @Test
     @Test

+ 1 - 1
ruoyi-admin/src/test/java/com/ruoyi/app/user/MerchantSessionAuthAnnotationTest.java

@@ -80,7 +80,7 @@ class MerchantSessionAuthAnnotationTest {
                 "setorderuzt", "getorderxq", "getstoreorderlist");
                 "setorderuzt", "getorderxq", "getstoreorderlist");
         assertAuth(PosOrderShOprateController.class,
         assertAuth(PosOrderShOprateController.class,
                 "getInvoice", "invalidInvoice", "getOrderPrintInfo", "createOrder", "acceptOrder", "dispatchOrder",
                 "getInvoice", "invalidInvoice", "getOrderPrintInfo", "createOrder", "acceptOrder", "dispatchOrder",
-                "completeOrder", "confirmCashPayment", "cancelOrder", "orderList", "orderDetail");
+                "completeOrder", "confirmCashPayment", "cancelOrder", "orderList");
     }
     }
 
 
     private void assertAuth(Class<?> controllerType, String... methodNames) {
     private void assertAuth(Class<?> controllerType, String... methodNames) {

+ 133 - 24
ruoyi-admin/src/test/java/com/ruoyi/app/user/UserCancellationServiceTest.java

@@ -1,5 +1,6 @@
 package com.ruoyi.app.user;
 package com.ruoyi.app.user;
 
 
+import com.alibaba.fastjson2.JSON;
 import com.ruoyi.common.core.redis.RedisCache;
 import com.ruoyi.common.core.redis.RedisCache;
 import com.ruoyi.common.exception.ServiceException;
 import com.ruoyi.common.exception.ServiceException;
 import com.ruoyi.common.utils.MessageUtils;
 import com.ruoyi.common.utils.MessageUtils;
@@ -7,6 +8,9 @@ import com.ruoyi.system.domain.InfoUser;
 import com.ruoyi.system.mapper.InfoUserMapper;
 import com.ruoyi.system.mapper.InfoUserMapper;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.Test;
 import java.util.Date;
 import java.util.Date;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
 import static org.junit.jupiter.api.Assertions.*;
 import static org.junit.jupiter.api.Assertions.*;
 import static org.mockito.Mockito.*;
 import static org.mockito.Mockito.*;
 import static org.mockito.ArgumentMatchers.*;
 import static org.mockito.ArgumentMatchers.*;
@@ -15,58 +19,163 @@ class UserCancellationServiceTest {
     private final InfoUserMapper users = mock(InfoUserMapper.class);
     private final InfoUserMapper users = mock(InfoUserMapper.class);
     private final RedisCache redis = mock(RedisCache.class);
     private final RedisCache redis = mock(RedisCache.class);
     private final UserCancellationService service = new UserCancellationService(users, redis);
     private final UserCancellationService service = new UserCancellationService(users, redis);
+    private final Map<String, String> cache = new HashMap<>();
+
+    UserCancellationServiceTest() {
+        doAnswer(call -> { cache.put(call.getArgument(0), call.getArgument(1)); return null; })
+                .when(redis).setCacheObject(anyString(), anyString(), anyInt(), eq(TimeUnit.SECONDS));
+        when(redis.getCacheObject(anyString())).thenAnswer(call -> cache.get(call.getArgument(0)));
+        when(redis.deleteObject(anyString())).thenAnswer(call -> cache.remove(call.getArgument(0)) != null);
+    }
     private InfoUser user(String deleted, Date deadline) {
     private InfoUser user(String deleted, Date deadline) {
-        InfoUser u = new InfoUser(); u.setUserId(1L); u.setUserType("0");
+        InfoUser u = new InfoUser(); u.setUserId(1L); u.setUserType("0"); u.setPhone("0912345678");
         u.setStatus("0"); u.setDelFlag(deleted); u.setCancellationDeadlineAt(deadline);
         u.setStatus("0"); u.setDelFlag(deleted); u.setCancellationDeadlineAt(deadline);
         when(users.selectCancellationUserForUpdate(1L)).thenReturn(u); return u;
         when(users.selectCancellationUserForUpdate(1L)).thenReturn(u); return u;
     }
     }
+    private CancellationLoginContext context() {
+        return new CancellationLoginContext(CancellationLoginContext.Flow.PHONE, 1L, "0912345678", "phone", null, null,
+                new CancellationLoginContext.Device("device", "cid", "ios", "push", null, true));
+    }
     @Test void cancellationRecordsSevenDaysAndRevokesUserSessions() {
     @Test void cancellationRecordsSevenDaysAndRevokesUserSessions() {
         user("0", null); when(users.requestUserCancellation(eq(1L), any())).thenReturn(1);
         user("0", null); when(users.requestUserCancellation(eq(1L), any())).thenReturn(1);
         Date before = new Date(); var result = service.request(1L);
         Date before = new Date(); var result = service.request(1L);
+        assertEquals(7, result.graceDays());
         assertTrue(result.cancellationDeadlineAt().getTime() >= before.getTime() + 7L*86400000);
         assertTrue(result.cancellationDeadlineAt().getTime() >= before.getTime() + 7L*86400000);
         verify(redis).deleteKeys(anyString());
         verify(redis).deleteKeys(anyString());
     }
     }
-    @Test void verifiedLoginRestoresOriginalUserAndClearsDeadline() {
+    @Test void verifiedLoginOnlyReturnsConfirmationAndCeilingDaysWithoutRestoring() {
+        InfoUser u = user("1", new Date(System.currentTimeMillis()+86400000L+60000));
+        var result = service.prepareLogin(context());
+        assertEquals("cancellationConfirm", result.status()); assertEquals(2, result.remainingDays());
+        assertEquals(300, result.expiresInSeconds());
+        assertEquals(u.getCancellationDeadlineAt(), result.cancellationDeadlineAt());
+        assertTrue(result.confirmationKey().matches("[0-9a-f]{32}"));
+        assertEquals("1", u.getDelFlag());
+        verify(users, never()).restoreCancelledUser(anyLong(), any());
+        verify(redis, never()).deleteKeys(anyString());
+        var cached = JSON.parseObject(cache.get(UserCancellationService.CONFIRM_PREFIX + result.confirmationKey()), UserCancellationService.PendingLogin.class);
+        assertEquals(context(), cached.context());
+    }
+    @Test void lessThanOneDayShowsOneDayAndTicketCannotOutliveGrace() {
         InfoUser u = user("1", new Date(System.currentTimeMillis()+60000));
         InfoUser u = user("1", new Date(System.currentTimeMillis()+60000));
-        when(users.restoreCancelledUser(eq(1L), any())).thenReturn(1);
-        assertSame(u, service.authenticatedLogin(1L));
-        assertEquals("0", u.getDelFlag()); assertNull(u.getCancellationDeadlineAt());
+        var result = service.prepareLogin(context());
+        assertEquals(1, result.remainingDays());
+        assertTrue(result.expiresInSeconds() > 0 && result.expiresInSeconds() <= 60);
+        var cached = JSON.parseObject(cache.get(UserCancellationService.CONFIRM_PREFIX + result.confirmationKey()), UserCancellationService.PendingLogin.class);
+        assertEquals(u.getCancellationDeadlineAt().getTime(), cached.expiresAt());
     }
     }
-    @Test void expiredHistoricalDisabledAndNonUserAccountsCannotRestore() {
+    @Test void confirmationRestoresOriginalAccountAndConsumesCredentialOnce() {
         try(var messages = mockStatic(MessageUtils.class, c -> c.getArgument(0))) {
         try(var messages = mockStatic(MessageUtils.class, c -> c.getArgument(0))) {
-            user("1", null); assertThrows(ServiceException.class, () -> service.authenticatedLogin(1L));
-            user("1", new Date(0)); assertThrows(ServiceException.class, () -> service.authenticatedLogin(1L));
-            InfoUser u = user("1", new Date(System.currentTimeMillis()+60000)); u.setStatus("1");
-            assertThrows(ServiceException.class, () -> service.authenticatedLogin(1L));
-            u.setStatus("0"); u.setUserType("2");
-            assertThrows(ServiceException.class, () -> service.request(1L));
+            InfoUser u = user("1", new Date(System.currentTimeMillis()+60000));
+            when(users.restoreCancelledUser(eq(1L), any())).thenReturn(1);
+            String key = service.prepareLogin(context()).confirmationKey();
+            var restored = service.confirmLogin(key);
+            assertSame(u, restored.user()); assertEquals(context(), restored.context());
+            assertEquals("0", u.getDelFlag()); assertNull(u.getCancellationDeadlineAt());
+            assertThrows(ServiceException.class, () -> service.confirmLogin(key));
+            verify(users, times(1)).restoreCancelledUser(eq(1L), any());
+        }
+    }
+    @Test void invalidMissingAndExpiredConfirmationCannotRestore() {
+        try(var messages = mockStatic(MessageUtils.class, c -> c.getArgument(0))) {
+            user("1", new Date(System.currentTimeMillis()+60000));
+            for (String key : new String[]{null, "", "abc", "a".repeat(32)})
+                assertThrows(ServiceException.class, () -> service.confirmLogin(key));
+            String key = service.prepareLogin(context()).confirmationKey();
+            String cacheKey = UserCancellationService.CONFIRM_PREFIX + key;
+            var pending = JSON.parseObject(cache.get(cacheKey), UserCancellationService.PendingLogin.class);
+            cache.put(cacheKey, JSON.toJSONString(new UserCancellationService.PendingLogin(context(), pending.cancellationDeadline(), 0, pending.cycle())));
+            assertThrows(ServiceException.class, () -> service.confirmLogin(key));
+            verify(users, never()).restoreCancelledUser(anyLong(), any());
+        }
+    }
+    @Test void staleCancellationCycleChangedPhoneAndDisabledAccountAreRejected() {
+        try(var messages = mockStatic(MessageUtils.class, c -> c.getArgument(0))) {
+            for (String mutation : new String[]{"deadline", "phone", "disabled", "expired", "deleted", "active", "role"}) {
+                InfoUser u = user("1", new Date(System.currentTimeMillis()+60000));
+                String key = service.prepareLogin(context()).confirmationKey();
+                switch(mutation) {
+                    case "deadline" -> u.setCancellationDeadlineAt(new Date(u.getCancellationDeadlineAt().getTime()+86400000));
+                    case "phone" -> u.setPhone("0999999999");
+                    case "disabled" -> u.setStatus("1");
+                    case "expired" -> u.setCancellationDeadlineAt(new Date(0));
+                    case "deleted" -> u.setCancellationDeadlineAt(null);
+                    case "active" -> u.setDelFlag("0");
+                    case "role" -> u.setUserType("2");
+                }
+                assertThrows(ServiceException.class, () -> service.confirmLogin(key), mutation);
+            }
+            verify(users, never()).restoreCancelledUser(anyLong(), any());
+        }
+    }
+    @Test void siblingConfirmationCannotRecoverNewCancellationEvenWithSameDeadline() {
+        try(var messages = mockStatic(MessageUtils.class, c -> c.getArgument(0))) {
+            Date deadline = new Date(System.currentTimeMillis()+60000);
+            InfoUser u = user("1", deadline);
+            String first = service.prepareLogin(context()).confirmationKey();
+            String oldSibling = service.prepareLogin(context()).confirmationKey();
+            when(users.restoreCancelledUser(eq(1L), any())).thenReturn(1);
+            service.confirmLogin(first);
+            // 模拟数据库秒精度导致新一次申请的截止时间与上次完全相同。
+            u.setDelFlag("1"); u.setCancellationDeadlineAt(deadline);
+            assertThrows(ServiceException.class, () -> service.confirmLogin(oldSibling));
+            verify(users, times(1)).restoreCancelledUser(eq(1L), any());
+        }
+    }
+    @Test void newCancellationInvalidatesExistingCycleBeforeAnotherPrompt() {
+        try(var messages = mockStatic(MessageUtils.class, c -> c.getArgument(0))) {
+            Date deadline = new Date(System.currentTimeMillis()+60000);
+            InfoUser u = user("1", deadline);
+            String oldKey = service.prepareLogin(context()).confirmationKey();
+            u.setDelFlag("0");
+            when(users.requestUserCancellation(eq(1L), any())).thenReturn(1);
+            service.request(1L);
+            u.setDelFlag("1"); u.setCancellationDeadlineAt(deadline);
+            service.prepareLogin(context());
+            assertThrows(ServiceException.class, () -> service.confirmLogin(oldKey));
             verify(users, never()).restoreCancelledUser(anyLong(), any());
             verify(users, never()).restoreCancelledUser(anyLong(), any());
         }
         }
     }
     }
-    @Test void updateLosingDeadlineRaceDoesNotRestoreAccount() {
+    @Test void lostCredentialConsumptionRaceCannotRestore() {
         try(var messages = mockStatic(MessageUtils.class, c -> c.getArgument(0))) {
         try(var messages = mockStatic(MessageUtils.class, c -> c.getArgument(0))) {
             user("1", new Date(System.currentTimeMillis()+60000));
             user("1", new Date(System.currentTimeMillis()+60000));
-            assertThrows(ServiceException.class, () -> service.authenticatedLogin(1L));
+            String key = service.prepareLogin(context()).confirmationKey();
+            when(redis.deleteObject(UserCancellationService.CONFIRM_PREFIX+key)).thenReturn(false);
+            assertThrows(ServiceException.class, () -> service.confirmLogin(key));
+            verify(users, never()).restoreCancelledUser(anyLong(), any());
+        }
+    }
+    @Test void updateLosingDeadlineRaceDoesNotReturnRecoveredAccount() {
+        try(var messages = mockStatic(MessageUtils.class, c -> c.getArgument(0))) {
+            InfoUser u = user("1", new Date(System.currentTimeMillis()+60000));
+            String key = service.prepareLogin(context()).confirmationKey();
+            assertThrows(ServiceException.class, () -> service.confirmLogin(key));
+            assertEquals("1", u.getDelFlag());
+        }
+    }
+    @Test void expiredHistoricalDisabledAndNonUserAccountsCannotPrepareConfirmation() {
+        try(var messages = mockStatic(MessageUtils.class, c -> c.getArgument(0))) {
+            user("1", null); assertThrows(ServiceException.class, () -> service.prepareLogin(context()));
+            user("1", new Date(0)); assertThrows(ServiceException.class, () -> service.prepareLogin(context()));
+            InfoUser u = user("1", new Date(System.currentTimeMillis()+60000)); u.setStatus("1");
+            assertThrows(ServiceException.class, () -> service.prepareLogin(context()));
+            u.setStatus("0"); u.setUserType("2");
+            assertThrows(ServiceException.class, () -> service.prepareLogin(context()));
+            assertThrows(ServiceException.class, () -> service.request(1L));
+            assertTrue(cache.isEmpty());
         }
         }
     }
     }
     @Test void exactDeadlineIsNotRecoverable() {
     @Test void exactDeadlineIsNotRecoverable() {
-        Date now = new Date();
-        assertFalse(UserCancellationService.canRecover(user("1", now), now));
+        Date now = new Date(); assertFalse(UserCancellationService.canRecover(user("1", now), now));
     }
     }
     @Test void repeatCancellationCannotExtendDeadline() {
     @Test void repeatCancellationCannotExtendDeadline() {
         try (var messages = mockStatic(MessageUtils.class, c -> c.getArgument(0))) {
         try (var messages = mockStatic(MessageUtils.class, c -> c.getArgument(0))) {
             user("1", new Date(System.currentTimeMillis() + 60000));
             user("1", new Date(System.currentTimeMillis() + 60000));
             assertThrows(ServiceException.class, () -> service.request(1L));
             assertThrows(ServiceException.class, () -> service.request(1L));
             verify(users, never()).requestUserCancellation(anyLong(), any());
             verify(users, never()).requestUserCancellation(anyLong(), any());
-            verifyNoInteractions(redis);
+            verify(redis, never()).deleteKeys(anyString());
         }
         }
     }
     }
-    @Test void activeLoginDoesNotChangeAccountLifecycle() {
-        InfoUser active = user("0", null);
-        assertSame(active, service.authenticatedLogin(1L));
-        verify(users, never()).restoreCancelledUser(anyLong(), any());
-    }
-
 
 
     private com.ruoyi.common.core.domain.entity.SysDictData days(String value) {
     private com.ruoyi.common.core.domain.entity.SysDictData days(String value) {
         var row = new com.ruoyi.common.core.domain.entity.SysDictData();
         var row = new com.ruoyi.common.core.domain.entity.SysDictData();
@@ -113,7 +222,7 @@ class UserCancellationServiceTest {
         var deadline = new Date(System.currentTimeMillis() + 60000);
         var deadline = new Date(System.currentTimeMillis() + 60000);
         var u = user("1", deadline); when(users.restoreCancelledUser(eq(1L), any())).thenReturn(1);
         var u = user("1", deadline); when(users.restoreCancelledUser(eq(1L), any())).thenReturn(1);
         try (var dict = mockStatic(com.ruoyi.common.utils.DictUtils.class)) {
         try (var dict = mockStatic(com.ruoyi.common.utils.DictUtils.class)) {
-            assertSame(u, service.authenticatedLogin(1L));
+            assertSame(u, service.confirmLogin(service.prepareLogin(context()).confirmationKey()).user());
             dict.verifyNoInteractions();
             dict.verifyNoInteractions();
         }
         }
     }
     }

+ 10 - 0
specs/003-merchant-billing/plan.md

@@ -232,6 +232,16 @@ git commit -m "feat(billing): 商家端账单功能 — 列表查询、汇总、
 
 
 分管账号新增独立日/周/月查询分支;UserBillingMapper XML 共用主账号和门店集合条件,日期绑定参数、空集合显式 AND 1=0。保留旧响应结构和其他角色路径。验证包括授权多店/单店、越权、空授权、撤权、取消范围和 XML BoundSql。
 分管账号新增独立日/周/月查询分支;UserBillingMapper XML 共用主账号和门店集合条件,日期绑定参数、空集合显式 AND 1=0。保留旧响应结构和其他角色路径。验证包括授权多店/单店、越权、空授权、撤权、取消范围和 XML BoundSql。
 
 
+### 整数运费入口与金额汇总(2026-11-04)
+
+1. 共用 OrderMoneyValidator,在用户/商家创建写入前拒绝小数、负数和非有限运费,保留原请求字段及 null 兼容。新增错误信息提供各现有语言。
+2. storeBillingSummary 将数据库汇总值规范为金额精度后用 BigDecimal 运算,防止 3393.6000000000004 等尾差;totalAmount仍为账单合计,netIncome不再从净入账金额重复扣抽成。
+3. 回归非法/整数运费、历史分角不重写、整数汇总,以及既有三类报价、普通下单和分管账单权限。无数据库迁移、不改 App、不自动提交。
+
+4. 删除旧addorder方法、专用积分/销售统计/优惠核销辅助方法和因此无用的依赖;保留被其他入口引用的推送与返还积分方法,更新入口说明。
+5. 按用户最终要求撤回orderAmount、关联订单查询及全部前端改动;列表维持原响应,前端由用户安排将amount直接显示为收入。
+6. 保留后端汇总不重复扣抽成、浮点精度及旧路由删除回归;运行受影响测试并打包后端,不修改或发布前端。
+
 ## 2026-11-05 骑手收入账单实施计划(按最终范围更新)
 ## 2026-11-05 骑手收入账单实施计划(按最终范围更新)
 
 
 1. `ruoyi-system` 的 RiderIncomeMapper、XML、RiderIncomeSummary、RiderIncomeItem 共用本人有效订单收入条件。按明确起止时间过滤;汇总不接收分类,明细按分类分页,详情按本人和 billId 查询。
 1. `ruoyi-system` 的 RiderIncomeMapper、XML、RiderIncomeSummary、RiderIncomeItem 共用本人有效订单收入条件。按明确起止时间过滤;汇总不接收分类,明细按分类分页,详情按本人和 billId 查询。

+ 15 - 0
specs/003-merchant-billing/spec.md

@@ -172,6 +172,21 @@ mdId 过滤采用 `(#{mdId} IS NULL OR md_id = #{mdId})` 模式,mdId 不传时
 
 
 `getzddaylist/getzdmeeklist/getzdmatlist` 对 userType=5 解析主账号及实时授权门店;不传 mdId 查询全部授权门店,指定未授权门店拒绝。明细、金额/商品汇总、取消订单列表及数量使用相同范围,空授权查询零行。原普通商家/夜市/摊主/骑手路径不变,无数据库迁移。
 `getzddaylist/getzdmeeklist/getzdmatlist` 对 userType=5 解析主账号及实时授权门店;不传 mdId 查询全部授权门店,指定未授权门店拒绝。明细、金额/商品汇总、取消订单列表及数量使用相同范围,空授权查询零行。原普通商家/夜市/摊主/骑手路径不变,无数据库迁移。
 
 
+### 2026-11-04 整数新台币与汇总显示精度
+
+新订单及运费以整数新台币元计价。闪送、多店及新版外卖报价沿用已有整数计算;用户、商家创建入口对传入运费增加非负、有限、整数校验。保留原字段类型和未传运费兼容,不静默取整改变应付。账单汇总使用十进制运算消除二进制尾差,整数以整数数字返回。用户明确历史数据不处理,历史分角账单保留,不改钱包、不补差、不迁移。
+
+接入变化:`POST /system/userOrder/createOrder`和 `POST /system/orderShOprate/createOrder` 保留原入参结构;已有运费字段如传入必须是非负整数(40或40.0均可,40.5拒绝),未传时维持原处理。订单总额字段已有Integer/Long类型。`storeBillingSummary` 三个金额字段仍返回JSON数字,消除浮点尾差、整数不附带小数部分;历史真实分角仍保留。本轮不修改App及商家PC代码,前端由用户安排人员调整。
+
+
+### 2026-11-04 账单收入展示口径(以用户最终确认为准)
+
+- 不返回原订单金额,不新增 `orderAmount`,不关联订单表;列表响应结构保持不变。
+- `amount` 保持实际账单入账金额,`divvy` 保持历史抽成记录。前端由用户安排人员将“订单金额”改为“收入”,直接使用 `amount`,不再减 `divvy`;本轮撤回所有前端代码及语言文件改动。
+- `storeBillingSummary.totalAmount` 保持账单金额合计,`totalDivvy` 保留抽成合计,`netIncome` 等于已入账金额合计,不再次减抽成;使用十进制消除浮点尾差。
+- 只读核对订单991793757396125:原订单135、账单114.75、历史抽成20.25;991793757103637及991793756962244:原订单95、账单80.75、抽成14.25。小数来自账单被错当订单展示。历史数据库、钱包均不回写,无SQL迁移。
+- 旧 `POST /system/order/addorder` 按用户要求彻底删除,不保留转发或兼容创建逻辑;用户及商家现有 createOrder 保持。
+
 ## 2026-11-05 骑手收入账单改版(最终范围:前端日期,后端范围查询)
 ## 2026-11-05 骑手收入账单改版(最终范围:前端日期,后端范围查询)
 
 
 ### 用户故事与验收
 ### 用户故事与验收

+ 23 - 0
specs/003-merchant-billing/tasks.md

@@ -141,6 +141,29 @@ description: "Task list for 商家前端账单功能 feature implementation"
 
 
 T021验证:022 T015所列JDK21定向回归144项全部通过,模块package成功,差异检查通过。分管旧账单控制器12项、SQL范围测试1项,以及既有商家/骑手账单回归均包含在内。无数据库变更,未部署;提交与推送状态以 Git 记录为准。
 T021验证:022 T015所列JDK21定向回归144项全部通过,模块package成功,差异检查通过。分管旧账单控制器12项、SQL范围测试1项,以及既有商家/骑手账单回归均包含在内。无数据库变更,未部署;提交与推送状态以 Git 记录为准。
 
 
+## 2026-11-04 新订单整数金额与汇总精度
+
+- [x] T022 补齐用户、商家创建入口整数运费校验,并更新金额单位注释和国际化提示。
+- [x] T023 汇总改为十进制计算,消除浮点尾差,保留历史分角记录。
+- [x] T024 运行定向回归及 JDK21 打包,更新验证记录;不修改历史数据或钱包。
+
+已验证:修复前定向测试30项中9项失败,复现入口接收非法运费和汇总浮点尾差。修复后使用JDK21执行:
+
+`mvn -q -pl ruoyi-admin -am -Dtest=OrderWholeYuanFreightTest,StoreBillingSubaccountAccessTest,LegacyBillingSubaccountScopeTest,OrderBillingNoDeductionTest,UserBillingFlashDeliveryTest,FlashDeliveryPricingCalculatorTest,FoodDeliveryPromotionQuoteServiceTest,MultiStoreCheckoutQuoteServiceTest,OrderPromotionHelperFreeDeliveryTest,LinePayOfflineMerchantAccessTest,PosOrderShOprateControllerTest -Dsurefire.failIfNoSpecifiedTests=false package`
+
+退出码0,11个测试类共120项全部通过,失败/错误/跳过均0,模块打包成功;`git diff --check`通过。日志为`ruoyi-admin/target/whole-yuan-red.log`与`whole-yuan-green.log`。未执行全量回归、客户端联调或部署;未修改历史数据库和钱包,无SQL迁移。尚未提交或推送。
+
+
+- [x] T025 删除addorder旧接口及仅供它使用的代码,同步接口边界文档并验证。
+- [x] T026 按用户最终要求不新增orderAmount,撤回关联订单查询与全部前端改动;只保留后端汇总重复扣抽成修复,不回写历史账单。
+- [x] T027 按最终范围完成汇总金额语义、原权限及旧接口删除回归,打包后端并记录验证结果。
+
+
+前一版范围验证(2026-11-04,orderAmount与前端改动已按用户最终要求撤回,以下123项记录不作为最终范围验证):删除旧接口前,OrderWholeYuanFreightTest 10项中1项按预期失败;账单展示修复前,StoreBillingSubaccountAccessTest 23项中2项失败,复现缺失原订单金额及重复扣抽成。最终执行T024同一组11类JDK21测试,123项全部通过(失败/错误/跳过均0),Maven package成功。商家PC执行 `npm run build:test` 成功。两个仓库 `git diff --check`通过。只读核对测试数据库订单与账单,无数据库或钱包修改,无SQL迁移。未执行浏览器/App联调、线上部署及完整回归;尚未提交或推送。
+
+
+最终范围验证(2026-11-04):撤回orderAmount及所有前端改动后,执行JDK21 `mvn -q -pl ruoyi-admin -am -Dtest=StoreBillingSubaccountAccessTest,OrderWholeYuanFreightTest -Dsurefire.failIfNoSpecifiedTests=false package`,31项全部通过,失败/错误/跳过均0,后端打包成功。日志:`ruoyi-admin/target/billing-final-scope.log`。商家PC工作区干净,UserBilling实体无变更,后端差异检查通过;保留汇总重复扣抽成/精度修复、整数运费校验及旧addorder删除。未部署、未提交、未改历史数据。
+
 ## 2026-11-05 骑手收入账单改版
 ## 2026-11-05 骑手收入账单改版
 
 
 - [x] T028 写入已确认规格与实现计划,确认仅后端和接入文档,不创建分支。
 - [x] T028 写入已确认规格与实现计划,确认仅后端和接入文档,不创建分支。

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

@@ -20,7 +20,7 @@
 - **`PosOrderShOprateController`** — 商家操作:出餐 `chuCan`(推用户+骑手)、商家取消
 - **`PosOrderShOprateController`** — 商家操作:出餐 `chuCan`(推用户+骑手)、商家取消
 - **`PosOrderQsOprateController`** — 骑手操作:接单/取餐/送达,按 `deliveryStatus` 1/2/3(推用户,接单时推商家)
 - **`PosOrderQsOprateController`** — 骑手操作:接单/取餐/送达,按 `deliveryStatus` 1/2/3(推用户,接单时推商家)
 - **`UserOrderController`** — 用户操作:下单(推商家"新订单")、用户取消
 - **`UserOrderController`** — 用户操作:下单(推商家"新订单")、用户取消
-- **`PosOrderController`** — 仅 `/addorder`(下单,含货到付款 `sendHdfkMessage`)、`/setorderuzt`(平台改状态)及各类 list 查询在用;订单状态流转操作已迁出至上列三个 Controller
+- **`PosOrderController`** — 旧 `/addorder` 已于2026-11-04删除;`/setorderuzt`(平台改状态)及各类 list 查询在用;订单状态流转操作已迁出至上列三个 Controller
 
 
 ## 推送通道现状
 ## 推送通道现状
 
 

+ 1 - 2
specs/031-pay-method-config/contracts/api.md

@@ -18,12 +18,11 @@ List<PayMethodOption> listAvailable(GateScope scope, Long payeeUserId)
 enum GateScope { MERCHANT, RIDER_FLASH }
 enum GateScope { MERCHANT, RIDER_FLASH }
 ```
 ```
 
 
-调用点(**在用仅此三处**;`/addorder` 一处随入口废弃留存,清理死代码时须一并移除并更新本表;禁止新增重复实现):
+调用点(**在用仅此三处**;旧 `/addorder` 及其闸门调用已于2026-11-04删除;禁止新增重复实现):
 
 
 | 调用方 | 位置 | 参数 |
 | 调用方 | 位置 | 参数 |
 |---|---|---|
 |---|---|---|
 | 用户餐饮下单 | `UserOrderController.createOrder`(setPayType 前后) | payType=input.paymentMethod, MERCHANT, 商家主账号 userId(US7 起经 `assertUsableForOrder`,见 0.1) |
 | 用户餐饮下单 | `UserOrderController.createOrder`(setPayType 前后) | payType=input.paymentMethod, MERCHANT, 商家主账号 userId(US7 起经 `assertUsableForOrder`,见 0.1) |
-| ~~商家建单~~ 旧代客外送下单(**已废弃 2026-09-29**) | `PosOrderController /addorder`(方法已标 `@Deprecated`) | payType 固定 "1"(COD), MERCHANT —— 入口被 `/system/orderShOprate/createOrder` 取代,调用点仅随废弃代码留存,勿作为新改动基准 |
 | 闪送下单 | `FlashDeliveryApplicationService.create`(normalizePayType 处) | payType, RIDER_FLASH, 无收款方(抢单前,只校验平台开关) |
 | 闪送下单 | `FlashDeliveryApplicationService.create`(normalizePayType 处) | payType, RIDER_FLASH, 无收款方(抢单前,只校验平台开关) |
 | 027 bankInfo | `ChantingStoreController.bankInfo` | OFFLINE_TRANSFER, MERCHANT, 商家 userId |
 | 027 bankInfo | `ChantingStoreController.bankInfo` | OFFLINE_TRANSFER, MERCHANT, 商家 userId |
 
 

+ 1 - 1
specs/032-self-hosted-map-integration/spec.md

@@ -153,7 +153,7 @@
 ## Assumptions
 ## Assumptions
 
 
 - 本 spec 覆盖 foodie 后端与运营配置;App 前端(uni-app,不在本仓库)的选址页、费用展示改造由前端团队按本 spec 接口对接。
 - 本 spec 覆盖 foodie 后端与运营配置;App 前端(uni-app,不在本仓库)的选址页、费用展示改造由前端团队按本 spec 接口对接。
-- `PosOrderController` 的通用管理接口 `POST /system/order`(`add(...)`)和 `PUT /system/order`(`edit(...)`)已废弃,不纳入本特性的订单创建、修改入口;它们与仍在使用的 `/system/order/addorder` 不同。
+- `PosOrderController` 的通用管理接口 `POST /system/order`(`add(...)`)和 `PUT /system/order`(`edit(...)`)已废弃,不纳入本特性的订单创建、修改入口;旧 `/system/order/addorder` 也已于2026-11-04删除。
 - 地图服务(Foodie_HCM_Map)部署在与 foodie 服务器同内网或经私网打通;其多站点路线扩展、读接口防护属地图仓库工作,作为跨仓库依赖跟踪。
 - 地图服务(Foodie_HCM_Map)部署在与 foodie 服务器同内网或经私网打通;其多站点路线扩展、读接口防护属地图仓库工作,作为跨仓库依赖跟踪。
 - 地图仅提供 car 档道路距离,不提供机车档与实时交通;距离用于计价可信,时长仅作折算参考(见 FR-008)。
 - 地图仅提供 car 档道路距离,不提供机车档与实时交通;距离用于计价可信,时长仅作折算参考(见 FR-008)。
 - 现有运费规则载体(pos_freight、flash_delivery_pricing)与管理端 CRUD 继续沿用,本特性不改动计价规则本身。
 - 现有运费规则载体(pos_freight、flash_delivery_pricing)与管理端 CRUD 继续沿用,本特性不改动计价规则本身。

+ 1 - 1
specs/034-stall-qualification/quickstart.md

@@ -19,7 +19,7 @@ SQL、真实数据库、服务重启及上述部署操作本轮均未执行。
 - 草稿可不完整;提交时负责人、证件及摊位照片、银行账户四项必须齐全,食品业者登录资料选填。每类图片最多 5 张、每张不超过 5MB,仅 JPEG/PNG;上传未完成时不能提交。
 - 草稿可不完整;提交时负责人、证件及摊位照片、银行账户四项必须齐全,食品业者登录资料选填。每类图片最多 5 张、每张不超过 5MB,仅 JPEG/PNG;上传未完成时不能提交。
 - 待审核不能编辑/重复提交。平台审核通过或填写原因驳回;驳回后修订形成新申请版本,旧材料和审核历史不覆盖。保存使用认证主体 version,审核使用申请 version;冲突需刷新,不能自动重试覆盖别人。
 - 待审核不能编辑/重复提交。平台审核通过或填写原因驳回;驳回后修订形成新申请版本,旧材料和审核历史不覆盖。保存使用认证主体 version,审核使用申请 version;冲突需刷新,不能自动重试覆盖别人。
 - 审核通过只开放营业资格,不自动开店;摊主仍通过原营业设置决定是否营业。已有有效材料重新申请时,原有效版本继续有效,直到新版本通过替换。
 - 审核通过只开放营业资格,不自动开店;摊主仍通过原营业设置决定是否营业。已有有效材料重新申请时,原有效版本继续有效,直到新版本通过替换。
-- 无记录、草稿、首次待审核或首次驳回均不得开店、新下单;闭店时可以编辑名称/地址来补资料。用户下单、商家创建订单以及仍可调用的旧 `/addorder` 均校验,不能只信列表或前端。旧订单出餐、配送、支付后续处理不调用此新门禁。
+- 无记录、草稿、首次待审核或首次驳回均不得开店、新下单;闭店时可以编辑名称/地址来补资料。用户下单、商家创建订单均校验(旧 `/addorder` 已于2026-11-04删除),不能只信列表或前端。旧订单出餐、配送、支付后续处理不调用此新门禁。
 - 用户端列表标为关闭;夜市摊位详情返回关闭状态;扫码摊位列表隐藏未通过摊位。普通商家的旧审核流程不替换。
 - 用户端列表标为关闭;夜市摊位详情返回关闭状态;扫码摊位列表隐藏未通过摊位。普通商家的旧审核流程不替换。
 
 
 ## 本轮自动化验证
 ## 本轮自动化验证

+ 99 - 19
specs/040-user-cancellation-grace/frontend-app-integration.md

@@ -1,42 +1,122 @@
-# 用户 App:注销与七天翻悔期接入
+# 用户 App:注销冷静期与登录确认接入
 
 
-仅普通用户账号(`userType=0`)适用;商家、骑手不接入此流程。本期未修改 App。
+更新:2026-11-05。仅普通用户账号适用,商家和骑手沿用原登录流程。
 
 
-## 申请注销
+## 注销确认与申请
 
 
-沿用 `GET /infouser/user/deleuser`,请求头 `token` 必填,无请求体。账号必须有有效 Redis 会话,仍沿用现有未完成订单拦截。
+注销前的确认弹窗由前端控制,按钮为“确认注销”“取消注销”。用户取消时不调用注销接口。
 
 
-成功响应:
+确认注销调用 `GET /infouser/user/deleuser`,携带 `token` 请求头,无请求体。存在未完成订单等情况时沿用接口错误提示。
+
+成功响应示例:
 
 
 ```json
 ```json
 {
 {
   "code": 200,
   "code": 200,
   "msg": "操作成功",
   "msg": "操作成功",
   "data": {
   "data": {
-    "cancellationDeadlineAt": "2026-11-06 12:00:00",
+    "cancellationDeadlineAt": "2026-11-12 15:00:00",
     "graceDays": 7
     "graceDays": 7
   }
   }
 }
 }
 ```
 ```
 
 
-`cancellationDeadlineAt` 为服务端计算的北京时间(UTC+8),示例日期仅为演示。`graceDays` 默认 7,页面以接口返回值展示,不在客户端另算截止日期。
+`graceDays` 是本次申请的冷静期总天数,默认7;`cancellationDeadlineAt` 是准确截止时间,格式 `yyyy-MM-dd HH:mm:ss`,时区 UTC+8。注销成功后以接口实际返回值显示期限,并立即清除本地登录态、退出账号。
+
+## 冷静期内尝试登录
+
+手机号登录、Apple/Google/LINE 已绑定登录、验证手机号绑定和设备确认登录仍使用原请求参数。身份验证通过后,如果账号处于冷静期,返回:
+
+```json
+{
+  "code": 200,
+  "msg": "操作成功",
+  "data": {
+    "status": "cancellationConfirm",
+    "remainingDays": 3,
+    "cancellationDeadlineAt": "2026-11-08 15:00:00",
+    "confirmationKey": "0123456789abcdef0123456789abcdef",
+    "expiresInSeconds": 300
+  }
+}
+```
+
+| 字段 | 说明 |
+|---|---|
+| status | `cancellationConfirm` 表示等待用户确认恢复,尚未登录 |
+| remainingDays | 剩余冷静期天数,由后端向上取整;不足24小时显示1天 |
+| cancellationDeadlineAt | 本次注销的截止时间,UTC+8 |
+| confirmationKey | 确认恢复所需的临时凭证,仅本次流程使用 |
+| expiresInSeconds | 临时凭证剩余有效秒数,最长300秒;临近注销截止时可能更短 |
+
+前端先判断 `data.status`。进入此分支时没有登录 token,不能仅凭 `code=200` 当作登录成功,也不能保存账号登录态。
+
+弹窗文案:
+
+> 该账号正在注销中,剩余X天。登录将撤销注销申请并恢复正常使用,是否继续?
+
+X 直接使用 `remainingDays`,不要使用申请注销时的 `graceDays`,也不要按手机本地日期重算。
+
+按钮行为:
+
+- **继续登录,恢复账号**:调用下方确认接口,成功后进入原登录成功流程。
+- **继续注销,不登录**:关闭弹窗,丢弃临时凭证,结束本次登录,不调用确认接口。关闭或返回退出弹窗同样不恢复账号。
+
+## 确认恢复并登录
 
 
-注销成功后立即清除本地登录态,退出账号。文案说明:在截止时间前重新登录,会撤销注销并恢复原账号;到期后无法恢复原账号。不能把注销成功展示成资料已物理删除。
+`POST /infouser/user/confirmCancellationLogin`
 
 
-## 翻悔与登录
+无需登录 token,请求头 `Content-Type: application/json`,请求体:
 
 
-无需新增恢复接口或传恢复字段。正常调用原有手机号登录、Apple/Google/LINE 登录、绑定手机号或设备确认接口,身份校验成功后服务端自动恢复,返回原 `userId` 和新 token。App 按原登录成功流程处理即可。
+```json
+{
+  "confirmationKey": "0123456789abcdef0123456789abcdef"
+}
+```
+
+只提交上一步返回的确认凭证,不需要重发短信验证码或第三方授权 code,也不传用户ID、手机号或恢复标志。
+
+成功响应保持原登录成功结构:
+
+```json
+{
+  "code": 200,
+  "msg": "登录成功",
+  "data": {
+    "userId": 1001,
+    "userType": "0",
+    "delFlag": "0"
+  },
+  "token": "新的登录token"
+}
+```
+
+示例只列出部分用户字段。成功后恢复原账号,保存新 token 并按现有流程初始化登录。注销前的旧 token 仍然无效。
+
+按钮提交期间禁用重复点击。确认凭证只能使用一次;超时、账号不可恢复或凭证失效时展示接口 `msg` 并重新发起登录,不能反复提交旧凭证。网络超时导致结果不确定时也重新登录获取当前账号状态。
+
+## LINE 网页回调
+
+通过 `/auth/line/callback` 回到 App 的登录方式,冷静期回跳参数为:
+
+```text
+?cancellationConfirm=1
+&confirmationKey=0123456789abcdef0123456789abcdef
+&remainingDays=3
+&cancellationDeadlineAt=2026-11-08%2015%3A00%3A00
+&expiresInSeconds=300
+```
 
 
-- 必须严格早于截止时间,且账号未被平台停用;到达截止时间即不能恢复。
-- 登录失败、打开页面、刷新 token 和使用原 token 均不会恢复。
-- 注销时原用户会话被撤销,之后使用返回 HTTP 401,业务 `code=401`。恢复后原 token 仍然无效。
-- 历史删除账号没有翻悔截止时间,不能按此流程恢复。
-- 到期账号保留历史业务资料。手机号重新注册沿用现有规则,可能创建新账号;已绑定第三方身份不自动解绑,不能借此恢复过期账号。
+上例换行仅为阅读,实际为同一个URL的查询参数。解析URL编码后显示同一确认弹窗;此分支没有 `token`,确认时调用同一个 `confirmCancellationLogin` 接口并处理JSON登录响应。
 
 
-## 后端部署与配置
+正常登录的 `token` 回跳、未绑定的 `needPhone` 和设备确认 `deviceConfirm` 保持原处理方式。
 
 
-先由开发者手动执行 `updatesql/sql.md` 中 2026-10-30 的 040 迁移,再部署代码。本轮未直接执行数据库迁移。
+## 联调检查
 
 
-后台字典 `user_cancellation_grace_days` 管理天数,默认7;有效整数范围1~365,字典缺失、停用、非法值或读取异常回退7。后台保存刷新字典缓存后影响后续注销,已有截止时间不变,无须重启。App 请求参数不变,文案使用接口返回的 graceDays,不写死7天。
+- 注销前取消不发请求;注销成功读取实际天数和截止时间并退出账号。
+- 所有用户登录方式遇到 `cancellationConfirm` 均先弹窗,不进入登录态。
+- 选择继续注销或关闭弹窗,账号仍处于注销状态;再次登录仍需身份验证。
+- 确认恢复成功后保持原userId,使用新token登录;重复点击、凭证超时及注销期已到均按错误提示处理。
+- 无冷静期的正常用户登录体验保持不变。
 
 
-账号申请注销时已经设置 `del_flag=1`,到期仅关闭恢复资格,因此无需定时任务再次设置删除状态。平台直接删除会清空翻悔期;平台停用的账号不允许通过登录恢复。
+本次提供后端接口及接入约定,App弹窗和按钮由前端实现,文案按现有客户端国际化机制处理。

+ 8 - 0
specs/040-user-cancellation-grace/plan.md

@@ -16,3 +16,11 @@
 1. 为动态修改、缺失/停用/非法值/异常默认7天及恢复不重算编写定向测试。
 1. 为动态修改、缺失/停用/非法值/异常默认7天及恢复不重算编写定向测试。
 2. UserCancellationService 复用 DictUtils 字典缓存,在申请时读取,不缓存到服务字段;删除 @Value 配置。
 2. UserCancellationService 复用 DictUtils 字典缓存,在申请时读取,不缓存到服务字段;删除 @Value 配置。
 3. 追加幂等字典初始化 SQL,默认7天,仅写入 updatesql/sql.md。更新接入文档,执行服务测试及模块构建。
 3. 追加幂等字典初始化 SQL,默认7天,仅写入 updatesql/sql.md。更新接入文档,执行服务测试及模块构建。
+
+## 2026-11-05 二次确认恢复实施计划
+
+1. 先复现冷静期认证直接签token;新增经过身份验证的短期确认上下文,Redis保存5分钟一次性凭证及注销周期快照,账号行锁下创建随机周期标识,恢复或重新注销时失效旧标识。
+2. UserCancellationService统一生成提示与消费确认:数据库行锁检查角色、停用、截止时间,确认后原子恢复;剩余天数向上取整。
+3. 在所有用户认证入口的账号恢复/绑定/设备写入之前返回确认分支;新确认接口恢复原认证流程,重新校验身份绑定、手机号和设备,签发失败回滚。
+4. LINE网页回调返回确认参数;商家/骑手、正常用户登录不变。注销响应维持原契约。
+5. 更新Service/Controller/设备及LINE回调测试、六份语言提示和App文档,运行相关认证定向回归及JDK21打包,检查差异与安全边界。

+ 13 - 2
specs/040-user-cancellation-grace/spec.md

@@ -3,14 +3,14 @@
 ## 用户确认的规则
 ## 用户确认的规则
 
 
 - 仅用户端普通用户(`user_type=0`):注销时设置 `del_flag=1`,记录截止时间,默认申请时刻起 7 天。
 - 仅用户端普通用户(`user_type=0`):注销时设置 `del_flag=1`,记录截止时间,默认申请时刻起 7 天。
-- 只有截止时间严格大于服务端当前时间,且该账号未被平台停用,身份认证成功后才恢复 `del_flag=0` 并清空截止时间。历史删除账号无截止时间,不能恢复。
+- 只有截止时间严格大于服务端当前时间,且该账号未被平台停用,身份认证成功且用户明确确认恢复后才恢复 `del_flag=0` 并清空截止时间。历史删除账号无截止时间,不能恢复。
 - 期限到达或超过后保持删除状态;刷新 token、旧会话和静默请求不能恢复账号。
 - 期限到达或超过后保持删除状态;刷新 token、旧会话和静默请求不能恢复账号。
 - 其他商家和骑手账号不进入此流程,保留原有行为。
 - 其他商家和骑手账号不进入此流程,保留原有行为。
 
 
 ## 必须覆盖的边界
 ## 必须覆盖的边界
 
 
 - 注销沿用未完成订单检查;注销须校验有效会话并使现有用户端会话失效。用户确认旧 token 已淘汰,所有 `@Auth` 接口一律要求有效 Redis 会话;不再兼容旧 JWT 放行。
 - 注销沿用未完成订单检查;注销须校验有效会话并使现有用户端会话失效。用户确认旧 token 已淘汰,所有 `@Auth` 接口一律要求有效 Redis 会话;不再兼容旧 JWT 放行。
-- 手机号登录、已绑定第三方登录、第三方绑定手机号和设备信任确认各入口需在身份验证成功后统一调用恢复;恢复条件通过数据库原子更新再次验证,避免截止时间竞争、禁用账号被恢复和历史删除账号误恢复。
+- 手机号登录、已绑定第三方登录、第三方绑定手机号和设备信任确认各入口需在身份验证成功后统一返回确认提示,仅确认接口可恢复;恢复条件通过数据库原子更新再次验证,避免截止时间竞争、禁用账号被恢复和历史删除账号误恢复。
 - 手机号和第三方身份的查询仅将有未来截止时间的用户纳入候选,不能影响同手机号商家、骑手;多条候选须安全拒绝,避免误恢复或误创建账号。
 - 手机号和第三方身份的查询仅将有未来截止时间的用户纳入候选,不能影响同手机号商家、骑手;多条候选须安全拒绝,避免误恢复或误创建账号。
 - 删除状态与截止时间同时写入同一事务;原会话撤销和新会话签发不得使注销并发登录时绕过注销状态。
 - 删除状态与截止时间同时写入同一事务;原会话撤销和新会话签发不得使注销并发登录时绕过注销状态。
 - 现有第三方绑定唯一索引保留;过期后的旧绑定如何解除和重新注册是独立问题,本期不能擅自删除身份绑定或历史业务资料。
 - 现有第三方绑定唯一索引保留;过期后的旧绑定如何解除和重新注册是独立问题,本期不能擅自删除身份绑定或历史业务资料。
@@ -28,3 +28,14 @@
 ## 2026-10-30 字典管理增量
 ## 2026-10-30 字典管理增量
 
 
 管理员通过现有字典管理修改天数,保存刷新字典缓存后对下一次注销申请生效,无须重启。配置只决定新申请的截止时间;已有申请的截止时间不重算,恢复登录不读取新配置。App 继续使用接口返回的 graceDays 与 cancellationDeadlineAt,无请求参数变更。
 管理员通过现有字典管理修改天数,保存刷新字典缓存后对下一次注销申请生效,无须重启。配置只决定新申请的截止时间;已有申请的截止时间不重算,恢复登录不读取新配置。App 继续使用接口返回的 graceDays 与 cancellationDeadlineAt,无请求参数变更。
+
+## 2026-11-05 登录前确认恢复(当前约定)
+
+- 本节替代身份验证成功即自动恢复的行为。注销前弹窗由前端控制;注销接口仍返回 graceDays 和 cancellationDeadlineAt,保留字典及默认7天规则。
+- 所有普通用户认证入口验证身份后,若处于冷静期,只返回 status=cancellationConfirm、remainingDays、cancellationDeadlineAt、confirmationKey、expiresInSeconds,不恢复账号、不签发登录token、不记录设备信任或写入第三方绑定/推送资料。
+- 剩余天数按服务端距截止时间的剩余毫秒除以24小时向上取整,未到期至少1天。等于或超过截止时间不能恢复。
+- 用户明确点击“继续登录,恢复账号”后,POST /infouser/user/confirmCancellationLogin,仅提交 confirmationKey。凭证有效期最长5分钟、一次性使用,绑定已验证账号/身份/设备上下文、本次注销截止时间和服务端随机周期标识,不接收客户端指定的userId或恢复标志。
+- 确认时重新校验账号、截止时间、手机号和第三方绑定/设备信任;拒绝过期、重放、停用、平台删除及旧注销周期的凭证。恢复和会话签发在同一数据库事务,失败回滚。重新注销或恢复会使旧周期凭证全部失效,防止数据库秒精度导致截止时间碰撞。
+- “继续注销,不登录”不调用确认接口,前端丢弃凭证并结束登录,保留注销状态;再次登录重新验证身份。
+- 手机号、Apple/Google/LINE、短信绑号、设备确认及LINE网页回调均覆盖;网页回调返回确认参数而非token,App再调同一确认接口。
+- 无新增数据库结构;本轮仅后端、六份国际化提示与前端接入文档,不创建分支,不自动提交/推送。

+ 13 - 0
specs/040-user-cancellation-grace/tasks.md

@@ -46,3 +46,16 @@ mvn -q -pl ruoyi-admin -am '-Dtest=UserCancellationServiceTest,UserCancellationM
 字典增量验证:JDK21 执行 `mvn -q -pl ruoyi-admin -am -Dtest=UserCancellationServiceTest,UserCancellationMapperTest,InfoUserControllerTest -Dsurefire.failIfNoSpecifiedTests=false package`,94项测试通过,模块打包成功,git diff --check 通过。新测试先复现字典设为14仍返回7,再随实现通过。日志:ruoyi-admin/target/040-dict-package.log。
 字典增量验证:JDK21 执行 `mvn -q -pl ruoyi-admin -am -Dtest=UserCancellationServiceTest,UserCancellationMapperTest,InfoUserControllerTest -Dsurefire.failIfNoSpecifiedTests=false package`,94项测试通过,模块打包成功,git diff --check 通过。新测试先复现字典设为14仍返回7,再随实现通过。日志:ruoyi-admin/target/040-dict-package.log。
 
 
 本次新增字典 INSERT 尚未执行;此前测试库关联表执行确认不包括本次字典 SQL。未执行真实数据库/Redis及App联调,本轮字典调整单独提交,发票打印等其他改动不纳入本次提交。
 本次新增字典 INSERT 尚未执行;此前测试库关联表执行确认不包括本次字典 SQL。未执行真实数据库/Redis及App联调,本轮字典调整单独提交,发票打印等其他改动不纳入本次提交。
+
+## 2026-11-05 登录前确认恢复
+
+状态:已验证(本次改动独立源码副本)。
+- [x] T012 先返回冷静期确认信息,统一剩余天数与一次性短期凭证;确认时重新检查注销周期并原子恢复。
+- [x] T013 覆盖手机号、已绑定OAuth、短信绑号、设备确认与LINE回调;确认之前不签token、不写绑定/信任。
+- [x] T014 补齐重放、到期、停用、身份变更、登录失败回滚及原渠道回归,更新国际化和前端文档并构建验证。
+
+本轮验证(2026-11-05):原登录直接签发token的4个OAuth渠道断言先失败;复审发现数据库秒精度下旧兄弟凭证可能跨注销周期,新增用例先复现,再加入Redis周期标识解决。H2配合真实DataSourceTransactionManager和Controller事务代理验证恢复提交及签会话失败回滚。
+
+执行 JDK21 `mvn -q -pl ruoyi-admin -am -Dtest=UserCancellationServiceTest,CancellationLoginConfirmationTest,UserCancellationMapperTest,DeviceBindingSwitchTest,DeviceTrustServiceTest,DeviceTrustFlowTest,InfoUserControllerTest,LineCallback*Test,AuthAspectTest,MerchantSessionAuthAnnotationTest -Dsurefire.failIfNoSpecifiedTests=false package`,独立副本共171项测试通过,失败/错误/跳过均0,package退出码0。副本为当前HEAD加本次注销相关代码、测试与语言key,与工作区交付文件逐字节核对一致;JSON示例及git diff --check通过,独立复审无剩余重要问题。
+
+工作区首次回归另有MerchantSessionAuthAnnotationTest失败:其他任务删除了PosOrderShOprateController.orderDetail,非本次改动;保留原工作区内容,独立副本未纳入该删除。真实MySQL/Redis并发集成、App与第三方实机联调、全量回归及部署未执行。无新SQL、无分支、未提交或推送。日志:ruoyi-admin/target/cancellation-confirm-red.log、cancellation-confirm-cycle-red.log、cancellation-confirm-green.log(含上述无关失败)、cancellation-confirm-isolated.log(最终通过)。