feat(密钥): 用户 API 密钥服务端化——V115 按账号绑定存储 + 后台密钥管理页 + 桌面端全站拦截与配置引导

- 新增 usersecret 模块:外观专利/货源查询密钥从本地 localStorage 迁移至 biz_user_api_secret(AES 加密、按 uid 绑定)
- 后台「密钥管理」页:脱敏展示、立即检测、清空;每日 04:30 分布式锁定时巡检
- 桌面端:密钥设置面板走服务端、未配齐引导 /setup-secrets、代理余量展示
- 删除专利汇令牌全链路与密钥保留时长选择器
This commit is contained in:
2026-09-13 10:03:59 +08:00
parent d1b56918fa
commit 82a782550e
61 changed files with 4108 additions and 655 deletions
+1
View File
@@ -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>&nbsp;</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>
+1
View File
@@ -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') },
+1 -1
View File
@@ -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} 必须是懒加载函数`)
}
+1 -1
View File
@@ -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', () => {
+1 -1
View File
@@ -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:30Asia/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 = "";
}
@@ -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;
}
@@ -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;
@@ -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<>();
@@ -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);
@@ -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"),
@@ -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")
@@ -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-effortservice 内部所有异常都已吞掉不影响主流程
*/
private final SimilarAsinImagePrefetchService imagePrefetchService;
private final UserApiSecretService userApiSecretService;
/**
* Task 89Excel 行解析器表头/单元格读取别名匹配空行跳过单字段截断
* 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) {
@@ -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();
}
}
@@ -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()));
}
}
@@ -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();
}
}
@@ -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> {
}
@@ -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;
}
@@ -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;
}
@@ -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 = "密钥模块 keyappearance-patent/similar-asin", requiredMode = Schema.RequiredMode.REQUIRED)
private String moduleKey;
@Schema(description = "密钥明文", requiredMode = Schema.RequiredMode.REQUIRED)
private String value;
}
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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);
}
}
}
@@ -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) {
}
}
@@ -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) {
}
}
@@ -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 '用户IDusers.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'
);
@@ -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 签名不变 ----------
@@ -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) {
@@ -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() {
@@ -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);
}
}
@@ -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;
}
}
+16 -2
View File
@@ -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 前端统一入口(SPAURL 无 .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>
+6
View File
@@ -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',
+8
View File
@@ -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>
+408 -151
View File
@@ -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()),
}
}
+9
View File
@@ -18,6 +18,7 @@ const MODULE_KEYS = [
'priceTrack',
'similarAsin',
'appearancePatent',
'userSecret',
'collectData',
'imageVideo',
'brand',
@@ -307,6 +308,14 @@ test('test_endpoints_frozen_snapshot', () => {
"taskDelete": "/api/appearance-patent/tasks/{taskId}",
"resultDownload": "/api/appearance-patent/results/{resultId}/download"
},
"userSecret": {
"bundle": "/api/user-secrets",
"save": "/api/user-secrets/{moduleKey}",
"clear": "/api/user-secrets/{moduleKey}",
"check": "/api/user-secrets/{moduleKey}/check",
"migrate": "/api/user-secrets/migrate",
"proxyBalance": "/api/user-secrets/proxy-balance"
},
"collectData": {
"parse": "/api/collect-data/parse",
"countryPreference": "/api/collect-data/country-preference",
+1 -1
View File
@@ -12,7 +12,7 @@ const ALLOWED_SECTIONS = ['url', 'method', 'params', 'data'] as const
const apiModules = [
'dedupe', 'split', 'convert', 'publish', 'similar-asin', 'appearance-patent',
'delete-brand', 'price-track', 'shop-match', 'query-asin', 'withdraw',
'collect-data', 'image-video', 'brand', 'permission', 'digital-human',
'collect-data', 'image-video', 'brand', 'permission', 'digital-human', 'user-secret',
] as const
export function isApiModule(name: string): boolean {
@@ -37,7 +37,7 @@ test('test_appearance_patent_parse_url_payload', async (t) => {
captured = config
return okResponse({ taskId: 1, totalRows: 1, acceptedRows: 1, droppedRows: 0, items: [] })
})
await parseAppearancePatent([{ fileKey: 'k1' }], '请识别', 'api-1', 'tk-1')
await parseAppearancePatent([{ fileKey: 'k1' }], '请识别', 'api-1')
assert.equal(captured.url, '/newApi/api/appearance-patent/parse')
assert.equal(captured.method, 'POST')
assert.deepEqual(captured.data, {
@@ -45,7 +45,6 @@ test('test_appearance_patent_parse_url_payload', async (t) => {
files: [{ fileKey: 'k1' }],
ai_prompt: '请识别',
api_key: 'api-1',
patent_token: 'tk-1',
})
})
+132
View File
@@ -0,0 +1,132 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { http } from '../src/shared/api/http.ts'
import {
clearApiSecretCache,
getStoredApiSecret,
getStoredApiSecretSnapshot,
loadApiSecrets,
saveApiSecret,
} from '../src/shared/utils/api-secret-store.ts'
type RequestConfig = { url?: string; method?: string; data?: unknown }
type MockTest = Parameters<typeof test>[1] extends (t: infer T) => unknown ? T : never
function createStorage() {
const store = new Map<string, string>()
return {
getItem: (key: string) => (store.has(key) ? (store.get(key) as string) : null),
setItem: (key: string, value: string) => {
store.set(key, String(value))
},
removeItem: (key: string) => {
store.delete(key)
},
}
}
function setupWindow() {
const localStorage = createStorage()
const sessionStorage = createStorage()
// 密钥按登录用户隔离:uid 必须存在(与真实前端 localStorage.uid 一致)
localStorage.setItem('uid', '42')
;(globalThis as Record<string, unknown>).window = {
localStorage,
sessionStorage,
location: { origin: 'http://localhost' },
}
return { localStorage, sessionStorage }
}
function mockRequest(t: MockTest, impl: (config: RequestConfig) => Promise<unknown>) {
t.mock.method(http, 'request', impl as never)
}
const okResponse = (data: unknown) => Promise.resolve({ data: { success: true, message: 'ok', data } })
function serverItem(moduleKey: string, overrides: Record<string, unknown> = {}) {
return {
moduleKey,
moduleLabel: moduleKey === 'appearance-patent' ? '外观专利密钥' : '货源查询密钥',
masked: '',
exists: false,
checkStatus: 'unknown',
checkCode: '',
checkMessage: '',
checkLatencyMs: null,
checkedAt: null,
updatedAt: null,
...overrides,
}
}
function bundleResponse(items: unknown[]) {
return okResponse({
items,
requiredModules: ['appearance-patent', 'similar-asin'],
complete: false,
})
}
const LEGACY_MIRROR_KEY = 'brand:api-secret:42:appearance-patent'
test('test_secret_store_reads_legacy_value_before_load', () => {
const { localStorage } = setupWindow()
clearApiSecretCache()
localStorage.setItem(LEGACY_MIRROR_KEY, JSON.stringify({ value: 'legacy-key-1', retention: 'forever' }))
assert.equal(getStoredApiSecret('appearance-patent'), 'legacy-key-1')
})
test('test_secret_store_load_failure_returns_unknown_and_keeps_cache', async (t) => {
const { localStorage } = setupWindow()
clearApiSecretCache()
localStorage.setItem(LEGACY_MIRROR_KEY, JSON.stringify({ value: 'legacy-key-2' }))
mockRequest(t, () => Promise.reject(new Error('服务不可用')))
const state = await loadApiSecrets({ force: true })
assert.equal(state, 'unknown')
assert.equal(getStoredApiSecret('appearance-patent'), 'legacy-key-2')
})
test('test_secret_store_migrates_legacy_value_once_and_clears_local', async (t) => {
const { localStorage } = setupWindow()
clearApiSecretCache()
localStorage.setItem(LEGACY_MIRROR_KEY, JSON.stringify({ value: 'legacy-key-3' }))
const calls: string[] = []
mockRequest(t, (config) => {
calls.push(config.url || '')
if ((config.url || '').includes('/migrate')) {
return okResponse({ migrated: 1 })
}
return bundleResponse([serverItem('appearance-patent'), serverItem('similar-asin')])
})
const state = await loadApiSecrets({ force: true })
assert.equal(state, 'incomplete')
assert.ok(calls.some((url) => url.includes('/api/user-secrets/migrate')), '应调用迁移接口')
assert.equal(localStorage.getItem(LEGACY_MIRROR_KEY), null, '迁移后应清理旧记录')
})
test('test_secret_store_save_keeps_plain_value_locally', async (t) => {
setupWindow()
clearApiSecretCache()
mockRequest(t, () =>
okResponse(serverItem('appearance-patent', { masked: 'sk-a****1234', exists: true })),
)
await saveApiSecret('appearance-patent', 'sk-abc123456')
assert.equal(getStoredApiSecret('appearance-patent'), 'sk-abc123456')
const snapshot = getStoredApiSecretSnapshot('appearance-patent')
assert.equal(snapshot.masked, 'sk-a****1234')
assert.equal(snapshot.exists, true)
})
test('test_secret_store_save_rejects_empty_value', async () => {
setupWindow()
clearApiSecretCache()
await assert.rejects(() => saveApiSecret('similar-asin', ' '), /密钥不能为空/)
})