增加 khhao 平台

This commit is contained in:
yml2213
2026-05-02 16:20:08 +08:00
parent 4a0003b29d
commit 8829652e9a
14 changed files with 1160 additions and 0 deletions
+7
View File
@@ -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: {
+25
View File
@@ -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
@@ -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(),
{
@@ -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(
@@ -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<string, string>
* 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)
}
}
@@ -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]'
}
@@ -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
}
}
@@ -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<string, string>} */
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
}
}
@@ -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
}
+4
View File
@@ -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 })
}
@@ -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
*/
@@ -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
+7
View File
@@ -89,6 +89,13 @@ export {}
* shops: AgisoMessagingShopsConfig
* }
* }
* khhao: {
* baseUrl: string
* timeoutMs: number
* loginPath: string
* captchaPath: string
* orderListPath: string
* }
* }
* redeem: {
* proofMode: string
+403
View File
@@ -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` 子服务;
- 第一阶段先做**手动查询/同步**最稳;
- 等字段、状态、平台映射稳定后,再接入统一履约主流程。