FlashDeliveryApplicationService.java 58 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099
  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.exception.FlashDeliveryQuoteChangedException;
  8. import com.ruoyi.app.flashdelivery.route.FlashDeliveryRouteService;
  9. import com.ruoyi.app.flashdelivery.route.GeoPoint;
  10. import com.ruoyi.app.flashdelivery.route.RouteDistance;
  11. import com.ruoyi.app.order.RiderDeliveryExclusivityService;
  12. import com.ruoyi.app.order.RiderDeliveryLockService;
  13. import com.ruoyi.common.core.domain.entity.SysDictData;
  14. import com.ruoyi.common.exception.ServiceException;
  15. import com.ruoyi.common.utils.DictUtils;
  16. import com.ruoyi.common.utils.MessageUtils;
  17. import com.ruoyi.common.utils.StringUtils;
  18. import com.ruoyi.system.domain.InfoUser;
  19. import com.ruoyi.system.domain.flash.*;
  20. import com.ruoyi.system.mapper.InfoUserMapper;
  21. import com.ruoyi.system.mapper.flash.*;
  22. import org.springframework.dao.DataIntegrityViolationException;
  23. import org.springframework.stereotype.Service;
  24. import org.springframework.transaction.annotation.Transactional;
  25. import java.net.URI;
  26. import java.math.BigDecimal;
  27. import java.math.RoundingMode;
  28. import java.security.SecureRandom;
  29. import java.time.Instant;
  30. import java.time.ZoneId;
  31. import java.time.format.DateTimeFormatter;
  32. import java.util.*;
  33. import java.util.function.Function;
  34. import static com.ruoyi.system.domain.flash.FlashDeliveryStatus.*;
  35. /**
  36. * 闪送业务编排服务。
  37. *
  38. * <p>负责报价、订单创建、抢单、履约状态流转、平台介入和审计日志。
  39. * 订单身份只接受 Controller 从 token 解析后的用户 ID,客户端传入的金额、距离或操作人均不可信。</p>
  40. */
  41. @Service
  42. public class FlashDeliveryApplicationService {
  43. private static final List<String> SERVICE_TYPES = List.of("HELP_SEND", "HELP_PICKUP");
  44. private static final Set<String> DELIVERY_TYPES = Set.of("NORMAL", "URGENT");
  45. private static final Set<String> PACKAGE_TYPES = Set.of("DOCUMENT", "GIFT", "CLOTHING", "BEAUTY",
  46. "DAILY_NECESSITIES", "FOOD_INGREDIENTS", "ELECTRONICS", "SMALL_APPLIANCE", "OTHER");
  47. private static final Set<String> DELIVERY_MODES = Set.of("NOW", "SCHEDULED");
  48. private static final BigDecimal MAX_TOTAL_WEIGHT_KG = new BigDecimal("20.00");
  49. private static final DateTimeFormatter PRICING_TIME_FORMAT = DateTimeFormatter.ofPattern("HH:mm");
  50. private static final ZoneId TAIPEI = ZoneId.of("Asia/Taipei");
  51. private static final int MAX_DISTANCE_METERS = 40_000;
  52. private static final BigDecimal MAX_PRICING_DISTANCE = new BigDecimal("999999.99");
  53. private static final long SCHEDULE_SLOT_MILLIS = 30L * 60L * 1000L;
  54. private static final long MAX_SCHEDULE_DELAY_MILLIS = 3L * 24L * 60L * 60L * 1000L;
  55. private static final SecureRandom PIN_RANDOM = new SecureRandom();
  56. private final FlashDeliveryOrderMapper orderMapper;
  57. private final FlashDeliveryPricingMapper pricingMapper;
  58. private final FlashDeliveryOrderImageMapper imageMapper;
  59. private final FlashDeliveryOrderLogMapper logMapper;
  60. private final InfoUserMapper userMapper;
  61. private final FlashDeliveryRouteService routeService;
  62. private final FlashDeliveryPricingCalculator pricingCalculator;
  63. private final RiderDeliveryLockService riderDeliveryLockService;
  64. private final RiderDeliveryExclusivityService riderDeliveryExclusivityService;
  65. public FlashDeliveryApplicationService(FlashDeliveryOrderMapper orderMapper,
  66. FlashDeliveryPricingMapper pricingMapper,
  67. FlashDeliveryOrderImageMapper imageMapper,
  68. FlashDeliveryOrderLogMapper logMapper,
  69. InfoUserMapper userMapper,
  70. FlashDeliveryRouteService routeService,
  71. FlashDeliveryPricingCalculator pricingCalculator,
  72. RiderDeliveryLockService riderDeliveryLockService,
  73. RiderDeliveryExclusivityService riderDeliveryExclusivityService) {
  74. this.orderMapper = orderMapper;
  75. this.pricingMapper = pricingMapper;
  76. this.imageMapper = imageMapper;
  77. this.logMapper = logMapper;
  78. this.userMapper = userMapper;
  79. this.routeService = routeService;
  80. this.pricingCalculator = pricingCalculator;
  81. this.riderDeliveryLockService = riderDeliveryLockService;
  82. this.riderDeliveryExclusivityService = riderDeliveryExclusivityService;
  83. }
  84. public FlashDeliveryHomeView home() {
  85. FlashDeliveryHomeView view = new FlashDeliveryHomeView();
  86. view.setServiceTypes(SERVICE_TYPES);
  87. view.setDeliveryTypes(List.of("NORMAL", "URGENT"));
  88. FlashDeliveryPricing pricing = findPricingAtOrNull(pricingTargetTime(new Date()));
  89. view.setServices(pricing == null ? List.of() : SERVICE_TYPES.stream()
  90. .map(serviceType -> serviceView(pricing, serviceType)).toList());
  91. return view;
  92. }
  93. public FlashDeliveryQuoteView quote(FlashDeliveryQuoteRequest request) {
  94. // 报价只使用服务端路线距离和当前启用的计价配置,避免客户端篡改金额。
  95. QuoteContext context = calculateQuote(request);
  96. return quoteView(request, context);
  97. }
  98. private FlashDeliveryQuoteView quoteView(FlashDeliveryQuoteRequest request, QuoteContext context) {
  99. FlashDeliveryQuoteView view = new FlashDeliveryQuoteView();
  100. view.setServiceType(request.getServiceType());
  101. view.setDeliveryType(request.getDeliveryType());
  102. view.setDeliveryMode(normalizeDeliveryMode(request.getDeliveryMode()));
  103. view.setScheduledPickupStartAt(request.getScheduledPickupStartAt());
  104. view.setScheduledPickupEndAt(request.getScheduledPickupEndAt());
  105. view.setPricingId(context.pricing().getId());
  106. view.setStartTime(context.pricing().getStartTime());
  107. view.setEndTime(context.pricing().getEndTime());
  108. view.setDistanceMeters(context.route().distanceMeters());
  109. view.setDistanceSource(context.route().source());
  110. view.setEstimatedDurationSeconds(context.route().durationSeconds());
  111. view.setStartingDistance(context.pricing().getStartingDistance());
  112. view.setStartingFare(context.pricing().getStartingFare());
  113. view.setDistance(context.pricing().getDistance());
  114. view.setFreight(context.pricing().getFreight());
  115. view.setUrgentRate(context.pricing().getUrgentRate());
  116. view.setMinimumUrgentFee(context.pricing().getMinimumUrgentFee());
  117. applyBreakdown(view, context.breakdown());
  118. view.setCurrency("TWD");
  119. view.setPricingVersion(context.pricing().getConfigVersion());
  120. return view;
  121. }
  122. @Transactional
  123. public FlashDeliveryOrderDetailView create(Long userId, FlashDeliveryCreateRequest request) {
  124. requireUserId(userId);
  125. if (request == null || !hasText(request.getClientRequestId())
  126. || request.getClientRequestId().trim().length() > 64) {
  127. throw fail("flash.delivery.client.request.id.invalid");
  128. }
  129. String requestId = request.getClientRequestId().trim();
  130. // 用户 ID + clientRequestId 构成幂等键;重复请求直接返回第一次创建的订单。
  131. FlashDeliveryOrder existing = orderMapper.selectByUserRequestId(userId, requestId);
  132. if (existing != null) return participantDetail(existing, true, false);
  133. if (request.getUserNote() != null && request.getUserNote().length() > 500) {
  134. throw fail("flash.delivery.note.too.long");
  135. }
  136. QuoteContext quote = calculateQuote(request);
  137. FlashDeliveryQuoteView latestQuote = quoteView(request, quote);
  138. if (!matchesQuotedPrice(request, latestQuote)) {
  139. throw new FlashDeliveryQuoteChangedException(latestQuote);
  140. }
  141. Date now = new Date();
  142. String deliveryMode = normalizeDeliveryMode(request.getDeliveryMode());
  143. validateSchedule(deliveryMode, request.getScheduledPickupStartAt(), request.getScheduledPickupEndAt(), now);
  144. boolean pinRequired = request.getPinRequired() == null || request.getPinRequired();
  145. List<String> senderImages = validateProofUrls(request.getSenderImageUrls(), false);
  146. FlashDeliveryOrder order = new FlashDeliveryOrder();
  147. order.setOrderNo("FD" + UUID.randomUUID().toString().replace("-", "").substring(0, 24).toUpperCase(Locale.ROOT));
  148. order.setClientRequestId(requestId);
  149. order.setUserId(userId);
  150. order.setServiceType(request.getServiceType());
  151. order.setDeliveryType(request.getDeliveryType());
  152. order.setStatus(WAITING_ACCEPTANCE);
  153. order.setPackageType(request.getPackageType());
  154. order.setQuantity(request.getQuantity());
  155. order.setTotalWeightKg(scaleWeight(request.getTotalWeightKg()));
  156. order.setSpecification(trimToNull(request.getSpecification()));
  157. order.setPackageSize(packageSize(request.getTotalWeightKg()));
  158. order.setDeliveryMode(deliveryMode);
  159. order.setScheduledPickupStartAt("SCHEDULED".equals(deliveryMode) ? request.getScheduledPickupStartAt() : null);
  160. order.setScheduledPickupEndAt("SCHEDULED".equals(deliveryMode) ? request.getScheduledPickupEndAt() : null);
  161. order.setPinRequired(pinRequired);
  162. order.setDeliveryPinCode(pinRequired ? String.format(Locale.ROOT, "%04d", PIN_RANDOM.nextInt(10_000)) : null);
  163. copyAddress(request.getPickup(), order, true);
  164. copyAddress(request.getDelivery(), order, false);
  165. order.setReceiverUserId(resolveReceiverUserId(order.getDeliveryPhone()));
  166. order.setDistanceMeters(quote.route().distanceMeters());
  167. order.setDistanceSource(quote.route().source());
  168. order.setEstimatedDurationSeconds(quote.route().durationSeconds());
  169. order.setAmount(quote.breakdown().getAmount());
  170. order.setCurrency("TWD");
  171. order.setPricingId(quote.pricing().getId());
  172. order.setPricingVersion(quote.pricing().getConfigVersion());
  173. order.setPricingStartTime(quote.pricing().getStartTime());
  174. order.setPricingEndTime(quote.pricing().getEndTime());
  175. order.setStartingDistance(quote.pricing().getStartingDistance());
  176. order.setStartingFare(quote.pricing().getStartingFare());
  177. order.setDistance(quote.pricing().getDistance());
  178. order.setFreight(quote.pricing().getFreight());
  179. // 保存计价快照,后台调价只影响新订单,不追溯修改历史订单金额。
  180. order.setDistanceFee(quote.breakdown().getDistanceFee());
  181. order.setBaseDeliveryFee(quote.breakdown().getBaseDeliveryFee());
  182. order.setUrgentRate(quote.pricing().getUrgentRate());
  183. order.setMinimumUrgentFee(quote.pricing().getMinimumUrgentFee());
  184. order.setUrgentFee(quote.breakdown().getUrgentFee());
  185. order.setTipAmount(quote.breakdown().getTipAmount());
  186. order.setUserNote(trimToNull(request.getUserNote()));
  187. order.setVersion(0);
  188. order.setCreateTime(now);
  189. order.setUpdateTime(now);
  190. try {
  191. orderMapper.insert(order);
  192. } catch (DataIntegrityViolationException duplicate) {
  193. // 并发重复请求可能同时通过前置查询,最终由数据库唯一键完成幂等收口。
  194. FlashDeliveryOrder concurrent = orderMapper.selectByUserRequestId(userId, requestId);
  195. if (concurrent != null) return participantDetail(concurrent, true, false);
  196. throw duplicate;
  197. }
  198. saveImages(order.getId(), "SENDER", "USER", userId, senderImages, now);
  199. writeLog(order.getId(), null, WAITING_ACCEPTANCE, "USER", userId, null, now);
  200. return participantDetail(order, true, false);
  201. }
  202. public IPage<FlashDeliveryUserOrderListView> userOrders(Long userId, int pageNum, int pageSize, String role) {
  203. String selectedRole = hasText(role) ? role.trim().toLowerCase(Locale.ROOT) : "sender";
  204. if (!"sender".equals(selectedRole) && !"receiver".equals(selectedRole)) {
  205. throw fail("flash.delivery.tab.invalid");
  206. }
  207. String participantColumn = "receiver".equals(selectedRole) ? "receiver_user_id" : "user_id";
  208. QueryWrapper<FlashDeliveryOrder> query = new QueryWrapper<FlashDeliveryOrder>()
  209. .eq(participantColumn, userId).orderByDesc("create_time");
  210. return mapPage(orderMapper.selectPage(page(pageNum, pageSize), query), this::userOrderListView);
  211. }
  212. public FlashDeliveryOrderDetailView userDetail(Long userId, Long orderId) {
  213. FlashDeliveryOrder order = requireOrder(orderId);
  214. if (!isParticipant(userId, order)) throw fail("flash.delivery.order.not.found");
  215. return participantDetail(order, true, false);
  216. }
  217. @Transactional
  218. public void userCancel(Long userId, Long orderId, FlashDeliveryReasonRequest request) {
  219. FlashDeliveryOrder order = requireOrder(orderId);
  220. if (!Objects.equals(userId, order.getUserId())) throw fail("flash.delivery.order.not.found");
  221. if (!FlashDeliveryStateMachine.canUserCancel(order.getStatus())) throw fail("flash.delivery.cancel.not.allowed");
  222. String reason = requiredReason(request);
  223. Date now = new Date();
  224. if (orderMapper.cancel(orderId, order.getStatus(), "USER", userId, reason, now) != 1) {
  225. throw fail("flash.delivery.state.changed");
  226. }
  227. writeLog(orderId, order.getStatus(), CANCELLED, "USER", userId, reason, now);
  228. }
  229. @Transactional
  230. public void confirmReceipt(Long userId, Long orderId) {
  231. FlashDeliveryOrder order = requireOrder(orderId);
  232. if (!isParticipant(userId, order)) throw fail("flash.delivery.order.not.found");
  233. Date now = new Date();
  234. if (orderMapper.transitionByParticipant(orderId, userId, DELIVERED, COMPLETED, now) != 1) {
  235. throw fail("flash.delivery.complete.not.allowed");
  236. }
  237. writeLog(orderId, DELIVERED, COMPLETED, "USER", userId, null, now);
  238. }
  239. public FlashDeliveryRiderOrderPageView riderOrders(Long riderId, int pageNum, int pageSize,
  240. String tab, BigDecimal longitude, BigDecimal latitude) {
  241. InfoUser rider = requireRider(riderId);
  242. if (!hasText(tab)) throw fail("flash.delivery.tab.invalid");
  243. String selectedTab = tab.trim();
  244. // lambda 无法捕获被重新赋值的参数,统一改用仅 newTask 携带坐标的有效变量。
  245. BigDecimal effectiveLongitude = "newTask".equals(selectedTab) ? longitude : null;
  246. BigDecimal effectiveLatitude = "newTask".equals(selectedTab) ? latitude : null;
  247. if ("newTask".equals(selectedTab)) {
  248. // 新任务派单按骑手选择的配送类型过滤;本人已接订单页签不受限制。
  249. if (!rider.supportsDeliveryType("FLASH")) throw fail("flash.delivery.rider.type.not.enabled");
  250. validateRiderCoordinates(effectiveLongitude, effectiveLatitude);
  251. }
  252. QueryWrapper<FlashDeliveryOrder> query = riderOrdersQuery(riderId, selectedTab,
  253. effectiveLongitude, effectiveLatitude, true);
  254. IPage<FlashDeliveryOrder> orders = orderMapper.selectPage(page(pageNum, pageSize), query);
  255. FlashDeliveryRiderOrderPageView result = new FlashDeliveryRiderOrderPageView();
  256. result.setRecords(orders.getRecords().stream()
  257. .map(order -> riderOrderListView(order, effectiveLongitude, effectiveLatitude)).toList());
  258. result.setTotal(orders.getTotal());
  259. result.setCurrent(orders.getCurrent());
  260. result.setSize(orders.getSize());
  261. if ("newTask".equals(selectedTab)) {
  262. result.setNearbyTaskCount(orders.getTotal());
  263. QueryWrapper<FlashDeliveryOrder> maximumQuery = riderOrdersQuery(riderId, selectedTab,
  264. effectiveLongitude, effectiveLatitude, false)
  265. .select("MAX(amount)");
  266. result.setHighestOrderAmount(firstLong(orderMapper.selectObjs(maximumQuery)));
  267. }
  268. return result;
  269. }
  270. public FlashDeliveryOrderDetailView riderDetail(Long riderId, Long orderId) {
  271. InfoUser rider = requireRider(riderId);
  272. FlashDeliveryOrder order = requireOrder(orderId);
  273. if (WAITING_ACCEPTANCE.equals(order.getStatus()) && order.getRiderId() == null) {
  274. // 不兼容闪送的骑手按订单不存在处理,避免泄露待抢任务信息。
  275. if (!isAvailableAt(order, new Date()) || !rider.supportsDeliveryType("FLASH")) {
  276. throw fail("flash.delivery.order.not.found");
  277. }
  278. // 接单前显示取送文字地址,但隐藏联系人、电话、精确坐标、实际 PIN 和履约图片。
  279. return participantDetail(order, false, true);
  280. }
  281. if (!Objects.equals(riderId, order.getRiderId())) throw fail("flash.delivery.order.not.found");
  282. return participantDetail(order, false, false);
  283. }
  284. @Transactional
  285. public FlashDeliveryOrderDetailView accept(Long riderId, Long orderId) {
  286. InfoUser rider = requireRider(riderId);
  287. // 抢单属于获取新任务,要求骑手承接闪送配送。
  288. if (!rider.supportsDeliveryType("FLASH")) throw fail("flash.delivery.rider.type.not.enabled");
  289. return riderDeliveryLockService.withLock(riderId, () -> {
  290. FlashDeliveryOrder target = requireOrder(orderId);
  291. riderDeliveryExclusivityService.assertCanAcceptFlash(riderId, target.getDeliveryType());
  292. Date now = new Date();
  293. // Mapper 使用“待接单且 rider_id 为空”的条件更新,受影响行数为 1 才表示抢单成功。
  294. if (orderMapper.accept(orderId, riderId, now) != 1) {
  295. throw fail("flash.delivery.order.already.accepted");
  296. }
  297. writeLog(orderId, WAITING_ACCEPTANCE, ACCEPTED, "RIDER", riderId, null, now);
  298. return participantDetail(requireOrder(orderId), false, false);
  299. });
  300. }
  301. @Transactional
  302. public void pickup(Long riderId, Long orderId, FlashDeliveryProofRequest request) {
  303. transitionWithProof(riderId, orderId, request, ACCEPTED, PICKED_UP, "PICKUP", "picked_up_at");
  304. }
  305. @Transactional
  306. public void deliver(Long riderId, Long orderId, FlashDeliveryDeliverRequest request) {
  307. requireRider(riderId);
  308. FlashDeliveryOrder order = requireOrder(orderId);
  309. if (!Objects.equals(riderId, order.getRiderId()) || !PICKED_UP.equals(order.getStatus())) {
  310. throw fail("flash.delivery.transition.not.allowed");
  311. }
  312. if (Boolean.TRUE.equals(order.getPinRequired())
  313. && (request == null || !Objects.equals(order.getDeliveryPinCode(), trimToNull(request.getPinCode())))) {
  314. throw fail("flash.delivery.pin.invalid");
  315. }
  316. transitionWithProof(riderId, orderId, request, PICKED_UP, DELIVERED, "DELIVERY", "delivered_at");
  317. }
  318. public List<FlashDeliveryPricing> adminPricing() {
  319. return pricingMapper.selectList(new QueryWrapper<FlashDeliveryPricing>()
  320. .orderByAsc("start_time", "id"));
  321. }
  322. @Transactional
  323. public FlashDeliveryPricing createPricing(Long adminId, FlashDeliveryPricingRequest request) {
  324. validatePricingRequest(request, false);
  325. lockPricing();
  326. ensureNoPricingOverlap(request, null);
  327. Date now = new Date();
  328. FlashDeliveryPricing pricing = pricingFrom(request);
  329. pricing.setConfigVersion(1);
  330. pricing.setUpdatedBy(adminId);
  331. pricing.setCreateTime(now);
  332. pricing.setUpdateTime(now);
  333. pricingMapper.insert(pricing);
  334. return pricing;
  335. }
  336. @Transactional
  337. public FlashDeliveryPricing updatePricing(Long adminId, Long pricingId,
  338. FlashDeliveryPricingRequest request) {
  339. validatePricingRequest(request, true);
  340. FlashDeliveryPricing preliminary = pricingId == null ? null : pricingMapper.selectById(pricingId);
  341. if (preliminary == null) throw fail("flash.delivery.pricing.not.found");
  342. lockPricing();
  343. FlashDeliveryPricing pricing = pricingMapper.selectById(pricingId);
  344. if (pricing == null) throw fail("flash.delivery.pricing.not.found");
  345. ensureNoPricingOverlap(request, pricingId);
  346. Date now = new Date();
  347. UpdateWrapper<FlashDeliveryPricing> update = new UpdateWrapper<FlashDeliveryPricing>()
  348. .eq("id", pricingId).eq("config_version", request.getConfigVersion())
  349. .set("start_time", request.getStartTime())
  350. .set("end_time", request.getEndTime())
  351. .set("starting_distance", scaleDistance(request.getStartingDistance()))
  352. .set("starting_fare", request.getStartingFare())
  353. .set("distance", scaleDistance(request.getDistance()))
  354. .set("freight", request.getFreight())
  355. .set("urgent_rate", normalizeRate(request.getUrgentRate()))
  356. .set("minimum_urgent_fee", request.getMinimumUrgentFee())
  357. .set("updated_by", adminId)
  358. .set("update_time", now)
  359. .setSql("config_version = config_version + 1");
  360. // 版本条件防止两个管理员同时保存时后提交者静默覆盖先提交者。
  361. if (pricingMapper.update(null, update) != 1) throw fail("flash.delivery.pricing.changed");
  362. pricing.setStartTime(request.getStartTime());
  363. pricing.setEndTime(request.getEndTime());
  364. pricing.setStartingDistance(scaleDistance(request.getStartingDistance()));
  365. pricing.setStartingFare(request.getStartingFare());
  366. pricing.setDistance(scaleDistance(request.getDistance()));
  367. pricing.setFreight(request.getFreight());
  368. pricing.setUrgentRate(normalizeRate(request.getUrgentRate()));
  369. pricing.setMinimumUrgentFee(request.getMinimumUrgentFee());
  370. pricing.setConfigVersion(request.getConfigVersion() + 1);
  371. pricing.setUpdatedBy(adminId);
  372. pricing.setUpdateTime(now);
  373. return pricing;
  374. }
  375. @Transactional
  376. public void deletePricing(Long pricingId) {
  377. FlashDeliveryPricing preliminary = pricingId == null ? null : pricingMapper.selectById(pricingId);
  378. if (preliminary == null) {
  379. throw fail("flash.delivery.pricing.not.found");
  380. }
  381. lockPricing();
  382. FlashDeliveryPricing pricing = pricingMapper.selectById(pricingId);
  383. if (pricing == null) throw fail("flash.delivery.pricing.not.found");
  384. if (pricingMapper.deleteById(pricingId) != 1) throw fail("flash.delivery.pricing.not.found");
  385. }
  386. public IPage<FlashDeliveryOrder> adminOrders(int pageNum, int pageSize, String status,
  387. String serviceType, String orderNo,
  388. Long userId, Long riderId) {
  389. validateOptionalServiceType(serviceType);
  390. QueryWrapper<FlashDeliveryOrder> query = new QueryWrapper<FlashDeliveryOrder>().orderByDesc("create_time");
  391. optionalEq(query, "status", status);
  392. optionalEq(query, "service_type", serviceType);
  393. if (hasText(orderNo)) query.like("order_no", orderNo.trim());
  394. if (userId != null) query.eq("user_id", userId);
  395. if (riderId != null) query.eq("rider_id", riderId);
  396. return orderMapper.selectPage(page(pageNum, pageSize), query);
  397. }
  398. public FlashDeliveryAdminOrderDetailView adminDetail(Long orderId) {
  399. FlashDeliveryOrder order = requireOrder(orderId);
  400. FlashDeliveryAdminOrderDetailView view = new FlashDeliveryAdminOrderDetailView();
  401. view.setOrder(order);
  402. view.setImages(imageMapper.selectList(new QueryWrapper<FlashDeliveryOrderImage>()
  403. .eq("order_id", orderId).orderByAsc("proof_type", "sort_order")));
  404. view.setLogs(logMapper.selectList(new QueryWrapper<FlashDeliveryOrderLog>()
  405. .eq("order_id", orderId).orderByAsc("create_time", "id")));
  406. return view;
  407. }
  408. @Transactional
  409. public void adminCancel(Long adminId, Long orderId, FlashDeliveryReasonRequest request) {
  410. FlashDeliveryOrder order = requireOrder(orderId);
  411. if (!FlashDeliveryStateMachine.canAdminCancel(order.getStatus())) throw fail("flash.delivery.cancel.not.allowed");
  412. String reason = requiredReason(request);
  413. Date now = new Date();
  414. if (orderMapper.cancel(orderId, order.getStatus(), "ADMIN", adminId, reason, now) != 1) {
  415. throw fail("flash.delivery.state.changed");
  416. }
  417. writeLog(orderId, order.getStatus(), CANCELLED, "ADMIN", adminId, reason, now);
  418. }
  419. @Transactional
  420. public boolean complete(Long operatorId, String operatorType, Long orderId) {
  421. Date now = new Date();
  422. if (orderMapper.transitionByStatus(orderId, DELIVERED, COMPLETED, "completed_at", now) != 1) return false;
  423. writeLog(orderId, DELIVERED, COMPLETED, operatorType, operatorId, null, now);
  424. return true;
  425. }
  426. public List<FlashDeliveryOrder> autoCompleteCandidates(Date deadline, int limit) {
  427. return orderMapper.selectAutoCompletable(deadline, Math.min(Math.max(limit, 1), 500));
  428. }
  429. private QuoteContext calculateQuote(FlashDeliveryQuoteRequest request) {
  430. if (request == null) throw fail("flash.delivery.request.required");
  431. validateServiceType(request.getServiceType());
  432. validateDeliveryType(request.getDeliveryType());
  433. if (!hasText(request.getPackageType()) || !PACKAGE_TYPES.contains(request.getPackageType())) {
  434. throw fail("flash.delivery.package.type.invalid");
  435. }
  436. validateItem(request);
  437. Date now = new Date();
  438. String deliveryMode = normalizeDeliveryMode(request.getDeliveryMode());
  439. validateSchedule(deliveryMode, request.getScheduledPickupStartAt(), request.getScheduledPickupEndAt(), now);
  440. validateAddress(request.getPickup());
  441. validateAddress(request.getDelivery());
  442. if (sameAddressText(request.getPickup(), request.getDelivery())
  443. || request.getPickup().getLatitude().compareTo(request.getDelivery().getLatitude()) == 0
  444. && request.getPickup().getLongitude().compareTo(request.getDelivery().getLongitude()) == 0) {
  445. throw fail("flash.delivery.address.same");
  446. }
  447. Date pricingDate = "SCHEDULED".equals(deliveryMode) ? request.getScheduledPickupStartAt() : now;
  448. FlashDeliveryPricing pricing = findPricingAt(pricingTargetTime(pricingDate));
  449. // 路线服务优先使用 Google Routes;不可用时在服务内部降级为球面直线距离。
  450. RouteDistance route = routeService.calculate(
  451. new GeoPoint(request.getPickup().getLatitude(), request.getPickup().getLongitude()),
  452. new GeoPoint(request.getDelivery().getLatitude(), request.getDelivery().getLongitude()));
  453. if (route.distanceMeters() > MAX_DISTANCE_METERS) throw fail("flash.delivery.distance.too.far");
  454. FlashDeliveryPriceBreakdown breakdown = pricingCalculator.calculateBreakdown(pricing,
  455. route.distanceMeters(), request.getDeliveryType(), request.getTipAmount());
  456. return new QuoteContext(pricing, route, breakdown);
  457. }
  458. private FlashDeliveryPricing findPricingAt(String targetTime) {
  459. FlashDeliveryPricing pricing = findPricingAtOrNull(targetTime);
  460. if (pricing == null) throw fail("flash.delivery.pricing.not.available");
  461. return pricing;
  462. }
  463. private FlashDeliveryPricing findPricingAtOrNull(String targetTime) {
  464. List<FlashDeliveryPricing> pricing = pricingMapper.selectAtTime(targetTime);
  465. if (pricing == null || pricing.isEmpty()) return null;
  466. if (pricing.size() > 1) throw fail("flash.delivery.pricing.overlap");
  467. return pricing.get(0);
  468. }
  469. private void validatePricingRequest(FlashDeliveryPricingRequest request, boolean requireVersion) {
  470. if (request == null) throw fail("flash.delivery.pricing.invalid");
  471. BigDecimal startingDistance = normalizeDistance(request.getStartingDistance());
  472. BigDecimal distance = normalizeDistance(request.getDistance());
  473. BigDecimal urgentRate = normalizeRate(request.getUrgentRate());
  474. if (!validStartTime(request.getStartTime()) || !validEndTime(request.getEndTime())
  475. || toMinute(request.getStartTime()) >= toMinute(request.getEndTime())
  476. || startingDistance == null || startingDistance.signum() <= 0
  477. || startingDistance.compareTo(MAX_PRICING_DISTANCE) > 0
  478. || request.getStartingFare() == null || request.getStartingFare() <= 0
  479. || distance == null || distance.signum() <= 0
  480. || distance.compareTo(MAX_PRICING_DISTANCE) > 0
  481. || request.getFreight() == null || request.getFreight() <= 0
  482. || urgentRate == null || urgentRate.signum() < 0
  483. || urgentRate.compareTo(MAX_PRICING_DISTANCE) > 0
  484. || request.getMinimumUrgentFee() == null || request.getMinimumUrgentFee() < 0
  485. || requireVersion && (request.getConfigVersion() == null || request.getConfigVersion() <= 0)) {
  486. throw fail("flash.delivery.pricing.invalid");
  487. }
  488. }
  489. private void ensureNoPricingOverlap(FlashDeliveryPricingRequest request, Long excludedId) {
  490. if (pricingMapper.countOverlapping(request.getStartTime(), request.getEndTime(), excludedId) > 0) {
  491. throw fail("flash.delivery.pricing.overlap");
  492. }
  493. }
  494. private void lockPricing() {
  495. if (!Objects.equals("GLOBAL", pricingMapper.lockPricing())) {
  496. throw fail("flash.delivery.pricing.invalid");
  497. }
  498. }
  499. private FlashDeliveryPricing pricingFrom(FlashDeliveryPricingRequest request) {
  500. FlashDeliveryPricing pricing = new FlashDeliveryPricing();
  501. pricing.setStartTime(request.getStartTime());
  502. pricing.setEndTime(request.getEndTime());
  503. pricing.setStartingDistance(scaleDistance(request.getStartingDistance()));
  504. pricing.setStartingFare(request.getStartingFare());
  505. pricing.setDistance(scaleDistance(request.getDistance()));
  506. pricing.setFreight(request.getFreight());
  507. pricing.setUrgentRate(normalizeRate(request.getUrgentRate()));
  508. pricing.setMinimumUrgentFee(request.getMinimumUrgentFee());
  509. return pricing;
  510. }
  511. private boolean validStartTime(String value) {
  512. return value != null && value.matches("(?:[01]\\d|2[0-3]):[0-5]\\d");
  513. }
  514. private boolean validEndTime(String value) {
  515. return "24:00".equals(value) || validStartTime(value);
  516. }
  517. private int toMinute(String value) {
  518. if ("24:00".equals(value)) return 24 * 60;
  519. return Integer.parseInt(value.substring(0, 2)) * 60 + Integer.parseInt(value.substring(3));
  520. }
  521. private BigDecimal scaleDistance(BigDecimal value) {
  522. return normalizeDistance(value);
  523. }
  524. private BigDecimal normalizeDistance(BigDecimal value) {
  525. return value == null ? null : value.setScale(2, RoundingMode.HALF_UP);
  526. }
  527. private BigDecimal normalizeRate(BigDecimal value) {
  528. return value == null ? null : value.setScale(2, RoundingMode.HALF_UP);
  529. }
  530. private void transitionWithProof(Long riderId, Long orderId, FlashDeliveryProofRequest request,
  531. String expected, String next, String proofType, String timeColumn) {
  532. List<String> urls = validateProofUrls(request == null ? null : request.getImageUrls(), true);
  533. requireRider(riderId);
  534. Date now = new Date();
  535. if (orderMapper.transitionByRider(orderId, riderId, expected, next, timeColumn, now) != 1) {
  536. throw fail("flash.delivery.transition.not.allowed");
  537. }
  538. saveImages(orderId, proofType, "RIDER", riderId, urls, now);
  539. writeLog(orderId, expected, next, "RIDER", riderId, null, now);
  540. }
  541. /** 状态、图片和日志处于同一事务;任一图片写入失败会回滚本次业务操作。 */
  542. private void saveImages(Long orderId, String proofType, String operatorType,
  543. Long operatorId, List<String> urls, Date now) {
  544. for (int index = 0; index < urls.size(); index++) {
  545. FlashDeliveryOrderImage image = new FlashDeliveryOrderImage();
  546. image.setOrderId(orderId);
  547. image.setProofType(proofType);
  548. image.setImageUrl(urls.get(index));
  549. image.setSortOrder(index);
  550. image.setOperatorType(operatorType);
  551. image.setOperatorId(operatorId);
  552. image.setCreateTime(now);
  553. imageMapper.insert(image);
  554. }
  555. }
  556. private List<String> validateProofUrls(List<String> values, boolean required) {
  557. if (values == null || values.isEmpty()) {
  558. if (!required) return List.of();
  559. throw fail("flash.delivery.proof.required");
  560. }
  561. if (values.size() > 9) throw fail("flash.delivery.proof.too.many");
  562. List<String> urls = new ArrayList<>();
  563. for (String value : values) {
  564. try {
  565. String url = value == null ? null : value.trim();
  566. URI uri = url == null ? null : URI.create(url);
  567. if (uri == null || url.length() > 1000 || uri.getHost() == null
  568. || !("http".equalsIgnoreCase(uri.getScheme()) || "https".equalsIgnoreCase(uri.getScheme()))) {
  569. throw new IllegalArgumentException();
  570. }
  571. urls.add(url);
  572. } catch (IllegalArgumentException exception) {
  573. throw fail("flash.delivery.proof.url.invalid");
  574. }
  575. }
  576. return urls;
  577. }
  578. private FlashDeliveryOrderDetailView participantDetail(FlashDeliveryOrder order,
  579. boolean userView,
  580. boolean hideSensitiveFields) {
  581. FlashDeliveryOrderDetailView view = detailOrderView(order, !hideSensitiveFields);
  582. if (userView) {
  583. view.setDeliveryPinCode(Boolean.TRUE.equals(order.getPinRequired()) ? order.getDeliveryPinCode() : null);
  584. view.setRider(riderSummary(order));
  585. }
  586. if (!hideSensitiveFields && order.getId() != null) {
  587. groupImageUrls(view, imageMapper.selectList(new QueryWrapper<FlashDeliveryOrderImage>()
  588. .eq("order_id", order.getId()).orderByAsc("proof_type", "sort_order")));
  589. }
  590. return view;
  591. }
  592. private FlashDeliveryUserOrderListView userOrderListView(FlashDeliveryOrder order) {
  593. FlashDeliveryUserOrderListView view = new FlashDeliveryUserOrderListView();
  594. view.setId(order.getId());
  595. view.setOrderNo(order.getOrderNo());
  596. view.setServiceType(order.getServiceType());
  597. view.setDeliveryType(order.getDeliveryType());
  598. view.setStatus(order.getStatus());
  599. view.setPackageType(order.getPackageType());
  600. view.setQuantity(order.getQuantity());
  601. view.setTotalWeightKg(order.getTotalWeightKg());
  602. view.setDeliveryMode(order.getDeliveryMode());
  603. view.setScheduledPickupStartAt(order.getScheduledPickupStartAt());
  604. view.setScheduledPickupEndAt(order.getScheduledPickupEndAt());
  605. view.setPickupAddress(order.getPickupAddress());
  606. view.setPickupDetailAddress(order.getPickupAddressDetail());
  607. view.setDeliveryAddress(order.getDeliveryAddress());
  608. view.setDeliveryDetailAddress(order.getDeliveryAddressDetail());
  609. view.setEstimatedDurationSeconds(order.getEstimatedDurationSeconds());
  610. view.setBaseDeliveryFee(order.getBaseDeliveryFee());
  611. view.setUrgentFee(order.getUrgentFee());
  612. view.setTipAmount(order.getTipAmount());
  613. view.setAmount(order.getAmount());
  614. view.setCurrency(order.getCurrency());
  615. view.setDeliveredAt(order.getDeliveredAt());
  616. view.setCreateTime(order.getCreateTime());
  617. return view;
  618. }
  619. private FlashDeliveryRiderOrderListView riderOrderListView(FlashDeliveryOrder order,
  620. BigDecimal longitude, BigDecimal latitude) {
  621. FlashDeliveryRiderOrderListView view = new FlashDeliveryRiderOrderListView();
  622. view.setId(order.getId());
  623. view.setOrderNo(order.getOrderNo());
  624. view.setServiceType(order.getServiceType());
  625. view.setDeliveryType(order.getDeliveryType());
  626. view.setStatus(order.getStatus());
  627. view.setPackageType(order.getPackageType());
  628. view.setQuantity(order.getQuantity());
  629. view.setTotalWeightKg(order.getTotalWeightKg());
  630. view.setSpecification(order.getSpecification());
  631. view.setPackageSize(order.getPackageSize());
  632. view.setDeliveryMode(order.getDeliveryMode());
  633. view.setScheduledPickupStartAt(order.getScheduledPickupStartAt());
  634. view.setScheduledPickupEndAt(order.getScheduledPickupEndAt());
  635. view.setPinRequired(order.getPinRequired());
  636. view.setPickupAddress(order.getPickupAddress());
  637. view.setPickupDetailAddress(order.getPickupAddressDetail());
  638. view.setDeliveryAddress(order.getDeliveryAddress());
  639. view.setDeliveryDetailAddress(order.getDeliveryAddressDetail());
  640. view.setPickupDistanceMeters(distanceMeters(longitude, latitude,
  641. order.getPickupLongitude(), order.getPickupLatitude()));
  642. view.setDistanceMeters(order.getDistanceMeters());
  643. view.setEstimatedDurationSeconds(order.getEstimatedDurationSeconds());
  644. view.setBaseDeliveryFee(order.getBaseDeliveryFee());
  645. view.setDistanceFee(order.getDistanceFee());
  646. view.setUrgentFee(order.getUrgentFee());
  647. view.setTipAmount(order.getTipAmount());
  648. view.setAmount(order.getAmount());
  649. view.setCurrency(order.getCurrency());
  650. view.setCreateTime(order.getCreateTime());
  651. return view;
  652. }
  653. private FlashDeliveryOrderDetailView detailOrderView(FlashDeliveryOrder order, boolean includeSensitiveFields) {
  654. FlashDeliveryOrderDetailView view = new FlashDeliveryOrderDetailView();
  655. populateOrderView(view, order, includeSensitiveFields);
  656. return view;
  657. }
  658. private void groupImageUrls(FlashDeliveryOrderDetailView view, List<FlashDeliveryOrderImage> images) {
  659. List<String> sender = new ArrayList<>();
  660. List<String> pickup = new ArrayList<>();
  661. List<String> delivery = new ArrayList<>();
  662. for (FlashDeliveryOrderImage image : images) {
  663. if ("SENDER".equals(image.getProofType())) sender.add(image.getImageUrl());
  664. else if ("PICKUP".equals(image.getProofType())) pickup.add(image.getImageUrl());
  665. else if ("DELIVERY".equals(image.getProofType())) delivery.add(image.getImageUrl());
  666. }
  667. view.setSenderImageUrls(sender);
  668. view.setPickupImageUrls(pickup);
  669. view.setDeliveryImageUrls(delivery);
  670. }
  671. private FlashDeliveryServiceView serviceView(FlashDeliveryPricing pricing, String serviceType) {
  672. FlashDeliveryServiceView view = new FlashDeliveryServiceView();
  673. view.setServiceType(serviceType);
  674. view.setPricingId(pricing.getId());
  675. view.setStartTime(pricing.getStartTime());
  676. view.setEndTime(pricing.getEndTime());
  677. view.setStartingDistance(pricing.getStartingDistance());
  678. view.setStartingFare(pricing.getStartingFare());
  679. view.setDistance(pricing.getDistance());
  680. view.setFreight(pricing.getFreight());
  681. view.setUrgentRate(pricing.getUrgentRate());
  682. view.setMinimumUrgentFee(pricing.getMinimumUrgentFee());
  683. view.setPricingVersion(pricing.getConfigVersion());
  684. view.setCurrency("TWD");
  685. return view;
  686. }
  687. private void populateOrderView(FlashDeliveryOrderView view, FlashDeliveryOrder order,
  688. boolean includeSensitiveFields) {
  689. view.setId(order.getId());
  690. view.setOrderNo(order.getOrderNo());
  691. view.setServiceType(order.getServiceType());
  692. view.setDeliveryType(order.getDeliveryType());
  693. view.setStatus(order.getStatus());
  694. view.setPackageType(order.getPackageType());
  695. view.setQuantity(order.getQuantity());
  696. view.setTotalWeightKg(order.getTotalWeightKg());
  697. view.setSpecification(order.getSpecification());
  698. view.setPackageSize(order.getPackageSize());
  699. view.setDeliveryMode(order.getDeliveryMode());
  700. view.setScheduledPickupStartAt(order.getScheduledPickupStartAt());
  701. view.setScheduledPickupEndAt(order.getScheduledPickupEndAt());
  702. view.setPinRequired(order.getPinRequired());
  703. view.setPickup(addressView(order, true, includeSensitiveFields));
  704. view.setDelivery(addressView(order, false, includeSensitiveFields));
  705. view.setDistanceMeters(order.getDistanceMeters());
  706. view.setDistanceSource(order.getDistanceSource());
  707. view.setEstimatedDurationSeconds(order.getEstimatedDurationSeconds());
  708. view.setBaseDeliveryFee(order.getBaseDeliveryFee());
  709. view.setDistanceFee(order.getDistanceFee());
  710. view.setUrgentFee(order.getUrgentFee());
  711. view.setTipAmount(order.getTipAmount());
  712. view.setAmount(order.getAmount());
  713. view.setCurrency(order.getCurrency());
  714. view.setUserNote(includeSensitiveFields ? order.getUserNote() : null);
  715. view.setAcceptedAt(order.getAcceptedAt());
  716. view.setPickedUpAt(order.getPickedUpAt());
  717. view.setDeliveredAt(order.getDeliveredAt());
  718. view.setCompletedAt(order.getCompletedAt());
  719. view.setCancelledAt(order.getCancelledAt());
  720. view.setCancelReason(order.getCancelReason());
  721. view.setCreateTime(order.getCreateTime());
  722. view.setUpdateTime(order.getUpdateTime());
  723. }
  724. private FlashDeliveryAddressView addressView(FlashDeliveryOrder order, boolean pickup,
  725. boolean includeSensitiveFields) {
  726. FlashDeliveryAddressView view = new FlashDeliveryAddressView();
  727. if (pickup) {
  728. view.setName(includeSensitiveFields ? order.getPickupName() : null);
  729. view.setPhone(includeSensitiveFields ? order.getPickupPhone() : null);
  730. view.setAddress(order.getPickupAddress()); view.setAddressDetail(order.getPickupAddressDetail());
  731. view.setCity(order.getPickupCity()); view.setArea(order.getPickupArea());
  732. view.setHandoffMethod(order.getPickupHandoffMethod());
  733. view.setLongitude(includeSensitiveFields ? order.getPickupLongitude() : null);
  734. view.setLatitude(includeSensitiveFields ? order.getPickupLatitude() : null);
  735. } else {
  736. view.setName(includeSensitiveFields ? order.getDeliveryName() : null);
  737. view.setPhone(includeSensitiveFields ? order.getDeliveryPhone() : null);
  738. view.setAddress(order.getDeliveryAddress()); view.setAddressDetail(order.getDeliveryAddressDetail());
  739. view.setCity(order.getDeliveryCity()); view.setArea(order.getDeliveryArea());
  740. view.setHandoffMethod(order.getDeliveryHandoffMethod());
  741. view.setLongitude(includeSensitiveFields ? order.getDeliveryLongitude() : null);
  742. view.setLatitude(includeSensitiveFields ? order.getDeliveryLatitude() : null);
  743. }
  744. return view;
  745. }
  746. private FlashDeliveryRiderSummaryView riderSummary(FlashDeliveryOrder order) {
  747. if (order.getRiderId() == null) return null;
  748. InfoUser rider = userMapper.selectInfoUserByUserId(order.getRiderId());
  749. if (rider == null) return null;
  750. FlashDeliveryRiderSummaryView view = new FlashDeliveryRiderSummaryView();
  751. view.setName(hasText(rider.getNickName()) ? rider.getNickName() : rider.getUserName());
  752. view.setAvatar(rider.getAvatar());
  753. view.setRating(rider.getStar());
  754. view.setImUserId(rider.getImUserId() == null ? null : String.valueOf(rider.getImUserId()));
  755. if (ACCEPTED.equals(order.getStatus()) || PICKED_UP.equals(order.getStatus())) {
  756. view.setLongitude(rider.getLongitude());
  757. view.setLatitude(rider.getLatitude());
  758. }
  759. return view;
  760. }
  761. private void applyBreakdown(FlashDeliveryQuoteView view, FlashDeliveryPriceBreakdown breakdown) {
  762. view.setBillableDistance(breakdown.getBillableDistance());
  763. view.setDistanceFee(breakdown.getDistanceFee());
  764. view.setBaseDeliveryFee(breakdown.getBaseDeliveryFee());
  765. view.setUrgentFee(breakdown.getUrgentFee());
  766. view.setTipAmount(breakdown.getTipAmount());
  767. view.setAmount(breakdown.getAmount());
  768. }
  769. private void copyAddress(FlashDeliveryAddressRequest source, FlashDeliveryOrder target, boolean pickup) {
  770. if (pickup) {
  771. target.setPickupName(source.getName().trim()); target.setPickupPhone(source.getPhone().trim());
  772. target.setPickupAddress(source.getAddress().trim()); target.setPickupAddressDetail(trimToNull(source.getAddressDetail()));
  773. target.setPickupCity(trimToNull(source.getCity())); target.setPickupArea(trimToNull(source.getArea()));
  774. target.setPickupHandoffMethod(trimToNull(source.getHandoffMethod()));
  775. target.setPickupLongitude(source.getLongitude()); target.setPickupLatitude(source.getLatitude());
  776. } else {
  777. target.setDeliveryName(source.getName().trim()); target.setDeliveryPhone(source.getPhone().trim());
  778. target.setDeliveryAddress(source.getAddress().trim()); target.setDeliveryAddressDetail(trimToNull(source.getAddressDetail()));
  779. target.setDeliveryCity(trimToNull(source.getCity())); target.setDeliveryArea(trimToNull(source.getArea()));
  780. target.setDeliveryHandoffMethod(trimToNull(source.getHandoffMethod()));
  781. target.setDeliveryLongitude(source.getLongitude()); target.setDeliveryLatitude(source.getLatitude());
  782. }
  783. }
  784. private void validateAddress(FlashDeliveryAddressRequest address) {
  785. if (address == null || !hasText(address.getName()) || !hasText(address.getPhone())
  786. || !hasText(address.getAddress()) || address.getLatitude() == null || address.getLongitude() == null
  787. || address.getName().trim().length() > 64 || address.getPhone().trim().length() > 32
  788. || address.getAddress().trim().length() > 255
  789. || (address.getAddressDetail() != null && address.getAddressDetail().trim().length() > 255)
  790. || tooLong(address.getCity(), 64) || tooLong(address.getArea(), 64)
  791. || tooLong(address.getHandoffMethod(), 40)
  792. || address.getLatitude().compareTo(BigDecimal.valueOf(-90)) < 0
  793. || address.getLatitude().compareTo(BigDecimal.valueOf(90)) > 0
  794. || address.getLongitude().compareTo(BigDecimal.valueOf(-180)) < 0
  795. || address.getLongitude().compareTo(BigDecimal.valueOf(180)) > 0) {
  796. throw fail("flash.delivery.address.invalid");
  797. }
  798. }
  799. private Long resolveReceiverUserId(String phone) {
  800. if (!hasText(phone)) return null;
  801. StringBuilder normalized = new StringBuilder(phone.length());
  802. for (int index = 0; index < phone.length(); index++) {
  803. char value = phone.charAt(index);
  804. if (Character.isWhitespace(value) || value == '+' || value == '-'
  805. || value == '(' || value == ')') {
  806. continue;
  807. }
  808. if (value < '0' || value > '9') return null;
  809. normalized.append(value);
  810. }
  811. if (normalized.isEmpty()) return null;
  812. List<Long> matches = userMapper.selectOrdinaryUserIdsByNormalizedPhone(normalized.toString());
  813. return matches != null && matches.size() == 1 ? matches.get(0) : null;
  814. }
  815. private boolean isParticipant(Long userId, FlashDeliveryOrder order) {
  816. return Objects.equals(userId, order.getUserId())
  817. || Objects.equals(userId, order.getReceiverUserId());
  818. }
  819. private InfoUser requireRider(Long riderId) {
  820. InfoUser rider = riderId == null ? null : userMapper.selectInfoUserByUserId(riderId);
  821. if (rider == null || !"2".equals(rider.getUserType())) throw fail("flash.delivery.rider.required");
  822. return rider;
  823. }
  824. private FlashDeliveryOrder requireOrder(Long orderId) {
  825. FlashDeliveryOrder order = orderId == null ? null : orderMapper.selectById(orderId);
  826. if (order == null) throw fail("flash.delivery.order.not.found");
  827. return order;
  828. }
  829. private void writeLog(Long orderId, String from, String to, String operatorType,
  830. Long operatorId, String reason, Date now) {
  831. FlashDeliveryOrderLog log = new FlashDeliveryOrderLog();
  832. log.setOrderId(orderId); log.setFromStatus(from); log.setToStatus(to);
  833. log.setOperatorType(operatorType); log.setOperatorId(operatorId); log.setReason(reason); log.setCreateTime(now);
  834. logMapper.insert(log);
  835. }
  836. private String requiredReason(FlashDeliveryReasonRequest request) {
  837. if (request == null || !hasText(request.getReason()) || request.getReason().trim().length() > 500) {
  838. throw fail("flash.delivery.cancel.reason.required");
  839. }
  840. return request.getReason().trim();
  841. }
  842. private void validateServiceType(String value) {
  843. if (!hasText(value) || !SERVICE_TYPES.contains(value)) throw fail("flash.delivery.service.type.invalid");
  844. }
  845. private void validateDeliveryType(String value) {
  846. if (!hasText(value) || !DELIVERY_TYPES.contains(value)) {
  847. throw fail("flash.delivery.delivery.type.invalid");
  848. }
  849. }
  850. private void validateItem(FlashDeliveryQuoteRequest request) {
  851. BigDecimal weight = request.getTotalWeightKg();
  852. if (request.getQuantity() == null || request.getQuantity() <= 0
  853. || weight == null || weight.signum() <= 0
  854. || weight.scale() > 2 || weight.compareTo(MAX_TOTAL_WEIGHT_KG) > 0
  855. || tooLong(request.getSpecification(), 255)) {
  856. throw fail("flash.delivery.item.invalid");
  857. }
  858. if (request.getTipAmount() == null || request.getTipAmount() < 0) {
  859. throw fail("flash.delivery.tip.invalid");
  860. }
  861. }
  862. private String packageSize(BigDecimal weight) {
  863. if (weight.compareTo(new BigDecimal("5.00")) <= 0) return "SMALL";
  864. if (weight.compareTo(new BigDecimal("12.00")) <= 0) return "MEDIUM";
  865. return "LARGE";
  866. }
  867. private BigDecimal scaleWeight(BigDecimal weight) {
  868. return weight.setScale(2, RoundingMode.UNNECESSARY);
  869. }
  870. private String pricingTargetTime(Date date) {
  871. return PRICING_TIME_FORMAT.format(Instant.ofEpochMilli(date.getTime())
  872. .atZone(TAIPEI).toLocalTime());
  873. }
  874. private boolean matchesQuotedPrice(FlashDeliveryCreateRequest request, FlashDeliveryQuoteView latest) {
  875. return Objects.equals(request.getPricingId(), latest.getPricingId())
  876. && Objects.equals(request.getPricingVersion(), latest.getPricingVersion())
  877. && Objects.equals(request.getQuotedBaseDeliveryFee(), latest.getBaseDeliveryFee())
  878. && Objects.equals(request.getQuotedDistanceFee(), latest.getDistanceFee())
  879. && Objects.equals(request.getQuotedUrgentFee(), latest.getUrgentFee())
  880. && Objects.equals(request.getQuotedAmount(), latest.getAmount());
  881. }
  882. private void validateOptionalServiceType(String value) {
  883. if (hasText(value)) validateServiceType(value);
  884. }
  885. private void requireUserId(Long userId) {
  886. if (userId == null || userId <= 0) throw fail("flash.delivery.auth.required");
  887. }
  888. private Page<FlashDeliveryOrder> page(int pageNum, int pageSize) {
  889. return new Page<>(Math.max(1, pageNum), Math.min(Math.max(1, pageSize), 100));
  890. }
  891. private <T> IPage<T> mapPage(IPage<FlashDeliveryOrder> source,
  892. Function<FlashDeliveryOrder, T> mapper) {
  893. Page<T> result = new Page<>(source.getCurrent(), source.getSize(), source.getTotal());
  894. result.setRecords(source.getRecords().stream().map(mapper).toList());
  895. return result;
  896. }
  897. private QueryWrapper<FlashDeliveryOrder> riderOrdersQuery(Long riderId, String tab,
  898. BigDecimal longitude, BigDecimal latitude,
  899. boolean orderResults) {
  900. QueryWrapper<FlashDeliveryOrder> query = new QueryWrapper<>();
  901. Date now = new Date();
  902. switch (tab) {
  903. case "newTask" -> {
  904. query.eq("status", WAITING_ACCEPTANCE).isNull("rider_id")
  905. .and(item -> item.eq("delivery_mode", "NOW")
  906. .or().le("scheduled_pickup_start_at", now));
  907. if (longitude != null) {
  908. Integer limitKm = newTaskDistanceLimit();
  909. if (limitKm != null) {
  910. query.apply("ST_Distance_Sphere(point(pickup_longitude, pickup_latitude), point({0}, {1})) <= {2}",
  911. longitude, latitude, limitKm.longValue() * 1000L);
  912. }
  913. if (orderResults) {
  914. query.orderByAsc("ST_Distance_Sphere(point(pickup_longitude, pickup_latitude), point(" + longitude
  915. + ", " + latitude + "))");
  916. }
  917. } else if (orderResults) {
  918. query.orderByDesc("create_time");
  919. }
  920. }
  921. case "toPickup" -> {
  922. query.eq("rider_id", riderId).eq("status", ACCEPTED);
  923. if (orderResults) query.orderByAsc("create_time");
  924. }
  925. case "delivering" -> {
  926. query.eq("rider_id", riderId).eq("status", PICKED_UP);
  927. if (orderResults) query.orderByAsc("create_time");
  928. }
  929. case "completed" -> {
  930. query.eq("rider_id", riderId).in("status", DELIVERED, COMPLETED);
  931. if (orderResults) query.orderByDesc("create_time");
  932. }
  933. case "cancelled" -> {
  934. query.eq("rider_id", riderId).eq("status", CANCELLED);
  935. if (orderResults) query.orderByDesc("create_time");
  936. }
  937. default -> throw fail("flash.delivery.tab.invalid");
  938. }
  939. return query;
  940. }
  941. private void validateRiderCoordinates(BigDecimal longitude, BigDecimal latitude) {
  942. if ((longitude == null) != (latitude == null)
  943. || longitude != null && (longitude.compareTo(BigDecimal.valueOf(-180)) < 0
  944. || longitude.compareTo(BigDecimal.valueOf(180)) > 0
  945. || latitude.compareTo(BigDecimal.valueOf(-90)) < 0
  946. || latitude.compareTo(BigDecimal.valueOf(90)) > 0)) {
  947. throw fail("flash.delivery.coordinates.invalid");
  948. }
  949. }
  950. private Integer newTaskDistanceLimit() {
  951. try {
  952. List<SysDictData> values = DictUtils.getDictCache("sys_qs_newtask_distance");
  953. if (values != null && !values.isEmpty()) {
  954. int limit = Integer.parseInt(values.get(0).getDictValue());
  955. return limit > 0 ? limit : null;
  956. }
  957. } catch (Exception ignored) {
  958. // Keep the established rider-list behaviour: absent or malformed configuration disables distance filtering.
  959. }
  960. return null;
  961. }
  962. private Integer distanceMeters(BigDecimal longitude, BigDecimal latitude,
  963. BigDecimal targetLongitude, BigDecimal targetLatitude) {
  964. if (longitude == null || targetLongitude == null || targetLatitude == null) return null;
  965. double latitudeRadians = Math.toRadians(latitude.doubleValue());
  966. double targetLatitudeRadians = Math.toRadians(targetLatitude.doubleValue());
  967. double latitudeDelta = targetLatitudeRadians - latitudeRadians;
  968. double longitudeDelta = Math.toRadians(targetLongitude.doubleValue() - longitude.doubleValue());
  969. double value = Math.sin(latitudeDelta / 2) * Math.sin(latitudeDelta / 2)
  970. + Math.cos(latitudeRadians) * Math.cos(targetLatitudeRadians)
  971. * Math.sin(longitudeDelta / 2) * Math.sin(longitudeDelta / 2);
  972. return (int) Math.round(6_371_000D * 2D * Math.atan2(Math.sqrt(value), Math.sqrt(1D - value)));
  973. }
  974. private Long firstLong(List<Object> values) {
  975. if (values == null || values.isEmpty() || values.get(0) == null) return null;
  976. Object value = values.get(0);
  977. if (value instanceof Number number) return number.longValue();
  978. return Long.valueOf(String.valueOf(value));
  979. }
  980. private String normalizeDeliveryMode(String value) {
  981. String mode = hasText(value) ? value.trim() : "NOW";
  982. if (!DELIVERY_MODES.contains(mode)) throw fail("flash.delivery.mode.invalid");
  983. return mode;
  984. }
  985. private void validateSchedule(String mode, Date start, Date end, Date now) {
  986. if ("NOW".equals(mode)) {
  987. if (start != null || end != null) throw fail("flash.delivery.schedule.invalid");
  988. return;
  989. }
  990. if (start == null || end == null || start.before(now)
  991. || end.getTime() - start.getTime() != SCHEDULE_SLOT_MILLIS
  992. || start.getTime() - now.getTime() > MAX_SCHEDULE_DELAY_MILLIS) {
  993. throw fail("flash.delivery.schedule.invalid");
  994. }
  995. }
  996. private boolean isAvailableAt(FlashDeliveryOrder order, Date now) {
  997. return "NOW".equals(order.getDeliveryMode())
  998. || "SCHEDULED".equals(order.getDeliveryMode())
  999. && order.getScheduledPickupStartAt() != null
  1000. && !order.getScheduledPickupStartAt().after(now);
  1001. }
  1002. private void optionalEq(QueryWrapper<FlashDeliveryOrder> query, String column, String value) {
  1003. if (hasText(value)) query.eq(column, value.trim());
  1004. }
  1005. private boolean hasText(String value) { return StringUtils.isNotEmpty(value) && !value.trim().isEmpty(); }
  1006. private String trimToNull(String value) { return hasText(value) ? value.trim() : null; }
  1007. private boolean tooLong(String value, int max) { return value != null && value.trim().length() > max; }
  1008. private boolean sameAddressText(FlashDeliveryAddressRequest left, FlashDeliveryAddressRequest right) {
  1009. return normalized(left.getAddress()).equals(normalized(right.getAddress()))
  1010. && normalized(left.getAddressDetail()).equals(normalized(right.getAddressDetail()));
  1011. }
  1012. private String normalized(String value) { return value == null ? "" : value.trim().toLowerCase(Locale.ROOT); }
  1013. private ServiceException fail(String key) { return new ServiceException(MessageUtils.message(key)); }
  1014. private record QuoteContext(FlashDeliveryPricing pricing, RouteDistance route,
  1015. FlashDeliveryPriceBreakdown breakdown) { }
  1016. }