薄肌俱乐部薄肌俱乐部文档

大学排名设计

大学排名的计数、排序、学校名单、学校邮箱认证、接口、数据模型、防刷和隐私设计。

状态:后端已实现(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 类型的归属

计数规则

一个账号必须同时满足以下条件,才计入学校人数:

  1. 账号的会员卡状态为 active,并且会员卡没有过期。暂停的会员卡不计入。
  2. 账号没有处于封禁期。服务端读取 user.banned 和 user.ban_expires。
  3. 账号的学校认证状态为有效,并且当前时间早于认证的到期时间。

服务端必须在一个领域函数中实现计数规则。排名、本校页和「我的学校」都调用这个函数,不各自判断。

每个账号最多关联一所学校。每个学校邮箱最多对一个账号有效。

邮箱域名不能区分学生、教职工和校友。所以「计入人数」的含义是「持有本校邮箱的会员数」,不是「在校学生数」。页面文案必须使用前一种含义。域名表应该排除明确的校友域名,见域名匹配。

学校认证是可选步骤。没有学校邮箱的人仍然可以领取会员卡,只是不计入任何学校。这条规则保留了守则「人人都有位置」。

排序规则

排序键

排名按以下三个键排序:

  1. 计入人数,从多到少。
  2. 凑齐时间,从早到晚。
  3. 学校 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.jsonHipo 数据快照,固定在一个上游提交上
bizserver/data/schools/hipo.commit快照对应的上游提交号
bizserver/data/schools/LICENSE-hipo.txtHipo 的 MIT 许可声明
bizserver/data/schools/overrides.json本地修正:中文名、补充域名、排除域名和合并

Hipo 的记录没有稳定 ID,只有名称、国家和域名。所以导入工具用学校键 <alpha_two_code>:<name> 识别学校。首次导入一所学校时,导入工具分配 UUID,数据库按学校键保存它。以后的导入沿用这个 UUID。上游改名、删除或拆分的记录,由导入工具在日志中列出,维护者手动处理。

导入命令为 cargo run --locked -- import-schools。导入在一个事务中完成,并遵守以下规则:

  • 新记录:创建学校和域名。
  • 已有学校:更新名称和域名。
  • 上游删除的学校:停用学校,不删除。停用的学校不接受新的认证,已有认证在到期前继续计入。导入工具为每所停用的学校输出一条警告,包含仍在计入的认证数。
  • 被移除的域名:已有的学校认证保持到到期日。新的认证不再接受这个域名。

仓库提交 Hipo 快照时,必须保留 MIT 许可声明。根 README.md「许可证」一节的排除列表已经列出这份数据。

域名匹配

服务端按以下步骤从邮箱找到学校:

  1. 去掉首尾空白,把整个地址转为小写。把国际化域名转为 Punycode。
  2. 去掉本地部分中 + 及其后面的内容。例如 [email protected] 变为 [email protected]。
  3. 在域名表中按标签边界找最长的后缀。student.uq.edu.au 匹配 uq.edu.au,xuq.edu.au 不匹配 uq.edu.au。
  4. 如果最长的后缀是排除项,拒绝认证。排除项用于校友转发域名等不代表在校身份的子域名。
  5. 如果最长的后缀对应多所学校,让用户在候选学校中选择。其他情况下,学校由域名唯一确定。

用户不能先选学校、再用另一所学校的邮箱认证。

名单申请

名单申请通过 GitHub issue 完成。这个机制以后设立,第一期上线时还没有。在第一期中,如果用户的邮箱域名不在名单中,认证页显示「你的学校暂未收录」。

设立以后,申请流程如下:

  1. 用户在认证页点击「申请添加学校」,打开仓库的 issue 表单 .github/ISSUE_TEMPLATE/school-request.yml。
  2. 用户选择申请类型:新增学校、补充或修正域名、修正中文名。然后填写学校英文全称、中文名、国家或地区、官网、学生邮箱域名,以及能证明这个域名的学校官方页面链接。
  3. 维护者核对官方来源。核对通过后,维护者提交 PR 修改 overrides.yaml,并在 PR 中引用 issue 编号。维护者应该同时向 Hipo 上游提交同样的修改。
  4. PR 合并并部署后,维护者运行导入命令,然后关闭 issue。

Issue 是公开的。表单必须提示:禁止填写完整邮箱地址、学号或真实姓名。表单的示例使用 [email protected] 这样的占位地址。

「新增学校」类申请需要改动收录规则,因为本地修正目前不能新增学校。设立 issue 机制时,overrides.yaml 必须增加「本地新增学校」,并写明审核标准。

没有 GitHub 账号的用户不能提交 issue。部分用户访问 GitHub 可能不稳定。这两个限制见待决事项。

学校邮箱认证

前置条件

用户必须满足以下条件,才能开始认证:

  • 已经登录,并且登录邮箱已经验证。
  • 持有状态为 active 的会员卡。

学校邮箱只用于认证,不能用于登录。学生毕业后,学校邮箱会失效。如果学校邮箱就是登录邮箱,用户会失去账号。

认证流程

  1. 用户输入学校邮箱。服务端按域名匹配找到候选学校,并检查限流。
  2. 服务端生成 6 位验证码,发送到学校邮箱。响应包含候选学校、遮盖后的邮箱和过期时间。遮盖后的邮箱形如 a***@student.xjtlu.edu.cn。
  3. 用户输入验证码。如果候选学校多于一所,用户同时选择学校。
  4. 服务端校验验证码,然后在同一个事务中完成以下操作:
    • 如果另一个账号持有同一个学校邮箱的有效认证,撤销那个认证。
    • 为本账号创建、续期或更换学校认证。
    • 写入认证事件,并作废验证码。
  5. 页面显示「你已计入西交利物浦大学」这样的结果,以及到期日期。

服务端在验证码校验通过之后,才检查学校邮箱是否被其他账号使用。所以只有学校邮箱的持有人才能知道这个邮箱是否已被使用。

同一个学校邮箱以最后一次认证为准。一个人丢失旧账号后,可以用新账号重新认证,不需要联系运营人员。这条规则不会增加计入人数,因为同一时间只有一个账号有效。

验证邮件只包含验证码,不包含链接。部分学校的邮件网关会预先打开邮件中的链接做安全扫描,一次性链接可能因此失效(未验证)。

续期、过期、更换和退出

  • 续期:用户可以随时用本校的任一邮箱重新认证。到期时间从认证成功时重新计算。如果原认证仍然有效,计入时间不变。
  • 过期:到期后,认证不再计入。用户重新认证后,计入时间重新开始。
  • 更换学校:用户用另一所学校的邮箱认证,旧认证立即结束。如果距离上次更换学校不足 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。

资源

资源资源名内容
Organizationorganizations/{organization}类型、中文名、英文名、国家或地区代码、可用于认证的邮箱域名
OrganizationStandingorganizations/{organization}/standing单例:公开状态、名次、人数、本周新增、计算时间、冻结标记
Affiliationusers/{user}/affiliations/{affiliation}本人一种类型的组织、认证邮箱的域名、状态、计入时间、到期时间
AffiliationEmailVerificationusers/{user}/affiliations/{affiliation}/emailVerifications/{email_verification}一次验证码认证:候选组织、遮盖后的邮箱、过期时间、可重发时间

{organization} 和 {email_verification} 是 UUID。{affiliation} 是小写的组织类型,所以学校认证的资源名是 users/{user}/affiliations/school。OrganizationStanding 不公开凑齐时间,因为凑齐时间是某一位会员的认证时间。人数不足公开门槛时,OrganizationStanding 也不返回人数和本周新增。

每个账号在每种类型下都有一个 Affiliation,所以它没有 Create 和 Delete 方法。账号没有这种类型的组织时,状态为 UNAFFILIATED。

公开与本人方法

服务方法调用条件用途
boji.v1.OrganizationServiceListOrganizations不要求登录按类型、名称、域名或地区查找组织
boji.v1.OrganizationServiceGetOrganization不要求登录读取一个组织
boji.v1.OrganizationServiceListOrganizationStandings不要求登录按名次分页列出一种类型的公开排名,或读取一个组织的排名
boji.v1.OrganizationServiceGetOrganizationStanding不要求登录读取一个组织的排名信息
boji.v1.AffiliationServiceGetAffiliation已验证邮箱的会话读取本人一种类型的归属
boji.v1.AffiliationServiceListAffiliations已验证邮箱的会话列出本人每种类型的归属
boji.v1.AffiliationServiceCreateAffiliationEmailVerification已验证邮箱的会话提交学校邮箱,发送验证码
boji.v1.AffiliationServiceGetAffiliationEmailVerification已验证邮箱的会话读取一次验证码认证
boji.v1.AffiliationServiceConfirmAffiliationEmailVerification已验证邮箱的会话提交验证码,建立、续期或更换学校认证
boji.v1.AffiliationServiceLeaveOrganization已验证邮箱的会话结束本人一种类型的归属,例如退出学校

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_REQUIREDfailed_precondition本人没有会员卡
AFFILIATION_VERIFICATION_NOT_CONFIGUREDfailed_precondition服务端没有设置 AFFILIATION_EMAIL_HMAC_KEY
AFFILIATION_EMAIL_DOMAIN_UNKNOWNinvalid_argument域名不在这种类型的域名表中,或者匹配到排除项
ORGANIZATION_NOT_FOUNDnot_found组织不存在
AFFILIATION_EMAIL_VERIFICATION_NOT_FOUNDnot_found验证码认证不存在,或者已经超过 24 小时的保存期
AFFILIATION_VERIFICATION_CODE_INVALIDinvalid_argument验证码错误
AFFILIATION_VERIFICATION_EXPIREDfailed_precondition验证码已过期、已用完错误次数或已经使用
ORGANIZATION_CHANGE_TOO_SOONfailed_precondition距离上次更换学校不足 30 天
AFFILIATION_INACTIVEfailed_precondition退出学校或运营撤销时,账号没有有效的学校认证
RATE_LIMITEDresource_exhausted超过发送上限。错误附带 google.rpc.RetryInfo
ORGANIZATION_STANDING_ALREADY_FROZENfailed_precondition运营冻结一所已经冻结的学校
ORGANIZATION_STANDING_NOT_FROZENfailed_precondition运营解冻一所没有冻结的学校

RATE_LIMITED 以后也可以用于其他接口。

数据模型

表主键主要字段
organizationidorganization_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_eventiduser_id、organization_id、action、created_at
affiliation_email_verificationiduser_id、organization_type、email_hmac、email_domain、masked_email、candidate_organization_ids、code_hmac、failed_attempts、created_at、expires_at、consumed_at
organization_standing_freezeorganization_idmember_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.swift
  • ios/BojiTests/MembershipPresentationTests.swift
  • android/app/src/main/res/values/strings_membership.xml
  • android/app/src/main/res/values-b+zh+Hans/strings_membership.xml

守则内容变化后,条款版本应该从 community-v1 升为新版本。服务端、iOS、Android 和免费会员卡开发说明必须同时更新条款版本。已领卡的会员是否需要重新确认,见待决事项。

俱乐部挑战的文案「这是你的本地挑战,不排名,也不催促」描述的是本地挑战。学校排名上线后,这句话仍然成立,不需要修改。

实施顺序

  1. 前置条件:接入真实邮件服务,完成请求限流。这两项也是生产上线的阻塞项。状态:未完成。
  2. 学校名单:提交 Hipo 快照和许可声明,编写导入命令。合并重复记录,填写中文名,核对接龙表中已收录学校的域名,并实测验证码送达。状态:快照、许可声明、导入命令、上海交通大学的合并和 39 个中文名已完成。其他重复记录的检查、域名核对和送达实测未完成。
  3. 学校认证:实现 AffiliationService、相关数据表和运营的撤销操作。状态:已完成。
  4. 公开排名:实现 OrganizationService、排名缓存、公开门槛和冻结。状态:已完成。按学校和日期统计新认证数的运营方法未完成。
  5. 网站页面:实现排名页、本校页、认证页和「我的学校」。
  6. App 入口:第一期不做。接口就绪后,iOS 和 Android 可以按 organization_ranking_enabled 显示入口。
  7. 名单申请:以后设立。添加 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 机制时,增加支持邮箱作为备用入口
11Hipo 快照多久更新一次每季度更新一次。处理申请时也可以更新

本页目录