Hands-on Tutorial: E-commerce Mini Program
Before reading, we recommend understanding the PG Mode Overview to clarify the relationship between PostgreSQL, authentication, Cloud Storage, and the permission model.
This article uses an e-commerce Mini Program as a complete real-world scenario, walking you through the end-to-end chain of data modeling, permission design, and REST API calls for a CloudBase PostgreSQL database. The capabilities covered:
- Multi-table modeling with foreign key constraints
- Combined use of the two-layer permission model (GRANT + RLS)
- Differentiated access for different roles (
anon/authenticated/service_role) - Full usage of the PostgREST REST API (filtering, sorting, pagination, Prefer)
- Typical business rules such as soft unlisting based on
is_active, JWT-based order-placement identity binding, and "orders cannot be modified"
ð¡ After reading this article, you can apply the same approach to blogs, private notes, SaaS, social apps, and more.
Scenario Overviewâ
Build an e-commerce app similar to Shopify / Youzan / a Mini Program store, which needs:
| Feature | Description |
|---|---|
| ðª Product browsing | Everyone (including anonymous users) can browse listed products |
| ð Hide unlisted products | Products with is_active=false are invisible to regular users |
| ð Buyer places an order | Logged-in users can create orders; the buyer identity is bound automatically |
| ðï¸ Private orders | Each buyer can only see their own orders |
| ð« Orders cannot be modified | Buyers cannot modify or cancel after placing an order; only admins can change the status |
| ð¨âð¼ Full admin rights | Admins can manage products and process all order status transitions |
Data Modelâ
ââââââââââââââââââââââââââ âââââââââââââââââââââââââââ
â 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)â
ââââââââââââââââââââââââââ âââââââââââââââââââââââââââ
Prerequisitesâ
- A CloudBase environment with PG mode created
- The Publishable Key and API Key obtained (see PG: Authentication - Credentials and JWT)
- The environment ID, noted as
<envId>, e.g.pg-test-3gxmdbdb580ecfd1 - API endpoints:
- REST:
https://<envId>.api.tcloudbasegateway.com/v1/rdb/rest/v1 - Auth:
https://<envId>.api.tcloudbasegateway.com/auth/v1
- REST:
SQL Execution Orderâ
The SQL below must be executed strictly in the following order, otherwise it will fail due to dependencies:
建表 (CREATE TABLE)
â å¯ç¨ RLS (ALTER TABLE ... ENABLE ROW LEVEL SECURITY)
â ææ (GRANT)
â å建çç¥ (CREATE POLICY)
When cleaning up, do it in reverse: delete Policy first â then disable RLS â finally drop the table (DROP TABLE ... CASCADE automatically cleans up the Policy).
ð§ SQL can be executed line by line directly through the SQL Editor in the console, or deployed automatically via the cloud API
ExecutePGSql(some DDL under the API path needs to be wrapped for retry withDO LANGUAGE plpgsql $$ BEGIN EXECUTE '...'; END $$; this is an anonymous procedural code block. See Architecture and Permission Model - ExecutePGSql).
Step 1: Create Tablesâ
-- âââââââââââââââââââââââââââââââââââââââââââââââââââ
-- åå表
-- âââââââââââââââââââââââââââââââââââââââââââââââââââ
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);
Key design:
buyer_idusesDEFAULT (current_setting('request.jwt.claims', true)::json->>'sub')to automatically get the current user ID from the JWT, no need to pass it from the frontend, eliminating the possibility of identity forgery.
Step 2: Enable RLSâ
ALTER TABLE public.products ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.orders ENABLE ROW LEVEL SECURITY;
After RLS is enabled, if there is no Policy, all non-
service_roleusers cannot access any data (default deny). Continue to the next step.
Step 3: Grant Table-level Permissions (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 任使éï¼æªç»å½ä¸è½æä½è®¢åï¼
ð¡ When using a
serial/bigserialprimary key, you must also grantUSAGEon the corresponding SEQUENCE, otherwise INSERT will fail because it cannot get the next auto-increment value.
Step 4: Create RLS Policiesâ
-- âââââââââââââââââââââââââââââââââââââââââââââââââââ
-- 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> in a PolicyThe TO <role> clause of an RLS Policy is used to restrict the policy to apply only to the specified role:
- Writing
FOR SELECT TO authenticated USING(...)â this Policy only takes effect for theauthenticatedrole; it is completely absent foranon - Not writing the
TOclause (FOR SELECT USING(...)) â applies to all roles (exceptservice_role, which automatically bypasses via BYPASSRLS)
For any (role, operation) combination that has no matching Policy, the default is deny â this is a core feature of RLS, and also why "enabling RLS but writing no Policy" causes all non-service_role requests to be rejected.
So in this example, the orders table has no Policy for anon + anon also has no permission at the GRANT layer â double lock, anonymous users absolutely cannot access orders.
Permission Effect Overviewâ
Products tableâ
| Operation | anon (guest) | authenticated (buyer) | service_role (admin) |
|---|---|---|---|
| SELECT (listed products) | â | â | â |
| SELECT (unlisted products) | â | â | â |
| INSERT | â | â | â |
| UPDATE | â | â | â |
| DELETE | â | â | â |
Orders tableâ
| Operation | anon (guest) | authenticated (own orders) | authenticated (others' orders) | service_role (admin) |
|---|---|---|---|---|
| SELECT | â | â | â | â |
| INSERT | â | â | â (cannot impersonate) | â |
| UPDATE | â | â | â | â |
| DELETE | â | â | â | â |
REST API Practiceâ
All requests pass the Token via HTTP Header:
Authorization: Bearer <Token>
Content-Type: application/json
The source of the Token depends on the role:
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
â å端ï¼å°ç¨åº / Webï¼ â
â â
â æ¸¸å®¢æ¨¡å¼ï¼Publishable Key ä½ä¸º Token â
â â role=anon, æ sub â
â â
â ç»å½æ¨¡å¼ï¼è°ç¨ /auth/v1/signin è·å access_token â
â â role=authenticated, æ sub â
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
â å端ï¼äºå½æ° / æå¡ç«¯ï¼ â
â â
â ç®¡çæ¨¡å¼ï¼API Key ä½ä¸º Token â
â â role=service_role, BYPASSRLS â
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
Scenario 1: Product browsing (guest)â
Guests can browse products without logging in, using the Publishable Key:
# æµè§ææä¸æ¶ååï¼æä»·æ ¼éåºï¼
GET /v1/rdb/rest/products?select=id,name,price,category&order=price.desc
Authorization: Bearer <Publishable Key>
Response:
[
{ "id": 1, "name": "iPhone 15 Pro", "price": "8999.00", "category": "çµå产å" },
{ "id": 2, "name": "MacBook Air M3", "price": "9499.00", "category": "çµå产å" }
]
â ï¸ Unlisted products (
is_active=false) will not appear in the results â this is filtered automatically by RLS, no frontend handling needed.
# æä»·æ ¼åºé´è¿æ»¤
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.çµå产å
Scenario 2: User places an order (logged-in user)â
Step 1: Log inâ
POST /auth/v1/signin
Content-Type: application/json
{ "username": "buyer-zhang", "password": "MyPassword@1234" }
Response:
{ "access_token": "eyJhbG...", "token_type": "Bearer", "expires_in": 7200, ... }
Step 2: Create an orderâ
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=representationmakes PostgREST return the complete row data after writing, which is convenient for updating the frontend UI.
Response:
[
{
"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"
}
]
ð¡ Security guarantee: Even if the frontend maliciously passes
"buyer_id": "other-user-id", the RLSWITH CHECKpolicy will reject this request (HTTP 403 / 409), becausebuyer_idmust equal thesubin the JWT.
Scenario 3: Order query (logged-in user)â
GET /v1/rdb/rest/orders?select=id,product_id,quantity,total_price,status,created_at
Authorization: Bearer <access_token>
RLS filters automatically, returning only the current user's own orders:
[
{
"id": 1,
"product_id": 1,
"quantity": 2,
"total_price": "17998.00",
"status": "pending",
"created_at": "2024-01-15T10:30:00Z"
}
]
â ï¸ Even without any filter condition, only your own orders are returned; even if you try
?buyer_id=eq.other-user-id, only an empty array is returned â the RLS policy takes priority over user-provided filter conditions.
Scenario 4: Admin operations (backend)â
Admins (backend cloud functions / ops tools) use the API Key, bypassing all 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 }
The API Key has superuser privileges with BYPASSRLS. It is strictly forbidden to appear in frontend code, Mini Programs, or Apps. It should only be used in backend environments such as cloud functions or CloudRun, and injected via environment variables.
PostgREST Query Parameters Referenceâ
The REST API strictly follows the PostgREST specification; common parameters:
Filteringâ
| Operator | Description | Example |
|---|---|---|
eq | equal | ?category=eq.çµå产å |
neq | not equal | ?status=neq.completed |
gt / gte | greater than / greater than or equal | ?price=gte.1000 |
lt / lte | less than / less than or equal | ?price=lte.5000 |
like / ilike | fuzzy / case-insensitive fuzzy | ?name=like.*iPhone* |
in | IN query | ?status=in.(pending,paid) |
is | IS NULL / NOT NULL | ?deleted_at=is.null |
Sortingâ
?order=price.desc # ååæ®µååº
?order=category.asc,price.desc # å¤å段æåº
Paginationâ
?limit=10&offset=0 # 第ä¸é¡µ
?limit=10&offset=10 # 第äºé¡µ
Column Selectionâ
?select=id,name,price # åªè¿åæå®åï¼åå°ä¼ è¾é
Related Query (based on foreign key)â
# è®¢åæºå¸¦å
³èçååä¿¡æ¯
GET /v1/rdb/rest/orders?select=id,quantity,products(name,price)
Prefer Headerâ
| Prefer | Effect |
|---|---|
return=representation | Return the full row after writing |
return=minimal | No body returned after writing (default) |
count=exact | Return the total row count (response header Content-Range) |
Combined Usageâ
GET /v1/rdb/rest/products?select=name,price,category
&category=eq.çµå产å
&price=gte.5000
&order=price.desc
&limit=10
&offset=0
Equivalent SDK Codeâ
For the same business logic, using @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('*');
Clean Up Test Dataâ
-- å
å å表ï¼åå ç¶è¡¨ï¼CASCADE ä¼èªå¨å é¤å
³èç Policyï¼
DROP TABLE IF EXISTS public.orders CASCADE;
DROP TABLE IF EXISTS public.products CASCADE;
Best Practices and Common Pitfallsâ
â Recommended Practicesâ
- Bind user identity automatically with
DEFAULTâ the ownership field prevents forgery from the source - Set both
USINGandWITH CHECKin the UPDATE Policy â the former controls "which rows can be changed", the latter controls "whether the changed value is valid" - GRANT + RLS double lock â if you don't want a role to write, don't grant it at the GRANT layer
- Don't forget to grant SEQUENCE permission when using a
serialprimary key - Delete the child table before the parent table when cleaning up (or use
CASCADE)
â ï¸ Common Pitfallsâ
| Pitfall | Symptom | Solution |
|---|---|---|
| RLS enabled but no Policy written | All non-service_role requests rejected | Create a Policy for each role that needs access |
| Policy written but no GRANT | Even if the Policy allows, still returns a permission error | Check the table-level GRANT |
| API Key exposed in the frontend | All data fully exposed | Use only in the backend, inject via environment variables |
UPDATE only has USING without WITH CHECK | User can change the ownership field to someone else | Set both in UPDATE |
| serial primary key without SEQUENCE permission | INSERT reports permission denied for sequence | GRANT USAGE, SELECT ON SEQUENCE |
DDL not wrapped in DO $$ going directly through ExecutePGSql | Some DDL errors | On failure, wrap with DO LANGUAGE plpgsql $$ BEGIN EXECUTE '...'; END $$ for retry |
| Too many permissions granted to anonymous users | Anonymous users can write data | anon should generally only get SELECT |
Next Stepsâ
- Quick Start â 5-minute Hello World
- Architecture and Permission Model â database capabilities, access methods, extensions
- PG: Authentication â includes three roles, JWT, Key management
- PG Mode Cloud Storage â file permissions based on RLS
- Basic Permission Management â advanced RLS topics