'use strict'; const { randomUUID } = require('crypto'); const planningDatabase = require('./planningDatabase'); const ENCODING_TABLE = 'alibaba_1688_product_encodings'; const DEFAULT_CLAIM_TTL_SECONDS = 180; let schemaReady = false; let schemaPromise = null; function registryError(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 registryError('缺少 1688 商品 ID', 400, 'PRODUCT_ID_REQUIRED'); if (!/^[A-Za-z0-9_-]+$/.test(productId)) { throw registryError('1688 商品 ID 格式不正确', 400, 'INVALID_PRODUCT_ID'); } return productId; } function parseJson(value, fallback) { if (value === null || value === undefined || value === '') return fallback; if (typeof value === 'object') return value; try { return JSON.parse(value); } catch { return fallback; } } function getClaimTtlSeconds() { const configuredTimeoutMs = Math.min( Math.max(Number.parseInt(process.env.PRODUCT_CODE_API_TIMEOUT_MS, 10) || 30000, 1000), 300000 ); const minimumTtl = Math.ceil(configuredTimeoutMs / 1000) + 60; const configured = Number.parseInt(process.env.PRODUCT_CODE_CLAIM_TTL_SECONDS, 10); if (!Number.isFinite(configured) || configured < minimumTtl) { return Math.max(DEFAULT_CLAIM_TTL_SECONDS, minimumTtl); } return Math.min(configured, 30 * 60); } async function ensureEncodingRegistrySchema() { if (schemaReady) return; if (schemaPromise) return schemaPromise; schemaPromise = (async () => { const existing = await planningDatabase.query( `SELECT 1 AS ok FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = ? LIMIT 1`, [ENCODING_TABLE] ); if (existing.length) { const columns = await planningDatabase.query( `SELECT column_name FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = ?`, [ENCODING_TABLE] ); const columnNames = new Set(columns.map(row => String(row.column_name || row.COLUMN_NAME || ''))); const requiredColumns = [ ['source_sku_count', "INT NULL COMMENT '编码时的 SKU 数量' AFTER sku_mapping_json"], ['source_sku_fingerprint', "CHAR(64) NULL COMMENT '编码时的有序 SKU 规格指纹' AFTER source_sku_count"], ]; for (const [columnName, definition] of requiredColumns) { if (!columnNames.has(columnName)) { await planningDatabase.execute( `ALTER TABLE ${ENCODING_TABLE} ADD COLUMN ${columnName} ${definition}` ); } } return; } await planningDatabase.execute( `CREATE TABLE IF NOT EXISTS ${ENCODING_TABLE} ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '编码登记主键', product_id VARCHAR(80) NOT NULL COMMENT '1688 商品 ID,全局唯一且删除商品后仍保留', status VARCHAR(20) NOT NULL DEFAULT 'encoding' COMMENT 'encoding/encoded/failed/conflict', claim_token CHAR(36) NULL COMMENT '本次编码占用令牌', request_json LONGTEXT NULL COMMENT '提交编码服务的完整业务参数', response_json LONGTEXT NULL COMMENT '编码服务原始成功响应', style_code VARCHAR(160) NULL COMMENT '主款式编码', style_codes_json LONGTEXT NULL COMMENT '全部款式编码', product_codes_json LONGTEXT NULL COMMENT '全部规格商品编码', sku_mapping_json LONGTEXT NULL COMMENT '1688 SKU 与商品编码映射', source_sku_count INT NULL COMMENT '编码时的 SKU 数量', source_sku_fingerprint CHAR(64) NULL COMMENT '编码时的有序 SKU 规格指纹', upstream_created TINYINT(1) NULL COMMENT '上游本次是否新建编码', operator_user_id VARCHAR(120) NULL COMMENT '编码操作人用户 ID', operator_name VARCHAR(120) NULL COMMENT '编码操作人', operator_department VARCHAR(120) NULL COMMENT '编码操作人部门', attempt_count INT NOT NULL DEFAULT 1 COMMENT '提交尝试次数', started_at DATETIME NULL COMMENT '最近一次提交开始时间', encoded_at DATETIME NULL COMMENT '编码成功时间', last_error VARCHAR(1000) NULL 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_encoding_product (product_id), KEY idx_alibaba_1688_encoding_status (status, updated_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='1688 商品一次性编码登记'` ); })().then(() => { schemaReady = true; }).catch((error) => { schemaPromise = null; throw error; }); return schemaPromise; } function mapEncodingRow(row, includeDetails = true) { if (!row) return null; const styleCodes = parseJson(row.style_codes_json, []).filter(Boolean); const productCodes = parseJson(row.product_codes_json, []).filter(Boolean); return { productId: String(row.product_id), status: row.status || 'encoding', styleCode: row.style_code || styleCodes[0] || '', styleCodes, productCodes, productCodeCount: productCodes.length, sourceSkuCount: Number(row.source_sku_count) || productCodes.length, sourceSkuFingerprint: row.source_sku_fingerprint || '', upstreamCreated: row.upstream_created == null ? null : Boolean(row.upstream_created), operatorName: row.operator_name || '', operatorDepartment: row.operator_department || '', attemptCount: Number(row.attempt_count) || 0, startedAt: row.started_at || null, encodedAt: row.encoded_at || null, lastError: row.last_error || '', createdAt: row.created_at || null, updatedAt: row.updated_at || null, ...(includeDetails ? { request: parseJson(row.request_json, null), response: parseJson(row.response_json, null), skuMappings: parseJson(row.sku_mapping_json, []), } : {}), }; } async function getEncodingByProductId(productId, options = {}) { await ensureEncodingRegistrySchema(); const normalizedId = normalizeProductId(productId); const rows = await planningDatabase.query( `SELECT * FROM ${ENCODING_TABLE} WHERE product_id = ? LIMIT 1`, [normalizedId] ); return mapEncodingRow(rows[0], options.includeDetails !== false); } async function listEncodingSummaries(productIds) { await ensureEncodingRegistrySchema(); const ids = [...new Set((Array.isArray(productIds) ? productIds : []) .map(value => normalizeText(value, 80)) .filter(Boolean))]; if (!ids.length) return new Map(); const rows = await planningDatabase.query( `SELECT product_id, status, style_code, style_codes_json, product_codes_json, source_sku_count, source_sku_fingerprint, upstream_created, operator_name, operator_department, attempt_count, started_at, encoded_at, last_error, created_at, updated_at FROM ${ENCODING_TABLE} WHERE product_id IN (${ids.map(() => '?').join(', ')})`, ids ); return new Map(rows.map(row => [String(row.product_id), mapEncodingRow(row, false)])); } async function attachEncodingSummaries(items) { const normalizedItems = Array.isArray(items) ? items : []; const summaries = await listEncodingSummaries(normalizedItems.map(item => item.productId)); return normalizedItems.map((item) => { const encoding = summaries.get(String(item.productId)) || null; return { ...item, encoding: encoding ? { ...encoding, mappingCurrent: encoding.status === 'encoded' && Boolean(item.skuFingerprint) && encoding.sourceSkuFingerprint === item.skuFingerprint && encoding.sourceSkuCount === Number(item.skuCount), } : null, }; }); } async function attachEncodingDetail(item) { if (!item?.productId) return item; const encoding = await getEncodingByProductId(item.productId); return { ...item, encoding: encoding ? { ...encoding, mappingCurrent: encoding.status === 'encoded' && Boolean(item.skuFingerprint) && encoding.sourceSkuFingerprint === item.skuFingerprint && encoding.sourceSkuCount === Number(item.skuCount), } : null, }; } function actorValues(actor = {}) { return { userId: normalizeText(actor.id || actor.sub, 120), name: normalizeText(actor.name || actor.username, 120), department: normalizeText(actor.departmentName, 120), }; } function retryComparableRequest(payload = {}) { const { operatorName: _operatorName, operatorDepartment: _operatorDepartment, ...businessPayload } = payload && typeof payload === 'object' ? payload : {}; const skuItems = Array.isArray(businessPayload.skuItems) ? businessPayload.skuItems.map((item) => { const comparableItem = { ...(item && typeof item === 'object' ? item : {}) }; for (const field of RETRY_FILLABLE_DIMENSION_FIELDS) delete comparableItem[field]; return comparableItem; }) : businessPayload.skuItems; return JSON.stringify({ ...businessPayload, skuItems }); } const RETRY_FILLABLE_DIMENSION_FIELDS = ['lengthCm', 'widthCm', 'heightCm']; function isMissingDimension(value) { return value === null || value === undefined || value === ''; } function reconcileRetryRequest(existingPayload, incomingPayload) { const existing = existingPayload && typeof existingPayload === 'object' ? existingPayload : {}; const incoming = incomingPayload && typeof incomingPayload === 'object' ? incomingPayload : {}; const { operatorName: _existingOperatorName, operatorDepartment: _existingOperatorDepartment, skuItems: existingSkuItems, ...existingBusiness } = existing; const { operatorName: _incomingOperatorName, operatorDepartment: _incomingOperatorDepartment, skuItems: incomingSkuItems, ...incomingBusiness } = incoming; if (JSON.stringify(existingBusiness) !== JSON.stringify(incomingBusiness) || !Array.isArray(existingSkuItems) || !Array.isArray(incomingSkuItems) || existingSkuItems.length !== incomingSkuItems.length) { throw registryError( '该 1688 商品已经发起过正式编码;重试只能复用首次提交的完整参数', 409, 'PRODUCT_ENCODING_RETRY_REQUEST_CHANGED' ); } const upgradedSkuItems = existingSkuItems.map((existingItem, index) => { const incomingItem = incomingSkuItems[index]; if (!existingItem || typeof existingItem !== 'object' || !incomingItem || typeof incomingItem !== 'object') { throw registryError( '该 1688 商品的首次编码 SKU 参数不完整,无法安全重试', 409, 'PRODUCT_ENCODING_RETRY_REQUEST_CHANGED' ); } const existingImmutable = { ...existingItem }; const incomingImmutable = { ...incomingItem }; for (const field of RETRY_FILLABLE_DIMENSION_FIELDS) { delete existingImmutable[field]; delete incomingImmutable[field]; } if (JSON.stringify(existingImmutable) !== JSON.stringify(incomingImmutable)) { throw registryError( '该 1688 商品已经发起过正式编码;SKU 业务参数不允许在重试时修改', 409, 'PRODUCT_ENCODING_RETRY_REQUEST_CHANGED' ); } const upgradedItem = { ...existingItem }; for (const field of RETRY_FILLABLE_DIMENSION_FIELDS) { const existingValue = existingItem[field]; const incomingValue = incomingItem[field]; if (!isMissingDimension(existingValue)) { if (JSON.stringify(existingValue) !== JSON.stringify(incomingValue)) { throw registryError( '首次编码请求中已有的 SKU 长宽高不允许在重试时修改', 409, 'PRODUCT_ENCODING_RETRY_REQUEST_CHANGED' ); } continue; } if (!Number.isFinite(Number(incomingValue)) || Number(incomingValue) <= 0) { throw registryError( '历史编码请求缺少 SKU 长宽高,请先完成智能解析后再重试', 409, 'PRODUCT_ENCODING_RETRY_DIMENSIONS_REQUIRED' ); } upgradedItem[field] = incomingValue; } return upgradedItem; }); return { ...existing, skuItems: upgradedSkuItems, }; } function normalizeSourceSnapshot(sourceSnapshot, requestPayload) { const sourceSkuCount = Number(sourceSnapshot?.sourceSkuCount); const sourceSkuFingerprint = normalizeText(sourceSnapshot?.sourceSkuFingerprint, 64).toLowerCase(); if (!Number.isInteger(sourceSkuCount) || sourceSkuCount <= 0 || !/^[a-f0-9]{64}$/.test(sourceSkuFingerprint)) { throw registryError( '正式编码前必须固化有效的 SKU 数量和指纹', 409, 'PRODUCT_ENCODING_SOURCE_SNAPSHOT_REQUIRED' ); } const requestSkuCount = Array.isArray(requestPayload?.skuItems) ? requestPayload.skuItems.length : 0; if (requestSkuCount !== sourceSkuCount) { throw registryError( '编码请求的 SKU 数量与当前商品不一致', 409, 'PRODUCT_ENCODING_SOURCE_SKU_CHANGED' ); } return { sourceSkuCount, sourceSkuFingerprint }; } async function claimEncoding(productId, requestPayload, actor = {}, sourceSnapshot = {}) { await ensureEncodingRegistrySchema(); const normalizedId = normalizeProductId(productId); const token = randomUUID(); const actorData = actorValues(actor); const requestJson = JSON.stringify(requestPayload || {}); const knownEncoding = await getEncodingByProductId(normalizedId); if (knownEncoding?.status === 'encoded') { return { claimed: false, token: '', record: knownEncoding }; } const snapshot = normalizeSourceSnapshot(sourceSnapshot, requestPayload); try { await planningDatabase.execute( `INSERT INTO ${ENCODING_TABLE} ( product_id, status, claim_token, request_json, source_sku_count, source_sku_fingerprint, operator_user_id, operator_name, operator_department, attempt_count, started_at ) VALUES (?, 'encoding', ?, ?, ?, ?, ?, ?, ?, 1, NOW())`, [ normalizedId, token, requestJson, snapshot.sourceSkuCount, snapshot.sourceSkuFingerprint, actorData.userId || null, actorData.name || null, actorData.department || null, ] ); return { claimed: true, token, requestPayload, record: await getEncodingByProductId(normalizedId), }; } catch (error) { if (error?.code !== 'ER_DUP_ENTRY' && Number(error?.errno) !== 1062) throw error; } const existing = await getEncodingByProductId(normalizedId); if (existing?.status === 'encoded') { return { claimed: false, token: '', record: existing }; } const savedRequestSkuCount = Array.isArray(existing?.request?.skuItems) ? existing.request.skuItems.length : 0; const savedSourceSkuCount = Number(existing?.sourceSkuCount) || savedRequestSkuCount; if (savedSourceSkuCount !== snapshot.sourceSkuCount || (existing?.sourceSkuFingerprint && existing.sourceSkuFingerprint !== snapshot.sourceSkuFingerprint)) { throw registryError( '该 1688 商品的 SKU 已在首次编码后发生变化,不能重试或自动映射', 409, 'PRODUCT_ENCODING_SOURCE_SKU_CHANGED' ); } const upgradedRequest = reconcileRetryRequest(existing?.request, requestPayload); const upgradedRequestJson = JSON.stringify(upgradedRequest); if (retryComparableRequest(existing?.request) !== retryComparableRequest(requestPayload)) { throw registryError( '该 1688 商品已经发起过正式编码;重试只能复用首次提交的完整参数', 409, 'PRODUCT_ENCODING_RETRY_REQUEST_CHANGED' ); } const claimTtlSeconds = getClaimTtlSeconds(); const result = await planningDatabase.execute( `UPDATE ${ENCODING_TABLE} SET status = 'encoding', claim_token = ?, request_json = ?, source_sku_count = COALESCE(source_sku_count, ?), source_sku_fingerprint = COALESCE(source_sku_fingerprint, ?), operator_user_id = ?, operator_name = ?, operator_department = ?, attempt_count = attempt_count + 1, started_at = NOW(), last_error = NULL WHERE product_id = ? AND (source_sku_count IS NULL OR source_sku_count = ?) AND (source_sku_fingerprint IS NULL OR source_sku_fingerprint = ?) AND (status IN ('failed', 'conflict') OR (status = 'encoding' AND started_at < DATE_SUB(NOW(), INTERVAL ${claimTtlSeconds} SECOND)))`, [ token, upgradedRequestJson, snapshot.sourceSkuCount, snapshot.sourceSkuFingerprint, actorData.userId || null, actorData.name || null, actorData.department || null, normalizedId, snapshot.sourceSkuCount, snapshot.sourceSkuFingerprint, ] ); if (Number(result?.affectedRows) === 1) { return { claimed: true, token, requestPayload: upgradedRequest, record: await getEncodingByProductId(normalizedId), }; } const latest = await getEncodingByProductId(normalizedId); if (latest?.status === 'encoded') { return { claimed: false, token: '', record: latest }; } throw registryError('该 1688 商品正在编码,请勿重复提交', 409, 'PRODUCT_ENCODING_IN_PROGRESS'); } async function completeEncoding(productId, token, result = {}) { const normalizedId = normalizeProductId(productId); const styleCodes = Array.isArray(result.styleCodes) ? result.styleCodes.filter(Boolean) : []; const productCodes = Array.isArray(result.productCodes) ? result.productCodes.filter(Boolean) : []; const skuMappings = Array.isArray(result.skuMappings) ? result.skuMappings : []; const updateResult = await planningDatabase.execute( `UPDATE ${ENCODING_TABLE} SET status = 'encoded', claim_token = NULL, response_json = ?, style_code = ?, style_codes_json = ?, product_codes_json = ?, sku_mapping_json = ?, upstream_created = ?, encoded_at = NOW(), last_error = NULL WHERE product_id = ? AND status = 'encoding' AND claim_token = ? AND source_sku_count = ? AND source_sku_fingerprint = ? AND EXISTS ( SELECT 1 FROM alibaba_1688_products source_product WHERE source_product.product_id = ${ENCODING_TABLE}.product_id AND source_product.sku_count = ? AND source_product.sku_fingerprint = ? )`, [ JSON.stringify(result.response || {}), normalizeText(result.styleCode || styleCodes[0], 160) || null, JSON.stringify(styleCodes), JSON.stringify(productCodes), JSON.stringify(skuMappings), result.upstreamCreated == null ? null : (result.upstreamCreated ? 1 : 0), normalizedId, token, Number(result.sourceSkuCount) || productCodes.length, normalizeText(result.sourceSkuFingerprint, 64), Number(result.sourceSkuCount) || productCodes.length, normalizeText(result.sourceSkuFingerprint, 64), ] ); if (Number(updateResult?.affectedRows) !== 1) { const existing = await getEncodingByProductId(normalizedId); if (existing?.status === 'encoded') return existing; const productRows = await planningDatabase.query( `SELECT sku_count, sku_fingerprint FROM alibaba_1688_products WHERE product_id = ? LIMIT 1`, [normalizedId] ); if (Number(productRows[0]?.sku_count) !== Number(result.sourceSkuCount) || productRows[0]?.sku_fingerprint !== normalizeText(result.sourceSkuFingerprint, 64)) { throw registryError( '商品在编码结果保存前发生了 SKU 变化;已禁止自动匹配和上架', 409, 'PRODUCT_CODE_SKU_CHANGED_DURING_ENCODING' ); } throw registryError('编码结果保存失败,当前请求已失去占用权', 409, 'PRODUCT_ENCODING_CLAIM_LOST'); } return getEncodingByProductId(normalizedId); } async function failEncoding(productId, token, error, response = null) { const normalizedId = normalizeProductId(productId); await planningDatabase.execute( `UPDATE ${ENCODING_TABLE} SET status = 'failed', claim_token = NULL, last_error = ?, response_json = COALESCE(?, response_json) WHERE product_id = ? AND status = 'encoding' AND claim_token = ?`, [ normalizeText(error?.message || error, 1000) || '商品编码失败', response == null ? null : JSON.stringify(response), normalizedId, token, ] ); } async function conflictEncoding(productId, token, error, response = null) { const normalizedId = normalizeProductId(productId); await planningDatabase.execute( `UPDATE ${ENCODING_TABLE} SET status = 'conflict', claim_token = NULL, last_error = ?, response_json = ? WHERE product_id = ? AND status = 'encoding' AND claim_token = ?`, [ normalizeText(error?.message || error, 1000) || '商品已有编码,但 SKU 映射冲突', response == null ? null : JSON.stringify(response), normalizedId, token, ] ); } module.exports = { ENCODING_TABLE, DEFAULT_CLAIM_TTL_SECONDS, getClaimTtlSeconds, ensureEncodingRegistrySchema, mapEncodingRow, retryComparableRequest, reconcileRetryRequest, normalizeSourceSnapshot, getEncodingByProductId, listEncodingSummaries, attachEncodingSummaries, attachEncodingDetail, claimEncoding, completeEncoding, failEncoding, conflictEncoding, };