From 8829652e9a1fd8d829e90818871905fefd4820b4 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sat, 2 May 2026 16:20:08 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=20khhao=20=E5=B9=B3=E5=8F=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/backend/config/default.cjs | 7 + apps/backend/src/config/runtime.js | 25 ++ .../src/routes/admin/platform-config.js | 52 +++ .../admin/admin-platform-config-service.js | 63 +++ .../platforms/khhao/captcha-service.js | 75 ++++ .../platforms/khhao/order-mapper-service.js | 54 +++ .../platforms/khhao/order-query-service.js | 112 +++++ .../platforms/khhao/session-service.js | 216 ++++++++++ .../src/services/platforms/khhao/shared.js | 113 +++++ apps/backend/src/services/session/ocr.js | 4 + apps/backend/src/types/admin-route-inputs.js | 8 + apps/backend/src/types/admin-write-inputs.js | 21 + apps/backend/src/types/runtime-config.js | 7 + docs/khhao来源接入设计.md | 403 ++++++++++++++++++ 14 files changed, 1160 insertions(+) create mode 100644 apps/backend/src/services/platforms/khhao/captcha-service.js create mode 100644 apps/backend/src/services/platforms/khhao/order-mapper-service.js create mode 100644 apps/backend/src/services/platforms/khhao/order-query-service.js create mode 100644 apps/backend/src/services/platforms/khhao/session-service.js create mode 100644 apps/backend/src/services/platforms/khhao/shared.js create mode 100644 docs/khhao来源接入设计.md diff --git a/apps/backend/config/default.cjs b/apps/backend/config/default.cjs index b839445d..e0fb9a9a 100644 --- a/apps/backend/config/default.cjs +++ b/apps/backend/config/default.cjs @@ -69,6 +69,13 @@ module.exports = { shops: {}, }, }, + khhao: { + baseUrl: 'https://admin.khhao.com', + timeoutMs: 5000, + loginPath: '/c/login/index.php', + captchaPath: '/verify_img.php', + orderListPath: '/c/payOrder/get.php', + }, }, redeem: { diff --git a/apps/backend/src/config/runtime.js b/apps/backend/src/config/runtime.js index faa1402b..2f076929 100644 --- a/apps/backend/src/config/runtime.js +++ b/apps/backend/src/config/runtime.js @@ -235,6 +235,31 @@ function applyEnvOverrides(baseConfig) { nextConfig.platforms.agiso.messaging.enabled = agisoMessagingEnabled } + const khhaoBaseUrl = String(process.env.KHHAO_BASE_URL || '').trim() + if (khhaoBaseUrl) { + nextConfig.platforms.khhao.baseUrl = khhaoBaseUrl + } + + const khhaoTimeoutMs = parseInteger(process.env.KHHAO_TIMEOUT_MS) + if (khhaoTimeoutMs !== null) { + nextConfig.platforms.khhao.timeoutMs = khhaoTimeoutMs + } + + const khhaoLoginPath = String(process.env.KHHAO_LOGIN_PATH || '').trim() + if (khhaoLoginPath) { + nextConfig.platforms.khhao.loginPath = khhaoLoginPath + } + + const khhaoCaptchaPath = String(process.env.KHHAO_CAPTCHA_PATH || '').trim() + if (khhaoCaptchaPath) { + nextConfig.platforms.khhao.captchaPath = khhaoCaptchaPath + } + + const khhaoOrderListPath = String(process.env.KHHAO_ORDER_LIST_PATH || '').trim() + if (khhaoOrderListPath) { + nextConfig.platforms.khhao.orderListPath = khhaoOrderListPath + } + const proofMode = String(process.env.TENCENT_REDEEM_PROOF_MODE || '').trim() if (proofMode) { nextConfig.redeem.proofMode = proofMode diff --git a/apps/backend/src/routes/admin/platform-config.js b/apps/backend/src/routes/admin/platform-config.js index 02d9236c..aae12919 100644 --- a/apps/backend/src/routes/admin/platform-config.js +++ b/apps/backend/src/routes/admin/platform-config.js @@ -6,6 +6,8 @@ import { getAdminAgisoShopConfigs, getAdminFulfillmentBindingConfigs, lookupAdminFulfillmentBindingOrder, + queryAdminKhhaoOrders, + testAdminKhhaoLogin, updateAdminAgisoShopConfigs, updateAdminFulfillmentBindingConfigs, } from '../../services/admin/admin-platform-config-service.js' @@ -14,6 +16,8 @@ import { createJsonHandler, requireAdminRoles } from './shared.js' /** @typedef {import('../../types/admin-route-inputs.js').AdminAgisoShopConfigRouteBody} AdminAgisoShopConfigRouteBody */ /** @typedef {import('../../types/admin-route-inputs.js').AdminFulfillmentBindingConfigRouteBody} AdminFulfillmentBindingConfigRouteBody */ /** @typedef {import('../../types/admin-route-inputs.js').AdminFulfillmentBindingLookupRouteBody} AdminFulfillmentBindingLookupRouteBody */ +/** @typedef {import('../../types/admin-route-inputs.js').AdminKhhaoOrderQueryRouteBody} AdminKhhaoOrderQueryRouteBody */ +/** @typedef {import('../../types/admin-route-inputs.js').AdminKhhaoTestLoginRouteBody} AdminKhhaoTestLoginRouteBody */ /** @typedef {import('../../types/admin-write-models.js').AdminAgisoShopConfigSaveResponse} AdminAgisoShopConfigSaveResponse */ const router = Router() @@ -50,6 +54,54 @@ router.post('/platform-config/agiso-shops', createJsonHandler( }, )) +router.post('/platform-config/khhao/test-login', createJsonHandler( + (req) => testAdminKhhaoLogin(/** @type {AdminKhhaoTestLoginRouteBody} */ (req.body)), + { + successMessage: 'khhao 登录测试成功', + errorMessage: 'khhao 登录测试失败', + scope: '[admin/platform-config/khhao/test-login]', + audit: (req, data) => { + const body = /** @type {AdminKhhaoTestLoginRouteBody} */ (req.body) + const result = /** @type {{ baseUrl?: string, username?: string, attempt?: number, session?: { cookieCount?: number } }} */ (data) + return { + action: 'platform_khhao_test_login', + targetType: 'platform_config', + targetId: String(result.username || body.username || '').trim() || 'khhao', + data: { + baseUrl: result.baseUrl || String(body.baseUrl || '').trim(), + attempt: Number(result.attempt || 0), + cookieCount: Number(result.session?.cookieCount || 0), + }, + } + }, + }, +)) + +router.post('/platform-config/khhao/query-orders', createJsonHandler( + (req) => queryAdminKhhaoOrders(/** @type {AdminKhhaoOrderQueryRouteBody} */ (req.body)), + { + successMessage: 'khhao 订单查询成功', + errorMessage: 'khhao 订单查询失败', + scope: '[admin/platform-config/khhao/query-orders]', + audit: (req, data) => { + const body = /** @type {AdminKhhaoOrderQueryRouteBody} */ (req.body) + const result = /** @type {{ page?: number, limit?: number, itemCount?: number, total?: number, baseUrl?: string }} */ (data) + return { + action: 'platform_khhao_query_orders', + targetType: 'platform_config', + targetId: String(body.username || '').trim() || 'khhao', + data: { + baseUrl: result.baseUrl || String(body.baseUrl || '').trim(), + page: Number(result.page || 1), + limit: Number(result.limit || 50), + itemCount: Number(result.itemCount || 0), + total: Number(result.total || 0), + }, + } + }, + }, +)) + router.get('/platform-config/fulfillment-bindings', createJsonHandler( () => getAdminFulfillmentBindingConfigs(), { 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 fc937119..7dd1092a 100644 --- a/apps/backend/src/services/admin/admin-platform-config-service.js +++ b/apps/backend/src/services/admin/admin-platform-config-service.js @@ -9,6 +9,9 @@ import { saveAgisoMessagingConfig, } from '../platforms/agiso/shop-config-service.js' import { enrichAgisoXianyuTradeOrder } from '../platforms/agiso/xianyu/order-detail-service.js' +import { mapKhhaoOrderPreviewList } from '../platforms/khhao/order-mapper-service.js' +import { queryKhhaoOrderList } from '../platforms/khhao/order-query-service.js' +import { loginKhhaoSession } from '../platforms/khhao/session-service.js' import { normalizeAgisoMessageTemplate } from '../platforms/agiso/xianyu/message-service.js' import { getOrderFulfillmentBindingConfigs, @@ -23,6 +26,8 @@ import { normalizeProductName } from '../order/product-match-service.js' import { resolveDisplayShopName } from './admin-read-shared-helpers.js' /** @typedef {import('../../types/admin-write-inputs.js').AdminAgisoShopConfigSaveInput} AdminAgisoShopConfigSaveInput */ +/** @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').AdminFulfillmentBindingConfigSaveInput} AdminFulfillmentBindingConfigSaveInput */ /** @typedef {import('../../types/admin-write-inputs.js').AdminFulfillmentBindingLookupInput} AdminFulfillmentBindingLookupInput */ @@ -176,6 +181,64 @@ function applyOptionalStringField(target, key, source) { delete target[key] } +/** @param {AdminKhhaoTestLoginInput} [payload] */ +export async function testAdminKhhaoLogin(payload = /** @type {AdminKhhaoTestLoginInput} */ ({})) { + const session = await loginKhhaoSession({ + baseUrl: payload.baseUrl, + username: payload.username, + password: payload.password, + maxCaptchaAttempts: payload.maxCaptchaAttempts, + includeImageBase64: payload.includeImageBase64, + requestId: `admin-khhao-test-login:${String(payload.username || '').trim() || 'anonymous'}`, + }) + + return { + baseUrl: session.baseUrl, + username: session.username, + loggedInAt: session.loggedInAt, + attempt: session.attempt, + responseMessage: session.responseMessage, + captcha: { + recognizedText: session.captchaText, + imageBase64: session.captchaImageBase64, + }, + session: { + cookieKeys: Object.keys(session.cookieMap), + cookieCount: Object.keys(session.cookieMap).length, + cookieHeaderMasked: maskSecret(session.cookieHeader), + }, + } +} + +/** @param {AdminKhhaoOrderQueryInput} [payload] */ +export async function queryAdminKhhaoOrders(payload = /** @type {AdminKhhaoOrderQueryInput} */ ({})) { + const session = await loginKhhaoSession({ + baseUrl: payload.baseUrl, + username: payload.username, + password: payload.password, + maxCaptchaAttempts: payload.maxCaptchaAttempts, + requestId: `admin-khhao-query-orders:${String(payload.username || '').trim() || 'anonymous'}`, + }) + + const result = await queryKhhaoOrderList({ + baseUrl: payload.baseUrl, + page: payload.page, + limit: payload.limit, + session, + }) + const previews = mapKhhaoOrderPreviewList(result.items) + + return { + baseUrl: session.baseUrl, + page: result.page, + limit: result.limit, + total: result.total, + itemCount: result.items.length, + items: previews, + rawItems: result.items, + } +} + export async function getAdminFulfillmentBindingConfigs() { const bindings = getOrderFulfillmentBindingConfigs() const rowsResult = await query( diff --git a/apps/backend/src/services/platforms/khhao/captcha-service.js b/apps/backend/src/services/platforms/khhao/captcha-service.js new file mode 100644 index 00000000..0fed71f1 --- /dev/null +++ b/apps/backend/src/services/platforms/khhao/captcha-service.js @@ -0,0 +1,75 @@ +// @ts-check + +import { recognizeImageCaptcha } from '../../session/ocr.js' +import { buildKhhaoUrl, buildCookieHeader, normalizeKhhaoCookieState, resolveKhhaoConfig } from './shared.js' + +/** + * @param {{ + * baseUrl?: string + * captchaPath?: string + * timeoutMs?: number + * cookies?: Record + * requestId?: string + * includeImageBase64?: boolean + * }} [options] + */ +export async function fetchKhhaoCaptcha(options = {}) { + const config = resolveKhhaoConfig(options) + const cookieHeader = buildCookieHeader(options.cookies) + const response = await fetchWithTimeout(buildKhhaoUrl(config.baseUrl, config.captchaPath), { + method: 'GET', + headers: { + accept: 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8', + referer: buildKhhaoUrl(config.baseUrl, config.loginPath).toString(), + ...(cookieHeader ? { cookie: cookieHeader } : {}), + }, + }, config.timeoutMs) + + if (!response.ok) { + throw new Error(`khhao 验证码请求失败,HTTP ${response.status}`) + } + + const imageBuffer = Buffer.from(await response.arrayBuffer()) + const imageBase64 = imageBuffer.toString('base64') + const ocr = await recognizeImageCaptcha({ + imageBase64, + imageContentType: String(response.headers.get('content-type') || 'image/png').trim() || 'image/png', + tag: options.requestId ? `khhao-${options.requestId}` : 'khhao-login-captcha', + }) + + if (ocr?.code !== 0) { + throw new Error(ocr?.msg || 'khhao 验证码 OCR 识别失败') + } + + const captchaText = String(ocr?.data?.text || ocr?.data?.recognizedText || '').trim() + if (!captchaText) { + throw new Error('khhao 验证码 OCR 未识别出内容') + } + + return { + captchaText, + ocr, + cookieMap: normalizeKhhaoCookieState(options.cookies, response.headers), + contentType: String(response.headers.get('content-type') || 'image/png').trim() || 'image/png', + imageBase64: options.includeImageBase64 ? imageBase64 : '', + } +} + +async function fetchWithTimeout(input, init, timeoutMs) { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeoutMs) + + try { + return await fetch(input, { + ...init, + signal: controller.signal, + }) + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + throw new Error(`khhao 验证码请求超时(${timeoutMs}ms)`) + } + throw error + } finally { + clearTimeout(timer) + } +} diff --git a/apps/backend/src/services/platforms/khhao/order-mapper-service.js b/apps/backend/src/services/platforms/khhao/order-mapper-service.js new file mode 100644 index 00000000..d467540a --- /dev/null +++ b/apps/backend/src/services/platforms/khhao/order-mapper-service.js @@ -0,0 +1,54 @@ +// @ts-check + +import { parseAmountToFen } from '../../../utils/money.js' + +export function mapKhhaoOrderPreviewList(items = []) { + return (Array.isArray(items) ? items : []).map((item) => mapKhhaoOrderPreview(item)) +} + +export function mapKhhaoOrderPreview(item = {}) { + const raw = isPlainObject(item) ? item : {} + const platform = resolveKhhaoPlatform(raw.pingtai) + const quantity = normalizeQuantity(raw.num) + + return { + provider: 'khhao', + platform, + platformLabel: String(raw.pingtaiName || '').trim(), + platformOrderId: String(raw.ordersn || '').trim(), + shopId: String(raw.shopid || '').trim(), + shopName: String(raw.shopName || '').trim(), + itemId: String(raw.goodid || '').trim(), + itemTitle: String(raw.goodName || '').trim(), + skuCode: String(raw.sku || '').trim(), + quantity, + totalAmountFen: parseAmountToFen(raw.fee), + status: String(raw.status || '').trim(), + statusLabel: stripHtmlTags(raw.statusName), + orderCreatedAt: String(raw.addtime || '').trim(), + raw, + } +} + +export function resolveKhhaoPlatform(value) { + const normalized = String(value || '').trim() + + if (normalized === '3') { + return 'kuaishou' + } + + return normalized ? 'unknown' : '' +} + +function normalizeQuantity(value) { + const parsed = Number(value) + return Number.isInteger(parsed) && parsed > 0 ? parsed : 1 +} + +function stripHtmlTags(value) { + return String(value || '').replace(/<[^>]+>/g, '').trim() +} + +function isPlainObject(value) { + return Object.prototype.toString.call(value) === '[object Object]' +} diff --git a/apps/backend/src/services/platforms/khhao/order-query-service.js b/apps/backend/src/services/platforms/khhao/order-query-service.js new file mode 100644 index 00000000..0345cd71 --- /dev/null +++ b/apps/backend/src/services/platforms/khhao/order-query-service.js @@ -0,0 +1,112 @@ +// @ts-check + +import { createHttpError } from '../../../utils/http.js' +import { buildKhhaoUrl, resolveKhhaoConfig } from './shared.js' + +/** + * @param {{ + * session?: { + * baseUrl?: string + * cookieHeader?: string + * } + * page?: number | string + * limit?: number | string + * baseUrl?: string + * orderListPath?: string + * timeoutMs?: number + * }} [payload] + */ +export async function queryKhhaoOrderList(payload = {}) { + const config = resolveKhhaoConfig(payload) + const page = normalizePage(payload.page) + const limit = normalizeLimit(payload.limit) + const session = payload.session || {} + const cookieHeader = String(session.cookieHeader || '').trim() + + if (!cookieHeader) { + throw createHttpError('khhao 查询订单缺少登录态 Cookie', { + statusCode: 400, + errorCode: 'khhao_query_missing_cookie', + }) + } + + const response = await fetchWithTimeout(buildKhhaoUrl(config.baseUrl, config.orderListPath, { page, limit }), { + method: 'GET', + headers: { + accept: 'application/json, text/javascript, */*; q=0.01', + referer: buildKhhaoUrl(config.baseUrl, config.loginPath).toString(), + 'x-requested-with': 'XMLHttpRequest', + cookie: cookieHeader, + }, + }, config.timeoutMs) + + const rawText = await response.text() + if (!response.ok) { + throw createHttpError(`khhao 订单查询失败,HTTP ${response.status}`, { + statusCode: 502, + errorCode: 'khhao_query_http_failed', + }) + } + + const payloadJson = tryParseJson(rawText) + if (!payloadJson || typeof payloadJson !== 'object') { + throw createHttpError('khhao 订单查询返回了无法解析的 JSON', { + statusCode: 502, + errorCode: 'khhao_query_invalid_json', + }) + } + + const items = Array.isArray(payloadJson.data) ? payloadJson.data : [] + + return { + page, + limit, + total: Number(payloadJson.count || items.length || 0), + items, + raw: payloadJson, + } +} + +function normalizePage(value) { + const parsed = Number(value) + return Number.isInteger(parsed) && parsed > 0 ? parsed : 1 +} + +function normalizeLimit(value) { + const parsed = Number(value) + if (!Number.isInteger(parsed) || parsed <= 0) { + return 50 + } + + return Math.min(parsed, 200) +} + +async function fetchWithTimeout(input, init, timeoutMs) { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeoutMs) + + try { + return await fetch(input, { + ...init, + signal: controller.signal, + }) + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + throw createHttpError(`khhao 订单查询超时(${timeoutMs}ms)`, { + statusCode: 504, + errorCode: 'khhao_query_timeout', + }) + } + throw error + } finally { + clearTimeout(timer) + } +} + +function tryParseJson(text) { + try { + return JSON.parse(text) + } catch { + return null + } +} diff --git a/apps/backend/src/services/platforms/khhao/session-service.js b/apps/backend/src/services/platforms/khhao/session-service.js new file mode 100644 index 00000000..14f7a4f2 --- /dev/null +++ b/apps/backend/src/services/platforms/khhao/session-service.js @@ -0,0 +1,216 @@ +// @ts-check + +import { createHttpError } from '../../../utils/http.js' +import { logInfo, logWarn } from '../../../utils/logger.js' +import { fetchKhhaoCaptcha } from './captcha-service.js' +import { + buildCookieHeader, + buildKhhaoUrl, + normalizeKhhaoCookieState, + resolveKhhaoConfig, +} from './shared.js' + +/** + * @param {{ + * username?: string + * password?: string + * baseUrl?: string + * timeoutMs?: number + * maxCaptchaAttempts?: number | string + * includeImageBase64?: boolean + * requestId?: string + * }} [payload] + */ +export async function loginKhhaoSession(payload = {}) { + const username = String(payload.username || '').trim() + const password = String(payload.password || '').trim() + const requestId = String(payload.requestId || '').trim() + + if (!username) { + throw createHttpError('khhao 登录缺少账号', { + statusCode: 400, + errorCode: 'khhao_login_missing_username', + }) + } + + if (!password) { + throw createHttpError('khhao 登录缺少密码', { + statusCode: 400, + errorCode: 'khhao_login_missing_password', + }) + } + + const config = resolveKhhaoConfig(payload) + const maxCaptchaAttempts = normalizeAttempts(payload.maxCaptchaAttempts) + /** @type {Record} */ + let cookieMap = {} + let lastFailureMessage = '' + + cookieMap = await openKhhaoLoginPage(config, cookieMap) + + for (let attempt = 1; attempt <= maxCaptchaAttempts; attempt += 1) { + const captcha = await fetchKhhaoCaptcha({ + ...config, + cookies: cookieMap, + requestId: requestId ? `${requestId}-captcha-${attempt}` : `login-${attempt}`, + includeImageBase64: Boolean(payload.includeImageBase64), + }) + cookieMap = captcha.cookieMap + + const result = await submitKhhaoLogin({ + config, + cookieMap, + username, + password, + captchaText: normalizeKhhaoCaptchaText(captcha.captchaText), + requestId, + }) + + cookieMap = result.cookieMap + + if (result.success) { + logInfo('[khhao/session]', 'khhao 登录成功', { + requestId, + username, + attempt, + cookieKeys: Object.keys(cookieMap), + }) + + return { + baseUrl: config.baseUrl, + cookieMap, + cookieHeader: buildCookieHeader(cookieMap), + loggedInAt: new Date().toISOString(), + username, + attempt, + captchaText: captcha.captchaText, + captchaImageBase64: captcha.imageBase64, + responseMessage: result.message, + } + } + + lastFailureMessage = result.message + logWarn('[khhao/session]', 'khhao 登录失败', { + requestId, + username, + attempt, + message: result.message, + }) + + if (!shouldRetryLogin(result.message) || attempt >= maxCaptchaAttempts) { + break + } + } + + throw createHttpError(lastFailureMessage || 'khhao 登录失败', { + statusCode: 401, + errorCode: 'khhao_login_failed', + }) +} + +async function openKhhaoLoginPage(config, currentCookies) { + const response = await fetchWithTimeout(buildKhhaoUrl(config.baseUrl, config.loginPath), { + method: 'GET', + headers: { + accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + }, + }, config.timeoutMs) + + if (!response.ok) { + throw createHttpError(`khhao 登录页打开失败,HTTP ${response.status}`, { + statusCode: 502, + errorCode: 'khhao_login_page_failed', + }) + } + + return normalizeKhhaoCookieState(currentCookies, response.headers) +} + +async function submitKhhaoLogin({ config, cookieMap, username, password, captchaText, requestId }) { + const response = await fetchWithTimeout(buildKhhaoUrl(config.baseUrl, config.loginPath), { + method: 'POST', + headers: { + accept: 'application/json, text/javascript, */*; q=0.01', + 'content-type': 'application/x-www-form-urlencoded; charset=UTF-8', + origin: config.baseUrl, + referer: buildKhhaoUrl(config.baseUrl, config.loginPath).toString(), + 'x-requested-with': 'XMLHttpRequest', + ...(buildCookieHeader(cookieMap) ? { cookie: buildCookieHeader(cookieMap) } : {}), + }, + body: new URLSearchParams({ + username, + password, + img_code: captchaText, + }).toString(), + }, config.timeoutMs) + + const rawText = await response.text() + const cookieState = normalizeKhhaoCookieState(cookieMap, response.headers) + + if (!response.ok) { + throw createHttpError(`khhao 登录请求失败,HTTP ${response.status}`, { + statusCode: 502, + errorCode: 'khhao_login_request_failed', + }) + } + + const payload = tryParseJson(rawText) + const errcode = Number(payload?.errcode ?? 1) + const message = String(payload?.msg || '').trim() || 'khhao 登录失败' + + return { + success: errcode === 0, + message, + payload, + cookieMap: cookieState, + requestId, + } +} + +function shouldRetryLogin(message) { + const normalized = String(message || '').trim() + return normalized.includes('验证码') +} + +function normalizeAttempts(value) { + const parsed = Number(value) + if (!Number.isInteger(parsed) || parsed <= 0) { + return 3 + } + + return Math.min(parsed, 5) +} + +function normalizeKhhaoCaptchaText(value) { + return String(value || '').trim().toLowerCase() +} + +async function fetchWithTimeout(input, init, timeoutMs) { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeoutMs) + + try { + return await fetch(input, { + ...init, + signal: controller.signal, + }) + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + throw createHttpError(`khhao 请求超时(${timeoutMs}ms)`, { + statusCode: 504, + errorCode: 'khhao_request_timeout', + }) + } + throw error + } finally { + clearTimeout(timer) + } +} + +function tryParseJson(text) { + try { + return JSON.parse(text) + } catch { + return null + } +} diff --git a/apps/backend/src/services/platforms/khhao/shared.js b/apps/backend/src/services/platforms/khhao/shared.js new file mode 100644 index 00000000..66797b94 --- /dev/null +++ b/apps/backend/src/services/platforms/khhao/shared.js @@ -0,0 +1,113 @@ +// @ts-check + +import { runtimeConfig } from '../../../config/runtime.js' + +/** @typedef {import('../../../types/runtime-config.js').RuntimeConfig} RuntimeConfig */ + +export function resolveKhhaoConfig(overrides = {}) { + /** @type {RuntimeConfig['platforms']['khhao']} */ + const baseConfig = runtimeConfig.platforms?.khhao || { + baseUrl: '', + timeoutMs: 5000, + loginPath: '/c/login/index.php', + captchaPath: '/verify_img.php', + orderListPath: '/c/payOrder/get.php', + } + + return { + baseUrl: normalizeBaseUrl(overrides.baseUrl || baseConfig.baseUrl), + timeoutMs: normalizePositiveInteger(overrides.timeoutMs || baseConfig.timeoutMs, 5000), + loginPath: normalizePath(overrides.loginPath || baseConfig.loginPath, '/c/login/index.php'), + captchaPath: normalizePath(overrides.captchaPath || baseConfig.captchaPath, '/verify_img.php'), + orderListPath: normalizePath(overrides.orderListPath || baseConfig.orderListPath, '/c/payOrder/get.php'), + } +} + +export function buildKhhaoUrl(baseUrl, pathname, searchParams = null) { + const url = new URL(normalizePath(pathname, '/'), normalizeBaseUrl(baseUrl) || 'https://admin.khhao.com') + + if (searchParams && typeof searchParams === 'object') { + for (const [key, value] of Object.entries(searchParams)) { + if (typeof value === 'undefined' || value === null || value === '') { + continue + } + url.searchParams.set(key, String(value)) + } + } + + return url +} + +export function collectSetCookieHeaders(headers) { + if (!headers || typeof headers !== 'object') { + return [] + } + + if (typeof headers.getSetCookie === 'function') { + return headers.getSetCookie() + } + + const raw = headers.get('set-cookie') + return raw ? [raw] : [] +} + +export function mergeCookies(currentCookies = {}, setCookieHeaders = []) { + const next = { ...currentCookies } + + for (const header of Array.isArray(setCookieHeaders) ? setCookieHeaders : []) { + const text = String(header || '').trim() + if (!text) { + continue + } + + const pair = text.split(';', 1)[0] + const separatorIndex = pair.indexOf('=') + if (separatorIndex <= 0) { + continue + } + + const key = pair.slice(0, separatorIndex).trim() + const value = pair.slice(separatorIndex + 1).trim() + + if (!key) { + continue + } + + if (value) { + next[key] = value + } else { + delete next[key] + } + } + + return next +} + +export function buildCookieHeader(cookieMap = {}) { + return Object.entries(cookieMap) + .filter(([key, value]) => String(key || '').trim() && String(value || '').trim()) + .map(([key, value]) => `${key}=${value}`) + .join('; ') +} + +export function normalizeKhhaoCookieState(currentCookies = {}, headers) { + return mergeCookies(currentCookies, collectSetCookieHeaders(headers)) +} + +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 +} diff --git a/apps/backend/src/services/session/ocr.js b/apps/backend/src/services/session/ocr.js index f1df7c98..97623ae6 100644 --- a/apps/backend/src/services/session/ocr.js +++ b/apps/backend/src/services/session/ocr.js @@ -11,6 +11,10 @@ export async function recognizeTencentCaptcha(payload) { return callOcrRequest('recognize', payload) } +export async function recognizeImageCaptcha(payload) { + return callOcrRequest('recognize', payload) +} + export async function batchRecognizeTencentCaptcha(payload) { return callOcrRequest('batch', payload, { timeoutMs: 180_000 }) } diff --git a/apps/backend/src/types/admin-route-inputs.js b/apps/backend/src/types/admin-route-inputs.js index 736346cf..016fb5fc 100644 --- a/apps/backend/src/types/admin-route-inputs.js +++ b/apps/backend/src/types/admin-route-inputs.js @@ -34,6 +34,14 @@ export {} * @typedef {import('./admin-write-inputs.js').AdminAgisoShopConfigSaveInput} AdminAgisoShopConfigRouteBody */ +/** + * @typedef {import('./admin-write-inputs.js').AdminKhhaoTestLoginInput} AdminKhhaoTestLoginRouteBody + */ + +/** + * @typedef {import('./admin-write-inputs.js').AdminKhhaoOrderQueryInput} AdminKhhaoOrderQueryRouteBody + */ + /** * @typedef {import('./admin-write-inputs.js').AdminFulfillmentBindingConfigSaveInput} AdminFulfillmentBindingConfigRouteBody */ diff --git a/apps/backend/src/types/admin-write-inputs.js b/apps/backend/src/types/admin-write-inputs.js index 55a3d67b..6f77db2c 100644 --- a/apps/backend/src/types/admin-write-inputs.js +++ b/apps/backend/src/types/admin-write-inputs.js @@ -67,6 +67,27 @@ export {} * }} AdminAgisoShopConfigSaveInput */ +/** + * @typedef {{ + * baseUrl?: string + * username?: string + * password?: string + * maxCaptchaAttempts?: number | string + * includeImageBase64?: boolean + * }} AdminKhhaoTestLoginInput + */ + +/** + * @typedef {{ + * baseUrl?: string + * username?: string + * password?: string + * page?: number | string + * limit?: number | string + * maxCaptchaAttempts?: number | string + * }} AdminKhhaoOrderQueryInput + */ + /** * @typedef {{ * provider?: string diff --git a/apps/backend/src/types/runtime-config.js b/apps/backend/src/types/runtime-config.js index db07c07d..4d7c6586 100644 --- a/apps/backend/src/types/runtime-config.js +++ b/apps/backend/src/types/runtime-config.js @@ -89,6 +89,13 @@ export {} * shops: AgisoMessagingShopsConfig * } * } + * khhao: { + * baseUrl: string + * timeoutMs: number + * loginPath: string + * captchaPath: string + * orderListPath: string + * } * } * redeem: { * proofMode: string diff --git a/docs/khhao来源接入设计.md b/docs/khhao来源接入设计.md new file mode 100644 index 00000000..8bb37174 --- /dev/null +++ b/docs/khhao来源接入设计.md @@ -0,0 +1,403 @@ +# khhao 新数据来源接入设计 + +## 1. 背景 + +当前系统的订单来源只有 `agiso`,核心模式是: + +- 上游通过 webhook 推送订单事件; +- 后端按 `provider + platform + shopId + platformOrderId` 幂等落库; +- 命中履约绑定后生成任务、领取链接、自动发货等后续流程。 + +现在需要新增一个来源:`admin.khhao.com` 这一套已开发好的平台后台。 + +和 `agiso` 的差异是: + +- **不是 webhook 推送**,而是**账号密码登录 + 验证码识别 + Cookie 会话查询**; +- 订单数据来自平台接口查询; +- 初期目标是先完成**登录能力**与**订单查询能力**,再决定是否做定时拉取/增量同步。 + +## 2. 已确认事实(来自 `2026-05-02` 的 HAR) + +HAR 文件:`/Users/yml/Desktop/抓包/登陆.har` + +已确认接口链路: + +1. 打开登录页 + `GET https://admin.khhao.com/c/login/index.php` + +2. 获取验证码图片 + `GET https://admin.khhao.com/verify_img.php` + +3. 提交登录表单 + `POST https://admin.khhao.com/c/login/index.php` + 表单字段: + - `username` + - `password` + - `img_code` + +4. 登录成功响应 + ```json + {"errcode":0,"msg":"登陆成功","url":"/c/payOrder/index.php"} + ``` + +5. 查询订单列表 + `GET https://admin.khhao.com/c/payOrder/get.php?page=1&limit=50` + +6. 当前 HAR 中,登录依赖 `PHPSESSID` Cookie 维持会话。 + +订单列表响应里已看到这些关键字段: + +- `ordersn`:外部订单号 +- `pingtai` / `pingtaiName`:平台标识/平台名 +- `shopid` / `shopName`:店铺 +- `goodid` / `goodName`:商品 +- `sku`:SKU +- `fee`:成交金额 +- `chengben` / `lirun`:成本/利润 +- `num`:数量 +- `status` / `statusName`:订单状态 +- `addtime`:下单/录入时间 + +## 3. 设计目标 + +### 3.1 MVP 目标 + +先做最小可用能力: + +1. 后端可通过账号密码自动登录 khhao; +2. 自动拉取验证码图片,并用现有 `ddddocr` 识别; +3. 登录成功后带 Cookie 调用订单查询接口; +4. 能按统一结构把 khhao 订单转换为内部订单模型; +5. 提供一个**手动查询 / 手动同步**入口,先不直接接入自动履约主流程。 + +### 3.2 第二阶段目标 + +1. 支持按时间窗口或页码批量拉取订单; +2. 增加定时轮询同步; +3. 将 khhao 订单正式纳入现有订单履约流程; +4. 视需要增加远端订单详情接口接入。 + +## 4. 关键设计决策 + +## 4.1 新来源标识 + +建议: + +- `provider = 'khhao'` +- `platform = 由返回字段映射` + +原因: + +- 现有系统已经以 `provider/platform` 作为来源分层; +- khhao 本身更像“聚合后台/运营后台”,其下可能有多个平台; +- 后续如果 `pingtai=3` 代表快手、还有其它平台,也可以继续扩展。 + +建议先做一个映射表,例如: + +- `3 -> kuaishou` +- 未识别值 -> `unknown` + +同时把原始 `pingtai`、`pingtaiName` 放进 `raw_payload_json` 保留证据。 + +## 4.2 接入模式 + +`agiso` 是 **push**;`khhao` 应设计成 **pull**。 + +因此不要强行塞进 `webhook-service`,而是单独建立: + +- 登录会话服务 +- 验证码识别服务 +- 订单查询服务 +- 订单转换/同步服务 + +## 4.3 会话管理 + +建议优先采用 **纯 HTTP + Cookie Jar**,不引入浏览器自动化。 + +原因: + +- HAR 显示登录流程很简单,没有前端加密、签名或复杂跳转; +- 验证码是普通图片; +- 登录后查询接口是标准 XHR; +- 用 HTTP 实现更稳定,也更适合服务端定时任务。 + +只有在后续发现以下情况时,再退回浏览器方案: + +- 服务端校验动态前端字段; +- 有 JS 生成签名; +- Cookie/会话依赖浏览器行为; +- 验证码识别成功但仍反复登录失败。 + +## 4.4 OCR 复用 + +现有 OCR 子服务已经使用: + +- `apps/backend/subservices/ocr-worker` +- `ddddocr>=1.6.1` + +因此不需要新增 OCR 技术栈,只需要把 khhao 验证码图片作为图片字节传给现有 OCR 能力即可。 + +建议新增一个更通用的方法,而不是继续绑定在 “Tencent” 语义上: + +- 现状:`recognizeTencentCaptcha` +- 建议新增:`recognizeImageCaptcha` + +然后: + +- 腾讯验证码继续走原接口或复用通用接口; +- khhao 登录验证码直接走通用接口。 + +## 5. 推荐模块拆分 + +建议新增目录: + +`apps/backend/src/services/platforms/khhao/` + +建议模块: + +### 5.1 `captcha-service.js` + +职责: + +- 请求 `/verify_img.php` +- 读取图片字节 +- 调 OCR +- 返回识别结果与调试信息 + +建议返回: + +- `captchaText` +- `imageBase64`(仅调试阶段可选) +- `cookieHeaders` + +### 5.2 `session-service.js` + +职责: + +- 初始化 Cookie 容器; +- 访问登录页,拿到初始 `PHPSESSID`; +- 获取验证码并识别; +- 提交登录表单; +- 校验登录响应; +- 返回已登录会话对象。 + +建议会话对象: + +- `baseUrl` +- `cookieHeader` +- `cookieMap` +- `loggedInAt` + +### 5.3 `order-query-service.js` + +职责: + +- 调用 `/c/payOrder/get.php` +- 支持分页参数:`page`、`limit` +- 后续支持筛选参数(如果继续抓到更多接口) +- 返回原始平台订单列表 + +### 5.4 `order-mapper-service.js` + +职责: + +- 将 khhao 原始订单映射成内部统一订单结构; +- 归一化金额、数量、状态、平台、店铺、SKU; +- 产出可供 `order-service` 或新同步服务消费的对象。 + +建议映射: + +- `platformOrderId <- ordersn` +- `shopId <- shopid` +- `shopName <- shopName` +- `totalAmount <- fee`(转分) +- `items[0].skuCode <- sku` +- `items[0].skuName <- goodName` +- `items[0].quantity <- num` +- `rawPayload <- 原始整条记录` + +注意: + +- 当前列表接口一条记录看起来像“一单一商品”,但不能假设永远如此; +- 第一版可以先按单商品映射; +- 如果后续发现有多商品订单,再调整内部映射。 + +### 5.5 `order-sync-service.js` + +职责: + +- 批量查询 khhao 订单; +- 调 `order-mapper-service` 转换; +- 调内部订单 upsert; +- 记录同步结果、失败原因、游标信息。 + +这里建议不要直接复用 `upsertOrderFromWebhook(event)`,因为它的命名和语义绑定 webhook。 + +更合理的演进方向: + +- 抽一个来源无关的方法,例如 `upsertOrderFromSource(event)`; +- `webhook-service` 继续只负责 webhook 解析; +- `khhao` 同步服务直接调用来源无关的 upsert。 + +## 6. 配置设计 + +## 6.1 运行时配置 + +建议在: + +- `apps/backend/config/default.cjs` +- `apps/backend/src/types/runtime-config.js` +- `apps/backend/src/config/runtime.js` + +增加: + +```js +platforms: { + khhao: { + baseUrl: 'https://admin.khhao.com', + timeoutMs: 5000, + loginPath: '/c/login/index.php', + captchaPath: '/verify_img.php', + orderListPath: '/c/payOrder/get.php', + }, +} +``` + +## 6.2 凭据配置 + +不建议把账号密码直接写进仓库文档或默认配置。 + +建议通过以下方式之一管理: + +1. 环境变量; +2. `apps/backend/data/khhao-sources.json`; +3. 后台平台配置页维护。 + +建议结构: + +```json +{ + "sources": [ + { + "sourceKey": "khhao-main", + "enabled": true, + "baseUrl": "https://admin.khhao.com", + "username": "******", + "password": "******", + "defaultPlatform": "kuaishou" + } + ] +} +``` + +第一阶段最省成本的方案:**本地文件 + 环境变量覆盖**。 + +## 7. 与现有系统的衔接方式 + +## 7.1 第一阶段:只做手动查询/同步 + +建议新增后台接口,例如: + +- `POST /api/v1/admin/platform-config/khhao/test-login` +- `POST /api/v1/admin/platform-config/khhao/query-orders` +- `POST /api/v1/admin/platform-config/khhao/sync-orders` + +这样好处是: + +- 不会影响现有 `agiso` 主链路; +- 可以先验证 OCR、登录、Cookie、字段映射; +- 便于比对真实订单数据。 + +## 7.2 第二阶段:纳入统一订单流程 + +当字段映射稳定后,再把 khhao 同步数据接到统一订单主链路: + +- 来源查询 -> 订单映射 -> 通用 upsert -> 履约绑定 -> 任务生成 + +这一步需要补充: + +- khhao 订单状态到内部 `orderStatus/payStatus` 的映射; +- 去重与增量同步策略; +- 同一来源重复登录的频率控制; +- 同步审计日志。 + +## 8. 状态映射建议 + +当前 HAR 仅看到: + +- `status = 2` => `已发货` +- `status = 0` => `未打款` + +第一阶段不要写死太多业务含义,建议: + +### 内部订单状态 + +- `status=0` -> `created` +- `status=2` -> `delivered` +- 未知值 -> `unknown` + +### 内部支付状态 + +- `status=0` -> `unpaid` +- `status=2` -> `paid` +- 未知值 -> `unknown` + +但这只是 **初版推断**,最终要以更多样本验证。 + +## 9. 风险点 + +1. **验证码识别率** + - `ddddocr` 大概率可用,但识别失败需要自动重试; + - 建议单次登录允许 `2~3` 次验证码重试。 + +2. **登录态过期** + - 需要判断何时复用 Cookie,何时重新登录; + - 第一阶段可以每次查询都重新登录,先求稳定。 + +3. **订单列表不是详情接口** + - 当前拿到的是列表接口,不排除存在订单详情页/详情接口; + - 第一版先以列表字段落库; + - 如果后续履约需要更多字段,再补抓详情接口。 + +4. **平台字段含义未完全确认** + - `pingtai=3` 目前只从样本推断是快手; + - 需要再抓几个平台样本确认映射表。 + +5. **同步边界** + - khhao 是拉取式来源,必须明确增量条件; + - 第一版建议按 `page/limit` 手动同步,先不做自动游标。 + +## 10. 推荐实施顺序 + +### 阶段 A:打通基础能力 + +1. 增加 khhao 配置结构; +2. 增加通用图片 OCR 能力; +3. 实现登录 + Cookie 会话; +4. 实现订单列表查询; +5. 增加后台“测试登录 / 查询订单”接口。 + +### 阶段 B:打通内部模型 + +1. 实现 khhao -> 内部订单映射; +2. 提炼 webhook 无关的通用 order upsert; +3. 增加手动同步接口; +4. 在后台展示同步结果。 + +### 阶段 C:进入自动履约 + +1. 增量同步; +2. 定时任务; +3. 状态回补; +4. 接入履约绑定、任务生成、消息通知。 + +## 11. 本次设计结论 + +结论很明确: + +- khhao 不应该按 `agiso webhook` 思路接; +- 应该作为一个新的 **pull 型来源** 单独建接入层; +- OCR 直接复用现有 `ddddocr` 子服务; +- 第一阶段先做**手动查询/同步**最稳; +- 等字段、状态、平台映射稳定后,再接入统一履约主流程。 +