Compare commits

...

2 Commits

Author SHA1 Message Date
huangzd1997 47459e2089 feat(紫鸟): 令牌级静态代理支持——白名单被拒的 key 走已授权代理 IP
- shop_key 新增 proxy_url / ip_whitelist_fail_count(V114 迁移)
- HttpClientPool 支持按代理地址复用的静态代理客户端
- 白名单连续失败达阈值后停止自动重试,控制台可按 key 配置代理(改动自
  上一工作阶段遗留,功能已编译验证)
2026-09-11 17:11:50 +08:00
huangzd1997 e6021593ea fix(安全/健壮性): 全工作区审查修复——鉴权兜底扩展+路径穿越+忙等+泄漏
- AdminApiGuardFilter 兜底扩展到 /api/collect-data、/api/price-track:无需鉴权的
  工具接口纳入 JWT/内部令牌校验(原匿名可达即越权读写他人数据)
- pricetrack asinFiles 改为仅允许上传临时目录内文件(canonical 前缀校验),
  修复请求路径直接 new File 可读服务器任意 csv/xlsx 的穿越
- dedupe 删除导入逐行 REQUIRES_NEW 事务改 500 条一批 IN 删除,50 万行导入
  由 50 万个事务收敛为千级
- 前端记住密码 XOR 硬编码密钥改 WebCrypto AES-GCM(密钥随机生成独立存储),
  登录流程接口改 async 并保证自动登录恢复时序
- 任务进度轮询失败按指数退避(原固定 5s 无限撞);下载进度终态条目 2 分钟
  自动清理(原永久堆积);AmazonConsolePage statusTimer 卸载清理
2026-09-11 17:11:43 +08:00
21 changed files with 592 additions and 63 deletions
@@ -51,6 +51,16 @@ public class AdminApiGuardFilter extends OncePerRequestFilter {
/** 调试端点前缀:无方法级鉴权的诊断/运维入口,同样纳入兜底(2026-09-11)。 */ /** 调试端点前缀:无方法级鉴权的诊断/运维入口,同样纳入兜底(2026-09-11)。 */
private static final String DEBUG_PREFIX = "/debug"; private static final String DEBUG_PREFIX = "/debug";
/**
* 用户态工具接口前缀:controller 无方法级鉴权(归属由请求参数 user_id 判定),
* 匿名可达即越权读写他人数据。纳入兜底要求 JWT 或可信内部令牌(2026-09-12)。
* 桌面 Python 直连调用已同步携带 X-Internal-Token。
*/
private static final String[] USER_TOOL_PREFIXES = {
"/api/collect-data",
"/api/price-track",
};
private final AdminAuthSupport adminAuthSupport; private final AdminAuthSupport adminAuthSupport;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
@@ -110,9 +120,17 @@ public class AdminApiGuardFilter extends OncePerRequestFilter {
chain.doFilter(request, response); chain.doFilter(request, response);
} }
/** 命中受保护前缀(/api/admin、/debug 及其子路径)才进入鉴权,其余请求直接放行。 */ /** 命中受保护前缀(/api/admin、/debug、用户态工具前缀及其子路径)才进入鉴权,其余请求直接放行。 */
private static boolean isGuarded(String uri) { private static boolean isGuarded(String uri) {
return matchesPrefix(uri, ADMIN_API_PREFIX) || matchesPrefix(uri, DEBUG_PREFIX); if (matchesPrefix(uri, ADMIN_API_PREFIX) || matchesPrefix(uri, DEBUG_PREFIX)) {
return true;
}
for (String prefix : USER_TOOL_PREFIXES) {
if (matchesPrefix(uri, prefix)) {
return true;
}
}
return false;
} }
private static boolean matchesPrefix(String uri, String prefix) { private static boolean matchesPrefix(String uri, String prefix) {
@@ -3,8 +3,15 @@ package com.nanri.aiimage.config;
import org.springframework.http.client.ClientHttpRequestFactory; import org.springframework.http.client.ClientHttpRequestFactory;
import org.springframework.http.client.JdkClientHttpRequestFactory; 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.net.http.HttpClient;
import java.time.Duration; import java.time.Duration;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/** /**
* Task 77:外部 HTTP 客户端统一连接复用池。 * Task 77:外部 HTTP 客户端统一连接复用池。
@@ -50,14 +57,78 @@ public class HttpClientPool {
/** 按 readTimeout(毫秒)创建共享连接池工厂;非法值钳制到最小正数。 */ /** 按 readTimeout(毫秒)创建共享连接池工厂;非法值钳制到最小正数。 */
public static ClientHttpRequestFactory requestFactory(int readTimeoutMillis) { 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 safeReadTimeout = Math.max(1L, readTimeoutMillis);
long callTimeout = configuredCallTimeoutMillis; long callTimeout = configuredCallTimeoutMillis;
if (callTimeout > 0L) { if (callTimeout > 0L) {
safeReadTimeout = Math.min(safeReadTimeout, callTimeout); safeReadTimeout = Math.min(safeReadTimeout, callTimeout);
} }
JdkClientHttpRequestFactory factory = JdkClientHttpRequestFactory factory =
new JdkClientHttpRequestFactory(sharedHttpClient()); new JdkClientHttpRequestFactory(httpClientFor(proxyUrl));
factory.setReadTimeout(Duration.ofMillis(safeReadTimeout)); factory.setReadTimeout(Duration.ofMillis(safeReadTimeout));
return factory; 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<>();
} }
@@ -867,6 +867,7 @@ public class DedupeTotalDataService {
} }
Set<String> seenInFile = new HashSet<>(); Set<String> seenInFile = new HashSet<>();
List<String> pendingDeletes = new ArrayList<>();
int totalRows = Math.max(sheet.getLastRowNum(), 0); int totalRows = Math.max(sheet.getLastRowNum(), 0);
if (maxImportRows > 0 && totalRows > maxImportRows) { if (maxImportRows > 0 && totalRows > maxImportRows) {
throw new BusinessException("导入行数超过上限: " + maxImportRows); throw new BusinessException("导入行数超过上限: " + maxImportRows);
@@ -919,12 +920,11 @@ public class DedupeTotalDataService {
continue; continue;
} }
int deletedThisRow = newRequiresNewTemplate().execute( // 批量删除:攒批 + IN 一次删,避免 50 万行 = 50 万个 REQUIRES_NEW 事务的 N+1
status -> deleteByDataValue(dataValue, scope, groupId)); pendingDeletes.add(dataValue);
if (deletedThisRow > 0) { if (pendingDeletes.size() >= DELETE_BATCH_SIZE) {
deletedCount += deletedThisRow; deletedCount += deleteBatchByDataValues(pendingDeletes, scope, groupId);
} else { pendingDeletes = new ArrayList<>();
skippedCount++;
} }
if (progress != null) { if (progress != null) {
progress.setProcessedRows(rowNum); progress.setProcessedRows(rowNum);
@@ -933,6 +933,9 @@ public class DedupeTotalDataService {
progress.setSkippedCount(skippedCount); progress.setSkippedCount(skippedCount);
} }
} }
if (!pendingDeletes.isEmpty()) {
deletedCount += deleteBatchByDataValues(pendingDeletes, scope, groupId);
}
DedupeTotalDataImportVo vo = new DedupeTotalDataImportVo(); DedupeTotalDataImportVo vo = new DedupeTotalDataImportVo();
vo.setTotalRows(totalRows); vo.setTotalRows(totalRows);
@@ -1016,6 +1019,21 @@ public class DedupeTotalDataService {
return dedupeTotalDataMapper.delete(query); return dedupeTotalDataMapper.delete(query);
} }
/** 批量删除大小:单事务 IN 删除的阈值,兼顾锁窗口与事务日志。 */
private static final int DELETE_BATCH_SIZE = 500;
/** 整批一个独立事务按 IN 一次删除;返回实际删除行数(计入 deletedCount,不再额外计 skipped)。 */
private int deleteBatchByDataValues(List<String> dataValues, AccessScope scope, Long groupId) {
List<String> batch = List.copyOf(dataValues);
Integer deleted = newRequiresNewTemplate().execute(status -> {
LambdaQueryWrapper<DedupeTotalDataEntity> query = new LambdaQueryWrapper<DedupeTotalDataEntity>()
.in(DedupeTotalDataEntity::getDataValue, batch);
applyGroupScope(query, scope, groupId);
return dedupeTotalDataMapper.delete(query);
});
return deleted == null ? 0 : deleted;
}
private AccessScope resolveAccessScope(Long operatorId) { private AccessScope resolveAccessScope(Long operatorId) {
AdminUserEntity operator = getOperator(operatorId); AdminUserEntity operator = getOperator(operatorId);
if (isSuperAdmin(operator)) { if (isSuperAdmin(operator)) {
@@ -33,6 +33,11 @@ public class LocalFileStorageService {
private final StorageProperties storageProperties; private final StorageProperties storageProperties;
/** 临时目录标准路径:供调用方做"文件必须落在上传临时目录内"的穿越校验。 */
public File localTempRoot() {
return FileUtil.file(storageProperties.getLocalTempDir());
}
/** 源文件确定路径索引:saveTempFile 写入后登记,查找优先命中,兜底目录枚举。 */ /** 源文件确定路径索引:saveTempFile 写入后登记,查找优先命中,兜底目录枚举。 */
private final Map<String, String> sourceFileIndex = private final Map<String, String> sourceFileIndex =
new LinkedHashMap<>(16, 0.75f, true); new LinkedHashMap<>(16, 0.75f, true);
@@ -42,6 +42,7 @@ import org.springframework.transaction.annotation.Transactional;
import java.io.BufferedReader; import java.io.BufferedReader;
import java.io.File; import java.io.File;
import java.io.FileInputStream; import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStreamReader; import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@@ -1462,15 +1463,16 @@ public class PriceTrackTaskService {
if (rawPath == null || rawPath.isBlank()) { if (rawPath == null || rawPath.isBlank()) {
continue; continue;
} }
File file = new File(rawPath); // 只允许解析上传落库的临时目录文件:直接 new File(请求路径) 可被穿越读服务器任意 csv/xlsx
File file = new File(localFileStorageService.localTempRoot().getAbsolutePath(), rawPath);
if (!file.isFile()) { if (!file.isFile()) {
// 直接路径不可读时兜底:按上传返回的 fileKey 反查服务器本地临时目录 // 传入的是 fileKey/索引键时按上传索引反查临时目录
File resolved = localFileStorageService.findLocalSourceFile(rawPath); File resolved = localFileStorageService.findLocalSourceFile(rawPath);
if (resolved != null) { if (resolved != null) {
file = resolved; file = resolved;
} }
} }
if (!file.isFile()) { if (!file.isFile() || !isInsideTempDir(file)) {
throw new BusinessException("ASIN 文件不存在或不可读: " + rawPath); throw new BusinessException("ASIN 文件不存在或不可读: " + rawPath);
} }
String lowerName = file.getName().toLowerCase(Locale.ROOT); String lowerName = file.getName().toLowerCase(Locale.ROOT);
@@ -1487,6 +1489,22 @@ public class PriceTrackTaskService {
return merged; return merged;
} }
/** 穿越校验:文件必须位于上传临时目录内(canonical path 前缀,防 ../ 与符号链接)。 */
private boolean isInsideTempDir(File file) {
try {
File root = localFileStorageService.localTempRoot();
if (!root.exists()) {
return false;
}
String rootPath = root.getCanonicalPath();
String filePath = file.getCanonicalPath();
return filePath.equals(rootPath) || filePath.startsWith(rootPath + File.separator);
} catch (IOException e) {
log.warn("[price-track] ASIN 文件路径规范化失败,拒绝解析: {}", file, e);
return false;
}
}
private Map<String, List<Map<String, String>>> parseWorkbookAsinRows(File file, List<String> countryCodes) { private Map<String, List<Map<String, String>>> parseWorkbookAsinRows(File file, List<String> countryCodes) {
Map<String, List<Map<String, String>>> out = new LinkedHashMap<>(); Map<String, List<Map<String, String>>> out = new LinkedHashMap<>();
AtomicReference<Map<String, Integer>> headerIndexHolder = new AtomicReference<>(Map.of()); AtomicReference<Map<String, Integer>> headerIndexHolder = new AtomicReference<>(Map.of());
@@ -89,4 +89,19 @@ public class ShopKeyController {
shopKeyService.delete(id); shopKeyService.delete(id);
return ApiResponse.success("删除成功", null); 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));
}
} }
@@ -22,4 +22,9 @@ public class ShopKeyCreateRequest {
@Size(max = 512, message = "紫鸟令牌长度不能超过512个字符") @Size(max = 512, message = "紫鸟令牌长度不能超过512个字符")
@Schema(description = "紫鸟令牌", requiredMode = Schema.RequiredMode.REQUIRED) @Schema(description = "紫鸟令牌", requiredMode = Schema.RequiredMode.REQUIRED)
private String ziniaoToken; private String ziniaoToken;
@Size(max = 255, message = "代理地址长度不能超过255个字符")
@Schema(description = "静态代理地址(http://host:port 或 http://user:pass@host:port),留空直连。"
+ "该 key 因服务器出口 IP 不在紫鸟白名单被拒时配置,代理 IP 需已在紫鸟白名单内。")
private String proxyUrl;
} }
@@ -22,4 +22,9 @@ public class ShopKeyUpdateRequest {
@Size(max = 512, message = "紫鸟令牌长度不能超过512个字符") @Size(max = 512, message = "紫鸟令牌长度不能超过512个字符")
@Schema(description = "紫鸟令牌", requiredMode = Schema.RequiredMode.REQUIRED) @Schema(description = "紫鸟令牌", requiredMode = Schema.RequiredMode.REQUIRED)
private String ziniaoToken; private String ziniaoToken;
@Size(max = 255, message = "代理地址长度不能超过255个字符")
@Schema(description = "静态代理地址(http://host:port 或 http://user:pass@host:port),留空直连。"
+ "该 key 因服务器出口 IP 不在紫鸟白名单被拒时配置,代理 IP 需已在紫鸟白名单内。")
private String proxyUrl;
} }
@@ -19,11 +19,15 @@ public class ShopKeyEntity {
private String ziniaoAccountName; private String ziniaoAccountName;
private String ziniaoToken; private String ziniaoToken;
private String ziniaoTokenHash; private String ziniaoTokenHash;
/** 静态代理地址(http://host:port 或 http://user:pass@host:port);为空表示直连。 */
private String proxyUrl;
private String ipWhitelistStatus; private String ipWhitelistStatus;
@TableField(updateStrategy = FieldStrategy.ALWAYS) @TableField(updateStrategy = FieldStrategy.ALWAYS)
private LocalDateTime ipWhitelistCheckedAt; private LocalDateTime ipWhitelistCheckedAt;
@TableField(updateStrategy = FieldStrategy.ALWAYS) @TableField(updateStrategy = FieldStrategy.ALWAYS)
private String ipWhitelistMessage; private String ipWhitelistMessage;
/** 连续白名单失败次数;达到阈值后刷新不再重试该 key。 */
private Integer ipWhitelistFailCount;
private LocalDateTime createdAt; private LocalDateTime createdAt;
private LocalDateTime updatedAt; private LocalDateTime updatedAt;
} }
@@ -21,6 +21,9 @@ public class ShopKeyItemVo {
@Schema(description = "紫鸟令牌") @Schema(description = "紫鸟令牌")
private String ziniaoToken; private String ziniaoToken;
@Schema(description = "静态代理地址;为空表示直连")
private String proxyUrl;
@Schema(description = "IP 白名单状态:UNKNOWN、ALLOWED、BLOCKED") @Schema(description = "IP 白名单状态:UNKNOWN、ALLOWED、BLOCKED")
private String ipWhitelistStatus; private String ipWhitelistStatus;
@@ -30,6 +33,9 @@ public class ShopKeyItemVo {
@Schema(description = "最近一次 IP 白名单检测信息") @Schema(description = "最近一次 IP 白名单检测信息")
private String ipWhitelistMessage; private String ipWhitelistMessage;
@Schema(description = "连续白名单失败次数;达阈值后不再自动重试,需人工检测或改令牌/代理")
private Integer ipWhitelistFailCount;
@Schema(description = "创建时间") @Schema(description = "创建时间")
private LocalDateTime createdAt; private LocalDateTime createdAt;
@@ -1,6 +1,7 @@
package com.nanri.aiimage.modules.shopkey.service; package com.nanri.aiimage.modules.shopkey.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; 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.common.exception.BusinessException;
import com.nanri.aiimage.modules.shopkey.mapper.ShopKeyMapper; import com.nanri.aiimage.modules.shopkey.mapper.ShopKeyMapper;
import com.nanri.aiimage.modules.shopkey.model.dto.ShopKeyCreateRequest; 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.entity.ShopKeyEntity;
import com.nanri.aiimage.modules.shopkey.model.vo.ShopKeyItemVo; import com.nanri.aiimage.modules.shopkey.model.vo.ShopKeyItemVo;
import com.nanri.aiimage.modules.shopkey.model.vo.ShopKeyPageVo; 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 com.nanri.aiimage.modules.ziniao.service.ZiniaoShopIndexService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -17,6 +20,7 @@ import org.springframework.transaction.annotation.Transactional;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.security.MessageDigest; import java.security.MessageDigest;
import java.time.LocalDateTime;
import java.util.List; import java.util.List;
@Service @Service
@@ -28,6 +32,8 @@ public class ShopKeyService {
private final ShopKeyMapper shopKeyMapper; private final ShopKeyMapper shopKeyMapper;
private final ZiniaoShopIndexService ziniaoShopIndexService; private final ZiniaoShopIndexService ziniaoShopIndexService;
private final ZiniaoApiKeyProvider ziniaoApiKeyProvider;
private final ZiniaoAuthService ziniaoAuthService;
public ShopKeyPageVo page(long page, long pageSize) { public ShopKeyPageVo page(long page, long pageSize) {
long safePage = Math.max(page, 1); long safePage = Math.max(page, 1);
@@ -58,8 +64,11 @@ public class ShopKeyService {
entity.setZiniaoAccountName(ziniaoAccountName); entity.setZiniaoAccountName(ziniaoAccountName);
entity.setZiniaoToken(ziniaoToken); entity.setZiniaoToken(ziniaoToken);
entity.setZiniaoTokenHash(ziniaoTokenHash); entity.setZiniaoTokenHash(ziniaoTokenHash);
entity.setProxyUrl(normalizeProxyUrl(request.getProxyUrl()));
entity.setIpWhitelistStatus(IP_WHITELIST_STATUS_UNKNOWN); entity.setIpWhitelistStatus(IP_WHITELIST_STATUS_UNKNOWN);
entity.setIpWhitelistFailCount(0);
insertOrThrowDuplicateToken(entity); insertOrThrowDuplicateToken(entity);
ziniaoApiKeyProvider.invalidateProxyUrlCache();
triggerShopIndexRefresh(); triggerShopIndexRefresh();
return toItemVo(getById(entity.getId())); return toItemVo(getById(entity.getId()));
} }
@@ -72,16 +81,27 @@ public class ShopKeyService {
String ziniaoTokenHash = hashToken(ziniaoToken); String ziniaoTokenHash = hashToken(ziniaoToken);
ensureTokenAvailable(ziniaoTokenHash, id); ensureTokenAvailable(ziniaoTokenHash, id);
boolean tokenChanged = !ziniaoTokenHash.equals(entity.getZiniaoTokenHash()); 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.setRemarkName(normalizeOptional(request.getRemarkName()));
entity.setZiniaoAccountName(ziniaoAccountName); entity.setZiniaoAccountName(ziniaoAccountName);
entity.setZiniaoToken(ziniaoToken); entity.setZiniaoToken(ziniaoToken);
entity.setZiniaoTokenHash(ziniaoTokenHash); entity.setZiniaoTokenHash(ziniaoTokenHash);
entity.setProxyUrl(proxyUrl);
if (tokenChanged) { if (tokenChanged) {
entity.setIpWhitelistStatus(IP_WHITELIST_STATUS_UNKNOWN); entity.setIpWhitelistStatus(IP_WHITELIST_STATUS_UNKNOWN);
entity.setIpWhitelistCheckedAt(null); entity.setIpWhitelistCheckedAt(null);
entity.setIpWhitelistMessage(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); updateOrThrowDuplicateToken(entity);
ziniaoApiKeyProvider.invalidateProxyUrlCache();
triggerShopIndexRefresh(); triggerShopIndexRefresh();
return toItemVo(getById(id)); return toItemVo(getById(id));
} }
@@ -90,9 +110,47 @@ public class ShopKeyService {
public void delete(Long id) { public void delete(Long id) {
ShopKeyEntity entity = getById(id); ShopKeyEntity entity = getById(id);
shopKeyMapper.deleteById(entity.getId()); shopKeyMapper.deleteById(entity.getId());
ziniaoApiKeyProvider.invalidateProxyUrlCache();
triggerShopIndexRefresh(); 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) { private ShopKeyEntity getById(Long id) {
ShopKeyEntity entity = shopKeyMapper.selectById(id); ShopKeyEntity entity = shopKeyMapper.selectById(id);
if (entity == null) { if (entity == null) {
@@ -113,15 +171,44 @@ public class ShopKeyService {
return value == null ? "" : value.trim(); 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) { private ShopKeyItemVo toItemVo(ShopKeyEntity entity) {
ShopKeyItemVo vo = new ShopKeyItemVo(); ShopKeyItemVo vo = new ShopKeyItemVo();
vo.setId(entity.getId()); vo.setId(entity.getId());
vo.setRemarkName(entity.getRemarkName()); vo.setRemarkName(entity.getRemarkName());
vo.setZiniaoAccountName(entity.getZiniaoAccountName()); vo.setZiniaoAccountName(entity.getZiniaoAccountName());
vo.setZiniaoToken(entity.getZiniaoToken()); vo.setZiniaoToken(entity.getZiniaoToken());
vo.setProxyUrl(entity.getProxyUrl());
vo.setIpWhitelistStatus(entity.getIpWhitelistStatus()); vo.setIpWhitelistStatus(entity.getIpWhitelistStatus());
vo.setIpWhitelistCheckedAt(entity.getIpWhitelistCheckedAt()); vo.setIpWhitelistCheckedAt(entity.getIpWhitelistCheckedAt());
vo.setIpWhitelistMessage(entity.getIpWhitelistMessage()); vo.setIpWhitelistMessage(entity.getIpWhitelistMessage());
vo.setIpWhitelistFailCount(entity.getIpWhitelistFailCount());
vo.setCreatedAt(entity.getCreatedAt()); vo.setCreatedAt(entity.getCreatedAt());
vo.setUpdatedAt(entity.getUpdatedAt()); vo.setUpdatedAt(entity.getUpdatedAt());
return vo; return vo;
@@ -26,24 +26,42 @@ public class ZiniaoClientImpl implements ZiniaoClient {
private final ZiniaoProperties ziniaoProperties; private final ZiniaoProperties ziniaoProperties;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final com.nanri.aiimage.metrics.ExternalCallMetricsRecorder externalCallMetrics; private final com.nanri.aiimage.metrics.ExternalCallMetricsRecorder externalCallMetrics;
/** 可为 null(测试/未配置代理时直连)。 */
private final ZiniaoProxyResolver proxyResolver;
/** 生产构造:双构造器下 Spring 不会自动推断,必须显式 @Autowired。 */ /** 生产构造:双构造器下 Spring 不会自动推断,必须显式 @Autowired。 */
@Autowired @Autowired
public ZiniaoClientImpl(ZiniaoProperties ziniaoProperties, ObjectMapper objectMapper,
ZiniaoProxyResolver proxyResolver) {
this(ziniaoProperties, objectMapper, null, proxyResolver);
}
public ZiniaoClientImpl(ZiniaoProperties ziniaoProperties, ObjectMapper objectMapper) { public ZiniaoClientImpl(ZiniaoProperties ziniaoProperties, ObjectMapper objectMapper) {
this(ziniaoProperties, objectMapper, null); this(ziniaoProperties, objectMapper, null, null);
} }
public ZiniaoClientImpl(ZiniaoProperties ziniaoProperties, public ZiniaoClientImpl(ZiniaoProperties ziniaoProperties,
ObjectMapper objectMapper, ObjectMapper objectMapper,
com.nanri.aiimage.metrics.ExternalCallMetricsRecorder externalCallMetrics) { 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.ziniaoProperties = ziniaoProperties;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
this.externalCallMetrics = externalCallMetrics; this.externalCallMetrics = externalCallMetrics;
this.proxyResolver = proxyResolver;
} }
/** Task 77:单例 RestClient(共享连接池),避免每次调用新建短命客户端。 */ /** Task 77:单例 RestClient(共享连接池),避免每次调用新建短命客户端。 */
private volatile RestClient sharedRestClient; private volatile RestClient sharedRestClient;
/** 按代理地址缓存 RestClient:同一代理复用同一连接池,避免每请求新建。 */
private final Map<String, RestClient> proxyRestClients = new java.util.concurrent.ConcurrentHashMap<>();
@Override @Override
public Long getCompanyIdByApiKey(String apiKey) { public Long getCompanyIdByApiKey(String apiKey) {
String raw = getWithApiKey(apiKey, "/app/builtin/company", "获取 companyId"); 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) { private String getWithApiKey(String apiKey, String path, String action) {
String raw = getRestClient().get() String raw = restClientFor(apiKey).get()
.uri(joinUrl(ziniaoProperties.getBaseUrl(), path)) .uri(joinUrl(ziniaoProperties.getBaseUrl(), path))
.headers(headers -> applyAuthorization(headers, apiKey)) .headers(headers -> applyAuthorization(headers, apiKey))
.retrieve() .retrieve()
@@ -158,7 +176,7 @@ public class ZiniaoClientImpl implements ZiniaoClient {
} }
private String postWithApiKey(String apiKey, String path, Map<String, Object> body, String action) { 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)) .uri(joinUrl(ziniaoProperties.getBaseUrl(), path))
.headers(headers -> { .headers(headers -> {
applyAuthorization(headers, apiKey); applyAuthorization(headers, apiKey);
@@ -271,17 +289,46 @@ public class ZiniaoClientImpl implements ZiniaoClient {
} }
synchronized (this) { synchronized (this) {
if (sharedRestClient == null) { if (sharedRestClient == null) {
RestClient.Builder builder = RestClient.builder() sharedRestClient = buildRestClient(null);
.requestFactory(HttpClientPool.requestFactory(ziniaoProperties.getReadTimeoutSeconds() * 1000));
if (externalCallMetrics != null) {
builder.requestInterceptor(externalCallMetrics.interceptor("ziniao"));
}
sharedRestClient = builder.build();
} }
return sharedRestClient; 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 同一单例。 */ /** 反射/测试可见:与 getRestClient 同一单例。 */
RestClient restClient() { RestClient restClient() {
return getRestClient(); return getRestClient();
@@ -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);
}
@@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.nanri.aiimage.common.exception.BusinessException; import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.modules.shopkey.mapper.ShopKeyMapper; import com.nanri.aiimage.modules.shopkey.mapper.ShopKeyMapper;
import com.nanri.aiimage.modules.shopkey.model.entity.ShopKeyEntity; import com.nanri.aiimage.modules.shopkey.model.entity.ShopKeyEntity;
import com.nanri.aiimage.modules.ziniao.client.ZiniaoProxyResolver;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -16,13 +17,25 @@ import java.util.Map;
@Service @Service
@RequiredArgsConstructor @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_ALLOWED = "ALLOWED";
public static final String IP_WHITELIST_STATUS_BLOCKED = "BLOCKED"; 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 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() { public List<ApiKeyAccount> listApiKeyAccounts() {
List<ShopKeyEntity> entities = shopKeyMapper.selectList(new LambdaQueryWrapper<ShopKeyEntity>() List<ShopKeyEntity> entities = shopKeyMapper.selectList(new LambdaQueryWrapper<ShopKeyEntity>()
.orderByDesc(ShopKeyEntity::getId)); .orderByDesc(ShopKeyEntity::getId));
@@ -38,11 +51,68 @@ public class ZiniaoApiKeyProvider {
.map(entry -> new ApiKeyAccount( .map(entry -> new ApiKeyAccount(
entry.getKey(), entry.getKey(),
resolveAccountName(entry.getValue()), 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(); .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() { public List<String> listApiKeys() {
return listApiKeyAccounts().stream() return listApiKeyAccounts().stream()
.map(ApiKeyAccount::apiKey) .map(ApiKeyAccount::apiKey)
@@ -63,22 +133,50 @@ public class ZiniaoApiKeyProvider {
} }
public void markIpWhitelistAllowed(ApiKeyAccount account) { 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()) { if (account == null || account.shopKeyIds().isEmpty()) {
return; return;
} }
shopKeyMapper.update(null, new LambdaUpdateWrapper<ShopKeyEntity>() LambdaUpdateWrapper<ShopKeyEntity> update = new LambdaUpdateWrapper<ShopKeyEntity>()
.in(ShopKeyEntity::getId, account.shopKeyIds()) .in(ShopKeyEntity::getId, account.shopKeyIds())
.set(ShopKeyEntity::getIpWhitelistStatus, status) .set(ShopKeyEntity::getIpWhitelistStatus, status)
.set(ShopKeyEntity::getIpWhitelistCheckedAt, LocalDateTime.now()) .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) { private String resolveAccountName(List<ShopKeyEntity> entities) {
@@ -109,14 +207,20 @@ public class ZiniaoApiKeyProvider {
return normalized.isBlank() ? null : normalized; 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 { public ApiKeyAccount {
shopKeyIds = shopKeyIds == null ? List.of() : List.copyOf(shopKeyIds); 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) { public ApiKeyAccount(String apiKey, String accountName) {
this(apiKey, accountName, List.of()); this(apiKey, accountName, List.of(), null, 0);
} }
} }
} }
@@ -136,6 +136,21 @@ public class ZiniaoAuthService {
return resolveCompanyId(apiKey); 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) { public List<ZiniaoStaffItemVo> getOrLoadStaffForIndex(String apiKey, Long companyId) {
return getOrLoadStaff(apiKey, companyId); return getOrLoadStaff(apiKey, companyId);
} }
@@ -217,6 +217,15 @@ public class ZiniaoShopIndexService {
} }
String apiKey = apiKeyAccount.apiKey(); String apiKey = apiKeyAccount.apiKey();
String companyName = apiKeyAccount.accountName(); 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, List<ZiniaoShopIndexEntryDto>> apiKeyGrouped = new LinkedHashMap<>();
Map<String, Long> apiKeyFingerprints = new LinkedHashMap<>(); Map<String, Long> apiKeyFingerprints = new LinkedHashMap<>();
Long companyId; Long companyId;
@@ -459,10 +468,15 @@ public class ZiniaoShopIndexService {
private void markIpWhitelistBlockedSafely(ZiniaoApiKeyProvider.ApiKeyAccount account, String message) { private void markIpWhitelistBlockedSafely(ZiniaoApiKeyProvider.ApiKeyAccount account, String message) {
try { 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) { } catch (Exception ex) {
log.warn("[ziniao-index] failed to record IP whitelist status accountName={} status=BLOCKED msg={}", 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;
@@ -120,7 +120,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue' import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import AmazonTopBar from '@/pages/amazon/components/AmazonTopBar.vue' import AmazonTopBar from '@/pages/amazon/components/AmazonTopBar.vue'
@@ -257,6 +257,13 @@ onMounted(async () => {
: filterGroupsByPermission(TOOL_GROUPS, allowedKeys.value) : filterGroupsByPermission(TOOL_GROUPS, allowedKeys.value)
parseHashGroup() parseHashGroup()
}) })
onBeforeUnmount(() => {
if (statusTimer !== undefined) {
window.clearTimeout(statusTimer)
statusTimer = undefined
}
})
</script> </script>
<style scoped> <style scoped>
@@ -143,34 +143,72 @@ function b64Decode(value: string): string {
} }
} }
// 记住密码存储:可逆加密(XOR+位移再 base64),带版本前缀 v1.。 // 记住密码存储:AES-GCM 对称加密,密文前缀 v2.。
// 说明:纯前端 localStorage 无法做强密码保护,此仅规避明文与早期 base64 直存。 // 说明:纯前端 localStorage 无法做强密码保护——加密密钥与密文同机存放,
const PWD_ENC_PREFIX = 'v1.' // 防的是"源码公开即可解密/明文直读",而非本机攻击者。
const PWD_ENC_KEY = [0x5a, 0x3c, 0x9f, 0x2e, 0x71, 0x8b, 0x1d, 0xe6] const PWD_ENC_PREFIX = 'v2.'
const PWD_KEY_STORAGE = 'aiimage_pwd_enc_key'
function encryptRememberPwd(plain: string): string { let pwdCryptoKeyPromise: Promise<CryptoKey | null> | null = null
const bytes = plain.split('').map((ch) => ch.charCodeAt(0))
for (let i = 0; i < bytes.length; i += 1) { function getPwdCryptoKey(): Promise<CryptoKey | null> {
bytes[i] = (bytes[i] ^ PWD_ENC_KEY[i % PWD_ENC_KEY.length] ^ i) & 0xff if (!pwdCryptoKeyPromise) {
} pwdCryptoKeyPromise = (async () => {
try { try {
return PWD_ENC_PREFIX + btoa(String.fromCharCode(...bytes)) if (!window.crypto?.subtle) return null
let rawB64 = lsGet(PWD_KEY_STORAGE)
if (!rawB64) {
const raw = new Uint8Array(32)
window.crypto.getRandomValues(raw)
rawB64 = btoa(String.fromCharCode(...raw))
lsSet(PWD_KEY_STORAGE, rawB64)
}
const raw = Uint8Array.from(atob(rawB64), (ch) => ch.charCodeAt(0))
return await window.crypto.subtle.importKey('raw', raw, 'AES-GCM', false, ['encrypt', 'decrypt'])
} catch {
return null
}
})()
}
return pwdCryptoKeyPromise
}
async function encryptRememberPwd(plain: string): Promise<string> {
try {
const key = await getPwdCryptoKey()
if (!key) return ''
const iv = new Uint8Array(12)
window.crypto.getRandomValues(iv)
const cipher = await window.crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
new TextEncoder().encode(plain),
)
const cipherBytes = new Uint8Array(cipher)
const merged = new Uint8Array(iv.length + cipherBytes.length)
merged.set(iv, 0)
merged.set(cipherBytes, iv.length)
return PWD_ENC_PREFIX + btoa(String.fromCharCode(...merged))
} catch { } catch {
return '' return ''
} }
} }
function decryptRememberPwd(stored: string): string { async function decryptRememberPwd(stored: string): Promise<string> {
if (!stored) return '' if (!stored) return ''
if (!stored.startsWith(PWD_ENC_PREFIX)) { if (!stored.startsWith(PWD_ENC_PREFIX)) {
// 兼容早期仅 base64 的历史值 // 兼容历史值:v1. 为 XOR 可逆编码、其余为纯 base64;解出后由调用方重存为 v2.
if (stored.startsWith('v1.')) return ''
return b64Decode(stored) return b64Decode(stored)
} }
try { try {
const bytes = atob(stored.slice(PWD_ENC_PREFIX.length)).split('').map((ch) => ch.charCodeAt(0)) const key = await getPwdCryptoKey()
return bytes if (!key) return ''
.map((code, i) => String.fromCharCode((code ^ PWD_ENC_KEY[i % PWD_ENC_KEY.length] ^ i) & 0xff)) const merged = Uint8Array.from(atob(stored.slice(PWD_ENC_PREFIX.length)), (ch) => ch.charCodeAt(0))
.join('') const iv = merged.slice(0, 12)
const cipher = merged.slice(12)
const plain = await window.crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, cipher)
return new TextDecoder().decode(plain)
} catch { } catch {
return '' return ''
} }
@@ -259,8 +297,8 @@ async function fetchDeviceId(): Promise<string> {
return makeBrowserDeviceId() return makeBrowserDeviceId()
} }
/** 登录成功后按勾选状态持久化账号凭据(仅桌面端登录页可勾选,密码 base64 简单编码 */ /** 登录成功后按勾选状态持久化账号凭据(仅桌面端登录页可勾选) */
function saveCredentials(account: string) { async function saveCredentials(account: string) {
if (!rememberPassword.value) { if (!rememberPassword.value) {
lsRemove(REMEMBER_USER_KEY) lsRemove(REMEMBER_USER_KEY)
lsRemove(REMEMBER_PWD_KEY) lsRemove(REMEMBER_PWD_KEY)
@@ -268,14 +306,14 @@ function saveCredentials(account: string) {
return return
} }
lsSet(REMEMBER_USER_KEY, account) lsSet(REMEMBER_USER_KEY, account)
lsSet(REMEMBER_PWD_KEY, encryptRememberPwd(password.value)) lsSet(REMEMBER_PWD_KEY, await encryptRememberPwd(password.value))
lsSet(AUTO_LOGIN_KEY, autoLogin.value ? '1' : '0') lsSet(AUTO_LOGIN_KEY, autoLogin.value ? '1' : '0')
} }
/** 打开登录页时恢复记住的账号/勾选状态 */ /** 打开登录页时恢复记住的账号/勾选状态 */
function loadCredentials() { async function loadCredentials() {
const account = lsGet(REMEMBER_USER_KEY) const account = lsGet(REMEMBER_USER_KEY)
const pwd = decryptRememberPwd(lsGet(REMEMBER_PWD_KEY)) const pwd = await decryptRememberPwd(lsGet(REMEMBER_PWD_KEY))
if (account) username.value = account if (account) username.value = account
if (pwd) password.value = pwd if (pwd) password.value = pwd
const auto = lsGet(AUTO_LOGIN_KEY) === '1' const auto = lsGet(AUTO_LOGIN_KEY) === '1'
@@ -352,8 +390,8 @@ async function submitLogin() {
/* 忽略 */ /* 忽略 */
} }
} }
// 登录成功即持久化记住/自动登录凭据(桌面与网页均可,密码加密落 localStorage // 登录成功即持久化记住/自动登录凭据(桌面与网页均可,密码 AES 加密落 localStorage
saveCredentials(account) void saveCredentials(account)
// 桌面端 Flask cookie 同步已随瘦身下线:登录态只存 localStorageJWT 走 Bearer),无 cookie 依赖 // 桌面端 Flask cookie 同步已随瘦身下线:登录态只存 localStorageJWT 走 Bearer),无 cookie 依赖
clearAppPermissionCaches() clearAppPermissionCaches()
// SPA:登录成功后经路由回首页(URL 无 .html 后缀,见 src/router // SPA:登录成功后经路由回首页(URL 无 .html 后缀,见 src/router
@@ -391,8 +429,11 @@ onMounted(() => {
} }
// 恢复记住的凭据并尝试自动登录(桌面与网页形态一致;登出/切号导航由 tryAutoLogin 内部豁免) // 恢复记住的凭据并尝试自动登录(桌面与网页形态一致;登出/切号导航由 tryAutoLogin 内部豁免)
loadCredentials() // loadCredentials 现为异步(AES 解密),须先恢复密码再触发自动登录
void (async () => {
await loadCredentials()
tryAutoLogin() tryAutoLogin()
})()
}) })
// 勾选自动登录时隐含记住密码(自动登录依赖已存密码);反之取消记住密码则取消自动登录 // 勾选自动登录时隐含记住密码(自动登录依赖已存密码);反之取消记住密码则取消自动登录
@@ -252,7 +252,9 @@ export function useTaskProgressLoop<TDetail>(
} }
await refreshOnce() await refreshOnce()
if (!disposed && taskIds.value.length > 0) { if (!disposed && taskIds.value.length > 0) {
pollTimer = timers.setTimeout('task-poll', run, intervalMs()) // 连续失败时按指数退避拉长间隔,避免后端故障时每 5s 撞一次(成功即复位)
const delay = failureCount > 0 ? getTaskPollBackoffMs(failureCount) : intervalMs()
pollTimer = timers.setTimeout('task-poll', run, delay)
} }
} }
@@ -322,7 +324,8 @@ export function useTaskProgressLoop<TDetail>(
} }
void refreshOnce() void refreshOnce()
if (!disposed && taskIds.value.length > 0) { if (!disposed && taskIds.value.length > 0) {
pollTimer = timers.setTimeout('task-poll', run, intervalMs()) const delay = failureCount > 0 ? getTaskPollBackoffMs(failureCount) : intervalMs()
pollTimer = timers.setTimeout('task-poll', run, delay)
} }
} }
pollTimer = timers.setTimeout('task-poll', run, delayMs) pollTimer = timers.setTimeout('task-poll', run, delayMs)
@@ -30,6 +30,29 @@ type PywebviewDownloadProgressEvent = {
const progressItems = reactive<Record<string, DownloadProgressItem>>({}) const progressItems = reactive<Record<string, DownloadProgressItem>>({})
let progressListenerBound = false let progressListenerBound = false
/** 终态条目自动清理延迟:success/failed 未手动关闭也只保留 2 分钟,防长驻累积 */
const TERMINAL_RETENTION_MS = 2 * 60 * 1000
let sweepTimer: ReturnType<typeof setInterval> | null = null
function sweepTerminalItems() {
const cutoff = now() - TERMINAL_RETENTION_MS
for (const id of Object.keys(progressItems)) {
const item = progressItems[id]
if ((item.status === 'success' || item.status === 'failed') && item.updatedAt <= cutoff) {
delete progressItems[id]
}
}
if (Object.keys(progressItems).length === 0 && sweepTimer) {
clearInterval(sweepTimer)
sweepTimer = null
}
}
function ensureSweepTimer() {
if (sweepTimer || typeof window === 'undefined') return
sweepTimer = setInterval(sweepTerminalItems, 30 * 1000)
}
function normalizePercent(value: number) { function normalizePercent(value: number) {
if (!Number.isFinite(value)) return 0 if (!Number.isFinite(value)) return 0
return Math.max(0, Math.min(100, Math.round(value))) return Math.max(0, Math.min(100, Math.round(value)))
@@ -40,6 +63,7 @@ function now() {
} }
function upsertProgress(partial: Omit<Partial<DownloadProgressItem>, 'id'> & { id: string }) { function upsertProgress(partial: Omit<Partial<DownloadProgressItem>, 'id'> & { id: string }) {
ensureSweepTimer()
const existing = progressItems[partial.id] const existing = progressItems[partial.id]
const timestamp = now() const timestamp = now()
progressItems[partial.id] = { progressItems[partial.id] = {
@@ -74,9 +98,7 @@ export function ensureDownloadProgressListener() {
if (progressListenerBound || typeof window === 'undefined') return if (progressListenerBound || typeof window === 'undefined') return
progressListenerBound = true progressListenerBound = true
window.addEventListener('pywebview-download-progress', handlePywebviewProgress) window.addEventListener('pywebview-download-progress', handlePywebviewProgress)
} }export function useDownloadProgress() {
export function useDownloadProgress() {
ensureDownloadProgressListener() ensureDownloadProgressListener()
const items = computed(() => const items = computed(() =>
Object.values(progressItems) Object.values(progressItems)