188 lines
5.9 KiB
TypeScript
188 lines
5.9 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { NextRequest, NextResponse } from "next/server";
|
|
import { getDefaultStorageConfig, getSystemSetting, upsertSystemSetting } from "@/lib/db/queries";
|
|
import {
|
|
PRODUCT_PLANNING_TEMPLATES_KEY,
|
|
normalizeTemplateCategoryName,
|
|
parseProductPlanningTemplates,
|
|
upsertProductPlanningTemplate,
|
|
} from "@/lib/product-planning/templates";
|
|
import {
|
|
isProductPlanningEnabled,
|
|
PRODUCT_PLANNING_DISABLED_MESSAGE,
|
|
} from "@/lib/product-planning/server-feature";
|
|
import type {
|
|
ProductPlanningTemplate,
|
|
ProductPlanningTemplateListResponse,
|
|
ProductPlanningTemplateMutationResponse,
|
|
} from "@/lib/product-planning/types";
|
|
import { getProductPlanningToken } from "@/lib/product-planning/server-auth";
|
|
import { uploadFile } from "@/lib/storage/service";
|
|
|
|
export const runtime = "nodejs";
|
|
|
|
const MAX_TEMPLATE_FILE_SIZE = 10 * 1024 * 1024;
|
|
const ALLOWED_TEMPLATE_EXTENSIONS = new Set(["txt", "csv", "xlsx"]);
|
|
|
|
function isAdminToken(token: Awaited<ReturnType<typeof getProductPlanningToken>>) {
|
|
const type = String(token?.type || "").trim();
|
|
return type === "admin" || type === "agent_admin";
|
|
}
|
|
|
|
function jsonListError(status: number, error: string) {
|
|
const payload: ProductPlanningTemplateListResponse = {
|
|
success: false,
|
|
templates: [],
|
|
error,
|
|
};
|
|
return NextResponse.json(payload, { status });
|
|
}
|
|
|
|
function jsonMutationError(status: number, error: string) {
|
|
const payload: ProductPlanningTemplateMutationResponse = {
|
|
success: false,
|
|
error,
|
|
};
|
|
return NextResponse.json(payload, { status });
|
|
}
|
|
|
|
function getTemplateExtension(fileName: string) {
|
|
return fileName.toLowerCase().split(".").pop() || "";
|
|
}
|
|
|
|
async function readTemplates() {
|
|
const setting = await getSystemSetting({ key: PRODUCT_PLANNING_TEMPLATES_KEY });
|
|
return parseProductPlanningTemplates(setting?.value);
|
|
}
|
|
|
|
async function tryDeleteLegacyTemplateFile(template: ProductPlanningTemplate) {
|
|
if (!template.filePath) return;
|
|
|
|
try {
|
|
const storageConfig = await getDefaultStorageConfig().catch(() => undefined);
|
|
if (!storageConfig || storageConfig.type !== "s3") return;
|
|
|
|
const { deleteS3ObjectByPath, toDbS3UploadConfig } = await import("@/lib/storage/service");
|
|
const uploadConfig = toDbS3UploadConfig(storageConfig);
|
|
if (!uploadConfig) return;
|
|
|
|
await deleteS3ObjectByPath(template.filePath, uploadConfig);
|
|
} catch (error) {
|
|
console.warn("[ProductPlanning][Templates] failed to delete legacy template file:", error);
|
|
}
|
|
}
|
|
|
|
export async function GET(request: NextRequest) {
|
|
const token = await getProductPlanningToken(request);
|
|
if (!token?.sub) {
|
|
return jsonListError(401, "未登录");
|
|
}
|
|
if (!(await isProductPlanningEnabled())) {
|
|
return jsonListError(403, PRODUCT_PLANNING_DISABLED_MESSAGE);
|
|
}
|
|
|
|
try {
|
|
const templates = await readTemplates();
|
|
return NextResponse.json({
|
|
success: true,
|
|
templates,
|
|
} satisfies ProductPlanningTemplateListResponse);
|
|
} catch (error) {
|
|
console.error("[ProductPlanning][Templates] list failed:", error);
|
|
return jsonListError(503, "模板库读取失败");
|
|
}
|
|
}
|
|
|
|
export async function POST(request: NextRequest) {
|
|
const token = await getProductPlanningToken(request);
|
|
if (!token?.sub) {
|
|
return jsonMutationError(401, "未登录");
|
|
}
|
|
if (!(await isProductPlanningEnabled())) {
|
|
return jsonMutationError(403, PRODUCT_PLANNING_DISABLED_MESSAGE);
|
|
}
|
|
|
|
if (!isAdminToken(token)) {
|
|
return jsonMutationError(403, "需要管理员权限");
|
|
}
|
|
|
|
let formData: FormData;
|
|
try {
|
|
formData = await request.formData();
|
|
} catch {
|
|
return jsonMutationError(400, "上传表单解析失败");
|
|
}
|
|
|
|
const categoryName = normalizeTemplateCategoryName(formData.get("categoryName"));
|
|
const description = String(formData.get("description") || "").trim();
|
|
const file = formData.get("file");
|
|
|
|
if (!categoryName) {
|
|
return jsonMutationError(400, "缺少模板名称");
|
|
}
|
|
|
|
if (categoryName.length > 50) {
|
|
return jsonMutationError(400, "模板名称不能超过 50 个字符");
|
|
}
|
|
|
|
if (!(file instanceof File)) {
|
|
return jsonMutationError(400, "缺少模板文件");
|
|
}
|
|
|
|
const extension = getTemplateExtension(file.name);
|
|
if (!ALLOWED_TEMPLATE_EXTENSIONS.has(extension)) {
|
|
return jsonMutationError(400, "模板文件仅支持 txt、csv、xlsx");
|
|
}
|
|
|
|
if (file.size <= 0) {
|
|
return jsonMutationError(400, "模板文件不能为空");
|
|
}
|
|
|
|
if (file.size > MAX_TEMPLATE_FILE_SIZE) {
|
|
return jsonMutationError(400, "模板文件不能超过 10MB");
|
|
}
|
|
|
|
try {
|
|
const buffer = await file.arrayBuffer();
|
|
const uploaded = await uploadFile(file.name, buffer);
|
|
const now = new Date().toISOString();
|
|
const nextTemplate: ProductPlanningTemplate = {
|
|
id: randomUUID(),
|
|
categoryName,
|
|
description: description || undefined,
|
|
fileName: file.name,
|
|
fileUrl: uploaded.url,
|
|
filePath: uploaded.pathname,
|
|
fileSize: file.size,
|
|
contentType: uploaded.contentType || file.type || undefined,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
uploadedById: String(token.sub),
|
|
};
|
|
|
|
const currentTemplates = await readTemplates();
|
|
const replacedTemplates = currentTemplates.filter(
|
|
(template) =>
|
|
normalizeTemplateCategoryName(template.categoryName) ===
|
|
normalizeTemplateCategoryName(categoryName)
|
|
);
|
|
const templates = upsertProductPlanningTemplate(currentTemplates, nextTemplate);
|
|
|
|
await upsertSystemSetting({
|
|
key: PRODUCT_PLANNING_TEMPLATES_KEY,
|
|
value: templates,
|
|
updatedBy: String(token.sub),
|
|
});
|
|
|
|
await Promise.all(replacedTemplates.map((template) => tryDeleteLegacyTemplateFile(template)));
|
|
|
|
return NextResponse.json({
|
|
success: true,
|
|
template: nextTemplate,
|
|
} satisfies ProductPlanningTemplateMutationResponse);
|
|
} catch (error) {
|
|
console.error("[ProductPlanning][Templates] upload failed:", error);
|
|
return jsonMutationError(502, "模板上传失败");
|
|
}
|
|
}
|