assets.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254
  1. "use strict";
  2. var __importDefault = (this && this.__importDefault) || function (mod) {
  3. return (mod && mod.__esModule) ? mod : { "default": mod };
  4. };
  5. Object.defineProperty(exports, "__esModule", { value: true });
  6. exports.assetsRouter = assetsRouter;
  7. const express_1 = require("express");
  8. const multer_1 = __importDefault(require("multer"));
  9. const adm_zip_1 = __importDefault(require("adm-zip"));
  10. const path_1 = __importDefault(require("path"));
  11. const fs_1 = __importDefault(require("fs"));
  12. const storageService_1 = require("../services/storageService");
  13. const upload = (0, multer_1.default)({
  14. storage: multer_1.default.memoryStorage(),
  15. limits: { fileSize: 50 * 1024 * 1024 },
  16. fileFilter: (_req, file, cb) => {
  17. if (file.mimetype === "application/zip" ||
  18. file.mimetype === "application/x-zip-compressed" ||
  19. file.originalname.endsWith(".zip")) {
  20. cb(null, true);
  21. }
  22. else {
  23. cb(new Error("Only .zip files are allowed"));
  24. }
  25. },
  26. });
  27. // 单图片上传(logo/slogon 等手动替换)
  28. const uploadImage = (0, multer_1.default)({
  29. storage: multer_1.default.memoryStorage(),
  30. limits: { fileSize: 5 * 1024 * 1024 },
  31. fileFilter: (_req, file, cb) => {
  32. if (file.mimetype.startsWith("image/")) {
  33. cb(null, true);
  34. }
  35. else {
  36. cb(new Error("Only image files are allowed"));
  37. }
  38. },
  39. });
  40. /**
  41. * 从 zip buffer 中提取素材文件、校验、写入磁盘、更新 DB。
  42. * 文件上传和 URL 导入共用此逻辑。
  43. */
  44. function extractAndSave(zipBuffer, manifest, assetsDir, db, creativeId) {
  45. const requiredAssets = manifest.assets?.required ?? [];
  46. const optionalAssets = manifest.assets?.optional ?? [];
  47. const allAssetDefs = [...requiredAssets, ...optionalAssets];
  48. // 清理并重建素材目录
  49. if (fs_1.default.existsSync(assetsDir)) {
  50. fs_1.default.rmSync(assetsDir, { recursive: true, force: true });
  51. }
  52. fs_1.default.mkdirSync(assetsDir, { recursive: true });
  53. const zip = new adm_zip_1.default(zipBuffer);
  54. const zipEntries = zip.getEntries();
  55. const extractedFiles = [];
  56. const warnings = [];
  57. // 清空旧素材记录
  58. db.prepare("DELETE FROM creative_assets WHERE creative_id = ?").run(creativeId);
  59. // 匹配并解压文件
  60. for (const def of allAssetDefs) {
  61. const entry = zipEntries.find((e) => {
  62. const entryName = path_1.default.basename(e.entryName).toLowerCase();
  63. const expectedName = def.file.toLowerCase();
  64. return entryName === expectedName;
  65. });
  66. if (entry) {
  67. const fileName = def.file;
  68. const filePath = path_1.default.join(assetsDir, fileName);
  69. fs_1.default.writeFileSync(filePath, entry.getData());
  70. const stat = fs_1.default.statSync(filePath);
  71. const isRequired = requiredAssets.some((r) => r.key === def.key);
  72. db.prepare("INSERT INTO creative_assets (creative_id, file_key, file_name, file_path, file_size, is_required) VALUES (?, ?, ?, ?, ?, ?)").run(creativeId, def.key, fileName, filePath, stat.size, isRequired ? 1 : 0);
  73. extractedFiles.push({ key: def.key, fileName, fileSize: stat.size, valid: true });
  74. }
  75. else if (requiredAssets.some((r) => r.key === def.key)) {
  76. warnings.push(`Required file '${def.file}' is missing from uploaded zip`);
  77. extractedFiles.push({ key: def.key, fileName: def.file, fileSize: 0, valid: false });
  78. }
  79. }
  80. // 检查未知文件
  81. for (const entry of zipEntries) {
  82. if (entry.isDirectory)
  83. continue;
  84. const entryName = path_1.default.basename(entry.entryName).toLowerCase();
  85. const known = allAssetDefs.some((d) => d.file.toLowerCase() === entryName);
  86. if (!known) {
  87. warnings.push(`Unknown file '${entry.entryName}' ignored`);
  88. }
  89. }
  90. return { files: extractedFiles, warnings };
  91. }
  92. function assetsRouter(db, storageDir) {
  93. const router = (0, express_1.Router)();
  94. // POST /api/v1/creatives/:id/assets/upload
  95. // 支持两种方式:
  96. // - multipart/form-data 上传 .zip 文件(file 字段)
  97. // - application/json 提供素材 URL({ url: "https://..." })
  98. router.post("/creatives/:id/assets/upload", upload.single("file"), async (req, res) => {
  99. try {
  100. const creativeId = req.params.id;
  101. // 校验创意存在
  102. const creative = db
  103. .prepare("SELECT c.*, t.manifest FROM creatives c JOIN templates t ON c.template_id = t.id WHERE c.id = ?")
  104. .get(creativeId);
  105. if (!creative) {
  106. res.status(404).json({ error: { message: "Creative not found" } });
  107. return;
  108. }
  109. const manifest = JSON.parse(creative.manifest);
  110. const assetsDir = path_1.default.join(storageDir, "creatives", creativeId, "assets");
  111. let zipBuffer = null;
  112. // 方式 1:文件上传
  113. if (req.file) {
  114. zipBuffer = req.file.buffer;
  115. }
  116. // 方式 2:URL 导入
  117. else if (req.body?.url) {
  118. const parsed = (0, storageService_1.parseDetailUrl)(req.body.url);
  119. if (!parsed) {
  120. res.status(400).json({
  121. error: { message: "无法解析素材 URL,请确认格式正确" },
  122. });
  123. return;
  124. }
  125. console.log(`[assets] Downloading encrypted zip: ${parsed.zipUrl}`);
  126. const encrypted = await (0, storageService_1.downloadFile)(parsed.zipUrl);
  127. console.log(`[assets] Decrypting with key: ${parsed.id}`);
  128. zipBuffer = (0, storageService_1.xorDecryptBuffer)(encrypted, parsed.id);
  129. }
  130. else {
  131. res.status(400).json({
  132. error: { message: "请上传素材 zip 文件或提供素材 URL" },
  133. });
  134. return;
  135. }
  136. // 共用解压 & 校验逻辑
  137. const { files, warnings } = extractAndSave(zipBuffer, manifest, assetsDir, db, creativeId);
  138. // 更新创意状态
  139. const missingRequired = files.some((f) => !f.valid);
  140. db.prepare("UPDATE creatives SET status = ?, updated_at = datetime('now') WHERE id = ?").run(missingRequired ? "draft" : "assets_ready", creativeId);
  141. res.json({ data: { files, warnings } });
  142. }
  143. catch (err) {
  144. console.error("[assets] Upload error:", err.message);
  145. res.status(500).json({ error: { message: err.message } });
  146. }
  147. });
  148. // GET /api/v1/creatives/:id/assets/:key — 获取单个素材文件(用于缩略图展示)
  149. router.get("/creatives/:id/assets/:key", (req, res) => {
  150. const creativeId = req.params.id;
  151. const fileKey = req.params.key;
  152. // 1. 先查用户上传的素材
  153. const asset = db
  154. .prepare("SELECT file_path, file_name FROM creative_assets WHERE creative_id = ? AND file_key = ?")
  155. .get(creativeId, fileKey);
  156. let filePath;
  157. let fileName;
  158. if (asset && fs_1.default.existsSync(asset.file_path)) {
  159. filePath = asset.file_path;
  160. fileName = asset.file_name;
  161. }
  162. else {
  163. // 2. 未上传 → 尝试回退到模板默认图
  164. const TEMPLATE_IMG_DIR = path_1.default.resolve(__dirname, "../../../../templates/coloring/assets/img");
  165. const defaultMap = {
  166. logo: "logo.png",
  167. logoTxt: "logo-txt.png",
  168. slogon: "slogon.png",
  169. coloringPages: "coloring-pages.png",
  170. };
  171. const defaultFile = defaultMap[fileKey];
  172. if (!defaultFile) {
  173. res.status(404).json({ error: { message: "Asset not found" } });
  174. return;
  175. }
  176. filePath = path_1.default.join(TEMPLATE_IMG_DIR, defaultFile);
  177. fileName = defaultFile;
  178. if (!fs_1.default.existsSync(filePath)) {
  179. res.status(404).json({ error: { message: "Default asset not found" } });
  180. return;
  181. }
  182. }
  183. const ext = path_1.default.extname(fileName).toLowerCase();
  184. const mimeTypes = {
  185. ".png": "image/png",
  186. ".jpg": "image/jpeg",
  187. ".jpeg": "image/jpeg",
  188. ".gif": "image/gif",
  189. ".webp": "image/webp",
  190. ".svg": "image/svg+xml",
  191. };
  192. const contentType = mimeTypes[ext] || "application/octet-stream";
  193. res.setHeader("Content-Type", contentType);
  194. res.setHeader("Cache-Control", "public, max-age=3600");
  195. fs_1.default.createReadStream(filePath).pipe(res);
  196. });
  197. // POST /api/v1/creatives/:id/assets/:key — 上传单个素材文件(手动替换)
  198. router.post("/creatives/:id/assets/:key", uploadImage.single("file"), (req, res) => {
  199. try {
  200. const creativeId = req.params.id;
  201. const fileKey = req.params.key;
  202. const creative = db
  203. .prepare("SELECT c.*, t.manifest FROM creatives c JOIN templates t ON c.template_id = t.id WHERE c.id = ?")
  204. .get(creativeId);
  205. if (!creative) {
  206. res.status(404).json({ error: { message: "Creative not found" } });
  207. return;
  208. }
  209. const manifest = JSON.parse(creative.manifest);
  210. const requiredAssets = manifest.assets?.required ?? [];
  211. const optionalAssets = manifest.assets?.optional ?? [];
  212. const def = [...requiredAssets, ...optionalAssets].find((d) => d.key === fileKey);
  213. if (!def) {
  214. res.status(400).json({ error: { message: `Unknown asset key: ${fileKey}` } });
  215. return;
  216. }
  217. if (!req.file) {
  218. res.status(400).json({ error: { message: "请选择文件" } });
  219. return;
  220. }
  221. const assetsDir = path_1.default.join(storageDir, "creatives", creativeId, "assets");
  222. if (!fs_1.default.existsSync(assetsDir))
  223. fs_1.default.mkdirSync(assetsDir, { recursive: true });
  224. // 删除同 key 的旧文件
  225. const oldAsset = db.prepare("SELECT file_path FROM creative_assets WHERE creative_id = ? AND file_key = ?").get(creativeId, fileKey);
  226. if (oldAsset && fs_1.default.existsSync(oldAsset.file_path))
  227. fs_1.default.unlinkSync(oldAsset.file_path);
  228. const filePath = path_1.default.join(assetsDir, def.file);
  229. fs_1.default.writeFileSync(filePath, req.file.buffer);
  230. const stat = fs_1.default.statSync(filePath);
  231. db.prepare("DELETE FROM creative_assets WHERE creative_id = ? AND file_key = ?").run(creativeId, fileKey);
  232. db.prepare("INSERT INTO creative_assets (creative_id, file_key, file_name, file_path, file_size, is_required) VALUES (?, ?, ?, ?, ?, ?)").run(creativeId, fileKey, def.file, filePath, stat.size, requiredAssets.some((r) => r.key === fileKey) ? 1 : 0);
  233. console.log(`[assets] Single upload: ${fileKey} -> ${filePath} (${stat.size} bytes)`);
  234. res.json({ data: { key: fileKey, fileName: def.file, fileSize: stat.size, valid: true } });
  235. }
  236. catch (err) {
  237. console.error("[assets] Single upload error:", err.message);
  238. res.status(500).json({ error: { message: err.message } });
  239. }
  240. });
  241. // DELETE /api/v1/creatives/:id/assets — 清除素材
  242. router.delete("/creatives/:id/assets", (req, res) => {
  243. const creativeId = req.params.id;
  244. const assetsDir = path_1.default.join(storageDir, "creatives", creativeId, "assets");
  245. if (fs_1.default.existsSync(assetsDir)) {
  246. fs_1.default.rmSync(assetsDir, { recursive: true, force: true });
  247. }
  248. db.prepare("DELETE FROM creative_assets WHERE creative_id = ?").run(creativeId);
  249. db.prepare("UPDATE creatives SET status = 'draft', updated_at = datetime('now') WHERE id = ?").run(creativeId);
  250. res.json({ data: { id: creativeId, cleared: true } });
  251. });
  252. return router;
  253. }
  254. //# sourceMappingURL=assets.js.map