Просмотр исходного кода

feat: 闪送订单编号改为98+毫秒时间戳+2位序列(17位纯数字)

- generateOrderNo():98 + 13位毫秒时间戳 + 2位同毫秒原子递增序列,
  与外卖订单(99+时间戳)风格同构,替代原 FD+UUID 前24位(26位十六进制串)
- 撞号兜底:插入冲突按约束名区分,uk_flash_order_no 冲突换号重插一次,
  userId+clientRequestId 幂等收口行为不变
- updatesql/sql.md:order_no 唯一索引(待手动执行,含执行前查重语句)
- specs/024-flash-delivery/spec.md:2026-09-22 订单编号规则调整变更记录
- 实体 orderNo 字段注释同步更新;存量旧单号保持不变
- 测试:+3(格式与同毫秒唯一性/撞号换号重试/幂等回退回归)
qmj 2 часов с этого момента
Родитель
Сommit
ee1538b40e

+ 33 - 5
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationService.java

@@ -32,6 +32,7 @@ import java.time.Instant;
 import java.time.ZoneId;
 import java.time.format.DateTimeFormatter;
 import java.util.*;
+import java.util.concurrent.atomic.AtomicLong;
 import java.util.function.Function;
 
 import static com.ruoyi.system.domain.flash.FlashDeliveryStatus.*;
@@ -63,6 +64,8 @@ public class FlashDeliveryApplicationService {
     private static final long SCHEDULE_SLOT_MILLIS = 30L * 60L * 1000L;
     private static final long MAX_SCHEDULE_DELAY_MILLIS = 3L * 24L * 60L * 60L * 1000L;
     private static final SecureRandom PIN_RANDOM = new SecureRandom();
+    /** 订单号同毫秒递增序列(01-99 循环):同一毫秒内的并发由原子递增区分,避免同号。 */
+    private static final AtomicLong ORDER_NO_SEQ = new AtomicLong();
     private final FlashDeliveryOrderMapper orderMapper;
     private final FlashDeliveryPricingMapper pricingMapper;
     private final FlashDeliveryOrderImageMapper imageMapper;
@@ -172,7 +175,7 @@ public class FlashDeliveryApplicationService {
         String payType = normalizePayType(request.getPayType());
         List<String> senderImages = validateProofUrls(request.getSenderImageUrls(), false);
         FlashDeliveryOrder order = new FlashDeliveryOrder();
-        order.setOrderNo("FD" + UUID.randomUUID().toString().replace("-", "").substring(0, 24).toUpperCase(Locale.ROOT));
+        order.setOrderNo(generateOrderNo());
         order.setClientRequestId(requestId);
         order.setUserId(userId);
         order.setServiceType(request.getServiceType());
@@ -220,10 +223,17 @@ public class FlashDeliveryApplicationService {
         try {
             orderMapper.insert(order);
         } catch (DataIntegrityViolationException duplicate) {
-            // 并发重复请求可能同时通过前置查询,最终由数据库唯一键完成幂等收口。
-            FlashDeliveryOrder concurrent = orderMapper.selectByUserRequestId(userId, requestId);
-            if (concurrent != null) return participantDetail(concurrent, true, false);
-            throw duplicate;
+            // order_no 撞号兜底(时钟回拨/多实例等理论情形):唯一索引 uk_flash_order_no 拒绝后换号重插一次。
+            if (isOrderNoConflict(duplicate)) {
+                order.setId(null);
+                order.setOrderNo(generateOrderNo());
+                orderMapper.insert(order);
+            } else {
+                // 并发重复请求可能同时通过前置查询,最终由 userId+clientRequestId 唯一键完成幂等收口。
+                FlashDeliveryOrder concurrent = orderMapper.selectByUserRequestId(userId, requestId);
+                if (concurrent != null) return participantDetail(concurrent, true, false);
+                throw duplicate;
+            }
         }
         saveImages(order.getId(), "SENDER", "USER", userId, senderImages, now);
         writeLog(order.getId(), null, WAITING_ACCEPTANCE, "USER", userId, null, now);
@@ -233,6 +243,24 @@ public class FlashDeliveryApplicationService {
         return participantDetail(order, true, false);
     }
 
+    /**
+     * 闪送订单号:98 + 13 位毫秒时间戳 + 2 位同毫秒递增序列,共 17 位纯数字(2026-09-22 起)。
+     * 与外卖订单(99 + 毫秒时间戳)风格同构,便于电话报单与按时间排查;
+     * 同毫秒并发由 JVM 内原子递增序列区分,时钟回拨/多实例等理论撞号由
+     * order_no 唯一索引(uk_flash_order_no)兜底,插入冲突换号重试。
+     * 2026-09-22 之前的存量单为「FD + UUID 前 24 位大写」旧格式,保持不变。
+     */
+    static String generateOrderNo() {
+        long seq = ORDER_NO_SEQ.updateAndGet(current -> (current + 1) % 100);
+        return "98" + System.currentTimeMillis() + String.format(Locale.ROOT, "%02d", seq);
+    }
+
+    /** 判断唯一键冲突是否来自订单号(区别于 userId+clientRequestId 幂等键)。 */
+    private static boolean isOrderNoConflict(DataIntegrityViolationException exception) {
+        String message = exception.getMostSpecificCause().getMessage();
+        return message != null && message.contains("uk_flash_order_no");
+    }
+
     /** 分页查询当前用户参与的订单卡片;role=sender 按寄件人、receiver 按收件人过滤。 */
     public IPage<FlashDeliveryUserOrderListView> userOrders(Long userId, int pageNum, int pageSize, String role) {
         String selectedRole = hasText(role) ? role.trim().toLowerCase(Locale.ROOT) : "sender";

+ 68 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationServiceTest.java

@@ -49,6 +49,7 @@ import java.util.Calendar;
 
 import static com.ruoyi.system.domain.flash.FlashDeliveryStatus.ACCEPTED;
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
 import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
@@ -1907,6 +1908,73 @@ class FlashDeliveryApplicationServiceTest {
         return input;
     }
 
+    @Test
+    void generateOrderNoUsesTimestampAndSequenceFormat() {
+        String first = FlashDeliveryApplicationService.generateOrderNo();
+        String second = FlashDeliveryApplicationService.generateOrderNo();
+
+        assertTrue(isFlashOrderNo(first), "应为 98 前缀 17 位纯数字,实际: " + first);
+        assertTrue(isFlashOrderNo(second), "应为 98 前缀 17 位纯数字,实际: " + second);
+        assertNotEquals(first, second, "同毫秒连续生成应由序列位区分,不得同号");
+    }
+
+    @Test
+    void createRegeneratesOrderNoWhenUniqueKeyConflicts() {
+        Fixture fixture = new Fixture();
+        when(fixture.pricingMapper.selectAtTime(anyString(), anyInt())).thenReturn(List.of(pricing()));
+        when(fixture.routeService.calculate(any(), any(), anyInt())).thenReturn(new RouteDistance(3200, 600, "ROUTE"));
+        when(fixture.userMapper.selectOrdinaryUserIdsByNormalizedPhone(anyString())).thenReturn(List.of());
+        org.springframework.dao.DataIntegrityViolationException orderNoConflict =
+                new org.springframework.dao.DataIntegrityViolationException("insert failed",
+                        new java.sql.SQLException(
+                                "Duplicate entry '98001' for key 'flash_delivery_order.uk_flash_order_no'"));
+        // 捕获器拿到的是同一对象引用,重试改单号后两次取值相同;改为每次调用时记录当时单号
+        java.util.List<String> attempted = new java.util.ArrayList<>();
+        doAnswer(invocation -> {
+            FlashDeliveryOrder order = invocation.getArgument(0);
+            attempted.add(order.getOrderNo());
+            if (attempted.size() == 1) throw orderNoConflict;
+            order.setId(99L);
+            return 1;
+        }).when(fixture.orderMapper).insert(any(FlashDeliveryOrder.class));
+
+        fixture.service.create(7L, createRequest());
+
+        verify(fixture.orderMapper, times(2)).insert(any(FlashDeliveryOrder.class));
+        assertEquals(2, attempted.size());
+        assertNotEquals(attempted.get(0), attempted.get(1), "撞号重试后应换新单号");
+        assertTrue(isFlashOrderNo(attempted.get(1)), "重试后仍应为新格式单号");
+    }
+
+    @Test
+    void createKeepsIdempotencyFallbackForNonOrderNoConflicts() {
+        Fixture fixture = new Fixture();
+        when(fixture.pricingMapper.selectAtTime(anyString(), anyInt())).thenReturn(List.of(pricing()));
+        when(fixture.routeService.calculate(any(), any(), anyInt())).thenReturn(new RouteDistance(3200, 600, "ROUTE"));
+        when(fixture.userMapper.selectOrdinaryUserIdsByNormalizedPhone(anyString())).thenReturn(List.of());
+        org.springframework.dao.DataIntegrityViolationException idempotentConflict =
+                new org.springframework.dao.DataIntegrityViolationException("insert failed",
+                        new java.sql.SQLException(
+                                "Duplicate entry '7-req-1' for key 'flash_delivery_order.uk_user_request'"));
+        doThrow(idempotentConflict).when(fixture.orderMapper).insert(any(FlashDeliveryOrder.class));
+        // 前置幂等查询第一次放行(null),插入冲突后 catch 内的复查返回并发已建订单
+        FlashDeliveryOrder concurrent = new FlashDeliveryOrder();
+        concurrent.setId(55L);
+        concurrent.setOrderNo("981789000000011");
+        when(fixture.orderMapper.selectByUserRequestId(eq(7L), anyString())).thenReturn(null, concurrent);
+
+        var detail = fixture.service.create(7L, createRequest());
+
+        assertEquals(55L, detail.getId());
+        verify(fixture.orderMapper, times(1)).insert(any(FlashDeliveryOrder.class));
+    }
+
+    /** 闪送新格式单号:98 开头、17 位纯数字。 */
+    private static boolean isFlashOrderNo(String orderNo) {
+        return orderNo != null && orderNo.startsWith("98") && orderNo.length() == 17
+                && orderNo.chars().allMatch(Character::isDigit);
+    }
+
     private static class Fixture {
         final FlashDeliveryOrderMapper orderMapper = mock(FlashDeliveryOrderMapper.class);
         final FlashDeliveryPricingMapper pricingMapper = mock(FlashDeliveryPricingMapper.class);

+ 1 - 1
ruoyi-system/src/main/java/com/ruoyi/system/domain/flash/FlashDeliveryOrder.java

@@ -15,7 +15,7 @@ public class FlashDeliveryOrder {
     @TableId(type = IdType.AUTO)
     /** 主键 ID,数据库自增。 */
     private Long id;
-    /** 订单号,FD 前缀加 24 位随机大写串,用于展示与检索。 */
+    /** 订单号,98 前缀加 13 位毫秒时间戳加 2 位递增序列(17 位纯数字),用于展示与检索;2026-09-22 前的存量单为 FD 前缀 24 位随机串。 */
     private String orderNo;
     /** 客户端请求幂等号,与 userId 共同建立唯一约束。 */
     private String clientRequestId;

+ 8 - 0
specs/024-flash-delivery/spec.md

@@ -366,3 +366,11 @@
 - 骑手沿用既有 `info_user.vehicle_type`(`1`=机车、`2`=轿车);待抢列表、待抢详情和接单均要求与订单车型一致。历史骑手、运价和订单均默认机车,保证迁移后既有行为不变。
 - 重量范围与体积/规格说明改为选填;未传时保存 `NULL`,不参与本期运费计算。物品类别、数量、小费及地址校验保持必填规则。
 - 本期不修改用户 App 界面;后续 App 仅需在报价和创建请求都传相同的 `vehicleType`。
+
+## 2026-09-22:订单编号规则调整(FD+UUID → 98+时间戳+序列)
+
+订单号 orderNo 由「FD + UUID 前 24 位大写」(26 位无语义十六进制串)调整为
+「98 + 13 位毫秒时间戳 + 2 位同毫秒递增序列」(17 位纯数字),与外卖订单(99 + 毫秒时间戳)
+风格同构,便于电话报单与按时间排查。同毫秒并发由 JVM 内原子递增序列区分;
+时钟回拨、多实例等理论撞号由 order_no 唯一索引 uk_flash_order_no 兜底(updatesql/sql.md 2026-09-22 节),
+插入冲突时换号重试一次,userId+clientRequestId 幂等键行为不变。存量订单编号保持不变,不做迁移。

+ 13 - 0
updatesql/sql.md

@@ -1632,3 +1632,16 @@ ALTER TABLE flash_delivery_order ADD INDEX idx_receiver_ctime (receiver_user_id,
 -- 清理参考:DELETE p1 FROM rider_position p1 INNER JOIN rider_position p2 ON p1.rider_id = p2.rider_id AND p1.id < p2.id;
 ALTER TABLE rider_position ADD UNIQUE INDEX uk_rider_id (rider_id);
 ```
+
+## 2026-09-22 闪送订单编号唯一索引
+
+订单号规则调整为「98 + 毫秒时间戳 + 2 位序列」(见 FlashDeliveryApplicationService.generateOrderNo),
+新增唯一索引作为时钟回拨/多实例等理论撞号的兜底。执行前先查重:
+
+```sql
+-- 预检查(应返回 0 行;如有重复需先人工处理再建索引)
+SELECT order_no, COUNT(*) AS cnt FROM flash_delivery_order GROUP BY order_no HAVING cnt > 1;
+
+-- 2026-09-22 闪送订单编号唯一索引(撞号兜底)
+ALTER TABLE flash_delivery_order ADD UNIQUE KEY uk_flash_order_no (order_no);
+```