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) {