/** * Chat Service — DB-persisted pub/sub for the public chat room. * * Messages are stored in SQLite (survives server restarts). * SSE subscribers are tracked so new messages are broadcast in real-time. */ import { assertUnderRateLimit } from "#server/utils/simple-rate-limit" import { sendMail } from "#server/service/email" import { dbGlobal } from "drizzle-pkg/lib/db" import { chatMessages } from "drizzle-pkg/lib/schema/content" import { desc, sql } from "drizzle-orm" // ── Types ── export interface ChatMessage { id: number nickname: string content: string createdAt: number /** ID of the user who sent this message */ userId?: number clientId?: string /** Sent by the author (admin) account */ isAuthor?: boolean /** @mention triggered an email notification */ mentionNotified?: boolean /** @mention would have triggered email but author was online — skipped */ mentionSkipped?: boolean } // ── Config ── const MAX_MESSAGES = 500 const NICKNAME_PREFIXES = ['小', '大', '阿', '老', '' as string] const NICKNAME_SUFFIXES = ['白', '黑', '花', '黄', '蓝', '云', '风', '月', '星', '龙', '虎', '猫', '狗', '鹿', '鹏'] function randomNickname(): string { const prefix = NICKNAME_PREFIXES[Math.floor(Math.random() * NICKNAME_PREFIXES.length)] const suffix = NICKNAME_SUFFIXES[Math.floor(Math.random() * NICKNAME_SUFFIXES.length)] return `${prefix}${suffix}` } // ── @mention → email mapping ── const MENTION_EMAIL_MAP: Record = { '作者': '1549469775@qq.com', } function parseMentions(content: string): string[] { const matches = content.match(/@(\S+)/g) if (!matches) return [] return matches.map(m => m.slice(1)) } // ── SSE subscribers ── type SSECallback = (msg: ChatMessage | string) => void const subscribers = new Set() // ── User online tracking (userId → connection count) ── const userConnections = new Map() export function userConnected(userId: string) { userConnections.set(userId, (userConnections.get(userId) || 0) + 1) } export function userDisconnected(userId: string) { const count = userConnections.get(userId) || 0 if (count <= 1) userConnections.delete(userId) else userConnections.set(userId, count - 1) } export function isUserOnline(userId: string): boolean { return (userConnections.get(userId) || 0) > 0 } // ── Unread @mention tracking (userId → count) ── const unreadCounts = new Map() export function getUnreadCount(userId: string): number { return unreadCounts.get(userId) || 0 } export function clearUnread(userId: string) { unreadCounts.delete(userId) } // ── Author online tracking ── let authorConnectionCount = 0 export function authorConnected() { const wasOffline = authorConnectionCount === 0 authorConnectionCount++ if (wasOffline) broadcastStatus('authorOnline', true) } export function authorDisconnected() { if (authorConnectionCount > 0) authorConnectionCount-- if (authorConnectionCount === 0) broadcastStatus('authorOnline', false) } function broadcastStatus(type: string, value: any) { const evt = JSON.stringify({ type, data: value }) for (const cb of subscribers) { try { cb(evt as any) } catch {} } } export function isAuthorOnline(): boolean { return authorConnectionCount > 0 } // ── Public API ── /** Get recent messages (newest last) from DB. Pass beforeId for cursor pagination. */ export async function getMessages(limit = 20, beforeId?: number): Promise { let query = dbGlobal .select() .from(chatMessages) .orderBy(desc(chatMessages.createdAt)) .limit(limit) if (beforeId) { query = query.where(sql`${chatMessages.id} < ${beforeId}`) } const rows = await query return rows.reverse().map(r => ({ id: r.id, nickname: r.nickname, content: r.content, createdAt: r.createdAt instanceof Date ? r.createdAt.getTime() : r.createdAt, userId: r.userId ?? undefined, clientId: r.clientId ?? undefined, })) } /** * Send a message to the chat room. */ export async function sendMessage( content: string, ip: string, nickname?: string, clientId?: string, isAuthor?: boolean, userId?: number ): Promise { assertUnderRateLimit(`chat:${ip}`, 10, 10_000) const trimmed = content.trim() if (!trimmed || trimmed.length > 500) { throw createError({ statusCode: 400, statusMessage: trimmed ? '消息不能超过 500 字' : '消息不能为空', }) } const displayNickname = isAuthor ? '作者' : (nickname?.trim() || randomNickname()) const now = Date.now() const [inserted] = await dbGlobal .insert(chatMessages) .values({ nickname: displayNickname, content: trimmed, createdAt: new Date(now), clientId: clientId || null, userId: userId || null, }) .returning({ id: chatMessages.id }) const msg: ChatMessage = { id: inserted!.id, nickname: displayNickname, content: trimmed, createdAt: now, userId: userId || undefined, clientId: clientId || undefined, isAuthor: isAuthor || undefined, } // ── Handle @mentions → email notification + unread tracking ── const mentions = parseMentions(trimmed) const hasSubscribers = subscribers.size > 0 for (const mention of mentions) { const email = MENTION_EMAIL_MAP[mention] if (email) { if (isAuthorOnline()) { msg.mentionSkipped = true // Broadcast a live notification to the author via SSE (toast) broadcastStatus('mentionNotify', { from: displayNickname, content: trimmed }) break } assertUnderRateLimit(`chat-mention:${ip}:${mention}`, 1, 5 * 60_000) sendMail({ to: email, subject: `[聊天室] @${mention} — 来自 ${displayNickname} 的消息`, text: `有人在聊天室 @${mention} 了你:\n\n"${trimmed}"\n\n—— ${displayNickname}\n时间:${new Date(now).toLocaleString('zh-CN')}\n\n网站:${process.env.APP_URL || '(未配置)'}`, }).catch(err => { console.error('Failed to send @mention email:', err) }) msg.mentionNotified = true break } // For any @mention: if no SSE subscribers at all, increment global unread if (!hasSubscribers) { const current = unreadCounts.get('_global') || 0 unreadCounts.set('_global', current + 1) break } } pruneMessages() for (const cb of subscribers) { try { cb(msg) } catch { /* drop broken subscriber */ } } return msg } async function pruneMessages() { const [{ count }] = await dbGlobal .select({ count: sql`count(*)` }) .from(chatMessages) if (count > MAX_MESSAGES) { const excess = count - MAX_MESSAGES await dbGlobal.run( sql`DELETE FROM chat_messages WHERE id IN (SELECT id FROM chat_messages ORDER BY created_at ASC LIMIT ${excess})` ) } } export function subscribe(cb: SSECallback): () => void { subscribers.add(cb) return () => { subscribers.delete(cb) } }