大学排名设计
大学排名的计数、排序、学校名单、学校邮箱认证、接口、数据模型、防刷和隐私设计。
状态:后端已实现(2026-10-21):学校名单、学校邮箱认证、公开排名、冻结和运营方法。网站页面和 App 入口尚未实现。实现与原设计的差异见实现状态。
本文档设计「大学排名」及相关功能。排名单位是学校,指标是学校的「计入人数」。会员必须同时持有有效的会员卡和有效的学校认证,才计入人数。排名按计入人数从多到少排列。人数相同时,先凑齐当前人数的学校在前。公开页面只显示每所学校的聚合数字,不显示会员名单。学校名单只收录 Hipo university-domains-list 中的学校。
这个功能依赖两项上线前置条件:真实邮件投递和请求限流。两项目前都没有完成,所以 bizserver/src/main.rs 在生产环境拒绝启动。
面向用户的规则说明见用户指南的大学排名。本文档是开发者设计,包含接口和数据模型。本文档不列出限流参数、异常判定阈值和密钥配置,以 bizserver/ 的代码为准。修改用户能看到的规则时,必须同时修改这两份文档。
范围
本文档包括以下内容:
- 学校名单与邮箱域名表
- 学校邮箱认证
- 计数规则与排序规则
- 排名页、本校页、认证页和「我的学校」
- 接口、数据模型、防刷与运营
- 隐私、守则文案与实施顺序
以下内容不在本文档范围内:
- 论坛:本文档只规定一条:本校版的访问权限以学校认证为准。
- 个人排名:系统不按体重、照片、训练量或打卡给个人排名。
- App 入口:社区提案的第一期不做 App。接口设计允许 iOS 和 Android 以后接入。
实现状态
后端代码位于 bizserver/src/domain/organization/、bizserver/src/rpc/organization.rs、bizserver/src/rpc/affiliation.rs 和 bizserver/src/rpc/organization_admin.rs。接口说明见 ConnectRPC 业务接口,名单数据见 bizserver/data/schools/README.md。
接口和数据表使用通用的「组织」模型,学校是 SCHOOL 类型的组织。以后加入公司或俱乐部等类型时,可以复用邮箱域名验证、计数和排名。每种类型有自己的名单、规则参数和排名,不同类型不混排。一个账号在每种类型下最多有一个组织。本文档的规则只针对学校。
| 部分 | 状态 |
|---|---|
学校名单、导入命令 import-schools | 已实现 |
boji.v1.OrganizationService:组织查询与公开排名 | 已实现 |
boji.v1.AffiliationService:学校邮箱认证、续期、更换和退出 | 已实现 |
| 按账号和学校邮箱限制验证码发送 | 已实现 |
boji.admin.v1.OrganizationService:认证查询、撤销、冻结和解冻 | 已实现 |
| 按学校和日期统计新认证数、查看域名排除项 | 未实现 |
| 认证事件的定期清理 | 未实现。bizserver 没有后台任务 |
| 网站页面、App 入口、名单申请 | 未实现 |
| 隐私政策和守则文案的修改 | 未实现 |
实现与原设计有以下差异:
| 原设计 | 实现 |
|---|---|
资源 School,单例 users/{user}/schoolAffiliation | 资源 Organization 带 type 字段。学校认证是 users/{user}/affiliations/school |
修正文件 overrides.yaml,ID 对应表 ids.yaml | 修正文件 overrides.json。没有 ID 对应表:数据库按学校键保存每所学校的 UUID |
| 上游删除的学校标记为待审核 | 学校只有有效和停用两种状态。上游删除的学校停用,已有认证在到期前继续计入 |
缺少会员卡时返回 MEMBERSHIP_NOT_FOUND | 返回 failed_precondition MEMBERSHIP_REQUIRED。MEMBERSHIP_NOT_FOUND 表示指定的会员卡不存在,含义不同 |
| 没有读取单个验证码认证的方法 | 增加 GetAffiliationEmailVerification,满足 AIP 对每个资源提供 Get 方法的要求 |
没有规定 ServerConfig 的排名开关何时为 true | 字段名为 organization_ranking_enabled。服务端设置了 AFFILIATION_EMAIL_HMAC_KEY 时为 true |
运营方法 ListSchoolAffiliations 按学校和时间查看认证 | ListAffiliations 按组织筛选,按最近一次验证时间降序排列,只列出有效和已过期的认证 |
| 冻结记录保存原因 | 冻结表保存操作者和冻结时间,原因只写入审计事件 |
学校键的格式为 <alpha_two_code>:<name>,例如 CN:Xi'an Jiaotong-Liverpool University。学校 ID 只保存在数据库中,所以新建的数据库会得到不同的学校 ID。上游给学校改名后,导入会创建新学校,并停用旧学校。
术语
| 术语 | 定义 |
|---|---|
| 学校 | 学校名单中的一条记录,例如「西交利物浦大学」。用户不能自己填写校名 |
| 学校认证 | 一个账号与一所学校的关联。用户通过学校邮箱验证码建立学校认证 |
| 计入 | 一个会员满足计数规则,在本校人数中算作 1 人 |
| 计入时间 | 学校认证本次连续计入的开始时间。认证有效期内续期,计入时间不变 |
| 计入人数 | 一所学校当前计入的会员数 |
| 凑齐时间 | 一所学校当前计入的会员中,最晚的计入时间。它表示学校在什么时间凑齐了当前人数 |
| 本周新增 | 本周内开始计入、并且现在仍然计入的会员数 |
| 冻结 | 运营人员暂停一所学校的公开计数。冻结期间,公开页面显示冻结时的数字 |
| 组织 | 接口中的通用实体 Organization。学校是 SCHOOL 类型的组织 |
| 归属 | 接口中的 Affiliation,即一个账号与一种类型下一个组织的关联。学校认证是 SCHOOL 类型的归属 |
计数规则
一个账号必须同时满足以下条件,才计入学校人数:
- 账号的会员卡状态为
active,并且会员卡没有过期。暂停的会员卡不计入。 - 账号没有处于封禁期。服务端读取
user.banned和user.ban_expires。 - 账号的学校认证状态为有效,并且当前时间早于认证的到期时间。
服务端必须在一个领域函数中实现计数规则。排名、本校页和「我的学校」都调用这个函数,不各自判断。
每个账号最多关联一所学校。每个学校邮箱最多对一个账号有效。
邮箱域名不能区分学生、教职工和校友。所以「计入人数」的含义是「持有本校邮箱的会员数」,不是「在校学生数」。页面文案必须使用前一种含义。域名表应该排除明确的校友域名,见域名匹配。
学校认证是可选步骤。没有学校邮箱的人仍然可以领取会员卡,只是不计入任何学校。这条规则保留了守则「人人都有位置」。
排序规则
排序键
排名按以下三个键排序:
- 计入人数,从多到少。
- 凑齐时间,从早到晚。
- 学校 ID,从小到大。前两个键都相同时,这个键保证顺序稳定。
名次从 1 开始连续编号,不出现并列名次。社区提案写的是「人数相同,先到的在前」。本设计把「先到」定义为凑齐时间更早。
凑齐时间只由当前计入的会员算出,不需要保存历史。例如,A 校两位会员的计入时间是 10 月 1 日和 10 月 3 日,B 校两位会员的计入时间是 10 月 2 日和 10 月 4 日。两校都是 2 人。A 校的凑齐时间是 10 月 3 日,早于 B 校的 10 月 4 日,所以 A 校在前。
一位会员退出后,凑齐时间可能提前。例如 A 校 10 月 3 日计入的会员退出后,A 校剩 1 人,凑齐时间变为 10 月 1 日。这个变化符合定义,页面不需要单独说明。
本周新增
一周从北京时间(UTC+8)周一 0:00 开始。全站使用同一个时区,因为社区接龙也按北京时间统计。
「本周新增」统计本周开始计入、并且现在仍然计入的会员。续期不改变计入时间,所以续期不算新增。认证过期后重新认证的会员,计入时间重新开始,所以算作本周新增。
公开门槛
计入人数少于 3 人的学校不进入公开排名。人数为 1 或 2 时,本校页显示「不足 3 人」。人数为 0 时,本校页显示「还没有人:你是第一个」。
设置门槛的原因是:人数为 1 或 2 时,公开数字加上论坛昵称或线下信息,可能指认出具体的人。门槛是服务端常数,值为 3。
更新频率
服务端在读取时计算排名,并在内存中缓存 60 秒。每个响应都带有计算时间,页面显示「截至 18:40(北京时间)」这样的说明。
这个方案不需要后台任务。bizserver 目前没有定时任务,所以认证过期也在查询时按 expires_at 判断。服务部署为多个实例后,应该改为把排名快照写入数据库。
冻结
运营人员可以冻结一所学校的公开计数,例如在调查异常增长时。冻结期间,公开排名使用冻结时的计入人数和凑齐时间排序,显示冻结时的本周新增,并标注「核查中」。真实计数照常进行。解除冻结后,页面恢复显示真实计数。
学校名单与域名表
收录规则
名单只收录 Hipo university-domains-list(下文简称 Hipo)中的学校。Hipo 没有收录的学校暂时不收录,以后通过名单申请添加。这条规则于 2026-10-21 确定。
Hipo 使用 MIT 许可证。2026-10-21 下载的数据对应上游提交 603e10f51b67(2026-09-22),共 10,268 条记录,其中中国大陆 399 条。
多校联合组和学生社团不是学校,所以不会出现在名单中。它们的成员分别计入各自的学校。例如「UQ Boji Club」的成员计入昆士兰大学。
对接龙表的影响
社区接龙表共 49 组。作者在 2026-10-21 逐组核对了 Hipo,结果如下:
- 39 组的学校在 Hipo 中。
- 8 组的学校不在 Hipo 中,开榜时不能认证。
- 福建农林大学(12 人)在 Hipo 中只有一条旧名记录 Fujian Agricultural University,域名是
fjau.edu.cn,没有fafu.edu.cn。按照本地修正规则,维护者可以给这条记录补上中文名和新域名。这条记录是否就是福建农林大学,未验证。 - 「上海海湾三校联合」(90 人)没有列出成员学校,无法核对。
不在 Hipo 中的 8 所学校全部位于中国大陆:
| 学校 | 接龙人数 |
|---|---|
| 北师港浸大(UIC) | 1,016 |
| 温州肯恩大学 | 330 |
| 温州商学院 | 130 |
| 中原科技学院 | 128 |
| 河南科技大学 | 120 |
| 长安大学 | 118 |
| 浙江水利水电学院 | 116 |
| 宁波卫校 | 18 |
这 8 组共 1,976 人,占接龙总人数 12,165 人的 16.2%。北师港浸大在接龙表中排第 2 名。开榜前,应该向社区说明这些学校暂时不能认证,并说明以后的申请方式。
本地修正
维护者可以在本地修正 Hipo 已经收录的学校,不需要等上游合并。本地修正不能新增学校。可以修正的内容如下:
- 中文名:Hipo 没有中文名字段,所以中文名由维护者填写。维护者可以参考 ROR 的中文标签,ROR 数据使用 CC0 1.0。还没有中文名的学校,在中文界面显示英文名。
- 补充域名:例如学生邮箱使用的子域名或新域名。抽样中,海南大学、大连医科大学和杜伦大学在 Hipo 中的域名与 ROR 不一致,上线前必须核对。
- 排除域名:校友转发域名等不代表在校身份的域名。
- 合并重复记录:上海交通大学在 Hipo 中有两条记录,域名分别是
sjtu.edu.cn和en.sjtu.edu.cn。如果不合并,同一所学校会在排名中出现两次。Hipo 中还有 20 组同一国家的同名记录,以及 4 个由多条记录共用的域名,维护者必须逐一检查。
维护者应该把本地修正同时提交给 Hipo 上游。上游合并后,下一次更新快照时,本地修正可以删除。
上线前,维护者必须核对接龙表中已收录学校的域名,并在每所学校实测验证码能够送达。
数据文件与导入
名单的唯一来源是仓库中的数据文件。运营接口不能修改名单。所有修改都通过 PR 合并,所以 Git 历史就是名单的修改记录。
| 文件 | 内容 |
|---|---|
bizserver/data/schools/hipo.json | Hipo 数据快照,固定在一个上游提交上 |
bizserver/data/schools/hipo.commit | 快照对应的上游提交号 |
bizserver/data/schools/LICENSE-hipo.txt | Hipo 的 MIT 许可声明 |
bizserver/data/schools/overrides.json | 本地修正:中文名、补充域名、排除域名和合并 |
Hipo 的记录没有稳定 ID,只有名称、国家和域名。所以导入工具用学校键 <alpha_two_code>:<name> 识别学校。首次导入一所学校时,导入工具分配 UUID,数据库按学校键保存它。以后的导入沿用这个 UUID。上游改名、删除或拆分的记录,由导入工具在日志中列出,维护者手动处理。
导入命令为 cargo run --locked -- import-schools。导入在一个事务中完成,并遵守以下规则:
- 新记录:创建学校和域名。
- 已有学校:更新名称和域名。
- 上游删除的学校:停用学校,不删除。停用的学校不接受新的认证,已有认证在到期前继续计入。导入工具为每所停用的学校输出一条警告,包含仍在计入的认证数。
- 被移除的域名:已有的学校认证保持到到期日。新的认证不再接受这个域名。
仓库提交 Hipo 快照时,必须保留 MIT 许可声明。根 README.md「许可证」一节的排除列表已经列出这份数据。
域名匹配
服务端按以下步骤从邮箱找到学校:
- 去掉首尾空白,把整个地址转为小写。把国际化域名转为 Punycode。
- 去掉本地部分中
+及其后面的内容。例如[email protected]变为[email protected]。 - 在域名表中按标签边界找最长的后缀。
student.uq.edu.au匹配uq.edu.au,xuq.edu.au不匹配uq.edu.au。 - 如果最长的后缀是排除项,拒绝认证。排除项用于校友转发域名等不代表在校身份的子域名。
- 如果最长的后缀对应多所学校,让用户在候选学校中选择。其他情况下,学校由域名唯一确定。
用户不能先选学校、再用另一所学校的邮箱认证。
名单申请
名单申请通过 GitHub issue 完成。这个机制以后设立,第一期上线时还没有。在第一期中,如果用户的邮箱域名不在名单中,认证页显示「你的学校暂未收录」。
设立以后,申请流程如下:
- 用户在认证页点击「申请添加学校」,打开仓库的 issue 表单
.github/ISSUE_TEMPLATE/school-request.yml。 - 用户选择申请类型:新增学校、补充或修正域名、修正中文名。然后填写学校英文全称、中文名、国家或地区、官网、学生邮箱域名,以及能证明这个域名的学校官方页面链接。
- 维护者核对官方来源。核对通过后,维护者提交 PR 修改
overrides.yaml,并在 PR 中引用 issue 编号。维护者应该同时向 Hipo 上游提交同样的修改。 - PR 合并并部署后,维护者运行导入命令,然后关闭 issue。
Issue 是公开的。表单必须提示:禁止填写完整邮箱地址、学号或真实姓名。表单的示例使用 [email protected] 这样的占位地址。
「新增学校」类申请需要改动收录规则,因为本地修正目前不能新增学校。设立 issue 机制时,overrides.yaml 必须增加「本地新增学校」,并写明审核标准。
没有 GitHub 账号的用户不能提交 issue。部分用户访问 GitHub 可能不稳定。这两个限制见待决事项。
学校邮箱认证
前置条件
用户必须满足以下条件,才能开始认证:
- 已经登录,并且登录邮箱已经验证。
- 持有状态为
active的会员卡。
学校邮箱只用于认证,不能用于登录。学生毕业后,学校邮箱会失效。如果学校邮箱就是登录邮箱,用户会失去账号。
认证流程
- 用户输入学校邮箱。服务端按域名匹配找到候选学校,并检查限流。
- 服务端生成 6 位验证码,发送到学校邮箱。响应包含候选学校、遮盖后的邮箱和过期时间。遮盖后的邮箱形如
a***@student.xjtlu.edu.cn。 - 用户输入验证码。如果候选学校多于一所,用户同时选择学校。
- 服务端校验验证码,然后在同一个事务中完成以下操作:
- 如果另一个账号持有同一个学校邮箱的有效认证,撤销那个认证。
- 为本账号创建、续期或更换学校认证。
- 写入认证事件,并作废验证码。
- 页面显示「你已计入西交利物浦大学」这样的结果,以及到期日期。
服务端在验证码校验通过之后,才检查学校邮箱是否被其他账号使用。所以只有学校邮箱的持有人才能知道这个邮箱是否已被使用。
同一个学校邮箱以最后一次认证为准。一个人丢失旧账号后,可以用新账号重新认证,不需要联系运营人员。这条规则不会增加计入人数,因为同一时间只有一个账号有效。
验证邮件只包含验证码,不包含链接。部分学校的邮件网关会预先打开邮件中的链接做安全扫描,一次性链接可能因此失效(未验证)。
续期、过期、更换和退出
- 续期:用户可以随时用本校的任一邮箱重新认证。到期时间从认证成功时重新计算。如果原认证仍然有效,计入时间不变。
- 过期:到期后,认证不再计入。用户重新认证后,计入时间重新开始。
- 更换学校:用户用另一所学校的邮箱认证,旧认证立即结束。如果距离上次更换学校不足 30 天,服务端拒绝更换。
- 退出学校:用户在「我的学校」中退出。认证立即结束,账号不再计入。
参数
下表是第一期使用的值:
| 参数 | 默认值 |
|---|---|
| 验证码长度 | 6 位数字 |
| 验证码有效期 | 10 分钟 |
| 验证码错误次数上限 | 5 次,超过后验证码作废 |
| 重发间隔 | 60 秒 |
| 认证有效期 | 365 天 |
| 更换学校的冷却期 | 30 天 |
| 公开门槛 | 3 人 |
参数是 bizserver/src/domain/organization.rs 中 policy 模块的常数,不是环境变量。上表的三项是 policy::SCHOOL,只用于学校。验证码的发送上限也定义在这个模块中,所有组织类型共用。
页面
排名页
排名页公开,未登录也可以查看。页面包含以下内容:
- 表格:列为名次、大学、人数、本周新增。
- 计算时间:例如「截至 2026-10-21 18:40(北京时间)」。
- 学校搜索:输入校名后,跳到这所学校的本校页。
- 规则说明:只计入通过学校邮箱认证、并且在有效期内的会员。人数相同时,先凑齐当前人数的学校在前。不足 3 人的学校不公开显示。
- 冻结标注:被冻结的学校显示「核查中」。
排名表必须使用语义化的表格结构,让读屏软件能够读出列名。
本校页
本校页显示学校名称、人数、本周新增和名次。人数不足 3 人时,本校页按公开门槛显示。
未认证的访客看到「用学校邮箱认证」入口。已认证的本校会员看到自己的计入状态。
我的学校
「我的学校」位于账号设置中,显示以下内容:
- 学校名称和认证邮箱的域名,例如
student.xjtlu.edu.cn。页面不显示完整的学校邮箱,因为服务端不保存邮箱原文。 - 计入状态:计入中,或者未计入及原因,例如会员卡已暂停、认证已过期。
- 到期日期和「续期」入口。
- 「退出学校」入口。
认证页
认证页在用户输入邮箱之前说明数据用途。建议的文案如下:
认证后,你会计入本校人数。公开页面只显示每所学校的人数,不显示你的昵称或邮箱。学校邮箱只用于发送验证码,服务端不保存邮箱原文。
接口
接口遵循现有的 AIP 约定,见 ConnectRPC 业务接口。下面的方法已经实现,并通过了 buf lint 和 api-linter。修改 bizserver/proto/boji/v1/ 后,必须在 ios/BojiAPI/ 和 android/rpc/ 中运行 buf generate。
资源
| 资源 | 资源名 | 内容 |
|---|---|---|
Organization | organizations/{organization} | 类型、中文名、英文名、国家或地区代码、可用于认证的邮箱域名 |
OrganizationStanding | organizations/{organization}/standing | 单例:公开状态、名次、人数、本周新增、计算时间、冻结标记 |
Affiliation | users/{user}/affiliations/{affiliation} | 本人一种类型的组织、认证邮箱的域名、状态、计入时间、到期时间 |
AffiliationEmailVerification | users/{user}/affiliations/{affiliation}/emailVerifications/{email_verification} | 一次验证码认证:候选组织、遮盖后的邮箱、过期时间、可重发时间 |
{organization} 和 {email_verification} 是 UUID。{affiliation} 是小写的组织类型,所以学校认证的资源名是 users/{user}/affiliations/school。OrganizationStanding 不公开凑齐时间,因为凑齐时间是某一位会员的认证时间。人数不足公开门槛时,OrganizationStanding 也不返回人数和本周新增。
每个账号在每种类型下都有一个 Affiliation,所以它没有 Create 和 Delete 方法。账号没有这种类型的组织时,状态为 UNAFFILIATED。
公开与本人方法
| 服务 | 方法 | 调用条件 | 用途 |
|---|---|---|---|
boji.v1.OrganizationService | ListOrganizations | 不要求登录 | 按类型、名称、域名或地区查找组织 |
boji.v1.OrganizationService | GetOrganization | 不要求登录 | 读取一个组织 |
boji.v1.OrganizationService | ListOrganizationStandings | 不要求登录 | 按名次分页列出一种类型的公开排名,或读取一个组织的排名 |
boji.v1.OrganizationService | GetOrganizationStanding | 不要求登录 | 读取一个组织的排名信息 |
boji.v1.AffiliationService | GetAffiliation | 已验证邮箱的会话 | 读取本人一种类型的归属 |
boji.v1.AffiliationService | ListAffiliations | 已验证邮箱的会话 | 列出本人每种类型的归属 |
boji.v1.AffiliationService | CreateAffiliationEmailVerification | 已验证邮箱的会话 | 提交学校邮箱,发送验证码 |
boji.v1.AffiliationService | GetAffiliationEmailVerification | 已验证邮箱的会话 | 读取一次验证码认证 |
boji.v1.AffiliationService | ConfirmAffiliationEmailVerification | 已验证邮箱的会话 | 提交验证码,建立、续期或更换学校认证 |
boji.v1.AffiliationService | LeaveOrganization | 已验证邮箱的会话 | 结束本人一种类型的归属,例如退出学校 |
OrganizationService 与 SystemService 一样不要求登录。ListOrganizationStandings 的 parent 为 organizations/- 时按名次分页,filter 必须指定类型,例如 type = SCHOOL。parent 为 organizations/{organization} 时,方法返回这个组织的一条排名(AIP-156 单例的 List 方法)。pageSize 默认为 50,上限为 100。页令牌绑定类型和排名的计算时间。缓存更新后,旧页令牌返回 INVALID_PAGE_TOKEN,客户端从第一页重新读取。
公开方法可以声明为无副作用,让 Connect 客户端使用 GET 请求,CDN 就能缓存响应。bizserver 使用的 ConnectRPC Rust 实现是否支持 GET 请求,未验证。
ServerConfig 增加 organization_ranking_enabled。服务端设置了 AFFILIATION_EMAIL_HMAC_KEY 时,这个值为 true。客户端只在这个值为 true 时显示排名和认证入口。
运营方法
运营方法位于 boji.admin.v1.OrganizationService,沿用会员运营接口的权限和会话要求。读取方法需要 organization.read 权限,变更方法需要 organization.update 权限。每次变更都必须填写原因,变更和审计事件在同一个事务中提交。
运营方法不能修改学校名单和域名表。名单只通过仓库的数据文件修改,见数据文件与导入。
| 方法 | 用途 | 状态 |
|---|---|---|
GetAffiliation | 读取任意账号一种类型的归属 | 已实现 |
ListAffiliations | 按组织列出有效和已过期的认证,不返回邮箱 HMAC | 已实现 |
RevokeAffiliation | 撤销一个有效的学校认证 | 已实现 |
FreezeOrganizationStanding、UnfreezeOrganizationStanding | 冻结或解除冻结一所学校的公开计数 | 已实现 |
| 按学校和日期列出新认证数 | 发现异常增长 | 未实现 |
| 查看一所学校的域名排除项和数据来源 | 核查域名 | 未实现。可用域名见公开的 Organization.email_domains |
撤销后,这个地址可以认证其他账号,本人也可以再次认证。要阻止被滥用的域名,在名单中排除这个域名。要阻止一个账号,暂停它的会员卡。
审计动作为 organization.affiliation.revoked、organization.standing.frozen 和 organization.standing.unfrozen。重复撤销、重复冻结或解冻没有冻结的学校时,服务端返回错误,不改变数据,也不写审计事件。
错误原因
新增的错误原因已经加入 proto/boji/v1/error_reason.proto。会员卡暂停沿用 MEMBERSHIP_SUSPENDED。
| 错误原因 | 状态码 | 条件 |
|---|---|---|
MEMBERSHIP_REQUIRED | failed_precondition | 本人没有会员卡 |
AFFILIATION_VERIFICATION_NOT_CONFIGURED | failed_precondition | 服务端没有设置 AFFILIATION_EMAIL_HMAC_KEY |
AFFILIATION_EMAIL_DOMAIN_UNKNOWN | invalid_argument | 域名不在这种类型的域名表中,或者匹配到排除项 |
ORGANIZATION_NOT_FOUND | not_found | 组织不存在 |
AFFILIATION_EMAIL_VERIFICATION_NOT_FOUND | not_found | 验证码认证不存在,或者已经超过 24 小时的保存期 |
AFFILIATION_VERIFICATION_CODE_INVALID | invalid_argument | 验证码错误 |
AFFILIATION_VERIFICATION_EXPIRED | failed_precondition | 验证码已过期、已用完错误次数或已经使用 |
ORGANIZATION_CHANGE_TOO_SOON | failed_precondition | 距离上次更换学校不足 30 天 |
AFFILIATION_INACTIVE | failed_precondition | 退出学校或运营撤销时,账号没有有效的学校认证 |
RATE_LIMITED | resource_exhausted | 超过发送上限。错误附带 google.rpc.RetryInfo |
ORGANIZATION_STANDING_ALREADY_FROZEN | failed_precondition | 运营冻结一所已经冻结的学校 |
ORGANIZATION_STANDING_NOT_FROZEN | failed_precondition | 运营解冻一所没有冻结的学校 |
RATE_LIMITED 以后也可以用于其他接口。
数据模型
| 表 | 主键 | 主要字段 |
|---|---|---|
organization | id | organization_type(目前只有 school)、source_key(学校键,同一类型内唯一)、english_name、chinese_name、region_code、state(active 或 retired)、imported_at、created_at |
organization_domain | (domain, organization_id) | kind(allow 或 exclude)、source(hipo 或 override) |
affiliation | (user_id, organization_type) | organization_id、state(active 或 ended)、email_hmac、email_domain、verified_at、counted_since、organization_since、expires_at、ended_at |
affiliation_event | id | user_id、organization_id、action、created_at |
affiliation_email_verification | id | user_id、organization_type、email_hmac、email_domain、masked_email、candidate_organization_ids、code_hmac、failed_attempts、created_at、expires_at、consumed_at |
organization_standing_freeze | organization_id | member_count、weekly_new_count、reach_time(冻结时的凑齐时间,没有计入的会员时为空)、actor_user_id、created_at |
affiliation 的主键保证一个账号在每种类型下最多有一个组织。(organization_id, organization_type) 是指向 organization(id, organization_type) 的复合外键,所以归属的类型必须与组织的类型一致。
affiliation_event.action 的取值为 verified(认证)、renewed(续期)、changed(更换学校)、left(退出)、displaced(被另一个账号用同一邮箱顶替)和 revoked(运营撤销)。
organization 和 organization_domain 只由导入命令写入。这两张表的修改记录在 Git 历史中,不写入审计表。冻结和解冻写入审计表 admin_audit_event。
affiliation 的 (organization_type, email_hmac) 有唯一索引。认证结束时,服务端清除 email_hmac,所以唯一性只约束有效记录。过期不改写 state,查询时按 expires_at 判断。所以计数不依赖后台任务。organization_since 是本次进入这个组织的时间,服务端用它计算更换学校的冷却期。
affiliation、affiliation_event 和 affiliation_email_verification 引用 user 时使用 ON DELETE CASCADE。注销账号时,学校认证、认证事件和验证码记录一起删除,bizserver/tests/account_contract.rs 检查这一点。organization_standing_freeze.actor_user_id 使用 ON DELETE SET NULL,所以运营人员注销后,冻结仍然有效。
计数查询的示意如下。$1 是当前时间,$2 是本周一 0:00(北京时间)对应的 UTC 时间,$3 是组织类型。WHERE 子句的前五个条件就是 bizserver/src/domain/organization.rs 中的 COUNTED_CONDITION:
SELECT a.organization_id,
count(*) AS member_count,
max(a.counted_since) AS reach_time,
count(*) FILTER (WHERE a.counted_since >= $2) AS weekly_new_count
FROM affiliation AS a
JOIN membership AS m ON m.user_id = a.user_id
JOIN "user" AS u ON u.id = a.user_id
WHERE a.state = 'active'
AND a.expires_at > $1
AND m.status = 'active'
AND (m.expires_at IS NULL OR m.expires_at > $1)
AND (NOT u.banned OR (u.ban_expires IS NOT NULL AND u.ban_expires <= $1))
AND a.organization_type = $3
GROUP BY a.organization_id;服务端用冻结表中的数字替换冻结学校的查询结果,再按排序键排序,最后应用公开门槛。
防刷与运营
学校荣誉会带来刷人数的动机。设计用三层措施应对:
- 认证门槛:每个计入的人都必须收到一封学校邮箱的验证码。
- 限流:服务端限制验证码的发送次数。
- 异常发现:运营后台按学校和日期统计新认证数,标记异常增长的学校。运营人员核查后,可以冻结计数或撤销认证。冻结和撤销已经实现,统计方法尚未实现。
运营人员填写原因时,禁止写入邮箱、令牌或健康数据。这条规则与现有的会员运营规则相同。
隐私
| 数据 | 处理方式 |
|---|---|
| 学校邮箱原文 | 不保存。服务端只在发送验证码的请求中使用 |
| 学校邮箱的 HMAC | 保存,用于保证一个学校邮箱只对一个账号有效。认证结束时清除 |
| 邮箱域名 | 保存,例如 student.xjtlu.edu.cn,用于显示和统计 |
| 验证码 | 只保存 HMAC,10 分钟后失效 |
| 学校、认证时间、到期时间 | 保存。这些数据属于个人信息 |
| 公开数据 | 只有每所学校的人数、本周新增和名次 |
学校邮箱的前缀常是学号,普通哈希可以被逐个试出原文。所以服务端使用带密钥的 HMAC-SHA256。
数据的保留期限必须写入隐私政策 docs/sites/privacy.mdx,这一步还没有完成。验证码记录保留 24 小时:服务端在下一次签发验证码时删除更早的记录,所以不需要后台任务。认证事件建议保留 1 年,定期清理需要后台任务,这一项还没有实现。注销账号时,所有学校数据一起删除。
错误事件禁止包含学校邮箱、邮箱 HMAC 和验证码。验证码只出现在请求体中,不出现在 URL 路径中。Sentry 错误上报已经写明这一点。
守则文案
会员守则「尊重自己的节奏」写着「不排名」,与学校排名在字面上冲突。建议的新文案如下:
| 语言 | 现有文案 | 建议文案 |
|---|---|---|
| 中文 | 不排名,不鼓励极端减脂;不舒服时停止训练。 | 不给个人排名,不鼓励极端减脂;不舒服时停止训练。 |
| 英文 | No rankings and no extreme cutting. Stop training when you feel unwell. | No individual rankings and no extreme cutting. Stop training when you feel unwell. |
修改涉及以下文件:
ios/Boji/Models/MembershipPresentation.swiftios/BojiTests/MembershipPresentationTests.swiftandroid/app/src/main/res/values/strings_membership.xmlandroid/app/src/main/res/values-b+zh+Hans/strings_membership.xml
守则内容变化后,条款版本应该从 community-v1 升为新版本。服务端、iOS、Android 和免费会员卡开发说明必须同时更新条款版本。已领卡的会员是否需要重新确认,见待决事项。
俱乐部挑战的文案「这是你的本地挑战,不排名,也不催促」描述的是本地挑战。学校排名上线后,这句话仍然成立,不需要修改。
实施顺序
- 前置条件:接入真实邮件服务,完成请求限流。这两项也是生产上线的阻塞项。状态:未完成。
- 学校名单:提交 Hipo 快照和许可声明,编写导入命令。合并重复记录,填写中文名,核对接龙表中已收录学校的域名,并实测验证码送达。状态:快照、许可声明、导入命令、上海交通大学的合并和 39 个中文名已完成。其他重复记录的检查、域名核对和送达实测未完成。
- 学校认证:实现
AffiliationService、相关数据表和运营的撤销操作。状态:已完成。 - 公开排名:实现
OrganizationService、排名缓存、公开门槛和冻结。状态:已完成。按学校和日期统计新认证数的运营方法未完成。 - 网站页面:实现排名页、本校页、认证页和「我的学校」。
- App 入口:第一期不做。接口就绪后,iOS 和 Android 可以按
organization_ranking_enabled显示入口。 - 名单申请:以后设立。添加 issue 表单和「本地新增学校」规则,再在认证页加入「申请添加学校」入口。
第 2 步要求的文档更新已经完成:bizserver/AGENTS.md 的目录表加入了 data/schools/,根 README.md 的许可证排除列表加入了 Hipo 数据。实现第 7 步时,根 AGENTS.md 的 .github/ 一行必须加入 issue 表单。
网站有两个依赖需要单独设计:
websites/是静态导出(output: "export"),排名页只能在浏览器中调用 API。服务端必须把网站域名加入CORS_ORIGINS。- 网站目前没有登录功能。业务接口只接受签名的 Bearer 令牌,不接受只有 Cookie 的请求。网站登录如何保存令牌,需要安全评审。
上线时,排名从 0 开始。接龙表的人数无法核验,所以不导入系统。建议公布一个开榜日期,让所有学校同时开始计数。
待决事项
已确定的事项:Hipo 没有收录的学校暂时不收录,以后通过 GitHub issue 申请添加(2026-10-21)。
以下事项尚未确定:
| 编号 | 问题 | 建议 |
|---|---|---|
| 1 | 认证有效期 | 365 天 |
| 2 | 公开门槛 | 3 人 |
| 3 | 一周的起点 | 北京时间周一 0:00 |
| 4 | 已领卡的会员是否需要确认新版守则 | 不强制。用户首次学校认证时确认 |
| 5 | 网站登录方案 | 单独做安全评审 |
| 6 | 网站域名用 leanmuscleclub.com 还是 leanmuscle.club | 待确认。社区海报和仓库不一致 |
| 7 | 排名是否按国家或地区筛选 | 第二期考虑。ListOrganizationStandings 的 filter 可以增加 region_code 条件 |
| 8 | 更换学校的冷却期 | 30 天 |
| 9 | 「本地新增学校」的审核标准 | 学校官方页面能证明学生邮箱域名 |
| 10 | 没有 GitHub 账号的用户如何申请 | 设立 issue 机制时,增加支持邮箱作为备用入口 |
| 11 | Hipo 快照多久更新一次 | 每季度更新一次。处理申请时也可以更新 |