diff --git a/ruoyi-admin/src/main/java/com/ruoyi/jarvis/wecom/PhoneForwardActivePushImpl.java b/ruoyi-admin/src/main/java/com/ruoyi/jarvis/wecom/PhoneForwardActivePushImpl.java
new file mode 100644
index 0000000..7d44124
--- /dev/null
+++ b/ruoyi-admin/src/main/java/com/ruoyi/jarvis/wecom/PhoneForwardActivePushImpl.java
@@ -0,0 +1,18 @@
+package com.ruoyi.jarvis.wecom;
+
+import java.util.List;
+import javax.annotation.Resource;
+import org.springframework.stereotype.Component;
+import com.ruoyi.jarvis.service.IPhoneForwardActivePush;
+
+@Component
+public class PhoneForwardActivePushImpl implements IPhoneForwardActivePush {
+
+ @Resource
+ private WxSendWeComPushClient wxSendWeComPushClient;
+
+ @Override
+ public void schedulePushChunks(String toUser, List chunks) {
+ wxSendWeComPushClient.scheduleActivePushes(toUser, chunks);
+ }
+}
diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/jarvis/TgScalperPhoneController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/jarvis/TgScalperPhoneController.java
new file mode 100644
index 0000000..627daf7
--- /dev/null
+++ b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/jarvis/TgScalperPhoneController.java
@@ -0,0 +1,82 @@
+package com.ruoyi.web.controller.jarvis;
+
+import java.util.List;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.DeleteMapping;
+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.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+import com.ruoyi.common.annotation.Log;
+import com.ruoyi.common.core.controller.BaseController;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.core.page.TableDataInfo;
+import com.ruoyi.common.enums.BusinessType;
+import com.ruoyi.common.utils.poi.ExcelUtil;
+import com.ruoyi.jarvis.domain.TgScalperPhone;
+import com.ruoyi.jarvis.service.ITgScalperPhoneService;
+
+/**
+ * TG 管理 - 黄牛电话库
+ */
+@RestController
+@RequestMapping("/jarvis/tgScalperPhone")
+public class TgScalperPhoneController extends BaseController
+{
+ @Autowired
+ private ITgScalperPhoneService tgScalperPhoneService;
+
+ @PreAuthorize("@ss.hasPermi('jarvis:tg:scalperPhone:list')")
+ @GetMapping("/list")
+ public TableDataInfo list(TgScalperPhone q)
+ {
+ startPage();
+ List list = tgScalperPhoneService.selectTgScalperPhoneList(q);
+ return getDataTable(list);
+ }
+
+ @PreAuthorize("@ss.hasPermi('jarvis:tg:scalperPhone:export')")
+ @Log(title = "TG黄牛电话库", businessType = BusinessType.EXPORT)
+ @GetMapping("/export")
+ public AjaxResult export(TgScalperPhone q)
+ {
+ List list = tgScalperPhoneService.selectTgScalperPhoneList(q);
+ ExcelUtil util = new ExcelUtil<>(TgScalperPhone.class);
+ return util.exportExcel(list, "TG黄牛电话库");
+ }
+
+ @PreAuthorize("@ss.hasPermi('jarvis:tg:scalperPhone:query')")
+ @GetMapping(value = "/{id}")
+ public AjaxResult getInfo(@PathVariable("id") Long id)
+ {
+ return success(tgScalperPhoneService.selectTgScalperPhoneById(id));
+ }
+
+ @PreAuthorize("@ss.hasPermi('jarvis:tg:scalperPhone:add')")
+ @Log(title = "TG黄牛电话库", businessType = BusinessType.INSERT)
+ @PostMapping
+ public AjaxResult add(@RequestBody TgScalperPhone row)
+ {
+ return toAjax(tgScalperPhoneService.insertTgScalperPhone(row));
+ }
+
+ @PreAuthorize("@ss.hasPermi('jarvis:tg:scalperPhone:edit')")
+ @Log(title = "TG黄牛电话库", businessType = BusinessType.UPDATE)
+ @PutMapping
+ public AjaxResult edit(@RequestBody TgScalperPhone row)
+ {
+ return toAjax(tgScalperPhoneService.updateTgScalperPhone(row));
+ }
+
+ @PreAuthorize("@ss.hasPermi('jarvis:tg:scalperPhone:remove')")
+ @Log(title = "TG黄牛电话库", businessType = BusinessType.DELETE)
+ @DeleteMapping("/{ids}")
+ public AjaxResult remove(@PathVariable Long[] ids)
+ {
+ return toAjax(tgScalperPhoneService.deleteTgScalperPhoneByIds(ids));
+ }
+}
diff --git a/ruoyi-admin/src/main/resources/application-dev.yml b/ruoyi-admin/src/main/resources/application-dev.yml
index b580ad1..1a35247 100644
--- a/ruoyi-admin/src/main/resources/application-dev.yml
+++ b/ruoyi-admin/src/main/resources/application-dev.yml
@@ -244,6 +244,13 @@ jarvis:
# 连续失败后熔断,不再发起 HTTP(与 tg_bridge 侧熔断互不替代)
circuit-failure-threshold: 5
circuit-open-ms: 120000
+ # 先查库 jarvis_tg_scalper_phone(启用+有备注),命中则不请求 tg_bridge
+ scalper-library-enabled: true
+ # 被动立即回「收到电话」,TG 结果经 wxSend /wecom/active-push 推送
+ async-result-push-enabled: true
+ # 同一企微 MsgId(wxSend 已透传)在 TTL 内复用首次成功结果,防重试重复计费;错误文案不缓存
+ dedup-enabled: true
+ dedup-ttl-seconds: 900
# reply_take_nth:仅「开」用 2;「慢开」由 tg_bridge reply_adaptive_skip_middle_ad 在 2/3 条间自适应
# Ollama 大模型服务(监控健康度调试用)
ollama:
diff --git a/ruoyi-admin/src/main/resources/application-prod.yml b/ruoyi-admin/src/main/resources/application-prod.yml
index 2d505b2..6ff413e 100644
--- a/ruoyi-admin/src/main/resources/application-prod.yml
+++ b/ruoyi-admin/src/main/resources/application-prod.yml
@@ -231,6 +231,10 @@ jarvis:
lock-acquire-timeout-ms: 180000
circuit-failure-threshold: 5
circuit-open-ms: 120000
+ scalper-library-enabled: true
+ async-result-push-enabled: true
+ dedup-enabled: true
+ dedup-ttl-seconds: 900
# 「开」取第 2 条;「慢开」由桥接自适应第 2/3 条
# Ollama 大模型服务(监控健康度调试用)
ollama:
diff --git a/ruoyi-system/src/main/java/com/ruoyi/jarvis/domain/TgScalperPhone.java b/ruoyi-system/src/main/java/com/ruoyi/jarvis/domain/TgScalperPhone.java
new file mode 100644
index 0000000..bcc1661
--- /dev/null
+++ b/ruoyi-system/src/main/java/com/ruoyi/jarvis/domain/TgScalperPhone.java
@@ -0,0 +1,81 @@
+package com.ruoyi.jarvis.domain;
+
+import com.ruoyi.common.annotation.Excel;
+import com.ruoyi.common.core.domain.BaseEntity;
+import org.apache.commons.lang3.builder.ToStringBuilder;
+import org.apache.commons.lang3.builder.ToStringStyle;
+
+/**
+ * TG 黄牛电话库 jarvis_tg_scalper_phone
+ */
+public class TgScalperPhone extends BaseEntity
+{
+ private static final long serialVersionUID = 1L;
+
+ private Long id;
+
+ /** 11位手机号 */
+ @Excel(name = "手机号")
+ private String phone;
+
+ /** 命中时直接回复企微的备注 */
+ @Excel(name = "备注")
+ private String remark;
+
+ /** 0禁用 1启用 */
+ @Excel(name = "状态", readConverterExp = "0=禁用,1=启用")
+ private Integer status;
+
+ public Long getId()
+ {
+ return id;
+ }
+
+ public void setId(Long id)
+ {
+ this.id = id;
+ }
+
+ public String getPhone()
+ {
+ return phone;
+ }
+
+ public void setPhone(String phone)
+ {
+ this.phone = phone;
+ }
+
+ public String getRemark()
+ {
+ return remark;
+ }
+
+ public void setRemark(String remark)
+ {
+ this.remark = remark;
+ }
+
+ public Integer getStatus()
+ {
+ return status;
+ }
+
+ public void setStatus(Integer status)
+ {
+ this.status = status;
+ }
+
+ @Override
+ public String toString()
+ {
+ return new ToStringBuilder(this, ToStringStyle.MULTI_LINE_STYLE)
+ .append("id", getId())
+ .append("phone", getPhone())
+ .append("remark", getRemark())
+ .append("status", getStatus())
+ .append("createTime", getCreateTime())
+ .append("updateTime", getUpdateTime())
+ .toString();
+ }
+}
diff --git a/ruoyi-system/src/main/java/com/ruoyi/jarvis/mapper/TgScalperPhoneMapper.java b/ruoyi-system/src/main/java/com/ruoyi/jarvis/mapper/TgScalperPhoneMapper.java
new file mode 100644
index 0000000..6fde8aa
--- /dev/null
+++ b/ruoyi-system/src/main/java/com/ruoyi/jarvis/mapper/TgScalperPhoneMapper.java
@@ -0,0 +1,28 @@
+package com.ruoyi.jarvis.mapper;
+
+import java.util.List;
+import com.ruoyi.jarvis.domain.TgScalperPhone;
+
+/**
+ * TG 黄牛电话库 Mapper
+ */
+public interface TgScalperPhoneMapper
+{
+ TgScalperPhone selectTgScalperPhoneById(Long id);
+
+ List selectTgScalperPhoneList(TgScalperPhone q);
+
+ /** 按手机号查(不限状态,供唯一性校验) */
+ TgScalperPhone selectTgScalperPhoneByPhone(String phone);
+
+ /** 启用状态下按手机号查(开/慢走前置) */
+ TgScalperPhone selectEnabledByPhone(String phone);
+
+ int insertTgScalperPhone(TgScalperPhone row);
+
+ int updateTgScalperPhone(TgScalperPhone row);
+
+ int deleteTgScalperPhoneById(Long id);
+
+ int deleteTgScalperPhoneByIds(Long[] ids);
+}
diff --git a/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/IPhoneForwardActivePush.java b/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/IPhoneForwardActivePush.java
new file mode 100644
index 0000000..74db217
--- /dev/null
+++ b/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/IPhoneForwardActivePush.java
@@ -0,0 +1,18 @@
+package com.ruoyi.jarvis.service;
+
+import java.util.List;
+
+/**
+ * 「开/慢开」TG 查询完成后,经 wxSend 主动推送文本(已按企微单条上限切分)。
+ * 实现类在 ruoyi-admin,委托 WxSendWeComPushClient。
+ */
+public interface IPhoneForwardActivePush {
+
+ /**
+ * 异步排队推送到企微成员
+ *
+ * @param toUser 成员 UserID(FromUserName)
+ * @param chunks 每段不超过 UTF-8 2048 字节的正文
+ */
+ void schedulePushChunks(String toUser, List chunks);
+}
diff --git a/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/ITgScalperPhoneService.java b/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/ITgScalperPhoneService.java
new file mode 100644
index 0000000..abd8f21
--- /dev/null
+++ b/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/ITgScalperPhoneService.java
@@ -0,0 +1,25 @@
+package com.ruoyi.jarvis.service;
+
+import java.util.List;
+import com.ruoyi.jarvis.domain.TgScalperPhone;
+
+/**
+ * TG 黄牛电话库
+ */
+public interface ITgScalperPhoneService
+{
+ TgScalperPhone selectTgScalperPhoneById(Long id);
+
+ List selectTgScalperPhoneList(TgScalperPhone q);
+
+ /** 启用状态命中(供 phone-forward 前置) */
+ TgScalperPhone selectEnabledByPhone(String phone);
+
+ int insertTgScalperPhone(TgScalperPhone row);
+
+ int updateTgScalperPhone(TgScalperPhone row);
+
+ int deleteTgScalperPhoneByIds(Long[] ids);
+
+ int deleteTgScalperPhoneById(Long id);
+}
diff --git a/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/impl/OpenPhoneForwardService.java b/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/impl/OpenPhoneForwardService.java
index 2083df4..dbc53b1 100644
--- a/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/impl/OpenPhoneForwardService.java
+++ b/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/impl/OpenPhoneForwardService.java
@@ -3,10 +3,19 @@ package com.ruoyi.jarvis.service.impl;
import com.alibaba.fastjson2.JSONObject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import javax.annotation.Resource;
+
+import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
+import com.ruoyi.jarvis.domain.TgScalperPhone;
+import com.ruoyi.jarvis.domain.dto.WeComInboundRequest;
+import com.ruoyi.jarvis.service.IPhoneForwardActivePush;
+import com.ruoyi.jarvis.service.ITgScalperPhoneService;
+import com.ruoyi.jarvis.util.WeComUtf8ChunkUtil;
+
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
@@ -15,6 +24,11 @@ import java.net.URL;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
@@ -30,6 +44,12 @@ import java.util.regex.Pattern;
* 在同一会话内多次收取(仅一次发送 query),按第 2 条是否已为结果决定在 2/3 条间取值,避免重复计费。
* 「慢开」返回仍会去掉尾部固定推广行。
*
+ *
+ * 计费幂等:与 {@link java.util.concurrent.locks.ReentrantLock} 只能串行化并发、不能防止企微对同一 {@code MsgId} 的重试。
+ * 开启 {@code jarvis.phone-forward.dedup-enabled} 后,对同一 msgId(wxSend 已传入)在 TTL 内复用首次成功结果,
+ * 并在获取锁后再次查缓存,避免「上一请求已付费返回、排队中的重试」再次调用 Telegram。
+ * 开启 {@code jarvis.phone-forward.async-result-push-enabled} 时,被动立即回复「收到电话:…」,TG 结果经 wxSend 主动推送。
+ *
*/
@Service
public class OpenPhoneForwardService {
@@ -96,17 +116,63 @@ public class OpenPhoneForwardService {
@Value("${jarvis.phone-forward.circuit-open-ms:120000}")
private long circuitOpenMs;
+ /** 为 true 时先查黄牛电话库,命中且备注非空则不再请求 Telegram */
+ @Value("${jarvis.phone-forward.scalper-library-enabled:true}")
+ private boolean scalperLibraryEnabled;
+
+ /**
+ * 同一企微 MsgId 在 TTL 内只走一次付费转发(成功结果缓存;错误文案不缓存以便用户重试)
+ */
+ @Value("${jarvis.phone-forward.dedup-enabled:true}")
+ private boolean dedupEnabled;
+
+ @Value("${jarvis.phone-forward.dedup-ttl-seconds:900}")
+ private int dedupTtlSeconds;
+
+ /**
+ * 为 true 时:立即被动回复「收到电话」,TG 在后台查询并通过 wxSend 主动推送全文
+ */
+ @Value("${jarvis.phone-forward.async-result-push-enabled:true}")
+ private boolean asyncResultPushEnabled;
+
+ @Resource
+ private ITgScalperPhoneService tgScalperPhoneService;
+
+ @Autowired(required = false)
+ private IPhoneForwardActivePush phoneForwardActivePush;
+
/** 仅允许单飞:所有 phone-forward 请求串行 */
private final ReentrantLock tgBridgeCallLock = new ReentrantLock(true);
+ /** 异步 TG 查询进行中(与幂等 dedup 使用同一关联键) */
+ private final ConcurrentHashMap forwardInflightUntilMs = new ConcurrentHashMap<>();
+
+ private static final class DedupEntry {
+ final String reply;
+ final long expireAtMs;
+
+ DedupEntry(String reply, long expireAtMs) {
+ this.reply = reply;
+ this.expireAtMs = expireAtMs;
+ }
+ }
+
+ /** key → 已成功返回给用户的正文(含黄牛库与 TG) */
+ private final ConcurrentHashMap forwardReplyDedup = new ConcurrentHashMap<>();
+
private final AtomicInteger circuitFailureCount = new AtomicInteger(0);
/** 熔断恢复时间(epoch ms),0 表示未熔断 */
private final AtomicLong circuitOpenUntilMs = new AtomicLong(0);
/**
+ * @param req 须含 content;msgId 由 wxSend 透传时可用于幂等防重复计费
* @return 非 null 表示本条消息已由本服务处理(含错误提示);null 表示不匹配规则
*/
- public String tryReply(String rawContent) {
+ public String tryReply(WeComInboundRequest req) {
+ if (req == null) {
+ return null;
+ }
+ String rawContent = req.getContent();
if (!enabled || !StringUtils.hasText(baseUrl) || rawContent == null) {
return null;
}
@@ -123,7 +189,115 @@ public class OpenPhoneForwardService {
if (phone == null) {
return null;
}
- return doForward(phone, bot);
+ final String cKey = resolveCorrelationKey(req, text, phone, bot);
+
+ if (dedupEnabled) {
+ String cached = dedupGet(cKey);
+ if (cached != null) {
+ log.info("phone-forward 幂等命中 key={} phone={} bot={}", cKey, phone, bot);
+ if (asyncResultPushEnabled) {
+ return "该消息已查询过,结果已通过应用消息推送,请在会话中查看。";
+ }
+ return cached;
+ }
+ }
+
+ if (asyncResultPushEnabled && isForwardInflight(cKey)) {
+ return "查询进行中,请稍候查看应用消息。";
+ }
+
+ if (scalperLibraryEnabled) {
+ TgScalperPhone hit = tgScalperPhoneService.selectEnabledByPhone(phone);
+ if (hit != null && StringUtils.hasText(hit.getRemark())) {
+ String remark = hit.getRemark().trim();
+ log.info("phone-forward 黄牛电话库命中 phone={},跳过 Telegram", phone);
+ dedupPut(cKey, remark);
+ return remark;
+ }
+ }
+
+ if (asyncResultPushEnabled && phoneForwardActivePush != null && StringUtils.hasText(req.getFromUserName())) {
+ markForwardInflight(cKey);
+ final String toUser = req.getFromUserName().trim();
+ CompletableFuture.runAsync(() -> runForwardAndPush(toUser, phone, bot, cKey));
+ return String.format("收到电话:%s。\n后续结果将通过应用消息推送。", phone);
+ }
+
+ return doForward(phone, bot, dedupEnabled ? cKey : null);
+ }
+
+ private static String resolveCorrelationKey(WeComInboundRequest req, String text, String phone, String bot) {
+ String msgId = req.getMsgId() != null ? req.getMsgId().trim() : "";
+ if (StringUtils.hasText(msgId)) {
+ return "pf:msg:" + msgId;
+ }
+ String from = req.getFromUserName() != null ? req.getFromUserName().trim() : "";
+ return "pf:h:" + from + ":" + Integer.toHexString(Objects.hash(text, phone, bot));
+ }
+
+ private boolean isForwardInflight(String cKey) {
+ Long until = forwardInflightUntilMs.get(cKey);
+ if (until == null) {
+ return false;
+ }
+ if (System.currentTimeMillis() > until) {
+ forwardInflightUntilMs.remove(cKey, until);
+ return false;
+ }
+ return true;
+ }
+
+ private void markForwardInflight(String cKey) {
+ long until = System.currentTimeMillis() + Math.max(120_000L, (long) dedupTtlSeconds * 1000L);
+ forwardInflightUntilMs.put(cKey, until);
+ }
+
+ private void clearForwardInflight(String cKey) {
+ forwardInflightUntilMs.remove(cKey);
+ }
+
+ private void runForwardAndPush(String toUser, String phone, String bot, String cKey) {
+ try {
+ String reply = doForward(phone, bot, dedupEnabled ? cKey : null);
+ List chunks = WeComUtf8ChunkUtil.splitUtf8Chunks(reply, WeComUtf8ChunkUtil.WE_COM_TEXT_MAX_UTF8_BYTES);
+ chunks.removeIf(s -> !StringUtils.hasText(s));
+ if (chunks.isEmpty()) {
+ chunks = Collections.singletonList("(无返回内容)");
+ }
+ phoneForwardActivePush.schedulePushChunks(toUser, chunks);
+ } catch (Exception e) {
+ log.warn("phone-forward 异步推送异常 phone={} err={}", phone, e.toString());
+ } finally {
+ clearForwardInflight(cKey);
+ }
+ }
+
+ private String dedupGet(String key) {
+ if (!dedupEnabled || key == null) {
+ return null;
+ }
+ DedupEntry e = forwardReplyDedup.get(key);
+ if (e == null) {
+ return null;
+ }
+ if (System.currentTimeMillis() > e.expireAtMs) {
+ forwardReplyDedup.remove(key, e);
+ return null;
+ }
+ return e.reply;
+ }
+
+ private void dedupPut(String key, String reply) {
+ if (key == null || !dedupEnabled || !StringUtils.hasText(reply) || !shouldCacheReplyForDedup(reply)) {
+ return;
+ }
+ long ttlMs = Math.max(60_000L, (long) dedupTtlSeconds * 1000L);
+ forwardReplyDedup.put(key, new DedupEntry(reply, System.currentTimeMillis() + ttlMs));
+ }
+
+ /** 失败提示不缓存,避免用户无法通过重试恢复 */
+ private static boolean shouldCacheReplyForDedup(String reply) {
+ return !reply.startsWith("「转发服务」");
}
private boolean isCircuitOpen() {
@@ -165,7 +339,10 @@ public class OpenPhoneForwardService {
return null;
}
- private String doForward(String phone, String bot) {
+ /**
+ * @param dedupKey 非空且开启幂等时:持锁后再次查缓存,避免首请求已付费、重试排队后二次请求 Telegram
+ */
+ private String doForward(String phone, String bot, String dedupKey) {
if (isCircuitOpen()) {
log.warn("phone-forward 熔断拒绝 phone={} bot={}", phone, bot);
return "「转发服务」暂时不可用(连续失败保护中),请一分钟后再试。";
@@ -185,7 +362,16 @@ public class OpenPhoneForwardService {
return "「转发服务」正忙(上一条查询尚未结束),请稍后再试。";
}
}
- return doForwardUnsynchronized(phone, bot);
+ if (dedupKey != null) {
+ String again = dedupGet(dedupKey);
+ if (again != null) {
+ log.info("phone-forward 持锁后幂等命中,跳过本次 HTTP/Telegram phone={} bot={}", phone, bot);
+ return again;
+ }
+ }
+ String reply = doForwardUnsynchronized(phone, bot);
+ dedupPut(dedupKey, reply);
+ return reply;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.warn("phone-forward 获取锁被打断 phone={} bot={}", phone, bot);
diff --git a/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/impl/TgScalperPhoneServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/impl/TgScalperPhoneServiceImpl.java
new file mode 100644
index 0000000..000c9d4
--- /dev/null
+++ b/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/impl/TgScalperPhoneServiceImpl.java
@@ -0,0 +1,100 @@
+package com.ruoyi.jarvis.service.impl;
+
+import java.util.List;
+import java.util.regex.Pattern;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.jarvis.domain.TgScalperPhone;
+import com.ruoyi.jarvis.mapper.TgScalperPhoneMapper;
+import com.ruoyi.jarvis.service.ITgScalperPhoneService;
+
+@Service
+public class TgScalperPhoneServiceImpl implements ITgScalperPhoneService
+{
+ private static final Pattern MOBILE_11 = Pattern.compile("^1\\d{10}$");
+
+ @Autowired
+ private TgScalperPhoneMapper tgScalperPhoneMapper;
+
+ @Override
+ public TgScalperPhone selectTgScalperPhoneById(Long id)
+ {
+ return tgScalperPhoneMapper.selectTgScalperPhoneById(id);
+ }
+
+ @Override
+ public List selectTgScalperPhoneList(TgScalperPhone q)
+ {
+ return tgScalperPhoneMapper.selectTgScalperPhoneList(q);
+ }
+
+ @Override
+ public TgScalperPhone selectEnabledByPhone(String phone)
+ {
+ if (!StringUtils.hasText(phone))
+ {
+ return null;
+ }
+ return tgScalperPhoneMapper.selectEnabledByPhone(phone.trim());
+ }
+
+ @Override
+ public int insertTgScalperPhone(TgScalperPhone row)
+ {
+ validatePhone(row.getPhone());
+ if (!StringUtils.hasText(row.getRemark()))
+ {
+ throw new ServiceException("备注不能为空");
+ }
+ TgScalperPhone exist = tgScalperPhoneMapper.selectTgScalperPhoneByPhone(row.getPhone().trim());
+ if (exist != null)
+ {
+ throw new ServiceException("手机号已存在");
+ }
+ if (row.getStatus() == null)
+ {
+ row.setStatus(1);
+ }
+ row.setPhone(row.getPhone().trim());
+ return tgScalperPhoneMapper.insertTgScalperPhone(row);
+ }
+
+ @Override
+ public int updateTgScalperPhone(TgScalperPhone row)
+ {
+ validatePhone(row.getPhone());
+ if (!StringUtils.hasText(row.getRemark()))
+ {
+ throw new ServiceException("备注不能为空");
+ }
+ TgScalperPhone other = tgScalperPhoneMapper.selectTgScalperPhoneByPhone(row.getPhone().trim());
+ if (other != null && !other.getId().equals(row.getId()))
+ {
+ throw new ServiceException("手机号已存在");
+ }
+ row.setPhone(row.getPhone().trim());
+ return tgScalperPhoneMapper.updateTgScalperPhone(row);
+ }
+
+ @Override
+ public int deleteTgScalperPhoneByIds(Long[] ids)
+ {
+ return tgScalperPhoneMapper.deleteTgScalperPhoneByIds(ids);
+ }
+
+ @Override
+ public int deleteTgScalperPhoneById(Long id)
+ {
+ return tgScalperPhoneMapper.deleteTgScalperPhoneById(id);
+ }
+
+ private static void validatePhone(String phone)
+ {
+ if (!StringUtils.hasText(phone) || !MOBILE_11.matcher(phone.trim()).matches())
+ {
+ throw new ServiceException("请输入正确的11位手机号");
+ }
+ }
+}
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 973ed3f..b07b42b 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
@@ -9,15 +9,14 @@ import com.ruoyi.jarvis.service.ILogisticsService;
import com.ruoyi.jarvis.service.IWeComChatSessionService;
import com.ruoyi.jarvis.service.IWeComInboundService;
import com.ruoyi.jarvis.service.SuperAdminService;
+import com.ruoyi.jarvis.util.WeComUtf8ChunkUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
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;
@@ -25,7 +24,7 @@ import java.util.regex.Pattern;
/**
* LinPingFan:全部指令;其他人员:须在超级管理员中识别为本人(wxid=企微 UserID,**或** 企微 UserID 出现在 touser 逗号分隔列表中),且仅「京*」指令 + 京东分享物流链接流程;
* 例外:以「单」或「开始」开头且含「分销标记」的录单正文优先于物流(不进入 3.cn 多轮、不占用物流监听)。
- * 以「开」或「慢开」开头且正文含 11 位手机号(1 开头):POST 配置项 jarvis.phone-forward 指向的局域网服务,回显 reply_text(body 含对应 bot)。
+ * 以「开」或「慢开」开头且正文含 11 位手机号(1 开头):TG 查询默认先被动回复「收到电话」,结果经 wxSend 主动推送;本地库命中仍为被动全文。
* 多轮会话使用 Redis({@link WeComChatSession},键 interaction_state:wecom:{FromUserName}),与旧版「开通礼金」interaction_state 思路一致。
* 回复正文按 UTF-8 每段至多 2048 字节拆分:首段被动回复,其余主动推送(同一次用户消息、不重复触发查询)。
*/
@@ -38,9 +37,6 @@ public class WeComInboundServiceImpl implements IWeComInboundService {
private static final Pattern JD_3CN = Pattern.compile("https://3\\.cn/[A-Za-z0-9\\-]+");
- /** 企微被动回复与应用文本消息 content 官方上限:UTF-8 字节(见被动回复 / 发送应用消息文档) */
- private static final int WE_COM_TEXT_MAX_UTF8_BYTES = 2048;
-
/** 无超级管理员配置 */
private static String replyPermissionDenied() {
return "「权限说明」\n\n"
@@ -109,7 +105,7 @@ public class WeComInboundServiceImpl implements IWeComInboundService {
return WeComInboundResult.passiveOnly(replyPermissionDenied());
}
- String openPhoneReply = openPhoneForwardService.tryReply(content);
+ String openPhoneReply = openPhoneForwardService.tryReply(req);
if (openPhoneReply != null) {
return toChunkedInboundResult(openPhoneReply);
}
@@ -180,7 +176,7 @@ public class WeComInboundServiceImpl implements IWeComInboundService {
if (parts.size() == 1) {
return toChunkedInboundResult(parts.get(0));
}
- List headChunks = splitUtf8Chunks(parts.get(0), WE_COM_TEXT_MAX_UTF8_BYTES);
+ List headChunks = WeComUtf8ChunkUtil.splitUtf8Chunks(parts.get(0), WeComUtf8ChunkUtil.WE_COM_TEXT_MAX_UTF8_BYTES);
String passive = headChunks.isEmpty() ? "" : headChunks.get(0);
List active = new ArrayList<>();
for (int h = 1; h < headChunks.size(); h++) {
@@ -191,7 +187,7 @@ public class WeComInboundServiceImpl implements IWeComInboundService {
if (p == null) {
continue;
}
- for (String chunk : splitUtf8Chunks(p, WE_COM_TEXT_MAX_UTF8_BYTES)) {
+ for (String chunk : WeComUtf8ChunkUtil.splitUtf8Chunks(p, WeComUtf8ChunkUtil.WE_COM_TEXT_MAX_UTF8_BYTES)) {
active.add(chunk);
}
}
@@ -202,7 +198,7 @@ public class WeComInboundServiceImpl implements IWeComInboundService {
* 首段 ≤2048 UTF-8 字节走被动回复,其余走 wxSend 主动推送(同一次用户消息内顺序下发,不重复计费)。
*/
private static WeComInboundResult toChunkedInboundResult(String fullText) {
- List chunks = splitUtf8Chunks(fullText, WE_COM_TEXT_MAX_UTF8_BYTES);
+ List chunks = WeComUtf8ChunkUtil.splitUtf8Chunks(fullText, WeComUtf8ChunkUtil.WE_COM_TEXT_MAX_UTF8_BYTES);
if (chunks.isEmpty()) {
return WeComInboundResult.passiveOnly("");
}
@@ -212,47 +208,6 @@ public class WeComInboundServiceImpl implements IWeComInboundService {
return new WeComInboundResult(chunks.get(0), new ArrayList<>(chunks.subList(1, chunks.size())));
}
- /**
- * 按 UTF-8 字节长度切分,每段不超过 maxUtf8Bytes(不在 BMP 的码点按整字符保留)。
- */
- private static List 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 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;
- }
-
/**
* 录单正文(指令层走「单…」写库)优先于物流:与 {@link InstructionServiceImpl} 新模板一致。
*/
diff --git a/ruoyi-system/src/main/java/com/ruoyi/jarvis/util/WeComUtf8ChunkUtil.java b/ruoyi-system/src/main/java/com/ruoyi/jarvis/util/WeComUtf8ChunkUtil.java
new file mode 100644
index 0000000..34d5ca6
--- /dev/null
+++ b/ruoyi-system/src/main/java/com/ruoyi/jarvis/util/WeComUtf8ChunkUtil.java
@@ -0,0 +1,59 @@
+package com.ruoyi.jarvis.util;
+
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * 企微文本按 UTF-8 字节切分(与应用消息 / 被动回复上限一致)。
+ */
+public final class WeComUtf8ChunkUtil {
+
+ /** 企微文本 content 官方上限约 2048 UTF-8 字节 */
+ public static final int WE_COM_TEXT_MAX_UTF8_BYTES = 2048;
+
+ private WeComUtf8ChunkUtil() {
+ }
+
+ /**
+ * 按 UTF-8 字节长度切分,每段不超过 maxUtf8Bytes(非 BMP 码点按整字符保留)。
+ */
+ public static List 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 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;
+ }
+}
diff --git a/ruoyi-system/src/main/resources/mapper/jarvis/TgScalperPhoneMapper.xml b/ruoyi-system/src/main/resources/mapper/jarvis/TgScalperPhoneMapper.xml
new file mode 100644
index 0000000..820eea0
--- /dev/null
+++ b/ruoyi-system/src/main/resources/mapper/jarvis/TgScalperPhoneMapper.xml
@@ -0,0 +1,85 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ select id, phone, remark, status, create_time, update_time
+ from jarvis_tg_scalper_phone
+
+
+
+
+
+
+
+
+
+
+
+ insert into jarvis_tg_scalper_phone
+
+ phone,
+ remark,
+ status,
+ create_time,
+
+
+ #{phone},
+ #{remark},
+ #{status},
+ sysdate(),
+
+
+
+
+ update jarvis_tg_scalper_phone
+
+ phone = #{phone},
+ remark = #{remark},
+ status = #{status},
+ update_time = sysdate(),
+
+ where id = #{id}
+
+
+
+ delete from jarvis_tg_scalper_phone where id = #{id}
+
+
+
+ delete from jarvis_tg_scalper_phone where id in
+
+ #{id}
+
+
+
diff --git a/sql/jarvis_tg_scalper_phone.sql b/sql/jarvis_tg_scalper_phone.sql
new file mode 100644
index 0000000..1d6a49c
--- /dev/null
+++ b/sql/jarvis_tg_scalper_phone.sql
@@ -0,0 +1,41 @@
+-- TG 管理:黄牛电话库(企微「开」「慢开」命中则直接返回备注,不请求 Telegram)
+CREATE TABLE IF NOT EXISTS `jarvis_tg_scalper_phone` (
+ `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
+ `phone` varchar(11) NOT NULL COMMENT '11位手机号',
+ `remark` varchar(2000) NOT NULL COMMENT '命中时直接回复的备注',
+ `status` tinyint(4) NOT NULL DEFAULT 1 COMMENT '0禁用 1启用',
+ `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+ `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_phone` (`phone`),
+ KEY `idx_status` (`status`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='TG黄牛电话库(开/慢开前置命中则不走TG)';
+
+-- 菜单:挂在系统管理下(parent_id=2,与企微跟踪等同);执行后请为角色分配权限并清缓存
+INSERT INTO sys_menu VALUES (
+ 2120, 'TG管理-黄牛电话库', 2, 12, 'tgScalperPhone', 'jarvis/tgScalperPhone/index', '', '', 1, 0, 'C', '0', '0',
+ 'jarvis:tg:scalperPhone:list', 'phone', 'admin', sysdate(), '', NULL, '开/慢开前置匹配,命中则不请求 Telegram'
+);
+INSERT INTO sys_menu VALUES (
+ 2121, '查询', 2120, 1, '', '', '', '', 1, 0, 'F', '0', '0',
+ 'jarvis:tg:scalperPhone:query', '#', 'admin', sysdate(), '', NULL, ''
+);
+INSERT INTO sys_menu VALUES (
+ 2122, '新增', 2120, 2, '', '', '', '', 1, 0, 'F', '0', '0',
+ 'jarvis:tg:scalperPhone:add', '#', 'admin', sysdate(), '', NULL, ''
+);
+INSERT INTO sys_menu VALUES (
+ 2123, '修改', 2120, 3, '', '', '', '', 1, 0, 'F', '0', '0',
+ 'jarvis:tg:scalperPhone:edit', '#', 'admin', sysdate(), '', NULL, ''
+);
+INSERT INTO sys_menu VALUES (
+ 2124, '删除', 2120, 4, '', '', '', '', 1, 0, 'F', '0', '0',
+ 'jarvis:tg:scalperPhone:remove', '#', 'admin', sysdate(), '', NULL, ''
+);
+INSERT INTO sys_menu VALUES (
+ 2125, '导出', 2120, 5, '', '', '', '', 1, 0, 'F', '0', '0',
+ 'jarvis:tg:scalperPhone:export', '#', 'admin', sysdate(), '', NULL, ''
+);
+
+-- 若需管理员角色默认拥有(role_id 按实际调整)
+-- INSERT INTO sys_role_menu (role_id, menu_id) VALUES (1, 2120), (1, 2121), (1, 2122), (1, 2123), (1, 2124), (1, 2125);