先前我们已经创建好了一个基础的Koa
工程,接下来我们来给它加上数据库功能
本例子将使用Mongoose
连接数据库
创建数据库服务
我这里使用的是一个免费的线上服务进行临时测试,具体可以根据自己手头上的资源进行选择
创建完毕后将相关配置信息填入环境变量文件
MongoDB
sh
pnpm add mongoose
连接数据库
编辑src/index.ts
ts
import './env'
import 'reflect-metadata'
import app from './app'
import mongoose from 'mongoose'
import { logger } from './utils'
const url = process.env.MONGODB_URL as string
mongoose
.connect(url)
.then(() => {
console.log('数据库连接成功')
const PORT = process.env.APP_PORT ?? 3000
app.listen(PORT, () => {
logger.info(`
------------
Server Started!
App is running in ${app.env} mode
Logging initialized at ${process.env.LOG_LEVEL ?? 'debug'} level
Http: http://localhost:${PORT}
API Docs: http://localhost:${PORT}/api/swagger-html
API Spec: http://localhost:${PORT}/api/swagger-json
------------
`)
})
})
.catch((err) => {
console.error('数据库连接失败', err)
})
定义模型
新建模型src/schemas/user.schema.ts
ts
import mongoose from 'mongoose'
import bcrypt from 'bcryptjs'
import dayjs from 'dayjs'
export interface User {
id: string
username: string
password: string
email: string
lock_token?: string
comparePassword(password: string): boolean
}
type UserDocument = mongoose.Document & User
const userSchema = new mongoose.Schema(
{
username: {
type: String,
required: true,
trim: true,
},
password: {
type: String,
required: true,
},
email: {
type: String,
required: true,
unique: true,
trim: true,
},
lock_token: {
type: String,
default: null,
},
createdAt: Number,
updatedAt: Number,
},
{ versionKey: false, timestamps: { currentTime: () => Date.now() } }
)
userSchema.pre('save', function (next) {
if (!this.isModified('password')) return next()
bcrypt.genSalt(10, (err, salt) => {
if (err) return next(err)
bcrypt.hash(this.password, salt, (err, hash) => {
if (err) return next(err)
this.password = hash
next()
})
})
})
userSchema.virtual('gmt_created').get(function () {
return dayjs(this.createdAt).format('YYYY-MM-DD HH:mm:ss')
})
userSchema.virtual('gmt_updated').get(function () {
return dayjs(this.updatedAt).format('YYYY-MM-DD HH:mm:ss')
})
userSchema.methods.comparePassword = function (password: string) {
return bcrypt.compareSync(password, this.password)
}
// 使用webpack打包时必须要显示声明表名称,否则压缩代码后模型名改变导致自动推断的表名称跟着改变
const UserRepository = mongoose.model<UserDocument>('User', userSchema, 'user')
export default UserRepository
CURD
服务模块
新建src/services/base.serv.ts
和src/services/auth.serv.ts
,用来操作数据模型层
ts
import type { Document, Model } from 'mongoose'
export abstract class BaseService<T> {
abstract Repository: Model<Document & T>
async findAll() {
return this.Repository.find()
}
async findOne(query: any) {
return this.Repository.findOne(query)
}
async findById(id: string) {
return this.Repository.findById(id)
}
async save(data: any) {
const doc = await this.Repository.create(data)
return doc.toObject({ virtuals: true })
}
async deleteOne(id: string) {
return this.Repository.deleteOne({ _id: id })
}
}
ts
import bcrypt from 'bcryptjs'
import UserRepository, { User } from '../schemas/user.schema'
import { BaseService } from './base.serv'
class UserService extends BaseService<User> {
Repository = UserRepository
async findByNameOrEmail(username: string, email: string) {
return this.Repository.findOne({ $or: [{ username }, { email }] })
}
comparePassword(password: string, hash: string) {
return bcrypt.compareSync(password, hash)
}
}
export default new UserService()
控制器模块
编辑src/dto/auth.ts
,补充注册接口的验证规则
ts
import { Length, IsNotEmpty, IsString, IsEmail } from 'class-validator'
// ...
export class SignUpDto {
@Length(4, 20, { message: '用户名长度为4-20' })
@IsString({ message: '用户名必须为字符串' })
@IsNotEmpty({ message: '用户名不能为空' })
username: string
@IsString({ message: '密码必须为字符串' })
@IsNotEmpty({ message: '密码不能为空' })
password: string
@IsString({ message: '邮箱必须为字符串' })
@IsNotEmpty({ message: '邮箱不能为空' })
@IsEmail({}, { message: '邮箱格式不正确' })
email: string
}
编辑src/controllers/auth.ctrl.ts
,把之前的模拟数据删掉,改成操作数据库和Redis
ts
import { request, summary, body, middlewares, tagsAll } from 'koa-swagger-decorator'
import jwt from 'jsonwebtoken'
import UserService from '../services/user.serv'
import { genToken, Redis, Success, Failed, HttpException } from '../utils'
import { ValidateContext, validator } from '../middlewares'
import { SignUpDto, SignInDto, TokenDto } from '../dto'
@tagsAll(['Auth'])
export default class AuthController {
@request('post', '/signup')
@summary('注册接口')
@middlewares([validator(SignUpDto)])
@body({
username: { type: 'string', required: true, example: 'admin' },
password: { type: 'string', required: true, example: '123456' },
email: { type: 'string', required: true, example: 'admin@example.com' },
})
async signUp(ctx: ValidateContext) {
// 1.检查邮箱是否已存在
if (await UserService.findOne({ email: ctx.dto.email })) {
throw new Failed({ msg: '该邮箱已被注册' })
} else {
const user = await UserService.save(ctx.dto)
const { _id, password, lock_token, ...rest } = user
const accessToken = genToken(rest)
const refreshToken = genToken(rest, 'REFRESH', '1d')
// 2.将token保存到redis中
await Redis.set(`${rest.id}:token`, JSON.stringify([refreshToken]), 24 * 60 * 60)
throw new Success({
status: 201,
msg: '注册成功',
data: { user: rest, accessToken, refreshToken },
})
}
}
@request('post', '/signin')
@summary('登录接口')
@middlewares([validator(SignInDto)])
@body({
username: { type: 'string', required: true, example: 'admin' },
password: { type: 'string', required: true, example: '123456' },
})
async signIn(ctx: ValidateContext) {
const doc = await UserService.findOne({ username: ctx.dto.username })
// 1.检查用户是否存在
if (!doc) {
throw new HttpException('not_found', { msg: '用户不存在' })
}
// 2.校验用户密码
if (!doc.comparePassword(ctx.dto.password)) {
throw new HttpException('auth_denied', { msg: '密码错误' })
}
// 3.生成token
const user = doc.toObject({ virtuals: true })
const { _id, password, lock_token, ...rest } = user
const accessToken = genToken(rest)
const refreshToken = genToken(rest, 'REFRESH', '1d')
// 4.拿到redis中的token
const refreshTokens: string[] = JSON.parse(await Redis.get(`${rest.id}:token`)) ?? []
// 5.将刷新token保存到redis中
refreshTokens.push(refreshToken)
await Redis.set(`${rest.id}:token`, JSON.stringify(refreshTokens), 24 * 60 * 60)
throw new Success({ msg: '登录成功', data: { accessToken, refreshToken } })
}
@request('put', '/token')
@summary('刷新token')
@middlewares([validator(TokenDto)])
@body({
token: { type: 'string', required: true, example: 'asdasd' },
})
async token(ctx: ValidateContext) {
// 1.先检查前端是否有提交token
if (!ctx.dto.token) {
throw new HttpException('unauthorized')
}
// 2.解析token中的用户信息
let user: any
jwt.verify(ctx.dto.token, process.env.REFRESH_TOKEN_SECRET ?? 'secret', (err, decode) => {
if (err) {
throw new HttpException('forbidden', { msg: '无效令牌,请重新登录' })
}
user = decode
})
// 3.拿到缓存中的token
let refreshTokens: string[] = JSON.parse(await Redis.get(`${user.id}:token`)) ?? []
// 4.再检查此用户在redis中是否有此token
if (!refreshTokens.includes(ctx.dto.token)) {
throw new HttpException('forbidden', { msg: '无效令牌,请重新登录' })
}
// 5.生成新的token
const { iat, exp, ...rest } = user
const accessToken = genToken(rest)
const refreshToken = genToken(rest, 'REFRESH', '1d')
// 6.将刷新token保存到redis中
refreshTokens = refreshTokens.filter((token) => token !== ctx.dto.token).concat([refreshToken])
await Redis.set(`${rest.id}:token`, JSON.stringify(refreshTokens), 24 * 60 * 60)
throw new Success({ msg: '刷新token成功', data: { accessToken, refreshToken } })
}
@request('delete', '/logout')
@summary('退出')
@middlewares([validator(TokenDto)])
@body({
token: { type: 'string', required: true, example: 'asdasd' },
})
async logout(ctx: ValidateContext) {
// 1.先检查前端是否有提交token
if (!ctx.dto.token) {
throw new HttpException('unauthorized')
}
// 2.解析token中的用户信息
let user: any
jwt.verify(ctx.dto.token, process.env.REFRESH_TOKEN_SECRET ?? 'secret', (err, decode) => {
if (err) {
throw new HttpException('forbidden', { msg: '无效令牌,请重新登录' })
}
user = decode
})
// 3.拿到缓存中的token
let refreshTokens: string[] = JSON.parse(await Redis.get(`${user.id}:token`)) ?? []
// 4.再检查此用户在redis中是否有此token
if (!refreshTokens.includes(ctx.dto.token)) {
throw new HttpException('forbidden', { msg: '无效令牌,请重新登录' })
}
// 5.移除redis中保存的此客户端token
refreshTokens = refreshTokens.filter((token) => token !== ctx.dto.token)
await Redis.set(`${user.id}:token`, JSON.stringify(refreshTokens), 24 * 60 * 60)
throw new Success({ status: 204, msg: '退出成功' })
}
}
export const authController = new AuthController()