fix: suppress duplicate-result errors and hide system apps from private deployment

update-service:
- AppPublishConfigRepository/AppStoreConfigRepository: change Optional-returning
  findBy methods to findTopBy...OrderByUpdatedAtDesc to tolerate duplicate rows in
  public DB and avoid IncorrectResultSizeDataAccessException
- Revert GlobalExceptionHandler to safe "服务器内部错误" (debug details removed)

tenant-service:
- SdkAppInitializer: skip Demo Chat creation on DEPLOYMENT_MODE=PRIVATE;
  migrate existing system apps (ak_demo_chat, IM platform app) to is_default=true
- SdkAppProvisioningService.ensureApp: mark all platform-provisioned apps as
  is_default=true, deletable=false so they don't appear in user's app list
- PrivateTenantBootstrapInitializer: migrate existing private bootstrap apps to
  is_default=true on upgrade
- AppService.listByTenant: filter out is_default=true system apps from the list

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
这个提交包含在:
XuqmGroup 2026-05-22 14:24:33 +08:00
父节点 855b17ef0e
当前提交 9728dbb002
共有 10 个文件被更改,包括 57 次插入21 次删除

查看文件

@ -89,6 +89,11 @@ public class PrivateTenantBootstrapInitializer implements ApplicationRunner {
if (app == null) { if (app == null) {
provisioningService.ensureApp(bootstrapAppKey, true); provisioningService.ensureApp(bootstrapAppKey, true);
log.info("[PRIVATE] Bootstrap app created: {}", bootstrapAppKey); log.info("[PRIVATE] Bootstrap app created: {}", bootstrapAppKey);
} else if (!app.isDefault() || app.isDeletable()) {
app.setDefault(true);
app.setDeletable(false);
appRepository.save(app);
log.info("[PRIVATE] Bootstrap app marked as system app: {}", bootstrapAppKey);
} }
} }
} }

查看文件

@ -1,6 +1,9 @@
package com.xuqm.tenant.config; package com.xuqm.tenant.config;
import com.xuqm.tenant.entity.AppEntity;
import com.xuqm.tenant.repository.AppRepository;
import com.xuqm.tenant.service.SdkAppProvisioningService; import com.xuqm.tenant.service.SdkAppProvisioningService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.ApplicationArguments; import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner; import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@ -9,13 +12,42 @@ import org.springframework.stereotype.Component;
public class SdkAppInitializer implements ApplicationRunner { public class SdkAppInitializer implements ApplicationRunner {
private final SdkAppProvisioningService provisioningService; private final SdkAppProvisioningService provisioningService;
private final PrivateDeploymentProperties deployProps;
private final AppRepository appRepository;
public SdkAppInitializer(SdkAppProvisioningService provisioningService) { @Value("${sdk.bootstrap-app-key:ak_demo_chat}")
private String bootstrapAppKey;
@Value("${sdk.im-platform-app-key:ak_409e217e4aa14254ad73ad3c}")
private String imPlatformAppKey;
public SdkAppInitializer(SdkAppProvisioningService provisioningService,
PrivateDeploymentProperties deployProps,
AppRepository appRepository) {
this.provisioningService = provisioningService; this.provisioningService = provisioningService;
this.deployProps = deployProps;
this.appRepository = appRepository;
} }
@Override @Override
public void run(ApplicationArguments args) { public void run(ApplicationArguments args) {
provisioningService.ensureBootstrapApp(); migrateExistingSystemApps();
if (!deployProps.isPrivate()) {
provisioningService.ensureBootstrapApp();
}
}
private void migrateExistingSystemApps() {
markSystemApp(bootstrapAppKey);
markSystemApp(imPlatformAppKey);
}
private void markSystemApp(String appKey) {
AppEntity app = appRepository.findByAppKey(appKey).orElse(null);
if (app != null && (!app.isDefault() || app.isDeletable())) {
app.setDefault(true);
app.setDeletable(false);
appRepository.save(app);
}
} }
} }

查看文件

@ -33,7 +33,9 @@ public class AppService {
} }
public List<AppEntity> listByTenant(String tenantId) { public List<AppEntity> listByTenant(String tenantId) {
return appRepository.findByTenantId(tenantId); return appRepository.findByTenantId(tenantId).stream()
.filter(app -> !app.isDefault())
.toList();
} }
public AppEntity getByAppKey(String appKey, String tenantId) { public AppEntity getByAppKey(String appKey, String tenantId) {

查看文件

@ -88,6 +88,8 @@ public class SdkAppProvisioningService {
app.setAppKey(appKey); app.setAppKey(appKey);
app.setAppSecret(generateSecret()); app.setAppSecret(generateSecret());
app.setCreatedAt(LocalDateTime.now()); app.setCreatedAt(LocalDateTime.now());
app.setDefault(true);
app.setDeletable(false);
app = appRepository.save(app); app = appRepository.save(app);
if (bootstrapDefaults) { if (bootstrapDefaults) {

查看文件

@ -46,13 +46,8 @@ public class GlobalExceptionHandler {
@ExceptionHandler(Exception.class) @ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handle(Exception ex) { public ResponseEntity<ApiResponse<Void>> handle(Exception ex) {
log.error("Unhandled exception", ex); log.error("Unhandled exception", ex);
String detail = ex.getClass().getSimpleName() + ": " + ex.getMessage();
Throwable cause = ex.getCause();
if (cause != null) {
detail += " | caused by: " + cause.getClass().getSimpleName() + ": " + cause.getMessage();
}
return ResponseEntity.internalServerError() return ResponseEntity.internalServerError()
.body(ApiResponse.error(500, detail)); .body(ApiResponse.error(500, "服务器内部错误"));
} }
private HttpStatus resolveStatus(int code) { private HttpStatus resolveStatus(int code) {

查看文件

@ -6,5 +6,5 @@ import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional; import java.util.Optional;
public interface AppPublishConfigRepository extends JpaRepository<AppPublishConfigEntity, String> { public interface AppPublishConfigRepository extends JpaRepository<AppPublishConfigEntity, String> {
Optional<AppPublishConfigEntity> findByAppKey(String appKey); Optional<AppPublishConfigEntity> findTopByAppKeyOrderByUpdatedAtDesc(String appKey);
} }

查看文件

@ -12,5 +12,5 @@ public interface AppStoreConfigRepository extends JpaRepository<AppStoreConfigEn
List<AppStoreConfigEntity> findByAppKeyAndEnabled(String appKey, boolean enabled); List<AppStoreConfigEntity> findByAppKeyAndEnabled(String appKey, boolean enabled);
Optional<AppStoreConfigEntity> findByAppKeyAndStoreType(String appKey, AppStoreConfigEntity.StoreType storeType); Optional<AppStoreConfigEntity> findTopByAppKeyAndStoreTypeOrderByUpdatedAtDesc(String appKey, AppStoreConfigEntity.StoreType storeType);
} }

查看文件

@ -69,7 +69,7 @@ public class AppStoreService {
AppStoreConfigEntity.StoreType storeType, AppStoreConfigEntity.StoreType storeType,
String configJson, String configJson,
boolean enabled) { boolean enabled) {
boolean isCreate = configRepo.findByAppKeyAndStoreType(appKey, storeType).isEmpty(); boolean isCreate = configRepo.findTopByAppKeyAndStoreTypeOrderByUpdatedAtDesc(appKey, storeType).isEmpty();
AppStoreConfigEntity entity = configRepo AppStoreConfigEntity entity = configRepo
.findByAppKeyAndStoreType(appKey, storeType) .findByAppKeyAndStoreType(appKey, storeType)
.orElseGet(AppStoreConfigEntity::new); .orElseGet(AppStoreConfigEntity::new);
@ -97,7 +97,7 @@ public class AppStoreService {
} }
public void deleteConfig(String appKey, AppStoreConfigEntity.StoreType storeType) { public void deleteConfig(String appKey, AppStoreConfigEntity.StoreType storeType) {
configRepo.findByAppKeyAndStoreType(appKey, storeType).ifPresent(cfg -> { configRepo.findTopByAppKeyAndStoreTypeOrderByUpdatedAtDesc(appKey, storeType).ifPresent(cfg -> {
configRepo.delete(cfg); configRepo.delete(cfg);
operationLogService.record( operationLogService.record(
appKey, appKey,
@ -245,7 +245,7 @@ public class AppStoreService {
} }
public Map<String, String> getReviewWebhookConfig(String appKey) throws Exception { public Map<String, String> getReviewWebhookConfig(String appKey) throws Exception {
AppStoreConfigEntity cfg = configRepo.findByAppKeyAndStoreType( AppStoreConfigEntity cfg = configRepo.findTopByAppKeyAndStoreTypeOrderByUpdatedAtDesc(
appKey, AppStoreConfigEntity.StoreType.REVIEW_WEBHOOK).orElse(null); appKey, AppStoreConfigEntity.StoreType.REVIEW_WEBHOOK).orElse(null);
if (cfg == null || !cfg.isEnabled() || cfg.getConfigJson() == null || cfg.getConfigJson().isBlank()) { if (cfg == null || !cfg.isEnabled() || cfg.getConfigJson() == null || cfg.getConfigJson().isBlank()) {
return Map.of(); return Map.of();
@ -257,7 +257,7 @@ public class AppStoreService {
if (storeType == null || storeType == AppStoreConfigEntity.StoreType.REVIEW_WEBHOOK) { if (storeType == null || storeType == AppStoreConfigEntity.StoreType.REVIEW_WEBHOOK) {
return ""; return "";
} }
return configRepo.findByAppKeyAndStoreType(appKey, storeType) return configRepo.findTopByAppKeyAndStoreTypeOrderByUpdatedAtDesc(appKey, storeType)
.filter(AppStoreConfigEntity::isEnabled) .filter(AppStoreConfigEntity::isEnabled)
.map(AppStoreConfigEntity::getConfigJson) .map(AppStoreConfigEntity::getConfigJson)
.map(this::extractJumpUrl) .map(this::extractJumpUrl)

查看文件

@ -45,7 +45,7 @@ public class PublishConfigService {
} }
public AppPublishConfigEntity getConfig(String appKey) { public AppPublishConfigEntity getConfig(String appKey) {
return configRepository.findByAppKey(appKey).orElseGet(() -> { return configRepository.findTopByAppKeyOrderByUpdatedAtDesc(appKey).orElseGet(() -> {
AppPublishConfigEntity entity = new AppPublishConfigEntity(); AppPublishConfigEntity entity = new AppPublishConfigEntity();
entity.setId(UUID.randomUUID().toString()); entity.setId(UUID.randomUUID().toString());
entity.setAppKey(appKey); entity.setAppKey(appKey);
@ -56,7 +56,7 @@ public class PublishConfigService {
} }
public AppPublishConfigEntity saveConfig(String appKey, Map<String, Object> body) { public AppPublishConfigEntity saveConfig(String appKey, Map<String, Object> body) {
AppPublishConfigEntity entity = configRepository.findByAppKey(appKey).orElseGet(AppPublishConfigEntity::new); AppPublishConfigEntity entity = configRepository.findTopByAppKeyOrderByUpdatedAtDesc(appKey).orElseGet(AppPublishConfigEntity::new);
if (entity.getId() == null) { if (entity.getId() == null) {
entity.setId(UUID.randomUUID().toString()); entity.setId(UUID.randomUUID().toString());
entity.setAppKey(appKey); entity.setAppKey(appKey);
@ -71,7 +71,7 @@ public class PublishConfigService {
} }
public JsonNode getConfigNode(String appKey) { public JsonNode getConfigNode(String appKey) {
AppPublishConfigEntity entity = configRepository.findByAppKey(appKey).orElse(null); AppPublishConfigEntity entity = configRepository.findTopByAppKeyOrderByUpdatedAtDesc(appKey).orElse(null);
if (entity == null || entity.getConfigJson() == null || entity.getConfigJson().isBlank()) { if (entity == null || entity.getConfigJson() == null || entity.getConfigJson().isBlank()) {
try { try {
return objectMapper.readTree(defaultConfigJson()); return objectMapper.readTree(defaultConfigJson());

查看文件

@ -398,7 +398,7 @@ public class StoreSubmissionService {
for (String storeType : targets) { for (String storeType : targets) {
AppStoreConfigEntity cfg; AppStoreConfigEntity cfg;
try { try {
cfg = configRepo.findByAppKeyAndStoreType(v.getAppKey(), cfg = configRepo.findTopByAppKeyAndStoreTypeOrderByUpdatedAtDesc(v.getAppKey(),
AppStoreConfigEntity.StoreType.valueOf(storeType)).orElse(null); AppStoreConfigEntity.StoreType.valueOf(storeType)).orElse(null);
} catch (Exception e) { } catch (Exception e) {
results.add(StoreRemoteState.failed( results.add(StoreRemoteState.failed(
@ -694,7 +694,7 @@ public class StoreSubmissionService {
if (!isUnderReview && !isRejected) continue; if (!isUnderReview && !isRejected) continue;
AppStoreConfigEntity cfg; AppStoreConfigEntity cfg;
try { try {
cfg = configRepo.findByAppKeyAndStoreType(v.getAppKey(), cfg = configRepo.findTopByAppKeyAndStoreTypeOrderByUpdatedAtDesc(v.getAppKey(),
AppStoreConfigEntity.StoreType.valueOf(storeType)).orElse(null); AppStoreConfigEntity.StoreType.valueOf(storeType)).orElse(null);
} catch (Exception e) { } catch (Exception e) {
continue; continue;
@ -779,7 +779,7 @@ public class StoreSubmissionService {
for (String storeType : targets) { for (String storeType : targets) {
AppStoreConfigEntity cfg; AppStoreConfigEntity cfg;
try { try {
cfg = configRepo.findByAppKeyAndStoreType(v.getAppKey(), cfg = configRepo.findTopByAppKeyAndStoreTypeOrderByUpdatedAtDesc(v.getAppKey(),
AppStoreConfigEntity.StoreType.valueOf(storeType)).orElse(null); AppStoreConfigEntity.StoreType.valueOf(storeType)).orElse(null);
} catch (Exception e) { } catch (Exception e) {
results.add(StoreRemoteState.failed( results.add(StoreRemoteState.failed(