├── 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(`${emoji}`); 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 | ![License](https://img.shields.io/badge/license-MIT-blue.svg) 6 | ![Node](https://img.shields.io/badge/node-%3E%3D24-brightgreen.svg) 7 | ![TypeScript](https://img.shields.io/badge/typescript-5.9+-blue.svg) 8 | ![Build Status](https://img.shields.io/badge/build-passing-brightgreen.svg) 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 | --------------------------------------------------------------------------------