Sprint 1-3 Completed (Backend + Frontend): Backend (Sprint 1-2): - Implement 5-layer Agent framework (Query->Planner->Executor->Tools->Reflection) - Create agent_schema with 6 tables (agent_definitions, stages, prompts, sessions, traces, reflexion_rules) - Create protocol_schema with 2 tables (protocol_contexts, protocol_generations) - Implement Protocol Agent core services (Orchestrator, ContextService, PromptBuilder) - Integrate LLM service adapter (DeepSeek/Qwen/GPT-5/Claude) - 6 API endpoints with full authentication - 10/10 API tests passed Frontend (Sprint 3): - Add Protocol Agent entry in AgentHub (indigo theme card) - Implement ProtocolAgentPage with 3-column layout - Collapsible sidebar (Gemini style, 48px <-> 280px) - StatePanel with 5 stage cards (scientific_question, pico, study_design, sample_size, endpoints) - ChatArea with sync button and action cards integration - 100% prototype design restoration (608 lines CSS) - Detailed endpoints structure: baseline, exposure, outcomes, confounders Features: - 5-stage dialogue flow for research protocol design - Conversation-driven interaction with sync-to-protocol button - Real-time context state management - One-click protocol generation button (UI ready, backend pending) Database: - agent_schema: 6 tables for reusable Agent framework - protocol_schema: 2 tables for Protocol Agent - Seed data: 1 agent + 5 stages + 9 prompts + 4 reflexion rules Code Stats: - Backend: 13 files, 4338 lines - Frontend: 14 files, 2071 lines - Total: 27 files, 6409 lines Status: MVP core functionality completed, pending frontend-backend integration testing Next: Sprint 4 - One-click protocol generation + Word export
208 lines
4.2 KiB
TypeScript
208 lines
4.2 KiB
TypeScript
/**
|
||
* JWT Service
|
||
*
|
||
* JWT令牌的生成、验证和刷新
|
||
*
|
||
* 设计原则:
|
||
* - 使用 jsonwebtoken 库
|
||
* - Token payload 包含用户ID、角色、租户ID
|
||
* - 支持 Access Token 和 Refresh Token
|
||
*/
|
||
|
||
import jwt, { SignOptions, JwtPayload } from 'jsonwebtoken';
|
||
import { config } from '../../config/env.js';
|
||
|
||
/**
|
||
* JWT Payload 接口
|
||
*/
|
||
export interface JWTPayload {
|
||
/** 用户ID */
|
||
userId: string;
|
||
/** 用户手机号 */
|
||
phone: string;
|
||
/** 用户角色 */
|
||
role: string;
|
||
/** 租户ID */
|
||
tenantId: string;
|
||
/** 租户Code(用于URL路由) */
|
||
tenantCode?: string;
|
||
/** 是否为默认密码 */
|
||
isDefaultPassword?: boolean;
|
||
}
|
||
|
||
/**
|
||
* Token 响应接口
|
||
*/
|
||
export interface TokenResponse {
|
||
accessToken: string;
|
||
refreshToken: string;
|
||
expiresIn: number; // 秒
|
||
tokenType: 'Bearer';
|
||
}
|
||
|
||
/**
|
||
* 解码后的 Token 接口
|
||
*/
|
||
export interface DecodedToken extends JWTPayload {
|
||
iat: number; // 签发时间
|
||
exp: number; // 过期时间
|
||
}
|
||
|
||
// Token 配置
|
||
const ACCESS_TOKEN_EXPIRES_IN = '2h'; // Access Token 2小时过期
|
||
const REFRESH_TOKEN_EXPIRES_IN = '7d'; // Refresh Token 7天过期
|
||
const ACCESS_TOKEN_EXPIRES_SECONDS = 2 * 60 * 60; // 7200秒
|
||
|
||
/**
|
||
* JWT Service 类
|
||
*/
|
||
export class JWTService {
|
||
private readonly secret: string;
|
||
|
||
constructor() {
|
||
this.secret = config.jwtSecret;
|
||
if (this.secret === 'your-secret-key-change-in-production' && config.nodeEnv === 'production') {
|
||
throw new Error('JWT_SECRET must be configured in production environment');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 生成 Access Token
|
||
*/
|
||
generateAccessToken(payload: JWTPayload): string {
|
||
const options: SignOptions = {
|
||
expiresIn: ACCESS_TOKEN_EXPIRES_IN,
|
||
issuer: 'aiclinical',
|
||
subject: payload.userId,
|
||
};
|
||
|
||
return jwt.sign(payload, this.secret, options);
|
||
}
|
||
|
||
/**
|
||
* 生成 Refresh Token
|
||
*/
|
||
generateRefreshToken(payload: JWTPayload): string {
|
||
// Refresh Token 只包含必要信息
|
||
const refreshPayload = {
|
||
userId: payload.userId,
|
||
type: 'refresh',
|
||
};
|
||
|
||
const options: SignOptions = {
|
||
expiresIn: REFRESH_TOKEN_EXPIRES_IN,
|
||
issuer: 'aiclinical',
|
||
subject: payload.userId,
|
||
};
|
||
|
||
return jwt.sign(refreshPayload, this.secret, options);
|
||
}
|
||
|
||
/**
|
||
* 生成完整的 Token 响应
|
||
*/
|
||
generateTokens(payload: JWTPayload): TokenResponse {
|
||
return {
|
||
accessToken: this.generateAccessToken(payload),
|
||
refreshToken: this.generateRefreshToken(payload),
|
||
expiresIn: ACCESS_TOKEN_EXPIRES_SECONDS,
|
||
tokenType: 'Bearer',
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 验证 Token
|
||
*/
|
||
verifyToken(token: string): DecodedToken {
|
||
try {
|
||
const decoded = jwt.verify(token, this.secret, {
|
||
issuer: 'aiclinical',
|
||
}) as DecodedToken;
|
||
|
||
return decoded;
|
||
} catch (error) {
|
||
if (error instanceof jwt.TokenExpiredError) {
|
||
throw new Error('Token已过期');
|
||
}
|
||
if (error instanceof jwt.JsonWebTokenError) {
|
||
throw new Error('Token无效');
|
||
}
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 刷新 Token
|
||
*
|
||
* 使用 Refresh Token 获取新的 Access Token
|
||
*/
|
||
async refreshToken(refreshToken: string, getUserById: (id: string) => Promise<JWTPayload | null>): Promise<TokenResponse> {
|
||
// 验证 Refresh Token
|
||
const decoded = this.verifyToken(refreshToken);
|
||
|
||
if ((decoded as any).type !== 'refresh') {
|
||
throw new Error('无效的Refresh Token');
|
||
}
|
||
|
||
// 获取用户最新信息
|
||
const user = await getUserById(decoded.userId);
|
||
if (!user) {
|
||
throw new Error('用户不存在');
|
||
}
|
||
|
||
// 生成新的 Tokens
|
||
return this.generateTokens(user);
|
||
}
|
||
|
||
/**
|
||
* 从 Authorization Header 中提取 Token
|
||
*/
|
||
extractTokenFromHeader(authHeader: string | undefined): string | null {
|
||
if (!authHeader) {
|
||
return null;
|
||
}
|
||
|
||
const [type, token] = authHeader.split(' ');
|
||
if (type !== 'Bearer' || !token) {
|
||
return null;
|
||
}
|
||
|
||
return token;
|
||
}
|
||
|
||
/**
|
||
* 解码 Token(不验证签名)
|
||
*
|
||
* 用于调试或获取过期token的payload
|
||
*/
|
||
decodeToken(token: string): DecodedToken | null {
|
||
const decoded = jwt.decode(token);
|
||
return decoded as DecodedToken | null;
|
||
}
|
||
}
|
||
|
||
// 导出单例
|
||
export const jwtService = new JWTService();
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|