From 05ae0c62d63e1044a1e1bb4518ddc09ac8352a5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E8=87=AA=E8=BE=BE?= <980324341@qq.com> Date: Sun, 13 Sep 2026 13:22:14 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E5=AF=86=E9=92=A5=E7=AE=A1=E7=90=86):=20?= =?UTF-8?q?=E5=90=8E=E5=8F=B0=E5=88=86=E7=BB=84=E8=BF=87=E6=BB=A4+?= =?UTF-8?q?=E6=AC=A0=E8=B4=B9=E7=8A=B6=E6=80=81=E8=AF=86=E5=88=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后台密钥列表:主管只看本组子账户(created_by_id=自己),超管全量可按创建人筛选;行 VO 新增 createdById/createdByUsername - 代理提取接口欠费(message=余额不足)识别为 insufficient_balance,不再静默回退直连 - 上游 LLM 网关欠费(code=insufficient_user_quota/预扣费额度失败)同样归为欠费并透传额度明细 - 前端:桌面设置面板显示"欠费",admin 后台单格药丸显示"欠费"、新增所属管理员列与超管筛选 --- admin-frontend-vue/src/api/user-secrets.ts | 10 +++ .../src/pages/account/UserSecretsPage.vue | 49 +++++++++++++-- .../usersecret/client/JikipProxyClient.java | 37 +++++++++++ .../AdminUserApiSecretController.java | 9 ++- .../model/dto/AdminUserSecretQuery.java | 3 + .../model/vo/AdminUserSecretRowVo.java | 6 ++ .../service/UserApiSecretCheckService.java | 48 ++++++++++++++- .../service/UserApiSecretService.java | 61 ++++++++++++------- .../UserApiSecretCheckServiceTest.java | 49 +++++++++++++++ .../service/UserApiSecretServiceTest.java | 35 ++++++++++- .../components/ApiSecretSettingsPanel.vue | 5 +- 11 files changed, 278 insertions(+), 34 deletions(-) diff --git a/admin-frontend-vue/src/api/user-secrets.ts b/admin-frontend-vue/src/api/user-secrets.ts index 265dd037..1289dd54 100644 --- a/admin-frontend-vue/src/api/user-secrets.ts +++ b/admin-frontend-vue/src/api/user-secrets.ts @@ -19,6 +19,8 @@ export interface AdminUserSecretModule { export interface AdminUserSecretRow { userId: number username: string + createdById: number | null + createdByUsername: string similarAsin: AdminUserSecretModule appearancePatent: AdminUserSecretModule proxy: AdminUserSecretModule @@ -34,9 +36,17 @@ export interface AdminUserSecretPage { pageSize: number } +/** 管理员下拉项(超管筛选用)。 */ +export interface AdminOption { + id: number + username: string +} + export interface UserSecretQuery { keyword?: string checkStatus?: string + /** 按创建人筛选(仅超管生效)。 */ + createdById?: number page: number pageSize: number } diff --git a/admin-frontend-vue/src/pages/account/UserSecretsPage.vue b/admin-frontend-vue/src/pages/account/UserSecretsPage.vue index 9c08a683..47a65f36 100644 --- a/admin-frontend-vue/src/pages/account/UserSecretsPage.vue +++ b/admin-frontend-vue/src/pages/account/UserSecretsPage.vue @@ -3,6 +3,8 @@ import { computed, onMounted, ref } from 'vue' import { ElMessage } from 'element-plus' import { formatDateTime } from '@/utils/datetime' import OldPagination from '@/components/OldPagination.vue' +import { useAdminSessionStore } from '@/stores/admin-session' +import { fetchUserList } from '@/api/users' import { checkUserSecret, deleteUserSecret, @@ -11,6 +13,7 @@ import { type AdminUserSecretRow, } from '@/api/user-secrets' +const session = useAdminSessionStore() const loading = ref(false) const rows = ref([]) const total = ref(0) @@ -18,6 +21,9 @@ const page = ref(1) const pageSize = ref(15) const keyword = ref('') const statusFilter = ref('') +const createdByIdFilter = ref(null) +/** 管理员下拉(仅超管可见/加载)。 */ +const adminOptions = ref>([]) /** 正在检测的行 userId,用于按钮 loading 态。 */ const checkingId = ref(null) @@ -48,7 +54,7 @@ function rowStatusMeta(status: string) { } } -/** 单格状态药丸:未配置时统一灰色。 */ +/** 单格状态药丸:未配置时统一灰色;欠费单独标识。 */ function moduleStatusMeta(module: AdminUserSecretModule | undefined) { if (!module || !module.exists) { return { label: '未配置', tone: 'is-unknown' } @@ -57,6 +63,9 @@ function moduleStatusMeta(module: AdminUserSecretModule | undefined) { case 'passed': return { label: '通过', tone: 'is-allowed' } case 'failed': + if (module.checkCode === 'insufficient_balance') { + return { label: '欠费', tone: 'is-warn' } + } return { label: '失败', tone: 'is-blocked' } case 'error': return { label: '无法判定', tone: 'is-warn' } @@ -82,12 +91,23 @@ function rowStatusTooltip(row: AdminUserSecretRow) { return parts.join(';') || '暂无检测记录' } +async function loadAdminOptions() { + if (!session.isSuperAdmin) return + try { + const result = await fetchUserList({ page: 1, pageSize: 999, role: 'admin' }) + adminOptions.value = result.admins || [] + } catch (error) { + console.warn('[user-secrets] 管理员下拉加载失败', error) + } +} + async function load() { loading.value = true try { const result = await fetchUserSecretList({ keyword: keyword.value.trim() || undefined, checkStatus: statusFilter.value || undefined, + createdById: createdByIdFilter.value || undefined, page: page.value, pageSize: pageSize.value, }) @@ -108,6 +128,7 @@ function search() { function reset() { keyword.value = '' statusFilter.value = '' + createdByIdFilter.value = null page.value = 1 load() } @@ -162,7 +183,10 @@ function changeSize(size: number) { load() } -onMounted(load) +onMounted(() => { + loadAdminOptions() + load() +}) - 加载中... + 加载中... - {{ keyword || statusFilter ? '暂无匹配记录' : '暂无用户密钥记录' }} + {{ keyword || statusFilter || createdByIdFilter ? '暂无匹配记录' : '暂无用户密钥记录' }} @@ -408,7 +443,7 @@ h3 { width: 100%; border-collapse: collapse; table-layout: fixed; - min-width: 1310px; + min-width: 1440px; } .secrets-table-scroll th, .secrets-table-scroll td { @@ -443,6 +478,10 @@ h3 { white-space: normal; overflow-wrap: anywhere; } +.creator-name { + color: #5b6f83; + font-size: 12.5px; +} .module-cell { display: flex; align-items: center; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/client/JikipProxyClient.java b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/client/JikipProxyClient.java index e53a04f2..04fb4cfb 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/client/JikipProxyClient.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/client/JikipProxyClient.java @@ -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(); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/controller/AdminUserApiSecretController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/controller/AdminUserApiSecretController.java index e8925477..59398f38 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/controller/AdminUserApiSecretController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/controller/AdminUserApiSecretController.java @@ -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 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") diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/model/dto/AdminUserSecretQuery.java b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/model/dto/AdminUserSecretQuery.java index 4eb67838..4c027e7e 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/model/dto/AdminUserSecretQuery.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/model/dto/AdminUserSecretQuery.java @@ -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; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/model/vo/AdminUserSecretRowVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/model/vo/AdminUserSecretRowVo.java index 5d9b5c63..8221d265 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/model/vo/AdminUserSecretRowVo.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/model/vo/AdminUserSecretRowVo.java @@ -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; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/service/UserApiSecretCheckService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/service/UserApiSecretCheckService.java index 27d3c6ae..e8948fd3 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/service/UserApiSecretCheckService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/service/UserApiSecretCheckService.java @@ -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 buildCheckBody(String model) { Map body = new LinkedHashMap<>(); body.put("model", model); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/service/UserApiSecretService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/service/UserApiSecretService.java index b8df0520..03b6ec36 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/service/UserApiSecretService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/service/UserApiSecretService.java @@ -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 wrapper = new LambdaQueryWrapper<>(); String keyword = normalize(safeQuery.getKeyword()); + List allowedUserIds = null; + // 用户存在性/归属先按 users 表圈定:keyword 匹配 + 组隔离前置过滤(无密钥记录的用户本来就不在聚合表里)。 + LambdaQueryWrapper userWrapper = new LambdaQueryWrapper<>(); if (!keyword.isEmpty()) { - List 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 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 resolveUserIdsByKeyword(String keyword) { - Set userIds = new LinkedHashSet<>(); - List matched = adminUserMapper.selectList(new LambdaQueryWrapper() - .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()); diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/usersecret/service/UserApiSecretCheckServiceTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/usersecret/service/UserApiSecretCheckServiceTest.java index e7025b09..da93e2e1 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/usersecret/service/UserApiSecretCheckServiceTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/usersecret/service/UserApiSecretCheckServiceTest.java @@ -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); diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/usersecret/service/UserApiSecretServiceTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/usersecret/service/UserApiSecretServiceTest.java index cfaff23f..ee6ebfee 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/usersecret/service/UserApiSecretServiceTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/usersecret/service/UserApiSecretServiceTest.java @@ -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(); diff --git a/frontend-vue/src/shared/components/ApiSecretSettingsPanel.vue b/frontend-vue/src/shared/components/ApiSecretSettingsPanel.vue index 518e8048..a7374619 100644 --- a/frontend-vue/src/shared/components/ApiSecretSettingsPanel.vue +++ b/frontend-vue/src/shared/components/ApiSecretSettingsPanel.vue @@ -202,7 +202,9 @@ async function runProxyCheck() { proxyCheckResult.value = await checkApiSecret('proxy', dirty ? inputValue : undefined) const result = proxyCheckResult.value if (result.checkStatus === 'failed') { - ElMessage.warning(result.checkMessage || '代理不可用') + ElMessage.warning( + result.checkCode === 'insufficient_balance' ? '代理服务商余额不足,请充值或联系管理员' : (result.checkMessage || '代理不可用'), + ) } else if (result.checkStatus === 'error') { ElMessage.warning(result.checkMessage || '暂时无法判定代理可用性') } else { @@ -291,6 +293,7 @@ function statusTextOf(moduleKey: string) { const latency = state.result.checkLatencyMs != null ? `(${state.result.checkLatencyMs}ms)` : '' const suffix = state.result.viaProxy === true ? ' · 经代理' : '' if (state.result.checkStatus === 'passed') return `检测通过${latency}${suffix}` + if (state.result.checkCode === 'insufficient_balance') return `欠费:${state.result.checkMessage || '代理服务商余额不足'}${suffix}` return `${state.result.checkMessage || '检测失败'}${suffix}` } if (state.error) return state.error