1
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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<TgScalperPhone> 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);
|
||||
}
|
||||
@@ -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<String> chunks);
|
||||
}
|
||||
@@ -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<TgScalperPhone> 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);
|
||||
}
|
||||
@@ -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 条间取值,避免重复计费。
|
||||
* 「慢开」返回仍会去掉尾部固定推广行。
|
||||
* </p>
|
||||
* <p>
|
||||
* <b>计费幂等</b>:与 {@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 主动推送。
|
||||
* </p>
|
||||
*/
|
||||
@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<String, Long> 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<String, DedupEntry> 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<String> 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);
|
||||
|
||||
@@ -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<TgScalperPhone> 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位手机号");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String> headChunks = splitUtf8Chunks(parts.get(0), WE_COM_TEXT_MAX_UTF8_BYTES);
|
||||
List<String> headChunks = WeComUtf8ChunkUtil.splitUtf8Chunks(parts.get(0), WeComUtf8ChunkUtil.WE_COM_TEXT_MAX_UTF8_BYTES);
|
||||
String passive = headChunks.isEmpty() ? "" : headChunks.get(0);
|
||||
List<String> 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<String> chunks = splitUtf8Chunks(fullText, WE_COM_TEXT_MAX_UTF8_BYTES);
|
||||
List<String> 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<String> splitUtf8Chunks(String text, int maxUtf8Bytes) {
|
||||
if (text == null) {
|
||||
return Collections.singletonList("");
|
||||
}
|
||||
if (text.isEmpty()) {
|
||||
return Collections.singletonList("");
|
||||
}
|
||||
if (maxUtf8Bytes < 1) {
|
||||
throw new IllegalArgumentException("maxUtf8Bytes must be >= 1");
|
||||
}
|
||||
List<String> out = new ArrayList<>();
|
||||
int i = 0;
|
||||
final int n = text.length();
|
||||
while (i < n) {
|
||||
int chunkStart = i;
|
||||
int usedBytes = 0;
|
||||
while (i < n) {
|
||||
int cp = text.codePointAt(i);
|
||||
int charCount = Character.charCount(cp);
|
||||
int b = new String(Character.toChars(cp)).getBytes(StandardCharsets.UTF_8).length;
|
||||
if (usedBytes + b > maxUtf8Bytes) {
|
||||
break;
|
||||
}
|
||||
usedBytes += b;
|
||||
i += charCount;
|
||||
}
|
||||
if (i == chunkStart) {
|
||||
int cp = text.codePointAt(i);
|
||||
int charCount = Character.charCount(cp);
|
||||
out.add(text.substring(chunkStart, chunkStart + charCount));
|
||||
i = chunkStart + charCount;
|
||||
} else {
|
||||
out.add(text.substring(chunkStart, i));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 录单正文(指令层走「单…」写库)优先于物流:与 {@link InstructionServiceImpl} 新模板一致。
|
||||
*/
|
||||
|
||||
@@ -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<String> splitUtf8Chunks(String text, int maxUtf8Bytes) {
|
||||
if (text == null) {
|
||||
return Collections.singletonList("");
|
||||
}
|
||||
if (text.isEmpty()) {
|
||||
return Collections.singletonList("");
|
||||
}
|
||||
if (maxUtf8Bytes < 1) {
|
||||
throw new IllegalArgumentException("maxUtf8Bytes must be >= 1");
|
||||
}
|
||||
List<String> out = new ArrayList<>();
|
||||
int i = 0;
|
||||
final int n = text.length();
|
||||
while (i < n) {
|
||||
int chunkStart = i;
|
||||
int usedBytes = 0;
|
||||
while (i < n) {
|
||||
int cp = text.codePointAt(i);
|
||||
int charCount = Character.charCount(cp);
|
||||
int b = new String(Character.toChars(cp)).getBytes(StandardCharsets.UTF_8).length;
|
||||
if (usedBytes + b > maxUtf8Bytes) {
|
||||
break;
|
||||
}
|
||||
usedBytes += b;
|
||||
i += charCount;
|
||||
}
|
||||
if (i == chunkStart) {
|
||||
int cp = text.codePointAt(i);
|
||||
int charCount = Character.charCount(cp);
|
||||
out.add(text.substring(chunkStart, chunkStart + charCount));
|
||||
i = chunkStart + charCount;
|
||||
} else {
|
||||
out.add(text.substring(chunkStart, i));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!DOCTYPE mapper
|
||||
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.ruoyi.jarvis.mapper.TgScalperPhoneMapper">
|
||||
|
||||
<resultMap type="TgScalperPhone" id="TgScalperPhoneResult">
|
||||
<result property="id" column="id" />
|
||||
<result property="phone" column="phone" />
|
||||
<result property="remark" column="remark" />
|
||||
<result property="status" column="status" />
|
||||
<result property="createTime" column="create_time" />
|
||||
<result property="updateTime" column="update_time" />
|
||||
</resultMap>
|
||||
|
||||
<sql id="selectTgScalperPhoneVo">
|
||||
select id, phone, remark, status, create_time, update_time
|
||||
from jarvis_tg_scalper_phone
|
||||
</sql>
|
||||
|
||||
<select id="selectTgScalperPhoneList" parameterType="TgScalperPhone" resultMap="TgScalperPhoneResult">
|
||||
<include refid="selectTgScalperPhoneVo"/>
|
||||
<where>
|
||||
<if test="phone != null and phone != ''"> and phone like concat('%', #{phone}, '%')</if>
|
||||
<if test="remark != null and remark != ''"> and remark like concat('%', #{remark}, '%')</if>
|
||||
<if test="status != null"> and status = #{status}</if>
|
||||
</where>
|
||||
order by update_time desc, id desc
|
||||
</select>
|
||||
|
||||
<select id="selectTgScalperPhoneById" parameterType="Long" resultMap="TgScalperPhoneResult">
|
||||
<include refid="selectTgScalperPhoneVo"/>
|
||||
where id = #{id}
|
||||
</select>
|
||||
|
||||
<select id="selectTgScalperPhoneByPhone" parameterType="String" resultMap="TgScalperPhoneResult">
|
||||
<include refid="selectTgScalperPhoneVo"/>
|
||||
where phone = #{phone}
|
||||
limit 1
|
||||
</select>
|
||||
|
||||
<select id="selectEnabledByPhone" parameterType="String" resultMap="TgScalperPhoneResult">
|
||||
<include refid="selectTgScalperPhoneVo"/>
|
||||
where phone = #{phone} and status = 1
|
||||
limit 1
|
||||
</select>
|
||||
|
||||
<insert id="insertTgScalperPhone" parameterType="TgScalperPhone" useGeneratedKeys="true" keyProperty="id">
|
||||
insert into jarvis_tg_scalper_phone
|
||||
<trim prefix="(" suffix=")" suffixOverrides=",">
|
||||
<if test="phone != null and phone != ''">phone,</if>
|
||||
<if test="remark != null">remark,</if>
|
||||
<if test="status != null">status,</if>
|
||||
create_time,
|
||||
</trim>
|
||||
<trim prefix="values (" suffix=")" suffixOverrides=",">
|
||||
<if test="phone != null and phone != ''">#{phone},</if>
|
||||
<if test="remark != null">#{remark},</if>
|
||||
<if test="status != null">#{status},</if>
|
||||
sysdate(),
|
||||
</trim>
|
||||
</insert>
|
||||
|
||||
<update id="updateTgScalperPhone" parameterType="TgScalperPhone">
|
||||
update jarvis_tg_scalper_phone
|
||||
<trim prefix="SET" suffixOverrides=",">
|
||||
<if test="phone != null and phone != ''">phone = #{phone},</if>
|
||||
<if test="remark != null">remark = #{remark},</if>
|
||||
<if test="status != null">status = #{status},</if>
|
||||
update_time = sysdate(),
|
||||
</trim>
|
||||
where id = #{id}
|
||||
</update>
|
||||
|
||||
<delete id="deleteTgScalperPhoneById" parameterType="Long">
|
||||
delete from jarvis_tg_scalper_phone where id = #{id}
|
||||
</delete>
|
||||
|
||||
<delete id="deleteTgScalperPhoneByIds" parameterType="Long">
|
||||
delete from jarvis_tg_scalper_phone where id in
|
||||
<foreach item="id" collection="array" open="(" separator="," close=")">
|
||||
#{id}
|
||||
</foreach>
|
||||
</delete>
|
||||
</mapper>
|
||||
Reference in New Issue
Block a user