├── src
├── routes
│ ├── client
│ │ ├── index.ts
│ │ └── users
│ │ │ ├── index.ts
│ │ │ └── users
│ │ │ ├── handlers.ts
│ │ │ ├── index.ts
│ │ │ └── routes.ts
│ ├── public
│ │ ├── index.ts
│ │ └── health
│ │ │ ├── handlers.ts
│ │ │ ├── index.ts
│ │ │ └── routes.ts
│ └── admin
│ │ ├── system
│ │ ├── index.ts
│ │ ├── users
│ │ │ ├── index.ts
│ │ │ ├── schema.ts
│ │ │ ├── routes.ts
│ │ │ └── handlers.ts
│ │ └── roles
│ │ │ ├── index.ts
│ │ │ ├── schema.ts
│ │ │ ├── helpers.ts
│ │ │ └── routes.ts
│ │ ├── index.ts
│ │ ├── resources
│ │ ├── schema.ts
│ │ ├── index.ts
│ │ ├── handlers.ts
│ │ └── routes.ts
│ │ └── auth
│ │ ├── index.ts
│ │ ├── routes.ts
│ │ └── handlers.ts
├── db
│ ├── schema
│ │ ├── admin
│ │ │ ├── auth
│ │ │ │ ├── index.ts
│ │ │ │ └── casbin-rule.ts
│ │ │ └── system
│ │ │ │ ├── index.ts
│ │ │ │ ├── roles.ts
│ │ │ │ ├── users.ts
│ │ │ │ └── user-roles.ts
│ │ ├── client
│ │ │ └── users
│ │ │ │ ├── index.ts
│ │ │ │ └── users.ts
│ │ ├── index.ts
│ │ └── _shard
│ │ │ ├── base-columns.ts
│ │ │ ├── enums.ts
│ │ │ └── types
│ │ │ └── app-user.ts
│ ├── index.ts
│ └── postgres.ts
├── lib
│ ├── enums
│ │ ├── index.ts
│ │ └── common.ts
│ ├── constants
│ │ ├── pagination.ts
│ │ ├── rate-limit.ts
│ │ ├── jwt.ts
│ │ ├── index.ts
│ │ ├── api.ts
│ │ └── cache.ts
│ ├── stoker
│ │ ├── openapi
│ │ │ ├── index.ts
│ │ │ ├── helpers
│ │ │ │ ├── types.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── json-content.ts
│ │ │ │ ├── json-content-required.ts
│ │ │ │ ├── json-content-one-of.ts
│ │ │ │ ├── one-of.ts
│ │ │ │ └── json-content-one-of.test.ts
│ │ │ ├── schemas
│ │ │ │ ├── id-params.ts
│ │ │ │ ├── create-message-object.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── id-uuid-params.ts
│ │ │ │ ├── slug-params.ts
│ │ │ │ ├── get-params-schema.ts
│ │ │ │ ├── create-error-schema.ts
│ │ │ │ └── slug-params.test.ts
│ │ │ ├── default-hook.ts
│ │ │ └── default-hook.test.ts
│ │ ├── middlewares
│ │ │ ├── index.ts
│ │ │ ├── not-found.ts
│ │ │ ├── serve-emoji-favicon.ts
│ │ │ ├── on-error.ts
│ │ │ └── on-error.test.ts
│ │ └── index.ts
│ ├── openapi
│ │ ├── index.ts
│ │ ├── types.ts
│ │ ├── config.ts
│ │ └── helper.ts
│ ├── logger.ts
│ ├── redis.ts
│ ├── casbin
│ │ └── index.ts
│ ├── pg-boss-adapter.ts
│ ├── refine-query
│ │ ├── index.ts
│ │ ├── pagination.ts
│ │ └── schemas.ts
│ ├── create-app.ts
│ ├── cap.ts
│ └── rate-limit-factory.ts
├── utils
│ ├── zod
│ │ ├── index.ts
│ │ ├── response.ts
│ │ └── env-validator.ts
│ ├── tools
│ │ ├── index.ts
│ │ ├── string.ts
│ │ ├── tryit.ts
│ │ ├── object.ts
│ │ └── tryit.test.ts
│ ├── index.ts
│ └── db-errors.ts
├── services
│ ├── admin
│ │ ├── index.ts
│ │ ├── object-storage.ts
│ │ └── token.ts
│ └── ip.ts
├── types
│ ├── global.d.ts
│ └── lib.d.ts
├── middlewares
│ ├── authorize.ts
│ └── operation-log.ts
├── env.ts
└── index.ts
├── .editorconfig
├── migrations
├── meta
│ └── _journal.json
├── 0000_square_dakota_north.sql
└── seed
│ └── index.ts
├── drizzle.config.ts
├── vitest.config.ts
├── .vscode
├── extensions.json
└── settings.json
├── .gitignore
├── .env.example
├── eslint.config.mjs
├── vite.config.ts
├── LICENSE
├── tsconfig.json
├── .dockerignore
├── Dockerfile
├── docker-compose.yml
├── tests
└── auth-utils.ts
├── package.json
├── CLAUDE.md
└── README.md
/src/routes/client/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./users";
2 |
--------------------------------------------------------------------------------
/src/routes/public/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./health";
2 |
--------------------------------------------------------------------------------
/src/routes/client/users/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./users";
2 |
--------------------------------------------------------------------------------
/src/db/schema/admin/auth/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./casbin-rule";
2 |
--------------------------------------------------------------------------------
/src/db/schema/client/users/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./users";
2 |
--------------------------------------------------------------------------------
/src/lib/enums/index.ts:
--------------------------------------------------------------------------------
1 | // 通用枚举
2 | export * from "./common";
3 |
--------------------------------------------------------------------------------
/src/routes/admin/system/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./roles";
2 | export * from "./users";
3 |
--------------------------------------------------------------------------------
/src/utils/zod/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./env-validator";
2 | export * from "./response";
3 |
--------------------------------------------------------------------------------
/src/services/admin/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./object-storage";
2 | export * from "./token";
3 |
--------------------------------------------------------------------------------
/src/utils/tools/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./object";
2 | export * from "./string";
3 | export * from "./tryit";
4 |
--------------------------------------------------------------------------------
/src/routes/admin/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./auth";
2 | export * from "./resources";
3 | export * from "./system";
4 |
--------------------------------------------------------------------------------
/src/db/schema/admin/system/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./roles";
2 | export * from "./user-roles";
3 | export * from "./users";
4 |
--------------------------------------------------------------------------------
/src/lib/constants/pagination.ts:
--------------------------------------------------------------------------------
1 | /** 默认每页大小 */
2 | export const DEFAULT_PAGE_SIZE = 10;
3 |
4 | /** 最大每页大小 */
5 | export const MAX_PAGE_SIZE = 100;
6 |
--------------------------------------------------------------------------------
/src/utils/index.ts:
--------------------------------------------------------------------------------
1 | // #region tools
2 | export * from "./tools";
3 | // #endregion
4 |
5 | // #region zod
6 | export * from "./zod";
7 | // #endregion
8 |
--------------------------------------------------------------------------------
/src/db/schema/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./_shard/enums";
2 |
3 | export * from "./admin/auth";
4 | export * from "./admin/system";
5 | export * from "./client/users";
6 |
--------------------------------------------------------------------------------
/src/lib/constants/rate-limit.ts:
--------------------------------------------------------------------------------
1 | /** 限流时间窗口(毫秒) - 15分钟 */
2 | export const RATE_LIMIT_WINDOW_MS = 15 * 60 * 1000;
3 |
4 | /** 限流最大请求数 */
5 | export const RATE_LIMIT_MAX_REQUESTS = 100;
6 |
--------------------------------------------------------------------------------
/src/lib/constants/jwt.ts:
--------------------------------------------------------------------------------
1 | /** Refresh Token 过期天数 */
2 | export const REFRESH_TOKEN_EXPIRES_DAYS = 7;
3 |
4 | /** Access Token 过期分钟数 */
5 | export const ACCESS_TOKEN_EXPIRES_MINUTES = 15;
6 |
--------------------------------------------------------------------------------
/src/lib/stoker/openapi/index.ts:
--------------------------------------------------------------------------------
1 | export { default as defaultHook } from "./default-hook.js";
2 | export * as helpers from "./helpers/index.js";
3 | export * as schemas from "./schemas/index.js";
4 |
--------------------------------------------------------------------------------
/src/lib/constants/index.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * 统一导出所有配置常量
3 | */
4 |
5 | export * from "./api";
6 | export * from "./cache";
7 | export * from "./jwt";
8 | export * from "./pagination";
9 | export * from "./rate-limit";
10 |
--------------------------------------------------------------------------------
/src/lib/stoker/middlewares/index.ts:
--------------------------------------------------------------------------------
1 | export { default as notFound } from "./not-found.js";
2 | export { default as onError } from "./on-error.js";
3 | export { default as serveEmojiFavicon } from "./serve-emoji-favicon.js";
4 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # editorconfig.org
2 | root = true
3 |
4 | [*]
5 | charset = utf-8
6 | end_of_line = lf
7 | indent_size = 2
8 | indent_style = space
9 | insert_final_newline = true
10 | trim_trailing_whitespace = true
11 |
--------------------------------------------------------------------------------
/src/types/global.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
3 | export {};
4 |
5 | declare global {
6 | // eslint-disable-next-line ts/consistent-type-definitions
7 | interface ParamsType {
8 | [key: string]: T;
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/src/lib/stoker/openapi/helpers/types.ts:
--------------------------------------------------------------------------------
1 | import type { z } from "@hono/zod-openapi";
2 |
3 | // eslint-disable-next-line ts/ban-ts-comment
4 | // @ts-expect-error
5 | export type ZodSchema = z.ZodUnion | z.AnyZodObject | z.ZodArray;
6 | export type ZodIssue = z.ZodIssue;
7 |
--------------------------------------------------------------------------------
/src/utils/tools/string.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * 去除字符串前缀
3 | * @param path 字符串
4 | * @param prefix 前缀
5 | * @returns 去除前缀后的字符串
6 | */
7 | export function stripPrefix(path: string, prefix: string): string {
8 | return path.startsWith(prefix) ? path.slice(prefix.length) : path;
9 | }
10 |
--------------------------------------------------------------------------------
/migrations/meta/_journal.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "7",
3 | "dialect": "postgresql",
4 | "entries": [
5 | {
6 | "idx": 0,
7 | "version": "7",
8 | "when": 1763958666759,
9 | "tag": "0000_square_dakota_north",
10 | "breakpoints": true
11 | }
12 | ]
13 | }
--------------------------------------------------------------------------------
/src/lib/constants/api.ts:
--------------------------------------------------------------------------------
1 | /** API 基础路径 */
2 | export const API_BASE_PATH = "/api";
3 |
4 | /** API 管理端路径 */
5 | export const API_ADMIN_PATH = "/api/admin";
6 |
7 | /** 文档端点路径 */
8 | export const DOC_ENDPOINT = "/doc";
9 |
10 | /** 公共API名称 */
11 | export const API_PUBLIC_NAME = "public";
12 |
--------------------------------------------------------------------------------
/src/lib/stoker/openapi/helpers/index.ts:
--------------------------------------------------------------------------------
1 | export { default as jsonContentOneOf } from "./json-content-one-of.js";
2 | export { default as jsonContentRequired } from "./json-content-required.js";
3 | export { default as jsonContent } from "./json-content.js";
4 | export { default as oneOf } from "./one-of.js";
5 |
--------------------------------------------------------------------------------
/src/lib/constants/cache.ts:
--------------------------------------------------------------------------------
1 | /** 默认租户 ID */
2 | export const DEFAULT_TENANT_ID = "default";
3 |
4 | /** 标准缓存过期时间(秒) - 1小时 */
5 | export const CACHE_TTL = 3600;
6 |
7 | /** 空值缓存过期时间(秒) - 5分钟,用于防止缓存穿透 */
8 | export const NULL_CACHE_TTL = 300;
9 |
10 | /** 空值缓存标记,用于区分真正的空值和缓存未命中 */
11 | export const NULL_CACHE_VALUE = "__NULL__";
12 |
--------------------------------------------------------------------------------
/drizzle.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "drizzle-kit";
2 |
3 | import env from "@/env";
4 |
5 | export default defineConfig({
6 | schema: "./src/db/schema",
7 | out: "./migrations",
8 | dialect: "postgresql",
9 | casing: "snake_case",
10 | dbCredentials: {
11 | url: env.DATABASE_URL!,
12 | },
13 | verbose: true,
14 | strict: true,
15 | });
16 |
--------------------------------------------------------------------------------
/src/lib/stoker/openapi/schemas/id-params.ts:
--------------------------------------------------------------------------------
1 | import { z } from "@hono/zod-openapi";
2 |
3 | const IdParamsSchema = z.object({
4 | id: z.coerce.number().openapi({
5 | param: {
6 | name: "id",
7 | in: "path",
8 | required: true,
9 | },
10 | required: ["id"],
11 | example: 42,
12 | }),
13 | });
14 |
15 | export default IdParamsSchema;
16 |
--------------------------------------------------------------------------------
/src/lib/stoker/openapi/schemas/create-message-object.ts:
--------------------------------------------------------------------------------
1 | import { z } from "@hono/zod-openapi";
2 |
3 | const createMessageObjectSchema = (exampleMessage: string = "Hello World") => {
4 | return z.object({
5 | message: z.string(),
6 | }).openapi({
7 | example: {
8 | message: exampleMessage,
9 | },
10 | });
11 | };
12 |
13 | export default createMessageObjectSchema;
14 |
--------------------------------------------------------------------------------
/src/lib/stoker/openapi/schemas/index.ts:
--------------------------------------------------------------------------------
1 | export { default as createErrorSchema } from "./create-error-schema.js";
2 | export { default as createMessageObjectSchema } from "./create-message-object.js";
3 | export { default as IdParamsSchema } from "./id-params.js";
4 | export { default as IdUUIDParamsSchema } from "./id-uuid-params.js";
5 | export { default as SlugParamsSchema } from "./slug-params.js";
6 |
--------------------------------------------------------------------------------
/src/lib/stoker/openapi/helpers/json-content.ts:
--------------------------------------------------------------------------------
1 | import type { ZodSchema } from "./types.ts";
2 |
3 | const jsonContent = <
4 | T extends ZodSchema,
5 | >(schema: T,
6 | description: string,
7 | ) => {
8 | return {
9 | content: {
10 | "application/json": {
11 | schema,
12 | },
13 | },
14 | description,
15 | };
16 | };
17 |
18 | export default jsonContent;
19 |
--------------------------------------------------------------------------------
/vitest.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig, mergeConfig } from "vitest/config";
2 |
3 | import viteConfig from "./vite.config";
4 |
5 | export default mergeConfig(
6 | viteConfig({ mode: "test", command: "serve" }),
7 | defineConfig({
8 | test: {
9 | setupFiles: [],
10 | fileParallelism: false,
11 | isolate: false,
12 | pool: "threads",
13 | },
14 | }),
15 | );
16 |
--------------------------------------------------------------------------------
/src/lib/openapi/index.ts:
--------------------------------------------------------------------------------
1 | import env from "@/env";
2 |
3 | import { createApps, createMainDocConfigurator } from "./helper";
4 |
5 | export default function configureOpenAPI() {
6 | const isNotProd = env.NODE_ENV !== "production";
7 | const apps = createApps();
8 |
9 | const configureMainDoc = isNotProd ? createMainDocConfigurator(apps) : null;
10 | return { ...apps, configureMainDoc };
11 | }
12 |
--------------------------------------------------------------------------------
/src/lib/stoker/openapi/schemas/id-uuid-params.ts:
--------------------------------------------------------------------------------
1 | import { z } from "@hono/zod-openapi";
2 |
3 | const IdUUIDParamsSchema = z.object({
4 | id: z.uuid().openapi({
5 | param: {
6 | name: "id",
7 | in: "path",
8 | required: true,
9 | },
10 | required: ["id"],
11 | example: "4651e634-a530-4484-9b09-9616a28f35e3",
12 | }),
13 | });
14 |
15 | export default IdUUIDParamsSchema;
16 |
--------------------------------------------------------------------------------
/src/lib/stoker/openapi/helpers/json-content-required.ts:
--------------------------------------------------------------------------------
1 | import type { ZodSchema } from "./types.ts";
2 |
3 | import jsonContent from "./json-content.js";
4 |
5 | const jsonContentRequired = <
6 | T extends ZodSchema,
7 | >(schema: T,
8 | description: string,
9 | ) => {
10 | return {
11 | ...jsonContent(schema, description),
12 | required: true,
13 | };
14 | };
15 |
16 | export default jsonContentRequired;
17 |
--------------------------------------------------------------------------------
/src/db/index.ts:
--------------------------------------------------------------------------------
1 | import { drizzle } from "drizzle-orm/postgres-js";
2 |
3 | import { getQueryClient } from "./postgres";
4 | import * as schema from "./schema";
5 |
6 | const db = drizzle({
7 | client: getQueryClient(),
8 | // schema同时用于提供类型
9 | schema,
10 | // 自动在数据库使用 snake_case 命名风格
11 | casing: "snake_case",
12 | // 开发环境数据库日志
13 | logger: false, // 或者 env.NODE_ENV !== "production"
14 | });
15 |
16 | export default db;
17 |
--------------------------------------------------------------------------------
/src/lib/stoker/middlewares/not-found.ts:
--------------------------------------------------------------------------------
1 | import type { NotFoundHandler } from "hono";
2 |
3 | import { Resp } from "@/utils";
4 |
5 | import { NOT_FOUND } from "../http-status-codes";
6 | import { NOT_FOUND as NOT_FOUND_MESSAGE } from "../http-status-phrases";
7 |
8 | const notFound: NotFoundHandler = (c) => {
9 | return c.json(Resp.fail(`${NOT_FOUND_MESSAGE} - ${c.req.path}`), NOT_FOUND);
10 | };
11 |
12 | export default notFound;
13 |
--------------------------------------------------------------------------------
/src/routes/public/health/handlers.ts:
--------------------------------------------------------------------------------
1 | import { format } from "date-fns";
2 |
3 | import * as HttpStatusCodes from "@/lib/stoker/http-status-codes";
4 | import { Resp } from "@/utils";
5 |
6 | import type { HealthRouteHandlerType } from ".";
7 |
8 | export const get: HealthRouteHandlerType<"get"> = async (c) => {
9 | return c.json(Resp.ok({
10 | status: "ok",
11 | timestamp: format(new Date(), "yyyy-MM-dd HH:mm:ss"),
12 | }), HttpStatusCodes.OK);
13 | };
14 |
--------------------------------------------------------------------------------
/src/db/postgres.ts:
--------------------------------------------------------------------------------
1 | import type { Sql } from "postgres";
2 |
3 | import postgres from "postgres";
4 |
5 | import env from "@/env";
6 |
7 | let queryClient: Sql | null = null;
8 |
9 | export function getQueryClient() {
10 | if (!queryClient) {
11 | queryClient = postgres(env.DATABASE_URL, {
12 | max: 10,
13 | idle_timeout: 10,
14 | connect_timeout: 30,
15 | transform: { undefined: null },
16 | });
17 | }
18 | return queryClient;
19 | }
20 |
--------------------------------------------------------------------------------
/src/lib/stoker/openapi/helpers/json-content-one-of.ts:
--------------------------------------------------------------------------------
1 | import type { ZodSchema } from "./types.ts";
2 |
3 | import oneOf from "./one-of.js";
4 |
5 | const jsonContentOneOf = <
6 | T extends ZodSchema,
7 | >(schemas: T[],
8 | description: string,
9 | ) => {
10 | return {
11 | content: {
12 | "application/json": {
13 | schema: {
14 | oneOf: oneOf(schemas),
15 | },
16 | },
17 | },
18 | description,
19 | };
20 | };
21 |
22 | export default jsonContentOneOf;
23 |
--------------------------------------------------------------------------------
/src/routes/public/health/index.ts:
--------------------------------------------------------------------------------
1 | import type { AppRouteHandler } from "@/types/lib";
2 |
3 | import { createRouter } from "@/lib/create-app";
4 |
5 | import * as handlers from "./handlers";
6 | import * as routes from "./routes";
7 |
8 | export const health = createRouter()
9 | .openapi(routes.get, handlers.get);
10 |
11 | type RouteTypes = {
12 | [K in keyof typeof routes]: typeof routes[K];
13 | };
14 |
15 | export type HealthRouteHandlerType = AppRouteHandler;
16 |
--------------------------------------------------------------------------------
/src/routes/client/users/users/handlers.ts:
--------------------------------------------------------------------------------
1 | import * as HttpStatusCodes from "@/lib/stoker/http-status-codes";
2 | import { Resp } from "@/utils/zod";
3 |
4 | import type { ClientUsersRouteHandlerType } from ".";
5 |
6 | export const getUsersInfo: ClientUsersRouteHandlerType<"getUsersInfo"> = (c) => {
7 | // Handler logic to get user information
8 | // This is a placeholder; actual implementation will depend on your application's logic
9 | return c.json(Resp.ok({ message: "User information retrieved successfully" }), HttpStatusCodes.OK);
10 | };
11 |
--------------------------------------------------------------------------------
/src/routes/client/users/users/index.ts:
--------------------------------------------------------------------------------
1 | import type { AppRouteHandler } from "@/types/lib";
2 |
3 | import { createRouter } from "@/lib/create-app";
4 |
5 | import * as handlers from "./handlers";
6 | import * as routes from "./routes";
7 |
8 | export const clientUsersRouter = createRouter()
9 | .openapi(routes.getUsersInfo, handlers.getUsersInfo);
10 |
11 | type RouteTypes = {
12 | [K in keyof typeof routes]: typeof routes[K];
13 | };
14 |
15 | export type ClientUsersRouteHandlerType = AppRouteHandler;
16 |
--------------------------------------------------------------------------------
/src/lib/logger.ts:
--------------------------------------------------------------------------------
1 | import type { DestinationStream } from "pino";
2 |
3 | import pino from "pino";
4 |
5 | import env from "@/env";
6 |
7 | let options: DestinationStream | undefined;
8 |
9 | if (env.NODE_ENV === "development") {
10 | try {
11 | const pretty = await import("pino-pretty");
12 | options = pretty.default({
13 | colorize: true,
14 | });
15 | }
16 | catch {
17 | // pino-pretty 未安装,使用默认日志格式
18 | }
19 | }
20 |
21 | const logger = pino({ level: env.LOG_LEVEL || "info" }, options);
22 |
23 | export default logger;
24 |
--------------------------------------------------------------------------------
/src/lib/stoker/middlewares/serve-emoji-favicon.ts:
--------------------------------------------------------------------------------
1 | import type { MiddlewareHandler } from "hono";
2 |
3 | const serveEmojiFavicon = (emoji: string): MiddlewareHandler => {
4 | return async (c, next) => {
5 | if (c.req.path === "/favicon.ico") {
6 | c.res.headers.set("content-type", "image/svg+xml");
7 | return c.body(``);
8 | }
9 | return next();
10 | };
11 | };
12 |
13 | export default serveEmojiFavicon;
14 |
--------------------------------------------------------------------------------
/src/lib/stoker/openapi/default-hook.ts:
--------------------------------------------------------------------------------
1 | import type { Hook } from "@hono/zod-openapi";
2 |
3 | import { UNPROCESSABLE_ENTITY } from "../http-status-codes.js";
4 |
5 | const defaultHook: Hook = (result, c) => {
6 | if (!result.success) {
7 | return c.json(
8 | {
9 | success: result.success,
10 | error: {
11 | name: result.error.name,
12 | issues: result.error.issues,
13 | },
14 | },
15 | UNPROCESSABLE_ENTITY,
16 | );
17 | }
18 | };
19 |
20 | export default defaultHook;
21 |
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": [
3 | // 将 ESLint JavaScript 集成到 VS Code 中。
4 | "dbaeumer.vscode-eslint",
5 | // 支持 dotenv 文件语法
6 | "mikestead.dotenv",
7 | // 源代码的拼写检查器
8 | "streetsidesoftware.code-spell-checker",
9 | // i18n 插件
10 | "Lokalise.i18n-ally",
11 | // 在 package.json 中显示 PNPM catalog 的版本
12 | "antfu.pnpm-catalog-lens",
13 | // editorconfig 插件
14 | "editorconfig.editorconfig",
15 | // vitest 插件
16 | "vitest.explorer"
17 | ],
18 | "unwantedRecommendations": [
19 | // 和...冲突
20 | ]
21 | }
22 |
--------------------------------------------------------------------------------
/src/routes/admin/resources/schema.ts:
--------------------------------------------------------------------------------
1 | import { z } from "@hono/zod-openapi";
2 |
3 | export const UploadTokenRequestSchema = z.object({
4 | fileName: z.string().meta({ description: "文件名" }),
5 | fileType: z.string().optional().meta({ description: "文件类型" }),
6 | }).strict();
7 |
8 | export const DownloadTokenRequestSchema = z.object({
9 | fileName: z.string().meta({ description: "文件名" }),
10 | }).strict();
11 |
12 | export const TokenResponseSchema = z.object({
13 | url: z.string().meta({ description: "预签名 URL" }),
14 | expiresAt: z.string().meta({ description: "过期时间" }),
15 | });
16 |
--------------------------------------------------------------------------------
/src/lib/redis.ts:
--------------------------------------------------------------------------------
1 | import Redis from "ioredis";
2 | import { parseURL } from "ioredis/built/utils/index.js";
3 |
4 | import env from "@/env";
5 |
6 | export const redisConnectionOptions = parseURL(env.REDIS_URL);
7 |
8 | // 确保 port 是数字类型
9 | if (redisConnectionOptions.port && typeof redisConnectionOptions.port === "string") {
10 | redisConnectionOptions.port = Number.parseInt(redisConnectionOptions.port, 10);
11 | }
12 |
13 | // 主 Redis 客户端(业务使用)
14 | const redisClient = new Redis({
15 | ...redisConnectionOptions,
16 | maxRetriesPerRequest: null,
17 | });
18 |
19 | export default redisClient;
20 |
--------------------------------------------------------------------------------
/src/routes/admin/resources/index.ts:
--------------------------------------------------------------------------------
1 | import type { AppRouteHandler } from "@/types/lib";
2 |
3 | import { createRouter } from "@/lib/create-app";
4 |
5 | import * as handlers from "./handlers";
6 | import * as routes from "./routes";
7 |
8 | export const objectStorage = createRouter()
9 | .openapi(routes.getUploadToken, handlers.getUploadToken)
10 | .openapi(routes.getDownloadToken, handlers.getDownloadToken);
11 |
12 | type RouteTypes = {
13 | [K in keyof typeof routes]: typeof routes[K];
14 | };
15 |
16 | export type ObjectStorageRouteHandlerType = AppRouteHandler;
17 |
--------------------------------------------------------------------------------
/src/lib/openapi/types.ts:
--------------------------------------------------------------------------------
1 | /** 应用OpenAPI配置 */
2 | export type AppConfig = {
3 | /** 应用名称 */
4 | name: string;
5 | /** 应用标题 */
6 | title: string;
7 | /** 应用令牌 */
8 | token?: string;
9 | /** 显式前缀 */
10 | basePath?: string;
11 | };
12 |
13 | /** Scalar源配置 */
14 | export type ScalarSource = {
15 | /** 应用标题 */
16 | title: string;
17 | /** 应用slug */
18 | slug: string;
19 | /** 应用URL */
20 | url: string;
21 | /** 是否默认 */
22 | default: boolean;
23 | };
24 |
25 | /** Scalar认证配置 */
26 | export type ScalarAuthentication = {
27 | /** 应用安全方案 */
28 | securitySchemes: Record;
29 | };
30 |
--------------------------------------------------------------------------------
/src/lib/stoker/openapi/schemas/slug-params.ts:
--------------------------------------------------------------------------------
1 | import { z } from "@hono/zod-openapi";
2 |
3 | // Regular expression to validate slug format: alphanumeric, underscores, and dashes
4 | const slugReg = /^[\w-]+$/;
5 | const SLUG_ERROR_MESSAGE = "Slug can only contain letters, numbers, dashes, and underscores";
6 |
7 | const SlugParamsSchema = z.object({
8 | slug: z.string()
9 | .regex(slugReg, SLUG_ERROR_MESSAGE)
10 | .openapi({
11 | param: {
12 | name: "slug",
13 | in: "path",
14 | required: true,
15 | },
16 | required: ["slug"],
17 | example: "my-cool-article",
18 | }),
19 | });
20 |
21 | export default SlugParamsSchema;
22 |
--------------------------------------------------------------------------------
/src/routes/client/users/users/routes.ts:
--------------------------------------------------------------------------------
1 | import { createRoute, z } from "@hono/zod-openapi";
2 |
3 | import { RefineResultSchema } from "@/lib/refine-query";
4 | import * as HttpStatusCodes from "@/lib/stoker/http-status-codes";
5 | import { jsonContent } from "@/lib/stoker/openapi/helpers";
6 |
7 | const tags = ["/client-users (用户端用户)"];
8 |
9 | export const getUsersInfo = createRoute({
10 | path: "/client-users/info",
11 | method: "get",
12 | tags,
13 | summary: "获取用户信息",
14 | responses: {
15 | [HttpStatusCodes.OK]: jsonContent(
16 | RefineResultSchema(z.object({
17 | message: z.string().optional(),
18 | })),
19 | "列表响应成功",
20 | ),
21 | },
22 | });
23 |
--------------------------------------------------------------------------------
/src/lib/stoker/openapi/helpers/one-of.ts:
--------------------------------------------------------------------------------
1 | import {
2 | OpenApiGeneratorV3,
3 | OpenAPIRegistry,
4 | } from "@asteasolutions/zod-to-openapi";
5 |
6 | import type { ZodSchema } from "./types.ts";
7 |
8 | const oneOf = <
9 | T extends ZodSchema,
10 | >(schemas: T[]) => {
11 | const registry = new OpenAPIRegistry();
12 |
13 | schemas.forEach((schema, index) => {
14 | registry.register(index.toString(), schema);
15 | });
16 |
17 | const generator = new OpenApiGeneratorV3(registry.definitions);
18 | const components = generator.generateComponents();
19 |
20 | return components.components?.schemas ? Object.values(components.components!.schemas!) : [];
21 | };
22 |
23 | export default oneOf;
24 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # dev
2 | .yarn/
3 | !.yarn/releases
4 | .vscode/*
5 | !.vscode/launch.json
6 | !.vscode/*.code-snippets
7 | !.vscode/extensions.json
8 | !.vscode/settings.json
9 | .idea/workspace.xml
10 | .idea/usage.statistics.xml
11 | .idea/shelf
12 |
13 | # deps
14 | node_modules/
15 |
16 | # env
17 | .env
18 | .env.production
19 | .env.test
20 |
21 | # logs
22 | logs/
23 | *.log
24 | npm-debug.log*
25 | yarn-debug.log*
26 | yarn-error.log*
27 | pnpm-debug.log*
28 | lerna-debug.log*
29 |
30 | # misc
31 | .DS_Store
32 |
33 | dev.db
34 | test.db
35 | .vercel
36 | dist
37 |
38 | .dev.vars
39 |
40 | *.local.md
41 |
42 | *-example
43 |
44 | # Serena cache
45 | .serena/
46 |
47 | # Claude Settings
48 | .claude/
49 |
50 | # local *
51 | *.local.*
52 |
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | NODE_ENV=development
2 | PORT=9999
3 | LOG_LEVEL=debug
4 |
5 | # Postgres configuration 如果是 本地开发 需要把 postgres 改成 localhost
6 | DATABASE_URL="postgresql://postgres:postgres@postgres:5432/postgres"
7 |
8 | # Redis configuration 如果是 本地开发 需要把 redis 改成 localhost
9 | REDIS_URL="redis://redis:6379/0"
10 |
11 | # jwt secret
12 | CLIENT_JWT_SECRET="终端执行: openssl rand -base64 32 生成一个出来"
13 | ADMIN_JWT_SECRET="终端执行: openssl rand -base64 32 生成一个出来"
14 |
15 | # cloudflare r2
16 | ACCESS_KEY_ID="you access key id"
17 | SECRET_ACCESS_KEY="you secret access key"
18 | ENDPOINT="https://xxxxx.r2.cloudflarestorage.com"
19 | BUCKET_NAME="xxxxx"
20 |
21 | # sentry,不想要可以不用管这个环境变量,自托管 sentry 参考官网示例
22 | SENTRY_DSN="https://xxxxxx@xxx.ingest.sentry.io/xxxxxx"
23 |
--------------------------------------------------------------------------------
/src/routes/admin/system/users/index.ts:
--------------------------------------------------------------------------------
1 | import type { AppRouteHandler } from "@/types/lib";
2 |
3 | import { createRouter } from "@/lib/create-app";
4 |
5 | import * as handlers from "./handlers";
6 | import * as routes from "./routes";
7 |
8 | export const systemUsersRouter = createRouter()
9 | .openapi(routes.get, handlers.get)
10 | .openapi(routes.update, handlers.update)
11 | .openapi(routes.remove, handlers.remove)
12 | .openapi(routes.create, handlers.create)
13 | .openapi(routes.list, handlers.list)
14 | .openapi(routes.saveRoles, handlers.saveRoles);
15 |
16 | type RouteTypes = {
17 | [K in keyof typeof routes]: typeof routes[K];
18 | };
19 |
20 | export type SystemUsersRouteHandlerType = AppRouteHandler;
21 |
--------------------------------------------------------------------------------
/src/db/schema/admin/system/roles.ts:
--------------------------------------------------------------------------------
1 | import { relations } from "drizzle-orm";
2 | import { pgTable, text, varchar } from "drizzle-orm/pg-core";
3 |
4 | import { baseColumns } from "@/db/schema/_shard/base-columns";
5 | import { Status } from "@/lib/enums";
6 |
7 | import { statusEnum } from "../../_shard/enums";
8 | import { systemUserRoles } from "./user-roles";
9 |
10 | export const systemRoles = pgTable("system_roles", {
11 | ...baseColumns,
12 | id: varchar({ length: 64 }).notNull().primaryKey(),
13 | name: varchar({ length: 64 }).notNull(),
14 | description: text(),
15 | status: statusEnum().default(Status.ENABLED).notNull(),
16 | });
17 |
18 | export const systemRolesRelations = relations(systemRoles, ({ many }) => ({
19 | systemUserRoles: many(systemUserRoles),
20 | }));
21 |
--------------------------------------------------------------------------------
/src/lib/openapi/config.ts:
--------------------------------------------------------------------------------
1 | import type { AppConfig } from "./types";
2 |
3 | /** 应用OpenAPI配置 */
4 | export const APP_CONFIG: AppConfig[] = [
5 | {
6 | name: "admin",
7 | title: "管理端API文档",
8 | token: "your-admin-token",
9 | },
10 | {
11 | name: "client",
12 | title: "客户端API文档",
13 | token: "your-client-token",
14 | },
15 | {
16 | name: "public",
17 | title: "公共API文档",
18 | },
19 | ];
20 |
21 | /** 应用OpenAPI版本 */
22 | export const OPENAPI_VERSION = "3.1.0";
23 |
24 | /** Scalar配置 */
25 | export const SCALAR_CONFIG = {
26 | /** Scalar主题 */
27 | theme: "kepler",
28 | /** Scalar布局 */
29 | layout: "modern",
30 | /** Scalar默认HTTP客户端 */
31 | defaultHttpClient: { targetKey: "js", clientKey: "fetch" },
32 | } as const;
33 |
34 | // 重新导出路径配置
35 | export * from "@/lib/constants/api";
36 |
--------------------------------------------------------------------------------
/src/types/lib.d.ts:
--------------------------------------------------------------------------------
1 | import type { RouteConfig as HonoRouteConfig, OpenAPIHono, RouteHandler } from "@hono/zod-openapi";
2 | import type { Schema } from "hono";
3 | import type { PinoLogger } from "hono-pino";
4 | import type { JWTPayload } from "hono/utils/jwt/types";
5 |
6 | export type AppBindings = {
7 | Variables: {
8 | /** 日志记录器 */
9 | logger: PinoLogger;
10 | /** 请求 ID */
11 | requestId: string;
12 | /** JWT 负载 */
13 | jwtPayload: JWTPayload & {
14 | /** 用户角色 */
15 | roles: string[];
16 | /** 用户 ID */
17 | sub: string;
18 | };
19 | };
20 | };
21 |
22 | // eslint-disable-next-line ts/no-empty-object-type
23 | export type AppOpenAPI = OpenAPIHono;
24 |
25 | export type AppRouteHandler = RouteHandler;
26 |
--------------------------------------------------------------------------------
/src/lib/stoker/middlewares/on-error.ts:
--------------------------------------------------------------------------------
1 | import type { ErrorHandler } from "hono";
2 | import type { ContentfulStatusCode } from "hono/utils/http-status";
3 |
4 | import ProcessEnv from "@/env";
5 | import { Resp } from "@/utils";
6 |
7 | import { INTERNAL_SERVER_ERROR, OK } from "../http-status-codes";
8 |
9 | const onError: ErrorHandler = (err, c) => {
10 | const currentStatus = "status" in err
11 | ? err.status
12 | : c.newResponse(null).status;
13 | const statusCode = currentStatus !== OK
14 | ? (currentStatus as ContentfulStatusCode)
15 | : INTERNAL_SERVER_ERROR;
16 |
17 | const env = c.env?.NODE_ENV || ProcessEnv?.NODE_ENV;
18 | return c.json(Resp.fail(err.message, {
19 | stack: env === "production"
20 | ? undefined
21 | : err.stack,
22 | }), statusCode);
23 | };
24 |
25 | export default onError;
26 |
--------------------------------------------------------------------------------
/src/routes/admin/system/roles/index.ts:
--------------------------------------------------------------------------------
1 | import type { AppRouteHandler } from "@/types/lib";
2 |
3 | import { createRouter } from "@/lib/create-app";
4 |
5 | import * as handlers from "./handlers";
6 | import * as routes from "./routes";
7 |
8 | export const systemRolesRouter = createRouter()
9 | .openapi(routes.list, handlers.list)
10 | .openapi(routes.create, handlers.create)
11 | .openapi(routes.get, handlers.get)
12 | .openapi(routes.update, handlers.update)
13 | .openapi(routes.remove, handlers.remove)
14 | .openapi(routes.getPermissions, handlers.getPermissions)
15 | .openapi(routes.savePermissions, handlers.savePermissions);
16 |
17 | type RouteTypes = {
18 | [K in keyof typeof routes]: typeof routes[K];
19 | };
20 |
21 | export type SystemRolesRouteHandlerType = AppRouteHandler;
22 |
--------------------------------------------------------------------------------
/src/routes/admin/auth/index.ts:
--------------------------------------------------------------------------------
1 | import type { AppRouteHandler } from "@/types/lib";
2 |
3 | import { createRouter } from "@/lib/create-app";
4 |
5 | import * as handlers from "./handlers";
6 | import * as routes from "./routes";
7 |
8 | export const auth = createRouter()
9 | .openapi(routes.login, handlers.login)
10 | .openapi(routes.refreshToken, handlers.refreshToken)
11 | .openapi(routes.logout, handlers.logout)
12 | .openapi(routes.getIdentity, handlers.getIdentity)
13 | .openapi(routes.getPermissions, handlers.getPermissions)
14 | .openapi(routes.createChallenge, handlers.createChallenge)
15 | .openapi(routes.redeemChallenge, handlers.redeemChallenge);
16 |
17 | type RouteTypes = {
18 | [K in keyof typeof routes]: typeof routes[K];
19 | };
20 |
21 | export type AuthRouteHandlerType = AppRouteHandler;
22 |
--------------------------------------------------------------------------------
/src/db/schema/_shard/base-columns.ts:
--------------------------------------------------------------------------------
1 | import { format } from "date-fns";
2 | import { sql } from "drizzle-orm";
3 | import { timestamp, uuid, varchar } from "drizzle-orm/pg-core";
4 |
5 | export const baseColumns = {
6 | // pg18 以下用这个 .$defaultFn(() => uuidV7()),需要安装 uuid 库,pg18 以上用这个 .default(sql`uuidv7()`)
7 | /** id,uuid v7 生成 */
8 | id: uuid().primaryKey().notNull().default(sql`uuidv7()`),
9 | /** 创建时间 */
10 | createdAt: timestamp({ mode: "string" })
11 | .$defaultFn(() => format(new Date(), "yyyy-MM-dd HH:mm:ss")),
12 | /** 创建人 */
13 | createdBy: varchar({ length: 64 }),
14 | /** 更新时间 包含自动更新 */
15 | updatedAt: timestamp({ mode: "string" })
16 | .$defaultFn(() => format(new Date(), "yyyy-MM-dd HH:mm:ss"))
17 | .$onUpdate(() => format(new Date(), "yyyy-MM-dd HH:mm:ss")),
18 | /** 更新人 */
19 | updatedBy: varchar({ length: 64 }),
20 | };
21 |
--------------------------------------------------------------------------------
/src/routes/public/health/routes.ts:
--------------------------------------------------------------------------------
1 | import { createRoute, z } from "@hono/zod-openapi";
2 |
3 | import { RefineResultSchema } from "@/lib/refine-query";
4 | import * as HttpStatusCodes from "@/lib/stoker/http-status-codes";
5 | import { jsonContent } from "@/lib/stoker/openapi/helpers";
6 |
7 | const routePrefix = "/health";
8 | const tags = [`${routePrefix}(健康检查)`];
9 |
10 | const HealthResponseSchema = z.object({
11 | status: z.string().meta({ description: "健康状态" }),
12 | timestamp: z.string().meta({ description: "时间戳" }),
13 | });
14 |
15 | /** 健康检查接口 */
16 | export const get = createRoute({
17 | tags,
18 | path: routePrefix,
19 | method: "get",
20 | summary: "健康检查",
21 | description: "返回服务健康状态",
22 | responses: {
23 | [HttpStatusCodes.OK]: jsonContent(
24 | RefineResultSchema(HealthResponseSchema),
25 | "服务正常",
26 | ),
27 | },
28 | });
29 |
--------------------------------------------------------------------------------
/src/routes/admin/resources/handlers.ts:
--------------------------------------------------------------------------------
1 | import * as HttpStatusCodes from "@/lib/stoker/http-status-codes";
2 | import { generateDownloadUrl, generateUploadUrl } from "@/services/admin";
3 | import { Resp } from "@/utils";
4 |
5 | import type { ObjectStorageRouteHandlerType } from ".";
6 |
7 | export const getUploadToken: ObjectStorageRouteHandlerType<"getUploadToken"> = async (c) => {
8 | const { fileName, fileType } = c.req.valid("json");
9 |
10 | const result = await generateUploadUrl({
11 | fileName,
12 | fileType,
13 | });
14 |
15 | return c.json(Resp.ok(result), HttpStatusCodes.OK);
16 | };
17 |
18 | export const getDownloadToken: ObjectStorageRouteHandlerType<"getDownloadToken"> = async (c) => {
19 | const { fileName } = c.req.valid("json");
20 |
21 | const result = await generateDownloadUrl({
22 | fileName,
23 | });
24 |
25 | return c.json(Resp.ok(result), HttpStatusCodes.OK);
26 | };
27 |
--------------------------------------------------------------------------------
/src/db/schema/admin/system/users.ts:
--------------------------------------------------------------------------------
1 | import { relations } from "drizzle-orm";
2 | import { boolean, index, pgTable, text, varchar } from "drizzle-orm/pg-core";
3 |
4 | import { baseColumns } from "@/db/schema/_shard/base-columns";
5 | import { Status } from "@/lib/enums";
6 |
7 | import { statusEnum } from "../../_shard/enums";
8 | import { systemUserRoles } from "./user-roles";
9 |
10 | export const systemUsers = pgTable("system_users", {
11 | ...baseColumns,
12 | username: varchar({ length: 64 }).notNull().unique(),
13 | password: text().notNull(),
14 | builtIn: boolean().default(false),
15 | avatar: text(),
16 | nickName: varchar({ length: 64 }).notNull(),
17 | status: statusEnum().default(Status.ENABLED).notNull(),
18 | }, table => [
19 | index("system_user_username_idx").on(table.username),
20 | ]);
21 |
22 | export const systemUsersRelations = relations(systemUsers, ({ many }) => ({
23 | systemUserRoles: many(systemUserRoles),
24 | }));
25 |
--------------------------------------------------------------------------------
/src/lib/stoker/openapi/schemas/get-params-schema.ts:
--------------------------------------------------------------------------------
1 | import { z } from "@hono/zod-openapi";
2 |
3 | type Validator = "uuid" | "nanoid" | "cuid" | "cuid2" | "ulid";
4 |
5 | export type ParamsSchema = {
6 | name?: string;
7 | validator?: Validator | undefined;
8 | };
9 |
10 | const examples: Record = {
11 | uuid: "4651e634-a530-4484-9b09-9616a28f35e3",
12 | nanoid: "V1StGXR8_Z5jdHi6B-myT",
13 | cuid: "cjld2cjxh0000qzrmn831i7rn",
14 | cuid2: "tz4a98xxat96iws9zmbrgj3a",
15 | ulid: "01ARZ3NDEKTSV4RRFFQ69G5FAV",
16 | };
17 |
18 | const getParamsSchema = ({
19 | name = "id",
20 | validator = "uuid",
21 | }: ParamsSchema) => {
22 | return z.object({
23 | [name]: z.string()[validator]().openapi({
24 | param: {
25 | name,
26 | in: "path",
27 | required: true,
28 | },
29 | required: [name],
30 | example: examples[validator],
31 | }),
32 | });
33 | };
34 |
35 | export default getParamsSchema;
36 |
--------------------------------------------------------------------------------
/src/lib/casbin/index.ts:
--------------------------------------------------------------------------------
1 | import { newEnforcer, newModel } from "casbin";
2 |
3 | import db from "@/db";
4 | import { casbinRule } from "@/db/schema";
5 |
6 | import { DrizzleCasbinAdapter } from "./adapter";
7 |
8 | // Casbin 模型配置
9 | export const casbinModelText = `
10 | [request_definition]
11 | r = sub, obj, act
12 |
13 | [policy_definition]
14 | p = sub, obj, act, eft
15 |
16 | [role_definition]
17 | g = _, _
18 |
19 | [policy_effect]
20 | e = some(where (p.eft == allow)) && !some(where (p.eft == deny))
21 |
22 | [matchers]
23 | m = g(r.sub, p.sub) && keyMatch3(r.obj, p.obj) && regexMatch(r.act, p.act)
24 | `;
25 |
26 | const model = newModel(casbinModelText);
27 |
28 | const adapter = await DrizzleCasbinAdapter.newAdapter(db, casbinRule);
29 |
30 | export const enforcerPromise = newEnforcer(model, adapter);
31 |
32 | /**
33 | * 重新加载 Casbin 策略(用于测试环境)
34 | */
35 | export async function reloadCasbinPolicy(): Promise {
36 | const enforcer = await enforcerPromise;
37 | await enforcer.loadPolicy();
38 | }
39 |
--------------------------------------------------------------------------------
/src/utils/tools/tryit.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * 将函数转换为错误优先的函数
3 | * 支持同步和异步函数
4 | *
5 | * @param fn 要包装的函数
6 | * @returns 返回一个函数,执行结果为判别联合类型,支持类型收窄
7 | *
8 | * @example
9 | * ```typescript
10 | * // 带参数调用
11 | * const [err, res] = await tryit(fetch)("https://api.example.com");
12 | *
13 | * // 无参数调用
14 | * const [err, res] = await tryit(someFunc)();
15 | *
16 | * if (err) {
17 | * // err 是 Error, res 是 null
18 | * return;
19 | * }
20 | * // err 是 null, res 是 Result (已自动收窄类型)
21 | * console.log(res.data);
22 | * ```
23 | */
24 | export function tryit any>(
25 | fn: TFunction,
26 | ) {
27 | return async (
28 | ...args: Partial>
29 | ): Promise<
30 | | [Error, null]
31 | | [null, Awaited>]
32 | > => {
33 | try {
34 | const result = await fn(...(args as Parameters));
35 | return [null, result];
36 | }
37 | catch (error) {
38 | return [error as Error, null];
39 | }
40 | };
41 | }
42 |
--------------------------------------------------------------------------------
/eslint.config.mjs:
--------------------------------------------------------------------------------
1 | import antfu from "@antfu/eslint-config";
2 |
3 | export default antfu({
4 | type: "app",
5 | typescript: true,
6 | formatters: true,
7 | stylistic: {
8 | indent: 2,
9 | semi: true,
10 | quotes: "double",
11 | },
12 | ignores: [
13 | "migrations/**/*",
14 | "CLAUDE.md",
15 | "CLAUDE.local.md",
16 | "./.claude/**/*",
17 | "./.serena/**/*",
18 | "./docs/**/*",
19 | ],
20 | }, {
21 | rules: {
22 | "no-console": ["warn"],
23 | "antfu/no-top-level-await": ["off"],
24 | "node/prefer-global/process": ["off"],
25 | "node/no-process-env": ["error"],
26 | "perfectionist/sort-imports": ["error", {
27 | tsconfigRootDir: ".",
28 | }],
29 | "unicorn/filename-case": ["error", {
30 | case: "kebabCase",
31 | ignore: ["README.md"],
32 | }],
33 | "antfu/top-level-function": "off",
34 | "ts/consistent-type-definitions": ["error", "type"],
35 | "test/padding-around-all": "error",
36 | "test/prefer-lowercase-title": "off",
37 | },
38 | });
39 |
--------------------------------------------------------------------------------
/src/db/schema/admin/system/user-roles.ts:
--------------------------------------------------------------------------------
1 | import { relations } from "drizzle-orm";
2 | import { index, pgTable, primaryKey, uuid, varchar } from "drizzle-orm/pg-core";
3 |
4 | import { systemRoles } from "./roles";
5 | import { systemUsers } from "./users";
6 |
7 | export const systemUserRoles = pgTable("system_user_roles", {
8 | userId: uuid().notNull().references(() => systemUsers.id, { onDelete: "cascade" }),
9 | roleId: varchar({ length: 64 }).notNull().references(() => systemRoles.id, { onDelete: "cascade" }),
10 | }, table => [
11 | primaryKey({ columns: [table.userId, table.roleId] }),
12 | index("idx_user_roles_user_id").on(table.userId),
13 | index("idx_user_roles_role_id").on(table.roleId),
14 | ]);
15 |
16 | export const systemUserRolesRelations = relations(systemUserRoles, ({ one }) => ({
17 | user: one(systemUsers, {
18 | fields: [systemUserRoles.userId],
19 | references: [systemUsers.id],
20 | }),
21 | roles: one(systemRoles, {
22 | fields: [systemUserRoles.roleId],
23 | references: [systemRoles.id],
24 | }),
25 | }));
26 |
--------------------------------------------------------------------------------
/src/utils/tools/object.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * 从对象中选择指定的属性
3 | * @param obj 对象
4 | * @param keys 属性名数组
5 | * @returns 新对象
6 | */
7 | export function pick(obj: T, keys: K[]) {
8 | return keys.reduce((acc, key) => {
9 | if (obj[key] !== undefined) {
10 | acc[key] = obj[key];
11 | }
12 | return acc;
13 | }, {} as Pick);
14 | }
15 |
16 | /**
17 | * 从对象中排除指定的属性
18 | * @param obj 对象
19 | * @param keys 要排除的属性名数组
20 | * @returns 新对象
21 | */
22 | export function omit(obj: T, keys: K[]) {
23 | const keysSet = new Set(keys);
24 | return Object.keys(obj).reduce((acc, key) => {
25 | if (!keysSet.has(key as K) && obj[key] !== undefined) {
26 | (acc as any)[key] = obj[key];
27 | }
28 | return acc;
29 | }, {} as Omit);
30 | }
31 |
32 | /** 工具函数:将字段数组转换为查询所需的格式 */
33 | export function toColumns(fields: readonly T[]) {
34 | return Object.fromEntries(
35 | fields.map(key => [key, true]),
36 | ) as Record;
37 | }
38 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import devServer from "@hono/vite-dev-server";
2 | import nodeAdapter from "@hono/vite-dev-server/node";
3 | import path from "node:path";
4 | import { defineConfig, loadEnv } from "vite";
5 |
6 | export default defineConfig(({ mode }) => {
7 | const env = loadEnv(mode, process.cwd());
8 |
9 | return {
10 | server: {
11 | port: Number(env.PORT),
12 | },
13 | build: {
14 | ssr: "src/index.ts",
15 | outDir: "dist",
16 | minify: false,
17 | target: "esnext",
18 | rollupOptions: {
19 | output: {
20 | entryFileNames: "index.mjs",
21 | format: "esm",
22 | },
23 | },
24 | },
25 | define: {
26 | "import.meta.env": env,
27 | },
28 | resolve: {
29 | alias: {
30 | "@": path.join(process.cwd(), "./src"),
31 | "~": path.join(process.cwd(), "."),
32 | },
33 | },
34 | plugins: [
35 | devServer({
36 | entry: "src/index.ts",
37 | adapter: nodeAdapter,
38 | }),
39 | ],
40 | };
41 | });
42 |
--------------------------------------------------------------------------------
/src/services/ip.ts:
--------------------------------------------------------------------------------
1 | import iconv from "iconv-lite";
2 | import { Buffer } from "node:buffer";
3 |
4 | import { tryit } from "@/utils";
5 |
6 | type IPLocationResponse = {
7 | addr: string;
8 | pro: string;
9 | city: string;
10 | err?: string;
11 | };
12 |
13 | /** 根据IP地址获取城市地址信息 */
14 | export async function getIPAddress(ip: string): Promise {
15 | // 如果是内网IP或localhost,直接返回本地
16 | if (ip === "unknown" || ip.startsWith("127.") || ip.startsWith("192.168.") || ip.startsWith("10.") || ip.startsWith("172.")) {
17 | return "本地";
18 | }
19 |
20 | const [error, response] = await tryit(fetch)(`https://whois.pconline.com.cn/ipJson.jsp?ip=${ip}&json=true`);
21 |
22 | if (error || !response.ok) {
23 | return "unknown";
24 | }
25 |
26 | const buffer = await response.arrayBuffer();
27 |
28 | // 使用iconv-lite解码GBK编码的响应
29 | const str = iconv.decode(Buffer.from(buffer), "gbk");
30 | const data: IPLocationResponse = JSON.parse(str);
31 |
32 | if (data.err) {
33 | return "unknown";
34 | }
35 |
36 | return data.addr || "unknown";
37 | }
38 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 zhe-qi
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "esnext",
4 | "jsx": "react-jsx",
5 | "jsxImportSource": "hono/jsx",
6 | "lib": ["ESNext"],
7 | "moduleDetection": "force",
8 | "useDefineForClassFields": true,
9 | "module": "esnext",
10 | "moduleResolution": "bundler",
11 | "paths": {
12 | "@/*": ["./src/*"],
13 | "~/*": ["./*"]
14 | },
15 | "resolveJsonModule": true,
16 | "typeRoots": ["./node_modules/@types"],
17 | "types": [
18 | "node"
19 | ],
20 | "allowImportingTsExtensions": true,
21 | "strict": true,
22 | "noFallthroughCasesInSwitch": true,
23 | "noUnusedLocals": true,
24 | "noUnusedParameters": true,
25 | "noEmit": true,
26 | "esModuleInterop": true,
27 | "verbatimModuleSyntax": true,
28 | "erasableSyntaxOnly": true,
29 | "skipLibCheck": true,
30 | "noUncheckedSideEffectImports": true
31 | },
32 | "include": [
33 | "./src/**/*",
34 | "./vite.config.ts",
35 | "./vitest.config.ts",
36 | "./tests/**/*",
37 | "./migrations/**/*.ts",
38 | "./drizzle.config.ts"
39 | ],
40 | "exclude": ["node_modules", "dist"]
41 | }
42 |
--------------------------------------------------------------------------------
/src/db/schema/_shard/enums.ts:
--------------------------------------------------------------------------------
1 | import { pgEnum } from "drizzle-orm/pg-core";
2 |
3 | import { Gender, RealNameAuthStatus, RealNameAuthType, Status, UserStatus, VerificationStatus } from "@/lib/enums";
4 |
5 | /**
6 | * 类型安全的枚举值提取函数
7 | * 将对象的值转换为 pgEnum 所需的元组类型
8 | */
9 | function extractEnumValues>(
10 | enumObj: T,
11 | ): [T[keyof T], ...T[keyof T][]] {
12 | const values = Object.values(enumObj) as T[keyof T][];
13 | if (values.length === 0) {
14 | throw new Error("Enum object must have at least one value");
15 | }
16 | return values as [T[keyof T], ...T[keyof T][]];
17 | }
18 |
19 | // 定义所有数据库枚举类型
20 | export const statusEnum = pgEnum("status", extractEnumValues(Status));
21 | export const genderEnum = pgEnum("gender", extractEnumValues(Gender));
22 | export const userStatusEnum = pgEnum("user_status", extractEnumValues(UserStatus));
23 | export const verificationStatusEnum = pgEnum("verification_status", extractEnumValues(VerificationStatus));
24 | export const realNameAuthTypeEnum = pgEnum("real_name_auth_type", extractEnumValues(RealNameAuthType));
25 | export const realNameAuthStatusEnum = pgEnum("real_name_auth_status", extractEnumValues(RealNameAuthStatus));
26 |
--------------------------------------------------------------------------------
/src/lib/pg-boss-adapter.ts:
--------------------------------------------------------------------------------
1 | import type { IDatabase } from "pg-boss/dist/types";
2 | import type { Sql } from "postgres";
3 |
4 | import { PgBoss } from "pg-boss";
5 |
6 | import { getQueryClient } from "@/db/postgres";
7 |
8 | export const postgresAdapter: (sql: Sql) => IDatabase = (sql: Sql) => ({
9 | executeSql: async (query: string, values?: any[]) => {
10 | // Reserving a connection is required because pg-boss starts transactions
11 | const reserved = await sql.reserve();
12 |
13 | // pg-boss inserts jobs with a query containing `json_to_recordset($1::json)`
14 | // with value being a stringified JSON object, which causes an error with the `postgres` package.
15 | const parsedValues = values?.map((v) => {
16 | if (typeof v !== "string") {
17 | return v;
18 | }
19 |
20 | try {
21 | return JSON.parse(v);
22 | }
23 | catch {
24 | return v;
25 | }
26 | });
27 |
28 | // Calling unsafe() this way is safe when the query
29 | // contains placeholders ($1, $2, ...) for the values
30 | const rows = await reserved.unsafe(query, parsedValues);
31 |
32 | reserved.release();
33 | return { rows };
34 | },
35 | });
36 |
37 | const boss = new PgBoss({
38 | db: postgresAdapter(getQueryClient()),
39 | });
40 |
41 | export default boss;
42 |
--------------------------------------------------------------------------------
/src/lib/refine-query/index.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Refine List Query
3 | * 不建议用于没经过认证的接口和c端接口
4 | */
5 |
6 | // 转换器功能
7 | export {
8 | addDefaultSorting,
9 | convertFiltersToSQL,
10 | convertSortersToSQL,
11 | FiltersConverter,
12 | SortersConverter,
13 | validateFilterFields,
14 | validateSorterFields,
15 | } from "./converters";
16 |
17 | // 分页功能
18 | export {
19 | calculatePagination,
20 | type PaginationCalculation,
21 | PaginationHandler,
22 | paginationHandler,
23 | validatePagination,
24 | } from "./pagination";
25 |
26 | // 查询执行器
27 | export {
28 | type DbInstance,
29 | executeRefineQuery,
30 | RefineQueryExecutor,
31 | } from "./query-executor";
32 |
33 | // Zod Schemas 和类型
34 | export {
35 | type ConditionalFilter,
36 | ConditionalFilterSchema,
37 | type CrudFilter,
38 | type CrudFilters,
39 | CrudFilterSchema,
40 | CrudFiltersSchema,
41 | type CrudOperators,
42 | CrudOperatorsSchema,
43 | type CrudSort,
44 | type CrudSorting,
45 | CrudSortingSchema,
46 | CrudSortSchema,
47 | type JoinConfig,
48 | type JoinDefinition,
49 | type JoinType,
50 | type LogicalFilter,
51 | LogicalFilterSchema,
52 | type Pagination,
53 | PaginationSchema,
54 | type QueryExecutionParams,
55 | type RefineQueryConfig,
56 | RefineQueryError,
57 | type RefineQueryParams,
58 | RefineQueryParamsSchema,
59 | type RefineQueryResult,
60 | RefineResultSchema,
61 | type Result,
62 | } from "./schemas";
63 |
--------------------------------------------------------------------------------
/src/lib/stoker/openapi/schemas/create-error-schema.ts:
--------------------------------------------------------------------------------
1 | import { z } from "@hono/zod-openapi";
2 |
3 | import type { ZodIssue, ZodSchema } from "../helpers/types.ts";
4 |
5 | const createErrorSchema = (schema: T) => {
6 | const { error } = schema.safeParse(
7 | schema._def.type === "array" ? [schema.element._def.type === "string" ? 123 : "invalid"] : {},
8 | );
9 |
10 | const example = error
11 | ? {
12 | name: error.name,
13 | issues: error.issues.map((issue: ZodIssue) => ({
14 | code: issue.code,
15 | path: issue.path,
16 | message: issue.message,
17 | })),
18 | }
19 | : {
20 | name: "ZodError",
21 | issues: [
22 | {
23 | code: "invalid_type",
24 | path: ["fieldName"],
25 | message: "Expected string, received undefined",
26 | },
27 | ],
28 | };
29 |
30 | return z.object({
31 | success: z.boolean().openapi({
32 | example: false,
33 | }),
34 | error: z
35 | .object({
36 | issues: z.array(
37 | z.object({
38 | code: z.string(),
39 | path: z.array(z.union([z.string(), z.number()])),
40 | message: z.string().optional(),
41 | }),
42 | ),
43 | name: z.string(),
44 | })
45 | .openapi({
46 | example,
47 | }),
48 | });
49 | };
50 |
51 | export default createErrorSchema;
52 |
--------------------------------------------------------------------------------
/src/routes/admin/system/roles/schema.ts:
--------------------------------------------------------------------------------
1 | import { createInsertSchema, createSelectSchema } from "drizzle-zod";
2 | import z from "zod";
3 |
4 | import { systemRoles } from "@/db/schema";
5 |
6 | export const selectSystemRoles = createSelectSchema(systemRoles, {
7 | id: schema => schema.meta({ description: "角色ID" }),
8 | name: schema => schema.meta({ description: "角色名称" }),
9 | description: schema => schema.meta({ description: "角色描述" }),
10 | status: schema => schema.meta({ description: "状态 (ENABLED=启用, DISABLED=禁用)" }),
11 | }).extend({
12 | parentRoles: z.array(z.string()).optional().describe("上级角色列表"),
13 | });
14 |
15 | export const insertSystemRoles = createInsertSchema(systemRoles, {
16 | id: schema => schema.min(1).regex(/^[a-z0-9_]+$/),
17 | name: schema => schema.min(1),
18 | }).omit({
19 | createdAt: true,
20 | updatedAt: true,
21 | createdBy: true,
22 | updatedBy: true,
23 | }).extend({
24 | parentRoleIds: z.array(z.string().min(1).regex(/^[a-z0-9_]+$/, "角色ID只能包含小写字母、数字和下划线")).optional().describe("上级角色ID列表"),
25 | });
26 |
27 | export const patchSystemRoles = insertSystemRoles.partial();
28 |
29 | // id 查询 schema
30 | export const idSystemRoles = z.object({
31 | id: z.string().min(1).regex(/^[a-z0-9_]+$/).meta({ description: "角色ID" }),
32 | });
33 |
34 | // 权限项 schema
35 | export const permissionItemSchema = z.object({
36 | resource: z.string().min(1).meta({ description: "资源路径" }),
37 | action: z.string().min(1).meta({ description: "操作" }),
38 | });
39 |
--------------------------------------------------------------------------------
/src/lib/stoker/index.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * This code is based on the stoker project by w3cj
3 | * Source: https://github.com/w3cj/stoker
4 | *
5 | * 由于英文不能很好的满足中文用户的需求,所以这个版本拷贝过来用于中文用户。
6 | *
7 | * MIT License
8 | *
9 | * Copyright (c) 2024 w3cj
10 | *
11 | * Permission is hereby granted, free of charge, to any person obtaining a copy
12 | * of this software and associated documentation files (the "Software"), to deal
13 | * in the Software without restriction, including without limitation the rights
14 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
15 | * copies of the Software, and to permit persons to whom the Software is
16 | * furnished to do so, subject to the following conditions:
17 | *
18 | * The above copyright notice and this permission notice shall be included in all
19 | * copies or substantial portions of the Software.
20 | *
21 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
22 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
23 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
24 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
25 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
26 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
27 | * SOFTWARE.
28 | */
29 |
30 | export * as middlewares from "./middlewares/index.js";
31 | export * as openapi from "./openapi/index.js";
32 |
--------------------------------------------------------------------------------
/src/lib/stoker/middlewares/on-error.test.ts:
--------------------------------------------------------------------------------
1 | import path from "node:path";
2 | import { describe, expect, it } from "vitest";
3 |
4 | import env from "@/env";
5 |
6 | import onError from "./on-error.js";
7 |
8 | describe("onError", () => {
9 | it("should use NODE_ENV from context if defined", async () => {
10 | const { Context } = await import(path.join(process.cwd(), "node_modules/hono/dist/context.js"));
11 | const req = new Request("http://localhost/");
12 | const context = new Context(req);
13 | context.env = {
14 | NODE_ENV: "production",
15 | };
16 | const response = await onError(
17 | new Error("Test error"),
18 | context,
19 | );
20 |
21 | expect(response.status).toBe(500);
22 |
23 | const json = await response.json();
24 |
25 | expect(json).toEqual({
26 | message: "Test error",
27 | stack: undefined,
28 | });
29 | });
30 |
31 | it("should use NODE_ENV from process.env otherwise", async () => {
32 | const { Context } = await import(path.join(process.cwd(), "node_modules/hono/dist/context.js"));
33 | const req = new Request("http://localhost/");
34 | const context = new Context(req);
35 | env.NODE_ENV = "production";
36 | const response = await onError(
37 | new Error("Test error"),
38 | context,
39 | );
40 |
41 | expect(response.status).toBe(500);
42 |
43 | const json = await response.json();
44 |
45 | expect(json).toEqual({
46 | message: "Test error",
47 | stack: undefined,
48 | });
49 | });
50 | });
51 |
--------------------------------------------------------------------------------
/src/middlewares/authorize.ts:
--------------------------------------------------------------------------------
1 | import type { MiddlewareHandler } from "hono";
2 |
3 | import { Enforcer } from "casbin";
4 |
5 | import type { AppBindings } from "@/types/lib";
6 |
7 | import { enforcerPromise } from "@/lib/casbin";
8 | import { API_ADMIN_PATH } from "@/lib/openapi/config";
9 | import * as HttpStatusCodes from "@/lib/stoker/http-status-codes";
10 | import * as HttpStatusPhrases from "@/lib/stoker/http-status-phrases";
11 | import { Resp } from "@/utils";
12 | import { stripPrefix } from "@/utils/tools";
13 |
14 | /**
15 | * Casbin 权限校验中间件
16 | * 用于校验当前用户是否有访问指定接口的权限
17 | */
18 | export function authorize(): MiddlewareHandler {
19 | return async (c, next) => {
20 | // 获取 Casbin 权限管理器
21 | const enforcer = await enforcerPromise;
22 |
23 | // 检查 enforcer 是否有效
24 | if (!(enforcer instanceof Enforcer)) {
25 | return c.json(Resp.fail(HttpStatusPhrases.INTERNAL_SERVER_ERROR), HttpStatusCodes.INTERNAL_SERVER_ERROR);
26 | }
27 |
28 | // 从 JWT 载荷中获取用户角色
29 | const { roles } = c.get("jwtPayload");
30 |
31 | // 去除 API 前缀,获取实际请求路径
32 | const path = stripPrefix(c.req.path, API_ADMIN_PATH);
33 |
34 | // 并行检查所有角色权限
35 | const results = await Promise.all(
36 | roles.map(role => enforcer.enforce(role, path, c.req.method)),
37 | );
38 | const hasPermission = results.some(hasPermission => hasPermission);
39 |
40 | // 无权限则返回 403
41 | if (!hasPermission) {
42 | return c.json(Resp.fail(HttpStatusPhrases.FORBIDDEN), HttpStatusCodes.FORBIDDEN);
43 | }
44 |
45 | // 有权限则继续后续中间件
46 | await next();
47 | };
48 | }
49 |
--------------------------------------------------------------------------------
/src/lib/stoker/openapi/helpers/json-content-one-of.test.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable ts/ban-ts-comment */
2 | import { z } from "@hono/zod-openapi";
3 | import { describe, expect, it } from "vitest";
4 |
5 | import jsonContentOneOf from "./json-content-one-of.js";
6 |
7 | describe("jsonContentOneOf", () => {
8 | it("accepts a single schema", () => {
9 | const result = jsonContentOneOf([
10 | z.object({ message: z.string() }),
11 | ], "Test 1");
12 | const oneOf = result.content["application/json"].schema.oneOf;
13 |
14 | expect(oneOf.length).toBe(1);
15 |
16 | const definition = oneOf[0];
17 |
18 | // @ts-expect-error
19 | expect(definition.type).toBe("object");
20 | // @ts-expect-error
21 | expect(definition.properties.message).toBeDefined();
22 | });
23 |
24 | it("accepts multiple schemas", () => {
25 | const result = jsonContentOneOf([
26 | z.object({ message: z.string() }),
27 | z.object({
28 | message: z.string(),
29 | error: z.array(
30 | z.object({ code: z.string() }),
31 | ),
32 | }),
33 | ], "Test 2");
34 | const oneOf = result.content["application/json"].schema.oneOf;
35 |
36 | expect(oneOf.length).toBe(2);
37 |
38 | const definition1 = oneOf[0];
39 |
40 | // @ts-expect-error
41 | expect(definition1.type).toBe("object");
42 | // @ts-expect-error
43 | expect(definition1.properties.message).toBeDefined();
44 |
45 | const definition2 = oneOf[1];
46 |
47 | // @ts-expect-error
48 | expect(definition2.type).toBe("object");
49 | // @ts-expect-error
50 | expect(definition2.properties.error).toBeDefined();
51 | });
52 | });
53 |
--------------------------------------------------------------------------------
/src/env.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable node/no-process-env */
2 | import { config } from "@dotenvx/dotenvx";
3 | import path from "node:path";
4 | import { z } from "zod";
5 |
6 | import { parseEnvOrExit } from "@/utils/zod";
7 |
8 | config({ path: path.resolve(
9 | process.cwd(),
10 | process.env.NODE_ENV === "test" ? ".env.test" : ".env",
11 | ) });
12 |
13 | /**
14 | * 环境变量验证模式,包含对于环境变量的校验,转换,默认值,类型等
15 | */
16 | const EnvSchema = z.object({
17 | /** 环境变量 */
18 | NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
19 | /** 端口 */
20 | PORT: z.coerce.number().default(9999),
21 | /** 日志级别 */
22 | LOG_LEVEL: z.enum(["fatal", "error", "warn", "info", "debug", "trace", "silent"])
23 | .default("info"),
24 | /** 数据库连接字符串 */
25 | DATABASE_URL: z.string().refine(
26 | val => process.env.NODE_ENV !== "production" || val !== "",
27 | { message: "生产环境下数据库连接字符串不能为空" },
28 | ),
29 | /** Redis连接字符串 */
30 | REDIS_URL: z.string().refine(
31 | val => process.env.NODE_ENV !== "production" || val !== "",
32 | { message: "生产环境下redis连接字符串不能为空" },
33 | ),
34 | /** 客户端JWT密钥 */
35 | CLIENT_JWT_SECRET: z.string().min(32, "JWT密钥长度至少32字符,建议使用强随机字符串"),
36 | /** 管理端JWT密钥 */
37 | ADMIN_JWT_SECRET: z.string().min(32, "JWT密钥长度至少32字符,建议使用强随机字符串"),
38 |
39 | /** 云服务商R2访问密钥ID */
40 | ACCESS_KEY_ID: z.string(),
41 | /** 云服务商R2访问密钥 */
42 | SECRET_ACCESS_KEY: z.string(),
43 | /** 云服务商R2终端节点 */
44 | ENDPOINT: z.url(),
45 | /** 云服务商R2存储桶名称 */
46 | BUCKET_NAME: z.string().default("default-bucket"),
47 |
48 | /** Sentry错误追踪 */
49 | SENTRY_DSN: z.string().optional(),
50 | });
51 |
52 | export type Env = z.infer;
53 |
54 | export default parseEnvOrExit(EnvSchema);
55 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | # Git
2 | .git
3 | .gitignore
4 |
5 | # Docker
6 | Dockerfile
7 | .dockerignore
8 | docker-compose.yml
9 | docker-compose.*.yml
10 |
11 | # Node.js
12 | node_modules
13 | npm-debug.log*
14 | yarn-debug.log*
15 | yarn-error.log*
16 | pnpm-debug.log*
17 |
18 | # Build outputs
19 | dist
20 | build
21 | .next
22 | .nuxt
23 |
24 | # Environment files
25 | .env
26 | .env.local
27 | .env.development.local
28 | .env.test.local
29 | .env.production.local
30 |
31 | # IDE and editors
32 | .vscode
33 | .idea
34 | *.swp
35 | *.swo
36 | *~
37 |
38 | # OS generated files
39 | .DS_Store
40 | .DS_Store?
41 | ._*
42 | .Spotlight-V100
43 | .Trashes
44 | ehthumbs.db
45 | Thumbs.db
46 |
47 | # Logs
48 | logs
49 | *.log
50 |
51 | # Runtime data
52 | pids
53 | *.pid
54 | *.seed
55 | *.pid.lock
56 |
57 | # Coverage directory used by tools like istanbul
58 | coverage
59 | .nyc_output
60 |
61 | # ESLint cache
62 | .eslintcache
63 |
64 | # Optional npm cache directory
65 | .npm
66 |
67 | # Optional REPL history
68 | .node_repl_history
69 |
70 | # Output of 'npm pack'
71 | *.tgz
72 |
73 | # Yarn Integrity file
74 | .yarn-integrity
75 |
76 | # dotenv environment variables file
77 | .env
78 |
79 | # Testing
80 | .vitest
81 |
82 | # Documentation
83 | README.md
84 | LICENSE
85 | CHANGELOG.md
86 | *.md
87 |
88 | # Development files
89 | .serena
90 | .claude
91 | src/**/*.test.ts
92 | src/**/*.spec.ts
93 | **/__tests__/**
94 | **/__mocks__/**
95 |
96 | # TypeScript
97 | *.tsbuildinfo
98 |
99 | # Docker optimization files
100 | Dockerfile.optimized
101 | Dockerfile.distroless
102 | Dockerfile.k8s
103 |
104 | # Kubernetes files
105 | k8s/
106 |
107 | # Development tools
108 | .eslintrc*
109 | .prettierrc*
110 | vitest.config.ts
--------------------------------------------------------------------------------
/src/utils/zod/response.ts:
--------------------------------------------------------------------------------
1 | import { z, ZodError } from "zod";
2 |
3 | export const respErr = z.object({
4 | message: z.string().optional().describe("错误信息"),
5 | stack: z.string().optional().describe("错误堆栈"),
6 | error: z.object({
7 | name: z.string().describe("错误名称"),
8 | issues: z.array(z.object({
9 | code: z.string().describe("错误码"),
10 | path: z.array(z.union([z.string(), z.number()])).describe("错误路径"),
11 | message: z.string().describe("错误信息"),
12 | })).optional().describe("错误详情"),
13 | }).optional().describe("错误对象"),
14 | }).catchall(z.unknown()).describe("错误响应");
15 |
16 | type RespErr = z.infer;
17 |
18 | export class Resp {
19 | /**
20 | * 失败响应,支持 字符串、Error、ZodError
21 | * extra为可选参数,会与message平级展开
22 | */
23 | static fail(
24 | input: string | Error | ZodError,
25 | extra?: Record,
26 | ): RespErr {
27 | let message: string;
28 | let error: RespErr["error"];
29 |
30 | if (typeof input === "string") {
31 | message = input;
32 | }
33 | else if (input instanceof ZodError) {
34 | message = z.prettifyError(input);
35 | error = {
36 | name: input.name,
37 | issues: input.issues.map(issue => ({
38 | code: issue.code,
39 | message: issue.message,
40 | path: issue.path.map(p => typeof p === "symbol" ? p.toString() : p), // convert symbol → string
41 | })),
42 | };
43 | }
44 | else {
45 | message = input.message || "未知错误";
46 | }
47 |
48 | return {
49 | message,
50 | ...(extra || {}),
51 | ...(error ? { error } : {}),
52 | };
53 | }
54 |
55 | /** 成功响应 */
56 | static ok(data: T): {
57 | data: T;
58 | } {
59 | return {
60 | data,
61 | };
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/src/routes/admin/resources/routes.ts:
--------------------------------------------------------------------------------
1 | import { createRoute } from "@hono/zod-openapi";
2 |
3 | import { RefineResultSchema } from "@/lib/refine-query";
4 | import * as HttpStatusCodes from "@/lib/stoker/http-status-codes";
5 | import { jsonContent, jsonContentRequired } from "@/lib/stoker/openapi/helpers";
6 | import { respErr } from "@/utils";
7 |
8 | import { DownloadTokenRequestSchema, TokenResponseSchema, UploadTokenRequestSchema } from "./schema";
9 |
10 | const routePrefix = "/resources";
11 | const tags = [`${routePrefix}(通用资源)`];
12 |
13 | /** 获取上传预签名 URL */
14 | export const getUploadToken = createRoute({
15 | path: `${routePrefix}/object-storage/upload`,
16 | method: "post",
17 | request: {
18 | body: jsonContentRequired(
19 | UploadTokenRequestSchema,
20 | "上传令牌请求",
21 | ),
22 | },
23 | tags,
24 | summary: "OS-获取上传预签名 URL",
25 | description: "用于管理员上传文件到对象存储",
26 | responses: {
27 | [HttpStatusCodes.OK]: jsonContent(
28 | RefineResultSchema(TokenResponseSchema),
29 | "获取成功",
30 | ),
31 | [HttpStatusCodes.BAD_REQUEST]: jsonContent(respErr, "请求参数错误"),
32 | [HttpStatusCodes.UNPROCESSABLE_ENTITY]: jsonContent(respErr, "参数验证失败"),
33 | },
34 | });
35 |
36 | /** 获取下载预签名 URL */
37 | export const getDownloadToken = createRoute({
38 | path: `${routePrefix}/object-storage/download`,
39 | method: "post",
40 | request: {
41 | body: jsonContentRequired(
42 | DownloadTokenRequestSchema,
43 | "下载令牌请求",
44 | ),
45 | },
46 | tags,
47 | summary: "OS-获取下载预签名 URL",
48 | description: "用于管理员从对象存储下载文件",
49 | responses: {
50 | [HttpStatusCodes.OK]: jsonContent(
51 | RefineResultSchema(TokenResponseSchema),
52 | "获取成功",
53 | ),
54 | [HttpStatusCodes.BAD_REQUEST]: jsonContent(respErr, "请求参数错误"),
55 | [HttpStatusCodes.UNPROCESSABLE_ENTITY]: jsonContent(respErr, "参数验证失败"),
56 | },
57 | });
58 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | // Disable the default formatter, use eslint instead
3 | "prettier.enable": false,
4 | "editor.formatOnSave": false,
5 |
6 | // Auto fix
7 | "editor.codeActionsOnSave": {
8 | "source.fixAll.eslint": "explicit",
9 | "source.organizeImports": "never"
10 | },
11 |
12 | // Silent the stylistic rules in you IDE, but still auto fix them
13 | "eslint.rules.customizations": [
14 | { "rule": "style/*", "severity": "off", "fixable": true },
15 | { "rule": "format/*", "severity": "off", "fixable": true },
16 | { "rule": "*-indent", "severity": "off", "fixable": true },
17 | { "rule": "*-spacing", "severity": "off", "fixable": true },
18 | { "rule": "*-spaces", "severity": "off", "fixable": true },
19 | { "rule": "*-order", "severity": "off", "fixable": true },
20 | { "rule": "*-dangle", "severity": "off", "fixable": true },
21 | { "rule": "*-newline", "severity": "off", "fixable": true },
22 | { "rule": "*quotes", "severity": "off", "fixable": true },
23 | { "rule": "*semi", "severity": "off", "fixable": true }
24 | ],
25 |
26 | // Enable eslint for all supported languages
27 | "eslint.validate": [
28 | "javascript",
29 | "javascriptreact",
30 | "typescript",
31 | "typescriptreact",
32 | "vue",
33 | "html",
34 | "markdown",
35 | "json",
36 | "jsonc",
37 | "yaml",
38 | "toml",
39 | "xml",
40 | "gql",
41 | "graphql",
42 | "astro",
43 | "css",
44 | "less",
45 | "scss",
46 | "pcss",
47 | "postcss"
48 | ],
49 | "cSpell.words": [
50 | "bullmq",
51 | "casbin",
52 | "Clhoria",
53 | "dotenvx",
54 | "Hono",
55 | "ilike",
56 | "openapi",
57 | "papaparse",
58 | "pgbouncer",
59 | "pkey",
60 | "ptype",
61 | "rolldown",
62 | "setex",
63 | "tryit",
64 | "tsdown",
65 | "uuidv"
66 | ]
67 | }
68 |
--------------------------------------------------------------------------------
/src/utils/zod/env-validator.ts:
--------------------------------------------------------------------------------
1 | import type { z } from "zod";
2 |
3 | import { env as processEnv } from "node:process";
4 |
5 | export type ValidationResult = {
6 | success: boolean;
7 | data?: T;
8 | fieldErrors?: Record;
9 | };
10 |
11 | /**
12 | * 安全解析环境变量schema
13 | */
14 | export function safeParseEnv(
15 | schema: T,
16 | env: Record = processEnv,
17 | ): ValidationResult> {
18 | const result = schema.safeParse(env);
19 |
20 | if (result.success) {
21 | return {
22 | success: true,
23 | data: result.data,
24 | };
25 | }
26 |
27 | const fieldErrors: Record = {};
28 |
29 | result.error.issues.forEach((issue) => {
30 | // 确保路径元素是字符串 (Symbol 不能作为索引类型)
31 | const field = issue.path
32 | .filter((p): p is string => typeof p === "string")
33 | .join("."); // 处理嵌套路径,尽管环境变量是扁平的
34 |
35 | if (field) {
36 | // 将错误添加到对应字段
37 | if (!fieldErrors[field]) {
38 | fieldErrors[field] = [];
39 | }
40 | fieldErrors[field].push(issue.message);
41 | }
42 | else {
43 | // 处理无字段关联的错误(如根级错误)
44 | const rootKey = "_";
45 | if (!fieldErrors[rootKey]) {
46 | fieldErrors[rootKey] = [];
47 | }
48 | fieldErrors[rootKey].push(issue.message);
49 | }
50 | });
51 |
52 | return {
53 | success: false,
54 | fieldErrors,
55 | };
56 | }
57 |
58 | /**
59 | * 解析环境变量并在失败时退出进程
60 | */
61 | export function parseEnvOrExit(
62 | schema: T,
63 | env: Record = processEnv,
64 | ): z.infer {
65 | const result = safeParseEnv(schema, env);
66 |
67 | if (!result.success) {
68 | console.error("❌ Invalid env:");
69 | console.error(JSON.stringify(result.fieldErrors, null, 2));
70 | process.exit(1);
71 | }
72 |
73 | return result.data!;
74 | }
75 |
--------------------------------------------------------------------------------
/src/services/admin/object-storage.ts:
--------------------------------------------------------------------------------
1 | import { GetObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
2 | import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
3 | import { addSeconds, formatISO } from "date-fns";
4 |
5 | import env from "@/env";
6 |
7 | // 创建 S3 客户端实例
8 | const s3Client = new S3Client({
9 | region: "auto",
10 | endpoint: env.ENDPOINT,
11 | credentials: {
12 | accessKeyId: env.ACCESS_KEY_ID,
13 | secretAccessKey: env.SECRET_ACCESS_KEY,
14 | },
15 | });
16 |
17 | export type GenerateUploadUrlParams = {
18 | fileName: string;
19 | fileType?: string;
20 | expiresIn?: number;
21 | };
22 |
23 | export type GenerateDownloadUrlParams = {
24 | fileName: string;
25 | expiresIn?: number;
26 | };
27 |
28 | export type PresignedUrlResult = {
29 | url: string;
30 | expiresAt: string;
31 | };
32 |
33 | /**
34 | * 生成上传预签名 URL
35 | */
36 | export async function generateUploadUrl(params: GenerateUploadUrlParams): Promise {
37 | const { fileName, fileType, expiresIn = 3600 } = params;
38 |
39 | const command = new PutObjectCommand({
40 | Bucket: env.BUCKET_NAME,
41 | Key: fileName,
42 | ContentType: fileType,
43 | });
44 |
45 | const url = await getSignedUrl(s3Client, command, { expiresIn });
46 | const expiresAt = formatISO(addSeconds(new Date(), expiresIn));
47 |
48 | return { url, expiresAt };
49 | }
50 |
51 | /**
52 | * 生成下载预签名 URL
53 | */
54 | export async function generateDownloadUrl(params: GenerateDownloadUrlParams): Promise {
55 | const { fileName, expiresIn = 3600 } = params;
56 |
57 | const command = new GetObjectCommand({
58 | Bucket: env.BUCKET_NAME,
59 | Key: fileName,
60 | });
61 |
62 | const url = await getSignedUrl(s3Client, command, { expiresIn });
63 | const expiresAt = formatISO(addSeconds(new Date(), expiresIn));
64 |
65 | return { url, expiresAt };
66 | }
67 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | # syntax=docker.io/docker/dockerfile:1
2 |
3 | FROM node:25-alpine AS base
4 | # 配置 pnpm 环境
5 | ENV PNPM_HOME="/pnpm"
6 | ENV PATH="$PNPM_HOME:$PATH"
7 |
8 | RUN npm install -g pnpm
9 |
10 | # 生产依赖安装阶段
11 | FROM base AS deps
12 | RUN apk add --no-cache libc6-compat openssl
13 | WORKDIR /app
14 |
15 | # 复制 package 文件
16 | COPY package.json pnpm-lock.yaml ./
17 |
18 | # 只安装生产依赖,减少镜像大小
19 | # 跳过后续处理脚本以避免 msgpackr-extract 构建问题
20 | RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm fetch --prod && \
21 | pnpm install --prod --frozen-lockfile --ignore-scripts
22 |
23 | # 构建阶段
24 | FROM base AS builder
25 | WORKDIR /app
26 |
27 | # 设置构建时环境变量和优化选项
28 | ARG NODE_ENV=production
29 | ENV NODE_ENV=${NODE_ENV}
30 | ENV NODE_OPTIONS="--max-old-space-size=2048"
31 | ENV CI=true
32 |
33 | # 安装所有依赖用于构建
34 | COPY package.json pnpm-lock.yaml ./
35 | RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm fetch && \
36 | pnpm install --frozen-lockfile --ignore-scripts
37 |
38 | # 复制源码
39 | COPY . .
40 |
41 | # 构建应用(增加超时和静默输出)
42 | RUN timeout 300 pnpm build --silent || (echo "Build timeout, retrying with verbose output..." && pnpm build)
43 |
44 | # 生产阶段
45 | FROM base AS runner
46 | WORKDIR /app
47 |
48 | # 设置生产环境变量
49 | ENV NODE_ENV=production
50 | ENV LOG_LEVEL=info
51 |
52 | # 安装运行时依赖并清理缓存
53 | RUN apk add --no-cache openssl curl && \
54 | rm -rf /var/cache/apk/*
55 |
56 | # 安装 dotenvx
57 | RUN curl -sfS https://dotenvx.sh/install.sh | sh
58 |
59 | # 创建非 root 用户
60 | RUN addgroup --system --gid 1001 nodejs && \
61 | adduser --system --uid 1001 hono
62 |
63 | # 从 deps 阶段复制生产依赖(避免重复安装)
64 | COPY --from=deps --chown=hono:nodejs /app/node_modules ./node_modules
65 | COPY --chown=hono:nodejs package.json pnpm-lock.yaml ./
66 |
67 | # 从构建阶段复制构建产物
68 | COPY --from=builder --chown=hono:nodejs /app/dist/index.mjs ./index.mjs
69 |
70 | # 设置默认端口
71 | ARG PORT=9999
72 | ENV PORT=${PORT}
73 | EXPOSE ${PORT}
74 |
75 | # 生产阶段入口点
76 | CMD ["dotenvx", "run", "--", "node", "index.mjs"]
77 |
--------------------------------------------------------------------------------
/src/lib/stoker/openapi/schemas/slug-params.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from "vitest";
2 |
3 | import SlugParamsSchema from "./slug-params.js";
4 |
5 | describe("slug-params", () => {
6 | it("allows letters", () => {
7 | const slug = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
8 | const { data, error } = SlugParamsSchema.safeParse({
9 | slug,
10 | });
11 |
12 | expect(data?.slug).toBe(slug);
13 | expect(error).toBeUndefined();
14 | });
15 |
16 | it("allows numbers", () => {
17 | const slug = "0123456789";
18 | const { data, error } = SlugParamsSchema.safeParse({
19 | slug,
20 | });
21 |
22 | expect(data?.slug).toBe(slug);
23 | expect(error).toBeUndefined();
24 | });
25 |
26 | it("allows numbers and letters", () => {
27 | const slug = "0123456789abcdeABCDE";
28 | const { data, error } = SlugParamsSchema.safeParse({
29 | slug,
30 | });
31 |
32 | expect(data?.slug).toBe(slug);
33 | expect(error).toBeUndefined();
34 | });
35 |
36 | it("allows dashes", () => {
37 | const slug = "test-slug-here";
38 | const { data, error } = SlugParamsSchema.safeParse({
39 | slug,
40 | });
41 |
42 | expect(data?.slug).toBe(slug);
43 | expect(error).toBeUndefined();
44 | });
45 |
46 | it("allows underscores", () => {
47 | const slug = "test_slug_here";
48 | const { data, error } = SlugParamsSchema.safeParse({
49 | slug,
50 | });
51 |
52 | expect(data?.slug).toBe(slug);
53 | expect(error).toBeUndefined();
54 | });
55 |
56 | it("does not allow special characters only", () => {
57 | const slug = "!@#$%^&*()+-=";
58 | const { error } = SlugParamsSchema.safeParse({
59 | slug,
60 | });
61 |
62 | expect(error).toBeDefined();
63 | });
64 |
65 | it("does not allow special characters with allowed characters", () => {
66 | const slug = "abc-DEF_ABC-!@#$%^&*()+-=";
67 | const { error } = SlugParamsSchema.safeParse({
68 | slug,
69 | });
70 |
71 | expect(error).toBeDefined();
72 | });
73 | });
74 |
--------------------------------------------------------------------------------
/src/lib/create-app.ts:
--------------------------------------------------------------------------------
1 | import { OpenAPIHono } from "@hono/zod-openapi";
2 | import { pinoLogger } from "hono-pino";
3 | import { bodyLimit } from "hono/body-limit";
4 | import { compress } from "hono/compress";
5 | import { cors } from "hono/cors";
6 | import { requestId } from "hono/request-id";
7 | import { secureHeaders } from "hono/secure-headers";
8 | import { timeout } from "hono/timeout";
9 | import { trimTrailingSlash } from "hono/trailing-slash";
10 |
11 | import type { AppBindings } from "@/types/lib";
12 |
13 | import { createRateLimiter, DEFAULT_RATE_LIMIT } from "@/lib/rate-limit-factory";
14 | import * as HttpStatusCodes from "@/lib/stoker/http-status-codes";
15 | import { notFound, onError, serveEmojiFavicon } from "@/lib/stoker/middlewares";
16 | import { defaultHook } from "@/lib/stoker/openapi";
17 | import { Resp } from "@/utils";
18 |
19 | import logger from "./logger";
20 |
21 | export function createRouter() {
22 | return new OpenAPIHono({
23 | strict: false,
24 | defaultHook,
25 | });
26 | }
27 |
28 | export default function createApp() {
29 | const app = createRouter();
30 |
31 | /** 1. 请求ID - 最先生成,用于全链路追踪 */
32 | app.use(requestId());
33 |
34 | /** 2. 日志记录 - 尽早记录,包括被拦截的请求 */
35 | app.use(pinoLogger({ pino: logger }));
36 |
37 | /** 3. 安全头部 */
38 | app.use(secureHeaders());
39 |
40 | /** 4. 超时控制 - 尽早设置,控制整个请求链 */
41 | app.use(timeout(30000));
42 |
43 | /** 5. 速率限制 - 在解析请求体之前拦截 */
44 | app.use(createRateLimiter(DEFAULT_RATE_LIMIT));
45 |
46 | /** 6. 基础功能 */
47 | app.use(trimTrailingSlash());
48 | app.use(cors());
49 |
50 | /** 7. 请求体限制 - 在实际解析前限制 */
51 | app.use(bodyLimit({
52 | maxSize: 1 * 1024 * 1024,
53 | onError: (c) => {
54 | return c.json(Resp.fail("请求体过大(超过 1MB)"), HttpStatusCodes.REQUEST_TOO_LONG);
55 | },
56 | }));
57 |
58 | /** 8. 压缩和静态资源 */
59 | app.use(compress());
60 | app.use(serveEmojiFavicon("📝"));
61 |
62 | /** 9. 错误处理 */
63 | app.notFound(notFound);
64 | app.onError(onError);
65 |
66 | return app;
67 | }
68 |
69 | export function createTestApp() {
70 | const app = createRouter();
71 | app.use(requestId())
72 | .use(pinoLogger({ pino: logger }));
73 | app.notFound(notFound);
74 | app.onError(onError);
75 | return app;
76 | }
77 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | services:
2 | # PostgreSQL 18 数据库
3 | postgres:
4 | image: postgres:latest
5 | container_name: hono-postgres
6 | environment:
7 | POSTGRES_DB: ${DB_NAME:-postgres}
8 | POSTGRES_USER: ${DB_USER:-postgres}
9 | POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres}
10 | POSTGRES_INITDB_ARGS: --encoding=UTF-8 --locale=C
11 | ports:
12 | - "5432:5432"
13 | volumes:
14 | - postgres_data:/var/lib/postgresql
15 | networks:
16 | - app_network
17 | healthcheck:
18 | test: [CMD-SHELL, "pg_isready -U ${DB_USER:-postgres} -d ${DB_NAME:-postgres}"]
19 | interval: 10s
20 | timeout: 5s
21 | retries: 5
22 | restart: unless-stopped
23 |
24 | # Redis 缓存
25 | redis:
26 | image: redis:latest
27 | container_name: hono-redis
28 | ports:
29 | - "${REDIS_PORT:-6379}:6379"
30 | volumes:
31 | - redis_data:/data
32 | networks:
33 | - app_network
34 | healthcheck:
35 | test: [CMD, redis-cli, ping]
36 | interval: 10s
37 | timeout: 5s
38 | retries: 3
39 | restart: unless-stopped
40 |
41 | # Hono 应用
42 | app:
43 | platform: linux/amd64
44 | build:
45 | context: .
46 | dockerfile: Dockerfile
47 | args:
48 | NODE_ENV: development
49 | PORT: 9999
50 | container_name: hono-app
51 | ports:
52 | - "9999:9999"
53 | image: clhoria-template
54 | env_file:
55 | - .env
56 | environment:
57 | - NODE_ENV=${NODE_ENV:-development}
58 | - PORT=${PORT:-9999}
59 | - DATABASE_URL=${DATABASE_URL:-postgresql://postgres:postgres@postgres:5432/postgres}
60 | - REDIS_URL=${REDIS_URL:-redis://redis:6379/0}
61 | - LOG_LEVEL=${LOG_LEVEL:-debug}
62 | - CLIENT_JWT_SECRET=${CLIENT_JWT_SECRET}
63 | - ADMIN_JWT_SECRET=${ADMIN_JWT_SECRET}
64 | - ACCESS_KEY_ID=${ACCESS_KEY_ID}
65 | - SECRET_ACCESS_KEY=${SECRET_ACCESS_KEY}
66 | - ENDPOINT=${ENDPOINT}
67 | - BUCKET_NAME=${BUCKET_NAME}
68 | - SENTRY_DSN=${SENTRY_DSN}
69 | depends_on:
70 | postgres:
71 | condition: service_healthy
72 | redis:
73 | condition: service_healthy
74 | networks:
75 | - app_network
76 | restart: unless-stopped
77 |
78 | volumes:
79 | postgres_data:
80 | driver: local
81 | redis_data:
82 | driver: local
83 |
84 | networks:
85 | app_network:
86 | driver: bridge
87 |
--------------------------------------------------------------------------------
/tests/auth-utils.ts:
--------------------------------------------------------------------------------
1 | import type { JWTPayload } from "hono/utils/jwt/types";
2 |
3 | import { addDays, getUnixTime } from "date-fns";
4 | import { eq } from "drizzle-orm";
5 | import { sign } from "hono/jwt";
6 |
7 | import db from "@/db";
8 | import { systemUsers } from "@/db/schema";
9 | import env from "@/env";
10 |
11 | /** 缓存的token信息 */
12 | type CachedToken = {
13 | token: string;
14 | userId: string;
15 | };
16 |
17 | /** 单例缓存 */
18 | let adminTokenCache: CachedToken | null = null;
19 | let userTokenCache: CachedToken | null = null;
20 |
21 | /**
22 | * 生成测试用的JWT token
23 | */
24 | async function generateTestToken(username: string): Promise {
25 | // 查询用户信息
26 | const user = await db.query.systemUsers.findFirst({
27 | where: eq(systemUsers.username, username),
28 | with: {
29 | systemUserRoles: true,
30 | },
31 | columns: {
32 | id: true,
33 | username: true,
34 | },
35 | });
36 |
37 | if (!user) {
38 | throw new Error(`测试用户 ${username} 不存在`);
39 | }
40 |
41 | // 生成token payload
42 | const now = getUnixTime(new Date());
43 | const accessTokenExp = getUnixTime(addDays(new Date(), 7));
44 | const jti = crypto.randomUUID();
45 |
46 | const tokenPayload: JWTPayload = {
47 | sub: user.id,
48 | iat: now,
49 | exp: accessTokenExp,
50 | jti,
51 | roles: user.systemUserRoles.map(role => role.roleId),
52 | };
53 |
54 | // 生成token
55 | const token = await sign({ ...tokenPayload, type: "access" }, env.ADMIN_JWT_SECRET, "HS256");
56 |
57 | return {
58 | token,
59 | userId: user.id,
60 | };
61 | }
62 |
63 | /**
64 | * 获取admin测试token(单例模式)
65 | */
66 | export async function getAdminToken(): Promise {
67 | if (!adminTokenCache) {
68 | adminTokenCache = await generateTestToken("admin");
69 | }
70 | return adminTokenCache.token;
71 | }
72 |
73 | /**
74 | * 获取普通用户测试token(单例模式)
75 | */
76 | export async function getUserToken(): Promise {
77 | if (!userTokenCache) {
78 | userTokenCache = await generateTestToken("user");
79 | }
80 | return userTokenCache.token;
81 | }
82 |
83 | /**
84 | * 生成认证请求头
85 | */
86 | export function getAuthHeaders(token: string): Record {
87 | return {
88 | Authorization: `Bearer ${token}`,
89 | };
90 | }
91 |
92 | /**
93 | * 清除缓存的token(测试结束时调用)
94 | */
95 | export function clearTokenCache(): void {
96 | adminTokenCache = null;
97 | userTokenCache = null;
98 | }
99 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import { jwt } from "hono/jwt";
2 | import * as z from "zod";
3 |
4 | import configureOpenAPI from "@/lib/openapi";
5 | import * as allAdminExports from "@/routes/admin";
6 | import * as allClientExports from "@/routes/client";
7 | import * as allPublicExports from "@/routes/public";
8 |
9 | import env from "./env";
10 | import createApp from "./lib/create-app";
11 | import { authorize } from "./middlewares/authorize";
12 | import { operationLog } from "./middlewares/operation-log";
13 |
14 | // 配置 Zod 使用中文错误消息
15 | z.config(z.locales.zhCN());
16 |
17 | // 获取OpenAPIHono实例
18 | const { adminApp, clientApp, publicApp, configureMainDoc } = configureOpenAPI();
19 |
20 | // 创建主应用
21 | const app = createApp();
22 |
23 | // 配置文档主页(非生产环境)
24 | configureMainDoc?.(app);
25 |
26 | if (env.SENTRY_DSN) {
27 | const { sentry } = await import("@hono/sentry");
28 | app.use("*", sentry({ dsn: env.SENTRY_DSN }));
29 | }
30 |
31 | // #region 公共路由
32 | const publicRoutes = Object.values(allPublicExports);
33 | publicRoutes.forEach((route) => {
34 | publicApp.route("/", route);
35 | });
36 | // #endregion
37 |
38 | // #region 客户端路由
39 | const clientRoutes = Object.values(allClientExports);
40 | clientApp.use("/*", jwt({ secret: env.CLIENT_JWT_SECRET }));
41 | clientRoutes.forEach((route) => {
42 | clientApp.route("/", route);
43 | });
44 | // #endregion
45 |
46 | // #region 后管路由
47 | // tip: 如果你要用 trpc 请参考 https://github.com/honojs/hono/issues/2399#issuecomment-2675421823
48 |
49 | const { auth: authModule, ...otherAdminModules } = allAdminExports;
50 | const otherAdminRoutes = Object.values(otherAdminModules);
51 |
52 | // admin auth module 自己处理自己的 jwt 校验
53 | adminApp.route("/", authModule);
54 |
55 | adminApp.use("/*", jwt({ secret: env.ADMIN_JWT_SECRET }));
56 | adminApp.use("/*", authorize());
57 | adminApp.use("/*", operationLog({ moduleName: "后台管理", description: "后台管理操作日志" }));
58 |
59 | otherAdminRoutes.forEach((route) => {
60 | adminApp.route("/", route);
61 | });
62 | // #endregion
63 |
64 | /** 路由分组 顺序很重要,直接影响了中间件的执行顺序,公共路由必须放最前面 */
65 | app.route("/", publicApp);
66 | app.route("/", clientApp);
67 | app.route("/", adminApp);
68 |
69 | // 生产环境启动服务器
70 | if (import.meta.env.PROD) {
71 | const { serve } = await import("@hono/node-server");
72 | const logger = (await import("./lib/logger")).default;
73 |
74 | serve({ fetch: app.fetch, port: env.PORT });
75 | logger.info({ port: env.PORT }, "[服务]: 启动成功");
76 | }
77 |
78 | export default app;
79 |
--------------------------------------------------------------------------------
/src/middlewares/operation-log.ts:
--------------------------------------------------------------------------------
1 | import type { Context, MiddlewareHandler } from "hono";
2 | import type { JWTPayload } from "hono/utils/jwt/types";
3 |
4 | import { differenceInMilliseconds, format } from "date-fns";
5 |
6 | import env from "@/env";
7 | import { LogType } from "@/lib/enums";
8 | import logger from "@/lib/logger";
9 |
10 | /**
11 | * 操作日志中间件
12 | */
13 | export function operationLog(options: { moduleName: string; description: string }): MiddlewareHandler {
14 | return async (c: Context, next) => {
15 | const startTime = new Date();
16 | // 获取请求信息
17 | const method = c.req.method;
18 | const urlPath = new URL(c.req.url).pathname;
19 | const ip = c.req.header("x-forwarded-for") || c.req.header("x-real-ip") || "unknown";
20 | const userAgent = c.req.header("user-agent") || "";
21 |
22 | // 获取请求体和参数
23 | let [body, params]: [unknown | null, ParamsType | null] = [null, null];
24 |
25 | try {
26 | if (method !== "GET" && method !== "DELETE") {
27 | body = await c.req.json().catch(() => null);
28 | }
29 | params = c.req.query();
30 | }
31 | catch (error) {
32 | logger.warn({ error }, "请求体解析失败");
33 | }
34 |
35 | // 执行实际的处理
36 | await next();
37 |
38 | const requestId = c.get("requestId");
39 | const endTime = new Date();
40 | const durationMs = differenceInMilliseconds(endTime, startTime);
41 |
42 | // 获取用户信息
43 | const payload: JWTPayload | undefined = c.get("jwtPayload");
44 | if (!payload) {
45 | // 没有用户信息,不记录日志
46 | return;
47 | }
48 |
49 | const { sub: userId, username } = payload;
50 |
51 | // 获取响应信息
52 | let response: unknown = null;
53 | try {
54 | // 尝试获取响应体(如果是 JSON)
55 | const resClone = c.res.clone();
56 | response = await resClone.json().catch(() => null);
57 | }
58 | catch (error) {
59 | logger.warn({ error }, "响应体解析失败");
60 | }
61 |
62 | // 异步写入
63 | const logEntry = {
64 | type: LogType.OPERATION,
65 | requestId,
66 | moduleName: options.moduleName,
67 | description: options.description,
68 | method,
69 | urlPath,
70 | ip,
71 | userAgent,
72 | userId,
73 | username,
74 | body,
75 | params,
76 | response,
77 | startTime: format(startTime, "yyyy-MM-dd HH:mm:ss"),
78 | endTime: format(endTime, "yyyy-MM-dd HH:mm:ss"),
79 | durationMs, // 单位:毫秒
80 | };
81 |
82 | // 你可以选择你自己的日志写入方式 比如 阿里云 sls,并移除这个控制台输出日志
83 | if (env.NODE_ENV === "production") {
84 | logger.info(logEntry, "操作日志");
85 | }
86 | };
87 | }
88 |
--------------------------------------------------------------------------------
/src/routes/admin/system/users/schema.ts:
--------------------------------------------------------------------------------
1 | import { createInsertSchema, createSelectSchema } from "drizzle-zod";
2 | import { z } from "zod";
3 |
4 | import { systemUsers } from "@/db/schema";
5 |
6 | export const selectSystemUsers = createSelectSchema(systemUsers, {
7 | id: schema => schema.meta({ description: "用户ID" }),
8 | username: schema => schema.meta({ description: "用户名" }),
9 | password: schema => schema.meta({ description: "密码" }),
10 | builtIn: schema => schema.meta({ description: "是否内置用户" }),
11 | avatar: schema => schema.meta({ description: "头像" }),
12 | nickName: schema => schema.meta({ description: "昵称" }),
13 | status: schema => schema.meta({ description: "状态 (ENABLED=启用, DISABLED=禁用)" }),
14 | });
15 |
16 | export const insertSystemUsers = createInsertSchema(systemUsers, {
17 | username: schema => schema.min(4).max(15).regex(/^\w+$/).meta({ description: "用户名" }),
18 | password: schema => schema.min(6).max(20).meta({ description: "密码" }),
19 | nickName: schema => schema.min(1).meta({ description: "昵称" }),
20 | }).omit({
21 | id: true,
22 | createdAt: true,
23 | createdBy: true,
24 | updatedAt: true,
25 | updatedBy: true,
26 | builtIn: true, // 系统字段,不允许用户设置
27 | });
28 |
29 | export const patchSystemUsers = insertSystemUsers.partial().refine(
30 | data => Object.keys(data).length > 0,
31 | { message: "至少需要提供一个字段进行更新" },
32 | );
33 |
34 | /** 用于响应的 schema(不包含密码) */
35 | export const responseSystemUsersWithoutPassword = selectSystemUsers.omit({ password: true });
36 |
37 | /** 用于响应的schema(包含密码) */
38 | export const responseSystemUsersWithPassword = selectSystemUsers.extend({
39 | roles: z.array(z.object({
40 | id: z.string().min(1).max(64).meta({ description: "角色ID" }),
41 | name: z.string().min(1).max(64).meta({ description: "角色名称" }),
42 | })).meta({ description: "用户角色" }),
43 | });
44 |
45 | /** 用于响应的单个 不包含密码 */
46 | export const responseSystemUsersWithoutPasswordAndRoles = responseSystemUsersWithPassword.omit({ password: true });
47 |
48 | /** 用于响应的 列表(不包含密码) */
49 | export const responseSystemUsersWithList = z.array(responseSystemUsersWithoutPasswordAndRoles);
50 |
51 | /** 用于登录的 schema(仅包含 username,password,domain ) */
52 | export const loginSystemUsers = insertSystemUsers.pick({
53 | username: true,
54 | password: true,
55 | }).extend({
56 | captchaToken: z.string().min(1).meta({ description: "验证码token" }),
57 | });
58 |
59 | /** 用于获取用户信息的 schema,支持拓展,如果后续新增或者联表可以继续拓展 */
60 | export const getUserInfoSchema = responseSystemUsersWithoutPassword.pick({
61 | id: true,
62 | username: true,
63 | avatar: true,
64 | nickName: true,
65 | }).extend({
66 | roles: z.array(z.string()).meta({ description: "用户角色" }),
67 | });
68 |
--------------------------------------------------------------------------------
/src/lib/enums/common.ts:
--------------------------------------------------------------------------------
1 | /** 通用状态枚举,用于表示实体的启用/禁用状态 */
2 | export const Status = {
3 | /** 启用状态 */
4 | ENABLED: "ENABLED",
5 |
6 | /** 禁用状态 */
7 | DISABLED: "DISABLED",
8 | } as const;
9 |
10 | /** 状态类型 */
11 | export type StatusType = (typeof Status)[keyof typeof Status];
12 |
13 | /** 日志类型枚举 */
14 | export const LogType = {
15 | /** 操作日志 */
16 | OPERATION: "OPERATION",
17 |
18 | /** 登录日志 */
19 | LOGIN: "LOGIN",
20 | } as const;
21 |
22 | /** 日志类型类型 */
23 | export type LogTypeType = (typeof LogType)[keyof typeof LogType];
24 |
25 | /** 性别枚举 */
26 | export const Gender = {
27 | /** 未知 */
28 | UNKNOWN: "UNKNOWN",
29 |
30 | /** 男性 */
31 | MALE: "MALE",
32 |
33 | /** 女性 */
34 | FEMALE: "FEMALE",
35 | } as const;
36 |
37 | /** 性别类型 */
38 | export type GenderType = (typeof Gender)[keyof typeof Gender];
39 |
40 | /** 用户状态枚举 */
41 | export const UserStatus = {
42 | /** 正常 */
43 | NORMAL: "NORMAL",
44 |
45 | /** 禁用 */
46 | DISABLED: "DISABLED",
47 |
48 | /** 审核中 */
49 | PENDING: "PENDING",
50 |
51 | /** 审核拒绝 */
52 | REJECTED: "REJECTED",
53 | } as const;
54 |
55 | /** 用户状态类型 */
56 | export type UserStatusType = (typeof UserStatus)[keyof typeof UserStatus];
57 |
58 | /** 验证状态枚举 */
59 | export const VerificationStatus = {
60 | /** 未验证 */
61 | UNVERIFIED: "UNVERIFIED",
62 |
63 | /** 已验证 */
64 | VERIFIED: "VERIFIED",
65 | } as const;
66 |
67 | /** 验证状态类型 */
68 | export type VerificationStatusType = (typeof VerificationStatus)[keyof typeof VerificationStatus];
69 |
70 | /** 实名认证类型枚举 */
71 | export const RealNameAuthType = {
72 | /** 个人用户 */
73 | INDIVIDUAL: "INDIVIDUAL",
74 |
75 | /** 企业用户 */
76 | ENTERPRISE: "ENTERPRISE",
77 | } as const;
78 |
79 | /** 实名认证类型 */
80 | export type RealNameAuthTypeType = (typeof RealNameAuthType)[keyof typeof RealNameAuthType];
81 |
82 | /** 实名认证状态枚举 */
83 | export const RealNameAuthStatus = {
84 | /** 未认证 */
85 | UNAUTHENTICATED: "UNAUTHENTICATED",
86 |
87 | /** 等待认证 */
88 | PENDING: "PENDING",
89 |
90 | /** 认证通过 */
91 | VERIFIED: "VERIFIED",
92 |
93 | /** 认证失败 */
94 | FAILED: "FAILED",
95 | } as const;
96 |
97 | /** 实名认证状态类型 */
98 | export type RealNameAuthStatusType = (typeof RealNameAuthStatus)[keyof typeof RealNameAuthStatus];
99 |
100 | /** 应用名称枚举, 用于标识不同的应用程序路由 */
101 | export const AppNameMenu = {
102 | /** 后台管理路由 */
103 | ADMIN_APP: "adminApp",
104 |
105 | /** 客户端路由 */
106 | CLIENT_APP: "clientApp",
107 |
108 | /** 公共路由 */
109 | PUBLIC_APP: "publicApp",
110 | } as const;
111 |
112 | /** 应用名称类型 */
113 | export type AppNameType = (typeof AppNameMenu)[keyof typeof AppNameMenu];
114 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "clhoria-template",
3 | "type": "module",
4 | "version": "1.0.0",
5 | "packageManager": "pnpm@10.26.1",
6 | "license": "MIT",
7 | "scripts": {
8 | "preinstall": "npx only-allow pnpm",
9 | "postinstall": "test -f node_modules/.bin/simple-git-hooks && simple-git-hooks",
10 | "dev": "vite",
11 | "start": "cross-env NODE_ENV=production dotenvx run -- node dist/index.mjs",
12 | "typecheck": "tsc --noEmit",
13 | "lint": "eslint",
14 | "lint:fix": "eslint --fix",
15 | "test": "cross-env LOG_LEVEL=silent vitest",
16 | "build": "cross-env NODE_ENV=production vite build",
17 | "generate": "drizzle-kit generate --config drizzle.config.ts",
18 | "push": "drizzle-kit push",
19 | "studio": "drizzle-kit studio --config drizzle.config.ts",
20 | "seed": "tsx migrations/seed/index.ts",
21 | "migrate": "drizzle-kit migrate",
22 | "prepare": "simple-git-hooks",
23 | "check": "pnpm lint && pnpm typecheck && pnpm test"
24 | },
25 | "dependencies": {
26 | "@asteasolutions/zod-to-openapi": "^8.2.0",
27 | "@aws-sdk/client-s3": "^3.955.0",
28 | "@aws-sdk/s3-request-presigner": "^3.955.0",
29 | "@cap.js/server": "^4.0.5",
30 | "@dotenvx/dotenvx": "^1.51.2",
31 | "@hono/node-server": "1.19.7",
32 | "@hono/node-ws": "^1.2.0",
33 | "@hono/sentry": "^1.2.2",
34 | "@hono/zod-openapi": "^1.1.6",
35 | "@node-rs/argon2": "^2.0.2",
36 | "@scalar/hono-api-reference": "^0.9.30",
37 | "casbin": "^5.45.0",
38 | "croner": "^9.1.0",
39 | "date-fns": "^4.1.0",
40 | "drizzle-orm": "^0.45.1",
41 | "drizzle-zod": "^0.8.3",
42 | "hono": "^4.11.1",
43 | "hono-pino": "^0.10.3",
44 | "hono-rate-limiter": "^0.5.1",
45 | "iconv-lite": "^0.7.1",
46 | "ioredis": "5.8.2",
47 | "pg-boss": "^12.5.3",
48 | "pino": "^10.1.0",
49 | "postgres": "^3.4.7",
50 | "rate-limit-redis": "^4.3.1",
51 | "zod": "4.2.1"
52 | },
53 | "devDependencies": {
54 | "@antfu/eslint-config": "^6.7.1",
55 | "@hono/vite-dev-server": "^0.23.0",
56 | "@types/node": "25.0.3",
57 | "cross-env": "^10.1.0",
58 | "drizzle-kit": "^0.31.8",
59 | "eslint": "^9.39.2",
60 | "eslint-plugin-format": "^1.1.0",
61 | "lint-staged": "^16.2.7",
62 | "pino-pretty": "^13.1.3",
63 | "simple-git-hooks": "^2.13.1",
64 | "type-fest": "^5.3.1",
65 | "typescript": "5.9.3",
66 | "vite": "8.0.0-beta.3",
67 | "vitest": "^4.0.16"
68 | },
69 | "simple-git-hooks": {
70 | "pre-commit": "pnpm lint-staged"
71 | },
72 | "lint-staged": {
73 | "*.{ts,tsx,js,jsx}": [
74 | "pnpm lint:fix",
75 | "bash -c 'pnpm typecheck'"
76 | ]
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/src/lib/refine-query/pagination.ts:
--------------------------------------------------------------------------------
1 | import type { Simplify } from "type-fest";
2 |
3 | import { DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE } from "@/lib/constants";
4 |
5 | import type { Pagination } from "./schemas";
6 |
7 | /**
8 | * 分页计算结果接口
9 | */
10 | export type PaginationCalculation = Simplify<{
11 | /** 偏移量(跳过的记录数) */
12 | offset: number;
13 | /** 限制数量(每页记录数) */
14 | limit: number;
15 | /** 当前页码 */
16 | current: number;
17 | /** 每页大小 */
18 | pageSize: number;
19 | /** 分页模式 */
20 | mode: "client" | "server" | "off";
21 | }>;
22 |
23 | /**
24 | * 分页处理器类
25 | */
26 | export class PaginationHandler {
27 | private defaultPageSize = DEFAULT_PAGE_SIZE;
28 | private maxPageSize = MAX_PAGE_SIZE;
29 |
30 | /**
31 | * 计算分页参数
32 | */
33 | calculate(pagination?: Pagination): PaginationCalculation {
34 | const mode = pagination?.mode ?? "server";
35 | const current = Math.max(1, pagination?.current ?? 1);
36 | const pageSize = Math.min(
37 | this.maxPageSize,
38 | Math.max(1, pagination?.pageSize ?? this.defaultPageSize),
39 | );
40 |
41 | const offset = (current - 1) * pageSize;
42 | const limit = pageSize;
43 |
44 | return {
45 | offset,
46 | limit,
47 | current,
48 | pageSize,
49 | mode,
50 | };
51 | }
52 |
53 | /**
54 | * 验证分页参数
55 | */
56 | validate(pagination?: Pagination): Readonly<{ valid: boolean; errors: readonly string[] }> {
57 | const errors: string[] = [];
58 |
59 | if (pagination) {
60 | if (pagination.current !== undefined) {
61 | if (pagination.current < 1) {
62 | errors.push("当前页码必须大于等于1");
63 | }
64 | if (!Number.isInteger(pagination.current)) {
65 | errors.push("当前页码必须是整数");
66 | }
67 | }
68 |
69 | if (pagination.pageSize !== undefined) {
70 | if (pagination.pageSize < 1) {
71 | errors.push("每页大小必须大于等于1");
72 | }
73 | if (pagination.pageSize > this.maxPageSize) {
74 | errors.push(`每页大小不能超过${this.maxPageSize}`);
75 | }
76 | if (!Number.isInteger(pagination.pageSize)) {
77 | errors.push("每页大小必须是整数");
78 | }
79 | }
80 |
81 | if (pagination.mode !== undefined) {
82 | const validModes = ["client", "server", "off"] as const;
83 | if (!validModes.includes(pagination.mode)) {
84 | errors.push(`分页模式必须是: ${validModes.join(", ")}`);
85 | }
86 | }
87 | }
88 |
89 | return {
90 | valid: errors.length === 0,
91 | errors,
92 | };
93 | }
94 | }
95 |
96 | // 全局分页处理器实例
97 | export const paginationHandler = new PaginationHandler();
98 |
99 | /**
100 | * 便捷函数:计算分页参数
101 | */
102 | export function calculatePagination(pagination?: Pagination): PaginationCalculation {
103 | return paginationHandler.calculate(pagination);
104 | }
105 |
106 | /**
107 | * 便捷函数:验证分页参数
108 | */
109 | export function validatePagination(pagination?: Pagination): Readonly<{ valid: boolean; errors: readonly string[] }> {
110 | return paginationHandler.validate(pagination);
111 | }
112 |
--------------------------------------------------------------------------------
/src/lib/openapi/helper.ts:
--------------------------------------------------------------------------------
1 | import { Scalar } from "@scalar/hono-api-reference";
2 |
3 | import type { AppNameType } from "@/lib/enums";
4 | import type { AppOpenAPI } from "@/types/lib";
5 |
6 | import type { AppConfig, ScalarAuthentication, ScalarSource } from "./types";
7 |
8 | import packageJSON from "../../../package.json" with { type: "json" };
9 | import { createRouter } from "../create-app";
10 | import { API_BASE_PATH, API_PUBLIC_NAME, APP_CONFIG, DOC_ENDPOINT, OPENAPI_VERSION, SCALAR_CONFIG } from "./config";
11 |
12 | export function createApps(): Record {
13 | return APP_CONFIG.reduce((acc, config) => {
14 | const path = config.basePath ?? (config.name === API_PUBLIC_NAME ? API_BASE_PATH : `${API_BASE_PATH}/${config.name}`);
15 | acc[`${config.name}App` as AppNameType] = createRouter().basePath(path);
16 | return acc;
17 | }, {} as Record);
18 | }
19 |
20 | export function registerSecurityScheme(router: AppOpenAPI, config: AppConfig) {
21 | const securityName = `${config.name}Bearer`;
22 | router.openAPIRegistry.registerComponent("securitySchemes", securityName, {
23 | type: "http",
24 | scheme: "bearer",
25 | });
26 | return securityName;
27 | }
28 |
29 | export function configureAppDocumentation(router: AppOpenAPI, config: AppConfig) {
30 | const docConfig = {
31 | openapi: OPENAPI_VERSION,
32 | info: { version: packageJSON.version, title: config.title },
33 | };
34 |
35 | if (config.token) {
36 | const securityName = registerSecurityScheme(router, config);
37 | router.doc31(DOC_ENDPOINT, {
38 | ...docConfig,
39 | security: [{ [securityName]: [] }],
40 | });
41 | }
42 | else {
43 | router.doc31(DOC_ENDPOINT, docConfig);
44 | }
45 | }
46 |
47 | export function createScalarSources(): ScalarSource[] {
48 | return APP_CONFIG.map((config, i) => ({
49 | title: config.title,
50 | slug: config.name,
51 | url: config.basePath ?? (config.name === API_PUBLIC_NAME
52 | ? `${API_BASE_PATH}${DOC_ENDPOINT}`
53 | : `${API_BASE_PATH}/${config.name}${DOC_ENDPOINT}`),
54 | default: i === 0,
55 | }));
56 | }
57 |
58 | export function createScalarAuthentication(): ScalarAuthentication {
59 | return {
60 | securitySchemes: APP_CONFIG.reduce((acc, config) => {
61 | if (config.token) {
62 | acc[`${config.name}Bearer`] = { token: config.token };
63 | }
64 | return acc;
65 | }, {} as Record),
66 | };
67 | }
68 |
69 | export function configureSubApplications(apps: Record) {
70 | APP_CONFIG.forEach((config) => {
71 | const router = apps[`${config.name}App` as AppNameType];
72 | configureAppDocumentation(router, config);
73 | });
74 | }
75 |
76 | export function configureMainDocumentation(app: AppOpenAPI) {
77 | app.get("/", Scalar({
78 | ...SCALAR_CONFIG,
79 | sources: createScalarSources(),
80 | authentication: createScalarAuthentication(),
81 | }));
82 | }
83 |
84 | export function createMainDocConfigurator(apps: Record) {
85 | return (app: AppOpenAPI) => {
86 | configureSubApplications(apps);
87 | configureMainDocumentation(app);
88 | };
89 | }
90 |
--------------------------------------------------------------------------------
/src/lib/cap.ts:
--------------------------------------------------------------------------------
1 | import Cap from "@cap.js/server";
2 | // 导入date-fns核心函数
3 | import { differenceInSeconds, isValid } from "date-fns";
4 |
5 | import redisClient from "@/lib/redis";
6 |
7 | /** 生成带前缀的Redis Key */
8 | function getRedisKey(type: "challenge" | "token", id: string): string {
9 | return `cap:${type}:${id}`; // 统一前缀格式:cap:类型:唯一标识
10 | }
11 |
12 | /** 计算TTL秒数(基于date-fns,增强鲁棒性) */
13 | function calculateTtlSeconds(expires: number | Date): number {
14 | // 校验expires是否为合法日期(避免无效时间导致的异常)
15 | const expiresDate = typeof expires === "number" ? new Date(expires) : expires;
16 | if (!isValid(expiresDate))
17 | return 0;
18 |
19 | // 计算当前时间与过期时间的秒数差(语义化清晰)
20 | const ttl = differenceInSeconds(expiresDate, new Date());
21 | // 确保TTL不小于0(过期数据直接返回0,不存入Redis)
22 | return Math.max(ttl, 0);
23 | }
24 |
25 | /**
26 | * Cap.js 配置
27 | * @description 使用 Redis 作为存储
28 | */
29 | const cap = new Cap({
30 | storage: {
31 | challenges: {
32 | store: async (token, challengeData) => {
33 | // 复用工具函数生成Key和计算TTL
34 | const key = getRedisKey("challenge", token);
35 | const ttlSeconds = calculateTtlSeconds(challengeData.expires);
36 |
37 | if (ttlSeconds > 0) {
38 | await redisClient.setex(key, ttlSeconds, JSON.stringify(challengeData));
39 | }
40 | },
41 |
42 | read: async (token) => {
43 | const key = getRedisKey("challenge", token);
44 | const data = await redisClient.get(key);
45 |
46 | if (!data)
47 | return null;
48 |
49 | const challengeData = JSON.parse(data);
50 | // 此处可额外用isValid校验challengeData.expires,增强鲁棒性
51 | if (!isValid(new Date(challengeData.expires)))
52 | return null;
53 |
54 | return {
55 | challenge: challengeData,
56 | expires: challengeData.expires,
57 | };
58 | },
59 |
60 | delete: async (token) => {
61 | const key = getRedisKey("challenge", token);
62 | await redisClient.del(key);
63 | },
64 |
65 | deleteExpired: async () => {
66 | // Redis自动过期,无需手动清理
67 | },
68 | },
69 |
70 | tokens: {
71 | store: async (tokenKey, expires) => {
72 | // 复用工具函数,逻辑与challenges.store统一
73 | const key = getRedisKey("token", tokenKey);
74 | const ttlSeconds = calculateTtlSeconds(expires);
75 |
76 | if (ttlSeconds > 0) {
77 | // 存入过期时间的字符串形式(保持原逻辑)
78 | await redisClient.setex(key, ttlSeconds, expires.toString());
79 | }
80 | },
81 |
82 | get: async (tokenKey) => {
83 | const key = getRedisKey("token", tokenKey);
84 | const expiresStr = await redisClient.get(key);
85 |
86 | if (!expiresStr)
87 | return null;
88 |
89 | const expires = Number.parseInt(expiresStr, 10);
90 | // 校验解析后的过期时间是否合法(避免无效数值)
91 | return isValid(new Date(expires)) ? expires : null;
92 | },
93 |
94 | delete: async (tokenKey) => {
95 | const key = getRedisKey("token", tokenKey);
96 | await redisClient.del(key);
97 | },
98 |
99 | deleteExpired: async () => {
100 | // Redis自动过期,无需手动清理
101 | },
102 | },
103 | },
104 | });
105 |
106 | export default cap;
107 |
--------------------------------------------------------------------------------
/src/lib/rate-limit-factory.ts:
--------------------------------------------------------------------------------
1 | import type { Context } from "hono";
2 | import type { Store } from "hono-rate-limiter";
3 | import type { RedisReply } from "rate-limit-redis";
4 |
5 | import { rateLimiter } from "hono-rate-limiter";
6 | import { RedisStore } from "rate-limit-redis";
7 | import { z } from "zod";
8 |
9 | import type { AppBindings } from "@/types/lib";
10 |
11 | import redisClient from "@/lib/redis";
12 |
13 | /**
14 | * Redis 存储实例 (全局单例)
15 | */
16 | const ioredisStore = new RedisStore({
17 | sendCommand: (...args) => {
18 | const [command, ...commandArgs] = args;
19 | return redisClient.call(command, ...commandArgs) as Promise;
20 | },
21 | }) as unknown as Store;
22 |
23 | /**
24 | * IP地址验证器 (Zod v4 - 支持 IPv4 和 IPv6)
25 | */
26 | const ipv4Schema = z.ipv4();
27 | const ipv6Schema = z.ipv6();
28 |
29 | /**
30 | * 验证并返回有效的IP地址
31 | * @param ip 待验证的IP字符串
32 | * @returns 验证通过返回IP,失败返回null
33 | */
34 | function validateIp(ip: string): string | null {
35 | // 先尝试 IPv4
36 | const ipv4Result = ipv4Schema.safeParse(ip);
37 | if (ipv4Result.success)
38 | return ipv4Result.data;
39 |
40 | // 再尝试 IPv6
41 | const ipv6Result = ipv6Schema.safeParse(ip);
42 | if (ipv6Result.success)
43 | return ipv6Result.data;
44 |
45 | return null;
46 | }
47 |
48 | /**
49 | * 获取客户端真实标识
50 | * 根据环境变量 TRUST_PROXY 决定是否信任代理头部
51 | */
52 | function getClientIdentifier(c: Context) {
53 | // 1. X-Forwarded-For
54 | const xff = c.req.header("X-Forwarded-For");
55 | if (xff) {
56 | const ip = xff.split(",")[0].trim();
57 | if (validateIp(ip))
58 | return ip;
59 | }
60 |
61 | // 2. X-Real-IP
62 | const real = c.req.header("X-Real-IP");
63 | if (real && validateIp(real))
64 | return real;
65 |
66 | // 3. Node.js socket
67 | // @ts-expect-error: Node.js adapter adds `incoming` but TS defs don’t include it
68 | const incoming = c.req.raw?.incoming;
69 | const socketIp
70 | = incoming?.socket?.remoteAddress
71 | || incoming?.connection?.remoteAddress;
72 |
73 | if (socketIp && validateIp(socketIp))
74 | return socketIp;
75 |
76 | return "0.0.0.0";
77 | }
78 |
79 | /**
80 | * 速率限制配置选项
81 | */
82 | export type RateLimitOptions = {
83 | /** 时间窗口(毫秒) */
84 | windowMs: number;
85 | /** 最大请求数 */
86 | limit: number;
87 | /** 自定义key生成器 (可选,默认使用IP) */
88 | keyGenerator?: (c: Context) => string;
89 | /** 是否跳过成功的请求计数 (默认false) */
90 | skipSuccessfulRequests?: boolean;
91 | /** 是否跳过失败的请求计数 (默认false) */
92 | skipFailedRequests?: boolean;
93 | };
94 |
95 | /**
96 | * 创建速率限制中间件
97 | * @param options 速率限制配置
98 | * @returns Hono 中间件
99 | */
100 | export function createRateLimiter(options: RateLimitOptions) {
101 | return rateLimiter({
102 | windowMs: options.windowMs,
103 | limit: options.limit,
104 | standardHeaders: "draft-6", // 返回 RateLimit-* 响应头
105 | keyGenerator: options.keyGenerator ?? getClientIdentifier,
106 | store: ioredisStore,
107 | skipSuccessfulRequests: options.skipSuccessfulRequests ?? false,
108 | skipFailedRequests: options.skipFailedRequests ?? false,
109 | });
110 | }
111 |
112 | /** 默认全局速率限制配置 (每1分钟100次) */
113 | export const DEFAULT_RATE_LIMIT: RateLimitOptions = {
114 | windowMs: 1 * 60 * 1000,
115 | limit: 100,
116 | };
117 |
--------------------------------------------------------------------------------
/src/db/schema/_shard/types/app-user.ts:
--------------------------------------------------------------------------------
1 | /** 微信各平台 openid 接口 */
2 | export type WxOpenId = {
3 | /** app平台微信openid */
4 | app?: string;
5 | /** 微信小程序平台openid */
6 | mp?: string;
7 | /** 微信网页应用openid */
8 | web?: string;
9 | /** 微信公众号应用openid */
10 | h5?: string;
11 | };
12 |
13 | /** QQ各平台 openid 接口 */
14 | export type QqOpenId = {
15 | /** app平台QQ openid */
16 | app?: string;
17 | /** QQ小程序平台openid */
18 | mp?: string;
19 | };
20 |
21 | /** 第三方平台信息接口 */
22 | export type ThirdPartyInfo = {
23 | /** 微信小程序相关信息 */
24 | mpWeixin?: {
25 | /** 微信小程序session key */
26 | sessionKey?: string;
27 | };
28 | /** app平台微信相关信息 */
29 | appWeixin?: {
30 | /** app平台微信access token */
31 | accessToken?: string;
32 | /** app平台微信access token过期时间 */
33 | accessTokenExpired?: string;
34 | /** app平台微信refresh token */
35 | refreshToken?: string;
36 | };
37 | /** 微信公众号平台微信相关信息 */
38 | h5Weixin?: {
39 | /** 微信公众号平台access token */
40 | accessToken?: string;
41 | /** 微信公众号平台access token过期时间 */
42 | accessTokenExpired?: string;
43 | /** 微信公众号平台refresh token */
44 | refreshToken?: string;
45 | };
46 | /** web平台微信相关信息 */
47 | webWeixin?: {
48 | /** web平台微信access token */
49 | accessToken?: string;
50 | /** web平台微信access token过期时间 */
51 | accessTokenExpired?: string;
52 | /** web平台微信refresh token */
53 | refreshToken?: string;
54 | };
55 | /** QQ小程序相关信息 */
56 | mpQq?: {
57 | /** QQ小程序session key */
58 | sessionKey?: string;
59 | };
60 | /** app平台QQ相关信息 */
61 | appQq?: {
62 | /** app平台QQ access token */
63 | accessToken?: string;
64 | /** app平台QQ access token过期时间 */
65 | accessTokenExpired?: string;
66 | };
67 | };
68 |
69 | /**
70 | * 注册环境信息接口
71 | * 注意:该字段仅记录前端用户注册时的前端环境信息,管理员通过云端添加用户则无此字段
72 | */
73 | export type RegisterEnv = {
74 | /** 注册时的客户端appId */
75 | appid?: string;
76 | /** 注册时的客户端平台,如 h5、app、mp-weixin 等 */
77 | uniPlatform?: string;
78 | /** 注册时的客户端系统名,如 ios、android、windows、mac、linux */
79 | osName?: string;
80 | /** 注册时的客户端名称 */
81 | appName?: string;
82 | /** 注册时的客户端版本 */
83 | appVersion?: string;
84 | /** 注册时的客户端版本号 */
85 | appVersionCode?: string;
86 | /** 注册时的客户端启动场景(小程序)或应用渠道(app) */
87 | channel?: string;
88 | /** 注册时的客户端IP */
89 | clientIp?: string;
90 | };
91 |
92 | /** 实名认证信息接口 */
93 | export type RealNameAuth = {
94 | /** 用户类型:0 个人用户 1 企业用户 */
95 | type: number;
96 | /** 认证状态:0 未认证 1 等待认证 2 认证通过 3 认证失败 */
97 | authStatus: number;
98 | /** 认证通过时间(ISO字符串) */
99 | authDate?: string;
100 | /** 真实姓名/企业名称 */
101 | realName?: string;
102 | /** 身份证号码/营业执照号码 */
103 | identity?: string;
104 | /** 身份证正面照 URL */
105 | idCardFront?: string;
106 | /** 身份证反面照 URL */
107 | idCardBack?: string;
108 | /** 手持身份证照片 URL */
109 | idCardInHand?: string;
110 | /** 营业执照 URL */
111 | license?: string;
112 | /** 联系人姓名 */
113 | contactPerson?: string;
114 | /** 联系人手机号码 */
115 | contactMobile?: string;
116 | /** 联系人邮箱 */
117 | contactEmail?: string;
118 | };
119 |
120 | /** 第三方平台身份信息接口 */
121 | export type Identity = {
122 | /** 身份源 */
123 | provider?: string;
124 | /** 三方用户信息 */
125 | userInfo?: Record;
126 | /** 三方openid */
127 | openid?: string;
128 | /** 三方unionid */
129 | unionid?: string;
130 | /** 三方uid */
131 | uid?: string;
132 | };
133 |
--------------------------------------------------------------------------------
/src/lib/stoker/openapi/default-hook.test.ts:
--------------------------------------------------------------------------------
1 | import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
2 | import { describe, expect, it } from "vitest";
3 |
4 | import * as HttpStatusCodes from "../http-status-codes.js";
5 | import defaultHook from "./default-hook.js";
6 |
7 | const UserSchema = z.object({
8 | name: z.string(),
9 | age: z.number(),
10 | });
11 |
12 | describe("default-hook", () => {
13 | it("returns 422 and error body for failed validation", async () => {
14 | const app = new OpenAPIHono({ defaultHook });
15 | app.openapi(
16 | createRoute({
17 | method: "post",
18 | path: "/users",
19 | request: {
20 | body: {
21 | content: {
22 | "application/json": {
23 | schema: UserSchema,
24 | },
25 | },
26 | description: "Create user",
27 | },
28 | },
29 | responses: {
30 | [HttpStatusCodes.UNPROCESSABLE_ENTITY]: {
31 | content: {
32 | "application/json": {
33 | schema: z.object({
34 | success: z.boolean(),
35 | error: z.object({
36 | name: z.string(),
37 | issues: z.array(z.any()),
38 | }),
39 | }),
40 | },
41 | },
42 | description: "Validation error",
43 | },
44 | },
45 | }),
46 | // @ts-expect-error will not reach here
47 | (c) => {
48 | // Should not reach here if validation fails
49 | return c.json({ ok: true });
50 | },
51 | );
52 |
53 | const res = await app.request("/users", {
54 | method: "POST",
55 | body: JSON.stringify({ name: 123, age: "not-a-number" }),
56 | headers: { "content-type": "application/json" },
57 | });
58 |
59 | expect(res.status).toBe(HttpStatusCodes.UNPROCESSABLE_ENTITY);
60 |
61 | const json = await res.json() as any;
62 |
63 | expect(json.success).toBe(false);
64 | expect(json.error).toBeDefined();
65 | expect(json.error.name).toBe("ZodError");
66 | expect(Array.isArray(json.error.issues)).toBe(true);
67 | expect(json.error.issues.length).toBeGreaterThan(0);
68 | });
69 |
70 | it("does not trigger hook for valid input", async () => {
71 | const app = new OpenAPIHono({ defaultHook });
72 | app.openapi(
73 | createRoute({
74 | method: "post",
75 | path: "/users",
76 | request: {
77 | body: {
78 | content: {
79 | "application/json": {
80 | schema: UserSchema,
81 | },
82 | },
83 | description: "Create user",
84 | },
85 | },
86 | responses: {
87 | [HttpStatusCodes.OK]: {
88 | content: {
89 | "application/json": {
90 | schema: UserSchema,
91 | },
92 | },
93 | description: "User created",
94 | },
95 | },
96 | }),
97 | (c) => {
98 | const user = c.req.valid("json");
99 | return c.json(user, HttpStatusCodes.OK);
100 | },
101 | );
102 |
103 | const res = await app.request("/users", {
104 | method: "POST",
105 | body: JSON.stringify({ name: "Alice", age: 30 }),
106 | headers: { "content-type": "application/json" },
107 | });
108 |
109 | expect(res.status).toBe(HttpStatusCodes.OK);
110 |
111 | const json = await res.json() as any;
112 |
113 | expect(json.name).toBe("Alice");
114 | expect(json.age).toBe(30);
115 | });
116 | });
117 |
--------------------------------------------------------------------------------
/src/services/admin/token.ts:
--------------------------------------------------------------------------------
1 | import { addDays, addMinutes, differenceInSeconds, getUnixTime } from "date-fns";
2 | import { sign } from "hono/jwt";
3 | import crypto from "node:crypto";
4 |
5 | import env from "@/env";
6 | import { ACCESS_TOKEN_EXPIRES_MINUTES, REFRESH_TOKEN_EXPIRES_DAYS } from "@/lib/constants";
7 | import redisClient from "@/lib/redis";
8 |
9 | // ===== 配置 =====
10 | const ACCESS_TOKEN_SECRET = env.ADMIN_JWT_SECRET;
11 | const ACCESS_TOKEN_DURATION = { minutes: ACCESS_TOKEN_EXPIRES_MINUTES };
12 | const REFRESH_TOKEN_DURATION = { days: REFRESH_TOKEN_EXPIRES_DAYS };
13 |
14 | // 工具: 根据配置计算过期时间
15 | function calculateExpiration(duration: { minutes?: number; days?: number; hours?: number }) {
16 | const now = new Date();
17 |
18 | switch (true) {
19 | case !!duration.minutes:
20 | return addMinutes(now, duration.minutes);
21 | case !!duration.days:
22 | return addDays(now, duration.days);
23 | case !!duration.hours:
24 | return addMinutes(now, duration.hours * 60);
25 | }
26 |
27 | throw new Error("Invalid duration configuration");
28 | }
29 |
30 | type UserTokenInfo = {
31 | id: string | number;
32 | roles: string[];
33 | };
34 |
35 | // 计算 TTL 秒数
36 | const REFRESH_TTL_SECONDS = differenceInSeconds(
37 | calculateExpiration(REFRESH_TOKEN_DURATION),
38 | new Date(),
39 | );
40 |
41 | // ===== Redis Key 约定 =====
42 | const refreshKey = (token: string) => `rt:${token}`;
43 | const refreshIndexKey = (userId: string | number) => `rtidx:${userId}`;
44 |
45 | // ===== Access Token 生成 =====
46 | async function generateAccessToken(user: UserTokenInfo) {
47 | const expirationDate = calculateExpiration(ACCESS_TOKEN_DURATION);
48 |
49 | const iat = getUnixTime(new Date());
50 | const exp = getUnixTime(expirationDate);
51 |
52 | return await sign(
53 | {
54 | roles: user.roles,
55 | sub: user.id,
56 | iat, // 签发时间(Unix 秒级时间戳)
57 | exp, // 过期时间(Unix 秒级时间戳)
58 | },
59 | ACCESS_TOKEN_SECRET,
60 | );
61 | }
62 |
63 | // ===== Refresh Token 生成 =====
64 | async function generateRefreshToken(user: UserTokenInfo) {
65 | const token = crypto.randomBytes(32).toString("hex"); // 256 bit
66 | const pipeline = redisClient.pipeline();
67 | pipeline.set(refreshKey(token), JSON.stringify(user), "EX", REFRESH_TTL_SECONDS);
68 | pipeline.sadd(refreshIndexKey(user.id), token);
69 | await pipeline.exec();
70 | return token;
71 | }
72 |
73 | /**
74 | * 登录/注册时生成一对 Token
75 | */
76 | export async function generateTokens(user: UserTokenInfo) {
77 | const accessToken = await generateAccessToken(user);
78 | const refreshToken = await generateRefreshToken(user);
79 | return { accessToken, refreshToken };
80 | }
81 |
82 | /**
83 | * 刷新 Access Token
84 | */
85 | export async function refreshAccessToken(refreshToken: string) {
86 | const userDataStr = await redisClient.get(refreshKey(refreshToken));
87 | if (!userDataStr) {
88 | throw new Error("Invalid refresh token");
89 | }
90 |
91 | const user: UserTokenInfo = JSON.parse(userDataStr);
92 |
93 | // 轮换:删除旧 refresh,发新 refresh
94 | await redisClient.del(refreshKey(refreshToken));
95 | await redisClient.srem(refreshIndexKey(user.id), refreshToken);
96 |
97 | const newAccessToken = await generateAccessToken(user);
98 | const newRefreshToken = await generateRefreshToken(user);
99 |
100 | return { accessToken: newAccessToken, refreshToken: newRefreshToken };
101 | }
102 |
103 | /**
104 | * 登出:吊销用户所有 Refresh Token
105 | */
106 | export async function logout(userId: string | number) {
107 | const tokens = await redisClient.smembers(refreshIndexKey(userId));
108 | const pipeline = redisClient.pipeline();
109 | tokens.forEach(t => pipeline.del(refreshKey(t)));
110 | pipeline.del(refreshIndexKey(userId));
111 | await pipeline.exec();
112 | }
113 |
--------------------------------------------------------------------------------
/src/routes/admin/system/roles/helpers.ts:
--------------------------------------------------------------------------------
1 | import type { z } from "zod";
2 |
3 | import { enforcerPromise } from "@/lib/casbin";
4 |
5 | import type { selectSystemRoles } from "./schema";
6 |
7 | type Role = z.infer;
8 | type RoleWithParents = Role & { parentRoles?: string[] };
9 |
10 | /**
11 | * 获取角色的所有上级角色
12 | * @param roleId 角色ID
13 | * @returns 上级角色ID数组
14 | */
15 | export async function getRoleParents(roleId: string): Promise {
16 | const enforcer = await enforcerPromise;
17 | return enforcer.getRolesForUser(roleId);
18 | }
19 |
20 | /**
21 | * 设置角色的上级角色(会先清除原有关系)
22 | * @param roleId 角色ID
23 | * @param parentIds 新的上级角色ID数组
24 | */
25 | export async function setRoleParents(roleId: string, parentIds: string[]): Promise {
26 | const enforcer = await enforcerPromise;
27 |
28 | // 先移除所有现有的上级角色关系
29 | await enforcer.removeFilteredGroupingPolicy(0, roleId);
30 |
31 | // 如果有新的上级角色,批量添加
32 | if (parentIds.length > 0) {
33 | const rules = parentIds.map(parentId => [roleId, parentId]);
34 | await enforcer.addGroupingPolicies(rules);
35 | }
36 | }
37 |
38 | /**
39 | * 检查是否会产生循环继承
40 | * @param roleId 当前角色ID
41 | * @param parentIds 要设置的上级角色ID数组
42 | * @returns true表示会产生循环,false表示正常
43 | */
44 | export async function checkCircularInheritance(
45 | roleId: string,
46 | parentIds: string[],
47 | ): Promise {
48 | const enforcer = await enforcerPromise;
49 |
50 | // 检查每个要设置的上级角色
51 | for (const parentId of parentIds) {
52 | // 自己不能是自己的上级
53 | if (parentId === roleId) {
54 | return true;
55 | }
56 |
57 | // 递归检查:如果parentId的祖先中包含roleId,则会形成循环
58 | const visited = new Set();
59 | const checkAncestors = async (currentId: string): Promise => {
60 | if (visited.has(currentId)) {
61 | return false; // 已检查过,避免死循环
62 | }
63 | visited.add(currentId);
64 |
65 | const ancestors = await enforcer.getRolesForUser(currentId);
66 | if (ancestors.includes(roleId)) {
67 | return true;
68 | }
69 |
70 | // 递归检查所有祖先
71 | for (const ancestorId of ancestors) {
72 | if (await checkAncestors(ancestorId)) {
73 | return true;
74 | }
75 | }
76 | return false;
77 | };
78 |
79 | if (await checkAncestors(parentId)) {
80 | return true;
81 | }
82 | }
83 |
84 | return false;
85 | }
86 |
87 | /**
88 | * 为单个角色对象添加上级角色信息
89 | * @param role 角色对象
90 | * @returns 包含上级角色信息的角色对象
91 | */
92 | export async function enrichRoleWithParents(role: Role): Promise {
93 | const parentRoles = await getRoleParents(role.id);
94 | return {
95 | ...role,
96 | parentRoles,
97 | };
98 | }
99 |
100 | /**
101 | * 批量为角色列表添加上级角色信息
102 | * @param roles 角色列表
103 | * @returns 包含上级角色信息的角色列表
104 | */
105 | export async function enrichRolesWithParents(roles: Role[]): Promise {
106 | const enforcer = await enforcerPromise;
107 |
108 | // 获取所有的角色继承关系
109 | const allGroupingPolicies = await enforcer.getGroupingPolicy();
110 |
111 | // 构建角色ID到上级角色的映射
112 | const parentMap = new Map();
113 | for (const [child, parent] of allGroupingPolicies) {
114 | if (!parentMap.has(child)) {
115 | parentMap.set(child, []);
116 | }
117 | parentMap.get(child)!.push(parent);
118 | }
119 |
120 | // 为每个角色添加上级角色信息
121 | return roles.map(role => ({
122 | ...role,
123 | parentRoles: parentMap.get(role.id) || [],
124 | }));
125 | }
126 |
127 | /**
128 | * 清理角色的所有继承关系(删除角色时使用)
129 | * @param roleId 角色ID
130 | */
131 | export async function cleanRoleInheritance(roleId: string): Promise {
132 | const enforcer = await enforcerPromise;
133 |
134 | // 删除作为子角色的关系(roleId继承自其他角色)
135 | await enforcer.removeFilteredGroupingPolicy(0, roleId);
136 |
137 | // 删除作为父角色的关系(其他角色继承自roleId)
138 | await enforcer.removeFilteredGroupingPolicy(1, roleId);
139 | }
140 |
--------------------------------------------------------------------------------
/src/utils/db-errors.ts:
--------------------------------------------------------------------------------
1 | import postgres from "postgres";
2 |
3 | /**
4 | * 将 PostgreSQL 数据库错误映射为结构化的错误对象
5 | * @param err - 捕获的异常对象
6 | * @returns 结构化的错误信息,如果不是 PostgreSQL 错误则返回 null
7 | */
8 | export function mapDbError(err: unknown) {
9 | // 尝试获取原始的 PostgreSQL 错误
10 | // Drizzle ORM 会将 PostgresError 包装在 Error 的 cause 属性中
11 | let postgresError: postgres.PostgresError | undefined;
12 |
13 | if (err instanceof postgres.PostgresError) {
14 | postgresError = err;
15 | }
16 | else if (err instanceof Error && err.cause instanceof postgres.PostgresError) {
17 | postgresError = err.cause;
18 | }
19 |
20 | // 如果不是 PostgreSQL 错误则返回 null
21 | if (!postgresError)
22 | return null;
23 |
24 | switch (postgresError.code) {
25 | // ============ 数据完整性约束违反 (Class 23) ============
26 |
27 | // 唯一约束违反:插入或更新的数据违反了唯一约束
28 | case "23505":
29 | return {
30 | type: "UniqueViolation",
31 | constraint: postgresError.constraint_name,
32 | detail: postgresError.detail,
33 | } as const;
34 |
35 | // 非空约束违反:必填字段为 null
36 | case "23502":
37 | return {
38 | type: "NotNullViolation",
39 | column: postgresError.column_name,
40 | table: postgresError.table_name,
41 | } as const;
42 |
43 | // 外键约束违反:引用的记录不存在或被引用的记录正在被删除
44 | case "23503":
45 | return {
46 | type: "ForeignKeyViolation",
47 | constraint: postgresError.constraint_name,
48 | table: postgresError.table_name,
49 | detail: postgresError.detail,
50 | } as const;
51 |
52 | // 检查约束违反:数据不满足 CHECK 约束条件
53 | case "23514":
54 | return {
55 | type: "CheckViolation",
56 | constraint: postgresError.constraint_name,
57 | detail: postgresError.detail,
58 | } as const;
59 |
60 | // 排他约束违反:数据违反了 EXCLUDE 约束
61 | case "23P01":
62 | return {
63 | type: "ExclusionViolation",
64 | constraint: postgresError.constraint_name,
65 | detail: postgresError.detail,
66 | } as const;
67 |
68 | // ============ 数据格式错误 (Class 22) ============
69 |
70 | // 无效的文本表示:例如将非数字字符串转换为数字类型
71 | case "22P02":
72 | return {
73 | type: "InvalidTextRepresentation",
74 | column: postgresError.column_name,
75 | detail: postgresError.detail,
76 | } as const;
77 |
78 | // 无效的日期时间格式:日期时间字符串格式不正确
79 | case "22007":
80 | return {
81 | type: "InvalidDatetimeFormat",
82 | detail: postgresError.detail,
83 | } as const;
84 |
85 | // 日期时间字段溢出:日期时间值超出有效范围
86 | case "22008":
87 | return {
88 | type: "DatetimeFieldOverflow",
89 | detail: postgresError.detail,
90 | } as const;
91 |
92 | // 数值超出范围:数值超出字段类型的允许范围
93 | case "22003":
94 | return {
95 | type: "NumericValueOutOfRange",
96 | column: postgresError.column_name,
97 | detail: postgresError.detail,
98 | } as const;
99 |
100 | // ============ 事务错误 (Class 40) ============
101 |
102 | // 序列化失败:并发事务冲突,需要重试
103 | case "40001":
104 | return {
105 | type: "SerializationFailure",
106 | detail: postgresError.detail,
107 | } as const;
108 |
109 | // 检测到死锁:两个或多个事务相互等待
110 | case "40P01":
111 | return {
112 | type: "DeadlockDetected",
113 | detail: postgresError.detail,
114 | } as const;
115 |
116 | // ============ 权限与对象错误 (Class 42) ============
117 |
118 | // 权限不足:当前用户没有执行操作的权限
119 | case "42501":
120 | return {
121 | type: "InsufficientPrivilege",
122 | table: postgresError.table_name,
123 | } as const;
124 |
125 | // 未定义的函数:调用了不存在的函数
126 | case "42883":
127 | return {
128 | type: "UndefinedFunction",
129 | detail: postgresError.detail,
130 | } as const;
131 |
132 | // 未定义的列:引用了不存在的列
133 | case "42703":
134 | return {
135 | type: "UndefinedColumn",
136 | column: postgresError.column_name,
137 | } as const;
138 |
139 | // 未定义的表:引用了不存在的表
140 | case "42P01":
141 | return {
142 | type: "UndefinedTable",
143 | table: postgresError.table_name,
144 | } as const;
145 |
146 | // ============ 其他未知错误 ============
147 | default:
148 | return {
149 | type: "Unknown",
150 | code: postgresError.code,
151 | message: postgresError.message,
152 | detail: postgresError.detail,
153 | } as const;
154 | }
155 | }
156 |
--------------------------------------------------------------------------------
/src/utils/tools/tryit.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from "vitest";
2 |
3 | import { tryit } from "./tryit";
4 |
5 | describe("tryit Utils", () => {
6 | describe("tryit", () => {
7 | it("should return successful result for sync function", async () => {
8 | const syncFn = (a: number, b: number) => a + b;
9 | const wrappedFn = tryit(syncFn);
10 |
11 | const [error, result] = await wrappedFn(2, 3);
12 |
13 | expect(error).toBeNull();
14 | expect(result).toBe(5);
15 | });
16 |
17 | it("should return successful result for async function", async () => {
18 | const asyncFn = async (value: string) => {
19 | return `Hello, ${value}!`;
20 | };
21 | const wrappedFn = tryit(asyncFn);
22 |
23 | const [error, result] = await wrappedFn("World");
24 |
25 | expect(error).toBeNull();
26 | expect(result).toBe("Hello, World!");
27 | });
28 |
29 | it("should catch sync function errors", async () => {
30 | const errorFn = () => {
31 | throw new Error("Sync error");
32 | };
33 | const wrappedFn = tryit(errorFn);
34 |
35 | const [error, result] = await wrappedFn();
36 |
37 | expect(error).toBeInstanceOf(Error);
38 | expect(error?.message).toBe("Sync error");
39 | expect(result).toBeNull();
40 | });
41 |
42 | it("should catch async function errors", async () => {
43 | const asyncErrorFn = async () => {
44 | throw new Error("Async error");
45 | };
46 | const wrappedFn = tryit(asyncErrorFn);
47 |
48 | const [error, result] = await wrappedFn();
49 |
50 | expect(error).toBeInstanceOf(Error);
51 | expect(error?.message).toBe("Async error");
52 | expect(result).toBeNull();
53 | });
54 |
55 | it("should handle function with multiple parameters", async () => {
56 | const multiParamFn = (name: string, age: number, active: boolean) => {
57 | return { name, age, active };
58 | };
59 | const wrappedFn = tryit(multiParamFn);
60 |
61 | const [error, result] = await wrappedFn("John", 30, true);
62 |
63 | expect(error).toBeNull();
64 | expect(result).toEqual({ name: "John", age: 30, active: true });
65 | });
66 |
67 | it("should handle function that returns promise", async () => {
68 | const promiseFn = (delay: number) => {
69 | return new Promise((resolve) => {
70 | setTimeout(() => resolve(`Resolved after ${delay}ms`), delay);
71 | });
72 | };
73 | const wrappedFn = tryit(promiseFn);
74 |
75 | const [error, result] = await wrappedFn(10);
76 |
77 | expect(error).toBeNull();
78 | expect(result).toBe("Resolved after 10ms");
79 | });
80 |
81 | it("should handle function that returns rejected promise", async () => {
82 | const rejectedPromiseFn = () => {
83 | return Promise.reject(new Error("Promise rejected"));
84 | };
85 | const wrappedFn = tryit(rejectedPromiseFn);
86 |
87 | const [error, result] = await wrappedFn();
88 |
89 | expect(error).toBeInstanceOf(Error);
90 | expect(error?.message).toBe("Promise rejected");
91 | expect(result).toBeNull();
92 | });
93 |
94 | it("should handle function that throws non-Error values", async () => {
95 | const stringThrowFn = () => {
96 | throw new Error("String error");
97 | };
98 | const wrappedFn = tryit(stringThrowFn);
99 |
100 | const [error, result] = await wrappedFn();
101 |
102 | expect(error).toBeInstanceOf(Error);
103 | expect(error?.message).toBe("String error");
104 | expect(result).toBeNull();
105 | });
106 |
107 | it("should handle function that returns undefined", async () => {
108 | const undefinedFn = () => {
109 | return undefined;
110 | };
111 | const wrappedFn = tryit(undefinedFn);
112 |
113 | const [error, result] = await wrappedFn();
114 |
115 | expect(error).toBeNull();
116 | expect(result).toBeUndefined();
117 | });
118 |
119 | it("should handle function that returns null", async () => {
120 | const nullFn = () => {
121 | return null;
122 | };
123 | const wrappedFn = tryit(nullFn);
124 |
125 | const [error, result] = await wrappedFn();
126 |
127 | expect(error).toBeNull();
128 | expect(result).toBeNull();
129 | });
130 |
131 | it("should preserve function parameter types", async () => {
132 | const typedFn = (id: string, count: number, options: { flag: boolean }) => {
133 | return { id, count, flag: options.flag };
134 | };
135 | const wrappedFn = tryit(typedFn);
136 |
137 | const [error, result] = await wrappedFn("test-id", 42, { flag: true });
138 |
139 | expect(error).toBeNull();
140 | expect(result).toEqual({ id: "test-id", count: 42, flag: true });
141 | });
142 | });
143 | });
144 |
--------------------------------------------------------------------------------
/src/routes/admin/auth/routes.ts:
--------------------------------------------------------------------------------
1 | import { createRoute, z } from "@hono/zod-openapi";
2 | import { jwt } from "hono/jwt";
3 |
4 | import env from "@/env";
5 | import { RefineResultSchema } from "@/lib/refine-query";
6 | import * as HttpStatusCodes from "@/lib/stoker/http-status-codes";
7 | import { jsonContent, jsonContentRequired } from "@/lib/stoker/openapi/helpers";
8 | import { respErr } from "@/utils";
9 |
10 | import { getUserInfoSchema, loginSystemUsers } from "../system/users/schema";
11 |
12 | const routePrefix = "/auth";
13 | const tags = [`${routePrefix} (管理端身份认证)`];
14 |
15 | /** 管理端登录 */
16 | export const login = createRoute({
17 | path: `${routePrefix}/login`,
18 | method: "post",
19 | request: {
20 | body: jsonContentRequired(
21 | loginSystemUsers,
22 | "登录请求",
23 | ),
24 | },
25 | tags,
26 | summary: "管理端登录",
27 | responses: {
28 | [HttpStatusCodes.OK]: jsonContent(
29 | RefineResultSchema(z.object({
30 | accessToken: z.string().meta({ description: "访问令牌" }),
31 | })),
32 | "登录成功",
33 | ),
34 | [HttpStatusCodes.UNAUTHORIZED]: jsonContent(respErr, "用户名或密码错误"),
35 | [HttpStatusCodes.BAD_REQUEST]: jsonContent(respErr, "验证码错误"),
36 | [HttpStatusCodes.FORBIDDEN]: jsonContent(respErr, "用户被禁用"),
37 | [HttpStatusCodes.TOO_MANY_REQUESTS]: jsonContent(respErr, "登录失败次数过多"),
38 | },
39 | });
40 |
41 | /** 刷新 Token */
42 | export const refreshToken = createRoute({
43 | path: `${routePrefix}/refresh`,
44 | method: "post",
45 | tags,
46 | summary: "管理端刷新访问令牌",
47 | responses: {
48 | [HttpStatusCodes.OK]: jsonContent(
49 | RefineResultSchema(z.object({
50 | accessToken: z.string().meta({ description: "访问令牌" }),
51 | })),
52 | "刷新成功",
53 | ),
54 | [HttpStatusCodes.UNAUTHORIZED]: jsonContent(respErr, "刷新令牌无效"),
55 | },
56 | });
57 |
58 | /** 退出登录 */
59 | export const logout = createRoute({
60 | path: `${routePrefix}/logout`,
61 | method: "post",
62 | tags,
63 | middleware: [jwt({ secret: env.ADMIN_JWT_SECRET })],
64 | summary: "管理端退出登录",
65 | responses: {
66 | [HttpStatusCodes.OK]: jsonContent(
67 | RefineResultSchema(z.object({})),
68 | "退出成功",
69 | ),
70 | [HttpStatusCodes.UNAUTHORIZED]: jsonContent(respErr, "未授权"),
71 | },
72 | });
73 |
74 | /** 获取用户信息 */
75 | export const getIdentity = createRoute({
76 | path: `${routePrefix}/userinfo`,
77 | method: "get",
78 | tags,
79 | middleware: [jwt({ secret: env.ADMIN_JWT_SECRET })],
80 | summary: "管理端获取当前用户信息",
81 | responses: {
82 | [HttpStatusCodes.OK]: jsonContent(
83 | RefineResultSchema(getUserInfoSchema),
84 | "获取成功",
85 | ),
86 | [HttpStatusCodes.NOT_FOUND]: jsonContent(respErr, "用户不存在"),
87 | },
88 | });
89 |
90 | /** 获取用户权限 */
91 | export const getPermissions = createRoute({
92 | path: `${routePrefix}/permissions`,
93 | method: "get",
94 | tags,
95 | middleware: [jwt({ secret: env.ADMIN_JWT_SECRET })],
96 | summary: "管理端获取当前用户权限",
97 | responses: {
98 | [HttpStatusCodes.OK]: jsonContent(
99 | RefineResultSchema(z.object({
100 | permissions: z.array(z.string()).meta({ description: "权限策略列表(p策略)" }),
101 | groupings: z.array(z.string()).meta({ description: "角色继承关系列表(g策略)" }),
102 | })),
103 | "获取成功",
104 | ),
105 | [HttpStatusCodes.NOT_FOUND]: jsonContent(respErr, "角色不存在"),
106 | },
107 | });
108 |
109 | /** 生成验证码挑战 */
110 | export const createChallenge = createRoute({
111 | path: `${routePrefix}/challenge`,
112 | method: "post",
113 | tags,
114 | summary: "管理端生成验证码挑战",
115 | responses: {
116 | [HttpStatusCodes.OK]: jsonContent(
117 | z.object({
118 | challenge: z.any().meta({ description: "验证码挑战数据" }),
119 | token: z.string().optional().meta({ description: "挑战token" }),
120 | expires: z.number().meta({ description: "过期时间戳" }),
121 | }),
122 | "生成成功",
123 | ),
124 | [HttpStatusCodes.INTERNAL_SERVER_ERROR]: jsonContent(respErr, "生成失败"),
125 | },
126 | });
127 |
128 | /** 验证用户解答并生成验证token */
129 | export const redeemChallenge = createRoute({
130 | path: `${routePrefix}/redeem`,
131 | method: "post",
132 | request: {
133 | body: jsonContentRequired(
134 | z.object({
135 | token: z.string().meta({ description: "挑战token" }),
136 | solutions: z.array(z.number()).meta({ description: "用户解答" }),
137 | }),
138 | "验证请求",
139 | ),
140 | },
141 | tags,
142 | summary: "管理端验证用户解答",
143 | responses: {
144 | [HttpStatusCodes.OK]: jsonContent(
145 | z.object({
146 | success: z.boolean().meta({ description: "验证结果" }),
147 | token: z.string().optional().meta({ description: "验证token" }),
148 | expires: z.number().optional().meta({ description: "过期时间戳" }),
149 | }),
150 | "验证成功",
151 | ),
152 | [HttpStatusCodes.BAD_REQUEST]: jsonContent(respErr, "验证失败"),
153 | },
154 | });
155 |
--------------------------------------------------------------------------------
/src/routes/admin/system/users/routes.ts:
--------------------------------------------------------------------------------
1 | import { createRoute } from "@hono/zod-openapi";
2 | import { z } from "zod";
3 |
4 | import { RefineQueryParamsSchema, RefineResultSchema } from "@/lib/refine-query";
5 | import * as HttpStatusCodes from "@/lib/stoker/http-status-codes";
6 | import { jsonContent, jsonContentRequired } from "@/lib/stoker/openapi/helpers";
7 | import { IdUUIDParamsSchema } from "@/lib/stoker/openapi/schemas";
8 | import { respErr } from "@/utils";
9 |
10 | import { insertSystemUsers, patchSystemUsers, responseSystemUsersWithList, responseSystemUsersWithoutPassword, responseSystemUsersWithoutPasswordAndRoles } from "./schema";
11 |
12 | const routePrefix = "/system/users";
13 | const tags = [`${routePrefix}(系统用户)`];
14 |
15 | /** 获取系统用户分页列表 */
16 | export const list = createRoute({
17 | tags,
18 | summary: "获取系统用户列表",
19 | method: "get",
20 | path: routePrefix,
21 | request: {
22 | query: RefineQueryParamsSchema,
23 | },
24 | responses: {
25 | [HttpStatusCodes.OK]: jsonContent(
26 | RefineResultSchema(responseSystemUsersWithList),
27 | "列表响应成功",
28 | ),
29 | [HttpStatusCodes.UNPROCESSABLE_ENTITY]: jsonContent(respErr, "查询参数验证错误"),
30 | [HttpStatusCodes.INTERNAL_SERVER_ERROR]: jsonContent(respErr, "服务器内部错误"),
31 | },
32 | });
33 |
34 | /** 创建系统用户 */
35 | export const create = createRoute({
36 | tags,
37 | summary: "创建系统用户",
38 | method: "post",
39 | path: routePrefix,
40 | request: {
41 | body: jsonContentRequired(
42 | insertSystemUsers,
43 | "创建系统用户参数",
44 | ),
45 | },
46 | responses: {
47 | [HttpStatusCodes.CREATED]: jsonContent(
48 | RefineResultSchema(responseSystemUsersWithoutPassword),
49 | "创建成功",
50 | ),
51 | [HttpStatusCodes.UNPROCESSABLE_ENTITY]: jsonContent(respErr, "The validation error(s)"),
52 | [HttpStatusCodes.CONFLICT]: jsonContent(respErr, "用户名已存在"),
53 | },
54 | });
55 |
56 | /** 根据ID获取系统用户详情 */
57 | export const get = createRoute({
58 | tags,
59 | summary: "获取系统用户详情",
60 | method: "get",
61 | path: `${routePrefix}/{id}`,
62 | request: {
63 | params: IdUUIDParamsSchema,
64 | },
65 | responses: {
66 | [HttpStatusCodes.OK]: jsonContent(
67 | RefineResultSchema(responseSystemUsersWithoutPasswordAndRoles),
68 | "获取成功",
69 | ),
70 | [HttpStatusCodes.BAD_REQUEST]: jsonContent(respErr, "ID参数错误"),
71 | [HttpStatusCodes.NOT_FOUND]: jsonContent(respErr, "用户不存在"),
72 | },
73 | });
74 |
75 | /** 更新系统用户 */
76 | export const update = createRoute({
77 | tags,
78 | summary: "更新系统用户",
79 | method: "patch",
80 | path: `${routePrefix}/{id}`,
81 | request: {
82 | params: IdUUIDParamsSchema,
83 | body: jsonContentRequired(
84 | patchSystemUsers,
85 | "更新系统用户参数",
86 | ),
87 | },
88 | responses: {
89 | [HttpStatusCodes.OK]: jsonContent(
90 | RefineResultSchema(responseSystemUsersWithoutPassword),
91 | "更新成功",
92 | ),
93 | [HttpStatusCodes.BAD_REQUEST]: jsonContent(respErr, "请求参数错误"),
94 | [HttpStatusCodes.FORBIDDEN]: jsonContent(respErr, "内置用户不允许修改状态"),
95 | [HttpStatusCodes.NOT_FOUND]: jsonContent(respErr, "用户不存在"),
96 | },
97 | });
98 |
99 | /** 删除系统用户 */
100 | export const remove = createRoute({
101 | tags,
102 | summary: "删除系统用户",
103 | method: "delete",
104 | path: `${routePrefix}/{id}`,
105 | request: {
106 | params: IdUUIDParamsSchema,
107 | },
108 | responses: {
109 | [HttpStatusCodes.OK]: jsonContent(
110 | RefineResultSchema(IdUUIDParamsSchema),
111 | "删除成功",
112 | ),
113 | [HttpStatusCodes.BAD_REQUEST]: jsonContent(respErr, "ID参数错误"),
114 | [HttpStatusCodes.FORBIDDEN]: jsonContent(respErr, "内置用户不允许删除"),
115 | [HttpStatusCodes.NOT_FOUND]: jsonContent(respErr, "用户不存在"),
116 | },
117 | });
118 |
119 | /** 保存用户角色(全量更新) */
120 | export const saveRoles = createRoute({
121 | tags,
122 | summary: "保存用户角色(全量更新)",
123 | method: "put",
124 | path: `${routePrefix}/{userId}/roles`,
125 | request: {
126 | params: z.object({
127 | userId: z.uuid().meta({ description: "用户ID" }),
128 | }),
129 | body: jsonContentRequired(
130 | z.object({
131 | roleIds: z.array(z.string().min(1).max(64).meta({ example: "admin", description: "角色编码" }))
132 | .meta({ description: "角色列表(全量)" }),
133 | }),
134 | "保存角色参数",
135 | ),
136 | },
137 | responses: {
138 | [HttpStatusCodes.OK]: jsonContent(
139 | RefineResultSchema(z.object({
140 | added: z.number().int().meta({ description: "新增角色数量" }),
141 | removed: z.number().int().meta({ description: "删除角色数量" }),
142 | total: z.number().int().meta({ description: "总角色数量" }),
143 | })),
144 | "保存成功",
145 | ),
146 | [HttpStatusCodes.UNPROCESSABLE_ENTITY]: jsonContent(respErr, "The validation error(s)"),
147 | [HttpStatusCodes.NOT_FOUND]: jsonContent(respErr, "用户或角色不存在"),
148 | [HttpStatusCodes.INTERNAL_SERVER_ERROR]: jsonContent(respErr, "保存角色失败"),
149 | },
150 | });
151 |
--------------------------------------------------------------------------------
/src/routes/admin/system/roles/routes.ts:
--------------------------------------------------------------------------------
1 | import { createRoute } from "@hono/zod-openapi";
2 | import { z } from "zod";
3 |
4 | import { RefineQueryParamsSchema, RefineResultSchema } from "@/lib/refine-query";
5 | import * as HttpStatusCodes from "@/lib/stoker/http-status-codes";
6 | import { jsonContent, jsonContentRequired } from "@/lib/stoker/openapi/helpers";
7 | import { respErr } from "@/utils";
8 |
9 | import { idSystemRoles, insertSystemRoles, patchSystemRoles, permissionItemSchema, selectSystemRoles } from "./schema";
10 |
11 | const routePrefix = "/system/roles";
12 | const tags = [`${routePrefix}(系统角色)`];
13 |
14 | /** 获取系统角色分页列表 */
15 | export const list = createRoute({
16 | tags,
17 | summary: "获取系统角色列表",
18 | method: "get",
19 | path: routePrefix,
20 | request: {
21 | query: RefineQueryParamsSchema,
22 | },
23 | responses: {
24 | [HttpStatusCodes.OK]: jsonContent(
25 | RefineResultSchema(z.array(selectSystemRoles)),
26 | "列表响应成功",
27 | ),
28 | [HttpStatusCodes.UNPROCESSABLE_ENTITY]: jsonContent(respErr, "查询参数验证错误"),
29 | [HttpStatusCodes.INTERNAL_SERVER_ERROR]: jsonContent(respErr, "服务器内部错误"),
30 | },
31 | });
32 |
33 | /** 创建系统角色 */
34 | export const create = createRoute({
35 | tags,
36 | summary: "创建系统角色",
37 | method: "post",
38 | path: routePrefix,
39 | request: {
40 | body: jsonContentRequired(
41 | insertSystemRoles,
42 | "创建系统角色参数",
43 | ),
44 | },
45 | responses: {
46 | [HttpStatusCodes.CREATED]: jsonContent(
47 | RefineResultSchema(selectSystemRoles),
48 | "创建成功",
49 | ),
50 | [HttpStatusCodes.BAD_REQUEST]: jsonContent(respErr, "请求参数错误"),
51 | [HttpStatusCodes.UNPROCESSABLE_ENTITY]: jsonContent(respErr, "参数验证失败"),
52 | [HttpStatusCodes.CONFLICT]: jsonContent(respErr, "角色代码已存在"),
53 | },
54 | });
55 |
56 | /** 根据ID获取系统角色详情 */
57 | export const get = createRoute({
58 | tags,
59 | summary: "获取系统角色详情",
60 | method: "get",
61 | path: `${routePrefix}/{id}`,
62 | request: {
63 | params: idSystemRoles,
64 | },
65 | responses: {
66 | [HttpStatusCodes.OK]: jsonContent(
67 | RefineResultSchema(selectSystemRoles),
68 | "获取成功",
69 | ),
70 | [HttpStatusCodes.BAD_REQUEST]: jsonContent(respErr, "ID参数错误"),
71 | [HttpStatusCodes.NOT_FOUND]: jsonContent(respErr, "角色不存在"),
72 | },
73 | });
74 |
75 | /** 更新系统角色 */
76 | export const update = createRoute({
77 | tags,
78 | summary: "更新系统角色",
79 | method: "patch",
80 | path: `${routePrefix}/{id}`,
81 | request: {
82 | params: idSystemRoles,
83 | body: jsonContentRequired(
84 | patchSystemRoles,
85 | "更新系统角色参数",
86 | ),
87 | },
88 | responses: {
89 | [HttpStatusCodes.OK]: jsonContent(
90 | RefineResultSchema(selectSystemRoles),
91 | "更新成功",
92 | ),
93 | [HttpStatusCodes.BAD_REQUEST]: jsonContent(respErr, "请求参数错误"),
94 | [HttpStatusCodes.NOT_FOUND]: jsonContent(respErr, "角色不存在"),
95 | },
96 | });
97 |
98 | /** 删除系统角色 */
99 | export const remove = createRoute({
100 | tags,
101 | summary: "删除系统角色",
102 | method: "delete",
103 | path: `${routePrefix}/{id}`,
104 | request: {
105 | params: idSystemRoles,
106 | },
107 | responses: {
108 | [HttpStatusCodes.OK]: jsonContent(
109 | RefineResultSchema(idSystemRoles),
110 | "删除成功",
111 | ),
112 | [HttpStatusCodes.BAD_REQUEST]: jsonContent(respErr, "ID参数错误"),
113 | [HttpStatusCodes.NOT_FOUND]: jsonContent(respErr, "角色不存在"),
114 | },
115 | });
116 |
117 | /** 获取角色权限 */
118 | export const getPermissions = createRoute({
119 | tags,
120 | summary: "获取角色权限",
121 | method: "get",
122 | path: `${routePrefix}/{id}/permissions`,
123 | request: {
124 | params: idSystemRoles,
125 | },
126 | responses: {
127 | [HttpStatusCodes.OK]: jsonContent(
128 | RefineResultSchema(z.object({
129 | permissions: z.array(permissionItemSchema).meta({ description: "权限列表" }),
130 | groupings: z.array(z.object({
131 | child: z.string().meta({ description: "子角色ID" }),
132 | parent: z.string().meta({ description: "父角色ID" }),
133 | })).meta({ description: "角色继承关系列表" }),
134 | })),
135 | "获取权限成功",
136 | ),
137 | [HttpStatusCodes.BAD_REQUEST]: jsonContent(respErr, "ID参数错误"),
138 | [HttpStatusCodes.INTERNAL_SERVER_ERROR]: jsonContent(respErr, "获取权限失败"),
139 | },
140 | });
141 |
142 | export const savePermissions = createRoute({
143 | tags,
144 | summary: "保存角色权限(全量更新)",
145 | method: "put",
146 | path: `${routePrefix}/{id}/permissions`,
147 | request: {
148 | params: idSystemRoles,
149 | body: jsonContentRequired(
150 | z.object({
151 | permissions: z.array(
152 | z.tuple([
153 | z.string().min(1).meta({ description: "资源" }),
154 | z.string().min(1).meta({ description: "操作" }),
155 | ]),
156 | ).meta({ description: "权限列表(全量)" }),
157 | parentRoleIds: z.array(z.string().min(1).regex(/^[a-z0-9_]+$/)).optional().meta({ description: "上级角色ID列表(可选)" }),
158 | }),
159 | "保存权限参数",
160 | ),
161 | },
162 | responses: {
163 | [HttpStatusCodes.OK]: jsonContent(
164 | RefineResultSchema(z.object({
165 | added: z.number().int().meta({ description: "新增权限数量" }),
166 | removed: z.number().int().meta({ description: "删除权限数量" }),
167 | total: z.number().int().meta({ description: "总权限数量" }),
168 | })),
169 | "保存权限成功",
170 | ),
171 | [HttpStatusCodes.BAD_REQUEST]: jsonContent(respErr, "参数错误"),
172 | [HttpStatusCodes.NOT_FOUND]: jsonContent(respErr, "角色不存在"),
173 | [HttpStatusCodes.INTERNAL_SERVER_ERROR]: jsonContent(respErr, "保存权限失败"),
174 | },
175 | });
176 |
--------------------------------------------------------------------------------
/CLAUDE.md:
--------------------------------------------------------------------------------
1 | # CLAUDE.md
2 |
3 | **CRITICAL: Always respond in Simplified Chinese**
4 |
5 | ## Commands
6 |
7 | ```bash
8 | # Package manager: pnpm only
9 | pnpm install/dev/build/start/typecheck/lint/lint:fix/test
10 | pnpm generate/push/migrate/studio/seed # Database
11 | ```
12 |
13 | ## Stack
14 |
15 | Hono + Node.js 25 + PostgreSQL(Drizzle snake_case) + Redis(ioredis) + JWT(admin/client) + Casbin RBAC + Zod(Chinese errors) + OpenAPI 3.1.0(Scalar) + Vitest + tsdown
16 |
17 | ## Architecture
18 |
19 | ### Route Tiers
20 | - `/api/public/*` - No auth
21 | - `/api/client/*` - JWT only
22 | - `/api/admin/*` - JWT + RBAC + audit logs
23 |
24 | ### Structure
25 | ```
26 | src/
27 | ├── app.ts
28 | ├── db/schema/ # snake_case DB
29 | ├── routes/{tier}/{feature}/
30 | │ ├── *.handlers.ts
31 | │ ├── *.routes.ts # OpenAPI + Zod
32 | │ └── *.index.ts
33 | ├── services/ # Functional, reuse ≥2x only
34 | └── lib/
35 | ```
36 |
37 | ## Critical Rules
38 |
39 | ### Response Wrapping (MANDATORY)
40 | ```typescript
41 | // ✓ Wrap all responses
42 | return c.json(Resp.ok(data), HttpStatusCodes.OK);
43 | return c.json(Resp.fail("错误"), HttpStatusCodes.BAD_REQUEST);
44 |
45 | // ✗ Never return raw data
46 | return c.json(data, HttpStatusCodes.OK);
47 |
48 | // OpenAPI
49 | [HttpStatusCodes.OK]: jsonContent(RefineResultSchema, "成功")
50 | [HttpStatusCodes.BAD_REQUEST]: jsonContent(respErr, "错误")
51 | ```
52 |
53 | ### Logging (MANDATORY)
54 | ```typescript
55 | // ✓ Use logger, data object first
56 | logger.info({ userId }, "[用户]: 登录成功");
57 | logger.error(error, "[系统]: 初始化失败");
58 |
59 | // ✗ Never use console.log/warn/error
60 | // ✗ Never put data object after message
61 | ```
62 |
63 | **Format**: `[模块名]: 描述` | Log progress at 25% intervals only | No emojis/English
64 |
65 | ### Database Schema
66 |
67 | **Basics**
68 | - Import: `import db from "@/db"` (default)
69 | - Casing: TypeScript camelCase → auto snake_case (config: `casing: "snake_case"`)
70 | - Extend `...defaultColumns` (id/createdAt/updatedAt/createdBy/updatedBy)
71 | - Modern syntax: `varchar({ length: 128 })` not `varchar("name", { length })`
72 | - Always specify varchar length
73 | - **NEVER modify `migrations/` folder or `meta/` folder files - they are auto-generated. Only modify schema files in `src/db/schema/`**
74 |
75 | **Enums**
76 | ```typescript
77 | // enums.ts
78 | export const statusEnum = pgEnum("status", Object.values(Status));
79 |
80 | // schema - call without params
81 | status: statusEnum().default(Status.ENABLED).notNull()
82 | ```
83 |
84 | **Constraints**
85 | - Return arrays: `table => [unique().on(table.col)]`
86 | - Indexes only when necessary: `index("name_idx").on(table.col)`
87 |
88 | **Zod Schema Inheritance**
89 | ```typescript
90 | // 1. Base (add descriptions ONLY here via callback)
91 | const selectSchema = createSelectSchema(table, {
92 | field: schema => schema.meta({ description: "描述" })
93 | });
94 |
95 | // 2. Insert
96 | const insertSchema = createInsertSchema(table).omit({ id: true });
97 |
98 | // 3. Derived
99 | const patchSchema = insertSchema.partial();
100 | const querySchema = selectSchema.pick({...}).extend({
101 | // Use extend for: override properties, change types, add new fields
102 | optional: z.string().optional()
103 | });
104 | ```
105 |
106 | **Type Constraints**
107 | ```typescript
108 | // Always constrain Zod schemas
109 | const Schema: z.ZodType = z.object({
110 | field: z.string().optional() // .optional() not .nullable()
111 | });
112 |
113 | // Zod v4
114 | z.uuid() // not z.string().uuid()
115 | ```
116 |
117 | ### Route Module Pattern
118 |
119 | **Export ({feature}.index.ts)**
120 | ```typescript
121 | import type { AppRouteHandler } from "@/types/lib";
122 | import { createRouter } from "@/lib/create-app";
123 | import * as handlers from "./*.handlers";
124 | import * as routes from "./*.routes";
125 |
126 | export const feature = createRouter()
127 | .openapi(routes.get, handlers.get);
128 |
129 | type RouteTypes = { [K in keyof typeof routes]: typeof routes[K] };
130 | export type FeatureRouteHandlerType =
131 | AppRouteHandler;
132 | ```
133 |
134 | **Handler Typing (MANDATORY)**
135 | ```typescript
136 | import type { FeatureRouteHandlerType } from "./*.index";
137 |
138 | // ✓ Strict typing
139 | export const list: FeatureRouteHandlerType<"list"> = async (c) => {};
140 |
141 | // ✗ Never use any/Context/generic types
142 | ```
143 |
144 | **OpenAPI Tags**
145 | ```typescript
146 | const routePrefix = "/feature";
147 | const tags = [`${routePrefix}(功能描述)`];
148 |
149 | export const list = createRoute({
150 | tags,
151 | path: routePrefix, // or `${routePrefix}/{id}`
152 | // ...
153 | });
154 | ```
155 |
156 | ## Other Rules
157 |
158 | **Must Follow**
159 | - Status codes: Use `HttpStatusCodes` constants
160 | - Dates: Use `date-fns` library
161 | - Timestamps: `timestamp({ mode: "string" })`
162 | - UUID params: Use `IdUUIDParamsSchema`
163 | - Dynamic imports: `await import()`
164 | - Ignore returns: Prefix with `void`
165 | - Redis: Specify return types
166 | - Enums: String values + `as const`
167 | - Naming: PascalCase (classes/types), UPPER_SNAKE_CASE (enum values), kebab-case (files)
168 | - Queries: Use enums not magic values: `eq(table.status, Status.ENABLED)`
169 |
170 | **Error Handling**
171 | - Research error behavior of Drizzle/dependencies before try-catch
172 | - Don't blindly wrap in try-catch
173 |
174 | **Services**
175 | - Extract only when reused ≥2x or likely will be
176 | - Prefixes: `create*`/`get*`/`update*`/`delete*`/`assign*`/`clear*`
177 | - Keep simple CRUD in handlers
178 |
179 | **Testing**
180 | - Developer must manually run and test project for debugging
181 |
--------------------------------------------------------------------------------
/src/db/schema/admin/auth/casbin-rule.ts:
--------------------------------------------------------------------------------
1 | import { z } from "@hono/zod-openapi";
2 | import { index, pgTable, primaryKey, varchar } from "drizzle-orm/pg-core";
3 | import { createSelectSchema } from "drizzle-zod";
4 |
5 | export const casbinRule = pgTable("casbin_rule", {
6 | /** 策略类型:p(权限策略)/g(角色继承) */
7 | ptype: varchar({ length: 8 }).notNull(), // 非空:所有规则必含 ptype
8 | /** 主体:角色或用户(p策略=sub,g策略=上级角色/用户) */
9 | v0: varchar({ length: 64 }).notNull(), // 非空:p/g 策略均需主体
10 | /** 对象:业务资源(p策略=obj,g策略=下级角色/用户) */
11 | v1: varchar({ length: 254 }).notNull(), // 非空:p/g 策略均需对象
12 | /** 动作:业务动作(仅p策略使用,g策略为空字符串) */
13 | v2: varchar({ length: 64 }).notNull().default(""), // 默认空字符串:兼容g策略
14 | /** 效果:allow/deny(仅p策略使用,g策略为空字符串) */
15 | v3: varchar({ length: 64 }).notNull().default(""), // 默认空字符串:兼容g策略
16 | /** 保留字段(暂不使用,默认空字符串) */
17 | v4: varchar({ length: 64 }).notNull().default(""),
18 | /** 保留字段(暂不使用,默认空字符串) */
19 | v5: varchar({ length: 64 }).notNull().default(""),
20 | }, table => [
21 | primaryKey({ name: "casbin_rule_pkey", columns: [table.ptype, table.v0, table.v1, table.v2, table.v3] }),
22 | index("idx_casbin_g_v0").on(table.ptype, table.v0, table.v1),
23 | ]);
24 |
25 | // Zod Schema 适配字段非空+默认值约束
26 | export const selectCasbinRuleSchema = createSelectSchema(casbinRule, {
27 | ptype: schema =>
28 | schema.meta({ description: "策略类型: p=策略 g=角色继承" }),
29 | v0: schema =>
30 | schema.meta({ description: "主体: 角色或用户(p=sub,g=上级)" }),
31 | v1: schema =>
32 | schema.meta({ description: "对象: 资源/角色(p=obj,g=下级)" }),
33 | v2: schema =>
34 | schema.meta({ description: "动作: 仅p策略使用(如GET/POST)" }),
35 | v3: schema =>
36 | schema.meta({ description: "效果: 仅p策略使用(allow/deny)" }),
37 | v4: schema =>
38 | schema.meta({ description: "保留字段" }),
39 | v5: schema =>
40 | schema.meta({ description: "保留字段" }),
41 | });
42 |
43 | // 类型来源:z.infer 是 selectSchema 的解析类型,omit 后得到插入类型
44 | type InsertCasbinRuleType = z.infer;
45 | type InsertCasbinRuleInput = z.infer;
46 |
47 | export const insertCasbinRuleSchema = selectCasbinRuleSchema
48 | .refine((data: InsertCasbinRuleInput) => {
49 | if (data.ptype === "g") {
50 | if ((data.v2 !== "") || (data.v3 !== "")) {
51 | throw new Error("角色继承规则(g)不允许设置「动作(v2)」和「效果(v3)」,请留空");
52 | }
53 | }
54 |
55 | if (data.ptype === "p") {
56 | if ((data.v2 === "") || (data.v3 === "")) {
57 | throw new Error("权限策略(p)必须设置「动作(v2,如GET/POST)」和「效果(v3,如allow/deny)」");
58 | }
59 | if (!["allow", "deny"].includes(data.v3)) {
60 | throw new Error("权限策略(p)的效果(v3)仅支持「allow」或「deny」");
61 | }
62 | }
63 |
64 | return true;
65 | });
66 |
67 | type FromOriginalRuleType = {
68 | ptype: InsertCasbinRuleType["ptype"];
69 | };
70 | type UpdateDataInput = z.infer;
71 | type PatchCasbinRuleInput = {
72 | fromOriginalRule: FromOriginalRuleType;
73 | updateData: Partial;
74 | };
75 |
76 | export const patchCasbinRuleSchema = z
77 | .object({
78 | fromOriginalRule: z.object({
79 | ptype: selectCasbinRuleSchema.shape.ptype,
80 | }).meta({ description: "原规则的基础信息(仅需ptype,用于校验更新合法性)" }),
81 | updateData: insertCasbinRuleSchema.partial().meta({ description: "待更新的规则字段(部分可选)" }),
82 | })
83 | .refine((data: PatchCasbinRuleInput) => {
84 | const { fromOriginalRule, updateData } = data;
85 | const originalPtype = fromOriginalRule.ptype;
86 | const { ptype: newPtype, v2: newV2, v3: newV3, v0: newV0, v1: newV1 } = updateData;
87 |
88 | // 场景1:更新 ptype(从g改p或p改g)
89 | if ((newPtype !== undefined) && (newPtype !== originalPtype)) {
90 | if (newPtype === "g") {
91 | if (((newV2 !== undefined) && (newV2 !== "")) || ((newV3 !== undefined) && (newV3 !== ""))) {
92 | throw new Error(`规则类型从「${originalPtype}」改为「g(角色继承)」后,不允许设置「动作(v2)」和「效果(v3)」`);
93 | }
94 | }
95 |
96 | if (newPtype === "p") {
97 | if (((newV2 === undefined) || (newV2 === "")) || ((newV3 === undefined) || (newV3 === ""))) {
98 | throw new Error(`规则类型从「${originalPtype}」改为「p(权限)」后,必须设置「动作(v2)」和「效果(v3)」`);
99 | }
100 | if (!["allow", "deny"].includes(newV3 as string)) {
101 | throw new Error(`规则类型改为「p(权限)」后,效果(v3)仅支持「allow」或「deny」,当前值:${newV3}`);
102 | }
103 | }
104 | }
105 |
106 | // 场景2:未更新 ptype(保持原类型)
107 | if ((newPtype === undefined) || (newPtype === originalPtype)) {
108 | if (originalPtype === "g") {
109 | // 修复混合运算符:用括号明确 (newV2 !== undefined) 的优先级
110 | if ((newV2 !== undefined) || (newV3 !== undefined)) {
111 | throw new Error(`原规则为「g(角色继承)」,不允许更新「动作(v2)」和「效果(v3)」,请移除这些字段`);
112 | }
113 | }
114 |
115 | if (originalPtype === "p") {
116 | if ((newV2 !== undefined) && (newV2 === "")) {
117 | throw new Error(`原规则为「p(权限)」,更新「动作(v2)」时不能为空(如GET/POST)`);
118 | }
119 | if (newV3 !== undefined) {
120 | if (newV3 === "") {
121 | throw new Error(`原规则为「p(权限)」,更新「效果(v3)」时不能为空`);
122 | }
123 | if (!["allow", "deny"].includes(newV3)) {
124 | throw new Error(`原规则为「p(权限)」,效果(v3)仅支持「allow」或「deny」,当前值:${newV3}`);
125 | }
126 | }
127 | }
128 | }
129 |
130 | // 场景3:更新 v0/v1 的额外校验
131 | if ((newV0 !== undefined) && (newV0.includes("@"))) {
132 | throw new Error("主体(v0)不允许包含「@」字符,请修改后重试");
133 | }
134 | if ((newV1 !== undefined) && (newV1.length > 254)) {
135 | throw new Error(`对象(v1)长度不能超过254个字符,当前长度:${newV1.length}`);
136 | }
137 |
138 | return true;
139 | })
140 | .meta({ description: "Casbin规则更新Schema(支持部分字段更新,结合原规则类型做全场景校验)" });
141 |
--------------------------------------------------------------------------------
/migrations/0000_square_dakota_north.sql:
--------------------------------------------------------------------------------
1 | CREATE TYPE "public"."gender" AS ENUM('UNKNOWN', 'MALE', 'FEMALE');--> statement-breakpoint
2 | CREATE TYPE "public"."real_name_auth_status" AS ENUM('UNAUTHENTICATED', 'PENDING', 'VERIFIED', 'FAILED');--> statement-breakpoint
3 | CREATE TYPE "public"."real_name_auth_type" AS ENUM('INDIVIDUAL', 'ENTERPRISE');--> statement-breakpoint
4 | CREATE TYPE "public"."status" AS ENUM('ENABLED', 'DISABLED');--> statement-breakpoint
5 | CREATE TYPE "public"."user_status" AS ENUM('NORMAL', 'DISABLED', 'PENDING', 'REJECTED');--> statement-breakpoint
6 | CREATE TYPE "public"."verification_status" AS ENUM('UNVERIFIED', 'VERIFIED');--> statement-breakpoint
7 | CREATE TABLE "casbin_rule" (
8 | "ptype" varchar(8) NOT NULL,
9 | "v0" varchar(64) NOT NULL,
10 | "v1" varchar(254) NOT NULL,
11 | "v2" varchar(64) DEFAULT '' NOT NULL,
12 | "v3" varchar(64) DEFAULT '' NOT NULL,
13 | "v4" varchar(64) DEFAULT '' NOT NULL,
14 | "v5" varchar(64) DEFAULT '' NOT NULL,
15 | CONSTRAINT "casbin_rule_pkey" PRIMARY KEY("ptype","v0","v1","v2","v3")
16 | );
17 | --> statement-breakpoint
18 | CREATE TABLE "system_roles" (
19 | "id" varchar(64) PRIMARY KEY NOT NULL,
20 | "created_at" timestamp,
21 | "created_by" varchar(64),
22 | "updated_at" timestamp,
23 | "updated_by" varchar(64),
24 | "name" varchar(64) NOT NULL,
25 | "description" text,
26 | "status" "status" DEFAULT 'ENABLED' NOT NULL
27 | );
28 | --> statement-breakpoint
29 | CREATE TABLE "system_user_roles" (
30 | "user_id" uuid NOT NULL,
31 | "role_id" varchar(64) NOT NULL,
32 | CONSTRAINT "system_user_roles_user_id_role_id_pk" PRIMARY KEY("user_id","role_id")
33 | );
34 | --> statement-breakpoint
35 | CREATE TABLE "system_users" (
36 | "id" uuid PRIMARY KEY DEFAULT uuidv7() NOT NULL,
37 | "created_at" timestamp,
38 | "created_by" varchar(64),
39 | "updated_at" timestamp,
40 | "updated_by" varchar(64),
41 | "username" varchar(64) NOT NULL,
42 | "password" text NOT NULL,
43 | "built_in" boolean DEFAULT false,
44 | "avatar" text,
45 | "nick_name" varchar(64) NOT NULL,
46 | "status" "status" DEFAULT 'ENABLED' NOT NULL,
47 | CONSTRAINT "system_users_username_unique" UNIQUE("username")
48 | );
49 | --> statement-breakpoint
50 | CREATE TABLE "client_users" (
51 | "id" uuid PRIMARY KEY DEFAULT uuidv7() NOT NULL,
52 | "created_at" timestamp,
53 | "created_by" varchar(64),
54 | "updated_at" timestamp,
55 | "updated_by" varchar(64),
56 | "username" varchar(64) NOT NULL,
57 | "password" varchar(128) NOT NULL,
58 | "password_secret_version" integer DEFAULT 1,
59 | "nickname" varchar(64),
60 | "gender" "gender" DEFAULT 'UNKNOWN',
61 | "status" "user_status" DEFAULT 'NORMAL',
62 | "mobile" varchar(20),
63 | "mobile_confirmed" "verification_status" DEFAULT 'UNVERIFIED',
64 | "email" varchar(128),
65 | "email_confirmed" "verification_status" DEFAULT 'UNVERIFIED',
66 | "avatar" text,
67 | "department_ids" jsonb DEFAULT '[]'::jsonb,
68 | "enterprise_ids" jsonb DEFAULT '[]'::jsonb,
69 | "role_ids" jsonb DEFAULT '[]'::jsonb,
70 | "wx_unionid" varchar(64),
71 | "wx_openid" jsonb,
72 | "qq_openid" jsonb,
73 | "qq_unionid" varchar(64),
74 | "ali_openid" varchar(64),
75 | "apple_openid" varchar(64),
76 | "dcloud_appids" jsonb DEFAULT '[]'::jsonb,
77 | "comment" text,
78 | "third_party" jsonb,
79 | "register_env" jsonb,
80 | "realname_auth" jsonb,
81 | "score" integer DEFAULT 0,
82 | "register_date" timestamp,
83 | "register_ip" varchar(45),
84 | "last_login_date" timestamp,
85 | "last_login_ip" varchar(45),
86 | "tokens" jsonb DEFAULT '[]'::jsonb,
87 | "inviter_uids" jsonb DEFAULT '[]'::jsonb,
88 | "invite_time" timestamp,
89 | "my_invite_code" varchar(32),
90 | "identities" jsonb DEFAULT '[]'::jsonb,
91 | CONSTRAINT "client_users_username_unique" UNIQUE("username"),
92 | CONSTRAINT "client_users_mobile_unique" UNIQUE("mobile"),
93 | CONSTRAINT "client_users_email_unique" UNIQUE("email"),
94 | CONSTRAINT "client_users_wxUnionid_unique" UNIQUE("wx_unionid"),
95 | CONSTRAINT "client_users_qqUnionid_unique" UNIQUE("qq_unionid"),
96 | CONSTRAINT "client_users_aliOpenid_unique" UNIQUE("ali_openid"),
97 | CONSTRAINT "client_users_appleOpenid_unique" UNIQUE("apple_openid"),
98 | CONSTRAINT "client_users_myInviteCode_unique" UNIQUE("my_invite_code")
99 | );
100 | --> statement-breakpoint
101 | ALTER TABLE "system_user_roles" ADD CONSTRAINT "system_user_roles_user_id_system_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."system_users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
102 | ALTER TABLE "system_user_roles" ADD CONSTRAINT "system_user_roles_role_id_system_roles_id_fk" FOREIGN KEY ("role_id") REFERENCES "public"."system_roles"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
103 | CREATE INDEX "idx_casbin_g_v0" ON "casbin_rule" USING btree ("ptype","v0","v1");--> statement-breakpoint
104 | CREATE INDEX "idx_user_roles_user_id" ON "system_user_roles" USING btree ("user_id");--> statement-breakpoint
105 | CREATE INDEX "idx_user_roles_role_id" ON "system_user_roles" USING btree ("role_id");--> statement-breakpoint
106 | CREATE INDEX "system_user_username_idx" ON "system_users" USING btree ("username");--> statement-breakpoint
107 | CREATE INDEX "client_users_username_idx" ON "client_users" USING btree ("username");--> statement-breakpoint
108 | CREATE INDEX "client_users_mobile_idx" ON "client_users" USING btree ("mobile");--> statement-breakpoint
109 | CREATE INDEX "client_users_email_idx" ON "client_users" USING btree ("email");--> statement-breakpoint
110 | CREATE INDEX "client_users_status_idx" ON "client_users" USING btree ("status");--> statement-breakpoint
111 | CREATE INDEX "client_users_register_date_idx" ON "client_users" USING btree ("register_date" DESC NULLS LAST);--> statement-breakpoint
112 | CREATE INDEX "client_users_last_login_date_idx" ON "client_users" USING btree ("last_login_date" DESC NULLS LAST);
--------------------------------------------------------------------------------
/migrations/seed/index.ts:
--------------------------------------------------------------------------------
1 | import { hash } from "@node-rs/argon2";
2 | import { eq } from "drizzle-orm";
3 | import db from "@/db";
4 | import { systemUsers, systemRoles, casbinRule, systemUserRoles } from "@/db/schema";
5 | import { Status } from "@/lib/enums/common";
6 |
7 | // 使用 logger,避免 console.log。这里只为 seed 脚本,允许必要时用 console,但建议更换 logger
8 | const logPrefix = "[数据种子]";
9 |
10 | async function seedUsers() {
11 | try {
12 | console.info(`${logPrefix} 开始写入用户...`);
13 | const adminPasswordHash = await hash("123456");
14 | const userPasswordHash = await hash("123456");
15 |
16 | let [adminUser] = await db.insert(systemUsers)
17 | .values({
18 | username: "admin",
19 | password: adminPasswordHash,
20 | nickName: "管理员",
21 | status: Status.ENABLED,
22 | builtIn: true,
23 | })
24 | .onConflictDoNothing()
25 | .returning();
26 |
27 | // 如果插入冲突,从数据库查询
28 | if (!adminUser) {
29 | [adminUser] = await db.select().from(systemUsers).where(eq(systemUsers.username, "admin"));
30 | }
31 |
32 | let [regularUser] = await db.insert(systemUsers)
33 | .values({
34 | username: "user",
35 | password: userPasswordHash,
36 | nickName: "普通用户",
37 | status: Status.ENABLED,
38 | builtIn: false,
39 | })
40 | .onConflictDoNothing()
41 | .returning();
42 |
43 | // 如果插入冲突,从数据库查询
44 | if (!regularUser) {
45 | [regularUser] = await db.select().from(systemUsers).where(eq(systemUsers.username, "user"));
46 | }
47 |
48 | console.info(`${logPrefix} 已创建用户 admin (${adminUser?.id}), user (${regularUser?.id})`);
49 | return { adminUser, regularUser };
50 | } catch (error) {
51 | console.error(`${logPrefix} 写入用户失败:`, error);
52 | return { adminUser: null, regularUser: null };
53 | }
54 | }
55 |
56 | async function seedRoles() {
57 | try {
58 | console.info(`${logPrefix} 开始写入角色...`);
59 |
60 | let [adminRole] = await db.insert(systemRoles)
61 | .values({
62 | id: "admin",
63 | name: "管理员",
64 | description: "系统管理员角色,拥有所有权限",
65 | status: Status.ENABLED,
66 | })
67 | .onConflictDoNothing()
68 | .returning();
69 |
70 | // 如果插入冲突,从数据库查询
71 | if (!adminRole) {
72 | [adminRole] = await db.select().from(systemRoles).where(eq(systemRoles.id, "admin"));
73 | }
74 |
75 | let [userRole] = await db.insert(systemRoles)
76 | .values({
77 | id: "user",
78 | name: "普通用户",
79 | description: "普通用户角色,拥有基本权限",
80 | status: Status.ENABLED,
81 | })
82 | .onConflictDoNothing()
83 | .returning();
84 |
85 | // 如果插入冲突,从数据库查询
86 | if (!userRole) {
87 | [userRole] = await db.select().from(systemRoles).where(eq(systemRoles.id, "user"));
88 | }
89 |
90 | console.info(`${logPrefix} 已创建角色 admin (${adminRole?.id}), user (${userRole?.id})`);
91 | return { adminRole, userRole };
92 | } catch (error) {
93 | console.error(`${logPrefix} 写入角色失败:`, error);
94 | return { adminRole: null, userRole: null };
95 | }
96 | }
97 |
98 | async function seedUserRoles(users: any, roles: any) {
99 | try {
100 | console.info(`${logPrefix} 开始写入用户-角色关联...`);
101 | if (!users?.adminUser || !users?.regularUser || !roles?.adminRole || !roles?.userRole) {
102 | console.warn(`${logPrefix} 跳过用户-角色关联:未找到用户或角色`);
103 | return;
104 | }
105 | await db.insert(systemUserRoles)
106 | .values({
107 | userId: users.adminUser.id,
108 | roleId: roles.adminRole.id,
109 | })
110 | .onConflictDoNothing();
111 | await db.insert(systemUserRoles)
112 | .values({
113 | userId: users.regularUser.id,
114 | roleId: roles.userRole.id,
115 | })
116 | .onConflictDoNothing();
117 | console.info(`${logPrefix} 已创建用户-角色关联`);
118 | } catch (error) {
119 | console.error(`${logPrefix} 写入用户-角色关联失败:`, error);
120 | }
121 | }
122 |
123 | async function seedCasbinRules(roles: any) {
124 | try {
125 | console.info(`${logPrefix} 开始写入 Casbin 规则...`);
126 | if (!roles?.adminRole) {
127 | console.warn(`${logPrefix} 跳过 Casbin 规则 seed:未找到 admin 角色`);
128 | return;
129 | }
130 | const adminRules = [
131 | { v1: "/system/roles", v2: "GET" },
132 | { v1: "/system/roles", v2: "POST" },
133 | { v1: "/system/roles/{id}", v2: "DELETE" },
134 | { v1: "/system/roles/{id}", v2: "GET" },
135 | { v1: "/system/roles/{id}", v2: "PATCH" },
136 | { v1: "/system/roles/{id}/permissions", v2: "GET" },
137 | { v1: "/system/roles/{id}/permissions", v2: "PUT" },
138 | { v1: "/system/users", v2: "GET" },
139 | { v1: "/system/users", v2: "POST" },
140 | { v1: "/system/users/{id}", v2: "DELETE" },
141 | { v1: "/system/users/{id}", v2: "GET" },
142 | { v1: "/system/users/{id}", v2: "PATCH" },
143 | { v1: "/system/users/{id}/roles", v2: "PUT" },
144 | ];
145 | for (const rule of adminRules) {
146 | await db.insert(casbinRule)
147 | .values({
148 | ptype: "p",
149 | v0: "admin",
150 | v1: rule.v1,
151 | v2: rule.v2,
152 | v3: "allow",
153 | v4: "",
154 | v5: "",
155 | })
156 | .onConflictDoNothing();
157 | }
158 | console.info(`${logPrefix} 已为 admin 角色创建 ${adminRules.length} 条 Casbin 规则`);
159 | } catch (error) {
160 | console.error(`${logPrefix} 写入 Casbin 规则失败:`, error);
161 | throw error;
162 | }
163 | }
164 |
165 | async function main() {
166 | // 标记整体 process 是否有 seed 失败
167 | let hasError = false;
168 | console.info(`${logPrefix} 🚀 开始种子数据写入...`);
169 | // 每个 seed 单独 try-catch,任何失败不影响下一个
170 | let users: any = {};
171 | let roles: any = {};
172 | try {
173 | users = await seedUsers();
174 | } catch (e) {
175 | hasError = true;
176 | }
177 | try {
178 | roles = await seedRoles();
179 | } catch (e) {
180 | hasError = true;
181 | }
182 | try {
183 | await seedUserRoles(users, roles);
184 | } catch (e) {
185 | hasError = true;
186 | }
187 | try {
188 | await seedCasbinRules(roles);
189 | } catch (e) {
190 | hasError = true;
191 | }
192 |
193 | if (hasError) {
194 | console.error(`${logPrefix} ❌ 部分数据种子写入失败,请检查上方日志`);
195 | process.exit(1);
196 | } else {
197 | console.info(`${logPrefix} 🎉 全部数据种子写入成功!`);
198 | process.exit(0);
199 | }
200 | }
201 |
202 | main();
203 |
--------------------------------------------------------------------------------
/src/routes/admin/auth/handlers.ts:
--------------------------------------------------------------------------------
1 | import { verify } from "@node-rs/argon2";
2 | import { eq } from "drizzle-orm";
3 | import { deleteCookie, getCookie, setCookie } from "hono/cookie";
4 |
5 | import db from "@/db";
6 | import { systemUserRoles, systemUsers } from "@/db/schema";
7 | import env from "@/env";
8 | import cap from "@/lib/cap";
9 | import { enforcerPromise } from "@/lib/casbin";
10 | import { REFRESH_TOKEN_EXPIRES_DAYS } from "@/lib/constants";
11 | import { Status } from "@/lib/enums";
12 | import * as HttpStatusCodes from "@/lib/stoker/http-status-codes";
13 | import * as HttpStatusPhrases from "@/lib/stoker/http-status-phrases";
14 | import { generateTokens, logout as logoutUtil, refreshAccessToken } from "@/services/admin";
15 | import { Resp, toColumns, tryit } from "@/utils";
16 |
17 | import type { AuthRouteHandlerType } from ".";
18 |
19 | /** 管理端登录 */
20 | export const login: AuthRouteHandlerType<"login"> = async (c) => {
21 | const body = c.req.valid("json");
22 |
23 | const { username, password } = body;
24 |
25 | // 1. 验证验证码token(生产环境必须验证)
26 | const { success } = await cap.validateToken(body.captchaToken);
27 | if (!success && env.NODE_ENV === "production") {
28 | return c.json(Resp.fail("验证码错误"), HttpStatusCodes.BAD_REQUEST);
29 | }
30 |
31 | // 2. 查询用户基本信息并验证
32 | const user = await db.query.systemUsers.findFirst({
33 | where: eq(systemUsers.username, username),
34 | columns: {
35 | id: true,
36 | username: true,
37 | password: true,
38 | status: true,
39 | },
40 | });
41 |
42 | // 统一的用户验证逻辑
43 | if (!user) {
44 | return c.json(Resp.fail("用户名或密码错误"), HttpStatusCodes.UNAUTHORIZED);
45 | }
46 |
47 | if (user.status !== Status.ENABLED) {
48 | return c.json(Resp.fail(HttpStatusPhrases.FORBIDDEN), HttpStatusCodes.FORBIDDEN);
49 | }
50 |
51 | // 3. 验证密码和查询角色
52 | const isPasswordValid = await verify(user.password, password);
53 |
54 | if (!isPasswordValid) {
55 | return c.json(Resp.fail("用户名或密码错误"), HttpStatusCodes.UNAUTHORIZED);
56 | }
57 |
58 | const userRoles = await db.query.systemUserRoles.findMany({
59 | where: eq(systemUserRoles.userId, user.id),
60 | });
61 |
62 | const { refreshToken, accessToken } = await generateTokens({
63 | id: user.id,
64 | roles: userRoles.map(({ roleId }) => roleId),
65 | });
66 |
67 | // 4. 设置 HttpOnly Refresh Token Cookie
68 | setCookie(c, "refreshToken", refreshToken, {
69 | httpOnly: true, // 防止 JS 访问
70 | secure: env.NODE_ENV === "production", // 生产环境启用 https
71 | sameSite: "strict", // 防止 CSRF
72 | maxAge: 60 * 60 * 24 * REFRESH_TOKEN_EXPIRES_DAYS,
73 | path: "/", // 所有路径可访问
74 | });
75 |
76 | return c.json(Resp.ok({ accessToken }), HttpStatusCodes.OK);
77 | };
78 |
79 | /** 刷新 Token */
80 | export const refreshToken: AuthRouteHandlerType<"refreshToken"> = async (c) => {
81 | const refreshTokenFromCookie = getCookie(c, "refreshToken");
82 |
83 | if (!refreshTokenFromCookie) {
84 | return c.json(Resp.fail("刷新令牌不存在"), HttpStatusCodes.UNAUTHORIZED);
85 | }
86 |
87 | const [err, res] = await tryit(refreshAccessToken)(refreshTokenFromCookie);
88 |
89 | if (err) {
90 | return c.json(Resp.fail(err.message), HttpStatusCodes.UNAUTHORIZED);
91 | }
92 |
93 | const { accessToken, refreshToken: newRefreshToken } = res;
94 |
95 | // 设置新的 HttpOnly Refresh Token Cookie
96 | setCookie(c, "refreshToken", newRefreshToken, {
97 | httpOnly: true, // 防止 JS 访问
98 | secure: env.NODE_ENV === "production", // 生产环境启用 https
99 | sameSite: "strict", // 防止 CSRF
100 | maxAge: 60 * 60 * 24 * REFRESH_TOKEN_EXPIRES_DAYS,
101 | path: "/", // 所有路径可访问
102 | });
103 |
104 | return c.json(Resp.ok({ accessToken }), HttpStatusCodes.OK);
105 | };
106 |
107 | /** 退出登录 */
108 | export const logout: AuthRouteHandlerType<"logout"> = async (c) => {
109 | const payload = c.get("jwtPayload");
110 |
111 | // 清理用户的所有刷新令牌
112 | await logoutUtil(payload.sub);
113 |
114 | // 删除 refreshToken cookie
115 | deleteCookie(c, "refreshToken", {
116 | path: "/",
117 | });
118 |
119 | return c.json(Resp.ok({}), HttpStatusCodes.OK);
120 | };
121 |
122 | /** 获取用户信息 */
123 | export const getIdentity: AuthRouteHandlerType<"getIdentity"> = async (c) => {
124 | const { sub } = c.get("jwtPayload");
125 |
126 | // 查询用户信息
127 | const user = await db.query.systemUsers.findFirst({
128 | where: eq(systemUsers.id, sub),
129 | columns: toColumns(["id", "username", "avatar", "nickName"]),
130 | with: {
131 | systemUserRoles: {
132 | columns: {
133 | roleId: true,
134 | },
135 | },
136 | },
137 | });
138 |
139 | if (!user) {
140 | return c.json(Resp.fail(HttpStatusPhrases.NOT_FOUND), HttpStatusCodes.NOT_FOUND);
141 | }
142 |
143 | const { systemUserRoles, ...userWithoutRoles } = user;
144 | const roles = systemUserRoles.map(({ roleId }) => roleId);
145 |
146 | return c.json(Resp.ok({ ...userWithoutRoles, roles }), HttpStatusCodes.OK);
147 | };
148 |
149 | /** 获取用户权限 */
150 | export const getPermissions: AuthRouteHandlerType<"getPermissions"> = async (c) => {
151 | const { roles } = c.get("jwtPayload");
152 |
153 | if (!roles || roles.length === 0) {
154 | return c.json(Resp.fail(HttpStatusPhrases.NOT_FOUND), HttpStatusCodes.NOT_FOUND);
155 | }
156 |
157 | const casbinEnforcer = await enforcerPromise;
158 | const permissionsSet = new Set();
159 | const groupingsSet = new Set();
160 |
161 | // 遍历角色,逐个处理权限(避免一次性创建大量中间数组)
162 | for (const role of roles) {
163 | // 获取当前角色的所有权限(包括通过角色继承获得的权限)
164 | const perms = await casbinEnforcer.getImplicitPermissionsForUser(role);
165 |
166 | // 处理当前角色的每一项权限
167 | for (const perm of perms) {
168 | // 过滤空数组
169 | if (!perm || perm.length === 0)
170 | continue;
171 |
172 | // 过滤空字符串元素并修剪
173 | const filteredPerm = perm.filter(item => item && item.trim() !== "");
174 |
175 | // 过滤处理后为空的数组
176 | if (filteredPerm.length === 0)
177 | continue;
178 |
179 | // 转换为Casbin策略字符串并加入Set(自动去重)
180 | const permStr = `p, ${filteredPerm.join(", ")}`;
181 | permissionsSet.add(permStr);
182 | }
183 | }
184 |
185 | // 获取所有角色继承关系(g 策略)
186 | const allGroupings = await casbinEnforcer.getGroupingPolicy();
187 | for (const grouping of allGroupings) {
188 | // 过滤空数组和空字符串
189 | if (!grouping || grouping.length === 0)
190 | continue;
191 |
192 | const filteredGrouping = grouping.filter(item => item && item.trim() !== "");
193 | if (filteredGrouping.length === 0)
194 | continue;
195 |
196 | // 转换为Casbin g策略字符串
197 | const groupingStr = `g, ${filteredGrouping.join(", ")}`;
198 | groupingsSet.add(groupingStr);
199 | }
200 |
201 | // 转换为最终数组
202 | const permissions = Array.from(permissionsSet);
203 | const groupings = Array.from(groupingsSet);
204 |
205 | return c.json(Resp.ok({ permissions, groupings }), HttpStatusCodes.OK);
206 | };
207 |
208 | /** 生成验证码挑战 */
209 | export const createChallenge: AuthRouteHandlerType<"createChallenge"> = async (c) => {
210 | const challenge = await cap.createChallenge();
211 |
212 | // cap.js 必须直接返回 challenge 对象,不能包装在 Resp.ok() 中
213 | return c.json(challenge, HttpStatusCodes.OK);
214 | };
215 |
216 | /** 验证用户解答并生成验证token */
217 | export const redeemChallenge: AuthRouteHandlerType<"redeemChallenge"> = async (c) => {
218 | const { token, solutions } = c.req.valid("json");
219 |
220 | const result = await cap.redeemChallenge({ token, solutions });
221 |
222 | // cap.js 必须直接返回 challenge 对象,不能包装在 Resp.ok() 中
223 | return c.json(result, HttpStatusCodes.OK);
224 | };
225 |
--------------------------------------------------------------------------------
/src/db/schema/client/users/users.ts:
--------------------------------------------------------------------------------
1 | import { index, integer, jsonb, pgTable, text, timestamp, unique, varchar } from "drizzle-orm/pg-core";
2 | import { createInsertSchema, createSelectSchema } from "drizzle-zod";
3 | import { z } from "zod";
4 |
5 | import type { Identity, QqOpenId, RealNameAuth, RegisterEnv, ThirdPartyInfo, WxOpenId } from "@/db/schema/_shard/types/app-user";
6 |
7 | import { baseColumns } from "@/db/schema/_shard/base-columns";
8 | import { genderEnum, userStatusEnum, verificationStatusEnum } from "@/db/schema/_shard/enums";
9 | import { Gender, UserStatus, VerificationStatus } from "@/lib/enums";
10 |
11 | export const clientUsers = pgTable("client_users", {
12 | ...baseColumns,
13 | /** 用户名 */
14 | username: varchar({ length: 64 }).notNull(),
15 | /** 密码,加密存储 */
16 | password: varchar({ length: 128 }).notNull(),
17 | /** 密码密钥版本 */
18 | passwordSecretVersion: integer().default(1),
19 | /** 用户昵称 */
20 | nickname: varchar({ length: 64 }),
21 | /** 性别 */
22 | gender: genderEnum().default(Gender.UNKNOWN),
23 | /** 用户状态 */
24 | status: userStatusEnum().default(UserStatus.NORMAL),
25 | /** 手机号码 */
26 | mobile: varchar({ length: 20 }),
27 | /** 手机号验证状态 */
28 | mobileConfirmed: verificationStatusEnum().default(VerificationStatus.UNVERIFIED),
29 | /** 邮箱地址 */
30 | email: varchar({ length: 128 }),
31 | /** 邮箱验证状态 */
32 | emailConfirmed: verificationStatusEnum().default(VerificationStatus.UNVERIFIED),
33 | /** 头像地址 */
34 | avatar: text(),
35 | /** 部门ID列表 */
36 | departmentIds: jsonb().$type().default([]),
37 | /** 企业ID列表 */
38 | enterpriseIds: jsonb().$type().default([]),
39 | /** 角色ID列表 */
40 | roleIds: jsonb().$type().default([]),
41 | /** 微信 unionid */
42 | wxUnionid: varchar({ length: 64 }),
43 | /** 微信各平台 openid */
44 | wxOpenid: jsonb().$type(),
45 | /** QQ各平台 openid */
46 | qqOpenid: jsonb().$type(),
47 | /** QQ unionid */
48 | qqUnionid: varchar({ length: 64 }),
49 | /** 支付宝 openid */
50 | aliOpenid: varchar({ length: 64 }),
51 | /** 苹果登录 openid */
52 | appleOpenid: varchar({ length: 64 }),
53 | /** 允许登录的客户端 appid 列表 */
54 | dcloudAppids: jsonb().$type().default([]),
55 | /** 备注 */
56 | comment: text(),
57 | /** 第三方平台token等信息 */
58 | thirdParty: jsonb().$type(),
59 | /** 注册环境信息 */
60 | registerEnv: jsonb().$type(),
61 | /** 实名认证信息 */
62 | realnameAuth: jsonb().$type(),
63 | /** 用户积分 */
64 | score: integer().default(0),
65 | /** 注册时间 */
66 | registerDate: timestamp({ mode: "string" }),
67 | /** 注册时 IP 地址 */
68 | registerIp: varchar({ length: 45 }),
69 | /** 最后登录时间 */
70 | lastLoginDate: timestamp({ mode: "string" }),
71 | /** 最后登录时 IP 地址 */
72 | lastLoginIp: varchar({ length: 45 }),
73 | /** 用户token列表 */
74 | tokens: jsonb().$type().default([]),
75 | /** 用户全部上级邀请者 */
76 | inviterUids: jsonb().$type().default([]),
77 | /** 受邀时间 */
78 | inviteTime: timestamp({ mode: "string" }),
79 | /** 用户自身邀请码 */
80 | myInviteCode: varchar({ length: 32 }),
81 | /** 第三方平台身份信息 */
82 | identities: jsonb().$type().default([]),
83 | }, table => [
84 | // 唯一约束
85 | unique().on(table.username),
86 | unique().on(table.mobile),
87 | unique().on(table.email),
88 | unique().on(table.wxUnionid),
89 | unique().on(table.qqUnionid),
90 | unique().on(table.aliOpenid),
91 | unique().on(table.appleOpenid),
92 | unique().on(table.myInviteCode),
93 |
94 | // 查询索引
95 | index("client_users_username_idx").on(table.username),
96 | index("client_users_mobile_idx").on(table.mobile),
97 | index("client_users_email_idx").on(table.email),
98 | index("client_users_status_idx").on(table.status),
99 | index("client_users_register_date_idx").on(table.registerDate.desc()),
100 | index("client_users_last_login_date_idx").on(table.lastLoginDate.desc()),
101 | ]);
102 |
103 | export const selectClientUsersSchema = createSelectSchema(clientUsers, {
104 | username: schema => schema.meta({ description: "用户名" }),
105 | password: schema => schema.meta({ description: "密码" }),
106 | passwordSecretVersion: schema => schema.meta({ description: "密码密钥版本" }),
107 | nickname: schema => schema.meta({ description: "用户昵称" }),
108 | gender: schema => schema.meta({ description: "性别 (UNKNOWN=未知, MALE=男性, FEMALE=女性)" }),
109 | status: schema => schema.meta({ description: "用户状态 (NORMAL=正常, DISABLED=禁用, PENDING=审核中, REJECTED=审核拒绝)" }),
110 | mobile: schema => schema.meta({ description: "手机号码" }),
111 | mobileConfirmed: schema => schema.meta({ description: "手机验证状态 (UNVERIFIED=未验证, VERIFIED=已验证)" }),
112 | email: schema => schema.meta({ description: "邮箱地址" }),
113 | emailConfirmed: schema => schema.meta({ description: "邮箱验证状态 (UNVERIFIED=未验证, VERIFIED=已验证)" }),
114 | avatar: schema => schema.meta({ description: "头像地址" }),
115 | score: schema => schema.meta({ description: "用户积分" }),
116 | comment: schema => schema.meta({ description: "备注" }),
117 | registerDate: schema => schema.meta({ description: "注册时间" }),
118 | registerIp: schema => schema.meta({ description: "注册时 IP 地址" }),
119 | lastLoginDate: schema => schema.meta({ description: "最后登录时间" }),
120 | lastLoginIp: schema => schema.meta({ description: "最后登录时 IP 地址" }),
121 | myInviteCode: schema => schema.meta({ description: "用户自身邀请码" }),
122 | inviteTime: schema => schema.meta({ description: "受邀时间" }),
123 | });
124 |
125 | export const insertClientUsersSchema = createInsertSchema(
126 | clientUsers,
127 | {
128 | username: z.string()
129 | .min(4, "用户名最少4个字符")
130 | .max(32, "用户名最多32个字符")
131 | .regex(/^\w+$/, "用户名只能包含字母、数字和下划线")
132 | .meta({ description: "用户名" }),
133 | password: z.string()
134 | .min(6, "密码最少6个字符")
135 | .max(20, "密码最多20个字符")
136 | .meta({ description: "密码" }),
137 | nickname: z.string()
138 | .min(1, "昵称不能为空")
139 | .max(32, "昵称最多32个字符")
140 | .optional()
141 | .meta({ description: "用户昵称" }),
142 | mobile: z.string()
143 | .regex(/^\+?[0-9-]{3,20}$/, "手机号格式不正确")
144 | .optional()
145 | .meta({ description: "手机号码" }),
146 | email: z
147 | .email("邮箱格式不正确")
148 | .optional()
149 | .meta({ description: "邮箱地址" }),
150 | gender: schema => schema
151 | .optional()
152 | .meta({ description: "性别 (UNKNOWN=未知, MALE=男性, FEMALE=女性)" }),
153 | status: schema => schema
154 | .optional()
155 | .meta({ description: "用户状态 (NORMAL=正常, DISABLED=禁用, PENDING=审核中, REJECTED=审核拒绝)" }),
156 | score: z.number()
157 | .min(0)
158 | .optional()
159 | .meta({ description: "用户积分" }),
160 | comment: z.string()
161 | .max(500, "备注最多500个字符")
162 | .optional()
163 | .meta({ description: "备注" }),
164 | registerIp: z.string()
165 | .regex(/^(?:(?:25[0-5]|2[0-4]\d|[01]?\d{1,2})\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d{1,2})$|^(?:[0-9a-f]{1,4}:){7}[0-9a-f]{1,4}$/i, "IP地址格式不正确")
166 | .optional()
167 | .meta({ description: "注册时 IP 地址" }),
168 | lastLoginIp: z.string()
169 | .regex(/^(?:(?:25[0-5]|2[0-4]\d|[01]?\d{1,2})\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d{1,2})$|^(?:[0-9a-f]{1,4}:){7}[0-9a-f]{1,4}$/i, "IP地址格式不正确")
170 | .optional()
171 | .meta({ description: "最后登录时 IP 地址" }),
172 | myInviteCode: z.string()
173 | .max(32, "邀请码最多32个字符")
174 | .optional()
175 | .meta({ description: "用户自身邀请码" }),
176 | },
177 | ).omit({
178 | id: true,
179 | createdAt: true,
180 | updatedAt: true,
181 | createdBy: true,
182 | updatedBy: true,
183 | passwordSecretVersion: true,
184 | mobileConfirmed: true,
185 | emailConfirmed: true,
186 | registerDate: true,
187 | lastLoginDate: true,
188 | tokens: true,
189 | });
190 |
191 | export const patchClientUsersSchema = insertClientUsersSchema.partial();
192 |
--------------------------------------------------------------------------------
/src/lib/refine-query/schemas.ts:
--------------------------------------------------------------------------------
1 | import type { SQL } from "drizzle-orm";
2 | import type { PgColumn, PgTable } from "drizzle-orm/pg-core";
3 | import type { EmptyObject, Simplify, UnknownRecord } from "type-fest";
4 |
5 | import { z } from "@hono/zod-openapi";
6 |
7 | import logger from "@/lib/logger";
8 |
9 | /**
10 | * Refine CRUD 操作符 Schema
11 | */
12 | export const CrudOperatorsSchema = z.enum([
13 | // 相等性操作符
14 | "eq",
15 | "ne",
16 | // 比较操作符
17 | "lt",
18 | "gt",
19 | "lte",
20 | "gte",
21 | // 数组操作符
22 | "in",
23 | "nin",
24 | "ina",
25 | "nina",
26 | // 字符串操作符
27 | "contains",
28 | "ncontains",
29 | "containss",
30 | "ncontainss",
31 | // 范围操作符
32 | "between",
33 | "nbetween",
34 | // 空值操作符
35 | "null",
36 | "nnull",
37 | // 字符串匹配操作符
38 | "startswith",
39 | "nstartswith",
40 | "startswiths",
41 | "nstartswiths",
42 | "endswith",
43 | "nendswith",
44 | "endswiths",
45 | "nendswiths",
46 | // 逻辑操作符
47 | "or",
48 | "and",
49 | ]);
50 |
51 | /**
52 | * 逻辑过滤器 Schema
53 | */
54 | export const LogicalFilterSchema = z.object({
55 | field: z.string().min(1, "字段名不能为空"),
56 | operator: CrudOperatorsSchema.exclude(["or", "and"]),
57 | value: z.any(),
58 | });
59 |
60 | /**
61 | * 条件过滤器 Schema (递归定义)
62 | */
63 | export const ConditionalFilterSchema: z.ZodType = z.lazy(() =>
64 | z.object({
65 | key: z.string().optional(),
66 | operator: z.enum(["or", "and"]),
67 | value: z.array(z.union([LogicalFilterSchema, ConditionalFilterSchema])),
68 | }),
69 | );
70 |
71 | /**
72 | * CRUD 过滤器 Schema
73 | */
74 | export const CrudFilterSchema = z.union([
75 | LogicalFilterSchema,
76 | ConditionalFilterSchema,
77 | ]);
78 |
79 | /**
80 | * CRUD 过滤器数组 Schema
81 | */
82 | export const CrudFiltersSchema = z.array(CrudFilterSchema);
83 |
84 | /**
85 | * CRUD 排序 Schema
86 | */
87 | export const CrudSortSchema = z.object({
88 | field: z.string().min(1, "排序字段名不能为空"),
89 | order: z.enum(["asc", "desc"]),
90 | });
91 |
92 | /**
93 | * CRUD 排序数组 Schema
94 | */
95 | export const CrudSortingSchema = z.array(CrudSortSchema);
96 |
97 | /**
98 | * 分页 Schema
99 | */
100 | export const PaginationSchema = z.object({
101 | current: z.coerce.number().int().positive().optional(),
102 | pageSize: z.coerce.number().int().positive().max(100).optional(),
103 | mode: z.enum(["client", "server", "off"]).optional(),
104 | });
105 |
106 | /**
107 | * 辅助函数:检查对象深度
108 | */
109 | function getDepth(obj: any, depth = 0): number {
110 | if (depth > 10)
111 | return depth; // 防止无限递归
112 | if (typeof obj !== "object" || obj === null)
113 | return depth;
114 |
115 | let maxDepth = depth;
116 | for (const value of Object.values(obj)) {
117 | if (typeof value === "object" && value !== null) {
118 | maxDepth = Math.max(maxDepth, getDepth(value, depth + 1));
119 | }
120 | }
121 | return maxDepth;
122 | }
123 |
124 | /**
125 | * JSON 字符串预处理函数
126 | * 安全地解析 JSON 字符串参数
127 | */
128 | function safeJsonPreprocess(val: unknown): unknown {
129 | if (val === undefined || val === null) {
130 | return undefined;
131 | }
132 |
133 | if (typeof val === "string") {
134 | const trimmed = val.trim();
135 |
136 | // 增加长度限制,防止DoS攻击
137 | if (trimmed.length > 10000) {
138 | logger.warn("[查询参数]: JSON字符串过长,已截断");
139 | return undefined;
140 | }
141 |
142 | // 检查基本的安全模式
143 | if (trimmed === "" || trimmed === "null" || trimmed === "undefined") {
144 | return undefined;
145 | }
146 | if (trimmed === "{}") {
147 | return {};
148 | }
149 | if (trimmed === "[]") {
150 | return [];
151 | }
152 |
153 | try {
154 | const parsed = JSON.parse(trimmed);
155 | // 递归深度限制,防止深层嵌套攻击
156 | if (getDepth(parsed) > 5) {
157 | logger.warn("[查询参数]: JSON嵌套层级过深");
158 | return undefined;
159 | }
160 | return parsed;
161 | }
162 | catch (error) {
163 | // 记录解析错误但不暴露详细信息
164 | logger.warn({ error: error instanceof Error ? error.message : String(error) }, "[查询参数]: JSON解析失败");
165 | return undefined;
166 | }
167 | }
168 |
169 | return val;
170 | }
171 |
172 | /**
173 | * Refine 查询参数 Schema
174 | * 用于验证来自前端的查询参数
175 | */
176 | export const RefineQueryParamsSchema = z.object({
177 | // 分页参数
178 | current: z.coerce.number().int().positive().optional().default(1).openapi({
179 | description: "当前页码",
180 | example: 1,
181 | }),
182 |
183 | pageSize: z.coerce.number().int().positive().max(100).optional().default(10).openapi({
184 | description: "每页大小",
185 | example: 10,
186 | }),
187 |
188 | mode: z.enum(["server", "client", "off"]).optional().default("server").openapi({
189 | description: "分页模式:server=服务端分页,client=客户端分页,off=不分页",
190 | example: "server",
191 | }),
192 |
193 | // 过滤条件(支持 JSON 字符串)
194 | filters: z.preprocess(
195 | safeJsonPreprocess,
196 | CrudFiltersSchema.optional(),
197 | ).optional().openapi({
198 | type: "string",
199 | description: "过滤条件,JSON 字符串格式",
200 | example: JSON.stringify([
201 | { field: "status", operator: "eq", value: "active" },
202 | { field: "name", operator: "contains", value: "john" },
203 | ]),
204 | }),
205 |
206 | // 排序条件(支持 JSON 字符串)
207 | sorters: z.preprocess(
208 | safeJsonPreprocess,
209 | CrudSortingSchema.optional(),
210 | ).optional().openapi({
211 | type: "string",
212 | description: "排序条件,JSON 字符串格式",
213 | example: JSON.stringify([
214 | { field: "createdAt", order: "desc" },
215 | { field: "name", order: "asc" },
216 | ]),
217 | }),
218 | }).partial();
219 |
220 | /**
221 | * 结果 Schema
222 | * 用于 API 响应的结构定义
223 | */
224 | export function RefineResultSchema(dataSchema: T) {
225 | return z.object({
226 | data: dataSchema,
227 | });
228 | }
229 |
230 | /**
231 | * 从 Zod schemas 推导的类型定义
232 | */
233 | export type CrudOperators = z.infer;
234 | export type LogicalFilter = z.infer;
235 | export type ConditionalFilter = z.infer;
236 | export type CrudFilter = z.infer;
237 | export type CrudFilters = z.infer;
238 | export type CrudSort = z.infer;
239 | export type CrudSorting = z.infer;
240 | export type Pagination = z.infer;
241 | export type RefineQueryParams = z.infer;
242 |
243 | // ============================================================================
244 | // 查询配置和工具类型
245 | // ============================================================================
246 |
247 | /** Refine 查询结果接口 */
248 | export type RefineQueryResult = {
249 | data: T[];
250 | total: number;
251 | };
252 |
253 | /** 查询错误类型 */
254 | export class RefineQueryError extends Error {
255 | code?: string;
256 | constructor(message: string, code?: string) {
257 | super(message);
258 | this.name = "RefineQueryError";
259 | this.code = code;
260 | }
261 | }
262 |
263 | /** Join 类型 */
264 | export type JoinType = "inner" | "left" | "right";
265 |
266 | /** Join 定义接口 */
267 | export type JoinDefinition = Simplify<{
268 | table: PgTable;
269 | type: JoinType;
270 | on: SQL;
271 | }>;
272 |
273 | /** Join 查询配置接口 */
274 | export type JoinConfig = Simplify<{
275 | joins: readonly JoinDefinition[];
276 | selectFields?: Readonly>> | EmptyObject;
277 | groupBy?: readonly PgColumn[];
278 | }>;
279 |
280 | /** Refine 查询执行配置接口 */
281 | export type RefineQueryConfig<_T = UnknownRecord> = Simplify<{
282 | table: PgTable;
283 | queryParams: Simplify<{
284 | filters?: CrudFilters;
285 | sorters?: CrudSorting;
286 | pagination?: Pagination;
287 | }>;
288 | joinConfig?: JoinConfig;
289 | allowedFields?: readonly string[];
290 | }>;
291 |
292 | /** 查询执行参数接口 */
293 | export type QueryExecutionParams<_T = UnknownRecord> = Simplify<{
294 | resource: PgTable;
295 | filters?: CrudFilters;
296 | sorters?: CrudSorting;
297 | pagination?: Pagination;
298 | tableColumns?: Readonly> | EmptyObject;
299 | joinConfig?: JoinConfig;
300 | allowedFields?: readonly string[];
301 | }>;
302 |
303 | /** 元组结果类型,用于错误处理 */
304 | export type Result = [E, null] | [null, T];
305 |
--------------------------------------------------------------------------------
/src/routes/admin/system/users/handlers.ts:
--------------------------------------------------------------------------------
1 | import type { z } from "zod";
2 |
3 | import { hash } from "@node-rs/argon2";
4 | import { and, eq, inArray, sql } from "drizzle-orm";
5 |
6 | import db from "@/db";
7 | import { systemRoles, systemUserRoles, systemUsers } from "@/db/schema";
8 | import { executeRefineQuery, RefineQueryParamsSchema } from "@/lib/refine-query";
9 | import * as HttpStatusCodes from "@/lib/stoker/http-status-codes";
10 | import * as HttpStatusPhrases from "@/lib/stoker/http-status-phrases";
11 | import { omit, Resp } from "@/utils";
12 | import { mapDbError } from "@/utils/db-errors";
13 |
14 | import type { SystemUsersRouteHandlerType } from ".";
15 | import type { responseSystemUsersWithPassword } from "./schema";
16 |
17 | export const list: SystemUsersRouteHandlerType<"list"> = async (c) => {
18 | const query = c.req.query();
19 |
20 | const parseResult = RefineQueryParamsSchema.safeParse(query);
21 | if (!parseResult.success) {
22 | return c.json(Resp.fail(parseResult.error), HttpStatusCodes.UNPROCESSABLE_ENTITY);
23 | }
24 |
25 | const [error, result] = await executeRefineQuery>({
26 | table: systemUsers,
27 | queryParams: parseResult.data,
28 | joinConfig: {
29 | joins: [
30 | {
31 | table: systemUserRoles,
32 | type: "left",
33 | on: eq(systemUsers.id, systemUserRoles.userId),
34 | },
35 | {
36 | table: systemRoles,
37 | type: "left",
38 | on: eq(systemUserRoles.roleId, systemRoles.id),
39 | },
40 | ],
41 | selectFields: {
42 | id: systemUsers.id,
43 | username: systemUsers.username,
44 | nickName: systemUsers.nickName,
45 | roles: sql`json_agg(json_build_object('id', ${systemRoles.id}, 'name', ${systemRoles.name}))`,
46 | createdAt: systemUsers.createdAt,
47 | updatedAt: systemUsers.updatedAt,
48 | status: systemUsers.status,
49 | avatar: systemUsers.avatar,
50 | },
51 | groupBy: [systemUsers.id],
52 | },
53 | });
54 | if (error) {
55 | return c.json(Resp.fail(error.message), HttpStatusCodes.INTERNAL_SERVER_ERROR);
56 | }
57 |
58 | const safeData = result.data.map(({ password, ...user }) => user);
59 | c.header("x-total-count", result.total.toString());
60 |
61 | return c.json(Resp.ok(safeData), HttpStatusCodes.OK);
62 | };
63 |
64 | export const create: SystemUsersRouteHandlerType<"create"> = async (c) => {
65 | const body = c.req.valid("json");
66 | const { sub } = c.get("jwtPayload");
67 |
68 | try {
69 | const hashedPassword = await hash(body.password);
70 |
71 | const [created] = await db
72 | .insert(systemUsers)
73 | .values({
74 | ...body,
75 | password: hashedPassword,
76 | createdBy: sub,
77 | updatedBy: sub,
78 | })
79 | .returning();
80 |
81 | const userWithoutPassword = omit(created, ["password"]);
82 |
83 | return c.json(Resp.ok(userWithoutPassword), HttpStatusCodes.CREATED);
84 | }
85 | catch (error) {
86 | const pgError = mapDbError(error);
87 |
88 | if (pgError?.type === "UniqueViolation") {
89 | return c.json(Resp.fail("用户名已存在"), HttpStatusCodes.CONFLICT);
90 | }
91 |
92 | throw error;
93 | }
94 | };
95 |
96 | export const get: SystemUsersRouteHandlerType<"get"> = async (c) => {
97 | const { id } = c.req.valid("param");
98 |
99 | const user = await db.query.systemUsers.findFirst({
100 | where: eq(systemUsers.id, id),
101 | with: {
102 | systemUserRoles: {
103 | with: {
104 | roles: true,
105 | },
106 | },
107 | },
108 | });
109 |
110 | if (!user) {
111 | return c.json(Resp.fail(HttpStatusPhrases.NOT_FOUND), HttpStatusCodes.NOT_FOUND);
112 | }
113 |
114 | const roles = user.systemUserRoles.map(({ roles: { id, name } }) => ({ id, name }));
115 | const userWithoutPassword = omit(user, ["password", "systemUserRoles"]);
116 |
117 | return c.json(Resp.ok({ ...userWithoutPassword, roles }), HttpStatusCodes.OK);
118 | };
119 |
120 | export const update: SystemUsersRouteHandlerType<"update"> = async (c) => {
121 | const { id } = c.req.valid("param");
122 | const body = c.req.valid("json");
123 | const { sub } = c.get("jwtPayload");
124 |
125 | // 检查是否为内置用户
126 | const [existingUser] = await db
127 | .select({ builtIn: systemUsers.builtIn })
128 | .from(systemUsers)
129 | .where(eq(systemUsers.id, id));
130 |
131 | if (!existingUser) {
132 | return c.json(Resp.fail(HttpStatusPhrases.NOT_FOUND), HttpStatusCodes.NOT_FOUND);
133 | }
134 |
135 | // 内置用户不允许修改状态
136 | if (existingUser.builtIn && body.status !== undefined) {
137 | return c.json(Resp.fail("内置用户不允许修改状态"), HttpStatusCodes.FORBIDDEN);
138 | }
139 |
140 | // 不允许直接更新密码
141 | const updateData = omit(body, ["password"]);
142 |
143 | const [updated] = await db
144 | .update(systemUsers)
145 | .set({
146 | ...updateData,
147 | updatedBy: sub,
148 | })
149 | .where(eq(systemUsers.id, id))
150 | .returning();
151 |
152 | if (!updated) {
153 | return c.json(Resp.fail(HttpStatusPhrases.NOT_FOUND), HttpStatusCodes.NOT_FOUND);
154 | }
155 |
156 | const userWithoutPassword = omit(updated, ["password"]);
157 |
158 | return c.json(Resp.ok(userWithoutPassword), HttpStatusCodes.OK);
159 | };
160 |
161 | export const remove: SystemUsersRouteHandlerType<"remove"> = async (c) => {
162 | const { id } = c.req.valid("param");
163 |
164 | // 检查是否为内置用户
165 | const [existingUser] = await db
166 | .select({ builtIn: systemUsers.builtIn })
167 | .from(systemUsers)
168 | .where(eq(systemUsers.id, id));
169 |
170 | if (!existingUser) {
171 | return c.json(Resp.fail(HttpStatusPhrases.NOT_FOUND), HttpStatusCodes.NOT_FOUND);
172 | }
173 |
174 | if (existingUser.builtIn) {
175 | return c.json(Resp.fail("内置用户不允许删除"), HttpStatusCodes.FORBIDDEN);
176 | }
177 |
178 | const [deleted] = await db
179 | .delete(systemUsers)
180 | .where(eq(systemUsers.id, id))
181 | .returning({ id: systemUsers.id });
182 |
183 | if (!deleted) {
184 | return c.json(Resp.fail(HttpStatusPhrases.NOT_FOUND), HttpStatusCodes.NOT_FOUND);
185 | }
186 |
187 | return c.json(Resp.ok(deleted), HttpStatusCodes.OK);
188 | };
189 |
190 | export const saveRoles: SystemUsersRouteHandlerType<"saveRoles"> = async (c) => {
191 | const { userId } = c.req.valid("param");
192 | const { roleIds } = c.req.valid("json");
193 |
194 | // 检查用户是否存在
195 | const [user] = await db.select({ id: systemUsers.id }).from(systemUsers).where(eq(systemUsers.id, userId)).limit(1);
196 |
197 | if (!user) {
198 | return c.json(Resp.fail("用户不存在"), HttpStatusCodes.NOT_FOUND);
199 | }
200 |
201 | // 检查所有新角色是否存在
202 | if (roleIds.length > 0) {
203 | const existingRoles = await db.select({ id: systemRoles.id }).from(systemRoles).where(inArray(systemRoles.id, roleIds));
204 |
205 | if (existingRoles.length !== roleIds.length) {
206 | const foundRoles = existingRoles.map(role => role.id);
207 | const notFoundRoles = roleIds.filter(roleId => !foundRoles.includes(roleId));
208 | return c.json(Resp.fail(`角色不存在: ${notFoundRoles.join(", ")}`), HttpStatusCodes.NOT_FOUND);
209 | }
210 | }
211 |
212 | // 获取用户当前的所有角色
213 | const currentUserRoles = await db
214 | .select({ roleId: systemUserRoles.roleId })
215 | .from(systemUserRoles)
216 | .where(eq(systemUserRoles.userId, userId));
217 |
218 | const currentRoleIds = currentUserRoles.map(ur => ur.roleId);
219 | const currentRoleSet = new Set(currentRoleIds);
220 | const newRoleSet = new Set(roleIds);
221 |
222 | // 计算需要删除的角色(在当前角色中但不在新角色中)
223 | const rolesToRemove = currentRoleIds.filter(roleId => !newRoleSet.has(roleId));
224 |
225 | // 计算需要添加的角色(在新角色中但不在当前角色中)
226 | const rolesToAdd = roleIds.filter(roleId => !currentRoleSet.has(roleId));
227 |
228 | let removedCount = 0;
229 | let addedCount = 0;
230 |
231 | // 使用事务确保数据一致性
232 | await db.transaction(async (tx) => {
233 | // 删除不需要的角色
234 | if (rolesToRemove.length > 0) {
235 | const deleteResult = await tx.delete(systemUserRoles).where(
236 | and(
237 | eq(systemUserRoles.userId, userId),
238 | inArray(systemUserRoles.roleId, rolesToRemove),
239 | ),
240 | ).returning({ roleId: systemUserRoles.roleId });
241 |
242 | removedCount = deleteResult.length;
243 | }
244 |
245 | // 添加新的角色
246 | if (rolesToAdd.length > 0) {
247 | const valuesToInsert = rolesToAdd.map(roleId => ({ userId, roleId }));
248 | const insertResult = await tx.insert(systemUserRoles).values(valuesToInsert).returning();
249 | addedCount = insertResult.length;
250 | }
251 | });
252 |
253 | return c.json(Resp.ok({ added: addedCount, removed: removedCount, total: roleIds.length }), HttpStatusCodes.OK);
254 | };
255 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Clhoria 基于 Hono 的快速开发模板
2 |
3 | 简体中文 | [English](./readme.en.md)
4 |
5 | 
6 | 
7 | 
8 | 
9 |
10 | 现代化企业级后端模板,基于 Hono 框架构建的高性能 TypeScript 应用。采用 AI 驱动开发模式,结合 Hono + OpenAPI + Zod 完整技术体系,实现真正的类型安全和开发效率提升。集成 Drizzle ORM + PostgreSQL 数据层,完整的 RBAC 权限体系,提供比传统后台管理系统更稳定、更高效的开发体验。
11 |
12 | Clhoria 将复杂的技术架构化繁为简,让每一次编码都如诗般优雅,每一个功能都如花般绽放。选择 Clhoria,就是选择与未来同行。
13 |
14 | > 模板配套的后台管理前端部分基于 Refine + Shadcn 开发:[https://github.com/zhe-qi/refine-project](https://github.com/zhe-qi/refine-project)
15 |
16 | ## 功能特性
17 |
18 | - **现代化技术栈**: Hono + TypeScript + Vite + Drizzle ORM + PostgreSQL
19 | - **混合架构**: 函数式开发规范、多层级路由结构、复杂业务可选DDD
20 | - **自动化文档**: OpenAPI 3.1 规范 + Scalar UI,代码即文档,支持在线调试和类型生成
21 | - **多层认证授权**: JWT 双密钥(Admin/Client 隔离)+ Casbin RBAC
22 | - **声明式分页器**: 基于 Refine 规范的安全声明式查询,拓展 refine 查询支持仅后端联表查询
23 | - **完整 RBAC**: 用户管理 + 角色管理 + Casbin 权限策略 + Refine Resource 菜单
24 | - **智能权限系统**: Casbin KeyMatch3 + RESTful + Refine Resource,无需后端存储权限标识
25 | - **高性能菜单**: 基于 Refine 的菜单和路由最佳实践,相比传统动态路由性能更优
26 | - **类型安全字典**: PostgreSQL Enum + Drizzle-Zod + OpenAPI 手动同步前端枚举,编译时类型检查
27 | - **日志中间件**: 收集日志,支持多种存储方案(阿里云 SLS、PostgreSQL TimescaleDB、Loki 等)
28 | - **高性能缓存**: Redis 缓存 + 多层限流策略 + 权限缓存 + 会话管理 + 分布式锁
29 | - **任务队列和定时任务**: 基于 pg-boss 的后台任务队列管理,基于 croner 的定时任务
30 | - **对象存储**: 集成 S3 兼容对象存储(支持 Cloudflare R2、阿里云 OSS、AWS S3 等)
31 | - **智能验证码**: 集成 Cap.js,支持多种挑战类型的现代化验证码系统
32 | - **AI 原生开发**: Claude Code + OpenAPI 自动生成,告别手工维护接口文档的痛苦
33 | - **类型安全体系**: Hono + Zod + TypeScript 全链路类型推导,编译时发现问题
34 | - **智能测试覆盖**: Vitest + AI 辅助,自动生成测试用例,确保接口稳定性
35 | - **即时反馈开发**: 基于 Vite 的热重载开发环境,代码变更毫秒级生效,开发体验极致流畅
36 | - **Claude Code 深度优化**: 完整 CLAUDE.md 配置,MCP 插件生态,AI 理解项目架构
37 | - **监控系统**: 集成 Sentry 错误追踪,支持自建或云原生方案(小团队推荐云服务,免运维)
38 |
39 | ## 快速开始
40 |
41 | ### 本地开发环境
42 |
43 | - Node.js >= 24
44 | - pnpm >= 10
45 | - PostgreSQL >= 18
46 | - Redis >= 7
47 |
48 | #### 安装步骤
49 |
50 | 1. **克隆项目**
51 |
52 | ```bash
53 | git clone https://github.com/zhe-qi/clhoria-template
54 | cd clhoria-template
55 | ```
56 |
57 | 2. **安装依赖**
58 |
59 | ```bash
60 | npm i -g corepack
61 | pnpm install
62 | ```
63 |
64 | 3. **配置环境变量**
65 |
66 | ```bash
67 | cp .env.example .env
68 | ```
69 |
70 | 4. **初始化数据库**
71 |
72 | ```bash
73 | # 推送数据库架构到开发环境
74 | pnpm push
75 |
76 | # 填充初始数据(可选,应用启动时会自动检查并初始化)
77 | pnpm seed
78 | ```
79 |
80 | **生产环境部署**需要先验证迁移:
81 |
82 | ```bash
83 | pnpm generate # 生成迁移文件
84 | pnpm migrate # 执行迁移
85 | ```
86 |
87 | 5. **启动开发服务器**
88 | ```bash
89 | pnpm dev
90 | ```
91 |
92 | 访问 查看 API 文档。
93 |
94 | ## 开发规范
95 |
96 | ### 路由模块结构
97 |
98 | ```text
99 | routes/{tier}/{feature}/
100 | ├── {feature}.handlers.ts # 业务逻辑处理器
101 | ├── {feature}.routes.ts # 路由定义和 OpenAPI 架构
102 | ├── {feature}.schema.ts # Zod 校验 Schema(类型约束与接口文档)
103 | └── {feature}.index.ts # 统一导出
104 | ```
105 |
106 | ### 数据库架构
107 |
108 | ```text
109 | src/db/schema/
110 | ├── {entity}.ts # Drizzle 表定义
111 | └── index.ts # 统一导出
112 | ```
113 |
114 | **PostgreSQL 版本说明**:
115 |
116 | - **PostgreSQL 18 及以上**: 无需任何修改,直接使用即可。项目默认使用 PostgreSQL 18 的 `uuidv7()` 函数。
117 | - **PostgreSQL 18 以下**: 需要手动修改 `src/db/schema/_shard/base-columns.ts` 文件:
118 | 1. 安装 `uuid` 库:
119 | ```bash
120 | pnpm add uuid
121 | pnpm add -D @types/uuid
122 | ```
123 | 2. 修改 `base-columns.ts` 文件,在文件顶部添加导入:
124 | ```typescript
125 | import { uuidV7 } from "uuid";
126 | ```
127 | 3. 修改 `id` 字段定义:
128 | ```typescript
129 | // 将
130 | uuid().primaryKey().notNull().default(sql`uuidv7()`);
131 | // 改为
132 | uuid().primaryKey().notNull().$defaultFn(() => uuidV7());
133 | ```
134 |
135 | **架构原则**:
136 |
137 | - **按需抽离**: 仅当业务逻辑在多个路由间复用时才创建服务层,避免过度抽象
138 | - **函数式设计**: 采用命名导出的纯函数/异步函数,支持 `create*`、`get*`、`update*`、`delete*` 等标准前缀
139 | - **混合实现**: 简单 CRUD 操作直接在 handler 中实现,复杂业务逻辑抽离为服务函数
140 | - **事务管理**: 复杂业务操作使用 `db.transaction()` 确保数据一致性
141 | - **缓存集成**: 服务层集成 Redis 缓存,提供数据缓存和权限缓存管理
142 |
143 | ### 混合架构策略(可选)
144 |
145 | **简单 CRUD(80%)**:直接在 handler 实现,保持轻量
146 |
147 | ```typescript
148 | // routes/admin/posts/handlers.ts
149 | export const list: PostRouteHandlerType<"list"> = async (c) => {
150 | const result = await db.select().from(posts).limit(10);
151 | return c.json(Resp.ok(result), HttpStatusCodes.OK);
152 | };
153 | ```
154 |
155 | **复杂业务(20%)**:采用轻量 DDD 分层
156 |
157 | ```text
158 | src/domain/user/ # 领域层
159 | ├── user.application.ts # 应用服务:编排多个领域服务
160 | ├── user.entity.ts # 领域实体:核心业务逻辑和规则验证
161 | └── user.repository.ts # 仓储接口:定义数据访问抽象
162 |
163 | src/infrastructure/persistence/ # 基础设施层
164 | └── user.repository.impl.ts # 仓储实现:Drizzle ORM 数据访问
165 |
166 | src/routes/admin/users/handlers.ts # 表示层:调用应用服务编排
167 | ```
168 |
169 | **分层职责**:
170 |
171 | - **Handler**:HTTP 请求响应、参数验证、调用应用服务、错误码映射
172 | - **Application**:业务流程编排、事务边界控制、跨聚合根协调
173 | - **Entity**:领域对象建模、业务规则验证、状态变更逻辑
174 | - **Repository**:数据访问抽象与实现分离
175 |
176 | ## 核心架构特性
177 |
178 | ### 🎯 权限 + 菜单 + 字典一体化方案
179 |
180 | 基于 **Casbin + Refine + PostgreSQL Enum + OpenAPI** 的现代化架构,彻底简化传统后台管理系统的复杂度。
181 |
182 | #### 核心思路
183 |
184 | **权限系统**:基于 RESTful API 路径 + Casbin KeyMatch3,代码即权限,无需数据库存储权限标识
185 | **菜单系统**:Refine Resource 编译时路由,运行时零开销,代码即菜单
186 | **字典系统**:TypeScript 枚举 → PostgreSQL Enum → OpenAPI 自动生成,前后端 100% 同步
187 |
188 | #### 对比传统方案
189 |
190 | | 维度 | 本项目方案 | 传统方案 |
191 | | -------- | ------------------------------------------ | -------------------------------------------- |
192 | | **权限** | OpenAPI 路由定义,Casbin 策略匹配,自动同步 | 数据库权限表 + 关联表,手动维护,容易不一致 |
193 | | **菜单** | 编译时生成路由树,类型安全,零运行时开销 | 数据库存储菜单,运行时查询解析,需要管理界面 |
194 | | **字典** | 单一数据源,编译时类型检查,4 字节 Enum 存储 | 数据库字典表,运行时查询,需要 JOIN,容易不同步 |
195 | | **维护** | 改一处自动同步,TypeScript 编译时报错 | 多处手动同步:数据库 → 后端 → 前端 → 文档 |
196 |
197 | ## 部署
198 |
199 | ### Docker 部署
200 |
201 | ```bash
202 | # 构建镜像
203 | docker build -t clhoria-template .
204 |
205 | # 运行容器
206 | docker run -p 9999:9999 --env-file .env clhoria-template
207 | ```
208 |
209 | ## 部署特性
210 |
211 | **可选 SaaS 依赖**: sentry、Cloudflare R2 对象存储等第三方服务均为可选,可完全部署在内网环境。技术栈符合信创要求,支持迁移至国产数据库(如人大金仓、华为高斯等)。
212 |
213 | ## 开发体验对比
214 |
215 | | 对比维度 | 本项目 (AI + Modern Stack) | 传统代码生成器 |
216 | | ------------ | ----------------------------------------------- | ---------------------------------------- |
217 | | **开发效率** | Claude Code 智能理解需求,秒级生成符合规范的代码 | 手动配置模板麻烦,生成僵化代码,需大量修改 |
218 | | **接口管理** | OpenAPI + Zod 自动同步,类型安全,文档永不过期 | 手工维护接口文档,容易不同步 |
219 | | **代码质量** | TypeScript 全链路类型检查,编译时发现问题 | 生成代码缺乏类型约束,运行时错误频发 |
220 | | **维护成本** | 代码规范统一,AI 理解项目架构,维护简单 | 代码量大不够优雅,不好维护 |
221 |
222 | ## 验证码系统对比
223 |
224 | ### 🔐 Cap.js vs svg-captcha
225 |
226 | | 对比维度 | Cap.js (本项目采用) | svg-captcha |
227 | | ------------ | -------------------------------------------- | ------------------------------ |
228 | | **安全性** | 多种挑战类型,难以被自动化工具破解 | 基于图像识别,易被 OCR 工具破解 |
229 | | **用户体验** | 现代化交互界面,快速通过验证,用户体验遥遥领先 | 传统图片验证,识别扭曲文字 |
230 | | **扩展性** | 数据库存储,支持分布式部署和自定义挑战类型 | 内存存储,功能固定 |
231 |
232 | ## 性能对比
233 |
234 | ### Hono vs Fastify 性能分析
235 |
236 | 在 Node.js 22 环境下,Fastify 依然保持性能优势,但差距已经不大:
237 |
238 | - **Fastify (Node.js)**: 142,695 req/s
239 | - **Hono (Node.js)**: 129,234 req/s
240 |
241 | 详细基准测试:[bun-http-framework-benchmark](https://github.com/SaltyAom/bun-http-framework-benchmark)
242 |
243 | ### 🚀 高并发与性能优化方案
244 |
245 | **高并发解决方案**:K8s 集群 + 负载均衡 + Redis 分布式会话 + 数据库主从读写分离,实现无状态水平扩展
246 |
247 | **CPU 密集型优化**:
248 |
249 | | 场景 | 推荐方案 | 适用场景 |
250 | | ---------------- | ---------------- | ------------------------------ |
251 | | **多次重复调用** | N-API (原生模块) | 图像处理、加密解密、数据压缩 |
252 | | **单次密集计算** | WASM | 复杂算法、科学计算、单次重计算 |
253 | | **并行多任务** | Worker Threads | 大量独立任务、并发数据处理 |
254 |
255 | ## Claude Code 深度集成(可选)
256 |
257 | 本项目专为 AI 驱动开发而设计,提供完整的 CLAUDE.md 配置,让 AI 深度理解项目架构。
258 |
259 | **推荐 MCP 插件**:
260 |
261 | - **[Serena](https://github.com/SerenaAI/serena-mcp)**:智能代码分析和重构建议
262 | - **[Context7](https://github.com/context7/mcp-plugin)**:实时技术文档查询和代码示例
263 |
264 | ## 测试
265 |
266 | 使用 Vitest 测试框架,支持完整的单元测试和集成测试,可以在 tests 下添加端到端测试。
267 |
268 | ```bash
269 | # 运行测试
270 | pnpm test
271 | ```
272 |
273 | ## 引用
274 |
275 | - [hono-open-api-starter](https://github.com/w3cj/hono-open-api-starter)
276 | - [stoker](https://github.com/w3cj/stoker)
277 |
278 | ## 支持
279 |
280 | 如有问题或建议,请创建 [Issue](https://github.com/zhe-qi/clhoria-template/issues) 或联系维护者。
281 |
282 | ## 贡献指南
283 |
284 | 欢迎贡献!请遵循 [Conventional Commits](https://www.conventionalcommits.org/) 规范,提交 PR 前确保 `pnpm test` 和 `pnpm lint` 通过。
285 |
286 | ## 许可证
287 |
288 | MIT License - 查看 [LICENSE](https://github.com/zhe-qi/clhoria-template/blob/main/LICENSE) 文件了解详情。
289 |
--------------------------------------------------------------------------------