支持快手行业电子凭证多店铺授权
This commit is contained in:
@@ -38,6 +38,7 @@ export function getKuaishouIndustryConfig(overrides: Partial<KuaishouIndustrySou
|
||||
shopId: String(config.shopId || KUISHOU_INDUSTRY_PROVIDER).trim() || KUISHOU_INDUSTRY_PROVIDER,
|
||||
shopName: String(config.shopName || '快手行业电子凭证').trim() || '快手行业电子凭证',
|
||||
version: String(config.version || '1').trim() || '1',
|
||||
shops: Array.isArray(config.shops) ? config.shops : [],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ const DEFAULT_KUAISHOU_OPEN_API = 'https://openapi.kwaixiaodian.com'
|
||||
|
||||
type ConsumeCallbackInput = {
|
||||
oid: string
|
||||
sellerId?: string
|
||||
etickets: Array<{
|
||||
id: string
|
||||
code?: string
|
||||
@@ -41,7 +42,10 @@ export async function consumeCallback(input: ConsumeCallbackInput): Promise<{ su
|
||||
}
|
||||
|
||||
try {
|
||||
const tokenResult = await ensureKuaishouIndustryAccessToken({ config })
|
||||
const tokenResult = await ensureKuaishouIndustryAccessToken({
|
||||
config,
|
||||
...(input.sellerId ? { sellerId: input.sellerId } : {}),
|
||||
})
|
||||
config = {
|
||||
...config,
|
||||
...tokenResult.config,
|
||||
@@ -111,6 +115,7 @@ export async function consumeCallback(input: ConsumeCallbackInput): Promise<{ su
|
||||
logInfo('[kuaishou-industry/consume-callback]', `发起核销回调 oid=${input.oid}`, {
|
||||
url,
|
||||
oid: input.oid,
|
||||
sellerId: input.sellerId || '',
|
||||
status: input.status,
|
||||
consumeType: input.consumeType,
|
||||
})
|
||||
|
||||
@@ -32,12 +32,16 @@ export async function handleConsumeCode(rawBody: JsonObject = {}) {
|
||||
const normalizedOid = params.oid
|
||||
|
||||
let consumedCount = 0
|
||||
let callbackSellerId = params.sellerId
|
||||
|
||||
for (const eticket of params.etickets) {
|
||||
const voucher = await findKuaishouIndustryVoucherByCode(String(eticket.id || ''), normalizedOid)
|
||||
if (!voucher) {
|
||||
continue
|
||||
}
|
||||
if (!callbackSellerId) {
|
||||
callbackSellerId = String(voucher.seller_id || '').trim()
|
||||
}
|
||||
|
||||
const task = voucher.task_id ? await getTaskById(voucher.task_id) : null
|
||||
const consumed = await consumeKuaishouIndustryVoucher(voucher, {
|
||||
@@ -88,6 +92,7 @@ export async function handleConsumeCode(rawBody: JsonObject = {}) {
|
||||
|
||||
fireConsumeCallback({
|
||||
oid: normalizedOid,
|
||||
sellerId: callbackSellerId,
|
||||
etickets: params.etickets.map((e) => ({
|
||||
id: String(e.id),
|
||||
code: e.code,
|
||||
|
||||
@@ -10,6 +10,7 @@ const DEFAULT_KUAISHOU_OPEN_API = 'https://openapi.kwaixiaodian.com'
|
||||
|
||||
type DestroyCallbackInput = {
|
||||
oid: string
|
||||
sellerId?: string
|
||||
etickets?: Array<{
|
||||
id: string
|
||||
code?: string
|
||||
@@ -31,7 +32,10 @@ export async function destroyCallback(input: DestroyCallbackInput): Promise<{ su
|
||||
}
|
||||
|
||||
try {
|
||||
const tokenResult = await ensureKuaishouIndustryAccessToken({ config })
|
||||
const tokenResult = await ensureKuaishouIndustryAccessToken({
|
||||
config,
|
||||
...(input.sellerId ? { sellerId: input.sellerId } : {}),
|
||||
})
|
||||
config = {
|
||||
...config,
|
||||
...tokenResult.config,
|
||||
@@ -94,6 +98,7 @@ export async function destroyCallback(input: DestroyCallbackInput): Promise<{ su
|
||||
logInfo('[kuaishou-industry/destroy-callback]', `发起销毁回调 oid=${input.oid}`, {
|
||||
url,
|
||||
oid: input.oid,
|
||||
sellerId: input.sellerId || '',
|
||||
reason: input.reason,
|
||||
})
|
||||
|
||||
|
||||
@@ -37,6 +37,8 @@ export async function handleDestroyCode(rawBody: JsonObject = {}) {
|
||||
const targetVouchers = targetIds.size > 0
|
||||
? vouchers.filter((voucher) => targetIds.has(String(voucher.voucher_code || '').trim()))
|
||||
: vouchers
|
||||
const callbackSellerId = params.sellerId
|
||||
|| String(targetVouchers.find((voucher) => String(voucher.seller_id || '').trim())?.seller_id || '').trim()
|
||||
|
||||
for (const voucher of targetVouchers) {
|
||||
const status = String(voucher.status || '').trim().toUpperCase()
|
||||
@@ -70,6 +72,7 @@ export async function handleDestroyCode(rawBody: JsonObject = {}) {
|
||||
|
||||
fireDestroyCallback({
|
||||
oid: normalizedOid,
|
||||
sellerId: callbackSellerId,
|
||||
etickets: params.etickets.map((e) => ({
|
||||
id: String(e.id),
|
||||
code: e.code,
|
||||
|
||||
@@ -53,6 +53,7 @@ export function normalizeDestroyCodePayload(raw: JsonObject = {}) {
|
||||
paramRaw: normalizeIndustryString(raw.param),
|
||||
|
||||
oid: normalizeIndustryString(param.oid),
|
||||
sellerId: normalizeIndustryString(param.sellerId),
|
||||
reason: normalizeIndustryString(param.reason),
|
||||
etickets: normalizeDestroyEtickets(param.etickets),
|
||||
}
|
||||
@@ -71,6 +72,7 @@ export function normalizeQueryCodePayload(raw: JsonObject = {}) {
|
||||
paramRaw: normalizeIndustryString(raw.param),
|
||||
|
||||
oid: normalizeIndustryString(param.oid),
|
||||
sellerId: normalizeIndustryString(param.sellerId),
|
||||
eticketId: normalizeIndustryString(param.eticketId),
|
||||
sendType: normalizeIndustryString(param.sendType) || 'VIRTUAL',
|
||||
eticketType: normalizeIndustryString(param.eticketType),
|
||||
@@ -93,6 +95,13 @@ export function assertSendCodePayload(payload: ReturnType<typeof normalizeSendCo
|
||||
})
|
||||
}
|
||||
|
||||
if (!payload.sellerId) {
|
||||
throw createHttpError('缺少 sellerId', {
|
||||
statusCode: 400,
|
||||
errorCode: 'kuaishou_industry_missing_seller_id',
|
||||
})
|
||||
}
|
||||
|
||||
assertCommonPayload(payload, config)
|
||||
}
|
||||
|
||||
@@ -138,6 +147,7 @@ export function normalizeConsumeCodePayload(raw: JsonObject = {}) {
|
||||
paramRaw: normalizeIndustryString(raw.param),
|
||||
|
||||
oid: normalizeIndustryString(param.oid),
|
||||
sellerId: normalizeIndustryString(param.sellerId),
|
||||
etickets: normalizeConsumeEtickets(param.etickets),
|
||||
status: normalizeIndustryString(param.status),
|
||||
consumeType: normalizeIndustryString(param.consumeType),
|
||||
|
||||
@@ -10,6 +10,7 @@ const DEFAULT_KUAISHOU_OPEN_API = 'https://openapi.kwaixiaodian.com'
|
||||
|
||||
type SendCallbackInput = {
|
||||
oid: string
|
||||
sellerId?: string
|
||||
sendType: string
|
||||
etickets: Array<{
|
||||
id: string
|
||||
@@ -36,7 +37,10 @@ export async function sendCallback(input: SendCallbackInput): Promise<{ success:
|
||||
}
|
||||
|
||||
try {
|
||||
const tokenResult = await ensureKuaishouIndustryAccessToken({ config })
|
||||
const tokenResult = await ensureKuaishouIndustryAccessToken({
|
||||
config,
|
||||
...(input.sellerId ? { sellerId: input.sellerId } : {}),
|
||||
})
|
||||
config = {
|
||||
...config,
|
||||
...tokenResult.config,
|
||||
@@ -100,11 +104,14 @@ export async function sendCallback(input: SendCallbackInput): Promise<{ success:
|
||||
}
|
||||
|
||||
const url = `${resolveKuaishouOpenApiBaseUrl(config.baseUrl)}/integration/callback/virtual/eticket/send`
|
||||
const requestLog = buildSendCallbackRequestLog({
|
||||
url,
|
||||
signParams,
|
||||
bizParams,
|
||||
})
|
||||
const requestLog = {
|
||||
sellerId: input.sellerId || '',
|
||||
...buildSendCallbackRequestLog({
|
||||
url,
|
||||
signParams,
|
||||
bizParams,
|
||||
}),
|
||||
}
|
||||
|
||||
logIntegration('[kuaishou-industry/send-callback]', `发起电子凭证发货回调 oid=${input.oid}`, requestLog)
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ export async function handleSendCode(rawBody: JsonObject = {}) {
|
||||
const voucher = await upsertKuaishouIndustryVoucher({
|
||||
oid: normalizedOid,
|
||||
unitIndex,
|
||||
sellerId: params.sellerId,
|
||||
token: params.token,
|
||||
orderId: order?.id || null,
|
||||
taskId: task?.id || null,
|
||||
@@ -99,6 +100,7 @@ export async function handleSendCode(rawBody: JsonObject = {}) {
|
||||
oid: normalizedOid,
|
||||
sendType: params.sendType,
|
||||
etickets,
|
||||
sellerId: params.sellerId,
|
||||
token: params.token,
|
||||
eticketType: params.eticketType,
|
||||
ext: params.ext,
|
||||
@@ -112,6 +114,7 @@ function fireSendCallback(input: {
|
||||
oid: string
|
||||
sendType: string
|
||||
etickets: ReturnType<typeof buildIndustryEticketItem>[]
|
||||
sellerId: string
|
||||
token: string
|
||||
eticketType?: string
|
||||
ext?: string
|
||||
@@ -138,6 +141,7 @@ function fireSendCallback(input: {
|
||||
|
||||
logIntegration('[kuaishou-industry/send-code]', '电子凭证发货回调已调度', {
|
||||
oid: input.oid,
|
||||
sellerId: input.sellerId,
|
||||
sendType: input.sendType,
|
||||
sendNum,
|
||||
totalGoodsValue,
|
||||
@@ -151,6 +155,7 @@ function fireSendCallback(input: {
|
||||
oid: input.oid,
|
||||
sendType: input.sendType,
|
||||
etickets: eticketItemsWithGoodsValue,
|
||||
sellerId: input.sellerId,
|
||||
sendNum,
|
||||
totalGoodsValue,
|
||||
token: input.token,
|
||||
|
||||
@@ -6,9 +6,29 @@ import { readJsonFile, writeJsonFile } from '../../../utils/json-file-store.js'
|
||||
const KUAISHOU_INDUSTRY_SOURCE_FILE_PATH = path.join(PROJECT_ROOT, 'data', 'kuaishou-industry-source.json')
|
||||
const DEFAULT_CALLBACK_BASE_URL = 'https://openapi.kwaixiaodian.com'
|
||||
const DEFAULT_AUTH_BASE_URL = 'https://open.kwaixiaodian.com'
|
||||
const LEGACY_DEFAULT_REDIRECT_URI = 'https://ks.khhao.com/oauth-callback'
|
||||
const DEFAULT_REDIRECT_URI = 'https://ks.khhao.com/admin/platform-shops?tab=kuaishouIndustry'
|
||||
const DEFAULT_SCOPES = 'merchant_item'
|
||||
|
||||
type JsonObject = Record<string, any>
|
||||
|
||||
export type KuaishouIndustryShopConfig = {
|
||||
enabled: boolean
|
||||
sellerId: string
|
||||
shopId: string
|
||||
shopName: string
|
||||
customShopName: string
|
||||
authState: string
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
accessTokenExpiresAt: string
|
||||
refreshTokenExpiresAt: string
|
||||
openId: string
|
||||
grantedScopes: string
|
||||
lastRefreshedAt: string
|
||||
lastRefreshError: string
|
||||
}
|
||||
|
||||
export type KuaishouIndustrySourceConfig = {
|
||||
enabled: boolean
|
||||
baseUrl: string
|
||||
@@ -32,6 +52,7 @@ export type KuaishouIndustrySourceConfig = {
|
||||
shopId: string
|
||||
shopName: string
|
||||
version: string
|
||||
shops: KuaishouIndustryShopConfig[]
|
||||
lastRefreshedAt: string
|
||||
lastRefreshError: string
|
||||
}
|
||||
@@ -65,35 +86,83 @@ export function patchKuaishouIndustrySourceConfig(
|
||||
})
|
||||
}
|
||||
|
||||
export function listKuaishouIndustryShopConfigs(
|
||||
source: KuaishouIndustrySourceConfig = getKuaishouIndustrySourceConfig(),
|
||||
): KuaishouIndustryShopConfig[] {
|
||||
return Array.isArray(source.shops) ? source.shops : []
|
||||
}
|
||||
|
||||
export function findKuaishouIndustryShopConfig(
|
||||
sellerId: unknown,
|
||||
source: KuaishouIndustrySourceConfig = getKuaishouIndustrySourceConfig(),
|
||||
): KuaishouIndustryShopConfig | null {
|
||||
const normalizedSellerId = String(sellerId || '').trim()
|
||||
if (!normalizedSellerId) {
|
||||
return null
|
||||
}
|
||||
|
||||
return listKuaishouIndustryShopConfigs(source)
|
||||
.find((shop) => String(shop.sellerId || shop.shopId || '').trim() === normalizedSellerId) || null
|
||||
}
|
||||
|
||||
export function patchKuaishouIndustryShopConfig(
|
||||
sellerId: unknown,
|
||||
patch: Partial<KuaishouIndustryShopConfig>,
|
||||
): KuaishouIndustrySourceConfig {
|
||||
const source = getKuaishouIndustrySourceConfig()
|
||||
const normalizedSellerId = String(patch.sellerId || sellerId || '').trim()
|
||||
const existing = findKuaishouIndustryShopConfig(normalizedSellerId, source)
|
||||
const nextShop = normalizeKuaishouIndustryShopConfig({
|
||||
...(existing || {}),
|
||||
...patch,
|
||||
sellerId: normalizedSellerId,
|
||||
})
|
||||
|
||||
if (!nextShop) {
|
||||
return source
|
||||
}
|
||||
|
||||
const shops = listKuaishouIndustryShopConfigs(source)
|
||||
.filter((shop) => String(shop.sellerId || shop.shopId || '').trim() !== normalizedSellerId)
|
||||
|
||||
return saveKuaishouIndustrySourceConfig({
|
||||
...source,
|
||||
shops: [...shops, nextShop],
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeKuaishouIndustrySourceConfig(rawValue: unknown): KuaishouIndustrySourceConfig {
|
||||
const fallback = createDefaultKuaishouIndustrySourceConfig()
|
||||
const source = isPlainObject(rawValue) ? rawValue : {}
|
||||
const shops = normalizeKuaishouIndustryShopList(source)
|
||||
const primaryShop = shops[0] || createEmptyKuaishouIndustryShopConfig()
|
||||
|
||||
return {
|
||||
enabled: typeof source.enabled === 'boolean' ? source.enabled : fallback.enabled,
|
||||
baseUrl: normalizeUrlLike(source.baseUrl, fallback.baseUrl),
|
||||
authBaseUrl: normalizeUrlLike(source.authBaseUrl, fallback.authBaseUrl),
|
||||
redirectUri: normalizeString(source.redirectUri, fallback.redirectUri),
|
||||
scopes: normalizeString(source.scopes, fallback.scopes),
|
||||
redirectUri: normalizeRedirectUri(source.redirectUri, fallback.redirectUri),
|
||||
scopes: normalizeScopeText(normalizeString(source.scopes, fallback.scopes)),
|
||||
authState: normalizeString(source.authState, fallback.authState),
|
||||
appKey: normalizeString(source.appKey, fallback.appKey),
|
||||
appSecret: normalizeString(source.appSecret, fallback.appSecret),
|
||||
signSecret: normalizeString(source.signSecret, fallback.signSecret),
|
||||
messageSecret: normalizeString(source.messageSecret, fallback.messageSecret),
|
||||
accessToken: normalizeString(source.accessToken, fallback.accessToken),
|
||||
refreshToken: normalizeString(source.refreshToken, fallback.refreshToken),
|
||||
accessTokenExpiresAt: normalizeNullableIso(source.accessTokenExpiresAt),
|
||||
refreshTokenExpiresAt: normalizeNullableIso(source.refreshTokenExpiresAt),
|
||||
openId: normalizeString(source.openId, fallback.openId),
|
||||
grantedScopes: normalizeString(source.grantedScopes, fallback.grantedScopes),
|
||||
sellerId: normalizeString(source.sellerId, fallback.sellerId),
|
||||
accessToken: primaryShop.accessToken,
|
||||
refreshToken: primaryShop.refreshToken,
|
||||
accessTokenExpiresAt: primaryShop.accessTokenExpiresAt,
|
||||
refreshTokenExpiresAt: primaryShop.refreshTokenExpiresAt,
|
||||
openId: primaryShop.openId,
|
||||
grantedScopes: primaryShop.grantedScopes,
|
||||
sellerId: primaryShop.sellerId,
|
||||
provider: normalizeString(source.provider, fallback.provider),
|
||||
platform: normalizeString(source.platform, fallback.platform),
|
||||
shopId: normalizeString(source.shopId, fallback.shopId),
|
||||
shopName: normalizeString(source.shopName, fallback.shopName),
|
||||
shopId: normalizeString(source.shopId, primaryShop.shopId || fallback.shopId),
|
||||
shopName: normalizeString(source.shopName, primaryShop.shopName || fallback.shopName),
|
||||
version: normalizeString(source.version, fallback.version),
|
||||
lastRefreshedAt: normalizeNullableIso(source.lastRefreshedAt),
|
||||
lastRefreshError: normalizeString(source.lastRefreshError, ''),
|
||||
shops,
|
||||
lastRefreshedAt: primaryShop.lastRefreshedAt || normalizeNullableIso(source.lastRefreshedAt),
|
||||
lastRefreshError: primaryShop.lastRefreshError || normalizeString(source.lastRefreshError, ''),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,8 +173,8 @@ function createDefaultKuaishouIndustrySourceConfig(): KuaishouIndustrySourceConf
|
||||
enabled: true,
|
||||
baseUrl: DEFAULT_CALLBACK_BASE_URL,
|
||||
authBaseUrl: DEFAULT_AUTH_BASE_URL,
|
||||
redirectUri: '',
|
||||
scopes: '',
|
||||
redirectUri: DEFAULT_REDIRECT_URI,
|
||||
scopes: DEFAULT_SCOPES,
|
||||
authState: '',
|
||||
appKey: String(runtime.appKey || '').trim(),
|
||||
appSecret: String(runtime.appSecret || '').trim(),
|
||||
@@ -123,6 +192,117 @@ function createDefaultKuaishouIndustrySourceConfig(): KuaishouIndustrySourceConf
|
||||
shopId: String(runtime.shopId || 'kuaishou-industry').trim() || 'kuaishou-industry',
|
||||
shopName: String(runtime.shopName || '快手行业电子凭证').trim() || '快手行业电子凭证',
|
||||
version: String(runtime.version || '1').trim() || '1',
|
||||
shops: [],
|
||||
lastRefreshedAt: '',
|
||||
lastRefreshError: '',
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeKuaishouIndustryShopList(source: JsonObject): KuaishouIndustryShopConfig[] {
|
||||
const rawShops = Array.isArray(source.shops)
|
||||
? source.shops
|
||||
: buildLegacySingleShopList(source)
|
||||
|
||||
const deduped = new Map<string, KuaishouIndustryShopConfig>()
|
||||
let unnamedIndex = 0
|
||||
for (const item of rawShops) {
|
||||
const shop = normalizeKuaishouIndustryShopConfig(item)
|
||||
if (!shop) {
|
||||
continue
|
||||
}
|
||||
|
||||
const key = shop.sellerId || shop.shopId || `__legacy_${unnamedIndex++}`
|
||||
|
||||
deduped.set(key, shop)
|
||||
}
|
||||
|
||||
return [...deduped.values()]
|
||||
}
|
||||
|
||||
function normalizeKuaishouIndustryShopConfig(rawValue: unknown): KuaishouIndustryShopConfig | null {
|
||||
if (!isPlainObject(rawValue)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const sellerId = String(rawValue.sellerId || rawValue.shopId || rawValue.userId || '').trim()
|
||||
const shopId = String(rawValue.shopId || sellerId).trim()
|
||||
const shopName = String(rawValue.shopName || rawValue.kshopName || rawValue.userName || '').trim()
|
||||
const customShopName = String(rawValue.customShopName || '').trim()
|
||||
const accessToken = normalizeString(rawValue.accessToken, '')
|
||||
const refreshToken = normalizeString(rawValue.refreshToken, '')
|
||||
const openId = normalizeString(rawValue.openId, '')
|
||||
|
||||
if (!sellerId && !shopId && !shopName && !customShopName && !accessToken && !refreshToken && !openId) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: typeof rawValue.enabled === 'boolean' ? rawValue.enabled : true,
|
||||
sellerId: sellerId || shopId,
|
||||
shopId: shopId || sellerId,
|
||||
shopName,
|
||||
customShopName,
|
||||
authState: normalizeString(rawValue.authState, ''),
|
||||
accessToken,
|
||||
refreshToken,
|
||||
accessTokenExpiresAt: normalizeNullableIso(rawValue.accessTokenExpiresAt),
|
||||
refreshTokenExpiresAt: normalizeNullableIso(rawValue.refreshTokenExpiresAt),
|
||||
openId,
|
||||
grantedScopes: normalizeScopeText(normalizeString(rawValue.grantedScopes, '')),
|
||||
lastRefreshedAt: normalizeNullableIso(rawValue.lastRefreshedAt),
|
||||
lastRefreshError: normalizeString(rawValue.lastRefreshError, ''),
|
||||
}
|
||||
}
|
||||
|
||||
function buildLegacySingleShopList(source: JsonObject): JsonObject[] {
|
||||
const legacyShopId = String(source.shopId || '').trim()
|
||||
const sellerId = String(
|
||||
source.sellerId || (legacyShopId && legacyShopId !== 'kuaishou-industry' ? legacyShopId : ''),
|
||||
).trim()
|
||||
const accessToken = String(source.accessToken || '').trim()
|
||||
const refreshToken = String(source.refreshToken || '').trim()
|
||||
const openId = String(source.openId || '').trim()
|
||||
const shopName = String(source.shopName || '').trim()
|
||||
const customShopName = String(source.customShopName || '').trim()
|
||||
|
||||
if (!sellerId && !accessToken && !refreshToken && !openId && !shopName && !customShopName) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
enabled: source.enabled !== false,
|
||||
sellerId,
|
||||
shopId: sellerId,
|
||||
shopName,
|
||||
customShopName,
|
||||
authState: source.authState,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
accessTokenExpiresAt: source.accessTokenExpiresAt,
|
||||
refreshTokenExpiresAt: source.refreshTokenExpiresAt,
|
||||
openId,
|
||||
grantedScopes: source.grantedScopes,
|
||||
lastRefreshedAt: source.lastRefreshedAt,
|
||||
lastRefreshError: source.lastRefreshError,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function createEmptyKuaishouIndustryShopConfig(): KuaishouIndustryShopConfig {
|
||||
return {
|
||||
enabled: true,
|
||||
sellerId: '',
|
||||
shopId: '',
|
||||
shopName: '',
|
||||
customShopName: '',
|
||||
authState: '',
|
||||
accessToken: '',
|
||||
refreshToken: '',
|
||||
accessTokenExpiresAt: '',
|
||||
refreshTokenExpiresAt: '',
|
||||
openId: '',
|
||||
grantedScopes: '',
|
||||
lastRefreshedAt: '',
|
||||
lastRefreshError: '',
|
||||
}
|
||||
@@ -133,6 +313,23 @@ function normalizeString(value: unknown, fallback: string): string {
|
||||
return text || fallback
|
||||
}
|
||||
|
||||
function normalizeRedirectUri(value: unknown, fallback: string): string {
|
||||
const text = String(value || '').trim()
|
||||
if (!text || text === LEGACY_DEFAULT_REDIRECT_URI) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
function normalizeScopeText(value: unknown): string {
|
||||
return String(value || '')
|
||||
.split(/[,\s]+/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.join(',')
|
||||
}
|
||||
|
||||
function normalizeUrlLike(value: unknown, fallback: string): string {
|
||||
return String(value || fallback).trim().replace(/\/+$/, '') || fallback
|
||||
}
|
||||
|
||||
@@ -2,9 +2,11 @@ import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import {
|
||||
ensureKuaishouIndustryAccessToken,
|
||||
shouldRefreshAccessToken,
|
||||
} from './token-service.js'
|
||||
import type {
|
||||
KuaishouIndustryShopConfig,
|
||||
KuaishouIndustrySourceConfig,
|
||||
} from './source-config-service.js'
|
||||
|
||||
@@ -37,6 +39,39 @@ test('shouldRefreshAccessToken keeps valid tokens without a forced refresh', ()
|
||||
})), false)
|
||||
})
|
||||
|
||||
test('ensureKuaishouIndustryAccessToken requires sellerId when multiple shops are enabled', async () => {
|
||||
await assert.rejects(
|
||||
() => ensureKuaishouIndustryAccessToken({
|
||||
config: createConfig({
|
||||
shops: [
|
||||
createShop({ sellerId: 'seller-a', accessToken: 'token-a' }),
|
||||
createShop({ sellerId: 'seller-b', accessToken: 'token-b' }),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
{
|
||||
message: '存在多个快手行业电子凭证店铺授权,请指定 sellerId',
|
||||
errorCode: 'kuaishou_industry_missing_seller_id',
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
test('ensureKuaishouIndustryAccessToken selects the token by sellerId', async () => {
|
||||
const result = await ensureKuaishouIndustryAccessToken({
|
||||
sellerId: 'seller-b',
|
||||
config: createConfig({
|
||||
shops: [
|
||||
createShop({ sellerId: 'seller-a', accessToken: 'token-a' }),
|
||||
createShop({ sellerId: 'seller-b', accessToken: 'token-b' }),
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
assert.equal(result.accessToken, 'token-b')
|
||||
assert.equal(result.refreshed, false)
|
||||
assert.equal(result.shop.sellerId, 'seller-b')
|
||||
})
|
||||
|
||||
function createConfig(
|
||||
patch: Partial<KuaishouIndustrySourceConfig> = {},
|
||||
): KuaishouIndustrySourceConfig {
|
||||
@@ -63,6 +98,29 @@ function createConfig(
|
||||
shopId: 'kuaishou-industry',
|
||||
shopName: '快手行业电子凭证',
|
||||
version: '1',
|
||||
shops: [],
|
||||
lastRefreshedAt: '',
|
||||
lastRefreshError: '',
|
||||
...patch,
|
||||
}
|
||||
}
|
||||
|
||||
function createShop(
|
||||
patch: Partial<KuaishouIndustryShopConfig> = {},
|
||||
): KuaishouIndustryShopConfig {
|
||||
return {
|
||||
enabled: true,
|
||||
sellerId: 'seller-a',
|
||||
shopId: 'seller-a',
|
||||
shopName: '测试店铺',
|
||||
customShopName: '自定义测试店铺',
|
||||
authState: '',
|
||||
accessToken: 'token',
|
||||
refreshToken: 'refresh-token',
|
||||
accessTokenExpiresAt: new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString(),
|
||||
refreshTokenExpiresAt: '',
|
||||
openId: '',
|
||||
grantedScopes: '',
|
||||
lastRefreshedAt: '',
|
||||
lastRefreshError: '',
|
||||
...patch,
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { createHttpError } from '../../../utils/http.js'
|
||||
import { logInfo, logWarn } from '../../../utils/logger.js'
|
||||
import {
|
||||
findKuaishouIndustryShopConfig,
|
||||
getKuaishouIndustrySourceConfig,
|
||||
listKuaishouIndustryShopConfigs,
|
||||
patchKuaishouIndustrySourceConfig,
|
||||
patchKuaishouIndustryShopConfig,
|
||||
type KuaishouIndustryShopConfig,
|
||||
type KuaishouIndustrySourceConfig,
|
||||
} from './source-config-service.js'
|
||||
|
||||
@@ -16,39 +20,51 @@ export type KuaishouIndustryAccessTokenResult = {
|
||||
accessToken: string
|
||||
refreshed: boolean
|
||||
config: KuaishouIndustrySourceConfig
|
||||
shop: KuaishouIndustryShopConfig
|
||||
}
|
||||
|
||||
export async function ensureKuaishouIndustryAccessToken(
|
||||
options: {
|
||||
forceRefresh?: boolean
|
||||
config?: KuaishouIndustrySourceConfig
|
||||
sellerId?: string
|
||||
} = {},
|
||||
): Promise<KuaishouIndustryAccessTokenResult> {
|
||||
const config = options.config || getKuaishouIndustrySourceConfig()
|
||||
const accessToken = String(config.accessToken || '').trim()
|
||||
const refreshToken = String(config.refreshToken || '').trim()
|
||||
const shop = resolveTokenShop(config, options.sellerId)
|
||||
const accessToken = String(shop.accessToken || '').trim()
|
||||
const refreshToken = String(shop.refreshToken || '').trim()
|
||||
|
||||
if (!options.forceRefresh && accessToken && !shouldRefreshAccessToken(config)) {
|
||||
return { accessToken, refreshed: false, config }
|
||||
if (!options.forceRefresh && accessToken && !shouldRefreshAccessToken(shop)) {
|
||||
return { accessToken, refreshed: false, config, shop }
|
||||
}
|
||||
|
||||
if (!refreshToken) {
|
||||
return { accessToken, refreshed: false, config }
|
||||
return { accessToken, refreshed: false, config, shop }
|
||||
}
|
||||
|
||||
return refreshKuaishouIndustryAccessToken(config)
|
||||
const sellerId = shop.sellerId || options.sellerId
|
||||
return refreshKuaishouIndustryAccessToken({
|
||||
config,
|
||||
...(sellerId ? { sellerId } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
export async function refreshKuaishouIndustryAccessToken(
|
||||
config: KuaishouIndustrySourceConfig = getKuaishouIndustrySourceConfig(),
|
||||
options: {
|
||||
config?: KuaishouIndustrySourceConfig
|
||||
sellerId?: string
|
||||
} = {},
|
||||
): Promise<KuaishouIndustryAccessTokenResult> {
|
||||
assertRefreshConfig(config)
|
||||
const config = options.config || getKuaishouIndustrySourceConfig()
|
||||
const shop = resolveTokenShop(config, options.sellerId)
|
||||
assertRefreshConfig(config, shop)
|
||||
|
||||
const params = new URLSearchParams()
|
||||
params.set('app_id', config.appKey)
|
||||
params.set('app_secret', config.appSecret)
|
||||
params.set('grant_type', 'refresh_token')
|
||||
params.set('refresh_token', config.refreshToken)
|
||||
params.set('refresh_token', shop.refreshToken)
|
||||
|
||||
try {
|
||||
const startedAt = Date.now()
|
||||
@@ -60,22 +76,27 @@ export async function refreshKuaishouIndustryAccessToken(
|
||||
failureMessage: '快手 accessToken 刷新失败',
|
||||
errorCode: 'kuaishou_industry_access_token_refresh_failed',
|
||||
})
|
||||
const saved = saveTokenPayload(tokenPayload, config)
|
||||
const saved = saveTokenPayload(tokenPayload, config, shop)
|
||||
|
||||
logInfo('[kuaishou-industry/token]', 'accessToken 刷新成功', {
|
||||
durationMs: Date.now() - startedAt,
|
||||
accessTokenExpiresAt: saved.accessTokenExpiresAt || '',
|
||||
refreshTokenExpiresAt: saved.refreshTokenExpiresAt || '',
|
||||
hasRefreshToken: Boolean(saved.refreshToken),
|
||||
sellerId: saved.shop.sellerId || '',
|
||||
accessTokenExpiresAt: saved.shop.accessTokenExpiresAt || '',
|
||||
refreshTokenExpiresAt: saved.shop.refreshTokenExpiresAt || '',
|
||||
hasRefreshToken: Boolean(saved.shop.refreshToken),
|
||||
})
|
||||
|
||||
return {
|
||||
accessToken: saved.accessToken,
|
||||
accessToken: saved.shop.accessToken,
|
||||
refreshed: true,
|
||||
config: saved,
|
||||
config: saved.config,
|
||||
shop: saved.shop,
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
patchKuaishouIndustryShopConfig(shop.sellerId, {
|
||||
lastRefreshError: message,
|
||||
})
|
||||
patchKuaishouIndustrySourceConfig({
|
||||
lastRefreshError: message,
|
||||
})
|
||||
@@ -86,8 +107,14 @@ export async function refreshKuaishouIndustryAccessToken(
|
||||
|
||||
export async function exchangeKuaishouIndustryAuthorizationCode(
|
||||
code: string,
|
||||
config: KuaishouIndustrySourceConfig = getKuaishouIndustrySourceConfig(),
|
||||
options: {
|
||||
config?: KuaishouIndustrySourceConfig
|
||||
sellerId?: string
|
||||
shopName?: string
|
||||
customShopName?: string
|
||||
} = {},
|
||||
): Promise<KuaishouIndustryAccessTokenResult> {
|
||||
const config = options.config || getKuaishouIndustrySourceConfig()
|
||||
assertAuthorizationCodeConfig(config, code)
|
||||
|
||||
const params = new URLSearchParams()
|
||||
@@ -106,19 +133,27 @@ export async function exchangeKuaishouIndustryAuthorizationCode(
|
||||
failureMessage: '快手授权码换取 accessToken 失败',
|
||||
errorCode: 'kuaishou_industry_authorization_code_exchange_failed',
|
||||
})
|
||||
const saved = saveTokenPayload(tokenPayload, config)
|
||||
const saved = saveTokenPayload(tokenPayload, config, {
|
||||
...createEmptyShopConfig(),
|
||||
sellerId: String(options.sellerId || '').trim(),
|
||||
shopId: String(options.sellerId || '').trim(),
|
||||
shopName: String(options.shopName || '').trim(),
|
||||
customShopName: String(options.customShopName || '').trim(),
|
||||
})
|
||||
|
||||
logInfo('[kuaishou-industry/token]', '授权码换取 accessToken 成功', {
|
||||
durationMs: Date.now() - startedAt,
|
||||
accessTokenExpiresAt: saved.accessTokenExpiresAt || '',
|
||||
refreshTokenExpiresAt: saved.refreshTokenExpiresAt || '',
|
||||
hasRefreshToken: Boolean(saved.refreshToken),
|
||||
sellerId: saved.shop.sellerId || '',
|
||||
accessTokenExpiresAt: saved.shop.accessTokenExpiresAt || '',
|
||||
refreshTokenExpiresAt: saved.shop.refreshTokenExpiresAt || '',
|
||||
hasRefreshToken: Boolean(saved.shop.refreshToken),
|
||||
})
|
||||
|
||||
return {
|
||||
accessToken: saved.accessToken,
|
||||
accessToken: saved.shop.accessToken,
|
||||
refreshed: true,
|
||||
config: saved,
|
||||
config: saved.config,
|
||||
shop: saved.shop,
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
@@ -130,7 +165,7 @@ export async function exchangeKuaishouIndustryAuthorizationCode(
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldRefreshAccessToken(config: KuaishouIndustrySourceConfig): boolean {
|
||||
export function shouldRefreshAccessToken(config: Pick<KuaishouIndustryShopConfig, 'accessToken' | 'accessTokenExpiresAt'>): boolean {
|
||||
if (!String(config.accessToken || '').trim()) {
|
||||
return true
|
||||
}
|
||||
@@ -143,7 +178,67 @@ export function shouldRefreshAccessToken(config: KuaishouIndustrySourceConfig):
|
||||
return expiresAt <= Date.now() + ACCESS_TOKEN_REFRESH_MARGIN_MS
|
||||
}
|
||||
|
||||
function assertRefreshConfig(config: KuaishouIndustrySourceConfig) {
|
||||
function resolveTokenShop(
|
||||
config: KuaishouIndustrySourceConfig,
|
||||
sellerId: unknown,
|
||||
): KuaishouIndustryShopConfig {
|
||||
const normalizedSellerId = String(sellerId || '').trim()
|
||||
if (normalizedSellerId) {
|
||||
const matched = findKuaishouIndustryShopConfig(normalizedSellerId, config)
|
||||
if (!matched) {
|
||||
throw createHttpError(`未找到 sellerId=${normalizedSellerId} 的快手行业电子凭证授权`, {
|
||||
statusCode: 400,
|
||||
errorCode: 'kuaishou_industry_shop_not_configured',
|
||||
})
|
||||
}
|
||||
|
||||
if (matched.enabled === false) {
|
||||
throw createHttpError(`sellerId=${normalizedSellerId} 的快手行业电子凭证授权已停用`, {
|
||||
statusCode: 400,
|
||||
errorCode: 'kuaishou_industry_shop_disabled',
|
||||
})
|
||||
}
|
||||
|
||||
return matched
|
||||
}
|
||||
|
||||
const enabledShops = listKuaishouIndustryShopConfigs(config)
|
||||
.filter((shop) => shop.enabled !== false)
|
||||
|
||||
if (enabledShops.length === 1) {
|
||||
return enabledShops[0] as KuaishouIndustryShopConfig
|
||||
}
|
||||
|
||||
if (enabledShops.length > 1) {
|
||||
throw createHttpError('存在多个快手行业电子凭证店铺授权,请指定 sellerId', {
|
||||
statusCode: 400,
|
||||
errorCode: 'kuaishou_industry_missing_seller_id',
|
||||
})
|
||||
}
|
||||
|
||||
return createEmptyShopConfig()
|
||||
}
|
||||
|
||||
function createEmptyShopConfig(): KuaishouIndustryShopConfig {
|
||||
return {
|
||||
enabled: true,
|
||||
sellerId: '',
|
||||
shopId: '',
|
||||
shopName: '',
|
||||
customShopName: '',
|
||||
authState: '',
|
||||
accessToken: '',
|
||||
refreshToken: '',
|
||||
accessTokenExpiresAt: '',
|
||||
refreshTokenExpiresAt: '',
|
||||
openId: '',
|
||||
grantedScopes: '',
|
||||
lastRefreshedAt: '',
|
||||
lastRefreshError: '',
|
||||
}
|
||||
}
|
||||
|
||||
function assertRefreshConfig(config: KuaishouIndustrySourceConfig, shop: KuaishouIndustryShopConfig) {
|
||||
if (!config.baseUrl) {
|
||||
throw createHttpError('快手开放平台 API 地址未配置', {
|
||||
statusCode: 400,
|
||||
@@ -158,7 +253,7 @@ function assertRefreshConfig(config: KuaishouIndustrySourceConfig) {
|
||||
})
|
||||
}
|
||||
|
||||
if (!config.refreshToken) {
|
||||
if (!shop.refreshToken) {
|
||||
throw createHttpError('refreshToken 未配置,无法刷新 accessToken', {
|
||||
statusCode: 400,
|
||||
errorCode: 'kuaishou_industry_missing_refresh_token',
|
||||
@@ -234,21 +329,37 @@ async function requestKuaishouIndustryToken({
|
||||
function saveTokenPayload(
|
||||
tokenPayload: ReturnType<typeof normalizeTokenResponse>,
|
||||
config: KuaishouIndustrySourceConfig,
|
||||
shop: KuaishouIndustryShopConfig,
|
||||
) {
|
||||
const nextRefreshToken = tokenPayload.refreshToken || config.refreshToken
|
||||
const nextSellerId = tokenPayload.sellerId || shop.sellerId
|
||||
if (!nextSellerId) {
|
||||
throw createHttpError('授权结果缺少 sellerId,请在店铺授权中填写 sellerId 后重试', {
|
||||
statusCode: 502,
|
||||
errorCode: 'kuaishou_industry_missing_token_seller_id',
|
||||
})
|
||||
}
|
||||
|
||||
return patchKuaishouIndustrySourceConfig({
|
||||
const nextRefreshToken = tokenPayload.refreshToken || shop.refreshToken
|
||||
const savedConfig = patchKuaishouIndustryShopConfig(nextSellerId, {
|
||||
...shop,
|
||||
enabled: shop.enabled !== false,
|
||||
sellerId: nextSellerId,
|
||||
shopId: shop.shopId || nextSellerId,
|
||||
accessToken: tokenPayload.accessToken,
|
||||
refreshToken: nextRefreshToken,
|
||||
accessTokenExpiresAt: tokenPayload.accessTokenExpiresAt,
|
||||
refreshTokenExpiresAt: tokenPayload.refreshTokenExpiresAt
|
||||
|| (tokenPayload.refreshToken ? new Date(Date.now() + DEFAULT_REFRESH_TOKEN_TTL_MS).toISOString() : config.refreshTokenExpiresAt),
|
||||
sellerId: tokenPayload.sellerId || config.sellerId,
|
||||
openId: tokenPayload.openId || config.openId,
|
||||
grantedScopes: tokenPayload.grantedScopes || config.grantedScopes,
|
||||
|| (tokenPayload.refreshToken ? new Date(Date.now() + DEFAULT_REFRESH_TOKEN_TTL_MS).toISOString() : shop.refreshTokenExpiresAt),
|
||||
openId: tokenPayload.openId || shop.openId,
|
||||
grantedScopes: tokenPayload.grantedScopes || shop.grantedScopes,
|
||||
lastRefreshedAt: new Date().toISOString(),
|
||||
lastRefreshError: '',
|
||||
})
|
||||
|
||||
return {
|
||||
config: savedConfig,
|
||||
shop: resolveTokenShop(savedConfig, nextSellerId),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTokenResponse(
|
||||
|
||||
@@ -169,6 +169,7 @@ export async function consumeKuaishouIndustryVoucher(
|
||||
? { success: true as const }
|
||||
: await consumeCallback({
|
||||
oid: voucher.oid,
|
||||
sellerId: String(voucher.seller_id || '').trim(),
|
||||
etickets: [{
|
||||
id: voucher.voucher_code,
|
||||
num: 1,
|
||||
|
||||
Reference in New Issue
Block a user