ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

EULA资源包工程化:条款抽取、指纹校验与同意留痕实践

EULA资源包工程化:条款抽取、指纹校验与同意留痕实践

简介:围绕《电子用户许可协议(EULA)详解》整理的Java版Minecraft服务端开服配置参考包,面向需要搭建、调试服务端并理解EULA约束的初学者与服主。包内共9个文件,以4个yml服务端配置文件、server.properties属性文件、eula.txt文本及server-icon.png图标为主,另含2个zip压缩包,整体约6.17MB,目录简洁,便于按用途查找。已有283人学习,说明其对开服基础配置与协议认知具有一定参考价值。内容覆盖EULA的性质、许可范围、使用限制、隐私政策、保修与责任限制、更新升级及终止条款等主题,并与实际服务端配置场景相呼应;读者可据此梳理EULA在软件使用中的权利义务边界,同时对照常见服务端配置文件了解参数分布与文件组织方式,为后续深入阅读协议和排查配置问题提供线索。

1. 从一段静态文字到可交付制品:eula 资源包解决的是什么问题

多数团队第一次处理 eula,是把一段文字塞进安装器、勾一个"我已阅读并同意"就算完事。等到私有化交付验收、海外版本发版、或者审计方要一份"某年某月某台机器上哪个版本同意了哪份文本"的记录时,才发现这段文字没有版本号、没有指纹、没有留痕,连改过几次都说不清。这份 eula 资源包要解决的就是这件事:把用户许可协议从一段静态文字,变成一份可版本化、可抽取、可校验、可留痕的工程制品。它适合三类人——做桌面客户端安装包的、做私有化交付的、以及被要求"证明你展示过"的发布工程师。下面按拆包顺序讲:结构、抽取、集成、踩坑、验收。

2. 拆开看:eula 包里到底有什么、按什么规则组织

拿到一个只有文本的资源包,最容易犯的错是先读正文。正文是给用户看的,工程信息全在结构里。先看清目录职责,再确认元数据字段能不能撑住后面所有自动化,最后再决定要不要用这份东西替换掉你手里那个license.txt。

2.1 目录结构与文件职责

资源包的组织思路是"源文本与产物分离、内容与元数据分离"。源文本按语言分目录存放,元数据单独一个 YAML,工具脚本只读不写源文本,所有生成物统一落到dist/。这套划分的好处是后面接 CI 时边界清晰:改文本的人只动texts/,改流程的人只动tools/,两边不会互相覆盖。

路径职责谁改是否进版本库
texts/<lang>/eula.md各语言源文本,带条款编号内容维护者是
meta/eula.yaml版本、语言清单、指纹、展示策略发布工程师是
tools/extract.py条款切分,输出结构化 JSON工具维护者是
tools/fingerprint.py规范化后计算哈希并回写元数据工具维护者是
tools/render.py生成安装器可读的纯文本 / RTF工具维护者是
dist/渲染产物、留痕模板构建机否,产物目录

我一般会把dist/直接写进.gitignore。原因很实际:产物带时间戳和指纹,一旦提交进来,每次构建都会产生无意义的 diff,review 的时候真正的文本改动会被淹没。而且产物是可以从源文本完全重建的,没有保存必要。

还有一个小约定值得抄走:texts/下的语言目录名必须和 BCP 47 的写法对齐,比如zh-CN、en-US,不要出现cn、zh_CN、chinese这类混写。安装器、客户端、留痕表三处都要用这个字符串做键,命名一乱,后面回退逻辑就得写一堆别名映射,血泪经验。

2.2 元数据 schema:撑住后续自动化的八个字段

元数据是整个资源包的"控制面"。字段设计的原则是:凡是被脚本读写两次以上的信息,都必须落到 YAML 里,不允许散落在脚本常量里。下面这套字段是够用的最小集,字段名可以直接照抄。

# meta/eula.yaml id: eula-main # 协议标识,多协议场景下用来区分,不要用中文 version: "3.2" # 面向用户的版本号,出现在标题与留痕里 revision: 7 # 同一 version 内的修订号,文本微调时递增 release_date: "2025-03-11" # 发布日,只做工程溯源,不承载其它含义 languages: # 语言清单,顺序即回退优先级 - zh-CN - en-US default_language: zh-CN min_app_version: "1.4.0" # 低于该版本的客户端不得展示本协议 placeholders: # 文本中允许被替换的占位符白名单 - product_name - company_name - contact_mail display: require_scroll: true # 是否必须滚到底才允许勾选 must_accept: true # 是否阻断式(不接受即退出) storage: keep_days: 1095 # 留痕保留天数,由清理任务读取 fingerprint: algo: sha256 value: "" # 由 fingerprint.py 回写,禁止手填

version和revision拆成两个字段是这套 schema 里最值得保留的设计。很多团队只有一个版本号,结果文本改了一个错别字就得升大版本,用户侧看到"协议版本 3.3"以为条款变了,实际只是标点。拆开之后,version表示条款实质变化,revision表示文本修订,留痕表里两个都存,出问题能精确定位到某一次构建。

min_app_version容易被忽略,但它是版本错配的后悔药。老客户端内置的是 2.x 版协议,服务端下发 3.x 的文本,如果客户端不判断直接渲染,就会出现"标题写 3.2、条款还是旧的"这种翻车现场。

placeholders白名单看着多余,实际是防御性设计。文本里出现${company_name}但没在安装器里注入,用户会看到一串原始占位符;反过来,如果允许任意变量替换,文本作者随手写个${path}就可能被打包脚本误替换成构建机路径。白名单配合校验脚本,两头都堵住。

2.3 为什么不用一个纯文本文件了事

最直接的替代方案是:一个license.txt扔进安装器,结束。这套做法的成本优势很明显,但它在三个地方会立刻崩掉。

第一是定位。审计方问"3.2 版第 4 条讲的是什么",纯文本没有稳定的条款标识,只能靠"从头数第几段"。而资源包里的源文本用 Markdown 标题做条款编号,抽取脚本能直接产出{"clause": "4.2", "text": "..."}这样的结构,任何一次查询都能落到确定的锚点。

第二是比对。纯文本改了之后,人眼 diff 只能看出"这段变了",看不出"这段变了是否影响用户已同意的范围"。资源包的做法是按条款生成子指纹,条款级指纹不变的部分,就不需要重新征求同意,这条规则能显著减少发版时的弹窗打扰。

第三是多语言。纯文本方案里,多语言通常变成license_zh.txt、license_en.txt一堆散文件加一段 if-else。资源包用languages列表加回退优先级,缺哪个语言就往下走,逻辑收敛在一处。

代价也有:多了一层抽取和渲染,构建时间会多一点,文本维护者要遵守编号规范。我的判断是,只要你的产品有两个以上语言、或者有私有化交付场景,这笔投入就是值的;纯内部工具、单语言、不对外分发,那确实没必要上这套。

3. 文本抽取与结构化:把 eula 变成可查询的数据

源文本进库之后,第一件要做的不是渲染,是抽取。抽取的意义在于把"给人读的段落"转成"给程序判断的条款单元",后面的版本比对、同意范围计算、留痕明细全依赖这一步的输出。

3.1 条款切分脚本与编号规范

切分规则必须和文本写法强绑定,否则脚本会脆。约定是:一级条款用## 4.,二级条款用### 4.2,正文段落跟在标题后面,遇到下一个标题就收口。这个规范足够简单,写作者不容易写错,正则也好写。

# tools/extract.py import json import re import sys from pathlib import Path import yaml # 一级:## 4. 标题 ;二级:### 4.1 标题 CLAUSE_RE = re.compile(r"^(#{2,3})\s+(\d+(?:\.\d+)?)\.?\s+(.*)$") def split_clauses(md_text: str): """按标题切分条款,返回 [{clause, title, level, body}]""" clauses, cur = [], None for raw in md_text.splitlines(): line = raw.rstrip() m = CLAUSE_RE.match(line) if m: if cur: clauses.append(cur) hashes, no, title = m.groups() cur = { "clause": no, # 条款编号,如 "4" / "4.2" "level": len(hashes) - 1, # 1=一级,2=二级 "title": title.strip(), "body": [], } elif cur is not None: cur["body"].append(line) if cur: clauses.append(cur) for c in clauses: # 段落合并:连续空行折叠为一个,避免指纹被空行数量影响 text = "\n".join(c["body"]).strip() c["text"] = re.sub(r"\n{2,}", "\n\n", text) del c["body"] return clauses def main(lang: str): root = Path(__file__).resolve().parent.parent meta = yaml.safe_load((root / "meta/eula.yaml").read_text(encoding="utf-8")) src = root / "texts" / lang / "eula.md" clauses = split_clauses(src.read_text(encoding="utf-8")) out = { "id": meta["id"], "version": meta["version"], "revision": meta["revision"], "language": lang, "count": len(clauses), "clauses": clauses, } dst = root / "dist" / f"eula.{lang}.json" dst.parent.mkdir(parents=True, exist_ok=True) dst.write_text(json.dumps(out, ensure_ascii=False, indent=2), encoding="utf-8") print(f"[extract] {lang}: {len(clauses)} clauses -> {dst}") if __name__ == "__main__": main(sys.argv[1] if len(sys.argv) > 1 else "zh-CN")

脚本逻辑有三处值得说明。CLAUSE_RE用#{2,3}同时匹配两级标题,靠捕获组的井号数量反推层级,避免写两条正则后面对不上。段落合并时把连续空行折叠成两个换行,是为了让指纹只对内容敏感、对排版不敏感——否则编辑在段落间多敲一个回车,整份协议的指纹就变了,留痕表里会凭空多出一批"文本已变更"的记录。输出落在dist/并按语言分文件,是为了让渲染脚本和安装器脚本各取所需,不用重复解析 Markdown。

调用方式是python tools/extract.py zh-CN,参数就是语言目录名,必须和meta/eula.yaml的languages里某一项完全一致,脚本不做大小写容错。这个严格是刻意的:容错会导致构建机上能过、本地过不了,排查起来很烦。

写文本的人要守的规矩只有三条:编号连续不跳号;标题行只写编号和标题,不写正文;条款正文里不要出现以##开头的行。第三条最容易被违反,有人在条款里贴了一段 Markdown 示例,直接把切分打乱,抽出来的条款数会莫名其妙变多。

3.2 指纹计算:整份指纹与条款级指纹

指纹有两个粒度。整份指纹用于判断"这份文本是否等同于上次发布的那份",条款级指纹用于判断"用户上次同意过的条款里,哪些变了"。两个都要,缺一不可。

# tools/fingerprint.py import hashlib import json import unicodedata from pathlib import Path import yaml ROOT = Path(__file__).resolve().parent.parent def normalize(text: str) -> str: """归一化:去 BOM、统一换行、去行尾空白、压缩连续空格""" text = text.lstrip("\ufeff") text = text.replace("\r\n", "\n").replace("\r", "\n") text = "\n".join(line.rstrip() for line in text.split("\n")) text = unicodedata.normalize("NFKC", text) # 全角/半角、兼容字符归一 return text.strip() def sha256(text: str) -> str: return hashlib.sha256(text.encode("utf-8")).hexdigest() def main(): meta_path = ROOT / "meta/eula.yaml" meta = yaml.safe_load(meta_path.read_text(encoding="utf-8")) per_lang = {} for lang in meta["languages"]: raw = (ROOT / "texts" / lang / "eula.md").read_text(encoding="utf-8") norm = normalize(raw) clause_hashes = {} data = json.loads((ROOT / "dist" / f"eula.{lang}.json").read_text(encoding="utf-8")) for c in data["clauses"]: clause_hashes[c["clause"]] = sha256(normalize(c["title"] + "\n" + c["text"])) per_lang[lang] = {"doc": sha256(norm), "clauses": clause_hashes} # 整份指纹取默认语言,条款指纹按语言分别保存 meta["fingerprint"]["value"] = per_lang[meta["default_language"]]["doc"] meta_path.write_text( yaml.safe_dump(meta, allow_unicode=True, sort_keys=False), encoding="utf-8" ) (ROOT / "dist" / "fingerprint.json").write_text( json.dumps(per_lang, ensure_ascii=False, indent=2), encoding="utf-8" ) print("[fingerprint] doc =", meta["fingerprint"]["value"][:16], "...") # 与上一版比对,输出变更条款清单,供发版脚本判断是否需要重新征得同意 prev = ROOT / "dist" / "fingerprint.prev.json" if prev.exists(): old = json.loads(prev.read_text(encoding="utf-8")) for lang in meta["languages"]: changed = [ k for k, v in per_lang[lang]["clauses"].items() if old.get(lang, {}).get("clauses", {}).get(k) != v ] if changed: print(f"[fingerprint] {lang} changed clauses: {sorted(changed)}") if __name__ == "__main__": main()

normalize里四个动作对应四类真实翻车。去 BOM 是因为 Windows 编辑器保存 UTF-8 时常带 BOM,Linux 构建机读到的首字符不同,同一条款算出两个哈希。统一换行是因为 Git 的core.autocrlf在不同机器上配置不同,同一份文件在两台机器上指纹不一致。去行尾空白是防止编辑器自动 trim。NFKC 归一化处理全角标点与兼容字符,中文文本里全角逗号和半角逗号混用极其常见,不做归一化,指纹每天都在变。

clause_hashes用title + "\n" + text拼起来算,是为了让"只改标题不改正文"也算变更。条款编号本身不参与哈希,这样把一个条款从 4.2 挪到 4.3 不会被判定为内容变更。

fingerprint.prev.json的存在是给发版脚本用的:变更条款清单为空,说明只是排版动了,用户不需要重新同意;清单非空,就得走阻断式重新确认。这个文件由构建流程负责在每次成功后重命名,别手工维护。

3.3 多语言对齐与回退策略

多语言的坑不在翻译,在对齐。texts/zh-CN/有 24 条,texts/en-US/只有 23 条,抽取脚本各自跑完都成功,但条款级指纹对不上,同意范围计算就会错位。

校验放在抽取之后立刻做,比对语言间的条款编号集合是否完全一致:

# tools/check_align.py import json from pathlib import Path import yaml ROOT = Path(__file__).resolve().parent.parent meta = yaml.safe_load((ROOT / "meta/eula.yaml").read_text(encoding="utf-8")) sets = {} for lang in meta["languages"]: data = json.loads((ROOT / "dist" / f"eula.{lang}.json").read_text(encoding="utf-8")) sets[lang] = [c["clause"] for c in data["clauses"]] base_lang = meta["default_language"] base = sets[base_lang] ok = True for lang, nums in sets.items(): missing = [n for n in base if n not in nums] extra = [n for n in nums if n not in base] if missing or extra: ok = False print(f"[align] {lang}: missing={missing} extra={extra}") print("[align] OK" if ok else "[align] FAILED") raise SystemExit(0 if ok else 1)

回退策略写在languages的顺序里,取第一个存在的语言,取不到就用default_language,再取不到直接构建失败——不允许静默降级成空字符串。静默降级的后果是安装器弹出一个空白协议页,"同意"按钮还亮着,用户点了也能过,这种翻车最难受,因为它在测试环境里往往复现不出来。

关于机器翻译:可以用于内部预览,但不能直接进texts/。指纹机制会把机器翻译稿和人工稿一视同仁,一旦发了版,后面就没法区分哪份是正式文本。常见做法是加一个texts/<lang>/eula.draft.md的旁路目录,抽取脚本默认不读,只有显式传--include-draft才处理。

4. 装机向导落地:同意流程的三处集成点

文本准备好之后,真正的交付在三个地方:安装器、客户端首启、留痕存储。三处的展示口径必须一致,否则用户会看到两份措辞不同的协议,信任度直接归零。

4.1 安装器展示:渲染产物与页面挂载

安装器普遍对 Markdown 不友好,所以要先渲染成纯文本或 RTF。渲染脚本的职责只有两个:把占位符替换成实际值,把 Markdown 标记压平成安装器能认的格式。

# tools/render.py import json import sys from pathlib import Path import yaml ROOT = Path(__file__).resolve().parent.parent def render(lang: str, values: dict): meta = yaml.safe_load((ROOT / "meta/eula.yaml").read_text(encoding="utf-8")) data = json.loads((ROOT / "dist" / f"eula.{lang}.json").read_text(encoding="utf-8")) allowed = set(meta["placeholders"]) unknown = set(values) - allowed if unknown: raise SystemExit(f"placeholder not in whitelist: {unknown}") lines = [f"{meta['id']} version {meta['version']}.{meta['revision']}", ""] for c in data["clauses"]: indent = "" if c["level"] == 1 else " " lines.append(f"{indent}{c['clause']}. {c['title']}") body = c["text"] for k, v in values.items(): body = body.replace("${" + k + "}", str(v)) lines.append(body) lines.append("") text = "\n".join(lines) # 校验:渲染后不允许残留占位符 if "${" in text: raise SystemExit("unresolved placeholder remains after render") out = ROOT / "dist" / f"eula.{lang}.txt" out.write_text(text, encoding="utf-8", newline="\r\n") # 安装器按 CRLF 更稳 print(f"[render] {lang} -> {out} ({len(text)} chars)") if __name__ == "__main__": render(sys.argv[1], { "product_name": sys.argv[2] if len(sys.argv) > 2 else "某客户端", "company_name": "某公司", "contact_mail": "support@example.invalid", })

newline="\r\n"是刻意写的。安装器的许可页控件大多是原生 Windows 控件,纯 LF 的文件在部分环境里会显示成一整行,用户滚不动,require_scroll的判定永远为真,谁都没法点下一步。这个坑我在两个项目上各踩过一次。

unresolved placeholder那条硬校验别删。文本里出现${contact_mail}而值没传,安装器上就会显示原始占位符,属于一眼可见的低级事故,但偏偏很容易漏,因为本地默认值往往填得挺全。

NSIS 侧挂载许可页只要两行,配合自定义的离开回调记录同意动作:

!define MUI_PAGE_CUSTOMFUNCTION_LEAVE OnLicenseLeave !insertmacro MUI_PAGE_LICENSE "dist\eula.zh-CN.txt" Function OnLicenseLeave ; $0 用于拼接留痕行,写入安装目录下的 consent.log FileOpen $0 "$INSTDIR\consent.log" a FileWrite $0 "lang=zh-CN$\r$\n" FileWrite $0 "version=3.2.7$\r$\n" FileWrite $0 "ts=$%DATE% $%TIME%$\r$\n" FileClose $0 FunctionEnd

MUI_PAGE_CUSTOMFUNCTION_LEAVE在用户点"下一步"离开许可页时触发,此时"同意"单选已被选中,是写入留痕的正确时机——放进.onInstSuccess就晚了,用户可能中途取消,留痕会漏。写入用追加模式a,避免重装时覆盖历史记录。路径用$INSTDIR是因为它此时已确定,别写$TEMP,临时目录会被清理,留痕等于没留。

4.2 客户端首启弹窗:版本比对与阻断

安装器只管"安装那一刻",客户端还得管"运行期间协议升级"。首启逻辑的判定顺序是:读服务端或本地配置的最新版本 → 和用户已同意的版本比对 → 不一致则弹窗 → 必须滚到底才解锁同意按钮 → 不接受则退出。

比对用(version, revision)元组,不要用字符串比较。字符串比较下"3.10" < "3.9"会成立,这个坑每年都有人踩。用 Python 写个比对函数就三行:

def is_newer(local: tuple, remote: tuple) -> bool: """local/remote 形如 (3, 2, 7);返回 remote 是否比 local 新""" return tuple(int(x) for x in remote) > tuple(int(x) for x in local)

require_scroll的判定不要依赖滚动条位置百分比,那个值在字体缩放、高 DPI 下不可靠。稳妥做法是给控件挂一个"滚动到末尾"事件,事件触发后置一个布尔位。高 DPI 场景下滚动条的nPos最大值可能小于nMax,所有基于位置的计算都会偏。

弹窗关闭后无论同意与否都要落一条记录,"拒绝"也是记录,因为拒绝次数多了说明文本措辞有问题,这是个有用的产品信号。

4.3 留痕存储:表结构与保留策略

留痕表建议单独一个 SQLite 文件,不要塞进业务库。理由是保留期不同,业务库有备份和清理策略,协议留痕的保留期由storage.keep_days控制,混在一起会让清理逻辑互相打架。

CREATE TABLE IF NOT EXISTS consent ( id INTEGER PRIMARY KEY AUTOINCREMENT, eula_id TEXT NOT NULL, version TEXT NOT NULL, revision INTEGER NOT NULL, lang TEXT NOT NULL, fingerprint TEXT NOT NULL, -- 当时的整份指纹 action TEXT NOT NULL, -- accept / decline channel TEXT NOT NULL, -- installer / first_run / upgrade machine_hash TEXT NOT NULL, -- 机器标识的哈希,不存原始值 created_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_consent_ver ON consent(eula_id, version, revision); CREATE INDEX IF NOT EXISTS idx_consent_ts ON consent(created_at);

fingerprint必须存。只存版本号的话,同一版本号下的多次修订就无法区分,事后无法证明"用户同意的到底是哪份文本"。这个字段是整个留痕表的价值所在。

machine_hash存哈希不存原始机器码,是出于数据最小化考虑。同一台机器多次同意会产生多行,这是正常的,别做去重合并——重装、换语言、协议升级都应该各留一条。

保留策略由清理任务读取keep_days执行,删除前先做一次归档导出(CSV 或 JSON Lines),别直接DELETE。见过直接在安装器里跑DELETE ... WHERE created_at < ...的写法,一旦日期格式解析出问题,整表清空,那真是没有后悔药。

5. 避坑与排查:五个能把发版卡住的真实问题

下面五条都是我在拆这类资源包时真遇到过的,按"现象 → 原因 → 解决"记。前三个偏文本与编码,后两个偏集成与留痕。

5.1 文本层的三个坑

坑一:同样的文件,两台机器指纹不一致。现象是构建机上跑fingerprint.py得到的哈希和本地不同,CI 因此判定文本变更,触发了一次不必要的重新同意。 原因有两层:一是 Git 在 Windows 上默认core.autocrlf=true,检出时把 LF 换成了 CRLF;二是某个编辑器在保存时加了 BOM。两者都会改变字节流。 解决办法是把归一化前置到所有读文件的地方,normalize()里那四步一个都不能省;同时在仓库根加.gitattributes,对texts/**声明-text,让 Git 不要做换行转换。做完这两步,同一份内容在任何机器上算出的指纹都一致。

坑二:条款数突然变多。现象是抽取脚本输出的count从 24 变成 27,check_align.py报某几个条款"缺失"。 原因是有编辑在条款正文里贴了一段以##开头的示例文本,切分正则把它当成了新条款,把原来的一条切成了三段。 解决办法是短期在编辑规范里加一条"正文中禁止以井号开头",长期在抽取脚本里加个断言:相邻两个条款编号必须连续,跳号就报错退出。我一般还会让extract.py输出一条告警,列出所有一级条款编号,肉眼扫一遍就知道有没有异常。

坑三:全角半角混用导致频繁误报变更。现象是没人改内容,但fingerprint.json里的条款指纹每隔几天就变一次。 原因是不同人用不同输入法,中文逗号和英文逗号、全角括号和半角括号混着来,甚至同一个词有时候是全角空格分隔。 解决办法就是unicodedata.normalize("NFKC", text),它会把全角 ASCII 统一成半角、把兼容字符归一。注意 NFKC 会改动部分特殊字符的形态,如果文本里有需要保持原样的符号,要在归一化前做白名单保护。

5.2 集成与留痕的两个坑

坑四:静默安装直接跳过了许可页。现象是批量部署的机器上,留痕表一条记录都没有,验收方认为"未展示协议"。 原因是安装器用了/S静默模式,MUI_PAGE_LICENSE页面被跳过,OnLicenseLeave根本不触发。 解决办法是在静默分支里显式补一条记录,把channel标成installer_silent,并在部署文档里写明"静默安装视为已接受,由部署方承担责任"。代码上不要试图在静默模式下弹窗,那会把自动化部署卡死。

坑五:升级后协议变了但用户没被重新询问。现象是服务端下发了 3.3 版文本,客户端启动后界面显示的仍是 3.2 版,用户也不会被要求重新同意。 原因有两个常见版本:一是比对用了字符串比较,"3.10" > "3.9"判断为假;二是客户端把已同意版本缓存在注册表里,升级安装包时把整个注册表键覆盖了,缓存被重置成默认值 0.0,反而变成"未同意",但 UI 又读的是安装目录里的旧文本。 解决办法是比对一律走整型元组,缓存键带上eula_id前缀避免被同名键覆盖;升级流程里加一步"读旧缓存 → 迁移到新键 → 删旧键",别直接删。

6. 验收与进阶:把 eula 纳入 CI 门禁

到这一步,文本、脚本、安装器集成都有了,剩下的问题是怎么保证下次发版不退化。我的习惯是让构建流水线在打包之前先跑一遍校验,任何一个检查失败就中断构建,不允许"先发出去再补"。

校验脚本写成纯 bash,不依赖额外运行时,放在tools/ci_check.sh:

#!/usr/bin/env bash set -euo pipefail LANG_DEFAULT="zh-CN" echo "[ci] 1/6 抽取条款" python tools/extract.py "$LANG_DEFAULT" echo "[ci] 2/6 多语言对齐" python tools/check_align.py echo "[ci] 3/6 计算指纹" python tools/fingerprint.py echo "[ci] 4/6 渲染安装器文本" python tools/render.py "$LANG_DEFAULT" "某客户端" echo "[ci] 5/6 检查渲染产物无占位符残留" if grep -q '\${' dist/eula."$LANG_DEFAULT".txt; then echo "[ci] FAIL: unresolved placeholder"; exit 1 fi echo "[ci] 6/6 检查产物非空且条款数达标" test -s dist/eula."$LANG_DEFAULT".json python - <<'PY' import json, sys data = json.load(open("dist/eula.zh-CN.json", encoding="utf-8")) if data["count"] < 10: print("[ci] FAIL: clause count too low:", data["count"]); sys.exit(1) print("[ci] clause count =", data["count"]) PY echo "[ci] ALL PASSED"

set -euo pipefail是必须的,少了pipefail,中间某个 python 命令失败会被管道吞掉,脚本照样输出 PASSED。第 5 步用grep -q '\${'做兜底检查,因为渲染脚本内部的校验万一被改坏了,这一步能独立拦住。第 6 步的条款数下限用 10 是个经验值——不是精确断言,而是防止"抽取脚本读错了文件"这种低级错误把空结果当成成功。这个下限每次协议大改时都要复核一遍,别让它变成一个永远为真的摆设。

配合 CI,还需要一份版本升级的回归清单。这份清单我通常会写成表格贴在发布流程文档里,每次动协议文本就照着走一遍:

触发条件必须执行的动作判定依据
仅修正错别字递增revision,不弹窗条款级指纹全部未变
条款实质变化递增version,阻断式重新同意至少一个条款指纹变化
新增语言补languages列表并跑对齐检查check_align.py通过
客户端大版本升级复核min_app_version老客户端不展示新文本
留痕清理策略调整先导出归档再删归档文件行数与表内一致

有一点值得单独说:min_app_version这个字段在真实发版里经常被忘掉,结果服务端配了新版协议,一批还在用旧客户端的机器启动后取到新文本,UI 又不会渲染新增的条款编号,用户看到的是缺号的协议,观感极差。我后来把这个字段接进了客户端的启动自检:本地版本低于min_app_version就不拉取远端文本,只用内置版本,并把这条判定写进启动日志。

最后提一个进阶玩法——把条款级指纹接进差异通知。发版前跑一次比对,如果变更条款清单非空,就自动把变更的条款编号和标题整理成一段摘要,附在发布单里。审核的人不用打开完整协议,只看这几条就能判断要不要重新走一遍确认流程。这一步做完,协议文本才算真正从"一段话"变成了"一个有状态、可追溯的工程对象"。

从那以后我每次接新的安装包项目,第一件事就是把texts/和meta/两棵树立起来,先跑通extract → fingerprint → render这条链,再动安装器脚本——顺序反过来,后面必然要返工。希望帮到你。

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

返回列表