Skip to main content

实战教程:电商小程序

阅读前建议先了解 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)│
└────────────────────────┘ └─────────────────────────┘

前置准备

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),实现"订单不可改"的业务规则
Policy 中 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 安全

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*
inIN 查询?status=in.(pending,paid)
isIS 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;

最佳实践与常见陷阱

✅ 推荐做法

  1. DEFAULT 自动绑定用户身份——归属字段从源头防伪造
  2. UPDATE Policy 同时设置 USINGWITH CHECK——前者控制"能改哪些行",后者控制"改后的值是否合法"
  3. GRANT + RLS 双重锁定——不希望某角色写的,在 GRANT 层面就不授予
  4. 使用 serial 主键时别忘记授 SEQUENCE 权限
  5. 清理时先删子表再删父表(或用 CASCADE

⚠️ 常见陷阱

陷阱现象解决
启用 RLS 但没写 Policy所有非 service_role 请求都被拒为每种需要访问的角色创建 Policy
写了 Policy 但没 GRANT即使 Policy 允许,仍返回权限错误检查表级 GRANT
前端暴露 API Key所有数据完全暴露仅在后端使用,通过环境变量注入
UPDATE 只写 USING 没写 WITH CHECK用户可以把归属字段改成别人UPDATE 同时设置两者
serial 主键没授 SEQUENCE 权限INSERT 报 permission denied for sequenceGRANT USAGE, SELECT ON SEQUENCE
DDL 未包裹 DO $$ 直接走 ExecutePGSql部分 DDL 报错失败时用 DO $$ BEGIN EXECUTE '...'; END $$ 包装重试
对匿名用户授予过多权限未登录也能写数据anon 一般只给 SELECT

下一步