feat(密钥管理): 列表按用户聚合三字段列 + 代理配置服务端上报

- Java:后台列表一行一用户(货源查询密钥/外观专利密钥/代理设置),行级状态三项全通过才算通过;检测/清空改为按用户;搜索仅按用户名(去 UID);新增代理检测(经用户代理请求自家域名)与代理掩码(隐去账密)
- 后台前端:三字段列改版 + 行级状态筛选 + 用户名筛选
- 桌面前端:登录加载密钥时补报本地代理(只填空缺不覆盖)、保存/清空代理实时上报
This commit is contained in:
2026-09-13 10:47:19 +08:00
parent 82a782550e
commit b7d4d325e0
14 changed files with 665 additions and 226 deletions
@@ -20,16 +20,17 @@ import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
/**
* 后台密钥管理:管理员查看用户密钥(脱敏)、立即检测、清空。
* 不提供查看明文与代填编辑能力。
* 后台密钥管理:一行一用户(货源查询密钥 / 外观专利密钥 / 代理设置 三字段列),
* 管理员查看脱敏值、立即检测、清空。不提供查看明文与代填编辑能力。
*/
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/admin/user-secrets")
@Tag(name = "后台密钥管理", description = "查看用户密钥配置(脱敏)、立即检测连通性、清空。")
@Tag(name = "后台密钥管理", description = "按用户查看密钥与代理配置(脱敏)、立即检测连通性、清空。")
public class AdminUserApiSecretController {
private final UserApiSecretService userApiSecretService;
@@ -37,41 +38,39 @@ public class AdminUserApiSecretController {
private final AdminAuthSupport adminAuthSupport;
@GetMapping
@Operation(summary = "分页查询用户密钥", description = "keyword 匹配用户名或用户ID")
@Operation(summary = "分页查询用户密钥", description = "一行一用户;keyword 匹配用户名checkStatus 按行级状态筛选")
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(required = false) String keyword,
@Parameter(description = "行级状态筛选:passed/failed/incomplete/error/unknown") @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(
@PostMapping("/{userId}/check")
@Operation(summary = "立即检测该用户全部已配置项", description = "逐模块真实探测(密钥请求 LLM、代理请求自家域名)并把结果落库。")
public ApiResponse<List<UserApiSecretCheckResultVo>> check(
HttpServletRequest request,
@Parameter(description = "记录主键", required = true) @PathVariable Long id) {
@Parameter(description = "用户ID", required = true) @PathVariable Long userId) {
adminAuthSupport.requireAdmin(request);
return ApiResponse.success("检测完成", userApiSecretService.adminCheck(id));
return ApiResponse.success("检测完成", userApiSecretService.adminCheckByUser(userId));
}
@DeleteMapping("/{id}")
@Operation(summary = "清空指定用户密钥")
public ApiResponse<Void> clear(
@DeleteMapping("/{userId}")
@Operation(summary = "清空该用户全部密钥与代理配置")
public ApiResponse<Integer> clear(
HttpServletRequest request,
@Parameter(description = "记录主键", required = true) @PathVariable Long id) {
@Parameter(description = "用户ID", required = true) @PathVariable Long userId) {
adminAuthSupport.requireAdmin(request);
userApiSecretService.adminClear(id);
return ApiResponse.success("已清空", null);
int deleted = userApiSecretService.adminClearByUser(userId);
return ApiResponse.success("已清空", deleted);
}
@PostMapping("/check-all")
@@ -10,10 +10,7 @@ public class AdminUserSecretQuery {
@Schema(description = "关键字:匹配用户名或用户ID")
private String keyword;
@Schema(description = "密钥模块筛选:appearance-patent/similar-asin")
private String moduleKey;
@Schema(description = "连通性状态筛选:unknown/passed/failed/error")
@Schema(description = "行级状态筛选:passed/failed/incomplete/error/unknown")
private String checkStatus;
@Schema(description = "页码,从 1 开始")
@@ -6,17 +6,8 @@ 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 = "后台密钥管理-单模块状态(脱敏值 + 连通性)")
public class AdminUserSecretModuleVo {
@Schema(description = "密钥模块 key")
private String moduleKey;
@@ -30,7 +21,7 @@ public class AdminUserSecretItemVo {
@Schema(description = "是否已配置")
private Boolean exists;
@Schema(description = "连通性状态")
@Schema(description = "连通性状态unknown/passed/failed/error")
private String checkStatus;
@Schema(description = "检测结果码")
@@ -45,9 +36,6 @@ public class AdminUserSecretItemVo {
@Schema(description = "最近检测时间")
private LocalDateTime checkedAt;
@Schema(description = "写入来源:client/admin/migrated")
private String source;
@Schema(description = "更新时间")
private LocalDateTime updatedAt;
}
@@ -9,8 +9,8 @@ import java.util.List;
@Schema(description = "后台密钥管理分页结果")
public class AdminUserSecretPageVo {
@Schema(description = "列表项")
private List<AdminUserSecretItemVo> items;
@Schema(description = "列表项(一行一用户)")
private List<AdminUserSecretRowVo> items;
@Schema(description = "总条数")
private Long total;
@@ -0,0 +1,35 @@
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 AdminUserSecretRowVo {
@Schema(description = "用户ID")
private Long userId;
@Schema(description = "用户名")
private String username;
@Schema(description = "货源查询密钥")
private AdminUserSecretModuleVo similarAsin;
@Schema(description = "外观专利密钥")
private AdminUserSecretModuleVo appearancePatent;
@Schema(description = "代理设置")
private AdminUserSecretModuleVo proxy;
@Schema(description = "行级状态:passed=三类都检测通过 / failed=有检测失败 / incomplete=未配齐 / error=无法判定 / unknown=未检测")
private String status;
@Schema(description = "行级状态说明")
private String statusMessage;
@Schema(description = "最近更新时间(三模块中最晚)")
private LocalDateTime updatedAt;
}
@@ -48,8 +48,11 @@ public class UserApiSecretCheckService {
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 PROXY_READ_TIMEOUT_MILLIS = 10_000;
private static final int MAX_RESPONSE_BYTES = 1024 * 1024;
private static final int CHECK_MAX_TOKENS = 8;
/** 代理探测目标:自家域名(http 无 CONNECT 依赖,兼容各类转发型代理)。 */
private static final String PROXY_PROBE_TARGET_URL = "http://api.aishufu.top/";
private final AppearancePatentProperties appearancePatentProperties;
private final SimilarAsinProperties similarAsinProperties;
@@ -58,8 +61,11 @@ public class UserApiSecretCheckService {
private volatile RestClient directClient;
/** 探测入口:优先经代理(若配置提取链接),代理网络不可达回退直连。 */
/** 探测入口:代理模块走代理连通性探测,LLM 密钥模块优先经检测出口代理(若配置提取链接),代理网络不可达回退直连。 */
public CheckOutcome probe(UserSecretModule module, String plainApiKey) {
if (module == UserSecretModule.PROXY) {
return probeProxy(plainApiKey);
}
String proxyUrl = jikipProxyClient.isExtractConfigured() ? jikipProxyClient.fetchProxyUrl() : null;
if (proxyUrl != null) {
CheckOutcome viaProxy = probeOnce(module, plainApiKey, proxyUrl, true);
@@ -79,6 +85,41 @@ public class UserApiSecretCheckService {
return probeOnce(module, plainApiKey, null, false);
}
/**
* 代理连通性探测:经由用户配置的代理请求一次自家域名。
* 拿到任意 HTTP 响应(含 4xx/5xx)即说明代理转发可用;407 为代理自身认证失败;网络异常视为不可达。
*/
private CheckOutcome probeProxy(String proxyUrl) {
String normalized = proxyUrl == null ? "" : proxyUrl.trim();
if (normalized.isEmpty()) {
return new CheckOutcome(STATUS_FAILED, CODE_INVALID_KEY, "代理地址为空,请重新配置", null, true);
}
long startMillis = System.currentTimeMillis();
try {
RestClient client = RestClient.builder()
.requestFactory(HttpClientPool.requestFactory(PROXY_READ_TIMEOUT_MILLIS, normalized))
.build();
int statusCode = client.get()
.uri(PROXY_PROBE_TARGET_URL)
.exchange((request, response) -> response.getStatusCode().value());
long latency = System.currentTimeMillis() - startMillis;
if (statusCode == 407) {
return new CheckOutcome(STATUS_FAILED, CODE_FORBIDDEN,
"代理认证失败(407),请检查代理账号密码", (int) latency, true);
}
CheckOutcome outcome = new CheckOutcome(STATUS_PASSED, CODE_OK,
"代理连通正常(HTTP " + statusCode + "", (int) latency, true);
log.info("[user-secret][check] 代理探测完成 status=passed code=ok httpStatus={} latency={}ms",
statusCode, latency);
return outcome;
} catch (Exception ex) {
long latency = System.currentTimeMillis() - startMillis;
log.warn("[user-secret][check] 代理探测异常 latency={}ms err={}", latency, ex.getMessage());
return new CheckOutcome(STATUS_FAILED, CODE_NETWORK_ERROR,
"代理不可达:" + rootCauseMessage(ex), (int) latency, true);
}
}
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");
@@ -1,7 +1,6 @@
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;
@@ -11,8 +10,9 @@ 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.AdminUserSecretModuleVo;
import com.nanri.aiimage.modules.usersecret.model.vo.AdminUserSecretPageVo;
import com.nanri.aiimage.modules.usersecret.model.vo.AdminUserSecretRowVo;
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;
@@ -23,18 +23,23 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.net.URI;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
/**
* 用户 API 密钥服务端存储:按登录用户绑定、AES 加密落库,
* 提供完整性包、迁移、任务兜底读取、连通性检测落库与后台管理查询。
* 后台列表按用户聚合(一行三列:货源查询密钥 / 外观专利密钥 / 代理设置)。
*/
@Service
@RequiredArgsConstructor
@@ -45,9 +50,18 @@ public class UserApiSecretService {
public static final String SOURCE_ADMIN = "admin";
public static final String SOURCE_MIGRATED = "migrated";
/** 行级状态:三类全部检测通过才算 passedfailed > incomplete > error > unknown。 */
public static final String ROW_STATUS_PASSED = "passed";
public static final String ROW_STATUS_FAILED = "failed";
public static final String ROW_STATUS_INCOMPLETE = "incomplete";
public static final String ROW_STATUS_ERROR = "error";
public static final String ROW_STATUS_UNKNOWN = "unknown";
private static final String STATUS_UNKNOWN = "unknown";
private static final int MASK_MIN_LENGTH = 8;
private static final int MESSAGE_MAX_LENGTH = 500;
private static final String PROXY_FORMAT_HINT =
"代理地址格式不正确,应形如 http://host:port 或 http://user:pass@host:port";
private final UserApiSecretMapper userApiSecretMapper;
private final ShopCredentialCryptoService cryptoService;
@@ -55,16 +69,17 @@ public class UserApiSecretService {
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()) {
List<UserSecretModule> required = UserSecretModule.requiredModules();
List<UserApiSecretItemVo> items = new ArrayList<>(required.size());
for (UserSecretModule module : required) {
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.setRequiredModules(required.stream().map(UserSecretModule::key).toList());
vo.setComplete(isComplete(items));
return vo;
}
@@ -76,7 +91,10 @@ public class UserApiSecretService {
UserSecretModule module = requireModule(moduleKey);
String plainValue = normalize(value);
if (plainValue.isEmpty()) {
throw new BusinessException("密钥不能为空");
throw new BusinessException(module == UserSecretModule.PROXY ? "代理地址不能为空" : "密钥不能为空");
}
if (module == UserSecretModule.PROXY) {
validateProxyValue(plainValue);
}
upsert(userId, module.key(), plainValue, SOURCE_CLIENT);
log.info("[user-secret] 密钥已保存 userId={} module={}", userId, module.key());
@@ -149,13 +167,16 @@ public class UserApiSecretService {
if (plainKey.isEmpty()) {
UserApiSecretEntity row = selectOne(userId, module.key());
if (row == null || !hasText(row.getSecretValue())) {
throw new BusinessException("请先保存密钥后再检测");
throw new BusinessException(module == UserSecretModule.PROXY
? "请先保存代理地址后再检测" : "请先保存密钥后再检测");
}
plainKey = normalize(cryptoService.decrypt(row.getSecretValue()));
if (plainKey.isEmpty()) {
throw new BusinessException("密钥内容为空,请重新配置");
throw new BusinessException("配置内容为空,请重新配置");
}
persist = true;
} else if (module == UserSecretModule.PROXY) {
validateProxyValue(plainKey);
}
UserApiSecretCheckService.CheckOutcome outcome = checkService.probe(module, plainKey);
UserApiSecretCheckResultVo vo = toCheckResult(module, outcome);
@@ -173,7 +194,11 @@ public class UserApiSecretService {
return jikipProxyClient.fetchBalance();
}
/** 后台分页:关键字匹配用户名或用户ID。 */
/**
* 后台分页:一行一用户,聚合货源查询密钥/外观专利密钥/代理设置三个字段列,并计算行级状态。
* 先按关键字圈定用户,再全量聚合、行级状态筛选、按最近更新时间倒序后内存分页
* (当前规模为用户数×3,内存聚合成本可控;量级上来后可改为 GROUP BY 下推)。
*/
public AdminUserSecretPageVo adminPage(AdminUserSecretQuery query) {
AdminUserSecretQuery safeQuery = query == null ? new AdminUserSecretQuery() : query;
long page = safeQuery.getPage() == null || safeQuery.getPage() < 1 ? 1L : safeQuery.getPage();
@@ -189,53 +214,86 @@ public class UserApiSecretService {
}
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);
List<UserApiSecretEntity> rows = userApiSecretMapper.selectList(wrapper);
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));
Map<Long, Map<String, UserApiSecretEntity>> grouped = new LinkedHashMap<>();
for (UserApiSecretEntity row : rows) {
if (row.getUserId() == null) {
continue;
}
grouped.computeIfAbsent(row.getUserId(), key -> new HashMap<>()).put(row.getModuleKey(), row);
}
List<AdminUserSecretRowVo> all = new ArrayList<>(grouped.size());
for (Map.Entry<Long, Map<String, UserApiSecretEntity>> entry : grouped.entrySet()) {
all.add(buildAdminRow(entry.getKey(), entry.getValue()));
}
String statusFilter = normalize(safeQuery.getCheckStatus());
if (!statusFilter.isEmpty()) {
all.removeIf(rowVo -> !statusFilter.equals(rowVo.getStatus()));
}
all.sort(Comparator.comparing(AdminUserSecretRowVo::getUpdatedAt,
Comparator.nullsLast(Comparator.reverseOrder())));
long total = all.size();
int from = (int) Math.min((page - 1) * pageSize, total);
int to = (int) Math.min(from + pageSize, total);
AdminUserSecretPageVo vo = new AdminUserSecretPageVo();
vo.setItems(items);
vo.setTotal(result.getTotal());
vo.setItems(new ArrayList<>(all.subList(from, to)));
vo.setTotal(total);
vo.setPage(page);
vo.setPageSize(pageSize);
log.info("[user-secret] 后台密钥列表 keyword={} statusFilter={} 聚合用户数={} 本页返回={}",
keyword, statusFilter, total, vo.getItems().size());
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());
/** 后台:检测该用户全部已配置模块并落库(未配置项跳过;解密失败落 failed 并计入结果)。 */
public List<UserApiSecretCheckResultVo> adminCheckByUser(Long userId) {
requireUserId(userId);
List<UserApiSecretCheckResultVo> results = new ArrayList<>();
for (UserSecretModule module : UserSecretModule.values()) {
UserApiSecretEntity row = selectOne(userId, module.key());
if (row == null || !hasText(row.getSecretValue())) {
continue;
}
UserApiSecretCheckService.CheckOutcome outcome;
try {
String plainValue = normalize(cryptoService.decrypt(row.getSecretValue()));
if (plainValue.isEmpty()) {
outcome = new UserApiSecretCheckService.CheckOutcome(
UserApiSecretCheckService.STATUS_FAILED,
UserApiSecretCheckService.CODE_INVALID_KEY,
"配置内容为空,请重新配置", null, false);
} else {
outcome = checkService.probe(module, plainValue);
}
} catch (Exception ex) {
log.warn("[user-secret] 后台检测解密失败 userId={} module={} err={}", userId, module.key(), ex.getMessage());
outcome = new UserApiSecretCheckService.CheckOutcome(
UserApiSecretCheckService.STATUS_FAILED,
UserApiSecretCheckService.CODE_INVALID_KEY,
"配置内容解密失败,请让用户重新配置", null, false);
}
applyCheckOutcome(userId, module.key(), outcome);
UserApiSecretCheckResultVo vo = toCheckResult(module, outcome);
vo.setCheckedAt(LocalDateTime.now());
results.add(vo);
log.info("[user-secret] 后台检测完成 userId={} module={} status={} code={}",
userId, module.key(), outcome.status(), outcome.code());
}
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;
return results;
}
/** 后台:清空指定记录。 */
/** 后台:清空该用户全部密钥与代理配置。 */
@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());
public int adminClearByUser(Long userId) {
requireUserId(userId);
int deleted = userApiSecretMapper.delete(new LambdaQueryWrapper<UserApiSecretEntity>()
.eq(UserApiSecretEntity::getUserId, userId));
log.info("[user-secret] 后台清空用户全部配置 userId={} 删除={} 条", userId, deleted);
return deleted;
}
/**
@@ -275,7 +333,7 @@ public class UserApiSecretService {
applyCheckOutcome(row.getUserId(), module.get().key(), new UserApiSecretCheckService.CheckOutcome(
UserApiSecretCheckService.STATUS_FAILED,
UserApiSecretCheckService.CODE_INVALID_KEY,
"密钥内容为空,请重新配置", null, false));
"配置内容为空,请重新配置", null, false));
failed++;
checked++;
continue;
@@ -310,6 +368,87 @@ public class UserApiSecretService {
return summary;
}
private AdminUserSecretRowVo buildAdminRow(Long userId, Map<String, UserApiSecretEntity> moduleRows) {
AdminUserSecretRowVo vo = new AdminUserSecretRowVo();
vo.setUserId(userId);
AdminUserSecretModuleVo similarAsin = toModuleVo(UserSecretModule.SIMILAR_ASIN,
moduleRows.get(UserSecretModule.SIMILAR_ASIN.key()));
AdminUserSecretModuleVo appearancePatent = toModuleVo(UserSecretModule.APPEARANCE_PATENT,
moduleRows.get(UserSecretModule.APPEARANCE_PATENT.key()));
AdminUserSecretModuleVo proxy = toModuleVo(UserSecretModule.PROXY,
moduleRows.get(UserSecretModule.PROXY.key()));
vo.setSimilarAsin(similarAsin);
vo.setAppearancePatent(appearancePatent);
vo.setProxy(proxy);
List<AdminUserSecretModuleVo> modules = List.of(similarAsin, appearancePatent, proxy);
vo.setStatus(summarizeRowStatus(modules));
vo.setStatusMessage(summarizeRowMessage(modules, vo.getStatus()));
vo.setUpdatedAt(latestUpdatedAt(modules));
AdminUserEntity user = adminUserMapper.selectById(userId);
vo.setUsername(user == null ? "" : user.getUsername());
return vo;
}
/** 行级状态:三类都检测通过才算通过;failed > incomplete > error > unknown,最后才是 passed。 */
String summarizeRowStatus(List<AdminUserSecretModuleVo> modules) {
boolean anyFailed = false;
boolean anyMissing = false;
boolean anyError = false;
boolean allPassed = true;
for (AdminUserSecretModuleVo module : modules) {
if (!Boolean.TRUE.equals(module.getExists())) {
anyMissing = true;
allPassed = false;
continue;
}
String status = module.getCheckStatus();
if (UserApiSecretCheckService.STATUS_FAILED.equals(status)) {
anyFailed = true;
allPassed = false;
} else if (UserApiSecretCheckService.STATUS_ERROR.equals(status)) {
anyError = true;
allPassed = false;
} else if (!UserApiSecretCheckService.STATUS_PASSED.equals(status)) {
allPassed = false;
}
}
if (anyFailed) {
return ROW_STATUS_FAILED;
}
if (anyMissing) {
return ROW_STATUS_INCOMPLETE;
}
if (allPassed) {
return ROW_STATUS_PASSED;
}
if (anyError) {
return ROW_STATUS_ERROR;
}
return ROW_STATUS_UNKNOWN;
}
private String summarizeRowMessage(List<AdminUserSecretModuleVo> modules, String status) {
long missing = modules.stream().filter(module -> !Boolean.TRUE.equals(module.getExists())).count();
return switch (status) {
case ROW_STATUS_PASSED -> "三项均检测通过";
case ROW_STATUS_FAILED -> "存在检测失败的配置";
case ROW_STATUS_INCOMPLETE -> missing + " 项未配置";
case ROW_STATUS_ERROR -> "存在无法判定的检测结果";
default -> "存在尚未检测的配置";
};
}
private LocalDateTime latestUpdatedAt(List<AdminUserSecretModuleVo> modules) {
LocalDateTime latest = null;
for (AdminUserSecretModuleVo module : modules) {
LocalDateTime value = module.getUpdatedAt();
if (value != null && (latest == null || value.isAfter(latest))) {
latest = value;
}
}
return latest;
}
private void upsert(Long userId, String moduleKey, String plainValue, String source) {
LocalDateTime now = LocalDateTime.now();
UserApiSecretEntity existing = selectOne(userId, moduleKey);
@@ -357,26 +496,9 @@ public class UserApiSecretService {
.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;
}
/** 关键字圈定用户:仅按用户名模糊匹配(页面不提供 UID 搜索)。 */
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"));
@@ -388,26 +510,27 @@ public class UserApiSecretService {
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()));
private AdminUserSecretModuleVo toModuleVo(UserSecretModule module, UserApiSecretEntity row) {
AdminUserSecretModuleVo vo = new AdminUserSecretModuleVo();
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.setMasked(maskValue(module, plain));
vo.setExists(hasText(plain));
vo.setCheckStatus(row.getCheckStatus());
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.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;
}
@@ -424,7 +547,7 @@ public class UserApiSecretService {
return vo;
}
String plain = decryptQuietly(row.getSecretValue());
vo.setMasked(mask(plain));
vo.setMasked(maskValue(module, plain));
vo.setExists(hasText(plain));
vo.setCheckStatus(hasText(row.getCheckStatus()) ? row.getCheckStatus() : STATUS_UNKNOWN);
vo.setCheckCode(row.getCheckCode());
@@ -486,6 +609,22 @@ public class UserApiSecretService {
}
}
/** 代理地址校验:http(s):// 开头且含 host:port(账密可省略)。 */
private void validateProxyValue(String value) {
try {
URI uri = URI.create(value);
boolean schemeOk = uri.getScheme() != null
&& (uri.getScheme().equalsIgnoreCase("http") || uri.getScheme().equalsIgnoreCase("https"));
if (!schemeOk || uri.getHost() == null || uri.getHost().isBlank() || uri.getPort() <= 0) {
throw new BusinessException(PROXY_FORMAT_HINT);
}
} catch (BusinessException ex) {
throw ex;
} catch (Exception ex) {
throw new BusinessException(PROXY_FORMAT_HINT);
}
}
private String decryptQuietly(String cipherText) {
if (!hasText(cipherText)) {
return "";
@@ -498,6 +637,13 @@ public class UserApiSecretService {
}
}
private String maskValue(UserSecretModule module, String plainValue) {
if (module == UserSecretModule.PROXY) {
return maskProxy(plainValue);
}
return mask(plainValue);
}
private String mask(String value) {
if (!hasText(value)) {
return "";
@@ -509,6 +655,31 @@ public class UserApiSecretService {
return text.substring(0, 4) + "****" + text.substring(text.length() - 4);
}
/** 代理掩码:隐去账号密码,保留 scheme://host:port 便于运维核对。 */
private String maskProxy(String value) {
if (!hasText(value)) {
return "";
}
try {
URI uri = URI.create(value.trim());
if (uri.getHost() == null || uri.getHost().isBlank()) {
return mask(value);
}
StringBuilder masked = new StringBuilder();
masked.append(uri.getScheme() == null ? "http" : uri.getScheme()).append("://");
if (uri.getUserInfo() != null && !uri.getUserInfo().isBlank()) {
masked.append("***@");
}
masked.append(uri.getHost());
if (uri.getPort() > 0) {
masked.append(':').append(uri.getPort());
}
return masked.toString();
} catch (Exception ex) {
return mask(value);
}
}
private String truncate(String value, int maxLength) {
String normalized = value == null ? "" : value.trim();
if (normalized.length() <= maxLength) {
@@ -3,23 +3,29 @@ package com.nanri.aiimage.modules.usersecret.support;
import com.nanri.aiimage.config.AppearancePatentProperties;
import com.nanri.aiimage.config.SimilarAsinProperties;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
/**
* 用户密钥模块:key / 显示名 / 检测目标(LLM host + model)的唯一来源。
* 用户密钥模块:key / 显示名 / 是否必填(参与桌面端门禁)的唯一来源。
* 新增密钥模块时在此登记,控制器与服务层不再散落字符串。
*/
public enum UserSecretModule {
APPEARANCE_PATENT("appearance-patent", "外观专利密钥"),
SIMILAR_ASIN("similar-asin", "货源查询密钥");
APPEARANCE_PATENT("appearance-patent", "外观专利密钥", true),
SIMILAR_ASIN("similar-asin", "货源查询密钥", true),
/** 客户端任务出口代理:仅服务端观测/检测,代理为选配,不参与桌面端门禁与密钥包。 */
PROXY("proxy", "代理设置", false);
private final String key;
private final String label;
private final boolean required;
UserSecretModule(String key, String label) {
UserSecretModule(String key, String label, boolean required) {
this.key = key;
this.label = label;
this.required = required;
}
public String key() {
@@ -30,7 +36,12 @@ public enum UserSecretModule {
return label;
}
/** 检测目标:LLM 主机 + 模型(取各模块自身配置,默认同为 ai.t8star.org。 */
/** 是否用户端必填:参与密钥包下发与桌面端完整性门禁。 */
public boolean required() {
return required;
}
/** 检测目标:LLM 主机 + 模型(仅 LLM 类模块;代理模块没有 LLM 目标)。 */
public LlmTarget resolveLlmTarget(AppearancePatentProperties appearancePatentProperties,
SimilarAsinProperties similarAsinProperties) {
return switch (this) {
@@ -40,9 +51,15 @@ public enum UserSecretModule {
case SIMILAR_ASIN -> new LlmTarget(
similarAsinProperties.getLlmHost(),
similarAsinProperties.getLlmCategoryModel());
case PROXY -> throw new IllegalStateException("代理模块没有 LLM 检测目标");
};
}
/** 桌面端门禁模块:密钥包下发与完整性判定的唯一来源。 */
public static List<UserSecretModule> requiredModules() {
return Arrays.stream(values()).filter(UserSecretModule::required).toList();
}
public static Optional<UserSecretModule> of(String key) {
if (key == null) {
return Optional.empty();
@@ -2,10 +2,13 @@ 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.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.AdminUserSecretModuleVo;
import com.nanri.aiimage.modules.usersecret.model.vo.UserApiSecretBundleVo;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
@@ -154,6 +157,102 @@ class UserApiSecretServiceTest {
verify(mapper).delete(any());
}
@Test
void bundleExcludesProxyModule() {
UserApiSecretService service = newService();
when(mapper.selectOne(any())).thenReturn(null);
UserApiSecretBundleVo bundle = service.bundle(7L);
assertThat(bundle.getItems()).extracting(item -> item.getModuleKey())
.containsExactlyInAnyOrder("appearance-patent", "similar-asin");
assertThat(bundle.getRequiredModules()).doesNotContain("proxy");
}
@Test
void saveRejectsInvalidProxyUrl() {
UserApiSecretService service = newService();
org.assertj.core.api.Assertions.assertThatThrownBy(() -> service.save(7L, "proxy", "1.2.3.4:8080"))
.isInstanceOf(com.nanri.aiimage.common.exception.BusinessException.class)
.hasMessageContaining("代理地址格式不正确");
}
@Test
void summarizeRequiresAllThreePassed() {
UserApiSecretService service = newService();
assertThat(service.summarizeRowStatus(List.of(
moduleVo(true, "passed"), moduleVo(true, "passed"), moduleVo(true, "passed")))
).isEqualTo("passed");
assertThat(service.summarizeRowStatus(List.of(
moduleVo(true, "passed"), moduleVo(false, "unknown"), moduleVo(true, "passed")))
).isEqualTo("incomplete");
assertThat(service.summarizeRowStatus(List.of(
moduleVo(true, "failed"), moduleVo(false, "unknown"), moduleVo(true, "passed")))
).isEqualTo("failed");
assertThat(service.summarizeRowStatus(List.of(
moduleVo(true, "passed"), moduleVo(true, "error"), moduleVo(true, "passed")))
).isEqualTo("error");
assertThat(service.summarizeRowStatus(List.of(
moduleVo(true, "passed"), moduleVo(true, "unknown"), moduleVo(true, "passed")))
).isEqualTo("unknown");
}
@Test
void adminPageAggregatesOneRowPerUserWithProxyMasked() {
UserApiSecretService service = newService();
when(mapper.selectList(any())).thenReturn(List.of(
row(1L, "similar-asin", "enc:sk-sa-1234", "passed"),
row(1L, "appearance-patent", "enc:sk-ap-5678", "passed"),
row(1L, "proxy", "enc:http://user:pass@1.2.3.4:8080", "failed")));
AdminUserEntity user = new AdminUserEntity();
user.setId(1L);
user.setUsername("张三");
when(adminUserMapper.selectById(1L)).thenReturn(user);
var page = service.adminPage(new AdminUserSecretQuery());
assertThat(page.getItems()).hasSize(1);
var rowVo = page.getItems().get(0);
assertThat(rowVo.getUsername()).isEqualTo("张三");
assertThat(rowVo.getStatus()).isEqualTo("failed");
assertThat(rowVo.getSimilarAsin().getMasked()).isEqualTo("sk-s****1234");
assertThat(rowVo.getProxy().getExists()).isTrue();
assertThat(rowVo.getProxy().getMasked()).isEqualTo("http://***@1.2.3.4:8080");
}
@Test
void adminClearByUserDeletesAllRowsOfUser() {
UserApiSecretService service = newService();
service.adminClearByUser(7L);
verify(mapper).delete(any());
}
private AdminUserSecretModuleVo moduleVo(boolean exists, String status) {
AdminUserSecretModuleVo vo = new AdminUserSecretModuleVo();
vo.setExists(exists);
vo.setCheckStatus(status);
return vo;
}
private UserApiSecretEntity row(Long userId, String moduleKey, String cipher, String status) {
UserApiSecretEntity entity = new UserApiSecretEntity();
entity.setId((long) (Math.random() * 100000));
entity.setUserId(userId);
entity.setModuleKey(moduleKey);
entity.setSecretValue(cipher);
entity.setCheckStatus(status);
entity.setUpdatedAt(java.time.LocalDateTime.now());
return entity;
}
private UserApiSecretMigrateRequest.Item item(String moduleKey, String value) {
UserApiSecretMigrateRequest.Item item = new UserApiSecretMigrateRequest.Item();
item.setModuleKey(moduleKey);