Web3 大学面试手册
# Web3 大学面试手册
从项目介绍到系统设计 从技术原理到压力追问
这份手册以仓库中的真实实现、部署记录、测试与已知限制为依据。目标不是背术语,而是让你能从业务目标讲到架构边界,再深入到合约安全、DeFi、钱包认证、链上链下数据一致性、事件驱动自动化和 AWS 部署。
| 适用面试 | 建议用法 |
|---|---|
| Web3 全栈 | 优先掌握项目叙事、Solidity、viem、钱包认证和链上链下边界 |
| 智能合约 | 重点复习 ERC20、ERC721、ERC5192、CEI、重入、授权和 Foundry |
| 后端与云 | 重点复习 Hono、tRPC、PostgreSQL、KMS Relayer、ECS、RDS 和 S3 |
| 系统设计 | 重点复习真相源、幂等性、一致性、密钥边界、故障恢复和扩展方案 |
版本 2026 年 9 月
# 使用方法
先读 项目介绍,理解购课和发证两条主线,再按问题查阅实现与取舍。技术基础复用 Aladdin 和站内专题笔记,这里重点说明 Web3 大学怎样使用这些技术、遇到了什么问题,以及怎样处理失败。
# 面试前优先掌握的核心问题
下面是可以直接口述的短答案。后文保留详细原理、代码落点和压力追问,需要时再下钻。
1. 为什么称为半中心化?
建议回答:用户可以在链上查到购买记录和证书,不必只相信平台页面。但课程内容、学习进度和是否结业仍由平台维护,管理员还能管理发证权限,所以它是链上可验证、链下仍需信任平台的半中心化系统。
2. 为什么不把所有数据都放到链上?
建议回答:链上存储成本高、公开且更新慢,只适合保存需要公开验证的资产和权限事实。课程价格、购买关系和证书放链上;视频、评论和学习进度体积大、写入频繁,放 PostgreSQL 或对象存储更合适。两边通过 courseId 和 tokenURI 关联。
3. 用户购买一门课程会经过哪些步骤?
建议回答:前端先检查网络、YD 余额和 allowance,也就是课程合约目前可以扣取多少 YD。余额不足时可通过 Uniswap V2 用 ETH 或 USDC 兑换 YD,额度不足时先调用 approve 完成授权,再调用 CoursePlatform.purchase。最后必须等交易回执确认成功,钱包给出的交易哈希只代表已经广播,不能提前当成购买成功。
4. 钱包签名登录为什么不需要私钥上传?
建议回答:后端先发一个只能使用一次的 nonce,钱包在本地用私钥签名,服务端通过签名恢复地址并核对 domain、uri、nonce 和时间。私钥始终留在钱包中;验证成功后由 better-auth 签发 HttpOnly Session。nonce 必须原子消费,否则同一签名可能被重复使用。
5. 结业证书为什么需要 Relayer 和 KMS?
建议回答:合约无法直接读取数据库里的学习进度,所以需要链下中继服务 Relayer 监听申领事件,再向后端确认用户确实完成课程。确认后生成 NFT metadata,并使用 AWS KMS 中不可导出的签名密钥发送 mint 交易。KMS 降低了私钥文件泄露风险,但调用权限、交易内容和 issuer 权限仍然需要限制。
6. 怎样避免事件漏处理或重复发证?
建议回答:生产方案用 RDS 保存扫描进度,服务重启后从已记录区块附近补扫,处理成功才推进进度。重复扫描不可怕,合约中的 hasClaimed 会阻止同一钱包为同一课程再领一张证书。进度记录防止漏发,合约检查防止重复发,二者缺一不可。
7. Chainlink CRE 和当前 Relayer 是什么关系?
建议回答:Chainlink CRE 是另一套结业自动化执行方案,与 Relayer 解决的是同一条业务链路,但当前生产路径仍是 AWS KMS Relayer。CRE 已经过隔离验证,但生产部署权限尚未开通,还没有替代 KMS Relayer。两者是同一业务的两条执行路径,不是每次发证都要一起运行。
8. 这套系统怎样部署到生产环境?
建议回答:生产方案把 Web、API 和 Relayer 分别打成 Docker 镜像,推送到 ECR,再由 ECS Fargate 运行,基础设施用 CDK 管理。用户通过 HTTPS 访问,CloudFront 缓存静态资源,WAF 过滤异常请求,ALB 分发给 Web 和 API;Relayer 只在后台发证。RDS 存业务数据,S3 存头像,KMS 保护发证密钥并签名。发布前跑测试,上线时滚动更新,失败回滚;Web、API 多实例部署,Relayer 保存处理进度并补扫漏掉的事件,再配合日志告警和数据库备份恢复,保证故障后能继续服务。
# 事实边界
本文按生产项目的设计标准组织说明:不只讲功能能跑,还要讲权限、重复请求、监控和故障恢复。正文给出完整方案与可口述答案,不在每节反复讨论是否适合 Demo。
实际经历与设计方案要区分:本机 Relayer 代码尚未持久化扫描进度,文中的 RDS checkpoint、租约和停机补扫按生产方案说明;云端高可用配置与恢复演练以部署记录为准。合约业务在 Sepolia 测试网,CRE 尚未接管发证,不等于已上主网。
# 技术栈与项目分工
| 模块 | 主要技术 | 在本项目中负责什么 |
|---|---|---|
| 前端 | TypeScript、Next.js 16 与 React 19、Tailwind CSS v4 | 课程页面、购买与兑换交互,以及钱包切换后的状态清理 |
| 钱包与身份 | viem、Privy、better-auth | 连接当前钱包、签名登录、建立会话、发送交易并等待确认 |
| API 与数据 | Hono、tRPC v11、Zod、PostgreSQL 与 Drizzle ORM | 校验身份与输入,保存课程内容和学习进度,合并链上课程状态 |
| 智能合约 | Solidity、Foundry、OpenZeppelin v5 | 实现并测试代币支付、购买记录、不可转让证书与发行权限 |
| 代币兑换 | Uniswap V2 | 将 ETH 或 USDC 换成 YD,处理报价、授权、滑点保护与交易确认 |
| 自动发证 | 自建 Relayer 与 AWS KMS 签名、Chainlink CRE、IPFS 与 Pinata | 核验结业条件、保存证书内容并提交铸造交易;CRE 是已隔离验证的备选路径 |
| 云部署 | AWS CDK、Docker、ECS Fargate 与 ALB、RDS PostgreSQL | 分别运行 Web、API 和 Relayer,持久化业务数据与消费进度,配置入口、权限和监控 |
# 第一部分 项目介绍与架构叙事
# 项目介绍
这是一个面向学生、老师和管理员的在线教育平台,支持课程发布与审核、购买、学习和结业发证,并通过钱包支付和链上证书,让购买记录与结业凭证可以公开核验。学生连接钱包并签名登录后,可以将 ETH 或 USDC 兑换成平台代币 YD 来购买课程;完成学习并申请证书后,后台自动核验结业条件,再在链上发放一张绑定钱包、不可转让的 NFT 结业证书。
系统采用链上、链下分工:课程价格、购买记录和证书保存在链上,付款以课程合约中的价格为准;课程内容、评论和学习进度保存在数据库,两边通过课程 ID 关联。这样既方便用户独立核验购买记录和证书,也便于查询和更新课程内容。技术重点是把购课、学习和自动发证串起来,处理交易确认、发证失败重试和重复申领,避免页面显示成功但交易未完成,或重复发放证书。
# 这个项目最大的技术亮点是什么?
建议回答:最大的技术亮点是换币和购课流程中的交易状态管理与资金操作保护。这个流程涉及授权、兑换和支付,多笔交易可能分别成功或失败。我把等待签名、已提交、确认成功和失败分开处理:交易前检查网络、余额和授权,兑换时设置最低接收数量来限制滑点,购买成功后再刷新课程权限。比如授权成功但购买失败,重试时先检查已有授权;交易结果不明确时先查原交易,不直接让用户再付一次。切换钱包后也会隔离旧请求,避免旧账户的结果覆盖新界面。这样用户能清楚知道操作到了哪一步,减少误操作,并让页面状态与链上结果保持一致。
# 项目是不是过度设计?
建议回答:没有过度设计,因为项目明确需要钱包支付和证书的公开核验。课程价格、购买记录和证书放在链上,让付款按合约规则执行,第三方也能独立核验证书;视频、评论和学习进度仍放在链下,避免大文件存储和频繁更新带来的链上成本。我们只把需要链上执行或验证的部分上链,而不是把整个网站搬上链。如果没有这些业务需求,普通数据库确实更简单。另外,链上能验证发证记录,但学生是否完成学习,仍由平台核验。
# 业务角色和主流程
| 角色 | 在项目里做什么 | 权限从哪里来 |
|---|---|---|
| 学生 | 换币、买课、学习、评论、领取证书 | 钱包签名登录,付费权限再查链上购买记录 |
| 老师 | 提交课程和资料,等待审核 | 数据库 isTeacher 教师标记,后端每次检查 |
| Owner(管理员) | 审核教师和课程、上架或下架课程 | 与 CoursePlatform.owner 返回的管理员钱包比对 |
| Relayer(发证服务) | 检查申领资格,生成资料并提交发证交易 | 使用证书合约认可的 issuer 发证地址 |
| CRE Workflow(备选工作流) | 由 Chainlink 网络执行同一套发证流程 | 报告来源验证通过后,由 Receiver 接收合约发证 |
# 链上链下数据边界
| 数据 | 保存位置 | 为什么这样分 |
|---|---|---|
| 课程价格、上架状态、购买记录 | CoursePlatform 合约 | 影响付款和付费访问,后端核验时以合约为准 |
| YD 余额与扣款授权额度 | YDToken 合约 | 属于用户资产状态,交易前读取确认 |
| 证书归属与资料地址 | NFT 合约;图片和说明放 IPFS | 合约公开证明归属,文件不必全部写到链上 |
| 标题、简介、视频地址、大纲 | PostgreSQL | 经常更新,按同一 courseId 与链上课程对应 |
| 评论和学习进度 | PostgreSQL | 写入频繁,由会话身份和数据库约束控制 |
| 头像文件 | S3 | 适合保存图片,后端限制上传路径与大小 |
# 为什么叫半中心化,而不是去中心化教育平台?
建议回答:因为付费与证书等关键状态由链上合约公开验证,但课程内容、评论、完成状态接口、Relayer 和云基础设施仍由平台运营。把它说成完全去中心化会掩盖后端、issuer 和 owner 的信任假设。
# 为什么这个项目需要区块链?
建议回答:区块链真正增加价值的地方是付款关系和证书可验证性,而不是把普通 CRUD 搬上链。用户可以独立验证自己是否购买、证书是否由指定合约和 issuer 发行;如果这些价值不足以抵消 gas、延迟和复杂度,就应该保留 Web2 方案。
学生 老师 Owner
│
▼
Next.js 前端 ── tRPC ── Hono 后端 ── Drizzle ── PostgreSQL
│ │
│ viem ├── S3 头像
▼ └── Pinata IPFS
Ethereum Sepolia
├── YDToken
├── CoursePlatform
├── CompletionRequest ── 事件 ── KMS Relayer ── CourseCompletionNFT
└── Uniswap V2 Router ── YD WETH 与 YD USDC Pair
2
3
4
5
6
7
8
9
10
11
12
图 1 项目架构总览
# 怎样避免链上购买状态与链下数据不一致?
建议回答:先明确谁说了算:是否购买由合约决定,标题和学习进度由数据库保存,不两边都维护一份权威购买记录。跨两边的操作则先确认链上成功,再更新数据库;若第二步失败,要凭交易哈希重试或核对,先后顺序本身不等于自动一致。
# 如果 RPC 挂了平台是否不可用?
建议回答:课程介绍等链下内容可以继续展示,但购买权限和链上交易会受影响。生产可以配置备用 RPC,查询失败时切换;仍无法确认购买权限,就提示稍后重试,不能把网络故障当成用户没买过,也不能直接放行付费内容。
# 链上查询很慢或超时,页面应该怎样处理?
建议回答:先区分是查询响应慢,还是交易还没被打包。查询慢时先减少请求:项目把课程列表的合约读取合并为 multicall,并对课程价格、上架状态做短时间缓存,减少反复查链。个人购买权限仍单独核验,不能靠过期缓存放行付费内容。
遇到临时网络错误或限流,项目会有限重试,并逐渐拉长重试间隔,避免密集请求加重限流;重试不代表响应一定更快。生产还可设置总等待上限、配置备用 RPC,持续不可用时提示稍后重试,而不是让页面一直转圈。
如果交易已经提交,查询超时不代表交易失败。页面保留交易哈希,提示结果暂时未知,不直接让用户重新付款;具体处理见 难点四 交易结果未知时避免重复付款。换 RPC 可以改善查询,但不能保证交易更快被打包。
相关代码:apps/server/src/lib/onchain/course-platform-client.ts(批量读取与短缓存)、apps/web/src/lib/viem-client.ts(RPC 重试配置)、apps/web/src/hooks/use-purchase-flow.ts(结果未知状态)。
# 核心业务流程
学生 钱包登录 → 兑换 YD → approve → purchase → 学习与进度 → 申领证书 → NFT
老师 钱包登录 → 申请教师 → 提交课程资料 → 等待审核 → 课程上架
Owner 钱包登录 → 校验 owner → 审核申请 → listCourse → 停用或恢复课程
2
3
图 2 学生 老师 Owner 的端到端业务流程
# 你如何划分模块?
建议回答:按一条业务链来分:Web 负责钱包交互,API 负责身份、课程资料和学习进度,合约负责付款与证书规则,Relayer 负责把结业结果送上链。CRE 是另一条发证执行路径,与 Relayer 复用结业规则,不再维护一份不同的业务判断。
# 为什么把前端、后端和发证服务放在同一个代码仓库?
建议回答:项目最初使用 Better-T-Stack 搭建,采用了 monorepo 结构。后续前后端需要共享接口类型,Relayer 和 CRE 也需要复用结业判断与证书生成逻辑,所以沿用了这个结构,把公共代码放进共享包,减少重复维护。接口修改后,可以在同一个仓库里检查相关调用方;各个应用仍然可以独立部署,但构建时需要带上依赖的共享包。
# 你主要负责什么?
建议回答:我主要负责钱包接入、代币兑换和课程支付的前端交互,以及相关后端接口对接。钱包连接复用 Privy,合约读写和交易发送复用 viem;我实现的是这些基础能力之上的业务流程,把授权、兑换和支付串起来,处理网络、余额与授权检查、滑点保护,以及等待签名、链上确认和失败重试等状态。
比如交易已提交但暂时查不到结果时,先核对原交易,避免用户重复付款;切换钱包后,隔离旧账户的请求结果,避免页面显示错误的余额或购买权限。此外,我也参与钱包签名登录和自动发证流程的联调,让用户从登录、支付到领取证书的流程保持连贯。
# 第二部分 区块链基础与智能合约
这一部分先记住
合约保存的是会影响资产和权限的事实。Solidity 用来编写合约的业务逻辑,例如买课时如何扣款、哪些地址有权发证;OpenZeppelin 提供现成的 ERC20 代币、ERC721 NFT 合约,以及权限控制、防重入组件,方便直接复用;Foundry 负责测试和部署。
使用成熟库能够减少重复造轮子,但业务权限、金额和状态机仍要由项目自己保证。例如,本项目复用 OpenZeppelin 的 ERC721 基础代码,再按 ERC5192 标准实现锁定状态查询和禁止转让的逻辑,让结业证书绑定获得它的钱包。
# 必须先掌握的以太坊基础
| 概念 | 用项目理解 |
|---|---|
| EOA(Externally Owned Account,外部账户) | 项目中的普通学生钱包和 KMS 发证地址使用 EOA;账户类型与冷/热钱包的区别见 钱包与账户类型怎么区分 |
| 交易 | 向链上提交一次操作,例如授权或购课;需要签名、手续费,并等待执行结果;原理见 一笔 EVM 交易经过哪些阶段 |
| eth_call(只读调用) | 向节点查询当前状态,例如余额和是否买过课程;不发链上交易,不支付链上 gas;原理见 调用从函数名变成字节码 |
| ABI(Application Binary Interface,应用二进制接口) | 合约的调用说明:有哪些函数、参数是什么。viem 用它编码请求、解码结果;原理见 调用从函数名变成字节码 |
| 事件日志(event log) | 合约留下的操作记录,例如谁买了哪门课;后端可以查询,但合约不能直接拿过去的日志做权限判断;原理见 交易、日志、Trace 有什么区别 |
| 回执(receipt)与回滚(revert) | 回执记录交易是否成功;回滚会撤销本次交易的状态修改,但已经消耗的手续费不退;原理见 一笔 EVM 交易经过哪些阶段、Gas 是工作量,不是 ETH 金额 |
| 确认与重组(reorg) | 最新区块可能被替换,所以重要操作要等后续区块确认,不能把刚查到的事件当成绝不会变化;原理见 回执失败和短重组为什么是两件事 |
# viem 的 readContract 和 writeContract 有什么区别?
建议回答:readContract 像查账,例如查询是否买过课程,不发送链上交易;writeContract 像提交一笔操作,例如授权或购课,需要签名并支付手续费。拿到交易哈希只说明已提交,必须等回执才能知道这次操作是否成功。
# 为什么事件适合做历史和触发,不适合做合约内权限判断?
建议回答:事件像操作日志,后端可以用它展示购买历史或触发发证,但合约不能直接查询自己过去的日志。所以判断某人是否买过课,要读 purchased 购买记录,不能说 “以前发过购买事件就行” 。
# Solidity
本项目用 Solidity (opens new window) 0.8.24 编写代币、购课、结业证书、申领入口和 CRE Receiver 合约。基础能力见 Aladdin:Solidity、Foundry 与 Hardhat。
- 购买记录:用 mapping 查询用户是否买过课程,同时维护 purchasedCourseIds 和 userCourses 数组,支持列出已购课程。
- 金额与事件:课程价格按 YD 最小单位存成 uint256;购买事件按用户和课程 ID 建索引,方便链下查询。
- 外部调用:扣款、NFT 安全铸造和 Receiver 调用都可能失败或触发回调,因此要检查权限、先更新状态,再调用外部合约。
相关代码:packages/contracts/src/CoursePlatform.sol(购课规则)、packages/contracts/src/CourseCompletionNFT.sol(证书规则)。
# 已经用 mapping 记录购买状态,为什么还要维护已购课程数组?
建议回答:两者解决的问题不同:mapping 用来判断 “这个用户是否买过这门课”,数组用来列出 “这个用户买过哪些课”。Solidity 的 mapping 只能按给定的键查询,不能直接遍历所有已记录的键,所以项目另外保存课程 ID 数组,用于展示已购课程列表。
# 为什么课程价格在合约中用整数存储,而不是小数?
建议回答:代币金额按最小单位存整数,不用小数。比如 1 YD 有 18 位精度,链上保存的是 10 的 18 次方;前端用 bigint 计算,展示时再转换。uint256 与 ERC20 金额接口一致,关键是避免用 JavaScript 浮点数算钱,而不是所有 ABI 类型都只能是 uint256。
# Foundry
本项目用 Foundry (opens new window) 测试合约,重点验证权限、购课扣款、重复购买、证书发放,以及失败时状态是否回滚。测试语言、TypeScript 支持和工具分工见 Foundry 与 Hardhat 有什么区别。
- 模拟不同身份:通过
vm.prank指定下一次合约调用的调用者,模拟学生、管理员或发证地址,检查哪些操作允许、哪些必须拒绝。vm是 Foundry 提供的测试工具入口。 例如,student是测试中的学生地址,先执行vm.prank(student),再调用购课方法,合约看到的调用者msg.sender就是该学生。它只影响测试环境中的下一次调用,不需要学生的私钥。 - 检查失败和事件:用
vm.expectRevert验证无权限、重复购买等操作是否按预期回滚;用vm.expectEmit检查购课、发证是否产生正确事件。这两个方法也由 Foundry 提供。 - 验证真实交互:先通过单元测试和 Foundry 的本地链工具 Anvil 验证合约逻辑,再到 Sepolia 测试网检查真实交易广播、RPC 和第三方合约交互。本地通过不代表真实环境一定成功,两类结果分别记录。
相关代码:packages/contracts/foundry.toml(工具配置)、packages/contracts/test(合约测试)、packages/contracts/script(部署与记录脚本)。
# 为什么项目选择 Foundry,而不是 Hardhat?
建议回答:主要是 Foundry 这套工具已经能满足项目的合约测试和部署需求。购课扣款、重复购买和发证权限都可以直接用 Solidity 写测试,再用 vm.prank 模拟不同身份、vm.expectRevert 检查失败情况,配合 Anvil 做本地联调,不必额外维护另一套工具链。Hardhat 3 同样支持 Solidity 测试;如果团队已有 Hardhat 的插件和部署流程,继续用它也合理。
# 怎样确认合约部署成功,避免前端使用错误的合约地址?
建议回答:模拟成功或拿到交易哈希,都不等于部署成功。我会先核对 chainId,确认部署到目标链;再等交易成功回执,检查目标地址确实有合约代码,并核对管理员、支付币地址等关键配置。确认无误后,再保存部署记录,并按链和环境更新前端使用的合约地址,避免把模拟地址、测试网地址或旧地址用于正式交易。
相关代码:packages/contracts/script/DeployCoursePlatform.s.sol(广播部署)、packages/contracts/script/RecordCoursePlatformDeployment.s.sol(校验并记录部署)。
# 怎样验证合约的权限控制确实有效?
建议回答:我会同时测试该允许的操作和该拒绝的操作。例如管理员可以管理课程,普通学生调用同一接口必须失败,并确认课程状态没有变化。如果管理员权限发生转移,还要验证旧管理员不能再操作、新管理员可以操作。测试时用 Foundry 的 vm.prank 模拟不同身份,用 vm.expectRevert 检查预期的权限错误。
# OpenZeppelin v5
本项目复用 OpenZeppelin v5 (opens new window) 的四个基础合约:ERC20、ERC721 分别实现 ERC-20 代币标准和 ERC-721 NFT 标准;Ownable 提供管理员权限控制;ReentrancyGuard 提供防重入保护。库的定位和安全边界见 Aladdin:OpenZeppelin。
onlyOwner、nonReentrant 是这些合约提供的函数修饰器(modifier),加在函数声明上,分别增加管理员权限检查和防重入保护;transfer()、approve() 则是执行转账、授权等具体操作的函数。
- YDToken:基于 ERC20 实现支付代币,增发入口受 owner 权限控制。
- CoursePlatform:用 Ownable 限制课程管理,用 ReentrancyGuard 保护购买入口;课程是否有效、是否已购买仍由业务代码检查。
- CourseCompletionNFT:基于 ERC721 实现证书,在 v5 的统一状态更新入口 _update 拦截转让和销毁,确保铸造后不可转让。
相关代码:packages/contracts/src/YDToken.sol(ERC20 与 Ownable)、packages/contracts/src/CourseCompletionNFT.sol(ERC721)、packages/contracts/src/CoursePlatform.sol(防重入)。
# 为什么没有使用 SafeERC20?
建议回答:项目当前只接受自己的标准 YD 代币,代码会检查转账是否返回成功。若以后接入其他代币,我会用 SafeERC20 包装调用,因为有些代币成功后不返回布尔值,直接按标准返回值处理会出错。它解决调用兼容,不代表任何代币的业务行为都安全。
# 核心合约与权限
| 合约 | 负责什么 | 关键权限与记录 |
|---|---|---|
| YDToken | 发行与转移课程支付币 YD | owner 是管理员,可增发;balances 记余额,allowances 记扣款授权额度 |
| CoursePlatform | 上架课程、收款、记录谁买过课程 | owner 管课程;purchased 记购买关系;treasury 是收款钱包 |
| CourseCompletionNFT | 给完成课程的钱包发不可转让证书 | issuer 是发证地址;hasClaimed 阻止同一钱包重复领取同一课程证书 |
| CompletionRequest | 接收用户的发证申请并留下事件 | 不直接判断是否结业;由链下服务向后端核验 |
| CRE Receiver | 接收 CRE 的执行结果,再调用证书合约 | 只接受指定转发合约和工作流的报告,并检查目标链与接收地址 |
- 合约如何更新:项目没有使用代理升级(原理见 代理升级到底改了什么),不能在原地址直接替换合约代码。修改业务逻辑需要部署新合约,让应用切换地址,并按需处理旧数据和资产的迁移;课程价格等已有接口允许修改的配置不受此限制。
- 管理与发证权限:
owner是管理员,issuer是发证地址。合约代码不能更换,不代表管理员不能操作:例如 owner 可以管理课程、替换发证地址,issuer 可以发证。因此这些账户的密钥和操作权限仍需保护。
相关代码:packages/contracts/src(业务合约)、apps/server/src/trpc/init.ts(后端权限校验)。
# ERC20 与 approve purchase
approve 是授权扣款,purchase 才是实际买课。例如课程价格为 100 YD:用户先允许 CoursePlatform 最多使用 100 YD,再调用 purchase。课程合约检查上架状态和是否重复购买,通过 transferFrom 把 100 YD 从用户钱包转到 treasury 收款地址,同时记录购买关系。
调用 approve 时,需要指定授权给谁(spender)和授权额度。本项目授权的是课程合约 CoursePlatform,让它能扣取购课费用。剩余授权额度叫 allowance,表示该合约还能从用户账户中扣取多少 YD;买课前先检查,额度足够支付本次费用就不用再次授权。
用户 → YDToken.approve:允许课程合约扣取指定数量的 YD
用户 → CoursePlatform.purchase:请求购买指定课程
课程合约 → YDToken.transferFrom:把课程费用转到收款地址
购买成功 → 扣款与购买记录一起生效
购买失败 → 本次购买中的扣款和记录一起回滚
2
3
4
5
这里回滚的只是本次 purchase 交易,不会撤销之前单独完成的 approve 授权。
# 为什么不能让购买合约直接扣用户代币?
建议回答:ERC20 不允许任意合约划走用户余额。用户必须通过 approve 明确授予 spender 和额度,之后 spender 才能 transferFrom。这是资产控制权的基本边界。
# 精确授权和无限授权怎么选?
建议回答:只授权本次费用,合约最多只能扣这笔额度,风险小,但以后买课可能要再授权;无限授权省去重复操作,但合约一旦出问题,更多余额会暴露。项目支持授权选项,界面应明确告诉用户授权给谁、多少额度,不能把无限授权当成无风险便利。
# CoursePlatform 的安全与完整性
购买时要同时守住三件事:课程可以买、用户没重复买、钱确实扣成功。
- 先检查 active 上架标记和 purchased 购买记录,再写入本次购买状态,最后调用代币合约扣款。扣款失败时,购买记录也一起回滚。
- 这个顺序叫 CEI(Checks–Effects–Interactions,先检查、再更新状态、最后调用外部合约)。配合 nonReentrant 防重入锁,防止外部调用又绕回购买入口,利用旧状态重复操作。
- treasury 收款地址允许管理员更新,便于换成多签钱包;ydToken 支付币地址部署后固定,不能悄悄把支付币种换掉。下架课程不会删除用户已经买过的记录。
已知问题:setCourseActive 没检查课程是否存在。管理员若误把陌生课程 ID 设为上架,它的默认价格是零,可能出现本来没创建却能购买的课程。应单独增加 exists 存在标记;只检查价格非零会误伤合法免费课程。
# 先记录已购买再扣款,扣款失败会不会留下错误记录?
建议回答:不会。EVM 交易是原子的,如果 transferFrom 返回 false 或 revert,后续 require 失败会让整笔交易回滚,前面写入的 purchased 和数组也一起恢复。先写状态还能减少外部调用重入时读到旧状态的风险。
# 课程购买合约还有哪些安全问题需要改进?
建议回答:先补课程存在性检查,防止管理员误把不存在的课程上架成零价;再根据是否要支持其他代币决定使用 SafeERC20;主网上线前用多签保护管理员和收款地址。优先修能造成错误购买的具体问题,不是先堆治理组件。
# 为什么收款地址允许修改?
建议回答:treasury 是收款钱包,将来可能要换成多签钱包,因此允许管理员修改;修改时要拒绝零地址并发出事件,便于追踪。
# 为什么支付代币的合约地址部署后不能修改?
建议回答:ydToken 是本合约接受的支付币地址,部署后固定,用户才不会今天授权 YD、明天管理员把币种悄悄换掉。immutable 就是部署时设定、之后不能再改。
# 为什么新增课程和修改课程共用一个接口?
建议回答:upsert 指有记录就更新、没有就新增。同一个接口既能上架新课程,也能给现有课程改价并重新上架。这样调用简单,但还应单独区分课程是否创建过,避免默认零值被误当作有效课程。
# 如何支持免费课程,同时避免用户购买不存在的课程?
建议回答:当前代码允许,可能用于免费课程。问题不在零价本身,而在不存在的课程也有默认零价,所以需要单独的存在标记,不能只用价格判断这门课是否有效。
# 用户签名前管理员改了课程价格,会按哪个价格扣款?
建议回答:当前 purchase 只接收课程 ID,执行时读取链上的现价,不保证等于用户刚才看到的价格。只授权原金额能限制部分涨价风险,但不是完整的价格保护。更严谨的合约接口应让用户传入愿意支付的最高价格,链上发现现价超出就回滚;前端也应在提交前刷新报价。
# 课程下架后,原来的购买记录会消失吗?
建议回答:不会。下架改变的是是否允许继续购买,合约里的已购记录仍然保留。已购用户还能否访问视频属于内容访问规则,不能靠删除购买事实解决。如果涉及违规内容下线或退款,要另设明确流程,不把下架当成自动退款。
相关代码:packages/contracts/src/CoursePlatform.sol(购买与扣款)、packages/contracts/test/CoursePlatform.t.sol(权限和失败路径测试)。
# ERC721 与 ERC5192 Soulbound
这里的 NFT 是结业证书,不是可以买卖的收藏品。ERC721 (opens new window) 负责给每张证书唯一编号并记录持有人;ERC5192 (opens new window) 让钱包知道这张证书处于锁定状态,不能转让。Soulbound(灵魂绑定)就是绑定钱包、不能转走的凭证。
- issuer 发证地址才能 mint(铸造证书);owner 管理员只能更换 issuer,日常发证和管理权限分开。
- hasClaimed 按钱包和课程记录是否领过,防止重复发证。
- 在 OpenZeppelin v5 的统一更新入口 _update 只允许首次铸造,阻止后续转让和销毁;另外禁用授权接口,拒绝授权别人转走证书。
- tokenCourse 记录证书对应哪门课;合约按证书编号
tokenId保存资料的 IPFS 地址,tokenURI(tokenId)是读取这个地址的方法。地址指向证书资料文件(metadata JSON),其中记录名称、说明、课程信息和图片地址;链上只保存资料地址,JSON 和图片文件放在 IPFS 上。
这种绑定只能证明某个钱包持有证书,不能阻止持有人私下出售整个钱包的私钥。
# 为什么把结业证书设计为不可转让的 NFT?
建议回答:证书是平台核验结业后发给学生钱包的凭证,不是用来交易的商品。如果允许转让,没学过课程的人也能买到证书,持有证书就不再代表获得过结业资格。所以项目用 ERC721 记录证书编号和归属,再按 ERC5192 标准提供锁定状态,并在合约中禁止转让。
# 合约怎样保证证书不能被转让?
建议回答:不能只在前端隐藏转账按钮,因为用户可以直接调用合约。项目在 OpenZeppelin v5 的统一更新入口 _update 中,只允许首次铸造,拒绝后续转让和销毁。transferFrom 和 safeTransferFrom 都会经过这里,不用分别拦截每个转账入口;同时禁用授权接口,避免用户做无效授权。locked 接口告诉钱包证书已锁定,真正阻止转让的是合约里的检查。
追问:钱包怎样识别这种证书?
建议回答:支持这些标准的钱包可以先通过 supportsInterface 查询合约是否支持 ERC721 和 ERC5192,再通过 locked 查询某张证书是否锁定。前者回答 “支持哪些标准” ,后者回答 “这张证书能不能转让” 。
# 发证服务重试时,怎样避免重复发证?
建议回答:合约用 hasClaimed 按 “钱包地址+课程 ID” 记录是否领过证书。每次发证先检查,已经领过就拒绝;没领过就先标记,再铸造,两步在同一笔交易里完成。例如上次发证成功但后台没收到回执,再次提交也不会多发一张。后台去重减少无效交易,合约检查保证同一钱包对同一课程只能领一次。
# 为什么把发证权限和管理员权限分开?
建议回答:自动发证服务需要经常签名,但不需要管理整个合约,所以项目把 issuer 发证地址和 owner 管理员分开。发证密钥泄露后,攻击者不能直接更换管理员或发证地址,管理员可以通过交易换掉被盗的 issuer,限制后续损失。但更换权限不会撤销已经违规发出的证书,因此发证密钥仍要严格保护。
# 给合约地址发证,有哪些额外的安全风险?
建议回答:一是接收合约可能不支持 NFT,所以项目用 _safeMint 调用对方的 onERC721Received 接收接口,确认对方接受这张证书。二是这个回调会执行对方代码,可能再次调用发证接口;项目除了检查发证权限,还在回调前标记已领取,防止重复发证。如果接收检查失败,整笔发证交易会回滚,领取标记也一起撤销,不会出现没拿到证书却被记成已领取。
# 学生钱包丢了,证书能直接转到新钱包吗?
建议回答:不能,当前合约没有转移或撤销证书的入口,这是绑定钱包的代价。如果以后需要支持找回,就要增加身份核验、旧证书失效和新证书补发机制,并让验证方检查证书是否有效,避免新旧证书同时有效。不能直接开放自由转让,否则证书就可以被买卖了。
相关代码:packages/contracts/src/CourseCompletionNFT.sol(证书铸造与转移限制)、packages/contracts/src/interfaces/IERC5192.sol(灵魂绑定接口)。
# 合约安全问题清单
| 风险 | 处理重点 |
|---|---|
| 重入攻击:利用外部回调重复操作 | 外部合约可能在本次操作结束前再次调用回来,利用尚未更新的状态重复操作。防护:先检查并更新状态,再调用外部合约;购买入口加防重入锁,阻止执行途中再次进入 |
| 重复购买或领取证书 | 合约按钱包与课程检查记录,不能只靠前端禁用按钮 |
| 非法发证或管理权限泄露 | 分开发证与管理员权限;生产用多签管理关键权限,并监控异常发证 |
| 部署到错误网络、记录假地址 | 广播前检查链 ID,记录地址时读取成功回执 |
| 管理员无限增发 YD | 明确代币总量规则,按业务需要增加上限、多签或延迟执行 |
| 不存在的课程被误上架 | 增加 exists 存在标记,不能把默认零价当成课程存在 |
权限检查回答 “谁能操作” ,业务检查回答 “这次操作是否合理” ,两者缺一不可。
相关代码:packages/contracts/src(合约规则)、packages/contracts/test(安全用例)。
# 项目为什么没做合约升级?以后需要修改怎么办?
建议回答:当前项目没有使用代理升级,选择的是更简单的部署和权限模型,代价是部署后不能直接替换合约逻辑。以后需要修改,就要部署新合约,并根据旧合约的能力安排数据衔接和资产处理,不能自动把旧状态搬过去。代理升级可以保留地址和已有数据,但需要严格控制升级权限,并测试新版本能否正确读取旧数据、继续处理原有业务。
# 只有管理员能增发 YD,为什么仍然存在风险?
建议回答:只有管理员能增发,不代表增发一定合理。当前 YD 没有总量上限,管理员滥用权限或密钥被盗,都可能导致大量增发,让原持有人的代币占总量的比例下降;新增代币如果被大量卖出,还可能压低市场价格。可以增加总量上限约束数量,用多签要求多方批准,再用时间锁延迟执行,给异常操作留出发现和应对的时间。这些是需要进一步增加的约束,不是有管理员权限检查就自动具备了。
# 第三部分 Uniswap V2 与课程购买
这一部分先记住
Uniswap V2 负责换币,课程合约负责卖课。用户已有足够的 YD 就直接进入购课流程;不足时,才需要先把 ETH 或 USDC 换成 YD。换币和购课是不同的交易,都要等链上确认成功,不能把钱包返回交易哈希当成已经完成。
# Uniswap V2
项目用 Uniswap V2 (opens new window) 解决用户没有 YD、却想购买课程的问题:用户交出 ETH 或 USDC,从预先存入两种代币的资金池中换出 YD,不需要等另一个用户刚好挂单出售。用资金池和公式完成兑换,就是自动做市(AMM);基础概念见 Uniswap、AMM 和 LP 是什么关系。
项目在 Sepolia 测试网上建立了 YD/WETH 和 YD/USDC 两个池子。WETH 是包装成 ERC20 标准的 ETH,方便池子用同一套规则处理代币;用户用 ETH 兑换时,Router 会完成这一步包装。
相关代码:apps/web/src/hooks/use-swap-quote.ts(报价)、apps/web/src/hooks/use-eth-to-yd-swap.ts(ETH 兑换)、apps/web/src/hooks/use-usdc-to-yd-swap.ts(USDC 授权与兑换)。
# Pair、Router 和 Factory 分别做什么?
| 合约 | 用本项目理解 |
|---|---|
| Pair(交易对资金池) | 真正存放两种代币、执行兑换。例如 YD/USDC 池收到 USDC,再按规则付出 YD。提供资金的人拿到 LP 代币,作为池中份额的凭证 |
| Router(兑换路由合约) | 前端主要调用它:先查询能换多少 YD,再提交兑换。它按指定路径把资金送入相应池子,也能串联多个池子完成兑换 |
| Factory(资金池工厂) | 创建和查找 Pair,例如找到 YD/USDC 对应的资金池地址 |
这里的 Router 和前端路由有什么区别?
Router 是 Uniswap 的正式命名,例如 V2 的 UniswapV2Router02 合约。前端 Router 根据 URL 找到页面组件;这里的 Router 根据兑换路径调用资金池,处理的是换币,不是页面跳转。
例如,传入 USDC → WETH → YD,且对应池子有可用流动性时,Router 会在同一笔交易中依次通过 USDC/WETH、WETH/YD 两个池子完成兑换。V2 Router 按调用方传入的路径执行,不会自己搜索全市场的最优路径;路径由前端或链下路由服务选择。
# 为什么选择 Uniswap V2,而不是 V3 或 V4?
建议回答:课程只接受 YD,接入 Uniswap 是为了让持有 ETH 或 USDC 的用户先换币再买课,不用自己开发兑换协议。项目还需要为 YD 建池并提供初始流动性。V2 的机制比较直接,不需要维护价格区间,能满足当前的基础兑换需求;我们暂时不需要 V3 的精细化区间管理,也没有 V4 的定制交易规则需求。代价是资金利用效率通常不如合理配置的集中流动性。选择 V2 是用资金效率的取舍换取更简单的维护。
| 版本 | 主要特点 | 放到本项目里怎么取舍 |
|---|---|---|
| Uniswap V2 | 流动性覆盖整个价格范围,使用恒定乘积定价,不需要设置价格区间 | 建池、兑换和维护比较直接;代价是资金分布较分散,要提供同等交易深度,可能需要更多资金 |
| Uniswap V3 | 支持集中流动性:把资金放在指定价格区间,提高区间内的资金利用效率 | 可以用较少资金在目标区间提供更深的流动性;但要选择区间,并考虑价格超出区间后的维护 |
| Uniswap V4 | 延续集中流动性,并支持 Hooks,即在兑换、增减流动性等操作前后执行自定义合约逻辑 | 适合需要定制池子规则的场景,例如根据条件调整费用;本项目暂无这类需求。Hooks 是可选的,普通兑换不要求自己编写 Hook |
例如,假设 YD 价格在 1 USDC 附近,V3 可以把一笔流动性集中在 0.9~1.1 USDC,而不是覆盖所有价格。这样同样资金在这个区间内能支持更多兑换;但价格超出区间后,这笔流动性会暂时停止参与兑换、赚取手续费,不代表整个池子都停止交易。价格回到区间会重新参与,也可以主动调整区间。V3 同样可以选择接近全范围的区间,只是集中资金的优势会减弱。
- 接入兑换不等于维护流动性:如果只是使用别人建好的 V3 池子换币,价格区间由流动性提供者维护,不能把这项工作算成前端的接入负担。
- 没有 Hooks 需求,也可以使用 V4:Hooks 是可选的,V4 也不只有这一项改进。是否采用仍要比较目标网络的可用池子、实际报价、费用和接入成本,不能只凭版本号决定。
# 用 V2,怎样保证用户换币时不会吃亏太多?
建议回答:版本本身不能保证好价格,还要看池子有多少资金、用户换多少,以及手续费。项目查询报价,并把用户接受的最低接收数量带到交易里;如果成交结果低于下限,就让交易失败。要改善兑换体验,还需要足够的流动性,对价格影响过大的交易给出提示或限制。滑点保护限制的是成交结果比报价差多少,不能把一个本来就很差的报价变好。
# 什么情况下会考虑迁移到 V3 或 V4?
建议回答:如果实际兑换量增加,现有资金难以提供足够的交易深度,就可以评估集中流动性能否降低资金需求;如果需要定制费用或交易规则,再评估 V4 Hooks。同时比较不同方案的实际报价、手续费和维护成本。迁移不是把 V2 池子直接升级:需要安排新池子的流动性,调整报价、授权和兑换调用,再验证最低接收数量、失败处理和交易确认;已有的购课规则不必因此重写。
官方资料:Uniswap 集中流动性 (opens new window)、Uniswap V4 概览 (opens new window)、Uniswap V4 Hooks (opens new window)。
# 项目具体怎样完成换币和购课?
建议回答:用户已有足够 YD 就直接进入购课流程;不足时,先查询兑换报价,让用户确认最低接收数量。USDC 和 ETH 都通过 Router 兑换为 YD:用 USDC 时,先检查授权额度,不足就先授权,再调用 Router 扣取 USDC 完成兑换;用 ETH 时,不需要授权,而是在调用 Router 时随交易转入 ETH,由 Router 包装成 WETH 后完成兑换。兑换交易确认成功后,再检查 YD 余额及课程合约的扣款额度,必要时完成授权,再提交购买交易。最后等购买回执成功,才更新购买状态;不能把交易哈希当成已经成功。
对应调用:getAmountsOut 查询预计输出;swapExactETHForTokens 用 ETH 换 YD;swapExactTokensForTokens 用 USDC 换 YD;approve 在额度不足时授权对应合约;CoursePlatform.purchase 完成购课。
# 为什么用 USDC 换币前要先授权兑换合约?
建议回答:Router 不能随意动用用户钱包里的 USDC,必须先获得用户授权的扣款额度,也就是 allowance;额度不足时才需要调用 approve。用 ETH 兑换则是把 ETH 随交易一起交给 Router,不需要这一步。还要区分两次授权:换币时授权 Router 使用 USDC,买课时授权 CoursePlatform 使用 YD,用途和收款合约都不同。
# 报价成功为什么交易仍可能失败?
建议回答:报价只是查询当时能换多少,不会锁定价格。比如页面预计能换到 100 YD,用户要求至少收到 99 YD,但等待交易确认时别人先换了币,导致现在只能换到 98 YD,合约就会拒绝兑换。除此之外,余额或授权不足、交易过期、手续费不足也可能导致失败。
# 为什么不用前端自己算报价?
建议回答:直接调用 Router 的报价方法更省事,也不容易算错手续费或兑换路径。它会根据池中最新读取到的代币数量计算预计输出;前端负责展示和设置保护条件,但这个报价仍不保证最终成交结果。
# Uniswap V2 的 LP 代币是什么,普通换币会获得吗?
建议回答:LP 是 Liquidity Provider,也就是流动性提供者。LP 代币代表提供资金的人在池子里的份额,普通换币不会获得它。比如用户用 USDC 换 YD,拿到的只有 YD;只有向 USDC/YD 池子按比例存入两种代币、添加流动性,才会获得这个池子的 LP 代币。
移除流动性时,交回并销毁对应的 LP 代币,就能按份额取回池子当时持有的两种资产。不同池子的 LP 代币不能混用,取回的资产数量也不一定等于最初存入的数量。
# 用户连续修改换币金额,怎样避免显示或使用旧报价?
建议回答:只使用当前输入对应的最新报价,之前请求返回的结果不能覆盖它。比如用户先输入 10 USDC,马上又改成 100 USDC,就会先后发出两次报价请求。如果 100 USDC 的报价先返回、10 USDC 的报价后返回,直接用最后返回的结果更新页面,就会出现输入框是 100 USDC、报价却对应 10 USDC 的错误。
处理时,每次金额、币种或网络变化,都让旧报价失效,并递增一个请求编号。结果返回时,只有编号仍是最新的,才更新页面;新报价还没拿到时,禁用兑换按钮。提交前再确认报价对应当前金额、币种和网络,并把根据这份报价设置的最低接收数量、交易截止时间一起传给 Router,避免使用旧报价,也限制等待成交期间价格变化带来的风险。
# 恒定乘积举例
池子不是按固定单价卖 YD:换走的 YD 越多,剩下的越少,继续购买就越贵。V2 用 x × y = k 表达这个规则,x、y 是池中两种代币的数量;忽略手续费时,兑换前后两者的乘积保持不变。
例如池里有 10,000 YD 和 100 USDC,乘积是 1,000,000。投入 1 USDC 后:
- USDC 变成 101,池里需要留下
1,000,000 ÷ 101 ≈ 9,900.99 YD。 - 因此用户拿到的是
10,000 − 9,900.99 ≈ 99.01 YD,不是按最初比例换到整整 100 YD。
这就是价格影响(price impact):自己的交易改变了池内比例,买得越多、池子越小,平均成交价越差。实际 V2 兑换还收取输入金额的 0.3% 作为手续费,预计收到的 YD 会更少;手续费留在池中,因此实际乘积会增长,并非永远不变。
相关代码:apps/web/src/hooks/use-swap-quote.ts(调用 Router 获取报价)、apps/web/src/lib/slippage.ts(最小接收量计算)。
# 滑点容忍度 1% 是什么意思?
建议回答:如果页面报价是 100 YD,设置 1% 就代表至少接受 99 YD,前端把 99 写入最低接收数量 amountOutMin。实际只能换到 98 YD 时,合约会让兑换失败,而不是让用户吃下这个差价;链上失败的交易仍需付 gas。价格影响已经体现在报价里,滑点容忍度限制的是成交结果还能比这个报价差多少,不是额外手续费。
# 什么是夹子攻击,这个项目如何降低风险?
建议回答:攻击者看到用户准备买 YD,就抢先买入推高价格,等用户买完再卖出获利,相当于把用户的交易夹在中间。最低接收数量能限制用户接受多差的结果;deadline 是交易有效期,防止旧交易隔很久才执行,但它不能阻止有效期内的夹子攻击。更深的资金池和受保护的交易提交渠道能进一步降低风险;项目的 Sepolia 小额测试只能验证兑换流程,不能据此证明主网交易安全。
# x 乘 y 等于 k 是否真的永远不变?
建议回答:不是。忽略手续费时,可以用乘积不变计算一次兑换;实际手续费留在池里,乘积通常会增大,添加或取出资金也会改变它。所以它是一套定价约束,不是说这个数字从建池起永远固定。
# 价格影响和滑点有什么区别?
建议回答:价格影响是自己的交易把池内价格推高或压低,通常已计入页面报价;滑点是最后成交与该报价的差异,可能由等待期间的其他交易造成。滑点容忍度才是允许结果比报价差多少的上限,它不是滑点本身。
# 流动性太少会怎样?
建议回答:同样交易产生更大价格影响,报价更差,也更容易被操纵。Sepolia 小额流动性池只能证明协议接入和交易链路可运行,不能代表主网所需的流动性深度与抗操纵能力。
# 完整购课状态机
这里的状态机就是把购买拆成几个明确阶段,而不是点击后一直转圈。下表从用户已有足够 YD 开始;不足时先完成前面的换币流程。
| 阶段 | 要做什么 | 失败或等待时怎么处理 |
|---|---|---|
| 购买前检查 | 读取价格、余额和扣款额度 | 网络或余额不满足时先提示,不急着发送交易 |
| 授权 | 额度不足才调用 approve | 用户拒签就停止,不自动重复弹出钱包 |
| 等待授权确认 | 查授权交易回执,再继续购买 | 保留交易哈希;暂时查不到结果不等于失败 |
| 提交购买 | 调用 CoursePlatform.purchase | 区分用户取消、网络错误和合约拒绝 |
| 确认购买成功 | 检查回执,并刷新购买状态 | 结果未知时保留哈希、禁用提交,提示核对链上记录 |
相关代码:apps/web/src/hooks/use-purchase-flow.ts(购买流程)、apps/web/src/components/ui/transaction-status.tsx(交易状态展示)。
# 遇到页面一直转圈,你会怎样定位?
建议回答:先分清卡在钱包签名、交易广播,还是链上确认。没有交易哈希,就查用户是否拒签、网络是否正确;有哈希就查回执和目标合约;链上成功但页面没变,再查缓存和后端查询。按阶段缩小范围,比反复刷新或让用户重新购买更安全。
# 为什么授权和购买要分别展示进度?
建议回答:授权和购买是两笔交易。用户可能已经授权成功,却拒绝了购买;也可能已提交购买,只是还没确认。界面分开显示 “等钱包签名” 和 “等链上确认” ,用户才知道卡在哪一步,也不容易误点重发。
# 用户切换钱包时旧请求怎么办?
建议回答:给请求记下发起时的钱包、课程和版本号。用户切换后,旧结果返回时版本不匹配,就不能覆盖新界面。已经广播的交易无法靠切换钱包取消,仍应按原钱包查询结果,不能把它算到新钱包头上。
# 交易失败 gas 会退吗?
建议回答:状态修改回滚,但执行消耗的 gas 不退。前端应提前校验余额、allowance、网络和配置,仍不能完全消除链上竞争导致的 revert。
# 兑换交易的有效期应该设多长?
建议回答:deadline 是允许交易执行的最后时间,避免一笔旧兑换很久后才成交。具体时长要看网络拥堵和用户确认时间,太短容易正常失败,太长让报价更容易过时。它限制时间,最低收到数量才限制成交价格。
# 为什么不把换币和买课合并成一笔交易?
建议回答:可以写组合合约,让换币和买课在同一笔交易里完成,任一步失败都回滚。但还要正确处理多余代币退款、兑换保护和授权,安全审查更复杂。项目先拆开复用成熟接口,用户步骤多一些,但资金流向和错误更容易看清。
# 授权成功但购买失败,授权会自动撤销吗?
建议回答:不会,因为它们是两笔独立交易。购买失败会回滚本次购买,但之前成功的授权仍然存在。页面应说明还没买课、额度仍可能保留;重试前重新检查价格和授权,用户不想继续时可以主动撤销额度,而不是期待系统自动退款。
# 链上购买成功,页面却还显示没买,怎么办?
建议回答:先查原交易的回执和正确网络上的购买记录。如果已成功,就刷新对应钱包和课程的查询缓存,并检查 RPC 是否延迟;结果还不确定就展示确认中。不能把一次查询失败解释成没付款,更不能马上再让用户提交购买。
# 换了钱包,为什么原来买过的课不能直接继续看?
建议回答:购买关系记录在付款的钱包地址下。切到新钱包后,要重新建立对应会话并核验它自己的购买关系,不能沿用旧钱包的缓存或权限。若产品希望多个钱包共享权益,需要额外的钱包绑定规则,不能因为浏览器是同一个就自动共享。
# 第四部分 前端、后端、认证与数据库
这一部分先记住
前端负责钱包交互和状态展示,Hono 与 tRPC 负责服务端接口,PostgreSQL 保存高频业务数据。钱包地址只是身份标识,只有完成 nonce 签名验证并建立服务端 Session 后,才能作为已登录用户访问受保护资源。
# TypeScript
Web、API、Relayer、CRE Workflow、CDK 和共享包统一使用 TypeScript (opens new window)。基础原理见 Aladdin:TypeScript 与 Zod,本项目重点处理三类数据边界:
- 接口类型:后端导出 AppRouter 类型,前端通过 tRPC 获得输入输出提示,接口变更能在编译时暴露。
- 链上金额:uint256 用 bigint 计算,通过 API 返回时转成十进制字符串,避免浮点误差和 JSON 序列化失败。
- 外部输入:请求、环境变量和 Workflow 配置仍需运行时校验;Address、Hex 等类型不能代替地址合法性和签名验证。
相关代码:apps/web/src/hooks/use-purchase-flow.ts(交易类型与金额)、apps/server/src/trpc/routers/index.ts(接口类型出口)。
# USDC 和 YD 的小数位数不同,怎样准确处理金额?
建议回答:合约用整数记录代币的最小单位,所以要按每种代币自己的小数位数转换,不能直接把用户输入的小数传给合约。比如 USDC 是 6 位小数,1 USDC 就对应 100 万个最小单位。用户输入 1.5 USDC 时,用 viem 的 parseUnits('1.5', 6) 转成 1500000n,末尾的 n 表示这是 bigint 整数;YD 是 18 位小数,就按 18 位转换。金额计算使用 bigint,避免 JavaScript Number 的精度问题;展示时再用 formatUnits 按对应的小数位数转回可读金额。
这里转换的是金额的表示单位,不是两种代币的兑换比例。就像把元换成分,金额本身没变。USDC 能换多少 YD,要看资金池报价,不能根据 6 位和 18 位小数直接推算。
# Next.js 16 与 React 19
本项目使用 Next.js 16 (opens new window) 与 React 19 (opens new window) 构建课程、购买、兑换、证书和后台审核页面。组件与渲染原理见 Aladdin:React 和 Next.js;Aladdin 手册的 2025 年背景采用 Next.js 15,具体构建配置仍需按各项目的实际版本和脚本区分。
前端构建由 Next.js 管理,不是 Vite。当前代码使用 Next.js 16,开发命令是 next dev,生产构建命令是 next build,都没有指定其他打包器,因此默认使用 Turbopack。Vitest 单独运行测试,不改变应用的构建方式。通用分工见 Next.js、打包工具与测试工具的关系。
- 页面与交互分开:不依赖浏览器状态的内容可在服务端渲染,钱包连接、签名和交易操作放在 Client Component。
- 交易按阶段展示:swap、purchase、claim 分别由 Hook 管理,区分钱包确认、链上确认、成功、失败和结果暂时未知。
- 钱包切换隔离:每次切换钱包时递增钱包状态版本号。异步请求开始时记下版本号,返回后与当前版本号比较,不一致就忽略结果,防止 A 钱包的余额或交易结果覆盖 B 钱包的界面。
当前交易历史仍依赖 RPC 查询;访问量增加后,需要引入事件索引或服务端缓存,而不是让每个页面重复扫描链上日志。
相关代码:apps/web/src/app(页面入口)、apps/web/src/app/providers.tsx(全局 Provider)、apps/web/src/hooks/use-purchase-flow.ts(交易交互)、apps/web/package.json(开发与构建命令)、apps/web/next.config.ts(Next.js 配置)。
# Tailwind CSS v4
项目用 Tailwind (opens new window) 开发课程卡片、购买按钮、表单和手机端布局。主要看中它的原子化工具类:把布局、间距、颜色等样式拆成小粒度的类,直接组合使用,减少重复写 CSS 和给样式起名字的工作。例如 flex 设置弹性布局,p-4 设置内边距,rounded-lg 设置圆角,不必为每张卡片重新写一套相似样式。
- 开发更快:直接组合工具类完成界面,多个组件也能复用同一条 CSS 规则,不需要各自重复声明相同样式。
- 风格更统一:课程卡片、表单和按钮共用主题里的颜色、字号和间距,减少不同页面各写一套数值的问题。
- 状态和适配更方便:例如
hover:bg-blue-600设置悬停背景,md:flex设置中等及以上屏幕的布局,disabled:opacity-50设置禁用时的外观。不过真正禁用按钮仍需设置disabled属性,样式不能代替交互逻辑。
代价是 JSX 中的类名可能较长,所以重复的按钮、卡片会抽成组件,避免到处复制。
相关代码:apps/web/src/app/globals.css(全局样式)、apps/web/postcss.config.mjs(构建配置)、apps/web/src/components/course/course-card.tsx(工具类使用)。
# Tailwind 和 CSS Modules 怎么选?
建议回答:它们解决的问题不完全一样。Tailwind 主要通过组合工具类提高样式开发效率;CSS Modules 则让自己写的类名默认只在当前模块内生效,减少不同组件之间的类名冲突。项目中的课程卡片、按钮和表单有很多相似样式,所以主要用 Tailwind,共用颜色、间距和响应式规则。如果某个组件用独立 CSS 更清楚,也可以搭配 CSS Modules。
# Tailwind 是否会让最终 CSS 很大?
建议回答:不会因为可用类很多,就把它们全部打进 CSS。Tailwind 会扫描源码中的完整类名,按需生成对应样式;同一个工具类在多个组件中使用,也不需要重复生成规则。不过用到的不同类越多,CSS 仍会增长。要注意不能用 bg-${color}-500 这样的字符串拼接指望它自动识别颜色,应把状态映射成 bg-red-500、bg-green-500 等完整类名,否则可能缺少对应样式。
# viem
本项目用 viem (opens new window) 统一前端交易、后端读链和 Relayer 发证。客户端与钱包层的区别见 Aladdin:wagmi、viem 与 SIWE。
- 调用钱包:Privy 负责连接和选择当前钱包,前端再用 viem 的
createWalletClient接入这个钱包,得到WalletClient,用于请求钱包签名、发送授权、兑换和购课交易。 - 查询链上数据:
PublicClient也是 viem 的客户端,通过区块链 RPC 读取余额、授权额度和交易回执。这类查询不需要用户连接钱包或签名;发出交易后,也可以用它等待回执,确认交易执行结果。 - 读取合约:
readContract和multicall都是 viem 提供的方法。前者读取一次合约调用的结果,后者把多个读取调用合并成批次,减少请求次数。后端用它们查询课程与购买权限,不把浏览器传来的状态当作事实;项目自己指定合约地址、ABI、函数名和参数。 - 自动发证:Relayer 按区块范围读取申领事件,核验结业条件后,通过 KMS 签名适配器请求签名(实现见 自建 Relayer 与 AWS KMS 签名),再由 viem 广播证书铸造(mint)交易。交易执行成功后,证书合约为学生钱包创建并发放 NFT 证书;查询的区块范围过大时,拆成多个小区间读取事件。
项目当前没有使用 wagmi,而是把 viem 调用封装进自定义 Hook,串起 “检查授权额度 → 必要时授权 → 提交购买 → 等待回执 → 更新页面” 的业务流程。
相关代码:apps/web/src/lib/viem-client.ts(只读客户端)、apps/web/src/hooks/use-wallet-client.ts(钱包客户端)、apps/server/src/lib/onchain/course-platform-client.ts(后端读链)。
# 为什么直接封装 viem,而没有使用 wagmi?
建议回答:项目已经用 Privy 管理钱包连接,前端主要围绕授权、兑换和购课展开,所以当前选择直接调用 viem,再用自定义 Hook 管理交易步骤和页面状态。这样可以沿用现有的钱包接入方式,把购课流程集中维护;代价是加载状态、错误处理和数据刷新都要自己做好。
如果多个页面开始重复处理余额查询、缓存和刷新,我会优先评估 wagmi,复用它现成的 Hooks 和查询状态管理,Privy 仍可继续使用。
# 为什么交易历史直接查链上,什么时候需要存数据库?
建议回答:当前交易历史通过 RPC 查询合约事件,再整理成页面列表。这样可以直接复用链上的记录,暂时省去维护事件同步服务和数据库副本的工作。取舍是每次查询都依赖 RPC;需要扫描的历史区块越多,或者用户频繁翻页、筛选,查询就更容易变慢或遇到节点限流。
如果这些查询影响使用体验,我会让后台持续读取链上事件,整理后存入数据库,页面再通过 API 分页和筛选,避免反复扫描链上历史。后台要记录扫描进度、避免同一事件重复入库,并在链重组时修正受影响的记录。数据库负责方便、快速地查询,记录是否有效仍以链上结果为准。
# Privy
项目用 Privy (opens new window) 统一处理连接钱包、选择账户和账户切换,避免分别适配每一种浏览器钱包。当前只开放钱包登录,不使用它的其他登录方式。
Privy 解决 “网页连接哪个钱包” ,后端签名验证解决 “请求者是否真的控制这个钱包” 。前端从 useActiveWallet 取得当前钱包,再交给 viem 发送交易;不能随便取钱包列表第一项,否则用户切换账户后可能操作错钱包。
断开时还要退出后端会话。只断开钱包连接,不会自动删除服务器发出的登录 Cookie。
相关代码:apps/web/src/hooks/use-wallet-client.ts(接入当前钱包)、apps/web/src/hooks/use-wallet-sign-in.ts(签名登录)、apps/web/src/components/wallet/wallet-auth-provider.tsx(会话与钱包切换)。
# 为什么连接钱包后还要再签名?
建议回答:连接钱包只让网页知道地址,后端不能凭一串地址认定你就是本人。后端发一次性消息,让钱包签名并验证,才能确认请求者控制这个地址;之后再建立登录会话。
# 钱包签名认证完整链路
登录不是把地址发给后端,而是签一条后端能验证、只能使用一次的消息。nonce 是本次登录的随机验证码,和链上交易的 nonce 序号不是同一种用途;后者见 用 nonce 解释卡单、加速和取消。
本项目采用类 SIWE 的钱包签名登录流程(原理见 SIWE 钱包签名登录):后端生成登录消息,前端通过 personal_sign 请求钱包签名,验证通过后由 better-auth 建立会话。SIWE 规定登录消息和验证规则,personal_sign 是钱包的消息签名方法,两者可以配合使用。当前自定义消息没有包含标准要求的 Chain ID,因此属于类 SIWE 实现。
SIWE 是规范,siwe (opens new window) 则是实现这套规范的 JavaScript/TypeScript 库。这里也可以直接使用该库生成、解析和验证标准登录消息,不必自己拼接消息、从零编写验签逻辑。Privy 的钱包连接和 better-auth 的会话管理可以保留;项目仍需配置并核对本站域名、URI 和允许的链,保存并原子消费 nonce,再衔接登录会话。这样既能复用标准实现,也能减少协议细节的维护成本。
1. 后端生成 nonce,保存对应地址和过期时间
2. 后端把站点域名、地址、nonce、签发和过期时间组成登录消息,返回前端
3. 前端调用 personal_sign,请用户用当前钱包签名;私钥始终留在钱包里
4. 后端核验签名、域名和时间,确认消息属于本站的这次登录
5. 数据库将 nonce 标记为已使用,只有第一次成功消费的请求可以继续
6. 查找或创建用户,交给 better-auth 建立会话
7. 浏览器保存 HttpOnly Cookie,后续请求通过它携带登录身份
2
3
4
5
6
7
相关代码:apps/web/src/hooks/use-wallet-sign-in.ts(前端签名)、apps/server/src/lib/auth-plugins/wallet-sign-in.ts(后端验签)、apps/server/src/lib/siwe-message.ts(消息格式)。
# 签名登录会消耗 gas 吗?
建议回答:不消耗。标准 SIWE 和本项目的类 SIWE 登录都是签署登录消息,后端验证后建立会话,不需要发送链上交易。personal_sign 就是完成这次消息签名的方法。后续代币授权、购买课程、兑换等链上操作才需要支付 Gas。
# 为什么登录签名消息要包含网站域名和地址?
建议回答:domain 和 uri 告诉用户这份签名用于哪个站点,后端也必须核对它们。否则为 A 网站签的消息可能被拿到 B 网站尝试登录。再配合一次性 nonce 和有效期,限制这份凭证的用途、次数和时间。
# 后端怎样验证钱包签名,确认登录者身份?
建议回答:对于普通外部账户钱包,服务端用原消息和签名恢复出地址,再与用户声明的地址比较,不需要也不应该拿到私钥。但签名正确还不够,消息里的域名、nonce 和有效期也要通过检查。若要支持合约钱包,还需验证它的合约签名规则,不能只套公钥恢复。
# 签名是否能被拿去转账?
建议回答:本项目签的是限定域名、用途和 nonce 的登录消息,不是转账交易,不能直接当交易广播。但不能因此认为所有签名都安全:代币授权等签名可能允许别人动用资产,所以必须让用户看清签署内容。
# 连接钱包成功了,为什么接口仍然说没登录?
建议回答:连接只让页面知道当前地址,后端还没有验证是谁在操作。用户需要签署后端发出的登录消息,服务端验证并消费 nonce 后才建立会话。还要检查 Cookie 是否正确写入和携带;钱包已连接和网站已登录是两个状态。
# better-auth
better-auth (opens new window) 是一个基于 TypeScript 的开源身份认证框架,提供用户登录、会话管理等通用能力,支持邮箱密码、第三方账号等登录方式,也可以通过插件扩展。使用它可以减少从零编写和维护登录逻辑的工作。
钱包签名解决一次身份验证,better-auth 负责记住登录状态。否则用户每次发表评论、保存学习进度都得重新签名。
本项目的 wallet-sign-in 插件验证钱包签名后,让 better-auth 创建用户和 Session(服务端会话),并写入登录 Cookie。退出时销毁会话;教师身份、昵称等可能变化的字段仍实时查数据库,不依赖缓存中的旧值。
Cookie 的 HttpOnly 禁止网页脚本读取它,Secure 要求经 HTTPS 发送,SameSite=Lax 限制部分跨站携带场景。这些配置各管一层,不能代替后端权限判断。
实现细节:Cookie 会话缓存为五分钟;Drizzle 适配器的表名映射要和 modelName 一致,否则会找不到表。
相关代码:apps/server/src/lib/auth.ts(会话配置)、apps/server/src/lib/auth-plugins/wallet-sign-in.ts(钱包登录插件)、apps/server/src/lib/siwe-message.ts(签名消息)。
# HttpOnly 能防什么?
建议回答:它让页面脚本读不到登录 Cookie,降低 Cookie 被直接窃取的风险。但如果恶意脚本已经在本站执行,仍可能借浏览器发起操作,所以还要防 XSS;跨站伪造请求则需要来源检查或防伪令牌。HttpOnly 保护的是读取 Cookie 这一步,不是包办所有登录安全。
# 登录 Cookie 设置 SameSite=Lax 能降低什么风险?
建议回答:它让浏览器在不少跨站请求中不自动携带登录 Cookie,降低恶意网站借用户身份发请求的风险。但它不是所有跨站请求都拦,也不能单独防住 CSRF 跨站请求伪造;写操作仍要检查请求来源或使用防伪令牌。
跨站与跨域有什么区别?Lax 什么时候携带 Cookie?
同站看协议和可注册主域名;同源看协议、完整域名和端口。可注册主域名就是可以注册的域名,例如 9277.fun,不包含 www、api 等子域名。
以 https://www.9277.fun 为例:
- 访问
https://api.9277.fun:子域名不同,跨域但同站。 - 访问
https://www.9277.fun:8080:端口不同,也是跨域但同站。 - 访问
http://www.9277.fun:协议不同,属于跨站。 - 访问
https://example.com:主域名不同,属于跨站。
主域名不能简单取最后两段,例如 shop.example.co.uk 的可注册主域名是 example.co.uk,浏览器按公共后缀列表判断。
假设已经登录网站 A,现在从另一个站点 B 向 A 发请求。对于明确设置了 SameSite=Lax 的登录 Cookie:
- 允许携带:点击链接、通过
location.href跳转,或提交 GET 表单,让整个页面或新标签页打开 A。这属于顶层导航,使用 GET 等安全方法时允许携带。 - 不允许携带:跨站
fetch、Ajax 请求,包括 GET 请求;即使设置credentials: 'include',也不能绕过 SameSite 限制。 - 不允许携带:跨站 POST 表单提交,以及通过图片、脚本或 iframe 加载 A 的内容。iframe 内打开页面不属于顶层导航。
这里携带的是浏览器把 A 的 Cookie 发给 A,不是把 Cookie 交给 B。实际发送还要满足 Cookie 的域名、路径、Secure 等条件;跨域但同站的 fetch 也仍需正确配置凭据选项。
可以记成:Lax 保留从外站正常打开网站时的登录状态,同时限制外站在后台借用登录身份。但 GET 跳转仍可能带 Cookie,所以删除、转账等修改数据的操作不能设计成 GET,敏感接口仍需来源检查或 CSRF Token(防伪令牌)。
# CSRF 怎么处理?
建议回答:CSRF 是恶意网站借浏览器自动携带的 Cookie,冒用用户身份发请求。SameSite=Lax 能降低部分风险,敏感写接口还应核对 Origin 来源或校验防伪令牌;仅使用同源代理、HttpOnly 都不能单独解决它。
# 为什么断开钱包连接后,网站仍可能保持登录?
建议回答:钱包 SDK 和 better-auth 是两个系统。只 logout Privy 不会删除 HttpOnly cookie,所以断开动作必须调用后端 sign out。
# 为什么登录会话里不保存全部用户资料和权限?
建议回答:会话里的信息可能被缓存,教师资格被撤销后,旧会话不一定马上变化。因此会话只保存必要身份,昵称、头像按需查最新值,教师和管理员权限在操作时重新核验,避免继续使用过期资格。
# nonce 防重放为什么要原子消费
假设同一份登录签名同时发来两次:如果两个请求都先查到 nonce 还没用,再分别标记为已用,就会全部登录成功。
解决办法是把 “检查没用过” 和 “标记已使用” 放在同一次数据库更新里,只有一个请求能抢到。这就叫原子消费。签名验证和过期检查应先通过,下面只展示阻止重复使用的关键 SQL:
-- $1 是本次已验证登录消息对应的 nonce 记录 ID。
UPDATE auth_nonce
SET used = true
WHERE id = $1 AND used = false
RETURNING id; -- 返回一行才允许创建会话;没有返回说明已被消费。
2
3
4
5
相关代码:apps/server/src/lib/auth-plugins/wallet-sign-in.ts(nonce 消费)、apps/server/src/db/schema/nonce.schema.ts(nonce 表结构)。
# 签名正确,但登录凭证已被用过,还能登录吗?
建议回答:拒绝这次登录,让客户端获取新 nonce 重新签名。签名正确说明消息由该钱包签过,但这个一次性凭证已经被别的请求用掉,不能再创建会话。
# 一次性登录凭证为什么还要设置过期时间?
建议回答:一次性只能防止 “用过再用” ,过期时间还能防止一份没用过的旧签名长期有效。两者配合,既限制使用次数,也限制能登录的时间窗口。
# 为什么登录时只接受该钱包最新且未使用的凭证?
建议回答:用户可以重新发起登录,但后端只接受该地址最新、未使用且未过期的登录凭证,避免多份旧签名长期有效。并发使用同一份凭证时再通过条件更新只放行一次;获取新凭证的接口也要限流,避免不断写入垃圾记录。
# 如何防止登录凭证接口被刷,产生大量垃圾数据?
建议回答:当前项目的 auth.getNonce 允许未登录用户调用,因为要先拿到 nonce 才能签名登录。每次请求通过钱包地址格式校验后,后端都会生成一条 nonce 记录并写入数据库,所以恶意反复调用会增加无用数据和数据库压力。针对这种情况,可以按 IP 和钱包地址限制请求频率、限制同一地址未过期且未使用的 nonce 数量,并定期清理过期或已使用的记录。这些是需要补充的防护措施,当前接口代码还没有实现。
# Hono
本项目把 Hono (opens new window) 作为 HTTP 入口,在常驻 Node 服务中挂载健康检查、better-auth、tRPC 和供 Relayer、CRE 使用的 REST 接口。框架与中间件原理见 Aladdin:Hono。
Web 前端通过 tRPC 调用类型化业务接口;Relayer 和 CRE 通过标准 REST 查询结业状态与课程信息,不依赖前端的 AppRouter 类型。Hono 负责接收请求和组织中间件,身份校验、数据库事务和业务权限由对应服务处理。
错误响应保留可识别的状态码,但不暴露 RPC URL、数据库错误详情或完整堆栈。
相关代码:apps/server/src/index.ts(HTTP 服务入口)、apps/server/src/routes/course-rest.routes.ts(发证查询接口)。
# 为什么 Hono 和 tRPC 同时用?
建议回答:Hono 负责接收 HTTP 请求、组织路由和中间件;tRPC 负责定义后端接口,再让前端以类型安全的方式调用。例如查询课程时,后端定义接收的课程 ID、查询逻辑和返回结果,前端就能自动获得对应的参数和返回值类型,减少两边不一致的问题。具体业务逻辑仍由我们编写。页面通过 tRPC 调用,Relayer 和 CRE 通过普通 REST 接口访问,底层 HTTP 请求都由 Hono 接收。
# 后端如何区分 401 403 500?
建议回答:401 表示没有有效登录身份,例如未登录或登录已过期;403 表示身份已确认,但没有操作权限,例如普通学生调用课程管理接口;500 表示服务器遇到了未预期的错误,无法完成请求,例如程序抛出未处理的异常。查询链上权限时,如果 RPC 调用失败,不能直接返回 403,因为查不到权限不等于没有权限,前端应提示服务异常,而不是引导用户重新买课。
# 课程和结业查询接口,哪些可以公开,哪些需要鉴权?
建议回答:要按返回的数据区分,不能因为给后台服务用就默认安全。公开课程资料可以匿名查询并限流;个人学习状态应由服务身份或访问权限保护。Relayer 读取结果后还要核验业务条件,不能让一个能被前端随意调用的接口直接决定给谁发证。
# tRPC v11
tRPC (opens new window) 用来定义后端接口,并让前端以类型安全的方式调用,不只是定义接口类型。开发者在后端定义接口的输入和处理逻辑,tRPC 根据这些定义推导参数与返回值类型,前端不必再手写一份,减少 “后端改了字段,前端还按旧格式调用” 的问题。类型提示和检查主要发生在开发、编译阶段;实际网络请求仍会发送到后端,运行时的输入校验由 Zod 等工具完成。
概念、调用过程和面试回答见 RPC、区块链 RPC 与 tRPC。
项目用 tRPC 定义课程、评论和学习进度等接口,并按接口需要接入参数校验、登录检查和角色权限控制。
相关代码:apps/server/src/trpc/init.ts(接口与权限中间件)、apps/server/src/trpc/routers(业务接口)、apps/web/src/lib/trpc-client.ts(前端调用)。
# 为什么选择 tRPC,而不是 GraphQL?
建议回答:我们前后端都是 TypeScript,主要是课程、评论和学习进度这些明确的业务接口,所以选 tRPC,直接复用后端的参数和返回值类型,减少两边手动维护类型和接口变更后的对接错误。GraphQL 的优势是让不同客户端按需选择字段,但目前这类需求不突出,没必要额外维护 GraphQL 的 Schema 和字段取数逻辑。Relayer 和 CRE 仍走普通 REST 接口。后续如果出现多种客户端、且数据需求差异明显,再评估 GraphQL。
两者的概念、例子和对比表见 GraphQL 与 tRPC 的联系和区别。
# tRPC 是否等于没有 API 文档?
建议回答:不是。它能告诉前端字段类型,却不能完整说明 “谁能审核课程” “失败后能否重试” 。这些业务规则仍要写清楚。若接口开放给其他语言的客户端,再提供 REST/OpenAPI 会更通用。
# 前端隐藏管理按钮,能代替后端权限校验吗?
建议回答:不能。别人可以不经过页面,直接发审核请求。后端必须拿会话里的钱包地址与链上 owner 比较,一致才允许操作;隐藏按钮只是避免普通用户误操作。
# 为什么管理员权限要以链上当前记录为准?
建议回答:owner 可能转移,数据库角色表会陈旧。实时 owner 调用以合约当前状态为准,RPC 失败应返回暂时不可用而不是误判。
# 为什么课程列表要批量读取链上数据?
建议回答:列表有十门课,逐门发请求要等很多次网络往返。multicall 把多个合约读取合并成较少的 RPC 请求,再按课程 ID 拼上数据库标题,降低延迟和请求量;但失败项仍要单独处理。
# 课程列表的链上数据查询失败,页面该怎么处理?
建议回答:页面应明确提示链上数据暂时不可用,而不是把失败当成零价格或未购买。列表可保留不依赖链的内容;涉及付费访问时,无法确认权限就暂不放行。部分失败的结果也要逐项检查,不能只看批量请求有没有返回。
# 只向已购用户返回视频地址,就能防止视频泄露吗?
建议回答:后端先查购买权限再返回地址,能挡住直接越权调用,但用户拿到长期有效的地址后仍可能分享。更合适的内容保护方案是私有存储加短时签名地址,必要时再加水印或 DRM。短时地址降低传播范围,不等于绝对无法录屏或转存。
DRM 是什么?和签名地址、水印有什么区别?
DRM 是 Digital Rights Management(数字版权管理)。在视频场景里,可以理解为先把视频加密,只有获得有效播放许可证,才能解密观看。
例如付费课程:平台先加密视频;学生点击播放时,播放器申请许可证;服务端核验购买权限后发放许可证,浏览器或设备中的受保护模块再据此解密播放。因此,仅拿到视频地址或下载到加密文件,没有有效许可证也不能直接播放。
- 短时签名地址:限制下载入口和链接有效期,链接过期后不能继续凭它获取文件,但不能收回已经下载的内容。
- DRM:控制加密内容的解密与播放,拿到文件不等于获得播放权限。
- 用户水印:在画面中加入用户标识,起到威慑作用,并在泄露后辅助追查来源。
常见 DRM 方案有 Google Widevine (opens new window)、Apple FairPlay (opens new window) 和 Microsoft PlayReady (opens new window)。它们能提高盗版门槛,但不能绝对防止录制,例如无法阻止别人用手机拍屏幕。
# Zod
本项目用 Zod (opens new window) 校验 tRPC 输入、Web/API 环境变量、Relayer 和 CRE 配置。Schema、parse 与 safeParse 的原理见 Aladdin:Zod 是什么。
- 服务启动:提前检查 RPC 地址等必要配置,避免运行到 new URL() 才因错误配置返回 500。
- 请求入口:tRPC 的 input 校验字段形状;用户身份和资源归属仍由 Session 与权限中间件判断。
- CRE 运行时:不能直接复用依赖 Node 或浏览器 URL 全局对象的校验,需使用经过 WASM 模拟验证的规则。
结构校验通过不等于业务合法。例如 courseId 类型正确,还必须确认课程存在、已上架且用户有相应权限。
相关代码:apps/server/src/env.ts(环境变量校验)、apps/server/src/trpc/routers/course.router.ts(接口输入校验)、apps/cre-workflow/src/config.ts(工作流配置校验)。
# Zod 校验通过是否就能直接写数据库?
建议回答:不能。它能确认 courseId 是合法格式,不能证明这门课存在,更不能证明用户有权修改。还要查身份、课程归属与业务状态,最后用数据库约束防止并发写入破坏数据。
# PostgreSQL 与 Drizzle ORM
PostgreSQL (opens new window) 是关系型数据库,在项目中保存用户、会话、课程资料、评论和学习进度等链下数据;Drizzle ORM (opens new window) 是代码操作数据库的工具,让我们用 TypeScript 定义表结构、编写查询,并获得类型提示;drizzle-kit (opens new window) 用于生成和执行表结构迁移,例如新增字段或索引。
项目不仅在接口里校验数据,也通过数据库约束保证数据完整性。例如,学习进度表 course_progress 用用户 ID(userId)和课程 ID(courseId)组成复合主键,确保同一个用户对同一门课最多只有一条进度记录,避免并发插入重复记录。
EVM 钱包地址先校验再统一转小写保存,避免同一地址因大小写不同被当成两个用户;页面展示时可使用校验和格式。
事务、约束与并发基础见 Aladdin:PostgreSQL,表结构变更见如何做 migration。
相关代码:apps/server/src/db/schema(表结构)、apps/server/src/db/migrations(迁移记录)、apps/server/src/db/index.ts(数据库连接)。
# 链上数据和数据库怎样分工、关联?
建议回答:价格和购买关系以链上合约为准,课程标题、视频地址和学习进度放数据库,两边通过同一个课程 ID 关联。当前直接读合约判断是否购买,减少同步逻辑,也避免刚买完课却被旧缓存拦住。以后查询量增大可以加缓存,但要设计更新和失效机制。若支持多条链或多份课程合约,还要把链 ID、合约地址一起作为课程标识。
# 数据库事务能保证什么?链上操作怎么办?
建议回答:同一个数据库事务内的多步写入,可以一起提交或一起回滚。例如批准教师申请时,更新申请状态和赋予教师身份要一起成功。但数据库回滚撤销不了链上交易。项目的课程审核先确认上链,再更新数据库;要完善失败恢复,还需要记录处理状态和交易哈希。若链上已经成功、数据库更新失败,就核对原交易后重试数据库写入,不能重新发起同一笔链上操作。
# 怎样处理并发写入,避免重复记录和旧数据覆盖?
建议回答:这要分成两个问题。防重复记录靠数据库唯一约束:项目的学习进度表用用户 ID 和课程 ID 组成复合主键,即使两个请求同时插入,也只能保留一条。防旧数据覆盖则要另外处理,唯一约束并不能阻止进度从 80% 被旧请求写回 40%。对于只增不减的累计完成进度,可以在数据库更新时保留较大值;如果数据允许来回修改,就用版本号或条件更新识别冲突。项目当前的进度覆盖更新仍需补上这类保护。
# 查询列表时,怎样避免每条记录都额外查一次数据库?
建议回答:先查列表,再逐条查询关联数据,会形成 N+1 次查询。例如查出十门课程后,再分别查询老师信息,就额外发了十次请求。可以用关联查询一次取出,或者收集当前页的老师 ID,批量查询后在内存中匹配。重点是减少循环里的数据库往返,再根据实际慢查询检查索引,而不是一开始就加缓存。
# 为什么选择 Drizzle,而不是 Prisma?
建议回答:两者都支持 TypeScript 类型安全,也都能满足项目需求,主要区别在开发方式。Prisma (opens new window) 通过 Schema 定义数据模型、生成客户端,常规增删改查和嵌套关联操作比较省事;Drizzle 直接用 TypeScript 定义表结构,查询写法更接近 SQL。项目选 Drizzle,主要看中能在保留类型提示的同时,直接表达课程查询中的关联、筛选和更新条件。它对熟悉 SQL 的开发者更顺手,但不代表所有业务都更简单,复杂查询和性能仍要自己分析;如果主要做常规增删改查,Prisma 同样是合适的选择。
# S3 头像预签名 POST
后端发一张短时有效、限制了上传位置和大小的 “上传许可” ,浏览器拿它直接把头像传给 S3 (opens new window)。这样图片不必经过 API 服务器转发,减少带宽和内存占用。
这张许可就是预签名 POST (opens new window):后端签署上传条件,S3 检查对象路径、有效期、文件大小和声明的内容类型。不符合条件就拒绝上传;声明是图片不代表文件内容一定安全,必要时仍需检查实际内容。
“上传许可” 具体是什么?
它不是单个字段,而是后端通过接口返回的一组上传参数。项目返回 uploadUrl、uploadFields 和 publicUrl,下面用 JavaScript 对象展示 JSON 数据的结构,具体签名值和部分 AWS 字段已省略:
{
// S3 接收文件的上传地址
uploadUrl: "https://example-bucket.s3.amazonaws.com",
uploadFields: {
key: "avatars/用户ID/随机文件名.png", // 本次允许上传的文件路径
"Content-Type": "image/png",
Policy: "...", // Base64 编码的上传条件和有效期
"X-Amz-Signature": "..." // S3 用来验证这些条件的签名
// 其他 AWS 要求的表单字段省略
},
// 上传成功后用于展示头像的地址,不是上传凭证
publicUrl: "https://images.example.com/avatars/用户ID/随机文件名.png"
}
2
3
4
5
6
7
8
9
10
11
12
13
后端没有把 AWS 签名密钥交给浏览器,而是给它限定条件的上传授权。浏览器上传时也不是把这段 JSON 原样发给 S3,而是把 uploadFields 中的字段与图片一起装进 FormData,以表单形式提交。
什么时候签发,拿到后怎么上传?
不是登录时就发,也不是后端主动推送,而是在用户选择头像后由前端申请:
- 选图并申请:前端检查图片类型和大小,通过后调用项目的
user.getAvatarUploadUrl,提交图片类型。 - 后端签发:检查登录状态和允许的图片类型,为当前用户生成专属文件路径,返回上面的参数;有效期为 5 分钟,文件大小上限为 2 MB(
2 × 1024 × 1024字节)。 - 浏览器直传:前端拿到参数后,把所有
uploadFields字段和图片放进FormData(图片最后加入),POST 到uploadUrl。S3 验证签名和上传条件,通过后才接收图片。 - 保存头像地址:S3 上传成功后,前端调用项目的
user.updateAvatar,后端校验publicUrl属于当前用户的头像路径,再将地址保存到数据库。
所以,这条流程是先向后端申请上传参数,再由浏览器直接上传到 S3,最后让后端保存头像地址。只知道 S3 的上传地址,并不代表有权向它上传文件。
相关代码:apps/server/src/lib/s3.ts(签发上传许可)、apps/server/src/trpc/routers/user.router.ts(上传接口)、apps/web/src/components/profile/avatar-uploader.tsx(前端上传)。
# 为什么不用预签名 PUT?
建议回答:项目选预签名 POST,主要是为了让 S3 直接检查头像大小不能超过 2 MB。POST 可以通过 content-length-range 设置允许的文件大小区间,超出范围就拒绝上传。预签名 PUT 也能直传文件、指定上传路径和有效期,但不支持这种大小区间策略。我们需要的是上传时直接校验大小上限,所以选择 POST 更直接。
# 保存头像时,为什么还要校验图片的访问地址?
建议回答:这里校验的是用于展示头像的图片访问地址 publicUrl,不是接收文件的上传地址 uploadUrl。这个访问地址由后端在签发上传许可时,根据配置的图片域名、当前用户 ID 和随机文件名生成,图片上传前就已确定,并不是用户自己指定的。图片上传成功后,前端会把 publicUrl 作为 avatarUrl 提交给更新头像接口,让后端保存到数据库。但客户端提交的地址可以被篡改,比如换成其他用户的头像地址或任意外部链接。所以后端保存前,还要检查地址是否以配置的图片访问域名和当前用户的头像目录开头,并检查文件路径格式。预签名管的是文件能往哪里上传,这次校验管的是哪个图片地址能保存为当前用户的头像。
# 第五部分 结业证书自动化 Chainlink CRE 与 AWS KMS Relayer
这一部分先记住
合约不能直接读取数据库中的学习进度,所以需要链下执行者把 “申领事件、完成状态、metadata 和 mint 交易” 串起来。当前生产路径是 KMS Relayer,CRE 是已经完成隔离验证、但尚未正式接管生产发行的候选路径。
# 为什么需要链下执行层
数据库显示学完了,不会自动让链上发证。合约既不能主动查业务数据库,也不会自己定时运行,因此需要 Relayer 这个后台服务串起两边:
学生申领 → CompletionRequest 合约留下事件
Relayer 读到事件 → 向后端核验是否完成课程
完成 → 生成证书名称、图片和课程信息 → 保存到 IPFS
调用 KMS 签名 → 提交 mint 发证交易 → 等回执确认成功
2
3
4
上面生成的证书名称、图片地址和课程信息等资料,统称为证书元数据(metadata);mint 则是在链上创建证书。后端决定是否学完,issuer 决定谁有权发证,两者都需要保护,不能因为用了区块链就省掉核验。
相关代码:apps/relayer/src/index.ts(发证服务入口)、apps/relayer/src/completion-handler.ts(核验与发证编排)。
# 自动发证时,合约和后端分别负责什么?
建议回答:合约负责记录申领和控制发证,后端负责判断是否满足结业条件,Relayer 把两边串起来。学生先调用申领合约,留下钱包地址和课程编号;Relayer 读取事件后查询后端,满足条件才生成证书资料、提交发证交易。证书合约只允许指定发证者调用,并限制同一钱包对同一课程只能领一次。这样分工是因为学习进度存在数据库里,合约不能直接读取。
相关代码:packages/contracts/src/CompletionRequest.sol(记录申领)、apps/relayer/src/completion-handler.ts(发证编排)、packages/contracts/src/CourseCompletionNFT.sol(发证权限与防重复)。
# 如何防止用户伪造学习完成状态,骗取证书?
建议回答:关键是不能只相信前端上报的完成百分比。当前进度接口要求登录,只更新当前用户的记录,并校验数值在 0~100 之间;但完成状态接口仍按这个进度是否达到 100% 判断,没有复核购课资格,所以还不能防住用户伪造自己的完成进度。
进一步增强时,可以由后端创建绑定用户和课程的学习会话,让前端每隔一段时间上报播放位置,后端根据两次请求的接收时间和允许的播放倍速,检查进度增长是否合理。例如,只过了 10 秒,即使允许 2 倍速,也不能直接计入 10 分钟的观看进度。重复上报不重复累计,长时间没有上报的间隔不自动计入,拖到结尾也不能把跳过的部分算作已观看。最后由后端根据累计覆盖的视频片段计算完成度,并在发证前复核链上购买记录。
这些措施可以减少直接篡改进度和跳过视频刷完成度,但定时上报仍可能被脚本模拟,不能据此证明用户一直在认真观看。
相关代码:apps/server/src/trpc/routers/progress.router.ts(进度上报)、apps/server/src/routes/course-rest.routes.ts(结业状态判断)。
# 自建 Relayer 与 AWS KMS 签名
项目自己编写 Relayer:代为提交链上交易的服务 处理发证任务,使用 AWS KMS (opens new window) 保管私钥并签名。Relayer 是使用 TypeScript 编写的 Node.js 后台程序。
Relayer 读取申领事件,查询后端结业状态,生成证书资料,再调用证书合约。KMS 只参与签名,学习资格是否满足、给哪个钱包发证,都由应用和合约规则控制。
一次发证交易的分工是:
Relayer 组织 mint 发证交易 → 请求 KMS 签署交易摘要
签名适配器转换格式 → viem 组装已签名交易 → RPC 节点广播
Relayer 查询交易回执 → 确认执行成功
2
3
项目先用 viem (opens new window) 模拟合约调用,检查发证权限、是否已领取等规则;通过后才签名并广播,再检查回执。模拟能提前拦住明显会失败的调用,但之后链上状态仍可能变化,最终结果以回执为准。
相关代码:apps/relayer/src/index.ts(服务入口与签名方式选择)、apps/relayer/src/mint-executor.ts(模拟、广播与回执确认)。
# 自动发证怎样实现,谁签名、谁支付 Gas?
建议回答:学生先在链上提交证书申请,Relayer 查询到申请事件后,向后端核验结业状态,生成证书资料,再调用合约发证。Relayer 负责组织交易,KMS 负责签名,通过 RPC 广播后还要检查回执,确认执行成功才算发证完成。申领交易的 Gas 由学生支付,发证交易的 Gas 由平台发证账户支付,KMS 本身不代付费用。
当前本地配置每 15 秒查询一次新增区块中的申请事件,启动时立即查一次。这个间隔只是发现申请的等待时间,完整发证还需要资格核验、资料上传和交易确认。
相关代码:apps/relayer/src/env.ts(轮询间隔)、apps/relayer/src/listener.ts(读取申请事件)、apps/relayer/src/completion-handler.ts(核验与生成资料)、apps/relayer/src/mint-executor.ts(发证交易与回执确认)。
# 为什么使用 KMS,而不是把私钥放在环境变量里?
建议回答:自动发证需要后台持续签名,使用 KMS 是为了减少私钥泄露的风险。环境变量里的私钥一旦被复制,就能在其他机器上继续使用;KMS 内部生成的非对称签名私钥不能导出,应用只拿到签名结果,还可以通过 IAM 限制密钥使用权限,并审计调用记录。但服务被攻破后,签名权限仍可能被滥用,所以应用要限制交易入口,合约也要检查发证权限。KMS 保护密钥,不替代业务校验。
签名适配:KMS 返回的是签名结果,不是完整的以太坊交易。项目通过签名适配器转换格式并核对签名地址,再由 viem 组装交易、提交上链。这些细节集中在适配器中,业务代码只负责调用合约。
相关代码:apps/relayer/iam-policy.example.json(密钥最小权限)、apps/relayer/src/kms-account.ts(签名适配)、apps/relayer/src/mint-executor.ts(固定发证合约与调用入口)。
# 发证失败或服务停机后,怎样恢复处理?
建议回答:当前按申请分别处理,某条申请失败会记录日志,不会因为这次报错而终止后续申请;已经领过证书的请求会跳过。失败后主要由用户重新申领来重试。另一个限制是扫描进度只保存在内存中,服务重启后从最新区块开始,可能漏掉停机期间的申请。
进一步增强时,可以复用 RDS 的 PostgreSQL 保存扫描进度 checkpoint 和发证任务:先把查到的申请可靠保存,再推进扫描进度;发证任务单独执行,临时故障有限重试,不符合资格的申请记录原因并结束。服务重启后从记录附近补扫,避免漏申请。如果已经拿到交易哈希,却暂时查不到回执,先核对原交易和链上发证状态,不能直接再发一笔。
补扫还要考虑链重组,也就是最近的区块可能被替换。增强方案应先等待设定的确认数,再处理申请,并保存区块高度和哈希,发现变化就回退补扫、核对任务。查询范围过大时分批读取,被 RPC 限流时延长重试间隔。数据库补扫不能撤销已经上链的证书,因此确认策略要放在发证之前;更深的重组仍需异常处置。
相关代码:apps/relayer/src/listener.ts(当前内存扫描进度)、apps/relayer/src/completion-handler.ts(失败处理)、apps/relayer/src/mint-executor.ts(回执确认);持久任务、自动重试和重组恢复属于增强方案。
# 怎样避免重复发证和多实例交易冲突?
建议回答:当前合约按钱包和课程检查 hasClaimed,已经领过就拒绝再次发证,所以重复请求只能成功一次。后台也会识别重复领取的失败结果并跳过。即使后台做了去重,重启、并发或其他调用入口仍可能重复提交,所以合约检查不能省。
扩展到多实例时,还需要用数据库租约分配任务,也就是暂时指定一个实例负责这条申请,减少重复提交。多个实例共用一个发证账户时,还要统一分配交易序号 nonce,避免发出序号相同的交易。租约过期后旧实例仍可能继续执行,因此后台协调减少重复交易和费用,合约检查守住只能发一次的最终规则。
相关代码:packages/contracts/src/CourseCompletionNFT.sol(防重复发证)、apps/relayer/src/completion-handler.ts(重复领取处理);实例租约与交易序号协调属于扩容方案。
# 为什么链上事件监听改为按区块范围查询?
建议回答:原来的监听方式依赖 RPC 节点保存事件过滤器,也就是记住我们要查询哪些事件,再通过一个编号查询新增结果。项目遇到过进程还在运行,却反复提示 filter not found、收不到申请的情况;节点切换或过滤器失效后,旧编号就不能继续使用。
所以改用 getContractEvents (opens new window),每次明确查询 “第几块到第几块有哪些事件” ,由服务自己记录扫描位置,不再依赖 RPC 保存过滤器状态。这样解决了过滤器失效的问题;至于重启漏事件,还需要前面提到的持久化进度和补扫机制。
相关代码:apps/relayer/src/listener.ts(分区间读取事件)。
# Chainlink CRE
CRE (opens new window)(Chainlink Runtime Environment,Chainlink 运行环境)用于编排链上、链下联动的工作流。本项目用它验证另一条发证路径:由 Chainlink 网络执行申请监听、结业查询和发证报告生成,再把结果提交给链上合约。
流程是:收到申领事件 → 查询后端结业状态 → 上传证书资料 → 生成报告 → 转发给接收合约 → 调用证书合约发证。
- DON:Decentralized Oracle Network,去中心化预言机网络,由多个节点执行任务并对结果形成共识。
- 报告(report):写明发证钱包、课程、证书资料地址,以及目标链和接收合约地址。
- Forwarder 与 Receiver:Forwarder 是 Chainlink 报告转发合约;Receiver 是项目的接收合约,校验来源和报告内容后才调用证书合约。
两条路径共用 completion-nft-shared 中的结业查询和证书资料生成逻辑,避免因执行方式不同而改变业务规则。
相关代码:apps/cre-workflow/src/main.ts(工作流入口)、apps/cre-workflow/src/completion-check-handler.ts(结业核验)、packages/contracts/src/CourseCompletionNFTReceiver.sol(链上报告接收)、packages/completion-nft-shared/src(共用发证逻辑)。
# 为什么评估 CRE,而不是只使用自建 Relayer?
建议回答:评估 CRE 是为了让发证工作流不只依赖平台自己运行的一个后台服务。自建 Relayer 由平台执行并用发证账户签名;CRE 则由多个节点执行、对结果形成共识,并生成可验证的报告供合约接收。好处是减少对单个执行服务的依赖,代价是接入、运行环境适配和报告校验更复杂。项目保留 Relayer 作为现有路径,用 CRE 验证替代方案,而不是让两套服务同时负责发证。
CRE 适合编排 “事件触发 → 查询接口 → 写回链上” 的多步流程。多节点执行只能增强执行过程的可信度,不能自动证明平台提供的学习记录真实。具体取舍见 自动发证方案对比。
相关代码:apps/cre-workflow/src/main.ts(事件触发)、apps/cre-workflow/src/http-adapter.ts(节点请求与结果聚合)、apps/cre-workflow/src/mint-report.ts(报告生成与提交)。
# 合约怎样验证 CRE 发证报告,防止伪造和重放?
建议回答:接收合约不能拿到报告就直接发证。它先检查调用者是不是指定的 Forwarder,再检查工作流编号,确认报告来自我们允许的发证流程;然后核对目标链和接收合约地址,防止报告被拿到别的链或合约使用。通过后才调用证书合约,证书合约还会检查发证权限和是否已经领取。这样既限制谁能请求发证,也防止同一钱包为同一课程重复领证。
本项目接收合约在工作流编号为零时会跳过该项检查,供隔离模拟使用;正式接入必须配置预期编号。只检查 Forwarder 地址不够,因为同一个转发合约也可能服务其他工作流。
相关代码:packages/contracts/src/cre/ReceiverTemplate.sol(转发者与工作流校验)、packages/contracts/src/CourseCompletionNFTReceiver.sol(报告目标校验)、packages/contracts/src/CourseCompletionNFT.sol(发行权限与防重复)、apps/cre-workflow/src/mint-report-encoding.ts(报告字段编码)。
# 怎样验证并安全切换到 CRE 发证?
建议回答:先在隔离环境用测试申请跑通后端查询、资料上传、报告生成和链上发证,再验证正式工作流已部署、能被触发,接收合约的来源与目标配置也正确。切换时暂停旧 Relayer 接收新任务,核对在途交易,保存待处理申请和扫描位置,再把证书合约的 issuer 发证权限切给 Receiver,并用真实申请验证完整流程。如果新路径异常,就切回旧发证账户,从保存的位置补处理,不能跳过切换期间的申请。
项目记录中,隔离模拟已完成 Sepolia 测试网发证验证,正式工作流部署与 issuer 切换尚未完成。模拟跑通不等于正式执行网络已经可用,不能先切权限再补验证。
相关代码:apps/cre-workflow/README.md(验证记录与部署流程)、apps/cre-workflow/scripts(模拟与部署脚本)、packages/contracts/src/CourseCompletionNFT.sol(issuer 权限切换);保存任务与扫描进度需配合前面的 发证失败或服务停机后,怎样恢复处理?。
# CRE 接入与运行环境适配
CRE 工作流用 TypeScript 编写,但不在普通 Node.js 中执行,而是在 WASM (opens new window)(WebAssembly,一种可移植执行格式)沙箱中运行。因此,本地代码能运行,不代表它依赖的 API 在 CRE 中也可用。运行环境说明见 CRE TypeScript Runtime (opens new window)。
项目接入时处理了三类问题,不应全部归为 WASM 兼容性:
- 运行环境:使用的 CRE 环境没有全局
URL构造器,原来的 Zod URL 校验会把合法地址判为无效,需要改用不依赖该对象的校验方式。 - 接口格式:CRE HTTP 请求要求超时时长写成
10s这类字符串;没有请求体时必须省略body字段,不能显式传入undefined。这些转换集中在 HTTP 适配器中。 - 事件解析:本项目事件的
topics[0]是事件类型标识,用户地址和课程编号从后面的项读取。这是普通 EVM 日志的编码规则,不是 WASM 特有问题。
验证时既运行单元测试检查业务逻辑,也运行 cre workflow simulate 检查实际运行环境和接口调用,避免只在 Node.js 测试通过就认为接入完成。
相关代码:apps/cre-workflow/src/http-adapter.ts(运行时 HTTP 适配)、apps/cre-workflow/src/config.ts(配置校验)、apps/cre-workflow/src/completion-event.ts(事件解析)。
# IPFS 与 Pinata
IPFS 是分布式文件存储与分发系统,能实际存储文件;Pinata 提供基于 IPFS 的文件托管服务,证书合约只保存资料地址。项目通过 Pinata 使用 IPFS,不必自己运营存储节点。
- IPFS (opens new window):InterPlanetary File System,星际文件系统,是一套文件存储与分发协议及网络。文件用 CID(Content Identifier,内容标识)定位,实际内容保存在参与网络的节点上,不会自动由所有节点永久保存。
- Pinata (opens new window):提供 IPFS 托管服务的厂商。后端调用它的接口上传资料,它负责保存并固定文件,也就是 pin,避免文件被其存储节点当作可清理的缓存删除;同时提供 HTTP 访问网关,让浏览器可以读取 IPFS 内容。
IPFS 和网盘都能保存文件,但不是同一种服务。普通网盘由某家公司保存文件,用户通常按账号和文件路径访问;IPFS 由参与网络的节点保存文件,通过 CID 查找内容,不是某家公司统一提供的网盘。自己运行 IPFS 节点,也能上传并固定文件,但要自己维护磁盘、网络、备份和服务可用性;使用 Pinata,就是把节点托管和文件保留交给服务商。
项目发证时的资料上传流程是:
- 生成资料:后端把证书名称、说明、图片地址和课程信息组装成元数据 JSON。图片地址引用已经上传的图片,不把图片二进制塞进 JSON。
- 上传并保存:后端调用 Pinata 的上传接口,Pinata 保存并固定这份 JSON,返回它的 CID。
- 写入合约:发证交易把
ipfs://CID保存到证书合约的链上存储中,与证书编号tokenId对应。本项目用_tokenURIs[tokenId]保存地址;tokenURI(tokenId)是读取这个地址的方法,不是存储字段。前端或钱包调用它取得地址后,再通过 IPFS 网关读取 JSON,并按其中的图片地址加载图片。
这不是先往 IPFS 上传一份、再往 Pinata 上传一份,而是通过 Pinata 将资料保存并提供给 IPFS 网络。合约不存图片和 JSON 正文,避免大文件的链上存储成本。当前合约没有修改已保存资料地址的入口,因此已发证书固定引用这份资料。
相关代码:packages/completion-nft-shared/src/ipfs/pinata.ts(文件上传)、packages/completion-nft-shared/src/metadata.ts(证书元数据)、packages/completion-nft-shared/src/build-completion-certificate.ts(组装证书资料)、packages/contracts/src/CourseCompletionNFT.sol(链上保存和读取资料地址)。
# IPFS、Pinata 与链上合约分别负责什么?
建议回答:IPFS 保存和分发文件,Pinata 帮我们托管并保留这些文件。发证时,先通过 Pinata 上传证书资料 JSON,再把返回 CID 对应的 IPFS 地址保存在证书合约中,与证书编号对应。前端或钱包调用 tokenURI(tokenId) 取得地址,再读取 IPFS 上的资料。简单说,链上存地址,IPFS 存文件,Pinata 负责托管;这样不用把大文件存到链上,也不必自己维护 IPFS 节点。
CID 用于核验取回的内容是否与地址一致;内容变化后不能继续冒充同一份资料。但它不能证明证书中的学习结果真实,也不保证文件永远可访问。内容可核验、业务结果真实、文件持续可用,是三个不同的问题。Pinata 上传凭据只放在后端,不交给浏览器。
# 为什么使用 Pinata,而不是自己运行 IPFS 节点?
建议回答:主要是为了减少存储节点的运维工作。自己运行 IPFS 节点,需要维护服务器、磁盘、网络、升级和故障恢复,还要保证文件持续保留、对外可读取。Pinata 提供上传接口、文件固定保存和 HTTP 访问网关,后端上传后就能拿到 CID,更容易接入证书发放流程。这样可以把精力放在发证流程和合约安全上,不必自己维护一套文件存储服务。
取舍是服务费用、访问限制和厂商依赖;托管不代表永久保存或绝不宕机,重要资料仍需备份,必要时由多个节点或服务固定保存。
相关代码:packages/completion-nft-shared/src/ipfs/pinata.ts(通过 Pinata 上传并固定证书资料)。
# Pinata 访问不了时,证书资料还能读取吗?
建议回答:先区分是访问入口出了问题,还是保存文件的节点不可用。如果只是 Pinata 网关故障,其他网关在能找到文件副本时仍可读取;如果 Pinata 是唯一保存者且文件暂时不可用,换网关也不能凭空取回文件。若所有副本都丢了,只能依靠保留的原文件重新提供存储。所以需要备份资料,重要文件由多个节点或服务固定保存,不能把 CID 当成备份。
# 证书资料已上传到 Pinata,但链上发证失败怎么办?
建议回答:上传资料和链上发证是两步,上传成功不代表证书已经发出。恢复方案是保存申请对应的 CID 和已有交易哈希:还没提交交易就从发证步骤继续;已经提交过,就先核对原交易和链上发证状态,确认交易失败且尚未发证才重试,并复用原资料。这样不会重复上传,也不会因为暂时查不到回执就盲目再发一笔。当前实现主要由用户重新申领来重试,保存这些信息并自动恢复属于前面的 发证失败或服务停机后,怎样恢复处理?。
# 证书资料放在 IPFS 上,有哪些隐私风险?
建议回答:项目把资料地址写入公开的证书合约,任何人都可能据此读取 JSON;即使平台后来删除自己的副本,也不能保证其他节点没有保存。因此资料只放必要的公开信息,不直接写入身份证号、手机号等敏感内容。需要核验身份时,由受权限控制的后端提供核验结果,不把隐私原文放进公开证书资料。
# 自动发证方案对比
两种方案的业务流程相同,区别是由谁执行、怎样签名和验证结果。合约都不会直接查询学习数据库,结业结果仍由平台后端提供。通用区别与选型见 预言机与 Relayer:区别与可信度。
| 维度 | 自建 Relayer + AWS KMS | Chainlink CRE |
|---|---|---|
| 谁执行流程 | 平台运营的 Node.js 后台服务 | Chainlink 网络中的多个节点 |
| 谁有权发证 | KMS 密钥对应的发证账户 | 校验报告后的 Receiver 合约 |
| 主要好处 | 调试直接,部署与重试可控,签名私钥不可导出 | 多节点执行与结果共识,报告来源可验证 |
| 维护代价 | 自己负责服务运行、进度恢复和密钥使用权限 | 接入执行网络,适配运行环境,配置并校验报告来源 |
| 共同限制 | 是否结业仍由平台后端判断 | 多节点执行也不能自动证明后端数据真实 |
如果平台后端把虚假的学习进度判为已完成,多个节点读到同一个结果,也不能证明用户真的学完。更换执行方式不能替代服务端对学习过程的核验。
证书合约只接受当前 issuer 发证者的调用,不同时启用两条发行路径。切换流程见 怎样验证并安全切换到 CRE 发证?。
相关代码:apps/relayer/src(自建发证服务)、apps/cre-workflow/src(CRE 工作流)、packages/contracts/src/CourseCompletionNFT.sol(发行权限与防重复)。
# 第六部分 AWS 基础设施与部署
这一部分先记住
AWS 部署按运行特点选择载体:Next.js、Hono 和长期运行的 Relayer 使用 ECS Fargate,RDS 保存业务数据和 checkpoint,KMS 管签名密钥,CloudFront、WAF 和 ALB 负责入口与安全。核心是让请求进得来、数据不乱、失败能发现并恢复。
# 生产部署选型结论 AWS 还是 Cloudflare
选择 AWS 的主要原因是 API、数据库和 KMS 发证服务可以放在同一套网络与权限体系里。当前 Node 服务能直接打包成容器,不必为了迁移平台重写长期运行的 Relayer。
| 真正影响选型的因素 | 本项目的取舍 |
|---|---|
| 运行方式 | Web 和 API 接收请求,Relayer 持续扫描事件;ECS 可以分别运行三个常驻服务 |
| 数据与密钥 | PostgreSQL、对象存储和 KMS 都在 AWS,放在一起更容易控制访问权限和排查故障 |
| 改造成本 | 迁到 Workers 需要验证 Next.js 适配、数据库连接和 Node 依赖,Relayer 也要改成适合该平台的执行方式 |
| 费用 | ECS、ALB、NAT 和 RDS 有持续费用,低流量时未必划算;开发环境应缩小规格并设置预算 |
Cloudflare 可以用于静态站点、边缘接口或接入 AWS 源站。只有访问速度或成本确实有收益时才增加跨云部署,不能只为多用一个平台而拆分系统。
# 为什么选择 AWS,而不是继续用 Vercel?
建议回答:主要是为了统一管理整套服务。Vercel 方便快速发布 Next.js 应用,但项目还有独立的 Hono API、持续轮询的 Relayer,以及 RDS、S3 和 KMS。把应用也放在 AWS,可以复用现有 Node.js 容器,在同一套网络和权限体系里访问这些服务,减少跨平台连接、凭据配置和日志排查的复杂度。
# 前端能否部署到 Cloudflare?怎样兼顾访问速度和高峰保护?
建议回答:可以,但要看运行方式。纯静态前端适合 Pages,这个项目的 Next.js 有服务端逻辑,迁移需要评估 Workers 适配。当前 AWS 方案已有 CloudFront 缓存静态资源,换到 Cloudflare 不代表动态请求一定更快,因为它们仍要访问 AWS 后端。活动高峰时可以让 Cloudflare 做入口,用 Waiting Room 排队放行,应用继续运行在 AWS。CDN 负责加速,等候室负责控制进入人数,后端限流和扩容负责保护实际处理能力。
Waiting Room (opens new window) 不要求应用迁到 Cloudflare,但入口必须经过 Cloudflare 代理;接入时还要保护对应业务接口,避免绕过等候室直接请求后端。该功能通常需要 Business 或 Enterprise 套餐,具体限制见 官方套餐说明 (opens new window)。
Cloudflare、Queue-it、Queue-Fair、自建方案与 AWS 已归档开源方案的优缺点和适用场景,见 等候室方案怎么选。
# AWS 部署有哪些成本和维护取舍,如何控制?
建议回答:统一到 AWS 可以减少跨平台管理,Fargate 和 RDS 也减少了自己维护服务器和数据库的工作。但相比 Vercel 托管页面,我们要配置更多网络、权限、发布和监控;常驻容器、ALB、NAT 和数据库在低流量时仍有持续费用。控制成本先从闲置资源入手:开发环境按需缩小或关闭,日志和旧镜像设置保留期限,再配预算告警。生产环境则保留满足恢复要求的副本、备份和安全配置,不把省钱变成更大的故障风险。
# Cloudflare Pages 与 Workers 应该怎样区分
先看要不要在收到请求时执行服务端代码。能完整静态导出的页面可放 Pages (opens new window);需要动态渲染和后端逻辑时,评估 Workers (opens new window) 的框架适配,不能把静态文件托管当成完整 Node 服务器。
本项目即使把页面迁走,PostgreSQL 和 KMS 仍可能留在 AWS。这样要额外处理跨云连接、凭据、延迟和日志,所以重点不是 “能不能跑” ,而是迁移是否值得。具体 Next.js 适配方式以文末官方指南为准,不把某条适配路径当成唯一方案。
# 生产部署拓扑
生产部署按入口、应用、数据和恢复能力分工。每个组件都对应具体问题。
| 环节 | 组件 | 在本项目中解决什么 |
|---|---|---|
| 域名与入口 | Route 53、ACM、CloudFront、WAF | 找到网站、提供 HTTPS、缓存静态资源、拦截异常请求 |
| 请求分发 | ALB | 把页面请求交给 Web,把接口请求交给 API;不给不健康实例继续分配请求 |
| 应用运行 | 三个 ECS Fargate Service | 分别运行 Next.js、Hono 和 Relayer,便于独立发布与排障 |
| 业务数据 | RDS PostgreSQL | 保存用户、课程资料和进度;生产补上多可用区、备份与恢复能力 |
| 图片资料 | S3、IPFS/Pinata | S3 保存头像;IPFS 保存证书资料,链上只引用资料地址 |
| 凭据与签名 | Secrets Manager、KMS | 前者保存数据库等凭据,后者让发证私钥不进入应用文件 |
| 发证恢复 | 持久化扫描进度、失败重试、合约防重复 | 进程重启后能补处理申请,又不会为同一课程发两张证书 |
| 发布与监控 | ECR、ECS 回滚、CloudWatch | 保存可回退的镜像,发现异常并追查是哪一段失败 |
Web、API 的生产高可用目标是跨可用区运行多个实例;Relayer 多实例则必须先解决谁处理事件、谁使用哪个交易序号,不能直接加副本就认为更可靠。
# 前端到底部署在哪里
项目部署在 AWS,Next.js 前端和 Hono 后端运行在 ECS Fargate 容器中,前面通过 CloudFront 做缓存和分发。运行与请求分发的职责见 ECS Fargate 与 ALB。
生产请求链路可以这样理解:先通过 Route 53:让域名找到入口 做域名解析,再访问 CloudFront;静态资源命中缓存直接返回,动态请求经 ALB:把请求分给应用实例 转到 Next.js 或 Hono。DNS 只负责解析地址,不是 HTTPS 请求流经的一台代理服务器。
# 为什么需要运行 Next.js 服务,而不是只用 S3 和 CDN 托管?
建议回答:因为这个前端不只是构建好的 HTML、JavaScript 和 CSS,还需要收到请求后执行 Next.js 的服务端逻辑。S3 只能保存和返回文件,CDN 主要负责缓存和分发,都不能代替 Next.js 服务处理请求。放在 ECS Fargate 可以保留现有 Node.js 运行方式。如果应用能完整静态导出,服务端业务都由独立 API 处理,就可以用 S3 配 CDN 托管,不必运行 Next.js 服务。
Cloudflare 部署与前后端分开部署的取舍,见上文 前端能否部署到 Cloudflare?怎样兼顾访问速度和高峰保护?。
# AWS CDK
本项目用 AWS CDK (opens new window) 以 TypeScript 定义网络、数据库、存储和应用资源,生成 CloudFormation (opens new window) 模板后,由 AWS 创建或更新资源。部署配置也纳入代码管理,方便审查变更和复用,不必在控制台重复配置。具体用法见 CDK:用熟悉的编程语言定义资源。
相关代码:packages/infra/bin/infra.ts(部署入口)、packages/infra/lib(各资源部署单元)。
# 为什么选择 AWS CDK?
建议回答:项目本身用 TypeScript,选择 CDK 后,基础设施也能用熟悉的语言管理,不必再维护一套手工配置。资源定义可以放进 Git,修改时能审查,换环境时也能复用,只调整域名、实例规格等参数。比如数据库连接权限可以写进代码,减少手动配置遗漏。CDK 最终仍通过 CloudFormation 部署,不是自己写脚本逐个创建资源。
# 为什么拆分 Stack,怎样避免循环依赖?
建议回答:Stack 是一组一起部署的云资源。项目按职责分成网络、数据库、存储和应用四组,因为它们的改动频率不同,分开后更容易看清一次发布会影响什么。拆分本身不保证数据库不会重建,是否替换资源仍取决于具体修改,发布前要用 cdk diff 检查。
依赖要保持单向。例如应用需要数据库凭据,数据库又需要引用应用安全组来允许连接;如果安全组放在应用 Stack,就会形成相互依赖。项目把共享安全组放到 NetworkStack,让数据库和应用都引用网络层,应用再引用数据库,数据库不再反过来依赖应用。
# Docker
Docker (opens new window) 把应用代码、Node.js 运行环境和依赖打包成镜像,减少 “本机能跑,服务器不能跑” 的差异。Web、API 和 Relayer 可以分别打包,镜像交给 ECR 保存,再由 ECS Fargate 启动容器运行,职责说明见 ECS Fargate 与 ALB。
相关代码:apps/server/Dockerfile(API 镜像构建)、apps/server/docker-entrypoint.sh(容器启动)。
# 项目为什么使用 Docker,镜像和容器有什么区别?
建议回答:主要是把代码、Node.js 版本和依赖一起固定下来,减少不同机器上的环境差异,也方便把同一份应用交给 ECS 运行。镜像是打包好的只读运行模板,容器是把这个模板启动后得到的运行实例。例如,同一个 API 镜像可以启动多个容器,每个容器分别处理请求,不需要各自重新安装依赖。
# 如何优化镜像构建,并保证发布版本可追踪、可回滚?
建议回答:构建时按锁文件安装依赖,用多阶段构建把安装过程和运行环境分开,减少最终镜像里的无关文件。启动时直接执行已安装的程序,不再临时联网装包,这样启动更稳定,也避免安装过程额外占用内存。项目 API 使用 Node.js 22,直接调用已安装的 TypeScript 运行工具 tsx 启动服务。
发布时用 Git 提交号或版本号标记镜像,再用 digest(镜像内容的唯一标识)锁定部署的那一份。latest 和普通版本标签都可能被覆盖,只看标签不够。保留旧镜像以及对应的部署配置,发布失败就能回退到上一版,不必临时重新构建;数据库和链上已经发生的变化,则不能靠回退镜像撤销。
# ECR
本项目用 ECR (opens new window) 保存 API 的 Docker 镜像,CDK 中的 ECS 任务配置通过仓库地址和镜像标签指定要运行的版本。ECR 管镜像存储,ECS 管容器调度,Fargate 提供运行资源。通用原理与权限说明见 ECR 怎样管理发布镜像。
相关代码:packages/infra/lib/backend-stack.ts(API 镜像引用)、apps/server/Dockerfile(API 镜像构建)。
# 项目为什么使用 ECR,它和 ECS 怎样配合?
建议回答:ECR 是 AWS 的镜像仓库,负责保存应用镜像;ECS 负责调度容器,Fargate 提供运行资源。选择 ECR,主要是因为项目部署在 AWS,可以通过 IAM 任务执行角色授予镜像拉取权限,不需要在应用里保存镜像仓库的长期密码。发布时先构建镜像并推到 ECR,再更新 ECS 的任务配置,让它拉取指定版本并启动容器。
# CloudFront
CloudFront (opens new window) 在这套部署方案中作为用户访问的统一入口:能命中缓存的内容就近返回,需要应用处理的请求再转发到 ALB,由 ALB 分发给健康的 Web 或 API 实例。基础原理见 CloudFront:缓存与回源。
# 项目为什么使用 CloudFront,它与 ALB 怎样分工?
建议回答:CloudFront 主要负责就近分发和静态资源缓存,减少访问延迟与源站压力;ALB 负责把回源请求分给健康的应用实例。比如 JS、CSS 命中缓存后可以直接返回,登录、购买等请求则交给后端处理。两者配合使用,不是互相替代。CloudFront 还能接入 WAF,但不会自动阻止用户直连 ALB,需要另外限制源站访问,避免绕过边缘防护。
# 哪些内容可以缓存,怎样避免用户数据串用和购买状态过期?
建议回答:缓存要看返回的内容,不能只看它是页面还是 API。带内容哈希的 JS、CSS 可以长时间缓存;公开课程介绍只要允许短暂延迟更新,也可以缓存,包括服务端渲染的页面。个人购买状态、登录会话和付费视频地址则不放进共享缓存,避免把 A 用户的视频地址返回给 B,或者买课后仍显示旧的“未购买”状态。
具体配置时,按路径区分缓存规则,对这些个性化响应明确禁用 CloudFront 缓存,并向后端转发鉴权所需的 Cookie 或 Authorization,不能以为请求带了它们就会自动不缓存。页面把公开介绍和个人状态分开获取,购买交易确认后重新查询购买状态,避免继续显示前端保留的旧结果。
# AWS WAF
AWS WAF(Web Application Firewall,Web 应用防火墙)可以在请求进入应用前,识别并拦截常见攻击,也可以限制过于频繁的访问。在这套部署方案中,它接入 CloudFront,减少恶意请求对后端服务的影响。
防护规则集中在 Web ACL(Web 访问控制列表)中,可以把它理解为绑定到 CloudFront 的一组检查规则,主要分为两类:
- 识别常见攻击:使用 AWS 提供的托管规则,检查 SQL 注入、跨站脚本等攻击特征,不必从零编写每条规则。
- 限制频繁访问:使用速率限制规则(rate-based rule),例如同一 IP 在一段时间内大量请求登录 nonce 接口,超过设定阈值后拦截,减少后端资源被反复消耗。
规则启用前,先用 Count 动作只统计命中的请求、不拦截,通过日志检查是否误判正常访问;调整规则和阈值后,再切换为 Block 动作进行拦截。按 IP 限速时,还要考虑公司或校园里多人共用一个公网 IP 的情况,避免把正常用户一起拦住。
# WAF 和安全组有什么区别?
建议回答:安全组管网络连接,WAF 检查 HTTP 请求。比如数据库的安全组只允许应用服务连接数据库端口;WAF 则可以在网站入口拦截带有 SQL 注入特征的请求,或者限制短时间内大量调用登录接口的访问。两者负责不同层面的防护,可以配合使用,但用户有没有购课权限,仍然要由业务代码检查。
# ACM
ACM (opens new window)(AWS Certificate Manager)是 AWS 的证书管理服务,负责申请、验证和管理 HTTPS 证书。证书绑定到 CloudFront 或 ALB 后,由这些服务处理 HTTPS 连接,ACM 本身不转发请求。基础原理见 ACM:管理 HTTPS 证书。
这套部署方案需要分别配置两段 HTTPS,不能只保证浏览器到网站入口这一段安全:
- 浏览器到 CloudFront:为网站域名绑定 ACM 证书,让用户通过 HTTPS 访问;CloudFront 使用的证书需要在
us-east-1(美国东部,弗吉尼亚北部)申请。 - CloudFront 到 ALB:回源也使用 HTTPS,在 ALB 的 443 端口绑定与 ALB 同一区域的证书,证书覆盖回源使用的域名;80 端口只用于将 HTTP 请求跳转到 HTTPS。
这两段 HTTPS 不代表后续连接都已加密。ALB 到容器的连接要单独确定是否使用 HTTPS;应用连接 RDS 数据库时,也需要另外配置加密连接,并校验证书和数据库主机名。
# 项目为什么使用 ACM,而不在容器里管理 HTTPS 证书?
建议回答:使用 ACM 可以把证书管理和应用发布分开。证书绑定在 CloudFront 和 ALB 上,应用容器就不需要保存网站证书的私钥,也不用为了更新证书重新构建镜像。采用 DNS 验证的 ACM 公有证书,在满足续期条件时可以自动续期,减少人工维护和证书过期的风险。用于验证域名的 DNS 记录需要保留,证书续期状态也要监控。
# 为什么域名解析正常,HTTPS 证书却可能签发失败?
建议回答:域名能解析到网站,只说明访问入口配置好了,不代表证书验证已经通过。申请 ACM 证书时,还需要添加它指定的 DNS 验证记录,通常是一条独立的 CNAME,用来证明我控制这个域名,它不是网站访问入口的那条记录。如果域名配置了 CAA,也要确认它允许 ACM 使用的证书机构签发证书。排查时先看 ACM 显示的失败原因,再检查验证记录是否正确、是否已经生效,以及 CAA 是否限制了签发。
# ECS Fargate 与 ALB
ECS (opens new window) 管应用实例,Fargate (opens new window) 提供运行容器的计算资源,ALB (opens new window) 把请求分给正常工作的实例。原理见 Amazon ECS 和 ALB:把请求分给应用实例。
ECS 中的 Task(任务)是按配置启动的一组容器,Service(服务)负责维持指定数量的任务,并在发布时替换它们。当前 CDK 代码配置的是 API 服务,期望任务数设为 1;生产方案可将 Web、API 和 Relayer 分开部署,避免页面发布影响发证,也方便分别扩容。
现有 API 配置允许发布时先启动新任务、再下线旧任务,并启用了部署失败回滚。需要更高可用性时,可让 Web/API 在不同可用区运行多个实例;Relayer 则要先补持久化进度和多实例协调,不能直接照搬 Web/API 的扩容方式。发布机制见 滚动发布怎样尽量避免请求中断。
相关代码:packages/infra/lib/backend-stack.ts(API 容器、负载均衡与健康检查)。
# 为什么选择 ECS Fargate,而不是自己管理 EC2 服务器?
建议回答:主要是复用容器,同时减少服务器运维。ECS 负责维持实例数量和滚动发布,Fargate 提供运行资源,不需要自己维护服务器和容器运行环境。项目的 API 和持续轮询的 Relayer 都适合这种运行方式。相比自己管理 EC2 云服务器 (opens new window),维护工作更少,但常驻容器仍会持续计费;如果以后负载很大且稳定,再比较 EC2 的成本与控制能力。
# 为什么不用 Lambda?
建议回答:不是 Lambda 做不到,而是当前服务按常驻 Node.js 程序运行,尤其 Relayer 需要持续轮询。改成 Lambda 要把任务拆成有限时长的批次,保存处理进度,并处理重复触发;使用 ECS 改造更少。图片处理、定时清理等短任务,仍可以单独使用 Lambda。
# 健康检查通过说明什么?
建议回答:只说明被检查的接口能正常响应。项目的 /health 返回 { ok: true },并没有查询数据库、RPC 或 Pinata,所以通过检查不代表学生一定能完成购课或领证。健康检查用于判断实例是否可接收请求,关键依赖和完整业务流程还需要单独监控、验证。
# ECS 任务启动失败、没有日志时,怎样排查?
建议回答:先看 ECS 的任务停止原因,再判断失败发生在哪一步。镜像没拉下来,查仓库权限、镜像地址、CPU 架构和镜像格式;进程启动后马上退出,查启动命令、环境变量和内存;应用已运行却没有日志,再查日志配置与权限。项目遇到过镜像清单与拉取环境不兼容,也遇到过启动时额外安装依赖导致内存不足,所以不能看到日志为空就认定是业务代码的问题。
相关代码:apps/server/Dockerfile(构建与启动命令)、apps/server/docker-entrypoint.sh(启动入口)、packages/infra/lib/backend-stack.ts(容器运行配置)。
# 怎样发布新版本,并处理正在执行的发证任务?
建议回答:Web 和 API 采用滚动发布:先让新实例通过健康检查,再让旧实例停止接收请求,完成已有请求后退出;新版本无法正常启动时回滚。数据库变更也要兼容同时运行的新旧版本,不能只靠增加实例数就保证不中断。
Relayer 还需要保存扫描进度、失败任务和已广播交易的哈希,退出前停止接收新任务,让新实例能接管。交易结果未知时先查原交易,不能立即再发一笔。当前 Relayer 尚未实现这些持久化与退出等待机制,这是生产发布前需要补齐的部分。
# RDS PostgreSQL
RDS (opens new window) 是 AWS 的托管数据库服务,本项目用它运行 PostgreSQL,保存用户、课程内容、评论和学习进度。课程价格与购买关系仍以合约为准。基础原理见 Amazon RDS,网络配置见 RDS、S3 与 Secret 怎样放。
生产环境重点关注三个问题:
- 谁能连接:数据库放在隔离子网,不公开访问;安全组只允许应用连接数据库端口,账号密码由 Secrets Manager 管理。
- 连接是否安全:既要加密传输,也要校验数据库服务器的证书。当前启动脚本使用
sslmode=no-verify,有加密但没有严格证书校验,生产环境应补上可信证书校验,不能把内网当作免校验的理由。 - 出事怎么恢复:Multi-AZ(多可用区部署)处理实例或可用区故障;备份与时间点恢复处理误删、错误更新等数据问题。
当前 CDK 中的生产配置开关为关闭状态,配置为单可用区、保留 1 天备份,未开启删除保护。生产环境需要按数据重要性调整这些设置,并演练恢复。Relayer 后续可用 RDS 保存进度和失败任务,但仍需实现对应的数据表与恢复逻辑。
相关代码:packages/infra/lib/database-stack.ts(数据库资源)、apps/server/src/db/index.ts(应用连接)。
# 数据库故障和误删数据,分别怎样恢复?
建议回答:这两种问题需要不同手段。实例或可用区故障,可以通过 Multi-AZ 切换到备用实例;但误删数据也可能同步到备用实例,所以不能靠它找回。误删或错误更新要通过备份和时间点恢复,创建恢复后的数据库,再核对数据并切换应用连接。两者分别解决可用性和数据恢复问题,都需要提前配置并演练。
# S3
S3 (opens new window) 是对象存储服务,本项目用它保存头像文件,数据库只记录头像地址。浏览器拿到后端签发的上传许可后直接上传到 S3,文件不依赖应用容器的磁盘,容器重建也不会丢失。基本能力见 Amazon S3。
项目只公开 avatars/ 目录下头像的读取权限。预签名 POST 限制上传路径、文件类型、大小和有效期;CORS 配置允许前端域名完成浏览器直传。付费视频不能沿用头像的公开读取策略,应使用私有对象和短时访问授权。
当前存储桶配置允许删除资源时清空对象,适合测试,但真实用户数据上线前应改为保留存储桶和对象;再按需要启用版本管理、备份及过期文件清理。
相关代码:packages/infra/lib/storage-stack.ts(桶权限与跨域配置)、apps/server/src/lib/s3.ts(上传许可)。
# 配置了 CORS,是否就能阻止未授权上传?
建议回答:不能。CORS 是浏览器的跨域访问规则,不是上传权限检查,curl 或后端程序不会受它约束。项目真正依靠后端核验登录身份、签发限定条件的上传许可,再由 S3 校验签名与上传条件。即使允许了前端域名,没有有效上传许可,也不能随意写入文件。
# AWS KMS
AWS KMS (opens new window)(Key Management Service,密钥管理服务)在本项目中保管发证私钥,并为 Relayer 提供交易签名能力。Relayer 通过公钥确定发证钱包地址,准备交易并把交易摘要交给 KMS,再使用返回的签名广播交易,整个过程无需读取或持有私钥。基础原理与凭据管理的区别见 KMS 与 Secrets Manager。
项目使用以太坊所需的 secp256k1 密钥,签名请求设置 MessageType=DIGEST,表示传入的是已经计算好的交易摘要。KMS 返回的签名还需要转换成以太坊格式,具体过程见 自建 Relayer 与 AWS KMS 签名。
KMS 降低的是私钥被复制的风险,不会自动判断交易是否合理。运行服务的角色应只拿到指定密钥的必要权限,应用和合约仍负责限制发证用途。
相关代码:apps/relayer/src/kms-account.ts(公钥读取与交易签名)。
# KMS 能不能限制只能调用某个 Solidity 函数?
建议回答:不能直接做到。KMS 收到的是待签名摘要,不知道它对应哪个合约函数。项目在发证执行器中固定证书合约和 mint 方法,合约再检查调用者是不是授权的发证地址。但这是业务代码的限制,不是 KMS 对交易内容的强制检查;如果签名权限被滥用,仍有风险,因此还要限制调用权限并审计异常签名。
# Secrets Manager
Secrets Manager (opens new window) 用来集中保存应用需要读取的密码和第三方凭据。项目把数据库账号密码、better-auth 会话密钥和 Pinata 凭据放在这里,ECS 启动容器时读取所需字段,注入环境变量,源码和镜像不保存这些真实值。基础原理见 KMS 与 Secrets Manager。
权限分为两类:ECS 启动时拉取镜像、读取凭据,使用任务执行角色(Execution Role);应用运行后上传头像或调用 KMS,使用任务角色(Task Role)。两类角色都只授予必要权限,不能为了方便给容器所有 AWS 权限。
通过环境变量注入后,凭据更新不会自动进入已运行的容器,需要启动新任务;日志和诊断输出也不能泄露这些值。轮换机制见 Secret 怎样交给应用。
相关代码:packages/infra/lib/backend-stack.ts(凭据创建与容器注入)、packages/infra/lib/database-stack.ts(数据库凭据)。
# 凭据已由 Secrets Manager 管理,为什么还要注入容器环境变量?
建议回答:Secrets Manager 负责保存和控制凭据的读取权限,环境变量是把凭据交给应用的一种方式。项目中的 Node.js 库通过环境变量读取配置,所以由 ECS 在启动时注入,应用不需要把密码写进源码。但环境变量仍是应用可读取的明文,不能打印到日志;更新凭据后,也需要滚动启动新任务才能使用新值。
# CloudWatch
CloudWatch (opens new window) 用于集中查看应用日志、运行指标和告警。现有 API 容器配置了日志输出,Relayer 也记录发证各阶段的结果;生产部署时还需要接入统一日志和告警。基础原理见 AWS 可观测性、性能监控与 AI 辅助排障。
比如学生申请后没收到证书,可以用钱包地址、课程 ID 和交易哈希串起日志,判断是没有读到申请、学习条件未通过、元数据上传失败,还是交易执行失败。日志记录必要的定位信息,但不记录密码、私钥或原始登录签名。
Web/API 重点监控错误率和响应时间,数据库监控连接数与剩余存储。Relayer 的业务指标与恢复流程统一放在 可观测性和故障恢复,避免只监控进程是否存活。
相关代码:packages/infra/lib/backend-stack.ts(API 容器日志配置)、apps/relayer/src/logger.ts(发证日志)。
# CloudWatch 和 CloudTrail 有什么区别?
建议回答:CloudWatch 主要回答服务是否正常、为什么报错;CloudTrail (opens new window) 主要记录谁在什么时间调用了哪些 AWS API。比如发证失败,先用 CloudWatch 找业务错误,再用 CloudTrail 核对哪个身份调用了 KMS、是否被拒绝。一个侧重运行监控,一个侧重操作审计。
# VPC 网络与访问控制
VPC (opens new window) 是 AWS 中的虚拟私有网络,用来划分应用和数据库的网络边界。本项目的原则是:入口可以接收公网请求,应用可以主动出网,数据库只接受应用连接。网络原理见 公有子网、私有子网、NAT 与安全组。
| 位置 | 放什么 | 访问规则 |
|---|---|---|
| 公有子网 | ALB、出网网关 | ALB 接收入口请求;网关供私网应用主动访问外部 |
| 可出网私有子网 | 当前 API;生产方案还可放 Web、Relayer | 不直接提供公网入口,通过 ALB 接收请求,通过 NAT 访问 RPC、Pinata 等服务 |
| 隔离子网 | RDS 数据库 | 安全组只允许应用连接数据库端口,没有直接的公网出口 |
子网决定网络路径,安全组决定哪些连接被允许。当前应用端口只放行 ALB 来源,数据库端口只放行应用安全组;服务在同一个 VPC 中,也不代表应互相开放所有端口。Relayer 只主动访问外部服务,不需要暴露公网接口。
相关代码:packages/infra/lib/network-stack.ts(子网与安全组)、packages/infra/lib/database-stack.ts(数据库网络)。
# NAT Gateway 做什么,为什么要考虑按可用区配置?
建议回答:NAT Gateway 让没有公网 IP 的应用主动访问互联网,比如查询 RPC 或调用 Pinata,但不把容器变成可直接访问的公网服务。项目当前用一个 NAT Gateway 控制成本;如果多个可用区共享它,它所在可用区故障时,其他可用区也可能失去出网能力。对出网可用性要求高时,可以每个可用区配置一个,代价是固定费用增加。多可用区仍不等于跨区域灾备。
这里讨论的是项目现有的可用区级配置。请求与响应怎样经过网关、基本路由和区域级方案,统一见 NAT Gateway:私网应用的公网出口。
# 为什么应用和数据库放在不同类型的私有子网?
建议回答:它们对网络的需求不同。应用需要主动访问 RPC、Pinata 等外部服务,所以放在能通过 NAT 出网的私有子网;数据库只需要接收应用连接,所以放在没有直接公网出口的隔离子网。再用安全组限制 ALB 到应用、应用到数据库的连接,不直接把容器或数据库暴露给用户。
共享安全组放在网络部署单元,是为了避免应用与数据库部署单元互相依赖,具体见 为什么拆分 Stack,怎样避免循环依赖?。
# 第七部分 测试、安全、可观测性与扩展
购课和发证是否可靠,要看资金、权限和业务记录是否正确,不能只看页面有没有成功提示。测试验证这些规则,安全措施限制谁能操作,日志和恢复机制帮助处理链上、链下交互中的失败。
# 测试与验证
按验证对象分为三层,既检查单个模块的规则,也检查它们能否配合完成业务:
| 验证层次 | 工具与验证环境 | 验证重点 |
|---|---|---|
| 合约测试 | Foundry | 购课扣款与记录一致;授权不足时交易回滚;越权、重复购买和重复发证被拒绝;证书不能转让 |
| 前后端测试 | 组件测试与 Vitest 选型 | 未登录或无教师资格时拒绝请求;查链失败不误判管理员身份;结业查询、上传或发证失败时走正确分支 |
| 集成验证 | Sepolia 测试网 (opens new window)、cre workflow simulate (opens new window) | Sepolia 验证真实钱包、RPC 和合约交互;CRE 模拟验证本地工作流运行与外部调用,不代表正式执行网络已上线 |
本地单元测试用替身(mock)控制外部依赖的返回值,方便复现成功、失败和异常情况;集成验证再检查真实网络、签名与权限配置。部署后的登录、购课、发证验收,以及重启、回滚演练,需要在目标环境单独执行并记录结果。
Foundry 和 Vitest 负责运行测试;Sepolia 提供真实的测试链环境,不是测试框架;cre workflow simulate 则是工作流模拟命令。
此外,tsc (opens new window) 检查类型,ESLint (opens new window) 检查代码规则,next build (opens new window) 和 forge build (opens new window) 检查能否生成应用和合约产物。这些检查不能替代业务测试。
相关代码:packages/contracts/test(合约测试)、apps/web/src/hooks/use-is-owner.test.ts(前端权限状态)、apps/server/src/trpc/init.test.ts(服务端权限)、apps/relayer/src/completion-handler.test.ts(发证流程)。
# 项目怎样测试,如何确认购课和发证流程正确?
建议回答:项目分合约、前后端和集成三层验证。Foundry 测试购课扣款、权限和证书规则,Vitest 测试接口权限及发证流程的成功、失败分支,再在 Sepolia 核对真实交易回执和合约状态。关键不只是流程能走完,还要验证失败时状态正确:例如扣款失败不能留下购买记录,重复申领不能多发证书,未登录或没有权限不能操作。前端验收还要覆盖切换钱包后旧结果返回、授权成功但购买失败等情况。CRE 已做隔离模拟,但正式部署和权限配置仍需单独验证,不能把模拟结果当成生产上线结果。
# 为什么使用 Vitest,没有同时引入 Jest 和 Playwright?
建议回答:Vitest 和 Jest 都能运行单元测试、模拟依赖和检查结果,作用有较多重叠。项目已经用 Vitest 执行前端 Hook、组件、后端接口和发证流程测试,没有必要为同类用例再维护一套 Jest 配置。前端配合 React Testing Library 和 jsdom 模拟组件交互,后端在 Node.js 环境测试业务逻辑。具体差异见 Vitest 与 Jest 的区别。
Playwright 不是被 Vitest 替代了。它可以在真实浏览器中自动操作页面,验证登录、购课、刷新后状态恢复等完整流程。当前项目没有接入 Playwright 端到端回归,这是自动化测试仍需补齐的一层;现有模拟测试和 Sepolia 交易验证不能替代浏览器验收。后续可以优先补关键流程,而不是把所有单元测试搬到浏览器里。相关分工见 Playwright:关键业务流程回归。
# 权限与资金安全
权限不能只靠前端按钮控制,资金和证书规则也不能只靠后台约定。各层承担的防护如下:
| 保护对象 | 关键防护 |
|---|---|
| 登录与访问权限 | 后端验证钱包签名并原子消费 nonce;管理接口核验角色,付费内容核验购买状态;查询失败时不能当成有权限 |
| 购课资金与证书 | 购课合约限制重复购买,扣款失败时整笔交易回滚;证书合约限制发证者,并用领取记录阻止重复发证 |
| 发证签名 | AWS KMS 保管不可导出的私钥,Relayer 执行器只调用固定证书合约的发证方法;部署时还需限制 IAM 签名权限,KMS 本身不判断用户是否结业 |
| CRE 报告 | 候选 Chainlink CRE 路径由接收合约校验报告来源、工作流与目标信息;这些检查不代表 CRE 已接管发证 |
| 头像上传 | S3 头像预签名 POST 限定上传路径、类型与大小,保存头像地址时由后端核验用户归属 |
详细实现见 nonce 防重放为什么要原子消费 和 核心合约与权限。
学习完成度校验仍需增强:当前进度由用户上报,后端虽然校验身份和数值范围,但结业查询没有复核购课资格,也不能识别伪造的 100% 进度。需要补充学习过程校验和发证前的购买复核,具体见 如何防止用户伪造学习完成状态,骗取证书?。
相关代码:apps/server/src/trpc/init.ts(接口权限)、apps/server/src/lib/auth-plugins/wallet-sign-in.ts(签名防重放)、packages/contracts/src(购课、证书与 CRE 接收合约)。
# 可观测性和故障恢复
排查 “学生申请后没收到证书” 时,以钱包地址和课程编号串起申领、结业查询、资料上传和发证日志,按顺序定位:
- 申领是否成功:查申领交易回执和事件。如果申领交易失败,先处理这个失败,而不是重启 Relayer。
- 任务卡在哪里:有事件但没有处理日志,查 RPC 和扫描区间;已经开始处理,就查结业判定、Pinata 上传或 KMS 签名在哪一步失败。
- 链上是否已发证:如果交易已广播但等待超时,先查原交易回执和合约的
hasClaimed领取记录,不能把超时直接当成失败。 - 是否可以补处理:已领取则跳过,原交易仍待确认则继续跟踪;确认失败且仍满足发证条件时,才安排重试。
已有能力:Relayer 已有处理日志、错误分类、交易回执检查,以及重复发证的正常跳过逻辑。
待完善能力:扫描进度目前只在内存中,失败任务没有持久保存,也没有完整的发证自动重试。需要保存扫描进度、任务状态和已广播的交易哈希,重启后补扫并核对结果,避免依赖用户重新申领。下一次轮询能重试 RPC 查询,不等于能恢复已经失败的发证任务。
监控应关注扫描落后多少区块、申请到发证用了多久、哪个阶段失败,不能只看进程是否存活。任务状态持久化后,还可以统计未完成任务数量和重试次数;这些业务指标与告警需要另外配置,现有日志不等于已经具备完整监控。
应用发布失败可以回滚镜像,但已经完成的链上交易不会随代码回滚撤销;数据库变更也需要单独的兼容与恢复方案。
相关代码:apps/relayer/src/listener.ts(扫描区间)、apps/relayer/src/logger.ts(日志)、apps/relayer/src/revert-classifier.ts(回滚原因分类)、apps/relayer/src/mint-executor.ts(交易与回执)。
# 交易超时或任务重复,怎样避免重复发证?
建议回答:超时只表示暂时拿不到结果,不等于链上交易失败。我会先查原交易回执和领取记录:已经发证就停止处理,仍待确认就继续跟踪,确认失败后才考虑重试。重复事件最终由合约的领取记录兜底,保证同一钱包对同一课程不多发证书;但查询和上传等步骤仍可能重复执行。要避免漏发,还需要持久化任务和扫描进度,让服务重启后能继续处理,这部分当前还需要补齐。
# 规模扩大后的演进
先补齐学习校验和任务恢复,再根据实际瓶颈增加组件。下面是后续方案,不是当前已部署的能力:
| 触发条件 | 优先改进 |
|---|---|
| 查链频繁、RPC 限流 | 后台同步事件形成查询索引,记录区块高度并处理链重组;购买权限不能盲信过期缓存 |
| 数据库响应变慢 | 先检查慢 SQL、索引和连接池,确认瓶颈后再评估连接代理或只读副本 |
| 访问或任务积压 | Web/API 按负载扩容;发证任务增多时引入队列,多 Relayer 实例运行前先协调任务领取和交易序号 |
| 重要权限集中 | 对发证者变更、资金接收地址等敏感操作评估多签、延迟执行和审计记录 |
视频防转发方案见 只向已购用户返回视频地址,就能防止视频泄露吗?,不在这里重复展开。
# 第八部分 难点与最难问题的标准回答
以下回答保留实际问题、处理办法和验证结果,并区分已经修复的故障与尚未补齐的可靠性机制。
# 难点一 KMS 签名适配与发证验证
建议回答:最难的是让后台安全地自动发证。服务必须能签链上交易,但我不希望把私钥直接放在服务器配置里,所以使用 AWS KMS。问题是 KMS 返回的是通用签名格式,viem 不能直接拿来发送以太坊交易。
我做了一层适配:先从 KMS 公钥确定发证地址,再转换签名格式,并恢复签名地址与预期地址比对,确认没转换错,最后交给 viem 广播。业务入口只允许调用指定证书合约的发证方法,不暴露任意交易能力。
验证时先核对恢复出的签名地址,再检查 Sepolia 交易回执,以及证书是否归目标钱包。合约通过 hasClaimed 阻止同一钱包对同一课程重复领证;但 Relayer 的持久化扫描进度还没补齐,所以签名适配完成,不代表停机恢复也已经完成。
相关代码:apps/relayer/src/kms-account.ts(签名转换)、apps/relayer/src/mint-executor.ts(铸造交易)。
# KMS 签名适配中,哪些是自己实现的,哪些复用了库?
建议回答:签名运算由 KMS 完成,签名解析、规范化和公钥恢复复用 noble curves (opens new window),交易编码与广播复用 viem。我自己实现的是把 KMS 公钥和签名转换为 viem 能使用的账户接口,并核对恢复出的地址。这是格式与接口适配,不是从零实现密码学算法。
# 难点二 RPC 事件监听不稳定
建议回答:当时学生已经在链上申请证书,后台发证服务 Relayer 也还在运行,但一直没有发证,日志不断报 filter not found。我先确认合约已经产生申领事件,发现问题不是用户申请失败,而是后台没有正常读到这条事件。
原来的监听方式由 viem 向 RPC 节点创建临时过滤器,让节点只关注证书申领合约中,学生申请证书时产生的事件。节点保存这些条件后返回一个编号,例如 0x123,后台再拿这个编号查询新增申请。但编号过期,或者后续请求被分配到另一台不认识这个编号的节点,就会报 filter not found,也就是 “找不到这个过滤器” 。
我把它改成按区块范围直接查询事件:例如这次查第 1000~1010 块,下次查第 1011~1020 块,每次都带上合约地址和事件条件,不再依赖临时编号。同时,前一次查询没结束时不启动下一次,避免重叠扫描。这个改动解决了过滤器失效导致的查询问题。
重启后的恢复仍需补齐:目前扫描进度只在内存里,重启后从最新区块开始,停机期间的申请和处理失败的任务仍可能被漏掉。后续应先把扫到的事件保存为数据库任务,再更新扫描进度,重启后继续扫描和处理未完成任务,并补上链重组检查。合约的领取记录能防止重复发证,但不能替后台找回漏掉的申请。
相关代码:apps/relayer/src/listener.ts(区块范围扫描)、apps/relayer/src/completion-handler.ts(事件处理)。
# 难点三 钱包切换后旧请求覆盖新状态
建议回答:用户用 A 钱包发起请求后切到 B 钱包,A 的结果可能更晚返回,把页面误更新成 A 的余额或购买结果。
我用版本号区分新旧请求:A 钱包发起请求时是版本 1,切到 B 后变成版本 2,版本 1 的结果返回时就不再更新页面。切换课程也做同样处理,交易另外记录发起时的钱包和课程,避免把旧交易结果显示到新页面。
这只是隔离页面状态,不能撤销已经广播的交易。登录方面,断开钱包时会调用后端退出登录;但如果旧登录请求更晚完成,仍可能重新建立会话,这种情况还需要补齐防护。
相关代码:apps/web/src/hooks/use-wallet-sign-in.ts(登录请求隔离)、apps/web/src/hooks/use-purchase-flow.ts(购买上下文隔离)、apps/web/src/components/wallet/wallet-auth-provider.tsx(退出后端登录)。
# 难点四 交易结果未知时避免重复付款
建议回答:这里的难点是交易已经提交,但 RPC 查询可能超时。如果直接显示失败并允许重试,用户可能再次发起交易。查询失败不等于交易失败。
我把授权和购买分开跟踪,拿到交易哈希后保留它。如果确认查询失败,就进入 “结果未知” 状态,禁用提交按钮并提供链上查询入口,而不是让用户直接重试。只有购买交易的成功回执返回后,才显示购买成功。合约也会拒绝同一钱包重复购买同一课程,避免再次扣款,但重复发送仍可能浪费 gas。
目前结果未知时仍需要用户核对,刷新后的交易跟踪也还需完善。后续应保存交易哈希、钱包和课程,重新打开页面时核对原交易回执及购买记录,再恢复状态,而不是直接重新付款。完整流程见 完整购课状态机。
相关代码:apps/web/src/hooks/use-purchase-flow.ts(区分失败与结果未知)、apps/web/src/components/course/purchase-guide.tsx(禁用提交与交易查询入口)、packages/contracts/src/CoursePlatform.sol(防止重复购买)。
# 难点五 CRE 运行环境兼容性
建议回答:Workflow 在本地测试通过,放到 CRE 的真实模拟里却失败。根因是它运行在 WASM 环境,不是完整 Node.js;例如原来依赖 URL 全局对象的校验不能照搬,HTTP 请求格式也有明确要求。
我把 HTTP 调用集中到适配层,按 CRE 要求处理请求体编码和超时时长,并调整依赖 Node.js 全局对象的配置校验。之后运行 CRE 模拟,核对后端查询、元数据上传和发证结果。这样保留了共享业务逻辑,同时把运行环境差异留在适配层,避免改动散落到整个流程。
相关代码:apps/cre-workflow/src/http-adapter.ts(HTTP 适配)、apps/cre-workflow/src/config.ts(配置校验)。
# 第九部分 项目复盘
# 如果重新做,最先改什么?
建议回答:先补交易与发证的恢复能力,保存未完成任务、扫描进度和交易哈希,让页面刷新或服务重启后能核对原结果,而不是依赖用户重新付款或申领。再补课程存在性校验、数据库证书校验和删除保护。优先解决重复资金操作、漏发证书和数据损失,队列、索引器和更多实例等出现实际瓶颈后再引入。
# 多人协作时怎样划分购课功能?
建议回答:先约定合约、前端和后端各自负责什么:合约执行扣款和购买规则,前端处理授权、签名和确认状态,后端核验登录与内容访问权限。联调不仅验证成功,还共同检查拒签、价格变化、交易超时和切换钱包;ABI 或接口类型变更后,同步验证相关调用方。
# 第十部分 重点问题速查
以下是面向合约与 CFD 岗位的精选入口,不是完整题目索引。
| 问题与答案跳转 | 回答要点 |
|---|---|
| 项目介绍 | 购课与发证两条主线 |
| 你主要负责什么? | 工具与自己实现的业务流程 |
| 完整购课状态机 | 授权、购买、签名与确认 |
| 难点四 交易结果未知时避免重复付款 | 保留原交易,不盲目重试 |
| 难点三 钱包切换后旧请求覆盖新状态 | 新旧账户状态隔离 |
| 连接钱包成功了,为什么接口仍然说没登录? | 钱包连接不等于后端会话 |
| nonce 防重放为什么要原子消费 | 并发请求只能使用一次挑战 |
| 用户签名前管理员改了课程价格,会按哪个价格扣款? | 当前合约价格与最高支付价 |
| 价格影响和滑点有什么区别? | 报价变化与最低接收数量 |
| 链上查询很慢或超时,页面应该怎样处理? | 批量读取、重试与安全降级 |
| 发证失败或服务停机后,怎样恢复处理? | 持久任务、补扫与结果核对 |
# 附录 项目证据索引
下列路径均相对项目仓库 yd-web3-university,不是笔记仓库。它们用于核对实现,不代表每项生产改进都已部署。
| 主题 | 代码或文档 |
|---|---|
| 项目状态和地址 | README.md、packages/contracts/deployments/sepolia.json |
| 合约 | packages/contracts/src、packages/contracts/test、packages/contracts/script |
| 购买与兑换前端 | apps/web/src/hooks/use-purchase-flow.ts、apps/web/src/hooks/use-swap-quote.ts、apps/web/src/hooks/use-eth-to-yd-swap.ts、apps/web/src/hooks/use-usdc-to-yd-swap.ts |
| 建池与初始流动性 | packages/contracts/script/AddUniswapV2*.s.sol |
| 钱包认证 | apps/web/src/hooks/use-wallet-sign-in.ts、apps/server/src/lib/auth-plugins/wallet-sign-in.ts |
| API 与链上聚合 | apps/server/src/trpc/routers、apps/server/src/lib/onchain |
| 数据库 | apps/server/src/db/schema、apps/server/src/db/migrations |
| Relayer | apps/relayer/src/listener.ts、apps/relayer/src/kms-account.ts、apps/relayer/src/completion-handler.ts |
| CRE | apps/cre-workflow、packages/contracts/src/CourseCompletionNFTReceiver.sol、packages/contracts/src/cre/ReceiverTemplate.sol |
| 共享证书逻辑 | packages/completion-nft-shared |
| AWS | packages/infra、apps/server/Dockerfile、apps/server/docker-entrypoint.sh |
| 技术复盘 | docs/knowledge-base、AGENTS.md、specs |
# Sepolia 证据
这些地址来自项目部署文件与 README。当前链上权限和服务状态可能变化,实时状态需要另查合约与运行环境,不能只凭这张表判断。
| 对象 | 地址或状态 |
|---|---|
| YDToken | 0xf64bE7869CAf8B00E42209A2CEc3e681a448c320 |
| CourseCompletionNFT | 0x411c76DCA6637286AE52107dAEfF07047f14A9b5 |
| CoursePlatform | 0x5Bd1C512458e087c6BD5188438055e3a47ed2eD9 |
| CompletionRequest | 0xF55292dc9a9256F1af1ec9F8a9758dFbD0F60032 |
| Circle Sepolia USDC | 0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238 |
| YD USDC Pair | 0x716A060EFF81D11D85beBfeFd8E121CA34FED531 |
| YD WETH Pair | 0x6ECd9ACd435D1EadC46a141DC35bf159C0bA5f76 |
| KMS Relayer 发证地址 | 0xdA40b3216d5ac587BbA32181cefE4C705a56762f |
| CRE 状态 | 项目记录显示隔离模拟已完成,尚未切换为 CRE 发证 |
# 技术术语速查
| 术语 | 一句话理解与详细介绍 |
|---|---|
| ABI | Application Binary Interface,应用二进制接口;客户端据此编码合约调用、解码返回值和事件详见:调用从函数名变成字节码 |
| EOA | Externally Owned Account,外部账户;由私钥控制,不等于整个钱包软件详见:账户类型 |
| RPC | Remote Procedure Call,远程过程调用;这里指向区块链节点查数据、提交交易的接口详见:区块链笔记里的 RPC 指什么 |
| Receipt | 交易回执,记录交易执行结果;拿到哈希不等于执行成功详见:一笔 EVM 交易经过哪些阶段 |
| Reorg | 链重组,最近区块被替换,之前看到的事件可能不再有效详见:回执失败和短重组为什么是两件事 |
| CEI | Checks–Effects–Interactions;先检查条件、更新自身状态,再调用外部合约,减少重入风险详见:CoursePlatform 的安全与完整性 |
| Allowance | 授权额度,表示某个合约还能从用户钱包扣取多少代币详见:ERC20 与 approve purchase |
| AMM | Automated Market Maker,自动做市商;按规则与资金池兑换,不必等另一人挂出匹配订单详见:AMM:让用户直接与资金池兑换 |
| Price impact | 价格影响,自己的交易改变池内比例造成的价格变化,通常已计入报价详见:价格影响和滑点有什么区别? |
| Slippage | 滑点,最终成交与此前报价的差异,可能有利也可能不利详见:价格影响和滑点有什么区别? |
| Slippage tolerance | 滑点容忍度,允许成交比报价差多少;超过限制就拒绝兑换详见:滑点容忍度 1% 是什么意思? |
| Soulbound | 灵魂绑定,证书绑定钱包、不能转让,但不等于绑定现实身份详见:ERC721 与 ERC5192 Soulbound |
| SIWE | Sign-In with Ethereum,钱包签名登录标准;本项目采用类 SIWE 流程,不发送链上交易详见:钱包签名认证完整链路 |
| Nonce | 登录里是一次性随机凭证;交易里是账户发送交易的序号,不能混用详见:用 nonce 解释卡单、加速和取消 |
| Idempotency | 幂等,同一操作重复到达,不产生重复业务结果,例如不多发一张证书详见:交易超时或任务重复,怎样避免重复发证? |
| Relayer | 中继服务,组织和提交链上交易;本项目自建后台程序核验结业资格并发证详见:自建 Relayer 与 AWS KMS 签名 |
| Checkpoint | 扫描进度,持久保存已处理到哪个区块,重启后据此补扫;当前 Relayer 尚未持久化详见:发证失败或服务停机后,怎样恢复处理? |
| Lease | 租约,限时指定一个实例处理任务;是多实例扩容方案,不是当前已实现能力详见:怎样避免重复发证和多实例交易冲突? |
| CRE | Chainlink Runtime Environment,编排链上、链下工作流的运行环境;本项目尚未用它接管发证详见:Chainlink CRE |
| DON | Decentralized Oracle Network,去中心化预言机网络;多个节点执行任务并对结果形成共识详见:Chainlink CRE |
| CID | Content Identifier,IPFS 内容标识;能核验内容,但不能凭空恢复丢失文件详见:IPFS 与 Pinata |
| tokenURI | 读取证书资料地址的方法;地址保存在合约,指向 IPFS 上的 JSON,而不是把文件放链上详见:IPFS 与 Pinata |
| KMS | Key Management Service,密钥管理服务;本项目用它保管不可导出的发证私钥并签名详见:AWS KMS |
| Fargate | AWS 提供的容器计算资源,不必自己维护底层服务器详见:ECS Fargate 与 ALB |
| CAA | Certification Authority Authorization;通过 DNS 指定哪些证书机构可以为域名签发证书详见:为什么域名解析正常,HTTPS 证书却可能签发失败? |