feat(密钥): 用户 API 密钥服务端化——V115 按账号绑定存储 + 后台密钥管理页 + 桌面端全站拦截与配置引导

- 新增 usersecret 模块:外观专利/货源查询密钥从本地 localStorage 迁移至 biz_user_api_secret(AES 加密、按 uid 绑定)
- 后台「密钥管理」页:脱敏展示、立即检测、清空;每日 04:30 分布式锁定时巡检
- 桌面端:密钥设置面板走服务端、未配齐引导 /setup-secrets、代理余量展示
- 删除专利汇令牌全链路与密钥保留时长选择器
This commit is contained in:
2026-09-13 10:03:59 +08:00
parent d1b56918fa
commit 82a782550e
61 changed files with 4108 additions and 655 deletions
@@ -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:30Asia/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 = "";
}
@@ -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;
}
@@ -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;
@@ -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<>();
@@ -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);
@@ -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"),
@@ -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")
@@ -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-effortservice 内部所有异常都已吞掉不影响主流程
*/
private final SimilarAsinImagePrefetchService imagePrefetchService;
private final UserApiSecretService userApiSecretService;
/**
* Task 89Excel 行解析器表头/单元格读取别名匹配空行跳过单字段截断
* 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) {
@@ -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();
}
}
@@ -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()));
}
}
@@ -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();
}
}
@@ -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> {
}
@@ -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;
}
@@ -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;
}
@@ -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 = "密钥模块 keyappearance-patent/similar-asin", requiredMode = Schema.RequiredMode.REQUIRED)
private String moduleKey;
@Schema(description = "密钥明文", requiredMode = Schema.RequiredMode.REQUIRED)
private String value;
}
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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);
}
}
}
@@ -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) {
}
}
@@ -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) {
}
}
@@ -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 '用户IDusers.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'
);
@@ -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 签名不变 ----------
@@ -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) {
@@ -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() {
@@ -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);
}
}
@@ -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;
}
}