项目开始

  • yarn init -y 初始化 package.json
  • 或者 使用 npm install koa-generator -g 生成项目 koa2 koa-restful-api
{
  "name": "koa2_restful",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "scripts": {
    "dev": "cross-env NODE_ENV=development nodemon app",
    "start": "cross-env NODE_ENV=production node app"
  },
  "dependencies": {
    "jsonwebtoken": "^8.5.1",
    "koa": "^2.13.4",
    "koa-bodyparser": "^4.3.0",
    "koa-json-error": "^3.1.2",
    "koa-jwt": "^4.0.3",
    "koa-parameter": "^3.0.1",
    "koa-router": "^12.0.0",
    "mongoose": "^6.7.2"
  },
  "devDependencies": {
    "cross-env": "^7.0.3",
    "nodemon": "^2.0.20"
  }
}

理解 koa 中间件 洋葱模型

  • koa 中间件的执行顺序是洋葱模型,先执行最外层的中间件,然后执行里面的中间件,最后执行最里面的中间件,然后再执行外层的中间件,依次类推,直到最外层的中间件执行完毕。
const Koa = require('koa')
const app = new Koa()

app.use(async (ctx, next) => {
  console.log(1)
  // 下一个中间件
  await next()
  console.log(2)
  // 下一个中间件执行完毕后将执行后面代码
  ctx.body = 'Hello World'
})

// 中间件2
app.use(async (ctx, next) => {
  console.log(3)
  await next()
  console.log(4)
})

// 中间件3
app.use(async (ctx, next) => {
  console.log(5)
  await next()
  console.log(6)
})

// 1 3 5 6 4 2

// === 中间件 洋葱模型 ===

app.listen(3000)

koa 添加路由

  • yarn add koa-router
const Koa = require('koa')
const Router = require('koa-router')
const bodyParser = require('koa-bodyparser')

const app = new Koa()
const router = new Router()

// 模拟用户验证鉴权中间件
const auth = async (ctx, next) => {
  if (ctx.url !== '/users') {
    ctx.throw(401)
  }
  await next()
}

// 用户路由
const userRouter = new Router({ prefix: '/user' })

// 首页
router.get('/', (ctx, next) => {
  ctx.body = 'Hello World'
})

// 用户列表
userRouter.get('/', (ctx, next) => {
  // 设置请求头
  ctx.set('Allow', 'GET, POST')
  ctx.body = [
    { name: 'tom', age: 20 },
    { name: 'jerry', age: 18 },
  ]
})

// 创建用户
userRouter.post('/', (ctx, next) => {
  console.log('ctx.request.body', ctx.request.body)
  ctx.body = { name: 'tom', age: 20 }
})

// 用户详情
userRouter.get('/:id', auth, (ctx, next) => {
  ctx.body = { name: 'tom', age: 20 }
})

// 修改用户
userRouter.put('/:id', auth, (ctx, next) => {
  ctx.body = { name: 'tom2', age: 21 }
})

// 删除用户
userRouter.delete('/:id', auth, (ctx, next) => {
  ctx.status = 204
})

// body解析
app.use(bodyParser())
// 注册路由
app.use(router.routes())
// 注册用户路由
app.use(userRouter.routes())
app.use(userRouter.allowedMethods())
// http options
// 检测服务器所支持的http请求方法
// CORS中的预检请求
// allowMethods: 用于响应OPTIONS请求
// 相应的请求方法不在路由中时,返回405 (不允许)例如: delete 请求 没有实现
// 相应的请求方法在路由中,但是没有对应的处理函数时,返回501 (未实现)例如:link 请求

// 获取http请求参数
// 1. query 如:http://localhost:3000/user?id=1&name=2
// 2. params 如:http://localhost:3000/user/1
// 3. body 如:post请求 {name: 'tom', age: 20}
// 4. header 如:Authorization Bearer token

// 发送http响应
// 1. 发送 status 例如: 200/400等
// 2. 发送 body 例如: {name: 'tom', age: 20}
// 3. 发送 header 例如: Content-Type: application/json

app.listen(3000)

目录重构

  • routes 目录放路由文件
  • controllers 目录放控制器文件
  • koa-parameter 参数校验
  • koa-json-error 错误处理
const Koa = require('koa')
const bodyParser = require('koa-bodyparser')
const parameter = require('koa-parameter')
const app = new Koa()
const router = require('./routes')

// koa-json-error
app.use(
  error({
    postFormat: (e, { stack, ...rest }) =>
      process.env.NODE_ENV === 'production' ? rest : { stack, ...rest },
  })
)
// koa-bodyparser 解析请求体
app.use(bodyParser())
// koa-parameter 参数校验
app.use(parameter(app))
// 注册路由
router(app)

// 异常状况有哪些?
// 1. 404 Not Found 412 Precondition Failed (先决条件失败) 422 Unprocessable Entity (无法处理的实体,参数格式不对)
// 2. 500 服务器错误 运行时错误
// 3. 401 未授权
// 4. 403 禁止访问
// 5. 400 Bad Request 请求参数错误
// 6. 405 Method Not Allowed 请求方法不被允许
// 7. 406 Not Acceptable 请求的资源的内容特性无法满足请求头中的条件,因而无法生成响应实体
// 8. 409 Conflict 通常是由于资源的当前状态和请求的条件冲突造成的

// jwt vs session
// 1. jwt 无状态,session 有状态
// 2. jwt 无法在多个服务器之间共享,session 可以

// jwt
// 1. 无状态
// 无状态是指服务器不需要记录用户的状态,每次请求都是独立的,不需要记录上一次的请求信息,也不需要记录用户的登录状态,这样就可以实现服务器的水平扩展,即可以增加服务器的数量来提高服务器的处理能力,而不会影响用户的登录状态。
// 2. 无法在多个服务器之间共享

app.listen(3000)
const fs = require('fs')

// 批量导出路由
module.exports = (app) => {
  fs.readdirSync(__dirname).forEach((file) => {
    if (file === 'index.js') return
    const route = require(`./${file}`)
    app.use(route.routes()).use(route.allowedMethods())
  })
}
const Router = require('koa-router')
const router = new Router({ prefix: '/users' })

// 模拟用户验证鉴权中间件
const auth = async (ctx, next) => {
  if (ctx.url !== '/users') {
    ctx.throw(401)
  }
  await next()
}

// 用户列表
router.get('/', (ctx, next) => {
  // 设置请求头
  ctx.set('Allow', 'GET, POST')
  ctx.body = [
    { name: 'tom', age: 20 },
    { name: 'jerry', age: 18 },
  ]
})

// 创建用户
router.post('/', (ctx, next) => {
  console.log('ctx.request.body', ctx.request.body)
  ctx.body = { name: 'tom', age: 20 }
})

// 用户详情
router.get('/:id', auth, (ctx, next) => {
  ctx.body = { name: 'tom', age: 20 }
})

// 修改用户
router.put('/:id', auth, (ctx, next) => {
  ctx.body = { name: 'tom2', age: 21 }
})

// 删除用户
router.delete('/:id', auth, (ctx, next) => {
  ctx.status = 204
})

module.exports = router
const Router = require('koa-router')
const router = new Router()

// 首页
router.get('/', (ctx, next) => {
  ctx.body = 'Hello World'
})

module.exports = router
  • controllers 使用类文件与路由关联
// home 控制器
class HomeCtl {
  async index(ctx) {
    ctx.body = 'Hello World'
  }
}

module.exports = new HomeCtl()
const Router = require('koa-router')
const router = new Router()
const { index } = require('../controllers/home')

// 首页
router.get('/', index)

module.exports = router
// 用户控制器
class UsersCtl {
  // 获取用户列表
  async find(ctx) {
    // 模拟抛出 500 错误
    // a.b
    // ctx.throw(412, '先决条件失败')
    ctx.body = [
      { name: 'tom', age: 20 },
      { name: 'jerry', age: 18 },
    ]
  }
  // 获取用户详情
  async findById(ctx) {
    ctx.body = { name: 'tom', age: 20 }
  }
  // 创建用户
  async create(ctx) {
    // 参数校验
    ctx.verifyParams({
      name: { type: 'string', required: true },
      age: { type: 'number', required: false },
    })

    ctx.body = { name: 'tom', age: 20 }
  }
  // 修改用户
  async update(ctx) {
    // 参数校验
    ctx.verifyParams({
      name: { type: 'string', required: false },
      age: { type: 'number', required: false },
    })
    ctx.body = { name: 'tom2', age: 21 }
  }
  // 删除用户
  async del(ctx) {
    ctx.status = 204
  }
}

module.exports = new UsersCtl()
const Router = require('koa-router')

const router = new Router({ prefix: '/users' })
const { find, findById, create, update, del } = require('../controllers/users')

// 模拟用户验证鉴权中间件
const auth = async (ctx, next) => {
  if (ctx.url !== '/users') {
    ctx.throw(401)
  }
  await next()
}

// 用户列表
router.get('/', find)

// 创建用户
router.post('/', create)

// 用户详情
router.get('/:id', auth, findById)

// 修改用户
// 全量修改使用 put
// router.put('/:id', update)
// 部分修改使用 patch
router.patch('/:id', update)

// 删除用户
router.delete('/:id', del)

module.exports = router

连接数据库,使用 mongoose

module.exports = {
  // 数据库配置
  connectStr:
    'mongodb+srv://xmllein:<password>@cluster0.9wopstp.mongodb.net/?retryWrites=true&w=majority',
}
const mongoose = require('mongoose')

// 连接数据库
mongoose.connect(
  connectStr,
  { useNewUrlParser: true, useUnifiedTopology: true },
  () => console.log('MongoDB 连接成功')
)
mongoose.connection.on('error', console.error)
// 用户模型
const mongoose = require('mongoose')
const { Schema, model } = mongoose
const userShechma = new Schema({
  name: { type: String, required: true },
  password: { type: String, required: true, select: false },
})

module.exports = model('User', userShechma)
const User = require('../models/users')
const { secret } = require('../config')
// 用户控制器
class UsersCtl {
  // 获取用户列表
  async find(ctx) {
    // 模拟抛出 500 错误
    // a.b
    // ctx.throw(412, '先决条件失败')
    ctx.body = await User.find()
  }
  // 获取用户详情
  async findById(ctx) {
    console.log(ctx.state.user)
    const user = await User.findById(ctx.params.id)
    if (!user) {
      ctx.throw(404, '用户不存在')
    }
    ctx.body = user
  }
  // 创建用户
  async create(ctx) {
    // 参数校验
    ctx.verifyParams({
      name: { type: 'string', required: true },
      password: { type: 'string', required: true },
    })
    console.log('ctx.request.body', ctx.request.body)
    // 重复用户名校验
    const { name } = ctx.request.body
    const repeatedUser = await User.findOne({ name })
    if (repeatedUser) {
      ctx.throw(409, '用户已存在')
    }
    const user = await new User(ctx.request.body).save()
    ctx.body = user
  }
  // 修改用户
  async update(ctx) {
    // 参数校验
    ctx.verifyParams({
      name: { type: 'string', required: false },
      password: { type: 'string', required: false },
    })
    const user = await User.findByIdAndUpdate(ctx.params.id, ctx.request.body)
    if (!user) {
      ctx.throw(404, '用户不存在')
    }
    ctx.body = user
  }
  // 删除用户
  async del(ctx) {
    const user = await User.findByIdAndRemove(ctx.params.id)
    if (!user) {
      ctx.throw(404, '用户不存在')
    }
    ctx.status = 204
  }
}

module.exports = new UsersCtl()

jwt 在 koa 中使用

  • jsonwebtoken 生成 token, 认证 token
  • koa-jwt 中间件 来作为 koa 验证 token 中间件

// 登录
  async login(ctx) {
    // 参数校验
    ctx.verifyParams({
      name: { type: 'string', required: true },
      password: { type: 'string', required: true },
    })
    // 是否有该用户
    const user = await User.findOne(ctx.request.body)
    if (!user) {
      ctx.throw(401, '用户名或密码不正确')
    }
    // 生成 token
    const { _id, name } = user
    const token = jsonwebtoken.sign({ _id, name }, secret, { expiresIn: '1d' })
    ctx.body = { token }
  }

   // 检查用户是否是自己(是本人才能修改数据和删除数据)
  async checkOwner(ctx, next) {
    if (ctx.params.id !== ctx.state.user._id) {
      ctx.throw(403, '没有权限')
    }
    await next()
  }

const Router = require('koa-router')
// const jsonwebtoken = require('jsonwebtoken')
const koajwt = require('koa-jwt')
const { secret } = require('../config')
const router = new Router({ prefix: '/users' })
const {
  find,
  findById,
  create,
  update,
  del,
  login,
  checkOwner,
} = require('../controllers/users')

// 模拟用户验证鉴权中间件
const auth = async (ctx, next) => {
  if (ctx.url !== '/users') {
    ctx.throw(401)
  }
  await next()
}

// const authToken = async (ctx, next) => {
//   const { authorization = '' } = ctx.request.header
//   const token = authorization.replace('Bearer ', '')
//   try {
//     const user = jsonwebtoken.verify(token, secret)
//     ctx.state.user = user
//   } catch (err) {
//     ctx.throw(401, err.message)
//   }
//   await next()
// }
// koa-jwt 替换上面自定义的 authToken 中间件
const authToken = koajwt({ secret })

// 用户列表
router.get('/', find)

// 创建用户
router.post('/', create)

// 用户详情
router.get('/:id', authToken, findById)

// 修改用户
// 全量修改使用 put
// router.put('/:id', update)
// 部分修改使用 patch
router.patch('/:id', authToken, checkOwner, update)

// 删除用户
router.delete('/:id', authToken, checkOwner, del)

// 登录
router.post('/login', login)

module.exports = router

使用 vscode 插件 REST Client

  • 测试接口,替换 postman 测试
### 变量

@token = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJfaWQiOiI2MzdhZjY3NDE2NWRjZDAxMzJkYTkxMjYiLCJuYW1lIjoidGVzdCIsImlhdCI6MTY2OTAwOTg3NCwiZXhwIjoxNjY5MDk2Mjc0fQ.o6VSajZt3eIncTVnsbZDif-zONLAlRdTHVaR3MxoAik


### 请求用户列表
GET http://localhost:3000/users/ HTTP/1.1

### 用户详情
GET http://localhost:3000/users/637af674165dcd0132da9126 HTTP/1.1
Authorization: Bearer {{token}}
Content-Type: application/json



### 新增用户
POST http://localhost:3000/users HTTP/1.1
Content-Type: application/json

{
    "name": "test",
    "password": "123456"
}

### 修改用户
PATCH http://localhost:3000/users/637af674165dcd0132da9126 HTTP/1.1
Authorization: Bearer {{token}}
Content-Type: application/json

{
    "name": "test100",
    "password": "123456"
}

### 删除用户
DELETE http://localhost:3000/users/637af42c7c3fd94d5c7916ba HTTP/1.1


### 用户登录
POST http://localhost:3000/users/login HTTP/1.1
Content-Type: application/json

{
    "name": "test",
    "password": "123456"
}

上传图片

  • 使用 koa-body@4.1.0 版本
  • 新建存放图片目录/public/uploads

// koa-body 解析请求体 multipart/form-data 上传文件
const koaBody = require('koa-body')
//最新版本
// const { koaBody } = require('koa-body')
....
app.use(
  koaBody({
    multipart: true,
    formidable: {
      maxFileSize: 200 * 1024 * 1024,
      uploadDir: path.join(__dirname, '/public/uploads'),
      keepExtensions: true,
    },
  })
)
// 上传
 upload(ctx) {
    const file = ctx.request.files.file
    const basename = path.basename(file.path)
    ctx.body = { url: `${ctx.origin}/uploads/${basename}` }
  }
const { index, upload } = require('../controllers/home')
...
// 上传文件
router.post('/upload', upload)
### 变量
@token = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJfaWQiOiI2MzdhZjY3NDE2NWRjZDAxMzJkYTkxMjYiLCJuYW1lIjoidGVzdCIsImlhdCI6MTY2OTAwOTg3NCwiZXhwIjoxNjY5MDk2Mjc0fQ.o6VSajZt3eIncTVnsbZDif-zONLAlRdTHVaR3MxoAik

@BASE_URL = http://localhost:3000/

### 请求用户列表
GET {{BASE_URL}}users/ HTTP/1.1

### 用户详情
GET {{BASE_URL}}users/637af674165dcd0132da9126 HTTP/1.1
Authorization: Bearer {{token}}
Content-Type: application/json



### 新增用户
POST {{BASE_URL}}users HTTP/1.1
Content-Type: application/json

{
    "name": "test",
    "password": "123456"
}

### 修改用户
PATCH {{BASE_URL}}users/637af674165dcd0132da9126 HTTP/1.1
Authorization: Bearer {{token}}
Content-Type: application/json

{
    "name": "test100",
    "password": "123456"
}

### 删除用户
DELETE {{BASE_URL}}users/637af42c7c3fd94d5c7916ba HTTP/1.1


### 用户登录
POST {{BASE_URL}}users/login HTTP/1.1
Content-Type: application/json

{
    "name": "test",
    "password": "123456"
}

### 文件上传
POST {{BASE_URL}}upload HTTP/1.1
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW

------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="file"; filename="1.jpg"
Content-Type: image/jpeg

< ./1.jpg
------WebKitFormBoundary7MA4YWxkTrZu0gW--

个人资料完善

  • 完善用户信息
// 用户模型
const mongoose = require('mongoose')
const { Schema, model } = mongoose
const userShechma = new Schema({
  __v: { type: Number, select: false },
  name: { type: String, required: true },
  password: { type: String, required: true, select: false },
  // 头像
  avatar_url: { type: String },
  // 性别
  gender: {
    type: String,
    enum: ['male', 'female'],
    default: 'male',
    required: true,
  },
  // 简介
  headline: { type: String },
  // 位置
  locations: { type: [{ type: String }], select: false },
  // 行业
  business: { type: String, select: false },
  // 职业经历
  employments: {
    type: [{ company: { type: String }, job: { type: String } }],
    select: false,
  },
  // 教育经历
  educations: {
    type: [
      {
        school: { type: String },
        major: { type: String },
        diploma: { type: Number, enum: [1, 2, 3, 4, 5] },
        // 入学时间
        entrance_year: { type: Number },
        // 毕业时间
        graduation_year: { type: Number },
      },
    ],
    select: false,
  },
})

module.exports = model('User', userShechma)
### 变量
@token = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJfaWQiOiI2MzdhZjY3NDE2NWRjZDAxMzJkYTkxMjYiLCJuYW1lIjoidGVzdCIsImlhdCI6MTY2OTAwOTg3NCwiZXhwIjoxNjY5MDk2Mjc0fQ.o6VSajZt3eIncTVnsbZDif-zONLAlRdTHVaR3MxoAik

@BASE_URL = http://localhost:3000/

### 请求用户列表
GET {{BASE_URL}}users/ HTTP/1.1

### 用户详情
GET {{BASE_URL}}users/637af674165dcd0132da9126?fields=business;locations HTTP/1.1
Authorization: Bearer {{token}}
Content-Type: application/json



### 新增用户
POST {{BASE_URL}}users HTTP/1.1
Content-Type: application/json

{
    "name": "test",
    "password": "123456"
}

### 修改用户
PATCH {{BASE_URL}}users/637af674165dcd0132da9126 HTTP/1.1
Authorization: Bearer {{token}}
Content-Type: application/json

{
    "name": "test100",
    "password": "123456",
    "avatar_url": "https://www.baidu.com/img/bd_logo1.png",
    "gender": "male",
    "headline": "test",
    "locations": ["北京", "上海"],
    "business": "IT",
    "employments": [
        {
            "company": "阿里巴巴",
            "job": "前端专家"
        }
    ],
    "educations": [
        {
            "school": "清华大学",
            "major": "计算机科学与技术",
            "diploma": 3,
            "entrance_year": 2010,
            "graduation_year": 2014
        }
    ]

}

### 删除用户
DELETE {{BASE_URL}}users/637af42c7c3fd94d5c7916ba HTTP/1.1


### 用户登录
POST {{BASE_URL}}users/login HTTP/1.1
Content-Type: application/json

{
    "name": "test",
    "password": "123456"
}

### 文件上传
POST {{BASE_URL}}upload HTTP/1.1
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW

------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="file"; filename="1.jpg"
Content-Type: image/jpeg

< ./1.jpg
------WebKitFormBoundary7MA4YWxkTrZu0gW--
 // 获取用户详情
  async findById(ctx) {
    // 用户要显示的字段
    const { fields } = ctx.query
    const selectFields = fields
      .split(';')
      .filter((f) => f)
      .map((f) => ' +' + f)
      .join('')
    const user = await User.findById(ctx.params.id).select(selectFields)
    if (!user) {
      ctx.throw(404, '用户不存在')
    }
    ctx.body = user
  }

关注与粉丝模块

  • 用户与粉丝的关系,多对多的关系
  • 修改用户 model
  • 关注与取消关注时候,需要校验用户是否存在
 // 关注者
  following: {
    type: [{ type: Schema.Types.ObjectId, ref: 'User' }],
    select: false,
  },
// 获取关注列表
  async listFollowing(ctx) {
    const user = await User.findById(ctx.params.id)
      .select('+following')
      .populate('following')
    if (!user) {
      ctx.throw(404)
    }
    ctx.body = user.following
  }

  // 关注用户
  async follow(ctx) {
    const me = await User.findById(ctx.state.user._id).select('+following')
    // 判断是否已经关注
    if (!me.following.map((id) => id.toString()).includes(ctx.params.id)) {
      me.following.push(ctx.params.id)
      me.save()
    }
    ctx.status = 204
  }

    // 校验用户是否存在 中间件
  async checkUserExist(ctx, next) {
    const user = await User.findById(ctx.params.id)
    if (!user) {
      ctx.throw(404, '用户不存在')
    }
    await next()
  }

  // 取消关注
  async unfollow(ctx) {
    const me = await User.findById(ctx.state.user._id).select('+following')
    // 判断是否已经关注
    const index = me.following.map((id) => id.toString()).indexOf(ctx.params.id)
    if (index > -1) {
      me.following.splice(index, 1)
      me.save()
    }
    ctx.status = 204
  }

  // 获取粉丝列表
  async listFollowers(ctx) {
    const users = await User.find({ following: ctx.params.id })
    ctx.body = users
  }
const {
  find,
  findById,
  create,
  update,
  del,
  login,
  checkOwner,
  listFollowing,
  listFollowers,
  checkUserExist,
  follow,
  unfollow,
} = require('../controllers/users')
...


// 获取关注列表
router.get('/:id/following', listFollowing)

// 关注某人
router.put('/following/:id', authToken, checkUserExist, follow)

// 取消关注某人
router.delete('/following/:id', authToken, checkUserExist, unfollow)

// 获取粉丝列表
router.get('/:id/followers', listFollowers)
### 获取某个用户关注人列表
GET {{BASE_URL}}users/637af674165dcd0132da9126/following HTTP/1.1

### 关注某个用户
PUT {{BASE_URL}}users/following/637b9536e023f89d918ba8b8 HTTP/1.1
Authorization: Bearer {{token}}

### 取消关注
DELETE {{BASE_URL}}users/following/637b9536e023f89d918ba8b8 HTTP/1.1
Authorization: Bearer {{token}}

### 获取某个用户粉丝列表
GET {{BASE_URL}}users/637b9536e023f89d918ba8b8/followers HTTP/1.1

话题模块

  • 话题的增删改查
  • 话题模糊搜索
const mongoose = require('mongoose')
const { Schema, model } = mongoose

const TopicSchema = new Schema({
  __v: { type: Number, select: false },
  // 话题名称
  name: { type: String, required: true },
  // 话题头像
  avatar_url: { type: String },
  // 话题简介
  introduction: { type: String, select: false },
})

module.exports = model('Topic', TopicSchema)
// 话题控制器
const Topic = require('../models/topics')
class TopicCtl {
  // 获取话题列表
  async find(ctx) {
    const { per_page = 10 } = ctx.query
    const page = Math.max(ctx.query.page * 1, 1) - 1
    const perPage = Math.max(per_page * 1, 1)
    // 模糊查询 q=xxx
    ctx.body = await Topic.find({ name: new RegExp(ctx.query.q) })
      .limit(perPage)
      .skip(page * perPage)
  }

  // 获取特定话题
  async findById(ctx) {
    const { fields = '' } = ctx.query
    const selectFields = fields
      .split(';')
      .filter((f) => f)
      .map((f) => ' +' + f)
      .join('')
    const topic = await Topic.findById(ctx.params.id).select(selectFields)
    ctx.body = topic
  }

  // 创建话题
  async create(ctx) {
    ctx.verifyParams({
      name: { type: 'string', required: true },
      avatar_url: { type: 'string', required: false },
      introduction: { type: 'string', required: false },
    })
    const topic = await new Topic(ctx.request.body).save()
    ctx.body = topic
  }

  // 修改话题
  async update(ctx) {
    ctx.verifyParams({
      name: { type: 'string', required: false },
      avatar_url: { type: 'string', required: false },
      introduction: { type: 'string', required: false },
    })
    const topic = await Topic.findByIdAndUpdate(ctx.params.id, ctx.request.body)
    ctx.body = topic
  }

  // 删除话题
  async delete(ctx) {
    await Topic.findByIdAndRemove(ctx.params.id)
    ctx.status = 204
  }
}

module.exports = new TopicCtl()
// 话题路由
const Router = require('koa-router')
const router = new Router({ prefix: '/topics' })
const {
  find,
  findById,
  create,
  update,
  delete: del,
} = require('../controllers/topics')

// 获取话题列表
router.get('/', find)
// 获取特定话题
router.get('/:id', findById)
// 创建话题
router.post('/', create)
// 修改话题
router.patch('/:id', update)
// 删除话题
router.delete('/:id', del)

module.exports = router
## 话题列表
GET {{BASE_URL}}topics?page=2&per_page=2&q=t HTTP/1.1

### 话题详情
GET {{BASE_URL}}topics/637ba02afdb8e477106fab5f HTTP/1.1

### 新增话题
POST {{BASE_URL}}topics HTTP/1.1
Authorization: Bearer {{token}}
Content-Type: application/json

{
    "name": "test3",
    "avatar_url": "https://www.baidu.com/img/bd_logo1.png",
    "introduction":"test3"

}

### 修改话题
PATCH {{BASE_URL}}topics/637ba02afdb8e477106fab5f HTTP/
Authorization: Bearer {{token}}
Content-Type: application/json

{
    "name": "test2",
    "avatar_url": "https://www.baidu.com/img/bd_logo1.png",
    "introduction":"test2"

}

### 删除话题
DELETE {{BASE_URL}}topics/637ba02afdb8e477106fab5f HTTP/1.1
Authorization: Bearer {{token}}
  • 用户关注话题 , 用户可以关注多个话题,话题也可以被多个用户关注
// 用户模型
const mongoose = require('mongoose')
const { Schema, model } = mongoose
const userShechma = new Schema({
  __v: { type: Number, select: false },
  name: { type: String, required: true },
  password: { type: String, required: true, select: false },
  // 头像
  avatar_url: { type: String },
  // 性别
  gender: {
    type: String,
    enum: ['male', 'female'],
    default: 'male',
    required: true,
  },
  // 简介
  headline: { type: String },
  // 位置
  locations: {
    type: [{ type: Schema.Types.ObjectId, ref: 'Topic' }],
    select: false,
  },
  // 行业
  business: { type: Schema.Types.ObjectId, ref: 'Topic', select: false },
  // 职业经历
  employments: {
    type: [
      {
        company: { type: Schema.Types.ObjectId, ref: 'Topic' },
        job: { type: Schema.Types.ObjectId, ref: 'Topic' },
      },
    ],
    select: false,
  },
  // 教育经历
  educations: {
    type: [
      {
        // 学校
        school: { type: Schema.Types.ObjectId, ref: 'Topic' },
        // 专业
        major: { type: Schema.Types.ObjectId, ref: 'Topic' },
        diploma: { type: Number, enum: [1, 2, 3, 4, 5] },
        // 入学时间
        entrance_year: { type: Number },
        // 毕业时间
        graduation_year: { type: Number },
      },
    ],
    select: false,
  },
  // 关注者
  following: {
    type: [{ type: Schema.Types.ObjectId, ref: 'User' }],
    select: false,
  },
  // 关注的话题
  followingTopics: {
    type: [{ type: Schema.Types.ObjectId, ref: 'Topic' }],
    select: false,
  },
})

module.exports = model('User', userShechma)
// 获取关注话题列表
  async listFollowingTopics(ctx) {
    const user = await User.findById(ctx.params.id)
      .select('+followingTopics')
      .populate('followingTopics')
    if (!user) {
      ctx.throw(404)
    }
    ctx.body = user.followingTopics
  }

  // 关注话题
  async followTopic(ctx) {
    const me = await User.findById(ctx.state.user._id).select(
      '+followingTopics'
    )
    // 判断是否已经关注
    if (
      !me.followingTopics.map((id) => id.toString()).includes(ctx.params.id)
    ) {
      me.followingTopics.push(ctx.params.id)
      me.save()
    }
    ctx.status = 204
  }

  // 取消关注话题
  async unfollowTopic(ctx) {
    const me = await User.findById(ctx.state.user._id).select(
      '+followingTopics'
    )
    // 判断是否已经关注
    const index = me.followingTopics
      .map((id) => id.toString())
      .indexOf(ctx.params.id)
    if (index > -1) {
      me.followingTopics.splice(index, 1)
      me.save()
    }
    ctx.status = 204
  }
const {
  find,
  findById,
  create,
  update,
  del,
  login,
  checkOwner,
  listFollowing,
  listFollowers,
  checkUserExist,
  follow,
  unfollow,
  followTopic,
  unfollowTopic,
  listFollowingTopics,
} = require('../controllers/users')

const { checkTopicExist } = require('../controllers/topics')

...
// 关注话题
router.put('/followingTopics/:id', authToken, checkTopicExist, followTopic)

// 取消关注话题
router.delete('/followingTopics/:id', authToken, checkTopicExist, unfollowTopic)

// 获取关注的话题列表
router.get('/:id/followingTopics', listFollowingTopics)

// 关注话题是否存在 中间件
  async checkTopicExist(ctx, next) {
    const topic = await Topic.findById(ctx.params.id)
    if (!topic) {
      ctx.throw(404, '话题不存在')
    }
    await next()
  }

  // 获取话题的关注者
  async listTopicFollowers(ctx) {
    const users = await User.find({ followingTopics: ctx.params.id })
    ctx.body = users
  }
// 话题路由
const Router = require('koa-router')
const koajwt = require('koa-jwt')
const router = new Router({ prefix: '/topics' })
const {
  find,
  findById,
  create,
  update,
  delete: del,
  checkTopicExist,
  listTopicFollowers,
} = require('../controllers/topics')
const { secret } = require('../config')

const auth = koajwt({ secret })

// 获取话题列表
router.get('/', find)
// 获取特定话题
router.get('/:id', checkTopicExist, findById)
// 创建话题
router.post('/', auth, create)
// 修改话题
router.patch('/:id', auth, checkTopicExist, update)
// 删除话题
router.delete('/:id', del)

// 获取话题的粉丝列表
router.get('/:id/followers', checkTopicExist, listTopicFollowers)

module.exports = router

### 变量
@token = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJfaWQiOiI2MzdiOTUzNmUwMjNmODlkOTE4YmE4YjgiLCJuYW1lIjoidGVzdDIiLCJpYXQiOjE2NjkwNDQ2MjIsImV4cCI6MTY2OTEzMTAyMn0.ZPLYEUEcriiWTGxT7HHPOUHLy1g-hSHLI8LN_4c6QVc

@BASE_URL = http://localhost:3000/

### 请求用户列表
GET {{BASE_URL}}users/ HTTP/1.1

### 用户详情
GET {{BASE_URL}}users/637af674165dcd0132da9126?fields=business;locations;educations;employments; HTTP/1.1
Authorization: Bearer {{token}}
Content-Type: application/json



### 新增用户
POST {{BASE_URL}}users HTTP/1.1
Content-Type: application/json

{
    "name": "test2",
    "password": "123456"
}

### 修改用户
PATCH {{BASE_URL}}users/637af674165dcd0132da9126 HTTP/1.1
Authorization: Bearer {{token}}
Content-Type: application/json

{
    "name": "test100",
    "password": "123456",
    "avatar_url": "https://www.baidu.com/img/bd_logo1.png",
    "gender": "male",
    "headline": "test",
    "locations": ["北京", "上海"],
    "business": "IT",
    "employments": [
        {
            "company": "阿里巴巴",
            "job": "前端专家"
        }
    ],
    "educations": [
        {
            "school": "清华大学",
            "major": "计算机科学与技术",
            "diploma": 3,
            "entrance_year": 2010,
            "graduation_year": 2014
        }
    ]

}

### 删除用户
DELETE {{BASE_URL}}users/637af42c7c3fd94d5c7916ba HTTP/1.1


### 用户登录
POST {{BASE_URL}}users/login HTTP/1.1
Content-Type: application/json

{
    "name": "test2",
    "password": "123456"
}

### 文件上传
POST {{BASE_URL}}upload HTTP/1.1
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW

------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="file"; filename="1.jpg"
Content-Type: image/jpeg

< ./1.jpg
------WebKitFormBoundary7MA4YWxkTrZu0gW--

### 获取用户关注人列表
GET {{BASE_URL}}users/637af674165dcd0132da9126/following HTTP/1.1

### 关注用户
PUT {{BASE_URL}}users/following/637b9536e023f89d918ba8b8 HTTP/1.1
Authorization: Bearer {{token}}

### 取消关注
DELETE {{BASE_URL}}users/following/637b9536e023f89d918ba8b8 HTTP/1.1
Authorization: Bearer {{token}}

### 获取用户粉丝列表
GET {{BASE_URL}}users/637b9536e023f89d918ba8b8/followers HTTP/1.1

### 获取用户关注话题列表
GET {{BASE_URL}}users/637b9536e023f89d918ba8b8/followingTopics HTTP/1.1

### 关注某个话题
PUT {{BASE_URL}}users/followingTopics/637c235dd0f3a38dd1b9783a
Authorization: Bearer {{token}}

### 取消关注某个话题
DELETE {{BASE_URL}}users/followingTopics/637c235dd0f3a38dd1b9783a
Authorization: Bearer {{token}}


### 话题列表
GET {{BASE_URL}}topics?page=1&per_page=20&q= HTTP/1.1

### 话题详情
GET {{BASE_URL}}topics/637c1fec3d25426a6c8c10e9 HTTP/1.1

### 新增话题
POST {{BASE_URL}}topics HTTP/1.1
Authorization: Bearer {{token}}
Content-Type: application/json

{
    "name": "新的话题",
    "avatar_url": "https://www.baidu.com/img/bd_logo1.png",
    "introduction":"新的话题"

}

### 修改话题
PATCH {{BASE_URL}}topics/637ba02afdb8e477106fab5f HTTP/
Authorization: Bearer {{token}}
Content-Type: application/json

{
    "name": "test2",
    "avatar_url": "https://www.baidu.com/img/bd_logo1.png",
    "introduction":"test2"

}

### 删除话题
DELETE {{BASE_URL}}topics/637ba02afdb8e477106fab5f HTTP/1.1
Authorization: Bearer {{token}}

### 获取话题的粉丝列表
GET {{BASE_URL}}topics/637c235dd0f3a38dd1b9783a/followers HTTP/1.1

问题模块

  • 问题列表(用户-问题一对多关系)
  • 话题列表(问题-话题多对多关系)
  • 用户关注问题(略)
 // 关注的问题
  followingQuestions: {
    type: [{ type: Schema.Types.ObjectId, ref: 'Question' }],
    select: false,
  },
// 问题模型
const mongoose = require('mongoose')
const { Schema, model } = mongoose

const QuestionSchema = new Schema({
  __v: { type: Number, select: false },
  // 问题标题
  title: { type: String, required: true },
  // 问题描述
  description: { type: String, required: true },
  // 问题关联的话题
  topics: { type: Schema.Types.ObjectId, ref: 'Topic', required: true },
  // 问题的创建者
  questioner: { type: Schema.Types.ObjectId, ref: 'User', required: true },
})

module.exports = model('Question', QuestionSchema)
const Question = require('../models/questions')
// 问题控制器
class QuestionsCtl {
  // 查询所有问题
  async find(ctx) {
    const { per_page = 10 } = ctx.query
    const page = Math.max(ctx.query.page * 1, 1) - 1
    const perPage = Math.max(per_page * 1, 1)
    ctx.body = await Question.find({ title: new RegExp(ctx.query.q) })
      .limit(perPage)
      .skip(page * perPage)
  }
  // 根据id查询问题
  async findById(ctx) {
    const question = await Question.findById(ctx.params.id).populate(
      'questioner topics'
    )
    ctx.body = question
  }
  // 创建问题
  async create(ctx) {
    ctx.verifyParams({
      title: { type: 'string', required: true },
      topics: { type: 'array', itemType: 'string', required: false },
      description: { type: 'string', required: false },
    })
    const question = await new Question({
      ...ctx.request.body,
      questioner: ctx.state.user._id,
    }).save()
    ctx.body = question
  }
  // 修改问题
  async update(ctx) {
    ctx.verifyParams({
      title: { type: 'string', required: false },
      topics: { type: 'array', itemType: 'string', required: false },
      description: { type: 'string', required: false },
    })

    const question = await Question.findByIdAndUpdate(
      ctx.params.id,
      ctx.request.body
    )
    ctx.body = question
  }
  // 删除问题
  async delete(ctx) {
    const question = await Question.findByIdAndRemove(ctx.params.id)
    ctx.status = 204
  }

  // 检查问题是否存在
  async checkQuestionExist(ctx, next) {
    const question = await Question.findById(ctx.params.id).select(
      '+questioner'
    )
    if (!question) {
      ctx.throw(404, '问题不存在')
    }
    await next()
  }
}

module.exports = new QuestionsCtl()
// 问题路由
// 话题路由
const Router = require('koa-router')
const koajwt = require('koa-jwt')
const router = new Router({ prefix: '/questions' })
const {
  find,
  findById,
  create,
  update,
  delete: del,
  checkQuestionExist,
} = require('../controllers/questions')
const { secret } = require('../config')

const auth = koajwt({ secret })

// 获取问题列表
router.get('/', find)
// 获取特定问题
router.get('/:id', findById)
// 创建问题
router.post('/', auth, create)
// 修改问题
router.patch('/:id', auth, checkQuestionExist, update)
// 删除问题
router.delete('/:id', auth, checkQuestionExist, del)

module.exports = router
 // 话题下面的问题列表
  async listQuestions(ctx) {
    const questions = await Question.find({ topics: ctx.params.id })
    ctx.body = questions
  }
// 获取话题的问题列表
router.get('/:id/questions', checkTopicExist, listQuestions)
### 变量
@token = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJfaWQiOiI2MzdiOTUzNmUwMjNmODlkOTE4YmE4YjgiLCJuYW1lIjoidGVzdDIiLCJpYXQiOjE2NjkwNDQ2MjIsImV4cCI6MTY2OTEzMTAyMn0.ZPLYEUEcriiWTGxT7HHPOUHLy1g-hSHLI8LN_4c6QVc

@BASE_URL = http://localhost:3000/

### 请求用户列表
GET {{BASE_URL}}users/ HTTP/1.1

### 用户详情
GET {{BASE_URL}}users/637af674165dcd0132da9126?fields=business;locations;educations;employments;followingQuestions; HTTP/1.1
Authorization: Bearer {{token}}
Content-Type: application/json



### 新增用户
POST {{BASE_URL}}users HTTP/1.1
Content-Type: application/json

{
    "name": "test2",
    "password": "123456"
}

### 修改用户
PATCH {{BASE_URL}}users/637af674165dcd0132da9126 HTTP/1.1
Authorization: Bearer {{token}}
Content-Type: application/json

{
    "name": "test100",
    "password": "123456",
    "avatar_url": "https://www.baidu.com/img/bd_logo1.png",
    "gender": "male",
    "headline": "test",
    "locations": ["北京", "上海"],
    "business": "IT",
    "employments": [
        {
            "company": "阿里巴巴",
            "job": "前端专家"
        }
    ],
    "educations": [
        {
            "school": "清华大学",
            "major": "计算机科学与技术",
            "diploma": 3,
            "entrance_year": 2010,
            "graduation_year": 2014
        }
    ]

}

### 删除用户
DELETE {{BASE_URL}}users/637af42c7c3fd94d5c7916ba HTTP/1.1


### 用户登录
POST {{BASE_URL}}users/login HTTP/1.1
Content-Type: application/json

{
    "name": "test2",
    "password": "123456"
}

### 文件上传
POST {{BASE_URL}}upload HTTP/1.1
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW

------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="file"; filename="1.jpg"
Content-Type: image/jpeg

< ./1.jpg
------WebKitFormBoundary7MA4YWxkTrZu0gW--

### 获取用户关注人列表
GET {{BASE_URL}}users/637af674165dcd0132da9126/following HTTP/1.1

### 关注用户
PUT {{BASE_URL}}users/following/637b9536e023f89d918ba8b8 HTTP/1.1
Authorization: Bearer {{token}}

### 取消关注
DELETE {{BASE_URL}}users/following/637b9536e023f89d918ba8b8 HTTP/1.1
Authorization: Bearer {{token}}

### 获取用户粉丝列表
GET {{BASE_URL}}users/637b9536e023f89d918ba8b8/followers HTTP/1.1

### 获取用户关注话题列表
GET {{BASE_URL}}users/637b9536e023f89d918ba8b8/followingTopics HTTP/1.1

### 关注某个话题
PUT {{BASE_URL}}users/followingTopics/637c235dd0f3a38dd1b9783a
Authorization: Bearer {{token}}

### 取消关注某个话题
DELETE {{BASE_URL}}users/followingTopics/637c235dd0f3a38dd1b9783a
Authorization: Bearer {{token}}


### 话题列表
GET {{BASE_URL}}topics?page=1&per_page=20&q= HTTP/1.1

### 话题详情
GET {{BASE_URL}}topics/637c1fec3d25426a6c8c10e9 HTTP/1.1

### 新增话题
POST {{BASE_URL}}topics HTTP/1.1
Authorization: Bearer {{token}}
Content-Type: application/json

{
    "name": "新的话题",
    "avatar_url": "https://www.baidu.com/img/bd_logo1.png",
    "introduction":"新的话题"

}

### 修改话题
PATCH {{BASE_URL}}topics/637ba02afdb8e477106fab5f HTTP/
Authorization: Bearer {{token}}
Content-Type: application/json

{
    "name": "test2",
    "avatar_url": "https://www.baidu.com/img/bd_logo1.png",
    "introduction":"test2"

}

### 删除话题
DELETE {{BASE_URL}}topics/637ba02afdb8e477106fab5f HTTP/1.1
Authorization: Bearer {{token}}

### 获取话题的粉丝列表
GET {{BASE_URL}}topics/637c235dd0f3a38dd1b9783a/followers HTTP/1.1

### 获取话题的问题列表
GET {{BASE_URL}}topics/637c235dd0f3a38dd1b9783a/questions HTTP/1.1

### 用户问题列表
GET {{BASE_URL}}users/637b9536e023f89d918ba8b8/questions?page=1&per_page=20&q= HTTP/1.1


### 问题列表
GET {{BASE_URL}}questions?page=1&per_page=20&q= HTTP/1.1


### 问题详情
GET {{BASE_URL}}questions/637c42c9b517d4d4c692d44e HTTP/1.1

### 新增问题
POST {{BASE_URL}}questions HTTP/1.1
Authorization: Bearer {{token}}
Content-Type: application/json

{
    "title": "我的问题1",
    "description": "我的问题1",
    "topics": ["637c1e9d3d25426a6c8c10d5"]
}

### 修改问题
PATCH {{BASE_URL}}questions/637c42c9b517d4d4c692d44e HTTP/1.1
Authorization: Bearer {{token}}
Content-Type: application/json

{
    "title": "我修改问题",
    "description": "我修改问题",
    "topics": ["637c1fb63d25426a6c8c10e4"]
}


### 删除问题
DELETE {{BASE_URL}}questions/637c1f9b3d25426a6c8c10e8 HTTP
Authorization: Bearer {{token}}

答案模块

  • 答案列表
  • 问题-答案/ 用户-答案 一对多关系
// 答案模型
const mongoose = require('mongoose')
const { Schema, model } = mongoose

const AnswerSchema = new Schema({
  __v: { type: Number, select: false },
  // 答案内容
  content: { type: String, required: true },
  // 答案的创建者
  answerer: { type: Schema.Types.ObjectId, ref: 'User', required: true },
  // 答案的问题
  questionId: { type: String, required: true },
  // 答案的点赞数
  voteCount: { type: Number, required: true, default: 0 },
})

module.exports = model('Answer', AnswerSchema)
// 答案控制器
const Answer = require('../models/answers')

class AnswersCtl {
  // 答案列表
  async find(ctx) {
    const { per_page = 10 } = ctx.query
    const page = Math.max(ctx.query.page * 1, 1) - 1
    const perPage = Math.max(per_page * 1, 1)
    const q = new RegExp(ctx.query.q)
    ctx.body = await Answer.find({
      content: q,
      questionId: ctx.params.questionId,
    })
      .limit(perPage)
      .skip(page * perPage)
  }

  // 根据id查询答案
  async findById(ctx) {
    const answer = await Answer.findById(ctx.params.id).populate('answerer')
    ctx.body = answer
  }

  // 创建答案
  async create(ctx) {
    ctx.verifyParams({
      content: { type: 'string', required: true },
    })
    const answer = await new Answer({
      ...ctx.request.body,
      answerer: ctx.state.user._id,
      questionId: ctx.params.questionId,
    }).save()
    ctx.body = answer
  }

  // 修改答案
  async update(ctx) {
    ctx.verifyParams({
      content: { type: 'string', required: false },
    })
    const answer = await Answer.findByIdAndUpdate(
      ctx.params.id,
      ctx.request.body
    )
    ctx.body = answer
  }

  // 删除答案
  async delete(ctx) {
    const answer = await Answer.findByIdAndRemove(ctx.params.id)
    ctx.status = 204
  }

  // 检查答案是否存在
  async checkAnswerExist(ctx, next) {
    const answer = await Answer.findById(ctx.params.id).select('+answerer')
    if (!answer) {
      ctx.throw(404, '答案不存在')
    }
    await next()
  }

  // 检查答案是否是当前用户
  async checkAnswerer(ctx, next) {
    const answer = await Answer.findById(ctx.params.id).select('+answerer')
    if (answer.answerer.toString() !== ctx.state.user._id) {
      ctx.throw(403, '没有权限')
    }
    await next()
  }
}

module.exports = new AnswersCtl()
// 答案路由
const Router = require('koa-router')
const koajwt = require('koa-jwt')
const router = new Router({ prefix: '/questions/:questionId/answers' })

const {
  find,
  findById,
  create,
  update,
  delete: del,
  checkAnswerExist,
  checkAnswerer,
} = require('../controllers/answers')

const { secret } = require('../config')

const auth = koajwt({ secret })

// 获取答案列表
router.get('/', find)

// 获取特定答案
router.get('/:id', checkAnswerExist, findById)

// 创建答案
router.post('/', auth, create)

// 修改答案
router.patch('/:id', auth, checkAnswerExist, checkAnswerer, update)

// 删除答案
router.delete('/:id', auth, checkAnswerExist, checkAnswerer, del)

module.exports = router
  • 赞同/踩 答案 (6 个接口,注意互斥)
  // 赞过的答案
  likingAnswers: {
    type: [{ type: Schema.Types.ObjectId, ref: 'Answer' }],
    select: false,
  },
  // 踩过的答案
  dislikingAnswers: {
    type: [{ type: Schema.Types.ObjectId, ref: 'Answer' }],
    select: false,
  },
// 获取用户的点赞列表
  async listLikingAnswers(ctx) {
    const user = await User.findById(ctx.params.id)
      .select('+likingAnswers')
      .populate('likingAnswers')
    if (!user) {
      ctx.throw(404)
    }
    ctx.body = user.likingAnswers
  }
  // 点赞答案
  async likeAnswer(ctx, next) {
    const me = await User.findById(ctx.state.user._id).select('+likingAnswers')
    if (!me.likingAnswers.map((id) => id.toString()).includes(ctx.params.id)) {
      me.likingAnswers.push(ctx.params.id)
      me.save()
    }
    ctx.status = 204
    await next()
  }

  // 取消点赞答案
  async unlikeAnswer(ctx) {
    const me = await User.findById(ctx.state.user._id).select('+likingAnswers')
    const index = me.likingAnswers
      .map((id) => id.toString())
      .indexOf(ctx.params.id)
    if (index > -1) {
      me.likingAnswers.splice(index, 1)
      me.save()
    }
    ctx.status = 204
  }

  // 获取用户的踩列表
  async listDislikingAnswers(ctx) {
    const user = await User.findById(ctx.params.id)
      .select('+dislikingAnswers')
      .populate('dislikingAnswers')
    if (!user) {
      ctx.throw(404)
    }
    ctx.body = user.dislikingAnswers
  }
  // 踩答案
  async dislikeAnswer(ctx, next) {
    const me = await User.findById(ctx.state.user._id).select(
      '+dislikingAnswers'
    )
    if (
      !me.dislikingAnswers.map((id) => id.toString()).includes(ctx.params.id)
    ) {
      me.dislikingAnswers.push(ctx.params.id)
      me.save()
    }
    ctx.status = 204
    await next()
  }

  // 取消踩答案
  async undislikeAnswer(ctx) {
    const me = await User.findById(ctx.state.user._id).select(
      '+dislikingAnswers'
    )
    const index = me.dislikingAnswers
      .map((id) => id.toString())
      .indexOf(ctx.params.id)
    if (index > -1) {
      me.dislikingAnswers.splice(index, 1)
      me.save()
    }
    ctx.status = 204
  }
const {
  find,
  findById,
  create,
  update,
  del,
  login,
  checkOwner,
  listFollowing,
  listFollowers,
  checkUserExist,
  follow,
  unfollow,
  followTopic,
  unfollowTopic,
  listFollowingTopics,
  listQuestions,

  listLikingAnswers,
  likeAnswer,
  unlikeAnswer,

  listDislikingAnswers,
  dislikeAnswer,
  undislikeAnswer,

} = require('../controllers/users')

...

// 用户点赞答案列表
router.get('/:id/likingAnswers', listLikingAnswers)

// 用户点赞答案
router.put(
  '/likingAnswers/:id',
  authToken,
  checkAnswerExist,
  likeAnswer,
  undislikeAnswer
)

// 用户取消点赞答案
router.delete('/likingAnswers/:id', authToken, checkAnswerExist, unlikeAnswer)

// 用户踩答案列表
router.get('/:id/dislikingAnswers', listDislikingAnswers)

// 用户踩答案
router.put(
  '/dislikingAnswers/:id',
  authToken,
  checkAnswerExist,
  dislikeAnswer,
  unlikeAnswer
)

// 用户取消踩答案
router.delete(
  '/dislikingAnswers/:id',
  authToken,
  checkAnswerExist,
  undislikeAnswer
)
  • 收藏答案
  // 收藏的答案
  collectingAnswers: {
    type: [{ type: Schema.Types.ObjectId, ref: 'Answer' }],
    select: false,
  },
// 获取用户的收藏列表
  async listCollectingAnswers(ctx) {
    const user = await User.findById(ctx.params.id)
      .select('+collectingAnswers')
      .populate('collectingAnswers')
    if (!user) {
      ctx.throw(404)
    }
    ctx.body = user.collectingAnswers
  }
  // 收藏答案
  async collectAnswer(ctx) {
    const me = await User.findById(ctx.state.user._id).select(
      '+collectingAnswers'
    )
    if (
      !me.collectingAnswers.map((id) => id.toString()).includes(ctx.params.id)
    ) {
      me.collectingAnswers.push(ctx.params.id)
      me.save()
    }
    ctx.status = 204
  }

  // 取消收藏答案
  async uncollectAnswer(ctx) {
    const me = await User.findById(ctx.state.user._id).select(
      '+collectingAnswers'
    )
    const index = me.collectingAnswers
      .map((id) => id.toString())
      .indexOf(ctx.params.id)
    if (index > -1) {
      me.collectingAnswers.splice(index, 1)
      me.save()
    }
    ctx.status = 204
  }
const {
  ...
  listCollectingAnswers,
  collectAnswer,
  uncollectAnswer,
} = require('../controllers/users')

...

// 用户收藏答案列表
router.get('/:id/collectingAnswers', listCollectingAnswers)

// 用户收藏答案
router.put('/collectingAnswers/:id', authToken, checkAnswerExist, collectAnswer)

// 用户取消收藏答案
router.delete(
  '/collectingAnswers/:id',
  authToken,
  checkAnswerExist,
  uncollectAnswer
)

评论模块

  • 评论的增删改查
  • 答案-评论/问题-评论/用户-评论 一对多关系
  • 一级评论与二级评论 (增加连个字段,replyTo, rootCommentId)
// 评论模型
const mongoose = require('mongoose')
const { Schema, model } = mongoose

const CommentSchema = new Schema(
  {
    // 评论的内容
    content: { type: String, required: true },
    // 评论的创建者
    commentator: { type: Schema.Types.ObjectId, ref: 'User', required: true },
    // 评论的问题
    questionId: { type: String, required: true },
    // 评论的答案
    answerId: { type: String, required: true },
    // 评论的点赞数
    voteCount: { type: Number, required: true, default: 0 },
    // 评论的回复
    replyTo: { type: Schema.Types.ObjectId, ref: 'User' },
    // 根评论
    rootCommentId: { type: String },
  },
  { timestamps: true }
)

module.exports = model('Comment', CommentSchema)
// 评论控制器
const Comment = require('../models/comments')

class CommentsCtl {
  // 评论列表
  async find(ctx) {
    const { per_page = 10 } = ctx.query
    const page = Math.max(ctx.query.page * 1, 1) - 1
    const perPage = Math.max(per_page * 1, 1)
    const q = new RegExp(ctx.query.q)
    const { rootCommentId, replyTo } = ctx.query
    ctx.body = await Comment.find({
      content: q,
      questionId: ctx.params.questionId,
      answerId: ctx.params.answerId,
      rootCommentId,
    })
      .limit(perPage)
      .skip(page * perPage)
      .populate('commentator replyTo')
  }

  // 根据id查询评论
  async findById(ctx) {
    const comment = await Comment.findById(ctx.params.id).populate(
      'commentator'
    )
    ctx.body = comment
  }

  // 创建评论
  async create(ctx) {
    ctx.verifyParams({
      content: { type: 'string', required: true },
      rootCommentId: { type: 'string', required: false },
      replyTo: { type: 'string', required: false },
    })
    const comment = await new Comment({
      ...ctx.request.body,
      commentator: ctx.state.user._id,
      questionId: ctx.params.questionId,
      answerId: ctx.params.answerId,
    }).save()
    ctx.body = comment
  }

  // 修改评论
  async update(ctx) {
    ctx.verifyParams({
      content: { type: 'string', required: false },
    })
    const { content } = ctx.request.body
    const comment = await Comment.findByIdAndUpdate(ctx.params.id, content)
    ctx.body = comment
  }

  // 删除评论
  async delete(ctx) {
    const comment = await Comment.findByIdAndRemove(ctx.params.id)
    ctx.status = 204
  }
}

module.exports = new CommentsCtl()
// 评论路由
const Router = require('koa-router')
const koajwt = require('koa-jwt')
const { secret } = require('../config')
const router = new Router({
  prefix: '/questions/:questionId/answers/:answerId/comments',
})

const auth = koajwt({ secret })

const {
  find,
  findById,
  create,
  update,
  delete: del,
} = require('../controllers/comments')

// 获取评论列表
router.get('/', find)
// 获取特定评论
router.get('/:id', findById)
// 创建评论
router.post('/', create)
// 修改评论
router.patch('/:id', auth, update)
// 删除评论
router.delete('/:id', auth, del)

module.exports = router
  • 评论的点赞与踩 (略)

参考资料