Files
dmt-modelLibrary/server/src/folders.ts
2026-05-25 20:35:07 +08:00

232 lines
8.3 KiB
TypeScript

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 };
});
}