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

免费会员卡开发说明

会员卡的用户流程、接口契约、配置、安全隐私和测试设计。

会员卡是绑定账号的免费俱乐部身份,不是付费订阅。卡号、入会日期、状态和权益由后端决定。客户端不能离线签发,也不提供本地升级会员操作。

用户流程

  1. 在俱乐部页或设置页打开「会员卡与账号」。
  2. 注册邮箱账号,并通过验证邮件确认邮箱。
  3. 返回 App 登录,查看账号关联的会员卡。
  4. 没有会员卡时,填写卡面昵称并预览。
  5. 逐条确认守则,再核对信息并同意数据说明。
  6. 领取成功后查看卡号、入会日期和免费权益。
  7. 制作会员分享卡,选择是否展示卡号,再确认分享。

卡面昵称在本版中不可修改。客户端展示每次请求的服务端结果。退出不删除本机训练记录,也不会注销服务器账号。

接口契约

账号认证使用 Better Auth 的 /api/auth/* 端点。会员卡、服务能力和钱包凭证使用 bizserver 的 ConnectRPC 方法。RPC 契约以 bizserver/proto/boji/v1/ 为准,详见 RPC 使用说明。下表列出 iOS 的调用。

接口行为
POST /api/auth/sign-up/email注册,提交 name、email、password;不自动登录
POST /api/auth/sign-in/email邮箱登录;从 set-auth-token 响应头读取令牌
POST /api/auth/sign-in/socialApple 或 Google 登录,提交 provider(apple 或 google)和 idToken;从 set-auth-token 响应头读取令牌
POST /api/auth/send-verification-email重发验证邮件
POST /api/auth/request-password-reset发送重置邮件
GET /api/auth/get-session恢复用户与会话状态;没有有效会话时返回 null
POST /api/auth/sign-out请求注销当前会话
SystemService.GetServerConfig读取 Apple 登录、Google 登录和钱包凭证的服务端开关;不需要登录
MembershipService.ListMembershipsparent 为 users/me,返回零或一张会员卡
MembershipService.CreateMembership提交 nickname 和 terms_version,返回会员卡。卡已存在时返回 MEMBERSHIP_ALREADY_EXISTS,客户端改为读取现有卡
WalletService.GetMembershipPassname 为 users/me/memberships/{membership}/pass,返回已签名的 .pkpass 文件

守则版本固定为 community-v1。后端校验登录和邮箱验证状态。Better Auth 端点返回 401 时,客户端要求重新登录。

Connect 错误带有一个 google.rpc.ErrorInfo,客户端按它的 reason 处理错误:

  • SESSION_REQUIRED:需要重新登录。
  • EMAIL_NOT_VERIFIED:需要验证邮箱。
  • WALLET_NOT_CONFIGURED:钱包签发服务未配置,客户端停用钱包按钮。

其他原因显示该操作的通用失败提示。没有 ErrorInfo 的 unavailable 或超时错误来自网关或网络,客户端按离线处理。暂停的会员卡不是错误:ListMemberships 返回它,state 为 SUSPENDED。

会员字段来自 boji.v1.Membership:name、member_number、nickname、terms_version、tier、state、entitlements、create_time 和 expire_time。name 的格式为 users/{user}/memberships/{membership}。客户端从中取得会员卡 ID 和用户 ID,并核对用户 ID 与当前账号一致。免费卡的 tier 为 COMMUNITY,没有 expire_time。未知的 state 按暂停处理,未知的 tier 不开启会员能力。

member_number 是不透明字符串。客户端原样展示,并使用最新响应刷新缓存。客户端不从卡号推断权限、等级或其他身份信息。

entitlements 是 EffectiveEntitlement 列表,每项包含 code、display_name、description、grant_time 和 expire_time。权益没有 ID。客户端使用服务端的展示信息,不依赖列表顺序。

旧字符串缓存可以读取,但不会被当成已核验的授权。未知、过期或尚未生效的权益不会开启客户端能力。

免费权益为 local_tracking、training_library 和 progress_history。这些对应已有的日常记录、基础课程和历史趋势。它们本来就是免费本地功能,不因退出或暂停会员而被锁定。

钱包会员卡

WalletService.GetMembershipPass 返回的 .pkpass 是一张 generic 样式的通行证。卡面使用 App 内森林绿卡面的配色,左上角是 App 图标的图形和 App 名称。字段的位置如下:

位置内容显示条件
顶部会员卡号总是显示。通行证叠放时,这一行仍然可见
主字段卡面昵称总是显示
次字段入会日期总是显示。钱包按设备语言格式化日期
次字段组织,例如学校预留。通行证还没有读取组织认证,见学校排名。接入后显示一个已认证组织的名称,接入前不显示
辅助字段有效期至会员卡有 expire_time 时显示。免费卡不显示
辅助字段状态会员卡暂停时显示「已暂停」
底部二维码,内容是静态会员码服务端设置了会员码密钥时显示。卡号仍在顶部显示
背面关于本卡、使用说明、会员状态总是显示。有二维码时,使用说明写明工作人员可以扫码确认会员身份

通行证的文案来自 locales/ 的 wallet 分组,只有 targets: [wallet] 的键。通行证包含中文和英文,钱包按设备语言显示,其他语言显示英文。组织名称按组织提供的语言显示,缺少设备语言时显示英文名称。

通行证带有更新服务地址和认证令牌。用户把通行证加入钱包后,钱包向 bizserver 登记设备。运营暂停会员卡后,服务端通知已登记的设备,钱包取回新的通行证:通行证标为已失效,状态显示「已暂停」。恢复会员卡后,通行证重新生效。协议和部署要求见 bizserver README。

通行证不能通过隔空投送或邮件分享。它不包含邮箱、用户 ID、令牌或身体数据,不是支付凭证。服务端为更新保存钱包的设备库标识和推送令牌。设备删除通行证、APNs 报告令牌失效或注销账号时,服务端删除这条登记。

会员码

会员码是会员卡上的二维码,供工作人员扫码确认会员身份。会员码分两层:

层出现位置变化方式防转发能力
静态码钱包会员卡底部不变,只有服务端更换密钥时才变弱。截图可以重复使用,适合低风险场景
动态码App 内的会员卡每 30 秒变化一次,前后各容忍一个时间步强。截图约 90 秒后失效

钱包会员卡只能放静态码,因为 Apple Wallet 的条码只能随整张通行证更新,不能按时间轮换。动态码使用 RFC 6238 TOTP,App 从服务端取得 TOTP 参数后在本机计算。App 在用户打开会员码时取得参数,只保存在内存中,不写入会员卡缓存。取得参数后,本次运行内离线也能显示动态码;App 重新启动后,要联网再取一次。两种码都不包含会员卡号、昵称、邮箱或用户 ID,接口格式见 ConnectRPC 业务接口。

扫码校验遵循以下规则:

  • 只有获得校验权限的工作人员可以扫码查询。校验权限沿用运营权限 membership.read。普通会员和未登录的人扫码得不到会员信息。
  • 校验结果显示会员卡号、卡面昵称、会员卡当前的状态和会员码的来源。状态分为有效、已暂停和已过期。
  • 来源是钱包会员卡的静态码时,结果提示这个码可能是截图,工作人员需要核对本人。
  • 动态码超出允许的时间窗口后,结果显示会员码已过期,提示会员刷新后重新出示。
  • 服务端不保存会员码,只用密钥重新计算并比对。服务端更换密钥后,全部会员码同时失效,用户需要重新下载钱包会员卡。

扫码端目前在 iOS 和 Android App 的会员中心。账号的运营角色拥有 membership.read 时,会员中心显示「扫码校验会员」入口。这个账号本身不需要领卡。App 用 Better Auth 的 /api/auth/admin/has-permission 判断是否显示入口。这个判断只决定入口是否显示,服务端每次校验时都重新检查权限。以后的独立管理端调用同一个方法 boji.admin.v1.MembershipCodeService/VerifyMembershipCode。

其他运营方法要求 15 分钟内创建的会话。扫码校验只读取一张会员卡,不修改数据,所以接受 12 小时内创建的会话。这样,前台工作人员每个工作日登录一次即可。会话超过 12 小时后,App 提示工作人员退出后重新登录。

App 在本机识别二维码:iOS 使用 VisionKit,Android 使用 CameraX 和 ZXing。App 只把识别出的文本发给服务端,不保存也不上传相机画面。App 在工作人员打开扫码页时才请求相机权限。设备没有相机或相机权限被拒绝时,工作人员可以手动输入会员码。校验结果只保存在内存中。关闭扫码页、退出登录或切换账号后,App 清除校验结果。

当前状态:服务端签发静态码和 TOTP 参数,并提供扫码校验方法。钱包会员卡显示静态码。iOS 和 Android 的 App 出示动态码,并为有权限的工作人员提供扫码校验。独立的管理端还没有接入。

配置与边界

Debug 默认访问 http://localhost:3000。通过 Xcode 构建设置 BOJI_API_BASE_URL 覆盖地址。该值写入应用的 BojiAPIBaseURL 配置。

Release 只接受 HTTPS。缺少有效地址时,会员服务显示未配置。客户端不能为了展示效果而自动改用测试卡。

本地开发邮件由后端写入文件。iOS 已提供邮箱和 Apple 登录的客户端流程。bizserver 校验 Apple 身份令牌,正式服务已开启 Apple 登录。真实邮件服务和账号删除仍需后端和发布配置。

安全与隐私

认证令牌只存入 Keychain,不进入 UserDefaults、分享卡或身体数据导出。会员缓存独立于 BojiStore 的身体记录。服务地址和用户身份必须与缓存匹配。

网络失败时可展示既有缓存,并标明离线副本。登录失效时清除会员会话。会员来源未知、状态未知或失效时,不授予客户端新增权益。

iOS 分享图片包含昵称和入会日期,卡号默认隐藏,用户可以主动开启。图片不包含邮箱、令牌、内部用户 ID 或身体数据。它不是核销凭证,不能用截图证明当前有效身份。

「删除全部本地数据」清理本机记录和登录缓存。此操作不代表服务器账号删除。公开发布前需补齐账号删除流程、正式隐私政策与发布审查。

测试设计

MembershipTests 覆盖数据解析、认证状态、缓存、领卡与错误路径。MembershipUITests 覆盖游客状态、守则确认、分享预览和退出。

Debug 的 --uitesting 使用隔离的内存测试服务。测试卡显示「测试卡 · 非真实会员」。该路径不用于真实联调,Release 不启用测试服务。

测试覆盖卡号原样编解码、服务端响应更新,以及新旧权益格式。最新结果见 iOS 的能力验证记录。

此前的编译与联调结果见 iOS 的会员卡验证记录。编译成功不等于真机、邮件服务或生产发布已通过。

本页目录