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