支持快手行业电子凭证多店铺授权

This commit is contained in:
yml2213
2026-07-08 14:13:15 +08:00
parent 102330d7af
commit ec23eb6af6
24 changed files with 1110 additions and 219 deletions
@@ -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<Record<string, string>>({})
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<AdminKuaishouIndustryShopConfig>) {
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<AdminKuaishouIndustryShopConfig> = [
{
title: '启用',
width: 76,
fixed: 'left',
render: (_, row, index) => (
<Switch
checked={row.enabled !== false}
onChange={(enabled) => updateShop(index, { enabled })}
/>
),
},
{
title: '店铺',
minWidth: 260,
render: (_, row, index) => (
<div className="cell-stack">
<Input
value={row.sellerId}
placeholder="sellerId"
onChange={(event) => updateShopSellerId(index, event.target.value)}
/>
<Input
value={row.shopName}
placeholder="官方店铺名称"
onChange={(event) => updateShop(index, { shopName: event.target.value })}
/>
<Input
value={row.customShopName}
placeholder="自定义店铺名"
onChange={(event) => updateShop(index, { customShopName: event.target.value })}
/>
</div>
),
},
{
title: 'Token 状态',
width: 150,
render: (_, row) => {
const status = resolveIndustryAccessTokenStatus(row)
return (
<div className="status-stack">
<Tag color={status.color}>{status.label}</Tag>
{row.accessTokenMasked ? <Typography.Text type="secondary">{row.accessTokenMasked}</Typography.Text> : null}
</div>
)
},
},
{
title: '过期时间',
minWidth: 220,
render: (_, row) => (
<div className="cell-stack">
<span>{row.accessTokenExpiresAt ? formatAdminDateTime(row.accessTokenExpiresAt) : '-'}</span>
<span className="muted">{formatIndustryTokenCountdown(row.accessTokenExpiresInSeconds)}</span>
</div>
),
},
{
title: '授权操作',
width: 420,
render: (_, row, index) => {
const actionKey = buildIndustryShopActionKey(index)
return (
<Space direction="vertical" className="full-width" size={8}>
<Space.Compact className="full-width">
<Input
value={authorizationCodes[actionKey] || ''}
placeholder="授权 code"
onChange={(event) =>
setAuthorizationCodes((current) => ({
...current,
[actionKey]: event.target.value,
}))
}
onPressEnter={() => exchangeAuthorizationCode(row, index)}
/>
<Button
type="primary"
loading={actionLoading === `exchange-${index}`}
disabled={saving || Boolean(actionLoading && actionLoading !== `exchange-${index}`)}
onClick={() => exchangeAuthorizationCode(row, index)}
>
</Button>
</Space.Compact>
<Space wrap>
<Button size="small" icon={<CopyOutlined />} onClick={() => copyAuthorizationUrl(row)}>
</Button>
<Button size="small" icon={<LinkOutlined />} onClick={() => openAuthorizationUrl(row)}>
</Button>
<Button
size="small"
icon={<ReloadOutlined />}
loading={actionLoading === `refresh-${index}`}
disabled={saving || Boolean(actionLoading && actionLoading !== `refresh-${index}`)}
onClick={() => refreshToken(row, index)}
>
</Button>
<Button
danger
size="small"
icon={<DeleteOutlined />}
disabled={saving || Boolean(actionLoading)}
onClick={() => deleteShop(index)}
>
</Button>
</Space>
</Space>
)
},
},
]
return (
<section className="platform-panel-stack">
@@ -1192,45 +1395,37 @@ function KuaishouIndustryPanel({
<div className="metric-grid four">
<MetricCard
label="回调状态"
label="接入状态"
value={source.enabled ? '已启用' : '停用'}
detail={source.enabled ? '配置启用' : '配置停用'}
/>
<MetricCard
label="accessToken"
value={accessTokenStatus.label}
detail={formatIndustryTokenCountdown(source.accessTokenExpiresInSeconds)}
label="店铺授权"
value={`${enabledShopCount}/${shops.length}`}
detail="启用店铺 / 全部店铺"
/>
<MetricCard
label="过期时间"
value={source.accessTokenExpiresAt ? formatAdminDateTime(source.accessTokenExpiresAt) : '-'}
detail={source.lastRefreshedAt ? `刷新:${formatAdminDateTime(source.lastRefreshedAt)}` : '尚未刷新'}
label="有效 token"
value={String(validTokenCount)}
detail={problemTokenCount > 0 ? `${problemTokenCount} 个需处理` : '全部正常'}
/>
<MetricCard
label="refreshToken"
value={source.hasRefreshToken ? '已配置' : '未配置'}
detail={source.refreshTokenExpiresAt ? `到期:${formatAdminDateTime(source.refreshTokenExpiresAt)}` : source.refreshTokenMasked || '-'}
label="授权范围"
value={source.scopes || '-'}
detail={source.redirectUri || '-'}
/>
</div>
<Card
title="快手行业电子凭证"
title="应用参数"
extra={
<Space wrap>
<Typography.Text type="secondary">{config.filePath || '默认配置'}</Typography.Text>
<Button
icon={<ReloadOutlined />}
loading={refreshing}
disabled={saving || exchanging}
onClick={refreshToken}
>
token
</Button>
<Button
type="primary"
icon={<SaveOutlined />}
loading={saving}
disabled={refreshing || exchanging}
disabled={Boolean(actionLoading)}
onClick={saveConfig}
>
@@ -1238,12 +1433,6 @@ function KuaishouIndustryPanel({
</Space>
}
>
<Space wrap size={[8, 8]} className="platform-section-gap">
<Tag color={accessTokenStatus.color}>{accessTokenStatus.label}</Tag>
{source.accessTokenMasked ? <Tag>{source.accessTokenMasked}</Tag> : null}
{source.refreshTokenMasked ? <Tag>{source.refreshTokenMasked}</Tag> : null}
</Space>
<div className="platform-form-grid">
<FieldSwitch
label="启用配置"
@@ -1265,105 +1454,139 @@ function KuaishouIndustryPanel({
value={source.appKey}
onChange={(appKey) => updateSource({ appKey })}
/>
<LabeledInput
label="回调地址"
value={source.redirectUri}
onChange={(redirectUri) => updateSource({ redirectUri })}
/>
<LabeledInput
label="scope"
value={source.scopes}
onChange={(scopes) => updateSource({ scopes })}
/>
<LabeledInput
label="全局 state 兜底"
value={source.authState}
onChange={(authState) => updateSource({ authState })}
/>
<LabeledInput
label="版本"
value={source.version}
onChange={(version) => updateSource({ version })}
/>
{renderSecretInput('appSecret', 'appSecret', source.appSecretMasked)}
{renderSecretInput('signSecret', 'signSecret', source.signSecretMasked)}
{renderSecretInput('messageSecret', 'messageSecret', source.messageSecretMasked)}
</div>
</Card>
<Card
size="small"
title="授权"
className="platform-section-gap"
extra={
<Space wrap>
<Button icon={<CopyOutlined />} disabled={!authorizationUrl} onClick={copyAuthorizationUrl}>
</Button>
<Button icon={<LinkOutlined />} disabled={!authorizationUrl} onClick={openAuthorizationUrl}>
</Button>
</Space>
}
>
<div className="platform-form-grid">
<LabeledInput
label="回调地址"
value={source.redirectUri}
onChange={(redirectUri) => updateSource({ redirectUri })}
/>
<LabeledInput
label="scope"
value={source.scopes}
onChange={(scopes) => updateSource({ scopes })}
/>
<LabeledInput
label="state"
value={source.authState}
onChange={(authState) => updateSource({ authState })}
/>
<div>
<Typography.Text type="secondary"></Typography.Text>
<Input readOnly value={authorizationUrl} />
</div>
<div>
<Typography.Text type="secondary">openId</Typography.Text>
<Input
value={source.openId}
onChange={(event) => updateSource({ openId: event.target.value })}
/>
</div>
<div>
<Typography.Text type="secondary"> scope</Typography.Text>
<Input
value={source.grantedScopes}
onChange={(event) => updateSource({ grantedScopes: event.target.value })}
/>
</div>
</div>
<Space.Compact className="full-width platform-section-gap">
<Input
value={authorizationCode}
placeholder="code"
onChange={(event) => setAuthorizationCode(event.target.value)}
onPressEnter={exchangeAuthorizationCode}
/>
<Card
title="店铺授权"
extra={
<Space wrap>
<Button icon={<PlusOutlined />} onClick={addShop}>
</Button>
<Button
type="primary"
loading={exchanging}
disabled={saving || refreshing}
onClick={exchangeAuthorizationCode}
icon={<SaveOutlined />}
loading={saving}
disabled={Boolean(actionLoading)}
onClick={saveConfig}
>
token
</Button>
</Space.Compact>
</Card>
<Card size="small" title="Token" className="platform-section-gap">
<div className="platform-form-grid">
{renderSecretInput('accessToken', 'accessToken', source.accessTokenMasked)}
{renderSecretInput('refreshToken', 'refreshToken', source.refreshTokenMasked)}
<LabeledInput
label="accessToken 过期时间"
value={source.accessTokenExpiresAt}
onChange={(accessTokenExpiresAt) => updateSource({ accessTokenExpiresAt })}
/>
<LabeledInput
label="refreshToken 过期时间"
value={source.refreshTokenExpiresAt}
onChange={(refreshTokenExpiresAt) => updateSource({ refreshTokenExpiresAt })}
/>
<LabeledInput
label="sellerId"
value={source.sellerId}
onChange={(sellerId) => updateSource({ sellerId })}
/>
<LabeledInput
label="版本"
value={source.version}
onChange={(version) => updateSource({ version })}
/>
</div>
</Card>
</Space>
}
>
{shops.length === 0 ? (
<Empty description="暂无店铺授权" />
) : (
<Table<AdminKuaishouIndustryShopConfig>
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 (
<div className="platform-form-grid">
<LabeledInput
label="shopId"
value={row.shopId}
onChange={(shopId) => updateShop(index, { shopId })}
/>
<LabeledInput
label="官方店铺名称"
value={row.shopName}
onChange={(shopName) => updateShop(index, { shopName })}
/>
<LabeledInput
label="自定义店铺名"
value={row.customShopName}
onChange={(customShopName) => updateShop(index, { customShopName })}
/>
<LabeledInput
label="state"
value={row.authState}
onChange={(authState) => updateShop(index, { authState })}
/>
<div>
<Typography.Text type="secondary"></Typography.Text>
<Input readOnly value={authorizationUrl} />
</div>
<LabeledInput
label="openId"
value={row.openId}
onChange={(openId) => updateShop(index, { openId })}
/>
<LabeledInput
label="已授权 scope"
value={row.grantedScopes}
onChange={(grantedScopes) => updateShop(index, { grantedScopes })}
/>
<div>
<Typography.Text type="secondary">accessToken</Typography.Text>
<Input.Password
value={row.accessToken}
placeholder={row.accessTokenMasked || '留空保持原值'}
onChange={(event) => updateShop(index, { accessToken: event.target.value })}
/>
</div>
<div>
<Typography.Text type="secondary">refreshToken</Typography.Text>
<Input.Password
value={row.refreshToken}
placeholder={row.refreshTokenMasked || '留空保持原值'}
onChange={(event) => updateShop(index, { refreshToken: event.target.value })}
/>
</div>
<LabeledInput
label="accessToken 过期时间"
value={row.accessTokenExpiresAt}
onChange={(accessTokenExpiresAt) => updateShop(index, { accessTokenExpiresAt })}
/>
<LabeledInput
label="refreshToken 过期时间"
value={row.refreshTokenExpiresAt}
onChange={(refreshTokenExpiresAt) => updateShop(index, { refreshTokenExpiresAt })}
/>
<div>
<Typography.Text type="secondary"></Typography.Text>
<Input.TextArea
autoSize={{ minRows: 1, maxRows: 4 }}
value={row.lastRefreshError}
onChange={(event) => updateShop(index, { lastRefreshError: event.target.value })}
/>
</div>
</div>
)
},
}}
/>
)}
</Card>
</section>
)
@@ -1815,7 +2038,9 @@ function CloudtentaclesPlatformPanel({
)
}
function resolveIndustryAccessTokenStatus(source: AdminKuaishouIndustrySourceConfig) {
function resolveIndustryAccessTokenStatus(
source: Pick<AdminKuaishouIndustrySourceConfig, 'accessTokenStatus'>,
) {
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]+/)