Explorar el Código

feat: 闪送订单完成后支持骑手评分

qmj hace 1 día
padre
commit
a477a90e4c

+ 9 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryUserController.java

@@ -114,5 +114,14 @@ public class FlashDeliveryUserController extends BaseController {
         return success();
     }
 
+    /** 寄件人对已完成订单评价骑手,星级 1-5 且仅可评一次。 */
+    @PostMapping("/orders/rate")
+    @Auth
+    public AjaxResult rate(@RequestHeader String token,
+                           @RequestBody FlashDeliveryRatingRequest request) {
+        service.rateRider(userId(token), request == null ? null : request.getOrderId(), request);
+        return success();
+    }
+
     private Long userId(String token) { return Long.valueOf(new JwtUtil().getusid(token)); }
 }

+ 3 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryOrderDetailView.java

@@ -23,6 +23,9 @@ public class FlashDeliveryOrderDetailView extends FlashDeliveryOrderView {
     /** 接单骑手完整用户资料,未接单时为空。 */
     @JsonInclude(JsonInclude.Include.NON_NULL)
     private InfoUser rider;
+    /** 寄件人对骑手的评分 1-5,未评价时不返回。 */
+    @JsonInclude(JsonInclude.Include.NON_NULL)
+    private Double riderStars;
     /** 寄件用户完整资料,仅中单骑手接单后的详情返回。 */
     @JsonInclude(JsonInclude.Include.NON_NULL)
     private InfoUser user;

+ 12 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryRatingRequest.java

@@ -0,0 +1,12 @@
+package com.ruoyi.app.flashdelivery.dto;
+
+import lombok.Data;
+
+/** 寄件人评价骑手的请求数据;骑手身份由订单绑定,客户端不可指定。 */
+@Data
+public class FlashDeliveryRatingRequest {
+    /** 订单 ID。 */
+    private Long orderId;
+    /** 骑手评分,1 至 5。 */
+    private Double stars;
+}

+ 18 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationService.java

@@ -418,6 +418,23 @@ public class FlashDeliveryApplicationService {
         writeLog(orderId, DELIVERED, COMPLETED, "USER", userId, null, now);
     }
 
+    /** 寄件人对已完成订单评价骑手:星级 1-5 仅一次,条件更新原子防并发重复提交。 */
+    @Transactional
+    public void rateRider(Long userId, Long orderId, FlashDeliveryRatingRequest request) {
+        if (request == null) throw fail("flash.delivery.request.required");
+        Double stars = request.getStars();
+        if (stars == null || stars < 1 || stars > 5) throw fail("flash.delivery.rating.stars.invalid");
+        FlashDeliveryOrder order = requireOrder(orderId);
+        if (!Objects.equals(userId, order.getUserId())) throw fail("flash.delivery.order.not.found");
+        if (!COMPLETED.equals(order.getStatus()) || order.getRiderId() == null) {
+            throw fail("flash.delivery.rating.not.allowed");
+        }
+        if (order.getRiderStars() != null) throw fail("flash.delivery.rating.already");
+        if (orderMapper.rateRider(orderId, userId, stars, new Date()) != 1) {
+            throw fail("flash.delivery.rating.already");
+        }
+    }
+
     /**
      * 骑手任务列表:按页签查询(newTask 可抢任务、toPickup 待取件、delivering 配送中、
      * completed 已送达/已完成、cancelled 已取消)。newTask 页签额外按骑手坐标做距离过滤和就近排序,
@@ -956,6 +973,7 @@ public class FlashDeliveryApplicationService {
     private FlashDeliveryOrderDetailView detailOrderView(FlashDeliveryOrder order, boolean includeSensitiveFields) {
         FlashDeliveryOrderDetailView view = new FlashDeliveryOrderDetailView();
         populateOrderView(view, order, includeSensitiveFields);
+        view.setRiderStars(order.getRiderStars());
         return view;
     }
 

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

@@ -311,6 +311,9 @@ flash.delivery.transition.not.allowed=The current order status does not allow th
 flash.delivery.cancel.reason.required=A cancellation reason is required
 flash.delivery.cancel.not.allowed=The current order status cannot be cancelled this way
 flash.delivery.complete.not.allowed=The order is not ready to complete
+flash.delivery.rating.stars.invalid=Rider rating must be between 1 and 5
+flash.delivery.rating.not.allowed=The rider can only be rated after the order is completed
+flash.delivery.rating.already=The rider of this order has already been rated
 flash.delivery.state.changed=The order status changed; please refresh and retry
 flash.delivery.pricing.invalid=Pricing values must be valid positive numbers
 flash.delivery.pricing.not.available=No pricing is available for the current time period

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

@@ -314,6 +314,9 @@ flash.delivery.transition.not.allowed=The current order status does not allow th
 flash.delivery.cancel.reason.required=A cancellation reason is required
 flash.delivery.cancel.not.allowed=The current order status cannot be cancelled this way
 flash.delivery.complete.not.allowed=The order is not ready to complete
+flash.delivery.rating.stars.invalid=Rider rating must be between 1 and 5
+flash.delivery.rating.not.allowed=The rider can only be rated after the order is completed
+flash.delivery.rating.already=The rider of this order has already been rated
 flash.delivery.state.changed=The order status changed; please refresh and retry
 flash.delivery.pricing.invalid=Pricing values must be valid positive numbers
 flash.delivery.pricing.not.available=No pricing is available for the current time period

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

@@ -315,6 +315,9 @@ flash.delivery.transition.not.allowed=สถานะคำสั่งซื้
 flash.delivery.cancel.reason.required=ต้องระบุเหตุผลในการยกเลิก
 flash.delivery.cancel.not.allowed=สถานะคำสั่งซื้อปัจจุบันไม่อนุญาตให้ยกเลิกด้วยวิธีนี้
 flash.delivery.complete.not.allowed=คำสั่งซื้อยังไม่สามารถดำเนินการให้เสร็จสิ้นได้
+flash.delivery.rating.stars.invalid=คะแนนไรเดอร์ต้องอยู่ระหว่าง 1 ถึง 5
+flash.delivery.rating.not.allowed=สามารถให้คะแนนไรเดอร์ได้หลังคำสั่งซื้อเสร็จสิ้นแล้วเท่านั้น
+flash.delivery.rating.already=ไรเดอร์ของคำสั่งซื้อนี้ถูกให้คะแนนแล้ว
 flash.delivery.state.changed=สถานะคำสั่งซื้อเปลี่ยนแปลงแล้ว โปรดรีเฟรชและลองอีกครั้ง
 flash.delivery.pricing.invalid=ค่ากำหนดราคาต้องเป็นจำนวนบวกที่ถูกต้อง
 flash.delivery.pricing.not.available=ไม่มีอัตราค่าบริการที่ใช้ได้ในช่วงเวลานี้

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

@@ -314,6 +314,9 @@ flash.delivery.transition.not.allowed=Trạng thái hiện tại không cho phé
 flash.delivery.cancel.reason.required=Phải nhập lý do hủy
 flash.delivery.cancel.not.allowed=Trạng thái hiện tại không cho phép hủy theo cách này
 flash.delivery.complete.not.allowed=Đơn hàng chưa thể hoàn tất
+flash.delivery.rating.stars.invalid=Đánh giá tài xế phải từ 1 đến 5
+flash.delivery.rating.not.allowed=Chỉ có thể đánh giá tài xế sau khi đơn hàng hoàn tất
+flash.delivery.rating.already=Tài xế của đơn hàng này đã được đánh giá
 flash.delivery.state.changed=Trạng thái đơn đã thay đổi, vui lòng tải lại và thử lại
 flash.delivery.pricing.invalid=Cấu hình giá phải là số dương hợp lệ
 flash.delivery.pricing.not.available=Không có mức giá khả dụng trong khung giờ hiện tại

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

@@ -315,6 +315,9 @@ flash.delivery.transition.not.allowed=当前订单状态不允许此操作
 flash.delivery.cancel.reason.required=取消原因不能为空
 flash.delivery.cancel.not.allowed=当前订单状态不允许这样取消
 flash.delivery.complete.not.allowed=当前订单尚不能完成
+flash.delivery.rating.stars.invalid=骑手评分必须是1到5之间的数值
+flash.delivery.rating.not.allowed=订单完成后才能评价骑手
+flash.delivery.rating.already=该订单骑手已评价过,不能重复评价
 flash.delivery.state.changed=订单状态已变化,请刷新后重试
 flash.delivery.pricing.invalid=计价配置必须为有效正数
 flash.delivery.pricing.not.available=当前时段暂无可用运价

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

@@ -315,6 +315,9 @@ flash.delivery.transition.not.allowed=目前訂單狀態不允許此操作
 flash.delivery.cancel.reason.required=取消原因不能為空
 flash.delivery.cancel.not.allowed=目前訂單狀態不允許這樣取消
 flash.delivery.complete.not.allowed=目前訂單尚不能完成
+flash.delivery.rating.stars.invalid=騎手評分必須是1到5之間的數值
+flash.delivery.rating.not.allowed=訂單完成後才能評價騎手
+flash.delivery.rating.already=該訂單騎手已評價過,不能重複評價
 flash.delivery.state.changed=訂單狀態已變更,請重新整理後再試
 flash.delivery.pricing.invalid=計價設定必須為有效正數
 flash.delivery.pricing.not.available=目前時段暫無可用運價

+ 13 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryControllerContractTest.java

@@ -34,6 +34,7 @@ import static org.junit.jupiter.api.Assertions.assertSame;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
 class FlashDeliveryControllerContractTest {
@@ -166,6 +167,18 @@ class FlashDeliveryControllerContractTest {
         assertSame(detail, controller.addTip(token, tip).get(AjaxResult.DATA_TAG));
     }
 
+    @Test
+    void rateEndpointDelegatesSenderAndStarsToService() {
+        FlashDeliveryApplicationService service = mock(FlashDeliveryApplicationService.class);
+        var request = new com.ruoyi.app.flashdelivery.dto.FlashDeliveryRatingRequest();
+        request.setOrderId(10L);
+        request.setStars(4.5);
+
+        new FlashDeliveryUserController(service).rate(JwtUtil.token("7", "user"), request);
+
+        verify(service).rateRider(7L, 10L, request);
+    }
+
     @Test
     void addressConfirmationReturnsLatestQuoteForAnotherUserConfirmation() {
         FlashDeliveryApplicationService service = mock(FlashDeliveryApplicationService.class);

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

@@ -607,6 +607,95 @@ class FlashDeliveryApplicationServiceTest {
                 eq("COMPLETED"), any(Date.class));
     }
 
+    @Test
+    void senderRatesRiderOnCompletedOrder() {
+        Fixture fixture = new Fixture();
+        FlashDeliveryOrder order = order(10L, "COMPLETED");
+        order.setRiderId(8L);
+        when(fixture.orderMapper.selectById(10L)).thenReturn(order);
+        when(fixture.orderMapper.rateRider(eq(10L), eq(7L), eq(4.5), any(Date.class))).thenReturn(1);
+
+        fixture.service.rateRider(7L, 10L, rating(4.5));
+
+        verify(fixture.orderMapper).rateRider(eq(10L), eq(7L), eq(4.5), any(Date.class));
+    }
+
+    @Test
+    void rateRiderRejectsUserOtherThanSender() {
+        Fixture fixture = new Fixture();
+        FlashDeliveryOrder order = order(10L, "COMPLETED");
+        order.setRiderId(8L);
+        when(fixture.orderMapper.selectById(10L)).thenReturn(order);
+
+        assertThrows(ServiceException.class, () -> fixture.service.rateRider(88L, 10L, rating(4.5)));
+        verify(fixture.orderMapper, never()).rateRider(any(), any(), any(), any());
+    }
+
+    @Test
+    void rateRiderRejectsOrderNotCompletedOrWithoutRider() {
+        Fixture fixture = new Fixture();
+        FlashDeliveryOrder delivered = order(10L, "DELIVERED");
+        delivered.setRiderId(8L);
+        when(fixture.orderMapper.selectById(10L)).thenReturn(delivered);
+        assertThrows(ServiceException.class, () -> fixture.service.rateRider(7L, 10L, rating(4.5)));
+
+        FlashDeliveryOrder completedWithoutRider = order(11L, "COMPLETED");
+        when(fixture.orderMapper.selectById(11L)).thenReturn(completedWithoutRider);
+        assertThrows(ServiceException.class, () -> fixture.service.rateRider(7L, 11L, rating(4.5)));
+
+        verify(fixture.orderMapper, never()).rateRider(any(), any(), any(), any());
+    }
+
+    @Test
+    void rateRiderRejectsAlreadyRatedOrder() {
+        Fixture fixture = new Fixture();
+        FlashDeliveryOrder order = order(10L, "COMPLETED");
+        order.setRiderId(8L);
+        order.setRiderStars(4.5);
+        when(fixture.orderMapper.selectById(10L)).thenReturn(order);
+
+        assertThrows(ServiceException.class, () -> fixture.service.rateRider(7L, 10L, rating(3.0)));
+        verify(fixture.orderMapper, never()).rateRider(any(), any(), any(), any());
+    }
+
+    @Test
+    void rateRiderRejectsConcurrentDuplicateSubmission() {
+        Fixture fixture = new Fixture();
+        FlashDeliveryOrder order = order(10L, "COMPLETED");
+        order.setRiderId(8L);
+        when(fixture.orderMapper.selectById(10L)).thenReturn(order);
+        when(fixture.orderMapper.rateRider(eq(10L), eq(7L), eq(4.5), any(Date.class))).thenReturn(0);
+
+        assertThrows(ServiceException.class, () -> fixture.service.rateRider(7L, 10L, rating(4.5)));
+    }
+
+    @Test
+    void rateRiderRejectsMissingOrOutOfRangeStars() {
+        Fixture fixture = new Fixture();
+
+        assertThrows(ServiceException.class, () -> fixture.service.rateRider(7L, 10L, null));
+        assertThrows(ServiceException.class, () -> fixture.service.rateRider(7L, 10L, rating(null)));
+        assertThrows(ServiceException.class, () -> fixture.service.rateRider(7L, 10L, rating(0.5)));
+        assertThrows(ServiceException.class, () -> fixture.service.rateRider(7L, 10L, rating(5.5)));
+
+        verify(fixture.orderMapper, never()).selectById(any());
+        verify(fixture.orderMapper, never()).rateRider(any(), any(), any(), any());
+    }
+
+    @Test
+    void userDetailReturnsRiderStars() {
+        Fixture fixture = new Fixture();
+        FlashDeliveryOrder order = order(10L, "COMPLETED");
+        order.setRiderId(8L);
+        order.setRiderStars(4.5);
+        when(fixture.orderMapper.selectById(10L)).thenReturn(order);
+        when(fixture.imageMapper.selectList(any(Wrapper.class))).thenReturn(List.of());
+
+        var view = fixture.service.userDetail(7L, 10L);
+
+        assertEquals(4.5, view.getRiderStars());
+    }
+
     @Test
     void orderWithoutCreationTimeReceiverBindingIsNotClaimedLater() {
         Fixture fixture = new Fixture();
@@ -1364,6 +1453,13 @@ class FlashDeliveryApplicationServiceTest {
         when(fixture.userMapper.selectInfoUserByUserId(riderId)).thenReturn(rider);
     }
 
+    private com.ruoyi.app.flashdelivery.dto.FlashDeliveryRatingRequest rating(Double stars) {
+        com.ruoyi.app.flashdelivery.dto.FlashDeliveryRatingRequest request =
+                new com.ruoyi.app.flashdelivery.dto.FlashDeliveryRatingRequest();
+        request.setStars(stars);
+        return request;
+    }
+
     private FlashDeliveryOrder order(Long id, String status) {
         FlashDeliveryOrder order = new FlashDeliveryOrder();
         order.setId(id);

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

@@ -124,6 +124,9 @@ public class FlashDeliveryOrder {
     private Date completedAt;
     private Date cancelledAt;
 
+    /** 寄件人对骑手的评分 1-5,未评为 NULL;仅完成态订单可评一次。 */
+    private Double riderStars;
+
     /** 取消审计信息,操作人类型可为 USER 或 ADMIN。 */
     private String cancelReason;
     private String cancelOperatorType;

+ 4 - 0
ruoyi-system/src/main/java/com/ruoyi/system/mapper/flash/FlashDeliveryOrderMapper.java

@@ -39,6 +39,10 @@ public interface FlashDeliveryOrderMapper extends BaseMapper<FlashDeliveryOrder>
                                 @Param("expectedStatus") String expectedStatus,
                                 @Param("nextStatus") String nextStatus, @Param("now") Date now);
 
+    /** 寄件人首次评价骑手:仅完成态、已接骑手且从未评价时更新成功,防并发重复提交。 */
+    int rateRider(@Param("id") Long id, @Param("userId") Long userId,
+                  @Param("riderStars") Double riderStars, @Param("now") Date now);
+
     /** 供平台和系统任务按预期状态执行条件更新。 */
     int transitionByStatus(@Param("id") Long id, @Param("expectedStatus") String expectedStatus,
                            @Param("nextStatus") String nextStatus,

+ 9 - 0
ruoyi-system/src/main/resources/mapper/flash/FlashDeliveryOrderMapper.xml

@@ -67,6 +67,7 @@
         <result property="deliveredAt" column="delivered_at"/>
         <result property="completedAt" column="completed_at"/>
         <result property="cancelledAt" column="cancelled_at"/>
+        <result property="riderStars" column="rider_stars"/>
         <result property="cancelReason" column="cancel_reason"/>
         <result property="cancelOperatorType" column="cancel_operator_type"/>
         <result property="cancelOperatorId" column="cancel_operator_id"/>
@@ -139,6 +140,14 @@
         where id=#{id} and (user_id=#{userId} or receiver_user_id = #{userId}) and status=#{expectedStatus}
     </update>
 
+    <!-- 评分条件更新:仅寄件人的完成态订单、已接骑手且从未评价才成功,并发重复提交返回 0。 -->
+    <update id="rateRider">
+        update flash_delivery_order
+        set rider_stars=#{riderStars}, update_time=#{now}
+        where id=#{id} and user_id=#{userId} and status='COMPLETED'
+          and rider_id is not null and rider_stars is null
+    </update>
+
     <!-- 平台/系统完成同样使用预期状态条件,防止与用户确认并发重复成功。 -->
     <update id="transitionByStatus">
         update flash_delivery_order

+ 14 - 1
specs/028-flash-delivery-push/tasks.md

@@ -128,7 +128,20 @@ description: "Task list for feature 028-flash-delivery-push"
 
 ---
 
-## Dependencies & Execution Order
+## Phase 8: 闪送订单骑手评分(2026-09-18 追加)
+
+**Goal**: 寄件人对已完成闪送订单的骑手评分(1-5 星,仅一次);闪送分与外卖 `pos_order_rating` 体系彻底分开,不并骑手总分、不给积分;用户端详情回显评分,平台 PC 端订单详情显示评分。
+
+**Background**: Bug 工单 #659「骑手评价后要可提交」——用户端已有评价页,后端此前无闪送评价支持。设计决策:评分存 `flash_delivery_order.rider_stars` 单字段(NULL=未评),新开专用接口带完整校验,骑手身份由后端从订单 `rider_id` 回填语义(评分条件更新即绑定该订单骑手),星级由服务端校验。
+
+- [x] T025 [US5] 先补失败测试:`FlashDeliveryApplicationServiceTest.java` 增补 `rateRider` 用例(寄件人完成单评价成功、非寄件人拒绝、未完成/无骑手拒绝、已评预检拒绝、并发条件更新 0 行拒绝、星级 null/越界拒绝、`userDetail` 回显 riderStars);`FlashDeliveryControllerContractTest.java` 增补 `POST /orders/{id}/rate` 契约(token→userId 透传 service)
+- [x] T026 [US5] `FlashDeliveryOrder` 增加 `riderStars` 字段;`FlashDeliveryOrderMapper.xml` resultMap 补 `rider_stars` 映射并新增 `rateRider` 原子条件更新(`status='COMPLETED' AND rider_id IS NOT NULL AND rider_stars IS NULL AND user_id=#{userId}` 守卫,防并发重复提交)
+- [x] T027 [US5] 新建 DTO `FlashDeliveryRatingRequest`(`Long orderId` + `Double stars`,orderId 放 body 与 `addTip`/`updateAddress` 模式一致);`FlashDeliveryUserController` 新增 `POST /system/flashDelivery/orders/rate`;`FlashDeliveryApplicationService.rateRider`(星级 1-5 服务端校验、寄件人/完成态/未评过校验、条件更新 0 行报已评);`FlashDeliveryOrderDetailView` 回显 `riderStars`(`adminDetail` 返回完整实体,平台端自动带出)
+- [x] T028 [US5] 后端 i18n 6 份资源新增 `flash.delivery.rating.stars.invalid` / `flash.delivery.rating.not.allowed` / `flash.delivery.rating.already`;`updatesql/sql.md` 登记 `flash_delivery_order.rider_stars` 列(不直接执行)
+- [x] T029 [US5] 平台 PC 端 `foodie-admin-vue` `views/flashDelivery/orders/OrderDetail.vue` 骑手信息区显示评分(NULL 显示未评价),4 份语言文件(`src/api/language/language.*.js`)`flashDelivery` 命名空间新增 key
+- [x] T030 [US5] 回归:定向测试全绿 + `mvn -pl ruoyi-admin -am test` 维持基线(仅存量已知失败 `createRejectsUnsupportedPayType`);用户端 App 前端调新接口与五星默认选中由 App 侧另行处理(Bug #659 前端部分)
+
+---
 
 ### Phase Dependencies
 

+ 7 - 0
updatesql/sql.md

@@ -1592,3 +1592,10 @@ WHERE NOT EXISTS (
   SELECT 1 FROM sys_job WHERE invoke_target = 'flashDeliveryAvailabilityTask.openScheduledOrders()'
 );
 ```
+
+## 2026-09-18 闪送订单骑手评分
+
+```sql
+-- 2026-09-18 闪送订单骑手评分:寄件人对已完成订单评价骑手(1-5,仅一次),NULL=未评价
+ALTER TABLE flash_delivery_order ADD COLUMN rider_stars DOUBLE DEFAULT NULL COMMENT '寄件人对骑手评分1-5,NULL=未评价';
+```