前阵子老班长在群里问“当年宿舍阳台那张合照,谁手里还有原图”,一堆人在微信里翻了三天,最后找到的还是被压缩过、带着水印、人脸已经糊掉的“聊天记录版”。那天晚上,我打开挂在仓库里大半年的 m262 项目,决定把这个同学录网站认真做完。
m262 最早只是我开发机器上随手写的一个编号,后来变成了仓库名、云服务器名,再后来干脆就当了项目代号。项目本身很朴素:一个只服务我们班、不开放注册、没有广告、没有推荐算法的私密站点,用来替代“群聊式记忆”。为什么要单独立一个网站?因为目前班级的记忆散落得到处都是,没有一处是真正可控的。微信群方便,但聊天记录里的文件有有效期,群相册照片会被压缩,朋友圈的时间线不可检索。百度网盘这类工具有的只是容量和链接有效期问题。真要某天聚会想找十年前的照片,基本是一场灾难。
这篇文章不是来展示什么炫技架构,而是把 m262 从立项到上线、再到维护一年的完整过程写一遍,包括功能取舍、表结构设计、相册链路踩坑,以及最后让我改变想法的几件事。无论是正准备给班级做点东西的同学,还是想花少量成本做一个“小而真”独立项目的开发者,应该都能从里面找到可抄作业的部分。
1. 立项动机:班级群解决不了的事,m262 来补位
同学录这三个字,在老一辈眼里可能还是一本毕业纪念册。但在 2024 年,班级记忆的实际载体已经变成了微信聊天记录、朋友圈分组和网盘文件夹。问题是这些载体各有各的缺陷,它们擅长传播信息,却不擅长沉淀信息。
我把这件事拆成三个具体痛点:一是聊天记录只在个人设备侧留存,换手机、退群、清理缓存之后,历史内容就很难再翻出来;二是图片会在传播链里被反复压缩,原图会丢失,多年后打印纪念册时根本找不到可用的素材;三是没有权限模型,群里所有人默认能拿到全量信息,反而让真正在意隐私的人不愿意填写真实联系方式。
1.1 信息散落点:这大概是所有班级的常态
我先把我们班的情况梳理了一遍,发现“找一个人的联系方式”通常要走这样的路:先在几百人的大群里 @全体成员,然后等热心同学去翻旧通讯录 Excel,最后发给你的可能还是五年前的手机号,拨过去已经停机。数据分散在私聊记录、朋友圈分组、手机通讯录导出文件、聚会接龙表里,根本没有统一维护的入口。
这不算微信生态不好,而是同学录网站应该解决的本质问题:把“一次性在群里吼一声”变成“可检索、可更新、可授权”的长期档案。哪怕只是表结构很简单的班级通讯录,只要大家打开能看到最新联系方式,就已经赢过了大多数翻聊天记录的做法。
所以 m262 立项时,给自己定的首要任务就是:把老班长手里的 Excel、大家朋友圈晒过的聚会照、每年聚会上口头更新的“谁在哪个城市”,收拢到一个有权限、有结构、能长期访问的地方。它不是一个公共社交网络,读者不是陌生人,只是本班同学、家属,以及偶尔回来的老同学。从立项第一天起,核心问题就不是“怎么拉新”,而是“怎么让老同学愿意回来填资料、传照片”。
1.2 群聊不能给的确定性,网站可以做到
微信群擅长制造热闹,不擅长保管档案。具体说有三个问题:第一,消息流是按时间排列的,三个月前的关键通知会彻底沉底,没有分类、没有置顶层级;第二,群里文件有有效期,很多重要表格和照片过期后根本打不开;第三,群里的信息天然没有结构化,手机号、城市、工作都是随手打的字,机器读不懂,人也很难筛选。
网站的优势是结构。同学录网站把信息变成字段:姓名是姓名字段,城市是城市字段,联系方式是联系方式字段。一个人更新了手机号,所有人看到的就是最新值,而不是又往聊天记录里补一条“XX的手机号改了,大家记一下”。这种确定性是群聊永远给不了的,这也是我坚持用独立站点、而不是再开一个微信群去解决需求的根本原因。
1.3 站点定位:小规模和私密性是前提
有人说,几十个人的班级网站,有必要做完整项目吗?我的看法是,正因为小,才更要克制。一个全班只有 40 人的站点,注册流程搞得很重,动不动要手机验证码、绑定一堆东西,大家第一反应一定是“这也太麻烦了”。反过来,如果完全没有门槛,随便一个陌生人注册就能看全部通讯录,那全班人的手机号也就等于公开了。
所以 m262 的定位从一开始就很明确:小规模、封闭、私密、易回归。网站的每一条设计决策,都在“方便老同学回来”和“不让外人看到”之间找平衡。后面讲权限模型和防爬的时候,你会看到这种平衡是怎么落地到代码里的。
2. 功能收敛:只有四个模块,因为同学录的核心不是功能多
同学录网站最容易踩的坑,是做成了“校园大全”。我最初画原型时,脑子里冒出来的功能清单有三十多个:班级投票、二手闲置、班级金库、座位表还原、毕业纪念日提醒、同城活动、兼职信息、相亲角……如果真把这些全做完,项目大概会在开发第三周就烂尾,因为核心价值已经被淹没了。
2.1 四件套:成员档案、班级相册、共忆时间线、留声墙
我把功能收敛到四条线,每条线解决一个具体问题,不多也不少:
- 成员档案:解决“找得到人”的问题。包含姓名、昵称、班级职务、所在城市、职业、联系方式、近照、个人简介。
- 班级相册:解决“照片留得住”的问题。按时间和活动分目录上传,原图归档,支持时间线查看。
- 共忆时间线:解决“知道大家最近在做什么”的问题。聚会通知、同学近况、喜讯公告,以事件卡形式展示。
- 留声墙:解决“有话想说但不好意思私聊”的问题。给某位同学或者整个班级留言,按主题聚合。
这四个模块正好覆盖了同学录的经典使用路径:回到班级 → 找到同学 → 翻看回忆 → 说句话。用户每一次真实回访,几乎都能落在这四件事中的一件上。别小瞧这个路径,它决定了网站信息架构的骨架,后续所有页面、接口、数据表都在为这条路径服务。
2.2 那些被砍掉的功能,为什么砍
很多人会不理解为什么连班级投票都砍。我的理由是:这类功能天花板太低,开发成本却不低。投票要防刷票、要定时关闭、要消息提醒,结果一年可能只用一两次。闲置交易更是要上举报、审核和交易流程,完全不适合小群体。班级金库更是碰都不碰,涉及钱就会立刻从“情怀产品”变成“财务纠纷现场”,维护成本直接失控。
功能收敛还有一个隐性好处:每个模块可以做得更深。比如成员档案支持字段级隐私,相册支持批量调整拍摄时间,这些细节比多做一个无人使用的投票功能,更能让老同学感受到“这个网站是用心做出来的”。砍功能不是偷懒,是把资源集中在真正有价值的体验上。
2.3 访问路径设计:三步回到班级
上线后我把首页路径压缩成三步:输入邀请码或手机号 → 验证身份 → 直接进入班级主页。班级主页默认展示最近动态和最新照片,未登录状态能看见的只有一张封面和“离开母校 N 年”的标题。为啥这么抠?因为对于几年没登录的人,任何一步多余操作都会让他们直接放弃。三步以内必须进入核心页面,这是同学录网站交互设计的底线。
对应的,成员档案页的浏览逻辑是分层的:班级内同学默认看到姓名、城市和概况;同班且对方勾选了“可查看联系方式”,才能看到手机、邮箱、微信;管理员可以看到全量。这样既保护了每个人的隐私,又不影响正常社交。这个分层不是前端按钮的显隐,而是后端接口级别的过滤,后面数据模型部分会展开讲。
3. 选型与部署:小站点也配得上严谨的全栈结构
同学录网站的并发量可以忽略,峰值可能就是某天聚会时二三十个人同时刷相册。但代码质量和工程结构不能忽略。维护者通常只有我一个人,代码如果写得乱七八糟,三个月后我自己都看不下去,项目就会死在第二次迭代之前。所以 m262 虽然小,技术选型我没有糊弄。
3.1 前端选型:为什么是 Vue3 + TypeScript
前端用了 Vue3 + TypeScript + Vite。原因不外乎这几点:组件化开发适合相册、留言这类交互密集页面;TypeScript 对“成员信息”这种强字段的数据特别有用,姓名、性别、城市这些字段如果拼错,在编译期就会报警,而不是上线后才发现某个页面卡在大小写不一致上。Vite 的开发体验也适合单维护者,启动快、热更新稳,不用像老式项目那样改一个变量就刷新整个页面。
这里需要解释一下,为什么不用更“轻”的方案,比如直接拿静态 HTML 做一个通讯录表格。因为同学录网站虽然用户少,业务状态却不简单:上传图片、点赞留言、权限校验、按城市筛选,样样都需要服务端状态参与。静态页能把名单展示出来,但做不了“上传原图”“回复留言”“动态更新”这类的交互。预期用户少不等于功能简单,这是两回事,技术选型必须围绕后者来。
3.2 后端与数据库:NestJS + Prisma + MySQL
后端用了 NestJS。NestJS 的优势不在性能,而在结构约束:模块划分是强制的,Controller、Service、Module 这套分层天然逼迫我写出可维护代码。一个人做项目最怕“想到哪写到哪”,NestJS 至少让目录结构保持稳定,不会越写越乱。数据库是 MySQL 8,Prisma 做 ORM。Prisma 不是没有折腾,版本升级有时会带来割裂感,但它的类型安全帮我挡住了大量低级错误,比如把createdAt写成createAt,这类错误在传统 ORM 里要等运行时才能发现。
不用 Express 加裸 SQL?因为裸 SQL 在嵌套查询多、关系字段复杂的时候非常容易出错,而且大量 SQL 堆在代码里极难维护。对于同学录这种生命周期可能长达十年、持续迭代的小项目,开发效率比微秒级的性能差异重要得多。真正的性能瓶颈点根本不在这,而在图片体积和数据库查询次数,后面相册链路会单独说。
3.3 图片文件的正确位置:对象存储,不是服务器本地磁盘
班级相册是 m262 最核心的模块,图片处理也是最容易忽略的工程点。一开始我也想过,就直接把上传图片存到服务器本地的 uploads 目录,后来放弃了。本地磁盘有三个绕不开的坑:一是扩容麻烦,照片只会越来越多;二是备份时要连文件带数据库一起打包,时间越长越痛苦;三是没法接CDN分发加速,偏远地区同学打开会慢。最终方案是对象存储放原图和生成后的缩略图,MySQL 里只保存文件元数据、URL 和关联信息。
对象存储的好处是容量弹性、流量可控,还可以设置生命周期规则,自动清理“回收站”里的旧对象。对同学录这种图片只增不减的场景,原图归档用对象存储明显比本地磁盘可靠。缩略图由后端在上传时用 Sharp 生成,列表页只加载缩略图,点击大图时才请求原图。这类细节直接决定了老同学在非 Wi-Fi 环境下翻相册会不会卡。
3.4 部署架构:一台云服务器加 Docker Compose 就够了
部署我没引入 Kubernetes,也让开云原生那一套。一台普通云服务器装 Docker 和 Docker Compose,里面跑四个容器:前端 Nginx、后端 Node.js、MySQL、图片处理任务。MySQL 数据目录挂载到宿主机持久卷,对象存储只存图片,后端密钥放到单独的环境文件。整套架构在入门级云主机上就能跑,月成本替到几十元以内,贴合班级项目“低维护成本”的定位。
当时写的 Compose 配置,去掉敏感值后大致长这样:
version: "3.8" services: nginx: image: nginx:1.25-alpine ports: - "80:80" - "443:443" volumes: - ./nginx.conf:/etc/nginx/conf.d/default.conf - /etc/letsencrypt:/etc/letsencrypt:ro depends_on: - api api: build: ./backend environment: - NODE_ENV=production - DATABASE_URL=mysql://m262:m262pass@mysql:3306/m262 - OSS_BUCKET=m262-photos depends_on: - mysql mysql: image: mysql:8.0 volumes: - ./mysql_data:/var/lib/mysql environment: - MYSQL_DATABASE=m262 - MYSQL_USER=m262 - MYSQL_PASSWORD=m262pass这里想提一句:密钥一定要和 Compose 文件分开管理,放进单独的环境文件并加入.gitignore。很多小型项目最后翻车,就是配置文件连密钥一起推到仓库里,整站接口裸奔,数据跟着遭殃。
4. 数据模型与隐私:封闭站点最不能省的两件事
表结构是同学录网站最容易暴露不专业感的地方。表面看只有几十个用户,但这几十个人之间的关系、文件、权限、留言一样都不能少。设计不合理,后面想加一个“按城市筛选”都可能要改三四张表。所以数据模型阶段值得慢下来,把字段和关系想清楚。
4.1 成员表与身份:一小撮人的关系建模
成员模型核心是一张students表,基础字段是姓名、昵称、性别、所在城市、职业、简介、头像,再加班级外键和状态字段。我用一张简化的 DDL 来说明:
CREATE TABLE students ( id INT AUTO_INCREMENT PRIMARY KEY, class_id INT NOT NULL, name VARCHAR(50) NOT NULL, nickname VARCHAR(50), city VARCHAR(50), bio TEXT, privacy_map JSON, join_status VARCHAR(20) DEFAULT 'inactive', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP );真正有讲究的是join_status和privacy_map。join_status区分“未激活”“已激活”“管理员”“荣誉成员”,避免把“只是进来看过一眼”和“正式填写过资料”混为一谈。privacy_map是一个 JSON 字段,记录联系方式、城市、生日等每个字段的可见性级别。不拆成单独表的理由是:字段数量少且固定,拆表反而增加查询复杂度;JSON 字段能直接表达“哪个字段谁可见”,语义清晰,维护方便。
班级信息单独建一张classes表,只存班级名称、毕业年份、封面图、班级语录。同学录项目往往一个站点对应多个班级,所以班级表作为顶层组织单元,所有成员、相册、留言都挂在它下面。这样未来如果想扩展到隔壁班,加一个班级实例就能复用整套逻辑,不需要重写任何核心代码。
4.2 相册与照片:原始文件与业务数据分离
相册模块拆成albums和photos两张表。albums保存相册标题、描述、封面、拍摄时间区间;photos保存单张照片的标题、拍摄时间、拍摄地点、上传者、可见范围、原图地址、缩略图地址。要拆开的原因很直接:一次聚会可能上传两百多张照片,但只有一个相册。如果每张照片都重复存一遍班级信息和相册描述,查询和更新都会变成灾难。
文件地址我只存相对路径,不存完整 URL。将来换对象存储服务商,只需改一个配置,不用逐条改数据库。相册表里我还留了一个“叙事字段”,用来描述这组照片背后的故事。比如“那次运动会我们拿了第二名,赛后全班在操场边吃冰棍”。老同学翻相册时,最先看到的应该是这句话,而不是一堆照片格子。数据模型上支持这个字段,才算是真正理解了同学录产品——它保存的是记忆,不是图片文件本身。
4.3 隐私权限:字段级可见性才是通讯录的安全底线
隐私设计上,我不能接受“登录后全看”这种一刀切。联系方式是敏感字段,要支持三种可见性:全班可见、好友可见、仅自己和管理员可见。后端查询时统一过滤,而不是把全量数据发给前端再让前端去隐藏——前端隐藏等于没隐藏,任何人打开控制台都能看到数据。
privacy_map的价值在于,它让老同学愿意填真实信息。如果大家心里清楚“我填的手机号全班都能看到”,很多人会填假号。只有给了“仅好友可见”“仅管理员可见”的选项,填写率才能真正上去。这是产品逻辑倒逼数据质量的典型做法。实现上,后端做了一层字段级序列化:根据当前用户身份,动态决定返回哪些字段,缺失的字段甚至不会出现在 JSON 里。
4.4 邀请码与身份验证:封闭站点的入口控制
m262 不开放自由注册,入口就是邀请码。逻辑很简单:管理员生成一批一次性码,发给各班联系人,码被用一次后立即失效。生成时我从字符集里剔除了容易混淆的 0、O、1、l、I,剩余 32 个字符,生成 8 位码,猜测成功的概率大约是 32 的 8 次方分之一,也就是万亿级别的空间,对班级站点来说已经完全够用。
邀请码过期时间设为 72 小时。为什么不永久有效?因为永久码一旦被截图发出去,等于给全网留了后门。72 小时后同学没用到,找管理员重新要一个就行,成本很低。在邀请码之上,还支持管理员批量导入“老班长那份 40 行 Excel”:导入时只激活姓名和班级,联系方式留空,由同学自己后续补充。这比一次性把所有旧数据灌进去更安全,也更符合隐私直觉。
5. 从相册链路复盘:我踩过的真实技术坑
任何独立项目上线后都会遇到一串 bug。m262 最值得记录的,不是某个框架的 API 怎么用,而是相册模块从上传到展示这条链路上踩过的几个坑。这些坑特别典型,做内容站点的同学大概率会遇到,分享出来给大家做个参考。
5.1 老照片没有拍摄时间:EXIF 缺失怎么办
相册时间线是同学录网站的核心体验,但老照片恰恰最缺时间信息。手机新拍的照片有 EXIF 时间,十年前那批用卡片机拍的、甚至从纸质照片扫描出来的旧图,基本没有任何元数据。如果直接拿上传时间当拍摄时间,排序会一团糟:2018 年扫描上传的照片全堆在 2018 年,可照片本身是 2008 年拍的。
处理办法分两步。第一步,如果文件名里带日期模式,比如20080612_IMG_2314.jpg,后端在导入时自动解析文件名,提取日期作为默认拍摄时间。第二步,提供一个“批量时间偏移”工具,管理员选中整个相册,统一把拍摄时间往前调几年。这个功能不大,但直接决定了相册时间线的可信度。没有它,整个照片模块的叙事能力都会大打折扣。
5.2 上传 10MB 原图的尴尬:前端压缩与后端缩略图
第一批同学上传照片时,我发现移动端传原图非常吃力。很多人手机相册一张图动辄 8-10MB,直接上传既慢又费流量。于是做了三件事:前端选图后用 canvas 做一次预览压缩,压缩质量控制在 0.8,最长边限制到 2500px;后端拿到图片后用 Sharp 生成三种缩略图(1280px、640px、320px);列表页全部使用 640px 缩略图,详情页点开大图再加载原图。
这里有几个实际数字:一张 2500px 的 JPEG,质量 0.8,体积大概在 800KB 到 1.2MB,上传时不容易卡死;原图保留一份,是为了将来打印纪念册或办展览用。如果只是浏览列表,640px 缩略图大概 80-150KB,即便是 4G 网络也能秒开。用户感知上的“流畅”,主要就是靠这套缩略图体系撑起来的,而不是靠什么高端性能优化。
5.3 被搜索引擎收录了成员页:noindex 晚一步的代价
m262 是封闭站点,但上线初期我忘了做防爬设置,结果两周后被搜索引擎收录了几张成员公开页。搜索引擎爬虫在未登录状态下看到的其实是“请登录”,但有些页面结构里带了可读的姓名和城市信息,还是被建了索引。虽然后来补上了 robots 协议和 meta robots noindex,已经被收录的几页还是过了一阵子才从索引里消失。
这件事给我的教训是:封闭站点的防爬设置必须在上线前完成,不能等出事了再补救。我在 Nginx 层对未登录请求统一 302 到登录页,在后端接口层对每个敏感查询做会话校验,同时在页面<head>里统一输出<meta name="robots" content="noindex, nofollow">。三层防御就是一道立体保险,哪怕某一层配置漏了,另外两层也能挡住大部分泄露路径。
5.4 垃圾文件清理:对象存储生命周期规则
开发阶段我反复往测试相册里传图,对象存储里堆了一堆垃圾对象;正式上线后又不断有人在“回收站”删除照片。最开始手动用脚本批量清理,发现不可持续,于是直接在对象存储侧配了生命周期规则:前缀为tmp/的对象超过 7 天自动删除,回收站目录里的对象保留 30 天后自动删除。规则看着简单,但让我彻底告别了“垃圾文件占满空间”的运维噩梦。
这背后其实是一条通用的经验:数据管理必须靠机制,不能靠记忆。手动清理一两次有效,时间一长必然漏掉。把规则写进基础设施配置,才是真正稳定、可持续的运维思路。
6. 上线后的产品思考:同学录网站真正在运营什么
网站上线快一年,真实使用情况和我的最初预期完全不同。最意外的一条是:同学录网站最重要的功能不是搜索,而是“让我知道你还记得我”。很多同学回访的高频场景,是有人上传新照片后,自动收到邮件通知“你的同学上传了新照片”。这让我把通知机制的地位,提到了比搜索功能更高一档。
6.1 用户回访靠的不是功能,是事件
没有外部拉动的 Web 应用,很难让人主动想起。同学录尤其如此——平时没人每天打开班级网站,但一旦有人传了聚会照片、有人更新了城市动态,回访率就会立刻起来。所以我把“事件驱动提醒”当增长核心:新增照片、新增留言、有同学更新档案时,给关联用户发送邮件通知。这不是营销推送,更像家里来消息了一样。效果比任何首页推荐位都好。
实现上就是两张小表:notification_logs记录每个人已经看过哪些通知,避免重复提醒;digest_prefs让用户自选“全班通知”“仅好友通知”“不通知”。全套代码不到一百行,但对激活率的影响是决定性的。这一点对任何低频内容型产品都适用:内容本身有价值还不够,必须有一条主动触达用户的“消息线”。
6.2 数据质量比功能数量重要得多
上线初期我花了很多时间写相册、留言这类花哨功能,结果真正最有用的反而是“成员档案更新提醒”。同学们的职业、城市、联系方式变化很快,如果网站里存的还是毕业时的信息,价值就会随时间下降。于是我在“共忆时间线”里增加了一种更新事件:当成员修改联系方式、城市或职业时,动态流里生成一条轻量信息,让其他人知道“XX 最近搬去广州了”。当然,是否公开这条动态由用户自己决定。
这就回到了最开始说过的那句话:数据质量驱动回访,而不是功能堆叠驱动回访。同学录网站如果只是静态通讯录,半年后一定没人打开;但如果它持续反映“大家真实的近况”,它就成了班级里一个活跃的坐标站。这个发现直接改变了我后续迭代的优先级排序。
6.3 把网站当“十年项目”来维护
如果要给这个项目一个注脚,我会说同学录网站是一个“十年项目”。它不是那种上线两个月冲一波流量就放弃的产物,而是要陪着这群人走很久的数字老家。所以在代码注释、部署文档、数据备份上下的功夫,比写新功能还多。备份策略最终是每天凌晨自动导出 MySQL 到对象存储,保留 30 天版本;图片本身在对象存储里有等同异地副本的冗余,我只需要定期验证原图可访问性。这些都是平淡无奇的运维操作,但正是它们决定了十年后同学翻开相册时,那些照片还在不在。
做 m262 最大的收获不在于掌握了某个新框架,而在于想清楚了一件事:什么样的产品值得做一个独立的网站?不是流量大的,不是商业模式清晰的,而是那群人真的需要一处能安放共同记忆的私密空间。数据可以迁移,框架可以换,但“老同学回来看一眼,还能认出自己当年的位置”这件事,值得认真对待。如果你也在维护类似的小型项目,我最重要的建议只有一条:从第一天起就认真做数据备份和权限设计,这两个模块决定项目能走多远。