【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 LR
  A["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" 前面;如果字符串还混入日期格式,问题会更严重。

建议统一为带单位的数值字段,例如 durationMstimestampMs。字段迁移前先确认云端实际数据类型,不要仅修改本地 JSON 后假设云端随之变化。

五、真实差异二:PlayerStats 多了两个字段

生成的 PlayerStats.ts 与 Helper 都包含:

userName: string = "昵称";
avatar: string = "头像资源路径";

Helper 中也有对应的 String 字段和默认值。但是根目录 cloud_db_schema.jsonPlayerStats 只到 updatedAt,没有 userNameavatar

字段 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、删除战绩或修改房间,就不符合最小权限原则。

生产设计至少应回答:

  1. 用户是否只能写自己的 uid 记录?
  2. bestScore 是否允许客户端直接提交?
  3. BattleRecord 由客户端还是可信服务端创建?
  4. 匹配请求能否被其他用户删除?
  5. lastFrameData 是否含有不应公开的网络或会话信息?

权限不能只在客户端校验,因为修改客户端即可绕过。高价值分数、奖励与胜负结果应有服务端验证、签名事件或可信计算链路。本文提出的是演进要求,不表示仓库当前已经部署了相应服务端。

八、不要把客户端分数天然当成可信数据

当前 GameStats.score 由本地引擎计算,结算后交给本地 ScoreManager。将这一路径直接换成云写入,能实现多设备展示,却不能自动防作弊。

sequenceDiagram
  participant C as "客户端"
  participant V as "校验服务"
  participant D as "Cloud DB/排行榜"
  C->>V: 提交局号、事件摘要、分数、幂等键
  V->>V: 校验身份、时长、规则与重复提交
  alt 校验通过
    V->>D: 写入权威战绩/更新最佳分
    D-->>C: 返回排名结果
  else 校验失败
    V-->>C: 返回稳定错误码
  end

轻量项目可以先接受“娱乐性排行榜”的弱可信度,但要在产品说明中承认边界,并把奖励发放与排行榜展示分离。只要排行关联虚拟资产或竞赛奖励,服务端权威就不再是可选优化。

九、生成文件为什么不应该手改?

四个模型文件头都有 DO NOT EDIT。直接把 duration: string 改成 number,短期能让本地编译通过,却会制造新的三方不一致:

云端实际对象类型 ≠ 本地 JSON ≠ 手改生成类

正确链路应该是:

flowchart LR
  A["权威 Schema"] --> B["对象类型编译器"]
  B --> C["生成模型类"]
  B --> D["ObjectTypeInfoHelper"]
  C --> E["客户端构建"]
  D --> E
  A --> F["Schema 契约测试"]
  C --> F
  D --> F

如果必须在业务中使用更友好的字段名或类型,应新增 Mapper/DTO,而不是让生成层承担显示格式和领域规则。

十、Schema 演进要区分兼容与破坏性变化

变更 通常兼容性 处理建议
新增可空字段 较好 客户端提供默认回退
新增非空字段 有风险 先补历史数据,再收紧约束
字符串改 Long 破坏性 双写/迁移/灰度读
删除字段 破坏性 先停止写入,跨版本观察后删除
修改主键 高风险 新对象类型或完整迁移
修改索引 影响性能 先验证查询与数据量
收紧权限 影响旧客户端 提供版本门槛和错误处理

BattleRecord.timestamp 为例,推荐采用扩展迁移,而不是原地强转:

  1. 新增 timestampMs Long 字段;
  2. 新客户端双写旧字段与新字段;
  3. 后台任务回填历史记录;
  4. 读路径优先新字段,缺失时解析旧字段;
  5. 观察旧客户端占比;
  6. 停止旧字段写入并最终清理。

如果数据尚未真实上线,可直接统一 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 漂移,PlayerStatsuserName/avatar 在两份 Schema 中不一致,索引和权限也需要以真实查询和数据所有权重新审视。

从本地模型走向云端,最重要的不是多写一个上传方法,而是建立唯一权威 Schema、可重复的代码生成、DTO/Mapper 隔离、版本迁移、最小权限、离线幂等与契约测试。完成这些之前,应把云排行称为“接入设计”或“原型准备”;完成这些之后,云端数据才有机会成为可维护、可升级、可解释的生产契约。🚀


推荐标签: OpenHarmony HarmonyOS ArkTS AppGalleryConnect CloudDB Schema 云数据库 版本治理

img

img

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐