用 Claude Code 设计 Firestore Schema: GCP/Firebase SaaS 实战指南
从查询、Rules、索引和SaaS数据模型出发,用Claude Code设计可靠的Firestore架构。
Firestore 设计不要先想表名,而要先想读取方式
我是 claudecode-lab.com 的 Masa。
刚开始用 Firestore 时,我也会自然地先建 users、projects、events、subscriptions。这些名字看起来很干净,但真正做 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 里保存 displayName 和 email 是有意的反规范化。反规范化就是把少量显示用数据复制到另一个文档。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.md、firestore.rules、firestore.indexes.json 和 src/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 Functions 和 Claude Code x GCP Cloud Run。API边界还不清楚时,用Claude Code设计REST API 会很有帮助。ClaudeCodeLab也会把这些设计检查表整理成免费PDF、教材和咨询流程;带着schema、rules、query列表来咨询,能更快进入具体的审查环节。
免费 PDF: Claude Code 速查表
输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。
我们会妥善保护你的信息,不发送垃圾邮件。
把 Claude Code 变成真正能带来结果的工作流
先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。
关于作者
Masa
专注 Claude Code 实务流程、团队导入和内容转化的工程师。
相关文章
从Obsidian到CLAUDE.md的Claude Code流程:不再反复解释上下文
把 Obsidian 工作笔记整理成 CLAUDE.md 运行说明,让 Claude Code 每次都带着正确上下文开始。
Claude Code 收入 CTA 路由:从文章分流到 PDF、Gumroad 与咨询
用 Claude Code 按读者意图把文章流量分到免费 PDF、Gumroad 教材或咨询入口。
Claude Code 团队交接规则: 把审查证据、权限、回滚和收入路径一起交付
面向团队的 Claude Code 交接格式: 证据、权限、回滚、免费 PDF、Gumroad 与咨询路径都要可审查。