initial commit
This commit is contained in:
154
server/src/folders.ts
Normal file
154
server/src/folders.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
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 { requireAdmin, requireAuth } from "./auth.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 buildTree(rows: FolderRow[]) {
|
||||
const nodes = rows.map((row) => ({
|
||||
id: String(row.id),
|
||||
text: row.name,
|
||||
parentId: row.parent_id,
|
||||
path: row.path,
|
||||
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 {
|
||||
roots.push(node);
|
||||
}
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
function getFolder(id: number) {
|
||||
return db.prepare("SELECT * FROM folders WHERE id = ?").get(id) as FolderRow | undefined;
|
||||
}
|
||||
|
||||
function childPath(parent: FolderRow | undefined, name: string) {
|
||||
return parent ? path.posix.join(parent.path, name) : name;
|
||||
}
|
||||
|
||||
function assertCanRenameRoot(folder: FolderRow) {
|
||||
if (folder.parent_id === null) {
|
||||
throw new Error("根目录不能重命名");
|
||||
}
|
||||
}
|
||||
|
||||
export async function folderRoutes(app: FastifyInstance) {
|
||||
app.get("/api/folders", { preHandler: [requireAuth] }, async () => {
|
||||
const rows = db.prepare("SELECT * FROM folders ORDER BY parent_id, name").all() as unknown as FolderRow[];
|
||||
return { folders: rows, tree: buildTree(rows) };
|
||||
});
|
||||
|
||||
app.post("/api/folders", { preHandler: [requireAdmin] }, 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: "父目录不存在" });
|
||||
}
|
||||
const folderPath = childPath(parent, name);
|
||||
try {
|
||||
const result = db.prepare("INSERT INTO folders (parent_id, name, path) VALUES (?, ?, ?)")
|
||||
.run(body.parentId ?? null, name, folderPath);
|
||||
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: [requireAdmin] }, 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: "目录不存在" });
|
||||
}
|
||||
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: [requireAdmin] }, 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 (folder.parent_id === null) {
|
||||
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 };
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user