Browse Source

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.
tags/邮箱功能前置
npmrun 2 months ago
parent
commit
e770bc9995
  1. 32
      packages/drizzle-pkg/database/pg/schema/config.ts
  2. 1
      packages/drizzle-pkg/lib/schema/config.ts
  3. 18
      packages/drizzle-pkg/migrations/0003_config-module.sql
  4. 278
      packages/drizzle-pkg/migrations/meta/0003_snapshot.json
  5. 7
      packages/drizzle-pkg/migrations/meta/_journal.json
  6. 45
      server/api/auth/login.post.ts
  7. 21
      server/api/auth/logout.post.ts
  8. 27
      server/api/auth/me.get.ts
  9. 32
      server/api/auth/register.post.ts
  10. 14
      server/api/config/global.get.ts
  11. 41
      server/api/config/global.put.ts
  12. 20
      server/api/config/me.get.ts
  13. 27
      server/api/config/me.put.ts
  14. 20
      server/api/config/me/[key].delete.ts
  15. 4
      server/constants/auth.ts
  16. 41
      server/plugins/00.global.ts
  17. 42
      server/service/auth/context.ts
  18. 26
      server/service/auth/cookie.ts
  19. 34
      server/service/auth/errors.ts
  20. 30
      server/service/config/errors.ts
  21. 165
      server/service/config/index.ts
  22. 122
      server/service/config/registry.ts
  23. 17
      server/types/config-context.d.ts
  24. 14
      server/utils/handler.ts

32
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),
]);

1
packages/drizzle-pkg/lib/schema/config.ts

@ -0,0 +1 @@
export { appConfigs, userConfigs } from "../../database/pg/schema/config";

18
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");

278
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": {}
}
}

7
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
}
]
}

45
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<LoginBody>(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);
}
});

21
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);
}
});

27
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);
}
});

32
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<RegisterBody>(event);
@ -40,6 +14,6 @@ export default defineWrappedResponseHandler(async (event) => {
user,
});
} catch (err) {
throw toPublicError(err);
throw toPublicAuthError(err);
}
});

14
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),
});
});

41
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<UpdateGlobalConfigBody>(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);
}
});

20
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),
});
});

27
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<UpdateMyConfigBody>(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);
}
});

20
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);
}
});

4
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 = "/";

41
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<string, Promise<unknown>>();
event.context.auth = createAuthContext(event);
})
event.context.config = {
getGlobal: async <K extends KnownConfigKey>(key: K) => {
const cacheKey = `global:${key}`;
if (!requestCache.has(cacheKey)) {
requestCache.set(cacheKey, getGlobalConfigValue(key));
}
return requestCache.get(cacheKey) as Promise<KnownConfigValue<K>>;
},
getUser: async <K extends KnownConfigKey>(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<KnownConfigValue<K> | undefined>;
},
get: async <K extends KnownConfigKey>(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<KnownConfigValue<K>>;
},
};
});
})

42
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<MinimalUser | null> | 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,
};
}

26
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,
});
}

34
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: "服务器繁忙,请稍后重试",
});
}

30
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: "服务器繁忙,请稍后重试",
});
}

165
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<K extends KnownConfigKey>(key: K): Promise<KnownConfigValue<K>> {
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<K extends KnownConfigKey>(
userId: number | undefined,
key: K,
): Promise<KnownConfigValue<K> | 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<K extends KnownConfigKey>(
userId: number | undefined,
key: K,
): Promise<KnownConfigValue<K>> {
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<K extends KnownConfigKey>(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<K extends KnownConfigKey>(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)));
}

122
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<string, unknown> | unknown[];
export type ConfigDefinition<T extends SupportedValue = SupportedValue> = {
key: string;
scope: ConfigScope;
valueType: ConfigValueType;
defaultValue: T;
userOverridable: boolean;
validate?: (value: T) => boolean;
};
function defineConfig<T extends SupportedValue>(config: ConfigDefinition<T>): ConfigDefinition<T> {
return config;
}
const CONFIG_REGISTRY = {
siteName: defineConfig<string>({
key: "siteName",
scope: "global",
valueType: "string",
defaultValue: "Person Panel",
userOverridable: false,
validate: (value: string) => value.trim().length > 0 && value.length <= 64,
}),
allowRegister: defineConfig<boolean>({
key: "allowRegister",
scope: "global",
valueType: "boolean",
defaultValue: true,
userOverridable: false,
}),
theme: defineConfig<string>({
key: "theme",
scope: "both",
valueType: "string",
defaultValue: "light",
userOverridable: true,
validate: (value: string) => ["light", "dark", "system"].includes(value),
}),
dashboardLayout: defineConfig<unknown[]>({
key: "dashboardLayout",
scope: "user",
valueType: "json",
defaultValue: [],
userOverridable: true,
}),
} as const;
export type KnownConfigKey = keyof typeof CONFIG_REGISTRY;
type KnownConfigDefinition<K extends KnownConfigKey> = (typeof CONFIG_REGISTRY)[K];
export type KnownConfigValue<K extends KnownConfigKey> = KnownConfigDefinition<K>["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<K extends KnownConfigKey>(key: K): KnownConfigDefinition<K> {
return CONFIG_REGISTRY[key];
}
export function getConfigDefaultValue<K extends KnownConfigKey>(key: K): KnownConfigValue<K> {
return CONFIG_REGISTRY[key].defaultValue;
}
export function getConfigScope<K extends KnownConfigKey>(key: K) {
return CONFIG_REGISTRY[key].scope;
}
export function canUserOverride<K extends KnownConfigKey>(key: K) {
return CONFIG_REGISTRY[key].userOverridable;
}
export function getConfigValueType<K extends KnownConfigKey>(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<K extends KnownConfigKey>(key: K, value: unknown): value is KnownConfigValue<K> {
const definition = getConfigDefinition(key);
if (!isSupportedValueType(definition.valueType, value)) {
return false;
}
const validator = (definition as ConfigDefinition<KnownConfigValue<K>>).validate;
if (!validator) {
return true;
}
return validator(value as KnownConfigValue<K>);
}
export function serializeConfigValue<K extends KnownConfigKey>(key: K, value: KnownConfigValue<K>): string {
const valueType = getConfigValueType(key);
if (valueType === "json") {
return JSON.stringify(value);
}
return String(value);
}
export function deserializeConfigValue<K extends KnownConfigKey>(key: K, rawValue: string): KnownConfigValue<K> {
const valueType = getConfigValueType(key);
switch (valueType) {
case "string":
return rawValue as KnownConfigValue<K>;
case "number":
return Number(rawValue) as unknown as KnownConfigValue<K>;
case "boolean":
return (rawValue === "true") as unknown as KnownConfigValue<K>;
case "json":
default:
return JSON.parse(rawValue) as KnownConfigValue<K>;
}
}

17
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: <K extends KnownConfigKey>(key: K) => Promise<KnownConfigValue<K>>;
getUser: <K extends KnownConfigKey>(key: K) => Promise<KnownConfigValue<K> | undefined>;
get: <K extends KnownConfigKey>(key: K) => Promise<KnownConfigValue<K>>;
};
}
}
export {};

14
server/utils/handler.ts

@ -10,6 +10,17 @@ const defaultConfig: IConfig = {
const logger = log4js.getLogger("ERROR");
function assertRequiredContext(
event: { context: Record<string, unknown> },
keys: string[],
) {
for (const key of keys) {
if (!event.context[key]) {
throw new Error(`event.context.${key} is not initialized`);
}
}
}
export const defineWrappedResponseHandler = <T extends EventHandlerRequest, D>(
handlerOrConfig?: EventHandler<T, D> | IConfig,
_handler?: EventHandler<T, D>,
@ -18,10 +29,11 @@ export const defineWrappedResponseHandler = <T extends EventHandlerRequest, D>(
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<T>(async (event) => {
try {
assertRequiredContext(event, ["auth", "config"]);
const response = await handler(event)
return response
} catch (error) {

Loading…
Cancel
Save