438 lines
19 KiB
Java
438 lines
19 KiB
Java
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<String, Object> getSettings() throws IOException {
|
|
final Map<String, Object> result = new HashMap<String, Object>();
|
|
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();
|
|
}
|
|
}
|