564 lines
21 KiB
JavaScript
564 lines
21 KiB
JavaScript
'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,
|
||
};
|