diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/jarvis/WeComShareLinkLogisticsJobController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/jarvis/WeComShareLinkLogisticsJobController.java new file mode 100644 index 0000000..03d5319 --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/jarvis/WeComShareLinkLogisticsJobController.java @@ -0,0 +1,46 @@ +package com.ruoyi.web.controller.jarvis; + +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.core.page.TableDataInfo; +import com.ruoyi.jarvis.domain.WeComShareLinkLogisticsJob; +import com.ruoyi.jarvis.service.IWeComShareLinkLogisticsJobService; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import javax.annotation.Resource; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/jarvis/wecom/shareLinkLogisticsJob") +public class WeComShareLinkLogisticsJobController extends BaseController { + + @Resource + private IWeComShareLinkLogisticsJobService weComShareLinkLogisticsJobService; + + @PreAuthorize("@ss.hasPermi('jarvis:wecom:shareLinkLog:list')") + @GetMapping("/list") + public TableDataInfo list(WeComShareLinkLogisticsJob query) { + startPage(); + List list = weComShareLinkLogisticsJobService.selectList(query); + return getDataTable(list); + } + + @PreAuthorize("@ss.hasPermi('jarvis:wecom:shareLinkLog:list')") + @GetMapping("/{jobKey}") + public AjaxResult getInfo(@PathVariable("jobKey") String jobKey) { + return success(weComShareLinkLogisticsJobService.selectByJobKey(jobKey)); + } + + @PreAuthorize("@ss.hasPermi('jarvis:wecom:shareLinkLog:import')") + @PostMapping("/backfillFromInboundTrace") + public AjaxResult backfillFromInboundTrace() { + Map r = weComShareLinkLogisticsJobService.backfillImportedFromInboundTrace(); + return success(r); + } +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/jarvis/domain/WeComShareLinkLogisticsJob.java b/ruoyi-system/src/main/java/com/ruoyi/jarvis/domain/WeComShareLinkLogisticsJob.java new file mode 100644 index 0000000..0570a91 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/jarvis/domain/WeComShareLinkLogisticsJob.java @@ -0,0 +1,120 @@ +package com.ruoyi.jarvis.domain; + +import com.ruoyi.common.annotation.Excel; +import com.ruoyi.common.core.domain.BaseEntity; + +/** + * 企微分享链物流任务 wecom_share_link_logistics_job + */ +public class WeComShareLinkLogisticsJob extends BaseEntity { + + private static final long serialVersionUID = 1L; + + private Long id; + + @Excel(name = "任务Key") + private String jobKey; + + @Excel(name = "发送人UserID") + private String fromUserName; + + private String trackingUrl; + + /** 用户填写的备注(表字段 remark,避免与 BaseEntity.remark 混淆) */ + @Excel(name = "用户备注") + private String userRemark; + + @Excel(name = "推送接收人") + private String touserPush; + + @Excel(name = "状态") + private String status; + + @Excel(name = "运单号") + private String waybillNo; + + @Excel(name = "扫描次数") + private Integer scanAttempts; + + private String lastNote; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getJobKey() { + return jobKey; + } + + public void setJobKey(String jobKey) { + this.jobKey = jobKey; + } + + public String getFromUserName() { + return fromUserName; + } + + public void setFromUserName(String fromUserName) { + this.fromUserName = fromUserName; + } + + public String getTrackingUrl() { + return trackingUrl; + } + + public void setTrackingUrl(String trackingUrl) { + this.trackingUrl = trackingUrl; + } + + public String getUserRemark() { + return userRemark; + } + + public void setUserRemark(String userRemark) { + this.userRemark = userRemark; + } + + public String getTouserPush() { + return touserPush; + } + + public void setTouserPush(String touserPush) { + this.touserPush = touserPush; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public String getWaybillNo() { + return waybillNo; + } + + public void setWaybillNo(String waybillNo) { + this.waybillNo = waybillNo; + } + + public Integer getScanAttempts() { + return scanAttempts; + } + + public void setScanAttempts(Integer scanAttempts) { + this.scanAttempts = scanAttempts; + } + + public String getLastNote() { + return lastNote; + } + + public void setLastNote(String lastNote) { + this.lastNote = lastNote; + } +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/jarvis/mapper/WeComInboundTraceMapper.java b/ruoyi-system/src/main/java/com/ruoyi/jarvis/mapper/WeComInboundTraceMapper.java index 81da036..5e38f88 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/jarvis/mapper/WeComInboundTraceMapper.java +++ b/ruoyi-system/src/main/java/com/ruoyi/jarvis/mapper/WeComInboundTraceMapper.java @@ -1,6 +1,7 @@ package com.ruoyi.jarvis.mapper; import com.ruoyi.jarvis.domain.WeComInboundTrace; +import org.apache.ibatis.annotations.Param; import java.util.List; @@ -15,4 +16,15 @@ public interface WeComInboundTraceMapper { int deleteWeComInboundTraceByIds(Long[] ids); int deleteAllWeComInboundTrace(); + + /** + * reply 中含「已加入查询队列」的消息(企微分享链备注提交成功后的被动回复) + */ + List selectTracesShareLinkRemarkDone(@Param("replyMark") String replyMark); + + /** + * 同一用户、更早的一条含 3.cn 的消息(用于与备注消息配对出短链) + */ + WeComInboundTrace selectLatestPriorTraceWith3cnLink(@Param("fromUserName") String fromUserName, + @Param("beforeId") long beforeId); } diff --git a/ruoyi-system/src/main/java/com/ruoyi/jarvis/mapper/WeComShareLinkLogisticsJobMapper.java b/ruoyi-system/src/main/java/com/ruoyi/jarvis/mapper/WeComShareLinkLogisticsJobMapper.java new file mode 100644 index 0000000..d64f537 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/jarvis/mapper/WeComShareLinkLogisticsJobMapper.java @@ -0,0 +1,21 @@ +package com.ruoyi.jarvis.mapper; + +import com.ruoyi.jarvis.domain.WeComShareLinkLogisticsJob; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +public interface WeComShareLinkLogisticsJobMapper { + + int insertWeComShareLinkLogisticsJob(WeComShareLinkLogisticsJob job); + + int updateByJobKey(@Param("jobKey") String jobKey, + @Param("status") String status, + @Param("lastNote") String lastNote, + @Param("scanAttempts") Integer scanAttempts, + @Param("waybillNo") String waybillNo); + + WeComShareLinkLogisticsJob selectByJobKey(String jobKey); + + List selectWeComShareLinkLogisticsJobList(WeComShareLinkLogisticsJob query); +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/ILogisticsService.java b/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/ILogisticsService.java index d9d6c5a..4de2510 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/ILogisticsService.java +++ b/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/ILogisticsService.java @@ -20,8 +20,10 @@ public interface ILogisticsService { /** * 企微备注提交后仅入队,由 {@link com.ruoyi.jarvis.task.LogisticsScanTask} 与订单扫描一并拉取物流,避免阻塞 HTTP 回调。 + * + * @param fromWecomUserId 企微消息 FromUserName,入库监控并在未配 touser 时作为推送目标 */ - void enqueueShareLinkForScan(String trackingUrl, String remark, String touser); + void enqueueShareLinkForScan(String trackingUrl, String remark, String touser, String fromWecomUserId); /** * 定时任务内:依次弹出队列并调用 {@link #fetchLogisticsByShareLinkAndPush}。 diff --git a/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/IWeComShareLinkLogisticsJobService.java b/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/IWeComShareLinkLogisticsJobService.java new file mode 100644 index 0000000..219a6b2 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/IWeComShareLinkLogisticsJobService.java @@ -0,0 +1,19 @@ +package com.ruoyi.jarvis.service; + +import com.ruoyi.jarvis.domain.WeComShareLinkLogisticsJob; + +import java.util.List; +import java.util.Map; + +public interface IWeComShareLinkLogisticsJobService { + + WeComShareLinkLogisticsJob selectByJobKey(String jobKey); + + List selectList(WeComShareLinkLogisticsJob query); + + /** + * 从 wecom_inbound_trace 补录:reply 含「已加入查询队列」且能解析出 3.cn 短链。 + * jobKey 固定为 tracebf{traceId},可重复执行跳过已存在项。 + */ + Map backfillImportedFromInboundTrace(); +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/impl/LogisticsServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/impl/LogisticsServiceImpl.java index 17da29b..be87a62 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/impl/LogisticsServiceImpl.java +++ b/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/impl/LogisticsServiceImpl.java @@ -4,6 +4,8 @@ import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONObject; 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.ILogisticsService; import com.ruoyi.jarvis.service.IJDOrderService; import com.ruoyi.system.service.ISysConfigService; @@ -20,6 +22,7 @@ import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.util.Calendar; import java.util.Date; +import java.util.UUID; import java.util.concurrent.TimeUnit; import org.springframework.util.DigestUtils; @@ -37,6 +40,8 @@ public class LogisticsServiceImpl implements ILogisticsService { private static final String REDIS_ADHOC_WAYBILL_PREFIX = "logistics:adhoc:waybill:"; /** 企微分享链+备注入队,FIFO:RPUSH 入队、LPOP 出队 */ private static final String REDIS_ADHOC_PENDING_QUEUE = "logistics:adhoc:pending:queue"; + /** 单次队列项最多重新入队次数(约对应多天 × 每 10 分钟一轮) */ + private static final int ADHOC_MAX_REQUEUE_ATTEMPTS = 500; private static final String PUSH_URL = "https://wxts.van333.cn/wx/send/pdd"; private static final String PUSH_TOKEN = "super_token_b62190c26"; private static final String CONFIG_KEY_PREFIX = "logistics.push.touser."; @@ -54,6 +59,9 @@ public class LogisticsServiceImpl implements ILogisticsService { @Value("${jarvis.server.logistics.adhoc-pending-batch-size:50}") private int adhocPendingBatchSize; + + @Resource + private WeComShareLinkLogisticsJobMapper weComShareLinkLogisticsJobMapper; private String externalApiUrlTemplate; private String healthCheckUrl; @@ -429,17 +437,34 @@ public class LogisticsServiceImpl implements ILogisticsService { } @Override - public void enqueueShareLinkForScan(String trackingUrl, String remark, String touser) { + public void enqueueShareLinkForScan(String trackingUrl, String remark, String touser, String fromWecomUserId) { if (!StringUtils.hasText(trackingUrl)) { logger.warn("adhoc 入队跳过:分享链接为空"); return; } + String jobKey = UUID.randomUUID().toString().replace("-", ""); JSONObject o = new JSONObject(); + o.put("jobKey", jobKey); + o.put("attempts", 0); o.put("trackingUrl", trackingUrl.trim()); o.put("remark", remark != null ? remark : ""); o.put("touser", touser != null ? touser : ""); + o.put("fromWecom", fromWecomUserId != null ? fromWecomUserId : ""); + try { + WeComShareLinkLogisticsJob row = new WeComShareLinkLogisticsJob(); + row.setJobKey(jobKey); + row.setFromUserName(StringUtils.hasText(fromWecomUserId) ? fromWecomUserId.trim() : null); + row.setTrackingUrl(trackingUrl.trim()); + row.setUserRemark(remark != null ? remark : ""); + row.setTouserPush(touser != null ? touser : ""); + row.setStatus("PENDING"); + row.setScanAttempts(0); + weComShareLinkLogisticsJobMapper.insertWeComShareLinkLogisticsJob(row); + } catch (Exception e) { + logger.warn("adhoc 分享链任务落库失败仍将入队 jobKey={} err={}", jobKey, e.toString()); + } stringRedisTemplate.opsForList().rightPush(REDIS_ADHOC_PENDING_QUEUE, o.toJSONString()); - logger.info("adhoc 分享链接已入队待定时扫描(与订单物流任务一致) trackingUrl={} queueKey={}", trackingUrl.trim(), REDIS_ADHOC_PENDING_QUEUE); + logger.info("adhoc 分享链接已入队 jobKey={} trackingUrl={} queueKey={}", jobKey, trackingUrl.trim(), REDIS_ADHOC_PENDING_QUEUE); } @Override @@ -458,7 +483,23 @@ public class LogisticsServiceImpl implements ILogisticsService { String url = o.getString("trackingUrl"); String remark = o.getString("remark"); String touser = o.getString("touser"); - fetchLogisticsByShareLinkAndPush(url, remark, touser); + String jobKey = o.getString("jobKey"); + int attempts = o.getIntValue("attempts"); + AdhocTryResult tr = tryAdhocShareLinkOnce(url, remark, touser); + if (tr.needsRequeue) { + if (attempts >= ADHOC_MAX_REQUEUE_ATTEMPTS) { + logger.warn("adhoc 已达最大重试次数 {},放弃 jobKey={} url={} note={}", + ADHOC_MAX_REQUEUE_ATTEMPTS, jobKey, url, tr.note); + touchShareLinkJobRow(jobKey, "ABANDONED", tr.note, attempts + 1, null); + continue; + } + o.put("attempts", attempts + 1); + stringRedisTemplate.opsForList().rightPush(REDIS_ADHOC_PENDING_QUEUE, o.toJSONString()); + touchShareLinkJobRow(jobKey, "WAITING", tr.note, attempts + 1, null); + } else { + touchShareLinkJobRow(jobKey, "PUSHED", + tr.note, attempts + 1, StringUtils.hasText(tr.waybillNo) ? tr.waybillNo : null); + } } catch (Exception e) { logger.error("adhoc 队列项处理失败 raw={}", json.length() > 200 ? json.substring(0, 200) + "..." : json, e); } @@ -477,45 +518,67 @@ public class LogisticsServiceImpl implements ILogisticsService { logger.warn("分享物流链接为空"); return false; } + AdhocTryResult tr = tryAdhocShareLinkOnce(trackingUrl, remark, touser); + return !tr.needsRequeue; + } + + private void touchShareLinkJobRow(String jobKey, String status, String lastNote, int scanAttempts, String waybillNo) { + if (!StringUtils.hasText(jobKey)) { + return; + } + try { + weComShareLinkLogisticsJobMapper.updateByJobKey(jobKey.trim(), status, lastNote, scanAttempts, waybillNo); + } catch (Exception e) { + logger.warn("adhoc 任务状态更新失败 jobKey={} err={}", jobKey, e.toString()); + } + } + + /** + * 处理一条分享链:需要重新入队时 {@link #needsRequeue} 为 true(未发货、接口异常、推送失败等)。 + */ + private AdhocTryResult tryAdhocShareLinkOnce(String trackingUrl, String remark, String touser) { + if (!StringUtils.hasText(trackingUrl)) { + return AdhocTryResult.requeue("empty_url"); + } String url = trackingUrl.trim(); String dedupeKey = REDIS_ADHOC_WAYBILL_PREFIX + DigestUtils.md5DigestAsHex(url.getBytes(StandardCharsets.UTF_8)); try { ILogisticsService.HealthCheckResult healthResult = checkHealth(); if (!healthResult.isHealthy()) { - logger.error("物流服务不可用,adhoc 推送跳过: {}", healthResult.getMessage()); - return false; + logger.error("物流服务不可用,adhoc 将重试: {}", healthResult.getMessage()); + return AdhocTryResult.requeue("health:" + healthResult.getMessage()); } String externalUrl = externalApiUrlTemplate + URLEncoder.encode(url, "UTF-8"); String result = HttpUtils.sendGet(externalUrl); if (!StringUtils.hasText(result)) { - logger.warn("物流接口空响应 adhoc"); - return false; + logger.warn("物流接口空响应 adhoc,将重试"); + return AdhocTryResult.requeue("empty_http_body"); } JSONObject parsedData; try { Object parsed = JSON.parse(result); if (!(parsed instanceof JSONObject)) { - return false; + return AdhocTryResult.requeue("not_json_object"); } parsedData = (JSONObject) parsed; } catch (Exception e) { logger.warn("物流响应非JSON adhoc: {}", e.getMessage()); - return false; + return AdhocTryResult.requeue("json_parse:" + e.getMessage()); } JSONObject dataObj = parsedData.getJSONObject("data"); if (dataObj == null) { - return false; + return AdhocTryResult.requeue("no_data_field"); } String waybillNo = dataObj.getString("waybill_no"); if (!StringUtils.hasText(waybillNo)) { - logger.info("adhoc 暂未返回运单号(可能未发货)"); - return false; + logger.info("adhoc 暂未返回运单号(可能未发货),将重新入队"); + return AdhocTryResult.requeue("no_waybill_yet"); } waybillNo = waybillNo.trim(); String existing = stringRedisTemplate.opsForValue().get(dedupeKey); if (existing != null && existing.equals(waybillNo)) { logger.info("adhoc 该链接已推送过运单 {}", waybillNo); - return true; + return AdhocTryResult.done("already_pushed", waybillNo); } StringBuilder pushContent = new StringBuilder(); pushContent.append("【分享链接物流】\n"); @@ -527,17 +590,42 @@ public class LogisticsServiceImpl implements ILogisticsService { pushParam.put("text", pushContent.toString()); if (StringUtils.hasText(touser)) { pushParam.put("touser", touser.trim()); + } else { + logger.info("adhoc 推送未设置接收人 touser,将使用远程接口默认接收人(与订单未配分销接收人时一致)"); } String pushResult = sendPostWithHeaders(PUSH_URL, pushParam.toJSONString(), PUSH_TOKEN); boolean success = isPushResponseSuccess(pushResult); if (success) { stringRedisTemplate.opsForValue().set(dedupeKey, waybillNo, 30, TimeUnit.DAYS); logger.info("adhoc 物流推送成功 waybill={}", waybillNo); + return AdhocTryResult.done("pushed", waybillNo); } - return success; + logger.warn("adhoc 推送接口未成功,将重新入队 waybill={} respSnippet={}", + waybillNo, pushResult != null && pushResult.length() > 120 ? pushResult.substring(0, 120) : pushResult); + return AdhocTryResult.requeue("push_failed"); } catch (Exception e) { - logger.error("adhoc 物流推送异常", e); - return false; + logger.error("adhoc 物流推送异常,将重新入队", e); + return AdhocTryResult.requeue("exception:" + e.getMessage()); + } + } + + private static final class AdhocTryResult { + final boolean needsRequeue; + final String note; + final String waybillNo; + + private AdhocTryResult(boolean needsRequeue, String note, String waybillNo) { + this.needsRequeue = needsRequeue; + this.note = note; + this.waybillNo = waybillNo; + } + + static AdhocTryResult requeue(String note) { + return new AdhocTryResult(true, note, null); + } + + static AdhocTryResult done(String note, String waybillNo) { + return new AdhocTryResult(false, note, waybillNo); } } diff --git a/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/impl/WeComInboundServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/impl/WeComInboundServiceImpl.java index 8575248..5d69ee3 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/impl/WeComInboundServiceImpl.java +++ b/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/impl/WeComInboundServiceImpl.java @@ -130,9 +130,9 @@ public class WeComInboundServiceImpl implements IWeComInboundService { String url = extractJd3cnUrl(content); if (url != null && url.equals(session.getLogisticsUrl())) { weComChatSessionService.delete(from); - String touser = resolveTouser(row, isSuper); + String touser = resolveTouser(row, isSuper, from); log.info("企微物流会话备注(含同款链接)提交 user={} url={} remarkLen={}", from, url, t.length()); - logisticsService.enqueueShareLinkForScan(url, content.trim(), touser); + logisticsService.enqueueShareLinkForScan(url, content.trim(), touser, from); return WeComInboundResult.passiveOnly(replyLogisticsRemarkDone()); } if (url != null) { @@ -145,9 +145,9 @@ public class WeComInboundServiceImpl implements IWeComInboundService { } else { String url = session.getLogisticsUrl(); weComChatSessionService.delete(from); - String touser = resolveTouser(row, isSuper); + String touser = resolveTouser(row, isSuper, from); log.info("企微物流会话提交备注 user={} url={} remarkLen={}", from, url, t.length()); - logisticsService.enqueueShareLinkForScan(url, content.trim(), touser); + logisticsService.enqueueShareLinkForScan(url, content.trim(), touser, from); return WeComInboundResult.passiveOnly(replyLogisticsRemarkDone()); } } @@ -225,7 +225,11 @@ public class WeComInboundServiceImpl implements IWeComInboundService { return null; } - private String resolveTouser(SuperAdmin row, boolean isSuper) { + /** + * 分享链推送目标:超级管理员表中的 touser(可多选)优先;否则回退为发消息成员的企微 UserID。 + * 仅配 wxid 未配 touser 时,过去会导致请求不带接收人、依赖远端默认路由,易出现「看不到推送」。 + */ + private String resolveTouser(SuperAdmin row, boolean isSuper, String fromWecomUserId) { if (row != null && StringUtils.hasText(row.getTouser())) { return row.getTouser().trim(); } @@ -235,6 +239,9 @@ public class WeComInboundServiceImpl implements IWeComInboundService { return ping.getTouser().trim(); } } + if (StringUtils.hasText(fromWecomUserId)) { + return fromWecomUserId.trim(); + } return null; } } diff --git a/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/impl/WeComShareLinkLogisticsJobServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/impl/WeComShareLinkLogisticsJobServiceImpl.java new file mode 100644 index 0000000..445ef02 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/impl/WeComShareLinkLogisticsJobServiceImpl.java @@ -0,0 +1,125 @@ +package com.ruoyi.jarvis.service.impl; + +import com.ruoyi.jarvis.domain.WeComInboundTrace; +import com.ruoyi.jarvis.domain.WeComShareLinkLogisticsJob; +import com.ruoyi.jarvis.mapper.WeComInboundTraceMapper; +import com.ruoyi.jarvis.mapper.WeComShareLinkLogisticsJobMapper; +import com.ruoyi.jarvis.service.IWeComShareLinkLogisticsJobService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import javax.annotation.Resource; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@Service +public class WeComShareLinkLogisticsJobServiceImpl implements IWeComShareLinkLogisticsJobService { + + private static final Logger log = LoggerFactory.getLogger(WeComShareLinkLogisticsJobServiceImpl.class); + + /** 与 WeComInboundServiceImpl.replyLogisticsRemarkDone 中文案一致 */ + private static final String REPLY_MARK_SHARE_LINK_DONE = "已加入查询队列"; + + private static final Pattern JD_3CN_HTTPS = Pattern.compile("https://3\\.cn/[A-Za-z0-9\\-]+"); + private static final Pattern JD_3CN_HTTP = Pattern.compile("http://3\\.cn/[A-Za-z0-9\\-]+"); + + @Resource + private WeComShareLinkLogisticsJobMapper weComShareLinkLogisticsJobMapper; + @Resource + private WeComInboundTraceMapper weComInboundTraceMapper; + + @Override + public WeComShareLinkLogisticsJob selectByJobKey(String jobKey) { + return weComShareLinkLogisticsJobMapper.selectByJobKey(jobKey); + } + + @Override + public List selectList(WeComShareLinkLogisticsJob query) { + return weComShareLinkLogisticsJobMapper.selectWeComShareLinkLogisticsJobList(query); + } + + @Override + public Map backfillImportedFromInboundTrace() { + int imported = 0; + int skippedDuplicate = 0; + int skippedNoUrl = 0; + int scanned = 0; + + List traces = weComInboundTraceMapper.selectTracesShareLinkRemarkDone(REPLY_MARK_SHARE_LINK_DONE); + if (traces != null) { + for (WeComInboundTrace t : traces) { + if (t == null || t.getId() == null || !StringUtils.hasText(t.getFromUserName())) { + continue; + } + scanned++; + String jobKey = "tracebf" + t.getId(); + if (weComShareLinkLogisticsJobMapper.selectByJobKey(jobKey) != null) { + skippedDuplicate++; + continue; + } + String url = extractJd3cnUrl(t.getContent()); + if (url == null) { + WeComInboundTrace prior = weComInboundTraceMapper.selectLatestPriorTraceWith3cnLink( + t.getFromUserName(), t.getId()); + if (prior != null) { + url = extractJd3cnUrl(prior.getContent()); + } + } + if (!StringUtils.hasText(url)) { + skippedNoUrl++; + log.debug("补录跳过 traceId={} 无可用 3.cn", t.getId()); + continue; + } + + WeComShareLinkLogisticsJob row = new WeComShareLinkLogisticsJob(); + row.setJobKey(jobKey); + row.setFromUserName(t.getFromUserName().trim()); + row.setTrackingUrl(url.trim()); + String remark = t.getContent() != null ? t.getContent() : ""; + row.setUserRemark(remark); + row.setTouserPush(t.getFromUserName().trim()); + row.setStatus("IMPORTED"); + row.setScanAttempts(0); + row.setLastNote("from_trace_id=" + t.getId()); + if (t.getCreateTime() != null) { + row.setCreateTime(t.getCreateTime()); + row.setUpdateTime(t.getCreateTime()); + } + try { + weComShareLinkLogisticsJobMapper.insertWeComShareLinkLogisticsJob(row); + imported++; + } catch (Exception e) { + log.warn("补录插入失败 traceId={} err={}", t.getId(), e.toString()); + } + } + } + + Map r = new LinkedHashMap<>(); + r.put("scannedRemarkDoneRows", scanned); + r.put("imported", imported); + r.put("skippedDuplicate", skippedDuplicate); + r.put("skippedNoUrl", skippedNoUrl); + r.put("hint", "IMPORTED 表示仅从企微消息追踪补录,运单与是否已推送以当时为准,可与 trace_id 对照 wecom_inbound_trace"); + return r; + } + + private static String extractJd3cnUrl(String text) { + if (!StringUtils.hasText(text)) { + return null; + } + Matcher m = JD_3CN_HTTPS.matcher(text); + if (m.find()) { + return m.group(); + } + Matcher m2 = JD_3CN_HTTP.matcher(text); + if (m2.find()) { + return m2.group(); + } + return null; + } +} diff --git a/ruoyi-system/src/main/resources/mapper/jarvis/WeComInboundTraceMapper.xml b/ruoyi-system/src/main/resources/mapper/jarvis/WeComInboundTraceMapper.xml index cb93933..85e0ad3 100644 --- a/ruoyi-system/src/main/resources/mapper/jarvis/WeComInboundTraceMapper.xml +++ b/ruoyi-system/src/main/resources/mapper/jarvis/WeComInboundTraceMapper.xml @@ -65,4 +65,19 @@ delete from wecom_inbound_trace + + + + diff --git a/ruoyi-system/src/main/resources/mapper/jarvis/WeComShareLinkLogisticsJobMapper.xml b/ruoyi-system/src/main/resources/mapper/jarvis/WeComShareLinkLogisticsJobMapper.xml new file mode 100644 index 0000000..c3293ea --- /dev/null +++ b/ruoyi-system/src/main/resources/mapper/jarvis/WeComShareLinkLogisticsJobMapper.xml @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + select id, job_key, from_user_name, tracking_url, remark, touser_push, status, waybill_no, + scan_attempts, last_note, create_time, update_time + from wecom_share_link_logistics_job + + + + insert into wecom_share_link_logistics_job ( + job_key, from_user_name, tracking_url, remark, touser_push, status, scan_attempts, last_note + , create_time, update_time + ) values ( + #{jobKey}, #{fromUserName}, #{trackingUrl}, #{userRemark}, #{touserPush}, #{status}, + #{scanAttempts}, #{lastNote} + , #{createTime}, #{createTime} + ) + + + + update wecom_share_link_logistics_job + + status = #{status}, + last_note = #{lastNote}, + scan_attempts = #{scanAttempts}, + waybill_no = #{waybillNo}, + update_time = now() + + where job_key = #{jobKey} + + + + + + diff --git a/sql/wecom_share_link_logistics_job.sql b/sql/wecom_share_link_logistics_job.sql new file mode 100644 index 0000000..e82bfab --- /dev/null +++ b/sql/wecom_share_link_logistics_job.sql @@ -0,0 +1,30 @@ +-- 企微「3.cn 分享链 + 备注」入队后的扫描/推送任务(便于排查未推送原因) +CREATE TABLE IF NOT EXISTS `wecom_share_link_logistics_job` ( + `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键', + `job_key` varchar(64) NOT NULL COMMENT '与 Redis 队列 JSON 中 jobKey 一致', + `from_user_name` varchar(128) DEFAULT NULL COMMENT '发消息的企微 UserID', + `tracking_url` varchar(768) NOT NULL COMMENT '3.cn 物流短链', + `remark` mediumtext COMMENT '用户备注', + `touser_push` varchar(512) DEFAULT NULL COMMENT '解析后的推送接收人(企微成员 UserID,多个逗号分隔)', + `status` varchar(32) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING/WAITING/PUSHED/ABANDONED/IMPORTED', + `waybill_no` varchar(128) DEFAULT NULL COMMENT '成功解析并推送后的运单号', + `scan_attempts` int(11) NOT NULL DEFAULT 0 COMMENT '已扫描次数(含重新入队)', + `last_note` varchar(512) DEFAULT NULL COMMENT '最近一次处理说明', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_job_key` (`job_key`), + KEY `idx_status` (`status`), + KEY `idx_from_user` (`from_user_name`), + KEY `idx_create_time` (`create_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='企微分享链物流扫描任务'; + +-- 菜单(与 wecom_inbound_trace 并列);menu_id 若冲突请在库中改大号 +INSERT INTO sys_menu VALUES ( + 2094, '企微分享链物流', 2, 9, 'wecomShareLinkLogistics', 'jarvis/wecomShareLinkLogistics/index', '', '', 1, 0, 'C', '0', '0', + 'jarvis:wecom:shareLinkLog:list', 'guide', 'admin', sysdate(), '', NULL, '监控企微录入的 3.cn 物流任务与推送状态' +); +INSERT INTO sys_menu VALUES ( + 2096, '从追踪补录历史', 2094, 1, '#', '', '', '', 1, 0, 'F', '0', '0', + 'jarvis:wecom:shareLinkLog:import', '#', 'admin', sysdate(), '', NULL, '从 wecom_inbound_trace 补录 IMPORTED 行' +); diff --git a/sql/wecom_share_link_logistics_job_backfill_historical.sql b/sql/wecom_share_link_logistics_job_backfill_historical.sql new file mode 100644 index 0000000..6043324 --- /dev/null +++ b/sql/wecom_share_link_logistics_job_backfill_historical.sql @@ -0,0 +1,48 @@ +-- --------------------------------------------------------------------------- +-- 历史补录(可选):从 wecom_inbound_trace 批量写入 wecom_share_link_logistics_job +-- +-- 优先使用后台「企微分享链物流」页「从追踪补录历史」按钮(Java 与线上一致,支持 http/https、备注行含链等)。 +-- +-- 需 MySQL 8.0.14+(LATERAL、REGEXP_SUBSTR)。可重复执行(按 job_key 去重)。 +-- --------------------------------------------------------------------------- + +INSERT INTO wecom_share_link_logistics_job ( + job_key, from_user_name, tracking_url, remark, touser_push, status, scan_attempts, last_note, + create_time, update_time +) +SELECT + CONCAT('tracebf', t.id), + t.from_user_name, + COALESCE( + NULLIF(REGEXP_SUBSTR(t.content, 'https://3\\.cn/[A-Za-z0-9\\-]+'), ''), + NULLIF(REGEXP_SUBSTR(t.content, 'http://3\\.cn/[A-Za-z0-9\\-]+'), ''), + NULLIF(REGEXP_SUBSTR(p.content, 'https://3\\.cn/[A-Za-z0-9\\-]+'), ''), + NULLIF(REGEXP_SUBSTR(p.content, 'http://3\\.cn/[A-Za-z0-9\\-]+'), '') + ), + t.content, + t.from_user_name, + 'IMPORTED', + 0, + CONCAT('from_trace_id=', t.id, '|sql_batch'), + t.create_time, + t.create_time +FROM wecom_inbound_trace t +LEFT JOIN LATERAL ( + SELECT p0.content + FROM wecom_inbound_trace p0 + WHERE p0.from_user_name = t.from_user_name + AND p0.id < t.id + AND (p0.content LIKE '%https://3.cn/%' OR p0.content LIKE '%http://3.cn/%') + ORDER BY p0.id DESC + LIMIT 1 +) AS p ON TRUE +WHERE t.reply_content LIKE '%已加入查询队列%' + AND COALESCE( + NULLIF(REGEXP_SUBSTR(t.content, 'https://3\\.cn/[A-Za-z0-9\\-]+'), ''), + NULLIF(REGEXP_SUBSTR(t.content, 'http://3\\.cn/[A-Za-z0-9\\-]+'), ''), + NULLIF(REGEXP_SUBSTR(p.content, 'https://3\\.cn/[A-Za-z0-9\\-]+'), ''), + NULLIF(REGEXP_SUBSTR(p.content, 'http://3\\.cn/[A-Za-z0-9\\-]+'), '') + ) IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM wecom_share_link_logistics_job e WHERE e.job_key = CONCAT('tracebf', t.id) + ); diff --git a/sql/wecom_share_link_logistics_job_patch_menu_import.sql b/sql/wecom_share_link_logistics_job_patch_menu_import.sql new file mode 100644 index 0000000..b9a5760 --- /dev/null +++ b/sql/wecom_share_link_logistics_job_patch_menu_import.sql @@ -0,0 +1,7 @@ +-- 若已执行过 wecom_share_link_logistics_job.sql 但无「从追踪补录」按钮,单独执行本文件。 +-- menu_id 2096 若冲突请改未占用 ID;parent_id 2094 为「企微分享链物流」菜单 ID,若不同请改。 + +INSERT INTO sys_menu VALUES ( + 2096, '从追踪补录历史', 2094, 1, '#', '', '', '', 1, 0, 'F', '0', '0', + 'jarvis:wecom:shareLinkLog:import', '#', 'admin', sysdate(), '', NULL, '从 wecom_inbound_trace 补录 IMPORTED 行' +);