This commit is contained in:
van
2026-04-10 00:09:26 +08:00
parent 31e7e6853b
commit 6f482256c5
8 changed files with 251 additions and 52 deletions

View File

@@ -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=&timestamp=&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));

View File

@@ -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 = "日日顺";
}

View File

@@ -17,7 +17,7 @@ public interface ErpGoofishOrderMapper {
int update(ErpGoofishOrder row);
List<ErpGoofishOrder> selectPendingShip(@Param("limit") int limit);
List<ErpGoofishOrder> selectPendingShip(@Param("statuses") List<Integer> statuses, @Param("limit") int limit);
int resetShipForRetry(@Param("id") Long id);
}

View File

@@ -34,4 +34,9 @@ public interface IErpGoofishOrderService {
int syncWaybillAndTryShipBatch(int limit);
void applyListOrNotifyItem(String appKey, JSONObject item, String lastNotifyJson);
/**
* 京东单物流扫描已得到运单号并写入 Redis 后调用:同步到闲鱼单并尝试开放平台发货。
*/
void notifyJdWaybillReady(Long jdOrderId);
}

View File

@@ -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<Integer> 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<Integer> resolveAutoShipOrderStatuses() {
String raw = goofishProperties.getAutoShipOrderStatuses();
List<Integer> 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 ∈ [nowlookbackHours, now]
*/
@@ -837,7 +937,8 @@ public class GoofishOrderPipeline {
}
public int syncWaybillAndTryShipBatch(int limit) {
List<ErpGoofishOrder> rows = erpGoofishOrderMapper.selectPendingShip(limit);
List<Integer> statuses = resolveAutoShipOrderStatuses();
List<ErpGoofishOrder> rows = erpGoofishOrderMapper.selectPendingShip(statuses, limit);
if (rows == null) {
return 0;
}

View File

@@ -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<ErpGoofishOrder> 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);
}
}
}

View File

@@ -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: {}, 新链接: {} ==========",

View File

@@ -79,7 +79,14 @@
<select id="selectPendingShip" resultMap="ErpGoofishOrderResult">
<include refid="selectJoinVo"/>
where e.order_status = 12 and (e.ship_status is null or e.ship_status != 1) and e.jd_order_id is not null
where e.jd_order_id is not null
and (e.ship_status is null or e.ship_status != 1)
<if test="statuses != null and statuses.size() &gt; 0">
and e.order_status in
<foreach collection="statuses" item="st" open="(" separator="," close=")">
#{st}
</foreach>
</if>
order by e.update_time asc
limit #{limit}
</select>