feat(密钥管理): 列表按用户聚合三字段列 + 代理配置服务端上报
- Java:后台列表一行一用户(货源查询密钥/外观专利密钥/代理设置),行级状态三项全通过才算通过;检测/清空改为按用户;搜索仅按用户名(去 UID);新增代理检测(经用户代理请求自家域名)与代理掩码(隐去账密) - 后台前端:三字段列改版 + 行级状态筛选 + 用户名筛选 - 桌面前端:登录加载密钥时补报本地代理(只填空缺不覆盖)、保存/清空代理实时上报
This commit is contained in:
@@ -1,10 +1,8 @@
|
|||||||
import { http } from './http'
|
import { http } from './http'
|
||||||
import { unwrap } from './envelope'
|
import { unwrap } from './envelope'
|
||||||
|
|
||||||
export interface AdminUserSecretItem {
|
/** 单模块状态(脱敏值 + 连通性)。 */
|
||||||
id: number
|
export interface AdminUserSecretModule {
|
||||||
userId: number
|
|
||||||
username: string
|
|
||||||
moduleKey: string
|
moduleKey: string
|
||||||
moduleLabel: string
|
moduleLabel: string
|
||||||
masked: string
|
masked: string
|
||||||
@@ -14,12 +12,23 @@ export interface AdminUserSecretItem {
|
|||||||
checkMessage: string
|
checkMessage: string
|
||||||
checkLatencyMs: number | null
|
checkLatencyMs: number | null
|
||||||
checkedAt: string | null
|
checkedAt: string | null
|
||||||
source: string
|
updatedAt: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 一行一用户:三个字段列 + 行级状态(三类都检测通过才算 passed)。 */
|
||||||
|
export interface AdminUserSecretRow {
|
||||||
|
userId: number
|
||||||
|
username: string
|
||||||
|
similarAsin: AdminUserSecretModule
|
||||||
|
appearancePatent: AdminUserSecretModule
|
||||||
|
proxy: AdminUserSecretModule
|
||||||
|
status: string
|
||||||
|
statusMessage: string
|
||||||
updatedAt: string | null
|
updatedAt: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminUserSecretPage {
|
export interface AdminUserSecretPage {
|
||||||
items: AdminUserSecretItem[]
|
items: AdminUserSecretRow[]
|
||||||
total: number
|
total: number
|
||||||
page: number
|
page: number
|
||||||
pageSize: number
|
pageSize: number
|
||||||
@@ -27,22 +36,22 @@ export interface AdminUserSecretPage {
|
|||||||
|
|
||||||
export interface UserSecretQuery {
|
export interface UserSecretQuery {
|
||||||
keyword?: string
|
keyword?: string
|
||||||
moduleKey?: string
|
|
||||||
checkStatus?: string
|
checkStatus?: string
|
||||||
page: number
|
page: number
|
||||||
pageSize: number
|
pageSize: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 分页查询用户密钥(脱敏):GET /api/admin/user-secrets */
|
/** 分页查询用户密钥(一行一用户):GET /api/admin/user-secrets */
|
||||||
export async function fetchUserSecretList(params: UserSecretQuery): Promise<AdminUserSecretPage> {
|
export async function fetchUserSecretList(params: UserSecretQuery): Promise<AdminUserSecretPage> {
|
||||||
const { data } = await http.get('/api/admin/user-secrets', { params })
|
const { data } = await http.get('/api/admin/user-secrets', { params })
|
||||||
return unwrap<AdminUserSecretPage>(data)
|
return unwrap<AdminUserSecretPage>(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 立即检测指定密钥:POST /api/admin/user-secrets/{id}/check */
|
/** 立即检测该用户全部已配置项:POST /api/admin/user-secrets/{userId}/check */
|
||||||
export async function checkUserSecret(id: number) {
|
export async function checkUserSecret(userId: number) {
|
||||||
const { data } = await http.post(`/api/admin/user-secrets/${id}/check`)
|
const { data } = await http.post(`/api/admin/user-secrets/${userId}/check`)
|
||||||
return unwrap<{
|
return unwrap<
|
||||||
|
Array<{
|
||||||
moduleKey: string
|
moduleKey: string
|
||||||
checkStatus: string
|
checkStatus: string
|
||||||
checkCode: string
|
checkCode: string
|
||||||
@@ -50,11 +59,12 @@ export async function checkUserSecret(id: number) {
|
|||||||
checkLatencyMs: number | null
|
checkLatencyMs: number | null
|
||||||
checkedAt: string | null
|
checkedAt: string | null
|
||||||
viaProxy: boolean
|
viaProxy: boolean
|
||||||
}>(data)
|
}>
|
||||||
|
>(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 清空指定用户密钥:DELETE /api/admin/user-secrets/{id} */
|
/** 清空该用户全部密钥与代理配置:DELETE /api/admin/user-secrets/{userId} */
|
||||||
export async function deleteUserSecret(id: number): Promise<void> {
|
export async function deleteUserSecret(userId: number): Promise<void> {
|
||||||
const { data } = await http.delete(`/api/admin/user-secrets/${id}`)
|
const { data } = await http.delete(`/api/admin/user-secrets/${userId}`)
|
||||||
unwrap<unknown>(data)
|
unwrap<unknown>(data)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,43 +7,40 @@ import {
|
|||||||
checkUserSecret,
|
checkUserSecret,
|
||||||
deleteUserSecret,
|
deleteUserSecret,
|
||||||
fetchUserSecretList,
|
fetchUserSecretList,
|
||||||
type AdminUserSecretItem,
|
type AdminUserSecretModule,
|
||||||
|
type AdminUserSecretRow,
|
||||||
} from '@/api/user-secrets'
|
} from '@/api/user-secrets'
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const rows = ref<AdminUserSecretItem[]>([])
|
const rows = ref<AdminUserSecretRow[]>([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const pageSize = ref(15)
|
const pageSize = ref(15)
|
||||||
const keyword = ref('')
|
const keyword = ref('')
|
||||||
const moduleFilter = ref('')
|
|
||||||
const statusFilter = ref('')
|
const statusFilter = ref('')
|
||||||
/** 正在检测的行 id,用于按钮 loading 态。 */
|
/** 正在检测的行 userId,用于按钮 loading 态。 */
|
||||||
const checkingId = ref<number | null>(null)
|
const checkingId = ref<number | null>(null)
|
||||||
|
|
||||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
|
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 = [
|
const STATUS_OPTIONS = [
|
||||||
{ value: '', label: '全部状态' },
|
{ value: '', label: '全部状态' },
|
||||||
{ value: 'passed', label: '检测通过' },
|
{ value: 'passed', label: '检测通过' },
|
||||||
{ value: 'failed', label: '检测失败' },
|
{ value: 'failed', label: '检测失败' },
|
||||||
|
{ value: 'incomplete', label: '未配齐' },
|
||||||
{ value: 'error', label: '无法判定' },
|
{ value: 'error', label: '无法判定' },
|
||||||
{ value: 'unknown', label: '未检测' },
|
{ value: 'unknown', label: '未检测' },
|
||||||
]
|
]
|
||||||
|
|
||||||
/** 状态药丸:与店铺密钥页 whitelistStatusMeta 同一视觉语言。 */
|
/** 行级状态药丸:三类都检测通过才显示「检测通过」。 */
|
||||||
function statusMeta(status: string) {
|
function rowStatusMeta(status: string) {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'passed':
|
case 'passed':
|
||||||
return { label: '检测通过', tone: 'is-allowed' }
|
return { label: '检测通过', tone: 'is-allowed' }
|
||||||
case 'failed':
|
case 'failed':
|
||||||
return { label: '检测失败', tone: 'is-blocked' }
|
return { label: '检测失败', tone: 'is-blocked' }
|
||||||
|
case 'incomplete':
|
||||||
|
return { label: '未配齐', tone: 'is-warn' }
|
||||||
case 'error':
|
case 'error':
|
||||||
return { label: '无法判定', tone: 'is-warn' }
|
return { label: '无法判定', tone: 'is-warn' }
|
||||||
default:
|
default:
|
||||||
@@ -51,13 +48,37 @@ function statusMeta(status: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 状态药丸悬停详情:检测时间 + 检测消息 + 耗时。 */
|
/** 单格状态药丸:未配置时统一灰色。 */
|
||||||
function statusTooltip(row: AdminUserSecretItem) {
|
function moduleStatusMeta(module: AdminUserSecretModule | undefined) {
|
||||||
|
if (!module || !module.exists) {
|
||||||
|
return { label: '未配置', tone: 'is-unknown' }
|
||||||
|
}
|
||||||
|
switch (module.checkStatus) {
|
||||||
|
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 moduleTooltip(module: AdminUserSecretModule | undefined) {
|
||||||
|
if (!module || !module.exists) return '未配置'
|
||||||
const parts: string[] = []
|
const parts: string[] = []
|
||||||
if (row.checkMessage) parts.push(row.checkMessage)
|
if (module.checkMessage) parts.push(module.checkMessage)
|
||||||
if (row.checkedAt) parts.push(`检测时间:${formatDateTime(row.checkedAt)}`)
|
if (module.checkedAt) parts.push(`检测时间:${formatDateTime(module.checkedAt)}`)
|
||||||
if (row.checkLatencyMs != null) parts.push(`耗时:${row.checkLatencyMs}ms`)
|
if (module.checkLatencyMs != null) parts.push(`耗时:${module.checkLatencyMs}ms`)
|
||||||
if (row.source) parts.push(`来源:${row.source}`)
|
return parts.join(';') || '暂无检测记录'
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowStatusTooltip(row: AdminUserSecretRow) {
|
||||||
|
const parts: string[] = []
|
||||||
|
if (row.statusMessage) parts.push(row.statusMessage)
|
||||||
|
if (row.updatedAt) parts.push(`最近更新:${formatDateTime(row.updatedAt)}`)
|
||||||
return parts.join(';') || '暂无检测记录'
|
return parts.join(';') || '暂无检测记录'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,7 +87,6 @@ async function load() {
|
|||||||
try {
|
try {
|
||||||
const result = await fetchUserSecretList({
|
const result = await fetchUserSecretList({
|
||||||
keyword: keyword.value.trim() || undefined,
|
keyword: keyword.value.trim() || undefined,
|
||||||
moduleKey: moduleFilter.value || undefined,
|
|
||||||
checkStatus: statusFilter.value || undefined,
|
checkStatus: statusFilter.value || undefined,
|
||||||
page: page.value,
|
page: page.value,
|
||||||
pageSize: pageSize.value,
|
pageSize: pageSize.value,
|
||||||
@@ -87,23 +107,28 @@ function search() {
|
|||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
keyword.value = ''
|
keyword.value = ''
|
||||||
moduleFilter.value = ''
|
|
||||||
statusFilter.value = ''
|
statusFilter.value = ''
|
||||||
page.value = 1
|
page.value = 1
|
||||||
load()
|
load()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 立即检测:真实请求一次 LLM 接口并把结果落库。 */
|
/** 立即检测:真实请求该用户全部已配置项并把结果落库。 */
|
||||||
async function check(row: AdminUserSecretItem) {
|
async function check(row: AdminUserSecretRow) {
|
||||||
checkingId.value = row.id
|
checkingId.value = row.userId
|
||||||
try {
|
try {
|
||||||
const result = await checkUserSecret(row.id)
|
const results = await checkUserSecret(row.userId)
|
||||||
if (result?.checkStatus === 'passed') {
|
if (!results || !results.length) {
|
||||||
ElMessage.success('检测通过')
|
ElMessage.warning('该用户尚未配置任何密钥或代理')
|
||||||
} else if (result?.checkStatus === 'failed') {
|
|
||||||
ElMessage.warning(result.checkMessage || '检测未通过')
|
|
||||||
} else {
|
} else {
|
||||||
ElMessage.warning(result?.checkMessage || '本次无法判定')
|
const failed = results.filter((item) => item.checkStatus === 'failed')
|
||||||
|
const errors = results.filter((item) => item.checkStatus === 'error')
|
||||||
|
if (failed.length) {
|
||||||
|
ElMessage.warning(failed[0].checkMessage || '存在检测失败项')
|
||||||
|
} else if (errors.length) {
|
||||||
|
ElMessage.warning('部分配置本次无法判定')
|
||||||
|
} else {
|
||||||
|
ElMessage.success('检测通过')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
load()
|
load()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -113,11 +138,11 @@ async function check(row: AdminUserSecretItem) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function remove(row: AdminUserSecretItem) {
|
async function remove(row: AdminUserSecretRow) {
|
||||||
const who = row.username || `UID ${row.userId}`
|
const who = row.username || `UID ${row.userId}`
|
||||||
if (!window.confirm(`确定清空「${who}」的${row.moduleLabel || row.moduleKey}吗?清空后该用户需要重新配置。`)) return
|
if (!window.confirm(`确定清空「${who}」的全部密钥与代理配置吗?清空后该用户需要重新配置。`)) return
|
||||||
try {
|
try {
|
||||||
await deleteUserSecret(row.id)
|
await deleteUserSecret(row.userId)
|
||||||
ElMessage.success('已清空')
|
ElMessage.success('已清空')
|
||||||
load()
|
load()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -149,17 +174,11 @@ onMounted(load)
|
|||||||
|
|
||||||
<div class="form-row secrets-filter-row">
|
<div class="form-row secrets-filter-row">
|
||||||
<div class="form-group" style="min-width: 220px">
|
<div class="form-group" style="min-width: 220px">
|
||||||
<label>用户名 / UID</label>
|
<label>用户名</label>
|
||||||
<input v-model="keyword" type="text" placeholder="模糊搜索用户名或输入用户ID" @keyup.enter="search" />
|
<input v-model="keyword" type="text" placeholder="模糊搜索用户名" @keyup.enter="search" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group" style="min-width: 170px">
|
<div class="form-group" style="min-width: 170px">
|
||||||
<label>密钥类型</label>
|
<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">
|
<select v-model="statusFilter">
|
||||||
<option v-for="option in STATUS_OPTIONS" :key="option.value" :value="option.value">{{ option.label }}</option>
|
<option v-for="option in STATUS_OPTIONS" :key="option.value" :value="option.value">{{ option.label }}</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -178,59 +197,71 @@ onMounted(load)
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style="width: 58px">序号</th>
|
<th style="width: 58px">序号</th>
|
||||||
<th style="width: 200px">用户</th>
|
<th style="width: 190px">用户</th>
|
||||||
<th style="width: 150px">密钥类型</th>
|
<th style="width: 230px">货源查询密钥</th>
|
||||||
<th style="width: 220px">密钥(脱敏)</th>
|
<th style="width: 230px">外观专利密钥</th>
|
||||||
<th style="width: 150px">连通性</th>
|
<th style="width: 230px">代理设置</th>
|
||||||
<th style="width: 130px">来源</th>
|
<th style="width: 130px">状态</th>
|
||||||
<th style="width: 170px">更新时间</th>
|
|
||||||
<th style="width: 170px">操作</th>
|
<th style="width: 170px">操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<template v-if="rows.length">
|
<template v-if="rows.length">
|
||||||
<tr v-for="(row, index) in rows" :key="row.id">
|
<tr v-for="(row, index) in rows" :key="row.userId">
|
||||||
<td>{{ (page - 1) * pageSize + index + 1 }}</td>
|
<td>{{ (page - 1) * pageSize + index + 1 }}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="user-cell">
|
|
||||||
<span class="user-name">{{ row.username || '—' }}</span>
|
<span class="user-name">{{ row.username || '—' }}</span>
|
||||||
<span class="user-uid">UID {{ row.userId }}</span>
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="module-cell">
|
||||||
|
<span v-if="row.similarAsin?.exists" class="mono-mask" :title="moduleTooltip(row.similarAsin)">{{ row.similarAsin.masked }}</span>
|
||||||
|
<span v-else class="empty-value">未配置</span>
|
||||||
|
<span class="wh-pill" :class="moduleStatusMeta(row.similarAsin).tone" :title="moduleTooltip(row.similarAsin)">
|
||||||
|
{{ moduleStatusMeta(row.similarAsin).label }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ row.moduleLabel || row.moduleKey }}</td>
|
|
||||||
<td>
|
<td>
|
||||||
<span v-if="row.exists" class="mono-mask">{{ row.masked || '已配置' }}</span>
|
<div class="module-cell">
|
||||||
|
<span v-if="row.appearancePatent?.exists" class="mono-mask" :title="moduleTooltip(row.appearancePatent)">{{ row.appearancePatent.masked }}</span>
|
||||||
<span v-else class="empty-value">未配置</span>
|
<span v-else class="empty-value">未配置</span>
|
||||||
|
<span class="wh-pill" :class="moduleStatusMeta(row.appearancePatent).tone" :title="moduleTooltip(row.appearancePatent)">
|
||||||
|
{{ moduleStatusMeta(row.appearancePatent).label }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span
|
<div class="module-cell">
|
||||||
class="wh-pill"
|
<span v-if="row.proxy?.exists" class="mono-mask" :title="moduleTooltip(row.proxy)">{{ row.proxy.masked }}</span>
|
||||||
:class="statusMeta(row.checkStatus).tone"
|
<span v-else class="empty-value">未配置</span>
|
||||||
:title="statusTooltip(row)"
|
<span class="wh-pill" :class="moduleStatusMeta(row.proxy).tone" :title="moduleTooltip(row.proxy)">
|
||||||
>
|
{{ moduleStatusMeta(row.proxy).label }}
|
||||||
{{ statusMeta(row.checkStatus).label }}
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="wh-pill" :class="rowStatusMeta(row.status).tone" :title="rowStatusTooltip(row)">
|
||||||
|
{{ rowStatusMeta(row.status).label }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ row.source || '—' }}</td>
|
|
||||||
<td>{{ formatDateTime(row.updatedAt) }}</td>
|
|
||||||
<td class="ops-cell">
|
<td class="ops-cell">
|
||||||
<button
|
<button
|
||||||
class="btn btn-sm"
|
class="btn btn-sm"
|
||||||
type="button"
|
type="button"
|
||||||
:disabled="checkingId === row.id || !row.exists"
|
:disabled="checkingId === row.userId"
|
||||||
@click="check(row)"
|
@click="check(row)"
|
||||||
>
|
>
|
||||||
{{ checkingId === row.id ? '检测中…' : '立即检测' }}
|
{{ checkingId === row.userId ? '检测中…' : '立即检测' }}
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-sm btn-danger" type="button" @click="remove(row)">清空</button>
|
<button class="btn btn-sm btn-danger" type="button" @click="remove(row)">清空</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
<tr v-else-if="loading">
|
<tr v-else-if="loading">
|
||||||
<td colspan="8" class="empty-tip">加载中...</td>
|
<td colspan="7" class="empty-tip">加载中...</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-else>
|
<tr v-else>
|
||||||
<td colspan="8" class="empty-tip">{{ keyword || moduleFilter || statusFilter ? '暂无匹配记录' : '暂无用户密钥记录' }}</td>
|
<td colspan="7" class="empty-tip">{{ keyword || statusFilter ? '暂无匹配记录' : '暂无用户密钥记录' }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -377,7 +408,7 @@ h3 {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
table-layout: fixed;
|
table-layout: fixed;
|
||||||
min-width: 1200px;
|
min-width: 1240px;
|
||||||
}
|
}
|
||||||
.secrets-table-scroll th,
|
.secrets-table-scroll th,
|
||||||
.secrets-table-scroll td {
|
.secrets-table-scroll td {
|
||||||
@@ -405,19 +436,20 @@ h3 {
|
|||||||
.secrets-table-scroll tbody tr:last-child td {
|
.secrets-table-scroll tbody tr:last-child td {
|
||||||
border-bottom: 0;
|
border-bottom: 0;
|
||||||
}
|
}
|
||||||
.user-cell {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
line-height: 1.35;
|
|
||||||
}
|
|
||||||
.user-name {
|
.user-name {
|
||||||
color: #24384d;
|
color: #24384d;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.user-uid {
|
.module-cell {
|
||||||
color: #8293a5;
|
display: flex;
|
||||||
font-size: 11.5px;
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.module-cell .mono-mask {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
.mono-mask {
|
.mono-mask {
|
||||||
font-family: Consolas, "Cascadia Mono", monospace;
|
font-family: Consolas, "Cascadia Mono", monospace;
|
||||||
@@ -444,6 +476,7 @@ h3 {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
cursor: help;
|
cursor: help;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.wh-pill.is-allowed {
|
.wh-pill.is-allowed {
|
||||||
background: #e8f6ee;
|
background: #e8f6ee;
|
||||||
|
|||||||
+18
-19
@@ -20,16 +20,17 @@ import org.springframework.web.bind.annotation.RequestParam;
|
|||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 后台密钥管理:管理员查看用户密钥(脱敏)、立即检测、清空。
|
* 后台密钥管理:一行一用户(货源查询密钥 / 外观专利密钥 / 代理设置 三字段列),
|
||||||
* 不提供查看明文与代填编辑能力。
|
* 管理员查看脱敏值、立即检测、清空。不提供查看明文与代填编辑能力。
|
||||||
*/
|
*/
|
||||||
@RestController
|
@RestController
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@RequestMapping("/api/admin/user-secrets")
|
@RequestMapping("/api/admin/user-secrets")
|
||||||
@Tag(name = "后台密钥管理", description = "查看用户密钥配置(脱敏)、立即检测连通性、清空。")
|
@Tag(name = "后台密钥管理", description = "按用户查看密钥与代理配置(脱敏)、立即检测连通性、清空。")
|
||||||
public class AdminUserApiSecretController {
|
public class AdminUserApiSecretController {
|
||||||
|
|
||||||
private final UserApiSecretService userApiSecretService;
|
private final UserApiSecretService userApiSecretService;
|
||||||
@@ -37,41 +38,39 @@ public class AdminUserApiSecretController {
|
|||||||
private final AdminAuthSupport adminAuthSupport;
|
private final AdminAuthSupport adminAuthSupport;
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@Operation(summary = "分页查询用户密钥", description = "keyword 匹配用户名或用户ID。")
|
@Operation(summary = "分页查询用户密钥", description = "一行一用户;keyword 匹配用户名,checkStatus 按行级状态筛选。")
|
||||||
public ApiResponse<AdminUserSecretPageVo> page(
|
public ApiResponse<AdminUserSecretPageVo> page(
|
||||||
HttpServletRequest request,
|
HttpServletRequest request,
|
||||||
@Parameter(description = "关键字:用户名或用户ID") @RequestParam(required = false) String keyword,
|
@Parameter(description = "关键字:用户名") @RequestParam(required = false) String keyword,
|
||||||
@Parameter(description = "密钥模块筛选") @RequestParam(required = false) String moduleKey,
|
@Parameter(description = "行级状态筛选:passed/failed/incomplete/error/unknown") @RequestParam(required = false) String checkStatus,
|
||||||
@Parameter(description = "连通性状态筛选") @RequestParam(required = false) String checkStatus,
|
|
||||||
@Parameter(description = "页码") @RequestParam(defaultValue = "1") Long page,
|
@Parameter(description = "页码") @RequestParam(defaultValue = "1") Long page,
|
||||||
@Parameter(description = "每页数量") @RequestParam(defaultValue = "15") Long pageSize) {
|
@Parameter(description = "每页数量") @RequestParam(defaultValue = "15") Long pageSize) {
|
||||||
adminAuthSupport.requireAdmin(request);
|
adminAuthSupport.requireAdmin(request);
|
||||||
AdminUserSecretQuery query = new AdminUserSecretQuery();
|
AdminUserSecretQuery query = new AdminUserSecretQuery();
|
||||||
query.setKeyword(keyword);
|
query.setKeyword(keyword);
|
||||||
query.setModuleKey(moduleKey);
|
|
||||||
query.setCheckStatus(checkStatus);
|
query.setCheckStatus(checkStatus);
|
||||||
query.setPage(page);
|
query.setPage(page);
|
||||||
query.setPageSize(pageSize);
|
query.setPageSize(pageSize);
|
||||||
return ApiResponse.success(userApiSecretService.adminPage(query));
|
return ApiResponse.success(userApiSecretService.adminPage(query));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/{id}/check")
|
@PostMapping("/{userId}/check")
|
||||||
@Operation(summary = "立即检测指定密钥", description = "解密后真实请求一次 LLM 接口并把结果落库。")
|
@Operation(summary = "立即检测该用户全部已配置项", description = "逐模块真实探测(密钥请求 LLM、代理请求自家域名)并把结果落库。")
|
||||||
public ApiResponse<UserApiSecretCheckResultVo> check(
|
public ApiResponse<List<UserApiSecretCheckResultVo>> check(
|
||||||
HttpServletRequest request,
|
HttpServletRequest request,
|
||||||
@Parameter(description = "记录主键", required = true) @PathVariable Long id) {
|
@Parameter(description = "用户ID", required = true) @PathVariable Long userId) {
|
||||||
adminAuthSupport.requireAdmin(request);
|
adminAuthSupport.requireAdmin(request);
|
||||||
return ApiResponse.success("检测完成", userApiSecretService.adminCheck(id));
|
return ApiResponse.success("检测完成", userApiSecretService.adminCheckByUser(userId));
|
||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/{id}")
|
@DeleteMapping("/{userId}")
|
||||||
@Operation(summary = "清空指定用户密钥")
|
@Operation(summary = "清空该用户全部密钥与代理配置")
|
||||||
public ApiResponse<Void> clear(
|
public ApiResponse<Integer> clear(
|
||||||
HttpServletRequest request,
|
HttpServletRequest request,
|
||||||
@Parameter(description = "记录主键", required = true) @PathVariable Long id) {
|
@Parameter(description = "用户ID", required = true) @PathVariable Long userId) {
|
||||||
adminAuthSupport.requireAdmin(request);
|
adminAuthSupport.requireAdmin(request);
|
||||||
userApiSecretService.adminClear(id);
|
int deleted = userApiSecretService.adminClearByUser(userId);
|
||||||
return ApiResponse.success("已清空", null);
|
return ApiResponse.success("已清空", deleted);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/check-all")
|
@PostMapping("/check-all")
|
||||||
|
|||||||
+1
-4
@@ -10,10 +10,7 @@ public class AdminUserSecretQuery {
|
|||||||
@Schema(description = "关键字:匹配用户名或用户ID")
|
@Schema(description = "关键字:匹配用户名或用户ID")
|
||||||
private String keyword;
|
private String keyword;
|
||||||
|
|
||||||
@Schema(description = "密钥模块筛选:appearance-patent/similar-asin")
|
@Schema(description = "行级状态筛选:passed/failed/incomplete/error/unknown")
|
||||||
private String moduleKey;
|
|
||||||
|
|
||||||
@Schema(description = "连通性状态筛选:unknown/passed/failed/error")
|
|
||||||
private String checkStatus;
|
private String checkStatus;
|
||||||
|
|
||||||
@Schema(description = "页码,从 1 开始")
|
@Schema(description = "页码,从 1 开始")
|
||||||
|
|||||||
+3
-15
@@ -6,17 +6,8 @@ import lombok.Data;
|
|||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
@Schema(description = "后台密钥管理列表项")
|
@Schema(description = "后台密钥管理-单模块状态(脱敏值 + 连通性)")
|
||||||
public class AdminUserSecretItemVo {
|
public class AdminUserSecretModuleVo {
|
||||||
|
|
||||||
@Schema(description = "记录主键")
|
|
||||||
private Long id;
|
|
||||||
|
|
||||||
@Schema(description = "用户ID")
|
|
||||||
private Long userId;
|
|
||||||
|
|
||||||
@Schema(description = "用户名")
|
|
||||||
private String username;
|
|
||||||
|
|
||||||
@Schema(description = "密钥模块 key")
|
@Schema(description = "密钥模块 key")
|
||||||
private String moduleKey;
|
private String moduleKey;
|
||||||
@@ -30,7 +21,7 @@ public class AdminUserSecretItemVo {
|
|||||||
@Schema(description = "是否已配置")
|
@Schema(description = "是否已配置")
|
||||||
private Boolean exists;
|
private Boolean exists;
|
||||||
|
|
||||||
@Schema(description = "连通性状态")
|
@Schema(description = "连通性状态:unknown/passed/failed/error")
|
||||||
private String checkStatus;
|
private String checkStatus;
|
||||||
|
|
||||||
@Schema(description = "检测结果码")
|
@Schema(description = "检测结果码")
|
||||||
@@ -45,9 +36,6 @@ public class AdminUserSecretItemVo {
|
|||||||
@Schema(description = "最近检测时间")
|
@Schema(description = "最近检测时间")
|
||||||
private LocalDateTime checkedAt;
|
private LocalDateTime checkedAt;
|
||||||
|
|
||||||
@Schema(description = "写入来源:client/admin/migrated")
|
|
||||||
private String source;
|
|
||||||
|
|
||||||
@Schema(description = "更新时间")
|
@Schema(description = "更新时间")
|
||||||
private LocalDateTime updatedAt;
|
private LocalDateTime updatedAt;
|
||||||
}
|
}
|
||||||
+2
-2
@@ -9,8 +9,8 @@ import java.util.List;
|
|||||||
@Schema(description = "后台密钥管理分页结果")
|
@Schema(description = "后台密钥管理分页结果")
|
||||||
public class AdminUserSecretPageVo {
|
public class AdminUserSecretPageVo {
|
||||||
|
|
||||||
@Schema(description = "列表项")
|
@Schema(description = "列表项(一行一用户)")
|
||||||
private List<AdminUserSecretItemVo> items;
|
private List<AdminUserSecretRowVo> items;
|
||||||
|
|
||||||
@Schema(description = "总条数")
|
@Schema(description = "总条数")
|
||||||
private Long total;
|
private Long total;
|
||||||
|
|||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
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 AdminUserSecretRowVo {
|
||||||
|
|
||||||
|
@Schema(description = "用户ID")
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
@Schema(description = "用户名")
|
||||||
|
private String username;
|
||||||
|
|
||||||
|
@Schema(description = "货源查询密钥")
|
||||||
|
private AdminUserSecretModuleVo similarAsin;
|
||||||
|
|
||||||
|
@Schema(description = "外观专利密钥")
|
||||||
|
private AdminUserSecretModuleVo appearancePatent;
|
||||||
|
|
||||||
|
@Schema(description = "代理设置")
|
||||||
|
private AdminUserSecretModuleVo proxy;
|
||||||
|
|
||||||
|
@Schema(description = "行级状态:passed=三类都检测通过 / failed=有检测失败 / incomplete=未配齐 / error=无法判定 / unknown=未检测")
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
@Schema(description = "行级状态说明")
|
||||||
|
private String statusMessage;
|
||||||
|
|
||||||
|
@Schema(description = "最近更新时间(三模块中最晚)")
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
}
|
||||||
+42
-1
@@ -48,8 +48,11 @@ public class UserApiSecretCheckService {
|
|||||||
|
|
||||||
private static final MediaType APPLICATION_JSON_UTF8 = new MediaType("application", "json", StandardCharsets.UTF_8);
|
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 READ_TIMEOUT_MILLIS = 15_000;
|
||||||
|
private static final int PROXY_READ_TIMEOUT_MILLIS = 10_000;
|
||||||
private static final int MAX_RESPONSE_BYTES = 1024 * 1024;
|
private static final int MAX_RESPONSE_BYTES = 1024 * 1024;
|
||||||
private static final int CHECK_MAX_TOKENS = 8;
|
private static final int CHECK_MAX_TOKENS = 8;
|
||||||
|
/** 代理探测目标:自家域名(http 无 CONNECT 依赖,兼容各类转发型代理)。 */
|
||||||
|
private static final String PROXY_PROBE_TARGET_URL = "http://api.aishufu.top/";
|
||||||
|
|
||||||
private final AppearancePatentProperties appearancePatentProperties;
|
private final AppearancePatentProperties appearancePatentProperties;
|
||||||
private final SimilarAsinProperties similarAsinProperties;
|
private final SimilarAsinProperties similarAsinProperties;
|
||||||
@@ -58,8 +61,11 @@ public class UserApiSecretCheckService {
|
|||||||
|
|
||||||
private volatile RestClient directClient;
|
private volatile RestClient directClient;
|
||||||
|
|
||||||
/** 探测入口:优先经代理(若配置提取链接),代理网络不可达回退直连。 */
|
/** 探测入口:代理模块走代理连通性探测,LLM 密钥模块优先经检测出口代理(若配置提取链接),代理网络不可达回退直连。 */
|
||||||
public CheckOutcome probe(UserSecretModule module, String plainApiKey) {
|
public CheckOutcome probe(UserSecretModule module, String plainApiKey) {
|
||||||
|
if (module == UserSecretModule.PROXY) {
|
||||||
|
return probeProxy(plainApiKey);
|
||||||
|
}
|
||||||
String proxyUrl = jikipProxyClient.isExtractConfigured() ? jikipProxyClient.fetchProxyUrl() : null;
|
String proxyUrl = jikipProxyClient.isExtractConfigured() ? jikipProxyClient.fetchProxyUrl() : null;
|
||||||
if (proxyUrl != null) {
|
if (proxyUrl != null) {
|
||||||
CheckOutcome viaProxy = probeOnce(module, plainApiKey, proxyUrl, true);
|
CheckOutcome viaProxy = probeOnce(module, plainApiKey, proxyUrl, true);
|
||||||
@@ -79,6 +85,41 @@ public class UserApiSecretCheckService {
|
|||||||
return probeOnce(module, plainApiKey, null, false);
|
return probeOnce(module, plainApiKey, null, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 代理连通性探测:经由用户配置的代理请求一次自家域名。
|
||||||
|
* 拿到任意 HTTP 响应(含 4xx/5xx)即说明代理转发可用;407 为代理自身认证失败;网络异常视为不可达。
|
||||||
|
*/
|
||||||
|
private CheckOutcome probeProxy(String proxyUrl) {
|
||||||
|
String normalized = proxyUrl == null ? "" : proxyUrl.trim();
|
||||||
|
if (normalized.isEmpty()) {
|
||||||
|
return new CheckOutcome(STATUS_FAILED, CODE_INVALID_KEY, "代理地址为空,请重新配置", null, true);
|
||||||
|
}
|
||||||
|
long startMillis = System.currentTimeMillis();
|
||||||
|
try {
|
||||||
|
RestClient client = RestClient.builder()
|
||||||
|
.requestFactory(HttpClientPool.requestFactory(PROXY_READ_TIMEOUT_MILLIS, normalized))
|
||||||
|
.build();
|
||||||
|
int statusCode = client.get()
|
||||||
|
.uri(PROXY_PROBE_TARGET_URL)
|
||||||
|
.exchange((request, response) -> response.getStatusCode().value());
|
||||||
|
long latency = System.currentTimeMillis() - startMillis;
|
||||||
|
if (statusCode == 407) {
|
||||||
|
return new CheckOutcome(STATUS_FAILED, CODE_FORBIDDEN,
|
||||||
|
"代理认证失败(407),请检查代理账号密码", (int) latency, true);
|
||||||
|
}
|
||||||
|
CheckOutcome outcome = new CheckOutcome(STATUS_PASSED, CODE_OK,
|
||||||
|
"代理连通正常(HTTP " + statusCode + ")", (int) latency, true);
|
||||||
|
log.info("[user-secret][check] 代理探测完成 status=passed code=ok httpStatus={} latency={}ms",
|
||||||
|
statusCode, latency);
|
||||||
|
return outcome;
|
||||||
|
} catch (Exception ex) {
|
||||||
|
long latency = System.currentTimeMillis() - startMillis;
|
||||||
|
log.warn("[user-secret][check] 代理探测异常 latency={}ms err={}", latency, ex.getMessage());
|
||||||
|
return new CheckOutcome(STATUS_FAILED, CODE_NETWORK_ERROR,
|
||||||
|
"代理不可达:" + rootCauseMessage(ex), (int) latency, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private CheckOutcome probeOnce(UserSecretModule module, String plainApiKey, String proxyUrl, boolean viaProxy) {
|
private CheckOutcome probeOnce(UserSecretModule module, String plainApiKey, String proxyUrl, boolean viaProxy) {
|
||||||
UserSecretModule.LlmTarget target = module.resolveLlmTarget(appearancePatentProperties, similarAsinProperties);
|
UserSecretModule.LlmTarget target = module.resolveLlmTarget(appearancePatentProperties, similarAsinProperties);
|
||||||
String url = joinUrl(target.host(), "/v1/chat/completions");
|
String url = joinUrl(target.host(), "/v1/chat/completions");
|
||||||
|
|||||||
+248
-77
@@ -1,7 +1,6 @@
|
|||||||
package com.nanri.aiimage.modules.usersecret.service;
|
package com.nanri.aiimage.modules.usersecret.service;
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
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.exception.BusinessException;
|
||||||
import com.nanri.aiimage.common.security.ShopCredentialCryptoService;
|
import com.nanri.aiimage.common.security.ShopCredentialCryptoService;
|
||||||
import com.nanri.aiimage.modules.permission.mapper.AdminUserMapper;
|
import com.nanri.aiimage.modules.permission.mapper.AdminUserMapper;
|
||||||
@@ -11,8 +10,9 @@ 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.AdminUserSecretQuery;
|
||||||
import com.nanri.aiimage.modules.usersecret.model.dto.UserApiSecretMigrateRequest;
|
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.entity.UserApiSecretEntity;
|
||||||
import com.nanri.aiimage.modules.usersecret.model.vo.AdminUserSecretItemVo;
|
import com.nanri.aiimage.modules.usersecret.model.vo.AdminUserSecretModuleVo;
|
||||||
import com.nanri.aiimage.modules.usersecret.model.vo.AdminUserSecretPageVo;
|
import com.nanri.aiimage.modules.usersecret.model.vo.AdminUserSecretPageVo;
|
||||||
|
import com.nanri.aiimage.modules.usersecret.model.vo.AdminUserSecretRowVo;
|
||||||
import com.nanri.aiimage.modules.usersecret.model.vo.UserApiSecretBalanceVo;
|
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.UserApiSecretBundleVo;
|
||||||
import com.nanri.aiimage.modules.usersecret.model.vo.UserApiSecretCheckResultVo;
|
import com.nanri.aiimage.modules.usersecret.model.vo.UserApiSecretCheckResultVo;
|
||||||
@@ -23,18 +23,23 @@ import lombok.extern.slf4j.Slf4j;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Arrays;
|
import java.util.Comparator;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.LinkedHashSet;
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 用户 API 密钥服务端存储:按登录用户绑定、AES 加密落库,
|
* 用户 API 密钥服务端存储:按登录用户绑定、AES 加密落库,
|
||||||
* 提供完整性包、迁移、任务兜底读取、连通性检测落库与后台管理查询。
|
* 提供完整性包、迁移、任务兜底读取、连通性检测落库与后台管理查询。
|
||||||
|
* 后台列表按用户聚合(一行三列:货源查询密钥 / 外观专利密钥 / 代理设置)。
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -45,9 +50,18 @@ public class UserApiSecretService {
|
|||||||
public static final String SOURCE_ADMIN = "admin";
|
public static final String SOURCE_ADMIN = "admin";
|
||||||
public static final String SOURCE_MIGRATED = "migrated";
|
public static final String SOURCE_MIGRATED = "migrated";
|
||||||
|
|
||||||
|
/** 行级状态:三类全部检测通过才算 passed;failed > incomplete > error > unknown。 */
|
||||||
|
public static final String ROW_STATUS_PASSED = "passed";
|
||||||
|
public static final String ROW_STATUS_FAILED = "failed";
|
||||||
|
public static final String ROW_STATUS_INCOMPLETE = "incomplete";
|
||||||
|
public static final String ROW_STATUS_ERROR = "error";
|
||||||
|
public static final String ROW_STATUS_UNKNOWN = "unknown";
|
||||||
|
|
||||||
private static final String STATUS_UNKNOWN = "unknown";
|
private static final String STATUS_UNKNOWN = "unknown";
|
||||||
private static final int MASK_MIN_LENGTH = 8;
|
private static final int MASK_MIN_LENGTH = 8;
|
||||||
private static final int MESSAGE_MAX_LENGTH = 500;
|
private static final int MESSAGE_MAX_LENGTH = 500;
|
||||||
|
private static final String PROXY_FORMAT_HINT =
|
||||||
|
"代理地址格式不正确,应形如 http://host:port 或 http://user:pass@host:port";
|
||||||
|
|
||||||
private final UserApiSecretMapper userApiSecretMapper;
|
private final UserApiSecretMapper userApiSecretMapper;
|
||||||
private final ShopCredentialCryptoService cryptoService;
|
private final ShopCredentialCryptoService cryptoService;
|
||||||
@@ -55,16 +69,17 @@ public class UserApiSecretService {
|
|||||||
private final JikipProxyClient jikipProxyClient;
|
private final JikipProxyClient jikipProxyClient;
|
||||||
private final AdminUserMapper adminUserMapper;
|
private final AdminUserMapper adminUserMapper;
|
||||||
|
|
||||||
/** 当前用户密钥包:全量模块 + 服务端下发的必填清单 + 完整性判定。 */
|
/** 当前用户密钥包:仅必填模块(代理为选配,不下发)+ 服务端下发的必填清单 + 完整性判定。 */
|
||||||
public UserApiSecretBundleVo bundle(Long userId) {
|
public UserApiSecretBundleVo bundle(Long userId) {
|
||||||
requireUserId(userId);
|
requireUserId(userId);
|
||||||
List<UserApiSecretItemVo> items = new ArrayList<>();
|
List<UserSecretModule> required = UserSecretModule.requiredModules();
|
||||||
for (UserSecretModule module : UserSecretModule.values()) {
|
List<UserApiSecretItemVo> items = new ArrayList<>(required.size());
|
||||||
|
for (UserSecretModule module : required) {
|
||||||
items.add(toItem(module, selectOne(userId, module.key())));
|
items.add(toItem(module, selectOne(userId, module.key())));
|
||||||
}
|
}
|
||||||
UserApiSecretBundleVo vo = new UserApiSecretBundleVo();
|
UserApiSecretBundleVo vo = new UserApiSecretBundleVo();
|
||||||
vo.setItems(items);
|
vo.setItems(items);
|
||||||
vo.setRequiredModules(Arrays.stream(UserSecretModule.values()).map(UserSecretModule::key).toList());
|
vo.setRequiredModules(required.stream().map(UserSecretModule::key).toList());
|
||||||
vo.setComplete(isComplete(items));
|
vo.setComplete(isComplete(items));
|
||||||
return vo;
|
return vo;
|
||||||
}
|
}
|
||||||
@@ -76,7 +91,10 @@ public class UserApiSecretService {
|
|||||||
UserSecretModule module = requireModule(moduleKey);
|
UserSecretModule module = requireModule(moduleKey);
|
||||||
String plainValue = normalize(value);
|
String plainValue = normalize(value);
|
||||||
if (plainValue.isEmpty()) {
|
if (plainValue.isEmpty()) {
|
||||||
throw new BusinessException("密钥不能为空");
|
throw new BusinessException(module == UserSecretModule.PROXY ? "代理地址不能为空" : "密钥不能为空");
|
||||||
|
}
|
||||||
|
if (module == UserSecretModule.PROXY) {
|
||||||
|
validateProxyValue(plainValue);
|
||||||
}
|
}
|
||||||
upsert(userId, module.key(), plainValue, SOURCE_CLIENT);
|
upsert(userId, module.key(), plainValue, SOURCE_CLIENT);
|
||||||
log.info("[user-secret] 密钥已保存 userId={} module={}", userId, module.key());
|
log.info("[user-secret] 密钥已保存 userId={} module={}", userId, module.key());
|
||||||
@@ -149,13 +167,16 @@ public class UserApiSecretService {
|
|||||||
if (plainKey.isEmpty()) {
|
if (plainKey.isEmpty()) {
|
||||||
UserApiSecretEntity row = selectOne(userId, module.key());
|
UserApiSecretEntity row = selectOne(userId, module.key());
|
||||||
if (row == null || !hasText(row.getSecretValue())) {
|
if (row == null || !hasText(row.getSecretValue())) {
|
||||||
throw new BusinessException("请先保存密钥后再检测");
|
throw new BusinessException(module == UserSecretModule.PROXY
|
||||||
|
? "请先保存代理地址后再检测" : "请先保存密钥后再检测");
|
||||||
}
|
}
|
||||||
plainKey = normalize(cryptoService.decrypt(row.getSecretValue()));
|
plainKey = normalize(cryptoService.decrypt(row.getSecretValue()));
|
||||||
if (plainKey.isEmpty()) {
|
if (plainKey.isEmpty()) {
|
||||||
throw new BusinessException("密钥内容为空,请重新配置");
|
throw new BusinessException("配置内容为空,请重新配置");
|
||||||
}
|
}
|
||||||
persist = true;
|
persist = true;
|
||||||
|
} else if (module == UserSecretModule.PROXY) {
|
||||||
|
validateProxyValue(plainKey);
|
||||||
}
|
}
|
||||||
UserApiSecretCheckService.CheckOutcome outcome = checkService.probe(module, plainKey);
|
UserApiSecretCheckService.CheckOutcome outcome = checkService.probe(module, plainKey);
|
||||||
UserApiSecretCheckResultVo vo = toCheckResult(module, outcome);
|
UserApiSecretCheckResultVo vo = toCheckResult(module, outcome);
|
||||||
@@ -173,7 +194,11 @@ public class UserApiSecretService {
|
|||||||
return jikipProxyClient.fetchBalance();
|
return jikipProxyClient.fetchBalance();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 后台分页:关键字匹配用户名或用户ID。 */
|
/**
|
||||||
|
* 后台分页:一行一用户,聚合货源查询密钥/外观专利密钥/代理设置三个字段列,并计算行级状态。
|
||||||
|
* 先按关键字圈定用户,再全量聚合、行级状态筛选、按最近更新时间倒序后内存分页
|
||||||
|
* (当前规模为用户数×3,内存聚合成本可控;量级上来后可改为 GROUP BY 下推)。
|
||||||
|
*/
|
||||||
public AdminUserSecretPageVo adminPage(AdminUserSecretQuery query) {
|
public AdminUserSecretPageVo adminPage(AdminUserSecretQuery query) {
|
||||||
AdminUserSecretQuery safeQuery = query == null ? new AdminUserSecretQuery() : query;
|
AdminUserSecretQuery safeQuery = query == null ? new AdminUserSecretQuery() : query;
|
||||||
long page = safeQuery.getPage() == null || safeQuery.getPage() < 1 ? 1L : safeQuery.getPage();
|
long page = safeQuery.getPage() == null || safeQuery.getPage() < 1 ? 1L : safeQuery.getPage();
|
||||||
@@ -189,53 +214,86 @@ public class UserApiSecretService {
|
|||||||
}
|
}
|
||||||
wrapper.in(UserApiSecretEntity::getUserId, userIds);
|
wrapper.in(UserApiSecretEntity::getUserId, userIds);
|
||||||
}
|
}
|
||||||
if (hasText(safeQuery.getModuleKey())) {
|
List<UserApiSecretEntity> rows = userApiSecretMapper.selectList(wrapper);
|
||||||
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);
|
Map<Long, Map<String, UserApiSecretEntity>> grouped = new LinkedHashMap<>();
|
||||||
List<AdminUserSecretItemVo> items = new ArrayList<>(result.getRecords().size());
|
for (UserApiSecretEntity row : rows) {
|
||||||
for (UserApiSecretEntity row : result.getRecords()) {
|
if (row.getUserId() == null) {
|
||||||
items.add(toAdminItem(row));
|
continue;
|
||||||
}
|
}
|
||||||
|
grouped.computeIfAbsent(row.getUserId(), key -> new HashMap<>()).put(row.getModuleKey(), row);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<AdminUserSecretRowVo> all = new ArrayList<>(grouped.size());
|
||||||
|
for (Map.Entry<Long, Map<String, UserApiSecretEntity>> entry : grouped.entrySet()) {
|
||||||
|
all.add(buildAdminRow(entry.getKey(), entry.getValue()));
|
||||||
|
}
|
||||||
|
|
||||||
|
String statusFilter = normalize(safeQuery.getCheckStatus());
|
||||||
|
if (!statusFilter.isEmpty()) {
|
||||||
|
all.removeIf(rowVo -> !statusFilter.equals(rowVo.getStatus()));
|
||||||
|
}
|
||||||
|
all.sort(Comparator.comparing(AdminUserSecretRowVo::getUpdatedAt,
|
||||||
|
Comparator.nullsLast(Comparator.reverseOrder())));
|
||||||
|
|
||||||
|
long total = all.size();
|
||||||
|
int from = (int) Math.min((page - 1) * pageSize, total);
|
||||||
|
int to = (int) Math.min(from + pageSize, total);
|
||||||
AdminUserSecretPageVo vo = new AdminUserSecretPageVo();
|
AdminUserSecretPageVo vo = new AdminUserSecretPageVo();
|
||||||
vo.setItems(items);
|
vo.setItems(new ArrayList<>(all.subList(from, to)));
|
||||||
vo.setTotal(result.getTotal());
|
vo.setTotal(total);
|
||||||
vo.setPage(page);
|
vo.setPage(page);
|
||||||
vo.setPageSize(pageSize);
|
vo.setPageSize(pageSize);
|
||||||
|
log.info("[user-secret] 后台密钥列表 keyword={} statusFilter={} 聚合用户数={} 本页返回={}",
|
||||||
|
keyword, statusFilter, total, vo.getItems().size());
|
||||||
return vo;
|
return vo;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 后台:按记录 ID 立即检测并落库。 */
|
/** 后台:检测该用户全部已配置模块并落库(未配置项跳过;解密失败落 failed 并计入结果)。 */
|
||||||
public UserApiSecretCheckResultVo adminCheck(Long id) {
|
public List<UserApiSecretCheckResultVo> adminCheckByUser(Long userId) {
|
||||||
UserApiSecretEntity row = requireById(id);
|
requireUserId(userId);
|
||||||
Optional<UserSecretModule> module = UserSecretModule.of(row.getModuleKey());
|
List<UserApiSecretCheckResultVo> results = new ArrayList<>();
|
||||||
if (module.isEmpty()) {
|
for (UserSecretModule module : UserSecretModule.values()) {
|
||||||
throw new BusinessException("密钥模块已下线:" + row.getModuleKey());
|
UserApiSecretEntity row = selectOne(userId, module.key());
|
||||||
|
if (row == null || !hasText(row.getSecretValue())) {
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
String plainKey = normalize(cryptoService.decrypt(row.getSecretValue()));
|
UserApiSecretCheckService.CheckOutcome outcome;
|
||||||
if (plainKey.isEmpty()) {
|
try {
|
||||||
throw new BusinessException("密钥内容为空,请让用户重新配置");
|
String plainValue = normalize(cryptoService.decrypt(row.getSecretValue()));
|
||||||
|
if (plainValue.isEmpty()) {
|
||||||
|
outcome = new UserApiSecretCheckService.CheckOutcome(
|
||||||
|
UserApiSecretCheckService.STATUS_FAILED,
|
||||||
|
UserApiSecretCheckService.CODE_INVALID_KEY,
|
||||||
|
"配置内容为空,请重新配置", null, false);
|
||||||
|
} else {
|
||||||
|
outcome = checkService.probe(module, plainValue);
|
||||||
}
|
}
|
||||||
UserApiSecretCheckService.CheckOutcome outcome = checkService.probe(module.get(), plainKey);
|
} catch (Exception ex) {
|
||||||
applyCheckOutcome(row.getUserId(), module.get().key(), outcome);
|
log.warn("[user-secret] 后台检测解密失败 userId={} module={} err={}", userId, module.key(), ex.getMessage());
|
||||||
UserApiSecretCheckResultVo vo = toCheckResult(module.get(), outcome);
|
outcome = new UserApiSecretCheckService.CheckOutcome(
|
||||||
|
UserApiSecretCheckService.STATUS_FAILED,
|
||||||
|
UserApiSecretCheckService.CODE_INVALID_KEY,
|
||||||
|
"配置内容解密失败,请让用户重新配置", null, false);
|
||||||
|
}
|
||||||
|
applyCheckOutcome(userId, module.key(), outcome);
|
||||||
|
UserApiSecretCheckResultVo vo = toCheckResult(module, outcome);
|
||||||
vo.setCheckedAt(LocalDateTime.now());
|
vo.setCheckedAt(LocalDateTime.now());
|
||||||
log.info("[user-secret] 后台检测完成 id={} userId={} module={} status={} code={}",
|
results.add(vo);
|
||||||
id, row.getUserId(), module.get().key(), outcome.status(), outcome.code());
|
log.info("[user-secret] 后台检测完成 userId={} module={} status={} code={}",
|
||||||
return vo;
|
userId, module.key(), outcome.status(), outcome.code());
|
||||||
|
}
|
||||||
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 后台:清空指定记录。 */
|
/** 后台:清空该用户全部密钥与代理配置。 */
|
||||||
@Transactional
|
@Transactional
|
||||||
public void adminClear(Long id) {
|
public int adminClearByUser(Long userId) {
|
||||||
UserApiSecretEntity row = requireById(id);
|
requireUserId(userId);
|
||||||
userApiSecretMapper.deleteById(id);
|
int deleted = userApiSecretMapper.delete(new LambdaQueryWrapper<UserApiSecretEntity>()
|
||||||
log.info("[user-secret] 后台清空密钥 id={} userId={} module={}", id, row.getUserId(), row.getModuleKey());
|
.eq(UserApiSecretEntity::getUserId, userId));
|
||||||
|
log.info("[user-secret] 后台清空用户全部配置 userId={} 删除={} 条", userId, deleted);
|
||||||
|
return deleted;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -275,7 +333,7 @@ public class UserApiSecretService {
|
|||||||
applyCheckOutcome(row.getUserId(), module.get().key(), new UserApiSecretCheckService.CheckOutcome(
|
applyCheckOutcome(row.getUserId(), module.get().key(), new UserApiSecretCheckService.CheckOutcome(
|
||||||
UserApiSecretCheckService.STATUS_FAILED,
|
UserApiSecretCheckService.STATUS_FAILED,
|
||||||
UserApiSecretCheckService.CODE_INVALID_KEY,
|
UserApiSecretCheckService.CODE_INVALID_KEY,
|
||||||
"密钥内容为空,请重新配置", null, false));
|
"配置内容为空,请重新配置", null, false));
|
||||||
failed++;
|
failed++;
|
||||||
checked++;
|
checked++;
|
||||||
continue;
|
continue;
|
||||||
@@ -310,6 +368,87 @@ public class UserApiSecretService {
|
|||||||
return summary;
|
return summary;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private AdminUserSecretRowVo buildAdminRow(Long userId, Map<String, UserApiSecretEntity> moduleRows) {
|
||||||
|
AdminUserSecretRowVo vo = new AdminUserSecretRowVo();
|
||||||
|
vo.setUserId(userId);
|
||||||
|
AdminUserSecretModuleVo similarAsin = toModuleVo(UserSecretModule.SIMILAR_ASIN,
|
||||||
|
moduleRows.get(UserSecretModule.SIMILAR_ASIN.key()));
|
||||||
|
AdminUserSecretModuleVo appearancePatent = toModuleVo(UserSecretModule.APPEARANCE_PATENT,
|
||||||
|
moduleRows.get(UserSecretModule.APPEARANCE_PATENT.key()));
|
||||||
|
AdminUserSecretModuleVo proxy = toModuleVo(UserSecretModule.PROXY,
|
||||||
|
moduleRows.get(UserSecretModule.PROXY.key()));
|
||||||
|
vo.setSimilarAsin(similarAsin);
|
||||||
|
vo.setAppearancePatent(appearancePatent);
|
||||||
|
vo.setProxy(proxy);
|
||||||
|
List<AdminUserSecretModuleVo> modules = List.of(similarAsin, appearancePatent, proxy);
|
||||||
|
vo.setStatus(summarizeRowStatus(modules));
|
||||||
|
vo.setStatusMessage(summarizeRowMessage(modules, vo.getStatus()));
|
||||||
|
vo.setUpdatedAt(latestUpdatedAt(modules));
|
||||||
|
AdminUserEntity user = adminUserMapper.selectById(userId);
|
||||||
|
vo.setUsername(user == null ? "" : user.getUsername());
|
||||||
|
return vo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 行级状态:三类都检测通过才算通过;failed > incomplete > error > unknown,最后才是 passed。 */
|
||||||
|
String summarizeRowStatus(List<AdminUserSecretModuleVo> modules) {
|
||||||
|
boolean anyFailed = false;
|
||||||
|
boolean anyMissing = false;
|
||||||
|
boolean anyError = false;
|
||||||
|
boolean allPassed = true;
|
||||||
|
for (AdminUserSecretModuleVo module : modules) {
|
||||||
|
if (!Boolean.TRUE.equals(module.getExists())) {
|
||||||
|
anyMissing = true;
|
||||||
|
allPassed = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String status = module.getCheckStatus();
|
||||||
|
if (UserApiSecretCheckService.STATUS_FAILED.equals(status)) {
|
||||||
|
anyFailed = true;
|
||||||
|
allPassed = false;
|
||||||
|
} else if (UserApiSecretCheckService.STATUS_ERROR.equals(status)) {
|
||||||
|
anyError = true;
|
||||||
|
allPassed = false;
|
||||||
|
} else if (!UserApiSecretCheckService.STATUS_PASSED.equals(status)) {
|
||||||
|
allPassed = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (anyFailed) {
|
||||||
|
return ROW_STATUS_FAILED;
|
||||||
|
}
|
||||||
|
if (anyMissing) {
|
||||||
|
return ROW_STATUS_INCOMPLETE;
|
||||||
|
}
|
||||||
|
if (allPassed) {
|
||||||
|
return ROW_STATUS_PASSED;
|
||||||
|
}
|
||||||
|
if (anyError) {
|
||||||
|
return ROW_STATUS_ERROR;
|
||||||
|
}
|
||||||
|
return ROW_STATUS_UNKNOWN;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String summarizeRowMessage(List<AdminUserSecretModuleVo> modules, String status) {
|
||||||
|
long missing = modules.stream().filter(module -> !Boolean.TRUE.equals(module.getExists())).count();
|
||||||
|
return switch (status) {
|
||||||
|
case ROW_STATUS_PASSED -> "三项均检测通过";
|
||||||
|
case ROW_STATUS_FAILED -> "存在检测失败的配置";
|
||||||
|
case ROW_STATUS_INCOMPLETE -> missing + " 项未配置";
|
||||||
|
case ROW_STATUS_ERROR -> "存在无法判定的检测结果";
|
||||||
|
default -> "存在尚未检测的配置";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private LocalDateTime latestUpdatedAt(List<AdminUserSecretModuleVo> modules) {
|
||||||
|
LocalDateTime latest = null;
|
||||||
|
for (AdminUserSecretModuleVo module : modules) {
|
||||||
|
LocalDateTime value = module.getUpdatedAt();
|
||||||
|
if (value != null && (latest == null || value.isAfter(latest))) {
|
||||||
|
latest = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return latest;
|
||||||
|
}
|
||||||
|
|
||||||
private void upsert(Long userId, String moduleKey, String plainValue, String source) {
|
private void upsert(Long userId, String moduleKey, String plainValue, String source) {
|
||||||
LocalDateTime now = LocalDateTime.now();
|
LocalDateTime now = LocalDateTime.now();
|
||||||
UserApiSecretEntity existing = selectOne(userId, moduleKey);
|
UserApiSecretEntity existing = selectOne(userId, moduleKey);
|
||||||
@@ -357,26 +496,9 @@ public class UserApiSecretService {
|
|||||||
.last("limit 1"));
|
.last("limit 1"));
|
||||||
}
|
}
|
||||||
|
|
||||||
private UserApiSecretEntity requireById(Long id) {
|
/** 关键字圈定用户:仅按用户名模糊匹配(页面不提供 UID 搜索)。 */
|
||||||
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) {
|
private List<Long> resolveUserIdsByKeyword(String keyword) {
|
||||||
Set<Long> userIds = new LinkedHashSet<>();
|
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>()
|
List<AdminUserEntity> matched = adminUserMapper.selectList(new LambdaQueryWrapper<AdminUserEntity>()
|
||||||
.like(AdminUserEntity::getUsername, keyword)
|
.like(AdminUserEntity::getUsername, keyword)
|
||||||
.last("limit 200"));
|
.last("limit 200"));
|
||||||
@@ -388,26 +510,27 @@ public class UserApiSecretService {
|
|||||||
return new ArrayList<>(userIds);
|
return new ArrayList<>(userIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
private AdminUserSecretItemVo toAdminItem(UserApiSecretEntity row) {
|
private AdminUserSecretModuleVo toModuleVo(UserSecretModule module, UserApiSecretEntity row) {
|
||||||
AdminUserSecretItemVo vo = new AdminUserSecretItemVo();
|
AdminUserSecretModuleVo vo = new AdminUserSecretModuleVo();
|
||||||
vo.setId(row.getId());
|
vo.setModuleKey(module.key());
|
||||||
vo.setUserId(row.getUserId());
|
vo.setModuleLabel(module.label());
|
||||||
vo.setModuleKey(row.getModuleKey());
|
if (row == null) {
|
||||||
UserSecretModule.of(row.getModuleKey())
|
vo.setMasked("");
|
||||||
.ifPresentOrElse(module -> vo.setModuleLabel(module.label()),
|
vo.setExists(false);
|
||||||
() -> vo.setModuleLabel(row.getModuleKey()));
|
vo.setCheckStatus(STATUS_UNKNOWN);
|
||||||
|
vo.setCheckCode("");
|
||||||
|
vo.setCheckMessage("");
|
||||||
|
return vo;
|
||||||
|
}
|
||||||
String plain = decryptQuietly(row.getSecretValue());
|
String plain = decryptQuietly(row.getSecretValue());
|
||||||
vo.setMasked(mask(plain));
|
vo.setMasked(maskValue(module, plain));
|
||||||
vo.setExists(hasText(plain));
|
vo.setExists(hasText(plain));
|
||||||
vo.setCheckStatus(row.getCheckStatus());
|
vo.setCheckStatus(hasText(row.getCheckStatus()) ? row.getCheckStatus() : STATUS_UNKNOWN);
|
||||||
vo.setCheckCode(row.getCheckCode());
|
vo.setCheckCode(row.getCheckCode());
|
||||||
vo.setCheckMessage(row.getCheckMessage());
|
vo.setCheckMessage(row.getCheckMessage());
|
||||||
vo.setCheckLatencyMs(row.getCheckLatencyMs());
|
vo.setCheckLatencyMs(row.getCheckLatencyMs());
|
||||||
vo.setCheckedAt(row.getCheckedAt());
|
vo.setCheckedAt(row.getCheckedAt());
|
||||||
vo.setSource(row.getSource());
|
|
||||||
vo.setUpdatedAt(row.getUpdatedAt());
|
vo.setUpdatedAt(row.getUpdatedAt());
|
||||||
AdminUserEntity user = row.getUserId() == null ? null : adminUserMapper.selectById(row.getUserId());
|
|
||||||
vo.setUsername(user == null ? "" : user.getUsername());
|
|
||||||
return vo;
|
return vo;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -424,7 +547,7 @@ public class UserApiSecretService {
|
|||||||
return vo;
|
return vo;
|
||||||
}
|
}
|
||||||
String plain = decryptQuietly(row.getSecretValue());
|
String plain = decryptQuietly(row.getSecretValue());
|
||||||
vo.setMasked(mask(plain));
|
vo.setMasked(maskValue(module, plain));
|
||||||
vo.setExists(hasText(plain));
|
vo.setExists(hasText(plain));
|
||||||
vo.setCheckStatus(hasText(row.getCheckStatus()) ? row.getCheckStatus() : STATUS_UNKNOWN);
|
vo.setCheckStatus(hasText(row.getCheckStatus()) ? row.getCheckStatus() : STATUS_UNKNOWN);
|
||||||
vo.setCheckCode(row.getCheckCode());
|
vo.setCheckCode(row.getCheckCode());
|
||||||
@@ -486,6 +609,22 @@ public class UserApiSecretService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 代理地址校验:http(s):// 开头且含 host:port(账密可省略)。 */
|
||||||
|
private void validateProxyValue(String value) {
|
||||||
|
try {
|
||||||
|
URI uri = URI.create(value);
|
||||||
|
boolean schemeOk = uri.getScheme() != null
|
||||||
|
&& (uri.getScheme().equalsIgnoreCase("http") || uri.getScheme().equalsIgnoreCase("https"));
|
||||||
|
if (!schemeOk || uri.getHost() == null || uri.getHost().isBlank() || uri.getPort() <= 0) {
|
||||||
|
throw new BusinessException(PROXY_FORMAT_HINT);
|
||||||
|
}
|
||||||
|
} catch (BusinessException ex) {
|
||||||
|
throw ex;
|
||||||
|
} catch (Exception ex) {
|
||||||
|
throw new BusinessException(PROXY_FORMAT_HINT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private String decryptQuietly(String cipherText) {
|
private String decryptQuietly(String cipherText) {
|
||||||
if (!hasText(cipherText)) {
|
if (!hasText(cipherText)) {
|
||||||
return "";
|
return "";
|
||||||
@@ -498,6 +637,13 @@ public class UserApiSecretService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String maskValue(UserSecretModule module, String plainValue) {
|
||||||
|
if (module == UserSecretModule.PROXY) {
|
||||||
|
return maskProxy(plainValue);
|
||||||
|
}
|
||||||
|
return mask(plainValue);
|
||||||
|
}
|
||||||
|
|
||||||
private String mask(String value) {
|
private String mask(String value) {
|
||||||
if (!hasText(value)) {
|
if (!hasText(value)) {
|
||||||
return "";
|
return "";
|
||||||
@@ -509,6 +655,31 @@ public class UserApiSecretService {
|
|||||||
return text.substring(0, 4) + "****" + text.substring(text.length() - 4);
|
return text.substring(0, 4) + "****" + text.substring(text.length() - 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 代理掩码:隐去账号密码,保留 scheme://host:port 便于运维核对。 */
|
||||||
|
private String maskProxy(String value) {
|
||||||
|
if (!hasText(value)) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
URI uri = URI.create(value.trim());
|
||||||
|
if (uri.getHost() == null || uri.getHost().isBlank()) {
|
||||||
|
return mask(value);
|
||||||
|
}
|
||||||
|
StringBuilder masked = new StringBuilder();
|
||||||
|
masked.append(uri.getScheme() == null ? "http" : uri.getScheme()).append("://");
|
||||||
|
if (uri.getUserInfo() != null && !uri.getUserInfo().isBlank()) {
|
||||||
|
masked.append("***@");
|
||||||
|
}
|
||||||
|
masked.append(uri.getHost());
|
||||||
|
if (uri.getPort() > 0) {
|
||||||
|
masked.append(':').append(uri.getPort());
|
||||||
|
}
|
||||||
|
return masked.toString();
|
||||||
|
} catch (Exception ex) {
|
||||||
|
return mask(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private String truncate(String value, int maxLength) {
|
private String truncate(String value, int maxLength) {
|
||||||
String normalized = value == null ? "" : value.trim();
|
String normalized = value == null ? "" : value.trim();
|
||||||
if (normalized.length() <= maxLength) {
|
if (normalized.length() <= maxLength) {
|
||||||
|
|||||||
+22
-5
@@ -3,23 +3,29 @@ package com.nanri.aiimage.modules.usersecret.support;
|
|||||||
import com.nanri.aiimage.config.AppearancePatentProperties;
|
import com.nanri.aiimage.config.AppearancePatentProperties;
|
||||||
import com.nanri.aiimage.config.SimilarAsinProperties;
|
import com.nanri.aiimage.config.SimilarAsinProperties;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 用户密钥模块:key / 显示名 / 检测目标(LLM host + model)的唯一来源。
|
* 用户密钥模块:key / 显示名 / 是否必填(参与桌面端门禁)的唯一来源。
|
||||||
* 新增密钥模块时在此登记,控制器与服务层不再散落字符串。
|
* 新增密钥模块时在此登记,控制器与服务层不再散落字符串。
|
||||||
*/
|
*/
|
||||||
public enum UserSecretModule {
|
public enum UserSecretModule {
|
||||||
|
|
||||||
APPEARANCE_PATENT("appearance-patent", "外观专利密钥"),
|
APPEARANCE_PATENT("appearance-patent", "外观专利密钥", true),
|
||||||
SIMILAR_ASIN("similar-asin", "货源查询密钥");
|
SIMILAR_ASIN("similar-asin", "货源查询密钥", true),
|
||||||
|
/** 客户端任务出口代理:仅服务端观测/检测,代理为选配,不参与桌面端门禁与密钥包。 */
|
||||||
|
PROXY("proxy", "代理设置", false);
|
||||||
|
|
||||||
private final String key;
|
private final String key;
|
||||||
private final String label;
|
private final String label;
|
||||||
|
private final boolean required;
|
||||||
|
|
||||||
UserSecretModule(String key, String label) {
|
UserSecretModule(String key, String label, boolean required) {
|
||||||
this.key = key;
|
this.key = key;
|
||||||
this.label = label;
|
this.label = label;
|
||||||
|
this.required = required;
|
||||||
}
|
}
|
||||||
|
|
||||||
public String key() {
|
public String key() {
|
||||||
@@ -30,7 +36,12 @@ public enum UserSecretModule {
|
|||||||
return label;
|
return label;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 检测目标:LLM 主机 + 模型(取各模块自身配置,默认同为 ai.t8star.org)。 */
|
/** 是否用户端必填:参与密钥包下发与桌面端完整性门禁。 */
|
||||||
|
public boolean required() {
|
||||||
|
return required;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 检测目标:LLM 主机 + 模型(仅 LLM 类模块;代理模块没有 LLM 目标)。 */
|
||||||
public LlmTarget resolveLlmTarget(AppearancePatentProperties appearancePatentProperties,
|
public LlmTarget resolveLlmTarget(AppearancePatentProperties appearancePatentProperties,
|
||||||
SimilarAsinProperties similarAsinProperties) {
|
SimilarAsinProperties similarAsinProperties) {
|
||||||
return switch (this) {
|
return switch (this) {
|
||||||
@@ -40,9 +51,15 @@ public enum UserSecretModule {
|
|||||||
case SIMILAR_ASIN -> new LlmTarget(
|
case SIMILAR_ASIN -> new LlmTarget(
|
||||||
similarAsinProperties.getLlmHost(),
|
similarAsinProperties.getLlmHost(),
|
||||||
similarAsinProperties.getLlmCategoryModel());
|
similarAsinProperties.getLlmCategoryModel());
|
||||||
|
case PROXY -> throw new IllegalStateException("代理模块没有 LLM 检测目标");
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 桌面端门禁模块:密钥包下发与完整性判定的唯一来源。 */
|
||||||
|
public static List<UserSecretModule> requiredModules() {
|
||||||
|
return Arrays.stream(values()).filter(UserSecretModule::required).toList();
|
||||||
|
}
|
||||||
|
|
||||||
public static Optional<UserSecretModule> of(String key) {
|
public static Optional<UserSecretModule> of(String key) {
|
||||||
if (key == null) {
|
if (key == null) {
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
|
|||||||
+99
@@ -2,10 +2,13 @@ package com.nanri.aiimage.modules.usersecret.service;
|
|||||||
|
|
||||||
import com.nanri.aiimage.common.security.ShopCredentialCryptoService;
|
import com.nanri.aiimage.common.security.ShopCredentialCryptoService;
|
||||||
import com.nanri.aiimage.modules.permission.mapper.AdminUserMapper;
|
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.client.JikipProxyClient;
|
||||||
import com.nanri.aiimage.modules.usersecret.mapper.UserApiSecretMapper;
|
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.dto.UserApiSecretMigrateRequest;
|
||||||
import com.nanri.aiimage.modules.usersecret.model.entity.UserApiSecretEntity;
|
import com.nanri.aiimage.modules.usersecret.model.entity.UserApiSecretEntity;
|
||||||
|
import com.nanri.aiimage.modules.usersecret.model.vo.AdminUserSecretModuleVo;
|
||||||
import com.nanri.aiimage.modules.usersecret.model.vo.UserApiSecretBundleVo;
|
import com.nanri.aiimage.modules.usersecret.model.vo.UserApiSecretBundleVo;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.mockito.ArgumentCaptor;
|
import org.mockito.ArgumentCaptor;
|
||||||
@@ -154,6 +157,102 @@ class UserApiSecretServiceTest {
|
|||||||
verify(mapper).delete(any());
|
verify(mapper).delete(any());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void bundleExcludesProxyModule() {
|
||||||
|
UserApiSecretService service = newService();
|
||||||
|
when(mapper.selectOne(any())).thenReturn(null);
|
||||||
|
|
||||||
|
UserApiSecretBundleVo bundle = service.bundle(7L);
|
||||||
|
|
||||||
|
assertThat(bundle.getItems()).extracting(item -> item.getModuleKey())
|
||||||
|
.containsExactlyInAnyOrder("appearance-patent", "similar-asin");
|
||||||
|
assertThat(bundle.getRequiredModules()).doesNotContain("proxy");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void saveRejectsInvalidProxyUrl() {
|
||||||
|
UserApiSecretService service = newService();
|
||||||
|
|
||||||
|
org.assertj.core.api.Assertions.assertThatThrownBy(() -> service.save(7L, "proxy", "1.2.3.4:8080"))
|
||||||
|
.isInstanceOf(com.nanri.aiimage.common.exception.BusinessException.class)
|
||||||
|
.hasMessageContaining("代理地址格式不正确");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void summarizeRequiresAllThreePassed() {
|
||||||
|
UserApiSecretService service = newService();
|
||||||
|
|
||||||
|
assertThat(service.summarizeRowStatus(List.of(
|
||||||
|
moduleVo(true, "passed"), moduleVo(true, "passed"), moduleVo(true, "passed")))
|
||||||
|
).isEqualTo("passed");
|
||||||
|
|
||||||
|
assertThat(service.summarizeRowStatus(List.of(
|
||||||
|
moduleVo(true, "passed"), moduleVo(false, "unknown"), moduleVo(true, "passed")))
|
||||||
|
).isEqualTo("incomplete");
|
||||||
|
|
||||||
|
assertThat(service.summarizeRowStatus(List.of(
|
||||||
|
moduleVo(true, "failed"), moduleVo(false, "unknown"), moduleVo(true, "passed")))
|
||||||
|
).isEqualTo("failed");
|
||||||
|
|
||||||
|
assertThat(service.summarizeRowStatus(List.of(
|
||||||
|
moduleVo(true, "passed"), moduleVo(true, "error"), moduleVo(true, "passed")))
|
||||||
|
).isEqualTo("error");
|
||||||
|
|
||||||
|
assertThat(service.summarizeRowStatus(List.of(
|
||||||
|
moduleVo(true, "passed"), moduleVo(true, "unknown"), moduleVo(true, "passed")))
|
||||||
|
).isEqualTo("unknown");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void adminPageAggregatesOneRowPerUserWithProxyMasked() {
|
||||||
|
UserApiSecretService service = newService();
|
||||||
|
when(mapper.selectList(any())).thenReturn(List.of(
|
||||||
|
row(1L, "similar-asin", "enc:sk-sa-1234", "passed"),
|
||||||
|
row(1L, "appearance-patent", "enc:sk-ap-5678", "passed"),
|
||||||
|
row(1L, "proxy", "enc:http://user:pass@1.2.3.4:8080", "failed")));
|
||||||
|
AdminUserEntity user = new AdminUserEntity();
|
||||||
|
user.setId(1L);
|
||||||
|
user.setUsername("张三");
|
||||||
|
when(adminUserMapper.selectById(1L)).thenReturn(user);
|
||||||
|
|
||||||
|
var page = service.adminPage(new AdminUserSecretQuery());
|
||||||
|
|
||||||
|
assertThat(page.getItems()).hasSize(1);
|
||||||
|
var rowVo = page.getItems().get(0);
|
||||||
|
assertThat(rowVo.getUsername()).isEqualTo("张三");
|
||||||
|
assertThat(rowVo.getStatus()).isEqualTo("failed");
|
||||||
|
assertThat(rowVo.getSimilarAsin().getMasked()).isEqualTo("sk-s****1234");
|
||||||
|
assertThat(rowVo.getProxy().getExists()).isTrue();
|
||||||
|
assertThat(rowVo.getProxy().getMasked()).isEqualTo("http://***@1.2.3.4:8080");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void adminClearByUserDeletesAllRowsOfUser() {
|
||||||
|
UserApiSecretService service = newService();
|
||||||
|
|
||||||
|
service.adminClearByUser(7L);
|
||||||
|
|
||||||
|
verify(mapper).delete(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
private AdminUserSecretModuleVo moduleVo(boolean exists, String status) {
|
||||||
|
AdminUserSecretModuleVo vo = new AdminUserSecretModuleVo();
|
||||||
|
vo.setExists(exists);
|
||||||
|
vo.setCheckStatus(status);
|
||||||
|
return vo;
|
||||||
|
}
|
||||||
|
|
||||||
|
private UserApiSecretEntity row(Long userId, String moduleKey, String cipher, String status) {
|
||||||
|
UserApiSecretEntity entity = new UserApiSecretEntity();
|
||||||
|
entity.setId((long) (Math.random() * 100000));
|
||||||
|
entity.setUserId(userId);
|
||||||
|
entity.setModuleKey(moduleKey);
|
||||||
|
entity.setSecretValue(cipher);
|
||||||
|
entity.setCheckStatus(status);
|
||||||
|
entity.setUpdatedAt(java.time.LocalDateTime.now());
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
|
||||||
private UserApiSecretMigrateRequest.Item item(String moduleKey, String value) {
|
private UserApiSecretMigrateRequest.Item item(String moduleKey, String value) {
|
||||||
UserApiSecretMigrateRequest.Item item = new UserApiSecretMigrateRequest.Item();
|
UserApiSecretMigrateRequest.Item item = new UserApiSecretMigrateRequest.Item();
|
||||||
item.setModuleKey(moduleKey);
|
item.setModuleKey(moduleKey);
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { del, get, post, put, type JavaApiResponse, unwrapJavaResponse } from '.
|
|||||||
import { buildJavaUrl } from '../../url.ts'
|
import { buildJavaUrl } from '../../url.ts'
|
||||||
import { API_ENDPOINTS } from '../../endpoints.ts'
|
import { API_ENDPOINTS } from '../../endpoints.ts'
|
||||||
|
|
||||||
/** 用户密钥模块 key:与服务端 UserSecretModule 枚举一致。 */
|
/** 用户密钥模块 key:与服务端 UserSecretModule 枚举一致(proxy=代理设置,由设置面板单独区块管理)。 */
|
||||||
export type ApiSecretModuleKey = 'appearance-patent' | 'similar-asin'
|
export type ApiSecretModuleKey = 'appearance-patent' | 'similar-asin' | 'proxy'
|
||||||
|
|
||||||
/** 连通性状态:unknown=未检测出结果(拦截)/ passed=通过 / failed=密钥无效(拦截)/ error=无法判定(放行)。 */
|
/** 连通性状态:unknown=未检测出结果(拦截)/ passed=通过 / failed=密钥无效(拦截)/ error=无法判定(放行)。 */
|
||||||
export type ApiSecretCheckStatus = 'unknown' | 'passed' | 'failed' | 'error'
|
export type ApiSecretCheckStatus = 'unknown' | 'passed' | 'failed' | 'error'
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ import {
|
|||||||
type UserApiSecretCheckResult,
|
type UserApiSecretCheckResult,
|
||||||
} from '@/shared/utils/api-secret-store'
|
} from '@/shared/utils/api-secret-store'
|
||||||
import { getPywebviewApi, type ProxyMode, type DesktopConfigUpdate } from '@/shared/bridges/pywebview'
|
import { getPywebviewApi, type ProxyMode, type DesktopConfigUpdate } from '@/shared/bridges/pywebview'
|
||||||
|
import { deleteMyApiSecret, putMyApiSecret } from '@/shared/api/types/modules/user-secret'
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
@@ -352,6 +353,21 @@ async function loadProxyConfig() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 代理配置上报服务端(后台密钥管理观测/检测):有地址则保存、清空则删除;失败不阻断本地保存。 */
|
||||||
|
async function syncProxyToServer(url: string) {
|
||||||
|
if (proxyUserId() === '0') return
|
||||||
|
try {
|
||||||
|
if (url) {
|
||||||
|
await putMyApiSecret('proxy', url)
|
||||||
|
} else {
|
||||||
|
await deleteMyApiSecret('proxy')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[api-secret] 代理配置上报失败(不影响本地任务):', error)
|
||||||
|
ElMessage.warning('代理已保存到本地,但上报后台失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadBalance() {
|
async function loadBalance() {
|
||||||
balanceLoading.value = true
|
balanceLoading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -404,6 +420,7 @@ async function saveAll(options: { requireAll?: boolean } = {}): Promise<boolean>
|
|||||||
await api.save_config(userProxyPatch(nextProxyUrl, proxyMode.value) as DesktopConfigUpdate)
|
await api.save_config(userProxyPatch(nextProxyUrl, proxyMode.value) as DesktopConfigUpdate)
|
||||||
proxyUrl.value = nextProxyUrl
|
proxyUrl.value = nextProxyUrl
|
||||||
proxyDirty.value = false
|
proxyDirty.value = false
|
||||||
|
await syncProxyToServer(nextProxyUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保存成功的模块立即自动检测,结果直接反映在卡片状态行
|
// 保存成功的模块立即自动检测,结果直接反映在卡片状态行
|
||||||
|
|||||||
@@ -342,6 +342,7 @@ async function doLoad(): Promise<ApiSecretLoadState> {
|
|||||||
}
|
}
|
||||||
notify()
|
notify()
|
||||||
}
|
}
|
||||||
|
await tryBackfillProxyToServer()
|
||||||
return computeApiSecretsComplete() ? 'complete' : 'incomplete'
|
return computeApiSecretsComplete() ? 'complete' : 'incomplete'
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[api-secret] 服务端密钥拉取失败,本次按未知处理(不阻断使用):', error)
|
console.warn('[api-secret] 服务端密钥拉取失败,本次按未知处理(不阻断使用):', error)
|
||||||
@@ -388,6 +389,37 @@ async function tryMigrateLocalSecrets(serverItems: UserApiSecretItem[]): Promise
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 本地代理配置补报服务端(只写空缺、不覆盖;后台密钥管理观测/检测用)。
|
||||||
|
* 非桌面环境、未登录、无本地代理时静默跳过;失败静默,下次加载重试。
|
||||||
|
* 桥模块动态导入:node 测试环境不触发(currentUid 为 0 时直接返回),避免加载浏览器侧依赖。
|
||||||
|
*/
|
||||||
|
async function tryBackfillProxyToServer(): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (currentUid() === '0') return
|
||||||
|
const { getPywebviewApi } = await import('../bridges/pywebview.ts')
|
||||||
|
const api = getPywebviewApi()
|
||||||
|
if (!api?.read_config) return
|
||||||
|
const config = (await api.read_config()) as Record<string, unknown> | null
|
||||||
|
const url = readLocalProxyUrl(config)
|
||||||
|
if (!url) return
|
||||||
|
await migrateMyApiSecrets([{ moduleKey: 'proxy', value: url }])
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[api-secret] 代理配置补报失败(下次加载重试):', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读取当前登录用户的本地代理地址:proxy_users[uid] 优先,未登录回退全局 proxy_url。 */
|
||||||
|
function readLocalProxyUrl(config: Record<string, unknown> | null | undefined): string {
|
||||||
|
const uid = currentUid()
|
||||||
|
if (uid !== '0') {
|
||||||
|
const users = (config?.proxy_users ?? {}) as Record<string, unknown>
|
||||||
|
const own = (users[uid] ?? {}) as Record<string, unknown>
|
||||||
|
return typeof own.proxy_url === 'string' ? own.proxy_url.trim() : ''
|
||||||
|
}
|
||||||
|
return typeof config?.proxy_url === 'string' ? config.proxy_url.trim() : ''
|
||||||
|
}
|
||||||
|
|
||||||
/** 保存密钥到服务端;成功后刷新本地缓存(失败不写本地,避免本地有值服务端没有的假象)。 */
|
/** 保存密钥到服务端;成功后刷新本地缓存(失败不写本地,避免本地有值服务端没有的假象)。 */
|
||||||
export async function saveApiSecret(moduleKey: ApiSecretModuleKey, value: string): Promise<ApiSecretSnapshot> {
|
export async function saveApiSecret(moduleKey: ApiSecretModuleKey, value: string): Promise<ApiSecretSnapshot> {
|
||||||
const trimmed = value.trim()
|
const trimmed = value.trim()
|
||||||
|
|||||||
Reference in New Issue
Block a user