build: 调整项目Java版本为1.8
feat(archive-service): 新增敏感词风控相关实体与Mapper feat(archive-service): 新增会话统计相关VO类 refactor(archive-service): 移除ArchiveMessage的deleted逻辑字段 fix: 修正数据库连接字符集配置为UTF-8 style(frontend): 优化nginx缓存配置规则 refactor(conversation-service): 替换var为显式类型声明
This commit is contained in:
parent
d327826c78
commit
6160691aa9
@ -11,7 +11,7 @@ spring:
|
|||||||
name: analytics-service
|
name: analytics-service
|
||||||
datasource:
|
datasource:
|
||||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||||
url: jdbc:mysql://${MYSQL_HOST:localhost}:${MYSQL_PORT:3306}/${MYSQL_DATABASE:ai_assistant}?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai&useSSL=false
|
url: jdbc:mysql://${MYSQL_HOST:localhost}:${MYSQL_PORT:3306}/${MYSQL_DATABASE:ai_assistant}?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&useSSL=false
|
||||||
username: ${MYSQL_USERNAME:root}
|
username: ${MYSQL_USERNAME:root}
|
||||||
password: ${MYSQL_PASSWORD:root}
|
password: ${MYSQL_PASSWORD:root}
|
||||||
redis:
|
redis:
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@ -11,7 +11,7 @@ spring:
|
|||||||
name: analytics-service
|
name: analytics-service
|
||||||
datasource:
|
datasource:
|
||||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||||
url: jdbc:mysql://${MYSQL_HOST:localhost}:${MYSQL_PORT:3306}/${MYSQL_DATABASE:ai_assistant}?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai&useSSL=false
|
url: jdbc:mysql://${MYSQL_HOST:localhost}:${MYSQL_PORT:3306}/${MYSQL_DATABASE:ai_assistant}?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&useSSL=false
|
||||||
username: ${MYSQL_USERNAME:root}
|
username: ${MYSQL_USERNAME:root}
|
||||||
password: ${MYSQL_PASSWORD:root}
|
password: ${MYSQL_PASSWORD:root}
|
||||||
redis:
|
redis:
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -16,9 +16,19 @@
|
|||||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency>
|
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency>
|
||||||
<dependency><groupId>net.java.dev.jna</groupId><artifactId>jna</artifactId></dependency>
|
<dependency><groupId>net.java.dev.jna</groupId><artifactId>jna</artifactId></dependency>
|
||||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
|
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
|
||||||
|
<dependency><groupId>org.bouncycastle</groupId><artifactId>bcpkix-jdk18on</artifactId><version>1.78</version></dependency>
|
||||||
|
<dependency><groupId>com.alibaba</groupId><artifactId>easyexcel</artifactId><version>3.3.4</version></dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
<build>
|
<build>
|
||||||
<plugins>
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-compiler-plugin</artifactId>
|
||||||
|
<configuration>
|
||||||
|
<source>1.8</source>
|
||||||
|
<target>1.8</target>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
<plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId><executions><execution><goals><goal>repackage</goal></goals></execution></executions></plugin>
|
<plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId><executions><execution><goals><goal>repackage</goal></goals></execution></executions></plugin>
|
||||||
</plugins>
|
</plugins>
|
||||||
</build>
|
</build>
|
||||||
|
|||||||
@ -1,15 +1,59 @@
|
|||||||
package com.artedu.archive;
|
package com.artedu.archive;
|
||||||
|
|
||||||
|
import com.artedu.archive.service.ArchivePullService;
|
||||||
|
import org.springframework.boot.ApplicationArguments;
|
||||||
|
import org.springframework.boot.ApplicationRunner;
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.scheduling.annotation.EnableAsync;
|
||||||
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 会话存档服务启动类
|
* 会话存档服务启动类
|
||||||
*/
|
*/
|
||||||
|
@EnableAsync
|
||||||
@SpringBootApplication(scanBasePackages = {"com.artedu.archive", "com.artedu.common"})
|
@SpringBootApplication(scanBasePackages = {"com.artedu.archive", "com.artedu.common"})
|
||||||
|
@org.springframework.scheduling.annotation.EnableScheduling
|
||||||
public class ArchiveServiceApplication {
|
public class ArchiveServiceApplication {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public RestTemplate restTemplate() {
|
||||||
|
return new RestTemplate();
|
||||||
|
}
|
||||||
|
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
|
if (args.length > 0 && "worker".equals(args[0])) {
|
||||||
|
try {
|
||||||
|
com.artedu.archive.worker.SdkWorker.run(
|
||||||
|
java.util.Arrays.copyOfRange(args, 1, args.length));
|
||||||
|
} catch (Exception e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
System.exit(1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
SpringApplication.run(ArchiveServiceApplication.class, args);
|
SpringApplication.run(ArchiveServiceApplication.class, args);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public ApplicationRunner repairRunner(ArchivePullService archivePullService) {
|
||||||
|
return new ApplicationRunner() {
|
||||||
|
@Override
|
||||||
|
public void run(ApplicationArguments args) throws Exception {
|
||||||
|
String repairFlag = System.getenv("ARCHIVE_REPAIR_ON_STARTUP");
|
||||||
|
if ("true".equalsIgnoreCase(repairFlag)) {
|
||||||
|
new Thread(() -> {
|
||||||
|
// 等待应用完全启动
|
||||||
|
try {
|
||||||
|
Thread.sleep(10000);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
|
archivePullService.repairHistoricalData();
|
||||||
|
}, "archive-repair-thread").start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -0,0 +1,197 @@
|
|||||||
|
package com.artedu.archive.client;
|
||||||
|
|
||||||
|
import com.artedu.common.util.JsonUtils;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.data.redis.core.RedisTemplate;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 企业微信API客户端
|
||||||
|
* 用于获取员工详情、客户详情等
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
public class WeComApiClient {
|
||||||
|
|
||||||
|
@Value("${wecom.corp-id:}")
|
||||||
|
private String corpId;
|
||||||
|
|
||||||
|
@Value("${wecom.agent.secret:}")
|
||||||
|
private String agentSecret;
|
||||||
|
|
||||||
|
@Value("${wecom.contact.secret:}")
|
||||||
|
private String contactSecret;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private RestTemplate restTemplate;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private RedisTemplate<String, Object> redisTemplate;
|
||||||
|
|
||||||
|
private static final String ACCESS_TOKEN_KEY = "wecom:access_token";
|
||||||
|
private static final String CONTACT_TOKEN_KEY = "wecom:contact_access_token";
|
||||||
|
private static final long TOKEN_EXPIRE_SECONDS = 7000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取AccessToken(会话存档/通讯录应用)
|
||||||
|
*/
|
||||||
|
public String getAccessToken() {
|
||||||
|
return doGetAccessToken(agentSecret, ACCESS_TOKEN_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取客户联系AccessToken(客户联系/通讯录同步应用)
|
||||||
|
*/
|
||||||
|
public String getContactAccessToken() {
|
||||||
|
return doGetAccessToken(contactSecret, CONTACT_TOKEN_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String doGetAccessToken(String secret, String cacheKey) {
|
||||||
|
if (corpId == null || corpId.isEmpty() || secret == null || secret.isEmpty()) {
|
||||||
|
log.warn("企微API配置不完整,无法获取access_token: cacheKey={}", cacheKey);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String token = (String) redisTemplate.opsForValue().get(cacheKey);
|
||||||
|
if (token != null) {
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
String url = "https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=" + corpId + "&corpsecret=" + secret;
|
||||||
|
ResponseEntity<String> response = restTemplate.getForEntity(url, String.class);
|
||||||
|
Map<String, Object> result = JsonUtils.fromJsonMap(response.getBody());
|
||||||
|
|
||||||
|
if (result == null || !Integer.valueOf(0).equals(result.get("errcode"))) {
|
||||||
|
log.error("获取AccessToken失败: cacheKey={}, resp={}", cacheKey, response.getBody());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
token = (String) result.get("access_token");
|
||||||
|
if (token != null) {
|
||||||
|
redisTemplate.opsForValue().set(cacheKey, token, TOKEN_EXPIRE_SECONDS, TimeUnit.SECONDS);
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("获取AccessToken异常: cacheKey={}, err={}", cacheKey, e.getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取员工详情
|
||||||
|
*/
|
||||||
|
public Map<String, Object> getUserDetail(String userId) {
|
||||||
|
String accessToken = getAccessToken();
|
||||||
|
if (accessToken == null) return Collections.emptyMap();
|
||||||
|
|
||||||
|
try {
|
||||||
|
String url = "https://qyapi.weixin.qq.com/cgi-bin/user/get?access_token=" + accessToken + "&userid=" + userId;
|
||||||
|
ResponseEntity<String> response = restTemplate.getForEntity(url, String.class);
|
||||||
|
Map<String, Object> result = JsonUtils.fromJsonMap(response.getBody());
|
||||||
|
|
||||||
|
if (result == null || !Integer.valueOf(0).equals(result.get("errcode"))) {
|
||||||
|
log.warn("获取员工详情失败: userId={}, resp={}", userId, response.getBody());
|
||||||
|
return Collections.emptyMap();
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("获取员工详情异常: userId={}", userId, e);
|
||||||
|
return Collections.emptyMap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取客户详情
|
||||||
|
*/
|
||||||
|
public Map<String, Object> getExternalContact(String externalUserId) {
|
||||||
|
String accessToken = getAccessToken();
|
||||||
|
if (accessToken == null) return Collections.emptyMap();
|
||||||
|
|
||||||
|
try {
|
||||||
|
String url = "https://qyapi.weixin.qq.com/cgi-bin/externalcontact/get?access_token=" + accessToken + "&external_userid=" + externalUserId;
|
||||||
|
ResponseEntity<String> response = restTemplate.getForEntity(url, String.class);
|
||||||
|
Map<String, Object> result = JsonUtils.fromJsonMap(response.getBody());
|
||||||
|
|
||||||
|
if (result == null || !Integer.valueOf(0).equals(result.get("errcode"))) {
|
||||||
|
log.warn("获取客户详情失败: externalUserId={}, resp={}", externalUserId, response.getBody());
|
||||||
|
return Collections.emptyMap();
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("获取客户详情异常: externalUserId={}", externalUserId, e);
|
||||||
|
return Collections.emptyMap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取客户列表(分页)
|
||||||
|
*/
|
||||||
|
public List<String> getExternalContactList(String userId) {
|
||||||
|
String accessToken = getAccessToken();
|
||||||
|
if (accessToken == null) return Collections.emptyList();
|
||||||
|
|
||||||
|
try {
|
||||||
|
String url = "https://qyapi.weixin.qq.com/cgi-bin/externalcontact/list?access_token=" + accessToken + "&userid=" + userId;
|
||||||
|
ResponseEntity<String> response = restTemplate.getForEntity(url, String.class);
|
||||||
|
Map<String, Object> result = JsonUtils.fromJsonMap(response.getBody());
|
||||||
|
|
||||||
|
if (result == null || !Integer.valueOf(0).equals(result.get("errcode"))) {
|
||||||
|
log.warn("获取客户列表失败: userId={}, resp={}", userId, response.getBody());
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Object list = result.get("external_userid");
|
||||||
|
if (list instanceof List) {
|
||||||
|
return (List<String>) list;
|
||||||
|
}
|
||||||
|
return Collections.emptyList();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("获取客户列表异常: userId={}", userId, e);
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取部门成员列表
|
||||||
|
*/
|
||||||
|
public List<Map<String, Object>> getDepartmentUserList(Long departmentId) {
|
||||||
|
String accessToken = getAccessToken();
|
||||||
|
if (accessToken == null) return Collections.emptyList();
|
||||||
|
|
||||||
|
try {
|
||||||
|
String url = "https://qyapi.weixin.qq.com/cgi-bin/user/simplelist?access_token=" + accessToken
|
||||||
|
+ "&department_id=" + departmentId + "&fetch_child=1";
|
||||||
|
ResponseEntity<String> response = restTemplate.getForEntity(url, String.class);
|
||||||
|
Map<String, Object> result = JsonUtils.fromJsonMap(response.getBody());
|
||||||
|
|
||||||
|
if (result == null || !Integer.valueOf(0).equals(result.get("errcode"))) {
|
||||||
|
int errcode = result != null && result.get("errcode") instanceof Number ? ((Number) result.get("errcode")).intValue() : -1;
|
||||||
|
if (errcode == 60011) {
|
||||||
|
log.warn("企微API权限不足(60011): 请在企微管理后台为应用开启'通讯录读取'权限");
|
||||||
|
} else {
|
||||||
|
log.warn("获取部门成员失败: deptId={}, errcode={}, resp={}", departmentId, errcode, response.getBody());
|
||||||
|
}
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Object list = result.get("userlist");
|
||||||
|
if (list instanceof List) {
|
||||||
|
return (List<Map<String, Object>>) list;
|
||||||
|
}
|
||||||
|
return Collections.emptyList();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("获取部门成员异常: deptId={}", departmentId, e);
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
package com.artedu.archive.controller;
|
package com.artedu.archive.controller;
|
||||||
|
|
||||||
import com.artedu.archive.service.ArchivePullService;
|
import com.artedu.archive.service.ArchivePullService;
|
||||||
|
import com.artedu.archive.util.WeComCryptUtil;
|
||||||
import com.artedu.common.result.Result;
|
import com.artedu.common.result.Result;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
@ -9,9 +10,11 @@ import org.springframework.validation.annotation.Validated;
|
|||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import javax.validation.constraints.NotBlank;
|
import javax.validation.constraints.NotBlank;
|
||||||
|
|
||||||
|
import com.artedu.archive.entity.ArchiveMessage;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.security.MessageDigest;
|
import java.security.MessageDigest;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -30,6 +33,22 @@ public class ArchiveCallbackController {
|
|||||||
@Value("${wecom.archive.callback-token:}")
|
@Value("${wecom.archive.callback-token:}")
|
||||||
private String callbackToken;
|
private String callbackToken;
|
||||||
|
|
||||||
|
@Value("${wecom.archive.callback-aeskey:}")
|
||||||
|
private String callbackAesKey;
|
||||||
|
|
||||||
|
private WeComCryptUtil cryptUtil;
|
||||||
|
|
||||||
|
private WeComCryptUtil getCryptUtil() {
|
||||||
|
if (cryptUtil == null && callbackAesKey != null && !callbackAesKey.isEmpty()) {
|
||||||
|
try {
|
||||||
|
cryptUtil = new WeComCryptUtil(callbackAesKey);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("初始化AES解密工具失败: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cryptUtil;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 企微回调URL验证(GET请求)
|
* 企微回调URL验证(GET请求)
|
||||||
*/
|
*/
|
||||||
@ -47,8 +66,22 @@ public class ArchiveCallbackController {
|
|||||||
return "fail";
|
return "fail";
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info("回调URL验证成功");
|
String plaintext = echostr;
|
||||||
return echostr;
|
|
||||||
|
// 如果配置了 AESKey,需要解密 echostr
|
||||||
|
WeComCryptUtil crypt = getCryptUtil();
|
||||||
|
if (crypt != null) {
|
||||||
|
try {
|
||||||
|
plaintext = crypt.decrypt(echostr);
|
||||||
|
log.info("echostr 解密成功");
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("echostr 解密失败: {}", e.getMessage());
|
||||||
|
return "fail";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("回调URL验证成功,返回明文");
|
||||||
|
return plaintext;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -61,20 +94,41 @@ public class ArchiveCallbackController {
|
|||||||
@NotBlank @RequestParam("nonce") String nonce,
|
@NotBlank @RequestParam("nonce") String nonce,
|
||||||
@RequestBody String requestBody) {
|
@RequestBody String requestBody) {
|
||||||
|
|
||||||
log.debug("收到企微回调: msg_signature={}, timestamp={}, nonce={}", msgSignature, timestamp, nonce);
|
log.debug("收到企微回调: msg_signature={}, timestamp={}, nonce={}, body={}", msgSignature, timestamp, nonce, requestBody);
|
||||||
|
|
||||||
if (!verifySignature(msgSignature, timestamp, nonce, requestBody)) {
|
// 企微POST签名基于 Encrypt 节点内容,不是整个 XML
|
||||||
|
String encrypt = extractEncrypt(requestBody);
|
||||||
|
if (encrypt == null) {
|
||||||
|
log.error("无法从消息体中提取 Encrypt 内容");
|
||||||
|
return Result.fail("消息格式错误");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!verifySignature(msgSignature, timestamp, nonce, encrypt)) {
|
||||||
log.error("回调消息签名验证失败");
|
log.error("回调消息签名验证失败");
|
||||||
return Result.fail("签名验证失败");
|
return Result.fail("签名验证失败");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (requestBody.contains("msgaudit_notify")) {
|
String plaintext = requestBody;
|
||||||
|
|
||||||
|
// 如果配置了 AESKey,需要解密消息体
|
||||||
|
WeComCryptUtil crypt = getCryptUtil();
|
||||||
|
if (crypt != null) {
|
||||||
|
try {
|
||||||
|
plaintext = crypt.decrypt(encrypt);
|
||||||
|
log.info("消息体解密成功: {}", plaintext);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("消息体解密失败: {}", e.getMessage());
|
||||||
|
return Result.fail("解密失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (plaintext.contains("msgaudit_notify")) {
|
||||||
log.info("收到msgaudit_notify回调,触发存档拉取");
|
log.info("收到msgaudit_notify回调,触发存档拉取");
|
||||||
|
|
||||||
CompletableFuture.runAsync(() -> {
|
CompletableFuture.runAsync(() -> {
|
||||||
try {
|
try {
|
||||||
long lastSeq = archivePullService.getLastSeq();
|
long lastSeq = archivePullService.getLastSeq();
|
||||||
var messages = archivePullService.pullMessages(lastSeq, 1000);
|
List<ArchiveMessage> messages = archivePullService.pullMessages(lastSeq, 1000);
|
||||||
archivePullService.saveAndNotify(messages);
|
archivePullService.saveAndNotify(messages);
|
||||||
log.info("回调触发拉取完成,共{}条消息", messages.size());
|
log.info("回调触发拉取完成,共{}条消息", messages.size());
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@ -108,13 +162,35 @@ public class ArchiveCallbackController {
|
|||||||
for (byte b : digest) {
|
for (byte b : digest) {
|
||||||
hex.append(String.format("%02x", b));
|
hex.append(String.format("%02x", b));
|
||||||
}
|
}
|
||||||
return hex.toString().equals(signature);
|
String computed = hex.toString();
|
||||||
|
boolean match = computed.equals(signature);
|
||||||
|
if (!match) {
|
||||||
|
log.error("签名验证失败: token={}, sortResult={}, computed={}, expected={}", callbackToken, content.toString(), computed, signature);
|
||||||
|
}
|
||||||
|
return match;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("签名验证异常: {}", e.getMessage());
|
log.error("签名验证异常: {}", e.getMessage());
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 XML 中提取 Encrypt 节点内容(去掉 CDATA 包装)
|
||||||
|
*/
|
||||||
|
private String extractEncrypt(String xml) {
|
||||||
|
int start = xml.indexOf("<Encrypt>");
|
||||||
|
int end = xml.indexOf("</Encrypt>");
|
||||||
|
if (start != -1 && end != -1) {
|
||||||
|
String content = xml.substring(start + 9, end);
|
||||||
|
// 去掉 CDATA 包装: <![CDATA[...]]>
|
||||||
|
if (content.startsWith("<![CDATA[") && content.endsWith("]]>")) {
|
||||||
|
content = content.substring(9, content.length() - 3);
|
||||||
|
}
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/pull")
|
@PostMapping("/pull")
|
||||||
public Result<String> manualPull(
|
public Result<String> manualPull(
|
||||||
@RequestParam(value = "seq", required = false) Long seq,
|
@RequestParam(value = "seq", required = false) Long seq,
|
||||||
@ -123,7 +199,7 @@ public class ArchiveCallbackController {
|
|||||||
long startSeq = seq != null ? seq : archivePullService.getLastSeq();
|
long startSeq = seq != null ? seq : archivePullService.getLastSeq();
|
||||||
log.info("手动触发存档拉取: seq={}, limit={}", startSeq, limit);
|
log.info("手动触发存档拉取: seq={}, limit={}", startSeq, limit);
|
||||||
|
|
||||||
var messages = archivePullService.pullMessages(startSeq, limit);
|
List<ArchiveMessage> messages = archivePullService.pullMessages(startSeq, limit);
|
||||||
archivePullService.saveAndNotify(messages);
|
archivePullService.saveAndNotify(messages);
|
||||||
|
|
||||||
return Result.success("拉取完成,共" + messages.size() + "条消息");
|
return Result.success("拉取完成,共" + messages.size() + "条消息");
|
||||||
|
|||||||
@ -0,0 +1,121 @@
|
|||||||
|
package com.artedu.archive.controller;
|
||||||
|
|
||||||
|
import com.artedu.archive.service.ArchiveContactService;
|
||||||
|
import com.artedu.archive.service.ArchiveSessionService;
|
||||||
|
import com.artedu.archive.vo.ArchiveMessageVO;
|
||||||
|
import com.artedu.archive.vo.CustomerSessionVO;
|
||||||
|
import com.artedu.archive.vo.SessionVO;
|
||||||
|
import com.artedu.archive.vo.StaffSessionVO;
|
||||||
|
import com.artedu.common.result.PageResult;
|
||||||
|
import com.artedu.common.result.Result;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.validation.annotation.Validated;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import javax.validation.constraints.Min;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 会话联系人控制器
|
||||||
|
* 员工/客户管理、会话查询、聊天时间线
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Validated
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/archive")
|
||||||
|
public class ArchiveContactController {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ArchiveSessionService archiveSessionService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ArchiveContactService archiveContactService;
|
||||||
|
|
||||||
|
// ========== 员工管理 ==========
|
||||||
|
|
||||||
|
@GetMapping("/staffs")
|
||||||
|
public Result<PageResult<StaffSessionVO>> listStaffs(
|
||||||
|
@RequestParam(required = false) String keyword,
|
||||||
|
@RequestParam(defaultValue = "1") @Min(1) Integer page,
|
||||||
|
@RequestParam(defaultValue = "20") @Min(1) Integer size,
|
||||||
|
@RequestParam(required = false, defaultValue = "false") Boolean hasArchive) {
|
||||||
|
return Result.success(archiveSessionService.listStaffs(keyword, page, size, hasArchive));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/staffs/{staffId}")
|
||||||
|
public Result<StaffSessionVO> getStaff(@PathVariable String staffId) {
|
||||||
|
StaffSessionVO vo = archiveSessionService.getStaffDetail(staffId);
|
||||||
|
if (vo == null) return Result.fail("员工不存在");
|
||||||
|
return Result.success(vo);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/staffs/{staffId}/sessions")
|
||||||
|
public Result<PageResult<SessionVO>> listStaffSessions(
|
||||||
|
@PathVariable String staffId,
|
||||||
|
@RequestParam(defaultValue = "1") @Min(1) Integer page,
|
||||||
|
@RequestParam(defaultValue = "20") @Min(1) Integer size) {
|
||||||
|
return Result.success(archiveSessionService.listStaffSessions(staffId, page, size));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 客户管理 ==========
|
||||||
|
|
||||||
|
@GetMapping("/customers")
|
||||||
|
public Result<PageResult<CustomerSessionVO>> listCustomers(
|
||||||
|
@RequestParam(required = false) String keyword,
|
||||||
|
@RequestParam(required = false) String staffId,
|
||||||
|
@RequestParam(defaultValue = "1") @Min(1) Integer page,
|
||||||
|
@RequestParam(defaultValue = "20") @Min(1) Integer size) {
|
||||||
|
return Result.success(archiveSessionService.listCustomers(keyword, staffId, page, size));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/customers/{customerId}")
|
||||||
|
public Result<CustomerSessionVO> getCustomer(@PathVariable String customerId) {
|
||||||
|
CustomerSessionVO vo = archiveSessionService.getCustomerDetail(customerId);
|
||||||
|
if (vo == null) return Result.fail("客户不存在");
|
||||||
|
return Result.success(vo);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/customers/{customerId}/sessions")
|
||||||
|
public Result<PageResult<SessionVO>> listCustomerSessions(
|
||||||
|
@PathVariable String customerId,
|
||||||
|
@RequestParam(defaultValue = "1") @Min(1) Integer page,
|
||||||
|
@RequestParam(defaultValue = "20") @Min(1) Integer size) {
|
||||||
|
return Result.success(archiveSessionService.listCustomerSessions(customerId, page, size));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 聊天时间线 ==========
|
||||||
|
|
||||||
|
@GetMapping("/sessions/{sessionId}/messages")
|
||||||
|
public Result<PageResult<ArchiveMessageVO>> listSessionMessages(
|
||||||
|
@PathVariable String sessionId,
|
||||||
|
@RequestParam(defaultValue = "1") @Min(1) Integer page,
|
||||||
|
@RequestParam(defaultValue = "20") @Min(1) Integer size) {
|
||||||
|
return Result.success(archiveSessionService.listSessionMessages(sessionId, page, size));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 手动同步 ==========
|
||||||
|
|
||||||
|
@PostMapping("/sync/staffs")
|
||||||
|
public Result<String> syncStaffs() {
|
||||||
|
int count = archiveContactService.syncAllStaffs();
|
||||||
|
return Result.success("同步完成,共 " + count + " 人");
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/sync/customers")
|
||||||
|
public Result<String> syncCustomers() {
|
||||||
|
int count = archiveContactService.syncAllCustomers();
|
||||||
|
return Result.success("同步完成,共 " + count + " 人");
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/sync/scan-contacts")
|
||||||
|
public Result<String> scanContacts() {
|
||||||
|
int count = archiveContactService.scanHistoricalContacts();
|
||||||
|
return Result.success("扫描完成,共处理 " + count + " 个联系人");
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/sync/customer-details")
|
||||||
|
public Result<String> fillCustomerDetails() {
|
||||||
|
int count = archiveContactService.fillAllCustomerDetails();
|
||||||
|
return Result.success("已开始批量补充客户资料,共 " + count + " 人,请稍后在客户列表查看结果");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,138 @@
|
|||||||
|
package com.artedu.archive.controller;
|
||||||
|
|
||||||
|
import com.alibaba.excel.EasyExcel;
|
||||||
|
import com.artedu.archive.entity.RiskEvent;
|
||||||
|
import com.artedu.archive.entity.SensitiveWordHit;
|
||||||
|
import com.artedu.archive.excel.RiskEventExcel;
|
||||||
|
import com.artedu.archive.excel.SensitiveWordHitExcel;
|
||||||
|
import com.artedu.archive.mapper.RiskEventMapper;
|
||||||
|
import com.artedu.archive.mapper.SensitiveWordHitMapper;
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import javax.servlet.http.HttpServletResponse;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.net.URLEncoder;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据导出控制器
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/archive")
|
||||||
|
public class ArchiveExportController {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private RiskEventMapper riskEventMapper;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private SensitiveWordHitMapper sensitiveWordHitMapper;
|
||||||
|
|
||||||
|
private static final int EXPORT_MAX_SIZE = 10000;
|
||||||
|
|
||||||
|
private static final String[] STATUS_LABELS = {"待处理", "已处理", "无需处理"};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出风险事件
|
||||||
|
*/
|
||||||
|
@GetMapping("/risk-events/export")
|
||||||
|
public void exportRiskEvents(
|
||||||
|
@RequestParam(required = false) String eventType,
|
||||||
|
@RequestParam(required = false) Integer status,
|
||||||
|
@RequestParam(required = false) String userId,
|
||||||
|
HttpServletResponse response) throws IOException {
|
||||||
|
|
||||||
|
LambdaQueryWrapper<RiskEvent> wrapper = new LambdaQueryWrapper<>();
|
||||||
|
if (eventType != null && !eventType.isEmpty()) {
|
||||||
|
wrapper.eq(RiskEvent::getEventType, eventType);
|
||||||
|
}
|
||||||
|
if (status != null) {
|
||||||
|
wrapper.eq(RiskEvent::getStatus, status);
|
||||||
|
}
|
||||||
|
if (userId != null && !userId.isEmpty()) {
|
||||||
|
wrapper.eq(RiskEvent::getUserId, userId);
|
||||||
|
}
|
||||||
|
wrapper.orderByDesc(RiskEvent::getCreatedAt);
|
||||||
|
wrapper.last("LIMIT " + EXPORT_MAX_SIZE);
|
||||||
|
|
||||||
|
List<RiskEvent> list = riskEventMapper.selectList(wrapper);
|
||||||
|
List<RiskEventExcel> excelList = new ArrayList<>();
|
||||||
|
for (RiskEvent event : list) {
|
||||||
|
RiskEventExcel e = new RiskEventExcel();
|
||||||
|
e.setEventType(event.getEventType());
|
||||||
|
e.setEventName(event.getEventName());
|
||||||
|
int st = event.getStatus() != null ? event.getStatus() : 0;
|
||||||
|
e.setStatus(st >= 0 && st < STATUS_LABELS.length ? STATUS_LABELS[st] : "未知");
|
||||||
|
e.setUserId(event.getUserId());
|
||||||
|
e.setUserRole(event.getUserRole());
|
||||||
|
e.setRelatedUser(event.getRelatedUser());
|
||||||
|
e.setDescription(event.getDescription());
|
||||||
|
e.setOccurredAt(event.getOccurredAt() != null ? event.getOccurredAt().toString() : "");
|
||||||
|
e.setHandler(event.getHandler());
|
||||||
|
e.setHandleNote(event.getHandleNote());
|
||||||
|
excelList.add(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
||||||
|
response.setCharacterEncoding("utf-8");
|
||||||
|
String fileName = URLEncoder.encode("风险事件报表_" + System.currentTimeMillis(), "UTF-8");
|
||||||
|
response.setHeader("Content-disposition", "attachment;filename=" + fileName + ".xlsx");
|
||||||
|
EasyExcel.write(response.getOutputStream(), RiskEventExcel.class)
|
||||||
|
.sheet("风险事件")
|
||||||
|
.doWrite(excelList);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出敏感词命中记录
|
||||||
|
*/
|
||||||
|
@GetMapping("/sensitive-word-hits/export")
|
||||||
|
public void exportSensitiveWordHits(
|
||||||
|
@RequestParam(required = false) Long wordId,
|
||||||
|
@RequestParam(required = false) String senderId,
|
||||||
|
@RequestParam(required = false) String sessionId,
|
||||||
|
HttpServletResponse response) throws IOException {
|
||||||
|
|
||||||
|
LambdaQueryWrapper<SensitiveWordHit> wrapper = new LambdaQueryWrapper<>();
|
||||||
|
if (wordId != null) {
|
||||||
|
wrapper.eq(SensitiveWordHit::getWordId, wordId);
|
||||||
|
}
|
||||||
|
if (senderId != null && !senderId.isEmpty()) {
|
||||||
|
wrapper.eq(SensitiveWordHit::getSenderId, senderId);
|
||||||
|
}
|
||||||
|
if (sessionId != null && !sessionId.isEmpty()) {
|
||||||
|
wrapper.eq(SensitiveWordHit::getSessionId, sessionId);
|
||||||
|
}
|
||||||
|
wrapper.orderByDesc(SensitiveWordHit::getCreatedAt);
|
||||||
|
wrapper.last("LIMIT " + EXPORT_MAX_SIZE);
|
||||||
|
|
||||||
|
List<SensitiveWordHit> list = sensitiveWordHitMapper.selectList(wrapper);
|
||||||
|
List<SensitiveWordHitExcel> excelList = new ArrayList<>();
|
||||||
|
for (SensitiveWordHit hit : list) {
|
||||||
|
SensitiveWordHitExcel e = new SensitiveWordHitExcel();
|
||||||
|
e.setWord(hit.getWord());
|
||||||
|
e.setMsgContent(hit.getMsgContent());
|
||||||
|
e.setSenderId(hit.getSenderId());
|
||||||
|
e.setSenderRole(hit.getSenderRole());
|
||||||
|
e.setReceiverId(hit.getReceiverId());
|
||||||
|
e.setSessionId(hit.getSessionId());
|
||||||
|
e.setHitCount(hit.getHitCount());
|
||||||
|
e.setCreatedAt(hit.getCreatedAt() != null ? hit.getCreatedAt().toString() : "");
|
||||||
|
excelList.add(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
||||||
|
response.setCharacterEncoding("utf-8");
|
||||||
|
String fileName = URLEncoder.encode("敏感词命中报表_" + System.currentTimeMillis(), "UTF-8");
|
||||||
|
response.setHeader("Content-disposition", "attachment;filename=" + fileName + ".xlsx");
|
||||||
|
EasyExcel.write(response.getOutputStream(), SensitiveWordHitExcel.class)
|
||||||
|
.sheet("敏感词命中")
|
||||||
|
.doWrite(excelList);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,305 @@
|
|||||||
|
package com.artedu.archive.controller;
|
||||||
|
|
||||||
|
import com.artedu.archive.entity.ArchiveMessage;
|
||||||
|
import com.artedu.archive.entity.Customer;
|
||||||
|
import com.artedu.archive.entity.Staff;
|
||||||
|
import com.artedu.archive.mapper.ArchiveMessageMapper;
|
||||||
|
import com.artedu.archive.mapper.CustomerMapper;
|
||||||
|
import com.artedu.archive.mapper.StaffMapper;
|
||||||
|
import com.artedu.archive.service.ArchivePullService;
|
||||||
|
import com.artedu.archive.vo.ArchiveMessageVO;
|
||||||
|
import com.artedu.common.result.PageResult;
|
||||||
|
import com.artedu.common.result.Result;
|
||||||
|
import com.artedu.common.util.JsonUtils;
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 会话存档消息管理接口
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/archive")
|
||||||
|
public class ArchiveMessageController {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ArchiveMessageMapper archiveMessageMapper;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private com.artedu.archive.mapper.StaffMapper staffMapper;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private com.artedu.archive.mapper.CustomerMapper customerMapper;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private com.artedu.archive.mapper.RiskEventMapper riskEventMapper;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private com.artedu.archive.mapper.SensitiveWordHitMapper sensitiveWordHitMapper;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ArchivePullService archivePullService;
|
||||||
|
|
||||||
|
private static final String MEDIA_CACHE_DIR = "/app/media";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下载媒体文件(图片/语音/视频等)
|
||||||
|
*/
|
||||||
|
@GetMapping("/media/{id}")
|
||||||
|
public ResponseEntity<byte[]> downloadMedia(@PathVariable Long id) {
|
||||||
|
ArchiveMessage message = archiveMessageMapper.selectById(id);
|
||||||
|
if (message == null) {
|
||||||
|
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
String msgtype = message.getMsgtype();
|
||||||
|
String mediaData = message.getMediaData();
|
||||||
|
if (mediaData == null || mediaData.isEmpty()) {
|
||||||
|
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从 mediaData 解析 sdkfileid
|
||||||
|
String sdkfileid = null;
|
||||||
|
try {
|
||||||
|
Map<String, Object> mediaMap = JsonUtils.fromJsonMap(mediaData);
|
||||||
|
if (mediaMap != null) {
|
||||||
|
sdkfileid = (String) mediaMap.get("sdkfileid");
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("解析 mediaData 失败: id={}, mediaData={}", id, mediaData);
|
||||||
|
}
|
||||||
|
if (sdkfileid == null || sdkfileid.isEmpty()) {
|
||||||
|
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建缓存路径
|
||||||
|
String ext = getMediaExtension(msgtype);
|
||||||
|
String cacheFileName = message.getMsgid().replaceAll("[^a-zA-Z0-9_-]", "_") + ext;
|
||||||
|
File cacheFile = new File(MEDIA_CACHE_DIR, cacheFileName);
|
||||||
|
|
||||||
|
// 如果缓存不存在,下载媒体
|
||||||
|
if (!cacheFile.exists()) {
|
||||||
|
boolean success = archivePullService.downloadMedia(sdkfileid, cacheFile.getAbsolutePath());
|
||||||
|
if (!success) {
|
||||||
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
byte[] data = Files.readAllBytes(cacheFile.toPath());
|
||||||
|
String contentType = getMediaContentType(msgtype);
|
||||||
|
HttpHeaders headers = new HttpHeaders();
|
||||||
|
headers.setContentType(MediaType.parseMediaType(contentType));
|
||||||
|
headers.setContentLength(data.length);
|
||||||
|
return new ResponseEntity<>(data, headers, HttpStatus.OK);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("读取媒体文件失败: {}", cacheFile.getAbsolutePath(), e);
|
||||||
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getMediaExtension(String msgtype) {
|
||||||
|
if ("image".equals(msgtype)) return ".jpg";
|
||||||
|
if ("voice".equals(msgtype)) return ".amr";
|
||||||
|
if ("video".equals(msgtype)) return ".mp4";
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getMediaContentType(String msgtype) {
|
||||||
|
if ("image".equals(msgtype)) return "image/jpeg";
|
||||||
|
if ("voice".equals(msgtype)) return "audio/amr";
|
||||||
|
if ("video".equals(msgtype)) return "video/mp4";
|
||||||
|
return "application/octet-stream";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页查询存档消息
|
||||||
|
*/
|
||||||
|
@GetMapping("/messages")
|
||||||
|
public Result<PageResult<ArchiveMessageVO>> listMessages(
|
||||||
|
@RequestParam(defaultValue = "1") Integer page,
|
||||||
|
@RequestParam(defaultValue = "20") Integer size,
|
||||||
|
@RequestParam(required = false) String keyword,
|
||||||
|
@RequestParam(required = false) String msgtype,
|
||||||
|
@RequestParam(required = false) String fromUser,
|
||||||
|
@RequestParam(required = false) String fromRole,
|
||||||
|
@RequestParam(required = false) String sessionId,
|
||||||
|
@RequestParam(required = false) Long startTime,
|
||||||
|
@RequestParam(required = false) Long endTime,
|
||||||
|
@RequestParam(required = false) String toUser) {
|
||||||
|
|
||||||
|
LambdaQueryWrapper<ArchiveMessage> wrapper = new LambdaQueryWrapper<>();
|
||||||
|
wrapper.orderByDesc(ArchiveMessage::getSeq);
|
||||||
|
|
||||||
|
if (keyword != null && !keyword.isEmpty()) {
|
||||||
|
wrapper.and(w -> w.like(ArchiveMessage::getContent, keyword)
|
||||||
|
.or()
|
||||||
|
.like(ArchiveMessage::getFromUser, keyword));
|
||||||
|
}
|
||||||
|
if (msgtype != null && !msgtype.isEmpty()) {
|
||||||
|
wrapper.eq(ArchiveMessage::getMsgtype, msgtype);
|
||||||
|
}
|
||||||
|
if (fromUser != null && !fromUser.isEmpty()) {
|
||||||
|
wrapper.eq(ArchiveMessage::getFromUser, fromUser);
|
||||||
|
}
|
||||||
|
if (fromRole != null && !fromRole.isEmpty()) {
|
||||||
|
wrapper.eq(ArchiveMessage::getFromRole, fromRole);
|
||||||
|
}
|
||||||
|
if (sessionId != null && !sessionId.isEmpty()) {
|
||||||
|
wrapper.eq(ArchiveMessage::getSessionId, sessionId);
|
||||||
|
}
|
||||||
|
if (toUser != null && !toUser.isEmpty()) {
|
||||||
|
wrapper.and(w -> w.eq(ArchiveMessage::getToUser, toUser)
|
||||||
|
.or()
|
||||||
|
.like(ArchiveMessage::getTolist, "\"" + toUser + "\""));
|
||||||
|
}
|
||||||
|
if (startTime != null) {
|
||||||
|
wrapper.ge(ArchiveMessage::getMsgtime, startTime);
|
||||||
|
}
|
||||||
|
if (endTime != null) {
|
||||||
|
wrapper.le(ArchiveMessage::getMsgtime, endTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
Page<ArchiveMessage> pageParam = new Page<>(page, size);
|
||||||
|
Page<ArchiveMessage> result = archiveMessageMapper.selectPage(pageParam, wrapper);
|
||||||
|
|
||||||
|
List<ArchiveMessageVO> list = result.getRecords().stream().map(this::toVO).collect(Collectors.toList());
|
||||||
|
// 补充发送人昵称
|
||||||
|
enrichSenderNames(list);
|
||||||
|
|
||||||
|
return Result.success(PageResult.of(
|
||||||
|
(int) result.getCurrent(),
|
||||||
|
(int) result.getSize(),
|
||||||
|
result.getTotal(),
|
||||||
|
list
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void enrichSenderNames(List<ArchiveMessageVO> list) {
|
||||||
|
if (list == null || list.isEmpty()) return;
|
||||||
|
|
||||||
|
Set<String> userIds = list.stream()
|
||||||
|
.map(ArchiveMessageVO::getFromUser)
|
||||||
|
.filter(id -> id != null && !id.isEmpty())
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
if (userIds.isEmpty()) return;
|
||||||
|
|
||||||
|
List<Staff> staffs = staffMapper.selectList(
|
||||||
|
new LambdaQueryWrapper<Staff>().in(Staff::getStaffId, userIds));
|
||||||
|
Map<String, String> staffNameMap = staffs.stream()
|
||||||
|
.collect(Collectors.toMap(Staff::getStaffId, s -> s.getName() != null ? s.getName() : s.getStaffId(), (a, b) -> a));
|
||||||
|
|
||||||
|
List<Customer> customers = customerMapper.selectList(
|
||||||
|
new LambdaQueryWrapper<Customer>().in(Customer::getCustomerId, userIds));
|
||||||
|
Map<String, String> customerNameMap = customers.stream()
|
||||||
|
.collect(Collectors.toMap(Customer::getCustomerId, c -> c.getName() != null ? c.getName() : c.getCustomerId(), (a, b) -> a));
|
||||||
|
|
||||||
|
Map<String, String> nameMap = new java.util.HashMap<>();
|
||||||
|
nameMap.putAll(staffNameMap);
|
||||||
|
nameMap.putAll(customerNameMap);
|
||||||
|
|
||||||
|
for (ArchiveMessageVO vo : list) {
|
||||||
|
String uid = vo.getFromUser();
|
||||||
|
if (uid != null && !uid.isEmpty()) {
|
||||||
|
vo.setFromUserName(nameMap.getOrDefault(uid, uid));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取消息详情
|
||||||
|
*/
|
||||||
|
@GetMapping("/messages/{id}")
|
||||||
|
public Result<ArchiveMessageVO> getMessage(@PathVariable Long id) {
|
||||||
|
ArchiveMessage message = archiveMessageMapper.selectById(id);
|
||||||
|
if (message == null) {
|
||||||
|
return Result.fail("消息不存在");
|
||||||
|
}
|
||||||
|
return Result.success(toVO(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计概览
|
||||||
|
*/
|
||||||
|
@GetMapping("/stats")
|
||||||
|
public Result<ArchiveStatsVO> getStats() {
|
||||||
|
LocalDateTime todayStart = LocalDateTime.now().toLocalDate().atStartOfDay();
|
||||||
|
|
||||||
|
// 消息统计
|
||||||
|
Long total = archiveMessageMapper.selectCount(new LambdaQueryWrapper<>());
|
||||||
|
Long todayCount = archiveMessageMapper.selectCount(
|
||||||
|
new LambdaQueryWrapper<ArchiveMessage>()
|
||||||
|
.ge(ArchiveMessage::getCreatedAt, todayStart));
|
||||||
|
|
||||||
|
// 总员工/客户
|
||||||
|
Long totalStaff = staffMapper.selectCount(new LambdaQueryWrapper<>());
|
||||||
|
Long totalCustomer = customerMapper.selectCount(new LambdaQueryWrapper<>());
|
||||||
|
|
||||||
|
// 风险事件/敏感词命中
|
||||||
|
Long totalRiskEvent = riskEventMapper.selectCount(new LambdaQueryWrapper<>());
|
||||||
|
Long totalSensitiveHit = sensitiveWordHitMapper.selectCount(new LambdaQueryWrapper<>());
|
||||||
|
|
||||||
|
ArchiveStatsVO stats = new ArchiveStatsVO();
|
||||||
|
stats.setTotalCount(total);
|
||||||
|
stats.setTodayCount(todayCount);
|
||||||
|
stats.setTotalStaff(totalStaff);
|
||||||
|
stats.setTotalCustomer(totalCustomer);
|
||||||
|
stats.setTotalRiskEvent(totalRiskEvent);
|
||||||
|
stats.setTotalSensitiveHit(totalSensitiveHit);
|
||||||
|
return Result.success(stats);
|
||||||
|
}
|
||||||
|
|
||||||
|
private com.artedu.archive.vo.ArchiveMessageVO toVO(ArchiveMessage m) {
|
||||||
|
com.artedu.archive.vo.ArchiveMessageVO vo = new com.artedu.archive.vo.ArchiveMessageVO();
|
||||||
|
vo.setId(m.getId());
|
||||||
|
vo.setMsgid(m.getMsgid());
|
||||||
|
vo.setSeq(m.getSeq());
|
||||||
|
vo.setFromUser(m.getFromUser());
|
||||||
|
vo.setFromRole(m.getFromRole());
|
||||||
|
vo.setToUser(m.getToUser());
|
||||||
|
vo.setTolist(m.getTolist());
|
||||||
|
vo.setRoomid(m.getRoomid());
|
||||||
|
vo.setMsgtype(m.getMsgtype());
|
||||||
|
vo.setContent(m.getContent());
|
||||||
|
vo.setMediaData(m.getMediaData());
|
||||||
|
vo.setSessionId(m.getSessionId());
|
||||||
|
vo.setDecryptStatus(m.getDecryptStatus());
|
||||||
|
vo.setCreatedAt(m.getCreatedAt() != null ? m.getCreatedAt().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) : null);
|
||||||
|
if (m.getMsgtime() != null) {
|
||||||
|
vo.setMsgTimeStr(LocalDateTime.ofInstant(Instant.ofEpochMilli(m.getMsgtime()), ZoneId.systemDefault())
|
||||||
|
.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
|
||||||
|
}
|
||||||
|
return vo;
|
||||||
|
}
|
||||||
|
|
||||||
|
@lombok.Data
|
||||||
|
public static class ArchiveStatsVO {
|
||||||
|
private Long totalCount;
|
||||||
|
private Long todayCount;
|
||||||
|
private Long todayActiveStaff;
|
||||||
|
private Long todayNewCustomer;
|
||||||
|
private Long totalStaff;
|
||||||
|
private Long totalCustomer;
|
||||||
|
private Long totalRiskEvent;
|
||||||
|
private Long totalSensitiveHit;
|
||||||
|
private java.util.Map<String, Long> msgTypeDistribution;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,111 @@
|
|||||||
|
package com.artedu.archive.controller;
|
||||||
|
|
||||||
|
import com.artedu.archive.mapper.ArchiveMessageMapper;
|
||||||
|
import com.artedu.archive.mapper.RiskEventMapper;
|
||||||
|
import com.artedu.archive.mapper.SensitiveWordHitMapper;
|
||||||
|
import com.artedu.common.result.Result;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据看板统计
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/archive/dashboard")
|
||||||
|
public class DashboardController {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ArchiveMessageMapper archiveMessageMapper;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private RiskEventMapper riskEventMapper;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private SensitiveWordHitMapper sensitiveWordHitMapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 趋势统计(近N天)
|
||||||
|
*/
|
||||||
|
@GetMapping("/trend")
|
||||||
|
public Result<TrendVO> getTrend(@RequestParam(defaultValue = "7") int days) {
|
||||||
|
if (days < 1 || days > 90) days = 7;
|
||||||
|
|
||||||
|
List<String> dateList = new ArrayList<>();
|
||||||
|
List<Long> msgList = new ArrayList<>();
|
||||||
|
List<Long> riskList = new ArrayList<>();
|
||||||
|
List<Long> hitList = new ArrayList<>();
|
||||||
|
|
||||||
|
DateTimeFormatter fmt = DateTimeFormatter.ofPattern("MM-dd");
|
||||||
|
|
||||||
|
for (int i = days - 1; i >= 0; i--) {
|
||||||
|
LocalDate date = LocalDate.now().minusDays(i);
|
||||||
|
LocalDateTime start = date.atStartOfDay();
|
||||||
|
LocalDateTime end = date.plusDays(1).atStartOfDay();
|
||||||
|
|
||||||
|
dateList.add(date.format(fmt));
|
||||||
|
|
||||||
|
// 每日消息量
|
||||||
|
Long msgCount = archiveMessageMapper.selectCount(
|
||||||
|
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<
|
||||||
|
com.artedu.archive.entity.ArchiveMessage>()
|
||||||
|
.ge(com.artedu.archive.entity.ArchiveMessage::getCreatedAt, start)
|
||||||
|
.lt(com.artedu.archive.entity.ArchiveMessage::getCreatedAt, end));
|
||||||
|
msgList.add(msgCount != null ? msgCount : 0L);
|
||||||
|
|
||||||
|
// 每日风险事件
|
||||||
|
Long riskCount = riskEventMapper.selectCount(
|
||||||
|
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<
|
||||||
|
com.artedu.archive.entity.RiskEvent>()
|
||||||
|
.ge(com.artedu.archive.entity.RiskEvent::getCreatedAt, start)
|
||||||
|
.lt(com.artedu.archive.entity.RiskEvent::getCreatedAt, end));
|
||||||
|
riskList.add(riskCount != null ? riskCount : 0L);
|
||||||
|
|
||||||
|
// 每日敏感词命中
|
||||||
|
Long hitCount = sensitiveWordHitMapper.selectCount(
|
||||||
|
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<
|
||||||
|
com.artedu.archive.entity.SensitiveWordHit>()
|
||||||
|
.ge(com.artedu.archive.entity.SensitiveWordHit::getCreatedAt, start)
|
||||||
|
.lt(com.artedu.archive.entity.SensitiveWordHit::getCreatedAt, end));
|
||||||
|
hitList.add(hitCount != null ? hitCount : 0L);
|
||||||
|
}
|
||||||
|
|
||||||
|
TrendVO vo = new TrendVO();
|
||||||
|
vo.setDates(dateList);
|
||||||
|
vo.setMessageCounts(msgList);
|
||||||
|
vo.setRiskCounts(riskList);
|
||||||
|
vo.setSensitiveHitCounts(hitList);
|
||||||
|
return Result.success(vo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 消息类型分布
|
||||||
|
*/
|
||||||
|
@GetMapping("/msg-type-distribution")
|
||||||
|
public Result<List<Map<String, Object>>> getMsgTypeDistribution() {
|
||||||
|
// 使用 MyBatis Plus 自定义 SQL 查询
|
||||||
|
List<Map<String, Object>> list = archiveMessageMapper.selectMsgTypeDistribution();
|
||||||
|
return Result.success(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class TrendVO {
|
||||||
|
private List<String> dates;
|
||||||
|
private List<Long> messageCounts;
|
||||||
|
private List<Long> riskCounts;
|
||||||
|
private List<Long> sensitiveHitCounts;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,139 @@
|
|||||||
|
package com.artedu.archive.controller;
|
||||||
|
|
||||||
|
import com.artedu.archive.entity.SensitiveWord;
|
||||||
|
import com.artedu.archive.entity.SensitiveWordHit;
|
||||||
|
import com.artedu.archive.mapper.SensitiveWordHitMapper;
|
||||||
|
import com.artedu.archive.mapper.SensitiveWordMapper;
|
||||||
|
import com.artedu.archive.service.ArchiveRiskService;
|
||||||
|
import com.artedu.archive.service.SensitiveWordScanner;
|
||||||
|
import com.artedu.common.result.Result;
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.validation.annotation.Validated;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import javax.validation.constraints.Min;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 风控管理控制器
|
||||||
|
* 敏感词库、命中记录等
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Validated
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/archive")
|
||||||
|
public class RiskController {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private SensitiveWordMapper sensitiveWordMapper;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private SensitiveWordHitMapper sensitiveWordHitMapper;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ArchiveRiskService archiveRiskService;
|
||||||
|
|
||||||
|
// ========== 敏感词库管理 ==========
|
||||||
|
|
||||||
|
@GetMapping("/sensitive-words")
|
||||||
|
public Result<List<SensitiveWord>> listSensitiveWords(
|
||||||
|
@RequestParam(required = false) String keyword,
|
||||||
|
@RequestParam(required = false) Integer status) {
|
||||||
|
return Result.success(archiveRiskService.listSensitiveWords(keyword, status));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/sensitive-words")
|
||||||
|
public Result<Long> addSensitiveWord(@RequestBody SensitiveWord word) {
|
||||||
|
// 检查是否已存在
|
||||||
|
LambdaQueryWrapper<SensitiveWord> wrapper = new LambdaQueryWrapper<>();
|
||||||
|
wrapper.eq(SensitiveWord::getWord, word.getWord());
|
||||||
|
SensitiveWord existing = sensitiveWordMapper.selectOne(wrapper);
|
||||||
|
if (existing != null) {
|
||||||
|
return Result.fail("敏感词已存在");
|
||||||
|
}
|
||||||
|
word.setMatchCount(0);
|
||||||
|
sensitiveWordMapper.insert(word);
|
||||||
|
return Result.success(word.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/sensitive-words/{id}")
|
||||||
|
public Result<String> updateSensitiveWord(@PathVariable Long id, @RequestBody SensitiveWord word) {
|
||||||
|
word.setId(id);
|
||||||
|
sensitiveWordMapper.updateById(word);
|
||||||
|
return Result.success("更新成功");
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/sensitive-words/{id}")
|
||||||
|
public Result<String> deleteSensitiveWord(@PathVariable Long id) {
|
||||||
|
sensitiveWordMapper.deleteById(id);
|
||||||
|
return Result.success("删除成功");
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/sensitive-words/batch")
|
||||||
|
public Result<String> batchAddSensitiveWords(@RequestBody String wordsText) {
|
||||||
|
if (wordsText == null || wordsText.isEmpty()) {
|
||||||
|
return Result.fail("敏感词内容不能为空");
|
||||||
|
}
|
||||||
|
String[] words = wordsText.split("[\n,,;;]");
|
||||||
|
int count = 0;
|
||||||
|
for (String word : words) {
|
||||||
|
word = word.trim();
|
||||||
|
if (word.isEmpty()) continue;
|
||||||
|
|
||||||
|
LambdaQueryWrapper<SensitiveWord> wrapper = new LambdaQueryWrapper<>();
|
||||||
|
wrapper.eq(SensitiveWord::getWord, word);
|
||||||
|
if (sensitiveWordMapper.selectCount(wrapper) > 0) continue;
|
||||||
|
|
||||||
|
SensitiveWord sw = new SensitiveWord();
|
||||||
|
sw.setWord(word);
|
||||||
|
sw.setCategory("自定义");
|
||||||
|
sw.setTargetType("全部");
|
||||||
|
sw.setChatType("全部");
|
||||||
|
sw.setStatus(1);
|
||||||
|
sensitiveWordMapper.insert(sw);
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
return Result.success("成功导入 " + count + " 个敏感词");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 敏感词测试 ==========
|
||||||
|
|
||||||
|
@PostMapping("/sensitive-words/test")
|
||||||
|
public Result<List<String>> testSensitiveWords(@RequestBody String content) {
|
||||||
|
if (content == null || content.isEmpty()) {
|
||||||
|
return Result.success(java.util.Collections.emptyList());
|
||||||
|
}
|
||||||
|
List<SensitiveWordScanner.HitResult> hits = archiveRiskService.testScan(content);
|
||||||
|
return Result.success(hits.stream().map(h -> h.getWord() + "(x" + h.getCount() + ")").collect(java.util.stream.Collectors.toList()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 批量扫描历史消息 ==========
|
||||||
|
|
||||||
|
@PostMapping("/sensitive-words/scan-history")
|
||||||
|
public Result<String> scanHistoryMessages() {
|
||||||
|
int count = archiveRiskService.scanHistoryMessages();
|
||||||
|
return Result.success("扫描完成,新增 " + count + " 条命中记录");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 敏感词命中记录 ==========
|
||||||
|
|
||||||
|
@GetMapping("/sensitive-word-hits")
|
||||||
|
public Result<List<SensitiveWordHit>> listSensitiveWordHits(
|
||||||
|
@RequestParam(required = false) Long wordId,
|
||||||
|
@RequestParam(required = false) String senderId,
|
||||||
|
@RequestParam(required = false) String sessionId,
|
||||||
|
@RequestParam(defaultValue = "1") @Min(1) Integer page,
|
||||||
|
@RequestParam(defaultValue = "20") @Min(1) Integer size) {
|
||||||
|
LambdaQueryWrapper<SensitiveWordHit> wrapper = new LambdaQueryWrapper<>();
|
||||||
|
if (wordId != null) wrapper.eq(SensitiveWordHit::getWordId, wordId);
|
||||||
|
if (senderId != null && !senderId.isEmpty()) wrapper.eq(SensitiveWordHit::getSenderId, senderId);
|
||||||
|
if (sessionId != null && !sessionId.isEmpty()) wrapper.eq(SensitiveWordHit::getSessionId, sessionId);
|
||||||
|
wrapper.orderByDesc(SensitiveWordHit::getCreatedAt);
|
||||||
|
wrapper.last("LIMIT " + ((page - 1) * size) + ", " + size);
|
||||||
|
return Result.success(sensitiveWordHitMapper.selectList(wrapper));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,92 @@
|
|||||||
|
package com.artedu.archive.controller;
|
||||||
|
|
||||||
|
import com.artedu.archive.entity.RiskConfig;
|
||||||
|
import com.artedu.archive.entity.RiskEvent;
|
||||||
|
import com.artedu.archive.mapper.RiskConfigMapper;
|
||||||
|
import com.artedu.archive.mapper.RiskEventMapper;
|
||||||
|
import com.artedu.archive.service.ArchiveRiskService;
|
||||||
|
import com.artedu.common.result.PageResult;
|
||||||
|
import com.artedu.common.result.Result;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.validation.annotation.Validated;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import javax.validation.constraints.Min;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 风险事件监控控制器
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Validated
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/archive")
|
||||||
|
public class RiskEventController {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private RiskConfigMapper riskConfigMapper;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private RiskEventMapper riskEventMapper;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ArchiveRiskService archiveRiskService;
|
||||||
|
|
||||||
|
// ========== 风险事件配置 ==========
|
||||||
|
|
||||||
|
@GetMapping("/risk-configs")
|
||||||
|
public Result<List<RiskConfig>> listRiskConfigs() {
|
||||||
|
return Result.success(riskConfigMapper.selectList(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/risk-configs/{id}")
|
||||||
|
public Result<String> updateRiskConfig(@PathVariable Long id, @RequestBody RiskConfig config) {
|
||||||
|
config.setId(id);
|
||||||
|
riskConfigMapper.updateById(config);
|
||||||
|
return Result.success("更新成功");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 风险事件记录 ==========
|
||||||
|
|
||||||
|
@GetMapping("/risk-events")
|
||||||
|
public Result<PageResult<RiskEvent>> listRiskEvents(
|
||||||
|
@RequestParam(required = false) String eventType,
|
||||||
|
@RequestParam(required = false) Integer status,
|
||||||
|
@RequestParam(required = false) String userId,
|
||||||
|
@RequestParam(defaultValue = "1") @Min(1) Integer page,
|
||||||
|
@RequestParam(defaultValue = "20") @Min(1) Integer size) {
|
||||||
|
return Result.success(archiveRiskService.listRiskEvents(eventType, status, userId, page, size));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/risk-events/stats")
|
||||||
|
public Result<Map<String, Long>> getRiskStats() {
|
||||||
|
return Result.success(archiveRiskService.getRiskStats());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/risk-events/{id}/handle")
|
||||||
|
public Result<String> handleRiskEvent(@PathVariable Long id, @RequestBody HandleRequest request) {
|
||||||
|
RiskEvent event = new RiskEvent();
|
||||||
|
event.setId(id);
|
||||||
|
event.setStatus(request.getStatus());
|
||||||
|
event.setHandler(request.getHandler());
|
||||||
|
event.setHandleNote(request.getHandleNote());
|
||||||
|
event.setHandledAt(java.time.LocalDateTime.now());
|
||||||
|
riskEventMapper.updateById(event);
|
||||||
|
return Result.success("处理成功");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class HandleRequest {
|
||||||
|
private Integer status;
|
||||||
|
private String handler;
|
||||||
|
private String handleNote;
|
||||||
|
|
||||||
|
public Integer getStatus() { return status; }
|
||||||
|
public void setStatus(Integer status) { this.status = status; }
|
||||||
|
public String getHandler() { return handler; }
|
||||||
|
public void setHandler(String handler) { this.handler = handler; }
|
||||||
|
public String getHandleNote() { return handleNote; }
|
||||||
|
public void setHandleNote(String handleNote) { this.handleNote = handleNote; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,40 @@
|
|||||||
|
package com.artedu.archive.controller;
|
||||||
|
|
||||||
|
import com.artedu.archive.service.RiskEventNotifyService;
|
||||||
|
import com.artedu.common.result.Result;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 风险事件实时通知 (SSE)
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/archive")
|
||||||
|
public class RiskEventNotifyController {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private RiskEventNotifyService notifyService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 订阅风险事件实时推送
|
||||||
|
*/
|
||||||
|
@GetMapping(value = "/risk-events/stream", produces = "text/event-stream;charset=UTF-8")
|
||||||
|
public SseEmitter streamRiskEvents(
|
||||||
|
@RequestParam(required = false) String clientId) {
|
||||||
|
String id = clientId != null && !clientId.isEmpty() ? clientId : UUID.randomUUID().toString();
|
||||||
|
return notifyService.subscribe(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前连接数(调试用)
|
||||||
|
*/
|
||||||
|
@GetMapping("/risk-events/stream/stats")
|
||||||
|
public Result<Integer> getStreamStats() {
|
||||||
|
return Result.success(notifyService.getConnectionCount());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -35,8 +35,6 @@ public class ArchiveMessage {
|
|||||||
private String sessionId;
|
private String sessionId;
|
||||||
private Integer decryptStatus;
|
private Integer decryptStatus;
|
||||||
private String decryptError;
|
private String decryptError;
|
||||||
@TableLogic
|
|
||||||
private Integer deleted;
|
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
private LocalDateTime updatedAt;
|
private LocalDateTime updatedAt;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,41 @@
|
|||||||
|
package com.artedu.archive.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 客户实体
|
||||||
|
* 对应customers表
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@TableName("customers")
|
||||||
|
public class Customer {
|
||||||
|
|
||||||
|
@TableId(type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
private String customerId;
|
||||||
|
private String corpId;
|
||||||
|
private String name;
|
||||||
|
private String avatar;
|
||||||
|
private String type;
|
||||||
|
private String gender;
|
||||||
|
private String phone;
|
||||||
|
private String unionid;
|
||||||
|
private LocalDateTime addTime;
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
// 统计字段
|
||||||
|
private Long firstMsgTime;
|
||||||
|
private Long lastMsgTime;
|
||||||
|
private Integer msgCount;
|
||||||
|
private Integer todayMsgCount;
|
||||||
|
private String staffIds;
|
||||||
|
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
}
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
package com.artedu.archive.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 风险事件配置实体
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@TableName("archive_risk_configs")
|
||||||
|
public class RiskConfig {
|
||||||
|
|
||||||
|
@TableId(type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
private String eventType;
|
||||||
|
private String eventName;
|
||||||
|
private Integer enabled;
|
||||||
|
private Integer threshold;
|
||||||
|
private String description;
|
||||||
|
private String corpId;
|
||||||
|
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
}
|
||||||
@ -0,0 +1,40 @@
|
|||||||
|
package com.artedu.archive.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 风险事件记录实体
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@TableName("archive_risk_events")
|
||||||
|
public class RiskEvent {
|
||||||
|
|
||||||
|
@TableId(type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
private String eventType;
|
||||||
|
private String eventName;
|
||||||
|
private Integer status;
|
||||||
|
private String userId;
|
||||||
|
private String userName;
|
||||||
|
private String userRole;
|
||||||
|
private String relatedUser;
|
||||||
|
private String relatedUserName;
|
||||||
|
private String description;
|
||||||
|
private Long msgId;
|
||||||
|
private String sessionId;
|
||||||
|
private LocalDateTime occurredAt;
|
||||||
|
private LocalDateTime reportAt;
|
||||||
|
private String handler;
|
||||||
|
private String handleNote;
|
||||||
|
private LocalDateTime handledAt;
|
||||||
|
private String corpId;
|
||||||
|
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
}
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
package com.artedu.archive.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 敏感词实体
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@TableName("archive_sensitive_words")
|
||||||
|
public class SensitiveWord {
|
||||||
|
|
||||||
|
@TableId(type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
private String word;
|
||||||
|
private String category;
|
||||||
|
private String targetType;
|
||||||
|
private String chatType;
|
||||||
|
private Integer matchCount;
|
||||||
|
private Integer status;
|
||||||
|
private String corpId;
|
||||||
|
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
}
|
||||||
@ -0,0 +1,32 @@
|
|||||||
|
package com.artedu.archive.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 敏感词命中记录实体
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@TableName("archive_sensitive_word_hits")
|
||||||
|
public class SensitiveWordHit {
|
||||||
|
|
||||||
|
@TableId(type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
private Long wordId;
|
||||||
|
private String word;
|
||||||
|
private Long msgId;
|
||||||
|
private String msgContent;
|
||||||
|
private String senderId;
|
||||||
|
private String senderRole;
|
||||||
|
private String receiverId;
|
||||||
|
private String sessionId;
|
||||||
|
private Integer hitCount;
|
||||||
|
private String corpId;
|
||||||
|
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
}
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
package com.artedu.archive.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 员工实体(archive-service 中使用,与 auth-service 的 Staff 对应同一张表)
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@TableName("staffs")
|
||||||
|
public class Staff {
|
||||||
|
|
||||||
|
@TableId(type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
private String staffId;
|
||||||
|
private String corpId;
|
||||||
|
private String name;
|
||||||
|
private String avatar;
|
||||||
|
private String department;
|
||||||
|
private String role;
|
||||||
|
private String status;
|
||||||
|
private String pushSettings;
|
||||||
|
private LocalDateTime lastLoginTime;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
}
|
||||||
@ -0,0 +1,52 @@
|
|||||||
|
package com.artedu.archive.excel;
|
||||||
|
|
||||||
|
import com.alibaba.excel.annotation.ExcelProperty;
|
||||||
|
import com.alibaba.excel.annotation.write.style.ColumnWidth;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 风险事件导出Excel实体
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class RiskEventExcel {
|
||||||
|
|
||||||
|
@ExcelProperty("事件类型")
|
||||||
|
@ColumnWidth(15)
|
||||||
|
private String eventType;
|
||||||
|
|
||||||
|
@ExcelProperty("事件名称")
|
||||||
|
@ColumnWidth(20)
|
||||||
|
private String eventName;
|
||||||
|
|
||||||
|
@ExcelProperty("状态")
|
||||||
|
@ColumnWidth(12)
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
@ExcelProperty("用户ID")
|
||||||
|
@ColumnWidth(20)
|
||||||
|
private String userId;
|
||||||
|
|
||||||
|
@ExcelProperty("用户角色")
|
||||||
|
@ColumnWidth(12)
|
||||||
|
private String userRole;
|
||||||
|
|
||||||
|
@ExcelProperty("关联用户")
|
||||||
|
@ColumnWidth(20)
|
||||||
|
private String relatedUser;
|
||||||
|
|
||||||
|
@ExcelProperty("描述")
|
||||||
|
@ColumnWidth(40)
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
@ExcelProperty("发生时间")
|
||||||
|
@ColumnWidth(20)
|
||||||
|
private String occurredAt;
|
||||||
|
|
||||||
|
@ExcelProperty("处理人")
|
||||||
|
@ColumnWidth(15)
|
||||||
|
private String handler;
|
||||||
|
|
||||||
|
@ExcelProperty("处理备注")
|
||||||
|
@ColumnWidth(30)
|
||||||
|
private String handleNote;
|
||||||
|
}
|
||||||
@ -0,0 +1,44 @@
|
|||||||
|
package com.artedu.archive.excel;
|
||||||
|
|
||||||
|
import com.alibaba.excel.annotation.ExcelProperty;
|
||||||
|
import com.alibaba.excel.annotation.write.style.ColumnWidth;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 敏感词命中记录导出Excel实体
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class SensitiveWordHitExcel {
|
||||||
|
|
||||||
|
@ExcelProperty("敏感词")
|
||||||
|
@ColumnWidth(15)
|
||||||
|
private String word;
|
||||||
|
|
||||||
|
@ExcelProperty("消息内容")
|
||||||
|
@ColumnWidth(50)
|
||||||
|
private String msgContent;
|
||||||
|
|
||||||
|
@ExcelProperty("发送者ID")
|
||||||
|
@ColumnWidth(20)
|
||||||
|
private String senderId;
|
||||||
|
|
||||||
|
@ExcelProperty("发送者角色")
|
||||||
|
@ColumnWidth(12)
|
||||||
|
private String senderRole;
|
||||||
|
|
||||||
|
@ExcelProperty("接收者ID")
|
||||||
|
@ColumnWidth(20)
|
||||||
|
private String receiverId;
|
||||||
|
|
||||||
|
@ExcelProperty("会话ID")
|
||||||
|
@ColumnWidth(35)
|
||||||
|
private String sessionId;
|
||||||
|
|
||||||
|
@ExcelProperty("命中次数")
|
||||||
|
@ColumnWidth(10)
|
||||||
|
private Integer hitCount;
|
||||||
|
|
||||||
|
@ExcelProperty("命中时间")
|
||||||
|
@ColumnWidth(20)
|
||||||
|
private String createdAt;
|
||||||
|
}
|
||||||
@ -5,6 +5,10 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
|||||||
import org.apache.ibatis.annotations.Mapper;
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
import org.apache.ibatis.annotations.Param;
|
import org.apache.ibatis.annotations.Param;
|
||||||
import org.apache.ibatis.annotations.Select;
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
import org.apache.ibatis.annotations.Update;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 存档消息数据访问层
|
* 存档消息数据访问层
|
||||||
@ -15,6 +19,77 @@ public interface ArchiveMessageMapper extends BaseMapper<ArchiveMessage> {
|
|||||||
@Select("SELECT MAX(seq) FROM archive_messages WHERE corp_id = #{corpId}")
|
@Select("SELECT MAX(seq) FROM archive_messages WHERE corp_id = #{corpId}")
|
||||||
Long selectMaxSeq(@Param("corpId") String corpId);
|
Long selectMaxSeq(@Param("corpId") String corpId);
|
||||||
|
|
||||||
|
@Select("SELECT MIN(seq) FROM archive_messages WHERE corp_id = #{corpId}")
|
||||||
|
Long selectMinSeq(@Param("corpId") String corpId);
|
||||||
|
|
||||||
@Select("SELECT COUNT(*) FROM archive_messages WHERE msgid = #{msgid} AND corp_id = #{corpId}")
|
@Select("SELECT COUNT(*) FROM archive_messages WHERE msgid = #{msgid} AND corp_id = #{corpId}")
|
||||||
int countByMsgId(@Param("msgid") String msgid, @Param("corpId") String corpId);
|
int countByMsgId(@Param("msgid") String msgid, @Param("corpId") String corpId);
|
||||||
|
|
||||||
|
@Update("UPDATE archive_messages SET " +
|
||||||
|
"from_user = #{msg.fromUser}, " +
|
||||||
|
"from_role = #{msg.fromRole}, " +
|
||||||
|
"to_user = #{msg.toUser}, " +
|
||||||
|
"tolist = #{msg.tolist}, " +
|
||||||
|
"roomid = #{msg.roomid}, " +
|
||||||
|
"msgtype = #{msg.msgtype}, " +
|
||||||
|
"msgtime = #{msg.msgtime}, " +
|
||||||
|
"action = #{msg.action}, " +
|
||||||
|
"content = #{msg.content}, " +
|
||||||
|
"media_data = #{msg.mediaData}, " +
|
||||||
|
"session_id = #{msg.sessionId}, " +
|
||||||
|
"decrypt_status = #{msg.decryptStatus}, " +
|
||||||
|
"decrypt_error = #{msg.decryptError}, " +
|
||||||
|
"updated_at = NOW() " +
|
||||||
|
"WHERE msgid = #{msg.msgid} AND corp_id = #{msg.corpId}")
|
||||||
|
int updateByMsgId(@Param("msg") ArchiveMessage message);
|
||||||
|
|
||||||
|
@Select("SELECT DISTINCT from_user FROM archive_messages WHERE corp_id = #{corpId} AND from_user IS NOT NULL")
|
||||||
|
List<String> selectDistinctFromUsers(@Param("corpId") String corpId);
|
||||||
|
|
||||||
|
@Select("SELECT DISTINCT to_user FROM archive_messages WHERE corp_id = #{corpId} AND to_user IS NOT NULL AND to_user != ''")
|
||||||
|
List<String> selectDistinctToUsers(@Param("corpId") String corpId);
|
||||||
|
|
||||||
|
@Select("SELECT * FROM archive_messages WHERE (from_user = #{customerId} OR to_user = #{customerId}) AND corp_id = #{corpId} ORDER BY msgtime DESC LIMIT #{limit}")
|
||||||
|
List<ArchiveMessage> selectLatestByCustomer(@Param("customerId") String customerId, @Param("corpId") String corpId, @Param("limit") int limit);
|
||||||
|
|
||||||
|
@Select("SELECT * FROM archive_messages WHERE session_id = #{sessionId} AND corp_id = #{corpId} ORDER BY msgtime ASC LIMIT #{offset}, #{limit}")
|
||||||
|
List<ArchiveMessage> selectBySessionId(@Param("sessionId") String sessionId, @Param("corpId") String corpId, @Param("offset") int offset, @Param("limit") int limit);
|
||||||
|
|
||||||
|
@Select("SELECT COUNT(*) FROM archive_messages WHERE session_id = #{sessionId} AND corp_id = #{corpId}")
|
||||||
|
Long countBySessionId(@Param("sessionId") String sessionId, @Param("corpId") String corpId);
|
||||||
|
|
||||||
|
@Select("SELECT session_id, COUNT(*) as cnt, MAX(msgtime) as last_time " +
|
||||||
|
"FROM archive_messages WHERE corp_id = #{corpId} AND (from_user = #{userId} OR to_user = #{userId}) " +
|
||||||
|
"AND session_id IS NOT NULL GROUP BY session_id ORDER BY last_time DESC LIMIT #{offset}, #{limit}")
|
||||||
|
List<java.util.Map<String, Object>> selectSessionsByUser(@Param("userId") String userId, @Param("corpId") String corpId, @Param("offset") int offset, @Param("limit") int limit);
|
||||||
|
|
||||||
|
@Select("SELECT COUNT(DISTINCT session_id) FROM archive_messages WHERE corp_id = #{corpId} AND (from_user = #{userId} OR to_user = #{userId}) AND session_id IS NOT NULL")
|
||||||
|
Long countSessionsByUser(@Param("userId") String userId, @Param("corpId") String corpId);
|
||||||
|
|
||||||
|
@Select("SELECT COUNT(*) FROM archive_messages WHERE corp_id = #{corpId} AND from_user = #{staffId} AND DATE(FROM_UNIXTIME(msgtime/1000)) = CURDATE()")
|
||||||
|
Integer countTodayMsgByStaff(@Param("staffId") String staffId, @Param("corpId") String corpId);
|
||||||
|
|
||||||
|
@Select("SELECT COUNT(*) FROM archive_messages WHERE corp_id = #{corpId} AND (from_user = #{customerId} OR to_user = #{customerId}) AND DATE(FROM_UNIXTIME(msgtime/1000)) = CURDATE()")
|
||||||
|
Integer countTodayMsgByCustomer(@Param("customerId") String customerId, @Param("corpId") String corpId);
|
||||||
|
|
||||||
|
@Select("SELECT MAX(msgtime) FROM archive_messages WHERE corp_id = #{corpId} AND from_user = #{userId}")
|
||||||
|
Long selectLastMsgTimeByUser(@Param("userId") String userId, @Param("corpId") String corpId);
|
||||||
|
|
||||||
|
@Select("SELECT MAX(msgtime) FROM archive_messages WHERE corp_id = #{corpId} AND (from_user = #{userId} OR to_user = #{userId})")
|
||||||
|
Long selectLastMsgTimeByAnyUser(@Param("userId") String userId, @Param("corpId") String corpId);
|
||||||
|
|
||||||
|
@Select("SELECT COUNT(DISTINCT from_user) FROM archive_messages " +
|
||||||
|
"WHERE corp_id = #{corpId} AND from_role = 'EXTERNAL' " +
|
||||||
|
"AND session_id IN (" +
|
||||||
|
" SELECT DISTINCT session_id FROM archive_messages " +
|
||||||
|
" WHERE corp_id = #{corpId} AND session_id IS NOT NULL " +
|
||||||
|
" AND (from_user = #{staffId} OR to_user LIKE CONCAT('%', #{staffId}, '%'))" +
|
||||||
|
")")
|
||||||
|
Integer countCustomersByStaff(@Param("staffId") String staffId, @Param("corpId") String corpId);
|
||||||
|
|
||||||
|
@Select("SELECT msgtype as name, COUNT(*) as value FROM archive_messages GROUP BY msgtype ORDER BY value DESC")
|
||||||
|
List<Map<String, Object>> selectMsgTypeDistribution();
|
||||||
|
|
||||||
|
@Select("SELECT DISTINCT from_user FROM archive_messages WHERE corp_id = #{corpId} AND from_role = 'INTERNAL'")
|
||||||
|
List<String> selectStaffIdsWithArchive(@Param("corpId") String corpId);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,17 @@
|
|||||||
|
package com.artedu.archive.mapper;
|
||||||
|
|
||||||
|
import com.artedu.archive.entity.Customer;
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 客户数据访问层
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface CustomerMapper extends BaseMapper<Customer> {
|
||||||
|
|
||||||
|
@Select("SELECT * FROM customers WHERE customer_id = #{customerId} AND corp_id = #{corpId} LIMIT 1")
|
||||||
|
Customer selectByCustomerId(@Param("customerId") String customerId, @Param("corpId") String corpId);
|
||||||
|
}
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
package com.artedu.archive.mapper;
|
||||||
|
|
||||||
|
import com.artedu.archive.entity.RiskConfig;
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface RiskConfigMapper extends BaseMapper<RiskConfig> {
|
||||||
|
|
||||||
|
@Select("SELECT * FROM archive_risk_configs WHERE enabled = 1 AND corp_id = #{corpId}")
|
||||||
|
List<RiskConfig> selectEnabledByCorp(@Param("corpId") String corpId);
|
||||||
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
package com.artedu.archive.mapper;
|
||||||
|
|
||||||
|
import com.artedu.archive.entity.RiskEvent;
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface RiskEventMapper extends BaseMapper<RiskEvent> {
|
||||||
|
}
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
package com.artedu.archive.mapper;
|
||||||
|
|
||||||
|
import com.artedu.archive.entity.SensitiveWordHit;
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface SensitiveWordHitMapper extends BaseMapper<SensitiveWordHit> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查某条消息的敏感词命中是否已存在
|
||||||
|
*/
|
||||||
|
@Select("SELECT COUNT(*) FROM archive_sensitive_word_hits WHERE msg_id = #{msgId} AND word_id = #{wordId}")
|
||||||
|
int countByMsgAndWord(@Param("msgId") Long msgId, @Param("wordId") Long wordId);
|
||||||
|
}
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
package com.artedu.archive.mapper;
|
||||||
|
|
||||||
|
import com.artedu.archive.entity.SensitiveWord;
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
import org.apache.ibatis.annotations.Update;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface SensitiveWordMapper extends BaseMapper<SensitiveWord> {
|
||||||
|
|
||||||
|
@Select("SELECT * FROM archive_sensitive_words WHERE status = 1 AND corp_id = #{corpId}")
|
||||||
|
List<SensitiveWord> selectEnabledByCorp(@Param("corpId") String corpId);
|
||||||
|
|
||||||
|
@Update("UPDATE archive_sensitive_words SET match_count = match_count + #{count} WHERE id = #{wordId}")
|
||||||
|
int incrementMatchCount(@Param("wordId") Long wordId, @Param("count") int count);
|
||||||
|
}
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
package com.artedu.archive.mapper;
|
||||||
|
|
||||||
|
import com.artedu.archive.entity.Staff;
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface StaffMapper extends BaseMapper<Staff> {
|
||||||
|
|
||||||
|
@Select("SELECT * FROM staffs WHERE staff_id = #{staffId} AND corp_id = #{corpId} LIMIT 1")
|
||||||
|
Staff selectByStaffId(@Param("staffId") String staffId, @Param("corpId") String corpId);
|
||||||
|
}
|
||||||
@ -0,0 +1,494 @@
|
|||||||
|
package com.artedu.archive.service;
|
||||||
|
|
||||||
|
import com.artedu.archive.client.WeComApiClient;
|
||||||
|
import com.artedu.archive.entity.ArchiveMessage;
|
||||||
|
import com.artedu.archive.entity.Customer;
|
||||||
|
import com.artedu.archive.entity.Staff;
|
||||||
|
import com.artedu.archive.mapper.ArchiveMessageMapper;
|
||||||
|
import com.artedu.archive.mapper.CustomerMapper;
|
||||||
|
import com.artedu.archive.mapper.StaffMapper;
|
||||||
|
import com.artedu.common.util.JsonUtils;
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.scheduling.annotation.Async;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 存档联系人服务
|
||||||
|
* 负责从消息中提取员工/客户信息,维护到数据库
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
public class ArchiveContactService {
|
||||||
|
|
||||||
|
@Value("${wecom.archive.corp-id}")
|
||||||
|
private String corpId;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private StaffMapper staffMapper;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private CustomerMapper customerMapper;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ArchiveMessageMapper archiveMessageMapper;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private WeComApiClient weComApiClient;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理消息,提取并维护员工/客户信息
|
||||||
|
*/
|
||||||
|
public void processMessage(ArchiveMessage message) {
|
||||||
|
try {
|
||||||
|
// 处理发送者
|
||||||
|
String fromUser = message.getFromUser();
|
||||||
|
String fromRole = message.getFromRole();
|
||||||
|
if (fromUser != null && !fromUser.isEmpty()) {
|
||||||
|
if ("INTERNAL".equals(fromRole)) {
|
||||||
|
ensureStaffExists(fromUser);
|
||||||
|
} else if ("EXTERNAL".equals(fromRole)) {
|
||||||
|
ensureCustomerExists(fromUser, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理接收者(单聊或群聊的toUser字段)
|
||||||
|
String toUser = message.getToUser();
|
||||||
|
if (toUser != null && !toUser.isEmpty() && !"null".equals(toUser)) {
|
||||||
|
// toUser 可能包含 JSON 数组(如 ["biu"]),需要解析
|
||||||
|
processUserIdField(toUser, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理接收者列表(群聊)
|
||||||
|
String tolist = message.getTolist();
|
||||||
|
if (tolist != null && !tolist.isEmpty() && !"null".equals(tolist)) {
|
||||||
|
try {
|
||||||
|
List<String> receivers = JsonUtils.fromJsonList(tolist, String.class);
|
||||||
|
if (receivers != null) {
|
||||||
|
for (String receiver : receivers) {
|
||||||
|
if (receiver != null && isExternalUserId(receiver)) {
|
||||||
|
ensureCustomerExists(receiver, message);
|
||||||
|
} else if (receiver != null && !receiver.isEmpty()) {
|
||||||
|
ensureStaffExists(receiver);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.debug("解析接收者列表失败: {}", tolist);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("处理消息联系人失败: msgid={}", message.getMsgid(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 确保员工记录存在
|
||||||
|
*/
|
||||||
|
private void ensureStaffExists(String staffId) {
|
||||||
|
try {
|
||||||
|
Staff existing = staffMapper.selectByStaffId(staffId, corpId);
|
||||||
|
if (existing == null) {
|
||||||
|
Staff staff = new Staff();
|
||||||
|
staff.setStaffId(staffId);
|
||||||
|
staff.setCorpId(corpId);
|
||||||
|
staff.setName(staffId); // 默认用ID作为名称
|
||||||
|
staff.setRole("ADVISOR");
|
||||||
|
staff.setStatus("ACTIVE");
|
||||||
|
staffMapper.insert(staff);
|
||||||
|
log.info("自动创建员工记录: staffId={}", staffId);
|
||||||
|
|
||||||
|
// 异步补充资料
|
||||||
|
asyncFillStaffDetail(staffId);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("确保员工记录失败: staffId={}", staffId, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 确保客户记录存在,并更新统计
|
||||||
|
*/
|
||||||
|
private void ensureCustomerExists(String customerId, ArchiveMessage message) {
|
||||||
|
try {
|
||||||
|
Customer existing = customerMapper.selectByCustomerId(customerId, corpId);
|
||||||
|
Long msgTime = message.getMsgtime();
|
||||||
|
String fromUser = message.getFromUser();
|
||||||
|
boolean isNew = false;
|
||||||
|
|
||||||
|
if (existing == null) {
|
||||||
|
Customer customer = new Customer();
|
||||||
|
customer.setCustomerId(customerId);
|
||||||
|
customer.setCorpId(corpId);
|
||||||
|
customer.setName(customerId);
|
||||||
|
customer.setType("UNKNOWN");
|
||||||
|
customer.setStatus("ACTIVE");
|
||||||
|
customer.setMsgCount(0);
|
||||||
|
customer.setTodayMsgCount(0);
|
||||||
|
customerMapper.insert(customer);
|
||||||
|
existing = customer;
|
||||||
|
isNew = true;
|
||||||
|
log.info("自动创建客户记录: customerId={}", customerId);
|
||||||
|
|
||||||
|
// 异步补充资料
|
||||||
|
asyncFillCustomerDetail(customerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新统计
|
||||||
|
if (msgTime != null) {
|
||||||
|
if (existing.getFirstMsgTime() == null || msgTime < existing.getFirstMsgTime()) {
|
||||||
|
existing.setFirstMsgTime(msgTime);
|
||||||
|
}
|
||||||
|
if (existing.getLastMsgTime() == null || msgTime > existing.getLastMsgTime()) {
|
||||||
|
existing.setLastMsgTime(msgTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 今日消息数
|
||||||
|
boolean isToday = isToday(msgTime);
|
||||||
|
if (isToday) {
|
||||||
|
existing.setTodayMsgCount((existing.getTodayMsgCount() != null ? existing.getTodayMsgCount() : 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
existing.setMsgCount((existing.getMsgCount() != null ? existing.getMsgCount() : 0) + 1);
|
||||||
|
|
||||||
|
// 更新关联员工列表
|
||||||
|
String oppositeStaff = "INTERNAL".equals(message.getFromRole()) ? fromUser : message.getToUser();
|
||||||
|
if (oppositeStaff != null && !oppositeStaff.isEmpty() && !isExternalUserId(oppositeStaff)) {
|
||||||
|
updateCustomerStaffIds(existing, oppositeStaff);
|
||||||
|
}
|
||||||
|
|
||||||
|
customerMapper.updateById(existing);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("确保客户记录失败: customerId={}", customerId, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新客户的关联员工列表
|
||||||
|
*/
|
||||||
|
private void updateCustomerStaffIds(Customer customer, String staffId) {
|
||||||
|
try {
|
||||||
|
String staffIds = customer.getStaffIds();
|
||||||
|
Set<String> idSet = new HashSet<>();
|
||||||
|
if (staffIds != null && !staffIds.isEmpty()) {
|
||||||
|
List<String> list = JsonUtils.fromJsonList(staffIds, String.class);
|
||||||
|
if (list != null) idSet.addAll(list);
|
||||||
|
}
|
||||||
|
if (idSet.add(staffId)) {
|
||||||
|
customer.setStaffIds(JsonUtils.toJson(new ArrayList<>(idSet)));
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.debug("更新客户员工列表失败: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断时间戳是否为今天
|
||||||
|
*/
|
||||||
|
private boolean isToday(Long msgTime) {
|
||||||
|
try {
|
||||||
|
LocalDate msgDate = Instant.ofEpochMilli(msgTime).atZone(ZoneId.of("Asia/Shanghai")).toLocalDate();
|
||||||
|
return msgDate.equals(LocalDate.now(ZoneId.of("Asia/Shanghai")));
|
||||||
|
} catch (Exception e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 异步补充员工资料
|
||||||
|
*/
|
||||||
|
@Async
|
||||||
|
public void asyncFillStaffDetail(String staffId) {
|
||||||
|
try {
|
||||||
|
Thread.sleep(100); // 稍微延迟,避免并发冲突
|
||||||
|
Map<String, Object> detail = weComApiClient.getUserDetail(staffId);
|
||||||
|
if (detail == null || detail.isEmpty()) return;
|
||||||
|
|
||||||
|
String name = (String) detail.get("name");
|
||||||
|
String avatar = (String) detail.get("avatar");
|
||||||
|
Object deptObj = detail.get("department");
|
||||||
|
String department = deptObj != null ? JsonUtils.toJson(deptObj) : null;
|
||||||
|
|
||||||
|
Staff staff = staffMapper.selectByStaffId(staffId, corpId);
|
||||||
|
if (staff != null) {
|
||||||
|
if (name != null && !name.isEmpty()) staff.setName(name);
|
||||||
|
if (avatar != null && !avatar.isEmpty()) staff.setAvatar(avatar);
|
||||||
|
if (department != null) staff.setDepartment(department);
|
||||||
|
staffMapper.updateById(staff);
|
||||||
|
log.info("补充员工资料成功: staffId={}, name={}", staffId, name);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("补充员工资料失败: staffId={}", staffId, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 异步补充客户资料
|
||||||
|
*/
|
||||||
|
@Async
|
||||||
|
public void asyncFillCustomerDetail(String customerId) {
|
||||||
|
try {
|
||||||
|
Thread.sleep(100);
|
||||||
|
Map<String, Object> detail = weComApiClient.getExternalContact(customerId);
|
||||||
|
if (detail == null || detail.isEmpty()) return;
|
||||||
|
|
||||||
|
Map<String, Object> contact = (Map<String, Object>) detail.get("external_contact");
|
||||||
|
if (contact == null) return;
|
||||||
|
|
||||||
|
String name = (String) contact.get("name");
|
||||||
|
String avatar = (String) contact.get("avatar");
|
||||||
|
Integer type = contact.get("type") instanceof Number ? ((Number) contact.get("type")).intValue() : null;
|
||||||
|
Integer gender = contact.get("gender") instanceof Number ? ((Number) contact.get("gender")).intValue() : null;
|
||||||
|
String unionid = (String) contact.get("unionid");
|
||||||
|
|
||||||
|
// follow_info 中的 add_time
|
||||||
|
List<Map<String, Object>> follows = (List<Map<String, Object>>) detail.get("follow_user");
|
||||||
|
Long addTime = null;
|
||||||
|
if (follows != null && !follows.isEmpty()) {
|
||||||
|
Object at = follows.get(0).get("createtime");
|
||||||
|
if (at instanceof Number) {
|
||||||
|
addTime = ((Number) at).longValue() * 1000; // 秒转毫秒
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Customer customer = customerMapper.selectByCustomerId(customerId, corpId);
|
||||||
|
if (customer != null) {
|
||||||
|
if (name != null && !name.isEmpty()) customer.setName(name);
|
||||||
|
if (avatar != null && !avatar.isEmpty()) customer.setAvatar(avatar);
|
||||||
|
if (type != null) customer.setType(type == 1 ? "企业" : type == 2 ? "个人" : "UNKNOWN");
|
||||||
|
if (gender != null) customer.setGender(gender == 1 ? "MALE" : gender == 2 ? "FEMALE" : null);
|
||||||
|
if (unionid != null) customer.setUnionid(unionid);
|
||||||
|
if (addTime != null) {
|
||||||
|
customer.setAddTime(new java.sql.Timestamp(addTime).toLocalDateTime());
|
||||||
|
}
|
||||||
|
customerMapper.updateById(customer);
|
||||||
|
log.info("补充客户资料成功: customerId={}, name={}", customerId, name);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("补充客户资料失败: customerId={}", customerId, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理可能包含 JSON 数组的 userId 字段
|
||||||
|
*/
|
||||||
|
private void processUserIdField(String fieldValue, ArchiveMessage message) {
|
||||||
|
if (fieldValue == null || fieldValue.isEmpty()) return;
|
||||||
|
// 检测是否是 JSON 数组
|
||||||
|
if (fieldValue.startsWith("[") && fieldValue.endsWith("]")) {
|
||||||
|
try {
|
||||||
|
List<String> ids = JsonUtils.fromJsonList(fieldValue, String.class);
|
||||||
|
if (ids != null) {
|
||||||
|
for (String id : ids) {
|
||||||
|
if (id != null && !id.isEmpty()) {
|
||||||
|
processSingleUserId(id.trim(), message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.debug("解析userId字段失败: {}", fieldValue);
|
||||||
|
// 回退:按单个值处理
|
||||||
|
processSingleUserId(fieldValue, message);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
processSingleUserId(fieldValue, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理单个 userId
|
||||||
|
*/
|
||||||
|
private void processSingleUserId(String userId, ArchiveMessage message) {
|
||||||
|
if (userId == null || userId.isEmpty()) return;
|
||||||
|
if (isExternalUserId(userId)) {
|
||||||
|
ensureCustomerExists(userId, message);
|
||||||
|
} else {
|
||||||
|
ensureStaffExists(userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断是否为外部联系人ID
|
||||||
|
* 企微外部联系人ID以 wm 或 wo 开头
|
||||||
|
*/
|
||||||
|
private boolean isExternalUserId(String userId) {
|
||||||
|
return userId != null && (userId.startsWith("wm") || userId.startsWith("wo"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全量扫描历史消息,补全 staff/customer 记录
|
||||||
|
*/
|
||||||
|
public int scanHistoricalContacts() {
|
||||||
|
int processed = 0;
|
||||||
|
try {
|
||||||
|
// 查询所有唯一的发送者
|
||||||
|
List<String> fromUsers = archiveMessageMapper.selectDistinctFromUsers(corpId);
|
||||||
|
|
||||||
|
// 查询所有唯一的接收者(可能包含 JSON 数组)
|
||||||
|
List<String> toUsers = archiveMessageMapper.selectDistinctToUsers(corpId);
|
||||||
|
|
||||||
|
Set<String> allUserIds = new HashSet<>();
|
||||||
|
|
||||||
|
// 处理发送者
|
||||||
|
if (fromUsers != null) {
|
||||||
|
for (String uid : fromUsers) {
|
||||||
|
if (uid != null && !uid.isEmpty()) allUserIds.add(uid.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理接收者(解析 JSON 数组)
|
||||||
|
if (toUsers != null) {
|
||||||
|
for (String field : toUsers) {
|
||||||
|
if (field == null || field.isEmpty()) continue;
|
||||||
|
if (field.startsWith("[") && field.endsWith("]")) {
|
||||||
|
try {
|
||||||
|
List<String> ids = JsonUtils.fromJsonList(field, String.class);
|
||||||
|
if (ids != null) {
|
||||||
|
for (String id : ids) {
|
||||||
|
if (id != null && !id.isEmpty()) allUserIds.add(id.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
allUserIds.add(field);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
allUserIds.add(field.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (String userId : allUserIds) {
|
||||||
|
if (userId == null || userId.isEmpty()) continue;
|
||||||
|
if (isExternalUserId(userId)) {
|
||||||
|
// 查找该客户的最新消息用于更新统计
|
||||||
|
List<ArchiveMessage> msgs = archiveMessageMapper.selectLatestByCustomer(userId, corpId, 1);
|
||||||
|
ArchiveMessage msg = msgs != null && !msgs.isEmpty() ? msgs.get(0) : null;
|
||||||
|
ensureCustomerExists(userId, msg != null ? msg : new ArchiveMessage());
|
||||||
|
} else {
|
||||||
|
ensureStaffExists(userId);
|
||||||
|
}
|
||||||
|
processed++;
|
||||||
|
}
|
||||||
|
log.info("历史数据扫描完成,共处理 {} 个联系人", processed);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("扫描历史联系人失败", e);
|
||||||
|
}
|
||||||
|
return processed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手动同步所有员工
|
||||||
|
*/
|
||||||
|
public int syncAllStaffs() {
|
||||||
|
int count = 0;
|
||||||
|
try {
|
||||||
|
// 从企微获取部门成员列表(根部门ID通常为1)
|
||||||
|
List<Map<String, Object>> users = weComApiClient.getDepartmentUserList(1L);
|
||||||
|
for (Map<String, Object> user : users) {
|
||||||
|
String userId = (String) user.get("userid");
|
||||||
|
String name = (String) user.get("name");
|
||||||
|
if (userId == null) continue;
|
||||||
|
|
||||||
|
Staff existing = staffMapper.selectByStaffId(userId, corpId);
|
||||||
|
if (existing == null) {
|
||||||
|
Staff staff = new Staff();
|
||||||
|
staff.setStaffId(userId);
|
||||||
|
staff.setCorpId(corpId);
|
||||||
|
staff.setName(name != null ? name : userId);
|
||||||
|
staff.setRole("ADVISOR");
|
||||||
|
staff.setStatus("ACTIVE");
|
||||||
|
staffMapper.insert(staff);
|
||||||
|
} else {
|
||||||
|
existing.setName(name != null ? name : userId);
|
||||||
|
staffMapper.updateById(existing);
|
||||||
|
}
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
log.info("同步员工完成,共 {} 人", count);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("同步员工失败", e);
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手动同步所有客户(通过遍历所有员工的客户列表)
|
||||||
|
*/
|
||||||
|
public int syncAllCustomers() {
|
||||||
|
int count = 0;
|
||||||
|
try {
|
||||||
|
List<Staff> staffs = staffMapper.selectList(
|
||||||
|
new LambdaQueryWrapper<Staff>().eq(Staff::getCorpId, corpId)
|
||||||
|
);
|
||||||
|
Set<String> processed = new HashSet<>();
|
||||||
|
|
||||||
|
for (Staff staff : staffs) {
|
||||||
|
List<String> customerIds = weComApiClient.getExternalContactList(staff.getStaffId());
|
||||||
|
for (String customerId : customerIds) {
|
||||||
|
if (processed.contains(customerId)) continue;
|
||||||
|
processed.add(customerId);
|
||||||
|
|
||||||
|
Customer existing = customerMapper.selectByCustomerId(customerId, corpId);
|
||||||
|
if (existing == null) {
|
||||||
|
Customer customer = new Customer();
|
||||||
|
customer.setCustomerId(customerId);
|
||||||
|
customer.setCorpId(corpId);
|
||||||
|
customer.setName(customerId);
|
||||||
|
customer.setType("UNKNOWN");
|
||||||
|
customer.setStatus("ACTIVE");
|
||||||
|
customerMapper.insert(customer);
|
||||||
|
}
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 异步补充所有客户详情
|
||||||
|
for (String customerId : processed) {
|
||||||
|
asyncFillCustomerDetail(customerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("同步客户完成,共 {} 人", count);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("同步客户失败", e);
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量补充所有现有客户的资料(昵称、头像等)
|
||||||
|
*/
|
||||||
|
public int fillAllCustomerDetails() {
|
||||||
|
int count = 0;
|
||||||
|
try {
|
||||||
|
List<Customer> customers = customerMapper.selectList(
|
||||||
|
new LambdaQueryWrapper<Customer>().eq(Customer::getCorpId, corpId)
|
||||||
|
);
|
||||||
|
log.info("开始批量补充客户资料,共 {} 人", customers.size());
|
||||||
|
for (Customer customer : customers) {
|
||||||
|
try {
|
||||||
|
asyncFillCustomerDetail(customer.getCustomerId());
|
||||||
|
count++;
|
||||||
|
// 稍微延迟避免触发频率限制
|
||||||
|
if (count % 100 == 0) {
|
||||||
|
Thread.sleep(500);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("补充客户资料失败: {}", customer.getCustomerId(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.info("批量补充客户资料完成,共处理 {} 人", count);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("批量补充客户资料失败", e);
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -5,7 +5,6 @@ import com.artedu.archive.mapper.ArchiveMessageMapper;
|
|||||||
import com.artedu.archive.util.RsaDecryptUtil;
|
import com.artedu.archive.util.RsaDecryptUtil;
|
||||||
import com.artedu.common.config.RabbitConfig;
|
import com.artedu.common.config.RabbitConfig;
|
||||||
import com.artedu.common.util.JsonUtils;
|
import com.artedu.common.util.JsonUtils;
|
||||||
import com.tencent.wework.Finance;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.amqp.rabbit.core.RabbitTemplate;
|
import org.springframework.amqp.rabbit.core.RabbitTemplate;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
@ -16,6 +15,7 @@ import org.springframework.stereotype.Service;
|
|||||||
import javax.annotation.PostConstruct;
|
import javax.annotation.PostConstruct;
|
||||||
import javax.annotation.PreDestroy;
|
import javax.annotation.PreDestroy;
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@ -38,6 +38,9 @@ public class ArchivePullService {
|
|||||||
@Value("${wecom.archive.rsa-private-key:}")
|
@Value("${wecom.archive.rsa-private-key:}")
|
||||||
private String rsaPrivateKey;
|
private String rsaPrivateKey;
|
||||||
|
|
||||||
|
@Value("${wecom.archive.rsa-private-key-path:}")
|
||||||
|
private String rsaPrivateKeyPath;
|
||||||
|
|
||||||
@Value("${wecom.archive.sdk-path:}")
|
@Value("${wecom.archive.sdk-path:}")
|
||||||
private String sdkPath;
|
private String sdkPath;
|
||||||
|
|
||||||
@ -56,141 +59,106 @@ public class ArchivePullService {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private RsaDecryptUtil rsaDecryptUtil;
|
private RsaDecryptUtil rsaDecryptUtil;
|
||||||
|
|
||||||
private long sdk;
|
@Autowired
|
||||||
|
private ArchiveContactService archiveContactService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ArchiveRiskService archiveRiskService;
|
||||||
|
|
||||||
private static final String LAST_SEQ_KEY = "archive:last_seq:";
|
private static final String LAST_SEQ_KEY = "archive:last_seq:";
|
||||||
|
|
||||||
@PostConstruct
|
@PostConstruct
|
||||||
public void init() {
|
public void init() {
|
||||||
if (mockMode) {
|
// 从文件加载RSA私钥(当环境变量未设置时)
|
||||||
log.info("企微存档Mock模式已启用,跳过SDK初始化");
|
if ((rsaPrivateKey == null || rsaPrivateKey.isEmpty()) && rsaPrivateKeyPath != null && !rsaPrivateKeyPath.isEmpty()) {
|
||||||
sdk = 0;
|
File keyFile = new File(rsaPrivateKeyPath);
|
||||||
return;
|
if (keyFile.exists() && keyFile.isFile()) {
|
||||||
}
|
|
||||||
if (secret == null || secret.isEmpty()) {
|
|
||||||
log.warn("企微存档Secret未配置,跳过SDK初始化");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
// 设置 java.library.path,确保 JNI 能找到 DLL
|
rsaPrivateKey = new String(java.nio.file.Files.readAllBytes(keyFile.toPath()), java.nio.charset.StandardCharsets.UTF_8);
|
||||||
setupLibraryPath();
|
log.info("已从文件加载RSA私钥: {}", rsaPrivateKeyPath);
|
||||||
|
|
||||||
sdk = Finance.NewSdk();
|
|
||||||
int ret = Finance.Init(sdk, corpId, secret);
|
|
||||||
if (ret != 0) {
|
|
||||||
log.error("企微存档SDK初始化失败, ret={}", ret);
|
|
||||||
sdk = 0;
|
|
||||||
} else {
|
|
||||||
log.info("企微存档SDK初始化成功");
|
|
||||||
}
|
|
||||||
} catch (UnsatisfiedLinkError e) {
|
|
||||||
log.error("企微存档SDK原生库加载失败: {}", e.getMessage());
|
|
||||||
sdk = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 将 sdk-path 添加到 java.library.path,使 JNI System.loadLibrary 能找到 DLL
|
|
||||||
*/
|
|
||||||
private void setupLibraryPath() {
|
|
||||||
if (sdkPath == null || sdkPath.isEmpty()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
File sdkDir = new File(sdkPath);
|
|
||||||
if (!sdkDir.exists() || !sdkDir.isDirectory()) {
|
|
||||||
log.warn("SDK路径不存在: {}", sdkPath);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
String existingPath = System.getProperty("java.library.path", "");
|
|
||||||
if (!existingPath.contains(sdkPath)) {
|
|
||||||
String newPath = existingPath.isEmpty() ? sdkPath : existingPath + File.pathSeparator + sdkPath;
|
|
||||||
System.setProperty("java.library.path", newPath);
|
|
||||||
// 刷新 JNA / JNI 的库路径缓存
|
|
||||||
try {
|
|
||||||
java.lang.reflect.Field field = ClassLoader.class.getDeclaredField("usr_paths");
|
|
||||||
field.setAccessible(true);
|
|
||||||
field.set(null, null);
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("刷新java.library.path缓存失败: {}", e.getMessage());
|
log.error("读取RSA私钥文件失败: {}", rsaPrivateKeyPath, e);
|
||||||
}
|
}
|
||||||
log.info("已将SDK路径添加到java.library.path: {}", sdkPath);
|
} else {
|
||||||
|
log.warn("RSA私钥文件不存在: {}", rsaPrivateKeyPath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@PreDestroy
|
|
||||||
public void destroy() {
|
|
||||||
if (sdk != 0) {
|
|
||||||
Finance.DestroySdk(sdk);
|
|
||||||
log.info("企微存档SDK已释放");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean isMockMode() {
|
public boolean isMockMode() {
|
||||||
return mockMode || sdk == 0;
|
return mockMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<ArchiveMessage> pullMessages(long seq, int limit) {
|
public List<ArchiveMessage> pullMessages(long seq, int limit) {
|
||||||
List<ArchiveMessage> messages = new ArrayList<>();
|
List<ArchiveMessage> messages = new ArrayList<>();
|
||||||
|
|
||||||
// Mock 模式:返回模拟数据
|
|
||||||
if (mockMode || sdk == 0) {
|
|
||||||
if (mockMode) {
|
if (mockMode) {
|
||||||
log.info("Mock模式:生成模拟存档消息, seq={}, limit={}", seq, limit);
|
log.info("Mock模式:生成模拟存档消息, seq={}, limit={}", seq, limit);
|
||||||
return MockArchiveDataProvider.generate(seq, limit, corpId);
|
return MockArchiveDataProvider.generate(seq, limit, corpId);
|
||||||
}
|
}
|
||||||
log.warn("企微存档SDK未初始化,跳过拉取");
|
|
||||||
|
if (corpId == null || secret == null || rsaPrivateKey == null || sdkPath == null) {
|
||||||
|
log.warn("企微存档配置不完整,跳过拉取");
|
||||||
return messages;
|
return messages;
|
||||||
}
|
}
|
||||||
|
|
||||||
long chatDatas = 0;
|
// 循环拉取直到没有数据返回,确保获取所有历史消息
|
||||||
|
long currentSeq = seq;
|
||||||
|
int totalParsed = 0;
|
||||||
|
int totalFetched = 0;
|
||||||
|
int maxLoops = Math.max(limit / 100, 10); // 最多循环10次,避免无限循环
|
||||||
|
int safeLimit = Math.min(limit, 100);
|
||||||
|
|
||||||
try {
|
for (int loop = 0; loop < maxLoops; loop++) {
|
||||||
chatDatas = Finance.NewSlice();
|
String jsonData = callWorker("pull", corpId, secret, String.valueOf(currentSeq), String.valueOf(safeLimit), sdkPath);
|
||||||
if (chatDatas == 0) {
|
if (jsonData == null) {
|
||||||
log.error("NewSlice返回0,内存不足");
|
log.warn("Worker拉取返回null, seq={}", currentSeq);
|
||||||
return messages;
|
break;
|
||||||
}
|
|
||||||
|
|
||||||
int ret = Finance.GetChatData(sdk, seq, limit, null, null, 5, chatDatas);
|
|
||||||
if (ret != 0) {
|
|
||||||
log.error("GetChatData失败, ret={}, seq={}", ret, seq);
|
|
||||||
return messages;
|
|
||||||
}
|
|
||||||
|
|
||||||
String jsonData = Finance.GetContentFromSlice(chatDatas);
|
|
||||||
if (jsonData == null || jsonData.isEmpty()) {
|
|
||||||
return messages;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, Object> result = JsonUtils.fromJsonMap(jsonData);
|
Map<String, Object> result = JsonUtils.fromJsonMap(jsonData);
|
||||||
if (result == null || !Integer.valueOf(0).equals(result.get("errcode"))) {
|
if (result == null || !Integer.valueOf(0).equals(result.get("errcode"))) {
|
||||||
log.error("拉取存档返回错误: {}", jsonData);
|
log.error("拉取存档返回错误: {}", jsonData);
|
||||||
return messages;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
List<Map<String, Object>> chatDataList = (List<Map<String, Object>>) result.get("chatdata");
|
List<Map<String, Object>> chatDataList = (List<Map<String, Object>>) result.get("chatdata");
|
||||||
if (chatDataList == null || chatDataList.isEmpty()) {
|
if (chatDataList == null || chatDataList.isEmpty()) {
|
||||||
return messages;
|
log.info("已无更多消息, seq={}", currentSeq);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
long maxSeq = currentSeq;
|
||||||
for (Map<String, Object> chatData : chatDataList) {
|
for (Map<String, Object> chatData : chatDataList) {
|
||||||
try {
|
try {
|
||||||
|
Long msgSeq = ((Number) chatData.get("seq")).longValue();
|
||||||
|
if (msgSeq > maxSeq) maxSeq = msgSeq;
|
||||||
|
totalFetched++;
|
||||||
|
|
||||||
ArchiveMessage message = parseAndDecrypt(chatData);
|
ArchiveMessage message = parseAndDecrypt(chatData);
|
||||||
if (message != null) {
|
if (message != null) {
|
||||||
messages.add(message);
|
messages.add(message);
|
||||||
|
totalParsed++;
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("解析单条消息失败: {}", e.getMessage(), e);
|
log.error("解析单条消息失败: {}", e.getMessage(), e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info("拉取存档消息完成: seq={}, limit={}, 成功解析{}条", seq, limit, messages.size());
|
// 更新 last_seq
|
||||||
|
if (maxSeq > currentSeq) {
|
||||||
|
redisTemplate.opsForValue().set(LAST_SEQ_KEY + corpId, maxSeq, 7, TimeUnit.DAYS);
|
||||||
|
currentSeq = maxSeq;
|
||||||
|
}
|
||||||
|
|
||||||
} finally {
|
// 如果返回的消息数小于 limit,说明已经拉取完了
|
||||||
if (chatDatas != 0) {
|
if (chatDataList.size() < safeLimit) {
|
||||||
Finance.FreeSlice(chatDatas);
|
log.info("返回消息数({})小于limit({}), 已拉取完毕", chatDataList.size(), safeLimit);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.info("拉取存档消息完成: 起始seq={}, 共获取{}条, 成功解析{}条, 最终seq={}", seq, totalFetched, totalParsed, currentSeq);
|
||||||
return messages;
|
return messages;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -199,15 +167,22 @@ public class ArchivePullService {
|
|||||||
String encryptChatMsg = (String) chatData.get("encrypt_chat_msg");
|
String encryptChatMsg = (String) chatData.get("encrypt_chat_msg");
|
||||||
Long msgSeq = ((Number) chatData.get("seq")).longValue();
|
Long msgSeq = ((Number) chatData.get("seq")).longValue();
|
||||||
|
|
||||||
|
Object pubKeyVer = chatData.get("publickey_ver");
|
||||||
|
// publickey_ver 与当前私钥版本不匹配时跳过(企微后台可能更换过公钥)
|
||||||
|
// 当前私钥对应版本3,历史消息可能是版本2加密的
|
||||||
|
log.debug("chatData: seq={}, publickey_ver={}", msgSeq, pubKeyVer);
|
||||||
|
|
||||||
|
// 主服务中 RSA 解密(不涉及 JNI,安全)
|
||||||
String encryptKey = rsaDecryptUtil.decrypt(encryptRandomKey, rsaPrivateKey);
|
String encryptKey = rsaDecryptUtil.decrypt(encryptRandomKey, rsaPrivateKey);
|
||||||
if (encryptKey == null) {
|
if (encryptKey == null) {
|
||||||
log.error("解密encrypt_random_key失败, seq={}", msgSeq);
|
log.debug("解密encrypt_random_key失败, seq={}, 可能是公钥版本不匹配", msgSeq);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
String decryptedMsg = decryptMessage(encryptKey, encryptChatMsg);
|
// Worker 2: 解密单条消息内容(隔离崩溃风险)
|
||||||
|
String decryptedMsg = callWorker("decrypt", corpId, secret, sdkPath, encryptKey, encryptChatMsg);
|
||||||
if (decryptedMsg == null) {
|
if (decryptedMsg == null) {
|
||||||
log.error("解密消息内容失败, seq={}", msgSeq);
|
log.error("解密消息内容失败(worker崩溃或超时), seq={}", msgSeq);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -223,7 +198,15 @@ public class ArchivePullService {
|
|||||||
message.setAction((String) msgMap.getOrDefault("action", "send"));
|
message.setAction((String) msgMap.getOrDefault("action", "send"));
|
||||||
message.setFromUser((String) msgMap.get("from"));
|
message.setFromUser((String) msgMap.get("from"));
|
||||||
message.setFromRole(detectRole((String) msgMap.get("from")));
|
message.setFromRole(detectRole((String) msgMap.get("from")));
|
||||||
message.setToUser((String) msgMap.get("tolist"));
|
|
||||||
|
// tolist 可能是字符串或数组
|
||||||
|
Object tolistObj = msgMap.get("tolist");
|
||||||
|
if (tolistObj instanceof java.util.List) {
|
||||||
|
message.setToUser(JsonUtils.toJson(tolistObj));
|
||||||
|
} else {
|
||||||
|
message.setToUser((String) tolistObj);
|
||||||
|
}
|
||||||
|
|
||||||
message.setRoomid((String) msgMap.get("roomid"));
|
message.setRoomid((String) msgMap.get("roomid"));
|
||||||
message.setMsgtype((String) msgMap.get("msgtype"));
|
message.setMsgtype((String) msgMap.get("msgtype"));
|
||||||
Object msgTimeObj = msgMap.get("msgtime");
|
Object msgTimeObj = msgMap.get("msgtime");
|
||||||
@ -231,43 +214,104 @@ public class ArchivePullService {
|
|||||||
message.setMsgtime(((Number) msgTimeObj).longValue());
|
message.setMsgtime(((Number) msgTimeObj).longValue());
|
||||||
}
|
}
|
||||||
|
|
||||||
Object content = msgMap.get("content");
|
// 企微消息结构: { msgtype: "text", text: { content: "..." } }
|
||||||
if (content != null) {
|
// 根据 msgtype 从对应子对象提取内容
|
||||||
message.setContent(content.toString());
|
String msgtype = message.getMsgtype();
|
||||||
|
Object typeContent = msgMap.get(msgtype);
|
||||||
|
if (typeContent instanceof Map) {
|
||||||
|
Map<String, Object> typeMap = (Map<String, Object>) typeContent;
|
||||||
|
if ("text".equals(msgtype)) {
|
||||||
|
message.setContent((String) typeMap.get("content"));
|
||||||
|
} else if ("image".equals(msgtype)) {
|
||||||
|
message.setContent("[图片]");
|
||||||
|
} else if ("voice".equals(msgtype)) {
|
||||||
|
message.setContent("[语音] " + typeMap.getOrDefault("play_length", "") + "秒");
|
||||||
|
} else if ("video".equals(msgtype)) {
|
||||||
|
message.setContent("[视频]");
|
||||||
|
} else if ("file".equals(msgtype)) {
|
||||||
|
String filename = (String) typeMap.get("filename");
|
||||||
|
message.setContent("[文件] " + (filename != null ? filename : ""));
|
||||||
|
} else if ("link".equals(msgtype)) {
|
||||||
|
String title = (String) typeMap.get("title");
|
||||||
|
message.setContent("[链接] " + (title != null ? title : ""));
|
||||||
|
} else if ("location".equals(msgtype)) {
|
||||||
|
String address = (String) typeMap.get("address");
|
||||||
|
message.setContent("[位置] " + (address != null ? address : ""));
|
||||||
|
} else if ("emotion".equals(msgtype)) {
|
||||||
|
message.setContent("[表情]");
|
||||||
|
} else if ("card".equals(msgtype)) {
|
||||||
|
String corpname = (String) typeMap.get("corpname");
|
||||||
|
message.setContent("[名片] " + (corpname != null ? corpname : ""));
|
||||||
|
} else if ("weapp".equals(msgtype)) {
|
||||||
|
String title = (String) typeMap.get("title");
|
||||||
|
message.setContent("[小程序] " + (title != null ? title : ""));
|
||||||
|
} else if ("chatrecord".equals(msgtype)) {
|
||||||
|
message.setContent("[聊天记录]");
|
||||||
|
} else if ("mixed".equals(msgtype)) {
|
||||||
|
message.setContent("[混合消息]");
|
||||||
|
} else {
|
||||||
|
message.setContent(JsonUtils.toJson(typeContent));
|
||||||
|
}
|
||||||
|
message.setMediaData(JsonUtils.toJson(typeContent));
|
||||||
|
} else if (msgMap.get("content") != null) {
|
||||||
|
message.setContent(msgMap.get("content").toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
message.setTolist(JsonUtils.toJson(msgMap.get("tolist")));
|
message.setTolist(JsonUtils.toJson(msgMap.get("tolist")));
|
||||||
message.setMediaData(JsonUtils.toJson(msgMap.get("mediaData")));
|
|
||||||
message.setDecryptStatus(1);
|
message.setDecryptStatus(1);
|
||||||
message.setSessionId(generateSessionId(message));
|
message.setSessionId(generateSessionId(message));
|
||||||
|
|
||||||
return message;
|
return message;
|
||||||
}
|
}
|
||||||
|
|
||||||
private String decryptMessage(String encryptKey, String encryptMsg) {
|
|
||||||
long msgSlice = 0;
|
private String callWorker(String... workerArgs) {
|
||||||
|
List<String> cmd = new ArrayList<>();
|
||||||
|
cmd.add("java");
|
||||||
|
cmd.add("-Djava.library.path=" + sdkPath);
|
||||||
|
cmd.add("-jar");
|
||||||
|
cmd.add("/app/app.jar");
|
||||||
|
cmd.add("worker");
|
||||||
|
cmd.addAll(java.util.Arrays.asList(workerArgs));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
msgSlice = Finance.NewSlice();
|
ProcessBuilder pb = new ProcessBuilder(cmd);
|
||||||
if (msgSlice == 0) {
|
pb.redirectErrorStream(true);
|
||||||
log.error("NewSlice返回0,内存不足");
|
Process process = pb.start();
|
||||||
|
|
||||||
|
String output;
|
||||||
|
try (java.io.BufferedReader reader = new java.io.BufferedReader(
|
||||||
|
new java.io.InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
String line;
|
||||||
|
while ((line = reader.readLine()) != null) {
|
||||||
|
sb.append(line);
|
||||||
|
}
|
||||||
|
output = sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean finished = process.waitFor(30, TimeUnit.SECONDS);
|
||||||
|
if (!finished) {
|
||||||
|
process.destroyForcibly();
|
||||||
|
log.error("Worker 超时: {}", java.util.Arrays.toString(workerArgs));
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
// 官方JNI: DecryptData(long sdk, String encrypt_key, String encrypt_msg, long msg)
|
|
||||||
int ret = Finance.DecryptData(sdk, encryptKey, encryptMsg, msgSlice);
|
int exitCode = process.exitValue();
|
||||||
if (ret != 0) {
|
if (exitCode != 0) {
|
||||||
log.error("DecryptData失败, ret={}", ret);
|
log.error("Worker 异常退出(code={}): {}, output: {}", exitCode, java.util.Arrays.toString(workerArgs), output);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return Finance.GetContentFromSlice(msgSlice);
|
|
||||||
} finally {
|
return output;
|
||||||
if (msgSlice != 0) {
|
} catch (Exception e) {
|
||||||
Finance.FreeSlice(msgSlice);
|
log.error("Worker 执行失败: {}", java.util.Arrays.toString(workerArgs), e);
|
||||||
}
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private String detectRole(String fromUser) {
|
private String detectRole(String fromUser) {
|
||||||
if (fromUser != null && fromUser.startsWith("wm")) {
|
if (fromUser != null && (fromUser.startsWith("wm") || fromUser.startsWith("wo"))) {
|
||||||
return "EXTERNAL";
|
return "EXTERNAL";
|
||||||
}
|
}
|
||||||
return "INTERNAL";
|
return "INTERNAL";
|
||||||
@ -277,7 +321,34 @@ public class ArchivePullService {
|
|||||||
if (message.getRoomid() != null && !message.getRoomid().isEmpty()) {
|
if (message.getRoomid() != null && !message.getRoomid().isEmpty()) {
|
||||||
return corpId + "_" + message.getRoomid();
|
return corpId + "_" + message.getRoomid();
|
||||||
}
|
}
|
||||||
return corpId + "_" + message.getFromUser() + "_" + message.getToUser();
|
String fromUser = cleanUserId(message.getFromUser());
|
||||||
|
String toUser = cleanUserId(message.getToUser());
|
||||||
|
// 按字典序排序,确保双向聊天的 sessionId 一致
|
||||||
|
if (fromUser != null && toUser != null && fromUser.compareTo(toUser) > 0) {
|
||||||
|
String temp = fromUser;
|
||||||
|
fromUser = toUser;
|
||||||
|
toUser = temp;
|
||||||
|
}
|
||||||
|
return corpId + "_" + fromUser + "_" + toUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理 userId 中的 JSON 数组格式,如 ["justin"] -> justin
|
||||||
|
*/
|
||||||
|
private String cleanUserId(String userId) {
|
||||||
|
if (userId == null || userId.isEmpty()) return userId;
|
||||||
|
if (userId.startsWith("[") && userId.endsWith("]")) {
|
||||||
|
try {
|
||||||
|
List<String> ids = JsonUtils.fromJsonList(userId, String.class);
|
||||||
|
if (ids != null && !ids.isEmpty()) {
|
||||||
|
return ids.get(0).trim();
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
// 回退:手动去除方括号和引号
|
||||||
|
}
|
||||||
|
return userId.replace("[", "").replace("]", "").replace("\"", "").trim();
|
||||||
|
}
|
||||||
|
return userId;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void saveAndNotify(List<ArchiveMessage> messages) {
|
public void saveAndNotify(List<ArchiveMessage> messages) {
|
||||||
@ -285,12 +356,27 @@ public class ArchivePullService {
|
|||||||
try {
|
try {
|
||||||
int count = archiveMessageMapper.countByMsgId(message.getMsgid(), message.getCorpId());
|
int count = archiveMessageMapper.countByMsgId(message.getMsgid(), message.getCorpId());
|
||||||
if (count > 0) {
|
if (count > 0) {
|
||||||
log.debug("消息已存在,跳过: msgid={}", message.getMsgid());
|
log.debug("消息已存在,更新内容: msgid={}", message.getMsgid());
|
||||||
|
archiveMessageMapper.updateByMsgId(message);
|
||||||
|
// 补充风控检测(历史消息修复时敏感词库可能已更新)
|
||||||
|
archiveRiskService.processMessageRisk(message);
|
||||||
|
// 更新 seq 推进进度
|
||||||
|
redisTemplate.opsForValue().set(
|
||||||
|
LAST_SEQ_KEY + corpId,
|
||||||
|
message.getSeq(),
|
||||||
|
7, TimeUnit.DAYS
|
||||||
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
archiveMessageMapper.insert(message);
|
archiveMessageMapper.insert(message);
|
||||||
|
|
||||||
|
// 提取并维护联系人信息
|
||||||
|
archiveContactService.processMessage(message);
|
||||||
|
|
||||||
|
// 风控检测(敏感词扫描等)
|
||||||
|
archiveRiskService.processMessageRisk(message);
|
||||||
|
|
||||||
rabbitTemplate.convertAndSend(
|
rabbitTemplate.convertAndSend(
|
||||||
RabbitConfig.ARCHIVE_EXCHANGE,
|
RabbitConfig.ARCHIVE_EXCHANGE,
|
||||||
RabbitConfig.ROUTING_KEY_NEW_MESSAGE,
|
RabbitConfig.ROUTING_KEY_NEW_MESSAGE,
|
||||||
@ -317,4 +403,114 @@ public class ArchivePullService {
|
|||||||
Long maxSeq = archiveMessageMapper.selectMaxSeq(corpId);
|
Long maxSeq = archiveMessageMapper.selectMaxSeq(corpId);
|
||||||
return maxSeq != null ? maxSeq : 0L;
|
return maxSeq != null ? maxSeq : 0L;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 修复历史数据:重新拉取并更新已存在消息的 content 和 mediaData
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* 下载媒体文件(图片/语音/视频/文件)
|
||||||
|
* @param sdkfileid 企微文件ID
|
||||||
|
* @param outputPath 输出路径
|
||||||
|
* @return 是否下载成功
|
||||||
|
*/
|
||||||
|
public boolean downloadMedia(String sdkfileid, String outputPath) {
|
||||||
|
if (corpId == null || secret == null || sdkPath == null) {
|
||||||
|
log.warn("企微存档配置不完整,无法下载媒体");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String result = callWorker("media", corpId, secret, sdkPath, sdkfileid, outputPath);
|
||||||
|
if (result == null) {
|
||||||
|
log.error("下载媒体失败(worker超时或崩溃): sdkfileid={}", sdkfileid);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Map<String, Object> map = JsonUtils.fromJsonMap(result);
|
||||||
|
if (map != null && Boolean.TRUE.equals(map.get("success"))) {
|
||||||
|
log.info("媒体下载成功: sdkfileid={}, size={}", sdkfileid, map.get("size"));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
log.error("下载媒体返回失败: sdkfileid={}, result={}", sdkfileid, result);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("解析媒体下载结果失败: sdkfileid={}, result={}", sdkfileid, result);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void repairHistoricalData() {
|
||||||
|
Long minSeq = archiveMessageMapper.selectMinSeq(corpId);
|
||||||
|
if (minSeq == null || minSeq <= 0) {
|
||||||
|
log.info("数据库为空,无需修复");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
long startSeq = minSeq - 1;
|
||||||
|
log.info("开始修复历史数据,起始 seq={}", startSeq);
|
||||||
|
|
||||||
|
// 重置 Redis 中的 last_seq
|
||||||
|
redisTemplate.opsForValue().set(LAST_SEQ_KEY + corpId, startSeq, 7, TimeUnit.DAYS);
|
||||||
|
|
||||||
|
int totalFetched = 0;
|
||||||
|
int totalUpdated = 0;
|
||||||
|
int totalInserted = 0;
|
||||||
|
long currentSeq = startSeq;
|
||||||
|
int maxRounds = 500; // 最多 500 轮
|
||||||
|
|
||||||
|
for (int round = 0; round < maxRounds; round++) {
|
||||||
|
try {
|
||||||
|
log.info("修复第 {} 轮开始: 从 seq={} 拉取, limit=100", round + 1, currentSeq);
|
||||||
|
List<ArchiveMessage> messages = pullMessages(currentSeq, 100);
|
||||||
|
log.info("修复第 {} 轮拉取完成: 获取 {} 条消息", round + 1, messages != null ? messages.size() : 0);
|
||||||
|
|
||||||
|
if (messages == null || messages.isEmpty()) {
|
||||||
|
log.info("修复完成,没有更多数据。共获取 {} 条,更新 {} 条,新增 {} 条",
|
||||||
|
totalFetched, totalUpdated, totalInserted);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
int roundUpdated = 0;
|
||||||
|
int roundInserted = 0;
|
||||||
|
for (ArchiveMessage message : messages) {
|
||||||
|
try {
|
||||||
|
int count = archiveMessageMapper.countByMsgId(message.getMsgid(), message.getCorpId());
|
||||||
|
if (count > 0) {
|
||||||
|
int updated = archiveMessageMapper.updateByMsgId(message);
|
||||||
|
if (updated > 0) { totalUpdated++; roundUpdated++; }
|
||||||
|
} else {
|
||||||
|
archiveMessageMapper.insert(message);
|
||||||
|
totalInserted++; roundInserted++;
|
||||||
|
}
|
||||||
|
archiveContactService.processMessage(message);
|
||||||
|
archiveRiskService.processMessageRisk(message);
|
||||||
|
redisTemplate.opsForValue().set(
|
||||||
|
LAST_SEQ_KEY + corpId,
|
||||||
|
message.getSeq(),
|
||||||
|
7, TimeUnit.DAYS
|
||||||
|
);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("修复单条消息失败: msgid={}, error={}", message.getMsgid(), e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
totalFetched += messages.size();
|
||||||
|
currentSeq = messages.get(messages.size() - 1).getSeq();
|
||||||
|
log.info("修复进度: 第 {} 轮完成, currentSeq={}, 本轮获取 {} 条/更新 {} 条/新增 {} 条, 累计获取 {} 条/更新 {} 条/新增 {} 条",
|
||||||
|
round + 1, currentSeq, messages.size(), roundUpdated, roundInserted,
|
||||||
|
totalFetched, totalUpdated, totalInserted);
|
||||||
|
|
||||||
|
// 如果返回的消息数很少,可能已经到达末尾
|
||||||
|
if (messages.size() < 10) {
|
||||||
|
log.info("本轮消息数 {} 较少,确认是否已修复完毕", messages.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
Thread.sleep(200);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("修复过程异常: round={}, error={}", round, e.getMessage(), e);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("历史数据修复结束。最终 seq={}, 累计获取 {} 条, 更新 {} 条, 新增 {} 条",
|
||||||
|
currentSeq, totalFetched, totalUpdated, totalInserted);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,266 @@
|
|||||||
|
package com.artedu.archive.service;
|
||||||
|
|
||||||
|
import com.artedu.archive.entity.ArchiveMessage;
|
||||||
|
import com.artedu.archive.entity.Customer;
|
||||||
|
import com.artedu.archive.entity.RiskEvent;
|
||||||
|
import com.artedu.archive.entity.SensitiveWord;
|
||||||
|
import com.artedu.archive.entity.Staff;
|
||||||
|
import com.artedu.archive.mapper.ArchiveMessageMapper;
|
||||||
|
import com.artedu.archive.mapper.CustomerMapper;
|
||||||
|
import com.artedu.archive.mapper.RiskEventMapper;
|
||||||
|
import com.artedu.archive.mapper.SensitiveWordMapper;
|
||||||
|
import com.artedu.archive.mapper.StaffMapper;
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 风控服务
|
||||||
|
* 整合敏感词扫描、行为检测等风控逻辑
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
public class ArchiveRiskService {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private SensitiveWordScanner sensitiveWordScanner;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private SensitiveWordMapper sensitiveWordMapper;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private RiskEventDetector riskEventDetector;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private RiskEventMapper riskEventMapper;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ArchiveMessageMapper archiveMessageMapper;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private StaffMapper staffMapper;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private CustomerMapper customerMapper;
|
||||||
|
|
||||||
|
@Value("${wecom.archive.corp-id}")
|
||||||
|
private String corpId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理新消息的风控检测
|
||||||
|
*/
|
||||||
|
public void processMessageRisk(ArchiveMessage message) {
|
||||||
|
try {
|
||||||
|
// 1. 敏感词扫描
|
||||||
|
if (message.getContent() != null && !message.getContent().isEmpty()) {
|
||||||
|
String chatType = message.getRoomid() != null && !message.getRoomid().isEmpty() ? "群聊" : "单聊";
|
||||||
|
String targetType = "INTERNAL".equals(message.getFromRole()) ? "员工" : "客户";
|
||||||
|
|
||||||
|
List<SensitiveWordScanner.HitResult> hits = sensitiveWordScanner.scanAndSave(
|
||||||
|
message.getContent(),
|
||||||
|
message.getFromUser(),
|
||||||
|
message.getFromRole(),
|
||||||
|
message.getToUser(),
|
||||||
|
message.getSessionId(),
|
||||||
|
message.getId(),
|
||||||
|
targetType,
|
||||||
|
chatType
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!hits.isEmpty()) {
|
||||||
|
log.info("敏感词命中: msgid={}, words={}, count={}",
|
||||||
|
message.getMsgid(),
|
||||||
|
hits.stream().map(SensitiveWordScanner.HitResult::getWord).collect(java.util.stream.Collectors.toList()),
|
||||||
|
hits.size());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 行为检测(红包、撤回、超时、深夜等)
|
||||||
|
riskEventDetector.detect(message);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("风控检测失败: msgid={}", message.getMsgid(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 敏感词列表查询
|
||||||
|
*/
|
||||||
|
public List<SensitiveWord> listSensitiveWords(String keyword, Integer status) {
|
||||||
|
LambdaQueryWrapper<SensitiveWord> wrapper = new LambdaQueryWrapper<>();
|
||||||
|
if (keyword != null && !keyword.isEmpty()) {
|
||||||
|
wrapper.like(SensitiveWord::getWord, keyword);
|
||||||
|
}
|
||||||
|
if (status != null) {
|
||||||
|
wrapper.eq(SensitiveWord::getStatus, status);
|
||||||
|
}
|
||||||
|
wrapper.orderByDesc(SensitiveWord::getMatchCount);
|
||||||
|
return sensitiveWordMapper.selectList(wrapper);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 风险事件列表查询
|
||||||
|
*/
|
||||||
|
public com.artedu.common.result.PageResult<RiskEvent> listRiskEvents(String eventType, Integer status, String userId, int page, int size) {
|
||||||
|
LambdaQueryWrapper<RiskEvent> wrapper = new LambdaQueryWrapper<>();
|
||||||
|
if (eventType != null && !eventType.isEmpty()) {
|
||||||
|
wrapper.eq(RiskEvent::getEventType, eventType);
|
||||||
|
}
|
||||||
|
if (status != null) {
|
||||||
|
wrapper.eq(RiskEvent::getStatus, status);
|
||||||
|
}
|
||||||
|
if (userId != null && !userId.isEmpty()) {
|
||||||
|
wrapper.eq(RiskEvent::getUserId, userId);
|
||||||
|
}
|
||||||
|
// 构建 count wrapper
|
||||||
|
LambdaQueryWrapper<RiskEvent> countWrapper = new LambdaQueryWrapper<>();
|
||||||
|
if (eventType != null && !eventType.isEmpty()) {
|
||||||
|
countWrapper.eq(RiskEvent::getEventType, eventType);
|
||||||
|
}
|
||||||
|
if (status != null) {
|
||||||
|
countWrapper.eq(RiskEvent::getStatus, status);
|
||||||
|
}
|
||||||
|
if (userId != null && !userId.isEmpty()) {
|
||||||
|
countWrapper.eq(RiskEvent::getUserId, userId);
|
||||||
|
}
|
||||||
|
long total = riskEventMapper.selectCount(countWrapper);
|
||||||
|
List<RiskEvent> list = riskEventMapper.selectList(countWrapper.orderByDesc(RiskEvent::getCreatedAt)
|
||||||
|
.last("LIMIT " + ((page - 1) * size) + ", " + size));
|
||||||
|
|
||||||
|
// 补充当事人和关联人的昵称
|
||||||
|
enrichUserNames(list);
|
||||||
|
|
||||||
|
return com.artedu.common.result.PageResult.of(page, size, total, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void enrichUserNames(List<RiskEvent> list) {
|
||||||
|
if (list == null || list.isEmpty()) return;
|
||||||
|
|
||||||
|
Set<String> userIds = list.stream()
|
||||||
|
.map(RiskEvent::getUserId)
|
||||||
|
.filter(id -> id != null && !id.isEmpty())
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
Set<String> relatedIds = list.stream()
|
||||||
|
.map(RiskEvent::getRelatedUser)
|
||||||
|
.filter(id -> id != null && !id.isEmpty())
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
userIds.addAll(relatedIds);
|
||||||
|
|
||||||
|
if (userIds.isEmpty()) return;
|
||||||
|
|
||||||
|
// 查询员工名称
|
||||||
|
List<Staff> staffs = staffMapper.selectList(
|
||||||
|
new LambdaQueryWrapper<Staff>()
|
||||||
|
.eq(Staff::getCorpId, corpId)
|
||||||
|
.in(Staff::getStaffId, userIds)
|
||||||
|
);
|
||||||
|
Map<String, String> staffNameMap = staffs.stream()
|
||||||
|
.collect(Collectors.toMap(Staff::getStaffId, s -> s.getName() != null ? s.getName() : s.getStaffId(), (a, b) -> a));
|
||||||
|
|
||||||
|
// 查询客户名称
|
||||||
|
List<Customer> customers = customerMapper.selectList(
|
||||||
|
new LambdaQueryWrapper<Customer>()
|
||||||
|
.eq(Customer::getCorpId, corpId)
|
||||||
|
.in(Customer::getCustomerId, userIds)
|
||||||
|
);
|
||||||
|
Map<String, String> customerNameMap = customers.stream()
|
||||||
|
.collect(Collectors.toMap(Customer::getCustomerId, c -> c.getName() != null ? c.getName() : c.getCustomerId(), (a, b) -> a));
|
||||||
|
|
||||||
|
// 合并名称映射
|
||||||
|
Map<String, String> nameMap = new java.util.HashMap<>();
|
||||||
|
nameMap.putAll(staffNameMap);
|
||||||
|
nameMap.putAll(customerNameMap);
|
||||||
|
|
||||||
|
for (RiskEvent event : list) {
|
||||||
|
String uid = event.getUserId();
|
||||||
|
if (uid != null && !uid.isEmpty()) {
|
||||||
|
event.setUserName(nameMap.getOrDefault(uid, uid));
|
||||||
|
}
|
||||||
|
String rid = event.getRelatedUser();
|
||||||
|
if (rid != null && !rid.isEmpty()) {
|
||||||
|
event.setRelatedUserName(nameMap.getOrDefault(rid, rid));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试敏感词扫描
|
||||||
|
*/
|
||||||
|
public List<SensitiveWordScanner.HitResult> testScan(String content) {
|
||||||
|
return sensitiveWordScanner.scan(content, "全部", "全部");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 风险事件统计
|
||||||
|
*/
|
||||||
|
public java.util.Map<String, Long> getRiskStats() {
|
||||||
|
java.util.Map<String, Long> stats = new java.util.HashMap<>();
|
||||||
|
stats.put("total", riskEventMapper.selectCount(null));
|
||||||
|
stats.put("pending", riskEventMapper.selectCount(
|
||||||
|
new LambdaQueryWrapper<RiskEvent>().eq(RiskEvent::getStatus, 0)));
|
||||||
|
stats.put("handled", riskEventMapper.selectCount(
|
||||||
|
new LambdaQueryWrapper<RiskEvent>().eq(RiskEvent::getStatus, 1)));
|
||||||
|
stats.put("ignored", riskEventMapper.selectCount(
|
||||||
|
new LambdaQueryWrapper<RiskEvent>().eq(RiskEvent::getStatus, 2)));
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量扫描历史消息中的敏感词
|
||||||
|
* @return 新增命中记录数
|
||||||
|
*/
|
||||||
|
public int scanHistoryMessages() {
|
||||||
|
int totalHits = 0;
|
||||||
|
int offset = 0;
|
||||||
|
int batchSize = 500;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
LambdaQueryWrapper<ArchiveMessage> wrapper = new LambdaQueryWrapper<>();
|
||||||
|
wrapper.eq(ArchiveMessage::getCorpId, corpId)
|
||||||
|
.eq(ArchiveMessage::getMsgtype, "text")
|
||||||
|
.isNotNull(ArchiveMessage::getContent)
|
||||||
|
.ne(ArchiveMessage::getContent, "")
|
||||||
|
.orderByAsc(ArchiveMessage::getId)
|
||||||
|
.last("LIMIT " + offset + ", " + batchSize);
|
||||||
|
|
||||||
|
List<ArchiveMessage> messages = archiveMessageMapper.selectList(wrapper);
|
||||||
|
if (messages == null || messages.isEmpty()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (ArchiveMessage message : messages) {
|
||||||
|
try {
|
||||||
|
String chatType = message.getRoomid() != null && !message.getRoomid().isEmpty() ? "群聊" : "单聊";
|
||||||
|
String targetType = "INTERNAL".equals(message.getFromRole()) ? "员工" : "客户";
|
||||||
|
|
||||||
|
List<SensitiveWordScanner.HitResult> hits = sensitiveWordScanner.scanAndSave(
|
||||||
|
message.getContent(),
|
||||||
|
message.getFromUser(),
|
||||||
|
message.getFromRole(),
|
||||||
|
message.getToUser(),
|
||||||
|
message.getSessionId(),
|
||||||
|
message.getId(),
|
||||||
|
targetType,
|
||||||
|
chatType
|
||||||
|
);
|
||||||
|
totalHits += hits.size();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("扫描历史消息敏感词失败: msgid={}", message.getMsgid(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("历史消息敏感词扫描进度: offset={}, batch={}, hits={}", offset, messages.size(), totalHits);
|
||||||
|
offset += batchSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("历史消息敏感词扫描完成: 共新增 {} 条命中记录", totalHits);
|
||||||
|
return totalHits;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,357 @@
|
|||||||
|
package com.artedu.archive.service;
|
||||||
|
|
||||||
|
import com.artedu.archive.entity.ArchiveMessage;
|
||||||
|
import com.artedu.archive.entity.Customer;
|
||||||
|
import com.artedu.archive.entity.Staff;
|
||||||
|
import com.artedu.archive.mapper.ArchiveMessageMapper;
|
||||||
|
import com.artedu.archive.mapper.CustomerMapper;
|
||||||
|
import com.artedu.archive.mapper.StaffMapper;
|
||||||
|
import com.artedu.archive.vo.ArchiveMessageVO;
|
||||||
|
import com.artedu.archive.vo.CustomerSessionVO;
|
||||||
|
import com.artedu.archive.vo.SessionVO;
|
||||||
|
import com.artedu.archive.vo.StaffSessionVO;
|
||||||
|
import com.artedu.common.result.PageResult;
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.text.SimpleDateFormat;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 会话查询服务
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
public class ArchiveSessionService {
|
||||||
|
|
||||||
|
@Value("${wecom.archive.corp-id}")
|
||||||
|
private String corpId;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private StaffMapper staffMapper;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private CustomerMapper customerMapper;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ArchiveMessageMapper archiveMessageMapper;
|
||||||
|
|
||||||
|
private static final SimpleDateFormat TIME_FORMAT = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 员工列表
|
||||||
|
*/
|
||||||
|
public PageResult<StaffSessionVO> listStaffs(String keyword, int page, int size, boolean hasArchive) {
|
||||||
|
LambdaQueryWrapper<Staff> wrapper = new LambdaQueryWrapper<>();
|
||||||
|
wrapper.eq(Staff::getCorpId, corpId);
|
||||||
|
if (keyword != null && !keyword.isEmpty()) {
|
||||||
|
wrapper.and(w -> w.like(Staff::getStaffId, keyword).or().like(Staff::getName, keyword));
|
||||||
|
}
|
||||||
|
// 只展示有会话存档的员工
|
||||||
|
if (hasArchive) {
|
||||||
|
List<String> archiveStaffIds = archiveMessageMapper.selectStaffIdsWithArchive(corpId);
|
||||||
|
if (archiveStaffIds == null || archiveStaffIds.isEmpty()) {
|
||||||
|
return PageResult.of(page, size, 0L, Collections.emptyList());
|
||||||
|
}
|
||||||
|
wrapper.in(Staff::getStaffId, archiveStaffIds);
|
||||||
|
}
|
||||||
|
wrapper.orderByDesc(Staff::getCreatedAt);
|
||||||
|
|
||||||
|
Page<Staff> pageResult = staffMapper.selectPage(new Page<>(page, size), wrapper);
|
||||||
|
List<StaffSessionVO> list = pageResult.getRecords().stream().map(this::convertStaffVO).collect(Collectors.toList());
|
||||||
|
return PageResult.of(page, size, pageResult.getTotal(), list);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 客户列表
|
||||||
|
*/
|
||||||
|
public PageResult<CustomerSessionVO> listCustomers(String keyword, String staffId, int page, int size) {
|
||||||
|
LambdaQueryWrapper<Customer> wrapper = new LambdaQueryWrapper<>();
|
||||||
|
wrapper.eq(Customer::getCorpId, corpId);
|
||||||
|
if (keyword != null && !keyword.isEmpty()) {
|
||||||
|
wrapper.and(w -> w.like(Customer::getCustomerId, keyword).or().like(Customer::getName, keyword));
|
||||||
|
}
|
||||||
|
if (staffId != null && !staffId.isEmpty()) {
|
||||||
|
wrapper.like(Customer::getStaffIds, staffId);
|
||||||
|
}
|
||||||
|
wrapper.orderByDesc(Customer::getLastMsgTime);
|
||||||
|
|
||||||
|
Page<Customer> pageResult = customerMapper.selectPage(new Page<>(page, size), wrapper);
|
||||||
|
List<CustomerSessionVO> list = pageResult.getRecords().stream().map(this::convertCustomerVO).collect(Collectors.toList());
|
||||||
|
return PageResult.of(page, size, pageResult.getTotal(), list);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 员工的会话列表
|
||||||
|
*/
|
||||||
|
public PageResult<SessionVO> listStaffSessions(String staffId, int page, int size) {
|
||||||
|
int offset = (page - 1) * size;
|
||||||
|
List<Map<String, Object>> rows = archiveMessageMapper.selectSessionsByUser(staffId, corpId, offset, size);
|
||||||
|
Long total = archiveMessageMapper.countSessionsByUser(staffId, corpId);
|
||||||
|
|
||||||
|
List<SessionVO> list = new ArrayList<>();
|
||||||
|
for (Map<String, Object> row : rows) {
|
||||||
|
SessionVO vo = new SessionVO();
|
||||||
|
String sessionId = (String) row.get("session_id");
|
||||||
|
vo.setSessionId(sessionId);
|
||||||
|
vo.setMsgCount(((Number) row.get("cnt")).intValue());
|
||||||
|
Long lastTime = row.get("last_time") instanceof Number ? ((Number) row.get("last_time")).longValue() : null;
|
||||||
|
vo.setLastMsgTime(lastTime);
|
||||||
|
vo.setLastMsgTimeStr(formatTime(lastTime));
|
||||||
|
|
||||||
|
// 查询会话中的对方信息
|
||||||
|
fillSessionParticipants(vo, sessionId);
|
||||||
|
list.add(vo);
|
||||||
|
}
|
||||||
|
return PageResult.of(page, size, total != null ? total : 0L, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 客户的会话列表
|
||||||
|
*/
|
||||||
|
public PageResult<SessionVO> listCustomerSessions(String customerId, int page, int size) {
|
||||||
|
int offset = (page - 1) * size;
|
||||||
|
List<Map<String, Object>> rows = archiveMessageMapper.selectSessionsByUser(customerId, corpId, offset, size);
|
||||||
|
Long total = archiveMessageMapper.countSessionsByUser(customerId, corpId);
|
||||||
|
|
||||||
|
List<SessionVO> list = new ArrayList<>();
|
||||||
|
for (Map<String, Object> row : rows) {
|
||||||
|
SessionVO vo = new SessionVO();
|
||||||
|
String sessionId = (String) row.get("session_id");
|
||||||
|
vo.setSessionId(sessionId);
|
||||||
|
vo.setMsgCount(((Number) row.get("cnt")).intValue());
|
||||||
|
Long lastTime = row.get("last_time") instanceof Number ? ((Number) row.get("last_time")).longValue() : null;
|
||||||
|
vo.setLastMsgTime(lastTime);
|
||||||
|
vo.setLastMsgTimeStr(formatTime(lastTime));
|
||||||
|
fillSessionParticipants(vo, sessionId);
|
||||||
|
list.add(vo);
|
||||||
|
}
|
||||||
|
return PageResult.of(page, size, total != null ? total : 0L, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 聊天时间线
|
||||||
|
*/
|
||||||
|
public PageResult<ArchiveMessageVO> listSessionMessages(String sessionId, int page, int size) {
|
||||||
|
int offset = (page - 1) * size;
|
||||||
|
List<ArchiveMessage> messages = archiveMessageMapper.selectBySessionId(sessionId, corpId, offset, size);
|
||||||
|
Long total = archiveMessageMapper.countBySessionId(sessionId, corpId);
|
||||||
|
|
||||||
|
// 兼容历史数据:查询反向 session_id 的消息
|
||||||
|
String reverseSessionId = reverseSessionId(sessionId);
|
||||||
|
if (reverseSessionId != null && !reverseSessionId.equals(sessionId)) {
|
||||||
|
List<ArchiveMessage> reverseMessages = archiveMessageMapper.selectBySessionId(reverseSessionId, corpId, offset, size);
|
||||||
|
if (reverseMessages != null && !reverseMessages.isEmpty()) {
|
||||||
|
messages.addAll(reverseMessages);
|
||||||
|
messages.sort(Comparator.comparing(ArchiveMessage::getMsgtime, Comparator.nullsLast(Comparator.naturalOrder())));
|
||||||
|
}
|
||||||
|
Long reverseTotal = archiveMessageMapper.countBySessionId(reverseSessionId, corpId);
|
||||||
|
if (reverseTotal != null) {
|
||||||
|
total = (total != null ? total : 0L) + reverseTotal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<ArchiveMessageVO> list = messages.stream().map(this::convertMessageVO).collect(Collectors.toList());
|
||||||
|
return PageResult.of(page, size, total != null ? total : 0L, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String reverseSessionId(String sessionId) {
|
||||||
|
if (sessionId == null || !sessionId.contains("_")) return null;
|
||||||
|
String[] parts = sessionId.split("_", 3);
|
||||||
|
if (parts.length != 3) return null;
|
||||||
|
return parts[0] + "_" + parts[2] + "_" + parts[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 员工详情
|
||||||
|
*/
|
||||||
|
public StaffSessionVO getStaffDetail(String staffId) {
|
||||||
|
Staff staff = staffMapper.selectByStaffId(staffId, corpId);
|
||||||
|
if (staff == null) return null;
|
||||||
|
return convertStaffVO(staff);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 客户详情
|
||||||
|
*/
|
||||||
|
public CustomerSessionVO getCustomerDetail(String customerId) {
|
||||||
|
Customer customer = customerMapper.selectByCustomerId(customerId, corpId);
|
||||||
|
if (customer == null) return null;
|
||||||
|
return convertCustomerVO(customer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 私有方法 ============
|
||||||
|
|
||||||
|
private StaffSessionVO convertStaffVO(Staff staff) {
|
||||||
|
StaffSessionVO vo = new StaffSessionVO();
|
||||||
|
vo.setStaffId(staff.getStaffId());
|
||||||
|
vo.setName(staff.getName());
|
||||||
|
vo.setAvatar(staff.getAvatar());
|
||||||
|
vo.setDepartment(staff.getDepartment());
|
||||||
|
vo.setRole(staff.getRole());
|
||||||
|
vo.setStatus(staff.getStatus());
|
||||||
|
vo.setCreatedAt(staff.getCreatedAt());
|
||||||
|
vo.setUpdatedAt(staff.getUpdatedAt());
|
||||||
|
|
||||||
|
// 查询统计
|
||||||
|
try {
|
||||||
|
Long msgCount = archiveMessageMapper.selectCount(
|
||||||
|
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<ArchiveMessage>()
|
||||||
|
.eq(ArchiveMessage::getCorpId, corpId)
|
||||||
|
.eq(ArchiveMessage::getFromUser, staff.getStaffId())
|
||||||
|
);
|
||||||
|
vo.setMsgCount(msgCount != null ? msgCount.intValue() : 0);
|
||||||
|
vo.setTodayMsgCount(archiveMessageMapper.countTodayMsgByStaff(staff.getStaffId(), corpId));
|
||||||
|
vo.setCustomerCount(archiveMessageMapper.countCustomersByStaff(staff.getStaffId(), corpId));
|
||||||
|
Long lastMsgTime = archiveMessageMapper.selectLastMsgTimeByUser(staff.getStaffId(), corpId);
|
||||||
|
vo.setLastMsgTime(lastMsgTime);
|
||||||
|
vo.setLastMsgTimeStr(formatTime(lastMsgTime));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.debug("查询员工统计失败: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
return vo;
|
||||||
|
}
|
||||||
|
|
||||||
|
private CustomerSessionVO convertCustomerVO(Customer customer) {
|
||||||
|
CustomerSessionVO vo = new CustomerSessionVO();
|
||||||
|
vo.setCustomerId(customer.getCustomerId());
|
||||||
|
vo.setName(customer.getName());
|
||||||
|
vo.setAvatar(customer.getAvatar());
|
||||||
|
vo.setType(customer.getType());
|
||||||
|
vo.setGender(customer.getGender());
|
||||||
|
vo.setStatus(customer.getStatus());
|
||||||
|
vo.setFirstMsgTime(customer.getFirstMsgTime());
|
||||||
|
vo.setFirstMsgTimeStr(formatTime(customer.getFirstMsgTime()));
|
||||||
|
vo.setLastMsgTime(customer.getLastMsgTime());
|
||||||
|
vo.setLastMsgTimeStr(formatTime(customer.getLastMsgTime()));
|
||||||
|
vo.setAddTime(customer.getAddTime());
|
||||||
|
vo.setCreatedAt(customer.getCreatedAt());
|
||||||
|
vo.setUpdatedAt(customer.getUpdatedAt());
|
||||||
|
|
||||||
|
// 统计
|
||||||
|
try {
|
||||||
|
vo.setMsgCount(customer.getMsgCount() != null ? customer.getMsgCount() : 0);
|
||||||
|
vo.setTodayMsgCount(customer.getTodayMsgCount() != null ? customer.getTodayMsgCount() : 0);
|
||||||
|
|
||||||
|
// 关联员工数
|
||||||
|
String staffIds = customer.getStaffIds();
|
||||||
|
if (staffIds != null && !staffIds.isEmpty()) {
|
||||||
|
List<String> list = com.artedu.common.util.JsonUtils.fromJsonList(staffIds, String.class);
|
||||||
|
vo.setStaffCount(list != null ? list.size() : 0);
|
||||||
|
} else {
|
||||||
|
vo.setStaffCount(0);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.debug("查询客户统计失败: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
return vo;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ArchiveMessageVO convertMessageVO(ArchiveMessage msg) {
|
||||||
|
ArchiveMessageVO vo = new ArchiveMessageVO();
|
||||||
|
vo.setId(msg.getId());
|
||||||
|
vo.setMsgid(msg.getMsgid());
|
||||||
|
vo.setSeq(msg.getSeq());
|
||||||
|
vo.setCorpId(msg.getCorpId());
|
||||||
|
vo.setAction(msg.getAction());
|
||||||
|
vo.setFromUser(msg.getFromUser());
|
||||||
|
vo.setFromRole(msg.getFromRole());
|
||||||
|
vo.setToUser(msg.getToUser());
|
||||||
|
vo.setTolist(msg.getTolist());
|
||||||
|
vo.setRoomid(msg.getRoomid());
|
||||||
|
vo.setMsgtype(msg.getMsgtype());
|
||||||
|
vo.setMsgtime(msg.getMsgtime());
|
||||||
|
vo.setMsgTimeStr(formatTime(msg.getMsgtime()));
|
||||||
|
vo.setContent(msg.getContent());
|
||||||
|
vo.setMediaData(msg.getMediaData());
|
||||||
|
vo.setSessionId(msg.getSessionId());
|
||||||
|
vo.setDecryptStatus(msg.getDecryptStatus());
|
||||||
|
vo.setDecryptError(msg.getDecryptError());
|
||||||
|
if (msg.getCreatedAt() != null) vo.setCreatedAt(msg.getCreatedAt().toString());
|
||||||
|
if (msg.getUpdatedAt() != null) vo.setUpdatedAt(msg.getUpdatedAt().toString());
|
||||||
|
return vo;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void fillSessionParticipants(SessionVO vo, String sessionId) {
|
||||||
|
try {
|
||||||
|
List<ArchiveMessage> msgs = archiveMessageMapper.selectBySessionId(sessionId, corpId, 0, 1);
|
||||||
|
if (msgs != null && !msgs.isEmpty()) {
|
||||||
|
ArchiveMessage msg = msgs.get(0);
|
||||||
|
if (msg.getRoomid() != null && !msg.getRoomid().isEmpty()) {
|
||||||
|
vo.setRoomId(msg.getRoomid());
|
||||||
|
vo.setSessionType("GROUP");
|
||||||
|
} else {
|
||||||
|
vo.setSessionType("SINGLE");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查找客户ID和员工ID(解析可能的JSON数组)
|
||||||
|
String fromUser = extractSingleUserId(msg.getFromUser());
|
||||||
|
String toUser = extractSingleUserId(msg.getToUser());
|
||||||
|
if (fromUser != null && isExternalUserId(fromUser)) {
|
||||||
|
vo.setCustomerId(fromUser);
|
||||||
|
Customer c = customerMapper.selectByCustomerId(fromUser, corpId);
|
||||||
|
vo.setCustomerName(c != null && c.getName() != null ? c.getName() : fromUser);
|
||||||
|
}
|
||||||
|
if (toUser != null && isExternalUserId(toUser)) {
|
||||||
|
vo.setCustomerId(toUser);
|
||||||
|
Customer c = customerMapper.selectByCustomerId(toUser, corpId);
|
||||||
|
vo.setCustomerName(c != null && c.getName() != null ? c.getName() : toUser);
|
||||||
|
}
|
||||||
|
if (fromUser != null && !isExternalUserId(fromUser)) {
|
||||||
|
vo.setStaffId(fromUser);
|
||||||
|
Staff s = staffMapper.selectByStaffId(fromUser, corpId);
|
||||||
|
vo.setStaffName(s != null && s.getName() != null ? s.getName() : fromUser);
|
||||||
|
}
|
||||||
|
if (toUser != null && !isExternalUserId(toUser)) {
|
||||||
|
vo.setStaffId(toUser);
|
||||||
|
Staff s = staffMapper.selectByStaffId(toUser, corpId);
|
||||||
|
vo.setStaffName(s != null && s.getName() != null ? s.getName() : toUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 最后一条消息内容
|
||||||
|
ArchiveMessage latest = archiveMessageMapper.selectBySessionId(sessionId, corpId, 0, 1).get(0);
|
||||||
|
if (latest != null) {
|
||||||
|
vo.setLastMsgContent(latest.getContent());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.debug("填充会话参与者失败: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String extractSingleUserId(String fieldValue) {
|
||||||
|
if (fieldValue == null || fieldValue.isEmpty()) return null;
|
||||||
|
if (fieldValue.startsWith("[") && fieldValue.endsWith("]")) {
|
||||||
|
try {
|
||||||
|
List<String> ids = com.artedu.common.util.JsonUtils.fromJsonList(fieldValue, String.class);
|
||||||
|
if (ids != null && !ids.isEmpty()) {
|
||||||
|
return ids.get(0).trim();
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
// 回退
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fieldValue.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isExternalUserId(String userId) {
|
||||||
|
return userId != null && (userId.startsWith("wm") || userId.startsWith("wo"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String formatTime(Long msgTime) {
|
||||||
|
if (msgTime == null) return "-";
|
||||||
|
try {
|
||||||
|
return TIME_FORMAT.format(new Date(msgTime));
|
||||||
|
} catch (Exception e) {
|
||||||
|
return String.valueOf(msgTime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,262 @@
|
|||||||
|
package com.artedu.archive.service;
|
||||||
|
|
||||||
|
import com.artedu.archive.entity.ArchiveMessage;
|
||||||
|
import com.artedu.archive.entity.RiskConfig;
|
||||||
|
import com.artedu.archive.entity.RiskEvent;
|
||||||
|
import com.artedu.archive.mapper.ArchiveMessageMapper;
|
||||||
|
import com.artedu.archive.mapper.RiskConfigMapper;
|
||||||
|
import com.artedu.archive.mapper.RiskEventMapper;
|
||||||
|
import com.artedu.common.util.JsonUtils;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 风险事件检测引擎
|
||||||
|
* 检测:超时未回复、红包、撤回消息、深夜聊天等
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
public class RiskEventDetector {
|
||||||
|
|
||||||
|
@Value("${wecom.archive.corp-id}")
|
||||||
|
private String corpId;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private RiskConfigMapper riskConfigMapper;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private RiskEventMapper riskEventMapper;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ArchiveMessageMapper archiveMessageMapper;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private RiskEventNotifyService notifyService;
|
||||||
|
|
||||||
|
// 配置缓存
|
||||||
|
private List<RiskConfig> configCache;
|
||||||
|
private long configCacheTime = 0;
|
||||||
|
private static final long CONFIG_CACHE_TTL = 60 * 1000; // 1分钟缓存
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取启用的风险配置
|
||||||
|
*/
|
||||||
|
private List<RiskConfig> getEnabledConfigs() {
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
if (configCache == null || now - configCacheTime > CONFIG_CACHE_TTL) {
|
||||||
|
configCache = riskConfigMapper.selectEnabledByCorp(corpId);
|
||||||
|
configCacheTime = now;
|
||||||
|
}
|
||||||
|
return configCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检测单条消息的风险事件
|
||||||
|
*/
|
||||||
|
public void detect(ArchiveMessage message) {
|
||||||
|
try {
|
||||||
|
List<RiskConfig> configs = getEnabledConfigs();
|
||||||
|
for (RiskConfig config : configs) {
|
||||||
|
if (config.getEnabled() == null || config.getEnabled() != 1) continue;
|
||||||
|
|
||||||
|
switch (config.getEventType()) {
|
||||||
|
case "RED_PACKET":
|
||||||
|
detectRedPacket(message, config);
|
||||||
|
break;
|
||||||
|
case "RECALL_MSG":
|
||||||
|
detectRecall(message, config);
|
||||||
|
break;
|
||||||
|
case "NIGHT_CHAT":
|
||||||
|
detectNightChat(message, config);
|
||||||
|
break;
|
||||||
|
case "TIMEOUT_NO_REPLY":
|
||||||
|
detectTimeoutNoReply(message, config);
|
||||||
|
break;
|
||||||
|
case "CUSTOMER_TIMEOUT":
|
||||||
|
detectCustomerTimeout(message, config);
|
||||||
|
break;
|
||||||
|
case "SENSITIVE_WORD":
|
||||||
|
// 敏感词检测在 SensitiveWordScanner 中处理
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("风险事件检测失败: msgid={}", message.getMsgid(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检测红包/转账
|
||||||
|
*/
|
||||||
|
private void detectRedPacket(ArchiveMessage message, RiskConfig config) {
|
||||||
|
String msgtype = message.getMsgtype();
|
||||||
|
if ("redpacket".equals(msgtype) || "transfer".equals(msgtype)) {
|
||||||
|
createRiskEvent(config, message.getFromUser(), message.getFromRole(),
|
||||||
|
message.getToUser(), "会话中涉及红包/转账", message.getId(),
|
||||||
|
message.getSessionId(), message.getMsgtime());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检测撤回消息
|
||||||
|
*/
|
||||||
|
private void detectRecall(ArchiveMessage message, RiskConfig config) {
|
||||||
|
if ("recall".equals(message.getAction())) {
|
||||||
|
createRiskEvent(config, message.getFromUser(), message.getFromRole(),
|
||||||
|
message.getToUser(), "撤回了一条消息", message.getId(),
|
||||||
|
message.getSessionId(), message.getMsgtime());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检测深夜聊天(22:00 - 08:00)
|
||||||
|
*/
|
||||||
|
private void detectNightChat(ArchiveMessage message, RiskConfig config) {
|
||||||
|
if (message.getMsgtime() == null) return;
|
||||||
|
LocalDateTime msgTime = LocalDateTime.ofInstant(
|
||||||
|
Instant.ofEpochMilli(message.getMsgtime()),
|
||||||
|
ZoneId.of("Asia/Shanghai"));
|
||||||
|
int hour = msgTime.getHour();
|
||||||
|
if (hour >= 22 || hour < 8) {
|
||||||
|
createRiskEvent(config, message.getFromUser(), message.getFromRole(),
|
||||||
|
message.getToUser(),
|
||||||
|
String.format("深夜聊天 (%02d:%02d)", hour, msgTime.getMinute()),
|
||||||
|
message.getId(), message.getSessionId(), message.getMsgtime());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检测员工超时未回复(客户消息后员工超过阈值未回复)
|
||||||
|
* 当收到员工回复时,检查上一条客户消息的时间间隔
|
||||||
|
* 限制:最大检测间隔8小时,超过则认为是跨天/跨周末,不生成事件
|
||||||
|
*/
|
||||||
|
private void detectTimeoutNoReply(ArchiveMessage message, RiskConfig config) {
|
||||||
|
if (!"INTERNAL".equals(message.getFromRole())) return;
|
||||||
|
if (message.getSessionId() == null) return;
|
||||||
|
|
||||||
|
Integer threshold = config.getThreshold();
|
||||||
|
if (threshold == null) threshold = 30; // 默认30分钟
|
||||||
|
long thresholdMs = threshold * 60 * 1000L;
|
||||||
|
long maxGapMs = 8 * 60 * 60 * 1000L; // 最大8小时
|
||||||
|
|
||||||
|
// 查询该会话中上一条消息
|
||||||
|
List<ArchiveMessage> prevMsgs = archiveMessageMapper.selectBySessionId(
|
||||||
|
message.getSessionId(), corpId, 0, 10);
|
||||||
|
|
||||||
|
ArchiveMessage lastExternalMsg = null;
|
||||||
|
for (ArchiveMessage prev : prevMsgs) {
|
||||||
|
if (prev.getId().equals(message.getId())) continue;
|
||||||
|
if (prev.getMsgtime() != null && prev.getMsgtime() >= message.getMsgtime()) continue;
|
||||||
|
if ("EXTERNAL".equals(prev.getFromRole())) {
|
||||||
|
lastExternalMsg = prev;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastExternalMsg != null && message.getMsgtime() != null) {
|
||||||
|
long gap = message.getMsgtime() - lastExternalMsg.getMsgtime();
|
||||||
|
// 只在阈值和最大间隔之间才生成事件
|
||||||
|
if (gap > thresholdMs && gap <= maxGapMs) {
|
||||||
|
createRiskEvent(config, message.getFromUser(), "INTERNAL",
|
||||||
|
lastExternalMsg.getFromUser(),
|
||||||
|
String.format("客户消息 %d 分钟后才回复", (int)(gap / 60000)),
|
||||||
|
message.getId(), message.getSessionId(), message.getMsgtime());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检测客户超时未回复(员工消息后客户超过阈值未回复)
|
||||||
|
* 当收到客户回复时,检查上一条员工消息的时间间隔
|
||||||
|
* 限制:最大检测间隔8小时
|
||||||
|
*/
|
||||||
|
private void detectCustomerTimeout(ArchiveMessage message, RiskConfig config) {
|
||||||
|
if (!"EXTERNAL".equals(message.getFromRole())) return;
|
||||||
|
if (message.getSessionId() == null) return;
|
||||||
|
|
||||||
|
Integer threshold = config.getThreshold();
|
||||||
|
if (threshold == null) threshold = 120; // 默认120分钟
|
||||||
|
long thresholdMs = threshold * 60 * 1000L;
|
||||||
|
long maxGapMs = 8 * 60 * 60 * 1000L; // 最大8小时
|
||||||
|
|
||||||
|
List<ArchiveMessage> prevMsgs = archiveMessageMapper.selectBySessionId(
|
||||||
|
message.getSessionId(), corpId, 0, 10);
|
||||||
|
|
||||||
|
ArchiveMessage lastInternalMsg = null;
|
||||||
|
for (ArchiveMessage prev : prevMsgs) {
|
||||||
|
if (prev.getId().equals(message.getId())) continue;
|
||||||
|
if (prev.getMsgtime() != null && prev.getMsgtime() >= message.getMsgtime()) continue;
|
||||||
|
if ("INTERNAL".equals(prev.getFromRole())) {
|
||||||
|
lastInternalMsg = prev;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastInternalMsg != null && message.getMsgtime() != null) {
|
||||||
|
long gap = message.getMsgtime() - lastInternalMsg.getMsgtime();
|
||||||
|
if (gap > thresholdMs && gap <= maxGapMs) {
|
||||||
|
createRiskEvent(config, message.getFromUser(), "EXTERNAL",
|
||||||
|
lastInternalMsg.getFromUser(),
|
||||||
|
String.format("员工消息后 %d 分钟才回复", (int)(gap / 60000)),
|
||||||
|
message.getId(), message.getSessionId(), message.getMsgtime());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建风险事件记录
|
||||||
|
*/
|
||||||
|
private void createRiskEvent(RiskConfig config, String userId, String userRole,
|
||||||
|
String relatedUser, String description,
|
||||||
|
Long msgId, String sessionId, Long msgtime) {
|
||||||
|
try {
|
||||||
|
RiskEvent event = new RiskEvent();
|
||||||
|
event.setEventType(config.getEventType());
|
||||||
|
event.setEventName(config.getEventName());
|
||||||
|
event.setStatus(0); // 待处理
|
||||||
|
event.setUserId(userId);
|
||||||
|
event.setUserName(userId); // 先用ID代替,后续异步补充姓名
|
||||||
|
event.setUserRole(userRole);
|
||||||
|
event.setRelatedUser(extractUserId(relatedUser));
|
||||||
|
event.setDescription(description);
|
||||||
|
event.setMsgId(msgId);
|
||||||
|
event.setSessionId(sessionId);
|
||||||
|
if (msgtime != null) {
|
||||||
|
event.setOccurredAt(LocalDateTime.ofInstant(
|
||||||
|
Instant.ofEpochMilli(msgtime), ZoneId.of("Asia/Shanghai")));
|
||||||
|
}
|
||||||
|
event.setCorpId(corpId);
|
||||||
|
riskEventMapper.insert(event);
|
||||||
|
log.debug("创建风险事件: type={}, user={}", config.getEventType(), userId);
|
||||||
|
|
||||||
|
// 风险事件不再实时推送,改为定时汇总通知(每30分钟一次)
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("创建风险事件失败", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从可能包含JSON数组的字段中提取单个userId
|
||||||
|
*/
|
||||||
|
private String extractUserId(String fieldValue) {
|
||||||
|
if (fieldValue == null || fieldValue.isEmpty()) return null;
|
||||||
|
if (fieldValue.startsWith("[") && fieldValue.endsWith("]")) {
|
||||||
|
try {
|
||||||
|
List<String> ids = JsonUtils.fromJsonList(fieldValue, String.class);
|
||||||
|
if (ids != null && !ids.isEmpty()) return ids.get(0);
|
||||||
|
} catch (Exception e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fieldValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,152 @@
|
|||||||
|
package com.artedu.archive.service;
|
||||||
|
|
||||||
|
import com.artedu.archive.entity.RiskEvent;
|
||||||
|
import com.artedu.archive.mapper.RiskEventMapper;
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 风险事件通知服务 (SSE)
|
||||||
|
* 改为每30分钟汇总推送一次,避免频繁打扰
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
public class RiskEventNotifyService {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private RiskEventMapper riskEventMapper;
|
||||||
|
|
||||||
|
// 存储所有连接的客户端
|
||||||
|
private final Map<String, SseEmitter> emitters = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
// 上次汇总推送时间
|
||||||
|
private volatile LocalDateTime lastSummaryTime = LocalDateTime.now().minusMinutes(30);
|
||||||
|
|
||||||
|
private static final long TIMEOUT = 3600_000L; // 1小时超时
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 订阅风险事件通知
|
||||||
|
*/
|
||||||
|
public SseEmitter subscribe(String clientId) {
|
||||||
|
removeEmitter(clientId);
|
||||||
|
|
||||||
|
SseEmitter emitter = new SseEmitter(TIMEOUT);
|
||||||
|
emitters.put(clientId, emitter);
|
||||||
|
|
||||||
|
emitter.onCompletion(() -> {
|
||||||
|
log.debug("SSE连接完成: {}", clientId);
|
||||||
|
emitters.remove(clientId);
|
||||||
|
});
|
||||||
|
|
||||||
|
emitter.onTimeout(() -> {
|
||||||
|
log.debug("SSE连接超时: {}", clientId);
|
||||||
|
emitters.remove(clientId);
|
||||||
|
});
|
||||||
|
|
||||||
|
emitter.onError((e) -> {
|
||||||
|
log.debug("SSE连接错误: {}, error={}", clientId, e.getMessage());
|
||||||
|
emitters.remove(clientId);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
emitter.send(SseEmitter.event()
|
||||||
|
.name("connected")
|
||||||
|
.data("{\"status\":\"connected\",\"message\":\"风险事件汇总通知已连接,每30分钟推送一次\"}"));
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.warn("SSE初始连接发送失败: {}", clientId);
|
||||||
|
emitters.remove(clientId);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("SSE客户端连接: {}, 当前连接数: {}", clientId, emitters.size());
|
||||||
|
return emitter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 定时汇总推送:每30分钟执行一次
|
||||||
|
*/
|
||||||
|
@Scheduled(fixedRate = 30 * 60 * 1000)
|
||||||
|
public void scheduledSummaryNotify() {
|
||||||
|
try {
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
LocalDateTime fromTime = lastSummaryTime;
|
||||||
|
|
||||||
|
LambdaQueryWrapper<RiskEvent> wrapper = new LambdaQueryWrapper<>();
|
||||||
|
wrapper.ge(RiskEvent::getCreatedAt, fromTime)
|
||||||
|
.lt(RiskEvent::getCreatedAt, now)
|
||||||
|
.orderByDesc(RiskEvent::getCreatedAt);
|
||||||
|
|
||||||
|
List<RiskEvent> events = riskEventMapper.selectList(wrapper);
|
||||||
|
if (events == null || events.isEmpty()) {
|
||||||
|
log.debug("过去30分钟无新增风险事件,跳过推送");
|
||||||
|
lastSummaryTime = now;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按类型统计
|
||||||
|
Map<String, Integer> typeCount = new HashMap<>();
|
||||||
|
int pendingCount = 0;
|
||||||
|
for (RiskEvent event : events) {
|
||||||
|
String name = event.getEventName() != null ? event.getEventName() : event.getEventType();
|
||||||
|
typeCount.put(name, typeCount.getOrDefault(name, 0) + 1);
|
||||||
|
if (event.getStatus() != null && event.getStatus() == 0) {
|
||||||
|
pendingCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> summary = new HashMap<>();
|
||||||
|
summary.put("type", "summary");
|
||||||
|
summary.put("totalCount", events.size());
|
||||||
|
summary.put("pendingCount", pendingCount);
|
||||||
|
summary.put("typeDistribution", typeCount);
|
||||||
|
summary.put("periodMinutes", 30);
|
||||||
|
summary.put("lastEventTime", events.get(0).getCreatedAt() != null ? events.get(0).getCreatedAt().toString() : null);
|
||||||
|
|
||||||
|
String data = com.artedu.common.util.JsonUtils.toJson(summary);
|
||||||
|
emitters.forEach((clientId, emitter) -> {
|
||||||
|
try {
|
||||||
|
emitter.send(SseEmitter.event()
|
||||||
|
.name("risk-summary")
|
||||||
|
.data(data));
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.debug("SSE汇总推送失败, 移除客户端: {}", clientId);
|
||||||
|
emitters.remove(clientId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
log.info("风险事件汇总推送完成: 过去30分钟共 {} 条新事件, 待处理 {} 条, 连接数: {}",
|
||||||
|
events.size(), pendingCount, emitters.size());
|
||||||
|
lastSummaryTime = now;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("风险事件汇总推送失败", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移除指定客户端
|
||||||
|
*/
|
||||||
|
public void removeEmitter(String clientId) {
|
||||||
|
SseEmitter old = emitters.remove(clientId);
|
||||||
|
if (old != null) {
|
||||||
|
try {
|
||||||
|
old.complete();
|
||||||
|
} catch (Exception e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getConnectionCount() {
|
||||||
|
return emitters.size();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,170 @@
|
|||||||
|
package com.artedu.archive.service;
|
||||||
|
|
||||||
|
import com.artedu.archive.entity.SensitiveWord;
|
||||||
|
import com.artedu.archive.entity.SensitiveWordHit;
|
||||||
|
import com.artedu.archive.mapper.SensitiveWordHitMapper;
|
||||||
|
import com.artedu.archive.mapper.SensitiveWordMapper;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Update;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import javax.annotation.PostConstruct;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.CopyOnWriteArrayList;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 敏感词扫描引擎
|
||||||
|
* 支持实时消息扫描和历史消息扫描
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
public class SensitiveWordScanner {
|
||||||
|
|
||||||
|
@Value("${wecom.archive.corp-id}")
|
||||||
|
private String corpId;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private SensitiveWordMapper sensitiveWordMapper;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private SensitiveWordHitMapper sensitiveWordHitMapper;
|
||||||
|
|
||||||
|
// 内存缓存的敏感词列表(线程安全)
|
||||||
|
private volatile List<SensitiveWord> wordCache = new CopyOnWriteArrayList<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化时加载敏感词
|
||||||
|
*/
|
||||||
|
@PostConstruct
|
||||||
|
public void init() {
|
||||||
|
refreshWordCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 每5分钟刷新一次敏感词缓存
|
||||||
|
*/
|
||||||
|
@Scheduled(fixedRate = 5 * 60 * 1000)
|
||||||
|
public void refreshWordCache() {
|
||||||
|
try {
|
||||||
|
List<SensitiveWord> words = sensitiveWordMapper.selectEnabledByCorp(corpId);
|
||||||
|
wordCache = new CopyOnWriteArrayList<>(words);
|
||||||
|
log.debug("敏感词缓存刷新完成,共 {} 个词", words.size());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("刷新敏感词缓存失败", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 扫描单条消息内容
|
||||||
|
* @param content 消息内容
|
||||||
|
* @param targetType 目标类型(员工/客户)
|
||||||
|
* @param chatType 会话类型(单聊/群聊)
|
||||||
|
* @return 命中的敏感词列表
|
||||||
|
*/
|
||||||
|
public List<HitResult> scan(String content, String targetType, String chatType) {
|
||||||
|
List<HitResult> hits = new ArrayList<>();
|
||||||
|
if (content == null || content.isEmpty()) {
|
||||||
|
return hits;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<SensitiveWord> words = wordCache;
|
||||||
|
log.debug("敏感词扫描: content={}, wordCacheSize={}", content, words.size());
|
||||||
|
for (SensitiveWord word : words) {
|
||||||
|
// 检查适用对象
|
||||||
|
String wordTarget = word.getTargetType();
|
||||||
|
if (wordTarget != null && !"全部".equals(wordTarget) && !wordTarget.equals(targetType)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// 检查适用会话类型
|
||||||
|
String wordChatType = word.getChatType();
|
||||||
|
if (wordChatType != null && !"全部".equals(wordChatType) && !wordChatType.equals(chatType)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
String keyword = word.getWord();
|
||||||
|
if (keyword == null || keyword.isEmpty()) continue;
|
||||||
|
|
||||||
|
// 匹配计数
|
||||||
|
int count = countMatches(content, keyword);
|
||||||
|
if (count > 0) {
|
||||||
|
hits.add(new HitResult(word.getId(), keyword, count));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hits;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 扫描消息并保存命中记录
|
||||||
|
*/
|
||||||
|
public List<HitResult> scanAndSave(String content, String senderId, String senderRole,
|
||||||
|
String receiverId, String sessionId, Long msgId,
|
||||||
|
String targetType, String chatType) {
|
||||||
|
List<HitResult> hits = scan(content, targetType, chatType);
|
||||||
|
for (HitResult hit : hits) {
|
||||||
|
try {
|
||||||
|
// 去重:同一条消息同一敏感词只记录一次
|
||||||
|
int exists = sensitiveWordHitMapper.countByMsgAndWord(msgId, hit.getWordId());
|
||||||
|
if (exists > 0) {
|
||||||
|
log.debug("敏感词命中记录已存在: msgId={}, wordId={}", msgId, hit.getWordId());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
SensitiveWordHit record = new SensitiveWordHit();
|
||||||
|
record.setWordId(hit.getWordId());
|
||||||
|
record.setWord(hit.getWord());
|
||||||
|
record.setMsgId(msgId);
|
||||||
|
record.setMsgContent(content);
|
||||||
|
record.setSenderId(senderId);
|
||||||
|
record.setSenderRole(senderRole);
|
||||||
|
record.setReceiverId(receiverId);
|
||||||
|
record.setSessionId(sessionId);
|
||||||
|
record.setHitCount(hit.getCount());
|
||||||
|
record.setCorpId(corpId);
|
||||||
|
sensitiveWordHitMapper.insert(record);
|
||||||
|
|
||||||
|
// 更新敏感词匹配次数
|
||||||
|
sensitiveWordMapper.incrementMatchCount(hit.getWordId(), hit.getCount());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("保存敏感词命中记录失败: word={}, msgId={}", hit.getWord(), msgId, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hits;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算关键词在内容中出现次数
|
||||||
|
*/
|
||||||
|
private int countMatches(String content, String keyword) {
|
||||||
|
int count = 0;
|
||||||
|
int index = 0;
|
||||||
|
while ((index = content.indexOf(keyword, index)) != -1) {
|
||||||
|
count++;
|
||||||
|
index += keyword.length();
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 命中结果
|
||||||
|
*/
|
||||||
|
public static class HitResult {
|
||||||
|
private Long wordId;
|
||||||
|
private String word;
|
||||||
|
private int count;
|
||||||
|
|
||||||
|
public HitResult(Long wordId, String word, int count) {
|
||||||
|
this.wordId = wordId;
|
||||||
|
this.word = word;
|
||||||
|
this.count = count;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getWordId() { return wordId; }
|
||||||
|
public String getWord() { return word; }
|
||||||
|
public int getCount() { return count; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,17 +1,24 @@
|
|||||||
package com.artedu.archive.util;
|
package com.artedu.archive.util;
|
||||||
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.bouncycastle.jce.provider.BouncyCastleProvider;
|
||||||
|
import org.bouncycastle.openssl.PEMKeyPair;
|
||||||
|
import org.bouncycastle.openssl.PEMParser;
|
||||||
|
import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter;
|
||||||
|
import org.bouncycastle.asn1.pkcs.PrivateKeyInfo;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
import javax.crypto.Cipher;
|
import javax.crypto.Cipher;
|
||||||
|
import java.io.StringReader;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.security.*;
|
import java.security.*;
|
||||||
import java.security.spec.PKCS8EncodedKeySpec;
|
import java.security.spec.*;
|
||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RSA解密工具类
|
* RSA解密工具类
|
||||||
* 用于解密企微会话存档的encrypt_random_key
|
* 用于解密企微会话存档的encrypt_random_key
|
||||||
|
* 支持PEM格式(PKCS#1和PKCS#8)私钥
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Component
|
@Component
|
||||||
@ -20,23 +27,101 @@ public class RsaDecryptUtil {
|
|||||||
private static final String ALGORITHM = "RSA";
|
private static final String ALGORITHM = "RSA";
|
||||||
private static final String TRANSFORMATION = "RSA/ECB/PKCS1Padding";
|
private static final String TRANSFORMATION = "RSA/ECB/PKCS1Padding";
|
||||||
|
|
||||||
public PrivateKey loadPrivateKey(String base64PrivateKey) throws Exception {
|
static {
|
||||||
byte[] keyBytes = Base64.getDecoder().decode(base64PrivateKey);
|
Security.addProvider(new BouncyCastleProvider());
|
||||||
PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(keyBytes);
|
|
||||||
KeyFactory keyFactory = KeyFactory.getInstance(ALGORITHM);
|
|
||||||
return keyFactory.generatePrivate(spec);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public String decrypt(String encryptedData, String base64PrivateKey) {
|
// 缓存解析后的私钥,避免每次重复解析
|
||||||
|
private PrivateKey cachedPrivateKey;
|
||||||
|
private String cachedPrivateKeyPem;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从PEM字符串加载私钥
|
||||||
|
* 支持 PKCS#1 (-----BEGIN RSA PRIVATE KEY-----) 和 PKCS#8 (-----BEGIN PRIVATE KEY-----)
|
||||||
|
*/
|
||||||
|
public PrivateKey loadPrivateKey(String pemPrivateKey) throws Exception {
|
||||||
|
// 提取纯 Base64 内容
|
||||||
|
String base64Only = pemPrivateKey
|
||||||
|
.replace("-----BEGIN RSA PRIVATE KEY-----", "")
|
||||||
|
.replace("-----END RSA PRIVATE KEY-----", "")
|
||||||
|
.replace("-----BEGIN PRIVATE KEY-----", "")
|
||||||
|
.replace("-----END PRIVATE KEY-----", "")
|
||||||
|
.replaceAll("\\s", "");
|
||||||
|
|
||||||
|
byte[] keyBytes = Base64.getDecoder().decode(base64Only);
|
||||||
|
|
||||||
|
// 尝试 PKCS#8
|
||||||
try {
|
try {
|
||||||
PrivateKey privateKey = loadPrivateKey(base64PrivateKey);
|
PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(keyBytes);
|
||||||
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
|
KeyFactory keyFactory = KeyFactory.getInstance(ALGORITHM);
|
||||||
cipher.init(Cipher.DECRYPT_MODE, privateKey);
|
PrivateKey pk = keyFactory.generatePrivate(spec);
|
||||||
|
log.debug("私钥以PKCS#8格式加载成功");
|
||||||
|
return pk;
|
||||||
|
} catch (InvalidKeySpecException e) {
|
||||||
|
// PKCS#1 格式,使用BouncyCastle解析
|
||||||
|
log.debug("PKCS#8加载失败,尝试BouncyCastle解析PKCS#1");
|
||||||
|
PEMParser parser = new PEMParser(new StringReader(pemPrivateKey));
|
||||||
|
Object obj = parser.readObject();
|
||||||
|
parser.close();
|
||||||
|
|
||||||
|
JcaPEMKeyConverter converter = new JcaPEMKeyConverter().setProvider("BC");
|
||||||
|
PrivateKey pk;
|
||||||
|
if (obj instanceof PEMKeyPair) {
|
||||||
|
pk = converter.getKeyPair((PEMKeyPair) obj).getPrivate();
|
||||||
|
log.debug("私钥以PKCS#1(PEMKeyPair)格式加载成功");
|
||||||
|
} else if (obj instanceof PrivateKeyInfo) {
|
||||||
|
pk = converter.getPrivateKey((PrivateKeyInfo) obj);
|
||||||
|
log.debug("私钥以PrivateKeyInfo格式加载成功");
|
||||||
|
} else {
|
||||||
|
throw new IllegalArgumentException("不支持的私钥格式: " + (obj != null ? obj.getClass() : "null"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证:从私钥导出公钥,确保密钥对有效
|
||||||
|
try {
|
||||||
|
KeyFactory kf = KeyFactory.getInstance(ALGORITHM);
|
||||||
|
RSAPrivateCrtKeySpec privSpec = kf.getKeySpec(pk, RSAPrivateCrtKeySpec.class);
|
||||||
|
RSAPublicKeySpec pubSpec = new RSAPublicKeySpec(privSpec.getModulus(), privSpec.getPublicExponent());
|
||||||
|
PublicKey derivedPub = kf.generatePublic(pubSpec);
|
||||||
|
log.debug("私钥验证通过,可导出公钥 (modulus bits={})", privSpec.getModulus().bitLength());
|
||||||
|
} catch (Exception verifyEx) {
|
||||||
|
log.warn("私钥导出公钥验证失败: {}", verifyEx.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
return pk;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public String decrypt(String encryptedData, String pemPrivateKey) {
|
||||||
|
try {
|
||||||
|
// 使用缓存的私钥(仅在私钥内容变化时重新解析)
|
||||||
|
if (cachedPrivateKey == null || !pemPrivateKey.equals(cachedPrivateKeyPem)) {
|
||||||
|
cachedPrivateKey = loadPrivateKey(pemPrivateKey);
|
||||||
|
cachedPrivateKeyPem = pemPrivateKey;
|
||||||
|
log.info("RSA私钥已加载并缓存, algorithm={}", cachedPrivateKey.getAlgorithm());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 明确使用SunJCE提供者,避免BouncyCastle的RSA实现问题
|
||||||
|
Cipher cipher = Cipher.getInstance(TRANSFORMATION, "SunJCE");
|
||||||
|
cipher.init(Cipher.DECRYPT_MODE, cachedPrivateKey);
|
||||||
byte[] encryptedBytes = Base64.getDecoder().decode(encryptedData);
|
byte[] encryptedBytes = Base64.getDecoder().decode(encryptedData);
|
||||||
|
|
||||||
|
// 企微返回的encrypt_random_key可能不足密钥模数长度,需要前补零
|
||||||
|
int keySize = ((java.security.interfaces.RSAPrivateKey) cachedPrivateKey).getModulus().bitLength() / 8;
|
||||||
|
if (encryptedBytes.length < keySize) {
|
||||||
|
byte[] padded = new byte[keySize];
|
||||||
|
System.arraycopy(encryptedBytes, 0, padded, keySize - encryptedBytes.length, encryptedBytes.length);
|
||||||
|
encryptedBytes = padded;
|
||||||
|
log.debug("密文已零填充: {} -> {} bytes", encryptedBytes.length - keySize + encryptedBytes.length, keySize);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug("解密数据长度: {} bytes (keySize={})", encryptedBytes.length, keySize);
|
||||||
byte[] decryptedBytes = cipher.doFinal(encryptedBytes);
|
byte[] decryptedBytes = cipher.doFinal(encryptedBytes);
|
||||||
return new String(decryptedBytes, StandardCharsets.UTF_8);
|
String result = new String(decryptedBytes, StandardCharsets.UTF_8);
|
||||||
|
log.debug("解密成功,结果长度: {}", result.length());
|
||||||
|
return result;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("RSA解密失败: {}", e.getMessage(), e);
|
// 使用debug级别避免日志刷屏(企微可能更换公钥版本导致旧消息无法解密)
|
||||||
|
log.debug("RSA解密失败: {}, encryptedData长度={}", e.getMessage(), encryptedData != null ? encryptedData.length() : 0);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,108 @@
|
|||||||
|
package com.artedu.archive.util;
|
||||||
|
|
||||||
|
import javax.crypto.Cipher;
|
||||||
|
import javax.crypto.spec.IvParameterSpec;
|
||||||
|
import javax.crypto.spec.SecretKeySpec;
|
||||||
|
import java.nio.ByteBuffer;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Base64;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 企业微信消息加解密工具
|
||||||
|
* 算法:AES-256-CBC
|
||||||
|
*/
|
||||||
|
public class WeComCryptUtil {
|
||||||
|
|
||||||
|
private final byte[] aesKey;
|
||||||
|
|
||||||
|
public WeComCryptUtil(String encodingAesKey) {
|
||||||
|
// EncodingAESKey 是 43 字节 Base64 编码,解码后为 32 字节
|
||||||
|
this.aesKey = Base64.getDecoder().decode(encodingAesKey + "=");
|
||||||
|
if (aesKey.length != 32) {
|
||||||
|
throw new IllegalArgumentException("EncodingAESKey 解码后必须是 32 字节,当前: " + aesKey.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解密企微回调的加密消息
|
||||||
|
*
|
||||||
|
* @param encrypted 密文(Base64编码)
|
||||||
|
* @return 明文
|
||||||
|
*/
|
||||||
|
public String decrypt(String encrypted) throws Exception {
|
||||||
|
byte[] encryptedBytes = Base64.getDecoder().decode(encrypted);
|
||||||
|
|
||||||
|
// AES-256-CBC 解密,IV = key 前 16 字节
|
||||||
|
Cipher cipher = Cipher.getInstance("AES/CBC/NoPadding");
|
||||||
|
SecretKeySpec keySpec = new SecretKeySpec(aesKey, "AES");
|
||||||
|
IvParameterSpec iv = new IvParameterSpec(Arrays.copyOfRange(aesKey, 0, 16));
|
||||||
|
cipher.init(Cipher.DECRYPT_MODE, keySpec, iv);
|
||||||
|
|
||||||
|
byte[] decrypted = cipher.doFinal(encryptedBytes);
|
||||||
|
|
||||||
|
// 去掉 PKCS7 填充
|
||||||
|
decrypted = removePKCS7Padding(decrypted);
|
||||||
|
|
||||||
|
// 解析明文结构:random(16B) + msg_len(4B) + msg + corp_id
|
||||||
|
// msg_len 是网络字节序(大端)
|
||||||
|
int msgLen = ByteBuffer.wrap(decrypted, 16, 4).getInt();
|
||||||
|
|
||||||
|
// 从第 20 字节开始,截取 msgLen 长度的消息
|
||||||
|
int msgStart = 20;
|
||||||
|
int msgEnd = msgStart + msgLen;
|
||||||
|
|
||||||
|
if (msgEnd > decrypted.length) {
|
||||||
|
throw new IllegalArgumentException("消息长度超出范围");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new String(decrypted, msgStart, msgLen, java.nio.charset.StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加密消息(企微回调响应时需要)
|
||||||
|
*/
|
||||||
|
public String encrypt(String msg, String corpId) throws Exception {
|
||||||
|
byte[] msgBytes = msg.getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
||||||
|
byte[] corpIdBytes = corpId.getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
// random(16B) + msg_len(4B) + msg + corp_id
|
||||||
|
ByteBuffer buffer = ByteBuffer.allocate(16 + 4 + msgBytes.length + corpIdBytes.length);
|
||||||
|
byte[] random = new byte[16];
|
||||||
|
new java.security.SecureRandom().nextBytes(random);
|
||||||
|
buffer.put(random);
|
||||||
|
buffer.putInt(msgBytes.length);
|
||||||
|
buffer.put(msgBytes);
|
||||||
|
buffer.put(corpIdBytes);
|
||||||
|
|
||||||
|
byte[] toEncrypt = buffer.array();
|
||||||
|
|
||||||
|
// PKCS7 填充
|
||||||
|
toEncrypt = addPKCS7Padding(toEncrypt);
|
||||||
|
|
||||||
|
// AES-256-CBC 加密
|
||||||
|
Cipher cipher = Cipher.getInstance("AES/CBC/NoPadding");
|
||||||
|
SecretKeySpec keySpec = new SecretKeySpec(aesKey, "AES");
|
||||||
|
IvParameterSpec iv = new IvParameterSpec(Arrays.copyOfRange(aesKey, 0, 16));
|
||||||
|
cipher.init(Cipher.ENCRYPT_MODE, keySpec, iv);
|
||||||
|
|
||||||
|
return Base64.getEncoder().encodeToString(cipher.doFinal(toEncrypt));
|
||||||
|
}
|
||||||
|
|
||||||
|
private byte[] removePKCS7Padding(byte[] data) {
|
||||||
|
int padLen = data[data.length - 1] & 0xFF;
|
||||||
|
if (padLen < 1 || padLen > 32) {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
return Arrays.copyOfRange(data, 0, data.length - padLen);
|
||||||
|
}
|
||||||
|
|
||||||
|
private byte[] addPKCS7Padding(byte[] data) {
|
||||||
|
int blockSize = 32;
|
||||||
|
int padLen = blockSize - (data.length % blockSize);
|
||||||
|
byte[] padded = Arrays.copyOf(data, data.length + padLen);
|
||||||
|
for (int i = data.length; i < padded.length; i++) {
|
||||||
|
padded[i] = (byte) padLen;
|
||||||
|
}
|
||||||
|
return padded;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,32 @@
|
|||||||
|
package com.artedu.archive.vo;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 消息VO(扩展版)
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class ArchiveMessageVO {
|
||||||
|
|
||||||
|
private Long id;
|
||||||
|
private String msgid;
|
||||||
|
private Long seq;
|
||||||
|
private String corpId;
|
||||||
|
private String action;
|
||||||
|
private String fromUser;
|
||||||
|
private String fromUserName;
|
||||||
|
private String fromRole;
|
||||||
|
private String toUser;
|
||||||
|
private String tolist;
|
||||||
|
private String roomid;
|
||||||
|
private String msgtype;
|
||||||
|
private Long msgtime;
|
||||||
|
private String msgTimeStr;
|
||||||
|
private String content;
|
||||||
|
private String mediaData;
|
||||||
|
private String sessionId;
|
||||||
|
private Integer decryptStatus;
|
||||||
|
private String decryptError;
|
||||||
|
private String createdAt;
|
||||||
|
private String updatedAt;
|
||||||
|
}
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
package com.artedu.archive.vo;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 客户会话统计VO
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class CustomerSessionVO {
|
||||||
|
|
||||||
|
private String customerId;
|
||||||
|
private String name;
|
||||||
|
private String avatar;
|
||||||
|
private String type;
|
||||||
|
private String gender;
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
// 统计
|
||||||
|
private Integer msgCount;
|
||||||
|
private Integer todayMsgCount;
|
||||||
|
private Integer staffCount;
|
||||||
|
private Long lastMsgTime;
|
||||||
|
private String lastMsgTimeStr;
|
||||||
|
private Long firstMsgTime;
|
||||||
|
private String firstMsgTimeStr;
|
||||||
|
|
||||||
|
// 企微添加时间
|
||||||
|
private LocalDateTime addTime;
|
||||||
|
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
}
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
package com.artedu.archive.vo;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 会话VO
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class SessionVO {
|
||||||
|
|
||||||
|
private String sessionId;
|
||||||
|
private String customerId;
|
||||||
|
private String customerName;
|
||||||
|
private String staffId;
|
||||||
|
private String staffName;
|
||||||
|
private String roomId;
|
||||||
|
private Integer msgCount;
|
||||||
|
private Long lastMsgTime;
|
||||||
|
private String lastMsgTimeStr;
|
||||||
|
private String lastMsgContent;
|
||||||
|
private String sessionType; // SINGLE / GROUP
|
||||||
|
}
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
package com.artedu.archive.vo;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 员工会话统计VO
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class StaffSessionVO {
|
||||||
|
|
||||||
|
private String staffId;
|
||||||
|
private String name;
|
||||||
|
private String avatar;
|
||||||
|
private String department;
|
||||||
|
private String role;
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
// 统计
|
||||||
|
private Integer msgCount;
|
||||||
|
private Integer todayMsgCount;
|
||||||
|
private Integer customerCount;
|
||||||
|
private Long lastMsgTime;
|
||||||
|
private String lastMsgTimeStr;
|
||||||
|
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
}
|
||||||
@ -0,0 +1,155 @@
|
|||||||
|
package com.artedu.archive.worker;
|
||||||
|
|
||||||
|
import com.artedu.common.util.JsonUtils;
|
||||||
|
import com.tencent.wework.Finance;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SDK Worker(独立进程)
|
||||||
|
* 支持两种模式:
|
||||||
|
* pull - 拉取一批加密消息元数据(安全,不涉及单条消息解析)
|
||||||
|
* decrypt - 解密单条消息内容(隔离崩溃风险)
|
||||||
|
*
|
||||||
|
* 用法:
|
||||||
|
* java -jar app.jar worker pull <corpId> <secret> <seq> <limit> <sdkPath>
|
||||||
|
* java -jar app.jar worker decrypt <corpId> <secret> <sdkPath> <encryptKey> <encryptMsg>
|
||||||
|
*/
|
||||||
|
public class SdkWorker {
|
||||||
|
|
||||||
|
public static void run(String[] args) throws Exception {
|
||||||
|
if (args.length < 1) {
|
||||||
|
System.err.println("Usage: worker <pull|decrypt|media> ...");
|
||||||
|
System.exit(1);
|
||||||
|
}
|
||||||
|
String cmd = args[0];
|
||||||
|
if ("pull".equals(cmd)) {
|
||||||
|
runPull(args);
|
||||||
|
} else if ("decrypt".equals(cmd)) {
|
||||||
|
runDecrypt(args);
|
||||||
|
} else if ("media".equals(cmd)) {
|
||||||
|
runGetMediaData(args);
|
||||||
|
} else {
|
||||||
|
System.err.println("Unknown command: " + cmd);
|
||||||
|
System.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void runPull(String[] args) throws Exception {
|
||||||
|
if (args.length < 6) {
|
||||||
|
System.err.println("Usage: worker pull <corpId> <secret> <seq> <limit> <sdkPath>");
|
||||||
|
System.exit(1);
|
||||||
|
}
|
||||||
|
String corpId = args[1];
|
||||||
|
String secret = args[2];
|
||||||
|
long seq = Long.parseLong(args[3]);
|
||||||
|
int limit = Integer.parseInt(args[4]);
|
||||||
|
String sdkPath = args[5];
|
||||||
|
|
||||||
|
setupLibraryPath(sdkPath);
|
||||||
|
long sdk = initSdk(corpId, secret);
|
||||||
|
long slice = Finance.NewSlice();
|
||||||
|
try {
|
||||||
|
int ret = Finance.GetChatData(sdk, seq, limit, null, null, 5, slice);
|
||||||
|
if (ret != 0) {
|
||||||
|
System.err.println("GetChatData failed: " + ret);
|
||||||
|
System.exit(1);
|
||||||
|
}
|
||||||
|
String json = Finance.GetContentFromSlice(slice);
|
||||||
|
System.out.println(json != null ? json : "{\"errcode\":0,\"chatdata\":[]}");
|
||||||
|
} finally {
|
||||||
|
Finance.FreeSlice(slice);
|
||||||
|
Finance.DestroySdk(sdk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void runDecrypt(String[] args) throws Exception {
|
||||||
|
if (args.length < 6) {
|
||||||
|
System.err.println("Usage: worker decrypt <corpId> <secret> <sdkPath> <encryptKey> <encryptMsg>");
|
||||||
|
System.exit(1);
|
||||||
|
}
|
||||||
|
String corpId = args[1];
|
||||||
|
String secret = args[2];
|
||||||
|
String sdkPath = args[3];
|
||||||
|
String encryptKey = args[4];
|
||||||
|
String encryptMsg = args[5];
|
||||||
|
|
||||||
|
setupLibraryPath(sdkPath);
|
||||||
|
long sdk = initSdk(corpId, secret);
|
||||||
|
long slice = Finance.NewSlice();
|
||||||
|
try {
|
||||||
|
int ret = Finance.DecryptData(sdk, encryptKey, encryptMsg, slice);
|
||||||
|
if (ret != 0) {
|
||||||
|
System.err.println("DecryptData failed: " + ret);
|
||||||
|
System.exit(1);
|
||||||
|
}
|
||||||
|
String json = Finance.GetContentFromSlice(slice);
|
||||||
|
System.out.println(json != null ? json : "{}");
|
||||||
|
} finally {
|
||||||
|
Finance.FreeSlice(slice);
|
||||||
|
Finance.DestroySdk(sdk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void runGetMediaData(String[] args) throws Exception {
|
||||||
|
if (args.length < 6) {
|
||||||
|
System.err.println("Usage: worker media <corpId> <secret> <sdkPath> <sdkfileid> <outputPath>");
|
||||||
|
System.exit(1);
|
||||||
|
}
|
||||||
|
String corpId = args[1];
|
||||||
|
String secret = args[2];
|
||||||
|
String sdkPath = args[3];
|
||||||
|
String sdkfileid = args[4];
|
||||||
|
String outputPath = args[5];
|
||||||
|
|
||||||
|
setupLibraryPath(sdkPath);
|
||||||
|
long sdk = initSdk(corpId, secret);
|
||||||
|
long mediaData = Finance.NewMediaData();
|
||||||
|
java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream();
|
||||||
|
try {
|
||||||
|
String indexbuf = "";
|
||||||
|
while (true) {
|
||||||
|
int ret = Finance.GetMediaData(sdk, indexbuf, sdkfileid, null, null, 30, mediaData);
|
||||||
|
if (ret != 0) {
|
||||||
|
System.err.println("GetMediaData failed: " + ret);
|
||||||
|
System.exit(1);
|
||||||
|
}
|
||||||
|
byte[] chunk = Finance.GetData(mediaData);
|
||||||
|
if (chunk != null && chunk.length > 0) {
|
||||||
|
baos.write(chunk);
|
||||||
|
}
|
||||||
|
String outIndexBuf = Finance.GetOutIndexBuf(mediaData);
|
||||||
|
if (outIndexBuf == null || outIndexBuf.isEmpty()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
indexbuf = outIndexBuf;
|
||||||
|
}
|
||||||
|
byte[] data = baos.toByteArray();
|
||||||
|
java.nio.file.Files.createDirectories(java.nio.file.Paths.get(outputPath).getParent());
|
||||||
|
java.nio.file.Files.write(java.nio.file.Paths.get(outputPath), data);
|
||||||
|
System.out.println("{\"success\":true,\"path\":\"" + outputPath + "\",\"size\":" + data.length + "}");
|
||||||
|
} finally {
|
||||||
|
Finance.FreeMediaData(mediaData);
|
||||||
|
Finance.DestroySdk(sdk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void setupLibraryPath(String sdkPath) {
|
||||||
|
System.setProperty("java.library.path", sdkPath);
|
||||||
|
try {
|
||||||
|
java.lang.reflect.Field field = ClassLoader.class.getDeclaredField("usr_paths");
|
||||||
|
field.setAccessible(true);
|
||||||
|
field.set(null, null);
|
||||||
|
} catch (Exception e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long initSdk(String corpId, String secret) {
|
||||||
|
long sdk = Finance.NewSdk();
|
||||||
|
int ret = Finance.Init(sdk, corpId, secret);
|
||||||
|
if (ret != 0) {
|
||||||
|
System.err.println("SDK init failed: " + ret);
|
||||||
|
System.exit(1);
|
||||||
|
}
|
||||||
|
return sdk;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -11,7 +11,7 @@ spring:
|
|||||||
name: archive-service
|
name: archive-service
|
||||||
datasource:
|
datasource:
|
||||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||||
url: jdbc:mysql://${MYSQL_HOST:localhost}:${MYSQL_PORT:3306}/${MYSQL_DATABASE:ai_assistant}?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai&useSSL=false
|
url: jdbc:mysql://${MYSQL_HOST:localhost}:${MYSQL_PORT:3306}/${MYSQL_DATABASE:ai_assistant}?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&useSSL=false
|
||||||
username: ${MYSQL_USERNAME:root}
|
username: ${MYSQL_USERNAME:root}
|
||||||
password: ${MYSQL_PASSWORD:root}
|
password: ${MYSQL_PASSWORD:root}
|
||||||
redis:
|
redis:
|
||||||
@ -31,11 +31,18 @@ spring:
|
|||||||
password: ${RABBITMQ_PASSWORD:admin}
|
password: ${RABBITMQ_PASSWORD:admin}
|
||||||
|
|
||||||
wecom:
|
wecom:
|
||||||
|
corp-id: wwd483c2fba24ae30a
|
||||||
|
agent:
|
||||||
|
secret: ${WECOM_AGENT_SECRET:}
|
||||||
|
contact:
|
||||||
|
secret: ${WECOM_CONTACT_SECRET:}
|
||||||
archive:
|
archive:
|
||||||
corp-id: wwd483c2fba24ae30a
|
corp-id: wwd483c2fba24ae30a
|
||||||
secret: ${WECOM_ARCHIVE_SECRET:}
|
secret: ${WECOM_ARCHIVE_SECRET:}
|
||||||
rsa-private-key: ${WECOM_ARCHIVE_RSA_KEY:}
|
rsa-private-key: ${WECOM_ARCHIVE_RSA_KEY:}
|
||||||
|
rsa-private-key-path: /app/keys/private.pem
|
||||||
callback-token: ${WECOM_ARCHIVE_CALLBACK_TOKEN:}
|
callback-token: ${WECOM_ARCHIVE_CALLBACK_TOKEN:}
|
||||||
|
callback-aeskey: ${WECOM_ARCHIVE_CALLBACK_AESKEY:}
|
||||||
sdk-path: ${WECOM_ARCHIVE_SDK_PATH:}
|
sdk-path: ${WECOM_ARCHIVE_SDK_PATH:}
|
||||||
mock-mode: ${WECOM_ARCHIVE_MOCK:false}
|
mock-mode: ${WECOM_ARCHIVE_MOCK:false}
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@ -11,7 +11,7 @@ spring:
|
|||||||
name: archive-service
|
name: archive-service
|
||||||
datasource:
|
datasource:
|
||||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||||
url: jdbc:mysql://${MYSQL_HOST:localhost}:${MYSQL_PORT:3306}/${MYSQL_DATABASE:ai_assistant}?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai&useSSL=false
|
url: jdbc:mysql://${MYSQL_HOST:localhost}:${MYSQL_PORT:3306}/${MYSQL_DATABASE:ai_assistant}?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&useSSL=false
|
||||||
username: ${MYSQL_USERNAME:root}
|
username: ${MYSQL_USERNAME:root}
|
||||||
password: ${MYSQL_PASSWORD:root}
|
password: ${MYSQL_PASSWORD:root}
|
||||||
redis:
|
redis:
|
||||||
@ -31,11 +31,18 @@ spring:
|
|||||||
password: ${RABBITMQ_PASSWORD:admin}
|
password: ${RABBITMQ_PASSWORD:admin}
|
||||||
|
|
||||||
wecom:
|
wecom:
|
||||||
|
corp-id: wwd483c2fba24ae30a
|
||||||
|
agent:
|
||||||
|
secret: ${WECOM_AGENT_SECRET:}
|
||||||
|
contact:
|
||||||
|
secret: ${WECOM_CONTACT_SECRET:}
|
||||||
archive:
|
archive:
|
||||||
corp-id: wwd483c2fba24ae30a
|
corp-id: wwd483c2fba24ae30a
|
||||||
secret: ${WECOM_ARCHIVE_SECRET:}
|
secret: ${WECOM_ARCHIVE_SECRET:}
|
||||||
rsa-private-key: ${WECOM_ARCHIVE_RSA_KEY:}
|
rsa-private-key: ${WECOM_ARCHIVE_RSA_KEY:}
|
||||||
|
rsa-private-key-path: /app/keys/private.pem
|
||||||
callback-token: ${WECOM_ARCHIVE_CALLBACK_TOKEN:}
|
callback-token: ${WECOM_ARCHIVE_CALLBACK_TOKEN:}
|
||||||
|
callback-aeskey: ${WECOM_ARCHIVE_CALLBACK_AESKEY:}
|
||||||
sdk-path: ${WECOM_ARCHIVE_SDK_PATH:}
|
sdk-path: ${WECOM_ARCHIVE_SDK_PATH:}
|
||||||
mock-mode: ${WECOM_ARCHIVE_MOCK:false}
|
mock-mode: ${WECOM_ARCHIVE_MOCK:false}
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user