liujie
2026-08-21 77649c76ec1c3afcb93e9963243d637665b3a307
update
6个文件已添加
14个文件已修改
764 ■■■■■ 已修改文件
backend/src/main/java/com/tourguide/api/guide/GuideTourOrderController.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tourguide/modules/order/domain/PaymentMethod.java 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tourguide/modules/order/service/GuideTourOrderService.java 60 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tourguide/modules/order/service/MiniTourOrderCommandService.java 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tourguide/modules/order/service/OrderRefundService.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tourguide/modules/order/service/TourOrderPaymentService.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tourguide/modules/user/mapper/GuideReceivingSessionMapper.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/resources/application-dev.yml 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/test/java/com/tourguide/modules/order/service/GuideTourOrderServiceTest.java 91 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/test/java/com/tourguide/modules/order/service/MiniTourOrderCommandServiceTest.java 134 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/test/java/com/tourguide/modules/order/service/OrderRefundServiceTest.java 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
data/guide-applications/1e3862aa6fda49d580017e360126062a.jpg 补丁 | 查看 | 原始文档 | blame | 历史
data/guide-applications/20563e26d1bc43c4a1aac5abc87ae480.jpg 补丁 | 查看 | 原始文档 | blame | 历史
data/guide-applications/7eff1dce4fc54626bde8a8b05021b5fd.jpg 补丁 | 查看 | 原始文档 | blame | 历史
data/guide-applications/b57f19850ed74770bf45906b9d3d2b19.jpg 补丁 | 查看 | 原始文档 | blame | 历史
mingdaojingjiang_guide_app/pages/workbench/index.vue 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
mingdaojingjiang_guide_app/services/order.js 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
mingdaojingjiang_uniapp/mingdaojingjiang_uniapp/pages/home/index.vue 38 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
mingdaojingjiang_uniapp/mingdaojingjiang_uniapp/pages/order-pending-guide/index.vue 86 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
mingdaojingjiang_uniapp/mingdaojingjiang_uniapp/pages/order-serving/index.vue 232 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tourguide/api/guide/GuideTourOrderController.java
@@ -31,4 +31,10 @@
                                                  @PathVariable Long id) {
        return ApiResponse.success(orderService.detail(principal.getUserId(), id));
    }
    @PostMapping("/{id}/start")
    public ApiResponse<GuideOrderResponse> start(@AuthenticationPrincipal AppUserPrincipal principal,
                                                 @PathVariable Long id) {
        return ApiResponse.success(orderService.start(principal.getUserId(), id));
    }
}
backend/src/main/java/com/tourguide/modules/order/domain/PaymentMethod.java
New file
@@ -0,0 +1,15 @@
package com.tourguide.modules.order.domain;
/** 订单支付方式。 */
public enum PaymentMethod {
    WECHAT,
    TEMPORARY_BYPASS;
    public String getCode() {
        return name();
    }
    public boolean matches(String code) {
        return name().equals(code);
    }
}
backend/src/main/java/com/tourguide/modules/order/service/GuideTourOrderService.java
@@ -8,9 +8,14 @@
import com.tourguide.modules.order.dto.GuideOrderResponse;
import com.tourguide.modules.order.entity.TourOrder;
import com.tourguide.modules.order.mapper.TourOrderMapper;
import com.tourguide.modules.user.entity.GuideReceivingSession;
import com.tourguide.modules.user.mapper.GuideRealtimeStatusMapper;
import com.tourguide.modules.user.mapper.GuideReceivingSessionMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.stream.Collectors;
@@ -23,9 +28,14 @@
    private static final String TAB_COMPLETED = "completed";
    private final TourOrderMapper orderMapper;
    private final GuideReceivingSessionMapper sessionMapper;
    private final GuideRealtimeStatusMapper realtimeStatusMapper;
    public GuideTourOrderService(TourOrderMapper orderMapper) {
    public GuideTourOrderService(TourOrderMapper orderMapper, GuideReceivingSessionMapper sessionMapper,
                                 GuideRealtimeStatusMapper realtimeStatusMapper) {
        this.orderMapper = orderMapper;
        this.sessionMapper = sessionMapper;
        this.realtimeStatusMapper = realtimeStatusMapper;
    }
    public PageResponse<GuideOrderResponse> page(Long guideId, String tab, long page, long size) {
@@ -41,16 +51,56 @@
    }
    public GuideOrderResponse detail(Long guideId, Long orderId) {
        TourOrder order = orderMapper.selectOne(new LambdaQueryWrapper<TourOrder>()
                .eq(TourOrder::getId, orderId)
                .eq(TourOrder::getGuideId, guideId)
                .last("LIMIT 1"));
        TourOrder order = findOwnedOrder(guideId, orderId);
        if (order == null) {
            throw new BusinessException(404, "订单不存在");
        }
        return GuideOrderResponse.from(order);
    }
    @Transactional
    public GuideOrderResponse start(Long guideId, Long orderId) {
        TourOrder order = findOwnedOrder(guideId, orderId);
        if (order == null) {
            throw new BusinessException(404, "订单不存在");
        }
        if (OrderStatus.IN_SERVICE.matches(order.getStatus())) {
            return GuideOrderResponse.from(order);
        }
        if (!OrderStatus.PENDING_MEETING.matches(order.getStatus()) || order.getReceivingSessionId() == null) {
            throw new BusinessException(400, "当前订单不能开始讲解");
        }
        GuideReceivingSession session = sessionMapper.selectForUpdate(order.getReceivingSessionId(), guideId);
        if (session == null) {
            throw new BusinessException(400, "接单会话不存在或已结束");
        }
        if ("IN_SERVICE".equals(session.getStatus())) {
            order.setStatus(OrderStatus.IN_SERVICE.getCode());
            return GuideOrderResponse.from(order);
        }
        if (!"RECEIVING".equals(session.getStatus()) && !"ASSIGNED".equals(session.getStatus())) {
            throw new BusinessException(400, "接单会话已结束,不能开始讲解");
        }
        LocalDateTime startedAt = LocalDateTime.now();
        if (sessionMapper.startSession(session.getId(), guideId) != 1
                || orderMapper.startSessionOrders(session.getId(), startedAt) == 0) {
            throw new BusinessException(409, "讲解状态已变化,请刷新后重试");
        }
        realtimeStatusMapper.disableReceiving(guideId);
        order.setStatus(OrderStatus.IN_SERVICE.getCode());
        order.setServiceStartedAt(startedAt);
        return GuideOrderResponse.from(order);
    }
    private TourOrder findOwnedOrder(Long guideId, Long orderId) {
        return orderMapper.selectOne(new LambdaQueryWrapper<TourOrder>()
                .eq(TourOrder::getId, orderId)
                .eq(TourOrder::getGuideId, guideId)
                .last("LIMIT 1"));
    }
    private String normalizeTab(String tab) {
        return StringUtils.hasText(tab) ? tab.trim() : TAB_ALL;
    }
backend/src/main/java/com/tourguide/modules/order/service/MiniTourOrderCommandService.java
@@ -10,6 +10,7 @@
import com.tourguide.modules.operation.mapper.ScenicPricingMapper;
import com.tourguide.modules.operation.service.PlatformSettingsService;
import com.tourguide.modules.order.domain.OrderStatus;
import com.tourguide.modules.order.domain.PaymentMethod;
import com.tourguide.modules.order.domain.RefundStatus;
import com.tourguide.modules.order.dto.MiniOrderCreateRequest;
import com.tourguide.modules.order.dto.TourOrderResponse;
@@ -20,6 +21,7 @@
import com.tourguide.modules.user.entity.GuideUser;
import com.tourguide.modules.user.mapper.GuideUserMapper;
import com.tourguide.modules.user.service.MiniUserService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -40,6 +42,8 @@
    private final PlatformSettingsService platformSettingsService;
    private final GuideNotificationService notificationService;
    private final OrderRefundService orderRefundService;
    private final GuideOrderMatchingService matchingService;
    private final boolean skipPayment;
    public MiniTourOrderCommandService(TourOrderMapper orderMapper,
                                       MiniUserService miniUserService,
@@ -49,7 +53,9 @@
                                       GuideUserMapper guideUserMapper,
                                       PlatformSettingsService platformSettingsService,
                                       GuideNotificationService notificationService,
                                       OrderRefundService orderRefundService) {
                                       OrderRefundService orderRefundService,
                                       GuideOrderMatchingService matchingService,
                                       @Value("${tourguide.order.skip-payment:false}") boolean skipPayment) {
        this.orderMapper = orderMapper;
        this.miniUserService = miniUserService;
        this.scenicSpotMapper = scenicSpotMapper;
@@ -59,6 +65,8 @@
        this.platformSettingsService = platformSettingsService;
        this.notificationService = notificationService;
        this.orderRefundService = orderRefundService;
        this.matchingService = matchingService;
        this.skipPayment = skipPayment;
    }
    @Transactional
@@ -103,9 +111,20 @@
        order.setMeetingPointCount(1);
        Integer settlementDays = platformSettingsService.getSettings().getSettlementDays();
        order.setSettlementDays(settlementDays == null ? 1 : Math.max(0, settlementDays));
        if (skipPayment) {
            order.setPaymentMethod(PaymentMethod.TEMPORARY_BYPASS.getCode());
            order.setPaymentOrderNo("TEMP" + order.getOrderNo());
            order.setPaymentAmount(subtotalAmount);
            order.setPaymentTime(now);
            order.setStatus(OrderStatus.PENDING_ACCEPT.getCode());
        } else {
        order.setStatus(OrderStatus.PENDING_PAYMENT.getCode());
        }
        order.setRefundStatus(RefundStatus.NONE.getCode());
        orderMapper.insert(order);
        if (skipPayment) {
            matchingService.match(order.getId());
        }
        return TourOrderResponse.from(order);
    }
backend/src/main/java/com/tourguide/modules/order/service/OrderRefundService.java
@@ -1,6 +1,7 @@
package com.tourguide.modules.order.service;
import com.tourguide.common.huifu.RequestSeqUtil;
import com.tourguide.modules.order.domain.PaymentMethod;
import com.tourguide.modules.order.domain.RefundStatus;
import com.tourguide.modules.order.entity.TourOrder;
import com.tourguide.modules.order.mapper.TourOrderMapper;
@@ -11,6 +12,7 @@
import org.springframework.util.StringUtils;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Service
public class OrderRefundService {
@@ -40,6 +42,12 @@
        order.setRefundStatus(RefundStatus.REFUNDING.getCode());
        order.setRefundAmount(refundAmount);
        order.setRefundMethod(order.getPaymentMethod());
        if (PaymentMethod.TEMPORARY_BYPASS.matches(order.getPaymentMethod())) {
            order.setRefundStatus(RefundStatus.REFUNDED.getCode());
            order.setRefundTime(LocalDateTime.now());
            orderMapper.updateById(order);
            return;
        }
        orderMapper.updateById(order);
        try {
            paymentRefundGateway.refund(order, refundAmount);
backend/src/main/java/com/tourguide/modules/order/service/TourOrderPaymentService.java
@@ -3,6 +3,7 @@
import com.tourguide.common.exception.BusinessException;
import com.tourguide.common.huifu.RequestSeqUtil;
import com.tourguide.modules.order.domain.OrderStatus;
import com.tourguide.modules.order.domain.PaymentMethod;
import com.tourguide.modules.order.entity.TourOrder;
import com.tourguide.modules.order.entity.TourOrderPaymentAttempt;
import com.tourguide.modules.order.mapper.TourOrderMapper;
@@ -56,7 +57,7 @@
        attempt.setStatus(ATTEMPT_CREATED);
        paymentAttemptMapper.insert(attempt);
        order.setPaymentMethod("WECHAT");
        order.setPaymentMethod(PaymentMethod.WECHAT.getCode());
        order.setPaymentOrderNo(reqSeqId);
        orderMapper.updateById(order);
@@ -91,7 +92,7 @@
        if (!OrderStatus.PENDING_PAYMENT.matches(order.getStatus())) {
            throw new BusinessException(409, "订单状态不允许确认支付");
        }
        order.setPaymentMethod("WECHAT");
        order.setPaymentMethod(PaymentMethod.WECHAT.getCode());
        order.setPaymentOrderNo(notice.getReqSeqId());
        order.setPaymentAmount(notice.getAmount());
        order.setPaymentTime(paidAt);
backend/src/main/java/com/tourguide/modules/user/mapper/GuideReceivingSessionMapper.java
@@ -9,6 +9,10 @@
import java.util.List;
public interface GuideReceivingSessionMapper extends BaseMapper<GuideReceivingSession> {
    @Select("SELECT * FROM guide_receiving_session WHERE id = #{id} AND guide_id = #{guideId} " +
            "AND deleted = 0 LIMIT 1 FOR UPDATE")
    GuideReceivingSession selectForUpdate(@Param("id") Long id, @Param("guideId") Long guideId);
    @Select("SELECT s.* FROM guide_receiving_session s " +
            "INNER JOIN guide_realtime_status r ON r.guide_id = s.guide_id AND r.deleted = 0 " +
            "INNER JOIN guide_user g ON g.id = s.guide_id AND g.deleted = 0 AND g.status = 1 " +
@@ -40,6 +44,10 @@
            "WHERE id = #{id} AND deleted = 0 AND status = 'RECEIVING' AND service_start_at <= NOW()")
    int startDueSession(Long id);
    @Update("UPDATE guide_receiving_session SET status = 'IN_SERVICE', updated_at = NOW() " +
            "WHERE id = #{id} AND guide_id = #{guideId} AND deleted = 0 AND status IN ('RECEIVING', 'ASSIGNED')")
    int startSession(@Param("id") Long id, @Param("guideId") Long guideId);
    @Update("UPDATE guide_receiving_session SET reminder_pushed_at = #{now}, updated_at = NOW() " +
            "WHERE id = #{id} AND deleted = 0 AND status = 'RECEIVING' AND reminder_pushed_at IS NULL")
    int claimServiceReminder(@Param("id") Long id, @Param("now") LocalDateTime now);
backend/src/main/resources/application-dev.yml
@@ -14,6 +14,9 @@
tourguide:
  guide-login:
    fixed-verification-code: ${GUIDE_LOGIN_FIXED_CODE:}
  order:
    # 临时跳过微信支付;恢复真实支付时将默认值改为 false。
    skip-payment: ${TOURGUIDE_ORDER_SKIP_PAYMENT:true}
logging:
  level:
backend/src/test/java/com/tourguide/modules/order/service/GuideTourOrderServiceTest.java
New file
@@ -0,0 +1,91 @@
package com.tourguide.modules.order.service;
import com.tourguide.common.exception.BusinessException;
import com.tourguide.modules.order.domain.OrderStatus;
import com.tourguide.modules.order.dto.GuideOrderResponse;
import com.tourguide.modules.order.entity.TourOrder;
import com.tourguide.modules.order.mapper.TourOrderMapper;
import com.tourguide.modules.user.entity.GuideReceivingSession;
import com.tourguide.modules.user.mapper.GuideRealtimeStatusMapper;
import com.tourguide.modules.user.mapper.GuideReceivingSessionMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class GuideTourOrderServiceTest {
    private TourOrderMapper orderMapper;
    private GuideReceivingSessionMapper sessionMapper;
    private GuideRealtimeStatusMapper realtimeStatusMapper;
    private GuideTourOrderService service;
    @BeforeEach
    void setUp() {
        orderMapper = mock(TourOrderMapper.class);
        sessionMapper = mock(GuideReceivingSessionMapper.class);
        realtimeStatusMapper = mock(GuideRealtimeStatusMapper.class);
        service = new GuideTourOrderService(orderMapper, sessionMapper, realtimeStatusMapper);
    }
    @Test
    void shouldStartAllOrdersInReceivingSession() {
        TourOrder order = order(OrderStatus.PENDING_MEETING);
        GuideReceivingSession session = session("ASSIGNED");
        when(orderMapper.selectOne(any())).thenReturn(order);
        when(sessionMapper.selectForUpdate(20L, 8L)).thenReturn(session);
        when(sessionMapper.startSession(20L, 8L)).thenReturn(1);
        when(orderMapper.startSessionOrders(eq(20L), any())).thenReturn(2);
        GuideOrderResponse response = service.start(8L, 10L);
        assertThat(response.getStatus()).isEqualTo("serving");
        assertThat(response.getLectureStartTime()).isNotNull();
        verify(realtimeStatusMapper).disableReceiving(8L);
    }
    @Test
    void shouldTreatRepeatedStartAsSuccess() {
        TourOrder order = order(OrderStatus.IN_SERVICE);
        when(orderMapper.selectOne(any())).thenReturn(order);
        GuideOrderResponse response = service.start(8L, 10L);
        assertThat(response.getStatus()).isEqualTo("serving");
        verify(sessionMapper, never()).selectForUpdate(any(), any());
    }
    @Test
    void shouldRejectOrderOutsidePendingMeetingState() {
        TourOrder order = order(OrderStatus.COMPLETED);
        when(orderMapper.selectOne(any())).thenReturn(order);
        assertThatThrownBy(() -> service.start(8L, 10L))
                .isInstanceOf(BusinessException.class)
                .hasMessage("当前订单不能开始讲解");
    }
    private TourOrder order(OrderStatus status) {
        TourOrder order = new TourOrder();
        order.setId(10L);
        order.setGuideId(8L);
        order.setReceivingSessionId(20L);
        order.setStatus(status.getCode());
        order.setPeopleCount(2);
        return order;
    }
    private GuideReceivingSession session(String status) {
        GuideReceivingSession session = new GuideReceivingSession();
        session.setId(20L);
        session.setGuideId(8L);
        session.setStatus(status);
        return session;
    }
}
backend/src/test/java/com/tourguide/modules/order/service/MiniTourOrderCommandServiceTest.java
@@ -9,6 +9,7 @@
import com.tourguide.modules.operation.dto.PlatformSettingsResponse;
import com.tourguide.modules.operation.service.PlatformSettingsService;
import com.tourguide.modules.order.domain.OrderStatus;
import com.tourguide.modules.order.domain.PaymentMethod;
import com.tourguide.modules.order.dto.MiniOrderCreateRequest;
import com.tourguide.modules.order.dto.TourOrderResponse;
import com.tourguide.modules.order.entity.TourOrder;
@@ -43,6 +44,7 @@
    private PlatformSettingsService platformSettingsService;
    private GuideNotificationService notificationService;
    private OrderRefundService orderRefundService;
    private GuideOrderMatchingService matchingService;
    private MiniTourOrderCommandService commandService;
    @BeforeEach
@@ -56,13 +58,92 @@
        platformSettingsService = mock(PlatformSettingsService.class);
        notificationService = mock(GuideNotificationService.class);
        orderRefundService = mock(OrderRefundService.class);
        matchingService = mock(GuideOrderMatchingService.class);
        commandService = new MiniTourOrderCommandService(
                orderMapper, miniUserService, scenicSpotMapper, meetingPointMapper, pricingMapper,
                guideUserMapper, platformSettingsService, notificationService, orderRefundService);
                guideUserMapper, platformSettingsService, notificationService, orderRefundService,
                matchingService, false);
    }
    @Test
    void createShouldKeepOrderPendingPaymentUntilPaymentSucceeds() {
        MiniOrderCreateRequest request = prepareCreateOrder();
        TourOrderResponse response = commandService.create(7L, request);
        ArgumentCaptor<TourOrder> captor = ArgumentCaptor.forClass(TourOrder.class);
        verify(orderMapper).insert(captor.capture());
        TourOrder order = captor.getValue();
        assertThat(order.getStatus()).isEqualTo(OrderStatus.PENDING_PAYMENT.getCode());
        assertThat(order.getPaymentMethod()).isNull();
        assertThat(order.getPaymentOrderNo()).isNull();
        assertThat(order.getPaymentAmount()).isNull();
        assertThat(order.getPaymentTime()).isNull();
        assertThat(order.getSettlementDays()).isEqualTo(7);
        assertThat(response.getStatus()).isEqualTo(OrderStatus.PENDING_PAYMENT.getCode());
        verify(matchingService, never()).match(any());
    }
    @Test
    void createShouldRejectVisitorWithUnfinishedOrder() {
        MiniUser visitor = new MiniUser();
        visitor.setId(7L);
        when(miniUserService.findEnabledById(7L)).thenReturn(visitor);
        when(orderMapper.selectCount(any())).thenReturn(1L);
        assertThatThrownBy(() -> commandService.create(7L, new MiniOrderCreateRequest()))
                .isInstanceOf(com.tourguide.common.exception.BusinessException.class)
                .hasMessage("您有一个未完成的订单,请先完成或取消后再下单");
        verify(orderMapper, never()).insert(any(TourOrder.class));
    }
    @Test
    void createShouldMarkOrderPaidAndStartMatchingWhenPaymentIsSkipped() {
        MiniOrderCreateRequest request = prepareCreateOrder();
        commandService = new MiniTourOrderCommandService(
                orderMapper, miniUserService, scenicSpotMapper, meetingPointMapper, pricingMapper,
                guideUserMapper, platformSettingsService, notificationService, orderRefundService,
                matchingService, true);
        TourOrderResponse response = commandService.create(7L, request);
        ArgumentCaptor<TourOrder> captor = ArgumentCaptor.forClass(TourOrder.class);
        verify(orderMapper).insert(captor.capture());
        TourOrder order = captor.getValue();
        assertThat(order.getStatus()).isEqualTo(OrderStatus.PENDING_ACCEPT.getCode());
        assertThat(order.getPaymentMethod()).isEqualTo(PaymentMethod.TEMPORARY_BYPASS.getCode());
        assertThat(order.getPaymentOrderNo()).startsWith("TEMPMJ");
        assertThat(order.getPaymentAmount()).isEqualByComparingTo("144.00");
        assertThat(order.getPaymentTime()).isNotNull();
        assertThat(response.getStatus()).isEqualTo(OrderStatus.PENDING_ACCEPT.getCode());
        verify(matchingService).match(99L);
    }
    @Test
    void cancelPendingMeetingOrderShouldNotifyAssignedGuide() {
        MiniUser visitor = new MiniUser();
        visitor.setId(7L);
        when(miniUserService.findEnabledById(7L)).thenReturn(visitor);
        TourOrder order = new TourOrder();
        order.setId(99L);
        order.setOrderNo("MJ202607300001");
        order.setVisitorId(7L);
        order.setGuideId(8L);
        order.setStatus(OrderStatus.PENDING_MEETING.getCode());
        order.setPaymentTime(LocalDateTime.now());
        order.setPaymentAmount(new BigDecimal("36.00"));
        when(orderMapper.selectForUpdate(99L)).thenReturn(order);
        commandService.cancel(7L, 99L);
        assertThat(order.getStatus()).isEqualTo(OrderStatus.CANCELLED.getCode());
        verify(orderRefundService).requestRefund(order);
        verify(notificationService).notifyVisitorCancelledOrder(order);
    }
    private MiniOrderCreateRequest prepareCreateOrder() {
        MiniUser visitor = new MiniUser();
        visitor.setId(7L);
        visitor.setNickname("游客");
@@ -104,55 +185,6 @@
        request.setMeetingPointId(12L);
        request.setServicePackageId(13L);
        request.setPeopleCount(4);
        TourOrderResponse response = commandService.create(7L, request);
        ArgumentCaptor<TourOrder> captor = ArgumentCaptor.forClass(TourOrder.class);
        verify(orderMapper).insert(captor.capture());
        TourOrder order = captor.getValue();
        assertThat(order.getStatus()).isEqualTo(OrderStatus.PENDING_PAYMENT.getCode());
        assertThat(order.getPaymentMethod()).isNull();
        assertThat(order.getPaymentOrderNo()).isNull();
        assertThat(order.getPaymentAmount()).isNull();
        assertThat(order.getPaymentTime()).isNull();
        assertThat(order.getSettlementDays()).isEqualTo(7);
        assertThat(response.getStatus()).isEqualTo(OrderStatus.PENDING_PAYMENT.getCode());
    }
    @Test
    void createShouldRejectVisitorWithUnfinishedOrder() {
        MiniUser visitor = new MiniUser();
        visitor.setId(7L);
        when(miniUserService.findEnabledById(7L)).thenReturn(visitor);
        when(orderMapper.selectCount(any())).thenReturn(1L);
        assertThatThrownBy(() -> commandService.create(7L, new MiniOrderCreateRequest()))
                .isInstanceOf(com.tourguide.common.exception.BusinessException.class)
                .hasMessage("您有一个未完成的订单,请先完成或取消后再下单");
        verify(orderMapper, never()).insert(any(TourOrder.class));
    }
    @Test
    void cancelPendingMeetingOrderShouldNotifyAssignedGuide() {
        MiniUser visitor = new MiniUser();
        visitor.setId(7L);
        when(miniUserService.findEnabledById(7L)).thenReturn(visitor);
        TourOrder order = new TourOrder();
        order.setId(99L);
        order.setOrderNo("MJ202607300001");
        order.setVisitorId(7L);
        order.setGuideId(8L);
        order.setStatus(OrderStatus.PENDING_MEETING.getCode());
        order.setPaymentTime(LocalDateTime.now());
        order.setPaymentAmount(new BigDecimal("36.00"));
        when(orderMapper.selectForUpdate(99L)).thenReturn(order);
        commandService.cancel(7L, 99L);
        assertThat(order.getStatus()).isEqualTo(OrderStatus.CANCELLED.getCode());
        verify(orderRefundService).requestRefund(order);
        verify(notificationService).notifyVisitorCancelledOrder(order);
        return request;
    }
}
backend/src/test/java/com/tourguide/modules/order/service/OrderRefundServiceTest.java
@@ -1,6 +1,7 @@
package com.tourguide.modules.order.service;
import com.tourguide.modules.order.domain.RefundStatus;
import com.tourguide.modules.order.domain.PaymentMethod;
import com.tourguide.modules.order.entity.TourOrder;
import com.tourguide.modules.order.mapper.TourOrderMapper;
import com.tourguide.modules.order.payment.PaymentRefundGateway;
@@ -71,6 +72,22 @@
                org.mockito.ArgumentMatchers.any());
    }
    @Test
    void temporaryBypassRefundShouldCompleteWithoutCallingGateway() {
        TourOrder order = paidOrder();
        order.setPaymentMethod(PaymentMethod.TEMPORARY_BYPASS.getCode());
        order.setPaymentOrderNo("TEMPMJ202608030001");
        refundService.requestRefund(order);
        assertThat(order.getRefundStatus()).isEqualTo(RefundStatus.REFUNDED.getCode());
        assertThat(order.getRefundTime()).isNotNull();
        assertThat(order.getRefundMethod()).isEqualTo(PaymentMethod.TEMPORARY_BYPASS.getCode());
        verify(refundGateway, never()).refund(org.mockito.ArgumentMatchers.any(),
                org.mockito.ArgumentMatchers.any());
        verify(orderMapper).updateById(order);
    }
    private TourOrder paidOrder() {
        TourOrder order = new TourOrder();
        order.setId(99L);
data/guide-applications/1e3862aa6fda49d580017e360126062a.jpg
data/guide-applications/20563e26d1bc43c4a1aac5abc87ae480.jpg
data/guide-applications/7eff1dce4fc54626bde8a8b05021b5fd.jpg
data/guide-applications/b57f19850ed74770bf45906b9d3d2b19.jpg
mingdaojingjiang_guide_app/pages/workbench/index.vue
@@ -65,7 +65,13 @@
            </view>
          </view>
          <button v-if="isServing" class="lecture-button" @tap="finishLecture">结束讲解</button>
          <button v-else class="lecture-button" @tap="startLecture">开始讲解</button>
          <button
            v-else
            class="lecture-button"
            :loading="startingLecture"
            :disabled="startingLecture"
            @tap="startLecture"
          >开始讲解</button>
        </template>
        <template v-else>
          <view class="empty-order">暂无订单请耐心等待</view>
@@ -136,7 +142,7 @@
  updateGuideOnlineStatus
} from '../../services/guide'
import { openMeetingPointPage } from '../../services/map'
import { getGuideOrders } from '../../services/order'
import { getGuideOrders, startGuideLecture } from '../../services/order'
import { playNotificationAlert } from '../../services/message'
import { getWorkbenchData } from '../../services/workbench'
import { formatDuration, maskPhone } from '../../utils/format'
@@ -196,6 +202,7 @@
      countdownTimer: null,
      refreshTimer: null,
      refreshing: false,
      startingLecture: false,
      dialog: {
        visible: false,
        title: '提示',
@@ -480,9 +487,27 @@
      })
    },
    startLecture() {
      updateGuideOnlineStatus('serving', this.guide.receivingConfig || {}).then(() => {
        this.loadData()
      const orderId = this.activeReceivingOrder.id
      if (this.startingLecture) {
        return
      }
      if (!orderId) {
        uni.showToast({ title: '当前没有可开始的订单', icon: 'none' })
        return
      }
      this.startingLecture = true
      startGuideLecture(orderId).then((startedOrder) => {
        this.updateGuide({ ...this.guide, onlineStatus: 'serving' })
        this.updateOrders(this.orders.map((order) => (
          order.id === startedOrder.id ? { ...order, ...startedOrder } : order
        )))
        this.setupCountdown()
        uni.showToast({ title: '已开始讲解', icon: 'none' })
        return this.loadData({ silent: true })
      }).catch((error) => {
        uni.showToast({ title: error.message || '开始讲解失败', icon: 'none' })
      }).finally(() => {
        this.startingLecture = false
      })
    },
    finishLecture() {
mingdaojingjiang_guide_app/services/order.js
@@ -55,6 +55,13 @@
  return request({ url: `/guide/orders/${orderId}` }).then(normalizeGuideOrder)
}
export function startGuideLecture(orderId) {
  return request({
    url: `/guide/orders/${orderId}/start`,
    method: 'POST'
  }).then(normalizeGuideOrder)
}
function normalizeOrderTab(filter) {
  if (filter === 'active' || filter === 'pending') {
    return 'accepted'
mingdaojingjiang_uniapp/mingdaojingjiang_uniapp/pages/home/index.vue
@@ -323,14 +323,14 @@
  const textMap = {
    service: '立即呼叫讲解',
    meeting: '下一步',
    payment: '确认支付'
    payment: '确认下单'
  }
  return textMap[guideFlowStep.value] || textMap.service
})
const guideActionLoadingText = computed(() => (guideFlowStep.value === 'payment' ? '支付中...' : '处理中...'))
const guideActionLoadingText = computed(() => (guideFlowStep.value === 'payment' ? '下单中...' : '处理中...'))
const guideActionSuccessText = computed(() => (guideFlowStep.value === 'payment' ? '支付成功' : '已完成'))
const guideActionSuccessText = computed(() => (guideFlowStep.value === 'payment' ? '下单成功' : '已完成'))
const guideActionDisabled = computed(() => {
  if (guideFlowStep.value === 'meeting') return !selectedMeetingPointId.value
@@ -902,19 +902,8 @@
  const order = activeHomeOrder.value
  if (!order || !order.id) return
  const routeMap = {
    pendingPay: '/pages/order-pending-pay/index',
    waiting: '/pages/order-waiting/index',
    pendingGuide: '/pages/order-pending-guide/index',
    serving: '/pages/order-serving/index',
    completed: '/pages/order-completed/index',
    pendingReview: '/pages/order-pending-review/index',
    canceled: '/pages/order-canceled/index'
  }
  const target = routeMap[order.status] || '/pages/orders/index'
  uni.navigateTo({
    url: `${target}?orderId=${order.id}`
    url: `${getActiveOrderRoute(order)}?orderId=${order.id}`
  })
}
@@ -987,13 +976,13 @@
  try {
    paymentSubmitting.value = true
    const orderId = await createPendingGuideOrder()
    const order = await createPendingGuideOrder()
    paymentSubmitting.value = false
    paymentSuccess.value = true
    setTimeout(() => {
      uni.navigateTo({
        url: `/pages/order-pending-pay/index?orderId=${orderId}`
        url: `${getActiveOrderRoute(activeHomeOrder.value)}?orderId=${order.id}`
      })
    }, 220)
  } catch (error) {
@@ -1035,7 +1024,20 @@
    peopleCount: selectedServiceQuantity.value
  })
  activeHomeOrder.value = normalizeActiveHomeOrder(order)
  return order.id
  return order
}
function getActiveOrderRoute(order) {
  const routeMap = {
    pendingPay: '/pages/order-pending-pay/index',
    waiting: '/pages/order-waiting/index',
    pendingGuide: '/pages/order-pending-guide/index',
    serving: '/pages/order-serving/index',
    completed: '/pages/order-completed/index',
    pendingReview: '/pages/order-pending-review/index',
    canceled: '/pages/order-canceled/index'
  }
  return routeMap[order && order.status] || '/pages/orders/index'
}
function normalizeActiveHomeOrder(order) {
mingdaojingjiang_uniapp/mingdaojingjiang_uniapp/pages/order-pending-guide/index.vue
@@ -120,7 +120,7 @@
<script setup>
import { computed, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { onHide, onLoad, onShow, onUnload } from '@dcloudio/uni-app'
import AppNav from '@/components/app-nav/app-nav.vue'
import CancelOrderSheet from '@/components/cancel-order-sheet/cancel-order-sheet.vue'
import { cancelTourOrder, getTourOrder } from '@/services/order'
@@ -129,8 +129,15 @@
useLoginGuard('/pages/order-pending-guide/index')
const ORDER_POLL_INTERVAL = 3000
const orderId = ref('')
const orderDetail = ref({})
let orderPollTimer = null
let orderRefreshing = false
let pageActive = false
let redirecting = false
let loadErrorShown = false
const meetingPoint = computed(() => ({
  longitude: Number(orderDetail.value.meetingPointLongitude || 0),
@@ -186,19 +193,84 @@
onLoad((options = {}) => {
  orderId.value = options.orderId || options.id || ''
  loadOrderDetail()
})
async function loadOrderDetail() {
onShow(() => {
  pageActive = true
  redirecting = false
  startOrderPolling()
})
onHide(() => {
  pageActive = false
  stopOrderPolling()
})
onUnload(() => {
  pageActive = false
  stopOrderPolling()
})
function startOrderPolling() {
  stopOrderPolling()
  if (!orderId.value) {
    uni.showToast({ title: '订单参数缺失', icon: 'none' })
    return
  }
  try {
    orderDetail.value = await getTourOrder(orderId.value)
  } catch (error) {
    uni.showToast({ title: error.message || '订单加载失败', icon: 'none' })
  refreshOrderStatus()
  orderPollTimer = setInterval(refreshOrderStatus, ORDER_POLL_INTERVAL)
  }
function stopOrderPolling() {
  if (orderPollTimer) {
    clearInterval(orderPollTimer)
    orderPollTimer = null
  }
}
async function refreshOrderStatus() {
  if (!pageActive || !orderId.value || orderRefreshing || redirecting) return
  orderRefreshing = true
  try {
    const detail = await getTourOrder(orderId.value)
    if (!pageActive) return
    orderDetail.value = detail
    loadErrorShown = false
    redirectForOrderStatus(detail && detail.status)
  } catch (error) {
    if (!loadErrorShown) {
      loadErrorShown = true
      uni.showToast({ title: error.message || '订单状态更新失败', icon: 'none' })
    }
    console.warn('待讲解订单状态查询失败', error)
  } finally {
    orderRefreshing = false
  }
}
function redirectForOrderStatus(status) {
  const routeMap = {
    PENDING_PAYMENT: '/pages/order-pending-pay/index',
    PENDING_ACCEPT: '/pages/order-waiting/index',
    IN_SERVICE: '/pages/order-serving/index',
    COMPLETED: '/pages/order-pending-review/index',
    CANCELLED: '/pages/order-canceled/index',
    REFUNDED: '/pages/order-canceled/index'
  }
  const target = routeMap[status]
  if (!target || redirecting) return
  redirecting = true
  stopOrderPolling()
  uni.redirectTo({
    url: `${target}?orderId=${orderId.value}`,
    fail() {
      redirecting = false
      if (pageActive) startOrderPolling()
    }
  })
}
function formatAmount(value) {
mingdaojingjiang_uniapp/mingdaojingjiang_uniapp/pages/order-serving/index.vue
@@ -6,20 +6,20 @@
      <view class="status-panel">
        <text class="status-title">讲解中</text>
        <text class="countdown-text">{{ countdownText }}</text>
        <view class="team-badge" :style="familyBadgeStyle">
          <text class="team-badge__text">家庭号 {{ familyGroup.number }}</text>
        <view v-if="order.familyNo" class="team-badge" :style="familyBadgeStyle">
          <text class="team-badge__text">家庭号 {{ order.familyNo }}</text>
        </view>
      </view>
      <view class="detail-card">
        <view class="guide-row">
          <view class="guide-avatar">
            <image class="guide-avatar__icon" src="/static/ui-icons/user-blue.svg" mode="aspectFit" />
            <image class="guide-avatar__icon" :src="order.guideAvatar || '/static/ui-icons/user-blue.svg'" mode="aspectFill" />
          </view>
          <view class="guide-main">
            <text class="guide-name">导游 · 赵四</text>
            <text class="guide-name">导游 · {{ order.guideName }}</text>
            <view class="guide-stars">
              <text v-for="item in 5" :key="item" class="guide-star">★</text>
              <text v-for="item in 5" :key="item" class="guide-star" :class="{ 'guide-star--inactive': item > guideStarCount }">★</text>
            </view>
          </view>
          <view class="phone-action" @tap="handleCallGuide">
@@ -42,7 +42,7 @@
          <view class="price-row">
            <view class="unit-block">
              <text class="unit-price">¥{{ order.unitPrice }}/人</text>
              <text class="unit-price">¥{{ order.unitPrice }}{{ order.unit }}</text>
              <text class="quantity">x{{ order.quantity }}</text>
            </view>
            <text class="total-price">¥{{ order.total }}</text>
@@ -68,34 +68,45 @@
</template>
<script setup>
import { computed, onBeforeUnmount, ref } from 'vue'
import { computed, ref } from 'vue'
import { onHide, onLoad, onShow, onUnload } from '@dcloudio/uni-app'
import AppNav from '@/components/app-nav/app-nav.vue'
import { getTourOrder } from '@/services/order'
import { resolveProfileAssetUrl } from '@/services/profile'
import { useLoginGuard } from '@/utils/auth-guard'
useLoginGuard('/pages/order-serving/index')
const order = {
  id: 'MJ20260612003',
  scenic: '青城山景区',
  service: '拼团·快讲',
  duration: '30分钟',
  meetingPoint: '青城山正门汇合',
  unitPrice: 36,
  quantity: 4,
  total: '366.00'
}
const ORDER_POLL_INTERVAL = 3000
const familyGroup = {
  number: 1,
  color: '#278BFF'
}
const orderId = ref('')
const orderDetail = ref({})
const remainingSeconds = ref(0)
let countdownTimer = null
let orderPollTimer = null
let orderRefreshing = false
let pageActive = false
let redirecting = false
let loadErrorShown = false
const remainingSeconds = ref(30 * 60 * 60)
const timer = setInterval(() => {
  if (remainingSeconds.value > 0) {
    remainingSeconds.value -= 1
  }
}, 1000)
const order = computed(() => ({
  id: orderDetail.value.id || orderId.value,
  scenic: orderDetail.value.scenicName || '-',
  service: formatServiceTitle(orderDetail.value.packageName, orderDetail.value.orderType),
  duration: orderDetail.value.durationMinutes ? `${orderDetail.value.durationMinutes}分钟` : '-',
  meetingPoint: orderDetail.value.meetingPointName || '-',
  unitPrice: formatAmount(orderDetail.value.unitPrice),
  unit: String(orderDetail.value.orderType || '').startsWith('GROUP_') ? '/人' : '/团',
  quantity: Number(orderDetail.value.peopleCount || 1),
  total: formatAmount(orderDetail.value.paymentAmount ?? orderDetail.value.amount),
  familyNo: orderDetail.value.familyNo || '',
  guideName: orderDetail.value.guideName || '未分配',
  guideMobile: orderDetail.value.guideMobile || '',
  guideAvatar: resolveProfileAssetUrl(orderDetail.value.guideAvatar),
  guideRating: Number(orderDetail.value.guideRating || 0)
}))
const guideStarCount = computed(() => Math.max(0, Math.min(5, Math.round(order.value.guideRating))))
const countdownText = computed(() => {
  const hours = Math.floor(remainingSeconds.value / 3600)
@@ -105,36 +116,159 @@
})
const familyBadgeStyle = computed(() => ({
  backgroundColor: familyGroup.color
  backgroundColor: '#278BFF'
}))
const detailRows = [
  { label: '下单时间', value: '2026-06-04 11:31:02' },
  { label: '接单时间', value: '2026-06-04 11:31:02' },
  { label: '支付金额', value: '¥366.00' },
  { label: '支付流水号', value: '46145113464411' },
  { label: '讲解开始时间', value: '2026-06-04 11:31:02' },
  { label: '讲解结束时间', value: '-' }
]
const detailRows = computed(() => [
  { label: '下单时间', value: formatDateTime(orderDetail.value.placedAt) },
  { label: '接单时间', value: formatDateTime(orderDetail.value.acceptedAt) },
  { label: '支付金额', value: `¥${formatAmount(orderDetail.value.paymentAmount ?? orderDetail.value.amount)}` },
  { label: '支付流水号', value: orderDetail.value.paymentOrderNo || '-' },
  { label: '讲解开始时间', value: formatDateTime(orderDetail.value.serviceStartedAt) },
  { label: '讲解结束时间', value: formatDateTime(orderDetail.value.completedAt) }
])
onBeforeUnmount(() => {
  clearInterval(timer)
onLoad((options = {}) => {
  orderId.value = options.orderId || options.id || ''
})
onShow(() => {
  pageActive = true
  redirecting = false
  startOrderPolling()
  startCountdown()
})
onHide(() => {
  pageActive = false
  clearTimers()
})
onUnload(() => {
  pageActive = false
  clearTimers()
})
function startOrderPolling() {
  if (!orderId.value) {
    uni.showToast({ title: '订单参数缺失', icon: 'none' })
    return
  }
  clearInterval(orderPollTimer)
  refreshOrderStatus()
  orderPollTimer = setInterval(refreshOrderStatus, ORDER_POLL_INTERVAL)
}
async function refreshOrderStatus() {
  if (!pageActive || !orderId.value || orderRefreshing || redirecting) return
  orderRefreshing = true
  try {
    const detail = await getTourOrder(orderId.value)
    if (!pageActive) return
    orderDetail.value = detail
    loadErrorShown = false
    syncCountdown(detail)
    redirectForOrderStatus(detail && detail.status)
  } catch (error) {
    if (!loadErrorShown) {
      loadErrorShown = true
      uni.showToast({ title: error.message || '订单状态更新失败', icon: 'none' })
    }
    console.warn('讲解中订单状态查询失败', error)
  } finally {
    orderRefreshing = false
  }
}
function syncCountdown(detail) {
  const durationSeconds = Math.max(0, Number(detail && detail.durationMinutes) || 0) * 60
  const startedAt = parseDateTime(detail && detail.serviceStartedAt)
  const elapsedSeconds = startedAt ? Math.max(0, Math.floor((Date.now() - startedAt) / 1000)) : 0
  remainingSeconds.value = Math.max(0, durationSeconds - elapsedSeconds)
}
function startCountdown() {
  clearInterval(countdownTimer)
  countdownTimer = setInterval(() => {
    if (remainingSeconds.value > 0) remainingSeconds.value -= 1
  }, 1000)
}
function redirectForOrderStatus(status) {
  const routeMap = {
    PENDING_PAYMENT: '/pages/order-pending-pay/index',
    PENDING_ACCEPT: '/pages/order-waiting/index',
    PENDING_MEETING: '/pages/order-pending-guide/index',
    COMPLETED: '/pages/order-pending-review/index',
    CANCELLED: '/pages/order-canceled/index',
    REFUNDED: '/pages/order-canceled/index'
  }
  const target = routeMap[status]
  if (!target || redirecting) return
  redirecting = true
  clearTimers()
  uni.redirectTo({
    url: `${target}?orderId=${orderId.value}`,
    fail() {
      redirecting = false
      if (pageActive) {
        startOrderPolling()
        startCountdown()
      }
    }
  })
}
function clearTimers() {
  if (countdownTimer) {
    clearInterval(countdownTimer)
    countdownTimer = null
  }
  if (orderPollTimer) {
    clearInterval(orderPollTimer)
    orderPollTimer = null
  }
}
function parseDateTime(value) {
  if (!value) return 0
  const timestamp = new Date(String(value).replace(' ', 'T')).getTime()
  return Number.isFinite(timestamp) ? timestamp : 0
}
function formatTime(value) {
  return String(value).padStart(2, '0')
}
function handleCallGuide() {
  uni.showActionSheet({
    itemList: ['拨打导游电话'],
    success() {
      uni.showToast({
        title: '电话联系开发中',
        icon: 'none'
      })
function formatAmount(value) {
  const amount = Number(value || 0)
  return Number.isFinite(amount) ? amount.toFixed(2) : '0.00'
    }
  })
function formatDateTime(value) {
  return value ? String(value).replace('T', ' ').slice(0, 19) : '-'
}
function formatServiceTitle(packageName, orderType) {
  if (packageName) return String(packageName).replace(/\s*·\s*/g, '·')
  const serviceTitleMap = {
    GROUP_QUICK: '拼团·快讲',
    GROUP_PREMIUM: '拼团·精讲',
    PRIVATE_QUICK: '包团·快讲',
    PRIVATE_PREMIUM: '包团·精讲'
  }
  return serviceTitleMap[orderType] || '讲解服务'
}
function handleCallGuide() {
  if (!order.value.guideMobile) {
    uni.showToast({ title: '暂无导游联系电话', icon: 'none' })
    return
  }
  uni.makePhoneCall({ phoneNumber: order.value.guideMobile })
}
</script>
@@ -256,6 +390,10 @@
  line-height: 1;
}
.guide-star--inactive {
  color: #d8dde6;
}
.phone-action {
  display: flex;
  flex-direction: column;