632 lines
24 KiB
JavaScript
632 lines
24 KiB
JavaScript
'use strict';
|
||
|
||
const planningDatabase = require('./planningDatabase');
|
||
const { buildAlibaba1688SkuFingerprint } = require('./alibaba1688SkuIdentity');
|
||
|
||
const PRODUCT_TABLE = 'alibaba_1688_products';
|
||
const MAX_PRODUCT_SNAPSHOT_BYTES = 12 * 1024 * 1024;
|
||
const MAX_PAGE_SIZE = 200;
|
||
const MAX_SKU_COUNT = 5000;
|
||
const PUBLISH_STATUSES = new Set(['pending', 'ready', 'publishing', 'published', 'failed', 'disabled']);
|
||
const IMPORT_TAGS = new Set(['测款', '风格化']);
|
||
const SOURCE_TYPES = new Set(['product', 'store']);
|
||
|
||
let schemaReady = false;
|
||
let schemaPromise = null;
|
||
|
||
function serviceError(message, statusCode = 400, code = 'INVALID_REQUEST') {
|
||
const error = new Error(message);
|
||
error.statusCode = statusCode;
|
||
error.code = code;
|
||
return error;
|
||
}
|
||
|
||
function normalizeText(value, maxLength = 1000) {
|
||
return String(value ?? '').trim().slice(0, maxLength);
|
||
}
|
||
|
||
function normalizeProductId(value) {
|
||
const productId = normalizeText(value, 80);
|
||
if (!productId) throw serviceError('缺少 1688 商品 ID', 400, 'PRODUCT_ID_REQUIRED');
|
||
if (!/^[A-Za-z0-9_-]+$/.test(productId)) {
|
||
throw serviceError('1688 商品 ID 格式不正确', 400, 'INVALID_PRODUCT_ID');
|
||
}
|
||
return productId;
|
||
}
|
||
|
||
function normalizeNumber(value) {
|
||
if (value === null || value === undefined || value === '') return null;
|
||
const numeric = Number(value);
|
||
return Number.isFinite(numeric) ? numeric : null;
|
||
}
|
||
|
||
function uniqueTextValues(values, limit, maxLength = 2000) {
|
||
return [...new Set((Array.isArray(values) ? values : [])
|
||
.map(value => normalizeText(value, maxLength))
|
||
.filter(Boolean))].slice(0, limit);
|
||
}
|
||
|
||
function normalizeAlibaba1688ImageUrl(value) {
|
||
return normalizeText(value, 2000).replace(/_sum\.jpg(?=([?#]|$))/gi, '');
|
||
}
|
||
|
||
function uniqueImageValues(values, limit) {
|
||
return [...new Set((Array.isArray(values) ? values : [])
|
||
.map(normalizeAlibaba1688ImageUrl)
|
||
.filter(Boolean))].slice(0, limit);
|
||
}
|
||
|
||
function normalizeWebUrl(value, maxLength = 4000) {
|
||
const text = normalizeText(value, maxLength);
|
||
if (!text) return '';
|
||
try {
|
||
const url = new URL(text);
|
||
return ['http:', 'https:'].includes(url.protocol) ? url.href : '';
|
||
} catch {
|
||
return '';
|
||
}
|
||
}
|
||
|
||
function normalizeVideos(videos) {
|
||
const result = [];
|
||
for (const video of Array.isArray(videos) ? videos : []) {
|
||
const url = normalizeWebUrl(video?.url || video?.src || video?.downloadUrl || video?.previewUrl);
|
||
if (!url || result.some(item => item.url === url)) continue;
|
||
const duration = normalizeNumber(video?.duration);
|
||
const width = normalizeNumber(video?.width || video?.videoWidth);
|
||
const height = normalizeNumber(video?.height || video?.videoHeight);
|
||
result.push({
|
||
url,
|
||
coverUrl: normalizeAlibaba1688ImageUrl(video?.coverUrl || video?.poster),
|
||
duration: duration !== null && duration >= 0 ? duration : null,
|
||
width: width !== null && width > 0 ? width : null,
|
||
height: height !== null && height > 0 ? height : null,
|
||
mimeType: normalizeText(video?.mimeType || video?.type, 120),
|
||
source: normalizeText(video?.source, 120) || 'main-gallery',
|
||
});
|
||
}
|
||
return result.slice(0, 20);
|
||
}
|
||
|
||
function normalizeSpecs(specs) {
|
||
return (Array.isArray(specs) ? specs : [])
|
||
.map(spec => ({
|
||
name: normalizeText(spec?.name, 120),
|
||
value: normalizeText(spec?.value, 500),
|
||
}))
|
||
.filter(spec => spec.name && spec.value)
|
||
.slice(0, 4);
|
||
}
|
||
|
||
function normalizeSkus(skus) {
|
||
if (!Array.isArray(skus)) return [];
|
||
if (skus.length > MAX_SKU_COUNT) {
|
||
throw serviceError(`SKU 数量不能超过 ${MAX_SKU_COUNT}`, 413, 'SKU_LIMIT_EXCEEDED');
|
||
}
|
||
return skus.map((sku, index) => ({
|
||
index: Number(sku?.index) || index + 1,
|
||
name: normalizeText(sku?.name, 1200),
|
||
specs: normalizeSpecs(sku?.specs),
|
||
image: normalizeAlibaba1688ImageUrl(sku?.image),
|
||
quantity: Number(sku?.quantity) || 1,
|
||
priceText: normalizeText(sku?.priceText, 300),
|
||
price: normalizeNumber(sku?.price),
|
||
rowPriceText: normalizeText(sku?.rowPriceText, 300),
|
||
freightText: normalizeText(sku?.freightText, 300),
|
||
freight: normalizeNumber(sku?.freight),
|
||
stockText: normalizeText(sku?.stockText, 200),
|
||
stock: normalizeNumber(sku?.stock),
|
||
stockUnit: normalizeText(sku?.stockUnit, 80),
|
||
deliveryText: normalizeText(sku?.deliveryText, 500),
|
||
selectedText: normalizeText(sku?.selectedText, 1200),
|
||
available: sku?.available !== false,
|
||
collectedAt: normalizeText(sku?.collectedAt, 40),
|
||
}));
|
||
}
|
||
|
||
function normalizeDimensions(dimensions) {
|
||
return (Array.isArray(dimensions) ? dimensions : [])
|
||
.map(dimension => ({
|
||
name: normalizeText(dimension?.name, 120),
|
||
values: uniqueTextValues(dimension?.values, 500, 500),
|
||
}))
|
||
.filter(dimension => dimension.name && dimension.values.length)
|
||
.slice(0, 4);
|
||
}
|
||
|
||
function normalizeAttributes(attributes) {
|
||
return (Array.isArray(attributes) ? attributes : [])
|
||
.map(attribute => ({
|
||
name: normalizeText(attribute?.name, 160),
|
||
value: normalizeText(attribute?.value, 2000),
|
||
}))
|
||
.filter(attribute => attribute.name && attribute.value)
|
||
.slice(0, 500);
|
||
}
|
||
|
||
function toMysqlDateTime(value) {
|
||
if (!value) return null;
|
||
const date = new Date(value);
|
||
if (Number.isNaN(date.getTime())) return null;
|
||
return date.toISOString().slice(0, 19).replace('T', ' ');
|
||
}
|
||
|
||
function normalizeAlibaba1688Product(input = {}, routeProductId = '') {
|
||
const envelope = input && typeof input === 'object' ? input : {};
|
||
const raw = envelope.product && typeof envelope.product === 'object' ? envelope.product : envelope;
|
||
const productId = normalizeProductId(raw.productId || routeProductId);
|
||
if (routeProductId && productId !== normalizeProductId(routeProductId)) {
|
||
throw serviceError('请求路径与商品数据中的 1688 商品 ID 不一致', 409, 'PRODUCT_ID_MISMATCH');
|
||
}
|
||
|
||
const title = normalizeText(raw.title, 500);
|
||
if (!title) throw serviceError('商品标题不能为空', 422, 'TITLE_REQUIRED');
|
||
|
||
const detailImages = uniqueImageValues(raw.detailImages, 300);
|
||
if (!detailImages.length) {
|
||
throw serviceError('详情图不能为空,禁止同步不完整商品', 422, 'DETAIL_IMAGES_REQUIRED');
|
||
}
|
||
const mainImages = uniqueImageValues(
|
||
Array.isArray(raw.mainImages) && raw.mainImages.length ? raw.mainImages : [raw.mainImage],
|
||
100
|
||
);
|
||
if (!mainImages.length) {
|
||
throw serviceError('主图不能为空', 422, 'MAIN_IMAGES_REQUIRED');
|
||
}
|
||
|
||
const skus = normalizeSkus(raw.skus);
|
||
const baseSkuRows = normalizeSkus(raw.baseSkuRows);
|
||
const skuImages = uniqueImageValues(
|
||
Array.isArray(raw.skuImages) && raw.skuImages.length
|
||
? raw.skuImages
|
||
: [...skus, ...baseSkuRows].map(sku => sku.image),
|
||
MAX_SKU_COUNT
|
||
);
|
||
const skuDimensions = normalizeDimensions(raw.skuDimensions);
|
||
const attributes = normalizeAttributes(raw.attributes);
|
||
const videos = normalizeVideos(
|
||
Array.isArray(raw.videos) && raw.videos.length ? raw.videos : raw.skuScan?.videos
|
||
);
|
||
const importTag = IMPORT_TAGS.has(raw.importTag) ? raw.importTag : (envelope.sourceType === 'store' ? '风格化' : '测款');
|
||
const snapshot = {
|
||
schemaVersion: 2,
|
||
productId,
|
||
title,
|
||
description: normalizeText(raw.description, 20000),
|
||
sourceUrl: normalizeText(raw.sourceUrl || envelope.sourceUrl, 2000),
|
||
sourceStoreUrl: normalizeText(raw.sourceStoreUrl, 2000),
|
||
priceText: normalizeText(raw.priceText, 300),
|
||
minPrice: normalizeNumber(raw.minPrice),
|
||
maxPrice: normalizeNumber(raw.maxPrice),
|
||
images: uniqueImageValues(raw.images, 500),
|
||
mainImages,
|
||
mainImage: normalizeAlibaba1688ImageUrl(raw.mainImage || mainImages[0]),
|
||
detailImages,
|
||
videos,
|
||
skus,
|
||
skuImages,
|
||
baseSkuRows,
|
||
skuDimensions,
|
||
skuCombinationCount: Number(raw.skuCombinationCount) || skus.length,
|
||
skuScan: raw.skuScan && typeof raw.skuScan === 'object' ? raw.skuScan : null,
|
||
attributes,
|
||
skuValues: uniqueTextValues(raw.skuValues, 1000, 500),
|
||
storeName: normalizeText(raw.storeName, 300),
|
||
storeCategory: normalizeText(raw.storeCategory, 500),
|
||
platformCategory: normalizeText(raw.platformCategory, 500),
|
||
category: normalizeText(raw.category || raw.storeCategory || raw.platformCategory, 500),
|
||
dropshipSupported: raw.dropshipSupported == null ? null : Boolean(raw.dropshipSupported),
|
||
importTag,
|
||
collectedAt: normalizeText(raw.collectedAt, 40),
|
||
};
|
||
const snapshotJson = JSON.stringify(snapshot);
|
||
if (Buffer.byteLength(snapshotJson, 'utf8') > MAX_PRODUCT_SNAPSHOT_BYTES) {
|
||
throw serviceError('商品快照超过 12MB,禁止同步', 413, 'SNAPSHOT_TOO_LARGE');
|
||
}
|
||
|
||
return {
|
||
productId,
|
||
sourceUrl: snapshot.sourceUrl,
|
||
title,
|
||
description: snapshot.description,
|
||
storeName: snapshot.storeName,
|
||
category: snapshot.category,
|
||
importTag,
|
||
priceText: snapshot.priceText,
|
||
minPrice: snapshot.minPrice,
|
||
maxPrice: snapshot.maxPrice,
|
||
mainImageUrl: snapshot.mainImage,
|
||
mainImageCount: mainImages.length,
|
||
detailImageCount: detailImages.length,
|
||
skuCount: skus.length,
|
||
skuFingerprint: buildAlibaba1688SkuFingerprint(skus),
|
||
attributeCount: attributes.length,
|
||
snapshot,
|
||
snapshotJson,
|
||
sourceCollectedAt: toMysqlDateTime(snapshot.collectedAt),
|
||
syncOperatorName: normalizeText(
|
||
envelope.dingtalkUserName || envelope.operatorName || envelope.syncOperatorName,
|
||
120
|
||
),
|
||
sourceType: envelope.sourceType === 'store' ? 'store' : 'product',
|
||
sourceImportId: normalizeText(envelope.sourceImportId, 80),
|
||
};
|
||
}
|
||
|
||
async function ensureAlibaba1688ProductSchema() {
|
||
if (schemaReady) return;
|
||
if (schemaPromise) return schemaPromise;
|
||
schemaPromise = (async () => {
|
||
// Production planning DB accounts commonly have DML permissions only.
|
||
// MariaDB can still require CREATE permission for CREATE TABLE IF NOT EXISTS.
|
||
const existing = await planningDatabase.query(
|
||
`SELECT 1
|
||
FROM information_schema.tables
|
||
WHERE table_schema = DATABASE()
|
||
AND table_name = ?
|
||
LIMIT 1`,
|
||
[PRODUCT_TABLE]
|
||
);
|
||
if (existing.length) {
|
||
const fingerprintColumn = await planningDatabase.query(
|
||
`SELECT 1 AS ok
|
||
FROM information_schema.columns
|
||
WHERE table_schema = DATABASE()
|
||
AND table_name = ?
|
||
AND column_name = 'sku_fingerprint'
|
||
LIMIT 1`,
|
||
[PRODUCT_TABLE]
|
||
);
|
||
if (!fingerprintColumn.length) {
|
||
await planningDatabase.execute(
|
||
`ALTER TABLE ${PRODUCT_TABLE}
|
||
ADD COLUMN sku_fingerprint CHAR(64) NULL COMMENT '有序 SKU 规格指纹' AFTER sku_count`
|
||
);
|
||
}
|
||
return;
|
||
}
|
||
|
||
await planningDatabase.execute(
|
||
`CREATE TABLE IF NOT EXISTS ${PRODUCT_TABLE} (
|
||
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '服务端主键',
|
||
product_id VARCHAR(80) NOT NULL COMMENT '1688 商品 ID,全局唯一',
|
||
source_url VARCHAR(2000) NULL COMMENT '1688 商品链接',
|
||
title VARCHAR(500) NOT NULL COMMENT '商品标题',
|
||
description MEDIUMTEXT NULL COMMENT '商品描述',
|
||
store_name VARCHAR(300) NULL COMMENT '1688 店铺/厂家',
|
||
category VARCHAR(500) NULL COMMENT '1688 商品分类',
|
||
import_tag VARCHAR(40) NOT NULL DEFAULT '测款' COMMENT '测款/风格化',
|
||
price_text VARCHAR(300) NULL COMMENT '价格展示文本',
|
||
min_price DECIMAL(18,2) NULL COMMENT '最低采购价',
|
||
max_price DECIMAL(18,2) NULL COMMENT '最高采购价',
|
||
main_image_url VARCHAR(2000) NULL COMMENT '首张主图',
|
||
main_image_count INT NOT NULL DEFAULT 0 COMMENT '主图数量',
|
||
detail_image_count INT NOT NULL DEFAULT 0 COMMENT '详情图数量',
|
||
sku_count INT NOT NULL DEFAULT 0 COMMENT 'SKU 组合数量',
|
||
sku_fingerprint CHAR(64) NULL COMMENT '有序 SKU 规格指纹',
|
||
attribute_count INT NOT NULL DEFAULT 0 COMMENT '属性数量',
|
||
product_snapshot_json LONGTEXT NOT NULL COMMENT '客户端采集完整快照',
|
||
source_collected_at DATETIME NULL COMMENT '客户端采集时间',
|
||
sync_operator_name VARCHAR(120) NULL COMMENT '同步运营花名',
|
||
source_type VARCHAR(30) NOT NULL DEFAULT 'product' COMMENT 'product/store',
|
||
source_import_id VARCHAR(80) NULL COMMENT '客户端导入任务 ID',
|
||
api_key_id VARCHAR(120) NULL COMMENT '同步 API Key ID',
|
||
api_key_name VARCHAR(120) NULL COMMENT '同步 API Key 名称',
|
||
publish_status VARCHAR(30) NOT NULL DEFAULT 'pending' COMMENT '后续上架状态',
|
||
last_publish_error VARCHAR(1000) NULL COMMENT '最近上架失败原因',
|
||
published_goods_json LONGTEXT NULL COMMENT '各店铺上架商品 ID',
|
||
last_published_at DATETIME NULL COMMENT '最近上架完成时间',
|
||
sync_version INT NOT NULL DEFAULT 1 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_alibaba_1688_product_id (product_id),
|
||
KEY idx_alibaba_1688_product_store (store_name),
|
||
KEY idx_alibaba_1688_product_tag (import_tag, updated_at),
|
||
KEY idx_alibaba_1688_product_publish (publish_status, updated_at)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='1688 分销商品服务端快照'`
|
||
);
|
||
})().then(() => {
|
||
schemaReady = true;
|
||
}).catch((error) => {
|
||
schemaPromise = null;
|
||
throw error;
|
||
});
|
||
return schemaPromise;
|
||
}
|
||
|
||
function parseSnapshot(value) {
|
||
try {
|
||
return typeof value === 'string' ? JSON.parse(value) : value;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function mapProductRow(row, includeSnapshot = true) {
|
||
if (!row) return null;
|
||
const product = includeSnapshot ? parseSnapshot(row.product_snapshot_json) : undefined;
|
||
return {
|
||
productId: String(row.product_id),
|
||
title: row.title || '',
|
||
sourceUrl: row.source_url || '',
|
||
storeName: row.store_name || '',
|
||
category: row.category || '',
|
||
importTag: row.import_tag || '测款',
|
||
priceText: row.price_text || '',
|
||
minPrice: row.min_price == null ? null : Number(row.min_price),
|
||
maxPrice: row.max_price == null ? null : Number(row.max_price),
|
||
mainImage: row.main_image_url || '',
|
||
mainImageCount: Number(row.main_image_count) || 0,
|
||
detailImageCount: Number(row.detail_image_count) || 0,
|
||
skuCount: Number(row.sku_count) || 0,
|
||
skuFingerprint: row.sku_fingerprint || (
|
||
includeSnapshot ? buildAlibaba1688SkuFingerprint(product?.skus) : ''
|
||
),
|
||
attributeCount: Number(row.attribute_count) || 0,
|
||
publishStatus: row.publish_status || 'pending',
|
||
lastPublishError: row.last_publish_error || '',
|
||
publishedGoods: parseSnapshot(row.published_goods_json) || [],
|
||
syncVersion: Number(row.sync_version) || 1,
|
||
sourceCollectedAt: row.source_collected_at || null,
|
||
syncOperatorName: row.sync_operator_name || '',
|
||
sourceType: row.source_type || 'product',
|
||
sourceImportId: row.source_import_id || '',
|
||
createdAt: row.created_at || null,
|
||
updatedAt: row.updated_at || null,
|
||
...(includeSnapshot ? { product } : {}),
|
||
};
|
||
}
|
||
|
||
async function getAlibaba1688Product(productId, options = {}) {
|
||
await ensureAlibaba1688ProductSchema();
|
||
const normalizedId = normalizeProductId(productId);
|
||
const rows = await planningDatabase.query(
|
||
`SELECT * FROM ${PRODUCT_TABLE} WHERE product_id = ? LIMIT 1`,
|
||
[normalizedId]
|
||
);
|
||
if (!rows[0] && options.required !== false) {
|
||
throw serviceError('未找到该 1688 商品', 404, 'PRODUCT_NOT_FOUND');
|
||
}
|
||
return mapProductRow(rows[0], options.includeSnapshot !== false);
|
||
}
|
||
|
||
async function listAlibaba1688Products(input = {}) {
|
||
await ensureAlibaba1688ProductSchema();
|
||
const page = Math.max(1, Number.parseInt(input.page, 10) || 1);
|
||
const pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, Number.parseInt(input.pageSize, 10) || 20));
|
||
const baseConditions = [];
|
||
const baseParams = [];
|
||
const keyword = normalizeText(input.keyword, 200);
|
||
if (keyword) {
|
||
baseConditions.push('(product_id LIKE ? OR title LIKE ? OR store_name LIKE ? OR category LIKE ? OR sync_operator_name LIKE ?)');
|
||
const pattern = `%${keyword}%`;
|
||
baseParams.push(pattern, pattern, pattern, pattern, pattern);
|
||
}
|
||
if (IMPORT_TAGS.has(input.importTag)) {
|
||
baseConditions.push('import_tag = ?');
|
||
baseParams.push(input.importTag);
|
||
}
|
||
const sourceType = normalizeText(input.sourceType, 30);
|
||
if (SOURCE_TYPES.has(sourceType)) {
|
||
baseConditions.push('source_type = ?');
|
||
baseParams.push(sourceType);
|
||
}
|
||
const syncOperatorName = normalizeText(input.syncOperatorName, 120);
|
||
if (syncOperatorName) {
|
||
baseConditions.push('sync_operator_name LIKE ?');
|
||
baseParams.push(`%${syncOperatorName}%`);
|
||
}
|
||
const publishStatuses = uniqueTextValues(
|
||
Array.isArray(input.publishStatus)
|
||
? input.publishStatus
|
||
: String(input.publishStatus || '').split(','),
|
||
PUBLISH_STATUSES.size,
|
||
30
|
||
).filter(status => PUBLISH_STATUSES.has(status));
|
||
const conditions = [...baseConditions];
|
||
const params = [...baseParams];
|
||
if (publishStatuses.length) {
|
||
conditions.push(`publish_status IN (${publishStatuses.map(() => '?').join(', ')})`);
|
||
params.push(...publishStatuses);
|
||
}
|
||
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
||
const baseWhere = baseConditions.length ? `WHERE ${baseConditions.join(' AND ')}` : '';
|
||
const countRows = await planningDatabase.query(
|
||
`SELECT COUNT(*) AS total FROM ${PRODUCT_TABLE} ${where}`,
|
||
params
|
||
);
|
||
const summaryRows = await planningDatabase.query(
|
||
`SELECT publish_status, COUNT(*) AS total, MAX(updated_at) AS latest_updated_at
|
||
FROM ${PRODUCT_TABLE}
|
||
${baseWhere}
|
||
GROUP BY publish_status`,
|
||
baseParams
|
||
);
|
||
const rows = await planningDatabase.query(
|
||
`SELECT product_id, source_url, title, store_name, category, import_tag, price_text,
|
||
min_price, max_price, main_image_url, main_image_count, detail_image_count,
|
||
sku_count, sku_fingerprint, attribute_count, publish_status, last_publish_error,
|
||
published_goods_json, sync_version, source_collected_at, sync_operator_name,
|
||
source_type, source_import_id, created_at, updated_at
|
||
FROM ${PRODUCT_TABLE}
|
||
${where}
|
||
ORDER BY updated_at DESC, id DESC
|
||
LIMIT ? OFFSET ?`,
|
||
[...params, pageSize, (page - 1) * pageSize]
|
||
);
|
||
return {
|
||
items: rows.map(row => mapProductRow(row, false)),
|
||
page,
|
||
pageSize,
|
||
total: Number(countRows[0]?.total) || 0,
|
||
summary: Object.fromEntries(
|
||
[...PUBLISH_STATUSES].map(status => [
|
||
status,
|
||
Number(summaryRows.find(row => row.publish_status === status)?.total) || 0,
|
||
])
|
||
),
|
||
latestUpdatedAt: summaryRows.reduce((latest, row) => {
|
||
const value = row.latest_updated_at;
|
||
if (!value) return latest;
|
||
return !latest || new Date(value) > new Date(latest) ? value : latest;
|
||
}, null),
|
||
};
|
||
}
|
||
|
||
function productSqlValues(product, context = {}) {
|
||
return [
|
||
product.productId,
|
||
product.sourceUrl || null,
|
||
product.title,
|
||
product.description || null,
|
||
product.storeName || null,
|
||
product.category || null,
|
||
product.importTag,
|
||
product.priceText || null,
|
||
product.minPrice,
|
||
product.maxPrice,
|
||
product.mainImageUrl || null,
|
||
product.mainImageCount,
|
||
product.detailImageCount,
|
||
product.skuCount,
|
||
product.skuFingerprint,
|
||
product.attributeCount,
|
||
product.snapshotJson,
|
||
product.sourceCollectedAt,
|
||
product.syncOperatorName || null,
|
||
product.sourceType,
|
||
product.sourceImportId || null,
|
||
normalizeText(context.apiKeyId, 120) || null,
|
||
normalizeText(context.apiKeyName, 120) || null,
|
||
];
|
||
}
|
||
|
||
async function createAlibaba1688Product(input, context = {}) {
|
||
await ensureAlibaba1688ProductSchema();
|
||
const product = normalizeAlibaba1688Product(input);
|
||
const existing = await getAlibaba1688Product(product.productId, { required: false, includeSnapshot: false });
|
||
if (existing) throw serviceError('该 1688 商品 ID 已存在', 409, 'PRODUCT_ALREADY_EXISTS');
|
||
try {
|
||
await planningDatabase.execute(
|
||
`INSERT INTO ${PRODUCT_TABLE} (
|
||
product_id, source_url, title, description, store_name, category, import_tag,
|
||
price_text, min_price, max_price, main_image_url, main_image_count,
|
||
detail_image_count, sku_count, sku_fingerprint, attribute_count, product_snapshot_json,
|
||
source_collected_at, sync_operator_name, source_type, source_import_id,
|
||
api_key_id, api_key_name
|
||
) VALUES (${Array(23).fill('?').join(', ')})`,
|
||
productSqlValues(product, context)
|
||
);
|
||
} catch (error) {
|
||
if (error.code === 'ER_DUP_ENTRY') {
|
||
throw serviceError('该 1688 商品 ID 已存在', 409, 'PRODUCT_ALREADY_EXISTS');
|
||
}
|
||
throw error;
|
||
}
|
||
return { operation: 'created', item: await getAlibaba1688Product(product.productId) };
|
||
}
|
||
|
||
async function replaceAlibaba1688Product(productId, input, context = {}) {
|
||
await ensureAlibaba1688ProductSchema();
|
||
const product = normalizeAlibaba1688Product(input, productId);
|
||
const result = await planningDatabase.execute(
|
||
`INSERT INTO ${PRODUCT_TABLE} (
|
||
product_id, source_url, title, description, store_name, category, import_tag,
|
||
price_text, min_price, max_price, main_image_url, main_image_count,
|
||
detail_image_count, sku_count, sku_fingerprint, attribute_count, product_snapshot_json,
|
||
source_collected_at, sync_operator_name, source_type, source_import_id,
|
||
api_key_id, api_key_name
|
||
) VALUES (${Array(23).fill('?').join(', ')})
|
||
ON DUPLICATE KEY UPDATE
|
||
source_url = VALUES(source_url),
|
||
title = VALUES(title),
|
||
description = VALUES(description),
|
||
store_name = VALUES(store_name),
|
||
category = VALUES(category),
|
||
import_tag = VALUES(import_tag),
|
||
price_text = VALUES(price_text),
|
||
min_price = VALUES(min_price),
|
||
max_price = VALUES(max_price),
|
||
main_image_url = VALUES(main_image_url),
|
||
main_image_count = VALUES(main_image_count),
|
||
detail_image_count = VALUES(detail_image_count),
|
||
sku_count = VALUES(sku_count),
|
||
sku_fingerprint = VALUES(sku_fingerprint),
|
||
attribute_count = VALUES(attribute_count),
|
||
product_snapshot_json = VALUES(product_snapshot_json),
|
||
source_collected_at = VALUES(source_collected_at),
|
||
sync_operator_name = VALUES(sync_operator_name),
|
||
source_type = VALUES(source_type),
|
||
source_import_id = VALUES(source_import_id),
|
||
api_key_id = VALUES(api_key_id),
|
||
api_key_name = VALUES(api_key_name),
|
||
sync_version = sync_version + 1`,
|
||
productSqlValues(product, context)
|
||
);
|
||
return {
|
||
operation: Number(result?.affectedRows) === 1 ? 'created' : 'replaced',
|
||
item: await getAlibaba1688Product(product.productId),
|
||
};
|
||
}
|
||
|
||
async function updateAlibaba1688Product(productId, input = {}) {
|
||
await ensureAlibaba1688ProductSchema();
|
||
const normalizedId = normalizeProductId(productId);
|
||
const existing = await getAlibaba1688Product(normalizedId);
|
||
const updates = [];
|
||
const params = [];
|
||
if (input.importTag !== undefined) {
|
||
if (!IMPORT_TAGS.has(input.importTag)) throw serviceError('商品标记仅支持测款或风格化');
|
||
updates.push('import_tag = ?');
|
||
params.push(input.importTag);
|
||
updates.push('product_snapshot_json = ?');
|
||
params.push(JSON.stringify({ ...(existing.product || {}), importTag: input.importTag }));
|
||
}
|
||
if (input.publishStatus !== undefined) {
|
||
if (!PUBLISH_STATUSES.has(input.publishStatus)) throw serviceError('上架状态不正确');
|
||
updates.push('publish_status = ?');
|
||
params.push(input.publishStatus);
|
||
}
|
||
if (input.lastPublishError !== undefined) {
|
||
updates.push('last_publish_error = ?');
|
||
params.push(normalizeText(input.lastPublishError, 1000) || null);
|
||
}
|
||
if (input.publishedGoods !== undefined) {
|
||
updates.push('published_goods_json = ?');
|
||
params.push(JSON.stringify(Array.isArray(input.publishedGoods) ? input.publishedGoods : []));
|
||
}
|
||
if (!updates.length) throw serviceError('没有可修改的字段');
|
||
params.push(normalizedId);
|
||
const result = await planningDatabase.execute(
|
||
`UPDATE ${PRODUCT_TABLE} SET ${updates.join(', ')} WHERE product_id = ?`,
|
||
params
|
||
);
|
||
if (!result.affectedRows) return existing;
|
||
return getAlibaba1688Product(normalizedId);
|
||
}
|
||
|
||
async function deleteAlibaba1688Product(productId) {
|
||
await ensureAlibaba1688ProductSchema();
|
||
const normalizedId = normalizeProductId(productId);
|
||
const result = await planningDatabase.execute(
|
||
`DELETE FROM ${PRODUCT_TABLE} WHERE product_id = ?`,
|
||
[normalizedId]
|
||
);
|
||
if (!result.affectedRows) throw serviceError('未找到该 1688 商品', 404, 'PRODUCT_NOT_FOUND');
|
||
return { productId: normalizedId, deleted: true };
|
||
}
|
||
|
||
module.exports = {
|
||
PRODUCT_TABLE,
|
||
ensureAlibaba1688ProductSchema,
|
||
normalizeAlibaba1688ImageUrl,
|
||
normalizeAlibaba1688Product,
|
||
mapProductRow,
|
||
listAlibaba1688Products,
|
||
getAlibaba1688Product,
|
||
createAlibaba1688Product,
|
||
replaceAlibaba1688Product,
|
||
updateAlibaba1688Product,
|
||
deleteAlibaba1688Product,
|
||
};
|