Compare commits

...

17 Commits

Author SHA1 Message Date
van
6fb46cc203 1 2026-05-07 18:35:40 +08:00
van
7582868b2c 1 2026-05-06 17:38:58 +08:00
van
9d03cca517 1 2026-05-06 01:51:30 +08:00
van
8b5abb44ee 1 2026-05-05 14:58:52 +08:00
van
e75f71d37b 1 2026-04-30 17:53:31 +08:00
van
5da74a155c 1 2026-04-30 17:35:50 +08:00
van
a88600788a 1 2026-04-30 17:10:34 +08:00
van
cf8008bdc1 1 2026-04-26 14:08:26 +08:00
van
d97a977a0e 1 2026-04-23 23:53:11 +08:00
van
1f25cc5d15 1 2026-04-23 23:26:55 +08:00
van
66aa339906 1 2026-04-23 22:20:34 +08:00
van
acd693f122 1 2026-04-23 21:55:47 +08:00
van
751844493b 1 2026-04-23 21:48:23 +08:00
van
256b54ffab 1 2026-04-23 16:06:33 +08:00
van
0ff357148b 1 2026-04-23 16:04:45 +08:00
van
7cd7440f1f 1 2026-04-23 16:00:44 +08:00
van
f5f14c730f 1 2026-04-22 11:23:34 +08:00
32 changed files with 1318 additions and 264 deletions

View File

@@ -17,7 +17,9 @@ import java.security.NoSuchAlgorithmException;
/**
* 闲管家开放平台推送回调(请在开放平台填写真实 URL
* 订单POST .../open/callback/order/receive?appid=&timestamp=&sign=
* 响应须与平台「强校验」一致:一般 {@code {"code":0,"msg":"OK","data":{}}} ,勿使用 {@code result} 等非标准字段。
* <p>
* 成功/失败体须与《订单推送通知》OpenAPI 一致:{@code result=success|fail} + {@code msg}
* 仅当 {@code result} 为 success 时平台认为接收成功(失败最多重试 3 次;建议业务异步处理、快速返回)。
*/
@Anonymous
@RestController
@@ -41,7 +43,7 @@ public class OpenCallbackController {
String normalizedBody = normalizeJsonBody(rawBody);
IERPAccount account = erpAccountResolver.resolveStrict(appid);
if (!verifyGoofishSign(account, timestamp, sellerId, sign, normalizedBody)) {
return failCallback(1, "签名失败");
return failCallback("签名失败");
}
return successCallback();
}
@@ -57,21 +59,21 @@ public class OpenCallbackController {
String normalizedBody = normalizeJsonBody(rawBody);
IERPAccount account = erpAccountResolver.resolveStrict(appid);
if (account == null) {
return failCallback(1, "未找到启用的 AppKey 配置");
return failCallback("未找到启用的 AppKey 配置");
}
if (!verifyGoofishSign(account, timestamp, sellerId, sign, normalizedBody)) {
return failCallback(1, "签名失败");
return failCallback("签名失败");
}
JSONObject body;
try {
body = "{}".equals(normalizedBody) ? new JSONObject() : JSON.parseObject(normalizedBody);
} catch (Exception e) {
return failCallback(2, "请求体不是合法JSON");
return failCallback("请求体不是合法JSON");
}
try {
erpGoofishOrderService.publishOrProcessNotify(appid, timestamp, body);
} catch (Exception e) {
return failCallback(3, "入队异常");
return failCallback("入队异常");
}
return successCallback();
}
@@ -105,20 +107,19 @@ public class OpenCallbackController {
return t.isEmpty() ? "{}" : t;
}
/** 与平台开放接口成功响应字段类型对齐code 为数值、msg 为字符串、data 为对象 */
/** 与《订单推送通知》notify_resp_ok 一致result=success 时平台才停止重试 */
private static JSONObject successCallback() {
JSONObject ok = new JSONObject();
ok.put("code", 0);
ok.put("msg", "OK");
ok.put("data", new JSONObject());
ok.put("result", "success");
ok.put("msg", "接收成功");
return ok;
}
private static JSONObject failCallback(int code, String msg) {
/** 与 notify_resp_fail 一致result=fail */
private static JSONObject failCallback(String msg) {
JSONObject j = new JSONObject();
j.put("code", code);
j.put("msg", msg == null ? "fail" : msg);
j.put("data", new JSONObject());
j.put("result", "fail");
j.put("msg", msg == null || msg.isEmpty() ? "处理失败" : msg);
return j;
}

View File

@@ -28,8 +28,8 @@ public class InstructionController extends BaseController {
public AjaxResult execute(@RequestBody Map<String, Object> body) {
String cmd = body != null ? (body.get("command") != null ? String.valueOf(body.get("command")) : null) : null;
boolean forceGenerate = body != null && body.get("forceGenerate") != null && Boolean.parseBoolean(String.valueOf(body.get("forceGenerate")));
// 控制台入口,传递 isFromConsole=true跳过订单查询校验
java.util.List<String> result = instructionService.execute(cmd, forceGenerate, true);
// 控制台入口:全量统计视角(排除后台标记不参与统计的联盟),非单个企微成员
java.util.List<String> result = instructionService.execute(cmd, forceGenerate, true, null);
return AjaxResult.success(result);
}

View File

@@ -10,6 +10,7 @@ import com.ruoyi.common.utils.http.HttpUtils;
import com.ruoyi.jarvis.domain.JDOrder;
import com.ruoyi.jarvis.domain.OrderRows;
import com.ruoyi.jarvis.service.IJDOrderService;
import com.ruoyi.jarvis.service.ILogisticsService;
import com.ruoyi.jarvis.service.IOrderRowsService;
import com.ruoyi.jarvis.service.IGiftCouponService;
import com.ruoyi.jarvis.domain.GiftCoupon;
@@ -41,6 +42,7 @@ public class JDOrderController extends BaseController {
private final IOrderRowsService orderRowsService;
private final IGiftCouponService giftCouponService;
private final ISysConfigService sysConfigService;
private final ILogisticsService logisticsService;
private static final String CONFIG_KEY_PREFIX = "logistics.push.touser.";
private static final java.util.regex.Pattern URL_DETECT_PATTERN = java.util.regex.Pattern.compile(
"(https?://[^\\s]+)|(u\\.jd\\.com/[^\\s]+)",
@@ -53,11 +55,13 @@ public class JDOrderController extends BaseController {
java.util.regex.Pattern.CASE_INSENSITIVE);
public JDOrderController(IJDOrderService jdOrderService, IOrderRowsService orderRowsService,
IGiftCouponService giftCouponService, ISysConfigService sysConfigService) {
IGiftCouponService giftCouponService, ISysConfigService sysConfigService,
ILogisticsService logisticsService) {
this.jdOrderService = jdOrderService;
this.orderRowsService = orderRowsService;
this.giftCouponService = giftCouponService;
this.sysConfigService = sysConfigService;
this.logisticsService = logisticsService;
}
private final static String skey = "2192057370ef8140c201079969c956a3";
@@ -68,12 +72,6 @@ public class JDOrderController extends BaseController {
@Value("${jarvis.server.jarvis-java.jd-api-path:/jd}")
private String jdApiPath;
@Value("${jarvis.server.logistics.base-url:http://127.0.0.1:5001}")
private String logisticsBaseUrl;
@Value("${jarvis.server.logistics.fetch-path:/fetch_logistics}")
private String logisticsFetchPath;
/**
* 获取JD接口请求URL
*/
@@ -947,9 +945,7 @@ public class JDOrderController extends BaseController {
logger.info("手动获取物流信息 - 订单ID: {}, 订单号: {}, 分销标识: {}, 物流链接: {}",
orderId, order.getOrderId(), distributionMark, logisticsLink);
// 构建外部接口URL
String externalUrl = logisticsBaseUrl + logisticsFetchPath + "?tracking_url=" +
java.net.URLEncoder.encode(logisticsLink, "UTF-8");
String externalUrl = logisticsService.buildFetchLogisticsRequestUrl(logisticsLink);
logger.info("准备调用外部接口 - URL: {}", externalUrl);

View File

@@ -178,6 +178,8 @@ public class OrderRowsController extends BaseController
/**
* 构建统计数据。
* <p>汇总口径:{@code totalCommission} 为预估佣金合计,排除取消单(validCode=3){@code totalActualFee} 为实际佣金,仅统计已完成(validCode=17)
* {@code estimatePaidPending} 为已付款待收货(validCode=16)的预估佣金,与分组 {@code paid} 一致。</p>
* @param forList true=与列表同数据源(不排除 isCount=0保证总订单数与分页一致false=独立统计(排除 isCount=0
*/
private Map<String, Object> buildStatistics(OrderRows orderRows, Date beginTime, Date endTime, boolean forList) {
@@ -209,7 +211,7 @@ public class OrderRowsController extends BaseController
groupStats.put("cancel", createGroupStat("取消", "cancel"));
groupStats.put("invalid", createGroupStat("无效", "invalid"));
groupStats.put("pending", createGroupStat("待付款", "pending"));
groupStats.put("paid", createGroupStat("已付款", "paid"));
groupStats.put("paid", createGroupStat("已付款(待结算)", "paid"));
groupStats.put("finished", createGroupStat("已完成", "finished"));
groupStats.put("deposit", createGroupStat("已付定金", "deposit"));
groupStats.put("illegal", createGroupStat("违规", "illegal"));
@@ -263,8 +265,14 @@ public class OrderRowsController extends BaseController
actualFeeAmount = row.getActualFee() != null ? row.getActualFee() : 0;
}
totalCommission += commissionAmount;
totalActualFee += actualFeeAmount;
// 顶部「预估佣金」汇总排除取消单validCode=3其余状态累加单条 commissionAmount
if (!"3".equals(validCode)) {
totalCommission += commissionAmount;
}
// 顶部「实际佣金」汇总仅已完成validCode=17与联盟「已结算」口径一致
if ("17".equals(validCode)) {
totalActualFee += actualFeeAmount;
}
if (validCode != null) {
for (Map.Entry<String, List<String>> group : groups.entrySet()) {
@@ -290,6 +298,8 @@ public class OrderRowsController extends BaseController
result.put("totalCosPrice", totalCosPrice);
result.put("totalCommission", totalCommission);
result.put("totalActualFee", totalActualFee);
// 已付款待结算:与分组 paid 的预估佣金口径一致,便于独立展示卡片
result.put("estimatePaidPending", (Double) groupStats.get("paid").get("commission"));
result.put("totalSkuNum", totalSkuNum);
result.put("violationOrders", violationOrders);
result.put("violationCommission", violationCommission);

View File

@@ -26,6 +26,7 @@ import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.jarvis.domain.JDOrder;
import com.ruoyi.jarvis.domain.dto.JDOrderSimpleDTO;
import com.ruoyi.jarvis.domain.dto.QuickRecordModelOption;
import com.ruoyi.jarvis.service.IJDOrderProfitService;
import com.ruoyi.jarvis.service.IJDOrderService;
import com.ruoyi.jarvis.service.IInstructionService;
@@ -157,6 +158,15 @@ public class JDOrderListController extends BaseController
return dataTable;
}
/**
* 快捷录单页:型号下拉数据;每型号取 jd_order 主键最大的一条的付款与后返(通常即最近落库单)
*/
@GetMapping("/quickRecord/modelOptions")
public AjaxResult quickRecordModelOptions() {
List<QuickRecordModelOption> options = jdOrderService.selectQuickRecordModelOptions();
return AjaxResult.success(options);
}
/**
* 导入跟团返现类 Excel按「单号/订单号」匹配系统订单,将「是否返现」「总共返现」等写入后返备注(可多次导入累加);文件落盘并记上传记录。
*/
@@ -512,6 +522,9 @@ public class JDOrderListController extends BaseController
if (query.getModelNumber() != null && !query.getModelNumber().trim().isEmpty()) {
query.setModelNumber(query.getModelNumber().trim());
}
if (query.getModelNumberExclude() != null && !query.getModelNumberExclude().trim().isEmpty()) {
query.setModelNumberExclude(query.getModelNumberExclude().trim());
}
if (query.getBuyer() != null && !query.getBuyer().trim().isEmpty()) {
query.setBuyer(query.getBuyer().trim());
}

View File

@@ -200,6 +200,8 @@ jarvis:
# 物流接口服务地址
logistics:
base-url: http://192.168.8.88:5001
# 同机多进程多端口时配置逗号分隔列表;非空时仅按下列地址轮询,不再使用 base-url
base-urls: http://192.168.8.88:5001,http://192.168.8.88:5002,http://192.168.8.88:5003
fetch-path: /fetch_logistics
health-path: /health
# 每次定时任务最多处理多少条企微分享链待队列RPUSH 入队、LPOP 出队)
@@ -228,7 +230,7 @@ jarvis:
session-ttl-minutes: 30
session-idle-timeout-minutes: 30
session-sweep-ms: 60000
# 企微「开」+ 手机号:Jarvis POST 该局域网接口,将响应中的 reply_text 被动回复用户
# 企微「开」/「慢开」+ 手机号:POST body 含 text手机号与 bot响应 reply_text 被动回复用户
phone-forward:
enabled: true
base-url: http://192.168.8.60:18080
@@ -237,7 +239,12 @@ jarvis:
# wait_reply 时服务端会等多条 Bot 回复,宜适当加大
read-timeout-ms: 120000
wait-reply: true
reply-take-nth: 2
# 多台企微线程同时触发时串行调用 tg_bridge排队超过该毫秒则提示「正忙」0 表示一直等到上一条结束)
lock-acquire-timeout-ms: 180000
# 连续失败后熔断,不再发起 HTTP与 tg_bridge 侧熔断互不替代)
circuit-failure-threshold: 5
circuit-open-ms: 120000
# reply_take_nth仅「开」用 2「慢开」由 tg_bridge reply_adaptive_skip_middle_ad 在 2/3 条间自适应
# Ollama 大模型服务(监控健康度调试用)
ollama:
base-url: http://192.168.8.34:11434
@@ -276,7 +283,7 @@ jarvis:
mq-topic: jarvis-goofish-erp-order
consumer-group: jarvis-goofish-order-consumer
pull-lookback-hours: 72
pull-cron: "0 0/15 * * * ?"
pull-cron: "0 * * * * ?"
auto-ship-cron: "0 2/10 * * * ?"
# 订单列表:每页条数(最大 100
pull-page-size: 100
@@ -288,8 +295,8 @@ jarvis:
pull-max-update-time-range-seconds: 15552000
# 全量拉单起点:距今多少天(默认约 3 年)
pull-full-history-days: 1095
# 列表拉单是否仅拉 auto-ship-order-statuses默认待发货false 时恢复按时间窗全状态(全量回补可临时关闭
pull-list-only-auto-ship-statuses: true
# true=仅拉 auto-ship-order-statuses省调用,其它状态依赖推送false=时间窗全状态(推荐,与本地对齐
pull-list-only-auto-ship-statuses: false
auto-ship-batch-size: 20

View File

@@ -200,6 +200,8 @@ jarvis:
# 物流接口服务地址
logistics:
base-url: http://127.0.0.1:5001
# 同机多进程多端口时配置逗号分隔列表;非空时仅按下列地址轮询,不再使用 base-url
base-urls: http://127.0.0.1:5001,http://127.0.0.1:5002,http://127.0.0.1:5003
fetch-path: /fetch_logistics
health-path: /health
adhoc-pending-batch-size: 50
@@ -226,7 +228,10 @@ jarvis:
connect-timeout-ms: 8000
read-timeout-ms: 120000
wait-reply: true
reply-take-nth: 2
lock-acquire-timeout-ms: 180000
circuit-failure-threshold: 5
circuit-open-ms: 120000
# 「开」取第 2 条;「慢开」由桥接自适应第 2/3 条
# Ollama 大模型服务(监控健康度调试用)
ollama:
base-url: http://192.168.8.34:11434

View File

@@ -21,7 +21,7 @@ public class JarvisGoofishProperties {
private int pullLookbackHours = 72;
/** 拉单定时 cron */
private String pullCron = "0 0/15 * * * ?";
private String pullCron = "0 * * * * ?";
/** 同步运单 + 自动发货 cron */
private String autoShipCron = "0 2/10 * * * ?";
@@ -59,11 +59,10 @@ public class JarvisGoofishProperties {
private String autoShipOrderStatuses = "12";
/**
* 定时/增量「订单列表拉单是否仅请求 {@link #autoShipOrderStatuses} 中的状态(通常即待发货)。
* 为 true 时可显著减少列表与后续详情拉取次数;其余状态依赖开放平台推送回调刷新
* 全量历史回补若需全状态,可临时设为 false。
* 为 true 时定时/增量列表拉单仅按 {@link #autoShipOrderStatuses} 过滤(减少调用量,但会漏掉其它状态)。
* 默认 false按时间窗拉全状态与本地 upsert 对齐,推送仍可用于更低延迟
*/
private boolean pullListOnlyAutoShipStatuses = true;
private boolean pullListOnlyAutoShipStatuses = false;
/**
* 未在 erp_open_config 配置 express_code 时,自动发货使用的默认快递公司编码(官方列表中日日顺多为 rrs

View File

@@ -64,4 +64,14 @@ public class ErpGoofishOrder {
private String jdRemark;
/** 联查:本地京东单收件地址 jd_order.address闲鱼详情常不返回明文地址 */
private String jdAddress;
// --------- 以下为列表查询扩展条件(不参与 insert/update ---------
/** 运单关键字:命中详情运单号或本地运单号(模糊) */
private String waybillKeyword;
/** 开放平台 modify_time 下限Unix 秒,含边界) */
private Long modifyTimeBegin;
/** 开放平台 modify_time 上限Unix 秒,含边界) */
private Long modifyTimeEnd;
/** 是否已关联京东单1 已关联 jd_order_id 非空0 未关联null 不限 */
private Integer jdLinkFilter;
}

View File

@@ -30,6 +30,10 @@ public class JDOrder extends BaseEntity {
@Excel(name = "型号")
private String modelNumber;
/** 列表筛选:型号不含此子串(对应 SQL NOT LIKE %值%),不入库 */
@Transient
private String modelNumberExclude;
/** 链接 */
@Excel(name = "链接")
private String link;

View File

@@ -0,0 +1,18 @@
package com.ruoyi.jarvis.domain.dto;
import lombok.Data;
/**
* 快捷录单页:型号下拉项及该型号最近一次落库单的付款 / 后返
*/
@Data
public class QuickRecordModelOption {
private String modelNumber;
/** 最近一次订单的下单付款金额 */
private Double lastPaymentAmount;
/** 最近一次订单的后返金额 */
private Double lastRebateAmount;
}

View File

@@ -1,6 +1,7 @@
package com.ruoyi.jarvis.mapper;
import com.ruoyi.jarvis.domain.JDOrder;
import com.ruoyi.jarvis.domain.dto.QuickRecordModelOption;
import java.util.List;
/**
@@ -61,6 +62,11 @@ public interface JDOrderMapper {
* @return 订单列表
*/
List<JDOrder> selectJDOrderListByDistributionMarkFOrPDD();
/**
* 每个型号取其主键最大的一条订单的付款 / 后返(用于快捷录单下拉回填)
*/
List<QuickRecordModelOption> selectQuickRecordModelOptions();
}

View File

@@ -27,4 +27,15 @@ public interface WeComShareLinkLogisticsJobMapper {
List<WeComShareLinkLogisticsJob> selectJobsNeedingQueueReconcile(@Param("limit") int limit);
int deleteByJobKey(@Param("jobKey") String jobKey);
/**
* 机器人「京外物列表」:最近若干天内的任务,按 id 倒序,可选备注子串筛选。
*/
List<WeComShareLinkLogisticsJob> selectRecentForInstruction(@Param("remarkKeyword") String remarkKeyword,
@Param("days") int days, @Param("limit") int limit);
/**
* 机器人「京外物删」按备注与短链精确匹配trim物理删除返回删除行数。
*/
int deleteByRemarkAndTrackingUrl(@Param("remark") String remark, @Param("trackingUrl") String trackingUrl);
}

View File

@@ -27,7 +27,13 @@ public interface IInstructionService {
* @return 执行结果文本列表(可能为单条或多条)
*/
java.util.List<String> execute(String command, boolean forceGenerate, boolean isFromConsole);
/**
* 执行文本指令(支持传入企微成员 UserID用于「京」统计按绑定联盟过滤
* @param wecomUserId 企业微信成员 UserID控制台等非企微入口传 null按全局规则统计
*/
java.util.List<String> execute(String command, boolean forceGenerate, boolean isFromConsole, String wecomUserId);
/**
* 获取历史消息记录
* @param type 消息类型request(请求) 或 response(响应)

View File

@@ -1,6 +1,7 @@
package com.ruoyi.jarvis.service;
import com.ruoyi.jarvis.domain.JDOrder;
import com.ruoyi.jarvis.domain.dto.QuickRecordModelOption;
import java.util.List;
/**
@@ -48,6 +49,9 @@ public interface IJDOrderService {
/** 查询分销标记为F或PDD且有物流链接的订单列表 */
java.util.List<JDOrder> selectJDOrderListByDistributionMarkFOrPDD();
/** 快捷录单:型号及最近一次单的付款 / 后返 */
List<QuickRecordModelOption> selectQuickRecordModelOptions();
}

View File

@@ -68,6 +68,14 @@ public interface ILogisticsService {
* @return 健康状态信息,包含是否健康、状态描述等
*/
HealthCheckResult checkHealth();
/**
* 构造调用物流解析服务的完整 GET URL路径与编码与 {@link #fetchLogisticsAndPush} 一致)。
* 配置多个 {@code jarvis.server.logistics.base-urls} 时按轮询选取 base便于内网多实例并行。
*
* @param logisticsLink 原始物流追踪链接(未编码)
*/
String buildFetchLogisticsRequestUrl(String logisticsLink);
/**
* 健康检测结果

View File

@@ -9,7 +9,7 @@ import com.ruoyi.jarvis.domain.dto.WeComInboundResult;
public interface IWeComInboundService {
/**
* 首条进入被动回复;其余由控制器异步调 wxSend /wecom/active-push。
* 长文本按企微上限拆成多段(每段 ≤2048 UTF-8 字节):首段被动回复,后续段由控制器异步调 wxSend /wecom/active-push。
*/
WeComInboundResult handleInbound(WeComInboundRequest request);
}

View File

@@ -16,4 +16,10 @@ public interface IWeComShareLinkLogisticsJobService {
* jobKey 固定为 tracebf{traceId},可重复执行跳过已存在项。
*/
Map<String, Object> backfillImportedFromInboundTrace();
List<WeComShareLinkLogisticsJob> selectRecentForInstruction(String remarkKeyword, int days, int limit);
int deleteByJobKey(String jobKey);
int deleteByRemarkAndTrackingUrl(String remark, String trackingUrl);
}

View File

@@ -33,6 +33,11 @@ public class GoofishOrderChangeLogger {
*/
public static final String SOURCE_JD_LOGISTICS_PUSH = "JD_LOGISTICS_PUSH";
/**
* Redis 写入本地运单:与后续详情运单合并、发货成功通知重复度高,仅落库便于对账,不推企微。
*/
public static final String SOURCE_REDIS_WAYBILL = "REDIS_WAYBILL";
@Resource
private ErpGoofishOrderEventLogMapper erpGoofishOrderEventLogMapper;
@@ -40,6 +45,14 @@ public class GoofishOrderChangeLogger {
private WxSendGoofishNotifyClient wxSendGoofishNotifyClient;
public void append(Long orderId, String appKey, String orderNo, String eventType, String source, String message) {
append(orderId, appKey, orderNo, eventType, source, message, true);
}
/**
* @param notifyWecom 为 false 时仅写事件日志,不推企微(用于过滤纯对齐类状态)。
*/
public void append(Long orderId, String appKey, String orderNo, String eventType, String source, String message,
boolean notifyWecom) {
if (orderId == null || StringUtils.isEmpty(message)) {
return;
}
@@ -59,7 +72,10 @@ public class GoofishOrderChangeLogger {
return;
}
log.info("[goofish-order-event] orderId={} orderNo={} type={} source={} {}", orderId, orderNo, eventType, source, msg);
if (SOURCE_JD_LOGISTICS_PUSH.equals(source)) {
if (SOURCE_JD_LOGISTICS_PUSH.equals(source) || SOURCE_REDIS_WAYBILL.equals(source)) {
return;
}
if (!notifyWecom) {
return;
}
try {
@@ -82,16 +98,21 @@ public class GoofishOrderChangeLogger {
RowSnap b = RowSnap.from(beforeSnap);
RowSnap a = RowSnap.from(afterRow);
boolean refundDiff = !Objects.equals(b.refundStatus, a.refundStatus);
boolean orderDiff = !Objects.equals(b.orderStatus, a.orderStatus);
List<String> orderParts = new ArrayList<>();
if (!Objects.equals(b.orderStatus, a.orderStatus)) {
orderParts.add("订单状态 " + str(b.orderStatus) + "" + str(a.orderStatus));
if (orderDiff) {
orderParts.add("订单状态 " + GoofishStatusLabels.orderStatusChangeForNotify(b.orderStatus, a.orderStatus));
}
if (!Objects.equals(b.refundStatus, a.refundStatus)) {
orderParts.add("退款状态 " + str(b.refundStatus) + "" + str(a.refundStatus));
if (refundDiff) {
orderParts.add("退款状态 " + GoofishStatusLabels.refundStatusChange(b.refundStatus, a.refundStatus));
}
if (!orderParts.isEmpty()) {
boolean notifyWecom = refundDiff || (orderDiff
&& GoofishStatusLabels.isWxNotifiableOrderStatusChange(b.orderStatus, a.orderStatus));
append(afterRow.getId(), afterRow.getAppKey(), afterRow.getOrderNo(), TYPE_ORDER, source,
String.join("", orderParts));
String.join("", orderParts), notifyWecom);
}
List<String> logParts = new ArrayList<>();

View File

@@ -143,15 +143,27 @@ public class GoofishOrderPipeline {
if (existingBeforeUpdate == null) {
goofishOrderChangeLogger.append(loaded.getId(), loaded.getAppKey(), loaded.getOrderNo(),
GoofishOrderChangeLogger.TYPE_ORDER, upsertSource,
"新订单入库,订单状态 " + loaded.getOrderStatus() + ",退款状态 " + loaded.getRefundStatus());
"新订单入库,订单状态 " + GoofishStatusLabels.orderStatusHumanForNotify(loaded.getOrderStatus())
+ ",退款状态 " + GoofishStatusLabels.refundStatusHuman(loaded.getRefundStatus()));
return;
}
if (!Objects.equals(existingBeforeUpdate.getOrderStatus(), upsertPayload.getOrderStatus())
|| !Objects.equals(existingBeforeUpdate.getRefundStatus(), upsertPayload.getRefundStatus())) {
boolean refundDiff = !Objects.equals(existingBeforeUpdate.getRefundStatus(), upsertPayload.getRefundStatus());
boolean orderDiff = !Objects.equals(existingBeforeUpdate.getOrderStatus(), upsertPayload.getOrderStatus());
if (orderDiff || refundDiff) {
List<String> parts = new ArrayList<>(2);
if (orderDiff) {
parts.add("订单状态 " + GoofishStatusLabels.orderStatusChangeForNotify(
existingBeforeUpdate.getOrderStatus(), upsertPayload.getOrderStatus()));
}
if (refundDiff) {
parts.add("退款状态 " + GoofishStatusLabels.refundStatusChange(
existingBeforeUpdate.getRefundStatus(), upsertPayload.getRefundStatus()));
}
boolean notifyWecom = refundDiff || (orderDiff && GoofishStatusLabels.isWxNotifiableOrderStatusChange(
existingBeforeUpdate.getOrderStatus(), upsertPayload.getOrderStatus()));
goofishOrderChangeLogger.append(loaded.getId(), loaded.getAppKey(), loaded.getOrderNo(),
GoofishOrderChangeLogger.TYPE_ORDER, upsertSource,
"订单状态 " + existingBeforeUpdate.getOrderStatus() + "" + upsertPayload.getOrderStatus()
+ ";退款状态 " + existingBeforeUpdate.getRefundStatus() + "" + upsertPayload.getRefundStatus());
String.join("", parts), notifyWecom);
}
}
@@ -423,7 +435,7 @@ public class GoofishOrderPipeline {
row.setLocalWaybillNo(wb.trim());
if (goofishOrderChangeLogger != null) {
goofishOrderChangeLogger.append(row.getId(), row.getAppKey(), row.getOrderNo(),
GoofishOrderChangeLogger.TYPE_LOGISTICS, "REDIS_WAYBILL",
GoofishOrderChangeLogger.TYPE_LOGISTICS, GoofishOrderChangeLogger.SOURCE_REDIS_WAYBILL,
"本地运单 " + (prev == null || prev.isEmpty() ? "" : prev) + "" + wb.trim());
}
}
@@ -530,7 +542,9 @@ public class GoofishOrderPipeline {
if (goofishOrderChangeLogger != null) {
goofishOrderChangeLogger.append(row.getId(), row.getAppKey(), row.getOrderNo(),
GoofishOrderChangeLogger.TYPE_SHIP, "AUTO_SHIP",
"发货成功,运单 " + waybill.trim() + ",快递编码 " + expressCode);
"发货成功,运单 " + waybill.trim()
+ ",订单状态 " + GoofishStatusLabels.orderStatusHuman(row.getOrderStatus())
+ ",退款状态 " + GoofishStatusLabels.refundStatusHuman(row.getRefundStatus()));
}
} else {
String msg = r != null ? r.getString("msg") : "unknown";

View File

@@ -0,0 +1,125 @@
package com.ruoyi.jarvis.service.goofish;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
/**
* 闲管家开放平台:订单状态、退款状态中文说明(与 Apifox 订单列表 schema 一致)
*/
public final class GoofishStatusLabels {
private static final Map<Integer, String> ORDER = new HashMap<>();
private static final Map<Integer, String> REFUND = new HashMap<>();
static {
ORDER.put(11, "待付款");
ORDER.put(12, "待发货");
ORDER.put(21, "已发货");
ORDER.put(22, "已完成");
ORDER.put(23, "已退款");
ORDER.put(24, "已关闭");
REFUND.put(0, "未申请退款");
REFUND.put(1, "待商家处理");
REFUND.put(2, "待买家退货");
REFUND.put(3, "待商家收货");
REFUND.put(4, "退款关闭");
REFUND.put(5, "退款成功");
REFUND.put(6, "已拒绝退款");
REFUND.put(8, "待确认退货地址");
}
private GoofishStatusLabels() {
}
/** 仅中文;未知时返回 null */
public static String orderStatusLabel(Integer s) {
if (s == null) {
return null;
}
return ORDER.get(s);
}
/** 仅中文;未知时返回 null */
public static String refundStatusLabel(Integer s) {
if (s == null) {
return null;
}
return REFUND.get(s);
}
/**
* 用于日志/企微:优先中文,未知时带码便于排查
*/
public static String orderStatusHuman(Integer s) {
String t = orderStatusLabel(s);
if (t != null) {
return t;
}
if (s == null) {
return "";
}
return "未识别状态(" + s + ")";
}
public static String refundStatusHuman(Integer s) {
String t = refundStatusLabel(s);
if (t != null) {
return t;
}
if (s == null) {
return "";
}
return "未识别状态(" + s + ")";
}
/**
* 变化摘要A→B两边均为人话
*/
public static String orderStatusChange(Integer from, Integer to) {
return orderStatusHuman(from) + "" + orderStatusHuman(to);
}
/**
* 快照/首行展示:待发阶段注明已付款语义(开放平台码 12 即待发货)。
*/
public static String orderStatusHumanForNotify(Integer s) {
if (Objects.equals(s, 12)) {
return "待发货(已付款)";
}
return orderStatusHuman(s);
}
/**
* 企微通知用变化文案:付款完成单独写「已付款(待发货)」。
*/
public static String orderStatusChangeForNotify(Integer from, Integer to) {
if (Objects.equals(from, 11) && Objects.equals(to, 12)) {
return orderStatusHuman(11) + " → 已付款(待发货)";
}
return orderStatusHuman(from) + "" + orderStatusHumanForNotify(to);
}
/**
* 是否与「付款、待发、在途、终态退款/完成/关闭」相关,从而值得推企微(排除已由 SHIP 覆盖的待发→已发)。
*/
public static boolean isWxNotifiableOrderStatusChange(Integer from, Integer to) {
if (Objects.equals(from, to)) {
return false;
}
if (Objects.equals(from, 12) && Objects.equals(to, 21)) {
return false;
}
int[] anchors = {11, 12, 21, 22, 23, 24};
for (int code : anchors) {
if (Objects.equals(from, code) || Objects.equals(to, code)) {
return true;
}
}
return false;
}
public static String refundStatusChange(Integer from, Integer to) {
return refundStatusHuman(from) + "" + refundStatusHuman(to);
}
}

View File

@@ -2,12 +2,15 @@ package com.ruoyi.jarvis.service.impl;
import com.ruoyi.jarvis.domain.OrderRows;
import com.ruoyi.jarvis.domain.JDOrder;
import com.ruoyi.jarvis.domain.SuperAdmin;
import com.ruoyi.jarvis.domain.WeComShareLinkLogisticsJob;
import com.ruoyi.jarvis.service.IInstructionService;
import com.ruoyi.jarvis.service.IOrderRowsService;
import com.ruoyi.jarvis.service.IJDOrderService;
import com.ruoyi.jarvis.service.IProductJdConfigService;
import com.ruoyi.jarvis.service.IPhoneReplaceConfigService;
import com.ruoyi.jarvis.service.SuperAdminService;
import com.ruoyi.jarvis.service.IWeComShareLinkLogisticsJobService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@@ -51,12 +54,23 @@ public class InstructionServiceImpl implements IInstructionService {
@Resource
private com.ruoyi.jarvis.service.ITencentDocTokenService tencentDocTokenService;
@Resource
private IWeComShareLinkLogisticsJobService weComShareLinkLogisticsJobService;
@Resource
private com.ruoyi.jarvis.config.TencentDocConfig tencentDocConfig;
@Resource
private com.ruoyi.common.core.redis.RedisCache redisCache;
@Autowired(required = false)
private com.ruoyi.jarvis.service.ITencentDocDelayedPushService tencentDocDelayedPushService;
/** 与 {@link com.ruoyi.jarvis.service.impl.WeComInboundServiceImpl#WE_COM_SUPER_USER_ID} 一致:该账号在京统计中视为全局视角 */
private static final String WE_COM_SUPER_GLOBAL_STATS_USER = "LinPingFan";
/** 与企微物流分享链解析一致 */
private static final Pattern JD_3CN = Pattern.compile("https://3\\.cn/[A-Za-z0-9\\-]+");
private static final int EXTERNAL_LOGISTICS_LIST_DAYS = 60;
private static final int EXTERNAL_LOGISTICS_LIST_LIMIT = 35;
// 录单模板(与 jd/JDUtil 中 WENAN_D 保持一致)
private static final String WENAN_D = "单:\n" + "{单号} \n" + "分销标记:{分销标记}\n" + "第三方单号:{第三方单号}\n" + "—————————\n" + "下单链接(必须用这个):\n" + "{链接}\n" + "下单地址(注意带分机):\n" + "{地址}\n" + "—————————\n" + "型号:{型号}\n" + "\n" + "下单人(需填):\n" + "\n" + "下单付款(注意核对):\n" + "\n" + "后返金额(注意核对):\n" + "\n" + "订单号(需填):\n" + "\n" + "物流链接(需填):\n" + "\n" + "备注(下单号码有变动/没法带分机号的写这里):\n" + "{单的备注}\n" + "—————————\n" + "京粉实际价格:不用填";
@@ -72,6 +86,11 @@ public class InstructionServiceImpl implements IInstructionService {
@Override
public List<String> execute(String command, boolean forceGenerate, boolean isFromConsole) {
return execute(command, forceGenerate, isFromConsole, null);
}
@Override
public List<String> execute(String command, boolean forceGenerate, boolean isFromConsole, String wecomUserId) {
// 存储接收的消息到Redis队列
storeMessageToRedis("instruction:request", command);
@@ -89,7 +108,7 @@ public class InstructionServiceImpl implements IInstructionService {
// 一级命令分流:京系(统计/订单)、录单/慢单、转链/礼金…
if (input.startsWith("") || menuKeywords().contains(input)) {
result = Collections.singletonList(handleJingFen(input.replaceFirst("^京", "")));
result = Collections.singletonList(handleJingFen(input.replaceFirst("^京", ""), wecomUserId));
}
// TF/H/生/拼多多 生成类指令
else if (input.startsWith("TF")) {
@@ -120,6 +139,77 @@ public class InstructionServiceImpl implements IInstructionService {
return result;
}
/**
* 「京」统计/订单所用京粉订单数据源:企微成员按超级管理员绑定 unionId全局视角排除「不参与订单统计」联盟。
*/
private static final class JingStatsScope {
private final List<OrderRows> rows;
/** 可为 null禁止访问统计不参与统计等 */
private final String denyMessage;
/** 回复前缀,标明当前统计归属 */
private final String replyPrefix;
private JingStatsScope(List<OrderRows> rows, String denyMessage, String replyPrefix) {
this.rows = rows != null ? rows : Collections.emptyList();
this.denyMessage = denyMessage;
this.replyPrefix = replyPrefix != null ? replyPrefix : "";
}
static JingStatsScope denied(String msg) {
return new JingStatsScope(Collections.emptyList(), msg, "");
}
static JingStatsScope ok(List<OrderRows> rows, String replyPrefix) {
return new JingStatsScope(rows, null, replyPrefix);
}
}
private List<Long> buildExcludeUnionIdsForStats() {
List<Long> excludeUnionIds = new ArrayList<>();
List<SuperAdmin> superAdminList = superAdminService.selectSuperAdminList(null);
if (superAdminList != null) {
for (SuperAdmin admin : superAdminList) {
if (admin.getIsCount() != null && admin.getIsCount() == 0 && admin.getUnionId() != null && !admin.getUnionId().trim().isEmpty()) {
try {
excludeUnionIds.add(Long.parseLong(admin.getUnionId().trim()));
} catch (NumberFormatException ignored) {
}
}
}
}
return excludeUnionIds;
}
private JingStatsScope resolveJingStatsScope(String wecomUserId) {
if (wecomUserId == null || wecomUserId.trim().isEmpty()
|| WE_COM_SUPER_GLOBAL_STATS_USER.equals(wecomUserId.trim())) {
List<OrderRows> all = orderRowsService.selectOrderRowsListWithFilter(new OrderRows(), null, null, buildExcludeUnionIdsForStats());
return JingStatsScope.ok(all, "");
}
String wxUser = wecomUserId.trim();
SuperAdmin sa = superAdminService.selectSuperAdminByWecomUserId(wxUser);
if (sa == null) {
return JingStatsScope.ok(Collections.emptyList(), "");
}
if (sa.getIsCount() != null && sa.getIsCount() == 0) {
return JingStatsScope.denied("「京统计」\n\n当前企微账号在后台「超级管理员」中标记为不参与订单统计无法使用统计与订单类京指令。\n如需开通请联系管理员。");
}
if (sa.getUnionId() == null || sa.getUnionId().trim().isEmpty()) {
return JingStatsScope.denied("「京统计」\n\n当前企微账号未绑定联盟ID无法匹配京粉订单。\n请在后台「超级管理员」中维护该账号对应行的联盟ID。");
}
try {
long uid = Long.parseLong(sa.getUnionId().trim());
OrderRows probe = new OrderRows();
probe.setUnionId(uid);
List<OrderRows> scoped = orderRowsService.selectOrderRowsListWithFilter(probe, null, null, Collections.emptyList());
String namePart = sa.getName() != null && !sa.getName().trim().isEmpty() ? sa.getName().trim() : "未命名";
String prefix = "【联盟 " + sa.getUnionId().trim() + " · " + namePart + "\n";
return JingStatsScope.ok(scoped, prefix);
} catch (NumberFormatException e) {
return JingStatsScope.denied("「京统计」\n\n联盟ID格式不正确请联系管理员检查「超级管理员」配置。");
}
}
/**
* 将消息存储到Redis队列最多保留100条
* @param key Redis键
@@ -192,43 +282,55 @@ public class InstructionServiceImpl implements IInstructionService {
return new HashSet<>(Arrays.asList("菜单", "今日统计", "昨日统计", "三日统计", "七日统计", "一个月统计", "两个月统计", "三个月统计", "这个月统计", "上个月统计", "今日订单", "昨日订单", "七日订单", "总统计"));
}
private String handleJingFen(String cmd) {
private String handleJingFen(String cmd, String wecomUserId) {
String action = cmd.trim();
if (action.isEmpty() || action.equals("菜单")) {
return jingMenu();
}
// 取出所有订单(排除被删除/无效:这里沿用 OrderRowsService 的常规查询,必要时可增加过滤参数)
List<OrderRows> all = orderRowsService.selectOrderRowsList(new OrderRows());
if (all == null) all = Collections.emptyList();
if (action.startsWith("外物列表")) {
String kw = action.substring("外物列表".length()).trim();
return textExternalShareLinkLogisticsList(kw);
}
if (action.startsWith("外物删")) {
String rest = action.substring("外物删".length()).trim();
return textExternalShareLinkLogisticsDelete(rest);
}
JingStatsScope scope = resolveJingStatsScope(wecomUserId);
if (scope.denyMessage != null) {
return scope.denyMessage;
}
List<OrderRows> all = scope.rows;
String header = scope.replyPrefix;
switch (action) {
case "今日统计":
return statsText(filterByDays(all, 0), "今日统计");
return header + statsText(filterByDays(all, 0), "今日统计");
case "昨日统计":
return statsText(filterYesterday(all), "昨日统计");
return header + statsText(filterYesterday(all), "昨日统计");
case "三日统计":
return statsText(filterByRange(all, 3), "三日统计");
return header + statsText(filterByRange(all, 3), "三日统计");
case "七日统计":
return statsText(filterByRange(all, 7), "七日统计");
return header + statsText(filterByRange(all, 7), "七日统计");
case "一个月统计":
return statsText(filterByRange(all, 30), "一个月统计");
return header + statsText(filterByRange(all, 30), "一个月统计");
case "两个月统计":
return statsText(filterByRange(all, 60), "两个月统计");
return header + statsText(filterByRange(all, 60), "两个月统计");
case "三个月统计":
return statsText(filterByRange(all, 90), "三个月统计");
return header + statsText(filterByRange(all, 90), "三个月统计");
case "这个月统计":
return statsText(filterThisMonth(all), "这个月统计");
return header + statsText(filterThisMonth(all), "这个月统计");
case "上个月统计":
return statsText(filterLastMonth(all), "上个月统计");
return header + statsText(filterLastMonth(all), "上个月统计");
case "总统计":
return statsText(all, "总统计");
return header + statsText(all, "总统计");
case "今日订单":
return listOrders(filterByDays(all, 0), "今日订单");
return header + listOrders(filterByDays(all, 0), "今日订单");
case "昨日订单":
return listOrders(filterYesterday(all), "昨日订单");
return header + listOrders(filterYesterday(all), "昨日订单");
case "七日订单":
return listOrders(filterByRange(all, 7), "七日订单");
return header + listOrders(filterByRange(all, 7), "七日订单");
default:
// 高级命令违规N、SKU、搜索、JF… 此处按需扩展
if (action.startsWith("高级")) {
@@ -2128,16 +2230,207 @@ public class InstructionServiceImpl implements IInstructionService {
}).collect(Collectors.toList());
}
/**
* 企微「外部分享链物流」登记查询:用于发现同一备注、不同短链等重复登记。
*/
private String textExternalShareLinkLogisticsList(String remarkKeyword) {
if (weComShareLinkLogisticsJobService == null) {
return "「外物列表」\n\n服务未就绪。";
}
List<WeComShareLinkLogisticsJob> rows = weComShareLinkLogisticsJobService.selectRecentForInstruction(
remarkKeyword, EXTERNAL_LOGISTICS_LIST_DAYS, EXTERNAL_LOGISTICS_LIST_LIMIT);
if (rows == null || rows.isEmpty()) {
return "「外物列表」\n\n近 " + EXTERNAL_LOGISTICS_LIST_DAYS + " 天内无匹配记录。"
+ (remarkKeyword.isEmpty() ? "" : "\n关键词" + remarkKeyword + "");
}
Map<String, Long> remarkCount = rows.stream()
.map(j -> j.getUserRemark() != null ? j.getUserRemark().trim() : "")
.collect(Collectors.groupingBy(s -> s, Collectors.counting()));
StringBuilder sb = new StringBuilder();
sb.append("「外物列表」近").append(EXTERNAL_LOGISTICS_LIST_DAYS).append("天,最多")
.append(EXTERNAL_LOGISTICS_LIST_LIMIT).append("");
if (!remarkKeyword.isEmpty()) {
sb.append(",关键词「").append(remarkKeyword).append("");
}
sb.append("\n\n");
int i = 0;
for (WeComShareLinkLogisticsJob j : rows) {
i++;
String rk = j.getJobKey() != null ? j.getJobKey() : "";
String st = j.getStatus() != null ? j.getStatus() : "";
String rm = j.getUserRemark() != null ? j.getUserRemark().trim() : "";
if (rm.length() > 80) {
rm = rm.substring(0, 80) + "";
}
String url = j.getTrackingUrl() != null ? j.getTrackingUrl().trim() : "";
long dup = remarkCount.getOrDefault(j.getUserRemark() != null ? j.getUserRemark().trim() : "", 0L);
sb.append(i).append(". ").append(st);
if (dup > 1) {
sb.append(" ·本批同备注").append(dup).append("");
}
sb.append("\nkey=").append(rk);
sb.append("\n备注").append(rm.isEmpty() ? "(空)" : rm);
sb.append("\n链").append(url);
if (j.getCreateTime() != null) {
sb.append("\n时").append(new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm")
.format(j.getCreateTime()));
}
sb.append("\n——————\n");
}
sb.append("删:京外物删 key\n或京外物删 后接换行写备注,最后一行写含 3.cn 的链接。");
return sb.toString();
}
/**
* 删除外部分享链物流任务行与后台物理删除一致Redis 中未消费项会因库无行而跳过)。
*/
private String textExternalShareLinkLogisticsDelete(String rest) {
if (weComShareLinkLogisticsJobService == null) {
return "「外物删」\n\n服务未就绪。";
}
if (rest == null || rest.isEmpty()) {
return "「外物删」\n\n用法\n1) 京外物删 <jobKey>(见「京外物列表」中的 key=\n"
+ "2) 京外物删\n<备注全文>\nhttps://3.cn/…";
}
String oneLine = rest.replace("\r\n", "\n").trim();
if (!oneLine.contains("\n") && looksLikeShareLinkJobKey(oneLine)) {
WeComShareLinkLogisticsJob existed = weComShareLinkLogisticsJobService.selectByJobKey(oneLine);
int n = weComShareLinkLogisticsJobService.deleteByJobKey(oneLine);
if (n <= 0) {
return "「外物删」\n\n未删除任何行jobKey 可能不存在):" + oneLine;
}
String hint = existed != null
? "\n备注" + shortenForReply(existed.getUserRemark(), 120)
+ "\n链" + nvl(existed.getTrackingUrl())
: "";
return "「外物删」\n\n已删除 " + n + " 条。" + hint;
}
ParsedRemarkAndUrl parsed = parseRemarkAnd3cnFromDeletePayload(rest);
if (parsed == null) {
return "「外物删」\n\n未能解析备注与 3.cn 链接。请多行发送:倒数第一个含 3.cn 的为链接,其上一行起为备注;"
+ "或单行:京外物删 <jobKey>";
}
int n = weComShareLinkLogisticsJobService.deleteByRemarkAndTrackingUrl(parsed.remark, parsed.trackingUrl);
if (n <= 0) {
return "「外物删」\n\n未找到完全匹配的行备注与短链需与登记一致含空格请对齐\n备注"
+ shortenForReply(parsed.remark, 200) + "\n链" + parsed.trackingUrl;
}
return "「外物删」\n\n已按备注+链接删除 " + n + " 条。";
}
private static boolean looksLikeShareLinkJobKey(String s) {
if (s == null) {
return false;
}
String t = s.trim();
return t.matches("^[a-fA-F0-9]{32}$") || t.matches("^tracebf\\d+$");
}
private static String shortenForReply(String s, int maxChars) {
if (s == null) {
return "";
}
String t = s.trim();
if (t.length() <= maxChars) {
return t;
}
return t.substring(0, maxChars) + "";
}
private static final class ParsedRemarkAndUrl {
final String remark;
final String trackingUrl;
ParsedRemarkAndUrl(String remark, String trackingUrl) {
this.remark = remark;
this.trackingUrl = trackingUrl;
}
}
private static ParsedRemarkAndUrl parseRemarkAnd3cnFromDeletePayload(String rest) {
if (rest == null) {
return null;
}
String normalized = rest.replace("\r\n", "\n").trim();
String[] lines = normalized.split("\n");
if (lines.length == 1) {
ParsedRemarkAndUrl one = tryParseRemarkAndUrlSingleLine(lines[0]);
if (one != null) {
return one;
}
}
int urlIndex = -1;
String canonicalUrl = null;
for (int i = lines.length - 1; i >= 0; i--) {
String u = extractJd3cnForInstruction(lines[i]);
if (u != null) {
urlIndex = i;
canonicalUrl = u;
break;
}
}
if (canonicalUrl == null || urlIndex < 0) {
return null;
}
StringBuilder rem = new StringBuilder();
for (int i = 0; i < urlIndex; i++) {
if (i > 0) {
rem.append('\n');
}
rem.append(lines[i].trim());
}
String remark = rem.toString().trim();
return new ParsedRemarkAndUrl(remark, canonicalUrl);
}
/** 单行「…备注… https://3.cn/…」 */
private static ParsedRemarkAndUrl tryParseRemarkAndUrlSingleLine(String line) {
if (line == null || line.isEmpty()) {
return null;
}
Matcher m = JD_3CN.matcher(line);
if (m.find()) {
String url = m.group();
String rem = line.substring(0, m.start()).trim();
return new ParsedRemarkAndUrl(rem, url);
}
Matcher m2 = Pattern.compile("http://3\\.cn/[A-Za-z0-9\\-]+").matcher(line);
if (m2.find()) {
String url = m2.group().replace("http://", "https://");
String rem = line.substring(0, m2.start()).trim();
return new ParsedRemarkAndUrl(rem, url);
}
return null;
}
private static String extractJd3cnForInstruction(String text) {
if (text == null) {
return null;
}
Matcher m = JD_3CN.matcher(text);
if (m.find()) {
return m.group();
}
Matcher m2 = Pattern.compile("http://3\\.cn/[A-Za-z0-9\\-]+").matcher(text);
if (m2.find()) {
return m2.group().replace("http://", "https://");
}
return null;
}
// ===== 工具 =====
private String jingMenu() {
return "「京粉 · 菜单」\n\n"
+ "企微/机器人前请加「京」,例如:京今日统计\n\n"
+ "企微/机器人前请加「京」,例如:京今日统计\n"
+ "说明企微内统计仅含当前账号在「超级管理员」绑定的联盟ID标记为不参与订单统计的联盟不会在全局汇总中出现与后台京粉订单列表统计一致\n\n"
+ "—— 统计 ——\n"
+ "今日统计、昨日统计、三日统计、七日统计\n"
+ "一个月统计、两个月统计、三个月统计\n"
+ "这个月统计、上个月统计、总统计\n\n"
+ "—— 订单 ——\n"
+ "今日订单、昨日订单、七日订单\n\n"
+ "—— 外部分享链物流 ——\n"
+ "京外物列表 [关键词]、京外物删(见列表说明)\n\n"
+ "发「京」单独或「京菜单」可再次打开本列表。";
}
@@ -2148,6 +2441,7 @@ public class InstructionServiceImpl implements IInstructionService {
+ "· 京今日统计 / 京昨日统计 / 京七日统计 …\n"
+ "· 京今日订单 / 京昨日订单 / 京七日订单\n"
+ "· 慢搜 关键词、慢查 关键词(录单库模糊查询)\n"
+ "· 京外物列表 / 京外物删 — 企微 3.cn 分享链登记查询与删除\n"
+ "· 录单20250101-20250107 或 录单昨日|三日|七日(导出)\n\n"
+ "说明:转链、礼金等请使用系统内「一键转链」页面。";
}

View File

@@ -1,6 +1,7 @@
package com.ruoyi.jarvis.service.impl;
import com.ruoyi.jarvis.domain.JDOrder;
import com.ruoyi.jarvis.domain.dto.QuickRecordModelOption;
import com.ruoyi.jarvis.mapper.JDOrderMapper;
import com.ruoyi.jarvis.service.IJDOrderProfitService;
import com.ruoyi.jarvis.service.IJDOrderService;
@@ -81,6 +82,11 @@ public class JDOrderServiceImpl implements IJDOrderService {
public List<JDOrder> selectJDOrderListByDistributionMarkFOrPDD() {
return jdOrderMapper.selectJDOrderListByDistributionMarkFOrPDD();
}
@Override
public List<QuickRecordModelOption> selectQuickRecordModelOptions() {
return jdOrderMapper.selectQuickRecordModelOptions();
}
}

View File

@@ -9,7 +9,6 @@ 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.jarvis.wecom.WxSendGoofishNotifyClient;
import com.ruoyi.system.service.ISysConfigService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -22,13 +21,16 @@ import javax.annotation.Resource;
import javax.annotation.PostConstruct;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Collections;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import org.springframework.util.DigestUtils;
@@ -59,6 +61,10 @@ public class LogisticsServiceImpl implements ILogisticsService {
@Value("${jarvis.server.logistics.base-url:http://127.0.0.1:5001}")
private String logisticsBaseUrl;
/** 逗号分隔的多个解析服务 base非空时优先于此列表轮询例如 http://10.0.0.1:5001,http://10.0.0.2:5001 */
@Value("${jarvis.server.logistics.base-urls:}")
private String logisticsBaseUrlsRaw;
@Value("${jarvis.server.logistics.fetch-path:/fetch_logistics}")
private String logisticsFetchPath;
@@ -72,8 +78,10 @@ public class LogisticsServiceImpl implements ILogisticsService {
@Resource
private WeComShareLinkLogisticsJobMapper weComShareLinkLogisticsJobMapper;
private String externalApiUrlTemplate;
private String healthCheckUrl;
/** 已规范化(无尾部斜杠)的物流解析服务 base 列表,至少一项 */
private List<String> logisticsServiceBases = Collections.emptyList();
private String logisticsServiceBasesSummary = "";
private final AtomicInteger logisticsBaseRoundRobin = new AtomicInteger(0);
@Resource
private StringRedisTemplate stringRedisTemplate;
@@ -87,15 +95,81 @@ public class LogisticsServiceImpl implements ILogisticsService {
@Resource
private IErpGoofishOrderService erpGoofishOrderService;
@Resource
private WxSendGoofishNotifyClient wxSendGoofishNotifyClient;
@PostConstruct
public void init() {
externalApiUrlTemplate = logisticsBaseUrl + logisticsFetchPath + "?tracking_url=";
healthCheckUrl = logisticsBaseUrl + logisticsHealthPath;
logger.info("物流服务地址已初始化: {}", externalApiUrlTemplate);
logger.info("物流服务健康检查地址已初始化: {}", healthCheckUrl);
List<String> list = new ArrayList<>();
if (StringUtils.hasText(logisticsBaseUrlsRaw)) {
for (String part : logisticsBaseUrlsRaw.split(",")) {
String n = normalizeLogisticsBaseUrl(part);
if (StringUtils.hasText(n)) {
list.add(n);
}
}
}
if (list.isEmpty()) {
list.add(normalizeLogisticsBaseUrl(logisticsBaseUrl));
}
logisticsServiceBases = Collections.unmodifiableList(list);
logisticsServiceBasesSummary = String.join(", ", logisticsServiceBases);
logger.info("物流服务实例 {} 个(轮询): {}", logisticsServiceBases.size(), logisticsServiceBasesSummary);
}
private static String normalizeLogisticsBaseUrl(String base) {
if (!StringUtils.hasText(base)) {
return "";
}
String t = base.trim();
while (t.endsWith("/")) {
t = t.substring(0, t.length() - 1);
}
return t;
}
private String pickLogisticsBaseUrl() {
int n = logisticsServiceBases.size();
if (n == 0) {
return normalizeLogisticsBaseUrl(logisticsBaseUrl);
}
if (n == 1) {
return logisticsServiceBases.get(0);
}
int idx = Math.floorMod(logisticsBaseRoundRobin.getAndIncrement(), n);
return logisticsServiceBases.get(idx);
}
@Override
public String buildFetchLogisticsRequestUrl(String logisticsLink) {
String base = pickLogisticsBaseUrl();
try {
return base + logisticsFetchPath + "?tracking_url=" + URLEncoder.encode(logisticsLink, "UTF-8");
} catch (java.io.UnsupportedEncodingException e) {
throw new IllegalStateException(e);
}
}
/**
* 与原先单 URL 健康检查语义一致JSON 字段命中则通过;非 JSON 时仅当正文含 ok/healthy/success 子串则通过。
*/
private boolean isHealthResponseBodyHealthy(String healthResult) {
if (healthResult == null || healthResult.trim().isEmpty()) {
return false;
}
try {
JSONObject response = JSON.parseObject(healthResult);
if (response != null) {
String status = response.getString("status");
Boolean healthy = response.getBoolean("healthy");
Integer code = response.getInteger("code");
if ("ok".equalsIgnoreCase(status) || "healthy".equalsIgnoreCase(status)
|| Boolean.TRUE.equals(healthy) || (code != null && code == 200)) {
return true;
}
}
return false;
} catch (Exception e) {
String lowerResult = healthResult.toLowerCase();
return lowerResult.contains("ok") || lowerResult.contains("healthy") || lowerResult.contains("success");
}
}
@Override
@@ -109,46 +183,26 @@ public class LogisticsServiceImpl implements ILogisticsService {
@Override
public ILogisticsService.HealthCheckResult checkHealth() {
try {
logger.debug("开始检查物流服务健康状态 - URL: {}", healthCheckUrl);
String healthResult = HttpUtils.sendGet(healthCheckUrl);
if (healthResult == null || healthResult.trim().isEmpty()) {
logger.warn("物流服务健康检查返回空结果");
return new ILogisticsService.HealthCheckResult(false, "异常", "健康检查返回空结果", healthCheckUrl);
}
// 尝试解析JSON响应
List<String> errors = new ArrayList<>();
for (String base : logisticsServiceBases) {
String url = base + logisticsHealthPath;
try {
JSONObject response = JSON.parseObject(healthResult);
if (response != null) {
// 检查常见的健康状态字段
String status = response.getString("status");
Boolean healthy = response.getBoolean("healthy");
Integer code = response.getInteger("code");
if ("ok".equalsIgnoreCase(status) || "healthy".equalsIgnoreCase(status) ||
Boolean.TRUE.equals(healthy) || (code != null && code == 200)) {
logger.debug("物流服务健康检查通过");
return new ILogisticsService.HealthCheckResult(true, "正常", "服务运行正常", healthCheckUrl);
}
logger.debug("开始检查物流服务健康状态 - URL: {}", url);
String healthResult = HttpUtils.sendGet(url);
if (isHealthResponseBodyHealthy(healthResult)) {
logger.debug("物流服务健康检查通过 - {}", url);
return new ILogisticsService.HealthCheckResult(true, "正常", "服务运行正常: " + url,
logisticsServiceBasesSummary);
}
logger.warn("物流服务健康检查失败 - URL: {} 响应: {}", url, healthResult);
errors.add(url + " -> " + (healthResult == null || healthResult.isEmpty() ? "空响应" : "状态异常"));
} catch (Exception e) {
// 如果不是JSON格式检查是否包含成功标识
String lowerResult = healthResult.toLowerCase();
if (lowerResult.contains("ok") || lowerResult.contains("healthy") || lowerResult.contains("success")) {
logger.debug("物流服务健康检查通过非JSON格式");
return new ILogisticsService.HealthCheckResult(true, "正常", "服务运行正常", healthCheckUrl);
}
logger.error("物流服务健康检查异常 - URL: {}, 错误: {}", url, e.getMessage(), e);
errors.add(url + " -> " + e.getMessage());
}
logger.warn("物流服务健康检查失败 - 响应: {}", healthResult);
return new ILogisticsService.HealthCheckResult(false, "异常", "健康检查返回异常状态: " + healthResult, healthCheckUrl);
} catch (Exception e) {
logger.error("物流服务健康检查异常 - URL: {}, 错误: {}", healthCheckUrl, e.getMessage(), e);
return new ILogisticsService.HealthCheckResult(false, "异常", "健康检查异常: " + e.getMessage(), healthCheckUrl);
}
String msg = errors.isEmpty() ? "未配置物流解析实例" : String.join("; ", errors);
return new ILogisticsService.HealthCheckResult(false, "异常", msg, logisticsServiceBasesSummary);
}
/**
@@ -187,7 +241,7 @@ public class LogisticsServiceImpl implements ILogisticsService {
// 构建推送消息
StringBuilder pushContent = new StringBuilder();
pushContent.append("【物流服务异常提醒】\n");
pushContent.append("服务地址").append(healthCheckUrl).append("\n");
pushContent.append("服务实例").append(logisticsServiceBasesSummary).append("\n");
pushContent.append("失败原因:").append(reason).append("\n");
pushContent.append("时间:").append(new Date()).append("\n");
pushContent.append("请及时检查服务状态!");
@@ -281,8 +335,7 @@ public class LogisticsServiceImpl implements ILogisticsService {
return false;
}
// 构建外部接口URL
String externalUrl = externalApiUrlTemplate + URLEncoder.encode(logisticsLink, "UTF-8");
String externalUrl = buildFetchLogisticsRequestUrl(logisticsLink);
logger.info("调用外部接口获取物流信息 - 订单ID: {}, URL: {}", orderId, externalUrl);
// 在服务端执行HTTP请求
@@ -443,9 +496,14 @@ public class LogisticsServiceImpl implements ILogisticsService {
// 更新过期时间,确保记录不会过期
stringRedisTemplate.opsForValue().set(redisKey, waybillNo, 30, TimeUnit.DAYS);
}
safeNotifyGoofishShip(orderId, waybillNo,
"企微货主推送成功;" + (logisticsLinkUpdated ? "物流链接已更新;" : "物流链接未变;")
+ "Redis 已写入;随后触发闲鱼同步");
String mark = order.getDistributionMark();
boolean skipGoofishJdWexin = erpGoofishOrderService.hasLinkedGoofishOrder(orderId)
|| (mark != null && mark.contains("\u95f2\u9c7c"));
String traceTail = (logisticsLinkUpdated ? "物流链接已更新;" : "物流链接未变;") + "Redis 已写入;随后触发闲鱼同步";
String traceSummary = (skipGoofishJdWexin
? "闲鱼单本环节未发京东物流企微,真发货见 SHIP 日志;"
: "企微货主推送成功;") + traceTail;
safeNotifyGoofishShip(orderId, waybillNo, traceSummary);
// 记录最终处理结果
if (logisticsLinkUpdated) {
@@ -699,7 +757,7 @@ public class LogisticsServiceImpl implements ILogisticsService {
if (debug != null) {
debug.put("healthOk", true);
}
String externalUrl = externalApiUrlTemplate + URLEncoder.encode(url, "UTF-8");
String externalUrl = buildFetchLogisticsRequestUrl(url);
if (debug != null) {
debug.put("requestUrl", externalUrl);
}
@@ -821,67 +879,41 @@ public class LogisticsServiceImpl implements ILogisticsService {
*/
private boolean sendEnterprisePushNotification(JDOrder order, String waybillNo, boolean logisticsLinkUpdated, String oldLogisticsLink, String newLogisticsLink) {
try {
String distributionMark = order.getDistributionMark() != null ? order.getDistributionMark() : "未知";
String distributionMark = order.getDistributionMark() != null ? order.getDistributionMark() : "\u672a\u77e5";
String thirdPartyOrderNo = order.getThirdPartyOrderNo();
String modelStr = order.getModelNumber() != null ? order.getModelNumber() : "";
String addressStr = order.getAddress() != null ? order.getAddress() : "";
String goofishOrderNo;
String modelStr = order.getModelNumber() != null ? order.getModelNumber() : "\u65e0";
String addressStr = order.getAddress() != null ? order.getAddress() : "\u65e0";
boolean goofishLinked = erpGoofishOrderService.hasLinkedGoofishOrder(order.getId())
|| (distributionMark != null && distributionMark.contains("\u95f2\u9c7c"));
if (goofishLinked) {
// 闲鱼单:不在京东扫到运单环节发企微;仅写 Redis 并 notifyJdWaybillReady / tryAutoShip。
// 真发货成功后由 GoofishOrderChangeLogger SHIP 事件发 wx。
logger.info("闲鱼关联/分销:跳过本环节企微直推 - 订单ID: {}, 订单号: {}, waybill_no: {}",
order.getId(), order.getOrderId(), waybillNo);
return true;
}
// 非闲鱼PDD 企微,完整 JD 物流版式
StringBuilder std = new StringBuilder();
std.append("JD物流信息推送").append("\n");
std.append(distributionMark).append("\n");
if (thirdPartyOrderNo != null && !thirdPartyOrderNo.trim().isEmpty()) {
goofishOrderNo = thirdPartyOrderNo.trim();
} else if (order.getOrderId() != null && !order.getOrderId().trim().isEmpty()) {
goofishOrderNo = order.getOrderId().trim();
} else {
goofishOrderNo = "";
std.append("第三方单号:").append(thirdPartyOrderNo.trim()).append("\n");
}
boolean useGoofishWecom = erpGoofishOrderService.hasLinkedGoofishOrder(order.getId())
|| (distributionMark != null && distributionMark.contains("闲鱼"));
String fullText;
if (useGoofishWecom) {
// 闲鱼:仅「闲鱼自动发货」块,便于复制到平台
StringBuilder goofish = new StringBuilder();
goofish.append("闲鱼自动发货:").append("\n");
goofish.append("单号:").append(goofishOrderNo).append("\n");
goofish.append("型号:").append(modelStr).append("\n");
goofish.append("收货地址:").append(addressStr).append("\n");
goofish.append("运单号:").append(waybillNo).append("\n");
fullText = goofish.toString();
} else {
// 非闲鱼:沿用京东物流信息完整版式(与历史 PDD 通道一致),不出现「闲鱼自动发货」
StringBuilder std = new StringBuilder();
std.append("JD物流信息推送").append("\n");
std.append(distributionMark).append("\n");
if (thirdPartyOrderNo != null && !thirdPartyOrderNo.trim().isEmpty()) {
std.append("第三方单号:").append(thirdPartyOrderNo.trim()).append("\n");
std.append("型号:").append(modelStr).append("\n");
std.append("收货地址:").append(addressStr).append("\n");
if (logisticsLinkUpdated && newLogisticsLink != null && !newLogisticsLink.trim().isEmpty()) {
std.append("【物流链接已更新】").append("\n");
std.append("新物流链接:").append(newLogisticsLink.trim()).append("\n");
if (oldLogisticsLink != null && !oldLogisticsLink.trim().isEmpty()) {
std.append("旧物流链接:").append(oldLogisticsLink.trim()).append("\n");
}
std.append("型号:").append(modelStr).append("\n");
std.append("收货地址:").append(addressStr).append("\n");
if (logisticsLinkUpdated && newLogisticsLink != null && !newLogisticsLink.trim().isEmpty()) {
std.append("【物流链接已更新】").append("\n");
std.append("新物流链接:").append(newLogisticsLink.trim()).append("\n");
if (oldLogisticsLink != null && !oldLogisticsLink.trim().isEmpty()) {
std.append("旧物流链接:").append(oldLogisticsLink.trim()).append("\n");
}
std.append("\n");
}
std.append("运单号:").append("\n").append("\n").append("\n").append("\n").append(waybillNo).append("\n");
fullText = std.toString();
}
if (useGoofishWecom) {
String touserGoofish = getTouserByDistributionMark(distributionMark);
logger.info("闲鱼关联或分销含「闲鱼」:尝试企微闲鱼自建应用 - 订单ID: {}, 分销标识: {}, 接收人: {}",
order.getId(), distributionMark,
StringUtils.hasText(touserGoofish) ? touserGoofish : "(jarvis.wecom.goofish-notify-touser)");
if (wxSendGoofishNotifyClient.pushGoofishAgentText(touserGoofish, "", fullText)) {
logger.info("企微闲鱼应用推送成功 - 订单ID: {}, 订单号: {}, waybill_no: {}",
order.getId(), order.getOrderId(), waybillNo);
return true;
}
logger.warn("企微闲鱼应用推送失败或未配置 wxSend不再走 PDD 通道 - 订单ID: {}", order.getId());
return false;
std.append("\n");
}
std.append("运单号:").append("\n").append("\n").append("\n").append("\n").append(waybillNo).append("\n");
String fullText = std.toString();
// 调用企业微信推送接口PDD 自建应用)
JSONObject pushParam = new JSONObject();

View File

@@ -10,18 +10,25 @@ import org.springframework.util.StringUtils;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.SocketTimeoutException;
import java.net.URL;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.locks.ReentrantLock;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 企微「开」+ 手机号POST 局域网 /v1/forward将 JSON 中的 {@code reply_text} 作为回显。
* 企微「开」/「慢开」+ 手机号POST 局域网 /v1/forwardbody 含 {@code text}(手机号)与 {@code bot}
* 将 JSON 中的 {@code reply_text} 作为回显。
* <p>
* 可配置 {@code wait_reply} / {@code reply_take_nth}:服务端先发 text再等 Bot 回 N 次,
* N内容填入 {@code reply_text}(前几条如「查询中…」由对端丢弃)
* {@code wait_reply} 时:{@code AJL05_bot} 固定取第 2 条;{@code QingBaoJuXWsgkbot} 由 tg_bridge
* 在同一会话内多次收取(仅一次发送 query2是否已为结果决定在 2/3 条间取值,避免重复计费
* 「慢开」返回仍会去掉尾部固定推广行。
* </p>
*/
@Service
@@ -31,6 +38,34 @@ public class OpenPhoneForwardService {
private static final Pattern MOBILE_11 = Pattern.compile("(1\\d{10})");
/** 「开」→ 对应 Bot 用户名(与对端 {@code bot} 字段一致,不含 @ */
private static final String BOT_OPEN = "AJL05_bot";
/** 「慢开」→ 对应 Bot 用户名 */
private static final String BOT_SLOW_OPEN = "QingBaoJuXWsgkbot";
private static final int REPLY_TAKE_NTH_OPEN_BOT = 2;
/**
* 情报局推广条Telegram 常为 Markdown
* {@code **👉‍**[**文案**](url)**👈**};另保留旧版纯文本。
*/
private static final Pattern[] QINGBAO_REPLY_JUNK_PATTERNS = {
Pattern.compile(
"\\*\\*👉\u200D?\\*\\*\\[\\*\\*公安路线查询价格表\\*\\*\\]"
+ "\\(https?://t\\.me/\\+C20ADPmEKJU0ZGFl\\)\\*\\*👈\\*\\*"),
Pattern.compile(
"\\*\\*👉\u200D?\\*\\*\\[\\*\\*如机器人提示被注销点我防丢\\*\\*\\]"
+ "\\(https?://telegra\\.ph/qingbaoju-10-01\\)\\*\\*👈\\*\\*"),
};
private static final String[] QINGBAO_REPLY_JUNK_LITERAL = {
"\uD83D\uDC49\u200D公安路线查询价格表 (https://t.me/+C20ADPmEKJU0ZGFl)\uD83D\uDC48",
"\uD83D\uDC49\u200D如机器人提示被注销点我防丢 (https://telegra.ph/qingbaoju-10-01)\uD83D\uDC48",
"\uD83D\uDC49公安路线查询价格表 (https://t.me/+C20ADPmEKJU0ZGFl)\uD83D\uDC48",
"\uD83D\uDC49如机器人提示被注销点我防丢 (https://telegra.ph/qingbaoju-10-01)\uD83D\uDC48",
};
@Value("${jarvis.phone-forward.enabled:false}")
private boolean enabled;
@@ -50,10 +85,24 @@ public class OpenPhoneForwardService {
@Value("${jarvis.phone-forward.wait-reply:true}")
private boolean waitReply;
/** 与 wait_reply 配合:取第几条 Bot 回复作为 reply_text须 ≥ 1 */
@Value("${jarvis.phone-forward.reply-take-nth:2}")
private int replyTakeNth;
/** 与 tg_bridge 串行:多线程同时「开」时排队,避免 Python 端会话串话0 表示无限等待 */
@Value("${jarvis.phone-forward.lock-acquire-timeout-ms:180000}")
private long lockAcquireTimeoutMs;
/** 连续失败达到阈值后,在 openDurationMs 内直接拒绝调用(不发起 HTTP */
@Value("${jarvis.phone-forward.circuit-failure-threshold:5}")
private int circuitFailureThreshold;
@Value("${jarvis.phone-forward.circuit-open-ms:120000}")
private long circuitOpenMs;
/** 仅允许单飞:所有 phone-forward 请求串行 */
private final ReentrantLock tgBridgeCallLock = new ReentrantLock(true);
private final AtomicInteger circuitFailureCount = new AtomicInteger(0);
/** 熔断恢复时间epoch ms0 表示未熔断 */
private final AtomicLong circuitOpenUntilMs = new AtomicLong(0);
/**
* @return 非 null 表示本条消息已由本服务处理含错误提示null 表示不匹配规则
*/
@@ -62,14 +111,50 @@ public class OpenPhoneForwardService {
return null;
}
String text = rawContent.trim().replaceFirst("^\uFEFF", "");
if (!text.startsWith("")) {
String bot;
if (text.startsWith("慢开")) {
bot = BOT_SLOW_OPEN;
} else if (text.startsWith("")) {
bot = BOT_OPEN;
} else {
return null;
}
String phone = extractFirstMobile11(text);
if (phone == null) {
return null;
}
return doForward(phone);
return doForward(phone, bot);
}
private boolean isCircuitOpen() {
long until = circuitOpenUntilMs.get();
return until > 0L && System.currentTimeMillis() < until;
}
private void recordSuccess() {
circuitFailureCount.set(0);
circuitOpenUntilMs.set(0L);
}
/** HTTP/网络类失败、5xx、超时记入达到阈值则熔断一段时间 */
private void recordFailure() {
long now = System.currentTimeMillis();
long until = circuitOpenUntilMs.get();
if (until > now) {
return;
}
int n = circuitFailureCount.incrementAndGet();
int thr = Math.max(1, circuitFailureThreshold);
if (n >= thr) {
long openUntil = now + Math.max(1000L, circuitOpenMs);
circuitOpenUntilMs.set(openUntil);
circuitFailureCount.set(0);
log.warn("phone-forward 熔断开启至 epochMs={}(连续失败 ≥ {}", openUntil, thr);
}
}
private static boolean shouldTripCircuit(int httpCode) {
return httpCode == 504 || httpCode >= 500;
}
private static String extractFirstMobile11(String text) {
@@ -80,7 +165,39 @@ public class OpenPhoneForwardService {
return null;
}
private String doForward(String phone) {
private String doForward(String phone, String bot) {
if (isCircuitOpen()) {
log.warn("phone-forward 熔断拒绝 phone={} bot={}", phone, bot);
return "「转发服务」暂时不可用(连续失败保护中),请一分钟后再试。";
}
boolean locked = false;
try {
if (lockAcquireTimeoutMs <= 0) {
tgBridgeCallLock.lock();
locked = true;
} else {
locked = tgBridgeCallLock.tryLock(lockAcquireTimeoutMs, TimeUnit.MILLISECONDS);
if (!locked) {
log.warn(
"phone-forward 排队超时上一条未完成phone={} bot={} ms={}",
phone, bot, lockAcquireTimeoutMs);
return "「转发服务」正忙(上一条查询尚未结束),请稍后再试。";
}
}
return doForwardUnsynchronized(phone, bot);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.warn("phone-forward 获取锁被打断 phone={} bot={}", phone, bot);
return "「转发服务」被中断,请稍后再试。";
} finally {
if (locked) {
tgBridgeCallLock.unlock();
}
}
}
private String doForwardUnsynchronized(String phone, String bot) {
try {
String base = baseUrl.trim();
if (base.endsWith("/")) {
@@ -97,13 +214,14 @@ public class OpenPhoneForwardService {
JSONObject body = new JSONObject();
body.put("text", phone);
body.put("bot", bot);
if (waitReply) {
int nth = replyTakeNth >= 1 ? replyTakeNth : 1;
if (replyTakeNth < 1) {
log.warn("phone-forward reply-take-nth={} 无效,已按 1 处理", replyTakeNth);
}
body.put("wait_reply", true);
body.put("reply_take_nth", nth);
if (BOT_SLOW_OPEN.equals(bot)) {
body.put("reply_adaptive_skip_middle_ad", true);
} else {
body.put("reply_take_nth", REPLY_TAKE_NTH_OPEN_BOT);
}
}
byte[] bytes = body.toJSONString().getBytes(StandardCharsets.UTF_8);
@@ -124,28 +242,54 @@ public class OpenPhoneForwardService {
String resp = readAll(is);
if (code < 200 || code >= 300) {
log.warn("phone-forward HTTP {} url={} body={}", code, urlStr, resp);
if (shouldTripCircuit(code)) {
recordFailure();
}
return "「转发服务」请求失败HTTP " + code + "),请稍后再试。";
}
JSONObject jo = JSONObject.parseObject(resp);
if (jo == null) {
recordFailure();
return "「转发服务」返回异常,请稍后再试。";
}
String reply = jo.getString("reply_text");
if (!StringUtils.hasText(reply)) {
recordFailure();
return "「转发服务」未返回 reply_text。";
}
if (BOT_SLOW_OPEN.equals(bot)) {
reply = filterQingBaoAdLines(reply);
}
recordSuccess();
return reply;
} finally {
if (conn != null) {
conn.disconnect();
}
}
} catch (SocketTimeoutException e) {
log.warn("phone-forward 超时 phone={} bot={} err={}", phone, bot, e.toString());
recordFailure();
return "「转发服务」超时,请稍后再试。";
} catch (Exception e) {
log.warn("phone-forward 异常 phone={} err={}", phone, e.toString());
log.warn("phone-forward 异常 phone={} bot={} err={}", phone, bot, e.toString());
recordFailure();
return "「转发服务」连接失败,请确认 Jarvis 与局域网服务可达。";
}
}
/** 将固定广告段Markdown 与纯文本)替换为空格后 trim。 */
private static String filterQingBaoAdLines(String reply) {
String s = reply;
for (Pattern p : QINGBAO_REPLY_JUNK_PATTERNS) {
s = p.matcher(s).replaceAll(" ");
}
for (String junk : QINGBAO_REPLY_JUNK_LITERAL) {
s = s.replace(junk, " ");
}
return s.trim();
}
private static String readAll(InputStream is) throws java.io.IOException {
if (is == null) {
return "";

View File

@@ -15,7 +15,9 @@ import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import javax.annotation.Resource;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@@ -23,8 +25,9 @@ import java.util.regex.Pattern;
/**
* LinPingFan全部指令其他人员须在超级管理员中识别为本人wxid=企微 UserID**或** 企微 UserID 出现在 touser 逗号分隔列表中),且仅「京*」指令 + 京东分享物流链接流程;
* 例外:以「单」或「开始」开头且含「分销标记」的录单正文优先于物流(不进入 3.cn 多轮、不占用物流监听)。
* 以「开」开头且正文含 11 位手机号1 开头POST 配置项 jarvis.phone-forward 指向的局域网服务,回显 reply_text。
* 以「开」或「慢开」开头且正文含 11 位手机号1 开头POST 配置项 jarvis.phone-forward 指向的局域网服务,回显 reply_textbody 含对应 bot
* 多轮会话使用 Redis{@link WeComChatSession},键 interaction_state:wecom:{FromUserName}与旧版「开通礼金」interaction_state 思路一致。
* 回复正文按 UTF-8 每段至多 2048 字节拆分:首段被动回复,其余主动推送(同一次用户消息、不重复触发查询)。
*/
@Service
public class WeComInboundServiceImpl implements IWeComInboundService {
@@ -34,8 +37,9 @@ public class WeComInboundServiceImpl implements IWeComInboundService {
public static final String WE_COM_SUPER_USER_ID = "LinPingFan";
private static final Pattern JD_3CN = Pattern.compile("https://3\\.cn/[A-Za-z0-9\\-]+");
private static final int REPLY_MAX_LEN = 3500;
private static final String REPLY_TRUNCATED_HINT = "\n…\n内容过长余下部分已省略";
/** 企微被动回复与应用文本消息 content 官方上限UTF-8 字节(见被动回复 / 发送应用消息文档) */
private static final int WE_COM_TEXT_MAX_UTF8_BYTES = 2048;
/** 无超级管理员配置 */
private static String replyPermissionDenied() {
@@ -52,8 +56,9 @@ public class WeComInboundServiceImpl implements IWeComInboundService {
+ "当前账号支持:\n"
+ "· 「京」开头的统计、订单类指令(可先发「京菜单」查看列表)\n"
+ "· 含 3.cn 的京东物流分享:先发链接,再发备注\n"
+ "· 京外物列表 / 京外物删 — 查询或删除外部分享链物流登记\n"
+ "· 以「单」或「开始」开头,且含「分销标记」的录单正文\n"
+ "· 以「开」开头且含手机号的查询\n\n"
+ "· 以「开」或「慢开」开头且含手机号的查询\n\n"
+ "如需其他指令,请联系管理员。";
}
@@ -106,7 +111,7 @@ public class WeComInboundServiceImpl implements IWeComInboundService {
String openPhoneReply = openPhoneForwardService.tryReply(content);
if (openPhoneReply != null) {
return WeComInboundResult.passiveOnly(truncateReply(openPhoneReply));
return toChunkedInboundResult(openPhoneReply);
}
final boolean danRecordPriority = isDanRecordPriorityOverLogistics(content);
@@ -163,33 +168,89 @@ public class WeComInboundServiceImpl implements IWeComInboundService {
if (!isSuper) {
String cmd = content.trim().replaceFirst("^\uFEFF", "");
if (!cmd.startsWith("") && !danRecordPriority && !cmd.startsWith("")) {
if (!cmd.startsWith("") && !danRecordPriority && !cmd.startsWith("") && !cmd.startsWith("慢开")) {
return WeComInboundResult.passiveOnly(replyGeneralUserScopeHint());
}
}
List<String> parts = instructionService.execute(content, false, isSuper);
List<String> parts = instructionService.execute(content, false, isSuper, from);
if (parts == null || parts.isEmpty()) {
return WeComInboundResult.empty();
}
if (parts.size() == 1) {
return WeComInboundResult.passiveOnly(truncateReply(parts.get(0)));
return toChunkedInboundResult(parts.get(0));
}
List<String> headChunks = splitUtf8Chunks(parts.get(0), WE_COM_TEXT_MAX_UTF8_BYTES);
String passive = headChunks.isEmpty() ? "" : headChunks.get(0);
List<String> active = new ArrayList<>();
for (int i = 1; i < parts.size(); i++) {
active.add(truncateReply(parts.get(i)));
for (int h = 1; h < headChunks.size(); h++) {
active.add(headChunks.get(h));
}
return new WeComInboundResult(truncateReply(parts.get(0)), active);
for (int i = 1; i < parts.size(); i++) {
String p = parts.get(i);
if (p == null) {
continue;
}
for (String chunk : splitUtf8Chunks(p, WE_COM_TEXT_MAX_UTF8_BYTES)) {
active.add(chunk);
}
}
return new WeComInboundResult(passive, active);
}
private static String truncateReply(String reply) {
if (reply == null) {
return "";
/**
* 首段 ≤2048 UTF-8 字节走被动回复,其余走 wxSend 主动推送(同一次用户消息内顺序下发,不重复计费)。
*/
private static WeComInboundResult toChunkedInboundResult(String fullText) {
List<String> chunks = splitUtf8Chunks(fullText, WE_COM_TEXT_MAX_UTF8_BYTES);
if (chunks.isEmpty()) {
return WeComInboundResult.passiveOnly("");
}
if (reply.length() > REPLY_MAX_LEN) {
return reply.substring(0, REPLY_MAX_LEN) + REPLY_TRUNCATED_HINT;
if (chunks.size() == 1) {
return WeComInboundResult.passiveOnly(chunks.get(0));
}
return reply;
return new WeComInboundResult(chunks.get(0), new ArrayList<>(chunks.subList(1, chunks.size())));
}
/**
* 按 UTF-8 字节长度切分,每段不超过 maxUtf8Bytes不在 BMP 的码点按整字符保留)。
*/
private static List<String> splitUtf8Chunks(String text, int maxUtf8Bytes) {
if (text == null) {
return Collections.singletonList("");
}
if (text.isEmpty()) {
return Collections.singletonList("");
}
if (maxUtf8Bytes < 1) {
throw new IllegalArgumentException("maxUtf8Bytes must be >= 1");
}
List<String> out = new ArrayList<>();
int i = 0;
final int n = text.length();
while (i < n) {
int chunkStart = i;
int usedBytes = 0;
while (i < n) {
int cp = text.codePointAt(i);
int charCount = Character.charCount(cp);
int b = new String(Character.toChars(cp)).getBytes(StandardCharsets.UTF_8).length;
if (usedBytes + b > maxUtf8Bytes) {
break;
}
usedBytes += b;
i += charCount;
}
if (i == chunkStart) {
int cp = text.codePointAt(i);
int charCount = Character.charCount(cp);
out.add(text.substring(chunkStart, chunkStart + charCount));
i = chunkStart + charCount;
} else {
out.add(text.substring(chunkStart, i));
}
}
return out;
}
/**

View File

@@ -47,6 +47,32 @@ public class WeComShareLinkLogisticsJobServiceImpl implements IWeComShareLinkLog
return weComShareLinkLogisticsJobMapper.selectWeComShareLinkLogisticsJobList(query);
}
@Override
public List<WeComShareLinkLogisticsJob> selectRecentForInstruction(String remarkKeyword, int days, int limit) {
int d = Math.max(1, Math.min(days, 365));
int lim = Math.max(1, Math.min(limit, 100));
String kw =
remarkKeyword != null && !remarkKeyword.trim().isEmpty() ? remarkKeyword.trim() : null;
return weComShareLinkLogisticsJobMapper.selectRecentForInstruction(kw, d, lim);
}
@Override
public int deleteByJobKey(String jobKey) {
if (jobKey == null || !StringUtils.hasText(jobKey.trim())) {
return 0;
}
return weComShareLinkLogisticsJobMapper.deleteByJobKey(jobKey.trim());
}
@Override
public int deleteByRemarkAndTrackingUrl(String remark, String trackingUrl) {
if (!StringUtils.hasText(trackingUrl)) {
return 0;
}
String r = remark != null ? remark : "";
return weComShareLinkLogisticsJobMapper.deleteByRemarkAndTrackingUrl(r, trackingUrl.trim());
}
@Override
public Map<String, Object> backfillImportedFromInboundTrace() {
int imported = 0;

View File

@@ -29,7 +29,7 @@ public class GoofishScheduledTasks {
@Resource
private JarvisGoofishProperties goofishProperties;
@Scheduled(cron = "${jarvis.goofish-order.pull-cron:0 0/15 * * * ?}")
@Scheduled(cron = "${jarvis.goofish-order.pull-cron:0 * * * * ?}")
public void scheduledPull() {
List<ErpOpenConfig> cfgs = erpOpenConfigService.selectEnabledOrderBySort();
if (cfgs == null || cfgs.isEmpty()) {

View File

@@ -110,22 +110,17 @@ public class WxSendGoofishNotifyClient {
}
}
/**
* 企微短通知:场景标题 + 订单号 + 必要字段行(不再展示数据来源等冗长前缀)。
*/
private static String buildContent(String orderNo, String eventType, String source, String message) {
String on = orderNo != null ? orderNo : "-";
String head = resolveNotifyHeadline(eventType, message);
String detail = normalizeNotifyDetailLines(eventType, message);
StringBuilder sb = new StringBuilder();
sb.append("【闲鱼订单】").append(orderNo != null ? orderNo : "-").append("\n");
String typeLabel = humanEventType(eventType);
String srcLabel = humanSource(source);
if (StringUtils.hasText(typeLabel)) {
sb.append(typeLabel);
if (StringUtils.hasText(srcLabel)) {
sb.append(" · ").append(srcLabel);
}
} else if (StringUtils.hasText(srcLabel)) {
sb.append(srcLabel);
}
sb.append("\n");
if (StringUtils.hasText(message)) {
sb.append(message);
sb.append(head).append("\n订单号").append(on).append('\n');
if (StringUtils.hasText(detail)) {
sb.append(detail);
}
String s = sb.toString();
if (s.length() > CONTENT_MAX) {
@@ -134,48 +129,221 @@ public class WxSendGoofishNotifyClient {
return s;
}
/** 企微展示用,与库内 event_type 枚举一致 */
private static String humanEventType(String eventType) {
if (!StringUtils.hasText(eventType)) {
return "";
private static String resolveNotifyHeadline(String eventType, String message) {
String m = message != null ? message : "";
String t = eventType != null ? eventType.trim() : "";
if ("SHIP".equals(t)) {
if (m.contains("失败") || m.contains("异常") || m.contains("缺地址")) {
return "发货失败";
}
return "发货";
}
switch (eventType.trim()) {
case "ORDER_SYNC":
return "订单同步";
case "LOGISTICS_SYNC":
return "物流同步";
case "SHIP":
return "发货";
default:
return eventType.trim();
if ("LOGISTICS_SYNC".equals(t)) {
return "物流";
}
if ("ORDER_SYNC".equals(t)) {
if (m.startsWith("新订单") || m.contains("新订单入库")) {
return "新订单";
}
if (looksRefundOnlyDiffLine(m)) {
return "退款";
}
return "订单";
}
return StringUtils.hasText(t) ? t : "订单";
}
/** 企微展示用,与库内 source 一致 */
private static String humanSource(String source) {
if (!StringUtils.hasText(source)) {
/** ORDER_SYNC 正文里是否仅有退款状态变更(单行或多行中的唯一语义块) */
private static boolean looksRefundOnlyDiffLine(String message) {
if (!StringUtils.hasText(message) || message.contains("新订单")) {
return false;
}
String normalized = message.replace('', '\n').trim();
String[] lines = normalized.split("\n");
boolean sawRefund = false;
boolean sawOrder = false;
for (String raw : lines) {
String line = raw.trim();
if (line.isEmpty()) {
continue;
}
if (line.startsWith("退款状态")) {
sawRefund = true;
continue;
}
if (line.startsWith("订单状态")) {
sawOrder = true;
continue;
}
return false;
}
return sawRefund && !sawOrder;
}
/**
* 将内部摘要句转为「字段:值」行;箭头统一为「 → 」。
*/
private static String normalizeNotifyDetailLines(String eventType, String message) {
if (!StringUtils.hasText(message)) {
return "";
}
switch (source.trim()) {
case "LIST_UPSERT":
return "列表拉单写入";
case "NOTIFY_UPSERT":
return "推送回调写入";
case "LIST":
return "列表摘要";
case "DETAIL_REFRESH":
return "详情刷新";
case "NOTIFY":
return "推送";
case "REDIS_WAYBILL":
return "Redis 运单";
case "AUTO_SHIP":
return "自动发货";
case "JD_LOGISTICS_PUSH":
return "京东物流扫描";
default:
return source.trim();
String t = eventType != null ? eventType.trim() : "";
String m = message.trim();
if ("SHIP".equals(t)) {
if (m.startsWith("发货成功")) {
StringBuilder details = new StringBuilder();
String wb = extractCommaField(m, "运单 ");
if (StringUtils.hasText(wb)) {
details.append("物流单号:").append(normalizeArrowSpaces(wb)).append('\n');
}
String os = extractCommaField(m, "订单状态 ");
if (StringUtils.hasText(os)) {
details.append("订单状态:").append(normalizeArrowSpaces(os)).append('\n');
}
String rs = extractCommaField(m, "退款状态 ");
if (StringUtils.hasText(rs)) {
details.append("退款状态:").append(normalizeArrowSpaces(rs)).append('\n');
}
if (details.length() > 0) {
return details.toString();
}
}
if (m.startsWith("发货失败") || m.startsWith("发货异常")) {
int idx = m.indexOf('');
String reason = idx >= 0 && idx < m.length() - 1 ? m.substring(idx + 1).trim() : m;
return "原因:" + normalizeArrowSpaces(reason) + "\n";
}
}
if ("ORDER_SYNC".equals(t)) {
// 新订单入库,订单状态 X退款状态 Y
if (m.contains("新订单入库") && m.contains("订单状态 ") && m.contains("退款状态 ")) {
String os = substringBetweenPrefixes(m, "订单状态 ", ",退款状态");
String rs = substringAfterPrefix(m, "退款状态 ");
StringBuilder sb = new StringBuilder();
if (StringUtils.hasText(os)) {
sb.append("订单状态:").append(os.trim()).append('\n');
}
if (StringUtils.hasText(rs)) {
sb.append("退款状态:").append(rs.trim()).append('\n');
}
return sb.toString();
}
// 订单状态 A → B退款状态 …(仅输出消息里出现的块)
return formatOrderSyncSemicolonParts(m);
}
if ("LOGISTICS_SYNC".equals(t)) {
return formatLogisticsParts(m);
}
return normalizeArrowSpaces(m) + "\n";
}
private static String formatOrderSyncSemicolonParts(String m) {
String[] parts = m.split("");
StringBuilder sb = new StringBuilder();
for (String p : parts) {
String line = kvLineOrderOrRefund(p.trim());
if (line != null) {
sb.append(line).append('\n');
}
}
return sb.toString();
}
private static String kvLineOrderOrRefund(String segment) {
if (!StringUtils.hasText(segment)) {
return null;
}
if (segment.startsWith("订单状态 ")) {
return "订单状态:" + normalizeArrowSpaces(segment.substring("订单状态 ".length()).trim());
}
if (segment.startsWith("退款状态 ")) {
return "退款状态:" + normalizeArrowSpaces(segment.substring("退款状态 ".length()).trim());
}
return null;
}
private static String formatLogisticsParts(String m) {
String[] parts = m.split("");
StringBuilder sb = new StringBuilder();
for (String p : parts) {
String line = logisticsSegmentToKv(p.trim());
if (line != null) {
sb.append(line).append('\n');
}
}
return sb.toString();
}
/** 本地/平台运单 → 对用户展示为物流单号;其它变更行仍可保留技术性标签 */
private static String logisticsSegmentToKv(String segment) {
if (!StringUtils.hasText(segment)) {
return null;
}
if (segment.startsWith("本地运单 ") || segment.startsWith("平台运单 ")) {
int cut = segment.indexOf(' ');
String rest = normalizeArrowSpaces(segment.substring(cut + 1).trim());
return "物流单号:" + rest;
}
String kv = logisticLabelToColon(segment);
return kv != null ? kv : (normalizeArrowSpaces(segment));
}
private static String logisticLabelToColon(String segment) {
String[] prefixes = {"快递编码 ", "快递名称 "};
for (String pref : prefixes) {
if (segment.startsWith(pref)) {
String label = pref.trim();
return label.substring(0, label.length() - 1) + ""
+ normalizeArrowSpaces(segment.substring(pref.length()).trim());
}
}
return null;
}
private static String normalizeArrowSpaces(String s) {
if (s == null) {
return "";
}
return s.replace("", "").replace("", "").replace("", "");
}
/** 从「片段1片段2…」中取出以 {@code fieldPrefix} 开头的片段值(去掉前缀) */
private static String extractCommaField(String m, String fieldPrefix) {
if (!StringUtils.hasText(m) || !StringUtils.hasText(fieldPrefix)) {
return null;
}
for (String part : m.split("")) {
String p = part.trim();
if (p.startsWith(fieldPrefix)) {
String v = p.substring(fieldPrefix.length()).trim();
return v.length() > 0 ? v : null;
}
}
return null;
}
private static String substringBetweenPrefixes(String m, String a, String b) {
int ia = m.indexOf(a);
if (ia < 0) {
return null;
}
int ib = m.indexOf(b, ia + a.length());
if (ib < 0) {
return null;
}
return m.substring(ia + a.length(), ib);
}
private static String substringAfterPrefix(String m, String pref) {
int i = m.indexOf(pref);
if (i < 0) {
return null;
}
return m.substring(i + pref.length()).trim();
}
/**

View File

@@ -71,8 +71,29 @@
<if test="userName != null and userName != ''">and e.user_name like concat('%', #{userName}, '%')</if>
<if test="orderNo != null and orderNo != ''">and e.order_no like concat('%', #{orderNo}, '%')</if>
<if test="orderStatus != null">and e.order_status = #{orderStatus}</if>
<if test="refundStatus != null">and e.refund_status = #{refundStatus}</if>
<if test="orderType != null">and e.order_type = #{orderType}</if>
<if test="shipStatus != null">and e.ship_status = #{shipStatus}</if>
<if test="jdOrderId != null">and e.jd_order_id = #{jdOrderId}</if>
<if test="buyerNick != null and buyerNick != ''">and e.buyer_nick like concat('%', #{buyerNick}, '%')</if>
<if test="goodsTitle != null and goodsTitle != ''">and e.goods_title like concat('%', #{goodsTitle}, '%')</if>
<if test="receiverMobile != null and receiverMobile != ''">and e.receiver_mobile like concat('%', #{receiverMobile}, '%')</if>
<if test="itemId != null">and e.item_id = #{itemId}</if>
<if test="productId != null">and e.product_id = #{productId}</if>
<if test="waybillKeyword != null and waybillKeyword != ''">
and (e.detail_waybill_no like concat('%', #{waybillKeyword}, '%')
or e.local_waybill_no like concat('%', #{waybillKeyword}, '%'))
</if>
<if test="jdRemark != null and jdRemark != ''">and o.remark like concat('%', #{jdRemark}, '%')</if>
<if test="jdThirdPartyOrderNo != null and jdThirdPartyOrderNo != ''">and o.third_party_order_no like concat('%', #{jdThirdPartyOrderNo}, '%')</if>
<if test="modifyTimeBegin != null">and e.modify_time &gt;= #{modifyTimeBegin}</if>
<if test="modifyTimeEnd != null">and e.modify_time &lt;= #{modifyTimeEnd}</if>
<if test="jdLinkFilter != null">
<choose>
<when test="jdLinkFilter == 1">and e.jd_order_id is not null</when>
<when test="jdLinkFilter == 0">and e.jd_order_id is null</when>
</choose>
</if>
</where>
order by e.modify_time desc, e.id desc
</select>

View File

@@ -67,6 +67,7 @@
</if>
<if test="distributionMark != null and distributionMark != ''"> and distribution_mark = #{distributionMark}</if>
<if test="modelNumber != null and modelNumber != ''"> and model_number like concat('%', #{modelNumber}, '%')</if>
<if test="modelNumberExclude != null and modelNumberExclude != ''"> and (model_number is null or model_number not like concat('%', #{modelNumberExclude}, '%'))</if>
<if test="link != null and link != ''"> and link like concat('%', #{link}, '%')</if>
<if test="paymentAmount != null"> and payment_amount = #{paymentAmount}</if>
<if test="rebateAmount != null"> and rebate_amount = #{rebateAmount}</if>
@@ -115,6 +116,7 @@
</if>
<if test="distributionMark != null and distributionMark != ''"> and distribution_mark = #{distributionMark}</if>
<if test="modelNumber != null and modelNumber != ''"> and model_number like concat('%', #{modelNumber}, '%')</if>
<if test="modelNumberExclude != null and modelNumberExclude != ''"> and (model_number is null or model_number not like concat('%', #{modelNumberExclude}, '%'))</if>
<if test="link != null and link != ''"> and link like concat('%', #{link}, '%')</if>
<if test="paymentAmount != null"> and payment_amount = #{paymentAmount}</if>
<if test="rebateAmount != null"> and rebate_amount = #{rebateAmount}</if>
@@ -290,6 +292,26 @@
ORDER BY create_time DESC
</select>
<resultMap id="QuickRecordModelOptionResult" type="com.ruoyi.jarvis.domain.dto.QuickRecordModelOption">
<result property="modelNumber" column="model_number"/>
<result property="lastPaymentAmount" column="last_payment_amount"/>
<result property="lastRebateAmount" column="last_rebate_amount"/>
</resultMap>
<select id="selectQuickRecordModelOptions" resultMap="QuickRecordModelOptionResult">
select o.model_number as model_number,
o.payment_amount as last_payment_amount,
o.rebate_amount as last_rebate_amount
from jd_order o
inner join (
select trim(model_number) as m, max(id) as mid
from jd_order
where model_number is not null and trim(model_number) != ''
group by trim(model_number)
) t on trim(o.model_number) = t.m and o.id = t.mid
order by o.id desc
</select>
</mapper>

View File

@@ -82,4 +82,20 @@
<delete id="deleteByJobKey">
delete from wecom_share_link_logistics_job where job_key = #{jobKey}
</delete>
<select id="selectRecentForInstruction" resultMap="WeComShareLinkLogisticsJobResult">
<include refid="selectVo"/>
where create_time >= date_sub(now(), interval #{days} day)
<if test="remarkKeyword != null and remarkKeyword != ''">
and remark like concat('%', #{remarkKeyword}, '%')
</if>
order by id desc
limit #{limit}
</select>
<delete id="deleteByRemarkAndTrackingUrl">
delete from wecom_share_link_logistics_job
where trim(remark) = trim(#{remark})
and trim(tracking_url) = trim(#{trackingUrl})
</delete>
</mapper>