feat(p5): messaging, push gateway and AI assistant services

P5 阶段交付物:
- services/msg: 消息通知服务(NestJS)
  - notifications: 发送通知 + ES 全文检索 + search
  - config/elasticsearch.ts: ES Client 单例
  - package.json: 补充 @opentelemetry/sdk-node + exporter-trace-otlp-http
- services/push-gateway: WebSocket 推送网关(Go Gin)
  - internal/hub/hub.go: WebSocket 连接池管理(Register/Unregister/SendToUser)
  - internal/ws/handler.go: JWT 鉴权 + WebSocket 升级 + 内部推送 API
- services/ai: AI 辅助服务(Python FastAPI)
  - /chat + /chat/stream(SSE 流式)
  - /generate/question + /optimize/expression
  - config.py: OpenAI 兼容 API 配置
- packages/shared-proto/proto/msg.proto: NotificationService 契约(send/search)
- packages/shared-proto/proto/ai.proto: AiService 契约(含 stream 方法)
This commit is contained in:
SpecialX
2026-07-08 01:39:02 +08:00
parent 9850bfcfd1
commit 7474a92e3b
34 changed files with 1264 additions and 0 deletions

View File

@@ -0,0 +1,96 @@
export type ErrorType =
| 'validation'
| 'not_found'
| 'permission_denied'
| 'conflict'
| 'business'
| 'database'
| 'internal';
export interface ErrorDetails {
[key: string]: unknown;
}
export abstract class ApplicationError extends Error {
abstract readonly type: ErrorType;
abstract readonly statusCode: number;
readonly code: string;
readonly details?: ErrorDetails;
// FIX #1: traceId 改为可写,以便 GlobalErrorFilter 注入请求级 traceId
traceId?: string;
constructor(message: string, code: string, details?: ErrorDetails) {
super(message);
this.name = this.constructor.name;
this.code = code;
this.details = details;
}
toJSON(): Record<string, unknown> {
return {
success: false,
error: {
code: this.code,
message: this.message,
details: this.details,
traceId: this.traceId,
},
};
}
}
export class ValidationError extends ApplicationError {
readonly type = 'validation' as const;
readonly statusCode = 400;
constructor(message: string, details?: ErrorDetails) {
super(message, 'MSG_VALIDATION_ERROR', details);
}
}
export class NotFoundError extends ApplicationError {
readonly type = 'not_found' as const;
readonly statusCode = 404;
constructor(resource: string, id: string) {
super(`${resource} not found: ${id}`, 'MSG_NOT_FOUND', { resource, id });
}
}
export class PermissionDeniedError extends ApplicationError {
readonly type = 'permission_denied' as const;
readonly statusCode = 403;
constructor(permission: string) {
super(`Permission denied: ${permission}`, 'MSG_PERMISSION_DENIED', { permission });
}
}
export class ConflictError extends ApplicationError {
readonly type = 'conflict' as const;
readonly statusCode = 409;
constructor(message: string, details?: ErrorDetails) {
super(message, 'MSG_CONFLICT', details);
}
}
export class BusinessError extends ApplicationError {
readonly type = 'business' as const;
readonly statusCode = 422;
constructor(message: string, details?: ErrorDetails) {
super(message, 'MSG_BUSINESS_ERROR', details);
}
}
export class DatabaseError extends ApplicationError {
readonly type = 'database' as const;
readonly statusCode = 500;
constructor(message: string, details?: ErrorDetails) {
super(message, 'MSG_DATABASE_ERROR', details);
}
}
export class InternalError extends ApplicationError {
readonly type = 'internal' as const;
readonly statusCode = 500;
constructor(message: string, details?: ErrorDetails) {
super(message, 'MSG_INTERNAL_ERROR', details);
}
}

View File

@@ -0,0 +1,77 @@
import { Catch, ExceptionFilter, ArgumentsHost, HttpException, Logger } from '@nestjs/common';
import { Request, Response } from 'express';
import { ZodError } from 'zod';
import { ApplicationError } from './application-error.js';
@Catch()
export class GlobalErrorFilter implements ExceptionFilter {
private readonly logger = new Logger(GlobalErrorFilter.name);
catch(exception: unknown, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const traceId = (request.headers['x-request-id'] as string | undefined) ?? 'unknown';
let statusCode = 500;
let body: Record<string, unknown>;
if (exception instanceof ApplicationError) {
exception.traceId = traceId;
statusCode = exception.statusCode;
body = exception.toJSON();
} else if (exception instanceof ZodError) {
// FIX #3: 捕获 Zod 解析错误,转换为结构化 ValidationError 响应
statusCode = 400;
body = {
success: false,
error: {
code: 'MSG_VALIDATION_ERROR',
message: 'Validation failed',
details: exception.flatten(),
traceId,
},
};
} else if (exception instanceof HttpException) {
statusCode = exception.getStatus();
const res = exception.getResponse();
const message = this.extractHttpMessage(res, exception);
body = {
success: false,
error: {
code: 'HTTP_ERROR',
message,
traceId,
},
};
} else {
this.logger.error(
`Unhandled exception: ${exception}`,
exception instanceof Error ? exception.stack : undefined,
);
body = {
success: false,
error: {
code: 'INTERNAL_ERROR',
message: 'An unexpected error occurred',
traceId,
},
};
}
response.status(statusCode).json(body);
}
private extractHttpMessage(res: string | object, exception: HttpException): string {
if (typeof res === 'string') {
return res;
}
if (res && typeof res === 'object' && 'message' in res) {
// 从 HttpException 响应体收窄类型NestJS 约定包含 message 字段)
const msg = (res as { message: unknown }).message;
return typeof msg === 'string' ? msg : exception.message;
}
return exception.message;
}
}