chat-store.ts 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247
  1. import Database from "better-sqlite3";
  2. import { existsSync, mkdirSync, rmSync } from "node:fs";
  3. import { readFile } from "node:fs/promises";
  4. import path from "node:path";
  5. export type ChatAttachment = {
  6. name: string;
  7. meta: string;
  8. kind: "image" | "file";
  9. url?: string;
  10. storageKey?: string;
  11. };
  12. export type ChatMessage = {
  13. id: string;
  14. user: string;
  15. time: string;
  16. content: string;
  17. file?: ChatAttachment;
  18. };
  19. type MessageRow = {
  20. id: string;
  21. user: string;
  22. time: string;
  23. content: string;
  24. file_name: string | null;
  25. file_meta: string | null;
  26. file_kind: "image" | "file" | null;
  27. file_storage_key: string | null;
  28. created_at: number;
  29. };
  30. const storageRoot = path.join(process.cwd(), "storage");
  31. const uploadRoot = path.join(storageRoot, "chat-uploads");
  32. const dbPath = path.join(storageRoot, "chat.sqlite");
  33. function ensureStorage() {
  34. if (!existsSync(storageRoot)) {
  35. mkdirSync(storageRoot, { recursive: true });
  36. }
  37. if (!existsSync(uploadRoot)) {
  38. mkdirSync(uploadRoot, { recursive: true });
  39. }
  40. }
  41. ensureStorage();
  42. const db = new Database(dbPath);
  43. db.pragma("journal_mode = WAL");
  44. db.exec(`
  45. CREATE TABLE IF NOT EXISTS chat_messages (
  46. id TEXT PRIMARY KEY,
  47. user TEXT NOT NULL,
  48. time TEXT NOT NULL,
  49. content TEXT NOT NULL DEFAULT '',
  50. file_name TEXT,
  51. file_meta TEXT,
  52. file_kind TEXT,
  53. file_storage_key TEXT,
  54. created_at INTEGER NOT NULL
  55. );
  56. CREATE TABLE IF NOT EXISTS chat_presence (
  57. user TEXT PRIMARY KEY,
  58. last_seen INTEGER NOT NULL
  59. );
  60. `);
  61. const selectMessagesStmt = db.prepare(`
  62. SELECT id, user, time, content, file_name, file_meta, file_kind, file_storage_key, created_at
  63. FROM chat_messages
  64. ORDER BY created_at ASC
  65. `);
  66. const insertMessageStmt = db.prepare(`
  67. INSERT INTO chat_messages (
  68. id,
  69. user,
  70. time,
  71. content,
  72. file_name,
  73. file_meta,
  74. file_kind,
  75. file_storage_key,
  76. created_at
  77. ) VALUES (
  78. @id,
  79. @user,
  80. @time,
  81. @content,
  82. @file_name,
  83. @file_meta,
  84. @file_kind,
  85. @file_storage_key,
  86. @created_at
  87. )
  88. `);
  89. const trimMessagesStmt = db.prepare(`
  90. DELETE FROM chat_messages
  91. WHERE id IN (
  92. SELECT id
  93. FROM chat_messages
  94. ORDER BY created_at DESC
  95. LIMIT -1 OFFSET 500
  96. )
  97. `);
  98. const touchPresenceStmt = db.prepare(`
  99. INSERT INTO chat_presence (user, last_seen)
  100. VALUES (?, ?)
  101. ON CONFLICT(user) DO UPDATE SET last_seen = excluded.last_seen
  102. `);
  103. const deleteExpiredPresenceStmt = db.prepare(`
  104. DELETE FROM chat_presence
  105. WHERE last_seen < ?
  106. `);
  107. const listPresenceStmt = db.prepare(`
  108. SELECT user
  109. FROM chat_presence
  110. ORDER BY last_seen DESC
  111. `);
  112. function formatCurrentTime() {
  113. return new Intl.DateTimeFormat("zh-CN", {
  114. hour: "2-digit",
  115. minute: "2-digit",
  116. hour12: false
  117. }).format(new Date());
  118. }
  119. function toFileUrl(storageKey: string) {
  120. return `/api/chat/files/${encodeURIComponent(storageKey)}`;
  121. }
  122. function mapMessage(row: MessageRow): ChatMessage {
  123. return {
  124. id: row.id,
  125. user: row.user,
  126. time: row.time,
  127. content: row.content,
  128. file: row.file_name && row.file_meta && row.file_kind && row.file_storage_key
  129. ? {
  130. name: row.file_name,
  131. meta: row.file_meta,
  132. kind: row.file_kind,
  133. storageKey: row.file_storage_key,
  134. url: toFileUrl(row.file_storage_key)
  135. }
  136. : undefined
  137. };
  138. }
  139. export function formatUploadedFileMeta(fileName: string, fileType: string, fileSize: number) {
  140. const sizeMb = fileSize / (1024 * 1024);
  141. const size = sizeMb >= 1 ? `${sizeMb.toFixed(1)} MB` : `${Math.max(1, Math.round(fileSize / 1024))} KB`;
  142. return `${size} · ${fileType || "application/octet-stream"}`;
  143. }
  144. export function touchPresence(user?: string) {
  145. const name = user?.trim();
  146. if (!name) {
  147. return;
  148. }
  149. touchPresenceStmt.run(name, Date.now());
  150. }
  151. export function getOnlineUsers() {
  152. deleteExpiredPresenceStmt.run(Date.now() - 45_000);
  153. return (listPresenceStmt.all() as Array<{ user: string }>).map((row) => row.user);
  154. }
  155. export function listMessages() {
  156. return (selectMessagesStmt.all() as MessageRow[]).map(mapMessage);
  157. }
  158. export function addMessage(input: {
  159. user: string;
  160. content?: string;
  161. file?: {
  162. name: string;
  163. meta: string;
  164. kind: "image" | "file";
  165. storageKey: string;
  166. };
  167. }) {
  168. const record = {
  169. id: crypto.randomUUID(),
  170. user: input.user.trim() || "局域网设备",
  171. time: formatCurrentTime(),
  172. content: input.content?.trim() || "",
  173. file_name: input.file?.name || null,
  174. file_meta: input.file?.meta || null,
  175. file_kind: input.file?.kind || null,
  176. file_storage_key: input.file?.storageKey || null,
  177. created_at: Date.now()
  178. };
  179. insertMessageStmt.run(record);
  180. trimMessagesStmt.run();
  181. return mapMessage(record);
  182. }
  183. export async function clearMessages(clearedBy: string) {
  184. const rows = selectMessagesStmt.all() as MessageRow[];
  185. const storageKeys = rows
  186. .map((row) => row.file_storage_key)
  187. .filter((value): value is string => Boolean(value));
  188. for (const storageKey of storageKeys) {
  189. try {
  190. rmSync(path.join(uploadRoot, storageKey), { force: true });
  191. } catch {
  192. // ignore missing files during cleanup
  193. }
  194. }
  195. db.prepare("DELETE FROM chat_messages").run();
  196. const systemMessage = addMessage({
  197. user: "系统消息",
  198. content: `${clearedBy} 清理了服务器历史聊天记录。`
  199. });
  200. return systemMessage;
  201. }
  202. export function resolveUploadPath(storageKey: string) {
  203. return path.join(uploadRoot, storageKey);
  204. }
  205. export async function readUploadBuffer(storageKey: string) {
  206. return readFile(resolveUploadPath(storageKey));
  207. }
  208. export function createStorageKey(originalName: string) {
  209. const extension = path.extname(originalName);
  210. return `${Date.now()}-${crypto.randomUUID()}${extension}`;
  211. }