| 描述:
一个独立完整的技术团队简历及面试系统。
项目价值:
通过逐年推广形成公司技术人才库,沉淀简历资产及过程信息
●便于未来简历再次捞出,挖掘候选人
●便于做年度招聘分析与复盘,优化招聘效果
技术栈:
Node.js + Egg + MySQL + React
| | —- |
功能说明
1. 用户管理
- 账号信息管理
- 团队成员的权限说明
- 超级管理员 - 管理一切,可增删团队,及设置团队管理员
- 皆为软删除(软删除是打标记、硬删除是表数据清除)
- 团队管理员 - 管理成员增删与简历增删(皆为软删除)
- 可为团队成员手动重置密码(密码符合一定强度)
- 普通成员 - 查看上传历史,上传简历,其他信息不可见
- 面试官 - 简历增改删、评论、指派(皆为软删除)
- 第一个情况,是团队管理者,能参与相关技术面
- 第二个情况,是 HR,能参与面试前后的沟通
- 超级管理员 - 管理一切,可增删团队,及设置团队管理员
-
2. 团队管理
超级管理员 可以做 团队增删与修改
可以新增团队,或修改既有团队
- 新增团队时默认创建团队简历池
- 简历池存放待筛选简历
- 简历可被废弃(软删除)
-
3. 职位管理
团队管理员 和 超级管理员 可以做 职位岗位增删与修改
可以新增公司的职位
- 可以新增职位下的岗位要求
-
4. 简历管理
简历新增与指派
简历新增默认到简历池
- 填写简历描述字段
- 上传简历附件
- 简历上传完毕,自动进入当前登录人上传历史页面
- 简历进简历池后,默认指派给团队管理员筛选
- 团队管理员拿到简历之后,做一下基础筛选(是否有重复简历,是否符合 HC 对应要求…)
- 筛选通过,团队管理员指派给适合的面试官,进入面试流程
- 筛选失败,团队管理员直接给简历标记为不符合。
简历面试指派
面试官面试阶段最多支持到 10 面
- 一面、二面…到十面
- 每一轮面试需要填写了评价之后,才能进行下一环节指派
- 如果当时确认当前候选人不适合,面试官可以标记为不合适,直接指派给 HR ,结束当前面试流程
- 面试评价
- 每一轮面试评价中需要包含候选人基础评价和职能评级
- 单个环节面试结果可以为不通过,不适合,适合
- 单环节面试结果如果为不通过,或者不适合,也可以进入到下一个流程
页面访问链路
上传简历流程(所有人)

上传简历环节,简洁版可以只填写字段可以只用填写姓名,手机号,推荐岗位,推荐类型(校招或社招)简历和备注
面试评价流程(所有人)
简历筛选及面试(团队管理员)
人员新增(团队管理员)

- 团队管理员 可以新增类型为面试官和普通成员 的成员
- 超级管理员 可以新增类型为 团队管理员,面试官和普通成员 的成员
- 多个部门的团队管理员可以是一个
岗位新增(团队管理员)
新增团队及设置管理员(超级管理员)

只有角色是团队管理员的成员,才可以关联为团队的团队管理员
技术架构
项目里程碑
里程碑 1 - 项目初始化及页面可渲染
- 完成项目环境搭建
- 完成项目的目录文件结构初始化
- 基于 Egg 的前后端分离项目,能基础跑起来
首页/简历详情页的样式完成,可以渲染出登录首页
注意事项
项目目录拆分合理
- 项目的各种配置文件完整
- 后台登录首页可以正常渲染出来
里程碑 2 - 数据建模及成员注册登录
- 完成 MySQL 环境搭建与数据库连接
- 完成 用户/简历/面试 等相关数据表结构的 Schema 设计
- 完成用户相关数据表增删改查 API 接口设计
- 完成后台管理页面的开发与渲染
- 后台首页
- 新增成员页
- 成员列表页
- 新增团队页
- 团队列表页
注意事项
- 数据建模合理
- 后台页面正常渲染
- 管理员可注册登录
- 团队可被新增、删除、设置管理员
- 成员可以被新增、删除、指派身份、修改密码
里程碑 3 - 简历上传及面试管理
- 完成岗位新增
- 完成简历新增
- 完成面试的筛选功能和指派功能
- 完成面试的评价流程功能
- 完成个人后台中心的关联任务
- 待筛选的简历
- 待面试的简历
- 待评价的简历
注意事项
- 职位可被新增
- 岗位可被新增
- 简历可被上传
- 简历可被指派
- 简历可被评价
- 简历可被删除
- 面试可被指派
- 简历及面试列表、详情可被展示
公司的部门及岗位
- 主要部门维度:技术部、产品部、运营部、人力资源部等
- 职位维度:技术、产品、运营、销售、行政、财务、物流、人力资源等
- 岗位维度:前端实习生、前端工程师、高级前端工程师、资深前端工程师、前端专家、高级前端专家等
简历的几个阶段
- 简历上传完毕,等待评估
- 简历过审,状态如下
- 等待电话邀约
- 等待安排面试
- 候选人联系不上
- 候选人拒邀
业务逻辑控制
放到 Egg/app/controller 下面
const Controller = require('../core/controller')class IntervieweeController extends Controller {async list () {const {currPage = 1,pageSize = 10,funcId,jobId,// 状态,可以传入多个以 , 隔开 status=1,2,3status} = this.ctx.queryconst res = await this.service.interviewee.pagination({currPage,pageSize,funcId,jobId,status})if (res.errMsg) {this.fail(res)} else {this.success(res)}}/*** 评价简历,评价后,面试流程将往后推一步*/async comment (ctx) {const {// 面试评语remark,// 求职者IDintervieweeId,// 第几面step} = ctx.request.body// 当前登录用户为面试官const currentUser = ctx.userif (!currentUser) {this.fail('请登录后再试')return}// 面试官IDconst userId = currentUser.idconst viewUserInfo = {user_id: userId,interviewee_id: intervieweeId,status: step,comment: remark}const res = await this.service.interviewee.nextProgress(viewUserInfo)this.checkRes(res)}}module.exports = IntervieweeController
MySQL 表字段定义
表结构初始化入库 - 可放到 Egg/database/migrations 下面
'use strict'const tableName = 'interviewee'module.exports = {up: async (queryInterface, Sequelize) => {const { STRING, INTEGER, DATE, NOW } = Sequelizereturn await queryInterface.createTable(tableName, {id: {type: INTEGER,primaryKey: true,autoIncrement: true,comment: '主键,自增'},name: {type: STRING,allowNull: false,comment: '候选人姓名'},phone: {type: STRING,allowNull: false,unique: 'intervieweePhone',is: /^1[3456789]\d{9}$/,comment: '候选人电话号码'},email: {type: STRING,allowNull: true,comment: '候选人电子邮箱',isEmail: true},address: {type: STRING,allowNull: false,comment: '候选人地址'},education: {type: STRING,allowNull: false,comment: '候选人学历,一般为 高中,大专,本科,研究生,博士,其他'},type: {type: INTEGER(1),comment: '招聘类型,0 社招,1校招,默认 0',defaultValue: 0},is_internship: {type: INTEGER(1),comment: '是否实习 0 试用 1 实习,默认 0',defaultValue: 0},job_id: {type: INTEGER,allowNull: true,comment: '推荐岗位外键'},reason: {type: STRING,allowNull: false,comment: '推荐理由'},resume_path: {type: STRING,allowNull: false,comment: '简历路径'},note: {type: STRING,allowNull: true,comment: '推荐备注'},channel: {type: INTEGER,allowNull: false,comment: '招聘渠道 0 外部 1 内部,默认 0',defaultValue: 1},status: {type: STRING,allowNull: false,comment: '面试状态,一面,二面,三面,四面,五面,六面,发放 offer,正式入职'},state: {type: INTEGER(1),defaultValue: 1,comment: '数据是否有效,0 无效,1 有效,默认 1'},is_success: {type: INTEGER(1),defaultValue: 0,comment: '是否成功 0 不成功 1 成功'},viewer_id: {type: INTEGER,allowNull: true,comment: '面试官外键'},recommender_id: {type: INTEGER,allowNull: false,comment: '内推人外键'},created_at: {type: DATE,allowNull: true,defaultValue: NOW,comment: '创建日期'},updated_at: {type: DATE,allowNull: true,defaultValue: NOW,comment: '更新日期'},deleted_at: {type: DATE,allowNull: true,comment: '删除日期'}}, {paranoid: true,deletedAt: true,underscored: true,freezeTableName: true,tableName: tableName,version: true,comment: '候选人表'}).then(() => {queryInterface.addIndex(tableName, {name: 'name_index',fields: ['name']})queryInterface.addIndex(tableName, {name: 'phone_index',fields: ['phone']})queryInterface.addIndex(tableName, {name: 'status_index',fields: ['status']})})},down: async (queryInterface, Sequelize) => {await queryInterface.dropTable(tableName)}}
表结构模型定义 - 放到 Egg/app/model 下面
'use strict'const tableName = 'interviewee'module.exports = app => {const { STRING, INTEGER } = app.Sequelizeconst Interviewee = app.model.define(tableName, {id: {type: INTEGER,primaryKey: true,autoIncrement: true,comment: '主键,自增'},name: {type: STRING,allowNull: false,comment: '候选人姓名'},phone: {type: STRING,allowNull: false,unique: 'intervieweePhone',is: /^1[3456789]\d{9}$/,comment: '候选人电话号码'},email: {type: STRING,allowNull: true,comment: '候选人电子邮箱',isEmail: true},address: {type: STRING,allowNull: false,comment: '候选人地址'},education: {type: STRING,allowNull: false,comment: '候选人学历,一般为 高中,大专,本科,研究生,博士,其他'},type: {type: INTEGER(1),comment: '招聘类型,0 社招,1校招,默认 0',defaultValue: 0},is_internship: {type: INTEGER(1),comment: '是否实习 0 试用 1 实习,默认 0',defaultValue: 0},job_id: {type: INTEGER,allowNull: true,comment: '推荐岗位外键'},reason: {type: STRING,allowNull: false,comment: '推荐理由'},resume_path: {type: STRING,allowNull: false,comment: '简历路径'},note: {type: STRING,allowNull: true,comment: '推荐备注'},channel: {type: INTEGER,allowNull: false,comment: '招聘渠道 0 外部 1 内部,默认 0',defaultValue: 1},status: {type: STRING,allowNull: false,comment: '面试状态,一面,二面,三面,四面,五面,六面,发放 offer,正式入职'},state: {type: INTEGER(1),defaultValue: 1,comment: '数据是否有效,0 无效,1 有效,默认 1'},is_success: {type: INTEGER(1),defaultValue: 0,comment: '是否成功 0 不成功 1 成功'},viewer_id: {type: INTEGER,allowNull: true,comment: '面试官外键'},recommender_id: {type: INTEGER,allowNull: false,comment: '内推人外键'}}, {paranoid: true,deletedAt: true,underscored: true,freezeTableName: true,tableName: tableName,version: true,comment: '候选人表',hooks: {}})Interviewee.associate = () => {Interviewee.belongsTo(app.model.Job, {as: 'job',foreignKey: 'job_id',constraints: false})Interviewee.belongsTo(app.model.Users, {as: 'viewr',foreignKey: 'viewer_id',constraints: false})Interviewee.belongsTo(app.model.Users, {as: 'recommender',foreignKey: 'recommender_id',constraints: false})}Interviewee.sync({ alter: true })return Interviewee}
接口能力实现
controller 能力扩展说明 —- 放到 Egg/app/core/controller.js 里
'use strict'const Controller = require('egg').Controllerclass BaseController extends Controller {validate () {const { ctx } = thisctx.validate(this.getResRule(ctx.url))}// 校验用户权限checkUseAuth (type) {return this.ctx.checkUseAuth(type)}// set success datasuccess (data) {this.ctx.success(data)}successMsg (msg) {this.ctx.successMsg(msg)}// set failed msgfail (error) {this.ctx.fail(error)}failMsg (msg) {this.ctx.failMsg(msg)}// get paramsgetParams (key) {return this.ctx.getParams(key)}// get request client ipget ip () {return this.ctx.ip}// get cookiesget cookies () {return this.ctx.cookies}// get sessionget session () {return this.ctx.session}validatePk () {this.ctx.validate({id: { type: 'int', convertType: 'number', required: true }}, this.ctx.params)}validatePageParam (type = 'query') {const checkPath = type === 'body'? 'body' : 'query'this.ctx.validate(this.ctx.rule.getPageRequest,this.ctx.request[checkPath])}formatOptions (obj) {const badParamList = [undefined,'', null];for (const name in obj) {badParamList.includes(obj[name]) && delete obj[name]}return obj}async find (id) {const table = await this.service[this.serviceName].findByPk(id, {}, true)if (!table) {this.failMsg(`${this.modelName} 表内无标识为 ${id} 的记录`)}return table}async destroy (needTip = true) {const ctx = this.ctxconst id = ctx.params.idconst table = await this.find(id)if (table) {await table.destroy()needTip && this.successMsg(`删除${id}成功`)return true}}// 封装统一的调用检查函数,可以在查询、创建和更新等 Service 中复用checkSuccess (result) {const DEAULT_REQ_STATUS_ATTR = this.ctx.getAttr('DEAULT_REQ_STATUS_ATTR')const DEAULT_REQ_MSG_ATTR = this.ctx.getAttr('DEAULT_REQ_MSG_ATTR')if (!result[DEAULT_REQ_STATUS_ATTR]) {this.ctx.failMsg(result[DEAULT_REQ_MSG_ATTR])} else {this.ctx.successMsg(result[DEAULT_REQ_MSG_ATTR])}}checkFindData (table, errMsg = '记录未找到') {if (!table) {this.failMsg(errMsg)}this.success(table)}}module.exports = BaseController
controller 能力消费 —- 放到 Egg/app/controller 里
'use strict'const BaseController = require('../core/controller')const currServiceName = 'sysDicItems'/*** @Controller*/class SysDicItemsController extends BaseController {get modelName() {return 'SysDicItems'}get serviceName() {return currServiceName}/*** @Summary 获取指定分组的所有字典项* @Description 获取指定分组的所有字典项* @Router get /sysDicItems/group/{groupName}* @Request path string groupName 分组名* @Response 200 SysDicItems 获取指定分组的所有字典项*/async group() {const { ctx } = thisconst { params } = ctxconst { groupName } = paramsctx.validate(ctx.rule.getSysDicGroupRequest)const res = await this.service[currServiceName].group(groupName)res && this.success(res)}/*** @Summary 获取所有字典项,不分页* @Description 获取所有字典项* @Router get /sysDicItems* @Response 200 SysDicItems 获取所有字典项成功*/async index() {this.success(await this.service[currServiceName].list())}/*** @Summary 分页获取字典项* @Description 分页获取字典项* @Router post /sysDicItems/page/* @Request query integer pageNo 页面数* @Request query integer pageSize 页面条数* @Response 200 SysDicItems 获取所有字典项成功*/async page() {this.validatePageParam()const { ctx } = thisconst {// 默认第一页pageNo = 1,// 默认每页10条记录pageSize = 10,...restParams} = ctx.request.bodyconst where = this.formatOptions({...restParams,})ctx.validate(ctx.rule.updateSysDicRequest)const config = { pageNo, pageSize, where }const res = await this.service[currServiceName].pagination(config)if (res.count) {this.success(res)} else {this.failMsg(`未找到第 ${pageNo} 页的字典项信息`)}}/*** @Summary 新增字典项* @Description 新增字典项信息* @Router post /sysDicItems* @Request body createSysDicRequest *body* @Response 200 defaultBaseResponse 新增字典项成功*/async create() {const ctx = this.ctxctx.validate(ctx.rule.createSysDicRequest)const { name, value, note, groupName, sortNum, enable } = ctx.request.bodyconst res = await this.service[currServiceName].create({ name, value, note, groupName, sortNum, enable })this.success({ id: res.id })}/*** @Summary 获取所有字典项* @Description 根据 id 获取字典项信息* @Router get /sysDicItems/{id}* @Request path integer id 标识* @Response 200 SysDicItems 获取指定字典项信息成功*/async show() {const ctx = this.ctxthis.validatePk()const res = await this.find(ctx.params.id)res && this.success(res)}/*** @Summary 查找字典项* @Description 查找字典项信息* @Router post /sysDicItems/find* @Request body findSysDicRequest *body* @Response 200 SysDicItems 查找字典项成功*/async findByParam() {const { ctx } = thisconst { name, value, note, groupName, sortNum, enable } = ctx.request.body// 参数校验ctx.validate(ctx.rule.findSysDicRequest, ctx.request.body)const where = this.formatOptions({ name, value, note, groupName, sortNum, enable })// 查找字典项数据const res = await ctx.service[currServiceName].list({ where })return res ? this.success(res) : this.failMsg(`已存在字典项 ${name}`)}/*** @Summary 修改指定字典项* @Description 根据 id 修改字典项信息* @Router put /sysDicItems/{id}* @Request path integer id 标识* @Request body createSysDicRequest *body* @Response 200 defaultBaseResponse 修改指定字典项成功*/async update() {const ctx = this.ctxthis.validatePk()const id = ctx.params.idconst SysDicItems = await this.find(id)if (SysDicItems) {ctx.validate(ctx.rule.createSysDicRequest)const { name, value, note, groupName, sortNum, enable } = ctx.request.bodyawait this.service[currServiceName].modelUpdateData({model: SysDicItems, data: { name, value, note, groupName, sortNum, enable }})this.success({ id })}}/*** @Summary 删除指定字典项* @Description 根据 id 删除字典项信息* @Router delete /sysDicItems/{id}* @Request path integer id 标识,可以用逗号隔开* @Response 200 defaultResponse 删除指定字典项成功*/async destroy() {const id = this.ctx.params.idconst state = await this.service[currServiceName].destroy({ where: { id: id.split(',') } })state && this.successMsg(`删除字典项${this.ctx.params.id}成功`)}}module.exports = SysDicItemsController
获取数据服务
services 能力扩展说明 —- 放到services.js 里
const Service = require('egg').Serviceconst dayjs = require('dayjs')const humps = require('humps')const _symbolJudge = (key, convert) => {if (typeof key === 'symbol') {return key} else {return convert(key)}}function toInt(str) {if (typeof str === 'number') return strif (!str) return strreturn parseInt(str, 10) || 0}class BaseService extends Service {get DEAULT_STATUS_LIST() {return this.ctx.getAttr('DEAULT_STATUS_LIST')}get DEAULT_REQ_STATUS_ATTR() {return this.ctx.getAttr('DEAULT_REQ_STATUS_ATTR')}get DEAULT_REQ_MSG_ATTR() {return this.ctx.getAttr('DEAULT_REQ_MSG_ATTR')}get DEAULT_REQ_DATA_ATTR() {return this.ctx.getAttr('DEAULT_REQ_DATA_ATTR')}get model() {return this.ctx.model}get _user() {return this.ctx.user}// 格式化数据库返回的列表数据,将之从下划线转为驼峰formatDBDataToCamelize(data) {if (data && data.length > 0) {return data.map((item) => humps.camelizeKeys(item.dataValues))} else if (data && data.dataValues) {return humps.camelizeKeys(data.dataValues)}return data}// 格式化下划线为驼峰formatDataToCamelize(data) {if (data && data.length > 0) {return data.map((item) => humps.camelizeKeys(item))} else if (data) {return humps.camelizeKeys(data)}return data}// 格式化驼峰参数为下划线参数,便于查询foramtToUnderscoredParams(data) {if (data && data.length > 0) {return data.map((item) => humps.decamelizeKeys(item, _symbolJudge))} else if (data && data) {return humps.decamelizeKeys(data, _symbolJudge)}return data}formatDate(time) {return dayjs(time).format('YYYY-MM-DD HH:mm:ss')}formatDay(time) {return dayjs(time).format('YYYY-MM-DD')}formatTimestampsField(table, callBackFn = (item) => item) {if (!table) {return table}const CREATE_ATTR = 'createdAt'const UPDATE_ATTR = 'updatedAt'const DELETE_ATTR = 'deletedAt'table[CREATE_ATTR] && (table[CREATE_ATTR] = this.formatDate(table[CREATE_ATTR]))table[UPDATE_ATTR] && (table[UPDATE_ATTR] = this.formatDate(table[UPDATE_ATTR]))DELETE_ATTR in table && (table[DELETE_ATTR] = undefined)return callBackFn(table)}dealWithTableField(table, callBackFn) {return table.dataValues && this.formatTimestampsField(this.formatDataToCamelize(table.dataValues), callBackFn)}async dealDBFn(callback, errHandler) {try {return await callback()} catch (e) {errHandler && await errHandler()this.ctx.throw(e.errors ? e.errors[0]?.message : e.message)}}async updateData({ modelName, data, configOption }) {const _t = thisreturn await _t.dealDBFn(async () => {const entity = _t.foramtToUnderscoredParams(data)return _t.dealWithTableField(await _t.model[modelName].update(entity, configOption))})}async modelUpdateData({ model, data, configOption }) {const _t = thisreturn await _t.dealDBFn(async () => {const entity = _t.foramtToUnderscoredParams(data)return _t.dealWithTableField(await model.update(entity, configOption))})}async createData({ modelName, data, configOption, callBackFn }) {const _t = thisreturn await _t.dealDBFn(async () => {const entity = _t.foramtToUnderscoredParams(data)const res = await _t.model[modelName].create(entity, configOption)return res ? _t.dealWithTableField(res, callBackFn) : res})}async findAll({ modelName = '', configOption = {}, callBackFn }) {const _t = thisreturn await _t.dealDBFn(async () => {const res = await _t.model[modelName].findAll(configOption)return res ? res.map(item => _t.dealWithTableField(item, callBackFn)) : res})}async findByPk({ modelName = '', id = '', configOption = {}, needModal = false, callBackFn }) {const _t = thisreturn await _t.dealDBFn(async () => {const res = await _t.model[modelName].findByPk(toInt(id), configOption)res && (res.dataValues = _t.dealWithTableField(res, callBackFn))return needModal ? res : res.dataValues})}async findOne({ modelName = '', configOption = {}, needModal = false, callBackFn }) {const _t = thisreturn await _t.dealDBFn(async () => {const res = await _t.model[modelName].findOne(configOption)res && (res.dataValues = _t.dealWithTableField(res, callBackFn))return needModal ? res : res && res.dataValues})}async findOrCreate({ modelName = '', configOption = {}, callBackFn }) {const _t = thisreturn await _t.dealDBFn(async () => {const res = await _t.model[modelName].findOrCreate(configOption)return res ? _t.dealWithTableField(res, callBackFn) : res})}async destroyData({ modelName = '', configOption = {} }) {const _t = thisreturn await _t.dealDBFn(async () => {await _t.model[modelName].destroy(configOption)return true})}async pagination({ modelName = '', configOption = {}, needTranUnder = true ,callBackFn }) {const _t = thisconst { attributes, include, order } = configOptionlet { pageNo, pageSize, where } = configOptionpageNo = toInt(pageNo)pageSize = toInt(pageSize)where && needTranUnder && (where = _t.foramtToUnderscoredParams(where))return await _t.dealDBFn(async () => {const options = {attributes,include,where,order: order || [['updated_at', 'DESC']],offset: (pageNo - 1) * pageSize,limit: toInt(pageSize)}const res = await _t.model[modelName].findAndCountAll(options)const data = res.count > 0 ? res.rows.map(item => _t.dealWithTableField(item, callBackFn)) : []return { data, count: res.count }})}async findAndCountAll({ modelName = '', configOption = {}, callBackFn }) {const _t = thisreturn await _t.dealDBFn(async () => {const res = await _t.model[modelName].findAndCountAll(configOption)return res ? res.map(item => _t.dealWithTableField(item, callBackFn)) : res})}// set success datasuccess(msg, data) {return this.ctx.successMsgResponse(msg, data)}// set failed msgfail(error) {return this.ctx.errorMsgResponse(error)}}module.exports = BaseService
services 能力消费举例 —- 放到 Egg/app/service/ 里
const Service = require('egg').BaseServiceconst fs = require('fs')const path = require('path')const currModelName = 'Interviewee'/*** 候选人表 Service*/class Interviewee extends Service {get configOption() {return {include: [{as: 'recommender',model: this.model.Users,attributes: ['name']},{as: 'job',model: this.model.Job,attributes: ['name']}],where: this.getAuthWhere()}}get callBackFn() {return (item) => {item.jobName = item.job && item.job.dataValues.nameitem.job = undefineditem.recommenderName = item.recommender && item.recommender.dataValues.nameitem.recommender = undefinedreturn item}}getAuthWhere() {return this.ctx.checkUseAuth(0, false) ? {} : { recommender_id: this._user.id }}async list() {return await super.findAll({ modelName: currModelName, configOption: this.configOption, callBackFn: this.callBackFn })}async findByPk(id, configOption, needModal) {configOption = { ...this.configOption, ...configOption }configOption.where.id = idthis.ctx.checkUseAuth(1) && delete configOption.where.recommender_idreturn await super.findOne({ modelName: currModelName, configOption, needModal, callBackFn: this.callBackFn })}async create(intervieweeInfo, file) {return await this.dealDBFn(async () => {const filename = `${intervieweeInfo.name}-${intervieweeInfo.phone}`const uploadRes = await this.uploadPdf(this.ctx.origin, file, filename)intervieweeInfo.resumePath = uploadRes.resumePath// 新增候选人记录const currUser = this._user || {}intervieweeInfo.recommenderId = currUser.id || 1return await this.createData({ modelName: currModelName, data: intervieweeInfo })})}async update(data, configOption, needJoin = true) {needJoin && (configOption = { ...this.configOption, ...configOption })let callBackFn = ''needJoin && (callBackFn = this.callBackFn)return await this.updateData({ modelName: currModelName, data, configOption, callBackFn })}async modelUpdate(model, data, configOption = {}) {let transactionreturn await this.dealDBFn(async () => {transaction = await this.model.transaction();configOption.transaction = transaction;const res = await super.modelUpdateData({ model, data, configOption })// 维护推荐记录recommendRecordInfo.id = res.recommenderIdrecommendRecordInfo.intervieweeName = res.namerecommendRecordInfo.intervieweeTel = res.phonerecommendRecordInfo.intervieweeType = res.typeawait this.service.recommendRecord.update(recommendRecordInfo, { transaction }, false)await transaction.commit()return res}, () => transaction.rollback())}async destroy() {let transactionreturn await this.dealDBFn(async () => {transaction = await this.model.transaction()const id = this.ctx.params.idlet recommenderId = ''this.ctx.checkUseAuth(0) && (recommenderId = this._user.id)const configOption = {where: { id, recommender_id: recommenderId },transaction}// 查找候选人记录let res = await this.exist(configOption.where)if (!res) {return this.ctx.failMsg(`${currModelName} 表内无标识为 ${id} 的记录`)}// 删除候选人记录res = await this.destroyData({ modelName: currModelName, configOption })// 删除推荐记录const recommendRecordWhere = {interviewee_id: id,recommender_user_id: recommenderId}await this.service.recommendRecord.destroy({ transaction, where: recommendRecordWhere })await transaction.commit()return res}, () => transaction.rollback())}/*** 上传简历* @returns {string} res 上传路径*/async uploadPdf(origin, stream, filename) {const resumeFolderPath = `public/${this.formatDay()}`const saveFolder = `app/${resumeFolderPath}`const resumePath = `${resumeFolderPath}/${filename}.pdf`// 如果文件夹不存在,则直接创建if (!fs.existsSync(saveFolder)) {fs.mkdirSync(saveFolder)}const writerStream = fs.createWriteStream(path.join(this.config.baseDir, `${saveFolder}/${filename}.pdf`))await stream.pipe(writerStream)return { resumePath, uploadPath: `${origin}${resumePath}` }}async pagination({ where, ...otherConfig }) {const _t = thisconst { Op } = this.app.Sequelizeconst { createdAt, } = where;where = _t.foramtToUnderscoredParams({ ...where, ..._t.configOption.where })createdAt && (delete where.created_at) && createdAt.length && (where.created_at = {[Op.lt]: new Date(createdAt[1]),[Op.gt]: new Date(createdAt[0]),});// [Op.like]: '%hat',return await super.pagination({ modelName: currModelName, configOption: { ...otherConfig, ..._t.configOption, where }, needTranUnder: false, callBackFn: this.callBackFn })}async findAllByCondtion (configOption, needJoin = true) {needJoin && (configOption = { ...this.configOption, ...configOption })let callBackFn = ''needJoin && (callBackFn = this.callBackFn)return await this.findAll({ modelName: currModelName, configOption, callBackFn })}/*** 判断简历是否存在* @return true 表示已存在,false 不存在*/async exist(where) {// 判断是否已存在const configOption = {attributes: ['id'],where: this.foramtToUnderscoredParams(where)}return !!await super.findOne({ modelName: currModelName, configOption })}}module.exports = Interviewee
路由文件配置
路由约束说明 —- 放到 Egg/app/router.js 配置
const { readdirSync } = require('fs')const { extname, resolve } = require('path')module.exports = app => {const checkFileType = (file) => extname(file) === '.js'const BASE_ROUTER = resolve(__dirname, './routers')readdirSync(BASE_ROUTER, 'utf-8').filter(checkFileType).map(file => {console.log(`[${app.config.projectName}] insert router ${file}`)require(resolve(BASE_ROUTER, file))(app)})}
路由能力注册举例说明 —- 放到 Egg/app/routers/ 下面
module.exports = app => {const { router, controller } = approuter.post('/interviewee/upload', controller.interviewee.upload)router.post('/interviewee/page/:param?', controller.interviewee.page)router.resources('Interviewee', '/interviewee', controller.interviewee)// 获取所有职能(id, name)列表,不分页// GET /interviewee app.controllers.interviewee.index// GET /interviewee/:id app.controllers.interviewee.show// PUT /interviewee/:id app.controllers.interviewee.update// DELETE /interviewee/:id app.controllers.interviewee.destroy}
