feat(密钥): 用户 API 密钥服务端化——V115 按账号绑定存储 + 后台密钥管理页 + 桌面端全站拦截与配置引导

- 新增 usersecret 模块:外观专利/货源查询密钥从本地 localStorage 迁移至 biz_user_api_secret(AES 加密、按 uid 绑定)
- 后台「密钥管理」页:脱敏展示、立即检测、清空;每日 04:30 分布式锁定时巡检
- 桌面端:密钥设置面板走服务端、未配齐引导 /setup-secrets、代理余量展示
- 删除专利汇令牌全链路与密钥保留时长选择器
This commit is contained in:
2026-09-13 10:03:59 +08:00
parent d1b56918fa
commit 82a782550e
61 changed files with 4108 additions and 655 deletions
+405 -148
View File
@@ -1,195 +1,452 @@
export type ApiSecretModuleKey = 'appearance-patent' | 'appearance-patent-token' | 'similar-asin'
/**
* 用户密钥前端存取:服务端为准 + 本地缓存。
*
* - 服务端(/api/user-secrets)是唯一权威:按登录用户绑定,客户端只缓存元数据与最近输入的值;
* - `getStoredApiSecret` / `getStoredApiSecretSnapshot` 保持同步读语义(提交任务瞬间立即取用),
* 读取顺序:内存 → 本地镜像 → 旧版 v1 记录(`brand:api-secret:{uid}:{moduleKey}`);
* - 保存/清空/检测走异步接口;服务端拉取失败一律软失败(返回 unknown,不阻断使用);
* - 旧版本地密钥在首次加载时自动迁移到服务端(只填空缺、不覆盖)。
*/
import {
checkMyApiSecret,
deleteMyApiSecret,
fetchMyApiSecrets,
fetchProxyBalance,
migrateMyApiSecrets,
putMyApiSecret,
type ApiSecretCheckStatus,
type ApiSecretModuleKey,
type UserApiSecretCheckResult,
type UserApiSecretItem,
} from '../api/types/modules/user-secret.ts'
export type ApiSecretRetention = 'session' | '1d' | '7d' | '30d' | 'forever'
export type { ApiSecretModuleKey, ApiSecretCheckStatus }
type ApiSecretRecord = {
export type { UserApiSecretCheckResult }
/** 门禁状态:complete=可放行 / incomplete=需拦截引导配置 / unknown=未知(软失败,放行)。 */
export type ApiSecretLoadState = 'complete' | 'incomplete' | 'unknown'
export interface ApiSecretSnapshot {
/** 本地明文缓存(来自用户输入或旧记录迁移);服务端不回传明文,换机后可能为空。 */
value: string
retention: ApiSecretRetention
expiresAt: number | null
updatedAt: number
}
export type ApiSecretSnapshot = {
value: string
retention: ApiSecretRetention
expiresAt: number | null
updatedAt: number | null
/** 服务端脱敏值(权威展示,如 sk-a****1234)。 */
masked: string
exists: boolean
checkStatus: ApiSecretCheckStatus
checkCode: string
checkMessage: string
checkLatencyMs: number | null
checkedAt: number | null
updatedAt: number | null
}
const STORAGE_PREFIX = 'brand:api-secret'
const COMMON_SECRET_KEY = 'common'
const MODULE_SECRET_KEYS: ApiSecretModuleKey[] = ['appearance-patent', 'appearance-patent-token', 'similar-asin']
interface ApiSecretCacheEntry extends ApiSecretSnapshot {
schema: 2
}
function currentUserStorageId() {
const MIRROR_PREFIX = 'brand:api-secret-cache'
const LEGACY_PREFIX = 'brand:api-secret'
const LEGACY_MODULE_KEYS = ['appearance-patent', 'appearance-patent-token', 'similar-asin']
const MIGRATED_FLAG_PREFIX = 'brand:api-secret-migrated'
const DEFAULT_REQUIRED_MODULES: ApiSecretModuleKey[] = ['appearance-patent', 'similar-asin']
const FETCH_TIMEOUT_MS = 4000
const memory = new Map<string, ApiSecretCacheEntry>()
const listeners = new Set<() => void>()
export interface ApiSecretModuleInfo {
moduleKey: string
moduleLabel: string
}
const DEFAULT_MODULE_INFOS: ApiSecretModuleInfo[] = [
{ moduleKey: 'appearance-patent', moduleLabel: '外观专利密钥' },
{ moduleKey: 'similar-asin', moduleLabel: '货源查询密钥' },
]
let moduleInfos: ApiSecretModuleInfo[] = [...DEFAULT_MODULE_INFOS]
let requiredModules: string[] = [...DEFAULT_REQUIRED_MODULES]
let bundleLoaded = false
let inflightLoad: Promise<ApiSecretLoadState> | null = null
function currentUid(): string {
if (typeof window === 'undefined') return '0'
return window.localStorage.getItem('uid') || '0'
}
function buildStorageKey(moduleKey: string) {
return `${STORAGE_PREFIX}:${currentUserStorageId()}:${moduleKey}`
function buildMirrorKey(moduleKey: string) {
return `${MIRROR_PREFIX}:${currentUid()}:${moduleKey}`
}
function readStorageRecord(storage: Storage, moduleKey: string): ApiSecretRecord | null {
const raw = storage.getItem(buildStorageKey(moduleKey))
if (!raw) return null
function buildLegacyKey(moduleKey: string) {
return `${LEGACY_PREFIX}:${currentUid()}:${moduleKey}`
}
function emptyEntry(): ApiSecretCacheEntry {
return {
schema: 2,
value: '',
masked: '',
exists: false,
checkStatus: 'unknown',
checkCode: '',
checkMessage: '',
checkLatencyMs: null,
checkedAt: null,
updatedAt: null,
}
}
function normalizeCheckStatus(value: unknown): ApiSecretCheckStatus {
switch (value) {
case 'passed':
case 'failed':
case 'error':
case 'unknown':
return value
default:
return 'unknown'
}
}
function readMirror(moduleKey: string): ApiSecretCacheEntry | null {
if (typeof window === 'undefined') return null
try {
const parsed = JSON.parse(raw) as Partial<ApiSecretRecord>
if (typeof parsed.value !== 'string') return null
const retention = normalizeRetention(parsed.retention)
const expiresAt = typeof parsed.expiresAt === 'number' ? parsed.expiresAt : null
const updatedAt = typeof parsed.updatedAt === 'number' ? parsed.updatedAt : Date.now()
const raw = window.localStorage.getItem(buildMirrorKey(moduleKey))
if (!raw) return null
const parsed = JSON.parse(raw) as Partial<ApiSecretCacheEntry>
if (parsed.schema !== 2) return null
return {
value: parsed.value,
retention,
expiresAt,
updatedAt,
schema: 2,
value: typeof parsed.value === 'string' ? parsed.value : '',
masked: typeof parsed.masked === 'string' ? parsed.masked : '',
exists: Boolean(parsed.exists),
checkStatus: normalizeCheckStatus(parsed.checkStatus),
checkCode: typeof parsed.checkCode === 'string' ? parsed.checkCode : '',
checkMessage: typeof parsed.checkMessage === 'string' ? parsed.checkMessage : '',
checkLatencyMs: typeof parsed.checkLatencyMs === 'number' ? parsed.checkLatencyMs : null,
checkedAt: typeof parsed.checkedAt === 'number' ? parsed.checkedAt : null,
updatedAt: typeof parsed.updatedAt === 'number' ? parsed.updatedAt : null,
}
} catch {
return null
}
}
function normalizeRetention(value: unknown): ApiSecretRetention {
switch (value) {
case '1d':
case '7d':
case '30d':
case 'forever':
case 'session':
return value
default:
return 'session'
}
}
function retentionToExpiresAt(retention: ApiSecretRetention, now: number) {
switch (retention) {
case '1d':
return now + 24 * 60 * 60 * 1000
case '7d':
return now + 7 * 24 * 60 * 60 * 1000
case '30d':
return now + 30 * 24 * 60 * 60 * 1000
case 'forever':
return null
case 'session':
default:
return null
}
}
function isExpired(record: ApiSecretRecord) {
return record.expiresAt != null && record.expiresAt <= Date.now()
}
function clearStorageRecord(storage: Storage, moduleKey: string) {
storage.removeItem(buildStorageKey(moduleKey))
}
function getLiveRecordFromKey(moduleKey: string): ApiSecretRecord | null {
if (typeof window === 'undefined') return null
const sessionRecord = readStorageRecord(window.sessionStorage, moduleKey)
if (sessionRecord) {
return sessionRecord
}
const localRecord = readStorageRecord(window.localStorage, moduleKey)
if (!localRecord) return null
if (isExpired(localRecord)) {
clearStorageRecord(window.localStorage, moduleKey)
return null
}
return localRecord
}
function clearLegacyStoredApiSecrets() {
function writeMirror(moduleKey: string, entry: ApiSecretCacheEntry) {
if (typeof window === 'undefined') return
for (const moduleKey of MODULE_SECRET_KEYS) {
clearStorageRecord(window.sessionStorage, moduleKey)
clearStorageRecord(window.localStorage, moduleKey)
try {
window.localStorage.setItem(buildMirrorKey(moduleKey), JSON.stringify(entry))
} catch {
/* 本地镜像写入失败不影响主流程 */
}
}
function migrateCommonRecord(record: ApiSecretRecord) {
function removeMirror(moduleKey: string) {
if (typeof window === 'undefined') return
const storage = record.retention === 'session' ? window.sessionStorage : window.localStorage
for (const moduleKey of ['appearance-patent', 'similar-asin'] satisfies ApiSecretModuleKey[]) {
if (!getLiveRecordFromKey(moduleKey)) {
storage.setItem(buildStorageKey(moduleKey), JSON.stringify(record))
try {
window.localStorage.removeItem(buildMirrorKey(moduleKey))
} catch {
/* 忽略 */
}
}
/** 读取旧版 v1 明文(sessionStorage 优先,兼容历史 session 保留策略)。 */
function readLegacyPlainValue(moduleKey: string): string {
if (typeof window === 'undefined') return ''
for (const storage of [window.sessionStorage, window.localStorage]) {
try {
const raw = storage.getItem(buildLegacyKey(moduleKey))
if (!raw) continue
const parsed = JSON.parse(raw) as { value?: unknown }
if (typeof parsed?.value === 'string' && parsed.value.trim()) {
return parsed.value.trim()
}
} catch {
/* 忽略损坏的历史记录 */
}
}
clearStorageRecord(window.sessionStorage, COMMON_SECRET_KEY)
clearStorageRecord(window.localStorage, COMMON_SECRET_KEY)
return ''
}
function getLiveRecord(moduleKey: ApiSecretModuleKey): ApiSecretRecord | null {
const moduleRecord = getLiveRecordFromKey(moduleKey)
if (moduleRecord) return moduleRecord
const commonRecord = getLiveRecordFromKey(COMMON_SECRET_KEY)
if (!commonRecord) return null
migrateCommonRecord(commonRecord)
return getLiveRecordFromKey(moduleKey)
function clearLegacyKeys() {
if (typeof window === 'undefined') return
for (const moduleKey of [...LEGACY_MODULE_KEYS, 'common']) {
for (const storage of [window.sessionStorage, window.localStorage]) {
try {
storage.removeItem(buildLegacyKey(moduleKey))
} catch {
/* 忽略 */
}
}
}
}
export function getStoredApiSecret(moduleKey: ApiSecretModuleKey) {
return getLiveRecord(moduleKey)?.value || ''
function migratedFlagKey() {
return `${MIGRATED_FLAG_PREFIX}:${currentUid()}:v1`
}
function readEntry(moduleKey: string): ApiSecretCacheEntry {
const cached = memory.get(moduleKey)
if (cached) return cached
const fromMirror = readMirror(moduleKey)
if (fromMirror) {
memory.set(moduleKey, fromMirror)
return fromMirror
}
const legacyValue = readLegacyPlainValue(moduleKey)
if (legacyValue) {
const entry = emptyEntry()
entry.value = legacyValue
memory.set(moduleKey, entry)
return entry
}
return emptyEntry()
}
function updateEntry(moduleKey: string, patch: Partial<ApiSecretCacheEntry>) {
const next: ApiSecretCacheEntry = { ...readEntry(moduleKey), ...patch, schema: 2 }
memory.set(moduleKey, next)
writeMirror(moduleKey, next)
notify()
}
function notify() {
for (const listener of listeners) {
try {
listener()
} catch {
/* 单个监听器异常不影响其他订阅者 */
}
}
}
function toMillis(value: string | null): number | null {
if (!value) return null
const parsed = Date.parse(value)
return Number.isNaN(parsed) ? null : parsed
}
function applyServerItem(moduleKey: string, item: UserApiSecretItem) {
const current = readEntry(moduleKey)
const next: ApiSecretCacheEntry = {
schema: 2,
// 明文仅来自本地输入/迁移,服务端不下发
value: current.value,
masked: item.masked || '',
exists: Boolean(item.exists),
checkStatus: normalizeCheckStatus(item.checkStatus),
checkCode: item.checkCode || '',
checkMessage: item.checkMessage || '',
checkLatencyMs: typeof item.checkLatencyMs === 'number' ? item.checkLatencyMs : null,
checkedAt: toMillis(item.checkedAt),
updatedAt: toMillis(item.updatedAt),
}
memory.set(moduleKey, next)
writeMirror(moduleKey, next)
if (item.moduleLabel && !moduleInfos.some((info) => info.moduleKey === moduleKey)) {
moduleInfos = [...moduleInfos, { moduleKey, moduleLabel: item.moduleLabel }]
}
}
/** 界面展示用的模块清单(默认两个,服务端返回新模块时自动追加)。 */
export function listApiSecretModules(): ApiSecretModuleInfo[] {
return [...moduleInfos]
}
/** 完整性:全部必填模块均检测通过;error(无法判定)视为放行,防上游抖动锁死客户端。 */
export function computeApiSecretsComplete(): boolean {
for (const moduleKey of requiredModules) {
const entry = memory.get(moduleKey) || readEntry(moduleKey)
if (!entry.exists) return false
if (entry.checkStatus !== 'passed' && entry.checkStatus !== 'error') return false
}
return requiredModules.length > 0
}
/** 门禁状态:从未成功拉到服务端数据 → unknown(软失败放行)。 */
export function getApiSecretGateState(): ApiSecretLoadState {
if (!bundleLoaded) return 'unknown'
return computeApiSecretsComplete() ? 'complete' : 'incomplete'
}
/** 同步读明文(提交任务瞬间调用);未加载时降级读本地镜像/旧记录。 */
export function getStoredApiSecret(moduleKey: ApiSecretModuleKey): string {
return readEntry(moduleKey).value
}
/** 同步读完整快照(含服务端脱敏值/检测状态)。 */
export function getStoredApiSecretSnapshot(moduleKey: ApiSecretModuleKey): ApiSecretSnapshot {
const record = getLiveRecord(moduleKey)
if (!record) {
return {
value: '',
retention: 'session',
expiresAt: null,
updatedAt: null,
exists: false,
const entry = readEntry(moduleKey)
const { schema: _schema, ...snapshot } = entry
return snapshot
}
export function subscribeApiSecrets(listener: () => void): () => void {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
}
/** 加载服务端密钥包;force=true 绕过 in-flight 复用但串行等待(登录后重拉用)。 */
export function loadApiSecrets(options: { force?: boolean } = {}): Promise<ApiSecretLoadState> {
if (inflightLoad && !options.force) {
return inflightLoad
}
const previous = inflightLoad
const task = (async (): Promise<ApiSecretLoadState> => {
if (previous && options.force) {
// 等上一轮结束,避免并发覆盖
await previous.catch(() => undefined)
}
return doLoad()
})().finally(() => {
if (inflightLoad === task) {
inflightLoad = null
}
})
inflightLoad = task
return task
}
/** 复用同一次 in-flight 加载(路由守卫调用)。 */
export function ensureApiSecretsLoaded(): Promise<ApiSecretLoadState> {
if (bundleLoaded || inflightLoad) {
return inflightLoad || Promise.resolve(getApiSecretGateState())
}
return {
value: record.value,
retention: record.retention,
expiresAt: record.expiresAt,
updatedAt: record.updatedAt,
exists: true,
return loadApiSecrets()
}
async function doLoad(): Promise<ApiSecretLoadState> {
try {
const bundle = await fetchMyApiSecrets()
if (Array.isArray(bundle?.requiredModules) && bundle.requiredModules.length) {
requiredModules = bundle.requiredModules
}
for (const item of bundle?.items || []) {
if (item?.moduleKey) {
applyServerItem(item.moduleKey, item)
}
}
bundleLoaded = true
notify()
const migrated = await tryMigrateLocalSecrets(bundle?.items || [])
if (migrated > 0) {
const refreshed = await fetchMyApiSecrets()
for (const item of refreshed?.items || []) {
if (item?.moduleKey) {
applyServerItem(item.moduleKey, item)
}
}
notify()
}
return computeApiSecretsComplete() ? 'complete' : 'incomplete'
} catch (error) {
console.warn('[api-secret] 服务端密钥拉取失败,本次按未知处理(不阻断使用):', error)
return 'unknown'
}
}
export function saveStoredApiSecret(
/** 旧本地密钥一次性迁移:仅当服务端空缺且有本地明文时上报;失败静默,下次加载重试。 */
async function tryMigrateLocalSecrets(serverItems: UserApiSecretItem[]): Promise<number> {
if (typeof window === 'undefined') return 0
try {
if (window.localStorage.getItem(migratedFlagKey()) === '1') {
clearLegacyKeys()
return 0
}
const serverHas = new Map(serverItems.map((item) => [item.moduleKey, Boolean(item.exists)]))
const pending: Array<{ moduleKey: string; value: string }> = []
for (const moduleKey of DEFAULT_REQUIRED_MODULES) {
if (serverHas.get(moduleKey)) continue
const legacyValue = readLegacyPlainValue(moduleKey)
if (legacyValue) {
pending.push({ moduleKey, value: legacyValue })
}
}
if (!pending.length) {
window.localStorage.setItem(migratedFlagKey(), '1')
clearLegacyKeys()
return 0
}
const result = await migrateMyApiSecrets(pending)
const migrated = Number(result?.migrated || 0)
// 迁移成功后本地保留明文(供提交任务直接使用),只清理旧记录与标记
for (const item of pending) {
const entry = readEntry(item.moduleKey)
updateEntry(item.moduleKey, { value: entry.value || item.value })
}
window.localStorage.setItem(migratedFlagKey(), '1')
clearLegacyKeys()
console.log(`[api-secret] 本地密钥迁移完成,写入 ${migrated}`)
return migrated
} catch (error) {
console.warn('[api-secret] 本地密钥迁移失败,将在下次加载时重试:', error)
return 0
}
}
/** 保存密钥到服务端;成功后刷新本地缓存(失败不写本地,避免本地有值服务端没有的假象)。 */
export async function saveApiSecret(moduleKey: ApiSecretModuleKey, value: string): Promise<ApiSecretSnapshot> {
const trimmed = value.trim()
if (!trimmed) {
throw new Error('密钥不能为空')
}
const item = await putMyApiSecret(moduleKey, trimmed)
applyServerItem(moduleKey, item)
updateEntry(moduleKey, { value: trimmed, exists: true })
bundleLoaded = true
return getStoredApiSecretSnapshot(moduleKey)
}
export async function clearApiSecret(moduleKey: ApiSecretModuleKey): Promise<void> {
await deleteMyApiSecret(moduleKey)
removeMirror(moduleKey)
memory.delete(moduleKey)
notify()
}
/** 检测连通性:value 非空时只检测输入值(不落库);为空时检测服务端已存值并刷新本地状态。 */
export async function checkApiSecret(
moduleKey: ApiSecretModuleKey,
value: string,
retention: ApiSecretRetention,
) {
if (typeof window === 'undefined') return
const trimmedValue = value.trim()
clearStoredApiSecret(moduleKey)
if (!trimmedValue) return
const now = Date.now()
const record: ApiSecretRecord = {
value: trimmedValue,
retention,
expiresAt: retentionToExpiresAt(retention, now),
updatedAt: now,
value?: string,
): Promise<UserApiSecretCheckResult> {
const override = value?.trim()
const result = await checkMyApiSecret(moduleKey, override || undefined)
if (!override) {
updateEntry(moduleKey, {
checkStatus: normalizeCheckStatus(result.checkStatus),
checkCode: result.checkCode || '',
checkMessage: result.checkMessage || '',
checkLatencyMs: typeof result.checkLatencyMs === 'number' ? result.checkLatencyMs : null,
checkedAt: toMillis(result.checkedAt) ?? Date.now(),
})
}
const storage = retention === 'session' ? window.sessionStorage : window.localStorage
storage.setItem(buildStorageKey(moduleKey), JSON.stringify(record))
return result
}
export function clearStoredApiSecret(moduleKey: ApiSecretModuleKey) {
if (typeof window === 'undefined') return
clearStorageRecord(window.sessionStorage, moduleKey)
clearStorageRecord(window.localStorage, moduleKey)
/** 服务端余量查询(jikip 套餐 IP 余量 / 账户余额)。 */
export async function fetchProxyBalanceSafely() {
return fetchProxyBalance()
}
export function clearAllStoredApiSecrets() {
if (typeof window === 'undefined') return
clearStorageRecord(window.sessionStorage, COMMON_SECRET_KEY)
clearStorageRecord(window.localStorage, COMMON_SECRET_KEY)
clearLegacyStoredApiSecrets()
/** 登出清理:内存与镜像一并清空(旧 v1 记录保留,供下次登录迁移)。 */
export function clearApiSecretCache(): void {
for (const moduleKey of [...DEFAULT_REQUIRED_MODULES]) {
removeMirror(moduleKey)
}
memory.clear()
bundleLoaded = false
inflightLoad = null
}
/** 供测试与调试:模块级状态快照。 */
export function __debugApiSecretState() {
return {
requiredModules: [...requiredModules],
bundleLoaded,
entries: Object.fromEntries(memory.entries()),
}
}