实战教程:电商小程序
阅读前建议先了解 PG 模式概述,明确 PostgreSQL、身份认证、云存储和权限模型的关系。
本文以一个电商小程序为完整实战场景,带你端到端完成 CloudBase PostgreSQL 数据库下的数据建模、权限设计、REST API 调用的完整链路。覆盖的能力:
- 多表建模与外键约束
- 双层权限(GRANT + RLS)的综合运用
- 不同角色(
anon/authenticated/service_role)的差异化访问 - PostgREST REST API 的完整使用(筛选、排序、分页、Prefer)
- 基于
is_active的软下架、基于 JWT 的下单身份绑定、"订单不可改"等典型业务规则
💡 读完本文后,你就可以把同样的思路套用到博客、私人笔记、SaaS、社交等各种应用中。
场景概述
构建一个类似 Shopify / 有赞 / 小程序商城 的电商应用,需要:
| 功能 | 说明 |
|---|---|
| 🏪 商品浏览 | 所有人(包括未登录用户)可浏览上架商品 |
| 🔒 下架商品隐藏 | is_active=false 的商品对普通用户不可见 |
| 🛒 买家下单 | 登录用户可创建订单,买家身份自动绑定 |
| 👁️ 订单私有 | 每个买家只能看到自己的订单 |
| 🚫 订单不可改 | 买家下单后不能修改或取消,只有管理员可变更状态 |
| 👨💼 管理员全权 | 管理员可管理商品、处理所有订单状态流转 |
数据模型
┌────────────────────────┐ ┌─────────────────────────┐
│ products │ │ orders │
├────────────────────────┤ ├─────────────────────────┤
│ id (serial PK) │◄──┐ │ id (serial PK) │
│ name (text) │ │ │ product_id (int FK) │───┘
│ description (text) │ │ │ buyer_id (text, JWT) │
│ price (numeric) │ └──┤ quantity (int) │
│ stock (int) │ │ total_price (numeric) │
│ category (text) │ │ status (text) │
│ is_active (bool) │ │ address (jsonb) │
│ created_at (timestamptz)│ │ created_at (timestamptz)│
└────────────────────────┘ └─────────────────────────┘
前置准备
- 已 创建 PG 模式的云开发环境
- 已拿到 Publishable Key 和 API Key(详见 PG:身份认证 - 凭证与 JWT)
- 环境 ID 记为
<envId>,例如pg-test-3gxmdbdb580ecfd1 - API 端点:
- REST:
https://<envId>.api.tcloudbasegateway.com/v1/rdb/rest/v1 - Auth:
https://<envId>.api.tcloudbasegateway.com/auth/v1
- REST:
SQL 执行顺序
以下 SQL 严格按以下顺序执行,否则会因依赖关系报错:
建表 (CREATE TABLE)
→ 启用 RLS (ALTER TABLE ... ENABLE ROW LEVEL SECURITY)
→ 授权 (GRANT)
→ 创建策略 (CREATE POLICY)
清理时需反序:先删 Policy → 再解除 RLS → 最后删表(DROP TABLE ... CASCADE 可自动清理 Policy)。
🔧 SQL 可通过控制台的 SQL 编辑器 直接逐条执行,也可通过云 API
ExecutePGSql自动化部署(部分 DDL 在 API 路径下需用DO $$ BEGIN EXECUTE '...'; END $$包装重试,详见 架构与权限模型 - ExecutePGSql)。
第一步:建表
-- ═══════════════════════════════════════════════════
-- 商品表
-- ═══════════════════════════════════════════════════
CREATE TABLE public.products (
id serial PRIMARY KEY,
name text NOT NULL,
description text,
price numeric(10,2) NOT NULL,
stock int DEFAULT 0,
category text,
is_active boolean DEFAULT true, -- true=上架, false=下架
created_at timestamptz DEFAULT now()
);
-- ═══════════════════════════════════════════════════
-- 订单表
-- ═══════════════════════════════════════════════════
CREATE TABLE public.orders (
id serial PRIMARY KEY,
product_id int NOT NULL REFERENCES public.products(id),
-- ⭐ buyer_id 自动从 JWT 中获取当前用户的 sub(auth.users.id 类型为 varchar(64))
buyer_id varchar(64) NOT NULL
DEFAULT (current_setting('request.jwt.claims', true)::json->>'sub'),
quantity int NOT NULL DEFAULT 1,
total_price numeric(10,2) NOT NULL,
status text NOT NULL DEFAULT 'pending', -- pending → paid → shipped → completed
address jsonb, -- 收货地址(结构化 JSON)
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now()
);
CREATE INDEX idx_orders_buyer_id ON public.orders(buyer_id);
关键设计:
buyer_id使用DEFAULT (current_setting('request.jwt.claims', true)::json->>'sub')自动从 JWT 中获取当前用户 ID,无需前端传入,杜绝了身份伪造的可能。
第二步:启用 RLS
ALTER TABLE public.products ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.orders ENABLE ROW LEVEL SECURITY;
启用 RLS 后,如果没有任何 Policy,所有非
service_role用户都无法访问任何数据(默认拒绝) 。继续下一步。
第三步:授予表级权限(GRANT)
-- ═══════════════════════════════════════════════════
-- products(商品表)— 所有人可读,仅管理员可写
-- ═══════════════════════════════════════════════════
-- 匿名用户:只能查看
GRANT SELECT ON public.products TO anon;
-- 已认证用户:也只能查看(普通用户不能操作商品)
GRANT SELECT ON public.products TO authenticated;
-- 管理 员:全部权限(BYPASSRLS 自动绕过 RLS)
GRANT ALL ON public.products TO service_role;
GRANT USAGE, SELECT ON SEQUENCE public.products_id_seq TO service_role;
-- ═══════════════════════════════════════════════════
-- orders(订单表)— 买家可查可下单,不可改不可删
-- ═══════════════════════════════════════════════════
-- 已认证用户:可查看 + 可下单,但不能修改和删除
GRANT SELECT, INSERT ON public.orders TO authenticated;
GRANT USAGE, SELECT ON SEQUENCE public.orders_id_seq TO authenticated;
-- 管理员:全部权限
GRANT ALL ON public.orders TO service_role;
GRANT USAGE, SELECT ON SEQUENCE public.orders_id_seq TO service_role;
-- 匿名用户:不授予 orders 任何权限(未登录不能操作订单)
💡 使用
serial/bigserial主键时,必须同时授予对应 SEQUENCE 的USAGE权限,否则 INSERT 会因为无法获取下一个自增值而失败。
第四步:创建 RLS 策略(Policy)
-- ═══════════════════════════════════════════════════
-- products 的 RLS Policy
-- ═══════════════════════════════════════════════════
-- 所有人只能看到上架商品(is_active = true)
CREATE POLICY products_select
ON public.products
FOR SELECT
USING (is_active = true);
-- ⚠️ 不需要创建 INSERT/UPDATE/DELETE 的 Policy
-- 因为 anon 和 authenticated 在表级权限上就没有写权限
-- service_role 拥有 BYPASSRLS,自动绕过所有 Policy
-- ═══════════════════════════════════════════════════
-- orders 的 RLS Policy
-- ═══════════════════════════════════════════════════
-- SELECT: 买家只能看到自己的订单
CREATE POLICY orders_select
ON public.orders
FOR SELECT
TO authenticated
USING (
buyer_id = (current_setting('request.jwt.claims', true)::json->>'sub')
);
-- INSERT: 买家下单时,buyer_id 必须是自己
CREATE POLICY orders_insert
ON public.orders
FOR INSERT
TO authenticated
WITH CHECK (
buyer_id = (current_setting('request.jwt.claims', true)::json->>'sub')
);
-- ⚠️ 故意不给 authenticated 创建 UPDATE/DELETE Policy
-- 配合表级权限(也没授予 UPDATE/DELETE),实现"订单不可改"的业务规则
TO <role> 的语义RLS Policy 的 TO <role> 子句用于限定该策略只对指定角色生效:
- 写了
FOR SELECT TO authenticated USING(...)— 这条 Policy 只对authenticated角色起作用,对anon完全不存在 - 没写
TO子句(FOR SELECT USING(...))— 对所有角色都适用(除service_role因 BYPASSRLS 自动绕过)
对没有任何匹配 Policy 的 (角色, 操作) 组合,默认拒绝——这是 RLS 的核心特性,也是为什么"启用 RLS 但不写 Policy"会让所有非 service_role 请求被拒。
所以本例中 orders 表对 anon 没有 Policy + anon 在 GRANT 层也没权限 → 双重锁定,匿名用户绝对访问不到订单。
权限效果一览
商品表(products)
| 操作 | anon(游客) | authenticated(买家) | service_role(管理员) |
|---|---|---|---|
| SELECT(上架商品) | ✅ | ✅ | ✅ |
| SELECT(下架商品) | ❌ | ❌ | ✅ |
| INSERT | ❌ | ❌ | ✅ |
| UPDATE | ❌ | ❌ | ✅ |
| DELETE | ❌ | ❌ | ✅ |
订单表(orders)
| 操作 | anon(游客) | authenticated(自己的订单) | authenticated(他人的订单) | service_role(管理员) |
|---|---|---|---|---|
| SELECT | ❌ | ✅ | ❌ | ✅ |
| INSERT | ❌ | ✅ | ❌(不能冒充下单) | ✅ |
| UPDATE | ❌ | ❌ | ❌ | ✅ |
| DELETE | ❌ | ❌ | ❌ | ✅ |
REST API 实操
所有请求都通过 HTTP Header 传 Token:
Authorization: Bearer <Token>
Content-Type: application/json
Token 的来源取决于角色:
┌───────────────────────────────────────────────────────┐
│ 前端(小程序 / Web) │
│ │
│ 游客模式:Publishable Key 作为 Token │
│ → role=anon, 无 sub │
│ │
│ 登录模式:调用 /auth/v1/signin 获取 access_token │
│ → role=authenticated, 有 sub │
└───────────────────────────────────────────────────────┘
┌───────────────────────────────────────────────────────┐
│ 后端(云函数 / 服务端) │
│ │
│ 管理模式:API Key 作为 Token │
│ → role=service_role, BYPASSRLS │
└───────────────────────────────────────────────────────┘
场景 1:商品浏览(游客)
游客无需登录,用 Publishable Key 即可浏览商品:
# 浏览所有上架商品(按价格降序)
GET /v1/rdb/rest/products?select=id,name,price,category&order=price.desc
Authorization: Bearer <Publishable Key>
返回:
[
{ "id": 1, "name": "iPhone 15 Pro", "price": "8999.00", "category": "电子产品" },
{ "id": 2, "name": "MacBook Air M3", "price": "9499.00", "category": "电子产品" }
]
⚠️ 下架商品(
is_active=false)不会出现在结果中 —— 这是 RLS 自动过滤的,前端无需处理。
# 按价格区间过滤
GET /v1/rdb/rest/products?select=name,price&price=gte.5000&price=lte.10000
# 按分类浏览(URL 中的中文可直接写或按 URL 编码 %E7%94%B5%E5%AD%90%E4%BA%A7%E5%93%81)
GET /v1/rdb/rest/products?select=name,price&category=eq.电子产品
场景 2:用户下单(登录用户)
第一步:登录
POST /auth/v1/signin
Content-Type: application/json
{ "username": "buyer-zhang", "password": "MyPassword@1234" }
响应:
{ "access_token": "eyJhbG...", "token_type": "Bearer", "expires_in": 7200, ... }
第二步:创建订单
POST /v1/rdb/rest/orders
Authorization: Bearer <access_token>
Prefer: return=representation
Content-Type: application/json
{
"product_id": 1,
"quantity": 2,
"total_price": 17998.00,
"address": { "name": "张三", "phone": "138****0000", "street": "深圳市南山区" }
}
Prefer: return=representation让 PostgREST 在写入后返回完整行数据,便于前端更新界面。
返回:
[
{
"id": 1,
"product_id": 1,
"buyer_id": "user-uuid-zhang",
"quantity": 2,
"total_price": "17998.00",
"status": "pending",
"address": { "name": "张三", "phone": "138****0000", "street": "深圳市南山区" },
"created_at": "2024-01-15T10:30:00Z"
}
]
💡 安全保障:即使前端恶意传入
"buyer_id": "other-user-id",RLS 的WITH CHECK策略会拒绝这个请求(HTTP 403 / 409),因为buyer_id必须等于 JWT 中的sub。
场景 3:订单查询(登录用户)
GET /v1/rdb/rest/orders?select=id,product_id,quantity,total_price,status,created_at
Authorization: Bearer <access_token>
RLS 自动过滤,只返回当前用户自己的订单:
[
{
"id": 1,
"product_id": 1,
"quantity": 2,
"total_price": "17998.00",
"status": "pending",
"created_at": "2024-01-15T10:30:00Z"
}
]
⚠️ 即使不加任何过滤条件,也只会返回自己的订单;即使尝试
?buyer_id=eq.other-user-id也只会返回空数组——RLS 策略优先级高于用户提供的过滤条件。
场景 4:管理员操作(后端)
管理员(后端云函数 / 运维工具)使用 API Key,绕过所有 RLS:
# 查看所有订单(包括所有买家的)
GET /v1/rdb/rest/orders?select=id,buyer_id,status,total_price
Authorization: Bearer <API Key>
# 更新订单状态:pending → paid
PATCH /v1/rdb/rest/orders?buyer_id=eq.user-uuid-zhang&status=eq.pending
Authorization: Bearer <API Key>
Prefer: return=representation
Content-Type: application/json
{ "status": "paid" }
# 下架商品(注意 URL 中空格用 %20 编码)
PATCH /v1/rdb/rest/products?name=eq.iPhone%2015%20Pro
Authorization: Bearer <API Key>
Content-Type: application/json
{ "is_active": false }
API Key 拥有 BYPASSRLS 的超级权限,严禁出现在前端代码、小程序、App 中,只能在云函数、云托管等后端环境中使用,并通过环境变量注入。
PostgREST 查询参数大全
REST API 严格遵循 PostgREST 规范,常用参数:
过滤
| 操作符 | 说明 | 示例 |
|---|---|---|
eq | 等于 | ?category=eq.电子产品 |
neq | 不等于 | ?status=neq.completed |
gt / gte | 大于 / 大于等于 | ?price=gte.1000 |
lt / lte | 小于 / 小于等于 | ?price=lte.5000 |
like / ilike | 模糊 / 忽略大小写模糊 | ?name=like.*iPhone* |
in | IN 查询 | ?status=in.(pending,paid) |
is | IS NULL / NOT NULL | ?deleted_at=is.null |
排序
?order=price.desc # 单字段倒序
?order=category.asc,price.desc # 多字段排序
分页
?limit=10&offset=0 # 第一页
?limit=10&offset=10 # 第二页
列选择
?select=id,name,price # 只返回指定列,减少传输量
关联查询(基于外键)
# 订单携带关联的商品信息
GET /v1/rdb/rest/orders?select=id,quantity,products(name,price)
Prefer 头
| Prefer | 作用 |
|---|---|
return=representation | 写入后返回完整行 |
return=minimal | 写入后不返回 body(默认) |
count=exact | 返回总行数(响应头 Content-Range) |
组合使用
GET /v1/rdb/rest/products?select=name,price,category
&category=eq.电子产品
&price=gte.5000
&order=price.desc
&limit=10
&offset=0
SDK 版本的等价代码
同样的业务,使用 @cloudbase/js-sdk:
import cloudbase from '@cloudbase/js-sdk';
const app = cloudbase.init({ env: '<envId>' });
const auth = app.auth;
const db = app.rdb();
// 游客浏览商品(使用匿名登录获取 anon 身份)
await auth.signInAnonymously();
const { data: products } = await db
.from('products')
.select('id, name, price, category')
.order('price', { ascending: false });
// 登录后下单
await auth.signInWithPassword({ username: 'buyer-zhang', password: 'MyPassword@1234' });
const { data: newOrder } = await db.from('orders').insert({
product_id: 1,
quantity: 2,
total_price: 17998.00,
address: { name: '张三', phone: '138****0000' }
});
// 查询我的订单
const { data: myOrders } = await db.from('orders').select('*');
清理测试数据
-- 先删子表,再删父表(CASCADE 会自动删除关联的 Policy)
DROP TABLE IF EXISTS public.orders CASCADE;
DROP TABLE IF EXISTS public.products CASCADE;
最佳实践与常见陷阱
✅ 推荐做法
- 用
DEFAULT自动绑定用户身份——归属字段从源头防伪造 - UPDATE Policy 同时设置
USING和WITH CHECK——前者控制"能改哪些行",后者控制"改后的值是否合法" - GRANT + RLS 双重锁定——不希望某角色写的,在 GRANT 层面就不授予
- 使用
serial主键时别忘记授 SEQUENCE 权限 - 清理时先删子表再删父表(或用
CASCADE)
⚠️ 常见陷阱
| 陷阱 | 现象 | 解决 |
|---|---|---|
| 启用 RLS 但没写 Policy | 所有非 service_role 请求都被拒 | 为每种需要访问的角色创建 Policy |
| 写了 Policy 但没 GRANT | 即使 Policy 允许,仍返回权限错误 | 检查表级 GRANT |
| 前端暴露 API Key | 所有数据完全暴露 | 仅在后端使用,通过环境变量注入 |
UPDATE 只写 USING 没写 WITH CHECK | 用户可以把归属字段改成别人 | UPDATE 同时设置两者 |
| serial 主键没授 SEQUENCE 权限 | INSERT 报 permission denied for sequence | GRANT USAGE, SELECT ON SEQUENCE |
DDL 未包裹 DO $$ 直接走 ExecutePGSql | 部分 DDL 报错 | 失败时用 DO $$ BEGIN EXECUTE '...'; END $$ 包装重试 |
| 对匿名用户授予过多权限 | 未登录也能写数据 | anon 一般只给 SELECT |