From 82a782550eedb19c35eff37722249bf32c066f0c 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:03:59 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E5=AF=86=E9=92=A5):=20=E7=94=A8=E6=88=B7?= =?UTF-8?q?=20API=20=E5=AF=86=E9=92=A5=E6=9C=8D=E5=8A=A1=E7=AB=AF=E5=8C=96?= =?UTF-8?q?=E2=80=94=E2=80=94V115=20=E6=8C=89=E8=B4=A6=E5=8F=B7=E7=BB=91?= =?UTF-8?q?=E5=AE=9A=E5=AD=98=E5=82=A8=20+=20=E5=90=8E=E5=8F=B0=E5=AF=86?= =?UTF-8?q?=E9=92=A5=E7=AE=A1=E7=90=86=E9=A1=B5=20+=20=E6=A1=8C=E9=9D=A2?= =?UTF-8?q?=E7=AB=AF=E5=85=A8=E7=AB=99=E6=8B=A6=E6=88=AA=E4=B8=8E=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E5=BC=95=E5=AF=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 usersecret 模块:外观专利/货源查询密钥从本地 localStorage 迁移至 biz_user_api_secret(AES 加密、按 uid 绑定) - 后台「密钥管理」页:脱敏展示、立即检测、清空;每日 04:30 分布式锁定时巡检 - 桌面端:密钥设置面板走服务端、未配齐引导 /setup-secrets、代理余量展示 - 删除专利汇令牌全链路与密钥保留时长选择器 --- .gitignore | 1 + admin-frontend-vue/src/api/user-secrets.ts | 60 ++ .../src/layout/operation-guides.ts | 4 + .../src/pages/account/UserSecretsPage.vue | 468 +++++++++++++ admin-frontend-vue/src/router/routes.ts | 1 + admin-frontend-vue/tests/task-10.test.ts | 2 +- admin-frontend-vue/tests/task-12.test.ts | 2 +- admin-frontend-vue/tests/task-8.test.ts | 2 +- .../aiimage/config/AdminApiGuardFilter.java | 15 +- .../aiimage/config/PropertiesConfig.java | 2 +- .../aiimage/config/UserSecretProperties.java | 39 ++ .../dto/AppearancePatentParseRequest.java | 9 +- .../AppearancePatentParsedGroupPageDto.java | 1 - .../dto/AppearancePatentParsedPayloadDto.java | 3 - .../service/AppearancePatentTaskService.java | 33 +- .../PermissionMenuSchemaInitializer.java | 1 + .../model/dto/SimilarAsinParseRequest.java | 4 +- .../service/SimilarAsinTaskService.java | 21 +- .../usersecret/client/JikipProxyClient.java | 206 ++++++ .../AdminUserApiSecretController.java | 90 +++ .../controller/UserApiSecretController.java | 104 +++ .../mapper/UserApiSecretMapper.java | 9 + .../model/dto/AdminUserSecretQuery.java | 24 + .../model/dto/UserApiSecretCheckRequest.java | 12 + .../dto/UserApiSecretMigrateRequest.java | 28 + .../model/dto/UserApiSecretSaveRequest.java | 14 + .../model/entity/UserApiSecretEntity.java | 27 + .../model/vo/AdminUserSecretItemVo.java | 53 ++ .../model/vo/AdminUserSecretPageVo.java | 23 + .../model/vo/UserApiSecretBalanceVo.java | 21 + .../model/vo/UserApiSecretBundleVo.java | 20 + .../model/vo/UserApiSecretCheckResultVo.java | 32 + .../model/vo/UserApiSecretItemVo.java | 41 ++ .../service/UserApiSecretCheckScheduler.java | 49 ++ .../service/UserApiSecretCheckService.java | 267 ++++++++ .../service/UserApiSecretService.java | 539 +++++++++++++++ .../usersecret/support/UserSecretModule.java | 61 ++ .../src/main/resources/application.yml | 10 + .../resources/db/V115__user_api_secret.sql | 31 + ...arancePatentTaskServiceDelegationTest.java | 7 +- ...ancePatentTaskServiceHistoryBatchTest.java | 4 +- .../RollbackSemanticsContractTest.java | 4 +- .../UserApiSecretCheckServiceTest.java | 90 +++ .../service/UserApiSecretServiceTest.java | 163 +++++ frontend-vue/src/main.ts | 18 +- .../BrandApiSecretSettingsButton.vue | 466 +------------ .../components/BrandAppearancePatentTab.vue | 15 +- .../brand/components/BrandSimilarAsinTab.vue | 8 +- .../src/pages/login/DesktopLoginPage.vue | 3 + .../pages/setup/DesktopSecretSetupPage.vue | 218 ++++++ frontend-vue/src/router/index.ts | 6 + frontend-vue/src/shared/api/endpoints.ts | 8 + frontend-vue/src/shared/api/java-modules.ts | 1 + .../api/types/modules/appearance-patent.ts | 6 +- .../shared/api/types/modules/user-secret.ts | 97 +++ .../components/ApiSecretSettingsPanel.vue | 621 ++++++++++++++++++ .../src/shared/utils/api-secret-store.ts | 553 +++++++++++----- frontend-vue/tests/endpoints.test.ts | 9 + frontend-vue/tests/helpers/api-snapshot.ts | 2 +- .../tests/modules-appearance-patent.test.ts | 3 +- frontend-vue/tests/secret-store.test.ts | 132 ++++ 61 files changed, 4108 insertions(+), 655 deletions(-) create mode 100644 admin-frontend-vue/src/api/user-secrets.ts create mode 100644 admin-frontend-vue/src/pages/account/UserSecretsPage.vue create mode 100644 backend-java/src/main/java/com/nanri/aiimage/config/UserSecretProperties.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/client/JikipProxyClient.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/controller/AdminUserApiSecretController.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/controller/UserApiSecretController.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/mapper/UserApiSecretMapper.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/model/dto/AdminUserSecretQuery.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/model/dto/UserApiSecretCheckRequest.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/model/dto/UserApiSecretMigrateRequest.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/model/dto/UserApiSecretSaveRequest.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/model/entity/UserApiSecretEntity.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/model/vo/AdminUserSecretItemVo.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/model/vo/AdminUserSecretPageVo.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/model/vo/UserApiSecretBalanceVo.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/model/vo/UserApiSecretBundleVo.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/model/vo/UserApiSecretCheckResultVo.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/model/vo/UserApiSecretItemVo.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/service/UserApiSecretCheckScheduler.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/service/UserApiSecretCheckService.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/service/UserApiSecretService.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/support/UserSecretModule.java create mode 100644 backend-java/src/main/resources/db/V115__user_api_secret.sql create mode 100644 backend-java/src/test/java/com/nanri/aiimage/modules/usersecret/service/UserApiSecretCheckServiceTest.java create mode 100644 backend-java/src/test/java/com/nanri/aiimage/modules/usersecret/service/UserApiSecretServiceTest.java create mode 100644 frontend-vue/src/pages/setup/DesktopSecretSetupPage.vue create mode 100644 frontend-vue/src/shared/api/types/modules/user-secret.ts create mode 100644 frontend-vue/src/shared/components/ApiSecretSettingsPanel.vue create mode 100644 frontend-vue/tests/secret-store.test.ts diff --git a/.gitignore b/.gitignore index 2f467210..4423b6a3 100644 --- a/.gitignore +++ b/.gitignore @@ -100,6 +100,7 @@ ipython_config.py *.sqlite3 # ===== Misc ===== +.playwright-cli/ desktop/ ERP-Demo/ xlsx/ diff --git a/admin-frontend-vue/src/api/user-secrets.ts b/admin-frontend-vue/src/api/user-secrets.ts new file mode 100644 index 00000000..61689935 --- /dev/null +++ b/admin-frontend-vue/src/api/user-secrets.ts @@ -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 { + 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) +} + +/** 清空指定用户密钥:DELETE /api/admin/user-secrets/{id} */ +export async function deleteUserSecret(id: number): Promise { + const { data } = await http.delete(`/api/admin/user-secrets/${id}`) + unwrap(data) +} diff --git a/admin-frontend-vue/src/layout/operation-guides.ts b/admin-frontend-vue/src/layout/operation-guides.ts index 1b837a8e..019438a5 100644 --- a/admin-frontend-vue/src/layout/operation-guides.ts +++ b/admin-frontend-vue/src/layout/operation-guides.ts @@ -25,6 +25,10 @@ export const OPERATION_GUIDES: Record = { steps: ['新增或调整菜单', '设置层级', '拖动排序'], }, admin_group_manage: OPERATION_GUIDE_FALLBACK, + admin_user_secrets: { + text: '用户密钥按账号绑定,列表只展示脱敏值。可对单条立即检测连通性;清空后该用户需要重新配置密钥。', + steps: ['筛选用户或状态', '立即检测连通性', '必要时清空'], + }, admin_dedupe_total_data: { text: '支持按关键词、用户、分组和国家筛选;导入前先确认所选分组,导出会按日期范围生成文件。', steps: ['选择分组', '筛选或导入', '核对并导出'], diff --git a/admin-frontend-vue/src/pages/account/UserSecretsPage.vue b/admin-frontend-vue/src/pages/account/UserSecretsPage.vue new file mode 100644 index 00000000..d7146167 --- /dev/null +++ b/admin-frontend-vue/src/pages/account/UserSecretsPage.vue @@ -0,0 +1,468 @@ + + + + + diff --git a/admin-frontend-vue/src/router/routes.ts b/admin-frontend-vue/src/router/routes.ts index 1924889c..9ffbaa13 100644 --- a/admin-frontend-vue/src/router/routes.ts +++ b/admin-frontend-vue/src/router/routes.ts @@ -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') }, diff --git a/admin-frontend-vue/tests/task-10.test.ts b/admin-frontend-vue/tests/task-10.test.ts index 27281f67..4b991c05 100644 --- a/admin-frontend-vue/tests/task-10.test.ts +++ b/admin-frontend-vue/tests/task-10.test.ts @@ -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} 必须是懒加载函数`) } diff --git a/admin-frontend-vue/tests/task-12.test.ts b/admin-frontend-vue/tests/task-12.test.ts index 9904917e..fca045da 100644 --- a/admin-frontend-vue/tests/task-12.test.ts +++ b/admin-frontend-vue/tests/task-12.test.ts @@ -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', () => { diff --git a/admin-frontend-vue/tests/task-8.test.ts b/admin-frontend-vue/tests/task-8.test.ts index 11b46cb5..cf212cfe 100644 --- a/admin-frontend-vue/tests/task-8.test.ts +++ b/admin-frontend-vue/tests/task-8.test.ts @@ -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') diff --git a/backend-java/src/main/java/com/nanri/aiimage/config/AdminApiGuardFilter.java b/backend-java/src/main/java/com/nanri/aiimage/config/AdminApiGuardFilter.java index d114515c..fe8df5c9 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/config/AdminApiGuardFilter.java +++ b/backend-java/src/main/java/com/nanri/aiimage/config/AdminApiGuardFilter.java @@ -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; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/config/PropertiesConfig.java b/backend-java/src/main/java/com/nanri/aiimage/config/PropertiesConfig.java index 97701fe3..3b7e80bf 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/config/PropertiesConfig.java +++ b/backend-java/src/main/java/com/nanri/aiimage/config/PropertiesConfig.java @@ -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 { } diff --git a/backend-java/src/main/java/com/nanri/aiimage/config/UserSecretProperties.java b/backend-java/src/main/java/com/nanri/aiimage/config/UserSecretProperties.java new file mode 100644 index 00000000..e0a6c3e0 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/config/UserSecretProperties.java @@ -0,0 +1,39 @@ +package com.nanri.aiimage.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * 用户 API 密钥(外观专利密钥 / 货源查询密钥)服务端化配置。 + */ +@Data +@ConfigurationProperties(prefix = "aiimage.user-secret") +public class UserSecretProperties { + + /** 每日定时连通性巡检开关(应急可关,无需重新打包)。 */ + private boolean checkEnabled = true; + + /** 巡检 cron(默认每天 04:30,Asia/Shanghai)。 */ + private String checkCron = "0 30 4 * * *"; + + /** 单轮巡检最多检测条数,超出顺延下一轮。 */ + private int checkMaxRows = 500; + + /** 单轮巡检时间预算(分钟),超时中断本轮。 */ + private int checkBudgetMinutes = 20; + + /** + * 检测出口代理提取链接(选配):配置后检测请求优先经该代理 IP 发出, + * 代理不可用时自动回退直连;留空则全部直连。 + */ + private String checkProxyExtractUrl = ""; + + /** jikip 余量查询接口(客户端设置弹窗展示套餐 IP 余量 / 账户余额)。 */ + private String jikipBalanceUrl = "https://api.jikip.com/find-balance"; + + /** jikip 套餐 id(余量查询参数)。 */ + private String jikipPlanId = ""; + + /** jikip 用户 ID(余量查询参数)。 */ + private String jikipUserId = ""; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/dto/AppearancePatentParseRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/dto/AppearancePatentParseRequest.java index a03912bc..00ab472e 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/dto/AppearancePatentParseRequest.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/dto/AppearancePatentParseRequest.java @@ -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; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/dto/AppearancePatentParsedGroupPageDto.java b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/dto/AppearancePatentParsedGroupPageDto.java index 75a6b2a6..35d90046 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/dto/AppearancePatentParsedGroupPageDto.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/dto/AppearancePatentParsedGroupPageDto.java @@ -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; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/dto/AppearancePatentParsedPayloadDto.java b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/dto/AppearancePatentParsedPayloadDto.java index 6d4c27be..9db1743f 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/dto/AppearancePatentParsedPayloadDto.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/dto/AppearancePatentParsedPayloadDto.java @@ -17,9 +17,6 @@ public class AppearancePatentParsedPayloadDto { @Schema(description = "调用 LLM API 的任务级密钥") private String apiKey; - @Schema(description = "专利汇令牌") - private String patentToken; - @Schema(description = "本次解析的源文件列表") private List sourceFiles = new ArrayList<>(); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskService.java index 9f7fe106..f005fcf5 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskService.java @@ -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 sourceFiles, List headers, List allRows) { + private String buildParsedPayloadJson(String aiPrompt, String apiKey, List sourceFiles, List headers, List 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 sourceFiles, String parsedPayloadPointer) { + private String buildTaskResultJson(String aiPrompt, String apiKey, List sourceFiles, String parsedPayloadPointer) { Map 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); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/permission/service/PermissionMenuSchemaInitializer.java b/backend-java/src/main/java/com/nanri/aiimage/modules/permission/service/PermissionMenuSchemaInitializer.java index b852cba6..cec77e1f 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/permission/service/PermissionMenuSchemaInitializer.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/permission/service/PermissionMenuSchemaInitializer.java @@ -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"), diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/model/dto/SimilarAsinParseRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/model/dto/SimilarAsinParseRequest.java index 35f5fd86..aa0509a9 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/model/dto/SimilarAsinParseRequest.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/model/dto/SimilarAsinParseRequest.java @@ -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") diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskService.java index 2f1b281a..d7d42bd7 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskService.java @@ -60,6 +60,8 @@ import com.nanri.aiimage.modules.task.service.TaskFileJobService; import com.nanri.aiimage.modules.task.service.TaskDistributedLockService; import com.nanri.aiimage.modules.task.service.TaskProgressSnapshotService; import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService; +import com.nanri.aiimage.modules.usersecret.service.UserApiSecretService; +import com.nanri.aiimage.modules.usersecret.support.UserSecretModule; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.poi.ss.usermodel.Cell; @@ -339,6 +341,7 @@ public class SimilarAsinTaskService { * best-effort:service 内部所有异常都已吞掉,不影响主流程。 */ private final SimilarAsinImagePrefetchService imagePrefetchService; + private final UserApiSecretService userApiSecretService; /** * Task 89:Excel 行解析器(表头/单元格读取、别名匹配、空行跳过、单字段截断)。 * 由 parseAndCreateTask 委托;解析语义与搬移前 parseWorkbook 完全一致。 @@ -2031,12 +2034,24 @@ public class SimilarAsinTaskService { } } + /** 密钥读取:任务 payload 优先(兼容老任务与显式覆盖),为空时按任务归属用户从服务端密钥表兜底。 */ private String readApiKey(FileTaskEntity task) { try { - return normalize(readParsedPayload(task).getApiKey()); - } catch (Exception ignored) { - return ""; + String fromPayload = normalize(readParsedPayload(task).getApiKey()); + if (!fromPayload.isEmpty()) { + return fromPayload; + } + } catch (Exception ex) { + log.warn("[similar-asin] 读取任务 payload 密钥失败,尝试用户密钥兜底 taskId={} err={}", + task.getId(), ex.getMessage()); } + String fromUserSecret = userApiSecretService.findPlainValue( + task.getUserId(), UserSecretModule.SIMILAR_ASIN.key()); + if (fromUserSecret.isEmpty()) { + log.warn("[similar-asin] 任务未携带密钥且用户未配置密钥,LLM 检测将保留原始行 taskId={} userId={}", + task.getId(), task.getUserId()); + } + return fromUserSecret; } private boolean readImgSwitch(FileTaskEntity task) { diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/client/JikipProxyClient.java b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/client/JikipProxyClient.java new file mode 100644 index 00000000..e53a04f2 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/client/JikipProxyClient.java @@ -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(); + } +} 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 new file mode 100644 index 00000000..434c53d0 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/controller/AdminUserApiSecretController.java @@ -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 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 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 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> 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())); + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/controller/UserApiSecretController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/controller/UserApiSecretController.java new file mode 100644 index 00000000..e684520a --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/controller/UserApiSecretController.java @@ -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 bundle(HttpServletRequest request) { + Long userId = currentUserId(request); + return ApiResponse.success(userApiSecretService.bundle(userId)); + } + + @PutMapping("/{moduleKey}") + @Operation(summary = "保存密钥", description = "加密落库并重置检测状态为未检测,保存后客户端应立即触发一次检测。") + public ApiResponse 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 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 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> 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 proxyBalance(HttpServletRequest request) { + currentUserId(request); + return ApiResponse.success(userApiSecretService.proxyBalance()); + } + + private Long currentUserId(HttpServletRequest request) { + AdminUserEntity me = adminAuthSupport.requireUser(request); + return me.getId(); + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/mapper/UserApiSecretMapper.java b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/mapper/UserApiSecretMapper.java new file mode 100644 index 00000000..b647aa14 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/mapper/UserApiSecretMapper.java @@ -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 { +} 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 new file mode 100644 index 00000000..6b0fdbe2 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/model/dto/AdminUserSecretQuery.java @@ -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; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/model/dto/UserApiSecretCheckRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/model/dto/UserApiSecretCheckRequest.java new file mode 100644 index 00000000..5277414b --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/model/dto/UserApiSecretCheckRequest.java @@ -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; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/model/dto/UserApiSecretMigrateRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/model/dto/UserApiSecretMigrateRequest.java new file mode 100644 index 00000000..9cb2dc0c --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/model/dto/UserApiSecretMigrateRequest.java @@ -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 items; + + @Data + @Schema(description = "单项迁移数据") + public static class Item { + + @Schema(description = "密钥模块 key:appearance-patent/similar-asin", requiredMode = Schema.RequiredMode.REQUIRED) + private String moduleKey; + + @Schema(description = "密钥明文", requiredMode = Schema.RequiredMode.REQUIRED) + private String value; + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/model/dto/UserApiSecretSaveRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/model/dto/UserApiSecretSaveRequest.java new file mode 100644 index 00000000..86d75396 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/model/dto/UserApiSecretSaveRequest.java @@ -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; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/model/entity/UserApiSecretEntity.java b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/model/entity/UserApiSecretEntity.java new file mode 100644 index 00000000..5e4b76bf --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/model/entity/UserApiSecretEntity.java @@ -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; +} 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/AdminUserSecretItemVo.java new file mode 100644 index 00000000..a4f1345e --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/model/vo/AdminUserSecretItemVo.java @@ -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; +} 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 new file mode 100644 index 00000000..6caba379 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/model/vo/AdminUserSecretPageVo.java @@ -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 items; + + @Schema(description = "总条数") + private Long total; + + @Schema(description = "页码") + private Long page; + + @Schema(description = "每页数量") + private Long pageSize; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/model/vo/UserApiSecretBalanceVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/model/vo/UserApiSecretBalanceVo.java new file mode 100644 index 00000000..797b082b --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/model/vo/UserApiSecretBalanceVo.java @@ -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; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/model/vo/UserApiSecretBundleVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/model/vo/UserApiSecretBundleVo.java new file mode 100644 index 00000000..e4da8713 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/model/vo/UserApiSecretBundleVo.java @@ -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 items; + + @Schema(description = "必须配置的模块 key 列表(服务端下发,客户端不硬编码)") + private List requiredModules; + + @Schema(description = "是否配置完整:全部 required 模块均检测通过(error 状态视为放行)") + private Boolean complete; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/model/vo/UserApiSecretCheckResultVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/model/vo/UserApiSecretCheckResultVo.java new file mode 100644 index 00000000..3048455a --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/model/vo/UserApiSecretCheckResultVo.java @@ -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; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/model/vo/UserApiSecretItemVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/model/vo/UserApiSecretItemVo.java new file mode 100644 index 00000000..5f6c03dd --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/model/vo/UserApiSecretItemVo.java @@ -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; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/service/UserApiSecretCheckScheduler.java b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/service/UserApiSecretCheckScheduler.java new file mode 100644 index 00000000..09c0f577 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/service/UserApiSecretCheckScheduler.java @@ -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); + } + } +} 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 new file mode 100644 index 00000000..fbc95260 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/service/UserApiSecretCheckService.java @@ -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 buildCheckBody(String model) { + Map body = new LinkedHashMap<>(); + body.put("model", model); + body.put("stream", false); + body.put("max_tokens", CHECK_MAX_TOKENS); + List> messages = new ArrayList<>(1); + Map 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) { + } +} 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 new file mode 100644 index 00000000..d9b76997 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/service/UserApiSecretService.java @@ -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 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() + .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 items) { + requireUserId(userId); + if (items == null || items.isEmpty()) { + return 0; + } + int migrated = 0; + for (UserApiSecretMigrateRequest.Item item : items) { + if (item == null) { + continue; + } + Optional 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 wrapper = new LambdaQueryWrapper<>(); + String keyword = normalize(safeQuery.getKeyword()); + if (!keyword.isEmpty()) { + List 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 result = userApiSecretMapper.selectPage(new Page<>(page, pageSize), wrapper); + List 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 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 batch = userApiSecretMapper.selectList(new LambdaQueryWrapper() + .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 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() + .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() + .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 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")); + 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 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) { + } +} 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 new file mode 100644 index 00000000..0846310b --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/support/UserSecretModule.java @@ -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 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) { + } +} diff --git a/backend-java/src/main/resources/application.yml b/backend-java/src/main/resources/application.yml index 8ea69b98..a193bddb 100644 --- a/backend-java/src/main/resources/application.yml +++ b/backend-java/src/main/resources/application.yml @@ -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:} diff --git a/backend-java/src/main/resources/db/V115__user_api_secret.sql b/backend-java/src/main/resources/db/V115__user_api_secret.sql new file mode 100644 index 00000000..b700dce3 --- /dev/null +++ b/backend-java/src/main/resources/db/V115__user_api_secret.sql @@ -0,0 +1,31 @@ +-- V115: 用户 API 密钥服务端化(外观专利密钥 / 货源查询密钥) +-- 密钥从客户端本地存储迁移到服务端,按登录用户绑定、加密存储; +-- 同时记录连通性检测状态,供后台「密钥管理」页展示与每日定时巡检更新。 + +CREATE TABLE IF NOT EXISTS `biz_user_api_secret` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `user_id` BIGINT NOT NULL COMMENT '用户ID(users.id)', + `module_key` VARCHAR(64) NOT NULL COMMENT '密钥模块:appearance-patent/similar-asin', + `secret_value` VARCHAR(2048) NOT NULL COMMENT '密钥密文(AES 加密)', + `check_status` VARCHAR(16) NOT NULL DEFAULT 'unknown' COMMENT '连通性状态:unknown/passed/failed/error', + `check_code` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '检测结果码:ok/invalid_key/forbidden/bad_request/rate_limited/server_error/network_error/provider_error', + `check_message` VARCHAR(512) NOT NULL DEFAULT '' COMMENT '检测结果说明', + `check_latency_ms` INT NULL COMMENT '检测耗时(毫秒)', + `checked_at` DATETIME NULL COMMENT '最近检测时间', + `source` VARCHAR(16) NOT NULL DEFAULT 'client' COMMENT '写入来源:client/admin/migrated', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_user_module` (`user_id`, `module_key`), + KEY `idx_check_status` (`check_status`), + KEY `idx_checked_at` (`checked_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户 API 密钥(服务端存储,按用户绑定)'; + +-- 后台菜单:密钥管理(挂在「账号与权限」分组下;幂等,仅当 column_key 不存在时插入) +INSERT INTO columns (name, column_key, menu_type, route_path, sort_order, parent_id) +SELECT '密钥管理', 'admin_user_secrets', 'admin', 'account/user-secrets', 41, parent.id +FROM columns parent +WHERE parent.column_key = 'admin_group_account' + AND NOT EXISTS ( + SELECT 1 FROM columns WHERE column_key = 'admin_user_secrets' + ); diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskServiceDelegationTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskServiceDelegationTest.java index 16db5b80..531fd9b4 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskServiceDelegationTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskServiceDelegationTest.java @@ -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 签名不变 ---------- diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskServiceHistoryBatchTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskServiceHistoryBatchTest.java index 915b701b..1cb52814 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskServiceHistoryBatchTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskServiceHistoryBatchTest.java @@ -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) { diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/task/contract/RollbackSemanticsContractTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/task/contract/RollbackSemanticsContractTest.java index c6f6cf27..2456fe13 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/task/contract/RollbackSemanticsContractTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/task/contract/RollbackSemanticsContractTest.java @@ -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() { diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/usersecret/service/UserApiSecretCheckServiceTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/usersecret/service/UserApiSecretCheckServiceTest.java new file mode 100644 index 00000000..e7025b09 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/usersecret/service/UserApiSecretCheckServiceTest.java @@ -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); + } +} 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 new file mode 100644 index 00000000..7070cab1 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/usersecret/service/UserApiSecretServiceTest.java @@ -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 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; + } +} diff --git a/frontend-vue/src/main.ts b/frontend-vue/src/main.ts index 3c5d5d1c..f86ace31 100644 --- a/frontend-vue/src/main.ts +++ b/frontend-vue/src/main.ts @@ -7,18 +7,32 @@ import '@/styles/main.css' import App from '@/App.vue' import router from '@/router' import { ensureAuth } from '@/shared/auth/ensure-auth' +import { ensureApiSecretsLoaded, loadApiSecrets } from '@/shared/utils/api-secret-store' /** * 数富AI 前端统一入口(SPA,URL 无 .html 后缀) * * 原 MPA 的 22 个 html 入口 + 22 个 *-main.ts 已合并: - * 页面路由见 src/router/index.ts;登录态由路由守卫统一引导。 + * 页面路由见 src/router/index.ts;登录态由路由守卫统一引导; + * 密钥门禁:服务端密钥未配置完整时全站拦截到 /setup-secrets(拉取失败软失败放行)。 */ +// 后台预热密钥包,减少首次进入守卫时的等待 +void loadApiSecrets() + router.beforeEach(async (to) => { if (to.name === 'login') return true const ok = await ensureAuth() - return ok ? true : { name: 'login' } + if (!ok) return { name: 'login' } + if (to.name === 'setup-secrets') return true + // 密钥门禁:仅当服务端明确返回"未配置完整"时拦截; + // unknown(拉取失败/网络异常)一律放行,避免服务端抖动把全体用户锁死。 + const secretState = await ensureApiSecretsLoaded() + if (secretState === 'incomplete') { + const query = to.fullPath && to.fullPath !== '/' ? { redirect: to.fullPath } : {} + return { name: 'setup-secrets', query } + } + return true }) router.beforeEach((to) => { diff --git a/frontend-vue/src/pages/brand/components/BrandApiSecretSettingsButton.vue b/frontend-vue/src/pages/brand/components/BrandApiSecretSettingsButton.vue index a55c7394..7f0a0436 100644 --- a/frontend-vue/src/pages/brand/components/BrandApiSecretSettingsButton.vue +++ b/frontend-vue/src/pages/brand/components/BrandApiSecretSettingsButton.vue @@ -17,117 +17,11 @@ -
-
-
-
-
{{ config.title }}
-
{{ config.description }}
-
- -
- - - -
-
保留时长
-
- -
-
- -
- - {{ formatRetentionText(secretStates[config.key].retention, secretStates[config.key].expiresAt) }} - - 当前未保存 -
-
- -
-
-
-
代理设置
-
供客户端任务连接代理服务。
-
-
- -
- - -
- -
-
代理模式
-
- -
-
- -
- 正在读取代理配置... - 代理配置读取失败,请关闭弹窗后重试 - 代理设置仅在桌面客户端中可用 -
-
-
+