ARTICLE DETAIL

资讯详情

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

Django+Flask组合搭建人事招聘培训管理系统:架构、实现与踩坑

Django+Flask组合搭建人事招聘培训管理系统:架构、实现与踩坑 最近帮朋友公司搭了一套人事应聘培训管理系统从需求梳理到上线部署前后折腾了两周。听起来就是一个常规的增删改查真正跑起来才发现简历筛选、面试安排、入职登记、培训计划这些环节串到一起之后几乎每个地方都有几个容易翻车的坑。这套系统我用的 Python 生态Django 做管理后台和核心数据Flask 做对外招聘流程接口和轻量页面展示两者共用一套 MySQL。整体上线后能稳定支撑几百人规模的应聘和培训数据管理HR 不用再靠 Excel 来回传每一步状态也都有迹可循。这篇文章就把这套系统的设计思路、关键实现和踩坑记录完整捋一遍给正在做类似项目的朋友一个可以直接参考的样本。1. 项目定位与需求拆解1.1 这个系统到底要管哪些事企业人事系统表面上就是“人员信息管理”但实际业务拆开之后核心其实是三个流程招聘流程、员工档案、培训流程。招聘流程要从职位发布、候选人简历录入、初筛、面试安排、Offer 到最终入职培训流程要从培训课程、培训计划、员工报名、签到考核到成绩归档。这些流程之间还互相咬合比如候选人通过面试后转成员工员工入职之后又可能参与新的培训计划。我之前见过不少团队直接把这三块做成三个独立小系统结果数据各存各的候选人入职之后员工档案里还得重新录一遍培训记录又想不起来关联到谁头上。所以这套系统的第一设计原则就是数据同源。部门、岗位、员工是主数据候选人和培训记录都通过外键指向这些主数据同一个员工从应聘到入职再到培训全程用同一套 ID 关联避免重复录入。除了流程管理权限也要一起考虑。HR 专员能看候选人、录简历培训专员只能维护课程和培训计划部门主管能查看自己部门的员工培训情况但改不了其他部门的数据。这块我直接用 Django 自带 auth 加自定义角色表实现没额外引权限框架因为系统规模几万人以内RBAC 足够用再上 Django REST Framework 那套权限体系反而有点重。1.2 为什么选 FlaskDjango 组合看到“基于 Flask-Django”这个描述很多人第一反应是“两个框架选一个不就行了”。说实话纯从零开发我通常建议直接用 Django 一套搞定它自带 ORM、Admin、Migration、Auth后台管理功能几乎不用自己造轮子。但这次需求里有一个特殊情况公司同时对接了好几个外部招聘平台需要提供一套轻量、独立的接口来接收简历投递数据还要支持后续扩展候选人自助查询面试状态。如果这些接口也往 Django 项目里塞不是不行但会让整个请求链路过重而且外部平台对接往往调得频繁、逻辑变化快放在一个轻量服务里更灵活。于是我把架构拆成两个服务服务框架职责管理后台端Django部门、岗位、员工、培训、权限、核心业务状态管理对外接口端Flask接收外部简历投递、候选人状态查询、轻量展示页接口两个服务共用同一个 MySQL 数据库。Django 负责全部表结构的 migrate 操作Flask 侧只做数据查询和写入不做任何表结构变更。实际部署时 Django 跑在 8001 端口Flask 跑在 8002 端口Nginx 按 URL 前缀转发。比如/api/v1/开头走 Flask其他管理端请求走 Django。这个组合还有一个好处Flask 侧代码量少出问题时排查成本低外部平台接口调整时只需要改 Flask 一个文件不用动整个后台系统。代价就是要维护两套依赖和一个跨服务事务问题后面细说。2. 数据库设计与业务模型2.1 核心数据模型拆解先看一下最核心的几张表。部门表department、岗位表position、员工表employee这三个是基础主数据。候选人表candidate关联岗位面试记录表interview_record关联候选人和面试官培训课程表course、培训计划表training_plan、培训记录表training_record构成培训模块。Django 模型写法我尽量保持简单方便直接照抄。以候选人表为例from django.db import models class Candidate(models.Model): STATUS [ (new, 新简历), (shortlisted, 初筛通过), (interviewed, 已面试), (offered, 已发 Offer), (hired, 已入职), (rejected, 已淘汰), ] name models.CharField(max_length64, verbose_name姓名) phone models.CharField(max_length20, verbose_name手机号) email models.EmailField(blankTrue, verbose_name邮箱) position models.ForeignKey( Position, on_deletemodels.PROTECT, verbose_name应聘岗位 ) status models.CharField( max_length20, choicesSTATUS, defaultnew, verbose_name状态 ) resume models.FileField(upload_toresumes/, blankTrue, verbose_name简历) created_at models.DateTimeField(auto_now_addTrue, verbose_name创建时间) class Meta: db_table candidate indexes [models.Index(fields[position, status])]这里两个细节值得注意。一是on_delete我用了PROTECT岗位下面有人投了简历就不允许直接删岗位只有先把相关候选人处理掉才能删。二是给position和status加了联合索引因为招聘看板最常见的查询就是“某个岗位下所有待处理简历”联合索引能省掉一次回表排序。培训模块的模型稍微复杂一点因为一个培训计划可以有多个员工参加一个员工也可能参加多门课程这种多对多关系里还要记录出勤和成绩所以必须用中间表。class TrainingPlan(models.Model): title models.CharField(max_length128, verbose_name培训计划名称) course models.ForeignKey(Course, on_deletemodels.PROTECT, verbose_name课程) start_time models.DateTimeField(verbose_name开始时间) end_time models.DateTimeField(verbose_name结束时间) class TrainingRecord(models.Model): employee models.ForeignKey(Employee, on_deletemodels.CASCADE, verbose_name员工) plan models.ForeignKey(TrainingPlan, on_deletemodels.CASCADE, verbose_name培训计划) attended models.BooleanField(defaultFalse, verbose_name是否到场) score models.DecimalField(max_digits5, decimal_places2, nullTrue, blankTrue, verbose_name考核得分) summary models.TextField(blankTrue, verbose_name培训总结)很多新手一看到“参加人员”就直接写ManyToManyField(Employee)后面想加成绩、出勤字段就得改表重建非常痛苦。从一开始就定义一个中间模型TrainingRecord以后字段随便加这才是多对多关系里最稳的写法。2.2 候选人状态流转设计候选人状态是整个招聘模块的“主干道”每个 HR 打开系统第一眼看的都是看板目前有多少新简历、多少在面试中、多少已发 Offer。这个状态字段如果设计得不好后续每个功能都要返工。我推荐的方案是状态只存在候选人主表面试、Offer 这些过程数据放在关联的子表里。新人容易反着来把当前状态放在最后一条面试记录里查列表时还得先算“最大 id 的面试记录”SQL 复杂不说索引还很难优化。状态放主表后看板统计一行GROUP BY就能出结果from django.db.models import Count result Candidate.objects.values(status).annotate(totalCount(id))状态的流转一定要控制好。我虽然没上完整的状态机框架但所有可能的状态变化都集中在 service 层视图函数不允许直接candidate.status xxx。比如shortlist(candidate)函数里先判断candidate.status new不满足就抛异常。这样做是为了防止 HR 手滑把一个已经淘汰的人直接拖到 Offer 状态。2.3 员工与培训记录的关联培训模块的关键在于员工档案和培训记录不要做成两条平行线。员工表里不需要存“最近一次培训时间”这种冗余字段所有培训历史都从training_record查询。需要看某个员工的完整培训履历时一条 ORM 就能搞定records TrainingRecord.objects.filter(employeeemp).select_related(plan, plan__course)这里用select_related把培训计划和课程的外键一次 JOIN 查出来避免循环取数据时触发几十条 SQL。小数据量看不出区别培训记录上千条之后N1 查询会让页面卡到怀疑人生。3. 核心功能实现与关键代码3.1 Django 后台搭建与模型注册后端部分我是从零开始搭 Django 项目的基本步骤大家都熟python -m venv venv source venv/bin/activate # Windows 下是 venv\Scripts\activate pip install django django-admin startproject hrms cd hrms python manage.py startapp candidate python manage.py startapp training建完 app 之后把candidate和training加进INSTALLED_APPS再同步数据库python manage.py makemigrations python manage.py migrate python manage.py createsuperuserDjango 后台的真正威力在admin.py。如果只是把模型挂上去列表页难用搜索、筛选都没有。我一般会把列表配置写细一点from django.contrib import admin from .models import Candidate admin.register(Candidate) class CandidateAdmin(admin.ModelAdmin): list_display (name, phone, position, status, created_at) list_filter (status, position) search_fields (name, phone, email) list_select_related (position,) ordering (-created_at,)list_select_related这行很多人会漏不加它后台列表展示岗位名称时每条记录都会额外查一次position表候选人多了之后后台打开速度会肉眼可见地变慢。加了之后 Django 生成列表查询时会自动 JOIN 岗位表一次查完。3.2 Flask 提供招聘接口Flask 侧只负责接收外部简历投递和状态查询代码非常薄。主要用 Flask-SQLAlchemy 连接同一个数据库模型通过__tablename__直接映射到 Django 已经建好的表。from flask import Flask, jsonify, request, abort from flask_sqlalchemy import SQLAlchemy app Flask(__name__) app.config[SQLALCHEMY_DATABASE_URI] mysql://hr:your_passwordlocalhost/hrms?charsetutf8mb4 app.config[SQLALCHEMY_TRACK_MODIFICATIONS] False db SQLAlchemy(app) class Candidate(db.Model): __tablename__ candidate id db.Column(db.Integer, primary_keyTrue) name db.Column(db.String(64)) phone db.Column(db.String(20)) status db.Column(db.String(20), defaultnew) app.route(/api/v1/candidate, methods[POST]) def create_candidate(): data request.get_json(silentTrue) or {} if not data.get(name) or not data.get(phone): abort(400, 姓名和手机号必填) candidate Candidate(namedata[name], phonedata[phone]) db.session.add(candidate) db.session.commit() return jsonify({id: candidate.id}), 201外部平台调用接口的鉴权我用的是一个固定的 Bearer Token写了一个装饰器统一校验from functools import wraps def authorize(f): wraps(f) def wrapper(*args, **kwargs): auth request.headers.get(Authorization, ) if auth ! Bearer my_internal_token: abort(401) return f(*args, **kwargs) return wrapper简单归简单注意 token 不要硬编码在代码里放到环境变量或者配置文件里。生产环境里我见过太多人把 token 随手写在app.py顶部一提交到仓库就等于公开了。3.3 培训报名与考核记录实现培训报名的逻辑比较典型员工在前端选择课程系统先检查培训计划是否还在允许报名时间范围内然后创建TrainingRecord。这里最容易出的问题是重复报名所以要用数据库约束保证一个员工在一个计划里只能有一条记录。Django 里直接用UniqueConstraint写在模型 Meta 里最省心class TrainingRecord(models.Model): employee models.ForeignKey(Employee, on_deletemodels.CASCADE) plan models.ForeignKey(TrainingPlan, on_deletemodels.CASCADE) class Meta: constraints [ models.UniqueConstraint(fields[employee, plan], nameunique_training_record) ]视图层再做一层业务校验防止报错信息太难看from django.db import IntegrityError, transaction transaction.atomic def sign_up_employee_for_plan(employee_id, plan_id): plan TrainingPlan.objects.select_for_update().get(idplan_id) employee Employee.objects.get(idemployee_id) if plan.start_time timezone.now(): raise ValueError(该培训计划已开始不能再报名) try: record, created TrainingRecord.objects.get_or_create( employeeemployee, planplan ) return record, created except IntegrityError: raise ValueError(你已经报过这门课了)这里get_or_create加唯一约束双保险。select_for_update()是为了防止两个管理员同时操作同一个培训计划导致名额超卖虽然这次项目没做名额限制但留下这个写法以后要加“限报 20 人”就很方便。3.4 Django 查询与删除对象的正确姿势说到“执行查询-删除对象”很多人第一反应就是Candidate.objects.filter(...).delete()。但这个人事实时系统里我建议慎重物理删除。简历数据、培训记录都有审计价值真删了后面想追溯就很麻烦。我常用的做法是加一个is_active的布尔字段删除时只做状态标记# 物理删除不推荐用于核心业务表 Candidate.objects.filter(id1).delete() # 软删除推荐 Candidate.objects.filter(id1).update(is_activeFalse)列表查询默认只筛有效数据candidates Candidate.objects.filter(is_activeTrue, position_id10)这样即使用户误操作也只是把记录标记成无效后台随时能还原。注意软删除字段一定在每一条查询里都记得带上条件否则时间久了无效数据就会混进统计报表。可以在模型 Manager 层定义默认过滤省得每个 view 都写一遍。4. 部署与上线4.1 环境准备与依赖管理上线前先锁定 Python 版本我用的是 Python 3.10 Django 4.2 Flask 3.0。依赖文件requirements.txt里把版本号写死避免哪天自动装到不兼容的新版本。Django4.2.7 Flask3.0.0 flask-sqlalchemy3.0.5 mysqlclient2.2.0 gunicorn21.2.0Python 环境安装这里提醒一句安装时一定要勾选“Add Python to PATH”不然后续在命令行里敲python没反应白白浪费时间。Windows 上如果出现 “Python 不是内部或外部命令”多半是环境变量没配置好。4.2 Gunicorn 启动两个服务生产环境我不用 Flask/Django 自带的开发服务器开发服务器性能差而且没有处理高并发的设计。Django 端用 Gunicorn 启动python manage.py collectstatic --noinput gunicorn hrms.wsgi:application -w 3 -b 127.0.0.1:8001 --timeout 60Flask 端同样用 Gunicorngunicorn app:app -w 2 -b 127.0.0.1:8002 --timeout 30-w是 worker 进程数不建议盲目设大一般 CPU 核数 x 2 1 够用。小公司内部系统Django 端 3 个 worker、Flask 端 2 个 worker 已经能撑住日常访问。--timeout一定要设否则某个慢接口卡住时 worker 会一直占着不释放最后整个服务不可用。Nginx 配置按路径分流server { listen 80; server_name hr.example.com; location /static/ { alias /data/hrms/static/; } location /media/ { alias /data/hrms/media/; } location /api/ { proxy_pass http://127.0.0.1:8002; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location / { proxy_pass http://127.0.0.1:8001; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }注意静态文件和媒体文件一定要让 Nginx 直接处理不要经过 Python 进程。Django 的collectstatic会把 admin 的 CSS/JS 复制到static目录Nginx 的 alias 路径末尾斜杠写不写是有讲究的强烈建议完事之后用浏览器检查一下资源加载路径这种小问题排查起来非常耗时间。4.3 附件路径的坑标题里提到的“附件路径错误”我这次也踩了。最开始开发机是 Windows直接在settings.py里写MEDIA_ROOT rD:\hrms\media结果代码部署到 Linux 服务器整个路径全部失效上传的简历全都报错。后来改成用pathlib基于项目目录拼接才彻底解决from pathlib import Path BASE_DIR Path(__file__).resolve().parent.parent MEDIA_ROOT BASE_DIR / media这样项目跑在哪个系统里路径都会自动跟随不用每次部署单独改。Flask 侧如果有文件上传文件名处理同样有坑。中文文件名直接用会导致编码乱和路径穿越风险要统一用secure_filename处理from werkzeug.utils import secure_filename filename secure_filename(file.filename)一个非常容易忽略的细节是secure_filename会把中文文件名直接替换成空字符串所以最好自己加一个时间戳前缀比如from datetime import datetime final_name f{datetime.now().strftime(%Y%m%d%H%M%S)}_{secure_filename(file.filename)}5. 常见问题与排查技巧5.1 两个服务共用数据库导致状态不同步Flask 接口和 Django 后台共用同一套表最怕出现“状态改了但另一边看到还是旧数据”。一开始我让两边都直接写candidate.status结果外部平台当天同步的简历Django 后台列表里过了半小时才看到一开始还以为是缓存查了半天才发现是 Flask 接口把状态写歪了。后来订了一条规矩业务状态字段写操作只允许从 Django 的 service 层走。Flask 收到的外部数据一律只做新增和查询状态流转由 Django 后台触发。如果公司已有招聘系统想直接改状态那就让外部平台调 Django 提供的内部 API不要在 Flask 侧用 SQLAlchemy 乱 update。5.2 CSRF 和跨域总是 403如果接口写在 Django 里用 POST 请求时会遇到 CSRF 校验403 是家常便饭。解决方案要看清楚场景管理后台必须保留 CSRF 防护不能为省事全局关掉对外开放的接口建议用 Django REST Framework 的permission_classes加 Token 认证或者直接让 Flask 侧接管这些接口。Flask 端如果被前端页面跨域调用要显式配置跨域白名单from flask_cors import CORS CORS(app, resources{r/api/*: {origins: [https://hr.example.com]}})不要直接CORS(app)全放开生产环境里这就等于把接口裸奔。5.3 中文数据乱码MySQL 里表和库的字符集必须统一成utf8mb4否则中文简历、培训总结存进去就是一堆问号。Django 连接数据库时在配置里显式指定DATABASES { default: { ENGINE: django.db.backends.mysql, NAME: hrms, USER: hr, PASSWORD: your_password, HOST: 127.0.0.1, PORT: 3306, OPTIONS: { charset: utf8mb4, }, } }Flask 侧连接 URL 也要加上?charsetutf8mb4两个服务保持一致。乱码问题最好在项目第一天就处理等数据攒多了再改字符集那种迁移真的会让人崩溃。5.4 面试记录里日期时间全是 UTCDjango 默认USE_TZ True如果不把TIME_ZONE设为Asia/Shanghai后台显示的创建时间会比本地时间差 8 小时。正确配置USE_TZ True TIME_ZONE Asia/Shanghai这里有个小坑数据库连接层的 timezone 也要跟着MySQL 里的time_zone如果保持默认 SYSTEM和 Django 的时区设置最好对得上。排查这个问题时不要只盯着 Python 代码里打印的时间还要去 MySQL 里SELECT NOW()看一下数据库自己的时间对不对。5.5 线上慢查询排查系统上线后最明显的一次卡顿出现在培训记录列表页。排查时先开 Django 的connection.queries或者直接看 MySQL 慢查询日志发现列表查询里每个员工的培训计划都执行了一次子查询。后来用select_related和prefetch_related优化把几十条 SQL 合并成两三条页面响应时间从 3 秒降到 300 毫秒以下。一个非常实用的原则是列表页展示的每一个字段都要问一句“这个字段要从哪个表取能不能一次取完”。6. 项目扩展与我的建议这套系统目前状态稳定但后续要加功能的话有几条方向可以提前想好。如果公司招聘量大面试官需要同时看到多个候选人的实时状态可以引入 Flask-SocketIO 或者 Django Channels后台有状态变更时主动推送到前端就不用同事频繁刷新页面了。但初期不建议一上来就上 WebSocket先把接口轮询做好等业务确实需要实时提醒再加异步推送排查起来比普通 HTTP 接口麻烦得多。关于 Flask 和 Django 的组合使用我的个人看法是别为了炫技强行拼两个框架。如果只是内部管理Django 一个框架完全够用只有当存在外部系统对接、接口和主业务逻辑边界很清晰时Flask 这种轻量服务才有价值。两个框架共用一个数据库一定要提前把谁管结构、谁管数据、事务边界在哪定清楚否则后面每次改表都会提心吊胆。人事系统最敏感的其实是数据安全岗位候选人、培训成绩这些数据一旦被删错或者漏出去影响面比代码报错严重得多。上线的每一个功能最好都在测试环境里把“录入—流转—入职—培训”整条流程完整走一遍再放开给 HR 用。
返回列表