【OpenHarmony/HarmonyOS】从本地模型到云端 Schema:AGC 对象类型、字段映射与版本治理
把一个 ArkTS class 放进
common/models,并不代表云数据库已经接通;拥有一份cloud_db_schema.json,也不代表客户端生成类与云端控制台保持一致。在“迷宫坦克派对”中,Cloud DB 对象类型、生成类、ObjectTypeInfoHelper和 AGC 排行榜指南已经形成了清晰的接入骨架,但仓库内也真实存在字段缺失、时间类型漂移和权限边界偏宽等问题。本文不把原型写成上线能力,而是从现有文件出发,讲透 Schema 如何成为跨端契约。☁️
一、先说明当前能力边界
项目当前本地排行榜由ScoreManager与 Preferences 承担。docs/AGC_Leaderboard_Guide.md的开头也明确写着:未来计划接入 AGC,目前项目使用本地ScoreManager进行模拟;示例依赖和 API 还要求接入时查询实际版本。
仓库同时存在:
cloud_db_schema.json:四个 Cloud DB 对象类型的 JSON 描述;common/models/*.ts:Cloud DB ObjectType 编译器生成的类;ObjectTypeInfoHelper.ts:客户端对象类型、字段、索引与版本信息;AGC_Leaderboard_Guide.md:从本地排行演进到云排行的设计指南。
因此准确表述应是“已有云端对象模型和接入资料”,而不是“已完成生产级云排行”。Cloud DB 对象存储与 AGC 游戏排行榜也是两个不同方向:前者可保存自定义实体,后者是面向排行榜业务的服务。选型前要先确定需求,不能因为都属于 AGC 就混为同一套 API。
二、四个对象类型各自解决什么问题?
| 对象类型 | 主键 | 主要字段 | 设计意图 |
|---|---|---|---|
PlayerStats | uid | level、exp、winRate、bestScore、updatedAt | 玩家成长与最佳成绩 |
MatchRequest | requestId | uid、status、roomId、createdAt | 匹配队列请求 |
GameRoom | roomId | playerA、playerB、state、lastFrameData | 双方房间状态 |
BattleRecord | recordId | winnerId、loserId、duration、timestamp | 对局结果记录 |
这是一种典型的“玩家 → 匹配请求 → 房间 → 战绩”链路:
flowchart LRA["PlayerStats 玩家"]-->B["MatchRequest 匹配请求"]B--> C["GameRoom 房间"]C --> D["BattleRecord 战绩"]D -->A这张图表达的是对象设计关系,不代表当前客户端已经跑通完整云端匹配。尤其是真人 3v3、服务端权威判定和生产排行,在仓库中仍没有完整闭环。
三、ObjectTypeInfoHelper 是客户端侧契约
ObjectTypeInfoHelper.getObjectTypeInfo()返回对象类型元数据,其中包括:
objectTypeName:云端对象名称;objectTypeClass:对应 ArkTS 类;fields:字段类型、主键、非空和默认值;indexes:索引名称、字段与排序方向;schemaVersion:当前对象类型版本。
当前文件末尾是:
return {"objectTypes": [//BattleRecord/ PlayerStats /GameRoom / MatchRequest ],"schemaVersion":7};版本号为 7,说明这份生成元数据并非“第一版随手定义”。问题在于,仓库里另一份 JSON Schema 与它并不完全一致。如果不知道哪一份是从控制台导出的权威版本,仅看schemaVersion无法保证契约正确。
四、真实差异一:BattleRecord 的时间类型漂移 ⚠️
根目录cloud_db_schema.json声明:
{"fieldName":"duration","fieldType":"Long"},{"fieldName":"timestamp","fieldType":"Long","notNull":true}但ObjectTypeInfoHelper.ts中二者都是String:
"duration":{"fieldName":"duration","fieldType":"String","isPrimaryKey":false,"notNull":false},"timestamp":{"fieldName":"timestamp","fieldType":"String","isPrimaryKey":false,"notNull":true,"defaultValue":"0"}生成的BattleRecord.ts也使用string。三份契约对照如下:
| 字段 | JSON Schema | Helper | ArkTS 生成类 |
|---|---|---|---|
| duration | Long | String | string |
| timestamp | Long | String | string |
这种漂移可能导致写入失败、排序异常、历史数据转换困难,或不同开发环境生成出不兼容代码。timestamp如果以字符串排序,"100"可能排在"20"前面;如果字符串还混入日期格式,问题会更严重。
建议统一为带单位的数值字段,例如durationMs与timestampMs。字段迁移前先确认云端实际数据类型,不要仅修改本地 JSON 后假设云端随之变化。
五、真实差异二:PlayerStats 多了两个字段
生成的PlayerStats.ts与 Helper 都包含:
userName:string="昵称";avatar:string="头像资源路径";Helper 中也有对应的String字段和默认值。但是根目录cloud_db_schema.json的PlayerStats只到updatedAt,没有userName和avatar。
| 字段 | JSON Schema | Helper/生成类 | 风险 |
|---|---|---|---|
| userName | 不存在 | 存在 | 客户端认为可写,云端可能拒绝或忽略 |
| avatar | 不存在 | 存在 | 头像映射无法跨端保持一致 |
这里不能简单下结论说“JSON 一定旧”或“生成类一定错”,因为仓库无法证明哪一次控制台导出更晚。正确动作是建立来源信息:每次生成记录控制台环境、Schema 版本、导出时间与生成工具版本,然后只允许权威源生成其他文件。
六、索引不是装饰:它必须对应查询模式
当前 Helper 给PlayerStats.bestScore定义了降序索引:
"indexes": [ {"indexName":"index_score_desc","indexList": [ {"fieldName":"bestScore","sortType":"DESC"} ] } ]这个索引适合“按最高分倒序取前 N 名”。但根目录 JSON Schema 的PlayerStats没有保存该索引,仍是一处差异。
MatchRequest则定义了(status ASC, createdAt ASC)复合索引:
"indexName":"index_status_created","indexList": [ {"fieldName":"status","sortType":"ASC"}, {"fieldName":"createdAt","sortType":"ASC"} ]它对应“筛选 matching 状态,再按最早请求优先”的队列查询。字段顺序非常关键:如果实际查询只按createdAt,或经常先按 uid 查请求,这个索引不一定覆盖。
索引设计应从查询清单反推:
| 查询 | 过滤 | 排序 | 建议索引 |
|---|---|---|---|
| 全球最高分 | 无/赛季 | bestScore DESC | score 或 season+score |
| 待匹配请求 | status=matching | createdAt ASC | status+createdAt |
| 玩家最近战绩 | playerId | timestamp DESC | playerId+timestamp |
| 房间恢复 | roomId | 无 | 主键已覆盖 |
当前BattleRecord没有playerId + timestamp一类索引,而它又拆成 winner/loser 两个字段。若未来要查“我的全部战绩”,可能需要调整数据模型或分别查询再合并。
七、权限模型需要和“谁拥有这条数据”对齐
cloud_db_schema.json为四类对象配置了相同权限:
"permissions": [ {"role":"World","rights": ["Read"] }, {"role":"Authenticated","rights": ["Read","Upsert","Delete"] } ]从文件字面看,世界角色可读,已认证角色可读、写入和删除。对于公开排行榜,世界可读可能符合展示需求;但如果“任何已认证用户”都能更新任意PlayerStats、删除战绩或修改房间,就不符合最小权限原则。
生产设计至少应回答:
- 用户是否只能写自己的
uid记录? bestScore是否允许客户端直接提交?BattleRecord由客户端还是可信服务端创建?- 匹配请求能否被其他用户删除?
lastFrameData是否含有不应公开的网络或会话信息?
权限不能只在客户端校验,因为修改客户端即可绕过。高价值分数、奖励与胜负结果应有服务端验证、签名事件或可信计算链路。本文提出的是演进要求,不表示仓库当前已经部署了相应服务端。
八、不要把客户端分数天然当成可信数据
当前GameStats.score由本地引擎计算,结算后交给本地ScoreManager。将这一路径直接换成云写入,能实现多设备展示,却不能自动防作弊。
sequenceDiagram participant Cas"客户端"participant Vas"校验服务"participant Das"Cloud DB/排行榜"C->>V: 提交局号、事件摘要、分数、幂等键 V->>V: 校验身份、时长、规则与重复提交 alt 校验通过 V->>D: 写入权威战绩/更新最佳分 D-->>C: 返回排名结果else校验失败 V-->>C: 返回稳定错误码end轻量项目可以先接受“娱乐性排行榜”的弱可信度,但要在产品说明中承认边界,并把奖励发放与排行榜展示分离。只要排行关联虚拟资产或竞赛奖励,服务端权威就不再是可选优化。
九、生成文件为什么不应该手改?
四个模型文件头都有DO NOT EDIT。直接把duration: string改成number,短期能让本地编译通过,却会制造新的三方不一致:
云端实际对象类型 ≠ 本地JSON≠ 手改生成类正确链路应该是:
flowchart LRA["权威 Schema"]-->B["对象类型编译器"]B--> C["生成模型类"]B--> D["ObjectTypeInfoHelper"]C --> E["客户端构建"]D --> EA--> F["Schema 契约测试"]C --> F D --> F如果必须在业务中使用更友好的字段名或类型,应新增 Mapper/DTO,而不是让生成层承担显示格式和领域规则。
十、Schema 演进要区分兼容与破坏性变化
| 变更 | 通常兼容性 | 处理建议 |
|---|---|---|
| 新增可空字段 | 较好 | 客户端提供默认回退 |
| 新增非空字段 | 有风险 | 先补历史数据,再收紧约束 |
| 字符串改 Long | 破坏性 | 双写/迁移/灰度读 |
| 删除字段 | 破坏性 | 先停止写入,跨版本观察后删除 |
| 修改主键 | 高风险 | 新对象类型或完整迁移 |
| 修改索引 | 影响性能 | 先验证查询与数据量 |
| 收紧权限 | 影响旧客户端 | 提供版本门槛和错误处理 |
以BattleRecord.timestamp为例,推荐采用扩展迁移,而不是原地强转:
- 新增
timestampMsLong 字段; - 新客户端双写旧字段与新字段;
- 后台任务回填历史记录;
- 读路径优先新字段,缺失时解析旧字段;
- 观察旧客户端占比;
- 停止旧字段写入并最终清理。
如果数据尚未真实上线,可直接统一 Schema 并重新生成,但仍应保留一次契约检查,避免下次再次漂移。
十一、客户端 Mapper 隔离云模型
页面不应直接依赖 Cloud DB 生成类的每一个细节。可以将云模型转换为应用模型:
interface LeaderboardItem { uid: string; displayName: string; avatarId: string; bestScore: number; updatedAtMs: number; } function toLeaderboardItem(source: PlayerStats): LeaderboardItem { return { uid: source.getUid(), displayName: source.getUserName() ||'玩家', avatarId: source.getAvatar() ||'avatar_default', bestScore: Math.max(0, source.getBestScore()), updatedAtMs: Math.max(0, source.getUpdatedAt()) }; }这样,即使云端默认值、字段名或 SDK 对象形式变化,ArkUI 页面仍消费稳定的LeaderboardItem。Mapper 也是处理userName/avatar是否存在、时间单位和默认头像的唯一位置。
十二、本地 + 云端的同步策略
AGC 指南提出断网或未登录时使用本地排行,登录后展示云端排行。要让它真正可用,还需定义:
- 写策略:本地先写还是云端先写;
- 离线队列:失败提交保存哪些字段,何时重试;
- 幂等键:同一局重试不能产生多条战绩;
- 冲突策略:最高分可取 max,昵称则需按版本/更新时间处理;
- 读取状态:缓存数据、刷新中、云端失败分别如何显示;
- 退出登录:是否清除云缓存,如何保留本地个人数据。
最高分适合使用单调合并:bestScore = max(local, cloud),但总局数、胜率不能简单取最大值。它们需要基于不可重复的对局记录聚合,否则多设备同步会重复计数。
十三、把 Schema 契约检查放进 CI 🧪
这次仓库中的差异完全可以自动发现。检查器至少应比较:
对象类型集合 ├─ 字段名集合 ├─ 字段类型 ├─ 主键与notNull├─ 默认值 ├─ 索引字段及顺序 └─Schema版本/来源元数据推荐测试用例:
| 用例 | 失败条件 |
|---|---|
| BattleRecord 类型契约 | JSON 为 Long、Helper 为 String |
| PlayerStats 字段契约 | 一侧缺 userName/avatar |
| 索引契约 | JSON 缺失 bestScore DESC |
| 默认值契约 | 空字符串生成成字面量双引号 |
| 映射往返 | Long 时间转换后精度丢失 |
| 旧数据读取 | 新增字段导致旧记录无法展示 |
生成代码可不参与普通风格检查,但必须参与契约检查和编译检查。“生成的”不等于“天然正确”,它只意味着错误可能来自上游 Schema 或生成输入。
十四、上线前检查清单 ✅
- 明确 Cloud DB 与游戏排行榜服务的选型,不混用概念;
- 确认哪份 Schema 是唯一权威源;
- 统一
duration/timestamp的 Long/String 类型; - 对齐
PlayerStats.userName/avatar字段; - 修正带额外引号的字符串默认值;
- 复核
bestScore与匹配队列索引; - 将权限收敛到数据所有者和可信服务;
- 建立本地缓存、离线队列和幂等策略;
- 对客户端分数定义可信度与防作弊边界;
- 用测试环境验证旧版/新版客户端兼容;
- 发布前运行 Schema 契约检查;
- 日志中不记录 Token、完整设备信息和个人数据。
十五、总结 ✨
“迷宫坦克派对”已经为云端演进准备了四个对象类型、生成类、Helper、索引和一份 AGC 接入指南,这是值得继续完善的工程基础。但源码也明确告诉我们:BattleRecord的两个时间字段存在 Long/String 漂移,PlayerStats的userName/avatar在两份 Schema 中不一致,索引和权限也需要以真实查询和数据所有权重新审视。
从本地模型走向云端,最重要的不是多写一个上传方法,而是建立唯一权威 Schema、可重复的代码生成、DTO/Mapper 隔离、版本迁移、最小权限、离线幂等与契约测试。完成这些之前,应把云排行称为“接入设计”或“原型准备”;完成这些之后,云端数据才有机会成为可维护、可升级、可解释的生产契约。🚀
推荐标签:OpenHarmonyHarmonyOSArkTSAppGalleryConnectCloudDBSchema云数据库版本治理