feat(密钥): 用户 API 密钥服务端化——V115 按账号绑定存储 + 后台密钥管理页 + 桌面端全站拦截与配置引导
- 新增 usersecret 模块:外观专利/货源查询密钥从本地 localStorage 迁移至 biz_user_api_secret(AES 加密、按 uid 绑定) - 后台「密钥管理」页:脱敏展示、立即检测、清空;每日 04:30 分布式锁定时巡检 - 桌面端:密钥设置面板走服务端、未配齐引导 /setup-secrets、代理余量展示 - 删除专利汇令牌全链路与密钥保留时长选择器
This commit is contained in:
@@ -100,6 +100,7 @@ ipython_config.py
|
||||
*.sqlite3
|
||||
|
||||
# ===== Misc =====
|
||||
.playwright-cli/
|
||||
desktop/
|
||||
ERP-Demo/
|
||||
xlsx/
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { http } from './http'
|
||||
import { unwrap } from './envelope'
|
||||
|
||||
export interface AdminUserSecretItem {
|
||||
id: number
|
||||
userId: number
|
||||
username: string
|
||||
moduleKey: string
|
||||
moduleLabel: string
|
||||
masked: string
|
||||
exists: boolean
|
||||
checkStatus: string
|
||||
checkCode: string
|
||||
checkMessage: string
|
||||
checkLatencyMs: number | null
|
||||
checkedAt: string | null
|
||||
source: string
|
||||
updatedAt: string | null
|
||||
}
|
||||
|
||||
export interface AdminUserSecretPage {
|
||||
items: AdminUserSecretItem[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export interface UserSecretQuery {
|
||||
keyword?: string
|
||||
moduleKey?: string
|
||||
checkStatus?: string
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
/** 分页查询用户密钥(脱敏):GET /api/admin/user-secrets */
|
||||
export async function fetchUserSecretList(params: UserSecretQuery): Promise<AdminUserSecretPage> {
|
||||
const { data } = await http.get('/api/admin/user-secrets', { params })
|
||||
return unwrap<AdminUserSecretPage>(data)
|
||||
}
|
||||
|
||||
/** 立即检测指定密钥:POST /api/admin/user-secrets/{id}/check */
|
||||
export async function checkUserSecret(id: number) {
|
||||
const { data } = await http.post(`/api/admin/user-secrets/${id}/check`)
|
||||
return unwrap<{
|
||||
moduleKey: string
|
||||
checkStatus: string
|
||||
checkCode: string
|
||||
checkMessage: string
|
||||
checkLatencyMs: number | null
|
||||
checkedAt: string | null
|
||||
viaProxy: boolean
|
||||
}>(data)
|
||||
}
|
||||
|
||||
/** 清空指定用户密钥:DELETE /api/admin/user-secrets/{id} */
|
||||
export async function deleteUserSecret(id: number): Promise<void> {
|
||||
const { data } = await http.delete(`/api/admin/user-secrets/${id}`)
|
||||
unwrap<unknown>(data)
|
||||
}
|
||||
@@ -25,6 +25,10 @@ export const OPERATION_GUIDES: Record<string, OperationGuideData> = {
|
||||
steps: ['新增或调整菜单', '设置层级', '拖动排序'],
|
||||
},
|
||||
admin_group_manage: OPERATION_GUIDE_FALLBACK,
|
||||
admin_user_secrets: {
|
||||
text: '用户密钥按账号绑定,列表只展示脱敏值。可对单条立即检测连通性;清空后该用户需要重新配置密钥。',
|
||||
steps: ['筛选用户或状态', '立即检测连通性', '必要时清空'],
|
||||
},
|
||||
admin_dedupe_total_data: {
|
||||
text: '支持按关键词、用户、分组和国家筛选;导入前先确认所选分组,导出会按日期范围生成文件。',
|
||||
steps: ['选择分组', '筛选或导入', '核对并导出'],
|
||||
|
||||
@@ -0,0 +1,468 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { formatDateTime } from '@/utils/datetime'
|
||||
import OldPagination from '@/components/OldPagination.vue'
|
||||
import {
|
||||
checkUserSecret,
|
||||
deleteUserSecret,
|
||||
fetchUserSecretList,
|
||||
type AdminUserSecretItem,
|
||||
} from '@/api/user-secrets'
|
||||
|
||||
const loading = ref(false)
|
||||
const rows = ref<AdminUserSecretItem[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(15)
|
||||
const keyword = ref('')
|
||||
const moduleFilter = ref('')
|
||||
const statusFilter = ref('')
|
||||
/** 正在检测的行 id,用于按钮 loading 态。 */
|
||||
const checkingId = ref<number | null>(null)
|
||||
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
|
||||
|
||||
const MODULE_OPTIONS = [
|
||||
{ value: '', label: '全部类型' },
|
||||
{ value: 'appearance-patent', label: '外观专利密钥' },
|
||||
{ value: 'similar-asin', label: '货源查询密钥' },
|
||||
]
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: '', label: '全部状态' },
|
||||
{ value: 'passed', label: '检测通过' },
|
||||
{ value: 'failed', label: '检测失败' },
|
||||
{ value: 'error', label: '无法判定' },
|
||||
{ value: 'unknown', label: '未检测' },
|
||||
]
|
||||
|
||||
/** 状态药丸:与店铺密钥页 whitelistStatusMeta 同一视觉语言。 */
|
||||
function statusMeta(status: string) {
|
||||
switch (status) {
|
||||
case 'passed':
|
||||
return { label: '检测通过', tone: 'is-allowed' }
|
||||
case 'failed':
|
||||
return { label: '检测失败', tone: 'is-blocked' }
|
||||
case 'error':
|
||||
return { label: '无法判定', tone: 'is-warn' }
|
||||
default:
|
||||
return { label: '未检测', tone: 'is-unknown' }
|
||||
}
|
||||
}
|
||||
|
||||
/** 状态药丸悬停详情:检测时间 + 检测消息 + 耗时。 */
|
||||
function statusTooltip(row: AdminUserSecretItem) {
|
||||
const parts: string[] = []
|
||||
if (row.checkMessage) parts.push(row.checkMessage)
|
||||
if (row.checkedAt) parts.push(`检测时间:${formatDateTime(row.checkedAt)}`)
|
||||
if (row.checkLatencyMs != null) parts.push(`耗时:${row.checkLatencyMs}ms`)
|
||||
if (row.source) parts.push(`来源:${row.source}`)
|
||||
return parts.join(';') || '暂无检测记录'
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await fetchUserSecretList({
|
||||
keyword: keyword.value.trim() || undefined,
|
||||
moduleKey: moduleFilter.value || undefined,
|
||||
checkStatus: statusFilter.value || undefined,
|
||||
page: page.value,
|
||||
pageSize: pageSize.value,
|
||||
})
|
||||
rows.value = result?.items || []
|
||||
total.value = Number(result?.total || 0)
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function search() {
|
||||
page.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function reset() {
|
||||
keyword.value = ''
|
||||
moduleFilter.value = ''
|
||||
statusFilter.value = ''
|
||||
page.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
/** 立即检测:真实请求一次 LLM 接口并把结果落库。 */
|
||||
async function check(row: AdminUserSecretItem) {
|
||||
checkingId.value = row.id
|
||||
try {
|
||||
const result = await checkUserSecret(row.id)
|
||||
if (result?.checkStatus === 'passed') {
|
||||
ElMessage.success('检测通过')
|
||||
} else if (result?.checkStatus === 'failed') {
|
||||
ElMessage.warning(result.checkMessage || '检测未通过')
|
||||
} else {
|
||||
ElMessage.warning(result?.checkMessage || '本次无法判定')
|
||||
}
|
||||
load()
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '检测失败')
|
||||
} finally {
|
||||
checkingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(row: AdminUserSecretItem) {
|
||||
const who = row.username || `UID ${row.userId}`
|
||||
if (!window.confirm(`确定清空「${who}」的${row.moduleLabel || row.moduleKey}吗?清空后该用户需要重新配置。`)) return
|
||||
try {
|
||||
await deleteUserSecret(row.id)
|
||||
ElMessage.success('已清空')
|
||||
load()
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '清空失败')
|
||||
}
|
||||
}
|
||||
|
||||
function changePage(next: number) {
|
||||
if (next < 1 || next > totalPages.value) return
|
||||
page.value = next
|
||||
load()
|
||||
}
|
||||
|
||||
function changeSize(size: number) {
|
||||
pageSize.value = size
|
||||
page.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="user-secrets-view">
|
||||
<section class="panel-box">
|
||||
<div class="secrets-head">
|
||||
<h3>用户密钥列表</h3>
|
||||
</div>
|
||||
|
||||
<div class="form-row secrets-filter-row">
|
||||
<div class="form-group" style="min-width: 220px">
|
||||
<label>用户名 / UID</label>
|
||||
<input v-model="keyword" type="text" placeholder="模糊搜索用户名或输入用户ID" @keyup.enter="search" />
|
||||
</div>
|
||||
<div class="form-group" style="min-width: 170px">
|
||||
<label>密钥类型</label>
|
||||
<select v-model="moduleFilter">
|
||||
<option v-for="option in MODULE_OPTIONS" :key="option.value" :value="option.value">{{ option.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" style="min-width: 150px">
|
||||
<label>连通性状态</label>
|
||||
<select v-model="statusFilter">
|
||||
<option v-for="option in STATUS_OPTIONS" :key="option.value" :value="option.value">{{ option.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label> </label>
|
||||
<div class="filter-actions">
|
||||
<button class="btn" type="button" @click="search">查询</button>
|
||||
<button class="btn btn-ghost" type="button" @click="reset">重置</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="secrets-table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 58px">序号</th>
|
||||
<th style="width: 200px">用户</th>
|
||||
<th style="width: 150px">密钥类型</th>
|
||||
<th style="width: 220px">密钥(脱敏)</th>
|
||||
<th style="width: 150px">连通性</th>
|
||||
<th style="width: 130px">来源</th>
|
||||
<th style="width: 170px">更新时间</th>
|
||||
<th style="width: 170px">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-if="rows.length">
|
||||
<tr v-for="(row, index) in rows" :key="row.id">
|
||||
<td>{{ (page - 1) * pageSize + index + 1 }}</td>
|
||||
<td>
|
||||
<div class="user-cell">
|
||||
<span class="user-name">{{ row.username || '—' }}</span>
|
||||
<span class="user-uid">UID {{ row.userId }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ row.moduleLabel || row.moduleKey }}</td>
|
||||
<td>
|
||||
<span v-if="row.exists" class="mono-mask">{{ row.masked || '已配置' }}</span>
|
||||
<span v-else class="empty-value">未配置</span>
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
class="wh-pill"
|
||||
:class="statusMeta(row.checkStatus).tone"
|
||||
:title="statusTooltip(row)"
|
||||
>
|
||||
{{ statusMeta(row.checkStatus).label }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ row.source || '—' }}</td>
|
||||
<td>{{ formatDateTime(row.updatedAt) }}</td>
|
||||
<td class="ops-cell">
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
type="button"
|
||||
:disabled="checkingId === row.id || !row.exists"
|
||||
@click="check(row)"
|
||||
>
|
||||
{{ checkingId === row.id ? '检测中…' : '立即检测' }}
|
||||
</button>
|
||||
<button class="btn btn-sm btn-danger" type="button" @click="remove(row)">清空</button>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<tr v-else-if="loading">
|
||||
<td colspan="8" class="empty-tip">加载中...</td>
|
||||
</tr>
|
||||
<tr v-else>
|
||||
<td colspan="8" class="empty-tip">{{ keyword || moduleFilter || statusFilter ? '暂无匹配记录' : '暂无用户密钥记录' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<OldPagination :total="total" :page="page" :page-size="pageSize" @change="changePage" @size-change="changeSize" />
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 像素复刻旧版 admin.html 面板风格(与店铺密钥页同一视觉语言)。 */
|
||||
.user-secrets-view {
|
||||
font-family: inherit;
|
||||
color: #24384d;
|
||||
}
|
||||
.panel-box {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 20px 22px 24px;
|
||||
border: 1px solid #d8e3ee;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(145deg, #ffffff, #f9fbfd);
|
||||
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
|
||||
}
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 650;
|
||||
color: #24384d;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
.secrets-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.secrets-filter-row {
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
.form-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 14px 18px;
|
||||
}
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.form-group label {
|
||||
color: #5b6f83;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.form-group input,
|
||||
.form-group select {
|
||||
min-width: 0;
|
||||
min-height: 42px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #cbd9e6;
|
||||
border-radius: 9px;
|
||||
background: #f8fbfd;
|
||||
color: #24384d;
|
||||
font-size: 13.5px;
|
||||
font-family: inherit;
|
||||
color-scheme: light;
|
||||
outline: none;
|
||||
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
|
||||
}
|
||||
.form-group input:hover,
|
||||
.form-group select:hover {
|
||||
border-color: #9fb7cd;
|
||||
}
|
||||
.form-group input:focus,
|
||||
.form-group select:focus {
|
||||
background: #ffffff;
|
||||
border-color: #5f85ad;
|
||||
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
|
||||
}
|
||||
.filter-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-height: 42px;
|
||||
padding: 9px 18px;
|
||||
border: 1px solid #4f78a5;
|
||||
border-radius: 9px;
|
||||
background: linear-gradient(135deg, #5f85ad, #4f78a5);
|
||||
color: #ffffff;
|
||||
font-family: inherit;
|
||||
font-size: 13.5px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
|
||||
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
|
||||
}
|
||||
.btn:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #7094ba, #5d83ac);
|
||||
box-shadow: 0 11px 22px -14px rgba(79, 120, 165, 0.8);
|
||||
}
|
||||
.btn:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
.btn-danger {
|
||||
background: linear-gradient(135deg, #c06d77, #b35f6a);
|
||||
border-color: #b35f6a;
|
||||
}
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #cb7c84, #b96570);
|
||||
}
|
||||
.btn-ghost {
|
||||
background: #ffffff;
|
||||
border-color: #c7d7e5;
|
||||
color: #4f78a5;
|
||||
box-shadow: none;
|
||||
}
|
||||
.btn-ghost:hover:not(:disabled) {
|
||||
background: #edf5fb;
|
||||
border-color: #95b1cb;
|
||||
color: #2f5d8b;
|
||||
}
|
||||
.btn-sm {
|
||||
min-height: 36px;
|
||||
padding: 7px 12px;
|
||||
}
|
||||
.secrets-table-scroll {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
border: 1px solid #dbe5ee;
|
||||
border-radius: 10px;
|
||||
background: #ffffff;
|
||||
}
|
||||
.secrets-table-scroll table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
min-width: 1200px;
|
||||
}
|
||||
.secrets-table-scroll th,
|
||||
.secrets-table-scroll td {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.5;
|
||||
border-bottom: 1px solid #e0e8ef;
|
||||
vertical-align: middle;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.secrets-table-scroll th {
|
||||
background: #edf4fa;
|
||||
color: #4e6479;
|
||||
border-bottom-color: #d5e1eb;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
.secrets-table-scroll tbody tr:hover td {
|
||||
background: #f1f7fb;
|
||||
}
|
||||
.secrets-table-scroll tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.user-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.user-name {
|
||||
color: #24384d;
|
||||
font-weight: 600;
|
||||
}
|
||||
.user-uid {
|
||||
color: #8293a5;
|
||||
font-size: 11.5px;
|
||||
}
|
||||
.mono-mask {
|
||||
font-family: Consolas, "Cascadia Mono", monospace;
|
||||
font-size: 12.5px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.empty-value {
|
||||
color: #a7b4c1;
|
||||
}
|
||||
.ops-cell {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.empty-tip {
|
||||
color: #8293a5;
|
||||
text-align: center;
|
||||
}
|
||||
.wh-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
cursor: help;
|
||||
}
|
||||
.wh-pill.is-allowed {
|
||||
background: #e8f6ee;
|
||||
border-color: #b9e0c9;
|
||||
color: #2f7d52;
|
||||
}
|
||||
.wh-pill.is-blocked {
|
||||
background: #fdecef;
|
||||
border-color: #f2c4cd;
|
||||
color: #b04a5a;
|
||||
}
|
||||
.wh-pill.is-warn {
|
||||
background: #fdf6e3;
|
||||
border-color: #f0dfae;
|
||||
color: #8f6d1e;
|
||||
}
|
||||
.wh-pill.is-unknown {
|
||||
background: #f0f3f6;
|
||||
border-color: #d6dee6;
|
||||
color: #6b7d8f;
|
||||
}
|
||||
</style>
|
||||
@@ -17,6 +17,7 @@ export const adminPages: AdminPageDef[] = [
|
||||
{ path: 'account/users', menuKey: 'admin_users', title: '用户管理', load: () => import('@/pages/account/UsersPage.vue') },
|
||||
{ path: 'account/menus', menuKey: 'admin_columns', title: '菜单管理', load: () => import('@/pages/account/MenusPage.vue') },
|
||||
{ path: 'account/groups', menuKey: 'admin_group_manage', title: '数据权限分组', load: () => import('@/pages/account/GroupsPage.vue') },
|
||||
{ path: 'account/user-secrets', menuKey: 'admin_user_secrets', title: '密钥管理', load: () => import('@/pages/account/UserSecretsPage.vue') },
|
||||
{ path: 'shop-center/duplicate-check', menuKey: 'admin_shop_data_duplicate_check', title: '店铺撞款监控', load: () => import('@/pages/tasks/DuplicateCheckPage.vue') },
|
||||
{ path: 'shop-center/keys', menuKey: 'admin_shop_keys', title: '店铺密钥管理', load: () => import('@/pages/shop/ShopKeysPage.vue') },
|
||||
{ path: 'shop-center/shops', menuKey: 'admin_shop_manage', title: '店铺管理', load: () => import('@/pages/shop/ShopManagePage.vue') },
|
||||
|
||||
@@ -13,7 +13,7 @@ import { adminPages } from '../src/router/routes.ts'
|
||||
|
||||
test('test_task_010_lazy_page_boundary_normal_primary_path', () => {
|
||||
// 正常主路径:所有注册页面都是懒加载器,可被路由异步边界包裹。
|
||||
assert.equal(adminPages.length, 16)
|
||||
assert.equal(adminPages.length, 17)
|
||||
for (const page of adminPages) {
|
||||
assert.equal(isLazyLoader(page.load), true, `页面 ${page.path} 必须是懒加载函数`)
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ test('test_task_012_route_error_page_normal_repeated_operation_is_idempotent', (
|
||||
test('test_task_012_route_error_page_boundary_empty_input', () => {
|
||||
// 边界空值:错误页文件存在,且不进入业务路由注册表。
|
||||
assert.equal(existsSync(join(process.cwd(), NOT_FOUND)), true)
|
||||
assert.equal(adminPages.length, 16, '错误页不应计入业务路由')
|
||||
assert.equal(adminPages.length, 17, '错误页不应计入业务路由')
|
||||
})
|
||||
|
||||
test('test_task_012_route_error_page_boundary_single_item', () => {
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
|
||||
test('test_task_008_domain_route_registry_normal_primary_path', () => {
|
||||
// 正常主路径:注册表首批 account 域页面登记为可消费路由记录。
|
||||
assert.equal(adminPages.length, 16)
|
||||
assert.equal(adminPages.length, 17)
|
||||
assert.equal(adminRouteRecords.length, adminPages.length)
|
||||
const first = adminRouteRecords[0]
|
||||
assert.equal(first.path, 'account/users')
|
||||
|
||||
@@ -65,6 +65,14 @@ public class AdminApiGuardFilter extends OncePerRequestFilter {
|
||||
"/api/price-track",
|
||||
};
|
||||
|
||||
/**
|
||||
* 桌面端自助接口前缀:新建端点、无历史匿名调用方,无条件纳入兜底鉴权
|
||||
* (controller 内 requireUser 为主防线,此处双保险;不挂 user-tool-guard-enabled 开关)。
|
||||
*/
|
||||
private static final String[] SELF_SERVICE_PREFIXES = {
|
||||
"/api/user-secrets",
|
||||
};
|
||||
|
||||
private final AdminAuthSupport adminAuthSupport;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@@ -131,11 +139,16 @@ public class AdminApiGuardFilter extends OncePerRequestFilter {
|
||||
chain.doFilter(request, response);
|
||||
}
|
||||
|
||||
/** 命中受保护前缀(/api/admin、/debug、用户态工具前缀及其子路径)才进入鉴权,其余请求直接放行。 */
|
||||
/** 命中受保护前缀(/api/admin、/debug、自助接口、用户态工具前缀及其子路径)才进入鉴权,其余请求直接放行。 */
|
||||
private boolean isGuarded(String uri) {
|
||||
if (matchesPrefix(uri, ADMIN_API_PREFIX) || matchesPrefix(uri, DEBUG_PREFIX)) {
|
||||
return true;
|
||||
}
|
||||
for (String prefix : SELF_SERVICE_PREFIXES) {
|
||||
if (matchesPrefix(uri, prefix)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (!userToolGuardEnabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,6 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
@EnableConfigurationProperties({OssProperties.class, TransientStorageProperties.class, StorageProperties.class, BrandProgressProperties.class, DeleteBrandProgressProperties.class, BrandCheckProperties.class, ZiniaoProperties.class, ModuleCleanupProperties.class, TaskPressureProperties.class, TaskImageCacheCleanupProperties.class, AppearancePatentProperties.class, SimilarAsinProperties.class, ImageVideoProperties.class, InstanceRoutingProperties.class, CapacityPlanProperties.class})
|
||||
@EnableConfigurationProperties({OssProperties.class, TransientStorageProperties.class, StorageProperties.class, BrandProgressProperties.class, DeleteBrandProgressProperties.class, BrandCheckProperties.class, ZiniaoProperties.class, ModuleCleanupProperties.class, TaskPressureProperties.class, TaskImageCacheCleanupProperties.class, AppearancePatentProperties.class, SimilarAsinProperties.class, ImageVideoProperties.class, InstanceRoutingProperties.class, CapacityPlanProperties.class, UserSecretProperties.class})
|
||||
public class PropertiesConfig {
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.nanri.aiimage.config;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/**
|
||||
* 用户 API 密钥(外观专利密钥 / 货源查询密钥)服务端化配置。
|
||||
*/
|
||||
@Data
|
||||
@ConfigurationProperties(prefix = "aiimage.user-secret")
|
||||
public class UserSecretProperties {
|
||||
|
||||
/** 每日定时连通性巡检开关(应急可关,无需重新打包)。 */
|
||||
private boolean checkEnabled = true;
|
||||
|
||||
/** 巡检 cron(默认每天 04:30,Asia/Shanghai)。 */
|
||||
private String checkCron = "0 30 4 * * *";
|
||||
|
||||
/** 单轮巡检最多检测条数,超出顺延下一轮。 */
|
||||
private int checkMaxRows = 500;
|
||||
|
||||
/** 单轮巡检时间预算(分钟),超时中断本轮。 */
|
||||
private int checkBudgetMinutes = 20;
|
||||
|
||||
/**
|
||||
* 检测出口代理提取链接(选配):配置后检测请求优先经该代理 IP 发出,
|
||||
* 代理不可用时自动回退直连;留空则全部直连。
|
||||
*/
|
||||
private String checkProxyExtractUrl = "";
|
||||
|
||||
/** jikip 余量查询接口(客户端设置弹窗展示套餐 IP 余量 / 账户余额)。 */
|
||||
private String jikipBalanceUrl = "https://api.jikip.com/find-balance";
|
||||
|
||||
/** jikip 套餐 id(余量查询参数)。 */
|
||||
private String jikipPlanId = "";
|
||||
|
||||
/** jikip 用户 ID(余量查询参数)。 */
|
||||
private String jikipUserId = "";
|
||||
}
|
||||
+1
-8
@@ -4,7 +4,6 @@ import com.fasterxml.jackson.annotation.JsonAlias;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
|
||||
@@ -29,12 +28,6 @@ public class AppearancePatentParseRequest {
|
||||
|
||||
@JsonProperty("api_key")
|
||||
@JsonAlias({"apiKey"})
|
||||
@Schema(description = "调用 LLM API 的任务级密钥。")
|
||||
@NotBlank(message = "密钥不能为空")
|
||||
@Schema(description = "调用 LLM API 的任务级密钥。非必填;为空时后端按用户密钥配置兜底。")
|
||||
private String apiKey;
|
||||
|
||||
@JsonProperty("patent_token")
|
||||
@JsonAlias({"patentToken"})
|
||||
@Schema(description = "专利汇令牌。非必填。")
|
||||
private String patentToken;
|
||||
}
|
||||
|
||||
-1
@@ -10,7 +10,6 @@ import java.util.List;
|
||||
public class AppearancePatentParsedGroupPageDto {
|
||||
private String aiPrompt;
|
||||
private String apiKey;
|
||||
private String patentToken;
|
||||
private Integer page;
|
||||
private Integer pageSize;
|
||||
private Integer totalGroups;
|
||||
|
||||
-3
@@ -17,9 +17,6 @@ public class AppearancePatentParsedPayloadDto {
|
||||
@Schema(description = "调用 LLM API 的任务级密钥")
|
||||
private String apiKey;
|
||||
|
||||
@Schema(description = "专利汇令牌")
|
||||
private String patentToken;
|
||||
|
||||
@Schema(description = "本次解析的源文件列表")
|
||||
private List<AppearancePatentSourceFileDto> sourceFiles = new ArrayList<>();
|
||||
|
||||
|
||||
+22
-11
@@ -92,6 +92,8 @@ import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
import com.nanri.aiimage.modules.task.model.vo.TaskProgressLightBatchVo;
|
||||
import com.nanri.aiimage.modules.task.service.TaskProgressLightAssembler;
|
||||
import com.nanri.aiimage.modules.usersecret.service.UserApiSecretService;
|
||||
import com.nanri.aiimage.modules.usersecret.support.UserSecretModule;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@@ -135,6 +137,7 @@ public class AppearancePatentTaskService {
|
||||
private final TaskDistributedLockService taskDistributedLockService;
|
||||
private final InstanceMetadata instanceMetadata;
|
||||
private final TaskProgressLightAssembler taskProgressLightAssembler;
|
||||
private final UserApiSecretService userApiSecretService;
|
||||
|
||||
public AppearancePatentParseVo parseAndCreateTask(AppearancePatentParseRequest request) {
|
||||
long startedAt = System.nanoTime();
|
||||
@@ -204,11 +207,11 @@ public class AppearancePatentTaskService {
|
||||
|
||||
String aggregateScopeKey = buildAggregateScopeKey(sourceFiles);
|
||||
String sourceScopeHash = DigestUtil.sha256Hex(aggregateScopeKey);
|
||||
String parsedPayload = buildParsedPayloadJson(request.getAiPrompt(), request.getApiKey(), request.getPatentToken(), sourceFiles, mergedHeaders, allRows);
|
||||
String parsedPayload = buildParsedPayloadJson(request.getAiPrompt(), request.getApiKey(), sourceFiles, mergedHeaders, allRows);
|
||||
long payloadBuiltAt = System.nanoTime();
|
||||
String parsedPayloadPointer = storeParsedPayload(task.getId(), sourceScopeHash, parsedPayload);
|
||||
long payloadStoredAt = System.nanoTime();
|
||||
task.setResultJson(buildTaskResultJson(request.getAiPrompt(), request.getApiKey(), request.getPatentToken(), sourceFiles, parsedPayloadPointer));
|
||||
task.setResultJson(buildTaskResultJson(request.getAiPrompt(), request.getApiKey(), sourceFiles, parsedPayloadPointer));
|
||||
task.setUpdatedAt(LocalDateTime.now());
|
||||
fileTaskMapper.updateById(task);
|
||||
|
||||
@@ -1408,12 +1411,24 @@ public class AppearancePatentTaskService {
|
||||
}
|
||||
}
|
||||
|
||||
/** 密钥读取:任务 payload 优先(兼容老任务与显式覆盖),为空时按任务归属用户从服务端密钥表兜底。 */
|
||||
private String readApiKey(FileTaskEntity task) {
|
||||
try {
|
||||
return normalize(readParsedPayload(task).getApiKey());
|
||||
} catch (Exception ignored) {
|
||||
return "";
|
||||
String fromPayload = normalize(readParsedPayload(task).getApiKey());
|
||||
if (!fromPayload.isEmpty()) {
|
||||
return fromPayload;
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
log.warn("[appearance-patent] 读取任务 payload 密钥失败,尝试用户密钥兜底 taskId={} err={}",
|
||||
task.getId(), ex.getMessage());
|
||||
}
|
||||
String fromUserSecret = userApiSecretService.findPlainValue(
|
||||
task.getUserId(), UserSecretModule.APPEARANCE_PATENT.key());
|
||||
if (fromUserSecret.isEmpty()) {
|
||||
log.warn("[appearance-patent] 任务未携带密钥且用户未配置密钥,LLM 检测将保留原始行 taskId={} userId={}",
|
||||
task.getId(), task.getUserId());
|
||||
}
|
||||
return fromUserSecret;
|
||||
}
|
||||
|
||||
private void finalizeTask(FileTaskEntity task, String error, int rowCount, boolean assembleWorkbook) {
|
||||
@@ -2655,11 +2670,10 @@ public class AppearancePatentTaskService {
|
||||
return val == null ? "" : val.replace(String.valueOf((char) 0xFEFF), "").replace((char) 0x3000, ' ').trim().replaceAll("\\s+", " ");
|
||||
}
|
||||
|
||||
private String buildParsedPayloadJson(String aiPrompt, String apiKey, String patentToken, List<AppearancePatentSourceFileDto> sourceFiles, List<String> headers, List<AppearancePatentParsedRowVo> allRows) {
|
||||
private String buildParsedPayloadJson(String aiPrompt, String apiKey, List<AppearancePatentSourceFileDto> sourceFiles, List<String> headers, List<AppearancePatentParsedRowVo> allRows) {
|
||||
AppearancePatentParsedPayloadDto payload = new AppearancePatentParsedPayloadDto();
|
||||
payload.setAiPrompt(normalize(aiPrompt));
|
||||
payload.setApiKey(normalize(apiKey));
|
||||
payload.setPatentToken(normalize(patentToken));
|
||||
payload.setSourceFiles(sourceFiles == null ? List.of() : sourceFiles);
|
||||
payload.setHeaders(headers == null ? List.of() : headers);
|
||||
payload.setItems(List.of());
|
||||
@@ -2668,11 +2682,10 @@ public class AppearancePatentTaskService {
|
||||
return writeJson(payload, "保存解析结果失败");
|
||||
}
|
||||
|
||||
private String buildTaskResultJson(String aiPrompt, String apiKey, String patentToken, List<AppearancePatentSourceFileDto> sourceFiles, String parsedPayloadPointer) {
|
||||
private String buildTaskResultJson(String aiPrompt, String apiKey, List<AppearancePatentSourceFileDto> sourceFiles, String parsedPayloadPointer) {
|
||||
Map<String, Object> payload = new LinkedHashMap<>();
|
||||
payload.put("aiPrompt", normalize(aiPrompt));
|
||||
payload.put("apiKey", normalize(apiKey));
|
||||
payload.put("patentToken", normalize(patentToken));
|
||||
payload.put("sourceFileKeys", sourceFiles == null ? List.of() : sourceFiles.stream()
|
||||
.map(AppearancePatentSourceFileDto::getFileKey)
|
||||
.filter(Objects::nonNull)
|
||||
@@ -2752,7 +2765,6 @@ public class AppearancePatentTaskService {
|
||||
AppearancePatentParsedPayloadDto queuePayload = new AppearancePatentParsedPayloadDto();
|
||||
queuePayload.setAiPrompt(payload.getAiPrompt());
|
||||
queuePayload.setApiKey(payload.getApiKey());
|
||||
queuePayload.setPatentToken(payload.getPatentToken());
|
||||
queuePayload.setGroups(payload.getGroups() == null ? List.of() : payload.getGroups());
|
||||
queuePayload.setItems(List.of());
|
||||
queuePayload.setAllItems(List.of());
|
||||
@@ -2778,7 +2790,6 @@ public class AppearancePatentTaskService {
|
||||
AppearancePatentParsedGroupPageDto vo = new AppearancePatentParsedGroupPageDto();
|
||||
vo.setAiPrompt(payload.getAiPrompt());
|
||||
vo.setApiKey(payload.getApiKey());
|
||||
vo.setPatentToken(payload.getPatentToken());
|
||||
vo.setPage(safePage);
|
||||
vo.setPageSize(safePageSize);
|
||||
vo.setTotalGroups(totalGroups);
|
||||
|
||||
+1
@@ -63,6 +63,7 @@ public class PermissionMenuSchemaInitializer {
|
||||
new DefaultAdminMenu("用户管理", "admin_users", "users", 10, "admin_group_account"),
|
||||
new DefaultAdminMenu("菜单权限配置", "admin_columns", "columns", 20, "admin_group_account"),
|
||||
new DefaultAdminMenu("分组管理", "admin_group_manage", "group-manage", 25, "admin_group_account"),
|
||||
new DefaultAdminMenu("密钥管理", "admin_user_secrets", "account/user-secrets", 41, "admin_group_account"),
|
||||
new DefaultAdminMenu("去重数据汇总", "admin_dedupe_total_data", "dedupe-total-data", 30, "admin_group_data"),
|
||||
new DefaultAdminMenu("品牌数据库", "admin_invalid_asin_data", "invalid-asin-data", 35, "admin_group_data"),
|
||||
new DefaultAdminMenu("查询ASIN", "admin_query_asin", "query-asin", 65, "admin_group_data"),
|
||||
|
||||
+1
-3
@@ -3,7 +3,6 @@ package com.nanri.aiimage.modules.similarasin.model.dto;
|
||||
import com.fasterxml.jackson.annotation.JsonAlias;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
@@ -29,8 +28,7 @@ public class SimilarAsinParseRequest {
|
||||
|
||||
@JsonProperty("api_key")
|
||||
@JsonAlias({"apiKey"})
|
||||
@Schema(description = "传递给 LLM 的任务级 api_key。")
|
||||
@NotBlank(message = "密钥不能为空")
|
||||
@Schema(description = "传递给 LLM 的任务级 api_key。非必填;为空时后端按用户密钥配置兜底。")
|
||||
private String apiKey;
|
||||
|
||||
@JsonProperty("img_switch")
|
||||
|
||||
+18
-3
@@ -60,6 +60,8 @@ import com.nanri.aiimage.modules.task.service.TaskFileJobService;
|
||||
import com.nanri.aiimage.modules.task.service.TaskDistributedLockService;
|
||||
import com.nanri.aiimage.modules.task.service.TaskProgressSnapshotService;
|
||||
import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService;
|
||||
import com.nanri.aiimage.modules.usersecret.service.UserApiSecretService;
|
||||
import com.nanri.aiimage.modules.usersecret.support.UserSecretModule;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.poi.ss.usermodel.Cell;
|
||||
@@ -339,6 +341,7 @@ public class SimilarAsinTaskService {
|
||||
* best-effort:service 内部所有异常都已吞掉,不影响主流程。
|
||||
*/
|
||||
private final SimilarAsinImagePrefetchService imagePrefetchService;
|
||||
private final UserApiSecretService userApiSecretService;
|
||||
/**
|
||||
* Task 89:Excel 行解析器(表头/单元格读取、别名匹配、空行跳过、单字段截断)。
|
||||
* 由 parseAndCreateTask 委托;解析语义与搬移前 parseWorkbook 完全一致。
|
||||
@@ -2031,12 +2034,24 @@ public class SimilarAsinTaskService {
|
||||
}
|
||||
}
|
||||
|
||||
/** 密钥读取:任务 payload 优先(兼容老任务与显式覆盖),为空时按任务归属用户从服务端密钥表兜底。 */
|
||||
private String readApiKey(FileTaskEntity task) {
|
||||
try {
|
||||
return normalize(readParsedPayload(task).getApiKey());
|
||||
} catch (Exception ignored) {
|
||||
return "";
|
||||
String fromPayload = normalize(readParsedPayload(task).getApiKey());
|
||||
if (!fromPayload.isEmpty()) {
|
||||
return fromPayload;
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
log.warn("[similar-asin] 读取任务 payload 密钥失败,尝试用户密钥兜底 taskId={} err={}",
|
||||
task.getId(), ex.getMessage());
|
||||
}
|
||||
String fromUserSecret = userApiSecretService.findPlainValue(
|
||||
task.getUserId(), UserSecretModule.SIMILAR_ASIN.key());
|
||||
if (fromUserSecret.isEmpty()) {
|
||||
log.warn("[similar-asin] 任务未携带密钥且用户未配置密钥,LLM 检测将保留原始行 taskId={} userId={}",
|
||||
task.getId(), task.getUserId());
|
||||
}
|
||||
return fromUserSecret;
|
||||
}
|
||||
|
||||
private boolean readImgSwitch(FileTaskEntity task) {
|
||||
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
package com.nanri.aiimage.modules.usersecret.client;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.nanri.aiimage.config.HttpClientPool;
|
||||
import com.nanri.aiimage.config.UserSecretProperties;
|
||||
import com.nanri.aiimage.modules.usersecret.model.vo.UserApiSecretBalanceVo;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* jikip 代理服务客户端:从提取链接取代理 IP(检测出口可选)、查询套餐余量(客户端展示)。
|
||||
* 所有失败均降级返回(null / available=false),绝不抛出中断调用方流程。
|
||||
*/
|
||||
@Component
|
||||
@Slf4j
|
||||
public class JikipProxyClient {
|
||||
|
||||
private static final int EXTRACT_READ_TIMEOUT_MILLIS = 10_000;
|
||||
private static final int BALANCE_READ_TIMEOUT_MILLIS = 8_000;
|
||||
private static final Pattern IP_PORT_PATTERN =
|
||||
Pattern.compile("(\\d{1,3}(?:\\.\\d{1,3}){3}):(\\d{2,5})");
|
||||
|
||||
private final UserSecretProperties properties;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
private volatile RestClient sharedClient;
|
||||
|
||||
public JikipProxyClient(UserSecretProperties properties, ObjectMapper objectMapper) {
|
||||
this.properties = properties;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
/** 是否配置了检测出口代理提取链接(未配置则检测全部直连)。 */
|
||||
public boolean isExtractConfigured() {
|
||||
return hasText(properties.getCheckProxyExtractUrl());
|
||||
}
|
||||
|
||||
/**
|
||||
* 从提取链接取一个代理地址(http://ip:port);未配置或失败返回 null,由调用方回退直连。
|
||||
*/
|
||||
public String fetchProxyUrl() {
|
||||
String extractUrl = normalize(properties.getCheckProxyExtractUrl());
|
||||
if (extractUrl.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
String body = restClient(EXTRACT_READ_TIMEOUT_MILLIS).get()
|
||||
.uri(extractUrl)
|
||||
.retrieve()
|
||||
.body(String.class);
|
||||
String proxyUrl = parseProxyUrl(body);
|
||||
if (proxyUrl == null) {
|
||||
log.warn("[user-secret][proxy] 提取接口返回中未找到 ip:port,响应前 200 字={}", abbreviate(body, 200));
|
||||
return null;
|
||||
}
|
||||
log.info("[user-secret][proxy] 代理提取成功 proxy={}", proxyUrl);
|
||||
return proxyUrl;
|
||||
} catch (Exception ex) {
|
||||
log.warn("[user-secret][proxy] 代理提取失败 err={}", ex.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 余量查询:GET {balanceUrl}?id={planId}&userId={userId},返回 surplus/balance。 */
|
||||
public UserApiSecretBalanceVo fetchBalance() {
|
||||
UserApiSecretBalanceVo vo = new UserApiSecretBalanceVo();
|
||||
String balanceUrl = normalize(properties.getJikipBalanceUrl());
|
||||
String planId = normalize(properties.getJikipPlanId());
|
||||
String userId = normalize(properties.getJikipUserId());
|
||||
if (balanceUrl.isBlank() || planId.isBlank() || userId.isBlank()) {
|
||||
log.info("[user-secret][proxy] 余量查询跳过:jikip 套餐信息未配置");
|
||||
vo.setAvailable(false);
|
||||
vo.setMessage("未配置代理套餐信息");
|
||||
return vo;
|
||||
}
|
||||
try {
|
||||
String separator = balanceUrl.contains("?") ? "&" : "?";
|
||||
String url = balanceUrl + separator
|
||||
+ "id=" + encode(planId) + "&userId=" + encode(userId);
|
||||
String body = restClient(BALANCE_READ_TIMEOUT_MILLIS).get()
|
||||
.uri(url)
|
||||
.retrieve()
|
||||
.body(String.class);
|
||||
JsonNode root = objectMapper.readTree(body == null ? "" : body);
|
||||
JsonNode data = root.path("data");
|
||||
JsonNode source = data.isObject() ? data : root;
|
||||
vo.setSurplus(text(source.get("surplus")));
|
||||
vo.setBalance(text(source.get("balance")));
|
||||
vo.setAvailable(true);
|
||||
log.info("[user-secret][proxy] 余量查询成功 surplus={} balance={}", vo.getSurplus(), vo.getBalance());
|
||||
} catch (Exception ex) {
|
||||
log.warn("[user-secret][proxy] 余量查询失败 err={}", ex.getMessage());
|
||||
vo.setAvailable(false);
|
||||
vo.setMessage("余量查询失败:" + ex.getMessage());
|
||||
}
|
||||
return vo;
|
||||
}
|
||||
|
||||
/** 解析提取接口响应:JSON 中的 ip/port 字段优先,否则正则匹配任意位置的 ip:port。 */
|
||||
private String parseProxyUrl(String body) {
|
||||
String text = normalize(body);
|
||||
if (text.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
if (text.startsWith("{") || text.startsWith("[")) {
|
||||
try {
|
||||
String fromJson = extractFromJson(objectMapper.readTree(text));
|
||||
if (fromJson != null) {
|
||||
return "http://" + fromJson;
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// JSON 解析失败继续走正则兜底
|
||||
}
|
||||
}
|
||||
Matcher matcher = IP_PORT_PATTERN.matcher(text);
|
||||
if (matcher.find()) {
|
||||
return "http://" + matcher.group(1) + ":" + matcher.group(2);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String extractFromJson(JsonNode node) {
|
||||
if (node == null || node.isMissingNode() || node.isNull()) {
|
||||
return null;
|
||||
}
|
||||
if (node.isArray()) {
|
||||
for (JsonNode child : node) {
|
||||
String found = extractFromJson(child);
|
||||
if (found != null) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (node.isObject()) {
|
||||
String ip = text(node.get("ip"));
|
||||
String port = text(node.get("port"));
|
||||
if (ip != null && port != null) {
|
||||
return ip + ":" + port;
|
||||
}
|
||||
for (JsonNode child : node) {
|
||||
String found = extractFromJson(child);
|
||||
if (found != null) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (node.isTextual()) {
|
||||
Matcher matcher = IP_PORT_PATTERN.matcher(node.asText());
|
||||
if (matcher.find()) {
|
||||
return matcher.group(1) + ":" + matcher.group(2);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private RestClient restClient(int readTimeoutMillis) {
|
||||
RestClient client = sharedClient;
|
||||
if (client != null) {
|
||||
return client;
|
||||
}
|
||||
synchronized (this) {
|
||||
if (sharedClient == null) {
|
||||
sharedClient = RestClient.builder()
|
||||
.requestFactory(HttpClientPool.requestFactory(readTimeoutMillis))
|
||||
.build();
|
||||
}
|
||||
return sharedClient;
|
||||
}
|
||||
}
|
||||
|
||||
private String encode(String value) {
|
||||
return URLEncoder.encode(value, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private String text(JsonNode node) {
|
||||
if (node == null || node.isNull() || node.isMissingNode()) {
|
||||
return null;
|
||||
}
|
||||
return node.asText();
|
||||
}
|
||||
|
||||
private String abbreviate(String value, int maxLength) {
|
||||
String normalized = value == null ? "" : value.trim();
|
||||
if (normalized.length() <= maxLength) {
|
||||
return normalized;
|
||||
}
|
||||
return normalized.substring(0, maxLength) + "...";
|
||||
}
|
||||
|
||||
private String normalize(String value) {
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
|
||||
private boolean hasText(String value) {
|
||||
return value != null && !value.trim().isEmpty();
|
||||
}
|
||||
}
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
package com.nanri.aiimage.modules.usersecret.controller;
|
||||
|
||||
import com.nanri.aiimage.common.api.ApiResponse;
|
||||
import com.nanri.aiimage.config.UserSecretProperties;
|
||||
import com.nanri.aiimage.modules.admin.support.AdminAuthSupport;
|
||||
import com.nanri.aiimage.modules.usersecret.model.dto.AdminUserSecretQuery;
|
||||
import com.nanri.aiimage.modules.usersecret.model.vo.AdminUserSecretPageVo;
|
||||
import com.nanri.aiimage.modules.usersecret.model.vo.UserApiSecretCheckResultVo;
|
||||
import com.nanri.aiimage.modules.usersecret.service.UserApiSecretService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 后台密钥管理:管理员查看用户密钥(脱敏)、立即检测、清空。
|
||||
* 不提供查看明文与代填编辑能力。
|
||||
*/
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/admin/user-secrets")
|
||||
@Tag(name = "后台密钥管理", description = "查看用户密钥配置(脱敏)、立即检测连通性、清空。")
|
||||
public class AdminUserApiSecretController {
|
||||
|
||||
private final UserApiSecretService userApiSecretService;
|
||||
private final UserSecretProperties userSecretProperties;
|
||||
private final AdminAuthSupport adminAuthSupport;
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "分页查询用户密钥", description = "keyword 匹配用户名或用户ID。")
|
||||
public ApiResponse<AdminUserSecretPageVo> page(
|
||||
HttpServletRequest request,
|
||||
@Parameter(description = "关键字:用户名或用户ID") @RequestParam(required = false) String keyword,
|
||||
@Parameter(description = "密钥模块筛选") @RequestParam(required = false) String moduleKey,
|
||||
@Parameter(description = "连通性状态筛选") @RequestParam(required = false) String checkStatus,
|
||||
@Parameter(description = "页码") @RequestParam(defaultValue = "1") Long page,
|
||||
@Parameter(description = "每页数量") @RequestParam(defaultValue = "15") Long pageSize) {
|
||||
adminAuthSupport.requireAdmin(request);
|
||||
AdminUserSecretQuery query = new AdminUserSecretQuery();
|
||||
query.setKeyword(keyword);
|
||||
query.setModuleKey(moduleKey);
|
||||
query.setCheckStatus(checkStatus);
|
||||
query.setPage(page);
|
||||
query.setPageSize(pageSize);
|
||||
return ApiResponse.success(userApiSecretService.adminPage(query));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/check")
|
||||
@Operation(summary = "立即检测指定密钥", description = "解密后真实请求一次 LLM 接口并把结果落库。")
|
||||
public ApiResponse<UserApiSecretCheckResultVo> check(
|
||||
HttpServletRequest request,
|
||||
@Parameter(description = "记录主键", required = true) @PathVariable Long id) {
|
||||
adminAuthSupport.requireAdmin(request);
|
||||
return ApiResponse.success("检测完成", userApiSecretService.adminCheck(id));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@Operation(summary = "清空指定用户密钥")
|
||||
public ApiResponse<Void> clear(
|
||||
HttpServletRequest request,
|
||||
@Parameter(description = "记录主键", required = true) @PathVariable Long id) {
|
||||
adminAuthSupport.requireAdmin(request);
|
||||
userApiSecretService.adminClear(id);
|
||||
return ApiResponse.success("已清空", null);
|
||||
}
|
||||
|
||||
@PostMapping("/check-all")
|
||||
@Operation(summary = "手动触发一轮全量巡检", description = "同步执行,受巡检条数与时间预算配置约束,请勿频繁调用。")
|
||||
public ApiResponse<Map<String, Integer>> checkAll(HttpServletRequest request) {
|
||||
adminAuthSupport.requireAdmin(request);
|
||||
UserApiSecretService.CheckSummary summary = userApiSecretService.checkAllForScheduler(
|
||||
userSecretProperties.getCheckMaxRows(), userSecretProperties.getCheckBudgetMinutes());
|
||||
return ApiResponse.success("巡检完成", Map.of(
|
||||
"checked", summary.checked(),
|
||||
"passed", summary.passed(),
|
||||
"failed", summary.failed(),
|
||||
"errors", summary.errors(),
|
||||
"skipped", summary.skipped()));
|
||||
}
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
package com.nanri.aiimage.modules.usersecret.controller;
|
||||
|
||||
import com.nanri.aiimage.common.api.ApiResponse;
|
||||
import com.nanri.aiimage.modules.admin.support.AdminAuthSupport;
|
||||
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
|
||||
import com.nanri.aiimage.modules.usersecret.model.dto.UserApiSecretCheckRequest;
|
||||
import com.nanri.aiimage.modules.usersecret.model.dto.UserApiSecretMigrateRequest;
|
||||
import com.nanri.aiimage.modules.usersecret.model.dto.UserApiSecretSaveRequest;
|
||||
import com.nanri.aiimage.modules.usersecret.model.vo.UserApiSecretBalanceVo;
|
||||
import com.nanri.aiimage.modules.usersecret.model.vo.UserApiSecretBundleVo;
|
||||
import com.nanri.aiimage.modules.usersecret.model.vo.UserApiSecretCheckResultVo;
|
||||
import com.nanri.aiimage.modules.usersecret.model.vo.UserApiSecretItemVo;
|
||||
import com.nanri.aiimage.modules.usersecret.service.UserApiSecretService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 桌面端用户密钥自助接口:当前登录用户维度,用户身份一律从 JWT 解析,
|
||||
* 不接受任何前端传入的 uid 参数;不落任何明文(仅返回脱敏值与检测状态)。
|
||||
*/
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/user-secrets")
|
||||
@Tag(name = "用户密钥(桌面端自助)", description = "外观专利密钥 / 货源查询密钥的服务端存取与连通性检测。")
|
||||
public class UserApiSecretController {
|
||||
|
||||
private final UserApiSecretService userApiSecretService;
|
||||
private final AdminAuthSupport adminAuthSupport;
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "拉取当前用户密钥包", description = "返回模块脱敏值与检测状态;必填清单由服务端下发。")
|
||||
public ApiResponse<UserApiSecretBundleVo> bundle(HttpServletRequest request) {
|
||||
Long userId = currentUserId(request);
|
||||
return ApiResponse.success(userApiSecretService.bundle(userId));
|
||||
}
|
||||
|
||||
@PutMapping("/{moduleKey}")
|
||||
@Operation(summary = "保存密钥", description = "加密落库并重置检测状态为未检测,保存后客户端应立即触发一次检测。")
|
||||
public ApiResponse<UserApiSecretItemVo> save(
|
||||
HttpServletRequest request,
|
||||
@Parameter(description = "密钥模块 key", required = true) @PathVariable String moduleKey,
|
||||
@Valid @RequestBody UserApiSecretSaveRequest body) {
|
||||
Long userId = currentUserId(request);
|
||||
return ApiResponse.success("保存成功", userApiSecretService.save(userId, moduleKey, body.getValue()));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{moduleKey}")
|
||||
@Operation(summary = "清空密钥")
|
||||
public ApiResponse<Void> clear(
|
||||
HttpServletRequest request,
|
||||
@Parameter(description = "密钥模块 key", required = true) @PathVariable String moduleKey) {
|
||||
Long userId = currentUserId(request);
|
||||
userApiSecretService.clear(userId, moduleKey);
|
||||
return ApiResponse.success("已清空", null);
|
||||
}
|
||||
|
||||
@PostMapping("/{moduleKey}/check")
|
||||
@Operation(summary = "检测密钥连通性",
|
||||
description = "value 非空时检测输入值(不落库);为空时检测服务端已存密钥并把结果落库。")
|
||||
public ApiResponse<UserApiSecretCheckResultVo> check(
|
||||
HttpServletRequest request,
|
||||
@Parameter(description = "密钥模块 key", required = true) @PathVariable String moduleKey,
|
||||
@RequestBody(required = false) UserApiSecretCheckRequest body) {
|
||||
Long userId = currentUserId(request);
|
||||
String overrideValue = body == null ? null : body.getValue();
|
||||
return ApiResponse.success("检测完成", userApiSecretService.check(userId, moduleKey, overrideValue));
|
||||
}
|
||||
|
||||
@PostMapping("/migrate")
|
||||
@Operation(summary = "迁移本地密钥", description = "客户端首次接入时上报本地已保存的密钥,仅写入服务端空缺的模块,不覆盖已有值。")
|
||||
public ApiResponse<Map<String, Integer>> migrate(
|
||||
HttpServletRequest request,
|
||||
@Valid @RequestBody UserApiSecretMigrateRequest body) {
|
||||
Long userId = currentUserId(request);
|
||||
int migrated = userApiSecretService.migrateIfAbsent(userId, body.getItems());
|
||||
return ApiResponse.success("迁移完成", Map.of("migrated", migrated));
|
||||
}
|
||||
|
||||
@GetMapping("/proxy-balance")
|
||||
@Operation(summary = "查询代理套餐余量", description = "转发 jikip find-balance,返回套餐 IP 余量与账户余额。")
|
||||
public ApiResponse<UserApiSecretBalanceVo> proxyBalance(HttpServletRequest request) {
|
||||
currentUserId(request);
|
||||
return ApiResponse.success(userApiSecretService.proxyBalance());
|
||||
}
|
||||
|
||||
private Long currentUserId(HttpServletRequest request) {
|
||||
AdminUserEntity me = adminAuthSupport.requireUser(request);
|
||||
return me.getId();
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.nanri.aiimage.modules.usersecret.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.nanri.aiimage.modules.usersecret.model.entity.UserApiSecretEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface UserApiSecretMapper extends BaseMapper<UserApiSecretEntity> {
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package com.nanri.aiimage.modules.usersecret.model.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Schema(description = "后台密钥管理查询条件")
|
||||
public class AdminUserSecretQuery {
|
||||
|
||||
@Schema(description = "关键字:匹配用户名或用户ID")
|
||||
private String keyword;
|
||||
|
||||
@Schema(description = "密钥模块筛选:appearance-patent/similar-asin")
|
||||
private String moduleKey;
|
||||
|
||||
@Schema(description = "连通性状态筛选:unknown/passed/failed/error")
|
||||
private String checkStatus;
|
||||
|
||||
@Schema(description = "页码,从 1 开始")
|
||||
private Long page = 1L;
|
||||
|
||||
@Schema(description = "每页数量")
|
||||
private Long pageSize = 15L;
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package com.nanri.aiimage.modules.usersecret.model.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Schema(description = "密钥连通性检测请求")
|
||||
public class UserApiSecretCheckRequest {
|
||||
|
||||
@Schema(description = "待检测的密钥明文;为空时检测服务端已保存的密钥(结果落库),非空时仅检测输入值(不落库)")
|
||||
private String value;
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package com.nanri.aiimage.modules.usersecret.model.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Schema(description = "本地密钥迁移请求:客户端首次接入时把本地已保存的密钥上报服务端")
|
||||
public class UserApiSecretMigrateRequest {
|
||||
|
||||
@Valid
|
||||
@NotEmpty(message = "迁移项不能为空")
|
||||
private List<Item> items;
|
||||
|
||||
@Data
|
||||
@Schema(description = "单项迁移数据")
|
||||
public static class Item {
|
||||
|
||||
@Schema(description = "密钥模块 key:appearance-patent/similar-asin", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String moduleKey;
|
||||
|
||||
@Schema(description = "密钥明文", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String value;
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package com.nanri.aiimage.modules.usersecret.model.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Schema(description = "保存用户密钥请求")
|
||||
public class UserApiSecretSaveRequest {
|
||||
|
||||
@NotBlank(message = "密钥不能为空")
|
||||
@Schema(description = "密钥明文(服务端加密存储)", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String value;
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package com.nanri.aiimage.modules.usersecret.model.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("biz_user_api_secret")
|
||||
public class UserApiSecretEntity {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private Long userId;
|
||||
private String moduleKey;
|
||||
private String secretValue;
|
||||
private String checkStatus;
|
||||
private String checkCode;
|
||||
private String checkMessage;
|
||||
private Integer checkLatencyMs;
|
||||
private LocalDateTime checkedAt;
|
||||
private String source;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
package com.nanri.aiimage.modules.usersecret.model.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@Schema(description = "后台密钥管理列表项")
|
||||
public class AdminUserSecretItemVo {
|
||||
|
||||
@Schema(description = "记录主键")
|
||||
private Long id;
|
||||
|
||||
@Schema(description = "用户ID")
|
||||
private Long userId;
|
||||
|
||||
@Schema(description = "用户名")
|
||||
private String username;
|
||||
|
||||
@Schema(description = "密钥模块 key")
|
||||
private String moduleKey;
|
||||
|
||||
@Schema(description = "密钥模块显示名")
|
||||
private String moduleLabel;
|
||||
|
||||
@Schema(description = "脱敏值")
|
||||
private String masked;
|
||||
|
||||
@Schema(description = "是否已配置")
|
||||
private Boolean exists;
|
||||
|
||||
@Schema(description = "连通性状态")
|
||||
private String checkStatus;
|
||||
|
||||
@Schema(description = "检测结果码")
|
||||
private String checkCode;
|
||||
|
||||
@Schema(description = "检测结果说明")
|
||||
private String checkMessage;
|
||||
|
||||
@Schema(description = "检测耗时(毫秒)")
|
||||
private Integer checkLatencyMs;
|
||||
|
||||
@Schema(description = "最近检测时间")
|
||||
private LocalDateTime checkedAt;
|
||||
|
||||
@Schema(description = "写入来源:client/admin/migrated")
|
||||
private String source;
|
||||
|
||||
@Schema(description = "更新时间")
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package com.nanri.aiimage.modules.usersecret.model.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Schema(description = "后台密钥管理分页结果")
|
||||
public class AdminUserSecretPageVo {
|
||||
|
||||
@Schema(description = "列表项")
|
||||
private List<AdminUserSecretItemVo> items;
|
||||
|
||||
@Schema(description = "总条数")
|
||||
private Long total;
|
||||
|
||||
@Schema(description = "页码")
|
||||
private Long page;
|
||||
|
||||
@Schema(description = "每页数量")
|
||||
private Long pageSize;
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package com.nanri.aiimage.modules.usersecret.model.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Schema(description = "jikip 代理余量")
|
||||
public class UserApiSecretBalanceVo {
|
||||
|
||||
@Schema(description = "是否查询成功")
|
||||
private Boolean available;
|
||||
|
||||
@Schema(description = "套餐 IP 余量")
|
||||
private String surplus;
|
||||
|
||||
@Schema(description = "账户余额")
|
||||
private String balance;
|
||||
|
||||
@Schema(description = "失败原因(available=false 时)")
|
||||
private String message;
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package com.nanri.aiimage.modules.usersecret.model.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Schema(description = "当前用户密钥包:全量模块 + 完整性判定")
|
||||
public class UserApiSecretBundleVo {
|
||||
|
||||
@Schema(description = "各模块密钥项")
|
||||
private List<UserApiSecretItemVo> items;
|
||||
|
||||
@Schema(description = "必须配置的模块 key 列表(服务端下发,客户端不硬编码)")
|
||||
private List<String> requiredModules;
|
||||
|
||||
@Schema(description = "是否配置完整:全部 required 模块均检测通过(error 状态视为放行)")
|
||||
private Boolean complete;
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package com.nanri.aiimage.modules.usersecret.model.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@Schema(description = "密钥连通性检测结果")
|
||||
public class UserApiSecretCheckResultVo {
|
||||
|
||||
@Schema(description = "密钥模块 key")
|
||||
private String moduleKey;
|
||||
|
||||
@Schema(description = "连通性状态:passed/failed/error")
|
||||
private String checkStatus;
|
||||
|
||||
@Schema(description = "检测结果码")
|
||||
private String checkCode;
|
||||
|
||||
@Schema(description = "检测结果说明")
|
||||
private String checkMessage;
|
||||
|
||||
@Schema(description = "检测耗时(毫秒)")
|
||||
private Integer checkLatencyMs;
|
||||
|
||||
@Schema(description = "检测时间")
|
||||
private LocalDateTime checkedAt;
|
||||
|
||||
@Schema(description = "是否经代理发出")
|
||||
private Boolean viaProxy;
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package com.nanri.aiimage.modules.usersecret.model.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@Schema(description = "用户密钥项(永不下发明文)")
|
||||
public class UserApiSecretItemVo {
|
||||
|
||||
@Schema(description = "密钥模块 key")
|
||||
private String moduleKey;
|
||||
|
||||
@Schema(description = "密钥模块显示名")
|
||||
private String moduleLabel;
|
||||
|
||||
@Schema(description = "脱敏值,如 sk-a****1234")
|
||||
private String masked;
|
||||
|
||||
@Schema(description = "是否已配置")
|
||||
private Boolean exists;
|
||||
|
||||
@Schema(description = "连通性状态:unknown/passed/failed/error")
|
||||
private String checkStatus;
|
||||
|
||||
@Schema(description = "检测结果码")
|
||||
private String checkCode;
|
||||
|
||||
@Schema(description = "检测结果说明")
|
||||
private String checkMessage;
|
||||
|
||||
@Schema(description = "检测耗时(毫秒)")
|
||||
private Integer checkLatencyMs;
|
||||
|
||||
@Schema(description = "最近检测时间")
|
||||
private LocalDateTime checkedAt;
|
||||
|
||||
@Schema(description = "更新时间")
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package com.nanri.aiimage.modules.usersecret.service;
|
||||
|
||||
import com.nanri.aiimage.common.service.DistributedJobLockService;
|
||||
import com.nanri.aiimage.config.UserSecretProperties;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* 用户密钥每日连通性巡检:默认每天 04:30(Asia/Shanghai)跑一轮,
|
||||
* 双实例经 Redis 分布式锁互斥;单轮受条数与时间预算约束,超出顺延下一轮。
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class UserApiSecretCheckScheduler {
|
||||
|
||||
private static final String LOCK_NAME = "user-secret-daily-check";
|
||||
|
||||
private final UserApiSecretService userApiSecretService;
|
||||
private final DistributedJobLockService distributedJobLockService;
|
||||
private final UserSecretProperties properties;
|
||||
|
||||
@Scheduled(cron = "${aiimage.user-secret.check-cron:0 30 4 * * *}", zone = "Asia/Shanghai")
|
||||
public void dailyCheck() {
|
||||
if (!properties.isCheckEnabled()) {
|
||||
log.info("[user-secret] 定时巡检已关闭,跳过本轮");
|
||||
return;
|
||||
}
|
||||
var lock = distributedJobLockService.tryLock(LOCK_NAME, Duration.ofMinutes(30));
|
||||
if (lock == null) {
|
||||
log.info("[user-secret] 另一实例持有巡检锁,跳过本轮");
|
||||
return;
|
||||
}
|
||||
try (lock) {
|
||||
log.info("[user-secret] 每日巡检开始 maxRows={} budgetMinutes={}",
|
||||
properties.getCheckMaxRows(), properties.getCheckBudgetMinutes());
|
||||
UserApiSecretService.CheckSummary summary = userApiSecretService.checkAllForScheduler(
|
||||
properties.getCheckMaxRows(), properties.getCheckBudgetMinutes());
|
||||
log.info("[user-secret] 每日巡检完成 checked={} passed={} failed={} errors={} skipped={}",
|
||||
summary.checked(), summary.passed(), summary.failed(), summary.errors(), summary.skipped());
|
||||
} catch (Exception ex) {
|
||||
log.warn("[user-secret] 每日巡检异常终止 err={}", ex.getMessage(), ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
+267
@@ -0,0 +1,267 @@
|
||||
package com.nanri.aiimage.modules.usersecret.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.nanri.aiimage.config.AppearancePatentProperties;
|
||||
import com.nanri.aiimage.config.HttpClientPool;
|
||||
import com.nanri.aiimage.config.SimilarAsinProperties;
|
||||
import com.nanri.aiimage.modules.usersecret.client.JikipProxyClient;
|
||||
import com.nanri.aiimage.modules.usersecret.support.UserSecretModule;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 密钥连通性探测:调一次 LLM /v1/chat/completions,能访问通(2xx 且返回 choices)即通过。
|
||||
* 无副作用(落库由 UserApiSecretService 负责)、不重试;
|
||||
* 出口默认直连,配置了提取链接时优先经代理、代理网络不可达自动回退直连。
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class UserApiSecretCheckService {
|
||||
|
||||
public static final String STATUS_PASSED = "passed";
|
||||
public static final String STATUS_FAILED = "failed";
|
||||
public static final String STATUS_ERROR = "error";
|
||||
|
||||
public static final String CODE_OK = "ok";
|
||||
public static final String CODE_INVALID_KEY = "invalid_key";
|
||||
public static final String CODE_FORBIDDEN = "forbidden";
|
||||
public static final String CODE_BAD_REQUEST = "bad_request";
|
||||
public static final String CODE_RATE_LIMITED = "rate_limited";
|
||||
public static final String CODE_SERVER_ERROR = "server_error";
|
||||
public static final String CODE_NETWORK_ERROR = "network_error";
|
||||
public static final String CODE_PROVIDER_ERROR = "provider_error";
|
||||
|
||||
private static final MediaType APPLICATION_JSON_UTF8 = new MediaType("application", "json", StandardCharsets.UTF_8);
|
||||
private static final int READ_TIMEOUT_MILLIS = 15_000;
|
||||
private static final int MAX_RESPONSE_BYTES = 1024 * 1024;
|
||||
private static final int CHECK_MAX_TOKENS = 8;
|
||||
|
||||
private final AppearancePatentProperties appearancePatentProperties;
|
||||
private final SimilarAsinProperties similarAsinProperties;
|
||||
private final JikipProxyClient jikipProxyClient;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
private volatile RestClient directClient;
|
||||
|
||||
/** 探测入口:优先经代理(若配置提取链接),代理网络不可达回退直连。 */
|
||||
public CheckOutcome probe(UserSecretModule module, String plainApiKey) {
|
||||
String proxyUrl = jikipProxyClient.isExtractConfigured() ? jikipProxyClient.fetchProxyUrl() : null;
|
||||
if (proxyUrl != null) {
|
||||
CheckOutcome viaProxy = probeOnce(module, plainApiKey, proxyUrl, true);
|
||||
if (CODE_NETWORK_ERROR.equals(viaProxy.code())) {
|
||||
log.warn("[user-secret][check] 经代理检测网络不可达 module={} proxy={},回退直连重试",
|
||||
module.key(), proxyUrl);
|
||||
CheckOutcome direct = probeOnce(module, plainApiKey, null, false);
|
||||
return new CheckOutcome(
|
||||
direct.status(),
|
||||
direct.code(),
|
||||
direct.message() + "(代理不可用,已回退直连)",
|
||||
direct.latencyMs(),
|
||||
false);
|
||||
}
|
||||
return viaProxy;
|
||||
}
|
||||
return probeOnce(module, plainApiKey, null, false);
|
||||
}
|
||||
|
||||
private CheckOutcome probeOnce(UserSecretModule module, String plainApiKey, String proxyUrl, boolean viaProxy) {
|
||||
UserSecretModule.LlmTarget target = module.resolveLlmTarget(appearancePatentProperties, similarAsinProperties);
|
||||
String url = joinUrl(target.host(), "/v1/chat/completions");
|
||||
String key = stripBearer(plainApiKey);
|
||||
long startMillis = System.currentTimeMillis();
|
||||
String viaText = viaProxy ? "经代理" : "直连";
|
||||
try {
|
||||
StatusAndBody statusAndBody = clientFor(proxyUrl).post()
|
||||
.uri(url)
|
||||
.headers(headers -> {
|
||||
headers.setBearerAuth(key);
|
||||
headers.setContentType(APPLICATION_JSON_UTF8);
|
||||
headers.set(HttpHeaders.ACCEPT_CHARSET, StandardCharsets.UTF_8.name());
|
||||
})
|
||||
.body(buildCheckBody(target.model()))
|
||||
.exchange((request, response) -> new StatusAndBody(
|
||||
response.getStatusCode().value(),
|
||||
readResponseBodyBounded(response.getBody())));
|
||||
long latency = System.currentTimeMillis() - startMillis;
|
||||
CheckOutcome outcome = classify(statusAndBody.statusCode(), statusAndBody.body(), (int) latency, viaProxy);
|
||||
log.info("[user-secret][check] {}探测完成 module={} status={} code={} httpStatus={} latency={}ms",
|
||||
viaText, module.key(), outcome.status(), outcome.code(), statusAndBody.statusCode(), latency);
|
||||
return outcome;
|
||||
} catch (Exception ex) {
|
||||
long latency = System.currentTimeMillis() - startMillis;
|
||||
log.warn("[user-secret][check] {}探测异常 module={} latency={}ms err={}",
|
||||
viaText, module.key(), latency, ex.getMessage());
|
||||
return new CheckOutcome(STATUS_ERROR, CODE_NETWORK_ERROR,
|
||||
"网络不可达:" + rootCauseMessage(ex), (int) latency, viaProxy);
|
||||
}
|
||||
}
|
||||
|
||||
/** 按 HTTP 状态码与响应体分类检测结果(package-private 供单测覆盖分类矩阵)。 */
|
||||
CheckOutcome classify(int statusCode, String body, int latencyMs, boolean viaProxy) {
|
||||
String responseBody = body == null ? "" : body;
|
||||
if (statusCode >= 200 && statusCode < 300) {
|
||||
JsonNode root = parseJson(body);
|
||||
if (root != null) {
|
||||
JsonNode errorNode = root.path("error");
|
||||
if (!errorNode.isMissingNode() && !errorNode.isNull()) {
|
||||
String errorMessage = text(errorNode.path("message"));
|
||||
return new CheckOutcome(STATUS_FAILED, CODE_PROVIDER_ERROR,
|
||||
"上游返回异常:" + firstNonBlank(errorMessage, abbreviate(body, 200)), latencyMs, viaProxy);
|
||||
}
|
||||
JsonNode choices = root.path("choices");
|
||||
if (choices.isArray() && !choices.isEmpty()) {
|
||||
return new CheckOutcome(STATUS_PASSED, CODE_OK, "连通正常", latencyMs, viaProxy);
|
||||
}
|
||||
}
|
||||
return new CheckOutcome(STATUS_FAILED, CODE_PROVIDER_ERROR,
|
||||
"上游响应缺少 choices:" + abbreviate(body, 200), latencyMs, viaProxy);
|
||||
}
|
||||
return switch (statusCode) {
|
||||
case 401 -> new CheckOutcome(STATUS_FAILED, CODE_INVALID_KEY, "密钥无效(401)", latencyMs, viaProxy);
|
||||
case 403 -> new CheckOutcome(STATUS_FAILED, CODE_FORBIDDEN,
|
||||
"密钥被拒绝(403),可能额度不足或无权限", latencyMs, viaProxy);
|
||||
case 400, 404 -> new CheckOutcome(STATUS_FAILED, CODE_BAD_REQUEST,
|
||||
"请求被拒绝(" + statusCode + "):" + abbreviate(body, 200), latencyMs, viaProxy);
|
||||
case 429 -> new CheckOutcome(STATUS_ERROR, CODE_RATE_LIMITED, "触发限流(429),本次无法判定", latencyMs, viaProxy);
|
||||
default -> statusCode >= 500
|
||||
? new CheckOutcome(STATUS_ERROR, CODE_SERVER_ERROR,
|
||||
"上游异常(" + statusCode + "):" + abbreviate(body, 200), latencyMs, viaProxy)
|
||||
: new CheckOutcome(STATUS_ERROR, CODE_SERVER_ERROR,
|
||||
"未知响应(" + statusCode + ")", latencyMs, viaProxy);
|
||||
};
|
||||
}
|
||||
|
||||
private Map<String, Object> buildCheckBody(String model) {
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("model", model);
|
||||
body.put("stream", false);
|
||||
body.put("max_tokens", CHECK_MAX_TOKENS);
|
||||
List<Map<String, Object>> messages = new ArrayList<>(1);
|
||||
Map<String, Object> userMessage = new LinkedHashMap<>();
|
||||
userMessage.put("role", "user");
|
||||
userMessage.put("content", "ping");
|
||||
messages.add(userMessage);
|
||||
body.put("messages", messages);
|
||||
return body;
|
||||
}
|
||||
|
||||
private RestClient clientFor(String proxyUrl) {
|
||||
if (proxyUrl != null && !proxyUrl.isBlank()) {
|
||||
return RestClient.builder()
|
||||
.requestFactory(HttpClientPool.requestFactory(READ_TIMEOUT_MILLIS, proxyUrl))
|
||||
.build();
|
||||
}
|
||||
RestClient client = directClient;
|
||||
if (client != null) {
|
||||
return client;
|
||||
}
|
||||
synchronized (this) {
|
||||
if (directClient == null) {
|
||||
directClient = RestClient.builder()
|
||||
.requestFactory(HttpClientPool.requestFactory(READ_TIMEOUT_MILLIS))
|
||||
.build();
|
||||
}
|
||||
return directClient;
|
||||
}
|
||||
}
|
||||
|
||||
private JsonNode parseJson(String body) {
|
||||
try {
|
||||
return objectMapper.readTree(body);
|
||||
} catch (Exception ex) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String readResponseBodyBounded(InputStream inputStream) throws IOException {
|
||||
if (inputStream == null) {
|
||||
return "";
|
||||
}
|
||||
try (InputStream input = inputStream; ByteArrayOutputStream output = new ByteArrayOutputStream(8192)) {
|
||||
byte[] buffer = new byte[8192];
|
||||
int read;
|
||||
int total = 0;
|
||||
while ((read = input.read(buffer)) != -1) {
|
||||
if (read == 0) {
|
||||
continue;
|
||||
}
|
||||
if ((long) total + read > MAX_RESPONSE_BYTES) {
|
||||
throw new IOException("检测响应超过 " + MAX_RESPONSE_BYTES + " 字节");
|
||||
}
|
||||
output.write(buffer, 0, read);
|
||||
total += read;
|
||||
}
|
||||
return output.toString(StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
|
||||
private String joinUrl(String baseUrl, String path) {
|
||||
String base = baseUrl == null ? "" : baseUrl.trim();
|
||||
String suffix = path == null ? "" : path.trim();
|
||||
if (base.endsWith("/") && suffix.startsWith("/")) {
|
||||
return base + suffix.substring(1);
|
||||
}
|
||||
if (!base.endsWith("/") && !suffix.startsWith("/")) {
|
||||
return base + "/" + suffix;
|
||||
}
|
||||
return base + suffix;
|
||||
}
|
||||
|
||||
private String stripBearer(String token) {
|
||||
String normalized = token == null ? "" : token.trim();
|
||||
return normalized.regionMatches(true, 0, "Bearer ", 0, 7) ? normalized.substring(7).trim() : normalized;
|
||||
}
|
||||
|
||||
private String rootCauseMessage(Throwable throwable) {
|
||||
Throwable current = throwable;
|
||||
while (current.getCause() != null && current.getCause() != current) {
|
||||
current = current.getCause();
|
||||
}
|
||||
String message = current.getMessage();
|
||||
if (message == null || message.isBlank()) {
|
||||
return current.getClass().getSimpleName();
|
||||
}
|
||||
return current.getClass().getSimpleName() + ": " + message;
|
||||
}
|
||||
|
||||
private String firstNonBlank(String preferred, String fallback) {
|
||||
return preferred == null || preferred.isBlank() ? fallback : preferred.trim();
|
||||
}
|
||||
|
||||
private String text(JsonNode node) {
|
||||
if (node == null || node.isNull() || node.isMissingNode()) {
|
||||
return null;
|
||||
}
|
||||
return node.asText();
|
||||
}
|
||||
|
||||
private String abbreviate(String value, int maxLength) {
|
||||
String normalized = value == null ? "" : value.trim();
|
||||
if (normalized.length() <= maxLength) {
|
||||
return normalized;
|
||||
}
|
||||
return normalized.substring(0, maxLength) + "...";
|
||||
}
|
||||
|
||||
/** 探测结果(无副作用)。 */
|
||||
public record CheckOutcome(String status, String code, String message, Integer latencyMs, boolean viaProxy) {
|
||||
}
|
||||
|
||||
private record StatusAndBody(int statusCode, String body) {
|
||||
}
|
||||
}
|
||||
+539
@@ -0,0 +1,539 @@
|
||||
package com.nanri.aiimage.modules.usersecret.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.common.security.ShopCredentialCryptoService;
|
||||
import com.nanri.aiimage.modules.permission.mapper.AdminUserMapper;
|
||||
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
|
||||
import com.nanri.aiimage.modules.usersecret.client.JikipProxyClient;
|
||||
import com.nanri.aiimage.modules.usersecret.mapper.UserApiSecretMapper;
|
||||
import com.nanri.aiimage.modules.usersecret.model.dto.AdminUserSecretQuery;
|
||||
import com.nanri.aiimage.modules.usersecret.model.dto.UserApiSecretMigrateRequest;
|
||||
import com.nanri.aiimage.modules.usersecret.model.entity.UserApiSecretEntity;
|
||||
import com.nanri.aiimage.modules.usersecret.model.vo.AdminUserSecretItemVo;
|
||||
import com.nanri.aiimage.modules.usersecret.model.vo.AdminUserSecretPageVo;
|
||||
import com.nanri.aiimage.modules.usersecret.model.vo.UserApiSecretBalanceVo;
|
||||
import com.nanri.aiimage.modules.usersecret.model.vo.UserApiSecretBundleVo;
|
||||
import com.nanri.aiimage.modules.usersecret.model.vo.UserApiSecretCheckResultVo;
|
||||
import com.nanri.aiimage.modules.usersecret.model.vo.UserApiSecretItemVo;
|
||||
import com.nanri.aiimage.modules.usersecret.support.UserSecretModule;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 用户 API 密钥服务端存储:按登录用户绑定、AES 加密落库,
|
||||
* 提供完整性包、迁移、任务兜底读取、连通性检测落库与后台管理查询。
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class UserApiSecretService {
|
||||
|
||||
public static final String SOURCE_CLIENT = "client";
|
||||
public static final String SOURCE_ADMIN = "admin";
|
||||
public static final String SOURCE_MIGRATED = "migrated";
|
||||
|
||||
private static final String STATUS_UNKNOWN = "unknown";
|
||||
private static final int MASK_MIN_LENGTH = 8;
|
||||
private static final int MESSAGE_MAX_LENGTH = 500;
|
||||
|
||||
private final UserApiSecretMapper userApiSecretMapper;
|
||||
private final ShopCredentialCryptoService cryptoService;
|
||||
private final UserApiSecretCheckService checkService;
|
||||
private final JikipProxyClient jikipProxyClient;
|
||||
private final AdminUserMapper adminUserMapper;
|
||||
|
||||
/** 当前用户密钥包:全量模块 + 服务端下发的必填清单 + 完整性判定。 */
|
||||
public UserApiSecretBundleVo bundle(Long userId) {
|
||||
requireUserId(userId);
|
||||
List<UserApiSecretItemVo> items = new ArrayList<>();
|
||||
for (UserSecretModule module : UserSecretModule.values()) {
|
||||
items.add(toItem(module, selectOne(userId, module.key())));
|
||||
}
|
||||
UserApiSecretBundleVo vo = new UserApiSecretBundleVo();
|
||||
vo.setItems(items);
|
||||
vo.setRequiredModules(Arrays.stream(UserSecretModule.values()).map(UserSecretModule::key).toList());
|
||||
vo.setComplete(isComplete(items));
|
||||
return vo;
|
||||
}
|
||||
|
||||
/** 保存密钥:加密落库并重置检测状态为 unknown(保存后由客户端立即触发检测)。 */
|
||||
@Transactional
|
||||
public UserApiSecretItemVo save(Long userId, String moduleKey, String value) {
|
||||
requireUserId(userId);
|
||||
UserSecretModule module = requireModule(moduleKey);
|
||||
String plainValue = normalize(value);
|
||||
if (plainValue.isEmpty()) {
|
||||
throw new BusinessException("密钥不能为空");
|
||||
}
|
||||
upsert(userId, module.key(), plainValue, SOURCE_CLIENT);
|
||||
log.info("[user-secret] 密钥已保存 userId={} module={}", userId, module.key());
|
||||
return toItem(module, selectOne(userId, module.key()));
|
||||
}
|
||||
|
||||
/** 清空密钥。 */
|
||||
@Transactional
|
||||
public void clear(Long userId, String moduleKey) {
|
||||
requireUserId(userId);
|
||||
UserSecretModule module = requireModule(moduleKey);
|
||||
userApiSecretMapper.delete(new LambdaQueryWrapper<UserApiSecretEntity>()
|
||||
.eq(UserApiSecretEntity::getUserId, userId)
|
||||
.eq(UserApiSecretEntity::getModuleKey, module.key()));
|
||||
log.info("[user-secret] 密钥已清空 userId={} module={}", userId, module.key());
|
||||
}
|
||||
|
||||
/** 本地密钥首次迁移:只写服务端空缺的模块,绝不覆盖已有值;忽略已废弃模块 key(如专利汇)。 */
|
||||
@Transactional
|
||||
public int migrateIfAbsent(Long userId, List<UserApiSecretMigrateRequest.Item> items) {
|
||||
requireUserId(userId);
|
||||
if (items == null || items.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
int migrated = 0;
|
||||
for (UserApiSecretMigrateRequest.Item item : items) {
|
||||
if (item == null) {
|
||||
continue;
|
||||
}
|
||||
Optional<UserSecretModule> module = UserSecretModule.of(item.getModuleKey());
|
||||
String plainValue = normalize(item.getValue());
|
||||
if (module.isEmpty() || plainValue.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
UserApiSecretEntity existing = selectOne(userId, module.get().key());
|
||||
if (existing != null && hasText(existing.getSecretValue())) {
|
||||
continue;
|
||||
}
|
||||
upsert(userId, module.get().key(), plainValue, SOURCE_MIGRATED);
|
||||
migrated++;
|
||||
}
|
||||
log.info("[user-secret] 本地密钥迁移完成 userId={} 提交={} 实际写入={}", userId, items.size(), migrated);
|
||||
return migrated;
|
||||
}
|
||||
|
||||
/** 任务执行兜底读取明文:未配置/解密失败返回空串,绝不抛异常中断任务。 */
|
||||
public String findPlainValue(Long userId, String moduleKey) {
|
||||
if (userId == null || userId <= 0 || !hasText(moduleKey)) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
UserApiSecretEntity row = selectOne(userId, moduleKey.trim());
|
||||
if (row == null || !hasText(row.getSecretValue())) {
|
||||
return "";
|
||||
}
|
||||
return normalize(cryptoService.decrypt(row.getSecretValue()));
|
||||
} catch (Exception ex) {
|
||||
log.warn("[user-secret] 任务兜底读取密钥失败 userId={} module={} err={}", userId, moduleKey, ex.getMessage());
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/** 检测:传 overrideValue 时只检测输入值不落库;否则检测已存值并落库。 */
|
||||
public UserApiSecretCheckResultVo check(Long userId, String moduleKey, String overrideValue) {
|
||||
requireUserId(userId);
|
||||
UserSecretModule module = requireModule(moduleKey);
|
||||
String override = normalize(overrideValue);
|
||||
String plainKey = override;
|
||||
boolean persist = false;
|
||||
if (plainKey.isEmpty()) {
|
||||
UserApiSecretEntity row = selectOne(userId, module.key());
|
||||
if (row == null || !hasText(row.getSecretValue())) {
|
||||
throw new BusinessException("请先保存密钥后再检测");
|
||||
}
|
||||
plainKey = normalize(cryptoService.decrypt(row.getSecretValue()));
|
||||
if (plainKey.isEmpty()) {
|
||||
throw new BusinessException("密钥内容为空,请重新配置");
|
||||
}
|
||||
persist = true;
|
||||
}
|
||||
UserApiSecretCheckService.CheckOutcome outcome = checkService.probe(module, plainKey);
|
||||
UserApiSecretCheckResultVo vo = toCheckResult(module, outcome);
|
||||
if (persist) {
|
||||
applyCheckOutcome(userId, module.key(), outcome);
|
||||
vo.setCheckedAt(LocalDateTime.now());
|
||||
}
|
||||
log.info("[user-secret] 检测完成 userId={} module={} status={} code={} viaProxy={} latency={}ms persist={}",
|
||||
userId, module.key(), outcome.status(), outcome.code(), outcome.viaProxy(), outcome.latencyMs(), persist);
|
||||
return vo;
|
||||
}
|
||||
|
||||
/** jikip 代理余量(客户端设置弹窗展示)。 */
|
||||
public UserApiSecretBalanceVo proxyBalance() {
|
||||
return jikipProxyClient.fetchBalance();
|
||||
}
|
||||
|
||||
/** 后台分页:关键字匹配用户名或用户ID。 */
|
||||
public AdminUserSecretPageVo adminPage(AdminUserSecretQuery query) {
|
||||
AdminUserSecretQuery safeQuery = query == null ? new AdminUserSecretQuery() : query;
|
||||
long page = safeQuery.getPage() == null || safeQuery.getPage() < 1 ? 1L : safeQuery.getPage();
|
||||
long pageSize = safeQuery.getPageSize() == null || safeQuery.getPageSize() < 1
|
||||
? 15L : Math.min(safeQuery.getPageSize(), 100L);
|
||||
|
||||
LambdaQueryWrapper<UserApiSecretEntity> wrapper = new LambdaQueryWrapper<>();
|
||||
String keyword = normalize(safeQuery.getKeyword());
|
||||
if (!keyword.isEmpty()) {
|
||||
List<Long> userIds = resolveUserIdsByKeyword(keyword);
|
||||
if (userIds.isEmpty()) {
|
||||
return emptyPage(page, pageSize);
|
||||
}
|
||||
wrapper.in(UserApiSecretEntity::getUserId, userIds);
|
||||
}
|
||||
if (hasText(safeQuery.getModuleKey())) {
|
||||
wrapper.eq(UserApiSecretEntity::getModuleKey, safeQuery.getModuleKey().trim());
|
||||
}
|
||||
if (hasText(safeQuery.getCheckStatus())) {
|
||||
wrapper.eq(UserApiSecretEntity::getCheckStatus, safeQuery.getCheckStatus().trim());
|
||||
}
|
||||
wrapper.orderByDesc(UserApiSecretEntity::getUpdatedAt).orderByDesc(UserApiSecretEntity::getId);
|
||||
|
||||
Page<UserApiSecretEntity> result = userApiSecretMapper.selectPage(new Page<>(page, pageSize), wrapper);
|
||||
List<AdminUserSecretItemVo> items = new ArrayList<>(result.getRecords().size());
|
||||
for (UserApiSecretEntity row : result.getRecords()) {
|
||||
items.add(toAdminItem(row));
|
||||
}
|
||||
AdminUserSecretPageVo vo = new AdminUserSecretPageVo();
|
||||
vo.setItems(items);
|
||||
vo.setTotal(result.getTotal());
|
||||
vo.setPage(page);
|
||||
vo.setPageSize(pageSize);
|
||||
return vo;
|
||||
}
|
||||
|
||||
/** 后台:按记录 ID 立即检测并落库。 */
|
||||
public UserApiSecretCheckResultVo adminCheck(Long id) {
|
||||
UserApiSecretEntity row = requireById(id);
|
||||
Optional<UserSecretModule> module = UserSecretModule.of(row.getModuleKey());
|
||||
if (module.isEmpty()) {
|
||||
throw new BusinessException("密钥模块已下线:" + row.getModuleKey());
|
||||
}
|
||||
String plainKey = normalize(cryptoService.decrypt(row.getSecretValue()));
|
||||
if (plainKey.isEmpty()) {
|
||||
throw new BusinessException("密钥内容为空,请让用户重新配置");
|
||||
}
|
||||
UserApiSecretCheckService.CheckOutcome outcome = checkService.probe(module.get(), plainKey);
|
||||
applyCheckOutcome(row.getUserId(), module.get().key(), outcome);
|
||||
UserApiSecretCheckResultVo vo = toCheckResult(module.get(), outcome);
|
||||
vo.setCheckedAt(LocalDateTime.now());
|
||||
log.info("[user-secret] 后台检测完成 id={} userId={} module={} status={} code={}",
|
||||
id, row.getUserId(), module.get().key(), outcome.status(), outcome.code());
|
||||
return vo;
|
||||
}
|
||||
|
||||
/** 后台:清空指定记录。 */
|
||||
@Transactional
|
||||
public void adminClear(Long id) {
|
||||
UserApiSecretEntity row = requireById(id);
|
||||
userApiSecretMapper.deleteById(id);
|
||||
log.info("[user-secret] 后台清空密钥 id={} userId={} module={}", id, row.getUserId(), row.getModuleKey());
|
||||
}
|
||||
|
||||
/**
|
||||
* 定时巡检:遍历全部密钥逐条探测并更新状态。
|
||||
* 单轮受 maxRows 与时间预算约束,超出部分顺延下一轮;单条异常不影响整轮。
|
||||
*/
|
||||
public CheckSummary checkAllForScheduler(int maxRows, int budgetMinutes) {
|
||||
long deadline = System.currentTimeMillis() + Duration.ofMinutes(Math.max(1, budgetMinutes)).toMillis();
|
||||
int checked = 0;
|
||||
int passed = 0;
|
||||
int failed = 0;
|
||||
int errors = 0;
|
||||
int skipped = 0;
|
||||
long lastId = 0L;
|
||||
while (true) {
|
||||
List<UserApiSecretEntity> batch = userApiSecretMapper.selectList(new LambdaQueryWrapper<UserApiSecretEntity>()
|
||||
.gt(UserApiSecretEntity::getId, lastId)
|
||||
.orderByAsc(UserApiSecretEntity::getId)
|
||||
.last("limit 100"));
|
||||
if (batch.isEmpty()) {
|
||||
break;
|
||||
}
|
||||
for (UserApiSecretEntity row : batch) {
|
||||
lastId = row.getId();
|
||||
if (checked >= maxRows || System.currentTimeMillis() >= deadline) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
Optional<UserSecretModule> module = UserSecretModule.of(row.getModuleKey());
|
||||
if (module.isEmpty()) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
String plainKey = normalize(cryptoService.decrypt(row.getSecretValue()));
|
||||
if (plainKey.isEmpty()) {
|
||||
applyCheckOutcome(row.getUserId(), module.get().key(), new UserApiSecretCheckService.CheckOutcome(
|
||||
UserApiSecretCheckService.STATUS_FAILED,
|
||||
UserApiSecretCheckService.CODE_INVALID_KEY,
|
||||
"密钥内容为空,请重新配置", null, false));
|
||||
failed++;
|
||||
checked++;
|
||||
continue;
|
||||
}
|
||||
UserApiSecretCheckService.CheckOutcome outcome = checkService.probe(module.get(), plainKey);
|
||||
applyCheckOutcome(row.getUserId(), module.get().key(), outcome);
|
||||
checked++;
|
||||
if (UserApiSecretCheckService.STATUS_PASSED.equals(outcome.status())) {
|
||||
passed++;
|
||||
} else if (UserApiSecretCheckService.STATUS_FAILED.equals(outcome.status())) {
|
||||
failed++;
|
||||
} else {
|
||||
errors++;
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
errors++;
|
||||
log.warn("[user-secret] 巡检单条失败 id={} userId={} module={} err={}",
|
||||
row.getId(), row.getUserId(), row.getModuleKey(), ex.getMessage());
|
||||
}
|
||||
sleepQuietly(200L);
|
||||
}
|
||||
if (checked >= maxRows || System.currentTimeMillis() >= deadline) {
|
||||
Long remaining = userApiSecretMapper.selectCount(new LambdaQueryWrapper<UserApiSecretEntity>()
|
||||
.gt(UserApiSecretEntity::getId, lastId));
|
||||
skipped += remaining == null ? 0 : remaining.intValue();
|
||||
break;
|
||||
}
|
||||
}
|
||||
CheckSummary summary = new CheckSummary(checked, passed, failed, errors, skipped);
|
||||
log.info("[user-secret] 巡检结束 checked={} passed={} failed={} errors={} skipped={}",
|
||||
summary.checked(), summary.passed(), summary.failed(), summary.errors(), summary.skipped());
|
||||
return summary;
|
||||
}
|
||||
|
||||
private void upsert(Long userId, String moduleKey, String plainValue, String source) {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
UserApiSecretEntity existing = selectOne(userId, moduleKey);
|
||||
UserApiSecretEntity row = existing == null ? new UserApiSecretEntity() : existing;
|
||||
row.setUserId(userId);
|
||||
row.setModuleKey(moduleKey);
|
||||
row.setSecretValue(cryptoService.encrypt(plainValue));
|
||||
row.setCheckStatus(STATUS_UNKNOWN);
|
||||
row.setCheckCode("");
|
||||
row.setCheckMessage("");
|
||||
row.setCheckLatencyMs(null);
|
||||
row.setCheckedAt(null);
|
||||
row.setSource(source);
|
||||
row.setUpdatedAt(now);
|
||||
if (row.getId() == null) {
|
||||
row.setCreatedAt(now);
|
||||
userApiSecretMapper.insert(row);
|
||||
} else {
|
||||
userApiSecretMapper.updateById(row);
|
||||
}
|
||||
}
|
||||
|
||||
private void applyCheckOutcome(Long userId, String moduleKey, UserApiSecretCheckService.CheckOutcome outcome) {
|
||||
try {
|
||||
UserApiSecretEntity row = selectOne(userId, moduleKey);
|
||||
if (row == null) {
|
||||
return;
|
||||
}
|
||||
row.setCheckStatus(outcome.status());
|
||||
row.setCheckCode(outcome.code());
|
||||
row.setCheckMessage(truncate(outcome.message(), MESSAGE_MAX_LENGTH));
|
||||
row.setCheckLatencyMs(outcome.latencyMs());
|
||||
row.setCheckedAt(LocalDateTime.now());
|
||||
row.setUpdatedAt(LocalDateTime.now());
|
||||
userApiSecretMapper.updateById(row);
|
||||
} catch (Exception ex) {
|
||||
log.warn("[user-secret] 检测状态落库失败 userId={} module={} err={}", userId, moduleKey, ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private UserApiSecretEntity selectOne(Long userId, String moduleKey) {
|
||||
return userApiSecretMapper.selectOne(new LambdaQueryWrapper<UserApiSecretEntity>()
|
||||
.eq(UserApiSecretEntity::getUserId, userId)
|
||||
.eq(UserApiSecretEntity::getModuleKey, moduleKey)
|
||||
.last("limit 1"));
|
||||
}
|
||||
|
||||
private UserApiSecretEntity requireById(Long id) {
|
||||
if (id == null || id <= 0) {
|
||||
throw new BusinessException("记录 ID 不合法");
|
||||
}
|
||||
UserApiSecretEntity row = userApiSecretMapper.selectById(id);
|
||||
if (row == null) {
|
||||
throw new BusinessException("密钥记录不存在");
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
private List<Long> resolveUserIdsByKeyword(String keyword) {
|
||||
Set<Long> userIds = new LinkedHashSet<>();
|
||||
if (keyword.matches("\\d+")) {
|
||||
try {
|
||||
userIds.add(Long.parseLong(keyword));
|
||||
} catch (NumberFormatException ignored) {
|
||||
// 超出 long 范围的关键字按纯文本处理
|
||||
}
|
||||
}
|
||||
List<AdminUserEntity> matched = adminUserMapper.selectList(new LambdaQueryWrapper<AdminUserEntity>()
|
||||
.like(AdminUserEntity::getUsername, keyword)
|
||||
.last("limit 200"));
|
||||
for (AdminUserEntity user : matched) {
|
||||
if (user.getId() != null) {
|
||||
userIds.add(user.getId());
|
||||
}
|
||||
}
|
||||
return new ArrayList<>(userIds);
|
||||
}
|
||||
|
||||
private AdminUserSecretItemVo toAdminItem(UserApiSecretEntity row) {
|
||||
AdminUserSecretItemVo vo = new AdminUserSecretItemVo();
|
||||
vo.setId(row.getId());
|
||||
vo.setUserId(row.getUserId());
|
||||
vo.setModuleKey(row.getModuleKey());
|
||||
UserSecretModule.of(row.getModuleKey())
|
||||
.ifPresentOrElse(module -> vo.setModuleLabel(module.label()),
|
||||
() -> vo.setModuleLabel(row.getModuleKey()));
|
||||
String plain = decryptQuietly(row.getSecretValue());
|
||||
vo.setMasked(mask(plain));
|
||||
vo.setExists(hasText(plain));
|
||||
vo.setCheckStatus(row.getCheckStatus());
|
||||
vo.setCheckCode(row.getCheckCode());
|
||||
vo.setCheckMessage(row.getCheckMessage());
|
||||
vo.setCheckLatencyMs(row.getCheckLatencyMs());
|
||||
vo.setCheckedAt(row.getCheckedAt());
|
||||
vo.setSource(row.getSource());
|
||||
vo.setUpdatedAt(row.getUpdatedAt());
|
||||
AdminUserEntity user = row.getUserId() == null ? null : adminUserMapper.selectById(row.getUserId());
|
||||
vo.setUsername(user == null ? "" : user.getUsername());
|
||||
return vo;
|
||||
}
|
||||
|
||||
private UserApiSecretItemVo toItem(UserSecretModule module, UserApiSecretEntity row) {
|
||||
UserApiSecretItemVo vo = new UserApiSecretItemVo();
|
||||
vo.setModuleKey(module.key());
|
||||
vo.setModuleLabel(module.label());
|
||||
if (row == null) {
|
||||
vo.setMasked("");
|
||||
vo.setExists(false);
|
||||
vo.setCheckStatus(STATUS_UNKNOWN);
|
||||
vo.setCheckCode("");
|
||||
vo.setCheckMessage("");
|
||||
return vo;
|
||||
}
|
||||
String plain = decryptQuietly(row.getSecretValue());
|
||||
vo.setMasked(mask(plain));
|
||||
vo.setExists(hasText(plain));
|
||||
vo.setCheckStatus(hasText(row.getCheckStatus()) ? row.getCheckStatus() : STATUS_UNKNOWN);
|
||||
vo.setCheckCode(row.getCheckCode());
|
||||
vo.setCheckMessage(row.getCheckMessage());
|
||||
vo.setCheckLatencyMs(row.getCheckLatencyMs());
|
||||
vo.setCheckedAt(row.getCheckedAt());
|
||||
vo.setUpdatedAt(row.getUpdatedAt());
|
||||
return vo;
|
||||
}
|
||||
|
||||
private UserApiSecretCheckResultVo toCheckResult(UserSecretModule module,
|
||||
UserApiSecretCheckService.CheckOutcome outcome) {
|
||||
UserApiSecretCheckResultVo vo = new UserApiSecretCheckResultVo();
|
||||
vo.setModuleKey(module.key());
|
||||
vo.setCheckStatus(outcome.status());
|
||||
vo.setCheckCode(outcome.code());
|
||||
vo.setCheckMessage(truncate(outcome.message(), MESSAGE_MAX_LENGTH));
|
||||
vo.setCheckLatencyMs(outcome.latencyMs());
|
||||
vo.setViaProxy(outcome.viaProxy());
|
||||
return vo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 完整性:全部必填模块均已配置且检测状态为 passed;
|
||||
* error(限流/上游异常/网络不可达等无法判定)视为放行,避免上游抖动把全体客户端锁死。
|
||||
*/
|
||||
private boolean isComplete(List<UserApiSecretItemVo> items) {
|
||||
for (UserApiSecretItemVo item : items) {
|
||||
if (!Boolean.TRUE.equals(item.getExists())) {
|
||||
return false;
|
||||
}
|
||||
String status = item.getCheckStatus();
|
||||
if (UserApiSecretCheckService.STATUS_PASSED.equals(status)
|
||||
|| UserApiSecretCheckService.STATUS_ERROR.equals(status)) {
|
||||
continue;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private AdminUserSecretPageVo emptyPage(long page, long pageSize) {
|
||||
AdminUserSecretPageVo vo = new AdminUserSecretPageVo();
|
||||
vo.setItems(new ArrayList<>());
|
||||
vo.setTotal(0L);
|
||||
vo.setPage(page);
|
||||
vo.setPageSize(pageSize);
|
||||
return vo;
|
||||
}
|
||||
|
||||
private UserSecretModule requireModule(String moduleKey) {
|
||||
return UserSecretModule.of(moduleKey)
|
||||
.orElseThrow(() -> new BusinessException("不支持的密钥模块:" + moduleKey));
|
||||
}
|
||||
|
||||
private void requireUserId(Long userId) {
|
||||
if (userId == null || userId <= 0) {
|
||||
throw new BusinessException("用户 ID 不合法");
|
||||
}
|
||||
}
|
||||
|
||||
private String decryptQuietly(String cipherText) {
|
||||
if (!hasText(cipherText)) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
return normalize(cryptoService.decrypt(cipherText));
|
||||
} catch (Exception ex) {
|
||||
log.warn("[user-secret] 解密失败,按未配置处理 err={}", ex.getMessage());
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private String mask(String value) {
|
||||
if (!hasText(value)) {
|
||||
return "";
|
||||
}
|
||||
String text = value.trim();
|
||||
if (text.length() <= MASK_MIN_LENGTH) {
|
||||
return "****";
|
||||
}
|
||||
return text.substring(0, 4) + "****" + text.substring(text.length() - 4);
|
||||
}
|
||||
|
||||
private String truncate(String value, int maxLength) {
|
||||
String normalized = value == null ? "" : value.trim();
|
||||
if (normalized.length() <= maxLength) {
|
||||
return normalized;
|
||||
}
|
||||
return normalized.substring(0, maxLength);
|
||||
}
|
||||
|
||||
private void sleepQuietly(long millis) {
|
||||
try {
|
||||
Thread.sleep(millis);
|
||||
} catch (InterruptedException interruptedException) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
|
||||
private String normalize(String value) {
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
|
||||
private boolean hasText(String value) {
|
||||
return value != null && !value.trim().isEmpty();
|
||||
}
|
||||
|
||||
/** 巡检统计。 */
|
||||
public record CheckSummary(int checked, int passed, int failed, int errors, int skipped) {
|
||||
}
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
package com.nanri.aiimage.modules.usersecret.support;
|
||||
|
||||
import com.nanri.aiimage.config.AppearancePatentProperties;
|
||||
import com.nanri.aiimage.config.SimilarAsinProperties;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 用户密钥模块:key / 显示名 / 检测目标(LLM host + model)的唯一来源。
|
||||
* 新增密钥模块时在此登记,控制器与服务层不再散落字符串。
|
||||
*/
|
||||
public enum UserSecretModule {
|
||||
|
||||
APPEARANCE_PATENT("appearance-patent", "外观专利密钥"),
|
||||
SIMILAR_ASIN("similar-asin", "货源查询密钥");
|
||||
|
||||
private final String key;
|
||||
private final String label;
|
||||
|
||||
UserSecretModule(String key, String label) {
|
||||
this.key = key;
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
public String key() {
|
||||
return key;
|
||||
}
|
||||
|
||||
public String label() {
|
||||
return label;
|
||||
}
|
||||
|
||||
/** 检测目标:LLM 主机 + 模型(取各模块自身配置,默认同为 ai.t8star.org)。 */
|
||||
public LlmTarget resolveLlmTarget(AppearancePatentProperties appearancePatentProperties,
|
||||
SimilarAsinProperties similarAsinProperties) {
|
||||
return switch (this) {
|
||||
case APPEARANCE_PATENT -> new LlmTarget(
|
||||
appearancePatentProperties.getLlmHost(),
|
||||
appearancePatentProperties.getTitleModel());
|
||||
case SIMILAR_ASIN -> new LlmTarget(
|
||||
similarAsinProperties.getLlmHost(),
|
||||
similarAsinProperties.getLlmCategoryModel());
|
||||
};
|
||||
}
|
||||
|
||||
public static Optional<UserSecretModule> of(String key) {
|
||||
if (key == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
String normalized = key.trim();
|
||||
for (UserSecretModule module : values()) {
|
||||
if (module.key.equalsIgnoreCase(normalized)) {
|
||||
return Optional.of(module);
|
||||
}
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
public record LlmTarget(String host, String model) {
|
||||
}
|
||||
}
|
||||
@@ -308,6 +308,16 @@ aiimage:
|
||||
archive-connect-timeout-millis: ${AIIMAGE_IMAGE_VIDEO_ARCHIVE_CONNECT_TIMEOUT_MILLIS:10000}
|
||||
archive-read-timeout-millis: ${AIIMAGE_IMAGE_VIDEO_ARCHIVE_READ_TIMEOUT_MILLIS:600000}
|
||||
archive-max-attempts: ${AIIMAGE_IMAGE_VIDEO_ARCHIVE_MAX_ATTEMPTS:3}
|
||||
user-secret:
|
||||
check-enabled: ${AIIMAGE_USER_SECRET_CHECK_ENABLED:true}
|
||||
check-cron: ${AIIMAGE_USER_SECRET_CHECK_CRON:0 30 4 * * *}
|
||||
check-max-rows: ${AIIMAGE_USER_SECRET_CHECK_MAX_ROWS:500}
|
||||
check-budget-minutes: ${AIIMAGE_USER_SECRET_CHECK_BUDGET_MINUTES:20}
|
||||
# 检测出口代理提取链接:留空=直连;配置后检测优先经代理、失败回退直连
|
||||
check-proxy-extract-url: ${AIIMAGE_USER_SECRET_CHECK_PROXY_EXTRACT_URL:}
|
||||
jikip-balance-url: ${AIIMAGE_USER_SECRET_JIKIP_BALANCE_URL:https://api.jikip.com/find-balance}
|
||||
jikip-plan-id: ${AIIMAGE_USER_SECRET_JIKIP_PLAN_ID:}
|
||||
jikip-user-id: ${AIIMAGE_USER_SECRET_JIKIP_USER_ID:}
|
||||
security:
|
||||
shop-credential-key: ${AIIMAGE_SHOP_CREDENTIAL_KEY:change-me-shop-credential-key}
|
||||
internal-token: ${AIIMAGE_INTERNAL_TOKEN:}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
-- V115: 用户 API 密钥服务端化(外观专利密钥 / 货源查询密钥)
|
||||
-- 密钥从客户端本地存储迁移到服务端,按登录用户绑定、加密存储;
|
||||
-- 同时记录连通性检测状态,供后台「密钥管理」页展示与每日定时巡检更新。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `biz_user_api_secret` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT,
|
||||
`user_id` BIGINT NOT NULL COMMENT '用户ID(users.id)',
|
||||
`module_key` VARCHAR(64) NOT NULL COMMENT '密钥模块:appearance-patent/similar-asin',
|
||||
`secret_value` VARCHAR(2048) NOT NULL COMMENT '密钥密文(AES 加密)',
|
||||
`check_status` VARCHAR(16) NOT NULL DEFAULT 'unknown' COMMENT '连通性状态:unknown/passed/failed/error',
|
||||
`check_code` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '检测结果码:ok/invalid_key/forbidden/bad_request/rate_limited/server_error/network_error/provider_error',
|
||||
`check_message` VARCHAR(512) NOT NULL DEFAULT '' COMMENT '检测结果说明',
|
||||
`check_latency_ms` INT NULL COMMENT '检测耗时(毫秒)',
|
||||
`checked_at` DATETIME NULL COMMENT '最近检测时间',
|
||||
`source` VARCHAR(16) NOT NULL DEFAULT 'client' COMMENT '写入来源:client/admin/migrated',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_user_module` (`user_id`, `module_key`),
|
||||
KEY `idx_check_status` (`check_status`),
|
||||
KEY `idx_checked_at` (`checked_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户 API 密钥(服务端存储,按用户绑定)';
|
||||
|
||||
-- 后台菜单:密钥管理(挂在「账号与权限」分组下;幂等,仅当 column_key 不存在时插入)
|
||||
INSERT INTO columns (name, column_key, menu_type, route_path, sort_order, parent_id)
|
||||
SELECT '密钥管理', 'admin_user_secrets', 'admin', 'account/user-secrets', 41, parent.id
|
||||
FROM columns parent
|
||||
WHERE parent.column_key = 'admin_group_account'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM columns WHERE column_key = 'admin_user_secrets'
|
||||
);
|
||||
+5
-2
@@ -26,6 +26,7 @@ import com.nanri.aiimage.modules.task.service.TaskFileJobService;
|
||||
import com.nanri.aiimage.modules.task.service.TaskProgressLightAssembler;
|
||||
import com.nanri.aiimage.modules.task.service.TaskProgressSnapshotService;
|
||||
import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService;
|
||||
import com.nanri.aiimage.modules.usersecret.service.UserApiSecretService;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.apache.poi.ss.usermodel.Row;
|
||||
import org.apache.poi.ss.usermodel.Sheet;
|
||||
@@ -102,7 +103,8 @@ class AppearancePatentTaskServiceDelegationTest {
|
||||
properties, taskFileJobService, taskProgressSnapshotService,
|
||||
transientPayloadStorageService, transactionManager, distributedJobLockService,
|
||||
taskDistributedLockService, instanceMetadata,
|
||||
mock(TaskProgressLightAssembler.class));
|
||||
mock(TaskProgressLightAssembler.class),
|
||||
mock(UserApiSecretService.class));
|
||||
}
|
||||
|
||||
private AppearancePatentTaskService serviceWithoutTransactionManager() {
|
||||
@@ -112,7 +114,8 @@ class AppearancePatentTaskServiceDelegationTest {
|
||||
properties, taskFileJobService, taskProgressSnapshotService,
|
||||
transientPayloadStorageService, null, distributedJobLockService,
|
||||
taskDistributedLockService, instanceMetadata,
|
||||
mock(TaskProgressLightAssembler.class));
|
||||
mock(TaskProgressLightAssembler.class),
|
||||
mock(UserApiSecretService.class));
|
||||
}
|
||||
|
||||
// ---------- 1 签名不变 ----------
|
||||
|
||||
+3
-1
@@ -26,6 +26,7 @@ import com.nanri.aiimage.modules.task.service.TaskFileJobService;
|
||||
import com.nanri.aiimage.modules.task.service.TaskProgressLightAssembler;
|
||||
import com.nanri.aiimage.modules.task.service.TaskProgressSnapshotService;
|
||||
import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService;
|
||||
import com.nanri.aiimage.modules.usersecret.service.UserApiSecretService;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
@@ -211,7 +212,8 @@ class AppearancePatentTaskServiceHistoryBatchTest {
|
||||
properties, taskFileJobService, taskProgressSnapshotService,
|
||||
transientPayloadStorageService, transactionManager, distributedJobLockService,
|
||||
taskDistributedLockService, instanceMetadata,
|
||||
mock(TaskProgressLightAssembler.class));
|
||||
mock(TaskProgressLightAssembler.class),
|
||||
mock(UserApiSecretService.class));
|
||||
}
|
||||
|
||||
private static FileResultEntity result(Long id, Long taskId, Long userId, LocalDateTime createdAt) {
|
||||
|
||||
+3
-1
@@ -32,6 +32,7 @@ import com.nanri.aiimage.modules.task.service.TaskFileJobService;
|
||||
import com.nanri.aiimage.modules.task.service.TaskProgressLightAssembler;
|
||||
import com.nanri.aiimage.modules.task.service.TaskProgressSnapshotService;
|
||||
import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService;
|
||||
import com.nanri.aiimage.modules.usersecret.service.UserApiSecretService;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
@@ -300,7 +301,8 @@ class RollbackSemanticsContractTest {
|
||||
mock(AppearancePatentTaskCacheService.class), mock(com.nanri.aiimage.config.AppearancePatentProperties.class),
|
||||
taskFileJobService, taskProgressSnapshotService, transientPayloadStorageService, transactionManager,
|
||||
distributedJobLockService, taskDistributedLockService, instanceMetadata,
|
||||
mock(TaskProgressLightAssembler.class));
|
||||
mock(TaskProgressLightAssembler.class),
|
||||
mock(UserApiSecretService.class));
|
||||
}
|
||||
|
||||
private SimilarAsinSubmitResultRequest request() {
|
||||
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
package com.nanri.aiimage.modules.usersecret.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.nanri.aiimage.config.AppearancePatentProperties;
|
||||
import com.nanri.aiimage.config.SimilarAsinProperties;
|
||||
import com.nanri.aiimage.modules.usersecret.client.JikipProxyClient;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
/** 检测结果分类矩阵:passed=放行,failed=拦截,error=无法判定(放行但警示)。 */
|
||||
class UserApiSecretCheckServiceTest {
|
||||
|
||||
private final UserApiSecretCheckService service = new UserApiSecretCheckService(
|
||||
new AppearancePatentProperties(),
|
||||
new SimilarAsinProperties(),
|
||||
mock(JikipProxyClient.class),
|
||||
new ObjectMapper());
|
||||
|
||||
@Test
|
||||
void classifyPassedWhenChoicesPresent() {
|
||||
UserApiSecretCheckService.CheckOutcome outcome =
|
||||
service.classify(200, "{\"choices\":[{\"message\":{\"content\":\"ok\"}}]}", 120, false);
|
||||
|
||||
assertThat(outcome.status()).isEqualTo(UserApiSecretCheckService.STATUS_PASSED);
|
||||
assertThat(outcome.code()).isEqualTo(UserApiSecretCheckService.CODE_OK);
|
||||
assertThat(outcome.latencyMs()).isEqualTo(120);
|
||||
assertThat(outcome.viaProxy()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void classifyMissingChoicesOnSuccessIsFailed() {
|
||||
UserApiSecretCheckService.CheckOutcome outcome = service.classify(200, "{}", 90, false);
|
||||
|
||||
assertThat(outcome.status()).isEqualTo(UserApiSecretCheckService.STATUS_FAILED);
|
||||
assertThat(outcome.code()).isEqualTo(UserApiSecretCheckService.CODE_PROVIDER_ERROR);
|
||||
}
|
||||
|
||||
@Test
|
||||
void classifyErrorNodeIsFailed() {
|
||||
UserApiSecretCheckService.CheckOutcome outcome =
|
||||
service.classify(200, "{\"error\":{\"message\":\"quota exceeded\"}}", 88, false);
|
||||
|
||||
assertThat(outcome.status()).isEqualTo(UserApiSecretCheckService.STATUS_FAILED);
|
||||
assertThat(outcome.code()).isEqualTo(UserApiSecretCheckService.CODE_PROVIDER_ERROR);
|
||||
assertThat(outcome.message()).contains("quota exceeded");
|
||||
}
|
||||
|
||||
@Test
|
||||
void classifyInvalidKeyOn401() {
|
||||
UserApiSecretCheckService.CheckOutcome outcome = service.classify(401, "unauthorized", 45, true);
|
||||
|
||||
assertThat(outcome.status()).isEqualTo(UserApiSecretCheckService.STATUS_FAILED);
|
||||
assertThat(outcome.code()).isEqualTo(UserApiSecretCheckService.CODE_INVALID_KEY);
|
||||
assertThat(outcome.viaProxy()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void classifyForbiddenOn403() {
|
||||
UserApiSecretCheckService.CheckOutcome outcome = service.classify(403, "forbidden", 50, false);
|
||||
|
||||
assertThat(outcome.status()).isEqualTo(UserApiSecretCheckService.STATUS_FAILED);
|
||||
assertThat(outcome.code()).isEqualTo(UserApiSecretCheckService.CODE_FORBIDDEN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void classifyBadRequestOn400() {
|
||||
UserApiSecretCheckService.CheckOutcome outcome = service.classify(400, "bad", 30, false);
|
||||
|
||||
assertThat(outcome.status()).isEqualTo(UserApiSecretCheckService.STATUS_FAILED);
|
||||
assertThat(outcome.code()).isEqualTo(UserApiSecretCheckService.CODE_BAD_REQUEST);
|
||||
}
|
||||
|
||||
@Test
|
||||
void classifyRateLimitedIsErrorNotFailed() {
|
||||
UserApiSecretCheckService.CheckOutcome outcome = service.classify(429, "too many", 20, false);
|
||||
|
||||
assertThat(outcome.status()).isEqualTo(UserApiSecretCheckService.STATUS_ERROR);
|
||||
assertThat(outcome.code()).isEqualTo(UserApiSecretCheckService.CODE_RATE_LIMITED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void classifyServerErrorIsError() {
|
||||
UserApiSecretCheckService.CheckOutcome outcome = service.classify(503, "unavailable", 60, false);
|
||||
|
||||
assertThat(outcome.status()).isEqualTo(UserApiSecretCheckService.STATUS_ERROR);
|
||||
assertThat(outcome.code()).isEqualTo(UserApiSecretCheckService.CODE_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
package com.nanri.aiimage.modules.usersecret.service;
|
||||
|
||||
import com.nanri.aiimage.common.security.ShopCredentialCryptoService;
|
||||
import com.nanri.aiimage.modules.permission.mapper.AdminUserMapper;
|
||||
import com.nanri.aiimage.modules.usersecret.client.JikipProxyClient;
|
||||
import com.nanri.aiimage.modules.usersecret.mapper.UserApiSecretMapper;
|
||||
import com.nanri.aiimage.modules.usersecret.model.dto.UserApiSecretMigrateRequest;
|
||||
import com.nanri.aiimage.modules.usersecret.model.entity.UserApiSecretEntity;
|
||||
import com.nanri.aiimage.modules.usersecret.model.vo.UserApiSecretBundleVo;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class UserApiSecretServiceTest {
|
||||
|
||||
private final UserApiSecretMapper mapper = mock(UserApiSecretMapper.class);
|
||||
private final ShopCredentialCryptoService crypto = mock(ShopCredentialCryptoService.class);
|
||||
private final UserApiSecretCheckService checkService = mock(UserApiSecretCheckService.class);
|
||||
private final JikipProxyClient jikipProxyClient = mock(JikipProxyClient.class);
|
||||
private final AdminUserMapper adminUserMapper = mock(AdminUserMapper.class);
|
||||
|
||||
private UserApiSecretService newService() {
|
||||
when(crypto.encrypt(anyString())).thenAnswer(inv -> "enc:" + inv.getArgument(0, String.class));
|
||||
when(crypto.decrypt(anyString())).thenAnswer(inv -> {
|
||||
String value = inv.getArgument(0, String.class);
|
||||
return value.startsWith("enc:") ? value.substring(4) : value;
|
||||
});
|
||||
return new UserApiSecretService(mapper, crypto, checkService, jikipProxyClient, adminUserMapper);
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveEncryptsValueAndResetsCheckState() {
|
||||
UserApiSecretService service = newService();
|
||||
UserApiSecretEntity existing = new UserApiSecretEntity();
|
||||
existing.setId(5L);
|
||||
existing.setUserId(7L);
|
||||
existing.setModuleKey("appearance-patent");
|
||||
existing.setSecretValue("enc:old-key");
|
||||
existing.setCheckStatus("passed");
|
||||
when(mapper.selectOne(any())).thenReturn(existing);
|
||||
|
||||
service.save(7L, "appearance-patent", "sk-new");
|
||||
|
||||
ArgumentCaptor<UserApiSecretEntity> captor = ArgumentCaptor.forClass(UserApiSecretEntity.class);
|
||||
verify(mapper).updateById(captor.capture());
|
||||
UserApiSecretEntity updated = captor.getValue();
|
||||
assertThat(updated.getSecretValue()).isEqualTo("enc:sk-new");
|
||||
assertThat(updated.getCheckStatus()).isEqualTo("unknown");
|
||||
assertThat(updated.getCheckedAt()).isNull();
|
||||
assertThat(updated.getCheckCode()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void findPlainValueReturnsEmptyWhenDecryptFails() {
|
||||
UserApiSecretService service = newService();
|
||||
UserApiSecretEntity row = new UserApiSecretEntity();
|
||||
row.setSecretValue("broken");
|
||||
when(mapper.selectOne(any())).thenReturn(row);
|
||||
when(crypto.decrypt("broken")).thenThrow(new IllegalStateException("解密失败"));
|
||||
|
||||
assertThat(service.findPlainValue(7L, "appearance-patent")).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void findPlainValueReturnsEmptyForInvalidUserId() {
|
||||
UserApiSecretService service = newService();
|
||||
assertThat(service.findPlainValue(null, "appearance-patent")).isEmpty();
|
||||
assertThat(service.findPlainValue(0L, "appearance-patent")).isEmpty();
|
||||
verify(mapper, never()).selectOne(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void migrateSkipsWhenServerValueExists() {
|
||||
UserApiSecretService service = newService();
|
||||
UserApiSecretEntity existing = new UserApiSecretEntity();
|
||||
existing.setId(9L);
|
||||
existing.setSecretValue("enc:existing");
|
||||
when(mapper.selectOne(any())).thenReturn(existing);
|
||||
|
||||
int migrated = service.migrateIfAbsent(7L, List.of(item("appearance-patent", "local-key")));
|
||||
|
||||
assertThat(migrated).isZero();
|
||||
verify(mapper, never()).insert(any(UserApiSecretEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void migrateWritesOnlyMissingModules() {
|
||||
UserApiSecretService service = newService();
|
||||
when(mapper.selectOne(any())).thenReturn(null);
|
||||
|
||||
int migrated = service.migrateIfAbsent(7L, List.of(
|
||||
item("appearance-patent", "app-key"),
|
||||
item("similar-asin", "asin-key"),
|
||||
item("appearance-patent-token", "legacy-token")));
|
||||
|
||||
assertThat(migrated).isEqualTo(2);
|
||||
verify(mapper, org.mockito.Mockito.times(2)).insert(any(UserApiSecretEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void bundleIncompleteWhenNothingConfigured() {
|
||||
UserApiSecretService service = newService();
|
||||
when(mapper.selectOne(any())).thenReturn(null);
|
||||
|
||||
UserApiSecretBundleVo bundle = service.bundle(7L);
|
||||
|
||||
assertThat(bundle.getComplete()).isFalse();
|
||||
assertThat(bundle.getItems()).hasSize(2);
|
||||
assertThat(bundle.getRequiredModules()).containsExactly("appearance-patent", "similar-asin");
|
||||
}
|
||||
|
||||
@Test
|
||||
void bundleCompleteWhenAllModulesPassed() {
|
||||
UserApiSecretService service = newService();
|
||||
UserApiSecretEntity passed = new UserApiSecretEntity();
|
||||
passed.setSecretValue("enc:key");
|
||||
passed.setCheckStatus("passed");
|
||||
when(mapper.selectOne(any())).thenReturn(passed);
|
||||
|
||||
assertThat(service.bundle(7L).getComplete()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void bundleTreatsErrorAsPassThroughButFailedBlocks() {
|
||||
UserApiSecretService service = newService();
|
||||
UserApiSecretEntity row = new UserApiSecretEntity();
|
||||
row.setSecretValue("enc:key");
|
||||
row.setCheckStatus("error");
|
||||
when(mapper.selectOne(any())).thenReturn(row);
|
||||
assertThat(service.bundle(7L).getComplete()).isTrue();
|
||||
|
||||
row.setCheckStatus("failed");
|
||||
assertThat(service.bundle(7L).getComplete()).isFalse();
|
||||
|
||||
row.setCheckStatus("unknown");
|
||||
assertThat(service.bundle(7L).getComplete()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void clearDeletesRowByUserAndModule() {
|
||||
UserApiSecretService service = newService();
|
||||
|
||||
service.clear(7L, "similar-asin");
|
||||
|
||||
verify(mapper).delete(any());
|
||||
}
|
||||
|
||||
private UserApiSecretMigrateRequest.Item item(String moduleKey, String value) {
|
||||
UserApiSecretMigrateRequest.Item item = new UserApiSecretMigrateRequest.Item();
|
||||
item.setModuleKey(moduleKey);
|
||||
item.setValue(value);
|
||||
return item;
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
const ok = await panel.saveAll()
|
||||
if (ok) {
|
||||
dialogVisible.value = false
|
||||
ElMessage.success(shouldSaveProxy ? '密钥和代理设置已保存' : '密钥设置已保存')
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '设置保存失败'
|
||||
ElMessage.error(secretsSaved && shouldSaveProxy ? `密钥已保存,但代理设置保存失败:${message}` : message)
|
||||
console.log('[api-secret] 密钥设置已保存')
|
||||
}
|
||||
} 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
|
||||
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()
|
||||
function buildLegacyKey(moduleKey: string) {
|
||||
return `${LEGACY_PREFIX}:${currentUid()}:${moduleKey}`
|
||||
}
|
||||
|
||||
function emptyEntry(): ApiSecretCacheEntry {
|
||||
return {
|
||||
value: parsed.value,
|
||||
retention,
|
||||
expiresAt,
|
||||
updatedAt,
|
||||
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 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 {
|
||||
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,
|
||||
}
|
||||
}
|
||||
return {
|
||||
value: record.value,
|
||||
retention: record.retention,
|
||||
expiresAt: record.expiresAt,
|
||||
updatedAt: record.updatedAt,
|
||||
exists: true,
|
||||
const entry = readEntry(moduleKey)
|
||||
const { schema: _schema, ...snapshot } = entry
|
||||
return snapshot
|
||||
}
|
||||
|
||||
export function subscribeApiSecrets(listener: () => void): () => void {
|
||||
listeners.add(listener)
|
||||
return () => {
|
||||
listeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
export function saveStoredApiSecret(
|
||||
/** 加载服务端密钥包;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 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'
|
||||
}
|
||||
}
|
||||
|
||||
/** 旧本地密钥一次性迁移:仅当服务端空缺且有本地明文时上报;失败静默,下次加载重试。 */
|
||||
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