initial commit

This commit is contained in:
zhangshun
2026-05-23 09:11:46 +08:00
commit 5d08480921
42 changed files with 10615 additions and 0 deletions

29
server/package.json Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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"]
}