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:
jiao 2026-05-29 15:46:55 +08:00
parent d327826c78
commit 6160691aa9
232 changed files with 7711 additions and 271 deletions

View File

@ -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:

View File

@ -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:

View File

@ -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>

View File

@ -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();
}
}
};
}
}

View File

@ -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();
}
}
}

View File

@ -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() + "条消息");

View File

@ -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 + " 人,请稍后在客户列表查看结果");
}
}

View File

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

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

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

View File

@ -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; }
}
}

View File

@ -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());
}
}

View File

@ -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;
} }

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

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

View File

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

View File

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

View File

@ -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> {
}

View File

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

View File

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

View File

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

View File

@ -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;
}
}

View File

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

View File

@ -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;
}
}

View File

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

View File

@ -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;
}
}

View File

@ -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();
}
}

View File

@ -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; }
}
}

View File

@ -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;
} }
} }

View File

@ -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;
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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
}

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -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}

View File

@ -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}

Some files were not shown because too many files have changed in this diff Show More