Use Cases (更新: 2026/6/1)

用 Claude Code 设计 Firestore Schema: GCP/Firebase SaaS 实战指南

从查询、Rules、索引和SaaS数据模型出发,用Claude Code设计可靠的Firestore架构。

用 Claude Code 设计 Firestore Schema: GCP/Firebase SaaS 实战指南

Firestore 设计不要先想表名,而要先想读取方式

我是 claudecode-lab.com 的 Masa。

刚开始用 Firestore 时,我也会自然地先建 usersprojectseventssubscriptions。这些名字看起来很干净,但真正做 SaaS 后,问题很快出现:项目列表要按最近活动排序,成员列表要显示名字和权限,事件日志要限制50条,试用期用户要被后台筛选,订阅状态又不能被客户端改写。集合名本身没有错,错的是没有先确认产品会怎样读取数据。

Firestore 的官方数据模型说明,它不是表和行,而是 collection 里面放 document。document 里可以有字段、对象,也可以有 subcollection。可以把 collection 理解成书架,document 是书架上的档案,subcollection 是档案里面的小文件夹。

这很灵活,但不能像关系型数据库那样事后靠 JOIN 解决所有问题。Firestore 的设计要从查询开始:哪个页面需要列表,按哪个字段过滤,按什么排序,谁可以读,需要什么 composite index,Security Rules 是否能证明这个查询安全。Claude Code 最适合做的不是替你拍脑袋生成结构,而是把 schema、rules、index、query 放在一起找矛盾。


先把 collection、document、subcollection 说清楚

一个普通 B2B SaaS 可以先从下面的路径开始。

users/{uid}
projects/{projectId}
projects/{projectId}/members/{uid}
projects/{projectId}/events/{eventId}
subscriptions/{uid}
billingCustomers/{uid}
路径作用常见读取方式
users/{uid}用户邮箱、显示名、创建时间当前用户资料
projects/{projectId}工作区、客户项目、团队空间项目详情
projects/{projectId}/members/{uid}项目内权限成员列表、权限判断
projects/{projectId}/events/{eventId}操作日志、通知、审计记录某项目最近事件
subscriptions/{uid}订阅状态、plan、试用期功能开关和付费判断
billingCustomers/{uid}Stripe 等计费系统ID仅服务器使用

这里的关键不是“嵌套越深越专业”。关键是读取路径是否自然。如果最常见页面是“某项目最近50条事件”,那么 projects/{projectId}/events 很合理。如果还需要跨项目搜索事件,就再考虑 collection group query,而不是一开始把所有东西都摊平成一个巨大集合。

我会这样让 Claude Code 先做查询清单:

claude -p "
请审查一个B2B SaaS的Firestore设计。
不要先提collection,请先按页面列出查询。

页面:
- 当前用户所属项目列表
- 项目详情
- 项目内最近50条事件
- 管理员查看订阅状态
- 试用期即将结束的用户

请为每个页面列出 where / orderBy / limit / 必要的Composite index / Security Rules条件。
"

这个提示词会把讨论拉回产品行为,而不是抽象的数据结构。


SaaS 的用户、项目、事件、订阅 schema 示例

下面是适合 Firebase Admin SDK 或 Cloud Functions 的 TypeScript 类型。即使你允许前端直接写部分文档,也应该先用这些类型定义清楚边界。

import type { Timestamp } from "firebase-admin/firestore";

export type ProjectRole = "owner" | "admin" | "member" | "viewer";
export type SubscriptionStatus =
  | "trialing"
  | "active"
  | "past_due"
  | "canceled";

export interface ProjectDoc {
  id: string;
  name: string;
  ownerUid: string;
  plan: "free" | "starter" | "pro";
  memberCount: number;
  lastEventAt: Timestamp | null;
  createdAt: Timestamp;
  updatedAt: Timestamp;
}

export interface ProjectMemberDoc {
  uid: string;
  role: ProjectRole;
  displayName: string;
  email: string;
  joinedAt: Timestamp;
}

export interface ProjectEventDoc {
  id: string;
  projectId: string;
  actorUid: string;
  actorName: string;
  type: "created" | "updated" | "commented" | "exported";
  message: string;
  createdAt: Timestamp;
}

export interface SubscriptionDoc {
  uid: string;
  status: SubscriptionStatus;
  plan: "free" | "starter" | "pro";
  trialEndsAt: Timestamp | null;
  updatedAt: Timestamp;
}

ProjectMemberDoc 里保存 displayNameemail 是有意的反规范化。反规范化就是把少量显示用数据复制到另一个文档。Firestore 中这通常是合理的,因为成员列表如果再去读50个 users/{uid},读取次数和费用都会增加。复制名字会带来同步成本,但列表页面会更稳定。

第一个实例是首页项目列表。为了让登录后的首页只读一次,可以添加:

users/{uid}/projectRefs/{projectId}
  projectId: string
  projectName: string
  role: "owner" | "admin" | "member" | "viewer"
  lastEventAt: Timestamp | null

这不是“最纯净”的模型,但它符合 Firestore 的现实:用少量重复换取可预测的读取。


Security Rules 不是过滤器

这是最容易出事故的点。官方的安全查询文档明确说明,Security Rules are not filters。查询不是“返回允许看的部分”,而是 all or nothing。如果一个查询可能返回无权读取的文档,整个查询会失败。

例如下面的规则要求用户是项目成员,并且列表查询必须限制最多50条。

rules_version = '2';

service cloud.firestore {
  match /databases/{database}/documents {
    match /projects/{projectId}/events/{eventId} {
      allow list: if request.auth != null
        && exists(/databases/$(database)/documents/projects/$(projectId)/members/$(request.auth.uid))
        && request.query.limit <= 50;
    }
  }
}

下面的查询会失败,因为它没有 limit。

import { collection, getDocs } from "firebase/firestore";

await getDocs(collection(db, "projects", projectId, "events"));

应该让查询和规则一致。

import {
  collection,
  getDocs,
  limit,
  orderBy,
  query,
} from "firebase/firestore";

export async function listProjectEvents(projectId: string) {
  const eventsQuery = query(
    collection(db, "projects", projectId, "events"),
    orderBy("createdAt", "desc"),
    limit(50),
  );

  const snap = await getDocs(eventsQuery);
  return snap.docs.map((doc) => ({ id: doc.id, ...doc.data() }));
}

第二个实例是公开内容。如果 Rules 要求 visibility == "public",查询也必须带 where("visibility", "==", "public")。不要期待 Firestore 自动帮你过滤掉私有文档。


Composite index 与 collection group query 要一起设计

Firestore 会自动创建基础索引,但多个 where、orderBy、array 条件组合时通常需要 composite index。官方的索引管理文档说明,缺少索引时会返回可创建索引的错误链接。开发时这很方便,但生产项目最好把 firestore.indexes.json 放进 Git。

{
  "indexes": [
    {
      "collectionGroup": "events",
      "queryScope": "COLLECTION_GROUP",
      "fields": [
        { "fieldPath": "projectId", "order": "ASCENDING" },
        { "fieldPath": "createdAt", "order": "DESCENDING" }
      ]
    },
    {
      "collectionGroup": "subscriptions",
      "queryScope": "COLLECTION",
      "fields": [
        { "fieldPath": "status", "order": "ASCENDING" },
        { "fieldPath": "trialEndsAt", "order": "ASCENDING" }
      ]
    }
  ],
  "fieldOverrides": []
}

collection group query 可以跨所有同名 subcollection 查询。例如所有项目下的 events

import {
  collectionGroup,
  getDocs,
  limit,
  orderBy,
  query,
  where,
} from "firebase/firestore";

export async function listRecentEventsAcrossProjects(projectId: string) {
  const eventsQuery = query(
    collectionGroup(db, "events"),
    where("projectId", "==", projectId),
    orderBy("createdAt", "desc"),
    limit(50),
  );

  const snap = await getDocs(eventsQuery);
  return snap.docs.map((doc) => ({ id: doc.id, ...doc.data() }));
}

规则也要对应。官方的Rules结构文档说明,match 指向的是 document path,不是 collection。collection group query 通常需要 rules_version = '2' 和递归通配符。

rules_version = '2';

service cloud.firestore {
  match /databases/{database}/documents {
    function signedIn() {
      return request.auth != null;
    }

    function isProjectMember(projectId) {
      return signedIn()
        && exists(/databases/$(database)/documents/projects/$(projectId)/members/$(request.auth.uid));
    }

    match /{path=**}/events/{eventId} {
      allow list: if signedIn()
        && request.query.limit <= 50
        && resource.data.projectId is string
        && isProjectMember(resource.data.projectId);
    }
  }
}

第三个实例是订阅状态。不要让客户端更新 subscriptions/{uid};应该只允许 webhook 或 Cloud Functions 在服务器端写入。

rules_version = '2';

service cloud.firestore {
  match /databases/{database}/documents {
    match /subscriptions/{uid} {
      allow get: if request.auth != null && request.auth.uid == uid;
      allow list: if false;
      allow create, update, delete: if false;
    }
  }
}

让 Claude Code 做本地设计审查

在写代码之前,让 Claude Code 先做一轮审查往往收效更大。我会先在仓库里放好 docs/firestore-schema.mdfirestore.rulesfirestore.indexes.jsonsrc/lib/firestore/queries.ts,然后这样请求。

claude -p "
请本地审查Firestore设计。
文件:
- docs/firestore-schema.md
- firestore.rules
- firestore.indexes.json
- src/lib/firestore/queries.ts

检查:
1. 每个页面查询是否和schema一致
2. 是否把Security Rules误当过滤器
3. list查询是否包含必要的where/orderBy/limit
4. Composite index是否缺失或过多
5. collection group query是否过宽
6. 客户端是否能篡改订阅状态
7. 哪个页面读取次数过多

请输出问题、原因和修正代码。
"

常见失败有四类。第一,用连续ID或日期ID作为document ID。通常使用自动ID更安全,slug单独保存。第二,Rules要求的条件没有写进查询。第三,把付费状态混进用户资料,导致权限边界模糊。第四,把不同含义的日志都命名为 events,以后 collection group query 和 Rules 会变得危险。

Masa的验证笔记:我把这个流程试在工单管理、内容后台、一个小SaaS demo上。先写collection的版本经常返工;先让Claude Code生成查询表,再审查rules和index的版本,返工明显少。Firestore看起来由schema、query、rules、index组成,但在实际项目里它们是一件事。

如果你正在做GCP集成,也可以继续看 Claude Code x GCP Cloud FunctionsClaude Code x GCP Cloud Run。API边界还不清楚时,用Claude Code设计REST API 会很有帮助。ClaudeCodeLab也会把这些设计检查表整理成免费PDF、教材和咨询流程;带着schema、rules、query列表来咨询,能更快进入具体的审查环节。

#claude-code #gcp #firestore #database #typescript #query-design
免费

免费 PDF: Claude Code 速查表

输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。

我们会妥善保护你的信息,不发送垃圾邮件。

把 Claude Code 变成真正能带来结果的工作流

先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。

Masa

关于作者

Masa

专注 Claude Code 实务流程、团队导入和内容转化的工程师。