feat(密钥管理): 后台分组过滤+欠费状态识别

- 后台密钥列表:主管只看本组子账户(created_by_id=自己),超管全量可按创建人筛选;行 VO 新增 createdById/createdByUsername
- 代理提取接口欠费(message=余额不足)识别为 insufficient_balance,不再静默回退直连
- 上游 LLM 网关欠费(code=insufficient_user_quota/预扣费额度失败)同样归为欠费并透传额度明细
- 前端:桌面设置面板显示"欠费",admin 后台单格药丸显示"欠费"、新增所属管理员列与超管筛选
This commit is contained in:
2026-09-13 13:22:14 +08:00
parent 4bc4969e5a
commit 05ae0c62d6
11 changed files with 278 additions and 34 deletions
@@ -8,6 +8,7 @@ import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/** 检测结果分类矩阵:passed=放行,failed=拦截,error=无法判定(放行但警示)。 */
class UserApiSecretCheckServiceTest {
@@ -47,6 +48,54 @@ class UserApiSecretCheckServiceTest {
assertThat(outcome.message()).contains("quota exceeded");
}
@Test
void classifyInsufficientUserQuotaIsBalanceShortage() {
String body = "{\"code\":\"insufficient_user_quota\",\"message\":\"预扣费额度失败,用户剩余额度:¥0.420000,需要预扣费额度:¥0.510000\"}";
UserApiSecretCheckService.CheckOutcome outcome = service.classify(200, body, 100, false);
assertThat(outcome.status()).isEqualTo(UserApiSecretCheckService.STATUS_FAILED);
assertThat(outcome.code()).isEqualTo(UserApiSecretCheckService.CODE_INSUFFICIENT_BALANCE);
assertThat(outcome.message()).contains("剩余额度:¥0.420000");
}
@Test
void classifyInsufficientUserQuotaOn400AlsoDetected() {
String body = "{\"code\":\"insufficient_user_quota\",\"message\":\"预扣费额度失败,用户剩余额度:¥0.42\"}";
UserApiSecretCheckService.CheckOutcome outcome = service.classify(400, body, 100, true);
assertThat(outcome.status()).isEqualTo(UserApiSecretCheckService.STATUS_FAILED);
assertThat(outcome.code()).isEqualTo(UserApiSecretCheckService.CODE_INSUFFICIENT_BALANCE);
assertThat(outcome.viaProxy()).isTrue();
}
@Test
void probeFailsWithInsufficientBalanceWhenProviderOverdrawn() {
JikipProxyClient jikip = mock(JikipProxyClient.class);
UserApiSecretCheckService probeService = new UserApiSecretCheckService(
new AppearancePatentProperties(), new SimilarAsinProperties(), jikip, new ObjectMapper());
when(jikip.isExtractConfigured()).thenReturn(true);
when(jikip.fetchProxyUrl()).thenThrow(new JikipProxyClient.InsufficientBalanceException());
UserApiSecretCheckService.CheckOutcome outcome = probeService.probe(
com.nanri.aiimage.modules.usersecret.support.UserSecretModule.SIMILAR_ASIN, "sk-test");
assertThat(outcome.status()).isEqualTo(UserApiSecretCheckService.STATUS_FAILED);
assertThat(outcome.code()).isEqualTo(UserApiSecretCheckService.CODE_INSUFFICIENT_BALANCE);
assertThat(outcome.message()).contains("余额不足");
}
@Test
void jikipBalanceDetectionRecognizesOverdrawnPayload() throws Exception {
com.nanri.aiimage.config.UserSecretProperties props = new com.nanri.aiimage.config.UserSecretProperties();
JikipProxyClient client = new JikipProxyClient(props, new ObjectMapper());
java.lang.reflect.Method method = JikipProxyClient.class.getDeclaredMethod("isInsufficientBalance", String.class);
method.setAccessible(true);
assertThat((Boolean) method.invoke(client, "{\"code\":-1,\"data\":null,\"status\":200,\"message\":\"余额不足\"}")).isTrue();
assertThat((Boolean) method.invoke(client, "{\"data\":{\"ip\":\"1.2.3.4\",\"port\":8080}}")).isFalse();
assertThat((Boolean) method.invoke(client, "")).isFalse();
}
@Test
void classifyInvalidKeyOn401() {
UserApiSecretCheckService.CheckOutcome outcome = service.classify(401, "unauthorized", 45, true);
@@ -1,6 +1,7 @@
package com.nanri.aiimage.modules.usersecret.service;
import com.nanri.aiimage.common.security.ShopCredentialCryptoService;
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.usersecret.client.JikipProxyClient;
@@ -30,6 +31,7 @@ class UserApiSecretServiceTest {
private final UserApiSecretCheckService checkService = mock(UserApiSecretCheckService.class);
private final JikipProxyClient jikipProxyClient = mock(JikipProxyClient.class);
private final AdminUserMapper adminUserMapper = mock(AdminUserMapper.class);
private final AdminAuthSupport adminAuthSupport = mock(AdminAuthSupport.class);
private UserApiSecretService newService() {
when(crypto.encrypt(anyString())).thenAnswer(inv -> "enc:" + inv.getArgument(0, String.class));
@@ -37,7 +39,9 @@ class UserApiSecretServiceTest {
String value = inv.getArgument(0, String.class);
return value.startsWith("enc:") ? value.substring(4) : value;
});
return new UserApiSecretService(mapper, crypto, checkService, jikipProxyClient, adminUserMapper);
// 默认按主管(admin)判定;超管用例里单独改打桩。
when(adminAuthSupport.currentRole(any())).thenReturn("admin");
return new UserApiSecretService(mapper, crypto, checkService, jikipProxyClient, adminUserMapper, adminAuthSupport);
}
@Test
@@ -206,6 +210,8 @@ class UserApiSecretServiceTest {
@Test
void adminPageAggregatesOneRowPerUserWithProxyMasked() {
UserApiSecretService service = newService();
// 非限定查询:无 keyword 无组过滤时不触发 users 表圈定查询。
when(adminUserMapper.selectList(any())).thenReturn(List.of());
when(mapper.selectList(any())).thenReturn(List.of(
row(1L, "similar-asin", "enc:sk-sa-1234", "passed"),
row(1L, "appearance-patent", "enc:sk-ap-5678", "passed"),
@@ -214,18 +220,43 @@ class UserApiSecretServiceTest {
user.setId(1L);
user.setUsername("张三");
when(adminUserMapper.selectById(1L)).thenReturn(user);
AdminUserEntity creator = new AdminUserEntity();
creator.setId(99L);
creator.setUsername("主管甲");
when(adminUserMapper.selectById(99L)).thenReturn(creator);
user.setCreatedById(99L);
var page = service.adminPage(new AdminUserSecretQuery());
AdminUserEntity operator = new AdminUserEntity();
operator.setId(88L);
when(adminAuthSupport.currentRole(operator)).thenReturn("super_admin");
var page = service.adminPage(operator, new AdminUserSecretQuery());
assertThat(page.getItems()).hasSize(1);
var rowVo = page.getItems().get(0);
assertThat(rowVo.getUsername()).isEqualTo("张三");
assertThat(rowVo.getCreatedById()).isEqualTo(99L);
assertThat(rowVo.getCreatedByUsername()).isEqualTo("主管甲");
assertThat(rowVo.getStatus()).isEqualTo("failed");
assertThat(rowVo.getSimilarAsin().getMasked()).isEqualTo("sk-s****1234");
assertThat(rowVo.getProxy().getExists()).isTrue();
assertThat(rowVo.getProxy().getMasked()).isEqualTo("http://***@1.2.3.4:8080");
}
@Test
void adminPageLocksAdminToOwnGroupUsers() {
UserApiSecretService service = newService();
// 主管无论传什么 createdById,都强制锁定为本组(created_by_id=88L)。
AdminUserEntity operator = new AdminUserEntity();
operator.setId(88L);
when(adminUserMapper.selectList(any())).thenReturn(List.of());
service.adminPage(operator, new AdminUserSecretQuery());
// 主管模式:先圈定 users 表(组过滤),密钥表因无匹配行不再查询。
verify(adminUserMapper).selectList(any());
verify(mapper, never()).selectList(any());
}
@Test
void adminClearByUserDeletesAllRowsOfUser() {
UserApiSecretService service = newService();