feat(品牌服务): 内部接口按 uid 供用户代理提取链接;代理检测改真实提取+转发探测
- 新增 /api/internal/user-proxy(X-Internal-Token 自校验,18080 公网可达必须校验), 供主机 A 品牌检测服务(15126)按 uid 取用户代理提取链接,未配置返回空串 - 代理检测修复"假通过":旧实现把提取链接直接当静态代理交给 HttpClientPool, 链接无显式端口解析失败静默回退直连,检测到的是自家站点直连响应(40 个用户 3-39ms 全 301 假通过);现在先真实提取一次(余额不足/解析失败如实报), 再经提取到的代理请求自家域名,兼容旧静态代理地址
This commit is contained in:
+61
@@ -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);
|
||||
}
|
||||
}
|
||||
+121
-8
@@ -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");
|
||||
|
||||
+76
@@ -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());
|
||||
}
|
||||
}
|
||||
+52
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user