feat(品牌服务): 内部接口按 uid 供用户代理提取链接;代理检测改真实提取+转发探测

- 新增 /api/internal/user-proxy(X-Internal-Token 自校验,18080 公网可达必须校验),
  供主机 A 品牌检测服务(15126)按 uid 取用户代理提取链接,未配置返回空串
- 代理检测修复"假通过":旧实现把提取链接直接当静态代理交给 HttpClientPool,
  链接无显式端口解析失败静默回退直连,检测到的是自家站点直连响应(40 个用户
  3-39ms 全 301 假通过);现在先真实提取一次(余额不足/解析失败如实报),
  再经提取到的代理请求自家域名,兼容旧静态代理地址
This commit is contained in:
2026-09-13 21:21:48 +08:00
parent fe052879bd
commit 1066625078
4 changed files with 310 additions and 8 deletions
@@ -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.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)直接探测。
*
* <p>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");
@@ -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.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();
}
}