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
@@ -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
}
@@ -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<AdminUserSecretRow[]>([])
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<number | null>(null)
/** 管理员下拉(仅超管可见/加载)。 */
const adminOptions = ref<Array<{ id: number; username: string }>>([])
/** 正在检测的行 userId,用于按钮 loading 态。 */
const checkingId = ref<number | null>(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()
})
</script>
<template>
@@ -183,6 +207,13 @@ onMounted(load)
<option v-for="option in STATUS_OPTIONS" :key="option.value" :value="option.value">{{ option.label }}</option>
</select>
</div>
<div class="form-group" v-if="session.isSuperAdmin" style="min-width: 170px">
<label>所属管理员</label>
<select v-model="createdByIdFilter">
<option :value="null">全部管理员</option>
<option v-for="admin in adminOptions" :key="admin.id" :value="admin.id">{{ admin.username }}</option>
</select>
</div>
<div class="form-group">
<label>&nbsp;</label>
<div class="filter-actions">
@@ -198,6 +229,7 @@ onMounted(load)
<tr>
<th style="width: 58px">序号</th>
<th style="width: 260px">用户</th>
<th style="width: 130px">所属管理员</th>
<th style="width: 230px">货源查询密钥</th>
<th style="width: 230px">外观专利密钥</th>
<th style="width: 230px">代理设置</th>
@@ -212,6 +244,9 @@ onMounted(load)
<td>
<span class="user-name">{{ row.username || '—' }}</span>
</td>
<td>
<span class="creator-name">{{ row.createdByUsername || (row.createdById ? `UID ${row.createdById}` : '—') }}</span>
</td>
<td>
<div class="module-cell">
<span v-if="row.similarAsin?.exists" class="mono-mask" :title="moduleTooltip(row.similarAsin)">{{ row.similarAsin.masked }}</span>
@@ -258,10 +293,10 @@ onMounted(load)
</tr>
</template>
<tr v-else-if="loading">
<td colspan="7" class="empty-tip">加载中...</td>
<td colspan="8" class="empty-tip">加载中...</td>
</tr>
<tr v-else>
<td colspan="7" class="empty-tip">{{ keyword || statusFilter ? '暂无匹配记录' : '暂无用户密钥记录' }}</td>
<td colspan="8" class="empty-tip">{{ keyword || statusFilter || createdByIdFilter ? '暂无匹配记录' : '暂无用户密钥记录' }}</td>
</tr>
</tbody>
</table>
@@ -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;
@@ -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();
@@ -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