feat(密钥): 用户 API 密钥服务端化——V115 按账号绑定存储 + 后台密钥管理页 + 桌面端全站拦截与配置引导
- 新增 usersecret 模块:外观专利/货源查询密钥从本地 localStorage 迁移至 biz_user_api_secret(AES 加密、按 uid 绑定) - 后台「密钥管理」页:脱敏展示、立即检测、清空;每日 04:30 分布式锁定时巡检 - 桌面端:密钥设置面板走服务端、未配齐引导 /setup-secrets、代理余量展示 - 删除专利汇令牌全链路与密钥保留时长选择器
This commit is contained in:
+5
-2
@@ -26,6 +26,7 @@ import com.nanri.aiimage.modules.task.service.TaskFileJobService;
|
||||
import com.nanri.aiimage.modules.task.service.TaskProgressLightAssembler;
|
||||
import com.nanri.aiimage.modules.task.service.TaskProgressSnapshotService;
|
||||
import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService;
|
||||
import com.nanri.aiimage.modules.usersecret.service.UserApiSecretService;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.apache.poi.ss.usermodel.Row;
|
||||
import org.apache.poi.ss.usermodel.Sheet;
|
||||
@@ -102,7 +103,8 @@ class AppearancePatentTaskServiceDelegationTest {
|
||||
properties, taskFileJobService, taskProgressSnapshotService,
|
||||
transientPayloadStorageService, transactionManager, distributedJobLockService,
|
||||
taskDistributedLockService, instanceMetadata,
|
||||
mock(TaskProgressLightAssembler.class));
|
||||
mock(TaskProgressLightAssembler.class),
|
||||
mock(UserApiSecretService.class));
|
||||
}
|
||||
|
||||
private AppearancePatentTaskService serviceWithoutTransactionManager() {
|
||||
@@ -112,7 +114,8 @@ class AppearancePatentTaskServiceDelegationTest {
|
||||
properties, taskFileJobService, taskProgressSnapshotService,
|
||||
transientPayloadStorageService, null, distributedJobLockService,
|
||||
taskDistributedLockService, instanceMetadata,
|
||||
mock(TaskProgressLightAssembler.class));
|
||||
mock(TaskProgressLightAssembler.class),
|
||||
mock(UserApiSecretService.class));
|
||||
}
|
||||
|
||||
// ---------- 1 签名不变 ----------
|
||||
|
||||
+3
-1
@@ -26,6 +26,7 @@ import com.nanri.aiimage.modules.task.service.TaskFileJobService;
|
||||
import com.nanri.aiimage.modules.task.service.TaskProgressLightAssembler;
|
||||
import com.nanri.aiimage.modules.task.service.TaskProgressSnapshotService;
|
||||
import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService;
|
||||
import com.nanri.aiimage.modules.usersecret.service.UserApiSecretService;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
@@ -211,7 +212,8 @@ class AppearancePatentTaskServiceHistoryBatchTest {
|
||||
properties, taskFileJobService, taskProgressSnapshotService,
|
||||
transientPayloadStorageService, transactionManager, distributedJobLockService,
|
||||
taskDistributedLockService, instanceMetadata,
|
||||
mock(TaskProgressLightAssembler.class));
|
||||
mock(TaskProgressLightAssembler.class),
|
||||
mock(UserApiSecretService.class));
|
||||
}
|
||||
|
||||
private static FileResultEntity result(Long id, Long taskId, Long userId, LocalDateTime createdAt) {
|
||||
|
||||
+3
-1
@@ -32,6 +32,7 @@ import com.nanri.aiimage.modules.task.service.TaskFileJobService;
|
||||
import com.nanri.aiimage.modules.task.service.TaskProgressLightAssembler;
|
||||
import com.nanri.aiimage.modules.task.service.TaskProgressSnapshotService;
|
||||
import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService;
|
||||
import com.nanri.aiimage.modules.usersecret.service.UserApiSecretService;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
@@ -300,7 +301,8 @@ class RollbackSemanticsContractTest {
|
||||
mock(AppearancePatentTaskCacheService.class), mock(com.nanri.aiimage.config.AppearancePatentProperties.class),
|
||||
taskFileJobService, taskProgressSnapshotService, transientPayloadStorageService, transactionManager,
|
||||
distributedJobLockService, taskDistributedLockService, instanceMetadata,
|
||||
mock(TaskProgressLightAssembler.class));
|
||||
mock(TaskProgressLightAssembler.class),
|
||||
mock(UserApiSecretService.class));
|
||||
}
|
||||
|
||||
private SimilarAsinSubmitResultRequest request() {
|
||||
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
package com.nanri.aiimage.modules.usersecret.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.nanri.aiimage.config.AppearancePatentProperties;
|
||||
import com.nanri.aiimage.config.SimilarAsinProperties;
|
||||
import com.nanri.aiimage.modules.usersecret.client.JikipProxyClient;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
/** 检测结果分类矩阵:passed=放行,failed=拦截,error=无法判定(放行但警示)。 */
|
||||
class UserApiSecretCheckServiceTest {
|
||||
|
||||
private final UserApiSecretCheckService service = new UserApiSecretCheckService(
|
||||
new AppearancePatentProperties(),
|
||||
new SimilarAsinProperties(),
|
||||
mock(JikipProxyClient.class),
|
||||
new ObjectMapper());
|
||||
|
||||
@Test
|
||||
void classifyPassedWhenChoicesPresent() {
|
||||
UserApiSecretCheckService.CheckOutcome outcome =
|
||||
service.classify(200, "{\"choices\":[{\"message\":{\"content\":\"ok\"}}]}", 120, false);
|
||||
|
||||
assertThat(outcome.status()).isEqualTo(UserApiSecretCheckService.STATUS_PASSED);
|
||||
assertThat(outcome.code()).isEqualTo(UserApiSecretCheckService.CODE_OK);
|
||||
assertThat(outcome.latencyMs()).isEqualTo(120);
|
||||
assertThat(outcome.viaProxy()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void classifyMissingChoicesOnSuccessIsFailed() {
|
||||
UserApiSecretCheckService.CheckOutcome outcome = service.classify(200, "{}", 90, false);
|
||||
|
||||
assertThat(outcome.status()).isEqualTo(UserApiSecretCheckService.STATUS_FAILED);
|
||||
assertThat(outcome.code()).isEqualTo(UserApiSecretCheckService.CODE_PROVIDER_ERROR);
|
||||
}
|
||||
|
||||
@Test
|
||||
void classifyErrorNodeIsFailed() {
|
||||
UserApiSecretCheckService.CheckOutcome outcome =
|
||||
service.classify(200, "{\"error\":{\"message\":\"quota exceeded\"}}", 88, false);
|
||||
|
||||
assertThat(outcome.status()).isEqualTo(UserApiSecretCheckService.STATUS_FAILED);
|
||||
assertThat(outcome.code()).isEqualTo(UserApiSecretCheckService.CODE_PROVIDER_ERROR);
|
||||
assertThat(outcome.message()).contains("quota exceeded");
|
||||
}
|
||||
|
||||
@Test
|
||||
void classifyInvalidKeyOn401() {
|
||||
UserApiSecretCheckService.CheckOutcome outcome = service.classify(401, "unauthorized", 45, true);
|
||||
|
||||
assertThat(outcome.status()).isEqualTo(UserApiSecretCheckService.STATUS_FAILED);
|
||||
assertThat(outcome.code()).isEqualTo(UserApiSecretCheckService.CODE_INVALID_KEY);
|
||||
assertThat(outcome.viaProxy()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void classifyForbiddenOn403() {
|
||||
UserApiSecretCheckService.CheckOutcome outcome = service.classify(403, "forbidden", 50, false);
|
||||
|
||||
assertThat(outcome.status()).isEqualTo(UserApiSecretCheckService.STATUS_FAILED);
|
||||
assertThat(outcome.code()).isEqualTo(UserApiSecretCheckService.CODE_FORBIDDEN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void classifyBadRequestOn400() {
|
||||
UserApiSecretCheckService.CheckOutcome outcome = service.classify(400, "bad", 30, false);
|
||||
|
||||
assertThat(outcome.status()).isEqualTo(UserApiSecretCheckService.STATUS_FAILED);
|
||||
assertThat(outcome.code()).isEqualTo(UserApiSecretCheckService.CODE_BAD_REQUEST);
|
||||
}
|
||||
|
||||
@Test
|
||||
void classifyRateLimitedIsErrorNotFailed() {
|
||||
UserApiSecretCheckService.CheckOutcome outcome = service.classify(429, "too many", 20, false);
|
||||
|
||||
assertThat(outcome.status()).isEqualTo(UserApiSecretCheckService.STATUS_ERROR);
|
||||
assertThat(outcome.code()).isEqualTo(UserApiSecretCheckService.CODE_RATE_LIMITED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void classifyServerErrorIsError() {
|
||||
UserApiSecretCheckService.CheckOutcome outcome = service.classify(503, "unavailable", 60, false);
|
||||
|
||||
assertThat(outcome.status()).isEqualTo(UserApiSecretCheckService.STATUS_ERROR);
|
||||
assertThat(outcome.code()).isEqualTo(UserApiSecretCheckService.CODE_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
package com.nanri.aiimage.modules.usersecret.service;
|
||||
|
||||
import com.nanri.aiimage.common.security.ShopCredentialCryptoService;
|
||||
import com.nanri.aiimage.modules.permission.mapper.AdminUserMapper;
|
||||
import com.nanri.aiimage.modules.usersecret.client.JikipProxyClient;
|
||||
import com.nanri.aiimage.modules.usersecret.mapper.UserApiSecretMapper;
|
||||
import com.nanri.aiimage.modules.usersecret.model.dto.UserApiSecretMigrateRequest;
|
||||
import com.nanri.aiimage.modules.usersecret.model.entity.UserApiSecretEntity;
|
||||
import com.nanri.aiimage.modules.usersecret.model.vo.UserApiSecretBundleVo;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class UserApiSecretServiceTest {
|
||||
|
||||
private final UserApiSecretMapper mapper = mock(UserApiSecretMapper.class);
|
||||
private final ShopCredentialCryptoService crypto = mock(ShopCredentialCryptoService.class);
|
||||
private final UserApiSecretCheckService checkService = mock(UserApiSecretCheckService.class);
|
||||
private final JikipProxyClient jikipProxyClient = mock(JikipProxyClient.class);
|
||||
private final AdminUserMapper adminUserMapper = mock(AdminUserMapper.class);
|
||||
|
||||
private UserApiSecretService newService() {
|
||||
when(crypto.encrypt(anyString())).thenAnswer(inv -> "enc:" + inv.getArgument(0, String.class));
|
||||
when(crypto.decrypt(anyString())).thenAnswer(inv -> {
|
||||
String value = inv.getArgument(0, String.class);
|
||||
return value.startsWith("enc:") ? value.substring(4) : value;
|
||||
});
|
||||
return new UserApiSecretService(mapper, crypto, checkService, jikipProxyClient, adminUserMapper);
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveEncryptsValueAndResetsCheckState() {
|
||||
UserApiSecretService service = newService();
|
||||
UserApiSecretEntity existing = new UserApiSecretEntity();
|
||||
existing.setId(5L);
|
||||
existing.setUserId(7L);
|
||||
existing.setModuleKey("appearance-patent");
|
||||
existing.setSecretValue("enc:old-key");
|
||||
existing.setCheckStatus("passed");
|
||||
when(mapper.selectOne(any())).thenReturn(existing);
|
||||
|
||||
service.save(7L, "appearance-patent", "sk-new");
|
||||
|
||||
ArgumentCaptor<UserApiSecretEntity> captor = ArgumentCaptor.forClass(UserApiSecretEntity.class);
|
||||
verify(mapper).updateById(captor.capture());
|
||||
UserApiSecretEntity updated = captor.getValue();
|
||||
assertThat(updated.getSecretValue()).isEqualTo("enc:sk-new");
|
||||
assertThat(updated.getCheckStatus()).isEqualTo("unknown");
|
||||
assertThat(updated.getCheckedAt()).isNull();
|
||||
assertThat(updated.getCheckCode()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void findPlainValueReturnsEmptyWhenDecryptFails() {
|
||||
UserApiSecretService service = newService();
|
||||
UserApiSecretEntity row = new UserApiSecretEntity();
|
||||
row.setSecretValue("broken");
|
||||
when(mapper.selectOne(any())).thenReturn(row);
|
||||
when(crypto.decrypt("broken")).thenThrow(new IllegalStateException("解密失败"));
|
||||
|
||||
assertThat(service.findPlainValue(7L, "appearance-patent")).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void findPlainValueReturnsEmptyForInvalidUserId() {
|
||||
UserApiSecretService service = newService();
|
||||
assertThat(service.findPlainValue(null, "appearance-patent")).isEmpty();
|
||||
assertThat(service.findPlainValue(0L, "appearance-patent")).isEmpty();
|
||||
verify(mapper, never()).selectOne(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void migrateSkipsWhenServerValueExists() {
|
||||
UserApiSecretService service = newService();
|
||||
UserApiSecretEntity existing = new UserApiSecretEntity();
|
||||
existing.setId(9L);
|
||||
existing.setSecretValue("enc:existing");
|
||||
when(mapper.selectOne(any())).thenReturn(existing);
|
||||
|
||||
int migrated = service.migrateIfAbsent(7L, List.of(item("appearance-patent", "local-key")));
|
||||
|
||||
assertThat(migrated).isZero();
|
||||
verify(mapper, never()).insert(any(UserApiSecretEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void migrateWritesOnlyMissingModules() {
|
||||
UserApiSecretService service = newService();
|
||||
when(mapper.selectOne(any())).thenReturn(null);
|
||||
|
||||
int migrated = service.migrateIfAbsent(7L, List.of(
|
||||
item("appearance-patent", "app-key"),
|
||||
item("similar-asin", "asin-key"),
|
||||
item("appearance-patent-token", "legacy-token")));
|
||||
|
||||
assertThat(migrated).isEqualTo(2);
|
||||
verify(mapper, org.mockito.Mockito.times(2)).insert(any(UserApiSecretEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void bundleIncompleteWhenNothingConfigured() {
|
||||
UserApiSecretService service = newService();
|
||||
when(mapper.selectOne(any())).thenReturn(null);
|
||||
|
||||
UserApiSecretBundleVo bundle = service.bundle(7L);
|
||||
|
||||
assertThat(bundle.getComplete()).isFalse();
|
||||
assertThat(bundle.getItems()).hasSize(2);
|
||||
assertThat(bundle.getRequiredModules()).containsExactly("appearance-patent", "similar-asin");
|
||||
}
|
||||
|
||||
@Test
|
||||
void bundleCompleteWhenAllModulesPassed() {
|
||||
UserApiSecretService service = newService();
|
||||
UserApiSecretEntity passed = new UserApiSecretEntity();
|
||||
passed.setSecretValue("enc:key");
|
||||
passed.setCheckStatus("passed");
|
||||
when(mapper.selectOne(any())).thenReturn(passed);
|
||||
|
||||
assertThat(service.bundle(7L).getComplete()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void bundleTreatsErrorAsPassThroughButFailedBlocks() {
|
||||
UserApiSecretService service = newService();
|
||||
UserApiSecretEntity row = new UserApiSecretEntity();
|
||||
row.setSecretValue("enc:key");
|
||||
row.setCheckStatus("error");
|
||||
when(mapper.selectOne(any())).thenReturn(row);
|
||||
assertThat(service.bundle(7L).getComplete()).isTrue();
|
||||
|
||||
row.setCheckStatus("failed");
|
||||
assertThat(service.bundle(7L).getComplete()).isFalse();
|
||||
|
||||
row.setCheckStatus("unknown");
|
||||
assertThat(service.bundle(7L).getComplete()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void clearDeletesRowByUserAndModule() {
|
||||
UserApiSecretService service = newService();
|
||||
|
||||
service.clear(7L, "similar-asin");
|
||||
|
||||
verify(mapper).delete(any());
|
||||
}
|
||||
|
||||
private UserApiSecretMigrateRequest.Item item(String moduleKey, String value) {
|
||||
UserApiSecretMigrateRequest.Item item = new UserApiSecretMigrateRequest.Item();
|
||||
item.setModuleKey(moduleKey);
|
||||
item.setValue(value);
|
||||
return item;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user