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 8beeb03..9a940da 100644 Binary files a/packages/drizzle-pkg/db.sqlite and b/packages/drizzle-pkg/db.sqlite differ diff --git a/packages/drizzle-pkg/lib/schema/content.ts b/packages/drizzle-pkg/lib/schema/content.ts index 98c065b..d8cd850 100644 --- a/packages/drizzle-pkg/lib/schema/content.ts +++ b/packages/drizzle-pkg/lib/schema/content.ts @@ -193,3 +193,21 @@ export const articleCards = sqliteTable( index("idx_article_card_card").on(table.cardId), ], ); + +// ============ ChatMessage(聊天室消息)============ +export const chatMessages = sqliteTable( + "chat_messages", + { + id: integer("id").primaryKey({ autoIncrement: true }), + nickname: text("nickname", { length: 20 }).notNull(), + content: text("content").notNull(), + createdAt: integer("created_at", { mode: "timestamp_ms" }) + .defaultNow() + .notNull(), + clientId: text("client_id"), + userId: integer("user_id"), + }, + (table) => [ + 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) } +}