feat(密钥管理): 代理配置后台明文展示 + 按次统计各密钥调用损耗

- 后台密钥管理「代理设置」列改为完整明文展示(含账号密码),便于运维核对;
  新增 full 字段仅对代理模块下发,密钥两列保持脱敏
- 新增 biz_user_secret_usage_daily(V117):用户 × 模块 × 天累计真实对外请求次数
- 计次口径:LLM 每次真实 HTTP 请求(含重试)计 1 次;代理每次成功提取计 1 次
- LLM 埋点走 SecretUsageContext 上下文(批次外设置、线程池内快照恢复)
- 新增内部上报接口 /api/internal/user-secret-usage(X-Internal-Token)
- 新增后台页「密钥用量统计」:日期范围 + 用户名 + 分组筛选,含范围内汇总
This commit is contained in:
2026-09-13 21:56:46 +08:00
parent 0c98c5bc15
commit a0f6582914
35 changed files with 1545 additions and 76 deletions
@@ -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-usagegroup_id 必须 snake_case)。 */
export async function fetchUserSecretUsage(params: UserSecretUsageQuery): Promise<AdminUserSecretUsagePage> {
const query: Record<string, string | number> = { 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<AdminUserSecretUsagePage>(data)
}
@@ -6,6 +6,8 @@ export interface AdminUserSecretModule {
moduleKey: string
moduleLabel: string
masked: string
/** 完整明文值:仅代理列有值(后端对密钥列不下发明文)。 */
full: string
exists: boolean
checkStatus: string
checkCode: string
@@ -78,6 +78,10 @@ export const OPERATION_GUIDES: Record<string, OperationGuideData> = {
text: '数字人版本需先上传草稿,再发布并标记最新版本。',
steps: ['上传草稿', '确认更新日志', '发布或设为最新'],
},
admin_user_secret_usage: {
text: '按日期范围统计各用户的密钥调用次数(货源查询与外观专利按 LLM 请求次数、代理按提取次数),用于评估用户资源损耗。',
steps: ['选择日期范围', '筛选用户或分组', '核对各模块次数'],
},
}
/** 全部有独立/兜底提示的后台业务菜单 key。 */
@@ -0,0 +1,395 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import OldPagination from '@/components/OldPagination.vue'
import {
fetchUserSecretUsage,
type AdminUserSecretUsageRow,
} from '@/api/user-secret-usage'
import type { GroupOption } from '@/api/user-secrets'
import { useAdminSessionStore } from '@/stores/admin-session'
const session = useAdminSessionStore()
/** 仅超管需要分组维度:非超管数据由后端裁剪到本人分组,分组筛选/列一律不展示。 */
const isSuperAdmin = computed(() => session.isSuperAdmin)
const loading = ref(false)
const rows = ref<AdminUserSecretUsageRow[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(15)
const keyword = ref('')
const startDate = ref('')
const endDate = ref('')
const groupFilter = ref<number | null>(null)
const groupOptions = ref<GroupOption[]>([])
/** 所选范围内三类总次数(后端按筛选条件统计,不分页)。 */
const summary = ref({ totalCalls: 0, similarAsinCalls: 0, appearancePatentCalls: 0, proxyCalls: 0 })
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
function formatCount(value: number | undefined) {
const num = Number(value || 0)
return num.toLocaleString('zh-CN')
}
async function load() {
loading.value = true
try {
const result = await fetchUserSecretUsage({
startDate: startDate.value || undefined,
endDate: endDate.value || undefined,
keyword: keyword.value.trim() || undefined,
groupId: groupFilter.value || undefined,
page: page.value,
pageSize: pageSize.value,
})
rows.value = result?.items || []
total.value = Number(result?.total || 0)
groupOptions.value = result?.groupOptions || []
summary.value = {
totalCalls: Number(result?.totalCalls || 0),
similarAsinCalls: Number(result?.similarAsinCalls || 0),
appearancePatentCalls: Number(result?.appearancePatentCalls || 0),
proxyCalls: Number(result?.proxyCalls || 0),
}
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '加载失败')
} finally {
loading.value = false
}
}
function search() {
page.value = 1
load()
}
function reset() {
keyword.value = ''
startDate.value = ''
endDate.value = ''
groupFilter.value = null
page.value = 1
load()
}
function changePage(next: number) {
if (next < 1 || next > totalPages.value) return
page.value = next
load()
}
function changeSize(size: number) {
pageSize.value = size
page.value = 1
load()
}
onMounted(load)
</script>
<template>
<div class="secret-usage-view">
<section class="panel-box">
<div class="usage-head">
<h3>密钥用量统计</h3>
<span class="usage-note">口径真实对外请求次数LLM 每次请求代理每次成功提取</span>
</div>
<div class="summary-row">
<div class="summary-card">
<div class="summary-label">总调用次数</div>
<div class="summary-value">{{ formatCount(summary.totalCalls) }}</div>
</div>
<div class="summary-card">
<div class="summary-label">货源查询密钥</div>
<div class="summary-value">{{ formatCount(summary.similarAsinCalls) }}</div>
</div>
<div class="summary-card">
<div class="summary-label">外观专利密钥</div>
<div class="summary-value">{{ formatCount(summary.appearancePatentCalls) }}</div>
</div>
<div class="summary-card">
<div class="summary-label">代理提取</div>
<div class="summary-value">{{ formatCount(summary.proxyCalls) }}</div>
</div>
</div>
<div class="form-row usage-filter-row">
<div class="form-group" style="min-width: 160px">
<label>开始日期</label>
<input v-model="startDate" type="date" />
</div>
<div class="form-group" style="min-width: 160px">
<label>结束日期</label>
<input v-model="endDate" type="date" />
</div>
<div class="form-group" style="min-width: 200px">
<label>用户名</label>
<input v-model="keyword" type="text" placeholder="模糊搜索用户名" @keyup.enter="search" />
</div>
<div class="form-group" v-if="isSuperAdmin && groupOptions.length" style="min-width: 170px">
<label>分组</label>
<select v-model="groupFilter">
<option :value="null">全部分组</option>
<option v-for="group in groupOptions" :key="group.id" :value="group.id">{{ group.groupName }}</option>
</select>
</div>
<div class="form-group">
<label>&nbsp;</label>
<div class="filter-actions">
<button class="btn" type="button" @click="search">查询</button>
<button class="btn btn-ghost" type="button" @click="reset">重置</button>
</div>
</div>
</div>
<div class="usage-table-scroll">
<table>
<thead>
<tr>
<th style="width: 58px">序号</th>
<th style="width: 220px">用户</th>
<th v-if="isSuperAdmin" style="width: 130px">分组</th>
<th style="width: 170px">货源查询密钥</th>
<th style="width: 170px">外观专利密钥</th>
<th style="width: 150px">代理提取</th>
<th style="width: 130px">合计</th>
</tr>
</thead>
<tbody>
<template v-if="rows.length">
<tr v-for="(row, index) in rows" :key="row.userId">
<td>{{ (page - 1) * pageSize + index + 1 }}</td>
<td>
<span class="user-name">{{ row.username || `UID ${row.userId}` }}</span>
</td>
<td v-if="isSuperAdmin">
<span class="group-name">{{ row.groups?.length ? row.groups.join('、') : '—' }}</span>
</td>
<td><span class="count-value">{{ formatCount(row.similarAsinCount) }}</span></td>
<td><span class="count-value">{{ formatCount(row.appearancePatentCount) }}</span></td>
<td><span class="count-value">{{ formatCount(row.proxyCount) }}</span></td>
<td><span class="count-value count-total">{{ formatCount(row.totalCount) }}</span></td>
</tr>
</template>
<tr v-else-if="loading">
<td :colspan="isSuperAdmin ? 7 : 6" class="empty-tip">加载中...</td>
</tr>
<tr v-else>
<td :colspan="isSuperAdmin ? 7 : 6" class="empty-tip">
{{ keyword || startDate || endDate || groupFilter ? '暂无匹配记录' : '暂无用量记录' }}
</td>
</tr>
</tbody>
</table>
</div>
<OldPagination :total="total" :page="page" :page-size="pageSize" @change="changePage" @size-change="changeSize" />
</section>
</div>
</template>
<style scoped>
/* 与密钥管理页保持同一视觉语言(像素复刻后台面板风格)。 */
.secret-usage-view {
font-family: inherit;
color: #24384d;
}
.panel-box {
width: 100%;
min-width: 0;
padding: 20px 22px 24px;
border: 1px solid #d8e3ee;
border-radius: 14px;
background: linear-gradient(145deg, #ffffff, #f9fbfd);
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
}
h3 {
margin: 0;
font-size: 15px;
font-weight: 650;
color: #24384d;
letter-spacing: 0.2px;
}
.usage-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.usage-note {
color: #7d8fa2;
font-size: 12px;
}
.summary-row {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 16px;
}
.summary-card {
flex: 1 1 160px;
min-width: 150px;
padding: 12px 16px;
border: 1px solid #dbe5ee;
border-radius: 10px;
background: #f7fafd;
}
.summary-label {
color: #5b6f83;
font-size: 12.5px;
font-weight: 600;
}
.summary-value {
margin-top: 6px;
color: #2f5d8b;
font-size: 22px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.usage-filter-row {
margin: 0 0 16px;
}
.form-row {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 14px 18px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 7px;
margin-bottom: 0;
}
.form-group label {
color: #5b6f83;
font-size: 12.5px;
font-weight: 600;
}
.form-group input,
.form-group select {
min-width: 0;
min-height: 42px;
padding: 8px 12px;
border: 1px solid #cbd9e6;
border-radius: 9px;
background: #f8fbfd;
color: #24384d;
font-size: 13.5px;
font-family: inherit;
color-scheme: light;
outline: none;
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
}
.form-group input:hover,
.form-group select:hover {
border-color: #9fb7cd;
}
.form-group input:focus,
.form-group select:focus {
background: #ffffff;
border-color: #5f85ad;
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
}
.filter-actions {
display: flex;
gap: 10px;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 42px;
padding: 9px 18px;
border: 1px solid #4f78a5;
border-radius: 9px;
background: linear-gradient(135deg, #5f85ad, #4f78a5);
color: #ffffff;
font-family: inherit;
font-size: 13.5px;
cursor: pointer;
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
}
.btn:hover:not(:disabled) {
background: linear-gradient(135deg, #7094ba, #5d83ac);
}
.btn-ghost {
background: #ffffff;
border-color: #c7d7e5;
color: #4f78a5;
box-shadow: none;
}
.btn-ghost:hover:not(:disabled) {
background: #edf5fb;
border-color: #95b1cb;
color: #2f5d8b;
}
.usage-table-scroll {
width: 100%;
min-width: 0;
overflow-x: auto;
border: 1px solid #dbe5ee;
border-radius: 10px;
background: #ffffff;
}
.usage-table-scroll table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
min-width: 1020px;
}
.usage-table-scroll th,
.usage-table-scroll td {
padding: 10px 12px;
text-align: left;
font-size: 13.5px;
line-height: 1.5;
border-bottom: 1px solid #e0e8ef;
vertical-align: middle;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.usage-table-scroll th {
background: #edf4fa;
color: #4e6479;
border-bottom-color: #d5e1eb;
font-size: 12.5px;
font-weight: 600;
letter-spacing: 0.4px;
}
.usage-table-scroll tbody tr:hover td {
background: #f1f7fb;
}
.usage-table-scroll tbody tr:last-child td {
border-bottom: 0;
}
.user-name {
color: #24384d;
font-weight: 600;
white-space: normal;
overflow-wrap: anywhere;
}
.group-name {
color: #5b6f83;
font-size: 12.5px;
}
.count-value {
color: #2f5d8b;
font-variant-numeric: tabular-nums;
font-size: 13.5px;
}
.count-total {
font-weight: 700;
}
.empty-tip {
color: #8293a5;
text-align: center;
}
</style>
@@ -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)
<th v-if="isSuperAdmin" style="width: 130px">分组</th>
<th style="width: 230px">货源查询密钥</th>
<th style="width: 230px">外观专利密钥</th>
<th style="width: 230px">代理设置</th>
<th style="width: 380px">代理设置</th>
<th style="width: 130px">状态</th>
<th style="width: 170px">操作</th>
</tr>
@@ -264,8 +265,8 @@ onMounted(load)
</div>
</td>
<td>
<div class="module-cell">
<span v-if="row.proxy?.exists" class="mono-mask" :title="moduleTooltip(row.proxy)">{{ row.proxy.masked }}</span>
<div class="module-cell module-cell--stack">
<span v-if="row.proxy?.exists" class="mono-mask mono-full" :title="moduleTooltip(row.proxy)">{{ row.proxy.full || row.proxy.masked }}</span>
<span v-else class="empty-value">未配置</span>
<span class="wh-pill" :class="moduleStatusMeta(row.proxy).tone" :title="moduleTooltip(row.proxy)">
{{ 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;
+1
View File
@@ -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') },
+1 -1
View File
@@ -13,7 +13,7 @@ import { adminPages } from '../src/router/routes.ts'
test('test_task_010_lazy_page_boundary_normal_primary_path', () => {
// 正常主路径:所有注册页面都是懒加载器,可被路由异步边界包裹。
assert.equal(adminPages.length, 17)
assert.ok(adminPages.length >= 18, '业务页至少 18(随版本递增,不设上限断言)')
for (const page of adminPages) {
assert.equal(isLazyLoader(page.load), true, `页面 ${page.path} 必须是懒加载函数`)
}
+4 -1
View File
@@ -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', () => {
+1 -1
View File
@@ -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')
@@ -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<CompletableFuture<AppearancePatentResultRowDto>> 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<AppearancePatentResultRowDto> merged = new ArrayList<>(rows.size());
for (int i = 0; i < futures.size(); i++) {
@@ -288,6 +294,7 @@ public class AppearancePatentLlmClient {
List<String> images,
String apiKey,
String responseFormat) {
recordSecretUsage();
Map<String, Object> 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);
@@ -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<AppearancePatentResultRowDto> 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={}",
@@ -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"),
@@ -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<String> images,
String apiKey,
String responseFormat) {
recordSecretUsage();
Map<String, Object> 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) {
@@ -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<CompletableFuture<SimilarAsinResultRowDto>> 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<SimilarAsinResultRowDto> merged = new ArrayList<>(rows.size());
for (int i = 0; i < futures.size(); i++) {
@@ -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<SimilarAsinResultRowDto> 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<SimilarAsinResultRowDto> 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<String, List<SimilarAsinParsedRowVo>> allRowsByBaseId) {
List<SimilarAsinResultRowDto> 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={}",
@@ -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<AdminUserSecretUsagePageVo> 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));
}
}
@@ -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<Map<String, Object>> 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));
}
}
@@ -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<UserSecretUsageEntity> {
/**
* 按唯一键 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);
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -18,6 +18,9 @@ public class AdminUserSecretModuleVo {
@Schema(description = "脱敏值")
private String masked;
@Schema(description = "完整明文值:仅代理列返回(含账号密码),密钥列为空")
private String full;
@Schema(description = "是否已配置")
private Boolean exists;
@@ -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<AdminUserSecretUsageRowVo> 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<AdminUserSecretPageVo.GroupOptionVo> groupOptions;
}
@@ -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<String> groups;
@Schema(description = "货源查询密钥调用次数")
private Integer similarAsinCount;
@Schema(description = "外观专利密钥调用次数")
private Integer appearancePatentCount;
@Schema(description = "代理提取次数")
private Integer proxyCount;
@Schema(description = "合计次数")
private Integer totalCount;
}
@@ -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<Long> ledGroupIds = superAdmin ? List.of() : listLedGroupIds(operator.getId());
List<Long> ledGroupIds = superAdmin ? List.of() : userDataScopeSupport.listLedGroupIds(operator.getId());
LambdaQueryWrapper<UserApiSecretEntity> wrapper = new LambdaQueryWrapper<>();
String keyword = normalize(safeQuery.getKeyword());
@@ -289,42 +298,6 @@ public class UserApiSecretService {
return vo;
}
/** 主管可见用户:自己 + 自己带的分组下的子账户(名下 users.created_by_id=自己)。 */
private List<Long> resolveLedGroupMemberIds(Long operatorId) {
if (operatorId == null) {
return List.of();
}
List<Long> ledGroupIds = listLedGroupIds(operatorId);
Set<Long> userIds = new LinkedHashSet<>();
for (Long groupId : ledGroupIds) {
userIds.addAll(adminGroupMapper.selectUserIdsByGroupId(groupId));
}
// 没有分组记录的主管(历史数据)回退按「名下子账户」兜底,避免整个页面空白。
if (userIds.isEmpty()) {
adminUserMapper.selectList(new LambdaQueryWrapper<AdminUserEntity>()
.eq(AdminUserEntity::getCreatedById, operatorId)
.last("limit 2000"))
.forEach(user -> {
if (user.getId() != null) {
userIds.add(user.getId());
}
});
}
userIds.add(operatorId);
return new ArrayList<>(userIds);
}
/** 主管带的分组 IDcreated_by_id / user_id = 自己)。 */
private List<Long> 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<AdminUserSecretPageVo.GroupOptionVo> groupOptions(AdminUserEntity operator, boolean superAdmin) {
List<ShopManageGroupEntity> 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<UserApiSecretEntity> batch = userApiSecretMapper.selectList(new LambdaQueryWrapper<UserApiSecretEntity>()
@@ -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<String, UserApiSecretEntity> 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());
@@ -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<Long> 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<UserSecretUsageEntity> 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<Long> allowedUserIds = scopedUserIds == null ? null : new ArrayList<>(scopedUserIds);
if (!keyword.isEmpty()) {
List<Long> matched = adminUserMapper.selectList(new LambdaQueryWrapper<AdminUserEntity>()
.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<UserSecretUsageEntity> rows = usageMapper.selectList(wrapper);
Map<Long, AdminUserSecretUsageRowVo> 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<AdminUserSecretUsageRowVo> 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<AdminUserSecretUsageRowVo> 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<AdminUserSecretUsageRowVo> pageItems) {
List<Long> userIds = pageItems.stream()
.map(AdminUserSecretUsageRowVo::getUserId)
.filter(id -> id != null)
.toList();
if (userIds.isEmpty()) {
return;
}
Map<Long, String> usernames = new HashMap<>();
for (AdminUserEntity user : adminUserMapper.selectBatchIds(userIds)) {
if (user.getId() != null) {
usernames.put(user.getId(), normalize(user.getUsername()));
}
}
Map<Long, List<String>> 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<AdminUserSecretPageVo.GroupOptionVo> groupOptions(AdminUserEntity operator, boolean superAdmin) {
List<ShopManageGroupEntity> 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<AdminUserSecretPageVo.GroupOptionVo> 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();
}
}
@@ -0,0 +1,79 @@
package com.nanri.aiimage.modules.usersecret.support;
import java.util.function.Supplier;
/**
* 密钥计次上下文任务批处理内标记当前调用归属哪个用户哪个密钥模块
* LLM 客户端在每次真实 HTTP 请求处计次
*
* <p>任务侧在批次循环外 {@link #call} 设置客户端在工作线程内 {@link #current} 读取
* 由于 LLM 行级处理跑在线程池CompletableFuture提交任务时必须用
* {@link #snapshot()} 捕获并在工作线程内用 {@link #wrap} 恢复
* 执行完恢复原值避免线程复用串号
*/
public final class SecretUsageContext {
private static final ThreadLocal<Context> 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> T call(Long userId, String moduleKey, Supplier<T> 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 <T> Supplier<T> wrap(Supplier<T> 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);
}
}
}
@@ -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 '用户IDusers.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'
);
@@ -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());
@@ -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);
@@ -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)
);
}
@@ -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");
@@ -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");
@@ -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
@@ -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());
}
}