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);