feat(密钥管理): 后台分组过滤+欠费状态识别
- 后台密钥列表:主管只看本组子账户(created_by_id=自己),超管全量可按创建人筛选;行 VO 新增 createdById/createdByUsername - 代理提取接口欠费(message=余额不足)识别为 insufficient_balance,不再静默回退直连 - 上游 LLM 网关欠费(code=insufficient_user_quota/预扣费额度失败)同样归为欠费并透传额度明细 - 前端:桌面设置面板显示"欠费",admin 后台单格药丸显示"欠费"、新增所属管理员列与超管筛选
This commit is contained in:
+49
@@ -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);
|
||||
|
||||
+33
-2
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user