ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

记一次使用七牛云 + Koa + Mongoose 搭建音乐 App 后台:从零到一,顺便把上传整明白

记一次使用七牛云 + Koa + Mongoose 搭建音乐 App 后台:从零到一,顺便把上传整明白

记一次使用七牛云 + Koa + Mongoose 搭建音乐 App 后台:从零到一,顺便把上传整明白

一个音乐 App 的后台,核心其实就三件事:存歌、找人、管权限。但当你真正动手时,会发现光是把一首 MP3 传上七牛云,就能折腾掉一个下午。

引子:为什么是“七牛云 + Koa + Mongoose”?

我一开始的想法很朴素:写个音乐 App 后台,能上传歌曲、能存歌单、能注册登录就行。

于是我开始选型:

  • Koa:轻,中间件洋葱模型写起来舒服,不像 Express 那么“重”。
  • Mongoose:操作 MongoDB 的 ODM,定义 Schema 像在写 TypeScript 接口,关联查询用 populate 一把梭。
  • 七牛云:对象存储,专门干文件上传的活儿,免费额度够个人项目折腾。

听起来很合理对吧?但接下来的过程告诉我:“合理”和“顺利”是两码事。

第一章:项目骨架——先把架子搭起来

一个 Koa + Mongoose 的后台项目,目录结构大概长这样:

text

src/ ├── config/ # 配置(数据库、七牛密钥、JWT密钥) ├── models/ # Mongoose Schema │ ├── User.js │ ├── Song.js │ └── Playlist.js ├── routes/ # 路由 │ ├── auth.js │ ├── song.js │ └── playlist.js ├── middleware/ # 中间件(JWT校验、错误处理) └── app.js # 入口

配置集中管理是第一步,别在代码里到处写 process.env,不然过两周你自己都不知道密码配在哪了。

js

// config/index.js const config = { port: process.env.PORT || 3000, mongo: { uri: process.env.MONGO_URI || 'mongodb://localhost:27017/music_app', }, qiniu: { accessKey: process.env.QINIU_ACCESS_KEY, secretKey: process.env.QINIU_SECRET_KEY, bucket: process.env.QINIU_BUCKET, domain: process.env.QINIU_DOMAIN, // 七牛空间绑定的域名 }, jwt: { secret: process.env.JWT_SECRET || 'dev-secret-change-me', expiresIn: '7d', }, };

第二章:Mongoose 建模——歌曲、用户、歌单

音乐 App 的数据模型核心就三个:用户、歌曲、歌单。

User:账号和权限

js

const userSchema = new mongoose.Schema({ username: { type: String, required: true, unique: true }, password: { type: String, required: true }, // 存 hash,别存明文 nickname: { type: String, default: '' }, avatar: { type: String, default: '' }, }, { timestamps: true });

注意:密码绝对不能明文存。用bcryptjs加密,注册时hashSync,登录时compareSync。

Song:歌曲元信息

js

const songSchema = new mongoose.Schema({ title: { type: String, required: true }, artist: { type: String, required: true }, album: { type: String, default: '' }, audioUrl: { type: String, required: true }, // 七牛返回的音频地址 coverUrl: { type: String, default: '' }, // 封面图地址 duration: { type: Number, default: 0 }, // 秒 uploader: { type: mongoose.Schema.Types.ObjectId, ref: 'User' }, }, { timestamps: true });

uploader字段用ref: 'User'关联到用户模型,之后用populate('uploader')就能查出上传者的信息。

Playlist:歌单,歌曲和用户的桥梁

js

const playlistSchema = new mongoose.Schema({ name: { type: String, required: true }, owner: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true }, songs: [{ type: mongoose.Schema.Types.ObjectId, ref: 'Song' }], isPublic: { type: Boolean, default: true }, }, { timestamps: true });

一个歌单有多个歌曲,songs数组存的是 Song 的 ObjectId,populate('songs')就能把歌单里的歌曲详情全查出来。

第三章:七牛云上传——全篇最折腾的部分

这是整个后台最“有故事”的环节。

上传流程的本质

七牛云的上传流程,核心是三步:

  1. 服务端生成上传凭证(uptoken):用 AccessKey/SecretKey 对上传策略做数字签名。
  2. 客户端(或服务端)拿着 uptoken 上传文件到七牛。
  3. 七牛返回文件信息,服务端保存 URL 到数据库。

对音乐 App 来说,最直接的方案是服务端直传:用户上传 MP3 → 后端接收文件流 → 后端用七牛 SDK 传到七牛 → 拿到 URL → 存数据库。

安装依赖

bash

npm install qiniu koa-multer

qiniu是七牛官方 Node.js SDK。koa-multer处理multipart/form-data的文件上传。

生成 uptoken 并上传

js

const qiniu = require('qiniu'); const config = require('../config'); // 初始化七牛配置 const mac = new qiniu.auth.digest.Mac( config.qiniu.accessKey, config.qiniu.secretKey ); // 上传工具函数 async function uploadToQiniu(stream, key) { const putPolicy = new qiniu.rs.PutPolicy({ scope: `${config.qiniu.bucket}:${key}`, // 指定 bucket 和 key }); const uploadToken = putPolicy.uploadToken(mac); const formUploader = new qiniu.form_up.FormUploader(new qiniu.conf.Config()); const putExtra = new qiniu.form_up.PutExtra(); return new Promise((resolve, reject) => { formUploader.putStream(uploadToken, key, stream, putExtra, (err, data, resp) => { if (err) return reject(err); if (resp.statusCode !== 200) return reject(new Error(`Upload failed: ${resp.statusCode}`)); resolve(data); }); }); }

putStream接受一个可读流,这样就不用先把文件存到本地磁盘再上传。

上传路由

js

const Router = require('@koa/router'); const multer = require('@koa/multer'); const fs = require('fs'); const { v4: uuidv4 } = require('uuid'); const router = new Router(); const upload = multer({ dest: 'temp/' }); // 临时存本地,上传后删除 router.post('/song/upload', upload.single('audio'), async (ctx) => { const file = ctx.file; if (!file) { ctx.status = 400; ctx.body = { message: '没有文件上传' }; return; } // 生成唯一 key const ext = file.originalname.split('.').pop(); const key = `songs/${uuidv4()}.${ext}`; try { const stream = fs.createReadStream(file.path); const result = await uploadToQiniu(stream, key); // 拼接公开访问 URL const audioUrl = `${config.qiniu.domain}/${result.key}`; // 删除本地临时文件 fs.unlinkSync(file.path); // 保存到数据库 const song = await Song.create({ title: ctx.request.body.title || file.originalname, artist: ctx.request.body.artist || '未知', audioUrl, uploader: ctx.state.user.id, }); ctx.body = { code: 200, data: song }; } catch (err) { ctx.status = 500; ctx.body = { message: '上传失败', error: err.message }; } });

这里有两个坑:

坑一:七牛的域名拼接。七牛返回的result.key是文件在空间里的路径,你需要用空间绑定的域名拼出完整 URL 才能访问。 比如http://你的域名.clouddn.com/songs/xxx.mp3。

坑二:上传后的本地临时文件要删掉。不删的话,服务器磁盘迟早被塞满。fs.unlinkSync(file.path)是必须的。

第四章:JWT 认证——不是谁都能传歌

用户上传歌曲,首先得登录。JWT 是 Koa 生态里最顺手的认证方案。

生成 Token

js

const jwt = require('jsonwebtoken'); const config = require('../config'); function generateToken(userId) { return jwt.sign({ id: userId }, config.jwt.secret, { expiresIn: config.jwt.expiresIn, }); }

认证中间件

js

const koaJwt = require('koa-jwt'); const config = require('../config'); // 白名单:注册、登录不需要 token const auth = koaJwt({ secret: config.jwt.secret }).unless({ path: ['/api/auth/register', '/api/auth/login'], });

注意:koa-jwt 会把解析后的 payload 挂到 ctx.state.user 上。所以在上传路由里,ctx.state.user.id就是当前登录用户的 ID。

登录接口

js

router.post('/auth/login', async (ctx) => { const { username, password } = ctx.request.body; const user = await User.findOne({ username }); if (!user) { ctx.status = 401; ctx.body = { message: '用户名或密码错误' }; return; } const valid = bcrypt.compareSync(password, user.password); if (!valid) { ctx.status = 401; ctx.body = { message: '用户名或密码错误' }; return; } const token = generateToken(user._id); ctx.body = { token, user: { id: user._id, username: user.username } }; });

第五章:查询歌单——populate 的魔法

有了歌曲和用户关联,查询歌单时就能玩出花来。

js

// 查一个歌单,同时带上 owner 信息和歌曲列表 const playlist = await Playlist.findById(id) .populate('owner', 'username nickname avatar') // 只取部分字段 .populate('songs'); // 把歌曲 ObjectId 展开成完整歌曲文档 // 返回的数据结构大概是: // { // name: "我喜欢的音乐", // owner: { username: "张三", nickname: "三哥", avatar: "..." }, // songs: [ // { title: "晴天", artist: "周杰伦", audioUrl: "...", ... }, // ... // ] // }

这就是 Mongoose 的 populate 的威力:一句代码,把外键关联的文档全查出来,省掉了手动 join 的麻烦。

如果关联字段不是_id怎么办?可以用$lookup聚合管道,或者给 Schema 加virtual字段用localField/foreignField指定关联字段。

尾声:一个音乐后台的最小闭环

把上面这些拼起来,一个音乐 App 后台的核心闭环就通了:

  1. 注册/登录 → 拿到 JWT。
  2. 上传歌曲 → 七牛返回 URL → 存数据库。
  3. 创建歌单 → 关联用户和歌曲。
  4. 查询歌单 → populate 一次性查出所有数据。

剩下的就是前端的事了。

但说实话,七牛云的上传配置、koa-multer的临时文件处理、JWT 的过期刷新……每一个环节都能吃掉你半天时间。这就是“搭后台”的真实体验:概念五分钟,配置两小时。

不过当你看到自己上传的第一首歌在七牛控制台里显示出来的那一刻,那种感觉,值了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进