补充行业凭证授权换 token
This commit is contained in:
@@ -17,6 +17,9 @@ export function getKuaishouIndustryConfig(overrides: Partial<KuaishouIndustrySou
|
||||
enabled: config.enabled !== false,
|
||||
baseUrl: String(config.baseUrl || '').trim(),
|
||||
authBaseUrl: String(config.authBaseUrl || '').trim(),
|
||||
redirectUri: String(config.redirectUri || '').trim(),
|
||||
scopes: String(config.scopes || '').trim(),
|
||||
authState: String(config.authState || '').trim(),
|
||||
appKey: String(config.appKey || '').trim(),
|
||||
appSecret: String(config.appSecret || '').trim(),
|
||||
signSecret: String(config.signSecret || '').trim(),
|
||||
@@ -25,6 +28,8 @@ export function getKuaishouIndustryConfig(overrides: Partial<KuaishouIndustrySou
|
||||
refreshToken: String(config.refreshToken || '').trim(),
|
||||
accessTokenExpiresAt: String(config.accessTokenExpiresAt || '').trim(),
|
||||
refreshTokenExpiresAt: String(config.refreshTokenExpiresAt || '').trim(),
|
||||
openId: String(config.openId || '').trim(),
|
||||
grantedScopes: String(config.grantedScopes || '').trim(),
|
||||
sellerId: String(config.sellerId || '').trim(),
|
||||
lastRefreshedAt: String(config.lastRefreshedAt || '').trim(),
|
||||
lastRefreshError: String(config.lastRefreshError || '').trim(),
|
||||
|
||||
@@ -14,6 +14,9 @@ export type KuaishouIndustrySourceConfig = {
|
||||
sendCallbackEnabled: boolean
|
||||
baseUrl: string
|
||||
authBaseUrl: string
|
||||
redirectUri: string
|
||||
scopes: string
|
||||
authState: string
|
||||
appKey: string
|
||||
appSecret: string
|
||||
signSecret: string
|
||||
@@ -22,6 +25,8 @@ export type KuaishouIndustrySourceConfig = {
|
||||
refreshToken: string
|
||||
accessTokenExpiresAt: string
|
||||
refreshTokenExpiresAt: string
|
||||
openId: string
|
||||
grantedScopes: string
|
||||
sellerId: string
|
||||
provider: string
|
||||
platform: string
|
||||
@@ -72,6 +77,9 @@ function normalizeKuaishouIndustrySourceConfig(rawValue: unknown): KuaishouIndus
|
||||
: fallback.sendCallbackEnabled,
|
||||
baseUrl: normalizeUrlLike(source.baseUrl, fallback.baseUrl),
|
||||
authBaseUrl: normalizeUrlLike(source.authBaseUrl, fallback.authBaseUrl),
|
||||
redirectUri: normalizeString(source.redirectUri, fallback.redirectUri),
|
||||
scopes: 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),
|
||||
@@ -80,6 +88,8 @@ function normalizeKuaishouIndustrySourceConfig(rawValue: unknown): KuaishouIndus
|
||||
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),
|
||||
provider: normalizeString(source.provider, fallback.provider),
|
||||
platform: normalizeString(source.platform, fallback.platform),
|
||||
@@ -99,6 +109,9 @@ function createDefaultKuaishouIndustrySourceConfig(): KuaishouIndustrySourceConf
|
||||
sendCallbackEnabled: Boolean(runtime.sendCallbackEnabled),
|
||||
baseUrl: DEFAULT_CALLBACK_BASE_URL,
|
||||
authBaseUrl: DEFAULT_AUTH_BASE_URL,
|
||||
redirectUri: '',
|
||||
scopes: '',
|
||||
authState: '',
|
||||
appKey: String(runtime.appKey || '').trim(),
|
||||
appSecret: String(runtime.appSecret || '').trim(),
|
||||
signSecret: String(runtime.signSecret || '').trim(),
|
||||
@@ -107,6 +120,8 @@ function createDefaultKuaishouIndustrySourceConfig(): KuaishouIndustrySourceConf
|
||||
refreshToken: '',
|
||||
accessTokenExpiresAt: '',
|
||||
refreshTokenExpiresAt: '',
|
||||
openId: '',
|
||||
grantedScopes: '',
|
||||
sellerId: '',
|
||||
provider: String(runtime.provider || 'kuaishou-industry').trim() || 'kuaishou-industry',
|
||||
platform: String(runtime.platform || 'kuaishou').trim() || 'kuaishou',
|
||||
|
||||
@@ -45,6 +45,9 @@ function createConfig(
|
||||
sendCallbackEnabled: true,
|
||||
baseUrl: 'https://openapi.kwaixiaodian.com',
|
||||
authBaseUrl: 'https://open.kwaixiaodian.com',
|
||||
redirectUri: '',
|
||||
scopes: '',
|
||||
authState: '',
|
||||
appKey: 'app-key',
|
||||
appSecret: 'app-secret',
|
||||
signSecret: 'sign-secret',
|
||||
@@ -53,6 +56,8 @@ function createConfig(
|
||||
refreshToken: 'refresh-token',
|
||||
accessTokenExpiresAt: new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString(),
|
||||
refreshTokenExpiresAt: '',
|
||||
openId: '',
|
||||
grantedScopes: '',
|
||||
sellerId: '',
|
||||
provider: 'kuaishou-industry',
|
||||
platform: 'kuaishou',
|
||||
|
||||
@@ -10,6 +10,7 @@ type JsonObject = Record<string, any>
|
||||
|
||||
const ACCESS_TOKEN_REFRESH_MARGIN_MS = 30 * 60 * 1000
|
||||
const DEFAULT_ACCESS_TOKEN_TTL_MS = 47 * 60 * 60 * 1000
|
||||
const DEFAULT_REFRESH_TOKEN_TTL_MS = 179 * 24 * 60 * 60 * 1000
|
||||
|
||||
export type KuaishouIndustryAccessTokenResult = {
|
||||
accessToken: string
|
||||
@@ -43,40 +44,23 @@ export async function refreshKuaishouIndustryAccessToken(
|
||||
): Promise<KuaishouIndustryAccessTokenResult> {
|
||||
assertRefreshConfig(config)
|
||||
|
||||
const authBaseUrl = String(config.authBaseUrl || '').trim().replace(/\/+$/, '')
|
||||
const url = new URL(`${authBaseUrl}/oauth2/refresh_token`)
|
||||
url.searchParams.set('app_id', config.appKey)
|
||||
url.searchParams.set('app_secret', config.appSecret)
|
||||
url.searchParams.set('grant_type', 'refresh_token')
|
||||
url.searchParams.set('refresh_token', config.refreshToken)
|
||||
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)
|
||||
|
||||
try {
|
||||
const startedAt = Date.now()
|
||||
const response = await fetch(url)
|
||||
const text = await response.text()
|
||||
const json = parseJsonObject(text)
|
||||
const tokenPayload = normalizeTokenRefreshResponse(json, config)
|
||||
|
||||
if (!response.ok || !tokenPayload.accessToken) {
|
||||
const message = resolveTokenRefreshErrorMessage(json, response.status)
|
||||
patchKuaishouIndustrySourceConfig({
|
||||
lastRefreshError: message,
|
||||
})
|
||||
throw createHttpError(message, {
|
||||
statusCode: 502,
|
||||
errorCode: 'kuaishou_industry_access_token_refresh_failed',
|
||||
})
|
||||
}
|
||||
|
||||
const saved = patchKuaishouIndustrySourceConfig({
|
||||
accessToken: tokenPayload.accessToken,
|
||||
refreshToken: tokenPayload.refreshToken || config.refreshToken,
|
||||
accessTokenExpiresAt: tokenPayload.accessTokenExpiresAt,
|
||||
refreshTokenExpiresAt: tokenPayload.refreshTokenExpiresAt || config.refreshTokenExpiresAt,
|
||||
sellerId: tokenPayload.sellerId || config.sellerId,
|
||||
lastRefreshedAt: new Date().toISOString(),
|
||||
lastRefreshError: '',
|
||||
const tokenPayload = await requestKuaishouIndustryToken({
|
||||
config,
|
||||
path: '/oauth2/refresh_token',
|
||||
params,
|
||||
method: 'POST',
|
||||
failureMessage: '快手 accessToken 刷新失败',
|
||||
errorCode: 'kuaishou_industry_access_token_refresh_failed',
|
||||
})
|
||||
const saved = saveTokenPayload(tokenPayload, config)
|
||||
|
||||
logInfo('[kuaishou-industry/token]', 'accessToken 刷新成功', {
|
||||
durationMs: Date.now() - startedAt,
|
||||
@@ -100,6 +84,52 @@ export async function refreshKuaishouIndustryAccessToken(
|
||||
}
|
||||
}
|
||||
|
||||
export async function exchangeKuaishouIndustryAuthorizationCode(
|
||||
code: string,
|
||||
config: KuaishouIndustrySourceConfig = getKuaishouIndustrySourceConfig(),
|
||||
): Promise<KuaishouIndustryAccessTokenResult> {
|
||||
assertAuthorizationCodeConfig(config, code)
|
||||
|
||||
const params = new URLSearchParams()
|
||||
params.set('app_id', config.appKey)
|
||||
params.set('app_secret', config.appSecret)
|
||||
params.set('grant_type', 'code')
|
||||
params.set('code', code.trim())
|
||||
|
||||
try {
|
||||
const startedAt = Date.now()
|
||||
const tokenPayload = await requestKuaishouIndustryToken({
|
||||
config,
|
||||
path: '/oauth2/access_token',
|
||||
params,
|
||||
method: 'GET',
|
||||
failureMessage: '快手授权码换取 accessToken 失败',
|
||||
errorCode: 'kuaishou_industry_authorization_code_exchange_failed',
|
||||
})
|
||||
const saved = saveTokenPayload(tokenPayload, config)
|
||||
|
||||
logInfo('[kuaishou-industry/token]', '授权码换取 accessToken 成功', {
|
||||
durationMs: Date.now() - startedAt,
|
||||
accessTokenExpiresAt: saved.accessTokenExpiresAt || '',
|
||||
refreshTokenExpiresAt: saved.refreshTokenExpiresAt || '',
|
||||
hasRefreshToken: Boolean(saved.refreshToken),
|
||||
})
|
||||
|
||||
return {
|
||||
accessToken: saved.accessToken,
|
||||
refreshed: true,
|
||||
config: saved,
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
patchKuaishouIndustrySourceConfig({
|
||||
lastRefreshError: message,
|
||||
})
|
||||
logWarn('[kuaishou-industry/token]', '授权码换取 accessToken 失败', resolveRefreshErrorDetail(error))
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldRefreshAccessToken(config: KuaishouIndustrySourceConfig): boolean {
|
||||
if (!String(config.accessToken || '').trim()) {
|
||||
return true
|
||||
@@ -114,10 +144,10 @@ export function shouldRefreshAccessToken(config: KuaishouIndustrySourceConfig):
|
||||
}
|
||||
|
||||
function assertRefreshConfig(config: KuaishouIndustrySourceConfig) {
|
||||
if (!config.authBaseUrl) {
|
||||
throw createHttpError('快手开放平台授权地址未配置', {
|
||||
if (!config.baseUrl) {
|
||||
throw createHttpError('快手开放平台 API 地址未配置', {
|
||||
statusCode: 400,
|
||||
errorCode: 'kuaishou_industry_missing_auth_base_url',
|
||||
errorCode: 'kuaishou_industry_missing_base_url',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -136,7 +166,92 @@ function assertRefreshConfig(config: KuaishouIndustrySourceConfig) {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTokenRefreshResponse(
|
||||
function assertAuthorizationCodeConfig(config: KuaishouIndustrySourceConfig, code: string) {
|
||||
if (!config.baseUrl) {
|
||||
throw createHttpError('快手开放平台 API 地址未配置', {
|
||||
statusCode: 400,
|
||||
errorCode: 'kuaishou_industry_missing_base_url',
|
||||
})
|
||||
}
|
||||
|
||||
if (!config.appKey || !config.appSecret) {
|
||||
throw createHttpError('快手 appKey/appSecret 未配置,无法换取 accessToken', {
|
||||
statusCode: 400,
|
||||
errorCode: 'kuaishou_industry_missing_oauth_credential',
|
||||
})
|
||||
}
|
||||
|
||||
if (!String(code || '').trim()) {
|
||||
throw createHttpError('授权 code 未填写,无法换取 accessToken', {
|
||||
statusCode: 400,
|
||||
errorCode: 'kuaishou_industry_missing_authorization_code',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function requestKuaishouIndustryToken({
|
||||
config,
|
||||
path,
|
||||
params,
|
||||
method,
|
||||
failureMessage,
|
||||
errorCode,
|
||||
}: {
|
||||
config: KuaishouIndustrySourceConfig
|
||||
path: string
|
||||
params: URLSearchParams
|
||||
method: 'GET' | 'POST'
|
||||
failureMessage: string
|
||||
errorCode: string
|
||||
}) {
|
||||
const baseUrl = String(config.baseUrl || '').trim().replace(/\/+$/, '')
|
||||
const url = new URL(`${baseUrl}${path}`)
|
||||
const response = method === 'GET'
|
||||
? await fetch(appendSearchParams(url, params))
|
||||
: await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body: params.toString(),
|
||||
})
|
||||
const text = await response.text()
|
||||
const json = parseJsonObject(text)
|
||||
const tokenPayload = normalizeTokenResponse(json, config)
|
||||
|
||||
if (!response.ok || !isTokenResponseSuccess(json) || !tokenPayload.accessToken) {
|
||||
const message = resolveTokenErrorMessage(json, response.status, failureMessage)
|
||||
patchKuaishouIndustrySourceConfig({
|
||||
lastRefreshError: message,
|
||||
})
|
||||
throw createHttpError(message, {
|
||||
statusCode: 502,
|
||||
errorCode,
|
||||
})
|
||||
}
|
||||
|
||||
return tokenPayload
|
||||
}
|
||||
|
||||
function saveTokenPayload(
|
||||
tokenPayload: ReturnType<typeof normalizeTokenResponse>,
|
||||
config: KuaishouIndustrySourceConfig,
|
||||
) {
|
||||
const nextRefreshToken = tokenPayload.refreshToken || config.refreshToken
|
||||
|
||||
return patchKuaishouIndustrySourceConfig({
|
||||
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,
|
||||
lastRefreshedAt: new Date().toISOString(),
|
||||
lastRefreshError: '',
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeTokenResponse(
|
||||
json: JsonObject,
|
||||
currentConfig: KuaishouIndustrySourceConfig,
|
||||
) {
|
||||
@@ -152,11 +267,15 @@ function normalizeTokenRefreshResponse(
|
||||
'refreshTokenValue',
|
||||
])
|
||||
const sellerId = pickFirstString(payload, ['seller_id', 'sellerId'])
|
||||
const openId = pickFirstString(payload, ['open_id', 'openId'])
|
||||
const grantedScopes = pickScopes(payload)
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
sellerId,
|
||||
openId,
|
||||
grantedScopes,
|
||||
accessTokenExpiresAt: resolveExpiresAt(payload, [
|
||||
'access_token_expires_at',
|
||||
'accessTokenExpiresAt',
|
||||
@@ -176,10 +295,18 @@ function normalizeTokenRefreshResponse(
|
||||
], [
|
||||
'refresh_token_expires_in',
|
||||
'refreshTokenExpiresIn',
|
||||
], 0) || currentConfig.refreshTokenExpiresAt,
|
||||
], 0),
|
||||
}
|
||||
}
|
||||
|
||||
function appendSearchParams(url: URL, params: URLSearchParams): URL {
|
||||
for (const [key, value] of params.entries()) {
|
||||
url.searchParams.set(key, value)
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
function resolveExpiresAt(
|
||||
payload: JsonObject,
|
||||
dateKeys: string[],
|
||||
@@ -218,7 +345,17 @@ function parseTimestampLike(value: unknown): number {
|
||||
return Date.parse(String(value || '').trim())
|
||||
}
|
||||
|
||||
function resolveTokenRefreshErrorMessage(json: JsonObject, status: number): string {
|
||||
function isTokenResponseSuccess(json: JsonObject): boolean {
|
||||
const payload = isPlainObject(json.data) ? json.data : json
|
||||
const result = payload.result ?? json.result
|
||||
if (result === undefined || result === null || result === '') {
|
||||
return true
|
||||
}
|
||||
|
||||
return Number(result) === 1 || result === true || String(result).toLowerCase() === 'true'
|
||||
}
|
||||
|
||||
function resolveTokenErrorMessage(json: JsonObject, status: number, fallback: string): string {
|
||||
const payload = isPlainObject(json.data) ? json.data : json
|
||||
return pickFirstString(payload, [
|
||||
'error_msg',
|
||||
@@ -227,7 +364,7 @@ function resolveTokenRefreshErrorMessage(json: JsonObject, status: number): stri
|
||||
'msg',
|
||||
'error_description',
|
||||
'errorDescription',
|
||||
]) || `快手 accessToken 刷新失败,HTTP ${status}`
|
||||
]) || `${fallback},HTTP ${status}`
|
||||
}
|
||||
|
||||
function parseJsonObject(text: string): JsonObject {
|
||||
@@ -250,6 +387,15 @@ function pickFirstString(payload: JsonObject, keys: string[]): string {
|
||||
return ''
|
||||
}
|
||||
|
||||
function pickScopes(payload: JsonObject): string {
|
||||
const value = payload.scopes ?? payload.scope
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => String(item || '').trim()).filter(Boolean).join(',')
|
||||
}
|
||||
|
||||
return String(value || '').trim()
|
||||
}
|
||||
|
||||
function resolveRefreshErrorDetail(error: unknown): JsonObject {
|
||||
const detail: JsonObject = {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
|
||||
Reference in New Issue
Block a user