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
+8
View File
@@ -201,6 +201,14 @@ export const API_ENDPOINTS = {
taskDelete: '/api/appearance-patent/tasks/{taskId}',
resultDownload: '/api/appearance-patent/results/{resultId}/download',
},
userSecret: {
bundle: '/api/user-secrets',
save: '/api/user-secrets/{moduleKey}',
clear: '/api/user-secrets/{moduleKey}',
check: '/api/user-secrets/{moduleKey}/check',
migrate: '/api/user-secrets/migrate',
proxyBalance: '/api/user-secrets/proxy-balance',
},
collectData: {
parse: '/api/collect-data/parse',
countryPreference: '/api/collect-data/country-preference',
@@ -13,6 +13,7 @@ export * from "./types/modules/collect-data.ts";
export * from "./types/modules/image-video.ts";
export * from "./types/modules/brand.ts";
export * from "./types/modules/permission.ts";
export * from "./types/modules/user-secret.ts";
export * from "./types/modules/digital-human.ts";
export * from "./progress-light.ts";
export * from "./upload.ts";
@@ -49,7 +49,6 @@ export interface AppearancePatentParseVo {
export interface AppearancePatentParsedPayloadDto {
aiPrompt?: string;
apiKey?: string;
patentToken?: string;
sourceFiles?: UploadedFileRef[];
headers?: string[];
items?: AppearancePatentParsedRow[];
@@ -173,17 +172,16 @@ async function postTaskProgressBatch<T>(
return requestPromise;
}
export function parseAppearancePatent(files: UploadedFileRef[], aiPrompt: string, apiKey?: string, patentToken?: string) {
export function parseAppearancePatent(files: UploadedFileRef[], aiPrompt: string, apiKey?: string) {
return unwrapJavaResponse(
post<
JavaApiResponse<AppearancePatentParseVo>,
{ user_id: number; files: UploadedFileRef[]; ai_prompt: string; api_key?: string; patent_token?: string }
{ user_id: number; files: UploadedFileRef[]; ai_prompt: string; api_key?: string }
>(buildJavaUrl(API_ENDPOINTS.appearancePatent.parse), {
user_id: getCurrentUserId(),
files,
ai_prompt: aiPrompt,
api_key: apiKey,
patent_token: patentToken,
}),
);
}
@@ -0,0 +1,97 @@
import { del, get, post, put, type JavaApiResponse, unwrapJavaResponse } from '../../http.ts'
import { buildJavaUrl } from '../../url.ts'
import { API_ENDPOINTS } from '../../endpoints.ts'
/** 用户密钥模块 key:与服务端 UserSecretModule 枚举一致。 */
export type ApiSecretModuleKey = 'appearance-patent' | 'similar-asin'
/** 连通性状态:unknown=未检测出结果(拦截)/ passed=通过 / failed=密钥无效(拦截)/ error=无法判定(放行)。 */
export type ApiSecretCheckStatus = 'unknown' | 'passed' | 'failed' | 'error'
export interface UserApiSecretItem {
moduleKey: string
moduleLabel: string
masked: string
exists: boolean
checkStatus: string
checkCode: string
checkMessage: string
checkLatencyMs: number | null
checkedAt: string | null
updatedAt: string | null
}
export interface UserApiSecretBundle {
items: UserApiSecretItem[]
requiredModules: string[]
complete: boolean
}
export interface UserApiSecretCheckResult {
moduleKey: string
checkStatus: string
checkCode: string
checkMessage: string
checkLatencyMs: number | null
checkedAt: string | null
viaProxy: boolean
}
export interface UserApiSecretBalance {
available: boolean
surplus: string | null
balance: string | null
message: string | null
}
export interface UserApiSecretMigrateItem {
moduleKey: string
value: string
}
export function fetchMyApiSecrets() {
return unwrapJavaResponse(
get<JavaApiResponse<UserApiSecretBundle>>(buildJavaUrl(API_ENDPOINTS.userSecret.bundle)),
)
}
export function putMyApiSecret(moduleKey: string, value: string) {
return unwrapJavaResponse(
put<JavaApiResponse<UserApiSecretItem>, { value: string }>(
buildJavaUrl(API_ENDPOINTS.userSecret.save.replace('{moduleKey}', encodeURIComponent(moduleKey))),
{ value },
),
)
}
export function deleteMyApiSecret(moduleKey: string) {
return unwrapJavaResponse(
del<JavaApiResponse<null>>(buildJavaUrl(API_ENDPOINTS.userSecret.clear.replace('{moduleKey}', encodeURIComponent(moduleKey)))),
)
}
/** value 非空时检测输入值(不落库);为空时检测服务端已存密钥并把结果落库。 */
export function checkMyApiSecret(moduleKey: string, value?: string) {
return unwrapJavaResponse(
post<JavaApiResponse<UserApiSecretCheckResult>, { value?: string }>(
buildJavaUrl(API_ENDPOINTS.userSecret.check.replace('{moduleKey}', encodeURIComponent(moduleKey))),
{ value },
),
)
}
/** 上报本地已保存的密钥,服务端只写空缺模块、不覆盖已有值。 */
export function migrateMyApiSecrets(items: UserApiSecretMigrateItem[]) {
return unwrapJavaResponse(
post<JavaApiResponse<{ migrated: number }>, { items: UserApiSecretMigrateItem[] }>(
buildJavaUrl(API_ENDPOINTS.userSecret.migrate),
{ items },
),
)
}
export function fetchProxyBalance() {
return unwrapJavaResponse(
get<JavaApiResponse<UserApiSecretBalance>>(buildJavaUrl(API_ENDPOINTS.userSecret.proxyBalance)),
)
}
@@ -0,0 +1,621 @@
<template>
<div class="secret-settings-body">
<section v-for="module in modules" :key="module.moduleKey" class="secret-card">
<div class="secret-card-head">
<div>
<div class="secret-card-title">{{ module.moduleLabel }}</div>
<div class="secret-card-desc">{{ descriptionOf(module.moduleKey) }}</div>
</div>
<button
v-if="snapshotOf(module.moduleKey).exists"
type="button"
class="link-danger"
:disabled="busy"
@click="clearModule(module.moduleKey as ApiSecretModuleKey)"
>
清空
</button>
</div>
<input
v-model="moduleStates[module.moduleKey].input"
class="secret-input"
type="password"
:placeholder="placeholderOf(module.moduleKey)"
autocomplete="off"
spellcheck="false"
:disabled="busy"
/>
<div class="secret-actions">
<button
type="button"
class="check-btn"
:disabled="busy || moduleStates[module.moduleKey].checking"
@click="runCheck(module.moduleKey as ApiSecretModuleKey)"
>
{{ moduleStates[module.moduleKey].checking ? '检测中...' : (moduleStates[module.moduleKey].input.trim() ? '检测输入值' : '检测已存密钥') }}
</button>
<span class="check-result" :class="resultClassOf(module.moduleKey)">
{{ statusTextOf(module.moduleKey) }}
</span>
</div>
</section>
<section v-if="showProxy" class="secret-card">
<div class="secret-card-head">
<div>
<div class="secret-card-title">代理设置</div>
<div class="secret-card-desc">供客户端任务连接代理服务</div>
</div>
</div>
<div class="proxy-field">
<label class="field-label" for="proxy-url">代理地址</label>
<input
id="proxy-url"
v-model="proxyUrl"
class="secret-input"
type="text"
placeholder="请输入代理地址"
autocomplete="off"
spellcheck="false"
:disabled="!proxyReady || busy"
@input="proxyDirty = true"
/>
</div>
<div class="retention-block">
<div class="field-label">代理模式</div>
<div class="retention-options">
<label
v-for="option in proxyModeOptions"
:key="option.value"
class="retention-option"
:class="{ 'retention-option--disabled': !proxyReady || busy }"
>
<input
v-model="proxyMode"
type="radio"
:value="option.value"
:disabled="!proxyReady || busy"
@change="proxyDirty = true"
/>
<span>{{ option.label }}</span>
</label>
</div>
</div>
<div class="secret-meta">
<span v-if="proxyLoading">正在读取代理配置...</span>
<span v-else-if="proxyLoadFailed">代理配置读取失败请关闭弹窗后重试</span>
<span v-else-if="!proxySupported">代理设置仅在桌面客户端中可用</span>
<span v-else-if="balanceLoading">正在查询代理余量...</span>
<span v-else-if="balance">{{ balanceText }}</span>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import {
checkApiSecret,
clearApiSecret,
fetchProxyBalanceSafely,
getStoredApiSecretSnapshot,
listApiSecretModules,
loadApiSecrets,
saveApiSecret,
subscribeApiSecrets,
type ApiSecretCheckStatus,
type ApiSecretModuleKey,
type ApiSecretSnapshot,
type UserApiSecretCheckResult,
} from '@/shared/utils/api-secret-store'
import { getPywebviewApi, type ProxyMode, type DesktopConfigUpdate } from '@/shared/bridges/pywebview'
const props = withDefaults(
defineProps<{
/** 是否展示代理设置区(默认展示)。 */
showProxy?: boolean
}>(),
{
showProxy: true,
},
)
type ModuleState = {
input: string
checking: boolean
result: UserApiSecretCheckResult | null
error: string
}
const modules = ref(listApiSecretModules())
const moduleStates = reactive<Record<string, ModuleState>>({})
// 首次渲染即需可读:按模块清单预初始化状态(后续 refreshSnapshots 兜底补齐)
for (const module of modules.value) {
moduleStates[module.moduleKey] = { input: '', checking: false, result: null, error: '' }
}
const snapshots = ref<Record<string, ApiSecretSnapshot>>({})
const busy = ref(false)
const proxyUrl = ref('')
const proxyMode = ref<ProxyMode>(1)
const proxyLoading = ref(false)
const proxyLoadFailed = ref(false)
const proxyReady = ref(false)
const proxySupported = ref(false)
const proxyDirty = ref(false)
const balance = ref<{ surplus: string | null; balance: string | null } | null>(null)
const balanceLoading = ref(false)
const proxyModeOptions: Array<{ value: ProxyMode; label: string }> = [
{ value: 1, label: '白名单' },
{ value: 2, label: '账号密码' },
]
const MODULE_DESCRIPTIONS: Record<string, string> = {
'appearance-patent': '仅用于外观专利检测,保存在服务端并绑定当前账号。',
'similar-asin': '仅用于货源查询,保存在服务端并绑定当前账号。',
}
const balanceText = computed(() => {
if (!balance.value) return ''
const surplus = balance.value.surplus ?? '-'
const amount = balance.value.balance ?? '-'
return `套餐IP余量:${surplus} · 账户余额:${amount}`
})
function ensureModuleState(moduleKey: string) {
if (!moduleStates[moduleKey]) {
moduleStates[moduleKey] = { input: '', checking: false, result: null, error: '' }
}
return moduleStates[moduleKey]
}
function refreshSnapshots() {
modules.value = listApiSecretModules()
const next: Record<string, ApiSecretSnapshot> = {}
for (const module of modules.value) {
next[module.moduleKey] = getStoredApiSecretSnapshot(module.moduleKey as ApiSecretModuleKey)
ensureModuleState(module.moduleKey)
}
snapshots.value = next
}
function snapshotOf(moduleKey: string): ApiSecretSnapshot {
return snapshots.value[moduleKey] || getStoredApiSecretSnapshot(moduleKey as ApiSecretModuleKey)
}
function descriptionOf(moduleKey: string) {
return MODULE_DESCRIPTIONS[moduleKey] || '保存在服务端并绑定当前账号。'
}
function placeholderOf(moduleKey: string) {
const snapshot = snapshotOf(moduleKey)
if (snapshot.exists) {
return `已保存(${snapshot.masked || '****'}),输入新值可覆盖`
}
return '请输入 LLM 接口密钥'
}
const CHECK_STATUS_TEXT: Record<ApiSecretCheckStatus, string> = {
passed: '检测通过',
failed: '检测失败',
error: '暂时无法判定',
unknown: '已保存,未检测',
}
function formatTime(millis: number | null) {
if (!millis) return ''
const date = new Date(millis)
const hour = String(date.getHours()).padStart(2, '0')
const minute = String(date.getMinutes()).padStart(2, '0')
return `${hour}:${minute}`
}
function statusTextOf(moduleKey: string) {
const state = ensureModuleState(moduleKey)
if (state.checking) return '正在检测...'
if (state.result) {
const latency = state.result.checkLatencyMs != null ? `${state.result.checkLatencyMs}ms` : ''
const suffix = state.result.viaProxy === true ? ' · 经代理' : ''
if (state.result.checkStatus === 'passed') return `检测通过${latency}${suffix}`
return `${state.result.checkMessage || '检测失败'}${suffix}`
}
if (state.error) return state.error
const snapshot = snapshotOf(moduleKey)
if (!snapshot.exists) return '当前未保存,请填写后保存'
const status = CHECK_STATUS_TEXT[snapshot.checkStatus] || '已保存'
const time = formatTime(snapshot.checkedAt)
const message = snapshot.checkStatus === 'unknown' ? '' : `${snapshot.checkMessage || ''}`
return `${status}${message}${time ? ` · ${time}` : ''}`
}
function resultClassOf(moduleKey: string) {
const state = ensureModuleState(moduleKey)
const status = state.result?.checkStatus || snapshotOf(moduleKey).checkStatus
return {
'check-result--ok': status === 'passed',
'check-result--fail': status === 'failed',
'check-result--warn': status === 'error',
}
}
async function runCheck(moduleKey: ApiSecretModuleKey) {
const state = ensureModuleState(moduleKey)
if (state.checking) return
state.checking = true
state.error = ''
state.result = null
const inputValue = state.input.trim()
try {
const result = await checkApiSecret(moduleKey, inputValue || undefined)
state.result = result
if (result.checkStatus === 'failed') {
ElMessage.warning(result.checkMessage || '密钥无效')
} else if (result.checkStatus === 'error') {
ElMessage.warning(result.checkMessage || '暂时无法判定密钥有效性')
} else {
ElMessage.success(inputValue ? '输入值检测通过(未保存)' : '密钥检测通过')
}
} catch (error) {
state.error = error instanceof Error ? error.message : '检测失败'
ElMessage.error(state.error)
} finally {
state.checking = false
refreshSnapshots()
}
}
async function clearModule(moduleKey: ApiSecretModuleKey) {
if (busy.value) return
busy.value = true
try {
await clearApiSecret(moduleKey)
const state = ensureModuleState(moduleKey)
state.input = ''
state.result = null
state.error = ''
refreshSnapshots()
ElMessage.success('已清空密钥')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '清空失败')
} finally {
busy.value = false
}
}
function proxyUserId() {
if (typeof window === 'undefined') return '0'
return window.localStorage.getItem('uid') || '0'
}
/** 代理地址按登录用户隔离:proxy_users[uid],各自计费各自复用;
* 未登录(uid=0)回退全局 proxy_url(兼容旧版本已保存的配置)。 */
function readUserProxy(config: Record<string, unknown> | null | undefined) {
const uid = proxyUserId()
if (uid !== '0') {
const users = (config?.proxy_users ?? {}) as Record<string, unknown>
const own = (users[uid] ?? {}) as Record<string, unknown>
return {
url: typeof own.proxy_url === 'string' ? own.proxy_url : '',
mode: Number(own.proxy_mode) === 2 ? 2 : 1,
}
}
return {
url: typeof config?.proxy_url === 'string' ? config.proxy_url : '',
mode: Number(config?.proxy_mode) === 2 ? 2 : 1,
}
}
function userProxyPatch(nextProxyUrl: string, nextProxyMode: ProxyMode) {
const uid = proxyUserId()
if (uid === '0') {
return { proxy_url: nextProxyUrl, proxy_mode: nextProxyMode }
}
const users: Record<string, unknown> = {}
users[uid] = { proxy_url: nextProxyUrl, proxy_mode: nextProxyMode }
return { proxy_users: users }
}
async function loadProxyConfig() {
proxyLoading.value = true
proxyLoadFailed.value = false
proxyReady.value = false
proxyDirty.value = false
const api = getPywebviewApi()
proxySupported.value = Boolean(api?.read_config && api.save_config)
if (!api?.read_config || !api.save_config) {
proxyUrl.value = ''
proxyMode.value = 1
proxyLoading.value = false
return
}
try {
const config = await api.read_config()
const own = readUserProxy(config as Record<string, unknown>)
proxyUrl.value = own.url
proxyMode.value = (own.mode === 2 ? 2 : 1) as ProxyMode
proxyReady.value = true
} catch (error) {
proxyLoadFailed.value = true
ElMessage.error(error instanceof Error ? error.message : '代理配置读取失败')
} finally {
proxyLoading.value = false
}
}
async function loadBalance() {
balanceLoading.value = true
try {
const result = await fetchProxyBalanceSafely()
if (result?.available) {
balance.value = { surplus: result.surplus, balance: result.balance }
} else {
balance.value = null
}
} catch (error) {
console.warn('[api-secret] 代理余量查询失败:', error)
balance.value = null
} finally {
balanceLoading.value = false
}
}
/**
* 保存:逐模块保存非空输入(空输入保留服务端原值),代理仅在改动时保存;
* 保存后对有输入值的模块自动检测一次,让用户立即知道密钥是否可用。
*/
async function saveAll(options: { requireAll?: boolean } = {}): Promise<boolean> {
if (busy.value || proxyLoading.value) return false
const pendingModules = modules.value.filter((module) => ensureModuleState(module.moduleKey).input.trim())
if (options.requireAll) {
const missing = modules.value.filter(
(module) => !ensureModuleState(module.moduleKey).input.trim() && !snapshotOf(module.moduleKey).exists,
)
if (missing.length) {
ElMessage.warning(`请填写:${missing.map((module) => module.moduleLabel).join('、')}`)
return false
}
}
busy.value = true
let secretsSaved = false
try {
for (const module of pendingModules) {
const moduleKey = module.moduleKey as ApiSecretModuleKey
await saveApiSecret(moduleKey, ensureModuleState(module.moduleKey).input.trim())
ensureModuleState(module.moduleKey).input = ''
}
secretsSaved = true
refreshSnapshots()
if (props.showProxy && proxyReady.value && proxyDirty.value) {
const api = getPywebviewApi()
if (!api?.save_config) throw new Error('当前客户端未提供代理配置保存能力')
const nextProxyUrl = proxyUrl.value.trim()
await api.save_config(userProxyPatch(nextProxyUrl, proxyMode.value) as DesktopConfigUpdate)
proxyUrl.value = nextProxyUrl
proxyDirty.value = false
}
// 保存成功的模块立即自动检测,结果直接反映在卡片状态行
for (const module of pendingModules) {
const moduleKey = module.moduleKey as ApiSecretModuleKey
try {
ensureModuleState(module.moduleKey).result = await checkApiSecret(moduleKey)
} catch (error) {
console.warn('[api-secret] 保存后自动检测失败:', error)
}
}
refreshSnapshots()
return true
} catch (error) {
const message = error instanceof Error ? error.message : '设置保存失败'
ElMessage.error(secretsSaved ? `密钥已保存,但后续步骤失败:${message}` : message)
return false
} finally {
busy.value = false
}
}
/** 重新从服务端拉取(设置页/弹窗打开时调用)。 */
async function reload() {
await loadApiSecrets({ force: true })
refreshSnapshots()
}
let unsubscribe: (() => void) | null = null
onMounted(() => {
refreshSnapshots()
unsubscribe = subscribeApiSecrets(refreshSnapshots)
void reload()
if (props.showProxy) {
void loadProxyConfig()
void loadBalance()
}
})
onUnmounted(() => {
if (unsubscribe) unsubscribe()
})
defineExpose({ saveAll, reload })
</script>
<style scoped>
.secret-settings-body {
display: flex;
flex-direction: column;
gap: 14px;
max-height: 62vh;
overflow-y: auto;
padding-right: 4px;
}
.secret-card {
padding: 18px;
border: 1px solid #313b46;
border-radius: 10px;
background: #20252b;
}
.secret-card-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.secret-card-title {
color: #eef4fb;
font-size: 15px;
font-weight: 700;
}
.secret-card-desc {
margin-top: 4px;
color: #909ba8;
font-size: 12px;
line-height: 1.5;
}
.secret-input {
width: 100%;
height: 42px;
padding: 0 12px;
box-sizing: border-box;
border: 1px solid #3b4652;
border-radius: 10px;
background: #1b2026;
color: #dce6f0;
font-size: 13px;
outline: none;
}
.secret-input:focus {
border-color: #5b96d6;
}
.secret-input:disabled {
color: #707b86;
cursor: not-allowed;
opacity: .72;
}
.secret-actions {
display: flex;
align-items: center;
gap: 10px;
margin-top: 10px;
}
.check-btn {
height: 32px;
padding: 0 14px;
border: 1px solid #3c4a58;
border-radius: 8px;
background: #262e37;
color: #dbe5f0;
font-size: 12px;
font-weight: 700;
cursor: pointer;
}
.check-btn:hover:not(:disabled) {
border-color: #4c647d;
}
.check-btn:disabled {
cursor: not-allowed;
opacity: .6;
}
.check-result {
color: #7f8a96;
font-size: 12px;
line-height: 1.5;
}
.check-result--ok {
color: #7fd6a4;
}
.check-result--fail {
color: #ff9b9b;
}
.check-result--warn {
color: #f0c674;
}
.proxy-field .field-label {
display: block;
}
.retention-block {
margin-top: 12px;
}
.field-label {
margin-bottom: 8px;
color: #a3afbb;
font-size: 12px;
}
.retention-options {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.retention-option {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-radius: 999px;
background: #1b2026;
color: #dce4ec;
font-size: 12px;
cursor: pointer;
}
.retention-option input {
margin: 0;
}
.retention-option--disabled {
cursor: not-allowed;
opacity: .6;
}
.secret-meta {
margin-top: 10px;
color: #7f8a96;
font-size: 12px;
}
.link-danger {
border: none;
background: transparent;
color: #ff9b9b;
font-size: 12px;
cursor: pointer;
padding: 0;
}
.link-danger:disabled {
cursor: not-allowed;
opacity: .55;
}
</style>
+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()),
}
}