cangku/backend/src/services/aiBuyerShowSeedPackService.js

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,
};