feat(密钥): 用户 API 密钥服务端化——V115 按账号绑定存储 + 后台密钥管理页 + 桌面端全站拦截与配置引导
- 新增 usersecret 模块:外观专利/货源查询密钥从本地 localStorage 迁移至 biz_user_api_secret(AES 加密、按 uid 绑定) - 后台「密钥管理」页:脱敏展示、立即检测、清空;每日 04:30 分布式锁定时巡检 - 桌面端:密钥设置面板走服务端、未配齐引导 /setup-secrets、代理余量展示 - 删除专利汇令牌全链路与密钥保留时长选择器
This commit is contained in:
@@ -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()),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user