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
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 "{}";
|
|
}
|
|
}
|
|
}
|
|
|