第 12 课:构建 Newsletter 应用(加密)
踏上构建生产级应用的旅程。
你已经分别学过这些零件:身份认证(auth)、支付、性能、邮件、安全。这一课就是把它们全部拼到一起。一个应用、一份毕业作业(capstone),每一项技能都各司其职。
过去几节课里你积累的一切,都会在这里派上用场:你在 auth 课里搭好的数据库和 RLS(行级安全)、payments 课里的支付流程、performance 课里的索引和分页、notifications 课里的事务性邮件,以及 security 课里那种「时刻保持警惕」的安全心态。
我们要构建的是一个付费 Newsletter 平台(paid newsletter platform):创作者在上面发布文章,读者用加密钱包一次性付费来解锁阅读权限,剩下的事自动发生:新文章发出时发邮件提醒、付费墙背后的内容只对订阅者可见、订阅状态在链上得到验证。
Capstone 规则。 不引入任何新概念。每一步都在复用之前某节课讲过的技能。如果你卡住了,答案就在那节课里。这里唯一新的事情,是把它们全部接起来变成一个完整应用。
我们要构建什么
创作者可以:
- 登录后写文章,包含标题、封面图和正文。
- 设置一个以 MON 计价的价格。
- 查看谁订阅了自己。
读者可以:
- 浏览公开的文章预览(标题、封面、第一段)。
- 连接加密钱包,一次性付费解锁某个创作者。
- 付费后阅读完整文章。
- 创作者发新文章时,立即收到邮件通知。
sophomore 阶段的每一项技能都在这里发挥作用:
- 数据库与存储(Database and storage) 保存文章、订阅记录和封面图。
- 身份认证(Authentication) 告诉应用谁登录了、能读什么。
- 加密钱包与链上支付(Crypto wallet and on-chain payment) 永久且公开地记录订阅关系。
- 邮件通知(Email notifications) 在新文章发布时通知订阅者。
- 性能(Performance) 让文章列表在内容增长后依然顺畅。
- 安全(Security) 是把这一切粘在一起的胶水:付费墙靠 RLS、支付靠签名校验、每一处输入都要做校验。
在这个应用里,区块链存了什么?
那数据库里又留下了什么?
搭建项目
我们要先搭一个带身份认证和数据库的应用骨架,因为这两件事是底层基础。你之前的课里已经有一个 Supabase 项目了,直接复用它。
打开你的 Repl,把下面这段粘到 AI 面板里:
I'm building the capstone for my sophomore course: a paid
newsletter platform. Scaffold it:
- Next.js with Supabase auth (email + password). Sign up,
sign in, sign out, protected routes that redirect to sign-in
when the user isn't authenticated.
- Supabase database with these tables (give me the SQL, I'll
run it in the SQL Editor):
- profiles (id references auth.users, wallet_address text,
display_name text)
- posts (id, creator_id references profiles, title,
cover_path, body, price_mon numeric, created_at)
- subscriptions (id, subscriber_id references profiles,
creator_id references profiles, tx_hash text unique,
paid_amount_mon numeric, created_at)
- Enable RLS on every table. For now, give me permissive
policies I'll tighten later.
- Read SUPABASE_URL and SUPABASE_KEY directly from process.env.
Do NOT create a .env file. Do NOT rename the variables.
- Minimal UI. Plain system fonts. We'll style later.
Run it locally so I can test sign-up and sign-in.
把智能体返回的 SQL 粘到 Supabase 的 SQL Editor 里,点 Run,并在继续往下走之前,先确认登录功能能正常使用。
创作者发布内容
加上创作者流程:写文章、上传封面图、设置价格。
> Add a "write post" page that only signed-in users can reach.
It has a title input, a body textarea, a JPG-only file input
for the cover image, and a number input for price in MON. On
submit: upload the image to a "post-covers" Supabase storage
bucket under path "{creator_id}/{timestamp}.jpg", insert a row
into posts with the cover_path, and redirect to the post.
Validate: body non-empty, title under 140 chars, price >= 0.
收紧 posts 表的 RLS
脚手架里那套宽松策略允许任何人写入 posts 表。我们要修掉这件事:
> Rewrite the RLS policies on posts:
- SELECT: anyone can read a row's id, title, cover_path, and
created_at. The body column is only readable if the user is
the creator OR has a matching row in subscriptions for that
creator_id. Implement this by exposing only the safe columns
via a Postgres VIEW for unauthenticated reads, and gating
the body column through an RLS policy on the posts table.
- INSERT: only rows where creator_id = auth.uid().
- UPDATE / DELETE: only rows where creator_id = auth.uid().
Give me the SQL so I can paste it into the SQL Editor.
安全。付费墙就是 RLS 策略。 真正被挡在墙后面的东西,是 body 列本身。如果 SELECT 策略写错了,body 就会泄露。一定要测一下:用一个未订阅的账号注册登录,确认接口返回里的 body 是
null。绝不要靠前端代码来挡付费墙。前端代码只是「建议」,不是「安全」。
付费墙到底应该在哪里强制执行?
读者用钱包付费订阅
订阅本身就是一笔链上支付。读者连接钱包,按创作者设定的价格付款,这笔交易的 hash 就是订阅凭证。
> On a creator's profile page, show a "Subscribe" button when
the reader is signed in and not already subscribed to that
creator. Clicking it:
- Opens the reader's connected wallet (use wagmi + viem) and
requests a transfer of price_mon MON to the creator's
wallet_address from the profiles table.
- Waits for the tx to confirm on Monad testnet (or mainnet
based on NEXT_PUBLIC_CHAIN).
- POSTs to /api/subscriptions with { creator_id, tx_hash,
paid_amount_mon }.
- The /api/subscriptions route uses viem on the server to
fetch the tx from the chain and verifies:
* tx.to === creator's wallet_address
* tx.value >= price_mon (in wei)
* tx.from === reader's wallet_address (auth check)
* tx_hash is unique in subscriptions (prevents replay)
Only then does it insert the subscription row.
Show a clear error if any check fails.
安全。永远在服务端验证。 永远不要相信客户端说「这笔钱已经付过了」。这个路由必须自己从链上拉取这笔交易,然后亲自检查
to、value、from。tx_hash unique约束能防止同一笔交易被重复提交。如果没有这些校验,读者随便 POST 一个 hash 就能白嫖订阅。
为什么 /api/subscriptions 路由要自己从链上重新拉一遍这笔交易?
新文章邮件提醒
创作者每发布一篇新文章,就给它的每位订阅者发一封带文章标题和链接的邮件。
> When a creator inserts a new row into posts, email every
subscriber of that creator. Use the email provider we set up
in the email lesson (Resend). Queue the sends from a Supabase
Edge Function (or a background job). Do not block the
create-post request on email delivery, the post should save
and render immediately whether the emails succeed or not.
Template: creator display_name, post title, cover image, a
short preview of the body, and a link. Sanitize all user
content before rendering into the HTML template.
安全。限流并清洗输入。 一个有大量订阅者的创作者会一次触发很多封邮件。要对单篇文章设置发送上限,记录每一次发送日志,绝对不允许文章作者把原始 HTML 注入到邮件正文里。在渲染之前,用一个公认靠谱的库做内容清洗(sanitize)。
为什么要把邮件放进后台任务里发,而不是在「发布文章」的请求里同步发?
性能:给信息流加索引
一个受欢迎的创作者会有数千篇文章和数万订阅者。两类查询特别关键:
- 「这个创作者所有的文章,按时间倒序」:创作者主页的信息流。
- 「这位读者订阅了这位创作者吗?」:付费墙判断,每次访问受保护内容时都会跑。
这两类查询都应该走索引。
CREATE INDEX posts_creator_created_idx
ON public.posts (creator_id, created_at DESC);
CREATE INDEX subscriptions_subscriber_creator_idx
ON public.subscriptions (subscriber_id, creator_id);
把上面这段粘到 Supabase 的 SQL Editor 里运行。
同时把信息流改成分页加载:
> Change the creator page to render 20 posts at a time with a
"Load more" button that fetches the next 20. Query with
.range(offset, offset + 19) using the index above. Return
only the public columns (id, title, cover_path, created_at)
for the feed. Don't fetch the body until the reader opens
the post.
为什么 subscriptions 的索引要同时包含两列?
端到端走一遍
注册一个 用户 A,设置好钱包地址,写一篇文章,设个价格。
换一个浏览器(或者无痕窗口)注册 用户 B,连接一个有一些测试 MON 的钱包,打开 A 的主页订阅他。交易在链上确认,订阅记录写进数据库,B 现在能看到完整的正文了。
再注册一个 用户 C,不要订阅。确认 C 看到的正文是空的或者被隐藏。如果 C 居然能看到正文,那你的 RLS 策略写错了——在继续往下之前先去把它修好。
让 A 再发一篇新文章。B 在一分钟内应该收到邮件。
整个流程就是这样。
安全自查清单(Security Recap)
完工前过一遍这份清单:
- RLS 在每张表上都开着。付费墙就住在这里。
- 密钥(Keys): Supabase anon key 放在环境变量里;
service_rolekey 永远不能出现在客户端代码里。 - 支付(Payments): 服务端从链上重新拉交易并验证。tx hash 上有 unique 约束。
- 上传(Uploads): 设置 MIME 类型和大小上限;除非内容真的完全公开,否则用私有 bucket。
- 邮件(Email): 渲染模板前先 sanitize 用户输入;按文章限制发送量。
- 索引(Indexes): 大表上的查询走索引,不是全表扫描。
- 认证(Auth): 每个 API 路由都在服务端重新校验用户身份,绝不相信客户端。
你刚刚做出来了什么
一个端到端的产品,用上了你在 sophomore 阶段学到的全部内容:
- 数据库与存储 保存文章、订阅和封面图。
- Auth 与 RLS 保证付费墙不被绕过。
- 加密钱包配合链上支付 把订阅记录到 Monad 上。
- 邮件通知 让订阅者随时跟上更新。
- 性能 在内容和读者增长后依然不卡。
- 安全 是贯穿每一步的那条主线。
这是一个真实可行的产品想法,不是个玩具。付费 Newsletter 是一个真实存在的市场(Substack、Patreon 等已经验证过)。如果你喜欢这一个,就基于它继续往下做。
接下来是什么
你已经从「搭一个单页应用」走到了「上线一个真正在搬动金钱、保存真实数据、保护真实用户的产品」。下一课:做一个链上太空射击游戏——另一种应用,这次需要保护的不是订阅,而是一个没人能造假的排行榜。
0/6 正确
0% — 全部答对即可完成