From 31f96dd49abb25022742ad4c86468f2e09c7f7be Mon Sep 17 00:00:00 2001 From: npmrun <1549469775@qq.com> Date: Sat, 27 Jun 2026 01:24:58 +0800 Subject: [PATCH] feat(chat): implement chat widget and backend services - Add ChatWidget component for real-time chat functionality, including unread message notifications and @mention toasts. - Create useChat composable for managing chat messages and SSE connections. - Update index page layout to welcome users. - Define API routes for fetching chat messages, sending messages, and managing unread counts. - Implement chat message storage in SQLite with necessary migrations. - Add server-side logic for handling chat message broadcasting and user connection tracking. - Ensure chat messages are persisted across server restarts and implement rate limiting for message sending. --- .reasonix/desktop-topic-title-sources.json | 3 +- .reasonix/desktop-topic-titles.json | 3 +- app/app.vue | 1 + app/components/chat/ChatBubble.vue | 202 +++++++++ app/components/chat/ChatRoom.vue | 469 +++++++++++++++++++++ app/components/chat/ChatWidget.vue | 208 +++++++++ app/composables/useChat.ts | 173 ++++++++ app/pages/index/index.vue | 59 ++- packages/common/config/index.ts | 2 + packages/drizzle-pkg/db.sqlite | Bin 282624 -> 282624 bytes packages/drizzle-pkg/lib/schema/content.ts | 18 + .../drizzle-pkg/migrations/0008_chat_messages.sql | 8 + packages/drizzle-pkg/migrations/meta/_journal.json | 7 + server/api/chat/messages.get.ts | 14 + server/api/chat/send.post.ts | 23 + server/api/chat/sse.get.ts | 52 +++ server/api/chat/unread.get.ts | 9 + server/api/chat/unread/clear.post.ts | 9 + server/plugins/05.chat-migrate.ts | 24 ++ server/service/chat/index.ts | 231 ++++++++++ 20 files changed, 1512 insertions(+), 3 deletions(-) create mode 100644 app/components/chat/ChatBubble.vue create mode 100644 app/components/chat/ChatRoom.vue create mode 100644 app/components/chat/ChatWidget.vue create mode 100644 app/composables/useChat.ts create mode 100644 packages/drizzle-pkg/migrations/0008_chat_messages.sql create mode 100644 server/api/chat/messages.get.ts create mode 100644 server/api/chat/send.post.ts create mode 100644 server/api/chat/sse.get.ts create mode 100644 server/api/chat/unread.get.ts create mode 100644 server/api/chat/unread/clear.post.ts create mode 100644 server/plugins/05.chat-migrate.ts create mode 100644 server/service/chat/index.ts diff --git a/.reasonix/desktop-topic-title-sources.json b/.reasonix/desktop-topic-title-sources.json index f04e650..48ee9cf 100644 --- a/.reasonix/desktop-topic-title-sources.json +++ b/.reasonix/desktop-topic-title-sources.json @@ -1,3 +1,4 @@ { - "topic_20260626-113733_9874ba9ab889b7ed": "manual" + "topic_20260626-113733_9874ba9ab889b7ed": "manual", + "topic_20260626-172249_f199eec9a4e051f4": "auto" } \ No newline at end of file diff --git a/.reasonix/desktop-topic-titles.json b/.reasonix/desktop-topic-titles.json index cd04107..dc8840f 100644 --- a/.reasonix/desktop-topic-titles.json +++ b/.reasonix/desktop-topic-titles.json @@ -1,3 +1,4 @@ { - "topic_20260626-113733_9874ba9ab889b7ed": "新的会话" + "topic_20260626-113733_9874ba9ab889b7ed": "新的会话", + "topic_20260626-172249_f199eec9a4e051f4": "新的会话" } \ No newline at end of file diff --git a/app/app.vue b/app/app.vue index 1b5bac7..15e8737 100644 --- a/app/app.vue +++ b/app/app.vue @@ -17,4 +17,5 @@ console.log('Global config siteName:', siteName.value) + diff --git a/app/components/chat/ChatBubble.vue b/app/components/chat/ChatBubble.vue new file mode 100644 index 0000000..054dfec --- /dev/null +++ b/app/components/chat/ChatBubble.vue @@ -0,0 +1,202 @@ + + + + + diff --git a/app/components/chat/ChatRoom.vue b/app/components/chat/ChatRoom.vue new file mode 100644 index 0000000..08a1ca1 --- /dev/null +++ b/app/components/chat/ChatRoom.vue @@ -0,0 +1,469 @@ + + + + + diff --git a/app/components/chat/ChatWidget.vue b/app/components/chat/ChatWidget.vue new file mode 100644 index 0000000..85db8ea --- /dev/null +++ b/app/components/chat/ChatWidget.vue @@ -0,0 +1,208 @@ + + + + + diff --git a/app/composables/useChat.ts b/app/composables/useChat.ts new file mode 100644 index 0000000..2f2ee08 --- /dev/null +++ b/app/composables/useChat.ts @@ -0,0 +1,173 @@ +/** + * useChat — composable for the public chat room. + * + * Global SSE connection (module-level singleton) for: + * - messages (shared array) + * - authorOnline status (reactive) + * - lastMention notification (for toast) + */ +import { request, unwrapApiBody, type ApiResponse } from "../utils/http/factory" +import type { ChatMessage } from "~~/server/service/chat" + +// ══════ Shared SSE state (module-level singleton) ══════ +const messages = ref([]) +const connected = ref(false) +const authorOnline = ref(false) +const lastMention = ref<{ from: string; content: string } | null>(null) +let sseStarted = false +let sseAbort: AbortController | null = null + +function startSSE() { + if (import.meta.server) return + if (sseStarted) return + sseStarted = true + + sseAbort?.abort() + sseAbort = new AbortController() + + fetch('/api/chat/sse', { signal: sseAbort.signal, credentials: 'include' }) + .then(async (response) => { + if (!response.ok || !response.body) throw new Error(`SSE failed: ${response.status}`) + const reader = response.body.getReader() + const decoder = new TextDecoder() + let buffer = '' + while (true) { + const { done, value } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split('\n') + buffer = lines.pop() || '' + for (const line of lines) { + if (!line.startsWith('data: ')) continue + const payload = line.slice(6) + if (payload.startsWith(':')) continue + try { + const parsed = JSON.parse(payload) + if (parsed.type === 'connected') { + connected.value = true + authorOnline.value = !!parsed.authorOnline + } else if (parsed.type === 'authorOnline') { + authorOnline.value = !!parsed.data + } else if (parsed.type === 'mentionNotify') { + lastMention.value = parsed.data + } else if (parsed.type === 'message' && parsed.data) { + messages.value = [...messages.value, parsed.data] + } + } catch { /* skip malformed */ } + } + } + }) + .catch((err: any) => { + if (err?.name !== 'AbortError') { + sseStarted = false + setTimeout(startSSE, 3000) + } + }) +} + +// ══════ Per-instance helpers ══════ +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}` +} + +function getClientId(): string { + if (import.meta.server) return '' + const key = 'chat_client_id' + let id = sessionStorage.getItem(key) + if (!id) { + id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}` + sessionStorage.setItem(key, id) + } + return id +} + +export function useChat() { + const loading = ref(false) + const loadingMore = ref(false) + const hasMore = ref(true) + const error = ref(null) + const clientId = getClientId() + + const _anonNickname = ref('') + function getAnonNickname(): string { + if (import.meta.server) return '' + if (!_anonNickname.value) { + _anonNickname.value = localStorage.getItem('chat_anon_nickname') || randomNickname() + localStorage.setItem('chat_anon_nickname', _anonNickname.value) + } + return _anonNickname.value + } + + async function loadHistory() { + loading.value = true + error.value = null + try { + const res = await request>('/api/chat/messages') + messages.value = unwrapApiBody(res) + hasMore.value = messages.value.length >= 20 + } catch (e: any) { + error.value = e?.message || '加载消息失败' + } finally { + loading.value = false + } + } + + async function loadMore() { + if (loadingMore.value || !hasMore.value || messages.value.length === 0) return + loadingMore.value = true + try { + const oldestId = messages.value[0]?.id + if (!oldestId) { hasMore.value = false; return } + const res = await request>(`/api/chat/messages?before=${oldestId}&limit=20`) + const older = unwrapApiBody(res) + if (older.length < 20) hasMore.value = false + // Prepend older messages + messages.value = [...older, ...messages.value] + } catch (e: any) { + error.value = e?.message || '加载更多失败' + } finally { + loadingMore.value = false + } + } + + async function send(content: string, nickname?: string) { + const trimmed = content.trim() + if (!trimmed) return + error.value = null + try { + await request('/api/chat/send', { + method: 'POST', + body: { content: trimmed, nickname: nickname || getAnonNickname(), clientId }, + }) + } catch (e: any) { + error.value = e?.data?.message || e?.message || '发送失败' + throw e + } + } + + if (import.meta.client) { + onMounted(() => { + startSSE() + loadHistory() + }) + } + + return { + messages, + connected, + authorOnline, + lastMention, + loading, + loadingMore, + hasMore, + error, + send, + loadHistory, + loadMore, + } +} diff --git a/app/pages/index/index.vue b/app/pages/index/index.vue index 367ed14..d6d5b1d 100644 --- a/app/pages/index/index.vue +++ b/app/pages/index/index.vue @@ -1 +1,58 @@ - \ No newline at end of file + + + + + diff --git a/packages/common/config/index.ts b/packages/common/config/index.ts index 7fa3ec2..cf3cddb 100644 --- a/packages/common/config/index.ts +++ b/packages/common/config/index.ts @@ -28,6 +28,8 @@ export const API_ALLOWLIST: RouteRule[] = [ { path: "/api/tools", methods: ["GET"] }, { path: "/api/tools/:id", methods: ["GET"] }, { path: "/api/pic/random", methods: ["GET"] }, + /** 聊天消息只读 — 公开 */ + { path: "/api/chat/messages", methods: ["GET"] }, ]; export const FRONTEND_LOGIN_PATH = "/auth/login" diff --git a/packages/drizzle-pkg/db.sqlite b/packages/drizzle-pkg/db.sqlite index 8beeb03dc38d8737992204f0e0494c43b3247375..9a940da04a77ccced180d89a9144c7da7f6a0659 100644 GIT binary patch delta 3845 zcmchadrVu`9moB^HTZFlJ-S9wqU3@B<6sl~1d~z{z)47n$ugoPYE^puN}L9_!7d`w zvJ1_ojWq2VqCi)LLbjxI$~stRLxakrf25*HBbdtasFG%3*g#kZT{Ly5owDEcy(f;3 z^GE-fhIG-_$DiN#{LbTd&UH(Vwxvh=#wu-(NF;K>qk=~RkL9@0D(?By^SymybRxaq zno$OgCGb=XRP~P`N``tx=qogiZlWR7JCH@6M44KdR4?}KVkro+8JZXz znhZkFIvtxwg1=c8d2%a#_AL6!g9jVjBE(oojbpzNN@OyKC!c z_pX{6qY&qPF%!Ek92P6G**x$%~VufT3 zFInU(?0RvlHW>2xn}fdQdOrW~-g+y;IOpM)QHx)Y^0lT%hgbDcVN^y67?8UFe-Dx)#{^|HZJkDBF;)g&8@fJBv zHZ$WfIXzCh$>B8zEOw9CSK{!fAnulJJH$=)Z+B?paSpq!*kUuA2k(C-kx5k=^+6F* zqCWLO^;?6~q=ZrL$d~RCf1j)}Iq-X=G5GN*UOqmBMh$PW|v zb!C6U!KNZ}Lvgd|K&098Qdp&T1Gb9CHqH#6nrrz52bSigfZHDl7L1W#b`5}9mbYZW zoSS|R3F)7fi&p*+7{1HE%ib3_aJ3ac`=Px}PE*hv@CTgAb=v^s^5emWQq=U3Y*jU| zwLHthKxT$}Vr?U_UmXWA^Wkf=-Dl@pPb_}aJ^T9aqsM;>0=isUs32{OTVVjXeEwW8 zj^yZb`^k|QwgA1Br@0Q_?3?Rv6?d~~OQ)tuHg7YqD<81Yv%{Qc`!dNHT`&;NlOHtL zf@k#PQJ@#6>dAcdRgaiUHf?eE7)k0Xq5n+2e~Z})cC-H}BRkKk00;>XhkUb;f^144 zW)8VmMVPsSvZ@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*?)OpGpdHACO3`|h zgH|F6NvVHRUs2Q4HR?0!W9kBRj_RRKQb#G4icmr7IqJvMR;rA$PGv!#`Yw^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`hsAG>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<^fWuJyQOo+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@=qm1j17bhSDJyW8=^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>{pF-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}luM(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+i8QBg#S%c^llozHjATndDD&-A8znoOvUepsK< z+6(WF+FNTzy^u6|SaYErj^v$5i$U|wq??mQ4Qq~mA2jKC^fvDS&D(r(idJTg0&6Y3 z;)nB@C|YyJH6={P~^rwts$Sdv(wZ&{SCy;f*2%2P>{` zqdnFLD_%0K2?9_j?0Np49s_W{9d+VvWXJ)+~_RRc! zI-@sBh!MhRuanLwdl!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) } +}