Files
AIclinicalresearch/backend/src/common/streaming/StreamingService.ts
HaHafeng 66255368b7 feat(admin): Add user management and upgrade to module permission system
Features - User Management (Phase 4.1):
- Database: Add user_modules table for fine-grained module permissions
- Database: Add 4 user permissions (view/create/edit/delete) to role_permissions
- Backend: UserService (780 lines) - CRUD with tenant isolation
- Backend: UserController + UserRoutes (648 lines) - 13 API endpoints
- Backend: Batch import users from Excel
- Frontend: UserListPage (412 lines) - list/filter/search/pagination
- Frontend: UserFormPage (341 lines) - create/edit with module config
- Frontend: UserDetailPage (393 lines) - details/tenant/module management
- Frontend: 3 modal components (592 lines) - import/assign/configure
- API: GET/POST/PUT/DELETE /api/admin/users/* endpoints

Architecture Upgrade - Module Permission System:
- Backend: Add getUserModules() method in auth.service
- Backend: Login API returns modules array in user object
- Frontend: AuthContext adds hasModule() method
- Frontend: Navigation filters modules based on user.modules
- Frontend: RouteGuard checks requiredModule instead of requiredVersion
- Frontend: Remove deprecated version-based permission system
- UX: Only show accessible modules in navigation (clean UI)
- UX: Smart redirect after login (avoid 403 for regular users)

Fixes:
- Fix UTF-8 encoding corruption in ~100 docs files
- Fix pageSize type conversion in userService (String to Number)
- Fix authUser undefined error in TopNavigation
- Fix login redirect logic with role-based access check
- Update Git commit guidelines v1.2 with UTF-8 safety rules

Database Changes:
- CREATE TABLE user_modules (user_id, tenant_id, module_code, is_enabled)
- ADD UNIQUE CONSTRAINT (user_id, tenant_id, module_code)
- INSERT 4 permissions + role assignments
- UPDATE PUBLIC tenant with 8 module subscriptions

Technical:
- Backend: 5 new files (~2400 lines)
- Frontend: 10 new files (~2500 lines)
- Docs: 1 development record + 2 status updates + 1 guideline update
- Total: ~4900 lines of code

Status: User management 100% complete, module permission system operational
2026-01-16 13:42:10 +08:00

205 lines
5.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 通用流式响应服务
*
* 封装 LLM 调用 + OpenAI Compatible 输出
* 支持深度思考、Token 统计、错误处理
*/
import { FastifyReply } from 'fastify';
import { OpenAIStreamAdapter, createOpenAIStreamAdapter } from './OpenAIStreamAdapter';
import { StreamOptions, StreamCallbacks, THINKING_TAGS, OpenAIMessage } from './types';
import { LLMFactory } from '../llm/adapters/LLMFactory';
import type { Message as LLMMessage } from '../llm/adapters/types';
import { logger } from '../logging/logger';
/**
* 深度思考标签处理结果
*/
interface ThinkingParseResult {
content: string;
thinking: string;
inThinking: boolean;
}
/**
* 流式响应服务
*/
export class StreamingService {
private adapter: OpenAIStreamAdapter;
private options: StreamOptions;
private fullContent: string = '';
private thinkingContent: string = '';
private isInThinking: boolean = false;
constructor(reply: FastifyReply, options: StreamOptions = {}) {
this.adapter = createOpenAIStreamAdapter(reply, options.model);
this.options = options;
}
/**
* 执行流式生成
*/
async streamGenerate(
messages: OpenAIMessage[],
callbacks?: StreamCallbacks
): Promise<{ content: string; thinking: string; messageId: string }> {
const { model = 'deepseek-v3', temperature = 0.7, maxTokens = 4096 } = this.options;
try {
// 获取 LLM 适配器
const llm = LLMFactory.getAdapter(model as any);
// 发送角色开始标识
this.adapter.sendRoleStart();
// 流式生成
const stream = llm.chatStream(
messages as LLMMessage[],
{ temperature, maxTokens }
);
for await (const chunk of stream) {
if (chunk.content) {
// 处理深度思考标签
const { content, thinking, inThinking } = this.processThinkingTags(
chunk.content,
this.options.enableDeepThinking ?? false
);
// 发送思考内容
if (thinking) {
this.thinkingContent += thinking;
this.adapter.sendReasoningDelta(thinking);
callbacks?.onThinking?.(thinking);
}
// 发送正文内容
if (content) {
this.fullContent += content;
this.adapter.sendContentDelta(content);
callbacks?.onContent?.(content);
}
}
}
// 发送完成标识
const usage = {
promptTokens: this.estimateTokens(messages.map(m => m.content).join('')),
completionTokens: this.estimateTokens(this.fullContent),
totalTokens: 0,
};
usage.totalTokens = usage.promptTokens + usage.completionTokens;
this.adapter.sendComplete(usage);
this.adapter.end();
// 完成回调
callbacks?.onComplete?.(this.fullContent, this.thinkingContent);
logger.info('[StreamingService] 流式生成完成', {
conversationId: this.options.conversationId,
contentLength: this.fullContent.length,
thinkingLength: this.thinkingContent.length,
tokens: usage.totalTokens,
});
return {
content: this.fullContent,
thinking: this.thinkingContent,
messageId: this.adapter.getMessageId(),
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '流式生成失败';
this.adapter.sendError(errorMessage);
this.adapter.end();
callbacks?.onError?.(error instanceof Error ? error : new Error(errorMessage));
logger.error('[StreamingService] 流式生成失败', {
error,
conversationId: this.options.conversationId,
});
throw error;
}
}
/**
* 处理深度思考标签
*/
private processThinkingTags(text: string, enableDeepThinking: boolean): ThinkingParseResult {
if (!enableDeepThinking) {
return { content: text, thinking: '', inThinking: this.isInThinking };
}
let content = '';
let thinking = '';
let remaining = text;
while (remaining.length > 0) {
if (this.isInThinking) {
// 在思考模式中,查找结束标签
const endIndex = remaining.indexOf(THINKING_TAGS.END);
if (endIndex !== -1) {
thinking += remaining.substring(0, endIndex);
remaining = remaining.substring(endIndex + THINKING_TAGS.END.length);
this.isInThinking = false;
} else {
thinking += remaining;
remaining = '';
}
} else {
// 不在思考模式,查找开始标签
const startIndex = remaining.indexOf(THINKING_TAGS.START);
if (startIndex !== -1) {
content += remaining.substring(0, startIndex);
remaining = remaining.substring(startIndex + THINKING_TAGS.START.length);
this.isInThinking = true;
} else {
content += remaining;
remaining = '';
}
}
}
return { content, thinking, inThinking: this.isInThinking };
}
/**
* 估算 Token 数量(简单实现)
*/
private estimateTokens(text: string): number {
// 中文约 1.5 字符/token英文约 4 字符/token
const chineseChars = (text.match(/[\u4e00-\u9fa5]/g) || []).length;
const otherChars = text.length - chineseChars;
return Math.ceil(chineseChars / 1.5 + otherChars / 4);
}
}
/**
* 创建流式响应服务
*/
export function createStreamingService(
reply: FastifyReply,
options?: StreamOptions
): StreamingService {
return new StreamingService(reply, options);
}
/**
* 快捷方法:直接执行流式生成
*/
export async function streamChat(
reply: FastifyReply,
messages: OpenAIMessage[],
options?: StreamOptions,
callbacks?: StreamCallbacks
) {
const service = createStreamingService(reply, options);
return service.streamGenerate(messages, callbacks);
}