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