feat(密钥): 用户 API 密钥服务端化——V115 按账号绑定存储 + 后台密钥管理页 + 桌面端全站拦截与配置引导
- 新增 usersecret 模块:外观专利/货源查询密钥从本地 localStorage 迁移至 biz_user_api_secret(AES 加密、按 uid 绑定) - 后台「密钥管理」页:脱敏展示、立即检测、清空;每日 04:30 分布式锁定时巡检 - 桌面端:密钥设置面板走服务端、未配齐引导 /setup-secrets、代理余量展示 - 删除专利汇令牌全链路与密钥保留时长选择器
This commit is contained in:
@@ -7,18 +7,32 @@ import '@/styles/main.css'
|
||||
import App from '@/App.vue'
|
||||
import router from '@/router'
|
||||
import { ensureAuth } from '@/shared/auth/ensure-auth'
|
||||
import { ensureApiSecretsLoaded, loadApiSecrets } from '@/shared/utils/api-secret-store'
|
||||
|
||||
/**
|
||||
* 数富AI 前端统一入口(SPA,URL 无 .html 后缀)
|
||||
*
|
||||
* 原 MPA 的 22 个 html 入口 + 22 个 *-main.ts 已合并:
|
||||
* 页面路由见 src/router/index.ts;登录态由路由守卫统一引导。
|
||||
* 页面路由见 src/router/index.ts;登录态由路由守卫统一引导;
|
||||
* 密钥门禁:服务端密钥未配置完整时全站拦截到 /setup-secrets(拉取失败软失败放行)。
|
||||
*/
|
||||
|
||||
// 后台预热密钥包,减少首次进入守卫时的等待
|
||||
void loadApiSecrets()
|
||||
|
||||
router.beforeEach(async (to) => {
|
||||
if (to.name === 'login') return true
|
||||
const ok = await ensureAuth()
|
||||
return ok ? true : { name: 'login' }
|
||||
if (!ok) return { name: 'login' }
|
||||
if (to.name === 'setup-secrets') return true
|
||||
// 密钥门禁:仅当服务端明确返回"未配置完整"时拦截;
|
||||
// unknown(拉取失败/网络异常)一律放行,避免服务端抖动把全体用户锁死。
|
||||
const secretState = await ensureApiSecretsLoaded()
|
||||
if (secretState === 'incomplete') {
|
||||
const query = to.fullPath && to.fullPath !== '/' ? { redirect: to.fullPath } : {}
|
||||
return { name: 'setup-secrets', query }
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
router.beforeEach((to) => {
|
||||
|
||||
@@ -17,117 +17,11 @@
|
||||
<template #header>
|
||||
<div class="dialog-header">
|
||||
<div class="dialog-title">密钥设置</div>
|
||||
<div class="dialog-subtitle">密钥按当前登录用户保存在本机,代理设置保存在当前客户端。</div>
|
||||
<div class="dialog-subtitle">密钥保存在服务端并绑定当前账号,换设备登录后自动同步;代理设置保存在当前客户端。</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="secret-settings-body">
|
||||
<section v-for="config in secretConfigs" :key="config.key" class="secret-card">
|
||||
<div class="secret-card-head">
|
||||
<div>
|
||||
<div class="secret-card-title">{{ config.title }}</div>
|
||||
<div class="secret-card-desc">{{ config.description }}</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="secretStates[config.key].exists"
|
||||
type="button"
|
||||
class="link-danger"
|
||||
:disabled="saving"
|
||||
@click="clearSecret(config.key)"
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
v-model="secretStates[config.key].value"
|
||||
class="secret-input"
|
||||
type="password"
|
||||
:placeholder="config.placeholder"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
:disabled="saving"
|
||||
/>
|
||||
|
||||
<div class="retention-block">
|
||||
<div class="retention-label">保留时长</div>
|
||||
<div class="retention-options">
|
||||
<label
|
||||
v-for="option in retentionOptions"
|
||||
:key="option.value"
|
||||
class="retention-option"
|
||||
:class="{ 'retention-option--disabled': saving }"
|
||||
>
|
||||
<input
|
||||
v-model="secretStates[config.key].retention"
|
||||
type="radio"
|
||||
:value="option.value"
|
||||
:disabled="saving"
|
||||
/>
|
||||
<span>{{ option.label }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="secret-meta">
|
||||
<span v-if="secretStates[config.key].exists">
|
||||
{{ formatRetentionText(secretStates[config.key].retention, secretStates[config.key].expiresAt) }}
|
||||
</span>
|
||||
<span v-else>当前未保存</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section 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="retention-label" for="proxy-url">代理地址</label>
|
||||
<input
|
||||
id="proxy-url"
|
||||
v-model="proxyUrl"
|
||||
class="secret-input"
|
||||
type="text"
|
||||
placeholder="请输入代理地址"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
:disabled="!proxyReady || saving"
|
||||
@input="proxyDirty = true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="retention-block">
|
||||
<div class="retention-label">代理模式</div>
|
||||
<div class="retention-options">
|
||||
<label
|
||||
v-for="option in proxyModeOptions"
|
||||
:key="option.value"
|
||||
class="retention-option"
|
||||
:class="{ 'retention-option--disabled': !proxyReady || saving }"
|
||||
>
|
||||
<input
|
||||
v-model="proxyMode"
|
||||
type="radio"
|
||||
:value="option.value"
|
||||
:disabled="!proxyReady || saving"
|
||||
@change="proxyDirty = true"
|
||||
/>
|
||||
<span>{{ option.label }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="proxyLoading || proxyLoadFailed || !proxySupported" class="secret-meta">
|
||||
<span v-if="proxyLoading">正在读取代理配置...</span>
|
||||
<span v-else-if="proxyLoadFailed">代理配置读取失败,请关闭弹窗后重试</span>
|
||||
<span v-else>代理设置仅在桌面客户端中可用</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<ApiSecretSettingsPanel ref="panelRef" />
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
@@ -142,8 +36,8 @@
|
||||
<button
|
||||
type="button"
|
||||
class="footer-btn footer-btn-primary"
|
||||
:disabled="saving || proxyLoading"
|
||||
@click="saveAll"
|
||||
:disabled="saving"
|
||||
@click="save"
|
||||
>
|
||||
{{ saving ? '保存中...' : '保存' }}
|
||||
</button>
|
||||
@@ -155,26 +49,9 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
clearStoredApiSecret,
|
||||
getStoredApiSecretSnapshot,
|
||||
saveStoredApiSecret,
|
||||
type ApiSecretModuleKey,
|
||||
type ApiSecretRetention,
|
||||
} from '@/shared/utils/api-secret-store'
|
||||
import { getPywebviewApi, type ProxyMode, type DesktopConfigUpdate } from '@/shared/bridges/pywebview'
|
||||
import ApiSecretSettingsPanel from '@/shared/components/ApiSecretSettingsPanel.vue'
|
||||
|
||||
type SecretState = {
|
||||
value: string
|
||||
retention: ApiSecretRetention
|
||||
expiresAt: number | null
|
||||
exists: boolean
|
||||
}
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
|
||||
const props = withDefaults(
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
/** topbar=顶栏纯文字样式(对齐主程序);默认=现有胶囊按钮 */
|
||||
variant?: 'topbar'
|
||||
@@ -183,213 +60,31 @@ const props = withDefaults(
|
||||
variant: undefined,
|
||||
},
|
||||
)
|
||||
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 dialogVisible = ref(false)
|
||||
const saving = ref(false)
|
||||
let proxyLoadRequestId = 0
|
||||
const panelRef = ref<InstanceType<typeof ApiSecretSettingsPanel> | null>(null)
|
||||
|
||||
const proxyModeOptions: Array<{ value: ProxyMode; label: string }> = [
|
||||
{ value: 1, label: '白名单' },
|
||||
{ value: 2, label: '账号密码' },
|
||||
]
|
||||
|
||||
const retentionOptions: Array<{ value: ApiSecretRetention; label: string }> = [
|
||||
{ value: 'session', label: '本次打开有效' },
|
||||
{ value: '1d', label: '1 天' },
|
||||
{ value: '7d', label: '7 天' },
|
||||
{ value: '30d', label: '30 天' },
|
||||
{ value: 'forever', label: '长期保留' },
|
||||
]
|
||||
|
||||
const secretConfigs: Array<{ key: ApiSecretModuleKey; title: string; description: string; placeholder: string }> = [
|
||||
{
|
||||
key: 'appearance-patent',
|
||||
title: '外观专利密钥',
|
||||
description: '仅用于外观专利检测。',
|
||||
placeholder: '请输入 LLM 接口密钥',
|
||||
},
|
||||
{
|
||||
key: 'appearance-patent-token',
|
||||
title: '专利汇令牌',
|
||||
description: '仅用于外观专利检测,非必填。',
|
||||
placeholder: '请输入专利汇令牌,可留空',
|
||||
},
|
||||
{
|
||||
key: 'similar-asin',
|
||||
title: '货源查询密钥',
|
||||
description: '仅用于货源查询。',
|
||||
placeholder: '请输入 LLM 接口密钥',
|
||||
},
|
||||
]
|
||||
|
||||
const secretStates = ref<Record<ApiSecretModuleKey, SecretState>>({
|
||||
'appearance-patent': emptySecretState(),
|
||||
'appearance-patent-token': emptySecretState(),
|
||||
'similar-asin': emptySecretState(),
|
||||
// 每次打开弹窗重新拉取服务端密钥状态(组件可能被 el-dialog 复用不会重新挂载)
|
||||
watch(dialogVisible, (visible) => {
|
||||
if (visible) void panelRef.value?.reload()
|
||||
})
|
||||
|
||||
function loadStates() {
|
||||
const nextStates = { ...secretStates.value }
|
||||
for (const config of secretConfigs) {
|
||||
const snapshot = getStoredApiSecretSnapshot(config.key)
|
||||
nextStates[config.key] = {
|
||||
value: snapshot.value,
|
||||
retention: snapshot.retention,
|
||||
expiresAt: snapshot.expiresAt,
|
||||
exists: snapshot.exists,
|
||||
}
|
||||
}
|
||||
secretStates.value = nextStates
|
||||
}
|
||||
|
||||
function emptySecretState(): SecretState {
|
||||
return {
|
||||
value: '',
|
||||
retention: 'session',
|
||||
expiresAt: null,
|
||||
exists: false,
|
||||
}
|
||||
}
|
||||
|
||||
function formatRetentionText(retention: ApiSecretRetention, expiresAt: number | null) {
|
||||
if (retention === 'session') return '关闭软件后自动清空'
|
||||
if (retention === 'forever') return '长期保留,直到手动清空'
|
||||
if (!expiresAt) return '已保存'
|
||||
const date = new Date(expiresAt)
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const hour = String(date.getHours()).padStart(2, '0')
|
||||
const minute = String(date.getMinutes()).padStart(2, '0')
|
||||
return `有效期至 ${year}-${month}-${day} ${hour}:${minute}`
|
||||
}
|
||||
|
||||
function clearSecret(moduleKey: ApiSecretModuleKey) {
|
||||
clearStoredApiSecret(moduleKey)
|
||||
loadStates()
|
||||
ElMessage.success('已清空密钥')
|
||||
}
|
||||
|
||||
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() {
|
||||
const requestId = ++proxyLoadRequestId
|
||||
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()
|
||||
if (requestId !== proxyLoadRequestId || !dialogVisible.value) return
|
||||
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) {
|
||||
if (requestId !== proxyLoadRequestId || !dialogVisible.value) return
|
||||
proxyLoadFailed.value = true
|
||||
ElMessage.error(error instanceof Error ? error.message : '代理配置读取失败')
|
||||
} finally {
|
||||
if (requestId === proxyLoadRequestId) proxyLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAll() {
|
||||
if (saving.value || proxyLoading.value) return
|
||||
async function save() {
|
||||
if (saving.value) return
|
||||
const panel = panelRef.value
|
||||
if (!panel) return
|
||||
saving.value = true
|
||||
const secretSnapshot = secretConfigs.map((config) => ({
|
||||
key: config.key,
|
||||
value: secretStates.value[config.key].value,
|
||||
retention: secretStates.value[config.key].retention,
|
||||
}))
|
||||
const shouldSaveProxy = proxyReady.value && proxyDirty.value
|
||||
const nextProxyUrl = proxyUrl.value.trim()
|
||||
const nextProxyMode = proxyMode.value
|
||||
let secretsSaved = false
|
||||
|
||||
try {
|
||||
for (const secret of secretSnapshot) {
|
||||
saveStoredApiSecret(secret.key, secret.value, secret.retention)
|
||||
const ok = await panel.saveAll()
|
||||
if (ok) {
|
||||
dialogVisible.value = false
|
||||
console.log('[api-secret] 密钥设置已保存')
|
||||
}
|
||||
secretsSaved = true
|
||||
loadStates()
|
||||
|
||||
if (shouldSaveProxy) {
|
||||
const api = getPywebviewApi()
|
||||
if (!api?.save_config) throw new Error('当前客户端未提供代理配置保存能力')
|
||||
// 按登录用户保存:proxy_users[uid](未登录回退全局 proxy_url 字段)
|
||||
await api.save_config(userProxyPatch(nextProxyUrl, nextProxyMode) as DesktopConfigUpdate)
|
||||
proxyUrl.value = nextProxyUrl
|
||||
proxyDirty.value = false
|
||||
}
|
||||
|
||||
dialogVisible.value = false
|
||||
ElMessage.success(shouldSaveProxy ? '密钥和代理设置已保存' : '密钥设置已保存')
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '设置保存失败'
|
||||
ElMessage.error(secretsSaved && shouldSaveProxy ? `密钥已保存,但代理设置保存失败:${message}` : message)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(dialogVisible, (visible) => {
|
||||
if (visible) {
|
||||
loadStates()
|
||||
void loadProxyConfig()
|
||||
} else {
|
||||
proxyLoadRequestId += 1
|
||||
proxyLoading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
loadStates()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -504,127 +199,6 @@ loadStates()
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.proxy-field .retention-label {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.retention-block {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.retention-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;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
@@ -254,10 +254,6 @@ function effectiveLlmApiKey() {
|
||||
return getStoredApiSecret('appearance-patent').trim()
|
||||
}
|
||||
|
||||
function effectivePatentToken() {
|
||||
return getStoredApiSecret('appearance-patent-token').trim()
|
||||
}
|
||||
|
||||
|
||||
function uidForStorage() {
|
||||
return typeof window !== 'undefined' ? window.localStorage.getItem('uid') || '0' : '0'
|
||||
@@ -391,8 +387,8 @@ async function parseFiles() {
|
||||
return
|
||||
}
|
||||
if (!effectiveLlmApiKey()) {
|
||||
ElMessage.warning('请先在左上角设置中填写外观专利密钥')
|
||||
return
|
||||
// 本地无明文不再阻断:密钥已服务端化,任务执行按用户 uid 兜底读取
|
||||
console.log('[appearance-patent] 本地无密钥明文,提交时由服务端按用户密钥兜底')
|
||||
}
|
||||
parsing.value = true
|
||||
try {
|
||||
@@ -401,7 +397,7 @@ async function parseFiles() {
|
||||
originalFilename: f.originalFilename,
|
||||
relativePath: f.relativePath,
|
||||
}))
|
||||
const res = await parseAppearancePatent(files, effectiveAiPrompt(), effectiveLlmApiKey(), effectivePatentToken())
|
||||
const res = await parseAppearancePatent(files, effectiveAiPrompt(), effectiveLlmApiKey())
|
||||
// 本模块按主 ID 分组执行,没有分组就没有可跑的批次,一并拦下
|
||||
const guard = checkParseResult(res, {
|
||||
requireGroups: true,
|
||||
@@ -437,8 +433,8 @@ async function pushToPythonQueue() {
|
||||
return
|
||||
}
|
||||
if (!effectiveLlmApiKey()) {
|
||||
ElMessage.warning('请先在左上角设置中填写外观专利密钥')
|
||||
return
|
||||
// 本地无明文不再阻断:服务端已保存该用户密钥,任务执行时按 uid 兜底读取
|
||||
console.log('[appearance-patent] 本地无密钥明文,启动任务由服务端兜底')
|
||||
}
|
||||
pushing.value = true
|
||||
try {
|
||||
@@ -451,7 +447,6 @@ async function pushToPythonQueue() {
|
||||
taskId,
|
||||
prompt: queueAiPrompt(),
|
||||
api_key: effectiveLlmApiKey(),
|
||||
patent_token: effectivePatentToken(),
|
||||
sourceFileCount: currentParseResult.sourceFileCount || 0,
|
||||
totalRows: currentParseResult.totalRows || 0,
|
||||
acceptedRows: currentParseResult.acceptedRows || 0,
|
||||
|
||||
@@ -455,8 +455,8 @@ async function parseFiles() {
|
||||
return
|
||||
}
|
||||
if (!effectiveLlmApiKey()) {
|
||||
ElMessage.warning('请先在左上角设置中填写货源查询密钥')
|
||||
return
|
||||
// 本地无明文不再阻断:密钥已服务端化,任务执行按用户 uid 兜底读取
|
||||
console.log('[similar-asin] 本地无密钥明文,提交时由服务端按用户密钥兜底')
|
||||
}
|
||||
if (!getRequiredAlipriceCredentials()) return
|
||||
parsing.value = true
|
||||
@@ -502,8 +502,8 @@ async function pushToPythonQueue() {
|
||||
return
|
||||
}
|
||||
if (!effectiveLlmApiKey()) {
|
||||
ElMessage.warning('请先在左上角设置中填写货源查询密钥')
|
||||
return
|
||||
// 本地无明文不再阻断:服务端已保存该用户密钥,任务执行时按 uid 兜底读取
|
||||
console.log('[similar-asin] 本地无密钥明文,启动任务由服务端兜底')
|
||||
}
|
||||
const alipriceCredentials = getRequiredAlipriceCredentials()
|
||||
if (!alipriceCredentials) return
|
||||
|
||||
@@ -99,6 +99,7 @@ import { onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { loginWithDevice } from '@/shared/api/user'
|
||||
import { useVersionUpdate } from '@/shared/composables/useVersionUpdate'
|
||||
import { clearApiSecretCache } from '@/shared/utils/api-secret-store'
|
||||
import UpdateProgressBar from '@/shared/components/UpdateProgressBar.vue'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -414,6 +415,8 @@ onMounted(() => {
|
||||
} catch {
|
||||
/* 忽略 */
|
||||
}
|
||||
// 清空密钥缓存:内存 + 本地镜像(旧 v1 记录保留,供下次登录自动迁移)
|
||||
clearApiSecretCache()
|
||||
// 同步清掉客户端的登录用户标记:Python 端回退到全局/默认代理池
|
||||
try {
|
||||
const bridge = (window as unknown as { pywebview?: { api?: { save_config?: (data: Record<string, unknown>) => Promise<unknown> } } }).pywebview
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
<template>
|
||||
<div class="setup-root">
|
||||
<header class="setup-header">
|
||||
<span class="setup-title">数富AI</span>
|
||||
<div class="setup-header-right">
|
||||
<span class="username-text">{{ username || '未登录' }}</span>
|
||||
<router-link to="/login?logout=1" class="logout-link">退出</router-link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="setup-main">
|
||||
<div class="setup-card">
|
||||
<div class="setup-card-title">完成密钥配置后即可使用</div>
|
||||
<div class="setup-card-desc">
|
||||
密钥保存在服务端并绑定当前账号,换设备登录后自动同步。
|
||||
请填写以下密钥并保存,检测通过后即可进入工具台。
|
||||
</div>
|
||||
|
||||
<ApiSecretSettingsPanel ref="panelRef" />
|
||||
|
||||
<div class="setup-actions">
|
||||
<button type="button" class="setup-submit" :disabled="submitting" @click="submit">
|
||||
{{ submitting ? '校验中...' : '保存并进入' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="setup-hint">{{ hint }}</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import ApiSecretSettingsPanel from '@/shared/components/ApiSecretSettingsPanel.vue'
|
||||
import {
|
||||
checkApiSecret,
|
||||
getStoredApiSecretSnapshot,
|
||||
listApiSecretModules,
|
||||
loadApiSecrets,
|
||||
type ApiSecretModuleKey,
|
||||
} from '@/shared/utils/api-secret-store'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const panelRef = ref<InstanceType<typeof ApiSecretSettingsPanel> | null>(null)
|
||||
const submitting = ref(false)
|
||||
const username = ref('')
|
||||
const hint = ref('保存后系统会自动检测密钥连通性;检测未通过的密钥需要修正后重试。')
|
||||
|
||||
onMounted(() => {
|
||||
try {
|
||||
username.value = window.localStorage.getItem('username') || ''
|
||||
} catch {
|
||||
/* 忽略存储异常 */
|
||||
}
|
||||
})
|
||||
|
||||
/** 对已保存但尚未检测出结果的必填模块补一次检测,避免"已配置却因未检测被拦"。 */
|
||||
async function ensureAllChecked() {
|
||||
for (const module of listApiSecretModules()) {
|
||||
const moduleKey = module.moduleKey as ApiSecretModuleKey
|
||||
const snapshot = getStoredApiSecretSnapshot(moduleKey)
|
||||
if (!snapshot.exists) continue
|
||||
if (snapshot.checkStatus === 'passed' || snapshot.checkStatus === 'error') continue
|
||||
try {
|
||||
await checkApiSecret(moduleKey)
|
||||
} catch (error) {
|
||||
console.warn('[api-secret] 补齐检测失败:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (submitting.value) return
|
||||
const panel = panelRef.value
|
||||
if (!panel) return
|
||||
submitting.value = true
|
||||
try {
|
||||
const saved = await panel.saveAll({ requireAll: true })
|
||||
if (!saved) return
|
||||
|
||||
await ensureAllChecked()
|
||||
const state = await loadApiSecrets({ force: true })
|
||||
if (state === 'incomplete') {
|
||||
const failed = listApiSecretModules()
|
||||
.map((module) => getStoredApiSecretSnapshot(module.moduleKey as ApiSecretModuleKey))
|
||||
.filter((snapshot) => !snapshot.exists || (snapshot.checkStatus !== 'passed' && snapshot.checkStatus !== 'error'))
|
||||
hint.value = `以下密钥尚未通过检测:${failed.map((item) => item.masked || '未配置').join('、')},请点击「检测」确认。`
|
||||
ElMessage.warning('密钥尚未全部检测通过,请逐项检测确认')
|
||||
return
|
||||
}
|
||||
const redirect = typeof route.query.redirect === 'string' && route.query.redirect.startsWith('/')
|
||||
? route.query.redirect
|
||||
: '/home'
|
||||
console.log('[api-secret] 密钥配置完成,进入', redirect)
|
||||
await router.replace(redirect)
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '保存失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.setup-root {
|
||||
min-height: 100vh;
|
||||
background: #12161a;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.setup-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 56px;
|
||||
padding: 0 24px;
|
||||
background: #171b20;
|
||||
border-bottom: 1px solid #262d35;
|
||||
}
|
||||
|
||||
.setup-title {
|
||||
color: #f2f6fa;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.setup-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.username-text {
|
||||
color: #9aa6b3;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.logout-link {
|
||||
color: #8dc4ff;
|
||||
font-size: 13px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.logout-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.setup-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 40px 16px 64px;
|
||||
}
|
||||
|
||||
.setup-card {
|
||||
width: 640px;
|
||||
max-width: 100%;
|
||||
padding: 26px;
|
||||
border: 1px solid #2c3540;
|
||||
border-radius: 16px;
|
||||
background: #171b20;
|
||||
box-shadow: 0 24px 70px rgba(0, 0, 0, .45);
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
.setup-card-title {
|
||||
color: #f5f7fa;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.setup-card-desc {
|
||||
margin: 8px 0 18px;
|
||||
color: #8f9aa7;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.setup-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.setup-submit {
|
||||
min-width: 132px;
|
||||
height: 40px;
|
||||
padding: 0 20px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: #4f8fda;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.setup-submit:hover:not(:disabled) {
|
||||
background: #67a6ed;
|
||||
}
|
||||
|
||||
.setup-submit:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: .58;
|
||||
}
|
||||
|
||||
.setup-hint {
|
||||
margin-top: 12px;
|
||||
color: #7f8a96;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
@@ -19,6 +19,12 @@ const routes = [
|
||||
name: 'home',
|
||||
component: () => import('@/pages/home/DesktopHomePage.vue'),
|
||||
},
|
||||
{
|
||||
// 密钥未配置完整时的强制引导页(全站拦截落点)
|
||||
path: '/setup-secrets',
|
||||
name: 'setup-secrets',
|
||||
component: () => import('@/pages/setup/DesktopSecretSetupPage.vue'),
|
||||
},
|
||||
{
|
||||
path: '/amazon-console',
|
||||
name: 'amazon-console',
|
||||
|
||||
@@ -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>
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ const MODULE_KEYS = [
|
||||
'priceTrack',
|
||||
'similarAsin',
|
||||
'appearancePatent',
|
||||
'userSecret',
|
||||
'collectData',
|
||||
'imageVideo',
|
||||
'brand',
|
||||
@@ -307,6 +308,14 @@ test('test_endpoints_frozen_snapshot', () => {
|
||||
"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",
|
||||
|
||||
@@ -12,7 +12,7 @@ const ALLOWED_SECTIONS = ['url', 'method', 'params', 'data'] as const
|
||||
const apiModules = [
|
||||
'dedupe', 'split', 'convert', 'publish', 'similar-asin', 'appearance-patent',
|
||||
'delete-brand', 'price-track', 'shop-match', 'query-asin', 'withdraw',
|
||||
'collect-data', 'image-video', 'brand', 'permission', 'digital-human',
|
||||
'collect-data', 'image-video', 'brand', 'permission', 'digital-human', 'user-secret',
|
||||
] as const
|
||||
|
||||
export function isApiModule(name: string): boolean {
|
||||
|
||||
@@ -37,7 +37,7 @@ test('test_appearance_patent_parse_url_payload', async (t) => {
|
||||
captured = config
|
||||
return okResponse({ taskId: 1, totalRows: 1, acceptedRows: 1, droppedRows: 0, items: [] })
|
||||
})
|
||||
await parseAppearancePatent([{ fileKey: 'k1' }], '请识别', 'api-1', 'tk-1')
|
||||
await parseAppearancePatent([{ fileKey: 'k1' }], '请识别', 'api-1')
|
||||
assert.equal(captured.url, '/newApi/api/appearance-patent/parse')
|
||||
assert.equal(captured.method, 'POST')
|
||||
assert.deepEqual(captured.data, {
|
||||
@@ -45,7 +45,6 @@ test('test_appearance_patent_parse_url_payload', async (t) => {
|
||||
files: [{ fileKey: 'k1' }],
|
||||
ai_prompt: '请识别',
|
||||
api_key: 'api-1',
|
||||
patent_token: 'tk-1',
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { http } from '../src/shared/api/http.ts'
|
||||
import {
|
||||
clearApiSecretCache,
|
||||
getStoredApiSecret,
|
||||
getStoredApiSecretSnapshot,
|
||||
loadApiSecrets,
|
||||
saveApiSecret,
|
||||
} from '../src/shared/utils/api-secret-store.ts'
|
||||
|
||||
type RequestConfig = { url?: string; method?: string; data?: unknown }
|
||||
type MockTest = Parameters<typeof test>[1] extends (t: infer T) => unknown ? T : never
|
||||
|
||||
function createStorage() {
|
||||
const store = new Map<string, string>()
|
||||
return {
|
||||
getItem: (key: string) => (store.has(key) ? (store.get(key) as string) : null),
|
||||
setItem: (key: string, value: string) => {
|
||||
store.set(key, String(value))
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
store.delete(key)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function setupWindow() {
|
||||
const localStorage = createStorage()
|
||||
const sessionStorage = createStorage()
|
||||
// 密钥按登录用户隔离:uid 必须存在(与真实前端 localStorage.uid 一致)
|
||||
localStorage.setItem('uid', '42')
|
||||
;(globalThis as Record<string, unknown>).window = {
|
||||
localStorage,
|
||||
sessionStorage,
|
||||
location: { origin: 'http://localhost' },
|
||||
}
|
||||
return { localStorage, sessionStorage }
|
||||
}
|
||||
|
||||
function mockRequest(t: MockTest, impl: (config: RequestConfig) => Promise<unknown>) {
|
||||
t.mock.method(http, 'request', impl as never)
|
||||
}
|
||||
|
||||
const okResponse = (data: unknown) => Promise.resolve({ data: { success: true, message: 'ok', data } })
|
||||
|
||||
function serverItem(moduleKey: string, overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
moduleKey,
|
||||
moduleLabel: moduleKey === 'appearance-patent' ? '外观专利密钥' : '货源查询密钥',
|
||||
masked: '',
|
||||
exists: false,
|
||||
checkStatus: 'unknown',
|
||||
checkCode: '',
|
||||
checkMessage: '',
|
||||
checkLatencyMs: null,
|
||||
checkedAt: null,
|
||||
updatedAt: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function bundleResponse(items: unknown[]) {
|
||||
return okResponse({
|
||||
items,
|
||||
requiredModules: ['appearance-patent', 'similar-asin'],
|
||||
complete: false,
|
||||
})
|
||||
}
|
||||
|
||||
const LEGACY_MIRROR_KEY = 'brand:api-secret:42:appearance-patent'
|
||||
|
||||
test('test_secret_store_reads_legacy_value_before_load', () => {
|
||||
const { localStorage } = setupWindow()
|
||||
clearApiSecretCache()
|
||||
localStorage.setItem(LEGACY_MIRROR_KEY, JSON.stringify({ value: 'legacy-key-1', retention: 'forever' }))
|
||||
|
||||
assert.equal(getStoredApiSecret('appearance-patent'), 'legacy-key-1')
|
||||
})
|
||||
|
||||
test('test_secret_store_load_failure_returns_unknown_and_keeps_cache', async (t) => {
|
||||
const { localStorage } = setupWindow()
|
||||
clearApiSecretCache()
|
||||
localStorage.setItem(LEGACY_MIRROR_KEY, JSON.stringify({ value: 'legacy-key-2' }))
|
||||
mockRequest(t, () => Promise.reject(new Error('服务不可用')))
|
||||
|
||||
const state = await loadApiSecrets({ force: true })
|
||||
|
||||
assert.equal(state, 'unknown')
|
||||
assert.equal(getStoredApiSecret('appearance-patent'), 'legacy-key-2')
|
||||
})
|
||||
|
||||
test('test_secret_store_migrates_legacy_value_once_and_clears_local', async (t) => {
|
||||
const { localStorage } = setupWindow()
|
||||
clearApiSecretCache()
|
||||
localStorage.setItem(LEGACY_MIRROR_KEY, JSON.stringify({ value: 'legacy-key-3' }))
|
||||
const calls: string[] = []
|
||||
mockRequest(t, (config) => {
|
||||
calls.push(config.url || '')
|
||||
if ((config.url || '').includes('/migrate')) {
|
||||
return okResponse({ migrated: 1 })
|
||||
}
|
||||
return bundleResponse([serverItem('appearance-patent'), serverItem('similar-asin')])
|
||||
})
|
||||
|
||||
const state = await loadApiSecrets({ force: true })
|
||||
|
||||
assert.equal(state, 'incomplete')
|
||||
assert.ok(calls.some((url) => url.includes('/api/user-secrets/migrate')), '应调用迁移接口')
|
||||
assert.equal(localStorage.getItem(LEGACY_MIRROR_KEY), null, '迁移后应清理旧记录')
|
||||
})
|
||||
|
||||
test('test_secret_store_save_keeps_plain_value_locally', async (t) => {
|
||||
setupWindow()
|
||||
clearApiSecretCache()
|
||||
mockRequest(t, () =>
|
||||
okResponse(serverItem('appearance-patent', { masked: 'sk-a****1234', exists: true })),
|
||||
)
|
||||
|
||||
await saveApiSecret('appearance-patent', 'sk-abc123456')
|
||||
|
||||
assert.equal(getStoredApiSecret('appearance-patent'), 'sk-abc123456')
|
||||
const snapshot = getStoredApiSecretSnapshot('appearance-patent')
|
||||
assert.equal(snapshot.masked, 'sk-a****1234')
|
||||
assert.equal(snapshot.exists, true)
|
||||
})
|
||||
|
||||
test('test_secret_store_save_rejects_empty_value', async () => {
|
||||
setupWindow()
|
||||
clearApiSecretCache()
|
||||
await assert.rejects(() => saveApiSecret('similar-asin', ' '), /密钥不能为空/)
|
||||
})
|
||||
Reference in New Issue
Block a user