diff --git a/apps/backend/data/khhao-sync-state.json b/apps/backend/data/khhao-sync-state.json index 9ecca422..5feede57 100644 --- a/apps/backend/data/khhao-sync-state.json +++ b/apps/backend/data/khhao-sync-state.json @@ -1,11 +1,11 @@ { "syncFromCreatedAt": "2026-05-02T10:53:28.550Z", - "lastRunStartedAt": "2026-05-03T11:11:55.949Z", - "lastRunFinishedAt": "2026-05-03T11:11:56.571Z", + "lastRunStartedAt": "2026-05-03T12:57:26.689Z", + "lastRunFinishedAt": "2026-05-03T12:57:27.309Z", "lastRunStatus": "success", "lastErrorMessage": "", "fetchedCount": 40, - "syncedCount": 0, - "ignoredCount": 40, - "lastOrderCreatedAt": "2026-05-03T11:11:29.000Z" + "syncedCount": 1, + "ignoredCount": 39, + "lastOrderCreatedAt": "2026-05-03T12:57:20.000Z" } diff --git a/apps/backend/data/order-fulfillment-bindings.json b/apps/backend/data/order-fulfillment-bindings.json index b8dafb0a..7e909b15 100644 --- a/apps/backend/data/order-fulfillment-bindings.json +++ b/apps/backend/data/order-fulfillment-bindings.json @@ -2,13 +2,21 @@ { "provider": "khhao", "platform": "kuaishou", - "shopId": "10", + "shopId": "4269276762", + "shopName": "稚嫩游戏交易店", + "khhaoShopId": "10", "skuCode": "套装-浪漫天命", "skuName": "套装-浪漫天命", "profileKey": "kuaishou_ct_assisted", "enabled": true, "priority": 100, - "config": {}, + "config": { + "kuaishouShop": { + "shopId": "4269276762", + "shopName": "稚嫩游戏交易店", + "khhaoShopId": "10" + } + }, "match": { "externalSkuCode": "830", "externalItemId": "830", @@ -22,6 +30,8 @@ "provider": "agiso", "platform": "xianyu", "shopId": "2209880145223", + "shopName": "", + "khhaoShopId": "", "skuCode": "海底捞第五人格皮肤", "skuName": "海底捞第五人格皮肤", "profileKey": "tencent_claim_redeem", @@ -41,6 +51,8 @@ "provider": "agiso", "platform": "xianyu", "shopId": "2209880145223", + "shopName": "", + "khhaoShopId": "", "skuCode": "三角洲海底捞动作", "skuName": "海底捞干员庆生动作1个", "profileKey": "tencent_claim_redeem", diff --git a/apps/backend/src/repositories/order-repo.js b/apps/backend/src/repositories/order-repo.js index 41576856..9a6e2685 100644 --- a/apps/backend/src/repositories/order-repo.js +++ b/apps/backend/src/repositories/order-repo.js @@ -23,6 +23,35 @@ export async function findOrderByPlatformOrderId({ provider = 'agiso', platform, return /** @type {OrderRow | null} */ (result.rows[0] || null) } +/** @returns {Promise} */ +export async function findOrderByPlatformOrderIdCandidates({ + provider = 'agiso', + platform, + shopIds = [], + platformOrderId, +}) { + const normalizedShopIds = [...new Set((Array.isArray(shopIds) ? shopIds : []) + .map((item) => String(item || '').trim()) + .filter(Boolean))] + + if (!platform || !platformOrderId || normalizedShopIds.length === 0) { + return null + } + + const result = await query( + ` + SELECT * + FROM orders + WHERE provider = $1 AND platform = $2 AND shop_id = ANY($3::text[]) AND platform_order_id = $4 + ORDER BY id DESC + LIMIT 1 + `, + [provider, platform, normalizedShopIds, platformOrderId], + ) + + return /** @type {OrderRow | null} */ (result.rows[0] || null) +} + /** @returns {Promise} */ /** @param {OrderUpsertInput} input */ export async function createOrder(input) { diff --git a/apps/backend/src/routes/claims.js b/apps/backend/src/routes/claims.js index 15353574..0b83dc6d 100644 --- a/apps/backend/src/routes/claims.js +++ b/apps/backend/src/routes/claims.js @@ -10,6 +10,10 @@ import { reloadClaimSession, redeemClaimTask, } from '../services/claim/claim-session-service.js' +import { + getKuaishouCloudClaimGuideAssetPath, + verifyKuaishouCloudClaimTicket, +} from '../services/claim/kuaishou-cloud-claim-service.js' import { buildNotFoundPayload, buildSuccessPayload, sendRouteError } from '../utils/http.js' const router = Router() @@ -77,6 +81,15 @@ router.post('/:token/redeem', async (req, res) => { } }) +router.post('/:token/kuaishou-cloud/verify-ticket', async (req, res) => { + try { + const data = await verifyKuaishouCloudClaimTicket(req.params.token, req.body) + res.json(buildSuccessPayload(data, '核销码校验成功')) + } catch (error) { + sendRouteError(res, error, '校验核销码失败', '[claims/:token/kuaishou-cloud/verify-ticket]') + } +}) + router.get('/:token/screenshot', async (req, res) => { try { const screenshotPath = await getClaimScreenshotPath(req.params.token) @@ -86,6 +99,15 @@ router.get('/:token/screenshot', async (req, res) => { } }) +router.get('/assets/kuaishou-cloud/:filename', async (req, res) => { + try { + const filePath = await getKuaishouCloudClaimGuideAssetPath(req.params.filename) + res.sendFile(filePath) + } catch (error) { + sendRouteError(res, error, '读取指引图片失败', '[claims/assets/kuaishou-cloud/:filename]') + } +}) + router.use((req, res) => { res.status(404).json(buildNotFoundPayload(req)) }) diff --git a/apps/backend/src/services/admin/admin-platform-config-service.js b/apps/backend/src/services/admin/admin-platform-config-service.js index f8df91cf..9d1deef7 100644 --- a/apps/backend/src/services/admin/admin-platform-config-service.js +++ b/apps/backend/src/services/admin/admin-platform-config-service.js @@ -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() diff --git a/apps/backend/src/services/admin/admin-read-service.js b/apps/backend/src/services/admin/admin-read-service.js index df49beef..7ecf7353 100644 --- a/apps/backend/src/services/admin/admin-read-service.js +++ b/apps/backend/src/services/admin/admin-read-service.js @@ -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' diff --git a/apps/backend/src/services/admin/admin-read-shared-helpers.js b/apps/backend/src/services/admin/admin-read-shared-helpers.js index 5e75e591..5c694442 100644 --- a/apps/backend/src/services/admin/admin-read-shared-helpers.js +++ b/apps/backend/src/services/admin/admin-read-shared-helpers.js @@ -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(), } diff --git a/apps/backend/src/services/admin/admin-write-service.js b/apps/backend/src/services/admin/admin-write-service.js index 458986a8..a7f67651 100644 --- a/apps/backend/src/services/admin/admin-write-service.js +++ b/apps/backend/src/services/admin/admin-write-service.js @@ -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() +} diff --git a/apps/backend/src/services/bootstrap/fulfillment-bootstrap-service.js b/apps/backend/src/services/bootstrap/fulfillment-bootstrap-service.js index fe416681..8a8e3bd2 100644 --- a/apps/backend/src/services/bootstrap/fulfillment-bootstrap-service.js +++ b/apps/backend/src/services/bootstrap/fulfillment-bootstrap-service.js @@ -120,15 +120,18 @@ export async function syncConfiguredFulfillmentBindings(profileMap = {}) { continue } + const matchShopId = resolveBindingMatchShopId(binding) + const bindingConfig = resolveBindingRuntimeConfig(binding) + await upsertSkuFulfillmentBinding({ skuCode: binding.skuCode, provider: binding.provider, platform: binding.platform, - shopId: binding.shopId, + shopId: matchShopId, profileId: profile.id, enabled: binding.enabled, priority: binding.priority, - configJson: JSON.stringify(binding.config || {}), + configJson: JSON.stringify(bindingConfig), createdAt: timestamp, updatedAt: timestamp, }) @@ -145,7 +148,7 @@ export async function syncConfiguredFulfillmentBindings(profileMap = {}) { await upsertProductMatchRule({ provider: binding.provider, platform: binding.platform, - shopId: binding.shopId, + shopId: matchShopId, externalItemId, externalSkuCode, externalSkuName, @@ -159,3 +162,30 @@ export async function syncConfiguredFulfillmentBindings(profileMap = {}) { }) } } + +function resolveBindingMatchShopId(binding = {}) { + if (String(binding.provider || '').trim() === 'khhao' && String(binding.platform || '').trim() === 'kuaishou') { + return String(binding.khhaoShopId || binding.shopId || '').trim() + } + + return String(binding.shopId || '').trim() +} + +function resolveBindingRuntimeConfig(binding = {}) { + const baseConfig = isPlainObject(binding.config) ? { ...binding.config } : {} + + if (String(binding.provider || '').trim() === 'khhao' && String(binding.platform || '').trim() === 'kuaishou') { + baseConfig.kuaishouShop = { + ...(isPlainObject(baseConfig.kuaishouShop) ? baseConfig.kuaishouShop : {}), + shopId: String(binding.shopId || '').trim(), + shopName: String(binding.shopName || '').trim(), + khhaoShopId: String(binding.khhaoShopId || '').trim(), + } + } + + return baseConfig +} + +function isPlainObject(value) { + return Object.prototype.toString.call(value) === '[object Object]' +} diff --git a/apps/backend/src/services/claim/claim-session-service.js b/apps/backend/src/services/claim/claim-session-service.js index 744e0668..2b3849b5 100644 --- a/apps/backend/src/services/claim/claim-session-service.js +++ b/apps/backend/src/services/claim/claim-session-service.js @@ -29,6 +29,7 @@ import { nowIso } from '../../utils/time.js' const CLAIM_TERMINAL_STATUSES = new Set(['expired', 'closed']) const REDEEM_REPLACEMENT_LIMIT = 10 +const KUAISHOU_CLOUD_CLAIM_GUIDE_BASE_PATH = '/kuaishou-cloud-guide' export async function getClaimDetail(token, { includeQrImage = true } = {}) { const context = await getClaimContext(token) @@ -574,7 +575,7 @@ export async function getClaimScreenshotPath(token) { return getTencentBrowserSessionScreenshotPath(context.task.browser_session_id) } -async function getClaimContext(token) { +export async function getClaimContext(token) { const normalized = String(token || '').trim() if (!normalized) { @@ -818,10 +819,12 @@ function buildClaimDetailPayload({ claimToken, task, order, orderItem, session } const screenshotReady = Boolean(task.screenshot_path) || Boolean(session?.artifacts?.hasScreenshot) const screenshotUrl = screenshotReady ? `/api/v1/claim/${claimToken.token}/screenshot` : '' const finalRedeem = session?.redeem?.final?.redeem || null + const kuaishouCloudFulfillment = mapClaimKuaishouCloudFulfillment(task, order) return { tokenStatus: claimToken.status, claimUrl: buildClaimUrl(claimToken.token), + flowType: kuaishouCloudFulfillment ? 'kuaishou_cloud' : 'tencent_claim', task: { taskId: task.id, taskNo: task.task_no, @@ -853,6 +856,7 @@ function buildClaimDetailPayload({ claimToken, task, order, orderItem, session } quantity: orderItem.quantity, }, session, + kuaishouCloudFulfillment, result: task.redeemed_at || session?.status === 'redeemed' ? { resultCode: String(task.result_code || finalRedeem?.iRet || finalRedeem?.ret || ''), @@ -864,6 +868,62 @@ function buildClaimDetailPayload({ claimToken, task, order, orderItem, session } } } +function mapClaimKuaishouCloudFulfillment(task, order) { + if (String(task?.executor_key || '').trim() !== 'kuaishou_ct_assisted') { + return null + } + + const source = parseTaskContext(task).kuaishouCloudFulfillment + if (!source || typeof source !== 'object') { + return null + } + + const binding = source.binding && typeof source.binding === 'object' ? source.binding : {} + const ticket = source.ticket && typeof source.ticket === 'object' ? source.ticket : {} + const dispatch = source.dispatch && typeof source.dispatch === 'object' ? source.dispatch : {} + const returnNumber = source.returnNumber && typeof source.returnNumber === 'object' ? source.returnNumber : {} + const consume = source.consume && typeof source.consume === 'object' ? source.consume : {} + + return { + flowType: 'kuaishou_cloud', + shopId: String(order?.shop_id || '').trim(), + shopName: String(order?.shop_name || '').trim(), + guideImages: [ + `${KUAISHOU_CLOUD_CLAIM_GUIDE_BASE_PATH}/1.png`, + `${KUAISHOU_CLOUD_CLAIM_GUIDE_BASE_PATH}/2.png`, + `${KUAISHOU_CLOUD_CLAIM_GUIDE_BASE_PATH}/3.png`, + ], + ticket: { + code: String(ticket.code || '').trim(), + status: String(ticket.status || 'pending').trim() || 'pending', + capturedAt: ticket.capturedAt || null, + verifiedAt: ticket.verifiedAt || null, + goodsTitle: String(ticket.goodsTitle || '').trim(), + leftCount: Number(ticket.leftCount || 0) || 0, + }, + binding: { + prepareStatus: String(binding.prepareStatus || 'pending').trim() || 'pending', + bindUrl: String(binding.bindUrl || '').trim(), + bindPreparedAt: binding.bindPreparedAt || null, + vnPhone: String(binding.vnPhone || '').trim(), + }, + dispatch: { + status: String(dispatch.status || 'pending').trim() || 'pending', + dispatchAt: dispatch.dispatchAt || null, + note: String(dispatch.note || '').trim(), + }, + returnNumber: { + status: String(returnNumber.status || 'pending').trim() || 'pending', + returnedAt: returnNumber.returnedAt || null, + }, + consume: { + status: String(consume.status || 'pending').trim() || 'pending', + consumedAt: consume.consumedAt || null, + errorMessage: String(consume.errorMessage || '').trim(), + }, + } +} + async function redeemClaimTaskWithInventoryFallback(context, initialInventoryItem) { return redeemClaimTaskWithInventoryFallbackWithDeps(context, initialInventoryItem) } diff --git a/apps/backend/src/services/claim/kuaishou-cloud-claim-service.js b/apps/backend/src/services/claim/kuaishou-cloud-claim-service.js new file mode 100644 index 00000000..a9b9f39c --- /dev/null +++ b/apps/backend/src/services/claim/kuaishou-cloud-claim-service.js @@ -0,0 +1,192 @@ +import fs from 'node:fs' +import path from 'node:path' + +import { PROJECT_ROOT } from '../../config/runtime.js' +import { createTaskEvent } from '../../repositories/task-event-repo.js' +import { updateTask } from '../../repositories/task-repo.js' +import { createHttpError } from '../../utils/http.js' +import { nowIso } from '../../utils/time.js' +import { + getKuaishouEticketSourceConfig, + resolveKuaishouEticketShopConfig, +} from '../platforms/kuaishou-eticket/source-config-service.js' +import { queryKuaishouEticketConsumeDetail } from '../platforms/kuaishou-eticket/consume-service.js' +import { getClaimContext, getClaimDetail } from './claim-session-service.js' + +const KUAISHOU_CLOUD_GUIDE_DIR = path.resolve(PROJECT_ROOT, '../../tems/imgs') +const ALLOWED_GUIDE_FILES = new Set(['1.png', '2.png', '3.png']) + +export async function verifyKuaishouCloudClaimTicket(token, payload = {}) { + const context = await getClaimContext(token) + const now = nowIso() + + if (String(context.task.executor_key || '').trim() !== 'kuaishou_ct_assisted') { + throw createHttpError('当前领取链接不是快手 Cloud 客户领取流程', { + statusCode: 409, + errorCode: 'claim_not_kuaishou_cloud', + }) + } + + const flow = normalizeKuaishouCloudFlow(parseTaskContext(context.task).kuaishouCloudFulfillment) + if (!flow.binding.bindUrl || flow.binding.prepareStatus !== 'ready') { + throw createHttpError('当前任务还没有准备好绑定资源,请联系客服稍后重试', { + statusCode: 409, + errorCode: 'claim_kuaishou_cloud_binding_not_ready', + }) + } + + const ticketCode = String(payload.ticketCode || payload.eTicketId || '').trim() + if (!ticketCode) { + throw createHttpError('请先粘贴快手小店核销码', { + statusCode: 400, + errorCode: 'claim_kuaishou_cloud_missing_ticket_code', + }) + } + + const shopId = String(flow.consume.shopId || context.order.shop_id || '').trim() + const shopConfig = resolveKuaishouEticketShopConfig({ + shopId, + shopName: String(context.order.shop_name || '').trim(), + }) + if (!shopConfig || shopConfig.enabled === false || !String(shopConfig.cookie || '').trim()) { + throw createHttpError('这笔订单对应的快手小店还没有配置可用 Cookie,请联系客服处理', { + statusCode: 409, + errorCode: 'claim_kuaishou_cloud_shop_cookie_missing', + }) + } + + const eticketSource = getKuaishouEticketSourceConfig() + const detailResult = await queryKuaishouEticketConsumeDetail({ + baseUrl: eticketSource.baseUrl, + cookie: shopConfig.cookie, + eTicketId: ticketCode, + }) + + if (!detailResult.ok || detailResult.alreadyConsumed || !detailResult.detail) { + throw createHttpError(detailResult.errorMessage || '核销码校验失败,请确认是否复制完整', { + statusCode: 409, + errorCode: detailResult.alreadyConsumed + ? 'claim_kuaishou_cloud_ticket_already_consumed' + : 'claim_kuaishou_cloud_ticket_invalid', + }) + } + + const nextContext = { + ...parseTaskContext(context.task), + kuaishouCloudFulfillment: { + ...flow, + ticket: { + ...flow.ticket, + code: detailResult.eTicketId || ticketCode, + status: 'verified', + capturedAt: flow.ticket.capturedAt || now, + capturedBy: flow.ticket.capturedBy || { + source: 'claim_page', + }, + verifiedAt: now, + oid: String(detailResult.detail.oid || '').trim(), + formToken: String(detailResult.detail.formToken || '').trim(), + leftCount: Number(detailResult.detail.leftCount || 0) || 0, + goodsTitle: String(detailResult.goods?.itemTitle || '').trim(), + }, + consume: { + ...flow.consume, + shopId, + }, + }, + } + + await updateTask(context.task.id, { + claim_token: context.claimToken.token, + claim_expires_at: context.claimToken.expired_at, + claimed_at: context.task.claimed_at || now, + context_json: JSON.stringify(nextContext), + last_error: '', + updated_at: now, + }) + + await createTaskEvent(context.task.id, 'kuaishou_cloud_ticket_verified', { + ticketCodeMasked: maskCode(detailResult.eTicketId || ticketCode), + shopId, + shopName: String(context.order.shop_name || '').trim(), + goodsTitle: String(detailResult.goods?.itemTitle || '').trim(), + }, now) + + return getClaimDetail(token, { includeQrImage: false }) +} + +export async function getKuaishouCloudClaimGuideAssetPath(filename) { + const normalized = String(filename || '').trim() + if (!ALLOWED_GUIDE_FILES.has(normalized)) { + throw createHttpError('指引图片不存在', { + statusCode: 404, + errorCode: 'claim_kuaishou_cloud_asset_not_found', + }) + } + + const filePath = path.resolve(KUAISHOU_CLOUD_GUIDE_DIR, normalized) + if (!filePath.startsWith(KUAISHOU_CLOUD_GUIDE_DIR) || !fs.existsSync(filePath)) { + throw createHttpError('指引图片不存在', { + statusCode: 404, + errorCode: 'claim_kuaishou_cloud_asset_not_found', + }) + } + + return filePath +} + +function normalizeKuaishouCloudFlow(value) { + const source = value && typeof value === 'object' ? value : {} + const binding = source.binding && typeof source.binding === 'object' ? source.binding : {} + const consume = source.consume && typeof source.consume === 'object' ? source.consume : {} + const ticket = source.ticket && typeof source.ticket === 'object' ? source.ticket : {} + + return { + ...source, + 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', + bindUrl: String(binding.bindUrl || '').trim(), + }, + consume: { + status: String(consume.status || 'pending').trim() || 'pending', + shopId: String(consume.shopId || '').trim(), + }, + } +} + +function parseTaskContext(task) { + const rawValue = task?.context_json + if (!rawValue) { + return {} + } + + if (typeof rawValue === 'object') { + return rawValue + } + + try { + return JSON.parse(String(rawValue || '{}')) + } catch { + return {} + } +} + +function maskCode(value) { + const normalized = String(value || '').trim() + if (normalized.length <= 6) { + return normalized + } + + return `${normalized.slice(0, 3)}***${normalized.slice(-3)}` +} diff --git a/apps/backend/src/services/order/delivery-task-service.js b/apps/backend/src/services/order/delivery-task-service.js index 93a84adf..c99c7493 100644 --- a/apps/backend/src/services/order/delivery-task-service.js +++ b/apps/backend/src/services/order/delivery-task-service.js @@ -42,6 +42,9 @@ export async function syncDeliveryTasksForOrder(order, orderItems) { const primaryRequirement = requirements.find((requirement) => requirement.is_required !== false) || requirements[0] || null const quantity = Math.max(1, Number(item.quantity || 1)) const fulfillmentConfig = parseJsonObject(profile.config_json) + const kuaishouShopConfig = fulfillmentConfig.kuaishouShop && typeof fulfillmentConfig.kuaishouShop === 'object' + ? fulfillmentConfig.kuaishouShop + : {} for (let index = 0; index < quantity; index += 1) { const createdAt = nowIso() @@ -93,8 +96,14 @@ export async function syncDeliveryTasksForOrder(order, orderItems) { internalSkuName: item.sku_name, ticket: { code: '', + status: 'pending', capturedAt: null, capturedBy: null, + verifiedAt: null, + oid: '', + formToken: '', + leftCount: 0, + goodsTitle: '', }, binding: { prepareStatus: 'pending', @@ -131,8 +140,10 @@ export async function syncDeliveryTasksForOrder(order, orderItems) { }, consume: { status: 'pending', - shopId: String(fulfillmentConfig.kuaishouConsume?.shopId || '').trim(), + shopId: String(fulfillmentConfig.kuaishouConsume?.shopId || kuaishouShopConfig.shopId || '').trim(), autoConsumeEnabled: fulfillmentConfig.kuaishouConsume?.autoConsumeAfterDispatch === true, + consumedAt: null, + errorMessage: '', }, notes: String(fulfillmentConfig.notes || '').trim(), } diff --git a/apps/backend/src/services/order/fulfillment-binding-config-service.js b/apps/backend/src/services/order/fulfillment-binding-config-service.js index 5fb39185..aa3f2550 100644 --- a/apps/backend/src/services/order/fulfillment-binding-config-service.js +++ b/apps/backend/src/services/order/fulfillment-binding-config-service.js @@ -56,6 +56,8 @@ function normalizeOrderFulfillmentBinding(rawValue) { const provider = String(rawValue.provider || 'agiso').trim() || 'agiso' const platform = String(rawValue.platform || '').trim() const shopId = String(rawValue.shopId || '').trim() + const shopName = String(rawValue.shopName || '').trim() + const khhaoShopId = String(rawValue.khhaoShopId || '').trim() const skuCode = String(rawValue.skuCode || '').trim() const skuName = String(rawValue.skuName || '').trim() const profileKey = String(rawValue.profileKey || '').trim() || 'manual_review' @@ -72,6 +74,8 @@ function normalizeOrderFulfillmentBinding(rawValue) { provider, platform, shopId, + shopName, + khhaoShopId, skuCode, skuName, profileKey, diff --git a/apps/backend/src/services/order/order-service.js b/apps/backend/src/services/order/order-service.js index 5e4f1254..8cc18ade 100644 --- a/apps/backend/src/services/order/order-service.js +++ b/apps/backend/src/services/order/order-service.js @@ -1,4 +1,9 @@ -import { createOrder, findOrderByPlatformOrderId, updateOrder } from '../../repositories/order-repo.js' +import { + createOrder, + findOrderByPlatformOrderId, + findOrderByPlatformOrderIdCandidates, + updateOrder, +} from '../../repositories/order-repo.js' import { replaceOrderItems } from '../../repositories/order-item-repo.js' import { getClaimTokenById } from '../../repositories/claim-token-repo.js' import { buildClaimUrl } from '../claim/claim-service.js' @@ -14,12 +19,18 @@ export async function upsertOrderFromWebhook(event) { export async function upsertOrderFromSource(event, { sourceLabel = 'source' } = {}) { const now = nowIso() - const existing = await findOrderByPlatformOrderId({ + const exactExisting = await findOrderByPlatformOrderId({ provider: event.provider, platform: event.platform, shopId: event.shopId, platformOrderId: event.platformOrderId, }) + const existing = exactExisting || await findOrderByPlatformOrderIdCandidates({ + provider: event.provider, + platform: event.platform, + shopIds: resolveEventShopIdCandidates(event), + platformOrderId: event.platformOrderId, + }) logWebhook('[order-service]', `开始处理 ${sourceLabel} 订单 upsert`, { provider: event.provider, @@ -155,6 +166,13 @@ export async function upsertOrderFromSource(event, { sourceLabel = 'source' } = } } +function resolveEventShopIdCandidates(event) { + return [...new Set([ + String(event?.shopId || '').trim(), + ...(Array.isArray(event?.shopIdAliases) ? event.shopIdAliases : []).map((item) => String(item || '').trim()), + ].filter(Boolean))] +} + const ORDER_STATUS_PRIORITY = { created: 0, paid: 1, diff --git a/apps/backend/src/services/platforms/khhao/order-mapper-service.js b/apps/backend/src/services/platforms/khhao/order-mapper-service.js index f1438710..633380ea 100644 --- a/apps/backend/src/services/platforms/khhao/order-mapper-service.js +++ b/apps/backend/src/services/platforms/khhao/order-mapper-service.js @@ -1,6 +1,7 @@ // @ts-check import { parseAmountToFen } from '../../../utils/money.js' +import { resolveKuaishouEticketShopConfig } from '../kuaishou-eticket/source-config-service.js' export function mapKhhaoOrderPreviewList(items = []) { return (Array.isArray(items) ? items : []).map((item) => mapKhhaoOrderPreview(item)) @@ -10,11 +11,13 @@ export function mapKhhaoOrderToSourceEvent(item = {}) { const preview = mapKhhaoOrderPreview(item) const orderStatus = resolveKhhaoOrderStatus(preview.status) const payStatus = resolveKhhaoPayStatus(preview.status) + const sourceShopId = String(preview.khhaoShopId || preview.shopId || '').trim() return { provider: 'khhao', platform: preview.platform || 'unknown', - shopId: preview.shopId, + shopId: sourceShopId, + shopIdAliases: uniqueNonEmptyValues([sourceShopId, preview.shopId, ...(preview.shopIdAliases || [])]), shopName: preview.shopName, platformOrderId: preview.platformOrderId, orderStatus, @@ -46,14 +49,25 @@ export function mapKhhaoOrderPreview(item = {}) { const raw = isPlainObject(item) ? item : {} const platform = resolveKhhaoPlatform(raw.pingtai) const quantity = normalizeQuantity(raw.num) + const internalShopId = String(raw.shopid || '').trim() + const shopName = String(raw.shopName || '').trim() + const resolvedShopIdentity = resolveKhhaoShopIdentity({ + platform, + internalShopId, + shopName, + }) return { provider: 'khhao', platform, platformLabel: String(raw.pingtaiName || '').trim(), platformOrderId: String(raw.ordersn || '').trim(), - shopId: String(raw.shopid || '').trim(), - shopName: String(raw.shopName || '').trim(), + shopId: resolvedShopIdentity.shopId, + kuaishouShopId: resolvedShopIdentity.officialShopId, + shopIdAliases: resolvedShopIdentity.shopIdAliases, + khhaoShopId: internalShopId, + internalShopId, + shopName, itemId: String(raw.goodid || '').trim(), itemTitle: String(raw.goodName || '').trim(), skuCode: String(raw.sku || '').trim(), @@ -66,6 +80,32 @@ export function mapKhhaoOrderPreview(item = {}) { } } +function resolveKhhaoShopIdentity({ platform = '', internalShopId = '', shopName = '' } = {}) { + const fallbackShopId = String(internalShopId || '').trim() + const normalizedShopName = String(shopName || '').trim() + + if (String(platform || '').trim() !== 'kuaishou') { + return { + shopId: fallbackShopId, + officialShopId: '', + shopIdAliases: fallbackShopId ? [fallbackShopId] : [], + } + } + + const matchedShop = resolveKuaishouEticketShopConfig({ + shopId: fallbackShopId, + shopName: normalizedShopName, + }) + const resolvedShopId = String(matchedShop?.shopId || '').trim() || fallbackShopId + const shopIdAliases = uniqueNonEmptyValues([resolvedShopId, fallbackShopId]) + + return { + shopId: resolvedShopId, + officialShopId: String(matchedShop?.shopId || '').trim(), + shopIdAliases, + } +} + export function resolveKhhaoPlatform(value) { const normalized = String(value || '').trim() @@ -121,3 +161,9 @@ function normalizePaidAt(value) { function isPlainObject(value) { return Object.prototype.toString.call(value) === '[object Object]' } + +function uniqueNonEmptyValues(values) { + return [...new Set((Array.isArray(values) ? values : []) + .map((value) => String(value || '').trim()) + .filter(Boolean))] +} diff --git a/apps/backend/src/services/platforms/kuaishou-eticket/source-config-service.js b/apps/backend/src/services/platforms/kuaishou-eticket/source-config-service.js index 53409dd2..9d20270b 100644 --- a/apps/backend/src/services/platforms/kuaishou-eticket/source-config-service.js +++ b/apps/backend/src/services/platforms/kuaishou-eticket/source-config-service.js @@ -36,6 +36,32 @@ export function findKuaishouEticketShopConfig(shopId, source = getKuaishouEticke return listKuaishouEticketShopConfigs(source).find((item) => String(item.shopId || '').trim() === normalizedShopId) || null } +export function findKuaishouEticketShopConfigByName(kshopName, source = getKuaishouEticketSourceConfig()) { + const normalizedName = String(kshopName || '').trim() + if (!normalizedName) { + return null + } + + return listKuaishouEticketShopConfigs(source).find((item) => String(item.kshopName || '').trim() === normalizedName) || null +} + +export function resolveKuaishouEticketShopConfig( + { shopId = '', kshopName = '', shopName = '' } = {}, + source = getKuaishouEticketSourceConfig(), +) { + const byId = findKuaishouEticketShopConfig(shopId, source) + if (byId) { + return byId + } + + const normalizedName = String(kshopName || shopName || '').trim() + if (!normalizedName) { + return null + } + + return findKuaishouEticketShopConfigByName(normalizedName, source) +} + export function getFirstAvailableKuaishouEticketShop(source = getKuaishouEticketSourceConfig()) { return listKuaishouEticketShopConfigs(source).find((item) => item.enabled !== false && String(item.cookie || '').trim()) || null } diff --git a/apps/backend/src/types/admin-write-inputs.js b/apps/backend/src/types/admin-write-inputs.js index 543abf4a..cddf5f5e 100644 --- a/apps/backend/src/types/admin-write-inputs.js +++ b/apps/backend/src/types/admin-write-inputs.js @@ -300,6 +300,8 @@ export {} * provider?: string * platform?: string * shopId?: string + * shopName?: string + * khhaoShopId?: string * skuCode?: string * skuName?: string * profileKey?: string diff --git a/apps/frontend/public/kuaishou-cloud-guide/1.png b/apps/frontend/public/kuaishou-cloud-guide/1.png new file mode 100644 index 00000000..6fb49bd6 Binary files /dev/null and b/apps/frontend/public/kuaishou-cloud-guide/1.png differ diff --git a/apps/frontend/public/kuaishou-cloud-guide/2.png b/apps/frontend/public/kuaishou-cloud-guide/2.png new file mode 100644 index 00000000..5c839ae7 Binary files /dev/null and b/apps/frontend/public/kuaishou-cloud-guide/2.png differ diff --git a/apps/frontend/public/kuaishou-cloud-guide/3.png b/apps/frontend/public/kuaishou-cloud-guide/3.png new file mode 100644 index 00000000..e01e0b80 Binary files /dev/null and b/apps/frontend/public/kuaishou-cloud-guide/3.png differ diff --git a/apps/frontend/src/components.d.ts b/apps/frontend/src/components.d.ts index 21f1402c..5128cae1 100644 --- a/apps/frontend/src/components.d.ts +++ b/apps/frontend/src/components.d.ts @@ -15,6 +15,8 @@ declare module 'vue' { AdminStatusTag: typeof import('./components/admin/AdminStatusTag.vue')['default'] ElButton: typeof import('element-plus/es')['ElButton'] ElCheckbox: typeof import('element-plus/es')['ElCheckbox'] + ElCollapse: typeof import('element-plus/es')['ElCollapse'] + ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem'] ElDialog: typeof import('element-plus/es')['ElDialog'] ElForm: typeof import('element-plus/es')['ElForm'] ElFormItem: typeof import('element-plus/es')['ElFormItem'] diff --git a/apps/frontend/src/services/admin.ts b/apps/frontend/src/services/admin.ts index be7f4e95..6a645905 100644 --- a/apps/frontend/src/services/admin.ts +++ b/apps/frontend/src/services/admin.ts @@ -508,7 +508,7 @@ export function dispatchAdminTaskKuaishouCloudFulfillment( taskId: number | string, payload: { ticketCode?: string - }, + } = {}, ) { return apiPost(`/api/v1/admin/tasks/${taskId}/kuaishou-cloud/dispatch`, payload) } diff --git a/apps/frontend/src/services/claim.ts b/apps/frontend/src/services/claim.ts index a0cdeeb9..08e6b2d1 100644 --- a/apps/frontend/src/services/claim.ts +++ b/apps/frontend/src/services/claim.ts @@ -21,6 +21,15 @@ export function fetchClaimSessionSummary(token: string) { return apiGet(`/api/v1/claim/${token}/session/summary`) } +export function verifyKuaishouCloudClaimTicket( + token: string, + payload: { + ticketCode: string + }, +) { + return apiPost(`/api/v1/claim/${token}/kuaishou-cloud/verify-ticket`, payload) +} + export function refreshClaimSession(token: string) { return apiPost(`/api/v1/claim/${token}/session/refresh`, {}) } diff --git a/apps/frontend/src/types/admin.ts b/apps/frontend/src/types/admin.ts index 9e7ab578..bc3ad960 100644 --- a/apps/frontend/src/types/admin.ts +++ b/apps/frontend/src/types/admin.ts @@ -212,6 +212,8 @@ export interface AdminKhhaoOrderPreview { platformLabel: string platformOrderId: string shopId: string + kuaishouShopId: string + khhaoShopId: string shopName: string itemId: string itemTitle: string @@ -321,6 +323,8 @@ export interface AdminFulfillmentBindingConfigItem { provider: string platform: string shopId: string + shopName: string + khhaoShopId: string skuCode: string skuName: string profileKey: string @@ -693,6 +697,10 @@ export interface AdminTaskDetail { ticket: { code: string capturedAt: string | null + status: string + verifiedAt: string | null + goodsTitle: string + leftCount: number } binding: { prepareStatus: string @@ -729,6 +737,8 @@ export interface AdminTaskDetail { status: string shopId: string autoConsumeEnabled: boolean + consumedAt: string | null + errorMessage: string } notes: string } diff --git a/apps/frontend/src/types/claim.ts b/apps/frontend/src/types/claim.ts index 47224bd5..68ffe1e6 100644 --- a/apps/frontend/src/types/claim.ts +++ b/apps/frontend/src/types/claim.ts @@ -6,6 +6,11 @@ export type ClaimTaskStatus = | 'pending_payment' | 'paid' | 'waiting_inventory' + | 'pending_binding_prepare' + | 'waiting_binding' + | 'dispatched_pending_return' + | 'completed' + | 'failed' | 'cdk_reserved' | 'link_generated' | 'claimed' @@ -58,12 +63,49 @@ export interface ClaimResultInfo { screenshotUrl: string } +export interface ClaimKuaishouCloudFlowInfo { + flowType: 'kuaishou_cloud' + shopId: string + shopName: string + guideImages: string[] + ticket: { + code: string + status: string + capturedAt: string | null + verifiedAt: string | null + goodsTitle: string + leftCount: number + } + binding: { + prepareStatus: string + bindUrl: string + bindPreparedAt: string | null + vnPhone: string + } + dispatch: { + status: string + dispatchAt: string | null + note: string + } + returnNumber: { + status: string + returnedAt: string | null + } + consume: { + status: string + consumedAt: string | null + errorMessage: string + } +} + export interface ClaimDetailData { tokenStatus: ClaimTokenStatus claimUrl: string + flowType: 'tencent_claim' | 'kuaishou_cloud' | (string & {}) task: ClaimTaskInfo order: ClaimOrderInfo orderItem: ClaimOrderItemInfo session: TencentBrowserSessionData | TencentBrowserSessionSummaryData | null + kuaishouCloudFulfillment: ClaimKuaishouCloudFlowInfo | null result: ClaimResultInfo | null } diff --git a/apps/frontend/src/views/admin/AdminFulfillmentBindingsView.vue b/apps/frontend/src/views/admin/AdminFulfillmentBindingsView.vue index 22efd755..710d6ffe 100644 --- a/apps/frontend/src/views/admin/AdminFulfillmentBindingsView.vue +++ b/apps/frontend/src/views/admin/AdminFulfillmentBindingsView.vue @@ -5,6 +5,7 @@ import { showSuccess } from '@/lib/feedback' import { fetchAdminFulfillmentBindingConfigs, fetchAdminKhhaoSourceConfig, + fetchAdminKuaishouEticketSourceConfig, queryAdminKhhaoOrders, lookupAdminFulfillmentBindingOrder, saveAdminFulfillmentBindingConfigs, @@ -14,6 +15,7 @@ import type { AdminFulfillmentLookupItem, AdminFulfillmentLookupResult, AdminKhhaoOrderPreview, + AdminKuaishouEticketShopConfigItem, AdminObservedProductItem, } from '@/types/admin' import { hasAdminRole } from '@/utils/admin-auth' @@ -24,6 +26,8 @@ type EditableBinding = { provider: string platform: string shopId: string + shopName: string + khhaoShopId: string skuCode: string skuName: string profileKey: string @@ -39,6 +43,8 @@ type SaveBindingPayload = { provider: string platform: string shopId: string + shopName: string + khhaoShopId: string skuCode: string skuName: string profileKey: string @@ -54,7 +60,7 @@ type SaveBindingPayload = { } } -type ValidationField = 'skuCode' | 'match' | 'profileKey' +type ValidationField = 'skuCode' | 'match' | 'profileKey' | 'shopId' | 'khhaoShopId' type ValidationState = { bindingId: string @@ -75,6 +81,7 @@ const errorMessage = ref('') const filePath = ref('') const bindings = ref([]) const observedProducts = ref([]) +const kuaishouShopOptions = ref([]) const lookupLoading = ref(false) const lookupErrorMessage = ref('') const lookupResult = ref(null) @@ -119,8 +126,10 @@ const pendingObservedCount = computed(() => observedProducts.value.filter((item) type ImportableProductCandidate = Pick< AdminObservedProductItem, - 'provider' | 'platform' | 'shopId' | 'externalSkuCode' | 'externalItemId' | 'externalSkuName' -> + 'provider' | 'platform' | 'shopId' | 'shopName' | 'externalSkuCode' | 'externalItemId' | 'externalSkuName' +> & { + khhaoShopId?: string +} function createEmptyBinding(): EditableBinding { return { @@ -128,6 +137,8 @@ function createEmptyBinding(): EditableBinding { provider: 'agiso', platform: '', shopId: '', + shopName: '', + khhaoShopId: '', skuCode: '', skuName: '', profileKey: 'manual_review', @@ -141,11 +152,14 @@ function createEmptyBinding(): EditableBinding { } function mapEditableBinding(item: AdminFulfillmentBindingConfigItem): EditableBinding { - return { + const matchedShop = resolveKuaishouShopById(item.shopId) || resolveKuaishouShopByName(item.shopName || '') + const binding = { id: crypto.randomUUID(), provider: item.provider || 'agiso', platform: item.platform, - shopId: item.shopId, + shopId: matchedShop?.shopId || item.shopId, + shopName: item.shopName || matchedShop?.kshopName || '', + khhaoShopId: item.khhaoShopId || '', skuCode: item.skuCode, skuName: item.skuName, profileKey: item.profileKey || 'manual_review', @@ -156,6 +170,9 @@ function mapEditableBinding(item: AdminFulfillmentBindingConfigItem): EditableBi externalSkuName: item.match.externalSkuName, resolvedSkuName: String(item.match.config?.resolvedSkuName || ''), } + + syncBindingShopSelection(binding) + return binding } function hasMatchCondition(item: Pick) { @@ -203,16 +220,64 @@ function getBindingStatusLabel(binding: EditableBinding) { } function getBindingSummary(binding: EditableBinding) { + const shopLabel = isKhhaoKuaishouBinding(binding) + ? [ + binding.shopName.trim() || binding.shopId.trim() || '未选快手店铺', + binding.khhaoShopId.trim() ? `khhao#${binding.khhaoShopId.trim()}` : '', + ].filter(Boolean).join(' / ') + : (binding.shopName.trim() || binding.shopId.trim() || '跨店铺') const parts = [ binding.provider.trim() || 'agiso', binding.platform.trim() || '未选平台', - binding.shopId.trim() || '跨店铺', + shopLabel, binding.profileKey.trim() || 'manual_review', ] return parts.join(' / ') } +function isKhhaoKuaishouBinding(binding: Pick) { + return binding.provider.trim() === 'khhao' && binding.platform.trim() === 'kuaishou' +} + +function resolveKuaishouShopById(shopId: string) { + const normalizedShopId = shopId.trim() + return kuaishouShopOptions.value.find((item) => item.shopId === normalizedShopId) || null +} + +function resolveKuaishouShopByName(shopName: string) { + const normalizedShopName = shopName.trim() + return kuaishouShopOptions.value.find((item) => item.kshopName === normalizedShopName) || null +} + +function getKuaishouShopOptionLabel(item: AdminKuaishouEticketShopConfigItem) { + return item.kshopName.trim() + ? `${item.kshopName} (${item.shopId})` + : item.shopId +} + +function syncBindingShopSelection(binding: EditableBinding) { + if (!isKhhaoKuaishouBinding(binding)) { + return + } + + const matchedById = resolveKuaishouShopById(binding.shopId) + if (matchedById) { + binding.shopId = matchedById.shopId + binding.shopName = matchedById.kshopName + return + } + + const matchedByName = resolveKuaishouShopByName(binding.shopName) + if (matchedByName) { + binding.shopId = matchedByName.shopId + binding.shopName = matchedByName.kshopName + return + } + + binding.shopId = '' +} + async function loadConfigs() { if (!hasAdminRole('admin')) { loading.value = false @@ -223,11 +288,13 @@ async function loadConfigs() { errorMessage.value = '' try { - const [response, khhaoSourceResponse] = await Promise.all([ + const [response, khhaoSourceResponse, kuaishouSourceResponse] = await Promise.all([ fetchAdminFulfillmentBindingConfigs(), fetchAdminKhhaoSourceConfig(), + fetchAdminKuaishouEticketSourceConfig(), ]) filePath.value = response.data.filePath + kuaishouShopOptions.value = kuaishouSourceResponse.data.source.shops.filter((item) => item.enabled !== false) const nextBindings = response.data.bindings.map(mapEditableBinding) bindings.value = nextBindings collapsedBindingIds.value = buildCollapsedIds(nextBindings) @@ -259,11 +326,13 @@ function removeBinding(id: string) { } function createBindingFromProductCandidate(item: ImportableProductCandidate): EditableBinding { - return { + const binding = { id: crypto.randomUUID(), provider: item.provider || 'agiso', platform: item.platform, shopId: item.shopId, + shopName: item.shopName, + khhaoShopId: item.khhaoShopId || '', skuCode: '', skuName: item.externalSkuName, profileKey: 'manual_review', @@ -274,6 +343,9 @@ function createBindingFromProductCandidate(item: ImportableProductCandidate): Ed externalSkuName: item.externalSkuName, resolvedSkuName: item.externalSkuName, } + + syncBindingShopSelection(binding) + return binding } function importObservedProduct(item: ImportableProductCandidate) { @@ -298,7 +370,11 @@ function importKhhaoProduct(item: AdminKhhaoOrderPreview) { importObservedProduct({ provider: item.provider || 'khhao', platform: item.platform || 'unknown', - shopId: item.shopId, + shopId: isKhhaoKuaishouBinding({ provider: item.provider || 'khhao', platform: item.platform || 'unknown' }) + ? (item.kuaishouShopId || resolveKuaishouShopByName(item.shopName)?.shopId || '') + : item.shopId, + shopName: item.shopName, + khhaoShopId: item.khhaoShopId || item.shopId, externalSkuCode: item.skuCode, externalItemId: item.itemId, externalSkuName: item.itemTitle, @@ -314,10 +390,14 @@ function isKhhaoProductImported(platformOrderId: string) { } function normalizeBindingForSave(item: EditableBinding): SaveBindingPayload { + syncBindingShopSelection(item) + return { provider: item.provider.trim() || 'agiso', platform: item.platform.trim(), shopId: item.shopId.trim(), + shopName: item.shopName.trim(), + khhaoShopId: item.khhaoShopId.trim(), skuCode: item.skuCode.trim(), skuName: item.skuName.trim(), profileKey: item.profileKey.trim() || 'manual_review', @@ -338,6 +418,8 @@ function hasBindingContent(item: SaveBindingPayload) { return Boolean( item.platform || item.shopId + || item.shopName + || item.khhaoShopId || item.skuCode || item.skuName || item.match.externalSkuCode @@ -353,6 +435,24 @@ function hasBindingContent(item: SaveBindingPayload) { function resolveBindingValidationState(item: SaveBindingPayload, index: number, bindingId: string): ValidationState { const label = `第 ${index + 1} 条规则` + const provider = item.provider.trim() || 'agiso' + const platform = item.platform.trim() + + if (provider === 'khhao' && platform === 'kuaishou' && (!item.shopId || !item.shopName)) { + return { + bindingId, + field: 'shopId', + message: `${label} 需要选择已配置的快手官方店铺`, + } + } + + if (provider === 'khhao' && platform === 'kuaishou' && !item.khhaoShopId) { + return { + bindingId, + field: 'khhaoShopId', + message: `${label} 需要填写 khhao 店铺 ID`, + } + } if (!item.skuCode) { return { @@ -397,6 +497,8 @@ async function focusValidationTarget(state: ValidationState) { card.scrollIntoView({ behavior: 'smooth', block: 'center' }) const selectors: Record = { + khhaoShopId: '[data-field="khhaoShopId"]', + shopId: '[data-field="shopId"]', skuCode: '[data-field="skuCode"]', match: '[data-field="externalSkuCode"]', profileKey: '[data-field="profileKey"]', @@ -426,6 +528,16 @@ function isFieldInvalid(bindingId: string, field: ValidationField | 'externalSku return validationState.value.field === field } +function handleBindingProviderPlatformChange(binding: EditableBinding) { + syncBindingShopSelection(binding) + clearValidationState() +} + +function handleKuaishouShopChange(binding: EditableBinding) { + syncBindingShopSelection(binding) + clearValidationState() +} + async function saveConfigs() { const normalizedBindings = bindings.value.map(normalizeBindingForSave) const nonEmptyBindings = normalizedBindings.filter(hasBindingContent) @@ -649,18 +761,90 @@ onMounted(loadConfigs)
- + + +
-
- -

“确认绑定完成并发货”已经合并为一个按钮,不再拆成两步。

+
+
+

客户领取页

+
+
客户链接{{ claimUrl || '-' }}
+
Token 状态{{ detail.claimToken?.status || '-' }}
+
首次提交{{ formatAdminDateTime(kuaishouCloudFlow.ticket.capturedAt) }}
+
校验时间{{ formatAdminDateTime(kuaishouCloudFlow.ticket.verifiedAt) }}
+
+

客户会先在这个页面查看图文指引并提交核销码,校验通过后才会自动跳转绑定链接。

+
+ +
+

核销码状态

+
+
核销码{{ kuaishouCloudFlow.ticket.code || '-' }}
+
校验状态{{ kuaishouCloudFlow.ticket.status || '-' }}
+
商品标题{{ kuaishouCloudFlow.ticket.goodsTitle || '-' }}
+
剩余次数{{ kuaishouCloudFlow.ticket.leftCount || 0 }}
+
+

“确认绑定完成并发货”已经合并为一个按钮,只有客户提交并校验过核销码后才能继续。

+
@@ -643,6 +644,8 @@ onBeforeUnmount(clearScreenshotPreview)
自动退号{{ kuaishouCloudFlow.returnNumber.autoReturnEnabled ? '开启' : '关闭' }}
自动核销{{ kuaishouCloudFlow.consume.autoConsumeEnabled ? '开启' : '关闭' }}
核销店铺{{ kuaishouCloudFlow.consume.shopId || '-' }}
+
核销时间{{ formatAdminDateTime(kuaishouCloudFlow.consume.consumedAt) }}
+
核销异常{{ kuaishouCloudFlow.consume.errorMessage || '-' }}
备注{{ kuaishouCloudFlow.notes || '-' }}
diff --git a/apps/frontend/src/views/claim/ClaimTencentView.vue b/apps/frontend/src/views/claim/ClaimTencentView.vue new file mode 100644 index 00000000..056e312d --- /dev/null +++ b/apps/frontend/src/views/claim/ClaimTencentView.vue @@ -0,0 +1,357 @@ + + + + + diff --git a/apps/frontend/src/views/claim/ClaimView.vue b/apps/frontend/src/views/claim/ClaimView.vue index 24eecc69..36a30180 100644 --- a/apps/frontend/src/views/claim/ClaimView.vue +++ b/apps/frontend/src/views/claim/ClaimView.vue @@ -1,374 +1,89 @@ diff --git a/apps/frontend/src/views/claim/KuaishouCloudClaimView.vue b/apps/frontend/src/views/claim/KuaishouCloudClaimView.vue new file mode 100644 index 00000000..6c4b2763 --- /dev/null +++ b/apps/frontend/src/views/claim/KuaishouCloudClaimView.vue @@ -0,0 +1,487 @@ + + + + +