私有对话数据一旦泄露,比丢几个账号严重得多,安全不能外包给云厂商的默认配置。当时我对照过两种思路:一种是把数据全部交给云厂商托管,靠平台的默认安全能力;另一种是脱敏做在前端,后端只存加工结果。权衡之后,我们选了”存储、传输、访问、销毁”四个环节全链路自控的方案,宁可多写代码,也不把风险外包给别人的默认配置。下面把这套设计完整拆开。
一、安全设计原则
安全设计不能靠零散补丁,先立原则再谈实现。我们定了五条,每条都能对应到具体的落地动作:
| 原则 | 落地 |
|---|---|
| 最小可见 | 用户只能看自己的数据,管理员也只能看脱敏后的数据 |
| 传输加密 | 全站 HTTPS,内部 RPC 用 mTLS |
| 存储加密 | 敏感字段 AES-256-GCM 静态加密 |
| 操作审计 | 任何读/导出/删除都有日志 |
| 销毁可证 | 用户删除时硬删,并可下载销毁证明 |
五条原则里”销毁可证”最容易被人忽略——用户删数据不是点个删除按钮,而是要有可下载的销毁证明,这一点在面向企业客户时尤其重要。
二、传输层加密
传输层是数据离开内存后的第一道防线。对外全站 HTTPS,证书交给 Let’s Encrypt 自动续期,HSTS 强制跳转,用户访问的每个请求都走加密通道。对内,服务间调用如果还是明文,等于内网裸奔,所以我们上了 mTLS(Mutual TLS)。用服务网格管理证书最省心,Istio 一条 DestinationRule 就搞定:
# Istio DestinationRule
apiVersion: networking.istio.io/v1
kind: DestinationRule
metadata:
name: eval-service-mtls
spec:
host: eval-service
trafficPolicy:
tls:
mode: ISTIO_MUTUAL
数据库连接同样不能省——MySQL、Redis 全部开 TLS,代码里禁掉明文连接串,从源头上杜绝”顺手连了个明文库”。传输加密整体可以归纳成三步:
- 对外统一 HTTPS 与 HSTS,证书自动续期;
- 内部服务间开启 mTLS,证书由服务网格管理;
- 数据库与缓存强制 TLS,关闭明文端口。
三、存储层加密
传输加密挡得住网络侧,挡不住存储侧。数据库明文落盘,拿到备份就拿到了一切。我们选择应用层加密而不是 TDE,原因在 FAQ 里展开,这里先看实现。对用户答案原文这类敏感字段,用 JPA 转换器在读写时自动加解密:
@Entity
@Table(name = "eval_output")
public class EvalOutput {
@Id
private Long id;
private Long userId;
// 加密字段
@Convert(converter = AesGcmConverter.class)
private String content; // 用户答案原文
// 索引用 hash
private String contentHash; // SHA-256(content) 用于去重,不暴露原文
}
AesGcmConverter 实现 JPA AttributeConverter,主密钥从环境变量或 KMS 读取,绝不落库,甚至不进配置文件:
public class AesGcmConverter implements AttributeConverter<String, String> {
private static final String KEY = System.getenv("PLATFORM_AES_KEY");
@Override
public String convertToDatabaseColumn(String attribute) {
return AesGcm.encrypt(attribute, KEY);
}
@Override
public String convertToEntityAttribute(String dbData) {
return AesGcm.decrypt(dbData, KEY);
}
}
相比 MySQL 8.0 的 TDE,应用层加密可以精确到字段,score、duration 这类需要聚合的列保持明文,只有 content 走加密。Session 和缓存里如果放敏感数据同样要加密,我们封装了一个 SecureCache:
@Service
public class SecureCache {
public void put(String key, String value) {
String enc = AesGcm.encrypt(value, sessionKey);
redisTemplate.opsForValue().set("sec:" + key, enc, Duration.ofHours(2));
}
public String get(String key) {
String enc = redisTemplate.opsForValue().get("sec:" + key);
return enc == null ? null : AesGcm.decrypt(enc, sessionKey);
}
}
缓存的密钥用了独立的 sessionKey,跟数据库主密钥分开,一个泄露不至于全盘皆输。
四、访问控制
加密做完了,还要防”合法账号干坏事”。访问控制我们做了两层:API 层做用户级校验,数据层用拦截器强制注入 userId。API 层最朴素也最有效——每条查询都核对归属:
@GetMapping("/session/{id}")
public BaseResponse<EvalSession> get(@PathVariable long id) {
EvalSession s = sessionService.getById(id);
if (!s.getUserId().equals(currentUser.getId())
&& !currentUser.isAdmin()) {
throw new BusinessException(ErrorCode.NO_AUTH_ERROR);
}
return ResultUtils.success(s);
}
手写校验容易漏,所以又在数据层加了一道保险,用 MyBatis Flex 的拦截器自动往 SQL 里拼 WHERE user_id = ?,开发者想绕过都难:
@Interceptor(ignoreAlias = "user")
public class TenantInterceptor implements InnerInterceptor {
@Override
public void beforeQuery(Executor executor, MappedStatement ms,
Object parameter, RowBounds rowBounds,
ResultHandler resultHandler, BoundSql boundSql) {
if (isTenantTable(boundSql.getSql())) {
boundSql.setAdditionalParameter("userId", currentUser.getId());
// 改 SQL 加 WHERE user_id = #{userId}
}
}
}
管理后台单独设计成脱敏视图,只展示前 50 字加省略号,要看完整内容必须二次审批,每一步都留审计。双保险下来,越权查询在测试阶段就被自动化用例拦住了。
五、操作审计
访问控制做对了,不等于可以信任,关键是出了事能查。任何对敏感数据的读、导出、删除、下载操作,都写一条审计日志:
CREATE TABLE data_audit_log (
id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL, -- 操作者
action VARCHAR(32) NOT NULL, -- READ/EXPORT/DELETE/DOWNLOAD
target_type VARCHAR(32), -- EVAL_SESSION / PROMPT_LAB
target_id BIGINT,
ip VARCHAR(64),
user_agent VARCHAR(256),
created_at DATETIME,
INDEX idx_user_time (user_id, created_at)
);
审计日志只增不改,定期归档到只读 S3 桶,即使数据库被删,审计链也不会断。这个表给安全事件复盘提供了全部依据。
六、数据销毁
数据安全最后一环是销毁。用户注销账号,不是把数据标记删掉就完了,而是要有完整流程:先软删让业务立即不可见,再异步排期硬删,最后出一份可验证的销毁证明:
public void deleteUserData(long userId) {
// 1) 软删
sessionService.softDeleteByUser(userId);
outputService.softDeleteByUser(userId);
labService.softDeleteByUser(userId);
// 2) 异步硬删(30 天后执行)
destroyTaskService.schedule(new DestroyUserDataTask(userId),
Duration.ofDays(30));
}
@Scheduled(cron = "0 0 3 * * ?") // 每天凌晨 3 点
public void runDestroyTasks() {
for (var task : destroyTaskService.listDue()) {
// 硬删所有表数据
hardDeleteByUser(task.getUserId());
// 写销毁证明
certificateService.generate(task.getUserId(), "GDPR-compliant");
}
}
销毁证明包含销毁时间、销毁范围、独立校验码,用户可下载作为凭证。这里有个容易被忽略的点:硬删要连 binlog 和备份一起处理,重要数据甚至要 DROP TABLE 重建,否则 DELETE 之后数据仍然残留在底层存储里。
七、API Key 安全
说完用户数据,再说平台自己的密钥。我们管理 LLM API Key 有几条铁律:
| 措施 | 落地 |
|---|---|
| 不存明文 | AES-256-GCM 加密存数据库 |
| 不进日志 | Logback Pattern 脱敏 |
| 不进前端 | 后端代理,前端只调平台 API |
| 定期轮换 | 90 天自动轮换,旧 key 保留 7 天灰度 |
| 权限隔离 | 管理员查 key 也只能看尾号 4 位 |
表格里的规则执行起来不复杂,核心代码就是一个脱敏工具,把密钥只露出头尾:
public class ApiKeyUtil {
public static String mask(String key) {
if (key == null || key.length() < 8) return "***";
return key.substring(0, 6) + "..." + key.substring(key.length() - 4);
}
}
管理员查 Key 只能看到尾号四位,想看完整 Key 必须重新生成。90 天自动轮换加上 7 天灰度期,即使 Key 泄露,影响窗口也被压得很小。
八、踩过的坑
方案设计得再完备,落地过程也踩了不少坑,写出来给后来人参考:
- 加密字段不能模糊查询:
LIKE '%keyword%'在加密字段上无法使用。要么用 hash 索引(不支持模糊),要么用pg_trgm(PG)或全文搜索引擎。 - KMS 性能瓶颈:所有加密都过 KMS 中心化服务会成为瓶颈。平台用”本地 KEK + 远程 KMS 主密钥”双层,主密钥解 KEK 后本地用 KEK 加解密。
- 审计日志膨胀:读写都要审计的话一天 1 亿行。改为”只审计敏感操作(导出、删除、查全量)”,普通读不审计。
- 销毁不彻底:MySQL
DELETE后 binlog 仍存,硬盘残留。重要数据DROP TABLE重建。 - 内部人员偷数据:所有查询走 API 限流 + 异常查询告警(短时间大量查询触发审查)。
这些坑集中在两类:一类是”功能与安全打架”(加密字段不能模糊查询、KMS 性能瓶颈),一类是”你以为删了其实没删”(binlog 残留、审计膨胀)。安全设计最容易出问题的地方,恰恰是这些边界。
九、合规检查清单
为了不流于口号,我们把安全要求做成检查清单,每个发布周期过一遍:
- [ ] 所有页面 HTTPS
- [ ] 敏感字段加密存储
- [ ] API Key 不落日志
- [ ] 跨用户访问 100% 拦截(自动化测试覆盖)
- [ ] 审计日志 90 天可查
- [ ] 数据销毁 30 天可执行
- [ ] 第三方依赖无已知高危漏洞
- [ ] 员工安全培训记录
清单不是一次性动作,平台每季度自审一轮,新员工入职也要过一遍流程培训。数据安全没有一劳永逸,只有持续检查。
常见问题(FAQ)
Q1:为什么用应用层加密而不是 MySQL TDE?
TDE 是表级加密,性能开销小但粒度粗。平台按字段加密更灵活,例如只对 content 加密,score、duration 仍明文方便聚合。
Q2:用户能看到自己的 API Key 明文吗?
不能。用户添加 Key 时平台只存加密版本和”尾号 4 位”。要看完整 Key 必须重新生成,这是金融级安全实践。
Q3:怎么验证加密没漏?
DBA 定期跑脚本:直接 SELECT 加密字段,应该全部是乱码 base64。任何明文命中都触发安全告警。