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
@@ -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();
@@ -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")
@@ -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;
@@ -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;
@@ -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_quotamessage 同时含"预扣费"与"额度"。
*/
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);
@@ -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());
@@ -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();