ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

用Django构建企业人事管理系统:从选型到部署的完整实践

用Django构建企业人事管理系统:从选型到部署的完整实践 最近连着好几个朋友来问同一个问题公司想上一套人事管理系统技术栈定了 Python但框架到底选 Django 还是 Flask这问题问多了之后我发现真正让大家纠结的不是框架本身而是这套系统到底要承担多少事。如果你也在考虑用 Python 从零搭建人力资源管理系统我可以直接给结论要做功能完整、权限清晰、能长期维护的 HRM 系统优先选 DjangoFlask 不是不能做但你会把框架缺的那部分轮子一个个自己造回来。为什么这么判断这套系统从模型设计到上线部署有哪些绕不开的细节这篇就按我实际做过的一个企业人事管理项目来拆开讲。1. 选型判断为什么人事系统首选Django而不是Flask1.1 Django和Flask的核心差异人事管理系统本质上是什么是一大堆关联表加上围绕这些表的增删改查、审批流、角色权限和报表统计。它不像博客系统那样只有文章和标签两种模型也不像纯 API 服务那样只需要把数据序列化抛出去。一个中等规模的公司人事系统光核心业务表就有组织架构、员工档案、考勤、请假、加班、薪资、培训记录、合同信息这些表之间的关系错综复杂。Django 在这类场景上的优势是碾压性的。它自带的 ORM 不仅支持一对一、一对多、多对多的关系映射还有迁移机制改完模型跑一句python manage.py makemigrations就能同步到数据库不用手写 SQL。Admin 后台更是做人事系统的一把好手——HR 部门需要快速录入、编辑员工信息Django Admin 配上几个 register 就能把一套完整的管理后台搭出来。而 Flask 是一个微框架本身只提供了路由和请求处理的基础能力。ORM 要自己接 SQLAlchemy表单要自己接 WTFormsAdmin 要自己找 Flask-Admin登录认证要自己折腾 Flask-Login。这些库拼起来确实也能工作但版本兼容、使用习惯、技术文档的碎片化程度足以让一个刚入门的开发者在一个小问题上卡好几天。我的建议很直接如果你的目标是公司里能用的完整人事系统选 Django如果目标只是给一个现有系统写几个供前端调用的接口那 Flask 的轻量反而更合适。人事系统这种重模型、重权限、重后台录入的项目Django 是省心路线。1.2 用MTV模式理解Django的组织方式很多新手在热词里搜django 之 MTV 模式的 MTV 有什么作用其实就是没搞清楚 Django 的代码到底怎么分层。MTV 就是 Model-Template-ViewModel 负责定义数据结构和操作数据的逻辑对应数据库表Template 负责页面渲染也就是用户看到的 HTMLView 负责接收请求、调用 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_length100) parent models.ForeignKey( self, verbose_name上级部门, nullTrue, blankTrue, on_deletemodels.CASCADE, related_namechildren ) sort_order models.IntegerField(排序, default0) is_active models.BooleanField(是否启用, defaultTrue) created_at models.DateTimeField(auto_now_addTrue) class Meta: ordering [sort_order, id] def __str__(self): return self.name注意related_namechildren这样通过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_length20, uniqueTrue) name models.CharField(姓名, max_length50) gender models.CharField(性别, max_length10, choices[(male, 男), (female, 女)]) department models.ForeignKey( Department, verbose_name所属部门, on_deletemodels.PROTECT, related_nameemployees ) position models.CharField(职位, max_length50) phone models.CharField(手机号, max_length20, blankTrue) email models.EmailField(邮箱, blankTrue) hire_date models.DateField(入职日期) status models.CharField(在职状态, max_length20, choicesSTATUS_CHOICES, defaultprobation) user models.OneToOneField( settings.AUTH_USER_MODEL, verbose_name关联登录用户, nullTrue, blankTrue, on_deletemodels.SET_NULL, related_nameemployee_profile ) created_at models.DateTimeField(auto_now_addTrue) 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_deletemodels.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_deletemodels.CASCADE, related_nameleave_applications ) leave_type models.CharField(请假类型, max_length20, choicesLEAVE_TYPE_CHOICES) start_time models.DateTimeField(开始时间) end_time models.DateTimeField(结束时间) reason models.TextField(请假事由) status models.CharField(审批状态, max_length20, choicesSTATUS_CHOICES, defaultpending) approver models.ForeignKey( Employee, verbose_name审批人, nullTrue, blankTrue, on_deletemodels.SET_NULL, related_nameapproved_leaves ) reject_reason models.TextField(驳回原因, blankTrue) created_at models.DateTimeField(提交时间, auto_now_addTrue) class Meta: ordering [-created_at]考勤表简单一点每天每人一条打卡记录字段带上日期和上下班时间就够了用UniqueConstraint保证同一个人同一天只能有一条记录。薪资流水表的核心约束是员工 月份唯一我用UniqueConstraint直接加在数据库层比在代码里先查再插要可靠得多并发也不会出事。class SalaryRecord(models.Model): employee models.ForeignKey( Employee, verbose_name员工, on_deletemodels.CASCADE, related_namesalary_records ) month models.CharField(工资月份, max_length7) # 格式2025-05 base_salary models.DecimalField(基础工资, max_digits10, decimal_places2) performance_bonus models.DecimalField(绩效奖金, max_digits10, decimal_places2, default0) overtime_pay models.DecimalField(加班费, max_digits10, decimal_places2, default0) deduction models.DecimalField(扣款, max_digits10, decimal_places2, default0) net_salary models.DecimalField(实发工资, max_digits10, decimal_places2, editableFalse) created_at models.DateTimeField(生成时间, auto_now_addTrue) class Meta: ordering [-month] constraints [ models.UniqueConstraint(fields[employee, month], nameuniq_employee_month) ]4. Django RBAC权限体系从内置auth到角色权限模型4.1 用Group实现角色用Permission定义权限点Django 内置的 auth 应用自带 User、Group、Permission 三个模型天然实现了 RBAC 里的核心逻辑。简单说User 属于 GroupGroup 拥有 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_exceptionTrue) def terminate_employee(request, employee_id): # 办理离职逻辑 ...这里有个细节必须提醒raise_exceptionTrue是必需的。如果不加没有权限的用户会被重定向到登录页但明明已经登录了页面就会变成让我重新登录的死循环你会收到一堆用户反馈说系统疯了。模板层用perms变量做细粒度控制比如只有有权限的人才能看到离职按钮{% if perms.hr.can_terminate_employee %} a href{% url hr:terminate_employee employee.id %} classbtn btn-danger办理离职/a {% endif %}4.3 行级权限部门主管只能看到本部门员工RBAC 权限模型只能解决能不能访问这个页面解决不了能看到哪些数据这个问题。部门主管登录后理论上可以访问员工列表但列表里只应该出现他本部门的员工。这就是行级权限。实现方式是在查询时根据当前用户所属部门过滤数据。我建议写成一个 service 函数而不是在每个视图里重复拼 QuerySetdef 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_idemployee.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__icontainskeyword) | Q(emp_no__icontainskeyword) ) if dept_id: employees employees.filter(department_iddept_id) if status: employees employees.filter(statusstatus) paginator Paginator(employees, 20) page_obj paginator.get_page(request.GET.get(page)) ...数据量大了之后这种列表页最容易踩的性能坑是 N1 查询。列表里要显示部门名称如果逐条访问employee.department.name20 条数据会多出 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(employeeemployee, monthmonth).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( employeeemployee, monthmonth, base_salarybase, performance_bonusperformance, overtime_payovertime_pay, deductionleave_deduction, net_salarynet_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 服务器上常用 gunicornWindows 服务器上推荐使用 Waitress。pip install waitress waitress-serve --listen0.0.0.0:8000 hrms_project.wsgi:application如果你用 Windows Server 做企业内网部署Waitress 是少有的稳定方案。进程守护可以用 NSSM 把上面的命令注册成 Windows 服务开机自启掉线自动拉起。加上 Nginx 做反向代理后架构是这样用户请求打给 NginxNginx 把动态请求转发给 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 DEBUGFalse之后静态文件404这是每个 Django 部署者都会遇到的一关本地开发时样式图片都正常一上线没改任何代码静态文件全挂了。原因是 DEBUGFalse 时 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 favatars/{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 开关、静态文件收集、附件路径。代码逻辑跑通从来不是终点把这些部署细节收拾干净系统才算真正能用。
返回列表