大家好,我是程序员小策。
先来几个灵魂拷问热热身:

  • 你的系统里 API Key 存在哪?如果数据库被拖库,Key 会不会明文泄露?
  • 老板说"把默认模型从豆包换成 DeepSeek",你需要改代码发版还是改个配置就行?
  • 某个模型的 API Key 过期了,你怎么在不重启服务的情况下换一个新 Key?
  • 前端调用 /api/ai-properties/options 获取可用模型列表时,API Key 会出现在返回值里吗?
  • 如果有人把模型状态误点成"禁用",正在使用这个模型的用户会怎样?
  • 你有几个环境(dev/test/prod)?每个环境的 Key 怎么区分?

大部分人能回答前两个,到第三个开始犹豫,到第五个就卡住了。

今天这篇文章就是要把这六个问题一个一个拆开。以一个生产级的 AI 配置管理系统为样本——它支持 OpenAI、DeepSeek、豆包、星火四种模型的动态管理,热更新不用重启,API Key 全程脱敏,模型启停零侵入。


问题定义

AI 配置管理的核心矛盾:配置的"安全性"要求它被严密保护,但配置的"灵活性"要求它能被随时调整。

朴素方案是把配置写死在 application.yml 里:

ai:
  openai:
    api-key: sk-xxx
    model: gpt-4o
  doubao:
    api-key: xxx
    model: doubao-pro

问题在哪?

  • 换了 Key 要重启服务
  • 多环境要维护多份配置文件,Key 还容易提交到 Git
  • 加了新模型要改配置 + 发版
  • 每个调用方都能直接读配置,泄露面太大

改进方向很明确——配置中心化 + 数据库存储 + 运行时读取。但这又引出新问题:数据库里的 Key 怎么保护?查询配置时要脱敏吗?模型被禁用时要怎么处理正在进行的对话?


核心概念

配置热更新:不重启服务即可更改系统行为。通过在数据库存储配置、每次请求实时读取实现。代价是每次请求都要查库——但 AI 配置属于"低频变更 + 读多写少"场景,适合加缓存。

API Key 脱敏(Masking):API Key 永远不能完整返回给前端。返回值中只展示前 4 位 + **** + 后 4 位,比如 sk-p***x123。确保即使管理后台被 XSS 攻击,Key 也不会完全泄露。

软删除 + 逻辑禁用:删除和禁用是两个维度。delFlag = 1 是删除(不可恢复或需管理员恢复),isEnabled = 0 是禁用(随时可重新启用)。两者独立,互不影响。

用游戏类比理解这个设计:

你玩过网游吧?游戏服务器维护时,运营团队会发通告:“今晚 2:00-6:00 停服维护”。停服不是删服——服务器还在地下车库里,只是暂时不接受玩家连接。

在这个系统里:

  • 游戏服务器(已启用/已禁用)= is_enabled 字段:0 是维护中(玩家无法进入),1 是正常开放。随时可以切换。
  • 服务器被拆除/报废 = del_flag 字段:1 是真删除,通常不会再恢复。
  • GM 后台(配置管理):管理员能看到所有服务器的状态,但看不到玩家的密码(Key 脱敏)。
  • 玩家登录时自动选择默认服务器 = getDefaultDoubaoConfig():用户没指定模型时,系统帮他选一个默认的。

实现

第一步,看配置实体——23 个字段覆盖了接入一个 AI 模型需要的所有参数:

@Data
@TableName("ai_properties")
public class AiPropertiesDO {
    @TableId(type = IdType.AUTO)
    private Long id;
    
    private String aiName;          // AI名称 — 展示用
    private String aiType;          // AI类型 — openai/doubao/deepseek/spark
    private String apiKey;          // API密钥 — 最敏感字段
    private String apiSecret;       // API密钥(部分AI需要双密钥)
    private String projectId;       // 项目ID (OpenAI专用)
    private String organizationId;  // 组织ID (OpenAI专用)
    private String apiUrl;          // API地址 — 为空时用枚举默认值
    private String modelName;       // 模型名称 — gpt-4o / deepseek-chat 等
    private Integer maxTokens;      // 最大token数 — 控制回复长度
    private BigDecimal temperature; // 温度参数 — 控制随机性
    private String systemPrompt;    // 系统提示词 — 定义AI角色
    
    private Integer isEnabled;      // 是否启用 0/1 — 运维开关
    private Date createTime;
    private Date updateTime;
    private Integer delFlag;        // 删除标识 0/1 — 软删除
}

关键设计:apiUrl可选字段——如果数据库不配,代码里从枚举拿默认值。这让管理员有了两个层级的配置自由度:想用默认地址就不用填,想用代理/私有部署就填自定义 URL。

第二步,看 API Key 脱敏。这是配置管理中最容易被忽视的安全细节:

private String maskApiKey(String apiKey) {
    if (StrUtil.isBlank(apiKey) || apiKey.length() <= 8) {
        return "****";
    }
    return apiKey.substring(0, 4) + "****" + apiKey.substring(apiKey.length() - 4);
}

每次返回 AiPropertiesRespDTO 时,都要经过这道脱敏处理。看调用方:

@Override
public AiPropertiesRespDTO getAiPropertiesById(Long id) {
    AiPropertiesDO aiPropertiesDO = baseMapper.selectById(id);
    if (aiPropertiesDO == null || aiPropertiesDO.getDelFlag() == 1) {
        throw new ClientException("AI配置不存在");
    }
    AiPropertiesRespDTO respDTO = new AiPropertiesRespDTO();
    BeanUtil.copyProperties(aiPropertiesDO, respDTO);
    
    // 脱敏!脱敏!脱敏! ← 三个地方都做了
    if (StrUtil.isNotBlank(respDTO.getApiKey())) {
        respDTO.setApiKey(maskApiKey(respDTO.getApiKey()));
    }
    return respDTO;
}

注意:脱敏发生在 RespDTO 层面,不是在 DO 层面。 AiPropertiesDO 里的 apiKey 始终是完整明文——不然内部的 AI 调用就拿不到真正的 Key 了。脱敏是"展示层"的职责,不是"数据层"的职责。

第三步,看热更新机制——模型配置如何在运行时生效:

// 在 AiMessageServiceImpl 中,每次聊天请求都实时读取配置
private AiPropertiesDO resolveAiProperties(Long aiId) {
    AiPropertiesDO aiProperties;
    if (aiId == null) {
        aiProperties = aiPropertiesService.getDefaultDoubaoConfig();  // ← 默认模型兜底
        if (aiProperties == null) {
            throw new ClientException("Default AI config does not exist");
        }
    } else {
        aiProperties = aiPropertiesService.getById(aiId);
        if (aiProperties == null || aiProperties.getDelFlag() == 1 
                || aiProperties.getIsEnabled() == 0) {
            throw new ClientException("AI config does not exist or is disabled");
        }
    }
    return aiProperties;
}

换个 Key?管理员在后台改一下数据库的 api_key 字段,下一次请求就生效了。不需要重启、不需要发版、不需要清缓存。

第四步,看模型启停——toggleAiPropertiesStatus 的设计:

@Override
public void toggleAiPropertiesStatus(Long id, Integer isEnabled) {
    AiPropertiesDO existingRecord = baseMapper.selectById(id);
    if (existingRecord == null || existingRecord.getDelFlag() == 1) {
        throw new ClientException("AI配置不存在");
    }
    LambdaUpdateWrapper<AiPropertiesDO> updateWrapper = Wrappers.lambdaUpdate(AiPropertiesDO.class)
            .eq(AiPropertiesDO::getId, id)
            .set(AiPropertiesDO::getIsEnabled, isEnabled)
            .set(AiPropertiesDO::getUpdateTime, new Date());
    baseMapper.update(null, updateWrapper);
}

这里有一个微妙的点——禁用模型后,正在进行的对话会怎样? 答案是:已经开始的对话不受影响,因为 AiPropertiesDO 只在 resolveAiProperties() 这一步被读取。也就是说,已经在流式返回中的请求已经拿到了配置对象,不会因为配置被禁用而中断。新请求才会被 getIsEnabled() == 0 拦截。

第五步,看默认模型兜底——getDefaultDoubaoConfig() 的设计意图:

@Override
public AiPropertiesDO getDefaultDoubaoConfig() {
    AiPropertiesDO doubaoConfig = getEnabledByAiType("doubao");
    if (doubaoConfig == null) {
        throw new ClientException("豆包AI配置不存在或未启用");
    }
    return doubaoConfig;
}

这个硬编码很有意思——它把"默认模型"这个决策固化在了代码里。为什么不配在数据库里?因为"如果默认配置本身也存数据库,那默认配置被删了怎么办?"——需要另一个兜底。代码里硬编码一个兜底类型(doubao),保证至少有一个 fallback。

第六步,数据库加密

前面讲了前端脱敏(maskApiKey),但那只是展示层的防护——返回给浏览器时把 Key 遮住。数据库里存的还是明文:sk-1234567890abcdef。如果数据库被拖库,或者运维人员直接 SELECT * FROM ai_properties,Key 一览无余。

所以真正的纵深防御需要三层:

前端展示层   →  maskApiKey()   →  sk-p****cdef(脱敏展示)
应用内存层   →  Java 对象      →  sk-1234567890abcdef(明文,仅供内部调用)
数据库持久层 →  AES 加密存储   →  x8Kd3mQpL9vR2sT7...(密文,拖库也不怕)

下面是一个可直接集成到本项目中的 AES 加解密方案。

方案一:独立加解密工具类

package com.hewei.hzyjy.xunzhi.ai.infrastructure.security;

import cn.hutool.crypto.SecureUtil;
import cn.hutool.crypto.symmetric.AES;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.util.Base64;

@Component
public class ApiKeyCrypto {

    private final AES aes;

    /**
     * 密钥从配置中心或环境变量注入,绝不硬编码
     * 16字节 = AES-128,32字节 = AES-256
     */
    public ApiKeyCrypto(@Value("${ai.crypto.aes-key}") String aesKey) {
        byte[] keyBytes = hexToBytes(aesKey);
        this.aes = SecureUtil.aes(keyBytes);
    }

    /** 加密:明文 → Base64密文 */
    public String encrypt(String plainText) {
        if (plainText == null || plainText.isEmpty()) {
            return plainText;
        }
        byte[] encrypted = aes.encrypt(plainText.getBytes(StandardCharsets.UTF_8));
        return Base64.getEncoder().encodeToString(encrypted);
    }

    /** 解密:Base64密文 → 明文 */
    public String decrypt(String cipherText) {
        if (cipherText == null || cipherText.isEmpty()) {
            return cipherText;
        }
        byte[] decoded = Base64.getDecoder().decode(cipherText);
        return new String(aes.decrypt(decoded), StandardCharsets.UTF_8);
    }

    private static byte[] hexToBytes(String hex) {
        int len = hex.length();
        byte[] data = new byte[len / 2];
        for (int i = 0; i < len; i += 2) {
            data[i / 2] = (byte) ((Character.digit(hex.charAt(i), 16) << 4)
                    + Character.digit(hex.charAt(i + 1), 16));
        }
        return data;
    }
}

然后在 AiPropertiesServiceImpl写入时加密、读取时解密

@Service
@RequiredArgsConstructor
public class AiPropertiesServiceImpl 
        extends ServiceImpl<AiPropertiesMapper, AiPropertiesDO> 
        implements AiPropertiesService {

    private final ApiKeyCrypto apiKeyCrypto;  // ← 注入加解密工具

    @Override
    public void createAiProperties(AiPropertiesCreateReqDTO requestParam) {
        // ... 重名校验省略 ...
        AiPropertiesDO aiPropertiesDO = new AiPropertiesDO();
        BeanUtil.copyProperties(requestParam, aiPropertiesDO);
        
        // 入库前加密 ← 核心!
        if (StrUtil.isNotBlank(aiPropertiesDO.getApiKey())) {
            aiPropertiesDO.setApiKey(apiKeyCrypto.encrypt(aiPropertiesDO.getApiKey()));
        }
        if (StrUtil.isNotBlank(aiPropertiesDO.getApiSecret())) {
            aiPropertiesDO.setApiSecret(apiKeyCrypto.encrypt(aiPropertiesDO.getApiSecret()));
        }
        
        baseMapper.insert(aiPropertiesDO);
    }

    // 提供一个"取明文Key"的方法,所有内部调用走这个方法
    public String getDecryptedApiKey(Long id) {
        AiPropertiesDO entity = baseMapper.selectById(id);
        if (entity == null || StrUtil.isBlank(entity.getApiKey())) {
            return null;
        }
        return apiKeyCrypto.decrypt(entity.getApiKey());  // ← 读取时解密
    }
}

方案二:MyBatis-Plus 类型处理器(更优雅,零侵入业务代码)

如果不想在每个 getById/insert 处手动加解密,可以用 MyBatis-Plus 的 TypeHandler

package com.hewei.hzyjy.xunzhi.ai.infrastructure.security;

import cn.hutool.crypto.SecureUtil;
import cn.hutool.crypto.symmetric.AES;
import org.apache.ibatis.type.BaseTypeHandler;
import org.apache.ibatis.type.JdbcType;
import org.apache.ibatis.type.MappedTypes;

import java.nio.charset.StandardCharsets;
import java.sql.CallableStatement;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.Base64;

@MappedTypes(String.class)
public class AesEncryptTypeHandler extends BaseTypeHandler<String> {

    private static final AES AES = SecureUtil.aes(
            hexToBytes(System.getenv("AI_AES_KEY"))  // ← 从环境变量取密钥
    );

    @Override
    public void setNonNullParameter(PreparedStatement ps, int i, 
                                     String parameter, JdbcType jdbcType) throws SQLException {
        // 写入数据库时自动加密
        byte[] encrypted = AES.encrypt(parameter.getBytes(StandardCharsets.UTF_8));
        ps.setString(i, Base64.getEncoder().encodeToString(encrypted));
    }

    @Override
    public String getNullableResult(ResultSet rs, String columnName) throws SQLException {
        // 从数据库读取时自动解密
        String cipherText = rs.getString(columnName);
        return decryptIfNeeded(cipherText);
    }

    @Override
    public String getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
        return decryptIfNeeded(rs.getString(columnIndex));
    }

    @Override
    public String getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
        return decryptIfNeeded(cs.getString(columnIndex));
    }

    private String decryptIfNeeded(String cipherText) {
        if (cipherText == null || cipherText.isEmpty()) return cipherText;
        byte[] decoded = Base64.getDecoder().decode(cipherText);
        return new String(AES.decrypt(decoded), StandardCharsets.UTF_8);
    }

    private static byte[] hexToBytes(String hex) { /* 同上省略 */ }
}

然后在 AiPropertiesDO 的字段上标注:

@TableField(typeHandler = AesEncryptTypeHandler.class)
private String apiKey;

@TableField(typeHandler = AesEncryptTypeHandler.class)
private String apiSecret;

这样 MyBatis-Plus 在 insert 时自动调 setNonNullParameter(加密),selectById 时自动调 getNullableResult(解密)。业务代码完全不用改,连 maskApiKey 的调用逻辑都不受影响——因为 DO 对象里已经是解密后的明文了。

方案对比:

加密方案 代码侵入性 实现复杂度 性能开销 适用场景
手动加解密(方案一) 高:每个读写点都要调 encrypt/decrypt 无额外开销 小项目、快速落地
TypeHandler(方案二,推荐) 零:加注解即可 每次读写多一次AES 生产环境首选
透明数据加密(TDE) 零(运维配置) 数据库层开销 MySQL Enterprise 或云数据库自带

边界情况与陷阱

陷阱一:API Key 在前端请求中有没有被打印到日志里?

这是一个隐蔽的安全问题。如果日志配置不当(比如开启了请求体日志),用户发送的 AiMessageReqDTO 请求体就会被完整打印——但不包含 API Key,因为 AiMessageReqDTO 里只有 inputMessagesessionIdaiId 这些字段。Key 是服务端从数据库取的,不会出现在请求日志中。

但要注意:如果开发时用了 .log() 或 debug 级别的日志打印了 AiPropertiesDO 对象,Key 就会明文出现在日志里。这个系统用了 Lombok 的 @Data 自动生成 toString(),打印 DO 对象时 Key 是明文的——这是一个潜在风险点。

陷阱二:前端获取模型列表时能拿到 Key 吗?

getAvailableAiModels() 的返回:

@Override
public List<AiModelOptionRespDTO> getAvailableAiModels() {
    List<AiPropertiesRespDTO> enabledProperties = getAllEnabledAiProperties();
    return enabledProperties.stream()
            .map(prop -> AiModelOptionRespDTO.builder()
                    .id(prop.getId())
                    .aiName(prop.getAiName())
                    .aiType(Integer.valueOf(prop.getAiType()))
                    .build())
            .collect(Collectors.toList());
}

返回的是 AiModelOptionRespDTO,只有 idaiNameaiType 三个字段——没有 apiKey。这个设计非常安全:前端下拉列表只需要知道"有哪些可用模型",不需要知道 Key。

陷阱三:管理员误删配置怎么办?

软删除机制:deleteAiProperties() 只设置 delFlag = 1,不真删。如果发现误删,数据库管理员直接改回 0 就能恢复。如果真删行,就只能靠备份恢复了。


对比表格

安全设计点 朴素做法 本项目的做法 进阶方案(推荐补充) 防护效果
API Key 存储 写死在 application.yml MySQL 数据库(明文存储) AES-256 加密入库,TypeHandler 自动加解密 拖库也无法获取明文 Key
API Key 展示 前端展示完整 Key mask 为 sk-p****x123 同左 管理后台不泄露完整 Key
API Key 轮换 改配置 → 重启服务 改数据库 → 下次请求生效 同左 热更新,零停机
模型切换 改代码/改配置 → 发版 改数据库 → 枚举自动匹配 同左 运维人员自主切换
模型禁用 删配置或注释代码 isEnabled = 0,进行中对话不受影响 同左 优雅降级
误删恢复 查备份 → 手动恢复 delFlag 改回 0 同左 秒级恢复
日志泄露 Key 可能出现在请求日志 Key 从数据库取,不出现在请求 DTO 中 DO 对象的 toString() 也需脱敏 日志不包含 Key
数据库加密 AES 加密入库 + TypeHandler 透明解密 纵深防御最后一道防线

面试追问

面试追问 1:每次请求都查 MySQL 获取配置,性能扛得住吗?

→ 回答方向:目前是读库模式。对于一个 QPS 在几百级别的管理后台,MySQL 完全能承受。如果 QPS 上到几千,可以加一层本地缓存(Caffeine 或 Guava Cache),设置合理的 TTL(比如 5 分钟)。注意——缓存 Key 的脱敏版本(AiPropertiesRespDTO),而不是明文版本(AiPropertiesDO)。缓存失效时重新从数据库加载并脱敏。

面试追问 2:apiSecret apiKey 分别是什么?为什么要两个?

→ 回答方向:有些 AI 服务(如讯飞星火的旧版 API)采用 APPID + APISecret + APIKey 三重认证。apiKey 是请求头中的 Authorization Bearer Token,apiSecret 是生成签名时的密钥。这两个字段只在构建请求签名时使用,不会同时出现在一个 HTTP 头里。不过随着各厂商逐渐兼容 OpenAI 格式(单一 API Key),apiSecret 的使用场景正在减少。

面试追问 3:getDefaultDoubaoConfig() 硬编码豆包作为默认——如果以后默认要换成 DeepSeek 怎么办?

→ 回答方向:当前方案需要改代码发版。更优雅的做法是在数据库的 ai_properties 表加一个 is_default 字段(1=默认),查询时 getEnabledByAiType 改为 getDefaultEnabled。或者用配置中心(Nacos / Apollo)存一个 ai.default-model=doubao,代码读配置而不是硬编码。但对于 MVP 阶段,硬编码一个默认值是务实的选择——避免循环依赖问题。

面试追问 4:AES 加密的密钥存在哪?如果密钥泄露了,加密还有什么意义?

→ 回答方向:这是加密方案的阿喀琉斯之踵。密钥绝不能和密文存在同一个地方。推荐做法:开发环境用环境变量 AI_AES_KEY,生产环境用配置中心(Nacos/Vault)或 KMS(云密钥管理服务)。即使数据库被拖库,攻击者拿到了密文,只要密钥在 KMS 中且开启了访问审计,攻击者就拿不到明文。更进一步可以用信封加密:KMS 生成一个数据密钥(DEK)来加密业务数据,KMS 再用根密钥(KEK)加密 DEK。DEK 密文可以和业务数据存在一起,但 KEK 永远不离开 KMS。


总结

配置管理的三个核心原则:热更新(改了能立刻生效)、最小可见(不该看的人看不到)、优雅降级(禁用了不影响进行中的)。

读完这篇你应该能:

  • 设计一个支持多模型的 AI 配置管理系统,Key 自动脱敏
  • 理解热更新的实现机制(数据库存储 + 请求时读取)
  • 区分"软删除"和"逻辑禁用"的设计意图
  • 在面试时说出"API Key 脱敏 + 模型热切换 + 默认兜底"而不只是"用 MySQL 管理配置"
Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐