前面的ok了, 绑定与角色

This commit is contained in:
yml
2026-05-03 20:58:18 +08:00
parent dd2192b6ae
commit 0f0253bffe
30 changed files with 1900 additions and 409 deletions
@@ -25,6 +25,7 @@ import {
getKuaishouEticketSourceConfig,
getKuaishouEticketSourceFilePath,
listKuaishouEticketShopConfigs,
resolveKuaishouEticketShopConfig,
saveKuaishouEticketSourceConfig,
} from '../platforms/kuaishou-eticket/source-config-service.js'
import { queryKuaishouEticketStableInfo } from '../platforms/kuaishou-eticket/info-service.js'
@@ -991,8 +992,8 @@ export async function updateAdminFulfillmentBindingConfigs(
payload = /** @type {AdminFulfillmentBindingConfigSaveInput} */ ({}),
) {
const bindingsInput = Array.isArray(payload.bindings) ? payload.bindings : []
await validateAdminFulfillmentBindingConfigs(bindingsInput)
const saved = saveOrderFulfillmentBindingConfigs(bindingsInput)
const normalizedBindings = await validateAdminFulfillmentBindingConfigs(bindingsInput)
const saved = saveOrderFulfillmentBindingConfigs(normalizedBindings)
await syncConfiguredFulfillmentBindings()
return {
@@ -1010,6 +1011,8 @@ async function validateAdminFulfillmentBindingConfigs(bindings = []) {
}
const seenKeys = new Set()
const kuaishouEticketSource = getKuaishouEticketSourceConfig()
const normalizedBindings = []
for (const [index, rawBinding] of bindings.entries()) {
if (!isPlainObject(rawBinding)) {
@@ -1021,13 +1024,53 @@ async function validateAdminFulfillmentBindingConfigs(bindings = []) {
const provider = String(rawBinding.provider || 'agiso').trim() || 'agiso'
const platform = String(rawBinding.platform || '').trim()
const shopId = String(rawBinding.shopId || '').trim()
let shopId = String(rawBinding.shopId || '').trim()
let shopName = String(rawBinding.shopName || '').trim()
const khhaoShopId = String(rawBinding.khhaoShopId || '').trim()
const skuCode = String(rawBinding.skuCode || '').trim()
const skuName = String(rawBinding.skuName || '').trim()
const profileKey = String(rawBinding.profileKey || '').trim() || 'manual_review'
const match = isPlainObject(rawBinding.match) ? rawBinding.match : {}
const externalItemId = String(match.externalItemId || '').trim()
const externalSkuCode = String(match.externalSkuCode || '').trim()
const externalSkuName = String(match.externalSkuName || '').trim()
const config = isPlainObject(rawBinding.config) ? { ...rawBinding.config } : {}
if (provider === 'khhao' && platform === 'kuaishou') {
if (!khhaoShopId) {
throw createHttpError(`${index + 1} 条快手规则缺少 khhao 店铺 ID`, {
statusCode: 400,
errorCode: 'admin_fulfillment_bindings_missing_khhao_shop_id',
})
}
const matchedShop = resolveKuaishouEticketShopConfig(
{
shopId,
shopName,
},
kuaishouEticketSource,
)
if (!matchedShop?.shopId || !matchedShop?.kshopName) {
throw createHttpError(
`${index + 1} 条快手规则的店铺未匹配到已配置的快手官方店铺,请先到“平台配置”补齐 Cookie 后再选择`,
{
statusCode: 400,
errorCode: 'admin_fulfillment_bindings_kuaishou_shop_not_configured',
},
)
}
shopId = String(matchedShop.shopId || '').trim()
shopName = String(matchedShop.kshopName || '').trim()
config.kuaishouShop = {
...(isPlainObject(config.kuaishouShop) ? config.kuaishouShop : {}),
shopId,
shopName,
khhaoShopId,
}
}
if (!skuCode) {
throw createHttpError(`${index + 1} 条规则缺少内部履约 SKU`, {
@@ -1054,7 +1097,12 @@ async function validateAdminFulfillmentBindingConfigs(bindings = []) {
const uniqueKey = [
provider,
platform,
shopId,
resolveFulfillmentBindingMatchShopId({
provider,
platform,
shopId,
khhaoShopId,
}),
externalItemId,
externalSkuCode,
normalizeProductName(externalSkuName),
@@ -1069,7 +1117,29 @@ async function validateAdminFulfillmentBindingConfigs(bindings = []) {
}
seenKeys.add(uniqueKey)
normalizedBindings.push({
provider,
platform,
shopId,
shopName,
khhaoShopId,
skuCode,
skuName,
profileKey,
enabled: rawBinding.enabled !== false,
priority: rawBinding.priority,
config,
match: {
externalSkuCode,
externalItemId,
externalSkuName,
config: isPlainObject(match.config) ? match.config : {},
},
})
}
return normalizedBindings
}
function mapAdminFulfillmentBindingConfigItem(item) {
@@ -1078,6 +1148,8 @@ function mapAdminFulfillmentBindingConfigItem(item) {
provider: String(item?.provider || '').trim(),
platform: String(item?.platform || '').trim(),
shopId: String(item?.shopId || '').trim(),
shopName: String(item?.shopName || '').trim(),
khhaoShopId: String(item?.khhaoShopId || '').trim(),
skuCode: String(item?.skuCode || '').trim(),
skuName: String(item?.skuName || '').trim(),
profileKey: String(item?.profileKey || '').trim(),
@@ -1093,6 +1165,19 @@ function mapAdminFulfillmentBindingConfigItem(item) {
}
}
function resolveFulfillmentBindingMatchShopId({
provider = '',
platform = '',
shopId = '',
khhaoShopId = '',
} = {}) {
if (String(provider || '').trim() === 'khhao' && String(platform || '').trim() === 'kuaishou') {
return String(khhaoShopId || shopId || '').trim()
}
return String(shopId || '').trim()
}
function matchesObservedProduct(binding, observed) {
const provider = String(binding?.provider || '').trim()
const platform = String(binding?.platform || '').trim()
@@ -221,6 +221,7 @@ export async function getAdminTaskDetail(taskId, session = null) {
const mappedInventoryBindings = inventoryBindings.map((binding) => mapAdminTaskInventoryBinding(binding, task, viewerContext))
const claimUrl = claimToken ? buildClaimUrl(claimToken.token) : ''
const screenshotUrl = await resolveAdminTaskScreenshotUrl(task, viewerContext)
const kuaishouCloudFulfillment = mapKuaishouCloudFulfillmentContext(taskContext.kuaishouCloudFulfillment)
return {
task: mapAdminTaskListItem({
@@ -279,7 +280,7 @@ export async function getAdminTaskDetail(taskId, session = null) {
roleName: String(taskState.reviewRoleName || '').trim() || '',
},
redeemResolution: mapRedeemResolutionContext(taskContext.redeemResolution),
kuaishouCloudFulfillment: mapKuaishouCloudFulfillmentContext(taskContext.kuaishouCloudFulfillment),
kuaishouCloudFulfillment,
manualDispatch: mapManualDispatchContext(taskContext.manualDispatch, viewerContext),
events: taskEvents.map(mapAdminTaskEvent),
operations: {
@@ -293,6 +294,8 @@ export async function getAdminTaskDetail(taskId, session = null) {
canCompleteManualDispatch: viewerContext.canManageTaskLifecycle && isManualDispatchTask(task) && !['redeemed', 'closed'].includes(task.task_status),
canPrepareKuaishouCloudFulfillment: viewerContext.canManageTaskLifecycle
&& String(task.executor_key || '').trim() === 'kuaishou_ct_assisted'
&& String(kuaishouCloudFulfillment?.dispatch.status || 'pending').trim() === 'pending'
&& String(kuaishouCloudFulfillment?.returnNumber.status || 'pending').trim() === 'pending'
&& ['pending_binding_prepare', 'manual_review', 'failed'].includes(String(task.task_status || '').trim()),
canDispatchKuaishouCloudFulfillment: viewerContext.canManageTaskLifecycle
&& String(task.executor_key || '').trim() === 'kuaishou_ct_assisted'
@@ -45,6 +45,10 @@ export function mapKuaishouCloudFulfillmentContext(value) {
ticket: {
code: String(ticket.code || '').trim(),
capturedAt: ticket.capturedAt || null,
status: String(ticket.status || 'pending').trim() || 'pending',
verifiedAt: ticket.verifiedAt || null,
goodsTitle: String(ticket.goodsTitle || '').trim(),
leftCount: Number(ticket.leftCount || 0) || 0,
},
binding: {
prepareStatus: String(binding.prepareStatus || 'pending').trim() || 'pending',
@@ -81,6 +85,8 @@ export function mapKuaishouCloudFulfillmentContext(value) {
status: String(consume.status || 'pending').trim() || 'pending',
shopId: String(consume.shopId || '').trim(),
autoConsumeEnabled: consume.autoConsumeEnabled === true,
consumedAt: consume.consumedAt || null,
errorMessage: String(consume.errorMessage || '').trim(),
},
notes: String(value.notes || '').trim(),
}
@@ -17,7 +17,7 @@ import {
} from '../../repositories/task-inventory-binding-repo.js'
import { createTaskEvent } from '../../repositories/task-event-repo.js'
import { getWebhookEventById } from '../../repositories/webhook-event-repo.js'
import { createTaskClaimToken } from '../claim/claim-service.js'
import { buildClaimUrl, createTaskClaimToken } from '../claim/claim-service.js'
import { confirmClaimRoleForAdminTask, redeemClaimTaskForAdminTask } from '../claim/claim-session-service.js'
import { reserveInventoryForTask } from '../order/inventory-service.js'
import { normalizeProductName } from '../order/product-match-service.js'
@@ -32,6 +32,11 @@ import {
useCloudtentaclesSku,
} from '../platforms/cloudtentacles/catalog-service.js'
import { getCloudtentaclesKnapsack } from '../platforms/cloudtentacles/knapsack-service.js'
import { consumeKuaishouEticket } from '../platforms/kuaishou-eticket/consume-service.js'
import {
getKuaishouEticketSourceConfig,
resolveKuaishouEticketShopConfig,
} from '../platforms/kuaishou-eticket/source-config-service.js'
import {
appointCloudtentaclesVirtualNumber,
backCloudtentaclesVirtualNumber,
@@ -755,6 +760,7 @@ export async function prepareAdminTaskKuaishouCloudFulfillment(taskId, session =
let purchaseTriggered = false
let assetBefore = 0
let assetAfter = 0
const claimLinkState = await ensureTaskClaimLink(task)
if (!usedKnapsack) {
if (!flowWithResolvedBinding.purchase.autoBuyEnabled) {
@@ -833,6 +839,8 @@ export async function prepareAdminTaskKuaishouCloudFulfillment(taskId, session =
task_status: 'waiting_binding',
inventory_status: 'not_required',
user_action_status: 'pending',
claim_token: claimLinkState.token || task.claim_token || '',
claim_expires_at: claimLinkState.expiredAt || getTaskClaimExpiresAt(task),
last_error: '',
context_json: JSON.stringify(nextContext),
updated_at: now,
@@ -852,6 +860,8 @@ export async function prepareAdminTaskKuaishouCloudFulfillment(taskId, session =
return {
task: mapTaskActionPayload(updatedTask),
claimUrl: claimLinkState.claimUrl,
token: claimLinkState.token,
}
}
@@ -890,6 +900,15 @@ export async function dispatchAdminTaskKuaishouCloudFulfillment(taskId, payload
}
const ticketCode = String(payload.ticketCode || '').trim()
const persistedTicketCode = String(flow.ticket.code || '').trim()
if (!persistedTicketCode && !ticketCode) {
throw createHttpError('客户还没有在领取页提交核销码,暂时不能直接发货', {
statusCode: 409,
errorCode: 'admin_task_kuaishou_cloud_missing_ticket_code',
})
}
const dispatchResult = await useCloudtentaclesSku({
...cloudContext,
id: flow.binding.skuId,
@@ -903,7 +922,7 @@ export async function dispatchAdminTaskKuaishouCloudFulfillment(taskId, payload
...flow,
ticket: {
...flow.ticket,
code: ticketCode || flow.ticket.code,
code: ticketCode || persistedTicketCode,
capturedAt: ticketCode ? now : flow.ticket.capturedAt,
capturedBy: ticketCode && session
? {
@@ -941,7 +960,7 @@ export async function dispatchAdminTaskKuaishouCloudFulfillment(taskId, payload
})
await createTaskEvent(task.id, 'kuaishou_cloud_dispatched', {
ticketCodeMasked: maskCode(ticketCode),
ticketCodeMasked: maskCode(ticketCode || persistedTicketCode),
skuId: flow.binding.skuId,
vnId: flow.binding.vnId,
vnPhoneMasked: maskPhone(flow.binding.vnPhone),
@@ -993,6 +1012,59 @@ export async function returnNumberAdminTaskKuaishouCloudFulfillment(taskId, sess
id: flow.binding.vnId,
})
const order = await getOrderById(task.order_id)
const ticketCode = String(flow.ticket.code || '').trim()
const shopId = String(order?.shop_id || '').trim()
const eticketSource = getKuaishouEticketSourceConfig()
const shopConfig = resolveKuaishouEticketShopConfig({
shopId,
shopName: String(order?.shop_name || '').trim(),
})
let consumeStatus = 'pending'
let consumeErrorMessage = ''
let consumedAt = null
let nextTaskStatus = 'completed'
let nextResultCode = 'kuaishou_cloud_completed'
let nextResultMessage = 'cloudtentacles 发货、退号并完成快手核销'
if (!order) {
consumeStatus = 'failed'
consumeErrorMessage = '任务关联订单不存在,无法执行快手核销'
} else if (!ticketCode) {
consumeStatus = 'failed'
consumeErrorMessage = '客户未提交有效核销码,无法执行快手核销'
} else if (!shopConfig || shopConfig.enabled === false || !String(shopConfig.cookie || '').trim()) {
consumeStatus = 'failed'
consumeErrorMessage = '订单对应快手小店缺少可用 Cookie,无法执行快手核销'
} else {
try {
const consumeResult = await consumeKuaishouEticket({
baseUrl: eticketSource.baseUrl,
cookie: shopConfig.cookie,
eTicketId: ticketCode,
oid: String(flow.ticket.oid || '').trim(),
formToken: String(flow.ticket.formToken || '').trim(),
})
if (consumeResult.consumed) {
consumeStatus = 'success'
consumedAt = now
} else {
consumeStatus = 'failed'
consumeErrorMessage = String(consumeResult.errorMessage || '快手核销失败').trim()
}
} catch (error) {
consumeStatus = 'failed'
consumeErrorMessage = error instanceof Error ? error.message : '快手核销失败'
}
}
if (consumeStatus !== 'success') {
nextTaskStatus = 'manual_review'
nextResultCode = 'kuaishou_cloud_consume_failed'
nextResultMessage = consumeErrorMessage || '号码已退还,但快手核销未完成,请人工处理'
}
const nextContext = {
...taskContext,
kuaishouCloudFulfillment: {
@@ -1009,16 +1081,24 @@ export async function returnNumberAdminTaskKuaishouCloudFulfillment(taskId, sess
}
: null,
},
consume: {
...flow.consume,
status: consumeStatus,
shopId: shopId || flow.consume.shopId,
autoConsumeEnabled: flow.consume.autoConsumeEnabled === true,
consumedAt,
errorMessage: consumeErrorMessage,
},
},
}
const updatedTask = await updateTask(task.id, {
task_status: 'completed',
task_status: nextTaskStatus,
delivery_status: 'delivered',
result_code: 'kuaishou_cloud_completed',
result_message: 'cloudtentacles 发货并退号完成',
redeemed_at: now,
last_error: '',
result_code: nextResultCode,
result_message: nextResultMessage,
redeemed_at: consumeStatus === 'success' ? now : task.redeemed_at,
last_error: consumeErrorMessage,
context_json: JSON.stringify(nextContext),
updated_at: now,
})
@@ -1028,6 +1108,18 @@ export async function returnNumberAdminTaskKuaishouCloudFulfillment(taskId, sess
vnPhoneMasked: maskPhone(flow.binding.vnPhone),
}, now)
await createTaskEvent(
task.id,
consumeStatus === 'success' ? 'kuaishou_cloud_consumed' : 'kuaishou_cloud_consume_failed',
{
ticketCodeMasked: maskCode(ticketCode),
shopId,
consumeStatus,
errorMessage: consumeErrorMessage,
},
now,
)
return {
task: mapTaskActionPayload(updatedTask),
}
@@ -1123,8 +1215,14 @@ function normalizeKuaishouCloudFlow(value) {
internalSkuName: String(source.internalSkuName || '').trim(),
ticket: {
code: String(ticket.code || '').trim(),
status: String(ticket.status || 'pending').trim() || 'pending',
capturedAt: ticket.capturedAt || null,
capturedBy: ticket.capturedBy || null,
verifiedAt: ticket.verifiedAt || null,
oid: String(ticket.oid || '').trim(),
formToken: String(ticket.formToken || '').trim(),
leftCount: Number(ticket.leftCount || 0) || 0,
goodsTitle: String(ticket.goodsTitle || '').trim(),
},
binding: {
prepareStatus: String(binding.prepareStatus || 'pending').trim() || 'pending',
@@ -1163,6 +1261,8 @@ function normalizeKuaishouCloudFlow(value) {
status: String(consume.status || 'pending').trim() || 'pending',
shopId: String(consume.shopId || '').trim(),
autoConsumeEnabled: consume.autoConsumeEnabled === true,
consumedAt: consume.consumedAt || null,
errorMessage: String(consume.errorMessage || '').trim(),
},
}
}
@@ -1462,3 +1562,33 @@ function normalizeManualDispatchOutcome(value) {
function getTaskClaimExpiresAt(task) {
return task?.claim_expires_at || task?.primary_claim_expires_at || null
}
async function ensureTaskClaimLink(task) {
const tokenStatus = String(task?.primary_claim_token_status || '').trim()
const token = String(task?.primary_claim_token || task?.claim_token || '').trim()
const expiredAt = getTaskClaimExpiresAt(task)
if (tokenStatus === 'active' && token && !isClaimExpired(expiredAt)) {
return {
token,
expiredAt,
claimUrl: buildClaimUrl(token),
}
}
const claimToken = await createTaskClaimToken(task.id)
return {
token: claimToken.token,
expiredAt: claimToken.expired_at,
claimUrl: claimToken.claimUrl,
}
}
function isClaimExpired(expiredAt) {
if (!expiredAt) {
return false
}
const timestamp = new Date(expiredAt).getTime()
return Number.isFinite(timestamp) && timestamp <= Date.now()
}