You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

232 lines
6.9 KiB

/**
* 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"
import { env } from "env"
// ── 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<string, string> = {
'作者': '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<SSECallback>()
// ── User online tracking (userId → connection count) ──
const userConnections = new Map<string, number>()
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<string, number>()
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<ChatMessage[]> {
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<ChatMessage> {
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网站:${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<number>`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) }
}