Halo 注册协议确认机制(Signup Agreement):从管理配置到服务端强校验的实现全解析 Halo 注册协议确认机制Signup Agreement从管理配置到服务端强校验的实现全解析【免费下载链接】haloHalo 是一款强大易用的开源建站工具从个人博客、知识库到企业官网、在线商城Halo 都能助您轻松实现一站式满足您的多样化建站需求。项目地址: https://gitcode.com/GitHub_Trending/ha/haloHalo 在注册流程中内置了一套“注册协议/条款确认”机制signup-agreement管理员可在用户设置中选择一个或多个独立页面如隐私政策、服务条款作为注册时的必读协议访客访问/signup时注册页会动态渲染带链接的强制勾选框后端则在UserServiceImpl.signUp()中对勾选结果做服务端强校验。本文基于仓库中 signup-agreement 规范 及其设计文档 proposal、design结合源码与测试逐层讲解管理员如何配置、注册页如何渲染、后端如何拦截未勾选请求、协议页链接为何要在渲染时动态解析以及该能力如何覆盖 OAuth2 注册场景。功能背景与设计目标Halo 的注册入口是/signup由 PreAuthSignUpEndpoint 提供服务页面使用 Thymeleaf 模板 gateway_fragments/signup.html 渲染。用户相关设置存储于SystemSetting.User通过SystemConfigFetcher读取并由GlobalInfoService把allowRegistration、mustVerifyEmailOnRegistration等全局配置下发到认证类模板。此前注册流程缺少合规性如 GDPR要求的“同意协议”环节。虽然主题作者可以通过自定义认证页加一个勾选框但一旦切换主题这段自定义配置就会丢失。设计文档给出的解决方案是将其做成内置能力并明确了四项目标管理员可选择一个或多个独立页面作为注册时必须同意的协议当配置了协议页时注册页动态展示带超链接的强制勾选框后端在用户未勾选协议时拒绝注册请求不修改GlobalInfo避免污染全局上下文。同时设计上明确不新增数据库表、不做迁移脚本复用既有 ConfigMap 设置存储且当时 Halo 唯一的注册入口就是/signup无需改动 UC 注册流。第一层管理员配置——把协议页绑定到用户设置后端配置模型的落点配置项被定义在 SystemSetting.java 的User类中。与既有的allowRegistration、mustVerifyEmailOnRegistration、defaultRole、protectedUsernames平级新增字段public static class User { public static final String GROUP user; boolean allowRegistration; boolean mustVerifyEmailOnRegistration; String defaultRole; String protectedUsernames; ListString requiredAgreementPages; // 注册时所需协议页SinglePage 的 metadata.name }requiredAgreementPages是一个ListString存储的是被选中 SinglePage 扩展对象的metadata.name而非显示名或 permalink。该值最终被序列化进系统 ConfigMap 中user分组的配置里属于纯设置数据、与页面渲染无关。设置界面中的表单元件设置项通过application/src/main/resources/extensions/system-setting.yaml中的系统设置扩展点注册。在user用户设置分组内可以看到它的完整定义- group: user label: 用户设置 formSchema: - $formkit: checkbox name: allowRegistration label: 开放注册 value: false - $formkit: singlePageSelect name: requiredAgreementPages label: 注册时所需协议 multiple: true if: $get(allowRegistration).value true help: 选择注册时需要用户同意的协议页面几个关键点采用$formkit: singlePageSelect组件实现见 ui/src/formkit/inputs/singlePage-select.ts配multiple: true即为多选返回值为ListString页面metadata.name。设计文档之所以选它是因为该组件已存在且天然支持多选、只允许选择已发布的独立页面。if: $get(allowRegistration).value true意味着该表单项仅在“开放注册”开启时才出现与规范中“WHEN Allow Registration is enabled”的场景一致。不需要为“隐私政策”“服务条款”分别建两个字段——设计评审时曾考虑拆两个独立字段但最终遵循“单一表单字段”的需求选择了一个多选字段多个协议页可在同一个字段内多选。管理员在 系统设置 → 用户设置 中完成勾选并保存后这些metadata.name即以user.requiredAgreementPages的键名落入系统配置这一行为与规范中 “Scenario: Admin saves agreement page configuration” 的要求一致。第二层注册页渲染——有配置才出现勾选框当访问/signup时PreAuthSignUpEndpoint 的 GET 处理器负责渲染注册页.GET(, request - { var signUpData new SignUpData(); var bindingResult new BeanPropertyBindingResult(signUpData, form); var model bindingResult.getModel(); model.put(globalInfo, globalInfoService.getGlobalInfo()); model.put(agreementPages, agreementPageFetcher.fetchAgreementPages()); return ServerResponse.ok().render(signup, model); })POST 处理同样在回渲染含校验失败回显时把agreementPages放入 model保证用户提交失败后勾选框及其链接仍能正确显示。注意这里没有把协议页放进GlobalInfo而是以agreementPages这个 model 变量传给模板——这正是“协议页在渲染时解析”的核心载体。对应的模板片段位于 signup.htmldiv classform-item-compact th:if${agreementPages ! null and !agreementPages.isEmpty()} input typecheckbox idagreedToTerms nameagreedToTerms valuetrue required / label foragreedToTerms span th:text#{form.agreedToTerms.label}I have read and agree to/span th:block th:eachpage, iterStat : ${agreementPages} a th:if${page.permalink ! null} th:href${page.permalink} target_blank th:text${page.title}/a span th:if${page.permalink null} th:text${page.title}/span span th:if${!iterStat.last},/span /th:block /label /div p classalert alert-error th:if${#fields.hasErrors(agreedToTerms)} th:errors*{agreedToTerms}/p对应规范中两条场景的实现要点未配置协议页时agreementPages为空集合th:if判断不成立整个勾选框 DOM 不渲染注册流程照常——这与“没有协议配置则不做任何打扰”的需求完全对应配置了协议页时勾选框默认requiredHTML 原生必填校验作为前端第一道防线文案 “我已阅读并同意”默认语言见下文的 i18n后面紧跟协议页标题每页标题若拿到permalink就以a target_blank渲染成在新窗口打开的链接多个页面之间以逗号分隔校验失败的字段级错误显示在勾选框下方th:errors绑定agreedToTerms字段把“字段错误落在勾选框上”落到 UI 上。多语言文案i18n该表单文案使用 Thymeleaf 消息键各语言文件位于application/src/main/resources/templates/gateway_fragments/下文件form.agreedToTerms.label值signup.properties中文默认我已阅读并同意signup_en.propertiesI have read and agree tosignup_es.propertiesHe leído y aceptosignup_zh_TW.properties我已閱讀並同意设计文档也指出本次改动仅新增一个错误消息键见下节后端校验多语言维护成本被控制在最小范围。第三层服务端强校验——UI 勾选不能代替后端拦截前端required属性可以被绕过因此规范明确要求后端在协议已配置而agreedToTerms不为true时拒绝注册。这一业务规则被集中到服务层 UserServiceImpl.signUp()与“是否开放注册”“用户名是否受保护”“默认角色是否配置”等注册规则放一起Override public MonoUser signUp(SignUpData signUpData) { return environmentFetcher .fetch(SystemSetting.User.GROUP, SystemSetting.User.class) .filter(SystemSetting.User::isAllowRegistration) .switchIfEmpty(Mono.error(() - new ServerWebInputException( The registration is not allowed by the administrator.))) // ... 用户名、昵称、默认角色校验 ... .filter(setting - { var pages setting.getRequiredAgreementPages(); if (CollectionUtils.isEmpty(pages)) { return true; // 未配置协议页跳过校验 } return Boolean.TRUE.equals(signUpData.getAgreedToTerms()); }) .switchIfEmpty(Mono.error(() - new AgreementNotAcceptedException( Agreement not accepted., problemDetail.user.signup.agreement-not-accepted, null))) .flatMap(setting - { /* 创建用户的实际逻辑 */ }); }这段代码对应规范里两条提交场景的精确语义requiredAgreementPages为空 → 放行注册不受影响协议已配置但agreedToTerms不是Boolean.TRUE含false与null→ 抛出 AgreementNotAcceptedException注册被拒。表单数据模型agreedToTerms勾选值先经 SignUpData 绑定该类承载注册表单的全部字段其中既有username/password等传统字段的 Bean Validation 注解也新增了private Boolean agreedToTerms;agreedToTerms刻意使用包装类型Boolean而非基本类型boolean以便区分“未提交”与“显式拒绝”。此外SignUpData上的类级校验器SignUpDataConstraintValidator负责“两次密码一致”之类的跨字段校验agreedToTerms属于状态性业务校验所以交给服务层处理而非注解校验。错误回显与既有异常处理模式保持一致AgreementNotAcceptedException并没有被全局异常兜底吞掉而是在 PreAuthSignUpEndpoint 的 POST 响应流中被显式捕获并转换为字段级错误.doOnError(AgreementNotAcceptedException.class, e - { bindingResult.addError(new FieldError( form, agreedToTerms, signUpData.getAgreedToTerms(), true, new String[] {signup.error.agreed-to-terms.required}, null, Please agree to the terms)); }) .onErrorResume(e - ServerResponse.ok().render(signup, model));设计文档特别强调之所以选择在UserServiceImpl.signUp()校验而不是放在 HTTP 层校验是为了避免在 Web 层多做一次设置查询把业务规则收敛到服务层。错误处理则复用DuplicateNameException、RestrictedNameException等既有异常的doOnErroronErrorResume范式把signup.error.agreed-to-terms.required作为错误码挂到agreedToTerms字段上最终由模板中的th:errors展示在勾选框下方与规范“页面以字段错误方式重新渲染”的要求吻合。第四层渲染时解析协议页——为何不走 GlobalInfo规范的第四条要求是“协议页在渲染时解析而不是通过 GlobalInfo”。背后的实现组件是 AgreementPageFetcherMonoListMapString, String fetchAgreementPages() { return systemConfigFetcher .fetch(SystemSetting.User.GROUP, SystemSetting.User.class) .flatMapMany(setting - CollectionUtils.isEmpty(setting.getRequiredAgreementPages()) ? Flux.empty() : Flux.fromIterable(setting.getRequiredAgreementPages())) .concatMap(name - extensionClient .fetch(SinglePage.class, name) .switchIfEmpty(Mono.error(new IllegalStateException( Required agreement page not found: name))) .map(page - { MapString, String result new HashMap(); result.put(title, page.getSpec().getTitle()); if (page.getStatus() ! null) { result.put(permalink, page.getStatus().getPermalink()); } return result; })) .collectList(); }它的执行链条正好与规范 “Scenario: Signup page renders with agreement links” 一一对应通过systemConfigFetcher读取user分组的SystemSetting.User拿到requiredAgreementPages配置为空则直接返回空列表对每个metadata.name用ReactiveExtensionClient.fetch(SinglePage.class, name)实时查询SinglePage 扩展从查询结果中取出spec.title页面标题与status.permalink页面永久链接由系统根据站点 URL 与主题路由规则生成组装成ListMapString,String返回作为 Thymeleaf model 变量使用。这个设计的价值在于解耦GlobalInfo是面向所有认证模板的全局共享上下文而协议页只在/signup用到。把它作为局部 model 变量传递既避免了污染全局上下文也保证 permalink 总是最新值——即使协议页在配置后被改动标题、或站点域名发生变化渲染时拿到的status.permalink也会随之更新不会缓存过期链接。设计文档还指出一个已评估的风险选中的 SinglePage 可能被删除或取消发布导致链接 404。应对策略是渲染时只取带 permalink 的页面page.getStatus() ! null才放入permalink无 permalink 的标题会以纯文本渲染而非断链且不强制页面必须存在——保证页面可用性被视为管理员的责任。从源码看该能力的测试覆盖仓库的测试用例可作为理解这套机制行为的“可执行文档”AgreementPageFetcherTest.java 覆盖fetchAgreementPages()的空配置、多页解析、标题与 permalink 提取等分支用StepVerifier验证响应式数据流UserServiceImplTest.java 覆盖signUp()中协议校验的成功与拒绝路径PreAuthOAuth2RegistrationEndpointTest.java 与 PreAuthOAuth2RegistrationIntegrationTest.java 则在 OAuth2 注册场景中 mockagreementPageFetcher.fetchAgreementPages()的返回。附加观察同一机制也服务于 OAuth2 注册选择页虽然本规范聚焦/signup表单但从源码结构可以推断协议确认能力被设计成了可复用的公共部件PreAuthOAuth2RegistrationEndpoint.java 同样注入了AgreementPageFetcher并调用fetchAgreementPages()而 oauth2_select.html 中也存在同名agreedToTerms勾选框与form.agreedToTerms.label文案绑定。也就是说当用户通过 OAuth2 第三方登录而本地账号尚不存在、需要进入注册确认流程时同样会要求先勾选协议。这为后续阅读该能力的完整作用域提供了一个延伸方向配置一次协议页两种注册入口共用同一套渲染与校验链路。兼容性与迁移该功能无需任何数据迁移。如 design.md 所述新字段默认缺省为null不配置此时UserServiceImpl.signUp()中的协议校验分支被跳过行为与旧版本完全一致只有当管理员在用户设置中显式选择了协议页后requiredAgreementPages才会非空并使校验生效。这种“默认关闭、显式开启”的设计让该能力可以安全地随版本发布而不影响存量站点。小结Halo 的注册协议确认机制覆盖了一条完整链路系统设置扩展点singlePageSelect 多选协议页→ ConfigMap 设置存储user.requiredAgreementPages→ 渲染时协议页解析AgreementPageFetcher ReactiveExtensionClient→ Thymeleaf 条件渲染勾选框signup.html→ 服务层业务强校验UserServiceImpl.signUp()→ 字段级错误回显AgreementNotAcceptedException → FieldError。整个过程中配置项默认缺省即关闭协议页链接渲染时动态解析、不走 GlobalInfo后端强校验作为最终防线与前端required形成双层保障——这套从规范到实现、从设置到回显的完整样例也值得作为阅读 Haloopenspec演进流程proposal → design → spec与认证模块代码的切入点。【免费下载链接】haloHalo 是一款强大易用的开源建站工具从个人博客、知识库到企业官网、在线商城Halo 都能助您轻松实现一站式满足您的多样化建站需求。项目地址: https://gitcode.com/GitHub_Trending/ha/halo创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考