移除了 khhao 平台相关

This commit is contained in:
yml2213
2026-05-14 13:08:24 +08:00
parent d335b430e6
commit 83cb2ff1fa
56 changed files with 108 additions and 3893 deletions
@@ -1,227 +0,0 @@
// @ts-check
import { getKhhaoSourceConfig } from './source-config-service.js'
import { ensureKhhaoSyncBaseline, getKhhaoSyncState, saveKhhaoSyncState } from './sync-state-service.js'
import { syncKhhaoOrders } from './order-sync-service.js'
import { logError, logInfo, logWarn } from '../../../utils/logger.js'
import { nowIso } from '../../../utils/time.js'
let timer = null
let running = false
const BOOST_INTERVAL_MIN_MS = 3_000
const BOOST_INTERVAL_MAX_MS = 5_000
export function startKhhaoAutoSyncLoop() {
scheduleNextRun(5_000)
}
export function stopKhhaoAutoSyncLoop() {
if (timer) {
clearTimeout(timer)
timer = null
}
}
async function runKhhaoAutoSyncCycle() {
const source = getKhhaoSourceConfig()
const currentState = getKhhaoSyncState()
if (!source.enabled || !source.autoSync?.enabled) {
scheduleNextRun(resolveIntervalMs(source.autoSync?.intervalMinutes))
return
}
if (running) {
logWarn('[khhao/auto-sync]', '检测到上一次自动同步仍在运行,本轮跳过')
scheduleNextRun(resolveNextDelayMs(source, currentState))
return
}
if (!String(source.username || '').trim() || !String(source.password || '').trim()) {
logWarn('[khhao/auto-sync]', 'khhao 自动同步已启用,但缺少账号或密码')
scheduleNextRun(resolveIntervalMs(source.autoSync?.intervalMinutes))
return
}
running = true
const baselineState = ensureKhhaoSyncBaseline()
const startedAt = nowIso()
saveKhhaoSyncState({
...baselineState,
lastRunStartedAt: startedAt,
lastRunStatus: 'running',
lastErrorMessage: '',
})
try {
const result = await syncKhhaoOrders({
baseUrl: source.baseUrl,
username: source.username,
password: source.password,
maxCaptchaAttempts: source.maxCaptchaAttempts,
pages: source.autoSync.pages,
limit: source.autoSync.pageSize,
createdAfter: baselineState.syncFromCreatedAt,
})
const latestCreatedAt = resolveLatestOrderCreatedAt(result.results)
const nextWatchOrders = reconcileKhhaoWatchOrders(baselineState.watchOrders, result.results, startedAt)
const watchMode = nextWatchOrders.length > 0 ? 'boosted' : 'normal'
saveKhhaoSyncState({
...getKhhaoSyncState(),
syncFromCreatedAt: baselineState.syncFromCreatedAt,
lastRunStartedAt: startedAt,
lastRunFinishedAt: nowIso(),
lastRunStatus: 'success',
lastErrorMessage: '',
fetchedCount: result.fetchedCount,
syncedCount: result.syncedCount,
ignoredCount: result.ignoredCount,
lastOrderCreatedAt: latestCreatedAt,
watchMode,
watchOrders: nextWatchOrders,
})
logInfo('[khhao/auto-sync]', 'khhao 自动同步完成', {
fetchedCount: result.fetchedCount,
syncedCount: result.syncedCount,
ignoredCount: result.ignoredCount,
syncFromCreatedAt: baselineState.syncFromCreatedAt,
watchMode,
watchOrderCount: nextWatchOrders.length,
watchOrders: nextWatchOrders.map((item) => ({
platformOrderId: item.platformOrderId,
shopName: item.shopName,
payStatus: item.payStatus,
})),
})
} catch (error) {
const message = error instanceof Error ? error.message : String(error || 'khhao 自动同步失败')
saveKhhaoSyncState({
...getKhhaoSyncState(),
syncFromCreatedAt: baselineState.syncFromCreatedAt,
lastRunStartedAt: startedAt,
lastRunFinishedAt: nowIso(),
lastRunStatus: 'failed',
lastErrorMessage: message,
})
logError('[khhao/auto-sync]', 'khhao 自动同步失败', error)
} finally {
running = false
scheduleNextRun(resolveNextDelayMs(source, getKhhaoSyncState()))
}
}
function scheduleNextRun(delayMs) {
stopKhhaoAutoSyncLoop()
timer = setTimeout(() => {
void runKhhaoAutoSyncCycle()
}, delayMs)
}
function resolveIntervalMs(value) {
const minutes = Number(value)
const normalized = Number.isInteger(minutes) && minutes > 0 ? minutes : 3
return normalized * 60 * 1000
}
function resolveNextDelayMs(source, syncState) {
if (hasActiveKhhaoWatchOrders(syncState?.watchOrders)) {
return resolveBoostIntervalMs()
}
return resolveIntervalMs(source?.autoSync?.intervalMinutes)
}
function resolveBoostIntervalMs() {
const delta = BOOST_INTERVAL_MAX_MS - BOOST_INTERVAL_MIN_MS
return BOOST_INTERVAL_MIN_MS + Math.floor(Math.random() * (delta + 1))
}
export function reconcileKhhaoWatchOrders(currentWatchOrders = [], results = [], now = nowIso()) {
const nextMap = new Map(normalizeKhhaoWatchOrders(currentWatchOrders).map((item) => [item.platformOrderId, item]))
for (const item of Array.isArray(results) ? results : []) {
const platformOrderId = String(item?.platformOrderId || '').trim()
if (!platformOrderId || item?.ignored) {
continue
}
const payStatus = normalizePayStatus(item?.payStatus)
if (payStatus === 'paid') {
nextMap.delete(platformOrderId)
continue
}
if (payStatus !== 'unpaid') {
continue
}
const previous = nextMap.get(platformOrderId)
nextMap.set(platformOrderId, {
platformOrderId,
shopId: String(item?.shopId || previous?.shopId || '').trim(),
shopName: String(item?.shopName || previous?.shopName || '').trim(),
itemTitle: String(item?.itemTitle || previous?.itemTitle || '').trim(),
payStatus,
detectedAt: previous?.detectedAt || now,
lastSeenAt: now,
})
}
return [...nextMap.values()]
}
export function hasActiveKhhaoWatchOrders(watchOrders = []) {
return normalizeKhhaoWatchOrders(watchOrders).length > 0
}
function normalizeKhhaoWatchOrders(value) {
return (Array.isArray(value) ? value : [])
.map((item) => ({
platformOrderId: String(item?.platformOrderId || '').trim(),
shopId: String(item?.shopId || '').trim(),
shopName: String(item?.shopName || '').trim(),
itemTitle: String(item?.itemTitle || '').trim(),
payStatus: normalizePayStatus(item?.payStatus),
detectedAt: String(item?.detectedAt || '').trim(),
lastSeenAt: String(item?.lastSeenAt || '').trim(),
}))
.filter((item) => item.platformOrderId && item.payStatus === 'unpaid')
}
function normalizePayStatus(value) {
const normalized = String(value || '').trim().toLowerCase()
if (normalized === 'paid') {
return 'paid'
}
return normalized === 'unpaid' ? 'unpaid' : 'unknown'
}
function resolveLatestOrderCreatedAt(results = []) {
let latestText = ''
let latestTime = 0
for (const item of Array.isArray(results) ? results : []) {
const text = String(item?.rawOrderCreatedAt || item?.orderCreatedAt || '').trim()
const timestamp = parseKhhaoOrderCreatedAt(text)
if (Number.isFinite(timestamp) && timestamp > latestTime) {
latestTime = timestamp
latestText = new Date(timestamp).toISOString()
}
}
return latestText
}
function parseKhhaoOrderCreatedAt(value) {
const text = String(value || '').trim()
if (!text) {
return NaN
}
return Date.parse(`${text.replace(' ', 'T')}+08:00`)
}
@@ -1,79 +0,0 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {
hasActiveKhhaoWatchOrders,
reconcileKhhaoWatchOrders,
} from './auto-sync-service.js'
test('reconcileKhhaoWatchOrders adds new unpaid synced order into watch list', () => {
const now = '2026-05-03T12:00:00.000Z'
const next = reconcileKhhaoWatchOrders([], [
{
platformOrderId: '2612300323173226',
shopId: '4269276762',
shopName: '稚嫩游戏交易店',
itemTitle: '暗影哥特',
payStatus: 'unpaid',
ignored: false,
},
], now)
assert.equal(next.length, 1)
assert.deepEqual(next[0], {
platformOrderId: '2612300323173226',
shopId: '4269276762',
shopName: '稚嫩游戏交易店',
itemTitle: '暗影哥特',
payStatus: 'unpaid',
detectedAt: now,
lastSeenAt: now,
})
assert.equal(hasActiveKhhaoWatchOrders(next), true)
})
test('reconcileKhhaoWatchOrders removes watched order after payment is detected', () => {
const current = [
{
platformOrderId: '2612300323173226',
shopId: '4269276762',
shopName: '稚嫩游戏交易店',
itemTitle: '暗影哥特',
payStatus: 'unpaid',
detectedAt: '2026-05-03T12:00:00.000Z',
lastSeenAt: '2026-05-03T12:00:00.000Z',
},
]
const next = reconcileKhhaoWatchOrders(current, [
{
platformOrderId: '2612300323173226',
shopId: '4269276762',
shopName: '稚嫩游戏交易店',
itemTitle: '暗影哥特',
payStatus: 'paid',
ignored: false,
},
], '2026-05-03T12:00:04.000Z')
assert.deepEqual(next, [])
assert.equal(hasActiveKhhaoWatchOrders(next), false)
})
test('reconcileKhhaoWatchOrders ignores ignored and unknown-pay-status rows', () => {
const now = '2026-05-03T12:00:00.000Z'
const next = reconcileKhhaoWatchOrders([], [
{
platformOrderId: 'ignored-order',
payStatus: 'unpaid',
ignored: true,
},
{
platformOrderId: 'unknown-order',
payStatus: 'unknown',
ignored: false,
},
], now)
assert.deepEqual(next, [])
})
@@ -1,75 +0,0 @@
// @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)
}
}
@@ -1,169 +0,0 @@
// @ts-check
import { parseAmountToFen } from '../../../utils/money.js'
import { resolveKuaishouEticketShopConfig } from '../kuaishou-eticket/source-config-service.js'
export function mapKhhaoOrderPreviewList(items = []) {
return (Array.isArray(items) ? items : []).map((item) => mapKhhaoOrderPreview(item))
}
export function mapKhhaoOrderToSourceEvent(item = {}) {
const preview = mapKhhaoOrderPreview(item)
const orderStatus = resolveKhhaoOrderStatus(preview.status)
const payStatus = resolveKhhaoPayStatus(preview.status)
const sourceShopId = String(preview.khhaoShopId || preview.shopId || '').trim()
return {
provider: 'khhao',
platform: preview.platform || 'unknown',
shopId: sourceShopId,
shopIdAliases: uniqueNonEmptyValues([sourceShopId, preview.shopId, ...(preview.shopIdAliases || [])]),
shopName: preview.shopName,
platformOrderId: preview.platformOrderId,
orderStatus,
payStatus,
buyerId: '',
buyerName: String(preview.raw.name || '').trim(),
receiverContact: '',
totalAmount: preview.totalAmountFen,
currency: 'CNY',
paidAt: payStatus === 'paid' ? normalizePaidAt(preview.orderCreatedAt) : null,
rawPayload: preview.raw,
items: [
{
itemId: preview.itemId,
externalItemId: preview.itemId,
externalSkuCode: preview.skuCode || preview.itemId,
externalSkuName: preview.itemTitle || preview.skuCode || preview.itemId,
skuCode: preview.skuCode || preview.itemId,
skuName: preview.itemTitle || preview.skuCode || preview.itemId,
quantity: preview.quantity,
snapshot: preview.raw,
spec: preview.raw,
},
],
}
}
export function mapKhhaoOrderPreview(item = {}) {
const raw = isPlainObject(item) ? item : {}
const platform = resolveKhhaoPlatform(raw.pingtai)
const quantity = normalizeQuantity(raw.num)
const internalShopId = String(raw.shopid || '').trim()
const shopName = String(raw.shopName || '').trim()
const resolvedShopIdentity = resolveKhhaoShopIdentity({
platform,
internalShopId,
shopName,
})
return {
provider: 'khhao',
platform,
platformLabel: String(raw.pingtaiName || '').trim(),
platformOrderId: String(raw.ordersn || '').trim(),
shopId: resolvedShopIdentity.shopId,
kuaishouShopId: resolvedShopIdentity.officialShopId,
shopIdAliases: resolvedShopIdentity.shopIdAliases,
khhaoShopId: internalShopId,
internalShopId,
shopName,
itemId: String(raw.goodid || '').trim(),
itemTitle: String(raw.goodName || '').trim(),
skuCode: String(raw.sku || '').trim(),
quantity,
totalAmountFen: parseAmountToFen(raw.fee),
status: String(raw.status || '').trim(),
statusLabel: stripHtmlTags(raw.statusName),
orderCreatedAt: String(raw.addtime || '').trim(),
raw,
}
}
function resolveKhhaoShopIdentity({ platform = '', internalShopId = '', shopName = '' } = {}) {
const fallbackShopId = String(internalShopId || '').trim()
const normalizedShopName = String(shopName || '').trim()
if (String(platform || '').trim() !== 'kuaishou') {
return {
shopId: fallbackShopId,
officialShopId: '',
shopIdAliases: fallbackShopId ? [fallbackShopId] : [],
}
}
const matchedShop = resolveKuaishouEticketShopConfig({
shopId: fallbackShopId,
shopName: normalizedShopName,
})
const resolvedShopId = String(matchedShop?.shopId || '').trim() || fallbackShopId
const shopIdAliases = uniqueNonEmptyValues([resolvedShopId, fallbackShopId])
return {
shopId: resolvedShopId,
officialShopId: String(matchedShop?.shopId || '').trim(),
shopIdAliases,
}
}
export function resolveKhhaoPlatform(value) {
const normalized = String(value || '').trim()
if (normalized === '3') {
return 'kuaishou'
}
return normalized ? 'unknown' : ''
}
export function resolveKhhaoOrderStatus(value) {
const normalized = String(value || '').trim()
if (normalized === '2') {
return 'paid'
}
if (normalized === '0') {
return 'created'
}
return 'unknown'
}
export function resolveKhhaoPayStatus(value) {
const normalized = String(value || '').trim()
if (normalized === '2') {
return 'paid'
}
if (normalized === '0') {
return 'unpaid'
}
return '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 normalizePaidAt(value) {
const text = String(value || '').trim()
return text ? text.replace(' ', 'T') : null
}
function isPlainObject(value) {
return Object.prototype.toString.call(value) === '[object Object]'
}
function uniqueNonEmptyValues(values) {
return [...new Set((Array.isArray(values) ? values : [])
.map((value) => String(value || '').trim())
.filter(Boolean))]
}
@@ -1,112 +0,0 @@
// @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
}
}
@@ -1,150 +0,0 @@
// @ts-check
import { upsertOrderFromSource } from '../../order/order-service.js'
import {
mapKhhaoOrderPreview,
mapKhhaoOrderToSourceEvent,
resolveKhhaoOrderStatus,
resolveKhhaoPayStatus,
} from './order-mapper-service.js'
import { queryKhhaoOrderList } from './order-query-service.js'
import { loginKhhaoSession } from './session-service.js'
/**
* @param {{
* baseUrl?: string
* username?: string
* password?: string
* page?: number | string
* limit?: number | string
* pages?: number | string
* maxCaptchaAttempts?: number | string
* createdAfter?: string
* }} [payload]
*/
export async function syncKhhaoOrders(payload = {}) {
const session = await loginKhhaoSession({
baseUrl: payload.baseUrl,
username: payload.username,
password: payload.password,
maxCaptchaAttempts: payload.maxCaptchaAttempts,
requestId: `admin-khhao-sync-orders:${String(payload.username || '').trim() || 'anonymous'}`,
})
const queryResult = await queryKhhaoOrderList({
baseUrl: payload.baseUrl,
page: 1,
limit: resolvePageSize(payload.limit),
session,
})
const createdAfter = String(payload.createdAfter || '').trim()
const pages = Math.max(1, Math.min(Number(payload.pages || payload.page || 1) || 1, 5))
const results = []
const allItems = [...queryResult.items]
for (let page = 2; page <= pages; page += 1) {
const nextPage = await queryKhhaoOrderList({
baseUrl: payload.baseUrl,
page,
limit: resolvePageSize(payload.limit),
session,
})
allItems.push(...nextPage.items)
}
for (const item of allItems) {
const preview = mapKhhaoOrderPreview(item)
if (createdAfter && !isKhhaoOrderAfter(preview.orderCreatedAt, createdAfter)) {
results.push({
platformOrderId: preview.platformOrderId,
provider: 'khhao',
platform: preview.platform || 'unknown',
shopId: preview.shopId,
shopName: preview.shopName,
skuCode: preview.skuCode,
itemTitle: preview.itemTitle,
orderStatus: resolvePreviewOrderStatus(preview),
payStatus: resolvePreviewPayStatus(preview),
rawOrderCreatedAt: preview.orderCreatedAt,
ignored: true,
ignoreReason: 'before_sync_baseline',
orderId: null,
orderItemCount: 0,
taskCount: 0,
})
continue
}
const sourceEvent = mapKhhaoOrderToSourceEvent(item)
const upsertResult = await upsertOrderFromSource(sourceEvent, { sourceLabel: 'khhao-sync' })
results.push({
platformOrderId: preview.platformOrderId,
provider: sourceEvent.provider,
platform: sourceEvent.platform,
shopId: sourceEvent.shopId,
shopName: sourceEvent.shopName,
skuCode: preview.skuCode,
itemTitle: preview.itemTitle,
orderStatus: sourceEvent.orderStatus,
payStatus: sourceEvent.payStatus,
rawOrderCreatedAt: preview.orderCreatedAt,
ignored: Boolean(upsertResult.ignored),
ignoreReason: String(upsertResult.ignoreReason || '').trim(),
orderId: Number(upsertResult.order?.id || 0) || null,
orderItemCount: Array.isArray(upsertResult.orderItems) ? upsertResult.orderItems.length : 0,
taskCount: Array.isArray(upsertResult.tasks) ? upsertResult.tasks.length : 0,
})
}
return {
baseUrl: session.baseUrl,
page: 1,
limit: resolvePageSize(payload.limit),
total: queryResult.total,
fetchedCount: allItems.length,
syncedCount: results.filter((item) => !item.ignored && item.orderId).length,
ignoredCount: results.filter((item) => item.ignored).length,
results,
}
}
function resolvePageSize(value) {
const parsed = Number(value)
if (!Number.isInteger(parsed) || parsed <= 0) {
return 20
}
return Math.min(parsed, 50)
}
function isKhhaoOrderAfter(orderCreatedAt, baselineIso) {
const orderTime = parseKhhaoOrderCreatedAt(orderCreatedAt)
const baselineTime = Date.parse(String(baselineIso || '').trim())
if (!Number.isFinite(orderTime) || !Number.isFinite(baselineTime)) {
return true
}
return orderTime > baselineTime
}
function parseKhhaoOrderCreatedAt(value) {
const text = String(value || '').trim()
if (!text) {
return NaN
}
const isoText = text.replace(' ', 'T')
return Date.parse(`${isoText}+08:00`)
}
function resolvePreviewOrderStatus(preview) {
return resolveKhhaoOrderStatus(preview?.status)
}
function resolvePreviewPayStatus(preview) {
return resolveKhhaoPayStatus(preview?.status)
}
@@ -1,216 +0,0 @@
// @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
}
}
@@ -1,113 +0,0 @@
// @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
}
@@ -1,80 +0,0 @@
// @ts-check
import fs from 'node:fs'
import path from 'node:path'
import { PROJECT_ROOT } from '../../../config/runtime.js'
const KHHAO_SOURCES_FILE_PATH = path.join(PROJECT_ROOT, 'data', 'khhao-sources.json')
export function getKhhaoSourcesFilePath() {
return KHHAO_SOURCES_FILE_PATH
}
export function getKhhaoSourceConfig() {
return loadKhhaoSourceConfigFromFile()
}
export function saveKhhaoSourceConfig(rawValue) {
const normalized = normalizeKhhaoSourceConfig(rawValue)
fs.mkdirSync(path.dirname(KHHAO_SOURCES_FILE_PATH), { recursive: true })
fs.writeFileSync(KHHAO_SOURCES_FILE_PATH, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8')
return normalized
}
function loadKhhaoSourceConfigFromFile() {
if (!fs.existsSync(KHHAO_SOURCES_FILE_PATH)) {
return createDefaultKhhaoSourceConfig()
}
try {
const rawText = fs.readFileSync(KHHAO_SOURCES_FILE_PATH, 'utf8')
return normalizeKhhaoSourceConfig(JSON.parse(rawText))
} catch {
return createDefaultKhhaoSourceConfig()
}
}
function normalizeKhhaoSourceConfig(rawValue) {
const source = isPlainObject(rawValue) ? rawValue : {}
return {
enabled: typeof source.enabled === 'boolean' ? source.enabled : true,
baseUrl: String(source.baseUrl || 'https://admin.khhao.com').trim() || 'https://admin.khhao.com',
username: String(source.username || '').trim(),
password: String(source.password || '').trim(),
maxCaptchaAttempts: normalizePositiveInteger(source.maxCaptchaAttempts, 3),
autoSync: normalizeAutoSyncConfig(source.autoSync),
}
}
function createDefaultKhhaoSourceConfig() {
return {
enabled: true,
baseUrl: 'https://admin.khhao.com',
username: '',
password: '',
maxCaptchaAttempts: 3,
autoSync: normalizeAutoSyncConfig({}),
}
}
function normalizeAutoSyncConfig(rawValue) {
const source = isPlainObject(rawValue) ? rawValue : {}
return {
enabled: typeof source.enabled === 'boolean' ? source.enabled : false,
intervalMinutes: normalizePositiveInteger(source.intervalMinutes, 3),
pages: normalizePositiveInteger(source.pages, 2),
pageSize: normalizePositiveInteger(source.pageSize, 20),
}
}
function normalizePositiveInteger(value, fallback) {
const parsed = Number(value)
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback
}
function isPlainObject(value) {
return Object.prototype.toString.call(value) === '[object Object]'
}
@@ -1,129 +0,0 @@
// @ts-check
import fs from 'node:fs'
import path from 'node:path'
import { PROJECT_ROOT } from '../../../config/runtime.js'
import { nowIso } from '../../../utils/time.js'
const KHHAO_SYNC_STATE_FILE_PATH = path.join(PROJECT_ROOT, 'data', 'khhao-sync-state.json')
export function getKhhaoSyncStateFilePath() {
return KHHAO_SYNC_STATE_FILE_PATH
}
export function getKhhaoSyncState() {
return loadKhhaoSyncStateFromFile()
}
export function saveKhhaoSyncState(rawValue) {
const normalized = normalizeKhhaoSyncState(rawValue)
fs.mkdirSync(path.dirname(KHHAO_SYNC_STATE_FILE_PATH), { recursive: true })
fs.writeFileSync(KHHAO_SYNC_STATE_FILE_PATH, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8')
return normalized
}
export function ensureKhhaoSyncBaseline() {
const current = getKhhaoSyncState()
if (String(current.syncFromCreatedAt || '').trim()) {
return current
}
return saveKhhaoSyncState({
...current,
syncFromCreatedAt: nowIso(),
})
}
function loadKhhaoSyncStateFromFile() {
if (!fs.existsSync(KHHAO_SYNC_STATE_FILE_PATH)) {
return createDefaultKhhaoSyncState()
}
try {
return normalizeKhhaoSyncState(JSON.parse(fs.readFileSync(KHHAO_SYNC_STATE_FILE_PATH, 'utf8')))
} catch {
return createDefaultKhhaoSyncState()
}
}
function createDefaultKhhaoSyncState() {
return {
syncFromCreatedAt: '',
lastRunStartedAt: '',
lastRunFinishedAt: '',
lastRunStatus: 'idle',
lastErrorMessage: '',
fetchedCount: 0,
syncedCount: 0,
ignoredCount: 0,
lastOrderCreatedAt: '',
watchMode: 'normal',
watchOrders: [],
}
}
function normalizeKhhaoSyncState(rawValue) {
const source = isPlainObject(rawValue) ? rawValue : {}
return {
syncFromCreatedAt: String(source.syncFromCreatedAt || '').trim(),
lastRunStartedAt: String(source.lastRunStartedAt || '').trim(),
lastRunFinishedAt: String(source.lastRunFinishedAt || '').trim(),
lastRunStatus: normalizeStatus(source.lastRunStatus),
lastErrorMessage: String(source.lastErrorMessage || '').trim(),
fetchedCount: normalizeNonNegativeInteger(source.fetchedCount),
syncedCount: normalizeNonNegativeInteger(source.syncedCount),
ignoredCount: normalizeNonNegativeInteger(source.ignoredCount),
lastOrderCreatedAt: String(source.lastOrderCreatedAt || '').trim(),
watchMode: normalizeWatchMode(source.watchMode),
watchOrders: normalizeWatchOrders(source.watchOrders),
}
}
function normalizeStatus(value) {
const normalized = String(value || '').trim()
return normalized || 'idle'
}
function normalizeNonNegativeInteger(value) {
const parsed = Number(value)
return Number.isInteger(parsed) && parsed >= 0 ? parsed : 0
}
function normalizeWatchMode(value) {
return String(value || '').trim() === 'boosted' ? 'boosted' : 'normal'
}
function normalizeWatchOrders(value) {
return (Array.isArray(value) ? value : [])
.map((item) => normalizeWatchOrder(item))
.filter((item) => item.platformOrderId)
}
function normalizeWatchOrder(value) {
const source = isPlainObject(value) ? value : {}
return {
platformOrderId: String(source.platformOrderId || '').trim(),
shopId: String(source.shopId || '').trim(),
shopName: String(source.shopName || '').trim(),
itemTitle: String(source.itemTitle || '').trim(),
payStatus: normalizeWatchPayStatus(source.payStatus),
detectedAt: String(source.detectedAt || '').trim(),
lastSeenAt: String(source.lastSeenAt || '').trim(),
}
}
function normalizeWatchPayStatus(value) {
const normalized = String(value || '').trim().toLowerCase()
if (normalized === 'paid') {
return 'paid'
}
return normalized === 'unpaid' ? 'unpaid' : 'unknown'
}
function isPlainObject(value) {
return Object.prototype.toString.call(value) === '[object Object]'
}