liujie
2026-09-14 a910819c1853081735356c7a2c5e05a2953602ab
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
package com.tourguide.modules.order.service;
 
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.tourguide.common.exception.BusinessException;
import com.tourguide.modules.operation.entity.OperationScenicSpot;
import com.tourguide.modules.operation.entity.ScenicMeetingPoint;
import com.tourguide.modules.operation.entity.ScenicPricing;
import com.tourguide.modules.operation.mapper.OperationScenicSpotMapper;
import com.tourguide.modules.operation.mapper.ScenicMeetingPointMapper;
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;
import com.tourguide.modules.order.entity.TourOrder;
import com.tourguide.modules.order.mapper.TourOrderMapper;
import com.tourguide.modules.system.service.GuideNotificationService;
import com.tourguide.modules.user.entity.MiniUser;
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;
 
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.UUID;
 
/** 小程序讲解订单创建及状态流转服务。 */
@Service
public class MiniTourOrderCommandService {
    private static final int DEFAULT_UNPAID_CANCEL_MINUTES = 15;
    private final TourOrderMapper orderMapper;
    private final MiniUserService miniUserService;
    private final OperationScenicSpotMapper scenicSpotMapper;
    private final ScenicMeetingPointMapper meetingPointMapper;
    private final ScenicPricingMapper pricingMapper;
    private final GuideUserMapper guideUserMapper;
    private final PlatformSettingsService platformSettingsService;
    private final GuideNotificationService notificationService;
    private final OrderRefundService orderRefundService;
    private final GuideOrderMatchingService matchingService;
    private final GuideSessionTransitionService sessionTransitionService;
    private final boolean skipPayment;
 
    public MiniTourOrderCommandService(TourOrderMapper orderMapper,
                                       MiniUserService miniUserService,
                                       OperationScenicSpotMapper scenicSpotMapper,
                                       ScenicMeetingPointMapper meetingPointMapper,
                                       ScenicPricingMapper pricingMapper,
                                       GuideUserMapper guideUserMapper,
                                       PlatformSettingsService platformSettingsService,
                                       GuideNotificationService notificationService,
                                       OrderRefundService orderRefundService,
                                       GuideOrderMatchingService matchingService,
                                       GuideSessionTransitionService sessionTransitionService,
                                       @Value("${tourguide.order.skip-payment:false}") boolean skipPayment) {
        this.orderMapper = orderMapper;
        this.miniUserService = miniUserService;
        this.scenicSpotMapper = scenicSpotMapper;
        this.meetingPointMapper = meetingPointMapper;
        this.pricingMapper = pricingMapper;
        this.guideUserMapper = guideUserMapper;
        this.platformSettingsService = platformSettingsService;
        this.notificationService = notificationService;
        this.orderRefundService = orderRefundService;
        this.matchingService = matchingService;
        this.sessionTransitionService = sessionTransitionService;
        this.skipPayment = skipPayment;
    }
 
    @Transactional
    public TourOrderResponse create(Long visitorId, MiniOrderCreateRequest request) {
        MiniUser visitor = miniUserService.findEnabledById(visitorId);
        ensureNoUnfinishedOrder(visitor.getId());
        OperationScenicSpot scenicSpot = requireScenicSpot(request.getScenicSpotId());
        ScenicMeetingPoint meetingPoint = requireMeetingPoint(request.getMeetingPointId(), scenicSpot.getId());
        ScenicPricing pricing = requirePricing(request.getServicePackageId(), scenicSpot.getId());
        validatePeopleCount(pricing, request.getPeopleCount());
 
        boolean groupService = pricing.getServiceType().startsWith("GROUP_");
        BigDecimal originalUnitPrice = valueOrZero(pricing.getOriginalPrice());
        BigDecimal discountUnitPrice = valueOrZero(pricing.getDiscountPrice());
        BigDecimal multiplier = groupService ? BigDecimal.valueOf(request.getPeopleCount()) : BigDecimal.ONE;
        BigDecimal originalAmount = originalUnitPrice.multiply(multiplier);
        BigDecimal subtotalAmount = discountUnitPrice.multiply(multiplier);
        BigDecimal discountAmount = originalAmount.subtract(subtotalAmount).max(BigDecimal.ZERO);
        LocalDateTime now = LocalDateTime.now();
 
        TourOrder order = new TourOrder();
        order.setOrderNo(generateOrderNo());
        order.setVisitorId(visitor.getId());
        order.setVisitorNickname(visitor.getNickname());
        order.setVisitorMobile(visitor.getMobile());
        order.setScenicSpotId(scenicSpot.getId());
        order.setScenicName(scenicSpot.getScenicName());
        order.setMeetingPointId(meetingPoint.getId());
        order.setMeetingPointName(meetingPoint.getPointName());
        order.setPackageName(packageName(pricing.getServiceType()));
        order.setServicePackageId(pricing.getId());
        order.setOrderType(pricing.getServiceType());
        order.setPeopleCount(request.getPeopleCount());
        order.setUnitPrice(discountUnitPrice);
        order.setOriginalPrice(originalAmount);
        order.setDiscountPrice(discountUnitPrice);
        order.setSubtotalAmount(subtotalAmount);
        order.setDiscountType(discountAmount.signum() > 0 ? "SCENIC_PRICE" : "NONE");
        order.setDiscountAmount(discountAmount);
        order.setAmount(subtotalAmount);
        order.setPlacedAt(now);
        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);
    }
 
    public TourOrderResponse active(Long visitorId) {
        miniUserService.findEnabledById(visitorId);
        TourOrder order = orderMapper.selectOne(
                new LambdaQueryWrapper<TourOrder>()
                        .eq(TourOrder::getVisitorId, visitorId)
                        .in(TourOrder::getStatus, OrderStatus.unfinishedStatusCodes())
                        .orderByDesc(TourOrder::getId)
                        .last("LIMIT 1"));
        return order == null ? null : TourOrderResponse.from(order);
    }
 
    public TourOrderResponse detail(Long visitorId, Long orderId) {
        TourOrder order = requireVisitorOrder(visitorId, orderId);
        OperationScenicSpot scenicSpot = scenicSpotMapper.selectOne(
                new LambdaQueryWrapper<OperationScenicSpot>()
                        .eq(OperationScenicSpot::getScenicName, order.getScenicName())
                        .orderByDesc(OperationScenicSpot::getId)
                        .last("LIMIT 1"));
        ScenicMeetingPoint meetingPoint = scenicSpot == null ? null : meetingPointMapper.selectOne(
                new LambdaQueryWrapper<ScenicMeetingPoint>()
                        .eq(ScenicMeetingPoint::getScenicSpotId, scenicSpot.getId())
                        .eq(ScenicMeetingPoint::getPointName, order.getMeetingPointName())
                        .orderByDesc(ScenicMeetingPoint::getId)
                        .last("LIMIT 1"));
        ScenicPricing pricing = scenicSpot == null ? null : pricingMapper.selectOne(
                new LambdaQueryWrapper<ScenicPricing>()
                        .eq(ScenicPricing::getScenicSpotId, scenicSpot.getId())
                        .eq(ScenicPricing::getServiceType, order.getOrderType())
                        .orderByDesc(ScenicPricing::getId)
                        .last("LIMIT 1"));
        GuideUser guide = order.getGuideId() == null ? null : guideUserMapper.selectById(order.getGuideId());
        int unpaidCancelMinutes = scenicSpot == null || scenicSpot.getUnpaidCancelMinutes() == null
                || scenicSpot.getUnpaidCancelMinutes() < 1
                ? DEFAULT_UNPAID_CANCEL_MINUTES : scenicSpot.getUnpaidCancelMinutes();
        cancelExpiredUnpaidIfNeeded(order, unpaidCancelMinutes);
        return TourOrderResponse.from(order)
                .enrich(scenicSpot, meetingPoint, pricing, guide)
                .withUnpaidCancelMinutes(unpaidCancelMinutes)
                .withUnacceptedAutoRefundMinutes(
                        platformSettingsService.getSettings().getUnacceptedAutoRefundMinutes());
    }
 
    /** 读取订单时惰性取消已超时的未支付订单,避免等待定时任务扫描延迟。 */
    private void cancelExpiredUnpaidIfNeeded(TourOrder order, int unpaidCancelMinutes) {
        if (!OrderStatus.PENDING_PAYMENT.matches(order.getStatus())) return;
        LocalDateTime baseTime = order.getPlacedAt() == null ? order.getCreatedAt() : order.getPlacedAt();
        if (baseTime == null || baseTime.plusMinutes(unpaidCancelMinutes).isAfter(LocalDateTime.now())) return;
        if (orderMapper.cancelUnpaid(order.getId()) > 0) {
            order.setStatus(OrderStatus.CANCELLED.getCode());
            order.setCancelledAt(LocalDateTime.now());
        }
    }
 
    @Transactional
    public TourOrderResponse cancel(Long visitorId, Long orderId) {
        TourOrder order = orderMapper.selectForUpdate(orderId);
        if (order == null || visitorId == null || !visitorId.equals(order.getVisitorId())) {
            throw new BusinessException(404, "订单不存在");
        }
        if (OrderStatus.CANCELLED.matches(order.getStatus())) {
            return TourOrderResponse.from(order);
        }
        boolean notifyGuide = OrderStatus.PENDING_MEETING.matches(order.getStatus()) && order.getGuideId() != null;
        if (!OrderStatus.PENDING_PAYMENT.matches(order.getStatus())
                && !OrderStatus.PENDING_ACCEPT.matches(order.getStatus())
                && !OrderStatus.PENDING_MEETING.matches(order.getStatus())) {
            throw new BusinessException(409, "当前订单状态不支持取消");
        }
        order.setStatus(OrderStatus.CANCELLED.getCode());
        order.setCancelledAt(LocalDateTime.now());
        if (order.getPaymentTime() == null) {
            orderMapper.updateById(order);
        } else {
            orderRefundService.requestRefund(order);
        }
        if (notifyGuide) sessionTransitionService.restoreAfterOrderCancellation(order);
        if (notifyGuide) notificationService.notifyVisitorCancelledOrder(order);
        return TourOrderResponse.from(order);
    }
 
    private OperationScenicSpot requireScenicSpot(Long id) {
        OperationScenicSpot scenicSpot = scenicSpotMapper.selectOne(
                new LambdaQueryWrapper<OperationScenicSpot>()
                        .eq(OperationScenicSpot::getId, id)
                        .eq(OperationScenicSpot::getStatus, 1));
        if (scenicSpot == null) throw new BusinessException(404, "景区不存在或未启用");
        return scenicSpot;
    }
 
    private void ensureNoUnfinishedOrder(Long visitorId) {
        Long count = orderMapper.selectCount(
                new LambdaQueryWrapper<TourOrder>()
                        .eq(TourOrder::getVisitorId, visitorId)
                        .in(TourOrder::getStatus, OrderStatus.unfinishedStatusCodes()));
        if (count != null && count > 0) {
            throw new BusinessException(409, "您有一个未完成的订单,请先完成或取消后再下单");
        }
    }
 
    private ScenicMeetingPoint requireMeetingPoint(Long id, Long scenicSpotId) {
        ScenicMeetingPoint point = meetingPointMapper.selectOne(
                new LambdaQueryWrapper<ScenicMeetingPoint>()
                        .eq(ScenicMeetingPoint::getId, id)
                        .eq(ScenicMeetingPoint::getScenicSpotId, scenicSpotId)
                        .eq(ScenicMeetingPoint::getStatus, 1));
        if (point == null) throw new BusinessException(400, "汇合点不属于当前景区或已停用");
        return point;
    }
 
    private ScenicPricing requirePricing(Long id, Long scenicSpotId) {
        ScenicPricing pricing = pricingMapper.selectOne(
                new LambdaQueryWrapper<ScenicPricing>()
                        .eq(ScenicPricing::getId, id)
                        .eq(ScenicPricing::getScenicSpotId, scenicSpotId));
        if (pricing == null) throw new BusinessException(400, "讲解套餐不属于当前景区");
        if (pricing.getDiscountPrice() == null || pricing.getDiscountPrice().signum() <= 0) {
            throw new BusinessException(409, "讲解套餐价格未配置");
        }
        return pricing;
    }
 
    private TourOrder requireVisitorOrder(Long visitorId, Long orderId) {
        miniUserService.findEnabledById(visitorId);
        TourOrder order = orderMapper.selectOne(new LambdaQueryWrapper<TourOrder>()
                .eq(TourOrder::getId, orderId)
                .eq(TourOrder::getVisitorId, visitorId));
        if (order == null) throw new BusinessException(404, "订单不存在");
        return order;
    }
 
    private void validatePeopleCount(ScenicPricing pricing, Integer peopleCount) {
        if (pricing.getServiceType().startsWith("GROUP_")
                && pricing.getMaxParticipants() != null
                && peopleCount > pricing.getMaxParticipants()) {
            throw new BusinessException(400, "讲解人数不能超过套餐人数上限");
        }
    }
 
    private BigDecimal valueOrZero(BigDecimal value) {
        return value == null ? BigDecimal.ZERO : value;
    }
 
    private String packageName(String serviceType) {
        if ("GROUP_QUICK".equals(serviceType)) return "拼团 · 快讲";
        if ("GROUP_PREMIUM".equals(serviceType)) return "拼团 · 精讲";
        if ("PRIVATE_QUICK".equals(serviceType)) return "包团 · 快讲";
        if ("PRIVATE_PREMIUM".equals(serviceType)) return "包团 · 精讲";
        throw new BusinessException(400, "不支持的讲解套餐类型");
    }
 
    private String generateOrderNo() {
        String time = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmssSSS"));
        String random = UUID.randomUUID().toString().replace("-", "").substring(0, 5).toUpperCase();
        return "MJ" + time + random;
    }
}