From 1066625078037a95fa919eff89abb559c482ae0a 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 21:21:48 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E5=93=81=E7=89=8C=E6=9C=8D=E5=8A=A1):=20?= =?UTF-8?q?=E5=86=85=E9=83=A8=E6=8E=A5=E5=8F=A3=E6=8C=89=20uid=20=E4=BE=9B?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E4=BB=A3=E7=90=86=E6=8F=90=E5=8F=96=E9=93=BE?= =?UTF-8?q?=E6=8E=A5=EF=BC=9B=E4=BB=A3=E7=90=86=E6=A3=80=E6=B5=8B=E6=94=B9?= =?UTF-8?q?=E7=9C=9F=E5=AE=9E=E6=8F=90=E5=8F=96+=E8=BD=AC=E5=8F=91?= =?UTF-8?q?=E6=8E=A2=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 /api/internal/user-proxy(X-Internal-Token 自校验,18080 公网可达必须校验), 供主机 A 品牌检测服务(15126)按 uid 取用户代理提取链接,未配置返回空串 - 代理检测修复"假通过":旧实现把提取链接直接当静态代理交给 HttpClientPool, 链接无显式端口解析失败静默回退直连,检测到的是自家站点直连响应(40 个用户 3-39ms 全 301 假通过);现在先真实提取一次(余额不足/解析失败如实报), 再经提取到的代理请求自家域名,兼容旧静态代理地址 --- .../InternalUserProxyController.java | 61 +++++++++ .../service/UserApiSecretCheckService.java | 129 ++++++++++++++++-- .../InternalUserProxyControllerTest.java | 76 +++++++++++ .../UserApiSecretCheckServiceTest.java | 52 +++++++ 4 files changed, 310 insertions(+), 8 deletions(-) create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/controller/InternalUserProxyController.java create mode 100644 backend-java/src/test/java/com/nanri/aiimage/modules/usersecret/controller/InternalUserProxyControllerTest.java diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/controller/InternalUserProxyController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/controller/InternalUserProxyController.java new file mode 100644 index 00000000..b71110f3 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/controller/InternalUserProxyController.java @@ -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)读取。 + * + *

鉴权:仅凭 X-Internal-Token(与容器 AIIMAGE_INTERNAL_TOKEN / 宿主机 + * ~/.aiimage/internal-token 同值)。/api/internal 前缀不在 AdminApiGuardFilter + * 的兜底名单内,controller 必须自校验令牌——18080 公网可达,漏校验即匿名泄露 + * 用户代理密钥。 + * + *

日志不落明文;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> 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 data = new HashMap<>(); + data.put("userId", userId); + data.put("proxyUrl", proxyUrl); + return ApiResponse.success(data); + } +} 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 e8948fd3..a3dd0aa9 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 @@ -17,11 +17,14 @@ import org.springframework.web.client.RestClient; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; +import java.net.URI; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; /** * 密钥连通性探测:调一次 LLM /v1/chat/completions,能访问通(2xx 且返回 choices)即通过。 @@ -58,6 +61,10 @@ public class UserApiSecretCheckService { /** 代理探测目标:自家域名(http 无 CONNECT 依赖,兼容各类转发型代理)。 */ 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 SimilarAsinProperties similarAsinProperties; private final JikipProxyClient jikipProxyClient; @@ -96,8 +103,14 @@ public class UserApiSecretCheckService { } /** - * 代理连通性探测:经由用户配置的代理请求一次自家域名。 - * 拿到任意 HTTP 响应(含 4xx/5xx)即说明代理转发可用;407 为代理自身认证失败;网络异常视为不可达。 + * 代理配置探测(按「提取链接」语义,与品牌检测的实际用法一致): + * 先 GET 用户链接真实提取一次——余额不足报余额不足、解析不出 IP 报配置错误; + * 提取到 IP(可含账密)后经该代理请求一次自家域名,拿到任意 HTTP 响应即转发可用。 + * 兼容纯静态代理地址(无 path/query 的 host:port)直接探测。 + * + *

2026-09-13 修复:旧实现把提取链接直接交给 HttpClientPool 当静态代理使用,链接无 + * 显式端口时解析失败会静默回退直连,探测到的是自家站点的直连响应——40 个用户全部 + * 「假通过」(3-39ms + HTTP 301),余额不足 / 链接失效都测不出来。 */ private CheckOutcome probeProxy(String proxyUrl) { String normalized = proxyUrl == null ? "" : proxyUrl.trim(); @@ -105,31 +118,131 @@ public class UserApiSecretCheckService { return new CheckOutcome(STATUS_FAILED, CODE_INVALID_KEY, "代理地址为空,请重新配置", null, true); } 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 { RestClient client = RestClient.builder() - .requestFactory(HttpClientPool.requestFactory(PROXY_READ_TIMEOUT_MILLIS, normalized)) + .requestFactory(HttpClientPool.requestFactory(PROXY_READ_TIMEOUT_MILLIS, proxyUrl)) .build(); int statusCode = client.get() .uri(PROXY_PROBE_TARGET_URL) .exchange((request, response) -> response.getStatusCode().value()); long latency = System.currentTimeMillis() - startMillis; if (statusCode == 407) { + log.warn("[user-secret][check] 代理认证失败(407) latency={}ms", latency); return new CheckOutcome(STATUS_FAILED, CODE_FORBIDDEN, "代理认证失败(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", statusCode, latency); - return outcome; + return new CheckOutcome(STATUS_PASSED, CODE_OK, + "代理可用(HTTP " + statusCode + ")", (int) latency, true); } catch (Exception ex) { 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, - "代理不可达:" + 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) { UserSecretModule.LlmTarget target = module.resolveLlmTarget(appearancePatentProperties, similarAsinProperties); String url = joinUrl(target.host(), "/v1/chat/completions"); diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/usersecret/controller/InternalUserProxyControllerTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/usersecret/controller/InternalUserProxyControllerTest.java new file mode 100644 index 00000000..e2ff32dd --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/usersecret/controller/InternalUserProxyControllerTest.java @@ -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> 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> 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()); + } +} 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 da93e2e1..98348312 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 @@ -136,4 +136,56 @@ class UserApiSecretCheckServiceTest { assertThat(outcome.status()).isEqualTo(UserApiSecretCheckService.STATUS_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(); + } }