cangku/backend/src/services/alibaba1688ProductEncodingService.js

493 lines
19 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 {
PRODUCT_TABLE,
getAlibaba1688Product,
} = require('./alibaba1688ProductService');
const planningDatabase = require('./planningDatabase');
const {
buildAlibaba1688SkuKey,
buildAlibaba1688SkuFingerprint,
} = require('./alibaba1688SkuIdentity');
const productCodeClient = require('./alibaba1688ProductCodeClient');
const encodingRegistry = require('./alibaba1688ProductEncodingRegistry');
const MAX_CODING_SKUS = 200;
const MAX_PRODUCT_NAME_LENGTH = 255;
const MAX_COLOR_SPEC_LENGTH = 255;
let metaCache = null;
function encodingError(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 requiredText(value, label, maxLength = 255) {
const raw = String(value ?? '').trim();
if (!raw) throw encodingError(`${label}不能为空`, 400, 'PRODUCT_CODE_FIELD_REQUIRED');
if (raw.length > maxLength) {
throw encodingError(`${label}不能超过 ${maxLength} 个字符`, 400, 'PRODUCT_CODE_FIELD_TOO_LONG');
}
return raw;
}
function optionalNumber(value, label, { allowZero = false } = {}) {
if (value === null || value === undefined || value === '') return null;
const number = Number(value);
if (!Number.isFinite(number) || (allowZero ? number < 0 : number <= 0)) {
throw encodingError(`${label}${allowZero ? '不能小于 0' : '必须大于 0'}`, 400, 'PRODUCT_CODE_NUMBER_INVALID');
}
return number;
}
function sourceSkuKey(sku, position) {
return buildAlibaba1688SkuKey(sku, position);
}
function normalizeCodingRequest(input = {}, productRecord, actor = {}) {
const sourceSkus = Array.isArray(productRecord?.product?.skus) ? productRecord.product.skus : [];
if (!sourceSkus.length) {
throw encodingError('该 1688 商品没有可编码的 SKU', 422, 'PRODUCT_CODE_SKUS_REQUIRED');
}
if (sourceSkus.length > MAX_CODING_SKUS) {
throw encodingError(
`该商品共有 ${sourceSkus.length} 个 SKU,编码接口单次最多支持 ${MAX_CODING_SKUS} 个;请先确认最终上架规格`,
422,
'PRODUCT_CODE_SKU_LIMIT_EXCEEDED'
);
}
const inputSkus = Array.isArray(input.skuItems) ? input.skuItems : [];
if (inputSkus.length !== sourceSkus.length) {
throw encodingError(
`必须一次提交该商品全部 ${sourceSkus.length} 个 SKU,当前提交了 ${inputSkus.length} 个`,
422,
'PRODUCT_CODE_SKU_COUNT_MISMATCH'
);
}
const selections = {
operationCenter: requiredText(input.operationCenter, '运营中心', 120),
categoryName: requiredText(input.categoryName, '编码分类', 255),
taxStatus: requiredText(input.taxStatus, '是否含税', 80),
invoiceType: requiredText(input.invoiceType, '开票类型', 80),
installationService: requiredText(input.installationService, '安装服务', 120),
installationMode: requiredText(input.installationMode, '安装方式', 80),
};
const normalizedSkus = sourceSkus.map((sourceSku, index) => {
const raw = inputSkus[index] || {};
const expectedSourceSkuKey = sourceSkuKey(sourceSku, index);
if (requiredText(raw.sourceSkuKey, `第 ${index + 1} 个 SKU 的来源标识`, 4000) !== expectedSourceSkuKey) {
throw encodingError(
`第 ${index + 1} 个 SKU 已发生变化,请关闭编码窗口后重新加载`,
409,
'PRODUCT_CODE_SKU_SNAPSHOT_CHANGED'
);
}
const productName = requiredText(raw.productName, `第 ${index + 1} 个 SKU 的商品名称`, MAX_PRODUCT_NAME_LENGTH);
const colorSpecRaw = String(raw.colorSpec ?? '').trim();
const colorNameRaw = String(raw.colorName ?? '').trim();
if (!colorSpecRaw && !colorNameRaw) {
throw encodingError(`第 ${index + 1} 个 SKU 缺少颜色及规格`, 400, 'PRODUCT_CODE_SKU_SPEC_REQUIRED');
}
if (colorSpecRaw.length > MAX_COLOR_SPEC_LENGTH || colorNameRaw.length > MAX_COLOR_SPEC_LENGTH) {
throw encodingError(
`第 ${index + 1} 个 SKU 的颜色及规格不能超过 ${MAX_COLOR_SPEC_LENGTH} 个字符`,
400,
'PRODUCT_CODE_FIELD_TOO_LONG'
);
}
const lengthCm = optionalNumber(raw.lengthCm, `第 ${index + 1} 个 SKU 的长度`);
const widthCm = optionalNumber(raw.widthCm, `第 ${index + 1} 个 SKU 的宽度`);
const heightCm = optionalNumber(raw.heightCm, `第 ${index + 1} 个 SKU 的高度`);
if (lengthCm == null || widthCm == null || heightCm == null) {
throw encodingError(
`第 ${index + 1} 个 SKU 的长宽高必须填写完整`,
400,
'PRODUCT_CODE_SKU_DIMENSIONS_REQUIRED'
);
}
const weight = optionalNumber(raw.weight, `第 ${index + 1} 个 SKU 的重量`, { allowZero: true });
return {
sourceSku,
sourceSkuKey: expectedSourceSkuKey,
requestItem: {
productName,
...(colorSpecRaw ? { colorSpec: colorSpecRaw } : { colorName: colorNameRaw }),
...(lengthCm == null ? {} : { lengthCm }),
...(widthCm == null ? {} : { widthCm }),
...(heightCm == null ? {} : { heightCm }),
...(weight == null ? {} : { weight }),
},
};
});
const operatorName = normalizeText(actor.name || actor.username, 100) || 'maixiang';
const requestPayload = {
...selections,
ali1688ProductId: requiredText(productRecord.productId, '1688 商品 ID', 100),
ali1688SupplierName: normalizeText(productRecord.storeName, 255),
sourceSystem: 'maixiang',
operatorName,
operatorDepartment: normalizeText(actor.departmentName, 100),
skuItems: normalizedSkus.map(item => item.requestItem),
};
return {
requestPayload,
sourceSkus: normalizedSkus,
sourceSkuFingerprint: buildAlibaba1688SkuFingerprint(sourceSkus),
};
}
function resolveUpstreamSkuOrder(upstreamData, responseItems, normalizedRequest) {
const fallbackCodes = Array.isArray(upstreamData.productCodes) ? upstreamData.productCodes : [];
const itemsWithCodes = responseItems.map((item, index) => ({
...item,
productCode: normalizeText(item?.productCode || fallbackCodes[index], 200),
}));
if (upstreamData.created !== false) {
const productCodes = (fallbackCodes.length ? fallbackCodes : itemsWithCodes.map(item => item.productCode))
.map(value => normalizeText(value, 200))
.filter(Boolean);
return { orderedItems: itemsWithCodes, productCodes };
}
if (itemsWithCodes.length !== normalizedRequest.sourceSkus.length) {
throw encodingError(
'该 1688 商品已在编码服务中存在,但历史 SKU 明细不足以安全匹配当前规格',
409,
'PRODUCT_CODE_REUSED_MAPPING_CONFLICT'
);
}
const usedIndexes = new Set();
const orderedItems = normalizedRequest.sourceSkus.map((entry) => {
const requestName = normalizeText(entry.requestItem.productName, MAX_PRODUCT_NAME_LENGTH);
const requestSpec = normalizeText(entry.requestItem.colorSpec || entry.requestItem.colorName, MAX_COLOR_SPEC_LENGTH);
const candidateIndexes = itemsWithCodes
.map((item, index) => ({ item, index }))
.filter(({ item, index }) => {
if (usedIndexes.has(index) || !item.productCode) return false;
if (normalizeText(item.productName, MAX_PRODUCT_NAME_LENGTH) !== requestName) return false;
const responseSpec = normalizeText(item.colorSpec, MAX_COLOR_SPEC_LENGTH);
return !responseSpec || responseSpec === requestSpec;
})
.map(({ index }) => index);
if (candidateIndexes.length !== 1) {
throw encodingError(
`已有编码无法唯一匹配 SKU“${requestName}”,已停止后续上架`,
409,
'PRODUCT_CODE_REUSED_MAPPING_CONFLICT'
);
}
usedIndexes.add(candidateIndexes[0]);
return itemsWithCodes[candidateIndexes[0]];
});
return {
orderedItems,
productCodes: orderedItems.map(item => item.productCode),
};
}
function normalizeUpstreamResult(upstreamData = {}, normalizedRequest) {
const responseItems = Array.isArray(upstreamData.items) ? upstreamData.items : [];
const { orderedItems, productCodes } = resolveUpstreamSkuOrder(
upstreamData,
responseItems,
normalizedRequest
);
if (productCodes.length !== normalizedRequest.sourceSkus.length) {
throw encodingError(
`编码服务返回 ${productCodes.length} 个商品编码,但当前商品有 ${normalizedRequest.sourceSkus.length} 个 SKU;已停止后续上架,请核对该 1688 ID 的历史编码`,
409,
'PRODUCT_CODE_RESPONSE_COUNT_MISMATCH'
);
}
if (new Set(productCodes).size !== productCodes.length) {
throw encodingError(
'编码服务返回了重复商品编码,已停止后续上架',
409,
'PRODUCT_CODE_RESPONSE_DUPLICATE'
);
}
const mismatchedProductId = responseItems
.map(item => normalizeText(item?.ali1688ProductId, 100))
.filter(Boolean)
.find(value => value !== normalizedRequest.requestPayload.ali1688ProductId);
if (mismatchedProductId) {
throw encodingError(
'编码服务返回的 1688 商品 ID 与当前商品不一致,已停止后续上架',
409,
'PRODUCT_CODE_RESPONSE_PRODUCT_MISMATCH'
);
}
const styleCodes = [...new Set([
...(Array.isArray(upstreamData.styleCodes) ? upstreamData.styleCodes : []),
upstreamData.styleCode,
...orderedItems.map(item => item?.styleCode),
].map(value => normalizeText(value, 200)).filter(Boolean))];
const skuMappings = normalizedRequest.sourceSkus.map((entry, index) => ({
sourceSkuKey: entry.sourceSkuKey,
sourceSkuIndex: Number(entry.sourceSku?.index) || index + 1,
sourceSkuName: normalizeText(entry.sourceSku?.name, 1200),
sourceSpecs: Array.isArray(entry.sourceSku?.specs) ? entry.sourceSku.specs : [],
productName: entry.requestItem.productName,
colorSpec: entry.requestItem.colorSpec || entry.requestItem.colorName || '',
lengthCm: entry.requestItem.lengthCm ?? null,
widthCm: entry.requestItem.widthCm ?? null,
heightCm: entry.requestItem.heightCm ?? null,
weight: entry.requestItem.weight ?? null,
styleCode: normalizeText(orderedItems[index]?.styleCode || upstreamData.styleCode || styleCodes[0], 200),
productCode: productCodes[index],
}));
return {
styleCode: normalizeText(upstreamData.styleCode || styleCodes[0], 160),
styleCodes,
productCodes,
skuMappings,
sourceSkuCount: normalizedRequest.sourceSkus.length,
sourceSkuFingerprint: normalizedRequest.sourceSkuFingerprint,
upstreamCreated: upstreamData.created !== false,
};
}
async function getAlibaba1688ProductCodeMeta(options = {}) {
const now = Date.now();
if (!options.force && metaCache && metaCache.expiresAt > now) return metaCache.value;
const response = await (options.getMeta || productCodeClient.getProductCodeMeta)(options);
metaCache = {
value: response.data,
expiresAt: now + 5 * 60 * 1000,
};
return response.data;
}
async function encodeAlibaba1688Product(productId, input = {}, actor = {}, options = {}) {
const productRecord = await (options.getProduct || getAlibaba1688Product)(productId);
const shouldLookupExisting = Boolean(options.getExistingEncoding)
|| (!options.getProduct && !options.claim);
if (shouldLookupExisting) {
const existing = await (
options.getExistingEncoding || encodingRegistry.getEncodingByProductId
)(productId);
if (existing?.status === 'encoded') {
return {
alreadyEncoded: true,
encoding: existing,
};
}
}
const normalized = normalizeCodingRequest(input, productRecord, actor);
if (!options.getProduct || options.persistProductFingerprint) {
await (options.persistProductFingerprint || planningDatabase.execute)(
`UPDATE ${PRODUCT_TABLE}
SET sku_count = ?, sku_fingerprint = ?
WHERE product_id = ? AND sync_version = ?
AND (sku_fingerprint IS NULL OR sku_fingerprint = '' OR sku_fingerprint = ?)`,
[
normalized.sourceSkus.length,
normalized.sourceSkuFingerprint,
productRecord.productId,
Number(productRecord.syncVersion) || 1,
normalized.sourceSkuFingerprint,
]
);
}
const preparedProduct = await (options.getPreparedProduct || options.getProduct || getAlibaba1688Product)(
productRecord.productId
);
const preparedFingerprint = preparedProduct.skuFingerprint
|| buildAlibaba1688SkuFingerprint(preparedProduct.product?.skus);
if (preparedFingerprint !== normalized.sourceSkuFingerprint) {
throw encodingError(
'商品在编码确认前发生了 SKU 变化,请关闭编码窗口后重新加载',
409,
'PRODUCT_CODE_SKU_SNAPSHOT_CHANGED'
);
}
const claim = await (options.claim || encodingRegistry.claimEncoding)(
productRecord.productId,
normalized.requestPayload,
actor,
{
sourceSkuCount: normalized.sourceSkus.length,
sourceSkuFingerprint: normalized.sourceSkuFingerprint,
}
);
if (!claim.claimed) {
return {
alreadyEncoded: true,
encoding: claim.record,
};
}
let upstreamResponse = null;
try {
const outboundPayload = claim.requestPayload || normalized.requestPayload;
upstreamResponse = await (options.createCodes || productCodeClient.createProductCodes)(outboundPayload, options);
const result = normalizeUpstreamResult(upstreamResponse.data, normalized);
const latestProduct = await (options.getLatestProduct || options.getProduct || getAlibaba1688Product)(
productRecord.productId
);
const latestFingerprint = latestProduct.skuFingerprint
|| buildAlibaba1688SkuFingerprint(latestProduct.product?.skus);
if (latestFingerprint !== normalized.sourceSkuFingerprint) {
throw encodingError(
'商品在编码请求执行期间发生了 SKU 变化;远端编码已保留,但已禁止自动匹配和上架',
409,
'PRODUCT_CODE_SKU_CHANGED_DURING_ENCODING'
);
}
const encoding = await (options.complete || encodingRegistry.completeEncoding)(
productRecord.productId,
claim.token,
{
...result,
response: upstreamResponse.data,
}
);
return {
alreadyEncoded: false,
encoding,
};
} catch (error) {
try {
const conflictCodes = new Set([
'PRODUCT_CODE_RESPONSE_COUNT_MISMATCH',
'PRODUCT_CODE_RESPONSE_DUPLICATE',
'PRODUCT_CODE_RESPONSE_PRODUCT_MISMATCH',
'PRODUCT_CODE_REUSED_MAPPING_CONFLICT',
'PRODUCT_CODE_SKU_CHANGED_DURING_ENCODING',
]);
const saveFailure = conflictCodes.has(error.code)
? (options.conflict || encodingRegistry.conflictEncoding)
: (options.fail || encodingRegistry.failEncoding);
await saveFailure(
productRecord.productId,
claim.token,
error,
upstreamResponse?.data || null
);
} catch (saveError) {
console.error('保存1688商品编码失败状态失败:', saveError.message);
}
throw error;
}
}
async function assertAlibaba1688PlanItemsEncoded(items, options = {}) {
const rows = Array.isArray(items) ? items : [];
const hydratedRows = [];
for (const row of rows) {
const sourceRow = row?.sourceRow && typeof row.sourceRow === 'object' ? row.sourceRow : {};
const declaredAlibaba1688 = normalizeText(row?.channelName, 40).toLowerCase() === '1688'
|| normalizeText(sourceRow.sourceChannel, 40).toLowerCase() === '1688'
|| normalizeText(sourceRow.source, 80) === 'alibaba_1688_products';
if (!declaredAlibaba1688) {
hydratedRows.push(row);
continue;
}
const productId = normalizeText(row?.marketItemId || row?.sourceRow?.productId, 80);
if (!productId || !/^[A-Za-z0-9_-]+$/.test(productId)) {
throw encodingError('1688 选品缺少有效商品 ID', 400, 'ALIBABA_1688_PRODUCT_ID_INVALID');
}
const product = await (options.getProduct || getAlibaba1688Product)(productId, { required: false });
if (!product) {
throw encodingError(
`未找到 1688 商品 ${productId},不能进入选品流程`,
404,
'ALIBABA_1688_PRODUCT_NOT_FOUND'
);
}
const encoding = await (options.getEncoding || encodingRegistry.getEncodingByProductId)(productId);
if (!encoding || encoding.status !== 'encoded') {
throw encodingError(
`1688 商品 ${productId} 尚未完成编码,不能进入选品流程`,
409,
'ALIBABA_1688_PRODUCT_NOT_ENCODED'
);
}
const sourceSkus = Array.isArray(product.product?.skus) ? product.product.skus : [];
const currentSkuCount = sourceSkus.length;
const calculatedFingerprint = currentSkuCount
? buildAlibaba1688SkuFingerprint(sourceSkus)
: '';
const currentFingerprint = product.skuFingerprint || calculatedFingerprint;
const expectedSourceSkuKeys = sourceSkus.map((sku, index) => sourceSkuKey(sku, index));
const productCodes = Array.isArray(encoding.productCodes) ? encoding.productCodes : [];
const skuMappings = Array.isArray(encoding.skuMappings) ? encoding.skuMappings : [];
const mappingSourceSkuKeys = skuMappings.map(mapping => normalizeText(mapping?.sourceSkuKey, 4000));
const mappingProductCodes = skuMappings.map(mapping => normalizeText(mapping?.productCode, 200));
const expectedSourceSkuKeySet = new Set(expectedSourceSkuKeys);
const productCodeSet = new Set(productCodes);
const encodingIsComplete = currentSkuCount > 0
&& Number(product.skuCount) === currentSkuCount
&& (!product.skuFingerprint || product.skuFingerprint === calculatedFingerprint)
&& Number(encoding.sourceSkuCount) === currentSkuCount
&& encoding.sourceSkuFingerprint === currentFingerprint
&& productCodes.length === currentSkuCount
&& new Set(productCodes).size === currentSkuCount
&& productCodes.every(Boolean)
&& skuMappings.length === currentSkuCount
&& new Set(mappingSourceSkuKeys).size === currentSkuCount
&& new Set(mappingProductCodes).size === currentSkuCount
&& mappingSourceSkuKeys.every(key => expectedSourceSkuKeySet.has(key))
&& mappingProductCodes.every(code => productCodeSet.has(code));
if (!encodingIsComplete) {
throw encodingError(
`1688 商品 ${productId} 的 SKU 已在编码后发生变化,请先处理编码映射冲突`,
409,
'ALIBABA_1688_PRODUCT_ENCODING_STALE'
);
}
hydratedRows.push({
...row,
channelName: '1688',
sourceRow: {
...(row.sourceRow && typeof row.sourceRow === 'object' ? row.sourceRow : {}),
...product,
encoding: {
status: encoding.status,
styleCode: encoding.styleCode,
styleCodes: encoding.styleCodes,
productCodes: encoding.productCodes,
skuMappings: encoding.skuMappings,
sourceSkuCount: encoding.sourceSkuCount,
sourceSkuFingerprint: encoding.sourceSkuFingerprint,
encodedAt: encoding.encodedAt,
},
source: 'alibaba_1688_products',
sourceChannel: '1688',
},
});
}
return hydratedRows;
}
module.exports = {
MAX_CODING_SKUS,
MAX_PRODUCT_NAME_LENGTH,
MAX_COLOR_SPEC_LENGTH,
sourceSkuKey,
normalizeCodingRequest,
resolveUpstreamSkuOrder,
normalizeUpstreamResult,
getAlibaba1688ProductCodeMeta,
encodeAlibaba1688Product,
assertAlibaba1688PlanItemsEncoded,
};