EULA同意管理:从法务文档到可审计服务端状态机

简介:这份资源围绕《电子用户许可协议(EULA)》与Minecraft服务端初始化配置展开,面向准备自行开服的服主、服务器管理员及刚接触服务端配置的玩家,帮助解决开服前协议确认、核心参数对照与基础环境搭建的问题。压缩包共9个文件,以4个YAML配置文件、属性文件、许可协议确认文本及PNG服务器图标为主,另含压缩包,整体约6.17MB,覆盖服务端运行前常见的配置类型。已有284人学习,说明其在开服入门场景中具备一定参考价值。读者可从中获得协议同意文件的样例、服务端主要配置项的模板与图标素材,便于快速搭建测试环境、对照理解各配置文件的分工,并在修改参数前建立清晰的配置结构认知,减少因遗漏协议或误改核心项导致的启动失败。

1. EULA 不是一份法务文档,而是一道服务端状态机

去年给一个订阅制工具产品做版本升级,条款改了,运营问我一句:能证明这批用户同意的是哪一版吗?我打开后台,发现只有一张user.agreed = 1的布尔字段。那一刻我才意识到,EULA 这件事在工程侧从来不是“贴个链接、勾个框”,它是一套带版本、带生效时间、带留痕的状态机。用户当前处于“未同意 / 已同意旧版 / 已同意当前版 / 已过期”四个状态中的哪一个,服务端必须随时能答上来,并且答得可审计。

它解决的是三件具体的事:条款变更后谁能继续用、谁要被拦下来;出争议时能不能拿出带哈希、带时间戳、带版本号的同意记录;多端多产品线时状态是否一致。适合正在做订阅制、SaaS、客户端分发、游戏服务端的团队,尤其是那种条款一年改两三回、又不想每次改完都靠客服兜底的场景。

2. 把 EULA 拆成可校验的数据结构:版本号、哈希与生效时间

这一章先把“条文”变成“数据”。判定的依据不能靠“文案组说改了”,判断必须落在三个字段上:版本号、正文哈希、生效时间,其中哈希是唯一身份,版本号只是给人看的标签。

2.1 三种存放形态的选型:数据库表、静态文件、配置中心

存放形态决定了后面所有流程的复杂度,先做选择再做实现。

形态优点代价适用
数据库表事务内可查、可与同意记录 join、支持后台改需要迁移与管理界面多产品线、需要审计
静态文件随包发简单、天然版本化、离线可用改条款必须发版,服务端不知道旧版本存在纯客户端、条款极少变
配置中心热更新快、灰度方便内容审计弱、容易被随手改临时公告类条款

我一般选数据库表 + 对象存储。正文放对象存储,表里只存body_url和content_hash。理由很实际:条款正文动辄几万字、带排版,塞进数据库列里既难 diff 也难回滚;而判定的全部依据其实只是那个哈希,正文本身只在展示和留档时用。静态文件方案看着省事,一旦条款改了要发版,历史版本就断了,审计时拿不出旧版全文,这条基本就堵死了。

表结构大致如下,注意它是“只追加”的,不做 update:

CREATE TABLE eula_document ( doc_id BIGSERIAL PRIMARY KEY, product_key TEXT NOT NULL, -- 产品线,如 'desktop'、'mobile' version TEXT NOT NULL, -- 人读标签,如 '2025.03-2' content_hash CHAR(64) NOT NULL, -- 正文 sha256,判定身份的字段 body_url TEXT NOT NULL, -- 对象存储地址 change_level SMALLINT NOT NULL, -- 1 格式性 / 2 实质性 effective_at TIMESTAMPTZ NOT NULL, -- 生效时间,统一存 UTC created_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (product_key, version), UNIQUE (product_key, content_hash) ); CREATE TABLE eula_consent ( consent_id BIGSERIAL PRIMARY KEY, account_id BIGINT NOT NULL, product_key TEXT NOT NULL, version TEXT NOT NULL, content_hash CHAR(64) NOT NULL, -- 冗余存一份,防止事后被篡改对应关系 consented_at TIMESTAMPTZ NOT NULL DEFAULT now(), source TEXT NOT NULL, -- web / desktop / api idem_key TEXT, -- 幂等键,来自客户端 UNIQUE (account_id, product_key, version) ); CREATE INDEX idx_consent_account ON eula_consent (account_id, product_key);

逻辑说明两句:eula_document上的UNIQUE (product_key, content_hash)是防呆设计,同一份正文不允许挂两个版本号,否则“用户同意的是哪一版”就说不清了。eula_consent上的唯一键(account_id, product_key, version)直接承担了幂等,重复提交不会产生第二条记录,这比在应用层先查后插可靠得多。

2.2 哈希要对正文做规范化,否则同一篇文章会算出两个值

这个坑吃过一次:文案组在编辑器里把换行从\r\n改成\n,内容一个字没变,哈希全变了,触发了全量再同意,客服当天就被打爆。规范化要在算哈希之前做。

import hashlib import re def normalize(raw: str) -> str: """把正文规范化为稳定形式,保证同样的内容算出同样的哈希。""" text = raw.replace("\r\n", "\n").replace("\r", "\n") # 统一换行符 text = re.sub(r"[ \t]+", " ", text) # 压缩行内空白 text = re.sub(r"\n{3,}", "\n\n", text) # 压缩空行 return text.strip() def content_hash(raw: str) -> str: return hashlib.sha256(normalize(raw).encode("utf-8")).hexdigest()

注意这里的处理范围:只做空白与换行层面的归一化,不动标点、不做大小写折叠。中文条款里全角半角标点是内容的一部分,折了就等于改了条款,哈希失真的风险比“多算一次”大得多。如果正文是 HTML,还要在规范化之前剥掉渲染层属性(比如编辑器自动加的style),只保留文本结构,否则换一个编辑器模板哈希就漂移。

2.3 版本号别用语义化版本,用日期加序号

第一版我用了1.2.0这种三段式,很快就乱。语义化的 MAJOR 表达“破坏性变更”,可协议没有 API 兼容性的概念,什么叫破坏性全靠人判断;而且同一天改两次(上午补个错别字、下午加一条数据使用说明),第三位根本不够分。

现在统一用<YYYY.MM>-<序号>,例如2025.03-2,含义是 2025 年 3 月的第二次修订。它满足三个需求:看一眼就知道新旧;同月多次修订有位置放;排序天然按时间。版本号只用于展示和人工沟通,程序判定一律比对content_hash——版本号可以被复用、可以被写错,哈希不会。

还有一条边界要守:effective_at与created_at必须分开。前者决定“什么时候开始拦人”,后者是“什么时候入库”。允许提前入库、延后生效,这样灰度时有缓冲期;反过来先上线后补数据,就会出现一段时间内服务端认为“没有生效版本”,所有人都被放行,等于开了个口子。

3. 同意流程的最小实现:拦截、展示、落库、放行

流程本身很朴素:请求进来先判状态,未同意就返回一个约定的错误码,客户端据此拉起条款页,用户确认后写一条同意记录,再放行。真正容易翻车的是拦截点的位置和幂等。

3.1 服务端拦截写在依赖里,别写成全局中间件

全局中间件的问题是无法按路由豁免。登录、健康检查、错误上报这些接口一旦被拦,整个客户端就进不去,用户连同意页都加载不出来,形成死锁。用依赖注入的方式挂到具体路由上,豁免清单反而变成次要问题。

from datetime import datetime, timezone from fastapi import Depends, HTTPException, Request class EulaRequired(HTTPException): def __init__(self, version: str, content_hash: str, body_url: str): super().__init__(status_code=403, detail={ "code": "EULA_REQUIRED", "version": version, "contentHash": content_hash, "bodyUrl": body_url, }) async def require_eula(request: Request, account=Depends(current_account)): product_key = request.headers.get("X-Product-Key") or "default" now = datetime.now(timezone.utc) # 取当前时刻应生效的最新版本,缓存 TTL 60s,见 3.3 的失效策略 doc = await eula_repo.latest_effective(product_key, now) if doc is None: return account # 只在 effective_at 之后才算数,提前入库的版本不参与判定 agreed = await eula_repo.has_consent(account.id, product_key, doc.content_hash) if not agreed: raise EulaRequired(doc.version, doc.content_hash, doc.body_url) return account

参数上有三个点要注意。product_key允许从请求头取,是为了同一个账号在不同客户端能有各自的同意状态——桌面端和移动端的条款往往不是一份。判定用content_hash而不是version,是为了防止后台误操作把版本号改回去但正文已经变了。返回体里带上bodyUrl而不是返回正文,是因为条款正文可能有几十 KB,塞进 403 响应体会拖慢首屏,客户端拿到地址后自己拉一次并缓存更合适。

缓存策略上,latest_effective的查询结果缓存 60 秒足够。这个 TTL 不是随便定的:生效时间通常精确到天,60 秒的窗口最多让用户在生效后一分钟内还能用旧状态,业务上完全可接受;而如果缓存太久,灰度发布时就会看到“一部分人已经被拦、一部分人没有”的诡异现象。

3.2 客户端把“滚动到底”做成硬约束,而不是装饰

条款页有个经典问题:用户根本没看就点了同意,事后主张“没有展示”。做法把确认按钮置灰,直到正文滚动到底部才可点,并且把用户停留时长一起上报。这不只是产品体验,也是留痕的一部分。

async function ensureEulaAccepted() { const res = await fetch('/api/eula/status', { credentials: 'include' }); const { required, acceptedHash } = await res.json(); if (!required || acceptedHash === required.contentHash) return true; const agreed = await new Promise((resolve) => { const dialog = openEulaDialog(required.bodyUrl); dialog.onScrollToBottom = () => { dialog.enableConfirm(); }; dialog.onConfirm = () => resolve(true); dialog.onClose = () => resolve(false); }); if (!agreed) return false; const submit = await fetch('/api/eula/accept', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json', 'Idempotency-Key': crypto.randomUUID(), // 每次提交生成一个,服务端去重 }, body: JSON.stringify({ version: required.version, contentHash: required.contentHash, // 回传哈希,服务端必须校验一致 }), }); if (!submit.ok) return false; return true; }

contentHash回传是这条链路里最关键的一环。如果只传version,用户在打开弹窗到点击确认之间,后台恰好发了一版新条款,那么这条同意记录就会把旧哈希记到新版本上,审计时对不上。服务端收到后要先比对contentHash是否等于当前生效版本的哈希,不等就直接拒绝并让客户端重新拉取,宁可多弹一次,也不要写错一条。

Idempotency-Key用于应对网络重试和用户连点。服务端把(account_id, idem_key)作为去重依据,如果没有这个机制,双击确认会触发两条插入,虽然唯一约束会挡住,但会返回 500,客户端就展示成“同意失败”,用户反复点,日志里全是噪声。

3.3 落库用 upsert,并发冲突交给数据库

应用层“先 select 再 insert”在并发下有天然窗口,尤其是客户端重试比较激进的时候。直接用ON CONFLICT DO NOTHING,然后把“是否已存在”当作成功处理。

INSERT INTO eula_consent (account_id, product_key, version, content_hash, source, idem_key) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (account_id, product_key, version) DO NOTHING RETURNING consent_id;

返回空行不代表失败,代表这条同意记录早就有了,接口照样返回 200。这个约定要在接口文档里写清楚,否则前端会把“返回空”当成异常去重试。另一个细节:consented_at用数据库默认值而不是应用传入,避免客户端时钟不准导致同意时间比生效时间还早,审计时看起来像是伪造的。

到这里最小闭环就通了:请求被拦 → 返回版本信息 → 客户端展示并被约束阅读 → 确认提交并校验哈希 → upsert 落库 → 下次请求放行。整条链路没有状态存在内存或缓存里,重启不丢,这是后面所有能力的基础。

4. 条款变更时的分级与再同意策略

条款一改,最危险的动作是“一刀切全量拦截”。用户正在付费流程里,突然被弹到条款页,转化率当场掉。我现在的做法是先给变更分级,不同等级对应不同动作,再谈时效。

4.1 变更分级:格式性、通知性、实质性

等级典型内容用户侧行为期限
1 格式性错别字、排版、联系方式更新静默记录新哈希,不打断无
2 通知性新增解释性条款、明确已有权利站内提示,可关闭,仍可继续用30 天
3 实质性计费方式、数据使用范围、责任限制阻断核心功能,必须再同意7~14 天宽限

分级不能靠程序判断,得由法务或产品在发布时手工标注,写进change_level字段。标注这件事本身就是个流程要求:谁定的等级、依据是什么,最好也在发布记录里留一句,不然后面出事回溯时没人认账。

宽限期的设计逻辑是“既不突然打断,也不无限拖延”。等级 3 的 7~14 天不是拍脑袋,它要覆盖一个完整的账单周期边缘——如果用户正好在计费日前后才登录,太短的窗口会让他来不及看到通知就被限制。等级 2 给 30 天,是因为它本来就不阻断,窗口的作用只是让“已阅”状态能收口,便于统计覆盖率。

4.2 把“当前状态”算成一个纯函数,线上线下共用

状态判定最忌讳散落在多处。写成一个纯函数,输入doc、consent、now、level,输出枚举,服务端拦截和后台报表都调它,口径才不会打架。

from dataclasses import dataclass from datetime import datetime, timedelta from enum import Enum class EulaState(str, Enum): OK = "ok" # 已同意当前版 NOTICE = "notice" # 需提示,但不阻断 GRACE = "grace" # 已过期,宽限期内,可继续用 BLOCKED = "blocked" # 必须同意后才能继续 GRACE_DAYS = {1: None, 2: 30, 3: 10} def resolve_state(now: datetime, doc, consent, level: int) -> EulaState: if consent and consent.content_hash == doc.content_hash: return EulaState.OK if now < doc.effective_at: return EulaState.OK # 新版本还没生效,按旧状态走 days = GRACE_DAYS.get(level) if days is None: return EulaState.NOTICE # 格式性变更,不阻断 if now < doc.effective_at + timedelta(days=days): return EulaState.GRACE if level >= 3 else EulaState.NOTICE return EulaState.BLOCKED if level >= 3 else EulaState.NOTICE

now作为显式参数传进来,而不是在函数里取系统时间,是为了可测——审计回放时把历史时间点传进去,就能复现“那一天这个用户应该是被拦还是放行”。GRACE_DAYS.get(level)返回None表示该等级没有宽限概念,直接落到NOTICE。等级 2 永远不返回BLOCKED,这个约束写在函数里而不是靠调用方自觉,能省掉很多“某个接口忘了判断”的事故。

宽限期结束后是硬阻断还是降级只读,取决于产品形态。订阅制工具我倾向“核心功能只读、导出和续费放行”,因为把用户彻底挡在门外只会催生退款,而只读状态既能表达“你需要处理这件事”,又不至于把人逼走。

4.3 通知要能重试,覆盖率的账要能算清

再同意这件事的成败,一半取决于通知有没有送达。站内信、邮件、客户端弹窗三路并行,每一路都要落发送记录,否则三天后你根本不知道“没同意的人”是没看到还是看到了不点。

用一张 outbox 表记录待发消息,定时任务扫描BLOCKED或GRACE状态的用户批量投递,失败按指数退避重试。关键指标只有一个:同意人数 / 应同意人数的日环比。这个数字如果第三天开始变平,说明还有效触达的用户已经触达完了,剩下的是沉默账户,这时候再加大推送力度意义不大,反而该考虑在下次登录时做一次强提示。

还有一个容易被忽略的动作:条款变更前,把变更摘要单独做成一页对照,标出哪几条改了。用户点同意之前至少知道改在哪,这比把几万字全文再读一遍现实得多,也是后续争议里“已充分告知”最直接的证据。

5. 排查:同意状态对不上的五个高频现场

5.1 缓存与落库不一致导致的状态漂移

现象:用户明确点了同意,刷新后又被弹窗,日志里能看到 accept 请求返回 200。

原因:同意状态写进了 Redis 但没落库,或者落库成功但缓存没失效,读路径拿到的是旧值。更隐蔽的一种是缓存了has_consent的布尔结果,key 里却没带content_hash,换版本后缓存命中,直接返回“已同意”。

解决:同意状态只认数据库,缓存只缓latest_effective这种与用户无关的数据,且 key 里必须带product_key和生效时间窗口。如果确实要缓存用户态,TTL 压到 5 秒以内,并在写入成功后主动删 key,别指望过期。

5.2 版本错位:后台改了正文没改版本号

现象:审计时发现两条同意记录指向同一版本号,但用户声称看到的内容不一样。

原因:运营在后台直接编辑了正文,绕过了发布流程,content_hash变了而version没动;或者先发布后补文档,短暂出现两个同版本号记录。

解决:给eula_document加触发器或在发布接口里做硬校验,content_hash已存在的记录不允许再用新版本号插入;正文更新一律走“新建版本”流程,旧版永不修改。已经产生的脏数据只能人工核对,代价远大于加一条约束。

5.3 时区把生效时间算早了或算晚了

现象:明明约定了某天零点生效,结果前一天晚上就有用户被拦。

原因:effective_at存的是本地时间却被当成 UTC 读,或者反过来,服务端容器时区与数据库会话时区不一致。

解决:列类型统一TIMESTAMPTZ,写入前全部转 UTC,展示层再转本地。拦截逻辑里取now一律用 UTC 并显式传入,别用数据库的now()和应用的datetime.now()混着比。

5.4 子账号与匿名用户成了漏网之鱼

现象:主账号已同意,切换到子账号仍然被拦;或者未登录用户走 API 完全不受限。

原因:同意记录挂在account_id上,但子账号有独立 ID;匿名接口没挂require_eula依赖。

解决:明确同意主体是账号还是租户。如果是租户级,记录挂tenant_id,子账号继承;如果是个体级,就别在子账号场景抱怨“怎么又要同意”。匿名接口逐个过一遍路由表,把涉及数据写入和计费的补上依赖,纯只读的公开接口可以豁免但要在文档里写明。

5.5 灰度期间新旧两版同时在用,报表口径打架

现象:后台显示“同意率 98%”,但客服反馈一堆人进不来。

原因:统计脚本用的是“是否同意过任意版本”,而拦截用的是“是否同意当前生效版本”,两个口径。

解决:报表口径必须和拦截口径共用 4.2 里那个resolve_state函数,按状态分桶输出OK / NOTICE / GRACE / BLOCKED四个数。只看一个“同意率”是会骗人的,真正该盯的是BLOCKED占比和它的下降速度。

6. 上线前拿历史时间点回放一遍,比写十页验收文档管用

验证这套东西,我不太信人工点页面。做法是写一个审计回放脚本,给一个账号和任意历史时间点,输出“那一刻应生效的版本、用户实际同意的版本、判定状态”,三个值并排看,对不上就是有问题。

async def replay(account_id: int, product_key: str, at: datetime): doc = await eula_repo.latest_effective(product_key, at) # 注意:按 at 而非 now 取 consent = await eula_repo.consent_at(account_id, product_key, at) state = resolve_state(at, doc, consent, doc.change_level if doc else 1) return { "at": at.isoformat(), "required_version": doc.version if doc else None, "required_hash": doc.content_hash if doc else None, "consented_version": consent.version if consent else None, "match": bool(doc and consent and consent.content_hash == doc.content_hash), "state": state.value, }

latest_effective必须支持传入时间点而不是内部取now,这是回放能成立的前提。consent_at同理,取的是consented_at <= at的最新一条,而不是“当前是否同意”。这两个方法在拦截路径上也复用,只是传now,代码只有一份,回放结论才可信。

上线前的固定动作是三件事。第一,把GRACE_DAYS全改成 0,在预发环境跑一遍,确认所有应拦用户都被拦住、豁免接口都通——宽限期会掩盖拦截逻辑的 bug,不关掉测不出来。第二,用影子账号模拟“未同意用户访问付费接口”,确认返回的是 403 加EULA_REQUIRED,而不是 500 或 401,客户端才能正确识别并拉起弹窗。第三,抽 20 个历史账号做回放,重点看跨版本的那些,确认状态迁移符合预期。

有一条血泪经验值得单独说:宽限期这个参数,上线前务必和生产配置对一遍。我有一次在预发把GRACE_DAYS调成 0 做压测,配置同步脚本把生产也刷了,第二天早高峰全量用户被弹到条款页,客服电话直接打爆。这种事没有后悔药,回滚配置也要等缓存过期。现在的习惯是:涉及时间窗口的参数全部走独立配置项并加环境前缀,预发和生产用两套 key,改之前先diff一眼。另外,每次条款变更前,我都会自己走一遍新用户注册流程,从零开始点一遍同意,这比看任何监控都直观。

希望帮到你。

本文还有配套的精品资源,点击获取