package com.svnlog.web.service; import java.io.IOException; import java.nio.file.Path; import java.nio.file.Paths; import java.util.HashMap; import java.util.Map; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import com.svnlog.web.model.PersistedSettings; @Service public class SettingsService { private static final Logger LOGGER = LoggerFactory.getLogger(SettingsService.class); public static final String PROVIDER_DEEPSEEK = "deepseek"; public static final String PROVIDER_OPENAI_COMPATIBLE = "openai-compatible"; private static final String SETTINGS_FILE_NAME = "settings.json"; // 启动默认 API Key(仅作为本地默认值,可在设置页覆盖) private static final String BOOTSTRAP_API_KEY = "sk-48c59012c93b43a08fecbaf3e74799e7"; private static final String DEFAULT_OPENAI_BASE_URL = "http://127.0.0.1:5001/v1"; private static final String DEFAULT_OPENAI_API_KEY = "sk-f8b3f43e1fdd4f50b287050f08a6c7ed"; private static final String DEFAULT_OPENAI_STAGE_ONE_MODEL = "deepseek-v4-flash"; private static final String DEFAULT_OPENAI_STAGE_TWO_MODEL = "deepseek-v4-pro"; private static final String ENV_SVN_USERNAME = "SVN_USERNAME"; private static final String ENV_SVN_PASSWORD = "SVN_PASSWORD"; private final OutputFileService outputFileService; private final SettingsPersistenceService settingsPersistenceService; private final SvnPresetService svnPresetService; private final Path bootstrapOutputRoot; private volatile String runtimeApiKey; private volatile String runtimeProvider; private volatile String runtimeOpenaiBaseUrl; private volatile String runtimeOpenaiApiKey; private volatile String runtimeOpenaiStageOneModel; private volatile String runtimeOpenaiStageTwoModel; private volatile String runtimeSvnUsername; private volatile String runtimeSvnPassword; private volatile String defaultSvnPresetId; public static final class SvnCredentials { private final String username; private final String password; public SvnCredentials(String username, String password) { this.username = username; this.password = password; } public String getUsername() { return username; } public String getPassword() { return password; } public boolean isConfigured() { return !isBlank(username) && !isBlank(password); } } @Autowired public SettingsService(OutputFileService outputFileService, SettingsPersistenceService settingsPersistenceService, SvnPresetService svnPresetService) { this.outputFileService = outputFileService; this.settingsPersistenceService = settingsPersistenceService; this.svnPresetService = svnPresetService; this.bootstrapOutputRoot = initBootstrapOutputRoot(outputFileService); this.runtimeApiKey = initStartupApiKey(); this.runtimeProvider = PROVIDER_DEEPSEEK; this.runtimeOpenaiBaseUrl = DEFAULT_OPENAI_BASE_URL; this.runtimeOpenaiApiKey = DEFAULT_OPENAI_API_KEY; this.runtimeOpenaiStageOneModel = DEFAULT_OPENAI_STAGE_ONE_MODEL; this.runtimeOpenaiStageTwoModel = DEFAULT_OPENAI_STAGE_TWO_MODEL; this.runtimeSvnUsername = trim(System.getenv(ENV_SVN_USERNAME)); this.runtimeSvnPassword = trim(System.getenv(ENV_SVN_PASSWORD)); this.defaultSvnPresetId = svnPresetService.configuredDefaultPresetId(); loadPersistedSettings(); } public Map getSettings() throws IOException { final Map result = new HashMap(); final String envKey = System.getenv("DEEPSEEK_API_KEY"); final String activeKey = pickActiveKey(null); result.put("provider", getProvider()); result.put("apiKeyConfigured", activeKey != null && !activeKey.trim().isEmpty()); result.put("apiKeySource", detectApiKeySource(envKey)); result.put("openaiBaseUrl", getOpenaiBaseUrl()); result.put("openaiApiKeyConfigured", isConfigured(getOpenaiApiKey())); result.put("openaiStageOneModel", getOpenaiStageOneModel()); result.put("openaiStageTwoModel", getOpenaiStageTwoModel()); result.put("svnUsername", getSvnUsername()); result.put("svnCredentialsConfigured", getConfiguredSvnCredentials().isConfigured()); result.put("outputDir", outputFileService.getOutputRoot().toString()); result.put("defaultSvnPresetId", getDefaultSvnPresetId()); return result; } public synchronized void updateSettings(String apiKey, String provider, String openaiBaseUrl, String openaiApiKey, String openaiStageOneModel, String openaiStageTwoModel, String svnUsername, String svnPassword, String outputDir, String newDefaultSvnPresetId) { final String previousRuntimeApiKey = runtimeApiKey; final String previousRuntimeProvider = runtimeProvider; final String previousRuntimeOpenaiBaseUrl = runtimeOpenaiBaseUrl; final String previousRuntimeOpenaiApiKey = runtimeOpenaiApiKey; final String previousRuntimeOpenaiStageOneModel = runtimeOpenaiStageOneModel; final String previousRuntimeOpenaiStageTwoModel = runtimeOpenaiStageTwoModel; final String previousRuntimeSvnUsername = runtimeSvnUsername; final String previousRuntimeSvnPassword = runtimeSvnPassword; final String previousDefaultSvnPresetId = defaultSvnPresetId; final Path previousOutputRoot = outputFileService.peekOutputRoot(); try { if (apiKey != null && !apiKey.trim().isEmpty()) { this.runtimeApiKey = apiKey.trim(); } this.runtimeProvider = normalizeProvider(provider); if (openaiBaseUrl != null && !openaiBaseUrl.trim().isEmpty()) { this.runtimeOpenaiBaseUrl = openaiBaseUrl.trim(); } if (openaiApiKey != null && !openaiApiKey.trim().isEmpty()) { this.runtimeOpenaiApiKey = openaiApiKey.trim(); } if (openaiStageOneModel != null && !openaiStageOneModel.trim().isEmpty()) { this.runtimeOpenaiStageOneModel = openaiStageOneModel.trim(); } if (openaiStageTwoModel != null && !openaiStageTwoModel.trim().isEmpty()) { this.runtimeOpenaiStageTwoModel = openaiStageTwoModel.trim(); } if (svnUsername != null && !svnUsername.trim().isEmpty()) { this.runtimeSvnUsername = svnUsername.trim(); } if (svnPassword != null && !svnPassword.trim().isEmpty()) { this.runtimeSvnPassword = svnPassword.trim(); } if (outputDir != null && !outputDir.trim().isEmpty()) { outputFileService.setOutputRoot(outputDir); } if (svnPresetService.containsPresetId(newDefaultSvnPresetId)) { this.defaultSvnPresetId = newDefaultSvnPresetId; } outputFileService.ensureOutputRootWritable(); persistCurrentSettings(); } catch (IOException e) { rollbackSettings( previousRuntimeApiKey, previousRuntimeProvider, previousRuntimeOpenaiBaseUrl, previousRuntimeOpenaiApiKey, previousRuntimeOpenaiStageOneModel, previousRuntimeOpenaiStageTwoModel, previousRuntimeSvnUsername, previousRuntimeSvnPassword, previousDefaultSvnPresetId, previousOutputRoot ); throw new IllegalStateException("保存系统设置失败: " + e.getMessage(), e); } catch (RuntimeException e) { rollbackSettings( previousRuntimeApiKey, previousRuntimeProvider, previousRuntimeOpenaiBaseUrl, previousRuntimeOpenaiApiKey, previousRuntimeOpenaiStageOneModel, previousRuntimeOpenaiStageTwoModel, previousRuntimeSvnUsername, previousRuntimeSvnPassword, previousDefaultSvnPresetId, previousOutputRoot ); throw e; } } private synchronized void rollbackSettings(String runtimeApiKey, String runtimeProvider, String runtimeOpenaiBaseUrl, String runtimeOpenaiApiKey, String runtimeOpenaiStageOneModel, String runtimeOpenaiStageTwoModel, String runtimeSvnUsername, String runtimeSvnPassword, String defaultSvnPresetId, Path outputRoot) { this.runtimeApiKey = runtimeApiKey; this.runtimeProvider = runtimeProvider; this.runtimeOpenaiBaseUrl = runtimeOpenaiBaseUrl; this.runtimeOpenaiApiKey = runtimeOpenaiApiKey; this.runtimeOpenaiStageOneModel = runtimeOpenaiStageOneModel; this.runtimeOpenaiStageTwoModel = runtimeOpenaiStageTwoModel; this.runtimeSvnUsername = runtimeSvnUsername; this.runtimeSvnPassword = runtimeSvnPassword; this.defaultSvnPresetId = defaultSvnPresetId; if (outputRoot != null) { outputFileService.setOutputRoot(outputRoot.toString()); } } public String pickActiveKey(String requestKey) { if (requestKey != null && !requestKey.trim().isEmpty()) { return requestKey.trim(); } if (runtimeApiKey != null && !runtimeApiKey.trim().isEmpty()) { return runtimeApiKey; } final String envKey = System.getenv("DEEPSEEK_API_KEY"); if (envKey != null && !envKey.trim().isEmpty()) { return envKey.trim(); } return null; } private String initStartupApiKey() { final String envKey = System.getenv("DEEPSEEK_API_KEY"); if (envKey != null && !envKey.trim().isEmpty()) { return envKey.trim(); } if (BOOTSTRAP_API_KEY != null && !BOOTSTRAP_API_KEY.trim().isEmpty() && !BOOTSTRAP_API_KEY.startsWith("REPLACE_WITH_")) { return BOOTSTRAP_API_KEY.trim(); } return null; } private synchronized void loadPersistedSettings() { try { final Path bootstrapStorePath = getBootstrapSettingsPath(); applyPersistedSettings(settingsPersistenceService.load(bootstrapStorePath)); final Path activeStorePath = getCurrentSettingsPath(); if (!activeStorePath.equals(bootstrapStorePath)) { applyPersistedSettings(settingsPersistenceService.load(activeStorePath)); } } catch (IOException e) { LOGGER.warn("Failed to load persisted settings", e); } } private void applyPersistedSettings(PersistedSettings persistedSettings) { if (persistedSettings == null) { return; } if (!isBlank(persistedSettings.getApiKey())) { this.runtimeApiKey = persistedSettings.getApiKey().trim(); } if (!isBlank(persistedSettings.getProvider())) { this.runtimeProvider = normalizeProvider(persistedSettings.getProvider()); } if (!isBlank(persistedSettings.getOpenaiBaseUrl())) { this.runtimeOpenaiBaseUrl = persistedSettings.getOpenaiBaseUrl().trim(); } if (!isBlank(persistedSettings.getOpenaiApiKey())) { this.runtimeOpenaiApiKey = persistedSettings.getOpenaiApiKey().trim(); } if (!isBlank(persistedSettings.getOpenaiStageOneModel())) { this.runtimeOpenaiStageOneModel = persistedSettings.getOpenaiStageOneModel().trim(); } if (!isBlank(persistedSettings.getOpenaiStageTwoModel())) { this.runtimeOpenaiStageTwoModel = persistedSettings.getOpenaiStageTwoModel().trim(); } if (!isBlank(persistedSettings.getSvnUsername())) { this.runtimeSvnUsername = persistedSettings.getSvnUsername().trim(); } if (!isBlank(persistedSettings.getSvnPassword())) { this.runtimeSvnPassword = persistedSettings.getSvnPassword().trim(); } if (!isBlank(persistedSettings.getOutputDir())) { outputFileService.setOutputRoot(persistedSettings.getOutputDir()); } if (svnPresetService.containsPresetId(persistedSettings.getDefaultSvnPresetId())) { this.defaultSvnPresetId = persistedSettings.getDefaultSvnPresetId().trim(); } } private void persistCurrentSettings() throws IOException { final PersistedSettings persistedSettings = snapshotCurrentSettings(); final Path activeStorePath = getCurrentSettingsPath(); settingsPersistenceService.save(activeStorePath, persistedSettings); final Path bootstrapStorePath = getBootstrapSettingsPath(); if (!bootstrapStorePath.equals(activeStorePath)) { settingsPersistenceService.save(bootstrapStorePath, persistedSettings); } } private PersistedSettings snapshotCurrentSettings() throws IOException { final PersistedSettings persistedSettings = new PersistedSettings(); persistedSettings.setProvider(getProvider()); persistedSettings.setApiKey(trim(runtimeApiKey)); persistedSettings.setOpenaiBaseUrl(getOpenaiBaseUrl()); persistedSettings.setOpenaiApiKey(trim(runtimeOpenaiApiKey)); persistedSettings.setOpenaiStageOneModel(getOpenaiStageOneModel()); persistedSettings.setOpenaiStageTwoModel(getOpenaiStageTwoModel()); persistedSettings.setSvnUsername(trim(runtimeSvnUsername)); persistedSettings.setSvnPassword(trim(runtimeSvnPassword)); persistedSettings.setOutputDir(outputFileService.getOutputRoot().toString()); persistedSettings.setDefaultSvnPresetId(getDefaultSvnPresetId()); return persistedSettings; } private Path getCurrentSettingsPath() throws IOException { return outputFileService.getOutputRoot().resolve(SETTINGS_FILE_NAME); } private Path getBootstrapSettingsPath() { return bootstrapOutputRoot.resolve(SETTINGS_FILE_NAME); } private Path initBootstrapOutputRoot(OutputFileService outputFileService) { try { return outputFileService.getOutputRoot(); } catch (IOException e) { LOGGER.warn("Failed to resolve bootstrap output root, fallback to ./outputs", e); return Paths.get("outputs").toAbsolutePath().normalize(); } } private String detectApiKeySource(String envKey) { if (runtimeApiKey != null && !runtimeApiKey.trim().isEmpty()) { if (envKey != null && !envKey.trim().isEmpty() && runtimeApiKey.equals(envKey.trim())) { return "env"; } return "runtime"; } if (envKey != null && !envKey.trim().isEmpty()) { return "env"; } return "none"; } public String getDefaultSvnPresetId() { if (svnPresetService.containsPresetId(defaultSvnPresetId)) { return defaultSvnPresetId; } return svnPresetService.firstPresetId(); } public String getProvider() { return normalizeProvider(runtimeProvider); } public String getOpenaiBaseUrl() { return trimOrDefault(runtimeOpenaiBaseUrl, DEFAULT_OPENAI_BASE_URL); } public String getOpenaiApiKey() { return trimOrDefault(runtimeOpenaiApiKey, DEFAULT_OPENAI_API_KEY); } public String getSvnUsername() { return trim(resolveSvnUsername(null)); } public SvnCredentials resolveSvnCredentials(String requestUsername, String requestPassword) { final String username = resolveSvnUsername(requestUsername); final String password = resolveSvnPassword(requestPassword); if (isBlank(username) || isBlank(password)) { throw new IllegalArgumentException("未配置 SVN 账号,请先到系统设置页填写 SVN 用户名和密码"); } return new SvnCredentials(username, password); } public SvnCredentials getConfiguredSvnCredentials() { return new SvnCredentials(resolveSvnUsername(null), resolveSvnPassword(null)); } public String getOpenaiStageOneModel() { return trimOrDefault(runtimeOpenaiStageOneModel, DEFAULT_OPENAI_STAGE_ONE_MODEL); } public String getOpenaiStageTwoModel() { return trimOrDefault(runtimeOpenaiStageTwoModel, DEFAULT_OPENAI_STAGE_TWO_MODEL); } private String normalizeProvider(String provider) { if (PROVIDER_OPENAI_COMPATIBLE.equalsIgnoreCase(trim(provider))) { return PROVIDER_OPENAI_COMPATIBLE; } return PROVIDER_DEEPSEEK; } private String trimOrDefault(String value, String defaultValue) { final String trimmed = trim(value); return trimmed.isEmpty() ? defaultValue : trimmed; } private boolean isConfigured(String value) { return value != null && !value.trim().isEmpty(); } private String resolveSvnUsername(String requestUsername) { final String requestValue = trim(requestUsername); if (!requestValue.isEmpty()) { return requestValue; } final String runtimeValue = trim(runtimeSvnUsername); if (!runtimeValue.isEmpty()) { return runtimeValue; } return trim(System.getenv(ENV_SVN_USERNAME)); } private String resolveSvnPassword(String requestPassword) { final String requestValue = trim(requestPassword); if (!requestValue.isEmpty()) { return requestValue; } final String runtimeValue = trim(runtimeSvnPassword); if (!runtimeValue.isEmpty()) { return runtimeValue; } return trim(System.getenv(ENV_SVN_PASSWORD)); } private String trim(String value) { return value == null ? "" : value.trim(); } private static boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } }