Node.js零基础到项目实战 Express+MySQL+Sequelize (二)

一、数据库设计

可视化数据库设计MySQL Workbench

二、建立所有的表

1. 回滚迁移 sequelize db:migrate:undo 运行命令后,会回滚上一次运行的迁移

2. 创建文章表模型文件 sequelize model:generate --name Article --attributes title:string,content:text

js 复制代码
// 文章迁移文件 migrations/***-create-article.js
async up(queryInterface, Sequelize) {
    await queryInterface.createTable('Articles', {
      id: {
        allowNull: false,
        autoIncrement: true,
        primaryKey: true,
        type: Sequelize.INTEGER.UNSIGNED // 无符号的,非负数
      },
      title: {
        type: Sequelize.STRING,
        allowNull: false 
      },
      content: {
        type: Sequelize.TEXT
      },
      createdAt: {
        allowNull: false,
        type: Sequelize.DATE
      },
      updatedAt: {
        allowNull: false,
        type: Sequelize.DATE
      }
    });
  },

3. 创建分类表模型文件 sequelize model:generate --name Category --attributes name:string,rank:integer

js 复制代码
async up(queryInterface, Sequelize) {
    await queryInterface.createTable('Categories', {
      id: {
        allowNull: false,
        autoIncrement: true,
        primaryKey: true,
        type: Sequelize.INTEGER.UNSIGNED
      },
      name: {
        type: Sequelize.STRING,
        allowNull: false
      },
      rank: {
        type: Sequelize.INTEGER.UNSIGNED,
        defaultValue: 1, // 默认值为1
        allowNull: false // 不能为空
      },
      createdAt: {
        allowNull: false,
        type: Sequelize.DATE
      },
      updatedAt: {
        allowNull: false,
        type: Sequelize.DATE
      }
    });
  },

4. 创建用户表模型文件 sequelize model:generate --name User --attributes email:string,username:string,password:string,nickname:string,sex:tinyint,company:string,introduce:text,role:tinyint

js 复制代码
async up(queryInterface, Sequelize) {
    await queryInterface.createTable('Users', {
      id: {
        allowNull: false,
        autoIncrement: true,
        primaryKey: true,
        type: Sequelize.INTEGER.UNSIGNED
      },
      email: {
        type: Sequelize.STRING,
        allowNull: false
      },
      username: {
        type: Sequelize.STRING,
        allowNull: false
      },
      password: {
        type: Sequelize.STRING,
        allowNull: false
      },
      nickname: {
        type: Sequelize.STRING,
        allowNull: false
      },
      sex: {
        type: Sequelize.TINYINT.UNSIGNED,
        defaultValue: 0,
        allowNull: false
      },
      company: {
        type: Sequelize.STRING,
      },
      introduce: {
        type: Sequelize.TEXT
      },
      role: {
        type: Sequelize.TINYINT.UNSIGNED,
        defaultValue: 0,
        allowNull: false
      },
      createdAt: {
        allowNull: false,
        type: Sequelize.DATE
      },
      updatedAt: {
        allowNull: false,
        type: Sequelize.DATE
      }
    });
    await queryInterface.addIndex('Users', ['email'], {
      unique: true
    });
    await queryInterface.addIndex('Users', ['username'], {
      unique: true
    });
    await queryInterface.addIndex('Users', ['role']);
  },

5. 创建课程表模型文件 sequelize model:generate --name Course --attributes categoryId:integer,userId:integer,name:string,image:string,recommended:boolean,introductory:boolean,content:text,likesCount:integer,chaptersCount:integer

js 复制代码
async up(queryInterface, Sequelize) {
    await queryInterface.createTable('Courses', {
      id: {
        allowNull: false,
        autoIncrement: true,
        primaryKey: true,
        type: Sequelize.INTEGER.UNSIGNED
      },
      categoryId: {
        type: Sequelize.INTEGER.UNSIGNED,
        allowNull: false
      },
      userId: {
        type: Sequelize.INTEGER.UNSIGNED,
        allowNull: false
      },
      name: {
        type: Sequelize.STRING,
        allowNull: false
      },
      image: {
        type: Sequelize.STRING
      },
      recommended: {
        type: Sequelize.BOOLEAN
      },
      introductory: {
        type: Sequelize.BOOLEAN
      },
      content: {
        type: Sequelize.TEXT
      },
      likesCount: {
        type: Sequelize.INTEGER.UNSIGNED,
        defaultValue: 0,
        allowNull: false
      },
      chaptersCount: {
        type: Sequelize.INTEGER.UNSIGNED,
        defaultValue: 0,
        allowNull: false
      },
      createdAt: {
        allowNull: false,
        type: Sequelize.DATE
      },
      updatedAt: {
        allowNull: false,
        type: Sequelize.DATE
      }
    });
    await queryInterface.addIndex('Courses', ['categoryId']); // 建立普通索引
    await queryInterface.addIndex('Courses', ['userId']);
  },

6. 创建章节表模型文件 sequelize model:generate --name Chapter --attributes courseId:integer,title:string,content:text,video:string,rank:integer

js 复制代码
async up(queryInterface, Sequelize) {
    await queryInterface.createTable('Chapters', {
      id: {
        allowNull: false,
        autoIncrement: true,
        primaryKey: true,
        type: Sequelize.INTEGER.UNSIGNED
      },
      courseId: {
        type: Sequelize.INTEGER.UNSIGNED,
        allowNull: false
      },
      title: {
        type: Sequelize.STRING,
        allowNull: false
      },
      content: {
        type: Sequelize.TEXT
      },
      video: {
        type: Sequelize.STRING
      },
      rank: {
        type: Sequelize.INTEGER.UNSIGNED,
        allowNull: false,
        defaultValue: 0
      },
      createdAt: {
        allowNull: false,
        type: Sequelize.DATE
      },
      updatedAt: {
        allowNull: false,
        type: Sequelize.DATE
      }
    });
    await queryInterface.addIndex('Chapters', ['courseId']);
  },

7. 创建点赞表模型文件 sequelize model:generate --name Like --attributes courseId:integer,userId:integer

js 复制代码
async up(queryInterface, Sequelize) {
    await queryInterface.createTable('Likes', {
      id: {
        allowNull: false,
        autoIncrement: true,
        primaryKey: true,
        type: Sequelize.INTEGER.UNSIGNED
      },
      courseId: {
        type: Sequelize.INTEGER.UNSIGNED,
        allowNull: false
      },
      userId: {
        type: Sequelize.INTEGER.UNSIGNED,
        allowNull: false
      },
      createdAt: {
        allowNull: false,
        type: Sequelize.DATE
      },
      updatedAt: {
        allowNull: false,
        type: Sequelize.DATE
      }
    });
    await queryInterface.addIndex('Likes', ['courseId']);
    await queryInterface.addIndex('Likes', ['userId']);
  },

7. 创建系统设置表 sequelize model:generate --name Setting --attributes name:string,icp:string,copyright:string

js 复制代码
async up(queryInterface, Sequelize) {
    await queryInterface.createTable('Settings', {
      id: {
        allowNull: false,
        autoIncrement: true,
        primaryKey: true,
        type: Sequelize.INTEGER.UNSIGNED
      },
      name: {
        type: Sequelize.STRING
      },
      icp: {
        type: Sequelize.STRING
      },
      copyright: {
        type: Sequelize.STRING
      },
      createdAt: {
        allowNull: false,
        type: Sequelize.DATE
      },
      updatedAt: {
        allowNull: false,
        type: Sequelize.DATE
      }
    });
  },

8. 建立所有的表 sequelize db:migrate

三、分类接口

1. 创建分类的种子文件 sequelize seed:generate --name category

js 复制代码
// seeders/20250829083444-category.js
async up (queryInterface, Sequelize) {
    await queryInterface.bulkInsert('Categories', [{
      name: '前端开发',
      rank: 1,
      createdAt: new Date(),
      updatedAt: new Date()
    },
    {
      name: '后端开发',
      rank: 2,
      createdAt: new Date(),
      updatedAt: new Date()
    },
    {
      name: '移动端开发',
      rank: 3,
      createdAt: new Date(),
      updatedAt: new Date()
    },
    {
      name: '数据库',
      rank: 4,
      createdAt: new Date(),
      updatedAt: new Date()
    },
    {
      name: '服务器运维',
      rank: 5,
      createdAt: new Date(),
      updatedAt: new Date()
    },
    {
      name: '公共',
      rank: 6,
      createdAt: new Date(),
      updatedAt: new Date()
    },
  ], {});
  },
  async down (queryInterface, Sequelize) {
    await queryInterface.bulkDelete('Categories', null, {});
  }

2. 执行种子文件命令 sequelize db:seed --seed 20250829083444-category

3. 修改分类的模型文件,添加验证

js 复制代码
// models/category.js
Category.init({
    name: {
      type: DataTypes.STRING,
      allowNull: false,
      validate: {
        notNull: {
          msg: '名称必须存在'
        },
        notEmpty: {
          msg: '名称不能为空'
        },
        len: {
          args:[2,45],
          msg: '名称长度需要在2~45个字符之间'
        },
        async isUnique(value) {
          const category = await Category.findOne({
            where: {
              name: value
            }
          })
          if (category) {
            throw new Error('名称已存在,请重新输入')
          }
        }
      }
    },
    rank: {
      type: DataTypes.INTEGER,
      allowNull: false,
      validate: {
        notNull: {
          msg: '排序必须存在'
        },
        notEmpty: {
          msg: '排序不能为空'
        },
        isInt: {
          msg: '排序必须是整数'
        },
        isPositive(value) {
          if (value <= 0) {
            throw new Error('排序必须是正数');
          }
        }
      }
    }
  }, {
    sequelize,
    modelName: 'Category',
  });

4. 添加分类的路由文件

js 复制代码
// routes/admin/categories.js
const express = require('express');
const router = express.Router();
const {Category} = require('../../models')
const {Op} = require('sequelize')
const {NotFoundError, success, failure} = require('../../utils/response')

router.get('/', async function(req, res, next) {
    try {

        const query = req.query
        const currentPage = Math.abs(Number(query.currentPage)) || 1
        const pageSize = Math.abs(Number(query.pageSize)) || 10
        const offset = (currentPage - 1) * pageSize

        const condition = {
            order: [['rank', 'ASC'],['id', 'ASC']],
            limit: pageSize,
            offset
        }

        if(query.name) {
            condition.where = {
                name: {
                    [Op.like]: `%${query.name}%`
                }
            }
        }

        const {count, rows} = await Category.findAndCountAll(condition)
        success(res, '查询分类列表成功', {
            categories: rows,
            pagination: {
                total: count,
                currentPage,
                pageSize
            }
        })
    }catch(error) {
        failure(res, error)
    }
});

router.get('/:id', async function(req, res, next) {
    try {
        const category = await getCategory(req)
        success(res, '查询分类成功', {category})
    } catch(error) {
        failure(res, error)
    }
})

router.post('/', async function(req, res, next) {
    try {
        const body = filterBody(req)
        const category = await Category.create(body)
        success(res, '创建分类成功', {category}, 201)
    }catch(error) {
        failure(res, error)
    }
});

router.delete('/:id', async function(req, res, next) {
    try {
        const category = await getCategory(req)
        await category.destroy()
        success(res, '删除分类成功')
    } catch(error) {
        failure(res, error)
    }
})

router.put('/:id', async function(req, res, next) {
    try {

        const body = filterBody(req)
        const category = await getCategory(req)
        await category.update(body)
        success(res, '更新分类成功')
    } catch(error) {
        failure(res, error)
    }
})

async function getCategory(req) {
    const {id} = req.params
    const category = await Category.findByPk(id)
    if(!category) {
        throw new NotFoundError(`ID: ${id}的分类未找到。`)
    }

    return category
} 

function filterBody(req) {
    return {
        name: req.body.name,
        rank: req.body.rank
    }
}
module.exports = router;

5. 引入分类的路由文件

js 复制代码
// app.js
const adminCategoriesRouter = require('./routes/admin/categories');
app.use('/admin/categories', adminCategoriesRouter);

四、系统设置接口

1. 创建系统设置种子文件 sequelize seed:generate --name setting

js 复制代码
// seeders/20250829101031-setting.js
async up (queryInterface, Sequelize) {
    await queryInterface.bulkInsert('Settings', [{
      name: '长乐未央',
      icp: 'ICP备123456789号',
      copyright: 'Copyright © 2025 长乐未央',
      createdAt: new Date(),
      updatedAt: new Date()
    }], {});
  },

  async down (queryInterface, Sequelize) {
    await queryInterface.bulkDelete('Settings', null, {});
  }

2. 执行种子命令 sequelize db:seed --seed 20250829101031-setting

3. 添加系统设置的路由文件

js 复制代码
// routes/admin/settings.js
const express = require('express');
const router = express.Router();
const {Setting} = require('../../models')
const {NotFoundError, success, failure} = require('../../utils/response')

router.get('/', async function(req, res, next) {
    try {
        const setting = await getSetting()
        success(res, '查询系统设置成功', {setting})
    } catch(error) {
        failure(res, error)
    }
})

router.put('/', async function(req, res, next) {
    try {

        const body = filterBody(req)
        const setting = await getSetting()
        await setting.update(body)
        success(res, '更新系统设置成功')
    } catch(error) {
        failure(res, error)
    }
})

async function getSetting() {
    const setting = await Setting.findOne()
    if(!setting) {
        throw new NotFoundError(`ID: ${id}的系统设置未找到。`)
    }

    return setting
} 

function filterBody(req) {
    return {
        name: req.body.name,
        icp: req.body.icp,
        copyright: req.body.copyright
    }
}
module.exports = router;

4. 引入系统设置的路由文件

js 复制代码
// app.js
const adminSettingsRouter = require('./routes/admin/settings');
app.use('/admin/settings', adminSettingsRouter);

五、用户管理接口

1. 发现用户头像字段没建,新增另一个迁移,给用户表增加头像字段

sequelize migration:create --name add-avatar-to-user

2. 修改新增迁移文件

js 复制代码
// 用户表的另一个迁移文件 migrations/**-add-avatar-to-user.js
module.exports = {
  async up (queryInterface, Sequelize) {
    await queryInterface.addColumn('Users', 'avatar', {
      type: Sequelize.STRING
    })
  },

  async down (queryInterface, Sequelize) {
    await queryInterface.removeColumn('Users', 'avatar')
  }
};

3. 执行迁移命令 sequelize db:migrate

4. 在 user.js 手动增加 avatar 字段

js 复制代码
// models/user.js
User.init({
    avatar: DataTypes.STRING
})

5. 创建用户的种子文件 sequelize seed:generate --name user

js 复制代码
async up (queryInterface, Sequelize) {
    await queryInterface.bulkInsert('Users', [
      {
        email: 'admin@example.com',
        username: 'admin',
        password: '123456',
        nickname: '管理员',
        sex: 2,
        role: 100,
        createdAt: new Date(),
        updatedAt: new Date(),
      },
      {
        email: 'user@example.com',
        username: 'user',
        password: '123456',
        nickname: '用户',
        sex: 0,
        role: 0,
        createdAt: new Date(),
        updatedAt: new Date(),
      },
      {
        email: 'user2@example.com',
        username: 'user2',
        password: '123456',
        nickname: '用户2',
        sex: 0,
        role: 0,
        createdAt: new Date(),
        updatedAt: new Date(),
      },
      {
        email: 'user3@example.com',
        username: 'user3',
        password: '123456',
        nickname: '用户3',
        sex: 1,
        role: 0,
        createdAt: new Date(),
        updatedAt: new Date(),
      },
    ])
  },

  async down (queryInterface, Sequelize) {
    await queryInterface.bulkDelete('Users', null, {});
  }

6. 执行种子命令 sequelize db:seed --seed 20250901025916-user

7. 修改用户的模型文件,添加验证

js 复制代码
User.init({
    email: {
      type: DataTypes.STRING,
      allowNull: false,
      validate: {
        notNull: {
          msg: '邮箱必须存在'
        },
        notEmpty: {
          msg: '邮箱不能为空'
        },
        isEmail: {
          msg: '邮箱格式错误'
        },
        async isUnique(value) {
          const user = await User.findOne({
            where: {
              email: value
            }
          })
          if (user) {
            throw new Error('邮箱已存在')
          }
        }
      }
    },
    username: {
      type: DataTypes.STRING,
      allowNull: false,
      validate: {
        notNull: {
          msg: '用户名必须存在'
        },
        notEmpty: {
          msg: '用户名不能为空'
        },
        len: {
          args: [2,45],
          msg: '用户名长度需要在2~45个字符之间'
        },
        async isUnique(value) {
          const user = await User.findOne({
            where: {
              username: value
            }
          })
          if (user) {
            throw new Error('用户名已存在')
          }
        }
      }
    },
    password: {
      type: DataTypes.STRING,
      allowNull: false,
      validate: {
        notNull: {
          msg: '密码必须存在'
        },
        notEmpty: {
          msg: '密码不能为空'
        },
        len: {
          args: [6,16],
          msg: '密码长度需要在6~16个字符之间'
        }
      }
    },
    nickname: {
      type: DataTypes.STRING,
      allowNull: false,
      validate: {
        notNull: {
          msg: '昵称必须存在'
        },
        notEmpty: {
          msg: '昵称不能为空'
        },
        len: {
          args: [2,45],
          msg: '昵称长度需要在2~45个字符之间'
        }
      }
    },
    sex: {
      type: DataTypes.TINYINT.UNSIGNED,
      allowNull: false,
      validate: {
        notNull: {
          msg: '性别必须存在'
        },
        notEmpty: {
          msg: '性别不能为空'
        },
        isIn: {
          args: [[0,1,2]],
          msg: '性别必须是男性:0、女性:1、未选择:2'
        }
      }
    },
    company: DataTypes.STRING,
    introduce: DataTypes.TEXT,
    role: {
      type: DataTypes.TINYINT,
      allowNull: false,
      validate: {
        notNull: {
          msg: '角色必须存在'
        },
        notEmpty: {
          msg: '角色不能为空'
        },
        isIn: {
          args: [[0,100]],
          msg: '角色必须是普通用户:0、管理员:100'
        }
      }
    },
    avatar: {
      type: DataTypes.STRING,
      validate: {
        isUrlOrEmpty(value) {
          if (value && typeof value === 'string' && value.trim() !== '') {
            // 使用正则表达式验证URL格式
            const urlRegex = /^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([/\w .-]*)*\/?$/;
            if (!urlRegex.test(value)) {
              throw new Error('头像必须是URL');
            }
          }
        }
      }
    }
  }, {
    sequelize,
    modelName: 'User',
  });

8. 添加用户的路由文件

js 复制代码
// routes/admin/users.js
const express = require('express');
const router = express.Router();
const {User} = require('../../models')
const {Op} = require('sequelize')
const {NotFoundError, success, failure} = require('../../utils/response')

router.get('/', async function(req, res, next) {
    try {

        const query = req.query
        const currentPage = Math.abs(Number(query.currentPage)) || 1
        const pageSize = Math.abs(Number(query.pageSize)) || 10
        const offset = (currentPage - 1) * pageSize

        const condition = {
            order: [['id', 'DESC']],
            limit: pageSize,
            offset
        }
        if(query.email) {
            condition.where = {
                email: {
                    [Op.eq]: query.email
                }
            }
        }
        if(query.username) {
            condition.where = {
                username: {
                    [Op.eq]: query.username
                }
            }
        }
        if(query.nickname) {
            condition.where = {
                nickname: {
                    [Op.like]: `%${query.nickname}%`
                }
            }
        }
        if(query.role) {
            condition.where = {
                role: {
                    [Op.eq]: query.role
                }
            }
        }

        const {count, rows} = await User.findAndCountAll(condition)
        success(res, '查询用户列表成功', {
            users: rows,
            pagination: {
                total: count,
                currentPage,
                pageSize
            }
        })
    }catch(error) {
        failure(res, error)
    }
});

router.get('/:id', async function(req, res, next) {
    try {
        const user = await getUser(req)
        success(res, '查询用户成功', {user})
    } catch(error) {
        failure(res, error)
    }
})

router.post('/', async function(req, res, next) {
    try {
        const body = filterBody(req)
        const user = await User.create(body)
        success(res, '创建用户成功', {user}, 201)
    }catch(error) {
        failure(res, error)
    }
});

router.put('/:id', async function(req, res, next) {
    try {

        const body = filterBody(req)
        const user = await getUser(req)
        await user.update(body)
        success(res, '更新用户成功')
    } catch(error) {
        failure(res, error)
    }
})

async function getUser(req) {
    const {id} = req.params
    const user = await User.findByPk(id)
    if(!user) {
        throw new NotFoundError(`ID: ${id}的用户未找到。`)
    }

    return user
} 

function filterBody(req) {
    return {
        email: req.body.email,
        username: req.body.username,
        password: req.body.password,
        nickname: req.body.nickname,
        role: req.body.role,
        avatar: req.body.avatar,
        sex: req.body.sex,
        company: req.body.company,
        introduce: req.body.introduce,
    }
}
module.exports = router;

9. 引入用户的路由文件

js 复制代码
// app.js
const adminUsersRouter = require('./routes/admin/users');
app.use('/admin/users', adminUsersRouter);

六、使用 bcryptjs 加密数据

1. 安装 npm i bcryptjs

2. 在用户的模型文件中对用户密码的加密

js 复制代码
// models/user.js
const bcrypt = require('bcryptjs')
password: {
      type: DataTypes.STRING,
      allowNull: false,
      validate: {
        notNull: {
          msg: '密码必须存在'
        },
        notEmpty: {
          msg: '密码不能为空'
        },
      },
      set(value) {
        if(value.length >= 6 && value.length <= 45) {
          const hash = bcrypt.hashSync(value, 10)
          this.setDataValue('password', hash)
        } else {
          throw new Error('密码长度需要在6个字符以上45个字符以下')
        }
      }
    },

3. 在用户的种子文件中对用户密码进行加密

js 复制代码
const bcrypt = require('bcryptjs')
module.exports = {
  async up (queryInterface, Sequelize) {
    await queryInterface.bulkInsert('Users', [
      {
        email: 'admin@example.com',
        username: 'admin',
        password: bcrypt.hashSync('123456', 10),
        nickname: '管理员',
        sex: 2,
        role: 100,
        createdAt: new Date(),
        updatedAt: new Date(),
      },
      {
        email: 'user@example.com',
        username: 'user',
        password: bcrypt.hashSync('123456', 10),
        nickname: '用户',
        sex: 0,
        role: 0,
        createdAt: new Date(),
        updatedAt: new Date(),
      },
      {
        email: 'user2@example.com',
        username: 'user2',
        password: bcrypt.hashSync('123456', 10),
        nickname: '用户2',
        sex: 0,
        role: 0,
        createdAt: new Date(),
        updatedAt: new Date(),
      },
      {
        email: 'user3@example.com',
        username: 'user3',
        password: bcrypt.hashSync('123456', 10),
        nickname: '用户3',
        sex: 1,
        role: 0,
        createdAt: new Date(),
        updatedAt: new Date(),
      },
    ])
  },

  async down (queryInterface, Sequelize) {
    await queryInterface.bulkDelete('Users', null, {});
  }
};

七、课程管理接口(关联模型)

1. 创建种子文件 sequelize seed:generate --name course

js 复制代码
// seeders/20250901064412-course.js
async up (queryInterface, Sequelize) {
    await queryInterface.bulkInsert('Courses', [
      {
        categoryId: 1,
        userId: 1,
        name: '前端课程',
        recommended: true,
        introductory: true,
        createdAt: new Date(),
        updatedAt: new Date(),
      },
      {
        categoryId: 2,
        userId: 1,
        name: 'nodeJs-课程',
        recommended: true,
        introductory: false,
        createdAt: new Date(),
        updatedAt: new Date(),
      },
    ])
  },

  async down (queryInterface, Sequelize) {
    await queryInterface.bulkDelete('Courses', null, {})
  }

2. 执行种子命令 sequelize db:seed --seed 20250901064412-course

3. 添加模型文件中的验证

js 复制代码
Course.init({
    categoryId: {
      type: DataTypes.INTEGER,
      allowNull: false,
      validate: {
        notNull: {
          msg: '分类ID必须存在'
        },
        notEmpty: {
          msg: '分类ID不能为空'
        },
        async isPresent(value) {
          const category = await sequelize.models.Category.findByPk(value)
          if(!category) {
            throw new Error('ID为' + value + '的分类不存在')
          }
        }
      }
    },
    userId: {
      type: DataTypes.INTEGER,
      allowNull: false,
      validate: {
        notNull: {
          msg: '用户ID必须存在'
        },
        notEmpty: {
          msg: '用户ID不能为空'
        },
        async isPresent(value) {
          const user = await sequelize.models.User.findByPk(value)
          if(!user) {
            throw new Error('ID为' + value + '的用户不存在')
          }
        }
      }
    },
    name: {
      type: DataTypes.STRING,
      allowNull: false,
      validate: {
        notNull: {
          msg: '名称必须存在'
        },
        notEmpty: {
          msg: '名称不能为空'
        },
        len: {
          args:[2,45],
          msg: '名称长度需要在2~45个字符之间'
        },
        async isUnique(value) {
          const course = await Course.findOne({
            where: {
              name: value
            }
          })
          if (course) {
            throw new Error('名称已存在')
          }
        }
      }
    },
    image: DataTypes.STRING,
    recommended: DataTypes.BOOLEAN,
    introductory: DataTypes.BOOLEAN,
    content: DataTypes.TEXT,
    likesCount: DataTypes.INTEGER,
    chaptersCount: DataTypes.INTEGER
  }, {
    sequelize,
    modelName: 'Course',
  });

4. 添加课程的路由文件

js 复制代码
// routes/admin/courses.js
const express = require('express');
const router = express.Router();
const {Course, Category, User} = require('../../models')
const {Op} = require('sequelize')
const {NotFoundError, success, failure} = require('../../utils/response')

router.get('/', async function(req, res, next) {
    try {

        const query = req.query
        const currentPage = Math.abs(Number(query.currentPage)) || 1
        const pageSize = Math.abs(Number(query.pageSize)) || 10
        const offset = (currentPage - 1) * pageSize

        const condition = {
            ...getCondition(),
            order: [['id', 'DESC']],
            limit: pageSize,
            offset
        }

        if(query.categoryId) {
            condition.where = {
                categoryId: {
                    [Op.eq]: query.categoryId
                }
            }
        }
        if(query.userId) {
            condition.where = {
                userId: {
                    [Op.eq]: query.userId
                }
            }
        }
        if(query.name) {
            condition.where = {
                name: {
                    [Op.like]: `%${query.name}%`
                }
            }
        }
        if(query.recommended) {
            condition.where = {
                [Op.eq]: query.recommended === 'true'
            }
        }
        if(query.introductory) {
            condition.where = {
                [Op.eq]: query.introductory === 'true'
            }
        }

        const {count, rows} = await Course.findAndCountAll(condition)
        success(res, '查询课程列表成功', {
            courses: rows,
            pagination: {
                total: count,
                currentPage,
                pageSize
            }
        })
    }catch(error) {
        failure(res, error)
    }
});

router.get('/:id', async function(req, res, next) {
    try {
        const course = await getCourse(req)
        success(res, '查询课程成功', {course})
    } catch(error) {
        failure(res, error)
    }
})

router.post('/', async function(req, res, next) {
    try {
        const body = filterBody(req)
        const course = await Course.create(body)
        success(res, '创建课程成功', {course}, 201)
    }catch(error) {
        failure(res, error)
    }
});

router.delete('/:id', async function(req, res, next) {
    try {
        const course = await getCourse(req)
        await course.destroy()
        success(res, '删除课程成功')
    } catch(error) {
        failure(res, error)
    }
})

router.put('/:id', async function(req, res, next) {
    try {

        const body = filterBody(req)
        const course = await getCourse(req)
        await course.update(body)
        success(res, '更新课程成功')
    } catch(error) {
        failure(res, error)
    }
})

function getCondition() {
    return {
        attributes: {
            exclude: ['CategoryId', 'UserId']
        },
        include: [
            {
                model: Category,
                as: 'category',
                attributes: ['id', 'name']
            },
            {
                model: User,
                as: 'user',
                attributes: ['id', 'username', 'avatar']
            }
        ]
    }
}

async function getCourse(req) {
    const {id} = req.params
    const condition = getCondition()
    const course = await Course.findByPk(id, condition)
    if(!course) {
        throw new NotFoundError(`ID: ${id}的课程未找到。`)
    }

    return course
} 

function filterBody(req) {
    return {
        categoryId: req.body.categoryId,
        userId: req.body.userId,
        name: req.body.name,
        image: req.body.image,
        recommended: req.body.recommended,
        introductory: req.body.introductory,
        content: req.body.content,
    }
}
module.exports = router;

5. 引入课程的路由文件

js 复制代码
// app.js
const adminCoursesRouter = require('./routes/admin/courses');  
app.use('/admin/courses', adminCoursesRouter);  

6. 关联模型

js 复制代码
// models/course.js
static associate(models) {
      models.Course.belongsTo(models.Category, {
        as: 'category'
      })
      models.Course.belongsTo(models.User, {
        as: 'user'
      })
    }
    
js 复制代码
// models/category.js
static associate(models) {
      models.Category.hasMany(models.Course, {
        as: 'courses' // 因为分类会有很多课程,这里是复数
      })
    }
js 复制代码
// models/user.js
static associate(models) {
      models.User.hasMany(models.Course, {
        as: 'courses' // 同上,复数
      })
    }
js 复制代码
// routes/admin/courses.js
const {Course, Category, User} = require('../../models')
// 获取分类表和用户表的数据
function getCondition() {
    return {
        attributes: {
            exclude: ['CategoryId', 'UserId']
        },
        include: [
            {
                model: Category,
                as: 'category',
                attributes: ['id', 'name']
            },
            {
                model: User,
                as: 'user',
                attributes: ['id', 'username', 'avatar']
            }
        ]
    }
}

7. 孤儿问题

如果删除分类表中的一条数据,课程表中就会有些数据找不到对应的分类了,这种没有对应表记录的数据,我们称为孤儿数据

解决方法:

  1. 在数据库里,设置 外键约束,删除时就会提示错误。注意:一般企业是不会让使用外键约束,因为使用外键约束后,数据库会产生额外的性能开销。在高并发、数据量大的情况,可能造成性能瓶颈
  2. 删除分类的同时,删除所有关联课程
  3. 只有没有关联课程的分类,才能被删除

我们使用 方法3 解决问题

js 复制代码
// routes/admin/categories.js
const {Category, Course} = require('../../models')

router.delete('/:id', async function(req, res, next) {
    try {
        const category = await getCategory(req)

        const count = await Course.count({
            where: {
                categoryId: req.params.id
            }
        })
        if(count > 0) {
            throw new Error('该分类下有课程,不能删除。')
        }
        await category.destroy()
        success(res, '删除分类成功')
    } catch(error) {
        failure(res, error)
    }
})

8. 表之间的关联关系

有关联字段的表,一定是属于(belongsTo)其他表 例如课程表里有 categoryId,那它就一定是 belongsTo 属于分类。反过来,分类模型里就是 hasMany 有很多课程

八、章节接口(关联模型)

1. 创建种子文件

js 复制代码
async up (queryInterface, Sequelize) {
    await queryInterface.bulkInsert('Chapters', [
    {
      courseId: 1,
      title: 'css 第1章',
      content: '这是第1章的内容',
      video:'',
      rank: 1,
      createdAt: new Date(),
      updatedAt: new Date()
    },
    {
      courseId: 2,
      title: 'node 第1章',
      content: '这是第1章的内容',
      video:'',
      rank: 1,
      createdAt: new Date(),
      updatedAt: new Date()
    },
    {
      courseId: 2,
      title: 'node 第2章',
      content: '这是第2章的内容',
      video:'',
      rank: 2,
      createdAt: new Date(),
      updatedAt: new Date()
    },
  ], {})
  },

  async down (queryInterface, Sequelize) {
    await queryInterface.bulkDelete('Chapters', null, {})
  }

2. 执行种子命令 sequelize db:seed --seed 20250901085407-chapter

3.添加模型文件中的验证

js 复制代码
// models/course.js
static associate(models) {
      models.Course.hasMany(models.Chapter, {
        as: 'chapters'
      })
    }
js 复制代码
// models/chapter.js
const {
  Model
} = require('sequelize');
module.exports = (sequelize, DataTypes) => {
  class Chapter extends Model {
    static associate(models) {
      Chapter.belongsTo(models.Course, {
        as: 'course'
      })
    }
  }
  Chapter.init({
    courseId: {
      type: DataTypes.INTEGER,
      allowNull: false,
      validate: {
        notEmpty: {
          msg: '课程ID必须存在'
        },
        notNull: {
          msg: '课程ID不能为空'
        },
        async isPresent(value) {
          const course = await sequelize.models.Course.findByPk(value)
          if(!course) {
            throw new Error('课程不存在')
          }
        }
      }
    },
    title: {
      type: DataTypes.STRING,
      allowNull: false,
      validate: {
        notEmpty: {
          msg: '标题必须存在'
        },
        notNull: {
          msg: '标题不能为空'
        },
        len: {
          args:[2,45],
          msg: '标题长度需要在2~45个字符之间'
        }
      }
    },
    content: DataTypes.TEXT,
    video: DataTypes.STRING,
    rank: DataTypes.INTEGER
  }, {
    sequelize,
    modelName: 'Chapter',
  });
  return Chapter;
};

4. 添加路由文件

js 复制代码
const express = require('express');
const router = express.Router();
const {Chapter, Course} = require('../../models')
const {Op} = require('sequelize')
const {NotFoundError, success, failure} = require('../../utils/response')

router.get('/', async function(req, res, next) {
    try {

        const query = req.query
        const currentPage = Math.abs(Number(query.currentPage)) || 1
        const pageSize = Math.abs(Number(query.pageSize)) || 10
        const offset = (currentPage - 1) * pageSize

        if(!query.courseId) {
            throw new Error('获取章节列表失败,课程ID不能为空')
        }

        const condition = {
            ...getCondition(),
            order: [['rank', 'ASC'],['id', 'ASC']],
            limit: pageSize,
            offset
        }
        condition.where = {
            courseId: {
                [Op.eq]: query.courseId
            }
        }

        if(query.title) {
            condition.where = {
                title: {
                    [Op.like]: `%${query.title}%`
                }
            }
        }

        const {count, rows} = await Chapter.findAndCountAll(condition)
        success(res, '查询章节列表成功', {
            chapters: rows,
            pagination: {
                total: count,
                currentPage,
                pageSize
            }
        })
    }catch(error) {
        failure(res, error)
    }
});

router.get('/:id', async function(req, res, next) {
    try {
        const chapter = await getChapter(req)
        success(res, '查询章节成功', {chapter})
    } catch(error) {
        failure(res, error)
    }
})

router.post('/', async function(req, res, next) {
    try {
        const body = filterBody(req)
        const chapter = await Chapter.create(body)
        success(res, '创建章节成功', {chapter}, 201)
    }catch(error) {
        failure(res, error)
    }
});

router.delete('/:id', async function(req, res, next) {
    try {
        const chapter = await getChapter(req)
        await chapter.destroy()
        success(res, '删除章节成功')
    } catch(error) {
        failure(res, error)
    }
})

router.put('/:id', async function(req, res, next) {
    try {

        const body = filterBody(req)
        const chapter = await getChapter(req)
        await chapter.update(body)
        success(res, '更新章节成功')
    } catch(error) {
        failure(res, error)
    }
})
function getCondition() {
    return {
        attributes: {
            exclude: ['CourseId']
        },
        include: [
            {
                model: Course,
                as: 'course',
                attributes: ['id', 'name']
            }
        ]
    }
}
async function getChapter(req) {
    const {id} = req.params
    const chapter = await Chapter.findByPk(id, getCondition())
    if(!chapter) {
        throw new NotFoundError(`ID: ${id}的章节未找到。`)
    }

    return chapter
} 

function filterBody(req) {
    return {
        courseId: req.body.courseId,
        title: req.body.title,
        content: req.body.content,
        video: req.body.video,
        rank: req.body.rank
    }
}
module.exports = router;

5. 在课程路由文件中增加删除判断

js 复制代码
// routes/admin/courses.js
const {Chapter} = require('../../models')
router.delete('/:id', async function(req, res, next) {
    try {
        const course = await getCourse(req)

        const count = await Chapter.count({
            where: {
                courseId: course.id
            }
        })
        if(count > 0) {
            throw new Error('删除课程失败,该课程下存在章节')
        }
        await course.destroy()
        success(res, '删除课程成功')
    } catch(error) {
        failure(res, error)
    }
})

6. 引入路由文件

js 复制代码
// app.js
const adminChaptersRouter = require('./routes/admin/chapters');
app.use('/admin/chapters', adminChaptersRouter);

九、Echarts 数据统计接口

1. 路由文件

js 复制代码
// routes/admin/chart.js
const express = require('express');
const router = express.Router();
const {sequelize, User} = require('../../models');
const {success, failure} = require('../../utils/response');

/**
 * 统计用户性别
 * Get /admin/chart/sex
 */
router.get('/', async function(req, res, next) {
    try {
        const male = await User.count({
            where: {
                sex: 0
            }
        })
        const female = await User.count({
            where: {
                sex: 1
            }
        })
        const unknown = await User.count({
            where: {
                sex: 2
            }
        })
        const data = [
            {value: male, name: '男'},
            {value: female, name: '女'},
            {value: unknown, name: '未选择'},
        ]
        success(res, '查询用户性别成功', data)
    } catch(error) {
        failure(res, error)
    }
})

/**
 * 统计用户每月注册人数
 * Get /admin/chart/user
 */
router.get('/user', async function(req, res, next) {
    try {
        const [results] = await sequelize.query('SELECT DATE_FORMAT(createdAt, "%Y-%m") AS months, COUNT(*) AS value FROM users GROUP BY months ORDER BY months ASC')
        const data = results.map(item => ({
            months: item.months,
            value: item.value
        }))
        success(res, '查询用户每月注册人数成功', data)
    } catch(error) {
        failure(res, error)
    }
})

module.exports = router;

2. 引入路由文件

js 复制代码
// app.js
const adminChartRouter = require('./routes/admin/chart');
app.use('/admin/chart', adminChartRouter);

十、jwt实现管理员登陆

1. 新增错误类型判断

js 复制代码
// utils/errors.js
class BadRequestError extends Error {
    constructor(message) {
        super(message)
        this.name = 'BadRequestError'
    }
}

/**
 * 401 错误
 */
class UnauthorizedError extends Error {
    constructor(message) {
        super(message)
        this.name = 'UnauthorizedError'
    }
}

/**
 * 404 错误
 */
class NotFoundError extends Error {
    constructor(message) {
        super(message)
        this.name = 'NotFoundError'
    }
}


module.exports = {
    BadRequestError,
    UnauthorizedError,
    NotFoundError,
}

2. 修改原先的 response 文件

js 复制代码
// utils/response.js -> utils/responses.js
function success(res, message, data = {}, code = 200) {
    res.status(code).json({
        status:true,
        message,
        data
    })
}

function failure(res, error) {
    if(error.name == 'SequelizeValidationError') {
        const errors = error.errors.map(e => e.message)
        return  res.status(400).json({
            status:false,
            message: '请求参数错误',
            errors
        })
    }

    if(error.name === 'BadRequestError') {
        return res.status(400).json({
            status:false,
            message: '请求参数错误',
            errors: [error.message]
        })
    }

    if(error.name === 'UnauthorizedError') {
        return res.status(401).json({
            status:false,
            message: '未授权',
            errors: [error.message]
        })
    }

    if(error.name === 'NotFoundError') {
        return res.status(404).json({
            status:false,
            message: '资源不存在',
            errors: [error.message]
        })
    }

    res.status(500).json({
        status:false,
        message: '服务器错误',
        errors: [error.message]
    })
}

module.exports = {
    success,
    failure
}

3. 修改路由文件中的引用

js 复制代码
// const {NotFoundError, success, failure} = require('../../utils/response');
const {NotFoundError} = require('../../utils/errors')
const {success, failure} = require('../../utils/responses');

4. 新增登陆判断的路由文件

js 复制代码
// routes/admin/auth.js
const express = require('express');
const router = express.Router();
const {User} = require('../../models')
const {Op} = require('sequelize')
const {NotFoundError} = require('../../utils/errors')
const {success, failure} = require('../../utils/responses');

/**
 * 管理员登陆
 * POST /admin/auth/sign_in
 */
router.post('/sign_in', async function(req, res, next) {
    try {
        const {login, password} = req.body
        if(!login) {
            throw new Error('邮箱/用户名不能为空')
        }
        if(!password) {
            throw new Error('密码不能为空')
        }
        
        const condition = {
            where: {
                [Op.or]: [
                    {
                        email: login
                    },
                    {
                        username: login
                    }
                ]
            }
        }
        const user = await User.findOne(condition)
        if(!user) {
            throw new NotFoundError('用户不存在,无法登陆。')
        }
        success(res, '登陆成功', {})
    } catch(error) {
        failure(res, error)
    }
})

module.exports = router

5. 引用路由文件

js 复制代码
// app.js
app.use(express.static(path.join(__dirname, 'public')));
app.use('/admin/auth', adminAuthRouter);

6. 判断密码是否相等

js 复制代码
const bcrypt = require('bcryptjs')

// 验证密码错误
const isPasswordValid = await bcrypt.compareSync(password, user.password)
if(!isPasswordValid) {
    throw new Error('密码错误')
}

7. 新建环境变量

npm install dotenv

js 复制代码
// .env
SECRET=94cb853f7b3a9c3adb5a59903962d1125fe56efb860f331ca669d13c0a979bb7

8. 生成token

npm install jsonwebtoken

js 复制代码
// 可以生成32位的随机字符串作为密钥(SECRET)
// const crypto = require('crypto')
// console.log(crypto.randomBytes(32).toString('hex'))

const token = jwt.sign({
    userId: user.id,
}, process.env.SECRET, {
    expiresIn: '30d'
})
success(res, '登陆成功', {token})

十一、使用中间件认证接口

1. 声明中间件

js 复制代码
// middlewares/admin-auth.js
const jwt = require('jsonwebtoken')
const {User} = require('../models')
const {UnauthorizedError} = require('../utils/errors')
const {success, failure} = require('../utils/responses')

module.exports = async function(req, res, next) {
    try {
        const {token} = req.headers
        if(!token) {
            throw new Error('当前接口需要认证才能访问')
        }
        // 验证token是否正确
        const decoded = jwt.verify(token, process.env.SECRET)

        // 从jwt中 解析出之前存入的userId
        const {userId} = decoded

        // 根据userId查询用户信息
        const user = await User.findByPk(userId)
        if(!user) {
            throw new Error('用户不存在')
        }

        // 验证当前用户是否是管理员
        if(user.role !== 100) {
            throw new Error('当前用户没有权限访问')
        }

        // 验证通过,将用户信息挂载到req对象上
        req.user = user
        next()
    } catch(error) {
        failure(res, error)
    }
}

2. 使用中间件

js 复制代码
// app.js 
const adminAuth = require('./middlewares/admin-auth');

// 后台路由配置
app.use('/admin/articles', adminAuth, adminArticlesRouter);
app.use('/admin/categories', adminAuth, adminCategoriesRouter);
app.use('/admin/settings', adminAuth, adminSettingsRouter);
app.use('/admin/users', adminAuth, adminUsersRouter);
app.use('/admin/courses', adminAuth, adminCoursesRouter); 
app.use('/admin/chapters', adminAuth, adminChaptersRouter);
app.use('/admin/chart', adminAuth, adminChartRouter);
app.use('/admin/auth', adminAuthRouter);

学习视频地址:bilibili

相关推荐
日月晨曦8 小时前
Node.js 架构与 HTTP 服务实战:从「跑龙套」到「流量明星」的进化
前端·node.js
Moment8 小时前
Bun 如何将 postMessage(string) 提速 500 倍,远超 NodeJs 🚀🚀🚀
前端·javascript·node.js
上单带刀不带妹1 天前
Node.js 的流(Stream)是什么?有哪些类型?
node.js·stream·js
Juchecar1 天前
解决Windows下根目录运行 pnpm dev “无法启动 Vite 前端,只能启动 Express 后端”
前端·后端·node.js
华仔啊1 天前
NestJS 3 分钟搭好 MySQL + MongoDB,CRUD 复制粘贴直接运行
javascript·node.js
Bdygsl2 天前
Node.js(3)—— fs模块
node.js
召摇2 天前
负载均衡技术解析
java·node.js
关山月3 天前
在 Next.js 项目中使用 SQLite
node.js