From 47459e20893655f4b003ff89c952d3aab3b36d5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E8=87=AA=E8=BE=BE?= <980324341@qq.com> Date: Fri, 11 Sep 2026 17:11:50 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E7=B4=AB=E9=B8=9F):=20=E4=BB=A4=E7=89=8C?= =?UTF-8?q?=E7=BA=A7=E9=9D=99=E6=80=81=E4=BB=A3=E7=90=86=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E2=80=94=E2=80=94=E7=99=BD=E5=90=8D=E5=8D=95=E8=A2=AB=E6=8B=92?= =?UTF-8?q?=E7=9A=84=20key=20=E8=B5=B0=E5=B7=B2=E6=8E=88=E6=9D=83=E4=BB=A3?= =?UTF-8?q?=E7=90=86=20IP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - shop_key 新增 proxy_url / ip_whitelist_fail_count(V114 迁移) - HttpClientPool 支持按代理地址复用的静态代理客户端 - 白名单连续失败达阈值后停止自动重试,控制台可按 key 配置代理(改动自 上一工作阶段遗留,功能已编译验证) --- .../nanri/aiimage/config/HttpClientPool.java | 73 ++++++++++- .../shopkey/controller/ShopKeyController.java | 15 +++ .../model/dto/ShopKeyCreateRequest.java | 5 + .../model/dto/ShopKeyUpdateRequest.java | 5 + .../shopkey/model/entity/ShopKeyEntity.java | 4 + .../shopkey/model/vo/ShopKeyItemVo.java | 6 + .../shopkey/service/ShopKeyService.java | 87 ++++++++++++ .../ziniao/client/ZiniaoClientImpl.java | 65 +++++++-- .../ziniao/client/ZiniaoProxyResolver.java | 13 ++ .../ziniao/service/ZiniaoApiKeyProvider.java | 124 ++++++++++++++++-- .../ziniao/service/ZiniaoAuthService.java | 15 +++ .../service/ZiniaoShopIndexService.java | 18 ++- .../resources/db/V114__shop_key_proxy_url.sql | 11 ++ 13 files changed, 419 insertions(+), 22 deletions(-) create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/client/ZiniaoProxyResolver.java create mode 100644 backend-java/src/main/resources/db/V114__shop_key_proxy_url.sql diff --git a/backend-java/src/main/java/com/nanri/aiimage/config/HttpClientPool.java b/backend-java/src/main/java/com/nanri/aiimage/config/HttpClientPool.java index 8b42a6e7..d1d7db09 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/config/HttpClientPool.java +++ b/backend-java/src/main/java/com/nanri/aiimage/config/HttpClientPool.java @@ -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 PROXY_CLIENTS = new ConcurrentHashMap<>(); } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/controller/ShopKeyController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/controller/ShopKeyController.java index 2bbc9411..23b6623c 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/controller/ShopKeyController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/controller/ShopKeyController.java @@ -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 checkWhitelist( + HttpServletRequest request, + @Parameter(description = "主键ID", required = true) @PathVariable Long id) { + adminAuthSupport.requireAdminOrInternal(request); + return ApiResponse.success("检测完成", shopKeyService.checkWhitelist(id)); + } } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/dto/ShopKeyCreateRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/dto/ShopKeyCreateRequest.java index f26c98fc..2ba55796 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/dto/ShopKeyCreateRequest.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/dto/ShopKeyCreateRequest.java @@ -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; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/dto/ShopKeyUpdateRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/dto/ShopKeyUpdateRequest.java index 0469d05a..fe6c5ebb 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/dto/ShopKeyUpdateRequest.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/dto/ShopKeyUpdateRequest.java @@ -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; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/entity/ShopKeyEntity.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/entity/ShopKeyEntity.java index 92aa9520..08fc1752 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/entity/ShopKeyEntity.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/entity/ShopKeyEntity.java @@ -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; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/vo/ShopKeyItemVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/vo/ShopKeyItemVo.java index cf47a167..80e9e5b5 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/vo/ShopKeyItemVo.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/vo/ShopKeyItemVo.java @@ -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; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/ShopKeyService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/ShopKeyService.java index 412d7df4..e63d3e9b 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/ShopKeyService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/ShopKeyService.java @@ -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() + .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; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/client/ZiniaoClientImpl.java b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/client/ZiniaoClientImpl.java index 0b220a49..5b252974 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/client/ZiniaoClientImpl.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/client/ZiniaoClientImpl.java @@ -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 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 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(); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/client/ZiniaoProxyResolver.java b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/client/ZiniaoProxyResolver.java new file mode 100644 index 00000000..e57a30ed --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/client/ZiniaoProxyResolver.java @@ -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); +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoApiKeyProvider.java b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoApiKeyProvider.java index 61497256..30813467 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoApiKeyProvider.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoApiKeyProvider.java @@ -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 cachedProxyUrls; + private volatile long proxyUrlCacheLoadedAt; + public List listApiKeyAccounts() { List entities = shopKeyMapper.selectList(new LambdaQueryWrapper() .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 proxyUrlCache() { + long now = System.currentTimeMillis(); + Map 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 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 entities) { + return entities.stream() + .map(ShopKeyEntity::getProxyUrl) + .filter(proxy -> proxy != null && !proxy.isBlank()) + .map(String::trim) + .findFirst() + .orElse(null); + } + + private Integer resolveIpWhitelistFailCount(List entities) { + return entities.stream() + .map(ShopKeyEntity::getIpWhitelistFailCount) + .filter(count -> count != null) + .max(Integer::compareTo) + .orElse(0); + } + public List 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() + LambdaUpdateWrapper update = new LambdaUpdateWrapper() .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 entities) { @@ -109,14 +207,20 @@ public class ZiniaoApiKeyProvider { return normalized.isBlank() ? null : normalized; } - public record ApiKeyAccount(String apiKey, String accountName, List shopKeyIds) { + public record ApiKeyAccount(String apiKey, String accountName, List 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 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); } } } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoAuthService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoAuthService.java index 00e1b767..f2e6a203 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoAuthService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoAuthService.java @@ -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 getOrLoadStaffForIndex(String apiKey, Long companyId) { return getOrLoadStaff(apiKey, companyId); } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoShopIndexService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoShopIndexService.java index 2bfb54b9..80f9b237 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoShopIndexService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoShopIndexService.java @@ -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> apiKeyGrouped = new LinkedHashMap<>(); Map 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()); } } diff --git a/backend-java/src/main/resources/db/V114__shop_key_proxy_url.sql b/backend-java/src/main/resources/db/V114__shop_key_proxy_url.sql new file mode 100644 index 00000000..3f2d2aba --- /dev/null +++ b/backend-java/src/main/resources/db/V114__shop_key_proxy_url.sql @@ -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;