02_28_version_1

This commit is contained in:
root 2026-02-28 07:15:26 +08:00
parent 86c65ece9f
commit 1810d40c01
11 changed files with 537 additions and 615 deletions

39
backend/Dockerfile Normal file
View File

@ -0,0 +1,39 @@
# ============================================
# 农产品直销平台 - 后端 Dockerfile
# 多阶段构建: Maven编译 → JRE运行
# ============================================
# ---------- 第一阶段Maven构建 ----------
FROM maven:3.8-eclipse-temurin-17 AS builder
WORKDIR /build
# 配置Maven阿里云镜像加速依赖下载
RUN mkdir -p /root/.m2 && \
echo '<?xml version="1.0" encoding="UTF-8"?><settings><mirrors><mirror><id>aliyun</id><mirrorOf>*</mirrorOf><url>https://maven.aliyun.com/repository/public</url></mirror></mirrors></settings>' > /root/.m2/settings.xml
# 先复制pom.xml利用Docker缓存层加速依赖不变时跳过下载
COPY pom.xml .
RUN mvn dependency:go-offline -B
# 复制源代码并打包
COPY src ./src
RUN mvn clean package -DskipTests
# ---------- 第二阶段:运行时 ----------
FROM eclipse-temurin:17-jre
WORKDIR /app
# 设置时区
RUN ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \
echo "Asia/Shanghai" > /etc/timezone
# 创建日志目录
RUN mkdir -p /app/logs
# 从构建阶段复制JAR包
COPY --from=builder /build/target/sunny-farm-0.0.1-SNAPSHOT.jar app.jar
EXPOSE 8080
# 启动命令支持通过JAVA_OPTS传入JVM参数
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]

View File

@ -2,7 +2,6 @@ package com.sunnyfarm.dto.express;
import lombok.Data; import lombok.Data;
import java.util.List; import java.util.List;
import java.util.Map;
/** /**
* 物流综合信息DTO包含轨迹和地图数据 * 物流综合信息DTO包含轨迹和地图数据
@ -10,103 +9,45 @@ import java.util.Map;
@Data @Data
public class ExpressInfoDTO { public class ExpressInfoDTO {
/** /** 快递单号 */
* 快递单号
*/
private String trackingNumber; private String trackingNumber;
/** /** 快递公司名称 */
* 快递公司名称
*/
private String companyName; private String companyName;
/** /** 快递公司代码 */
* 快递公司代码
*/
private String companyCode; private String companyCode;
/** /** 物流状态 */
* 物流状态
*/
private String status; private String status;
/** /** 当前位置 */
* 当前位置
*/
private String currentLocation; private String currentLocation;
/** /** 更新时间 */
* 更新时间
*/
private String updateTime; private String updateTime;
/** /** 运输时长 */
* 物流轨迹列表 private String takeTime;
*/
private List<TraceItem> traces;
/** /** 快递员电话 */
* 地图路线数据 private String courierPhone;
*/
private MapRouteData mapRoute; /** 物流轨迹地图URL可直接iframe嵌入 */
private String mapUrl;
/** 物流轨迹列表 */
private List<TraceItem> traces;
@Data @Data
public static class TraceItem { public static class TraceItem {
/** /** 时间 */
* 时间
*/
private String time; private String time;
/** 地点 */
/**
* 地点
*/
private String location; private String location;
/** 状态描述 */
/**
* 状态描述
*/
private String status; private String status;
} /** 动作类型1揽收,2运输,202派件,211投柜,311签收 */
private String action;
@Data
public static class MapRouteData {
/**
* 城市列表用于标记
*/
private List<CityPoint> cities;
/**
* 路线坐标点如果有路径规划
*/
private List<List<Double>> routePoints;
/**
* 路线信息
*/
private String distance;
private String duration;
}
@Data
public static class CityPoint {
/**
* 城市名称
*/
private String name;
/**
* 经度
*/
private Double lng;
/**
* 纬度
*/
private Double lat;
/**
* 序号
*/
private Integer index;
} }
} }

View File

@ -1,8 +1,6 @@
package com.sunnyfarm.service.impl; package com.sunnyfarm.service.impl;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sunnyfarm.config.properties.AliyunExpressProperties; import com.sunnyfarm.config.properties.AliyunExpressProperties;
import com.sunnyfarm.config.properties.AmapProperties;
import com.sunnyfarm.dto.express.ExpressInfoDTO; import com.sunnyfarm.dto.express.ExpressInfoDTO;
import com.sunnyfarm.dto.express.ExpressTraceDTO; import com.sunnyfarm.dto.express.ExpressTraceDTO;
import com.sunnyfarm.entity.Order; import com.sunnyfarm.entity.Order;
@ -13,16 +11,19 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.http.*; import org.springframework.http.*;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestTemplate; import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder; import org.springframework.web.util.UriComponentsBuilder;
import java.util.*; import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/** /**
* 物流查询服务实现 * 物流查询服务实现
* 使用两个API
* 1. 旧APIwuliu.market.alicloudapi.com查询物流轨迹信息
* 2. 新APIlhkdwlgjdt.market.alicloudapi.com查询物流轨迹地图
*/ */
@Slf4j @Slf4j
@Service @Service
@ -30,57 +31,46 @@ import java.util.stream.Collectors;
public class ExpressServiceImpl implements ExpressService { public class ExpressServiceImpl implements ExpressService {
private final AliyunExpressProperties expressProperties; private final AliyunExpressProperties expressProperties;
private final AmapProperties amapProperties;
private final OrderMapper orderMapper; private final OrderMapper orderMapper;
private final RestTemplate restTemplate = new RestTemplate(); private final RestTemplate restTemplate = new RestTemplate();
private final ObjectMapper objectMapper = new ObjectMapper();
// 预设主要城市坐标 // 物流轨迹地图API地址
private static final Map<String, double[]> CITY_COORDINATES = new HashMap<>(); private static final String TRACE_MAP_URL = "https://lhkdwlgjdt.market.alicloudapi.com/express/trace-map/v2";
static {
CITY_COORDINATES.put("广州市", new double[]{113.264385, 23.129163});
CITY_COORDINATES.put("深圳市", new double[]{114.057868, 22.543099});
CITY_COORDINATES.put("北京市", new double[]{116.407526, 39.904030});
CITY_COORDINATES.put("上海市", new double[]{121.473701, 31.230416});
CITY_COORDINATES.put("杭州市", new double[]{120.153576, 30.287459});
CITY_COORDINATES.put("南京市", new double[]{118.796877, 32.060255});
CITY_COORDINATES.put("成都市", new double[]{104.065735, 30.659462});
CITY_COORDINATES.put("武汉市", new double[]{114.305393, 30.593099});
CITY_COORDINATES.put("西安市", new double[]{108.939621, 34.341568});
CITY_COORDINATES.put("重庆市", new double[]{106.551557, 29.563009});
CITY_COORDINATES.put("天津市", new double[]{117.200983, 39.084158});
CITY_COORDINATES.put("苏州市", new double[]{120.585315, 31.298886});
CITY_COORDINATES.put("东莞市", new double[]{113.751765, 23.020673});
CITY_COORDINATES.put("佛山市", new double[]{113.121416, 23.021548});
CITY_COORDINATES.put("惠州市", new double[]{114.416196, 23.111847});
}
@Override @Override
public ExpressInfoDTO queryExpressInfo(String trackingNumber, String companyCode) { public ExpressInfoDTO queryExpressInfo(String trackingNumber, String companyCode) {
log.info("🔍 查询物流信息 - 单号: {}, 公司代码: {}", trackingNumber, companyCode); log.info("🔍 查询物流信息 - 单号: {}, 公司代码: {}", trackingNumber, companyCode);
try { try {
// 1. 调用阿里云物流查询API // 1. 用旧API查询物流轨迹
ExpressTraceDTO traceData = queryExpressTrace(trackingNumber, companyCode); String oldApiCode = convertToOldApiCode(companyCode);
ExpressTraceDTO traceData = queryExpressTrace(trackingNumber, oldApiCode);
if (traceData == null || !"0".equals(traceData.getStatus())) {
throw new BusinessException("物流信息查询失败");
}
// 2. 解析物流轨迹数据 // 2. 解析物流轨迹数据
ExpressInfoDTO result = parseExpressData(traceData); ExpressInfoDTO result = parseExpressData(traceData, trackingNumber);
// 3. 提取城市并规划路线 // 3. 用新API查询物流地图不影响主流程失败也不报错
List<String> cities = extractCities(traceData); try {
if (!cities.isEmpty()) { String mapApiCode = convertToMapApiCode(companyCode);
ExpressInfoDTO.MapRouteData routeData = planRoute(cities); // 从trackingNumber中提取手机尾号如果有的话
result.setMapRoute(routeData); String phone = null;
String pureTrackingNumber = trackingNumber;
if (trackingNumber.contains(":")) {
String[] parts = trackingNumber.split(":");
pureTrackingNumber = parts[0];
phone = parts[1];
}
String mapUrl = queryTraceMap(pureTrackingNumber, mapApiCode, phone);
result.setMapUrl(mapUrl);
} catch (Exception e) {
log.warn("⚠️ 物流地图查询失败(不影响轨迹展示): {}", e.getMessage());
} }
log.info("✅ 物流信息查询成功 - 单号: {}", trackingNumber); log.info("✅ 物流信息查询成功 - 单号: {}", trackingNumber);
return result; return result;
} catch (BusinessException e) {
throw e;
} catch (Exception e) { } catch (Exception e) {
log.error("❌ 物流信息查询失败", e); log.error("❌ 物流信息查询失败", e);
throw new BusinessException("物流信息查询失败: " + e.getMessage()); throw new BusinessException("物流信息查询失败: " + e.getMessage());
@ -100,14 +90,56 @@ public class ExpressServiceImpl implements ExpressService {
throw new BusinessException("订单暂无物流信息"); throw new BusinessException("订单暂无物流信息");
} }
// 将订单的物流公司名称转换为代码
String companyCode = convertCompanyNameToCode(order.getShipCompany()); String companyCode = convertCompanyNameToCode(order.getShipCompany());
String trackingNumber = order.getShipNo();
return queryExpressInfo(order.getShipNo(), companyCode); // 顺丰需要拼接手机尾号到trackingNumber旧API需要
String phone = null;
if ("SF".equals(companyCode) && order.getPhone() != null && order.getPhone().length() >= 4) {
phone = order.getPhone().substring(order.getPhone().length() - 4);
if (!trackingNumber.contains(":")) {
trackingNumber = trackingNumber + ":" + phone;
log.info("📱 顺丰快递自动拼接手机尾号: {} -> {}", order.getShipNo(), trackingNumber);
}
}
// 对于新地图API中通也需要手机号
if ("ZTO".equals(companyCode) && phone == null && order.getPhone() != null && order.getPhone().length() >= 4) {
phone = order.getPhone().substring(order.getPhone().length() - 4);
}
try {
// 1. 用旧API查询物流轨迹
String oldApiCode = convertToOldApiCode(companyCode);
ExpressTraceDTO traceData = queryExpressTrace(trackingNumber, oldApiCode);
// 2. 解析物流轨迹数据
ExpressInfoDTO result = parseExpressData(traceData, order.getShipNo());
// 3. 用新API查询物流地图
try {
String mapApiCode = convertToMapApiCode(companyCode);
String mapUrl = queryTraceMap(order.getShipNo(), mapApiCode, phone);
result.setMapUrl(mapUrl);
} catch (Exception e) {
log.warn("⚠️ 物流地图查询失败(不影响轨迹展示): {}", e.getMessage());
}
log.info("✅ 物流信息查询成功 - 单号: {}", order.getShipNo());
return result;
} catch (BusinessException e) {
throw e;
} catch (Exception e) {
log.error("❌ 物流信息查询失败", e);
throw new BusinessException("物流信息查询失败: " + e.getMessage());
}
} }
// ==================== 旧API查询物流轨迹 ====================
/** /**
* 调用阿里云物流查询API * 调用旧API查询物流轨迹
*/ */
private ExpressTraceDTO queryExpressTrace(String trackingNumber, String companyCode) { private ExpressTraceDTO queryExpressTrace(String trackingNumber, String companyCode) {
try { try {
@ -123,56 +155,58 @@ public class ExpressServiceImpl implements ExpressService {
HttpEntity<String> entity = new HttpEntity<>(headers); HttpEntity<String> entity = new HttpEntity<>(headers);
log.info("📡 调用阿里云物流API: {}", url); log.info("📡 调用物流轨迹API: {}", url);
ResponseEntity<ExpressTraceDTO> response = restTemplate.exchange( ResponseEntity<ExpressTraceDTO> response = restTemplate.exchange(
url, url, HttpMethod.GET, entity, ExpressTraceDTO.class);
HttpMethod.GET,
entity,
ExpressTraceDTO.class
);
log.info("✅ 阿里云物流API响应成功"); ExpressTraceDTO body = response.getBody();
return response.getBody(); log.info("✅ 物流轨迹API响应 - status={}, msg={}",
body != null ? body.getStatus() : "null",
body != null ? body.getMsg() : "null");
if (body == null || (!"0".equals(body.getStatus()) && !"ok".equals(body.getMsg()))) {
String msg = body != null ? body.getMsg() : "返回为空";
throw new BusinessException("物流信息查询失败: " + msg);
}
return body;
} catch (BusinessException e) {
throw e;
} catch (Exception e) { } catch (Exception e) {
log.error("❌ 调用阿里云物流API失败", e); log.error("❌ 调用物流轨迹API失败", e);
throw new BusinessException("物流查询服务异常"); throw new BusinessException("物流查询服务异常");
} }
} }
/** /**
* 解析物流数据 * 解析物流轨迹数据
*/ */
private ExpressInfoDTO parseExpressData(ExpressTraceDTO traceData) { private ExpressInfoDTO parseExpressData(ExpressTraceDTO traceData, String trackingNumber) {
ExpressInfoDTO result = new ExpressInfoDTO(); ExpressInfoDTO result = new ExpressInfoDTO();
ExpressTraceDTO.ExpressResult data = traceData.getResult(); ExpressTraceDTO.ExpressResult data = traceData.getResult();
result.setTrackingNumber(data.getNumber()); result.setTrackingNumber(trackingNumber);
result.setCompanyName(data.getExpName()); result.setCompanyName(data.getExpName());
result.setCompanyCode(data.getType()); result.setCompanyCode(data.getType());
result.setStatus(getStatusText(data.getDeliverystatus())); result.setStatus(getStatusText(data.getDeliverystatus()));
result.setUpdateTime(data.getUpdateTime()); result.setUpdateTime(data.getUpdateTime());
result.setCourierPhone(data.getCourierPhone());
// 转换物流轨迹
if (data.getList() != null && !data.getList().isEmpty()) { if (data.getList() != null && !data.getList().isEmpty()) {
List<ExpressInfoDTO.TraceItem> traces = data.getList().stream() List<ExpressInfoDTO.TraceItem> traces = data.getList().stream()
.map(trace -> { .map(trace -> {
ExpressInfoDTO.TraceItem item = new ExpressInfoDTO.TraceItem(); ExpressInfoDTO.TraceItem item = new ExpressInfoDTO.TraceItem();
item.setTime(trace.getTime()); item.setTime(trace.getTime());
item.setStatus(trace.getStatus()); item.setStatus(trace.getStatus());
item.setLocation(extractLocation(trace.getStatus()));
// 提取地点信息
String location = extractLocation(trace.getStatus());
item.setLocation(location);
return item; return item;
}) })
.collect(Collectors.toList()); .collect(Collectors.toList());
result.setTraces(traces); result.setTraces(traces);
// 设置当前位置最新的轨迹
if (!traces.isEmpty()) { if (!traces.isEmpty()) {
result.setCurrentLocation(traces.get(0).getLocation()); result.setCurrentLocation(traces.get(0).getLocation());
} }
@ -181,201 +215,114 @@ public class ExpressServiceImpl implements ExpressService {
return result; return result;
} }
// ==================== 新API查询物流轨迹地图 ====================
/** /**
* 提取城市列表 * 调用新API查询物流轨迹地图返回mapUrl
*/ */
private List<String> extractCities(ExpressTraceDTO traceData) { @SuppressWarnings("unchecked")
Set<String> citySet = new LinkedHashSet<>(); private String queryTraceMap(String trackingNumber, String companyCode, String phone) {
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "APPCODE " + expressProperties.getAppCode());
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
if (traceData.getResult() != null && traceData.getResult().getList() != null) { MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
for (ExpressTraceDTO.ExpressTrace trace : traceData.getResult().getList()) { body.add("shipperCode", companyCode);
String location = extractLocation(trace.getStatus()); body.add("logisticCode", trackingNumber);
if (location != null && !location.isEmpty()) { body.add("sort", "asc");
// 尝试匹配城市名称
for (String city : CITY_COORDINATES.keySet()) { if (phone != null && !phone.isEmpty()) {
if (location.contains(city.replace("", ""))) { body.add("phone", phone);
citySet.add(city);
break;
}
}
}
}
} }
List<String> cities = new ArrayList<>(citySet); log.info("📡 调用物流地图API - 单号: {}, 公司: {}, phone: {}", trackingNumber, companyCode, phone);
// 反转列表使其按照物流顺序排列
Collections.reverse(cities);
log.info("🏙️ 提取到的城市列表: {}", cities); HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(body, headers);
return cities;
Map<String, Object> response = restTemplate.postForObject(TRACE_MAP_URL, request, Map.class);
if (response == null || !Boolean.TRUE.equals(response.get("success"))) {
String msg = response != null ? (String) response.get("msg") : "返回为空";
log.warn("⚠️ 物流地图API返回失败: {}", msg);
return null;
}
Map<String, Object> data = (Map<String, Object>) response.get("data");
if (data != null) {
String mapUrl = (String) data.get("mapUrl");
log.info("🗺️ 获取到物流地图URL: {}", mapUrl);
return mapUrl;
}
return null;
}
// ==================== 编码转换 ====================
/**
* 统一内部编码基于快递鸟标准
*/
private String convertCompanyNameToCode(String companyName) {
if (companyName == null) return null;
Map<String, String> companyMap = new LinkedHashMap<>();
companyMap.put("顺丰", "SF");
companyMap.put("圆通", "YTO");
companyMap.put("中通", "ZTO");
companyMap.put("韵达", "YD");
companyMap.put("申通", "STO");
companyMap.put("邮政", "EMS");
companyMap.put("EMS", "EMS");
companyMap.put("京东", "JD");
companyMap.put("极兔", "JTSD");
for (Map.Entry<String, String> entry : companyMap.entrySet()) {
if (companyName.contains(entry.getKey())) {
return entry.getValue();
}
}
return null;
} }
/** /**
* 提取地点信息 * 转换为旧APIwuliu的编码
*/ */
private String convertToOldApiCode(String code) {
if (code == null) return null;
Map<String, String> map = new HashMap<>();
map.put("SF", "SFEXPRESS");
map.put("YTO", "YTO");
map.put("ZTO", "ZTO");
map.put("YD", "YUNDA");
map.put("STO", "STO");
map.put("EMS", "EMS");
map.put("JD", "JD");
map.put("JTSD", "JTSD");
return map.getOrDefault(code, code);
}
/**
* 转换为新API快递鸟地图的编码
*/
private String convertToMapApiCode(String code) {
// 新API用的就是快递鸟标准编码和内部编码一致
return code;
}
// ==================== 工具方法 ====================
private String extractLocation(String status) { private String extractLocation(String status) {
if (status == null) return ""; if (status == null) return "";
java.util.regex.Pattern pattern = java.util.regex.Pattern.compile("【(.+?)】");
// 匹配城市格式 java.util.regex.Matcher matcher = pattern.matcher(status);
Pattern pattern = Pattern.compile("【(.+?)】");
Matcher matcher = pattern.matcher(status);
if (matcher.find()) { if (matcher.find()) {
return matcher.group(1); return matcher.group(1);
} }
return ""; return "";
} }
/**
* 规划路线
*/
private ExpressInfoDTO.MapRouteData planRoute(List<String> cities) {
ExpressInfoDTO.MapRouteData routeData = new ExpressInfoDTO.MapRouteData();
// 添加城市标记点
List<ExpressInfoDTO.CityPoint> cityPoints = new ArrayList<>();
for (int i = 0; i < cities.size(); i++) {
String city = cities.get(i);
double[] coords = CITY_COORDINATES.get(city);
if (coords != null) {
ExpressInfoDTO.CityPoint point = new ExpressInfoDTO.CityPoint();
point.setName(city);
point.setLng(coords[0]);
point.setLat(coords[1]);
point.setIndex(i);
cityPoints.add(point);
}
}
routeData.setCities(cityPoints);
// 如果有多个城市规划路线
if (cityPoints.size() >= 2) {
try {
// 调用高德驾车路径规划API
ExpressInfoDTO.CityPoint origin = cityPoints.get(0);
ExpressInfoDTO.CityPoint destination = cityPoints.get(cityPoints.size() - 1);
String waypoints = null;
if (cityPoints.size() > 2) {
// 中间途经点
waypoints = cityPoints.subList(1, cityPoints.size() - 1).stream()
.map(p -> p.getLng() + "," + p.getLat())
.collect(Collectors.joining(";"));
}
Map<String, Object> routeResult = queryDrivingRoute(
origin.getLng() + "," + origin.getLat(),
destination.getLng() + "," + destination.getLat(),
waypoints
);
// 解析路线数据
if (routeResult != null && "1".equals(routeResult.get("status"))) {
Map<String, Object> route = (Map<String, Object>) routeResult.get("route");
if (route != null) {
List<Map<String, Object>> paths = (List<Map<String, Object>>) route.get("paths");
if (paths != null && !paths.isEmpty()) {
Map<String, Object> path = paths.get(0);
routeData.setDistance(path.get("distance").toString());
routeData.setDuration(path.get("duration").toString());
// 提取路径点
List<List<Double>> routePoints = extractRoutePoints(path);
routeData.setRoutePoints(routePoints);
}
}
}
} catch (Exception e) {
log.warn("⚠️ 路径规划失败,使用直线连接: {}", e.getMessage());
}
}
return routeData;
}
/**
* 调用高德驾车路径规划API
*/
private Map<String, Object> queryDrivingRoute(String origin, String destination, String waypoints) {
try {
// 构建URL确保坐标格式正确
StringBuilder urlBuilder = new StringBuilder();
urlBuilder.append(amapProperties.getWebApi().getBaseUrl())
.append("/v3/direction/driving")
.append("?key=").append(amapProperties.getWebApi().getKey())
.append("&origin=").append(origin)
.append("&destination=").append(destination)
.append("&output=json");
if (waypoints != null && !waypoints.isEmpty()) {
urlBuilder.append("&waypoints=").append(waypoints);
}
String url = urlBuilder.toString();
log.info("🚗 调用高德路径规划API: {}", url);
// 验证坐标格式
if (!origin.matches("\\d+\\.\\d+,\\d+\\.\\d+")) {
log.error("❌ 起点坐标格式错误: {}", origin);
return null;
}
if (!destination.matches("\\d+\\.\\d+,\\d+\\.\\d+")) {
log.error("❌ 终点坐标格式错误: {}", destination);
return null;
}
Map<String, Object> result = restTemplate.getForObject(url, Map.class);
log.info("✅ 高德路径规划API响应成功");
return result;
} catch (Exception e) {
log.error("❌ 高德路径规划API调用失败", e);
return null;
}
}
/**
* 提取路径点
*/
private List<List<Double>> extractRoutePoints(Map<String, Object> path) {
List<List<Double>> points = new ArrayList<>();
try {
List<Map<String, Object>> steps = (List<Map<String, Object>>) path.get("steps");
if (steps != null) {
for (Map<String, Object> step : steps) {
String polyline = (String) step.get("polyline");
if (polyline != null) {
String[] coords = polyline.split(";");
for (String coord : coords) {
String[] lngLat = coord.split(",");
if (lngLat.length == 2) {
points.add(Arrays.asList(
Double.parseDouble(lngLat[0]),
Double.parseDouble(lngLat[1])
));
}
}
}
}
}
} catch (Exception e) {
log.warn("⚠️ 解析路径点失败: {}", e.getMessage());
}
return points;
}
/**
* 获取状态文本
*/
private String getStatusText(String status) { private String getStatusText(String status) {
if (status == null) return "未知"; if (status == null) return "未知";
switch (status) { switch (status) {
case "0": return "在途中"; case "0": return "在途中";
case "1": return "已揽收"; case "1": return "已揽收";
@ -387,33 +334,4 @@ public class ExpressServiceImpl implements ExpressService {
default: return "未知"; default: return "未知";
} }
} }
/**
* 转换物流公司名称为代码
*/
private String convertCompanyNameToCode(String companyName) {
if (companyName == null) return null;
Map<String, String> companyMap = new HashMap<>();
companyMap.put("韵达", "yunda");
companyMap.put("韵达快递", "yunda");
companyMap.put("顺丰", "shunfeng");
companyMap.put("顺丰快递", "shunfeng");
companyMap.put("圆通", "yuantong");
companyMap.put("圆通快递", "yuantong");
companyMap.put("中通", "zhongtong");
companyMap.put("中通快递", "zhongtong");
companyMap.put("申通", "shentong");
companyMap.put("申通快递", "shentong");
companyMap.put("EMS", "ems");
companyMap.put("邮政", "ems");
for (Map.Entry<String, String> entry : companyMap.entrySet()) {
if (companyName.contains(entry.getKey())) {
return entry.getValue();
}
}
return null;
}
} }

View File

@ -136,8 +136,8 @@
<select id="getSalesTrendByMerchant" parameterType="long" resultType="com.sunnyfarm.dto.MerchantDashboardDTO$SalesTrendDTO"> <select id="getSalesTrendByMerchant" parameterType="long" resultType="com.sunnyfarm.dto.MerchantDashboardDTO$SalesTrendDTO">
SELECT SELECT
DATE(created_at) as date, DATE(created_at) as date,
IFNULL(SUM(actual_amount), 0) as amount, IFNULL(SUM(actual_amount), 0) as sales,
COUNT(*) as orderCount COUNT(*) as orders
FROM orders FROM orders
WHERE merchant_id = #{merchantId} WHERE merchant_id = #{merchantId}
AND status IN (2, 3, 4) AND status IN (2, 3, 4)

42
docker-compose.yml Normal file
View File

@ -0,0 +1,42 @@
version: '3.8'
services:
# ===== 后端服务 - Spring Boot =====
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: sunnyfarm-backend
ports:
- "8080:8080"
environment:
- SPRING_PROFILES_ACTIVE=prod,docker
- TZ=Asia/Shanghai
- JAVA_OPTS=-Xms256m -Xmx512m
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
- ./logs/backend:/app/logs
restart: always
networks:
- sunnyfarm-net
# ===== 前端服务 - Nginx =====
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: sunnyfarm-frontend
ports:
- "53921:80"
environment:
- TZ=Asia/Shanghai
restart: always
depends_on:
- backend
networks:
- sunnyfarm-net
networks:
sunnyfarm-net:
driver: bridge

36
frontend/Dockerfile Normal file
View File

@ -0,0 +1,36 @@
# ============================================
# 农产品直销平台 - 前端 Dockerfile
# 多阶段构建: Node编译 → Nginx部署
# ============================================
# ---------- 第一阶段Node构建 ----------
FROM node:18-alpine AS builder
WORKDIR /app
# 配置npm镜像加速依赖下载
RUN npm config set registry https://registry.npmmirror.com
# 先复制package文件利用Docker缓存层
COPY package*.json ./
RUN npm install
# 复制源代码并构建
COPY . .
RUN npm run build
# ---------- 第二阶段Nginx部署 ----------
FROM nginx:1.25-alpine
# 设置时区
RUN ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \
echo "Asia/Shanghai" > /etc/timezone
# 复制自定义Nginx配置
COPY nginx.conf /etc/nginx/conf.d/default.conf
# 从构建阶段复制静态文件
COPY --from=builder /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

32
frontend/nginx.conf Normal file
View File

@ -0,0 +1,32 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Gzip压缩
gzip on;
gzip_min_length 1k;
gzip_comp_level 6;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml;
gzip_vary on;
# Vue Router History模式 - SPA路由支持
location / {
try_files $uri $uri/ /index.html;
}
# 静态资源长期缓存Vite构建带hash
location /assets/ {
expires 30d;
add_header Cache-Control "public, immutable";
access_log off;
}
# 图片等资源缓存
location ~* \.(ico|png|jpg|jpeg|gif|svg|webp)$ {
expires 7d;
add_header Cache-Control "public";
access_log off;
}
}

View File

@ -36,10 +36,14 @@
<div class="update-time" v-if="expressInfo.updateTime"> <div class="update-time" v-if="expressInfo.updateTime">
更新时间{{ expressInfo.updateTime }} 更新时间{{ expressInfo.updateTime }}
</div> </div>
<div class="courier-phone" v-if="expressInfo.courierPhone">
<el-icon><Phone /></el-icon>
快递员电话{{ expressInfo.courierPhone }}
</div>
</div> </div>
<!-- 物流地图 --> <!-- 物流地图 - iframe嵌入 -->
<div class="express-map" v-if="showMap && expressInfo.mapRoute"> <div class="express-map" v-if="showMap && expressInfo.mapUrl">
<div class="map-header"> <div class="map-header">
<span class="map-title"> <span class="map-title">
<el-icon><MapLocation /></el-icon> <el-icon><MapLocation /></el-icon>
@ -51,7 +55,13 @@
</div> </div>
<div v-show="mapExpanded" class="map-container"> <div v-show="mapExpanded" class="map-container">
<div id="express-map" ref="mapContainer"></div> <iframe
:src="expressInfo.mapUrl"
class="map-iframe"
frameborder="0"
allowfullscreen
scrolling="no"
></iframe>
</div> </div>
</div> </div>
@ -68,7 +78,7 @@
:key="index" :key="index"
:timestamp="trace.time" :timestamp="trace.time"
placement="top" placement="top"
:type="index === 0 ? 'primary' : 'info'" :type="getTimelineType(trace, index)"
:size="index === 0 ? 'large' : 'normal'" :size="index === 0 ? 'large' : 'normal'"
:hollow="index !== 0" :hollow="index !== 0"
> >
@ -87,7 +97,7 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue' import { ref, onMounted, watch } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { import {
Loading, Loading,
@ -95,7 +105,8 @@ import {
CopyDocument, CopyDocument,
Location, Location,
MapLocation, MapLocation,
Clock Clock,
Phone
} from '@element-plus/icons-vue' } from '@element-plus/icons-vue'
import request from '../utils/request' import request from '../utils/request'
@ -114,8 +125,6 @@ const loading = ref(true)
const error = ref(null) const error = ref(null)
const expressInfo = ref(null) const expressInfo = ref(null)
const mapExpanded = ref(true) const mapExpanded = ref(true)
const map = ref(null)
const mapContainer = ref(null)
// //
const loadExpressInfo = async () => { const loadExpressInfo = async () => {
@ -125,261 +134,17 @@ const loadExpressInfo = async () => {
try { try {
const response = await request.get(`/express/order/${props.orderId}`) const response = await request.get(`/express/order/${props.orderId}`)
expressInfo.value = response expressInfo.value = response
//
if (props.showMap && expressInfo.value.mapRoute && mapExpanded.value) {
// DOM
await nextTick()
//
setTimeout(() => {
if (mapContainer.value) {
initMap()
} else {
console.warn('⚠️ 地图容器仍未就绪500ms后重试')
setTimeout(() => {
if (mapContainer.value) {
initMap()
}
}, 500)
}
}, 100)
}
} catch (err) { } catch (err) {
console.error('❌ 加载物流信息失败:', err) console.error('❌ 加载物流信息失败:', err)
error.value = err.response?.data?.message || '加载物流信息失败' error.value = err.response?.data?.message || err.message || '加载物流信息失败'
} finally { } finally {
loading.value = false loading.value = false
} }
} }
//
const initMap = () => {
if (!window.AMap) {
console.warn('⚠️ 高德地图API未加载')
return
}
if (!mapContainer.value) {
console.warn('⚠️ 地图容器未找到')
return
}
try {
//
map.value = new AMap.Map(mapContainer.value, {
zoom: 8,
mapStyle: 'amap://styles/normal',
viewMode: '3D'
})
const routeData = expressInfo.value.mapRoute
//
if (routeData.cities && routeData.cities.length > 0) {
const totalCities = routeData.cities.length
routeData.cities.forEach((city, index) => {
const isStart = index === 0
const isEnd = index === totalCities - 1
//
let markerColor, labelBg, labelColor, labelText, borderColor
if (isStart) {
// - 绿
markerColor = '#66BB6A'
labelBg = '#E8F5E9'
labelColor = '#2E7D32'
borderColor = '#66BB6A'
labelText = '发'
} else if (isEnd) {
// -
markerColor = '#42A5F5'
labelBg = '#E3F2FD'
labelColor = '#1976D2'
borderColor = '#42A5F5'
labelText = '收'
} else {
// -
markerColor = '#FFA726'
labelBg = '#FFF3E0'
labelColor = '#E65100'
borderColor = '#FFA726'
labelText = index.toString()
}
//
const marker = new AMap.CircleMarker({
center: [city.lng, city.lat],
radius: 12,
strokeColor: markerColor,
strokeWeight: 3,
fillColor: markerColor,
fillOpacity: 0.8,
zIndex: 100
})
//
const text = new AMap.Text({
text: isStart || isEnd ? `${labelText}${city.name}` : `${labelText}·${city.name}`,
position: [city.lng, city.lat],
offset: new AMap.Pixel(0, -35),
style: {
'background': labelBg,
'border': `2px solid ${borderColor}`,
'color': labelColor,
'font-size': '13px',
'font-weight': 'bold',
'padding': '6px 12px',
'border-radius': '16px',
'box-shadow': '0 2px 8px rgba(0,0,0,0.15)',
'white-space': 'nowrap'
}
})
map.value.add([marker, text])
})
// 线
if (routeData.routePoints && routeData.routePoints.length > 0) {
// 线
//
const borderLine = new AMap.Polyline({
path: routeData.routePoints,
strokeColor: '#FFFFFF',
strokeWeight: 10,
strokeOpacity: 0.8,
strokeStyle: 'solid',
lineJoin: 'round',
lineCap: 'round',
zIndex: 49
})
//
const polyline = new AMap.Polyline({
path: routeData.routePoints,
strokeColor: '#8B5CF6',
strokeWeight: 6,
strokeOpacity: 1,
strokeStyle: 'solid',
lineJoin: 'round',
lineCap: 'round',
zIndex: 50,
showDir: true
})
map.value.add([borderLine, polyline])
} else {
// 使线
const path = routeData.cities.map(city => [city.lng, city.lat])
//
const borderLine = new AMap.Polyline({
path: path,
strokeColor: '#FFFFFF',
strokeWeight: 10,
strokeOpacity: 0.8,
strokeStyle: 'solid',
lineJoin: 'round',
lineCap: 'round',
zIndex: 49
})
//
const polyline = new AMap.Polyline({
path: path,
strokeColor: '#8B5CF6',
strokeWeight: 6,
strokeOpacity: 1,
strokeStyle: 'solid',
lineJoin: 'round',
showDir: true,
zIndex: 50
})
map.value.add([borderLine, polyline])
}
//
const currentLocation = expressInfo.value.currentLocation
if (currentLocation && routeData.cities.length > 0) {
//
let currentCity = null
for (const city of routeData.cities) {
if (currentLocation.includes(city.name.replace('市', ''))) {
currentCity = city
break
}
}
//
if (currentCity) {
const truckIcon = new AMap.Marker({
position: [currentCity.lng, currentCity.lat],
content: `<div style="
background: #FF6B35;
color: white;
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
box-shadow: 0 4px 12px rgba(255, 107, 53, 0.5);
border: 3px solid white;
animation: pulse 2s infinite;
">🚚</div>
<style>
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.1); }
}
</style>`,
offset: new AMap.Pixel(-20, -20),
zIndex: 200
})
//
const currentText = new AMap.Text({
text: '当前位置',
position: [currentCity.lng, currentCity.lat],
offset: new AMap.Pixel(0, 25),
style: {
'background': '#FF6B35',
'border': 'none',
'color': 'white',
'font-size': '12px',
'font-weight': 'bold',
'padding': '4px 10px',
'border-radius': '12px',
'box-shadow': '0 2px 6px rgba(255, 107, 53, 0.4)'
}
})
map.value.add([truckIcon, currentText])
}
}
//
map.value.setFitView()
}
} catch (err) {
console.error('❌ 初始化地图失败:', err)
}
}
// / // /
const toggleMap = async () => { const toggleMap = () => {
mapExpanded.value = !mapExpanded.value mapExpanded.value = !mapExpanded.value
if (mapExpanded.value && !map.value) {
await nextTick()
initMap()
}
} }
// //
@ -398,14 +163,23 @@ const getStatusType = (status) => {
'已签收': 'success', '已签收': 'success',
'派件中': 'primary', '派件中': 'primary',
'已揽收': 'info', '已揽收': 'info',
'在途中': 'warning', '运输中': 'warning',
'疑难': 'danger', '问题件': 'danger',
'退签': 'danger', '转寄': 'warning',
'退回': 'danger' '清关中': 'info'
} }
return typeMap[status] || 'info' return typeMap[status] || 'info'
} }
// 线
const getTimelineType = (trace, index) => {
if (index === 0) return 'primary'
if (trace.action === '311') return 'success'
if (trace.action === '202' || trace.action === '211') return 'primary'
if (trace.action === '1') return 'info'
return 'info'
}
// orderId // orderId
watch(() => props.orderId, () => { watch(() => props.orderId, () => {
if (props.orderId) { if (props.orderId) {
@ -418,12 +192,6 @@ onMounted(() => {
loadExpressInfo() loadExpressInfo()
} }
}) })
onUnmounted(() => {
if (map.value) {
map.value.destroy()
}
})
</script> </script>
<style scoped> <style scoped>
@ -482,7 +250,8 @@ onUnmounted(() => {
} }
.current-location, .current-location,
.update-time { .update-time,
.courier-phone {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
@ -514,13 +283,14 @@ onUnmounted(() => {
} }
.map-container { .map-container {
height: 400px; height: 450px;
background: #f5f5f5; background: #f5f5f5;
} }
#express-map { .map-iframe {
width: 100%; width: 100%;
height: 100%; height: 100%;
border: none;
} }
.express-timeline { .express-timeline {
@ -575,7 +345,7 @@ onUnmounted(() => {
@media (max-width: 768px) { @media (max-width: 768px) {
.map-container { .map-container {
height: 300px; height: 350px;
} }
.express-header { .express-header {

View File

@ -270,7 +270,7 @@ export default {
if (item.date) { if (item.date) {
const date = new Date(item.date) const date = new Date(item.date)
dates.push(`${date.getMonth() + 1}/${date.getDate()}`) dates.push(`${date.getMonth() + 1}/${date.getDate()}`)
amounts.push(parseFloat(item.amount) || 0) amounts.push(parseFloat(item.sales) || 0)
} }
}) })
} else { } else {

View File

@ -92,6 +92,21 @@
> >
确认收货 确认收货
</el-button> </el-button>
<el-button
v-if="order.status === 4 && !reviewed"
type="success"
size="large"
@click="handleReviewOrder"
>
去评价
</el-button>
<el-tag
v-if="order.status === 4 && reviewed"
type="success"
size="large"
>
已评价
</el-tag>
</div> </div>
</div> </div>
</el-card> </el-card>
@ -226,6 +241,15 @@
</div> </div>
</div> </div>
</div> </div>
<!-- 评价弹窗 -->
<ReviewModal
:visible="reviewVisible"
@update:visible="reviewVisible = $event"
:product="reviewProduct"
:orderId="reviewOrderId"
@success="onReviewSuccess"
/>
</div> </div>
</template> </template>
@ -233,6 +257,8 @@
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import ReviewModal from '../../components/ReviewModal.vue'
import { checkUserReview } from '../../api/review'
import { import {
ArrowDown, ArrowDown,
Document, Document,
@ -253,6 +279,7 @@ import ExpressTracking from '../../components/ExpressTracking.vue'
export default { export default {
name: 'OrderDetail', name: 'OrderDetail',
components: { components: {
ReviewModal,
ArrowDown, ArrowDown,
Document, Document,
Location, Location,
@ -271,6 +298,10 @@ export default {
// //
const loading = ref(true) const loading = ref(true)
const reviewVisible = ref(false)
const reviewed = ref(false)
const reviewProduct = ref({})
const reviewOrderId = ref(0)
const payLoading = ref(false) const payLoading = ref(false)
const order = ref(null) const order = ref(null)
@ -345,6 +376,19 @@ export default {
order.value = response order.value = response
console.log('订单详情完整数据:', JSON.stringify(response, null, 2)) console.log('订单详情完整数据:', JSON.stringify(response, null, 2))
//
if (response.status === 4) {
try {
const items = response.orderItems || response.orderDetails || []
if (items.length > 0) {
const res = await checkUserReview(items[0].productId, response.id)
reviewed.value = res === true || res?.data === true
}
} catch (e) {
reviewed.value = false
}
}
console.log('创建时间字段检查:') console.log('创建时间字段检查:')
console.log('- createdAt:', response.createdAt) console.log('- createdAt:', response.createdAt)
console.log('- created_at:', response.created_at) console.log('- created_at:', response.created_at)
@ -530,6 +574,28 @@ export default {
router.push('/') router.push('/')
} }
const handleReviewOrder = () => {
const items = order.value.orderItems || order.value.orderDetails || []
if (items.length > 0) {
const item = items[0]
reviewProduct.value = {
id: item.productId,
name: item.productName,
price: item.price,
image: item.productImage,
merchantId: order.value.merchantId
}
reviewOrderId.value = order.value.id
reviewVisible.value = true
}
}
const onReviewSuccess = () => {
reviewVisible.value = false
reviewed.value = true
ElMessage.success('评价成功!')
}
const goToOrders = () => { const goToOrders = () => {
router.push('/orders') router.push('/orders')
} }
@ -578,6 +644,12 @@ export default {
return { return {
// //
loading, loading,
reviewVisible,
reviewed,
reviewProduct,
reviewOrderId,
handleReviewOrder,
onReviewSuccess,
payLoading, payLoading,
order, order,
route, route,

View File

@ -128,6 +128,21 @@
> >
确认收货 确认收货
</el-button> </el-button>
<el-button
v-if="order.status === 4 && !order.reviewed"
type="success"
size="small"
@click="handleReviewOrder(order)"
>
去评价
</el-button>
<el-tag
v-if="order.status === 4 && order.reviewed"
type="success"
size="small"
>
已评价
</el-tag>
<el-button <el-button
v-if="[4, 5, 6].includes(order.status)" v-if="[4, 5, 6].includes(order.status)"
size="small" size="small"
@ -162,12 +177,23 @@
</div> </div>
</div> </div>
</div> </div>
<!-- 评价弹窗 -->
<ReviewModal
:visible="reviewVisible"
@update:visible="reviewVisible = $event"
:product="reviewProduct"
:orderId="reviewOrderId"
@success="onReviewSuccess"
/>
</template> </template>
<script> <script>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import ReviewModal from '../../components/ReviewModal.vue'
import { checkUserReview } from '../../api/review'
import { Document, ArrowDown } from '@element-plus/icons-vue' import { Document, ArrowDown } from '@element-plus/icons-vue'
import { userStore } from '../../store/user' import { userStore } from '../../store/user'
import request from '../../utils/request' import request from '../../utils/request'
@ -175,6 +201,7 @@ import request from '../../utils/request'
export default { export default {
name: 'Orders', name: 'Orders',
components: { components: {
ReviewModal,
Document, Document,
ArrowDown ArrowDown
}, },
@ -182,6 +209,9 @@ export default {
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
const loading = ref(false) const loading = ref(false)
const reviewVisible = ref(false)
const reviewProduct = ref({})
const reviewOrderId = ref(0)
const orders = ref([]) const orders = ref([])
const activeStatus = ref('all') const activeStatus = ref('all')
const currentPage = ref(1) const currentPage = ref(1)
@ -262,6 +292,21 @@ export default {
} }
console.log('📋 订单列表处理完成:', orders.value.length, '条订单') console.log('📋 订单列表处理完成:', orders.value.length, '条订单')
//
for (const order of orders.value) {
if (order.status === 4) {
try {
const items = order.orderItems || order.orderDetails || []
if (items.length > 0) {
const res = await checkUserReview(items[0].productId, order.id)
order.reviewed = res === true || res?.data === true
}
} catch (e) {
order.reviewed = false
}
}
}
} catch (error) { } catch (error) {
console.error('❌ 加载订单失败:', error) console.error('❌ 加载订单失败:', error)
ElMessage.error('加载订单失败') ElMessage.error('加载订单失败')
@ -347,6 +392,28 @@ export default {
}).catch(() => {}) }).catch(() => {})
} }
const handleReviewOrder = (order) => {
const items = order.orderItems || order.orderDetails || []
if (items.length > 0) {
const item = items[0]
reviewProduct.value = {
id: item.productId,
name: item.productName,
price: item.price,
image: item.productImage,
merchantId: order.merchantId
}
reviewOrderId.value = order.id
reviewVisible.value = true
}
}
const onReviewSuccess = () => {
reviewVisible.value = false
ElMessage.success('评价成功!')
loadOrders()
}
const handleDeleteOrder = (order) => { const handleDeleteOrder = (order) => {
ElMessageBox.confirm('确认删除该订单?', '确认删除', { ElMessageBox.confirm('确认删除该订单?', '确认删除', {
confirmButtonText: '确认', confirmButtonText: '确认',
@ -453,6 +520,11 @@ export default {
return { return {
userStore, userStore,
loading, loading,
reviewVisible,
reviewProduct,
reviewOrderId,
handleReviewOrder,
onReviewSuccess,
orders, orders,
activeStatus, activeStatus,
currentPage, currentPage,