import fs from "node:fs/promises"; import path from "node:path"; import { FastifyInstance } from "fastify"; import { z } from "zod"; import { db } from "./db.js"; import { requireAuth } from "./auth.js"; import { folderPermissions, getFolder, requireFolderWrite } from "./permissions.js"; import { storage } from "./storage.js"; import { FolderRow } from "./types.js"; import { assertValidFolderName, ensureDir, removeIfExists, safeStoragePath, toFolderStorageRelative } from "./utils.js"; const createSchema = z.object({ parentId: z.number().int().nullable().optional(), name: z.string() }); const updateSchema = z.object({ name: z.string() }); function folderPayload(request: { userInfo: { id: number; username: string; role: "admin" | "user" } }, row: FolderRow) { return { ...row, permissions: folderPermissions(request.userInfo, row) }; } function buildFolderNodes(rows: FolderRow[], userInfo: { id: number; username: string; role: "admin" | "user" }) { return rows.map((row) => ({ id: String(row.id), text: `${displayFolderName(row)}(${row.model_count ?? 0})`, parentId: row.parent_id, path: row.path, data: { permissions: folderPermissions(userInfo, row), libraryType: row.library_type, ownerUserId: row.owner_user_id, isSystem: Boolean(row.is_system) }, state: { opened: row.library_type === "system" && row.parent_id === null }, children: [] as unknown[] })); } function buildTree(rows: FolderRow[], userInfo: { id: number; username: string; role: "admin" | "user" }) { const nodes = buildFolderNodes(rows, userInfo); const userGroupNode = { id: "virtual:user-libraries", text: "用户模型库", parentId: null as number | null, path: "users", data: { permissions: { read: true, write: false }, virtual: true }, state: { opened: true }, children: [] as unknown[] }; const byId = new Map(nodes.map((node) => [Number(node.id), node])); const roots = []; for (const node of nodes) { if (node.parentId && byId.has(node.parentId)) { byId.get(node.parentId)!.children.push(node); } else if (node.data.libraryType === "user") { userGroupNode.children.push(node); } else { roots.push(node); } } if (userGroupNode.children.length > 0) { roots.push(userGroupNode); } return roots; } function childPath(parent: FolderRow | undefined, name: string) { return parent ? path.posix.join(parent.path, name) : name; } function assertCanRenameRoot(folder: FolderRow) { if (folder.is_system) { throw new Error("系统保留目录不能重命名"); } } function displayFolderName(row: FolderRow) { if (row.library_type === "user" && row.is_system && row.owner_username) { return row.owner_username; } return row.name; } function ensureUserLibrary(user: { id: number; username: string }) { const pathValue = `users/${user.id}`; const existing = db.prepare("SELECT id FROM folders WHERE path = ?").get(pathValue) as { id: number } | undefined; if (existing) return existing.id; const result = db.prepare(` INSERT INTO folders (parent_id, name, path, library_type, owner_user_id, is_system) VALUES (NULL, ?, ?, 'user', ?, 1) `).run("用户模型库", pathValue, user.id); return Number(result.lastInsertRowid); } export async function folderRoutes(app: FastifyInstance) { app.get("/api/folders", { preHandler: [requireAuth] }, async (request) => { ensureUserLibrary(request.userInfo); const rows = db.prepare(` SELECT f.*, u.username AS owner_username, COUNT(m.id) AS model_count FROM folders f LEFT JOIN users u ON u.id = f.owner_user_id LEFT JOIN models m ON m.folder_id = f.id GROUP BY f.id ORDER BY CASE f.library_type WHEN 'system' THEN 0 ELSE 1 END, f.owner_user_id, f.parent_id, f.name `).all() as unknown as FolderRow[]; return { folders: rows.map((row) => folderPayload(request, row)), tree: buildTree(rows, request.userInfo) }; }); app.post("/api/folders", { preHandler: [requireAuth] }, async (request, reply) => { const body = createSchema.parse(request.body); const name = assertValidFolderName(body.name); const parent = body.parentId ? getFolder(body.parentId) : undefined; if (body.parentId && !parent) { return reply.code(404).send({ message: "父目录不存在" }); } if (!parent) { return reply.code(400).send({ message: "请选择父目录" }); } if (!requireFolderWrite(request, reply, parent)) return; const folderPath = childPath(parent, name); try { const result = db.prepare(` INSERT INTO folders (parent_id, name, path, library_type, owner_user_id, is_system) VALUES (?, ?, ?, ?, ?, 0) `).run(parent.id, name, folderPath, parent.library_type, parent.owner_user_id); await ensureDir(safeStoragePath(toFolderStorageRelative(folderPath))); return reply.code(201).send({ id: result.lastInsertRowid, name, path: folderPath }); } catch { return reply.code(409).send({ message: "同级目录已存在" }); } }); app.put("/api/folders/:id", { preHandler: [requireAuth] }, async (request, reply) => { const params = z.object({ id: z.coerce.number().int() }).parse(request.params); const body = updateSchema.parse(request.body); const name = assertValidFolderName(body.name); const folder = getFolder(params.id); if (!folder) { return reply.code(404).send({ message: "目录不存在" }); } if (!requireFolderWrite(request, reply, folder)) return; assertCanRenameRoot(folder); const parent = folder.parent_id ? getFolder(folder.parent_id) : undefined; const oldPath = folder.path; const newPath = childPath(parent, name); if (storage.provider !== "local") { const modelCount = db.prepare("SELECT COUNT(*) AS count FROM models WHERE file_path LIKE ?") .get(`models/${oldPath}/%`) as { count: number }; if (modelCount.count > 0) { return reply.code(400).send({ message: "当前存储模式下,包含模型的目录不能重命名" }); } } const tx = () => { db.exec("BEGIN"); try { db.prepare("UPDATE folders SET name = ?, path = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?") .run(name, newPath, folder.id); const descendants = db.prepare("SELECT * FROM folders WHERE path LIKE ? ORDER BY LENGTH(path)") .all(`${oldPath}/%`) as unknown as FolderRow[]; for (const item of descendants) { const replacedPath = item.path.replace(oldPath + "/", newPath + "/"); db.prepare("UPDATE folders SET path = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?") .run(replacedPath, item.id); } if (storage.provider === "local") { const models = db.prepare("SELECT id, file_path FROM models WHERE file_path LIKE ?") .all(`models/${oldPath}/%`) as { id: number; file_path: string }[]; for (const model of models) { db.prepare("UPDATE models SET file_path = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?") .run(model.file_path.replace(`models/${oldPath}/`, `models/${newPath}/`), model.id); } } db.exec("COMMIT"); } catch (error) { db.exec("ROLLBACK"); throw error; } }; try { tx(); if (storage.provider === "local") { await fs.rename( safeStoragePath(toFolderStorageRelative(oldPath)), safeStoragePath(toFolderStorageRelative(newPath)) ); } return { ok: true }; } catch { return reply.code(409).send({ message: "目录重命名失败,可能存在重名目录" }); } }); app.delete("/api/folders/:id", { preHandler: [requireAuth] }, async (request, reply) => { const params = z.object({ id: z.coerce.number().int() }).parse(request.params); const folder = getFolder(params.id); if (!folder) { return reply.code(404).send({ message: "目录不存在" }); } if (!requireFolderWrite(request, reply, folder)) return; if (folder.is_system) { return reply.code(400).send({ message: "系统保留目录不能删除" }); } db.prepare("DELETE FROM folders WHERE id = ?").run(folder.id); await removeIfExists(safeStoragePath(toFolderStorageRelative(folder.path))); return { ok: true }; }); }