cangku/backend/src/services/alibaba1688ProductEncodingRegistry.js

564 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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