Compare commits

..

2 Commits

Author SHA1 Message Date
huangzd1997 1066625078 feat(品牌服务): 内部接口按 uid 供用户代理提取链接;代理检测改真实提取+转发探测
- 新增 /api/internal/user-proxy(X-Internal-Token 自校验,18080 公网可达必须校验),
  供主机 A 品牌检测服务(15126)按 uid 取用户代理提取链接,未配置返回空串
- 代理检测修复"假通过":旧实现把提取链接直接当静态代理交给 HttpClientPool,
  链接无显式端口解析失败静默回退直连,检测到的是自家站点直连响应(40 个用户
  3-39ms 全 301 假通过);现在先真实提取一次(余额不足/解析失败如实报),
  再经提取到的代理请求自家域名,兼容旧静态代理地址
2026-09-13 21:21:48 +08:00
huangzd1997 fe052879bd fix(用户管理): 删除用户时级联清理服务端密钥,修已删账号留下孤儿密钥行
biz_user_api_secret 按 uid 绑定且无外键,删除用户后密钥/代理配置成为无主行
(生产已出现 5 条孤儿)。deleteUser 内同步调用 adminClearByUser 并纳入同一事务,
删除失败(行不存在)时不触碰密钥。
2026-09-13 21:21:45 +08:00
6 changed files with 374 additions and 10 deletions
@@ -15,7 +15,9 @@ import com.nanri.aiimage.modules.permission.mapper.AdminUserMapper;
import com.nanri.aiimage.modules.permission.model.dto.UserColumnPermissionUpdateRequest; import com.nanri.aiimage.modules.permission.model.dto.UserColumnPermissionUpdateRequest;
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity; import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
import com.nanri.aiimage.modules.permission.service.PermissionMenuService; import com.nanri.aiimage.modules.permission.service.PermissionMenuService;
import com.nanri.aiimage.modules.usersecret.service.UserApiSecretService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DuplicateKeyException; import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -32,6 +34,7 @@ import java.util.stream.Collectors;
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
@Slf4j
public class AdminUserService { public class AdminUserService {
private static final DateTimeFormatter CREATED_AT_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); private static final DateTimeFormatter CREATED_AT_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
@@ -40,6 +43,7 @@ public class AdminUserService {
private final WerkzeugPasswordEncoder passwordEncoder; private final WerkzeugPasswordEncoder passwordEncoder;
private final AdminAuthSupport adminAuthSupport; private final AdminAuthSupport adminAuthSupport;
private final PermissionMenuService permissionMenuService; private final PermissionMenuService permissionMenuService;
private final UserApiSecretService userApiSecretService;
public AdminUserListVo listUsers(AdminUserEntity currentUser, Integer page, Integer pageSize, public AdminUserListVo listUsers(AdminUserEntity currentUser, Integer page, Integer pageSize,
String username, Long createdById, String roleFilter) { String username, Long createdById, String roleFilter) {
@@ -271,6 +275,8 @@ public class AdminUserService {
operator, userId, permissionRequest, menuType, false); operator, userId, permissionRequest, menuType, false);
} }
/** 删除用户并级联清理其服务端密钥;不清理会留下无主密钥行(biz_user_api_secret 按 uid 绑定,无外键)。 */
@Transactional
public void deleteUser(AdminUserEntity currentUser, Long uid) { public void deleteUser(AdminUserEntity currentUser, Long uid) {
String role = adminAuthSupport.currentRole(currentUser); String role = adminAuthSupport.currentRole(currentUser);
if (role == null) { if (role == null) {
@@ -297,6 +303,8 @@ public class AdminUserService {
if (affected == 0) { if (affected == 0) {
throw new BusinessException("用户不存在"); throw new BusinessException("用户不存在");
} }
int secretRows = userApiSecretService.adminClearByUser(uid);
log.info("[admin-user] 用户已删除 uid={} 级联清理密钥行={}", uid, secretRows);
} }
private Map<Long, String> loadCreatorMap(List<AdminUserEntity> rows) { private Map<Long, String> loadCreatorMap(List<AdminUserEntity> rows) {
@@ -0,0 +1,61 @@
package com.nanri.aiimage.modules.usersecret.controller;
import com.nanri.aiimage.common.api.ApiResponse;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.modules.admin.support.AdminAuthSupport;
import com.nanri.aiimage.modules.usersecret.service.UserApiSecretService;
import com.nanri.aiimage.modules.usersecret.support.UserSecretModule;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.HashMap;
import java.util.Map;
/**
* 内部接口:按 uid 取用户代理提取链接,供主机 A 品牌检测服务(15126)读取。
*
* <p>鉴权:仅凭 X-Internal-Token(与容器 AIIMAGE_INTERNAL_TOKEN / 宿主机
* ~/.aiimage/internal-token 同值)。/api/internal 前缀不在 AdminApiGuardFilter
* 的兜底名单内,controller 必须自校验令牌——18080 公网可达,漏校验即匿名泄露
* 用户代理密钥。
*
* <p>日志不落明文;proxyUrl 为空串表示该用户未配置代理(调用方退化为直连)。
*/
@RestController
@RequiredArgsConstructor
@Slf4j
@RequestMapping("/api/internal")
@Tag(name = "内部接口", description = "服务间调用(X-Internal-Token 鉴权)。")
public class InternalUserProxyController {
private final UserApiSecretService userApiSecretService;
private final AdminAuthSupport adminAuthSupport;
@GetMapping("/user-proxy")
@Operation(summary = "按 uid 取用户代理提取链接",
description = "明文返回该用户的代理提取链接(仅内部令牌可调);未配置返回空串。")
public ApiResponse<Map<String, Object>> userProxy(
HttpServletRequest request,
@RequestParam(name = "userId", required = false) Long userId) {
if (!adminAuthSupport.isTrustedInternalToken(request)) {
log.warn("[internal-proxy] 拒绝未携带可信内部令牌的请求 userId={} remoteAddr={}",
userId, request.getRemoteAddr());
throw new BusinessException(401, "未授权");
}
String proxyUrl = userId == null || userId <= 0
? ""
: userApiSecretService.findPlainValue(userId, UserSecretModule.PROXY.key());
log.info("[internal-proxy] 查询用户代理配置 userId={} configured={}", userId, !proxyUrl.isEmpty());
Map<String, Object> data = new HashMap<>();
data.put("userId", userId);
data.put("proxyUrl", proxyUrl);
return ApiResponse.success(data);
}
}
@@ -17,11 +17,14 @@ import org.springframework.web.client.RestClient;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.net.URI;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/** /**
* 密钥连通性探测:调一次 LLM /v1/chat/completions,能访问通(2xx 且返回 choices)即通过。 * 密钥连通性探测:调一次 LLM /v1/chat/completions,能访问通(2xx 且返回 choices)即通过。
@@ -58,6 +61,10 @@ public class UserApiSecretCheckService {
/** 代理探测目标:自家域名(http 无 CONNECT 依赖,兼容各类转发型代理)。 */ /** 代理探测目标:自家域名(http 无 CONNECT 依赖,兼容各类转发型代理)。 */
private static final String PROXY_PROBE_TARGET_URL = "http://api.aishufu.top/"; private static final String PROXY_PROBE_TARGET_URL = "http://api.aishufu.top/";
/** 提取链接响应里的 IP:PORT 正则兜底(与 JikipProxyClient 同款)。 */
private static final Pattern IP_PORT_PATTERN =
Pattern.compile("(\\d{1,3}(?:\\.\\d{1,3}){3}):(\\d{2,5})");
private final AppearancePatentProperties appearancePatentProperties; private final AppearancePatentProperties appearancePatentProperties;
private final SimilarAsinProperties similarAsinProperties; private final SimilarAsinProperties similarAsinProperties;
private final JikipProxyClient jikipProxyClient; private final JikipProxyClient jikipProxyClient;
@@ -96,8 +103,14 @@ public class UserApiSecretCheckService {
} }
/** /**
* 代理连通性探测:经由用户配置的代理请求一次自家域名。 * 代理配置探测(按「提取链接」语义,与品牌检测的实际用法一致):
* 拿到任意 HTTP 响应(含 4xx/5xx)即说明代理转发可用;407 为代理自身认证失败;网络异常视为不可达。 * 先 GET 用户链接真实提取一次——余额不足报余额不足、解析不出 IP 报配置错误;
* 提取到 IP(可含账密)后经该代理请求一次自家域名,拿到任意 HTTP 响应即转发可用。
* 兼容纯静态代理地址(无 path/query 的 host:port)直接探测。
*
* <p>2026-09-13 修复:旧实现把提取链接直接交给 HttpClientPool 当静态代理使用,链接无
* 显式端口时解析失败会静默回退直连,探测到的是自家站点的直连响应——40 个用户全部
* 「假通过」(3-39ms + HTTP 301),余额不足 / 链接失效都测不出来。
*/ */
private CheckOutcome probeProxy(String proxyUrl) { private CheckOutcome probeProxy(String proxyUrl) {
String normalized = proxyUrl == null ? "" : proxyUrl.trim(); String normalized = proxyUrl == null ? "" : proxyUrl.trim();
@@ -105,31 +118,131 @@ public class UserApiSecretCheckService {
return new CheckOutcome(STATUS_FAILED, CODE_INVALID_KEY, "代理地址为空,请重新配置", null, true); return new CheckOutcome(STATUS_FAILED, CODE_INVALID_KEY, "代理地址为空,请重新配置", null, true);
} }
long startMillis = System.currentTimeMillis(); long startMillis = System.currentTimeMillis();
// 纯静态代理地址:直接经它探测(兼容旧配置)
if (looksLikeStaticProxy(normalized)) {
return probeThroughProxy(normalized, startMillis);
}
// 提取链接:先真实提取一次
String body;
try {
body = clientFor(null).get().uri(normalized).retrieve().body(String.class);
} catch (Exception ex) {
long latency = System.currentTimeMillis() - startMillis;
log.warn("[user-secret][check] 代理提取接口不可达 latency={}ms err={}", latency, ex.getMessage());
return new CheckOutcome(STATUS_FAILED, CODE_NETWORK_ERROR,
"代理提取接口不可达:" + rootCauseMessage(ex), (int) latency, false);
}
long extractLatency = System.currentTimeMillis() - startMillis;
if (isInsufficientBalance(body)) {
log.warn("[user-secret][check] 代理提取被拒:供应商余额不足 latency={}ms", extractLatency);
return new CheckOutcome(STATUS_FAILED, CODE_INSUFFICIENT_BALANCE,
INSUFFICIENT_BALANCE_MESSAGE, (int) extractLatency, false);
}
String extractedProxyUrl = extractProxyUrlFromBody(body);
if (extractedProxyUrl == null) {
log.warn("[user-secret][check] 代理提取响应未解析出 IP,响应前 150 字={}", abbreviate(body, 150));
return new CheckOutcome(STATUS_FAILED, CODE_INVALID_KEY,
"链接未返回可用 IP,请检查代理提取配置", (int) extractLatency, false);
}
return probeThroughProxy(extractedProxyUrl, startMillis);
}
/** 经指定代理请求自家域名:任意 HTTP 响应即转发可用;407 为代理自身认证失败;网络异常不可达。 */
private CheckOutcome probeThroughProxy(String proxyUrl, long startMillis) {
try { try {
RestClient client = RestClient.builder() RestClient client = RestClient.builder()
.requestFactory(HttpClientPool.requestFactory(PROXY_READ_TIMEOUT_MILLIS, normalized)) .requestFactory(HttpClientPool.requestFactory(PROXY_READ_TIMEOUT_MILLIS, proxyUrl))
.build(); .build();
int statusCode = client.get() int statusCode = client.get()
.uri(PROXY_PROBE_TARGET_URL) .uri(PROXY_PROBE_TARGET_URL)
.exchange((request, response) -> response.getStatusCode().value()); .exchange((request, response) -> response.getStatusCode().value());
long latency = System.currentTimeMillis() - startMillis; long latency = System.currentTimeMillis() - startMillis;
if (statusCode == 407) { if (statusCode == 407) {
log.warn("[user-secret][check] 代理认证失败(407 latency={}ms", latency);
return new CheckOutcome(STATUS_FAILED, CODE_FORBIDDEN, return new CheckOutcome(STATUS_FAILED, CODE_FORBIDDEN,
"代理认证失败(407),请检查代理账号密码", (int) latency, true); "代理认证失败(407),请检查代理账号密码", (int) latency, true);
} }
CheckOutcome outcome = new CheckOutcome(STATUS_PASSED, CODE_OK,
"代理连通正常(HTTP " + statusCode + "", (int) latency, true);
log.info("[user-secret][check] 代理探测完成 status=passed code=ok httpStatus={} latency={}ms", log.info("[user-secret][check] 代理探测完成 status=passed code=ok httpStatus={} latency={}ms",
statusCode, latency); statusCode, latency);
return outcome; return new CheckOutcome(STATUS_PASSED, CODE_OK,
"代理可用(HTTP " + statusCode + "", (int) latency, true);
} catch (Exception ex) { } catch (Exception ex) {
long latency = System.currentTimeMillis() - startMillis; long latency = System.currentTimeMillis() - startMillis;
log.warn("[user-secret][check] 代理探测异常 latency={}ms err={}", latency, ex.getMessage()); log.warn("[user-secret][check] 代理转发失败 latency={}ms err={}", latency, ex.getMessage());
return new CheckOutcome(STATUS_FAILED, CODE_NETWORK_ERROR, return new CheckOutcome(STATUS_FAILED, CODE_NETWORK_ERROR,
"代理不可达" + rootCauseMessage(ex), (int) latency, true); "代理转发失败" + rootCauseMessage(ex), (int) latency, true);
} }
} }
/** 纯静态代理形态判定:http(s)://[user:pass@]host:port 且无 path/query。 */
static boolean looksLikeStaticProxy(String url) {
try {
URI uri = URI.create(url.trim());
if (uri.getHost() == null || uri.getHost().isBlank() || uri.getPort() <= 0) {
return false;
}
String path = uri.getPath();
boolean blankPath = path == null || path.isEmpty() || "/".equals(path);
return blankPath && uri.getQuery() == null;
} catch (Exception ex) {
return false;
}
}
/** 提取响应解析:JSON data.list[0] 的 ip/port(账密可选)优先,正文 ip:port 正则兜底。 */
String extractProxyUrlFromBody(String body) {
String text = body == null ? "" : body.trim();
if (text.isEmpty()) {
return null;
}
if (text.charAt(0) == '{' || text.charAt(0) == '[') {
JsonNode root = parseJson(text);
if (root != null) {
JsonNode list = root.path("data").path("list");
if (list.isArray() && list.size() > 0) {
JsonNode first = list.get(0);
String ip = text(first.get("ip"));
String port = text(first.get("port"));
if (ip != null && !ip.isBlank() && port != null && !port.isBlank()) {
String account = text(first.get("account"));
String password = text(first.get("password"));
if (account != null && !account.isBlank() && password != null && !password.isBlank()) {
return "http://" + account + ":" + password + "@" + ip + ":" + port;
}
return "http://" + ip + ":" + port;
}
}
}
}
Matcher matcher = IP_PORT_PATTERN.matcher(text);
if (matcher.find()) {
return "http://" + matcher.group(1) + ":" + matcher.group(2);
}
return null;
}
/** 提取响应是否表明供应商欠费(如 jikip {"code":-1,"data":null,"message":"余额不足"})。 */
boolean isInsufficientBalance(String body) {
String text = body == null ? "" : body.trim();
if (text.isEmpty()) {
return false;
}
JsonNode root = parseJson(text);
if (root != null && root.isObject()) {
String message = root.path("message").asText("");
if (message.contains("余额不足")) {
return true;
}
JsonNode code = root.get("code");
if (code != null && !code.isNull() && code.asDouble(Double.NaN) == -1D) {
return true;
}
}
return text.contains("余额不足");
}
private CheckOutcome probeOnce(UserSecretModule module, String plainApiKey, String proxyUrl, boolean viaProxy) { private CheckOutcome probeOnce(UserSecretModule module, String plainApiKey, String proxyUrl, boolean viaProxy) {
UserSecretModule.LlmTarget target = module.resolveLlmTarget(appearancePatentProperties, similarAsinProperties); UserSecretModule.LlmTarget target = module.resolveLlmTarget(appearancePatentProperties, similarAsinProperties);
String url = joinUrl(target.host(), "/v1/chat/completions"); String url = joinUrl(target.host(), "/v1/chat/completions");
@@ -1,5 +1,6 @@
package com.nanri.aiimage.modules.admin.service; package com.nanri.aiimage.modules.admin.service;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.modules.admin.model.dto.AdminUserCreateRequest; import com.nanri.aiimage.modules.admin.model.dto.AdminUserCreateRequest;
import com.nanri.aiimage.modules.admin.model.dto.AdminUserUpdateRequest; import com.nanri.aiimage.modules.admin.model.dto.AdminUserUpdateRequest;
import com.nanri.aiimage.modules.admin.support.AdminAuthSupport; import com.nanri.aiimage.modules.admin.support.AdminAuthSupport;
@@ -8,6 +9,7 @@ import com.nanri.aiimage.modules.permission.mapper.AdminUserMapper;
import com.nanri.aiimage.modules.permission.model.dto.UserColumnPermissionUpdateRequest; import com.nanri.aiimage.modules.permission.model.dto.UserColumnPermissionUpdateRequest;
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity; import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
import com.nanri.aiimage.modules.permission.service.PermissionMenuService; import com.nanri.aiimage.modules.permission.service.PermissionMenuService;
import com.nanri.aiimage.modules.usersecret.service.UserApiSecretService;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor; import org.mockito.ArgumentCaptor;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -16,6 +18,7 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
@@ -32,8 +35,9 @@ class AdminUserServiceTest {
WerkzeugPasswordEncoder passwordEncoder = mock(WerkzeugPasswordEncoder.class); WerkzeugPasswordEncoder passwordEncoder = mock(WerkzeugPasswordEncoder.class);
AdminAuthSupport authSupport = mock(AdminAuthSupport.class); AdminAuthSupport authSupport = mock(AdminAuthSupport.class);
PermissionMenuService permissionService = mock(PermissionMenuService.class); PermissionMenuService permissionService = mock(PermissionMenuService.class);
UserApiSecretService userApiSecretService = mock(UserApiSecretService.class);
AdminUserService service = new AdminUserService( AdminUserService service = new AdminUserService(
userMapper, passwordEncoder, authSupport, permissionService); userMapper, passwordEncoder, authSupport, permissionService, userApiSecretService);
AdminUserEntity operator = user(10L, "admin", 1, null); AdminUserEntity operator = user(10L, "admin", 1, null);
AdminUserCreateRequest request = new AdminUserCreateRequest(); AdminUserCreateRequest request = new AdminUserCreateRequest();
request.setUsername("created-user"); request.setUsername("created-user");
@@ -77,8 +81,9 @@ class AdminUserServiceTest {
WerkzeugPasswordEncoder passwordEncoder = mock(WerkzeugPasswordEncoder.class); WerkzeugPasswordEncoder passwordEncoder = mock(WerkzeugPasswordEncoder.class);
AdminAuthSupport authSupport = mock(AdminAuthSupport.class); AdminAuthSupport authSupport = mock(AdminAuthSupport.class);
PermissionMenuService permissionService = mock(PermissionMenuService.class); PermissionMenuService permissionService = mock(PermissionMenuService.class);
UserApiSecretService userApiSecretService = mock(UserApiSecretService.class);
AdminUserService service = new AdminUserService( AdminUserService service = new AdminUserService(
userMapper, passwordEncoder, authSupport, permissionService); userMapper, passwordEncoder, authSupport, permissionService, userApiSecretService);
AdminUserEntity operator = user(10L, "admin", 1, null); AdminUserEntity operator = user(10L, "admin", 1, null);
AdminUserEntity target = user(20L, "normal", 0, 10L); AdminUserEntity target = user(20L, "normal", 0, 10L);
AdminUserUpdateRequest request = new AdminUserUpdateRequest(); AdminUserUpdateRequest request = new AdminUserUpdateRequest();
@@ -110,6 +115,55 @@ class AdminUserServiceTest {
.isAnnotationPresent(Transactional.class)).isTrue(); .isAnnotationPresent(Transactional.class)).isTrue();
} }
@Test
void deleteUserCascadesUserApiSecretsInSameTransactionalMethod() throws Exception {
AdminUserMapper userMapper = mock(AdminUserMapper.class);
WerkzeugPasswordEncoder passwordEncoder = mock(WerkzeugPasswordEncoder.class);
AdminAuthSupport authSupport = mock(AdminAuthSupport.class);
PermissionMenuService permissionService = mock(PermissionMenuService.class);
UserApiSecretService userApiSecretService = mock(UserApiSecretService.class);
AdminUserService service = new AdminUserService(
userMapper, passwordEncoder, authSupport, permissionService, userApiSecretService);
AdminUserEntity operator = user(10L, "admin", 1, null);
AdminUserEntity target = user(20L, "normal", 0, 10L);
when(authSupport.currentRole(operator)).thenReturn("admin");
when(permissionService.hasAnyAdminMenu(eq(operator), any())).thenReturn(true);
when(userMapper.selectById(20L)).thenReturn(target);
when(userMapper.deleteById(20L)).thenReturn(1);
when(userApiSecretService.adminClearByUser(20L)).thenReturn(3);
service.deleteUser(operator, 20L);
verify(userMapper).deleteById(20L);
verify(userApiSecretService).adminClearByUser(20L);
assertThat(AdminUserService.class
.getMethod("deleteUser", AdminUserEntity.class, Long.class)
.isAnnotationPresent(Transactional.class)).isTrue();
}
@Test
void deleteUserSkipsSecretCleanupWhenUserRowMissing() {
AdminUserMapper userMapper = mock(AdminUserMapper.class);
WerkzeugPasswordEncoder passwordEncoder = mock(WerkzeugPasswordEncoder.class);
AdminAuthSupport authSupport = mock(AdminAuthSupport.class);
PermissionMenuService permissionService = mock(PermissionMenuService.class);
UserApiSecretService userApiSecretService = mock(UserApiSecretService.class);
AdminUserService service = new AdminUserService(
userMapper, passwordEncoder, authSupport, permissionService, userApiSecretService);
AdminUserEntity operator = user(10L, "admin", 1, null);
AdminUserEntity target = user(20L, "normal", 0, 10L);
when(authSupport.currentRole(operator)).thenReturn("admin");
when(permissionService.hasAnyAdminMenu(eq(operator), any())).thenReturn(true);
when(userMapper.selectById(20L)).thenReturn(target);
when(userMapper.deleteById(20L)).thenReturn(0);
assertThatThrownBy(() -> service.deleteUser(operator, 20L))
.isInstanceOf(BusinessException.class);
verify(userApiSecretService, never()).adminClearByUser(any());
}
private AdminUserEntity user(Long id, String role, int isAdmin, Long createdById) { private AdminUserEntity user(Long id, String role, int isAdmin, Long createdById) {
AdminUserEntity user = new AdminUserEntity(); AdminUserEntity user = new AdminUserEntity();
user.setId(id); user.setId(id);
@@ -0,0 +1,76 @@
package com.nanri.aiimage.modules.usersecret.controller;
import com.nanri.aiimage.common.api.ApiResponse;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.modules.admin.support.AdminAuthSupport;
import com.nanri.aiimage.modules.usersecret.service.UserApiSecretService;
import jakarta.servlet.http.HttpServletRequest;
import org.junit.jupiter.api.Test;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class InternalUserProxyControllerTest {
private final UserApiSecretService userApiSecretService = mock(UserApiSecretService.class);
private final AdminAuthSupport adminAuthSupport = mock(AdminAuthSupport.class);
private InternalUserProxyController newController() {
return new InternalUserProxyController(userApiSecretService, adminAuthSupport);
}
private HttpServletRequest request() {
return mock(HttpServletRequest.class);
}
@Test
void rejectsRequestWithoutTrustedInternalTokenAndSkipsLookup() {
when(adminAuthSupport.isTrustedInternalToken(any())).thenReturn(false);
assertThatThrownBy(() -> newController().userProxy(request(), 1098L))
.isInstanceOf(BusinessException.class)
.hasMessageContaining("未授权");
verify(userApiSecretService, never()).findPlainValue(anyLong(), anyString());
}
@Test
void returnsProxyUrlForConfiguredUser() {
when(adminAuthSupport.isTrustedInternalToken(any())).thenReturn(true);
when(userApiSecretService.findPlainValue(1098L, "proxy")).thenReturn("http://p.example/x");
ApiResponse<Map<String, Object>> resp = newController().userProxy(request(), 1098L);
assertThat(resp.isSuccess()).isTrue();
assertThat(resp.getData()).containsEntry("userId", 1098L);
assertThat(resp.getData()).containsEntry("proxyUrl", "http://p.example/x");
}
@Test
void returnsEmptyUrlForUnconfiguredUser() {
when(adminAuthSupport.isTrustedInternalToken(any())).thenReturn(true);
when(userApiSecretService.findPlainValue(1098L, "proxy")).thenReturn("");
ApiResponse<Map<String, Object>> resp = newController().userProxy(request(), 1098L);
assertThat(resp.getData()).containsEntry("proxyUrl", "");
}
@Test
void skipsLookupForMissingOrInvalidUserId() {
when(adminAuthSupport.isTrustedInternalToken(any())).thenReturn(true);
InternalUserProxyController controller = newController();
assertThat(controller.userProxy(request(), null).getData()).containsEntry("proxyUrl", "");
assertThat(controller.userProxy(request(), 0L).getData()).containsEntry("proxyUrl", "");
verify(userApiSecretService, never()).findPlainValue(any(), anyString());
}
}
@@ -136,4 +136,56 @@ class UserApiSecretCheckServiceTest {
assertThat(outcome.status()).isEqualTo(UserApiSecretCheckService.STATUS_ERROR); assertThat(outcome.status()).isEqualTo(UserApiSecretCheckService.STATUS_ERROR);
assertThat(outcome.code()).isEqualTo(UserApiSecretCheckService.CODE_SERVER_ERROR); assertThat(outcome.code()).isEqualTo(UserApiSecretCheckService.CODE_SERVER_ERROR);
} }
// ===== 代理配置探测(提取链接语义;2026-09-13 修复「直连自家站点假通过」)=====
@Test
void looksLikeStaticProxyOnlyForHostPortWithoutPathOrQuery() {
assertThat(UserApiSecretCheckService.looksLikeStaticProxy("http://1.2.3.4:8080")).isTrue();
assertThat(UserApiSecretCheckService.looksLikeStaticProxy("http://u:p@1.2.3.4:8080")).isTrue();
assertThat(UserApiSecretCheckService.looksLikeStaticProxy("https://1.2.3.4:8080/")).isTrue();
// 提取链接(带 path/query 或无显式端口)不能按静态代理直连探测
assertThat(UserApiSecretCheckService.looksLikeStaticProxy(
"https://api.jikip.com/ip-get?num=1&mode=2&key=xx")).isFalse();
assertThat(UserApiSecretCheckService.looksLikeStaticProxy("https://api.jikip.com")).isFalse();
assertThat(UserApiSecretCheckService.looksLikeStaticProxy("")).isFalse();
}
@Test
void extractProxyUrlPrefersJsonWithCredentials() {
String body = "{\"data\":{\"list\":[{\"ip\":\"218.95.39.19\",\"port\":\"15902\","
+ "\"account\":\"acc1\",\"password\":\"pw1\"}]},\"code\":0,\"message\":\"\"}";
assertThat(service.extractProxyUrlFromBody(body))
.isEqualTo("http://acc1:pw1@218.95.39.19:15902");
}
@Test
void extractProxyUrlWithoutCredentialsWhenWhitelistMode() {
String body = "{\"data\":{\"list\":[{\"ip\":\"1.2.3.4\",\"port\":8080}]}}";
assertThat(service.extractProxyUrlFromBody(body)).isEqualTo("http://1.2.3.4:8080");
}
@Test
void extractProxyUrlFallsBackToPlainText() {
assertThat(service.extractProxyUrlFromBody("1.2.3.4:8080")).isEqualTo("http://1.2.3.4:8080");
}
@Test
void extractProxyUrlReturnsNullWhenNoIpPresent() {
assertThat(service.extractProxyUrlFromBody("{\"code\":0,\"data\":null}")).isNull();
assertThat(service.extractProxyUrlFromBody("")).isNull();
assertThat(service.extractProxyUrlFromBody(null)).isNull();
}
@Test
void insufficientBalanceDetectedByMessageOrCode() {
assertThat(service.isInsufficientBalance(
"{\"code\":-1,\"data\":null,\"status\":200,\"message\":\"余额不足\"}")).isTrue();
assertThat(service.isInsufficientBalance("余额不足")).isTrue();
assertThat(service.isInsufficientBalance(
"{\"data\":{\"list\":[{\"ip\":\"1.2.3.4\",\"port\":8080}]},\"code\":0}")).isFalse();
assertThat(service.isInsufficientBalance("")).isFalse();
}
} }