feat(pkb): Complete PKB module frontend migration with V3 design

Summary:
- Implement PKB Dashboard and Workspace pages based on V3 prototype
- Add single-layer header with integrated Tab navigation
- Implement 3 work modes: Full Text, Deep Read, Batch Processing
- Integrate Ant Design X Chat component for AI conversations
- Create BatchModeComplete with template selection and document processing
- Add compact work mode selector with dropdown design

Backend:
- Migrate PKB controllers and services to /modules/pkb structure
- Register v2 API routes at /api/v2/pkb/knowledge
- Maintain dual API routes for backward compatibility

Technical details:
- Use Zustand for state management
- Handle SSE streaming responses for AI chat
- Support document selection for Deep Read mode
- Implement batch processing with progress tracking

Known issues:
- Batch processing API integration pending
- Knowledge assets page navigation needs optimization

Status: Frontend functional, pending refinement
This commit is contained in:
2026-01-06 22:15:42 +08:00
parent b31255031e
commit 5a17d096a7
226 changed files with 14899 additions and 224 deletions

View File

@@ -170,3 +170,7 @@ logger.info('[SessionMemory] 会话记忆管理器已启动', {

View File

@@ -104,3 +104,7 @@ async function checkTableStructure() {
checkTableStructure();

View File

@@ -91,3 +91,7 @@ checkProjectConfig().catch(console.error);

View File

@@ -73,3 +73,7 @@ main();

View File

@@ -0,0 +1,335 @@
/**
* 微信服务号回调控制器 - 明文模式Plain Text Mode
*
* 功能:
* 1. URL验证GET请求
* 2. 接收消息推送POST请求
* 3. 签名校验SHA1
* 4. 不进行消息加解密
*
* 官方文档:
* https://developers.weixin.qq.com/doc/offiaccount/Basic_Information/Access_Overview.html
*
* @author AI Assistant
* @date 2026-01-04
*/
import { FastifyRequest, FastifyReply } from 'fastify';
import crypto from 'crypto';
import { logger } from '../../../common/logging/index.js';
/**
* URL验证请求参数
*/
interface WechatMpVerifyQuery {
signature: string; // 微信加密签名
timestamp: string; // 时间戳
nonce: string; // 随机数
echostr: string; // 随机字符串
}
/**
* 消息推送回调请求参数
*/
interface WechatMpCallbackQuery {
signature: string; // 微信加密签名
timestamp: string; // 时间戳
nonce: string; // 随机数
}
/**
* 微信服务号回调控制器(明文模式)
*/
export class PatientWechatCallbackPlainController {
private token: string;
constructor() {
// 从环境变量读取Token
this.token = process.env.WECHAT_MP_TOKEN || '';
if (!this.token) {
logger.error('⚠️ WECHAT_MP_TOKEN 未配置!');
throw new Error('微信服务号Token未配置');
}
logger.info('✅ 微信服务号回调控制器已初始化(明文模式)', {
token: this.token.substring(0, 10) + '...',
});
}
/**
* 处理URL验证GET请求
*
* 微信服务器会发送GET请求验证服务器地址的有效性
* GET /wechat/patient/callback?signature=xxx&timestamp=xxx&nonce=xxx&echostr=xxx
*
* 验证步骤:
* 1. 将token、timestamp、nonce三个参数进行字典序排序
* 2. 将三个参数字符串拼接成一个字符串进行sha1加密
* 3. 将加密后的字符串与signature对比如果相同则返回echostr
*/
async handleVerification(
request: FastifyRequest<{ Querystring: WechatMpVerifyQuery }>,
reply: FastifyReply
): Promise<void> {
const { signature, timestamp, nonce, echostr } = request.query;
logger.info('📥 收到微信服务号 URL 验证请求(明文模式)', {
signature,
timestamp,
nonce,
echostr: echostr ? echostr.substring(0, 20) + '...' : undefined,
});
try {
// 验证签名
const isValid = this.verifySignature(signature, timestamp, nonce);
if (!isValid) {
logger.error('❌ URL 验证失败:签名不匹配', {
signature,
timestamp,
nonce,
});
reply.code(403).send('Signature verification failed');
return;
}
logger.info('✅ URL 验证成功,返回 echostr', {
echostr: echostr.substring(0, 20) + '...',
});
// 验证成功原样返回echostr纯文本
reply.type('text/plain').send(echostr);
} catch (error) {
logger.error('❌ URL 验证异常', {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
});
reply.code(500).send('Internal Server Error');
}
}
/**
* 处理消息推送回调POST请求
*
* 微信服务器推送消息时会发送POST请求
* POST /wechat/patient/callback?signature=xxx&timestamp=xxx&nonce=xxx
* Body: XML格式的消息内容明文模式
*/
async handleCallback(
request: FastifyRequest<{
Querystring: WechatMpCallbackQuery;
Body: string; // XML格式字符串
}>,
reply: FastifyReply
): Promise<void> {
const { signature, timestamp, nonce } = request.query;
const body = request.body;
logger.info('📥 收到微信服务号回调消息(明文模式)', {
signature,
timestamp,
nonce,
bodyType: typeof body,
bodyLength: JSON.stringify(body).length,
});
try {
// 验证签名
const isValid = this.verifySignature(signature, timestamp, nonce);
if (!isValid) {
logger.error('❌ 消息推送验证失败:签名不匹配', {
signature,
timestamp,
nonce,
});
reply.code(403).send('Signature verification failed');
return;
}
logger.info('✅ 签名验证成功');
// 立即返回success5秒内必须返回
reply.type('text/plain').send('success');
// 异步处理消息
this.processMessageAsync(body).catch((error) => {
logger.error('❌ 异步消息处理失败', {
error: error instanceof Error ? error.message : String(error),
});
});
} catch (error) {
logger.error('❌ 消息推送处理异常', {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
});
reply.code(500).send('Internal Server Error');
}
}
/**
* 验证微信签名(明文模式)
*
* 签名计算方法:
* 1. 将token、timestamp、nonce三个参数进行字典序排序
* 2. 将三个参数字符串拼接成一个字符串
* 3. 进行sha1加密
* 4. 与signature参数对比
*
* @param signature 微信传递的签名
* @param timestamp 时间戳
* @param nonce 随机数
* @returns 验证结果
*/
private verifySignature(signature: string, timestamp: string, nonce: string): boolean {
try {
// 1. 字典序排序
const arr = [this.token, timestamp, nonce].sort();
// 2. 拼接字符串
const str = arr.join('');
// 3. SHA1加密
const hash = crypto.createHash('sha1').update(str).digest('hex');
logger.debug('🔐 签名验证详情', {
token: this.token.substring(0, 10) + '...',
timestamp,
nonce,
sortedArray: arr.map(s => s.substring(0, 10) + (s.length > 10 ? '...' : '')),
concatenatedString: str.substring(0, 50) + (str.length > 50 ? '...' : ''),
calculatedHash: hash,
receivedSignature: signature,
isMatch: hash === signature,
});
// 4. 对比签名
return hash === signature;
} catch (error) {
logger.error('❌ 签名验证计算失败', {
error: error instanceof Error ? error.message : String(error),
});
return false;
}
}
/**
* 异步处理消息
*
* 在5秒内返回success后异步处理实际的业务逻辑
*
* @param body 消息体XML格式字符串
*/
private async processMessageAsync(body: string): Promise<void> {
try {
logger.info('📝 开始异步处理消息', {
bodyType: typeof body,
bodyPreview: typeof body === 'string' ? body.substring(0, 200) : JSON.stringify(body).substring(0, 200),
});
// 解析XML消息体
const message = this.parseXmlMessage(body);
// 判断消息类型
if (message.MsgType === 'event') {
// 事件消息
logger.info('🎯 处理事件消息', {
event: message.Event,
fromUser: message.FromUserName,
});
// TODO: 根据不同的事件类型进行处理
switch (message.Event) {
case 'subscribe':
logger.info('👤 用户关注公众号', { openid: message.FromUserName });
break;
case 'unsubscribe':
logger.info('👋 用户取消关注公众号', { openid: message.FromUserName });
break;
default:
logger.info('📌 其他事件', { event: message.Event });
}
} else if (message.MsgType === 'text') {
// 文本消息
logger.info('💬 处理文本消息', {
fromUser: message.FromUserName,
content: message.Content,
});
// TODO: 调用ChatService处理文本消息回复患者
} else {
logger.info('📦 其他类型消息', { msgType: message.MsgType });
}
logger.info('✅ 消息处理完成');
} catch (error) {
logger.error('❌ 消息处理失败', {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
});
}
}
/**
* 解析XML消息体
*
* 微信推送的消息格式(明文模式):
* <xml>
* <ToUserName><![CDATA[gh_xxx]]></ToUserName>
* <FromUserName><![CDATA[oXXX]]></FromUserName>
* <CreateTime>1234567890</CreateTime>
* <MsgType><![CDATA[text]]></MsgType>
* <Content><![CDATA[你好]]></Content>
* </xml>
*
* @param xml XML字符串
* @returns 解析后的消息对象
*/
private parseXmlMessage(xml: string): any {
try {
const message: any = {};
// 简单的XML解析提取标签内容
const extractTag = (tagName: string): string | undefined => {
// 匹配 <tagName><![CDATA[...]]></tagName> 或 <tagName>...</tagName>
const cdataMatch = xml.match(new RegExp(`<${tagName}><!\\[CDATA\\[([^\\]]+)\\]\\]><\\/${tagName}>`));
if (cdataMatch) return cdataMatch[1];
const textMatch = xml.match(new RegExp(`<${tagName}>([^<]+)<\\/${tagName}>`));
if (textMatch) return textMatch[1];
return undefined;
};
// 提取常见字段
message.ToUserName = extractTag('ToUserName');
message.FromUserName = extractTag('FromUserName');
message.CreateTime = extractTag('CreateTime');
message.MsgType = extractTag('MsgType');
message.MsgId = extractTag('MsgId');
// 根据消息类型提取特定字段
if (message.MsgType === 'text') {
message.Content = extractTag('Content');
} else if (message.MsgType === 'event') {
message.Event = extractTag('Event');
message.EventKey = extractTag('EventKey');
}
logger.debug('📋 XML解析结果', {
message,
});
return message;
} catch (error) {
logger.error('❌ XML解析失败', {
error: error instanceof Error ? error.message : String(error),
xml: xml.substring(0, 200),
});
return {};
}
}
}

View File

@@ -530,3 +530,7 @@ URL: https://iit.xunzhengyixue.com/api/v1/iit/patient-wechat/callback
**最后更新**2026-01-04
**文档版本**v1.0

View File

@@ -165,3 +165,7 @@ console.log('');
console.log('🎉 生成完成!');
console.log('');

View File

@@ -6,6 +6,7 @@ import { FastifyInstance } from 'fastify';
import { WebhookController } from '../controllers/WebhookController.js';
import { wechatCallbackController } from '../controllers/WechatCallbackController.js';
import { patientWechatCallbackController } from '../controllers/PatientWechatCallbackController.js';
import { PatientWechatCallbackPlainController } from '../controllers/PatientWechatCallbackPlainController.js';
import { SyncManager } from '../services/SyncManager.js';
import { logger } from '../../../common/logging/index.js';
@@ -13,6 +14,33 @@ export async function registerIitRoutes(fastify: FastifyInstance) {
// 初始化控制器和服务
const webhookController = new WebhookController();
const syncManager = new SyncManager();
const patientWechatCallbackPlainController = new PatientWechatCallbackPlainController();
// 添加XML内容解析器用于微信公众号明文模式
// 安全注册:避免重复注册错误
try {
fastify.addContentTypeParser('text/xml', { parseAs: 'string' }, function (req, body, done) {
done(null, body);
});
} catch (error: any) {
// 解析器可能已存在,忽略错误
if (error.code !== 'FST_ERR_CTP_ALREADY_PRESENT') {
throw error;
}
logger.debug('text/xml parser already exists, skipping');
}
try {
fastify.addContentTypeParser('application/xml', { parseAs: 'string' }, function (req, body, done) {
done(null, body);
});
} catch (error: any) {
// 解析器可能已存在,忽略错误
if (error.code !== 'FST_ERR_CTP_ALREADY_PRESENT') {
throw error;
}
logger.debug('application/xml parser already exists, skipping');
}
// =============================================
// 健康检查
@@ -240,18 +268,8 @@ export async function registerIitRoutes(fastify: FastifyInstance) {
// 企业微信回调路由
// =============================================
// 注text/xml解析器(企业微信回调使用此格式
fastify.addContentTypeParser(
'text/xml',
{ parseAs: 'string' },
(req, body, done) => {
// 企业微信发送的是XML字符串直接返回字符串即可
// 在控制器中使用xml2js进行解析
done(null, body);
}
);
logger.info('Registered content parser: text/xml');
// 注意:text/xml解析器已在文件开头注册(通用于企业微信和微信服务号
logger.info('Using shared text/xml parser for WeChat Work callbacks');
// GET: URL验证企业微信配置回调URL时使用
fastify.get(
@@ -300,8 +318,56 @@ export async function registerIitRoutes(fastify: FastifyInstance) {
// 微信服务号回调路由(患者端)
// =============================================
// 简化路由(用于微信公众平台配置,路径更短)
// GET: URL验证
// ===== 明文模式Plain Text Mode =====
// 推荐:先用明文模式测试基础功能,成功后再切换到安全模式
// GET: URL验证明文模式
fastify.get(
'/wechat/patient/callback-plain',
{
schema: {
querystring: {
type: 'object',
properties: {
signature: { type: 'string' },
timestamp: { type: 'string' },
nonce: { type: 'string' },
echostr: { type: 'string' }
},
additionalProperties: true
}
}
},
patientWechatCallbackPlainController.handleVerification.bind(patientWechatCallbackPlainController)
);
logger.info('Registered route: GET /wechat/patient/callback-plain (明文模式)');
// POST: 接收消息明文模式XML格式
// 注意微信推送的是XML格式的消息体
fastify.post(
'/wechat/patient/callback-plain',
{
schema: {
querystring: {
type: 'object',
properties: {
signature: { type: 'string' },
timestamp: { type: 'string' },
nonce: { type: 'string' }
},
additionalProperties: true
}
}
},
patientWechatCallbackPlainController.handleCallback.bind(patientWechatCallbackPlainController)
);
logger.info('Registered route: POST /wechat/patient/callback-plain (明文模式, XML)');
// ===== 安全模式Secure Mode / AES加密 =====
// GET: URL验证安全模式
// 注意不使用required字段避免Fastify过早拦截微信的请求
fastify.get(
'/wechat/patient/callback',
@@ -323,9 +389,9 @@ export async function registerIitRoutes(fastify: FastifyInstance) {
patientWechatCallbackController.handleVerification.bind(patientWechatCallbackController)
);
logger.info('Registered route: GET /wechat/patient/callback');
logger.info('Registered route: GET /wechat/patient/callback (安全模式)');
// POST: 接收消息
// POST: 接收消息(安全模式)
// 注意不使用required字段避免Fastify过早拦截微信的请求
fastify.post(
'/wechat/patient/callback',
@@ -348,7 +414,7 @@ export async function registerIitRoutes(fastify: FastifyInstance) {
patientWechatCallbackController.handleCallback.bind(patientWechatCallbackController)
);
logger.info('Registered route: POST /wechat/patient/callback');
logger.info('Registered route: POST /wechat/patient/callback (安全模式)');
// 完整路由(兼容旧配置,保留)
// GET: URL验证微信服务号配置回调URL时使用

View File

@@ -482,3 +482,7 @@ export class PatientWechatService {
// 导出单例
export const patientWechatService = new PatientWechatService();

View File

@@ -127,3 +127,7 @@ testDifyIntegration().catch(error => {
});

View File

@@ -156,3 +156,7 @@ testIitDatabase()

View File

@@ -142,3 +142,7 @@ if (hasError) {
console.log('\n');
}

View File

@@ -168,3 +168,7 @@ async function testUrlVerification() {
}
})();

View File

@@ -249,3 +249,7 @@ main().catch((error) => {

View File

@@ -133,3 +133,7 @@ try {
Write-Host ""

View File

@@ -0,0 +1,119 @@
# Test WeChat Official Account - Plain Text Mode URL Verification
# Configuration
$Token = "IitPatientWechat2026JanToken"
$Timestamp = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()
$Nonce = Get-Random -Minimum 100000000 -Maximum 999999999
$Echostr = "test_echo_string_$(Get-Random)"
Write-Host "================================" -ForegroundColor Cyan
Write-Host "WeChat MP Plain Mode URL Verification Test" -ForegroundColor Cyan
Write-Host "================================" -ForegroundColor Cyan
Write-Host ""
Write-Host "Configuration:" -ForegroundColor Yellow
Write-Host " Token: $Token"
Write-Host " Timestamp: $Timestamp"
Write-Host " Nonce: $Nonce"
Write-Host " Echostr: $Echostr"
Write-Host ""
# Calculate signature
Write-Host "Calculating signature..." -ForegroundColor Yellow
# 1. Sort by dictionary order
$sortedArray = @($Token, $Timestamp, $Nonce) | Sort-Object
Write-Host " Sorted array: [$($sortedArray -join ', ')]"
# 2. Concatenate string
$concatenatedString = $sortedArray -join ''
Write-Host " Concatenated: $concatenatedString"
# 3. SHA1 hash
$sha1 = [System.Security.Cryptography.SHA1]::Create()
$bytes = [System.Text.Encoding]::UTF8.GetBytes($concatenatedString)
$hashBytes = $sha1.ComputeHash($bytes)
$signature = [System.BitConverter]::ToString($hashBytes).Replace("-", "").ToLower()
Write-Host " SHA1 signature: $signature" -ForegroundColor Green
Write-Host ""
# Test 1: Local server (via natapp)
Write-Host "Test 1: Local server (via natapp)" -ForegroundColor Cyan
Write-Host "----------------------------------------"
$localUrl = "https://devlocal.xunzhengyixue.com/wechat/patient/callback-plain"
$localFullUrl = "{0}?signature={1}`&timestamp={2}`&nonce={3}`&echostr={4}" -f $localUrl, $signature, $Timestamp, $Nonce, $Echostr
Write-Host " Request URL: $localFullUrl" -ForegroundColor Gray
try {
$response = Invoke-WebRequest -Uri $localFullUrl -Method Get -UseBasicParsing
if ($response.StatusCode -eq 200) {
Write-Host " SUCCESS (200 OK)" -ForegroundColor Green
Write-Host " Response content: $($response.Content)" -ForegroundColor Green
if ($response.Content -eq $Echostr) {
Write-Host " echostr MATCHED!" -ForegroundColor Green
} else {
Write-Host " echostr MISMATCH!" -ForegroundColor Red
Write-Host " Expected: $Echostr" -ForegroundColor Red
Write-Host " Actual: $($response.Content)" -ForegroundColor Red
}
} else {
Write-Host " FAILED ($($response.StatusCode))" -ForegroundColor Red
}
} catch {
Write-Host " ERROR: $($_.Exception.Message)" -ForegroundColor Red
if ($_.Exception.Response) {
$statusCode = [int]$_.Exception.Response.StatusCode
Write-Host " HTTP Status: $statusCode" -ForegroundColor Red
}
}
Write-Host ""
# Test 2: Direct localhost (localhost:3001)
Write-Host "Test 2: Direct localhost (localhost:3001)" -ForegroundColor Cyan
Write-Host "----------------------------------------"
$localhostUrl = "http://localhost:3001/wechat/patient/callback-plain"
$localhostFullUrl = "{0}?signature={1}`&timestamp={2}`&nonce={3}`&echostr={4}" -f $localhostUrl, $signature, $Timestamp, $Nonce, $Echostr
Write-Host " Request URL: $localhostFullUrl" -ForegroundColor Gray
try {
$response = Invoke-WebRequest -Uri $localhostFullUrl -Method Get -UseBasicParsing
if ($response.StatusCode -eq 200) {
Write-Host " SUCCESS (200 OK)" -ForegroundColor Green
Write-Host " Response content: $($response.Content)" -ForegroundColor Green
if ($response.Content -eq $Echostr) {
Write-Host " echostr MATCHED!" -ForegroundColor Green
} else {
Write-Host " echostr MISMATCH!" -ForegroundColor Red
Write-Host " Expected: $Echostr" -ForegroundColor Red
Write-Host " Actual: $($response.Content)" -ForegroundColor Red
}
} else {
Write-Host " FAILED ($($response.StatusCode))" -ForegroundColor Red
}
} catch {
Write-Host " ERROR: $($_.Exception.Message)" -ForegroundColor Red
if ($_.Exception.Response) {
$statusCode = [int]$_.Exception.Response.StatusCode
Write-Host " HTTP Status: $statusCode" -ForegroundColor Red
}
}
Write-Host ""
Write-Host "================================" -ForegroundColor Cyan
Write-Host "Next Step: WeChat MP Configuration" -ForegroundColor Green
Write-Host "================================" -ForegroundColor Cyan
Write-Host ""
Write-Host "If local tests passed, configure in WeChat MP:" -ForegroundColor Yellow
Write-Host ""
Write-Host " URL: https://devlocal.xunzhengyixue.com/wechat/patient/callback-plain" -ForegroundColor Cyan
Write-Host " Token: $Token" -ForegroundColor Cyan
Write-Host " Encryption Mode: Plain Text Mode" -ForegroundColor Cyan
Write-Host " Data Format: XML (IMPORTANT!)" -ForegroundColor Cyan
Write-Host ""

View File

@@ -226,3 +226,7 @@ export interface CachedProtocolRules {