一次退款纠纷,四类日志
《泡泡工坊》的一笔退款纠纷把四个团队拉进同一个工单。财务要看支付回调与退款状态,安全团队要看登录设备和异常行为,运营要看玩家当天的对局记录,客服要确认工单本身有没有被改过。
四类日志都在,但它们的保留期、存储介质和访问权限完全不同。支付日志按财务参数保留,放在可查询的热存储;安全日志需要更长的窗口,冷热分层;战斗日志量大,归档后只保留聚合与回放索引;客服工单属于行为记录,既要能查,又不能被客服自己改写。
更麻烦的是取证。安全团队要按玩家导出登录记录,财务要按订单导出支付流水,法务要按时间窗口导出全部相关事件。三个请求都叫“导出日志”,但如果都走同一条 SQL,脱敏、授权和清单格式一定有一个是错的。
这一节讨论日志留存、防篡改和取证的工程做法,不解读具体法律条文,也不构成法律意见。每类日志保留多久、哪些字段属于敏感信息、谁有权批准导出,都是外部输入;服务端要做的是让这些输入可配置、可验证、可追溯。
留存分级:先分类,再谈成本
把所有日志塞进一个索引,最常见的结局是:热存储成本失控,冷存储没人查,到期清理不敢动。北辰按“谁会用、用来回答什么问题”把日志分成四类。
| 类别 | 典型内容 | 主要使用者 | 存储策略 |
|---|---|---|---|
| security_audit | 登录、权限变更、后台操作 | 安全与审计 | 热存短、冷存长,禁止改写 |
| payment | 下单、回调、退款、对账 | 财务与客服 | 热存可查,按财务参数保留 |
| combat | 对局开始、结算、异常 | 运营与反作弊 | 热存短,归档保留索引 |
| behavior | 点击、页面、客服工单 | 运营与产品 | 聚合优先,明细可匿名化 |
保留参数按类别配置,不在代码里写死。每类有三个时间点:热存窗口、冷存窗口、总保留期。超过热存窗口进入冷存,超过总保留期才允许清理;三个值必须单调递增,配置加载时校验,避免“冷存比热存还短”这种看起来能跑、实际永远命不中冷存的配置。
到期清理要单独授权。清理任务只读策略,不读业务代码里的常量;某类日志被法律保留时,策略上的 hold 标记会让 Purgeable 返回 false,清理任务即使跑到了总保留期也不能删。hold 的开关是一次可审计的人工动作,不能由定时任务自动解除。
成本优化发生在分层,不发生在“少记”。安全审计和支付日志的价值在完整,不在样本;战斗日志可以先聚合再归档;行为日志可以只保留聚合指标和少量样本。把“降本”做成“随机丢 90% 日志”,出事时既证明不了访问,也还原不了现场。
分层边界要按查询模式定,而不是按天数拍脑袋。安全调查通常在事件发生后的一小段时间内最频繁,热存窗口应覆盖这段高频期;支付对账集中在结算周期,冷存要保证按订单号可查;战斗日志的峰值在赛季末,归档后保留索引比保留全量原文更划算。
| 查询场景 | 主要命中的层 | 索引键 | 可接受延迟 |
|---|---|---|---|
| 客服查玩家最近行为 | 热存 | 账号 ID + 时间 | 秒级 |
| 财务按订单对账 | 热存或冷存 | 订单号 | 分钟级 |
| 安全事件回溯 | 冷存 + 归档 | 账号 ID / 设备 | 小时级 |
| 法务按窗口调取 | 归档 | 时间范围 | 天级 |
这张表也解释了为什么“把热存调短”不能单独作为降本手段:如果冷存没有按订单号建索引,财务的查询会退化成全量扫描,成本从存储转移到了计算,总账未必下降。
防篡改:追加写、哈希链与 WORM
日志系统最危险的假设是“写进去就不会变”。运维有删库权限,应用有更新权限,对象存储的管理员可以覆盖对象;如果没有校验,任何一次误操作或内部人操作都无法被发现。
第一层是追加写。应用只有 append 权限,没有 update 和 delete 权限;写入接口不接受“修改第 N 条”,只接受“追加一条”。这一层挡的是应用 bug 和普通权限滥用,挡不住存储管理员。
第二层是哈希链。每条记录包含序号、时间、类别、操作者、动作、对象、结构化字段、前一条的哈希,以及自身内容的哈希。自身哈希覆盖前面所有字段,因此修改任意字段都会导致校验失败;前一条哈希把记录串成链,删除或重排中间一条会让后续链接断开。
第三层是 WORM 或对象锁。归档对象写入后进入不可覆盖状态,保留期由存储策略控制。哈希链能发现篡改,WORM 能阻止篡改;两者解决的是不同问题,不能互相替代。
| 机制 | 挡住什么 | 挡不住什么 | 代价 |
|---|---|---|---|
| 追加写权限 | 应用误改、普通账号删除 | 存储管理员 | 低 |
| 哈希链 | 字段修改、删除、重排 | 整链重算(若攻击者可写全部) | 中 |
| WORM / 对象锁 | 覆盖与提前删除 | 配置错误、保留期设置过短 | 高 |
| 外部见证 | 整链重算 | 见证服务自身被控 | 中高 |
哈希链的校验要保存“断点”。生产环境不会每次从头校验全链,通常按天或按批次做锚点,把锚点哈希写到独立介质;校验时从最近锚点开始,发现断点就停下并告警。不要静默跳过坏记录继续往后算,那会把一次可定位的篡改变成一条看起来完整的链。
锚点存储必须独立于日志存储。如果锚点和链在同一个账号、同一个对象存储桶里,能改链的人也能改锚点,见证就失效了。北辰把每日锚点写入独立的审计账号,只允许追加;校验任务用只读凭据读日志链,用另一套凭据读锚点。
一次完整的校验至少要回答三个问题:
- 链内是否连续:序号从锚点之后逐条递增,没有缺口;
- 链接是否闭合:每条记录的前向哈希等于上一条的自身哈希;
- 内容是否未变:每条记录的自身哈希与重新计算的结果一致。
三个问题分别对应删除、重排和字段修改。只校验其中一项,都会留下一种未被覆盖的篡改方式。
审计日志自身的访问也要控制。能读审计日志的人,不能同时拥有删除审计日志的权限;导出动作本身要写一条审计记录,形成“谁在什么时候导出了谁的数据”的闭环。
取证:按主体、按时间、按授权
取证导出与运营报表是两件事。报表可以聚合、可以采样;取证导出要能回答“这个玩家/订单在某个时间窗口内发生了什么”,并且每一条都能回到原始记录。
一个可执行的导出流程包含四个要素。
第一,主体明确。按玩家导出用账号键,按订单导出用订单号,按时间导出必须同时给出用途和范围。没有主体的“全量导出”需要更高级别的授权,不能和普通客服工单走同一个入口。
第二,授权分离。申请人、审批人、执行人至少有两个角色;导出请求记录申请理由、审批人、时间窗口和字段范围。审批不是走形式,它决定了脱敏规则:客服看到设备型号,不需要看到完整设备号;财务看到订单金额,不需要看到聊天内容。
第三,字段脱敏。导出前按字段名替换敏感值,并记录哪些字段被替换。脱敏发生在生成清单的阶段,不是导出后手工处理;否则明文已经落到临时文件,删除请求又要多扇出一层。
第四,清单摘要。导出结果附带一份清单:请求号、生成时间、行数、每行的序号与脱敏字段、整份清单的摘要。接收方可以用摘要验证文件没有被追加或裁剪;审计只需保存摘要和授权记录,不必长期保存明文导出件。
导出件本身也有生命周期。生成后的临时文件放在受控目录,交付后按参数清理;接收方拿到的文件登记编号和摘要,后续删除请求到达时,系统能定位哪些导出件包含该主体。导出件散落在个人下载目录,删除权就失去了边界。
清单摘要还解决了一个常见争议:接收方说文件少了几行,导出方说原始数据就是这样。摘要只证明文件与生成时一致,不证明生成时的过滤条件正确;因此授权记录里必须同时保存主体键、时间窗口和字段范围,否则摘要只能自证完整,不能自证范围。
| 导出类型 | 主体键 | 默认脱敏 | 审批级别 |
|---|---|---|---|
| 玩家查询 | 账号 ID | 设备号、IP、支付凭证 | 客服主管 |
| 订单核对 | 订单号 | 聊天、设备指纹 | 财务主管 |
| 安全调查 | 账号 ID + 时间窗 | 支付凭证 | 安全负责人 |
| 法务调取 | 时间窗 + 范围 | 按调取函要求 | 法务 + 安全 |
把防篡改做成链:auditretention
下面的实现展示追加写与哈希链的核心:每条记录覆盖自身内容与前一条哈希,校验时同时检查序号连续、前向链接和自身哈希。
// file: internal/auditretention/chain.go
// tier: L1
// verify: go test ./internal/auditretention/
package auditretention
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"sort"
"strings"
"time"
)
var (
ErrChainBroken = errors.New("auditretention: hash chain broken")
ErrChainSequence = errors.New("auditretention: non-contiguous sequence")
)
// Entry is one audit record. Fields carries structured details and is part of
// the hash; the map is cloned on append so a caller cannot mutate history.
type Entry struct {
Seq uint64
At time.Time
Class Class
Actor string
Action string
Object string
Fields map[string]string
Prev string
Hash string
}
// Chain is an append-only in-memory chain. Production would persist the same
// fields in an append-only store, but the verification rule is identical:
// each hash covers its own content and the previous hash.
type Chain struct {
entries []Entry
}
func NewChain() *Chain {
return &Chain{}
}
var genesis = strings.Repeat("0", sha256.Size*2)
func (c *Chain) Append(at time.Time, class Class, actor, action, object string, fields map[string]string) Entry {
prev := genesis
if n := len(c.entries); n > 0 {
prev = c.entries[n-1].Hash
}
e := Entry{
Seq: uint64(len(c.entries) + 1),
At: at.UTC(),
Class: class,
Actor: actor,
Action: action,
Object: object,
Fields: cloneFields(fields),
Prev: prev,
}
e.Hash = hashEntry(e)
c.entries = append(c.entries, e)
return e
}
func (c *Chain) Entries() []Entry {
out := make([]Entry, len(c.entries))
for i, e := range c.entries {
out[i] = e
out[i].Fields = cloneFields(e.Fields)
}
return out
}
func cloneFields(in map[string]string) map[string]string {
if in == nil {
return map[string]string{}
}
out := make(map[string]string, len(in))
for k, v := range in {
out[k] = v
}
return out
}
func hashEntry(e Entry) string {
h := sha256.New()
fmt.Fprintf(h, "%d\n%s\n%s\n%s\n%s\n%s\n",
e.Seq, e.At.UTC().Format(time.RFC3339Nano), e.Class, e.Actor, e.Action, e.Object)
keys := make([]string, 0, len(e.Fields))
for k := range e.Fields {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
fmt.Fprintf(h, "%s=%s\n", k, e.Fields[k])
}
return hex.EncodeToString(h.Sum(nil))
}
// Verify checks sequence continuity, previous-hash linkage and each record's
// own hash. Reporting the first broken index is enough for an operator to
// find the boundary; it is not a claim that later entries are intact.
func (c *Chain) Verify() error {
for i, e := range c.entries {
if e.Seq != uint64(i+1) {
return fmt.Errorf("%w at index %d: seq=%d", ErrChainSequence, i, e.Seq)
}
wantPrev := genesis
if i > 0 {
wantPrev = c.entries[i-1].Hash
}
if e.Prev != wantPrev {
return fmt.Errorf("%w at seq %d: prev mismatch", ErrChainBroken, e.Seq)
}
if e.Hash != hashEntry(e) {
return fmt.Errorf("%w at seq %d: hash mismatch", ErrChainBroken, e.Seq)
}
}
return nil
}
这段代码有几个刻意的选择。
第一,Append 克隆传入的字段 map。调用方在写入后修改原 map,不能改变已经落链的历史;Entries 返回的也是副本,读取方修改返回值不会污染链。
第二,哈希覆盖结构化字段的排序结果。字段 map 的遍历顺序不稳定,哈希前必须按 key 排序,否则同样的内容会算出不同哈希,校验永远失败。
第三,Verify 报出第一个坏点就停止。序号不连续、前向哈希不匹配、自身哈希不匹配是三类不同的问题,错误信息带序号,运维可以直接定位到批次。
同包的 retention.go 负责分类、分层与 hold,export.go 负责按主体和时间过滤、字段脱敏与清单摘要。三部分共用同一套类别定义,避免“留存按 A 分类、导出按 B 分类”的口径漂移。
失败模式与兜底
| 失败 | 直接后果 | 兜底 |
|---|---|---|
| 应用拥有 update 权限 | 历史被静默改写 | 权限收紧 + 链校验告警 |
| 哈希链断点未告警 | 篡改长期不可见 | 按批锚点 + 断点即停 |
| 归档保留期配置过短 | 该留的日志提前消失 | 配置校验 + 变更审批 |
| hold 被定时任务解除 | 保留义务被绕过 | hold 只能人工释放并留审计 |
| 导出未脱敏 | 明文扩散到工单附件 | 生成阶段脱敏 + 字段白名单 |
| 清单无摘要 | 文件可被追加 | 摘要随文件交付并登记 |
兜底的原则是“让异常可见,让权限可收”。日志系统不怕告警多,怕的是链断了没人知道、导出没记录、hold 被自动解除。每一次例外都应该留下一条审计记录,而不是在代码里加一个静默分支。
常见错误
第一,所有日志一个保留期。支付要长、战斗可短、行为可聚合,一刀切不是过度保留就是提前删除。
第二,把冷存储当备份。冷存储仍要可校验、可导出;只写不读的归档,等于把风险藏起来。
第三,哈希链只算内容不算前链。这样只能发现单条修改,发现不了删除和重排。
第四,校验失败后跳过坏记录继续。断点被抹平,事后无法判断影响范围。
第五,导出和报表共用接口。报表可以采样,取证不能;共用接口最终一定在某一侧做错。
第六,审批人和执行人可以是同一人。授权分离失效,导出记录再完整也只是自证。
小结
日志留存、审计与取证的落点,是三个分离:留存按类别分离,防篡改按机制分层,取证按授权分离。类别决定保留多久、放哪一层;追加写、哈希链和 WORM 各自挡住不同层级的改写;取证导出要能按主体和时间定位,做字段脱敏,并用清单摘要证明文件完整。
做到这里,合规与数据治理的三个工程面就齐了:入口处的规则判断、数据生命周期的分类与删除、日志的留存与取证。它们共同的前提是参数化——期限、年龄、区域和字段清单都来自外部确认,代码只负责执行和留痕。
阅读导航:上一节:10.2 隐私、数据出境与删除权 · 下一节:11.1 有状态服务的发布 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。