From 1cd96abed677c2e0ae1c5f8bfb5e86a1705a7b0c Mon Sep 17 00:00:00 2001 From: dash <1549469775@qq.com> Date: Thu, 9 Oct 2025 14:27:57 +0800 Subject: [PATCH] feat: Add middleware for response time logging and static file serving - Implemented ResponseTime middleware to log response times for non-static and API requests. - Created Send middleware for serving static files with support for Brotli and Gzip compression. - Added resolve-path utility to prevent path traversal attacks in Send middleware. - Introduced Session middleware for managing user sessions with configurable options. - Installed middleware in the application with proper routing and error handling. - Developed JobController and JobService for managing scheduled jobs with CRUD operations. - Created UploadController for handling file uploads with support for multiple file types. - Added environment variable validation utility to ensure required and optional variables are set. - Implemented a response utility for consistent API responses. - Developed custom error classes for better error handling in the application. - Updated TypeScript configuration for improved module resolution. - Added script to fix type declaration issues in vue-router. --- README.md | 7 +- bun.lockb | Bin 93241 -> 167363 bytes internal/helper/src/env.ts | 2 + internal/x/composables/useShareContext.ts | 10 + package.json | 7 +- packages/client/auto-imports.d.ts | 16 ++ packages/client/components.d.ts | 5 + packages/client/index.html | 21 +- packages/client/package.json | 15 +- packages/client/src/App.vue | 23 +- packages/client/src/assets/styles/css/reset.css | 48 ++++ .../client/src/components/QuillEditor/_Editor.vue | 73 +++++ .../client/src/components/QuillEditor/index.vue | 10 + .../src/components/QuillEditor/useQuill/index.ts | 135 +++++++++ .../components/QuillEditor/useQuill/quill-shim.ts | 75 +++++ .../components/QuillEditor/useQuill/quill-video.ts | 75 +++++ packages/client/src/components/ThemeDemo.vue | 111 +++++++ packages/client/src/entry-client.ts | 31 +- packages/client/src/entry-server.ts | 47 ++- packages/client/src/layouts/base.vue | 28 ++ packages/client/src/main.ts | 6 +- packages/client/src/pages/_M.vue | 54 ++++ packages/client/src/pages/about/index.vue | 11 - packages/client/src/pages/home/index.vue | 19 -- packages/client/src/pages/index.vue | 32 +++ packages/client/src/pages/not-found/index.vue | 5 - packages/client/src/pages/test/index.vue | 34 +++ packages/client/src/pages/test/index2.vue | 28 ++ packages/client/src/pages/test/readme.md | 1 + packages/client/src/router/index.ts | 30 +- packages/client/src/typed-router.d.ts | 66 +++++ packages/client/src/vite-env.d.ts | 2 + packages/client/src/vue.d.ts | 18 +- packages/client/tsconfig.json | 34 ++- packages/client/vite.config.ts | 42 ++- packages/core/src/SsrMiddleWare.ts | 16 +- packages/server/package.json | 17 +- packages/server/src/api/main.ts | 2 +- packages/server/src/app.ts | 14 +- packages/server/src/base/BaseController.ts | 318 +++++++++++++++++++++ packages/server/src/booststap.ts | 40 ++- packages/server/src/env.d.ts | 11 + packages/server/src/jobs/index.ts | 62 ++++ packages/server/src/jobs/jobs/exampleJob.ts | 12 + packages/server/src/jobs/scheduler.ts | 63 ++++ packages/server/src/logger.ts | 61 ++++ packages/server/src/middleware/Auth/index.ts | 38 +++ packages/server/src/middleware/Controller/index.ts | 100 +++++++ .../server/src/middleware/ResponseTime/index.ts | 59 ++++ packages/server/src/middleware/Send/index.ts | 186 ++++++++++++ .../server/src/middleware/Send/resolve-path.ts | 74 +++++ packages/server/src/middleware/Session/index.ts | 16 ++ packages/server/src/middleware/install.ts | 104 +++++++ .../server/src/modules/Job/controller/index.ts | 51 ++++ packages/server/src/modules/Job/services/index.ts | 18 ++ .../server/src/modules/Upload/controller/index.ts | 207 ++++++++++++++ packages/server/src/utils/EnvValidator.ts | 165 +++++++++++ packages/server/src/utils/R.ts | 35 +++ packages/server/src/utils/Router.ts | 141 +++++++++ packages/server/src/utils/error/ApiError.ts | 24 ++ packages/server/src/utils/error/AuthError.ts | 13 + packages/server/src/utils/error/BaseError.ts | 17 ++ packages/server/src/utils/error/CommonError.ts | 24 ++ packages/server/tsconfig.json | 7 +- scripts/fix-type-router.js | 18 ++ 65 files changed, 2900 insertions(+), 134 deletions(-) create mode 100644 internal/x/composables/useShareContext.ts create mode 100644 packages/client/src/assets/styles/css/reset.css create mode 100644 packages/client/src/components/QuillEditor/_Editor.vue create mode 100644 packages/client/src/components/QuillEditor/index.vue create mode 100644 packages/client/src/components/QuillEditor/useQuill/index.ts create mode 100644 packages/client/src/components/QuillEditor/useQuill/quill-shim.ts create mode 100644 packages/client/src/components/QuillEditor/useQuill/quill-video.ts create mode 100644 packages/client/src/components/ThemeDemo.vue create mode 100644 packages/client/src/layouts/base.vue create mode 100644 packages/client/src/pages/_M.vue delete mode 100644 packages/client/src/pages/about/index.vue delete mode 100644 packages/client/src/pages/home/index.vue create mode 100644 packages/client/src/pages/index.vue delete mode 100644 packages/client/src/pages/not-found/index.vue create mode 100644 packages/client/src/pages/test/index.vue create mode 100644 packages/client/src/pages/test/index2.vue create mode 100644 packages/client/src/pages/test/readme.md create mode 100644 packages/client/src/typed-router.d.ts create mode 100644 packages/server/src/base/BaseController.ts create mode 100644 packages/server/src/env.d.ts create mode 100644 packages/server/src/jobs/index.ts create mode 100644 packages/server/src/jobs/jobs/exampleJob.ts create mode 100644 packages/server/src/jobs/scheduler.ts create mode 100644 packages/server/src/logger.ts create mode 100644 packages/server/src/middleware/Auth/index.ts create mode 100644 packages/server/src/middleware/Controller/index.ts create mode 100644 packages/server/src/middleware/ResponseTime/index.ts create mode 100644 packages/server/src/middleware/Send/index.ts create mode 100644 packages/server/src/middleware/Send/resolve-path.ts create mode 100644 packages/server/src/middleware/Session/index.ts create mode 100644 packages/server/src/middleware/install.ts create mode 100644 packages/server/src/modules/Job/controller/index.ts create mode 100644 packages/server/src/modules/Job/services/index.ts create mode 100644 packages/server/src/modules/Upload/controller/index.ts create mode 100644 packages/server/src/utils/EnvValidator.ts create mode 100644 packages/server/src/utils/R.ts create mode 100644 packages/server/src/utils/Router.ts create mode 100644 packages/server/src/utils/error/ApiError.ts create mode 100644 packages/server/src/utils/error/AuthError.ts create mode 100644 packages/server/src/utils/error/BaseError.ts create mode 100644 packages/server/src/utils/error/CommonError.ts create mode 100644 scripts/fix-type-router.js diff --git a/README.md b/README.md index 6d83d19..8253342 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,8 @@ # 基于koa实现的简易ssr -- https://segmentfault.com/a/1190000042389086 \ No newline at end of file +- https://segmentfault.com/a/1190000042389086 + + +## 试试grpc,实现node与python通信,扩展更多的功能。 +https://grpc.org.cn/docs/languages/node/quickstart/ +https://www.doubao.com/chat/23869592666505474 \ No newline at end of file diff --git a/bun.lockb b/bun.lockb index 71e79af02a5fe072453a5e4387ba7a51e70a1938..b602fbb90e2b8f6bb582916b1202501518ac3315 100644 GIT binary patch literal 167363 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 diff --git a/internal/helper/src/env.ts b/internal/helper/src/env.ts index a208659..6307ec6 100644 --- a/internal/helper/src/env.ts +++ b/internal/helper/src/env.ts @@ -2,10 +2,12 @@ const isProduction = process.env.NODE_ENV === 'production' const port = process.env.PORT || 5173 const base = process.env.BASE || '/' +const LOG_DIR = process.env.LOG_DIR || 'logs' export const Env = { isProduction, port: Number(port), base, + LOG_DIR, } \ No newline at end of file diff --git a/internal/x/composables/useShareContext.ts b/internal/x/composables/useShareContext.ts new file mode 100644 index 0000000..34f242c --- /dev/null +++ b/internal/x/composables/useShareContext.ts @@ -0,0 +1,10 @@ +import { useSSRContext } from "vue" + +export function useShareCache(): Map | null { + if (typeof window === 'undefined') { + const ssrContext = useSSRContext() + return ssrContext?.cache || null + } else { + return (window as any).__SSR_CONTEXT__?.cache || null + } +} \ No newline at end of file diff --git a/package.json b/package.json index 61f6249..1269c4e 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,9 @@ "internal/*" ], "scripts": { + "postinstall": "node scripts/fix-type-router.js", "dev": "bun run --hot packages/server/src/booststap.ts", + "build": "bun run --filter client build", "preview": "cross-env NODE_ENV=production bun run packages/server/src/booststap.ts", "tsc:booststap": "tsc packages/booststap/src/server.ts --outDir dist --module es2022 --target es2022 --lib es2022,dom --moduleResolution bundler --esModuleInterop --skipLibCheck --forceConsistentCasingInFileNames --noEmit false --incremental false", "tsc:server": "tsc packages/server/src/**/*.ts --outDir dist/server --module es2022 --target es2022 --lib es2022,dom --moduleResolution bundler --esModuleInterop --skipLibCheck --forceConsistentCasingInFileNames --noEmit false --incremental false" @@ -20,10 +22,11 @@ "helper": "workspace:*", "server": "workspace:*", "unplugin-vue-components": "^29.1.0", - "vite-plugin-devtools-json": "^1.0.0" + "vite-plugin-devtools-json": "^1.0.0", + "x": "workspace:*" }, "peerDependencies": { - "typescript": "^5.0.0" + "typescript": "^5.9.3" }, "dependencies": { "koa-compose": "^4.1.0", diff --git a/packages/client/auto-imports.d.ts b/packages/client/auto-imports.d.ts index 055e7ca..459aa18 100644 --- a/packages/client/auto-imports.d.ts +++ b/packages/client/auto-imports.d.ts @@ -25,6 +25,7 @@ declare global { const h: typeof import('vue')['h'] const hydrateSSRContext: typeof import('../../internal/x/composables/ssrContext')['hydrateSSRContext'] const inject: typeof import('vue')['inject'] + const injectHead: typeof import('@unhead/vue')['injectHead'] const isProxy: typeof import('vue')['isProxy'] const isReactive: typeof import('vue')['isReactive'] const isReadonly: typeof import('vue')['isReadonly'] @@ -82,11 +83,18 @@ declare global { const useCssVars: typeof import('vue')['useCssVars'] const useFetch: typeof import('../../internal/x/composables/useFetch')['useFetch'] const useGlobal: typeof import('./src/composables/useGlobal/index')['useGlobal'] + const useHead: typeof import('@unhead/vue')['useHead'] + const useHeadSafe: typeof import('@unhead/vue')['useHeadSafe'] const useId: typeof import('vue')['useId'] const useLink: typeof import('vue-router')['useLink'] const useModel: typeof import('vue')['useModel'] const useRoute: typeof import('vue-router')['useRoute'] const useRouter: typeof import('vue-router')['useRouter'] + const useSeoMeta: typeof import('@unhead/vue')['useSeoMeta'] + const useServerHead: typeof import('@unhead/vue')['useServerHead'] + const useServerHeadSafe: typeof import('@unhead/vue')['useServerHeadSafe'] + const useServerSeoMeta: typeof import('@unhead/vue')['useServerSeoMeta'] + const useShareCache: typeof import('../../internal/x/composables/useShareContext')['useShareCache'] const useSlots: typeof import('vue')['useSlots'] const useTemplateRef: typeof import('vue')['useTemplateRef'] const watch: typeof import('vue')['watch'] @@ -131,6 +139,7 @@ declare module 'vue' { readonly h: UnwrapRef readonly hydrateSSRContext: UnwrapRef readonly inject: UnwrapRef + readonly injectHead: UnwrapRef readonly isProxy: UnwrapRef readonly isReactive: UnwrapRef readonly isReadonly: UnwrapRef @@ -188,11 +197,18 @@ declare module 'vue' { readonly useCssVars: UnwrapRef readonly useFetch: UnwrapRef readonly useGlobal: UnwrapRef + readonly useHead: UnwrapRef + readonly useHeadSafe: UnwrapRef readonly useId: UnwrapRef readonly useLink: UnwrapRef readonly useModel: UnwrapRef readonly useRoute: UnwrapRef readonly useRouter: UnwrapRef + readonly useSeoMeta: UnwrapRef + readonly useServerHead: UnwrapRef + readonly useServerHeadSafe: UnwrapRef + readonly useServerSeoMeta: UnwrapRef + readonly useShareCache: UnwrapRef readonly useSlots: UnwrapRef readonly useTemplateRef: UnwrapRef readonly watch: UnwrapRef diff --git a/packages/client/components.d.ts b/packages/client/components.d.ts index 2bb3718..cb7b8d4 100644 --- a/packages/client/components.d.ts +++ b/packages/client/components.d.ts @@ -9,12 +9,17 @@ export {} declare module 'vue' { export interface GlobalComponents { AiDemo: typeof import('./src/components/AiDemo/index.vue')['default'] + AXBubble: typeof import('ant-design-x-vue')['Bubble'] ClientOnly: typeof import('./../../internal/x/components/ClientOnly.vue')['default'] CookieDemo: typeof import('./src/components/CookieDemo.vue')['default'] DataFetch: typeof import('./src/components/DataFetch.vue')['default'] HelloWorld: typeof import('./src/components/HelloWorld.vue')['default'] + MazBtn: typeof import('maz-ui/components/MazBtn')['default'] + QuillEditor: typeof import('./src/components/QuillEditor/index.vue')['default'] RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] SimpleTest: typeof import('./src/components/SimpleTest.vue')['default'] + ThemeDemo: typeof import('./src/components/ThemeDemo.vue')['default'] + VueNodeRenderer: typeof import('./src/components/AiDemo/_/VueNodeRenderer.vue')['default'] } } diff --git a/packages/client/index.html b/packages/client/index.html index 0ec77f1..789957a 100644 --- a/packages/client/index.html +++ b/packages/client/index.html @@ -1,17 +1,10 @@ - - - - - - - Vite + Vue + TS + + - - - + +
- - - - \ No newline at end of file + + + diff --git a/packages/client/package.json b/packages/client/package.json index 40cdbff..ca92079 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -11,17 +11,22 @@ "unplugin-vue-components": "^29.1.0", "vue-tsc": "^3.1.0" }, - "peerDependencies": { - "typescript": "^5.0.0" - }, "dependencies": { + "@maz-ui/icons": "^4.1.3", + "@maz-ui/themes": "^4.1.5", + "@unhead/vue": "^2.0.17", "@vitejs/plugin-vue": "^6.0.1", + "ant-design-x-vue": "^1.3.2", "dompurify": "^3.2.7", "htmlparser2": "^10.0.0", "marked": "^16.3.0", + "maz-ui": "^4.1.6", + "quill": "^2.0.3", "unplugin-auto-import": "^20.2.0", + "unplugin-vue-router": "^0.15.0", + "vite-plugin-vue-layouts": "^0.11.0", "vue": "^3.5.22", - "vue-router": "^4.5.1", - "x": "workspace:*" + "vue-final-modal": "^4.5.5", + "vue-router": "^4.5.1" } } diff --git a/packages/client/src/App.vue b/packages/client/src/App.vue index 1c0065b..121ff3d 100644 --- a/packages/client/src/App.vue +++ b/packages/client/src/App.vue @@ -1,20 +1,4 @@ diff --git a/packages/client/src/assets/styles/css/reset.css b/packages/client/src/assets/styles/css/reset.css new file mode 100644 index 0000000..af94440 --- /dev/null +++ b/packages/client/src/assets/styles/css/reset.css @@ -0,0 +1,48 @@ +/* http://meyerweb.com/eric/tools/css/reset/ + v2.0 | 20110126 + License: none (public domain) +*/ + +html, body, div, span, applet, object, iframe, +h1, h2, h3, h4, h5, h6, p, blockquote, pre, +a, abbr, acronym, address, big, cite, code, +del, dfn, em, img, ins, kbd, q, s, samp, +small, strike, strong, sub, sup, tt, var, +b, u, i, center, +dl, dt, dd, ol, ul, li, +fieldset, form, label, legend, +table, caption, tbody, tfoot, thead, tr, th, td, +article, aside, canvas, details, embed, +figure, figcaption, footer, header, hgroup, +menu, nav, output, ruby, section, summary, +time, mark, audio, video { + margin: 0; + padding: 0; + border: 0; + font-size: 100%; + font: inherit; + vertical-align: baseline; +} +/* HTML5 display-role reset for older browsers */ +article, aside, details, figcaption, figure, +footer, header, hgroup, menu, nav, section { + display: block; +} +body { + line-height: 1; +} +ol, ul { + list-style: none; +} +blockquote, q { + quotes: none; +} +blockquote:before, blockquote:after, +q:before, q:after { + content: ''; + content: none; +} +table { + border-collapse: collapse; + border-spacing: 0; +} \ No newline at end of file diff --git a/packages/client/src/components/QuillEditor/_Editor.vue b/packages/client/src/components/QuillEditor/_Editor.vue new file mode 100644 index 0000000..85d2202 --- /dev/null +++ b/packages/client/src/components/QuillEditor/_Editor.vue @@ -0,0 +1,73 @@ + + + diff --git a/packages/client/src/components/QuillEditor/index.vue b/packages/client/src/components/QuillEditor/index.vue new file mode 100644 index 0000000..c76adb8 --- /dev/null +++ b/packages/client/src/components/QuillEditor/index.vue @@ -0,0 +1,10 @@ + + + diff --git a/packages/client/src/components/QuillEditor/useQuill/index.ts b/packages/client/src/components/QuillEditor/useQuill/index.ts new file mode 100644 index 0000000..c8c338b --- /dev/null +++ b/packages/client/src/components/QuillEditor/useQuill/index.ts @@ -0,0 +1,135 @@ +import { QuillOptions } from "quill"; +import Quill from "./quill-shim" +// import "quill/dist/quill.core.css"; +import "quill/dist/quill.snow.css"; +import Toolbar from "quill/modules/toolbar"; + +interface IOption { + el: string | Ref | Readonly>; + onTextChange?: (delta: any, oldDelta: any, source: any) => void; + quillOptions?: QuillOptions; + handleImageUpload?: (file: FileList) => Promise; +} + +const defalutOption: Partial = { + quillOptions: { + placeholder: "Compose an epic...", + modules: { + toolbar: [ + ["bold", "italic", "underline", "strike"], // toggled buttons + ["blockquote", "code-block"], + ["link", "image", "video", "formula"], + + [{ header: 1 }, { header: 2 }], // custom button values + [{ list: "ordered" }, { list: "bullet" }, { list: "check" }], + [{ script: "sub" }, { script: "super" }], // superscript/subscript + [{ indent: "-1" }, { indent: "+1" }], // outdent/indent + [{ direction: "rtl" }], // text direction + + [{ size: ["small", false, "large", "huge"] }], // custom dropdown + [{ header: [1, 2, 3, 4, 5, 6, false] }], + + [{ color: [] }, { background: [] }], // dropdown with defaults from theme + [{ font: [] }], + [{ align: [] }], + + ["clean"], + ], + }, + }, +} + +export function useQuill(option: IOption) { + option = { ...defalutOption, ...option, quillOptions: Object.assign({}, defalutOption.quillOptions, option.quillOptions) }; + + let editor: Quill | null = null; + const onTextChange = option.onTextChange || (() => { }); + + let ReadyResolve: Function + const isReadyPromise = new Promise((resolve,) => { + ReadyResolve = resolve; + }); + + function setContent(content: string) { + if (editor) { + editor.root.innerHTML = content; + } + } + + function init(option: IOption) { + if (editor) return; + if (!option.el) return; + if (typeof option.el !== "string" && !option.el.value) return; + editor = new Quill(typeof option.el === "string" ? option.el : option.el.value!, { + theme: "snow", + ...(option.quillOptions || {}), + }); + ReadyResolve?.(editor); + editor.on("text-change", onTextChange); + + const toolbar = editor.getModule('toolbar') as Toolbar; + toolbar.addHandler("video", (value) => { + if (value) { + let range = editor!.getSelection(true); + editor!.insertText(range.index, '\n', Quill.sources.USER); + let url = 'https://alist.xieyaxin.top/d/%E8%B5%84%E6%BA%90/%E3%80%90%E5%BB%BA%E8%AE%AE%E6%94%B6%E8%97%8F%E3%80%91IPv4%E5%88%86%E9%85%8D%E8%80%97%E5%B0%BD%EF%BC%9F%E4%BA%BA%E4%BA%BA%E9%83%BD%E6%9C%89%E7%9A%84%E5%85%AC%E7%BD%91IP%EF%BC%8CIPv6%E6%96%B0%E6%89%8B%E5%85%A5%E9%97%A8%EF%BC%8C%E7%94%B5%E8%84%91%E8%B7%AF%E7%94%B1%E5%99%A8%E9%85%8D%E7%BD%AEIPv6%E5%9C%B0%E5%9D%80%EF%BC%8CIPv6%E9%80%9A%E4%BF%A1%E6%B5%81%E7%A8%8B%EF%BC%8CIPv4%E7%9A%84NAT%E7%BD%91%E7%BB%9C%E5%9C%B0%E5%9D%80%E8%BD%AC%E6%8D%A2%E5%AD%98%E5%9C%A8%E7%9A%84%E9%97%AE%E9%A2%98%EF%BC%8CIPv6-PD%E5%89%8D%E7%BC%80%E5%A7%94%E6%89%98%E4%B8%8B%E5%8F%91%E6%97%A0%E9%99%90%E5%85%AC%E7%BD%91IPv6%E5%9C%B0%E5%9D%80.mp4?sign=zRn6CLBSrRGO6IPz7F0NPHiIeKkK7bsRNMtUrZNrN9k=:1759587506'; + editor!.insertEmbed(range.index + 1, 'video', { + url: url, + autoplay: "true", + loop: "true", + muted: "true", + width: "100%", + height: "auto", + controls: "true", + }, Quill.sources.USER); + editor!.formatText(range.index + 1, 1, { height: '170', width: '400' }); + editor!.setSelection(range.index + 2, Quill.sources.SILENT); + } else { + editor!.format("video", false); + } + }); + if (option.handleImageUpload) { + toolbar.addHandler('image', async function () { + const input = document.createElement("input"); + input.setAttribute("type", "file"); + input.setAttribute("multiple", "multiple"); + input.setAttribute("accept", "image/*"); + input.click(); + input.onchange = async () => { + const files = input!.files + const textOrArray = files ? await option.handleImageUpload?.(files) : null; + if (typeof textOrArray === "string") { + const range = editor!.getSelection(); + editor!.insertEmbed(range ? range.index : 0, 'image', textOrArray, 'user') + } else { + (textOrArray || []).forEach(text => { + const range = editor!.getSelection(); + editor!.insertEmbed(range ? range.index : 0, 'image', text, 'user') + }) + } + }; + }); + } + } + + function destroy() { + if (editor) { + editor.off("text-change", onTextChange); + // @ts-ignore + editor.destroy(); + editor = null; + } + } + + onMounted(init.bind(null, option)); + + onScopeDispose(destroy); + + return { + isReadyPromise, + setContent, + init, + destroy, + getEditor: () => editor, + }; +} \ No newline at end of file diff --git a/packages/client/src/components/QuillEditor/useQuill/quill-shim.ts b/packages/client/src/components/QuillEditor/useQuill/quill-shim.ts new file mode 100644 index 0000000..32a9303 --- /dev/null +++ b/packages/client/src/components/QuillEditor/useQuill/quill-shim.ts @@ -0,0 +1,75 @@ +// @ts-nocheck +import Quill from "quill"; +import "./quill-video" + +if (Quill.prototype.destroy === undefined) { + Quill.prototype.destroy = function () { + if (!this.emitter) return; + // Disable the editor to prevent further user input + this.enable(false); + + // Remove event listeners managed by Quill + this.emitter.listeners = {}; + this.emitter.off(); + + // Clear clipboard event handlers + if (this.clipboard && this.clipboard.off) { + this.clipboard.off(); + } + + // Remove keyboard bindings + this.keyboard.bindings = {}; + + // Clear history stack + this.history.clear(); + + // Remove toolbar event handlers (if toolbar module exists) + if (this.theme && this.theme.modules.toolbar) { + this.theme.modules.toolbar.container.remove(); + } + + // Remove tooltip (if present) + if (this.theme && this.theme.tooltip) { + this.theme.tooltip.root.remove(); + } + + // Remove all Quill-added classes from the container + const container = this.container; + container.classList.forEach((cls) => { + if (cls.startsWith('ql-')) { + container.classList.remove(cls); + } + }); + + // Restore the original container content (before Quill modified it) + container.innerHTML = this.root.innerHTML; + + // Remove Quill-specific DOM elements + this.root.remove(); + + // Nullify references to allow garbage collection + this.root = null; + this.scroll = null; + this.emitter = null; + this.clipboard = null; + this.keyboard = null; + this.history = null; + this.theme = null; + this.container = null; + + // Override isEnabled to prevent errors after destruction + this.isEnabled = function () { + return false; + }; + + // Remove the instance from Quill's internal registry (if any) + if (Quill.instances && Quill.instances[this.id]) { + delete Quill.instances[this.id]; + } + }; +} + +export { + Quill +} +export default Quill diff --git a/packages/client/src/components/QuillEditor/useQuill/quill-video.ts b/packages/client/src/components/QuillEditor/useQuill/quill-video.ts new file mode 100644 index 0000000..b8c0423 --- /dev/null +++ b/packages/client/src/components/QuillEditor/useQuill/quill-video.ts @@ -0,0 +1,75 @@ +// @ts-nocheck + +import Quill from "quill"; + +// 源码中是import直接倒入,这里要用Quill.import引入 +const BlockEmbed = Quill.import('blots/block/embed') +const Link = Quill.import('formats/link') + +const ATTRIBUTES = ['height', 'width'] + +class Video extends BlockEmbed { + static create(value) { + let node = super.create() + //添加 + node.setAttribute('src', value.url) + node.setAttribute('controls', value.controls) + node.setAttribute('width', value.width) + node.setAttribute('height', value.height) + node.setAttribute('loop', value.loop) + node.setAttribute('autoplay', value.autoplay) + node.setAttribute('muted', value.muted) + return node + } + + static formats(domNode) { + return ATTRIBUTES.reduce((formats, attribute) => { + if (domNode.hasAttribute(attribute)) { + formats[attribute] = domNode.getAttribute(attribute) + } + return formats + }, {}) + } + + static sanitize(url) { + return Link.sanitize(url) + } + + static value(domNode) { + // 设置值包含宽高,为了达到自定义效果 + //宽高为空的话,就是按100%算 + return { + url: domNode.getAttribute('src'), + controls: domNode.getAttribute('controls'), + width: domNode.getAttribute('width'), + height: domNode.getAttribute('height'), + autoplay: domNode.getAttribute('autoplay'), + loop: domNode.getAttribute('loop'), + muted: domNode.getAttribute('muted'), + + } + } + + + format(name, value) { + if (ATTRIBUTES.indexOf(name) > -1) { + if (value) { + this.domNode.setAttribute(name, value) + } else { + this.domNode.removeAttribute(name) + } + } else { + super.format(name, value) + } + } + + html() { + const { video } = this.value() + return `${video}` + } +} +Video.blotName = 'video' +// Video.className = 'ql-video' // 可添加样式,看主要需要 +Video.tagName = 'video' // 用video标签替换iframe + +Quill.register(Video, true); diff --git a/packages/client/src/components/ThemeDemo.vue b/packages/client/src/components/ThemeDemo.vue new file mode 100644 index 0000000..8cbe852 --- /dev/null +++ b/packages/client/src/components/ThemeDemo.vue @@ -0,0 +1,111 @@ + + + diff --git a/packages/client/src/entry-client.ts b/packages/client/src/entry-client.ts index 142cc0d..10f7d3d 100644 --- a/packages/client/src/entry-client.ts +++ b/packages/client/src/entry-client.ts @@ -1,5 +1,13 @@ import { createApp } from "./main" import { hydrateSSRContext, clearSSRContext } from 'x/composables/ssrContext' +import { createHead } from '@unhead/vue/client' + +import "@/assets/styles/css/reset.css" +import 'vue-final-modal/style.css' + +import { MazUi } from 'maz-ui/plugins/maz-ui' +import { mazUi, ocean, pristine, obsidian } from '@maz-ui/themes' +import { zhCN } from '@maz-ui/translations' // 水合 SSR 上下文(如果存在) let ssrContext = null @@ -14,6 +22,27 @@ if (typeof window !== 'undefined' && (window as any).__SSR_CONTEXT__) { // 使用相同的 SSR 上下文创建应用 const { app, pinia, router } = createApp(ssrContext) +const head = createHead() +app.use(head) + +const presetCookie = useCookie("maz-preset-mode"); +const colorCookie = useCookie("maz-color-mode"); +app.use(MazUi, { + theme: { + mode: 'both', + strategy: 'hybrid', + // class会触发https://github.com/LouisMazel/maz-ui/blob/3051819550985506413a8f0d103e8f11b4cb17d7/packages/themes/src/composables/useTheme.ts#L165 + // 使用class会触发如上链接的问题,导致执行两次setColorMode,从而覆盖掉cookie的值 + darkModeStrategy: 'class', // 'class', + preset: { "maz-ui": mazUi, "ocean": ocean, "pristine": pristine, "obsidian": obsidian }[presetCookie.get() || "maz-ui"], + colorMode: presetCookie.get() ? (colorCookie.get() as "light" | "dark" | "auto") : "auto", + }, + translations: { + messages: { zhCN }, + }, +}) + + if (ssrContext) { pinia.state.value = ssrContext.piniaState } @@ -21,7 +50,7 @@ if (ssrContext) { // 等待路由准备就绪,然后挂载应用 router.isReady().then(() => { console.log('[Client] 路由已准备就绪,挂载应用') - app.mount('#app') + app.mount('#app', true) // 水合完成后清除 SSR 上下文 clearSSRContext() diff --git a/packages/client/src/entry-server.ts b/packages/client/src/entry-server.ts index 05b6e56..9751534 100644 --- a/packages/client/src/entry-server.ts +++ b/packages/client/src/entry-server.ts @@ -2,7 +2,7 @@ import { renderToString } from 'vue/server-renderer' import { createApp } from './main' import { createSSRContext } from 'x/composables/ssrContext' import { basename } from 'node:path' - +import { createHead } from '@unhead/vue/server' export async function render(url: string, manifest: any, init?: { cookies?: Record }) { // 创建 SSR 上下文,包含数据缓存与 cookies @@ -14,6 +14,46 @@ export async function render(url: string, manifest: any, init?: { cookies?: Reco // 将 SSR 上下文传递给应用创建函数 const { app, pinia, router } = createApp(ssrContext) + const unHead = createHead({ + disableDefaults: true + }) + app.use(unHead) + + // https://github.com/antfu-collective/vitesse + // https://github.com/unjs/unhead/blob/main/examples/vite-ssr-vue/src/entry-server.ts + useSeoMeta({ + title: 'My Awesome Site', + description: 'My awesome site description', + }, { head: unHead }) + + useHead({ + title: "aa", + htmlAttrs: { + lang: "zh-CN" + }, + meta: [ + { + charset: "UTF-8" + }, + { + name: "viewport", + content: "width=device-width, initial-scale=1.0", + }, + { + name: "description", + content: "Welcome to our website", + }, + ], + link: [ + { + rel: "icon", + type: "image/svg+xml", + // href: () => (preferredDark.value ? "/favicon-dark.svg" : "/favicon.svg"), + href: () => "/vite.svg", + }, + ], + }, { head: unHead }) + router.push(url); // 根据请求 URL 设置路由 await router.isReady(); // 等待路由准备完成 @@ -28,24 +68,23 @@ export async function render(url: string, manifest: any, init?: { cookies?: Reco // 使用更安全的方式序列化 Map const cacheEntries = ssrContext.cache ? Array.from(ssrContext.cache.entries()) : [] const ssrData = JSON.stringify(cacheEntries) - const cookieInit = JSON.stringify(ssrContext.cookies || {}) // @ts-ignore const preloadLinks = renderPreloadLinks(ctx.modules, manifest) console.log('[SSR] 序列化缓存数据:', cacheEntries) + const head = ` ${preloadLinks} ` - return { html, head, setCookies: ssrContext.setCookies || [] } + return { html, head, unHead, setCookies: ssrContext.setCookies || [] } } function renderPreloadLinks(modules: any, manifest: any) { diff --git a/packages/client/src/layouts/base.vue b/packages/client/src/layouts/base.vue new file mode 100644 index 0000000..ec52bf2 --- /dev/null +++ b/packages/client/src/layouts/base.vue @@ -0,0 +1,28 @@ + + + diff --git a/packages/client/src/main.ts b/packages/client/src/main.ts index d7f1f4b..fb3e109 100644 --- a/packages/client/src/main.ts +++ b/packages/client/src/main.ts @@ -2,17 +2,17 @@ import { createSSRApp } from 'vue' import App from './App.vue' import createSSRRouter from './router'; import { createPinia } from 'pinia' +import { createVfm } from 'vue-final-modal' -// SSR requires a fresh app instance per request, therefore we export a function -// that creates a fresh app instance. If using Vuex, we'd also be creating a -// fresh store here. export function createApp(ssrContext?: any) { const app = createSSRApp(App) const router = createSSRRouter() const pinia = createPinia() + const vfm = createVfm() as any app.use(router) app.use(pinia) + app.use(vfm) // 如果有 SSR 上下文,注入到应用中 if (ssrContext) { diff --git a/packages/client/src/pages/_M.vue b/packages/client/src/pages/_M.vue new file mode 100644 index 0000000..2515a89 --- /dev/null +++ b/packages/client/src/pages/_M.vue @@ -0,0 +1,54 @@ + + + + + diff --git a/packages/client/src/pages/about/index.vue b/packages/client/src/pages/about/index.vue deleted file mode 100644 index 597f5c1..0000000 --- a/packages/client/src/pages/about/index.vue +++ /dev/null @@ -1,11 +0,0 @@ - - \ No newline at end of file diff --git a/packages/client/src/pages/home/index.vue b/packages/client/src/pages/home/index.vue deleted file mode 100644 index 573de29..0000000 --- a/packages/client/src/pages/home/index.vue +++ /dev/null @@ -1,19 +0,0 @@ - - - diff --git a/packages/client/src/pages/index.vue b/packages/client/src/pages/index.vue new file mode 100644 index 0000000..42643a0 --- /dev/null +++ b/packages/client/src/pages/index.vue @@ -0,0 +1,32 @@ + + + diff --git a/packages/client/src/pages/not-found/index.vue b/packages/client/src/pages/not-found/index.vue deleted file mode 100644 index 5ef1232..0000000 --- a/packages/client/src/pages/not-found/index.vue +++ /dev/null @@ -1,5 +0,0 @@ - diff --git a/packages/client/src/pages/test/index.vue b/packages/client/src/pages/test/index.vue new file mode 100644 index 0000000..3009320 --- /dev/null +++ b/packages/client/src/pages/test/index.vue @@ -0,0 +1,34 @@ + + + diff --git a/packages/client/src/pages/test/index2.vue b/packages/client/src/pages/test/index2.vue new file mode 100644 index 0000000..526cea6 --- /dev/null +++ b/packages/client/src/pages/test/index2.vue @@ -0,0 +1,28 @@ + + diff --git a/packages/client/src/pages/test/readme.md b/packages/client/src/pages/test/readme.md new file mode 100644 index 0000000..752f6e6 --- /dev/null +++ b/packages/client/src/pages/test/readme.md @@ -0,0 +1 @@ +仅供测试的界面 \ No newline at end of file diff --git a/packages/client/src/router/index.ts b/packages/client/src/router/index.ts index 82c1eea..7470420 100644 --- a/packages/client/src/router/index.ts +++ b/packages/client/src/router/index.ts @@ -1,16 +1,28 @@ -// src/router.js -import { createRouter, createMemoryHistory, createWebHistory } from 'vue-router'; -import NotFound from '../pages/not-found/index.vue'; +// https://uvr.esm.is/guide/extending-routes.html#definepage + +import { createRouter, createMemoryHistory, createWebHistory, RouteRecordRaw } from 'vue-router'; +// import NotFound from '../pages/not-found/index.vue'; +import { routes } from 'vue-router/auto-routes' +import { setupLayouts } from 'virtual:generated-layouts' + +// import BaseLayout from '@/layouts/base.vue'; export default function createSSRRouter() { return createRouter({ history: import.meta.env.SSR ? createMemoryHistory() : createWebHistory(), // 使用内存模式 - routes: [ - { name: "home", path: '/', meta: { cache: true }, component: () => import('../pages/home/index.vue') }, - { name: "about", path: '/about', meta: { cache: true }, component: () => import('../pages/about/index.vue') }, + routes: setupLayouts(routes), + // routes: [ + // { + // name: "BaseLayout", path: '', component: BaseLayout, children: [ + // { name: "home", path: '', meta: { cache: true }, component: () => import('../pages/home/index.vue') }, + // { name: "about", path: 'about', meta: { cache: true }, component: () => import('../pages/about/index.vue') }, + // ] + // }, + // // { name: "home", path: '/', meta: { cache: true }, component: () => import('../pages/home/index.vue') }, + // // { name: "about", path: '/about', meta: { cache: true }, component: () => import('../pages/about/index.vue') }, - // 404 - { path: '/:pathMatch(.*)*', name: 'NotFound', component: NotFound }, - ], + // // 404 + // { path: '/:pathMatch(.*)*', name: 'NotFound', component: NotFound }, + // ], }); } \ No newline at end of file diff --git a/packages/client/src/typed-router.d.ts b/packages/client/src/typed-router.d.ts new file mode 100644 index 0000000..1924560 --- /dev/null +++ b/packages/client/src/typed-router.d.ts @@ -0,0 +1,66 @@ +/* eslint-disable */ +/* prettier-ignore */ +// @ts-nocheck +// Generated by unplugin-vue-router. \u203C\uFE0F DO NOT MODIFY THIS FILE \u203C\uFE0F +// It's recommended to commit this file. +// Make sure to add this file to your tsconfig.json file as an "includes" or "files" entry. + +declare module 'vue-router/auto-routes' { + import type { + RouteRecordInfo, + ParamValue, + ParamValueOneOrMore, + ParamValueZeroOrMore, + ParamValueZeroOrOne, + } from 'vue-router' + + /** + * Route name map generated by unplugin-vue-router + */ + export interface RouteNamedMap { + 'home': RouteRecordInfo<'home', '/', Record, Record>, + '/_M': RouteRecordInfo<'/_M', '/_M', Record, Record>, + 'test': RouteRecordInfo<'test', '/test', Record, Record>, + 'about': RouteRecordInfo<'about', '/test/index2', Record, Record>, + } + + /** + * Route file to route info map by unplugin-vue-router. + * Used by the volar plugin to automatically type useRoute() + * + * Each key is a file path relative to the project root with 2 properties: + * - routes: union of route names of the possible routes when in this page (passed to useRoute<...>()) + * - views: names of nested views (can be passed to ) + * + * @internal + */ + export interface _RouteFileInfoMap { + 'src/pages/index.vue': { + routes: 'home' + views: never + } + 'src/pages/_M.vue': { + routes: '/_M' + views: never + } + 'src/pages/test/index.vue': { + routes: 'test' + views: never + } + 'src/pages/test/index2.vue': { + routes: 'about' + views: never + } + } + + /** + * Get a union of possible route names in a certain route component file. + * Used by the volar plugin to automatically type useRoute() + * + * @internal + */ + export type _RouteNamesForFilePath = + _RouteFileInfoMap extends Record + ? Info['routes'] + : keyof RouteNamedMap +} diff --git a/packages/client/src/vite-env.d.ts b/packages/client/src/vite-env.d.ts index 323c78a..a4d9ae6 100644 --- a/packages/client/src/vite-env.d.ts +++ b/packages/client/src/vite-env.d.ts @@ -1,4 +1,6 @@ /// +/// +/// declare module '*.vue' { import type { DefineComponent } from 'vue' diff --git a/packages/client/src/vue.d.ts b/packages/client/src/vue.d.ts index de7be77..89f70b6 100644 --- a/packages/client/src/vue.d.ts +++ b/packages/client/src/vue.d.ts @@ -1,10 +1,20 @@ +import 'vue-router' + +// 为了确保这个文件被当作一个模块,添加至少一个 `export` 声明 export { } -declare module 'vue' { - export interface ComponentCustomProperties { +declare module 'vue-router' { + interface RouteMeta { + // 是可选的 + cache?: boolean + } +} + +declare module '@vue/runtime-core' { + interface ComponentCustomProperties { $ssrContext?: Record } - export interface ComponentInternalInstance { + interface ComponentInternalInstance { _nuxtClientOnly?: boolean } -} \ No newline at end of file +} diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json index fc06120..cebd767 100644 --- a/packages/client/tsconfig.json +++ b/packages/client/tsconfig.json @@ -2,10 +2,13 @@ "compilerOptions": { "target": "es2022", "useDefineForClassFields": true, - "lib": ["ES2022", "DOM", "DOM.Iterable"], + "lib": [ + "ES2022", + "DOM", + "DOM.Iterable" + ], "module": "esnext", "skipLibCheck": true, - /* Bundler mode */ "moduleResolution": "bundler", "allowImportingTsExtensions": true, @@ -13,14 +16,31 @@ "moduleDetection": "force", "noEmit": true, "jsx": "preserve", - + "jsxImportSource": "vue", /* Linting */ "strict": true, + "noImplicitThis": true, "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, - "noUncheckedSideEffectImports": true + "noUncheckedSideEffectImports": true, + "baseUrl": ".", + "paths": { + "@/*": [ + "./src/*" + ] + } }, - "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "components.d.ts", "auto-imports.d.ts"], - "references": [{ "path": "./tsconfig.node.json" }] -} + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "src/**/*.vue", + "components.d.ts", + "auto-imports.d.ts" + ], + "references": [ + { + "path": "./tsconfig.node.json" + } + ] +} \ No newline at end of file diff --git a/packages/client/vite.config.ts b/packages/client/vite.config.ts index 02f84e2..1949a3d 100644 --- a/packages/client/vite.config.ts +++ b/packages/client/vite.config.ts @@ -1,27 +1,63 @@ import { defineConfig } from 'vite' +import { resolve } from 'node:path' import vue from '@vitejs/plugin-vue' import Components from 'unplugin-vue-components/vite' import AutoImport from 'unplugin-auto-import/vite' import devtoolsJson from 'vite-plugin-devtools-json'; +import VueRouter from 'unplugin-vue-router/vite' +import Layouts from 'vite-plugin-vue-layouts'; +import { VueRouterAutoImports } from 'unplugin-vue-router' +import { unheadVueComposablesImports } from '@unhead/vue' +import { AntDesignXVueResolver } from 'ant-design-x-vue/resolver'; +import { + MazComponentsResolver, + MazDirectivesResolver, + MazModulesResolver +} from 'maz-ui/resolvers' +import { MazIconsResolver } from '@maz-ui/icons/resolvers' // https://vite.dev/config/ export default defineConfig({ + cacheDir: '../../node_modules/.vite', + resolve: { + alias: { + '@': resolve(__dirname, 'src') + }, + }, build: { - emptyOutDir: true + emptyOutDir: true, + }, + // https://github.com/posva/unplugin-vue-router/discussions/349#discussioncomment-9043123 + ssr: { + noExternal: process.env.NODE_ENV === 'development' ? ['vue-router'] : [] }, plugins: [ devtoolsJson(), + VueRouter({ + root: resolve(__dirname), + dts: 'src/typed-router.d.ts', + }), vue(), + Layouts({ + defaultLayout: "base" + }), Components({ dts: true, dirs: ['src/components', '../../internal/x/components'], - globsExclude: ["**/_*/**/*"] + excludeNames: [/^\_.+/], + resolvers: [ + AntDesignXVueResolver(), + MazIconsResolver(), + MazComponentsResolver(), + MazDirectivesResolver(), + ], }), AutoImport({ dts: true, dtsMode: "overwrite", + resolvers: [MazModulesResolver()], ignore: ["**/_*/**/*"], - imports: ['vue', 'vue-router', 'pinia'], + imports: ['vue', 'vue-router', 'pinia', VueRouterAutoImports, unheadVueComposablesImports], dirs: ['./src/composables/**/*', '../../internal/x/composables/**', "./src/store/**/*"], vueTemplate: true, }), diff --git a/packages/core/src/SsrMiddleWare.ts b/packages/core/src/SsrMiddleWare.ts index b6e8388..4be0786 100644 --- a/packages/core/src/SsrMiddleWare.ts +++ b/packages/core/src/SsrMiddleWare.ts @@ -6,6 +6,7 @@ import { ViteDevServer } from 'vite' import Send from 'koa-send' import type Koa from 'koa' import c2k from 'koa-connect' +import { transformHtmlTemplate } from "unhead/server"; const isProduction = Env.isProduction const base = Env.base @@ -27,14 +28,13 @@ export async function SsrMiddleWare(app: Koa, options?: { onDevViteClose?: Funct base, }) app.use(c2k(vite.middlewares)) - vite.httpServer?.on("close", () => { + vite.httpServer?.on("close", () => { vite.close() options?.onDevViteClose?.() - }) + }) } else { // Production mode: serve pre-built static assets. app.use(async (ctx, next) => { - if (ctx.originalUrl === "/.well-known/appspecific/com.chrome.devtools.json") return await next() try { await Send(ctx, ctx.path, { root: getPathByRoot('dist/client'), index: false }); if (ctx.status === 404) { @@ -76,10 +76,12 @@ export async function SsrMiddleWare(app: Koa, options?: { onDevViteClose?: Funct const rendered = await render(url, manifest, { cookies }) - const html = template - .replace(``, rendered.head ?? '') - .replace(``, rendered.html ?? '') - + const html = await transformHtmlTemplate( + rendered.unHead, + template + .replace(``, rendered.head ?? '') + .replace(``, rendered.html ?? '') + ) ctx.status = 200 ctx.set({ 'Content-Type': 'text/html' }) ctx.body = html diff --git a/packages/server/package.json b/packages/server/package.json index 3d6af73..6da9a5e 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -9,12 +9,25 @@ }, "scripts": {}, "devDependencies": { + "@types/formidable": "^3.4.5", "@types/koa": "^3.0.0", - "@types/koa-send": "^4.1.6" + "@types/koa-bodyparser": "^4.3.12", + "@types/koa-send": "^4.1.6", + "@types/path-is-absolute": "^1.0.2", + "jsonwebtoken": "^9.0.2" }, "dependencies": { + "@types/jsonwebtoken": "^9.0.10", + "formidable": "^3.5.4", "koa": "^3.0.1", + "koa-bodyparser": "^4.4.1", "koa-connect": "^2.1.0", - "koa-send": "^5.0.1" + "koa-send": "^5.0.1", + "koa-session": "^7.0.2", + "log4js": "^6.9.1", + "minimatch": "^10.0.3", + "node-cron": "^4.2.1", + "path-is-absolute": "^2.0.0", + "path-to-regexp": "^8.3.0" } } diff --git a/packages/server/src/api/main.ts b/packages/server/src/api/main.ts index 3221941..63dcb0b 100644 --- a/packages/server/src/api/main.ts +++ b/packages/server/src/api/main.ts @@ -47,7 +47,7 @@ export function bootstrapServer() { // ctx.set("Set-Cookie", [setItem]); // } if (ctx.originalUrl !== "/api/pics/random") return await next(); - ctx.body = `Hello World` + const { type, data } = await fetchFirstSuccess([ "https://api.miaomc.cn/image/get", ]); diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts index 38d9780..14b26e0 100644 --- a/packages/server/src/app.ts +++ b/packages/server/src/app.ts @@ -1,7 +1,17 @@ - +import { validateEnvironment } from "@/utils/EnvValidator" import Koa from "koa" +import { logger } from "@/logger" + +// 启动前验证环境变量 +if (!validateEnvironment()) { + logger.error("环境变量验证失败,应用退出") + process.exit(1) +} -const app = new Koa() +const app = new Koa({ + asyncLocalStorage: true, + keys: (process.env.SESSION_SECRET || '').split(",").filter(v => !!v).map(s => s.trim()) +}) export default app export { diff --git a/packages/server/src/base/BaseController.ts b/packages/server/src/base/BaseController.ts new file mode 100644 index 0000000..29e3fd5 --- /dev/null +++ b/packages/server/src/base/BaseController.ts @@ -0,0 +1,318 @@ +// @ts-nocheck +import { R } from "@/utils/R" +import { logger } from "@/logger.js" +import CommonError from "@/utils/error/CommonError.js" + +/** + * 基础控制器类 + * 提供通用的错误处理、响应格式化等功能 + * 所有控制器都应继承此类 + */ +class BaseController { + constructor() { + // 绑定所有方法的this上下文,确保在路由中使用时this指向正确 + this._bindMethods() + } + + /** + * 绑定所有方法的this上下文 + * @private + */ + _bindMethods() { + const proto = Object.getPrototypeOf(this) + const propertyNames = Object.getOwnPropertyNames(proto) + + propertyNames.forEach(name => { + + if (name !== 'constructor' && typeof this[name] === 'function') { + this[name] = this[name].bind(this) + } + }) + } + + /** + * 统一成功响应 + * @param {*} ctx - Koa上下文 + * @param {*} data - 响应数据 + * @param {string} message - 响应消息 + * @param {number} statusCode - HTTP状态码 + */ + success(ctx, data = null, message = null, statusCode = 200) { + return R.response(statusCode, data, message) + } + + /** + * 统一错误响应 + * @param {*} ctx - Koa上下文 + * @param {string} message - 错误消息 + * @param {*} data - 错误数据 + * @param {number} statusCode - HTTP状态码 + */ + error(ctx, message = "操作失败", data = null, statusCode = 500) { + return R.response(statusCode, data, message) + } + + /** + * 统一异常处理装饰器 + * 用于包装控制器方法,自动处理异常 + * @param {Function} handler - 控制器方法 + * @returns {Function} 包装后的方法 + */ + handleRequest(handler) { + return async (ctx, next) => { + try { + await handler.call(this, ctx, next) + } catch (error) { + logger.error("Controller error:", error) + + if (error instanceof CommonError) { + // 业务异常,返回具体错误信息 + return this.error(ctx, error.message, null, 400) + } + + // 系统异常,返回通用错误信息 + return this.error(ctx, "系统内部错误", null, 500) + } + } + } + + /** + * 分页响应助手 + * @param {*} ctx - Koa上下文 + * @param {Object} paginationResult - 分页结果 + * @param {string} message - 响应消息 + */ + paginated(ctx, paginationResult, message = "获取数据成功") { + const { data, pagination } = paginationResult + return this.success(ctx, { + list: data, + pagination + }, message) + } + + /** + * 验证请求参数 + * @param {*} ctx - Koa上下文 + * @param {Object} rules - 验证规则 + * @throws {CommonError} 验证失败时抛出异常 + */ + validateParams(ctx, rules) { + const data = { ...ctx.request.body, ...ctx.query, ...ctx.params } + + for (const [field, rule] of Object.entries(rules)) { + const value = data[field] + + // 必填验证 + if (rule.required && (value === undefined || value === null || value === '')) { + throw new CommonError(`${rule.label || field}不能为空`) + } + + // 类型验证 + if (value !== undefined && value !== null && rule.type) { + if (rule.type === 'number' && isNaN(value)) { + throw new CommonError(`${rule.label || field}必须是数字`) + } + if (rule.type === 'email' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) { + throw new CommonError(`${rule.label || field}格式不正确`) + } + } + + // 长度验证 + if (value && rule.minLength && value.length < rule.minLength) { + throw new CommonError(`${rule.label || field}长度不能少于${rule.minLength}个字符`) + } + if (value && rule.maxLength && value.length > rule.maxLength) { + throw new CommonError(`${rule.label || field}长度不能超过${rule.maxLength}个字符`) + } + } + + return data + } + + /** + * 获取分页参数 + * @param {*} ctx - Koa上下文 + * @param {Object} defaults - 默认值 + * @returns {Object} 分页参数 + */ + getPaginationParams(ctx, defaults = {}) { + const { + page = defaults.page || 1, + limit = defaults.limit || 10, + orderBy = defaults.orderBy || 'created_at', + order = defaults.order || 'desc' + } = ctx.query + + return { + page: Math.max(1, parseInt(page) || 1), + limit: Math.min(100, Math.max(1, parseInt(limit) || 10)), // 限制最大100条 + orderBy, + order: order.toLowerCase() === 'asc' ? 'asc' : 'desc' + } + } + + /** + * 获取搜索参数 + * @param {*} ctx - Koa上下文 + * @returns {Object} 搜索参数 + */ + getSearchParams(ctx) { + const { keyword, status, category, author } = ctx.query + + const params = {} + if (keyword && keyword.trim()) { + params.keyword = keyword.trim() + } + if (status) { + params.status = status + } + if (category) { + params.category = category + } + if (author) { + params.author = author + } + + return params + } + + /** + * 处理文件上传 + * @param {*} ctx - Koa上下文 + * @param {string} fieldName - 文件字段名 + * @returns {Object} 文件信息 + */ + getUploadedFile(ctx, fieldName = 'file') { + const files = ctx.request.files + if (!files || !files[fieldName]) { + return null + } + + const file = Array.isArray(files[fieldName]) ? files[fieldName][0] : files[fieldName] + return { + name: file.originalFilename || file.name, + size: file.size, + type: file.mimetype || file.type, + path: file.filepath || file.path + } + } + + /** + * 重定向助手 + * @param {*} ctx - Koa上下文 + * @param {string} url - 重定向URL + * @param {string} message - 提示消息 + */ + redirect(ctx, url, message = null) { + if (message) { + // 设置flash消息(如果有toast中间件) + if (ctx.flash) { + ctx.flash('success', message) + } + } + ctx.redirect(url) + } + + /** + * 渲染视图助手 + * @param {*} ctx - Koa上下文 + * @param {string} template - 模板路径 + * @param {Object} data - 模板数据 + * @param {Object} options - 渲染选项 + */ + async render(ctx, template, data = {}, options = {}) { + const defaultOptions = { + // includeSite: true, + // includeUser: true, + ...options + } + + return await ctx.render(template, data, defaultOptions) + } + + /** + * JSON API响应助手 + * @param {*} ctx - Koa上下文 + * @param {*} data - 响应数据 + * @param {string} message - 响应消息 + * @param {number} statusCode - HTTP状态码 + */ + json(ctx, data = null, message = null, statusCode = 200) { + ctx.status = statusCode + ctx.body = { + success: statusCode < 400, + data, + message, + timestamp: new Date().toISOString() + } + } + + /** + * 获取当前用户 + * @param {*} ctx - Koa上下文 + * @returns {Object|null} 用户信息 + */ + getCurrentUser(ctx) { + return ctx.state.user || null + } + + /** + * 检查用户是否已登录 + * @param {*} ctx - Koa上下文 + * @returns {boolean} 是否已登录 + */ + isLoggedIn(ctx) { + return !!ctx.state.user + } + + /** + * 获取用户ID + * @param {*} ctx - Koa上下文 + * @returns {string|number|null} 用户ID + */ + getCurrentUserId(ctx) { + const user = this.getCurrentUser(ctx) + return user ? (user.id || user._id || null) : null + } + + /** + * 检查用户权限 + * @param {*} ctx - Koa上下文 + * @param {string|Array} permission - 权限名或权限数组 + * @throws {CommonError} 权限不足时抛出异常 + */ + checkPermission(ctx, permission) { + const user = this.getCurrentUser(ctx) + if (!user) { + throw new CommonError("用户未登录") + } + + // 这里可以根据实际需求实现权限检查逻辑 + // 例如检查用户角色、权限列表等 + // if (!user.hasPermission(permission)) { + // throw new CommonError("权限不足") + // } + } + + /** + * 检查资源所有权 + * @param {*} ctx - Koa上下文 + * @param {Object} resource - 资源对象 + * @param {string} ownerField - 所有者字段名,默认为'author' + * @throws {CommonError} 无权限时抛出异常 + */ + checkOwnership(ctx, resource, ownerField = 'author') { + const user = this.getCurrentUser(ctx) + if (!user) { + throw new CommonError("用户未登录") + } + + const userId = this.getCurrentUserId(ctx) + if (resource[ownerField] !== userId && resource[ownerField] !== user.username) { + throw new CommonError("无权限操作此资源") + } + } +} + +export default BaseController +export { BaseController } \ No newline at end of file diff --git a/packages/server/src/booststap.ts b/packages/server/src/booststap.ts index c8432ce..4a1ebbf 100644 --- a/packages/server/src/booststap.ts +++ b/packages/server/src/booststap.ts @@ -1,20 +1,38 @@ import app from "./app" import { bootstrapServer } from "./api/main" -import { SsrMiddleWare } from "core/SsrMiddleWare" +import LoadMiddleware from "./middleware/install" import { Env } from "helper/env" +import os from "node:os" + +import "./jobs" bootstrapServer() -SsrMiddleWare(app, { - onDevViteClose() { - console.log("Vite dev server closed") - if (server) { - server.close() - console.log('Server closed') - } - } -}) +await LoadMiddleware(app) const server = app.listen(Env.port, () => { - console.log(`Server started at http://localhost:${Env.port}`) + const address = server.address() + if (address != null && typeof address !== 'string') { + const port = address.port + // 获取本地 IP + const getLocalIP = () => { + const interfaces = os.networkInterfaces() + for (const name of Object.keys(interfaces)) { + if (!interfaces[name]) continue + for (const iface of interfaces[name]) { + if (iface.family === "IPv4" && !iface.internal) { + return iface.address + } + } + } + return "localhost" + } + const localIP = getLocalIP() + console.log(`────────────────────────────────────────`) + console.log(`🚀 服务器已启动`) + console.log(` 本地访问: http://localhost:${port}`) + console.log(` 局域网: http://${localIP}:${port}`) + console.log(` 启动时间: ${new Date().toLocaleString()}`) + console.log(`────────────────────────────────────────`) + } }) diff --git a/packages/server/src/env.d.ts b/packages/server/src/env.d.ts new file mode 100644 index 0000000..4c91033 --- /dev/null +++ b/packages/server/src/env.d.ts @@ -0,0 +1,11 @@ + +declare global { + namespace NodeJS { + interface ProcessEnv { + SESSION_SECRET: string; + JWT_SECRET: string; + } + } +} + +export { }; diff --git a/packages/server/src/jobs/index.ts b/packages/server/src/jobs/index.ts new file mode 100644 index 0000000..19c1c19 --- /dev/null +++ b/packages/server/src/jobs/index.ts @@ -0,0 +1,62 @@ +import fs from 'fs'; +import path from 'path'; +import scheduler from './scheduler'; +import { TaskOptions } from 'node-cron'; + +interface OneJob { + id: string + cronTime: string + task: Function + options: TaskOptions, + autoStart: boolean, + [key: string]: Function | string | boolean | TaskOptions | undefined +} + +export function defineJob(job: OneJob) { + return job; +} + +const jobsDir = path.join(__dirname, 'jobs'); +const jobModules: Record = {}; + +fs.readdirSync(jobsDir).forEach(file => { + if (!file.endsWith('Job.ts')) return; + const jobModule = require(path.join(jobsDir, file)); + const job = jobModule.default || jobModule; + if (job && job.id && job.cronTime && typeof job.task === 'function') { + jobModules[job.id] = job; + scheduler.add(job.id, job.cronTime, job.task, job.options); + if (job.autoStart) scheduler.start(job.id); + } +}); + +function callHook(id: string, hookName: string) { + const job = jobModules[id]; + if (job && typeof job[hookName] === 'function') { + try { + job[hookName](); + } catch (e) { + console.error(`[Job:${id}] ${hookName} 执行异常:`, e); + } + } +} + +export default { + start: (id: string) => { + callHook(id, 'beforeStart'); + scheduler.start(id); + }, + stop: (id: string) => { + scheduler.stop(id); + callHook(id, 'afterStop'); + }, + updateCronTime: (id: string, cronTime: string) => scheduler.updateCronTime(id, cronTime), + list: () => scheduler.list(), + reload: (id: string) => { + const job = jobModules[id]; + if (job) { + scheduler.remove(id); + scheduler.add(job.id, job.cronTime, job.task, job.options); + } + } +}; diff --git a/packages/server/src/jobs/jobs/exampleJob.ts b/packages/server/src/jobs/jobs/exampleJob.ts new file mode 100644 index 0000000..d1610d4 --- /dev/null +++ b/packages/server/src/jobs/jobs/exampleJob.ts @@ -0,0 +1,12 @@ +import { jobLogger } from "@/logger" +import { defineJob } from ".." + +export default defineJob({ + id: "example", + cronTime: "*/10 * * * * *", // 每10秒执行一次 + task: () => { + jobLogger.info("Example Job 执行了") + }, + options: {}, + autoStart: false, +}) diff --git a/packages/server/src/jobs/scheduler.ts b/packages/server/src/jobs/scheduler.ts new file mode 100644 index 0000000..5f2e36e --- /dev/null +++ b/packages/server/src/jobs/scheduler.ts @@ -0,0 +1,63 @@ +import cron, { ScheduledTask, TaskFn, TaskOptions } from 'node-cron'; + +export interface Job { job: ScheduledTask; cronTime: string; task: Function; options: TaskOptions; status: 'running' | 'stopped' } + +class Scheduler { + jobs: Map; + constructor() { + this.jobs = new Map(); + } + + add(id: string, cronTime: string, task: Function, options: TaskOptions = {}) { + if (this.jobs.has(id)) this.remove(id); + const job = cron.createTask(cronTime, task as TaskFn, { ...options, noOverlap: true }); + this.jobs.set(id, { job, cronTime, task, options, status: 'stopped' }); + } + + execute(id: string) { + const entry = this.jobs.get(id); + if (entry && entry.status === 'running') { + entry.job.execute(); + } + } + + start(id: string) { + const entry = this.jobs.get(id); + if (entry && entry.status !== 'running') { + entry.job.start(); + entry.status = 'running'; + } + } + + stop(id: string) { + const entry = this.jobs.get(id); + if (entry && entry.status === 'running') { + entry.job.stop(); + entry.status = 'stopped'; + } + } + + remove(id: string) { + const entry = this.jobs.get(id); + if (entry) { + entry.job.destroy(); + this.jobs.delete(id); + } + } + + updateCronTime(id: string, newCronTime: string) { + const entry = this.jobs.get(id); + if (entry) { + this.remove(id); + this.add(id, newCronTime, entry.task, entry.options); + } + } + + list() { + return Array.from(this.jobs.entries()).map(([id, { cronTime, status }]) => ({ + id, cronTime, status + })); + } +} + +export default new Scheduler(); diff --git a/packages/server/src/logger.ts b/packages/server/src/logger.ts new file mode 100644 index 0000000..6d7daea --- /dev/null +++ b/packages/server/src/logger.ts @@ -0,0 +1,61 @@ + +import log4js from "log4js"; +import { Env } from 'helper/env'; + +const { LOG_DIR } = Env; + +log4js.configure({ + appenders: { + all: { + type: "file", + filename: `${LOG_DIR}/all.log`, + maxLogSize: 102400, + pattern: "-yyyy-MM-dd.log", + alwaysIncludePattern: true, + backups: 3, + layout: { + type: 'pattern', + pattern: '[%d{yyyy-MM-dd hh:mm:ss}] [%p] %m', + }, + }, + error: { + type: "file", + filename: `${LOG_DIR}/error.log`, + maxLogSize: 102400, + pattern: "-yyyy-MM-dd.log", + alwaysIncludePattern: true, + backups: 3, + layout: { + type: 'pattern', + pattern: '[%d{yyyy-MM-dd hh:mm:ss}] [%p] %m', + }, + }, + jobs: { + type: "file", + filename: `${LOG_DIR}/jobs.log`, + maxLogSize: 102400, + pattern: "-yyyy-MM-dd.log", + alwaysIncludePattern: true, + backups: 3, + layout: { + type: 'pattern', + pattern: '[%d{yyyy-MM-dd hh:mm:ss}] [%p] %m', + }, + }, + console: { + type: "console", + layout: { + type: "pattern", + pattern: '\x1b[90m[%d{hh:mm:ss}]\x1b[0m \x1b[1m[%p]\x1b[0m %m', + }, + }, + }, + categories: { + jobs: { appenders: ["console", "jobs"], level: "info" }, + // error: { appenders: ["console", "error"], level: "error" }, + default: { appenders: ["console", "all"], level: "all" }, + }, +}); + +export const logger = log4js.getLogger(); +export const jobLogger = log4js.getLogger('jobs'); diff --git a/packages/server/src/middleware/Auth/index.ts b/packages/server/src/middleware/Auth/index.ts new file mode 100644 index 0000000..fcc2426 --- /dev/null +++ b/packages/server/src/middleware/Auth/index.ts @@ -0,0 +1,38 @@ +import { minimatch } from "minimatch" +import CommonError from "@/utils/error/CommonError" +import { DefaultContext, Next } from "koa" + +export const JWT_SECRET = process.env.JWT_SECRET + +function matchList(list: any[], path: string) { + for (const item of list) { + if (typeof item === "string" && minimatch(path, item, { dot: true })) { + return { matched: true } + } + if (typeof item === "object" && minimatch(path, item.pattern, { dot: true })) { + return { matched: true } + } + } + return { matched: false } +} + +export function AuthMiddleware(options: any = { + whiteList: [], + blackList: [] +}) { + return (ctx: DefaultContext, next: Next) => { + if (ctx.session.user) { + ctx.state.user = ctx.session.user + } + // 黑名单优先生效 + if (matchList(options.blackList, ctx.path).matched) { + throw new CommonError("禁止访问", CommonError.ERR_CODE.FORBIDDEN) + } + // 白名单处理 + const white = matchList(options.whiteList, ctx.path) + if (!white.matched) { + throw new CommonError(`禁止访问:${ctx.path}`, CommonError.ERR_CODE.FORBIDDEN) + } + return next() + } +} diff --git a/packages/server/src/middleware/Controller/index.ts b/packages/server/src/middleware/Controller/index.ts new file mode 100644 index 0000000..6b19bd6 --- /dev/null +++ b/packages/server/src/middleware/Controller/index.ts @@ -0,0 +1,100 @@ +import fs from "fs" +import path from "path" +import { logger } from "@/logger.js" +import compose from "koa-compose" +import { Next, ParameterizedContext } from "koa" + +async function scanControllers(rootDir: string) { + const routers = [] + const stack: string[] = [rootDir] + while (stack.length) { + const dir = stack.pop() + if (!dir) continue + let files + try { + files = fs.readdirSync(dir) + } catch (error: any) { + logger.error(`[控制器注册] ❌ 读取目录失败 ${dir}: ${error.message}`) + continue + } + + for (const file of files) { + if (file.startsWith("_")) continue + const fullPath = path.join(dir, file) + let stat + try { + stat = fs.statSync(fullPath) + } catch (error: any) { + logger.error(`[控制器注册] ❌ 读取文件信息失败 ${fullPath}: ${error.message}`) + continue + } + + if (stat.isDirectory()) { + stack.push(fullPath) + continue + } + + if (!fullPath.replace(/\\/g, "/").includes("/controller/")) continue + + let fileName = fullPath.replace(rootDir + path.sep, "") + + try { + const controllerModule = await import(fullPath) + const controller = controllerModule.default || controllerModule + if (!controller) { + logger.warn(`[控制器注册] ${fileName} - 缺少默认导出,跳过注册`) + continue + } + + let routesFactory = controller.createRoutes || controller.default?.createRoutes || controller.default || controller + if (typeof routesFactory === "function") { + routesFactory = routesFactory.bind(controller) + } + if (typeof routesFactory !== "function") { + logger.warn(`[控制器注册] ⚠️ ${fileName} - 未找到 createRoutes 方法或导出对象`) + continue + } + + let routerResult + try { + routerResult = routesFactory() + } catch (error: any) { + logger.error(`[控制器注册] ❌ ${fileName} - createRoutes() 执行失败: ${error.message}`) + continue + } + + const list = Array.isArray(routerResult) ? routerResult : [routerResult] + let added = 0 + for (const r of list) { + if (r && typeof r.middleware === "function") { + routers.push(r) + added++ + } else { + logger.warn(`[控制器注册] ⚠️ ${fileName} - createRoutes() 返回的部分路由器对象无效`) + } + } + if (added > 0) logger.debug(`[控制器注册] ✅ ${fileName} - 创建成功 (${added})`) + } catch (importError: any) { + logger.error(`[控制器注册] ❌ ${fileName} - 模块导入失败: ${importError.message}`) + logger.error(importError) + } + } + } + return routers +} + +export default async function (options: { root: string, handleBeforeEachRequest: Function }) { + const { root, handleBeforeEachRequest } = options + if (!root) { + throw new Error("controller root is required") + } + const routers = await scanControllers(root) + const allRouters: any[] = [] + for (let i = 0; i < routers.length; i++) { + const router = routers[i] + allRouters.push(router.middleware((options = {}) => handleBeforeEachRequest(options))) + } + return async function (ctx: ParameterizedContext, next: Next) { + return await compose(allRouters)(ctx, next) + } +} diff --git a/packages/server/src/middleware/ResponseTime/index.ts b/packages/server/src/middleware/ResponseTime/index.ts new file mode 100644 index 0000000..db8b876 --- /dev/null +++ b/packages/server/src/middleware/ResponseTime/index.ts @@ -0,0 +1,59 @@ +import { logger } from "@/logger" +import type { ParameterizedContext, Next } from "koa" + +// 静态资源扩展名列表 +const staticExts = [".css", ".js", ".png", ".jpg", ".jpeg", ".gif", ".ico", ".svg", ".map", ".woff", ".woff2", ".ttf", ".eot"] + +function isStaticResource(path: string): boolean { + return staticExts.some(ext => path.endsWith(ext)) +} + +export default async (ctx: ParameterizedContext, next: Next) => { + if (isStaticResource(ctx.path)) { + await next() + return + } + if (!ctx.path.includes("/api")) { + const start = Date.now() + await next() + const ms = Date.now() - start + ctx.set("X-Response-Time", `${ms}ms`) + if (ms > 500) { + logger.info(`${ctx.path} | ⏱️ ${ms}ms`) + } + return + } + // API日志记录 + const start = Date.now() + await next() + const ms = Date.now() - start + ctx.set("X-Response-Time", `${ms}ms`) + const Threshold = 0 + if (ms > Threshold) { + logger.info("====================[ ➡️ REQ]====================") + // 用户信息(假设ctx.state.user存在) + const user = ctx.state && ctx.state.user ? ctx.state.user : null + // IP + const ip = ctx.ip || ctx.request.ip || ctx.headers["x-forwarded-for"] || ctx.req.connection.remoteAddress + // 请求参数 + const params = { + query: ctx.query, + body: ctx.request.body, + } + // 响应状态码 + const status = ctx.status + // 组装日志对象 + const logObj = { + method: ctx.method, + path: ctx.path, + url: ctx.url, + user: user ? { id: user.id, username: user.username } : null, + ip, + params, + status, + ms, + } + logger.info(JSON.stringify(logObj, null, 2)) + logger.info("====================[ ⬅️ END]====================\n") + } +} diff --git a/packages/server/src/middleware/Send/index.ts b/packages/server/src/middleware/Send/index.ts new file mode 100644 index 0000000..5ac84a0 --- /dev/null +++ b/packages/server/src/middleware/Send/index.ts @@ -0,0 +1,186 @@ +// @ts-nocheck +/** + * koa-send@5.0.1 转换为ES Module版本 + * 静态资源服务中间件 + */ +import fs from 'fs'; +import { promisify } from 'util'; +import logger from 'log4js'; +import resolvePath from './resolve-path.js'; +import createError from 'http-errors'; +import assert from 'assert'; +import { normalize, basename, extname, resolve, parse, sep } from 'path'; +import { fileURLToPath } from 'url'; +import path from "path" + +// 转换为ES Module格式 +const log = logger.getLogger('koa-send'); +const stat = promisify(fs.stat); +const access = promisify(fs.access); +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +/** + * 检查文件是否存在 + * @param {string} path - 文件路径 + * @returns {Promise} 文件是否存在 + */ +async function exists(path) { + try { + await access(path); + return true; + } catch (e) { + return false; + } +} + +/** + * 发送文件给客户端 + * @param {Context} ctx - Koa上下文对象 + * @param {String} path - 文件路径 + * @param {Object} [opts] - 配置选项 + * @returns {Promise} - 异步Promise + */ +async function send(ctx, path, opts = {}) { + assert(ctx, 'koa context required'); + assert(path, 'pathname required'); + + // 移除硬编码的public目录,要求必须通过opts.root配置 + const root = opts.root; + if (!root) { + throw new Error('Static root directory must be configured via opts.root'); + } + const trailingSlash = path[path.length - 1] === '/'; + path = path.substr(parse(path).root.length); + const index = opts.index || 'index.html'; + const maxage = opts.maxage || opts.maxAge || 0; + const immutable = opts.immutable || false; + const hidden = opts.hidden || false; + const format = opts.format !== false; + const extensions = Array.isArray(opts.extensions) ? opts.extensions : false; + const brotli = opts.brotli !== false; + const gzip = opts.gzip !== false; + const setHeaders = opts.setHeaders; + + if (setHeaders && typeof setHeaders !== 'function') { + throw new TypeError('option setHeaders must be function'); + } + + // 解码路径 + path = decode(path); + if (path === -1) return ctx.throw(400, 'failed to decode'); + + // 索引文件支持 + if (index && trailingSlash) path += index; + + path = resolvePath(root, path); + + // 隐藏文件支持 + if (!hidden && isHidden(root, path)) return; + + let encodingExt = ''; + // 尝试提供压缩文件 + if (ctx.acceptsEncodings('br', 'identity') === 'br' && brotli && (await exists(path + '.br'))) { + path = path + '.br'; + ctx.set('Content-Encoding', 'br'); + ctx.res.removeHeader('Content-Length'); + encodingExt = '.br'; + } else if (ctx.acceptsEncodings('gzip', 'identity') === 'gzip' && gzip && (await exists(path + '.gz'))) { + path = path + '.gz'; + ctx.set('Content-Encoding', 'gzip'); + ctx.res.removeHeader('Content-Length'); + encodingExt = '.gz'; + } + + // 尝试添加文件扩展名 + if (extensions && !/\./.exec(basename(path))) { + const list = [].concat(extensions); + for (let i = 0; i < list.length; i++) { + let ext = list[i]; + if (typeof ext !== 'string') { + throw new TypeError('option extensions must be array of strings or false'); + } + if (!/^\./.exec(ext)) ext = `.${ext}`; + if (await exists(`${path}${ext}`)) { + path = `${path}${ext}`; + break; + } + } + } + + // 获取文件状态 + let stats; + try { + stats = await stat(path); + + // 处理目录 + if (stats.isDirectory()) { + if (format && index) { + path += `/${index}`; + stats = await stat(path); + } else { + return; + } + } + } catch (err) { + const notfound = ['ENOENT', 'ENAMETOOLONG', 'ENOTDIR']; + if (notfound.includes(err.code)) { + throw createError(404, err); + } + err.status = 500; + throw err; + } + + if (setHeaders) setHeaders(ctx.res, path, stats); + + // 设置响应头 + ctx.set('Content-Length', stats.size); + if (!ctx.response.get('Last-Modified')) ctx.set('Last-Modified', stats.mtime.toUTCString()); + if (!ctx.response.get('Cache-Control')) { + const directives = [`max-age=${(maxage / 1000) | 0}`]; + if (immutable) directives.push('immutable'); + ctx.set('Cache-Control', directives.join(',')); + } + if (!ctx.type) ctx.type = type(path, encodingExt); + ctx.body = fs.createReadStream(path); + + return path; +} + +/** + * 检查是否为隐藏文件 + * @param {string} root - 根目录 + * @param {string} path - 文件路径 + * @returns {boolean} 是否为隐藏文件 + */ +function isHidden(root, path) { + path = path.substr(root.length).split(sep); + for (let i = 0; i < path.length; i++) { + if (path[i][0] === '.') return true; + } + return false; +} + +/** + * 获取文件类型 + * @param {string} file - 文件路径 + * @param {string} ext - 编码扩展名 + * @returns {string} 文件MIME类型 + */ +function type(file, ext) { + return ext !== '' ? extname(basename(file, ext)) : extname(file); +} + +/** + * 解码URL路径 + * @param {string} path - 需要解码的路径 + * @returns {string|number} 解码后的路径或错误代码 + */ +function decode(path) { + try { + return decodeURIComponent(path); + } catch (err) { + return -1; + } +} + +export default send; diff --git a/packages/server/src/middleware/Send/resolve-path.ts b/packages/server/src/middleware/Send/resolve-path.ts new file mode 100644 index 0000000..fd73f71 --- /dev/null +++ b/packages/server/src/middleware/Send/resolve-path.ts @@ -0,0 +1,74 @@ +/*! + * resolve-path + * Copyright(c) 2014 Jonathan Ong + * Copyright(c) 2015-2018 Douglas Christopher Wilson + * MIT Licensed + */ + +/** + * ES Module 转换版本 + * 路径解析工具,防止路径遍历攻击 + */ +import createError from 'http-errors'; +import { join, normalize, resolve, sep } from 'path'; +import pathIsAbsolute from 'path-is-absolute'; + +/** + * 模块变量 + * @private + */ +const UP_PATH_REGEXP = /(?:^|[\/])\.\.(?:[\/]|$)/; + +/** + * 解析相对路径到根路径 + * @param {string} rootPath - 根目录路径 + * @param {string} relativePath - 相对路径 + * @returns {string} 解析后的绝对路径 + * @public + */ +function resolvePath(rootPath: string, relativePath: string) { + let path = relativePath; + let root = rootPath; + + // root是可选的,类似于root.resolve + if (arguments.length === 1) { + path = rootPath; + root = process.cwd(); + } + + if (root == null) { + throw new TypeError('argument rootPath is required'); + } + + if (typeof root !== 'string') { + throw new TypeError('argument rootPath must be a string'); + } + + if (path == null) { + throw new TypeError('argument relativePath is required'); + } + + if (typeof path !== 'string') { + throw new TypeError('argument relativePath must be a string'); + } + + // 包含NULL字节是恶意的 + if (path.indexOf('\0') !== -1) { + throw createError(400, 'Malicious Path'); + } + + // 路径绝不能是绝对路径 + if (pathIsAbsolute.posix(path) || pathIsAbsolute.win32(path)) { + throw createError(400, 'Malicious Path'); + } + + // 路径超出根目录 + if (UP_PATH_REGEXP.test(normalize('.' + sep + path))) { + throw createError(403); + } + + // 拼接相对路径 + return normalize(join(resolve(root), path)); +} + +export default resolvePath; diff --git a/packages/server/src/middleware/Session/index.ts b/packages/server/src/middleware/Session/index.ts new file mode 100644 index 0000000..9ea61f0 --- /dev/null +++ b/packages/server/src/middleware/Session/index.ts @@ -0,0 +1,16 @@ +import { DefaultContext } from 'koa'; +import session from 'koa-session'; + +export default (app: DefaultContext) => { + const CONFIG = { + key: 'koa:sess', // cookie key + maxAge: 86400000, // 1天 + httpOnly: true, + signed: true, // 将 cookie 的内容通过密钥进行加密。需配置app.keys + rolling: false, + renew: false, + secure: process.env.NODE_ENV === "production" && process.env.HTTPS_ENABLE === "on", + sameSite: "strict", // https://scotthelme.co.uk/csrf-is-dead/ + }; + return session(CONFIG, app); +}; diff --git a/packages/server/src/middleware/install.ts b/packages/server/src/middleware/install.ts new file mode 100644 index 0000000..20d21a2 --- /dev/null +++ b/packages/server/src/middleware/install.ts @@ -0,0 +1,104 @@ + +import { SsrMiddleWare } from "core/SsrMiddleWare" +import bodyParser from "koa-bodyparser" +import app from "@/app" +import ResponseTime from "./ResponseTime" +import Controller from "./Controller" +import path from "node:path" +import jwt from "jsonwebtoken" +import AuthError from "@/utils/error/AuthError" +import CommonError from "@/utils/error/CommonError" +import { DefaultContext, Next, ParameterizedContext } from "koa" +import { AuthMiddleware } from "./Auth" +import Session from "./Session" +import Send from "./Send" +import { getPathByRoot } from "helper/path" + +type App = typeof app + +export default async (app: App) => { + + app.use(ResponseTime) + + // 拦截 Chrome DevTools 探测请求,直接返回 204 + app.use((ctx: DefaultContext, next: Next) => { + if (ctx.path === "/.well-known/appspecific/com.chrome.devtools.json") { + ctx.status = 204 + ctx.body = "" + return + } + return next() + }) + + + const publicPath = getPathByRoot("public") + app.use(async (ctx, next) => { + if (!ctx.path.startsWith("/public")) return await next() + if (ctx.method.toLowerCase() === "get") { + try { + await Send(ctx, ctx.path.replace("/public", ""), { root: publicPath, maxAge: 0, immutable: false }) + } catch (err: any) { + if (err.status !== 404) throw err + } + } + }) + + app.use(Session(app)) + + // 权限设置 + app.use( + AuthMiddleware({ + whiteList: [ + // 所有请求放行 + { pattern: "/" }, + { pattern: "/**/*" }, + ], + blackList: [ + // 禁用api请求 + // "/api", + // "/api/", + // "/api/**/*", + ], + }) + ) + + app.use(bodyParser()) + app.use( + await Controller({ + root: path.resolve(__dirname, "../modules"), + handleBeforeEachRequest: (options: any) => { + const { auth = true } = options || {} + return async (ctx: ParameterizedContext, next: Next) => { + if (ctx.session && ctx.session.user) { + ctx.state.user = ctx.session.user + } else { + const authorizationString = ctx.headers && ctx.headers["authorization"] + if (authorizationString) { + const token = authorizationString.replace(/^Bearer\s/, "") + try { + ctx.state.user = jwt.verify(token, process.env.JWT_SECRET) + } catch (_) { + // 无效token忽略 + } + } + } + + if (auth === false && ctx.state.user) { + throw new CommonError("不能登录查看") + } + if (auth === "try") { + return next() + } + if (auth === true && !ctx.state.user) { + throw new AuthError("需要登录才能访问") + } + + return await next() + } + }, + }) + ) + // 处理SSR的插件,理应放在所有路由中间件的最后 + await SsrMiddleWare(app) + +} \ No newline at end of file diff --git a/packages/server/src/modules/Job/controller/index.ts b/packages/server/src/modules/Job/controller/index.ts new file mode 100644 index 0000000..5e059a7 --- /dev/null +++ b/packages/server/src/modules/Job/controller/index.ts @@ -0,0 +1,51 @@ +// Job Controller 示例:如何调用 service 层动态控制和查询定时任务 +import JobService from "../services" +import { R } from "@/utils/R" +import Router from "@/utils/Router" +import { ParameterizedContext } from "koa" + +class JobController { + + static createRoutes() { + const controller = new this() + const router = new Router({ prefix: "/api/jobs", auth: "try" }) + router.get("", controller.list.bind(controller)) + router.get("/", controller.list.bind(controller)) + router.get("/start/:id", controller.start.bind(controller)) + router.post("/stop/:id", controller.stop.bind(controller)) + router.post("/update/:id", controller.updateCron.bind(controller)) + return router + } + + jobService: JobService + + constructor() { + this.jobService = new JobService() + } + + async list(ctx: ParameterizedContext) { + const data = this.jobService.listJobs() + R.response(R.SUCCESS, data) + } + + async start(ctx: ParameterizedContext) { + const { id } = ctx.params + this.jobService.startJob(id) + R.response(R.SUCCESS, null, `${id} 任务已启动`) + } + + async stop(ctx: ParameterizedContext) { + const { id } = ctx.params + this.jobService.stopJob(id) + R.response(R.SUCCESS, null, `${id} 任务已停止`) + } + + async updateCron(ctx: ParameterizedContext) { + const { id } = ctx.params + const { cronTime } = ctx.request.body as { cronTime: string } + this.jobService.updateJobCron(id, cronTime) + R.response(R.SUCCESS, null, `${id} 任务频率已修改`) + } +} + +export default JobController diff --git a/packages/server/src/modules/Job/services/index.ts b/packages/server/src/modules/Job/services/index.ts new file mode 100644 index 0000000..16c6180 --- /dev/null +++ b/packages/server/src/modules/Job/services/index.ts @@ -0,0 +1,18 @@ +import jobs from "@/jobs" + +class JobService { + startJob(id: string) { + return jobs.start(id) + } + stopJob(id: string) { + return jobs.stop(id) + } + updateJobCron(id: string, cronTime: string) { + return jobs.updateCronTime(id, cronTime) + } + listJobs() { + return jobs.list() + } +} + +export default JobService diff --git a/packages/server/src/modules/Upload/controller/index.ts b/packages/server/src/modules/Upload/controller/index.ts new file mode 100644 index 0000000..b51a312 --- /dev/null +++ b/packages/server/src/modules/Upload/controller/index.ts @@ -0,0 +1,207 @@ +// @ts-nocheck +import Router from "@/utils/Router" +import formidable from "formidable" +import fs from "fs/promises" +import path from "path" +import { fileURLToPath } from "url" +import { logger } from "@/logger.js" +import { R } from "@/utils/R" +import BaseController from "@/base/BaseController" +import { getPathByRoot } from "helper/path" + +/** + * 文件上传控制器 + * 负责处理通用文件上传功能 + */ +class UploadController extends BaseController { + + /** + * 创建文件上传相关路由 + * @returns {Router} 路由实例 + */ + static createRoutes() { + const controller = new this() + const router = new Router({ auth: "try" }) + + // 通用文件上传 + router.post("/upload", controller.handleRequest(controller.upload), { auth: "try" }) + + return router + } + + constructor() { + super() + // 初始化上传配置 + this.initConfig() + } + + /** + * 初始化上传配置 + */ + initConfig() { + // 默认支持的文件类型配置 + this.defaultTypeList = [ + { mime: "image/jpeg", ext: ".jpg" }, + { mime: "image/png", ext: ".png" }, + { mime: "image/webp", ext: ".webp" }, + { mime: "image/gif", ext: ".gif" }, + { mime: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ext: ".xlsx" }, // .xlsx + { mime: "application/vnd.ms-excel", ext: ".xls" }, // .xls + { mime: "application/msword", ext: ".doc" }, // .doc + { mime: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", ext: ".docx" }, // .docx + ] + + this.fallbackExt = ".bin" + this.maxFileSize = 10 * 1024 * 1024 // 10MB + } + + /** + * 获取允许的文件类型 + * @param {Object} ctx - Koa上下文 + * @returns {Array} 允许的文件类型列表 + */ + getAllowedTypes(ctx) { + let typeList = this.defaultTypeList + + // 支持通过ctx.query.allowedTypes自定义类型(逗号分隔,自动过滤无效类型) + if (ctx.query.allowedTypes) { + const allowed = ctx.query.allowedTypes + .split(",") + .map(t => t.trim()) + .filter(Boolean) + typeList = this.defaultTypeList.filter(item => allowed.includes(item.mime)) + } + + return typeList + } + + /** + * 获取上传目录路径 + * @returns {string} 上传目录路径 + */ + getUploadDir() { + const __dirname = path.dirname(fileURLToPath(import.meta.url)) + const publicDir = getPathByRoot("public") + return path.resolve(publicDir, "uploads/files") + } + + /** + * 确保上传目录存在 + * @param {string} dir - 目录路径 + */ + async ensureUploadDir(dir) { + await fs.mkdir(dir, { recursive: true }) + } + + /** + * 生成安全的文件名 + * @param {Object} ctx - Koa上下文 + * @param {string} ext - 文件扩展名 + * @returns {string} 生成的文件名 + */ + generateFileName(ctx, ext) { + // return `${ctx.session.user.id}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}` + return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}` + } + + /** + * 获取文件扩展名 + * @param {Object} file - 文件对象 + * @param {Array} typeList - 类型列表 + * @returns {string} 文件扩展名 + */ + getFileExtension(file, typeList) { + // 优先用mimetype判断扩展名 + let ext = (typeList.find(item => item.mime === file.mimetype) || {}).ext + if (!ext) { + // 回退到原始文件名的扩展名 + ext = path.extname(file.originalFilename || file.newFilename || "") || this.fallbackExt + } + return ext + } + + /** + * 处理单个文件上传 + * @param {Object} file - 文件对象 + * @param {Object} ctx - Koa上下文 + * @param {string} uploadsDir - 上传目录 + * @param {Array} typeList - 类型列表 + * @returns {string} 文件URL + */ + async processFile(file, ctx, uploadsDir, typeList) { + if (!file) return null + + const oldPath = file.filepath || file.path + const ext = this.getFileExtension(file, typeList) + const filename = this.generateFileName(ctx, ext) + const destPath = path.join(uploadsDir, filename) + + // 移动文件到目标位置 + if (oldPath && oldPath !== destPath) { + await fs.rename(oldPath, destPath) + } + + // 返回相对于public的URL路径 + return `/public/uploads/files/${filename}` + } + + // 支持多文件上传,支持图片、Excel、Word文档类型,可通过配置指定允许的类型和扩展名(只需配置一个数组) + async upload(ctx) { + try { + const uploadsDir = this.getUploadDir() + await this.ensureUploadDir(uploadsDir) + + const typeList = this.getAllowedTypes(ctx) + const allowedTypes = typeList.map(item => item.mime) + + const form = formidable({ + multiples: true, // 支持多文件 + maxFileSize: this.maxFileSize, + filter: ({ mimetype }) => { + return !!mimetype && allowedTypes.includes(mimetype) + }, + uploadDir: uploadsDir, + keepExtensions: true, + }) + + const { files } = await new Promise((resolve, reject) => { + form.parse(ctx.req, (err, fields, files) => { + if (err) return reject(err) + resolve({ fields, files }) + }) + }) + + let fileList = files.file + if (!fileList) { + return R.response(R.ERROR, null, "未选择文件或字段名应为 file") + } + + // 统一为数组 + if (!Array.isArray(fileList)) { + fileList = [fileList] + } + + // 处理所有文件 + const urls = [] + for (const file of fileList) { + const url = await this.processFile(file, ctx, uploadsDir, typeList) + if (url) { + urls.push(url) + } + } + + ctx.body = { + success: true, + message: "上传成功", + urls, + } + } catch (error) { + logger.error(`上传失败: ${error.message}`) + ctx.status = 500 + ctx.body = { success: false, message: error.message || "上传失败" } + } + } + +} + +export default UploadController \ No newline at end of file diff --git a/packages/server/src/utils/EnvValidator.ts b/packages/server/src/utils/EnvValidator.ts new file mode 100644 index 0000000..76360cd --- /dev/null +++ b/packages/server/src/utils/EnvValidator.ts @@ -0,0 +1,165 @@ +import { logger } from "@/logger.js" + +/** + * 环境变量验证配置 + * required: 必需的环境变量 + * optional: 可选的环境变量(提供默认值) + */ +const ENV_CONFIG = { + required: [ + "SESSION_SECRET", + "JWT_SECRET" + ], + optional: { + "NODE_ENV": "development", + "PORT": "3000", + "LOG_DIR": "logs", + "HTTPS_ENABLE": "off" + } +} + +/** + * 验证必需的环境变量 + * @returns {Object} 验证结果 + */ +function validateRequiredEnv() { + const missing = [] + const valid: Record = {} + + for (const key of ENV_CONFIG.required) { + const value = process.env[key] + if (!value || value.trim() === '') { + missing.push(key) + } else { + valid[key] = value + } + } + + return { missing, valid } +} + +/** + * 设置可选环境变量的默认值 + * @returns {Object} 设置的默认值 + */ +function setOptionalDefaults() { + const defaults: Record = {} + + for (const [key, defaultValue] of Object.entries(ENV_CONFIG.optional)) { + if (!process.env[key]) { + process.env[key] = defaultValue + defaults[key] = defaultValue + } + } + + return defaults +} + +/** + * 验证环境变量的格式和有效性 + * @param {Object} env 环境变量对象 + * @returns {Array} 错误列表 + */ +function validateEnvFormat(env: NodeJS.ProcessEnv) { + const errors = [] + + // 验证 PORT 是数字 + if (env.PORT && isNaN(parseInt(env.PORT))) { + errors.push("PORT must be a valid number") + } + + // 验证 NODE_ENV 的值 + const validNodeEnvs = ['development', 'production', 'test'] + if (env.NODE_ENV && !validNodeEnvs.includes(env.NODE_ENV)) { + errors.push(`NODE_ENV must be one of: ${validNodeEnvs.join(', ')}`) + } + + // 验证 SESSION_SECRET 至少包含一个密钥 + if (env.SESSION_SECRET) { + const secrets = env.SESSION_SECRET.split(',').filter(s => s.trim()) + if (secrets.length === 0) { + errors.push("SESSION_SECRET must contain at least one non-empty secret") + } + } + + // 验证 JWT_SECRET 长度 + if (env.JWT_SECRET && env.JWT_SECRET.length < 32) { + errors.push("JWT_SECRET must be at least 32 characters long for security") + } + + return errors +} + +/** + * 初始化和验证所有环境变量 + * @returns {boolean} 验证是否成功 + */ +export function validateEnvironment() { + logger.info("🔍 开始验证环境变量...") + + // 1. 验证必需的环境变量 + const { missing, valid } = validateRequiredEnv() + + if (missing.length > 0) { + logger.error("❌ 缺少必需的环境变量:") + missing.forEach(key => { + logger.error(` - ${key}`) + }) + logger.error("请设置这些环境变量后重新启动应用") + return false + } + + // 2. 设置可选环境变量的默认值 + const defaults = setOptionalDefaults() + if (Object.keys(defaults).length > 0) { + logger.info("⚙️ 设置默认环境变量:") + Object.entries(defaults).forEach(([key, value]) => { + logger.info(` - ${key}=${value}`) + }) + } + + // 3. 验证环境变量格式 + const formatErrors = validateEnvFormat(process.env) + if (formatErrors.length > 0) { + logger.error("❌ 环境变量格式错误:") + formatErrors.forEach(error => { + logger.error(` - ${error}`) + }) + return false + } + + // 4. 记录有效的环境变量(敏感信息脱敏) + logger.info("✅ 环境变量验证成功:") + logger.info(` - NODE_ENV=${process.env.NODE_ENV}`) + logger.info(` - PORT=${process.env.PORT}`) + logger.info(` - LOG_DIR=${process.env.LOG_DIR}`) + logger.info(` - SESSION_SECRET=${maskSecret(process.env.SESSION_SECRET)}`) + logger.info(` - JWT_SECRET=${maskSecret(process.env.JWT_SECRET)}`) + + return true +} + +/** + * 脱敏显示敏感信息 + * @param {string} secret 敏感字符串 + * @returns {string} 脱敏后的字符串 + */ +export function maskSecret(secret: string) { + if (!secret) return "未设置" + if (secret.length <= 8) return "*".repeat(secret.length) + return secret.substring(0, 4) + "*".repeat(secret.length - 8) + secret.substring(secret.length - 4) +} + +/** + * 获取环境变量配置(用于生成 .env.example) + * @returns {Object} 环境变量配置 + */ +export function getEnvConfig() { + return ENV_CONFIG +} + +export default { + validateEnvironment, + getEnvConfig, + maskSecret +} \ No newline at end of file diff --git a/packages/server/src/utils/R.ts b/packages/server/src/utils/R.ts new file mode 100644 index 0000000..f5f2b36 --- /dev/null +++ b/packages/server/src/utils/R.ts @@ -0,0 +1,35 @@ +import { app } from "@/app" + +function success(data = null, message = null) { + const ctx = app.currentContext! + ctx.status = 200 + ctx.set("Content-Type", "application/json") + return ctx.body = { success: true, error: message, data } +} + +function error(data = null, message = null) { + const ctx = app.currentContext! + ctx.status = 500 + ctx.set("Content-Type", "application/json") + return ctx.body = { success: false, error: message, data } +} + +function response(statusCode: number, data: T | null = null, message: string | null = null) { + const ctx = app.currentContext! + ctx.status = statusCode + ctx.set("Content-Type", "application/json") + return ctx.body = { success: true, error: message, data } +} + +const R = { + success, + error, + response, + + SUCCESS: 200, + ERROR: 500, + NOTFOUND: 404, +} + +export { R } +export default R diff --git a/packages/server/src/utils/Router.ts b/packages/server/src/utils/Router.ts new file mode 100644 index 0000000..a442cf4 --- /dev/null +++ b/packages/server/src/utils/Router.ts @@ -0,0 +1,141 @@ +import { match } from 'path-to-regexp'; +import compose from 'koa-compose'; +import { Next, ParameterizedContext } from 'koa'; + +class Router { + + routes: any + middlewares: any + + /** + * 初始化路由实例 + * @param {Object} options - 路由配置 + * @param {string} options.prefix - 全局路由前缀 + * @param {Object} options.auth - 全局默认auth配置(可选,优先级低于路由级) + */ + constructor(options = {}) { + this.routes = { get: [], post: [], put: [], delete: [] }; + this.middlewares = []; + this.options = Object.assign({}, this.options, options); + } + + options = { + prefix: '', + auth: true, + } + + /** + * 注册中间件 + * @param {Function} middleware - 中间件函数 + */ + use(middleware: Function) { + this.middlewares.push(middleware); + } + + /** + * 注册GET路由,支持中间件链 + * @param {string} path - 路由路径 + * @param {Function} handler - 中间件和处理函数 + * @param {Object} others - 其他参数(可选) + */ + get(path: string, handler: Function, others: Object = {}) { + this._registerRoute("get", path, handler, others) + } + + /** + * 注册POST路由,支持中间件链 + * @param {string} path - 路由路径 + * @param {Function} handler - 中间件和处理函数 + * @param {Object} others - 其他参数(可选) + */ + post(path: string, handler: Function, others: Object = {}) { + this._registerRoute("post", path, handler, others) + } + + /** + * 注册PUT路由,支持中间件链 + */ + put(path: string, handler: Function, others: Object = {}) { + this._registerRoute("put", path, handler, others) + } + + /** + * 注册DELETE路由,支持中间件链 + */ + delete(path: string, handler: Function, others: Object = {}) { + this._registerRoute("delete", path, handler, others) + } + + /** + * 创建路由组 + * @param {string} prefix - 组内路由前缀 + * @param {Function} callback - 组路由注册回调 + */ + group(prefix: string, callback: Function) { + const groupRouter = new Router({ prefix: this.options.prefix + prefix }) + callback(groupRouter); + // 合并组路由到当前路由 + Object.keys(groupRouter.routes).forEach(method => { + this.routes[method].push(...groupRouter.routes[method]); + }); + this.middlewares.push(...groupRouter.middlewares); + } + + /** + * 生成Koa中间件 + * @returns {Function} Koa中间件函数 + */ + middleware(beforeMiddleware?: Function) { + return async (ctx: ParameterizedContext, next: Next) => { + const { method, path } = ctx; + // 直接进行路由匹配(不使用缓存) + const route = this._matchRoute(method.toLowerCase(), path); + + // 组合全局中间件、路由专属中间件和 handler + const middlewares = [...this.middlewares]; + if (route) { + // 如果匹配到路由,添加路由专属中间件和处理函数 + ctx.params = route.params; + + if (beforeMiddleware) { + const options = Object.assign({}, this.options, route.meta); + middlewares.push(await beforeMiddleware(options)); + } + middlewares.push(route.handler); + const composed = compose(middlewares); + await composed(ctx, next); + } else { + // 如果没有匹配到路由,直接调用 next + await next(); + } + }; + } + + /** + * 内部路由注册方法,支持中间件链 + * @private + */ + _registerRoute(method: string, path: string, handler: Function, others: Object) { + const fullPath = this.options.prefix + path + const keys: string[] = []; + const matcher = match(fullPath, { decode: decodeURIComponent }); + this.routes[method].push({ path: fullPath, matcher, keys, handler, meta: others }) + } + + /** + * 匹配路由 + * @private + */ + _matchRoute(method: string, currentPath: string) { + const routes = this.routes[method] || []; + for (const route of routes) { + const matchResult = route.matcher(currentPath); + if (matchResult) { + return { ...route, params: matchResult.params }; + } + } + return null; + } +} + +export default Router; \ No newline at end of file diff --git a/packages/server/src/utils/error/ApiError.ts b/packages/server/src/utils/error/ApiError.ts new file mode 100644 index 0000000..471af4e --- /dev/null +++ b/packages/server/src/utils/error/ApiError.ts @@ -0,0 +1,24 @@ +import app from "@/app" +import BaseError from "./BaseError" +import { DefaultContext } from "koa" + +export default class ApiError extends BaseError { + ctx?: DefaultContext + user?: any = null + info?: null | any = null + + constructor(message: string, status = ApiError.ERR_CODE.BAD_REQUEST) { + super(message, status) + this.name = "ApiError" + const ctx = app.currentContext + this.ctx = ctx + this.user = ctx?.state?.user || null + this.info = { + path: ctx?.path || "", + method: ctx?.method || "", + query: ctx?.query || {}, + body: ctx?.request?.body || {}, + params: ctx?.params || {}, + } + } +} diff --git a/packages/server/src/utils/error/AuthError.ts b/packages/server/src/utils/error/AuthError.ts new file mode 100644 index 0000000..4b4b22b --- /dev/null +++ b/packages/server/src/utils/error/AuthError.ts @@ -0,0 +1,13 @@ +import app from "@/app" +import BaseError from "./BaseError" +import { DefaultContext } from "koa" + +export default class AuthError extends BaseError { + ctx?: DefaultContext + + constructor(message: string, status = AuthError.ERR_CODE.UNAUTHORIZED) { + super(message, status) + this.name = "AuthError" + this.ctx = app.currentContext + } +} diff --git a/packages/server/src/utils/error/BaseError.ts b/packages/server/src/utils/error/BaseError.ts new file mode 100644 index 0000000..982c57f --- /dev/null +++ b/packages/server/src/utils/error/BaseError.ts @@ -0,0 +1,17 @@ + +export class BaseError extends Error { + statusCode: number + + static ERR_CODE = { + NOT_FOUND: 404, + UNAUTHORIZED: 401, + FORBIDDEN: 403, + BAD_REQUEST: 400, + INTERNAL_SERVER_ERROR: 500, + } + constructor(message: string, code: number) { + super(message) + this.statusCode = code + } +} +export default BaseError \ No newline at end of file diff --git a/packages/server/src/utils/error/CommonError.ts b/packages/server/src/utils/error/CommonError.ts new file mode 100644 index 0000000..30d7011 --- /dev/null +++ b/packages/server/src/utils/error/CommonError.ts @@ -0,0 +1,24 @@ +import app from "@/app" +import BaseError from "./BaseError.js" +import { DefaultContext } from "koa" + +export default class CommonError extends BaseError { + ctx?: DefaultContext + user?: any = null + info?: null | any = null + + constructor(message: string, status = CommonError.ERR_CODE.BAD_REQUEST) { + super(message, status) + this.name = "CommonError" + const ctx = app.currentContext + this.ctx = ctx + this.user = ctx?.state?.user || null + this.info = { + path: ctx?.path || "", + method: ctx?.method || "", + query: ctx?.query || {}, + body: ctx?.request?.body || {}, + params: ctx?.params || {}, + } + } +} diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json index 90b3e96..cf9a923 100644 --- a/packages/server/tsconfig.json +++ b/packages/server/tsconfig.json @@ -23,7 +23,12 @@ "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, - "noUncheckedSideEffectImports": true + "noUncheckedSideEffectImports": true, + + "baseUrl": ".", + "paths": { + "@/*": ["src/*"], + }, }, "include": [ "src/**/*.ts" diff --git a/scripts/fix-type-router.js b/scripts/fix-type-router.js new file mode 100644 index 0000000..9cf6e91 --- /dev/null +++ b/scripts/fix-type-router.js @@ -0,0 +1,18 @@ +import fs from "fs"; +import path from "path"; + +const dtsPath = path.resolve( + import.meta.dirname, + "../node_modules/vue-router/dist/vue-router.d.ts" +); + +const content = fs.readFileSync(dtsPath, "utf8"); + +const fixedContent = content.replace( + /declare module ['"]vue['"]/g, + "declare module '@vue/runtime-core'" +); + +fs.writeFileSync(dtsPath, fixedContent, "utf8"); + +console.log("Fixed vue-router.d.ts module declaration.");