FlashDeliveryApplicationService.java 41 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760
  1. package com.ruoyi.app.flashdelivery.service;
  2. import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
  3. import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
  4. import com.baomidou.mybatisplus.core.metadata.IPage;
  5. import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
  6. import com.ruoyi.app.flashdelivery.dto.*;
  7. import com.ruoyi.app.flashdelivery.route.FlashDeliveryRouteService;
  8. import com.ruoyi.app.flashdelivery.route.GeoPoint;
  9. import com.ruoyi.app.flashdelivery.route.RouteDistance;
  10. import com.ruoyi.common.exception.ServiceException;
  11. import com.ruoyi.common.utils.MessageUtils;
  12. import com.ruoyi.common.utils.StringUtils;
  13. import com.ruoyi.system.domain.InfoUser;
  14. import com.ruoyi.system.domain.flash.*;
  15. import com.ruoyi.system.mapper.InfoUserMapper;
  16. import com.ruoyi.system.mapper.flash.*;
  17. import org.springframework.dao.DataIntegrityViolationException;
  18. import org.springframework.stereotype.Service;
  19. import org.springframework.transaction.annotation.Transactional;
  20. import java.net.URI;
  21. import java.math.BigDecimal;
  22. import java.math.RoundingMode;
  23. import java.security.SecureRandom;
  24. import java.util.*;
  25. import java.util.function.Function;
  26. import static com.ruoyi.system.domain.flash.FlashDeliveryStatus.*;
  27. /**
  28. * 闪送业务编排服务。
  29. *
  30. * <p>负责报价、订单创建、抢单、履约状态流转、平台介入和审计日志。
  31. * 订单身份只接受 Controller 从 token 解析后的用户 ID,客户端传入的金额、距离或操作人均不可信。</p>
  32. */
  33. @Service
  34. public class FlashDeliveryApplicationService {
  35. private static final Set<String> SERVICE_TYPES = Set.of("HELP_SEND", "HELP_PICKUP", "URGENT");
  36. private static final Set<String> PACKAGE_TYPES = Set.of("DOCUMENT", "GIFT", "CLOTHING", "BEAUTY",
  37. "DAILY_NECESSITIES", "FOOD_INGREDIENTS", "ELECTRONICS", "SMALL_APPLIANCE", "OTHER");
  38. private static final Set<String> PACKAGE_SIZES = Set.of("SMALL", "MEDIUM", "LARGE");
  39. private static final Set<String> DELIVERY_MODES = Set.of("NOW", "SCHEDULED");
  40. private static final int MAX_DISTANCE_METERS = 40_000;
  41. private static final long SCHEDULE_SLOT_MILLIS = 30L * 60L * 1000L;
  42. private static final long MAX_SCHEDULE_DELAY_MILLIS = 3L * 24L * 60L * 60L * 1000L;
  43. private static final SecureRandom PIN_RANDOM = new SecureRandom();
  44. private final FlashDeliveryOrderMapper orderMapper;
  45. private final FlashDeliveryPricingMapper pricingMapper;
  46. private final FlashDeliveryOrderImageMapper imageMapper;
  47. private final FlashDeliveryOrderLogMapper logMapper;
  48. private final InfoUserMapper userMapper;
  49. private final FlashDeliveryRouteService routeService;
  50. private final FlashDeliveryPricingCalculator pricingCalculator;
  51. public FlashDeliveryApplicationService(FlashDeliveryOrderMapper orderMapper,
  52. FlashDeliveryPricingMapper pricingMapper,
  53. FlashDeliveryOrderImageMapper imageMapper,
  54. FlashDeliveryOrderLogMapper logMapper,
  55. InfoUserMapper userMapper,
  56. FlashDeliveryRouteService routeService,
  57. FlashDeliveryPricingCalculator pricingCalculator) {
  58. this.orderMapper = orderMapper;
  59. this.pricingMapper = pricingMapper;
  60. this.imageMapper = imageMapper;
  61. this.logMapper = logMapper;
  62. this.userMapper = userMapper;
  63. this.routeService = routeService;
  64. this.pricingCalculator = pricingCalculator;
  65. }
  66. public FlashDeliveryHomeView home() {
  67. FlashDeliveryHomeView view = new FlashDeliveryHomeView();
  68. view.setServices(pricingMapper.selectList(new QueryWrapper<FlashDeliveryPricing>()
  69. .eq("enabled", true).orderByAsc("id")).stream().map(this::serviceView).toList());
  70. return view;
  71. }
  72. public FlashDeliveryQuoteView quote(FlashDeliveryQuoteRequest request) {
  73. // 报价只使用服务端路线距离和当前启用的计价配置,避免客户端篡改金额。
  74. QuoteContext context = calculateQuote(request);
  75. FlashDeliveryQuoteView view = new FlashDeliveryQuoteView();
  76. view.setServiceType(context.pricing().getServiceType());
  77. view.setDistanceMeters(context.route().distanceMeters());
  78. view.setDistanceSource(context.route().source());
  79. view.setEstimatedDurationSeconds(context.route().durationSeconds());
  80. applyBreakdown(view, context.breakdown());
  81. view.setCurrency("TWD");
  82. view.setPricingVersion(context.pricing().getConfigVersion());
  83. return view;
  84. }
  85. @Transactional
  86. public FlashDeliveryOrderDetailView create(Long userId, FlashDeliveryCreateRequest request) {
  87. requireUserId(userId);
  88. if (request == null || !hasText(request.getClientRequestId())
  89. || request.getClientRequestId().trim().length() > 64) {
  90. throw fail("flash.delivery.client.request.id.invalid");
  91. }
  92. String requestId = request.getClientRequestId().trim();
  93. // 用户 ID + clientRequestId 构成幂等键;重复请求直接返回第一次创建的订单。
  94. FlashDeliveryOrder existing = orderMapper.selectByUserRequestId(userId, requestId);
  95. if (existing != null) return participantDetail(existing, true, false);
  96. if (request.getUserNote() != null && request.getUserNote().length() > 500) {
  97. throw fail("flash.delivery.note.too.long");
  98. }
  99. QuoteContext quote = calculateQuote(request);
  100. Date now = new Date();
  101. String deliveryMode = normalizeDeliveryMode(request.getDeliveryMode());
  102. validateSchedule(deliveryMode, request.getScheduledPickupStartAt(), request.getScheduledPickupEndAt(), now);
  103. boolean pinRequired = request.getPinRequired() == null || request.getPinRequired();
  104. List<String> senderImages = validateProofUrls(request.getSenderImageUrls(), false);
  105. FlashDeliveryOrder order = new FlashDeliveryOrder();
  106. order.setOrderNo("FD" + UUID.randomUUID().toString().replace("-", "").substring(0, 24).toUpperCase(Locale.ROOT));
  107. order.setClientRequestId(requestId);
  108. order.setUserId(userId);
  109. order.setServiceType(request.getServiceType());
  110. order.setStatus(WAITING_ACCEPTANCE);
  111. order.setPackageType(request.getPackageType());
  112. order.setPackageSize(request.getPackageSize());
  113. order.setDeliveryMode(deliveryMode);
  114. order.setScheduledPickupStartAt("SCHEDULED".equals(deliveryMode) ? request.getScheduledPickupStartAt() : null);
  115. order.setScheduledPickupEndAt("SCHEDULED".equals(deliveryMode) ? request.getScheduledPickupEndAt() : null);
  116. order.setPinRequired(pinRequired);
  117. order.setDeliveryPinCode(pinRequired ? String.format(Locale.ROOT, "%04d", PIN_RANDOM.nextInt(10_000)) : null);
  118. copyAddress(request.getPickup(), order, true);
  119. copyAddress(request.getDelivery(), order, false);
  120. order.setDistanceMeters(quote.route().distanceMeters());
  121. order.setDistanceSource(quote.route().source());
  122. order.setEstimatedDurationSeconds(quote.route().durationSeconds());
  123. order.setAmount(quote.breakdown().getAmount());
  124. order.setCurrency("TWD");
  125. order.setPricingVersion(quote.pricing().getConfigVersion());
  126. // 保存计价快照,后台调价只影响新订单,不追溯修改历史订单金额。
  127. order.setStartPrice(quote.pricing().getStartPrice());
  128. order.setMinimumPrice(quote.pricing().getMinimumPrice());
  129. order.setStartDistanceMeters(quote.pricing().getStartDistanceMeters());
  130. order.setPerKmPrice(quote.pricing().getPerKmPrice());
  131. order.setPerMinutePrice(quote.pricing().getPerMinutePrice());
  132. order.setPeakMultiplier(quote.pricing().getPeakMultiplier());
  133. order.setDistanceFee(quote.breakdown().getDistanceFee());
  134. order.setDurationFee(quote.breakdown().getDurationFee());
  135. order.setPeakFee(quote.breakdown().getPeakFee());
  136. order.setUserNote(trimToNull(request.getUserNote()));
  137. order.setVersion(0);
  138. order.setCreateTime(now);
  139. order.setUpdateTime(now);
  140. try {
  141. orderMapper.insert(order);
  142. } catch (DataIntegrityViolationException duplicate) {
  143. // 并发重复请求可能同时通过前置查询,最终由数据库唯一键完成幂等收口。
  144. FlashDeliveryOrder concurrent = orderMapper.selectByUserRequestId(userId, requestId);
  145. if (concurrent != null) return participantDetail(concurrent, true, false);
  146. throw duplicate;
  147. }
  148. saveImages(order.getId(), "SENDER", "USER", userId, senderImages, now);
  149. writeLog(order.getId(), null, WAITING_ACCEPTANCE, "USER", userId, null, now);
  150. return participantDetail(order, true, false);
  151. }
  152. public IPage<FlashDeliveryOrderView> userOrders(Long userId, int pageNum, int pageSize,
  153. String status, String scene, String serviceType) {
  154. if (hasText(status) && hasText(scene)) throw fail("flash.delivery.scene.invalid");
  155. QueryWrapper<FlashDeliveryOrder> query = new QueryWrapper<FlashDeliveryOrder>()
  156. .eq("user_id", userId).orderByDesc("create_time");
  157. optionalEq(query, "status", status);
  158. applyUserScene(query, scene);
  159. validateOptionalServiceType(serviceType);
  160. optionalEq(query, "service_type", serviceType);
  161. return mapPage(orderMapper.selectPage(page(pageNum, pageSize), query), this::orderView);
  162. }
  163. public FlashDeliveryOrderDetailView userDetail(Long userId, Long orderId) {
  164. FlashDeliveryOrder order = requireOrder(orderId);
  165. if (!Objects.equals(userId, order.getUserId())) throw fail("flash.delivery.order.not.found");
  166. return participantDetail(order, true, false);
  167. }
  168. @Transactional
  169. public void userCancel(Long userId, Long orderId, FlashDeliveryReasonRequest request) {
  170. FlashDeliveryOrder order = requireOrder(orderId);
  171. if (!Objects.equals(userId, order.getUserId())) throw fail("flash.delivery.order.not.found");
  172. if (!FlashDeliveryStateMachine.canUserCancel(order.getStatus())) throw fail("flash.delivery.cancel.not.allowed");
  173. String reason = requiredReason(request);
  174. Date now = new Date();
  175. if (orderMapper.cancel(orderId, order.getStatus(), "USER", userId, reason, now) != 1) {
  176. throw fail("flash.delivery.state.changed");
  177. }
  178. writeLog(orderId, order.getStatus(), CANCELLED, "USER", userId, reason, now);
  179. }
  180. @Transactional
  181. public void confirmReceipt(Long userId, Long orderId) {
  182. FlashDeliveryOrder order = requireOrder(orderId);
  183. if (!Objects.equals(userId, order.getUserId())) throw fail("flash.delivery.order.not.found");
  184. Date now = new Date();
  185. if (orderMapper.transitionByUser(orderId, userId, DELIVERED, COMPLETED, now) != 1) {
  186. throw fail("flash.delivery.complete.not.allowed");
  187. }
  188. writeLog(orderId, DELIVERED, COMPLETED, "USER", userId, null, now);
  189. }
  190. public IPage<FlashDeliveryAvailableOrderView> available(Long riderId, int pageNum, int pageSize,
  191. String serviceType) {
  192. requireRider(riderId);
  193. validateOptionalServiceType(serviceType);
  194. QueryWrapper<FlashDeliveryOrder> query = new QueryWrapper<FlashDeliveryOrder>()
  195. .eq("status", WAITING_ACCEPTANCE).isNull("rider_id")
  196. .and(item -> item.eq("delivery_mode", "NOW")
  197. .or().le("scheduled_pickup_start_at", new Date()));
  198. optionalEq(query, "service_type", serviceType);
  199. // 第一版不自动派单:加急单优先,其余订单按发布时间由骑手主动抢单。
  200. query.orderByAsc("case when service_type = 'URGENT' then 0 else 1 end", "create_time");
  201. IPage<FlashDeliveryOrder> orders = orderMapper.selectPage(page(pageNum, pageSize), query);
  202. Page<FlashDeliveryAvailableOrderView> result = new Page<>(orders.getCurrent(), orders.getSize(), orders.getTotal());
  203. result.setRecords(orders.getRecords().stream().map(this::availableView).toList());
  204. return result;
  205. }
  206. public IPage<FlashDeliveryOrderView> riderOrders(Long riderId, int pageNum, int pageSize,
  207. String status, String scene) {
  208. requireRider(riderId);
  209. if (hasText(status) && hasText(scene)) throw fail("flash.delivery.scene.invalid");
  210. QueryWrapper<FlashDeliveryOrder> query = new QueryWrapper<FlashDeliveryOrder>()
  211. .eq("rider_id", riderId).orderByDesc("create_time");
  212. optionalEq(query, "status", status);
  213. applyRiderScene(query, scene);
  214. return mapPage(orderMapper.selectPage(page(pageNum, pageSize), query), this::orderView);
  215. }
  216. public Object riderDetail(Long riderId, Long orderId) {
  217. requireRider(riderId);
  218. FlashDeliveryOrder order = requireOrder(orderId);
  219. if (WAITING_ACCEPTANCE.equals(order.getStatus()) && order.getRiderId() == null) {
  220. if (!isAvailableAt(order, new Date())) throw fail("flash.delivery.order.not.found");
  221. // 未接单骑手只能看到白名单字段和近似坐标,联系人及精确地址在抢单成功后开放。
  222. return availableView(order);
  223. }
  224. if (!Objects.equals(riderId, order.getRiderId())) throw fail("flash.delivery.order.not.found");
  225. return participantDetail(order, false, false);
  226. }
  227. @Transactional
  228. public FlashDeliveryOrderDetailView accept(Long riderId, Long orderId) {
  229. requireRider(riderId);
  230. Date now = new Date();
  231. // Mapper 使用“待接单且 rider_id 为空”的条件更新,受影响行数为 1 才表示抢单成功。
  232. if (orderMapper.accept(orderId, riderId, now) != 1) throw fail("flash.delivery.order.already.accepted");
  233. writeLog(orderId, WAITING_ACCEPTANCE, ACCEPTED, "RIDER", riderId, null, now);
  234. return participantDetail(requireOrder(orderId), false, false);
  235. }
  236. @Transactional
  237. public void pickup(Long riderId, Long orderId, FlashDeliveryProofRequest request) {
  238. transitionWithProof(riderId, orderId, request, ACCEPTED, PICKED_UP, "PICKUP", "picked_up_at");
  239. }
  240. @Transactional
  241. public void deliver(Long riderId, Long orderId, FlashDeliveryDeliverRequest request) {
  242. requireRider(riderId);
  243. FlashDeliveryOrder order = requireOrder(orderId);
  244. if (!Objects.equals(riderId, order.getRiderId()) || !PICKED_UP.equals(order.getStatus())) {
  245. throw fail("flash.delivery.transition.not.allowed");
  246. }
  247. if (Boolean.TRUE.equals(order.getPinRequired())
  248. && (request == null || !Objects.equals(order.getDeliveryPinCode(), trimToNull(request.getPinCode())))) {
  249. throw fail("flash.delivery.pin.invalid");
  250. }
  251. transitionWithProof(riderId, orderId, request, PICKED_UP, DELIVERED, "DELIVERY", "delivered_at");
  252. }
  253. public List<FlashDeliveryPricing> adminPricing() {
  254. return pricingMapper.selectList(new QueryWrapper<FlashDeliveryPricing>().orderByAsc("id"));
  255. }
  256. @Transactional
  257. public FlashDeliveryPricing updatePricing(Long adminId, String serviceType,
  258. FlashDeliveryPricingUpdateRequest request) {
  259. validateServiceType(serviceType);
  260. if (request == null || request.getStartPrice() == null || request.getStartPrice().signum() <= 0
  261. || request.getMinimumPrice() == null || request.getMinimumPrice().signum() <= 0
  262. || request.getStartDistanceMeters() == null || request.getStartDistanceMeters() <= 0
  263. || request.getPerKmPrice() == null || request.getPerKmPrice().signum() <= 0
  264. || request.getPerMinutePrice() == null || request.getPerMinutePrice().signum() < 0
  265. || request.getPeakMultiplier() == null
  266. || request.getPeakMultiplier().compareTo(BigDecimal.ONE) < 0
  267. || request.getEnabled() == null) throw fail("flash.delivery.pricing.invalid");
  268. FlashDeliveryPricing pricing = findPricing(serviceType, false);
  269. if (pricing == null) throw fail("flash.delivery.pricing.not.found");
  270. int expectedVersion = pricing.getConfigVersion() == null ? 0 : pricing.getConfigVersion();
  271. Date now = new Date();
  272. BigDecimal startPrice = request.getStartPrice().setScale(2, RoundingMode.HALF_UP);
  273. BigDecimal minimumPrice = request.getMinimumPrice().setScale(2, RoundingMode.HALF_UP);
  274. BigDecimal perKmPrice = request.getPerKmPrice().setScale(2, RoundingMode.HALF_UP);
  275. BigDecimal perMinutePrice = request.getPerMinutePrice().setScale(2, RoundingMode.HALF_UP);
  276. BigDecimal peakMultiplier = request.getPeakMultiplier().setScale(2, RoundingMode.HALF_UP);
  277. UpdateWrapper<FlashDeliveryPricing> update = new UpdateWrapper<FlashDeliveryPricing>()
  278. .eq("id", pricing.getId()).eq("config_version", expectedVersion)
  279. .set("start_price", startPrice)
  280. .set("minimum_price", minimumPrice)
  281. .set("start_distance_meters", request.getStartDistanceMeters())
  282. .set("per_km_price", perKmPrice)
  283. .set("per_minute_price", perMinutePrice)
  284. .set("peak_multiplier", peakMultiplier)
  285. .set("enabled", request.getEnabled())
  286. .set("updated_by", adminId)
  287. .set("update_time", now)
  288. .setSql("config_version = config_version + 1");
  289. // 版本条件防止两个管理员同时保存时后提交者静默覆盖先提交者。
  290. if (pricingMapper.update(null, update) != 1) throw fail("flash.delivery.pricing.changed");
  291. pricing.setStartPrice(startPrice);
  292. pricing.setMinimumPrice(minimumPrice);
  293. pricing.setStartDistanceMeters(request.getStartDistanceMeters());
  294. pricing.setPerKmPrice(perKmPrice);
  295. pricing.setPerMinutePrice(perMinutePrice);
  296. pricing.setPeakMultiplier(peakMultiplier);
  297. pricing.setEnabled(request.getEnabled());
  298. pricing.setConfigVersion(expectedVersion + 1);
  299. pricing.setUpdatedBy(adminId);
  300. pricing.setUpdateTime(now);
  301. return pricing;
  302. }
  303. public IPage<FlashDeliveryOrder> adminOrders(int pageNum, int pageSize, String status,
  304. String serviceType, String orderNo,
  305. Long userId, Long riderId) {
  306. validateOptionalServiceType(serviceType);
  307. QueryWrapper<FlashDeliveryOrder> query = new QueryWrapper<FlashDeliveryOrder>().orderByDesc("create_time");
  308. optionalEq(query, "status", status);
  309. optionalEq(query, "service_type", serviceType);
  310. if (hasText(orderNo)) query.like("order_no", orderNo.trim());
  311. if (userId != null) query.eq("user_id", userId);
  312. if (riderId != null) query.eq("rider_id", riderId);
  313. return orderMapper.selectPage(page(pageNum, pageSize), query);
  314. }
  315. public FlashDeliveryAdminOrderDetailView adminDetail(Long orderId) {
  316. FlashDeliveryOrder order = requireOrder(orderId);
  317. FlashDeliveryAdminOrderDetailView view = new FlashDeliveryAdminOrderDetailView();
  318. view.setOrder(order);
  319. view.setImages(imageMapper.selectList(new QueryWrapper<FlashDeliveryOrderImage>()
  320. .eq("order_id", orderId).orderByAsc("proof_type", "sort_order")));
  321. view.setLogs(logMapper.selectList(new QueryWrapper<FlashDeliveryOrderLog>()
  322. .eq("order_id", orderId).orderByAsc("create_time", "id")));
  323. return view;
  324. }
  325. @Transactional
  326. public void adminCancel(Long adminId, Long orderId, FlashDeliveryReasonRequest request) {
  327. FlashDeliveryOrder order = requireOrder(orderId);
  328. if (!FlashDeliveryStateMachine.canAdminCancel(order.getStatus())) throw fail("flash.delivery.cancel.not.allowed");
  329. String reason = requiredReason(request);
  330. Date now = new Date();
  331. if (orderMapper.cancel(orderId, order.getStatus(), "ADMIN", adminId, reason, now) != 1) {
  332. throw fail("flash.delivery.state.changed");
  333. }
  334. writeLog(orderId, order.getStatus(), CANCELLED, "ADMIN", adminId, reason, now);
  335. }
  336. @Transactional
  337. public boolean complete(Long operatorId, String operatorType, Long orderId) {
  338. Date now = new Date();
  339. if (orderMapper.transitionByStatus(orderId, DELIVERED, COMPLETED, "completed_at", now) != 1) return false;
  340. writeLog(orderId, DELIVERED, COMPLETED, operatorType, operatorId, null, now);
  341. return true;
  342. }
  343. public List<FlashDeliveryOrder> autoCompleteCandidates(Date deadline, int limit) {
  344. return orderMapper.selectAutoCompletable(deadline, Math.min(Math.max(limit, 1), 500));
  345. }
  346. private QuoteContext calculateQuote(FlashDeliveryQuoteRequest request) {
  347. if (request == null) throw fail("flash.delivery.request.required");
  348. validateServiceType(request.getServiceType());
  349. if (!hasText(request.getPackageType()) || !PACKAGE_TYPES.contains(request.getPackageType())) {
  350. throw fail("flash.delivery.package.type.invalid");
  351. }
  352. if (!hasText(request.getPackageSize()) || !PACKAGE_SIZES.contains(request.getPackageSize())) {
  353. throw fail("flash.delivery.package.size.invalid");
  354. }
  355. validateAddress(request.getPickup());
  356. validateAddress(request.getDelivery());
  357. if (sameAddressText(request.getPickup(), request.getDelivery())
  358. || request.getPickup().getLatitude().compareTo(request.getDelivery().getLatitude()) == 0
  359. && request.getPickup().getLongitude().compareTo(request.getDelivery().getLongitude()) == 0) {
  360. throw fail("flash.delivery.address.same");
  361. }
  362. FlashDeliveryPricing pricing = findPricing(request.getServiceType(), true);
  363. // 路线服务优先使用 Google Routes;不可用时在服务内部降级为球面直线距离。
  364. RouteDistance route = routeService.calculate(
  365. new GeoPoint(request.getPickup().getLatitude(), request.getPickup().getLongitude()),
  366. new GeoPoint(request.getDelivery().getLatitude(), request.getDelivery().getLongitude()));
  367. if (route.distanceMeters() > MAX_DISTANCE_METERS) throw fail("flash.delivery.distance.too.far");
  368. FlashDeliveryPriceBreakdown breakdown = pricingCalculator.calculateBreakdown(pricing,
  369. route.distanceMeters(), route.durationSeconds() == null ? 0 : route.durationSeconds());
  370. return new QuoteContext(pricing, route, breakdown);
  371. }
  372. private FlashDeliveryPricing findPricing(String serviceType, boolean enabledOnly) {
  373. QueryWrapper<FlashDeliveryPricing> query = new QueryWrapper<FlashDeliveryPricing>()
  374. .eq("service_type", serviceType);
  375. if (enabledOnly) query.eq("enabled", true);
  376. FlashDeliveryPricing pricing = pricingMapper.selectOne(query);
  377. if (enabledOnly && pricing == null) throw fail("flash.delivery.service.unavailable");
  378. return pricing;
  379. }
  380. private void transitionWithProof(Long riderId, Long orderId, FlashDeliveryProofRequest request,
  381. String expected, String next, String proofType, String timeColumn) {
  382. List<String> urls = validateProofUrls(request == null ? null : request.getImageUrls(), true);
  383. requireRider(riderId);
  384. Date now = new Date();
  385. if (orderMapper.transitionByRider(orderId, riderId, expected, next, timeColumn, now) != 1) {
  386. throw fail("flash.delivery.transition.not.allowed");
  387. }
  388. saveImages(orderId, proofType, "RIDER", riderId, urls, now);
  389. writeLog(orderId, expected, next, "RIDER", riderId, null, now);
  390. }
  391. /** 状态、图片和日志处于同一事务;任一图片写入失败会回滚本次业务操作。 */
  392. private void saveImages(Long orderId, String proofType, String operatorType,
  393. Long operatorId, List<String> urls, Date now) {
  394. for (int index = 0; index < urls.size(); index++) {
  395. FlashDeliveryOrderImage image = new FlashDeliveryOrderImage();
  396. image.setOrderId(orderId);
  397. image.setProofType(proofType);
  398. image.setImageUrl(urls.get(index));
  399. image.setSortOrder(index);
  400. image.setOperatorType(operatorType);
  401. image.setOperatorId(operatorId);
  402. image.setCreateTime(now);
  403. imageMapper.insert(image);
  404. }
  405. }
  406. private List<String> validateProofUrls(List<String> values, boolean required) {
  407. if (values == null || values.isEmpty()) {
  408. if (!required) return List.of();
  409. throw fail("flash.delivery.proof.required");
  410. }
  411. if (values.size() > 9) throw fail("flash.delivery.proof.too.many");
  412. List<String> urls = new ArrayList<>();
  413. for (String value : values) {
  414. try {
  415. String url = value == null ? null : value.trim();
  416. URI uri = url == null ? null : URI.create(url);
  417. if (uri == null || url.length() > 1000 || uri.getHost() == null
  418. || !("http".equalsIgnoreCase(uri.getScheme()) || "https".equalsIgnoreCase(uri.getScheme()))) {
  419. throw new IllegalArgumentException();
  420. }
  421. urls.add(url);
  422. } catch (IllegalArgumentException exception) {
  423. throw fail("flash.delivery.proof.url.invalid");
  424. }
  425. }
  426. return urls;
  427. }
  428. private FlashDeliveryOrderDetailView participantDetail(FlashDeliveryOrder order,
  429. boolean userView,
  430. boolean hideEvidence) {
  431. FlashDeliveryOrderDetailView view = new FlashDeliveryOrderDetailView();
  432. view.setOrder(orderView(order));
  433. if (userView) {
  434. view.setDeliveryPinCode(Boolean.TRUE.equals(order.getPinRequired()) ? order.getDeliveryPinCode() : null);
  435. view.setRider(riderSummary(order));
  436. }
  437. if (hideEvidence || order.getId() == null) {
  438. view.setImages(List.of());
  439. view.setLogs(List.of());
  440. } else {
  441. view.setImages(imageMapper.selectList(new QueryWrapper<FlashDeliveryOrderImage>()
  442. .eq("order_id", order.getId()).orderByAsc("proof_type", "sort_order"))
  443. .stream().map(this::imageView).toList());
  444. view.setLogs(logMapper.selectList(new QueryWrapper<FlashDeliveryOrderLog>()
  445. .eq("order_id", order.getId()).orderByAsc("create_time", "id"))
  446. .stream().map(this::logView).toList());
  447. }
  448. return view;
  449. }
  450. private FlashDeliveryAvailableOrderView availableView(FlashDeliveryOrder order) {
  451. // 明确逐字段组装白名单 DTO,禁止直接复制订单实体造成未来新增敏感字段意外泄露。
  452. FlashDeliveryAvailableOrderView view = new FlashDeliveryAvailableOrderView();
  453. view.setId(order.getId());
  454. view.setOrderNo(order.getOrderNo());
  455. view.setServiceType(order.getServiceType());
  456. view.setStatus(order.getStatus());
  457. view.setPackageType(order.getPackageType());
  458. view.setPackageSize(order.getPackageSize());
  459. view.setDeliveryMode(order.getDeliveryMode());
  460. view.setScheduledPickupStartAt(order.getScheduledPickupStartAt());
  461. view.setScheduledPickupEndAt(order.getScheduledPickupEndAt());
  462. view.setPickupCity(order.getPickupCity());
  463. view.setPickupArea(order.getPickupArea());
  464. view.setDeliveryCity(order.getDeliveryCity());
  465. view.setDeliveryArea(order.getDeliveryArea());
  466. view.setPickupLatitudeApprox(approximate(order.getPickupLatitude()));
  467. view.setPickupLongitudeApprox(approximate(order.getPickupLongitude()));
  468. view.setDeliveryLatitudeApprox(approximate(order.getDeliveryLatitude()));
  469. view.setDeliveryLongitudeApprox(approximate(order.getDeliveryLongitude()));
  470. view.setDistanceMeters(order.getDistanceMeters());
  471. view.setEstimatedDurationSeconds(order.getEstimatedDurationSeconds());
  472. view.setAmount(order.getAmount());
  473. view.setCurrency(order.getCurrency());
  474. view.setCreateTime(order.getCreateTime());
  475. return view;
  476. }
  477. private FlashDeliveryServiceView serviceView(FlashDeliveryPricing pricing) {
  478. FlashDeliveryServiceView view = new FlashDeliveryServiceView();
  479. view.setServiceType(pricing.getServiceType());
  480. view.setStartPrice(pricing.getStartPrice());
  481. view.setMinimumPrice(pricing.getMinimumPrice());
  482. view.setStartDistanceMeters(pricing.getStartDistanceMeters());
  483. view.setPerKmPrice(pricing.getPerKmPrice());
  484. view.setPerMinutePrice(pricing.getPerMinutePrice());
  485. view.setPeakMultiplier(pricing.getPeakMultiplier());
  486. view.setPricingVersion(pricing.getConfigVersion());
  487. view.setCurrency("TWD");
  488. return view;
  489. }
  490. private FlashDeliveryOrderView orderView(FlashDeliveryOrder order) {
  491. FlashDeliveryOrderView view = new FlashDeliveryOrderView();
  492. view.setId(order.getId());
  493. view.setOrderNo(order.getOrderNo());
  494. view.setServiceType(order.getServiceType());
  495. view.setStatus(order.getStatus());
  496. view.setPackageType(order.getPackageType());
  497. view.setPackageSize(order.getPackageSize());
  498. view.setDeliveryMode(order.getDeliveryMode());
  499. view.setScheduledPickupStartAt(order.getScheduledPickupStartAt());
  500. view.setScheduledPickupEndAt(order.getScheduledPickupEndAt());
  501. view.setPinRequired(order.getPinRequired());
  502. view.setPickup(addressView(order, true));
  503. view.setDelivery(addressView(order, false));
  504. view.setDistanceMeters(order.getDistanceMeters());
  505. view.setDistanceSource(order.getDistanceSource());
  506. view.setEstimatedDurationSeconds(order.getEstimatedDurationSeconds());
  507. view.setAmount(order.getAmount());
  508. view.setCurrency(order.getCurrency());
  509. view.setUserNote(order.getUserNote());
  510. view.setAcceptedAt(order.getAcceptedAt());
  511. view.setPickedUpAt(order.getPickedUpAt());
  512. view.setDeliveredAt(order.getDeliveredAt());
  513. view.setCompletedAt(order.getCompletedAt());
  514. view.setCancelledAt(order.getCancelledAt());
  515. view.setCancelReason(order.getCancelReason());
  516. view.setCreateTime(order.getCreateTime());
  517. view.setUpdateTime(order.getUpdateTime());
  518. return view;
  519. }
  520. private FlashDeliveryAddressView addressView(FlashDeliveryOrder order, boolean pickup) {
  521. FlashDeliveryAddressView view = new FlashDeliveryAddressView();
  522. if (pickup) {
  523. view.setName(order.getPickupName()); view.setPhone(order.getPickupPhone());
  524. view.setAddress(order.getPickupAddress()); view.setAddressDetail(order.getPickupAddressDetail());
  525. view.setCity(order.getPickupCity()); view.setArea(order.getPickupArea());
  526. view.setHandoffMethod(order.getPickupHandoffMethod());
  527. view.setLongitude(order.getPickupLongitude()); view.setLatitude(order.getPickupLatitude());
  528. } else {
  529. view.setName(order.getDeliveryName()); view.setPhone(order.getDeliveryPhone());
  530. view.setAddress(order.getDeliveryAddress()); view.setAddressDetail(order.getDeliveryAddressDetail());
  531. view.setCity(order.getDeliveryCity()); view.setArea(order.getDeliveryArea());
  532. view.setHandoffMethod(order.getDeliveryHandoffMethod());
  533. view.setLongitude(order.getDeliveryLongitude()); view.setLatitude(order.getDeliveryLatitude());
  534. }
  535. return view;
  536. }
  537. private FlashDeliveryImageView imageView(FlashDeliveryOrderImage image) {
  538. FlashDeliveryImageView view = new FlashDeliveryImageView();
  539. view.setProofType(image.getProofType()); view.setImageUrl(image.getImageUrl());
  540. view.setSortOrder(image.getSortOrder()); view.setCreateTime(image.getCreateTime());
  541. return view;
  542. }
  543. private FlashDeliveryLogView logView(FlashDeliveryOrderLog log) {
  544. FlashDeliveryLogView view = new FlashDeliveryLogView();
  545. view.setFromStatus(log.getFromStatus()); view.setToStatus(log.getToStatus());
  546. view.setOperatorType(log.getOperatorType()); view.setReason(log.getReason());
  547. view.setCreateTime(log.getCreateTime());
  548. return view;
  549. }
  550. private FlashDeliveryRiderSummaryView riderSummary(FlashDeliveryOrder order) {
  551. if (order.getRiderId() == null) return null;
  552. InfoUser rider = userMapper.selectInfoUserByUserId(order.getRiderId());
  553. if (rider == null) return null;
  554. FlashDeliveryRiderSummaryView view = new FlashDeliveryRiderSummaryView();
  555. view.setName(hasText(rider.getNickName()) ? rider.getNickName() : rider.getUserName());
  556. view.setAvatar(rider.getAvatar());
  557. view.setRating(rider.getStar());
  558. view.setImUserId(rider.getImUserId() == null ? null : String.valueOf(rider.getImUserId()));
  559. if (ACCEPTED.equals(order.getStatus()) || PICKED_UP.equals(order.getStatus())) {
  560. view.setLongitude(rider.getLongitude());
  561. view.setLatitude(rider.getLatitude());
  562. }
  563. return view;
  564. }
  565. private void applyBreakdown(FlashDeliveryQuoteView view, FlashDeliveryPriceBreakdown breakdown) {
  566. view.setStartPrice(breakdown.getStartPrice());
  567. view.setMinimumPrice(breakdown.getMinimumPrice());
  568. view.setDistanceFee(breakdown.getDistanceFee());
  569. view.setDurationFee(breakdown.getDurationFee());
  570. view.setSubtotal(breakdown.getSubtotal());
  571. view.setPeakMultiplier(breakdown.getPeakMultiplier());
  572. view.setPeakFee(breakdown.getPeakFee());
  573. view.setAmount(breakdown.getAmount());
  574. }
  575. private void copyAddress(FlashDeliveryAddressRequest source, FlashDeliveryOrder target, boolean pickup) {
  576. if (pickup) {
  577. target.setPickupName(source.getName().trim()); target.setPickupPhone(source.getPhone().trim());
  578. target.setPickupAddress(source.getAddress().trim()); target.setPickupAddressDetail(trimToNull(source.getAddressDetail()));
  579. target.setPickupCity(trimToNull(source.getCity())); target.setPickupArea(trimToNull(source.getArea()));
  580. target.setPickupHandoffMethod(trimToNull(source.getHandoffMethod()));
  581. target.setPickupLongitude(source.getLongitude()); target.setPickupLatitude(source.getLatitude());
  582. } else {
  583. target.setDeliveryName(source.getName().trim()); target.setDeliveryPhone(source.getPhone().trim());
  584. target.setDeliveryAddress(source.getAddress().trim()); target.setDeliveryAddressDetail(trimToNull(source.getAddressDetail()));
  585. target.setDeliveryCity(trimToNull(source.getCity())); target.setDeliveryArea(trimToNull(source.getArea()));
  586. target.setDeliveryHandoffMethod(trimToNull(source.getHandoffMethod()));
  587. target.setDeliveryLongitude(source.getLongitude()); target.setDeliveryLatitude(source.getLatitude());
  588. }
  589. }
  590. private void validateAddress(FlashDeliveryAddressRequest address) {
  591. if (address == null || !hasText(address.getName()) || !hasText(address.getPhone())
  592. || !hasText(address.getAddress()) || address.getLatitude() == null || address.getLongitude() == null
  593. || address.getName().trim().length() > 64 || address.getPhone().trim().length() > 32
  594. || address.getAddress().trim().length() > 255
  595. || (address.getAddressDetail() != null && address.getAddressDetail().trim().length() > 255)
  596. || tooLong(address.getCity(), 64) || tooLong(address.getArea(), 64)
  597. || tooLong(address.getHandoffMethod(), 40)
  598. || address.getLatitude().compareTo(BigDecimal.valueOf(-90)) < 0
  599. || address.getLatitude().compareTo(BigDecimal.valueOf(90)) > 0
  600. || address.getLongitude().compareTo(BigDecimal.valueOf(-180)) < 0
  601. || address.getLongitude().compareTo(BigDecimal.valueOf(180)) > 0) {
  602. throw fail("flash.delivery.address.invalid");
  603. }
  604. }
  605. private void requireRider(Long riderId) {
  606. InfoUser rider = riderId == null ? null : userMapper.selectInfoUserByUserId(riderId);
  607. if (rider == null || !"2".equals(rider.getUserType())) throw fail("flash.delivery.rider.required");
  608. }
  609. private FlashDeliveryOrder requireOrder(Long orderId) {
  610. FlashDeliveryOrder order = orderId == null ? null : orderMapper.selectById(orderId);
  611. if (order == null) throw fail("flash.delivery.order.not.found");
  612. return order;
  613. }
  614. private void writeLog(Long orderId, String from, String to, String operatorType,
  615. Long operatorId, String reason, Date now) {
  616. FlashDeliveryOrderLog log = new FlashDeliveryOrderLog();
  617. log.setOrderId(orderId); log.setFromStatus(from); log.setToStatus(to);
  618. log.setOperatorType(operatorType); log.setOperatorId(operatorId); log.setReason(reason); log.setCreateTime(now);
  619. logMapper.insert(log);
  620. }
  621. private String requiredReason(FlashDeliveryReasonRequest request) {
  622. if (request == null || !hasText(request.getReason()) || request.getReason().trim().length() > 500) {
  623. throw fail("flash.delivery.cancel.reason.required");
  624. }
  625. return request.getReason().trim();
  626. }
  627. private void validateServiceType(String value) {
  628. if (!hasText(value) || !SERVICE_TYPES.contains(value)) throw fail("flash.delivery.service.type.invalid");
  629. }
  630. private void validateOptionalServiceType(String value) {
  631. if (hasText(value)) validateServiceType(value);
  632. }
  633. private void requireUserId(Long userId) {
  634. if (userId == null || userId <= 0) throw fail("flash.delivery.auth.required");
  635. }
  636. private Page<FlashDeliveryOrder> page(int pageNum, int pageSize) {
  637. return new Page<>(Math.max(1, pageNum), Math.min(Math.max(1, pageSize), 100));
  638. }
  639. private <T> IPage<T> mapPage(IPage<FlashDeliveryOrder> source,
  640. Function<FlashDeliveryOrder, T> mapper) {
  641. Page<T> result = new Page<>(source.getCurrent(), source.getSize(), source.getTotal());
  642. result.setRecords(source.getRecords().stream().map(mapper).toList());
  643. return result;
  644. }
  645. private void applyUserScene(QueryWrapper<FlashDeliveryOrder> query, String scene) {
  646. if (!hasText(scene) || "all".equals(scene)) return;
  647. switch (scene.trim()) {
  648. case "matching" -> query.eq("status", WAITING_ACCEPTANCE);
  649. case "progress" -> query.in("status", ACCEPTED, PICKED_UP, DELIVERED);
  650. case "done" -> query.eq("status", COMPLETED);
  651. case "cancelled" -> query.eq("status", CANCELLED);
  652. default -> throw fail("flash.delivery.scene.invalid");
  653. }
  654. }
  655. private void applyRiderScene(QueryWrapper<FlashDeliveryOrder> query, String scene) {
  656. if (!hasText(scene) || "all".equals(scene)) return;
  657. switch (scene.trim()) {
  658. case "pickup" -> query.eq("status", ACCEPTED);
  659. case "delivering" -> query.eq("status", PICKED_UP);
  660. case "completed" -> query.in("status", DELIVERED, COMPLETED);
  661. case "cancelled" -> query.eq("status", CANCELLED);
  662. default -> throw fail("flash.delivery.scene.invalid");
  663. }
  664. }
  665. private String normalizeDeliveryMode(String value) {
  666. String mode = hasText(value) ? value.trim() : "NOW";
  667. if (!DELIVERY_MODES.contains(mode)) throw fail("flash.delivery.mode.invalid");
  668. return mode;
  669. }
  670. private void validateSchedule(String mode, Date start, Date end, Date now) {
  671. if ("NOW".equals(mode)) {
  672. if (start != null || end != null) throw fail("flash.delivery.schedule.invalid");
  673. return;
  674. }
  675. if (start == null || end == null || start.before(now)
  676. || end.getTime() - start.getTime() != SCHEDULE_SLOT_MILLIS
  677. || start.getTime() - now.getTime() > MAX_SCHEDULE_DELAY_MILLIS) {
  678. throw fail("flash.delivery.schedule.invalid");
  679. }
  680. }
  681. private boolean isAvailableAt(FlashDeliveryOrder order, Date now) {
  682. return "NOW".equals(order.getDeliveryMode())
  683. || "SCHEDULED".equals(order.getDeliveryMode())
  684. && order.getScheduledPickupStartAt() != null
  685. && !order.getScheduledPickupStartAt().after(now);
  686. }
  687. private void optionalEq(QueryWrapper<FlashDeliveryOrder> query, String column, String value) {
  688. if (hasText(value)) query.eq(column, value.trim());
  689. }
  690. private boolean hasText(String value) { return StringUtils.isNotEmpty(value) && !value.trim().isEmpty(); }
  691. private String trimToNull(String value) { return hasText(value) ? value.trim() : null; }
  692. private boolean tooLong(String value, int max) { return value != null && value.trim().length() > max; }
  693. private BigDecimal approximate(BigDecimal value) { return value == null ? null : value.setScale(2, RoundingMode.HALF_UP); }
  694. private boolean sameAddressText(FlashDeliveryAddressRequest left, FlashDeliveryAddressRequest right) {
  695. return normalized(left.getAddress()).equals(normalized(right.getAddress()))
  696. && normalized(left.getAddressDetail()).equals(normalized(right.getAddressDetail()));
  697. }
  698. private String normalized(String value) { return value == null ? "" : value.trim().toLowerCase(Locale.ROOT); }
  699. private ServiceException fail(String key) { return new ServiceException(MessageUtils.message(key)); }
  700. private record QuoteContext(FlashDeliveryPricing pricing, RouteDistance route,
  701. FlashDeliveryPriceBreakdown breakdown) { }
  702. }