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