feat(密钥): 用户 API 密钥服务端化——V115 按账号绑定存储 + 后台密钥管理页 + 桌面端全站拦截与配置引导
- 新增 usersecret 模块:外观专利/货源查询密钥从本地 localStorage 迁移至 biz_user_api_secret(AES 加密、按 uid 绑定) - 后台「密钥管理」页:脱敏展示、立即检测、清空;每日 04:30 分布式锁定时巡检 - 桌面端:密钥设置面板走服务端、未配齐引导 /setup-secrets、代理余量展示 - 删除专利汇令牌全链路与密钥保留时长选择器
This commit is contained in:
@@ -65,6 +65,14 @@ public class AdminApiGuardFilter extends OncePerRequestFilter {
|
||||
"/api/price-track",
|
||||
};
|
||||
|
||||
/**
|
||||
* 桌面端自助接口前缀:新建端点、无历史匿名调用方,无条件纳入兜底鉴权
|
||||
* (controller 内 requireUser 为主防线,此处双保险;不挂 user-tool-guard-enabled 开关)。
|
||||
*/
|
||||
private static final String[] SELF_SERVICE_PREFIXES = {
|
||||
"/api/user-secrets",
|
||||
};
|
||||
|
||||
private final AdminAuthSupport adminAuthSupport;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@@ -131,11 +139,16 @@ public class AdminApiGuardFilter extends OncePerRequestFilter {
|
||||
chain.doFilter(request, response);
|
||||
}
|
||||
|
||||
/** 命中受保护前缀(/api/admin、/debug、用户态工具前缀及其子路径)才进入鉴权,其余请求直接放行。 */
|
||||
/** 命中受保护前缀(/api/admin、/debug、自助接口、用户态工具前缀及其子路径)才进入鉴权,其余请求直接放行。 */
|
||||
private boolean isGuarded(String uri) {
|
||||
if (matchesPrefix(uri, ADMIN_API_PREFIX) || matchesPrefix(uri, DEBUG_PREFIX)) {
|
||||
return true;
|
||||
}
|
||||
for (String prefix : SELF_SERVICE_PREFIXES) {
|
||||
if (matchesPrefix(uri, prefix)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (!userToolGuardEnabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,6 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
@EnableConfigurationProperties({OssProperties.class, TransientStorageProperties.class, StorageProperties.class, BrandProgressProperties.class, DeleteBrandProgressProperties.class, BrandCheckProperties.class, ZiniaoProperties.class, ModuleCleanupProperties.class, TaskPressureProperties.class, TaskImageCacheCleanupProperties.class, AppearancePatentProperties.class, SimilarAsinProperties.class, ImageVideoProperties.class, InstanceRoutingProperties.class, CapacityPlanProperties.class})
|
||||
@EnableConfigurationProperties({OssProperties.class, TransientStorageProperties.class, StorageProperties.class, BrandProgressProperties.class, DeleteBrandProgressProperties.class, BrandCheckProperties.class, ZiniaoProperties.class, ModuleCleanupProperties.class, TaskPressureProperties.class, TaskImageCacheCleanupProperties.class, AppearancePatentProperties.class, SimilarAsinProperties.class, ImageVideoProperties.class, InstanceRoutingProperties.class, CapacityPlanProperties.class, UserSecretProperties.class})
|
||||
public class PropertiesConfig {
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.nanri.aiimage.config;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/**
|
||||
* 用户 API 密钥(外观专利密钥 / 货源查询密钥)服务端化配置。
|
||||
*/
|
||||
@Data
|
||||
@ConfigurationProperties(prefix = "aiimage.user-secret")
|
||||
public class UserSecretProperties {
|
||||
|
||||
/** 每日定时连通性巡检开关(应急可关,无需重新打包)。 */
|
||||
private boolean checkEnabled = true;
|
||||
|
||||
/** 巡检 cron(默认每天 04:30,Asia/Shanghai)。 */
|
||||
private String checkCron = "0 30 4 * * *";
|
||||
|
||||
/** 单轮巡检最多检测条数,超出顺延下一轮。 */
|
||||
private int checkMaxRows = 500;
|
||||
|
||||
/** 单轮巡检时间预算(分钟),超时中断本轮。 */
|
||||
private int checkBudgetMinutes = 20;
|
||||
|
||||
/**
|
||||
* 检测出口代理提取链接(选配):配置后检测请求优先经该代理 IP 发出,
|
||||
* 代理不可用时自动回退直连;留空则全部直连。
|
||||
*/
|
||||
private String checkProxyExtractUrl = "";
|
||||
|
||||
/** jikip 余量查询接口(客户端设置弹窗展示套餐 IP 余量 / 账户余额)。 */
|
||||
private String jikipBalanceUrl = "https://api.jikip.com/find-balance";
|
||||
|
||||
/** jikip 套餐 id(余量查询参数)。 */
|
||||
private String jikipPlanId = "";
|
||||
|
||||
/** jikip 用户 ID(余量查询参数)。 */
|
||||
private String jikipUserId = "";
|
||||
}
|
||||
+1
-8
@@ -4,7 +4,6 @@ import com.fasterxml.jackson.annotation.JsonAlias;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
|
||||
@@ -29,12 +28,6 @@ public class AppearancePatentParseRequest {
|
||||
|
||||
@JsonProperty("api_key")
|
||||
@JsonAlias({"apiKey"})
|
||||
@Schema(description = "调用 LLM API 的任务级密钥。")
|
||||
@NotBlank(message = "密钥不能为空")
|
||||
@Schema(description = "调用 LLM API 的任务级密钥。非必填;为空时后端按用户密钥配置兜底。")
|
||||
private String apiKey;
|
||||
|
||||
@JsonProperty("patent_token")
|
||||
@JsonAlias({"patentToken"})
|
||||
@Schema(description = "专利汇令牌。非必填。")
|
||||
private String patentToken;
|
||||
}
|
||||
|
||||
-1
@@ -10,7 +10,6 @@ import java.util.List;
|
||||
public class AppearancePatentParsedGroupPageDto {
|
||||
private String aiPrompt;
|
||||
private String apiKey;
|
||||
private String patentToken;
|
||||
private Integer page;
|
||||
private Integer pageSize;
|
||||
private Integer totalGroups;
|
||||
|
||||
-3
@@ -17,9 +17,6 @@ public class AppearancePatentParsedPayloadDto {
|
||||
@Schema(description = "调用 LLM API 的任务级密钥")
|
||||
private String apiKey;
|
||||
|
||||
@Schema(description = "专利汇令牌")
|
||||
private String patentToken;
|
||||
|
||||
@Schema(description = "本次解析的源文件列表")
|
||||
private List<AppearancePatentSourceFileDto> sourceFiles = new ArrayList<>();
|
||||
|
||||
|
||||
+22
-11
@@ -92,6 +92,8 @@ import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
import com.nanri.aiimage.modules.task.model.vo.TaskProgressLightBatchVo;
|
||||
import com.nanri.aiimage.modules.task.service.TaskProgressLightAssembler;
|
||||
import com.nanri.aiimage.modules.usersecret.service.UserApiSecretService;
|
||||
import com.nanri.aiimage.modules.usersecret.support.UserSecretModule;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@@ -135,6 +137,7 @@ public class AppearancePatentTaskService {
|
||||
private final TaskDistributedLockService taskDistributedLockService;
|
||||
private final InstanceMetadata instanceMetadata;
|
||||
private final TaskProgressLightAssembler taskProgressLightAssembler;
|
||||
private final UserApiSecretService userApiSecretService;
|
||||
|
||||
public AppearancePatentParseVo parseAndCreateTask(AppearancePatentParseRequest request) {
|
||||
long startedAt = System.nanoTime();
|
||||
@@ -204,11 +207,11 @@ public class AppearancePatentTaskService {
|
||||
|
||||
String aggregateScopeKey = buildAggregateScopeKey(sourceFiles);
|
||||
String sourceScopeHash = DigestUtil.sha256Hex(aggregateScopeKey);
|
||||
String parsedPayload = buildParsedPayloadJson(request.getAiPrompt(), request.getApiKey(), request.getPatentToken(), sourceFiles, mergedHeaders, allRows);
|
||||
String parsedPayload = buildParsedPayloadJson(request.getAiPrompt(), request.getApiKey(), sourceFiles, mergedHeaders, allRows);
|
||||
long payloadBuiltAt = System.nanoTime();
|
||||
String parsedPayloadPointer = storeParsedPayload(task.getId(), sourceScopeHash, parsedPayload);
|
||||
long payloadStoredAt = System.nanoTime();
|
||||
task.setResultJson(buildTaskResultJson(request.getAiPrompt(), request.getApiKey(), request.getPatentToken(), sourceFiles, parsedPayloadPointer));
|
||||
task.setResultJson(buildTaskResultJson(request.getAiPrompt(), request.getApiKey(), sourceFiles, parsedPayloadPointer));
|
||||
task.setUpdatedAt(LocalDateTime.now());
|
||||
fileTaskMapper.updateById(task);
|
||||
|
||||
@@ -1408,12 +1411,24 @@ public class AppearancePatentTaskService {
|
||||
}
|
||||
}
|
||||
|
||||
/** 密钥读取:任务 payload 优先(兼容老任务与显式覆盖),为空时按任务归属用户从服务端密钥表兜底。 */
|
||||
private String readApiKey(FileTaskEntity task) {
|
||||
try {
|
||||
return normalize(readParsedPayload(task).getApiKey());
|
||||
} catch (Exception ignored) {
|
||||
return "";
|
||||
String fromPayload = normalize(readParsedPayload(task).getApiKey());
|
||||
if (!fromPayload.isEmpty()) {
|
||||
return fromPayload;
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
log.warn("[appearance-patent] 读取任务 payload 密钥失败,尝试用户密钥兜底 taskId={} err={}",
|
||||
task.getId(), ex.getMessage());
|
||||
}
|
||||
String fromUserSecret = userApiSecretService.findPlainValue(
|
||||
task.getUserId(), UserSecretModule.APPEARANCE_PATENT.key());
|
||||
if (fromUserSecret.isEmpty()) {
|
||||
log.warn("[appearance-patent] 任务未携带密钥且用户未配置密钥,LLM 检测将保留原始行 taskId={} userId={}",
|
||||
task.getId(), task.getUserId());
|
||||
}
|
||||
return fromUserSecret;
|
||||
}
|
||||
|
||||
private void finalizeTask(FileTaskEntity task, String error, int rowCount, boolean assembleWorkbook) {
|
||||
@@ -2655,11 +2670,10 @@ public class AppearancePatentTaskService {
|
||||
return val == null ? "" : val.replace(String.valueOf((char) 0xFEFF), "").replace((char) 0x3000, ' ').trim().replaceAll("\\s+", " ");
|
||||
}
|
||||
|
||||
private String buildParsedPayloadJson(String aiPrompt, String apiKey, String patentToken, List<AppearancePatentSourceFileDto> sourceFiles, List<String> headers, List<AppearancePatentParsedRowVo> allRows) {
|
||||
private String buildParsedPayloadJson(String aiPrompt, String apiKey, List<AppearancePatentSourceFileDto> sourceFiles, List<String> headers, List<AppearancePatentParsedRowVo> allRows) {
|
||||
AppearancePatentParsedPayloadDto payload = new AppearancePatentParsedPayloadDto();
|
||||
payload.setAiPrompt(normalize(aiPrompt));
|
||||
payload.setApiKey(normalize(apiKey));
|
||||
payload.setPatentToken(normalize(patentToken));
|
||||
payload.setSourceFiles(sourceFiles == null ? List.of() : sourceFiles);
|
||||
payload.setHeaders(headers == null ? List.of() : headers);
|
||||
payload.setItems(List.of());
|
||||
@@ -2668,11 +2682,10 @@ public class AppearancePatentTaskService {
|
||||
return writeJson(payload, "保存解析结果失败");
|
||||
}
|
||||
|
||||
private String buildTaskResultJson(String aiPrompt, String apiKey, String patentToken, List<AppearancePatentSourceFileDto> sourceFiles, String parsedPayloadPointer) {
|
||||
private String buildTaskResultJson(String aiPrompt, String apiKey, List<AppearancePatentSourceFileDto> sourceFiles, String parsedPayloadPointer) {
|
||||
Map<String, Object> payload = new LinkedHashMap<>();
|
||||
payload.put("aiPrompt", normalize(aiPrompt));
|
||||
payload.put("apiKey", normalize(apiKey));
|
||||
payload.put("patentToken", normalize(patentToken));
|
||||
payload.put("sourceFileKeys", sourceFiles == null ? List.of() : sourceFiles.stream()
|
||||
.map(AppearancePatentSourceFileDto::getFileKey)
|
||||
.filter(Objects::nonNull)
|
||||
@@ -2752,7 +2765,6 @@ public class AppearancePatentTaskService {
|
||||
AppearancePatentParsedPayloadDto queuePayload = new AppearancePatentParsedPayloadDto();
|
||||
queuePayload.setAiPrompt(payload.getAiPrompt());
|
||||
queuePayload.setApiKey(payload.getApiKey());
|
||||
queuePayload.setPatentToken(payload.getPatentToken());
|
||||
queuePayload.setGroups(payload.getGroups() == null ? List.of() : payload.getGroups());
|
||||
queuePayload.setItems(List.of());
|
||||
queuePayload.setAllItems(List.of());
|
||||
@@ -2778,7 +2790,6 @@ public class AppearancePatentTaskService {
|
||||
AppearancePatentParsedGroupPageDto vo = new AppearancePatentParsedGroupPageDto();
|
||||
vo.setAiPrompt(payload.getAiPrompt());
|
||||
vo.setApiKey(payload.getApiKey());
|
||||
vo.setPatentToken(payload.getPatentToken());
|
||||
vo.setPage(safePage);
|
||||
vo.setPageSize(safePageSize);
|
||||
vo.setTotalGroups(totalGroups);
|
||||
|
||||
+1
@@ -63,6 +63,7 @@ public class PermissionMenuSchemaInitializer {
|
||||
new DefaultAdminMenu("用户管理", "admin_users", "users", 10, "admin_group_account"),
|
||||
new DefaultAdminMenu("菜单权限配置", "admin_columns", "columns", 20, "admin_group_account"),
|
||||
new DefaultAdminMenu("分组管理", "admin_group_manage", "group-manage", 25, "admin_group_account"),
|
||||
new DefaultAdminMenu("密钥管理", "admin_user_secrets", "account/user-secrets", 41, "admin_group_account"),
|
||||
new DefaultAdminMenu("去重数据汇总", "admin_dedupe_total_data", "dedupe-total-data", 30, "admin_group_data"),
|
||||
new DefaultAdminMenu("品牌数据库", "admin_invalid_asin_data", "invalid-asin-data", 35, "admin_group_data"),
|
||||
new DefaultAdminMenu("查询ASIN", "admin_query_asin", "query-asin", 65, "admin_group_data"),
|
||||
|
||||
+1
-3
@@ -3,7 +3,6 @@ package com.nanri.aiimage.modules.similarasin.model.dto;
|
||||
import com.fasterxml.jackson.annotation.JsonAlias;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
@@ -29,8 +28,7 @@ public class SimilarAsinParseRequest {
|
||||
|
||||
@JsonProperty("api_key")
|
||||
@JsonAlias({"apiKey"})
|
||||
@Schema(description = "传递给 LLM 的任务级 api_key。")
|
||||
@NotBlank(message = "密钥不能为空")
|
||||
@Schema(description = "传递给 LLM 的任务级 api_key。非必填;为空时后端按用户密钥配置兜底。")
|
||||
private String apiKey;
|
||||
|
||||
@JsonProperty("img_switch")
|
||||
|
||||
+18
-3
@@ -60,6 +60,8 @@ import com.nanri.aiimage.modules.task.service.TaskFileJobService;
|
||||
import com.nanri.aiimage.modules.task.service.TaskDistributedLockService;
|
||||
import com.nanri.aiimage.modules.task.service.TaskProgressSnapshotService;
|
||||
import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService;
|
||||
import com.nanri.aiimage.modules.usersecret.service.UserApiSecretService;
|
||||
import com.nanri.aiimage.modules.usersecret.support.UserSecretModule;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.poi.ss.usermodel.Cell;
|
||||
@@ -339,6 +341,7 @@ public class SimilarAsinTaskService {
|
||||
* best-effort:service 内部所有异常都已吞掉,不影响主流程。
|
||||
*/
|
||||
private final SimilarAsinImagePrefetchService imagePrefetchService;
|
||||
private final UserApiSecretService userApiSecretService;
|
||||
/**
|
||||
* Task 89:Excel 行解析器(表头/单元格读取、别名匹配、空行跳过、单字段截断)。
|
||||
* 由 parseAndCreateTask 委托;解析语义与搬移前 parseWorkbook 完全一致。
|
||||
@@ -2031,12 +2034,24 @@ public class SimilarAsinTaskService {
|
||||
}
|
||||
}
|
||||
|
||||
/** 密钥读取:任务 payload 优先(兼容老任务与显式覆盖),为空时按任务归属用户从服务端密钥表兜底。 */
|
||||
private String readApiKey(FileTaskEntity task) {
|
||||
try {
|
||||
return normalize(readParsedPayload(task).getApiKey());
|
||||
} catch (Exception ignored) {
|
||||
return "";
|
||||
String fromPayload = normalize(readParsedPayload(task).getApiKey());
|
||||
if (!fromPayload.isEmpty()) {
|
||||
return fromPayload;
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
log.warn("[similar-asin] 读取任务 payload 密钥失败,尝试用户密钥兜底 taskId={} err={}",
|
||||
task.getId(), ex.getMessage());
|
||||
}
|
||||
String fromUserSecret = userApiSecretService.findPlainValue(
|
||||
task.getUserId(), UserSecretModule.SIMILAR_ASIN.key());
|
||||
if (fromUserSecret.isEmpty()) {
|
||||
log.warn("[similar-asin] 任务未携带密钥且用户未配置密钥,LLM 检测将保留原始行 taskId={} userId={}",
|
||||
task.getId(), task.getUserId());
|
||||
}
|
||||
return fromUserSecret;
|
||||
}
|
||||
|
||||
private boolean readImgSwitch(FileTaskEntity task) {
|
||||
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
package com.nanri.aiimage.modules.usersecret.client;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.nanri.aiimage.config.HttpClientPool;
|
||||
import com.nanri.aiimage.config.UserSecretProperties;
|
||||
import com.nanri.aiimage.modules.usersecret.model.vo.UserApiSecretBalanceVo;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* jikip 代理服务客户端:从提取链接取代理 IP(检测出口可选)、查询套餐余量(客户端展示)。
|
||||
* 所有失败均降级返回(null / available=false),绝不抛出中断调用方流程。
|
||||
*/
|
||||
@Component
|
||||
@Slf4j
|
||||
public class JikipProxyClient {
|
||||
|
||||
private static final int EXTRACT_READ_TIMEOUT_MILLIS = 10_000;
|
||||
private static final int BALANCE_READ_TIMEOUT_MILLIS = 8_000;
|
||||
private static final Pattern IP_PORT_PATTERN =
|
||||
Pattern.compile("(\\d{1,3}(?:\\.\\d{1,3}){3}):(\\d{2,5})");
|
||||
|
||||
private final UserSecretProperties properties;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
private volatile RestClient sharedClient;
|
||||
|
||||
public JikipProxyClient(UserSecretProperties properties, ObjectMapper objectMapper) {
|
||||
this.properties = properties;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
/** 是否配置了检测出口代理提取链接(未配置则检测全部直连)。 */
|
||||
public boolean isExtractConfigured() {
|
||||
return hasText(properties.getCheckProxyExtractUrl());
|
||||
}
|
||||
|
||||
/**
|
||||
* 从提取链接取一个代理地址(http://ip:port);未配置或失败返回 null,由调用方回退直连。
|
||||
*/
|
||||
public String fetchProxyUrl() {
|
||||
String extractUrl = normalize(properties.getCheckProxyExtractUrl());
|
||||
if (extractUrl.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
String body = restClient(EXTRACT_READ_TIMEOUT_MILLIS).get()
|
||||
.uri(extractUrl)
|
||||
.retrieve()
|
||||
.body(String.class);
|
||||
String proxyUrl = parseProxyUrl(body);
|
||||
if (proxyUrl == null) {
|
||||
log.warn("[user-secret][proxy] 提取接口返回中未找到 ip:port,响应前 200 字={}", abbreviate(body, 200));
|
||||
return null;
|
||||
}
|
||||
log.info("[user-secret][proxy] 代理提取成功 proxy={}", proxyUrl);
|
||||
return proxyUrl;
|
||||
} catch (Exception ex) {
|
||||
log.warn("[user-secret][proxy] 代理提取失败 err={}", ex.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 余量查询:GET {balanceUrl}?id={planId}&userId={userId},返回 surplus/balance。 */
|
||||
public UserApiSecretBalanceVo fetchBalance() {
|
||||
UserApiSecretBalanceVo vo = new UserApiSecretBalanceVo();
|
||||
String balanceUrl = normalize(properties.getJikipBalanceUrl());
|
||||
String planId = normalize(properties.getJikipPlanId());
|
||||
String userId = normalize(properties.getJikipUserId());
|
||||
if (balanceUrl.isBlank() || planId.isBlank() || userId.isBlank()) {
|
||||
log.info("[user-secret][proxy] 余量查询跳过:jikip 套餐信息未配置");
|
||||
vo.setAvailable(false);
|
||||
vo.setMessage("未配置代理套餐信息");
|
||||
return vo;
|
||||
}
|
||||
try {
|
||||
String separator = balanceUrl.contains("?") ? "&" : "?";
|
||||
String url = balanceUrl + separator
|
||||
+ "id=" + encode(planId) + "&userId=" + encode(userId);
|
||||
String body = restClient(BALANCE_READ_TIMEOUT_MILLIS).get()
|
||||
.uri(url)
|
||||
.retrieve()
|
||||
.body(String.class);
|
||||
JsonNode root = objectMapper.readTree(body == null ? "" : body);
|
||||
JsonNode data = root.path("data");
|
||||
JsonNode source = data.isObject() ? data : root;
|
||||
vo.setSurplus(text(source.get("surplus")));
|
||||
vo.setBalance(text(source.get("balance")));
|
||||
vo.setAvailable(true);
|
||||
log.info("[user-secret][proxy] 余量查询成功 surplus={} balance={}", vo.getSurplus(), vo.getBalance());
|
||||
} catch (Exception ex) {
|
||||
log.warn("[user-secret][proxy] 余量查询失败 err={}", ex.getMessage());
|
||||
vo.setAvailable(false);
|
||||
vo.setMessage("余量查询失败:" + ex.getMessage());
|
||||
}
|
||||
return vo;
|
||||
}
|
||||
|
||||
/** 解析提取接口响应:JSON 中的 ip/port 字段优先,否则正则匹配任意位置的 ip:port。 */
|
||||
private String parseProxyUrl(String body) {
|
||||
String text = normalize(body);
|
||||
if (text.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
if (text.startsWith("{") || text.startsWith("[")) {
|
||||
try {
|
||||
String fromJson = extractFromJson(objectMapper.readTree(text));
|
||||
if (fromJson != null) {
|
||||
return "http://" + fromJson;
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// JSON 解析失败继续走正则兜底
|
||||
}
|
||||
}
|
||||
Matcher matcher = IP_PORT_PATTERN.matcher(text);
|
||||
if (matcher.find()) {
|
||||
return "http://" + matcher.group(1) + ":" + matcher.group(2);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String extractFromJson(JsonNode node) {
|
||||
if (node == null || node.isMissingNode() || node.isNull()) {
|
||||
return null;
|
||||
}
|
||||
if (node.isArray()) {
|
||||
for (JsonNode child : node) {
|
||||
String found = extractFromJson(child);
|
||||
if (found != null) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (node.isObject()) {
|
||||
String ip = text(node.get("ip"));
|
||||
String port = text(node.get("port"));
|
||||
if (ip != null && port != null) {
|
||||
return ip + ":" + port;
|
||||
}
|
||||
for (JsonNode child : node) {
|
||||
String found = extractFromJson(child);
|
||||
if (found != null) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (node.isTextual()) {
|
||||
Matcher matcher = IP_PORT_PATTERN.matcher(node.asText());
|
||||
if (matcher.find()) {
|
||||
return matcher.group(1) + ":" + matcher.group(2);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private RestClient restClient(int readTimeoutMillis) {
|
||||
RestClient client = sharedClient;
|
||||
if (client != null) {
|
||||
return client;
|
||||
}
|
||||
synchronized (this) {
|
||||
if (sharedClient == null) {
|
||||
sharedClient = RestClient.builder()
|
||||
.requestFactory(HttpClientPool.requestFactory(readTimeoutMillis))
|
||||
.build();
|
||||
}
|
||||
return sharedClient;
|
||||
}
|
||||
}
|
||||
|
||||
private String encode(String value) {
|
||||
return URLEncoder.encode(value, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private String text(JsonNode node) {
|
||||
if (node == null || node.isNull() || node.isMissingNode()) {
|
||||
return null;
|
||||
}
|
||||
return node.asText();
|
||||
}
|
||||
|
||||
private String abbreviate(String value, int maxLength) {
|
||||
String normalized = value == null ? "" : value.trim();
|
||||
if (normalized.length() <= maxLength) {
|
||||
return normalized;
|
||||
}
|
||||
return normalized.substring(0, maxLength) + "...";
|
||||
}
|
||||
|
||||
private String normalize(String value) {
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
|
||||
private boolean hasText(String value) {
|
||||
return value != null && !value.trim().isEmpty();
|
||||
}
|
||||
}
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
package com.nanri.aiimage.modules.usersecret.controller;
|
||||
|
||||
import com.nanri.aiimage.common.api.ApiResponse;
|
||||
import com.nanri.aiimage.config.UserSecretProperties;
|
||||
import com.nanri.aiimage.modules.admin.support.AdminAuthSupport;
|
||||
import com.nanri.aiimage.modules.usersecret.model.dto.AdminUserSecretQuery;
|
||||
import com.nanri.aiimage.modules.usersecret.model.vo.AdminUserSecretPageVo;
|
||||
import com.nanri.aiimage.modules.usersecret.model.vo.UserApiSecretCheckResultVo;
|
||||
import com.nanri.aiimage.modules.usersecret.service.UserApiSecretService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 后台密钥管理:管理员查看用户密钥(脱敏)、立即检测、清空。
|
||||
* 不提供查看明文与代填编辑能力。
|
||||
*/
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/admin/user-secrets")
|
||||
@Tag(name = "后台密钥管理", description = "查看用户密钥配置(脱敏)、立即检测连通性、清空。")
|
||||
public class AdminUserApiSecretController {
|
||||
|
||||
private final UserApiSecretService userApiSecretService;
|
||||
private final UserSecretProperties userSecretProperties;
|
||||
private final AdminAuthSupport adminAuthSupport;
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "分页查询用户密钥", description = "keyword 匹配用户名或用户ID。")
|
||||
public ApiResponse<AdminUserSecretPageVo> page(
|
||||
HttpServletRequest request,
|
||||
@Parameter(description = "关键字:用户名或用户ID") @RequestParam(required = false) String keyword,
|
||||
@Parameter(description = "密钥模块筛选") @RequestParam(required = false) String moduleKey,
|
||||
@Parameter(description = "连通性状态筛选") @RequestParam(required = false) String checkStatus,
|
||||
@Parameter(description = "页码") @RequestParam(defaultValue = "1") Long page,
|
||||
@Parameter(description = "每页数量") @RequestParam(defaultValue = "15") Long pageSize) {
|
||||
adminAuthSupport.requireAdmin(request);
|
||||
AdminUserSecretQuery query = new AdminUserSecretQuery();
|
||||
query.setKeyword(keyword);
|
||||
query.setModuleKey(moduleKey);
|
||||
query.setCheckStatus(checkStatus);
|
||||
query.setPage(page);
|
||||
query.setPageSize(pageSize);
|
||||
return ApiResponse.success(userApiSecretService.adminPage(query));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/check")
|
||||
@Operation(summary = "立即检测指定密钥", description = "解密后真实请求一次 LLM 接口并把结果落库。")
|
||||
public ApiResponse<UserApiSecretCheckResultVo> check(
|
||||
HttpServletRequest request,
|
||||
@Parameter(description = "记录主键", required = true) @PathVariable Long id) {
|
||||
adminAuthSupport.requireAdmin(request);
|
||||
return ApiResponse.success("检测完成", userApiSecretService.adminCheck(id));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@Operation(summary = "清空指定用户密钥")
|
||||
public ApiResponse<Void> clear(
|
||||
HttpServletRequest request,
|
||||
@Parameter(description = "记录主键", required = true) @PathVariable Long id) {
|
||||
adminAuthSupport.requireAdmin(request);
|
||||
userApiSecretService.adminClear(id);
|
||||
return ApiResponse.success("已清空", null);
|
||||
}
|
||||
|
||||
@PostMapping("/check-all")
|
||||
@Operation(summary = "手动触发一轮全量巡检", description = "同步执行,受巡检条数与时间预算配置约束,请勿频繁调用。")
|
||||
public ApiResponse<Map<String, Integer>> checkAll(HttpServletRequest request) {
|
||||
adminAuthSupport.requireAdmin(request);
|
||||
UserApiSecretService.CheckSummary summary = userApiSecretService.checkAllForScheduler(
|
||||
userSecretProperties.getCheckMaxRows(), userSecretProperties.getCheckBudgetMinutes());
|
||||
return ApiResponse.success("巡检完成", Map.of(
|
||||
"checked", summary.checked(),
|
||||
"passed", summary.passed(),
|
||||
"failed", summary.failed(),
|
||||
"errors", summary.errors(),
|
||||
"skipped", summary.skipped()));
|
||||
}
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
package com.nanri.aiimage.modules.usersecret.controller;
|
||||
|
||||
import com.nanri.aiimage.common.api.ApiResponse;
|
||||
import com.nanri.aiimage.modules.admin.support.AdminAuthSupport;
|
||||
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
|
||||
import com.nanri.aiimage.modules.usersecret.model.dto.UserApiSecretCheckRequest;
|
||||
import com.nanri.aiimage.modules.usersecret.model.dto.UserApiSecretMigrateRequest;
|
||||
import com.nanri.aiimage.modules.usersecret.model.dto.UserApiSecretSaveRequest;
|
||||
import com.nanri.aiimage.modules.usersecret.model.vo.UserApiSecretBalanceVo;
|
||||
import com.nanri.aiimage.modules.usersecret.model.vo.UserApiSecretBundleVo;
|
||||
import com.nanri.aiimage.modules.usersecret.model.vo.UserApiSecretCheckResultVo;
|
||||
import com.nanri.aiimage.modules.usersecret.model.vo.UserApiSecretItemVo;
|
||||
import com.nanri.aiimage.modules.usersecret.service.UserApiSecretService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 桌面端用户密钥自助接口:当前登录用户维度,用户身份一律从 JWT 解析,
|
||||
* 不接受任何前端传入的 uid 参数;不落任何明文(仅返回脱敏值与检测状态)。
|
||||
*/
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/user-secrets")
|
||||
@Tag(name = "用户密钥(桌面端自助)", description = "外观专利密钥 / 货源查询密钥的服务端存取与连通性检测。")
|
||||
public class UserApiSecretController {
|
||||
|
||||
private final UserApiSecretService userApiSecretService;
|
||||
private final AdminAuthSupport adminAuthSupport;
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "拉取当前用户密钥包", description = "返回模块脱敏值与检测状态;必填清单由服务端下发。")
|
||||
public ApiResponse<UserApiSecretBundleVo> bundle(HttpServletRequest request) {
|
||||
Long userId = currentUserId(request);
|
||||
return ApiResponse.success(userApiSecretService.bundle(userId));
|
||||
}
|
||||
|
||||
@PutMapping("/{moduleKey}")
|
||||
@Operation(summary = "保存密钥", description = "加密落库并重置检测状态为未检测,保存后客户端应立即触发一次检测。")
|
||||
public ApiResponse<UserApiSecretItemVo> save(
|
||||
HttpServletRequest request,
|
||||
@Parameter(description = "密钥模块 key", required = true) @PathVariable String moduleKey,
|
||||
@Valid @RequestBody UserApiSecretSaveRequest body) {
|
||||
Long userId = currentUserId(request);
|
||||
return ApiResponse.success("保存成功", userApiSecretService.save(userId, moduleKey, body.getValue()));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{moduleKey}")
|
||||
@Operation(summary = "清空密钥")
|
||||
public ApiResponse<Void> clear(
|
||||
HttpServletRequest request,
|
||||
@Parameter(description = "密钥模块 key", required = true) @PathVariable String moduleKey) {
|
||||
Long userId = currentUserId(request);
|
||||
userApiSecretService.clear(userId, moduleKey);
|
||||
return ApiResponse.success("已清空", null);
|
||||
}
|
||||
|
||||
@PostMapping("/{moduleKey}/check")
|
||||
@Operation(summary = "检测密钥连通性",
|
||||
description = "value 非空时检测输入值(不落库);为空时检测服务端已存密钥并把结果落库。")
|
||||
public ApiResponse<UserApiSecretCheckResultVo> check(
|
||||
HttpServletRequest request,
|
||||
@Parameter(description = "密钥模块 key", required = true) @PathVariable String moduleKey,
|
||||
@RequestBody(required = false) UserApiSecretCheckRequest body) {
|
||||
Long userId = currentUserId(request);
|
||||
String overrideValue = body == null ? null : body.getValue();
|
||||
return ApiResponse.success("检测完成", userApiSecretService.check(userId, moduleKey, overrideValue));
|
||||
}
|
||||
|
||||
@PostMapping("/migrate")
|
||||
@Operation(summary = "迁移本地密钥", description = "客户端首次接入时上报本地已保存的密钥,仅写入服务端空缺的模块,不覆盖已有值。")
|
||||
public ApiResponse<Map<String, Integer>> migrate(
|
||||
HttpServletRequest request,
|
||||
@Valid @RequestBody UserApiSecretMigrateRequest body) {
|
||||
Long userId = currentUserId(request);
|
||||
int migrated = userApiSecretService.migrateIfAbsent(userId, body.getItems());
|
||||
return ApiResponse.success("迁移完成", Map.of("migrated", migrated));
|
||||
}
|
||||
|
||||
@GetMapping("/proxy-balance")
|
||||
@Operation(summary = "查询代理套餐余量", description = "转发 jikip find-balance,返回套餐 IP 余量与账户余额。")
|
||||
public ApiResponse<UserApiSecretBalanceVo> proxyBalance(HttpServletRequest request) {
|
||||
currentUserId(request);
|
||||
return ApiResponse.success(userApiSecretService.proxyBalance());
|
||||
}
|
||||
|
||||
private Long currentUserId(HttpServletRequest request) {
|
||||
AdminUserEntity me = adminAuthSupport.requireUser(request);
|
||||
return me.getId();
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.nanri.aiimage.modules.usersecret.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.nanri.aiimage.modules.usersecret.model.entity.UserApiSecretEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface UserApiSecretMapper extends BaseMapper<UserApiSecretEntity> {
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package com.nanri.aiimage.modules.usersecret.model.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Schema(description = "后台密钥管理查询条件")
|
||||
public class AdminUserSecretQuery {
|
||||
|
||||
@Schema(description = "关键字:匹配用户名或用户ID")
|
||||
private String keyword;
|
||||
|
||||
@Schema(description = "密钥模块筛选:appearance-patent/similar-asin")
|
||||
private String moduleKey;
|
||||
|
||||
@Schema(description = "连通性状态筛选:unknown/passed/failed/error")
|
||||
private String checkStatus;
|
||||
|
||||
@Schema(description = "页码,从 1 开始")
|
||||
private Long page = 1L;
|
||||
|
||||
@Schema(description = "每页数量")
|
||||
private Long pageSize = 15L;
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package com.nanri.aiimage.modules.usersecret.model.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Schema(description = "密钥连通性检测请求")
|
||||
public class UserApiSecretCheckRequest {
|
||||
|
||||
@Schema(description = "待检测的密钥明文;为空时检测服务端已保存的密钥(结果落库),非空时仅检测输入值(不落库)")
|
||||
private String value;
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package com.nanri.aiimage.modules.usersecret.model.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Schema(description = "本地密钥迁移请求:客户端首次接入时把本地已保存的密钥上报服务端")
|
||||
public class UserApiSecretMigrateRequest {
|
||||
|
||||
@Valid
|
||||
@NotEmpty(message = "迁移项不能为空")
|
||||
private List<Item> items;
|
||||
|
||||
@Data
|
||||
@Schema(description = "单项迁移数据")
|
||||
public static class Item {
|
||||
|
||||
@Schema(description = "密钥模块 key:appearance-patent/similar-asin", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String moduleKey;
|
||||
|
||||
@Schema(description = "密钥明文", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String value;
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package com.nanri.aiimage.modules.usersecret.model.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Schema(description = "保存用户密钥请求")
|
||||
public class UserApiSecretSaveRequest {
|
||||
|
||||
@NotBlank(message = "密钥不能为空")
|
||||
@Schema(description = "密钥明文(服务端加密存储)", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String value;
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package com.nanri.aiimage.modules.usersecret.model.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("biz_user_api_secret")
|
||||
public class UserApiSecretEntity {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private Long userId;
|
||||
private String moduleKey;
|
||||
private String secretValue;
|
||||
private String checkStatus;
|
||||
private String checkCode;
|
||||
private String checkMessage;
|
||||
private Integer checkLatencyMs;
|
||||
private LocalDateTime checkedAt;
|
||||
private String source;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
package com.nanri.aiimage.modules.usersecret.model.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@Schema(description = "后台密钥管理列表项")
|
||||
public class AdminUserSecretItemVo {
|
||||
|
||||
@Schema(description = "记录主键")
|
||||
private Long id;
|
||||
|
||||
@Schema(description = "用户ID")
|
||||
private Long userId;
|
||||
|
||||
@Schema(description = "用户名")
|
||||
private String username;
|
||||
|
||||
@Schema(description = "密钥模块 key")
|
||||
private String moduleKey;
|
||||
|
||||
@Schema(description = "密钥模块显示名")
|
||||
private String moduleLabel;
|
||||
|
||||
@Schema(description = "脱敏值")
|
||||
private String masked;
|
||||
|
||||
@Schema(description = "是否已配置")
|
||||
private Boolean exists;
|
||||
|
||||
@Schema(description = "连通性状态")
|
||||
private String checkStatus;
|
||||
|
||||
@Schema(description = "检测结果码")
|
||||
private String checkCode;
|
||||
|
||||
@Schema(description = "检测结果说明")
|
||||
private String checkMessage;
|
||||
|
||||
@Schema(description = "检测耗时(毫秒)")
|
||||
private Integer checkLatencyMs;
|
||||
|
||||
@Schema(description = "最近检测时间")
|
||||
private LocalDateTime checkedAt;
|
||||
|
||||
@Schema(description = "写入来源:client/admin/migrated")
|
||||
private String source;
|
||||
|
||||
@Schema(description = "更新时间")
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package com.nanri.aiimage.modules.usersecret.model.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Schema(description = "后台密钥管理分页结果")
|
||||
public class AdminUserSecretPageVo {
|
||||
|
||||
@Schema(description = "列表项")
|
||||
private List<AdminUserSecretItemVo> items;
|
||||
|
||||
@Schema(description = "总条数")
|
||||
private Long total;
|
||||
|
||||
@Schema(description = "页码")
|
||||
private Long page;
|
||||
|
||||
@Schema(description = "每页数量")
|
||||
private Long pageSize;
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package com.nanri.aiimage.modules.usersecret.model.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Schema(description = "jikip 代理余量")
|
||||
public class UserApiSecretBalanceVo {
|
||||
|
||||
@Schema(description = "是否查询成功")
|
||||
private Boolean available;
|
||||
|
||||
@Schema(description = "套餐 IP 余量")
|
||||
private String surplus;
|
||||
|
||||
@Schema(description = "账户余额")
|
||||
private String balance;
|
||||
|
||||
@Schema(description = "失败原因(available=false 时)")
|
||||
private String message;
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package com.nanri.aiimage.modules.usersecret.model.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Schema(description = "当前用户密钥包:全量模块 + 完整性判定")
|
||||
public class UserApiSecretBundleVo {
|
||||
|
||||
@Schema(description = "各模块密钥项")
|
||||
private List<UserApiSecretItemVo> items;
|
||||
|
||||
@Schema(description = "必须配置的模块 key 列表(服务端下发,客户端不硬编码)")
|
||||
private List<String> requiredModules;
|
||||
|
||||
@Schema(description = "是否配置完整:全部 required 模块均检测通过(error 状态视为放行)")
|
||||
private Boolean complete;
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package com.nanri.aiimage.modules.usersecret.model.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@Schema(description = "密钥连通性检测结果")
|
||||
public class UserApiSecretCheckResultVo {
|
||||
|
||||
@Schema(description = "密钥模块 key")
|
||||
private String moduleKey;
|
||||
|
||||
@Schema(description = "连通性状态:passed/failed/error")
|
||||
private String checkStatus;
|
||||
|
||||
@Schema(description = "检测结果码")
|
||||
private String checkCode;
|
||||
|
||||
@Schema(description = "检测结果说明")
|
||||
private String checkMessage;
|
||||
|
||||
@Schema(description = "检测耗时(毫秒)")
|
||||
private Integer checkLatencyMs;
|
||||
|
||||
@Schema(description = "检测时间")
|
||||
private LocalDateTime checkedAt;
|
||||
|
||||
@Schema(description = "是否经代理发出")
|
||||
private Boolean viaProxy;
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package com.nanri.aiimage.modules.usersecret.model.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@Schema(description = "用户密钥项(永不下发明文)")
|
||||
public class UserApiSecretItemVo {
|
||||
|
||||
@Schema(description = "密钥模块 key")
|
||||
private String moduleKey;
|
||||
|
||||
@Schema(description = "密钥模块显示名")
|
||||
private String moduleLabel;
|
||||
|
||||
@Schema(description = "脱敏值,如 sk-a****1234")
|
||||
private String masked;
|
||||
|
||||
@Schema(description = "是否已配置")
|
||||
private Boolean exists;
|
||||
|
||||
@Schema(description = "连通性状态:unknown/passed/failed/error")
|
||||
private String checkStatus;
|
||||
|
||||
@Schema(description = "检测结果码")
|
||||
private String checkCode;
|
||||
|
||||
@Schema(description = "检测结果说明")
|
||||
private String checkMessage;
|
||||
|
||||
@Schema(description = "检测耗时(毫秒)")
|
||||
private Integer checkLatencyMs;
|
||||
|
||||
@Schema(description = "最近检测时间")
|
||||
private LocalDateTime checkedAt;
|
||||
|
||||
@Schema(description = "更新时间")
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package com.nanri.aiimage.modules.usersecret.service;
|
||||
|
||||
import com.nanri.aiimage.common.service.DistributedJobLockService;
|
||||
import com.nanri.aiimage.config.UserSecretProperties;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* 用户密钥每日连通性巡检:默认每天 04:30(Asia/Shanghai)跑一轮,
|
||||
* 双实例经 Redis 分布式锁互斥;单轮受条数与时间预算约束,超出顺延下一轮。
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class UserApiSecretCheckScheduler {
|
||||
|
||||
private static final String LOCK_NAME = "user-secret-daily-check";
|
||||
|
||||
private final UserApiSecretService userApiSecretService;
|
||||
private final DistributedJobLockService distributedJobLockService;
|
||||
private final UserSecretProperties properties;
|
||||
|
||||
@Scheduled(cron = "${aiimage.user-secret.check-cron:0 30 4 * * *}", zone = "Asia/Shanghai")
|
||||
public void dailyCheck() {
|
||||
if (!properties.isCheckEnabled()) {
|
||||
log.info("[user-secret] 定时巡检已关闭,跳过本轮");
|
||||
return;
|
||||
}
|
||||
var lock = distributedJobLockService.tryLock(LOCK_NAME, Duration.ofMinutes(30));
|
||||
if (lock == null) {
|
||||
log.info("[user-secret] 另一实例持有巡检锁,跳过本轮");
|
||||
return;
|
||||
}
|
||||
try (lock) {
|
||||
log.info("[user-secret] 每日巡检开始 maxRows={} budgetMinutes={}",
|
||||
properties.getCheckMaxRows(), properties.getCheckBudgetMinutes());
|
||||
UserApiSecretService.CheckSummary summary = userApiSecretService.checkAllForScheduler(
|
||||
properties.getCheckMaxRows(), properties.getCheckBudgetMinutes());
|
||||
log.info("[user-secret] 每日巡检完成 checked={} passed={} failed={} errors={} skipped={}",
|
||||
summary.checked(), summary.passed(), summary.failed(), summary.errors(), summary.skipped());
|
||||
} catch (Exception ex) {
|
||||
log.warn("[user-secret] 每日巡检异常终止 err={}", ex.getMessage(), ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
+267
@@ -0,0 +1,267 @@
|
||||
package com.nanri.aiimage.modules.usersecret.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.nanri.aiimage.config.AppearancePatentProperties;
|
||||
import com.nanri.aiimage.config.HttpClientPool;
|
||||
import com.nanri.aiimage.config.SimilarAsinProperties;
|
||||
import com.nanri.aiimage.modules.usersecret.client.JikipProxyClient;
|
||||
import com.nanri.aiimage.modules.usersecret.support.UserSecretModule;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 密钥连通性探测:调一次 LLM /v1/chat/completions,能访问通(2xx 且返回 choices)即通过。
|
||||
* 无副作用(落库由 UserApiSecretService 负责)、不重试;
|
||||
* 出口默认直连,配置了提取链接时优先经代理、代理网络不可达自动回退直连。
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class UserApiSecretCheckService {
|
||||
|
||||
public static final String STATUS_PASSED = "passed";
|
||||
public static final String STATUS_FAILED = "failed";
|
||||
public static final String STATUS_ERROR = "error";
|
||||
|
||||
public static final String CODE_OK = "ok";
|
||||
public static final String CODE_INVALID_KEY = "invalid_key";
|
||||
public static final String CODE_FORBIDDEN = "forbidden";
|
||||
public static final String CODE_BAD_REQUEST = "bad_request";
|
||||
public static final String CODE_RATE_LIMITED = "rate_limited";
|
||||
public static final String CODE_SERVER_ERROR = "server_error";
|
||||
public static final String CODE_NETWORK_ERROR = "network_error";
|
||||
public static final String CODE_PROVIDER_ERROR = "provider_error";
|
||||
|
||||
private static final MediaType APPLICATION_JSON_UTF8 = new MediaType("application", "json", StandardCharsets.UTF_8);
|
||||
private static final int READ_TIMEOUT_MILLIS = 15_000;
|
||||
private static final int MAX_RESPONSE_BYTES = 1024 * 1024;
|
||||
private static final int CHECK_MAX_TOKENS = 8;
|
||||
|
||||
private final AppearancePatentProperties appearancePatentProperties;
|
||||
private final SimilarAsinProperties similarAsinProperties;
|
||||
private final JikipProxyClient jikipProxyClient;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
private volatile RestClient directClient;
|
||||
|
||||
/** 探测入口:优先经代理(若配置提取链接),代理网络不可达回退直连。 */
|
||||
public CheckOutcome probe(UserSecretModule module, String plainApiKey) {
|
||||
String proxyUrl = jikipProxyClient.isExtractConfigured() ? jikipProxyClient.fetchProxyUrl() : null;
|
||||
if (proxyUrl != null) {
|
||||
CheckOutcome viaProxy = probeOnce(module, plainApiKey, proxyUrl, true);
|
||||
if (CODE_NETWORK_ERROR.equals(viaProxy.code())) {
|
||||
log.warn("[user-secret][check] 经代理检测网络不可达 module={} proxy={},回退直连重试",
|
||||
module.key(), proxyUrl);
|
||||
CheckOutcome direct = probeOnce(module, plainApiKey, null, false);
|
||||
return new CheckOutcome(
|
||||
direct.status(),
|
||||
direct.code(),
|
||||
direct.message() + "(代理不可用,已回退直连)",
|
||||
direct.latencyMs(),
|
||||
false);
|
||||
}
|
||||
return viaProxy;
|
||||
}
|
||||
return probeOnce(module, plainApiKey, null, false);
|
||||
}
|
||||
|
||||
private CheckOutcome probeOnce(UserSecretModule module, String plainApiKey, String proxyUrl, boolean viaProxy) {
|
||||
UserSecretModule.LlmTarget target = module.resolveLlmTarget(appearancePatentProperties, similarAsinProperties);
|
||||
String url = joinUrl(target.host(), "/v1/chat/completions");
|
||||
String key = stripBearer(plainApiKey);
|
||||
long startMillis = System.currentTimeMillis();
|
||||
String viaText = viaProxy ? "经代理" : "直连";
|
||||
try {
|
||||
StatusAndBody statusAndBody = clientFor(proxyUrl).post()
|
||||
.uri(url)
|
||||
.headers(headers -> {
|
||||
headers.setBearerAuth(key);
|
||||
headers.setContentType(APPLICATION_JSON_UTF8);
|
||||
headers.set(HttpHeaders.ACCEPT_CHARSET, StandardCharsets.UTF_8.name());
|
||||
})
|
||||
.body(buildCheckBody(target.model()))
|
||||
.exchange((request, response) -> new StatusAndBody(
|
||||
response.getStatusCode().value(),
|
||||
readResponseBodyBounded(response.getBody())));
|
||||
long latency = System.currentTimeMillis() - startMillis;
|
||||
CheckOutcome outcome = classify(statusAndBody.statusCode(), statusAndBody.body(), (int) latency, viaProxy);
|
||||
log.info("[user-secret][check] {}探测完成 module={} status={} code={} httpStatus={} latency={}ms",
|
||||
viaText, module.key(), outcome.status(), outcome.code(), statusAndBody.statusCode(), latency);
|
||||
return outcome;
|
||||
} catch (Exception ex) {
|
||||
long latency = System.currentTimeMillis() - startMillis;
|
||||
log.warn("[user-secret][check] {}探测异常 module={} latency={}ms err={}",
|
||||
viaText, module.key(), latency, ex.getMessage());
|
||||
return new CheckOutcome(STATUS_ERROR, CODE_NETWORK_ERROR,
|
||||
"网络不可达:" + rootCauseMessage(ex), (int) latency, viaProxy);
|
||||
}
|
||||
}
|
||||
|
||||
/** 按 HTTP 状态码与响应体分类检测结果(package-private 供单测覆盖分类矩阵)。 */
|
||||
CheckOutcome classify(int statusCode, String body, int latencyMs, boolean viaProxy) {
|
||||
String responseBody = body == null ? "" : body;
|
||||
if (statusCode >= 200 && statusCode < 300) {
|
||||
JsonNode root = parseJson(body);
|
||||
if (root != null) {
|
||||
JsonNode errorNode = root.path("error");
|
||||
if (!errorNode.isMissingNode() && !errorNode.isNull()) {
|
||||
String errorMessage = text(errorNode.path("message"));
|
||||
return new CheckOutcome(STATUS_FAILED, CODE_PROVIDER_ERROR,
|
||||
"上游返回异常:" + firstNonBlank(errorMessage, abbreviate(body, 200)), latencyMs, viaProxy);
|
||||
}
|
||||
JsonNode choices = root.path("choices");
|
||||
if (choices.isArray() && !choices.isEmpty()) {
|
||||
return new CheckOutcome(STATUS_PASSED, CODE_OK, "连通正常", latencyMs, viaProxy);
|
||||
}
|
||||
}
|
||||
return new CheckOutcome(STATUS_FAILED, CODE_PROVIDER_ERROR,
|
||||
"上游响应缺少 choices:" + abbreviate(body, 200), latencyMs, viaProxy);
|
||||
}
|
||||
return switch (statusCode) {
|
||||
case 401 -> new CheckOutcome(STATUS_FAILED, CODE_INVALID_KEY, "密钥无效(401)", latencyMs, viaProxy);
|
||||
case 403 -> new CheckOutcome(STATUS_FAILED, CODE_FORBIDDEN,
|
||||
"密钥被拒绝(403),可能额度不足或无权限", latencyMs, viaProxy);
|
||||
case 400, 404 -> new CheckOutcome(STATUS_FAILED, CODE_BAD_REQUEST,
|
||||
"请求被拒绝(" + statusCode + "):" + abbreviate(body, 200), latencyMs, viaProxy);
|
||||
case 429 -> new CheckOutcome(STATUS_ERROR, CODE_RATE_LIMITED, "触发限流(429),本次无法判定", latencyMs, viaProxy);
|
||||
default -> statusCode >= 500
|
||||
? new CheckOutcome(STATUS_ERROR, CODE_SERVER_ERROR,
|
||||
"上游异常(" + statusCode + "):" + abbreviate(body, 200), latencyMs, viaProxy)
|
||||
: new CheckOutcome(STATUS_ERROR, CODE_SERVER_ERROR,
|
||||
"未知响应(" + statusCode + ")", latencyMs, viaProxy);
|
||||
};
|
||||
}
|
||||
|
||||
private Map<String, Object> buildCheckBody(String model) {
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("model", model);
|
||||
body.put("stream", false);
|
||||
body.put("max_tokens", CHECK_MAX_TOKENS);
|
||||
List<Map<String, Object>> messages = new ArrayList<>(1);
|
||||
Map<String, Object> userMessage = new LinkedHashMap<>();
|
||||
userMessage.put("role", "user");
|
||||
userMessage.put("content", "ping");
|
||||
messages.add(userMessage);
|
||||
body.put("messages", messages);
|
||||
return body;
|
||||
}
|
||||
|
||||
private RestClient clientFor(String proxyUrl) {
|
||||
if (proxyUrl != null && !proxyUrl.isBlank()) {
|
||||
return RestClient.builder()
|
||||
.requestFactory(HttpClientPool.requestFactory(READ_TIMEOUT_MILLIS, proxyUrl))
|
||||
.build();
|
||||
}
|
||||
RestClient client = directClient;
|
||||
if (client != null) {
|
||||
return client;
|
||||
}
|
||||
synchronized (this) {
|
||||
if (directClient == null) {
|
||||
directClient = RestClient.builder()
|
||||
.requestFactory(HttpClientPool.requestFactory(READ_TIMEOUT_MILLIS))
|
||||
.build();
|
||||
}
|
||||
return directClient;
|
||||
}
|
||||
}
|
||||
|
||||
private JsonNode parseJson(String body) {
|
||||
try {
|
||||
return objectMapper.readTree(body);
|
||||
} catch (Exception ex) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String readResponseBodyBounded(InputStream inputStream) throws IOException {
|
||||
if (inputStream == null) {
|
||||
return "";
|
||||
}
|
||||
try (InputStream input = inputStream; ByteArrayOutputStream output = new ByteArrayOutputStream(8192)) {
|
||||
byte[] buffer = new byte[8192];
|
||||
int read;
|
||||
int total = 0;
|
||||
while ((read = input.read(buffer)) != -1) {
|
||||
if (read == 0) {
|
||||
continue;
|
||||
}
|
||||
if ((long) total + read > MAX_RESPONSE_BYTES) {
|
||||
throw new IOException("检测响应超过 " + MAX_RESPONSE_BYTES + " 字节");
|
||||
}
|
||||
output.write(buffer, 0, read);
|
||||
total += read;
|
||||
}
|
||||
return output.toString(StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
|
||||
private String joinUrl(String baseUrl, String path) {
|
||||
String base = baseUrl == null ? "" : baseUrl.trim();
|
||||
String suffix = path == null ? "" : path.trim();
|
||||
if (base.endsWith("/") && suffix.startsWith("/")) {
|
||||
return base + suffix.substring(1);
|
||||
}
|
||||
if (!base.endsWith("/") && !suffix.startsWith("/")) {
|
||||
return base + "/" + suffix;
|
||||
}
|
||||
return base + suffix;
|
||||
}
|
||||
|
||||
private String stripBearer(String token) {
|
||||
String normalized = token == null ? "" : token.trim();
|
||||
return normalized.regionMatches(true, 0, "Bearer ", 0, 7) ? normalized.substring(7).trim() : normalized;
|
||||
}
|
||||
|
||||
private String rootCauseMessage(Throwable throwable) {
|
||||
Throwable current = throwable;
|
||||
while (current.getCause() != null && current.getCause() != current) {
|
||||
current = current.getCause();
|
||||
}
|
||||
String message = current.getMessage();
|
||||
if (message == null || message.isBlank()) {
|
||||
return current.getClass().getSimpleName();
|
||||
}
|
||||
return current.getClass().getSimpleName() + ": " + message;
|
||||
}
|
||||
|
||||
private String firstNonBlank(String preferred, String fallback) {
|
||||
return preferred == null || preferred.isBlank() ? fallback : preferred.trim();
|
||||
}
|
||||
|
||||
private String text(JsonNode node) {
|
||||
if (node == null || node.isNull() || node.isMissingNode()) {
|
||||
return null;
|
||||
}
|
||||
return node.asText();
|
||||
}
|
||||
|
||||
private String abbreviate(String value, int maxLength) {
|
||||
String normalized = value == null ? "" : value.trim();
|
||||
if (normalized.length() <= maxLength) {
|
||||
return normalized;
|
||||
}
|
||||
return normalized.substring(0, maxLength) + "...";
|
||||
}
|
||||
|
||||
/** 探测结果(无副作用)。 */
|
||||
public record CheckOutcome(String status, String code, String message, Integer latencyMs, boolean viaProxy) {
|
||||
}
|
||||
|
||||
private record StatusAndBody(int statusCode, String body) {
|
||||
}
|
||||
}
|
||||
+539
@@ -0,0 +1,539 @@
|
||||
package com.nanri.aiimage.modules.usersecret.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.common.security.ShopCredentialCryptoService;
|
||||
import com.nanri.aiimage.modules.permission.mapper.AdminUserMapper;
|
||||
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
|
||||
import com.nanri.aiimage.modules.usersecret.client.JikipProxyClient;
|
||||
import com.nanri.aiimage.modules.usersecret.mapper.UserApiSecretMapper;
|
||||
import com.nanri.aiimage.modules.usersecret.model.dto.AdminUserSecretQuery;
|
||||
import com.nanri.aiimage.modules.usersecret.model.dto.UserApiSecretMigrateRequest;
|
||||
import com.nanri.aiimage.modules.usersecret.model.entity.UserApiSecretEntity;
|
||||
import com.nanri.aiimage.modules.usersecret.model.vo.AdminUserSecretItemVo;
|
||||
import com.nanri.aiimage.modules.usersecret.model.vo.AdminUserSecretPageVo;
|
||||
import com.nanri.aiimage.modules.usersecret.model.vo.UserApiSecretBalanceVo;
|
||||
import com.nanri.aiimage.modules.usersecret.model.vo.UserApiSecretBundleVo;
|
||||
import com.nanri.aiimage.modules.usersecret.model.vo.UserApiSecretCheckResultVo;
|
||||
import com.nanri.aiimage.modules.usersecret.model.vo.UserApiSecretItemVo;
|
||||
import com.nanri.aiimage.modules.usersecret.support.UserSecretModule;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 用户 API 密钥服务端存储:按登录用户绑定、AES 加密落库,
|
||||
* 提供完整性包、迁移、任务兜底读取、连通性检测落库与后台管理查询。
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class UserApiSecretService {
|
||||
|
||||
public static final String SOURCE_CLIENT = "client";
|
||||
public static final String SOURCE_ADMIN = "admin";
|
||||
public static final String SOURCE_MIGRATED = "migrated";
|
||||
|
||||
private static final String STATUS_UNKNOWN = "unknown";
|
||||
private static final int MASK_MIN_LENGTH = 8;
|
||||
private static final int MESSAGE_MAX_LENGTH = 500;
|
||||
|
||||
private final UserApiSecretMapper userApiSecretMapper;
|
||||
private final ShopCredentialCryptoService cryptoService;
|
||||
private final UserApiSecretCheckService checkService;
|
||||
private final JikipProxyClient jikipProxyClient;
|
||||
private final AdminUserMapper adminUserMapper;
|
||||
|
||||
/** 当前用户密钥包:全量模块 + 服务端下发的必填清单 + 完整性判定。 */
|
||||
public UserApiSecretBundleVo bundle(Long userId) {
|
||||
requireUserId(userId);
|
||||
List<UserApiSecretItemVo> items = new ArrayList<>();
|
||||
for (UserSecretModule module : UserSecretModule.values()) {
|
||||
items.add(toItem(module, selectOne(userId, module.key())));
|
||||
}
|
||||
UserApiSecretBundleVo vo = new UserApiSecretBundleVo();
|
||||
vo.setItems(items);
|
||||
vo.setRequiredModules(Arrays.stream(UserSecretModule.values()).map(UserSecretModule::key).toList());
|
||||
vo.setComplete(isComplete(items));
|
||||
return vo;
|
||||
}
|
||||
|
||||
/** 保存密钥:加密落库并重置检测状态为 unknown(保存后由客户端立即触发检测)。 */
|
||||
@Transactional
|
||||
public UserApiSecretItemVo save(Long userId, String moduleKey, String value) {
|
||||
requireUserId(userId);
|
||||
UserSecretModule module = requireModule(moduleKey);
|
||||
String plainValue = normalize(value);
|
||||
if (plainValue.isEmpty()) {
|
||||
throw new BusinessException("密钥不能为空");
|
||||
}
|
||||
upsert(userId, module.key(), plainValue, SOURCE_CLIENT);
|
||||
log.info("[user-secret] 密钥已保存 userId={} module={}", userId, module.key());
|
||||
return toItem(module, selectOne(userId, module.key()));
|
||||
}
|
||||
|
||||
/** 清空密钥。 */
|
||||
@Transactional
|
||||
public void clear(Long userId, String moduleKey) {
|
||||
requireUserId(userId);
|
||||
UserSecretModule module = requireModule(moduleKey);
|
||||
userApiSecretMapper.delete(new LambdaQueryWrapper<UserApiSecretEntity>()
|
||||
.eq(UserApiSecretEntity::getUserId, userId)
|
||||
.eq(UserApiSecretEntity::getModuleKey, module.key()));
|
||||
log.info("[user-secret] 密钥已清空 userId={} module={}", userId, module.key());
|
||||
}
|
||||
|
||||
/** 本地密钥首次迁移:只写服务端空缺的模块,绝不覆盖已有值;忽略已废弃模块 key(如专利汇)。 */
|
||||
@Transactional
|
||||
public int migrateIfAbsent(Long userId, List<UserApiSecretMigrateRequest.Item> items) {
|
||||
requireUserId(userId);
|
||||
if (items == null || items.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
int migrated = 0;
|
||||
for (UserApiSecretMigrateRequest.Item item : items) {
|
||||
if (item == null) {
|
||||
continue;
|
||||
}
|
||||
Optional<UserSecretModule> module = UserSecretModule.of(item.getModuleKey());
|
||||
String plainValue = normalize(item.getValue());
|
||||
if (module.isEmpty() || plainValue.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
UserApiSecretEntity existing = selectOne(userId, module.get().key());
|
||||
if (existing != null && hasText(existing.getSecretValue())) {
|
||||
continue;
|
||||
}
|
||||
upsert(userId, module.get().key(), plainValue, SOURCE_MIGRATED);
|
||||
migrated++;
|
||||
}
|
||||
log.info("[user-secret] 本地密钥迁移完成 userId={} 提交={} 实际写入={}", userId, items.size(), migrated);
|
||||
return migrated;
|
||||
}
|
||||
|
||||
/** 任务执行兜底读取明文:未配置/解密失败返回空串,绝不抛异常中断任务。 */
|
||||
public String findPlainValue(Long userId, String moduleKey) {
|
||||
if (userId == null || userId <= 0 || !hasText(moduleKey)) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
UserApiSecretEntity row = selectOne(userId, moduleKey.trim());
|
||||
if (row == null || !hasText(row.getSecretValue())) {
|
||||
return "";
|
||||
}
|
||||
return normalize(cryptoService.decrypt(row.getSecretValue()));
|
||||
} catch (Exception ex) {
|
||||
log.warn("[user-secret] 任务兜底读取密钥失败 userId={} module={} err={}", userId, moduleKey, ex.getMessage());
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/** 检测:传 overrideValue 时只检测输入值不落库;否则检测已存值并落库。 */
|
||||
public UserApiSecretCheckResultVo check(Long userId, String moduleKey, String overrideValue) {
|
||||
requireUserId(userId);
|
||||
UserSecretModule module = requireModule(moduleKey);
|
||||
String override = normalize(overrideValue);
|
||||
String plainKey = override;
|
||||
boolean persist = false;
|
||||
if (plainKey.isEmpty()) {
|
||||
UserApiSecretEntity row = selectOne(userId, module.key());
|
||||
if (row == null || !hasText(row.getSecretValue())) {
|
||||
throw new BusinessException("请先保存密钥后再检测");
|
||||
}
|
||||
plainKey = normalize(cryptoService.decrypt(row.getSecretValue()));
|
||||
if (plainKey.isEmpty()) {
|
||||
throw new BusinessException("密钥内容为空,请重新配置");
|
||||
}
|
||||
persist = true;
|
||||
}
|
||||
UserApiSecretCheckService.CheckOutcome outcome = checkService.probe(module, plainKey);
|
||||
UserApiSecretCheckResultVo vo = toCheckResult(module, outcome);
|
||||
if (persist) {
|
||||
applyCheckOutcome(userId, module.key(), outcome);
|
||||
vo.setCheckedAt(LocalDateTime.now());
|
||||
}
|
||||
log.info("[user-secret] 检测完成 userId={} module={} status={} code={} viaProxy={} latency={}ms persist={}",
|
||||
userId, module.key(), outcome.status(), outcome.code(), outcome.viaProxy(), outcome.latencyMs(), persist);
|
||||
return vo;
|
||||
}
|
||||
|
||||
/** jikip 代理余量(客户端设置弹窗展示)。 */
|
||||
public UserApiSecretBalanceVo proxyBalance() {
|
||||
return jikipProxyClient.fetchBalance();
|
||||
}
|
||||
|
||||
/** 后台分页:关键字匹配用户名或用户ID。 */
|
||||
public AdminUserSecretPageVo adminPage(AdminUserSecretQuery query) {
|
||||
AdminUserSecretQuery safeQuery = query == null ? new AdminUserSecretQuery() : query;
|
||||
long page = safeQuery.getPage() == null || safeQuery.getPage() < 1 ? 1L : safeQuery.getPage();
|
||||
long pageSize = safeQuery.getPageSize() == null || safeQuery.getPageSize() < 1
|
||||
? 15L : Math.min(safeQuery.getPageSize(), 100L);
|
||||
|
||||
LambdaQueryWrapper<UserApiSecretEntity> wrapper = new LambdaQueryWrapper<>();
|
||||
String keyword = normalize(safeQuery.getKeyword());
|
||||
if (!keyword.isEmpty()) {
|
||||
List<Long> userIds = resolveUserIdsByKeyword(keyword);
|
||||
if (userIds.isEmpty()) {
|
||||
return emptyPage(page, pageSize);
|
||||
}
|
||||
wrapper.in(UserApiSecretEntity::getUserId, userIds);
|
||||
}
|
||||
if (hasText(safeQuery.getModuleKey())) {
|
||||
wrapper.eq(UserApiSecretEntity::getModuleKey, safeQuery.getModuleKey().trim());
|
||||
}
|
||||
if (hasText(safeQuery.getCheckStatus())) {
|
||||
wrapper.eq(UserApiSecretEntity::getCheckStatus, safeQuery.getCheckStatus().trim());
|
||||
}
|
||||
wrapper.orderByDesc(UserApiSecretEntity::getUpdatedAt).orderByDesc(UserApiSecretEntity::getId);
|
||||
|
||||
Page<UserApiSecretEntity> result = userApiSecretMapper.selectPage(new Page<>(page, pageSize), wrapper);
|
||||
List<AdminUserSecretItemVo> items = new ArrayList<>(result.getRecords().size());
|
||||
for (UserApiSecretEntity row : result.getRecords()) {
|
||||
items.add(toAdminItem(row));
|
||||
}
|
||||
AdminUserSecretPageVo vo = new AdminUserSecretPageVo();
|
||||
vo.setItems(items);
|
||||
vo.setTotal(result.getTotal());
|
||||
vo.setPage(page);
|
||||
vo.setPageSize(pageSize);
|
||||
return vo;
|
||||
}
|
||||
|
||||
/** 后台:按记录 ID 立即检测并落库。 */
|
||||
public UserApiSecretCheckResultVo adminCheck(Long id) {
|
||||
UserApiSecretEntity row = requireById(id);
|
||||
Optional<UserSecretModule> module = UserSecretModule.of(row.getModuleKey());
|
||||
if (module.isEmpty()) {
|
||||
throw new BusinessException("密钥模块已下线:" + row.getModuleKey());
|
||||
}
|
||||
String plainKey = normalize(cryptoService.decrypt(row.getSecretValue()));
|
||||
if (plainKey.isEmpty()) {
|
||||
throw new BusinessException("密钥内容为空,请让用户重新配置");
|
||||
}
|
||||
UserApiSecretCheckService.CheckOutcome outcome = checkService.probe(module.get(), plainKey);
|
||||
applyCheckOutcome(row.getUserId(), module.get().key(), outcome);
|
||||
UserApiSecretCheckResultVo vo = toCheckResult(module.get(), outcome);
|
||||
vo.setCheckedAt(LocalDateTime.now());
|
||||
log.info("[user-secret] 后台检测完成 id={} userId={} module={} status={} code={}",
|
||||
id, row.getUserId(), module.get().key(), outcome.status(), outcome.code());
|
||||
return vo;
|
||||
}
|
||||
|
||||
/** 后台:清空指定记录。 */
|
||||
@Transactional
|
||||
public void adminClear(Long id) {
|
||||
UserApiSecretEntity row = requireById(id);
|
||||
userApiSecretMapper.deleteById(id);
|
||||
log.info("[user-secret] 后台清空密钥 id={} userId={} module={}", id, row.getUserId(), row.getModuleKey());
|
||||
}
|
||||
|
||||
/**
|
||||
* 定时巡检:遍历全部密钥逐条探测并更新状态。
|
||||
* 单轮受 maxRows 与时间预算约束,超出部分顺延下一轮;单条异常不影响整轮。
|
||||
*/
|
||||
public CheckSummary checkAllForScheduler(int maxRows, int budgetMinutes) {
|
||||
long deadline = System.currentTimeMillis() + Duration.ofMinutes(Math.max(1, budgetMinutes)).toMillis();
|
||||
int checked = 0;
|
||||
int passed = 0;
|
||||
int failed = 0;
|
||||
int errors = 0;
|
||||
int skipped = 0;
|
||||
long lastId = 0L;
|
||||
while (true) {
|
||||
List<UserApiSecretEntity> batch = userApiSecretMapper.selectList(new LambdaQueryWrapper<UserApiSecretEntity>()
|
||||
.gt(UserApiSecretEntity::getId, lastId)
|
||||
.orderByAsc(UserApiSecretEntity::getId)
|
||||
.last("limit 100"));
|
||||
if (batch.isEmpty()) {
|
||||
break;
|
||||
}
|
||||
for (UserApiSecretEntity row : batch) {
|
||||
lastId = row.getId();
|
||||
if (checked >= maxRows || System.currentTimeMillis() >= deadline) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
Optional<UserSecretModule> module = UserSecretModule.of(row.getModuleKey());
|
||||
if (module.isEmpty()) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
String plainKey = normalize(cryptoService.decrypt(row.getSecretValue()));
|
||||
if (plainKey.isEmpty()) {
|
||||
applyCheckOutcome(row.getUserId(), module.get().key(), new UserApiSecretCheckService.CheckOutcome(
|
||||
UserApiSecretCheckService.STATUS_FAILED,
|
||||
UserApiSecretCheckService.CODE_INVALID_KEY,
|
||||
"密钥内容为空,请重新配置", null, false));
|
||||
failed++;
|
||||
checked++;
|
||||
continue;
|
||||
}
|
||||
UserApiSecretCheckService.CheckOutcome outcome = checkService.probe(module.get(), plainKey);
|
||||
applyCheckOutcome(row.getUserId(), module.get().key(), outcome);
|
||||
checked++;
|
||||
if (UserApiSecretCheckService.STATUS_PASSED.equals(outcome.status())) {
|
||||
passed++;
|
||||
} else if (UserApiSecretCheckService.STATUS_FAILED.equals(outcome.status())) {
|
||||
failed++;
|
||||
} else {
|
||||
errors++;
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
errors++;
|
||||
log.warn("[user-secret] 巡检单条失败 id={} userId={} module={} err={}",
|
||||
row.getId(), row.getUserId(), row.getModuleKey(), ex.getMessage());
|
||||
}
|
||||
sleepQuietly(200L);
|
||||
}
|
||||
if (checked >= maxRows || System.currentTimeMillis() >= deadline) {
|
||||
Long remaining = userApiSecretMapper.selectCount(new LambdaQueryWrapper<UserApiSecretEntity>()
|
||||
.gt(UserApiSecretEntity::getId, lastId));
|
||||
skipped += remaining == null ? 0 : remaining.intValue();
|
||||
break;
|
||||
}
|
||||
}
|
||||
CheckSummary summary = new CheckSummary(checked, passed, failed, errors, skipped);
|
||||
log.info("[user-secret] 巡检结束 checked={} passed={} failed={} errors={} skipped={}",
|
||||
summary.checked(), summary.passed(), summary.failed(), summary.errors(), summary.skipped());
|
||||
return summary;
|
||||
}
|
||||
|
||||
private void upsert(Long userId, String moduleKey, String plainValue, String source) {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
UserApiSecretEntity existing = selectOne(userId, moduleKey);
|
||||
UserApiSecretEntity row = existing == null ? new UserApiSecretEntity() : existing;
|
||||
row.setUserId(userId);
|
||||
row.setModuleKey(moduleKey);
|
||||
row.setSecretValue(cryptoService.encrypt(plainValue));
|
||||
row.setCheckStatus(STATUS_UNKNOWN);
|
||||
row.setCheckCode("");
|
||||
row.setCheckMessage("");
|
||||
row.setCheckLatencyMs(null);
|
||||
row.setCheckedAt(null);
|
||||
row.setSource(source);
|
||||
row.setUpdatedAt(now);
|
||||
if (row.getId() == null) {
|
||||
row.setCreatedAt(now);
|
||||
userApiSecretMapper.insert(row);
|
||||
} else {
|
||||
userApiSecretMapper.updateById(row);
|
||||
}
|
||||
}
|
||||
|
||||
private void applyCheckOutcome(Long userId, String moduleKey, UserApiSecretCheckService.CheckOutcome outcome) {
|
||||
try {
|
||||
UserApiSecretEntity row = selectOne(userId, moduleKey);
|
||||
if (row == null) {
|
||||
return;
|
||||
}
|
||||
row.setCheckStatus(outcome.status());
|
||||
row.setCheckCode(outcome.code());
|
||||
row.setCheckMessage(truncate(outcome.message(), MESSAGE_MAX_LENGTH));
|
||||
row.setCheckLatencyMs(outcome.latencyMs());
|
||||
row.setCheckedAt(LocalDateTime.now());
|
||||
row.setUpdatedAt(LocalDateTime.now());
|
||||
userApiSecretMapper.updateById(row);
|
||||
} catch (Exception ex) {
|
||||
log.warn("[user-secret] 检测状态落库失败 userId={} module={} err={}", userId, moduleKey, ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private UserApiSecretEntity selectOne(Long userId, String moduleKey) {
|
||||
return userApiSecretMapper.selectOne(new LambdaQueryWrapper<UserApiSecretEntity>()
|
||||
.eq(UserApiSecretEntity::getUserId, userId)
|
||||
.eq(UserApiSecretEntity::getModuleKey, moduleKey)
|
||||
.last("limit 1"));
|
||||
}
|
||||
|
||||
private UserApiSecretEntity requireById(Long id) {
|
||||
if (id == null || id <= 0) {
|
||||
throw new BusinessException("记录 ID 不合法");
|
||||
}
|
||||
UserApiSecretEntity row = userApiSecretMapper.selectById(id);
|
||||
if (row == null) {
|
||||
throw new BusinessException("密钥记录不存在");
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
private List<Long> resolveUserIdsByKeyword(String keyword) {
|
||||
Set<Long> userIds = new LinkedHashSet<>();
|
||||
if (keyword.matches("\\d+")) {
|
||||
try {
|
||||
userIds.add(Long.parseLong(keyword));
|
||||
} catch (NumberFormatException ignored) {
|
||||
// 超出 long 范围的关键字按纯文本处理
|
||||
}
|
||||
}
|
||||
List<AdminUserEntity> matched = adminUserMapper.selectList(new LambdaQueryWrapper<AdminUserEntity>()
|
||||
.like(AdminUserEntity::getUsername, keyword)
|
||||
.last("limit 200"));
|
||||
for (AdminUserEntity user : matched) {
|
||||
if (user.getId() != null) {
|
||||
userIds.add(user.getId());
|
||||
}
|
||||
}
|
||||
return new ArrayList<>(userIds);
|
||||
}
|
||||
|
||||
private AdminUserSecretItemVo toAdminItem(UserApiSecretEntity row) {
|
||||
AdminUserSecretItemVo vo = new AdminUserSecretItemVo();
|
||||
vo.setId(row.getId());
|
||||
vo.setUserId(row.getUserId());
|
||||
vo.setModuleKey(row.getModuleKey());
|
||||
UserSecretModule.of(row.getModuleKey())
|
||||
.ifPresentOrElse(module -> vo.setModuleLabel(module.label()),
|
||||
() -> vo.setModuleLabel(row.getModuleKey()));
|
||||
String plain = decryptQuietly(row.getSecretValue());
|
||||
vo.setMasked(mask(plain));
|
||||
vo.setExists(hasText(plain));
|
||||
vo.setCheckStatus(row.getCheckStatus());
|
||||
vo.setCheckCode(row.getCheckCode());
|
||||
vo.setCheckMessage(row.getCheckMessage());
|
||||
vo.setCheckLatencyMs(row.getCheckLatencyMs());
|
||||
vo.setCheckedAt(row.getCheckedAt());
|
||||
vo.setSource(row.getSource());
|
||||
vo.setUpdatedAt(row.getUpdatedAt());
|
||||
AdminUserEntity user = row.getUserId() == null ? null : adminUserMapper.selectById(row.getUserId());
|
||||
vo.setUsername(user == null ? "" : user.getUsername());
|
||||
return vo;
|
||||
}
|
||||
|
||||
private UserApiSecretItemVo toItem(UserSecretModule module, UserApiSecretEntity row) {
|
||||
UserApiSecretItemVo vo = new UserApiSecretItemVo();
|
||||
vo.setModuleKey(module.key());
|
||||
vo.setModuleLabel(module.label());
|
||||
if (row == null) {
|
||||
vo.setMasked("");
|
||||
vo.setExists(false);
|
||||
vo.setCheckStatus(STATUS_UNKNOWN);
|
||||
vo.setCheckCode("");
|
||||
vo.setCheckMessage("");
|
||||
return vo;
|
||||
}
|
||||
String plain = decryptQuietly(row.getSecretValue());
|
||||
vo.setMasked(mask(plain));
|
||||
vo.setExists(hasText(plain));
|
||||
vo.setCheckStatus(hasText(row.getCheckStatus()) ? row.getCheckStatus() : STATUS_UNKNOWN);
|
||||
vo.setCheckCode(row.getCheckCode());
|
||||
vo.setCheckMessage(row.getCheckMessage());
|
||||
vo.setCheckLatencyMs(row.getCheckLatencyMs());
|
||||
vo.setCheckedAt(row.getCheckedAt());
|
||||
vo.setUpdatedAt(row.getUpdatedAt());
|
||||
return vo;
|
||||
}
|
||||
|
||||
private UserApiSecretCheckResultVo toCheckResult(UserSecretModule module,
|
||||
UserApiSecretCheckService.CheckOutcome outcome) {
|
||||
UserApiSecretCheckResultVo vo = new UserApiSecretCheckResultVo();
|
||||
vo.setModuleKey(module.key());
|
||||
vo.setCheckStatus(outcome.status());
|
||||
vo.setCheckCode(outcome.code());
|
||||
vo.setCheckMessage(truncate(outcome.message(), MESSAGE_MAX_LENGTH));
|
||||
vo.setCheckLatencyMs(outcome.latencyMs());
|
||||
vo.setViaProxy(outcome.viaProxy());
|
||||
return vo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 完整性:全部必填模块均已配置且检测状态为 passed;
|
||||
* error(限流/上游异常/网络不可达等无法判定)视为放行,避免上游抖动把全体客户端锁死。
|
||||
*/
|
||||
private boolean isComplete(List<UserApiSecretItemVo> items) {
|
||||
for (UserApiSecretItemVo item : items) {
|
||||
if (!Boolean.TRUE.equals(item.getExists())) {
|
||||
return false;
|
||||
}
|
||||
String status = item.getCheckStatus();
|
||||
if (UserApiSecretCheckService.STATUS_PASSED.equals(status)
|
||||
|| UserApiSecretCheckService.STATUS_ERROR.equals(status)) {
|
||||
continue;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private AdminUserSecretPageVo emptyPage(long page, long pageSize) {
|
||||
AdminUserSecretPageVo vo = new AdminUserSecretPageVo();
|
||||
vo.setItems(new ArrayList<>());
|
||||
vo.setTotal(0L);
|
||||
vo.setPage(page);
|
||||
vo.setPageSize(pageSize);
|
||||
return vo;
|
||||
}
|
||||
|
||||
private UserSecretModule requireModule(String moduleKey) {
|
||||
return UserSecretModule.of(moduleKey)
|
||||
.orElseThrow(() -> new BusinessException("不支持的密钥模块:" + moduleKey));
|
||||
}
|
||||
|
||||
private void requireUserId(Long userId) {
|
||||
if (userId == null || userId <= 0) {
|
||||
throw new BusinessException("用户 ID 不合法");
|
||||
}
|
||||
}
|
||||
|
||||
private String decryptQuietly(String cipherText) {
|
||||
if (!hasText(cipherText)) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
return normalize(cryptoService.decrypt(cipherText));
|
||||
} catch (Exception ex) {
|
||||
log.warn("[user-secret] 解密失败,按未配置处理 err={}", ex.getMessage());
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private String mask(String value) {
|
||||
if (!hasText(value)) {
|
||||
return "";
|
||||
}
|
||||
String text = value.trim();
|
||||
if (text.length() <= MASK_MIN_LENGTH) {
|
||||
return "****";
|
||||
}
|
||||
return text.substring(0, 4) + "****" + text.substring(text.length() - 4);
|
||||
}
|
||||
|
||||
private String truncate(String value, int maxLength) {
|
||||
String normalized = value == null ? "" : value.trim();
|
||||
if (normalized.length() <= maxLength) {
|
||||
return normalized;
|
||||
}
|
||||
return normalized.substring(0, maxLength);
|
||||
}
|
||||
|
||||
private void sleepQuietly(long millis) {
|
||||
try {
|
||||
Thread.sleep(millis);
|
||||
} catch (InterruptedException interruptedException) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
|
||||
private String normalize(String value) {
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
|
||||
private boolean hasText(String value) {
|
||||
return value != null && !value.trim().isEmpty();
|
||||
}
|
||||
|
||||
/** 巡检统计。 */
|
||||
public record CheckSummary(int checked, int passed, int failed, int errors, int skipped) {
|
||||
}
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
package com.nanri.aiimage.modules.usersecret.support;
|
||||
|
||||
import com.nanri.aiimage.config.AppearancePatentProperties;
|
||||
import com.nanri.aiimage.config.SimilarAsinProperties;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 用户密钥模块:key / 显示名 / 检测目标(LLM host + model)的唯一来源。
|
||||
* 新增密钥模块时在此登记,控制器与服务层不再散落字符串。
|
||||
*/
|
||||
public enum UserSecretModule {
|
||||
|
||||
APPEARANCE_PATENT("appearance-patent", "外观专利密钥"),
|
||||
SIMILAR_ASIN("similar-asin", "货源查询密钥");
|
||||
|
||||
private final String key;
|
||||
private final String label;
|
||||
|
||||
UserSecretModule(String key, String label) {
|
||||
this.key = key;
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
public String key() {
|
||||
return key;
|
||||
}
|
||||
|
||||
public String label() {
|
||||
return label;
|
||||
}
|
||||
|
||||
/** 检测目标:LLM 主机 + 模型(取各模块自身配置,默认同为 ai.t8star.org)。 */
|
||||
public LlmTarget resolveLlmTarget(AppearancePatentProperties appearancePatentProperties,
|
||||
SimilarAsinProperties similarAsinProperties) {
|
||||
return switch (this) {
|
||||
case APPEARANCE_PATENT -> new LlmTarget(
|
||||
appearancePatentProperties.getLlmHost(),
|
||||
appearancePatentProperties.getTitleModel());
|
||||
case SIMILAR_ASIN -> new LlmTarget(
|
||||
similarAsinProperties.getLlmHost(),
|
||||
similarAsinProperties.getLlmCategoryModel());
|
||||
};
|
||||
}
|
||||
|
||||
public static Optional<UserSecretModule> of(String key) {
|
||||
if (key == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
String normalized = key.trim();
|
||||
for (UserSecretModule module : values()) {
|
||||
if (module.key.equalsIgnoreCase(normalized)) {
|
||||
return Optional.of(module);
|
||||
}
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
public record LlmTarget(String host, String model) {
|
||||
}
|
||||
}
|
||||
@@ -308,6 +308,16 @@ aiimage:
|
||||
archive-connect-timeout-millis: ${AIIMAGE_IMAGE_VIDEO_ARCHIVE_CONNECT_TIMEOUT_MILLIS:10000}
|
||||
archive-read-timeout-millis: ${AIIMAGE_IMAGE_VIDEO_ARCHIVE_READ_TIMEOUT_MILLIS:600000}
|
||||
archive-max-attempts: ${AIIMAGE_IMAGE_VIDEO_ARCHIVE_MAX_ATTEMPTS:3}
|
||||
user-secret:
|
||||
check-enabled: ${AIIMAGE_USER_SECRET_CHECK_ENABLED:true}
|
||||
check-cron: ${AIIMAGE_USER_SECRET_CHECK_CRON:0 30 4 * * *}
|
||||
check-max-rows: ${AIIMAGE_USER_SECRET_CHECK_MAX_ROWS:500}
|
||||
check-budget-minutes: ${AIIMAGE_USER_SECRET_CHECK_BUDGET_MINUTES:20}
|
||||
# 检测出口代理提取链接:留空=直连;配置后检测优先经代理、失败回退直连
|
||||
check-proxy-extract-url: ${AIIMAGE_USER_SECRET_CHECK_PROXY_EXTRACT_URL:}
|
||||
jikip-balance-url: ${AIIMAGE_USER_SECRET_JIKIP_BALANCE_URL:https://api.jikip.com/find-balance}
|
||||
jikip-plan-id: ${AIIMAGE_USER_SECRET_JIKIP_PLAN_ID:}
|
||||
jikip-user-id: ${AIIMAGE_USER_SECRET_JIKIP_USER_ID:}
|
||||
security:
|
||||
shop-credential-key: ${AIIMAGE_SHOP_CREDENTIAL_KEY:change-me-shop-credential-key}
|
||||
internal-token: ${AIIMAGE_INTERNAL_TOKEN:}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
-- V115: 用户 API 密钥服务端化(外观专利密钥 / 货源查询密钥)
|
||||
-- 密钥从客户端本地存储迁移到服务端,按登录用户绑定、加密存储;
|
||||
-- 同时记录连通性检测状态,供后台「密钥管理」页展示与每日定时巡检更新。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `biz_user_api_secret` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT,
|
||||
`user_id` BIGINT NOT NULL COMMENT '用户ID(users.id)',
|
||||
`module_key` VARCHAR(64) NOT NULL COMMENT '密钥模块:appearance-patent/similar-asin',
|
||||
`secret_value` VARCHAR(2048) NOT NULL COMMENT '密钥密文(AES 加密)',
|
||||
`check_status` VARCHAR(16) NOT NULL DEFAULT 'unknown' COMMENT '连通性状态:unknown/passed/failed/error',
|
||||
`check_code` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '检测结果码:ok/invalid_key/forbidden/bad_request/rate_limited/server_error/network_error/provider_error',
|
||||
`check_message` VARCHAR(512) NOT NULL DEFAULT '' COMMENT '检测结果说明',
|
||||
`check_latency_ms` INT NULL COMMENT '检测耗时(毫秒)',
|
||||
`checked_at` DATETIME NULL COMMENT '最近检测时间',
|
||||
`source` VARCHAR(16) NOT NULL DEFAULT 'client' COMMENT '写入来源:client/admin/migrated',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_user_module` (`user_id`, `module_key`),
|
||||
KEY `idx_check_status` (`check_status`),
|
||||
KEY `idx_checked_at` (`checked_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户 API 密钥(服务端存储,按用户绑定)';
|
||||
|
||||
-- 后台菜单:密钥管理(挂在「账号与权限」分组下;幂等,仅当 column_key 不存在时插入)
|
||||
INSERT INTO columns (name, column_key, menu_type, route_path, sort_order, parent_id)
|
||||
SELECT '密钥管理', 'admin_user_secrets', 'admin', 'account/user-secrets', 41, parent.id
|
||||
FROM columns parent
|
||||
WHERE parent.column_key = 'admin_group_account'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM columns WHERE column_key = 'admin_user_secrets'
|
||||
);
|
||||
+5
-2
@@ -26,6 +26,7 @@ import com.nanri.aiimage.modules.task.service.TaskFileJobService;
|
||||
import com.nanri.aiimage.modules.task.service.TaskProgressLightAssembler;
|
||||
import com.nanri.aiimage.modules.task.service.TaskProgressSnapshotService;
|
||||
import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService;
|
||||
import com.nanri.aiimage.modules.usersecret.service.UserApiSecretService;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.apache.poi.ss.usermodel.Row;
|
||||
import org.apache.poi.ss.usermodel.Sheet;
|
||||
@@ -102,7 +103,8 @@ class AppearancePatentTaskServiceDelegationTest {
|
||||
properties, taskFileJobService, taskProgressSnapshotService,
|
||||
transientPayloadStorageService, transactionManager, distributedJobLockService,
|
||||
taskDistributedLockService, instanceMetadata,
|
||||
mock(TaskProgressLightAssembler.class));
|
||||
mock(TaskProgressLightAssembler.class),
|
||||
mock(UserApiSecretService.class));
|
||||
}
|
||||
|
||||
private AppearancePatentTaskService serviceWithoutTransactionManager() {
|
||||
@@ -112,7 +114,8 @@ class AppearancePatentTaskServiceDelegationTest {
|
||||
properties, taskFileJobService, taskProgressSnapshotService,
|
||||
transientPayloadStorageService, null, distributedJobLockService,
|
||||
taskDistributedLockService, instanceMetadata,
|
||||
mock(TaskProgressLightAssembler.class));
|
||||
mock(TaskProgressLightAssembler.class),
|
||||
mock(UserApiSecretService.class));
|
||||
}
|
||||
|
||||
// ---------- 1 签名不变 ----------
|
||||
|
||||
+3
-1
@@ -26,6 +26,7 @@ import com.nanri.aiimage.modules.task.service.TaskFileJobService;
|
||||
import com.nanri.aiimage.modules.task.service.TaskProgressLightAssembler;
|
||||
import com.nanri.aiimage.modules.task.service.TaskProgressSnapshotService;
|
||||
import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService;
|
||||
import com.nanri.aiimage.modules.usersecret.service.UserApiSecretService;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
@@ -211,7 +212,8 @@ class AppearancePatentTaskServiceHistoryBatchTest {
|
||||
properties, taskFileJobService, taskProgressSnapshotService,
|
||||
transientPayloadStorageService, transactionManager, distributedJobLockService,
|
||||
taskDistributedLockService, instanceMetadata,
|
||||
mock(TaskProgressLightAssembler.class));
|
||||
mock(TaskProgressLightAssembler.class),
|
||||
mock(UserApiSecretService.class));
|
||||
}
|
||||
|
||||
private static FileResultEntity result(Long id, Long taskId, Long userId, LocalDateTime createdAt) {
|
||||
|
||||
+3
-1
@@ -32,6 +32,7 @@ import com.nanri.aiimage.modules.task.service.TaskFileJobService;
|
||||
import com.nanri.aiimage.modules.task.service.TaskProgressLightAssembler;
|
||||
import com.nanri.aiimage.modules.task.service.TaskProgressSnapshotService;
|
||||
import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService;
|
||||
import com.nanri.aiimage.modules.usersecret.service.UserApiSecretService;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
@@ -300,7 +301,8 @@ class RollbackSemanticsContractTest {
|
||||
mock(AppearancePatentTaskCacheService.class), mock(com.nanri.aiimage.config.AppearancePatentProperties.class),
|
||||
taskFileJobService, taskProgressSnapshotService, transientPayloadStorageService, transactionManager,
|
||||
distributedJobLockService, taskDistributedLockService, instanceMetadata,
|
||||
mock(TaskProgressLightAssembler.class));
|
||||
mock(TaskProgressLightAssembler.class),
|
||||
mock(UserApiSecretService.class));
|
||||
}
|
||||
|
||||
private SimilarAsinSubmitResultRequest request() {
|
||||
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
package com.nanri.aiimage.modules.usersecret.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.nanri.aiimage.config.AppearancePatentProperties;
|
||||
import com.nanri.aiimage.config.SimilarAsinProperties;
|
||||
import com.nanri.aiimage.modules.usersecret.client.JikipProxyClient;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
/** 检测结果分类矩阵:passed=放行,failed=拦截,error=无法判定(放行但警示)。 */
|
||||
class UserApiSecretCheckServiceTest {
|
||||
|
||||
private final UserApiSecretCheckService service = new UserApiSecretCheckService(
|
||||
new AppearancePatentProperties(),
|
||||
new SimilarAsinProperties(),
|
||||
mock(JikipProxyClient.class),
|
||||
new ObjectMapper());
|
||||
|
||||
@Test
|
||||
void classifyPassedWhenChoicesPresent() {
|
||||
UserApiSecretCheckService.CheckOutcome outcome =
|
||||
service.classify(200, "{\"choices\":[{\"message\":{\"content\":\"ok\"}}]}", 120, false);
|
||||
|
||||
assertThat(outcome.status()).isEqualTo(UserApiSecretCheckService.STATUS_PASSED);
|
||||
assertThat(outcome.code()).isEqualTo(UserApiSecretCheckService.CODE_OK);
|
||||
assertThat(outcome.latencyMs()).isEqualTo(120);
|
||||
assertThat(outcome.viaProxy()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void classifyMissingChoicesOnSuccessIsFailed() {
|
||||
UserApiSecretCheckService.CheckOutcome outcome = service.classify(200, "{}", 90, false);
|
||||
|
||||
assertThat(outcome.status()).isEqualTo(UserApiSecretCheckService.STATUS_FAILED);
|
||||
assertThat(outcome.code()).isEqualTo(UserApiSecretCheckService.CODE_PROVIDER_ERROR);
|
||||
}
|
||||
|
||||
@Test
|
||||
void classifyErrorNodeIsFailed() {
|
||||
UserApiSecretCheckService.CheckOutcome outcome =
|
||||
service.classify(200, "{\"error\":{\"message\":\"quota exceeded\"}}", 88, false);
|
||||
|
||||
assertThat(outcome.status()).isEqualTo(UserApiSecretCheckService.STATUS_FAILED);
|
||||
assertThat(outcome.code()).isEqualTo(UserApiSecretCheckService.CODE_PROVIDER_ERROR);
|
||||
assertThat(outcome.message()).contains("quota exceeded");
|
||||
}
|
||||
|
||||
@Test
|
||||
void classifyInvalidKeyOn401() {
|
||||
UserApiSecretCheckService.CheckOutcome outcome = service.classify(401, "unauthorized", 45, true);
|
||||
|
||||
assertThat(outcome.status()).isEqualTo(UserApiSecretCheckService.STATUS_FAILED);
|
||||
assertThat(outcome.code()).isEqualTo(UserApiSecretCheckService.CODE_INVALID_KEY);
|
||||
assertThat(outcome.viaProxy()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void classifyForbiddenOn403() {
|
||||
UserApiSecretCheckService.CheckOutcome outcome = service.classify(403, "forbidden", 50, false);
|
||||
|
||||
assertThat(outcome.status()).isEqualTo(UserApiSecretCheckService.STATUS_FAILED);
|
||||
assertThat(outcome.code()).isEqualTo(UserApiSecretCheckService.CODE_FORBIDDEN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void classifyBadRequestOn400() {
|
||||
UserApiSecretCheckService.CheckOutcome outcome = service.classify(400, "bad", 30, false);
|
||||
|
||||
assertThat(outcome.status()).isEqualTo(UserApiSecretCheckService.STATUS_FAILED);
|
||||
assertThat(outcome.code()).isEqualTo(UserApiSecretCheckService.CODE_BAD_REQUEST);
|
||||
}
|
||||
|
||||
@Test
|
||||
void classifyRateLimitedIsErrorNotFailed() {
|
||||
UserApiSecretCheckService.CheckOutcome outcome = service.classify(429, "too many", 20, false);
|
||||
|
||||
assertThat(outcome.status()).isEqualTo(UserApiSecretCheckService.STATUS_ERROR);
|
||||
assertThat(outcome.code()).isEqualTo(UserApiSecretCheckService.CODE_RATE_LIMITED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void classifyServerErrorIsError() {
|
||||
UserApiSecretCheckService.CheckOutcome outcome = service.classify(503, "unavailable", 60, false);
|
||||
|
||||
assertThat(outcome.status()).isEqualTo(UserApiSecretCheckService.STATUS_ERROR);
|
||||
assertThat(outcome.code()).isEqualTo(UserApiSecretCheckService.CODE_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
package com.nanri.aiimage.modules.usersecret.service;
|
||||
|
||||
import com.nanri.aiimage.common.security.ShopCredentialCryptoService;
|
||||
import com.nanri.aiimage.modules.permission.mapper.AdminUserMapper;
|
||||
import com.nanri.aiimage.modules.usersecret.client.JikipProxyClient;
|
||||
import com.nanri.aiimage.modules.usersecret.mapper.UserApiSecretMapper;
|
||||
import com.nanri.aiimage.modules.usersecret.model.dto.UserApiSecretMigrateRequest;
|
||||
import com.nanri.aiimage.modules.usersecret.model.entity.UserApiSecretEntity;
|
||||
import com.nanri.aiimage.modules.usersecret.model.vo.UserApiSecretBundleVo;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class UserApiSecretServiceTest {
|
||||
|
||||
private final UserApiSecretMapper mapper = mock(UserApiSecretMapper.class);
|
||||
private final ShopCredentialCryptoService crypto = mock(ShopCredentialCryptoService.class);
|
||||
private final UserApiSecretCheckService checkService = mock(UserApiSecretCheckService.class);
|
||||
private final JikipProxyClient jikipProxyClient = mock(JikipProxyClient.class);
|
||||
private final AdminUserMapper adminUserMapper = mock(AdminUserMapper.class);
|
||||
|
||||
private UserApiSecretService newService() {
|
||||
when(crypto.encrypt(anyString())).thenAnswer(inv -> "enc:" + inv.getArgument(0, String.class));
|
||||
when(crypto.decrypt(anyString())).thenAnswer(inv -> {
|
||||
String value = inv.getArgument(0, String.class);
|
||||
return value.startsWith("enc:") ? value.substring(4) : value;
|
||||
});
|
||||
return new UserApiSecretService(mapper, crypto, checkService, jikipProxyClient, adminUserMapper);
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveEncryptsValueAndResetsCheckState() {
|
||||
UserApiSecretService service = newService();
|
||||
UserApiSecretEntity existing = new UserApiSecretEntity();
|
||||
existing.setId(5L);
|
||||
existing.setUserId(7L);
|
||||
existing.setModuleKey("appearance-patent");
|
||||
existing.setSecretValue("enc:old-key");
|
||||
existing.setCheckStatus("passed");
|
||||
when(mapper.selectOne(any())).thenReturn(existing);
|
||||
|
||||
service.save(7L, "appearance-patent", "sk-new");
|
||||
|
||||
ArgumentCaptor<UserApiSecretEntity> captor = ArgumentCaptor.forClass(UserApiSecretEntity.class);
|
||||
verify(mapper).updateById(captor.capture());
|
||||
UserApiSecretEntity updated = captor.getValue();
|
||||
assertThat(updated.getSecretValue()).isEqualTo("enc:sk-new");
|
||||
assertThat(updated.getCheckStatus()).isEqualTo("unknown");
|
||||
assertThat(updated.getCheckedAt()).isNull();
|
||||
assertThat(updated.getCheckCode()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void findPlainValueReturnsEmptyWhenDecryptFails() {
|
||||
UserApiSecretService service = newService();
|
||||
UserApiSecretEntity row = new UserApiSecretEntity();
|
||||
row.setSecretValue("broken");
|
||||
when(mapper.selectOne(any())).thenReturn(row);
|
||||
when(crypto.decrypt("broken")).thenThrow(new IllegalStateException("解密失败"));
|
||||
|
||||
assertThat(service.findPlainValue(7L, "appearance-patent")).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void findPlainValueReturnsEmptyForInvalidUserId() {
|
||||
UserApiSecretService service = newService();
|
||||
assertThat(service.findPlainValue(null, "appearance-patent")).isEmpty();
|
||||
assertThat(service.findPlainValue(0L, "appearance-patent")).isEmpty();
|
||||
verify(mapper, never()).selectOne(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void migrateSkipsWhenServerValueExists() {
|
||||
UserApiSecretService service = newService();
|
||||
UserApiSecretEntity existing = new UserApiSecretEntity();
|
||||
existing.setId(9L);
|
||||
existing.setSecretValue("enc:existing");
|
||||
when(mapper.selectOne(any())).thenReturn(existing);
|
||||
|
||||
int migrated = service.migrateIfAbsent(7L, List.of(item("appearance-patent", "local-key")));
|
||||
|
||||
assertThat(migrated).isZero();
|
||||
verify(mapper, never()).insert(any(UserApiSecretEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void migrateWritesOnlyMissingModules() {
|
||||
UserApiSecretService service = newService();
|
||||
when(mapper.selectOne(any())).thenReturn(null);
|
||||
|
||||
int migrated = service.migrateIfAbsent(7L, List.of(
|
||||
item("appearance-patent", "app-key"),
|
||||
item("similar-asin", "asin-key"),
|
||||
item("appearance-patent-token", "legacy-token")));
|
||||
|
||||
assertThat(migrated).isEqualTo(2);
|
||||
verify(mapper, org.mockito.Mockito.times(2)).insert(any(UserApiSecretEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void bundleIncompleteWhenNothingConfigured() {
|
||||
UserApiSecretService service = newService();
|
||||
when(mapper.selectOne(any())).thenReturn(null);
|
||||
|
||||
UserApiSecretBundleVo bundle = service.bundle(7L);
|
||||
|
||||
assertThat(bundle.getComplete()).isFalse();
|
||||
assertThat(bundle.getItems()).hasSize(2);
|
||||
assertThat(bundle.getRequiredModules()).containsExactly("appearance-patent", "similar-asin");
|
||||
}
|
||||
|
||||
@Test
|
||||
void bundleCompleteWhenAllModulesPassed() {
|
||||
UserApiSecretService service = newService();
|
||||
UserApiSecretEntity passed = new UserApiSecretEntity();
|
||||
passed.setSecretValue("enc:key");
|
||||
passed.setCheckStatus("passed");
|
||||
when(mapper.selectOne(any())).thenReturn(passed);
|
||||
|
||||
assertThat(service.bundle(7L).getComplete()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void bundleTreatsErrorAsPassThroughButFailedBlocks() {
|
||||
UserApiSecretService service = newService();
|
||||
UserApiSecretEntity row = new UserApiSecretEntity();
|
||||
row.setSecretValue("enc:key");
|
||||
row.setCheckStatus("error");
|
||||
when(mapper.selectOne(any())).thenReturn(row);
|
||||
assertThat(service.bundle(7L).getComplete()).isTrue();
|
||||
|
||||
row.setCheckStatus("failed");
|
||||
assertThat(service.bundle(7L).getComplete()).isFalse();
|
||||
|
||||
row.setCheckStatus("unknown");
|
||||
assertThat(service.bundle(7L).getComplete()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void clearDeletesRowByUserAndModule() {
|
||||
UserApiSecretService service = newService();
|
||||
|
||||
service.clear(7L, "similar-asin");
|
||||
|
||||
verify(mapper).delete(any());
|
||||
}
|
||||
|
||||
private UserApiSecretMigrateRequest.Item item(String moduleKey, String value) {
|
||||
UserApiSecretMigrateRequest.Item item = new UserApiSecretMigrateRequest.Item();
|
||||
item.setModuleKey(moduleKey);
|
||||
item.setValue(value);
|
||||
return item;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user