From 45f22de70353afe2e59e149eccb19367aaf14a17 Mon Sep 17 00:00:00 2001 From: npmrun <1549469775@qq.com> Date: Mon, 27 Apr 2026 20:54:08 +0800 Subject: [PATCH] feat(quick-note): add resizable quick note modal with per-user persistence Introduce a lightweight quick note flow with draggable/resizable modal editor, unsaved-change protection, and per-user backend storage APIs backed by a dedicated quick_notes schema. Made-with: Cursor --- app/components/AppShell.vue | 15 + app/components/QuickNoteEditor.vue | 142 +++++ app/components/QuickNoteModal.vue | 578 +++++++++++++++++++++ app/components/quick-note-editor-vditor-config.ts | 37 ++ app/components/quick-note-modal-layout.test.ts | 86 +++ app/components/quick-note-modal-layout.ts | 113 ++++ app/components/quick-note-modal-state.test.ts | 58 +++ app/components/quick-note-modal-state.ts | 58 +++ .../drizzle-pkg/database/sqlite/schema/content.ts | 17 + packages/drizzle-pkg/lib/schema/content.ts | 1 + .../drizzle-pkg/migrations/0012_quick_notes.sql | 10 + packages/drizzle-pkg/migrations/meta/_journal.json | 7 + server/api/me/quick-note.get.ts | 13 + server/api/me/quick-note.put.ts | 18 + server/service/quick-note/index.test.ts | 172 ++++++ server/service/quick-note/index.ts | 74 +++ 16 files changed, 1399 insertions(+) create mode 100644 app/components/QuickNoteEditor.vue create mode 100644 app/components/QuickNoteModal.vue create mode 100644 app/components/quick-note-editor-vditor-config.ts create mode 100644 app/components/quick-note-modal-layout.test.ts create mode 100644 app/components/quick-note-modal-layout.ts create mode 100644 app/components/quick-note-modal-state.test.ts create mode 100644 app/components/quick-note-modal-state.ts create mode 100644 packages/drizzle-pkg/migrations/0012_quick_notes.sql create mode 100644 server/api/me/quick-note.get.ts create mode 100644 server/api/me/quick-note.put.ts create mode 100644 server/service/quick-note/index.test.ts create mode 100644 server/service/quick-note/index.ts diff --git a/app/components/AppShell.vue b/app/components/AppShell.vue index 9e6e93f..d4e8acc 100644 --- a/app/components/AppShell.vue +++ b/app/components/AppShell.vue @@ -1,5 +1,6 @@ + + + + diff --git a/app/components/QuickNoteModal.vue b/app/components/QuickNoteModal.vue new file mode 100644 index 0000000..c0eca6b --- /dev/null +++ b/app/components/QuickNoteModal.vue @@ -0,0 +1,578 @@ + + + diff --git a/app/components/quick-note-editor-vditor-config.ts b/app/components/quick-note-editor-vditor-config.ts new file mode 100644 index 0000000..3e0d386 --- /dev/null +++ b/app/components/quick-note-editor-vditor-config.ts @@ -0,0 +1,37 @@ +import { buildPostBodyMarkdownEditorVditorOptions } from './post-body-markdown-editor-vditor-config' + +interface BuildQuickNoteVditorOptionsInput { + value: string + onInput: (value: string) => void + onUploadError: () => void +} + +const QUICK_NOTE_TOOLBAR: ReadonlyArray = [ + 'bold', + 'italic', + 'headings', + '|', + 'list', + 'ordered-list', + '|', + 'link', + 'upload', + 'code', +] + +export function buildQuickNoteEditorVditorOptions(input: BuildQuickNoteVditorOptionsInput): Record { + const baseOptions = buildPostBodyMarkdownEditorVditorOptions({ + value: input.value, + isMobile: true, + onInput: input.onInput, + onUploadError: input.onUploadError, + }) + + return { + ...baseOptions, + height: '100%', + toolbar: QUICK_NOTE_TOOLBAR, + } +} + +export const quickNoteEditorToolbarPreset = QUICK_NOTE_TOOLBAR diff --git a/app/components/quick-note-modal-layout.test.ts b/app/components/quick-note-modal-layout.test.ts new file mode 100644 index 0000000..972ac54 --- /dev/null +++ b/app/components/quick-note-modal-layout.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, test } from 'bun:test' +import { + clampModalOffset, + clampModalRect, + clampModalSize, + createDefaultModalRect, + createDefaultModalSize, +} from './quick-note-modal-layout' + +describe('quick-note modal layout helpers', () => { + test('createDefaultModalSize uses near-fullscreen viewport with margins', () => { + expect(createDefaultModalSize({ + viewportWidth: 1200, + viewportHeight: 900, + margin: 24, + minWidth: 640, + minHeight: 420, + })).toEqual({ + width: 1152, + height: 852, + }) + }) + + test('createDefaultModalRect uses margin as origin', () => { + expect(createDefaultModalRect({ + viewportWidth: 1200, + viewportHeight: 900, + margin: 24, + minWidth: 640, + minHeight: 420, + })).toEqual({ + left: 24, + top: 24, + width: 1152, + height: 852, + }) + }) + + test('clampModalSize enforces min and max bounds', () => { + expect(clampModalSize({ + width: 400, + height: 2000, + minWidth: 640, + minHeight: 420, + maxWidth: 1152, + maxHeight: 852, + })).toEqual({ + width: 640, + height: 852, + }) + }) + + test('clampModalOffset keeps dragged modal inside viewport bounds', () => { + expect(clampModalOffset({ + offsetX: 1000, + offsetY: -1000, + width: 800, + height: 500, + viewportWidth: 1200, + viewportHeight: 900, + margin: 24, + })).toEqual({ + offsetX: 176, + offsetY: -176, + }) + }) + + test('clampModalRect keeps resized modal inside viewport and min size', () => { + expect(clampModalRect({ + left: -200, + top: 50, + width: 300, + height: 1200, + minWidth: 640, + minHeight: 420, + viewportWidth: 1200, + viewportHeight: 900, + margin: 24, + })).toEqual({ + left: 24, + top: 24, + width: 640, + height: 852, + }) + }) +}) diff --git a/app/components/quick-note-modal-layout.ts b/app/components/quick-note-modal-layout.ts new file mode 100644 index 0000000..12b4362 --- /dev/null +++ b/app/components/quick-note-modal-layout.ts @@ -0,0 +1,113 @@ +export interface ModalSize { + width: number + height: number +} + +export interface ModalOffset { + offsetX: number + offsetY: number +} + +export interface ModalRect { + left: number + top: number + width: number + height: number +} + +export interface CreateDefaultModalSizeInput { + viewportWidth: number + viewportHeight: number + margin: number + minWidth: number + minHeight: number +} + +export interface CreateDefaultModalRectInput extends CreateDefaultModalSizeInput {} + +export interface ClampModalSizeInput extends ModalSize { + minWidth: number + minHeight: number + maxWidth: number + maxHeight: number +} + +export interface ClampModalOffsetInput extends ModalSize { + offsetX: number + offsetY: number + viewportWidth: number + viewportHeight: number + margin: number +} + +export interface ClampModalRectInput extends ModalRect { + minWidth: number + minHeight: number + viewportWidth: number + viewportHeight: number + margin: number +} + +export function clampModalSize(input: ClampModalSizeInput): ModalSize { + return { + width: Math.max(input.minWidth, Math.min(input.width, input.maxWidth)), + height: Math.max(input.minHeight, Math.min(input.height, input.maxHeight)), + } +} + +export function createDefaultModalSize(input: CreateDefaultModalSizeInput): ModalSize { + const maxWidth = Math.max(input.minWidth, input.viewportWidth - input.margin * 2) + const maxHeight = Math.max(input.minHeight, input.viewportHeight - input.margin * 2) + return clampModalSize({ + width: maxWidth, + height: maxHeight, + minWidth: input.minWidth, + minHeight: input.minHeight, + maxWidth, + maxHeight, + }) +} + +export function createDefaultModalRect(input: CreateDefaultModalRectInput): ModalRect { + const size = createDefaultModalSize(input) + return { + left: input.margin, + top: input.margin, + width: size.width, + height: size.height, + } +} + +export function clampModalOffset(input: ClampModalOffsetInput): ModalOffset { + const centerX = (input.viewportWidth - input.width) / 2 + const centerY = (input.viewportHeight - input.height) / 2 + const minOffsetX = input.margin - centerX + const maxOffsetX = input.viewportWidth - input.margin - (centerX + input.width) + const minOffsetY = input.margin - centerY + const maxOffsetY = input.viewportHeight - input.margin - (centerY + input.height) + return { + offsetX: Math.max(minOffsetX, Math.min(input.offsetX, maxOffsetX)), + offsetY: Math.max(minOffsetY, Math.min(input.offsetY, maxOffsetY)), + } +} + +export function clampModalRect(input: ClampModalRectInput): ModalRect { + const maxWidth = Math.max(input.minWidth, input.viewportWidth - input.margin * 2) + const maxHeight = Math.max(input.minHeight, input.viewportHeight - input.margin * 2) + const size = clampModalSize({ + width: input.width, + height: input.height, + minWidth: input.minWidth, + minHeight: input.minHeight, + maxWidth, + maxHeight, + }) + const maxLeft = input.viewportWidth - input.margin - size.width + const maxTop = input.viewportHeight - input.margin - size.height + return { + left: Math.max(input.margin, Math.min(input.left, maxLeft)), + top: Math.max(input.margin, Math.min(input.top, maxTop)), + width: size.width, + height: size.height, + } +} diff --git a/app/components/quick-note-modal-state.test.ts b/app/components/quick-note-modal-state.test.ts new file mode 100644 index 0000000..791d919 --- /dev/null +++ b/app/components/quick-note-modal-state.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from 'bun:test' +import { + computeIsDirty, + createQuickNoteModalState, + markSaveFailed, + markSaveSucceeded, + updateDraftContent, +} from './quick-note-modal-state' + +describe('quick-note modal state', () => { + test('draft differs from saved => dirty', () => { + expect(computeIsDirty({ savedContent: 'a', draftContent: 'b' })).toBe(true) + expect(computeIsDirty({ savedContent: 'a', draftContent: 'a' })).toBe(false) + }) + + test('saving success marks state clean', () => { + const initial = createQuickNoteModalState('hello') + const editing = updateDraftContent(initial, 'hello world') + expect(editing.isDirty).toBe(true) + + const saved = markSaveSucceeded(editing) + expect(saved.savedContent).toBe('hello world') + expect(saved.draftContent).toBe('hello world') + expect(saved.isDirty).toBe(false) + expect(saved.lastSaveFailed).toBe(false) + }) + + test('saving failure keeps draft dirty', () => { + const initial = createQuickNoteModalState('hello') + const editing = updateDraftContent(initial, 'draft changed') + const failed = markSaveFailed(editing) + + expect(failed.savedContent).toBe('hello') + expect(failed.draftContent).toBe('draft changed') + expect(failed.isDirty).toBe(true) + expect(failed.lastSaveFailed).toBe(true) + }) + + test('input after failed save resets lastSaveFailed', () => { + const initial = createQuickNoteModalState('hello') + const editing = updateDraftContent(initial, 'draft changed') + const failed = markSaveFailed(editing) + expect(failed.lastSaveFailed).toBe(true) + + const editedAgain = updateDraftContent(failed, 'draft changed again') + expect(editedAgain.lastSaveFailed).toBe(false) + expect(editedAgain.isDirty).toBe(true) + }) + + test('save success after a failure clears lastSaveFailed', () => { + const initial = createQuickNoteModalState('hello') + const failed = markSaveFailed(updateDraftContent(initial, 'draft changed')) + const saved = markSaveSucceeded(failed) + + expect(saved.lastSaveFailed).toBe(false) + expect(saved.isDirty).toBe(false) + }) +}) diff --git a/app/components/quick-note-modal-state.ts b/app/components/quick-note-modal-state.ts new file mode 100644 index 0000000..cbaaabe --- /dev/null +++ b/app/components/quick-note-modal-state.ts @@ -0,0 +1,58 @@ +export interface QuickNoteModalState { + savedContent: string + draftContent: string + isDirty: boolean + lastSaveFailed: boolean +} + +export interface ComputeIsDirtyInput { + savedContent: string + draftContent: string +} + +export function computeIsDirty(input: ComputeIsDirtyInput): boolean { + return input.savedContent !== input.draftContent +} + +export function createQuickNoteModalState(savedContent: string): QuickNoteModalState { + return { + savedContent, + draftContent: savedContent, + isDirty: false, + lastSaveFailed: false, + } +} + +export function updateDraftContent(state: QuickNoteModalState, draftContent: string): QuickNoteModalState { + return { + ...state, + draftContent, + isDirty: computeIsDirty({ + savedContent: state.savedContent, + draftContent, + }), + lastSaveFailed: false, + } +} + +export function markSaveSucceeded(state: QuickNoteModalState): QuickNoteModalState { + const nextSavedContent = state.draftContent + return { + ...state, + savedContent: nextSavedContent, + draftContent: nextSavedContent, + isDirty: false, + lastSaveFailed: false, + } +} + +export function markSaveFailed(state: QuickNoteModalState): QuickNoteModalState { + return { + ...state, + isDirty: computeIsDirty({ + savedContent: state.savedContent, + draftContent: state.draftContent, + }), + lastSaveFailed: true, + } +} diff --git a/packages/drizzle-pkg/database/sqlite/schema/content.ts b/packages/drizzle-pkg/database/sqlite/schema/content.ts index 89fd748..563866c 100644 --- a/packages/drizzle-pkg/database/sqlite/schema/content.ts +++ b/packages/drizzle-pkg/database/sqlite/schema/content.ts @@ -129,6 +129,23 @@ export const timelineEvents = sqliteTable("timeline_events", { .notNull(), }); +export const quickNotes = sqliteTable( + "quick_notes", + { + id: integer().primaryKey(), + userId: integer("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + content: text().notNull(), + createdAt: integer("created_at", { mode: "timestamp_ms" }).defaultNow().notNull(), + updatedAt: integer("updated_at", { mode: "timestamp_ms" }) + .defaultNow() + .$onUpdate(() => new Date()) + .notNull(), + }, + (table) => [uniqueIndex("quick_notes_user_id_unique").on(table.userId)], +); + export const postComments = sqliteTable( "post_comments", { diff --git a/packages/drizzle-pkg/lib/schema/content.ts b/packages/drizzle-pkg/lib/schema/content.ts index 8e79676..b066baf 100644 --- a/packages/drizzle-pkg/lib/schema/content.ts +++ b/packages/drizzle-pkg/lib/schema/content.ts @@ -4,6 +4,7 @@ export { postComments, postTags, posts, + quickNotes, tags, timelineEvents, } from "../../database/sqlite/schema/content"; diff --git a/packages/drizzle-pkg/migrations/0012_quick_notes.sql b/packages/drizzle-pkg/migrations/0012_quick_notes.sql new file mode 100644 index 0000000..8b001a0 --- /dev/null +++ b/packages/drizzle-pkg/migrations/0012_quick_notes.sql @@ -0,0 +1,10 @@ +CREATE TABLE `quick_notes` ( + `id` integer PRIMARY KEY NOT NULL, + `user_id` integer NOT NULL, + `content` text NOT NULL, + `created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL, + `updated_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `quick_notes_user_id_unique` ON `quick_notes` (`user_id`); diff --git a/packages/drizzle-pkg/migrations/meta/_journal.json b/packages/drizzle-pkg/migrations/meta/_journal.json index 3771aa1..d2da17d 100644 --- a/packages/drizzle-pkg/migrations/meta/_journal.json +++ b/packages/drizzle-pkg/migrations/meta/_journal.json @@ -85,6 +85,13 @@ "when": 1777110000000, "tag": "0011_post_tags", "breakpoints": true + }, + { + "idx": 12, + "version": "6", + "when": 1777120000000, + "tag": "0012_quick_notes", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/api/me/quick-note.get.ts b/server/api/me/quick-note.get.ts new file mode 100644 index 0000000..1c2d592 --- /dev/null +++ b/server/api/me/quick-note.get.ts @@ -0,0 +1,13 @@ +import { getQuickNoteByUserId } from "#server/service/quick-note"; + +export default defineWrappedResponseHandler(async (event) => { + const user = await event.context.auth.requireUser(); + const quickNote = await getQuickNoteByUserId(user.id); + + return R.success({ + quickNote: { + content: quickNote?.content ?? "", + updatedAt: quickNote?.updatedAt ?? null, + }, + }); +}); diff --git a/server/api/me/quick-note.put.ts b/server/api/me/quick-note.put.ts new file mode 100644 index 0000000..3485566 --- /dev/null +++ b/server/api/me/quick-note.put.ts @@ -0,0 +1,18 @@ +import { upsertQuickNoteByUserId } from "#server/service/quick-note"; + +export default defineWrappedResponseHandler(async (event) => { + const user = await event.context.auth.requireUser(); + const body = await readBody<{ content?: unknown }>(event); + + if (typeof body?.content !== "string") { + throw createError({ statusCode: 400, statusMessage: "content 必须为字符串" }); + } + + const quickNote = await upsertQuickNoteByUserId(user.id, body.content); + return R.success({ + quickNote: { + content: quickNote.content, + updatedAt: quickNote.updatedAt, + }, + }); +}); diff --git a/server/service/quick-note/index.test.ts b/server/service/quick-note/index.test.ts new file mode 100644 index 0000000..caf656e --- /dev/null +++ b/server/service/quick-note/index.test.ts @@ -0,0 +1,172 @@ +import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"; +import { eq, sql } from "drizzle-orm"; + +process.env.DATABASE_URL ??= "file:./db.sqlite"; + +(globalThis as { createError?: (input: unknown) => unknown }).createError = (input: unknown) => { + if (input instanceof Error) { + return input; + } + const payload = (input ?? {}) as { statusMessage?: string }; + const error = new Error(payload.statusMessage ?? "Error") as Error & Record; + Object.assign(error, payload); + return error; +}; + +const { dbGlobal } = await import("drizzle-pkg/database/sqlite/db-bun"); +mock.module("drizzle-pkg/lib/db", () => ({ dbGlobal })); + +const { users } = await import("drizzle-pkg/lib/schema/auth"); +const { quickNotes } = await import("drizzle-pkg/lib/schema/content"); +const { + QUICK_NOTE_MAX_LENGTH, + getQuickNoteByUserId, + isQuickNoteUniqueViolation, + normalizeQuickNoteContent, + upsertQuickNoteByUserId, + validateQuickNoteContentLength, +} = await import("./index"); + +const TEST_USER = { + id: 920001, + username: "quick_note_u1", + password: "pw", +}; + +async function resetRows() { + await dbGlobal.delete(quickNotes).where(eq(quickNotes.userId, TEST_USER.id)); + await dbGlobal.delete(users).where(eq(users.id, TEST_USER.id)); +} + +describe("quick-note service", () => { + beforeAll(async () => { + await dbGlobal.run(sql` + CREATE TABLE IF NOT EXISTS quick_notes ( + id INTEGER PRIMARY KEY NOT NULL, + user_id INTEGER NOT NULL, + content TEXT NOT NULL, + created_at INTEGER DEFAULT (unixepoch() * 1000) NOT NULL, + updated_at INTEGER DEFAULT (unixepoch() * 1000) NOT NULL + ) + `); + await dbGlobal.run(sql` + CREATE UNIQUE INDEX IF NOT EXISTS quick_notes_user_id_unique ON quick_notes (user_id) + `); + await resetRows(); + }); + + beforeEach(async () => { + await resetRows(); + await dbGlobal.insert(users).values(TEST_USER); + }); + + test("exports max length constant", () => { + expect(QUICK_NOTE_MAX_LENGTH).toBe(200000); + }); + + test("normalizeQuickNoteContent converts CRLF to LF", () => { + expect(normalizeQuickNoteContent("a\r\nb\r\n")).toBe("a\nb\n"); + }); + + test("validateQuickNoteContentLength throws 400 when too long", () => { + const tooLong = "a".repeat(QUICK_NOTE_MAX_LENGTH + 1); + expect(() => validateQuickNoteContentLength(tooLong)).toThrow("速记内容过长"); + try { + validateQuickNoteContentLength(tooLong); + } catch (error) { + expect(error).toMatchObject({ statusCode: 400, statusMessage: "速记内容过长" }); + } + }); + + test("validateQuickNoteContentLength allows content at max length", () => { + const maxLengthContent = "a".repeat(QUICK_NOTE_MAX_LENGTH); + expect(() => validateQuickNoteContentLength(maxLengthContent)).not.toThrow(); + }); + + test("isQuickNoteUniqueViolation returns true for quick_notes user unique conflict", () => { + expect( + isQuickNoteUniqueViolation(new Error("UNIQUE constraint failed: quick_notes.user_id")), + ).toBe(true); + }); + + test("isQuickNoteUniqueViolation returns false for non-quick-note conflict", () => { + expect( + isQuickNoteUniqueViolation(new Error("UNIQUE constraint failed: posts.user_id, posts.slug")), + ).toBe(false); + expect(isQuickNoteUniqueViolation(new Error("random error"))).toBe(false); + }); + + test("getQuickNoteByUserId returns null when not found", async () => { + const row = await getQuickNoteByUserId(TEST_USER.id); + expect(row).toBeNull(); + }); + + test("upsertQuickNoteByUserId inserts then updates and normalizes newlines", async () => { + const inserted = await upsertQuickNoteByUserId(TEST_USER.id, "line1\r\nline2"); + expect(inserted.userId).toBe(TEST_USER.id); + expect(inserted.content).toBe("line1\nline2"); + + const updated = await upsertQuickNoteByUserId(TEST_USER.id, "next\r\nvalue"); + expect(updated.id).toBe(inserted.id); + expect(updated.content).toBe("next\nvalue"); + + const fromDb = await getQuickNoteByUserId(TEST_USER.id); + expect(fromDb?.id).toBe(inserted.id); + expect(fromDb?.content).toBe("next\nvalue"); + }); + + test("upsertQuickNoteByUserId supports empty string content", async () => { + const saved = await upsertQuickNoteByUserId(TEST_USER.id, ""); + expect(saved.content).toBe(""); + + const fromDb = await getQuickNoteByUserId(TEST_USER.id); + expect(fromDb).not.toBeNull(); + expect(fromDb?.content).toBe(""); + }); + + test("upsertQuickNoteByUserId normalizes all CRLF to LF", async () => { + const raw = "\r\nline1\r\nline2\r\n"; + const saved = await upsertQuickNoteByUserId(TEST_USER.id, raw); + expect(saved.content).toBe("\nline1\nline2\n"); + + const fromDb = await getQuickNoteByUserId(TEST_USER.id); + expect(fromDb?.content).toBe("\nline1\nline2\n"); + }); + + test("upsertQuickNoteByUserId validates length after normalize", async () => { + const raw = "a\r\n".repeat(100000); + expect(raw.length).toBeGreaterThan(QUICK_NOTE_MAX_LENGTH); + expect(normalizeQuickNoteContent(raw).length).toBe(QUICK_NOTE_MAX_LENGTH); + + const saved = await upsertQuickNoteByUserId(TEST_USER.id, raw); + expect(saved.content.length).toBe(QUICK_NOTE_MAX_LENGTH); + }); + + test("upsertQuickNoteByUserId falls back to update on unique conflict and returns row", async () => { + const originalInsert = dbGlobal.insert.bind(dbGlobal); + let injected = false; + (dbGlobal as { insert: typeof dbGlobal.insert }).insert = ((...args: Parameters) => { + const builder = originalInsert(...args) as { values: (payload: unknown) => Promise } & Record; + return { + ...builder, + values: async (payload: unknown) => { + const result = await builder.values(payload); + if (!injected) { + injected = true; + throw new Error("UNIQUE constraint failed: quick_notes.user_id"); + } + return result; + }, + }; + }) as typeof dbGlobal.insert; + + try { + const saved = await upsertQuickNoteByUserId(TEST_USER.id, "fallback\r\nok"); + expect(saved.userId).toBe(TEST_USER.id); + expect(saved.content).toBe("fallback\nok"); + expect(injected).toBe(true); + } finally { + (dbGlobal as { insert: typeof dbGlobal.insert }).insert = originalInsert as typeof dbGlobal.insert; + } + }); +}); diff --git a/server/service/quick-note/index.ts b/server/service/quick-note/index.ts new file mode 100644 index 0000000..a59a59c --- /dev/null +++ b/server/service/quick-note/index.ts @@ -0,0 +1,74 @@ +import { dbGlobal } from "drizzle-pkg/lib/db"; +import { quickNotes } from "drizzle-pkg/lib/schema/content"; +import { eq } from "drizzle-orm"; +import { nextIntegerId } from "../../utils/sqlite-id"; + +export const QUICK_NOTE_MAX_LENGTH = 200000; + +export function normalizeQuickNoteContent(content: string): string { + return content.replaceAll("\r\n", "\n"); +} + +export function validateQuickNoteContentLength(content: string) { + if (content.length > QUICK_NOTE_MAX_LENGTH) { + throw createError({ statusCode: 400, statusMessage: "速记内容过长" }); + } +} + +export function isQuickNoteUniqueViolation(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error ?? ""); + if (!message.includes("UNIQUE constraint failed")) { + return false; + } + return message.includes("quick_notes.user_id") || message.includes("quick_notes_user_id_unique"); +} + +export async function getQuickNoteByUserId(userId: number) { + const [row] = await dbGlobal + .select() + .from(quickNotes) + .where(eq(quickNotes.userId, userId)) + .limit(1); + return row ?? null; +} + +export async function upsertQuickNoteByUserId(userId: number, content: string) { + const normalizedContent = normalizeQuickNoteContent(content); + validateQuickNoteContentLength(normalizedContent); + + const existing = await getQuickNoteByUserId(userId); + if (!existing) { + try { + const id = await nextIntegerId(quickNotes, quickNotes.id); + await dbGlobal.insert(quickNotes).values({ + id, + userId, + content: normalizedContent, + }); + } catch (error) { + // 处理并发下“先查后插”触发的唯一键冲突:回退为 update。 + if (!isQuickNoteUniqueViolation(error)) { + throw error; + } + await dbGlobal + .update(quickNotes) + .set({ content: normalizedContent }) + .where(eq(quickNotes.userId, userId)); + } + const inserted = await getQuickNoteByUserId(userId); + if (!inserted) { + throw new Error(`速记写入失败:userId=${userId}`); + } + return inserted; + } + + await dbGlobal + .update(quickNotes) + .set({ content: normalizedContent }) + .where(eq(quickNotes.userId, userId)); + const updated = await getQuickNoteByUserId(userId); + if (!updated) { + throw new Error(`速记更新失败:userId=${userId}`); + } + return updated; +}