From e770bc999554d29f0a7eca367673a9b532f93fe6 Mon Sep 17 00:00:00 2001 From: npmrun <1549469775@qq.com> Date: Thu, 16 Apr 2026 22:52:14 +0800 Subject: [PATCH] feat: implement global and user configuration modules Add new database tables for global and user configurations, along with corresponding migration files. Introduce API endpoints for managing configuration values, including retrieval and updates. Enhance authentication context to support configuration access, and implement error handling for configuration-related operations. --- packages/drizzle-pkg/database/pg/schema/config.ts | 32 +++ packages/drizzle-pkg/lib/schema/config.ts | 1 + .../drizzle-pkg/migrations/0003_config-module.sql | 18 ++ .../drizzle-pkg/migrations/meta/0003_snapshot.json | 278 +++++++++++++++++++++ packages/drizzle-pkg/migrations/meta/_journal.json | 7 + server/api/auth/login.post.ts | 45 +--- server/api/auth/logout.post.ts | 21 +- server/api/auth/me.get.ts | 27 +- server/api/auth/register.post.ts | 32 +-- server/api/config/global.get.ts | 14 ++ server/api/config/global.put.ts | 41 +++ server/api/config/me.get.ts | 20 ++ server/api/config/me.put.ts | 27 ++ server/api/config/me/[key].delete.ts | 20 ++ server/constants/auth.ts | 4 + server/plugins/00.global.ts | 41 ++- server/service/auth/context.ts | 42 ++++ server/service/auth/cookie.ts | 26 ++ server/service/auth/errors.ts | 34 +++ server/service/config/errors.ts | 30 +++ server/service/config/index.ts | 165 ++++++++++++ server/service/config/registry.ts | 122 +++++++++ server/types/config-context.d.ts | 17 ++ server/utils/handler.ts | 14 +- 24 files changed, 972 insertions(+), 106 deletions(-) create mode 100644 packages/drizzle-pkg/database/pg/schema/config.ts create mode 100644 packages/drizzle-pkg/lib/schema/config.ts create mode 100644 packages/drizzle-pkg/migrations/0003_config-module.sql create mode 100644 packages/drizzle-pkg/migrations/meta/0003_snapshot.json create mode 100644 server/api/config/global.get.ts create mode 100644 server/api/config/global.put.ts create mode 100644 server/api/config/me.get.ts create mode 100644 server/api/config/me.put.ts create mode 100644 server/api/config/me/[key].delete.ts create mode 100644 server/constants/auth.ts create mode 100644 server/service/auth/context.ts create mode 100644 server/service/auth/cookie.ts create mode 100644 server/service/auth/errors.ts create mode 100644 server/service/config/errors.ts create mode 100644 server/service/config/index.ts create mode 100644 server/service/config/registry.ts create mode 100644 server/types/config-context.d.ts diff --git a/packages/drizzle-pkg/database/pg/schema/config.ts b/packages/drizzle-pkg/database/pg/schema/config.ts new file mode 100644 index 0000000..71d77fa --- /dev/null +++ b/packages/drizzle-pkg/database/pg/schema/config.ts @@ -0,0 +1,32 @@ +import { sql } from "drizzle-orm"; +import { index, integer, pgTable, primaryKey, timestamp, varchar } from "drizzle-orm/pg-core"; +import { users } from "./auth"; + +export const appConfigs = pgTable("app_configs", { + key: varchar().primaryKey(), + value: varchar().notNull(), + valueType: varchar("value_type").notNull(), + updatedAt: timestamp("updated_at") + .defaultNow() + .$onUpdate(() => sql`CURRENT_TIMESTAMP`) + .notNull(), +}); + +export const userConfigs = pgTable("user_configs", { + userId: integer("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + key: varchar().notNull(), + value: varchar().notNull(), + valueType: varchar("value_type").notNull(), + updatedAt: timestamp("updated_at") + .defaultNow() + .$onUpdate(() => sql`CURRENT_TIMESTAMP`) + .notNull(), +}, (table) => [ + primaryKey({ + name: "user_configs_user_id_key_pk", + columns: [table.userId, table.key], + }), + index("user_configs_user_id_idx").on(table.userId), +]); diff --git a/packages/drizzle-pkg/lib/schema/config.ts b/packages/drizzle-pkg/lib/schema/config.ts new file mode 100644 index 0000000..d7a6a75 --- /dev/null +++ b/packages/drizzle-pkg/lib/schema/config.ts @@ -0,0 +1 @@ +export { appConfigs, userConfigs } from "../../database/pg/schema/config"; diff --git a/packages/drizzle-pkg/migrations/0003_config-module.sql b/packages/drizzle-pkg/migrations/0003_config-module.sql new file mode 100644 index 0000000..22fdaf7 --- /dev/null +++ b/packages/drizzle-pkg/migrations/0003_config-module.sql @@ -0,0 +1,18 @@ +CREATE TABLE "app_configs" ( + "key" varchar PRIMARY KEY NOT NULL, + "value" varchar NOT NULL, + "value_type" varchar NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "user_configs" ( + "user_id" integer NOT NULL, + "key" varchar NOT NULL, + "value" varchar NOT NULL, + "value_type" varchar NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "user_configs_user_id_key_pk" PRIMARY KEY("user_id","key") +); +--> statement-breakpoint +ALTER TABLE "user_configs" ADD CONSTRAINT "user_configs_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "user_configs_user_id_idx" ON "user_configs" USING btree ("user_id"); \ No newline at end of file diff --git a/packages/drizzle-pkg/migrations/meta/0003_snapshot.json b/packages/drizzle-pkg/migrations/meta/0003_snapshot.json new file mode 100644 index 0000000..abc32ac --- /dev/null +++ b/packages/drizzle-pkg/migrations/meta/0003_snapshot.json @@ -0,0 +1,278 @@ +{ + "id": "5ad5cea4-52fb-4e68-813a-f1b125bcba51", + "prevId": "3caf12bf-ffef-4a49-b1ed-9af7f01551f4", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sessions_user_id_idx": { + "name": "sessions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "nickname": { + "name": "nickname", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "avatar": { + "name": "avatar", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_configs": { + "name": "app_configs", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "value_type": { + "name": "value_type", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_configs": { + "name": "user_configs", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "value_type": { + "name": "value_type", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_configs_user_id_idx": { + "name": "user_configs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_configs_user_id_users_id_fk": { + "name": "user_configs_user_id_users_id_fk", + "tableFrom": "user_configs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_configs_user_id_key_pk": { + "name": "user_configs_user_id_key_pk", + "columns": [ + "user_id", + "key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/drizzle-pkg/migrations/meta/_journal.json b/packages/drizzle-pkg/migrations/meta/_journal.json index fb1dc16..60f9a55 100644 --- a/packages/drizzle-pkg/migrations/meta/_journal.json +++ b/packages/drizzle-pkg/migrations/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1776337212081, "tag": "0002_auth-sessions-quality-fixes", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1776348953237, + "tag": "0003_config-module", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/api/auth/login.post.ts b/server/api/auth/login.post.ts index 721a3fc..80dd6bd 100644 --- a/server/api/auth/login.post.ts +++ b/server/api/auth/login.post.ts @@ -1,57 +1,22 @@ -import { AuthFailedError, AuthValidationError, loginUser } from "../../service/auth"; +import { loginUser } from "#server/service/auth"; +import { toPublicAuthError } from "#server/service/auth/errors"; +import { setSessionCookie } from "#server/service/auth/cookie"; type LoginBody = { username: string; password: string; }; -const SESSION_COOKIE_NAME = "pp_session"; -const SESSION_MAX_AGE_SECONDS = 7 * 24 * 60 * 60; - -function hasStatusCode(err: unknown): err is { statusCode: number } { - return typeof err === "object" && err !== null && "statusCode" in err - && typeof (err as { statusCode?: unknown }).statusCode === "number"; -} - -function toPublicError(err: unknown) { - if (hasStatusCode(err)) { - return err; - } - if (err instanceof AuthValidationError) { - return createError({ - statusCode: 400, - statusMessage: err.message, - }); - } - if (err instanceof AuthFailedError) { - return createError({ - statusCode: 401, - statusMessage: err.message, - }); - } - return createError({ - statusCode: 500, - statusMessage: "服务器繁忙,请稍后重试", - }); -} - export default defineWrappedResponseHandler(async (event) => { try { const body = await readBody(event); const result = await loginUser(body); - - setCookie(event, SESSION_COOKIE_NAME, result.sessionId, { - httpOnly: true, - sameSite: "lax", - secure: process.env.NODE_ENV === "production", - path: "/", - maxAge: SESSION_MAX_AGE_SECONDS, - }); + setSessionCookie(event, result.sessionId); return R.success({ user: result.user, }); } catch (err) { - throw toPublicError(err); + throw toPublicAuthError(err); } }); diff --git a/server/api/auth/logout.post.ts b/server/api/auth/logout.post.ts index a459ce5..dfcaa64 100644 --- a/server/api/auth/logout.post.ts +++ b/server/api/auth/logout.post.ts @@ -1,28 +1,19 @@ -import { logoutUser } from "../../service/auth"; - -const SESSION_COOKIE_NAME = "pp_session"; +import { logoutUser } from "#server/service/auth"; +import { getSessionId, clearSessionCookie } from "#server/service/auth/cookie"; +import { toPublicAuthError } from "#server/service/auth/errors"; export default defineWrappedResponseHandler(async (event) => { try { - const sessionId = getCookie(event, SESSION_COOKIE_NAME); + const sessionId = getSessionId(event); if (sessionId) { await logoutUser(sessionId); } - - deleteCookie(event, SESSION_COOKIE_NAME, { - path: "/", - }); + clearSessionCookie(event); return R.success({ success: true, }); } catch (err) { - if (err && typeof err === "object" && "statusCode" in err) { - throw err; - } - throw createError({ - statusCode: 500, - statusMessage: "服务器繁忙,请稍后重试", - }); + throw toPublicAuthError(err); } }); diff --git a/server/api/auth/me.get.ts b/server/api/auth/me.get.ts index d53f7c7..f173e0f 100644 --- a/server/api/auth/me.get.ts +++ b/server/api/auth/me.get.ts @@ -1,26 +1,21 @@ -import { getCurrentUser } from "../../service/auth"; - -const SESSION_COOKIE_NAME = "pp_session"; -const UNAUTHORIZED_MESSAGE = "未登录或会话已失效"; +import { UNAUTHORIZED_MESSAGE } from "#server/constants/auth"; +import { clearSessionCookie, getSessionId } from "#server/service/auth/cookie"; +import { toPublicAuthError } from "#server/service/auth/errors"; export default defineWrappedResponseHandler(async (event) => { try { - const sessionId = getCookie(event, SESSION_COOKIE_NAME); + const sessionId = getSessionId(event); if (!sessionId) { - deleteCookie(event, SESSION_COOKIE_NAME, { - path: "/", - }); + clearSessionCookie(event); throw createError({ statusCode: 401, statusMessage: UNAUTHORIZED_MESSAGE, }); } - const user = await getCurrentUser(sessionId); + const user = await event.context.auth.getCurrent(); if (!user) { - deleteCookie(event, SESSION_COOKIE_NAME, { - path: "/", - }); + clearSessionCookie(event); throw createError({ statusCode: 401, statusMessage: UNAUTHORIZED_MESSAGE, @@ -31,12 +26,6 @@ export default defineWrappedResponseHandler(async (event) => { user, }); } catch (err) { - if (err && typeof err === "object" && "statusCode" in err) { - throw err; - } - throw createError({ - statusCode: 500, - statusMessage: "服务器繁忙,请稍后重试", - }); + throw toPublicAuthError(err); } }); diff --git a/server/api/auth/register.post.ts b/server/api/auth/register.post.ts index b31d6c7..c46e633 100644 --- a/server/api/auth/register.post.ts +++ b/server/api/auth/register.post.ts @@ -1,37 +1,11 @@ -import { AuthConflictError, AuthValidationError, registerUser } from "../../service/auth"; +import { registerUser } from "#server/service/auth"; +import { toPublicAuthError } from "#server/service/auth/errors"; type RegisterBody = { username: string; password: string; }; -function hasStatusCode(err: unknown): err is { statusCode: number } { - return typeof err === "object" && err !== null && "statusCode" in err - && typeof (err as { statusCode?: unknown }).statusCode === "number"; -} - -function toPublicError(err: unknown) { - if (hasStatusCode(err)) { - return err; - } - if (err instanceof AuthValidationError) { - return createError({ - statusCode: 400, - statusMessage: err.message, - }); - } - if (err instanceof AuthConflictError) { - return createError({ - statusCode: 409, - statusMessage: err.message, - }); - } - return createError({ - statusCode: 500, - statusMessage: "服务器繁忙,请稍后重试", - }); -} - export default defineWrappedResponseHandler(async (event) => { try { const body = await readBody(event); @@ -40,6 +14,6 @@ export default defineWrappedResponseHandler(async (event) => { user, }); } catch (err) { - throw toPublicError(err); + throw toPublicAuthError(err); } }); diff --git a/server/api/config/global.get.ts b/server/api/config/global.get.ts new file mode 100644 index 0000000..0db70fd --- /dev/null +++ b/server/api/config/global.get.ts @@ -0,0 +1,14 @@ +import { KNOWN_CONFIG_KEYS } from "#server/service/config/registry"; + +export default defineWrappedResponseHandler(async (event) => { + const entries = await Promise.all( + KNOWN_CONFIG_KEYS.map(async (key) => { + const value = await event.context.config.getGlobal(key); + return [key, value] as const; + }), + ); + + return R.success({ + config: Object.fromEntries(entries), + }); +}); diff --git a/server/api/config/global.put.ts b/server/api/config/global.put.ts new file mode 100644 index 0000000..8e215d3 --- /dev/null +++ b/server/api/config/global.put.ts @@ -0,0 +1,41 @@ +import { + ensureKnownConfigKey, + setGlobalConfigValue, +} from "#server/service/config"; +import { toPublicConfigError } from "#server/service/config/errors"; +import type { H3Event } from "h3"; + +type UpdateGlobalConfigBody = { + key: string; + value: unknown; +}; + +async function assertCanManageGlobalConfig(event: H3Event) { + const user = await event.context.auth.requireUser(); + // 当前版本先采用最小权限策略:仅首个系统用户可写全局配置。 + if (user.id !== 1) { + throw createError({ + statusCode: 403, + statusMessage: "无权限修改全局配置", + data: { + code: "CONFIG_FORBIDDEN", + }, + }); + } +} + +export default defineWrappedResponseHandler(async (event) => { + try { + await assertCanManageGlobalConfig(event); + const body = await readBody(event); + const key = ensureKnownConfigKey(body.key); + await setGlobalConfigValue(key, body.value); + const value = await event.context.config.getGlobal(key); + return R.success({ + key, + value, + }); + } catch (err) { + throw toPublicConfigError(err); + } +}); diff --git a/server/api/config/me.get.ts b/server/api/config/me.get.ts new file mode 100644 index 0000000..afb76c8 --- /dev/null +++ b/server/api/config/me.get.ts @@ -0,0 +1,20 @@ +import { KNOWN_CONFIG_KEYS } from "#server/service/config/registry"; + +export default defineWrappedResponseHandler(async (event) => { + const user = await event.context.auth.requireUser(); + + const entries = await Promise.all( + KNOWN_CONFIG_KEYS.map(async (key) => { + const value = await event.context.config.get(key); + return [key, value] as const; + }), + ); + + return R.success({ + user: { + id: user.id, + username: user.username, + }, + config: Object.fromEntries(entries), + }); +}); diff --git a/server/api/config/me.put.ts b/server/api/config/me.put.ts new file mode 100644 index 0000000..591f1ca --- /dev/null +++ b/server/api/config/me.put.ts @@ -0,0 +1,27 @@ +import { + ensureKnownConfigKey, + setCurrentUserConfigValue, +} from "#server/service/config"; +import { toPublicConfigError } from "#server/service/config/errors"; + +type UpdateMyConfigBody = { + key: string; + value: unknown; +}; + +export default defineWrappedResponseHandler(async (event) => { + try { + const user = await event.context.auth.requireUser(); + + const body = await readBody(event); + const key = ensureKnownConfigKey(body.key); + await setCurrentUserConfigValue(user.id, key, body.value); + const value = await event.context.config.get(key); + return R.success({ + key, + value, + }); + } catch (err) { + throw toPublicConfigError(err); + } +}); diff --git a/server/api/config/me/[key].delete.ts b/server/api/config/me/[key].delete.ts new file mode 100644 index 0000000..5cc68ed --- /dev/null +++ b/server/api/config/me/[key].delete.ts @@ -0,0 +1,20 @@ +import { + ensureKnownConfigKey, + resetCurrentUserConfigValue, +} from "#server/service/config"; +import { toPublicConfigError } from "#server/service/config/errors"; + +export default defineWrappedResponseHandler(async (event) => { + try { + const user = await event.context.auth.requireUser(); + const key = ensureKnownConfigKey(getRouterParam(event, "key") ?? ""); + await resetCurrentUserConfigValue(user.id, key); + const value = await event.context.config.get(key); + return R.success({ + key, + value, + }); + } catch (err) { + throw toPublicConfigError(err); + } +}); diff --git a/server/constants/auth.ts b/server/constants/auth.ts new file mode 100644 index 0000000..ff9f81a --- /dev/null +++ b/server/constants/auth.ts @@ -0,0 +1,4 @@ +export const SESSION_COOKIE_NAME = "pp_session"; +export const UNAUTHORIZED_MESSAGE = "未登录或会话已失效"; +export const SESSION_MAX_AGE_SECONDS = 7 * 24 * 60 * 60; +export const SESSION_COOKIE_PATH = "/"; diff --git a/server/plugins/00.global.ts b/server/plugins/00.global.ts index 220db0d..e9379e3 100644 --- a/server/plugins/00.global.ts +++ b/server/plugins/00.global.ts @@ -1,8 +1,45 @@ +import { createAuthContext } from "../service/auth/context"; +import { getGlobalConfigValue, getMergedConfigValue, getUserConfigValue } from "../service/config"; +import type { KnownConfigKey, KnownConfigValue } from "../service/config/registry"; + if (import.meta.dev) { console.log("plugin: 00.global"); } -export default defineNitroPlugin(async () => { +export default defineNitroPlugin(async (nitroApp) => { + nitroApp.hooks.hook("request", (event) => { + const requestCache = new Map>(); + event.context.auth = createAuthContext(event); -}) + event.context.config = { + getGlobal: async (key: K) => { + const cacheKey = `global:${key}`; + if (!requestCache.has(cacheKey)) { + requestCache.set(cacheKey, getGlobalConfigValue(key)); + } + return requestCache.get(cacheKey) as Promise>; + }, + getUser: async (key: K) => { + const cacheKey = `user:${key}`; + if (!requestCache.has(cacheKey)) { + requestCache.set(cacheKey, (async () => { + const user = await event.context.auth.getCurrent(); + return getUserConfigValue(user?.id, key); + })()); + } + return requestCache.get(cacheKey) as Promise | undefined>; + }, + get: async (key: K) => { + const cacheKey = `merged:${key}`; + if (!requestCache.has(cacheKey)) { + requestCache.set(cacheKey, (async () => { + const user = await event.context.auth.getCurrent(); + return getMergedConfigValue(user?.id, key); + })()); + } + return requestCache.get(cacheKey) as Promise>; + }, + }; + }); +}) diff --git a/server/service/auth/context.ts b/server/service/auth/context.ts new file mode 100644 index 0000000..938d766 --- /dev/null +++ b/server/service/auth/context.ts @@ -0,0 +1,42 @@ +import type { H3Event } from "h3"; +import { SESSION_COOKIE_NAME, UNAUTHORIZED_MESSAGE } from "#server/constants/auth"; +import { getCurrentUser } from "."; + +type MinimalUser = { + id: number; + username: string; +}; + +export function createAuthContext(event: H3Event) { + let currentUserPromise: Promise | undefined; + + const getCurrent = async () => { + if (!currentUserPromise) { + currentUserPromise = (async () => { + const sessionId = getCookie(event, SESSION_COOKIE_NAME); + if (!sessionId) { + return null; + } + return getCurrentUser(sessionId); + })(); + } + return currentUserPromise; + }; + + const requireUser = async () => { + const user = await getCurrent(); + if (!user) { + throw createError({ + statusCode: 401, + statusMessage: UNAUTHORIZED_MESSAGE, + data: { code: "UNAUTHORIZED" }, + }); + } + return user; + }; + + return { + getCurrent, + requireUser, + }; +} diff --git a/server/service/auth/cookie.ts b/server/service/auth/cookie.ts new file mode 100644 index 0000000..b787b41 --- /dev/null +++ b/server/service/auth/cookie.ts @@ -0,0 +1,26 @@ +import type { H3Event } from "h3"; +import { + SESSION_COOKIE_NAME, + SESSION_COOKIE_PATH, + SESSION_MAX_AGE_SECONDS, +} from "#server/constants/auth"; + +export function getSessionId(event: H3Event) { + return getCookie(event, SESSION_COOKIE_NAME); +} + +export function clearSessionCookie(event: H3Event) { + deleteCookie(event, SESSION_COOKIE_NAME, { + path: SESSION_COOKIE_PATH, + }); +} + +export function setSessionCookie(event: H3Event, sessionId: string) { + setCookie(event, SESSION_COOKIE_NAME, sessionId, { + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + path: SESSION_COOKIE_PATH, + maxAge: SESSION_MAX_AGE_SECONDS, + }); +} diff --git a/server/service/auth/errors.ts b/server/service/auth/errors.ts new file mode 100644 index 0000000..638690d --- /dev/null +++ b/server/service/auth/errors.ts @@ -0,0 +1,34 @@ +import { AuthConflictError, AuthFailedError, AuthValidationError } from "."; + +function hasStatusCode(err: unknown): err is { statusCode: number } { + return typeof err === "object" && err !== null && "statusCode" in err + && typeof (err as { statusCode?: unknown }).statusCode === "number"; +} + +export function toPublicAuthError(err: unknown) { + if (hasStatusCode(err)) { + return err; + } + if (err instanceof AuthValidationError) { + return createError({ + statusCode: 400, + statusMessage: err.message, + }); + } + if (err instanceof AuthFailedError) { + return createError({ + statusCode: 401, + statusMessage: err.message, + }); + } + if (err instanceof AuthConflictError) { + return createError({ + statusCode: 409, + statusMessage: err.message, + }); + } + return createError({ + statusCode: 500, + statusMessage: "服务器繁忙,请稍后重试", + }); +} diff --git a/server/service/config/errors.ts b/server/service/config/errors.ts new file mode 100644 index 0000000..20ebf58 --- /dev/null +++ b/server/service/config/errors.ts @@ -0,0 +1,30 @@ +import { ConfigKeyNotFoundError, ConfigScopeInvalidError, ConfigValueInvalidError } from "."; + +function hasStatusCode(err: unknown): err is { statusCode: number } { + return typeof err === "object" && err !== null && "statusCode" in err + && typeof (err as { statusCode?: unknown }).statusCode === "number"; +} + +export function toPublicConfigError(err: unknown) { + if (hasStatusCode(err)) { + return err; + } + if (err instanceof ConfigKeyNotFoundError) { + return createError({ + statusCode: 404, + statusMessage: err.message, + data: { code: err.code }, + }); + } + if (err instanceof ConfigScopeInvalidError || err instanceof ConfigValueInvalidError) { + return createError({ + statusCode: 400, + statusMessage: err.message, + data: { code: err.code }, + }); + } + return createError({ + statusCode: 500, + statusMessage: "服务器繁忙,请稍后重试", + }); +} diff --git a/server/service/config/index.ts b/server/service/config/index.ts new file mode 100644 index 0000000..da77ee8 --- /dev/null +++ b/server/service/config/index.ts @@ -0,0 +1,165 @@ +import { dbGlobal } from "drizzle-pkg/lib/db"; +import { appConfigs, userConfigs } from "drizzle-pkg/lib/schema/config"; +import { and, eq } from "drizzle-orm"; +import { + canUserOverride, + deserializeConfigValue, + getConfigDefaultValue, + getConfigDefinition, + getConfigScope, + isKnownConfigKey, + KnownConfigKey, + KnownConfigValue, + serializeConfigValue, + validateConfigValue, +} from "./registry"; + +export class ConfigKeyNotFoundError extends Error { + code = "CONFIG_KEY_NOT_FOUND"; + constructor(key: string) { + super(`配置项不存在: ${key}`); + this.name = "ConfigKeyNotFoundError"; + } +} + +export class ConfigScopeInvalidError extends Error { + code = "CONFIG_SCOPE_INVALID"; + constructor(message: string) { + super(message); + this.name = "ConfigScopeInvalidError"; + } +} + +export class ConfigValueInvalidError extends Error { + code = "CONFIG_VALUE_INVALID"; + constructor(message: string) { + super(message); + this.name = "ConfigValueInvalidError"; + } +} + +function assertKnownKey(key: string): asserts key is KnownConfigKey { + if (!isKnownConfigKey(key)) { + throw new ConfigKeyNotFoundError(key); + } +} + +export function ensureKnownConfigKey(key: string): KnownConfigKey { + assertKnownKey(key); + return key; +} + +export async function getGlobalConfigValue(key: K): Promise> { + const [row] = await dbGlobal + .select({ + value: appConfigs.value, + }) + .from(appConfigs) + .where(eq(appConfigs.key, key)) + .limit(1); + + if (!row) { + return getConfigDefaultValue(key); + } + return deserializeConfigValue(key, row.value); +} + +export async function getUserConfigValue( + userId: number | undefined, + key: K, +): Promise | undefined> { + if (!userId) { + return undefined; + } + const [row] = await dbGlobal + .select({ + value: userConfigs.value, + }) + .from(userConfigs) + .where(and(eq(userConfigs.userId, userId), eq(userConfigs.key, key))) + .limit(1); + + if (!row) { + return undefined; + } + return deserializeConfigValue(key, row.value); +} + +export async function getMergedConfigValue( + userId: number | undefined, + key: K, +): Promise> { + const scope = getConfigScope(key); + if (scope === "global") { + return getGlobalConfigValue(key); + } + if (scope === "user") { + return (await getUserConfigValue(userId, key)) ?? getConfigDefaultValue(key); + } + const userValue = await getUserConfigValue(userId, key); + if (userValue !== undefined) { + return userValue; + } + return getGlobalConfigValue(key); +} + +export async function setGlobalConfigValue(key: K, value: unknown) { + const scope = getConfigScope(key); + if (scope === "user") { + throw new ConfigScopeInvalidError(`配置项 ${key} 不支持全局写入`); + } + if (!validateConfigValue(key, value)) { + throw new ConfigValueInvalidError(`配置项 ${key} 的值不合法`); + } + + await dbGlobal + .insert(appConfigs) + .values({ + key, + value: serializeConfigValue(key, value), + valueType: getConfigDefinition(key).valueType, + }) + .onConflictDoUpdate({ + target: appConfigs.key, + set: { + value: serializeConfigValue(key, value), + valueType: getConfigDefinition(key).valueType, + }, + }); +} + +export async function setCurrentUserConfigValue(userId: number, key: K, value: unknown) { + const scope = getConfigScope(key); + if (scope === "global" || !canUserOverride(key)) { + throw new ConfigScopeInvalidError(`配置项 ${key} 不允许用户覆盖`); + } + if (!validateConfigValue(key, value)) { + throw new ConfigValueInvalidError(`配置项 ${key} 的值不合法`); + } + + await dbGlobal + .insert(userConfigs) + .values({ + userId, + key, + value: serializeConfigValue(key, value), + valueType: getConfigDefinition(key).valueType, + }) + .onConflictDoUpdate({ + target: [userConfigs.userId, userConfigs.key], + set: { + value: serializeConfigValue(key, value), + valueType: getConfigDefinition(key).valueType, + }, + }); +} + +export async function resetCurrentUserConfigValue(userId: number, key: KnownConfigKey) { + const scope = getConfigScope(key); + if (scope === "global" || !canUserOverride(key)) { + throw new ConfigScopeInvalidError(`配置项 ${key} 不允许用户重置`); + } + await dbGlobal + .delete(userConfigs) + .where(and(eq(userConfigs.userId, userId), eq(userConfigs.key, key))); +} diff --git a/server/service/config/registry.ts b/server/service/config/registry.ts new file mode 100644 index 0000000..dee5535 --- /dev/null +++ b/server/service/config/registry.ts @@ -0,0 +1,122 @@ +type ConfigScope = "global" | "user" | "both"; +type ConfigValueType = "string" | "number" | "boolean" | "json"; +export type SupportedValue = string | number | boolean | Record | unknown[]; + +export type ConfigDefinition = { + key: string; + scope: ConfigScope; + valueType: ConfigValueType; + defaultValue: T; + userOverridable: boolean; + validate?: (value: T) => boolean; +}; + +function defineConfig(config: ConfigDefinition): ConfigDefinition { + return config; +} + +const CONFIG_REGISTRY = { + siteName: defineConfig({ + key: "siteName", + scope: "global", + valueType: "string", + defaultValue: "Person Panel", + userOverridable: false, + validate: (value: string) => value.trim().length > 0 && value.length <= 64, + }), + allowRegister: defineConfig({ + key: "allowRegister", + scope: "global", + valueType: "boolean", + defaultValue: true, + userOverridable: false, + }), + theme: defineConfig({ + key: "theme", + scope: "both", + valueType: "string", + defaultValue: "light", + userOverridable: true, + validate: (value: string) => ["light", "dark", "system"].includes(value), + }), + dashboardLayout: defineConfig({ + key: "dashboardLayout", + scope: "user", + valueType: "json", + defaultValue: [], + userOverridable: true, + }), +} as const; + +export type KnownConfigKey = keyof typeof CONFIG_REGISTRY; +type KnownConfigDefinition = (typeof CONFIG_REGISTRY)[K]; +export type KnownConfigValue = KnownConfigDefinition["defaultValue"]; +export type KnownConfigItem = (typeof CONFIG_REGISTRY)[KnownConfigKey]; +export const KNOWN_CONFIG_KEYS = Object.keys(CONFIG_REGISTRY) as KnownConfigKey[]; + +export function isKnownConfigKey(key: string): key is KnownConfigKey { + return key in CONFIG_REGISTRY; +} + +export function getConfigDefinition(key: K): KnownConfigDefinition { + return CONFIG_REGISTRY[key]; +} + +export function getConfigDefaultValue(key: K): KnownConfigValue { + return CONFIG_REGISTRY[key].defaultValue; +} + +export function getConfigScope(key: K) { + return CONFIG_REGISTRY[key].scope; +} + +export function canUserOverride(key: K) { + return CONFIG_REGISTRY[key].userOverridable; +} + +export function getConfigValueType(key: K) { + return CONFIG_REGISTRY[key].valueType; +} + +function isSupportedValueType(valueType: ConfigValueType, value: unknown) { + if (valueType === "string") return typeof value === "string"; + if (valueType === "number") return typeof value === "number" && Number.isFinite(value); + if (valueType === "boolean") return typeof value === "boolean"; + if (valueType === "json") return value !== undefined; + return false; +} + +export function validateConfigValue(key: K, value: unknown): value is KnownConfigValue { + const definition = getConfigDefinition(key); + if (!isSupportedValueType(definition.valueType, value)) { + return false; + } + const validator = (definition as ConfigDefinition>).validate; + if (!validator) { + return true; + } + return validator(value as KnownConfigValue); +} + +export function serializeConfigValue(key: K, value: KnownConfigValue): string { + const valueType = getConfigValueType(key); + if (valueType === "json") { + return JSON.stringify(value); + } + return String(value); +} + +export function deserializeConfigValue(key: K, rawValue: string): KnownConfigValue { + const valueType = getConfigValueType(key); + switch (valueType) { + case "string": + return rawValue as KnownConfigValue; + case "number": + return Number(rawValue) as unknown as KnownConfigValue; + case "boolean": + return (rawValue === "true") as unknown as KnownConfigValue; + case "json": + default: + return JSON.parse(rawValue) as KnownConfigValue; + } +} diff --git a/server/types/config-context.d.ts b/server/types/config-context.d.ts new file mode 100644 index 0000000..d929eb8 --- /dev/null +++ b/server/types/config-context.d.ts @@ -0,0 +1,17 @@ +import type { KnownConfigKey, KnownConfigValue } from "../service/config/registry"; + +declare module "h3" { + interface H3EventContext { + auth: { + getCurrent: () => Promise<{ id: number; username: string } | null>; + requireUser: () => Promise<{ id: number; username: string }>; + }; + config: { + getGlobal: (key: K) => Promise>; + getUser: (key: K) => Promise | undefined>; + get: (key: K) => Promise>; + }; + } +} + +export {}; diff --git a/server/utils/handler.ts b/server/utils/handler.ts index 02cd641..bac7820 100644 --- a/server/utils/handler.ts +++ b/server/utils/handler.ts @@ -10,6 +10,17 @@ const defaultConfig: IConfig = { const logger = log4js.getLogger("ERROR"); +function assertRequiredContext( + event: { context: Record }, + keys: string[], +) { + for (const key of keys) { + if (!event.context[key]) { + throw new Error(`event.context.${key} is not initialized`); + } + } +} + export const defineWrappedResponseHandler = ( handlerOrConfig?: EventHandler | IConfig, _handler?: EventHandler, @@ -18,10 +29,11 @@ export const defineWrappedResponseHandler = ( if (!handler) { throw new Error('handler or config is required'); } - const config = Object.assign({ ...defaultConfig }, typeof handlerOrConfig === 'object' ? handlerOrConfig : {}); + Object.assign({ ...defaultConfig }, typeof handlerOrConfig === 'object' ? handlerOrConfig : {}); return defineEventHandler(async (event) => { try { + assertRequiredContext(event, ["auth", "config"]); const response = await handler(event) return response } catch (error) {