diff --git a/admin-frontend-vue/src/api/user-secret-usage.ts b/admin-frontend-vue/src/api/user-secret-usage.ts new file mode 100644 index 00000000..71d91cfe --- /dev/null +++ b/admin-frontend-vue/src/api/user-secret-usage.ts @@ -0,0 +1,47 @@ +import { http } from './http' +import { unwrap } from './envelope' + +/** 单用户一行:三类密钥调用次数 + 合计(口径=真实对外请求次数)。 */ +export interface AdminUserSecretUsageRow { + userId: number + username: string + groups: string[] + similarAsinCount: number + appearancePatentCount: number + proxyCount: number + totalCount: number +} + +export interface AdminUserSecretUsagePage { + items: AdminUserSecretUsageRow[] + total: number + page: number + pageSize: number + /** 所选范围内(不分页)三类总次数。 */ + totalCalls: number + similarAsinCalls: number + appearancePatentCalls: number + proxyCalls: number + groupOptions?: Array<{ id: number; groupName: string }> +} + +export interface UserSecretUsageQuery { + startDate?: string + endDate?: string + keyword?: string + /** 按数据权限分组筛选(仅超管生效);后端参数为 snake_case。 */ + groupId?: number + page: number + pageSize: number +} + +/** 分页查询密钥用量:GET /api/admin/user-secret-usage(group_id 必须 snake_case)。 */ +export async function fetchUserSecretUsage(params: UserSecretUsageQuery): Promise { + const query: Record = { page: params.page, pageSize: params.pageSize } + if (params.startDate) query.startDate = params.startDate + if (params.endDate) query.endDate = params.endDate + if (params.keyword) query.keyword = params.keyword + if (params.groupId) query.group_id = params.groupId + const { data } = await http.get('/api/admin/user-secret-usage', { params: query }) + return unwrap(data) +} diff --git a/admin-frontend-vue/src/api/user-secrets.ts b/admin-frontend-vue/src/api/user-secrets.ts index cf6ea108..3868e708 100644 --- a/admin-frontend-vue/src/api/user-secrets.ts +++ b/admin-frontend-vue/src/api/user-secrets.ts @@ -6,6 +6,8 @@ export interface AdminUserSecretModule { moduleKey: string moduleLabel: string masked: string + /** 完整明文值:仅代理列有值(后端对密钥列不下发明文)。 */ + full: string exists: boolean checkStatus: string checkCode: string diff --git a/admin-frontend-vue/src/layout/operation-guides.ts b/admin-frontend-vue/src/layout/operation-guides.ts index 019438a5..a580303f 100644 --- a/admin-frontend-vue/src/layout/operation-guides.ts +++ b/admin-frontend-vue/src/layout/operation-guides.ts @@ -78,6 +78,10 @@ export const OPERATION_GUIDES: Record = { text: '数字人版本需先上传草稿,再发布并标记最新版本。', steps: ['上传草稿', '确认更新日志', '发布或设为最新'], }, + admin_user_secret_usage: { + text: '按日期范围统计各用户的密钥调用次数(货源查询与外观专利按 LLM 请求次数、代理按提取次数),用于评估用户资源损耗。', + steps: ['选择日期范围', '筛选用户或分组', '核对各模块次数'], + }, } /** 全部有独立/兜底提示的后台业务菜单 key。 */ diff --git a/admin-frontend-vue/src/pages/account/UserSecretUsagePage.vue b/admin-frontend-vue/src/pages/account/UserSecretUsagePage.vue new file mode 100644 index 00000000..5eac0d9c --- /dev/null +++ b/admin-frontend-vue/src/pages/account/UserSecretUsagePage.vue @@ -0,0 +1,395 @@ + + + + + diff --git a/admin-frontend-vue/src/pages/account/UserSecretsPage.vue b/admin-frontend-vue/src/pages/account/UserSecretsPage.vue index 38ce3175..4ffdb86d 100644 --- a/admin-frontend-vue/src/pages/account/UserSecretsPage.vue +++ b/admin-frontend-vue/src/pages/account/UserSecretsPage.vue @@ -77,10 +77,11 @@ function moduleStatusMeta(module: AdminUserSecretModule | undefined) { } } -/** 单格悬停详情:检测消息 + 检测时间 + 耗时。 */ +/** 单格悬停详情:完整值(代理为全量地址)+ 检测消息 + 检测时间 + 耗时。 */ function moduleTooltip(module: AdminUserSecretModule | undefined) { if (!module || !module.exists) return '未配置' const parts: string[] = [] + if (module.full) parts.push(module.full) if (module.checkMessage) parts.push(module.checkMessage) if (module.checkedAt) parts.push(`检测时间:${formatDateTime(module.checkedAt)}`) if (module.checkLatencyMs != null) parts.push(`耗时:${module.checkLatencyMs}ms`) @@ -230,7 +231,7 @@ onMounted(load) 分组 货源查询密钥 外观专利密钥 - 代理设置 + 代理设置 状态 操作 @@ -264,8 +265,8 @@ onMounted(load) -
- {{ row.proxy.masked }} +
+ {{ row.proxy.full || row.proxy.masked }} 未配置 {{ moduleStatusMeta(row.proxy).label }} @@ -486,11 +487,29 @@ h3 { gap: 8px; min-width: 0; } +/* 代理列:完整地址独占一行,状态药丸另起一行,避免长地址被药丸挤压折行 */ +.module-cell--stack { + flex-direction: column; + align-items: flex-start; + gap: 6px; +} .module-cell .mono-mask { min-width: 0; overflow: hidden; text-overflow: ellipsis; } +/* 代理列展示完整明文:允许折行显示,不截断、不省略 */ +.module-cell .mono-full { + flex: 1 1 auto; + min-width: 0; + overflow: visible; + text-overflow: clip; + white-space: normal; + overflow-wrap: anywhere; + line-height: 1.45; + cursor: text; + user-select: all; +} .mono-mask { font-family: Consolas, "Cascadia Mono", monospace; font-size: 12.5px; diff --git a/admin-frontend-vue/src/router/routes.ts b/admin-frontend-vue/src/router/routes.ts index 9ffbaa13..65532d53 100644 --- a/admin-frontend-vue/src/router/routes.ts +++ b/admin-frontend-vue/src/router/routes.ts @@ -18,6 +18,7 @@ export const adminPages: AdminPageDef[] = [ { 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: 'account/user-secret-usage', menuKey: 'admin_user_secret_usage', title: '密钥用量统计', load: () => import('@/pages/account/UserSecretUsagePage.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 4b991c05..d77816ad 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, 17) + assert.ok(adminPages.length >= 18, '业务页至少 18(随版本递增,不设上限断言)') 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 fca045da..d9e62b1e 100644 --- a/admin-frontend-vue/tests/task-12.test.ts +++ b/admin-frontend-vue/tests/task-12.test.ts @@ -34,7 +34,10 @@ 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, 17, '错误页不应计入业务路由') + // 业务路由条目与页面定义一一对应;错误页不占用其中任何一条。 + const router = readSource(ROUTER) + assert.equal(occurrences(router, '...adminRouteRecords'), 1, '错误页不应计入业务路由') + assert.ok(adminPages.length >= 18, '业务页至少 18(随版本递增,不设上限断言)') }) 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 cf212cfe..0f7ec7f6 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, 17) + assert.ok(adminPages.length >= 18, '业务页至少 18(随版本递增,不设上限断言)') 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/modules/appearancepatent/client/AppearancePatentLlmClient.java b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentLlmClient.java index 8d2b07e9..d1838be7 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentLlmClient.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentLlmClient.java @@ -7,6 +7,9 @@ import com.nanri.aiimage.config.HttpClientPool; import com.nanri.aiimage.metrics.ExternalCallMetricsRecorder; import com.nanri.aiimage.modules.appearancepatent.model.dto.AppearancePatentResultRowDto; import com.nanri.aiimage.modules.brand.client.BrandCheckClient; +import com.nanri.aiimage.modules.usersecret.service.UserSecretUsageService; +import com.nanri.aiimage.modules.usersecret.support.SecretUsageContext; +import com.nanri.aiimage.modules.usersecret.support.UserSecretModule; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpHeaders; @@ -97,6 +100,7 @@ public class AppearancePatentLlmClient { private final ObjectMapper objectMapper; private final ExternalCallMetricsRecorder externalCallMetrics; private final BrandCheckClient brandCheckClient; + private final UserSecretUsageService userSecretUsageService; private volatile RestClient sharedRestClient; private volatile ExecutorService rowExecutor; @@ -117,9 +121,11 @@ public class AppearancePatentLlmClient { } Semaphore concurrency = new Semaphore(Math.max(1, properties.getLlmRowConcurrency())); ExecutorService executor = rowExecutor(); + // 行级处理跑在 worker 线程:把「当前任务归属用户」快照带过去,供 LLM 请求计次 + SecretUsageContext.Context usageContext = SecretUsageContext.snapshot(); List> futures = new ArrayList<>(rows.size()); for (AppearancePatentResultRowDto row : rows) { - futures.add(CompletableFuture.supplyAsync(() -> { + futures.add(CompletableFuture.supplyAsync(SecretUsageContext.wrap(() -> { try { concurrency.acquire(); try { @@ -131,7 +137,7 @@ public class AppearancePatentLlmClient { Thread.currentThread().interrupt(); throw new IllegalStateException("LLM row inspect interrupted"); } - }, executor)); + }, usageContext), executor)); } List merged = new ArrayList<>(rows.size()); for (int i = 0; i < futures.size(); i++) { @@ -288,6 +294,7 @@ public class AppearancePatentLlmClient { List images, String apiKey, String responseFormat) { + recordSecretUsage(); Map body = buildChatBody(model, system, userText, images, responseFormat); log.info("[appearance-patent] llm request model={} url={} body={}", model, joinUrl(properties.getLlmHost(), "/v1/chat/completions"), @@ -841,6 +848,15 @@ public class AppearancePatentLlmClient { sleepQuietly(Math.max(1, attemptIndex) * 1500L); } + /** 密钥调用计次:每次真实 HTTP 请求(含重试)计 1 次;无任务上下文(无人归属)时跳过。 */ + private void recordSecretUsage() { + SecretUsageContext.Context context = SecretUsageContext.current(); + if (context == null) { + return; + } + userSecretUsageService.record(context.userId(), UserSecretModule.APPEARANCE_PATENT.key(), 1); + } + private void sleepQuietly(long delayMillis) { try { Thread.sleep(delayMillis); 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 f005fcf5..7a49b8b3 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 @@ -93,6 +93,7 @@ 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.SecretUsageContext; import com.nanri.aiimage.modules.usersecret.support.UserSecretModule; @Service @@ -802,7 +803,8 @@ public class AppearancePatentTaskService { long startedAt = System.currentTimeMillis(); List llmRows; try { - llmRows = llmClient.inspectRows(batchRows, prompt, apiKey); + llmRows = SecretUsageContext.call(task.getUserId(), UserSecretModule.APPEARANCE_PATENT.key(), + () -> llmClient.inspectRows(batchRows, prompt, apiKey)); } catch (Exception ex) { String message = firstNonBlank(ex.getMessage(), "LLM 检测失败"); log.warn("[appearance-patent] llm batch failed taskId={} jobId={} rows={} batch={}/{} err={}", 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 cec77e1f..fe70eb97 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 @@ -64,6 +64,7 @@ public class PermissionMenuSchemaInitializer { 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_user_secret_usage", "account/user-secret-usage", 42, "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/client/SimilarAsinLlmClient.java b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/client/SimilarAsinLlmClient.java index 55939627..4e359ff0 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/client/SimilarAsinLlmClient.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/client/SimilarAsinLlmClient.java @@ -6,6 +6,9 @@ import com.nanri.aiimage.config.HttpClientPool; import com.nanri.aiimage.config.SimilarAsinProperties; import com.nanri.aiimage.metrics.ExternalCallMetricsRecorder; import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinResultRowDto; +import com.nanri.aiimage.modules.usersecret.service.UserSecretUsageService; +import com.nanri.aiimage.modules.usersecret.support.SecretUsageContext; +import com.nanri.aiimage.modules.usersecret.support.UserSecretModule; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; @@ -37,15 +40,18 @@ public class SimilarAsinLlmClient { private final SimilarAsinProperties properties; private final ObjectMapper objectMapper; private final ExternalCallMetricsRecorder externalCallMetrics; + private final UserSecretUsageService userSecretUsageService; private volatile RestClient sharedRestClient; public SimilarAsinLlmClient(SimilarAsinProperties properties, ObjectMapper objectMapper, - ExternalCallMetricsRecorder externalCallMetrics) { + ExternalCallMetricsRecorder externalCallMetrics, + UserSecretUsageService userSecretUsageService) { this.properties = properties; this.objectMapper = objectMapper; this.externalCallMetrics = externalCallMetrics; + this.userSecretUsageService = userSecretUsageService; } /** 文本对话,json_object 输出。 */ @@ -105,6 +111,7 @@ public class SimilarAsinLlmClient { List images, String apiKey, String responseFormat) { + recordSecretUsage(); Map body = buildChatBody(model, system, userText, images, responseFormat); log.debug("[similar-asin][llm] request model={} url={} body={}", model, joinUrl(properties.getLlmHost(), "/v1/chat/completions"), @@ -294,6 +301,15 @@ public class SimilarAsinLlmClient { } } + /** 密钥调用计次:每次真实 HTTP 请求(含重试)计 1 次;无任务上下文(无人归属)时跳过。 */ + private void recordSecretUsage() { + SecretUsageContext.Context context = SecretUsageContext.current(); + if (context == null) { + return; + } + userSecretUsageService.record(context.userId(), UserSecretModule.SIMILAR_ASIN.key(), 1); + } + private RestClient restClient() { RestClient client = sharedRestClient; if (client != null) { diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinLlmService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinLlmService.java index cc6cd924..d257b822 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinLlmService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinLlmService.java @@ -11,6 +11,7 @@ import com.nanri.aiimage.modules.similarasin.client.SimilarAsinLlmClient; import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinResultRowDto; import com.nanri.aiimage.modules.similarasin.util.PuzzleImageMerger; import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder; +import com.nanri.aiimage.modules.usersecret.support.SecretUsageContext; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -187,9 +188,11 @@ public class SimilarAsinLlmService { } Semaphore concurrency = new Semaphore(Math.max(1, properties.getLlmRowConcurrency())); ExecutorService executor = rowExecutor(); + // 行级处理跑在 worker 线程:把「当前任务归属用户」快照带过去,供 LLM 请求计次 + SecretUsageContext.Context usageContext = SecretUsageContext.snapshot(); List> futures = new ArrayList<>(rows.size()); for (SimilarAsinResultRowDto row : rows) { - futures.add(CompletableFuture.supplyAsync(() -> { + futures.add(CompletableFuture.supplyAsync(SecretUsageContext.wrap(() -> { try { concurrency.acquire(); try { @@ -201,7 +204,7 @@ public class SimilarAsinLlmService { Thread.currentThread().interrupt(); throw new IllegalStateException("LLM row inspect interrupted", interruptedException); } - }, executor)); + }, usageContext), executor)); } List merged = new ArrayList<>(rows.size()); for (int i = 0; i < futures.size(); i++) { 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 d7d42bd7..50c98502 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 @@ -61,6 +61,7 @@ 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.SecretUsageContext; import com.nanri.aiimage.modules.usersecret.support.UserSecretModule; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -1493,7 +1494,10 @@ public class SimilarAsinTaskService { int batchSize = resolveLlmBatchSize(imgSwitch); List result = new ArrayList<>(); for (int i = 0; i < items.size(); i += batchSize) { - result.addAll(similarAsinLlmService.inspectRows(items.subList(i, Math.min(i + batchSize, items.size())), prompt, apiKey, imgSwitch, categorySwitch)); + List batch = items.subList(i, Math.min(i + batchSize, items.size())); + // 批次内所有 LLM 请求按任务归属用户计次(无归属时上下文自动跳过) + result.addAll(SecretUsageContext.call(task.getUserId(), UserSecretModule.SIMILAR_ASIN.key(), + () -> similarAsinLlmService.inspectRows(batch, prompt, apiKey, imgSwitch, categorySwitch))); if (progressHook != null) { progressHook.run(); } @@ -2665,7 +2669,8 @@ public class SimilarAsinTaskService { Map> allRowsByBaseId) { List llmRows; try { - llmRows = similarAsinLlmService.inspectRows(batchRows, prompt, apiKey, imgSwitch, categorySwitch); + llmRows = SecretUsageContext.call(task.getUserId(), UserSecretModule.SIMILAR_ASIN.key(), + () -> similarAsinLlmService.inspectRows(batchRows, prompt, apiKey, imgSwitch, categorySwitch)); } catch (Exception ex) { String message = firstNonBlank(ex.getMessage(), "LLM submit failed"); log.warn("[similar-asin] llm submit failed taskId={} jobId={} rows={} batch={}/{} err={}", diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/controller/AdminUserSecretUsageController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/controller/AdminUserSecretUsageController.java new file mode 100644 index 00000000..561503fa --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/controller/AdminUserSecretUsageController.java @@ -0,0 +1,58 @@ +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.AdminUserSecretUsageQuery; +import com.nanri.aiimage.modules.usersecret.model.vo.AdminUserSecretUsagePageVo; +import com.nanri.aiimage.modules.usersecret.service.UserSecretUsageService; +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.format.annotation.DateTimeFormat; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.time.LocalDate; + +/** + * 后台密钥用量统计:按用户聚合三类密钥的调用次数(货源查询 LLM / 外观专利 LLM / 代理提取), + * 口径为真实对外请求次数。主管只能看自己带的分组成员,超管看全量并可按分组筛选。 + */ +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/admin/user-secret-usage") +@Tag(name = "后台密钥用量统计", description = "按用户按日期范围聚合密钥调用次数。") +public class AdminUserSecretUsageController { + + private final UserSecretUsageService userSecretUsageService; + private final AdminAuthSupport adminAuthSupport; + + @GetMapping + @Operation(summary = "分页查询密钥用量", + description = "一行一用户,返回三类调用次数与所选范围汇总;日期为空表示不限。") + public ApiResponse page( + HttpServletRequest request, + @Parameter(description = "起始日期(含),格式 yyyy-MM-dd") + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate, + @Parameter(description = "结束日期(含),格式 yyyy-MM-dd") + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate, + @Parameter(description = "关键字:用户名") @RequestParam(required = false) String keyword, + @Parameter(description = "按数据权限分组筛选(仅超管生效)") @RequestParam(name = "group_id", required = false) Long groupId, + @Parameter(description = "页码") @RequestParam(defaultValue = "1") Long page, + @Parameter(description = "每页数量") @RequestParam(defaultValue = "15") Long pageSize) { + AdminUserEntity operator = adminAuthSupport.requireAdmin(request); + AdminUserSecretUsageQuery query = new AdminUserSecretUsageQuery(); + query.setStartDate(startDate); + query.setEndDate(endDate); + query.setKeyword(keyword); + query.setGroupId(groupId); + query.setPage(page); + query.setPageSize(pageSize); + return ApiResponse.success(userSecretUsageService.adminPage(operator, query)); + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/controller/InternalUserSecretUsageController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/controller/InternalUserSecretUsageController.java new file mode 100644 index 00000000..556e2551 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/controller/InternalUserSecretUsageController.java @@ -0,0 +1,55 @@ +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.usersecret.model.dto.UserSecretUsageReportRequest; +import com.nanri.aiimage.modules.usersecret.service.UserSecretUsageService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.PostMapping; +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; + +/** + * 内部密钥计次上报:客户端 / 品牌检测服务把「代理提取成功次数」按用户上报, + * 供后台「密钥用量统计」展示用户真实损耗。仅内部令牌可调。 + */ +@RestController +@RequiredArgsConstructor +@Slf4j +@RequestMapping("/api/internal") +@Tag(name = "内部接口", description = "服务间调用(X-Internal-Token 鉴权)。") +public class InternalUserSecretUsageController { + + private final UserSecretUsageService userSecretUsageService; + private final AdminAuthSupport adminAuthSupport; + + @PostMapping("/user-secret-usage") + @Operation(summary = "上报用户密钥调用计次", + description = "按 userId + module 累加当日调用次数;模块非法返回 success=false。") + public ApiResponse> report(HttpServletRequest request, + @RequestBody UserSecretUsageReportRequest body) { + if (!adminAuthSupport.isTrustedInternalToken(request)) { + log.warn("[internal-usage] 拒绝未携带可信内部令牌的计次上报 userId={} remoteAddr={}", + body == null ? null : body.getUserId(), request.getRemoteAddr()); + throw new com.nanri.aiimage.common.exception.BusinessException(401, "未授权"); + } + if (body == null || body.getUserId() == null || body.getCount() == null) { + return ApiResponse.fail("上报参数不完整"); + } + boolean accepted = userSecretUsageService.recordReported( + body.getUserId(), body.getModule(), body.getCount()); + log.info("[internal-usage] 计次上报 userId={} module={} count={} accepted={}", + body.getUserId(), body.getModule(), body.getCount(), accepted); + if (!accepted) { + return ApiResponse.fail("模块或参数非法,未记录"); + } + return ApiResponse.success(Map.of("recorded", true)); + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/mapper/UserSecretUsageMapper.java b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/mapper/UserSecretUsageMapper.java new file mode 100644 index 00000000..4b18d6e1 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/mapper/UserSecretUsageMapper.java @@ -0,0 +1,27 @@ +package com.nanri.aiimage.modules.usersecret.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.nanri.aiimage.modules.usersecret.model.entity.UserSecretUsageEntity; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.time.LocalDate; + +@Mapper +public interface UserSecretUsageMapper extends BaseMapper { + + /** + * 按唯一键 uk_user_module_date 原子累加计次:命中则 call_count 自增,否则插入。 + * 并发下无需应用层加锁(引擎层唯一键冲突转更新)。 + */ + @Insert(""" + INSERT INTO biz_user_secret_usage_daily (user_id, module_key, business_date, call_count, created_at, updated_at) + VALUES (#{userId}, #{moduleKey}, #{businessDate}, #{count}, NOW(), NOW()) + ON DUPLICATE KEY UPDATE call_count = call_count + #{count}, updated_at = NOW() + """) + int increment(@Param("userId") Long userId, + @Param("moduleKey") String moduleKey, + @Param("businessDate") LocalDate businessDate, + @Param("count") int count); +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/model/dto/AdminUserSecretUsageQuery.java b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/model/dto/AdminUserSecretUsageQuery.java new file mode 100644 index 00000000..cbdaf9c5 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/model/dto/AdminUserSecretUsageQuery.java @@ -0,0 +1,24 @@ +package com.nanri.aiimage.modules.usersecret.model.dto; + +import lombok.Data; + +import java.time.LocalDate; + +/** 后台密钥用量统计查询条件(日期范围 + 用户名 + 分组)。 */ +@Data +public class AdminUserSecretUsageQuery { + + /** 起始日期(含);空则不限。 */ + private LocalDate startDate; + + /** 结束日期(含);空则不限。 */ + private LocalDate endDate; + + private String keyword; + + private Long groupId; + + private Long page; + + private Long pageSize; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/model/dto/UserSecretUsageReportRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/model/dto/UserSecretUsageReportRequest.java new file mode 100644 index 00000000..cf0f0b69 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/model/dto/UserSecretUsageReportRequest.java @@ -0,0 +1,19 @@ +package com.nanri.aiimage.modules.usersecret.model.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** 内部通道密钥计次上报请求(客户端/品牌服务上报代理提取次数)。 */ +@Data +@Schema(description = "密钥调用计次上报") +public class UserSecretUsageReportRequest { + + @Schema(description = "用户ID", requiredMode = Schema.RequiredMode.REQUIRED) + private Long userId; + + @Schema(description = "密钥模块:similar-asin/appearance-patent/proxy", requiredMode = Schema.RequiredMode.REQUIRED) + private String module; + + @Schema(description = "本次新增调用次数(真实对外请求)", requiredMode = Schema.RequiredMode.REQUIRED) + private Integer count; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/model/entity/UserSecretUsageEntity.java b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/model/entity/UserSecretUsageEntity.java new file mode 100644 index 00000000..0d3e1939 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/model/entity/UserSecretUsageEntity.java @@ -0,0 +1,26 @@ +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.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** 用户密钥调用计次(按天聚合,一行 = 用户 × 模块 × 日期)。 */ +@Data +@TableName("biz_user_secret_usage_daily") +public class UserSecretUsageEntity { + + @TableId(type = IdType.AUTO) + private Long id; + private Long userId; + private String moduleKey; + private LocalDate businessDate; + private Integer callCount; + private BigDecimal unitPrice; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/model/vo/AdminUserSecretModuleVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/model/vo/AdminUserSecretModuleVo.java index 81248859..277307ed 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/model/vo/AdminUserSecretModuleVo.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/model/vo/AdminUserSecretModuleVo.java @@ -18,6 +18,9 @@ public class AdminUserSecretModuleVo { @Schema(description = "脱敏值") private String masked; + @Schema(description = "完整明文值:仅代理列返回(含账号密码),密钥列为空") + private String full; + @Schema(description = "是否已配置") private Boolean exists; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/model/vo/AdminUserSecretUsagePageVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/model/vo/AdminUserSecretUsagePageVo.java new file mode 100644 index 00000000..480c822c --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/model/vo/AdminUserSecretUsagePageVo.java @@ -0,0 +1,39 @@ +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 AdminUserSecretUsagePageVo { + + @Schema(description = "列表项(一行一用户)") + private List items; + + @Schema(description = "总条数") + private Long total; + + @Schema(description = "页码") + private Long page; + + @Schema(description = "每页数量") + private Long pageSize; + + @Schema(description = "所选范围内(不分页)三类总次数") + private Integer totalCalls; + + @Schema(description = "所选范围内货源查询密钥总次数") + private Integer similarAsinCalls; + + @Schema(description = "所选范围内外观专利密钥总次数") + private Integer appearancePatentCalls; + + @Schema(description = "所选范围内代理提取总次数") + private Integer proxyCalls; + + @Schema(description = "分组筛选项(超管=全部分组,主管=自己带的分组)") + private List groupOptions; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/model/vo/AdminUserSecretUsageRowVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/model/vo/AdminUserSecretUsageRowVo.java new file mode 100644 index 00000000..7e80dc41 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/model/vo/AdminUserSecretUsageRowVo.java @@ -0,0 +1,31 @@ +package com.nanri.aiimage.modules.usersecret.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** 后台密钥用量统计-单用户一行(三模块次数 + 合计)。 */ +@Data +@Schema(description = "用户密钥调用次数(一行一用户)") +public class AdminUserSecretUsageRowVo { + + @Schema(description = "用户ID") + private Long userId; + + @Schema(description = "用户名") + private String username; + + @Schema(description = "所属分组名(数据权限分组,可能多个)") + private java.util.List groups; + + @Schema(description = "货源查询密钥调用次数") + private Integer similarAsinCount; + + @Schema(description = "外观专利密钥调用次数") + private Integer appearancePatentCount; + + @Schema(description = "代理提取次数") + private Integer proxyCount; + + @Schema(description = "合计次数") + private Integer totalCount; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/service/UserApiSecretService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/service/UserApiSecretService.java index 659e64e4..0709ff09 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/service/UserApiSecretService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/service/UserApiSecretService.java @@ -3,9 +3,13 @@ package com.nanri.aiimage.modules.usersecret.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.nanri.aiimage.common.exception.BusinessException; import com.nanri.aiimage.common.security.ShopCredentialCryptoService; +import com.nanri.aiimage.config.UserSecretProperties; import com.nanri.aiimage.modules.admin.support.AdminAuthSupport; +import com.nanri.aiimage.modules.notification.service.NotificationDispatchService; +import com.nanri.aiimage.modules.notification.service.NotificationService; import com.nanri.aiimage.modules.permission.mapper.AdminUserMapper; import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity; +import com.nanri.aiimage.modules.permission.support.UserDataScopeSupport; import com.nanri.aiimage.modules.shopkey.mapper.ShopManageGroupMapper; import com.nanri.aiimage.modules.shopkey.model.dto.UserGroupRef; import com.nanri.aiimage.modules.shopkey.model.entity.ShopManageGroupEntity; @@ -28,7 +32,9 @@ import org.springframework.transaction.annotation.Transactional; import java.net.URI; import java.time.Duration; +import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Comparator; import java.util.HashMap; @@ -73,6 +79,9 @@ public class UserApiSecretService { private final AdminUserMapper adminUserMapper; private final AdminAuthSupport adminAuthSupport; private final ShopManageGroupMapper adminGroupMapper; + private final UserDataScopeSupport userDataScopeSupport; + private final NotificationDispatchService notificationDispatchService; + private final UserSecretProperties userSecretProperties; /** 当前用户密钥包:仅必填模块(代理为选配,不下发)+ 服务端下发的必填清单 + 完整性判定。 */ public UserApiSecretBundleVo bundle(Long userId) { @@ -220,13 +229,13 @@ public class UserApiSecretService { scopedUserIds = adminGroupMapper.selectUserIdsByGroupId(requestedGroupId); } } else { - scopedUserIds = resolveLedGroupMemberIds(operator.getId()); + scopedUserIds = userDataScopeSupport.resolveVisibleUserIds(operator.getId()); if (scopedUserIds.isEmpty()) { log.info("[user-secret] 后台密钥列表:主管名下无子账户,返回空 operatorId={}", operator.getId()); return emptyPageWithGroups(page, pageSize, groupOptions(operator, false)); } } - List ledGroupIds = superAdmin ? List.of() : listLedGroupIds(operator.getId()); + List ledGroupIds = superAdmin ? List.of() : userDataScopeSupport.listLedGroupIds(operator.getId()); LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); String keyword = normalize(safeQuery.getKeyword()); @@ -289,42 +298,6 @@ public class UserApiSecretService { return vo; } - /** 主管可见用户:自己 + 自己带的分组下的子账户(名下 users.created_by_id=自己)。 */ - private List resolveLedGroupMemberIds(Long operatorId) { - if (operatorId == null) { - return List.of(); - } - List ledGroupIds = listLedGroupIds(operatorId); - Set userIds = new LinkedHashSet<>(); - for (Long groupId : ledGroupIds) { - userIds.addAll(adminGroupMapper.selectUserIdsByGroupId(groupId)); - } - // 没有分组记录的主管(历史数据)回退按「名下子账户」兜底,避免整个页面空白。 - if (userIds.isEmpty()) { - adminUserMapper.selectList(new LambdaQueryWrapper() - .eq(AdminUserEntity::getCreatedById, operatorId) - .last("limit 2000")) - .forEach(user -> { - if (user.getId() != null) { - userIds.add(user.getId()); - } - }); - } - userIds.add(operatorId); - return new ArrayList<>(userIds); - } - - /** 主管带的分组 ID(created_by_id / user_id = 自己)。 */ - private List listLedGroupIds(Long operatorId) { - if (operatorId == null) { - return List.of(); - } - return adminGroupMapper.selectLedGroups(operatorId).stream() - .map(ShopManageGroupEntity::getId) - .filter(id -> id != null && id > 0) - .toList(); - } - /** 分组筛选项:超管=全部;主管=自己带的分组。 */ private List groupOptions(AdminUserEntity operator, boolean superAdmin) { List groups = superAdmin @@ -457,6 +430,10 @@ public class UserApiSecretService { int failed = 0; int errors = 0; int skipped = 0; + int notifySent = 0; + // 管理员受众整轮复用:轮内管理员/分组几乎不变,避免每条异常都重新解析 + NotificationDispatchService.AdminAudience adminAudience = + userSecretProperties.isNotifyEnabled() ? notificationDispatchService.prepareAdminAudience() : null; long lastId = 0L; while (true) { List batch = userApiSecretMapper.selectList(new LambdaQueryWrapper() @@ -478,19 +455,21 @@ public class UserApiSecretService { continue; } try { + UserApiSecretCheckService.CheckOutcome outcome; String plainKey = normalize(cryptoService.decrypt(row.getSecretValue())); if (plainKey.isEmpty()) { - applyCheckOutcome(row.getUserId(), module.get().key(), new UserApiSecretCheckService.CheckOutcome( + outcome = new UserApiSecretCheckService.CheckOutcome( UserApiSecretCheckService.STATUS_FAILED, UserApiSecretCheckService.CODE_INVALID_KEY, - "配置内容为空,请重新配置", null, false)); - failed++; - checked++; - continue; + "配置内容为空,请重新配置", null, false); + } else { + outcome = checkService.probe(module.get(), plainKey); } - UserApiSecretCheckService.CheckOutcome outcome = checkService.probe(module.get(), plainKey); applyCheckOutcome(row.getUserId(), module.get().key(), outcome); checked++; + if (adminAudience != null) { + notifySent += notifySecretAlert(adminAudience, row.getUserId(), module.get(), outcome); + } if (UserApiSecretCheckService.STATUS_PASSED.equals(outcome.status())) { passed++; } else if (UserApiSecretCheckService.STATUS_FAILED.equals(outcome.status())) { @@ -513,11 +492,68 @@ public class UserApiSecretService { } } 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()); + log.info("[user-secret] 巡检结束 checked={} passed={} failed={} errors={} skipped={} 通知={}", + summary.checked(), summary.passed(), summary.failed(), summary.errors(), summary.skipped(), notifySent); return summary; } + /** + * 巡检告警判定:欠费(insufficient_balance)→ error 级 secret_balance; + * 密钥无效/被拒(invalid_key/forbidden)→ warning 级 secret_invalid; + * 其余(网络抖动/限流/上游异常)不通知,避免噪音。package-private 供单测覆盖分类矩阵。 + */ + static SecretAlertDecision decideSecretAlert(UserApiSecretCheckService.CheckOutcome outcome) { + if (outcome == null) { + return null; + } + if (UserApiSecretCheckService.CODE_INSUFFICIENT_BALANCE.equals(outcome.code())) { + return new SecretAlertDecision(NotificationService.SCENE_SECRET_BALANCE, NotificationService.LEVEL_ERROR); + } + if (UserApiSecretCheckService.STATUS_FAILED.equals(outcome.status()) + && (UserApiSecretCheckService.CODE_INVALID_KEY.equals(outcome.code()) + || UserApiSecretCheckService.CODE_FORBIDDEN.equals(outcome.code()))) { + return new SecretAlertDecision(NotificationService.SCENE_SECRET_INVALID, NotificationService.LEVEL_WARNING); + } + return null; + } + + /** 巡检发现欠费/失效:推用户通知 + 按数据权限推管理员通知;同一模块每天最多各一条。返回落库条数。 */ + private int notifySecretAlert(NotificationDispatchService.AdminAudience adminAudience, + Long userId, UserSecretModule module, + UserApiSecretCheckService.CheckOutcome outcome) { + SecretAlertDecision decision = decideSecretAlert(outcome); + if (decision == null) { + return 0; + } + String day = LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); + String moduleLabel = module.label(); + String reason = outcome.message() == null ? "" : outcome.message().trim(); + boolean balance = NotificationService.SCENE_SECRET_BALANCE.equals(decision.scene()); + + String userTitle = moduleLabel + (balance ? "服务商余额不足" : "检测失败"); + String userContent = balance + ? "您的" + moduleLabel + "对应服务商余额不足,相关任务可能失败,请尽快充值或联系管理员。" + : "您的" + moduleLabel + "检测未通过:" + reason + ",请到「密钥设置」重新配置并手动检测。"; + int pushed = notificationDispatchService.pushToUser(userId, decision.scene(), decision.level(), + userTitle, userContent, + decision.scene() + ":" + userId + ":" + module.key() + ":" + day) ? 1 : 0; + + String username = notificationDispatchService.displayNameOf(userId); + String adminContent = "用户 " + username + "(uid=" + userId + ")的" + moduleLabel + + (balance ? "对应服务商余额不足" : "检测失败") + + (reason.isEmpty() ? "" : ":" + reason); + pushed += notificationDispatchService.pushToAdmins(adminAudience, decision.scene(), decision.level(), + "用户密钥异常:" + username, adminContent, + decision.scene() + "_admin:" + userId + ":" + module.key() + ":" + day, userId); + log.info("[user-secret] 巡检通知已发 userId={} module={} scene={} level={} 条数={}", + userId, module.key(), decision.scene(), decision.level(), pushed); + return pushed; + } + + /** 巡检告警判定结果(场景 + 级别)。 */ + record SecretAlertDecision(String scene, String level) { + } + private AdminUserSecretRowVo buildAdminRow(Long userId, Map moduleRows) { AdminUserSecretRowVo vo = new AdminUserSecretRowVo(); vo.setUserId(userId); @@ -661,6 +697,10 @@ public class UserApiSecretService { } String plain = decryptQuietly(row.getSecretValue()); vo.setMasked(maskValue(module, plain)); + // 后台代理列要求完整可见(含账号密码),密钥列不下发明文 + if (module == UserSecretModule.PROXY) { + vo.setFull(plain); + } vo.setExists(hasText(plain)); vo.setCheckStatus(hasText(row.getCheckStatus()) ? row.getCheckStatus() : STATUS_UNKNOWN); vo.setCheckCode(row.getCheckCode()); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/service/UserSecretUsageService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/service/UserSecretUsageService.java new file mode 100644 index 00000000..e645c78f --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/service/UserSecretUsageService.java @@ -0,0 +1,271 @@ +package com.nanri.aiimage.modules.usersecret.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.nanri.aiimage.modules.admin.support.AdminAuthSupport; +import com.nanri.aiimage.modules.permission.mapper.AdminUserMapper; +import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity; +import com.nanri.aiimage.modules.permission.support.UserDataScopeSupport; +import com.nanri.aiimage.modules.shopkey.mapper.ShopManageGroupMapper; +import com.nanri.aiimage.modules.shopkey.model.dto.UserGroupRef; +import com.nanri.aiimage.modules.shopkey.model.entity.ShopManageGroupEntity; +import com.nanri.aiimage.modules.usersecret.mapper.UserSecretUsageMapper; +import com.nanri.aiimage.modules.usersecret.model.dto.AdminUserSecretUsageQuery; +import com.nanri.aiimage.modules.usersecret.model.entity.UserSecretUsageEntity; +import com.nanri.aiimage.modules.usersecret.model.vo.AdminUserSecretPageVo; +import com.nanri.aiimage.modules.usersecret.model.vo.AdminUserSecretUsagePageVo; +import com.nanri.aiimage.modules.usersecret.model.vo.AdminUserSecretUsageRowVo; +import com.nanri.aiimage.modules.usersecret.support.UserSecretModule; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 用户密钥调用计次:按用户 × 模块 × 天原子累加,后台按日期范围聚合查询。 + * 计次口径为真实对外请求次数(LLM 每次请求含重试、代理每次成功提取), + * 记录失败只告警不抛异常,绝不影响任务执行。 + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class UserSecretUsageService { + + /** 单次上报上限:防止异常客户端把计数写爆。 */ + private static final int MAX_REPORT_COUNT = 10_000; + + private final UserSecretUsageMapper usageMapper; + private final AdminUserMapper adminUserMapper; + private final AdminAuthSupport adminAuthSupport; + private final ShopManageGroupMapper adminGroupMapper; + private final UserDataScopeSupport userDataScopeSupport; + + /** + * 记一次调用(count 通常为 1):按天原子累加。 + * 用户或模块非法时静默跳过;写库异常只告警,不影响任务主流程。 + */ + public void record(Long userId, String moduleKey, int count) { + if (userId == null || userId <= 0 || count <= 0) { + return; + } + UserSecretModule module = UserSecretModule.of(moduleKey).orElse(null); + if (module == null) { + log.warn("[user-secret-usage] 忽略未知模块计次 userId={} module={}", userId, moduleKey); + return; + } + recordModule(userId, module, count); + } + + /** 内部通道上报入口:模块非法返回 false(供接口回执),写库失败只告警。 */ + public boolean recordReported(Long userId, String moduleKey, int count) { + if (userId == null || userId <= 0 || count <= 0) { + return false; + } + UserSecretModule module = UserSecretModule.of(moduleKey).orElse(null); + if (module == null) { + return false; + } + return recordModule(userId, module, Math.min(count, MAX_REPORT_COUNT)); + } + + private boolean recordModule(Long userId, UserSecretModule module, int count) { + try { + usageMapper.increment(userId, module.key(), LocalDate.now(), count); + return true; + } catch (Exception ex) { + log.warn("[user-secret-usage] 计次写入失败 userId={} module={} count={} err={}", + userId, module.key(), count, ex.getMessage()); + return false; + } + } + + /** + * 后台分页:一行一用户,按日期范围聚合三模块调用次数并计算合计。 + * 隔离规则同密钥列表:主管只看自己带的「数据权限分组」成员,超管看全量并可按分组筛选; + * 先全量聚合、再按合计次数倒序内存分页(当前规模可控,量级上来后可改 GROUP BY 下推)。 + */ + public AdminUserSecretUsagePageVo adminPage(AdminUserEntity operator, AdminUserSecretUsageQuery query) { + AdminUserSecretUsageQuery safeQuery = query == null ? new AdminUserSecretUsageQuery() : 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); + + boolean superAdmin = "super_admin".equals(adminAuthSupport.currentRole(operator)); + Long requestedGroupId = safeQuery.getGroupId(); + List scopedUserIds = null; + if (superAdmin) { + if (requestedGroupId != null && requestedGroupId > 0) { + scopedUserIds = adminGroupMapper.selectUserIdsByGroupId(requestedGroupId); + } + } else { + scopedUserIds = userDataScopeSupport.resolveVisibleUserIds(operator.getId()); + if (scopedUserIds.isEmpty()) { + log.info("[user-secret-usage] 用量统计:主管名下无子账户,返回空 operatorId={}", operator.getId()); + return emptyPage(page, pageSize, groupOptions(operator, false)); + } + } + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + if (safeQuery.getStartDate() != null) { + wrapper.ge(UserSecretUsageEntity::getBusinessDate, safeQuery.getStartDate()); + } + if (safeQuery.getEndDate() != null) { + wrapper.le(UserSecretUsageEntity::getBusinessDate, safeQuery.getEndDate()); + } + String keyword = normalize(safeQuery.getKeyword()); + if (!keyword.isEmpty() || scopedUserIds != null) { + List allowedUserIds = scopedUserIds == null ? null : new ArrayList<>(scopedUserIds); + if (!keyword.isEmpty()) { + List matched = adminUserMapper.selectList(new LambdaQueryWrapper() + .like(AdminUserEntity::getUsername, keyword) + .last("limit 200")) + .stream().map(AdminUserEntity::getId).filter(id -> id != null).toList(); + if (allowedUserIds == null) { + allowedUserIds = new ArrayList<>(matched); + } else { + allowedUserIds.retainAll(matched); + } + } + if (allowedUserIds == null || allowedUserIds.isEmpty()) { + log.info("[user-secret-usage] 用量统计无匹配用户 keyword={} groupId={} operatorId={} role={}", + keyword, requestedGroupId, operator.getId(), superAdmin ? "super_admin" : "admin"); + return emptyPage(page, pageSize, groupOptions(operator, superAdmin)); + } + wrapper.in(UserSecretUsageEntity::getUserId, allowedUserIds); + } + + List rows = usageMapper.selectList(wrapper); + + Map byUser = new LinkedHashMap<>(); + long similarAsinCalls = 0; + long appearancePatentCalls = 0; + long proxyCalls = 0; + for (UserSecretUsageEntity row : rows) { + if (row.getUserId() == null || row.getModuleKey() == null) { + continue; + } + int count = row.getCallCount() == null ? 0 : row.getCallCount(); + AdminUserSecretUsageRowVo item = byUser.computeIfAbsent(row.getUserId(), userId -> { + AdminUserSecretUsageRowVo vo = new AdminUserSecretUsageRowVo(); + vo.setUserId(userId); + vo.setSimilarAsinCount(0); + vo.setAppearancePatentCount(0); + vo.setProxyCount(0); + vo.setTotalCount(0); + vo.setGroups(List.of()); + return vo; + }); + switch (row.getModuleKey()) { + case "similar-asin" -> { + item.setSimilarAsinCount(item.getSimilarAsinCount() + count); + similarAsinCalls += count; + } + case "appearance-patent" -> { + item.setAppearancePatentCount(item.getAppearancePatentCount() + count); + appearancePatentCalls += count; + } + case "proxy" -> { + item.setProxyCount(item.getProxyCount() + count); + proxyCalls += count; + } + default -> log.debug("[user-secret-usage] 聚合跳过未知模块 userId={} module={}", + row.getUserId(), row.getModuleKey()); + } + } + + List all = new ArrayList<>(byUser.values()); + for (AdminUserSecretUsageRowVo item : all) { + item.setTotalCount(item.getSimilarAsinCount() + item.getAppearancePatentCount() + item.getProxyCount()); + } + all.sort(Comparator.comparing(AdminUserSecretUsageRowVo::getTotalCount, Comparator.reverseOrder())); + + long total = all.size(); + int from = (int) Math.min((page - 1) * pageSize, total); + int to = (int) Math.min(from + pageSize, total); + List pageItems = new ArrayList<>(all.subList(from, to)); + fillRowMeta(pageItems); + + AdminUserSecretUsagePageVo vo = new AdminUserSecretUsagePageVo(); + vo.setItems(pageItems); + vo.setTotal(total); + vo.setPage(page); + vo.setPageSize(pageSize); + vo.setSimilarAsinCalls((int) similarAsinCalls); + vo.setAppearancePatentCalls((int) appearancePatentCalls); + vo.setProxyCalls((int) proxyCalls); + vo.setTotalCalls((int) (similarAsinCalls + appearancePatentCalls + proxyCalls)); + vo.setGroupOptions(groupOptions(operator, superAdmin)); + log.info("[user-secret-usage] 用量统计查询 start={} end={} keyword={} groupId={} role={} 用户数={} 本页={} 总次数={}", + safeQuery.getStartDate(), safeQuery.getEndDate(), keyword, requestedGroupId, + superAdmin ? "super_admin" : "admin", total, pageItems.size(), vo.getTotalCalls()); + return vo; + } + + /** 本页行补用户名与分组名(各一次批量查询)。 */ + private void fillRowMeta(List pageItems) { + List userIds = pageItems.stream() + .map(AdminUserSecretUsageRowVo::getUserId) + .filter(id -> id != null) + .toList(); + if (userIds.isEmpty()) { + return; + } + Map usernames = new HashMap<>(); + for (AdminUserEntity user : adminUserMapper.selectBatchIds(userIds)) { + if (user.getId() != null) { + usernames.put(user.getId(), normalize(user.getUsername())); + } + } + Map> groups = new LinkedHashMap<>(); + for (UserGroupRef ref : adminGroupMapper.selectGroupNamesByUserIds(userIds)) { + if (ref.getUserId() == null) { + continue; + } + String name = normalize(ref.getGroupName()); + if (name.isEmpty()) { + continue; + } + groups.computeIfAbsent(ref.getUserId(), key -> new ArrayList<>()).add(name); + } + for (AdminUserSecretUsageRowVo item : pageItems) { + item.setUsername(usernames.getOrDefault(item.getUserId(), "")); + item.setGroups(groups.getOrDefault(item.getUserId(), List.of())); + } + } + + /** 分组筛选项:超管=全部;主管=自己带的分组。 */ + private List groupOptions(AdminUserEntity operator, boolean superAdmin) { + List groups = superAdmin + ? adminGroupMapper.selectAllGroups() + : adminGroupMapper.selectLedGroups(operator.getId()); + return groups.stream() + .filter(group -> group.getId() != null) + .map(group -> new AdminUserSecretPageVo.GroupOptionVo(group.getId(), group.getGroupName())) + .toList(); + } + + private AdminUserSecretUsagePageVo emptyPage(long page, long pageSize, + List groupOptions) { + AdminUserSecretUsagePageVo vo = new AdminUserSecretUsagePageVo(); + vo.setItems(new ArrayList<>()); + vo.setTotal(0L); + vo.setPage(page); + vo.setPageSize(pageSize); + vo.setSimilarAsinCalls(0); + vo.setAppearancePatentCalls(0); + vo.setProxyCalls(0); + vo.setTotalCalls(0); + vo.setGroupOptions(groupOptions); + return vo; + } + + private String normalize(String value) { + return value == null ? "" : value.trim(); + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/support/SecretUsageContext.java b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/support/SecretUsageContext.java new file mode 100644 index 00000000..2c0ea830 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/support/SecretUsageContext.java @@ -0,0 +1,79 @@ +package com.nanri.aiimage.modules.usersecret.support; + +import java.util.function.Supplier; + +/** + * 密钥计次上下文:任务批处理内标记「当前调用归属哪个用户、哪个密钥模块」, + * 供 LLM 客户端在每次真实 HTTP 请求处计次。 + * + *

任务侧在批次循环外 {@link #call} 设置,客户端在工作线程内 {@link #current} 读取; + * 由于 LLM 行级处理跑在线程池(CompletableFuture),提交任务时必须用 + * {@link #snapshot()} 捕获并在工作线程内用 {@link #wrap} 恢复 + * (执行完恢复原值,避免线程复用串号)。 + */ +public final class SecretUsageContext { + + private static final ThreadLocal HOLDER = new ThreadLocal<>(); + + private SecretUsageContext() { + } + + /** 计次归属:用户 + 密钥模块 key(如 similar-asin / appearance-patent)。 */ + public record Context(Long userId, String moduleKey) { + public boolean usable() { + return userId != null && userId > 0 && moduleKey != null && !moduleKey.isBlank(); + } + } + + /** 在指定归属下执行并返回结果,结束后恢复原有上下文(支持嵌套)。 */ + public static T call(Long userId, String moduleKey, Supplier action) { + Context previous = HOLDER.get(); + Context next = new Context(userId, moduleKey); + if (next.usable()) { + HOLDER.set(next); + } + try { + return action.get(); + } finally { + restore(previous); + } + } + + /** 当前归属;无上下文或归属不可用时返回 null。 */ + public static Context current() { + Context context = HOLDER.get(); + return context != null && context.usable() ? context : null; + } + + /** 捕获当前上下文快照,供工作线程恢复;无上下文时返回 null。 */ + public static Context snapshot() { + return HOLDER.get(); + } + + /** + * 用快照包裹提交给线程池的任务:工作线程内先设置归属,执行完恢复原值。 + * 快照为空时原样执行(不引入额外开销)。 + */ + public static Supplier wrap(Supplier task, Context snapshot) { + if (snapshot == null || !snapshot.usable()) { + return task; + } + return () -> { + Context previous = HOLDER.get(); + HOLDER.set(snapshot); + try { + return task.get(); + } finally { + restore(previous); + } + }; + } + + private static void restore(Context previous) { + if (previous == null) { + HOLDER.remove(); + } else { + HOLDER.set(previous); + } + } +} diff --git a/backend-java/src/main/resources/db/V117__user_secret_usage_daily.sql b/backend-java/src/main/resources/db/V117__user_secret_usage_daily.sql new file mode 100644 index 00000000..445b8269 --- /dev/null +++ b/backend-java/src/main/resources/db/V117__user_secret_usage_daily.sql @@ -0,0 +1,27 @@ +-- V117: 用户密钥调用计次(按用户 × 模块 × 天聚合) +-- 口径:真实对外请求次数。货源/专利密钥按每次 LLM 请求(含重试)计 1 次; +-- 代理按每次成功提取计 1 次(供应商按次计费,租约复用不产生新提取)。 +-- 单价字段本期不参与折算,仅预留后续「次数 × 单价」成本估算。 + +CREATE TABLE IF NOT EXISTS `biz_user_secret_usage_daily` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `user_id` BIGINT NOT NULL COMMENT '用户ID(users.id)', + `module_key` VARCHAR(64) NOT NULL COMMENT '密钥模块:similar-asin/appearance-patent/proxy', + `business_date` DATE NOT NULL COMMENT '统计日期', + `call_count` INT NOT NULL DEFAULT 0 COMMENT '调用次数(真实对外请求)', + `unit_price` DECIMAL(10,4) NULL COMMENT '预留:单价(元/次),本期不折算', + `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_date` (`user_id`, `module_key`, `business_date`), + KEY `idx_date_module` (`business_date`, `module_key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户密钥调用计次(按天聚合)'; + +-- 后台菜单:密钥用量统计(挂在「账号与权限」分组下;幂等,仅当 column_key 不存在时插入) +INSERT INTO columns (name, column_key, menu_type, route_path, sort_order, parent_id) +SELECT '密钥用量统计', 'admin_user_secret_usage', 'admin', 'account/user-secret-usage', 42, parent.id +FROM columns parent +WHERE parent.column_key = 'admin_group_account' + AND NOT EXISTS ( + SELECT 1 FROM columns WHERE column_key = 'admin_user_secret_usage' + ); diff --git a/backend-java/src/test/java/com/nanri/aiimage/config/HttpClientConnectionReuseTest.java b/backend-java/src/test/java/com/nanri/aiimage/config/HttpClientConnectionReuseTest.java index 3aa0c922..7ca7b4f2 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/config/HttpClientConnectionReuseTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/config/HttpClientConnectionReuseTest.java @@ -23,6 +23,7 @@ import static org.junit.jupiter.api.Assertions.assertNotSame; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; +import com.nanri.aiimage.modules.usersecret.service.UserSecretUsageService; /** * Task 77:统一 LLM、品牌检查和紫鸟 HTTP 客户端的连接复用策略。 @@ -98,7 +99,7 @@ class HttpClientConnectionReuseTest { void test_task_077_brand_normal_multiple_items() throws Exception { // 批量场景:Coze/品牌/紫鸟三个客户端各自持有独立 RestClient, // 但底层连接池共用同一 HttpClient 实例,不重复创建。 - SimilarAsinLlmClient llm = new SimilarAsinLlmClient(new SimilarAsinProperties(), new ObjectMapper(), null); + SimilarAsinLlmClient llm = new SimilarAsinLlmClient(new SimilarAsinProperties(), new ObjectMapper(), null, null); BrandCheckClient brand = new BrandCheckClient(new BrandCheckProperties(), null); ZiniaoClientImpl ziniao = new ZiniaoClientImpl(new ZiniaoProperties(), new ObjectMapper()); diff --git a/backend-java/src/test/java/com/nanri/aiimage/metrics/ExternalCallMetricsRecorderTest.java b/backend-java/src/test/java/com/nanri/aiimage/metrics/ExternalCallMetricsRecorderTest.java index a04f1775..83a3f91d 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/metrics/ExternalCallMetricsRecorderTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/metrics/ExternalCallMetricsRecorderTest.java @@ -34,6 +34,8 @@ import java.util.concurrent.atomic.AtomicInteger; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import com.nanri.aiimage.modules.usersecret.service.UserSecretUsageService; +import static org.mockito.Mockito.mock; /** * Task 78:为所有外部调用(LLM / 品牌检查 / 紫鸟)统一增加耗时、重试、 @@ -159,7 +161,8 @@ class ExternalCallMetricsRecorderTest { void test_task_078_payload_metrics_normal_default_path() throws Exception { SimilarAsinProperties props = llmProps(); SimilarAsinLlmClient client = - new SimilarAsinLlmClient(props, objectMapper, new ExternalCallMetricsRecorder(registry)); + new SimilarAsinLlmClient(props, objectMapper, new ExternalCallMetricsRecorder(registry), + mock(UserSecretUsageService.class)); String content = client.invokeChat("test-model", "system", "hello", "test-key"); @@ -177,7 +180,8 @@ class ExternalCallMetricsRecorderTest { void test_task_078_payload_metrics_normal_multiple_items() throws Exception { SimilarAsinProperties props = llmProps(); SimilarAsinLlmClient client = - new SimilarAsinLlmClient(props, objectMapper, new ExternalCallMetricsRecorder(registry)); + new SimilarAsinLlmClient(props, objectMapper, new ExternalCallMetricsRecorder(registry), + mock(UserSecretUsageService.class)); for (int i = 0; i < 3; i++) { client.invokeChat("test-model", "system", "prompt-" + i, "test-key"); @@ -259,7 +263,8 @@ class ExternalCallMetricsRecorderTest { for (int i = 0; i < 20; i++) { int index = i; pool.submit(() -> { - SimilarAsinLlmClient client = new SimilarAsinLlmClient(props, objectMapper, recorder); + SimilarAsinLlmClient client = new SimilarAsinLlmClient(props, objectMapper, recorder, + mock(UserSecretUsageService.class)); try { client.invokeChat("test-model", "system", "prompt-" + index, "test-key"); } catch (Exception ignored) { @@ -309,7 +314,8 @@ class ExternalCallMetricsRecorderTest { ExternalCallMetricsRecorder recorder = new ExternalCallMetricsRecorder(registry); SimilarAsinProperties props = llmProps(); SimilarAsinLlmClient client = - new SimilarAsinLlmClient(props, objectMapper, recorder); + new SimilarAsinLlmClient(props, objectMapper, recorder, + mock(UserSecretUsageService.class)); // 第一次调用走 500 失败路径,第二次调用恢复成功:错误可恢复 llmFailNext.set(true); diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentLlmClientHttpTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentLlmClientHttpTest.java index a8c22123..52a0f8de 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentLlmClientHttpTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentLlmClientHttpTest.java @@ -22,6 +22,8 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicInteger; import static org.assertj.core.api.Assertions.assertThat; +import com.nanri.aiimage.modules.usersecret.service.UserSecretUsageService; +import static org.mockito.Mockito.mock; class AppearancePatentLlmClientHttpTest { @@ -45,7 +47,8 @@ class AppearancePatentLlmClientHttpTest { properties, objectMapper, null, - new BrandCheckClient(new BrandCheckProperties(), null) + new BrandCheckClient(new BrandCheckProperties(), null), + mock(UserSecretUsageService.class) ); } diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentLlmClientTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentLlmClientTest.java index 461c1ea7..178dd8fe 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentLlmClientTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentLlmClientTest.java @@ -15,6 +15,7 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import com.nanri.aiimage.modules.usersecret.service.UserSecretUsageService; class AppearancePatentLlmClientTest { @@ -22,7 +23,8 @@ class AppearancePatentLlmClientTest { new AppearancePatentProperties(), new ObjectMapper(), null, - new BrandCheckClient(new BrandCheckProperties(), null) + new BrandCheckClient(new BrandCheckProperties(), null), + mock(UserSecretUsageService.class) ); @Test @@ -65,7 +67,8 @@ class AppearancePatentLlmClientTest { new AppearancePatentProperties(), new ObjectMapper(), null, - brandCheckClient + brandCheckClient, + mock(UserSecretUsageService.class) ); AppearancePatentResultRowDto row = new AppearancePatentResultRowDto(); row.setId("1"); @@ -90,7 +93,8 @@ class AppearancePatentLlmClientTest { new AppearancePatentProperties(), new ObjectMapper(), null, - brandCheckClient + brandCheckClient, + mock(UserSecretUsageService.class) ); AppearancePatentResultRowDto row = new AppearancePatentResultRowDto(); row.setId("1"); @@ -112,7 +116,8 @@ class AppearancePatentLlmClientTest { new AppearancePatentProperties(), new ObjectMapper(), null, - brandCheckClient + brandCheckClient, + mock(UserSecretUsageService.class) ); AppearancePatentResultRowDto row = new AppearancePatentResultRowDto(); row.setId("1"); @@ -134,7 +139,8 @@ class AppearancePatentLlmClientTest { new AppearancePatentProperties(), new ObjectMapper(), null, - brandCheckClient + brandCheckClient, + mock(UserSecretUsageService.class) ); AppearancePatentResultRowDto row = new AppearancePatentResultRowDto(); row.setId("1"); diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinLlmLocalVerify.java b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinLlmLocalVerify.java index 10ff01e7..98d4c3ed 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinLlmLocalVerify.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinLlmLocalVerify.java @@ -16,6 +16,8 @@ import org.mockito.Mockito; import java.io.File; import java.util.ArrayList; import java.util.List; +import com.nanri.aiimage.modules.usersecret.service.UserSecretUsageService; +import static org.mockito.Mockito.mock; /** * 本地验证入口:用生产真实批次数据 + 生产 LLM 网关跑 SimilarAsinLlmService 完整链路。 @@ -60,7 +62,7 @@ public class SimilarAsinLlmLocalVerify { props.setLlmRowConcurrency(2); props.setLlmImageDownloadTimeoutSeconds(10); - SimilarAsinLlmClient client = new SimilarAsinLlmClient(props, objectMapper, null); + SimilarAsinLlmClient client = new SimilarAsinLlmClient(props, objectMapper, null, null); OssProperties ossProps = new OssProperties(); ossProps.setEndpoint("https://oss.aishufu.top"); ossProps.setPublicEndpoint("https://oss.aishufu.top"); diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/usersecret/service/UserApiSecretServiceTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/usersecret/service/UserApiSecretServiceTest.java index f77c09de..311b1417 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/usersecret/service/UserApiSecretServiceTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/usersecret/service/UserApiSecretServiceTest.java @@ -1,9 +1,12 @@ package com.nanri.aiimage.modules.usersecret.service; import com.nanri.aiimage.common.security.ShopCredentialCryptoService; +import com.nanri.aiimage.config.UserSecretProperties; import com.nanri.aiimage.modules.admin.support.AdminAuthSupport; +import com.nanri.aiimage.modules.notification.service.NotificationDispatchService; import com.nanri.aiimage.modules.permission.mapper.AdminUserMapper; import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity; +import com.nanri.aiimage.modules.permission.support.UserDataScopeSupport; import com.nanri.aiimage.modules.shopkey.model.dto.UserGroupRef; import com.nanri.aiimage.modules.shopkey.model.entity.ShopManageGroupEntity; import com.nanri.aiimage.modules.usersecret.client.JikipProxyClient; @@ -36,6 +39,9 @@ class UserApiSecretServiceTest { private final AdminAuthSupport adminAuthSupport = mock(AdminAuthSupport.class); private final com.nanri.aiimage.modules.shopkey.mapper.ShopManageGroupMapper adminGroupMapper = mock(com.nanri.aiimage.modules.shopkey.mapper.ShopManageGroupMapper.class); + private final UserDataScopeSupport userDataScopeSupport = mock(UserDataScopeSupport.class); + private final NotificationDispatchService notificationDispatchService = mock(NotificationDispatchService.class); + private final UserSecretProperties userSecretProperties = new UserSecretProperties(); private UserApiSecretService newService() { when(crypto.encrypt(anyString())).thenAnswer(inv -> "enc:" + inv.getArgument(0, String.class)); @@ -46,7 +52,8 @@ class UserApiSecretServiceTest { // 默认按主管(admin)判定;超管用例里单独改打桩。 when(adminAuthSupport.currentRole(any())).thenReturn("admin"); return new UserApiSecretService( - mapper, crypto, checkService, jikipProxyClient, adminUserMapper, adminAuthSupport, adminGroupMapper); + mapper, crypto, checkService, jikipProxyClient, adminUserMapper, adminAuthSupport, adminGroupMapper, + userDataScopeSupport, notificationDispatchService, userSecretProperties); } @Test @@ -240,8 +247,10 @@ class UserApiSecretServiceTest { assertThat(rowVo.getGroups()).containsExactly("一组"); assertThat(rowVo.getStatus()).isEqualTo("failed"); assertThat(rowVo.getSimilarAsin().getMasked()).isEqualTo("sk-s****1234"); + assertThat(rowVo.getSimilarAsin().getFull()).isNull(); assertThat(rowVo.getProxy().getExists()).isTrue(); assertThat(rowVo.getProxy().getMasked()).isEqualTo("http://***@1.2.3.4:8080"); + assertThat(rowVo.getProxy().getFull()).isEqualTo("http://user:pass@1.2.3.4:8080"); } @Test @@ -268,7 +277,7 @@ class UserApiSecretServiceTest { group.setId(5L); group.setGroupName("一组"); when(adminGroupMapper.selectLedGroups(88L)).thenReturn(List.of(group)); - when(adminGroupMapper.selectUserIdsByGroupId(5L)).thenReturn(List.of(1L, 88L)); + when(userDataScopeSupport.resolveVisibleUserIds(88L)).thenReturn(List.of(1L, 88L)); when(mapper.selectList(any())).thenReturn(List.of(row(1L, "similar-asin", "enc:sk-1", "passed"))); AdminUserEntity user = new AdminUserEntity(); user.setId(1L); @@ -285,7 +294,42 @@ class UserApiSecretServiceTest { assertThat(page.getItems().get(0).getGroups()).containsExactly("一组"); assertThat(page.getGroupOptions()).extracting(com.nanri.aiimage.modules.usersecret.model.vo.AdminUserSecretPageVo.GroupOptionVo::groupName) .containsExactly("一组"); - verify(adminGroupMapper).selectUserIdsByGroupId(5L); + verify(userDataScopeSupport).resolveVisibleUserIds(88L); + } + + @Test + void decideSecretAlertMapsBalanceAndInvalidKeyOnly() { + // 欠费 → error 级 secret_balance + var balance = UserApiSecretService.decideSecretAlert(new UserApiSecretCheckService.CheckOutcome( + UserApiSecretCheckService.STATUS_FAILED, + UserApiSecretCheckService.CODE_INSUFFICIENT_BALANCE, + "余额不足", 120, false)); + assertThat(balance).isNotNull(); + assertThat(balance.scene()).isEqualTo("secret_balance"); + assertThat(balance.level()).isEqualTo("error"); + + // 密钥无效 / 被拒 → warning 级 secret_invalid + for (String code : List.of(UserApiSecretCheckService.CODE_INVALID_KEY, UserApiSecretCheckService.CODE_FORBIDDEN)) { + var invalid = UserApiSecretService.decideSecretAlert(new UserApiSecretCheckService.CheckOutcome( + UserApiSecretCheckService.STATUS_FAILED, code, "失败", 100, false)); + assertThat(invalid).isNotNull(); + assertThat(invalid.scene()).isEqualTo("secret_invalid"); + assertThat(invalid.level()).isEqualTo("warning"); + } + + // 网络抖动 / 限流 / 上游异常 → 不通知 + for (String code : List.of(UserApiSecretCheckService.CODE_NETWORK_ERROR, + UserApiSecretCheckService.CODE_RATE_LIMITED, UserApiSecretCheckService.CODE_SERVER_ERROR)) { + assertThat(UserApiSecretService.decideSecretAlert(new UserApiSecretCheckService.CheckOutcome( + UserApiSecretCheckService.STATUS_ERROR, code, "抖动", 90, false))).isNull(); + assertThat(UserApiSecretService.decideSecretAlert(new UserApiSecretCheckService.CheckOutcome( + UserApiSecretCheckService.STATUS_FAILED, code, "抖动", 90, false))).isNull(); + } + + // 通过 / 空 outcome → 不通知 + assertThat(UserApiSecretService.decideSecretAlert(new UserApiSecretCheckService.CheckOutcome( + UserApiSecretCheckService.STATUS_PASSED, UserApiSecretCheckService.CODE_OK, "正常", 50, false))).isNull(); + assertThat(UserApiSecretService.decideSecretAlert(null)).isNull(); } @Test diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/usersecret/service/UserSecretUsageServiceTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/usersecret/service/UserSecretUsageServiceTest.java new file mode 100644 index 00000000..db4515f6 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/usersecret/service/UserSecretUsageServiceTest.java @@ -0,0 +1,194 @@ +package com.nanri.aiimage.modules.usersecret.service; + +import com.nanri.aiimage.modules.admin.support.AdminAuthSupport; +import com.nanri.aiimage.modules.permission.mapper.AdminUserMapper; +import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity; +import com.nanri.aiimage.modules.permission.support.UserDataScopeSupport; +import com.nanri.aiimage.modules.shopkey.mapper.ShopManageGroupMapper; +import com.nanri.aiimage.modules.shopkey.model.dto.UserGroupRef; +import com.nanri.aiimage.modules.usersecret.mapper.UserSecretUsageMapper; +import com.nanri.aiimage.modules.usersecret.model.dto.AdminUserSecretUsageQuery; +import com.nanri.aiimage.modules.usersecret.model.entity.UserSecretUsageEntity; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +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 UserSecretUsageServiceTest { + + private final UserSecretUsageMapper usageMapper = mock(UserSecretUsageMapper.class); + private final AdminUserMapper adminUserMapper = mock(AdminUserMapper.class); + private final AdminAuthSupport adminAuthSupport = mock(AdminAuthSupport.class); + private final ShopManageGroupMapper adminGroupMapper = mock(ShopManageGroupMapper.class); + private final UserDataScopeSupport userDataScopeSupport = mock(UserDataScopeSupport.class); + + private UserSecretUsageService newService() { + // 默认按主管(admin)判定;超管用例里单独改打桩。 + when(adminAuthSupport.currentRole(any())).thenReturn("admin"); + return new UserSecretUsageService( + usageMapper, adminUserMapper, adminAuthSupport, adminGroupMapper, userDataScopeSupport); + } + + private UserSecretUsageEntity usageRow(Long userId, String moduleKey, int count) { + UserSecretUsageEntity row = new UserSecretUsageEntity(); + row.setUserId(userId); + row.setModuleKey(moduleKey); + row.setBusinessDate(LocalDate.now()); + row.setCallCount(count); + return row; + } + + @Test + void recordIncrementsByModuleWithTodayDate() { + UserSecretUsageService service = newService(); + when(usageMapper.increment(any(), any(), any(), eq(1))).thenReturn(1); + + service.record(7L, "similar-asin", 1); + + verify(usageMapper).increment(eq(7L), eq("similar-asin"), eq(LocalDate.now()), eq(1)); + } + + @Test + void recordSkipsInvalidUserOrUnknownModule() { + UserSecretUsageService service = newService(); + + service.record(null, "similar-asin", 1); + service.record(0L, "similar-asin", 1); + service.record(7L, "not-a-module", 1); + service.record(7L, "similar-asin", 0); + + verify(usageMapper, never()).increment(any(), any(), any(), any(Integer.class)); + } + + @Test + void recordSwallowsMapperFailure() { + UserSecretUsageService service = newService(); + when(usageMapper.increment(any(), any(), any(), eq(1))) + .thenThrow(new IllegalStateException("db down")); + + // 计次失败绝不影响任务主流程 + service.record(7L, "proxy", 1); + + verify(usageMapper).increment(eq(7L), eq("proxy"), eq(LocalDate.now()), eq(1)); + } + + @Test + void recordReportedCapsCountAndRejectsUnknownModule() { + UserSecretUsageService service = newService(); + when(usageMapper.increment(any(), any(), any(), any(Integer.class))).thenReturn(1); + + assertThat(service.recordReported(7L, "proxy", 50_000)).isTrue(); + assertThat(service.recordReported(7L, "not-a-module", 3)).isFalse(); + assertThat(service.recordReported(7L, "proxy", 0)).isFalse(); + + // 超出上限按 10000 截断,防止异常客户端写爆计数 + verify(usageMapper).increment(eq(7L), eq("proxy"), eq(LocalDate.now()), eq(10_000)); + } + + @Test + void adminPageAggregatesByUserAndSortsByTotal() { + UserSecretUsageService service = newService(); + when(adminAuthSupport.currentRole(any())).thenReturn("super_admin"); + when(usageMapper.selectList(any())).thenReturn(List.of( + usageRow(1L, "similar-asin", 10), + usageRow(1L, "proxy", 2), + usageRow(2L, "appearance-patent", 7))); + + AdminUserEntity user1 = new AdminUserEntity(); + user1.setId(1L); + user1.setUsername("张三"); + AdminUserEntity user2 = new AdminUserEntity(); + user2.setId(2L); + user2.setUsername("李四"); + when(adminUserMapper.selectBatchIds(any())).thenReturn(List.of(user1, user2)); + UserGroupRef ref = new UserGroupRef(); + ref.setUserId(1L); + ref.setGroupName("一组"); + when(adminGroupMapper.selectGroupNamesByUserIds(any())).thenReturn(List.of(ref)); + + var page = service.adminPage(new AdminUserEntity(), new AdminUserSecretUsageQuery()); + + assertThat(page.getTotal()).isEqualTo(2); + assertThat(page.getItems()).hasSize(2); + // 合计倒序:张三 12 > 李四 7 + var first = page.getItems().get(0); + assertThat(first.getUserId()).isEqualTo(1L); + assertThat(first.getUsername()).isEqualTo("张三"); + assertThat(first.getGroups()).containsExactly("一组"); + assertThat(first.getSimilarAsinCount()).isEqualTo(10); + assertThat(first.getProxyCount()).isEqualTo(2); + assertThat(first.getTotalCount()).isEqualTo(12); + var second = page.getItems().get(1); + assertThat(second.getAppearancePatentCount()).isEqualTo(7); + assertThat(second.getTotalCount()).isEqualTo(7); + // 汇总覆盖全量(不分页) + assertThat(page.getSimilarAsinCalls()).isEqualTo(10); + assertThat(page.getAppearancePatentCalls()).isEqualTo(7); + assertThat(page.getProxyCalls()).isEqualTo(2); + assertThat(page.getTotalCalls()).isEqualTo(19); + } + + @Test + void adminPageReturnsEmptyWhenLeaderHasNoVisibleUsers() { + UserSecretUsageService service = newService(); + AdminUserEntity operator = new AdminUserEntity(); + operator.setId(88L); + when(userDataScopeSupport.resolveVisibleUserIds(88L)).thenReturn(List.of()); + when(adminGroupMapper.selectLedGroups(88L)).thenReturn(List.of()); + + var page = service.adminPage(operator, new AdminUserSecretUsageQuery()); + + assertThat(page.getItems()).isEmpty(); + assertThat(page.getTotal()).isZero(); + assertThat(page.getTotalCalls()).isZero(); + // 无可见用户时不应触发用量查询 + verify(usageMapper, never()).selectList(any()); + } + + @Test + void adminPageFiltersByGroupForSuperAdmin() { + UserSecretUsageService service = newService(); + when(adminAuthSupport.currentRole(any())).thenReturn("super_admin"); + when(adminGroupMapper.selectUserIdsByGroupId(5L)).thenReturn(List.of(2L)); + when(usageMapper.selectList(any())).thenReturn(List.of(usageRow(2L, "proxy", 3))); + AdminUserEntity user2 = new AdminUserEntity(); + user2.setId(2L); + user2.setUsername("李四"); + when(adminUserMapper.selectBatchIds(any())).thenReturn(List.of(user2)); + when(adminGroupMapper.selectGroupNamesByUserIds(any())).thenReturn(List.of()); + + AdminUserSecretUsageQuery query = new AdminUserSecretUsageQuery(); + query.setGroupId(5L); + var page = service.adminPage(new AdminUserEntity(), query); + + assertThat(page.getItems()).hasSize(1); + assertThat(page.getItems().get(0).getProxyCount()).isEqualTo(3); + assertThat(page.getProxyCalls()).isEqualTo(3); + + // 空日期范围不产生额外过滤条件(由 mapper 兜底) + verify(usageMapper).selectList(any()); + verify(adminGroupMapper).selectUserIdsByGroupId(eq(5L)); + } + + @Test + void adminPageReturnsEmptyWhenKeywordMatchesNobody() { + UserSecretUsageService service = newService(); + when(adminAuthSupport.currentRole(any())).thenReturn("super_admin"); + when(adminUserMapper.selectList(any())).thenReturn(List.of()); + + AdminUserSecretUsageQuery query = new AdminUserSecretUsageQuery(); + query.setKeyword("不存在的人"); + var page = service.adminPage(new AdminUserEntity(), query); + + assertThat(page.getItems()).isEmpty(); + verify(usageMapper, never()).selectList(any()); + } +}