【寻迹校园 HarmonyOS NEXT 实战 10】表单校验应该放在哪里:ReportService 的隐私友好校验策略

这是“寻迹校园 HarmonyOS NEXT 实战”系列第 10 篇。本文结合 PublishValidationReportService.validateDraft(),说明页面即时反馈、Service 最终门禁、隐私拦截、日期真实性和图片数量校验如何形成同一套可测试规则。

ReportService 校验门禁原创概念图

上图为本文原创生成的校验门禁概念图,不是项目截图。用户输入先经过页面提示,真正写入前仍必须由 Service 统一验证。

一、输入框 maxLength 为什么不是业务校验

maxLength 只能限制字符数量,无法回答这些问题:

  • 标题只有空格是否有效;
  • 分类是否来自允许词表;
  • 事件日期是否真的存在;
  • 日期是否晚于今天;
  • 公开描述是否包含手机号;
  • 拾得物是否填写私密核验特征;
  • 图片数量是否超过系统约束;
  • 编辑时是否偷偷改变了记录类型。

按钮禁用也不能成为唯一防线。调用方可能来自另一个页面、自动化测试或未来的深链入口。只要 Service 接受 ReportDraft,它就必须自己完成最终校验。

二、页面校验与 Service 校验各负责什么

页面层负责即时体验:输入变化后清理错误、在字段附近显示提示、控制步骤切换和按钮状态。

Service 层负责业务安全:任何创建或更新动作都重新校验完整草稿,失败时不进入 Repository。

两层可以使用同一个 PublishValidation 结果,但不能只保留页面层。正确顺序是:

用户输入
  -> 页面给出即时提示
  -> 组装 ReportDraft
  -> Service.validateDraft()
  -> 失败:返回字段错误,不写入
  -> 成功:归一化、复制图片、Repository 持久化

三、用 PublishValidation 承载字段级错误

项目没有只返回一个布尔值,而是定义字段级错误对象:

export class PublishValidation {
  titleError: string = '';
  categoryError: string = '';
  areaError: string = '';
  eventDateError: string = '';
  descriptionError: string = '';
  privateFeatureError: string = '';
  photoError: string = '';

  isValid(): boolean {
    return this.titleError.length === 0 &&
      this.categoryError.length === 0 &&
      this.areaError.length === 0 &&
      this.eventDateError.length === 0 &&
      this.descriptionError.length === 0 &&
      this.privateFeatureError.length === 0 &&
      this.photoError.length === 0;
  }
}

页面能够把错误放到正确字段旁边,Service 也可以在 createReport()updateReport() 中复用同一规则。

四、当前项目的校验规则

validateDraft() 覆盖以下字段:

字段 规则 用户提示
标题 去空格后至少 2 个字 物品名称至少填写 2 个字
分类 去空格后至少 2 个字 请填写物品分类
区域 去空格后至少 2 个字 请填写具体校园区域
日期 YYYY-MM-DD、真实日期、不晚于今天 请选择不晚于今天的事件日期
公开描述 至少 5 个字 公开描述至少填写 5 个字
隐私内容 拦截明显手机号、微信号和证件号 公开描述中不要填写敏感信息
私密特征 拾得记录至少 4 个字 请填写私密核验特征
照片 最多 3 张 最多选择 3 张照片

真实实现保持顺序清晰:

validateDraft(draft: ReportDraft): PublishValidation {
  const validation = new PublishValidation();
  if (draft.title.trim().length < 2) validation.titleError = '物品名称至少填写 2 个字';
  if (draft.category.trim().length < 2) validation.categoryError = '请填写物品分类';
  if (draft.area.trim().length < 2) validation.areaError = '请填写具体校园区域';
  if (!this.isValidEventDate(draft.eventDate)) {
    validation.eventDateError = '请选择不晚于今天的事件日期';
  }
  if (draft.description.trim().length < 5) {
    validation.descriptionError = '公开描述至少填写 5 个字';
  }
  if (this.containsSensitiveContent(draft.description)) {
    validation.descriptionError = '公开描述中不要填写手机号、微信号或证件号码';
  }
  if (draft.reportType === ReportType.FOUND && draft.privateFeature.trim().length < 4) {
    validation.privateFeatureError = '拾得物品需填写至少 4 个字的私密核验特征';
  }
  if (draft.imageUris.length > 3) validation.photoError = '最多选择 3 张照片';
  return validation;
}

五、校验顺序也会影响用户体验

公开描述先检查长度,再检查敏感内容。后一个错误可以覆盖前一个错误,使用户优先看到更重要的隐私风险,而不是只提示“字数不足”。

如果未来需要同时展示多个问题,可以把单字符串升级为错误数组或错误码集合。但当前单字段显示一个最关键错误,能够控制页面复杂度。

六、日期校验不能只依赖正则

2026-02-31 能匹配 YYYY-MM-DD 正则,却不是有效日期。项目的日期校验分四步:

  1. 正则检查格式;
  2. 构造 Date 并检查是否为 NaN
  3. 把解析后的年、月、日与原字符串逐项比较;
  4. 把今天设为 23:59:59,拒绝未来日期。
private isValidEventDate(value: string): boolean {
  const normalized: string = value.trim();
  if (!/^\d{4}-\d{2}-\d{2}$/.test(normalized)) return false;
  const selected: Date = this.parseDate(normalized);
  if (Number.isNaN(selected.getTime())) return false;

  const year = Number(normalized.substring(0, 4));
  const month = Number(normalized.substring(5, 7));
  const day = Number(normalized.substring(8, 10));
  if (selected.getFullYear() !== year ||
    selected.getMonth() !== month - 1 || selected.getDate() !== day) return false;

  const today = new Date();
  today.setHours(23, 59, 59, 999);
  return selected.getTime() <= today.getTime();
}

日期选择器改善输入体验,Service 校验保证任何调用路径都无法绕过真实性检查。

页面即时提示与 Service 最终门禁原创分层图

上图展示两层校验的职责:页面提供快速反馈,Service 决定是否允许写入,Repository 只接收已经通过业务门禁的数据。

七、隐私拦截应该诚实描述能力边界

当前项目使用轻量规则拦截明显内容:关键词“微信”“手机号”“身份证”,以及中国大陆手机号格式。

private containsSensitiveContent(value: string): boolean {
  const text: string = value.trim();
  return text.includes('微信') ||
    text.includes('手机号') ||
    text.includes('身份证') ||
    /1[3-9][0-9]{9}/.test(text);
}

这是一道演示级防线,不是完整敏感信息识别系统。它可能漏掉:

  • 使用空格或符号分隔的号码;
  • 拼音、谐音和图片中的联系方式;
  • QQ、邮箱、宿舍门牌和精确交接地点;
  • 非中国大陆手机号;
  • 经过编码或刻意规避的文本。

因此文章和产品都不能宣称“已识别所有敏感信息”。更完整的生产方案需要规范化、规则库、服务端内容安全、人工审核和申诉链路。

八、私密字段不是公开描述的替代垃圾桶

拾得物的私密核验特征用于认领审核,例如夹层内特定物品。它应该满足最小化原则:只记录能验证归属的必要特征,不记录无关身份信息。

它还需要明确访问边界:

  • 不出现在首页和详情公开区域;
  • 不参与 Agent 输入;
  • 不作为公开匹配理由;
  • 只在认领审核场景展示;
  • 删除记录时同步清理;
  • 日志不得打印原文。

校验“至少 4 个字”只是完整保护的一小部分,数据流和访问控制同样重要。

九、Service 最终门禁如何阻止无效写入

创建和更新都先调用 validateDraft()

async createReport(draft: ReportDraft): Promise<OperationResult<ItemReport>> {
  const validation: PublishValidation = this.validateDraft(draft);
  if (!validation.isValid()) {
    return new OperationResult<ItemReport>(
      false,
      '请检查必填信息',
      undefined,
      ErrorCategory.VALIDATION
    );
  }
  // 通过后才复制图片并写入 Repository
}

这保证无论来自表单提交、编辑页还是未来其他入口,非法草稿都不会进入持久化层。Repository 不需要重复理解标题长度和隐私关键词。

十、按钮禁用仍然有价值,但不是安全边界

页面可以根据当前输入禁用“下一步”或“发布”,减少无效点击,并在 loading、图片选择或草稿保存期间防止冲突。

但按钮禁用只负责体验:状态可能过时,未来调用方也可能不经过这个按钮。最终是否写入必须以 Service 校验结果为准。

两者关系不是二选一,而是“页面早反馈 + Service 强门禁”。

十一、如何测试校验规则

建议使用表驱动测试覆盖:

  • 标题:空、空格、1 字、2 字、长文本;
  • 分类与区域:空值、未知旧值和权威词表值;
  • 日期:空、格式错、闰日、2 月 31 日、今天和明天;
  • 描述:4 字、5 字、手机号、关键词和普通文本;
  • 类型:丢失与拾得对私密特征的不同要求;
  • 图片:0、1、3、4 张;
  • 组合错误:多个字段同时无效;
  • 更新动作:类型变化、非用户创建记录和非开放状态。

测试应该直接调用 ReportService.validateDraft() 和创建/更新方法,而不是只模拟按钮点击。这样规则可以在没有 ArkUI 页面的环境中快速回归。

十二、错误文案也是契约

错误文案要告诉用户如何修复,而不是泄露内部异常。存储异常应该映射成“保存失败,请稍后重试”,而不是把数据库路径、SQL 或堆栈直接显示在页面。

同一错误在页面、测试和日志中应有稳定分类,例如 VALIDATIONSTORAGECAPABILITY。用户看到可理解文案,开发者通过受控日志定位原因。

十三、生产级扩展方向

当项目接入后端后,校验需要分层扩展:

  • 客户端:即时反馈、基础格式、减少无效请求;
  • Service:跨页面统一业务规则;
  • 服务端:鉴权、幂等、最终数据约束;
  • 内容安全:文本与图片检测;
  • 人工治理:复杂边界和申诉;
  • 数据库:非空、长度、枚举和唯一性约束。

任何一层都不能单独替代其他层。特别是客户端校验永远不能当成服务端安全边界。

十四、修改一条规则前先做影响分析

校验规则不是孤立的 if。例如把公开描述最小长度从 5 改成 10,至少会影响:

  • 新建表单的按钮状态和字段提示;
  • 编辑旧记录时能否重新保存;
  • 草稿恢复后是否立刻显示错误;
  • 自动化用例中的边界值;
  • 种子数据和迁移数据是否仍合法;
  • 服务端接入后的字段约束是否一致;
  • 错误文案、帮助说明和无障碍朗读。

因此变更顺序应是:先写业务规则和兼容策略,再更新 PublishValidation 与测试,最后调整页面提示。不要先改输入框 maxLength,再让 Service 和旧数据被动追赶。

十五、失败处理、回滚与观测边界

校验失败属于预期业务结果,不应记录完整草稿或私密字段。日志可以保留错误分类、字段名和规则版本,但不能输出用户输入原文、图片 URI、联系方式或私密核验内容。

如果新版规则误伤正常输入,最小回滚是恢复对应 Service 规则与错误码,同时保留 Repository 数据。不要清空数据库,也不要临时绕过全部校验。对于已经存在但不满足新规则的旧记录,可以采用“允许查看、编辑时提示迁移”或定向兼容,而不是让所有旧数据突然消失。

用户看到的失败状态也应可恢复:字段内容保留、焦点定位到首个错误、按钮退出 loading、页面级错误不会覆盖字段级提示。只有这样,严格门禁才不会变成输入惩罚。

十六、如何形成可追踪的验收证据

一次完整验收至少包含三层:

  1. 规则层:直接调用 validateDraft() 覆盖边界值,确认错误落在正确字段;
  2. 写入层:无效草稿无法进入 Repository,有效草稿只写入一次;
  3. 页面层:用户修正错误后能够继续,失败不会清空其他字段。

还应单独记录未验证项。单元测试通过不能证明最大字体布局,页面点击通过不能证明所有 Service 调用方都受保护,客户端规则通过更不能证明服务端内容安全。把 passedfailednot run 分开写,才能让后续维护者知道下一轮测试应该补在哪里。

十七、本文小结

表单校验的正确位置不是“页面或 Service 二选一”。页面负责即时反馈,ReportService 负责最终门禁,Repository 只处理已经通过业务规则的数据。

PublishValidation 提供字段级错误,日期检查验证真实日历值,隐私规则拦截明显联系方式,并诚实保留能力边界。下一篇将进入本地数据层,介绍 ReportDraftRepository 如何让三步表单在退出后恢复,同时安全管理图片 URI。

系列导航:第 10 篇 / 共 50 篇。上一篇:《三步结构化发布表单》;下一篇:《本地草稿恢复与图片 URI 生命周期》。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐