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.route.FlashDeliveryRouteService; import com.ruoyi.app.flashdelivery.route.GeoPoint; import com.ruoyi.app.flashdelivery.route.RouteDistance; import com.ruoyi.common.exception.ServiceException; 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 org.springframework.dao.DataIntegrityViolationException; 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.util.*; import java.util.function.Function; import static com.ruoyi.system.domain.flash.FlashDeliveryStatus.*; /** * 闪送业务编排服务。 * *

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

*/ @Service public class FlashDeliveryApplicationService { private static final Set SERVICE_TYPES = Set.of("HELP_SEND", "HELP_PICKUP", "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 PACKAGE_SIZES = Set.of("SMALL", "MEDIUM", "LARGE"); private static final Set DELIVERY_MODES = Set.of("NOW", "SCHEDULED"); private static final int MAX_DISTANCE_METERS = 40_000; 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 FlashDeliveryPricingMapper pricingMapper; private final FlashDeliveryOrderImageMapper imageMapper; private final FlashDeliveryOrderLogMapper logMapper; private final InfoUserMapper userMapper; private final FlashDeliveryRouteService routeService; private final FlashDeliveryPricingCalculator pricingCalculator; public FlashDeliveryApplicationService(FlashDeliveryOrderMapper orderMapper, FlashDeliveryPricingMapper pricingMapper, FlashDeliveryOrderImageMapper imageMapper, FlashDeliveryOrderLogMapper logMapper, InfoUserMapper userMapper, FlashDeliveryRouteService routeService, FlashDeliveryPricingCalculator pricingCalculator) { this.orderMapper = orderMapper; this.pricingMapper = pricingMapper; this.imageMapper = imageMapper; this.logMapper = logMapper; this.userMapper = userMapper; this.routeService = routeService; this.pricingCalculator = pricingCalculator; } public FlashDeliveryHomeView home() { FlashDeliveryHomeView view = new FlashDeliveryHomeView(); view.setServices(pricingMapper.selectList(new QueryWrapper() .eq("enabled", true).orderByAsc("id")).stream().map(this::serviceView).toList()); return view; } public FlashDeliveryQuoteView quote(FlashDeliveryQuoteRequest request) { // 报价只使用服务端路线距离和当前启用的计价配置,避免客户端篡改金额。 QuoteContext context = calculateQuote(request); FlashDeliveryQuoteView view = new FlashDeliveryQuoteView(); view.setServiceType(context.pricing().getServiceType()); view.setDistanceMeters(context.route().distanceMeters()); view.setDistanceSource(context.route().source()); view.setEstimatedDurationSeconds(context.route().durationSeconds()); applyBreakdown(view, context.breakdown()); view.setCurrency("TWD"); view.setPricingVersion(context.pricing().getConfigVersion()); return view; } @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(existing, true, false); if (request.getUserNote() != null && request.getUserNote().length() > 500) { throw fail("flash.delivery.note.too.long"); } QuoteContext quote = calculateQuote(request); Date now = new Date(); String deliveryMode = normalizeDeliveryMode(request.getDeliveryMode()); validateSchedule(deliveryMode, request.getScheduledPickupStartAt(), request.getScheduledPickupEndAt(), now); boolean pinRequired = request.getPinRequired() == null || request.getPinRequired(); 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.setStatus(WAITING_ACCEPTANCE); order.setPackageType(request.getPackageType()); order.setPackageSize(request.getPackageSize()); 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.getPickup(), order, true); copyAddress(request.getDelivery(), order, false); order.setDistanceMeters(quote.route().distanceMeters()); order.setDistanceSource(quote.route().source()); order.setEstimatedDurationSeconds(quote.route().durationSeconds()); order.setAmount(quote.breakdown().getAmount()); order.setCurrency("TWD"); order.setPricingVersion(quote.pricing().getConfigVersion()); // 保存计价快照,后台调价只影响新订单,不追溯修改历史订单金额。 order.setStartPrice(quote.pricing().getStartPrice()); order.setMinimumPrice(quote.pricing().getMinimumPrice()); order.setStartDistanceMeters(quote.pricing().getStartDistanceMeters()); order.setPerKmPrice(quote.pricing().getPerKmPrice()); order.setPerMinutePrice(quote.pricing().getPerMinutePrice()); order.setPeakMultiplier(quote.pricing().getPeakMultiplier()); order.setDistanceFee(quote.breakdown().getDistanceFee()); order.setDurationFee(quote.breakdown().getDurationFee()); order.setPeakFee(quote.breakdown().getPeakFee()); order.setUserNote(trimToNull(request.getUserNote())); order.setVersion(0); order.setCreateTime(now); order.setUpdateTime(now); try { orderMapper.insert(order); } catch (DataIntegrityViolationException duplicate) { // 并发重复请求可能同时通过前置查询,最终由数据库唯一键完成幂等收口。 FlashDeliveryOrder concurrent = orderMapper.selectByUserRequestId(userId, requestId); if (concurrent != null) return participantDetail(concurrent, true, false); throw duplicate; } saveImages(order.getId(), "SENDER", "USER", userId, senderImages, now); writeLog(order.getId(), null, WAITING_ACCEPTANCE, "USER", userId, null, now); return participantDetail(order, true, false); } public IPage userOrders(Long userId, int pageNum, int pageSize, String status, String scene, String serviceType) { if (hasText(status) && hasText(scene)) throw fail("flash.delivery.scene.invalid"); QueryWrapper query = new QueryWrapper() .eq("user_id", userId).orderByDesc("create_time"); optionalEq(query, "status", status); applyUserScene(query, scene); validateOptionalServiceType(serviceType); optionalEq(query, "service_type", serviceType); return mapPage(orderMapper.selectPage(page(pageNum, pageSize), query), this::orderView); } public FlashDeliveryOrderDetailView userDetail(Long userId, Long orderId) { FlashDeliveryOrder order = requireOrder(orderId); if (!Objects.equals(userId, order.getUserId())) throw fail("flash.delivery.order.not.found"); return participantDetail(order, true, false); } @Transactional public void userCancel(Long userId, Long orderId, FlashDeliveryReasonRequest request) { FlashDeliveryOrder order = requireOrder(orderId); if (!Objects.equals(userId, order.getUserId())) throw fail("flash.delivery.order.not.found"); if (!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 = requireOrder(orderId); if (!Objects.equals(userId, order.getUserId())) throw fail("flash.delivery.order.not.found"); Date now = new Date(); if (orderMapper.transitionByUser(orderId, userId, DELIVERED, COMPLETED, now) != 1) { throw fail("flash.delivery.complete.not.allowed"); } writeLog(orderId, DELIVERED, COMPLETED, "USER", userId, null, now); } public IPage available(Long riderId, int pageNum, int pageSize, String serviceType) { requireRider(riderId); validateOptionalServiceType(serviceType); QueryWrapper query = new QueryWrapper() .eq("status", WAITING_ACCEPTANCE).isNull("rider_id") .and(item -> item.eq("delivery_mode", "NOW") .or().le("scheduled_pickup_start_at", new Date())); optionalEq(query, "service_type", serviceType); // 第一版不自动派单:加急单优先,其余订单按发布时间由骑手主动抢单。 query.orderByAsc("case when service_type = 'URGENT' then 0 else 1 end", "create_time"); IPage orders = orderMapper.selectPage(page(pageNum, pageSize), query); Page result = new Page<>(orders.getCurrent(), orders.getSize(), orders.getTotal()); result.setRecords(orders.getRecords().stream().map(this::availableView).toList()); return result; } public IPage riderOrders(Long riderId, int pageNum, int pageSize, String status, String scene) { requireRider(riderId); if (hasText(status) && hasText(scene)) throw fail("flash.delivery.scene.invalid"); QueryWrapper query = new QueryWrapper() .eq("rider_id", riderId).orderByDesc("create_time"); optionalEq(query, "status", status); applyRiderScene(query, scene); return mapPage(orderMapper.selectPage(page(pageNum, pageSize), query), this::orderView); } public Object riderDetail(Long riderId, Long orderId) { requireRider(riderId); FlashDeliveryOrder order = requireOrder(orderId); if (WAITING_ACCEPTANCE.equals(order.getStatus()) && order.getRiderId() == null) { if (!isAvailableAt(order, new Date())) throw fail("flash.delivery.order.not.found"); // 未接单骑手只能看到白名单字段和近似坐标,联系人及精确地址在抢单成功后开放。 return availableView(order); } 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) { requireRider(riderId); 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) { transitionWithProof(riderId, orderId, request, ACCEPTED, PICKED_UP, "PICKUP", "picked_up_at"); } @Transactional public void deliver(Long riderId, Long orderId, FlashDeliveryDeliverRequest request) { requireRider(riderId); FlashDeliveryOrder order = requireOrder(orderId); if (!Objects.equals(riderId, order.getRiderId()) || !PICKED_UP.equals(order.getStatus())) { 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"); } transitionWithProof(riderId, orderId, request, PICKED_UP, DELIVERED, "DELIVERY", "delivered_at"); } public List adminPricing() { return pricingMapper.selectList(new QueryWrapper().orderByAsc("id")); } @Transactional public FlashDeliveryPricing updatePricing(Long adminId, String serviceType, FlashDeliveryPricingUpdateRequest request) { validateServiceType(serviceType); if (request == null || request.getStartPrice() == null || request.getStartPrice().signum() <= 0 || request.getMinimumPrice() == null || request.getMinimumPrice().signum() <= 0 || request.getStartDistanceMeters() == null || request.getStartDistanceMeters() <= 0 || request.getPerKmPrice() == null || request.getPerKmPrice().signum() <= 0 || request.getPerMinutePrice() == null || request.getPerMinutePrice().signum() < 0 || request.getPeakMultiplier() == null || request.getPeakMultiplier().compareTo(BigDecimal.ONE) < 0 || request.getEnabled() == null) throw fail("flash.delivery.pricing.invalid"); FlashDeliveryPricing pricing = findPricing(serviceType, false); if (pricing == null) throw fail("flash.delivery.pricing.not.found"); int expectedVersion = pricing.getConfigVersion() == null ? 0 : pricing.getConfigVersion(); Date now = new Date(); BigDecimal startPrice = request.getStartPrice().setScale(2, RoundingMode.HALF_UP); BigDecimal minimumPrice = request.getMinimumPrice().setScale(2, RoundingMode.HALF_UP); BigDecimal perKmPrice = request.getPerKmPrice().setScale(2, RoundingMode.HALF_UP); BigDecimal perMinutePrice = request.getPerMinutePrice().setScale(2, RoundingMode.HALF_UP); BigDecimal peakMultiplier = request.getPeakMultiplier().setScale(2, RoundingMode.HALF_UP); UpdateWrapper update = new UpdateWrapper() .eq("id", pricing.getId()).eq("config_version", expectedVersion) .set("start_price", startPrice) .set("minimum_price", minimumPrice) .set("start_distance_meters", request.getStartDistanceMeters()) .set("per_km_price", perKmPrice) .set("per_minute_price", perMinutePrice) .set("peak_multiplier", peakMultiplier) .set("enabled", request.getEnabled()) .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.setStartPrice(startPrice); pricing.setMinimumPrice(minimumPrice); pricing.setStartDistanceMeters(request.getStartDistanceMeters()); pricing.setPerKmPrice(perKmPrice); pricing.setPerMinutePrice(perMinutePrice); pricing.setPeakMultiplier(peakMultiplier); pricing.setEnabled(request.getEnabled()); pricing.setConfigVersion(expectedVersion + 1); pricing.setUpdatedBy(adminId); pricing.setUpdateTime(now); return pricing; } public IPage adminOrders(int pageNum, int pageSize, String status, String serviceType, String orderNo, Long userId, Long riderId) { validateOptionalServiceType(serviceType); QueryWrapper query = new QueryWrapper().orderByDesc("create_time"); 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); view.setImages(imageMapper.selectList(new QueryWrapper() .eq("order_id", orderId).orderByAsc("proof_type", "sort_order"))); 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 = requireOrder(orderId); if (!FlashDeliveryStateMachine.canAdminCancel(order.getStatus())) throw fail("flash.delivery.cancel.not.allowed"); String reason = requiredReason(request); Date now = new Date(); 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); } @Transactional public boolean complete(Long operatorId, String operatorType, Long orderId) { 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; } 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()); if (!hasText(request.getPackageType()) || !PACKAGE_TYPES.contains(request.getPackageType())) { throw fail("flash.delivery.package.type.invalid"); } if (!hasText(request.getPackageSize()) || !PACKAGE_SIZES.contains(request.getPackageSize())) { throw fail("flash.delivery.package.size.invalid"); } validateAddress(request.getPickup()); validateAddress(request.getDelivery()); if (sameAddressText(request.getPickup(), request.getDelivery()) || request.getPickup().getLatitude().compareTo(request.getDelivery().getLatitude()) == 0 && request.getPickup().getLongitude().compareTo(request.getDelivery().getLongitude()) == 0) { throw fail("flash.delivery.address.same"); } FlashDeliveryPricing pricing = findPricing(request.getServiceType(), true); // 路线服务优先使用 Google Routes;不可用时在服务内部降级为球面直线距离。 RouteDistance route = routeService.calculate( new GeoPoint(request.getPickup().getLatitude(), request.getPickup().getLongitude()), new GeoPoint(request.getDelivery().getLatitude(), request.getDelivery().getLongitude())); if (route.distanceMeters() > MAX_DISTANCE_METERS) throw fail("flash.delivery.distance.too.far"); FlashDeliveryPriceBreakdown breakdown = pricingCalculator.calculateBreakdown(pricing, route.distanceMeters(), route.durationSeconds() == null ? 0 : route.durationSeconds()); return new QuoteContext(pricing, route, breakdown); } private FlashDeliveryPricing findPricing(String serviceType, boolean enabledOnly) { QueryWrapper query = new QueryWrapper() .eq("service_type", serviceType); if (enabledOnly) query.eq("enabled", true); FlashDeliveryPricing pricing = pricingMapper.selectOne(query); if (enabledOnly && pricing == null) throw fail("flash.delivery.service.unavailable"); return pricing; } private void transitionWithProof(Long riderId, Long orderId, FlashDeliveryProofRequest request, String expected, String next, String proofType, String timeColumn) { List urls = validateProofUrls(request == null ? null : request.getImageUrls(), true); requireRider(riderId); Date now = new Date(); if (orderMapper.transitionByRider(orderId, riderId, expected, next, timeColumn, now) != 1) { throw fail("flash.delivery.transition.not.allowed"); } saveImages(orderId, proofType, "RIDER", riderId, urls, now); writeLog(orderId, expected, next, "RIDER", riderId, null, now); } /** 状态、图片和日志处于同一事务;任一图片写入失败会回滚本次业务操作。 */ 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); } } 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(); URI uri = url == null ? null : URI.create(url); if (uri == null || url.length() > 1000 || uri.getHost() == null || !("http".equalsIgnoreCase(uri.getScheme()) || "https".equalsIgnoreCase(uri.getScheme()))) { throw new IllegalArgumentException(); } urls.add(url); } catch (IllegalArgumentException exception) { throw fail("flash.delivery.proof.url.invalid"); } } return urls; } private FlashDeliveryOrderDetailView participantDetail(FlashDeliveryOrder order, boolean userView, boolean hideEvidence) { FlashDeliveryOrderDetailView view = new FlashDeliveryOrderDetailView(); view.setOrder(orderView(order)); if (userView) { view.setDeliveryPinCode(Boolean.TRUE.equals(order.getPinRequired()) ? order.getDeliveryPinCode() : null); view.setRider(riderSummary(order)); } if (hideEvidence || order.getId() == null) { view.setImages(List.of()); view.setLogs(List.of()); } else { view.setImages(imageMapper.selectList(new QueryWrapper() .eq("order_id", order.getId()).orderByAsc("proof_type", "sort_order")) .stream().map(this::imageView).toList()); view.setLogs(logMapper.selectList(new QueryWrapper() .eq("order_id", order.getId()).orderByAsc("create_time", "id")) .stream().map(this::logView).toList()); } return view; } private FlashDeliveryAvailableOrderView availableView(FlashDeliveryOrder order) { // 明确逐字段组装白名单 DTO,禁止直接复制订单实体造成未来新增敏感字段意外泄露。 FlashDeliveryAvailableOrderView view = new FlashDeliveryAvailableOrderView(); view.setId(order.getId()); view.setOrderNo(order.getOrderNo()); view.setServiceType(order.getServiceType()); view.setStatus(order.getStatus()); view.setPackageType(order.getPackageType()); view.setPackageSize(order.getPackageSize()); view.setDeliveryMode(order.getDeliveryMode()); view.setScheduledPickupStartAt(order.getScheduledPickupStartAt()); view.setScheduledPickupEndAt(order.getScheduledPickupEndAt()); view.setPickupCity(order.getPickupCity()); view.setPickupArea(order.getPickupArea()); view.setDeliveryCity(order.getDeliveryCity()); view.setDeliveryArea(order.getDeliveryArea()); view.setPickupLatitudeApprox(approximate(order.getPickupLatitude())); view.setPickupLongitudeApprox(approximate(order.getPickupLongitude())); view.setDeliveryLatitudeApprox(approximate(order.getDeliveryLatitude())); view.setDeliveryLongitudeApprox(approximate(order.getDeliveryLongitude())); view.setDistanceMeters(order.getDistanceMeters()); view.setEstimatedDurationSeconds(order.getEstimatedDurationSeconds()); view.setAmount(order.getAmount()); view.setCurrency(order.getCurrency()); view.setCreateTime(order.getCreateTime()); return view; } private FlashDeliveryServiceView serviceView(FlashDeliveryPricing pricing) { FlashDeliveryServiceView view = new FlashDeliveryServiceView(); view.setServiceType(pricing.getServiceType()); view.setStartPrice(pricing.getStartPrice()); view.setMinimumPrice(pricing.getMinimumPrice()); view.setStartDistanceMeters(pricing.getStartDistanceMeters()); view.setPerKmPrice(pricing.getPerKmPrice()); view.setPerMinutePrice(pricing.getPerMinutePrice()); view.setPeakMultiplier(pricing.getPeakMultiplier()); view.setPricingVersion(pricing.getConfigVersion()); view.setCurrency("TWD"); return view; } private FlashDeliveryOrderView orderView(FlashDeliveryOrder order) { FlashDeliveryOrderView view = new FlashDeliveryOrderView(); view.setId(order.getId()); view.setOrderNo(order.getOrderNo()); view.setServiceType(order.getServiceType()); view.setStatus(order.getStatus()); view.setPackageType(order.getPackageType()); view.setPackageSize(order.getPackageSize()); view.setDeliveryMode(order.getDeliveryMode()); view.setScheduledPickupStartAt(order.getScheduledPickupStartAt()); view.setScheduledPickupEndAt(order.getScheduledPickupEndAt()); view.setPinRequired(order.getPinRequired()); view.setPickup(addressView(order, true)); view.setDelivery(addressView(order, false)); view.setDistanceMeters(order.getDistanceMeters()); view.setDistanceSource(order.getDistanceSource()); view.setEstimatedDurationSeconds(order.getEstimatedDurationSeconds()); view.setAmount(order.getAmount()); view.setCurrency(order.getCurrency()); view.setUserNote(order.getUserNote()); 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()); return view; } private FlashDeliveryAddressView addressView(FlashDeliveryOrder order, boolean pickup) { FlashDeliveryAddressView view = new FlashDeliveryAddressView(); if (pickup) { view.setName(order.getPickupName()); view.setPhone(order.getPickupPhone()); view.setAddress(order.getPickupAddress()); view.setAddressDetail(order.getPickupAddressDetail()); view.setCity(order.getPickupCity()); view.setArea(order.getPickupArea()); view.setHandoffMethod(order.getPickupHandoffMethod()); view.setLongitude(order.getPickupLongitude()); view.setLatitude(order.getPickupLatitude()); } else { view.setName(order.getDeliveryName()); view.setPhone(order.getDeliveryPhone()); view.setAddress(order.getDeliveryAddress()); view.setAddressDetail(order.getDeliveryAddressDetail()); view.setCity(order.getDeliveryCity()); view.setArea(order.getDeliveryArea()); view.setHandoffMethod(order.getDeliveryHandoffMethod()); view.setLongitude(order.getDeliveryLongitude()); view.setLatitude(order.getDeliveryLatitude()); } return view; } private FlashDeliveryImageView imageView(FlashDeliveryOrderImage image) { FlashDeliveryImageView view = new FlashDeliveryImageView(); view.setProofType(image.getProofType()); view.setImageUrl(image.getImageUrl()); view.setSortOrder(image.getSortOrder()); view.setCreateTime(image.getCreateTime()); return view; } private FlashDeliveryLogView logView(FlashDeliveryOrderLog log) { FlashDeliveryLogView view = new FlashDeliveryLogView(); view.setFromStatus(log.getFromStatus()); view.setToStatus(log.getToStatus()); view.setOperatorType(log.getOperatorType()); view.setReason(log.getReason()); view.setCreateTime(log.getCreateTime()); 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.setStartPrice(breakdown.getStartPrice()); view.setMinimumPrice(breakdown.getMinimumPrice()); view.setDistanceFee(breakdown.getDistanceFee()); view.setDurationFee(breakdown.getDurationFee()); view.setSubtotal(breakdown.getSubtotal()); view.setPeakMultiplier(breakdown.getPeakMultiplier()); view.setPeakFee(breakdown.getPeakFee()); 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 void requireRider(Long riderId) { InfoUser rider = riderId == null ? null : userMapper.selectInfoUserByUserId(riderId); if (rider == null || !"2".equals(rider.getUserType())) throw fail("flash.delivery.rider.required"); } 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 validateOptionalServiceType(String value) { if (hasText(value)) validateServiceType(value); } private void requireUserId(Long userId) { if (userId == null || userId <= 0) throw fail("flash.delivery.auth.required"); } 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; } private void applyUserScene(QueryWrapper query, String scene) { if (!hasText(scene) || "all".equals(scene)) return; switch (scene.trim()) { case "matching" -> query.eq("status", WAITING_ACCEPTANCE); case "progress" -> query.in("status", ACCEPTED, PICKED_UP, DELIVERED); case "done" -> query.eq("status", COMPLETED); case "cancelled" -> query.eq("status", CANCELLED); default -> throw fail("flash.delivery.scene.invalid"); } } private void applyRiderScene(QueryWrapper query, String scene) { if (!hasText(scene) || "all".equals(scene)) return; switch (scene.trim()) { case "pickup" -> query.eq("status", ACCEPTED); case "delivering" -> query.eq("status", PICKED_UP); case "completed" -> query.in("status", DELIVERED, COMPLETED); case "cancelled" -> query.eq("status", CANCELLED); default -> throw fail("flash.delivery.scene.invalid"); } } 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; } 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 BigDecimal approximate(BigDecimal value) { return value == null ? null : value.setScale(2, RoundingMode.HALF_UP); } 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 record QuoteContext(FlashDeliveryPricing pricing, RouteDistance route, FlashDeliveryPriceBreakdown breakdown) { } }