const crypto = require('crypto'); const { getPool, query, resolveUser, } = require('./aiBuyerShowService'); const { uploadBufferToDefaultS3 } = require('./objectStorage'); const SEED_PACK_TABLE = 'pdd_seed_resource_packs'; const MAX_SEED_PACK_FILE_BYTES = 200 * 1024 * 1024; let tableReady = false; function normalizeString(value) { return String(value || '').trim(); } function normalizeNullableString(value) { const text = normalizeString(value); return text || null; } // multer/busboy may decode multipart filenames as latin1, garbling non-ASCII // names; re-encode to recover the original UTF-8 text. function normalizeUploadFileName(value) { const text = normalizeString(value); if (!text) return ''; try { const decoded = Buffer.from(text, 'latin1').toString('utf8'); return /[\uFFFD]/.test(decoded) ? text : decoded; } catch (_) { return text; } } async function ensureSeedPackTable() { if (tableReady) return; await query( `CREATE TABLE IF NOT EXISTS ${SEED_PACK_TABLE} ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '资源包ID', pack_uid VARCHAR(64) NOT NULL COMMENT '资源包唯一标识', shop_name VARCHAR(128) NOT NULL COMMENT '店铺名称', product_id VARCHAR(64) NOT NULL COMMENT '商品ID', sku_name VARCHAR(255) NULL COMMENT 'SKU名称', file_name VARCHAR(255) NOT NULL COMMENT 'ZIP文件名', file_size BIGINT NOT NULL DEFAULT 0 COMMENT '文件大小(字节)', object_key VARCHAR(512) NOT NULL COMMENT '对象存储Key', file_url VARCHAR(1024) NULL COMMENT '公开下载地址', review_count INT NOT NULL DEFAULT 0 COMMENT '包含评价条数', operator_id VARCHAR(64) NULL COMMENT '上传人ID', operator_name VARCHAR(128) NOT NULL DEFAULT '' COMMENT '上传人', download_count INT NOT NULL DEFAULT 0 COMMENT '下载次数', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '上传时间', updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', UNIQUE KEY uk_pack_uid (pack_uid), KEY idx_seed_pack_shop (shop_name), KEY idx_seed_pack_product (product_id), KEY idx_seed_pack_created (created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='种菜资源包'` ); tableReady = true; } function normalizePackRow(row = {}) { return { id: Number(row.id), packUid: row.pack_uid ?? row.packUid ?? '', shopName: row.shop_name ?? row.shopName ?? '', productId: row.product_id ?? row.productId ?? '', skuName: row.sku_name ?? row.skuName ?? '', fileName: row.file_name ?? row.fileName ?? '', fileSize: Number(row.file_size ?? row.fileSize ?? 0), fileUrl: row.file_url ?? row.fileUrl ?? '', reviewCount: Number(row.review_count ?? row.reviewCount ?? 0), operatorName: row.operator_name ?? row.operatorName ?? '', downloadCount: Number(row.download_count ?? row.downloadCount ?? 0), createdAt: row.created_at || null, }; } function validateSeedPackInput({ shopName, productId, file }) { if (!normalizeString(shopName)) { const error = new Error('店铺名称不能为空'); error.statusCode = 400; return error; } if (!normalizeString(productId)) { const error = new Error('商品ID不能为空'); error.statusCode = 400; return error; } if (!file?.buffer || !file.buffer.length) { const error = new Error('ZIP 文件不能为空'); error.statusCode = 400; return error; } if (file.buffer.length > MAX_SEED_PACK_FILE_BYTES) { const error = new Error('ZIP 文件不能超过 200MB'); error.statusCode = 413; return error; } return null; } async function createSeedPack({ shopName, productId, skuName, fileName, reviewCount, file }, user = {}) { await ensureSeedPackTable(); const operator = resolveUser(user); const validationError = validateSeedPackInput({ shopName, productId, file }); if (validationError) throw validationError; const uploaded = await uploadBufferToDefaultS3( file.buffer, 'application/zip', 'seed-packs', 'zip' ); const resolvedFileName = normalizeString(fileName) || normalizeUploadFileName(file?.originalname) || `种菜资源包_${Date.now()}.zip`; const packUid = crypto.randomUUID(); const insertRows = await query( `INSERT INTO ${SEED_PACK_TABLE} ( pack_uid, shop_name, product_id, sku_name, file_name, file_size, object_key, file_url, review_count, operator_id, operator_name ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ packUid, normalizeString(shopName).slice(0, 128), normalizeString(productId).slice(0, 64), normalizeNullableString(String(skuName || '').slice(0, 255)), resolvedFileName.slice(0, 255), file.buffer.length, uploaded.key, uploaded.url || '', Math.max(Number(reviewCount) || 0, 0), operator.id || null, operator.name, ] ); const rows = await query( `SELECT * FROM ${SEED_PACK_TABLE} WHERE id = ? LIMIT 1`, [insertRows.insertId] ); return normalizePackRow(rows[0]); } async function listSeedPacks({ page = 1, pageSize = 15, keyword = '' } = {}) { await ensureSeedPackTable(); const normalizedPage = Math.max(Number(page) || 1, 1); const normalizedPageSize = Math.min(Math.max(Number(pageSize) || 15, 1), 100); const offset = (normalizedPage - 1) * normalizedPageSize; const normalizedKeyword = normalizeString(keyword); const likeKeyword = `%${normalizedKeyword}%`; const whereSql = normalizedKeyword ? 'WHERE shop_name LIKE ? OR product_id LIKE ? OR sku_name LIKE ? OR operator_name LIKE ? OR file_name LIKE ?' : ''; const params = normalizedKeyword ? Array(5).fill(likeKeyword) : []; const countRows = await query( `SELECT COUNT(*) AS total FROM ${SEED_PACK_TABLE} ${whereSql}`, params ); const rows = await query( `SELECT * FROM ${SEED_PACK_TABLE} ${whereSql} ORDER BY id DESC LIMIT ? OFFSET ?`, [...params, normalizedPageSize, offset] ); return { rows: rows.map(normalizePackRow), total: Number(countRows[0]?.total || 0), page: normalizedPage, pageSize: normalizedPageSize, }; } async function getSeedPackDownload(id) { await ensureSeedPackTable(); const packId = Number(id); if (!Number.isFinite(packId) || packId <= 0) { const error = new Error('资源包不存在'); error.statusCode = 404; throw error; } const rows = await query( `SELECT * FROM ${SEED_PACK_TABLE} WHERE id = ? LIMIT 1`, [packId] ); const pack = rows[0]; if (!pack) { const error = new Error('资源包不存在'); error.statusCode = 404; throw error; } await query( `UPDATE ${SEED_PACK_TABLE} SET download_count = download_count + 1 WHERE id = ?`, [packId] ); return { ...normalizePackRow(pack), downloadCount: Number(pack.downloadCount || 0) + 1, }; } async function deleteSeedPack(id) { await ensureSeedPackTable(); const packId = Number(id); if (!Number.isFinite(packId) || packId <= 0) { const error = new Error('资源包不存在'); error.statusCode = 404; throw error; } const rows = await query( `SELECT id FROM ${SEED_PACK_TABLE} WHERE id = ? LIMIT 1`, [packId] ); if (!rows.length) { const error = new Error('资源包不存在'); error.statusCode = 404; throw error; } await query(`DELETE FROM ${SEED_PACK_TABLE} WHERE id = ?`, [packId]); return { id: packId }; } module.exports = { createSeedPack, deleteSeedPack, getSeedPackDownload, listSeedPacks, MAX_SEED_PACK_FILE_BYTES, };