【寻迹校园 HarmonyOS NEXT 实战 10】表单校验应该放在哪里:ReportService 的隐私友好校验策略
【寻迹校园 HarmonyOS NEXT 实战 10】表单校验应该放在哪里:ReportService 的隐私友好校验策略
这是“寻迹校园 HarmonyOS NEXT 实战”系列第 10 篇。本文结合
PublishValidation与ReportService.validateDraft(),说明页面即时反馈、Service 最终门禁、隐私拦截、日期真实性和图片数量校验如何形成同一套可测试规则。

上图为本文原创生成的校验门禁概念图,不是项目截图。用户输入先经过页面提示,真正写入前仍必须由 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 正则,却不是有效日期。项目的日期校验分四步:
- 正则检查格式;
- 构造
Date并检查是否为NaN; - 把解析后的年、月、日与原字符串逐项比较;
- 把今天设为 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 决定是否允许写入,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 或堆栈直接显示在页面。
同一错误在页面、测试和日志中应有稳定分类,例如 VALIDATION、STORAGE 和 CAPABILITY。用户看到可理解文案,开发者通过受控日志定位原因。
十三、生产级扩展方向
当项目接入后端后,校验需要分层扩展:
- 客户端:即时反馈、基础格式、减少无效请求;
- Service:跨页面统一业务规则;
- 服务端:鉴权、幂等、最终数据约束;
- 内容安全:文本与图片检测;
- 人工治理:复杂边界和申诉;
- 数据库:非空、长度、枚举和唯一性约束。
任何一层都不能单独替代其他层。特别是客户端校验永远不能当成服务端安全边界。
十四、修改一条规则前先做影响分析
校验规则不是孤立的 if。例如把公开描述最小长度从 5 改成 10,至少会影响:
- 新建表单的按钮状态和字段提示;
- 编辑旧记录时能否重新保存;
- 草稿恢复后是否立刻显示错误;
- 自动化用例中的边界值;
- 种子数据和迁移数据是否仍合法;
- 服务端接入后的字段约束是否一致;
- 错误文案、帮助说明和无障碍朗读。
因此变更顺序应是:先写业务规则和兼容策略,再更新 PublishValidation 与测试,最后调整页面提示。不要先改输入框 maxLength,再让 Service 和旧数据被动追赶。
十五、失败处理、回滚与观测边界
校验失败属于预期业务结果,不应记录完整草稿或私密字段。日志可以保留错误分类、字段名和规则版本,但不能输出用户输入原文、图片 URI、联系方式或私密核验内容。
如果新版规则误伤正常输入,最小回滚是恢复对应 Service 规则与错误码,同时保留 Repository 数据。不要清空数据库,也不要临时绕过全部校验。对于已经存在但不满足新规则的旧记录,可以采用“允许查看、编辑时提示迁移”或定向兼容,而不是让所有旧数据突然消失。
用户看到的失败状态也应可恢复:字段内容保留、焦点定位到首个错误、按钮退出 loading、页面级错误不会覆盖字段级提示。只有这样,严格门禁才不会变成输入惩罚。
十六、如何形成可追踪的验收证据
一次完整验收至少包含三层:
- 规则层:直接调用
validateDraft()覆盖边界值,确认错误落在正确字段; - 写入层:无效草稿无法进入 Repository,有效草稿只写入一次;
- 页面层:用户修正错误后能够继续,失败不会清空其他字段。
还应单独记录未验证项。单元测试通过不能证明最大字体布局,页面点击通过不能证明所有 Service 调用方都受保护,客户端规则通过更不能证明服务端内容安全。把 passed、failed、not run 分开写,才能让后续维护者知道下一轮测试应该补在哪里。
十七、本文小结
表单校验的正确位置不是“页面或 Service 二选一”。页面负责即时反馈,ReportService 负责最终门禁,Repository 只处理已经通过业务规则的数据。
PublishValidation 提供字段级错误,日期检查验证真实日历值,隐私规则拦截明显联系方式,并诚实保留能力边界。下一篇将进入本地数据层,介绍 ReportDraftRepository 如何让三步表单在退出后恢复,同时安全管理图片 URI。
系列导航:第 10 篇 / 共 50 篇。上一篇:《三步结构化发布表单》;下一篇:《本地草稿恢复与图片 URI 生命周期》。
更多推荐


所有评论(0)