我一直在想,纸质时代的钥匙串为什么让人舍不得扔。上面每一把钥匙都对应一段记忆:家门、工位、旧宿舍,甚至是一把小锁的备用钥匙。后来手机把物理钥匙收纳进了门禁卡、二维码里,但那种“一串小物件挂在腰间,叮叮当当”的亲密感反而没了。eChain(A Fun Digital Key Chain)要做的,就是把这种亲密感搬到线上:它不是一个普通数字徽章墙,而是一条能挂在个人主页上、随身携带的数字钥匙链。每一枚钥匙扣代表一次真实完成的小成就、一次线下活动参与、一个值得记住的时刻。下面我会从产品设计、数据模型到 MVP 实现完整拆一遍这个项目,适合正在做数字收藏、游戏化激励、社区成长体系的产品经理和开发同学参考。
1. 项目形态拆解:eChain到底是个什么产品
1.1 一个看得见、摸得着的“数字钥匙链”
传统产品里的成就系统,绝大多数做完就沉底了。用户完成打卡、获得一枚徽章,然后在个人中心某个角落里看到一个灰扑扑的图标,再也没有然后。eChain 想解决两件事:一是让“完成”这件事变成“拥有”的感觉,二是让这种拥有感具备社交传播力。
我们参考了实体钥匙链的物理结构。一枚钥匙扣有环、有链、有挂孔,不同材质的钥匙扣摸上去触感完全不一样:塑料的轻飘飘,金属的沉甸甸,皮质的有温度。数字世界里没有触觉,但我们可以用视觉和交互来模拟:普通钥匙扣是哑光灰边,史诗钥匙扣有流动光晕,传说钥匙扣带金属拉丝纹理。用户把一枚新钥匙扣挂到自己的链上时,会有一声响亮的“咔哒”反馈,链条会轻轻摆动一下。这个细节花不了多少开发成本,但用户就是会因为这一下“咔哒”多玩十分钟。
和勋章墙相比,钥匙链更像一个“空间叙事”。勋章墙是后台数据的汇总展示,钥匙链则是用户主动排列、筛选、置顶的个人物件集合。有人会把最珍贵的传说钥匙扣挂到第一格,有人会把第一次参加活动的纪念钥匙扣长期置顶。这个选择过程本身就是产品价值的体现。
1.2 为什么叫Chain而不是Badge
项目起名阶段我们列了十几个备选:DigiBadge、AchievementGo、KeyRing+……最后定了 eChain。很多人看到 Chain 会往“链”的方向联想,但我们更想表达的是钥匙串上那根真实的金属链条,同时它也暗示下一枚钥匙扣会不断接上来,形成一个连续的收集链条。
这个命名决定了产品基调。如果叫 Badge,大家默认这是一个成就系统,设计师会不自觉地在“完成度”上堆功能;改成 Key Chain 之后,所有设计语言都围绕“挂上去、戴出去、秀出来”展开。后来的实际效果也验证了这一点:用户不太会去跟朋友说“我拿到了一个徽章”,但会很自然地说“我钥匙串上加了一枚超酷的钥匙扣”。
另外,我们在设计初期就明确不做资产化、不做二级交易。钥匙扣可以转赠,但转赠是一次性的,转出后原链上不再保留。它更像朋友之间送实体小挂件,而不是可炒作的数字商品。这个限制让整个项目避开了大量复杂的合规问题,也让用户更关注收集本身的乐趣。
1.3 目标用户和三类典型玩法
eChain 的用户定位是 18-30 岁、喜欢收集和展示自我的年轻人,同时为活动主办方和社群运营者提供了一个低门槛的用户激励工具。项目上线测试期,我们验证了三类典型玩法:
活动型玩法是最直接的。主办方在活动页配置一个限定钥匙扣,用户到场签到或线上参与后自动领取。比如城市漫步活动,每走过一个地标就能获得对应的建筑造型钥匙扣;技术社区办 Meetup,参会者能领到当期限定款。这类玩法的特点是发放逻辑简单,适合验证冷启动。
行为型玩法用来做习惯养成。用户连续阅读七天,获得一枚书本造型的钥匙扣;连续运动十天,获得一对哑铃钥匙扣。和打卡日历相比,钥匙扣给了行为一个“可积累的实体感”。日历中断了会让人沮丧,钥匙扣不会消失,已经拿到的那一枚还挂在链上,反而会激励用户继续补完下一个七天。
社交型玩法是我们后来验证到最有潜力的方向。好友之间可以互赠钥匙扣,也可以发起“合体”:两个人各自把一枚同系列钥匙扣放一起,合成一个双环组合链。这个玩法在测试群里被玩出了花,有人专门为了合体钥匙扣拉上朋友一起报名活动。数字物品一旦有了赠送和组合的能力,传播动力会明显上升。
2. 功能架构与数据模型:让每枚钥匙扣都有据可循
2.1 核心数据表设计与字段说明
eChain 的数据模型从第一版开始就坚持关系型建模。核心实体有四类:用户、钥匙扣模板、颁发记录、用户持有的钥匙扣实例。很多做这类产品的人一开始会把“模板”和“实例”混在一张表里,后面一旦出现同一款钥匙扣可以重复获得的情况,就不得不开始加各种奇怪的冗余字段。
钥匙扣模板表存的是“这一款钥匙扣长什么样”:名称、描述、稀有度、视觉模板参数、所属系列、发行总量限制。颁发记录表存的是“谁在什么时间因为什么事件被授予了这枚钥匙扣”,它是一张不可变的事件流水。用户持有表才是真正挂在用户链上的实例,拥有独立序号、获取时间、展示顺序、是否置顶、是否隐藏。
用 Prisma 表达核心关系大概是这样的:
enum Rarity { COMMON UNCOMMON RARE EPIC LEGENDARY } model Charm { id String @id @default(cuid()) code String @unique name String description String? rarity Rarity @default(COMMON) imageUrl String totalSupply Int? issuedCount Int @default(0) seriesId String? series Series? @relation(fields: [seriesId], references: [id]) grants Grant[] items KeychainItem[] } model Grant { id String @id @default(cuid()) charmId String charm Charm @relation(fields: [charmId], references: [id]) userId String note String? createdAt DateTime @default(now()) items KeychainItem[] } model KeychainItem { id String @id @default(cuid()) ownerId String charmId String grantId String serialNo Int obtainedAt DateTime @default(now()) displayOrder Int @default(0) pinned Boolean @default(false) hidden Boolean @default(false) charm Charm @relation(fields: [charmId], references: [id]) grant Grant @relation(fields: [grantId], references: [id]) }这里有一个关键设计:为什么要单独建 Grant,而不是在 KeychainItem 里直接记录来源?因为同一个用户可能在两个不同活动中获得同一款纪念钥匙扣(比如某系列在两个城市各发一次)。如果只在 item 表里存来源字段,就无法表达“同一款被获得过两次”。Grant 作为颁发事件,天然支持一对多:一次颁发事件产生一条 Grant,每条 Grant 在用户钱包里生成一枚 KeychainItem。这样后续即使要做“重复获得自动合成升级款”之类的功能,数据基础也完全够用。
唯一约束也需要强调。Charm 表的 code 字段要设唯一索引,这个 code 是给活动运营配置和系统内部引用用的,不能用自增 id 做业务关联。KeychainItem 表建议加 (ownerId, charmId, serialNo) 的联合唯一索引,防止同一枚限量钥匙扣被异常重复发放时出现脏数据。
2.2 稀有度、套组和成长感怎么设计
稀有度是整个收藏动力的第一引擎。我们参考了常见收集游戏的掉落分布,初始配置了五个等级:普通、罕见、稀有、史诗、传说。普通没有边框特效,罕见加了细描边,稀有开始有渐变背景,史诗有流动光晕,传说则使用动态金属拉丝纹理加特殊挂环造型。
| 稀有度 | 获得权重 | 视觉表现 | 对应场景 |
|---|---|---|---|
| 普通 | 50% | 灰色哑光边框 | 日常打卡、活动基础参与 |
| 罕见 | 25% | 银灰色描边 | 持续七天行为 |
| 稀有 | 15% | 渐变底色 | 主题活动限量发放 |
| 史诗 | 7% | 光晕流动特效 | 大型线下活动限定 |
| 传说 | 3% | 金属拉丝+动态光效 | 周年纪念、特殊贡献 |
权重分布不直接告诉用户,靠社区自己摸索传播,反而能形成话题。上线后有人开始统计“第几枚必出稀有”,我们内部知道没有保底机制,但这份讨论热度确实帮助了自然传播。
成长感则通过“链条本身”的变化来体现。用户获得第一枚钥匙扣时,链上只有一个圆环;第十枚时,所有圆环会合并成一段更粗的链条;第二十五枚时,链条上会出现一个身份铭牌,显示用户的收集等级。这种变化不打断收集体验,但会持续给用户一个“我的链正在变长变粗”的直观反馈。
套组机制是后期留存的关键。同一个系列集齐三枚、五枚、九枚时,用户可以主动触发“串链”操作,把多枚钥匙扣串成一个整体,解锁系列专属的背景挂板。系列背景挂板会在个人主页展示时占据更大的视觉空间,相当于给用户的收集行为颁发了一个可展示的成就。
2.3 领取到分享的主链路拆解
整个产品最核心的一条链路是:发现活动、完成条件、领取钥匙扣、挂上链、生成分享卡片、好友扫码参与。六步里每一步都可能流失用户,我们每一步都做了针对性设计。
发现环节依赖活动落地页和二维码,拿到活动页的用户会看到这枚钥匙扣的“未点亮”状态。未点亮状态比已获得状态更重要,它展示的是钥匙扣的剪影轮廓和稀有度标签,让用户提前产生“我想要”的念头。完成条件后,领取按钮会从灰色变为彩色,点击时立刻播放挂环的“咔哒”动画,同时弹出一张全屏卡片,卡片上带用户的专属编号。
挂上链之后,默认出现在链尾,但我们允许用户长按拖拽调整顺序。这个拖拽操作在移动端看起来是个小功能,实际开发时却花了不少功夫做手势判定和防误触。后来我们干脆把“第一格”设成了一个重要展示位:用户把任意钥匙扣拖到第一格时,会触发一次特殊的链条特效,很多用户就是为了这个特效才反复调整排序。
分享环节直接决定传播系数。分享出去的不是网页链接,而是一张竖版长图:上方是钥匙扣大图,中间是用户昵称和拿到的时间,下方是一句用户自定义的寄语,底部附带活动二维码。长图的视觉效果必须足够好,用户才愿意把它发到朋友圈和群里。关于分享图的生成,我在第三章会详细讲实现方案。
3. 实操实现:从0到1跑通eChain的MVP
3.1 技术选型:为什么是Next.js+Prisma+PostgreSQL
第一版 MVP 没有引入任何微服务,也没有做前后端分离,直接用 Next.js 的 API Routes 包了服务端接口。这个选择在当时承担了不少质疑,有人觉得 Next.js 只适合做展示型站点,不适合做业务系统。实际上对于 eChain 这种以页面渲染为主、含大量社交分享场景的项目,一体化的 Next.js 反而最顺手。
选 PostgreSQL 而不是 MongoDB,理由可以总结成三点:第一,Charm、Grant、KeychainItem 之间有明确的关系约束,用外键和唯一索引能直接在数据库层拦住大量脏数据;第二,限量钥匙扣的发放需要行级锁和原子更新,这是关系型数据库的舒适区,文档数据库做这类操作要绕很多弯;第三,Prisma 提供了类型安全的 client 和 migration 流程,表结构变更时不容易出错。
Redis 在这个项目里不承担核心存储职责,只用来做两类事:热点数据的短期缓存和领取接口的限流。用户在拿完钥匙扣后打开个人主页这个动作,是典型的读多写少场景,我们会把前 N 条用户钥匙扣列表缓存一分钟,避免每次都查库。但任何涉及发放、转赠、排序的写操作,一律绕过缓存直写数据库。
3.2 动态生成“钥匙扣”卡片并导出分享图
分享图是整个传播闭环里最不能省的功能。市面上很多项目图省事,直接把前端页面截图当成分享图,结果不仅尺寸模糊,还经常因为没有等待图片加载而缺元素。eChain 的做法是用服务端渲染 SVG,再转成 PNG 输出。SVG 模板的好处是稀有度特效、边框颜色、文字排版都可以通过参数控制,不需要为每一款钥匙扣单独做设计稿。
核心渲染流程大概是这样的:
import { Resvg } from '@resvg/resvg-js'; interface CharmRenderParams { name: string; serialNo: number; rarity: string; seriesName?: string; customNote?: string; } function renderCharmCard(params: CharmRenderParams): string { const frameColor = getRarityFrameColor(params.rarity); return ` <svg width="640" height="800" xmlns="http://www.w3.org/2000/svg"> <defs> <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1"> <stop offset="0%" stop-color="${frameColor.a}"/> <stop offset="100%" stop-color="${frameColor.b}"/> </linearGradient> </defs> <rect width="640" height="800" rx="24" fill="#0F1115"/> <rect x="24" y="24" width="592" height="752" rx="16" fill="none" stroke="url(#bg)" stroke-width="4"/> <text x="320" y="360" text-anchor="middle" font-family="sans-serif" font-size="64" fill="#FFFFFF">${params.name}</text> <text x="320" y="440" text-anchor="middle" font-family="monospace" font-size="28" fill="#AAAAAA">No.${params.serialNo}</text> <text x="320" y="700" text-anchor="middle" font-family="sans-serif" font-size="32" fill="#CCCCCC">${params.customNote}</text> </svg> `; } async function generateShareImage(params: CharmRenderParams) { const svg = renderCharmCard(params); const png = new Resvg(svg, { fitTo: { mode: 'width', value: 640 }, font: { loadSystemFonts: false } }).render().asPng(); return png; }这里的细节是字体处理。服务端环境往往没有中文字体,如果直接用系统字体渲染中文会变成方块。我们前期踩过这个坑,后来把一张开源中文字体文件放到项目静态目录里,加载进 Resvg 的 font 配置才解决。渲染是很消耗 CPU 的操作,所以绝对不能每次请求都实时生成。我们的策略是做成懒生成加缓存:第一次请求时生成 PNG 并上传到对象存储,后续直接返回图片 URL,缓存 key 用 userId + charmId + serialNo 拼接。
前端拿到图片 URL 后也不需要做任何重绘,直接把这张 PNG 作为分享图展示给用户。用户长按图片就能保存,在微信里直接发图也比发链接轻很多。
3.3 提升分享率的四个交互细节
分享卡片做好了,还得让用户愿意分享。我们迭代了四个版本,最终发现下面四个细节对分享率影响最大。
第一个细节是分享文案里的“序号”必须清晰。用户看到“我在 eChain 收集的第 37 枚钥匙扣”,比单纯看到“我获得了一枚钥匙扣”更有分享冲动。这里的 37 是用户在全局收集的累计序号,不是某款钥匙扣的编号,它表达的是“我已经玩了很久了”。
第二个细节是自定义寄语的输入时机。我们原本把它放在领取后的分享卡片生成页,结果填写率很低,因为用户正在兴奋地想看卡片效果,不愿意停下来打字。后来改成默认引用活动主题作为寄语,用户想改可以在生成后的编辑页改,填写率反而上升了。先用默认值把分享动作做轻,再提供精细化编辑入口。
第三个细节是二维码离分享图底部不能太近,也不能太远。太近容易被其他 App 的图片裁剪功能切掉,太远又会降低扫码转化。我们测试下来底部留白 80 像素左右最稳妥,二维码尺寸不小于 120×120 像素,扫码成功率才够高。
第四个细节是“被领取通知”。当好友通过分享图扫码领取了同系列钥匙扣,原分享者会收到一条站内通知加一条推送。这个通知创造了一种“我带动了别人”的成就感,也是触发二次分享的核心动力。很多用户第一次分享没什么反馈,但收到这条通知后会马上把卡片再发一遍。
4. 实战避坑:常见问题与排查记录
4.1 领取成功但钥匙扣消失的状态一致性问题
上线第一周,我们收到一个反馈:用户明明看到领取成功的动画,但回到个人主页钥匙扣不见了。排查后发现问题出在领取接口的写入顺序上。初版代码先把 Grant 插入数据库,再创建 KeychainItem。如果第二步因为序列号冲突等原因失败,Grant 已经存在,但用户链上没有任何新增。前端只接住了“领取成功”的返回,没有校验实际生成的 item 是否落库。
修复方案很直接,把两步写入放进同一个数据库事务里,要么全部成功,要么全部回滚。同时接口的返回结构改成显式返回 itemId,前端拿到这个 id 才播放领取成功动画。这样一来,动画一旦播放,数据一定是真实存在的。
这里还有一个隐藏问题:用户连续快速点击领取按钮,可能会发出多个并发请求。我们在领接口做了用户维度的互斥锁,同一用户同一秒只允许一个领取请求进入处理逻辑,防止重复生成两条一模一样的钥匙扣。
4.2 并发抢限量钥匙扣导致超发
限量版钥匙扣是社区最关注的活动类型。比如某次活动只发 1000 枚,用户同时涌入领取,初版代码是先查出当前 issuedCount,判断小于 totalSupply 再插入数据。并发场景下两个请求读到同一个 issuedCount,就会突破限量,这是经典的超发问题。
后来改成单条 SQL 原子更新:
UPDATE "Charm" SET "issuedCount" = "issuedCount" + 1 WHERE id = $1 AND ("totalSupply" IS NULL OR "issuedCount" < "totalSupply") RETURNING "issuedCount";这条语句利用数据库的行锁保证并发安全:只有 UPDATE 影响行数大于 0,才允许继续创建 KeychainItem,否则直接返回“已领完”。最开始我们也考虑过用 Redis 做一个原子扣减,通过 Lua 脚本控制数量,但 Redis 和数据库之间的事务一致性很难保证,一旦 Redis 扣减成功而数据库写入失败,限量数据就乱了。用数据库自带的行锁虽然压力大一点,但在 MVP 规模下是更稳妥的方案。
还有一个体验层的细节:用户看到已领完时往往很失望,但如果他其实已经获得了领取资格,只是最后写入时库存没了,界面会显示“资格已锁定,等待补货”,让用户觉得自己的参与没有白费。这个提示需要判断用户是否已经完成领取条件,而不是看到发放失败就一律提示“已售罄”。
4.3 图片加载慢和多端不同步
分享图一旦生成,就涉及到图片存储的访问速度。第一版我们直接把 PNG 原图放在对象存储的公共桶里,一张图平均 800KB。朋友圈打开倒是不慢,但个人主页里同时加载几十枚钥匙扣大图,流量和延迟都扛不住。后来的解决方法是动态生成多个尺寸的缩略图:列表页用 160×200,详情页用 480×600,分享场景才加载 640×800 原图,并且给对象存储配了 CDN。列表页整体体积从原本可能几十 MB 降到了几 MB,加载速度肉眼可见地提升。
多端不同步的问题则主要发生在用户用手机领取、平板查看的场景。我们最初把用户链的排序数据缓存在本地,导致一台设备上拖拽排序后,另一台设备完全不变。排查后确认了原则:设备端永远只做展示缓存,排序、置顶、隐藏这些操作都以服务端为准。每个用户的钥匙扣列表在后端维护一个 version 字段,前端拉取时带上 version,如果版本落后就重新获取全量列表,而不是做复杂的增量同步。这个策略在只有个人主页展示的情况下已经够用。
如果后续要支持更复杂的多端实时同步,再引入 WebSocket 推送也来得及。MVP 阶段千万别为不存在的复杂度提前设计。
5. eChain给我留下的三个设计教训
做这个项目最大的体会是:数字收藏品的价值来自用户的付出,而不是平台义无反顾的发放。我们的测试群里有人特意把第一次参加黑客松拿到的普通钥匙扣置顶,哪怕后来他集齐了一整套传说款。因为那枚普通钥匙扣承载的是“第一次创造出一件完整作品”的记忆,这种情感连接是任何算法都算不出来的。
第二个教训是分享体验要先于收藏体验打磨。产品做到第二版时我们还在优化个人主页的动效,结果用户增长数据毫无起色。后来把精力集中到分享图的尺寸、文案、二维码位置这些“看起来很小”的事情上,分享率立刻涨了三分之一。在社交传播类产品里,让用户愿意把一张图发出去,比让用户在自己页面里多停留两分钟重要得多。
第三个教训是权限和审核要提前留位。早期版本里用户自定义寄语没有做内容过滤,结果有人在分享卡片上写了不合适的话,被截图传播后处理了很久。后来加上了文本审核接口和人工举报入口,这个问题才算解决。所有允许用户输入文本的地方,上线第一天就要有过滤机制,不能等出问题再补。
最后再分享一个小技巧:领取成功后的“咔哒”音效,我们最开始用的是系统默认提示音,效果非常廉价。后来自己录了一段钥匙扣碰撞的声音,经过降噪和加速处理后作为应用内音效,用户反馈明显变好。数字产品的质感往往就藏在这些细节里,它不会被写进需求文档,但用户一定能感知到。