chore: 完成项目整体打包与配置更新
1. 更新所有服务Dockerfile,切换到eclipse-temurin:17-jre-alpine镜像并调整服务端口 2. 配置前端项目基础路径与nginx代理 3. 新增数据库初始化编码设置与测试依赖 4. 添加部分服务的Servlet编码配置与JWT/LLM密钥配置 5. 完善话术实体类校验注解与Mapper接口 6. 新增密码生成工具类与部分测试文件配置
This commit is contained in:
parent
183698824a
commit
d327826c78
31
.env
Normal file
31
.env
Normal 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
30
.gitignore
vendored
Normal 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/
|
||||
|
||||
@ -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
BIN
GenerateHash.class
Normal file
Binary file not shown.
9
GenerateHash.java
Normal file
9
GenerateHash.java
Normal 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));
|
||||
}
|
||||
}
|
||||
@ -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"]
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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:
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@ -1,4 +1,9 @@
|
||||
server:
|
||||
servlet:
|
||||
encoding:
|
||||
charset: UTF-8
|
||||
enabled: true
|
||||
force: true
|
||||
port: 8088
|
||||
|
||||
spring:
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,2 +1,3 @@
|
||||
com\artedu\analytics\service\AnalyticsService.class
|
||||
com\artedu\analytics\AnalyticsServiceApplication.class
|
||||
com\artedu\analytics\controller\AnalyticsController.class
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"]
|
||||
|
||||
171
backend/archive-service/SDK_SETUP.md
Normal file
171
backend/archive-service/SDK_SETUP.md
Normal 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`
|
||||
@ -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>
|
||||
|
||||
6
backend/archive-service/sdk/.classpath
Normal file
6
backend/archive-service/sdk/.classpath
Normal 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>
|
||||
17
backend/archive-service/sdk/.project
Normal file
17
backend/archive-service/sdk/.project
Normal 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>
|
||||
@ -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
|
||||
3
backend/archive-service/sdk/META-INF/MANIFEST.MF
Normal file
3
backend/archive-service/sdk/META-INF/MANIFEST.MF
Normal file
@ -0,0 +1,3 @@
|
||||
Manifest-Version: 1.0
|
||||
Main-Class: com.company.Main
|
||||
|
||||
BIN
backend/archive-service/sdk/WeWorkFinanceSdk.dll
Normal file
BIN
backend/archive-service/sdk/WeWorkFinanceSdk.dll
Normal file
Binary file not shown.
BIN
backend/archive-service/sdk/libcrypto-3-x64.dll
Normal file
BIN
backend/archive-service/sdk/libcrypto-3-x64.dll
Normal file
Binary file not shown.
BIN
backend/archive-service/sdk/libcurl-x64.dll
Normal file
BIN
backend/archive-service/sdk/libcurl-x64.dll
Normal file
Binary file not shown.
138
backend/archive-service/sdk/src/com/company/Main.java
Normal file
138
backend/archive-service/sdk/src/com/company/Main.java
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
128
backend/archive-service/sdk/src/com/tencent/wework/Finance.java
Normal file
128
backend/archive-service/sdk/src/com/tencent/wework/Finance.java
Normal 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");
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 (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);
|
||||
throw new RuntimeException("SDK初始化失败: " + 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);
|
||||
}
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
public void destroy() {
|
||||
WeWorkFinanceSdk.INSTANCE.DestroySdk(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";
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@ -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
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -0,0 +1,2 @@
|
||||
com\artedu\archive\controller\ArchiveCallbackControllerTest.class
|
||||
com\artedu\archive\service\ArchivePullServiceTest.class
|
||||
@ -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
|
||||
Binary file not shown.
Binary file not shown.
@ -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"]
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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("-", "");
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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 + "×tamp=" + timestamp + "&url=" + url);
|
||||
String sig2 = sha1("jsapi_ticket=" + ticket + "&noncestr=" + nonce + "×tamp=" + 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@ -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:
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -0,0 +1 @@
|
||||
com\artedu\auth\controller\AuthControllerTest.class
|
||||
@ -0,0 +1 @@
|
||||
D:\www\agent_9art\backend\auth-service\src\test\java\com\artedu\auth\controller\AuthControllerTest.java
|
||||
Binary file not shown.
@ -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);
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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"]
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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) {
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,9 @@
|
||||
server:
|
||||
servlet:
|
||||
encoding:
|
||||
charset: UTF-8
|
||||
enabled: true
|
||||
force: true
|
||||
port: 8083
|
||||
|
||||
spring:
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user