diff --git a/apps/backend/src/db/migrations/004_kuaishou_industry_voucher_seller_id.sql b/apps/backend/src/db/migrations/004_kuaishou_industry_voucher_seller_id.sql new file mode 100644 index 00000000..f029ff02 --- /dev/null +++ b/apps/backend/src/db/migrations/004_kuaishou_industry_voucher_seller_id.sql @@ -0,0 +1,5 @@ +ALTER TABLE kuaishou_industry_vouchers + ADD COLUMN IF NOT EXISTS seller_id TEXT NOT NULL DEFAULT ''; + +CREATE INDEX IF NOT EXISTS idx_kuaishou_industry_vouchers_seller_id + ON kuaishou_industry_vouchers(seller_id); diff --git a/apps/backend/src/repositories/kuaishou-industry-voucher-repo.ts b/apps/backend/src/repositories/kuaishou-industry-voucher-repo.ts index 8edd8991..6c839175 100644 --- a/apps/backend/src/repositories/kuaishou-industry-voucher-repo.ts +++ b/apps/backend/src/repositories/kuaishou-industry-voucher-repo.ts @@ -54,6 +54,7 @@ async function insertKuaishouIndustryVoucher( order_id, task_id, unit_index, + seller_id, token, status, valid_start_time, @@ -77,15 +78,20 @@ async function insertKuaishouIndustryVoucher( $8, $9, $10, - $11::jsonb, - $12, + $11, + $12::jsonb, $13, - $14::jsonb, - $15, - $16 + $14, + $15::jsonb, + $16, + $17 ) ON CONFLICT (oid, unit_index) DO UPDATE SET + seller_id = CASE + WHEN EXCLUDED.seller_id <> '' THEN EXCLUDED.seller_id + ELSE kuaishou_industry_vouchers.seller_id + END, token = CASE WHEN EXCLUDED.token <> '' THEN EXCLUDED.token ELSE kuaishou_industry_vouchers.token @@ -104,6 +110,7 @@ async function insertKuaishouIndustryVoucher( normalizeNullableId(input.orderId), normalizeNullableId(input.taskId), input.unitIndex, + input.sellerId || '', input.token || '', input.status || 'UNUSED', input.validStartTime || 0, @@ -234,6 +241,10 @@ function normalizeVoucherPatchColumns( columns.push({ column: 'token', value: patch.token || '' }) } + if (patch.sellerId !== undefined) { + columns.push({ column: 'seller_id', value: patch.sellerId || '' }) + } + if (patch.status !== undefined) { columns.push({ column: 'status', value: patch.status || 'UNUSED' }) } diff --git a/apps/backend/src/routes/admin/platform-config/kuaishou-industry.ts b/apps/backend/src/routes/admin/platform-config/kuaishou-industry.ts index fffdac21..376a8bcf 100644 --- a/apps/backend/src/routes/admin/platform-config/kuaishou-industry.ts +++ b/apps/backend/src/routes/admin/platform-config/kuaishou-industry.ts @@ -44,6 +44,7 @@ router.post( data: { filePath: String(result.filePath || '').trim(), enabled: Boolean(result.source?.enabled), + shopCount: Array.isArray(result.source?.shops) ? result.source.shops.length : 0, hasAccessToken: Boolean(result.source?.hasAccessToken), hasRefreshToken: Boolean(result.source?.hasRefreshToken), accessTokenStatus: String(result.source?.accessTokenStatus || '').trim(), @@ -56,7 +57,7 @@ router.post( router.post( '/kuaishou-industry-source/refresh-token', - createJsonHandler(() => refreshAdminKuaishouIndustryAccessToken(), { + createJsonHandler((req) => refreshAdminKuaishouIndustryAccessToken(req.body as JsonRecord), { successMessage: '快手行业电子凭证 accessToken 已刷新', errorMessage: '刷新快手行业电子凭证 accessToken 失败', scope: '[admin/platform-config/kuaishou-industry-source/refresh-token]', @@ -68,8 +69,9 @@ router.post( targetId: 'kuaishou_industry_source', data: { refreshed: Boolean(result.refreshed), - accessTokenExpiresAt: String(result.source?.accessTokenExpiresAt || '').trim(), - accessTokenStatus: String(result.source?.accessTokenStatus || '').trim(), + sellerId: String(result.shop?.sellerId || '').trim(), + accessTokenExpiresAt: String(result.shop?.accessTokenExpiresAt || '').trim(), + accessTokenStatus: String(result.shop?.accessTokenStatus || '').trim(), }, } }, @@ -95,9 +97,10 @@ router.post( targetId: 'kuaishou_industry_source', data: { refreshed: Boolean(result.refreshed), - accessTokenExpiresAt: String(result.source?.accessTokenExpiresAt || '').trim(), - refreshTokenExpiresAt: String(result.source?.refreshTokenExpiresAt || '').trim(), - accessTokenStatus: String(result.source?.accessTokenStatus || '').trim(), + sellerId: String(result.shop?.sellerId || '').trim(), + accessTokenExpiresAt: String(result.shop?.accessTokenExpiresAt || '').trim(), + refreshTokenExpiresAt: String(result.shop?.refreshTokenExpiresAt || '').trim(), + accessTokenStatus: String(result.shop?.accessTokenStatus || '').trim(), }, } }, diff --git a/apps/backend/src/services/admin/platform-config/kuaishou-industry-service.ts b/apps/backend/src/services/admin/platform-config/kuaishou-industry-service.ts index 6ce84746..a47fea11 100644 --- a/apps/backend/src/services/admin/platform-config/kuaishou-industry-service.ts +++ b/apps/backend/src/services/admin/platform-config/kuaishou-industry-service.ts @@ -1,9 +1,11 @@ import { maskSecret } from '../../../utils/masking.js' import { createHttpError } from '../../../utils/http.js' import { + findKuaishouIndustryShopConfig, getKuaishouIndustrySourceConfig, getKuaishouIndustrySourceFilePath, saveKuaishouIndustrySourceConfig, + type KuaishouIndustryShopConfig, type KuaishouIndustrySourceConfig, } from '../../platforms/kuaishou-industry/source-config-service.js' import { @@ -17,6 +19,9 @@ const SECRET_FIELDS = [ 'appSecret', 'signSecret', 'messageSecret', +] as const + +const SHOP_SECRET_FIELDS = [ 'accessToken', 'refreshToken', ] as const @@ -49,8 +54,10 @@ export function updateAdminKuaishouIndustrySourceConfig(payload: JsonObject = {} shopId: readConfigString(payload, 'shopId', current.shopId), shopName: readConfigString(payload, 'shopName', current.shopName), version: readConfigString(payload, 'version', current.version), - accessTokenExpiresAt: readConfigString(payload, 'accessTokenExpiresAt', current.accessTokenExpiresAt, { allowBlank: true }), - refreshTokenExpiresAt: readConfigString(payload, 'refreshTokenExpiresAt', current.refreshTokenExpiresAt, { allowBlank: true }), + shops: normalizeShopConfigPayloads( + Array.isArray(payload.shops) ? payload.shops : current.shops, + current.shops, + ), ...resolveSecretPatch(payload, current), }) @@ -60,12 +67,15 @@ export function updateAdminKuaishouIndustrySourceConfig(payload: JsonObject = {} } } -export async function refreshAdminKuaishouIndustryAccessToken() { - const result = await refreshKuaishouIndustryAccessToken() +export async function refreshAdminKuaishouIndustryAccessToken(payload: JsonObject = {}) { + const result = await refreshKuaishouIndustryAccessToken({ + sellerId: String(payload.sellerId || '').trim(), + }) return { filePath: getKuaishouIndustrySourceFilePath(), refreshed: result.refreshed, + shop: mapAdminKuaishouIndustryShopConfig(result.shop, result.config), source: mapAdminKuaishouIndustrySourceConfig(result.config), } } @@ -79,17 +89,23 @@ export async function exchangeAdminKuaishouIndustryAuthorizationCode(payload: Js }) } - const result = await exchangeKuaishouIndustryAuthorizationCode(code) + const result = await exchangeKuaishouIndustryAuthorizationCode(code, { + sellerId: String(payload.sellerId || '').trim(), + shopName: String(payload.shopName || '').trim(), + customShopName: String(payload.customShopName || '').trim(), + }) return { filePath: getKuaishouIndustrySourceFilePath(), refreshed: result.refreshed, + shop: mapAdminKuaishouIndustryShopConfig(result.shop, result.config), source: mapAdminKuaishouIndustrySourceConfig(result.config), } } function mapAdminKuaishouIndustrySourceConfig(config: KuaishouIndustrySourceConfig) { - const accessTokenStatus = resolveAccessTokenStatus(config) + const primaryShop = config.shops[0] || null + const accessTokenStatus = resolveAccessTokenStatus(primaryShop || config) return { enabled: config.enabled !== false, @@ -127,12 +143,47 @@ function mapAdminKuaishouIndustrySourceConfig(config: KuaishouIndustrySourceConf shopId: config.shopId, shopName: config.shopName, version: config.version, + shops: config.shops.map((shop) => mapAdminKuaishouIndustryShopConfig(shop, config)), lastRefreshedAt: config.lastRefreshedAt, lastRefreshError: config.lastRefreshError, } } -function buildKuaishouIndustryAuthorizationUrl(config: KuaishouIndustrySourceConfig): string { +function mapAdminKuaishouIndustryShopConfig( + shop: KuaishouIndustryShopConfig, + config: KuaishouIndustrySourceConfig, +) { + const accessTokenStatus = resolveAccessTokenStatus(shop) + + return { + enabled: shop.enabled !== false, + sellerId: shop.sellerId, + shopId: shop.shopId, + shopName: shop.shopName, + customShopName: shop.customShopName, + authState: shop.authState, + authorizationUrl: buildKuaishouIndustryAuthorizationUrl(config, shop), + accessToken: '', + accessTokenMasked: maskSecret(shop.accessToken), + hasAccessToken: Boolean(shop.accessToken), + refreshToken: '', + refreshTokenMasked: maskSecret(shop.refreshToken), + hasRefreshToken: Boolean(shop.refreshToken), + accessTokenExpiresAt: shop.accessTokenExpiresAt, + refreshTokenExpiresAt: shop.refreshTokenExpiresAt, + accessTokenStatus: accessTokenStatus.status, + accessTokenExpiresInSeconds: accessTokenStatus.expiresInSeconds, + openId: shop.openId, + grantedScopes: shop.grantedScopes, + lastRefreshedAt: shop.lastRefreshedAt, + lastRefreshError: shop.lastRefreshError, + } +} + +function buildKuaishouIndustryAuthorizationUrl( + config: KuaishouIndustrySourceConfig, + shop?: Pick | null, +): string { if (!config.authBaseUrl || !config.appKey || !config.redirectUri || !config.scopes) { return '' } @@ -143,8 +194,9 @@ function buildKuaishouIndustryAuthorizationUrl(config: KuaishouIndustrySourceCon url.searchParams.set('redirect_uri', config.redirectUri) url.searchParams.set('scope', normalizeScopeText(config.scopes)) url.searchParams.set('response_type', 'code') - if (config.authState) { - url.searchParams.set('state', config.authState) + const state = String(shop?.authState || config.authState || shop?.sellerId || '').trim() + if (state) { + url.searchParams.set('state', state) } return url.toString() @@ -174,7 +226,96 @@ function resolveSecretPatch( return patch } -function resolveAccessTokenStatus(config: KuaishouIndustrySourceConfig) { +function normalizeShopConfigPayloads( + rawShops: unknown[], + currentShops: KuaishouIndustryShopConfig[] = [], +): KuaishouIndustryShopConfig[] { + return rawShops + .map((rawShop, index) => normalizeShopConfigPayload(rawShop, index, currentShops)) + .filter((shop): shop is KuaishouIndustryShopConfig => Boolean(shop)) +} + +function normalizeShopConfigPayload( + rawShop: unknown, + index: number, + currentShops: KuaishouIndustryShopConfig[], +): KuaishouIndustryShopConfig | null { + if (!rawShop || typeof rawShop !== 'object' || Array.isArray(rawShop)) { + return null + } + + const payload = rawShop as JsonObject + const current = resolveCurrentShopConfig(payload, index, currentShops) + const sellerId = readConfigString(payload, 'sellerId', current?.sellerId || '', { allowBlank: true }) + const shopId = readConfigString(payload, 'shopId', current?.shopId || sellerId, { allowBlank: true }) || sellerId + const shopName = readConfigString(payload, 'shopName', current?.shopName || '', { allowBlank: true }) + const customShopName = readConfigString(payload, 'customShopName', current?.customShopName || '', { allowBlank: true }) + const accessToken = readSecretString(payload, 'accessToken', current?.accessToken || '') + const refreshToken = readSecretString(payload, 'refreshToken', current?.refreshToken || '') + const openId = readConfigString(payload, 'openId', current?.openId || '', { allowBlank: true }) + + if (!sellerId && !shopId && !shopName && !customShopName && !accessToken && !refreshToken && !openId) { + return null + } + + return { + enabled: hasPayloadField(payload, 'enabled') ? payload.enabled !== false : current?.enabled !== false, + sellerId, + shopId, + shopName, + customShopName, + authState: readConfigString(payload, 'authState', current?.authState || '', { allowBlank: true }), + accessToken, + refreshToken, + accessTokenExpiresAt: readConfigString(payload, 'accessTokenExpiresAt', current?.accessTokenExpiresAt || '', { allowBlank: true }), + refreshTokenExpiresAt: readConfigString(payload, 'refreshTokenExpiresAt', current?.refreshTokenExpiresAt || '', { allowBlank: true }), + openId, + grantedScopes: normalizeScopeText(readConfigString(payload, 'grantedScopes', current?.grantedScopes || '', { allowBlank: true })), + lastRefreshedAt: readConfigString(payload, 'lastRefreshedAt', current?.lastRefreshedAt || '', { allowBlank: true }), + lastRefreshError: readConfigString(payload, 'lastRefreshError', current?.lastRefreshError || '', { allowBlank: true }), + ...resolveShopSecretPatch(payload, current), + } +} + +function resolveCurrentShopConfig( + payload: JsonObject, + index: number, + currentShops: KuaishouIndustryShopConfig[], +): KuaishouIndustryShopConfig | null { + const sellerId = String(payload.sellerId || payload.shopId || '').trim() + if (sellerId) { + const sourceLike = { shops: currentShops } as KuaishouIndustrySourceConfig + const matched = findKuaishouIndustryShopConfig(sellerId, sourceLike) + if (matched) { + return matched + } + } + + return currentShops[index] || null +} + +function resolveShopSecretPatch( + payload: JsonObject, + current: KuaishouIndustryShopConfig | null, +): Partial { + const patch: Partial = {} + for (const field of SHOP_SECRET_FIELDS) { + patch[field] = readSecretString(payload, field, current?.[field] || '') + } + + return patch +} + +function readSecretString( + payload: JsonObject, + field: string, + fallback: string, +): string { + const text = String(payload[field] || '').trim() + return text || fallback +} + +function resolveAccessTokenStatus(config: Pick) { if (!config.accessToken) { return { status: 'missing', diff --git a/apps/backend/src/services/platforms/kuaishou-industry/config.ts b/apps/backend/src/services/platforms/kuaishou-industry/config.ts index 7efa6560..96091dad 100644 --- a/apps/backend/src/services/platforms/kuaishou-industry/config.ts +++ b/apps/backend/src/services/platforms/kuaishou-industry/config.ts @@ -38,6 +38,7 @@ export function getKuaishouIndustryConfig(overrides: Partial ({ id: String(e.id), code: e.code, diff --git a/apps/backend/src/services/platforms/kuaishou-industry/destroy-callback-service.ts b/apps/backend/src/services/platforms/kuaishou-industry/destroy-callback-service.ts index 6fb05fe1..ec40190f 100644 --- a/apps/backend/src/services/platforms/kuaishou-industry/destroy-callback-service.ts +++ b/apps/backend/src/services/platforms/kuaishou-industry/destroy-callback-service.ts @@ -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, }) diff --git a/apps/backend/src/services/platforms/kuaishou-industry/destroy-code-service.ts b/apps/backend/src/services/platforms/kuaishou-industry/destroy-code-service.ts index 81296271..844ac25a 100644 --- a/apps/backend/src/services/platforms/kuaishou-industry/destroy-code-service.ts +++ b/apps/backend/src/services/platforms/kuaishou-industry/destroy-code-service.ts @@ -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, diff --git a/apps/backend/src/services/platforms/kuaishou-industry/payload.ts b/apps/backend/src/services/platforms/kuaishou-industry/payload.ts index 651d5efe..eb91c87a 100644 --- a/apps/backend/src/services/platforms/kuaishou-industry/payload.ts +++ b/apps/backend/src/services/platforms/kuaishou-industry/payload.ts @@ -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[] + 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, diff --git a/apps/backend/src/services/platforms/kuaishou-industry/source-config-service.ts b/apps/backend/src/services/platforms/kuaishou-industry/source-config-service.ts index 80ec6d69..76c2ee65 100644 --- a/apps/backend/src/services/platforms/kuaishou-industry/source-config-service.ts +++ b/apps/backend/src/services/platforms/kuaishou-industry/source-config-service.ts @@ -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 +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, +): 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() + 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 } diff --git a/apps/backend/src/services/platforms/kuaishou-industry/token-service.test.ts b/apps/backend/src/services/platforms/kuaishou-industry/token-service.test.ts index 1f8c1ca1..ede8b6aa 100644 --- a/apps/backend/src/services/platforms/kuaishou-industry/token-service.test.ts +++ b/apps/backend/src/services/platforms/kuaishou-industry/token-service.test.ts @@ -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 { @@ -63,6 +98,29 @@ function createConfig( shopId: 'kuaishou-industry', shopName: '快手行业电子凭证', version: '1', + shops: [], + lastRefreshedAt: '', + lastRefreshError: '', + ...patch, + } +} + +function createShop( + patch: Partial = {}, +): 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, diff --git a/apps/backend/src/services/platforms/kuaishou-industry/token-service.ts b/apps/backend/src/services/platforms/kuaishou-industry/token-service.ts index 9925bc1c..6faba428 100644 --- a/apps/backend/src/services/platforms/kuaishou-industry/token-service.ts +++ b/apps/backend/src/services/platforms/kuaishou-industry/token-service.ts @@ -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 { 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 { - 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 { + 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): 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, 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( diff --git a/apps/backend/src/services/platforms/kuaishou-industry/voucher-service.ts b/apps/backend/src/services/platforms/kuaishou-industry/voucher-service.ts index 05d797e5..13536285 100644 --- a/apps/backend/src/services/platforms/kuaishou-industry/voucher-service.ts +++ b/apps/backend/src/services/platforms/kuaishou-industry/voucher-service.ts @@ -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, diff --git a/apps/backend/src/types/admin/write-inputs.ts b/apps/backend/src/types/admin/write-inputs.ts index aa60567e..a2db9e0c 100644 --- a/apps/backend/src/types/admin/write-inputs.ts +++ b/apps/backend/src/types/admin/write-inputs.ts @@ -12,6 +12,23 @@ export type AdminKuaishouEticketSourceConfigInput = { shops?: AdminKuaishouEticketShopConfigWriteItemInput[] } +export type AdminKuaishouIndustryShopConfigInput = { + 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 AdminKuaishouIndustrySourceConfigInput = { enabled?: boolean baseUrl?: string @@ -35,10 +52,14 @@ export type AdminKuaishouIndustrySourceConfigInput = { shopId?: string shopName?: string version?: string + shops?: AdminKuaishouIndustryShopConfigInput[] } export type AdminKuaishouIndustryAuthorizationCodeInput = { code?: string + sellerId?: string + shopName?: string + customShopName?: string } export type AdminNotificationBarkRecipientInput = { diff --git a/apps/backend/src/types/repository/inputs.ts b/apps/backend/src/types/repository/inputs.ts index 0398ec8e..df2707e9 100644 --- a/apps/backend/src/types/repository/inputs.ts +++ b/apps/backend/src/types/repository/inputs.ts @@ -131,6 +131,7 @@ export type TaskUpdatePatch = { export type KuaishouIndustryVoucherUpsertInput = { oid: string unitIndex: number + sellerId?: string token?: string orderId?: number | string | null taskId?: number | string | null diff --git a/apps/backend/src/types/repository/rows.ts b/apps/backend/src/types/repository/rows.ts index 706d872c..567a1d1f 100644 --- a/apps/backend/src/types/repository/rows.ts +++ b/apps/backend/src/types/repository/rows.ts @@ -111,6 +111,7 @@ export type KuaishouIndustryVoucherRow = { order_id: number | null task_id: number | null unit_index: number + seller_id: string token: string status: string valid_start_time: number | string diff --git a/apps/frontend/src/pages/admin/platform/AdminPlatformShopsPage.tsx b/apps/frontend/src/pages/admin/platform/AdminPlatformShopsPage.tsx index e7bdc7f9..5d43fa57 100644 --- a/apps/frontend/src/pages/admin/platform/AdminPlatformShopsPage.tsx +++ b/apps/frontend/src/pages/admin/platform/AdminPlatformShopsPage.tsx @@ -71,6 +71,7 @@ import type { AdminKuaishouEticketShopInfoResult, AdminKuaishouEticketSourceConfig, AdminKuaishouIndustryConfigResponse, + AdminKuaishouIndustryShopConfig, AdminKuaishouIndustrySourceConfig, AdminKuaishouFeifeiConfig, AdminKuaishouFeifeiConfigResponse, @@ -1070,12 +1071,45 @@ function KuaishouIndustryPanel({ config: AdminKuaishouIndustryConfigResponse onChange: (config: AdminKuaishouIndustryConfigResponse) => void }) { + const [searchParams] = useSearchParams() const [saving, setSaving] = useState(false) - const [refreshing, setRefreshing] = useState(false) - const [exchanging, setExchanging] = useState(false) - const [authorizationCode, setAuthorizationCode] = useState('') + const [actionLoading, setActionLoading] = useState('') + const [authorizationCodes, setAuthorizationCodes] = useState>({}) const source = config.source - const authorizationUrl = buildIndustryAuthorizationUrl(source) + const shops = source.shops || [] + const enabledShopCount = shops.filter((shop) => shop.enabled !== false).length + const validTokenCount = shops.filter((shop) => shop.accessTokenStatus === 'valid').length + const problemTokenCount = shops.filter((shop) => + ['expired', 'expiring', 'missing'].includes(shop.accessTokenStatus), + ).length + + useEffect(() => { + const code = String(searchParams.get('code') || '').trim() + if (!code || shops.length === 0) { + return + } + + const state = String(searchParams.get('state') || '').trim() + const matchedIndex = state + ? shops.findIndex((shop) => + [shop.authState, shop.sellerId, shop.shopId].some((value) => value === state), + ) + : -1 + const enabledIndex = shops.findIndex((shop) => shop.enabled !== false) + const targetIndex = matchedIndex >= 0 ? matchedIndex : Math.max(enabledIndex, 0) + const actionKey = buildIndustryShopActionKey(targetIndex) + + setAuthorizationCodes((current) => { + if (current[actionKey] || Object.values(current).some((value) => value.trim() === code)) { + return current + } + + return { + ...current, + [actionKey]: code, + } + }) + }, [searchParams, shops]) async function saveConfig() { setSaving(true) @@ -1090,44 +1124,63 @@ function KuaishouIndustryPanel({ } } - async function refreshToken() { - setRefreshing(true) + async function refreshToken(shop: AdminKuaishouIndustryShopConfig, index: number) { + const sellerId = shop.sellerId.trim() + if (!sellerId) { + showError('请先填写 sellerId') + return + } + + setActionLoading(`refresh-${index}`) try { const saved = await saveAdminKuaishouIndustrySourceConfig(source) onChange(saved.data) - const response = await refreshAdminKuaishouIndustryAccessToken() + const response = await refreshAdminKuaishouIndustryAccessToken({ sellerId }) onChange(response.data) - showSuccess('accessToken 已刷新') + showSuccess(`${resolveIndustryShopDisplayName(shop)} 的 accessToken 已刷新`) } catch (error) { showError(error instanceof Error ? error.message : '刷新 accessToken 失败') } finally { - setRefreshing(false) + setActionLoading('') } } - async function exchangeAuthorizationCode() { - const code = authorizationCode.trim() + async function exchangeAuthorizationCode(shop: AdminKuaishouIndustryShopConfig, index: number) { + const sellerId = shop.sellerId.trim() + if (!sellerId) { + showError('请先填写 sellerId') + return + } + + const actionKey = buildIndustryShopActionKey(index) + const code = String(authorizationCodes[actionKey] || '').trim() if (!code) { showError('请输入授权 code') return } - setExchanging(true) + setActionLoading(`exchange-${index}`) try { const saved = await saveAdminKuaishouIndustrySourceConfig(source) onChange(saved.data) - const response = await exchangeAdminKuaishouIndustryAuthorizationCode({ code }) + const response = await exchangeAdminKuaishouIndustryAuthorizationCode({ + code, + sellerId, + shopName: shop.shopName, + customShopName: shop.customShopName, + }) onChange(response.data) - setAuthorizationCode('') - showSuccess('授权 token 已保存') + setAuthorizationCodes((current) => ({ ...current, [actionKey]: '' })) + showSuccess(`${resolveIndustryShopDisplayName(shop)} 的授权 token 已保存`) } catch (error) { showError(error instanceof Error ? error.message : '授权 code 换 token 失败') } finally { - setExchanging(false) + setActionLoading('') } } - async function copyAuthorizationUrl() { + async function copyAuthorizationUrl(shop: AdminKuaishouIndustryShopConfig) { + const authorizationUrl = buildIndustryAuthorizationUrl(source, shop) if (!authorizationUrl) { showError('授权链接未生成') return @@ -1141,7 +1194,8 @@ function KuaishouIndustryPanel({ } } - function openAuthorizationUrl() { + function openAuthorizationUrl(shop: AdminKuaishouIndustryShopConfig) { + const authorizationUrl = buildIndustryAuthorizationUrl(source, shop) if (!authorizationUrl) { showError('授权链接未生成') return @@ -1160,9 +1214,42 @@ function KuaishouIndustryPanel({ }) } + function updateShop(index: number, patch: Partial) { + updateSource({ + shops: shops.map((shop, shopIndex) => + shopIndex === index ? { ...shop, ...patch } : shop, + ), + }) + } + + function updateShopSellerId(index: number, sellerId: string) { + const shop = shops[index] + if (!shop) { + return + } + + updateShop(index, { + sellerId, + shopId: !shop.shopId || shop.shopId === shop.sellerId ? sellerId : shop.shopId, + authState: !shop.authState || shop.authState === shop.sellerId ? sellerId : shop.authState, + }) + } + + function addShop() { + updateSource({ + shops: [...shops, createEmptyIndustryShopConfig()], + }) + } + + function deleteShop(index: number) { + updateSource({ + shops: shops.filter((_, shopIndex) => shopIndex !== index), + }) + } + function renderSecretInput( label: string, - field: 'appSecret' | 'signSecret' | 'messageSecret' | 'accessToken' | 'refreshToken', + field: 'appSecret' | 'signSecret' | 'messageSecret', masked: string, ) { return ( @@ -1177,7 +1264,123 @@ function KuaishouIndustryPanel({ ) } - const accessTokenStatus = resolveIndustryAccessTokenStatus(source) + const shopColumns: TableColumnsType = [ + { + title: '启用', + width: 76, + fixed: 'left', + render: (_, row, index) => ( + updateShop(index, { enabled })} + /> + ), + }, + { + title: '店铺', + minWidth: 260, + render: (_, row, index) => ( +
+ updateShopSellerId(index, event.target.value)} + /> + updateShop(index, { shopName: event.target.value })} + /> + updateShop(index, { customShopName: event.target.value })} + /> +
+ ), + }, + { + title: 'Token 状态', + width: 150, + render: (_, row) => { + const status = resolveIndustryAccessTokenStatus(row) + return ( +
+ {status.label} + {row.accessTokenMasked ? {row.accessTokenMasked} : null} +
+ ) + }, + }, + { + title: '过期时间', + minWidth: 220, + render: (_, row) => ( +
+ {row.accessTokenExpiresAt ? formatAdminDateTime(row.accessTokenExpiresAt) : '-'} + {formatIndustryTokenCountdown(row.accessTokenExpiresInSeconds)} +
+ ), + }, + { + title: '授权操作', + width: 420, + render: (_, row, index) => { + const actionKey = buildIndustryShopActionKey(index) + return ( + + + + setAuthorizationCodes((current) => ({ + ...current, + [actionKey]: event.target.value, + })) + } + onPressEnter={() => exchangeAuthorizationCode(row, index)} + /> + + + + + + + + + + ) + }, + }, + ] return (
@@ -1192,45 +1395,37 @@ function KuaishouIndustryPanel({
0 ? `${problemTokenCount} 个需处理` : '全部正常'} />
{config.filePath || '默认配置'} - - - - } - > -
- updateSource({ redirectUri })} - /> - updateSource({ scopes })} - /> - updateSource({ authState })} - /> -
- 授权链接 - -
-
- openId - updateSource({ openId: event.target.value })} - /> -
-
- 已授权 scope - updateSource({ grantedScopes: event.target.value })} - /> -
-
- - setAuthorizationCode(event.target.value)} - onPressEnter={exchangeAuthorizationCode} - /> + + - -
- - -
- {renderSecretInput('accessToken', 'accessToken', source.accessTokenMasked)} - {renderSecretInput('refreshToken', 'refreshToken', source.refreshTokenMasked)} - updateSource({ accessTokenExpiresAt })} - /> - updateSource({ refreshTokenExpiresAt })} - /> - updateSource({ sellerId })} - /> - updateSource({ version })} - /> -
-
+ + } + > + {shops.length === 0 ? ( + + ) : ( + + rowKey={(row, index) => buildIndustryShopRowKey(row, index || 0)} + columns={shopColumns} + dataSource={shops} + pagination={false} + scroll={{ x: 1120 }} + expandable={{ + expandedRowRender: (row, index) => { + const authorizationUrl = buildIndustryAuthorizationUrl(source, row) + return ( +
+ updateShop(index, { shopId })} + /> + updateShop(index, { shopName })} + /> + updateShop(index, { customShopName })} + /> + updateShop(index, { authState })} + /> +
+ 授权链接 + +
+ updateShop(index, { openId })} + /> + updateShop(index, { grantedScopes })} + /> +
+ accessToken + updateShop(index, { accessToken: event.target.value })} + /> +
+
+ refreshToken + updateShop(index, { refreshToken: event.target.value })} + /> +
+ updateShop(index, { accessTokenExpiresAt })} + /> + updateShop(index, { refreshTokenExpiresAt })} + /> +
+ 最近错误 + updateShop(index, { lastRefreshError: event.target.value })} + /> +
+
+ ) + }, + }} + /> + )}
) @@ -1815,7 +2038,9 @@ function CloudtentaclesPlatformPanel({ ) } -function resolveIndustryAccessTokenStatus(source: AdminKuaishouIndustrySourceConfig) { +function resolveIndustryAccessTokenStatus( + source: Pick, +) { switch (source.accessTokenStatus) { case 'valid': return { label: '有效', color: 'green' } @@ -1852,9 +2077,12 @@ function formatIndustryTokenCountdown(value: number | null) { return `${minutes} 分钟后过期` } -function buildIndustryAuthorizationUrl(source: AdminKuaishouIndustrySourceConfig) { +function buildIndustryAuthorizationUrl( + source: AdminKuaishouIndustrySourceConfig, + shop?: AdminKuaishouIndustryShopConfig, +) { if (!source.authBaseUrl || !source.appKey || !source.redirectUri || !source.scopes) { - return source.authorizationUrl || '' + return shop?.authorizationUrl || source.authorizationUrl || '' } try { @@ -1863,16 +2091,55 @@ function buildIndustryAuthorizationUrl(source: AdminKuaishouIndustrySourceConfig url.searchParams.set('redirect_uri', source.redirectUri) url.searchParams.set('scope', normalizeIndustryScopeText(source.scopes)) url.searchParams.set('response_type', 'code') - if (source.authState) { - url.searchParams.set('state', source.authState) + const state = String(shop?.authState || source.authState || shop?.sellerId || '').trim() + if (state) { + url.searchParams.set('state', state) } return url.toString() } catch { - return source.authorizationUrl || '' + return shop?.authorizationUrl || source.authorizationUrl || '' } } +function createEmptyIndustryShopConfig(): AdminKuaishouIndustryShopConfig { + return { + enabled: true, + sellerId: '', + shopId: '', + shopName: '', + customShopName: '', + authState: '', + authorizationUrl: '', + accessToken: '', + accessTokenMasked: '', + hasAccessToken: false, + refreshToken: '', + refreshTokenMasked: '', + hasRefreshToken: false, + accessTokenExpiresAt: '', + refreshTokenExpiresAt: '', + accessTokenStatus: 'missing', + accessTokenExpiresInSeconds: null, + openId: '', + grantedScopes: '', + lastRefreshedAt: '', + lastRefreshError: '', + } +} + +function buildIndustryShopRowKey(shop: AdminKuaishouIndustryShopConfig, index: number) { + return shop.sellerId || shop.shopId || `shop-${index}` +} + +function buildIndustryShopActionKey(index: number) { + return `shop-${index}` +} + +function resolveIndustryShopDisplayName(shop: AdminKuaishouIndustryShopConfig) { + return shop.customShopName || shop.shopName || shop.sellerId || '未命名店铺' +} + function normalizeIndustryScopeText(value: string) { return value .split(/[,\s]+/) diff --git a/apps/frontend/src/services/admin/platform-config/kuaishou-industry.ts b/apps/frontend/src/services/admin/platform-config/kuaishou-industry.ts index 52480ba4..99610806 100644 --- a/apps/frontend/src/services/admin/platform-config/kuaishou-industry.ts +++ b/apps/frontend/src/services/admin/platform-config/kuaishou-industry.ts @@ -22,10 +22,12 @@ export function saveAdminKuaishouIndustrySourceConfig( ) } -export function refreshAdminKuaishouIndustryAccessToken() { +export function refreshAdminKuaishouIndustryAccessToken( + payload: { sellerId?: string } = {}, +) { return apiPost( '/api/v1/admin/platform-config/kuaishou-industry-source/refresh-token', - {}, + payload, ) } diff --git a/apps/frontend/src/types/admin/index.ts b/apps/frontend/src/types/admin/index.ts index 7197f8f3..fa5be748 100644 --- a/apps/frontend/src/types/admin/index.ts +++ b/apps/frontend/src/types/admin/index.ts @@ -51,6 +51,7 @@ export type { AdminKuaishouEticketDetailResult, AdminKuaishouEticketConsumeResult, AdminKuaishouIndustryAccessTokenStatus, + AdminKuaishouIndustryShopConfig, AdminKuaishouIndustrySourceConfig, AdminKuaishouIndustryConfigResponse, AdminKuaishouIndustryRefreshTokenResponse, diff --git a/apps/frontend/src/types/admin/platform-config/index.ts b/apps/frontend/src/types/admin/platform-config/index.ts index 50d6daa6..85b2af30 100644 --- a/apps/frontend/src/types/admin/platform-config/index.ts +++ b/apps/frontend/src/types/admin/platform-config/index.ts @@ -32,6 +32,7 @@ export type { export type { AdminKuaishouIndustryAccessTokenStatus, + AdminKuaishouIndustryShopConfig, AdminKuaishouIndustrySourceConfig, AdminKuaishouIndustryConfigResponse, AdminKuaishouIndustryRefreshTokenResponse, diff --git a/apps/frontend/src/types/admin/platform-config/kuaishou-industry.ts b/apps/frontend/src/types/admin/platform-config/kuaishou-industry.ts index 7f7d8500..0b25f8c3 100644 --- a/apps/frontend/src/types/admin/platform-config/kuaishou-industry.ts +++ b/apps/frontend/src/types/admin/platform-config/kuaishou-industry.ts @@ -5,6 +5,30 @@ export type AdminKuaishouIndustryAccessTokenStatus = | 'expiring' | 'valid' +export interface AdminKuaishouIndustryShopConfig { + enabled: boolean + sellerId: string + shopId: string + shopName: string + customShopName: string + authState: string + authorizationUrl: string + accessToken: string + accessTokenMasked: string + hasAccessToken: boolean + refreshToken: string + refreshTokenMasked: string + hasRefreshToken: boolean + accessTokenExpiresAt: string + refreshTokenExpiresAt: string + accessTokenStatus: AdminKuaishouIndustryAccessTokenStatus + accessTokenExpiresInSeconds: number | null + openId: string + grantedScopes: string + lastRefreshedAt: string + lastRefreshError: string +} + export interface AdminKuaishouIndustrySourceConfig { enabled: boolean baseUrl: string @@ -41,6 +65,7 @@ export interface AdminKuaishouIndustrySourceConfig { shopId: string shopName: string version: string + shops: AdminKuaishouIndustryShopConfig[] lastRefreshedAt: string lastRefreshError: string } @@ -52,10 +77,14 @@ export interface AdminKuaishouIndustryConfigResponse { export interface AdminKuaishouIndustryRefreshTokenResponse extends AdminKuaishouIndustryConfigResponse { refreshed: boolean + shop?: AdminKuaishouIndustryShopConfig } export interface AdminKuaishouIndustryAuthorizationCodePayload { code: string + sellerId?: string + shopName?: string + customShopName?: string } export type AdminKuaishouIndustryExchangeCodeResponse = AdminKuaishouIndustryRefreshTokenResponse