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
@@ -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;
}
}