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) { }
}