238 lines
7.5 KiB
JavaScript
238 lines
7.5 KiB
JavaScript
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,
|
|
};
|