用Django构建企业人事管理系统:从选型到部署的完整实践
2026/9/24 20:40:03 网站建设 项目流程

最近连着好几个朋友来问同一个问题:公司想上一套人事管理系统,技术栈定了 Python,但框架到底选 Django 还是 Flask?这问题问多了之后我发现,真正让大家纠结的不是框架本身,而是这套系统到底要承担多少事。如果你也在考虑用 Python 从零搭建人力资源管理系统,我可以直接给结论:要做功能完整、权限清晰、能长期维护的 HRM 系统,优先选 Django;Flask 不是不能做,但你会把框架缺的那部分轮子一个个自己造回来。为什么这么判断,这套系统从模型设计到上线部署有哪些绕不开的细节,这篇就按我实际做过的一个企业人事管理项目来拆开讲。

1. 选型判断:为什么人事系统首选Django而不是Flask

1.1 Django和Flask的核心差异

人事管理系统本质上是什么?是一大堆关联表加上围绕这些表的增删改查、审批流、角色权限和报表统计。它不像博客系统那样只有文章和标签两种模型,也不像纯 API 服务那样只需要把数据序列化抛出去。一个中等规模的公司人事系统,光核心业务表就有组织架构、员工档案、考勤、请假、加班、薪资、培训记录、合同信息这些,表之间的关系错综复杂。

Django 在这类场景上的优势是碾压性的。它自带的 ORM 不仅支持一对一、一对多、多对多的关系映射,还有迁移机制,改完模型跑一句python manage.py makemigrations就能同步到数据库,不用手写 SQL。Admin 后台更是做人事系统的一把好手——HR 部门需要快速录入、编辑员工信息,Django Admin 配上几个 register 就能把一套完整的管理后台搭出来。

而 Flask 是一个微框架,本身只提供了路由和请求处理的基础能力。ORM 要自己接 SQLAlchemy,表单要自己接 WTForms,Admin 要自己找 Flask-Admin,登录认证要自己折腾 Flask-Login。这些库拼起来确实也能工作,但版本兼容、使用习惯、技术文档的碎片化程度,足以让一个刚入门的开发者在一个小问题上卡好几天。

我的建议很直接:如果你的目标是"公司里能用的完整人事系统",选 Django;如果目标只是"给一个现有系统写几个供前端调用的接口",那 Flask 的轻量反而更合适。人事系统这种重模型、重权限、重后台录入的项目,Django 是省心路线。

1.2 用MTV模式理解Django的组织方式

很多新手在热词里搜"django 之 MTV 模式的 MTV 有什么作用",其实就是没搞清楚 Django 的代码到底怎么分层。MTV 就是 Model-Template-View:

  • Model 负责定义数据结构和操作数据的逻辑,对应数据库表
  • Template 负责页面渲染,也就是用户看到的 HTML
  • View 负责接收请求、调用 Model 拿数据、把数据塞给 Template

这套分层对人事系统的意义在于:页面多、逻辑重,如果不分层,把 SQL 查询写在视图函数里、把业务判断写在模板里,项目三个月后就会变成谁都不敢动的屎山。MTV 强制你把手写 SQL 换成 ORM、把数据加工逻辑放到 Model 或 Service 层、把展示逻辑留在模板,维护成本会低很多。

1.3 什么情况下才该用Flask

不是说 Flask 一无是处。如果你接了一个项目,只需要提供一套员工查询 API 给前端,不涉及复杂权限和后台录入,Flask 加 SQLAlchemy 轻装上阵完全够用。又或者你准备做微服务拆分,把组织架构服务、考勤服务拆成独立的小应用,Flask 也更符合"每个服务小而独立"的目标。

但如果你在 Flask 里为了做权限管理,网上搜半天,看到的是各种第三方库版本不兼容的报错,然后开始自己手写 Session 和装饰器做角色控制,这时候就得停下来想一想:你其实已经在重新发明 Django 已经内置的东西了。时间应该花在业务逻辑上,而不是花在补全框架短板上。

2. 先梳理业务闭环再动手写代码:人事系统的模块边界

2.1 人事系统的核心业务链路

人事管理系统不是"员工表 + 增删改查"这么简单。真正的业务链路是这样的:

组织架构确定部门归属,员工入职时在组织架构下建立档案,然后每天产生考勤数据,考勤影响请假和加班,请假单要走审批流,月底把考勤、请假、绩效汇总起来核算薪资,最后生成工资条。这条链路每一环的数据都会影响下一环,所以建模时必须把关系理清,而不是各做各的。

我做这套系统时第一步不是写代码,而是跟公司的 HR 聊了两个小时,把她们的日常操作和月底对账流程完整过了一遍。聊完才发现很多需求是文档里看不出来的:比如她们要批量导入历史员工数据,比如离职员工的信息不能删只能改状态,比如部门调整后历史薪资数据里要能查到调整前的部门名称。

2.2 MVP版本该做哪些模块,不该做哪些

很多开发者的毛病是一上来就想把所有模块做齐,招聘、培训、绩效、考试、证书全都要。我的经验是:第一版只做五个模块就够用。

模块核心对象必须实现的业务规则
组织管理部门、职位部门树可上下级,部门可停用但不可删除
员工档案员工工号唯一,手机号/身份证校验,离职后状态变更
考勤管理打卡记录每天每人一条,迟到/早退自动标记
请假管理请假单提交后走审批,审批人可驳回并填原因
薪资管理薪资流水每人每月一条,按公式计算实发工资,不可重复生成

其他像招聘、培训、绩效这些,完全可以等主链路跑通之后再加。原因很简单:人事系统的核心价值是"算得准、查得清",先把考勤、请假、薪资这些跟钱相关的数据链路做扎实,比堆功能重要得多。

另外有一个我踩过坑的模块边界问题:不要在第一个版本做复杂的排班系统。排班牵扯到班次、轮换、节假日调休,复杂度远超想象。MVP 阶段用固定打卡时间(早九晚六)来处理就足够了,等运行稳定再考虑扩展。

2.3 几条必须提前定好的基础业务规则

以下规则是 HR 系统里最容易扯皮的地方,也是必须提前在代码里定死的:

  • 员工离职后不能删除记录,只能把状态改成"已离职",所有历史数据要保留
  • 部门被合并或解散后,历史员工数据中的部门关联不能被外键删除逻辑连带删掉
  • 请假审批未通过之前,申请人可以撤销重新编辑
  • 当月的薪资记录只能生成一次,不允许重复生成,除非有权限的人手动解锁
  • 薪资计算以审批通过的请假和考勤记录为准,未审批的数据不算数

这些规则都直接决定了模型字段和代码逻辑怎么设计。比如"员工不能删除",就意味着部门外键的on_delete要用PROTECT而不是CASCADE;"每月薪资唯一",就要在数据库层面加唯一约束而不是靠代码判断。

3. 核心数据模型设计:员工、部门、考勤与薪资的落库方案

3.1 组织架构模型:部门自关联

部门表最常规的做法是自关联外键,parent指向自己的主键,形成一棵树。别用层级数字(比如 01/0101/010101)去硬编码层级,那样部门一多、层级一深,查询和调整都会非常痛苦。

class Department(models.Model): name = models.CharField('部门名称', max_length=100) parent = models.ForeignKey( 'self', verbose_name='上级部门', null=True, blank=True, on_delete=models.CASCADE, related_name='children' ) sort_order = models.IntegerField('排序', default=0) is_active = models.BooleanField('是否启用', default=True) created_at = models.DateTimeField(auto_now_add=True) class Meta: ordering = ['sort_order', 'id'] def __str__(self): return self.name

注意related_name='children',这样通过department.children.all()就能拿到所有下级部门。is_active字段用来做部门停用,而不是删除,这样历史数据不会断链。

3.2 员工档案与用户账户的绑定方式

员工表和用户表的关系是人事系统建模时第一个要决策的点。我的方案是:用户认证走 Django 内置的auth.User,员工档案单独建一张Employee表,两者用OneToOneField关联。

class Employee(models.Model): STATUS_CHOICES = [ ('probation', '试用期'), ('active', '在职'), ('resigned', '已离职'), ] emp_no = models.CharField('工号', max_length=20, unique=True) name = models.CharField('姓名', max_length=50) gender = models.CharField('性别', max_length=10, choices=[('male', '男'), ('female', '女')]) department = models.ForeignKey( Department, verbose_name='所属部门', on_delete=models.PROTECT, related_name='employees' ) position = models.CharField('职位', max_length=50) phone = models.CharField('手机号', max_length=20, blank=True) email = models.EmailField('邮箱', blank=True) hire_date = models.DateField('入职日期') status = models.CharField('在职状态', max_length=20, choices=STATUS_CHOICES, default='probation') user = models.OneToOneField( settings.AUTH_USER_MODEL, verbose_name='关联登录用户', null=True, blank=True, on_delete=models.SET_NULL, related_name='employee_profile' ) created_at = models.DateTimeField(auto_now_add=True) class Meta: ordering = ['emp_no'] def __str__(self): return f'{self.emp_no} {self.name}'

为什么不直接在 Employee 表里塞 username 和 password?因为我希望认证逻辑和业务数据解耦。HR 不一定每个员工都要开系统账号,但每个员工都要有档案。如果员工表里直接放账号密码字段,那没有账号的员工就得硬造一个空密码出来,逻辑会很别扭。用OneToOneField关联之后,有账号的员工就employee.user = user,没有账号的员工employee.user = None,各不干扰。

on_delete=models.PROTECT是刻意的:部门下面有员工时不允许删除部门,必须先把员工转走。这个约束防止了误操作导致的数据灾难。

3.3 请假、考勤、薪资流水模型设计

请假单要记录申请人和审批人,状态字段保存流程位置,这是最基础的审批流模型。

class LeaveApplication(models.Model): LEAVE_TYPE_CHOICES = [ ('annual', '年假'), ('personal', '事假'), ('sick', '病假'), ('marriage', '婚假'), ('maternity', '产假'), ] STATUS_CHOICES = [ ('pending', '待审批'), ('approved', '已通过'), ('rejected', '已驳回'), ('cancelled', '已撤销'), ] employee = models.ForeignKey( Employee, verbose_name='申请人', on_delete=models.CASCADE, related_name='leave_applications' ) leave_type = models.CharField('请假类型', max_length=20, choices=LEAVE_TYPE_CHOICES) start_time = models.DateTimeField('开始时间') end_time = models.DateTimeField('结束时间') reason = models.TextField('请假事由') status = models.CharField('审批状态', max_length=20, choices=STATUS_CHOICES, default='pending') approver = models.ForeignKey( Employee, verbose_name='审批人', null=True, blank=True, on_delete=models.SET_NULL, related_name='approved_leaves' ) reject_reason = models.TextField('驳回原因', blank=True) created_at = models.DateTimeField('提交时间', auto_now_add=True) class Meta: ordering = ['-created_at']

考勤表简单一点,每天每人一条打卡记录,字段带上日期和上下班时间就够了,用UniqueConstraint保证同一个人同一天只能有一条记录。

薪资流水表的核心约束是"员工 + 月份"唯一,我用UniqueConstraint直接加在数据库层,比在代码里先查再插要可靠得多,并发也不会出事。

class SalaryRecord(models.Model): employee = models.ForeignKey( Employee, verbose_name='员工', on_delete=models.CASCADE, related_name='salary_records' ) month = models.CharField('工资月份', max_length=7) # 格式:2025-05 base_salary = models.DecimalField('基础工资', max_digits=10, decimal_places=2) performance_bonus = models.DecimalField('绩效奖金', max_digits=10, decimal_places=2, default=0) overtime_pay = models.DecimalField('加班费', max_digits=10, decimal_places=2, default=0) deduction = models.DecimalField('扣款', max_digits=10, decimal_places=2, default=0) net_salary = models.DecimalField('实发工资', max_digits=10, decimal_places=2, editable=False) created_at = models.DateTimeField('生成时间', auto_now_add=True) class Meta: ordering = ['-month'] constraints = [ models.UniqueConstraint(fields=['employee', 'month'], name='uniq_employee_month') ]

4. Django RBAC权限体系:从内置auth到角色权限模型

4.1 用Group实现角色,用Permission定义权限点

Django 内置的 auth 应用自带 User、Group、Permission 三个模型,天然实现了 RBAC 里的核心逻辑。简单说:User 属于 Group,Group 拥有 Permission,用户最终拥有的权限等于他所属所有 Group 权限的并集。

在人事系统里,我把角色直接映射成 Group:

  • 系统管理员:所有权限
  • HR 专员:员工档案的增删改查、薪资生成、请假审批
  • 部门主管:查看本部门员工、审批本部门请假单
  • 普通员工:查看自己的档案、提交请假申请

权限点用 Django 模型里的 Meta permissions 自定义。比如在 Employee 模型里加一个"停用/离职操作"的权限点,这个权限不会随着某个模型自动生成,需要显式声明:

class Employee(models.Model): # ...字段省略 class Meta: ordering = ['emp_no'] permissions = [ ('can_terminate_employee', '可以办理员工离职'), ]

创建好权限后,通过group.permissions.add(permission)把权限点挂到对应角色上,流程就通了。

4.2 视图层和模板层的权限控制写法

视图层用装饰器做粗粒度拦截,@login_required保证必须登录,@permission_required保证必须拥有特定权限码。

from django.contrib.auth.decorators import login_required, permission_required @login_required @permission_required('hr.can_terminate_employee', raise_exception=True) def terminate_employee(request, employee_id): # 办理离职逻辑 ...

这里有个细节必须提醒:raise_exception=True是必需的。如果不加,没有权限的用户会被重定向到登录页,但明明已经登录了,页面就会变成"让我重新登录"的死循环,你会收到一堆用户反馈说系统疯了。

模板层用perms变量做细粒度控制,比如只有有权限的人才能看到"离职"按钮:

{% if perms.hr.can_terminate_employee %} <a href="{% url 'hr:terminate_employee' employee.id %}" class="btn btn-danger">办理离职</a> {% endif %}

4.3 行级权限:部门主管只能看到本部门员工

RBAC 权限模型只能解决"能不能访问这个页面",解决不了"能看到哪些数据"这个问题。部门主管登录后,理论上可以访问员工列表,但列表里只应该出现他本部门的员工。这就是行级权限。

实现方式是在查询时根据当前用户所属部门过滤数据。我建议写成一个 service 函数,而不是在每个视图里重复拼 QuerySet:

def get_visible_employees(user): """返回当前用户可见的员工 QuerySet""" employee = user.employee_profile # 通过 OneToOne 反向拿到员工档案 if not employee: return Employee.objects.none() # 系统管理员和 HR 看全部 if user.groups.filter(name__in=['系统管理员', 'HR专员']).exists(): return Employee.objects.all() # 部门主管看本部门员工(包含子部门,这里按一级部门处理,已足够用) return Employee.objects.filter(department_id=employee.department_id)

视图里直接调用这个函数,模板渲染、Excel 导出、薪资核算全走同一套可见范围逻辑,避免你到处写一遍权限判断出现遗漏。

5. 三个核心功能的落地实现:档案、请假审批、薪资核算

5.1 员工档案:搜索、分页与表单校验

员工列表页是 HR 最常用的页面,搜索条件至少要覆盖姓名、工号、部门、状态四个维度。Django 的Q对象可以轻松实现多条件组合查询:

def employee_list(request): employees = get_visible_employees(request.user) keyword = request.GET.get('keyword', '').strip() dept_id = request.GET.get('department', '') status = request.GET.get('status', '') if keyword: employees = employees.filter( Q(name__icontains=keyword) | Q(emp_no__icontains=keyword) ) if dept_id: employees = employees.filter(department_id=dept_id) if status: employees = employees.filter(status=status) paginator = Paginator(employees, 20) page_obj = paginator.get_page(request.GET.get('page')) ...

数据量大了之后,这种列表页最容易踩的性能坑是 N+1 查询。列表里要显示部门名称,如果逐条访问employee.department.name,20 条数据会多出 20 条 SQL。解决办法是查询时加select_related('department'),把关联部门一次性 JOIN 出来。

表单校验方面,身份证号建议只校验长度和格式,不要自己去算校验位,那是一个容易出错又没有太大实际收益的投入。手机号用正则校验就够。入职日期和离职日期的先后顺序校验则必须有,否则会出现离职时间比入职还早的闹剧。

5.2 请假审批:一个简单的状态机

请假审批的流程是"待审批 -> 已通过 / 已驳回",中间还可以撤销。状态流转不复杂,但我强烈建议用状态机的方式封装方法,而不是在视图里直接改status字段。

class LeaveApplication(models.Model): # ...字段省略 def submit(self, approver_employee): """提交后指定审批人""" if self.status != 'draft': raise ValidationError('只有草稿状态的请假单才能提交') self.status = 'pending' self.approver = approver_employee self.save(update_fields=['status', 'approver', 'updated_at']) def approve(self, user): """审批通过""" if self.status != 'pending': raise ValidationError('当前状态不可审批') if self.approver.user_id != user.id: raise PermissionError('你不是该单据的审批人') self.status = 'approved' self.save(update_fields=['status', 'updated_at']) def reject(self, user, reason): """审批驳回""" if self.status != 'pending': raise ValidationError('当前状态不可审批') if self.approver.user_id != user.id: raise PermissionError('你不是该单据的审批人') self.status = 'rejected' self.reject_reason = reason self.save(update_fields=['status', 'reject_reason', 'updated_at'])

把状态流转封装在模型方法里有一个额外的好处:不管你在视图里、Admin 后台还是命令行脚本里调用,审批逻辑都不会被绕过。有一次我就是发现有人在 Django Admin 后台直接把请假单的 status 改成了 approved,完全绕过了审批人判断。封装之后,后台所有改动都必须走方法,就没有这个漏洞了。

5.3 薪资核算:月度唯一性与计算逻辑

薪资核算我单独写了一个 service 函数,没有放在模型里,因为这里的业务逻辑较为复杂,涉及多张表的聚合计算。

核算逻辑大致是:基础工资 + 绩效奖金 + 加班费 - 事假扣款 - 社保公积金。其中事假扣款按天计算:月薪除以当月应出勤天数得出日薪,旷工和事假按实际天数扣除。

def generate_monthly_salary(employee, month): """生成某员工指定月份的薪资记录""" if SalaryRecord.objects.filter(employee=employee, month=month).exists(): raise ValidationError(f'{month} 薪资已生成,不能重复生成') base = employee.base_salary # 实际项目中建议建 SalaryStandard 表 performance = calculate_performance(employee, month) overtime_pay = calculate_overtime(employee, month) leave_deduction = calculate_leave_deduction(employee, month) net_salary = base + performance + overtime_pay - leave_deduction SalaryRecord.objects.create( employee=employee, month=month, base_salary=base, performance_bonus=performance, overtime_pay=overtime_pay, deduction=leave_deduction, net_salary=net_salary, )

关于"重复生成"的问题,我用了两层保障:代码里先查一次给用户友好提示,数据库层的UniqueConstraint兜底,防止并发情况下的偶发重复。月底生成薪资时,事务一定要包好,否则算到一半出错会留下半张废表。

from django.db import transaction def generate_salaries_for_month(month, user): employees = get_visible_employees(user) with transaction.atomic(): for emp in employees: generate_monthly_salary(emp, month)

6. 上线部署与高频坑位:Waitress + Nginx + 静态文件/附件路径

6.1 开发模式切生产模式的正确姿势

Django 自带的runserver是开发服务器,单线程、性能差、并发一高就假死,绝对不能用于生产环境。Linux 服务器上常用 gunicorn,Windows 服务器上推荐使用 Waitress。

pip install waitress waitress-serve --listen=0.0.0.0:8000 hrms_project.wsgi:application

如果你用 Windows Server 做企业内网部署,Waitress 是少有的稳定方案。进程守护可以用 NSSM 把上面的命令注册成 Windows 服务,开机自启,掉线自动拉起。

加上 Nginx 做反向代理后,架构是这样:用户请求打给 Nginx,Nginx 把动态请求转发给 Waitress 的 8000 端口,静态文件由 Nginx 直接处理。这样 Waitress 只处理业务逻辑,负担小很多。

server { listen 80; server_name hr.example.com; location /static/ { alias /opt/hrms/collected_static/; } location /media/ { alias /opt/hrms/uploads/; } location / { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } }

6.2 DEBUG=False之后静态文件404

这是每个 Django 部署者都会遇到的一关:本地开发时样式图片都正常,一上线没改任何代码,静态文件全挂了。原因是 DEBUG=False 时 Django 不再提供静态文件服务,必须由 web 服务器(Nginx)或者 WhiteNoise 来做。

根本解决路径是三步走。第一,在 settings 里把STATIC_ROOT配成一个独立目录。第二,运行python manage.py collectstatic,把所有应用里的静态文件集中复制到STATIC_ROOT。第三,Nginx 的 alias 路径要和STATIC_ROOT保持一致,别配错。

排查顺序我也给你列好:先看STATIC_ROOT目录下有没有文件,没有就先 collectstatic;目录有文件就看 Nginx 路径是否匹配;路径没问题就看目录权限,Nginx 运行用户能不能读取。80% 的问题都能在这三步里找到答案。

还有一个容易忽略的:collectstatic之后如果不小心改了静态文件,必须重新执行一次,否则线上还是旧文件。建议在部署脚本里固定加上这一步,别手动操作。

6.3 附件上传路径与文件名

人事系统里附件上传的场景非常多:员工照片、简历附件、劳动合同扫描件、离职证明。这些文件的存储和管理,有几个坑是必须提前避开的。

第一个坑是路径分隔符。Windows 上用\,Linux 上用/,如果代码里写死相对路径或者手动拼字符串,换服务器部署就炸。正确做法是用os.path.join或者直接使用 Django 的upload_to参数,让框架来拼路径:

def employee_avatar_path(instance, filename): ext = filename.rsplit('.', 1)[-1] # 取原始扩展名 return f'avatars/{instance.emp_no}_{uuid.uuid4().hex}.{ext}'

第二个坑是文件名。用户上传的图片可能叫"张三.jpg",但在不同系统里编码不同,浏览器缓存可能冲突,重名文件还会互相覆盖。所以我在upload_to里强制用工号 + UUID 重命名,原始文件名只保留扩展名,既避免乱码又避免覆盖。

第三个坑是MEDIA_ROOT必须是绝对路径。Django 的BASE_DIR是动态计算的,配合BASE_DIR / 'uploads'就能保证在任何机器上都能定位到正确目录。千万别写死C:\myproject\uploads这种路径,代码换台电脑就废了。

settings 里的配套配置也一并给出:

MEDIA_URL = '/media/' MEDIA_ROOT = BASE_DIR / 'uploads' # Nginx 里对应配置: # location /media/ { # alias /opt/hrms/uploads/; # }

如果部署后发现附件路径不对,我的排查顺序是:先看上传后文件实际落在哪个目录,少了或者错了就检查 MEDIA_ROOT;再看浏览器访问的 URL 带什么前缀,确定 MEDIA_URL 配置;最后看 Nginx 转发逻辑,特别是 alias 和 proxy_pass 两个 location 不要配反。

在我实际把人事系统部署到公司服务器上的过程中,踩得最惨的其实就是最后这三块——DEBUG 开关、静态文件收集、附件路径。代码逻辑跑通从来不是终点,把这些部署细节收拾干净,系统才算真正能用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询