package com.ruoyi.app.flashdelivery.service; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper; import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ruoyi.app.flashdelivery.dto.*; import com.ruoyi.app.flashdelivery.exception.FlashDeliveryQuoteChangedException; import com.ruoyi.app.flashdelivery.route.FlashDeliveryRouteService; import com.ruoyi.app.flashdelivery.route.GeoPoint; import com.ruoyi.app.flashdelivery.route.RouteDistance; import com.ruoyi.app.order.RiderDeliveryAcceptanceService; import com.ruoyi.app.order.RiderDeliveryLockService; import com.ruoyi.common.core.domain.entity.SysDictData; import com.ruoyi.common.exception.ServiceException; import com.ruoyi.common.utils.DictUtils; import com.ruoyi.common.utils.MessageUtils; import com.ruoyi.common.utils.StringUtils; import com.ruoyi.system.domain.InfoUser; import com.ruoyi.system.domain.flash.*; import com.ruoyi.system.mapper.InfoUserMapper; import com.ruoyi.system.mapper.flash.*; import com.ruoyi.system.service.PaymentMethodGateService; import org.springframework.beans.BeanUtils; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.transaction.support.TransactionSynchronizationManager; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.net.URI; import java.math.BigDecimal; import java.math.RoundingMode; import java.security.SecureRandom; import java.time.Instant; import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.util.*; import java.util.function.Function; import static com.ruoyi.system.domain.flash.FlashDeliveryStatus.*; /** * 闪送业务编排服务。 * *

负责报价、订单创建、抢单、履约状态流转、平台介入和审计日志。 * 订单身份只接受 Controller 从 token 解析后的用户 ID,客户端传入的金额、距离或操作人均不可信。

*/ @Service public class FlashDeliveryApplicationService { private static final List SERVICE_TYPES = List.of("HELP_SEND", "HELP_PICKUP"); private static final Set DELIVERY_TYPES = Set.of("NORMAL", "URGENT"); private static final Set PACKAGE_TYPES = Set.of("DOCUMENT", "GIFT", "CLOTHING", "BEAUTY", "DAILY_NECESSITIES", "FOOD_INGREDIENTS", "ELECTRONICS", "SMALL_APPLIANCE", "OTHER"); private static final Set WEIGHT_RANGES = Set.of("UP_TO_5_KG", "OVER_5_TO_10_KG", "OVER_10_TO_15_KG", "OVER_15_TO_20_KG"); private static final Set DELIVERY_MODES = Set.of("NOW", "SCHEDULED"); /** 支付方式取值与主订单系统同域:4=现金、6=线下转账(027 线下转账支付)。 */ private static final String PAY_TYPE_CASH = "4"; private static final DateTimeFormatter PRICING_TIME_FORMAT = DateTimeFormatter.ofPattern("HH:mm"); private static final ZoneId TAIPEI = ZoneId.of("Asia/Taipei"); private static final int MAX_DISTANCE_METERS = 40_000; private static final BigDecimal MAX_PRICING_DISTANCE = new BigDecimal("999999.99"); 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(); private final FlashDeliveryOrderMapper orderMapper; private final FlashDeliveryOrderStopMapper stopMapper; private final FlashDeliveryTargetAdjustmentMapper adjustmentMapper; private final FlashDeliveryPricingMapper pricingMapper; private final FlashDeliveryOrderImageMapper imageMapper; private final FlashDeliveryOrderLogMapper logMapper; private final InfoUserMapper userMapper; private final FlashDeliveryRouteService routeService; private final FlashDeliveryPricingCalculator pricingCalculator; private final RiderDeliveryLockService riderDeliveryLockService; private final RiderDeliveryAcceptanceService riderDeliveryAcceptanceService; /** 031 支付闸门:闪送下单、报价展示和骑手待抢列表使用同一可用性规则。 */ private final PaymentMethodGateService paymentMethodGateService; public FlashDeliveryApplicationService(FlashDeliveryOrderMapper orderMapper, FlashDeliveryPricingMapper pricingMapper, FlashDeliveryOrderImageMapper imageMapper, FlashDeliveryOrderLogMapper logMapper, InfoUserMapper userMapper, FlashDeliveryRouteService routeService, FlashDeliveryPricingCalculator pricingCalculator, RiderDeliveryLockService riderDeliveryLockService, RiderDeliveryAcceptanceService riderDeliveryAcceptanceService, FlashDeliveryOrderStopMapper stopMapper, FlashDeliveryTargetAdjustmentMapper adjustmentMapper, PaymentMethodGateService paymentMethodGateService) { this.orderMapper = orderMapper; this.stopMapper = stopMapper; this.adjustmentMapper = adjustmentMapper; this.pricingMapper = pricingMapper; this.imageMapper = imageMapper; this.logMapper = logMapper; this.userMapper = userMapper; this.routeService = routeService; this.pricingCalculator = pricingCalculator; this.riderDeliveryLockService = riderDeliveryLockService; this.riderDeliveryAcceptanceService = riderDeliveryAcceptanceService; this.paymentMethodGateService = paymentMethodGateService; } /** 闪送首页:返回开放的业务场景、配送等级,以及当前时段各服务的公开运价摘要。 */ public FlashDeliveryHomeView home() { FlashDeliveryHomeView view = new FlashDeliveryHomeView(); view.setPickupStopLimit(pickupStopLimit()); view.setShowMultiPickup(dictionaryEnabled("flash_show_multi_pickup")); view.setShowUrgentOption(dictionaryEnabled("flash_show_urgent_option")); view.setServiceTypes(SERVICE_TYPES); view.setDeliveryTypes(List.of("NORMAL", "URGENT")); FlashDeliveryPricing pricing = findPricingAtOrNull(pricingTargetTime(new Date()), FlashDeliveryVehicleType.MOTORCYCLE); view.setServices(pricing == null ? List.of() : SERVICE_TYPES.stream() .map(serviceType -> serviceView(pricing, serviceType)).toList()); view.setPayMethods(paymentMethodGateService.listAvailable( PaymentMethodGateService.GateScope.RIDER_FLASH, null, null)); return view; } /** 实时报价:校验请求后按服务端路线距离和命中时段的运价计算费用(立即单取当前时段、预约单取预约开始时刻),不写库。 */ public FlashDeliveryQuoteView quote(FlashDeliveryQuoteRequest request) { // 报价只使用服务端路线距离和当前启用的计价配置,避免客户端篡改金额。 QuoteContext context = calculateQuote(request); return quoteView(request, context); } /** 将报价上下文组装为对外报价视图,含运价快照字段与费用明细。 */ private FlashDeliveryQuoteView quoteView(FlashDeliveryQuoteRequest request, QuoteContext context) { FlashDeliveryQuoteView view = new FlashDeliveryQuoteView(); view.setServiceType(request.getServiceType()); view.setDeliveryType(request.getDeliveryType()); view.setVehicleType(request.getVehicleType()); view.setDeliveryMode(normalizeDeliveryMode(request.getDeliveryMode())); view.setScheduledPickupStartAt(request.getScheduledPickupStartAt()); view.setScheduledPickupEndAt(request.getScheduledPickupEndAt()); view.setPricingId(context.pricing().getId()); view.setStartTime(context.pricing().getStartTime()); view.setEndTime(context.pricing().getEndTime()); view.setDistanceMeters(context.route().distanceMeters()); view.setDistanceSource(context.route().source()); view.setEstimatedDurationSeconds(context.route().durationSeconds()); view.setStartingDistance(context.pricing().getStartingDistance()); view.setStartingFare(context.pricing().getStartingFare()); view.setDistance(context.pricing().getDistance()); view.setFreight(context.pricing().getFreight()); view.setUrgentRate(context.pricing().getUrgentRate()); view.setMinimumUrgentFee(context.pricing().getMinimumUrgentFee()); applyBreakdown(view, context.breakdown()); view.setCurrency("TWD"); view.setPricingVersion(context.pricing().getConfigVersion()); view.setPayMethods(paymentMethodGateService.listAvailable( PaymentMethodGateService.GateScope.RIDER_FLASH, null, null)); return view; } /** * 幂等创建订单:clientRequestId 与 userId 构成唯一键,重复提交直接返回已有订单。 * 创建前按最新条件重新报价,客户端回传报价不一致时抛出 FlashDeliveryQuoteChangedException; * 成功后保存计价快照、寄件图片和初始状态日志。 */ @Transactional public FlashDeliveryOrderDetailView create(Long userId, FlashDeliveryCreateRequest request) { requireUserId(userId); if (request == null || !hasText(request.getClientRequestId()) || request.getClientRequestId().trim().length() > 64) { throw fail("flash.delivery.client.request.id.invalid"); } String requestId = request.getClientRequestId().trim(); // 用户 ID + clientRequestId 构成幂等键;重复请求直接返回第一次创建的订单。 FlashDeliveryOrder existing = orderMapper.selectByUserRequestId(userId, requestId); if (existing != null) return participantDetail(lockOrder(existing.getId()), true, false); if (request.getUserNote() != null && request.getUserNote().length() > 500) { throw fail("flash.delivery.note.too.long"); } QuoteContext quote = calculateQuote(request); FlashDeliveryQuoteView latestQuote = quoteView(request, quote); if (!matchesQuotedPrice(request, latestQuote)) { throw new FlashDeliveryQuoteChangedException(latestQuote); } Date now = new Date(); String deliveryMode = normalizeDeliveryMode(request.getDeliveryMode()); validateSchedule(deliveryMode, request.getScheduledPickupStartAt(), request.getScheduledPickupEndAt(), now); boolean pinRequired = request.getPinRequired() == null || request.getPinRequired(); String payType = normalizePayType(request.getPayType()); List senderImages = validateProofUrls(request.getSenderImageUrls(), false); FlashDeliveryOrder order = new FlashDeliveryOrder(); order.setOrderNo("FD" + UUID.randomUUID().toString().replace("-", "").substring(0, 24).toUpperCase(Locale.ROOT)); order.setClientRequestId(requestId); order.setUserId(userId); order.setServiceType(request.getServiceType()); order.setDeliveryType(request.getDeliveryType()); order.setVehicleType(request.getVehicleType()); order.setStatus(WAITING_ACCEPTANCE); copyPickupSummary(order, quote.pickups()); order.setPickedUpStopCount(0); order.setDeliveryMode(deliveryMode); order.setScheduledPickupStartAt("SCHEDULED".equals(deliveryMode) ? request.getScheduledPickupStartAt() : null); order.setScheduledPickupEndAt("SCHEDULED".equals(deliveryMode) ? request.getScheduledPickupEndAt() : null); order.setPinRequired(pinRequired); order.setDeliveryPinCode(pinRequired ? String.format(Locale.ROOT, "%04d", PIN_RANDOM.nextInt(10_000)) : null); copyAddress(request.getDelivery(), order, false); order.setReceiverUserId(resolveReceiverUserId(order.getDeliveryPhone())); order.setDistanceMeters(quote.route().distanceMeters()); order.setDistanceSource(quote.route().source()); order.setEstimatedDurationSeconds(quote.route().durationSeconds()); order.setAmount(quote.breakdown().getAmount()); order.setCurrency("TWD"); order.setPayType(payType); order.setPricingId(quote.pricing().getId()); order.setPricingVersion(quote.pricing().getConfigVersion()); order.setPricingStartTime(quote.pricing().getStartTime()); order.setPricingEndTime(quote.pricing().getEndTime()); order.setStartingDistance(quote.pricing().getStartingDistance()); order.setStartingFare(quote.pricing().getStartingFare()); order.setDistance(quote.pricing().getDistance()); order.setFreight(quote.pricing().getFreight()); // 保存计价快照,后台调价只影响新订单,不追溯修改历史订单金额。 order.setDistanceFee(quote.breakdown().getDistanceFee()); order.setBaseDeliveryFee(quote.breakdown().getBaseDeliveryFee()); order.setUrgentRate(quote.pricing().getUrgentRate()); order.setMinimumUrgentFee(quote.pricing().getMinimumUrgentFee()); order.setUrgentFee(quote.breakdown().getUrgentFee()); order.setTipAmount(quote.breakdown().getTipAmount()); order.setUserNote(trimToNull(request.getUserNote())); order.setVersion(0); order.setCreateTime(now); order.setUpdateTime(now); try { orderMapper.insert(order); } catch (DataIntegrityViolationException duplicate) { // 并发重复请求可能同时通过前置查询,最终由数据库唯一键完成幂等收口。 FlashDeliveryOrder concurrent = orderMapper.selectOne(new QueryWrapper() .eq("user_id", userId).eq("client_request_id", requestId).last("FOR UPDATE")); if (concurrent != null) return participantDetail(lockOrder(concurrent.getId()), true, false); throw duplicate; } saveStops(order, quote.pickups(), request.getDelivery(), now); saveImages(order.getId(), "SENDER", "USER", userId, senderImages, now); writeLog(order.getId(), null, WAITING_ACCEPTANCE, "USER", userId, null, now); return participantDetail(order, true, false); } /** 分页查询当前用户参与的订单卡片;role=sender 按寄件人、receiver 按收件人过滤。 */ public IPage userOrders(Long userId, int pageNum, int pageSize, String role) { String selectedRole = hasText(role) ? role.trim().toLowerCase(Locale.ROOT) : "sender"; if (!"sender".equals(selectedRole) && !"receiver".equals(selectedRole)) { throw fail("flash.delivery.tab.invalid"); } String participantColumn = "receiver".equals(selectedRole) ? "receiver_user_id" : "user_id"; QueryWrapper query = new QueryWrapper() .eq(participantColumn, userId).orderByDesc("create_time"); return mapPage(orderMapper.selectPage(page(pageNum, pageSize), query), order -> { FlashDeliveryUserOrderListView view = userOrderListView(order); view.setCanUserCancel(Objects.equals(userId, order.getUserId()) && canUserCancel(order)); return view; }); } /** 查询本人订单详情,仅寄件人和匹配到的收件人可见。 */ public FlashDeliveryOrderDetailView userDetail(Long userId, Long orderId) { FlashDeliveryOrder order = requireOrder(orderId); if (!isParticipant(userId, order)) throw fail("flash.delivery.order.not.found"); FlashDeliveryOrderDetailView view = participantDetail(order, true, false); view.setCanUserCancel(Objects.equals(userId, order.getUserId()) && canUserCancel(order)); return view; } /** 地址修改先报价,用户确认前不改动订单。 */ public FlashDeliveryQuoteView quoteAddress(Long userId, Long orderId, FlashDeliveryAddressChangeRequest request) { if (request == null) throw fail("flash.delivery.request.required"); FlashDeliveryOrder order = requireEditableOrder(userId, orderId, request.getOrderVersion()); return calculateAddressQuote(order, request); } /** 确认修改待接单订单地址:重算报价并与回传值比对,按版本条件更新。 */ @Transactional public FlashDeliveryOrderDetailView updateAddress(Long userId, Long orderId, FlashDeliveryAddressConfirmRequest request) { if (request == null) throw fail("flash.delivery.request.required"); FlashDeliveryOrder order = requireEditableOrder(userId, orderId, request.getOrderVersion()); FlashDeliveryQuoteView quote = calculateAddressQuote(order, request); if (!Objects.equals(request.getQuotedDistanceMeters(), quote.getDistanceMeters()) || !Objects.equals(request.getQuotedBaseDeliveryFee(), quote.getBaseDeliveryFee()) || !Objects.equals(request.getQuotedDistanceFee(), quote.getDistanceFee()) || !Objects.equals(request.getQuotedUrgentFee(), quote.getUrgentFee()) || !Objects.equals(request.getQuotedAmount(), quote.getAmount())) { throw new FlashDeliveryQuoteChangedException(quote); } List pickups = editablePickups(order, request); copyPickupSummary(order, pickups); copyAddress(request.getDelivery(), order, false); order.setReceiverUserId(resolveReceiverUserId(order.getDeliveryPhone())); order.setDistanceMeters(quote.getDistanceMeters()); order.setDistanceSource(quote.getDistanceSource()); order.setEstimatedDurationSeconds(quote.getEstimatedDurationSeconds()); // 改址报价携带原运价快照,金额与完整站点同事务保存,不引入最新运价。 order.setPricingId(quote.getPricingId()); order.setPricingVersion(quote.getPricingVersion()); order.setPricingStartTime(quote.getStartTime()); order.setPricingEndTime(quote.getEndTime()); order.setStartingDistance(quote.getStartingDistance()); order.setStartingFare(quote.getStartingFare()); order.setDistance(quote.getDistance()); order.setFreight(quote.getFreight()); order.setUrgentRate(quote.getUrgentRate()); order.setMinimumUrgentFee(quote.getMinimumUrgentFee()); order.setDistanceFee(quote.getDistanceFee()); order.setBaseDeliveryFee(quote.getBaseDeliveryFee()); order.setUrgentFee(quote.getUrgentFee()); order.setAmount(quote.getAmount()); Date now = new Date(); order.setUpdateTime(now); // 即使上面的查询仍显示待接单,骑手也可能已接走,最终以条件 UPDATE 为准。 if (orderMapper.updateWaitingAddress(order, request.getOrderVersion()) != 1) { throw fail("flash.delivery.state.changed"); } // 条件更新成功后持有订单行锁至提交,抢单不会读到仅修改一半的地址。 stopMapper.delete(new QueryWrapper().eq("order_id", orderId)); saveStops(order, pickups, request.getDelivery(), now); order.setVersion(order.getVersion() + 1); writeLog(orderId, WAITING_ACCEPTANCE, WAITING_ACCEPTANCE, "USER", userId, "ADDRESS_UPDATED", now); return participantDetail(order, true, false); } /** 待接单订单追加小费:订单总额与小费同步增加,版本条件防止并发重复加款。 */ @Transactional public FlashDeliveryOrderDetailView addTip(Long userId, Long orderId, FlashDeliveryTipAddRequest request) { if (request == null) throw fail("flash.delivery.request.required"); FlashDeliveryOrder order = requireEditableOrder(userId, orderId, request.getOrderVersion()); if (request.getAdditionalTipAmount() == null || request.getAdditionalTipAmount().signum() <= 0) { throw fail("flash.delivery.tip.additional.invalid"); } long tip; long amount; try { // 保留 JSON 原始数值,禁止 1.9 等小数在绑定时被截断为整数小费。 long additionalTip = request.getAdditionalTipAmount().longValueExact(); tip = Math.addExact(order.getTipAmount(), additionalTip); amount = Math.addExact(order.getAmount(), additionalTip); } catch (ArithmeticException exception) { throw fail("flash.delivery.tip.additional.invalid"); } order.setTipAmount(tip); order.setAmount(amount); Date now = new Date(); order.setUpdateTime(now); if (orderMapper.updateWaitingTip(order, request.getOrderVersion()) != 1) { throw fail("flash.delivery.state.changed"); } order.setVersion(order.getVersion() + 1); writeLog(orderId, WAITING_ACCEPTANCE, WAITING_ACCEPTANCE, "USER", userId, "TIP_ADDED", now); return participantDetail(order, true, false); } /** 校验并加载可编辑订单:本人、待接单、未绑定骑手且版本一致。 */ private FlashDeliveryOrder requireEditableOrder(Long userId, Long orderId, Integer version) { requireUserId(userId); if (orderId == null || orderId <= 0) throw fail("flash.delivery.order.not.found"); // 写入路径先锁订单再读取站点,保持与抢单、取货和取消一致的锁顺序。 FlashDeliveryOrder order = TransactionSynchronizationManager.isActualTransactionActive() ? lockOrder(orderId) : requireOrder(orderId); if (!Objects.equals(userId, order.getUserId())) throw fail("flash.delivery.order.not.found"); if (!WAITING_ACCEPTANCE.equals(order.getStatus()) || order.getRiderId() != null) { throw fail("flash.delivery.edit.not.allowed"); } if (version == null || version < 0) throw fail("flash.delivery.order.version.invalid"); if (!Objects.equals(version, order.getVersion())) throw fail("flash.delivery.state.changed"); return order; } /** 地址修改报价:使用订单保存的运价快照重算,不采用当前运价,避免改地址顺带变价。 */ private FlashDeliveryQuoteView calculateAddressQuote(FlashDeliveryOrder order, FlashDeliveryAddressChangeRequest request) { List pickups = editablePickups(order, request); validatePickups(pickups, request.getDelivery()); RouteDistance route = pickupRoute(pickups, request.getDelivery(), vehicleTypeOf(order)); // FR-074/093:改址沿用下单快照,只重算整条路线,平台后续调价不影响此单。 FlashDeliveryPricing pricing = new FlashDeliveryPricing(); pricing.setId(order.getPricingId()); pricing.setConfigVersion(order.getPricingVersion()); pricing.setStartTime(order.getPricingStartTime()); pricing.setEndTime(order.getPricingEndTime()); pricing.setStartingDistance(order.getStartingDistance()); pricing.setStartingFare(order.getStartingFare()); pricing.setDistance(order.getDistance()); pricing.setFreight(order.getFreight()); pricing.setUrgentRate(order.getUrgentRate()); pricing.setMinimumUrgentFee(order.getMinimumUrgentFee()); FlashDeliveryPriceBreakdown breakdown; try { breakdown = pricingCalculator.calculateBreakdown(pricing, route.distanceMeters(), order.getDeliveryType(), order.getTipAmount()); } catch (ArithmeticException exception) { throw fail("flash.delivery.tip.invalid"); } FlashDeliveryQuoteRequest quoteRequest = new FlashDeliveryQuoteRequest(); quoteRequest.setServiceType(order.getServiceType()); quoteRequest.setDeliveryType(order.getDeliveryType()); quoteRequest.setVehicleType(vehicleTypeOf(order)); quoteRequest.setDeliveryMode(order.getDeliveryMode()); quoteRequest.setScheduledPickupStartAt(order.getScheduledPickupStartAt()); quoteRequest.setScheduledPickupEndAt(order.getScheduledPickupEndAt()); FlashDeliveryQuoteView view = quoteView(quoteRequest, new QuoteContext(pricing, route, breakdown, pickups)); view.setOrderVersion(order.getVersion()); return view; } /** 用户取消:仅待接单或已接单(实际取件前)允许。 */ @Transactional public void userCancel(Long userId, Long orderId, FlashDeliveryReasonRequest request) { FlashDeliveryOrder order = lockOrder(orderId); if (!Objects.equals(userId, order.getUserId())) throw fail("flash.delivery.order.not.found"); if (anyPicked(requireStops(order)) || !FlashDeliveryStateMachine.canUserCancel(order.getStatus())) throw fail("flash.delivery.cancel.not.allowed"); String reason = requiredReason(request); Date now = new Date(); if (orderMapper.cancel(orderId, order.getStatus(), "USER", userId, reason, now) != 1) { throw fail("flash.delivery.state.changed"); } writeLog(orderId, order.getStatus(), CANCELLED, "USER", userId, reason, now); } /** 用户(寄件人或收件人)确认收货:已送达推进为已完成。 */ @Transactional public void confirmReceipt(Long userId, Long orderId) { FlashDeliveryOrder order = lockOrder(orderId); if (!isParticipant(userId, order)) throw fail("flash.delivery.order.not.found"); if (!allPicked(requireStops(order))) throw fail("flash.delivery.complete.not.allowed"); Date now = new Date(); if (orderMapper.transitionByParticipant(orderId, userId, DELIVERED, COMPLETED, now) != 1) { throw fail("flash.delivery.complete.not.allowed"); } writeLog(orderId, DELIVERED, COMPLETED, "USER", userId, null, now); } /** * 骑手任务列表:按页签查询(newTask 可抢任务、toPickup 待取件、delivering 配送中、 * completed 已送达/已完成、cancelled 已取消)。newTask 页签额外按骑手坐标做距离过滤和就近排序, * 并返回附近任务数与最高订单金额;其余页签按订单归属骑手查询。 */ public FlashDeliveryRiderOrderPageView riderOrders(Long riderId, int pageNum, int pageSize, String tab, BigDecimal longitude, BigDecimal latitude) { InfoUser rider = requireRider(riderId); Set acceptedPayTypes = acceptedRiderPayTypes(rider); if (!hasText(tab)) throw fail("flash.delivery.tab.invalid"); String selectedTab = tab.trim(); // lambda 无法捕获被重新赋值的参数,统一改用仅 newTask 携带坐标的有效变量。 BigDecimal effectiveLongitude = "newTask".equals(selectedTab) ? longitude : null; BigDecimal effectiveLatitude = "newTask".equals(selectedTab) ? latitude : null; if ("newTask".equals(selectedTab)) { // 新任务派单按骑手选择的配送类型过滤;本人已接订单页签不受限制。 if (!rider.supportsDeliveryType("FLASH")) throw fail("flash.delivery.rider.type.not.enabled"); validateRiderCoordinates(effectiveLongitude, effectiveLatitude); } Integer riderVehicleType = riderVehicleType(rider); QueryWrapper query = riderOrdersQuery(riderId, riderVehicleType, selectedTab, effectiveLongitude, effectiveLatitude, true, acceptedPayTypes); IPage orders = orderMapper.selectPage(page(pageNum, pageSize), query); FlashDeliveryRiderOrderPageView result = new FlashDeliveryRiderOrderPageView(); result.setRecords(orders.getRecords().stream() .map(order -> riderOrderListView(order, effectiveLongitude, effectiveLatitude)).toList()); result.setTotal(orders.getTotal()); result.setCurrent(orders.getCurrent()); result.setSize(orders.getSize()); if ("newTask".equals(selectedTab)) { result.setNearbyTaskCount(orders.getTotal()); QueryWrapper maximumQuery = riderOrdersQuery(riderId, riderVehicleType, selectedTab, effectiveLongitude, effectiveLatitude, false, acceptedPayTypes) .select("MAX(amount)"); result.setHighestOrderAmount(firstLong(orderMapper.selectObjs(maximumQuery))); } return result; } /** 骑手任务详情:抢单前返回脱敏视图(隐藏联系人、电话、坐标和 PIN);接单后仅中单骑手可见完整信息。 */ public FlashDeliveryOrderDetailView riderDetail(Long riderId, Long orderId) { InfoUser rider = requireRider(riderId); FlashDeliveryOrder order = requireOrder(orderId); if (WAITING_ACCEPTANCE.equals(order.getStatus()) && order.getRiderId() == null) { // 不兼容闪送的骑手按订单不存在处理,避免泄露待抢任务信息。 if (!isAvailableAt(order, new Date()) || !rider.supportsDeliveryType("FLASH") || !isRiderVehicleCompatible(rider, order)) { throw fail("flash.delivery.order.not.found"); } // 接单前显示取送文字地址,但隐藏联系人、电话、精确坐标、实际 PIN 和履约图片。 return participantDetail(order, false, true); } if (!Objects.equals(riderId, order.getRiderId())) throw fail("flash.delivery.order.not.found"); return participantDetail(order, false, false); } /** 原子抢单:骑手锁内完成互斥校验与条件更新,同一订单仅一名骑手成功。 */ @Transactional public FlashDeliveryOrderDetailView accept(Long riderId, Long orderId) { return riderDeliveryLockService.withLock(riderId, () -> { FlashDeliveryOrder target = requireOrder(orderId); InfoUser rider = requireRider(riderId); if (!isRiderVehicleCompatible(rider, target)) throw fail("flash.delivery.order.not.found"); // 账号资格、闪送/总任务容量与急送独占统一在骑手锁内复核,列表结果不能替代接单校验。 riderDeliveryAcceptanceService.assertCanAcceptFlash(riderId, target.getDeliveryType()); Date now = new Date(); // Mapper 使用“待接单且 rider_id 为空”的条件更新,受影响行数为 1 才表示抢单成功。 if (orderMapper.accept(orderId, riderId, now) != 1) { throw fail("flash.delivery.order.already.accepted"); } writeLog(orderId, WAITING_ACCEPTANCE, ACCEPTED, "RIDER", riderId, null, now); return participantDetail(requireOrder(orderId), false, false); }); } /** 骑手逐站取件:保存本站凭证,仅在全部取货站完成后推进订单为已取件。 */ @Transactional public void pickup(Long riderId, Long orderId, FlashDeliveryProofRequest request) { pickupStation(riderId, orderId, request == null ? null : request.getStopId(), request); } /** 骑手送达:校验交付 PIN(启用时)并提交送达凭证,订单由已取件推进为已送达。 */ @Transactional public void deliver(Long riderId, Long orderId, FlashDeliveryDeliverRequest request) { requireRider(riderId); FlashDeliveryOrder order = lockOrder(orderId); if (!Objects.equals(riderId, order.getRiderId()) || !PICKED_UP.equals(order.getStatus())) { throw fail("flash.delivery.transition.not.allowed"); } List rows = requireStops(order); if (!allPicked(rows)) throw fail("flash.delivery.transition.not.allowed"); if (Boolean.TRUE.equals(order.getPinRequired()) && (request == null || !Objects.equals(order.getDeliveryPinCode(), trimToNull(request.getPinCode())))) { throw fail("flash.delivery.pin.invalid"); } FlashDeliveryOrderStop delivery = rows.stream().filter(s -> "DELIVERY".equals(s.getStopType())).findFirst().orElseThrow(); if (request != null && request.getStopId() != null && !Objects.equals(request.getStopId(), delivery.getId())) throw fail("flash.delivery.stop.invalid"); List urls = validateProofUrls(request == null ? null : request.getImageUrls(), true); Date now = new Date(); if (orderMapper.transitionByRider(orderId, riderId, PICKED_UP, DELIVERED, "delivered_at", now) != 1) { throw fail("flash.delivery.transition.not.allowed"); } delivery.setStatus("DELIVERED"); delivery.setDeliveredAt(now); if (stopMapper.updateById(delivery) != 1) throw fail("flash.delivery.state.changed"); saveStationImages(orderId, delivery.getId(), "DELIVERY", "RIDER", riderId, urls, now); writeLog(orderId, PICKED_UP, DELIVERED, "RIDER", riderId, null, now); } /** 平台运价配置列表,按时段开始时间排序。 */ public List adminPricing() { return pricingMapper.selectList(new QueryWrapper() .orderByAsc("start_time", "id")); } /** 新增运价配置:校验参数合法性与时段不重叠后入库,版本号从 1 开始。 */ @Transactional public FlashDeliveryPricing createPricing(Long adminId, FlashDeliveryPricingRequest request) { validatePricingRequest(request, false); ensureNoPricingOverlap(request, null); Date now = new Date(); FlashDeliveryPricing pricing = pricingFrom(request); pricing.setConfigVersion(1); pricing.setUpdatedBy(adminId); pricing.setCreateTime(now); pricing.setUpdateTime(now); pricingMapper.insert(pricing); return pricing; } /** 修改运价配置:按 configVersion 乐观锁条件更新,防止管理员并发互相覆盖。 */ @Transactional public FlashDeliveryPricing updatePricing(Long adminId, Long pricingId, FlashDeliveryPricingRequest request) { validatePricingRequest(request, true); FlashDeliveryPricing preliminary = pricingId == null ? null : pricingMapper.selectById(pricingId); if (preliminary == null) throw fail("flash.delivery.pricing.not.found"); FlashDeliveryPricing pricing = pricingMapper.selectById(pricingId); if (pricing == null) throw fail("flash.delivery.pricing.not.found"); ensureNoPricingOverlap(request, pricingId); Date now = new Date(); UpdateWrapper update = new UpdateWrapper() .eq("id", pricingId).eq("config_version", request.getConfigVersion()) .set("vehicle_type", request.getVehicleType()) .set("start_time", request.getStartTime()) .set("end_time", request.getEndTime()) .set("starting_distance", scaleDistance(request.getStartingDistance())) .set("starting_fare", request.getStartingFare()) .set("distance", scaleDistance(request.getDistance())) .set("freight", request.getFreight()) .set("urgent_rate", normalizeRate(request.getUrgentRate())) .set("minimum_urgent_fee", request.getMinimumUrgentFee()) .set("updated_by", adminId) .set("update_time", now) .setSql("config_version = config_version + 1"); // 版本条件防止两个管理员同时保存时后提交者静默覆盖先提交者。 if (pricingMapper.update(null, update) != 1) throw fail("flash.delivery.pricing.changed"); pricing.setVehicleType(request.getVehicleType()); pricing.setStartTime(request.getStartTime()); pricing.setEndTime(request.getEndTime()); pricing.setStartingDistance(scaleDistance(request.getStartingDistance())); pricing.setStartingFare(request.getStartingFare()); pricing.setDistance(scaleDistance(request.getDistance())); pricing.setFreight(request.getFreight()); pricing.setUrgentRate(normalizeRate(request.getUrgentRate())); pricing.setMinimumUrgentFee(request.getMinimumUrgentFee()); pricing.setConfigVersion(request.getConfigVersion() + 1); pricing.setUpdatedBy(adminId); pricing.setUpdateTime(now); return pricing; } /** 删除运价配置。 */ @Transactional public void deletePricing(Long pricingId) { FlashDeliveryPricing preliminary = pricingId == null ? null : pricingMapper.selectById(pricingId); if (preliminary == null) { throw fail("flash.delivery.pricing.not.found"); } FlashDeliveryPricing pricing = pricingMapper.selectById(pricingId); if (pricing == null) throw fail("flash.delivery.pricing.not.found"); if (pricingMapper.deleteById(pricingId) != 1) throw fail("flash.delivery.pricing.not.found"); } /** 平台订单分页:支持状态、业务场景、订单号模糊、寄件用户、骑手条件过滤。 */ public IPage adminOrders(int pageNum, int pageSize, String status, String serviceType, String orderNo, Long userId, Long riderId) { validateOptionalServiceType(serviceType); QueryWrapper query = new QueryWrapper().orderByDesc("id"); optionalEq(query, "status", status); optionalEq(query, "service_type", serviceType); if (hasText(orderNo)) query.like("order_no", orderNo.trim()); if (userId != null) query.eq("user_id", userId); if (riderId != null) query.eq("rider_id", riderId); return orderMapper.selectPage(page(pageNum, pageSize), query); } /** 平台订单审计详情:返回完整订单实体、全部凭证图片和状态日志。 */ public FlashDeliveryAdminOrderDetailView adminDetail(Long orderId) { FlashDeliveryOrder order = requireOrder(orderId); FlashDeliveryAdminOrderDetailView view = new FlashDeliveryAdminOrderDetailView(); view.setOrder(order); List stationRows = stops(orderId); view.setPickupStopCount(order.getPickupStopCount()); view.setPickedUpStopCount(order.getPickedUpStopCount()); view.setCanUserCancel(canUserCancel(order)); view.setCurrentTargetStopId(targetStopId(order, stationRows)); view.setTargetAdjustments(adjustmentMapper.selectList(new QueryWrapper() .eq("order_id", orderId).orderByAsc("create_time", "id"))); view.setImages(imageMapper.selectList(new QueryWrapper() .eq("order_id", orderId).orderByAsc("proof_type", "sort_order"))); view.setStops(stopViews(stationRows, true, view.getImages())); view.setHandoffImageUrls(view.getImages().stream().filter(i -> "HANDOFF".equals(i.getProofType())) .map(FlashDeliveryOrderImage::getImageUrl).toList()); view.setLogs(logMapper.selectList(new QueryWrapper() .eq("order_id", orderId).orderByAsc("create_time", "id"))); return view; } /** 平台取消:任何非终态订单可取消,记录操作人与原因,用于异常履约人工收口。 */ @Transactional public void adminCancel(Long adminId, Long orderId, FlashDeliveryReasonRequest request) { FlashDeliveryOrder order = lockOrder(orderId); if (!FlashDeliveryStateMachine.canAdminCancel(order.getStatus())) throw fail("flash.delivery.cancel.not.allowed"); String reason = requiredReason(request); List rows = requireStops(order); if (request.getProblemStopId() == null || !hasText(request.getHandlingResult()) || tooLong(request.getHandlingResult(), 500)) { throw fail("flash.delivery.intervention.required"); } requireStop(rows, request.getProblemStopId()); boolean collected = anyPicked(rows); if ((collected && !hasText(request.getPickedGoodsDisposition())) || tooLong(request.getPickedGoodsDisposition(), 500)) { throw fail("flash.delivery.handoff.required"); } List proofs = validateProofUrls(request.getHandoffImageUrls(), collected); Date now = new Date(); // 关闭异常订单前保存介入结果和物品去向;凭证写入失败时整笔事务回滚。 UpdateWrapper audit = new UpdateWrapper().eq("id", orderId) .set("problem_stop_id", request.getProblemStopId()).set("handling_result", request.getHandlingResult().trim()) .set("picked_goods_disposition", trimToNull(request.getPickedGoodsDisposition())); if (orderMapper.update(null, audit) != 1) throw fail("flash.delivery.state.changed"); saveStationImages(orderId, request.getProblemStopId(), "HANDOFF", "ADMIN", adminId, proofs, now); if (orderMapper.cancel(orderId, order.getStatus(), "ADMIN", adminId, reason, now) != 1) throw fail("flash.delivery.state.changed"); writeLog(orderId, order.getStatus(), CANCELLED, "ADMIN", adminId, reason, now); } /** 将已送达订单推进为已完成;平台手动与系统自动完成共用此入口,条件更新失败返回 false。 */ @Transactional public boolean complete(Long operatorId, String operatorType, Long orderId) { FlashDeliveryOrder order = lockOrder(orderId); if (!DELIVERED.equals(order.getStatus()) || !allPicked(requireStops(order))) return false; Date now = new Date(); if (orderMapper.transitionByStatus(orderId, DELIVERED, COMPLETED, "completed_at", now) != 1) return false; writeLog(orderId, DELIVERED, COMPLETED, operatorType, operatorId, null, now); return true; } /** 骑手确认实际收款:仅本人已送达订单可确认,条件更新保证重复请求不重复写日志。 */ @Transactional public void confirmPayment(Long riderId, Long orderId) { requireRider(riderId); FlashDeliveryOrder order = requireOrder(orderId); if (!Objects.equals(riderId, order.getRiderId())) throw fail("flash.delivery.order.not.found"); if (!DELIVERED.equals(order.getStatus()) && !COMPLETED.equals(order.getStatus())) { throw fail("flash.delivery.payment.confirm.not.allowed"); } if (Integer.valueOf(1).equals(order.getPaymentStatus())) return; Date now = new Date(); if (orderMapper.update(null, new UpdateWrapper() .eq("id", orderId).eq("payment_status", 0) .set("payment_status", 1).set("update_time", now)) == 1) { writeLog(orderId, order.getStatus(), order.getStatus(), "RIDER", riderId, "PAYMENT_CONFIRMED", now); } } /** 查询送达时间早于截止点且未完成的订单,供自动完成任务分批处理。 */ public List autoCompleteCandidates(Date deadline, int limit) { return orderMapper.selectAutoCompletable(deadline, Math.min(Math.max(limit, 1), 500)); } /** 报价核心:校验枚举、物品、地址与预约窗后,按时段命中运价并计算路线距离,产出报价上下文。 */ private QuoteContext calculateQuote(FlashDeliveryQuoteRequest request) { if (request == null) throw fail("flash.delivery.request.required"); validateServiceType(request.getServiceType()); validateDeliveryType(request.getDeliveryType()); List pickups = normalizePickups(request); validatePickups(pickups, request.getDelivery()); if (request.getTipAmount() == null || request.getTipAmount() < 0) throw fail("flash.delivery.tip.invalid"); Date now = new Date(); String deliveryMode = normalizeDeliveryMode(request.getDeliveryMode()); validateSchedule(deliveryMode, request.getScheduledPickupStartAt(), request.getScheduledPickupEndAt(), now); Date pricingDate = "SCHEDULED".equals(deliveryMode) ? request.getScheduledPickupStartAt() : now; Integer selectedVehicle = requireVehicleType(request.getVehicleType()); FlashDeliveryPricing pricing = findPricingAt(pricingTargetTime(pricingDate), selectedVehicle); RouteDistance route = pickupRoute(pickups, request.getDelivery(), selectedVehicle); FlashDeliveryPriceBreakdown breakdown = pricingCalculator.calculateBreakdown(pricing, route.distanceMeters(), request.getDeliveryType(), request.getTipAmount()); return new QuoteContext(pricing, route, breakdown, pickups); } /** 查询指定时刻命中的运价配置,未配置时直接报错。 */ private FlashDeliveryPricing findPricingAt(String targetTime, Integer vehicleType) { FlashDeliveryPricing pricing = findPricingAtOrNull(targetTime, vehicleType); if (pricing == null) throw fail("flash.delivery.pricing.not.available"); return pricing; } /** 查询命中运价;命中多条说明配置重叠,报错交由平台修正。 */ private FlashDeliveryPricing findPricingAtOrNull(String targetTime, Integer vehicleType) { List pricing = pricingMapper.selectAtTime(targetTime, vehicleType); if (pricing == null || pricing.isEmpty()) return null; if (pricing.size() > 1) throw fail("flash.delivery.pricing.overlap"); return pricing.get(0); } /** 校验运价请求:时间格式与区间顺序、各金额取值范围及修改时的版本号。 */ private void validatePricingRequest(FlashDeliveryPricingRequest request, boolean requireVersion) { if (request == null) throw fail("flash.delivery.pricing.invalid"); BigDecimal startingDistance = normalizeDistance(request.getStartingDistance()); BigDecimal distance = normalizeDistance(request.getDistance()); BigDecimal urgentRate = normalizeRate(request.getUrgentRate()); if (!FlashDeliveryVehicleType.isSupported(request.getVehicleType()) || !validStartTime(request.getStartTime()) || !validEndTime(request.getEndTime()) || toMinute(request.getStartTime()) >= toMinute(request.getEndTime()) || startingDistance == null || startingDistance.signum() <= 0 || startingDistance.compareTo(MAX_PRICING_DISTANCE) > 0 || request.getStartingFare() == null || request.getStartingFare() <= 0 || distance == null || distance.signum() <= 0 || distance.compareTo(MAX_PRICING_DISTANCE) > 0 || request.getFreight() == null || request.getFreight() <= 0 || urgentRate == null || urgentRate.signum() < 0 || urgentRate.compareTo(MAX_PRICING_DISTANCE) > 0 || request.getMinimumUrgentFee() == null || request.getMinimumUrgentFee() < 0 || requireVersion && (request.getConfigVersion() == null || request.getConfigVersion() <= 0)) { throw fail("flash.delivery.pricing.invalid"); } } /** 确保新时段与既有运价不重叠,excludedId 用于修改时排除自身。 */ private void ensureNoPricingOverlap(FlashDeliveryPricingRequest request, Long excludedId) { if (pricingMapper.countOverlapping(request.getStartTime(), request.getEndTime(), request.getVehicleType(), excludedId) > 0) { throw fail("flash.delivery.pricing.overlap"); } } /** 将运价请求转换为实体,距离与比例统一保留两位小数。 */ private FlashDeliveryPricing pricingFrom(FlashDeliveryPricingRequest request) { FlashDeliveryPricing pricing = new FlashDeliveryPricing(); pricing.setVehicleType(request.getVehicleType()); pricing.setStartTime(request.getStartTime()); pricing.setEndTime(request.getEndTime()); pricing.setStartingDistance(scaleDistance(request.getStartingDistance())); pricing.setStartingFare(request.getStartingFare()); pricing.setDistance(scaleDistance(request.getDistance())); pricing.setFreight(request.getFreight()); pricing.setUrgentRate(normalizeRate(request.getUrgentRate())); pricing.setMinimumUrgentFee(request.getMinimumUrgentFee()); return pricing; } private boolean validStartTime(String value) { return value != null && value.matches("(?:[01]\\d|2[0-3]):[0-5]\\d"); } private boolean validEndTime(String value) { return "24:00".equals(value) || validStartTime(value); } private int toMinute(String value) { if ("24:00".equals(value)) return 24 * 60; return Integer.parseInt(value.substring(0, 2)) * 60 + Integer.parseInt(value.substring(3)); } private BigDecimal scaleDistance(BigDecimal value) { return normalizeDistance(value); } private BigDecimal normalizeDistance(BigDecimal value) { return value == null ? null : value.setScale(2, RoundingMode.HALF_UP); } private BigDecimal normalizeRate(BigDecimal value) { return value == null ? null : value.setScale(2, RoundingMode.HALF_UP); } /** 状态、图片和日志处于同一事务;任一图片写入失败会回滚本次业务操作。 */ private void saveImages(Long orderId, String proofType, String operatorType, Long operatorId, List urls, Date now) { for (int index = 0; index < urls.size(); index++) { FlashDeliveryOrderImage image = new FlashDeliveryOrderImage(); image.setOrderId(orderId); image.setProofType(proofType); image.setImageUrl(urls.get(index)); image.setSortOrder(index); image.setOperatorType(operatorType); image.setOperatorId(operatorId); image.setCreateTime(now); imageMapper.insert(image); } } /** 校验凭证图片 URL:必填时 1 至 9 张,每项为合法 URI 且不超过 1000 字符。 */ private List validateProofUrls(List values, boolean required) { if (values == null || values.isEmpty()) { if (!required) return List.of(); throw fail("flash.delivery.proof.required"); } if (values.size() > 9) throw fail("flash.delivery.proof.too.many"); List urls = new ArrayList<>(); for (String value : values) { try { String url = value == null ? null : value.trim(); if (url == null || url.isEmpty() || url.length() > 1000) { throw new IllegalArgumentException(); } URI.create(url); urls.add(url); } catch (IllegalArgumentException exception) { throw fail("flash.delivery.proof.url.invalid"); } } return urls; } /** 组装用户或骑手视角详情;hideSensitiveFields 为 true 时按抢单前脱敏返回。 */ private FlashDeliveryOrderDetailView participantDetail(FlashDeliveryOrder order, boolean userView, boolean hideSensitiveFields) { FlashDeliveryOrderDetailView view = detailOrderView(order, !hideSensitiveFields); if (!hideSensitiveFields) view.setOrderVersion(order.getVersion()); List stationRows = stops(order.getId()); List stationImages = hideSensitiveFields ? List.of() : imageMapper.selectList(currentRead( new QueryWrapper().eq("order_id", order.getId()).orderByAsc("proof_type", "sort_order"))); view.setStops(stopViews(stationRows, !hideSensitiveFields, stationImages)); view.setCurrentTargetStopId(targetStopId(order, stationRows)); if (!hideSensitiveFields) { view.setTargetAdjustments(adjustmentMapper.selectList(currentRead(new QueryWrapper() .eq("order_id", order.getId()).orderByAsc("create_time", "id")))); } if (userView) { view.setDeliveryPinCode(Boolean.TRUE.equals(order.getPinRequired()) ? order.getDeliveryPinCode() : null); view.setRider(riderSummary(order)); } if (!hideSensitiveFields && order.getId() != null) { groupImageUrls(view, stationImages); } return view; } /** 订单实体转用户端订单卡片。 */ private FlashDeliveryUserOrderListView userOrderListView(FlashDeliveryOrder order) { FlashDeliveryUserOrderListView view = new FlashDeliveryUserOrderListView(); view.setId(order.getId()); view.setOrderNo(order.getOrderNo()); view.setServiceType(order.getServiceType()); view.setDeliveryType(order.getDeliveryType()); view.setVehicleType(vehicleTypeOf(order)); view.setStatus(order.getStatus()); view.setPickupStopCount(order.getPickupStopCount()); view.setPickedUpStopCount(order.getPickedUpStopCount()); view.setCanUserCancel(canUserCancel(order)); view.setOrderVersion(order.getVersion()); view.setPackageType(order.getPackageType()); view.setQuantity(order.getQuantity()); view.setWeightRange(order.getWeightRange()); view.setDeliveryMode(order.getDeliveryMode()); view.setScheduledPickupStartAt(order.getScheduledPickupStartAt()); view.setScheduledPickupEndAt(order.getScheduledPickupEndAt()); view.setPickupAddress(order.getPickupAddress()); view.setPickupDetailAddress(order.getPickupAddressDetail()); view.setDeliveryAddress(order.getDeliveryAddress()); view.setDeliveryDetailAddress(order.getDeliveryAddressDetail()); view.setEstimatedDurationSeconds(order.getEstimatedDurationSeconds()); view.setBaseDeliveryFee(order.getBaseDeliveryFee()); view.setUrgentFee(order.getUrgentFee()); view.setTipAmount(order.getTipAmount()); view.setAmount(order.getAmount()); view.setCurrency(order.getCurrency()); view.setPaymentStatus(order.getPaymentStatus()); view.setDeliveredAt(order.getDeliveredAt()); view.setCreateTime(order.getCreateTime()); return view; } /** 订单实体转骑手端任务卡片,可附带骑手到取件点的直线距离。 */ private FlashDeliveryRiderOrderListView riderOrderListView(FlashDeliveryOrder order, BigDecimal longitude, BigDecimal latitude) { FlashDeliveryRiderOrderListView view = new FlashDeliveryRiderOrderListView(); view.setId(order.getId()); view.setOrderNo(order.getOrderNo()); view.setServiceType(order.getServiceType()); view.setDeliveryType(order.getDeliveryType()); view.setVehicleType(vehicleTypeOf(order)); view.setStatus(order.getStatus()); view.setPickupStopCount(order.getPickupStopCount()); view.setPickedUpStopCount(order.getPickedUpStopCount()); view.setCanUserCancel(canUserCancel(order)); view.setPackageType(order.getPackageType()); view.setQuantity(order.getQuantity()); view.setWeightRange(order.getWeightRange()); view.setSpecification(order.getSpecification()); view.setDeliveryMode(order.getDeliveryMode()); view.setScheduledPickupStartAt(order.getScheduledPickupStartAt()); view.setScheduledPickupEndAt(order.getScheduledPickupEndAt()); view.setPinRequired(order.getPinRequired()); view.setPickupAddress(order.getPickupAddress()); view.setPickupDetailAddress(order.getPickupAddressDetail()); view.setDeliveryAddress(order.getDeliveryAddress()); view.setDeliveryDetailAddress(order.getDeliveryAddressDetail()); view.setPayType(order.getPayType()); view.setPaymentStatus(order.getPaymentStatus()); view.setPickupDistanceMeters(distanceMeters(longitude, latitude, order.getPickupLongitude(), order.getPickupLatitude())); view.setDistanceMeters(order.getDistanceMeters()); view.setEstimatedDurationSeconds(order.getEstimatedDurationSeconds()); view.setBaseDeliveryFee(order.getBaseDeliveryFee()); view.setDistanceFee(order.getDistanceFee()); view.setUrgentFee(order.getUrgentFee()); view.setTipAmount(order.getTipAmount()); view.setAmount(order.getAmount()); view.setCurrency(order.getCurrency()); view.setCreateTime(order.getCreateTime()); return view; } /** 订单实体转详情基础视图。 */ private FlashDeliveryOrderDetailView detailOrderView(FlashDeliveryOrder order, boolean includeSensitiveFields) { FlashDeliveryOrderDetailView view = new FlashDeliveryOrderDetailView(); populateOrderView(view, order, includeSensitiveFields); return view; } /** 按凭证类型把图片分组为寄件、取件、送达三组 URL。 */ private void groupImageUrls(FlashDeliveryOrderDetailView view, List images) { List sender = new ArrayList<>(); List pickup = new ArrayList<>(); List delivery = new ArrayList<>(); for (FlashDeliveryOrderImage image : images) { if ("SENDER".equals(image.getProofType())) sender.add(image.getImageUrl()); else if ("PICKUP".equals(image.getProofType())) pickup.add(image.getImageUrl()); else if ("DELIVERY".equals(image.getProofType())) delivery.add(image.getImageUrl()); } view.setSenderImageUrls(sender); view.setPickupImageUrls(pickup); view.setDeliveryImageUrls(delivery); } /** 运价实体转首页服务摘要视图。 */ private FlashDeliveryServiceView serviceView(FlashDeliveryPricing pricing, String serviceType) { FlashDeliveryServiceView view = new FlashDeliveryServiceView(); view.setServiceType(serviceType); view.setPricingId(pricing.getId()); view.setStartTime(pricing.getStartTime()); view.setEndTime(pricing.getEndTime()); view.setStartingDistance(pricing.getStartingDistance()); view.setStartingFare(pricing.getStartingFare()); view.setDistance(pricing.getDistance()); view.setFreight(pricing.getFreight()); view.setUrgentRate(pricing.getUrgentRate()); view.setMinimumUrgentFee(pricing.getMinimumUrgentFee()); view.setPricingVersion(pricing.getConfigVersion()); view.setCurrency("TWD"); return view; } /** 将订单实体的公共字段填充到视图,includeSensitiveFields 控制是否包含敏感字段。 */ private void populateOrderView(FlashDeliveryOrderView view, FlashDeliveryOrder order, boolean includeSensitiveFields) { view.setId(order.getId()); view.setOrderNo(order.getOrderNo()); view.setServiceType(order.getServiceType()); view.setDeliveryType(order.getDeliveryType()); view.setVehicleType(vehicleTypeOf(order)); view.setStatus(order.getStatus()); view.setPickupStopCount(order.getPickupStopCount()); view.setPickedUpStopCount(order.getPickedUpStopCount()); view.setCanUserCancel(canUserCancel(order)); view.setPackageType(order.getPackageType()); view.setQuantity(order.getQuantity()); view.setWeightRange(order.getWeightRange()); view.setSpecification(order.getSpecification()); view.setDeliveryMode(order.getDeliveryMode()); view.setScheduledPickupStartAt(order.getScheduledPickupStartAt()); view.setScheduledPickupEndAt(order.getScheduledPickupEndAt()); view.setPinRequired(order.getPinRequired()); view.setPickup(addressView(order, true, includeSensitiveFields)); view.setDelivery(addressView(order, false, includeSensitiveFields)); view.setDistanceMeters(order.getDistanceMeters()); view.setDistanceSource(order.getDistanceSource()); view.setEstimatedDurationSeconds(order.getEstimatedDurationSeconds()); view.setBaseDeliveryFee(order.getBaseDeliveryFee()); view.setDistanceFee(order.getDistanceFee()); view.setUrgentFee(order.getUrgentFee()); view.setTipAmount(order.getTipAmount()); view.setAmount(order.getAmount()); view.setCurrency(order.getCurrency()); view.setPayType(order.getPayType()); view.setPaymentStatus(order.getPaymentStatus()); view.setUserNote(includeSensitiveFields ? order.getUserNote() : null); view.setAcceptedAt(order.getAcceptedAt()); view.setPickedUpAt(order.getPickedUpAt()); view.setDeliveredAt(order.getDeliveredAt()); view.setCompletedAt(order.getCompletedAt()); view.setCancelledAt(order.getCancelledAt()); view.setCancelReason(order.getCancelReason()); view.setCreateTime(order.getCreateTime()); view.setUpdateTime(order.getUpdateTime()); } /** 从订单实体提取取件或收件地址视图;脱敏时不返回联系人、电话和坐标。 */ private FlashDeliveryAddressView addressView(FlashDeliveryOrder order, boolean pickup, boolean includeSensitiveFields) { FlashDeliveryAddressView view = new FlashDeliveryAddressView(); if (pickup) { view.setName(includeSensitiveFields ? order.getPickupName() : null); view.setPhone(includeSensitiveFields ? order.getPickupPhone() : null); view.setAddress(order.getPickupAddress()); view.setAddressDetail(order.getPickupAddressDetail()); view.setCity(order.getPickupCity()); view.setArea(order.getPickupArea()); view.setHandoffMethod(order.getPickupHandoffMethod()); view.setLongitude(includeSensitiveFields ? order.getPickupLongitude() : null); view.setLatitude(includeSensitiveFields ? order.getPickupLatitude() : null); } else { view.setName(includeSensitiveFields ? order.getDeliveryName() : null); view.setPhone(includeSensitiveFields ? order.getDeliveryPhone() : null); view.setAddress(order.getDeliveryAddress()); view.setAddressDetail(order.getDeliveryAddressDetail()); view.setCity(order.getDeliveryCity()); view.setArea(order.getDeliveryArea()); view.setHandoffMethod(order.getDeliveryHandoffMethod()); view.setLongitude(includeSensitiveFields ? order.getDeliveryLongitude() : null); view.setLatitude(includeSensitiveFields ? order.getDeliveryLatitude() : null); } return view; } /** 组装骑手公开资料;仅已接单或已取件状态附带骑手实时位置。 */ private FlashDeliveryRiderSummaryView riderSummary(FlashDeliveryOrder order) { if (order.getRiderId() == null) return null; InfoUser rider = userMapper.selectInfoUserByUserId(order.getRiderId()); if (rider == null) return null; FlashDeliveryRiderSummaryView view = new FlashDeliveryRiderSummaryView(); view.setName(hasText(rider.getNickName()) ? rider.getNickName() : rider.getUserName()); view.setAvatar(rider.getAvatar()); view.setRating(rider.getStar()); view.setImUserId(rider.getImUserId() == null ? null : String.valueOf(rider.getImUserId())); if (ACCEPTED.equals(order.getStatus()) || PICKED_UP.equals(order.getStatus())) { view.setLongitude(rider.getLongitude()); view.setLatitude(rider.getLatitude()); } return view; } /** 把费用明细写入报价视图。 */ private void applyBreakdown(FlashDeliveryQuoteView view, FlashDeliveryPriceBreakdown breakdown) { view.setBillableDistance(breakdown.getBillableDistance()); view.setDistanceFee(breakdown.getDistanceFee()); view.setBaseDeliveryFee(breakdown.getBaseDeliveryFee()); view.setUrgentFee(breakdown.getUrgentFee()); view.setTipAmount(breakdown.getTipAmount()); view.setAmount(breakdown.getAmount()); } /** 把请求地址复制为订单取件或收件快照字段。 */ private void copyAddress(FlashDeliveryAddressRequest source, FlashDeliveryOrder target, boolean pickup) { if (pickup) { target.setPickupName(source.getName().trim()); target.setPickupPhone(source.getPhone().trim()); target.setPickupAddress(source.getAddress().trim()); target.setPickupAddressDetail(trimToNull(source.getAddressDetail())); target.setPickupCity(trimToNull(source.getCity())); target.setPickupArea(trimToNull(source.getArea())); target.setPickupHandoffMethod(trimToNull(source.getHandoffMethod())); target.setPickupLongitude(source.getLongitude()); target.setPickupLatitude(source.getLatitude()); } else { target.setDeliveryName(source.getName().trim()); target.setDeliveryPhone(source.getPhone().trim()); target.setDeliveryAddress(source.getAddress().trim()); target.setDeliveryAddressDetail(trimToNull(source.getAddressDetail())); target.setDeliveryCity(trimToNull(source.getCity())); target.setDeliveryArea(trimToNull(source.getArea())); target.setDeliveryHandoffMethod(trimToNull(source.getHandoffMethod())); target.setDeliveryLongitude(source.getLongitude()); target.setDeliveryLatitude(source.getLatitude()); } } /** 校验地址请求:必填项、长度限制和经纬度取值范围。 */ private void validateAddress(FlashDeliveryAddressRequest address) { if (address == null || !hasText(address.getName()) || !hasText(address.getPhone()) || !hasText(address.getAddress()) || address.getLatitude() == null || address.getLongitude() == null || address.getName().trim().length() > 64 || address.getPhone().trim().length() > 32 || address.getAddress().trim().length() > 255 || (address.getAddressDetail() != null && address.getAddressDetail().trim().length() > 255) || tooLong(address.getCity(), 64) || tooLong(address.getArea(), 64) || tooLong(address.getHandoffMethod(), 40) || address.getLatitude().compareTo(BigDecimal.valueOf(-90)) < 0 || address.getLatitude().compareTo(BigDecimal.valueOf(90)) > 0 || address.getLongitude().compareTo(BigDecimal.valueOf(-180)) < 0 || address.getLongitude().compareTo(BigDecimal.valueOf(180)) > 0) { throw fail("flash.delivery.address.invalid"); } } /** 收件手机号归一化后唯一匹配普通用户作为收件人;匹配零个或多个视为无收件人。 */ private Long resolveReceiverUserId(String phone) { if (!hasText(phone)) return null; StringBuilder normalized = new StringBuilder(phone.length()); for (int index = 0; index < phone.length(); index++) { char value = phone.charAt(index); if (Character.isWhitespace(value) || value == '+' || value == '-' || value == '(' || value == ')') { continue; } if (value < '0' || value > '9') return null; normalized.append(value); } if (normalized.isEmpty()) return null; List matches = userMapper.selectOrdinaryUserIdsByNormalizedPhone(normalized.toString()); return matches != null && matches.size() == 1 ? matches.get(0) : null; } /** 判断用户是否为订单寄件人或匹配收件人。 */ private boolean isParticipant(Long userId, FlashDeliveryOrder order) { return Objects.equals(userId, order.getUserId()) || Objects.equals(userId, order.getReceiverUserId()); } /** 校验 token 用户为骑手(userType=2)并返回其资料。 */ private InfoUser requireRider(Long riderId) { InfoUser rider = riderId == null ? null : userMapper.selectInfoUserByUserId(riderId); if (rider == null || !"2".equals(rider.getUserType())) throw fail("flash.delivery.rider.required"); return rider; } /** 加载订单,不存在时统一按订单不存在报错。 */ private FlashDeliveryOrder requireOrder(Long orderId) { FlashDeliveryOrder order = orderId == null ? null : orderMapper.selectById(orderId); if (order == null) throw fail("flash.delivery.order.not.found"); return order; } /** 写入状态变更审计日志。 */ private void writeLog(Long orderId, String from, String to, String operatorType, Long operatorId, String reason, Date now) { FlashDeliveryOrderLog log = new FlashDeliveryOrderLog(); log.setOrderId(orderId); log.setFromStatus(from); log.setToStatus(to); log.setOperatorType(operatorType); log.setOperatorId(operatorId); log.setReason(reason); log.setCreateTime(now); logMapper.insert(log); } /** 校验并提取取消原因。 */ private String requiredReason(FlashDeliveryReasonRequest request) { if (request == null || !hasText(request.getReason()) || request.getReason().trim().length() > 500) { throw fail("flash.delivery.cancel.reason.required"); } return request.getReason().trim(); } private void validateServiceType(String value) { if (!hasText(value) || !SERVICE_TYPES.contains(value)) throw fail("flash.delivery.service.type.invalid"); } private void validateDeliveryType(String value) { if (!hasText(value) || !DELIVERY_TYPES.contains(value)) { throw fail("flash.delivery.delivery.type.invalid"); } } /** 支付方式由 031 闸门校验;闪送维度按平台开关判定,缺省现金。 */ private String normalizePayType(String payType) { String value = hasText(payType) ? payType.trim() : PAY_TYPE_CASH; paymentMethodGateService.assertUsable(value, PaymentMethodGateService.GateScope.RIDER_FLASH, null, null); return value; } /** 将时间转为台北时区 HH:mm,用于命中运价时段。 */ private String pricingTargetTime(Date date) { return PRICING_TIME_FORMAT.format(Instant.ofEpochMilli(date.getTime()) .atZone(TAIPEI).toLocalTime()); } /** 比对客户端回传报价与服务端最新报价是否完全一致。 */ private boolean matchesQuotedPrice(FlashDeliveryCreateRequest request, FlashDeliveryQuoteView latest) { return Objects.equals(request.getPricingId(), latest.getPricingId()) && Objects.equals(request.getPricingVersion(), latest.getPricingVersion()) && Objects.equals(request.getQuotedBaseDeliveryFee(), latest.getBaseDeliveryFee()) && Objects.equals(request.getQuotedDistanceFee(), latest.getDistanceFee()) && Objects.equals(request.getQuotedUrgentFee(), latest.getUrgentFee()) && Objects.equals(request.getQuotedAmount(), latest.getAmount()); } private void validateOptionalServiceType(String value) { if (hasText(value)) validateServiceType(value); } private void requireUserId(Long userId) { if (userId == null || userId <= 0) throw fail("flash.delivery.auth.required"); } /** 构造分页对象并夹紧页码与每页数量(1 至 100)。 */ private Page page(int pageNum, int pageSize) { return new Page<>(Math.max(1, pageNum), Math.min(Math.max(1, pageSize), 100)); } /** 分页结果实体转换,保留分页元数据。 */ private IPage mapPage(IPage source, Function mapper) { Page result = new Page<>(source.getCurrent(), source.getSize(), source.getTotal()); result.setRecords(source.getRecords().stream().map(mapper).toList()); return result; } /** 按页签构建骑手任务查询:newTask 限定可抢状态与时间窗,可选距离过滤和就近排序。 */ private QueryWrapper riderOrdersQuery(Long riderId, Integer riderVehicleType, String tab, BigDecimal longitude, BigDecimal latitude, boolean orderResults, Set acceptedPayTypes) { QueryWrapper query = new QueryWrapper<>(); Date now = new Date(); switch (tab) { case "newTask" -> { query.eq("status", WAITING_ACCEPTANCE).isNull("rider_id") .eq("vehicle_type", riderVehicleType) .and(item -> item.eq("delivery_mode", "NOW") .or().le("scheduled_pickup_start_at", now)); // 同时满足车型和骑手已接受的支付方式,列表不能替代接单接口的资格复核。 if (acceptedPayTypes.isEmpty()) query.apply("1 = 0"); else query.in("pay_type", acceptedPayTypes); if (longitude != null) { Integer limitKm = newTaskDistanceLimit(); if (limitKm != null) { query.apply("ST_Distance_Sphere(point(pickup_longitude, pickup_latitude), point({0}, {1})) <= {2}", longitude, latitude, limitKm.longValue() * 1000L); } if (orderResults) { query.orderByAsc("ST_Distance_Sphere(point(pickup_longitude, pickup_latitude), point(" + longitude + ", " + latitude + "))"); } } else if (orderResults) { query.orderByDesc("create_time"); } } case "toPickup" -> { query.eq("rider_id", riderId).eq("status", ACCEPTED); if (orderResults) query.orderByAsc("create_time"); } case "delivering" -> { query.eq("rider_id", riderId).eq("status", PICKED_UP); if (orderResults) query.orderByAsc("create_time"); } case "completed" -> { query.eq("rider_id", riderId).in("status", DELIVERED, COMPLETED); if (orderResults) query.orderByDesc("create_time"); } case "cancelled" -> { query.eq("rider_id", riderId).eq("status", CANCELLED); if (orderResults) query.orderByDesc("create_time"); } default -> throw fail("flash.delivery.tab.invalid"); } return query; } /** 骑手未设置闪送收款方式时继承平台开放项;现金单按既有规则始终可见。 */ private Set acceptedRiderPayTypes(InfoUser rider) { String csv = rider.getFlashPayMethods(); List groups = new ArrayList<>(); if (!hasText(csv)) { for (PaymentMethodGateService.PayMethodOption option : paymentMethodGateService.listAvailable( PaymentMethodGateService.GateScope.RIDER_FLASH, null, null)) { if (!groups.contains(option.getMethodCode())) groups.add(option.getMethodCode()); } } else { for (String part : csv.split(",")) { if (hasText(part)) groups.add(part.trim()); } } Set payTypes = new LinkedHashSet<>(); payTypes.add(PAY_TYPE_CASH); for (String group : groups) payTypes.addAll(PaymentMethodGateService.payTypesOf(group)); return payTypes; } /** 校验骑手坐标:经纬度必须成对出现且在合法范围。 */ private void validateRiderCoordinates(BigDecimal longitude, BigDecimal latitude) { if ((longitude == null) != (latitude == null) || longitude != null && (longitude.compareTo(BigDecimal.valueOf(-180)) < 0 || longitude.compareTo(BigDecimal.valueOf(180)) > 0 || latitude.compareTo(BigDecimal.valueOf(-90)) < 0 || latitude.compareTo(BigDecimal.valueOf(90)) > 0)) { throw fail("flash.delivery.coordinates.invalid"); } } /** 读取字典 sys_qs_newtask_distance 的新任务距离上限(公里);缺失或非法时不限距离。 */ private Integer newTaskDistanceLimit() { try { List values = DictUtils.getDictCache("sys_qs_newtask_distance"); if (values != null && !values.isEmpty()) { int limit = Integer.parseInt(values.get(0).getDictValue()); return limit > 0 ? limit : null; } } catch (Exception ignored) { // Keep the established rider-list behaviour: absent or malformed configuration disables distance filtering. } return null; } /** Haversine 球面直线距离(米),用于骑手列表展示距取件点的距离。 */ private Integer distanceMeters(BigDecimal longitude, BigDecimal latitude, BigDecimal targetLongitude, BigDecimal targetLatitude) { if (longitude == null || targetLongitude == null || targetLatitude == null) return null; double latitudeRadians = Math.toRadians(latitude.doubleValue()); double targetLatitudeRadians = Math.toRadians(targetLatitude.doubleValue()); double latitudeDelta = targetLatitudeRadians - latitudeRadians; double longitudeDelta = Math.toRadians(targetLongitude.doubleValue() - longitude.doubleValue()); double value = Math.sin(latitudeDelta / 2) * Math.sin(latitudeDelta / 2) + Math.cos(latitudeRadians) * Math.cos(targetLatitudeRadians) * Math.sin(longitudeDelta / 2) * Math.sin(longitudeDelta / 2); return (int) Math.round(6_371_000D * 2D * Math.atan2(Math.sqrt(value), Math.sqrt(1D - value))); } /** 从 selectObjs 聚合结果中取第一个 Long 值。 */ private Long firstLong(List values) { if (values == null || values.isEmpty() || values.get(0) == null) return null; Object value = values.get(0); if (value instanceof Number number) return number.longValue(); return Long.valueOf(String.valueOf(value)); } /** 取件方式归一化:缺省按 NOW 处理,非法值报错。 */ private String normalizeDeliveryMode(String value) { String mode = hasText(value) ? value.trim() : "NOW"; if (!DELIVERY_MODES.contains(mode)) throw fail("flash.delivery.mode.invalid"); return mode; } /** 校验预约窗:NOW 不得携带时间;SCHEDULED 必须是未来 3 天内恰好 30 分钟的窗。 */ private void validateSchedule(String mode, Date start, Date end, Date now) { if ("NOW".equals(mode)) { if (start != null || end != null) throw fail("flash.delivery.schedule.invalid"); return; } if (start == null || end == null || start.before(now) || end.getTime() - start.getTime() != SCHEDULE_SLOT_MILLIS || start.getTime() - now.getTime() > MAX_SCHEDULE_DELAY_MILLIS) { throw fail("flash.delivery.schedule.invalid"); } } /** 判断订单当前是否开放抢单:立即单随时可抢,预约单到点开放。 */ private boolean isAvailableAt(FlashDeliveryOrder order, Date now) { return "NOW".equals(order.getDeliveryMode()) || "SCHEDULED".equals(order.getDeliveryMode()) && order.getScheduledPickupStartAt() != null && !order.getScheduledPickupStartAt().after(now); } private void optionalEq(QueryWrapper query, String column, String value) { if (hasText(value)) query.eq(column, value.trim()); } private boolean hasText(String value) { return StringUtils.isNotEmpty(value) && !value.trim().isEmpty(); } private String trimToNull(String value) { return hasText(value) ? value.trim() : null; } private boolean tooLong(String value, int max) { return value != null && value.trim().length() > max; } private boolean sameAddressText(FlashDeliveryAddressRequest left, FlashDeliveryAddressRequest right) { return normalized(left.getAddress()).equals(normalized(right.getAddress())) && normalized(left.getAddressDetail()).equals(normalized(right.getAddressDetail())); } private String normalized(String value) { return value == null ? "" : value.trim().toLowerCase(Locale.ROOT); } private ServiceException fail(String key) { return new ServiceException(MessageUtils.message(key)); } /** 将旧单点输入适配为完整取货站;多点请求必须逐站申报物品。 */ private List normalizePickups(FlashDeliveryQuoteRequest request) { if (request.getPickups() != null) { if (request.getPickup() != null || request.getPackageType() != null || request.getQuantity() != null || request.getWeightRange() != null || request.getSpecification() != null) { throw fail("flash.delivery.pickup.input.conflict"); } return request.getPickups(); } FlashDeliveryPickupRequest pickup = new FlashDeliveryPickupRequest(); if (request.getPickup() == null) throw fail("flash.delivery.address.invalid"); BeanUtils.copyProperties(request.getPickup(), pickup); pickup.setPackageType(request.getPackageType()); pickup.setQuantity(request.getQuantity()); pickup.setWeightRange(request.getWeightRange()); pickup.setSpecification(request.getSpecification()); return List.of(pickup); } /** 编辑提交完整站点;旧单点地址编辑保留该站原有物品信息。 */ private List editablePickups(FlashDeliveryOrder order, FlashDeliveryAddressChangeRequest request) { List existing = requireStops(order); if (request.getPickups() != null) { if (request.getPickup() != null) throw fail("flash.delivery.pickup.input.conflict"); return request.getPickups(); } List pickups = pickupStops(existing); if (pickups.size() != 1) throw fail("flash.delivery.pickups.required"); if (request.getPickup() == null) throw fail("flash.delivery.address.invalid"); FlashDeliveryPickupRequest pickup = new FlashDeliveryPickupRequest(); BeanUtils.copyProperties(pickups.get(0), pickup); BeanUtils.copyProperties(request.getPickup(), pickup); return List.of(pickup); } /** 地图调用前先校验有效点数、开关及每站地址和物品。 */ private void validatePickups(List pickups, FlashDeliveryAddressRequest delivery) { if (pickups == null || pickups.isEmpty() || pickups.size() > pickupStopLimit()) { throw fail("flash.delivery.pickup.limit.exceeded"); } if (pickups.size() > 1 && !dictionaryEnabled("flash_show_multi_pickup")) { throw fail("flash.delivery.multi.pickup.disabled"); } validateAddress(delivery); for (FlashDeliveryPickupRequest pickup : pickups) { validateAddress(pickup); if (!PACKAGE_TYPES.contains(Objects.toString(pickup.getPackageType(), ""))) { throw fail("flash.delivery.package.type.invalid"); } // 重量与规格仅供骑手判断物品情况,用户可不填写;填写重量时仍须使用受支持的区间。 if (pickup.getQuantity() == null || pickup.getQuantity() <= 0 || hasText(pickup.getWeightRange()) && !WEIGHT_RANGES.contains(pickup.getWeightRange()) || tooLong(pickup.getSpecification(), 255)) throw fail("flash.delivery.item.invalid"); if (sameAddressText(pickup, delivery) || pickup.getLatitude().compareTo(delivery.getLatitude()) == 0 && pickup.getLongitude().compareTo(delivery.getLongitude()) == 0) { throw fail("flash.delivery.address.same"); } } } private RouteDistance pickupRoute(List pickups, FlashDeliveryAddressRequest delivery, Integer vehicleType) { List points = new ArrayList<>(); for (FlashDeliveryPickupRequest pickup : pickups) points.add(new GeoPoint(pickup.getLatitude(), pickup.getLongitude())); points.add(new GeoPoint(delivery.getLatitude(), delivery.getLongitude())); RouteDistance route = points.size() == 2 ? routeService.calculate(points.get(0), points.get(1), vehicleType) : routeService.calculate(points, vehicleType); if (route.distanceMeters() > MAX_DISTANCE_METERS) throw fail("flash.delivery.distance.too.far"); return route; } private Integer requireVehicleType(Integer vehicleType) { if (!FlashDeliveryVehicleType.isSupported(vehicleType)) { throw fail("flash.delivery.vehicle.type.invalid"); } return vehicleType; } /** DDL 执行前存在的旧订单按用户确认的机车规则兼容。 */ private Integer vehicleTypeOf(FlashDeliveryOrder order) { return FlashDeliveryVehicleType.isSupported(order.getVehicleType()) ? order.getVehicleType() : FlashDeliveryVehicleType.MOTORCYCLE; } /** 骑手资料复用既有 vehicleType;历史空值和非法值安全回落为机车。 */ private Integer riderVehicleType(InfoUser rider) { try { Integer value = Integer.valueOf(rider.getVehicleType()); return FlashDeliveryVehicleType.isSupported(value) ? value : FlashDeliveryVehicleType.MOTORCYCLE; } catch (Exception ignored) { return FlashDeliveryVehicleType.MOTORCYCLE; } } private boolean isRiderVehicleCompatible(InfoUser rider, FlashDeliveryOrder order) { return riderVehicleType(rider).equals(vehicleTypeOf(order)); } /** 点数配置缺失、非整数或越界时,有效上限回退为1。 */ private int pickupStopLimit() { String value = dictionaryValue("flash_pickup_stop_limit"); if (value == null || !value.matches("[0-9]+")) return 1; try { int limit = Integer.parseInt(value); return limit >= 1 && limit <= 26 ? limit : 1; } catch (NumberFormatException ignored) { return 1; } } private boolean dictionaryEnabled(String key) { return "1".equals(dictionaryValue(key)); } private String dictionaryValue(String key) { try { List values = DictUtils.getDictCache(key); return values == null || values.isEmpty() ? null : values.get(0).getDictValue(); } catch (Exception ignored) { return null; } } /** 订单旧字段仅维护首站兼容摘要,不代表全部站点物品。 */ private void copyPickupSummary(FlashDeliveryOrder order, List pickups) { FlashDeliveryPickupRequest first = pickups.get(0); copyAddress(first, order, true); order.setPackageType(first.getPackageType()); order.setQuantity(first.getQuantity()); order.setWeightRange(first.getWeightRange()); order.setSpecification(trimToNull(first.getSpecification())); order.setPickupStopCount(pickups.size()); } /** 调用方已创建订单或持有订单行锁,全部站点与订单在同一事务保存。 */ private void saveStops(FlashDeliveryOrder order, List pickups, FlashDeliveryAddressRequest delivery, Date now) { for (int i = 0; i < pickups.size(); i++) insertStop(order.getId(), pickups.get(i), "PICKUP", i, now); insertStop(order.getId(), delivery, "DELIVERY", pickups.size(), now); } private void insertStop(Long orderId, FlashDeliveryAddressRequest address, String type, int index, Date now) { FlashDeliveryOrderStop stop = new FlashDeliveryOrderStop(); BeanUtils.copyProperties(address, stop); stop.setName(address.getName().trim()); stop.setPhone(address.getPhone().trim()); stop.setAddress(address.getAddress().trim()); stop.setAddressDetail(trimToNull(address.getAddressDetail())); stop.setCity(trimToNull(address.getCity())); stop.setArea(trimToNull(address.getArea())); stop.setHandoffMethod(trimToNull(address.getHandoffMethod())); stop.setSpecification(trimToNull(stop.getSpecification())); stop.setOrderId(orderId); stop.setStopType(type); stop.setStopOrder(index); stop.setStatus("PENDING"); stop.setCreateTime(now); if (stopMapper.insert(stop) != 1) throw fail("flash.delivery.state.changed"); } /** 事务内使用当前读,避免骑手资料查询建立的旧快照遮蔽锁等待期间的履约变化;GET 不加锁。 */ private QueryWrapper currentRead(QueryWrapper query) { if (TransactionSynchronizationManager.isActualTransactionActive()) { query.last("FOR UPDATE"); } return query; } private List stops(Long orderId) { return stopMapper.selectList(currentRead(new QueryWrapper() .eq("order_id", orderId).orderByAsc("stop_order", "id"))); } /** 履约必须有真实站点记录,不从旧测试订单的摘要字段合成站点。 */ private List requireStops(FlashDeliveryOrder order) { List rows = stops(order.getId()); if (rows == null || pickupStops(rows).isEmpty() || !Objects.equals(order.getPickupStopCount(), pickupStops(rows).size()) || rows.stream().filter(s -> "DELIVERY".equals(s.getStopType())).count() != 1) { throw fail("flash.delivery.stops.invalid"); } return rows; } private List pickupStops(List rows) { return rows.stream().filter(s -> "PICKUP".equals(s.getStopType())).toList(); } private boolean anyPicked(List rows) { return rows.stream().anyMatch(s -> "PICKUP".equals(s.getStopType()) && "PICKED_UP".equals(s.getStatus())); } private boolean allPicked(List rows) { List pickups = pickupStops(rows); return !pickups.isEmpty() && pickups.stream().allMatch(s -> "PICKED_UP".equals(s.getStatus())); } /** 选择的目标在刷新后保留;该站取货后回到原顺序最早的待取站点。 */ private Long targetStopId(FlashDeliveryOrder order, List rows) { if (CANCELLED.equals(order.getStatus()) || COMPLETED.equals(order.getStatus()) || DELIVERED.equals(order.getStatus())) return null; if (order.getCurrentTargetStopId() != null && rows.stream().anyMatch(s -> Objects.equals(s.getId(), order.getCurrentTargetStopId()) && "PICKUP".equals(s.getStopType()) && "PENDING".equals(s.getStatus()))) { return order.getCurrentTargetStopId(); } return rows.stream().filter(s -> "PICKUP".equals(s.getStopType()) && "PENDING".equals(s.getStatus())) .map(FlashDeliveryOrderStop::getId).findFirst().orElseGet(() -> rows.stream() .filter(s -> "DELIVERY".equals(s.getStopType())).map(FlashDeliveryOrderStop::getId).findFirst().orElse(null)); } /** 事务内先锁订单并读取最新状态,再查询站点,统一取货、调整与取消的并发边界。 */ private FlashDeliveryOrder lockOrder(Long orderId) { FlashDeliveryOrder order = orderId == null ? null : orderMapper.selectByIdForUpdate(orderId); if (order == null) throw fail("flash.delivery.order.not.found"); return order; } private FlashDeliveryOrderStop requireStop(List rows, Long stopId) { return rows.stream().filter(s -> Objects.equals(s.getId(), stopId)).findFirst() .orElseThrow(() -> fail("flash.delivery.stop.invalid")); } /** 选择前往目标只记录履约调整,不修改取件进度、金额或用户站点顺序。 */ @Transactional public FlashDeliveryOrderDetailView selectTarget(Long riderId, Long orderId, Long stopId, FlashDeliveryTargetRequest request) { requireRider(riderId); FlashDeliveryOrder order = lockOrder(orderId); if (!Objects.equals(riderId, order.getRiderId())) throw fail("flash.delivery.order.not.found"); if (request == null || !hasText(request.getClientRequestId()) || tooLong(request.getClientRequestId(), 64)) { throw fail("flash.delivery.client.request.id.invalid"); } String requestId = request.getClientRequestId().trim(); FlashDeliveryTargetAdjustment previous = adjustmentMapper.selectOne(new QueryWrapper() .eq("order_id", orderId).eq("rider_id", riderId).eq("client_request_id", requestId).last("FOR UPDATE")); if (previous != null) { if (!Objects.equals(previous.getToStopId(), stopId) || !Objects.equals(previous.getOrderVersion(), request.getOrderVersion()) || !Objects.equals(previous.getReason(), trimToNull(request.getReason())) || !Objects.equals(previous.getConsentDeclared(), Boolean.TRUE.equals(request.getConsentDeclared()))) { throw fail("flash.delivery.target.request.conflict"); } // 重试返回当前进度,不把已过期的历史目标重新写回订单。 return participantDetail(order, false, false); } if (!ACCEPTED.equals(order.getStatus())) throw fail("flash.delivery.transition.not.allowed"); if (request.getOrderVersion() == null || !Objects.equals(request.getOrderVersion(), order.getVersion())) { throw fail("flash.delivery.state.changed"); } List rows = requireStops(order); FlashDeliveryOrderStop target = requireStop(rows, stopId); if (!"PICKUP".equals(target.getStopType()) || !"PENDING".equals(target.getStatus())) throw fail("flash.delivery.stop.invalid"); Long before = targetStopId(order, rows); if (!Objects.equals(before, stopId) && (!hasText(request.getReason()) || tooLong(request.getReason(), 500) || !Boolean.TRUE.equals(request.getConsentDeclared()))) throw fail("flash.delivery.target.consent.required"); if (tooLong(request.getReason(), 500)) throw fail("flash.delivery.target.consent.required"); Date now = new Date(); FlashDeliveryTargetAdjustment adjustment = new FlashDeliveryTargetAdjustment(); adjustment.setOrderId(orderId); adjustment.setFromStopId(before); adjustment.setToStopId(stopId); adjustment.setRiderId(riderId); adjustment.setClientRequestId(requestId); adjustment.setOrderVersion(request.getOrderVersion()); adjustment.setReason(trimToNull(request.getReason())); adjustment.setConsentDeclared(Boolean.TRUE.equals(request.getConsentDeclared())); adjustment.setCreateTime(now); if (adjustmentMapper.insert(adjustment) != 1) throw fail("flash.delivery.state.changed"); order.setCurrentTargetStopId(stopId); order.setUpdateTime(now); if (orderMapper.updateFulfilment(order) != 1) throw fail("flash.delivery.state.changed"); order.setVersion(order.getVersion() + 1); return participantDetail(order, false, false); } /** 两种取货接口共用同一加锁流程,路径与请求体中的站点编号必须一致。 */ @Transactional public void pickupAtStop(Long riderId, Long orderId, Long stopId, FlashDeliveryProofRequest request) { if (request == null) throw fail("flash.delivery.request.required"); if (request.getStopId() != null && !Objects.equals(request.getStopId(), stopId)) throw fail("flash.delivery.stop.invalid"); pickupStation(riderId, orderId, stopId, request); } private void pickupStation(Long riderId, Long orderId, Long requestedStopId, FlashDeliveryProofRequest request) { requireRider(riderId); FlashDeliveryOrder order = lockOrder(orderId); if (!Objects.equals(riderId, order.getRiderId())) throw fail("flash.delivery.order.not.found"); List rows = requireStops(order); List pickups = pickupStops(rows); Long stopId = requestedStopId; if (stopId == null) { if (pickups.size() != 1) throw fail("flash.delivery.stop.id.required"); stopId = pickups.get(0).getId(); } FlashDeliveryOrderStop stop = requireStop(rows, stopId); if (!"PICKUP".equals(stop.getStopType())) throw fail("flash.delivery.stop.invalid"); // 已确认站点的重试直接成功,不因当前目标或主状态已变化而重复写凭证。 if ("PICKED_UP".equals(stop.getStatus())) return; if (!ACCEPTED.equals(order.getStatus())) throw fail("flash.delivery.transition.not.allowed"); if (!Objects.equals(stopId, targetStopId(order, rows))) throw fail("flash.delivery.target.mismatch"); List urls = validateProofUrls(request == null ? null : request.getImageUrls(), true); Date now = new Date(); stop.setStatus("PICKED_UP"); stop.setPickedUpAt(now); if (stopMapper.updateById(stop) != 1) throw fail("flash.delivery.state.changed"); saveStationImages(orderId, stopId, "PICKUP", "RIDER", riderId, urls, now); order.setPickedUpStopCount((int) pickups.stream().filter(s -> "PICKED_UP".equals(s.getStatus())).count()); order.setCurrentTargetStopId(null); order.setUpdateTime(now); if (allPicked(rows)) { order.setStatus(PICKED_UP); order.setPickedUpAt(now); } if (orderMapper.updateFulfilment(order) != 1) throw fail("flash.delivery.state.changed"); writeLog(orderId, ACCEPTED, order.getStatus(), "RIDER", riderId, "PICKUP_STOP:" + stopId, now); } private void saveStationImages(Long orderId, Long stopId, String proofType, String operatorType, Long operatorId, List urls, Date now) { for (int index = 0; index < urls.size(); index++) { FlashDeliveryOrderImage image = new FlashDeliveryOrderImage(); image.setOrderId(orderId); image.setStopId(stopId); image.setProofType(proofType); image.setOperatorType(operatorType); image.setOperatorId(operatorId); image.setImageUrl(urls.get(index)); image.setSortOrder(index); image.setCreateTime(now); if (imageMapper.insert(image) != 1) throw fail("flash.delivery.state.changed"); } } private List stopViews(List rows, boolean sensitive, List images) { return rows.stream().map(stop -> { FlashDeliveryStopView view = new FlashDeliveryStopView(); BeanUtils.copyProperties(stop, view); if (sensitive) view.setImageUrls(images.stream().filter(i -> Objects.equals(i.getStopId(), stop.getId()) && Objects.equals(i.getProofType(), stop.getStopType())) .map(FlashDeliveryOrderImage::getImageUrl).toList()); else { view.setName(null); view.setPhone(null); view.setLongitude(null); view.setLatitude(null); } return view; }).toList(); } private boolean canUserCancel(FlashDeliveryOrder order) { return FlashDeliveryStateMachine.canUserCancel(order.getStatus()) && Integer.valueOf(0).equals(order.getPickedUpStopCount()); } /** 报价中间结果:命中的运价配置、路线距离和费用明细。 */ private record QuoteContext(FlashDeliveryPricing pricing, RouteDistance route, FlashDeliveryPriceBreakdown breakdown, List pickups) { } }