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
+9
View File
@@ -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",
+1 -1
View File
@@ -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',
})
})
+132
View File
@@ -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', ' '), /密钥不能为空/)
})