+ 作者在线 · @作者 无需邮件
+ @作者 可邮件通知
+
一个收集、分享和交流的地方
+VPsSvZ@qH7QI&d)_U8ca+vOQl3<|~Km%_*_r|+( zBO_qU;-low`W7<4V1WRkWai%-jdhJg&-6mxck;g@QKLDj5;(e}Z~pWsN3S{$bkbp= zFM5zNJDM2W9nD0F?|L9{jkmYQI^Tg5X3xG2zPE3r(E7p$z7N^nzEV!w>P!H}3yi%u zI@fZHv-`wNQWhf&ssJX8FK;O9=pWyXwViG4*|mI)8uUi*Xxb1lCC*<0C5)s)Q>M5;I5BrH(ey9%S~8?eE|*{ zPrlk%xZ84a5?+D5f&GA8eedt2SFR2y;i6>1zA(dKT^%Hroeh|lPb_|`U40@Yt-LH? z_-bTFxn+$X*?)O pGpdHACO3`|h zgH|F6NvVHRUs2Q4HR?0!W9kBRj_RRKQb#G4icmr7IqJvMR;rA$P Gv!#`Y w^c(EYisCHmE-fG9P*J%?Z9G_M!^S`rTx$3+b zOU*=)AmQX%kL8u}LP4@{0-11gCu77iv|NBC)wE8KQ0E3LuTSI)66#bhNGQfUEXVtE s1qo%9gJoBT4$CK8*$U~p`07IuWC_i%#c3=Ds@7szvu({g&r6s73wucz9{>OV delta 3645 zcma);YmAk}8OQhRSqh8LS1w+ I`hs AG>hyAC% ze{Zk8y{tK<_luc8xF|V+pG3{!_5=vldy6b_zI4iHLi@-s0kCWJ1OV(>eSQnjJv9TW z<9};j+_!248ohAm#w{S(xp62-E*+N4cr8nI?7k8tJ9d9Fh4F +?y4`c{ngVz*#7E2(;=5+LN)OR&5J9#x(R`7JGKfm+m2P~l#8>* z0aylS5w+AHj-k{Jhlsono<^fWu JyQOo+53#4wF~5OtivBgeHxQcCVi zD}^-`SzX7u_1ss0u=U*65@AXva9eWAOh6$@3IyqqA!Ui24#arj1jT*!ZU7AR-VA`D z-rHJ$?u#;@T5vmL)7=gCe0tV+kUTx>ymZFoEaBc^s%4!%NF-zwiYhIKgRC}QQio$Z zn@=qm1j17bhSDJyW 8=^D^HdDDWVnKZ0<`LDHRMRR)h;GJhcGI-~I zQXrqp66utKv4V7!D%ODrNS7#~Kd~u;wAitVoYMuj-E!~mfUxD>HHq-qOu)`#s|aKm z`Wf+3TBKDBD0*8Lf<)r0#z39$fqn*rfqs(+7i5C9kX|KWss#rE4-6SodSq3d9b&M= zslzw#tt+;9Z@XeW8BlVCxncq-4+q1D5CA}4L!eeDR#HnU@^Br(rq^!+!lu`Mk}~y~ zOsH;zY^E9MKRFR3{U>{p F-KK={+xNgu_@XiXj6WTF1w_ zYaEQcEG4m#3g_C*_e9SeAUx4CFXiIgVS=~91XUtWT3#19sx1UbRuU>rC=HC+isp>& z$7j?9eSF5hQ<uK=o?fe64AJZ1!@HJU08+>5Pe4!kN=Fln|xY0&&_JWloTcN_rEm zA*$Vc8yD3T+qg(4!h}q4UV0q^MNT;A6)H<)Jab4;;x+WOwz%CjkKXbZAUt|YyJX`t zK}l uM(HNq^Z995+|961!@c!#kl%LLoYR8g1f)Bm%5l9|-BUNnL*euanN@s(G z!3)s65u8U;!4Wl#S*8e)eM@)D7`^bp(|z#!2T$Ky9lED^)AZ%v$N*|1!2xavHGr-Y zT7#KDyp+P?vCc$Sx|FDomi-D4A1(V$y607yASsNQ2fuV8Kt}}4kR!0KH0F*+YPs^M z>&~p13V<_fPy)=!fRb3_xyDFD$S=nRCNMaPXpG80J9q}J+nT`B{m%m7bpP{-Fgp{Z zk6;5vAc`5Z6b6igVnKgIEg6r(fFst8e`=ovz^Q$U6X41W5UPaer9_gz#Br|)vM@7< z8C^QbWfVRY`$W&%065Wev<2wCA_J=L9vODj -rv%GaC{{b zoTN-fXerAj*o2Q1nNoX1JeO2}f0e8~dx!VG2ZY1>+Xek{CRFxBebZC*P2W3sA86h? zcz?R-m$C+i8 QBg#S%c^llozHjATndDD&-A8znoOvUepsK< z+6(WF+FNTzy^u6|SaYErj^v$5i$U|wq??mQ4Qq~mA2jKC^fvDS&D(r(idJTg0&6Y3 z;)n B@C|YyJH6={P~^rwts$Sdv(wZ&{SCy;f*2%2P>{` zqdnFLD_%0 K2?9_j?0Np49s_W{9d+VvWXJ)+~_RRc! zI-@sBh!M hRuanLwdl!C}})^;+W0 mLhZoqk->~p7pPWcyJ? [ + index("idx_chat_msg_created").on(table.createdAt), + ], +); diff --git a/packages/drizzle-pkg/migrations/0008_chat_messages.sql b/packages/drizzle-pkg/migrations/0008_chat_messages.sql new file mode 100644 index 0000000..8d34684 --- /dev/null +++ b/packages/drizzle-pkg/migrations/0008_chat_messages.sql @@ -0,0 +1,8 @@ +CREATE TABLE `chat_messages` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `nickname` text(20) NOT NULL, + `content` text NOT NULL, + `created_at` integer NOT NULL +); +--> statement-breakpoint +CREATE INDEX `idx_chat_msg_created` ON `chat_messages` (`created_at`); diff --git a/packages/drizzle-pkg/migrations/meta/_journal.json b/packages/drizzle-pkg/migrations/meta/_journal.json index 0a8f4ea..4e3700e 100644 --- a/packages/drizzle-pkg/migrations/meta/_journal.json +++ b/packages/drizzle-pkg/migrations/meta/_journal.json @@ -57,6 +57,13 @@ "when": 1780705829924, "tag": "0007_gigantic_franklin_storm", "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1780800000000, + "tag": "0008_chat_messages", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/api/chat/messages.get.ts b/server/api/chat/messages.get.ts new file mode 100644 index 0000000..17bfb78 --- /dev/null +++ b/server/api/chat/messages.get.ts @@ -0,0 +1,14 @@ +/** + * GET /api/chat/messages — get recent chat messages + * Query: ?limit=50&before=123 (cursor pagination by message ID) + */ +import { getMessages } from "#server/service/chat" + +export default defineEventHandler(async (event) => { + const query = getQuery(event) + const limit = Math.min(Number(query.limit) || 20, 100) + const before = query.before ? Number(query.before) : undefined + + const messages = await getMessages(limit, before) + return { code: 0, data: messages } +}) diff --git a/server/api/chat/send.post.ts b/server/api/chat/send.post.ts new file mode 100644 index 0000000..92584be --- /dev/null +++ b/server/api/chat/send.post.ts @@ -0,0 +1,23 @@ +/** + * POST /api/chat/send — send a message to the chat room (auth required) + */ +import { sendMessage } from "#server/service/chat" +import { getCurrentUser } from "#server/utils/context" + +export default defineEventHandler(async (event) => { + const body = await readBody<{ content: string; nickname?: string; clientId?: string }>(event) + const ip = getRequestIP(event, { xForwardedFor: true }) ?? '127.0.0.1' + + const user = await getCurrentUser(event) + const isAuthor = user?.role === 'admin' + const userId = user?.id + + const defaultNick = user?.nickname || user?.username || undefined + + const msg = await sendMessage(body?.content ?? '', ip, body?.nickname || defaultNick, body?.clientId, isAuthor, userId) + + return { + code: 0, + data: msg, + } +}) diff --git a/server/api/chat/sse.get.ts b/server/api/chat/sse.get.ts new file mode 100644 index 0000000..687f667 --- /dev/null +++ b/server/api/chat/sse.get.ts @@ -0,0 +1,52 @@ +/** + * GET /api/chat/sse — Server-Sent Events stream for real-time chat messages + */ +import { subscribe, authorConnected, authorDisconnected, isAuthorOnline, userConnected, userDisconnected } from "#server/service/chat" +import { getCurrentUser } from "#server/utils/context" + +export default defineEventHandler(async (event) => { + const res = event.node.res + + const user = await getCurrentUser(event) + const isAuthor = user?.role === 'admin' + const userId = user?.id + + // Register connection BEFORE sending connected event (so isAuthorOnline is accurate) + if (isAuthor) authorConnected() + if (userId) userConnected(String(userId)) + + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache, no-transform', + 'Connection': 'keep-alive', + 'X-Accel-Buffering': 'no', + }) + + // Send actual global author online status (not just the connecting user's role) + res.write(`data: ${JSON.stringify({ type: 'connected', authorOnline: isAuthorOnline() })}\n\n`) + + const unsub = subscribe((msg) => { + // msg is either a ChatMessage object or a status-string from broadcastStatus + if (typeof msg === 'string') { + // Status broadcast: send directly (already JSON: {"type":"authorOnline","data":true}) + res.write(`data: ${msg}\n\n`) + } else { + res.write(`data: ${JSON.stringify({ type: 'message', data: msg })}\n\n`) + } + }) + + const heartbeat = setInterval(() => { + res.write(': heartbeat\n\n') + }, 30_000) + + event.node.req.on('close', () => { + unsub() + clearInterval(heartbeat) + if (isAuthor) authorDisconnected() + if (userId) userDisconnected(String(userId)) + }) + + await new Promise ((resolve) => { + event.node.req.on('close', () => resolve()) + }) +}) diff --git a/server/api/chat/unread.get.ts b/server/api/chat/unread.get.ts new file mode 100644 index 0000000..84bcd81 --- /dev/null +++ b/server/api/chat/unread.get.ts @@ -0,0 +1,9 @@ +/** + * GET /api/chat/unread — get unread @mention count for current user + */ +import { getUnreadCount } from "#server/service/chat" + +export default defineEventHandler(async () => { + const count = getUnreadCount('_global') + return { code: 0, data: { count } } +}) diff --git a/server/api/chat/unread/clear.post.ts b/server/api/chat/unread/clear.post.ts new file mode 100644 index 0000000..3301f36 --- /dev/null +++ b/server/api/chat/unread/clear.post.ts @@ -0,0 +1,9 @@ +/** + * POST /api/chat/unread/clear — clear unread count for current user + */ +import { clearUnread } from "#server/service/chat" + +export default defineEventHandler(async () => { + clearUnread('_global') + return { code: 0, data: null } +}) diff --git a/server/plugins/05.chat-migrate.ts b/server/plugins/05.chat-migrate.ts new file mode 100644 index 0000000..e459625 --- /dev/null +++ b/server/plugins/05.chat-migrate.ts @@ -0,0 +1,24 @@ +/** + * Auto-migrate: ensure chat_messages table and columns exist. + * Runs on every server start — idempotent DDL. + */ +import { dbGlobal } from "drizzle-pkg/lib/db" + +export default defineNitroPlugin(async () => { + await dbGlobal.run(` + CREATE TABLE IF NOT EXISTS chat_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + nickname TEXT(20) NOT NULL, + content TEXT NOT NULL, + created_at INTEGER NOT NULL, + client_id TEXT, + user_id INTEGER + ) + `) + try { await dbGlobal.run(`ALTER TABLE chat_messages ADD COLUMN client_id TEXT`) } catch {} + try { await dbGlobal.run(`ALTER TABLE chat_messages ADD COLUMN user_id INTEGER`) } catch {} + + await dbGlobal.run(` + CREATE INDEX IF NOT EXISTS idx_chat_msg_created ON chat_messages (created_at) + `) +}) diff --git a/server/service/chat/index.ts b/server/service/chat/index.ts new file mode 100644 index 0000000..d8ffdce --- /dev/null +++ b/server/service/chat/index.ts @@ -0,0 +1,231 @@ +/** + * 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) } +}