diff --git a/backend/analytics-service/src/main/resources/application.yml b/backend/analytics-service/src/main/resources/application.yml index 1aa49eb..e70966f 100644 --- a/backend/analytics-service/src/main/resources/application.yml +++ b/backend/analytics-service/src/main/resources/application.yml @@ -11,7 +11,7 @@ spring: name: analytics-service datasource: 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} password: ${MYSQL_PASSWORD:root} redis: diff --git a/backend/analytics-service/target/analytics-service-1.0.0-SNAPSHOT.jar b/backend/analytics-service/target/analytics-service-1.0.0-SNAPSHOT.jar index 3f3d8bc..5dc35c7 100644 Binary files a/backend/analytics-service/target/analytics-service-1.0.0-SNAPSHOT.jar and b/backend/analytics-service/target/analytics-service-1.0.0-SNAPSHOT.jar differ diff --git a/backend/analytics-service/target/analytics-service-1.0.0-SNAPSHOT.jar.original b/backend/analytics-service/target/analytics-service-1.0.0-SNAPSHOT.jar.original index 6652a5f..e3647ef 100644 Binary files a/backend/analytics-service/target/analytics-service-1.0.0-SNAPSHOT.jar.original and b/backend/analytics-service/target/analytics-service-1.0.0-SNAPSHOT.jar.original differ diff --git a/backend/analytics-service/target/classes/application.yml b/backend/analytics-service/target/classes/application.yml index 1aa49eb..e70966f 100644 --- a/backend/analytics-service/target/classes/application.yml +++ b/backend/analytics-service/target/classes/application.yml @@ -11,7 +11,7 @@ spring: name: analytics-service datasource: 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} password: ${MYSQL_PASSWORD:root} redis: diff --git a/backend/analytics-service/target/classes/com/artedu/analytics/AnalyticsServiceApplication.class b/backend/analytics-service/target/classes/com/artedu/analytics/AnalyticsServiceApplication.class index e96882f..41eb847 100644 Binary files a/backend/analytics-service/target/classes/com/artedu/analytics/AnalyticsServiceApplication.class and b/backend/analytics-service/target/classes/com/artedu/analytics/AnalyticsServiceApplication.class differ diff --git a/backend/analytics-service/target/classes/com/artedu/analytics/controller/AnalyticsController.class b/backend/analytics-service/target/classes/com/artedu/analytics/controller/AnalyticsController.class index 7cc1332..b899ad9 100644 Binary files a/backend/analytics-service/target/classes/com/artedu/analytics/controller/AnalyticsController.class and b/backend/analytics-service/target/classes/com/artedu/analytics/controller/AnalyticsController.class differ diff --git a/backend/analytics-service/target/classes/com/artedu/analytics/service/AnalyticsService.class b/backend/analytics-service/target/classes/com/artedu/analytics/service/AnalyticsService.class index db5b458..ded7e9c 100644 Binary files a/backend/analytics-service/target/classes/com/artedu/analytics/service/AnalyticsService.class and b/backend/analytics-service/target/classes/com/artedu/analytics/service/AnalyticsService.class differ diff --git a/backend/archive-service/pom.xml b/backend/archive-service/pom.xml index 80f4d21..7202414 100644 --- a/backend/archive-service/pom.xml +++ b/backend/archive-service/pom.xml @@ -16,9 +16,19 @@ org.springframework.bootspring-boot-starter-web net.java.dev.jnajna org.springframework.bootspring-boot-starter-testtest + org.bouncycastlebcpkix-jdk18on1.78 + com.alibabaeasyexcel3.3.4 + + org.apache.maven.plugins + maven-compiler-plugin + + 1.8 + 1.8 + + org.springframework.bootspring-boot-maven-pluginrepackage diff --git a/backend/archive-service/src/main/java/com/artedu/archive/ArchiveServiceApplication.java b/backend/archive-service/src/main/java/com/artedu/archive/ArchiveServiceApplication.java index c96e8a4..2d8dd19 100644 --- a/backend/archive-service/src/main/java/com/artedu/archive/ArchiveServiceApplication.java +++ b/backend/archive-service/src/main/java/com/artedu/archive/ArchiveServiceApplication.java @@ -1,15 +1,59 @@ 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.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"}) +@org.springframework.scheduling.annotation.EnableScheduling public class ArchiveServiceApplication { + @Bean + public RestTemplate restTemplate() { + return new RestTemplate(); + } + public static void main(String[] args) { - SpringApplication.run(ArchiveServiceApplication.class, 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); + } + } + + @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(); + } + } + }; } } diff --git a/backend/archive-service/src/main/java/com/artedu/archive/client/WeComApiClient.java b/backend/archive-service/src/main/java/com/artedu/archive/client/WeComApiClient.java new file mode 100644 index 0000000..e373d8e --- /dev/null +++ b/backend/archive-service/src/main/java/com/artedu/archive/client/WeComApiClient.java @@ -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 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 response = restTemplate.getForEntity(url, String.class); + Map 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 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 response = restTemplate.getForEntity(url, String.class); + Map 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 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 response = restTemplate.getForEntity(url, String.class); + Map 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 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 response = restTemplate.getForEntity(url, String.class); + Map 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) list; + } + return Collections.emptyList(); + } catch (Exception e) { + log.error("获取客户列表异常: userId={}", userId, e); + return Collections.emptyList(); + } + } + + /** + * 获取部门成员列表 + */ + public List> 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 response = restTemplate.getForEntity(url, String.class); + Map 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>) list; + } + return Collections.emptyList(); + } catch (Exception e) { + log.error("获取部门成员异常: deptId={}", departmentId, e); + return Collections.emptyList(); + } + } +} diff --git a/backend/archive-service/src/main/java/com/artedu/archive/controller/ArchiveCallbackController.java b/backend/archive-service/src/main/java/com/artedu/archive/controller/ArchiveCallbackController.java index 611599a..b2d2564 100644 --- a/backend/archive-service/src/main/java/com/artedu/archive/controller/ArchiveCallbackController.java +++ b/backend/archive-service/src/main/java/com/artedu/archive/controller/ArchiveCallbackController.java @@ -1,6 +1,7 @@ package com.artedu.archive.controller; import com.artedu.archive.service.ArchivePullService; +import com.artedu.archive.util.WeComCryptUtil; import com.artedu.common.result.Result; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; @@ -9,9 +10,11 @@ import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import javax.validation.constraints.NotBlank; +import com.artedu.archive.entity.ArchiveMessage; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.util.Arrays; +import java.util.List; import java.util.concurrent.CompletableFuture; /** @@ -30,6 +33,22 @@ public class ArchiveCallbackController { @Value("${wecom.archive.callback-token:}") 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请求) */ @@ -47,8 +66,22 @@ public class ArchiveCallbackController { return "fail"; } - log.info("回调URL验证成功"); - return echostr; + String plaintext = 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, @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("回调消息签名验证失败"); 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回调,触发存档拉取"); CompletableFuture.runAsync(() -> { try { long lastSeq = archivePullService.getLastSeq(); - var messages = archivePullService.pullMessages(lastSeq, 1000); + List messages = archivePullService.pullMessages(lastSeq, 1000); archivePullService.saveAndNotify(messages); log.info("回调触发拉取完成,共{}条消息", messages.size()); } catch (Exception e) { @@ -108,13 +162,35 @@ public class ArchiveCallbackController { for (byte b : digest) { 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) { log.error("签名验证异常: {}", e.getMessage()); return false; } } + /** + * 从 XML 中提取 Encrypt 节点内容(去掉 CDATA 包装) + */ + private String extractEncrypt(String xml) { + int start = xml.indexOf(""); + int end = xml.indexOf(""); + if (start != -1 && end != -1) { + String content = xml.substring(start + 9, end); + // 去掉 CDATA 包装: + if (content.startsWith("")) { + content = content.substring(9, content.length() - 3); + } + return content; + } + return null; + } + @PostMapping("/pull") public Result manualPull( @RequestParam(value = "seq", required = false) Long seq, @@ -123,7 +199,7 @@ public class ArchiveCallbackController { long startSeq = seq != null ? seq : archivePullService.getLastSeq(); log.info("手动触发存档拉取: seq={}, limit={}", startSeq, limit); - var messages = archivePullService.pullMessages(startSeq, limit); + List messages = archivePullService.pullMessages(startSeq, limit); archivePullService.saveAndNotify(messages); return Result.success("拉取完成,共" + messages.size() + "条消息"); diff --git a/backend/archive-service/src/main/java/com/artedu/archive/controller/ArchiveContactController.java b/backend/archive-service/src/main/java/com/artedu/archive/controller/ArchiveContactController.java new file mode 100644 index 0000000..1d870a6 --- /dev/null +++ b/backend/archive-service/src/main/java/com/artedu/archive/controller/ArchiveContactController.java @@ -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> 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 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> 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> 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 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> 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> 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 syncStaffs() { + int count = archiveContactService.syncAllStaffs(); + return Result.success("同步完成,共 " + count + " 人"); + } + + @PostMapping("/sync/customers") + public Result syncCustomers() { + int count = archiveContactService.syncAllCustomers(); + return Result.success("同步完成,共 " + count + " 人"); + } + + @PostMapping("/sync/scan-contacts") + public Result scanContacts() { + int count = archiveContactService.scanHistoricalContacts(); + return Result.success("扫描完成,共处理 " + count + " 个联系人"); + } + + @PostMapping("/sync/customer-details") + public Result fillCustomerDetails() { + int count = archiveContactService.fillAllCustomerDetails(); + return Result.success("已开始批量补充客户资料,共 " + count + " 人,请稍后在客户列表查看结果"); + } +} diff --git a/backend/archive-service/src/main/java/com/artedu/archive/controller/ArchiveExportController.java b/backend/archive-service/src/main/java/com/artedu/archive/controller/ArchiveExportController.java new file mode 100644 index 0000000..e5ff1df --- /dev/null +++ b/backend/archive-service/src/main/java/com/artedu/archive/controller/ArchiveExportController.java @@ -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 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 list = riskEventMapper.selectList(wrapper); + List 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 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 list = sensitiveWordHitMapper.selectList(wrapper); + List 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); + } +} diff --git a/backend/archive-service/src/main/java/com/artedu/archive/controller/ArchiveMessageController.java b/backend/archive-service/src/main/java/com/artedu/archive/controller/ArchiveMessageController.java new file mode 100644 index 0000000..504ff3f --- /dev/null +++ b/backend/archive-service/src/main/java/com/artedu/archive/controller/ArchiveMessageController.java @@ -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 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 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> 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 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 pageParam = new Page<>(page, size); + Page result = archiveMessageMapper.selectPage(pageParam, wrapper); + + List 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 list) { + if (list == null || list.isEmpty()) return; + + Set userIds = list.stream() + .map(ArchiveMessageVO::getFromUser) + .filter(id -> id != null && !id.isEmpty()) + .collect(Collectors.toSet()); + if (userIds.isEmpty()) return; + + List staffs = staffMapper.selectList( + new LambdaQueryWrapper().in(Staff::getStaffId, userIds)); + Map staffNameMap = staffs.stream() + .collect(Collectors.toMap(Staff::getStaffId, s -> s.getName() != null ? s.getName() : s.getStaffId(), (a, b) -> a)); + + List customers = customerMapper.selectList( + new LambdaQueryWrapper().in(Customer::getCustomerId, userIds)); + Map customerNameMap = customers.stream() + .collect(Collectors.toMap(Customer::getCustomerId, c -> c.getName() != null ? c.getName() : c.getCustomerId(), (a, b) -> a)); + + Map 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 getMessage(@PathVariable Long id) { + ArchiveMessage message = archiveMessageMapper.selectById(id); + if (message == null) { + return Result.fail("消息不存在"); + } + return Result.success(toVO(message)); + } + + /** + * 统计概览 + */ + @GetMapping("/stats") + public Result getStats() { + LocalDateTime todayStart = LocalDateTime.now().toLocalDate().atStartOfDay(); + + // 消息统计 + Long total = archiveMessageMapper.selectCount(new LambdaQueryWrapper<>()); + Long todayCount = archiveMessageMapper.selectCount( + new LambdaQueryWrapper() + .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 msgTypeDistribution; + } +} diff --git a/backend/archive-service/src/main/java/com/artedu/archive/controller/DashboardController.java b/backend/archive-service/src/main/java/com/artedu/archive/controller/DashboardController.java new file mode 100644 index 0000000..8d68b99 --- /dev/null +++ b/backend/archive-service/src/main/java/com/artedu/archive/controller/DashboardController.java @@ -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 getTrend(@RequestParam(defaultValue = "7") int days) { + if (days < 1 || days > 90) days = 7; + + List dateList = new ArrayList<>(); + List msgList = new ArrayList<>(); + List riskList = new ArrayList<>(); + List 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>> getMsgTypeDistribution() { + // 使用 MyBatis Plus 自定义 SQL 查询 + List> list = archiveMessageMapper.selectMsgTypeDistribution(); + return Result.success(list); + } + + @Data + public static class TrendVO { + private List dates; + private List messageCounts; + private List riskCounts; + private List sensitiveHitCounts; + } +} diff --git a/backend/archive-service/src/main/java/com/artedu/archive/controller/RiskController.java b/backend/archive-service/src/main/java/com/artedu/archive/controller/RiskController.java new file mode 100644 index 0000000..4f4ecca --- /dev/null +++ b/backend/archive-service/src/main/java/com/artedu/archive/controller/RiskController.java @@ -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> listSensitiveWords( + @RequestParam(required = false) String keyword, + @RequestParam(required = false) Integer status) { + return Result.success(archiveRiskService.listSensitiveWords(keyword, status)); + } + + @PostMapping("/sensitive-words") + public Result addSensitiveWord(@RequestBody SensitiveWord word) { + // 检查是否已存在 + LambdaQueryWrapper 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 updateSensitiveWord(@PathVariable Long id, @RequestBody SensitiveWord word) { + word.setId(id); + sensitiveWordMapper.updateById(word); + return Result.success("更新成功"); + } + + @DeleteMapping("/sensitive-words/{id}") + public Result deleteSensitiveWord(@PathVariable Long id) { + sensitiveWordMapper.deleteById(id); + return Result.success("删除成功"); + } + + @PostMapping("/sensitive-words/batch") + public Result 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 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> testSensitiveWords(@RequestBody String content) { + if (content == null || content.isEmpty()) { + return Result.success(java.util.Collections.emptyList()); + } + List 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 scanHistoryMessages() { + int count = archiveRiskService.scanHistoryMessages(); + return Result.success("扫描完成,新增 " + count + " 条命中记录"); + } + + // ========== 敏感词命中记录 ========== + + @GetMapping("/sensitive-word-hits") + public Result> 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 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)); + } +} diff --git a/backend/archive-service/src/main/java/com/artedu/archive/controller/RiskEventController.java b/backend/archive-service/src/main/java/com/artedu/archive/controller/RiskEventController.java new file mode 100644 index 0000000..86d9804 --- /dev/null +++ b/backend/archive-service/src/main/java/com/artedu/archive/controller/RiskEventController.java @@ -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> listRiskConfigs() { + return Result.success(riskConfigMapper.selectList(null)); + } + + @PutMapping("/risk-configs/{id}") + public Result updateRiskConfig(@PathVariable Long id, @RequestBody RiskConfig config) { + config.setId(id); + riskConfigMapper.updateById(config); + return Result.success("更新成功"); + } + + // ========== 风险事件记录 ========== + + @GetMapping("/risk-events") + public Result> 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> getRiskStats() { + return Result.success(archiveRiskService.getRiskStats()); + } + + @PutMapping("/risk-events/{id}/handle") + public Result 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; } + } +} diff --git a/backend/archive-service/src/main/java/com/artedu/archive/controller/RiskEventNotifyController.java b/backend/archive-service/src/main/java/com/artedu/archive/controller/RiskEventNotifyController.java new file mode 100644 index 0000000..f920d69 --- /dev/null +++ b/backend/archive-service/src/main/java/com/artedu/archive/controller/RiskEventNotifyController.java @@ -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 getStreamStats() { + return Result.success(notifyService.getConnectionCount()); + } +} diff --git a/backend/archive-service/src/main/java/com/artedu/archive/entity/ArchiveMessage.java b/backend/archive-service/src/main/java/com/artedu/archive/entity/ArchiveMessage.java index 7076ea2..409947e 100644 --- a/backend/archive-service/src/main/java/com/artedu/archive/entity/ArchiveMessage.java +++ b/backend/archive-service/src/main/java/com/artedu/archive/entity/ArchiveMessage.java @@ -35,8 +35,6 @@ public class ArchiveMessage { private String sessionId; private Integer decryptStatus; private String decryptError; - @TableLogic - private Integer deleted; private LocalDateTime createdAt; private LocalDateTime updatedAt; } diff --git a/backend/archive-service/src/main/java/com/artedu/archive/entity/Customer.java b/backend/archive-service/src/main/java/com/artedu/archive/entity/Customer.java new file mode 100644 index 0000000..6ce3334 --- /dev/null +++ b/backend/archive-service/src/main/java/com/artedu/archive/entity/Customer.java @@ -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; +} diff --git a/backend/archive-service/src/main/java/com/artedu/archive/entity/RiskConfig.java b/backend/archive-service/src/main/java/com/artedu/archive/entity/RiskConfig.java new file mode 100644 index 0000000..80b25fe --- /dev/null +++ b/backend/archive-service/src/main/java/com/artedu/archive/entity/RiskConfig.java @@ -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; +} diff --git a/backend/archive-service/src/main/java/com/artedu/archive/entity/RiskEvent.java b/backend/archive-service/src/main/java/com/artedu/archive/entity/RiskEvent.java new file mode 100644 index 0000000..067865e --- /dev/null +++ b/backend/archive-service/src/main/java/com/artedu/archive/entity/RiskEvent.java @@ -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; +} diff --git a/backend/archive-service/src/main/java/com/artedu/archive/entity/SensitiveWord.java b/backend/archive-service/src/main/java/com/artedu/archive/entity/SensitiveWord.java new file mode 100644 index 0000000..3522093 --- /dev/null +++ b/backend/archive-service/src/main/java/com/artedu/archive/entity/SensitiveWord.java @@ -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; +} diff --git a/backend/archive-service/src/main/java/com/artedu/archive/entity/SensitiveWordHit.java b/backend/archive-service/src/main/java/com/artedu/archive/entity/SensitiveWordHit.java new file mode 100644 index 0000000..2a3a438 --- /dev/null +++ b/backend/archive-service/src/main/java/com/artedu/archive/entity/SensitiveWordHit.java @@ -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; +} diff --git a/backend/archive-service/src/main/java/com/artedu/archive/entity/Staff.java b/backend/archive-service/src/main/java/com/artedu/archive/entity/Staff.java new file mode 100644 index 0000000..62501d3 --- /dev/null +++ b/backend/archive-service/src/main/java/com/artedu/archive/entity/Staff.java @@ -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; +} diff --git a/backend/archive-service/src/main/java/com/artedu/archive/excel/RiskEventExcel.java b/backend/archive-service/src/main/java/com/artedu/archive/excel/RiskEventExcel.java new file mode 100644 index 0000000..c1c9872 --- /dev/null +++ b/backend/archive-service/src/main/java/com/artedu/archive/excel/RiskEventExcel.java @@ -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; +} diff --git a/backend/archive-service/src/main/java/com/artedu/archive/excel/SensitiveWordHitExcel.java b/backend/archive-service/src/main/java/com/artedu/archive/excel/SensitiveWordHitExcel.java new file mode 100644 index 0000000..c5ceb21 --- /dev/null +++ b/backend/archive-service/src/main/java/com/artedu/archive/excel/SensitiveWordHitExcel.java @@ -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; +} diff --git a/backend/archive-service/src/main/java/com/artedu/archive/mapper/ArchiveMessageMapper.java b/backend/archive-service/src/main/java/com/artedu/archive/mapper/ArchiveMessageMapper.java index 9562dc4..058b39e 100644 --- a/backend/archive-service/src/main/java/com/artedu/archive/mapper/ArchiveMessageMapper.java +++ b/backend/archive-service/src/main/java/com/artedu/archive/mapper/ArchiveMessageMapper.java @@ -5,6 +5,10 @@ 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; +import java.util.Map; /** * 存档消息数据访问层 @@ -15,6 +19,77 @@ public interface ArchiveMessageMapper extends BaseMapper { @Select("SELECT MAX(seq) FROM archive_messages WHERE corp_id = #{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}") 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 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 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 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 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> 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> selectMsgTypeDistribution(); + + @Select("SELECT DISTINCT from_user FROM archive_messages WHERE corp_id = #{corpId} AND from_role = 'INTERNAL'") + List selectStaffIdsWithArchive(@Param("corpId") String corpId); } diff --git a/backend/archive-service/src/main/java/com/artedu/archive/mapper/CustomerMapper.java b/backend/archive-service/src/main/java/com/artedu/archive/mapper/CustomerMapper.java new file mode 100644 index 0000000..d6be636 --- /dev/null +++ b/backend/archive-service/src/main/java/com/artedu/archive/mapper/CustomerMapper.java @@ -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 { + + @Select("SELECT * FROM customers WHERE customer_id = #{customerId} AND corp_id = #{corpId} LIMIT 1") + Customer selectByCustomerId(@Param("customerId") String customerId, @Param("corpId") String corpId); +} diff --git a/backend/archive-service/src/main/java/com/artedu/archive/mapper/RiskConfigMapper.java b/backend/archive-service/src/main/java/com/artedu/archive/mapper/RiskConfigMapper.java new file mode 100644 index 0000000..9ba6b4b --- /dev/null +++ b/backend/archive-service/src/main/java/com/artedu/archive/mapper/RiskConfigMapper.java @@ -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 { + + @Select("SELECT * FROM archive_risk_configs WHERE enabled = 1 AND corp_id = #{corpId}") + List selectEnabledByCorp(@Param("corpId") String corpId); +} diff --git a/backend/archive-service/src/main/java/com/artedu/archive/mapper/RiskEventMapper.java b/backend/archive-service/src/main/java/com/artedu/archive/mapper/RiskEventMapper.java new file mode 100644 index 0000000..b618518 --- /dev/null +++ b/backend/archive-service/src/main/java/com/artedu/archive/mapper/RiskEventMapper.java @@ -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 { +} diff --git a/backend/archive-service/src/main/java/com/artedu/archive/mapper/SensitiveWordHitMapper.java b/backend/archive-service/src/main/java/com/artedu/archive/mapper/SensitiveWordHitMapper.java new file mode 100644 index 0000000..121318d --- /dev/null +++ b/backend/archive-service/src/main/java/com/artedu/archive/mapper/SensitiveWordHitMapper.java @@ -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 { + + /** + * 检查某条消息的敏感词命中是否已存在 + */ + @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); +} diff --git a/backend/archive-service/src/main/java/com/artedu/archive/mapper/SensitiveWordMapper.java b/backend/archive-service/src/main/java/com/artedu/archive/mapper/SensitiveWordMapper.java new file mode 100644 index 0000000..1bb7590 --- /dev/null +++ b/backend/archive-service/src/main/java/com/artedu/archive/mapper/SensitiveWordMapper.java @@ -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 { + + @Select("SELECT * FROM archive_sensitive_words WHERE status = 1 AND corp_id = #{corpId}") + List 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); +} diff --git a/backend/archive-service/src/main/java/com/artedu/archive/mapper/StaffMapper.java b/backend/archive-service/src/main/java/com/artedu/archive/mapper/StaffMapper.java new file mode 100644 index 0000000..6defc1f --- /dev/null +++ b/backend/archive-service/src/main/java/com/artedu/archive/mapper/StaffMapper.java @@ -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 { + + @Select("SELECT * FROM staffs WHERE staff_id = #{staffId} AND corp_id = #{corpId} LIMIT 1") + Staff selectByStaffId(@Param("staffId") String staffId, @Param("corpId") String corpId); +} diff --git a/backend/archive-service/src/main/java/com/artedu/archive/service/ArchiveContactService.java b/backend/archive-service/src/main/java/com/artedu/archive/service/ArchiveContactService.java new file mode 100644 index 0000000..e15cc72 --- /dev/null +++ b/backend/archive-service/src/main/java/com/artedu/archive/service/ArchiveContactService.java @@ -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 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 idSet = new HashSet<>(); + if (staffIds != null && !staffIds.isEmpty()) { + List 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 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 detail = weComApiClient.getExternalContact(customerId); + if (detail == null || detail.isEmpty()) return; + + Map contact = (Map) 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> follows = (List>) 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 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 fromUsers = archiveMessageMapper.selectDistinctFromUsers(corpId); + + // 查询所有唯一的接收者(可能包含 JSON 数组) + List toUsers = archiveMessageMapper.selectDistinctToUsers(corpId); + + Set 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 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 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> users = weComApiClient.getDepartmentUserList(1L); + for (Map 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 staffs = staffMapper.selectList( + new LambdaQueryWrapper().eq(Staff::getCorpId, corpId) + ); + Set processed = new HashSet<>(); + + for (Staff staff : staffs) { + List 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 customers = customerMapper.selectList( + new LambdaQueryWrapper().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; + } +} diff --git a/backend/archive-service/src/main/java/com/artedu/archive/service/ArchivePullService.java b/backend/archive-service/src/main/java/com/artedu/archive/service/ArchivePullService.java index 26b8dca..a86b8a2 100644 --- a/backend/archive-service/src/main/java/com/artedu/archive/service/ArchivePullService.java +++ b/backend/archive-service/src/main/java/com/artedu/archive/service/ArchivePullService.java @@ -5,7 +5,6 @@ import com.artedu.archive.mapper.ArchiveMessageMapper; import com.artedu.archive.util.RsaDecryptUtil; import com.artedu.common.config.RabbitConfig; import com.artedu.common.util.JsonUtils; -import com.tencent.wework.Finance; import lombok.extern.slf4j.Slf4j; import org.springframework.amqp.rabbit.core.RabbitTemplate; import org.springframework.beans.factory.annotation.Autowired; @@ -16,6 +15,7 @@ import org.springframework.stereotype.Service; import javax.annotation.PostConstruct; import javax.annotation.PreDestroy; import java.io.File; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -38,6 +38,9 @@ public class ArchivePullService { @Value("${wecom.archive.rsa-private-key:}") private String rsaPrivateKey; + @Value("${wecom.archive.rsa-private-key-path:}") + private String rsaPrivateKeyPath; + @Value("${wecom.archive.sdk-path:}") private String sdkPath; @@ -56,141 +59,106 @@ public class ArchivePullService { @Autowired private RsaDecryptUtil rsaDecryptUtil; - private long sdk; + @Autowired + private ArchiveContactService archiveContactService; + + @Autowired + private ArchiveRiskService archiveRiskService; + private static final String LAST_SEQ_KEY = "archive:last_seq:"; @PostConstruct public void init() { - if (mockMode) { - log.info("企微存档Mock模式已启用,跳过SDK初始化"); - sdk = 0; - return; - } - if (secret == null || secret.isEmpty()) { - log.warn("企微存档Secret未配置,跳过SDK初始化"); - return; - } - try { - // 设置 java.library.path,确保 JNI 能找到 DLL - setupLibraryPath(); - - sdk = Finance.NewSdk(); - int ret = Finance.Init(sdk, corpId, secret); - if (ret != 0) { - log.error("企微存档SDK初始化失败, ret={}", ret); - sdk = 0; + // 从文件加载RSA私钥(当环境变量未设置时) + if ((rsaPrivateKey == null || rsaPrivateKey.isEmpty()) && rsaPrivateKeyPath != null && !rsaPrivateKeyPath.isEmpty()) { + File keyFile = new File(rsaPrivateKeyPath); + if (keyFile.exists() && keyFile.isFile()) { + try { + rsaPrivateKey = new String(java.nio.file.Files.readAllBytes(keyFile.toPath()), java.nio.charset.StandardCharsets.UTF_8); + log.info("已从文件加载RSA私钥: {}", rsaPrivateKeyPath); + } catch (Exception e) { + log.error("读取RSA私钥文件失败: {}", rsaPrivateKeyPath, e); + } } else { - log.info("企微存档SDK初始化成功"); + log.warn("RSA私钥文件不存在: {}", rsaPrivateKeyPath); } - } 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) { - log.warn("刷新java.library.path缓存失败: {}", e.getMessage()); - } - log.info("已将SDK路径添加到java.library.path: {}", sdkPath); - } - } - - @PreDestroy - public void destroy() { - if (sdk != 0) { - Finance.DestroySdk(sdk); - log.info("企微存档SDK已释放"); } } public boolean isMockMode() { - return mockMode || sdk == 0; + return mockMode; } public List pullMessages(long seq, int limit) { List messages = new ArrayList<>(); - // Mock 模式:返回模拟数据 - if (mockMode || sdk == 0) { - if (mockMode) { - log.info("Mock模式:生成模拟存档消息, seq={}, limit={}", seq, limit); - return MockArchiveDataProvider.generate(seq, limit, corpId); - } - log.warn("企微存档SDK未初始化,跳过拉取"); + if (mockMode) { + log.info("Mock模式:生成模拟存档消息, seq={}, limit={}", seq, limit); + return MockArchiveDataProvider.generate(seq, limit, corpId); + } + + if (corpId == null || secret == null || rsaPrivateKey == null || sdkPath == null) { + log.warn("企微存档配置不完整,跳过拉取"); 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 { - chatDatas = Finance.NewSlice(); - if (chatDatas == 0) { - log.error("NewSlice返回0,内存不足"); - return messages; - } - - 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; + for (int loop = 0; loop < maxLoops; loop++) { + String jsonData = callWorker("pull", corpId, secret, String.valueOf(currentSeq), String.valueOf(safeLimit), sdkPath); + if (jsonData == null) { + log.warn("Worker拉取返回null, seq={}", currentSeq); + break; } Map result = JsonUtils.fromJsonMap(jsonData); if (result == null || !Integer.valueOf(0).equals(result.get("errcode"))) { log.error("拉取存档返回错误: {}", jsonData); - return messages; + break; } List> chatDataList = (List>) result.get("chatdata"); if (chatDataList == null || chatDataList.isEmpty()) { - return messages; + log.info("已无更多消息, seq={}", currentSeq); + break; } + long maxSeq = currentSeq; for (Map chatData : chatDataList) { try { + Long msgSeq = ((Number) chatData.get("seq")).longValue(); + if (msgSeq > maxSeq) maxSeq = msgSeq; + totalFetched++; + ArchiveMessage message = parseAndDecrypt(chatData); if (message != null) { messages.add(message); + totalParsed++; } } catch (Exception 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 { - if (chatDatas != 0) { - Finance.FreeSlice(chatDatas); + // 如果返回的消息数小于 limit,说明已经拉取完了 + if (chatDataList.size() < safeLimit) { + log.info("返回消息数({})小于limit({}), 已拉取完毕", chatDataList.size(), safeLimit); + break; } } + log.info("拉取存档消息完成: 起始seq={}, 共获取{}条, 成功解析{}条, 最终seq={}", seq, totalFetched, totalParsed, currentSeq); return messages; } @@ -199,15 +167,22 @@ public class ArchivePullService { String encryptChatMsg = (String) chatData.get("encrypt_chat_msg"); 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); if (encryptKey == null) { - log.error("解密encrypt_random_key失败, seq={}", msgSeq); + log.debug("解密encrypt_random_key失败, seq={}, 可能是公钥版本不匹配", msgSeq); return null; } - String decryptedMsg = decryptMessage(encryptKey, encryptChatMsg); + // Worker 2: 解密单条消息内容(隔离崩溃风险) + String decryptedMsg = callWorker("decrypt", corpId, secret, sdkPath, encryptKey, encryptChatMsg); if (decryptedMsg == null) { - log.error("解密消息内容失败, seq={}", msgSeq); + log.error("解密消息内容失败(worker崩溃或超时), seq={}", msgSeq); return null; } @@ -223,7 +198,15 @@ public class ArchivePullService { message.setAction((String) msgMap.getOrDefault("action", "send")); message.setFromUser((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.setMsgtype((String) msgMap.get("msgtype")); Object msgTimeObj = msgMap.get("msgtime"); @@ -231,43 +214,104 @@ public class ArchivePullService { message.setMsgtime(((Number) msgTimeObj).longValue()); } - Object content = msgMap.get("content"); - if (content != null) { - message.setContent(content.toString()); + // 企微消息结构: { msgtype: "text", text: { content: "..." } } + // 根据 msgtype 从对应子对象提取内容 + String msgtype = message.getMsgtype(); + Object typeContent = msgMap.get(msgtype); + if (typeContent instanceof Map) { + Map typeMap = (Map) 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.setMediaData(JsonUtils.toJson(msgMap.get("mediaData"))); message.setDecryptStatus(1); message.setSessionId(generateSessionId(message)); return message; } - private String decryptMessage(String encryptKey, String encryptMsg) { - long msgSlice = 0; + + private String callWorker(String... workerArgs) { + List 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 { - msgSlice = Finance.NewSlice(); - if (msgSlice == 0) { - log.error("NewSlice返回0,内存不足"); + ProcessBuilder pb = new ProcessBuilder(cmd); + pb.redirectErrorStream(true); + 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; } - // 官方JNI: DecryptData(long sdk, String encrypt_key, String encrypt_msg, long msg) - int ret = Finance.DecryptData(sdk, encryptKey, encryptMsg, msgSlice); - if (ret != 0) { - log.error("DecryptData失败, ret={}", ret); + + int exitCode = process.exitValue(); + if (exitCode != 0) { + log.error("Worker 异常退出(code={}): {}, output: {}", exitCode, java.util.Arrays.toString(workerArgs), output); return null; } - return Finance.GetContentFromSlice(msgSlice); - } finally { - if (msgSlice != 0) { - Finance.FreeSlice(msgSlice); - } + + return output; + } catch (Exception e) { + log.error("Worker 执行失败: {}", java.util.Arrays.toString(workerArgs), e); + return null; } } private String detectRole(String fromUser) { - if (fromUser != null && fromUser.startsWith("wm")) { + if (fromUser != null && (fromUser.startsWith("wm") || fromUser.startsWith("wo"))) { return "EXTERNAL"; } return "INTERNAL"; @@ -277,7 +321,34 @@ public class ArchivePullService { if (message.getRoomid() != null && !message.getRoomid().isEmpty()) { 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 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 messages) { @@ -285,12 +356,27 @@ public class ArchivePullService { try { int count = archiveMessageMapper.countByMsgId(message.getMsgid(), message.getCorpId()); 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; } archiveMessageMapper.insert(message); + // 提取并维护联系人信息 + archiveContactService.processMessage(message); + + // 风控检测(敏感词扫描等) + archiveRiskService.processMessageRisk(message); + rabbitTemplate.convertAndSend( RabbitConfig.ARCHIVE_EXCHANGE, RabbitConfig.ROUTING_KEY_NEW_MESSAGE, @@ -317,4 +403,114 @@ public class ArchivePullService { Long maxSeq = archiveMessageMapper.selectMaxSeq(corpId); 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 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 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); + } } diff --git a/backend/archive-service/src/main/java/com/artedu/archive/service/ArchiveRiskService.java b/backend/archive-service/src/main/java/com/artedu/archive/service/ArchiveRiskService.java new file mode 100644 index 0000000..aee2c9d --- /dev/null +++ b/backend/archive-service/src/main/java/com/artedu/archive/service/ArchiveRiskService.java @@ -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 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 listSensitiveWords(String keyword, Integer status) { + LambdaQueryWrapper 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 listRiskEvents(String eventType, Integer status, String userId, int page, int size) { + LambdaQueryWrapper 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 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 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 list) { + if (list == null || list.isEmpty()) return; + + Set userIds = list.stream() + .map(RiskEvent::getUserId) + .filter(id -> id != null && !id.isEmpty()) + .collect(Collectors.toSet()); + Set relatedIds = list.stream() + .map(RiskEvent::getRelatedUser) + .filter(id -> id != null && !id.isEmpty()) + .collect(Collectors.toSet()); + userIds.addAll(relatedIds); + + if (userIds.isEmpty()) return; + + // 查询员工名称 + List staffs = staffMapper.selectList( + new LambdaQueryWrapper() + .eq(Staff::getCorpId, corpId) + .in(Staff::getStaffId, userIds) + ); + Map staffNameMap = staffs.stream() + .collect(Collectors.toMap(Staff::getStaffId, s -> s.getName() != null ? s.getName() : s.getStaffId(), (a, b) -> a)); + + // 查询客户名称 + List customers = customerMapper.selectList( + new LambdaQueryWrapper() + .eq(Customer::getCorpId, corpId) + .in(Customer::getCustomerId, userIds) + ); + Map customerNameMap = customers.stream() + .collect(Collectors.toMap(Customer::getCustomerId, c -> c.getName() != null ? c.getName() : c.getCustomerId(), (a, b) -> a)); + + // 合并名称映射 + Map 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 testScan(String content) { + return sensitiveWordScanner.scan(content, "全部", "全部"); + } + + /** + * 风险事件统计 + */ + public java.util.Map getRiskStats() { + java.util.Map stats = new java.util.HashMap<>(); + stats.put("total", riskEventMapper.selectCount(null)); + stats.put("pending", riskEventMapper.selectCount( + new LambdaQueryWrapper().eq(RiskEvent::getStatus, 0))); + stats.put("handled", riskEventMapper.selectCount( + new LambdaQueryWrapper().eq(RiskEvent::getStatus, 1))); + stats.put("ignored", riskEventMapper.selectCount( + new LambdaQueryWrapper().eq(RiskEvent::getStatus, 2))); + return stats; + } + + /** + * 批量扫描历史消息中的敏感词 + * @return 新增命中记录数 + */ + public int scanHistoryMessages() { + int totalHits = 0; + int offset = 0; + int batchSize = 500; + + while (true) { + LambdaQueryWrapper 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 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 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; + } +} diff --git a/backend/archive-service/src/main/java/com/artedu/archive/service/ArchiveSessionService.java b/backend/archive-service/src/main/java/com/artedu/archive/service/ArchiveSessionService.java new file mode 100644 index 0000000..ca33587 --- /dev/null +++ b/backend/archive-service/src/main/java/com/artedu/archive/service/ArchiveSessionService.java @@ -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 listStaffs(String keyword, int page, int size, boolean hasArchive) { + LambdaQueryWrapper 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 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 pageResult = staffMapper.selectPage(new Page<>(page, size), wrapper); + List list = pageResult.getRecords().stream().map(this::convertStaffVO).collect(Collectors.toList()); + return PageResult.of(page, size, pageResult.getTotal(), list); + } + + /** + * 客户列表 + */ + public PageResult listCustomers(String keyword, String staffId, int page, int size) { + LambdaQueryWrapper 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 pageResult = customerMapper.selectPage(new Page<>(page, size), wrapper); + List list = pageResult.getRecords().stream().map(this::convertCustomerVO).collect(Collectors.toList()); + return PageResult.of(page, size, pageResult.getTotal(), list); + } + + /** + * 员工的会话列表 + */ + public PageResult listStaffSessions(String staffId, int page, int size) { + int offset = (page - 1) * size; + List> rows = archiveMessageMapper.selectSessionsByUser(staffId, corpId, offset, size); + Long total = archiveMessageMapper.countSessionsByUser(staffId, corpId); + + List list = new ArrayList<>(); + for (Map 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 listCustomerSessions(String customerId, int page, int size) { + int offset = (page - 1) * size; + List> rows = archiveMessageMapper.selectSessionsByUser(customerId, corpId, offset, size); + Long total = archiveMessageMapper.countSessionsByUser(customerId, corpId); + + List list = new ArrayList<>(); + for (Map 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 listSessionMessages(String sessionId, int page, int size) { + int offset = (page - 1) * size; + List 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 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 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() + .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 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 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 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); + } + } +} diff --git a/backend/archive-service/src/main/java/com/artedu/archive/service/RiskEventDetector.java b/backend/archive-service/src/main/java/com/artedu/archive/service/RiskEventDetector.java new file mode 100644 index 0000000..cb304f6 --- /dev/null +++ b/backend/archive-service/src/main/java/com/artedu/archive/service/RiskEventDetector.java @@ -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 configCache; + private long configCacheTime = 0; + private static final long CONFIG_CACHE_TTL = 60 * 1000; // 1分钟缓存 + + /** + * 获取启用的风险配置 + */ + private List 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 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 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 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 ids = JsonUtils.fromJsonList(fieldValue, String.class); + if (ids != null && !ids.isEmpty()) return ids.get(0); + } catch (Exception e) { + // ignore + } + } + return fieldValue; + } +} diff --git a/backend/archive-service/src/main/java/com/artedu/archive/service/RiskEventNotifyService.java b/backend/archive-service/src/main/java/com/artedu/archive/service/RiskEventNotifyService.java new file mode 100644 index 0000000..75cd26d --- /dev/null +++ b/backend/archive-service/src/main/java/com/artedu/archive/service/RiskEventNotifyService.java @@ -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 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 wrapper = new LambdaQueryWrapper<>(); + wrapper.ge(RiskEvent::getCreatedAt, fromTime) + .lt(RiskEvent::getCreatedAt, now) + .orderByDesc(RiskEvent::getCreatedAt); + + List events = riskEventMapper.selectList(wrapper); + if (events == null || events.isEmpty()) { + log.debug("过去30分钟无新增风险事件,跳过推送"); + lastSummaryTime = now; + return; + } + + // 按类型统计 + Map 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 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(); + } +} diff --git a/backend/archive-service/src/main/java/com/artedu/archive/service/SensitiveWordScanner.java b/backend/archive-service/src/main/java/com/artedu/archive/service/SensitiveWordScanner.java new file mode 100644 index 0000000..3d80dd0 --- /dev/null +++ b/backend/archive-service/src/main/java/com/artedu/archive/service/SensitiveWordScanner.java @@ -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 wordCache = new CopyOnWriteArrayList<>(); + + /** + * 初始化时加载敏感词 + */ + @PostConstruct + public void init() { + refreshWordCache(); + } + + /** + * 每5分钟刷新一次敏感词缓存 + */ + @Scheduled(fixedRate = 5 * 60 * 1000) + public void refreshWordCache() { + try { + List 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 scan(String content, String targetType, String chatType) { + List hits = new ArrayList<>(); + if (content == null || content.isEmpty()) { + return hits; + } + + List 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 scanAndSave(String content, String senderId, String senderRole, + String receiverId, String sessionId, Long msgId, + String targetType, String chatType) { + List 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; } + } +} diff --git a/backend/archive-service/src/main/java/com/artedu/archive/util/RsaDecryptUtil.java b/backend/archive-service/src/main/java/com/artedu/archive/util/RsaDecryptUtil.java index 247ffc8..5f3e21f 100644 --- a/backend/archive-service/src/main/java/com/artedu/archive/util/RsaDecryptUtil.java +++ b/backend/archive-service/src/main/java/com/artedu/archive/util/RsaDecryptUtil.java @@ -1,17 +1,24 @@ package com.artedu.archive.util; 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 javax.crypto.Cipher; +import java.io.StringReader; import java.nio.charset.StandardCharsets; import java.security.*; -import java.security.spec.PKCS8EncodedKeySpec; +import java.security.spec.*; import java.util.Base64; /** * RSA解密工具类 * 用于解密企微会话存档的encrypt_random_key + * 支持PEM格式(PKCS#1和PKCS#8)私钥 */ @Slf4j @Component @@ -20,23 +27,101 @@ public class RsaDecryptUtil { private static final String ALGORITHM = "RSA"; private static final String TRANSFORMATION = "RSA/ECB/PKCS1Padding"; - public PrivateKey loadPrivateKey(String base64PrivateKey) throws Exception { - byte[] keyBytes = Base64.getDecoder().decode(base64PrivateKey); - PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(keyBytes); - KeyFactory keyFactory = KeyFactory.getInstance(ALGORITHM); - return keyFactory.generatePrivate(spec); + static { + Security.addProvider(new BouncyCastleProvider()); } - 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 { - PrivateKey privateKey = loadPrivateKey(base64PrivateKey); - Cipher cipher = Cipher.getInstance(TRANSFORMATION); - cipher.init(Cipher.DECRYPT_MODE, privateKey); + PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(keyBytes); + KeyFactory keyFactory = KeyFactory.getInstance(ALGORITHM); + 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); + + // 企微返回的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); - return new String(decryptedBytes, StandardCharsets.UTF_8); + String result = new String(decryptedBytes, StandardCharsets.UTF_8); + log.debug("解密成功,结果长度: {}", result.length()); + return result; } catch (Exception e) { - log.error("RSA解密失败: {}", e.getMessage(), e); + // 使用debug级别避免日志刷屏(企微可能更换公钥版本导致旧消息无法解密) + log.debug("RSA解密失败: {}, encryptedData长度={}", e.getMessage(), encryptedData != null ? encryptedData.length() : 0); return null; } } diff --git a/backend/archive-service/src/main/java/com/artedu/archive/util/WeComCryptUtil.java b/backend/archive-service/src/main/java/com/artedu/archive/util/WeComCryptUtil.java new file mode 100644 index 0000000..b317b18 --- /dev/null +++ b/backend/archive-service/src/main/java/com/artedu/archive/util/WeComCryptUtil.java @@ -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; + } +} diff --git a/backend/archive-service/src/main/java/com/artedu/archive/vo/ArchiveMessageVO.java b/backend/archive-service/src/main/java/com/artedu/archive/vo/ArchiveMessageVO.java new file mode 100644 index 0000000..923760f --- /dev/null +++ b/backend/archive-service/src/main/java/com/artedu/archive/vo/ArchiveMessageVO.java @@ -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; +} diff --git a/backend/archive-service/src/main/java/com/artedu/archive/vo/CustomerSessionVO.java b/backend/archive-service/src/main/java/com/artedu/archive/vo/CustomerSessionVO.java new file mode 100644 index 0000000..6b2e3f0 --- /dev/null +++ b/backend/archive-service/src/main/java/com/artedu/archive/vo/CustomerSessionVO.java @@ -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; +} diff --git a/backend/archive-service/src/main/java/com/artedu/archive/vo/SessionVO.java b/backend/archive-service/src/main/java/com/artedu/archive/vo/SessionVO.java new file mode 100644 index 0000000..3afefe6 --- /dev/null +++ b/backend/archive-service/src/main/java/com/artedu/archive/vo/SessionVO.java @@ -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 +} diff --git a/backend/archive-service/src/main/java/com/artedu/archive/vo/StaffSessionVO.java b/backend/archive-service/src/main/java/com/artedu/archive/vo/StaffSessionVO.java new file mode 100644 index 0000000..07b1af2 --- /dev/null +++ b/backend/archive-service/src/main/java/com/artedu/archive/vo/StaffSessionVO.java @@ -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; +} diff --git a/backend/archive-service/src/main/java/com/artedu/archive/worker/SdkWorker.java b/backend/archive-service/src/main/java/com/artedu/archive/worker/SdkWorker.java new file mode 100644 index 0000000..14387e2 --- /dev/null +++ b/backend/archive-service/src/main/java/com/artedu/archive/worker/SdkWorker.java @@ -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 + * java -jar app.jar worker decrypt + */ +public class SdkWorker { + + public static void run(String[] args) throws Exception { + if (args.length < 1) { + System.err.println("Usage: worker ..."); + 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 "); + 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 "); + 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 "); + 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; + } +} diff --git a/backend/archive-service/src/main/resources/application.yml b/backend/archive-service/src/main/resources/application.yml index 3d1afff..aa58cab 100644 --- a/backend/archive-service/src/main/resources/application.yml +++ b/backend/archive-service/src/main/resources/application.yml @@ -11,7 +11,7 @@ spring: name: archive-service datasource: 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} password: ${MYSQL_PASSWORD:root} redis: @@ -31,11 +31,18 @@ spring: password: ${RABBITMQ_PASSWORD:admin} wecom: + corp-id: wwd483c2fba24ae30a + agent: + secret: ${WECOM_AGENT_SECRET:} + contact: + secret: ${WECOM_CONTACT_SECRET:} archive: corp-id: wwd483c2fba24ae30a secret: ${WECOM_ARCHIVE_SECRET:} rsa-private-key: ${WECOM_ARCHIVE_RSA_KEY:} + rsa-private-key-path: /app/keys/private.pem callback-token: ${WECOM_ARCHIVE_CALLBACK_TOKEN:} + callback-aeskey: ${WECOM_ARCHIVE_CALLBACK_AESKEY:} sdk-path: ${WECOM_ARCHIVE_SDK_PATH:} mock-mode: ${WECOM_ARCHIVE_MOCK:false} diff --git a/backend/archive-service/target/archive-service-1.0.0-SNAPSHOT.jar b/backend/archive-service/target/archive-service-1.0.0-SNAPSHOT.jar index 1a68183..5dd9ff6 100644 Binary files a/backend/archive-service/target/archive-service-1.0.0-SNAPSHOT.jar and b/backend/archive-service/target/archive-service-1.0.0-SNAPSHOT.jar differ diff --git a/backend/archive-service/target/archive-service-1.0.0-SNAPSHOT.jar.original b/backend/archive-service/target/archive-service-1.0.0-SNAPSHOT.jar.original index 3691a5b..7c15da8 100644 Binary files a/backend/archive-service/target/archive-service-1.0.0-SNAPSHOT.jar.original and b/backend/archive-service/target/archive-service-1.0.0-SNAPSHOT.jar.original differ diff --git a/backend/archive-service/target/classes/application.yml b/backend/archive-service/target/classes/application.yml index 3d1afff..aa58cab 100644 --- a/backend/archive-service/target/classes/application.yml +++ b/backend/archive-service/target/classes/application.yml @@ -11,7 +11,7 @@ spring: name: archive-service datasource: 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} password: ${MYSQL_PASSWORD:root} redis: @@ -31,11 +31,18 @@ spring: password: ${RABBITMQ_PASSWORD:admin} wecom: + corp-id: wwd483c2fba24ae30a + agent: + secret: ${WECOM_AGENT_SECRET:} + contact: + secret: ${WECOM_CONTACT_SECRET:} archive: corp-id: wwd483c2fba24ae30a secret: ${WECOM_ARCHIVE_SECRET:} rsa-private-key: ${WECOM_ARCHIVE_RSA_KEY:} + rsa-private-key-path: /app/keys/private.pem callback-token: ${WECOM_ARCHIVE_CALLBACK_TOKEN:} + callback-aeskey: ${WECOM_ARCHIVE_CALLBACK_AESKEY:} sdk-path: ${WECOM_ARCHIVE_SDK_PATH:} mock-mode: ${WECOM_ARCHIVE_MOCK:false} diff --git a/backend/archive-service/target/classes/com/artedu/archive/ArchiveServiceApplication$1.class b/backend/archive-service/target/classes/com/artedu/archive/ArchiveServiceApplication$1.class new file mode 100644 index 0000000..4e4e4a1 Binary files /dev/null and b/backend/archive-service/target/classes/com/artedu/archive/ArchiveServiceApplication$1.class differ diff --git a/backend/archive-service/target/classes/com/artedu/archive/ArchiveServiceApplication.class b/backend/archive-service/target/classes/com/artedu/archive/ArchiveServiceApplication.class index 0df7c57..d76f608 100644 Binary files a/backend/archive-service/target/classes/com/artedu/archive/ArchiveServiceApplication.class and b/backend/archive-service/target/classes/com/artedu/archive/ArchiveServiceApplication.class differ diff --git a/backend/archive-service/target/classes/com/artedu/archive/client/WeComApiClient.class b/backend/archive-service/target/classes/com/artedu/archive/client/WeComApiClient.class new file mode 100644 index 0000000..8f1f6de Binary files /dev/null and b/backend/archive-service/target/classes/com/artedu/archive/client/WeComApiClient.class differ diff --git a/backend/archive-service/target/classes/com/artedu/archive/controller/ArchiveCallbackController.class b/backend/archive-service/target/classes/com/artedu/archive/controller/ArchiveCallbackController.class index 5e0074d..b023be2 100644 Binary files a/backend/archive-service/target/classes/com/artedu/archive/controller/ArchiveCallbackController.class and b/backend/archive-service/target/classes/com/artedu/archive/controller/ArchiveCallbackController.class differ diff --git a/backend/archive-service/target/classes/com/artedu/archive/controller/ArchiveContactController.class b/backend/archive-service/target/classes/com/artedu/archive/controller/ArchiveContactController.class new file mode 100644 index 0000000..8549eaf Binary files /dev/null and b/backend/archive-service/target/classes/com/artedu/archive/controller/ArchiveContactController.class differ diff --git a/backend/archive-service/target/classes/com/artedu/archive/controller/ArchiveExportController.class b/backend/archive-service/target/classes/com/artedu/archive/controller/ArchiveExportController.class new file mode 100644 index 0000000..f136dac Binary files /dev/null and b/backend/archive-service/target/classes/com/artedu/archive/controller/ArchiveExportController.class differ diff --git a/backend/archive-service/target/classes/com/artedu/archive/controller/ArchiveMessageController$ArchiveStatsVO.class b/backend/archive-service/target/classes/com/artedu/archive/controller/ArchiveMessageController$ArchiveStatsVO.class new file mode 100644 index 0000000..a261ff0 Binary files /dev/null and b/backend/archive-service/target/classes/com/artedu/archive/controller/ArchiveMessageController$ArchiveStatsVO.class differ diff --git a/backend/archive-service/target/classes/com/artedu/archive/controller/ArchiveMessageController.class b/backend/archive-service/target/classes/com/artedu/archive/controller/ArchiveMessageController.class new file mode 100644 index 0000000..c2de508 Binary files /dev/null and b/backend/archive-service/target/classes/com/artedu/archive/controller/ArchiveMessageController.class differ diff --git a/backend/archive-service/target/classes/com/artedu/archive/controller/DashboardController$TrendVO.class b/backend/archive-service/target/classes/com/artedu/archive/controller/DashboardController$TrendVO.class new file mode 100644 index 0000000..10e38c1 Binary files /dev/null and b/backend/archive-service/target/classes/com/artedu/archive/controller/DashboardController$TrendVO.class differ diff --git a/backend/archive-service/target/classes/com/artedu/archive/controller/DashboardController.class b/backend/archive-service/target/classes/com/artedu/archive/controller/DashboardController.class new file mode 100644 index 0000000..c7b74fb Binary files /dev/null and b/backend/archive-service/target/classes/com/artedu/archive/controller/DashboardController.class differ diff --git a/backend/archive-service/target/classes/com/artedu/archive/controller/RiskController.class b/backend/archive-service/target/classes/com/artedu/archive/controller/RiskController.class new file mode 100644 index 0000000..4345a69 Binary files /dev/null and b/backend/archive-service/target/classes/com/artedu/archive/controller/RiskController.class differ diff --git a/backend/archive-service/target/classes/com/artedu/archive/controller/RiskEventController$HandleRequest.class b/backend/archive-service/target/classes/com/artedu/archive/controller/RiskEventController$HandleRequest.class new file mode 100644 index 0000000..d58964a Binary files /dev/null and b/backend/archive-service/target/classes/com/artedu/archive/controller/RiskEventController$HandleRequest.class differ diff --git a/backend/archive-service/target/classes/com/artedu/archive/controller/RiskEventController.class b/backend/archive-service/target/classes/com/artedu/archive/controller/RiskEventController.class new file mode 100644 index 0000000..24b4b7d Binary files /dev/null and b/backend/archive-service/target/classes/com/artedu/archive/controller/RiskEventController.class differ diff --git a/backend/archive-service/target/classes/com/artedu/archive/controller/RiskEventNotifyController.class b/backend/archive-service/target/classes/com/artedu/archive/controller/RiskEventNotifyController.class new file mode 100644 index 0000000..01f28a7 Binary files /dev/null and b/backend/archive-service/target/classes/com/artedu/archive/controller/RiskEventNotifyController.class differ diff --git a/backend/archive-service/target/classes/com/artedu/archive/entity/ArchiveMessage.class b/backend/archive-service/target/classes/com/artedu/archive/entity/ArchiveMessage.class index 1f397a1..9f7d406 100644 Binary files a/backend/archive-service/target/classes/com/artedu/archive/entity/ArchiveMessage.class and b/backend/archive-service/target/classes/com/artedu/archive/entity/ArchiveMessage.class differ diff --git a/backend/archive-service/target/classes/com/artedu/archive/entity/Customer.class b/backend/archive-service/target/classes/com/artedu/archive/entity/Customer.class new file mode 100644 index 0000000..5699c54 Binary files /dev/null and b/backend/archive-service/target/classes/com/artedu/archive/entity/Customer.class differ diff --git a/backend/archive-service/target/classes/com/artedu/archive/entity/RiskConfig.class b/backend/archive-service/target/classes/com/artedu/archive/entity/RiskConfig.class new file mode 100644 index 0000000..ddcca5b Binary files /dev/null and b/backend/archive-service/target/classes/com/artedu/archive/entity/RiskConfig.class differ diff --git a/backend/archive-service/target/classes/com/artedu/archive/entity/RiskEvent.class b/backend/archive-service/target/classes/com/artedu/archive/entity/RiskEvent.class new file mode 100644 index 0000000..97bcd62 Binary files /dev/null and b/backend/archive-service/target/classes/com/artedu/archive/entity/RiskEvent.class differ diff --git a/backend/archive-service/target/classes/com/artedu/archive/entity/SensitiveWord.class b/backend/archive-service/target/classes/com/artedu/archive/entity/SensitiveWord.class new file mode 100644 index 0000000..7bac12a Binary files /dev/null and b/backend/archive-service/target/classes/com/artedu/archive/entity/SensitiveWord.class differ diff --git a/backend/archive-service/target/classes/com/artedu/archive/entity/SensitiveWordHit.class b/backend/archive-service/target/classes/com/artedu/archive/entity/SensitiveWordHit.class new file mode 100644 index 0000000..5afa213 Binary files /dev/null and b/backend/archive-service/target/classes/com/artedu/archive/entity/SensitiveWordHit.class differ diff --git a/backend/archive-service/target/classes/com/artedu/archive/entity/Staff.class b/backend/archive-service/target/classes/com/artedu/archive/entity/Staff.class new file mode 100644 index 0000000..8348c0b Binary files /dev/null and b/backend/archive-service/target/classes/com/artedu/archive/entity/Staff.class differ diff --git a/backend/archive-service/target/classes/com/artedu/archive/excel/RiskEventExcel.class b/backend/archive-service/target/classes/com/artedu/archive/excel/RiskEventExcel.class new file mode 100644 index 0000000..f98e154 Binary files /dev/null and b/backend/archive-service/target/classes/com/artedu/archive/excel/RiskEventExcel.class differ diff --git a/backend/archive-service/target/classes/com/artedu/archive/excel/SensitiveWordHitExcel.class b/backend/archive-service/target/classes/com/artedu/archive/excel/SensitiveWordHitExcel.class new file mode 100644 index 0000000..8c7a48f Binary files /dev/null and b/backend/archive-service/target/classes/com/artedu/archive/excel/SensitiveWordHitExcel.class differ diff --git a/backend/archive-service/target/classes/com/artedu/archive/mapper/ArchiveMessageMapper.class b/backend/archive-service/target/classes/com/artedu/archive/mapper/ArchiveMessageMapper.class index f0f3f3b..55d7408 100644 Binary files a/backend/archive-service/target/classes/com/artedu/archive/mapper/ArchiveMessageMapper.class and b/backend/archive-service/target/classes/com/artedu/archive/mapper/ArchiveMessageMapper.class differ diff --git a/backend/archive-service/target/classes/com/artedu/archive/mapper/CustomerMapper.class b/backend/archive-service/target/classes/com/artedu/archive/mapper/CustomerMapper.class new file mode 100644 index 0000000..4c377d3 Binary files /dev/null and b/backend/archive-service/target/classes/com/artedu/archive/mapper/CustomerMapper.class differ diff --git a/backend/archive-service/target/classes/com/artedu/archive/mapper/RiskConfigMapper.class b/backend/archive-service/target/classes/com/artedu/archive/mapper/RiskConfigMapper.class new file mode 100644 index 0000000..889ab3b Binary files /dev/null and b/backend/archive-service/target/classes/com/artedu/archive/mapper/RiskConfigMapper.class differ diff --git a/backend/archive-service/target/classes/com/artedu/archive/mapper/RiskEventMapper.class b/backend/archive-service/target/classes/com/artedu/archive/mapper/RiskEventMapper.class new file mode 100644 index 0000000..24ba626 Binary files /dev/null and b/backend/archive-service/target/classes/com/artedu/archive/mapper/RiskEventMapper.class differ diff --git a/backend/archive-service/target/classes/com/artedu/archive/mapper/SensitiveWordHitMapper.class b/backend/archive-service/target/classes/com/artedu/archive/mapper/SensitiveWordHitMapper.class new file mode 100644 index 0000000..d843b69 Binary files /dev/null and b/backend/archive-service/target/classes/com/artedu/archive/mapper/SensitiveWordHitMapper.class differ diff --git a/backend/archive-service/target/classes/com/artedu/archive/mapper/SensitiveWordMapper.class b/backend/archive-service/target/classes/com/artedu/archive/mapper/SensitiveWordMapper.class new file mode 100644 index 0000000..65024e2 Binary files /dev/null and b/backend/archive-service/target/classes/com/artedu/archive/mapper/SensitiveWordMapper.class differ diff --git a/backend/archive-service/target/classes/com/artedu/archive/mapper/StaffMapper.class b/backend/archive-service/target/classes/com/artedu/archive/mapper/StaffMapper.class new file mode 100644 index 0000000..e16d507 Binary files /dev/null and b/backend/archive-service/target/classes/com/artedu/archive/mapper/StaffMapper.class differ diff --git a/backend/archive-service/target/classes/com/artedu/archive/service/ArchiveContactService.class b/backend/archive-service/target/classes/com/artedu/archive/service/ArchiveContactService.class new file mode 100644 index 0000000..27ac093 Binary files /dev/null and b/backend/archive-service/target/classes/com/artedu/archive/service/ArchiveContactService.class differ diff --git a/backend/archive-service/target/classes/com/artedu/archive/service/ArchivePullService.class b/backend/archive-service/target/classes/com/artedu/archive/service/ArchivePullService.class index 50ba87e..37cb283 100644 Binary files a/backend/archive-service/target/classes/com/artedu/archive/service/ArchivePullService.class and b/backend/archive-service/target/classes/com/artedu/archive/service/ArchivePullService.class differ diff --git a/backend/archive-service/target/classes/com/artedu/archive/service/ArchiveRiskService.class b/backend/archive-service/target/classes/com/artedu/archive/service/ArchiveRiskService.class new file mode 100644 index 0000000..da85094 Binary files /dev/null and b/backend/archive-service/target/classes/com/artedu/archive/service/ArchiveRiskService.class differ diff --git a/backend/archive-service/target/classes/com/artedu/archive/service/ArchiveSessionService.class b/backend/archive-service/target/classes/com/artedu/archive/service/ArchiveSessionService.class new file mode 100644 index 0000000..eb6d093 Binary files /dev/null and b/backend/archive-service/target/classes/com/artedu/archive/service/ArchiveSessionService.class differ diff --git a/backend/archive-service/target/classes/com/artedu/archive/service/MockArchiveDataProvider.class b/backend/archive-service/target/classes/com/artedu/archive/service/MockArchiveDataProvider.class index 5450297..2c8b179 100644 Binary files a/backend/archive-service/target/classes/com/artedu/archive/service/MockArchiveDataProvider.class and b/backend/archive-service/target/classes/com/artedu/archive/service/MockArchiveDataProvider.class differ diff --git a/backend/archive-service/target/classes/com/artedu/archive/service/RiskEventDetector.class b/backend/archive-service/target/classes/com/artedu/archive/service/RiskEventDetector.class new file mode 100644 index 0000000..fd58604 Binary files /dev/null and b/backend/archive-service/target/classes/com/artedu/archive/service/RiskEventDetector.class differ diff --git a/backend/archive-service/target/classes/com/artedu/archive/service/RiskEventNotifyService.class b/backend/archive-service/target/classes/com/artedu/archive/service/RiskEventNotifyService.class new file mode 100644 index 0000000..d440c04 Binary files /dev/null and b/backend/archive-service/target/classes/com/artedu/archive/service/RiskEventNotifyService.class differ diff --git a/backend/archive-service/target/classes/com/artedu/archive/service/SensitiveWordScanner$HitResult.class b/backend/archive-service/target/classes/com/artedu/archive/service/SensitiveWordScanner$HitResult.class new file mode 100644 index 0000000..b004b22 Binary files /dev/null and b/backend/archive-service/target/classes/com/artedu/archive/service/SensitiveWordScanner$HitResult.class differ diff --git a/backend/archive-service/target/classes/com/artedu/archive/service/SensitiveWordScanner.class b/backend/archive-service/target/classes/com/artedu/archive/service/SensitiveWordScanner.class new file mode 100644 index 0000000..8b4dc81 Binary files /dev/null and b/backend/archive-service/target/classes/com/artedu/archive/service/SensitiveWordScanner.class differ diff --git a/backend/archive-service/target/classes/com/artedu/archive/util/RsaDecryptUtil.class b/backend/archive-service/target/classes/com/artedu/archive/util/RsaDecryptUtil.class index 00fbb85..9c3315b 100644 Binary files a/backend/archive-service/target/classes/com/artedu/archive/util/RsaDecryptUtil.class and b/backend/archive-service/target/classes/com/artedu/archive/util/RsaDecryptUtil.class differ diff --git a/backend/archive-service/target/classes/com/artedu/archive/util/WeComCryptUtil.class b/backend/archive-service/target/classes/com/artedu/archive/util/WeComCryptUtil.class new file mode 100644 index 0000000..ea08843 Binary files /dev/null and b/backend/archive-service/target/classes/com/artedu/archive/util/WeComCryptUtil.class differ diff --git a/backend/archive-service/target/classes/com/artedu/archive/vo/ArchiveMessageVO.class b/backend/archive-service/target/classes/com/artedu/archive/vo/ArchiveMessageVO.class new file mode 100644 index 0000000..bb7385c Binary files /dev/null and b/backend/archive-service/target/classes/com/artedu/archive/vo/ArchiveMessageVO.class differ diff --git a/backend/archive-service/target/classes/com/artedu/archive/vo/CustomerSessionVO.class b/backend/archive-service/target/classes/com/artedu/archive/vo/CustomerSessionVO.class new file mode 100644 index 0000000..b007f00 Binary files /dev/null and b/backend/archive-service/target/classes/com/artedu/archive/vo/CustomerSessionVO.class differ diff --git a/backend/archive-service/target/classes/com/artedu/archive/vo/SessionVO.class b/backend/archive-service/target/classes/com/artedu/archive/vo/SessionVO.class new file mode 100644 index 0000000..13c6e4a Binary files /dev/null and b/backend/archive-service/target/classes/com/artedu/archive/vo/SessionVO.class differ diff --git a/backend/archive-service/target/classes/com/artedu/archive/vo/StaffSessionVO.class b/backend/archive-service/target/classes/com/artedu/archive/vo/StaffSessionVO.class new file mode 100644 index 0000000..f30e133 Binary files /dev/null and b/backend/archive-service/target/classes/com/artedu/archive/vo/StaffSessionVO.class differ diff --git a/backend/archive-service/target/classes/com/artedu/archive/worker/SdkWorker.class b/backend/archive-service/target/classes/com/artedu/archive/worker/SdkWorker.class new file mode 100644 index 0000000..b8aa854 Binary files /dev/null and b/backend/archive-service/target/classes/com/artedu/archive/worker/SdkWorker.class differ diff --git a/backend/archive-service/target/classes/com/tencent/wework/Finance.class b/backend/archive-service/target/classes/com/tencent/wework/Finance.class index c326ae2..378b7d5 100644 Binary files a/backend/archive-service/target/classes/com/tencent/wework/Finance.class and b/backend/archive-service/target/classes/com/tencent/wework/Finance.class differ diff --git a/backend/archive-service/target/classes/com/tencent/wework/Finance.java.utf8 b/backend/archive-service/target/classes/com/tencent/wework/Finance.java.utf8 deleted file mode 100644 index e69de29..0000000 diff --git a/backend/archive-service/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst b/backend/archive-service/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst index 17ddf55..c96aaf5 100644 --- a/backend/archive-service/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst +++ b/backend/archive-service/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst @@ -1,8 +1,47 @@ -com\tencent\wework\Finance.class -com\artedu\archive\service\ArchivePullService.class -com\artedu\archive\controller\ArchiveCallbackController.class -com\artedu\archive\ArchiveServiceApplication.class +com\artedu\archive\mapper\CustomerMapper.class +com\artedu\archive\mapper\RiskEventMapper.class +com\artedu\archive\controller\RiskEventNotifyController.class +com\artedu\archive\entity\Staff.class +com\artedu\archive\service\ArchiveRiskService.class com\artedu\archive\service\MockArchiveDataProvider.class +com\artedu\archive\worker\SdkWorker.class +com\artedu\archive\service\ArchiveContactService.class +com\artedu\archive\service\SensitiveWordScanner$HitResult.class +com\artedu\archive\controller\ArchiveMessageController.class +com\artedu\archive\controller\DashboardController.class +com\artedu\archive\controller\RiskEventController.class +com\artedu\archive\service\ArchivePullService.class +com\artedu\archive\mapper\SensitiveWordMapper.class +com\artedu\archive\excel\SensitiveWordHitExcel.class +com\artedu\archive\vo\StaffSessionVO.class +com\artedu\archive\excel\RiskEventExcel.class +com\artedu\archive\vo\SessionVO.class +com\artedu\archive\service\RiskEventDetector.class +com\artedu\archive\service\RiskEventNotifyService.class +com\tencent\wework\Finance.class +com\artedu\archive\entity\SensitiveWordHit.class +com\artedu\archive\mapper\RiskConfigMapper.class +com\artedu\archive\entity\SensitiveWord.class +com\artedu\archive\controller\ArchiveCallbackController.class +com\artedu\archive\service\SensitiveWordScanner.class +com\artedu\archive\controller\DashboardController$TrendVO.class +com\artedu\archive\entity\Customer.class +com\artedu\archive\vo\ArchiveMessageVO.class +com\artedu\archive\ArchiveServiceApplication$1.class +com\artedu\archive\mapper\StaffMapper.class +com\artedu\archive\controller\RiskController.class com\artedu\archive\entity\ArchiveMessage.class com\artedu\archive\mapper\ArchiveMessageMapper.class com\artedu\archive\util\RsaDecryptUtil.class +com\artedu\archive\controller\ArchiveContactController.class +com\artedu\archive\controller\RiskEventController$HandleRequest.class +com\artedu\archive\entity\RiskEvent.class +com\artedu\archive\service\ArchiveSessionService.class +com\artedu\archive\mapper\SensitiveWordHitMapper.class +com\artedu\archive\util\WeComCryptUtil.class +com\artedu\archive\ArchiveServiceApplication.class +com\artedu\archive\client\WeComApiClient.class +com\artedu\archive\vo\CustomerSessionVO.class +com\artedu\archive\controller\ArchiveMessageController$ArchiveStatsVO.class +com\artedu\archive\entity\RiskConfig.class +com\artedu\archive\controller\ArchiveExportController.class diff --git a/backend/archive-service/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst b/backend/archive-service/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst index 5536605..a686598 100644 --- a/backend/archive-service/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst +++ b/backend/archive-service/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst @@ -1,8 +1,42 @@ D:\www\agent_9art\backend\archive-service\src\main\java\com\artedu\archive\ArchiveServiceApplication.java +D:\www\agent_9art\backend\archive-service\src\main\java\com\artedu\archive\client\WeComApiClient.java D:\www\agent_9art\backend\archive-service\src\main\java\com\artedu\archive\controller\ArchiveCallbackController.java +D:\www\agent_9art\backend\archive-service\src\main\java\com\artedu\archive\controller\ArchiveContactController.java +D:\www\agent_9art\backend\archive-service\src\main\java\com\artedu\archive\controller\ArchiveExportController.java +D:\www\agent_9art\backend\archive-service\src\main\java\com\artedu\archive\controller\ArchiveMessageController.java +D:\www\agent_9art\backend\archive-service\src\main\java\com\artedu\archive\controller\DashboardController.java +D:\www\agent_9art\backend\archive-service\src\main\java\com\artedu\archive\controller\RiskController.java +D:\www\agent_9art\backend\archive-service\src\main\java\com\artedu\archive\controller\RiskEventController.java +D:\www\agent_9art\backend\archive-service\src\main\java\com\artedu\archive\controller\RiskEventNotifyController.java D:\www\agent_9art\backend\archive-service\src\main\java\com\artedu\archive\entity\ArchiveMessage.java +D:\www\agent_9art\backend\archive-service\src\main\java\com\artedu\archive\entity\Customer.java +D:\www\agent_9art\backend\archive-service\src\main\java\com\artedu\archive\entity\RiskConfig.java +D:\www\agent_9art\backend\archive-service\src\main\java\com\artedu\archive\entity\RiskEvent.java +D:\www\agent_9art\backend\archive-service\src\main\java\com\artedu\archive\entity\SensitiveWord.java +D:\www\agent_9art\backend\archive-service\src\main\java\com\artedu\archive\entity\SensitiveWordHit.java +D:\www\agent_9art\backend\archive-service\src\main\java\com\artedu\archive\entity\Staff.java +D:\www\agent_9art\backend\archive-service\src\main\java\com\artedu\archive\excel\RiskEventExcel.java +D:\www\agent_9art\backend\archive-service\src\main\java\com\artedu\archive\excel\SensitiveWordHitExcel.java D:\www\agent_9art\backend\archive-service\src\main\java\com\artedu\archive\mapper\ArchiveMessageMapper.java +D:\www\agent_9art\backend\archive-service\src\main\java\com\artedu\archive\mapper\CustomerMapper.java +D:\www\agent_9art\backend\archive-service\src\main\java\com\artedu\archive\mapper\RiskConfigMapper.java +D:\www\agent_9art\backend\archive-service\src\main\java\com\artedu\archive\mapper\RiskEventMapper.java +D:\www\agent_9art\backend\archive-service\src\main\java\com\artedu\archive\mapper\SensitiveWordHitMapper.java +D:\www\agent_9art\backend\archive-service\src\main\java\com\artedu\archive\mapper\SensitiveWordMapper.java +D:\www\agent_9art\backend\archive-service\src\main\java\com\artedu\archive\mapper\StaffMapper.java +D:\www\agent_9art\backend\archive-service\src\main\java\com\artedu\archive\service\ArchiveContactService.java D:\www\agent_9art\backend\archive-service\src\main\java\com\artedu\archive\service\ArchivePullService.java +D:\www\agent_9art\backend\archive-service\src\main\java\com\artedu\archive\service\ArchiveRiskService.java +D:\www\agent_9art\backend\archive-service\src\main\java\com\artedu\archive\service\ArchiveSessionService.java D:\www\agent_9art\backend\archive-service\src\main\java\com\artedu\archive\service\MockArchiveDataProvider.java +D:\www\agent_9art\backend\archive-service\src\main\java\com\artedu\archive\service\RiskEventDetector.java +D:\www\agent_9art\backend\archive-service\src\main\java\com\artedu\archive\service\RiskEventNotifyService.java +D:\www\agent_9art\backend\archive-service\src\main\java\com\artedu\archive\service\SensitiveWordScanner.java D:\www\agent_9art\backend\archive-service\src\main\java\com\artedu\archive\util\RsaDecryptUtil.java +D:\www\agent_9art\backend\archive-service\src\main\java\com\artedu\archive\util\WeComCryptUtil.java +D:\www\agent_9art\backend\archive-service\src\main\java\com\artedu\archive\vo\ArchiveMessageVO.java +D:\www\agent_9art\backend\archive-service\src\main\java\com\artedu\archive\vo\CustomerSessionVO.java +D:\www\agent_9art\backend\archive-service\src\main\java\com\artedu\archive\vo\SessionVO.java +D:\www\agent_9art\backend\archive-service\src\main\java\com\artedu\archive\vo\StaffSessionVO.java +D:\www\agent_9art\backend\archive-service\src\main\java\com\artedu\archive\worker\SdkWorker.java D:\www\agent_9art\backend\archive-service\src\main\java\com\tencent\wework\Finance.java diff --git a/backend/archive-service/target/test-classes/com/artedu/archive/controller/ArchiveCallbackControllerTest.class b/backend/archive-service/target/test-classes/com/artedu/archive/controller/ArchiveCallbackControllerTest.class index 647986f..c4b5a36 100644 Binary files a/backend/archive-service/target/test-classes/com/artedu/archive/controller/ArchiveCallbackControllerTest.class and b/backend/archive-service/target/test-classes/com/artedu/archive/controller/ArchiveCallbackControllerTest.class differ diff --git a/backend/archive-service/target/test-classes/com/artedu/archive/service/ArchivePullServiceTest.class b/backend/archive-service/target/test-classes/com/artedu/archive/service/ArchivePullServiceTest.class index 9ace06f..f76c9a5 100644 Binary files a/backend/archive-service/target/test-classes/com/artedu/archive/service/ArchivePullServiceTest.class and b/backend/archive-service/target/test-classes/com/artedu/archive/service/ArchivePullServiceTest.class differ diff --git a/backend/auth-service/src/main/java/com/artedu/auth/controller/AuthController.java b/backend/auth-service/src/main/java/com/artedu/auth/controller/AuthController.java index e48e40f..1578988 100644 --- a/backend/auth-service/src/main/java/com/artedu/auth/controller/AuthController.java +++ b/backend/auth-service/src/main/java/com/artedu/auth/controller/AuthController.java @@ -98,9 +98,19 @@ public class AuthController { @GetMapping("/signature") public Result> getJsSdkSignature( - @RequestParam("url") String url) { + @RequestParam("url") String url, + @RequestParam(value = "type", required = false, defaultValue = "corp") String type, + @RequestParam(value = "agentId", required = false) String reqAgentId) { + + String ticket; + if ("agent".equals(type)) { + // 应用级 jsapi_ticket,用于 wx.agentConfig + ticket = weComOAuthService.getAgentJsApiTicket(); + } else { + // 企业级 jsapi_ticket,用于 wx.config + ticket = weComOAuthService.getJsApiTicket(); + } - String ticket = weComOAuthService.getJsApiTicket(); String nonceStr = java.util.UUID.randomUUID().toString().replace("-", ""); String timestamp = String.valueOf(System.currentTimeMillis() / 1000); @@ -108,7 +118,7 @@ public class AuthController { Map result = new HashMap<>(); result.put("corpId", corpId); - result.put("agentId", agentId); + result.put("agentId", reqAgentId != null ? reqAgentId : agentId); result.put("nonceStr", nonceStr); result.put("timestamp", timestamp); result.put("signature", signature); diff --git a/backend/auth-service/src/main/java/com/artedu/auth/service/WeComOAuthService.java b/backend/auth-service/src/main/java/com/artedu/auth/service/WeComOAuthService.java index db6029b..f828f1d 100644 --- a/backend/auth-service/src/main/java/com/artedu/auth/service/WeComOAuthService.java +++ b/backend/auth-service/src/main/java/com/artedu/auth/service/WeComOAuthService.java @@ -44,6 +44,7 @@ public class WeComOAuthService { private static final String ACCESS_TOKEN_KEY = "wecom:access_token"; private static final String JSAPI_TICKET_KEY = "wecom:jsapi_ticket"; + private static final String AGENT_JSAPI_TICKET_KEY = "wecom:agent_jsapi_ticket"; private static final String USER_INFO_URL = "https://qyapi.weixin.qq.com/cgi-bin/user/getuserinfo?access_token={accessToken}&code={code}"; private static final String USER_DETAIL_URL = "https://qyapi.weixin.qq.com/cgi-bin/user/get?access_token={accessToken}&userid={userId}"; @@ -88,6 +89,31 @@ public class WeComOAuthService { return ticket; } + /** + * 获取应用级 jsapi_ticket(用于 wx.agentConfig) + * 接口: https://qyapi.weixin.qq.com/cgi-bin/ticket/get?access_token=ACCESS_TOKEN&type=agent_config + */ + public String getAgentJsApiTicket() { + String ticket = (String) redisTemplate.opsForValue().get(AGENT_JSAPI_TICKET_KEY); + if (ticket != null) { + return ticket; + } + + String accessToken = getAccessToken(); + String url = "https://qyapi.weixin.qq.com/cgi-bin/ticket/get?access_token=" + accessToken + "&type=agent_config"; + ResponseEntity response = restTemplate.getForEntity(url, String.class); + Map result = JsonUtils.fromJsonMap(response.getBody()); + + if (result == null || !Integer.valueOf(0).equals(result.get("errcode"))) { + log.error("获取AgentJsApiTicket失败: {}", response.getBody()); + throw new BusinessException("获取企微AgentJsApiTicket失败"); + } + + ticket = (String) result.get("ticket"); + redisTemplate.opsForValue().set(AGENT_JSAPI_TICKET_KEY, ticket, 7000, TimeUnit.SECONDS); + return ticket; + } + public String getUserIdByCode(String code) { String accessToken = getAccessToken(); String url = USER_INFO_URL.replace("{accessToken}", accessToken).replace("{code}", code); diff --git a/backend/auth-service/src/main/resources/application.yml b/backend/auth-service/src/main/resources/application.yml index eef5a23..4f1de38 100644 --- a/backend/auth-service/src/main/resources/application.yml +++ b/backend/auth-service/src/main/resources/application.yml @@ -11,7 +11,7 @@ spring: name: auth-service datasource: 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} password: ${MYSQL_PASSWORD:root} redis: diff --git a/backend/auth-service/target/auth-service-1.0.0-SNAPSHOT.jar b/backend/auth-service/target/auth-service-1.0.0-SNAPSHOT.jar index cf1a109..f997ec5 100644 Binary files a/backend/auth-service/target/auth-service-1.0.0-SNAPSHOT.jar and b/backend/auth-service/target/auth-service-1.0.0-SNAPSHOT.jar differ diff --git a/backend/auth-service/target/auth-service-1.0.0-SNAPSHOT.jar.original b/backend/auth-service/target/auth-service-1.0.0-SNAPSHOT.jar.original index 98b61c6..a598da9 100644 Binary files a/backend/auth-service/target/auth-service-1.0.0-SNAPSHOT.jar.original and b/backend/auth-service/target/auth-service-1.0.0-SNAPSHOT.jar.original differ diff --git a/backend/auth-service/target/classes/application.yml b/backend/auth-service/target/classes/application.yml index eef5a23..4f1de38 100644 --- a/backend/auth-service/target/classes/application.yml +++ b/backend/auth-service/target/classes/application.yml @@ -11,7 +11,7 @@ spring: name: auth-service datasource: 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} password: ${MYSQL_PASSWORD:root} redis: diff --git a/backend/auth-service/target/classes/com/artedu/auth/AuthServiceApplication.class b/backend/auth-service/target/classes/com/artedu/auth/AuthServiceApplication.class index 56547e5..22cfedd 100644 Binary files a/backend/auth-service/target/classes/com/artedu/auth/AuthServiceApplication.class and b/backend/auth-service/target/classes/com/artedu/auth/AuthServiceApplication.class differ diff --git a/backend/auth-service/target/classes/com/artedu/auth/controller/AuthController.class b/backend/auth-service/target/classes/com/artedu/auth/controller/AuthController.class index 3875a64..44cecb2 100644 Binary files a/backend/auth-service/target/classes/com/artedu/auth/controller/AuthController.class and b/backend/auth-service/target/classes/com/artedu/auth/controller/AuthController.class differ diff --git a/backend/auth-service/target/classes/com/artedu/auth/entity/Staff.class b/backend/auth-service/target/classes/com/artedu/auth/entity/Staff.class index 272bebd..02fee1b 100644 Binary files a/backend/auth-service/target/classes/com/artedu/auth/entity/Staff.class and b/backend/auth-service/target/classes/com/artedu/auth/entity/Staff.class differ diff --git a/backend/auth-service/target/classes/com/artedu/auth/mapper/StaffMapper.class b/backend/auth-service/target/classes/com/artedu/auth/mapper/StaffMapper.class index c960773..ae58f1c 100644 Binary files a/backend/auth-service/target/classes/com/artedu/auth/mapper/StaffMapper.class and b/backend/auth-service/target/classes/com/artedu/auth/mapper/StaffMapper.class differ diff --git a/backend/auth-service/target/classes/com/artedu/auth/service/JwtService.class b/backend/auth-service/target/classes/com/artedu/auth/service/JwtService.class index dd1cd22..ad66839 100644 Binary files a/backend/auth-service/target/classes/com/artedu/auth/service/JwtService.class and b/backend/auth-service/target/classes/com/artedu/auth/service/JwtService.class differ diff --git a/backend/auth-service/target/classes/com/artedu/auth/service/WeComOAuthService.class b/backend/auth-service/target/classes/com/artedu/auth/service/WeComOAuthService.class index 2a53443..19bd65d 100644 Binary files a/backend/auth-service/target/classes/com/artedu/auth/service/WeComOAuthService.class and b/backend/auth-service/target/classes/com/artedu/auth/service/WeComOAuthService.class differ diff --git a/backend/auth-service/target/test-classes/com/artedu/auth/controller/AuthControllerTest.class b/backend/auth-service/target/test-classes/com/artedu/auth/controller/AuthControllerTest.class index ad4de91..b04894f 100644 Binary files a/backend/auth-service/target/test-classes/com/artedu/auth/controller/AuthControllerTest.class and b/backend/auth-service/target/test-classes/com/artedu/auth/controller/AuthControllerTest.class differ diff --git a/backend/common/target/classes/com/artedu/common/config/CorsConfig.class b/backend/common/target/classes/com/artedu/common/config/CorsConfig.class index 7011286..2c98325 100644 Binary files a/backend/common/target/classes/com/artedu/common/config/CorsConfig.class and b/backend/common/target/classes/com/artedu/common/config/CorsConfig.class differ diff --git a/backend/common/target/classes/com/artedu/common/config/MyBatisPlusConfig.class b/backend/common/target/classes/com/artedu/common/config/MyBatisPlusConfig.class index 12f85ae..39245fd 100644 Binary files a/backend/common/target/classes/com/artedu/common/config/MyBatisPlusConfig.class and b/backend/common/target/classes/com/artedu/common/config/MyBatisPlusConfig.class differ diff --git a/backend/common/target/classes/com/artedu/common/config/RabbitConfig.class b/backend/common/target/classes/com/artedu/common/config/RabbitConfig.class index f32426a..1617260 100644 Binary files a/backend/common/target/classes/com/artedu/common/config/RabbitConfig.class and b/backend/common/target/classes/com/artedu/common/config/RabbitConfig.class differ diff --git a/backend/common/target/classes/com/artedu/common/config/RedisConfig.class b/backend/common/target/classes/com/artedu/common/config/RedisConfig.class index ca104a8..c202079 100644 Binary files a/backend/common/target/classes/com/artedu/common/config/RedisConfig.class and b/backend/common/target/classes/com/artedu/common/config/RedisConfig.class differ diff --git a/backend/common/target/classes/com/artedu/common/entity/BaseEntity.class b/backend/common/target/classes/com/artedu/common/entity/BaseEntity.class index 5c0c181..c1e6bc2 100644 Binary files a/backend/common/target/classes/com/artedu/common/entity/BaseEntity.class and b/backend/common/target/classes/com/artedu/common/entity/BaseEntity.class differ diff --git a/backend/common/target/classes/com/artedu/common/exception/BusinessException.class b/backend/common/target/classes/com/artedu/common/exception/BusinessException.class index a53acc4..9ed3b15 100644 Binary files a/backend/common/target/classes/com/artedu/common/exception/BusinessException.class and b/backend/common/target/classes/com/artedu/common/exception/BusinessException.class differ diff --git a/backend/common/target/classes/com/artedu/common/exception/GlobalExceptionHandler.class b/backend/common/target/classes/com/artedu/common/exception/GlobalExceptionHandler.class index 32efa25..05d49b5 100644 Binary files a/backend/common/target/classes/com/artedu/common/exception/GlobalExceptionHandler.class and b/backend/common/target/classes/com/artedu/common/exception/GlobalExceptionHandler.class differ diff --git a/backend/common/target/classes/com/artedu/common/handler/AutoFillHandler.class b/backend/common/target/classes/com/artedu/common/handler/AutoFillHandler.class index d24ff82..112c043 100644 Binary files a/backend/common/target/classes/com/artedu/common/handler/AutoFillHandler.class and b/backend/common/target/classes/com/artedu/common/handler/AutoFillHandler.class differ diff --git a/backend/common/target/classes/com/artedu/common/mybatis/JsonTypeHandler.class b/backend/common/target/classes/com/artedu/common/mybatis/JsonTypeHandler.class index dca2af2..adaef91 100644 Binary files a/backend/common/target/classes/com/artedu/common/mybatis/JsonTypeHandler.class and b/backend/common/target/classes/com/artedu/common/mybatis/JsonTypeHandler.class differ diff --git a/backend/common/target/classes/com/artedu/common/result/PageResult.class b/backend/common/target/classes/com/artedu/common/result/PageResult.class index 4db5a50..cb59d36 100644 Binary files a/backend/common/target/classes/com/artedu/common/result/PageResult.class and b/backend/common/target/classes/com/artedu/common/result/PageResult.class differ diff --git a/backend/common/target/classes/com/artedu/common/result/Result.class b/backend/common/target/classes/com/artedu/common/result/Result.class index 2b7ab51..362c975 100644 Binary files a/backend/common/target/classes/com/artedu/common/result/Result.class and b/backend/common/target/classes/com/artedu/common/result/Result.class differ diff --git a/backend/common/target/classes/com/artedu/common/util/JsonUtils$1.class b/backend/common/target/classes/com/artedu/common/util/JsonUtils$1.class index ebfbd27..31fda18 100644 Binary files a/backend/common/target/classes/com/artedu/common/util/JsonUtils$1.class and b/backend/common/target/classes/com/artedu/common/util/JsonUtils$1.class differ diff --git a/backend/common/target/classes/com/artedu/common/util/JsonUtils.class b/backend/common/target/classes/com/artedu/common/util/JsonUtils.class index 1a0e4a0..53aa45f 100644 Binary files a/backend/common/target/classes/com/artedu/common/util/JsonUtils.class and b/backend/common/target/classes/com/artedu/common/util/JsonUtils.class differ diff --git a/backend/common/target/common-1.0.0-SNAPSHOT.jar b/backend/common/target/common-1.0.0-SNAPSHOT.jar index 0fa4dd5..31d1237 100644 Binary files a/backend/common/target/common-1.0.0-SNAPSHOT.jar and b/backend/common/target/common-1.0.0-SNAPSHOT.jar differ diff --git a/backend/conversation-service/src/main/java/com/artedu/conversation/controller/ConversationController.java b/backend/conversation-service/src/main/java/com/artedu/conversation/controller/ConversationController.java index 2a32486..f824b96 100644 --- a/backend/conversation-service/src/main/java/com/artedu/conversation/controller/ConversationController.java +++ b/backend/conversation-service/src/main/java/com/artedu/conversation/controller/ConversationController.java @@ -28,7 +28,7 @@ public class ConversationController { @GetMapping("/{sessionId}/context") public Result> getContext(@NotBlank(message = "sessionId不能为空") @PathVariable("sessionId") String sessionId) { String contextStr = conversationManager.getContextString(sessionId); - var messages = conversationManager.getContextMessages(sessionId); + List messages = conversationManager.getContextMessages(sessionId); Map result = new HashMap<>(); result.put("sessionId", sessionId); diff --git a/backend/conversation-service/src/main/resources/application.yml b/backend/conversation-service/src/main/resources/application.yml index 5264743..51fc557 100644 --- a/backend/conversation-service/src/main/resources/application.yml +++ b/backend/conversation-service/src/main/resources/application.yml @@ -11,7 +11,7 @@ spring: name: conversation-service datasource: 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} password: ${MYSQL_PASSWORD:root} redis: diff --git a/backend/conversation-service/target/classes/application.yml b/backend/conversation-service/target/classes/application.yml index 5264743..51fc557 100644 --- a/backend/conversation-service/target/classes/application.yml +++ b/backend/conversation-service/target/classes/application.yml @@ -11,7 +11,7 @@ spring: name: conversation-service datasource: 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} password: ${MYSQL_PASSWORD:root} redis: diff --git a/backend/conversation-service/target/classes/com/artedu/conversation/ConversationServiceApplication.class b/backend/conversation-service/target/classes/com/artedu/conversation/ConversationServiceApplication.class index 5491d70..b19dcb8 100644 Binary files a/backend/conversation-service/target/classes/com/artedu/conversation/ConversationServiceApplication.class and b/backend/conversation-service/target/classes/com/artedu/conversation/ConversationServiceApplication.class differ diff --git a/backend/conversation-service/target/classes/com/artedu/conversation/controller/ConversationController.class b/backend/conversation-service/target/classes/com/artedu/conversation/controller/ConversationController.class index 7e321d3..1e9bcb6 100644 Binary files a/backend/conversation-service/target/classes/com/artedu/conversation/controller/ConversationController.class and b/backend/conversation-service/target/classes/com/artedu/conversation/controller/ConversationController.class differ diff --git a/backend/conversation-service/target/classes/com/artedu/conversation/entity/Conversation.class b/backend/conversation-service/target/classes/com/artedu/conversation/entity/Conversation.class index 730a87f..9de86b9 100644 Binary files a/backend/conversation-service/target/classes/com/artedu/conversation/entity/Conversation.class and b/backend/conversation-service/target/classes/com/artedu/conversation/entity/Conversation.class differ diff --git a/backend/conversation-service/target/classes/com/artedu/conversation/entity/ConversationTurn.class b/backend/conversation-service/target/classes/com/artedu/conversation/entity/ConversationTurn.class index 9cdba55..b7cb2e6 100644 Binary files a/backend/conversation-service/target/classes/com/artedu/conversation/entity/ConversationTurn.class and b/backend/conversation-service/target/classes/com/artedu/conversation/entity/ConversationTurn.class differ diff --git a/backend/conversation-service/target/classes/com/artedu/conversation/mapper/ConversationMapper.class b/backend/conversation-service/target/classes/com/artedu/conversation/mapper/ConversationMapper.class index ce17d44..49e1f97 100644 Binary files a/backend/conversation-service/target/classes/com/artedu/conversation/mapper/ConversationMapper.class and b/backend/conversation-service/target/classes/com/artedu/conversation/mapper/ConversationMapper.class differ diff --git a/backend/conversation-service/target/classes/com/artedu/conversation/mapper/ConversationTurnMapper.class b/backend/conversation-service/target/classes/com/artedu/conversation/mapper/ConversationTurnMapper.class index c66c71f..12c9721 100644 Binary files a/backend/conversation-service/target/classes/com/artedu/conversation/mapper/ConversationTurnMapper.class and b/backend/conversation-service/target/classes/com/artedu/conversation/mapper/ConversationTurnMapper.class differ diff --git a/backend/conversation-service/target/classes/com/artedu/conversation/service/ConversationManager$ArchiveMessageEvent.class b/backend/conversation-service/target/classes/com/artedu/conversation/service/ConversationManager$ArchiveMessageEvent.class index c17e2ed..e6258a9 100644 Binary files a/backend/conversation-service/target/classes/com/artedu/conversation/service/ConversationManager$ArchiveMessageEvent.class and b/backend/conversation-service/target/classes/com/artedu/conversation/service/ConversationManager$ArchiveMessageEvent.class differ diff --git a/backend/conversation-service/target/classes/com/artedu/conversation/service/ConversationManager$ContextMessage.class b/backend/conversation-service/target/classes/com/artedu/conversation/service/ConversationManager$ContextMessage.class index 2eff885..01809f2 100644 Binary files a/backend/conversation-service/target/classes/com/artedu/conversation/service/ConversationManager$ContextMessage.class and b/backend/conversation-service/target/classes/com/artedu/conversation/service/ConversationManager$ContextMessage.class differ diff --git a/backend/conversation-service/target/classes/com/artedu/conversation/service/ConversationManager.class b/backend/conversation-service/target/classes/com/artedu/conversation/service/ConversationManager.class index 3e55fcc..ad70d61 100644 Binary files a/backend/conversation-service/target/classes/com/artedu/conversation/service/ConversationManager.class and b/backend/conversation-service/target/classes/com/artedu/conversation/service/ConversationManager.class differ diff --git a/backend/conversation-service/target/conversation-service-1.0.0-SNAPSHOT.jar b/backend/conversation-service/target/conversation-service-1.0.0-SNAPSHOT.jar index c53a147..88e2c14 100644 Binary files a/backend/conversation-service/target/conversation-service-1.0.0-SNAPSHOT.jar and b/backend/conversation-service/target/conversation-service-1.0.0-SNAPSHOT.jar differ diff --git a/backend/conversation-service/target/conversation-service-1.0.0-SNAPSHOT.jar.original b/backend/conversation-service/target/conversation-service-1.0.0-SNAPSHOT.jar.original index 40a2693..a9c9372 100644 Binary files a/backend/conversation-service/target/conversation-service-1.0.0-SNAPSHOT.jar.original and b/backend/conversation-service/target/conversation-service-1.0.0-SNAPSHOT.jar.original differ diff --git a/backend/conversation-service/target/test-classes/com/artedu/conversation/controller/ConversationControllerTest.class b/backend/conversation-service/target/test-classes/com/artedu/conversation/controller/ConversationControllerTest.class index b2e4409..a15294c 100644 Binary files a/backend/conversation-service/target/test-classes/com/artedu/conversation/controller/ConversationControllerTest.class and b/backend/conversation-service/target/test-classes/com/artedu/conversation/controller/ConversationControllerTest.class differ diff --git a/backend/gateway/target/classes/com/artedu/gateway/GatewayApplication.class b/backend/gateway/target/classes/com/artedu/gateway/GatewayApplication.class index 3b616b3..79e6dd3 100644 Binary files a/backend/gateway/target/classes/com/artedu/gateway/GatewayApplication.class and b/backend/gateway/target/classes/com/artedu/gateway/GatewayApplication.class differ diff --git a/backend/gateway/target/gateway-1.0.0-SNAPSHOT.jar b/backend/gateway/target/gateway-1.0.0-SNAPSHOT.jar index e1ad64e..6eb5b3c 100644 Binary files a/backend/gateway/target/gateway-1.0.0-SNAPSHOT.jar and b/backend/gateway/target/gateway-1.0.0-SNAPSHOT.jar differ diff --git a/backend/gateway/target/gateway-1.0.0-SNAPSHOT.jar.original b/backend/gateway/target/gateway-1.0.0-SNAPSHOT.jar.original index c444312..225e5ea 100644 Binary files a/backend/gateway/target/gateway-1.0.0-SNAPSHOT.jar.original and b/backend/gateway/target/gateway-1.0.0-SNAPSHOT.jar.original differ diff --git a/backend/generation-service/src/main/resources/application.yml b/backend/generation-service/src/main/resources/application.yml index ab9a63e..0f1b0cb 100644 --- a/backend/generation-service/src/main/resources/application.yml +++ b/backend/generation-service/src/main/resources/application.yml @@ -11,7 +11,7 @@ spring: name: generation-service datasource: 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} password: ${MYSQL_PASSWORD:root} redis: diff --git a/backend/generation-service/target/classes/application.yml b/backend/generation-service/target/classes/application.yml index ab9a63e..0f1b0cb 100644 --- a/backend/generation-service/target/classes/application.yml +++ b/backend/generation-service/target/classes/application.yml @@ -11,7 +11,7 @@ spring: name: generation-service datasource: 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} password: ${MYSQL_PASSWORD:root} redis: diff --git a/backend/generation-service/target/classes/com/artedu/generation/GenerationServiceApplication.class b/backend/generation-service/target/classes/com/artedu/generation/GenerationServiceApplication.class index 7d04df2..9c91624 100644 Binary files a/backend/generation-service/target/classes/com/artedu/generation/GenerationServiceApplication.class and b/backend/generation-service/target/classes/com/artedu/generation/GenerationServiceApplication.class differ diff --git a/backend/generation-service/target/classes/com/artedu/generation/controller/GenerationController.class b/backend/generation-service/target/classes/com/artedu/generation/controller/GenerationController.class index 5641a84..bf00ef4 100644 Binary files a/backend/generation-service/target/classes/com/artedu/generation/controller/GenerationController.class and b/backend/generation-service/target/classes/com/artedu/generation/controller/GenerationController.class differ diff --git a/backend/generation-service/target/classes/com/artedu/generation/service/PromptEngine.class b/backend/generation-service/target/classes/com/artedu/generation/service/PromptEngine.class index 163b8c4..1661d52 100644 Binary files a/backend/generation-service/target/classes/com/artedu/generation/service/PromptEngine.class and b/backend/generation-service/target/classes/com/artedu/generation/service/PromptEngine.class differ diff --git a/backend/generation-service/target/classes/com/artedu/generation/service/QianwenClient.class b/backend/generation-service/target/classes/com/artedu/generation/service/QianwenClient.class index eb55ae2..3a9b383 100644 Binary files a/backend/generation-service/target/classes/com/artedu/generation/service/QianwenClient.class and b/backend/generation-service/target/classes/com/artedu/generation/service/QianwenClient.class differ diff --git a/backend/generation-service/target/generation-service-1.0.0-SNAPSHOT.jar b/backend/generation-service/target/generation-service-1.0.0-SNAPSHOT.jar index 808b8bb..5cad77f 100644 Binary files a/backend/generation-service/target/generation-service-1.0.0-SNAPSHOT.jar and b/backend/generation-service/target/generation-service-1.0.0-SNAPSHOT.jar differ diff --git a/backend/generation-service/target/generation-service-1.0.0-SNAPSHOT.jar.original b/backend/generation-service/target/generation-service-1.0.0-SNAPSHOT.jar.original index cd2286b..d74f5c4 100644 Binary files a/backend/generation-service/target/generation-service-1.0.0-SNAPSHOT.jar.original and b/backend/generation-service/target/generation-service-1.0.0-SNAPSHOT.jar.original differ diff --git a/backend/intent-service/src/main/java/com/artedu/intent/controller/CustomerProfileController.java b/backend/intent-service/src/main/java/com/artedu/intent/controller/CustomerProfileController.java new file mode 100644 index 0000000..2b2edd6 --- /dev/null +++ b/backend/intent-service/src/main/java/com/artedu/intent/controller/CustomerProfileController.java @@ -0,0 +1,54 @@ +package com.artedu.intent.controller; + +import com.artedu.common.result.Result; +import com.artedu.intent.entity.CustomerProfile; +import com.artedu.intent.service.CustomerProfileService; +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.NotBlank; +import java.util.List; + +@Slf4j +@Validated +@RestController +@RequestMapping("/api/v1/intent") +public class CustomerProfileController { + + @Autowired + private CustomerProfileService customerProfileService; + + @GetMapping("/profiles") + public Result> listProfiles( + @NotBlank(message = "corpId不能为空") @RequestParam("corpId") String corpId, + @RequestParam(value = "keyword", required = false) String keyword) { + List list = customerProfileService.listProfiles(corpId, keyword); + return Result.success(list); + } + + @GetMapping("/profiles/{customerId}") + public Result getProfile( + @NotBlank(message = "customerId不能为空") @PathVariable("customerId") String customerId, + @NotBlank(message = "corpId不能为空") @RequestParam("corpId") String corpId) { + CustomerProfile profile = customerProfileService.getProfile(customerId, corpId); + return Result.success(profile); + } + + @PostMapping("/profiles") + public Result saveProfile(@RequestBody CustomerProfile profile) { + customerProfileService.saveOrUpdateProfile(profile); + log.info("保存客户画像: customerId={}, corpId={}", profile.getCustomerId(), profile.getCorpId()); + return Result.success(); + } + + @DeleteMapping("/profiles/{customerId}") + public Result deleteProfile( + @NotBlank(message = "customerId不能为空") @PathVariable("customerId") String customerId, + @NotBlank(message = "corpId不能为空") @RequestParam("corpId") String corpId) { + customerProfileService.deleteProfile(customerId, corpId); + log.info("删除客户画像: customerId={}, corpId={}", customerId, corpId); + return Result.success(); + } +} diff --git a/backend/intent-service/src/main/java/com/artedu/intent/service/CustomerProfileService.java b/backend/intent-service/src/main/java/com/artedu/intent/service/CustomerProfileService.java new file mode 100644 index 0000000..43cb2c6 --- /dev/null +++ b/backend/intent-service/src/main/java/com/artedu/intent/service/CustomerProfileService.java @@ -0,0 +1,63 @@ +package com.artedu.intent.service; + +import com.artedu.intent.entity.CustomerProfile; +import com.artedu.intent.mapper.CustomerProfileMapper; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.List; + +@Service +public class CustomerProfileService { + + @Autowired + private CustomerProfileMapper customerProfileMapper; + + public List listProfiles(String corpId, String keyword) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(CustomerProfile::getCorpId, corpId); + if (keyword != null && !keyword.trim().isEmpty()) { + wrapper.and(w -> w.like(CustomerProfile::getCustomerId, keyword) + .or() + .like(CustomerProfile::getCurrentOccupation, keyword)); + } + wrapper.orderByDesc(CustomerProfile::getUpdatedAt); + return customerProfileMapper.selectList(wrapper); + } + + public CustomerProfile getProfile(String customerId, String corpId) { + return customerProfileMapper.selectOne( + new LambdaQueryWrapper() + .eq(CustomerProfile::getCustomerId, customerId) + .eq(CustomerProfile::getCorpId, corpId) + ); + } + + public void saveOrUpdateProfile(CustomerProfile profile) { + CustomerProfile exist = customerProfileMapper.selectOne( + new LambdaQueryWrapper() + .eq(CustomerProfile::getCustomerId, profile.getCustomerId()) + .eq(CustomerProfile::getCorpId, profile.getCorpId()) + ); + if (exist == null) { + profile.setId(null); + profile.setCreatedAt(LocalDateTime.now()); + profile.setUpdatedAt(LocalDateTime.now()); + customerProfileMapper.insert(profile); + } else { + profile.setId(exist.getId()); + profile.setUpdatedAt(LocalDateTime.now()); + customerProfileMapper.updateById(profile); + } + } + + public void deleteProfile(String customerId, String corpId) { + customerProfileMapper.delete( + new LambdaQueryWrapper() + .eq(CustomerProfile::getCustomerId, customerId) + .eq(CustomerProfile::getCorpId, corpId) + ); + } +} diff --git a/backend/intent-service/src/main/resources/application.yml b/backend/intent-service/src/main/resources/application.yml index 575f142..a0ecf78 100644 --- a/backend/intent-service/src/main/resources/application.yml +++ b/backend/intent-service/src/main/resources/application.yml @@ -11,7 +11,7 @@ spring: name: intent-service datasource: 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} password: ${MYSQL_PASSWORD:root} redis: diff --git a/backend/intent-service/target/classes/application.yml b/backend/intent-service/target/classes/application.yml index 575f142..a0ecf78 100644 --- a/backend/intent-service/target/classes/application.yml +++ b/backend/intent-service/target/classes/application.yml @@ -11,7 +11,7 @@ spring: name: intent-service datasource: 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} password: ${MYSQL_PASSWORD:root} redis: diff --git a/backend/intent-service/target/classes/com/artedu/intent/IntentServiceApplication.class b/backend/intent-service/target/classes/com/artedu/intent/IntentServiceApplication.class index e708257..dc8dca7 100644 Binary files a/backend/intent-service/target/classes/com/artedu/intent/IntentServiceApplication.class and b/backend/intent-service/target/classes/com/artedu/intent/IntentServiceApplication.class differ diff --git a/backend/intent-service/target/classes/com/artedu/intent/controller/CustomerProfileController.class b/backend/intent-service/target/classes/com/artedu/intent/controller/CustomerProfileController.class new file mode 100644 index 0000000..9840402 Binary files /dev/null and b/backend/intent-service/target/classes/com/artedu/intent/controller/CustomerProfileController.class differ diff --git a/backend/intent-service/target/classes/com/artedu/intent/controller/IntentController.class b/backend/intent-service/target/classes/com/artedu/intent/controller/IntentController.class index 888424c..78775f9 100644 Binary files a/backend/intent-service/target/classes/com/artedu/intent/controller/IntentController.class and b/backend/intent-service/target/classes/com/artedu/intent/controller/IntentController.class differ diff --git a/backend/intent-service/target/classes/com/artedu/intent/dto/IntentResult$IntentInfo.class b/backend/intent-service/target/classes/com/artedu/intent/dto/IntentResult$IntentInfo.class index c3f7d59..ea10301 100644 Binary files a/backend/intent-service/target/classes/com/artedu/intent/dto/IntentResult$IntentInfo.class and b/backend/intent-service/target/classes/com/artedu/intent/dto/IntentResult$IntentInfo.class differ diff --git a/backend/intent-service/target/classes/com/artedu/intent/dto/IntentResult.class b/backend/intent-service/target/classes/com/artedu/intent/dto/IntentResult.class index 736fabb..a7ba670 100644 Binary files a/backend/intent-service/target/classes/com/artedu/intent/dto/IntentResult.class and b/backend/intent-service/target/classes/com/artedu/intent/dto/IntentResult.class differ diff --git a/backend/intent-service/target/classes/com/artedu/intent/entity/CustomerProfile.class b/backend/intent-service/target/classes/com/artedu/intent/entity/CustomerProfile.class index 8bd743c..979e1e9 100644 Binary files a/backend/intent-service/target/classes/com/artedu/intent/entity/CustomerProfile.class and b/backend/intent-service/target/classes/com/artedu/intent/entity/CustomerProfile.class differ diff --git a/backend/intent-service/target/classes/com/artedu/intent/entity/IntentCategory.class b/backend/intent-service/target/classes/com/artedu/intent/entity/IntentCategory.class index 87534f8..cb882c3 100644 Binary files a/backend/intent-service/target/classes/com/artedu/intent/entity/IntentCategory.class and b/backend/intent-service/target/classes/com/artedu/intent/entity/IntentCategory.class differ diff --git a/backend/intent-service/target/classes/com/artedu/intent/entity/IntentRecognition.class b/backend/intent-service/target/classes/com/artedu/intent/entity/IntentRecognition.class index 655a1f6..68e3cde 100644 Binary files a/backend/intent-service/target/classes/com/artedu/intent/entity/IntentRecognition.class and b/backend/intent-service/target/classes/com/artedu/intent/entity/IntentRecognition.class differ diff --git a/backend/intent-service/target/classes/com/artedu/intent/mapper/CustomerProfileMapper.class b/backend/intent-service/target/classes/com/artedu/intent/mapper/CustomerProfileMapper.class index ab21922..612a318 100644 Binary files a/backend/intent-service/target/classes/com/artedu/intent/mapper/CustomerProfileMapper.class and b/backend/intent-service/target/classes/com/artedu/intent/mapper/CustomerProfileMapper.class differ diff --git a/backend/intent-service/target/classes/com/artedu/intent/mapper/IntentCategoryMapper.class b/backend/intent-service/target/classes/com/artedu/intent/mapper/IntentCategoryMapper.class index f7b7643..399bcaa 100644 Binary files a/backend/intent-service/target/classes/com/artedu/intent/mapper/IntentCategoryMapper.class and b/backend/intent-service/target/classes/com/artedu/intent/mapper/IntentCategoryMapper.class differ diff --git a/backend/intent-service/target/classes/com/artedu/intent/mapper/IntentRecognitionMapper.class b/backend/intent-service/target/classes/com/artedu/intent/mapper/IntentRecognitionMapper.class index 8eabddd..545a05c 100644 Binary files a/backend/intent-service/target/classes/com/artedu/intent/mapper/IntentRecognitionMapper.class and b/backend/intent-service/target/classes/com/artedu/intent/mapper/IntentRecognitionMapper.class differ diff --git a/backend/intent-service/target/classes/com/artedu/intent/service/CustomerProfileService.class b/backend/intent-service/target/classes/com/artedu/intent/service/CustomerProfileService.class new file mode 100644 index 0000000..f24fb55 Binary files /dev/null and b/backend/intent-service/target/classes/com/artedu/intent/service/CustomerProfileService.class differ diff --git a/backend/intent-service/target/classes/com/artedu/intent/service/IntentRecognitionService.class b/backend/intent-service/target/classes/com/artedu/intent/service/IntentRecognitionService.class index fa85717..568d877 100644 Binary files a/backend/intent-service/target/classes/com/artedu/intent/service/IntentRecognitionService.class and b/backend/intent-service/target/classes/com/artedu/intent/service/IntentRecognitionService.class differ diff --git a/backend/intent-service/target/classes/com/artedu/intent/service/LLMIntentClient.class b/backend/intent-service/target/classes/com/artedu/intent/service/LLMIntentClient.class index 4eee286..7c2bf97 100644 Binary files a/backend/intent-service/target/classes/com/artedu/intent/service/LLMIntentClient.class and b/backend/intent-service/target/classes/com/artedu/intent/service/LLMIntentClient.class differ diff --git a/backend/intent-service/target/intent-service-1.0.0-SNAPSHOT.jar b/backend/intent-service/target/intent-service-1.0.0-SNAPSHOT.jar index 6ecd845..8763b24 100644 Binary files a/backend/intent-service/target/intent-service-1.0.0-SNAPSHOT.jar and b/backend/intent-service/target/intent-service-1.0.0-SNAPSHOT.jar differ diff --git a/backend/intent-service/target/intent-service-1.0.0-SNAPSHOT.jar.original b/backend/intent-service/target/intent-service-1.0.0-SNAPSHOT.jar.original index c8840d3..0ee3ba3 100644 Binary files a/backend/intent-service/target/intent-service-1.0.0-SNAPSHOT.jar.original and b/backend/intent-service/target/intent-service-1.0.0-SNAPSHOT.jar.original differ diff --git a/backend/intent-service/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst b/backend/intent-service/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst index 7b8ff5c..4a91ffb 100644 --- a/backend/intent-service/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst +++ b/backend/intent-service/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst @@ -1,4 +1,5 @@ com\artedu\intent\IntentServiceApplication.class +com\artedu\intent\controller\CustomerProfileController.class com\artedu\intent\dto\IntentResult$IntentInfo.class com\artedu\intent\service\LLMIntentClient.class com\artedu\intent\mapper\IntentCategoryMapper.class @@ -6,6 +7,7 @@ com\artedu\intent\dto\IntentResult.class com\artedu\intent\entity\CustomerProfile.class com\artedu\intent\mapper\CustomerProfileMapper.class com\artedu\intent\mapper\IntentRecognitionMapper.class +com\artedu\intent\service\CustomerProfileService.class com\artedu\intent\controller\IntentController.class com\artedu\intent\entity\IntentRecognition.class com\artedu\intent\entity\IntentCategory.class diff --git a/backend/intent-service/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst b/backend/intent-service/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst index dfa3898..a49299b 100644 --- a/backend/intent-service/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst +++ b/backend/intent-service/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst @@ -1,3 +1,4 @@ +D:\www\agent_9art\backend\intent-service\src\main\java\com\artedu\intent\controller\CustomerProfileController.java D:\www\agent_9art\backend\intent-service\src\main\java\com\artedu\intent\controller\IntentController.java D:\www\agent_9art\backend\intent-service\src\main\java\com\artedu\intent\dto\IntentResult.java D:\www\agent_9art\backend\intent-service\src\main\java\com\artedu\intent\entity\CustomerProfile.java @@ -7,5 +8,6 @@ D:\www\agent_9art\backend\intent-service\src\main\java\com\artedu\intent\IntentS D:\www\agent_9art\backend\intent-service\src\main\java\com\artedu\intent\mapper\CustomerProfileMapper.java D:\www\agent_9art\backend\intent-service\src\main\java\com\artedu\intent\mapper\IntentCategoryMapper.java D:\www\agent_9art\backend\intent-service\src\main\java\com\artedu\intent\mapper\IntentRecognitionMapper.java +D:\www\agent_9art\backend\intent-service\src\main\java\com\artedu\intent\service\CustomerProfileService.java D:\www\agent_9art\backend\intent-service\src\main\java\com\artedu\intent\service\IntentRecognitionService.java D:\www\agent_9art\backend\intent-service\src\main\java\com\artedu\intent\service\LLMIntentClient.java diff --git a/backend/intent-service/target/test-classes/com/artedu/intent/controller/IntentControllerTest.class b/backend/intent-service/target/test-classes/com/artedu/intent/controller/IntentControllerTest.class index d11ce72..512b5c7 100644 Binary files a/backend/intent-service/target/test-classes/com/artedu/intent/controller/IntentControllerTest.class and b/backend/intent-service/target/test-classes/com/artedu/intent/controller/IntentControllerTest.class differ diff --git a/backend/kb-admin-service/src/main/resources/application.yml b/backend/kb-admin-service/src/main/resources/application.yml index cc918ff..c598db3 100644 --- a/backend/kb-admin-service/src/main/resources/application.yml +++ b/backend/kb-admin-service/src/main/resources/application.yml @@ -11,7 +11,7 @@ spring: name: kb-admin-service datasource: 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} password: ${MYSQL_PASSWORD:root} redis: diff --git a/backend/kb-admin-service/target/classes/application.yml b/backend/kb-admin-service/target/classes/application.yml index cc918ff..c598db3 100644 --- a/backend/kb-admin-service/target/classes/application.yml +++ b/backend/kb-admin-service/target/classes/application.yml @@ -11,7 +11,7 @@ spring: name: kb-admin-service datasource: 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} password: ${MYSQL_PASSWORD:root} redis: diff --git a/backend/kb-admin-service/target/classes/com/artedu/kbadmin/KbAdminServiceApplication.class b/backend/kb-admin-service/target/classes/com/artedu/kbadmin/KbAdminServiceApplication.class index 8f34efc..48c887c 100644 Binary files a/backend/kb-admin-service/target/classes/com/artedu/kbadmin/KbAdminServiceApplication.class and b/backend/kb-admin-service/target/classes/com/artedu/kbadmin/KbAdminServiceApplication.class differ diff --git a/backend/kb-admin-service/target/classes/com/artedu/kbadmin/config/SecurityConfig.class b/backend/kb-admin-service/target/classes/com/artedu/kbadmin/config/SecurityConfig.class index d9b0823..ad553e0 100644 Binary files a/backend/kb-admin-service/target/classes/com/artedu/kbadmin/config/SecurityConfig.class and b/backend/kb-admin-service/target/classes/com/artedu/kbadmin/config/SecurityConfig.class differ diff --git a/backend/kb-admin-service/target/classes/com/artedu/kbadmin/controller/AdminController.class b/backend/kb-admin-service/target/classes/com/artedu/kbadmin/controller/AdminController.class index 1cdc1dd..63e9efb 100644 Binary files a/backend/kb-admin-service/target/classes/com/artedu/kbadmin/controller/AdminController.class and b/backend/kb-admin-service/target/classes/com/artedu/kbadmin/controller/AdminController.class differ diff --git a/backend/kb-admin-service/target/classes/com/artedu/kbadmin/controller/AuthController.class b/backend/kb-admin-service/target/classes/com/artedu/kbadmin/controller/AuthController.class index ecca56d..e54059f 100644 Binary files a/backend/kb-admin-service/target/classes/com/artedu/kbadmin/controller/AuthController.class and b/backend/kb-admin-service/target/classes/com/artedu/kbadmin/controller/AuthController.class differ diff --git a/backend/kb-admin-service/target/classes/com/artedu/kbadmin/controller/KnowledgeController.class b/backend/kb-admin-service/target/classes/com/artedu/kbadmin/controller/KnowledgeController.class index 9d621bb..33a76fa 100644 Binary files a/backend/kb-admin-service/target/classes/com/artedu/kbadmin/controller/KnowledgeController.class and b/backend/kb-admin-service/target/classes/com/artedu/kbadmin/controller/KnowledgeController.class differ diff --git a/backend/kb-admin-service/target/classes/com/artedu/kbadmin/controller/UtteranceController.class b/backend/kb-admin-service/target/classes/com/artedu/kbadmin/controller/UtteranceController.class index 33b93be..2aacfd2 100644 Binary files a/backend/kb-admin-service/target/classes/com/artedu/kbadmin/controller/UtteranceController.class and b/backend/kb-admin-service/target/classes/com/artedu/kbadmin/controller/UtteranceController.class differ diff --git a/backend/kb-admin-service/target/classes/com/artedu/kbadmin/entity/Admin.class b/backend/kb-admin-service/target/classes/com/artedu/kbadmin/entity/Admin.class index f74c20f..6b61bd8 100644 Binary files a/backend/kb-admin-service/target/classes/com/artedu/kbadmin/entity/Admin.class and b/backend/kb-admin-service/target/classes/com/artedu/kbadmin/entity/Admin.class differ diff --git a/backend/kb-admin-service/target/classes/com/artedu/kbadmin/entity/KnowledgeDocument.class b/backend/kb-admin-service/target/classes/com/artedu/kbadmin/entity/KnowledgeDocument.class index 43d2160..d94f3f4 100644 Binary files a/backend/kb-admin-service/target/classes/com/artedu/kbadmin/entity/KnowledgeDocument.class and b/backend/kb-admin-service/target/classes/com/artedu/kbadmin/entity/KnowledgeDocument.class differ diff --git a/backend/kb-admin-service/target/classes/com/artedu/kbadmin/entity/Utterance.class b/backend/kb-admin-service/target/classes/com/artedu/kbadmin/entity/Utterance.class index 80f92dd..084aeea 100644 Binary files a/backend/kb-admin-service/target/classes/com/artedu/kbadmin/entity/Utterance.class and b/backend/kb-admin-service/target/classes/com/artedu/kbadmin/entity/Utterance.class differ diff --git a/backend/kb-admin-service/target/classes/com/artedu/kbadmin/mapper/AdminMapper.class b/backend/kb-admin-service/target/classes/com/artedu/kbadmin/mapper/AdminMapper.class index ba08e67..fe94e70 100644 Binary files a/backend/kb-admin-service/target/classes/com/artedu/kbadmin/mapper/AdminMapper.class and b/backend/kb-admin-service/target/classes/com/artedu/kbadmin/mapper/AdminMapper.class differ diff --git a/backend/kb-admin-service/target/classes/com/artedu/kbadmin/mapper/KnowledgeDocumentMapper.class b/backend/kb-admin-service/target/classes/com/artedu/kbadmin/mapper/KnowledgeDocumentMapper.class index c601916..1c29ea1 100644 Binary files a/backend/kb-admin-service/target/classes/com/artedu/kbadmin/mapper/KnowledgeDocumentMapper.class and b/backend/kb-admin-service/target/classes/com/artedu/kbadmin/mapper/KnowledgeDocumentMapper.class differ diff --git a/backend/kb-admin-service/target/classes/com/artedu/kbadmin/mapper/UtteranceMapper.class b/backend/kb-admin-service/target/classes/com/artedu/kbadmin/mapper/UtteranceMapper.class index 514f399..bf42437 100644 Binary files a/backend/kb-admin-service/target/classes/com/artedu/kbadmin/mapper/UtteranceMapper.class and b/backend/kb-admin-service/target/classes/com/artedu/kbadmin/mapper/UtteranceMapper.class differ diff --git a/backend/kb-admin-service/target/kb-admin-service-1.0.0-SNAPSHOT.jar b/backend/kb-admin-service/target/kb-admin-service-1.0.0-SNAPSHOT.jar index dd60cc4..6c76e06 100644 Binary files a/backend/kb-admin-service/target/kb-admin-service-1.0.0-SNAPSHOT.jar and b/backend/kb-admin-service/target/kb-admin-service-1.0.0-SNAPSHOT.jar differ diff --git a/backend/kb-admin-service/target/kb-admin-service-1.0.0-SNAPSHOT.jar.original b/backend/kb-admin-service/target/kb-admin-service-1.0.0-SNAPSHOT.jar.original index 12ed64c..72c8698 100644 Binary files a/backend/kb-admin-service/target/kb-admin-service-1.0.0-SNAPSHOT.jar.original and b/backend/kb-admin-service/target/kb-admin-service-1.0.0-SNAPSHOT.jar.original differ diff --git a/backend/kb-admin-service/target/test-classes/com/artedu/kbadmin/controller/AuthControllerTest.class b/backend/kb-admin-service/target/test-classes/com/artedu/kbadmin/controller/AuthControllerTest.class index ca9a9d6..21b7686 100644 Binary files a/backend/kb-admin-service/target/test-classes/com/artedu/kbadmin/controller/AuthControllerTest.class and b/backend/kb-admin-service/target/test-classes/com/artedu/kbadmin/controller/AuthControllerTest.class differ diff --git a/backend/kb-admin-service/target/test-classes/com/artedu/kbadmin/controller/UtteranceControllerTest.class b/backend/kb-admin-service/target/test-classes/com/artedu/kbadmin/controller/UtteranceControllerTest.class index b8f7cb9..e81cbc7 100644 Binary files a/backend/kb-admin-service/target/test-classes/com/artedu/kbadmin/controller/UtteranceControllerTest.class and b/backend/kb-admin-service/target/test-classes/com/artedu/kbadmin/controller/UtteranceControllerTest.class differ diff --git a/backend/pom.xml b/backend/pom.xml index 70b7de4..98a6b27 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -27,9 +27,9 @@ - 17 - 17 - 17 + 1.8 + 1.8 + 1.8 UTF-8 2.7.18 diff --git a/backend/recommendation-service/src/main/resources/application.yml b/backend/recommendation-service/src/main/resources/application.yml index b7ea577..4c26539 100644 --- a/backend/recommendation-service/src/main/resources/application.yml +++ b/backend/recommendation-service/src/main/resources/application.yml @@ -11,7 +11,7 @@ spring: name: recommendation-service datasource: 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} password: ${MYSQL_PASSWORD:root} redis: diff --git a/backend/recommendation-service/target/classes/application.yml b/backend/recommendation-service/target/classes/application.yml index b7ea577..4c26539 100644 --- a/backend/recommendation-service/target/classes/application.yml +++ b/backend/recommendation-service/target/classes/application.yml @@ -11,7 +11,7 @@ spring: name: recommendation-service datasource: 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} password: ${MYSQL_PASSWORD:root} redis: diff --git a/backend/recommendation-service/target/classes/com/artedu/recommend/RecommendationServiceApplication.class b/backend/recommendation-service/target/classes/com/artedu/recommend/RecommendationServiceApplication.class index 79bc2e5..61759be 100644 Binary files a/backend/recommendation-service/target/classes/com/artedu/recommend/RecommendationServiceApplication.class and b/backend/recommendation-service/target/classes/com/artedu/recommend/RecommendationServiceApplication.class differ diff --git a/backend/recommendation-service/target/classes/com/artedu/recommend/controller/RecommendController.class b/backend/recommendation-service/target/classes/com/artedu/recommend/controller/RecommendController.class index 60de04b..ba09e6b 100644 Binary files a/backend/recommendation-service/target/classes/com/artedu/recommend/controller/RecommendController.class and b/backend/recommendation-service/target/classes/com/artedu/recommend/controller/RecommendController.class differ diff --git a/backend/recommendation-service/target/classes/com/artedu/recommend/entity/Recommendation.class b/backend/recommendation-service/target/classes/com/artedu/recommend/entity/Recommendation.class index f026bbe..519847d 100644 Binary files a/backend/recommendation-service/target/classes/com/artedu/recommend/entity/Recommendation.class and b/backend/recommendation-service/target/classes/com/artedu/recommend/entity/Recommendation.class differ diff --git a/backend/recommendation-service/target/classes/com/artedu/recommend/entity/Utterance.class b/backend/recommendation-service/target/classes/com/artedu/recommend/entity/Utterance.class index f0190ba..75cf0e5 100644 Binary files a/backend/recommendation-service/target/classes/com/artedu/recommend/entity/Utterance.class and b/backend/recommendation-service/target/classes/com/artedu/recommend/entity/Utterance.class differ diff --git a/backend/recommendation-service/target/classes/com/artedu/recommend/mapper/RecommendationMapper.class b/backend/recommendation-service/target/classes/com/artedu/recommend/mapper/RecommendationMapper.class index 956e760..4ae4269 100644 Binary files a/backend/recommendation-service/target/classes/com/artedu/recommend/mapper/RecommendationMapper.class and b/backend/recommendation-service/target/classes/com/artedu/recommend/mapper/RecommendationMapper.class differ diff --git a/backend/recommendation-service/target/classes/com/artedu/recommend/mapper/UtteranceMapper.class b/backend/recommendation-service/target/classes/com/artedu/recommend/mapper/UtteranceMapper.class index 9b8d098..108800d 100644 Binary files a/backend/recommendation-service/target/classes/com/artedu/recommend/mapper/UtteranceMapper.class and b/backend/recommendation-service/target/classes/com/artedu/recommend/mapper/UtteranceMapper.class differ diff --git a/backend/recommendation-service/target/classes/com/artedu/recommend/service/EmbeddingClient.class b/backend/recommendation-service/target/classes/com/artedu/recommend/service/EmbeddingClient.class index 80bd616..db50bc6 100644 Binary files a/backend/recommendation-service/target/classes/com/artedu/recommend/service/EmbeddingClient.class and b/backend/recommendation-service/target/classes/com/artedu/recommend/service/EmbeddingClient.class differ diff --git a/backend/recommendation-service/target/classes/com/artedu/recommend/service/RankingService$ScoredUtterance.class b/backend/recommendation-service/target/classes/com/artedu/recommend/service/RankingService$ScoredUtterance.class index 500b8b2..8530c54 100644 Binary files a/backend/recommendation-service/target/classes/com/artedu/recommend/service/RankingService$ScoredUtterance.class and b/backend/recommendation-service/target/classes/com/artedu/recommend/service/RankingService$ScoredUtterance.class differ diff --git a/backend/recommendation-service/target/classes/com/artedu/recommend/service/RankingService.class b/backend/recommendation-service/target/classes/com/artedu/recommend/service/RankingService.class index 1339f28..a8d9d4a 100644 Binary files a/backend/recommendation-service/target/classes/com/artedu/recommend/service/RankingService.class and b/backend/recommendation-service/target/classes/com/artedu/recommend/service/RankingService.class differ diff --git a/backend/recommendation-service/target/classes/com/artedu/recommend/service/RecallService.class b/backend/recommendation-service/target/classes/com/artedu/recommend/service/RecallService.class index 278bac7..7224439 100644 Binary files a/backend/recommendation-service/target/classes/com/artedu/recommend/service/RecallService.class and b/backend/recommendation-service/target/classes/com/artedu/recommend/service/RecallService.class differ diff --git a/backend/recommendation-service/target/recommendation-service-1.0.0-SNAPSHOT.jar b/backend/recommendation-service/target/recommendation-service-1.0.0-SNAPSHOT.jar index 1f32470..43e7085 100644 Binary files a/backend/recommendation-service/target/recommendation-service-1.0.0-SNAPSHOT.jar and b/backend/recommendation-service/target/recommendation-service-1.0.0-SNAPSHOT.jar differ diff --git a/backend/recommendation-service/target/recommendation-service-1.0.0-SNAPSHOT.jar.original b/backend/recommendation-service/target/recommendation-service-1.0.0-SNAPSHOT.jar.original index 95367a2..8a1a154 100644 Binary files a/backend/recommendation-service/target/recommendation-service-1.0.0-SNAPSHOT.jar.original and b/backend/recommendation-service/target/recommendation-service-1.0.0-SNAPSHOT.jar.original differ diff --git a/backend/recommendation-service/target/test-classes/com/artedu/recommend/service/RecallServiceTest.class b/backend/recommendation-service/target/test-classes/com/artedu/recommend/service/RecallServiceTest.class index bc2b86c..1142b11 100644 Binary files a/backend/recommendation-service/target/test-classes/com/artedu/recommend/service/RecallServiceTest.class and b/backend/recommendation-service/target/test-classes/com/artedu/recommend/service/RecallServiceTest.class differ diff --git a/fix_customer_names.py b/fix_customer_names.py new file mode 100644 index 0000000..68db52f --- /dev/null +++ b/fix_customer_names.py @@ -0,0 +1,46 @@ +import json +import urllib.request +import subprocess +import time + +token = json.loads(urllib.request.urlopen( + 'https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=wwd483c2fba24ae30a&corpsecret=nU1xezyHyonS9rOJ9Kwo3i5OM9iuWc1TrjXf-rOYZx0' +).read()).get('access_token', '') +print('Token acquired') + +cmd = "mysql -h sh-cdb-6fzlwnms.sql.tencentcdb.com -P 63912 -u assistant_prod -p'U%$4Tu_C3+4' -e 'SET NAMES utf8mb4; SELECT customer_id FROM ai_assistant.customers WHERE name = customer_id;' 2>&1 | grep -v Warning" +result = subprocess.run(cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) +cids = [l.strip() for l in result.stdout.decode('utf-8').strip().split('\n') if l.strip() and not l.startswith('customer_id')] +print('Found %d customers without name' % len(cids)) + +fixed = 0 +failed = 0 +for cid in cids: + try: + url = 'https://qyapi.weixin.qq.com/cgi-bin/externalcontact/get?access_token=' + token + '&external_userid=' + cid + data = json.loads(urllib.request.urlopen(url, timeout=10).read()) + if data.get('errcode') == 0: + c = data.get('external_contact', {}) + name = c.get('name', '') + if name: + updates = ["name='" + name + "'", "avatar='" + c.get('avatar', '') + "'", "type='" + ('企业' if c.get('type') == 1 else '个人' if c.get('type') == 2 else 'UNKNOWN') + "'"] + g = c.get('gender') + if g == 1: updates.append("gender='MALE'") + elif g == 2: updates.append("gender='FEMALE'") + uid = c.get('unionid', '') + if uid: updates.append("unionid='" + uid + "'") + sql = 'UPDATE ai_assistant.customers SET ' + ', '.join(updates) + " WHERE customer_id='" + cid + "';" + subprocess.run(['mysql', '-h', 'sh-cdb-6fzlwnms.sql.tencentcdb.com', '-P', '63912', '-u', 'assistant_prod', '-pU%$4Tu_C3+4', '-e', sql], stdout=subprocess.PIPE, stderr=subprocess.PIPE) + fixed += 1 + print('Fixed: %s -> %s' % (cid, name)) + else: + failed += 1 + else: + failed += 1 + print('Failed: %s -> %d %s' % (cid, data.get('errcode'), str(data.get('errmsg', ''))[:50])) + time.sleep(0.05) + except Exception as e: + failed += 1 + print('Error: %s -> %s' % (cid, str(e)[:80])) + +print('Done. Fixed=%d, Failed=%d' % (fixed, failed)) diff --git a/fix_session_id.py b/fix_session_id.py new file mode 100644 index 0000000..614688d --- /dev/null +++ b/fix_session_id.py @@ -0,0 +1,27 @@ +import re +import subprocess + +cmd = "mysql -h sh-cdb-6fzlwnms.sql.tencentcdb.com -P 63912 -u assistant_prod -p'U%$4Tu_C3+4' -e 'SET NAMES utf8mb4; SELECT id, session_id FROM ai_assistant.archive_messages WHERE session_id LIKE \"%[%\";' 2>&1 | grep -v Warning" +result = subprocess.run(cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) +lines = result.stdout.decode('utf-8').strip().split('\n') + +print('Total lines:', len(lines)) +fixed = 0 +for line in lines: + if not line.strip() or line.startswith('id') or '\t' not in line: + continue + parts = line.split('\t') + if len(parts) < 2: + continue + id_str, session_id = parts[0], parts[1] + # Replace [\"xxx\"] -> xxx or [xxx] -> xxx + new_id = re.sub(r'\[\s*"([^"]+)"\s*\]', r'\1', session_id) + new_id = re.sub(r'\[\s*([^\]]+)\s*\]', r'\1', new_id) + if new_id != session_id: + sql = "UPDATE ai_assistant.archive_messages SET session_id='%s' WHERE id=%s;" % (new_id, id_str) + subprocess.run(['mysql', '-h', 'sh-cdb-6fzlwnms.sql.tencentcdb.com', '-P', '63912', '-u', 'assistant_prod', '-pU%$4Tu_C3+4', '-e', sql], stdout=subprocess.PIPE, stderr=subprocess.PIPE) + fixed += 1 + if fixed % 100 == 0: + print('Fixed %d...' % fixed) + +print('Done. Fixed=%d' % fixed) diff --git a/frontend/admin/nginx.conf b/frontend/admin/nginx.conf index 3599b59..365f580 100644 --- a/frontend/admin/nginx.conf +++ b/frontend/admin/nginx.conf @@ -12,12 +12,20 @@ server { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } + # HTML 文件禁用缓存 + location ~* \.html$ { + add_header Cache-Control "no-cache, no-store, must-revalidate"; + add_header Pragma "no-cache"; + add_header Expires "0"; + try_files $uri $uri/ /index.html; + } + # 静态资源 location / { try_files $uri $uri/ /index.html; } - # 缓存静态资源 + # 缓存静态资源(JS/CSS/图片等) location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { expires 1d; add_header Cache-Control "public, immutable"; diff --git a/frontend/admin/src/App.tsx b/frontend/admin/src/App.tsx index 65bb38a..a0a5243 100644 --- a/frontend/admin/src/App.tsx +++ b/frontend/admin/src/App.tsx @@ -5,6 +5,13 @@ import Dashboard from './pages/Dashboard' import UtteranceList from './pages/UtteranceList' import KnowledgeList from './pages/KnowledgeList' import AdminList from './pages/AdminList' +import CustomerList from './pages/CustomerList' +import ArchiveList from './pages/ArchiveList' +import StaffSessionList from './pages/StaffSessionList' +import CustomerSessionList from './pages/CustomerSessionList' +import SensitiveWordList from './pages/SensitiveWordList' +import RiskEventList from './pages/RiskEventList' +import MessageSearch from './pages/MessageSearch' function App() { return ( @@ -16,7 +23,14 @@ function App() { } /> } /> } /> + } /> } /> + } /> + } /> + } /> + } /> + } /> + } /> diff --git a/frontend/admin/src/components/ChatTimeline.tsx b/frontend/admin/src/components/ChatTimeline.tsx new file mode 100644 index 0000000..2697bb2 --- /dev/null +++ b/frontend/admin/src/components/ChatTimeline.tsx @@ -0,0 +1,280 @@ +import { useState, useEffect, useRef, useCallback } from 'react' +import { Card, Spin, Empty, Tag, Avatar } from 'antd' +import { AudioOutlined, VideoCameraOutlined, FileOutlined, LinkOutlined, EnvironmentOutlined, SmileOutlined, AppstoreOutlined } from '@ant-design/icons' +import request from '../utils/request' + +interface Message { + id: number + msgid: string + fromUser: string + fromRole: string + toUser: string + tolist: string + roomid: string + msgtype: string + msgtime: number + msgTimeStr: string + content: string + mediaData: string + sessionId: string + action: string +} + +interface ChatTimelineProps { + sessionId: string + staffName?: string + customerName?: string +} + +const msgTypeMap: Record = { + text: { label: '文本', color: '#52c41a' }, + image: { label: '图片', color: '#1890ff' }, + voice: { label: '语音', color: '#722ed1' }, + video: { label: '视频', color: '#eb2f96' }, + file: { label: '文件', color: '#fa8c16' }, + link: { label: '链接', color: '#13c2c2' }, + location: { label: '位置', color: '#52c41a' }, + emotion: { label: '表情', color: '#faad14' }, + card: { label: '名片', color: '#2f4554' }, + weapp: { label: '小程序', color: '#1890ff' }, + chatrecord: { label: '聊天记录', color: '#fa8c16' }, + mixed: { label: '混合', color: '#722ed1' }, + redpacket: { label: '红包', color: '#f5222d' }, + transfer: { label: '转账', color: '#fa8c16' }, +} + +export default function ChatTimeline({ sessionId, staffName = '员工', customerName = '客户' }: ChatTimelineProps) { + const [messages, setMessages] = useState([]) + const [loading, setLoading] = useState(false) + const [hasMore, setHasMore] = useState(true) + const [page, setPage] = useState(1) + const scrollRef = useRef(null) + const pageSize = 20 + + const loadMessages = useCallback(async (targetPage: number, append: boolean = false) => { + if (!sessionId) return + setLoading(true) + try { + const res: any = await request.get(`/v1/archive/sessions/${sessionId}/messages`, { + params: { page: targetPage, size: pageSize } + }) + const list = res.data.list || [] + const total = res.data.total || 0 + if (append) { + setMessages(prev => [...prev, ...list]) + } else { + setMessages(list) + } + setHasMore(list.length > 0 && messages.length + list.length < total) + setPage(targetPage) + } catch (e) { + console.error('加载消息失败', e) + } finally { + setLoading(false) + } + }, [sessionId, messages.length]) + + useEffect(() => { + setMessages([]) + setPage(1) + setHasMore(true) + loadMessages(1, false) + }, [sessionId]) + + const handleScroll = useCallback(() => { + const el = scrollRef.current + if (!el || loading || !hasMore) return + if (el.scrollTop + el.clientHeight >= el.scrollHeight - 50) { + loadMessages(page + 1, true) + } + }, [loading, hasMore, page, loadMessages]) + + const formatDate = (msgTimeStr: string) => { + if (!msgTimeStr || msgTimeStr === '-') return '' + return msgTimeStr.substring(0, 16) // yyyy-MM-dd HH:mm + } + + const renderContent = (msg: Message) => { + const type = msg.msgtype + const content = msg.content || '' + + if (type === 'image') { + return ( +
+ 图片 { (e.target as any).style.display = 'none' }} + onClick={() => window.open(`/api/v1/archive/media/${msg.id}`, '_blank')} + /> +
+ ) + } + + if (type === 'emotion') { + return + } + + if (type === 'voice') { + return ( +
+ + {content || '[语音]'} +
+ ) + } + + if (type === 'video') { + return ( +
+ + [视频] +
+ ) + } + + if (type === 'file') { + return ( +
+ + {content || '[文件]'} +
+ ) + } + + if (type === 'link') { + return ( +
+ + {content || '[链接]'} +
+ ) + } + + if (type === 'location') { + return ( +
+ + {content || '[位置]'} +
+ ) + } + + if (type === 'redpacket' || type === 'transfer') { + return ( +
+ {content || `[${type === 'redpacket' ? '红包' : '转账'}]`} +
+ ) + } + + return {content} + } + + const isStaff = (role: string) => role === 'INTERNAL' + + return ( + + 聊天记录 + {staffName} + 与 + {customerName} + + } + bodyStyle={{ padding: 0 }} + > +
+ {messages.length === 0 && !loading && ( + + )} + + {messages.map((msg, index) => { + const staffSide = isStaff(msg.fromRole) + const showTime = index === 0 || ( + messages[index - 1].msgtime && msg.msgtime && + msg.msgtime - messages[index - 1].msgtime > 5 * 60 * 1000 + ) + + return ( +
+ {showTime && ( +
+ + {formatDate(msg.msgTimeStr)} + +
+ )} + +
+ {!staffSide && ( + + {customerName?.charAt(0) || '客'} + + )} + +
+
+ {renderContent(msg)} +
+
+ + {msgTypeMap[msg.msgtype]?.label || msg.msgtype} + + {msg.action === 'recall' && [已撤回]} +
+
+ + {staffSide && ( + + {staffName?.charAt(0) || '员'} + + )} +
+
+ ) + })} + + {loading && ( +
+ +
+ )} + + {!hasMore && messages.length > 0 && ( +
+ 没有更多消息了 +
+ )} +
+
+ ) +} diff --git a/frontend/admin/src/components/Layout.tsx b/frontend/admin/src/components/Layout.tsx index a22263a..d6d066b 100644 --- a/frontend/admin/src/components/Layout.tsx +++ b/frontend/admin/src/components/Layout.tsx @@ -1,7 +1,8 @@ import { useEffect, useState } from 'react' import { Outlet, useNavigate, useLocation } from 'react-router-dom' -import { Layout as AntLayout, Menu, Button, message } from 'antd' -import { DashboardOutlined, CommentOutlined, BookOutlined, TeamOutlined } from '@ant-design/icons' +import { Layout as AntLayout, Menu, Button, message, Badge } from 'antd' +import { DashboardOutlined, CommentOutlined, BookOutlined, TeamOutlined, UserOutlined, SafetyCertificateOutlined } from '@ant-design/icons' +import { connectRiskEventStream, disconnectRiskEventStream, requestNotificationPermission, showDesktopNotification } from '../utils/notification' const { Header, Sider, Content } = AntLayout @@ -9,11 +10,43 @@ export default function Layout() { const navigate = useNavigate() const location = useLocation() const [collapsed, setCollapsed] = useState(false) + const [unreadRiskCount, setUnreadRiskCount] = useState(0) useEffect(() => { const token = localStorage.getItem('admin_token') if (!token) { navigate('/login') + return + } + + // 请求通知权限并连接 SSE(每30分钟汇总推送) + requestNotificationPermission().then(() => { + connectRiskEventStream( + (data) => { + // 收到30分钟汇总通知 + const total = data.totalCount || 0 + const pending = data.pendingCount || 0 + setUnreadRiskCount((prev) => prev + total) + showDesktopNotification( + `【风险事件汇总】过去30分钟新增 ${total} 条`, + pending > 0 ? `其中 ${pending} 条待处理,请及时查看` : '暂无待处理事件', + () => { + navigate('/risk/events') + setUnreadRiskCount(0) + } + ) + if (pending > 0) { + message.warning(`过去30分钟新增 ${total} 条风险事件,${pending} 条待处理`) + } + }, + () => { + console.log('[SSE] 风险事件汇总通知已连接') + } + ) + }) + + return () => { + disconnectRiskEventStream() } }, [navigate]) @@ -21,10 +54,32 @@ export default function Layout() { { key: '/dashboard', icon: , label: '数据看板' }, { key: '/utterances', icon: , label: '话术库管理' }, { key: '/knowledge', icon: , label: '知识库管理' }, + { key: '/customers', icon: , label: '客户画像管理' }, + { + key: '/risk', + icon: , + label: ( + + 会话风控管理 + {unreadRiskCount > 0 && ( + + )} + + ), + children: [ + { key: '/risk/staff-sessions', label: '员工会话管理' }, + { key: '/risk/customer-sessions', label: '客户会话管理' }, + { key: '/risk/message-search', label: '消息检索' }, + { key: '/risk/sensitive-words', label: '敏感词库' }, + { key: '/risk/events', label: '敏感事件监控' }, + { key: '/archive', label: '会话存档管理' }, + ], + }, { key: '/admins', icon: , label: '管理员账号' }, ] const handleLogout = () => { + disconnectRiskEventStream() localStorage.removeItem('admin_token') localStorage.removeItem('admin_info') message.success('已退出登录') @@ -40,14 +95,32 @@ export default function Layout() { navigate(key)} + onClick={({ key }) => { + if (key === '/risk' || key.startsWith('/risk/')) { + setUnreadRiskCount(0) + } + navigate(key) + }} />
- {menuItems.find(i => i.key === location.pathname)?.label || '后台管理'} + {(() => { + const findLabel = (items: any[]): string => { + for (const item of items) { + if (item.key === location.pathname) return item.label + if (item.children) { + const found = findLabel(item.children) + if (found) return found + } + } + return '后台管理' + } + return findLabel(menuItems) + })()}
diff --git a/frontend/admin/src/pages/ArchiveList.tsx b/frontend/admin/src/pages/ArchiveList.tsx new file mode 100644 index 0000000..b403ce6 --- /dev/null +++ b/frontend/admin/src/pages/ArchiveList.tsx @@ -0,0 +1,401 @@ +import { useState, useEffect } from 'react' +import { Table, Input, Select, Tag, Space, Card, Statistic, Row, Col, Button } from 'antd' +import { MessageOutlined, FileTextOutlined, PictureOutlined, AudioOutlined, VideoCameraOutlined, FileOutlined, LinkOutlined, EnvironmentOutlined, SmileOutlined, AppstoreOutlined, WechatOutlined } from '@ant-design/icons' +import request from '../utils/request' + +const { Option } = Select + +export default function ArchiveList() { + const [data, setData] = useState([]) + const [loading, setLoading] = useState(false) + const [pagination, setPagination] = useState({ current: 1, pageSize: 20, total: 0 }) + const [stats, setStats] = useState({ totalCount: 0, todayCount: 0 }) + const [keyword, setKeyword] = useState('') + const [msgtype, setMsgtype] = useState('') + const [fromUser, setFromUser] = useState('') + const [fromRole, setFromRole] = useState('') + + const msgTypeMap: Record = { + text: { label: '文本消息', icon: , color: '#52c41a' }, + image: { label: '图片消息', icon: , color: '#1890ff' }, + voice: { label: '语音消息', icon: , color: '#722ed1' }, + video: { label: '视频消息', icon: , color: '#eb2f96' }, + file: { label: '文件消息', icon: , color: '#fa8c16' }, + link: { label: '链接消息', icon: , color: '#13c2c2' }, + location: { label: '位置消息', icon: , color: '#52c41a' }, + emotion: { label: '表情消息', icon: , color: '#faad14' }, + card: { label: '名片消息', icon: , color: '#2f4554' }, + weapp: { label: '小程序消息', icon: , color: '#1890ff' }, + chatrecord: { label: '聊天记录', icon: , color: '#fa8c16' }, + mixed: { label: '混合消息', icon: , color: '#722ed1' }, + meeting: { label: '会议', icon: , color: '#2f4554' }, + docmsg: { label: '文档', icon: , color: '#1890ff' }, + markdown: { label: 'Markdown', icon: , color: '#13c2c2' }, + news: { label: '图文', icon: , color: '#eb2f96' }, + calendar: { label: '日历', icon: , color: '#52c41a' }, + vote: { label: '投票', icon: , color: '#1890ff' }, + collect: { label: '填表', icon: , color: '#faad14' }, + redpacket: { label: '红包', icon: , color: '#f5222d' }, + transfer: { label: '转账', icon: , color: '#fa8c16' }, + switch: { label: '切换企业', icon: , color: '#2f4554' }, + } + + const roleMap: Record = { + INTERNAL: '员工', + EXTERNAL: '客户', + } + + const actionMap: Record = { + send: '发送消息', + recall: '撤回消息', + switch: '切换企业', + } + + const loadData = async (page = 1, pageSize = 20) => { + setLoading(true) + try { + const res: any = await request.get('/v1/archive/messages', { + params: { + page, + size: pageSize, + keyword: keyword || undefined, + msgtype: msgtype || undefined, + fromUser: fromUser || undefined, + fromRole: fromRole || undefined, + } + }) + setData(res.data.records || []) + setPagination({ + current: res.data.page || 1, + pageSize: res.data.size || 20, + total: res.data.total || 0, + }) + } finally { + setLoading(false) + } + } + + const loadStats = async () => { + try { + const res: any = await request.get('/v1/archive/stats') + setStats(res.data || { totalCount: 0, todayCount: 0 }) + } catch (e) {} + } + + useEffect(() => { + loadData() + loadStats() + }, []) + + const handleSearch = () => { + loadData(1, pagination.pageSize) + } + + const handleReset = () => { + setKeyword('') + setMsgtype('') + setFromUser('') + setFromRole('') + loadData(1, pagination.pageSize) + } + + const handleTableChange = (newPagination: any) => { + loadData(newPagination.current, newPagination.pageSize) + } + + // 解析接收人列表 + const parseReceivers = (toUser: string, tolist: string) => { + try { + const list = JSON.parse(tolist || toUser || '[]') + if (Array.isArray(list)) { + return list.join(', ') + } + return list + } catch { + return toUser || '-' + } + } + + // 渲染消息内容预览 + const renderContent = (record: any) => { + const type = record.msgtype + const content = record.content + const typeInfo = msgTypeMap[type] || { label: type, icon: , color: '#999' } + + if (type === 'image') { + return ( +
+
+ 图片 { (e.target as any).style.display = 'none' }} + /> +
+ [图片] +
+ ) + } + + if (type === 'emotion') { + return ( +
+
+ +
+ [表情] +
+ ) + } + + if (type === 'voice') { + return ( +
+
+ +
+ {content || '[语音]'} +
+ ) + } + + if (type === 'video') { + return ( +
+
+ +
+ [视频] +
+ ) + } + + if (type === 'file') { + return ( +
+
+ +
+ {content || '[文件]'} +
+ ) + } + + return ( +
+ + {content || -} + +
+ ) + } + + const columns = [ + { + title: '消息内容', + dataIndex: 'content', + width: 280, + render: (_: any, record: any) => renderContent(record), + }, + { + title: '消息格式', + dataIndex: 'msgtype', + width: 100, + align: 'center' as const, + render: (type: string) => { + const info = msgTypeMap[type] || { label: type, color: '#999' } + return {info.label} + }, + }, + { + title: '会话类型', + dataIndex: 'roomid', + width: 80, + align: 'center' as const, + render: (roomid: string) => roomid ? 群聊 : 单聊, + }, + { + title: '发送人', + dataIndex: 'fromUser', + width: 120, + render: (fromUser: string, record: any) => ( + {record.fromUserName || fromUser} + ), + }, + { + title: '发送人类型', + dataIndex: 'fromRole', + width: 90, + align: 'center' as const, + render: (role: string) => ( + role === 'INTERNAL' + ? {roleMap[role] || role} + : {roleMap[role] || role} + ), + }, + { + title: '发送时间', + dataIndex: 'msgTimeStr', + width: 160, + render: (time: string) => {time}, + }, + { + title: '消息动作', + dataIndex: 'action', + width: 90, + align: 'center' as const, + render: (action: string) => {actionMap[action] || action}, + }, + { + title: '接收人', + dataIndex: 'toUser', + width: 200, + ellipsis: true, + render: (_: any, record: any) => ( + + {parseReceivers(record.toUser, record.tolist)} + + ), + }, + ] + + return ( +
+ + + + } + /> + + + + + } + /> + + + + + + + +
内容查找
+ setKeyword(e.target.value)} + onPressEnter={handleSearch} + allowClear + /> + + +
消息格式
+ + + +
发送人
+ setFromUser(e.target.value)} + allowClear + /> + + +
发送人类型
+ + + +
 
+ + + + + +
+
+ + `共 ${total} 条`, + }} + onChange={handleTableChange} + /> + + ) +} diff --git a/frontend/admin/src/pages/CustomerList.tsx b/frontend/admin/src/pages/CustomerList.tsx new file mode 100644 index 0000000..aa3e84e --- /dev/null +++ b/frontend/admin/src/pages/CustomerList.tsx @@ -0,0 +1,226 @@ +import { useState, useEffect } from 'react' +import { Table, Button, Tag, Space, Modal, Form, Input, Select, InputNumber, message, Popconfirm } from 'antd' +import request from '../utils/request' + +const CORP_ID = 'wwd483c2fba24ae30a' + +export default function CustomerList() { + const [data, setData] = useState([]) + const [loading, setLoading] = useState(false) + const [modalVisible, setModalVisible] = useState(false) + const [editing, setEditing] = useState(null) + const [form] = Form.useForm() + const [searchKeyword, setSearchKeyword] = useState('') + + const loadData = async (keyword = '') => { + setLoading(true) + try { + const res: any = await request.get('/v1/intent/profiles', { + params: { corpId: CORP_ID, keyword: keyword || undefined } + }) + setData(res.data || []) + } finally { + setLoading(false) + } + } + + useEffect(() => { + loadData() + }, []) + + const handleSave = async () => { + const values = await form.validateFields() + try { + const payload = { ...values, corpId: CORP_ID } + await request.post('/v1/intent/profiles', payload) + message.success(editing ? '更新成功' : '创建成功') + setModalVisible(false) + loadData(searchKeyword) + } catch (e) { + // handled by interceptor + } + } + + const handleDelete = async (customerId: string) => { + try { + await request.delete(`/v1/intent/profiles/${customerId}?corpId=${CORP_ID}`) + message.success('删除成功') + loadData(searchKeyword) + } catch (e) {} + } + + const handleSearch = () => { + loadData(searchKeyword) + } + + const openEdit = (record: any) => { + setEditing(record) + form.setFieldsValue({ + customerId: record.customerId, + studentType: record.studentType, + skillLevel: record.skillLevel, + intentLevel: record.intentLevel, + intentScore: record.intentScore, + concernFocus: record.concernFocus, + decisionStage: record.decisionStage, + interestedCourses: record.interestedCourses, + budgetHint: record.budgetHint, + preferredCity: record.preferredCity, + age: record.age, + education: record.education, + currentOccupation: record.currentOccupation, + }) + setModalVisible(true) + } + + const openCreate = () => { + setEditing(null) + form.resetFields() + setModalVisible(true) + } + + const getIntentColor = (level?: string) => { + switch (level) { + case '高': return 'green' + case '中': return 'orange' + case '低': return 'red' + default: return 'default' + } + } + + const columns = [ + { title: '客户ID', dataIndex: 'customerId', ellipsis: true, width: 180 }, + { title: '职业/名称', dataIndex: 'currentOccupation', width: 120 }, + { title: '学员类型', dataIndex: 'studentType', width: 110 }, + { title: '基础水平', dataIndex: 'skillLevel', width: 110 }, + { + title: '意向度', + dataIndex: 'intentLevel', + width: 90, + render: (level: string, record: any) => ( + {level} {record.intentScore != null ? `(${record.intentScore})` : ''} + ) + }, + { title: '关注重点', dataIndex: 'concernFocus', width: 120 }, + { title: '决策阶段', dataIndex: 'decisionStage', width: 110 }, + { + title: '操作', + width: 140, + render: (_: any, record: any) => ( + + + handleDelete(record.customerId)}> + + + + ) + } + ] + + return ( +
+
+

客户画像管理

+ + setSearchKeyword(e.target.value)} + onSearch={handleSearch} + style={{ width: 260 }} + allowClear + /> + + +
+
+ setModalVisible(false)} + onOk={handleSave} + width={720} + destroyOnClose + > +
+ + + +
+ + + + + + + + + +
+ + + +
+ + + + + + +
+ + + + +
+ + ) +} diff --git a/frontend/admin/src/pages/CustomerSessionList.tsx b/frontend/admin/src/pages/CustomerSessionList.tsx new file mode 100644 index 0000000..f7e62cd --- /dev/null +++ b/frontend/admin/src/pages/CustomerSessionList.tsx @@ -0,0 +1,335 @@ +import { useState, useEffect } from 'react' +import { Table, Input, Card, Avatar, Tag, Button, Modal, Row, Col, Statistic, Space, Spin } from 'antd' +import { UserOutlined } from '@ant-design/icons' +import request from '../utils/request' +import ChatTimeline from '../components/ChatTimeline' + +interface CustomerVO { + customerId: string + name: string + avatar: string + type: string + gender: string + status: string + msgCount: number + todayMsgCount: number + staffCount: number + lastMsgTimeStr: string + firstMsgTimeStr: string + addTime: string +} + +interface SessionVO { + sessionId: string + customerId: string + customerName: string + staffId: string + staffName: string + msgCount: number + lastMsgTimeStr: string + lastMsgContent: string + sessionType: string +} + +export default function CustomerSessionList() { + const [data, setData] = useState([]) + const [loading, setLoading] = useState(false) + const [pagination, setPagination] = useState({ current: 1, pageSize: 20, total: 0 }) + const [keyword, setKeyword] = useState('') + const [staffFilter, setStaffFilter] = useState('') + const [detailVisible, setDetailVisible] = useState(false) + const [selectedCustomer, setSelectedCustomer] = useState(null) + const [sessions, setSessions] = useState([]) + const [sessionLoading, setSessionLoading] = useState(false) + const [chatVisible, setChatVisible] = useState(false) + const [selectedSession, setSelectedSession] = useState(null) + + const loadData = async (page = 1, pageSize = 20) => { + setLoading(true) + try { + const res: any = await request.get('/v1/archive/customers', { + params: { + page, + size: pageSize, + keyword: keyword || undefined, + staffId: staffFilter || undefined, + } + }) + setData(res.data.list || []) + setPagination({ + current: res.data.page || 1, + pageSize: res.data.size || 20, + total: res.data.total || 0, + }) + } finally { + setLoading(false) + } + } + + const loadSessions = async (customerId: string) => { + setSessionLoading(true) + try { + const res: any = await request.get(`/v1/archive/customers/${customerId}/sessions`, { + params: { page: 1, size: 50 } + }) + setSessions(res.data.list || []) + } finally { + setSessionLoading(false) + } + } + + useEffect(() => { + loadData() + }, []) + + const handleSearch = () => { + loadData(1, pagination.pageSize) + } + + const handleTableChange = (newPagination: any) => { + loadData(newPagination.current, newPagination.pageSize) + } + + const openDetail = (record: CustomerVO) => { + setSelectedCustomer(record) + setDetailVisible(true) + loadSessions(record.customerId) + } + + const openChat = (session: SessionVO) => { + setSelectedSession(session) + setChatVisible(true) + } + + const columns = [ + { + title: '客户', + dataIndex: 'name', + width: 200, + render: (_: any, record: CustomerVO) => ( +
+ + {record.name?.charAt(0) || '客'} + +
+
{record.name || record.customerId}
+
{record.customerId}
+
+
+ ), + }, + { + title: '类型', + dataIndex: 'type', + width: 80, + render: (type: string) => ( + + {type || '未知'} + + ), + }, + { + title: '性别', + dataIndex: 'gender', + width: 70, + render: (gender: string) => ( + {gender === 'MALE' ? '男' : gender === 'FEMALE' ? '女' : '-'} + ), + }, + { + title: '状态', + dataIndex: 'status', + width: 80, + render: (status: string) => ( + + {status === 'ACTIVE' ? '正常' : status === 'BLOCKED' ? '拉黑' : status === 'DELETED' ? '删除' : status} + + ), + }, + { + title: '首次添加', + dataIndex: 'addTime', + width: 140, + render: (time: string) => {time ? time.substring(0, 10) : '-'}, + }, + { + title: '消息总数', + dataIndex: 'msgCount', + width: 100, + align: 'center' as const, + render: (count: number) => {count || 0}, + }, + { + title: '今日消息', + dataIndex: 'todayMsgCount', + width: 100, + align: 'center' as const, + render: (count: number) => ( + 0 ? '#52c41a' : '#999' }}>{count || 0} + ), + }, + { + title: '关联员工', + dataIndex: 'staffCount', + width: 100, + align: 'center' as const, + render: (count: number) => {count || 0}, + }, + { + title: '最近消息', + dataIndex: 'lastMsgTimeStr', + width: 160, + render: (time: string) => {time || '-'}, + }, + { + title: '操作', + width: 120, + render: (_: any, record: CustomerVO) => ( + + ), + }, + ] + + return ( +
+ +
+ + } /> + + + + + + + setKeyword(e.target.value)} + onPressEnter={handleSearch} + style={{ width: 240 }} + allowClear + /> + setStaffFilter(e.target.value)} + style={{ width: 180 }} + allowClear + /> + + + + +
`共 ${total} 人`, + }} + onChange={handleTableChange} + /> + + {/* 客户详情弹窗 */} + + + {selectedCustomer?.name?.charAt(0)} + + {selectedCustomer?.name || selectedCustomer?.customerId} 的会话 + + } + open={detailVisible} + onCancel={() => setDetailVisible(false)} + width={800} + footer={null} + > + +
( + + {type === 'GROUP' ? '群聊' : '单聊'} + + ), + }, + { + title: '员工', + dataIndex: 'staffName', + render: (name: string, record: SessionVO) => ( +
+ + {name?.charAt(0) || '员'} + + {name || record.staffId} +
+ ), + }, + { + title: '消息数', + dataIndex: 'msgCount', + width: 80, + align: 'center' as const, + }, + { + title: '最近消息', + dataIndex: 'lastMsgContent', + ellipsis: true, + render: (content: string) => {content || '-'}, + }, + { + title: '时间', + dataIndex: 'lastMsgTimeStr', + width: 150, + }, + { + title: '操作', + width: 100, + render: (_: any, record: SessionVO) => ( + + ), + }, + ]} + /> + + + + {/* 聊天时间线弹窗 */} + setChatVisible(false)} + width={720} + footer={null} + > + {selectedSession && ( + + )} + + + ) +} diff --git a/frontend/admin/src/pages/Dashboard.tsx b/frontend/admin/src/pages/Dashboard.tsx index adc4728..8e7d0f7 100644 --- a/frontend/admin/src/pages/Dashboard.tsx +++ b/frontend/admin/src/pages/Dashboard.tsx @@ -4,9 +4,12 @@ import { } from 'antd' import { ArrowUpOutlined, MessageOutlined, UserOutlined, LikeOutlined, - ClockCircleOutlined, TeamOutlined, CommentOutlined, TrophyOutlined + ClockCircleOutlined, TeamOutlined, CommentOutlined, TrophyOutlined, + SafetyCertificateOutlined, WarningOutlined, FileTextOutlined } from '@ant-design/icons' import type { ColumnsType } from 'antd/es/table' +import { Line, Pie } from '@ant-design/charts' +import request from '../utils/request' interface DashboardData { todayConversations: number @@ -47,6 +50,9 @@ export default function Dashboard() { const [dashboard, setDashboard] = useState(null) const [utterancePerf, setUtterancePerf] = useState([]) const [staffPerf, setStaffPerf] = useState([]) + const [archiveStats, setArchiveStats] = useState(null) + const [trendData, setTrendData] = useState([]) + const [msgTypeData, setMsgTypeData] = useState([]) const corpId = 'wwd483c2fba24ae30a' @@ -60,11 +66,14 @@ export default function Dashboard() { const token = localStorage.getItem('admin_token') || '' const headers = { 'Authorization': 'Bearer ' + token } - // 并行请求三个接口 - const [dashRes, utterRes, staffRes] = await Promise.all([ + // 并行请求所有接口 + const [dashRes, utterRes, staffRes, statsRes, trendRes, typeRes] = await Promise.all([ fetch(`/api/v1/analytics/dashboard?corpId=${corpId}`, { headers }), fetch(`/api/v1/analytics/utterance-performance?corpId=${corpId}&days=${days}`, { headers }), - fetch(`/api/v1/analytics/staff-performance?corpId=${corpId}&days=${days}`, { headers }) + fetch(`/api/v1/analytics/staff-performance?corpId=${corpId}&days=${days}`, { headers }), + request.get('/v1/archive/stats'), + request.get('/v1/archive/dashboard/trend?days=' + days), + request.get('/v1/archive/dashboard/msg-type-distribution') ]) const dashData = await dashRes.json() @@ -74,6 +83,29 @@ export default function Dashboard() { if (dashData.code === 0) setDashboard(dashData.data) if (utterData.code === 0) setUtterancePerf(utterData.data || []) if (staffData.code === 0) setStaffPerf(staffData.data || []) + + const statsResAny: any = statsRes + const trendResAny: any = trendRes + const typeResAny: any = typeRes + if (statsResAny.code === 0) setArchiveStats(statsResAny.data) + if (trendResAny.code === 0) { + const d = trendResAny.data + if (d && d.dates) { + const list: any[] = [] + d.dates.forEach((date: string, i: number) => { + list.push({ date, value: d.messageCounts[i] || 0, category: '消息量' }) + list.push({ date, value: d.riskCounts[i] || 0, category: '风险事件' }) + list.push({ date, value: d.sensitiveHitCounts[i] || 0, category: '敏感词命中' }) + }) + setTrendData(list) + } + } + if (typeResAny.code === 0) { + setMsgTypeData((typeResAny.data || []).map((item: any) => ({ + type: item.name || 'unknown', + value: Number(item.value) || 0, + }))) + } } catch (e) { message.error('加载数据失败') } finally { @@ -287,6 +319,106 @@ export default function Dashboard() { + {/* 会话风控统计 */} +

会话风控概览

+ +
+ + } + /> + + + + + } + /> + + + + + } + /> + + + + + } + valueStyle={{ color: '#cf1322' }} + /> + + + + + } + valueStyle={{ color: '#fa8c16' }} + /> + + + + + } + valueStyle={{ color: '#3f8600' }} + /> + + + + + {/* 趋势图表 */} + + + + {trendData.length > 0 ? ( + + ) : ( +
暂无数据
+ )} +
+ + + + {msgTypeData.length > 0 ? ( + + ) : ( +
暂无数据
+ )} +
+ + + {/* 话术绩效排行 */} 话术绩效排行(近{days}天)} diff --git a/frontend/admin/src/pages/MessageSearch.tsx b/frontend/admin/src/pages/MessageSearch.tsx new file mode 100644 index 0000000..d148f2f --- /dev/null +++ b/frontend/admin/src/pages/MessageSearch.tsx @@ -0,0 +1,258 @@ +import { useState } from 'react' +import { Table, Card, Tag, Button, Row, Col, Form, Input, Select, DatePicker, Space, Modal } from 'antd' +import { SearchOutlined, MessageOutlined } from '@ant-design/icons' +import dayjs from 'dayjs' +import request from '../utils/request' +import ChatTimeline from '../components/ChatTimeline' + +interface ArchiveMessage { + id: number + msgid: string + fromUser: string + fromRole: string + toUser: string + msgtype: string + content: string + msgtime: number + sessionId: string +} + +const msgTypeMap: Record = { + text: { label: '文本', color: 'blue' }, + image: { label: '图片', color: 'green' }, + voice: { label: '语音', color: 'orange' }, + video: { label: '视频', color: 'purple' }, + file: { label: '文件', color: 'cyan' }, + emotion: { label: '表情', color: 'magenta' }, + link: { label: '链接', color: 'geekblue' }, + weapp: { label: '小程序', color: 'lime' }, + location: { label: '位置', color: 'gold' }, + redpacket: { label: '红包', color: 'red' }, + transfer: { label: '转账', color: 'volcano' }, + meeting_voice_call: { label: '通话', color: 'default' }, +} + +export default function MessageSearch() { + const [form] = Form.useForm() + const [messages, setMessages] = useState([]) + const [loading, setLoading] = useState(false) + const [pagination, setPagination] = useState({ current: 1, pageSize: 20, total: 0 }) + const [searched, setSearched] = useState(false) + const [chatVisible, setChatVisible] = useState(false) + const [chatSessionId, setChatSessionId] = useState('') + + const loadMessages = async (page = 1, pageSize = 20, searchParams?: any) => { + setLoading(true) + try { + const params: any = { page, size: pageSize, ...searchParams } + const res: any = await request.get('/v1/archive/messages', { params }) + setMessages(res.data.list || []) + setPagination({ + current: res.data.page || 1, + pageSize: res.data.size || 20, + total: res.data.total || 0, + }) + } finally { + setLoading(false) + } + } + + const handleSearch = (values: any) => { + const params: any = {} + if (values.keyword) params.keyword = values.keyword + if (values.msgtype) params.msgtype = values.msgtype + if (values.fromUser) params.fromUser = values.fromUser + if (values.toUser) params.toUser = values.toUser + if (values.timeRange && values.timeRange.length === 2) { + params.startTime = values.timeRange[0].valueOf() + params.endTime = values.timeRange[1].valueOf() + } + setSearched(true) + loadMessages(1, 20, params) + } + + const handleReset = () => { + form.resetFields() + setMessages([]) + setSearched(false) + setPagination({ current: 1, pageSize: 20, total: 0 }) + } + + const handlePageChange = (page: number, pageSize?: number) => { + const values = form.getFieldsValue() + const params: any = {} + if (values.keyword) params.keyword = values.keyword + if (values.msgtype) params.msgtype = values.msgtype + if (values.fromUser) params.fromUser = values.fromUser + if (values.toUser) params.toUser = values.toUser + if (values.timeRange && values.timeRange.length === 2) { + params.startTime = values.timeRange[0].valueOf() + params.endTime = values.timeRange[1].valueOf() + } + loadMessages(page, pageSize || 20, params) + } + + const formatTime = (ts: number) => { + if (!ts) return '-' + return dayjs(ts).format('YYYY-MM-DD HH:mm:ss') + } + + const getSenderLabel = (role: string, userId: string) => { + if (role === 'INTERNAL') return `员工:${userId}` + if (role === 'EXTERNAL') return `客户:${userId}` + return userId + } + + const columns = [ + { + title: '消息内容', + dataIndex: 'content', + key: 'content', + width: 400, + render: (content: string, record: ArchiveMessage) => { + const text = content || '[非文本消息]' + const display = text.length > 80 ? text.substring(0, 80) + '...' : text + return ( +
+ + {msgTypeMap[record.msgtype]?.label || record.msgtype} + + {display} +
+ ) + }, + }, + { + title: '发送者', + dataIndex: 'fromUser', + key: 'fromUser', + width: 150, + render: (fromUser: string, record: ArchiveMessage) => ( + + {getSenderLabel(record.fromRole, fromUser)} + + ), + }, + { + title: '接收者', + dataIndex: 'toUser', + key: 'toUser', + width: 150, + render: (toUser: string) => toUser || '-', + }, + { + title: '发送时间', + dataIndex: 'msgtime', + key: 'msgtime', + width: 170, + render: (msgtime: number) => formatTime(msgtime), + }, + { + title: '操作', + key: 'action', + width: 120, + render: (_: any, record: ArchiveMessage) => ( + + ), + }, + ] + + return ( +
+

消息检索

+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {searched && ( + +
+ 共找到 {pagination.total} 条消息 +
+
`共 ${total} 条`, + onChange: handlePageChange, + }} + /> + + )} + + setChatVisible(false)} + width={800} + footer={null} + destroyOnClose + > + + + + ) +} diff --git a/frontend/admin/src/pages/RiskEventList.tsx b/frontend/admin/src/pages/RiskEventList.tsx new file mode 100644 index 0000000..89ae16d --- /dev/null +++ b/frontend/admin/src/pages/RiskEventList.tsx @@ -0,0 +1,354 @@ +import { useState, useEffect } from 'react' +import { Table, Card, Tag, Button, Modal, Select, Row, Col, Statistic, Space, Form, Input, message } from 'antd' +import { WarningOutlined, CheckCircleOutlined, ExclamationCircleOutlined, BellOutlined, DownloadOutlined } from '@ant-design/icons' +import request from '../utils/request' + +interface RiskEvent { + id: number + eventType: string + eventName: string + status: number + userId: string + userName: string + userRole: string + relatedUser: string + relatedUserName: string + description: string + msgId: number + sessionId: string + occurredAt: string + reportAt: string + handler: string + handleNote: string +} + +interface RiskConfig { + id: number + eventType: string + eventName: string + enabled: number + threshold: number + description: string +} + +const statusMap: Record = { + 0: { label: '待处理', color: 'red' }, + 1: { label: '已处理', color: 'green' }, + 2: { label: '无需处理', color: 'default' }, +} + +const eventTypeColors: Record = { + RED_PACKET: 'red', + RECALL_MSG: 'orange', + NIGHT_CHAT: 'purple', + TIMEOUT_NO_REPLY: 'blue', + CUSTOMER_TIMEOUT: 'cyan', + SENSITIVE_WORD: 'magenta', + DEL_FRIEND: 'volcano', +} + +export default function RiskEventList() { + const [events, setEvents] = useState([]) + const [configs, setConfigs] = useState([]) + const [loading, setLoading] = useState(false) + const [configLoading, setConfigLoading] = useState(false) + const [statusFilter, setStatusFilter] = useState(undefined) + const [typeFilter, setTypeFilter] = useState(undefined) + const [handleModalVisible, setHandleModalVisible] = useState(false) + const [selectedEvent, setSelectedEvent] = useState(null) + const [form] = Form.useForm() + const [activeTab, setActiveTab] = useState('events') + const [pagination, setPagination] = useState({ current: 1, pageSize: 20, total: 0 }) + const [stats, setStats] = useState({ total: 0, pending: 0, handled: 0, ignored: 0 }) + + const loadEvents = async (page = 1, pageSize = 20) => { + setLoading(true) + try { + const res: any = await request.get('/v1/archive/risk-events', { + params: { + status: statusFilter, + eventType: typeFilter, + page, + size: pageSize, + } + }) + setEvents(res.data.list || []) + setPagination({ + current: res.data.page || 1, + pageSize: res.data.size || 20, + total: res.data.total || 0, + }) + } finally { + setLoading(false) + } + } + + const loadStats = async () => { + try { + const res: any = await request.get('/v1/archive/risk-events/stats') + setStats(res.data || { total: 0, pending: 0, handled: 0, ignored: 0 }) + } catch (e) {} + } + + const loadConfigs = async () => { + setConfigLoading(true) + try { + const res: any = await request.get('/v1/archive/risk-configs') + setConfigs(res.data || []) + } finally { + setConfigLoading(false) + } + } + + useEffect(() => { + loadEvents() + loadStats() + loadConfigs() + }, [statusFilter, typeFilter]) + + const handleEvent = (values: any) => { + if (!selectedEvent) return + request.put(`/v1/archive/risk-events/${selectedEvent.id}/handle`, values).then((res: any) => { + if (res.code === 0) { + message.success('处理成功') + setHandleModalVisible(false) + loadEvents() + loadStats() + } + }) + } + + const toggleConfig = (config: RiskConfig) => { + request.put(`/v1/archive/risk-configs/${config.id}`, { + enabled: config.enabled === 1 ? 0 : 1 + }).then((res: any) => { + if (res.code === 0) { + loadConfigs() + } + }) + } + + const handleTableChange = (newPagination: any) => { + loadEvents(newPagination.current, newPagination.pageSize) + } + + const eventColumns = [ + { + title: '事件类型', + dataIndex: 'eventType', + width: 120, + render: (type: string, record: RiskEvent) => ( + {record.eventName || type} + ), + }, + { + title: '状态', + dataIndex: 'status', + width: 100, + render: (status: number) => ( + {statusMap[status]?.label} + ), + }, + { + title: '当事人', + dataIndex: 'userId', + width: 150, + render: (id: string, record: RiskEvent) => ( + + {record.userName || id} + + {record.userRole === 'INTERNAL' ? '员工' : '客户'} + + + ), + }, + { + title: '关联人', + dataIndex: 'relatedUser', + width: 150, + render: (id: string, record: RiskEvent) => ( + {record.relatedUserName || id || '-'} + ), + }, + { + title: '事件描述', + dataIndex: 'description', + ellipsis: true, + render: (desc: string) => {desc || '-'}, + }, + { + title: '发生时间', + dataIndex: 'occurredAt', + width: 160, + render: (time: string) => {time ? time.substring(0, 16) : '-'}, + }, + { + title: '操作', + width: 120, + render: (_: any, record: RiskEvent) => ( + record.status === 0 ? ( + + ) : ( + + {record.handler || '已处理'} + + ) + ), + }, + ] + + return ( +
+ {activeTab === 'events' && ( + <> + +
+ + } /> + + + + + } /> + + + + + } /> + + + + + } /> + + + + + + + + + + + + + +
`共 ${total} 条`, + }} + onChange={handleTableChange} + /> + + )} + + {activeTab === 'config' && ( + +
( + + ), + }, + { title: '阈值', dataIndex: 'threshold', width: 100, render: (t: number) => t != null ? `${t}分钟` : '-' }, + { title: '说明', dataIndex: 'description' }, + ]} + /> + + )} + + {/* 处理弹窗 */} + setHandleModalVisible(false)} + onOk={() => form.submit()} + > +
+ + + + + + + + + + +
+ + {/* 底部切换 */} +
+ + + + +
+ + ) +} diff --git a/frontend/admin/src/pages/SensitiveWordList.tsx b/frontend/admin/src/pages/SensitiveWordList.tsx new file mode 100644 index 0000000..62570fe --- /dev/null +++ b/frontend/admin/src/pages/SensitiveWordList.tsx @@ -0,0 +1,273 @@ +import { useState, useEffect } from 'react' +import { Table, Input, Card, Button, Tag, Switch, Modal, Form, Select, Space, message, Tabs } from 'antd' +import { PlusOutlined, ImportOutlined, DownloadOutlined } from '@ant-design/icons' +import request from '../utils/request' + +interface SensitiveWord { + id: number + word: string + category: string + targetType: string + chatType: string + matchCount: number + status: number +} + +interface HitRecord { + id: number + word: string + msgContent: string + senderId: string + senderRole: string + sessionId: string + createdAt: string +} + +export default function SensitiveWordList() { + const [activeTab, setActiveTab] = useState('words') + const [words, setWords] = useState([]) + const [hits, setHits] = useState([]) + const [loading, setLoading] = useState(false) + const [hitLoading, setHitLoading] = useState(false) + const [modalVisible, setModalVisible] = useState(false) + const [batchModalVisible, setBatchModalVisible] = useState(false) + const [batchText, setBatchText] = useState('') + const [editingWord, setEditingWord] = useState(null) + const [form] = Form.useForm() + + const loadWords = async () => { + setLoading(true) + try { + const res: any = await request.get('/v1/archive/sensitive-words') + setWords(res.data || []) + } finally { + setLoading(false) + } + } + + const loadHits = async () => { + setHitLoading(true) + try { + const res: any = await request.get('/v1/archive/sensitive-word-hits') + setHits(res.data || []) + } finally { + setHitLoading(false) + } + } + + useEffect(() => { + loadWords() + loadHits() + }, []) + + const handleAdd = (values: any) => { + const url = editingWord + ? `/v1/archive/sensitive-words/${editingWord.id}` + : '/v1/archive/sensitive-words' + const method = editingWord ? 'put' : 'post' + request[method](url, values).then((res: any) => { + if (res.code === 0) { + message.success(editingWord ? '更新成功' : '添加成功') + setModalVisible(false) + loadWords() + } else { + message.error(res.message) + } + }) + } + + const handleDelete = (id: number) => { + request.delete(`/v1/archive/sensitive-words/${id}`).then((res: any) => { + if (res.code === 0) { + message.success('删除成功') + loadWords() + } + }) + } + + const handleBatchImport = () => { + request.post('/v1/archive/sensitive-words/batch', batchText, { + headers: { 'Content-Type': 'text/plain' } + }).then((res: any) => { + if (res.code === 0) { + message.success(res.data) + setBatchModalVisible(false) + setBatchText('') + loadWords() + } else { + message.error(res.message) + } + }) + } + + const toggleStatus = (record: SensitiveWord) => { + request.put(`/v1/archive/sensitive-words/${record.id}`, { + status: record.status === 1 ? 0 : 1 + }).then((res: any) => { + if (res.code === 0) { + loadWords() + } + }) + } + + const openEdit = (record: SensitiveWord) => { + setEditingWord(record) + form.setFieldsValue(record) + setModalVisible(true) + } + + const openAdd = () => { + setEditingWord(null) + form.resetFields() + setModalVisible(true) + } + + const wordColumns = [ + { title: '敏感词', dataIndex: 'word', width: 120 }, + { title: '分类', dataIndex: 'category', width: 100, render: (c: string) => {c || '默认'} }, + { title: '适用对象', dataIndex: 'targetType', width: 100 }, + { title: '适用会话', dataIndex: 'chatType', width: 100 }, + { + title: '命中次数', + dataIndex: 'matchCount', + width: 100, + align: 'center' as const, + render: (count: number) => 0 ? '#f5222d' : '#999' }}>{count || 0}, + }, + { + title: '状态', + dataIndex: 'status', + width: 100, + align: 'center' as const, + render: (status: number, record: SensitiveWord) => ( + toggleStatus(record)} + checkedChildren="启用" + unCheckedChildren="禁用" + /> + ), + }, + { + title: '操作', + width: 150, + render: (_: any, record: SensitiveWord) => ( + + + + + ), + }, + ] + + const hitColumns = [ + { title: '敏感词', dataIndex: 'word', width: 100, render: (w: string) => {w} }, + { + title: '消息内容', + dataIndex: 'msgContent', + ellipsis: true, + render: (content: string) => {content || '-'}, + }, + { + title: '发送人', + dataIndex: 'senderId', + width: 150, + render: (id: string, record: HitRecord) => ( + + {id} + + {record.senderRole === 'INTERNAL' ? '员工' : '客户'} + + + ), + }, + { title: '会话ID', dataIndex: 'sessionId', width: 200, ellipsis: true }, + { title: '命中时间', dataIndex: 'createdAt', width: 160 }, + ] + + return ( +
+ + + + + + + + +
+ + + +
+ +
+
+ + + + {/* 新增/编辑弹窗 */} + setModalVisible(false)} + onOk={() => form.submit()} + > +
+ + + + + + + + + + + + + +
+ + {/* 批量导入弹窗 */} + setBatchModalVisible(false)} + onOk={handleBatchImport} + > +

每行一个词,或用逗号/分号分隔

+ setBatchText(e.target.value)} + placeholder="退费 退款 举报 退钱 退学 投诉" + /> +
+ + ) +} diff --git a/frontend/admin/src/pages/StaffSessionList.tsx b/frontend/admin/src/pages/StaffSessionList.tsx new file mode 100644 index 0000000..a9abb12 --- /dev/null +++ b/frontend/admin/src/pages/StaffSessionList.tsx @@ -0,0 +1,332 @@ +import { useState, useEffect } from 'react' +import { Table, Input, Card, Avatar, Tag, Button, Modal, Row, Col, Statistic, Space, Spin, Radio } from 'antd' +import { TeamOutlined } from '@ant-design/icons' +import request from '../utils/request' +import ChatTimeline from '../components/ChatTimeline' + +interface StaffVO { + staffId: string + name: string + avatar: string + department: string + role: string + status: string + msgCount: number + todayMsgCount: number + customerCount: number + lastMsgTimeStr: string +} + +interface SessionVO { + sessionId: string + customerId: string + customerName: string + staffId: string + staffName: string + msgCount: number + lastMsgTimeStr: string + lastMsgContent: string + sessionType: string +} + +export default function StaffSessionList() { + const [data, setData] = useState([]) + const [loading, setLoading] = useState(false) + const [pagination, setPagination] = useState({ current: 1, pageSize: 20, total: 0 }) + const [keyword, setKeyword] = useState('') + const [hasArchive, setHasArchive] = useState(true) + const [detailVisible, setDetailVisible] = useState(false) + const [selectedStaff, setSelectedStaff] = useState(null) + const [sessions, setSessions] = useState([]) + const [sessionLoading, setSessionLoading] = useState(false) + const [chatVisible, setChatVisible] = useState(false) + const [selectedSession, setSelectedSession] = useState(null) + + const loadData = async (page = 1, pageSize = 20) => { + setLoading(true) + try { + const res: any = await request.get('/v1/archive/staffs', { + params: { page, size: pageSize, keyword: keyword || undefined, hasArchive } + }) + setData(res.data.list || []) + setPagination({ + current: res.data.page || 1, + pageSize: res.data.size || 20, + total: res.data.total || 0, + }) + } finally { + setLoading(false) + } + } + + const loadSessions = async (staffId: string) => { + setSessionLoading(true) + try { + const res: any = await request.get(`/v1/archive/staffs/${staffId}/sessions`, { + params: { page: 1, size: 50 } + }) + setSessions(res.data.list || []) + } finally { + setSessionLoading(false) + } + } + + useEffect(() => { + loadData() + }, []) + + const handleSearch = () => { + loadData(1, pagination.pageSize) + } + + const handleFilterChange = (value: boolean) => { + setHasArchive(value) + loadData(1, pagination.pageSize) + } + + const handleTableChange = (newPagination: any) => { + loadData(newPagination.current, newPagination.pageSize) + } + + const openDetail = (record: StaffVO) => { + setSelectedStaff(record) + setDetailVisible(true) + loadSessions(record.staffId) + } + + const openChat = (session: SessionVO) => { + setSelectedSession(session) + setChatVisible(true) + } + + const columns = [ + { + title: '员工', + dataIndex: 'name', + width: 200, + render: (_: any, record: StaffVO) => ( +
+ + {record.name?.charAt(0) || '?'} + +
+
{record.name || record.staffId}
+
{record.staffId}
+
+
+ ), + }, + { + title: '部门', + dataIndex: 'department', + width: 120, + render: (dept: string) => { + try { + const arr = JSON.parse(dept) + return {Array.isArray(arr) ? arr.join(', ') : dept} + } catch { + return {dept || '-'} + } + }, + }, + { + title: '角色', + dataIndex: 'role', + width: 100, + render: (role: string) => {role || '顾问'}, + }, + { + title: '状态', + dataIndex: 'status', + width: 80, + render: (status: string) => ( + + {status === 'ACTIVE' ? '在职' : '离职'} + + ), + }, + { + title: '消息总数', + dataIndex: 'msgCount', + width: 100, + align: 'center' as const, + render: (count: number) => {count || 0}, + }, + { + title: '今日消息', + dataIndex: 'todayMsgCount', + width: 100, + align: 'center' as const, + render: (count: number) => ( + 0 ? '#52c41a' : '#999' }}>{count || 0} + ), + }, + { + title: '客户数', + dataIndex: 'customerCount', + width: 100, + align: 'center' as const, + render: (count: number) => ( + {count || 0} + ), + }, + { + title: '最近消息', + dataIndex: 'lastMsgTimeStr', + width: 160, + render: (time: string) => {time || '-'}, + }, + { + title: '操作', + width: 120, + render: (_: any, record: StaffVO) => ( + + ), + }, + ] + + return ( +
+ +
+ + } /> + + + + + + + setKeyword(e.target.value)} + onPressEnter={handleSearch} + style={{ width: 240 }} + allowClear + /> + + handleFilterChange(e.target.value)} + optionType="button" + buttonStyle="solid" + > + 有会话存档 + 全部员工 + + + + +
`共 ${total} 人`, + }} + onChange={handleTableChange} + /> + + {/* 员工详情弹窗 */} + + + {selectedStaff?.name?.charAt(0)} + + {selectedStaff?.name || selectedStaff?.staffId} 的会话 + + } + open={detailVisible} + onCancel={() => setDetailVisible(false)} + width={800} + footer={null} + > + +
( + + {type === 'GROUP' ? '群聊' : '单聊'} + + ), + }, + { + title: '客户', + dataIndex: 'customerName', + render: (name: string, record: SessionVO) => ( +
+ + {name?.charAt(0) || '客'} + + {name || record.customerId} +
+ ), + }, + { + title: '消息数', + dataIndex: 'msgCount', + width: 80, + align: 'center' as const, + }, + { + title: '最近消息', + dataIndex: 'lastMsgContent', + ellipsis: true, + render: (content: string) => {content || '-'}, + }, + { + title: '时间', + dataIndex: 'lastMsgTimeStr', + width: 150, + }, + { + title: '操作', + width: 100, + render: (_: any, record: SessionVO) => ( + + ), + }, + ]} + /> + + + + {/* 聊天时间线弹窗 */} + setChatVisible(false)} + width={720} + footer={null} + > + {selectedSession && ( + + )} + + + ) +} diff --git a/frontend/admin/src/utils/notification.ts b/frontend/admin/src/utils/notification.ts new file mode 100644 index 0000000..737ed9e --- /dev/null +++ b/frontend/admin/src/utils/notification.ts @@ -0,0 +1,94 @@ +let eventSource: EventSource | null = null +let reconnectTimer: ReturnType | null = null + +const EVENT_ENDPOINT = '/api/v1/archive/risk-events/stream' + +/** + * 请求浏览器通知权限 + */ +export async function requestNotificationPermission(): Promise { + if (!('Notification' in window)) { + console.log('浏览器不支持桌面通知') + return false + } + if (Notification.permission === 'granted') return true + if (Notification.permission === 'denied') return false + const permission = await Notification.requestPermission() + return permission === 'granted' +} + +/** + * 显示浏览器桌面通知 + */ +export function showDesktopNotification(title: string, body: string, onClick?: () => void) { + if (Notification.permission !== 'granted') return + const notification = new Notification(title, { + body, + icon: '/admin/favicon.ico', + badge: '/admin/favicon.ico', + tag: 'risk-summary-' + Date.now(), + }) + notification.onclick = () => { + window.focus() + if (onClick) onClick() + notification.close() + } + setTimeout(() => notification.close(), 15000) +} + +/** + * 连接 SSE 接收风险事件汇总通知 + */ +export function connectRiskEventStream( + onSummary: (data: any) => void, + onConnected?: () => void +) { + disconnectRiskEventStream() + + const baseUrl = window.location.origin + const url = baseUrl + EVENT_ENDPOINT + + try { + eventSource = new EventSource(url) + + eventSource.addEventListener('connected', (e) => { + console.log('[SSE] 已连接:', e.data) + if (onConnected) onConnected() + }) + + // 收到汇总通知(每30分钟一次) + eventSource.addEventListener('risk-summary', (e) => { + try { + const data = JSON.parse(e.data) + console.log('[SSE] 收到风险事件汇总:', data) + onSummary(data) + } catch (err) { + console.error('[SSE] 解析汇总通知失败:', err) + } + }) + + eventSource.onerror = (_e) => { + console.error('[SSE] 连接错误,准备重连...') + disconnectRiskEventStream() + reconnectTimer = setTimeout(() => { + connectRiskEventStream(onSummary, onConnected) + }, 30000) + } + } catch (e) { + console.error('[SSE] 连接失败:', e) + } +} + +/** + * 断开 SSE 连接 + */ +export function disconnectRiskEventStream() { + if (reconnectTimer) { + clearTimeout(reconnectTimer) + reconnectTimer = null + } + if (eventSource) { + eventSource.close() + eventSource = null + } +} diff --git a/frontend/sidebar/src/App.tsx b/frontend/sidebar/src/App.tsx index 9144a76..ff74622 100644 --- a/frontend/sidebar/src/App.tsx +++ b/frontend/sidebar/src/App.tsx @@ -9,6 +9,8 @@ function App() { const [activeTab, setActiveTab] = useState('recommend') const [authState, setAuthState] = useState<'loading' | 'authed' | 'error'>('loading') const [userInfo, setUserInfo] = useState(null) + const [currentCustomerId, setCurrentCustomerId] = useState('') + const [manualCustomerId, setManualCustomerId] = useState('') useEffect(() => { initWeCom() @@ -16,13 +18,10 @@ function App() { const initWeCom = async () => { try { - // 1. OAuth2 登录流程 const urlParams = new URLSearchParams(window.location.search) const code = urlParams.get('code') - // const state = urlParams.get('state') // 保留用于后续扩展 if (code) { - // 有 code,换取 token const res = await fetch(`/api/v1/auth/login?code=${encodeURIComponent(code)}`, { method: 'POST' }) const data = await res.json() if (data.code === 0 && data.data) { @@ -34,7 +33,6 @@ function App() { Toast.show({ content: '登录失败: ' + (data.message || '未知错误'), icon: 'fail' }) } } else { - // 无 code,检查本地 token const token = localStorage.getItem('staff_token') if (token) { const res = await fetch('/api/v1/auth/info', { @@ -45,7 +43,6 @@ function App() { setUserInfo(data.data) setAuthState('authed') } else { - // token 过期,需要重新登录 redirectToOAuth() } } else { @@ -59,8 +56,6 @@ function App() { } const redirectToOAuth = () => { - // 在真实企微环境中,由企微容器自动注入 code - // 浏览器调试模式下,保持未登录状态,使用测试身份 setAuthState('authed') setUserInfo({ userId: 'staff_001', @@ -72,17 +67,23 @@ function App() { const initWxConfig = async () => { const wx = (window as any).wx - if (!wx || !wx.config) return + if (!wx || !wx.config) { + console.log('非企微环境,跳过JS-SDK初始化') + return + } try { - const res = await fetch(`/api/v1/auth/signature?url=${encodeURIComponent(window.location.href.split('#')[0])}`) - const data = await res.json() - if (data.code !== 0 || !data.data) { - console.error('获取JS-SDK签名失败') + const currentUrl = window.location.href.split('#')[0] + + // 1. 企业级 wx.config + const corpRes = await fetch(`/api/v1/auth/signature?url=${encodeURIComponent(currentUrl)}`) + const corpData = await corpRes.json() + if (corpData.code !== 0 || !corpData.data) { + console.error('获取JS-SDK企业签名失败') return } - const cfg = data.data + const cfg = corpData.data wx.config({ beta: true, debug: false, @@ -90,25 +91,69 @@ function App() { timestamp: cfg.timestamp, nonceStr: cfg.nonceStr, signature: cfg.signature, - jsApiList: ['sendChatMessage', 'getContext', 'getCurExternalContact'] + jsApiList: ['agentConfig', 'sendChatMessage', 'getContext', 'getCurExternalContact'] }) - wx.ready(() => { - console.log('JS-SDK 配置成功') - // 获取当前客户信息 - wx.invoke('getCurExternalContact', {}, (res: any) => { - if (res.err_msg === 'getCurExternalContact:ok') { - console.log('当前客户userId:', res.userId) - localStorage.setItem('current_customer_id', res.userId) + wx.ready(async () => { + console.log('JS-SDK wx.config 成功') + + // 2. 尝试应用级 agentConfig(获取客户联系权限) + try { + const agentRes = await fetch(`/api/v1/auth/signature?type=agent&agentId=1000021&url=${encodeURIComponent(currentUrl)}`) + const agentData = await agentRes.json() + + if (agentData.code === 0 && agentData.data) { + const ac = agentData.data + wx.agentConfig({ + corpid: ac.corpId, + agentid: ac.agentId, + timestamp: ac.timestamp, + nonceStr: ac.nonceStr, + signature: ac.signature, + jsApiList: ['sendChatMessage', 'getContext', 'getCurExternalContact'], + success: () => { + console.log('✅ agentConfig 成功,已获取客户联系权限') + // agentConfig 成功后,获取当前客户ID + wx.invoke('getCurExternalContact', {}, (res: any) => { + if (res.err_msg === 'getCurExternalContact:ok' && res.userId) { + console.log('✅ 获取到客户ID:', res.userId) + localStorage.setItem('current_customer_id', res.userId) + setCurrentCustomerId(res.userId) + } else { + console.warn('getCurExternalContact 失败:', res.err_msg) + fallbackToCachedId() + } + }) + }, + fail: (err: any) => { + console.warn('⚠️ agentConfig 失败(无客户联系权限):', err.err_msg || err) + fallbackToCachedId() + } + }) + } else { + console.warn('⚠️ 获取应用签名失败:', agentData.message) + fallbackToCachedId() } - }) + } catch (e) { + console.warn('⚠️ agentConfig 流程异常:', e) + fallbackToCachedId() + } }) wx.error((err: any) => { - console.error('JS-SDK 配置失败:', err) + console.error('JS-SDK wx.config 失败:', err) + fallbackToCachedId() }) } catch (e) { console.error('初始化JS-SDK异常:', e) + fallbackToCachedId() + } + } + + const fallbackToCachedId = () => { + const cachedId = localStorage.getItem('current_customer_id') + if (cachedId && cachedId !== '${userId}') { + setCurrentCustomerId(cachedId) } } @@ -118,6 +163,15 @@ function App() { } }, [authState]) + const handleManualCustomer = () => { + if (manualCustomerId.trim()) { + localStorage.setItem('current_customer_id', manualCustomerId.trim()) + setCurrentCustomerId(manualCustomerId.trim()) + setManualCustomerId('') + Toast.show({ content: '已加载客户: ' + manualCustomerId.trim(), icon: 'success' }) + } + } + if (authState === 'loading') { return (
@@ -134,7 +188,31 @@ function App() { AI坐席助手 - + + + {/* 手动输入客户ID(调试/降级) */} +
+
+ 💡 调试模式:可手动输入客户ID加载画像 +
+
+ setManualCustomerId(e.target.value)} + style={{ flex: 1, padding: '6px 10px', border: '1px solid #d9d9d9', borderRadius: 4, fontSize: 13 }} + onKeyPress={(e) => e.key === 'Enter' && handleManualCustomer()} + /> + +
+
+ diff --git a/frontend/sidebar/src/components/ProfileCard.tsx b/frontend/sidebar/src/components/ProfileCard.tsx index 812fe3a..9d27fc7 100644 --- a/frontend/sidebar/src/components/ProfileCard.tsx +++ b/frontend/sidebar/src/components/ProfileCard.tsx @@ -1,8 +1,10 @@ import { useEffect, useState } from 'react' +import { Toast } from 'antd-mobile' import type { CustomerProfile } from '../types' interface Props { userInfo?: any + customerId?: string } const DEFAULT_PROFILE: CustomerProfile = { @@ -17,58 +19,43 @@ const DEFAULT_PROFILE: CustomerProfile = { interestedCourses: ['3D场景UE地编', '次世代角色模型'] } -export default function ProfileCard({ userInfo }: Props) { +const CORPID = 'wwd483c2fba24ae30a' + +export default function ProfileCard({ userInfo, customerId }: Props) { const [profile, setProfile] = useState(DEFAULT_PROFILE) const [loading, setLoading] = useState(false) - const [manualCustomerId, setManualCustomerId] = useState('') + const [saving, setSaving] = useState(false) + const [loadError, setLoadError] = useState('') + const [showForm, setShowForm] = useState(false) + + // 精简表单:只保留基础信息 + const [form, setForm] = useState({ + currentOccupation: '', + studentType: '', + skillLevel: '', + intentLevel: '中', + intentScore: 50, + }) useEffect(() => { - // 0. 优先从 URL 参数获取(调试模式) - const urlParams = new URLSearchParams(window.location.search) - const urlCustomerId = urlParams.get('customerId') - if (urlCustomerId && urlCustomerId !== '${userId}') { - console.log('调试模式,从URL获取客户ID:', urlCustomerId) - localStorage.setItem('current_customer_id', urlCustomerId) - loadCustomerProfile(urlCustomerId) - return - } - - // 1. 优先从企微JS-SDK获取当前客户信息 - const wx = (window as any).wx - if (wx && wx.invoke) { - wx.invoke('getCurExternalContact', {}, (res: any) => { - if (res && res.err_msg === 'getCurExternalContact:ok' && res.userId) { - console.log('当前客户:', res.userId) - localStorage.setItem('current_customer_id', res.userId) - loadCustomerProfile(res.userId) - } else { - // JS-SDK 获取失败(无权限),尝试从 localStorage 加载 - console.log('JS-SDK获取客户失败:', res?.err_msg || '无响应') - const cachedId = localStorage.getItem('current_customer_id') - if (cachedId && cachedId !== '${userId}') { - loadCustomerProfile(cachedId) - } - // 如果没有缓存,显示输入框(见下方手动输入逻辑) - } - }) + if (customerId && customerId !== '${userId}') { + loadCustomerProfile(customerId) } else { - // 浏览器环境:直接加载默认或缓存的客户 - const cachedId = localStorage.getItem('current_customer_id') - if (cachedId && cachedId !== '${userId}') { - loadCustomerProfile(cachedId) - } + setProfile(DEFAULT_PROFILE) + setLoadError('') + setShowForm(false) } - }, []) + }, [customerId]) - const loadCustomerProfile = async (customerId: string) => { + const loadCustomerProfile = async (id: string) => { setLoading(true) + setLoadError('') + setShowForm(false) try { - const corpId = userInfo?.corpId || 'wwd483c2fba24ae30a' - const res = await fetch(`/api/v1/intent/profile/${customerId}?corpId=${corpId}`) + const res = await fetch(`/api/v1/intent/profile/${encodeURIComponent(id)}?corpId=${CORPID}`) const data = await res.json() if (data.code === 0 && data.data) { const p = data.data - // 解析 interested_courses JSON 字符串 let courses: string[] = [] try { courses = p.interestedCourses ? JSON.parse(p.interestedCourses) : [] @@ -77,7 +64,7 @@ export default function ProfileCard({ userInfo }: Props) { } setProfile({ customerId: p.customerId, - name: p.customerId === 'wx_001' ? '张同学' : (p.currentOccupation || '学员'), + name: p.currentOccupation || '学员', studentType: p.studentType, skillLevel: p.skillLevel, intentLevel: p.intentLevel, @@ -91,14 +78,63 @@ export default function ProfileCard({ userInfo }: Props) { education: p.education, currentOccupation: p.currentOccupation }) + } else { + setProfile({ + customerId: id, + name: '未知客户', + studentType: '未录入', + skillLevel: '未录入', + intentLevel: '低', + intentScore: 0, + concernFocus: '', + decisionStage: '未录入', + interestedCourses: [] + }) } - } catch (e) { - console.error('加载学员画像失败:', e) + } catch (e: any) { + setLoadError('画像加载失败') } finally { setLoading(false) } } + const handleFormChange = (field: string, value: any) => { + setForm(prev => ({ ...prev, [field]: value })) + } + + const handleSave = async () => { + if (!customerId) return + setSaving(true) + try { + const payload = { + customerId, + corpId: CORPID, + currentOccupation: form.currentOccupation || undefined, + studentType: form.studentType || undefined, + skillLevel: form.skillLevel || undefined, + intentLevel: form.intentLevel, + intentScore: Number(form.intentScore) || 0, + } + const res = await fetch('/api/v1/intent/profiles', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }) + const data = await res.json() + if (data.code === 0) { + Toast.show({ content: '录入成功', icon: 'success' }) + setShowForm(false) + loadCustomerProfile(customerId) + } else { + Toast.show({ content: '录入失败: ' + (data.message || '未知错误'), icon: 'fail' }) + } + } catch (e) { + Toast.show({ content: '录入失败,请检查网络', icon: 'fail' }) + } finally { + setSaving(false) + } + } + const getIntentColor = (level?: string) => { switch (level) { case '高': return '#52c41a' @@ -108,45 +144,150 @@ export default function ProfileCard({ userInfo }: Props) { } } - const handleManualLoad = () => { - if (manualCustomerId.trim()) { - localStorage.setItem('current_customer_id', manualCustomerId.trim()) - loadCustomerProfile(manualCustomerId.trim()) - } + const hasCustomerId = !!(customerId && customerId !== '${userId}') + const isUnknown = hasCustomerId && profile.name === '未知客户' + + const selectStyle = { + width: '100%', + padding: '6px 8px', + border: '1px solid #d9d9d9', + borderRadius: 4, + fontSize: 13, + background: '#fff' + } + + const inputStyle = { + width: '100%', + padding: '6px 8px', + border: '1px solid #d9d9d9', + borderRadius: 4, + fontSize: 13, + boxSizing: 'border-box' as const } return (
- {/* 手动输入客户ID(当JS-SDK无权限时) */} - {profile.customerId === 'wx_001' && !loading && ( -
-
- ⚠️ 未获取到客户ID(无客户联系权限) + {!loading && ( + <> + {!hasCustomerId && ( +
+
+ ⚠️ 未获取到客户ID(无客户联系权限) +
+
+ )} + + {isUnknown && !showForm && ( +
+
+ ℹ️ 已识别客户,基础画像未录入 +
+
+ 客户ID: {profile.customerId} +
+
+ 录入基础信息后,系统将通过会话存档自动完善画像 +
+
+ + +
+
+ )} + + {loadError && ( +
+
❌ {loadError}
+ +
+ )} + + )} + + {/* 精简录入表单 */} + {showForm && ( +
+
+ 📝 录入基础信息
-
- setManualCustomerId(e.target.value)} - style={{ flex: 1, padding: '6px 10px', border: '1px solid #d9d9d9', borderRadius: 4, fontSize: 13 }} - onKeyPress={(e) => e.key === 'Enter' && handleManualLoad()} - /> +
+ 只需填写基础标签,详细画像将通过AI对话分析自动完善 +
+
+
+
当前职业 / 名称 *
+ handleFormChange('currentOccupation', e.target.value)} style={inputStyle} placeholder="如: 在校大学生 / 设计师" /> +
+
+
学员类型
+ +
+
+
基础水平
+ +
+
+
意向度
+ +
+
+
意向分数 (0-100)
+ handleFormChange('intentScore', e.target.value)} style={inputStyle} /> +
+
+
+
)} +
-
{profile.name?.[0]}
+
{profile.name?.[0] || '?'}
{profile.name} - {loading && 加载中...} + {loading && 加载中...}
意向度: @@ -161,12 +302,9 @@ export default function ProfileCard({ userInfo }: Props) { {profile.concernFocus && {profile.concernFocus}} {profile.decisionStage && {profile.decisionStage}} {profile.interestedCourses?.map(course => ( - - {course} - + {course} ))}
- {/* 扩展信息 */} {(profile.age || profile.education || profile.budgetHint || profile.preferredCity) && (
{profile.age && 年龄: {profile.age}岁} @@ -177,7 +315,7 @@ export default function ProfileCard({ userInfo }: Props) { )} {userInfo && (
- 顾问: {userInfo.userName || userInfo.userId} | {userInfo.corpId} + 顾问: {userInfo.userName || userInfo.userId}
)}
diff --git a/root.pem b/root.pem new file mode 100644 index 0000000..bf4c728 --- /dev/null +++ b/root.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEArQO/DeCSM20u5HpeRxQv3uLdIjdSke0xHGJdoWvBEG1f6lif +j2mycfaLRLS5nVCnNflTue6Knu8T4lKMglnbmW3EbYNXSuPyeCqb0lH197CCIvBX +htenvZibEmbtg4uvAk4o2y15hDBjfZ0tHC9Uyxq0AMkO5MiXt29cRW1cGg+BS3MK +dOW71vGlgts64CiEq6uqEm1aQP4Zgp5w7t8eG5F4eBy0VnV0tosF/EVa2Vg0LiJm +CqiweVsi5bOXe52Cd3jYN7gCVfMn9g0vQiu9PP7Vvm+aF6zF5jdZKsRaMaj4PVbz +2L0OtGNP101r45B2FASSWj1yLwEX2F+4uYykiwIDAQABAoIBAFQmW9EAlqtroeCf +weYZY/Uo63w+Yys1rerLzxV5huQaBopBQ44pzxCN5QvsSNFCjql6VKY85vBJgSbV +lqC52szDrkPDm3wB5Ydb1gEUOymXEJy0wCWGE4BsTpRDOqd+E4dwA4AjHJTWQjWw +8mVgggHtQXqWT+enxt8By2O7Po4viq0md4y4IPd3W9oq9CpRlTuJh4grmpPtHOkN +rGaUhTUYiPG1e2v4ahV70DrCIJOVNyP0sbIOxqZNm+TUpDHzrffw6Yqz+rNEZYS0 +NJoqU4lDpK0bqQC0IOHYZ2rXKaLSGx4x4I0ekxp+BPkC1zoQYAl0KEAWqqfK6wMa +sWSNuj0CgYEA0OQueai/jwe+vKfsttG0XUbb/UbzL6fNpqVGCSDcbxIbpYFKNMf1 +I7IqK3JhzzlSPvaYr1bp7hoGgfh7SizvodW9YvvUdLUFv6/ehJisGk1AZ73V1shW +DF9Q2N6PtRC3XhrsYA0e7n7y6P+NTmxPr9zaHHRQL/1tyGfZTU0f+C0CgYEA1AhQ +MXkc1UzWSmsnhNp3KUKVfv67RhKzUrv6lbei1R/MFl5ST3xqixeEgryH8T0ewa7e +Fre5kL4fDfq6a4YvJSEZywjrHIXI9ZAj5v5t+WFN42i8Lxgnr10WYZ/Xp8qKqoUQ +bLMFXqWqrkW+b0XbJF3+0S3oUkfTTgq8Q+AtipcCgYADRgAgce+Gi0WG6U7BUqnz +6D9cSbdX4NZNLTvausQZjXwXbN4/035Y79rPUXyM5cYPLIgZ/0+tJoVBliZAdFHR +BGtyWKXsqkWLynK/OzE/PEStG4F1WzBWA0xeCabQ3JEzDVL08l+wtxaRZifNuc7J +37kfRwdWqS8GO/JgzruYhQKBgCsnDfsjpQScQLigmkhllf7luDtCaKbYQ3rBGzzB +RPiPIiAjTikzLxPj02owKJKmV84fYr/I4JFewD8+GhasFog99lvmEsOZTs77aPMo +MQWiEhIITNRg3CLKlTWFLITPqe0Oa8tTm/IpUVVqiFlpJDKAls3HxBKK36xwbuR5 +iYs9AoGBAIeTQwJxmspxl2KLcd4o8RUXglA7FSFz0H9ygMySYuXt+r7u2cy1rYaa +HOMrYq7qJYa9Gmvv6fYi4XwKi/VIWtDhPYjpJb98LG7h3TGpdixudInxWsVU1Z17 +LlA2QVchw/LcdXHtxdp57K/DxPaI1Bf4+B5mzB3W2Qf3WMLht2wf +-----END RSA PRIVATE KEY-----