feat(密钥管理): 后台分组过滤+欠费状态识别
- 后台密钥列表:主管只看本组子账户(created_by_id=自己),超管全量可按创建人筛选;行 VO 新增 createdById/createdByUsername - 代理提取接口欠费(message=余额不足)识别为 insufficient_balance,不再静默回退直连 - 上游 LLM 网关欠费(code=insufficient_user_quota/预扣费额度失败)同样归为欠费并透传额度明细 - 前端:桌面设置面板显示"欠费",admin 后台单格药丸显示"欠费"、新增所属管理员列与超管筛选
This commit is contained in:
+37
@@ -44,6 +44,7 @@ public class JikipProxyClient {
|
||||
|
||||
/**
|
||||
* 从提取链接取一个代理地址(http://ip:port);未配置或失败返回 null,由调用方回退直连。
|
||||
* 供应商欠费(message 含"余额不足")抛 InsufficientBalanceException 供调用方给出明确提示。
|
||||
*/
|
||||
public String fetchProxyUrl() {
|
||||
String extractUrl = normalize(properties.getCheckProxyExtractUrl());
|
||||
@@ -57,17 +58,53 @@ public class JikipProxyClient {
|
||||
.body(String.class);
|
||||
String proxyUrl = parseProxyUrl(body);
|
||||
if (proxyUrl == null) {
|
||||
if (isInsufficientBalance(body)) {
|
||||
log.warn("[user-secret][proxy] 代理提取接口欠费,响应前 200 字={}", abbreviate(body, 200));
|
||||
throw new InsufficientBalanceException();
|
||||
}
|
||||
log.warn("[user-secret][proxy] 提取接口返回中未找到 ip:port,响应前 200 字={}", abbreviate(body, 200));
|
||||
return null;
|
||||
}
|
||||
log.info("[user-secret][proxy] 代理提取成功 proxy={}", proxyUrl);
|
||||
return proxyUrl;
|
||||
} catch (InsufficientBalanceException ex) {
|
||||
throw ex;
|
||||
} catch (Exception ex) {
|
||||
log.warn("[user-secret][proxy] 代理提取失败 err={}", ex.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 提取接口响应指明欠费(如 {"code":-1,"message":"余额不足"})。 */
|
||||
private boolean isInsufficientBalance(String body) {
|
||||
String text = normalize(body);
|
||||
if (text.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
// 先看 JSON 的 message/code 字段,再看任意文本兜底(防字段改名)。
|
||||
try {
|
||||
JsonNode root = objectMapper.readTree(text);
|
||||
String message = text(root.get("message"));
|
||||
if (message != null && message.contains("余额不足")) {
|
||||
return true;
|
||||
}
|
||||
Double code = root.get("code") == null ? null : root.get("code").asDouble(Double.NaN);
|
||||
if (code != null && !code.isNaN() && code == -1) {
|
||||
return true;
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// 非 JSON 走文本兜底
|
||||
}
|
||||
return text.contains("余额不足");
|
||||
}
|
||||
|
||||
/** 代理服务供应商欠费。 */
|
||||
public static class InsufficientBalanceException extends RuntimeException {
|
||||
public InsufficientBalanceException() {
|
||||
super("代理服务余额不足");
|
||||
}
|
||||
}
|
||||
|
||||
/** 余量查询:GET {balanceUrl}?id={planId}&userId={userId},返回 surplus/balance。 */
|
||||
public UserApiSecretBalanceVo fetchBalance() {
|
||||
UserApiSecretBalanceVo vo = new UserApiSecretBalanceVo();
|
||||
|
||||
+6
-3
@@ -3,6 +3,7 @@ package com.nanri.aiimage.modules.usersecret.controller;
|
||||
import com.nanri.aiimage.common.api.ApiResponse;
|
||||
import com.nanri.aiimage.config.UserSecretProperties;
|
||||
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.AdminUserSecretQuery;
|
||||
import com.nanri.aiimage.modules.usersecret.model.vo.AdminUserSecretPageVo;
|
||||
import com.nanri.aiimage.modules.usersecret.model.vo.UserApiSecretCheckResultVo;
|
||||
@@ -38,20 +39,22 @@ public class AdminUserApiSecretController {
|
||||
private final AdminAuthSupport adminAuthSupport;
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "分页查询用户密钥", description = "一行一用户;keyword 匹配用户名,checkStatus 按行级状态筛选。")
|
||||
@Operation(summary = "分页查询用户密钥", description = "一行一用户;keyword 匹配用户名,checkStatus 按行级状态筛选。主管只能看自己名下子账户,超管看全量并可按创建人筛选。")
|
||||
public ApiResponse<AdminUserSecretPageVo> page(
|
||||
HttpServletRequest request,
|
||||
@Parameter(description = "关键字:用户名") @RequestParam(required = false) String keyword,
|
||||
@Parameter(description = "行级状态筛选:passed/failed/incomplete/error/unknown") @RequestParam(required = false) String checkStatus,
|
||||
@Parameter(description = "按创建人筛选(仅超管生效)") @RequestParam(name = "created_by_id", required = false) Long createdById,
|
||||
@Parameter(description = "页码") @RequestParam(defaultValue = "1") Long page,
|
||||
@Parameter(description = "每页数量") @RequestParam(defaultValue = "15") Long pageSize) {
|
||||
adminAuthSupport.requireAdmin(request);
|
||||
AdminUserEntity operator = adminAuthSupport.requireAdmin(request);
|
||||
AdminUserSecretQuery query = new AdminUserSecretQuery();
|
||||
query.setKeyword(keyword);
|
||||
query.setCheckStatus(checkStatus);
|
||||
query.setCreatedById(createdById);
|
||||
query.setPage(page);
|
||||
query.setPageSize(pageSize);
|
||||
return ApiResponse.success(userApiSecretService.adminPage(query));
|
||||
return ApiResponse.success(userApiSecretService.adminPage(operator, query));
|
||||
}
|
||||
|
||||
@PostMapping("/{userId}/check")
|
||||
|
||||
+3
@@ -13,6 +13,9 @@ public class AdminUserSecretQuery {
|
||||
@Schema(description = "行级状态筛选:passed/failed/incomplete/error/unknown")
|
||||
private String checkStatus;
|
||||
|
||||
@Schema(description = "按创建人筛选(仅超管生效;主管强制为本组)")
|
||||
private Long createdById;
|
||||
|
||||
@Schema(description = "页码,从 1 开始")
|
||||
private Long page = 1L;
|
||||
|
||||
|
||||
+6
@@ -15,6 +15,12 @@ public class AdminUserSecretRowVo {
|
||||
@Schema(description = "用户名")
|
||||
private String username;
|
||||
|
||||
@Schema(description = "所属管理员(创建人)ID")
|
||||
private Long createdById;
|
||||
|
||||
@Schema(description = "所属管理员用户名")
|
||||
private String createdByUsername;
|
||||
|
||||
@Schema(description = "货源查询密钥")
|
||||
private AdminUserSecretModuleVo similarAsin;
|
||||
|
||||
|
||||
+47
-1
@@ -45,6 +45,10 @@ public class UserApiSecretCheckService {
|
||||
public static final String CODE_SERVER_ERROR = "server_error";
|
||||
public static final String CODE_NETWORK_ERROR = "network_error";
|
||||
public static final String CODE_PROVIDER_ERROR = "provider_error";
|
||||
public static final String CODE_INSUFFICIENT_BALANCE = "insufficient_balance";
|
||||
|
||||
/** 供应商欠费提示文案(前后端都按 code 识别展示)。 */
|
||||
public static final String INSUFFICIENT_BALANCE_MESSAGE = "代理服务商余额不足,请充值后重试";
|
||||
|
||||
private static final MediaType APPLICATION_JSON_UTF8 = new MediaType("application", "json", StandardCharsets.UTF_8);
|
||||
private static final int READ_TIMEOUT_MILLIS = 15_000;
|
||||
@@ -66,7 +70,13 @@ public class UserApiSecretCheckService {
|
||||
if (module == UserSecretModule.PROXY) {
|
||||
return probeProxy(plainApiKey);
|
||||
}
|
||||
String proxyUrl = jikipProxyClient.isExtractConfigured() ? jikipProxyClient.fetchProxyUrl() : null;
|
||||
String proxyUrl;
|
||||
try {
|
||||
proxyUrl = jikipProxyClient.isExtractConfigured() ? jikipProxyClient.fetchProxyUrl() : null;
|
||||
} catch (JikipProxyClient.InsufficientBalanceException ex) {
|
||||
log.warn("[user-secret][check] 代理提取接口欠费 module={},按余额不足处理", module.key());
|
||||
return new CheckOutcome(STATUS_FAILED, CODE_INSUFFICIENT_BALANCE, INSUFFICIENT_BALANCE_MESSAGE, null, false);
|
||||
}
|
||||
if (proxyUrl != null) {
|
||||
CheckOutcome viaProxy = probeOnce(module, plainApiKey, proxyUrl, true);
|
||||
if (CODE_NETWORK_ERROR.equals(viaProxy.code())) {
|
||||
@@ -155,6 +165,12 @@ public class UserApiSecretCheckService {
|
||||
/** 按 HTTP 状态码与响应体分类检测结果(package-private 供单测覆盖分类矩阵)。 */
|
||||
CheckOutcome classify(int statusCode, String body, int latencyMs, boolean viaProxy) {
|
||||
String responseBody = body == null ? "" : body;
|
||||
// 上游网关欠费(任意 HTTP 状态):形如 {"code":"insufficient_user_quota","message":"预扣费额度失败,用户剩余额度:¥0.42,需要预扣费额度:¥0.51"}
|
||||
String quotaMessage = extractInsufficientQuotaMessage(statusCode, responseBody);
|
||||
if (quotaMessage != null) {
|
||||
return new CheckOutcome(STATUS_FAILED, CODE_INSUFFICIENT_BALANCE,
|
||||
"预扣费额度失败:" + quotaMessage, latencyMs, viaProxy);
|
||||
}
|
||||
if (statusCode >= 200 && statusCode < 300) {
|
||||
JsonNode root = parseJson(body);
|
||||
if (root != null) {
|
||||
@@ -187,6 +203,36 @@ public class UserApiSecretCheckService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 识别上游欠费报文并提取 message(含剩余/需要的额度明细)。
|
||||
* 匹配任一条件:code=insufficient_user_quota;message 同时含"预扣费"与"额度"。
|
||||
*/
|
||||
private String extractInsufficientQuotaMessage(int statusCode, String body) {
|
||||
String normalized = body == null ? "" : body.trim();
|
||||
if (normalized.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
if (bodyContainsQuotaSignal(normalized)) {
|
||||
JsonNode root = parseJson(normalized);
|
||||
if (root != null) {
|
||||
String message = text(root.get("message"));
|
||||
if (message != null && !message.isBlank()) {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
return abbreviate(normalized, 200);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private boolean bodyContainsQuotaSignal(String body) {
|
||||
// 关键字直查(code 与 message 字段都可能变名,双信号兜底)。
|
||||
if (body.contains("insufficient_user_quota")) {
|
||||
return true;
|
||||
}
|
||||
return body.contains("预扣费") && body.contains("额度");
|
||||
}
|
||||
|
||||
private Map<String, Object> buildCheckBody(String model) {
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("model", model);
|
||||
|
||||
+39
-22
@@ -3,10 +3,10 @@ package com.nanri.aiimage.modules.usersecret.service;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.common.security.ShopCredentialCryptoService;
|
||||
import com.nanri.aiimage.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;
|
||||
import com.nanri.aiimage.modules.usersecret.mapper.UserApiSecretMapper;
|
||||
import com.nanri.aiimage.modules.usersecret.client.JikipProxyClient;import com.nanri.aiimage.modules.usersecret.mapper.UserApiSecretMapper;
|
||||
import com.nanri.aiimage.modules.usersecret.model.dto.AdminUserSecretQuery;
|
||||
import com.nanri.aiimage.modules.usersecret.model.dto.UserApiSecretMigrateRequest;
|
||||
import com.nanri.aiimage.modules.usersecret.model.entity.UserApiSecretEntity;
|
||||
@@ -68,6 +68,7 @@ public class UserApiSecretService {
|
||||
private final UserApiSecretCheckService checkService;
|
||||
private final JikipProxyClient jikipProxyClient;
|
||||
private final AdminUserMapper adminUserMapper;
|
||||
private final AdminAuthSupport adminAuthSupport;
|
||||
|
||||
/** 当前用户密钥包:仅必填模块(代理为选配,不下发)+ 服务端下发的必填清单 + 完整性判定。 */
|
||||
public UserApiSecretBundleVo bundle(Long userId) {
|
||||
@@ -199,20 +200,45 @@ public class UserApiSecretService {
|
||||
* 先按关键字圈定用户,再全量聚合、行级状态筛选、按最近更新时间倒序后内存分页
|
||||
* (当前规模为用户数×3,内存聚合成本可控;量级上来后可改为 GROUP BY 下推)。
|
||||
*/
|
||||
public AdminUserSecretPageVo adminPage(AdminUserSecretQuery query) {
|
||||
/** 后台分页查询:主管(admin)只能看自己名下子账户(created_by_id=自己),超管看全量可按创建人筛选。 */
|
||||
public AdminUserSecretPageVo adminPage(AdminUserEntity operator, AdminUserSecretQuery query) {
|
||||
AdminUserSecretQuery safeQuery = query == null ? new AdminUserSecretQuery() : 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);
|
||||
|
||||
// 组隔离:主管强制锁定本组;超管可按 created_by_id 筛选(0 或空 = 全部)。
|
||||
boolean superAdmin = "super_admin".equals(adminAuthSupport.currentRole(operator));
|
||||
Long scopedCreatedById;
|
||||
if (superAdmin) {
|
||||
Long filter = safeQuery.getCreatedById();
|
||||
scopedCreatedById = filter != null && filter > 0 ? filter : null;
|
||||
} else {
|
||||
scopedCreatedById = operator.getId();
|
||||
}
|
||||
|
||||
LambdaQueryWrapper<UserApiSecretEntity> wrapper = new LambdaQueryWrapper<>();
|
||||
String keyword = normalize(safeQuery.getKeyword());
|
||||
List<Long> allowedUserIds = null;
|
||||
// 用户存在性/归属先按 users 表圈定:keyword 匹配 + 组隔离前置过滤(无密钥记录的用户本来就不在聚合表里)。
|
||||
LambdaQueryWrapper<AdminUserEntity> userWrapper = new LambdaQueryWrapper<>();
|
||||
if (!keyword.isEmpty()) {
|
||||
List<Long> userIds = resolveUserIdsByKeyword(keyword);
|
||||
if (userIds.isEmpty()) {
|
||||
userWrapper.like(AdminUserEntity::getUsername, keyword);
|
||||
}
|
||||
if (scopedCreatedById != null) {
|
||||
userWrapper.eq(AdminUserEntity::getCreatedById, scopedCreatedById);
|
||||
}
|
||||
if (!keyword.isEmpty() || scopedCreatedById != null) {
|
||||
allowedUserIds = adminUserMapper.selectList(userWrapper).stream()
|
||||
.map(AdminUserEntity::getId)
|
||||
.filter(id -> id != null)
|
||||
.toList();
|
||||
if (allowedUserIds.isEmpty()) {
|
||||
log.info("[user-secret] 后台密钥列表无匹配用户 keyword={} createdById={} operatorId={} role={}",
|
||||
keyword, scopedCreatedById, operator.getId(), superAdmin ? "super_admin" : "admin");
|
||||
return emptyPage(page, pageSize);
|
||||
}
|
||||
wrapper.in(UserApiSecretEntity::getUserId, userIds);
|
||||
wrapper.in(UserApiSecretEntity::getUserId, allowedUserIds);
|
||||
}
|
||||
List<UserApiSecretEntity> rows = userApiSecretMapper.selectList(wrapper);
|
||||
|
||||
@@ -244,8 +270,8 @@ public class UserApiSecretService {
|
||||
vo.setTotal(total);
|
||||
vo.setPage(page);
|
||||
vo.setPageSize(pageSize);
|
||||
log.info("[user-secret] 后台密钥列表 keyword={} statusFilter={} 聚合用户数={} 本页返回={}",
|
||||
keyword, statusFilter, total, vo.getItems().size());
|
||||
log.info("[user-secret] 后台密钥列表 keyword={} statusFilter={} createdById={} role={} 聚合用户数={} 本页返回={}",
|
||||
keyword, statusFilter, scopedCreatedById, superAdmin ? "super_admin" : "admin", total, vo.getItems().size());
|
||||
return vo;
|
||||
}
|
||||
|
||||
@@ -386,6 +412,11 @@ public class UserApiSecretService {
|
||||
vo.setUpdatedAt(latestUpdatedAt(modules));
|
||||
AdminUserEntity user = adminUserMapper.selectById(userId);
|
||||
vo.setUsername(user == null ? "" : user.getUsername());
|
||||
if (user != null && user.getCreatedById() != null) {
|
||||
vo.setCreatedById(user.getCreatedById());
|
||||
AdminUserEntity creator = adminUserMapper.selectById(user.getCreatedById());
|
||||
vo.setCreatedByUsername(creator == null ? "" : creator.getUsername());
|
||||
}
|
||||
return vo;
|
||||
}
|
||||
|
||||
@@ -496,20 +527,6 @@ public class UserApiSecretService {
|
||||
.last("limit 1"));
|
||||
}
|
||||
|
||||
/** 关键字圈定用户:仅按用户名模糊匹配(页面不提供 UID 搜索)。 */
|
||||
private List<Long> resolveUserIdsByKeyword(String keyword) {
|
||||
Set<Long> userIds = new LinkedHashSet<>();
|
||||
List<AdminUserEntity> matched = adminUserMapper.selectList(new LambdaQueryWrapper<AdminUserEntity>()
|
||||
.like(AdminUserEntity::getUsername, keyword)
|
||||
.last("limit 200"));
|
||||
for (AdminUserEntity user : matched) {
|
||||
if (user.getId() != null) {
|
||||
userIds.add(user.getId());
|
||||
}
|
||||
}
|
||||
return new ArrayList<>(userIds);
|
||||
}
|
||||
|
||||
private AdminUserSecretModuleVo toModuleVo(UserSecretModule module, UserApiSecretEntity row) {
|
||||
AdminUserSecretModuleVo vo = new AdminUserSecretModuleVo();
|
||||
vo.setModuleKey(module.key());
|
||||
|
||||
+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