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

- 后台密钥管理「代理设置」列改为完整明文展示(含账号密码),便于运维核对;
  新增 full 字段仅对代理模块下发,密钥两列保持脱敏
- 新增 biz_user_secret_usage_daily(V117):用户 × 模块 × 天累计真实对外请求次数
- 计次口径:LLM 每次真实 HTTP 请求(含重试)计 1 次;代理每次成功提取计 1 次
- LLM 埋点走 SecretUsageContext 上下文(批次外设置、线程池内快照恢复)
- 新增内部上报接口 /api/internal/user-secret-usage(X-Internal-Token)
- 新增后台页「密钥用量统计」:日期范围 + 用户名 + 分组筛选,含范围内汇总
This commit is contained in:
2026-09-13 21:56:46 +08:00
parent 0c98c5bc15
commit a0f6582914
35 changed files with 1545 additions and 76 deletions
@@ -23,6 +23,7 @@ import static org.junit.jupiter.api.Assertions.assertNotSame;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.nanri.aiimage.modules.usersecret.service.UserSecretUsageService;
/**
* Task 77:统一 LLM、品牌检查和紫鸟 HTTP 客户端的连接复用策略。
@@ -98,7 +99,7 @@ class HttpClientConnectionReuseTest {
void test_task_077_brand_normal_multiple_items() throws Exception {
// 批量场景:Coze/品牌/紫鸟三个客户端各自持有独立 RestClient,
// 但底层连接池共用同一 HttpClient 实例,不重复创建。
SimilarAsinLlmClient llm = new SimilarAsinLlmClient(new SimilarAsinProperties(), new ObjectMapper(), null);
SimilarAsinLlmClient llm = new SimilarAsinLlmClient(new SimilarAsinProperties(), new ObjectMapper(), null, null);
BrandCheckClient brand = new BrandCheckClient(new BrandCheckProperties(), null);
ZiniaoClientImpl ziniao = new ZiniaoClientImpl(new ZiniaoProperties(), new ObjectMapper());
@@ -34,6 +34,8 @@ import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.nanri.aiimage.modules.usersecret.service.UserSecretUsageService;
import static org.mockito.Mockito.mock;
/**
* Task 78:为所有外部调用(LLM / 品牌检查 / 紫鸟)统一增加耗时、重试、
@@ -159,7 +161,8 @@ class ExternalCallMetricsRecorderTest {
void test_task_078_payload_metrics_normal_default_path() throws Exception {
SimilarAsinProperties props = llmProps();
SimilarAsinLlmClient client =
new SimilarAsinLlmClient(props, objectMapper, new ExternalCallMetricsRecorder(registry));
new SimilarAsinLlmClient(props, objectMapper, new ExternalCallMetricsRecorder(registry),
mock(UserSecretUsageService.class));
String content = client.invokeChat("test-model", "system", "hello", "test-key");
@@ -177,7 +180,8 @@ class ExternalCallMetricsRecorderTest {
void test_task_078_payload_metrics_normal_multiple_items() throws Exception {
SimilarAsinProperties props = llmProps();
SimilarAsinLlmClient client =
new SimilarAsinLlmClient(props, objectMapper, new ExternalCallMetricsRecorder(registry));
new SimilarAsinLlmClient(props, objectMapper, new ExternalCallMetricsRecorder(registry),
mock(UserSecretUsageService.class));
for (int i = 0; i < 3; i++) {
client.invokeChat("test-model", "system", "prompt-" + i, "test-key");
@@ -259,7 +263,8 @@ class ExternalCallMetricsRecorderTest {
for (int i = 0; i < 20; i++) {
int index = i;
pool.submit(() -> {
SimilarAsinLlmClient client = new SimilarAsinLlmClient(props, objectMapper, recorder);
SimilarAsinLlmClient client = new SimilarAsinLlmClient(props, objectMapper, recorder,
mock(UserSecretUsageService.class));
try {
client.invokeChat("test-model", "system", "prompt-" + index, "test-key");
} catch (Exception ignored) {
@@ -309,7 +314,8 @@ class ExternalCallMetricsRecorderTest {
ExternalCallMetricsRecorder recorder = new ExternalCallMetricsRecorder(registry);
SimilarAsinProperties props = llmProps();
SimilarAsinLlmClient client =
new SimilarAsinLlmClient(props, objectMapper, recorder);
new SimilarAsinLlmClient(props, objectMapper, recorder,
mock(UserSecretUsageService.class));
// 第一次调用走 500 失败路径,第二次调用恢复成功:错误可恢复
llmFailNext.set(true);
@@ -22,6 +22,8 @@ import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
import static org.assertj.core.api.Assertions.assertThat;
import com.nanri.aiimage.modules.usersecret.service.UserSecretUsageService;
import static org.mockito.Mockito.mock;
class AppearancePatentLlmClientHttpTest {
@@ -45,7 +47,8 @@ class AppearancePatentLlmClientHttpTest {
properties,
objectMapper,
null,
new BrandCheckClient(new BrandCheckProperties(), null)
new BrandCheckClient(new BrandCheckProperties(), null),
mock(UserSecretUsageService.class)
);
}
@@ -15,6 +15,7 @@ import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.nanri.aiimage.modules.usersecret.service.UserSecretUsageService;
class AppearancePatentLlmClientTest {
@@ -22,7 +23,8 @@ class AppearancePatentLlmClientTest {
new AppearancePatentProperties(),
new ObjectMapper(),
null,
new BrandCheckClient(new BrandCheckProperties(), null)
new BrandCheckClient(new BrandCheckProperties(), null),
mock(UserSecretUsageService.class)
);
@Test
@@ -65,7 +67,8 @@ class AppearancePatentLlmClientTest {
new AppearancePatentProperties(),
new ObjectMapper(),
null,
brandCheckClient
brandCheckClient,
mock(UserSecretUsageService.class)
);
AppearancePatentResultRowDto row = new AppearancePatentResultRowDto();
row.setId("1");
@@ -90,7 +93,8 @@ class AppearancePatentLlmClientTest {
new AppearancePatentProperties(),
new ObjectMapper(),
null,
brandCheckClient
brandCheckClient,
mock(UserSecretUsageService.class)
);
AppearancePatentResultRowDto row = new AppearancePatentResultRowDto();
row.setId("1");
@@ -112,7 +116,8 @@ class AppearancePatentLlmClientTest {
new AppearancePatentProperties(),
new ObjectMapper(),
null,
brandCheckClient
brandCheckClient,
mock(UserSecretUsageService.class)
);
AppearancePatentResultRowDto row = new AppearancePatentResultRowDto();
row.setId("1");
@@ -134,7 +139,8 @@ class AppearancePatentLlmClientTest {
new AppearancePatentProperties(),
new ObjectMapper(),
null,
brandCheckClient
brandCheckClient,
mock(UserSecretUsageService.class)
);
AppearancePatentResultRowDto row = new AppearancePatentResultRowDto();
row.setId("1");
@@ -16,6 +16,8 @@ import org.mockito.Mockito;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
import com.nanri.aiimage.modules.usersecret.service.UserSecretUsageService;
import static org.mockito.Mockito.mock;
/**
* 本地验证入口:用生产真实批次数据 + 生产 LLM 网关跑 SimilarAsinLlmService 完整链路。
@@ -60,7 +62,7 @@ public class SimilarAsinLlmLocalVerify {
props.setLlmRowConcurrency(2);
props.setLlmImageDownloadTimeoutSeconds(10);
SimilarAsinLlmClient client = new SimilarAsinLlmClient(props, objectMapper, null);
SimilarAsinLlmClient client = new SimilarAsinLlmClient(props, objectMapper, null, null);
OssProperties ossProps = new OssProperties();
ossProps.setEndpoint("https://oss.aishufu.top");
ossProps.setPublicEndpoint("https://oss.aishufu.top");
@@ -1,9 +1,12 @@
package com.nanri.aiimage.modules.usersecret.service;
import com.nanri.aiimage.common.security.ShopCredentialCryptoService;
import com.nanri.aiimage.config.UserSecretProperties;
import com.nanri.aiimage.modules.admin.support.AdminAuthSupport;
import com.nanri.aiimage.modules.notification.service.NotificationDispatchService;
import com.nanri.aiimage.modules.permission.mapper.AdminUserMapper;
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
import com.nanri.aiimage.modules.permission.support.UserDataScopeSupport;
import com.nanri.aiimage.modules.shopkey.model.dto.UserGroupRef;
import com.nanri.aiimage.modules.shopkey.model.entity.ShopManageGroupEntity;
import com.nanri.aiimage.modules.usersecret.client.JikipProxyClient;
@@ -36,6 +39,9 @@ class UserApiSecretServiceTest {
private final AdminAuthSupport adminAuthSupport = mock(AdminAuthSupport.class);
private final com.nanri.aiimage.modules.shopkey.mapper.ShopManageGroupMapper adminGroupMapper =
mock(com.nanri.aiimage.modules.shopkey.mapper.ShopManageGroupMapper.class);
private final UserDataScopeSupport userDataScopeSupport = mock(UserDataScopeSupport.class);
private final NotificationDispatchService notificationDispatchService = mock(NotificationDispatchService.class);
private final UserSecretProperties userSecretProperties = new UserSecretProperties();
private UserApiSecretService newService() {
when(crypto.encrypt(anyString())).thenAnswer(inv -> "enc:" + inv.getArgument(0, String.class));
@@ -46,7 +52,8 @@ class UserApiSecretServiceTest {
// 默认按主管(admin)判定;超管用例里单独改打桩。
when(adminAuthSupport.currentRole(any())).thenReturn("admin");
return new UserApiSecretService(
mapper, crypto, checkService, jikipProxyClient, adminUserMapper, adminAuthSupport, adminGroupMapper);
mapper, crypto, checkService, jikipProxyClient, adminUserMapper, adminAuthSupport, adminGroupMapper,
userDataScopeSupport, notificationDispatchService, userSecretProperties);
}
@Test
@@ -240,8 +247,10 @@ class UserApiSecretServiceTest {
assertThat(rowVo.getGroups()).containsExactly("一组");
assertThat(rowVo.getStatus()).isEqualTo("failed");
assertThat(rowVo.getSimilarAsin().getMasked()).isEqualTo("sk-s****1234");
assertThat(rowVo.getSimilarAsin().getFull()).isNull();
assertThat(rowVo.getProxy().getExists()).isTrue();
assertThat(rowVo.getProxy().getMasked()).isEqualTo("http://***@1.2.3.4:8080");
assertThat(rowVo.getProxy().getFull()).isEqualTo("http://user:pass@1.2.3.4:8080");
}
@Test
@@ -268,7 +277,7 @@ class UserApiSecretServiceTest {
group.setId(5L);
group.setGroupName("一组");
when(adminGroupMapper.selectLedGroups(88L)).thenReturn(List.of(group));
when(adminGroupMapper.selectUserIdsByGroupId(5L)).thenReturn(List.of(1L, 88L));
when(userDataScopeSupport.resolveVisibleUserIds(88L)).thenReturn(List.of(1L, 88L));
when(mapper.selectList(any())).thenReturn(List.of(row(1L, "similar-asin", "enc:sk-1", "passed")));
AdminUserEntity user = new AdminUserEntity();
user.setId(1L);
@@ -285,7 +294,42 @@ class UserApiSecretServiceTest {
assertThat(page.getItems().get(0).getGroups()).containsExactly("一组");
assertThat(page.getGroupOptions()).extracting(com.nanri.aiimage.modules.usersecret.model.vo.AdminUserSecretPageVo.GroupOptionVo::groupName)
.containsExactly("一组");
verify(adminGroupMapper).selectUserIdsByGroupId(5L);
verify(userDataScopeSupport).resolveVisibleUserIds(88L);
}
@Test
void decideSecretAlertMapsBalanceAndInvalidKeyOnly() {
// 欠费 → error 级 secret_balance
var balance = UserApiSecretService.decideSecretAlert(new UserApiSecretCheckService.CheckOutcome(
UserApiSecretCheckService.STATUS_FAILED,
UserApiSecretCheckService.CODE_INSUFFICIENT_BALANCE,
"余额不足", 120, false));
assertThat(balance).isNotNull();
assertThat(balance.scene()).isEqualTo("secret_balance");
assertThat(balance.level()).isEqualTo("error");
// 密钥无效 / 被拒 → warning 级 secret_invalid
for (String code : List.of(UserApiSecretCheckService.CODE_INVALID_KEY, UserApiSecretCheckService.CODE_FORBIDDEN)) {
var invalid = UserApiSecretService.decideSecretAlert(new UserApiSecretCheckService.CheckOutcome(
UserApiSecretCheckService.STATUS_FAILED, code, "失败", 100, false));
assertThat(invalid).isNotNull();
assertThat(invalid.scene()).isEqualTo("secret_invalid");
assertThat(invalid.level()).isEqualTo("warning");
}
// 网络抖动 / 限流 / 上游异常 → 不通知
for (String code : List.of(UserApiSecretCheckService.CODE_NETWORK_ERROR,
UserApiSecretCheckService.CODE_RATE_LIMITED, UserApiSecretCheckService.CODE_SERVER_ERROR)) {
assertThat(UserApiSecretService.decideSecretAlert(new UserApiSecretCheckService.CheckOutcome(
UserApiSecretCheckService.STATUS_ERROR, code, "抖动", 90, false))).isNull();
assertThat(UserApiSecretService.decideSecretAlert(new UserApiSecretCheckService.CheckOutcome(
UserApiSecretCheckService.STATUS_FAILED, code, "抖动", 90, false))).isNull();
}
// 通过 / 空 outcome → 不通知
assertThat(UserApiSecretService.decideSecretAlert(new UserApiSecretCheckService.CheckOutcome(
UserApiSecretCheckService.STATUS_PASSED, UserApiSecretCheckService.CODE_OK, "正常", 50, false))).isNull();
assertThat(UserApiSecretService.decideSecretAlert(null)).isNull();
}
@Test
@@ -0,0 +1,194 @@
package com.nanri.aiimage.modules.usersecret.service;
import com.nanri.aiimage.modules.admin.support.AdminAuthSupport;
import com.nanri.aiimage.modules.permission.mapper.AdminUserMapper;
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
import com.nanri.aiimage.modules.permission.support.UserDataScopeSupport;
import com.nanri.aiimage.modules.shopkey.mapper.ShopManageGroupMapper;
import com.nanri.aiimage.modules.shopkey.model.dto.UserGroupRef;
import com.nanri.aiimage.modules.usersecret.mapper.UserSecretUsageMapper;
import com.nanri.aiimage.modules.usersecret.model.dto.AdminUserSecretUsageQuery;
import com.nanri.aiimage.modules.usersecret.model.entity.UserSecretUsageEntity;
import org.junit.jupiter.api.Test;
import java.time.LocalDate;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class UserSecretUsageServiceTest {
private final UserSecretUsageMapper usageMapper = mock(UserSecretUsageMapper.class);
private final AdminUserMapper adminUserMapper = mock(AdminUserMapper.class);
private final AdminAuthSupport adminAuthSupport = mock(AdminAuthSupport.class);
private final ShopManageGroupMapper adminGroupMapper = mock(ShopManageGroupMapper.class);
private final UserDataScopeSupport userDataScopeSupport = mock(UserDataScopeSupport.class);
private UserSecretUsageService newService() {
// 默认按主管(admin)判定;超管用例里单独改打桩。
when(adminAuthSupport.currentRole(any())).thenReturn("admin");
return new UserSecretUsageService(
usageMapper, adminUserMapper, adminAuthSupport, adminGroupMapper, userDataScopeSupport);
}
private UserSecretUsageEntity usageRow(Long userId, String moduleKey, int count) {
UserSecretUsageEntity row = new UserSecretUsageEntity();
row.setUserId(userId);
row.setModuleKey(moduleKey);
row.setBusinessDate(LocalDate.now());
row.setCallCount(count);
return row;
}
@Test
void recordIncrementsByModuleWithTodayDate() {
UserSecretUsageService service = newService();
when(usageMapper.increment(any(), any(), any(), eq(1))).thenReturn(1);
service.record(7L, "similar-asin", 1);
verify(usageMapper).increment(eq(7L), eq("similar-asin"), eq(LocalDate.now()), eq(1));
}
@Test
void recordSkipsInvalidUserOrUnknownModule() {
UserSecretUsageService service = newService();
service.record(null, "similar-asin", 1);
service.record(0L, "similar-asin", 1);
service.record(7L, "not-a-module", 1);
service.record(7L, "similar-asin", 0);
verify(usageMapper, never()).increment(any(), any(), any(), any(Integer.class));
}
@Test
void recordSwallowsMapperFailure() {
UserSecretUsageService service = newService();
when(usageMapper.increment(any(), any(), any(), eq(1)))
.thenThrow(new IllegalStateException("db down"));
// 计次失败绝不影响任务主流程
service.record(7L, "proxy", 1);
verify(usageMapper).increment(eq(7L), eq("proxy"), eq(LocalDate.now()), eq(1));
}
@Test
void recordReportedCapsCountAndRejectsUnknownModule() {
UserSecretUsageService service = newService();
when(usageMapper.increment(any(), any(), any(), any(Integer.class))).thenReturn(1);
assertThat(service.recordReported(7L, "proxy", 50_000)).isTrue();
assertThat(service.recordReported(7L, "not-a-module", 3)).isFalse();
assertThat(service.recordReported(7L, "proxy", 0)).isFalse();
// 超出上限按 10000 截断,防止异常客户端写爆计数
verify(usageMapper).increment(eq(7L), eq("proxy"), eq(LocalDate.now()), eq(10_000));
}
@Test
void adminPageAggregatesByUserAndSortsByTotal() {
UserSecretUsageService service = newService();
when(adminAuthSupport.currentRole(any())).thenReturn("super_admin");
when(usageMapper.selectList(any())).thenReturn(List.of(
usageRow(1L, "similar-asin", 10),
usageRow(1L, "proxy", 2),
usageRow(2L, "appearance-patent", 7)));
AdminUserEntity user1 = new AdminUserEntity();
user1.setId(1L);
user1.setUsername("张三");
AdminUserEntity user2 = new AdminUserEntity();
user2.setId(2L);
user2.setUsername("李四");
when(adminUserMapper.selectBatchIds(any())).thenReturn(List.of(user1, user2));
UserGroupRef ref = new UserGroupRef();
ref.setUserId(1L);
ref.setGroupName("一组");
when(adminGroupMapper.selectGroupNamesByUserIds(any())).thenReturn(List.of(ref));
var page = service.adminPage(new AdminUserEntity(), new AdminUserSecretUsageQuery());
assertThat(page.getTotal()).isEqualTo(2);
assertThat(page.getItems()).hasSize(2);
// 合计倒序:张三 12 > 李四 7
var first = page.getItems().get(0);
assertThat(first.getUserId()).isEqualTo(1L);
assertThat(first.getUsername()).isEqualTo("张三");
assertThat(first.getGroups()).containsExactly("一组");
assertThat(first.getSimilarAsinCount()).isEqualTo(10);
assertThat(first.getProxyCount()).isEqualTo(2);
assertThat(first.getTotalCount()).isEqualTo(12);
var second = page.getItems().get(1);
assertThat(second.getAppearancePatentCount()).isEqualTo(7);
assertThat(second.getTotalCount()).isEqualTo(7);
// 汇总覆盖全量(不分页)
assertThat(page.getSimilarAsinCalls()).isEqualTo(10);
assertThat(page.getAppearancePatentCalls()).isEqualTo(7);
assertThat(page.getProxyCalls()).isEqualTo(2);
assertThat(page.getTotalCalls()).isEqualTo(19);
}
@Test
void adminPageReturnsEmptyWhenLeaderHasNoVisibleUsers() {
UserSecretUsageService service = newService();
AdminUserEntity operator = new AdminUserEntity();
operator.setId(88L);
when(userDataScopeSupport.resolveVisibleUserIds(88L)).thenReturn(List.of());
when(adminGroupMapper.selectLedGroups(88L)).thenReturn(List.of());
var page = service.adminPage(operator, new AdminUserSecretUsageQuery());
assertThat(page.getItems()).isEmpty();
assertThat(page.getTotal()).isZero();
assertThat(page.getTotalCalls()).isZero();
// 无可见用户时不应触发用量查询
verify(usageMapper, never()).selectList(any());
}
@Test
void adminPageFiltersByGroupForSuperAdmin() {
UserSecretUsageService service = newService();
when(adminAuthSupport.currentRole(any())).thenReturn("super_admin");
when(adminGroupMapper.selectUserIdsByGroupId(5L)).thenReturn(List.of(2L));
when(usageMapper.selectList(any())).thenReturn(List.of(usageRow(2L, "proxy", 3)));
AdminUserEntity user2 = new AdminUserEntity();
user2.setId(2L);
user2.setUsername("李四");
when(adminUserMapper.selectBatchIds(any())).thenReturn(List.of(user2));
when(adminGroupMapper.selectGroupNamesByUserIds(any())).thenReturn(List.of());
AdminUserSecretUsageQuery query = new AdminUserSecretUsageQuery();
query.setGroupId(5L);
var page = service.adminPage(new AdminUserEntity(), query);
assertThat(page.getItems()).hasSize(1);
assertThat(page.getItems().get(0).getProxyCount()).isEqualTo(3);
assertThat(page.getProxyCalls()).isEqualTo(3);
// 空日期范围不产生额外过滤条件(由 mapper 兜底)
verify(usageMapper).selectList(any());
verify(adminGroupMapper).selectUserIdsByGroupId(eq(5L));
}
@Test
void adminPageReturnsEmptyWhenKeywordMatchesNobody() {
UserSecretUsageService service = newService();
when(adminAuthSupport.currentRole(any())).thenReturn("super_admin");
when(adminUserMapper.selectList(any())).thenReturn(List.of());
AdminUserSecretUsageQuery query = new AdminUserSecretUsageQuery();
query.setKeyword("不存在的人");
var page = service.adminPage(new AdminUserEntity(), query);
assertThat(page.getItems()).isEmpty();
verify(usageMapper, never()).selectList(any());
}
}