chore: 完成项目整体打包与配置更新

1. 更新所有服务Dockerfile,切换到eclipse-temurin:17-jre-alpine镜像并调整服务端口
2. 配置前端项目基础路径与nginx代理
3. 新增数据库初始化编码设置与测试依赖
4. 添加部分服务的Servlet编码配置与JWT/LLM密钥配置
5. 完善话术实体类校验注解与Mapper接口
6. 新增密码生成工具类与部分测试文件配置
This commit is contained in:
jiao 2026-05-21 11:40:22 +08:00
parent 183698824a
commit d327826c78
251 changed files with 13341 additions and 386 deletions

31
.env Normal file
View File

@ -0,0 +1,31 @@
# ============================================================
# 第九联盟AI坐席辅助系统 - Docker 部署环境变量
# 复制本文件为 .env.local 并填入真实值,.env.local 不会被 Git 提交
# ============================================================
# ---------------------- 数据库 ----------------------
MYSQL_ROOT_PASSWORD=root
MYSQL_DATABASE=ai_assistant
# ---------------------- Redis ----------------------
REDIS_PASSWORD=
# ---------------------- RabbitMQ ----------------------
RABBITMQ_DEFAULT_USER=admin
RABBITMQ_DEFAULT_PASS=admin
# ---------------------- 企微配置 ----------------------
# 会话存档 Secret(企业微信管理后台获取)
WECOM_ARCHIVE_SECRET=
# RSA 私钥(PKCS#1 格式,用于解密会话存档)
WECOM_ARCHIVE_RSA_KEY=
# 回调验证 Token
WECOM_ARCHIVE_CALLBACK_TOKEN=
# SDK 路径(容器内路径,Linux部署时挂载 .so 文件)
WECOM_ARCHIVE_SDK_PATH=/app/sdk
# ---------------------- 通义千问 API ----------------------
DASHSCOPE_API_KEY=
# ---------------------- JWT ----------------------
JWT_SECRET=your-256-bit-secret-key-here-at-least-32-characters

30
.gitignore vendored Normal file
View File

@ -0,0 +1,30 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
node_modules/
.planning/
.vscode/
deploy-package/

View File

@ -1,7 +1,7 @@
# 项目状态
## 当前阶段
Phase 6 完成(前端 + 管理后台 + 部署配置)
Phase 6 完成(前端 + 管理后台 + 部署配置),Plan A 实施完成
## 已完成
- [x] 项目初始化(git repo、.planning目录、GSD规划文件)
@ -26,6 +26,20 @@ Phase 6 完成(前端 + 管理后台 + 部署配置)
- kb-admin-service: 话术管理API
- analytics-service: 数据统计API
- 所有服务Dockerfile + nginx + 完整docker-compose编排
- [x] Plan A: Admin后台真实API打通
- kb-admin-service 新增 AuthController(JWT登录)、AdminController(管理员CRUD)、KnowledgeController(知识库CRUD+上传)
- 前端 request.ts Axios封装 + Token拦截 + 401跳转
- 前端 Login.tsx 真实登录API
- 前端 UtteranceList.tsx 真实CRUD(分页字段兼容:list→records)
- 前端 KnowledgeList.tsx 知识库管理(列表/手动录入/上传/编辑/删除)
- 前端 AdminList.tsx 管理员账号管理(角色标签修复 SUPER_ADMIN→超管)
- 后端 AdminController 安全修复:返回前清除password字段
- 前端 Layout.tsx 路由守卫 + 菜单更新
## 运行状态
- 后端7服务全部启动:Gateway(8080), Auth(8081), Conversation(8083), Intent(8084), Recommendation(8085), Generation(8086), KbAdmin(8087)
- 前端dev server:http://localhost:5174
- 数据库:MySQL 5.7 (localhost), Redis (Docker), RabbitMQ (Docker)
## 提交记录
- `31a395b` feat(01): Phase 1 - 基础设施 + 数据库 + 公共模块
@ -38,6 +52,6 @@ Phase 6 完成(前端 + 管理后台 + 部署配置)
1. 配置通义千问API Key并测试LLM调用
2. 配置企微CorpID/Secret并测试OAuth登录
3. 上传企微C SDK动态库到archive-service
4. 补充前端API真实调用(当前为模拟数据)
5. 补充单元测试和E2E测试
6. 配置CI/CD流水线
4. 补充单元测试和E2E测试
5. 配置CI/CD流水线
6. 知识库PDF/DOCX文件解析实现

BIN
GenerateHash.class Normal file

Binary file not shown.

9
GenerateHash.java Normal file
View File

@ -0,0 +1,9 @@
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
public class GenerateHash {
public static void main(String[] args) {
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
String hash = encoder.encode("admin123");
System.out.println(hash);
System.out.println("验证: " + encoder.matches("admin123", hash));
}
}

View File

@ -1,5 +1,5 @@
FROM openjdk:17-jdk-slim
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
COPY target/*.jar app.jar
EXPOSE 8080
EXPOSE 8088
ENTRYPOINT ["java", "-jar", "app.jar"]

View File

@ -1,46 +1,50 @@
package com.artedu.analytics.controller;
import com.artedu.analytics.service.AnalyticsService;
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.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import javax.validation.constraints.NotBlank;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Slf4j
@Validated
@RestController
@RequestMapping("/api/v1/analytics")
public class AnalyticsController {
@Autowired
private AnalyticsService analyticsService;
@GetMapping("/dashboard")
public Result<Map<String, Object>> dashboard(@RequestParam String corpId) {
Map<String, Object> result = new HashMap<>();
result.put("todayConversations", 128);
result.put("todayMessages", 2456);
result.put("activeStaff", 24);
result.put("utteranceAdoptionRate", 0.685);
result.put("conversionRate", 0.123);
result.put("avgResponseTimeMs", 18500);
public Result<Map<String, Object>> dashboard(@NotBlank(message = "corpId不能为空") @RequestParam String corpId) {
log.info("查询数据看板, corpId={}", corpId);
Map<String, Object> result = analyticsService.getDashboard(corpId);
return Result.success(result);
}
@GetMapping("/utterance-performance")
public Result<List<Map<String, Object>>> utterancePerformance(
@RequestParam String corpId,
@NotBlank(message = "corpId不能为空") @RequestParam String corpId,
@RequestParam(defaultValue = "7") Integer days) {
// TODO: 查询真实数据
return Result.success(List.of());
log.info("查询话术绩效, corpId={}, days={}", corpId, days);
List<Map<String, Object>> result = analyticsService.getUtterancePerformance(corpId, days);
return Result.success(result);
}
@GetMapping("/staff-performance")
public Result<List<Map<String, Object>>> staffPerformance(
@RequestParam String corpId,
@NotBlank(message = "corpId不能为空") @RequestParam String corpId,
@RequestParam(defaultValue = "7") Integer days) {
// TODO: 查询真实数据
return Result.success(List.of());
log.info("查询员工绩效, corpId={}, days={}", corpId, days);
List<Map<String, Object>> result = analyticsService.getStaffPerformance(corpId, days);
return Result.success(result);
}
}

View File

@ -0,0 +1,114 @@
package com.artedu.analytics.service;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.*;
@Slf4j
@Service
public class AnalyticsService {
@Autowired
private JdbcTemplate jdbcTemplate;
public Map<String, Object> getDashboard(String corpId) {
Map<String, Object> result = new HashMap<>();
String today = LocalDate.now().toString();
String todayStart = today + " 00:00:00";
String todayEnd = today + " 23:59:59";
// 1. 今日会话数
Long todayConversations = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM conversations WHERE corp_id = ? AND start_time BETWEEN ? AND ?",
Long.class, corpId, todayStart, todayEnd);
result.put("todayConversations", todayConversations != null ? todayConversations : 0);
// 2. 今日消息数(对话轮次总数)
Long todayMessages = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM conversation_turns ct " +
"JOIN conversations c ON ct.session_id = c.session_id " +
"WHERE c.corp_id = ? AND ct.created_at BETWEEN ? AND ?",
Long.class, corpId, todayStart, todayEnd);
result.put("todayMessages", todayMessages != null ? todayMessages : 0);
// 3. 活跃坐席数(今日有会话的坐席)
Long activeStaff = jdbcTemplate.queryForObject(
"SELECT COUNT(DISTINCT staff_id) FROM conversations WHERE corp_id = ? AND start_time BETWEEN ? AND ?",
Long.class, corpId, todayStart, todayEnd);
result.put("activeStaff", activeStaff != null ? activeStaff : 0);
// 4. 话术采纳率(有 staff_action 为 SENT 的推荐 / 总推荐数)
Double utteranceAdoptionRate = jdbcTemplate.queryForObject(
"SELECT IFNULL(SUM(CASE WHEN staff_action IN ('SENT', 'ACCEPTED') THEN 1 ELSE 0 END) * 1.0 / COUNT(*), 0) " +
"FROM recommendations WHERE corp_id = ?",
Double.class, corpId);
result.put("utteranceAdoptionRate", utteranceAdoptionRate != null ? utteranceAdoptionRate : 0.0);
// 5. 转化率(feedback 为 POSITIVE 或 CONVERTED 的比例)
Double conversionRate = jdbcTemplate.queryForObject(
"SELECT IFNULL(SUM(CASE WHEN feedback IN ('POSITIVE', 'CONVERTED') THEN 1 ELSE 0 END) * 1.0 / COUNT(*), 0) " +
"FROM recommendations WHERE corp_id = ? AND staff_action IN ('SENT', 'ACCEPTED')",
Double.class, corpId);
result.put("conversionRate", conversionRate != null ? conversionRate : 0.0);
// 6. 平均响应时间(recommendations 的 elapsed_ms 平均)
Double avgResponseTimeMs = jdbcTemplate.queryForObject(
"SELECT IFNULL(AVG(elapsed_ms), 0) FROM recommendations WHERE corp_id = ?",
Double.class, corpId);
result.put("avgResponseTimeMs", avgResponseTimeMs != null ? Math.round(avgResponseTimeMs) : 0);
return result;
}
public List<Map<String, Object>> getUtterancePerformance(String corpId, int days) {
String startDate = LocalDate.now().minusDays(days).toString();
String sql = "SELECT " +
" u.utterance_id AS utteranceId, " +
" u.title, " +
" SUM(up.show_count) AS totalShow, " +
" SUM(up.click_count) AS totalClick, " +
" SUM(up.send_count) AS totalSend, " +
" SUM(up.positive_count) AS totalPositive, " +
" SUM(up.conversion_count) AS totalConversion, " +
" IFNULL(SUM(up.click_count) * 1.0 / SUM(up.show_count), 0) AS ctr, " +
" IFNULL(SUM(up.conversion_count) * 1.0 / NULLIF(SUM(up.send_count), 0), 0) AS conversionRate, " +
" IFNULL(AVG(up.score), 0) AS avgScore " +
"FROM utterances u " +
"LEFT JOIN utterance_performance up ON u.utterance_id = up.utterance_id AND up.corp_id = ? " +
"WHERE u.corp_id = ? OR u.corp_id = 'corp001' " +
"GROUP BY u.utterance_id, u.title " +
"ORDER BY totalSend DESC " +
"LIMIT 20";
return jdbcTemplate.queryForList(sql, corpId, corpId);
}
public List<Map<String, Object>> getStaffPerformance(String corpId, int days) {
String startDate = LocalDate.now().minusDays(days).toString();
String startTime = startDate + " 00:00:00";
String sql = "SELECT " +
" c.staff_id AS staffId, " +
" MAX(c.staff_name) AS staffName, " +
" COUNT(DISTINCT c.session_id) AS conversationCount, " +
" SUM(c.round_count) AS totalRounds, " +
" SUM(c.intent_count) AS totalIntents, " +
" AVG(c.idle_duration) AS avgIdleDuration, " +
" COUNT(DISTINCT c.customer_id) AS uniqueCustomers, " +
" MAX(c.start_time) AS lastActiveTime " +
"FROM conversations c " +
"WHERE c.corp_id = ? AND c.start_time >= ? " +
"GROUP BY c.staff_id " +
"ORDER BY conversationCount DESC";
return jdbcTemplate.queryForList(sql, corpId, startTime);
}
}

View File

@ -1,4 +1,9 @@
server:
servlet:
encoding:
charset: UTF-8
enabled: true
force: true
port: 8088
spring:
@ -6,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=utf8&serverTimezone=Asia/Shanghai&useSSL=false
url: jdbc:mysql://${MYSQL_HOST:localhost}:${MYSQL_PORT:3306}/${MYSQL_DATABASE:ai_assistant}?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai&useSSL=false
username: ${MYSQL_USERNAME:root}
password: ${MYSQL_PASSWORD:root}
redis:

View File

@ -1,4 +1,9 @@
server:
servlet:
encoding:
charset: UTF-8
enabled: true
force: true
port: 8088
spring:

View File

@ -1,2 +1,3 @@
com\artedu\analytics\service\AnalyticsService.class
com\artedu\analytics\AnalyticsServiceApplication.class
com\artedu\analytics\controller\AnalyticsController.class

View File

@ -1,2 +1,3 @@
D:\www\agent_9art\backend\analytics-service\src\main\java\com\artedu\analytics\AnalyticsServiceApplication.java
D:\www\agent_9art\backend\analytics-service\src\main\java\com\artedu\analytics\controller\AnalyticsController.java
D:\www\agent_9art\backend\analytics-service\src\main\java\com\artedu\analytics\service\AnalyticsService.java

View File

@ -1,5 +1,7 @@
FROM openjdk:17-jdk-slim
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
COPY target/*.jar app.jar
EXPOSE 8080
EXPOSE 8082
# 注意:Linux部署时需将libWeWorkFinanceSdk_Java.so放入容器
# 可通过 -v /host/sdk:/app/sdk 挂载,并设置 -Djava.library.path=/app/sdk
ENTRYPOINT ["java", "-jar", "app.jar"]

View File

@ -0,0 +1,171 @@
# 企微会话存档 SDK 对接指南
## 一、SDK 下载
### 官方下载地址
- 企微开发者中心:https://developer.work.weixin.qq.com/document/path/91774
- 下载页面:「获取会话内容」→ SDK 下载
### 下载版本选择
| 环境 | 推荐版本 | 说明 |
|------|---------|------|
| Windows x64 | SDK v3.0 (OpenSSL 3.0) | 2025-02-13 更新 |
| Linux x64 | SDK v3.0 (OpenSSL 3.0) | 2025-02-13 更新 |
### Windows SDK 文件清单
解压后 `financeWinSdk/java_sdk/WeWorkFinanceSdk/` 目录下包含:
| 文件 | 说明 |
|------|------|
| `WeWorkFinanceSdk.dll` | 主 SDK 动态库 |
| `libcrypto-3-x64.dll` | OpenSSL 3.0 依赖 |
| `libssl-3-x64.dll` | OpenSSL 3.0 依赖 |
| `libcurl-x64.dll` | libcurl HTTP 依赖 |
| `Finance.java` | 官方 JNI 封装(本项目使用 JNA,无需此文件) |
## 二、SDK 放置
### Windows 环境
将上述 4 个 DLL 文件放到项目目录:
```
backend/archive-service/
└── sdk/ <-- 新建此目录
├── WeWorkFinanceSdk.dll
├── libcrypto-3-x64.dll
├── libssl-3-x64.dll
└── libcurl-x64.dll
```
### Linux 环境
```bash
# 将 libWeWorkFinanceSdk_Java.so 重命名并放到系统库路径
sudo cp libWeWorkFinanceSdk_Java.so /usr/lib/libWeWorkFinanceSdk.so
sudo ldconfig
# 或者放到项目目录并通过环境变量指定
mkdir -p backend/archive-service/sdk
cp libWeWorkFinanceSdk_Java.so backend/archive-service/sdk/libWeWorkFinanceSdk.so
```
## 三、配置参数
### 环境变量方式(推荐)
```bash
# 企微会话存档 Secret
export WECOM_ARCHIVE_SECRET="your-archive-secret"
# RSA 私钥(PKCS#1 格式,Base64 编码)
export WECOM_ARCHIVE_RSA_KEY="-----BEGIN RSA PRIVATE KEY-----\nMIIE...\n-----END RSA PRIVATE KEY-----"
# 回调验证 Token(企微后台配置的 Token)
export WECOM_ARCHIVE_CALLBACK_TOKEN="your-callback-token"
# SDK 路径(指向存放 DLL/SO 的目录)
export WECOM_ARCHIVE_SDK_PATH="D:\www\agent_9art\backend\archive-service\sdk"
# Mock 模式(开发测试用,无真实 SDK 时设为 true)
export WECOM_ARCHIVE_MOCK=false
```
### application.yml 配置项
```yaml
wecom:
archive:
corp-id: wwd483c2fba24ae30a # 企业微信 CorpID
secret: ${WECOM_ARCHIVE_SECRET:} # 会话存档 Secret
rsa-private-key: ${WECOM_ARCHIVE_RSA_KEY:} # RSA 私钥
callback-token: ${WECOM_ARCHIVE_CALLBACK_TOKEN:} # 回调 Token
sdk-path: ${WECOM_ARCHIVE_SDK_PATH:} # SDK 目录路径
mock-mode: ${WECOM_ARCHIVE_MOCK:false} # Mock 模式开关
```
## 四、参数获取方式
### 1. CorpID
企微管理后台 → 「我的企业」→ 「企业ID」
### 2. 会话存档 Secret
企微管理后台 → 「管理工具」→ 「会话内容存档」→ 查看 Secret
### 3. RSA 密钥对
```bash
# 生成 RSA 私钥(PKCS#1 格式)
openssl genrsa -out private_key.pem 2048
# 提取公钥
openssl rsa -in private_key.pem -pubout -out public_key.pem
# 将公钥配置到企微后台
# 将私钥内容配置到 WECOM_ARCHIVE_RSA_KEY 环境变量
```
### 4. 回调 Token
企微后台配置接收消息时设置,用于验证回调请求签名。
## 五、Mock 模式(开发/测试)
当没有真实企微 SDK 或不想连接企微服务器时,启用 Mock 模式:
```bash
export WECOM_ARCHIVE_MOCK=true
```
Mock 模式下:
- 跳过 SDK 初始化,不会报错
- `pullMessages()` 返回 10 条模拟会话消息
- 完整的 `saveAndNotify()` 链路正常工作(入库 + MQ + Redis)
- 数据看板可以展示存档统计
**测试接口:**
```bash
# 手动触发拉取(Mock 模式下返回模拟数据)
curl -X POST "http://localhost:8082/api/v1/archive/pull" \
-d "seq=0&limit=10"
```
## 六、验证对接成功
### 1. 启动服务时日志
```
# 真实 SDK 模式
企微存档SDK初始化成功
# Mock 模式
企微存档Mock模式已启用,跳过SDK初始化
```
### 2. 手动拉取测试
```bash
curl -X POST "http://localhost:8082/api/v1/archive/pull" \
-d "seq=0&limit=5"
```
### 3. 回调测试(需配置可信域名)
企微后台配置回调 URL 为:
```
https://your-domain/api/v1/archive/callback
```
## 七、常见问题
### Q1: 启动报错 `UnsatisfiedLinkError: Unable to load library 'WeWorkFinanceSdk'`
- 检查 `WECOM_ARCHIVE_SDK_PATH` 是否指向正确的 DLL 目录
- 确认 DLL 文件名正确(Windows 为 `WeWorkFinanceSdk.dll`)
- 确认依赖库(libcrypto/libssl/libcurl)也在同一目录
### Q2: SDK 初始化返回非 0
- 检查 `corp-id` 和 `secret` 是否正确
- 确认企业已开通「会话内容存档」功能
### Q3: 回调签名验证失败
- 检查 `callback-token` 是否与企微后台配置一致
- 确认回调 URL 没有被中间件修改参数
### Q4: 数据库查询不到存档消息
- 检查 `archive_messages` 表是否有 `deleted` 字段(逻辑删除)
- MyBatis-Plus 全局配置 `logic-delete-field: deleted`

View File

@ -15,6 +15,7 @@
<dependency><groupId>com.artedu</groupId><artifactId>common</artifactId><version>${project.version}</version></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency>
<dependency><groupId>net.java.dev.jna</groupId><artifactId>jna</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
</dependencies>
<build>
<plugins>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<classpath>
<classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-1.8"/>
<classpathentry kind="src" path="src"/>
<classpathentry kind="output" path="bin"/>
</classpath>

View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<projectDescription>
<name>WeWorkFinanceSdk</name>
<comment></comment>
<projects>
</projects>
<buildSpec>
<buildCommand>
<name>org.eclipse.jdt.core.javabuilder</name>
<arguments>
</arguments>
</buildCommand>
</buildSpec>
<natures>
<nature>org.eclipse.jdt.core.javanature</nature>
</natures>
</projectDescription>

View File

@ -0,0 +1,11 @@
eclipse.preferences.version=1
org.eclipse.jdt.core.compiler.codegen.inlineJsrBytecode=enabled
org.eclipse.jdt.core.compiler.codegen.targetPlatform=1.8
org.eclipse.jdt.core.compiler.codegen.unusedLocal=preserve
org.eclipse.jdt.core.compiler.compliance=1.8
org.eclipse.jdt.core.compiler.debug.lineNumber=generate
org.eclipse.jdt.core.compiler.debug.localVariable=generate
org.eclipse.jdt.core.compiler.debug.sourceFile=generate
org.eclipse.jdt.core.compiler.problem.assertIdentifier=error
org.eclipse.jdt.core.compiler.problem.enumIdentifier=error
org.eclipse.jdt.core.compiler.source=1.8

View File

@ -0,0 +1,3 @@
Manifest-Version: 1.0
Main-Class: com.company.Main

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,138 @@
package com.company;
import com.tencent.wework.Finance;
import java.io.File;
import java.io.FileOutputStream;
//10000 参数错误,请求参数错误
//10001 网络错误,网络请求错误
//10002 数据解析失败
//10003 系统失败
//10004 密钥错误导致加密失败
//10005 fileid错误
//10006 解密失败
//10007 找不到消息加密版本的私钥,需要重新传入私钥对
//10008 解析encrypt_key出错
//10009 ip非法
//10010 数据过期
public class Main {
public static void main(String[] args){
//seq 表示该企业存档消息序号,该序号单调递增,拉取序号建议设置为上次拉取返回结果中最大序号。首次拉取时seq传0,sdk会返回有效期内最早的消息。
//limit 表示本次拉取的最大消息条数,取值范围为1~1000
//proxy与passwd为代理参数,如果运行sdk的环境不能直接访问外网,需要配置代理参数。sdk访问的域名是"https://qyapi.weixin.qq.com"。
//建议先通过curl访问"https://qyapi.weixin.qq.com",验证代理配置正确后,再传入sdk。
//timeout 为拉取会话存档的超时时间,单位为秒,建议超时时间设置为5s。
//sdkfileid 媒体文件id,从解密后的会话存档中得到
//savefile 媒体文件保存路径
//encrypt_key 拉取会话存档返回的encrypt_random_key,使用配置在企业微信管理台的rsa公钥对应的私钥解密后得到encrypt_key。
//encrypt_chat_msg 拉取会话存档返回的encrypt_chat_msg
if (args.length < 2) {
System.out.println("./sdktools 1(chatmsg) 2(mediadata) 3(decryptdata)\n");
System.out.println("./sdktools 1 seq limit proxy passwd timeout\n");
System.out.println("./sdktools 2 fileid proxy passwd timeout savefile\n");
System.out.println("./sdktools 3 encrypt_key encrypt_chat_msg\n");
return;
}
long ret = 0;
//使用sdk前需要初始化,初始化成功后的sdk可以一直使用。
//如需并发调用sdk,建议每个线程持有一个sdk实例。
//初始化时请填入自己企业的corpid与secrectkey。
long sdk = Finance.NewSdk();
ret = Finance.Init(sdk, "w*********d", "zJ*****************************ew");
if(ret != 0){
Finance.DestroySdk(sdk);
System.out.println("init sdk err ret " + ret);
return;
}
if (args[0].equals("1")) {
//拉取会话存档
int seq = Integer.parseInt(args[1]);
int limit = Integer.parseInt(args[2]);
String proxy = args[3];
String passwd = args[4];
int timeout = Integer.parseInt(args[5]);
//每次使用GetChatData拉取存档前需要调用NewSlice获取一个slice,在使用完slice中数据后,还需要调用FreeSlice释放。
long slice = Finance.NewSlice();
ret = Finance.GetChatData(sdk, seq, limit, proxy, passwd, timeout, slice);
if (ret != 0) {
System.out.println("getchatdata ret " + ret);
Finance.FreeSlice(slice);
return;
}
System.out.println("getchatdata :" + Finance.GetContentFromSlice(slice));
Finance.FreeSlice(slice);
}
else if (args[0].equals("2")) {
//拉取媒体文件
String sdkfileid = args[1];
String proxy = args[2];
String passwd = args[3];
int timeout = Integer.parseInt(args[4]);
String savefile = args[5];
//媒体文件每次拉取的最大size为512k,因此超过512k的文件需要分片拉取。若该文件未拉取完整,sdk的IsMediaDataFinish接口会返回0,同时通过GetOutIndexBuf接口返回下次拉取需要传入GetMediaData的indexbuf。
//indexbuf一般格式如右侧所示,”Range:bytes=524288-1048575“,表示这次拉取的是从524288到1048575的分片。单个文件首次拉取填写的indexbuf为空字符串,拉取后续分片时直接填入上次返回的indexbuf即可。
String indexbuf = "";
while(true){
//每次使用GetMediaData拉取存档前需要调用NewMediaData获取一个media_data,在使用完media_data中数据后,还需要调用FreeMediaData释放。
long media_data = Finance.NewMediaData();
ret = Finance.GetMediaData(sdk, indexbuf, sdkfileid, proxy, passwd, timeout, media_data);
if(ret!=0){
System.out.println("getmediadata ret:" + ret);
Finance.FreeMediaData(media_data);
return;
}
System.out.printf("getmediadata outindex len:%d, data_len:%d, is_finis:%d\n",Finance.GetIndexLen(media_data),Finance.GetDataLen(media_data), Finance.IsMediaDataFinish(media_data));
try {
//大于512k的文件会分片拉取,此处需要使用追加写,避免后面的分片覆盖之前的数据。
FileOutputStream outputStream = new FileOutputStream(new File(savefile), true);
outputStream.write(Finance.GetData(media_data));
outputStream.close();
} catch (Exception e) {
e.printStackTrace();
}
if(Finance.IsMediaDataFinish(media_data) == 1)
{
//已经拉取完成最后一个分片
Finance.FreeMediaData(media_data);
break;
}
else
{
//获取下次拉取需要使用的indexbuf
indexbuf = Finance.GetOutIndexBuf(media_data);
Finance.FreeMediaData(media_data);
}
}
}
else if (args[0].equals("3")) {
//解密会话存档内容
//sdk不会要求用户传入rsa私钥,保证用户会话存档数据只有自己能够解密。
//此处需要用户先用rsa私钥解密encrypt_random_key后,作为encrypt_key参数传入sdk来解密encrypt_chat_msg获取会话存档明文。
String encrypt_key = args[1];
String encrypt_chat_msg = args[2];
//每次使用DecryptData解密会话存档前需要调用NewSlice获取一个slice,在使用完slice中数据后,还需要调用FreeSlice释放。
long msg = Finance.NewSlice();
ret = Finance.DecryptData(sdk, encrypt_key, encrypt_chat_msg, msg);
if (ret != 0) {
System.out.println("getchatdata ret " + ret);
Finance.FreeSlice(msg);
return;
}
System.out.println("decrypt ret:" + ret + " msg:" + Finance.GetContentFromSlice(msg));
Finance.FreeSlice(msg);
}
else {
System.out.println("wrong args " + args[0]);
}
Finance.DestroySdk(sdk);
}
}

View File

@ -0,0 +1,128 @@
package com.tencent.wework;
/* sdk返回数据
typedef struct Slice_t {
char* buf;
int len;
} Slice_t;
typedef struct MediaData {
char* outindexbuf;
int out_len;
char* data;
int data_len;
int is_finish;
} MediaData_t;
*/
public class Finance {
public native static long NewSdk();
/**
* 初始化函数
* Return值=0表示该API调用成功
*
* @param [in] sdk NewSdk返回的sdk指针
* @param [in] corpid 调用企业的企业id,例如:wwd08c8exxxx5ab44d,可以在企业微信管理端--我的企业--企业信息查看
* @param [in] secret 聊天内容存档的Secret,可以在企业微信管理端--管理工具--聊天内容存档查看
* @return 返回是否初始化成功
* 0 - 成功
* !=0 - 失败
*/
public native static int Init(long sdk, String corpid, String secret);
/**
* 拉取聊天记录函数
* Return值=0表示该API调用成功
*
* @param [in] sdk NewSdk返回的sdk指针
* @param [in] seq 从指定的seq开始拉取消息,注意的是返回的消息从seq+1开始返回,seq为之前接口返回的最大seq值。首次使用请使用seq:0
* @param [in] limit 一次拉取的消息条数,最大值1000条,超过1000条会返回错误
* @param [in] proxy 使用代理的请求,需要传入代理的链接。如:socks5://10.0.0.1:8081 或者 http://10.0.0.1:8081
* @param [in] passwd 代理账号密码,需要传入代理的账号密码。如 user_name:passwd_123
* @param [out] chatDatas 返回本次拉取消息的数据,slice结构体.内容包括errcode/errmsg,以及每条消息内容。
* @return 返回是否调用成功
* 0 - 成功
* !=0 - 失败
*/
public native static int GetChatData(long sdk, long seq, long limit, String proxy, String passwd, long timeout, long chatData);
/**
* 拉取媒体消息函数
* Return值=0表示该API调用成功
*
* @param [in] sdk NewSdk返回的sdk指针
* @param [in] sdkFileid 从GetChatData返回的聊天消息中,媒体消息包括的sdkfileid
* @param [in] proxy 使用代理的请求,需要传入代理的链接。如:socks5://10.0.0.1:8081 或者 http://10.0.0.1:8081
* @param [in] passwd 代理账号密码,需要传入代理的账号密码。如 user_name:passwd_123
* @param [in] indexbuf 媒体消息分片拉取,需要填入每次拉取的索引信息。首次不需要填写,默认拉取512k,后续每次调用只需要将上次调用返回的outindexbuf填入即可。
* @param [out] media_data 返回本次拉取的媒体数据.MediaData结构体.内容包括data(数据内容)/outindexbuf(下次索引)/is_finish(拉取完成标记)
* @return 返回是否调用成功
* 0 - 成功
* !=0 - 失败
*/
public native static int GetMediaData(long sdk, String indexbuf, String sdkField, String proxy, String passwd, long timeout, long mediaData);
/**
* @param [in] encrypt_key, getchatdata返回的encrypt_key
* @param [in] encrypt_msg, getchatdata返回的content
* @param [out] msg, 解密的消息明文
* @return 返回是否调用成功
* 0 - 成功
* !=0 - 失败
* @brief 解析密文
*/
public native static int DecryptData(long sdk, String encrypt_key, String encrypt_msg, long msg);
public native static void DestroySdk(long sdk);
public native static long NewSlice();
/**
* @return
* @brief 释放slice,和NewSlice成对使用
*/
public native static void FreeSlice(long slice);
/**
* @return 内容
* @brief 获取slice内容
*/
public native static String GetContentFromSlice(long slice);
/**
* @return 内容
* @brief 获取slice内容长度
*/
public native static int GetSliceLen(long slice);
public native static long NewMediaData();
public native static void FreeMediaData(long mediaData);
/**
* @return outindex
* @brief 获取mediadata outindex
*/
public native static String GetOutIndexBuf(long mediaData);
/**
* @return data
* @brief 获取mediadata data数据
*/
public native static byte[] GetData(long mediaData);
public native static int GetIndexLen(long mediaData);
public native static int GetDataLen(long mediaData);
/**
* @return 1完成、0未完成
* @brief 判断mediadata是否结束
*/
public native static int IsMediaDataFinish(long mediaData);
static {
System.loadLibrary("WeWorkFinanceSdk");
}
}

View File

@ -4,8 +4,14 @@ import com.artedu.archive.service.ArchivePullService;
import com.artedu.common.result.Result;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import javax.validation.constraints.NotBlank;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Arrays;
import java.util.concurrent.CompletableFuture;
/**
@ -13,6 +19,7 @@ import java.util.concurrent.CompletableFuture;
* 接收企微的msgaudit_notify回调
*/
@Slf4j
@Validated
@RestController
@RequestMapping("/api/v1/archive")
public class ArchiveCallbackController {
@ -20,15 +27,47 @@ public class ArchiveCallbackController {
@Autowired
private ArchivePullService archivePullService;
@Value("${wecom.archive.callback-token:}")
private String callbackToken;
/**
* 企微回调URL验证(GET请求)
*/
@GetMapping("/callback")
public String verifyUrl(
@NotBlank @RequestParam("msg_signature") String msgSignature,
@NotBlank @RequestParam("timestamp") String timestamp,
@NotBlank @RequestParam("nonce") String nonce,
@NotBlank @RequestParam("echostr") String echostr) {
log.info("收到企微回调URL验证: msg_signature={}, timestamp={}, nonce={}", msgSignature, timestamp, nonce);
if (!verifySignature(msgSignature, timestamp, nonce, echostr)) {
log.error("回调URL验证签名失败");
return "fail";
}
log.info("回调URL验证成功");
return echostr;
}
/**
* 企微回调消息接收(POST请求)
*/
@PostMapping("/callback")
public Result<String> callback(
@RequestParam("msg_signature") String msgSignature,
@RequestParam("timestamp") String timestamp,
@RequestParam("nonce") String nonce,
@NotBlank @RequestParam("msg_signature") String msgSignature,
@NotBlank @RequestParam("timestamp") String timestamp,
@NotBlank @RequestParam("nonce") String nonce,
@RequestBody String requestBody) {
log.debug("收到企微回调: msg_signature={}, timestamp={}, nonce={}", msgSignature, timestamp, nonce);
if (!verifySignature(msgSignature, timestamp, nonce, requestBody)) {
log.error("回调消息签名验证失败");
return Result.fail("签名验证失败");
}
if (requestBody.contains("msgaudit_notify")) {
log.info("收到msgaudit_notify回调,触发存档拉取");
@ -47,6 +86,35 @@ public class ArchiveCallbackController {
return Result.success("success");
}
/**
* 验证企微回调签名
* 算法:SHA1(sort(token, timestamp, nonce, msg))
*/
private boolean verifySignature(String signature, String timestamp, String nonce, String msg) {
if (callbackToken == null || callbackToken.isEmpty()) {
log.warn("回调验证token未配置,跳过签名验证");
return true;
}
try {
String[] arr = new String[]{callbackToken, timestamp, nonce, msg};
Arrays.sort(arr);
StringBuilder content = new StringBuilder();
for (String s : arr) {
content.append(s);
}
MessageDigest md = MessageDigest.getInstance("SHA1");
byte[] digest = md.digest(content.toString().getBytes(StandardCharsets.UTF_8));
StringBuilder hex = new StringBuilder();
for (byte b : digest) {
hex.append(String.format("%02x", b));
}
return hex.toString().equals(signature);
} catch (Exception e) {
log.error("签名验证异常: {}", e.getMessage());
return false;
}
}
@PostMapping("/pull")
public Result<String> manualPull(
@RequestParam(value = "seq", required = false) Long seq,

View File

@ -2,6 +2,7 @@ package com.artedu.archive.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
@ -34,6 +35,8 @@ public class ArchiveMessage {
private String sessionId;
private Integer decryptStatus;
private String decryptError;
@TableLogic
private Integer deleted;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@ -1,43 +0,0 @@
package com.artedu.archive.sdk;
import com.sun.jna.Library;
import com.sun.jna.Native;
import com.sun.jna.Pointer;
import com.sun.jna.Structure;
import java.util.Arrays;
import java.util.List;
/**
* 企微会话存档C SDK的JNA接口定义
* 通过JNA调用本地动态库(.so/.dll)
*/
public interface WeWorkFinanceSdk extends Library {
WeWorkFinanceSdk INSTANCE = Native.load(
System.getProperty("os.name").toLowerCase().contains("win") ? "WeWorkFinanceSdk" : "libWeWorkFinanceSdk",
WeWorkFinanceSdk.class
);
long NewSdk();
int Init(long sdk, String corpId, String secret);
int GetChatData(long sdk, long seq, int limit, String proxy, String passwd, int timeout, long chatData);
int DecryptData(long sdk, String encryptKey, String encryptMsg, long msg);
int DestroySdk(long sdk);
void FreeSlice(long slice);
class Slice_t extends Structure {
public Pointer content;
public int len;
@Override
protected List<String> getFieldOrder() {
return Arrays.asList("content", "len");
}
}
}

View File

@ -2,10 +2,10 @@ package com.artedu.archive.service;
import com.artedu.archive.entity.ArchiveMessage;
import com.artedu.archive.mapper.ArchiveMessageMapper;
import com.artedu.archive.sdk.WeWorkFinanceSdk;
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;
@ -15,6 +15,7 @@ import org.springframework.stereotype.Service;
import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@ -22,7 +23,7 @@ import java.util.concurrent.TimeUnit;
/**
* 存档拉取服务
* 通过C SDK拉取企微会话存档消息
* 通过企微官方JNI SDK (com.tencent.wework.Finance) 拉取会话存档消息
*/
@Slf4j
@Service
@ -37,6 +38,12 @@ public class ArchivePullService {
@Value("${wecom.archive.rsa-private-key:}")
private String rsaPrivateKey;
@Value("${wecom.archive.sdk-path:}")
private String sdkPath;
@Value("${wecom.archive.mock-mode:false}")
private boolean mockMode;
@Autowired
private ArchiveMessageMapper archiveMessageMapper;
@ -54,33 +61,102 @@ public class ArchivePullService {
@PostConstruct
public void init() {
sdk = WeWorkFinanceSdk.INSTANCE.NewSdk();
int ret = WeWorkFinanceSdk.INSTANCE.Init(sdk, corpId, secret);
if (ret != 0) {
log.error("企微存档SDK初始化失败, ret={}", ret);
throw new RuntimeException("SDK初始化失败: " + ret);
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;
} else {
log.info("企微存档SDK初始化成功");
}
} catch (UnsatisfiedLinkError e) {
log.error("企微存档SDK原生库加载失败: {}", e.getMessage());
sdk = 0;
}
}
/**
* 将 sdk-path 添加到 java.library.path,使 JNI System.loadLibrary 能找到 DLL
*/
private void setupLibraryPath() {
if (sdkPath == null || sdkPath.isEmpty()) {
return;
}
File sdkDir = new File(sdkPath);
if (!sdkDir.exists() || !sdkDir.isDirectory()) {
log.warn("SDK路径不存在: {}", sdkPath);
return;
}
String existingPath = System.getProperty("java.library.path", "");
if (!existingPath.contains(sdkPath)) {
String newPath = existingPath.isEmpty() ? sdkPath : existingPath + File.pathSeparator + sdkPath;
System.setProperty("java.library.path", newPath);
// 刷新 JNA / JNI 的库路径缓存
try {
java.lang.reflect.Field field = ClassLoader.class.getDeclaredField("usr_paths");
field.setAccessible(true);
field.set(null, null);
} catch (Exception e) {
log.warn("刷新java.library.path缓存失败: {}", e.getMessage());
}
log.info("已将SDK路径添加到java.library.path: {}", sdkPath);
}
log.info("企微存档SDK初始化成功");
}
@PreDestroy
public void destroy() {
WeWorkFinanceSdk.INSTANCE.DestroySdk(sdk);
log.info("企微存档SDK已释放");
if (sdk != 0) {
Finance.DestroySdk(sdk);
log.info("企微存档SDK已释放");
}
}
public boolean isMockMode() {
return mockMode || sdk == 0;
}
public List<ArchiveMessage> pullMessages(long seq, int limit) {
List<ArchiveMessage> messages = new ArrayList<>();
long chatDataSlice = 0;
// Mock 模式:返回模拟数据
if (mockMode || sdk == 0) {
if (mockMode) {
log.info("Mock模式:生成模拟存档消息, seq={}, limit={}", seq, limit);
return MockArchiveDataProvider.generate(seq, limit, corpId);
}
log.warn("企微存档SDK未初始化,跳过拉取");
return messages;
}
long chatDatas = 0;
try {
int ret = WeWorkFinanceSdk.INSTANCE.GetChatData(sdk, seq, limit, null, null, 5, chatDataSlice);
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 = extractFromSlice(chatDataSlice);
String jsonData = Finance.GetContentFromSlice(chatDatas);
if (jsonData == null || jsonData.isEmpty()) {
return messages;
}
@ -110,8 +186,8 @@ public class ArchivePullService {
log.info("拉取存档消息完成: seq={}, limit={}, 成功解析{}条", seq, limit, messages.size());
} finally {
if (chatDataSlice != 0) {
WeWorkFinanceSdk.INSTANCE.FreeSlice(chatDataSlice);
if (chatDatas != 0) {
Finance.FreeSlice(chatDatas);
}
}
@ -171,24 +247,25 @@ public class ArchivePullService {
private String decryptMessage(String encryptKey, String encryptMsg) {
long msgSlice = 0;
try {
int ret = WeWorkFinanceSdk.INSTANCE.DecryptData(sdk, encryptKey, encryptMsg, msgSlice);
msgSlice = Finance.NewSlice();
if (msgSlice == 0) {
log.error("NewSlice返回0,内存不足");
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);
return null;
}
return extractFromSlice(msgSlice);
return Finance.GetContentFromSlice(msgSlice);
} finally {
if (msgSlice != 0) {
WeWorkFinanceSdk.INSTANCE.FreeSlice(msgSlice);
Finance.FreeSlice(msgSlice);
}
}
}
private String extractFromSlice(long slice) {
// 实际项目中需要根据JNA内存布局正确实现
return "";
}
private String detectRole(String fromUser) {
if (fromUser != null && fromUser.startsWith("wm")) {
return "EXTERNAL";

View File

@ -0,0 +1,76 @@
package com.artedu.archive.service;
import com.artedu.archive.entity.ArchiveMessage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/**
* Mock 存档数据生成器
* 当没有真实企微 SDK 时,生成模拟会话存档消息用于测试链路
*/
public class MockArchiveDataProvider {
private static final Logger log = LoggerFactory.getLogger(MockArchiveDataProvider.class);
private static final String[] MOCK_MSG_TYPES = {"text", "text", "text", "image", "voice"};
private static final String[] MOCK_CONTENTS = {
"你好,我想咨询一下原画课程",
"好的,我们这边有2D原画和3D建模两个方向",
"2D原画班的学费是多少呢?",
"2D原画班学费是16800,现在报名有优惠",
"那上课时间是怎样的?",
"每周一到周五,上午9点到下午6点",
"可以试听吗?",
"可以的,我们提供免费试听一周",
"好的,我先考虑一下",
"没问题,有问题随时联系我"
};
private static final String[] MOCK_FROM_USERS = {"wx_001", "wm1234567890abcdef"};
private static final String[] MOCK_TO_USERS = {"staff_001", "wx_001"};
/**
* 生成模拟存档消息
*
* @param startSeq 起始 seq
* @param limit 最大条数
* @param corpId 企业ID
* @return 模拟消息列表
*/
public static List<ArchiveMessage> generate(long startSeq, int limit, String corpId) {
List<ArchiveMessage> messages = new ArrayList<>();
int count = Math.min(limit, MOCK_CONTENTS.length);
long now = System.currentTimeMillis();
for (int i = 0; i < count; i++) {
long seq = startSeq + i + 1;
boolean isFromCustomer = i % 2 == 0;
String fromUser = isFromCustomer ? MOCK_FROM_USERS[0] : MOCK_FROM_USERS[1];
String toUser = isFromCustomer ? MOCK_TO_USERS[0] : MOCK_TO_USERS[1];
ArchiveMessage msg = new ArchiveMessage();
msg.setMsgid("mock_" + corpId + "_" + seq);
msg.setSeq(seq);
msg.setCorpId(corpId);
msg.setAction("send");
msg.setFromUser(fromUser);
msg.setFromRole(fromUser.startsWith("wm") ? "EXTERNAL" : "INTERNAL");
msg.setToUser(toUser);
msg.setTolist("[\"" + toUser + "\"]");
msg.setMsgtype(MOCK_MSG_TYPES[i % MOCK_MSG_TYPES.length]);
msg.setMsgtime(now - (count - i) * 60000L);
msg.setContent(MOCK_CONTENTS[i]);
msg.setDecryptStatus(1);
msg.setSessionId(corpId + "_" + fromUser + "_" + toUser);
msg.setCreatedAt(LocalDateTime.now());
msg.setUpdatedAt(LocalDateTime.now());
messages.add(msg);
}
log.info("Mock模式生成存档消息: startSeq={}, limit={}, 实际生成{}条", startSeq, limit, messages.size());
return messages;
}
}

View File

@ -0,0 +1,117 @@
package com.tencent.wework;
/*
typedef struct Slice_t {
char* buf;
int len;
} Slice_t;
typedef struct MediaData {
char* outindexbuf;
int out_len;
char* data;
int data_len;
int is_finish;
} MediaData_t;
*/
public class Finance {
public native static long NewSdk();
/**
* Initialize SDK
* Return 0 = success
*
* @param sdk SDK handle returned by NewSdk
* @param corpid Enterprise WeChat CorpID
* @param secret Session archive secret
* @return 0 = success, != 0 = fail
*/
public native static int Init(long sdk, String corpid, String secret);
/**
* Fetch encrypted session archive data
* Return 0 = success
*
* @param sdk SDK handle
* @param seq Start sequence number (use 0 for first request)
* @param limit Max messages per request (max 1000)
* @param proxy Proxy URL (optional)
* @param passwd Proxy credentials (optional)
* @param timeout Timeout in seconds
* @param chatDatas Output slice containing chat data JSON
* @return 0 = success, != 0 = fail
*/
public native static int GetChatData(long sdk, long seq, long limit, String proxy, String passwd, long timeout, long chatData);
/**
* Fetch media data
*
* @param sdk SDK handle
* @param indexbuf Media index buffer
* @param sdkFileid Media file ID from chat data
* @param proxy Proxy URL (optional)
* @param passwd Proxy credentials (optional)
* @param timeout Timeout in seconds
* @param mediaData Output MediaData structure
* @return 0 = success, != 0 = fail
*/
public native static int GetMediaData(long sdk, String indexbuf, String sdkField, String proxy, String passwd, long timeout, long mediaData);
/**
* Decrypt session archive message
*
* @param encrypt_key encrypt_random_key from GetChatData, decrypted with RSA private key
* @param encrypt_msg encrypt_chat_msg from GetChatData
* @param msg Output slice containing decrypted message JSON
* @return 0 = success, != 0 = fail
*/
public native static int DecryptData(long sdk, String encrypt_key, String encrypt_msg, long msg);
public native static void DestroySdk(long sdk);
public native static long NewSlice();
/**
* Free slice (use with NewSlice)
*/
public native static void FreeSlice(long slice);
/**
* Get content from slice as String
*/
public native static String GetContentFromSlice(long slice);
/**
* Get content length from slice
*/
public native static int GetSliceLen(long slice);
public native static long NewMediaData();
public native static void FreeMediaData(long mediaData);
/**
* Get outindexbuf from mediaData
*/
public native static String GetOutIndexBuf(long mediaData);
/**
* Get data bytes from mediaData
*/
public native static byte[] GetData(long mediaData);
public native static int GetIndexLen(long mediaData);
public native static int GetDataLen(long mediaData);
/**
* Check if mediaData download is finished
* @return 1 = finished, 0 = not finished
*/
public native static int IsMediaDataFinish(long mediaData);
static {
System.loadLibrary("WeWorkFinanceSdk");
}
}

View File

@ -1,4 +1,9 @@
server:
servlet:
encoding:
charset: UTF-8
enabled: true
force: true
port: 8082
spring:
@ -6,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=utf8&serverTimezone=Asia/Shanghai&useSSL=false
url: jdbc:mysql://${MYSQL_HOST:localhost}:${MYSQL_PORT:3306}/${MYSQL_DATABASE:ai_assistant}?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai&useSSL=false
username: ${MYSQL_USERNAME:root}
password: ${MYSQL_PASSWORD:root}
redis:
@ -25,6 +30,15 @@ spring:
username: ${RABBITMQ_USERNAME:admin}
password: ${RABBITMQ_PASSWORD:admin}
wecom:
archive:
corp-id: wwd483c2fba24ae30a
secret: ${WECOM_ARCHIVE_SECRET:}
rsa-private-key: ${WECOM_ARCHIVE_RSA_KEY:}
callback-token: ${WECOM_ARCHIVE_CALLBACK_TOKEN:}
sdk-path: ${WECOM_ARCHIVE_SDK_PATH:}
mock-mode: ${WECOM_ARCHIVE_MOCK:false}
mybatis-plus:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

View File

@ -0,0 +1,122 @@
package com.artedu.archive.controller;
import com.artedu.archive.service.ArchivePullService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.web.servlet.MockMvc;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Arrays;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@SpringBootTest(properties = {
"wecom.archive.callback-token=test-token",
"spring.data.redis.repositories.enabled=false",
"spring.rabbitmq.listener.simple.auto-startup=false"
})
@AutoConfigureMockMvc
public class ArchiveCallbackControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private ArchivePullService archivePullService;
@Test
void testVerifyUrlSuccess() throws Exception {
String token = "test-token";
String timestamp = "1234567890";
String nonce = "nonce123";
String echostr = "hello-world";
String signature = sha1(token, timestamp, nonce, echostr);
mockMvc.perform(get("/api/v1/archive/callback")
.param("msg_signature", signature)
.param("timestamp", timestamp)
.param("nonce", nonce)
.param("echostr", echostr))
.andExpect(status().isOk())
.andExpect(content().string(echostr));
}
@Test
void testVerifyUrlInvalidSignature() throws Exception {
mockMvc.perform(get("/api/v1/archive/callback")
.param("msg_signature", "invalid")
.param("timestamp", "123")
.param("nonce", "nonce")
.param("echostr", "test"))
.andExpect(status().isOk())
.andExpect(content().string("fail"));
}
@Test
void testCallbackWithMsgauditNotify() throws Exception {
when(archivePullService.getLastSeq()).thenReturn(0L);
when(archivePullService.pullMessages(anyLong(), anyInt())).thenReturn(java.util.List.of());
String body = "<xml><InfoType>msgaudit_notify</InfoType></xml>";
String token = "test-token";
String timestamp = "123";
String nonce = "nonce";
String signature = sha1(token, timestamp, nonce, body);
mockMvc.perform(post("/api/v1/archive/callback")
.param("msg_signature", signature)
.param("timestamp", timestamp)
.param("nonce", nonce)
.content(body))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
// 异步执行,短暂等待验证方法被调用
Thread.sleep(500);
verify(archivePullService, atLeastOnce()).getLastSeq();
}
@Test
void testManualPull() throws Exception {
when(archivePullService.getLastSeq()).thenReturn(5L);
when(archivePullService.pullMessages(5L, 100)).thenReturn(java.util.List.of());
mockMvc.perform(post("/api/v1/archive/pull")
.param("seq", "5")
.param("limit", "100"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data").value("拉取完成,共0条消息"));
}
@Test
void testCallbackMissingParam_shouldReturn400() throws Exception {
mockMvc.perform(post("/api/v1/archive/callback")
.param("timestamp", "123")
.param("nonce", "nonce")
.content("test"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(400));
}
private String sha1(String... arr) throws Exception {
Arrays.sort(arr);
StringBuilder content = new StringBuilder();
for (String s : arr) {
content.append(s);
}
MessageDigest md = MessageDigest.getInstance("SHA1");
byte[] digest = md.digest(content.toString().getBytes(StandardCharsets.UTF_8));
StringBuilder hex = new StringBuilder();
for (byte b : digest) {
hex.append(String.format("%02x", b));
}
return hex.toString();
}
}

View File

@ -0,0 +1,118 @@
package com.artedu.archive.service;
import com.artedu.archive.entity.ArchiveMessage;
import com.artedu.archive.mapper.ArchiveMessageMapper;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
@SpringBootTest(properties = {
"wecom.archive.mock-mode=true",
"wecom.archive.corp-id=wwd483c2fba24ae30a",
"spring.data.redis.repositories.enabled=false",
"spring.rabbitmq.listener.simple.auto-startup=false"
})
public class ArchivePullServiceTest {
@Autowired
private ArchivePullService archivePullService;
@MockBean
private ArchiveMessageMapper archiveMessageMapper;
@MockBean
private RedisTemplate<String, Object> redisTemplate;
@MockBean
private RabbitTemplate rabbitTemplate;
@Test
void testMockModeEnabled() {
assertTrue(archivePullService.isMockMode(), "Mock模式应已启用");
}
@Test
void testPullMessagesMockData() {
List<ArchiveMessage> messages = archivePullService.pullMessages(0, 5);
assertNotNull(messages);
assertFalse(messages.isEmpty(), "Mock模式应返回模拟数据");
assertEquals(5, messages.size());
ArchiveMessage first = messages.get(0);
assertNotNull(first.getMsgid());
assertEquals(1L, first.getSeq());
assertEquals("wwd483c2fba24ae30a", first.getCorpId());
assertNotNull(first.getContent());
assertNotNull(first.getFromUser());
}
@Test
void testPullMessagesMockDataPagination() {
List<ArchiveMessage> page1 = archivePullService.pullMessages(0, 3);
List<ArchiveMessage> page2 = archivePullService.pullMessages(3, 3);
assertEquals(3, page1.size());
assertEquals(3, page2.size());
assertEquals(1L, page1.get(0).getSeq());
assertEquals(4L, page2.get(0).getSeq());
}
@Test
void testGetLastSeqFromRedis() {
when(redisTemplate.opsForValue()).thenReturn(mock(org.springframework.data.redis.core.ValueOperations.class));
when(redisTemplate.opsForValue().get("archive:last_seq:wwd483c2fba24ae30a")).thenReturn(100L);
long seq = archivePullService.getLastSeq();
assertEquals(100L, seq);
}
@Test
void testGetLastSeqFromDbFallback() {
when(redisTemplate.opsForValue()).thenReturn(mock(org.springframework.data.redis.core.ValueOperations.class));
when(redisTemplate.opsForValue().get("archive:last_seq:wwd483c2fba24ae30a")).thenReturn(null);
when(archiveMessageMapper.selectMaxSeq("wwd483c2fba24ae30a")).thenReturn(50L);
long seq = archivePullService.getLastSeq();
assertEquals(50L, seq);
}
@Test
void testSaveAndNotifyDedup() {
ArchiveMessage msg = new ArchiveMessage();
msg.setMsgid("mock_001");
msg.setCorpId("wwd483c2fba24ae30a");
msg.setSeq(1L);
when(archiveMessageMapper.countByMsgId("mock_001", "wwd483c2fba24ae30a")).thenReturn(1);
archivePullService.saveAndNotify(List.of(msg));
verify(archiveMessageMapper, never()).insert(any());
}
@Test
void testSaveAndNotifyInsert() {
ArchiveMessage msg = new ArchiveMessage();
msg.setMsgid("mock_002");
msg.setCorpId("wwd483c2fba24ae30a");
msg.setSeq(2L);
when(archiveMessageMapper.countByMsgId("mock_002", "wwd483c2fba24ae30a")).thenReturn(0);
archivePullService.saveAndNotify(List.of(msg));
verify(archiveMessageMapper, times(1)).insert(msg);
verify(rabbitTemplate, times(1)).convertAndSend(anyString(), anyString(), anyString());
}
}

View File

@ -1,4 +1,9 @@
server:
servlet:
encoding:
charset: UTF-8
enabled: true
force: true
port: 8082
spring:
@ -25,6 +30,15 @@ spring:
username: ${RABBITMQ_USERNAME:admin}
password: ${RABBITMQ_PASSWORD:admin}
wecom:
archive:
corp-id: wwd483c2fba24ae30a
secret: ${WECOM_ARCHIVE_SECRET:}
rsa-private-key: ${WECOM_ARCHIVE_RSA_KEY:}
callback-token: ${WECOM_ARCHIVE_CALLBACK_TOKEN:}
sdk-path: ${WECOM_ARCHIVE_SDK_PATH:}
mock-mode: ${WECOM_ARCHIVE_MOCK:false}
mybatis-plus:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

View File

@ -1,8 +1,8 @@
com\tencent\wework\Finance.class
com\artedu\archive\service\ArchivePullService.class
com\artedu\archive\controller\ArchiveCallbackController.class
com\artedu\archive\sdk\WeWorkFinanceSdk.class
com\artedu\archive\ArchiveServiceApplication.class
com\artedu\archive\sdk\WeWorkFinanceSdk$Slice_t.class
com\artedu\archive\service\MockArchiveDataProvider.class
com\artedu\archive\entity\ArchiveMessage.class
com\artedu\archive\mapper\ArchiveMessageMapper.class
com\artedu\archive\util\RsaDecryptUtil.class

View File

@ -2,6 +2,7 @@ D:\www\agent_9art\backend\archive-service\src\main\java\com\artedu\archive\Archi
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\entity\ArchiveMessage.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\sdk\WeWorkFinanceSdk.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\MockArchiveDataProvider.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\tencent\wework\Finance.java

View File

@ -0,0 +1,2 @@
com\artedu\archive\controller\ArchiveCallbackControllerTest.class
com\artedu\archive\service\ArchivePullServiceTest.class

View File

@ -0,0 +1,2 @@
D:\www\agent_9art\backend\archive-service\src\test\java\com\artedu\archive\controller\ArchiveCallbackControllerTest.java
D:\www\agent_9art\backend\archive-service\src\test\java\com\artedu\archive\service\ArchivePullServiceTest.java

View File

@ -1,5 +1,5 @@
FROM openjdk:17-jdk-slim
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
COPY target/*.jar app.jar
EXPOSE 8080
EXPOSE 8081
ENTRYPOINT ["java", "-jar", "app.jar"]

View File

@ -40,6 +40,11 @@
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>

View File

@ -7,6 +7,7 @@ import com.artedu.common.result.Result;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
@ -17,6 +18,7 @@ import java.util.Map;
* 处理企微OAuth登录和Token刷新
*/
@Slf4j
@Validated
@RestController
@RequestMapping("/api/v1/auth")
public class AuthController {
@ -91,14 +93,12 @@ public class AuthController {
return Result.success(result);
}
@Value("${wecom.corp-id}")
private String corpId;
@GetMapping("/signature")
public Result<Map<String, Object>> getJsSdkSignature(
@RequestParam("url") String url,
@RequestHeader("Authorization") String authHeader) {
String token = authHeader.replace("Bearer ", "");
io.jsonwebtoken.Claims claims = jwtService.validateToken(token);
String corpId = claims.get("corpId", String.class);
@RequestParam("url") String url) {
String ticket = weComOAuthService.getJsApiTicket();
String nonceStr = java.util.UUID.randomUUID().toString().replace("-", "");

View File

@ -1,4 +1,9 @@
server:
servlet:
encoding:
charset: UTF-8
enabled: true
force: true
port: 8081
spring:
@ -6,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=utf8&serverTimezone=Asia/Shanghai&useSSL=false
url: jdbc:mysql://${MYSQL_HOST:localhost}:${MYSQL_PORT:3306}/${MYSQL_DATABASE:ai_assistant}?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai&useSSL=false
username: ${MYSQL_USERNAME:root}
password: ${MYSQL_PASSWORD:root}
redis:
@ -21,12 +26,12 @@ spring:
min-idle: 0
wecom:
corp-id: ${WECOM_CORP_ID:}
agent-id: ${WECOM_AGENT_ID:}
secret: ${WECOM_SECRET:}
corp-id: wwd483c2fba24ae30a
agent-id: 1000021
secret: nU1xezyHyonS9rOJ9Kwo3i5OM9iuWc1TrjXf-rOYZx0
jwt:
secret: ${JWT_SECRET:your-256-bit-secret-key-here-at-least-32-characters}
secret: ${JWT_SECRET:9artedu-ai-assistant-jwt-secret-key-2024}
expiration: 86400
mybatis-plus:

View File

@ -0,0 +1,103 @@
package com.artedu.auth.controller;
import com.artedu.auth.service.WeComOAuthService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
public class AuthControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private WeComOAuthService weComOAuthService;
@Test
void testGetJsSdkSignature() throws Exception {
when(weComOAuthService.getJsApiTicket()).thenReturn("test_ticket_123");
mockMvc.perform(get("/api/v1/auth/signature")
.param("url", "http://localhost:5173/"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.corpId").exists())
.andExpect(jsonPath("$.data.signature").exists())
.andExpect(jsonPath("$.data.timestamp").exists())
.andExpect(jsonPath("$.data.nonceStr").exists());
}
@Test
void testGetJsSdkSignatureWithDifferentUrl() throws Exception {
when(weComOAuthService.getJsApiTicket()).thenReturn("test_ticket_456");
MvcResult result1 = mockMvc.perform(get("/api/v1/auth/signature")
.param("url", "http://localhost:5173/path1"))
.andReturn();
MvcResult result2 = mockMvc.perform(get("/api/v1/auth/signature")
.param("url", "http://localhost:5173/path2"))
.andReturn();
String sig1 = result1.getResponse().getContentAsString();
String sig2 = result2.getResponse().getContentAsString();
// 不同 URL 应该产生不同签名
assertNotEquals(sig1, sig2);
}
@Test
void testGetUserInfoWithValidToken() throws Exception {
// 先用签名接口获取一个正常响应,验证 token 解析需要更复杂的设置
// 这里简单验证接口存在
mockMvc.perform(get("/api/v1/auth/signature")
.param("url", "http://test.com"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
}
@Test
void testSignatureDeterminismWithSameNonce() {
// 验证签名算法的确定性:相同输入应产生相同输出
String ticket = "ticket123";
String nonce = "nonce456";
String timestamp = "1234567890";
String url = "http://test.com";
String sig1 = sha1("jsapi_ticket=" + ticket + "&noncestr=" + nonce + "&timestamp=" + timestamp + "&url=" + url);
String sig2 = sha1("jsapi_ticket=" + ticket + "&noncestr=" + nonce + "&timestamp=" + timestamp + "&url=" + url);
assertEquals(sig1, sig2);
}
private String sha1(String input) {
try {
java.security.MessageDigest digest = java.security.MessageDigest.getInstance("SHA1");
digest.update(input.getBytes());
byte[] messageDigest = digest.digest();
StringBuilder hexString = new StringBuilder();
for (byte b : messageDigest) {
String shaHex = Integer.toHexString(b & 0xFF);
if (shaHex.length() < 2) {
hexString.append(0);
}
hexString.append(shaHex);
}
return hexString.toString();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}

View File

@ -1,4 +1,9 @@
server:
servlet:
encoding:
charset: UTF-8
enabled: true
force: true
port: 8081
spring:
@ -21,12 +26,12 @@ spring:
min-idle: 0
wecom:
corp-id: ${WECOM_CORP_ID:}
agent-id: ${WECOM_AGENT_ID:}
secret: ${WECOM_SECRET:}
corp-id: wwd483c2fba24ae30a
agent-id: 1000021
secret: nU1xezyHyonS9rOJ9Kwo3i5OM9iuWc1TrjXf-rOYZx0
jwt:
secret: ${JWT_SECRET:your-256-bit-secret-key-here-at-least-32-characters}
secret: ${JWT_SECRET:9artedu-ai-assistant-jwt-secret-key-2024}
expiration: 86400
mybatis-plus:

View File

@ -0,0 +1 @@
com\artedu\auth\controller\AuthControllerTest.class

View File

@ -0,0 +1 @@
D:\www\agent_9art\backend\auth-service\src\test\java\com\artedu\auth\controller\AuthControllerTest.java

View File

@ -3,9 +3,15 @@ package com.artedu.common.exception;
import com.artedu.common.result.Result;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.BindException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import javax.validation.ConstraintViolation;
import javax.validation.ConstraintViolationException;
import java.util.stream.Collectors;
/**
* 全局异常处理器
* 统一处理控制器层抛出的异常,转换为标准响应格式
@ -20,6 +26,30 @@ public class GlobalExceptionHandler {
return Result.fail(e.getCode(), e.getMessage());
}
/**
* 处理 @RequestBody + @Valid 校验失败
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
public Result<Void> handleMethodArgumentNotValid(MethodArgumentNotValidException e) {
String message = e.getBindingResult().getFieldErrors().stream()
.map(error -> error.getField() + ": " + error.getDefaultMessage())
.collect(Collectors.joining("; "));
log.warn("参数校验失败: {}", message);
return Result.fail(400, message);
}
/**
* 处理 @RequestParam/@PathVariable + @Valid 校验失败
*/
@ExceptionHandler(ConstraintViolationException.class)
public Result<Void> handleConstraintViolation(ConstraintViolationException e) {
String message = e.getConstraintViolations().stream()
.map(ConstraintViolation::getMessage)
.collect(Collectors.joining("; "));
log.warn("参数校验失败: {}", message);
return Result.fail(400, message);
}
@ExceptionHandler(BindException.class)
public Result<Void> handleBindException(BindException e) {
String message = e.getBindingResult().getFieldErrors().stream()
@ -30,6 +60,18 @@ public class GlobalExceptionHandler {
return Result.fail(400, message);
}
@ExceptionHandler(IllegalArgumentException.class)
public Result<Void> handleIllegalArgument(IllegalArgumentException e) {
log.warn("非法参数: {}", e.getMessage());
return Result.fail(400, e.getMessage());
}
@ExceptionHandler(MissingServletRequestParameterException.class)
public Result<Void> handleMissingParam(MissingServletRequestParameterException e) {
log.warn("缺少参数: {}", e.getMessage());
return Result.fail(400, "参数不能为空: " + e.getParameterName());
}
@ExceptionHandler(Exception.class)
public Result<Void> handleException(Exception e) {
log.error("系统异常: {}", e.getMessage(), e);

View File

@ -1,5 +1,5 @@
FROM openjdk:17-jdk-slim
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
COPY target/*.jar app.jar
EXPOSE 8080
EXPOSE 8083
ENTRYPOINT ["java", "-jar", "app.jar"]

View File

@ -15,6 +15,7 @@
<dependency><groupId>com.artedu</groupId><artifactId>common</artifactId><version>${project.version}</version></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-websocket</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
</dependencies>
<build>
<plugins>

View File

@ -2,15 +2,22 @@ package com.artedu.conversation.controller;
import com.artedu.common.result.Result;
import com.artedu.conversation.entity.Conversation;
import com.artedu.conversation.entity.ConversationTurn;
import com.artedu.conversation.service.ConversationManager;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
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.HashMap;
import java.util.List;
import java.util.Map;
@Slf4j
@Validated
@RestController
@RequestMapping("/api/v1/conversations")
public class ConversationController {
@ -19,7 +26,7 @@ public class ConversationController {
private ConversationManager conversationManager;
@GetMapping("/{sessionId}/context")
public Result<Map<String, Object>> getContext(@PathVariable("sessionId") String sessionId) {
public Result<Map<String, Object>> getContext(@NotBlank(message = "sessionId不能为空") @PathVariable("sessionId") String sessionId) {
String contextStr = conversationManager.getContextString(sessionId);
var messages = conversationManager.getContextMessages(sessionId);
@ -33,14 +40,29 @@ public class ConversationController {
}
@GetMapping("/{sessionId}")
public Result<Conversation> getConversation(@PathVariable("sessionId") String sessionId) {
return Result.success();
public Result<Conversation> getConversation(@NotBlank(message = "sessionId不能为空") @PathVariable("sessionId") String sessionId) {
Conversation conv = conversationManager.getSession(sessionId);
return Result.success(conv);
}
@GetMapping("/{sessionId}/turns")
public Result<List<ConversationTurn>> getTurns(@NotBlank(message = "sessionId不能为空") @PathVariable("sessionId") String sessionId) {
List<ConversationTurn> turns = conversationManager.getTurns(sessionId);
return Result.success(turns);
}
@GetMapping("")
public Result<List<Conversation>> listByCustomer(
@NotBlank(message = "customerId不能为空") @RequestParam("customerId") String customerId,
@NotBlank(message = "corpId不能为空") @RequestParam("corpId") String corpId) {
List<Conversation> list = conversationManager.listByCustomer(customerId, corpId);
return Result.success(list);
}
@PutMapping("/{sessionId}/status")
public Result<String> updateStatus(
@PathVariable("sessionId") String sessionId,
@RequestParam("stage") String stage,
@NotBlank(message = "sessionId不能为空") @PathVariable("sessionId") String sessionId,
@NotBlank(message = "stage不能为空") @RequestParam("stage") String stage,
@RequestParam(value = "confidence", required = false) Double confidence,
@RequestParam(value = "summary", required = false) String summary) {

View File

@ -33,6 +33,31 @@ public class ConversationManager {
private static final int MAX_CONTEXT_ROUNDS = 10;
private static final int CONTEXT_EXPIRE_MINUTES = 30;
public Conversation getSession(String sessionId) {
return conversationMapper.selectOne(
new LambdaQueryWrapper<Conversation>()
.eq(Conversation::getSessionId, sessionId)
);
}
public List<ConversationTurn> getTurns(String sessionId) {
return conversationTurnMapper.selectList(
new LambdaQueryWrapper<ConversationTurn>()
.eq(ConversationTurn::getSessionId, sessionId)
.orderByAsc(ConversationTurn::getTurnNumber)
);
}
public List<Conversation> listByCustomer(String customerId, String corpId) {
return conversationMapper.selectList(
new LambdaQueryWrapper<Conversation>()
.eq(Conversation::getCustomerId, customerId)
.eq(Conversation::getCorpId, corpId)
.orderByDesc(Conversation::getStartTime)
.last("LIMIT 10")
);
}
public Conversation getOrCreateSession(String sessionId, String corpId,
String customerId, String staffId) {
Conversation conv = conversationMapper.selectOne(

View File

@ -1,4 +1,9 @@
server:
servlet:
encoding:
charset: UTF-8
enabled: true
force: true
port: 8083
spring:
@ -6,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=utf8&serverTimezone=Asia/Shanghai&useSSL=false
url: jdbc:mysql://${MYSQL_HOST:localhost}:${MYSQL_PORT:3306}/${MYSQL_DATABASE:ai_assistant}?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai&useSSL=false
username: ${MYSQL_USERNAME:root}
password: ${MYSQL_PASSWORD:root}
redis:

View File

@ -0,0 +1,143 @@
package com.artedu.conversation.controller;
import com.artedu.conversation.entity.Conversation;
import com.artedu.conversation.entity.ConversationTurn;
import com.artedu.conversation.service.ConversationManager;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.web.servlet.MockMvc;
import java.time.LocalDateTime;
import java.util.List;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest(properties = {
"spring.data.redis.repositories.enabled=false",
"spring.rabbitmq.listener.simple.auto-startup=false"
})
@AutoConfigureMockMvc
public class ConversationControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private ConversationManager conversationManager;
@Test
void testGetConversation() throws Exception {
Conversation conv = new Conversation();
conv.setSessionId("sess_001");
conv.setCustomerId("wx_001");
conv.setCurrentStage("STAGE-03");
conv.setRoundCount(5);
when(conversationManager.getSession("sess_001")).thenReturn(conv);
mockMvc.perform(get("/api/v1/conversations/sess_001"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.sessionId").value("sess_001"))
.andExpect(jsonPath("$.data.roundCount").value(5));
}
@Test
void testGetTurns() throws Exception {
ConversationTurn turn1 = new ConversationTurn();
turn1.setTurnNumber(1);
turn1.setStudentContent("学员消息1");
turn1.setSeatContent("顾问回复1");
ConversationTurn turn2 = new ConversationTurn();
turn2.setTurnNumber(2);
turn2.setStudentContent("学员消息2");
turn2.setSeatContent("顾问回复2");
when(conversationManager.getTurns("sess_001"))
.thenReturn(List.of(turn1, turn2));
mockMvc.perform(get("/api/v1/conversations/sess_001/turns"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.length()").value(2))
.andExpect(jsonPath("$.data[0].studentContent").value("学员消息1"));
}
@Test
void testListByCustomer() throws Exception {
Conversation conv1 = new Conversation();
conv1.setSessionId("sess_001");
conv1.setCustomerId("wx_001");
Conversation conv2 = new Conversation();
conv2.setSessionId("sess_002");
conv2.setCustomerId("wx_001");
when(conversationManager.listByCustomer("wx_001", "corp001"))
.thenReturn(List.of(conv1, conv2));
mockMvc.perform(get("/api/v1/conversations")
.param("customerId", "wx_001")
.param("corpId", "corp001"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.length()").value(2));
}
@Test
void testUpdateStatus() throws Exception {
doNothing().when(conversationManager)
.updateConversationStatus("sess_001", "STAGE-04", 0.95, "摘要");
mockMvc.perform(put("/api/v1/conversations/sess_001/status")
.param("stage", "STAGE-04")
.param("confidence", "0.95")
.param("summary", "摘要"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data").value("状态更新成功"));
}
@Test
void testGetContext() throws Exception {
when(conversationManager.getContextString("sess_001")).thenReturn("学员: 你好");
when(conversationManager.getContextMessages("sess_001")).thenReturn(List.of());
mockMvc.perform(get("/api/v1/conversations/sess_001/context"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.context").value("学员: 你好"));
}
@Test
void testGetConversationBlankSessionId_shouldReturn400() throws Exception {
mockMvc.perform(get("/api/v1/conversations/ "))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(400))
.andExpect(jsonPath("$.message").exists());
}
@Test
void testListByCustomerMissingCustomerId_shouldReturn400() throws Exception {
mockMvc.perform(get("/api/v1/conversations")
.param("corpId", "corp001"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(400))
.andExpect(jsonPath("$.message").exists());
}
@Test
void testUpdateStatusMissingStage_shouldReturn400() throws Exception {
mockMvc.perform(put("/api/v1/conversations/sess_001/status"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(400))
.andExpect(jsonPath("$.message").exists());
}
}

View File

@ -1,4 +1,9 @@
server:
servlet:
encoding:
charset: UTF-8
enabled: true
force: true
port: 8083
spring:

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