You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

233 lines
9.4 KiB

package com.autovoucher.service;
import com.autovoucher.common.ApiException;
import com.autovoucher.config.TenantContext;
import com.autovoucher.domain.SysConfig;
import com.autovoucher.dto.SysConfigRequest;
import com.autovoucher.repository.SysConfigRepository;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.regex.Pattern;
@Service
public class SysConfigService {
private static final int MAX_GROUP_LENGTH = 64;
private static final int MAX_KEY_LENGTH = 128;
private static final int MAX_DESCRIPTION_LENGTH = 255;
private static final int MAX_VALUE_BYTES = 65_535;
private static final Pattern KEY_PATTERN = Pattern.compile("^[A-Za-z0-9][A-Za-z0-9._-]*$");
private static final List<String> SECRET_SUFFIXES = List.of(
"token", "password", "secret", "apikey", "api-key",
"accesskey", "access-key", "privatekey", "private-key");
private final SysConfigRepository sysConfigRepository;
private final AuditService auditService;
private final ObjectMapper objectMapper;
public SysConfigService(SysConfigRepository sysConfigRepository,
AuditService auditService,
ObjectMapper objectMapper) {
this.sysConfigRepository = sysConfigRepository;
this.auditService = auditService;
this.objectMapper = objectMapper;
}
public record View(Long id, String configGroup, String configKey, String configValue,
String description, LocalDateTime updatedAt, boolean secret,
boolean configured) {
}
@Transactional(readOnly = true)
public List<View> list() {
return sysConfigRepository
.findAllByTenantIdOrderByConfigGroupAscConfigKeyAsc(TenantContext.getTenantId())
.stream()
.map(this::toView)
.toList();
}
@Transactional
public View create(SysConfigRequest request) {
requireRequest(request);
String group = normalizeGroup(request.configGroup());
String key = normalizeKey(request.configKey());
String value = normalizeValue(request.configValue());
String description = normalizeDescription(request.description());
Long tenantId = TenantContext.getTenantId();
if (sysConfigRepository.existsByTenantIdAndConfigKey(tenantId, key)) {
throw ApiException.conflict("CONFIG_KEY_EXISTS", "配置键已存在: " + key);
}
SysConfig config = new SysConfig();
config.setTenantId(tenantId);
config.setConfigGroup(group);
config.setConfigKey(key);
config.setConfigValue(value);
config.setDescription(description);
try {
config = sysConfigRepository.saveAndFlush(config);
} catch (DataIntegrityViolationException e) {
throw ApiException.conflict("CONFIG_KEY_EXISTS", "配置键已存在: " + key);
}
Map<String, Object> detail = new LinkedHashMap<>();
detail.put("tenantId", config.getTenantId());
detail.put("configGroup", config.getConfigGroup());
detail.put("configKey", config.getConfigKey());
detail.put("valueChanged", !value.isEmpty());
auditService.log("SYS_CONFIG_CREATED", "SYS_CONFIG", config.getId(), toJson(detail));
return toView(config);
}
@Transactional
public View update(Long id, SysConfigRequest request) {
requireRequest(request);
SysConfig config = sysConfigRepository.findByIdAndTenantId(id, TenantContext.getTenantId())
.orElseThrow(() -> ApiException.notFound("SYS_CONFIG_NOT_FOUND", "配置项不存在: " + id));
String group = normalizeGroup(request.configGroup());
String requestedKey = request.configKey() == null ? "" : request.configKey().trim();
if (!requestedKey.isEmpty() && !requestedKey.equals(config.getConfigKey())) {
throw ApiException.badRequest("CONFIG_KEY_IMMUTABLE", "配置键创建后不可修改");
}
String value;
if (isSecretKey(config.getConfigKey()) && request.configValue() == null) {
// Secret values are never returned to clients, so a blank edit means "keep existing".
value = config.getConfigValue();
} else {
value = normalizeValue(request.configValue());
}
String description = normalizeDescription(request.description());
boolean valueChanged = !Objects.equals(config.getConfigValue(), value);
config.setConfigGroup(group);
config.setConfigValue(value);
config.setDescription(description);
config = sysConfigRepository.saveAndFlush(config);
Map<String, Object> detail = new LinkedHashMap<>();
detail.put("tenantId", config.getTenantId());
detail.put("configGroup", config.getConfigGroup());
detail.put("configKey", config.getConfigKey());
detail.put("valueChanged", valueChanged);
auditService.log("SYS_CONFIG_UPDATED", "SYS_CONFIG", config.getId(), toJson(detail));
return toView(config);
}
@Transactional
public void delete(Long id) {
SysConfig config = sysConfigRepository.findByIdAndTenantId(id, TenantContext.getTenantId())
.orElseThrow(() -> ApiException.notFound("SYS_CONFIG_NOT_FOUND", "配置项不存在: " + id));
sysConfigRepository.delete(config);
Map<String, Object> detail = new LinkedHashMap<>();
detail.put("tenantId", config.getTenantId());
detail.put("configGroup", config.getConfigGroup());
detail.put("configKey", config.getConfigKey());
auditService.log("SYS_CONFIG_DELETED", "SYS_CONFIG", config.getId(), toJson(detail));
}
private void requireRequest(SysConfigRequest request) {
if (request == null) {
throw ApiException.badRequest("SYS_CONFIG_REQUEST_REQUIRED", "请求内容不能为空");
}
}
private String normalizeGroup(String raw) {
String group = raw == null ? "" : raw.trim();
if (group.isEmpty()) {
throw ApiException.badRequest("CONFIG_GROUP_REQUIRED", "配置分组不能为空");
}
if (group.length() > MAX_GROUP_LENGTH) {
throw ApiException.badRequest("CONFIG_GROUP_TOO_LONG",
"配置分组不能超过 " + MAX_GROUP_LENGTH + " 个字符");
}
return group;
}
private String normalizeKey(String raw) {
String key = raw == null ? "" : raw.trim();
if (key.isEmpty()) {
throw ApiException.badRequest("CONFIG_KEY_REQUIRED", "配置键不能为空");
}
if (key.length() > MAX_KEY_LENGTH) {
throw ApiException.badRequest("CONFIG_KEY_TOO_LONG",
"配置键不能超过 " + MAX_KEY_LENGTH + " 个字符");
}
if (!KEY_PATTERN.matcher(key).matches()) {
throw ApiException.badRequest("CONFIG_KEY_INVALID",
"配置键只能包含字母、数字、点、下划线和短横线,且需以字母或数字开头");
}
return key;
}
private String normalizeValue(String raw) {
String value = raw == null ? "" : raw;
if (value.getBytes(StandardCharsets.UTF_8).length > MAX_VALUE_BYTES) {
throw ApiException.badRequest("CONFIG_VALUE_TOO_LONG", "配置值不能超过 65535 字节");
}
return value;
}
private String normalizeDescription(String raw) {
String description = raw == null ? "" : raw.trim();
if (description.isEmpty()) {
return null;
}
if (description.length() > MAX_DESCRIPTION_LENGTH) {
throw ApiException.badRequest("CONFIG_DESCRIPTION_TOO_LONG",
"说明不能超过 " + MAX_DESCRIPTION_LENGTH + " 个字符");
}
return description;
}
private View toView(SysConfig config) {
boolean secret = isSecretKey(config.getConfigKey());
String value = config.getConfigValue();
return new View(
config.getId(),
config.getConfigGroup(),
config.getConfigKey(),
secret ? null : value,
config.getDescription(),
config.getUpdatedAt(),
secret,
value != null && !value.isEmpty());
}
static boolean isSecretKey(String key) {
if (key == null) {
return false;
}
// Treat separators uniformly so both `llm.token` and `oa.app-secret`
// (or their underscore variants) are masked.
String normalized = key.toLowerCase(Locale.ROOT).replace('_', '-');
for (String suffix : SECRET_SUFFIXES) {
if (normalized.endsWith("." + suffix) || normalized.endsWith("-" + suffix)) {
return true;
}
}
return false;
}
private String toJson(Object value) {
try {
return objectMapper.writeValueAsString(value);
} catch (JsonProcessingException e) {
return "{}";
}
}
}