feat(紫鸟): 令牌级静态代理支持——白名单被拒的 key 走已授权代理 IP
- shop_key 新增 proxy_url / ip_whitelist_fail_count(V114 迁移) - HttpClientPool 支持按代理地址复用的静态代理客户端 - 白名单连续失败达阈值后停止自动重试,控制台可按 key 配置代理(改动自 上一工作阶段遗留,功能已编译验证)
This commit is contained in:
@@ -3,8 +3,15 @@ package com.nanri.aiimage.config;
|
||||
import org.springframework.http.client.ClientHttpRequestFactory;
|
||||
import org.springframework.http.client.JdkClientHttpRequestFactory;
|
||||
|
||||
import java.net.Authenticator;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.PasswordAuthentication;
|
||||
import java.net.ProxySelector;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* Task 77:外部 HTTP 客户端统一连接复用池。
|
||||
@@ -50,14 +57,78 @@ public class HttpClientPool {
|
||||
|
||||
/** 按 readTimeout(毫秒)创建共享连接池工厂;非法值钳制到最小正数。 */
|
||||
public static ClientHttpRequestFactory requestFactory(int readTimeoutMillis) {
|
||||
return requestFactory(readTimeoutMillis, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 readTimeout 创建请求工厂;proxyUrl 非空时改走该静态代理(用于出口 IP 需白名单的场景)。
|
||||
* 代理 HttpClient 按代理地址缓存复用,避免每次请求新建连接池。
|
||||
*/
|
||||
public static ClientHttpRequestFactory requestFactory(int readTimeoutMillis, String proxyUrl) {
|
||||
long safeReadTimeout = Math.max(1L, readTimeoutMillis);
|
||||
long callTimeout = configuredCallTimeoutMillis;
|
||||
if (callTimeout > 0L) {
|
||||
safeReadTimeout = Math.min(safeReadTimeout, callTimeout);
|
||||
}
|
||||
JdkClientHttpRequestFactory factory =
|
||||
new JdkClientHttpRequestFactory(sharedHttpClient());
|
||||
new JdkClientHttpRequestFactory(httpClientFor(proxyUrl));
|
||||
factory.setReadTimeout(Duration.ofMillis(safeReadTimeout));
|
||||
return factory;
|
||||
}
|
||||
|
||||
/** 解析代理地址并返回对应 HttpClient;地址为空/非法时回退共享直连客户端。 */
|
||||
private static HttpClient httpClientFor(String proxyUrl) {
|
||||
URI uri = parseProxyUri(proxyUrl);
|
||||
if (uri == null) {
|
||||
return sharedHttpClient();
|
||||
}
|
||||
ProxyEndpoint endpoint = new ProxyEndpoint(uri.getHost(), uri.getPort(), uri.getUserInfo());
|
||||
return PROXY_CLIENTS.computeIfAbsent(endpoint, HttpClientPool::buildProxyClient);
|
||||
}
|
||||
|
||||
private static HttpClient buildProxyClient(ProxyEndpoint endpoint) {
|
||||
HttpClient.Builder builder = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofMillis(10_000L))
|
||||
.version(HttpClient.Version.HTTP_1_1)
|
||||
.proxy(ProxySelector.of(new InetSocketAddress(endpoint.host(), endpoint.port())));
|
||||
if (endpoint.userInfo() != null && !endpoint.userInfo().isBlank()) {
|
||||
String[] parts = endpoint.userInfo().split(":", 2);
|
||||
String user = parts[0];
|
||||
char[] password = parts.length > 1 ? parts[1].toCharArray() : new char[0];
|
||||
builder.authenticator(new Authenticator() {
|
||||
@Override
|
||||
protected PasswordAuthentication getPasswordAuthentication() {
|
||||
return new PasswordAuthentication(user, password);
|
||||
}
|
||||
});
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 http(s)://[user:pass@]host:port;不合法返回 null(由调用方回退直连)。
|
||||
*/
|
||||
private static URI parseProxyUri(String proxyUrl) {
|
||||
if (proxyUrl == null || proxyUrl.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
URI uri = URI.create(proxyUrl.trim());
|
||||
if (uri.getHost() == null || uri.getHost().isBlank() || uri.getPort() <= 0) {
|
||||
return null;
|
||||
}
|
||||
String scheme = uri.getScheme();
|
||||
if (scheme != null && !scheme.equalsIgnoreCase("http") && !scheme.equalsIgnoreCase("https")) {
|
||||
return null;
|
||||
}
|
||||
return uri;
|
||||
} catch (Exception ex) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private record ProxyEndpoint(String host, int port, String userInfo) {
|
||||
}
|
||||
|
||||
private static final Map<ProxyEndpoint, HttpClient> PROXY_CLIENTS = new ConcurrentHashMap<>();
|
||||
}
|
||||
|
||||
+15
@@ -89,4 +89,19 @@ public class ShopKeyController {
|
||||
shopKeyService.delete(id);
|
||||
return ApiResponse.success("删除成功", null);
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/check-whitelist")
|
||||
@Operation(summary = "手动检测 IP 白名单",
|
||||
description = "绕过缓存真实请求一次紫鸟接口,重置自动重试次数。"
|
||||
+ "白名单放行需紫鸟公司侧授权,自动重试达上限后会停止重试,授权后可用此接口重新验证。")
|
||||
@ApiResponses({
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "检测完成", content = @Content(schema = @Schema(implementation = ShopKeyItemVo.class))),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "记录不存在")
|
||||
})
|
||||
public ApiResponse<ShopKeyItemVo> checkWhitelist(
|
||||
HttpServletRequest request,
|
||||
@Parameter(description = "主键ID", required = true) @PathVariable Long id) {
|
||||
adminAuthSupport.requireAdminOrInternal(request);
|
||||
return ApiResponse.success("检测完成", shopKeyService.checkWhitelist(id));
|
||||
}
|
||||
}
|
||||
|
||||
+5
@@ -22,4 +22,9 @@ public class ShopKeyCreateRequest {
|
||||
@Size(max = 512, message = "紫鸟令牌长度不能超过512个字符")
|
||||
@Schema(description = "紫鸟令牌", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String ziniaoToken;
|
||||
|
||||
@Size(max = 255, message = "代理地址长度不能超过255个字符")
|
||||
@Schema(description = "静态代理地址(http://host:port 或 http://user:pass@host:port),留空直连。"
|
||||
+ "该 key 因服务器出口 IP 不在紫鸟白名单被拒时配置,代理 IP 需已在紫鸟白名单内。")
|
||||
private String proxyUrl;
|
||||
}
|
||||
|
||||
+5
@@ -22,4 +22,9 @@ public class ShopKeyUpdateRequest {
|
||||
@Size(max = 512, message = "紫鸟令牌长度不能超过512个字符")
|
||||
@Schema(description = "紫鸟令牌", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String ziniaoToken;
|
||||
|
||||
@Size(max = 255, message = "代理地址长度不能超过255个字符")
|
||||
@Schema(description = "静态代理地址(http://host:port 或 http://user:pass@host:port),留空直连。"
|
||||
+ "该 key 因服务器出口 IP 不在紫鸟白名单被拒时配置,代理 IP 需已在紫鸟白名单内。")
|
||||
private String proxyUrl;
|
||||
}
|
||||
|
||||
+4
@@ -19,11 +19,15 @@ public class ShopKeyEntity {
|
||||
private String ziniaoAccountName;
|
||||
private String ziniaoToken;
|
||||
private String ziniaoTokenHash;
|
||||
/** 静态代理地址(http://host:port 或 http://user:pass@host:port);为空表示直连。 */
|
||||
private String proxyUrl;
|
||||
private String ipWhitelistStatus;
|
||||
@TableField(updateStrategy = FieldStrategy.ALWAYS)
|
||||
private LocalDateTime ipWhitelistCheckedAt;
|
||||
@TableField(updateStrategy = FieldStrategy.ALWAYS)
|
||||
private String ipWhitelistMessage;
|
||||
/** 连续白名单失败次数;达到阈值后刷新不再重试该 key。 */
|
||||
private Integer ipWhitelistFailCount;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
|
||||
+6
@@ -21,6 +21,9 @@ public class ShopKeyItemVo {
|
||||
@Schema(description = "紫鸟令牌")
|
||||
private String ziniaoToken;
|
||||
|
||||
@Schema(description = "静态代理地址;为空表示直连")
|
||||
private String proxyUrl;
|
||||
|
||||
@Schema(description = "IP 白名单状态:UNKNOWN、ALLOWED、BLOCKED")
|
||||
private String ipWhitelistStatus;
|
||||
|
||||
@@ -30,6 +33,9 @@ public class ShopKeyItemVo {
|
||||
@Schema(description = "最近一次 IP 白名单检测信息")
|
||||
private String ipWhitelistMessage;
|
||||
|
||||
@Schema(description = "连续白名单失败次数;达阈值后不再自动重试,需人工检测或改令牌/代理")
|
||||
private Integer ipWhitelistFailCount;
|
||||
|
||||
@Schema(description = "创建时间")
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
|
||||
+87
@@ -1,6 +1,7 @@
|
||||
package com.nanri.aiimage.modules.shopkey.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.modules.shopkey.mapper.ShopKeyMapper;
|
||||
import com.nanri.aiimage.modules.shopkey.model.dto.ShopKeyCreateRequest;
|
||||
@@ -8,6 +9,8 @@ import com.nanri.aiimage.modules.shopkey.model.dto.ShopKeyUpdateRequest;
|
||||
import com.nanri.aiimage.modules.shopkey.model.entity.ShopKeyEntity;
|
||||
import com.nanri.aiimage.modules.shopkey.model.vo.ShopKeyItemVo;
|
||||
import com.nanri.aiimage.modules.shopkey.model.vo.ShopKeyPageVo;
|
||||
import com.nanri.aiimage.modules.ziniao.service.ZiniaoApiKeyProvider;
|
||||
import com.nanri.aiimage.modules.ziniao.service.ZiniaoAuthService;
|
||||
import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopIndexService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -17,6 +20,7 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
@@ -28,6 +32,8 @@ public class ShopKeyService {
|
||||
|
||||
private final ShopKeyMapper shopKeyMapper;
|
||||
private final ZiniaoShopIndexService ziniaoShopIndexService;
|
||||
private final ZiniaoApiKeyProvider ziniaoApiKeyProvider;
|
||||
private final ZiniaoAuthService ziniaoAuthService;
|
||||
|
||||
public ShopKeyPageVo page(long page, long pageSize) {
|
||||
long safePage = Math.max(page, 1);
|
||||
@@ -58,8 +64,11 @@ public class ShopKeyService {
|
||||
entity.setZiniaoAccountName(ziniaoAccountName);
|
||||
entity.setZiniaoToken(ziniaoToken);
|
||||
entity.setZiniaoTokenHash(ziniaoTokenHash);
|
||||
entity.setProxyUrl(normalizeProxyUrl(request.getProxyUrl()));
|
||||
entity.setIpWhitelistStatus(IP_WHITELIST_STATUS_UNKNOWN);
|
||||
entity.setIpWhitelistFailCount(0);
|
||||
insertOrThrowDuplicateToken(entity);
|
||||
ziniaoApiKeyProvider.invalidateProxyUrlCache();
|
||||
triggerShopIndexRefresh();
|
||||
return toItemVo(getById(entity.getId()));
|
||||
}
|
||||
@@ -72,16 +81,27 @@ public class ShopKeyService {
|
||||
String ziniaoTokenHash = hashToken(ziniaoToken);
|
||||
ensureTokenAvailable(ziniaoTokenHash, id);
|
||||
boolean tokenChanged = !ziniaoTokenHash.equals(entity.getZiniaoTokenHash());
|
||||
String proxyUrl = normalizeProxyUrl(request.getProxyUrl());
|
||||
boolean proxyChanged = !java.util.Objects.equals(proxyUrl, entity.getProxyUrl());
|
||||
entity.setRemarkName(normalizeOptional(request.getRemarkName()));
|
||||
entity.setZiniaoAccountName(ziniaoAccountName);
|
||||
entity.setZiniaoToken(ziniaoToken);
|
||||
entity.setZiniaoTokenHash(ziniaoTokenHash);
|
||||
entity.setProxyUrl(proxyUrl);
|
||||
if (tokenChanged) {
|
||||
entity.setIpWhitelistStatus(IP_WHITELIST_STATUS_UNKNOWN);
|
||||
entity.setIpWhitelistCheckedAt(null);
|
||||
entity.setIpWhitelistMessage(null);
|
||||
entity.setIpWhitelistFailCount(0);
|
||||
} else if (proxyChanged) {
|
||||
// 换代理等于换出口 IP:白名单结论失效,重刷才能验证新代理是否已在白名单内
|
||||
entity.setIpWhitelistStatus(IP_WHITELIST_STATUS_UNKNOWN);
|
||||
entity.setIpWhitelistCheckedAt(null);
|
||||
entity.setIpWhitelistMessage(null);
|
||||
entity.setIpWhitelistFailCount(0);
|
||||
}
|
||||
updateOrThrowDuplicateToken(entity);
|
||||
ziniaoApiKeyProvider.invalidateProxyUrlCache();
|
||||
triggerShopIndexRefresh();
|
||||
return toItemVo(getById(id));
|
||||
}
|
||||
@@ -90,9 +110,47 @@ public class ShopKeyService {
|
||||
public void delete(Long id) {
|
||||
ShopKeyEntity entity = getById(id);
|
||||
shopKeyMapper.deleteById(entity.getId());
|
||||
ziniaoApiKeyProvider.invalidateProxyUrlCache();
|
||||
triggerShopIndexRefresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台手动检测白名单:绕过缓存真实打一次紫鸟接口,并重置自动重试次数。
|
||||
* 白名单放行需紫鸟公司侧授权,自动重试达阈值后会停试,此按钮供授权后重新验证。
|
||||
*/
|
||||
@Transactional
|
||||
public ShopKeyItemVo checkWhitelist(Long id) {
|
||||
ShopKeyEntity entity = getById(id);
|
||||
ziniaoApiKeyProvider.invalidateProxyUrlCache();
|
||||
try {
|
||||
ziniaoAuthService.probeApiKeyUpstream(entity.getZiniaoToken());
|
||||
updateWhitelistState(id, ZiniaoApiKeyProvider.IP_WHITELIST_STATUS_ALLOWED, "手动检测通过", 0);
|
||||
log.info("[shop-key] manual whitelist check passed id={} accountName={}", id, entity.getZiniaoAccountName());
|
||||
} catch (BusinessException ex) {
|
||||
if (ziniaoAuthService.isIpWhitelistError(ex)) {
|
||||
// 人工检测确认为白名单受阻:直接置为已耗尽,避免自动轮次再空跑三次
|
||||
updateWhitelistState(id, ZiniaoApiKeyProvider.IP_WHITELIST_STATUS_BLOCKED, ex.getMessage(),
|
||||
ZiniaoApiKeyProvider.IP_WHITELIST_MAX_FAIL_COUNT);
|
||||
log.warn("[shop-key] manual whitelist check blocked id={} accountName={} msg={}",
|
||||
id, entity.getZiniaoAccountName(), ex.getMessage());
|
||||
} else {
|
||||
updateWhitelistState(id, ZiniaoApiKeyProvider.IP_WHITELIST_STATUS_UNKNOWN, ex.getMessage(), 0);
|
||||
log.warn("[shop-key] manual whitelist check failed id={} accountName={} msg={}",
|
||||
id, entity.getZiniaoAccountName(), ex.getMessage());
|
||||
}
|
||||
}
|
||||
return toItemVo(getById(id));
|
||||
}
|
||||
|
||||
private void updateWhitelistState(Long id, String status, String message, int failCount) {
|
||||
shopKeyMapper.update(null, new LambdaUpdateWrapper<ShopKeyEntity>()
|
||||
.eq(ShopKeyEntity::getId, id)
|
||||
.set(ShopKeyEntity::getIpWhitelistStatus, status)
|
||||
.set(ShopKeyEntity::getIpWhitelistCheckedAt, LocalDateTime.now())
|
||||
.set(ShopKeyEntity::getIpWhitelistMessage, message)
|
||||
.set(ShopKeyEntity::getIpWhitelistFailCount, failCount));
|
||||
}
|
||||
|
||||
private ShopKeyEntity getById(Long id) {
|
||||
ShopKeyEntity entity = shopKeyMapper.selectById(id);
|
||||
if (entity == null) {
|
||||
@@ -113,15 +171,44 @@ public class ShopKeyService {
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验并规范化代理地址;空串表示直连。仅支持 http/https 代理。
|
||||
*/
|
||||
private String normalizeProxyUrl(String value) {
|
||||
String normalized = value == null ? "" : value.trim();
|
||||
if (normalized.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
java.net.URI uri;
|
||||
try {
|
||||
uri = java.net.URI.create(normalized);
|
||||
} catch (Exception ex) {
|
||||
throw new BusinessException("代理地址格式不合法,应形如 http://host:port");
|
||||
}
|
||||
String scheme = uri.getScheme();
|
||||
if (scheme == null || !(scheme.equalsIgnoreCase("http") || scheme.equalsIgnoreCase("https"))) {
|
||||
throw new BusinessException("代理地址仅支持 http/https 协议");
|
||||
}
|
||||
if (uri.getHost() == null || uri.getHost().isBlank()) {
|
||||
throw new BusinessException("代理地址缺少主机名,应形如 http://host:port");
|
||||
}
|
||||
if (uri.getPort() <= 0) {
|
||||
throw new BusinessException("代理地址缺少端口,应形如 http://host:port");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private ShopKeyItemVo toItemVo(ShopKeyEntity entity) {
|
||||
ShopKeyItemVo vo = new ShopKeyItemVo();
|
||||
vo.setId(entity.getId());
|
||||
vo.setRemarkName(entity.getRemarkName());
|
||||
vo.setZiniaoAccountName(entity.getZiniaoAccountName());
|
||||
vo.setZiniaoToken(entity.getZiniaoToken());
|
||||
vo.setProxyUrl(entity.getProxyUrl());
|
||||
vo.setIpWhitelistStatus(entity.getIpWhitelistStatus());
|
||||
vo.setIpWhitelistCheckedAt(entity.getIpWhitelistCheckedAt());
|
||||
vo.setIpWhitelistMessage(entity.getIpWhitelistMessage());
|
||||
vo.setIpWhitelistFailCount(entity.getIpWhitelistFailCount());
|
||||
vo.setCreatedAt(entity.getCreatedAt());
|
||||
vo.setUpdatedAt(entity.getUpdatedAt());
|
||||
return vo;
|
||||
|
||||
+56
-9
@@ -26,24 +26,42 @@ public class ZiniaoClientImpl implements ZiniaoClient {
|
||||
private final ZiniaoProperties ziniaoProperties;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final com.nanri.aiimage.metrics.ExternalCallMetricsRecorder externalCallMetrics;
|
||||
/** 可为 null(测试/未配置代理时直连)。 */
|
||||
private final ZiniaoProxyResolver proxyResolver;
|
||||
|
||||
/** 生产构造:双构造器下 Spring 不会自动推断,必须显式 @Autowired。 */
|
||||
@Autowired
|
||||
public ZiniaoClientImpl(ZiniaoProperties ziniaoProperties, ObjectMapper objectMapper,
|
||||
ZiniaoProxyResolver proxyResolver) {
|
||||
this(ziniaoProperties, objectMapper, null, proxyResolver);
|
||||
}
|
||||
|
||||
public ZiniaoClientImpl(ZiniaoProperties ziniaoProperties, ObjectMapper objectMapper) {
|
||||
this(ziniaoProperties, objectMapper, null);
|
||||
this(ziniaoProperties, objectMapper, null, null);
|
||||
}
|
||||
|
||||
public ZiniaoClientImpl(ZiniaoProperties ziniaoProperties,
|
||||
ObjectMapper objectMapper,
|
||||
com.nanri.aiimage.metrics.ExternalCallMetricsRecorder externalCallMetrics) {
|
||||
this(ziniaoProperties, objectMapper, externalCallMetrics, null);
|
||||
}
|
||||
|
||||
public ZiniaoClientImpl(ZiniaoProperties ziniaoProperties,
|
||||
ObjectMapper objectMapper,
|
||||
com.nanri.aiimage.metrics.ExternalCallMetricsRecorder externalCallMetrics,
|
||||
ZiniaoProxyResolver proxyResolver) {
|
||||
this.ziniaoProperties = ziniaoProperties;
|
||||
this.objectMapper = objectMapper;
|
||||
this.externalCallMetrics = externalCallMetrics;
|
||||
this.proxyResolver = proxyResolver;
|
||||
}
|
||||
|
||||
/** Task 77:单例 RestClient(共享连接池),避免每次调用新建短命客户端。 */
|
||||
private volatile RestClient sharedRestClient;
|
||||
|
||||
/** 按代理地址缓存 RestClient:同一代理复用同一连接池,避免每请求新建。 */
|
||||
private final Map<String, RestClient> proxyRestClients = new java.util.concurrent.ConcurrentHashMap<>();
|
||||
|
||||
@Override
|
||||
public Long getCompanyIdByApiKey(String apiKey) {
|
||||
String raw = getWithApiKey(apiKey, "/app/builtin/company", "获取 companyId");
|
||||
@@ -145,7 +163,7 @@ public class ZiniaoClientImpl implements ZiniaoClient {
|
||||
|
||||
|
||||
private String getWithApiKey(String apiKey, String path, String action) {
|
||||
String raw = getRestClient().get()
|
||||
String raw = restClientFor(apiKey).get()
|
||||
.uri(joinUrl(ziniaoProperties.getBaseUrl(), path))
|
||||
.headers(headers -> applyAuthorization(headers, apiKey))
|
||||
.retrieve()
|
||||
@@ -158,7 +176,7 @@ public class ZiniaoClientImpl implements ZiniaoClient {
|
||||
}
|
||||
|
||||
private String postWithApiKey(String apiKey, String path, Map<String, Object> body, String action) {
|
||||
RestClient.RequestBodySpec request = getRestClient().post()
|
||||
RestClient.RequestBodySpec request = restClientFor(apiKey).post()
|
||||
.uri(joinUrl(ziniaoProperties.getBaseUrl(), path))
|
||||
.headers(headers -> {
|
||||
applyAuthorization(headers, apiKey);
|
||||
@@ -271,17 +289,46 @@ public class ZiniaoClientImpl implements ZiniaoClient {
|
||||
}
|
||||
synchronized (this) {
|
||||
if (sharedRestClient == null) {
|
||||
RestClient.Builder builder = RestClient.builder()
|
||||
.requestFactory(HttpClientPool.requestFactory(ziniaoProperties.getReadTimeoutSeconds() * 1000));
|
||||
if (externalCallMetrics != null) {
|
||||
builder.requestInterceptor(externalCallMetrics.interceptor("ziniao"));
|
||||
}
|
||||
sharedRestClient = builder.build();
|
||||
sharedRestClient = buildRestClient(null);
|
||||
}
|
||||
return sharedRestClient;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按该 apiKey 配置的静态代理返回客户端;未配置代理时回退共享直连客户端。
|
||||
* 代理 RestClient 按代理地址缓存,避免同一代理反复新建连接池。
|
||||
*/
|
||||
private RestClient restClientFor(String apiKey) {
|
||||
String proxyUrl = resolveProxyUrl(apiKey);
|
||||
if (proxyUrl == null || proxyUrl.isBlank()) {
|
||||
return getRestClient();
|
||||
}
|
||||
return proxyRestClients.computeIfAbsent(proxyUrl, this::buildRestClient);
|
||||
}
|
||||
|
||||
private String resolveProxyUrl(String apiKey) {
|
||||
if (proxyResolver == null || apiKey == null || apiKey.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return proxyResolver.resolveProxyUrl(apiKey);
|
||||
} catch (Exception ex) {
|
||||
// 代理解析失败不应阻断请求:回退直连,由上游按白名单失败处理
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private RestClient buildRestClient(String proxyUrl) {
|
||||
RestClient.Builder builder = RestClient.builder()
|
||||
.requestFactory(HttpClientPool.requestFactory(
|
||||
ziniaoProperties.getReadTimeoutSeconds() * 1000, proxyUrl));
|
||||
if (externalCallMetrics != null) {
|
||||
builder.requestInterceptor(externalCallMetrics.interceptor("ziniao"));
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
/** 反射/测试可见:与 getRestClient 同一单例。 */
|
||||
RestClient restClient() {
|
||||
return getRestClient();
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package com.nanri.aiimage.modules.ziniao.client;
|
||||
|
||||
/**
|
||||
* 按紫鸟 apiKey 解析该 key 应使用的静态代理地址。
|
||||
* 实现方需自行做短时缓存,客户端会在每次请求前调用。
|
||||
*/
|
||||
public interface ZiniaoProxyResolver {
|
||||
|
||||
/**
|
||||
* @return 代理地址(http://host:port 或 http://user:pass@host:port);无代理返回 null 表示直连
|
||||
*/
|
||||
String resolveProxyUrl(String apiKey);
|
||||
}
|
||||
+114
-10
@@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.modules.shopkey.mapper.ShopKeyMapper;
|
||||
import com.nanri.aiimage.modules.shopkey.model.entity.ShopKeyEntity;
|
||||
import com.nanri.aiimage.modules.ziniao.client.ZiniaoProxyResolver;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@@ -16,13 +17,25 @@ import java.util.Map;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ZiniaoApiKeyProvider {
|
||||
public class ZiniaoApiKeyProvider implements ZiniaoProxyResolver {
|
||||
|
||||
public static final String IP_WHITELIST_STATUS_ALLOWED = "ALLOWED";
|
||||
public static final String IP_WHITELIST_STATUS_BLOCKED = "BLOCKED";
|
||||
public static final String IP_WHITELIST_STATUS_UNKNOWN = "UNKNOWN";
|
||||
|
||||
/**
|
||||
* 白名单放行需紫鸟公司侧授权,反复重试只是白白拖慢每轮刷新。
|
||||
* 连续失败达到该次数后不再自动重试,需人工点击检测或改令牌/代理。
|
||||
*/
|
||||
public static final int IP_WHITELIST_MAX_FAIL_COUNT = 3;
|
||||
|
||||
private final ShopKeyMapper shopKeyMapper;
|
||||
|
||||
private static final long PROXY_URL_CACHE_MILLIS = 60_000L;
|
||||
|
||||
private volatile Map<String, String> cachedProxyUrls;
|
||||
private volatile long proxyUrlCacheLoadedAt;
|
||||
|
||||
public List<ApiKeyAccount> listApiKeyAccounts() {
|
||||
List<ShopKeyEntity> entities = shopKeyMapper.selectList(new LambdaQueryWrapper<ShopKeyEntity>()
|
||||
.orderByDesc(ShopKeyEntity::getId));
|
||||
@@ -38,11 +51,68 @@ public class ZiniaoApiKeyProvider {
|
||||
.map(entry -> new ApiKeyAccount(
|
||||
entry.getKey(),
|
||||
resolveAccountName(entry.getValue()),
|
||||
entry.getValue().stream().map(ShopKeyEntity::getId).filter(java.util.Objects::nonNull).toList()
|
||||
entry.getValue().stream().map(ShopKeyEntity::getId).filter(java.util.Objects::nonNull).toList(),
|
||||
resolveProxyUrl(entry.getValue()),
|
||||
resolveIpWhitelistFailCount(entry.getValue())
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 客户端每次请求都会问一次代理地址,直查 DB 太重;这里缓存 60 秒快照。
|
||||
*/
|
||||
@Override
|
||||
public String resolveProxyUrl(String apiKey) {
|
||||
return proxyUrlCache().get(apiKey);
|
||||
}
|
||||
|
||||
private Map<String, String> proxyUrlCache() {
|
||||
long now = System.currentTimeMillis();
|
||||
Map<String, String> snapshot = cachedProxyUrls;
|
||||
if (snapshot != null && now - proxyUrlCacheLoadedAt < PROXY_URL_CACHE_MILLIS) {
|
||||
return snapshot;
|
||||
}
|
||||
synchronized (this) {
|
||||
if (cachedProxyUrls != null && System.currentTimeMillis() - proxyUrlCacheLoadedAt < PROXY_URL_CACHE_MILLIS) {
|
||||
return cachedProxyUrls;
|
||||
}
|
||||
Map<String, String> fresh = new LinkedHashMap<>();
|
||||
for (ApiKeyAccount account : listApiKeyAccounts()) {
|
||||
if (account.proxyUrl() != null && !account.proxyUrl().isBlank()) {
|
||||
fresh.put(account.apiKey(), account.proxyUrl());
|
||||
}
|
||||
}
|
||||
cachedProxyUrls = fresh;
|
||||
proxyUrlCacheLoadedAt = System.currentTimeMillis();
|
||||
return fresh;
|
||||
}
|
||||
}
|
||||
|
||||
/** 令牌或代理变更后立即失效缓存,避免后台改完仍按旧代理发请求。 */
|
||||
public void invalidateProxyUrlCache() {
|
||||
synchronized (this) {
|
||||
cachedProxyUrls = null;
|
||||
proxyUrlCacheLoadedAt = 0L;
|
||||
}
|
||||
}
|
||||
|
||||
private String resolveProxyUrl(List<ShopKeyEntity> entities) {
|
||||
return entities.stream()
|
||||
.map(ShopKeyEntity::getProxyUrl)
|
||||
.filter(proxy -> proxy != null && !proxy.isBlank())
|
||||
.map(String::trim)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
private Integer resolveIpWhitelistFailCount(List<ShopKeyEntity> entities) {
|
||||
return entities.stream()
|
||||
.map(ShopKeyEntity::getIpWhitelistFailCount)
|
||||
.filter(count -> count != null)
|
||||
.max(Integer::compareTo)
|
||||
.orElse(0);
|
||||
}
|
||||
|
||||
public List<String> listApiKeys() {
|
||||
return listApiKeyAccounts().stream()
|
||||
.map(ApiKeyAccount::apiKey)
|
||||
@@ -63,22 +133,50 @@ public class ZiniaoApiKeyProvider {
|
||||
}
|
||||
|
||||
public void markIpWhitelistAllowed(ApiKeyAccount account) {
|
||||
updateIpWhitelistStatus(account, IP_WHITELIST_STATUS_ALLOWED, null);
|
||||
updateIpWhitelistStatus(account, IP_WHITELIST_STATUS_ALLOWED, null, 0);
|
||||
}
|
||||
|
||||
public void markIpWhitelistBlocked(ApiKeyAccount account, String message) {
|
||||
updateIpWhitelistStatus(account, IP_WHITELIST_STATUS_BLOCKED, message);
|
||||
/**
|
||||
* 记录一次白名单失败:累加连续失败次数,仅在首次达阈值时写库(避免每轮重复更新)。
|
||||
*
|
||||
* @return true 表示本次为该 key 首次达到阈值(调用方据此打一次日志)
|
||||
*/
|
||||
public boolean markIpWhitelistBlocked(ApiKeyAccount account, String message) {
|
||||
if (account == null || account.shopKeyIds().isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
int currentCount = account.ipWhitelistFailCount() == null ? 0 : account.ipWhitelistFailCount();
|
||||
int nextCount = currentCount + 1;
|
||||
boolean reachedThresholdNow = currentCount < IP_WHITELIST_MAX_FAIL_COUNT
|
||||
&& nextCount >= IP_WHITELIST_MAX_FAIL_COUNT;
|
||||
if (currentCount >= IP_WHITELIST_MAX_FAIL_COUNT) {
|
||||
// 已达阈值:只刷新检测时间与最新消息,不再累计,也不再触发重试
|
||||
updateIpWhitelistStatus(account, IP_WHITELIST_STATUS_BLOCKED, message, null);
|
||||
return false;
|
||||
}
|
||||
updateIpWhitelistStatus(account, IP_WHITELIST_STATUS_BLOCKED, message, nextCount);
|
||||
return reachedThresholdNow;
|
||||
}
|
||||
|
||||
private void updateIpWhitelistStatus(ApiKeyAccount account, String status, String message) {
|
||||
/** 该 key 是否已连续失败达阈值、应跳过自动重试。 */
|
||||
public boolean isIpWhitelistRetryExhausted(ApiKeyAccount account) {
|
||||
int count = account == null || account.ipWhitelistFailCount() == null ? 0 : account.ipWhitelistFailCount();
|
||||
return count >= IP_WHITELIST_MAX_FAIL_COUNT;
|
||||
}
|
||||
|
||||
private void updateIpWhitelistStatus(ApiKeyAccount account, String status, String message, Integer failCount) {
|
||||
if (account == null || account.shopKeyIds().isEmpty()) {
|
||||
return;
|
||||
}
|
||||
shopKeyMapper.update(null, new LambdaUpdateWrapper<ShopKeyEntity>()
|
||||
LambdaUpdateWrapper<ShopKeyEntity> update = new LambdaUpdateWrapper<ShopKeyEntity>()
|
||||
.in(ShopKeyEntity::getId, account.shopKeyIds())
|
||||
.set(ShopKeyEntity::getIpWhitelistStatus, status)
|
||||
.set(ShopKeyEntity::getIpWhitelistCheckedAt, LocalDateTime.now())
|
||||
.set(ShopKeyEntity::getIpWhitelistMessage, truncateMessage(message)));
|
||||
.set(ShopKeyEntity::getIpWhitelistMessage, truncateMessage(message));
|
||||
if (failCount != null) {
|
||||
update.set(ShopKeyEntity::getIpWhitelistFailCount, failCount);
|
||||
}
|
||||
shopKeyMapper.update(null, update);
|
||||
}
|
||||
|
||||
private String resolveAccountName(List<ShopKeyEntity> entities) {
|
||||
@@ -109,14 +207,20 @@ public class ZiniaoApiKeyProvider {
|
||||
return normalized.isBlank() ? null : normalized;
|
||||
}
|
||||
|
||||
public record ApiKeyAccount(String apiKey, String accountName, List<Long> shopKeyIds) {
|
||||
public record ApiKeyAccount(String apiKey, String accountName, List<Long> shopKeyIds,
|
||||
String proxyUrl, Integer ipWhitelistFailCount) {
|
||||
|
||||
public ApiKeyAccount {
|
||||
shopKeyIds = shopKeyIds == null ? List.of() : List.copyOf(shopKeyIds);
|
||||
ipWhitelistFailCount = ipWhitelistFailCount == null ? 0 : ipWhitelistFailCount;
|
||||
}
|
||||
|
||||
public ApiKeyAccount(String apiKey, String accountName, List<Long> shopKeyIds) {
|
||||
this(apiKey, accountName, shopKeyIds, null, 0);
|
||||
}
|
||||
|
||||
public ApiKeyAccount(String apiKey, String accountName) {
|
||||
this(apiKey, accountName, List.of());
|
||||
this(apiKey, accountName, List.of(), null, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+15
@@ -136,6 +136,21 @@ public class ZiniaoAuthService {
|
||||
return resolveCompanyId(apiKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台手动检测专用:绕过本地缓存与固定 companyId 配置,真实打一次紫鸟接口,
|
||||
* 否则命中缓存会把已失效的白名单状态误判为可用。
|
||||
* 成功时顺带刷新 COMPANY_ID 缓存。
|
||||
*
|
||||
* @throws BusinessException 上游失败(白名单/令牌无效等)时抛出,错误信息透传给后台展示
|
||||
*/
|
||||
public Long probeApiKeyUpstream(String apiKey) {
|
||||
String normalizedApiKey = requireText(apiKey, "紫鸟 apiKey 未配置");
|
||||
Long companyId = ziniaoClient.getCompanyIdByApiKey(normalizedApiKey);
|
||||
ziniaoTransientCacheService.put(CACHE_TYPE_COMPANY_ID, buildApiKeyHash(normalizedApiKey), companyId, COMPANY_ID_CACHE_TTL);
|
||||
log.info("[ziniao-whitelist-probe] upstream ok keyHash={} companyId={}", shortKeyHash(normalizedApiKey), companyId);
|
||||
return companyId;
|
||||
}
|
||||
|
||||
public List<ZiniaoStaffItemVo> getOrLoadStaffForIndex(String apiKey, Long companyId) {
|
||||
return getOrLoadStaff(apiKey, companyId);
|
||||
}
|
||||
|
||||
+16
-2
@@ -217,6 +217,15 @@ public class ZiniaoShopIndexService {
|
||||
}
|
||||
String apiKey = apiKeyAccount.apiKey();
|
||||
String companyName = apiKeyAccount.accountName();
|
||||
// 白名单放行需紫鸟公司侧授权:连续失败达阈值后不再自动重试,省掉每轮无谓的失败等待
|
||||
if (ziniaoApiKeyProvider.isIpWhitelistRetryExhausted(apiKeyAccount)) {
|
||||
skippedApiKeyCount++;
|
||||
whitelistSkippedApiKeyCount++;
|
||||
completeCoverage = false;
|
||||
log.info("[ziniao-index] skip apiKey reason=IP_WHITELIST_RETRY_EXHAUSTED accountName={} failCount={}",
|
||||
companyName, apiKeyAccount.ipWhitelistFailCount());
|
||||
continue;
|
||||
}
|
||||
Map<String, List<ZiniaoShopIndexEntryDto>> apiKeyGrouped = new LinkedHashMap<>();
|
||||
Map<String, Long> apiKeyFingerprints = new LinkedHashMap<>();
|
||||
Long companyId;
|
||||
@@ -459,10 +468,15 @@ public class ZiniaoShopIndexService {
|
||||
|
||||
private void markIpWhitelistBlockedSafely(ZiniaoApiKeyProvider.ApiKeyAccount account, String message) {
|
||||
try {
|
||||
ziniaoApiKeyProvider.markIpWhitelistBlocked(account, message);
|
||||
boolean exhaustedNow = ziniaoApiKeyProvider.markIpWhitelistBlocked(account, message);
|
||||
if (exhaustedNow) {
|
||||
log.warn("[ziniao-index] IP whitelist retry exhausted accountName={} maxFailCount={} 后续轮次不再重试,需紫鸟公司授权后人工检测",
|
||||
account == null ? null : account.accountName(),
|
||||
ZiniaoApiKeyProvider.IP_WHITELIST_MAX_FAIL_COUNT);
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
log.warn("[ziniao-index] failed to record IP whitelist status accountName={} status=BLOCKED msg={}",
|
||||
account.accountName(), ex.getMessage());
|
||||
account == null ? null : account.accountName(), ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
-- 紫鸟令牌支持按 key 配置静态代理:部分 key 因服务器出口 IP 不在紫鸟白名单被拒,
|
||||
-- 配置一个已在紫鸟白名单内的固定代理 IP 后,该 key 的请求改经代理发出。
|
||||
ALTER TABLE biz_shop_key
|
||||
ADD COLUMN proxy_url VARCHAR(255) NULL COMMENT '静态代理地址(http://host:port 或 http://user:pass@host:port);为空则直连'
|
||||
AFTER ziniao_token_hash;
|
||||
|
||||
-- 白名单放行需紫鸟公司侧授权,反复重试无意义且拖慢每轮刷新。
|
||||
-- 记录连续失败次数,达到阈值后停止重试(改令牌/改代理/人工重置才清零重试)。
|
||||
ALTER TABLE biz_shop_key
|
||||
ADD COLUMN ip_whitelist_fail_count INT NOT NULL DEFAULT 0 COMMENT '连续白名单失败次数,达阈值后不再重试'
|
||||
AFTER ip_whitelist_message;
|
||||
Reference in New Issue
Block a user