#项目背景
AVO 节点商城销售算力节点的 NFT 份额。这类业务的销售半径不取决于页面,而取决于代理网络:平台直卖只有一套价格,把定价权下放给区域与社群的代理商,货才能铺开。平台因此做成两级结构 —— L1 一级代理商向平台按批发价进货,L2 二级代理商再从 L1 进货并面向终端零售,每一级赚自己那一段差价。
进货、返利与零售结算全部在链上完成,账目由合约与索引服务共同维护。对代码而言,这套模式的约束是同一份代码要服务 N 个彼此独立的代理商:每家有自己的域名、合约地址、售价与分成比例,账目互不可见。项目因此做成「一份代码 + 一个部署包」。
#项目角色
负责该平台的前端架构、全部开发,并承担部分后端接口的实现。
具体包括店铺与下单链路、代理后台、邀请归因,以及面向代理商的 L2 独立部署包。合约与索引器用于说明前端所依赖的账本口径与数据来源,不代表该部分由我实现。
#技术方案
#整体架构
应用自上而下四层 —— 合约、索引器、接口与前端,职责边界互不重叠。数据进入前端走两条路,按问题的性质划分:
| 问题 | 来源 | 理由 |
|---|---|---|
| 此刻余额、授权额度、售价与成本配置 | 链上 | 下单前的判断必须是最新值,索引滞后会直接导致交易失败 |
| 我的团队有几个人、本月卖了多少、佣金何时到账 | 后端 | 链上不提供遍历与聚合,这些问题只能由索引后的表回答 |
两条路不合并,也不互相兜底:链上那条失败就报 RPC 错误,不会退回缓存值;后端那条失败就显示空态,不会用链上数据拼一个近似答案。
#方案对比
| 选择 | 替代方案 | 理由 |
|---|---|---|
| Next.js 16 App Router | 纯客户端 SPA | 店铺页需要可被抓取的标题与描述,代理商分享出去的链接要能被搜索到 |
| 直读链上 + 索引后端并存 | 全部走后端 | 授权与余额类读如果走后端,等于在关键路径上加一次缓存失效的判断 |
| Express + Prisma | 直接写 SQL | 24 张表以事件流水为主,写入模式单一,ORM 的迁移与类型收益更高 |
| BullMQ 队列处理事件 | 轮询中同步处理 | 事件处理含跨表写入与外部调用,同步处理会让一次抖动阻塞整段区块 |
| 合约走 UUPS 代理 | 每次升级换地址 | 地址一变,前端配置、索引器起点与已发出的分享链接同时失效 |
| L2 以配置文件 + Zod 校验部署 | 每个代理商改代码 | 配置错误应在部署前被拒绝,而不是在运行时表现为地址为空 |
最后一项是本项目与普通多租户系统的分野:租户之间不是数据行上的隔离,而是各有一份部署,因此交付物必须包含部署工具本身。
#核心业务
商城首页(/):列出当前可售的店铺并按系列聚合,买家从这里进入某一家店,成交发生在店铺内。
店铺详情(/storefront/[collectionId]):节点信息、购买面板、成本拆分与最近成交;成本拆分在下单前从链上读取,买家签名之前就知道这笔钱去了哪里。
开店(/create):五步完成一次上架 —— 建系列、上架节点、批量铸造、配置分成比例、完成。
代理后台(/dashboard):店铺信息、销售汇总、库存、分成比例、邀请链接、推荐关系、佣金领取与 L2 运营面板;代理商只看得到自己那一家店的账。
交易记录(/history):按购买、售出、佣金三类筛选,服务端分页。
平台后台(/admin):黑名单管理、异常转移识别与平台指标;权限来源是环境变量白名单,与代理后台的会话鉴权区分。
邀请短链(/r/[code]):代理商分享出去的入口,解析为对应店铺与签名令牌后跳转到店铺页。
#难点与解决方案
#难点一:归因一旦丢失,佣金无法追溯
问题:分成体系的每一笔佣金都指向一个推荐人,而这个指向只存在于跳转链接里。用户从代理商分享的短链进来,通常不会立刻下单:先看节点说明、再对比档位、可能切去别的页面再回来。这条路径上有三次机会丢失归因 —— 短链解析失败、跨店铺跳转带了别人的令牌、页面刷新后内存中的 ref 消失。任何一种情况都不会报错,只会表现为「这笔单没有推荐人」,且事后无法从链上反推。
解决方案:归因不存内存,存成带校验的令牌。短链 /r/[code] 先请求 resolve-short,拿回该代理商所属的 collectionId 与签名后的 refToken,再跳转到对应店铺。落地页的 useReferrer 做两件事:解码令牌,比对令牌内的 collectionId 与 URL 中的是否一致 —— 不一致说明这是别家店铺的令牌,直接 replace 到正确的店铺,令牌原样带上;一致才写入 localStorage 的 nw_referrer。下单时从本地取出,作为 referrer 传给合约。
结果:归因不可伪造(令牌签名,改一位即校验失败),不随浏览路径丢失(落本地而非内存),也不会把 A 店的下单算到 B 店(店铺归属校验)。历史裸地址链接仍可用,但走的是无法校验的降级路径。
#难点二:四步开店向导,后一步依赖前一步的返回值
问题:上架一个系列是四笔交易,且严格有序:先建系列拿到 collectionId,才能上架节点拿到 tokenId;有了 tokenId 才能批量铸造;铸造完成后才有对象可配置分成。链上没有事务,用户签了前两笔、拒了第三笔,前两笔已经生效;前端若简单地从头再来,会留下一个没有任何节点的空系列,而页面上看不出哪一步其实已经做过了。
解决方案:把向导做成显式状态机,每一步独立记录自己的 txHash 与产出 id,已完成的步骤在重进页面时从链上读回,不重放。id 用 nextCollectionId / nextTokenId 预读,而不是从已有数量推算 —— 后者在别人同时开店时会读到已被占用的值。任一步失败只回退该步,前面的成果保留。
结果:中途退出后可以从断点继续,不必重签已完成的交易;链上不再出现孤立空系列。代价是页面需要持有跨步骤的状态,四个 id 与四个哈希缺一不可,这也是 /create 成为全站状态最复杂页面的原因。
#难点三:同一套前端要交付给 N 个代理商,差异不能靠改代码
问题:每个 L2 有自己的域名、合约地址、接口地址与分成比例。若每个代理商拿一份代码去改,分支当场分叉 —— 后续的修复无法合并回所有店铺,越靠后的代理商版本越旧。若改为运行时读配置,错误就会在用户打开页面时才暴露:地址为空、调错合约、接口 404,而这些问题在部署成功的那一刻全看不出来。
解决方案:把差异全部收敛进一份配置文件,在构建期注入,不在运行时拼接。配置先过 schema 校验 —— 必填项缺失、地址格式不对、chain ID 不在支持列表内,任一不通过即拒绝构建,产物不会生成。链上的差异同样以配置表达:每个 chain ID 对应一组合约地址与「是否支持生息」的布尔开关,有生息的链进入对应流程,没有的链留在合约内由运营手动转出,而不是在业务代码里写条件分支。部署包内附脚本与 docker compose,顺序固定为 config → validate → deploy → env → up。
结果:代理商的接入从「改一套代码」变成「填一份配置」,升级时替换部署包即可,不必逐店合并分支。配置错误在构建期被拒绝,不会留到线上以白屏或空地址的形式出现。代价是改配置必须重新构建,不能只重启进程。
#项目成果
- 业务收益:一套代码支撑 N 个独立代理商,各持域名、合约地址与分成比例;L2 的进场成本从「改一套代码」降为「填一份配置」
- 交付能力:L2 部署包内含配置 schema、原子部署脚本、
.env生成器与docker compose,顺序为 config → validate → deploy → env → up,配置错误在部署前即被 Zod 拒绝 - 资金透明:零售价在下单前拆为初始成本、分成池、固定成本三行,数据直读链上
retailPrices与costConfigs,不经过缓存 - 归因可靠:邀请归因走签名令牌 + 店铺归属校验 + 本地持久化,跨店铺令牌不会错记;历史裸地址链接保留为降级路径
- 账本正确性:24 张表 17 个事件唯一约束,索引器断点续传;15 个测试文件覆盖索引处理、代理商作用域与鉴权
- 交付规模:7 条前端路由、41 个组件、33 个后端接口、24 张数据表、19 个索引器模块、7 个合约文件 3,056 行
- 工程质量:会话鉴权与管理员白名单两级分离,登录走 SIWE 且 nonce 一次一用、限流 10 次/分钟;双语文案(zh / en);全部地址走环境变量配置,无硬编码
#项目链接
源码托管在公司私有 Git 仓库,受知识产权约束,不对外公开。替代说明材料为店铺与代理后台的部分页面截图,以及本页的技术方案与难点复盘。