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