Compare commits
12 Commits
Author | SHA1 | Date |
---|---|---|
|
9d1dfa3715 | 2 months ago |
|
e7425ec594 | 2 months ago |
|
fddb11d84f | 2 months ago |
|
07dc21c1f7 | 2 months ago |
|
9611e33b82 | 2 months ago |
|
f7dc33873d | 2 months ago |
|
8aaf9b5cd4 | 2 months ago |
|
1d142c3900 | 2 months ago |
|
c073c46410 | 2 months ago |
|
838dbbd406 | 2 months ago |
|
7d395f02bf | 2 months ago |
|
d2e8df87f3 | 2 months ago |
55 changed files with 2961 additions and 66 deletions
@ -0,0 +1,2 @@ |
|||
# Don't index SpecStory auto-save files, but allow explicit context inclusion via @ references |
|||
.specstory/** |
@ -0,0 +1,65 @@ |
|||
|
|||
# SpecStory Artifacts Directory |
|||
|
|||
This directory is automatically created and maintained by the SpecStory extension to preserve your Cursor composer and chat history. |
|||
|
|||
## What's Here? |
|||
|
|||
- `.specstory/history`: Contains markdown files of your AI coding sessions |
|||
- Each file represents a separate chat or composer session |
|||
- Files are automatically updated as you work |
|||
- `.specstory/cursor_rules_backups`: Contains backups of the `.cursor/rules/derived-cursor-rules.mdc` file |
|||
- Backups are automatically created each time the `.cursor/rules/derived-cursor-rules.mdc` file is updated |
|||
- You can enable/disable the Cursor Rules feature in the SpecStory settings, it is disabled by default |
|||
|
|||
## Valuable Uses |
|||
|
|||
- Capture: Keep your context window up-to-date when starting new Chat/Composer sessions via @ references |
|||
- Search: For previous prompts and code snippets |
|||
- Learn: Meta-analyze your patterns and learn from your past experiences |
|||
- Derive: Keep Cursor on course with your past decisions by automatically deriving Cursor rules from your AI interactions |
|||
|
|||
## Version Control |
|||
|
|||
We recommend keeping this directory under version control to maintain a history of your AI interactions. However, if you prefer not to version these files, you can exclude them by adding this to your `.gitignore`: |
|||
|
|||
``` |
|||
.specstory |
|||
``` |
|||
|
|||
We recommend not keeping the `.specstory/cursor_rules_backups` directory under version control if you are already using git to version the `.cursor/rules` directory, and committing regularly. You can exclude it by adding this to your `.gitignore`: |
|||
|
|||
``` |
|||
.specstory/cursor_rules_backups |
|||
``` |
|||
|
|||
## Searching Your Codebase |
|||
|
|||
When searching your codebase in Cursor, search results may include your previous AI coding interactions. To focus solely on your actual code files, you can exclude the AI interaction history from search results. |
|||
|
|||
To exclude AI interaction history: |
|||
|
|||
1. Open the "Find in Files" search in Cursor (Cmd/Ctrl + Shift + F) |
|||
2. Navigate to the "files to exclude" section |
|||
3. Add the following pattern: |
|||
|
|||
``` |
|||
.specstory/* |
|||
``` |
|||
|
|||
This will ensure your searches only return results from your working codebase files. |
|||
|
|||
## Notes |
|||
|
|||
- Auto-save only works when Cursor/sqlite flushes data to disk. This results in a small delay after the AI response is complete before SpecStory can save the history. |
|||
- Auto-save does not yet work on remote WSL workspaces. |
|||
|
|||
## Settings |
|||
|
|||
You can control auto-saving behavior in Cursor: |
|||
|
|||
1. Open Cursor → Settings → VS Code Settings (Cmd/Ctrl + ,) |
|||
2. Search for "SpecStory" |
|||
3. Find "Auto Save" setting to enable/disable |
|||
|
|||
Auto-save occurs when changes are detected in Cursor's sqlite database, or every 2 minutes as a safety net. |
File diff suppressed because it is too large
@ -0,0 +1,29 @@ |
|||
# 使用官方 Bun 运行时的轻量级镜像 |
|||
FROM oven/bun:alpine AS base |
|||
|
|||
WORKDIR /app |
|||
|
|||
# 仅复制生产依赖相关文件 |
|||
COPY package.json bun.lockb knexfile.mjs .npmrc ./ |
|||
|
|||
# 安装依赖(生产环境) |
|||
RUN bun install --production |
|||
|
|||
# 复制应用代码和静态资源 |
|||
COPY src ./src |
|||
COPY public ./public |
|||
|
|||
COPY entrypoint.sh ./entrypoint.sh |
|||
RUN chmod +x ./entrypoint.sh |
|||
|
|||
# 如需数据库文件(如 SQLite),可挂载到宿主机 |
|||
VOLUME /app/database |
|||
|
|||
# 启动命令(如有端口需求可暴露端口) |
|||
EXPOSE 3000 |
|||
|
|||
# 健康检查:每30秒检查一次服务端口,3次失败则容器为unhealthy |
|||
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ |
|||
CMD wget --spider -q http://localhost:3000/ || exit 1 |
|||
|
|||
ENTRYPOINT ["./entrypoint.sh"] |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -0,0 +1,18 @@ |
|||
#!/bin/sh |
|||
set -e |
|||
|
|||
# 数据库文件路径(可根据实际环境调整) |
|||
DB_FILE=./database/db.sqlite3 |
|||
|
|||
# 如果数据库文件不存在,先 migrate 再 seed |
|||
if [ ! -f "$DB_FILE" ]; then |
|||
echo "Database not found, running migration and seed..." |
|||
bun run npx knex migrate:latest |
|||
bun run npx knex seed:run |
|||
else |
|||
echo "Database exists, running migration only..." |
|||
bun run npx knex migrate:latest |
|||
fi |
|||
|
|||
# 启动主服务 |
|||
exec bun src/main.js |
@ -0,0 +1,26 @@ |
|||
{ |
|||
"compilerOptions": { |
|||
"baseUrl": ".", |
|||
"paths": { |
|||
"@/*": [ |
|||
"src/*" |
|||
], |
|||
"db/*": [ |
|||
"src/db/*" |
|||
], |
|||
"utils/*": [ |
|||
"src/utils/*" |
|||
], |
|||
"services/*": [ |
|||
"src/services/*" |
|||
] |
|||
}, |
|||
"module": "commonjs", |
|||
"target": "es6", |
|||
"allowSyntheticDefaultImports": true |
|||
}, |
|||
"include": [ |
|||
"src/**/*", |
|||
"jsconfig.json" |
|||
] |
|||
} |
@ -0,0 +1 @@ |
|||
asd |
@ -0,0 +1,19 @@ |
|||
html, |
|||
body { |
|||
margin: 0; |
|||
padding: 0; |
|||
height: 100%; |
|||
} |
|||
|
|||
.navbar { |
|||
height: 49px; |
|||
display: flex; |
|||
align-items: center; |
|||
box-shadow: 1px 1px 3px #e4e4e4; |
|||
} |
|||
|
|||
.title{ |
|||
font-size: 1.5em; |
|||
margin-left: 10px; |
|||
color: #333; |
|||
} |
@ -0,0 +1,56 @@ |
|||
// Job Controller 示例:如何调用 service 层动态控制和查询定时任务
|
|||
import JobService from "services/JobService.js" |
|||
import { formatResponse } from "utils/helper.js" |
|||
|
|||
const jobService = new JobService() |
|||
|
|||
export const list = async (ctx) => { |
|||
try { |
|||
const data = jobService.listJobs() |
|||
ctx.body = formatResponse(true, data) |
|||
} catch (err) { |
|||
ctx.body = formatResponse(false, null, err.message || "获取任务列表失败") |
|||
} |
|||
} |
|||
|
|||
export const start = async (ctx) => { |
|||
const { id } = ctx.params |
|||
try { |
|||
jobService.startJob(id) |
|||
ctx.body = formatResponse(true, null, null, `${id} 任务已启动`) |
|||
} catch (err) { |
|||
ctx.body = formatResponse(false, null, err.message || "启动任务失败") |
|||
} |
|||
} |
|||
|
|||
export const stop = async (ctx) => { |
|||
const { id } = ctx.params |
|||
try { |
|||
jobService.stopJob(id) |
|||
ctx.body = formatResponse(true, null, null, `${id} 任务已停止`) |
|||
} catch (err) { |
|||
ctx.body = formatResponse(false, null, err.message || "停止任务失败") |
|||
} |
|||
} |
|||
|
|||
export const updateCron = async (ctx) => { |
|||
const { id } = ctx.params |
|||
const { cronTime } = ctx.request.body |
|||
try { |
|||
jobService.updateJobCron(id, cronTime) |
|||
ctx.body = formatResponse(true, null, null, `${id} 任务频率已修改`) |
|||
} catch (err) { |
|||
ctx.body = formatResponse(false, null, err.message || "修改任务频率失败") |
|||
} |
|||
} |
|||
|
|||
// 路由注册示例
|
|||
import Router from "utils/router.js" |
|||
export function createRoutes() { |
|||
const router = new Router({ prefix: "/api/jobs", auth: true }) |
|||
router.get("/", list) |
|||
router.post("/start/:id", start) |
|||
router.post("/stop/:id", stop) |
|||
router.post("/update/:id", updateCron) |
|||
return router |
|||
} |
@ -0,0 +1,15 @@ |
|||
export const Index = async ctx => { |
|||
return await ctx.render("index", { name: "bluescurry" }) |
|||
} |
|||
|
|||
export const Page = (name, data) => async ctx => { |
|||
return await ctx.render(name, data) |
|||
} |
|||
|
|||
import Router from "utils/router" |
|||
export function createRoutes() { |
|||
const router = new Router({ auth: "try" }) |
|||
// 如有页面路由可继续添加
|
|||
// router.get("/htmx", Index)
|
|||
return router |
|||
} |
@ -0,0 +1,14 @@ |
|||
export const Index = async ctx => { |
|||
return await ctx.render("page/index/index", { title: "沧源一场" }) |
|||
} |
|||
|
|||
export const Page = (name, data) => async ctx => { |
|||
return await ctx.render(name, data) |
|||
} |
|||
|
|||
import Router from "utils/router" |
|||
export function createRoutes() { |
|||
const router = new Router({ auth: "try" }) |
|||
router.get("/", Index) |
|||
return router |
|||
} |
@ -0,0 +1,16 @@ |
|||
import { formatResponse } from "utils/helper.js" |
|||
|
|||
export const status = async (ctx) => { |
|||
ctx.body = "OK" |
|||
} |
|||
|
|||
import Router from "utils/router.js" |
|||
export function createRoutes() { |
|||
const v1 = new Router({ prefix: "/api/v1" }) |
|||
v1.use((ctx, next) => { |
|||
ctx.set("X-API-Version", "v1") |
|||
return next() |
|||
}) |
|||
v1.get("/status", { auth: "try" }, status) |
|||
return v1 |
|||
} |
@ -0,0 +1,48 @@ |
|||
import UserService from "services/UserService.js" |
|||
import { formatResponse } from "utils/helper.js" |
|||
|
|||
const userService = new UserService() |
|||
|
|||
export const hello = async (ctx) => { |
|||
ctx.body = formatResponse(true, "Hello World") |
|||
} |
|||
|
|||
export const getUser = async (ctx) => { |
|||
const user = await userService.getUserById(ctx.params.id) |
|||
ctx.body = formatResponse(true, user) |
|||
} |
|||
|
|||
export const register = async (ctx) => { |
|||
try { |
|||
const { username, email, password } = ctx.request.body |
|||
const user = await userService.register({ username, email, password }) |
|||
ctx.body = formatResponse(true, user) |
|||
} catch (err) { |
|||
ctx.body = formatResponse(false, null, err.message) |
|||
} |
|||
} |
|||
|
|||
export const login = async (ctx) => { |
|||
try { |
|||
const { username, email, password } = ctx.request.body |
|||
const result = await userService.login({ username, email, password }) |
|||
if (result && result.user) { |
|||
// 登录成功后写入 session,不再设置 jwt 到 cookie
|
|||
ctx.session.user = result.user |
|||
} |
|||
ctx.body = formatResponse(true, result) |
|||
} catch (err) { |
|||
ctx.body = formatResponse(false, null, err.message) |
|||
} |
|||
} |
|||
|
|||
// 路由注册示例
|
|||
import Router from "utils/router.js" |
|||
export function createRoutes() { |
|||
const router = new Router({ prefix: "/api", auth: false }) |
|||
router.get("/hello", hello) |
|||
router.get("/user/:id", { auth: true }, getUser) |
|||
router.post("/register", register) |
|||
router.post("/login", login) |
|||
return router |
|||
} |
@ -1,23 +0,0 @@ |
|||
import db from "../db/index.js" |
|||
|
|||
// 创建用户
|
|||
export async function createUser(userData) { |
|||
const [id] = await db("users").insert(userData) |
|||
return id |
|||
} |
|||
|
|||
// 查询所有用户
|
|||
export async function getUsers() { |
|||
return db("users").select("*") |
|||
} |
|||
|
|||
// 更新用户
|
|||
export async function updateUser(id, updates) { |
|||
updates.updated_at = new Date() |
|||
return db("users").where("id", id).update(updates) |
|||
} |
|||
|
|||
// 删除用户
|
|||
export async function deleteUser(id) { |
|||
return db("users").where("id", id).del() |
|||
} |
@ -0,0 +1,11 @@ |
|||
import { jobLogger } from "@/logger"; |
|||
|
|||
export default { |
|||
id: 'example', |
|||
cronTime: '*/10 * * * * *', // 每10秒执行一次
|
|||
task: () => { |
|||
jobLogger.info('Example Job 执行了'); |
|||
}, |
|||
options: {}, |
|||
autoStart: false |
|||
}; |
@ -0,0 +1,48 @@ |
|||
import fs from 'fs'; |
|||
import path from 'path'; |
|||
import scheduler from 'utils/scheduler.js'; |
|||
|
|||
const jobsDir = __dirname; |
|||
const jobModules = {}; |
|||
|
|||
fs.readdirSync(jobsDir).forEach(file => { |
|||
if (file === 'index.js' || !file.endsWith('Job.js')) 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, hookName) { |
|||
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 => { |
|||
callHook(id, 'beforeStart'); |
|||
scheduler.start(id); |
|||
}, |
|||
stop: id => { |
|||
scheduler.stop(id); |
|||
callHook(id, 'afterStop'); |
|||
}, |
|||
updateCronTime: (id, cronTime) => scheduler.updateCronTime(id, cronTime), |
|||
list: () => scheduler.list(), |
|||
reload: id => { |
|||
const job = jobModules[id]; |
|||
if (job) { |
|||
scheduler.remove(id); |
|||
scheduler.add(job.id, job.cronTime, job.task, job.options); |
|||
} |
|||
} |
|||
}; |
@ -0,0 +1,71 @@ |
|||
import { logger } from "@/logger" |
|||
import jwt from "./jwt" |
|||
import { minimatch } from "minimatch" |
|||
|
|||
export const JWT_SECRET = process.env.JWT_SECRET || "jwt-demo-secret" |
|||
|
|||
function matchList(list, path) { |
|||
for (const item of list) { |
|||
if (typeof item === "string" && minimatch(path, item)) { |
|||
return { matched: true, auth: false } |
|||
} |
|||
if (typeof item === "object" && minimatch(path, item.pattern)) { |
|||
return { matched: true, auth: item.auth } |
|||
} |
|||
} |
|||
return { matched: false } |
|||
} |
|||
|
|||
function verifyToken(ctx) { |
|||
let token = ctx.headers["authorization"]?.replace(/^Bearer\s/, "") |
|||
if (!token) { |
|||
return { ok: false, status: -1 } |
|||
} |
|||
try { |
|||
ctx.state.user = jwt.verify(token, JWT_SECRET) |
|||
return { ok: true } |
|||
} catch { |
|||
ctx.state.user = undefined |
|||
return { ok: false } |
|||
} |
|||
} |
|||
|
|||
export default function authMiddleware( |
|||
options = { |
|||
whiteList: [], |
|||
blackList: [], |
|||
} |
|||
) { |
|||
return async (ctx, next) => { |
|||
// 黑名单优先生效
|
|||
if (matchList(options.blackList, ctx.path).matched) { |
|||
ctx.status = 403 |
|||
ctx.body = { success: false, error: "禁止访问" } |
|||
return |
|||
} |
|||
// 白名单处理
|
|||
const white = matchList(options.whiteList, ctx.path) |
|||
if (white.matched) { |
|||
if (white.auth === false) { |
|||
return await next() |
|||
} |
|||
if (white.auth === "try") { |
|||
verifyToken(ctx) |
|||
return await next() |
|||
} |
|||
// true 或其他情况,必须有token
|
|||
if (!verifyToken(ctx).ok) { |
|||
ctx.status = 401 |
|||
ctx.body = { success: false, error: "未登录或token缺失或无效" } |
|||
return |
|||
} |
|||
return await next() |
|||
} |
|||
// 非白名单,必须有token
|
|||
if (!verifyToken(ctx).ok) { |
|||
ctx.status = 401 |
|||
ctx.body = { success: false, error: "未登录或token缺失或无效" } |
|||
} |
|||
await next() |
|||
} |
|||
} |
@ -0,0 +1,3 @@ |
|||
// 统一导出所有中间件
|
|||
import auth from "./auth.js" |
|||
export { auth } |
@ -0,0 +1,3 @@ |
|||
// 兼容性导出,便于后续扩展
|
|||
import jwt from "jsonwebtoken" |
|||
export default jwt |
@ -0,0 +1,63 @@ |
|||
import { siteLogger, logger } from "@/logger" |
|||
|
|||
// 静态资源扩展名列表
|
|||
const staticExts = [".css", ".js", ".png", ".jpg", ".jpeg", ".gif", ".ico", ".svg", ".map", ".woff", ".woff2", ".ttf", ".eot"] |
|||
|
|||
function isStaticResource(path) { |
|||
return staticExts.some(ext => path.endsWith(ext)) |
|||
} |
|||
|
|||
/** |
|||
* 响应时间记录中间件 |
|||
* @param {Object} ctx - Koa上下文对象 |
|||
* @param {Function} next - Koa中间件链函数 |
|||
*/ |
|||
export default async (ctx, 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) { |
|||
siteLogger.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") |
|||
} |
|||
} |
@ -0,0 +1,185 @@ |
|||
/** |
|||
* 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<boolean>} 文件是否存在 |
|||
*/ |
|||
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; |
@ -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, relativePath) { |
|||
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; |
@ -0,0 +1,57 @@ |
|||
import { resolve } from "path" |
|||
import consolidate from "consolidate" |
|||
import send from "../Send" |
|||
import getPaths from "get-paths" |
|||
// import pretty from "pretty"
|
|||
import { logger } from "@/logger" |
|||
|
|||
export default viewsMiddleware |
|||
|
|||
function viewsMiddleware(path, { engineSource = consolidate, extension = "html", options = {}, map } = {}) { |
|||
return function views(ctx, next) { |
|||
if (ctx.render) return next() |
|||
|
|||
// 将 render 注入到 context 和 response 对象中
|
|||
ctx.response.render = ctx.render = function (relPath, locals = {}) { |
|||
return getPaths(path, relPath, extension).then(paths => { |
|||
const suffix = paths.ext |
|||
const state = Object.assign(locals, options, ctx.state || {}) |
|||
// deep copy partials
|
|||
state.partials = Object.assign({}, options.partials || {}) |
|||
// logger.debug("render `%s` with %j", paths.rel, state)
|
|||
ctx.type = "text/html" |
|||
|
|||
// 如果是 html 文件,不编译直接 send 静态文件
|
|||
if (isHtml(suffix) && !map) { |
|||
return send(ctx, paths.rel, { |
|||
root: path, |
|||
}) |
|||
} else { |
|||
const engineName = map && map[suffix] ? map[suffix] : suffix |
|||
|
|||
// 使用 engineSource 配置的渲染引擎 render
|
|||
const render = engineSource[engineName] |
|||
|
|||
if (!engineName || !render) return Promise.reject(new Error(`Engine not found for the ".${suffix}" file extension`)) |
|||
|
|||
return render(resolve(path, paths.rel), state).then(html => { |
|||
// since pug has deprecated `pretty` option
|
|||
// we'll use the `pretty` package in the meanwhile
|
|||
// if (locals.pretty) {
|
|||
// debug("using `pretty` package to beautify HTML")
|
|||
// html = pretty(html)
|
|||
// }
|
|||
ctx.body = html |
|||
}) |
|||
} |
|||
}) |
|||
} |
|||
|
|||
// 中间件执行结束
|
|||
return next() |
|||
} |
|||
} |
|||
|
|||
function isHtml(ext) { |
|||
return ext === "html" |
|||
} |
@ -0,0 +1,54 @@ |
|||
// src/plugins/errorHandler.js
|
|||
// 错误处理中间件插件
|
|||
|
|||
function formatError(ctx, status, message, stack) { |
|||
const accept = ctx.accepts('json', 'html', 'text'); |
|||
const isDev = process.env.NODE_ENV === 'development'; |
|||
if (accept === 'json') { |
|||
ctx.type = 'application/json'; |
|||
ctx.body = isDev && stack |
|||
? { success: false, error: message, stack } |
|||
: { success: false, error: message }; |
|||
} else if (accept === 'html') { |
|||
ctx.type = 'html'; |
|||
ctx.body = ` |
|||
<html> |
|||
<head><title>${status} Error</title></head> |
|||
<body> |
|||
<h1>${status} Error</h1> |
|||
<p>${message}</p> |
|||
${isDev && stack ? `<pre style='color:red;'>${stack}</pre>` : ''} |
|||
</body> |
|||
</html> |
|||
`;
|
|||
} else { |
|||
ctx.type = 'text'; |
|||
ctx.body = isDev && stack |
|||
? `${status} - ${message}\n${stack}` |
|||
: `${status} - ${message}`; |
|||
} |
|||
ctx.status = status; |
|||
} |
|||
|
|||
export default function errorHandler() { |
|||
return async (ctx, next) => { |
|||
// 拦截 Chrome DevTools 探测请求,直接返回 204
|
|||
if (ctx.path === '/.well-known/appspecific/com.chrome.devtools.json') { |
|||
ctx.status = 204; |
|||
ctx.body = ''; |
|||
return; |
|||
} |
|||
try { |
|||
await next(); |
|||
if (ctx.status === 404) { |
|||
formatError(ctx, 404, 'Resource not found'); |
|||
} |
|||
} catch (err) { |
|||
const isDev = process.env.NODE_ENV === 'development'; |
|||
if (isDev && err.stack) { |
|||
console.error(err.stack); |
|||
} |
|||
formatError(ctx, err.statusCode || 500, err.message || err || 'Internal server error', isDev ? err.stack : undefined); |
|||
} |
|||
}; |
|||
} |
@ -0,0 +1,51 @@ |
|||
import ResponseTime from "./ResponseTime" |
|||
import Send from "./Send" |
|||
import { resolve } from "path" |
|||
import { fileURLToPath } from "url" |
|||
import path from "path" |
|||
import ErrorHandler from "./ErrorHandler" |
|||
import { auth } from "./Auth" |
|||
import bodyParser from "koa-bodyparser" |
|||
import Views from "./Views" |
|||
import { autoRegisterControllers } from "utils/autoRegister.js" |
|||
|
|||
const __dirname = path.dirname(fileURLToPath(import.meta.url)) |
|||
const publicPath = resolve(__dirname, "../../public") |
|||
|
|||
export default app => { |
|||
app.use(ErrorHandler()) |
|||
app.use(ResponseTime) |
|||
app.use( |
|||
auth({ |
|||
whiteList: [ |
|||
// // API接口访问
|
|||
// "/api/login",
|
|||
// "/api/register",
|
|||
// { pattern: "/api/v1/status", auth: "try" },
|
|||
// { pattern: "/api/**/*", auth: true },
|
|||
// // 静态资源访问
|
|||
// { pattern: "/", auth: "try" },
|
|||
// { pattern: "/**/*", auth: "try" },
|
|||
], |
|||
blackList: [], |
|||
}) |
|||
) |
|||
app.use(bodyParser()) |
|||
app.use( |
|||
Views(resolve(__dirname, "../views"), { |
|||
extension: "pug", |
|||
options: { |
|||
basedir: resolve(__dirname, "../views"), |
|||
}, |
|||
}) |
|||
) |
|||
autoRegisterControllers(app) |
|||
app.use(async (ctx, next) => { |
|||
try { |
|||
await Send(ctx, ctx.path, { root: publicPath }) |
|||
} catch (err) { |
|||
if (err.status !== 404) throw err |
|||
} |
|||
await next() |
|||
}) |
|||
} |
@ -1,13 +0,0 @@ |
|||
import log4js from "log4js" |
|||
|
|||
const logger = log4js.getLogger() |
|||
|
|||
export default async (ctx, next) => { |
|||
logger.debug("::in:: %s %s", ctx.method, ctx.path) |
|||
const start = Date.now() |
|||
await next() |
|||
const ms = Date.now() - start |
|||
ctx.set("X-Response-Time", `${ms}ms`) |
|||
const rt = ctx.response.get("X-Response-Time") |
|||
logger.debug(`::out:: takes ${rt} for ${ctx.method} ${ctx.url}`) |
|||
} |
@ -1,5 +0,0 @@ |
|||
import ResponseTime from "./ResponseTime"; |
|||
|
|||
export default (app)=>{ |
|||
app.use(ResponseTime) |
|||
} |
@ -0,0 +1,18 @@ |
|||
import jobs from "../jobs" |
|||
|
|||
class JobService { |
|||
startJob(id) { |
|||
return jobs.start(id) |
|||
} |
|||
stopJob(id) { |
|||
return jobs.stop(id) |
|||
} |
|||
updateJobCron(id, cronTime) { |
|||
return jobs.updateCronTime(id, cronTime) |
|||
} |
|||
listJobs() { |
|||
return jobs.list() |
|||
} |
|||
} |
|||
|
|||
export default JobService |
@ -0,0 +1,74 @@ |
|||
import UserModel from "db/models/UserModel.js" |
|||
import { hashPassword, comparePassword } from "utils/bcrypt.js" |
|||
import { JWT_SECRET } from "@/middlewares/Auth/auth.js" |
|||
import jwt from "@/middlewares/Auth/jwt.js" |
|||
|
|||
class UserService { |
|||
async getUserById(id) { |
|||
// 这里可以调用数据库模型
|
|||
// 示例返回
|
|||
return { id, name: `User_${id}` } |
|||
} |
|||
|
|||
// 获取所有用户
|
|||
async getAllUsers() { |
|||
return await UserModel.findAll() |
|||
} |
|||
|
|||
// 创建新用户
|
|||
async createUser(data) { |
|||
if (!data.name) throw new Error("用户名不能为空") |
|||
return await UserModel.create(data) |
|||
} |
|||
|
|||
// 更新用户
|
|||
async updateUser(id, data) { |
|||
const user = await UserModel.findById(id) |
|||
if (!user) throw new Error("用户不存在") |
|||
return await UserModel.update(id, data) |
|||
} |
|||
|
|||
// 删除用户
|
|||
async deleteUser(id) { |
|||
const user = await UserModel.findById(id) |
|||
if (!user) throw new Error("用户不存在") |
|||
return await UserModel.delete(id) |
|||
} |
|||
|
|||
// 注册新用户
|
|||
async register(data) { |
|||
if (!data.username || !data.email || !data.password) throw new Error("用户名、邮箱和密码不能为空") |
|||
const existUser = await UserModel.findByUsername(data.username) |
|||
if (existUser) throw new Error("用户名已存在") |
|||
const existEmail = await UserModel.findByEmail(data.email) |
|||
if (existEmail) throw new Error("邮箱已被注册") |
|||
// 密码加密
|
|||
const hashed = await hashPassword(data.password) |
|||
|
|||
const user = await UserModel.create({ ...data, password: hashed }) |
|||
// 返回脱敏信息
|
|||
const { password, ...userInfo } = Array.isArray(user) ? user[0] : user |
|||
return userInfo |
|||
} |
|||
|
|||
// 登录
|
|||
async login({ username, email, password }) { |
|||
let user |
|||
if (username) { |
|||
user = await UserModel.findByUsername(username) |
|||
} else if (email) { |
|||
user = await UserModel.findByEmail(email) |
|||
} |
|||
if (!user) throw new Error("用户不存在") |
|||
// 校验密码
|
|||
const ok = await comparePassword(password, user.password) |
|||
if (!ok) throw new Error("密码错误") |
|||
// 生成token
|
|||
const token = jwt.sign({ id: user.id, username: user.username }, JWT_SECRET, { expiresIn: "2h" }) |
|||
// 返回token和用户信息
|
|||
const { password: pwd, ...userInfo } = user |
|||
return { token, user: userInfo } |
|||
} |
|||
} |
|||
|
|||
export default UserService |
@ -0,0 +1,37 @@ |
|||
// 抽象基类,使用泛型来正确推导子类类型
|
|||
class BaseSingleton { |
|||
static _instance |
|||
|
|||
constructor() { |
|||
if (this.constructor === BaseSingleton) { |
|||
throw new Error("禁止直接实例化 BaseOne 抽象类") |
|||
} |
|||
|
|||
if (this.constructor._instance) { |
|||
throw new Error("构造函数私有化失败,禁止重复 new") |
|||
} |
|||
|
|||
// this.constructor 是子类,所以这里设为 instance
|
|||
this.constructor._instance = this |
|||
} |
|||
|
|||
static getInstance() { |
|||
const clazz = this |
|||
if (!clazz._instance) { |
|||
const self = new this() |
|||
const handler = { |
|||
get: function (target, prop) { |
|||
const value = Reflect.get(target, prop) |
|||
if (typeof value === "function") { |
|||
return value.bind(target) |
|||
} |
|||
return Reflect.get(target, prop) |
|||
}, |
|||
} |
|||
clazz._instance = new Proxy(self, handler) |
|||
} |
|||
return clazz._instance |
|||
} |
|||
} |
|||
|
|||
export { BaseSingleton } |
@ -0,0 +1,44 @@ |
|||
// 自动扫描 controllers 目录并注册路由
|
|||
// 兼容传统 routes 方式和自动注册 controller 方式
|
|||
import fs from "fs" |
|||
import path from "path" |
|||
|
|||
/** |
|||
* 自动扫描 controllers 目录,注册所有导出的路由 |
|||
* 自动检测 routes 目录下已手动注册的 controller,避免重复注册 |
|||
* @param {Koa} app - Koa 实例 |
|||
* @param {string} controllersDir - controllers 目录路径 |
|||
* @param {string} prefix - 路由前缀 |
|||
* @param {Set<string>} [manualControllers] - 可选,手动传入已注册 controller 文件名集合,优先于自动扫描 |
|||
*/ |
|||
export function autoRegisterControllers(app, controllersDir = path.resolve(__dirname, "../controllers")) { |
|||
let allRouter = [] |
|||
async function scan(dir, routePrefix = "") { |
|||
for (const file of fs.readdirSync(dir)) { |
|||
const fullPath = path.join(dir, file) |
|||
const stat = fs.statSync(fullPath) |
|||
if (stat.isDirectory()) { |
|||
await scan(fullPath, routePrefix + "/" + file) |
|||
} else if (file.endsWith("Controller.js")) { |
|||
let controller |
|||
try { |
|||
controller = require(fullPath) |
|||
} catch (e) { |
|||
controller = (await import(fullPath)).default |
|||
} |
|||
const routes = controller.createRoutes || controller.default?.createRoutes || controller.default || controller |
|||
// 判断 routes 方法参数个数,支持自动适配
|
|||
if (typeof routes === "function") { |
|||
allRouter.push(routes()) |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
;(async () => { |
|||
await scan(controllersDir) |
|||
allRouter.forEach(router => { |
|||
app.use(router.middleware()) |
|||
}) |
|||
})() |
|||
} |
@ -0,0 +1,11 @@ |
|||
// 密码加密与校验工具
|
|||
import bcrypt from "bcryptjs" |
|||
|
|||
export async function hashPassword(password) { |
|||
const salt = await bcrypt.genSalt(10) |
|||
return bcrypt.hash(password, salt) |
|||
} |
|||
|
|||
export async function comparePassword(password, hash) { |
|||
return bcrypt.compare(password, hash) |
|||
} |
@ -0,0 +1,4 @@ |
|||
|
|||
export function formatResponse(success, data = null, error = null) { |
|||
return { success, error, data } |
|||
} |
@ -0,0 +1,134 @@ |
|||
import { match } from 'path-to-regexp'; |
|||
import compose from 'koa-compose'; |
|||
import RouteAuth from './router/RouteAuth.js'; |
|||
|
|||
class Router { |
|||
/** |
|||
* 初始化路由实例 |
|||
* @param {Object} options - 路由配置 |
|||
* @param {string} options.prefix - 全局路由前缀 |
|||
* @param {Object} options.auth - 全局默认auth配置(可选,优先级低于路由级) |
|||
*/ |
|||
constructor(options = {}) { |
|||
this.prefix = options.prefix || ''; |
|||
this.routes = { get: [], post: [], put: [], delete: [] }; |
|||
this.middlewares = []; |
|||
this.defaultAuth = options.auth !== undefined ? options.auth : true; |
|||
} |
|||
|
|||
/** |
|||
* 注册中间件 |
|||
* @param {Function} middleware - 中间件函数 |
|||
*/ |
|||
use(middleware) { |
|||
this.middlewares.push(middleware); |
|||
} |
|||
|
|||
/** |
|||
* 注册GET路由,支持中间件链 |
|||
* @param {string} path - 路由路径 |
|||
* @param {...Function|Object} handlers - 中间件和处理函数,支持最后一个参数为auth配置对象 |
|||
*/ |
|||
get(path, ...handlers) { |
|||
this._registerRoute('get', path, handlers); |
|||
} |
|||
|
|||
/** |
|||
* 注册POST路由,支持中间件链 |
|||
* @param {string} path - 路由路径 |
|||
* @param {...Function|Object} handlers - 中间件和处理函数,支持最后一个参数为auth配置对象 |
|||
*/ |
|||
post(path, ...handlers) { |
|||
this._registerRoute('post', path, handlers); |
|||
} |
|||
|
|||
/** |
|||
* 注册PUT路由,支持中间件链 |
|||
*/ |
|||
put(path, ...handlers) { |
|||
this._registerRoute('put', path, handlers); |
|||
} |
|||
|
|||
/** |
|||
* 注册DELETE路由,支持中间件链 |
|||
*/ |
|||
delete(path, ...handlers) { |
|||
this._registerRoute('delete', path, handlers); |
|||
} |
|||
|
|||
/** |
|||
* 创建路由组 |
|||
* @param {string} prefix - 组内路由前缀 |
|||
* @param {Function} callback - 组路由注册回调 |
|||
*/ |
|||
group(prefix, callback) { |
|||
const groupRouter = new Router({ prefix: this.prefix + prefix, auth: this.defaultAuth }); |
|||
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() { |
|||
return async (ctx, next) => { |
|||
const { method, path } = ctx; |
|||
const route = this._matchRoute(method.toLowerCase(), path); |
|||
|
|||
// 组合全局中间件、默认RouteAuth、路由专属中间件和 handler
|
|||
const middlewares = [...this.middlewares]; |
|||
if (route) { |
|||
ctx.params = route.params; |
|||
// 路由级auth优先,其次Router构造参数auth,最后默认true
|
|||
let routeAuthConfig = this.defaultAuth; |
|||
if (route.handlers.length && typeof route.handlers[0] === 'object' && route.handlers[0].hasOwnProperty('auth')) { |
|||
routeAuthConfig = { ...route.handlers[0] }; |
|||
route.handlers = route.handlers.slice(1); |
|||
} else if (typeof routeAuthConfig !== 'object') { |
|||
routeAuthConfig = { auth: routeAuthConfig }; |
|||
} |
|||
middlewares.push(RouteAuth(routeAuthConfig)); |
|||
middlewares.push(...route.handlers); |
|||
} else { |
|||
// 未命中路由也加默认RouteAuth
|
|||
let defaultAuthConfig = typeof this.defaultAuth === 'object' ? this.defaultAuth : { auth: this.defaultAuth }; |
|||
middlewares.push(RouteAuth(defaultAuthConfig)); |
|||
} |
|||
const composed = compose(middlewares); |
|||
await composed(ctx, next); |
|||
}; |
|||
} |
|||
|
|||
/** |
|||
* 内部路由注册方法,支持中间件链 |
|||
* @private |
|||
*/ |
|||
_registerRoute(method, path, handlers) { |
|||
const fullPath = this.prefix + path; |
|||
const keys = []; |
|||
const matcher = match(fullPath, { decode: decodeURIComponent }); |
|||
this.routes[method].push({ path: fullPath, matcher, keys, handlers }); |
|||
} |
|||
|
|||
/** |
|||
* 匹配路由 |
|||
* @private |
|||
*/ |
|||
_matchRoute(method, currentPath) { |
|||
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; |
@ -0,0 +1,50 @@ |
|||
import jwt from "./Auth/jwt.js" |
|||
import { JWT_SECRET } from "@/middlewares/Auth/auth.js" |
|||
|
|||
/** |
|||
* 路由级权限中间件 |
|||
* 支持:auth: false/try/true/roles |
|||
* 用法:router.get('/api/user', RouteAuth({ auth: true }), handler) |
|||
*/ |
|||
export default function RouteAuth(options = {}) { |
|||
const { auth = true, roles } = options; |
|||
return async (ctx, next) => { |
|||
if (auth === false) return next(); |
|||
|
|||
// 统一用户解析逻辑
|
|||
if (!ctx.state.user) { |
|||
const token = getToken(ctx); |
|||
if (token) { |
|||
try { |
|||
ctx.state.user = jwt.verify(token, JWT_SECRET); |
|||
} catch {} |
|||
} |
|||
} |
|||
|
|||
if (auth === "try") { |
|||
return next(); |
|||
} |
|||
|
|||
if (auth === true) { |
|||
if (!ctx.state.user) { |
|||
ctx.status = 401; |
|||
ctx.body = { success: false, error: "未登录或Token无效" }; |
|||
return; |
|||
} |
|||
if (roles && !roles.includes(ctx.state.user.role)) { |
|||
ctx.status = 403; |
|||
ctx.body = { success: false, error: "无权限" }; |
|||
return; |
|||
} |
|||
return next(); |
|||
} |
|||
|
|||
// 其他自定义模式
|
|||
return next(); |
|||
}; |
|||
} |
|||
|
|||
function getToken(ctx) { |
|||
// 只支持 Authorization: Bearer xxx
|
|||
return ctx.headers["authorization"]?.replace(/^Bearer\s/i, ""); |
|||
} |
@ -0,0 +1,60 @@ |
|||
import cron from 'node-cron'; |
|||
|
|||
class Scheduler { |
|||
constructor() { |
|||
this.jobs = new Map(); |
|||
} |
|||
|
|||
add(id, cronTime, task, options = {}) { |
|||
if (this.jobs.has(id)) this.remove(id); |
|||
const job = cron.createTask(cronTime, task, { ...options, noOverlap: true }); |
|||
this.jobs.set(id, { job, cronTime, task, options, status: 'stopped' }); |
|||
} |
|||
|
|||
execute(id) { |
|||
const entry = this.jobs.get(id); |
|||
if (entry && entry.status === 'running') { |
|||
entry.job.execute(); |
|||
} |
|||
} |
|||
|
|||
start(id) { |
|||
const entry = this.jobs.get(id); |
|||
if (entry && entry.status !== 'running') { |
|||
entry.job.start(); |
|||
entry.status = 'running'; |
|||
} |
|||
} |
|||
|
|||
stop(id) { |
|||
const entry = this.jobs.get(id); |
|||
if (entry && entry.status === 'running') { |
|||
entry.job.stop(); |
|||
entry.status = 'stopped'; |
|||
} |
|||
} |
|||
|
|||
remove(id) { |
|||
const entry = this.jobs.get(id); |
|||
if (entry) { |
|||
entry.job.destroy(); |
|||
this.jobs.delete(id); |
|||
} |
|||
} |
|||
|
|||
updateCronTime(id, newCronTime) { |
|||
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(); |
@ -0,0 +1,4 @@ |
|||
if title |
|||
h1 <a href="/page/htmx">#{title}</a> |
|||
else |
|||
h1 默认标题 |
@ -0,0 +1,13 @@ |
|||
if edit |
|||
.row.justify-content-center.mt-5 |
|||
.col-md-6 |
|||
form#loginForm(method="post" action="/api/login" hx-post="/api/login" hx-trigger="submit" hx-target="body" hx-swap="none" hx-on:htmx:afterRequest="if(event.detail.xhr.status===200){window.location='/';}") |
|||
.mb-3 |
|||
label.form-label(for="username") 用户名 |
|||
input.form-control(type="text" id="username" name="username" required) |
|||
.mb-3 |
|||
label.form-label(for="password") 密码 |
|||
input.form-control(type="password" id="password" name="password" required) |
|||
button.btn.btn-primary(type="submit") 登录 |
|||
else |
|||
div sad 404 |
@ -0,0 +1,2 @@ |
|||
nav.navbar |
|||
.title 首页 |
@ -0,0 +1,15 @@ |
|||
mixin include() |
|||
if block |
|||
block |
|||
|
|||
doctype html |
|||
html(lang="zh-CN") |
|||
head |
|||
block head |
|||
title #{title || ''} |
|||
meta(charset="utf-8") |
|||
meta(name="viewport" content="width=device-width, initial-scale=1") |
|||
script(src="https://unpkg.com/htmx.org@2.0.4") |
|||
body |
|||
block content |
|||
block scripts |
@ -0,0 +1,14 @@ |
|||
|
|||
extends /layouts/base.pug |
|||
|
|||
block head |
|||
link(rel='stylesheet', href='styles.css') |
|||
block pageHead |
|||
|
|||
block content |
|||
+include() |
|||
include /htmx/navbar.pug |
|||
block pageContent |
|||
|
|||
block scripts |
|||
block pageScripts |
@ -0,0 +1,8 @@ |
|||
extends /layouts/page.pug |
|||
|
|||
//- +include() |
|||
//- - var edit = false |
|||
//- include /htmx/login.pug |
|||
|
|||
block pageContent |
|||
div adsd |
Loading…
Reference in new issue