快手小店-核销--ok

This commit is contained in:
yml
2026-05-03 16:34:39 +08:00
parent d7c61ed20f
commit b568d70e64
17 changed files with 2108 additions and 8 deletions
@@ -0,0 +1,233 @@
// @ts-check
import { createHttpError } from '../../../utils/http.js'
import { kuaishouEticketRequest } from './http-client.js'
import { resolveKuaishouEticketConfig } from './shared.js'
export async function queryKuaishouEticketConsumeDetail(payload = {}) {
const context = resolveKuaishouEticketActionContext(payload)
const response = await kuaishouEticketRequest(context.detailPath, {
baseUrl: context.baseUrl,
cookie: context.cookie,
timeoutMs: context.timeoutMs,
body: {
eTicketId: context.eTicketId,
},
})
return mapKuaishouEticketDetailResult(response.payload, {
baseUrl: context.baseUrl,
eTicketId: context.eTicketId,
})
}
export async function consumeKuaishouEticket(payload = {}) {
const context = resolveKuaishouEticketActionContext(payload)
const detailResult = shouldResolveConsumeDetail(payload)
? await queryKuaishouEticketConsumeDetail({
baseUrl: context.baseUrl,
cookie: context.cookie,
eTicketId: context.eTicketId,
timeoutMs: context.timeoutMs,
})
: null
const consumePayload = resolveKuaishouEticketConsumePayload(payload, detailResult)
const response = await kuaishouEticketRequest(context.consumePath, {
baseUrl: context.baseUrl,
cookie: context.cookie,
timeoutMs: context.timeoutMs,
body: consumePayload,
})
return mapKuaishouEticketConsumeResult(response.payload, {
baseUrl: context.baseUrl,
eTicketId: consumePayload.eTicketId,
oid: consumePayload.oid,
formToken: consumePayload.formToken,
num: consumePayload.num,
storeId: consumePayload.storeId,
detailResult,
})
}
export function mapKuaishouEticketDetailResult(payload = {}, context = {}) {
const normalized = normalizeKuaishouEticketApiResult(payload)
const detail = mapKuaishouEticketDetailPayload(payload?.data?.eTicket)
const goods = mapKuaishouEticketGoodsPayload(payload?.data?.goods)
return {
baseUrl: String(context.baseUrl || '').trim(),
eTicketId: String(context.eTicketId || detail?.eTicketId || '').trim(),
ok: normalized.ok,
alreadyConsumed: normalized.result === 21,
result: normalized.result,
errorMessage: normalized.errorMessage,
requestId: normalized.requestId,
serverTimestamp: normalized.serverTimestamp,
detail,
goods,
raw: payload && typeof payload === 'object' ? payload : {},
}
}
export function mapKuaishouEticketConsumeResult(payload = {}, context = {}) {
const normalized = normalizeKuaishouEticketApiResult(payload)
return {
baseUrl: String(context.baseUrl || '').trim(),
eTicketId: String(context.eTicketId || '').trim(),
oid: String(context.oid || '').trim(),
formToken: String(context.formToken || '').trim(),
num: normalizePositiveInteger(context.num, 1),
storeId: String(context.storeId || '0').trim() || '0',
ok: normalized.ok,
consumed: normalized.result === 1 || normalized.result === 21,
alreadyConsumed: normalized.result === 21,
result: normalized.result,
errorMessage: normalized.errorMessage,
requestId: normalized.requestId,
serverTimestamp: normalized.serverTimestamp,
detail: context.detailResult?.detail || null,
raw: payload && typeof payload === 'object' ? payload : {},
}
}
export function resolveKuaishouEticketConsumePayload(payload = {}, detailResult = null) {
const detail = detailResult?.detail || null
const eTicketId = pickFirstNonEmpty([payload.eTicketId, detail?.eTicketId])
const oid = pickFirstNonEmpty([payload.oid, detail?.oid])
const formToken = pickFirstNonEmpty([payload.formToken, detail?.formToken])
const storeId = pickFirstNonEmpty([payload.storeId, '0'])
const num = normalizePositiveInteger(payload.num, normalizePositiveInteger(detail?.leftCount, 1))
if (!eTicketId) {
throw createHttpError('请先填写 eTicketId', {
statusCode: 400,
errorCode: 'kuaishou_eticket_missing_eticket_id',
})
}
if (!oid) {
throw createHttpError('缺少核销所需 oid,请先查询核销信息', {
statusCode: 400,
errorCode: 'kuaishou_eticket_missing_oid',
})
}
if (!formToken) {
throw createHttpError('缺少核销所需 formToken,请先查询核销信息', {
statusCode: 400,
errorCode: 'kuaishou_eticket_missing_form_token',
})
}
return {
eTicketId,
num,
storeId,
oid,
formToken,
}
}
export function normalizeKuaishouEticketApiResult(payload = {}) {
const result = Number(payload?.result || 0)
return {
ok: result === 1,
result,
errorMessage: String(payload?.error_msg || '').trim(),
requestId: String(payload?.requestId || '').trim(),
serverTimestamp: String(payload?.serverTimestamp || '').trim(),
}
}
export function mapKuaishouEticketDetailPayload(value = {}) {
if (!isPlainObject(value)) {
return null
}
return {
uid: String(value.uid || '').trim(),
fulfillDetailId: String(value.fulfillDetailId || '').trim(),
sellerId: String(value.sellerId || '').trim(),
formToken: String(value.formToken || '').trim(),
validEndTime: String(value.validEndTime || '').trim(),
validStartTime: String(value.validStartTime || '').trim(),
leftReverseCount: normalizeInteger(value.leftReverseCount, 0),
eTicketId: String(value.eTicketId || '').trim(),
oid: String(value.oid || '').trim(),
totalCount: normalizeInteger(value.totalCount, 0),
leftCount: normalizeInteger(value.leftCount, 0),
status: String(value.status || '').trim(),
}
}
export function mapKuaishouEticketGoodsPayload(value = {}) {
if (!isPlainObject(value)) {
return null
}
return {
itemId: String(value.itemId || '').trim(),
itemPicUrl: String(value.itemPicUrl || '').trim(),
itemTitle: String(value.itemTitle || '').trim(),
price: String(value.price || '').trim(),
skuDesc: String(value.skuDesc || '').trim(),
skuId: String(value.skuId || '').trim(),
}
}
function resolveKuaishouEticketActionContext(payload = {}) {
const config = resolveKuaishouEticketConfig(payload)
const eTicketId = String(payload.eTicketId || '').trim()
if (!config.cookie) {
throw createHttpError('请先配置快手小店 Cookie', {
statusCode: 400,
errorCode: 'kuaishou_eticket_missing_cookie',
})
}
if (!eTicketId) {
throw createHttpError('请先填写 eTicketId', {
statusCode: 400,
errorCode: 'kuaishou_eticket_missing_eticket_id',
})
}
return {
...config,
eTicketId,
}
}
function shouldResolveConsumeDetail(payload = {}) {
return !String(payload.oid || '').trim() || !String(payload.formToken || '').trim()
}
function pickFirstNonEmpty(values) {
for (const value of values) {
const normalized = String(value || '').trim()
if (normalized) {
return normalized
}
}
return ''
}
function normalizePositiveInteger(value, fallback) {
const parsed = Number(value)
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback
}
function normalizeInteger(value, fallback) {
const parsed = Number(value)
return Number.isInteger(parsed) ? parsed : fallback
}
function isPlainObject(value) {
return Object.prototype.toString.call(value) === '[object Object]'
}
@@ -0,0 +1,72 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {
mapKuaishouEticketDetailResult,
normalizeKuaishouEticketApiResult,
resolveKuaishouEticketConsumePayload,
} from './consume-service.js'
test('normalizeKuaishouEticketApiResult recognizes already consumed response', () => {
const result = normalizeKuaishouEticketApiResult({
result: 21,
error_msg: '券码已核销无法再次核销,请核实后重试',
requestId: 'abc',
})
assert.equal(result.ok, false)
assert.equal(result.result, 21)
assert.equal(result.errorMessage, '券码已核销无法再次核销,请核实后重试')
assert.equal(result.requestId, 'abc')
})
test('mapKuaishouEticketDetailResult maps detail payload fields', () => {
const result = mapKuaishouEticketDetailResult({
result: 1,
error_msg: '成功',
data: {
eTicket: {
eTicketId: 'F2E886B6CBF8E5F5',
oid: '2612200246413561',
formToken: '1777716306154',
leftCount: 1,
totalCount: 1,
status: 'CONSUME_ORDER_CREATED',
},
goods: {
itemTitle: '测试链接五排',
skuDesc: '测试1',
},
},
}, {
baseUrl: 'https://s.kwaixiaodian.com',
eTicketId: 'F2E886B6CBF8E5F5',
})
assert.equal(result.ok, true)
assert.equal(result.detail?.oid, '2612200246413561')
assert.equal(result.detail?.formToken, '1777716306154')
assert.equal(result.detail?.leftCount, 1)
assert.equal(result.goods?.itemTitle, '测试链接五排')
})
test('resolveKuaishouEticketConsumePayload falls back to detail result', () => {
const payload = resolveKuaishouEticketConsumePayload({
eTicketId: 'F2E886B6CBF8E5F5',
}, {
detail: {
eTicketId: 'F2E886B6CBF8E5F5',
oid: '2612200246413561',
formToken: '1777716306154',
leftCount: 2,
},
})
assert.deepEqual(payload, {
eTicketId: 'F2E886B6CBF8E5F5',
num: 2,
storeId: '0',
oid: '2612200246413561',
formToken: '1777716306154',
})
})
@@ -0,0 +1,170 @@
// @ts-check
import http from 'node:http'
import https from 'node:https'
import { parseJsonObject } from '../../../utils/json.js'
import { createHttpError } from '../../../utils/http.js'
import { logInfo } from '../../../utils/logger.js'
import { buildKuaishouEticketHeaders, buildKuaishouEticketUrl, resolveKuaishouEticketConfig } from './shared.js'
export async function kuaishouEticketRequest(pathname, options = {}) {
const config = resolveKuaishouEticketConfig(options)
const url = buildKuaishouEticketUrl(config.baseUrl, pathname)
const timeoutMs = Number(options.timeoutMs || config.timeoutMs || 8000)
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeoutMs)
const method = String(options.method || 'POST').trim().toUpperCase()
const headers = buildKuaishouEticketHeaders({
baseUrl: config.baseUrl,
refererPath: config.refererPath,
userAgent: config.userAgent,
cookie: options.cookie ?? config.cookie,
contentType: options.contentType === null ? '' : options.contentType || inferContentType(options.body),
extra: options.headers,
})
try {
const response = await requestViaNodeHttp(url, {
method,
headers,
body: normalizeRequestBody(options.body, headers['content-type']),
signal: controller.signal,
})
const rawText = response.bodyText
const payload = parseJsonObject(rawText, { preserveLargeIntegers: true })
if (!response.ok) {
throw createHttpError(`快手小店核销请求失败,HTTP ${response.status}`, {
statusCode: 502,
errorCode: 'kuaishou_eticket_http_error',
})
}
logInfo('[kuaishou-eticket/http]', '请求完成', {
method,
pathname,
status: response.status,
result: Number(payload?.result || 0),
})
return {
url: url.toString(),
status: response.status,
headers: response.headers,
payload,
rawText,
}
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
throw createHttpError(`快手小店核销请求超时(${timeoutMs}ms`, {
statusCode: 504,
errorCode: 'kuaishou_eticket_request_timeout',
})
}
throw error
} finally {
clearTimeout(timer)
}
}
function inferContentType(body) {
if (body == null) {
return ''
}
if (typeof body === 'string') {
return 'application/json'
}
return 'application/json'
}
function normalizeRequestBody(body, contentType) {
if (body == null) {
return undefined
}
if (typeof body === 'string') {
return body
}
if (String(contentType || '').includes('application/json')) {
return JSON.stringify(body)
}
return String(body)
}
async function requestViaNodeHttp(url, { method, headers, body, signal }) {
const isHttps = url.protocol === 'https:'
const transport = isHttps ? https : http
return new Promise((resolve, reject) => {
const request = transport.request(url, {
method,
headers,
rejectUnauthorized: false,
}, (response) => {
const chunks = []
response.on('data', (chunk) => {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
})
response.on('end', () => {
const bodyText = Buffer.concat(chunks).toString('utf8')
resolve({
ok: Number(response.statusCode || 0) >= 200 && Number(response.statusCode || 0) < 300,
status: Number(response.statusCode || 0),
headers: createHeaderAdapter(response.headers),
bodyText,
})
})
})
request.on('error', reject)
if (signal) {
if (signal.aborted) {
const error = new Error('Request aborted')
error.name = 'AbortError'
request.destroy(error)
} else {
signal.addEventListener('abort', () => {
const error = new Error('Request aborted')
error.name = 'AbortError'
request.destroy(error)
}, { once: true })
}
}
if (typeof body !== 'undefined') {
request.write(body)
}
request.end()
})
}
function createHeaderAdapter(headers) {
const normalized = new Map()
for (const [key, value] of Object.entries(headers || {})) {
if (Array.isArray(value)) {
normalized.set(String(key || '').toLowerCase(), value.join(', '))
continue
}
if (typeof value === 'string') {
normalized.set(String(key || '').toLowerCase(), value)
}
}
return {
get(name) {
return normalized.get(String(name || '').toLowerCase()) || null
},
}
}
@@ -0,0 +1,61 @@
// @ts-check
import { createHttpError } from '../../../utils/http.js'
import { kuaishouEticketRequest } from './http-client.js'
import { resolveKuaishouEticketConfig } from './shared.js'
export async function queryKuaishouEticketStableInfo(payload = {}) {
const config = resolveKuaishouEticketConfig(payload)
const cookie = String(payload.cookie || config.cookie || '').trim()
if (!cookie) {
throw createHttpError('请先提供店铺 Cookie', {
statusCode: 400,
errorCode: 'kuaishou_eticket_missing_cookie',
})
}
const response = await kuaishouEticketRequest(config.infoPath, {
method: 'GET',
baseUrl: config.baseUrl,
cookie,
timeoutMs: config.timeoutMs,
contentType: null,
})
return mapKuaishouEticketStableInfoResult(response.payload, {
baseUrl: config.baseUrl,
cookie,
})
}
export function mapKuaishouEticketStableInfoResult(payload = {}, context = {}) {
const result = Number(payload?.result || 0)
const userInfo = isPlainObject(payload?.data?.userInfo) ? payload.data.userInfo : {}
const bizStatus = isPlainObject(payload?.data?.bizStatus) ? payload.data.bizStatus : {}
return {
baseUrl: String(context.baseUrl || '').trim(),
ok: result === 1,
result,
errorMessage: String(payload?.error_msg || '').trim(),
requestId: String(payload?.requestId || '').trim(),
shop: {
shopId: String(userInfo.userId || '').trim(),
kshopName: String(userInfo.userName || '').trim(),
userAvatar: String(userInfo.userAvatar || '').trim(),
settleStatus: normalizeInteger(bizStatus.settleStatus, 0),
hasCookie: Boolean(String(context.cookie || '').trim()),
},
raw: payload && typeof payload === 'object' ? payload : {},
}
}
function normalizeInteger(value, fallback) {
const parsed = Number(value)
return Number.isInteger(parsed) ? parsed : fallback
}
function isPlainObject(value) {
return Object.prototype.toString.call(value) === '[object Object]'
}
@@ -0,0 +1,32 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { mapKuaishouEticketStableInfoResult } from './info-service.js'
test('mapKuaishouEticketStableInfoResult extracts shop info from payload', () => {
const result = mapKuaishouEticketStableInfoResult({
result: 1,
error_msg: '成功',
requestId: '777795601162094230',
data: {
userInfo: {
userId: 4269276762,
userName: '稚嫩游戏交易店',
userAvatar: 'https://example.com/avatar.jpg',
},
bizStatus: {
settleStatus: 200,
},
},
}, {
baseUrl: 'https://s.kwaixiaodian.com',
cookie: 'sid=abc',
})
assert.equal(result.ok, true)
assert.equal(result.shop.shopId, '4269276762')
assert.equal(result.shop.kshopName, '稚嫩游戏交易店')
assert.equal(result.shop.userAvatar, 'https://example.com/avatar.jpg')
assert.equal(result.shop.settleStatus, 200)
assert.equal(result.shop.hasCookie, true)
})
@@ -0,0 +1,75 @@
// @ts-check
const DEFAULT_BASE_URL = 'https://s.kwaixiaodian.com'
const DEFAULT_REFERER_PATH = '/zone/industry/voucher/verify-list'
const DEFAULT_USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36'
export function resolveKuaishouEticketConfig(overrides = {}) {
return {
enabled: overrides.enabled !== false,
baseUrl: normalizeBaseUrl(overrides.baseUrl || DEFAULT_BASE_URL) || DEFAULT_BASE_URL,
cookie: String(overrides.cookie || '').trim(),
timeoutMs: normalizePositiveInteger(overrides.timeoutMs, 8000),
infoPath: normalizePath(overrides.infoPath, '/gateway/pc/workbench/service/stable/info'),
detailPath: normalizePath(overrides.detailPath, '/gateway/industry/eticket/consume/detail'),
consumePath: normalizePath(overrides.consumePath, '/gateway/industry/eticket/consume'),
refererPath: normalizePath(overrides.refererPath, DEFAULT_REFERER_PATH),
userAgent: String(overrides.userAgent || DEFAULT_USER_AGENT).trim() || DEFAULT_USER_AGENT,
}
}
export function buildKuaishouEticketUrl(baseUrl, pathname) {
return new URL(normalizePath(pathname, '/'), normalizeBaseUrl(baseUrl) || DEFAULT_BASE_URL)
}
export function buildKuaishouEticketHeaders({
baseUrl = DEFAULT_BASE_URL,
refererPath = DEFAULT_REFERER_PATH,
userAgent = DEFAULT_USER_AGENT,
cookie = '',
contentType = 'application/json',
extra = {},
} = {}) {
return {
accept: 'application/json',
'accept-language': 'zh-CN,zh;q=0.9',
...(contentType ? { 'content-type': contentType } : {}),
kpf: 'PC_WEB',
kpn: 'KWAIXIAODIAN',
origin: normalizeBaseUrl(baseUrl) || DEFAULT_BASE_URL,
referer: buildKuaishouEticketUrl(baseUrl, refererPath).toString(),
'user-agent': String(userAgent || DEFAULT_USER_AGENT).trim() || DEFAULT_USER_AGENT,
...(String(cookie || '').trim() ? { cookie: String(cookie || '').trim() } : {}),
...normalizeHeaderMap(extra),
}
}
function normalizeBaseUrl(value) {
return String(value || '').trim().replace(/\/+$/, '')
}
function normalizePath(value, fallback) {
const normalized = String(value || '').trim()
if (!normalized) {
return fallback
}
return normalized.startsWith('/') ? normalized : `/${normalized}`
}
function normalizePositiveInteger(value, fallback) {
const parsed = Number(value)
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback
}
function normalizeHeaderMap(extra) {
if (!extra || typeof extra !== 'object') {
return {}
}
return Object.fromEntries(
Object.entries(extra)
.map(([key, value]) => [String(key || '').trim().toLowerCase(), String(value || '').trim()])
.filter(([key, value]) => key && value),
)
}
@@ -0,0 +1,122 @@
// @ts-check
import fs from 'node:fs'
import path from 'node:path'
import { PROJECT_ROOT } from '../../../config/runtime.js'
const KUAISHOU_ETICKET_SOURCE_FILE_PATH = path.join(PROJECT_ROOT, 'data', 'kuaishou-eticket-source.json')
const DEFAULT_BASE_URL = 'https://s.kwaixiaodian.com'
export function getKuaishouEticketSourceFilePath() {
return KUAISHOU_ETICKET_SOURCE_FILE_PATH
}
export function getKuaishouEticketSourceConfig() {
return loadKuaishouEticketSourceConfigFromFile()
}
export function saveKuaishouEticketSourceConfig(rawValue) {
const normalized = normalizeKuaishouEticketSourceConfig(rawValue)
fs.mkdirSync(path.dirname(KUAISHOU_ETICKET_SOURCE_FILE_PATH), { recursive: true })
fs.writeFileSync(KUAISHOU_ETICKET_SOURCE_FILE_PATH, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8')
return normalized
}
export function listKuaishouEticketShopConfigs(source = getKuaishouEticketSourceConfig()) {
return Array.isArray(source?.shops) ? source.shops : []
}
export function findKuaishouEticketShopConfig(shopId, source = getKuaishouEticketSourceConfig()) {
const normalizedShopId = String(shopId || '').trim()
if (!normalizedShopId) {
return null
}
return listKuaishouEticketShopConfigs(source).find((item) => String(item.shopId || '').trim() === normalizedShopId) || null
}
export function getFirstAvailableKuaishouEticketShop(source = getKuaishouEticketSourceConfig()) {
return listKuaishouEticketShopConfigs(source).find((item) => item.enabled !== false && String(item.cookie || '').trim()) || null
}
function loadKuaishouEticketSourceConfigFromFile() {
if (!fs.existsSync(KUAISHOU_ETICKET_SOURCE_FILE_PATH)) {
return createDefaultKuaishouEticketSourceConfig()
}
try {
const rawText = fs.readFileSync(KUAISHOU_ETICKET_SOURCE_FILE_PATH, 'utf8')
return normalizeKuaishouEticketSourceConfig(JSON.parse(rawText))
} catch {
return createDefaultKuaishouEticketSourceConfig()
}
}
function normalizeKuaishouEticketSourceConfig(rawValue) {
const source = isPlainObject(rawValue) ? rawValue : {}
const shops = Array.isArray(source.shops)
? source.shops
: buildLegacySingleShopList(source)
return {
enabled: typeof source.enabled === 'boolean' ? source.enabled : true,
baseUrl: String(source.baseUrl || DEFAULT_BASE_URL).trim() || DEFAULT_BASE_URL,
shops: shops
.map((item) => normalizeKuaishouEticketShopConfig(item))
.filter(Boolean),
}
}
function normalizeKuaishouEticketShopConfig(rawValue) {
if (!isPlainObject(rawValue)) {
return null
}
const shopId = String(rawValue.shopId || rawValue.userId || '').trim()
const kshopName = String(rawValue.kshopName || rawValue.shopName || rawValue.userName || '').trim()
const cookie = String(rawValue.cookie || '').trim()
const userAvatar = String(rawValue.userAvatar || '').trim()
const enabled = typeof rawValue.enabled === 'boolean' ? rawValue.enabled : true
if (!shopId && !kshopName && !cookie) {
return null
}
return {
shopId,
kshopName,
cookie,
userAvatar,
enabled,
}
}
function buildLegacySingleShopList(source) {
const cookie = String(source.cookie || '').trim()
if (!cookie) {
return []
}
return [
{
shopId: '',
kshopName: '',
cookie,
userAvatar: '',
enabled: source.enabled !== false,
},
]
}
function createDefaultKuaishouEticketSourceConfig() {
return {
enabled: true,
baseUrl: DEFAULT_BASE_URL,
shops: [],
}
}
function isPlainObject(value) {
return Object.prototype.toString.call(value) === '[object Object]'
}