From b568d70e64ed07def92922e0c9e854328f30743b Mon Sep 17 00:00:00 2001 From: yml Date: Sun, 3 May 2026 16:34:39 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BF=AB=E6=89=8B=E5=B0=8F=E5=BA=97-=E6=A0=B8?= =?UTF-8?q?=E9=94=80--ok?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/backend/data/khhao-sync-state.json | 6 +- .../backend/data/kuaishou-eticket-source.json | 20 + .../src/routes/admin/platform-config.js | 109 +++ .../admin/admin-platform-config-service.js | 110 +++ .../kuaishou-eticket/consume-service.js | 233 +++++ .../kuaishou-eticket/consume-service.test.js | 72 ++ .../platforms/kuaishou-eticket/http-client.js | 170 ++++ .../kuaishou-eticket/info-service.js | 61 ++ .../kuaishou-eticket/info-service.test.js | 32 + .../platforms/kuaishou-eticket/shared.js | 75 ++ .../kuaishou-eticket/source-config-service.js | 122 +++ apps/backend/src/types/admin-route-inputs.js | 16 + apps/backend/src/types/admin-write-inputs.js | 48 + apps/frontend/src/services/admin.ts | 48 + apps/frontend/src/types/admin.ts | 84 ++ .../views/admin/AdminPlatformShopsView.vue | 836 +++++++++++++++++- 快手小店核销.md | 74 ++ 17 files changed, 2108 insertions(+), 8 deletions(-) create mode 100644 apps/backend/data/kuaishou-eticket-source.json create mode 100644 apps/backend/src/services/platforms/kuaishou-eticket/consume-service.js create mode 100644 apps/backend/src/services/platforms/kuaishou-eticket/consume-service.test.js create mode 100644 apps/backend/src/services/platforms/kuaishou-eticket/http-client.js create mode 100644 apps/backend/src/services/platforms/kuaishou-eticket/info-service.js create mode 100644 apps/backend/src/services/platforms/kuaishou-eticket/info-service.test.js create mode 100644 apps/backend/src/services/platforms/kuaishou-eticket/shared.js create mode 100644 apps/backend/src/services/platforms/kuaishou-eticket/source-config-service.js create mode 100644 快手小店核销.md diff --git a/apps/backend/data/khhao-sync-state.json b/apps/backend/data/khhao-sync-state.json index 17d87a58..f4d17625 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-02T16:37:16.806Z", - "lastRunFinishedAt": "2026-05-02T16:37:17.707Z", + "lastRunStartedAt": "2026-05-03T08:34:17.211Z", + "lastRunFinishedAt": "2026-05-03T08:34:17.844Z", "lastRunStatus": "success", "lastErrorMessage": "", "fetchedCount": 40, "syncedCount": 0, "ignoredCount": 40, - "lastOrderCreatedAt": "2026-05-02T16:36:46.000Z" + "lastOrderCreatedAt": "2026-05-03T08:33:37.000Z" } diff --git a/apps/backend/data/kuaishou-eticket-source.json b/apps/backend/data/kuaishou-eticket-source.json new file mode 100644 index 00000000..aa2bd142 --- /dev/null +++ b/apps/backend/data/kuaishou-eticket-source.json @@ -0,0 +1,20 @@ +{ + "enabled": true, + "baseUrl": "https://s.kwaixiaodian.com", + "shops": [ + { + "shopId": "140000112682050", + "kshopName": "洋洋运营", + "cookie": "_did=web_412376696871A458; did=web_pdd5bx2mm4sdx78w7zg3oj1uya9cqs59; bUserId=1000598999789; userId=140000112682050; merchantSellerId=4269276762; sid=kshop.api; kshop.api_st=Cgxrc2hvcC5hcGkuc3QSoAEHQpSWH5NWp5ReO9nWzyqJIZ9NsB7YhqywKcjVzUAf1YBJODvE9HHSA5jNrO1kyoYtQmK7hlUmD7FCkDjvzTNoDJCd5mGiu_oFbUr-JYJ2--q_QNhHlY13he_NQSPQ7EYDf54NBMk1jKQNarlUnB-1WosXNgV7G5YNeZXr8CDMWkDwKUaDnG-2JGmPTsouINryXjteKsj4YZEzwWHR2GTZGhJex1zz60qNCRAHidwVN1PhX4wiIN3oIIsuly3Zub__D_EnvOhbCP_Ma363nNk4hmpKm_NpKAUwAQ; kshop.api_ph=fabd3673d2238257e37fc892abf2e8e73fd4; KS-CSRF-Token=179C60C8C34FB066A71221C9EE64B5CE; merchantSessionKey=1777795509898_209a40cd7ad55a7e842151ee83de41bb", + "userAvatar": "https://p2-pro.a.yximgs.com/uhead/AB/2024/07/23/17/BMjAyNDA3MjMxNzA1MThfNDI2OTI3Njc2Ml8yX2hkNDg1XzgxNQ==_s.jpg", + "enabled": true + }, + { + "shopId": "4269276762", + "kshopName": "稚嫩游戏交易店", + "cookie": "_did=web_1426300268274CD9; did=web_ozkrrsevhb6yuztyk6m63vqqi5h2pzfl; __risk_web_device_id=cd52e12c1773406609757e71; sid=kuaishou.shop.b; sellerId=4269276762; bUserId=1000492186791; userId=4269276762; merchantSellerId=4269276762; merchantSessionKey=1777621574120_fbd47024d23e73fc4ad1a157ffece433; kuaishou.shop.b_st=ChJrdWFpc2hvdS5zaG9wLmIuc3QSoAFzGnewF-wCpt4rHi-D-pp8zPUp7HByKY0MlHkw-WV676zG7z09Y5i7d7URwmGC8CL13jN0fOGV7d3GfaH2XLH8d4p60z0Ms-dIX0bipkI0yUWHfgnWqv9VPa8HqVRqMLs2VGD82X9tAR3eN9PXu38AYg0a5kpawchGPb2tjcYgLgH5b5DnJgjipHqzWb8Ma8AcLOmryqCSg4ZHwO5dbVJHGhIdwFBlxRpZgyh6i0Do-DSRsroiIIayd1xA628OsvCY2qV1HjCrTvMVt-3LmXcU6rLKSzytKAUwAQ; kuaishou.shop.b_ph=38925ccb882ec5456dd1c6bcee3f87b7e740", + "userAvatar": "https://p2-pro.a.yximgs.com/uhead/AB/2024/07/23/17/BMjAyNDA3MjMxNzA1MThfNDI2OTI3Njc2Ml8yX2hkNDg1XzgxNQ==_s.jpg", + "enabled": true + } + ] +} diff --git a/apps/backend/src/routes/admin/platform-config.js b/apps/backend/src/routes/admin/platform-config.js index fab92bc2..3835be7c 100644 --- a/apps/backend/src/routes/admin/platform-config.js +++ b/apps/backend/src/routes/admin/platform-config.js @@ -10,20 +10,25 @@ import { getAdminCloudtentaclesAsset, getAdminCloudtentaclesBindUrl, getAdminCloudtentaclesCategories, + getAdminKuaishouEticketSourceConfig, getAdminCloudtentaclesKnapsack, getAdminCloudtentaclesSourceConfig, getAdminCloudtentaclesSkuList, getAdminAgisoShopConfigs, getAdminFulfillmentBindingConfigs, getAdminKhhaoSourceConfig, + consumeAdminKuaishouEticket, listAdminCloudtentaclesVirtualNumbers, lookupAdminFulfillmentBindingOrder, + queryAdminKuaishouEticketShopInfo, + queryAdminKuaishouEticketDetail, queryAdminKhhaoOrders, runAdminCloudtentaclesFullFlow, sendAdminCloudtentaclesSmsCode, syncAdminKhhaoOrders, testAdminCloudtentaclesLogin, testAdminKhhaoLogin, + updateAdminKuaishouEticketSourceConfig, updateAdminCloudtentaclesSourceConfig, updateAdminAgisoShopConfigs, updateAdminKhhaoSourceConfig, @@ -39,6 +44,10 @@ import { createJsonHandler, requireAdminRoles } from './shared.js' /** @typedef {import('../../types/admin-route-inputs.js').AdminKhhaoOrderQueryRouteBody} AdminKhhaoOrderQueryRouteBody */ /** @typedef {import('../../types/admin-route-inputs.js').AdminKhhaoSourceConfigRouteBody} AdminKhhaoSourceConfigRouteBody */ /** @typedef {import('../../types/admin-route-inputs.js').AdminKhhaoTestLoginRouteBody} AdminKhhaoTestLoginRouteBody */ +/** @typedef {import('../../types/admin-route-inputs.js').AdminKuaishouEticketSourceConfigRouteBody} AdminKuaishouEticketSourceConfigRouteBody */ +/** @typedef {import('../../types/admin-route-inputs.js').AdminKuaishouEticketDetailQueryRouteBody} AdminKuaishouEticketDetailQueryRouteBody */ +/** @typedef {import('../../types/admin-route-inputs.js').AdminKuaishouEticketConsumeRouteBody} AdminKuaishouEticketConsumeRouteBody */ +/** @typedef {import('../../types/admin-route-inputs.js').AdminKuaishouEticketShopInfoRouteBody} AdminKuaishouEticketShopInfoRouteBody */ /** @typedef {import('../../types/admin-route-inputs.js').AdminCloudtentaclesSourceConfigRouteBody} AdminCloudtentaclesSourceConfigRouteBody */ /** @typedef {import('../../types/admin-route-inputs.js').AdminCloudtentaclesSendSmsCodeRouteBody} AdminCloudtentaclesSendSmsCodeRouteBody */ /** @typedef {import('../../types/admin-route-inputs.js').AdminCloudtentaclesTestLoginRouteBody} AdminCloudtentaclesTestLoginRouteBody */ @@ -83,6 +92,37 @@ router.post('/platform-config/agiso-shops', createJsonHandler( }, )) +router.get('/platform-config/kuaishou-eticket-source', createJsonHandler( + () => getAdminKuaishouEticketSourceConfig(), + { + successMessage: 'ok', + errorMessage: '读取快手小店核销配置失败', + scope: '[admin/platform-config/kuaishou-eticket-source]', + }, +)) + +router.post('/platform-config/kuaishou-eticket-source', createJsonHandler( + (req) => updateAdminKuaishouEticketSourceConfig(/** @type {AdminKuaishouEticketSourceConfigRouteBody} */ (req.body)), + { + successMessage: '快手小店核销配置已保存', + errorMessage: '保存快手小店核销配置失败', + scope: '[admin/platform-config/kuaishou-eticket-source]', + audit: (_req, data) => { + const result = /** @type {{ filePath?: string, source?: { enabled?: boolean, shops?: unknown[] } }} */ (data) + return { + action: 'platform_kuaishou_eticket_source_updated', + targetType: 'platform_config', + targetId: 'kuaishou_eticket_source', + data: { + filePath: String(result.filePath || '').trim(), + enabled: Boolean(result.source?.enabled), + shopCount: Array.isArray(result.source?.shops) ? result.source.shops.length : 0, + }, + } + }, + }, +)) + router.get('/platform-config/khhao-source', createJsonHandler( () => getAdminKhhaoSourceConfig(), { @@ -188,6 +228,75 @@ router.post('/platform-config/khhao/sync-orders', createJsonHandler( }, )) +router.post('/platform-config/kuaishou-eticket/query-detail', createJsonHandler( + (req) => queryAdminKuaishouEticketDetail(/** @type {AdminKuaishouEticketDetailQueryRouteBody} */ (req.body)), + { + successMessage: '快手小店核销信息查询成功', + errorMessage: '查询快手小店核销信息失败', + scope: '[admin/platform-config/kuaishou-eticket/query-detail]', + audit: (req, data) => { + const body = /** @type {AdminKuaishouEticketDetailQueryRouteBody} */ (req.body) + const result = /** @type {{ eTicketId?: string, result?: number, ok?: boolean, alreadyConsumed?: boolean }} */ (data) + return { + action: 'platform_kuaishou_eticket_query_detail', + targetType: 'platform_config', + targetId: String(result.eTicketId || body.eTicketId || '').trim() || 'kuaishou_eticket', + data: { + result: Number(result.result || 0), + ok: Boolean(result.ok), + alreadyConsumed: Boolean(result.alreadyConsumed), + }, + } + }, + }, +)) + +router.post('/platform-config/kuaishou-eticket/query-shop-info', createJsonHandler( + (req) => queryAdminKuaishouEticketShopInfo(/** @type {AdminKuaishouEticketShopInfoRouteBody} */ (req.body)), + { + successMessage: '快手小店店铺信息查询成功', + errorMessage: '查询快手小店店铺信息失败', + scope: '[admin/platform-config/kuaishou-eticket/query-shop-info]', + audit: (req, data) => { + const body = /** @type {AdminKuaishouEticketShopInfoRouteBody} */ (req.body) + const result = /** @type {{ shop?: { shopId?: string, kshopName?: string }, result?: number, ok?: boolean }} */ (data) + return { + action: 'platform_kuaishou_eticket_query_shop_info', + targetType: 'platform_config', + targetId: String(result.shop?.shopId || body.shopId || '').trim() || 'kuaishou_eticket_shop_info', + data: { + result: Number(result.result || 0), + ok: Boolean(result.ok), + kshopName: String(result.shop?.kshopName || '').trim(), + }, + } + }, + }, +)) + +router.post('/platform-config/kuaishou-eticket/consume', createJsonHandler( + (req) => consumeAdminKuaishouEticket(/** @type {AdminKuaishouEticketConsumeRouteBody} */ (req.body)), + { + successMessage: '快手小店核销请求已执行', + errorMessage: '执行快手小店核销失败', + scope: '[admin/platform-config/kuaishou-eticket/consume]', + audit: (req, data) => { + const body = /** @type {AdminKuaishouEticketConsumeRouteBody} */ (req.body) + const result = /** @type {{ eTicketId?: string, result?: number, consumed?: boolean, alreadyConsumed?: boolean }} */ (data) + return { + action: 'platform_kuaishou_eticket_consume', + targetType: 'platform_config', + targetId: String(result.eTicketId || body.eTicketId || '').trim() || 'kuaishou_eticket', + data: { + result: Number(result.result || 0), + consumed: Boolean(result.consumed), + alreadyConsumed: Boolean(result.alreadyConsumed), + }, + } + }, + }, +)) + router.get('/platform-config/cloudtentacles-source', createJsonHandler( () => getAdminCloudtentaclesSourceConfig(), { 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 e1ecc9f9..288b15de 100644 --- a/apps/backend/src/services/admin/admin-platform-config-service.js +++ b/apps/backend/src/services/admin/admin-platform-config-service.js @@ -15,6 +15,19 @@ import { syncKhhaoOrders } from '../platforms/khhao/order-sync-service.js' import { getKhhaoSourceConfig, getKhhaoSourcesFilePath, saveKhhaoSourceConfig } from '../platforms/khhao/source-config-service.js' import { ensureKhhaoSyncBaseline, getKhhaoSyncState, getKhhaoSyncStateFilePath } from '../platforms/khhao/sync-state-service.js' import { loginKhhaoSession } from '../platforms/khhao/session-service.js' +import { + consumeKuaishouEticket, + queryKuaishouEticketConsumeDetail, +} from '../platforms/kuaishou-eticket/consume-service.js' +import { + findKuaishouEticketShopConfig, + getFirstAvailableKuaishouEticketShop, + getKuaishouEticketSourceConfig, + getKuaishouEticketSourceFilePath, + listKuaishouEticketShopConfigs, + saveKuaishouEticketSourceConfig, +} from '../platforms/kuaishou-eticket/source-config-service.js' +import { queryKuaishouEticketStableInfo } from '../platforms/kuaishou-eticket/info-service.js' import { getCloudtentaclesSourceConfig, getCloudtentaclesSourcesFilePath, @@ -62,6 +75,10 @@ import { resolveDisplayShopName } from './admin-read-shared-helpers.js' /** @typedef {import('../../types/admin-write-inputs.js').AdminAgisoShopConfigSaveInput} AdminAgisoShopConfigSaveInput */ /** @typedef {import('../../types/admin-write-inputs.js').AdminKhhaoSourceConfigInput} AdminKhhaoSourceConfigInput */ +/** @typedef {import('../../types/admin-write-inputs.js').AdminKuaishouEticketSourceConfigInput} AdminKuaishouEticketSourceConfigInput */ +/** @typedef {import('../../types/admin-write-inputs.js').AdminKuaishouEticketShopInfoInput} AdminKuaishouEticketShopInfoInput */ +/** @typedef {import('../../types/admin-write-inputs.js').AdminKuaishouEticketDetailQueryInput} AdminKuaishouEticketDetailQueryInput */ +/** @typedef {import('../../types/admin-write-inputs.js').AdminKuaishouEticketConsumeInput} AdminKuaishouEticketConsumeInput */ /** @typedef {import('../../types/admin-write-inputs.js').AdminKhhaoOrderQueryInput} AdminKhhaoOrderQueryInput */ /** @typedef {import('../../types/admin-write-inputs.js').AdminKhhaoTestLoginInput} AdminKhhaoTestLoginInput */ /** @typedef {import('../../types/admin-write-inputs.js').AdminCloudtentaclesSourceConfigInput} AdminCloudtentaclesSourceConfigInput */ @@ -258,6 +275,37 @@ export function getAdminKhhaoSourceConfig() { } } +export function getAdminKuaishouEticketSourceConfig() { + const config = getKuaishouEticketSourceConfig() + + return { + filePath: getKuaishouEticketSourceFilePath(), + source: { + enabled: config.enabled !== false, + baseUrl: String(config.baseUrl || '').trim(), + shops: listKuaishouEticketShopConfigs(config).map((item) => mapAdminKuaishouEticketShopItem(item)), + }, + } +} + +/** @param {AdminKuaishouEticketSourceConfigInput} [payload] */ +export function updateAdminKuaishouEticketSourceConfig(payload = /** @type {AdminKuaishouEticketSourceConfigInput} */ ({})) { + const saved = saveKuaishouEticketSourceConfig({ + enabled: payload.enabled !== false, + baseUrl: String(payload.baseUrl || '').trim() || 'https://s.kwaixiaodian.com', + shops: Array.isArray(payload.shops) ? payload.shops : [], + }) + + return { + filePath: getKuaishouEticketSourceFilePath(), + source: { + enabled: saved.enabled !== false, + baseUrl: String(saved.baseUrl || '').trim(), + shops: listKuaishouEticketShopConfigs(saved).map((item) => mapAdminKuaishouEticketShopItem(item)), + }, + } +} + /** @param {AdminKhhaoSourceConfigInput} [payload] */ export function updateAdminKhhaoSourceConfig(payload = /** @type {AdminKhhaoSourceConfigInput} */ ({})) { const saved = saveKhhaoSourceConfig({ @@ -376,6 +424,41 @@ export async function syncAdminKhhaoOrders(payload = /** @type {AdminKhhaoOrderQ }) } +/** @param {AdminKuaishouEticketDetailQueryInput} [payload] */ +export async function queryAdminKuaishouEticketDetail(payload = /** @type {AdminKuaishouEticketDetailQueryInput} */ ({})) { + const context = resolveKuaishouEticketAdminContext(payload) + + return queryKuaishouEticketConsumeDetail({ + baseUrl: context.baseUrl, + cookie: context.cookie, + eTicketId: String(payload.eTicketId || '').trim(), + }) +} + +/** @param {AdminKuaishouEticketShopInfoInput} [payload] */ +export async function queryAdminKuaishouEticketShopInfo(payload = /** @type {AdminKuaishouEticketShopInfoInput} */ ({})) { + const context = resolveKuaishouEticketAdminContext(payload) + return queryKuaishouEticketStableInfo({ + baseUrl: context.baseUrl, + cookie: context.cookie, + }) +} + +/** @param {AdminKuaishouEticketConsumeInput} [payload] */ +export async function consumeAdminKuaishouEticket(payload = /** @type {AdminKuaishouEticketConsumeInput} */ ({})) { + const context = resolveKuaishouEticketAdminContext(payload) + + return consumeKuaishouEticket({ + baseUrl: context.baseUrl, + cookie: context.cookie, + eTicketId: String(payload.eTicketId || '').trim(), + oid: String(payload.oid || '').trim(), + formToken: String(payload.formToken || '').trim(), + num: payload.num, + storeId: String(payload.storeId || '').trim(), + }) +} + export function getAdminCloudtentaclesSourceConfig() { const config = getCloudtentaclesSourceConfig() const session = getCloudtentaclesSessionState() @@ -984,6 +1067,33 @@ function maskPhone(value) { return `${normalized.slice(0, 3)}****${normalized.slice(-4)}` } +function mapAdminKuaishouEticketShopItem(item = {}) { + return { + shopId: String(item.shopId || '').trim(), + kshopName: String(item.kshopName || '').trim(), + cookie: String(item.cookie || '').trim(), + cookieMasked: maskSecret(item.cookie), + hasCookie: Boolean(String(item.cookie || '').trim()), + userAvatar: String(item.userAvatar || '').trim(), + enabled: item.enabled !== false, + } +} + +function resolveKuaishouEticketAdminContext(payload = {}) { + const savedSource = getKuaishouEticketSourceConfig() + const requestedShopId = String(payload.shopId || '').trim() + const configuredShop = requestedShopId + ? findKuaishouEticketShopConfig(requestedShopId, savedSource) + : getFirstAvailableKuaishouEticketShop(savedSource) + + return { + baseUrl: pickFirstNonEmpty([payload.baseUrl, savedSource.baseUrl, 'https://s.kwaixiaodian.com']), + cookie: pickFirstNonEmpty([payload.cookie, configuredShop?.cookie]), + shopId: pickFirstNonEmpty([requestedShopId, configuredShop?.shopId]), + shop: configuredShop, + } +} + function resolveCloudtentaclesAdminContext(payload = {}) { const savedSource = getCloudtentaclesSourceConfig() const persistedSession = getCloudtentaclesSessionState() diff --git a/apps/backend/src/services/platforms/kuaishou-eticket/consume-service.js b/apps/backend/src/services/platforms/kuaishou-eticket/consume-service.js new file mode 100644 index 00000000..6703d81d --- /dev/null +++ b/apps/backend/src/services/platforms/kuaishou-eticket/consume-service.js @@ -0,0 +1,233 @@ +// @ts-check + +import { createHttpError } from '../../../utils/http.js' +import { kuaishouEticketRequest } from './http-client.js' +import { resolveKuaishouEticketConfig } from './shared.js' + +export async function queryKuaishouEticketConsumeDetail(payload = {}) { + const context = resolveKuaishouEticketActionContext(payload) + const response = await kuaishouEticketRequest(context.detailPath, { + baseUrl: context.baseUrl, + cookie: context.cookie, + timeoutMs: context.timeoutMs, + body: { + eTicketId: context.eTicketId, + }, + }) + + return mapKuaishouEticketDetailResult(response.payload, { + baseUrl: context.baseUrl, + eTicketId: context.eTicketId, + }) +} + +export async function consumeKuaishouEticket(payload = {}) { + const context = resolveKuaishouEticketActionContext(payload) + const detailResult = shouldResolveConsumeDetail(payload) + ? await queryKuaishouEticketConsumeDetail({ + baseUrl: context.baseUrl, + cookie: context.cookie, + eTicketId: context.eTicketId, + timeoutMs: context.timeoutMs, + }) + : null + const consumePayload = resolveKuaishouEticketConsumePayload(payload, detailResult) + + const response = await kuaishouEticketRequest(context.consumePath, { + baseUrl: context.baseUrl, + cookie: context.cookie, + timeoutMs: context.timeoutMs, + body: consumePayload, + }) + + return mapKuaishouEticketConsumeResult(response.payload, { + baseUrl: context.baseUrl, + eTicketId: consumePayload.eTicketId, + oid: consumePayload.oid, + formToken: consumePayload.formToken, + num: consumePayload.num, + storeId: consumePayload.storeId, + detailResult, + }) +} + +export function mapKuaishouEticketDetailResult(payload = {}, context = {}) { + const normalized = normalizeKuaishouEticketApiResult(payload) + const detail = mapKuaishouEticketDetailPayload(payload?.data?.eTicket) + const goods = mapKuaishouEticketGoodsPayload(payload?.data?.goods) + + return { + baseUrl: String(context.baseUrl || '').trim(), + eTicketId: String(context.eTicketId || detail?.eTicketId || '').trim(), + ok: normalized.ok, + alreadyConsumed: normalized.result === 21, + result: normalized.result, + errorMessage: normalized.errorMessage, + requestId: normalized.requestId, + serverTimestamp: normalized.serverTimestamp, + detail, + goods, + raw: payload && typeof payload === 'object' ? payload : {}, + } +} + +export function mapKuaishouEticketConsumeResult(payload = {}, context = {}) { + const normalized = normalizeKuaishouEticketApiResult(payload) + + return { + baseUrl: String(context.baseUrl || '').trim(), + eTicketId: String(context.eTicketId || '').trim(), + oid: String(context.oid || '').trim(), + formToken: String(context.formToken || '').trim(), + num: normalizePositiveInteger(context.num, 1), + storeId: String(context.storeId || '0').trim() || '0', + ok: normalized.ok, + consumed: normalized.result === 1 || normalized.result === 21, + alreadyConsumed: normalized.result === 21, + result: normalized.result, + errorMessage: normalized.errorMessage, + requestId: normalized.requestId, + serverTimestamp: normalized.serverTimestamp, + detail: context.detailResult?.detail || null, + raw: payload && typeof payload === 'object' ? payload : {}, + } +} + +export function resolveKuaishouEticketConsumePayload(payload = {}, detailResult = null) { + const detail = detailResult?.detail || null + const eTicketId = pickFirstNonEmpty([payload.eTicketId, detail?.eTicketId]) + const oid = pickFirstNonEmpty([payload.oid, detail?.oid]) + const formToken = pickFirstNonEmpty([payload.formToken, detail?.formToken]) + const storeId = pickFirstNonEmpty([payload.storeId, '0']) + const num = normalizePositiveInteger(payload.num, normalizePositiveInteger(detail?.leftCount, 1)) + + if (!eTicketId) { + throw createHttpError('请先填写 eTicketId', { + statusCode: 400, + errorCode: 'kuaishou_eticket_missing_eticket_id', + }) + } + + if (!oid) { + throw createHttpError('缺少核销所需 oid,请先查询核销信息', { + statusCode: 400, + errorCode: 'kuaishou_eticket_missing_oid', + }) + } + + if (!formToken) { + throw createHttpError('缺少核销所需 formToken,请先查询核销信息', { + statusCode: 400, + errorCode: 'kuaishou_eticket_missing_form_token', + }) + } + + return { + eTicketId, + num, + storeId, + oid, + formToken, + } +} + +export function normalizeKuaishouEticketApiResult(payload = {}) { + const result = Number(payload?.result || 0) + + return { + ok: result === 1, + result, + errorMessage: String(payload?.error_msg || '').trim(), + requestId: String(payload?.requestId || '').trim(), + serverTimestamp: String(payload?.serverTimestamp || '').trim(), + } +} + +export function mapKuaishouEticketDetailPayload(value = {}) { + if (!isPlainObject(value)) { + return null + } + + return { + uid: String(value.uid || '').trim(), + fulfillDetailId: String(value.fulfillDetailId || '').trim(), + sellerId: String(value.sellerId || '').trim(), + formToken: String(value.formToken || '').trim(), + validEndTime: String(value.validEndTime || '').trim(), + validStartTime: String(value.validStartTime || '').trim(), + leftReverseCount: normalizeInteger(value.leftReverseCount, 0), + eTicketId: String(value.eTicketId || '').trim(), + oid: String(value.oid || '').trim(), + totalCount: normalizeInteger(value.totalCount, 0), + leftCount: normalizeInteger(value.leftCount, 0), + status: String(value.status || '').trim(), + } +} + +export function mapKuaishouEticketGoodsPayload(value = {}) { + if (!isPlainObject(value)) { + return null + } + + return { + itemId: String(value.itemId || '').trim(), + itemPicUrl: String(value.itemPicUrl || '').trim(), + itemTitle: String(value.itemTitle || '').trim(), + price: String(value.price || '').trim(), + skuDesc: String(value.skuDesc || '').trim(), + skuId: String(value.skuId || '').trim(), + } +} + +function resolveKuaishouEticketActionContext(payload = {}) { + const config = resolveKuaishouEticketConfig(payload) + const eTicketId = String(payload.eTicketId || '').trim() + + if (!config.cookie) { + throw createHttpError('请先配置快手小店 Cookie', { + statusCode: 400, + errorCode: 'kuaishou_eticket_missing_cookie', + }) + } + + if (!eTicketId) { + throw createHttpError('请先填写 eTicketId', { + statusCode: 400, + errorCode: 'kuaishou_eticket_missing_eticket_id', + }) + } + + return { + ...config, + eTicketId, + } +} + +function shouldResolveConsumeDetail(payload = {}) { + return !String(payload.oid || '').trim() || !String(payload.formToken || '').trim() +} + +function pickFirstNonEmpty(values) { + for (const value of values) { + const normalized = String(value || '').trim() + if (normalized) { + return normalized + } + } + + return '' +} + +function normalizePositiveInteger(value, fallback) { + const parsed = Number(value) + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback +} + +function normalizeInteger(value, fallback) { + const parsed = Number(value) + return Number.isInteger(parsed) ? parsed : fallback +} + +function isPlainObject(value) { + return Object.prototype.toString.call(value) === '[object Object]' +} diff --git a/apps/backend/src/services/platforms/kuaishou-eticket/consume-service.test.js b/apps/backend/src/services/platforms/kuaishou-eticket/consume-service.test.js new file mode 100644 index 00000000..555b154d --- /dev/null +++ b/apps/backend/src/services/platforms/kuaishou-eticket/consume-service.test.js @@ -0,0 +1,72 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { + mapKuaishouEticketDetailResult, + normalizeKuaishouEticketApiResult, + resolveKuaishouEticketConsumePayload, +} from './consume-service.js' + +test('normalizeKuaishouEticketApiResult recognizes already consumed response', () => { + const result = normalizeKuaishouEticketApiResult({ + result: 21, + error_msg: '券码已核销无法再次核销,请核实后重试', + requestId: 'abc', + }) + + assert.equal(result.ok, false) + assert.equal(result.result, 21) + assert.equal(result.errorMessage, '券码已核销无法再次核销,请核实后重试') + assert.equal(result.requestId, 'abc') +}) + +test('mapKuaishouEticketDetailResult maps detail payload fields', () => { + const result = mapKuaishouEticketDetailResult({ + result: 1, + error_msg: '成功', + data: { + eTicket: { + eTicketId: 'F2E886B6CBF8E5F5', + oid: '2612200246413561', + formToken: '1777716306154', + leftCount: 1, + totalCount: 1, + status: 'CONSUME_ORDER_CREATED', + }, + goods: { + itemTitle: '测试链接五排', + skuDesc: '测试1', + }, + }, + }, { + baseUrl: 'https://s.kwaixiaodian.com', + eTicketId: 'F2E886B6CBF8E5F5', + }) + + assert.equal(result.ok, true) + assert.equal(result.detail?.oid, '2612200246413561') + assert.equal(result.detail?.formToken, '1777716306154') + assert.equal(result.detail?.leftCount, 1) + assert.equal(result.goods?.itemTitle, '测试链接五排') +}) + +test('resolveKuaishouEticketConsumePayload falls back to detail result', () => { + const payload = resolveKuaishouEticketConsumePayload({ + eTicketId: 'F2E886B6CBF8E5F5', + }, { + detail: { + eTicketId: 'F2E886B6CBF8E5F5', + oid: '2612200246413561', + formToken: '1777716306154', + leftCount: 2, + }, + }) + + assert.deepEqual(payload, { + eTicketId: 'F2E886B6CBF8E5F5', + num: 2, + storeId: '0', + oid: '2612200246413561', + formToken: '1777716306154', + }) +}) diff --git a/apps/backend/src/services/platforms/kuaishou-eticket/http-client.js b/apps/backend/src/services/platforms/kuaishou-eticket/http-client.js new file mode 100644 index 00000000..ec11ebae --- /dev/null +++ b/apps/backend/src/services/platforms/kuaishou-eticket/http-client.js @@ -0,0 +1,170 @@ +// @ts-check + +import http from 'node:http' +import https from 'node:https' + +import { parseJsonObject } from '../../../utils/json.js' +import { createHttpError } from '../../../utils/http.js' +import { logInfo } from '../../../utils/logger.js' +import { buildKuaishouEticketHeaders, buildKuaishouEticketUrl, resolveKuaishouEticketConfig } from './shared.js' + +export async function kuaishouEticketRequest(pathname, options = {}) { + const config = resolveKuaishouEticketConfig(options) + const url = buildKuaishouEticketUrl(config.baseUrl, pathname) + const timeoutMs = Number(options.timeoutMs || config.timeoutMs || 8000) + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeoutMs) + const method = String(options.method || 'POST').trim().toUpperCase() + const headers = buildKuaishouEticketHeaders({ + baseUrl: config.baseUrl, + refererPath: config.refererPath, + userAgent: config.userAgent, + cookie: options.cookie ?? config.cookie, + contentType: options.contentType === null ? '' : options.contentType || inferContentType(options.body), + extra: options.headers, + }) + + try { + const response = await requestViaNodeHttp(url, { + method, + headers, + body: normalizeRequestBody(options.body, headers['content-type']), + signal: controller.signal, + }) + + const rawText = response.bodyText + const payload = parseJsonObject(rawText, { preserveLargeIntegers: true }) + + if (!response.ok) { + throw createHttpError(`快手小店核销请求失败,HTTP ${response.status}`, { + statusCode: 502, + errorCode: 'kuaishou_eticket_http_error', + }) + } + + logInfo('[kuaishou-eticket/http]', '请求完成', { + method, + pathname, + status: response.status, + result: Number(payload?.result || 0), + }) + + return { + url: url.toString(), + status: response.status, + headers: response.headers, + payload, + rawText, + } + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + throw createHttpError(`快手小店核销请求超时(${timeoutMs}ms)`, { + statusCode: 504, + errorCode: 'kuaishou_eticket_request_timeout', + }) + } + throw error + } finally { + clearTimeout(timer) + } +} + +function inferContentType(body) { + if (body == null) { + return '' + } + + if (typeof body === 'string') { + return 'application/json' + } + + return 'application/json' +} + +function normalizeRequestBody(body, contentType) { + if (body == null) { + return undefined + } + + if (typeof body === 'string') { + return body + } + + if (String(contentType || '').includes('application/json')) { + return JSON.stringify(body) + } + + return String(body) +} + +async function requestViaNodeHttp(url, { method, headers, body, signal }) { + const isHttps = url.protocol === 'https:' + const transport = isHttps ? https : http + + return new Promise((resolve, reject) => { + const request = transport.request(url, { + method, + headers, + rejectUnauthorized: false, + }, (response) => { + const chunks = [] + + response.on('data', (chunk) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)) + }) + + response.on('end', () => { + const bodyText = Buffer.concat(chunks).toString('utf8') + resolve({ + ok: Number(response.statusCode || 0) >= 200 && Number(response.statusCode || 0) < 300, + status: Number(response.statusCode || 0), + headers: createHeaderAdapter(response.headers), + bodyText, + }) + }) + }) + + request.on('error', reject) + + if (signal) { + if (signal.aborted) { + const error = new Error('Request aborted') + error.name = 'AbortError' + request.destroy(error) + } else { + signal.addEventListener('abort', () => { + const error = new Error('Request aborted') + error.name = 'AbortError' + request.destroy(error) + }, { once: true }) + } + } + + if (typeof body !== 'undefined') { + request.write(body) + } + + request.end() + }) +} + +function createHeaderAdapter(headers) { + const normalized = new Map() + + for (const [key, value] of Object.entries(headers || {})) { + if (Array.isArray(value)) { + normalized.set(String(key || '').toLowerCase(), value.join(', ')) + continue + } + + if (typeof value === 'string') { + normalized.set(String(key || '').toLowerCase(), value) + } + } + + return { + get(name) { + return normalized.get(String(name || '').toLowerCase()) || null + }, + } +} diff --git a/apps/backend/src/services/platforms/kuaishou-eticket/info-service.js b/apps/backend/src/services/platforms/kuaishou-eticket/info-service.js new file mode 100644 index 00000000..496247cf --- /dev/null +++ b/apps/backend/src/services/platforms/kuaishou-eticket/info-service.js @@ -0,0 +1,61 @@ +// @ts-check + +import { createHttpError } from '../../../utils/http.js' +import { kuaishouEticketRequest } from './http-client.js' +import { resolveKuaishouEticketConfig } from './shared.js' + +export async function queryKuaishouEticketStableInfo(payload = {}) { + const config = resolveKuaishouEticketConfig(payload) + const cookie = String(payload.cookie || config.cookie || '').trim() + + if (!cookie) { + throw createHttpError('请先提供店铺 Cookie', { + statusCode: 400, + errorCode: 'kuaishou_eticket_missing_cookie', + }) + } + + const response = await kuaishouEticketRequest(config.infoPath, { + method: 'GET', + baseUrl: config.baseUrl, + cookie, + timeoutMs: config.timeoutMs, + contentType: null, + }) + + return mapKuaishouEticketStableInfoResult(response.payload, { + baseUrl: config.baseUrl, + cookie, + }) +} + +export function mapKuaishouEticketStableInfoResult(payload = {}, context = {}) { + const result = Number(payload?.result || 0) + const userInfo = isPlainObject(payload?.data?.userInfo) ? payload.data.userInfo : {} + const bizStatus = isPlainObject(payload?.data?.bizStatus) ? payload.data.bizStatus : {} + + return { + baseUrl: String(context.baseUrl || '').trim(), + ok: result === 1, + result, + errorMessage: String(payload?.error_msg || '').trim(), + requestId: String(payload?.requestId || '').trim(), + shop: { + shopId: String(userInfo.userId || '').trim(), + kshopName: String(userInfo.userName || '').trim(), + userAvatar: String(userInfo.userAvatar || '').trim(), + settleStatus: normalizeInteger(bizStatus.settleStatus, 0), + hasCookie: Boolean(String(context.cookie || '').trim()), + }, + raw: payload && typeof payload === 'object' ? payload : {}, + } +} + +function normalizeInteger(value, fallback) { + const parsed = Number(value) + return Number.isInteger(parsed) ? parsed : fallback +} + +function isPlainObject(value) { + return Object.prototype.toString.call(value) === '[object Object]' +} diff --git a/apps/backend/src/services/platforms/kuaishou-eticket/info-service.test.js b/apps/backend/src/services/platforms/kuaishou-eticket/info-service.test.js new file mode 100644 index 00000000..de13e0c2 --- /dev/null +++ b/apps/backend/src/services/platforms/kuaishou-eticket/info-service.test.js @@ -0,0 +1,32 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { mapKuaishouEticketStableInfoResult } from './info-service.js' + +test('mapKuaishouEticketStableInfoResult extracts shop info from payload', () => { + const result = mapKuaishouEticketStableInfoResult({ + result: 1, + error_msg: '成功', + requestId: '777795601162094230', + data: { + userInfo: { + userId: 4269276762, + userName: '稚嫩游戏交易店', + userAvatar: 'https://example.com/avatar.jpg', + }, + bizStatus: { + settleStatus: 200, + }, + }, + }, { + baseUrl: 'https://s.kwaixiaodian.com', + cookie: 'sid=abc', + }) + + assert.equal(result.ok, true) + assert.equal(result.shop.shopId, '4269276762') + assert.equal(result.shop.kshopName, '稚嫩游戏交易店') + assert.equal(result.shop.userAvatar, 'https://example.com/avatar.jpg') + assert.equal(result.shop.settleStatus, 200) + assert.equal(result.shop.hasCookie, true) +}) diff --git a/apps/backend/src/services/platforms/kuaishou-eticket/shared.js b/apps/backend/src/services/platforms/kuaishou-eticket/shared.js new file mode 100644 index 00000000..51552c4b --- /dev/null +++ b/apps/backend/src/services/platforms/kuaishou-eticket/shared.js @@ -0,0 +1,75 @@ +// @ts-check + +const DEFAULT_BASE_URL = 'https://s.kwaixiaodian.com' +const DEFAULT_REFERER_PATH = '/zone/industry/voucher/verify-list' +const DEFAULT_USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36' + +export function resolveKuaishouEticketConfig(overrides = {}) { + return { + enabled: overrides.enabled !== false, + baseUrl: normalizeBaseUrl(overrides.baseUrl || DEFAULT_BASE_URL) || DEFAULT_BASE_URL, + cookie: String(overrides.cookie || '').trim(), + timeoutMs: normalizePositiveInteger(overrides.timeoutMs, 8000), + infoPath: normalizePath(overrides.infoPath, '/gateway/pc/workbench/service/stable/info'), + detailPath: normalizePath(overrides.detailPath, '/gateway/industry/eticket/consume/detail'), + consumePath: normalizePath(overrides.consumePath, '/gateway/industry/eticket/consume'), + refererPath: normalizePath(overrides.refererPath, DEFAULT_REFERER_PATH), + userAgent: String(overrides.userAgent || DEFAULT_USER_AGENT).trim() || DEFAULT_USER_AGENT, + } +} + +export function buildKuaishouEticketUrl(baseUrl, pathname) { + return new URL(normalizePath(pathname, '/'), normalizeBaseUrl(baseUrl) || DEFAULT_BASE_URL) +} + +export function buildKuaishouEticketHeaders({ + baseUrl = DEFAULT_BASE_URL, + refererPath = DEFAULT_REFERER_PATH, + userAgent = DEFAULT_USER_AGENT, + cookie = '', + contentType = 'application/json', + extra = {}, +} = {}) { + return { + accept: 'application/json', + 'accept-language': 'zh-CN,zh;q=0.9', + ...(contentType ? { 'content-type': contentType } : {}), + kpf: 'PC_WEB', + kpn: 'KWAIXIAODIAN', + origin: normalizeBaseUrl(baseUrl) || DEFAULT_BASE_URL, + referer: buildKuaishouEticketUrl(baseUrl, refererPath).toString(), + 'user-agent': String(userAgent || DEFAULT_USER_AGENT).trim() || DEFAULT_USER_AGENT, + ...(String(cookie || '').trim() ? { cookie: String(cookie || '').trim() } : {}), + ...normalizeHeaderMap(extra), + } +} + +function normalizeBaseUrl(value) { + return String(value || '').trim().replace(/\/+$/, '') +} + +function normalizePath(value, fallback) { + const normalized = String(value || '').trim() + if (!normalized) { + return fallback + } + + return normalized.startsWith('/') ? normalized : `/${normalized}` +} + +function normalizePositiveInteger(value, fallback) { + const parsed = Number(value) + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback +} + +function normalizeHeaderMap(extra) { + if (!extra || typeof extra !== 'object') { + return {} + } + + return Object.fromEntries( + Object.entries(extra) + .map(([key, value]) => [String(key || '').trim().toLowerCase(), String(value || '').trim()]) + .filter(([key, value]) => key && value), + ) +} 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 new file mode 100644 index 00000000..53409dd2 --- /dev/null +++ b/apps/backend/src/services/platforms/kuaishou-eticket/source-config-service.js @@ -0,0 +1,122 @@ +// @ts-check + +import fs from 'node:fs' +import path from 'node:path' + +import { PROJECT_ROOT } from '../../../config/runtime.js' + +const KUAISHOU_ETICKET_SOURCE_FILE_PATH = path.join(PROJECT_ROOT, 'data', 'kuaishou-eticket-source.json') +const DEFAULT_BASE_URL = 'https://s.kwaixiaodian.com' + +export function getKuaishouEticketSourceFilePath() { + return KUAISHOU_ETICKET_SOURCE_FILE_PATH +} + +export function getKuaishouEticketSourceConfig() { + return loadKuaishouEticketSourceConfigFromFile() +} + +export function saveKuaishouEticketSourceConfig(rawValue) { + const normalized = normalizeKuaishouEticketSourceConfig(rawValue) + fs.mkdirSync(path.dirname(KUAISHOU_ETICKET_SOURCE_FILE_PATH), { recursive: true }) + fs.writeFileSync(KUAISHOU_ETICKET_SOURCE_FILE_PATH, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8') + return normalized +} + +export function listKuaishouEticketShopConfigs(source = getKuaishouEticketSourceConfig()) { + return Array.isArray(source?.shops) ? source.shops : [] +} + +export function findKuaishouEticketShopConfig(shopId, source = getKuaishouEticketSourceConfig()) { + const normalizedShopId = String(shopId || '').trim() + if (!normalizedShopId) { + return null + } + + return listKuaishouEticketShopConfigs(source).find((item) => String(item.shopId || '').trim() === normalizedShopId) || null +} + +export function getFirstAvailableKuaishouEticketShop(source = getKuaishouEticketSourceConfig()) { + return listKuaishouEticketShopConfigs(source).find((item) => item.enabled !== false && String(item.cookie || '').trim()) || null +} + +function loadKuaishouEticketSourceConfigFromFile() { + if (!fs.existsSync(KUAISHOU_ETICKET_SOURCE_FILE_PATH)) { + return createDefaultKuaishouEticketSourceConfig() + } + + try { + const rawText = fs.readFileSync(KUAISHOU_ETICKET_SOURCE_FILE_PATH, 'utf8') + return normalizeKuaishouEticketSourceConfig(JSON.parse(rawText)) + } catch { + return createDefaultKuaishouEticketSourceConfig() + } +} + +function normalizeKuaishouEticketSourceConfig(rawValue) { + const source = isPlainObject(rawValue) ? rawValue : {} + const shops = Array.isArray(source.shops) + ? source.shops + : buildLegacySingleShopList(source) + + return { + enabled: typeof source.enabled === 'boolean' ? source.enabled : true, + baseUrl: String(source.baseUrl || DEFAULT_BASE_URL).trim() || DEFAULT_BASE_URL, + shops: shops + .map((item) => normalizeKuaishouEticketShopConfig(item)) + .filter(Boolean), + } +} + +function normalizeKuaishouEticketShopConfig(rawValue) { + if (!isPlainObject(rawValue)) { + return null + } + + const shopId = String(rawValue.shopId || rawValue.userId || '').trim() + const kshopName = String(rawValue.kshopName || rawValue.shopName || rawValue.userName || '').trim() + const cookie = String(rawValue.cookie || '').trim() + const userAvatar = String(rawValue.userAvatar || '').trim() + const enabled = typeof rawValue.enabled === 'boolean' ? rawValue.enabled : true + + if (!shopId && !kshopName && !cookie) { + return null + } + + return { + shopId, + kshopName, + cookie, + userAvatar, + enabled, + } +} + +function buildLegacySingleShopList(source) { + const cookie = String(source.cookie || '').trim() + if (!cookie) { + return [] + } + + return [ + { + shopId: '', + kshopName: '', + cookie, + userAvatar: '', + enabled: source.enabled !== false, + }, + ] +} + +function createDefaultKuaishouEticketSourceConfig() { + return { + enabled: true, + baseUrl: DEFAULT_BASE_URL, + shops: [], + } +} + +function isPlainObject(value) { + return Object.prototype.toString.call(value) === '[object Object]' +} diff --git a/apps/backend/src/types/admin-route-inputs.js b/apps/backend/src/types/admin-route-inputs.js index f92cee79..f3a39969 100644 --- a/apps/backend/src/types/admin-route-inputs.js +++ b/apps/backend/src/types/admin-route-inputs.js @@ -34,10 +34,26 @@ export {} * @typedef {import('./admin-write-inputs.js').AdminKhhaoSourceConfigInput} AdminKhhaoSourceConfigRouteBody */ +/** + * @typedef {import('./admin-write-inputs.js').AdminKuaishouEticketSourceConfigInput} AdminKuaishouEticketSourceConfigRouteBody + */ + /** * @typedef {import('./admin-write-inputs.js').AdminAgisoShopConfigSaveInput} AdminAgisoShopConfigRouteBody */ +/** + * @typedef {import('./admin-write-inputs.js').AdminKuaishouEticketDetailQueryInput} AdminKuaishouEticketDetailQueryRouteBody + */ + +/** + * @typedef {import('./admin-write-inputs.js').AdminKuaishouEticketConsumeInput} AdminKuaishouEticketConsumeRouteBody + */ + +/** + * @typedef {import('./admin-write-inputs.js').AdminKuaishouEticketShopInfoInput} AdminKuaishouEticketShopInfoRouteBody + */ + /** * @typedef {import('./admin-write-inputs.js').AdminKhhaoTestLoginInput} AdminKhhaoTestLoginRouteBody */ diff --git a/apps/backend/src/types/admin-write-inputs.js b/apps/backend/src/types/admin-write-inputs.js index 7dc647b9..80a2a830 100644 --- a/apps/backend/src/types/admin-write-inputs.js +++ b/apps/backend/src/types/admin-write-inputs.js @@ -83,6 +83,54 @@ export {} * }} AdminKhhaoSourceConfigInput */ +/** + * @typedef {{ + * shopId?: string + * kshopName?: string + * cookie?: string + * userAvatar?: string + * enabled?: boolean + * }} AdminKuaishouEticketShopConfigWriteItemInput + */ + +/** + * @typedef {{ + * enabled?: boolean + * baseUrl?: string + * shops?: AdminKuaishouEticketShopConfigWriteItemInput[] + * }} AdminKuaishouEticketSourceConfigInput + */ + +/** + * @typedef {{ + * baseUrl?: string + * shopId?: string + * cookie?: string + * }} AdminKuaishouEticketShopInfoInput + */ + +/** + * @typedef {{ + * baseUrl?: string + * shopId?: string + * cookie?: string + * eTicketId?: string + * }} AdminKuaishouEticketDetailQueryInput + */ + +/** + * @typedef {{ + * baseUrl?: string + * shopId?: string + * cookie?: string + * eTicketId?: string + * oid?: string + * formToken?: string + * num?: number | string + * storeId?: string + * }} AdminKuaishouEticketConsumeInput + */ + /** * @typedef {{ * baseUrl?: string diff --git a/apps/frontend/src/services/admin.ts b/apps/frontend/src/services/admin.ts index bac462b6..495396dc 100644 --- a/apps/frontend/src/services/admin.ts +++ b/apps/frontend/src/services/admin.ts @@ -16,6 +16,10 @@ import type { AdminKhhaoSourceConfig, AdminKhhaoSyncState, AdminKhhaoOrderSyncResult, + AdminKuaishouEticketConsumeResult, + AdminKuaishouEticketDetailResult, + AdminKuaishouEticketShopInfoResult, + AdminKuaishouEticketSourceConfig, AdminInventoryItemListItem, AdminInventorySkuSuggestion, AdminMessageDeliveryListItem, @@ -109,6 +113,50 @@ export function fetchAdminKhhaoSourceConfig() { }>('/api/v1/admin/platform-config/khhao-source') } +export function fetchAdminKuaishouEticketSourceConfig() { + return apiGet<{ + filePath: string + source: AdminKuaishouEticketSourceConfig + }>('/api/v1/admin/platform-config/kuaishou-eticket-source') +} + +export function saveAdminKuaishouEticketSourceConfig(payload: AdminKuaishouEticketSourceConfig) { + return apiPost<{ + filePath: string + source: AdminKuaishouEticketSourceConfig + }>('/api/v1/admin/platform-config/kuaishou-eticket-source', payload as unknown as Record) +} + +export function queryAdminKuaishouEticketDetail(payload: { + baseUrl?: string + shopId?: string + cookie?: string + eTicketId: string +}) { + return apiPost('/api/v1/admin/platform-config/kuaishou-eticket/query-detail', payload) +} + +export function queryAdminKuaishouEticketShopInfo(payload: { + baseUrl?: string + shopId?: string + cookie?: string +}) { + return apiPost('/api/v1/admin/platform-config/kuaishou-eticket/query-shop-info', payload) +} + +export function consumeAdminKuaishouEticket(payload: { + baseUrl?: string + shopId?: string + cookie?: string + eTicketId: string + oid?: string + formToken?: string + num?: number + storeId?: string +}) { + return apiPost('/api/v1/admin/platform-config/kuaishou-eticket/consume', payload) +} + export function saveAdminKhhaoSourceConfig(payload: AdminKhhaoSourceConfig) { return apiPost<{ filePath: string diff --git a/apps/frontend/src/types/admin.ts b/apps/frontend/src/types/admin.ts index ff4624f1..c5f76203 100644 --- a/apps/frontend/src/types/admin.ts +++ b/apps/frontend/src/types/admin.ts @@ -111,6 +111,90 @@ export interface AdminKhhaoSourceConfig { } } +export interface AdminKuaishouEticketSourceConfig { + enabled: boolean + baseUrl: string + shops: AdminKuaishouEticketShopConfigItem[] +} + +export interface AdminKuaishouEticketShopConfigItem { + shopId: string + kshopName: string + cookie: string + cookieMasked: string + hasCookie: boolean + userAvatar: string + enabled: boolean +} + +export interface AdminKuaishouEticketShopInfoResult { + baseUrl: string + ok: boolean + result: number + errorMessage: string + requestId: string + shop: { + shopId: string + kshopName: string + userAvatar: string + settleStatus: number + hasCookie: boolean + } + raw: Record +} + +export interface AdminKuaishouEticketDetailResult { + baseUrl: string + eTicketId: string + ok: boolean + alreadyConsumed: boolean + result: number + errorMessage: string + requestId: string + serverTimestamp: string + detail: { + uid: string + fulfillDetailId: string + sellerId: string + formToken: string + validEndTime: string + validStartTime: string + leftReverseCount: number + eTicketId: string + oid: string + totalCount: number + leftCount: number + status: string + } | null + goods: { + itemId: string + itemPicUrl: string + itemTitle: string + price: string + skuDesc: string + skuId: string + } | null + raw: Record +} + +export interface AdminKuaishouEticketConsumeResult { + baseUrl: string + eTicketId: string + oid: string + formToken: string + num: number + storeId: string + ok: boolean + consumed: boolean + alreadyConsumed: boolean + result: number + errorMessage: string + requestId: string + serverTimestamp: string + detail: AdminKuaishouEticketDetailResult['detail'] + raw: Record +} + export interface AdminKhhaoSyncState { syncFromCreatedAt: string lastRunStartedAt: string diff --git a/apps/frontend/src/views/admin/AdminPlatformShopsView.vue b/apps/frontend/src/views/admin/AdminPlatformShopsView.vue index d5ff7f8d..d75f349f 100644 --- a/apps/frontend/src/views/admin/AdminPlatformShopsView.vue +++ b/apps/frontend/src/views/admin/AdminPlatformShopsView.vue @@ -5,21 +5,26 @@ import { showError, showSuccess } from '@/lib/feedback' import { appointAdminCloudtentaclesVn, buyAdminCloudtentaclesSku, + consumeAdminKuaishouEticket, fetchAdminCloudtentaclesAsset, fetchAdminCloudtentaclesBindUrl, fetchAdminCloudtentaclesCategories, fetchAdminCloudtentaclesKnapsack, fetchAdminAgisoShopConfigs, fetchAdminCloudtentaclesSourceConfig, + fetchAdminKuaishouEticketSourceConfig, fetchAdminCloudtentaclesSkuList, fetchAdminCloudtentaclesVnCode, fetchAdminCloudtentaclesVnList, fetchAdminKhhaoSourceConfig, generateAdminCloudtentaclesVnLoginCode, + queryAdminKuaishouEticketDetail, + queryAdminKuaishouEticketShopInfo, queryAdminKhhaoOrders, runAdminCloudtentaclesFullFlow, saveAdminCloudtentaclesSourceConfig, saveAdminAgisoShopConfigs, + saveAdminKuaishouEticketSourceConfig, saveAdminKhhaoSourceConfig, sendAdminCloudtentaclesSmsCode, syncAdminKhhaoOrders, @@ -41,11 +46,16 @@ import type { AdminKhhaoOrderQueryResult, AdminKhhaoSyncState, AdminKhhaoOrderSyncResult, + AdminKuaishouEticketShopConfigItem, + AdminKuaishouEticketShopInfoResult, + AdminKuaishouEticketConsumeResult, + AdminKuaishouEticketDetailResult, + AdminKuaishouEticketSourceConfig, } from '@/types/admin' import { hasAdminRole } from '@/utils/admin-auth' import { formatAdminDateTime } from '@/utils/admin-time' -type PlatformTab = 'agiso' | 'khhao' | 'cloudtentacles' +type PlatformTab = 'agiso' | 'khhao' | 'kuaishouEticket' | 'cloudtentacles' type EditableDefaults = { messageTemplate: string @@ -62,6 +72,15 @@ type EditableShop = { autoDeliveryMessageTemplate: string } +type EditableKuaishouEticketShop = { + id: string + shopId: string + kshopName: string + cookie: string + userAvatar: string + enabled: boolean +} + const loading = ref(true) const isDevMode = import.meta.env.DEV const saving = ref(false) @@ -73,6 +92,7 @@ const activePlatform = ref('agiso') const agisoFilePath = ref('') const khhaoFilePath = ref('') const khhaoStateFilePath = ref('') +const kuaishouEticketFilePath = ref('') const cloudtentaclesFilePath = ref('') const cloudtentaclesSessionFilePath = ref('') const defaults = ref(createEmptyDefaults()) @@ -100,6 +120,26 @@ const khhaoLoginResult = ref(null) const khhaoQueryResult = ref(null) const khhaoSyncResult = ref(null) const khhaoAutoSyncState = ref(null) +const kuaishouEticketShops = ref([]) +const expandedKuaishouEticketShopIds = ref([]) +const kuaishouEticketForm = ref({ + baseUrl: 'https://s.kwaixiaodian.com', + selectedShopId: '', + queryTicketCode: '', + eTicketId: '', + oid: '', + formToken: '', + num: 1, + storeId: '0', +}) +const kuaishouEticketSaving = ref(false) +const kuaishouEticketResolvingShopId = ref('') +const kuaishouEticketDetailing = ref(false) +const kuaishouEticketConsuming = ref(false) +const kuaishouEticketResultError = ref('') +const kuaishouEticketShopInfoResult = ref(null) +const kuaishouEticketDetailResult = ref(null) +const kuaishouEticketConsumeResult = ref(null) const cloudtentaclesForm = ref({ baseUrl: 'https://123.207.217.176', username: '', @@ -138,6 +178,18 @@ const khhaoStats = computed(() => ({ ignoredCount: khhaoAutoSyncState.value?.ignoredCount || khhaoSyncResult.value?.ignoredCount || 0, })) +const activeKuaishouEticketShop = computed(() => ( + kuaishouEticketShops.value.find((item) => item.id === kuaishouEticketForm.value.selectedShopId) || null +)) + +const kuaishouEticketStats = computed(() => ({ + configuredShopCount: kuaishouEticketShops.value.length, + cookieReadyCount: kuaishouEticketShops.value.filter((item) => item.cookie.trim()).length, + selectedShopReady: Boolean(activeKuaishouEticketShop.value?.cookie.trim()), + detailReady: Boolean(kuaishouEticketDetailResult.value?.detail), + consumed: Boolean(kuaishouEticketConsumeResult.value?.consumed), +})) + const cloudtentaclesStats = computed(() => ({ hasCredential: Boolean( cloudtentaclesForm.value.username.trim() @@ -186,6 +238,28 @@ function mapEditableShop(item: AdminAgisoShopConfigItem): EditableShop { } } +function createEmptyKuaishouEticketShop(): EditableKuaishouEticketShop { + return { + id: crypto.randomUUID(), + shopId: '', + kshopName: '', + cookie: '', + userAvatar: '', + enabled: true, + } +} + +function mapEditableKuaishouEticketShop(item: AdminKuaishouEticketShopConfigItem): EditableKuaishouEticketShop { + return { + id: crypto.randomUUID(), + shopId: item.shopId, + kshopName: item.kshopName, + cookie: item.cookie, + userAvatar: item.userAvatar, + enabled: item.enabled !== false, + } +} + async function loadConfigs() { if (!hasAdminRole('admin')) { loading.value = false @@ -196,9 +270,10 @@ async function loadConfigs() { errorMessage.value = '' try { - const [agisoResponse, khhaoResponse, cloudtentaclesResponse] = await Promise.all([ + const [agisoResponse, khhaoResponse, kuaishouEticketResponse, cloudtentaclesResponse] = await Promise.all([ fetchAdminAgisoShopConfigs(), fetchAdminKhhaoSourceConfig(), + fetchAdminKuaishouEticketSourceConfig(), fetchAdminCloudtentaclesSourceConfig(), ]) @@ -224,6 +299,21 @@ async function loadConfigs() { autoSyncPageSize: khhaoResponse.data.source.autoSync?.pageSize || 20, } + kuaishouEticketFilePath.value = kuaishouEticketResponse.data.filePath + kuaishouEticketShops.value = kuaishouEticketResponse.data.source.shops.map(mapEditableKuaishouEticketShop) + kuaishouEticketForm.value = { + baseUrl: kuaishouEticketResponse.data.source.baseUrl || 'https://s.kwaixiaodian.com', + selectedShopId: '', + queryTicketCode: '', + eTicketId: '', + oid: '', + formToken: '', + num: 1, + storeId: '0', + } + kuaishouEticketForm.value.selectedShopId = kuaishouEticketShops.value[0]?.id || '' + expandedKuaishouEticketShopIds.value = kuaishouEticketShops.value[0]?.id ? [kuaishouEticketShops.value[0].id] : [] + cloudtentaclesFilePath.value = cloudtentaclesResponse.data.filePath cloudtentaclesSessionFilePath.value = cloudtentaclesResponse.data.sessionFilePath cloudtentaclesPersistedSession.value = cloudtentaclesResponse.data.session @@ -468,6 +558,256 @@ async function handleKhhaoSyncOrders() { } } +function buildKuaishouEticketConfigPayload(): AdminKuaishouEticketSourceConfig { + return { + enabled: true, + baseUrl: kuaishouEticketForm.value.baseUrl.trim() || 'https://s.kwaixiaodian.com', + shops: kuaishouEticketShops.value.map((item) => ({ + shopId: item.shopId.trim(), + kshopName: item.kshopName.trim(), + cookie: item.cookie.trim(), + cookieMasked: '', + hasCookie: Boolean(item.cookie.trim()), + userAvatar: item.userAvatar.trim(), + enabled: item.enabled, + })), + } +} + +function addKuaishouEticketShop() { + const shop = createEmptyKuaishouEticketShop() + kuaishouEticketShops.value.unshift(shop) + setKuaishouEticketDebugShop(shop.id) +} + +function removeKuaishouEticketShop(id: string) { + kuaishouEticketShops.value = kuaishouEticketShops.value.filter((item) => item.id !== id) + collapseKuaishouEticketShop(id) + if (kuaishouEticketForm.value.selectedShopId === id) { + kuaishouEticketForm.value.selectedShopId = kuaishouEticketShops.value[0]?.id || '' + } +} + +function isKuaishouEticketShopExpanded(id: string) { + return expandedKuaishouEticketShopIds.value.includes(id) +} + +function expandKuaishouEticketShop(id: string) { + if (expandedKuaishouEticketShopIds.value.includes(id)) { + return + } + expandedKuaishouEticketShopIds.value = [id, ...expandedKuaishouEticketShopIds.value] +} + +function collapseKuaishouEticketShop(id: string) { + expandedKuaishouEticketShopIds.value = expandedKuaishouEticketShopIds.value.filter((item) => item !== id) +} + +function toggleKuaishouEticketShop(id: string) { + if (isKuaishouEticketShopExpanded(id)) { + collapseKuaishouEticketShop(id) + return + } + expandKuaishouEticketShop(id) +} + +function expandAllKuaishouEticketShops() { + expandedKuaishouEticketShopIds.value = kuaishouEticketShops.value.map((item) => item.id) +} + +function collapseAllKuaishouEticketShops() { + expandedKuaishouEticketShopIds.value = [] +} + +function setKuaishouEticketDebugShop(id: string) { + kuaishouEticketForm.value.selectedShopId = id + expandKuaishouEticketShop(id) +} + +function describeKuaishouEticketShop(shop: EditableKuaishouEticketShop) { + const status = shop.cookie.trim() ? 'Cookie 已就绪' : '待补 Cookie' + const identity = shop.shopId.trim() ? '已识别店铺' : '待读取信息' + return `${status} · ${identity}` +} + +function getKuaishouEticketShopInitial(shop: EditableKuaishouEticketShop) { + const label = shop.kshopName.trim() || shop.shopId.trim() || '店' + return label.slice(0, 1).toUpperCase() +} + +function ensureSelectedKuaishouEticketShop() { + const selected = activeKuaishouEticketShop.value + + if (!selected) { + kuaishouEticketResultError.value = '请先新增并选择一个店铺配置' + return null + } + + if (!selected.cookie.trim()) { + kuaishouEticketResultError.value = '当前店铺缺少 Cookie,请先补全后再操作' + return null + } + + return selected +} + +function ensureKuaishouEticketQueryFields() { + const selected = ensureSelectedKuaishouEticketShop() + if (!selected) return false + + if (!kuaishouEticketForm.value.queryTicketCode.trim()) { + kuaishouEticketResultError.value = '请先填写查询券码' + return false + } + + return true +} + +function ensureKuaishouEticketConsumeFields() { + const selected = ensureSelectedKuaishouEticketShop() + if (!selected) return false + + if (!kuaishouEticketDetailResult.value?.detail) { + kuaishouEticketResultError.value = '请先查询核销信息,系统会自动带出 eTicketId、oid 和 formToken' + return false + } + + return true +} + +function applyKuaishouEticketDetailToForm(result: AdminKuaishouEticketDetailResult | null) { + if (!result?.detail) { + return + } + + kuaishouEticketForm.value.eTicketId = result.detail.eTicketId || kuaishouEticketForm.value.eTicketId + kuaishouEticketForm.value.oid = result.detail.oid || kuaishouEticketForm.value.oid + kuaishouEticketForm.value.formToken = result.detail.formToken || kuaishouEticketForm.value.formToken + + const leftCount = Number(result.detail.leftCount || 0) + if (leftCount > 0) { + kuaishouEticketForm.value.num = leftCount + } +} + +async function handleKuaishouEticketResolveShopInfo(shop: EditableKuaishouEticketShop) { + if (!shop.cookie.trim()) { + kuaishouEticketResultError.value = '请先为该店铺粘贴 Cookie' + showError(kuaishouEticketResultError.value) + return + } + + kuaishouEticketResolvingShopId.value = shop.id + kuaishouEticketResultError.value = '' + + try { + const response = await queryAdminKuaishouEticketShopInfo({ + baseUrl: kuaishouEticketForm.value.baseUrl.trim() || 'https://s.kwaixiaodian.com', + shopId: shop.shopId.trim(), + cookie: shop.cookie.trim(), + }) + kuaishouEticketShopInfoResult.value = response.data + shop.shopId = response.data.shop.shopId || shop.shopId + shop.kshopName = response.data.shop.kshopName || shop.kshopName + shop.userAvatar = response.data.shop.userAvatar || shop.userAvatar + if (!kuaishouEticketForm.value.selectedShopId) { + setKuaishouEticketDebugShop(shop.id) + } + showSuccess(`已识别店铺:${response.data.shop.kshopName || response.data.shop.shopId || '未知店铺'}`) + } catch (error) { + kuaishouEticketResultError.value = error instanceof Error ? error.message : '读取快手小店店铺信息失败' + showError(kuaishouEticketResultError.value) + } finally { + kuaishouEticketResolvingShopId.value = '' + } +} + +async function handleKuaishouEticketSaveSource() { + kuaishouEticketSaving.value = true + kuaishouEticketResultError.value = '' + + try { + const response = await saveAdminKuaishouEticketSourceConfig(buildKuaishouEticketConfigPayload()) + kuaishouEticketFilePath.value = response.data.filePath + kuaishouEticketForm.value.baseUrl = response.data.source.baseUrl || 'https://s.kwaixiaodian.com' + const previousSelectedShopId = activeKuaishouEticketShop.value?.shopId || '' + kuaishouEticketShops.value = response.data.source.shops.map(mapEditableKuaishouEticketShop) + kuaishouEticketForm.value.selectedShopId = ( + kuaishouEticketShops.value.find((item) => item.shopId && item.shopId === previousSelectedShopId)?.id + || kuaishouEticketShops.value[0]?.id + || '' + ) + expandedKuaishouEticketShopIds.value = kuaishouEticketForm.value.selectedShopId + ? [kuaishouEticketForm.value.selectedShopId] + : [] + showSuccess('快手小店核销配置已保存') + } catch (error) { + kuaishouEticketResultError.value = error instanceof Error ? error.message : '快手小店核销配置保存失败' + showError(kuaishouEticketResultError.value) + } finally { + kuaishouEticketSaving.value = false + } +} + +async function handleKuaishouEticketQueryDetail() { + if (!ensureKuaishouEticketQueryFields()) { + return + } + + const selected = activeKuaishouEticketShop.value + kuaishouEticketDetailing.value = true + kuaishouEticketResultError.value = '' + kuaishouEticketConsumeResult.value = null + + try { + const response = await queryAdminKuaishouEticketDetail({ + baseUrl: kuaishouEticketForm.value.baseUrl.trim() || 'https://s.kwaixiaodian.com', + shopId: selected?.shopId.trim(), + cookie: selected?.cookie.trim(), + eTicketId: kuaishouEticketForm.value.queryTicketCode.trim(), + }) + kuaishouEticketDetailResult.value = response.data + applyKuaishouEticketDetailToForm(response.data) + showSuccess(response.data.ok ? '核销信息查询成功' : (response.data.errorMessage || '核销信息已返回')) + } catch (error) { + kuaishouEticketResultError.value = error instanceof Error ? error.message : '查询快手小店核销信息失败' + showError(kuaishouEticketResultError.value) + } finally { + kuaishouEticketDetailing.value = false + } +} + +async function handleKuaishouEticketConsume() { + if (!ensureKuaishouEticketConsumeFields()) { + return + } + + const selected = activeKuaishouEticketShop.value + kuaishouEticketConsuming.value = true + kuaishouEticketResultError.value = '' + + try { + const detail = kuaishouEticketDetailResult.value?.detail + const response = await consumeAdminKuaishouEticket({ + baseUrl: kuaishouEticketForm.value.baseUrl.trim() || 'https://s.kwaixiaodian.com', + shopId: selected?.shopId.trim(), + cookie: selected?.cookie.trim(), + eTicketId: String(detail?.eTicketId || kuaishouEticketForm.value.eTicketId || '').trim(), + oid: String(detail?.oid || kuaishouEticketForm.value.oid || '').trim(), + formToken: String(detail?.formToken || kuaishouEticketForm.value.formToken || '').trim(), + num: Number(kuaishouEticketForm.value.num || 1), + storeId: kuaishouEticketForm.value.storeId.trim() || '0', + }) + kuaishouEticketConsumeResult.value = response.data + showSuccess(response.data.ok ? '快手小店核销成功' : (response.data.errorMessage || '核销请求已执行')) + } catch (error) { + kuaishouEticketResultError.value = error instanceof Error ? error.message : '执行快手小店核销失败' + showError(kuaishouEticketResultError.value) + } finally { + kuaishouEticketConsuming.value = false + } +} + function buildCloudtentaclesConfigPayload(): AdminCloudtentaclesSourceConfig { return { enabled: true, @@ -776,7 +1116,7 @@ onMounted(loadConfigs)

平台配置

-

按平台分别管理来源接入、凭据与店铺能力。Agiso 负责 webhook / 店铺消息配置,khhao 负责来源拉单,cloudtentacles 负责外部履约平台登录。

+

按平台分别管理来源接入、凭据与店铺能力。Agiso 负责 webhook / 店铺消息配置,khhao 负责来源拉单,快手小店核销负责独立券核销调试,cloudtentacles 负责外部履约平台登录。

刷新全部配置 @@ -826,6 +1166,23 @@ onMounted(loadConfigs)
+ + +