[ 12 / 16 ] · Monad课程

第 12 课:构建 Newsletter 应用(加密)

6 分钟400 XP

踏上构建生产级应用的旅程。

你已经分别学过这些零件:身份认证(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.

安全。永远在服务端验证。 永远不要相信客户端说「这笔钱已经付过了」。这个路由必须自己从链上拉取这笔交易,然后亲自检查 tovaluefromtx_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_role key 永远不能出现在客户端代码里。
  • 支付(Payments): 服务端从链上重新拉交易并验证。tx hash 上有 unique 约束。
  • 上传(Uploads): 设置 MIME 类型和大小上限;除非内容真的完全公开,否则用私有 bucket。
  • 邮件(Email): 渲染模板前先 sanitize 用户输入;按文章限制发送量。
  • 索引(Indexes): 大表上的查询走索引,不是全表扫描。
  • 认证(Auth): 每个 API 路由都在服务端重新校验用户身份,绝不相信客户端。

你刚刚做出来了什么

一个端到端的产品,用上了你在 sophomore 阶段学到的全部内容:

  • 数据库与存储 保存文章、订阅和封面图。
  • Auth 与 RLS 保证付费墙不被绕过。
  • 加密钱包配合链上支付 把订阅记录到 Monad 上。
  • 邮件通知 让订阅者随时跟上更新。
  • 性能 在内容和读者增长后依然不卡。
  • 安全 是贯穿每一步的那条主线。

这是一个真实可行的产品想法,不是个玩具。付费 Newsletter 是一个真实存在的市场(Substack、Patreon 等已经验证过)。如果你喜欢这一个,就基于它继续往下做。


接下来是什么

你已经从「搭一个单页应用」走到了「上线一个真正在搬动金钱、保存真实数据、保护真实用户的产品」。下一课:做一个链上太空射击游戏——另一种应用,这次需要保护的不是订阅,而是一个没人能造假的排行榜。

0/6 正确

0% — 全部答对即可完成

注册以记录进度