diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/common/OpenCallbackController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/common/OpenCallbackController.java index 57b792f..d0a0576 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/common/OpenCallbackController.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/common/OpenCallbackController.java @@ -1,5 +1,6 @@ package com.ruoyi.web.controller.common; +import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONObject; import com.ruoyi.common.annotation.Anonymous; import com.ruoyi.common.utils.StringUtils; @@ -15,7 +16,8 @@ import java.security.NoSuchAlgorithmException; /** * 闲管家开放平台推送回调(请在开放平台填写真实 URL) - * 订单:建议 POST .../open/callback/order/receive + * 订单:POST .../open/callback/order/receive?appid=×tamp=&sign= + * 响应须与平台「强校验」一致:一般 {@code {"code":0,"msg":"OK","data":{}}} ,勿使用 {@code result} 等非标准字段。 */ @Anonymous @RestController @@ -32,61 +34,95 @@ public class OpenCallbackController { public JSONObject receiveProductCallback( @RequestParam("appid") String appid, @RequestParam(value = "timestamp", required = false) Long timestamp, + @RequestParam(value = "seller_id", required = false) Long sellerId, @RequestParam("sign") String sign, - @RequestBody JSONObject body + @RequestBody(required = false) String rawBody ) { + String normalizedBody = normalizeJsonBody(rawBody); IERPAccount account = erpAccountResolver.resolveStrict(appid); - if (!verifySign(account, timestamp, sign, body)) { - JSONObject fail = new JSONObject(); - fail.put("result", "fail"); - fail.put("msg", "签名失败"); - return fail; + if (!verifyGoofishSign(account, timestamp, sellerId, sign, normalizedBody)) { + return failCallback(1, "签名失败"); } - JSONObject ok = new JSONObject(); - ok.put("result", "success"); - ok.put("msg", "接收成功"); - return ok; + return successCallback(); } @PostMapping("/order/receive") public JSONObject receiveOrderCallback( @RequestParam("appid") String appid, @RequestParam(value = "timestamp", required = false) Long timestamp, + @RequestParam(value = "seller_id", required = false) Long sellerId, @RequestParam("sign") String sign, - @RequestBody JSONObject body + @RequestBody(required = false) String rawBody ) { + String normalizedBody = normalizeJsonBody(rawBody); IERPAccount account = erpAccountResolver.resolveStrict(appid); - if (!verifySign(account, timestamp, sign, body)) { - JSONObject fail = new JSONObject(); - fail.put("result", "fail"); - fail.put("msg", "签名失败"); - return fail; + if (account == null) { + return failCallback(1, "未找到启用的 AppKey 配置"); + } + if (!verifyGoofishSign(account, timestamp, sellerId, sign, normalizedBody)) { + return failCallback(1, "签名失败"); + } + JSONObject body; + try { + body = "{}".equals(normalizedBody) ? new JSONObject() : JSON.parseObject(normalizedBody); + } catch (Exception e) { + return failCallback(2, "请求体不是合法JSON"); } try { erpGoofishOrderService.publishOrProcessNotify(appid, timestamp, body); } catch (Exception e) { - JSONObject fail = new JSONObject(); - fail.put("result", "fail"); - fail.put("msg", "入队异常"); - return fail; + return failCallback(3, "入队异常"); } - JSONObject ok = new JSONObject(); - ok.put("result", "success"); - ok.put("msg", "接收成功"); - return ok; + return successCallback(); } - private boolean verifySign(IERPAccount account, Long timestamp, String sign, JSONObject body) { + /** + * 与开放平台示例一致:签名字符串 = md5(appKey + "," + md5(body原文) + "," + timestamp + [,sellerId] + "," + appSecret) + * body 须与推送原文完全一致后做 MD5,不能用解析后再 toJSONString(字段顺序变化会导致验签失败)。 + */ + private boolean verifyGoofishSign(IERPAccount account, Long timestamp, Long sellerId, String sign, String bodyExactForMd5) { if (account == null || StringUtils.isEmpty(sign)) { return false; } - String json = body == null ? "{}" : body.toJSONString(); - String data = account.getApiKey() + "," + md5(json) + "," + (timestamp == null ? 0 : timestamp) + "," + account.getApiKeySecret(); - String local = md5(data); - return StringUtils.equalsIgnoreCase(local, sign); + String jsonForMd5 = bodyExactForMd5 == null || bodyExactForMd5.isEmpty() ? "{}" : bodyExactForMd5; + String bodyMd5 = md5Hex(jsonForMd5); + long ts = timestamp == null ? 0L : timestamp; + String data; + if (sellerId != null) { + data = account.getApiKey() + "," + bodyMd5 + "," + ts + "," + sellerId + "," + account.getApiKeySecret(); + } else { + data = account.getApiKey() + "," + bodyMd5 + "," + ts + "," + account.getApiKeySecret(); + } + String local = md5Hex(data); + return StringUtils.equalsIgnoreCase(local, sign.trim()); } - private String md5(String str) { + private static String normalizeJsonBody(String rawBody) { + if (rawBody == null) { + return "{}"; + } + String t = rawBody.trim(); + return t.isEmpty() ? "{}" : t; + } + + /** 与平台开放接口成功响应字段类型对齐:code 为数值、msg 为字符串、data 为对象 */ + private static JSONObject successCallback() { + JSONObject ok = new JSONObject(); + ok.put("code", 0); + ok.put("msg", "OK"); + ok.put("data", new JSONObject()); + return ok; + } + + private static JSONObject failCallback(int code, String msg) { + JSONObject j = new JSONObject(); + j.put("code", code); + j.put("msg", msg == null ? "fail" : msg); + j.put("data", new JSONObject()); + return j; + } + + private String md5Hex(String str) { try { MessageDigest md = MessageDigest.getInstance("MD5"); byte[] digest = md.digest(str.getBytes(StandardCharsets.UTF_8)); diff --git a/ruoyi-system/src/main/java/com/ruoyi/jarvis/config/JarvisGoofishProperties.java b/ruoyi-system/src/main/java/com/ruoyi/jarvis/config/JarvisGoofishProperties.java index 4e80734..2d49702 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/jarvis/config/JarvisGoofishProperties.java +++ b/ruoyi-system/src/main/java/com/ruoyi/jarvis/config/JarvisGoofishProperties.java @@ -52,4 +52,17 @@ public class JarvisGoofishProperties { private int pullFullHistoryDays = 1095; private int autoShipBatchSize = 20; + + /** + * 允许触发闲鱼开放平台「发货」的本地 order_status(逗号分隔,与推送/列表一致)。默认 12 通常表示待发货。 + */ + private String autoShipOrderStatuses = "12"; + + /** + * 未在 erp_open_config 配置 express_code 时,自动发货使用的默认快递公司编码(官方列表中日日顺多为 rrs)。 + */ + private String defaultShipExpressCode = "rrs"; + + /** 与 defaultShipExpressCode 配套的展示名称 */ + private String defaultShipExpressName = "日日顺"; } diff --git a/ruoyi-system/src/main/java/com/ruoyi/jarvis/mapper/ErpGoofishOrderMapper.java b/ruoyi-system/src/main/java/com/ruoyi/jarvis/mapper/ErpGoofishOrderMapper.java index 3c6cc19..2943126 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/jarvis/mapper/ErpGoofishOrderMapper.java +++ b/ruoyi-system/src/main/java/com/ruoyi/jarvis/mapper/ErpGoofishOrderMapper.java @@ -17,7 +17,7 @@ public interface ErpGoofishOrderMapper { int update(ErpGoofishOrder row); - List selectPendingShip(@Param("limit") int limit); + List selectPendingShip(@Param("statuses") List statuses, @Param("limit") int limit); int resetShipForRetry(@Param("id") Long id); } diff --git a/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/IErpGoofishOrderService.java b/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/IErpGoofishOrderService.java index 940b29e..aa2b004 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/IErpGoofishOrderService.java +++ b/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/IErpGoofishOrderService.java @@ -34,4 +34,9 @@ public interface IErpGoofishOrderService { int syncWaybillAndTryShipBatch(int limit); void applyListOrNotifyItem(String appKey, JSONObject item, String lastNotifyJson); + + /** + * 京东单物流扫描已得到运单号并写入 Redis 后调用:同步到闲鱼单并尝试开放平台发货。 + */ + void notifyJdWaybillReady(Long jdOrderId); } diff --git a/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/goofish/GoofishOrderPipeline.java b/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/goofish/GoofishOrderPipeline.java index eb8f9c4..905b075 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/goofish/GoofishOrderPipeline.java +++ b/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/goofish/GoofishOrderPipeline.java @@ -51,9 +51,10 @@ public class GoofishOrderPipeline { public void runFullPipeline(String appid, JSONObject notifyBody) { try { + JSONObject shape = sourceForNotifyUpsert(notifyBody); ErpGoofishOrder row = upsertFromNotify(appid, notifyBody, notifyBody.toJSONString()); tryLinkJdOrder(row); - mergeSummaryFromOrderDetailShape(row, notifyBody); + mergeSummaryFromOrderDetailShape(row, shape); refreshDetail(row); syncWaybillFromRedis(row); tryAutoShip(row); @@ -74,24 +75,29 @@ public class GoofishOrderPipeline { tryAutoShip(row); } - public ErpGoofishOrder upsertFromNotify(String appKey, JSONObject body, String lastNotifyJson) { + public ErpGoofishOrder upsertFromNotify(String appKey, JSONObject rawBody, String lastNotifyJson) { + JSONObject body = sourceForNotifyUpsert(rawBody); Date now = DateUtils.getNowDate(); - String orderNo = body.getString("order_no"); + String orderNo = notifyFirstString(body, "order_no", "orderNo"); if (StringUtils.isEmpty(orderNo)) { throw new IllegalArgumentException("缺少 order_no"); } ErpGoofishOrder existing = erpGoofishOrderMapper.selectByAppKeyAndOrderNo(appKey, orderNo); ErpGoofishOrder e = new ErpGoofishOrder(); e.setAppKey(appKey); - e.setSellerId(body.getLong("seller_id")); - e.setUserName(body.getString("user_name")); + Long sid = notifyFirstLong(body, "seller_id", "sellerId"); + e.setSellerId(sid); + e.setUserName(notifyFirstString(body, "user_name", "userName", "seller_name")); e.setOrderNo(orderNo); - e.setOrderType(body.getInteger("order_type")); - e.setOrderStatus(body.getInteger("order_status")); - e.setRefundStatus(body.getInteger("refund_status")); - e.setModifyTime(body.getLong("modify_time")); - e.setProductId(body.getLong("product_id")); - e.setItemId(body.getLong("item_id")); + e.setOrderType(notifyFirstInteger(body, "order_type", "orderType")); + e.setOrderStatus(notifyFirstInteger(body, "order_status", "orderStatus")); + e.setRefundStatus(notifyFirstInteger(body, "refund_status", "refundStatus")); + Long mt = notifyFirstLong(body, "modify_time", "order_modify_time", "update_time", "modifyTime"); + e.setModifyTime(mt); + Long pid = notifyFirstLong(body, "product_id", "productId"); + e.setProductId(pid); + Long iid = notifyFirstLong(body, "item_id", "itemId"); + e.setItemId(iid); e.setLastNotifyJson(lastNotifyJson); e.setUpdateTime(now); if (existing == null) { @@ -254,11 +260,11 @@ public class GoofishOrderPipeline { patch.setReceiverAddress(recv.getString("address")); } } - Integer os = data.getInteger("order_status"); + Integer os = firstInt(data, "order_status", "orderStatus"); if (os != null) { patch.setOrderStatus(os); } - Integer rs = data.getInteger("refund_status"); + Integer rs = firstInt(data, "refund_status", "refundStatus"); if (rs != null) { patch.setRefundStatus(rs); } @@ -394,7 +400,8 @@ public class GoofishOrderPipeline { if (row == null || row.getId() == null) { return; } - if (row.getOrderStatus() == null || row.getOrderStatus() != 12) { + List awaiting = resolveAutoShipOrderStatuses(); + if (row.getOrderStatus() == null || !awaiting.contains(row.getOrderStatus())) { return; } if (row.getRefundStatus() != null && row.getRefundStatus() != 0) { @@ -411,10 +418,18 @@ public class GoofishOrderPipeline { return; } ErpOpenConfig cfg = erpOpenConfigService.selectByAppKey(row.getAppKey()); - String expressCode = cfg != null ? cfg.getExpressCode() : null; - String expressName = cfg != null && StringUtils.isNotEmpty(cfg.getExpressName()) ? cfg.getExpressName() : "日日顺"; + String expressCode = cfg != null && StringUtils.isNotEmpty(cfg.getExpressCode()) ? cfg.getExpressCode() : null; if (StringUtils.isEmpty(expressCode)) { - log.info("闲管家自动发货跳过:未配置 express_code appKey={} orderNo={}", row.getAppKey(), row.getOrderNo()); + expressCode = goofishProperties.getDefaultShipExpressCode(); + } + // 业务约定:对闲鱼回传物流名称固定为「日日顺」(编码以配置 / 默认 rrs 为准) + String expressName = goofishProperties.getDefaultShipExpressName(); + if (StringUtils.isEmpty(expressName)) { + expressName = "日日顺"; + } + if (StringUtils.isEmpty(expressCode)) { + log.info("闲管家自动发货跳过:无快递公司编码(请配置 erp_open_config.express_code 或 jarvis.goofish-order.default-ship-express-code) appKey={} orderNo={}", + row.getAppKey(), row.getOrderNo()); return; } ShipAddressParts addr = parseShipAddress(row.getDetailJson()); @@ -656,6 +671,91 @@ public class GoofishOrderPipeline { return null; } + private List resolveAutoShipOrderStatuses() { + String raw = goofishProperties.getAutoShipOrderStatuses(); + List list = new ArrayList<>(); + if (StringUtils.isNotEmpty(raw)) { + for (String part : raw.split(",")) { + String t = part.trim(); + if (t.isEmpty()) { + continue; + } + try { + list.add(Integer.parseInt(t)); + } catch (NumberFormatException ignored) { + // ignore invalid token + } + } + } + if (list.isEmpty()) { + list.add(12); + } + return list; + } + + /** + * 推送回调体可能是扁平订单字段,也可能包在 data / data.list[0] / order 中(与列表项 schema 对齐的优先解包)。 + */ + private static JSONObject sourceForNotifyUpsert(JSONObject rawBody) { + if (rawBody == null) { + return new JSONObject(); + } + if (StringUtils.isNotEmpty(rawBody.getString("order_no")) || StringUtils.isNotEmpty(rawBody.getString("orderNo"))) { + return rawBody; + } + JSONObject data = rawBody.getJSONObject("data"); + if (data != null) { + if (StringUtils.isNotEmpty(data.getString("order_no")) || StringUtils.isNotEmpty(data.getString("orderNo"))) { + return data; + } + JSONObject order = data.getJSONObject("order"); + if (order != null && (StringUtils.isNotEmpty(order.getString("order_no")) + || StringUtils.isNotEmpty(order.getString("orderNo")))) { + return order; + } + JSONArray list = data.getJSONArray("list"); + if (list != null && !list.isEmpty()) { + Object first = list.get(0); + if (first instanceof JSONObject) { + JSONObject row = (JSONObject) first; + if (StringUtils.isNotEmpty(row.getString("order_no")) || StringUtils.isNotEmpty(row.getString("orderNo"))) { + return row; + } + } + } + } + JSONObject order = rawBody.getJSONObject("order"); + if (order != null && (StringUtils.isNotEmpty(order.getString("order_no")) + || StringUtils.isNotEmpty(order.getString("orderNo")))) { + return order; + } + return rawBody; + } + + private static String notifyFirstString(JSONObject o, String... keys) { + return firstNonEmpty(o, keys); + } + + private static Integer notifyFirstInteger(JSONObject o, String... keys) { + return firstInt(o, keys); + } + + private static Long notifyFirstLong(JSONObject o, String... keys) { + if (o == null) { + return null; + } + for (String k : keys) { + if (!o.containsKey(k)) { + continue; + } + Long v = o.getLong(k); + if (v != null) { + return v; + } + } + return null; + } + /** * 增量拉单:按 update_time ∈ [now−lookbackHours, now] */ @@ -837,7 +937,8 @@ public class GoofishOrderPipeline { } public int syncWaybillAndTryShipBatch(int limit) { - List rows = erpGoofishOrderMapper.selectPendingShip(limit); + List statuses = resolveAutoShipOrderStatuses(); + List rows = erpGoofishOrderMapper.selectPendingShip(statuses, limit); if (rows == null) { return 0; } diff --git a/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/impl/ErpGoofishOrderServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/impl/ErpGoofishOrderServiceImpl.java index 9903605..6a48560 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/impl/ErpGoofishOrderServiceImpl.java +++ b/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/impl/ErpGoofishOrderServiceImpl.java @@ -138,4 +138,25 @@ public class ErpGoofishOrderServiceImpl implements IErpGoofishOrderService { public void applyListOrNotifyItem(String appKey, JSONObject item, String lastNotifyJson) { goofishOrderPipeline.applyListOrNotifyItem(appKey, item, lastNotifyJson); } + + @Override + public void notifyJdWaybillReady(Long jdOrderId) { + if (jdOrderId == null) { + return; + } + ErpGoofishOrder query = new ErpGoofishOrder(); + query.setJdOrderId(jdOrderId); + List list = erpGoofishOrderMapper.selectList(query); + if (list == null || list.isEmpty()) { + return; + } + for (ErpGoofishOrder ref : list) { + ErpGoofishOrder full = erpGoofishOrderMapper.selectById(ref.getId()); + if (full == null) { + continue; + } + goofishOrderPipeline.syncWaybillFromRedis(full); + goofishOrderPipeline.tryAutoShip(full); + } + } } diff --git a/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/impl/LogisticsServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/impl/LogisticsServiceImpl.java index 351a649..11fd564 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/impl/LogisticsServiceImpl.java +++ b/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/impl/LogisticsServiceImpl.java @@ -6,6 +6,7 @@ import com.ruoyi.common.utils.http.HttpUtils; import com.ruoyi.jarvis.domain.JDOrder; import com.ruoyi.jarvis.domain.WeComShareLinkLogisticsJob; import com.ruoyi.jarvis.mapper.WeComShareLinkLogisticsJobMapper; +import com.ruoyi.jarvis.service.IErpGoofishOrderService; import com.ruoyi.jarvis.service.ILogisticsService; import com.ruoyi.jarvis.service.IJDOrderService; import com.ruoyi.system.service.ISysConfigService; @@ -81,6 +82,9 @@ public class LogisticsServiceImpl implements ILogisticsService { @Resource private IJDOrderService jdOrderService; + + @Resource + private IErpGoofishOrderService erpGoofishOrderService; @PostConstruct public void init() { @@ -215,6 +219,15 @@ public class LogisticsServiceImpl implements ILogisticsService { } } + /** 京东单已写入 Redis 运单后,联动闲鱼单同步并发货(失败不影响物流主流程) */ + private void safeNotifyGoofishShip(Long jdOrderId) { + try { + erpGoofishOrderService.notifyJdWaybillReady(jdOrderId); + } catch (Exception e) { + logger.warn("闲鱼发货联动异常 jdOrderId={} err={}", jdOrderId, e.toString()); + } + } + @Override public boolean fetchLogisticsAndPush(JDOrder order) { if (order == null || order.getId() == null) { @@ -375,6 +388,7 @@ public class LogisticsServiceImpl implements ILogisticsService { logger.info("订单运单号已存在且一致,说明之前已推送过,跳过重复推送 - 订单ID: {}, waybill_no: {}", orderId, waybillNo); // 更新过期时间,确保记录不会过期 stringRedisTemplate.opsForValue().set(redisKey, waybillNo, 30, TimeUnit.DAYS); + safeNotifyGoofishShip(orderId); return true; } @@ -392,6 +406,7 @@ public class LogisticsServiceImpl implements ILogisticsService { logger.info("订单创建时间较早({}),且Redis中无记录但已获取到运单号,视为之前已推送过,直接标记为已处理,跳过推送 - 订单ID: {}, waybill_no: {}", order.getCreateTime(), orderId, waybillNo); stringRedisTemplate.opsForValue().set(redisKey, waybillNo, 30, TimeUnit.DAYS); + safeNotifyGoofishShip(orderId); return true; } } @@ -418,7 +433,8 @@ public class LogisticsServiceImpl implements ILogisticsService { // 更新过期时间,确保记录不会过期 stringRedisTemplate.opsForValue().set(redisKey, waybillNo, 30, TimeUnit.DAYS); } - + safeNotifyGoofishShip(orderId); + // 记录最终处理结果 if (logisticsLinkUpdated) { logger.info("========== 物流信息获取并推送成功(已更新物流链接) - 订单ID: {}, 订单号: {}, waybill_no: {}, 新链接: {} ==========", diff --git a/ruoyi-system/src/main/resources/mapper/jarvis/ErpGoofishOrderMapper.xml b/ruoyi-system/src/main/resources/mapper/jarvis/ErpGoofishOrderMapper.xml index 5fc3e3f..47a60f5 100644 --- a/ruoyi-system/src/main/resources/mapper/jarvis/ErpGoofishOrderMapper.xml +++ b/ruoyi-system/src/main/resources/mapper/jarvis/ErpGoofishOrderMapper.xml @@ -79,7 +79,14 @@