initial commit
This commit is contained in:
29
server/package.json
Normal file
29
server/package.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "dmt-model-library-server",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^10.0.1",
|
||||
"@fastify/jwt": "^9.1.0",
|
||||
"@fastify/multipart": "^9.0.3",
|
||||
"@fastify/static": "^8.0.3",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cos-nodejs-sdk-v5": "^2.15.4",
|
||||
"dotenv": "^17.4.2",
|
||||
"fastify": "^5.2.1",
|
||||
"nanoid": "^5.0.9",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/node": "^24.0.0",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
227
server/src/auth.ts
Normal file
227
server/src/auth.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
import bcrypt from "bcryptjs";
|
||||
import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { db } from "./db.js";
|
||||
import { AuthUser, UserRow } from "./types.js";
|
||||
|
||||
declare module "fastify" {
|
||||
interface FastifyRequest {
|
||||
userInfo: AuthUser;
|
||||
}
|
||||
}
|
||||
|
||||
const registerSchema = z.object({
|
||||
username: z.string().min(3).max(32),
|
||||
password: z.string().min(6).max(128),
|
||||
captcha: z.string().optional()
|
||||
});
|
||||
|
||||
const loginSchema = registerSchema.pick({
|
||||
username: true,
|
||||
password: true
|
||||
}).extend({
|
||||
captcha: z.string().optional()
|
||||
});
|
||||
|
||||
const userCreateSchema = z.object({
|
||||
username: z.string().min(3).max(32),
|
||||
password: z.string().min(6).max(128),
|
||||
role: z.enum(["admin", "user"]).default("user"),
|
||||
enabled: z.boolean().default(true),
|
||||
expiresAt: z.string().nullable().optional()
|
||||
});
|
||||
|
||||
const userUpdateSchema = z.object({
|
||||
role: z.enum(["admin", "user"]),
|
||||
enabled: z.boolean(),
|
||||
expiresAt: z.string().nullable().optional(),
|
||||
password: z.string().min(6).max(128).optional()
|
||||
});
|
||||
|
||||
function publicUser(row: UserRow) {
|
||||
return {
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
role: row.role,
|
||||
enabled: Boolean(row.enabled),
|
||||
expires_at: row.expires_at,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeExpiresAt(value: string | null | undefined) {
|
||||
const clean = value?.trim();
|
||||
if (!clean) return null;
|
||||
const date = new Date(clean);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
throw new Error("授权到期时间格式不正确");
|
||||
}
|
||||
return clean.length === 10 ? `${clean}T23:59:59` : clean;
|
||||
}
|
||||
|
||||
export async function authRoutes(app: FastifyInstance) {
|
||||
app.post("/api/auth/register", async (request, reply) => {
|
||||
const body = registerSchema.parse(request.body);
|
||||
const passwordHash = await bcrypt.hash(body.password, 10);
|
||||
try {
|
||||
db.prepare("INSERT INTO users (username, password_hash, role, enabled) VALUES (?, ?, 'user', 1)")
|
||||
.run(body.username, passwordHash);
|
||||
return reply.code(201).send({ ok: true });
|
||||
} catch {
|
||||
return reply.code(409).send({ message: "用户名已存在" });
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/auth/login", async (request, reply) => {
|
||||
const body = loginSchema.parse(request.body);
|
||||
const user = db.prepare(`
|
||||
SELECT id, username, password_hash, role, enabled, expires_at
|
||||
FROM users
|
||||
WHERE username = ?
|
||||
`).get(body.username) as {
|
||||
id: number;
|
||||
username: string;
|
||||
password_hash: string;
|
||||
role: "admin" | "user";
|
||||
enabled: number;
|
||||
expires_at: string | null;
|
||||
} | undefined;
|
||||
|
||||
if (!user || !(await bcrypt.compare(body.password, user.password_hash))) {
|
||||
return reply.code(401).send({ message: "用户名或密码错误" });
|
||||
}
|
||||
if (!user.enabled) {
|
||||
return reply.code(403).send({ message: "账号已禁用" });
|
||||
}
|
||||
if (user.expires_at && new Date(user.expires_at).getTime() < Date.now()) {
|
||||
return reply.code(403).send({ message: "账号授权已过期" });
|
||||
}
|
||||
|
||||
const token = app.jwt.sign({ id: user.id, username: user.username, role: user.role });
|
||||
return { token, user: { id: user.id, username: user.username, role: user.role } };
|
||||
});
|
||||
|
||||
app.get("/api/auth/me", { preHandler: [requireAuth] }, async (request) => {
|
||||
return { user: request.userInfo };
|
||||
});
|
||||
|
||||
app.get("/api/users", { preHandler: [requireAdmin] }, async () => {
|
||||
const rows = db.prepare(`
|
||||
SELECT id, username, password_hash, role, enabled, expires_at, created_at, updated_at
|
||||
FROM users
|
||||
ORDER BY id ASC
|
||||
`).all() as unknown as UserRow[];
|
||||
return { users: rows.map(publicUser) };
|
||||
});
|
||||
|
||||
app.post("/api/users", { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const body = userCreateSchema.parse(request.body);
|
||||
const passwordHash = await bcrypt.hash(body.password, 10);
|
||||
try {
|
||||
const result = db.prepare(`
|
||||
INSERT INTO users (username, password_hash, role, enabled, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(body.username.trim(), passwordHash, body.role, body.enabled ? 1 : 0, normalizeExpiresAt(body.expiresAt));
|
||||
const row = db.prepare(`
|
||||
SELECT id, username, password_hash, role, enabled, expires_at, created_at, updated_at
|
||||
FROM users
|
||||
WHERE id = ?
|
||||
`).get(Number(result.lastInsertRowid)) as unknown as UserRow;
|
||||
return reply.code(201).send({ user: publicUser(row) });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes("授权到期时间")) {
|
||||
return reply.code(400).send({ message: error.message });
|
||||
}
|
||||
return reply.code(409).send({ message: "用户名已存在" });
|
||||
}
|
||||
});
|
||||
|
||||
app.put("/api/users/:id", { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const params = z.object({ id: z.coerce.number().int() }).parse(request.params);
|
||||
const body = userUpdateSchema.parse(request.body);
|
||||
const user = db.prepare("SELECT * FROM users WHERE id = ?").get(params.id) as UserRow | undefined;
|
||||
if (!user) {
|
||||
return reply.code(404).send({ message: "用户不存在" });
|
||||
}
|
||||
if (user.id === request.userInfo.id && (!body.enabled || body.role !== "admin")) {
|
||||
return reply.code(400).send({ message: "不能取消当前登录管理员的权限" });
|
||||
}
|
||||
const adminCount = db.prepare("SELECT COUNT(*) AS count FROM users WHERE role = 'admin' AND enabled = 1")
|
||||
.get() as { count: number };
|
||||
if (user.role === "admin" && user.enabled && (body.role !== "admin" || !body.enabled) && adminCount.count <= 1) {
|
||||
return reply.code(400).send({ message: "至少保留一个启用的管理员" });
|
||||
}
|
||||
try {
|
||||
const expiresAt = normalizeExpiresAt(body.expiresAt);
|
||||
if (body.password) {
|
||||
const passwordHash = await bcrypt.hash(body.password, 10);
|
||||
db.prepare(`
|
||||
UPDATE users
|
||||
SET role = ?, enabled = ?, expires_at = ?, password_hash = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`).run(body.role, body.enabled ? 1 : 0, expiresAt, passwordHash, user.id);
|
||||
} else {
|
||||
db.prepare(`
|
||||
UPDATE users
|
||||
SET role = ?, enabled = ?, expires_at = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`).run(body.role, body.enabled ? 1 : 0, expiresAt, user.id);
|
||||
}
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "保存失败";
|
||||
return reply.code(400).send({ message });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete("/api/users/:id", { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const params = z.object({ id: z.coerce.number().int() }).parse(request.params);
|
||||
if (params.id === request.userInfo.id) {
|
||||
return reply.code(400).send({ message: "不能删除当前登录用户" });
|
||||
}
|
||||
const user = db.prepare("SELECT * FROM users WHERE id = ?").get(params.id) as UserRow | undefined;
|
||||
if (!user) {
|
||||
return reply.code(404).send({ message: "用户不存在" });
|
||||
}
|
||||
const adminCount = db.prepare("SELECT COUNT(*) AS count FROM users WHERE role = 'admin' AND enabled = 1")
|
||||
.get() as { count: number };
|
||||
if (user.role === "admin" && user.enabled && adminCount.count <= 1) {
|
||||
return reply.code(400).send({ message: "至少保留一个启用的管理员" });
|
||||
}
|
||||
db.prepare("DELETE FROM users WHERE id = ?").run(user.id);
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
|
||||
export async function requireAuth(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const payload = await request.jwtVerify<AuthUser>();
|
||||
const user = db.prepare("SELECT id, username, role, enabled, expires_at FROM users WHERE id = ?")
|
||||
.get(payload.id) as Pick<UserRow, "id" | "username" | "role" | "enabled" | "expires_at"> | undefined;
|
||||
if (!user) {
|
||||
return reply.code(401).send({ message: "请先登录" });
|
||||
}
|
||||
if (!user.enabled) {
|
||||
return reply.code(403).send({ message: "账号已禁用" });
|
||||
}
|
||||
if (user.expires_at && new Date(user.expires_at).getTime() < Date.now()) {
|
||||
return reply.code(403).send({ message: "账号授权已过期" });
|
||||
}
|
||||
request.userInfo = {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
role: user.role
|
||||
};
|
||||
} catch {
|
||||
return reply.code(401).send({ message: "请先登录" });
|
||||
}
|
||||
}
|
||||
|
||||
export async function requireAdmin(request: FastifyRequest, reply: FastifyReply) {
|
||||
await requireAuth(request, reply);
|
||||
if (reply.sent) return;
|
||||
if (request.userInfo.role !== "admin") {
|
||||
return reply.code(403).send({ message: "需要管理员权限" });
|
||||
}
|
||||
}
|
||||
28
server/src/config.ts
Normal file
28
server/src/config.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import dotenv from "dotenv";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const serverRoot = path.resolve(__dirname, "..");
|
||||
|
||||
dotenv.config({ path: path.resolve(serverRoot, "..", ".env") });
|
||||
dotenv.config({ path: path.resolve(serverRoot, ".env") });
|
||||
|
||||
export const config = {
|
||||
host: process.env.HOST ?? "0.0.0.0",
|
||||
port: Number(process.env.PORT ?? 3001),
|
||||
jwtSecret: process.env.JWT_SECRET ?? "dev-only-change-me",
|
||||
dataDir: path.resolve(serverRoot, "data"),
|
||||
storageDir: path.resolve(serverRoot, "storage"),
|
||||
databasePath: process.env.DB_PATH ?? path.resolve(serverRoot, "data", "model-library.db"),
|
||||
storageDriver: (process.env.STORAGE_DRIVER ?? "local").toLowerCase(),
|
||||
publicBaseUrl: process.env.PUBLIC_BASE_URL ?? "",
|
||||
cdnBaseUrl: process.env.CDN_BASE_URL ?? "",
|
||||
cos: {
|
||||
secretId: process.env.COS_SECRET_ID ?? "",
|
||||
secretKey: process.env.COS_SECRET_KEY ?? "",
|
||||
bucket: process.env.COS_BUCKET ?? "",
|
||||
region: process.env.COS_REGION ?? "",
|
||||
prefix: (process.env.COS_PREFIX ?? "").replace(/^\/+|\/+$/g, "")
|
||||
}
|
||||
};
|
||||
119
server/src/db.ts
Normal file
119
server/src/db.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import fs from "node:fs";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { config } from "./config.js";
|
||||
|
||||
fs.mkdirSync(config.dataDir, { recursive: true });
|
||||
fs.mkdirSync(config.storageDir, { recursive: true });
|
||||
|
||||
export const db = new DatabaseSync(config.databasePath);
|
||||
db.exec("PRAGMA journal_mode = WAL");
|
||||
db.exec("PRAGMA foreign_keys = ON");
|
||||
|
||||
function hasColumn(table: string, column: string) {
|
||||
const rows = db.prepare(`PRAGMA table_info(${table})`).all() as unknown as { name: string }[];
|
||||
return rows.some((row) => row.name === column);
|
||||
}
|
||||
|
||||
export function migrate() {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
expires_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS folders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
parent_id INTEGER REFERENCES folders(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
path TEXT NOT NULL UNIQUE,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(parent_id, name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS brands (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS model_types (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS models (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
folder_id INTEGER NOT NULL REFERENCES folders(id) ON DELETE CASCADE,
|
||||
brand_id INTEGER REFERENCES brands(id) ON DELETE SET NULL,
|
||||
type_id INTEGER REFERENCES model_types(id) ON DELETE SET NULL,
|
||||
name TEXT NOT NULL,
|
||||
original_filename TEXT NOT NULL,
|
||||
file_path TEXT NOT NULL UNIQUE,
|
||||
storage_provider TEXT NOT NULL DEFAULT 'local',
|
||||
file_size INTEGER NOT NULL,
|
||||
thumbnail TEXT,
|
||||
thumbnail_path TEXT,
|
||||
thumbnail_provider TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(folder_id, name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS model_properties (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
model_id INTEGER NOT NULL REFERENCES models(id) ON DELETE CASCADE,
|
||||
property_key TEXT NOT NULL,
|
||||
property_value TEXT NOT NULL DEFAULT '',
|
||||
UNIQUE(model_id, property_key)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_folders_parent_id ON folders(parent_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_models_folder_id ON models(folder_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_model_properties_model_id ON model_properties(model_id);
|
||||
`);
|
||||
|
||||
if (!hasColumn("models", "brand_id")) {
|
||||
db.exec("ALTER TABLE models ADD COLUMN brand_id INTEGER REFERENCES brands(id) ON DELETE SET NULL");
|
||||
}
|
||||
if (!hasColumn("models", "type_id")) {
|
||||
db.exec("ALTER TABLE models ADD COLUMN type_id INTEGER REFERENCES model_types(id) ON DELETE SET NULL");
|
||||
}
|
||||
if (!hasColumn("models", "storage_provider")) {
|
||||
db.exec("ALTER TABLE models ADD COLUMN storage_provider TEXT NOT NULL DEFAULT 'local'");
|
||||
}
|
||||
if (!hasColumn("models", "thumbnail_path")) {
|
||||
db.exec("ALTER TABLE models ADD COLUMN thumbnail_path TEXT");
|
||||
}
|
||||
if (!hasColumn("models", "thumbnail_provider")) {
|
||||
db.exec("ALTER TABLE models ADD COLUMN thumbnail_provider TEXT");
|
||||
}
|
||||
|
||||
db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_models_brand_id ON models(brand_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_models_type_id ON models(type_id);
|
||||
`);
|
||||
|
||||
const rootFolder = db.prepare("SELECT id FROM folders WHERE parent_id IS NULL AND name = ?").get("模型库");
|
||||
if (!rootFolder) {
|
||||
db.prepare("INSERT INTO folders (parent_id, name, path) VALUES (NULL, ?, ?)").run("模型库", "模型库");
|
||||
}
|
||||
|
||||
const admin = db.prepare("SELECT id FROM users WHERE username = ?").get("admin");
|
||||
if (!admin) {
|
||||
db.prepare(`
|
||||
INSERT INTO users (username, password_hash, role, enabled)
|
||||
VALUES (?, ?, 'admin', 1)
|
||||
`).run("admin", bcrypt.hashSync("admin123", 10));
|
||||
}
|
||||
}
|
||||
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 };
|
||||
});
|
||||
}
|
||||
52
server/src/index.ts
Normal file
52
server/src/index.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import Fastify from "fastify";
|
||||
import cors from "@fastify/cors";
|
||||
import jwt from "@fastify/jwt";
|
||||
import multipart from "@fastify/multipart";
|
||||
import fastifyStatic from "@fastify/static";
|
||||
import { config } from "./config.js";
|
||||
import { migrate } from "./db.js";
|
||||
import { authRoutes } from "./auth.js";
|
||||
import { folderRoutes } from "./folders.js";
|
||||
import { modelRoutes } from "./models.js";
|
||||
import { storageStatus } from "./storage.js";
|
||||
|
||||
migrate();
|
||||
|
||||
const app = Fastify({
|
||||
logger: true,
|
||||
bodyLimit: 1024 * 1024 * 20
|
||||
});
|
||||
|
||||
await app.register(cors, {
|
||||
origin: true,
|
||||
credentials: true
|
||||
});
|
||||
await app.register(jwt, {
|
||||
secret: config.jwtSecret
|
||||
});
|
||||
await app.register(multipart, {
|
||||
limits: {
|
||||
fileSize: 1024 * 1024 * 200
|
||||
}
|
||||
});
|
||||
await app.register(fastifyStatic, {
|
||||
root: config.storageDir,
|
||||
prefix: "/storage/"
|
||||
});
|
||||
|
||||
app.get("/api/health", async () => ({ ok: true, storage: storageStatus() }));
|
||||
|
||||
await app.register(authRoutes);
|
||||
await app.register(folderRoutes);
|
||||
await app.register(modelRoutes);
|
||||
|
||||
app.setErrorHandler((error, _request, reply) => {
|
||||
const fastifyError = error as { statusCode?: number; message?: string };
|
||||
const statusCode = fastifyError.statusCode && fastifyError.statusCode >= 400 ? fastifyError.statusCode : 500;
|
||||
app.log.error(error);
|
||||
reply.code(statusCode).send({
|
||||
message: statusCode === 500 ? "服务器内部错误" : fastifyError.message
|
||||
});
|
||||
});
|
||||
|
||||
await app.listen({ host: config.host, port: config.port });
|
||||
340
server/src/models.ts
Normal file
340
server/src/models.ts
Normal file
@@ -0,0 +1,340 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { nanoid } from "nanoid";
|
||||
import { db } from "./db.js";
|
||||
import { requireAdmin, requireAuth } from "./auth.js";
|
||||
import { deleteStoredObject, publicObjectUrl, storage } from "./storage.js";
|
||||
import { DictionaryRow, FolderRow, ModelRow } from "./types.js";
|
||||
import {
|
||||
assertGlbFilename,
|
||||
assertValidFolderName,
|
||||
normalizeModelName,
|
||||
toStorageRelative
|
||||
} from "./utils.js";
|
||||
|
||||
const updateSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
brandName: z.string().optional(),
|
||||
typeName: z.string().optional(),
|
||||
thumbnail: z.string().nullable().optional(),
|
||||
properties: z.record(z.string()).optional()
|
||||
});
|
||||
|
||||
const thumbnailSchema = z.object({
|
||||
thumbnail: z.string().startsWith("data:image/").max(2_000_000)
|
||||
});
|
||||
|
||||
const dictionaryKindSchema = z.object({
|
||||
kind: z.enum(["brands", "types"])
|
||||
});
|
||||
|
||||
const dictionaryBodySchema = z.object({
|
||||
name: z.string().min(1).max(80)
|
||||
});
|
||||
|
||||
function getFolder(id: number) {
|
||||
return db.prepare("SELECT * FROM folders WHERE id = ?").get(id) as FolderRow | undefined;
|
||||
}
|
||||
|
||||
function getProperties(modelId: number) {
|
||||
const rows = db.prepare("SELECT property_key, property_value FROM model_properties WHERE model_id = ?")
|
||||
.all(modelId) as { property_key: string; property_value: string }[];
|
||||
return Object.fromEntries(rows.map((row) => [row.property_key, row.property_value]));
|
||||
}
|
||||
|
||||
function saveProperties(modelId: number, properties: Record<string, string>) {
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO model_properties (model_id, property_key, property_value)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(model_id, property_key)
|
||||
DO UPDATE SET property_value = excluded.property_value
|
||||
`);
|
||||
for (const [key, value] of Object.entries(properties)) {
|
||||
const cleanKey = key.trim();
|
||||
if (!cleanKey) continue;
|
||||
stmt.run(modelId, cleanKey, String(value ?? ""));
|
||||
}
|
||||
}
|
||||
|
||||
function modelPayload(row: ModelRow) {
|
||||
return {
|
||||
...row,
|
||||
file_url: publicObjectUrl(row.file_path, row.storage_provider),
|
||||
thumbnail_url: row.thumbnail_path && row.thumbnail_provider
|
||||
? publicObjectUrl(row.thumbnail_path, row.thumbnail_provider)
|
||||
: row.thumbnail,
|
||||
properties: getProperties(row.id)
|
||||
};
|
||||
}
|
||||
|
||||
function dataImageToBuffer(dataUrl: string) {
|
||||
const match = dataUrl.match(/^data:(image\/png|image\/jpeg|image\/webp);base64,(.+)$/);
|
||||
if (!match) {
|
||||
throw new Error("缩略图格式不正确");
|
||||
}
|
||||
return {
|
||||
ext: match[1] === "image/png" ? "png" : match[1] === "image/webp" ? "webp" : "jpg",
|
||||
buffer: Buffer.from(match[2], "base64")
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDictionaryName(name: string | undefined) {
|
||||
const clean = name?.trim();
|
||||
return clean || null;
|
||||
}
|
||||
|
||||
function findOrCreateDictionary(table: "brands" | "model_types", name: string | undefined) {
|
||||
const cleanName = normalizeDictionaryName(name);
|
||||
if (!cleanName) return null;
|
||||
const existing = db.prepare(`SELECT id FROM ${table} WHERE name = ?`).get(cleanName) as { id: number } | undefined;
|
||||
if (existing) return existing.id;
|
||||
const result = db.prepare(`INSERT INTO ${table} (name) VALUES (?)`).run(cleanName);
|
||||
return Number(result.lastInsertRowid);
|
||||
}
|
||||
|
||||
function dictionaryList(table: "brands" | "model_types") {
|
||||
return db.prepare(`SELECT id, name FROM ${table} ORDER BY name`).all() as unknown as DictionaryRow[];
|
||||
}
|
||||
|
||||
function dictionaryTable(kind: "brands" | "types") {
|
||||
return kind === "brands" ? "brands" : "model_types";
|
||||
}
|
||||
|
||||
export async function modelRoutes(app: FastifyInstance) {
|
||||
app.get("/api/dictionaries", { preHandler: [requireAuth] }, async () => {
|
||||
return {
|
||||
brands: dictionaryList("brands"),
|
||||
types: dictionaryList("model_types")
|
||||
};
|
||||
});
|
||||
|
||||
app.post("/api/dictionaries/:kind", { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const params = dictionaryKindSchema.parse(request.params);
|
||||
const body = dictionaryBodySchema.parse(request.body);
|
||||
const name = body.name.trim();
|
||||
const table = dictionaryTable(params.kind);
|
||||
try {
|
||||
const result = db.prepare(`INSERT INTO ${table} (name) VALUES (?)`).run(name);
|
||||
return reply.code(201).send({ id: result.lastInsertRowid, name });
|
||||
} catch {
|
||||
return reply.code(409).send({ message: "名称已存在" });
|
||||
}
|
||||
});
|
||||
|
||||
app.put("/api/dictionaries/:kind/:id", { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const params = dictionaryKindSchema.extend({ id: z.coerce.number().int() }).parse(request.params);
|
||||
const body = dictionaryBodySchema.parse(request.body);
|
||||
const name = body.name.trim();
|
||||
const table = dictionaryTable(params.kind);
|
||||
try {
|
||||
const result = db.prepare(`UPDATE ${table} SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`)
|
||||
.run(name, params.id);
|
||||
if (result.changes === 0) {
|
||||
return reply.code(404).send({ message: "字典项不存在" });
|
||||
}
|
||||
return { ok: true };
|
||||
} catch {
|
||||
return reply.code(409).send({ message: "名称已存在" });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete("/api/dictionaries/:kind/:id", { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const params = dictionaryKindSchema.extend({ id: z.coerce.number().int() }).parse(request.params);
|
||||
const table = dictionaryTable(params.kind);
|
||||
const result = db.prepare(`DELETE FROM ${table} WHERE id = ?`).run(params.id);
|
||||
if (result.changes === 0) {
|
||||
return reply.code(404).send({ message: "字典项不存在" });
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.get("/api/models", { preHandler: [requireAuth] }, async (request) => {
|
||||
const query = z.object({
|
||||
folderId: z.coerce.number().int().optional(),
|
||||
brandId: z.coerce.number().int().optional(),
|
||||
typeId: z.coerce.number().int().optional(),
|
||||
keyword: z.string().optional(),
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).default(12)
|
||||
}).parse(request.query);
|
||||
|
||||
const offset = (query.page - 1) * query.pageSize;
|
||||
const whereParts: string[] = [];
|
||||
const params: Array<string | number> = [];
|
||||
if (query.folderId) {
|
||||
whereParts.push("m.folder_id = ?");
|
||||
params.push(query.folderId);
|
||||
}
|
||||
if (query.brandId) {
|
||||
whereParts.push("m.brand_id = ?");
|
||||
params.push(query.brandId);
|
||||
}
|
||||
if (query.typeId) {
|
||||
whereParts.push("m.type_id = ?");
|
||||
params.push(query.typeId);
|
||||
}
|
||||
if (query.keyword?.trim()) {
|
||||
whereParts.push("(m.name LIKE ? OR m.original_filename LIKE ? OR mp.property_value LIKE ?)");
|
||||
const like = `%${query.keyword.trim()}%`;
|
||||
params.push(like, like, like);
|
||||
}
|
||||
const where = whereParts.length > 0 ? `WHERE ${whereParts.join(" AND ")}` : "";
|
||||
const totalStmt = db.prepare(`
|
||||
SELECT COUNT(DISTINCT m.id) as count
|
||||
FROM models m
|
||||
LEFT JOIN model_properties mp ON mp.model_id = m.id
|
||||
${where}
|
||||
`);
|
||||
const total = totalStmt.get(...params) as { count: number };
|
||||
const listStmt = db.prepare(`
|
||||
SELECT DISTINCT
|
||||
m.*,
|
||||
b.name AS brand_name,
|
||||
t.name AS type_name
|
||||
FROM models m
|
||||
LEFT JOIN brands b ON b.id = m.brand_id
|
||||
LEFT JOIN model_types t ON t.id = m.type_id
|
||||
LEFT JOIN model_properties mp ON mp.model_id = m.id
|
||||
${where}
|
||||
ORDER BY m.updated_at DESC, m.id DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`);
|
||||
const rows = listStmt.all(...params, query.pageSize, offset) as unknown as ModelRow[];
|
||||
|
||||
return {
|
||||
total: total.count,
|
||||
page: query.page,
|
||||
pageSize: query.pageSize,
|
||||
items: rows.map(modelPayload)
|
||||
};
|
||||
});
|
||||
|
||||
app.post("/api/models/upload", { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const parts = request.parts();
|
||||
let folderId: number | undefined;
|
||||
let thumbnail: string | null = null;
|
||||
const properties: Record<string, string> = {};
|
||||
let uploaded: { filename: string; buffer: Buffer } | undefined;
|
||||
let modelName: string | undefined;
|
||||
let brandName: string | undefined;
|
||||
let typeName: string | undefined;
|
||||
|
||||
for await (const part of parts) {
|
||||
if (part.type === "file") {
|
||||
if (part.fieldname === "file") {
|
||||
const chunks = [];
|
||||
for await (const chunk of part.file) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
uploaded = { filename: part.filename, buffer: Buffer.concat(chunks) };
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (part.fieldname === "folderId") folderId = Number(part.value);
|
||||
if (part.fieldname === "name") modelName = String(part.value ?? "");
|
||||
if (part.fieldname === "brandName") brandName = String(part.value ?? "");
|
||||
if (part.fieldname === "typeName") typeName = String(part.value ?? "");
|
||||
if (part.fieldname === "thumbnail") thumbnail = String(part.value || "") || null;
|
||||
if (part.fieldname.startsWith("prop.")) {
|
||||
properties[part.fieldname.slice(5)] = String(part.value ?? "");
|
||||
}
|
||||
}
|
||||
|
||||
if (!folderId || !uploaded) {
|
||||
return reply.code(400).send({ message: "缺少目录或模型文件" });
|
||||
}
|
||||
assertGlbFilename(uploaded.filename);
|
||||
const folder = getFolder(folderId);
|
||||
if (!folder) {
|
||||
return reply.code(404).send({ message: "目录不存在" });
|
||||
}
|
||||
|
||||
const originalModelName = assertValidFolderName(modelName || normalizeModelName(uploaded.filename));
|
||||
const storageFilename = `${normalizeModelName(uploaded.filename)}-${nanoid(8)}.glb`;
|
||||
const objectKey = toStorageRelative(folder.path, storageFilename);
|
||||
|
||||
const exists = db.prepare("SELECT id FROM models WHERE folder_id = ? AND name = ?").get(folderId, originalModelName);
|
||||
if (exists) {
|
||||
return reply.code(409).send({ message: "当前目录下模型名称已存在" });
|
||||
}
|
||||
|
||||
const stored = await storage.putObject(objectKey, uploaded.buffer);
|
||||
try {
|
||||
const brandId = findOrCreateDictionary("brands", brandName);
|
||||
const typeId = findOrCreateDictionary("model_types", typeName);
|
||||
const result = db.prepare(`
|
||||
INSERT INTO models (folder_id, brand_id, type_id, name, original_filename, file_path, storage_provider, file_size, thumbnail)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(folderId, brandId, typeId, originalModelName, uploaded.filename, stored.key, stored.provider, uploaded.buffer.length, thumbnail);
|
||||
saveProperties(Number(result.lastInsertRowid), properties);
|
||||
return reply.code(201).send({ id: result.lastInsertRowid });
|
||||
} catch (error) {
|
||||
await storage.deleteObject(stored.key);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
app.put("/api/models/: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 model = db.prepare("SELECT * FROM models WHERE id = ?").get(params.id) as ModelRow | undefined;
|
||||
if (!model) {
|
||||
return reply.code(404).send({ message: "模型不存在" });
|
||||
}
|
||||
const name = assertValidFolderName(body.name);
|
||||
try {
|
||||
const brandId = findOrCreateDictionary("brands", body.brandName);
|
||||
const typeId = findOrCreateDictionary("model_types", body.typeName);
|
||||
db.prepare(`
|
||||
UPDATE models
|
||||
SET name = ?, brand_id = ?, type_id = ?, thumbnail = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`).run(name, brandId, typeId, body.thumbnail ?? model.thumbnail, model.id);
|
||||
if (body.properties) {
|
||||
saveProperties(model.id, body.properties);
|
||||
}
|
||||
return { ok: true };
|
||||
} catch {
|
||||
return reply.code(409).send({ message: "当前目录下模型名称已存在" });
|
||||
}
|
||||
});
|
||||
|
||||
app.put("/api/models/:id/thumbnail", { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const params = z.object({ id: z.coerce.number().int() }).parse(request.params);
|
||||
const body = thumbnailSchema.parse(request.body);
|
||||
const model = db.prepare("SELECT * FROM models WHERE id = ?").get(params.id) as ModelRow | undefined;
|
||||
if (!model) {
|
||||
return reply.code(404).send({ message: "模型不存在" });
|
||||
}
|
||||
const image = dataImageToBuffer(body.thumbnail);
|
||||
const stored = await storage.putObject(`thumbnails/model-${model.id}-${nanoid(8)}.${image.ext}`, image.buffer);
|
||||
try {
|
||||
db.prepare(`
|
||||
UPDATE models
|
||||
SET thumbnail = NULL, thumbnail_path = ?, thumbnail_provider = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`).run(stored.key, stored.provider, model.id);
|
||||
if (model.thumbnail_path && model.thumbnail_provider) {
|
||||
await deleteStoredObject(model.thumbnail_path, model.thumbnail_provider);
|
||||
}
|
||||
return { ok: true, thumbnail_url: publicObjectUrl(stored.key, stored.provider) };
|
||||
} catch (error) {
|
||||
await storage.deleteObject(stored.key);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
app.delete("/api/models/:id", { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
const params = z.object({ id: z.coerce.number().int() }).parse(request.params);
|
||||
const model = db.prepare("SELECT * FROM models WHERE id = ?").get(params.id) as ModelRow | undefined;
|
||||
if (!model) {
|
||||
return reply.code(404).send({ message: "模型不存在" });
|
||||
}
|
||||
db.prepare("DELETE FROM models WHERE id = ?").run(model.id);
|
||||
await deleteStoredObject(model.file_path, model.storage_provider);
|
||||
if (model.thumbnail_path && model.thumbnail_provider) {
|
||||
await deleteStoredObject(model.thumbnail_path, model.thumbnail_provider);
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
138
server/src/storage.ts
Normal file
138
server/src/storage.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import COS from "cos-nodejs-sdk-v5";
|
||||
import { config } from "./config.js";
|
||||
import { ensureDir, removeIfExists, safeStoragePath } from "./utils.js";
|
||||
|
||||
export type StorageProvider = "local" | "cos";
|
||||
|
||||
export type StoredObject = {
|
||||
key: string;
|
||||
provider: StorageProvider;
|
||||
};
|
||||
|
||||
type StorageService = {
|
||||
provider: StorageProvider;
|
||||
putObject(key: string, buffer: Buffer): Promise<StoredObject>;
|
||||
deleteObject(key: string): Promise<void>;
|
||||
publicUrl(key: string): string;
|
||||
};
|
||||
|
||||
function normalizeKey(key: string) {
|
||||
return key.replace(/\\/g, "/").replace(/^\/+/, "");
|
||||
}
|
||||
|
||||
function joinUrl(base: string, key: string) {
|
||||
return `${base.replace(/\/+$/g, "")}/${normalizeKey(key).split("/").map(encodeURIComponent).join("/")}`;
|
||||
}
|
||||
|
||||
class LocalStorageService implements StorageService {
|
||||
provider: StorageProvider = "local";
|
||||
|
||||
async putObject(key: string, buffer: Buffer) {
|
||||
const normalized = normalizeKey(key);
|
||||
const fullPath = safeStoragePath(normalized);
|
||||
await ensureDir(path.dirname(fullPath));
|
||||
await fs.writeFile(fullPath, buffer);
|
||||
return { key: normalized, provider: this.provider };
|
||||
}
|
||||
|
||||
async deleteObject(key: string) {
|
||||
await removeIfExists(safeStoragePath(normalizeKey(key)));
|
||||
}
|
||||
|
||||
publicUrl(key: string) {
|
||||
const normalized = normalizeKey(key);
|
||||
if (config.publicBaseUrl) {
|
||||
return joinUrl(`${config.publicBaseUrl}/storage`, normalized);
|
||||
}
|
||||
return `/storage/${normalized}`;
|
||||
}
|
||||
}
|
||||
|
||||
class CosStorageService implements StorageService {
|
||||
provider: StorageProvider = "cos";
|
||||
private cos: COS;
|
||||
|
||||
constructor() {
|
||||
const missing = [
|
||||
["COS_SECRET_ID", config.cos.secretId],
|
||||
["COS_SECRET_KEY", config.cos.secretKey],
|
||||
["COS_BUCKET", config.cos.bucket],
|
||||
["COS_REGION", config.cos.region],
|
||||
["CDN_BASE_URL", config.cdnBaseUrl]
|
||||
].filter(([, value]) => !value).map(([key]) => key);
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`COS 存储配置缺失:${missing.join(", ")}`);
|
||||
}
|
||||
this.cos = new COS({
|
||||
SecretId: config.cos.secretId,
|
||||
SecretKey: config.cos.secretKey
|
||||
});
|
||||
}
|
||||
|
||||
async putObject(key: string, buffer: Buffer) {
|
||||
const objectKey = normalizeKey(config.cos.prefix ? `${config.cos.prefix}/${key}` : key);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
this.cos.putObject({
|
||||
Bucket: config.cos.bucket,
|
||||
Region: config.cos.region,
|
||||
Key: objectKey,
|
||||
Body: buffer
|
||||
}, (error) => {
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
return { key: objectKey, provider: this.provider };
|
||||
}
|
||||
|
||||
async deleteObject(key: string) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
this.cos.deleteObject({
|
||||
Bucket: config.cos.bucket,
|
||||
Region: config.cos.region,
|
||||
Key: normalizeKey(key)
|
||||
}, (error) => {
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
publicUrl(key: string) {
|
||||
return joinUrl(config.cdnBaseUrl, key);
|
||||
}
|
||||
}
|
||||
|
||||
export const storage = config.storageDriver === "cos"
|
||||
? new CosStorageService()
|
||||
: new LocalStorageService();
|
||||
|
||||
const localStorage = storage.provider === "local" ? storage : new LocalStorageService();
|
||||
|
||||
export function publicObjectUrl(key: string, provider: StorageProvider) {
|
||||
if (provider === "cos") {
|
||||
if (!config.cdnBaseUrl) return normalizeKey(key);
|
||||
return joinUrl(config.cdnBaseUrl, key);
|
||||
}
|
||||
return localStorage.publicUrl(key);
|
||||
}
|
||||
|
||||
export async function deleteStoredObject(key: string, provider: StorageProvider) {
|
||||
if (provider === storage.provider) {
|
||||
await storage.deleteObject(key);
|
||||
return;
|
||||
}
|
||||
if (provider === "local") {
|
||||
await localStorage.deleteObject(key);
|
||||
}
|
||||
}
|
||||
|
||||
export function storageStatus() {
|
||||
return {
|
||||
driver: storage.provider,
|
||||
cdnEnabled: Boolean(config.cdnBaseUrl),
|
||||
cosConfigured: Boolean(config.cos.bucket && config.cos.region && config.cos.secretId && config.cos.secretKey)
|
||||
};
|
||||
}
|
||||
51
server/src/types.ts
Normal file
51
server/src/types.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
export type UserRole = "admin" | "user";
|
||||
|
||||
export interface AuthUser {
|
||||
id: number;
|
||||
username: string;
|
||||
role: UserRole;
|
||||
}
|
||||
|
||||
export interface UserRow {
|
||||
id: number;
|
||||
username: string;
|
||||
password_hash: string;
|
||||
role: UserRole;
|
||||
enabled: number;
|
||||
expires_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface FolderRow {
|
||||
id: number;
|
||||
parent_id: number | null;
|
||||
name: string;
|
||||
path: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ModelRow {
|
||||
id: number;
|
||||
folder_id: number;
|
||||
brand_id: number | null;
|
||||
type_id: number | null;
|
||||
name: string;
|
||||
original_filename: string;
|
||||
file_path: string;
|
||||
storage_provider: "local" | "cos";
|
||||
file_size: number;
|
||||
thumbnail: string | null;
|
||||
thumbnail_path: string | null;
|
||||
thumbnail_provider: "local" | "cos" | null;
|
||||
brand_name?: string | null;
|
||||
type_name?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface DictionaryRow {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
55
server/src/utils.ts
Normal file
55
server/src/utils.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { config } from "./config.js";
|
||||
|
||||
const invalidNamePattern = /[\\/:*?"<>|]/;
|
||||
|
||||
export function assertValidFolderName(name: string) {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error("名称不能为空");
|
||||
}
|
||||
if (invalidNamePattern.test(trimmed)) {
|
||||
throw new Error('名称不能包含 \\ / : * ? " < > |');
|
||||
}
|
||||
if (trimmed === "." || trimmed === "..") {
|
||||
throw new Error("名称不合法");
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function assertGlbFilename(filename: string) {
|
||||
if (path.extname(filename).toLowerCase() !== ".glb") {
|
||||
throw new Error("当前阶段只允许上传 .glb 模型");
|
||||
}
|
||||
}
|
||||
|
||||
export function safeStoragePath(relativePath: string) {
|
||||
const fullPath = path.resolve(config.storageDir, relativePath);
|
||||
const root = path.resolve(config.storageDir);
|
||||
if (!fullPath.startsWith(root)) {
|
||||
throw new Error("文件路径不合法");
|
||||
}
|
||||
return fullPath;
|
||||
}
|
||||
|
||||
export async function ensureDir(dirPath: string) {
|
||||
await fs.mkdir(dirPath, { recursive: true });
|
||||
}
|
||||
|
||||
export async function removeIfExists(targetPath: string) {
|
||||
await fs.rm(targetPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
export function toStorageRelative(folderPath: string, filename: string) {
|
||||
return path.join("models", folderPath, filename);
|
||||
}
|
||||
|
||||
export function toFolderStorageRelative(folderPath: string) {
|
||||
return path.join("models", folderPath);
|
||||
}
|
||||
|
||||
export function normalizeModelName(filename: string) {
|
||||
return path.basename(filename, path.extname(filename)).trim();
|
||||
}
|
||||
|
||||
13
server/tsconfig.json
Normal file
13
server/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user