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,32 @@
import { Body, Controller, Get, Param, Post, Query, Req } from '@nestjs/common';
import { NotificationsService } from './notifications.service.js';
import type { SendNotificationDto } from './notifications.service.js';
@Controller('notifications')
export class NotificationsController {
constructor(private readonly service: NotificationsService) {}
@Post()
async send(@Body() body: unknown) {
const result = await this.service.send(body as SendNotificationDto);
return { success: true, data: result };
}
@Get()
async list(@Req() req: { userId: string }, @Query('unread') unread: string) {
const result = await this.service.listByUser(req.userId, unread === 'true');
return { success: true, data: result };
}
@Post(':id/read')
async markAsRead(@Param('id') id: string) {
await this.service.markAsRead(id);
return { success: true };
}
@Get('search')
async search(@Req() req: { userId: string }, @Query('q') q: string) {
const result = await this.service.search(req.userId, q);
return { success: true, data: result };
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { NotificationsController } from './notifications.controller.js';
import { NotificationsService } from './notifications.service.js';
@Module({
controllers: [NotificationsController],
providers: [NotificationsService],
})
export class NotificationsModule {}

View File

@@ -0,0 +1,24 @@
import { mysqlTable, varchar, char, timestamp, text, boolean, json } from 'drizzle-orm/mysql-core';
export const notifications = mysqlTable('msg_notifications', {
id: char('id', { length: 36 }).notNull().primaryKey(),
userId: char('user_id', { length: 36 }).notNull(),
type: varchar('type', { length: 50 }).notNull(),
title: varchar('title', { length: 200 }).notNull(),
content: text('content').notNull(),
channel: varchar('channel', { length: 20 }).notNull().default('in_app'),
isRead: boolean('is_read').notNull().default(false),
metadata: json('metadata'),
createdAt: timestamp('created_at').notNull().defaultNow(),
});
export const notificationPreferences = mysqlTable('msg_notification_preferences', {
userId: char('user_id', { length: 36 }).notNull().primaryKey(),
emailEnabled: boolean('email_enabled').notNull().default(true),
smsEnabled: boolean('sms_enabled').notNull().default(false),
pushEnabled: boolean('push_enabled').notNull().default(true),
inAppEnabled: boolean('in_app_enabled').notNull().default(true),
});
export type Notification = typeof notifications.$inferSelect;
export type NotificationPreference = typeof notificationPreferences.$inferSelect;

View File

@@ -0,0 +1,110 @@
import { Injectable } from '@nestjs/common';
import { getDb } from '../config/database.js';
import { notifications, notificationPreferences } from './notifications.schema.js';
import type { NotificationPreference } from './notifications.schema.js';
import { v4 as uuidv4 } from 'uuid';
import { eq, and } from 'drizzle-orm';
import { esClient } from '../config/elasticsearch.js';
import { logger } from '../shared/observability/logger.js';
export interface SendNotificationDto {
userId: string;
type: string;
title: string;
content: string;
channel?: string;
metadata?: Record<string, unknown>;
}
@Injectable()
export class NotificationsService {
async send(dto: SendNotificationDto) {
const id = uuidv4();
const db = getDb();
// 检查用户偏好
const [pref] = await db.select().from(notificationPreferences).where(eq(notificationPreferences.userId, dto.userId));
const channel = dto.channel || 'in_app';
if (pref && !this.isChannelEnabled(pref, channel)) {
logger.info({ userId: dto.userId, channel }, 'Notification skipped by preference');
return { skipped: true };
}
// 写入 DB
await db.insert(notifications).values({
id,
userId: dto.userId,
type: dto.type,
title: dto.title,
content: dto.content,
channel,
metadata: dto.metadata,
});
// 索引到 ES全文检索
try {
await esClient.index({
index: 'notifications',
id,
document: {
userId: dto.userId,
type: dto.type,
title: dto.title,
content: dto.content,
channel,
createdAt: new Date().toISOString(),
},
});
} catch (err) {
logger.error({ err }, 'Failed to index notification in ES');
}
// TODO: 触发 Push Gateway 推送P5 后期)
return { id, skipped: false };
}
async listByUser(userId: string, onlyUnread: boolean = false) {
const db = getDb();
const conditions = onlyUnread
? and(eq(notifications.userId, userId), eq(notifications.isRead, false))
: eq(notifications.userId, userId);
return db.select().from(notifications).where(conditions);
}
async markAsRead(id: string) {
const db = getDb();
await db.update(notifications).set({ isRead: true }).where(eq(notifications.id, id));
}
async search(userId: string, query: string) {
const result = await esClient.search({
index: 'notifications',
query: {
bool: {
must: [
{ term: { userId } },
{
multi_match: {
query,
fields: ['title', 'content'],
},
},
],
},
},
});
return result.hits.hits;
}
private isChannelEnabled(pref: NotificationPreference, channel: string): boolean {
switch (channel) {
case 'email': return pref.emailEnabled;
case 'sms': return pref.smsEnabled;
case 'push': return pref.pushEnabled;
case 'in_app': return pref.inAppEnabled;
default: return true;
}
}
}