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:
@@ -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<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
|
moduleKey: string
|
||||||
moduleLabel: string
|
moduleLabel: string
|
||||||
masked: string
|
masked: string
|
||||||
|
/** 完整明文值:仅代理列有值(后端对密钥列不下发明文)。 */
|
||||||
|
full: string
|
||||||
exists: boolean
|
exists: boolean
|
||||||
checkStatus: string
|
checkStatus: string
|
||||||
checkCode: string
|
checkCode: string
|
||||||
|
|||||||
@@ -78,6 +78,10 @@ export const OPERATION_GUIDES: Record<string, OperationGuideData> = {
|
|||||||
text: '数字人版本需先上传草稿,再发布并标记最新版本。',
|
text: '数字人版本需先上传草稿,再发布并标记最新版本。',
|
||||||
steps: ['上传草稿', '确认更新日志', '发布或设为最新'],
|
steps: ['上传草稿', '确认更新日志', '发布或设为最新'],
|
||||||
},
|
},
|
||||||
|
admin_user_secret_usage: {
|
||||||
|
text: '按日期范围统计各用户的密钥调用次数(货源查询与外观专利按 LLM 请求次数、代理按提取次数),用于评估用户资源损耗。',
|
||||||
|
steps: ['选择日期范围', '筛选用户或分组', '核对各模块次数'],
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 全部有独立/兜底提示的后台业务菜单 key。 */
|
/** 全部有独立/兜底提示的后台业务菜单 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> </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) {
|
function moduleTooltip(module: AdminUserSecretModule | undefined) {
|
||||||
if (!module || !module.exists) return '未配置'
|
if (!module || !module.exists) return '未配置'
|
||||||
const parts: string[] = []
|
const parts: string[] = []
|
||||||
|
if (module.full) parts.push(module.full)
|
||||||
if (module.checkMessage) parts.push(module.checkMessage)
|
if (module.checkMessage) parts.push(module.checkMessage)
|
||||||
if (module.checkedAt) parts.push(`检测时间:${formatDateTime(module.checkedAt)}`)
|
if (module.checkedAt) parts.push(`检测时间:${formatDateTime(module.checkedAt)}`)
|
||||||
if (module.checkLatencyMs != null) parts.push(`耗时:${module.checkLatencyMs}ms`)
|
if (module.checkLatencyMs != null) parts.push(`耗时:${module.checkLatencyMs}ms`)
|
||||||
@@ -230,7 +231,7 @@ onMounted(load)
|
|||||||
<th v-if="isSuperAdmin" style="width: 130px">分组</th>
|
<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: 230px">外观专利密钥</th>
|
||||||
<th style="width: 230px">代理设置</th>
|
<th style="width: 380px">代理设置</th>
|
||||||
<th style="width: 130px">状态</th>
|
<th style="width: 130px">状态</th>
|
||||||
<th style="width: 170px">操作</th>
|
<th style="width: 170px">操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -264,8 +265,8 @@ onMounted(load)
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="module-cell">
|
<div class="module-cell module-cell--stack">
|
||||||
<span v-if="row.proxy?.exists" class="mono-mask" :title="moduleTooltip(row.proxy)">{{ row.proxy.masked }}</span>
|
<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 v-else class="empty-value">未配置</span>
|
||||||
<span class="wh-pill" :class="moduleStatusMeta(row.proxy).tone" :title="moduleTooltip(row.proxy)">
|
<span class="wh-pill" :class="moduleStatusMeta(row.proxy).tone" :title="moduleTooltip(row.proxy)">
|
||||||
{{ moduleStatusMeta(row.proxy).label }}
|
{{ moduleStatusMeta(row.proxy).label }}
|
||||||
@@ -486,11 +487,29 @@ h3 {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
/* 代理列:完整地址独占一行,状态药丸另起一行,避免长地址被药丸挤压折行 */
|
||||||
|
.module-cell--stack {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
.module-cell .mono-mask {
|
.module-cell .mono-mask {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
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 {
|
.mono-mask {
|
||||||
font-family: Consolas, "Cascadia Mono", monospace;
|
font-family: Consolas, "Cascadia Mono", monospace;
|
||||||
font-size: 12.5px;
|
font-size: 12.5px;
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export const adminPages: AdminPageDef[] = [
|
|||||||
{ path: 'account/menus', menuKey: 'admin_columns', title: '菜单管理', load: () => import('@/pages/account/MenusPage.vue') },
|
{ path: 'account/menus', menuKey: 'admin_columns', title: '菜单管理', load: () => import('@/pages/account/MenusPage.vue') },
|
||||||
{ path: 'account/groups', menuKey: 'admin_group_manage', title: '数据权限分组', load: () => import('@/pages/account/GroupsPage.vue') },
|
{ path: 'account/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-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/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/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') },
|
{ path: 'shop-center/shops', menuKey: 'admin_shop_manage', title: '店铺管理', load: () => import('@/pages/shop/ShopManagePage.vue') },
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { adminPages } from '../src/router/routes.ts'
|
|||||||
|
|
||||||
test('test_task_010_lazy_page_boundary_normal_primary_path', () => {
|
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) {
|
for (const page of adminPages) {
|
||||||
assert.equal(isLazyLoader(page.load), true, `页面 ${page.path} 必须是懒加载函数`)
|
assert.equal(isLazyLoader(page.load), true, `页面 ${page.path} 必须是懒加载函数`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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', () => {
|
test('test_task_012_route_error_page_boundary_empty_input', () => {
|
||||||
// 边界空值:错误页文件存在,且不进入业务路由注册表。
|
// 边界空值:错误页文件存在,且不进入业务路由注册表。
|
||||||
assert.equal(existsSync(join(process.cwd(), NOT_FOUND)), true)
|
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', () => {
|
test('test_task_012_route_error_page_boundary_single_item', () => {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
|
|
||||||
test('test_task_008_domain_route_registry_normal_primary_path', () => {
|
test('test_task_008_domain_route_registry_normal_primary_path', () => {
|
||||||
// 正常主路径:注册表首批 account 域页面登记为可消费路由记录。
|
// 正常主路径:注册表首批 account 域页面登记为可消费路由记录。
|
||||||
assert.equal(adminPages.length, 17)
|
assert.ok(adminPages.length >= 18, '业务页至少 18(随版本递增,不设上限断言)')
|
||||||
assert.equal(adminRouteRecords.length, adminPages.length)
|
assert.equal(adminRouteRecords.length, adminPages.length)
|
||||||
const first = adminRouteRecords[0]
|
const first = adminRouteRecords[0]
|
||||||
assert.equal(first.path, 'account/users')
|
assert.equal(first.path, 'account/users')
|
||||||
|
|||||||
+18
-2
@@ -7,6 +7,9 @@ import com.nanri.aiimage.config.HttpClientPool;
|
|||||||
import com.nanri.aiimage.metrics.ExternalCallMetricsRecorder;
|
import com.nanri.aiimage.metrics.ExternalCallMetricsRecorder;
|
||||||
import com.nanri.aiimage.modules.appearancepatent.model.dto.AppearancePatentResultRowDto;
|
import com.nanri.aiimage.modules.appearancepatent.model.dto.AppearancePatentResultRowDto;
|
||||||
import com.nanri.aiimage.modules.brand.client.BrandCheckClient;
|
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.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.http.HttpHeaders;
|
import org.springframework.http.HttpHeaders;
|
||||||
@@ -97,6 +100,7 @@ public class AppearancePatentLlmClient {
|
|||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final ExternalCallMetricsRecorder externalCallMetrics;
|
private final ExternalCallMetricsRecorder externalCallMetrics;
|
||||||
private final BrandCheckClient brandCheckClient;
|
private final BrandCheckClient brandCheckClient;
|
||||||
|
private final UserSecretUsageService userSecretUsageService;
|
||||||
|
|
||||||
private volatile RestClient sharedRestClient;
|
private volatile RestClient sharedRestClient;
|
||||||
private volatile ExecutorService rowExecutor;
|
private volatile ExecutorService rowExecutor;
|
||||||
@@ -117,9 +121,11 @@ public class AppearancePatentLlmClient {
|
|||||||
}
|
}
|
||||||
Semaphore concurrency = new Semaphore(Math.max(1, properties.getLlmRowConcurrency()));
|
Semaphore concurrency = new Semaphore(Math.max(1, properties.getLlmRowConcurrency()));
|
||||||
ExecutorService executor = rowExecutor();
|
ExecutorService executor = rowExecutor();
|
||||||
|
// 行级处理跑在 worker 线程:把「当前任务归属用户」快照带过去,供 LLM 请求计次
|
||||||
|
SecretUsageContext.Context usageContext = SecretUsageContext.snapshot();
|
||||||
List<CompletableFuture<AppearancePatentResultRowDto>> futures = new ArrayList<>(rows.size());
|
List<CompletableFuture<AppearancePatentResultRowDto>> futures = new ArrayList<>(rows.size());
|
||||||
for (AppearancePatentResultRowDto row : rows) {
|
for (AppearancePatentResultRowDto row : rows) {
|
||||||
futures.add(CompletableFuture.supplyAsync(() -> {
|
futures.add(CompletableFuture.supplyAsync(SecretUsageContext.wrap(() -> {
|
||||||
try {
|
try {
|
||||||
concurrency.acquire();
|
concurrency.acquire();
|
||||||
try {
|
try {
|
||||||
@@ -131,7 +137,7 @@ public class AppearancePatentLlmClient {
|
|||||||
Thread.currentThread().interrupt();
|
Thread.currentThread().interrupt();
|
||||||
throw new IllegalStateException("LLM row inspect interrupted");
|
throw new IllegalStateException("LLM row inspect interrupted");
|
||||||
}
|
}
|
||||||
}, executor));
|
}, usageContext), executor));
|
||||||
}
|
}
|
||||||
List<AppearancePatentResultRowDto> merged = new ArrayList<>(rows.size());
|
List<AppearancePatentResultRowDto> merged = new ArrayList<>(rows.size());
|
||||||
for (int i = 0; i < futures.size(); i++) {
|
for (int i = 0; i < futures.size(); i++) {
|
||||||
@@ -288,6 +294,7 @@ public class AppearancePatentLlmClient {
|
|||||||
List<String> images,
|
List<String> images,
|
||||||
String apiKey,
|
String apiKey,
|
||||||
String responseFormat) {
|
String responseFormat) {
|
||||||
|
recordSecretUsage();
|
||||||
Map<String, Object> body = buildChatBody(model, system, userText, images, responseFormat);
|
Map<String, Object> body = buildChatBody(model, system, userText, images, responseFormat);
|
||||||
log.info("[appearance-patent] llm request model={} url={} body={}",
|
log.info("[appearance-patent] llm request model={} url={} body={}",
|
||||||
model, joinUrl(properties.getLlmHost(), "/v1/chat/completions"),
|
model, joinUrl(properties.getLlmHost(), "/v1/chat/completions"),
|
||||||
@@ -841,6 +848,15 @@ public class AppearancePatentLlmClient {
|
|||||||
sleepQuietly(Math.max(1, attemptIndex) * 1500L);
|
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) {
|
private void sleepQuietly(long delayMillis) {
|
||||||
try {
|
try {
|
||||||
Thread.sleep(delayMillis);
|
Thread.sleep(delayMillis);
|
||||||
|
|||||||
+3
-1
@@ -93,6 +93,7 @@ import java.util.zip.ZipOutputStream;
|
|||||||
import com.nanri.aiimage.modules.task.model.vo.TaskProgressLightBatchVo;
|
import com.nanri.aiimage.modules.task.model.vo.TaskProgressLightBatchVo;
|
||||||
import com.nanri.aiimage.modules.task.service.TaskProgressLightAssembler;
|
import com.nanri.aiimage.modules.task.service.TaskProgressLightAssembler;
|
||||||
import com.nanri.aiimage.modules.usersecret.service.UserApiSecretService;
|
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 com.nanri.aiimage.modules.usersecret.support.UserSecretModule;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@@ -802,7 +803,8 @@ public class AppearancePatentTaskService {
|
|||||||
long startedAt = System.currentTimeMillis();
|
long startedAt = System.currentTimeMillis();
|
||||||
List<AppearancePatentResultRowDto> llmRows;
|
List<AppearancePatentResultRowDto> llmRows;
|
||||||
try {
|
try {
|
||||||
llmRows = llmClient.inspectRows(batchRows, prompt, apiKey);
|
llmRows = SecretUsageContext.call(task.getUserId(), UserSecretModule.APPEARANCE_PATENT.key(),
|
||||||
|
() -> llmClient.inspectRows(batchRows, prompt, apiKey));
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
String message = firstNonBlank(ex.getMessage(), "LLM 检测失败");
|
String message = firstNonBlank(ex.getMessage(), "LLM 检测失败");
|
||||||
log.warn("[appearance-patent] llm batch failed taskId={} jobId={} rows={} batch={}/{} err={}",
|
log.warn("[appearance-patent] llm batch failed taskId={} jobId={} rows={} batch={}/{} err={}",
|
||||||
|
|||||||
+1
@@ -64,6 +64,7 @@ public class PermissionMenuSchemaInitializer {
|
|||||||
new DefaultAdminMenu("菜单权限配置", "admin_columns", "columns", 20, "admin_group_account"),
|
new DefaultAdminMenu("菜单权限配置", "admin_columns", "columns", 20, "admin_group_account"),
|
||||||
new DefaultAdminMenu("分组管理", "admin_group_manage", "group-manage", 25, "admin_group_account"),
|
new DefaultAdminMenu("分组管理", "admin_group_manage", "group-manage", 25, "admin_group_account"),
|
||||||
new DefaultAdminMenu("密钥管理", "admin_user_secrets", "account/user-secrets", 41, "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_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("品牌数据库", "admin_invalid_asin_data", "invalid-asin-data", 35, "admin_group_data"),
|
||||||
new DefaultAdminMenu("查询ASIN", "admin_query_asin", "query-asin", 65, "admin_group_data"),
|
new DefaultAdminMenu("查询ASIN", "admin_query_asin", "query-asin", 65, "admin_group_data"),
|
||||||
|
|||||||
+17
-1
@@ -6,6 +6,9 @@ import com.nanri.aiimage.config.HttpClientPool;
|
|||||||
import com.nanri.aiimage.config.SimilarAsinProperties;
|
import com.nanri.aiimage.config.SimilarAsinProperties;
|
||||||
import com.nanri.aiimage.metrics.ExternalCallMetricsRecorder;
|
import com.nanri.aiimage.metrics.ExternalCallMetricsRecorder;
|
||||||
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinResultRowDto;
|
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 lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.http.HttpHeaders;
|
import org.springframework.http.HttpHeaders;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
@@ -37,15 +40,18 @@ public class SimilarAsinLlmClient {
|
|||||||
private final SimilarAsinProperties properties;
|
private final SimilarAsinProperties properties;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final ExternalCallMetricsRecorder externalCallMetrics;
|
private final ExternalCallMetricsRecorder externalCallMetrics;
|
||||||
|
private final UserSecretUsageService userSecretUsageService;
|
||||||
|
|
||||||
private volatile RestClient sharedRestClient;
|
private volatile RestClient sharedRestClient;
|
||||||
|
|
||||||
public SimilarAsinLlmClient(SimilarAsinProperties properties,
|
public SimilarAsinLlmClient(SimilarAsinProperties properties,
|
||||||
ObjectMapper objectMapper,
|
ObjectMapper objectMapper,
|
||||||
ExternalCallMetricsRecorder externalCallMetrics) {
|
ExternalCallMetricsRecorder externalCallMetrics,
|
||||||
|
UserSecretUsageService userSecretUsageService) {
|
||||||
this.properties = properties;
|
this.properties = properties;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
this.externalCallMetrics = externalCallMetrics;
|
this.externalCallMetrics = externalCallMetrics;
|
||||||
|
this.userSecretUsageService = userSecretUsageService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 文本对话,json_object 输出。 */
|
/** 文本对话,json_object 输出。 */
|
||||||
@@ -105,6 +111,7 @@ public class SimilarAsinLlmClient {
|
|||||||
List<String> images,
|
List<String> images,
|
||||||
String apiKey,
|
String apiKey,
|
||||||
String responseFormat) {
|
String responseFormat) {
|
||||||
|
recordSecretUsage();
|
||||||
Map<String, Object> body = buildChatBody(model, system, userText, images, responseFormat);
|
Map<String, Object> body = buildChatBody(model, system, userText, images, responseFormat);
|
||||||
log.debug("[similar-asin][llm] request model={} url={} body={}",
|
log.debug("[similar-asin][llm] request model={} url={} body={}",
|
||||||
model, joinUrl(properties.getLlmHost(), "/v1/chat/completions"),
|
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() {
|
private RestClient restClient() {
|
||||||
RestClient client = sharedRestClient;
|
RestClient client = sharedRestClient;
|
||||||
if (client != null) {
|
if (client != null) {
|
||||||
|
|||||||
+5
-2
@@ -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.model.dto.SimilarAsinResultRowDto;
|
||||||
import com.nanri.aiimage.modules.similarasin.util.PuzzleImageMerger;
|
import com.nanri.aiimage.modules.similarasin.util.PuzzleImageMerger;
|
||||||
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder;
|
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder;
|
||||||
|
import com.nanri.aiimage.modules.usersecret.support.SecretUsageContext;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@@ -187,9 +188,11 @@ public class SimilarAsinLlmService {
|
|||||||
}
|
}
|
||||||
Semaphore concurrency = new Semaphore(Math.max(1, properties.getLlmRowConcurrency()));
|
Semaphore concurrency = new Semaphore(Math.max(1, properties.getLlmRowConcurrency()));
|
||||||
ExecutorService executor = rowExecutor();
|
ExecutorService executor = rowExecutor();
|
||||||
|
// 行级处理跑在 worker 线程:把「当前任务归属用户」快照带过去,供 LLM 请求计次
|
||||||
|
SecretUsageContext.Context usageContext = SecretUsageContext.snapshot();
|
||||||
List<CompletableFuture<SimilarAsinResultRowDto>> futures = new ArrayList<>(rows.size());
|
List<CompletableFuture<SimilarAsinResultRowDto>> futures = new ArrayList<>(rows.size());
|
||||||
for (SimilarAsinResultRowDto row : rows) {
|
for (SimilarAsinResultRowDto row : rows) {
|
||||||
futures.add(CompletableFuture.supplyAsync(() -> {
|
futures.add(CompletableFuture.supplyAsync(SecretUsageContext.wrap(() -> {
|
||||||
try {
|
try {
|
||||||
concurrency.acquire();
|
concurrency.acquire();
|
||||||
try {
|
try {
|
||||||
@@ -201,7 +204,7 @@ public class SimilarAsinLlmService {
|
|||||||
Thread.currentThread().interrupt();
|
Thread.currentThread().interrupt();
|
||||||
throw new IllegalStateException("LLM row inspect interrupted", interruptedException);
|
throw new IllegalStateException("LLM row inspect interrupted", interruptedException);
|
||||||
}
|
}
|
||||||
}, executor));
|
}, usageContext), executor));
|
||||||
}
|
}
|
||||||
List<SimilarAsinResultRowDto> merged = new ArrayList<>(rows.size());
|
List<SimilarAsinResultRowDto> merged = new ArrayList<>(rows.size());
|
||||||
for (int i = 0; i < futures.size(); i++) {
|
for (int i = 0; i < futures.size(); i++) {
|
||||||
|
|||||||
+7
-2
@@ -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.TaskProgressSnapshotService;
|
||||||
import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService;
|
import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService;
|
||||||
import com.nanri.aiimage.modules.usersecret.service.UserApiSecretService;
|
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 com.nanri.aiimage.modules.usersecret.support.UserSecretModule;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
@@ -1493,7 +1494,10 @@ public class SimilarAsinTaskService {
|
|||||||
int batchSize = resolveLlmBatchSize(imgSwitch);
|
int batchSize = resolveLlmBatchSize(imgSwitch);
|
||||||
List<SimilarAsinResultRowDto> result = new ArrayList<>();
|
List<SimilarAsinResultRowDto> result = new ArrayList<>();
|
||||||
for (int i = 0; i < items.size(); i += batchSize) {
|
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) {
|
if (progressHook != null) {
|
||||||
progressHook.run();
|
progressHook.run();
|
||||||
}
|
}
|
||||||
@@ -2665,7 +2669,8 @@ public class SimilarAsinTaskService {
|
|||||||
Map<String, List<SimilarAsinParsedRowVo>> allRowsByBaseId) {
|
Map<String, List<SimilarAsinParsedRowVo>> allRowsByBaseId) {
|
||||||
List<SimilarAsinResultRowDto> llmRows;
|
List<SimilarAsinResultRowDto> llmRows;
|
||||||
try {
|
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) {
|
} catch (Exception ex) {
|
||||||
String message = firstNonBlank(ex.getMessage(), "LLM submit failed");
|
String message = firstNonBlank(ex.getMessage(), "LLM submit failed");
|
||||||
log.warn("[similar-asin] llm submit failed taskId={} jobId={} rows={} batch={}/{} err={}",
|
log.warn("[similar-asin] llm submit failed taskId={} jobId={} rows={} batch={}/{} err={}",
|
||||||
|
|||||||
+58
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+55
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+27
@@ -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);
|
||||||
|
}
|
||||||
+24
@@ -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;
|
||||||
|
}
|
||||||
+19
@@ -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;
|
||||||
|
}
|
||||||
+26
@@ -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;
|
||||||
|
}
|
||||||
+3
@@ -18,6 +18,9 @@ public class AdminUserSecretModuleVo {
|
|||||||
@Schema(description = "脱敏值")
|
@Schema(description = "脱敏值")
|
||||||
private String masked;
|
private String masked;
|
||||||
|
|
||||||
|
@Schema(description = "完整明文值:仅代理列返回(含账号密码),密钥列为空")
|
||||||
|
private String full;
|
||||||
|
|
||||||
@Schema(description = "是否已配置")
|
@Schema(description = "是否已配置")
|
||||||
private Boolean exists;
|
private Boolean exists;
|
||||||
|
|
||||||
|
|||||||
+39
@@ -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;
|
||||||
|
}
|
||||||
+31
@@ -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;
|
||||||
|
}
|
||||||
+86
-46
@@ -3,9 +3,13 @@ package com.nanri.aiimage.modules.usersecret.service;
|
|||||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
import com.nanri.aiimage.common.exception.BusinessException;
|
import com.nanri.aiimage.common.exception.BusinessException;
|
||||||
import com.nanri.aiimage.common.security.ShopCredentialCryptoService;
|
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.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.mapper.AdminUserMapper;
|
||||||
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
|
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.mapper.ShopManageGroupMapper;
|
||||||
import com.nanri.aiimage.modules.shopkey.model.dto.UserGroupRef;
|
import com.nanri.aiimage.modules.shopkey.model.dto.UserGroupRef;
|
||||||
import com.nanri.aiimage.modules.shopkey.model.entity.ShopManageGroupEntity;
|
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.net.URI;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
|
import java.time.LocalDate;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
@@ -73,6 +79,9 @@ public class UserApiSecretService {
|
|||||||
private final AdminUserMapper adminUserMapper;
|
private final AdminUserMapper adminUserMapper;
|
||||||
private final AdminAuthSupport adminAuthSupport;
|
private final AdminAuthSupport adminAuthSupport;
|
||||||
private final ShopManageGroupMapper adminGroupMapper;
|
private final ShopManageGroupMapper adminGroupMapper;
|
||||||
|
private final UserDataScopeSupport userDataScopeSupport;
|
||||||
|
private final NotificationDispatchService notificationDispatchService;
|
||||||
|
private final UserSecretProperties userSecretProperties;
|
||||||
|
|
||||||
/** 当前用户密钥包:仅必填模块(代理为选配,不下发)+ 服务端下发的必填清单 + 完整性判定。 */
|
/** 当前用户密钥包:仅必填模块(代理为选配,不下发)+ 服务端下发的必填清单 + 完整性判定。 */
|
||||||
public UserApiSecretBundleVo bundle(Long userId) {
|
public UserApiSecretBundleVo bundle(Long userId) {
|
||||||
@@ -220,13 +229,13 @@ public class UserApiSecretService {
|
|||||||
scopedUserIds = adminGroupMapper.selectUserIdsByGroupId(requestedGroupId);
|
scopedUserIds = adminGroupMapper.selectUserIdsByGroupId(requestedGroupId);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
scopedUserIds = resolveLedGroupMemberIds(operator.getId());
|
scopedUserIds = userDataScopeSupport.resolveVisibleUserIds(operator.getId());
|
||||||
if (scopedUserIds.isEmpty()) {
|
if (scopedUserIds.isEmpty()) {
|
||||||
log.info("[user-secret] 后台密钥列表:主管名下无子账户,返回空 operatorId={}", operator.getId());
|
log.info("[user-secret] 后台密钥列表:主管名下无子账户,返回空 operatorId={}", operator.getId());
|
||||||
return emptyPageWithGroups(page, pageSize, groupOptions(operator, false));
|
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<>();
|
LambdaQueryWrapper<UserApiSecretEntity> wrapper = new LambdaQueryWrapper<>();
|
||||||
String keyword = normalize(safeQuery.getKeyword());
|
String keyword = normalize(safeQuery.getKeyword());
|
||||||
@@ -289,42 +298,6 @@ public class UserApiSecretService {
|
|||||||
return vo;
|
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 主管带的分组 ID(created_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) {
|
private List<AdminUserSecretPageVo.GroupOptionVo> groupOptions(AdminUserEntity operator, boolean superAdmin) {
|
||||||
List<ShopManageGroupEntity> groups = superAdmin
|
List<ShopManageGroupEntity> groups = superAdmin
|
||||||
@@ -457,6 +430,10 @@ public class UserApiSecretService {
|
|||||||
int failed = 0;
|
int failed = 0;
|
||||||
int errors = 0;
|
int errors = 0;
|
||||||
int skipped = 0;
|
int skipped = 0;
|
||||||
|
int notifySent = 0;
|
||||||
|
// 管理员受众整轮复用:轮内管理员/分组几乎不变,避免每条异常都重新解析
|
||||||
|
NotificationDispatchService.AdminAudience adminAudience =
|
||||||
|
userSecretProperties.isNotifyEnabled() ? notificationDispatchService.prepareAdminAudience() : null;
|
||||||
long lastId = 0L;
|
long lastId = 0L;
|
||||||
while (true) {
|
while (true) {
|
||||||
List<UserApiSecretEntity> batch = userApiSecretMapper.selectList(new LambdaQueryWrapper<UserApiSecretEntity>()
|
List<UserApiSecretEntity> batch = userApiSecretMapper.selectList(new LambdaQueryWrapper<UserApiSecretEntity>()
|
||||||
@@ -478,19 +455,21 @@ public class UserApiSecretService {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
UserApiSecretCheckService.CheckOutcome outcome;
|
||||||
String plainKey = normalize(cryptoService.decrypt(row.getSecretValue()));
|
String plainKey = normalize(cryptoService.decrypt(row.getSecretValue()));
|
||||||
if (plainKey.isEmpty()) {
|
if (plainKey.isEmpty()) {
|
||||||
applyCheckOutcome(row.getUserId(), module.get().key(), new UserApiSecretCheckService.CheckOutcome(
|
outcome = new UserApiSecretCheckService.CheckOutcome(
|
||||||
UserApiSecretCheckService.STATUS_FAILED,
|
UserApiSecretCheckService.STATUS_FAILED,
|
||||||
UserApiSecretCheckService.CODE_INVALID_KEY,
|
UserApiSecretCheckService.CODE_INVALID_KEY,
|
||||||
"配置内容为空,请重新配置", null, false));
|
"配置内容为空,请重新配置", null, false);
|
||||||
failed++;
|
} else {
|
||||||
checked++;
|
outcome = checkService.probe(module.get(), plainKey);
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
UserApiSecretCheckService.CheckOutcome outcome = checkService.probe(module.get(), plainKey);
|
|
||||||
applyCheckOutcome(row.getUserId(), module.get().key(), outcome);
|
applyCheckOutcome(row.getUserId(), module.get().key(), outcome);
|
||||||
checked++;
|
checked++;
|
||||||
|
if (adminAudience != null) {
|
||||||
|
notifySent += notifySecretAlert(adminAudience, row.getUserId(), module.get(), outcome);
|
||||||
|
}
|
||||||
if (UserApiSecretCheckService.STATUS_PASSED.equals(outcome.status())) {
|
if (UserApiSecretCheckService.STATUS_PASSED.equals(outcome.status())) {
|
||||||
passed++;
|
passed++;
|
||||||
} else if (UserApiSecretCheckService.STATUS_FAILED.equals(outcome.status())) {
|
} else if (UserApiSecretCheckService.STATUS_FAILED.equals(outcome.status())) {
|
||||||
@@ -513,11 +492,68 @@ public class UserApiSecretService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
CheckSummary summary = new CheckSummary(checked, passed, failed, errors, skipped);
|
CheckSummary summary = new CheckSummary(checked, passed, failed, errors, skipped);
|
||||||
log.info("[user-secret] 巡检结束 checked={} passed={} failed={} errors={} skipped={}",
|
log.info("[user-secret] 巡检结束 checked={} passed={} failed={} errors={} skipped={} 通知={}",
|
||||||
summary.checked(), summary.passed(), summary.failed(), summary.errors(), summary.skipped());
|
summary.checked(), summary.passed(), summary.failed(), summary.errors(), summary.skipped(), notifySent);
|
||||||
return summary;
|
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) {
|
private AdminUserSecretRowVo buildAdminRow(Long userId, Map<String, UserApiSecretEntity> moduleRows) {
|
||||||
AdminUserSecretRowVo vo = new AdminUserSecretRowVo();
|
AdminUserSecretRowVo vo = new AdminUserSecretRowVo();
|
||||||
vo.setUserId(userId);
|
vo.setUserId(userId);
|
||||||
@@ -661,6 +697,10 @@ public class UserApiSecretService {
|
|||||||
}
|
}
|
||||||
String plain = decryptQuietly(row.getSecretValue());
|
String plain = decryptQuietly(row.getSecretValue());
|
||||||
vo.setMasked(maskValue(module, plain));
|
vo.setMasked(maskValue(module, plain));
|
||||||
|
// 后台代理列要求完整可见(含账号密码),密钥列不下发明文
|
||||||
|
if (module == UserSecretModule.PROXY) {
|
||||||
|
vo.setFull(plain);
|
||||||
|
}
|
||||||
vo.setExists(hasText(plain));
|
vo.setExists(hasText(plain));
|
||||||
vo.setCheckStatus(hasText(row.getCheckStatus()) ? row.getCheckStatus() : STATUS_UNKNOWN);
|
vo.setCheckStatus(hasText(row.getCheckStatus()) ? row.getCheckStatus() : STATUS_UNKNOWN);
|
||||||
vo.setCheckCode(row.getCheckCode());
|
vo.setCheckCode(row.getCheckCode());
|
||||||
|
|||||||
+271
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+79
@@ -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 '用户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'
|
||||||
|
);
|
||||||
+2
-1
@@ -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.assertNull;
|
||||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import com.nanri.aiimage.modules.usersecret.service.UserSecretUsageService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Task 77:统一 LLM、品牌检查和紫鸟 HTTP 客户端的连接复用策略。
|
* Task 77:统一 LLM、品牌检查和紫鸟 HTTP 客户端的连接复用策略。
|
||||||
@@ -98,7 +99,7 @@ class HttpClientConnectionReuseTest {
|
|||||||
void test_task_077_brand_normal_multiple_items() throws Exception {
|
void test_task_077_brand_normal_multiple_items() throws Exception {
|
||||||
// 批量场景:Coze/品牌/紫鸟三个客户端各自持有独立 RestClient,
|
// 批量场景:Coze/品牌/紫鸟三个客户端各自持有独立 RestClient,
|
||||||
// 但底层连接池共用同一 HttpClient 实例,不重复创建。
|
// 但底层连接池共用同一 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);
|
BrandCheckClient brand = new BrandCheckClient(new BrandCheckProperties(), null);
|
||||||
ZiniaoClientImpl ziniao = new ZiniaoClientImpl(new ZiniaoProperties(), new ObjectMapper());
|
ZiniaoClientImpl ziniao = new ZiniaoClientImpl(new ZiniaoProperties(), new ObjectMapper());
|
||||||
|
|
||||||
|
|||||||
+10
-4
@@ -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.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
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 / 品牌检查 / 紫鸟)统一增加耗时、重试、
|
* Task 78:为所有外部调用(LLM / 品牌检查 / 紫鸟)统一增加耗时、重试、
|
||||||
@@ -159,7 +161,8 @@ class ExternalCallMetricsRecorderTest {
|
|||||||
void test_task_078_payload_metrics_normal_default_path() throws Exception {
|
void test_task_078_payload_metrics_normal_default_path() throws Exception {
|
||||||
SimilarAsinProperties props = llmProps();
|
SimilarAsinProperties props = llmProps();
|
||||||
SimilarAsinLlmClient client =
|
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");
|
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 {
|
void test_task_078_payload_metrics_normal_multiple_items() throws Exception {
|
||||||
SimilarAsinProperties props = llmProps();
|
SimilarAsinProperties props = llmProps();
|
||||||
SimilarAsinLlmClient client =
|
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++) {
|
for (int i = 0; i < 3; i++) {
|
||||||
client.invokeChat("test-model", "system", "prompt-" + i, "test-key");
|
client.invokeChat("test-model", "system", "prompt-" + i, "test-key");
|
||||||
@@ -259,7 +263,8 @@ class ExternalCallMetricsRecorderTest {
|
|||||||
for (int i = 0; i < 20; i++) {
|
for (int i = 0; i < 20; i++) {
|
||||||
int index = i;
|
int index = i;
|
||||||
pool.submit(() -> {
|
pool.submit(() -> {
|
||||||
SimilarAsinLlmClient client = new SimilarAsinLlmClient(props, objectMapper, recorder);
|
SimilarAsinLlmClient client = new SimilarAsinLlmClient(props, objectMapper, recorder,
|
||||||
|
mock(UserSecretUsageService.class));
|
||||||
try {
|
try {
|
||||||
client.invokeChat("test-model", "system", "prompt-" + index, "test-key");
|
client.invokeChat("test-model", "system", "prompt-" + index, "test-key");
|
||||||
} catch (Exception ignored) {
|
} catch (Exception ignored) {
|
||||||
@@ -309,7 +314,8 @@ class ExternalCallMetricsRecorderTest {
|
|||||||
ExternalCallMetricsRecorder recorder = new ExternalCallMetricsRecorder(registry);
|
ExternalCallMetricsRecorder recorder = new ExternalCallMetricsRecorder(registry);
|
||||||
SimilarAsinProperties props = llmProps();
|
SimilarAsinProperties props = llmProps();
|
||||||
SimilarAsinLlmClient client =
|
SimilarAsinLlmClient client =
|
||||||
new SimilarAsinLlmClient(props, objectMapper, recorder);
|
new SimilarAsinLlmClient(props, objectMapper, recorder,
|
||||||
|
mock(UserSecretUsageService.class));
|
||||||
|
|
||||||
// 第一次调用走 500 失败路径,第二次调用恢复成功:错误可恢复
|
// 第一次调用走 500 失败路径,第二次调用恢复成功:错误可恢复
|
||||||
llmFailNext.set(true);
|
llmFailNext.set(true);
|
||||||
|
|||||||
+4
-1
@@ -22,6 +22,8 @@ import java.util.concurrent.ConcurrentHashMap;
|
|||||||
import java.util.concurrent.atomic.AtomicInteger;
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import com.nanri.aiimage.modules.usersecret.service.UserSecretUsageService;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
|
||||||
class AppearancePatentLlmClientHttpTest {
|
class AppearancePatentLlmClientHttpTest {
|
||||||
|
|
||||||
@@ -45,7 +47,8 @@ class AppearancePatentLlmClientHttpTest {
|
|||||||
properties,
|
properties,
|
||||||
objectMapper,
|
objectMapper,
|
||||||
null,
|
null,
|
||||||
new BrandCheckClient(new BrandCheckProperties(), null)
|
new BrandCheckClient(new BrandCheckProperties(), null),
|
||||||
|
mock(UserSecretUsageService.class)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+11
-5
@@ -15,6 +15,7 @@ import static org.mockito.Mockito.mock;
|
|||||||
import static org.mockito.Mockito.never;
|
import static org.mockito.Mockito.never;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
import com.nanri.aiimage.modules.usersecret.service.UserSecretUsageService;
|
||||||
|
|
||||||
class AppearancePatentLlmClientTest {
|
class AppearancePatentLlmClientTest {
|
||||||
|
|
||||||
@@ -22,7 +23,8 @@ class AppearancePatentLlmClientTest {
|
|||||||
new AppearancePatentProperties(),
|
new AppearancePatentProperties(),
|
||||||
new ObjectMapper(),
|
new ObjectMapper(),
|
||||||
null,
|
null,
|
||||||
new BrandCheckClient(new BrandCheckProperties(), null)
|
new BrandCheckClient(new BrandCheckProperties(), null),
|
||||||
|
mock(UserSecretUsageService.class)
|
||||||
);
|
);
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -65,7 +67,8 @@ class AppearancePatentLlmClientTest {
|
|||||||
new AppearancePatentProperties(),
|
new AppearancePatentProperties(),
|
||||||
new ObjectMapper(),
|
new ObjectMapper(),
|
||||||
null,
|
null,
|
||||||
brandCheckClient
|
brandCheckClient,
|
||||||
|
mock(UserSecretUsageService.class)
|
||||||
);
|
);
|
||||||
AppearancePatentResultRowDto row = new AppearancePatentResultRowDto();
|
AppearancePatentResultRowDto row = new AppearancePatentResultRowDto();
|
||||||
row.setId("1");
|
row.setId("1");
|
||||||
@@ -90,7 +93,8 @@ class AppearancePatentLlmClientTest {
|
|||||||
new AppearancePatentProperties(),
|
new AppearancePatentProperties(),
|
||||||
new ObjectMapper(),
|
new ObjectMapper(),
|
||||||
null,
|
null,
|
||||||
brandCheckClient
|
brandCheckClient,
|
||||||
|
mock(UserSecretUsageService.class)
|
||||||
);
|
);
|
||||||
AppearancePatentResultRowDto row = new AppearancePatentResultRowDto();
|
AppearancePatentResultRowDto row = new AppearancePatentResultRowDto();
|
||||||
row.setId("1");
|
row.setId("1");
|
||||||
@@ -112,7 +116,8 @@ class AppearancePatentLlmClientTest {
|
|||||||
new AppearancePatentProperties(),
|
new AppearancePatentProperties(),
|
||||||
new ObjectMapper(),
|
new ObjectMapper(),
|
||||||
null,
|
null,
|
||||||
brandCheckClient
|
brandCheckClient,
|
||||||
|
mock(UserSecretUsageService.class)
|
||||||
);
|
);
|
||||||
AppearancePatentResultRowDto row = new AppearancePatentResultRowDto();
|
AppearancePatentResultRowDto row = new AppearancePatentResultRowDto();
|
||||||
row.setId("1");
|
row.setId("1");
|
||||||
@@ -134,7 +139,8 @@ class AppearancePatentLlmClientTest {
|
|||||||
new AppearancePatentProperties(),
|
new AppearancePatentProperties(),
|
||||||
new ObjectMapper(),
|
new ObjectMapper(),
|
||||||
null,
|
null,
|
||||||
brandCheckClient
|
brandCheckClient,
|
||||||
|
mock(UserSecretUsageService.class)
|
||||||
);
|
);
|
||||||
AppearancePatentResultRowDto row = new AppearancePatentResultRowDto();
|
AppearancePatentResultRowDto row = new AppearancePatentResultRowDto();
|
||||||
row.setId("1");
|
row.setId("1");
|
||||||
|
|||||||
+3
-1
@@ -16,6 +16,8 @@ import org.mockito.Mockito;
|
|||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import com.nanri.aiimage.modules.usersecret.service.UserSecretUsageService;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 本地验证入口:用生产真实批次数据 + 生产 LLM 网关跑 SimilarAsinLlmService 完整链路。
|
* 本地验证入口:用生产真实批次数据 + 生产 LLM 网关跑 SimilarAsinLlmService 完整链路。
|
||||||
@@ -60,7 +62,7 @@ public class SimilarAsinLlmLocalVerify {
|
|||||||
props.setLlmRowConcurrency(2);
|
props.setLlmRowConcurrency(2);
|
||||||
props.setLlmImageDownloadTimeoutSeconds(10);
|
props.setLlmImageDownloadTimeoutSeconds(10);
|
||||||
|
|
||||||
SimilarAsinLlmClient client = new SimilarAsinLlmClient(props, objectMapper, null);
|
SimilarAsinLlmClient client = new SimilarAsinLlmClient(props, objectMapper, null, null);
|
||||||
OssProperties ossProps = new OssProperties();
|
OssProperties ossProps = new OssProperties();
|
||||||
ossProps.setEndpoint("https://oss.aishufu.top");
|
ossProps.setEndpoint("https://oss.aishufu.top");
|
||||||
ossProps.setPublicEndpoint("https://oss.aishufu.top");
|
ossProps.setPublicEndpoint("https://oss.aishufu.top");
|
||||||
|
|||||||
+47
-3
@@ -1,9 +1,12 @@
|
|||||||
package com.nanri.aiimage.modules.usersecret.service;
|
package com.nanri.aiimage.modules.usersecret.service;
|
||||||
|
|
||||||
import com.nanri.aiimage.common.security.ShopCredentialCryptoService;
|
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.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.mapper.AdminUserMapper;
|
||||||
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
|
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.dto.UserGroupRef;
|
||||||
import com.nanri.aiimage.modules.shopkey.model.entity.ShopManageGroupEntity;
|
import com.nanri.aiimage.modules.shopkey.model.entity.ShopManageGroupEntity;
|
||||||
import com.nanri.aiimage.modules.usersecret.client.JikipProxyClient;
|
import com.nanri.aiimage.modules.usersecret.client.JikipProxyClient;
|
||||||
@@ -36,6 +39,9 @@ class UserApiSecretServiceTest {
|
|||||||
private final AdminAuthSupport adminAuthSupport = mock(AdminAuthSupport.class);
|
private final AdminAuthSupport adminAuthSupport = mock(AdminAuthSupport.class);
|
||||||
private final com.nanri.aiimage.modules.shopkey.mapper.ShopManageGroupMapper adminGroupMapper =
|
private final com.nanri.aiimage.modules.shopkey.mapper.ShopManageGroupMapper adminGroupMapper =
|
||||||
mock(com.nanri.aiimage.modules.shopkey.mapper.ShopManageGroupMapper.class);
|
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() {
|
private UserApiSecretService newService() {
|
||||||
when(crypto.encrypt(anyString())).thenAnswer(inv -> "enc:" + inv.getArgument(0, String.class));
|
when(crypto.encrypt(anyString())).thenAnswer(inv -> "enc:" + inv.getArgument(0, String.class));
|
||||||
@@ -46,7 +52,8 @@ class UserApiSecretServiceTest {
|
|||||||
// 默认按主管(admin)判定;超管用例里单独改打桩。
|
// 默认按主管(admin)判定;超管用例里单独改打桩。
|
||||||
when(adminAuthSupport.currentRole(any())).thenReturn("admin");
|
when(adminAuthSupport.currentRole(any())).thenReturn("admin");
|
||||||
return new UserApiSecretService(
|
return new UserApiSecretService(
|
||||||
mapper, crypto, checkService, jikipProxyClient, adminUserMapper, adminAuthSupport, adminGroupMapper);
|
mapper, crypto, checkService, jikipProxyClient, adminUserMapper, adminAuthSupport, adminGroupMapper,
|
||||||
|
userDataScopeSupport, notificationDispatchService, userSecretProperties);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -240,8 +247,10 @@ class UserApiSecretServiceTest {
|
|||||||
assertThat(rowVo.getGroups()).containsExactly("一组");
|
assertThat(rowVo.getGroups()).containsExactly("一组");
|
||||||
assertThat(rowVo.getStatus()).isEqualTo("failed");
|
assertThat(rowVo.getStatus()).isEqualTo("failed");
|
||||||
assertThat(rowVo.getSimilarAsin().getMasked()).isEqualTo("sk-s****1234");
|
assertThat(rowVo.getSimilarAsin().getMasked()).isEqualTo("sk-s****1234");
|
||||||
|
assertThat(rowVo.getSimilarAsin().getFull()).isNull();
|
||||||
assertThat(rowVo.getProxy().getExists()).isTrue();
|
assertThat(rowVo.getProxy().getExists()).isTrue();
|
||||||
assertThat(rowVo.getProxy().getMasked()).isEqualTo("http://***@1.2.3.4:8080");
|
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
|
@Test
|
||||||
@@ -268,7 +277,7 @@ class UserApiSecretServiceTest {
|
|||||||
group.setId(5L);
|
group.setId(5L);
|
||||||
group.setGroupName("一组");
|
group.setGroupName("一组");
|
||||||
when(adminGroupMapper.selectLedGroups(88L)).thenReturn(List.of(group));
|
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")));
|
when(mapper.selectList(any())).thenReturn(List.of(row(1L, "similar-asin", "enc:sk-1", "passed")));
|
||||||
AdminUserEntity user = new AdminUserEntity();
|
AdminUserEntity user = new AdminUserEntity();
|
||||||
user.setId(1L);
|
user.setId(1L);
|
||||||
@@ -285,7 +294,42 @@ class UserApiSecretServiceTest {
|
|||||||
assertThat(page.getItems().get(0).getGroups()).containsExactly("一组");
|
assertThat(page.getItems().get(0).getGroups()).containsExactly("一组");
|
||||||
assertThat(page.getGroupOptions()).extracting(com.nanri.aiimage.modules.usersecret.model.vo.AdminUserSecretPageVo.GroupOptionVo::groupName)
|
assertThat(page.getGroupOptions()).extracting(com.nanri.aiimage.modules.usersecret.model.vo.AdminUserSecretPageVo.GroupOptionVo::groupName)
|
||||||
.containsExactly("一组");
|
.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
|
@Test
|
||||||
|
|||||||
+194
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user