直接上一套我最近完整跑通的项目:python基于Django框架的面向IT行业的招聘求职推荐系统。说白了,就是用Django做了一整套招聘网站的后端和推荐逻辑,求职者能看职位、搜职位、获取个性化推荐,企业端能发职位、筛选简历,后台可以直接管理数据。整个系统没有依赖外部推荐平台,推荐算法是用Python自己写的,数据存在MySQL里,部署也没走太重的方案。
如果你是想拿Python做实战项目、搞毕设,或者想给作品集加一个“带推荐算法”的完整Web系统,这篇内容应该能帮你省掉不少弯路。我会把项目怎么拆、数据表怎么设计、推荐算法怎么落地、以及我之前测试时才暴露的坑,全部写清楚。
1. 面向IT招聘场景,推荐系统该解决什么问题
1.1 招聘推荐和电商推荐的本质差别
市面上讲推荐系统的教程,十个里有九个拿电商举例:用户看了A商品,我们推荐相似商品。但招聘求职推荐和电商推荐有本质区别,一开始如果照搬电商思路,做出来的推荐会很弱。
电商推荐关心的是“买买买”,用户点击、加购、购买就是目标,推荐错了顶多是不买。招聘推荐是双向匹配:求职者在找工作,企业在招人,两边都有明确的条件约束。给求职者推一个技能完全不匹配的职位,哪怕用户行为数据再相似,也是无效推荐。所以在做这个项目的时候,我把“技能匹配度”放到了推荐核心位置,而不是把“点击行为”放第一位。
再说一个招聘特有现象:职位和简历都有“时效性”。一个后端岗位发布一周可能已经招满了,一份简历可能投了一周就找到工作了。电商的商品相对稳定,但招聘系统里的数据变化非常快,推荐策略必须考虑状态过滤,绝不能把已下线、已投递、已拒绝的职位再推给用户。
1.2 从标题反推系统边界:这些功能必须包含
“面向IT行业的招聘求职推荐系统”这个标题看起来简单,但拆开看其实包含了几条明确的需求线:
- 求职者端:注册登录、完善简历(技能、工作年限、期望城市、期望薪资)、浏览职位、搜索过滤、查看推荐列表、投递简历、查看投递状态。
- 企业端:发布职位、维护职位上下线、查看收到的简历列表、更新投递状态(筛选中、面试、不合适)。
- 推荐引擎:根据求职者简历和浏览/投递行为,从职位库中召回一批匹配的职位,排序后输出给用户。
- 后台管理:用Django Admin统一管理用户、职位、投递记录,方便管理员干预数据。
标题里的“面向IT行业”意味着字段设计要考虑IT行业特色,比如技能标签(Python、Java、Vue、Docker这些是硬通货)、工作模式(远程/驻场)、技术栈匹配度。这些字段都是后面推荐算法的素材,前期不建好,后面算法就空转。
1.3 为什么用Django而不是其他方案
这个项目技术栈直接用Django,一个重要原因是它自带的东西足够支撑整个项目,不需要额外拼装:
- 自带ORM和数据库迁移,数据模型改起来方便,招聘系统的表结构从简历到投递记录,迭代非常频繁,这个能力很关键。
- 自带Admin后台,企业端和管理员可以直接用Django Admin管理数据源,方便前期录入测试数据。
- 自带认证体系,用户的注册、登录、session管理不用自己造轮子。
- Python生态天然适配推荐算法,推荐引擎要处理文本相似度、用户行为矩阵,Python的字符串处理、集合运算、数据结构写起来比Java要顺手很多。
当然有人会说用Flask更轻量,但招聘系统不是一个只有几个页面的Demo,它有用户体系、多角色、业务状态流转。用Flask意味着这些组件要自己找方案,开发周期会拉长不少。Django的“约定优于配置”在这里不是缺点,反而能把你从重复劳动里解放出来,把精力放在推荐算法和业务逻辑上。
2. Django工程搭建与数据模型设计:面向招聘场景的表结构与App拆法
2.1 环境版本与项目初始化
我在本机开发用的是Python 3.10 + Django 4.2 + MySQL 8.0,操作系统是Windows,后面部署到Linux服务器用宝塔面板。这几个版本组合很成熟,网上遇到问题也好查。
项目名我内部代号叫itrecruit,标题里那个_a4av7只是项目发布时的内部编号后缀,实际写代码时可以忽略,不碍事。
初始化项目:
pip install django==4.2 mysqlclient django-admin startproject itrecruit cd itrecruit python manage.py startapp users python manage.py startapp positions python manage.py startapp applications python manage.py startapp reco数据库连接配置在settings.py里:
DATABASES = { 'default': { 'ENGINE': 'django.db.backends.mysql', 'NAME': 'itrecruit_db', 'USER': 'root', 'PASSWORD': 'your_password', 'HOST': '127.0.0.1', 'PORT': '3306', 'OPTIONS': { 'charset': 'utf8mb4', }, } }有一个坑必须提醒:MySQL 8.0的认证插件默认是caching_sha2_password,mysqlclient连的时候可能报错,要么装PyMySQL并在__init__.py里pymysql.install_as_MySQLdb(),要么在MySQL里把用户插件改回mysql_native_password。我在Linux上部署时用宝塔自带MySQL,直接装mysqlclient反而顺利,Windows上则用PyMySQL更省事。
2.2 四个App的划分逻辑
Django项目里App怎么拆,直接决定后面代码好不好维护。我拆了四个:
| App | 职责 | 核心模型 |
|---|---|---|
| users | 用户扩展、简历管理 | Profile、Resume |
| positions | 职位发布、职位检索 | Company、Position |
| applications | 投递记录、状态流转 | Application |
| reco | 推荐引擎、行为记录 | UserBehavior |
这样拆的核心依据是业务边界:用户、职位、投递、推荐,四个环节各自独立,互不绕圈子。尤其是把推荐引擎单独拆成一个App,里面可以写算法模块,不会污染业务视图。
2.3 核心模型的字段设计
职位模型是整个系统的核心数据源,字段设计直接关系后面的搜索和推荐:
from django.db import models class Position(models.Model): company_name = models.CharField(max_length=100, verbose_name='公司名称') title = models.CharField(max_length=100, verbose_name='职位名称') city = models.CharField(max_length=50, verbose_name='工作城市', default='北京') salary_min = models.IntegerField(verbose_name='最低薪资(K)') salary_max = models.IntegerField(verbose_name='最高薪资(K)') experience_required = models.CharField(max_length=50, verbose_name='经验要求', blank=True) skills = models.CharField(max_length=300, verbose_name='技能标签', help_text='多个技能用逗号分隔') description = models.TextField(verbose_name='职位描述') is_active = models.BooleanField(default=True, verbose_name='是否上架') view_count = models.IntegerField(default=0, verbose_name='浏览次数') created_at = models.DateTimeField(auto_now_add=True)技能标签字段我特意用逗号分隔的字符串,而不是再建一张多对多关联表。这个决策后面详细说,在推荐计算时,逗号分隔的字符串可以快速转成技能集合。
简历模型关联Django自带的User,扩展职业技能信息:
class Resume(models.Model): user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='resume') real_name = models.CharField(max_length=50, blank=True) years_experience = models.IntegerField(default=0, verbose_name='工作年限') skills = models.CharField(max_length=300, blank=True, verbose_name='技能标签') expected_city = models.CharField(max_length=50, blank=True) expected_salary_min = models.IntegerField(default=0) expected_salary_max = models.IntegerField(default=0) education = models.CharField(max_length=50, blank=True, verbose_name='学历') updated_at = models.DateTimeField(auto_now=True)投递记录模型记录每一次求职行为,也是推荐系统最珍贵的反馈信号:
class Application(models.Model): STATUS_CHOICES = ( ('submitted', '已投递'), ('viewed', '被查看'), ('interview', '面试中'), ('rejected', '不合适'), ('offered', '已发Offer'), ) user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='applications') position = models.ForeignKey(Position, on_delete=models.CASCADE, related_name='applications') status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='submitted') created_at = models.DateTimeField(auto_now_add=True) class Meta: unique_together = ('user', 'position')unique_together保证了同一用户不能重复投递同一个职位,这个约束在数据层就锁死,比在视图层做判断可靠很多。
行为记录模型用于收集用户的浏览、收藏等隐式反馈:
class UserBehavior(models.Model): BEHAVIOR_TYPES = ( ('view', '浏览'), ('favorite', '收藏'), ('apply', '投递'), ) user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='behavior') position = models.ForeignKey(Position, on_delete=models.CASCADE) behavior_type = models.CharField(max_length=20, choices=BEHAVIOR_TYPES) created_at = models.DateTimeField(auto_now_add=True)2.4 Admin后台配置:先用起来再谈美化
Django Admin是前期录入测试数据的主要入口,一定要配置好。
from django.contrib import admin from .models import Position @admin.register(Position) class PositionAdmin(admin.ModelAdmin): list_display = ['title', 'company_name', 'city', 'salary_min', 'salary_max', 'is_active', 'created_at'] list_filter = ['city', 'is_active'] search_fields = ['title', 'company_name', 'skills'] list_editable = ['is_active']如果嫌原生后台丑,可以装simpleui或django-jet,两分钟换皮肤:
pip install django-simpleui然后在INSTALLED_APPS里把simpleui放到django.contrib.admin前面,刷新页面就生效了。对于招聘系统这种需要频繁录入职位和调整状态的场景,后台好用能省很多时间。
我一开始没做其他前端后台页面,直接Admin管数据,把精力全部放在推荐算法和用户端页面上,这是一个很划算的选择。
3. 推荐引擎从0实现:内容召回、行为反馈与混排策略
3.1 第一步:技能标签的归一化处理
推荐引擎里被低估的一步,其实是数据清洗。职位里的技能字段长这样:Python,Django,MySQL,RESTful API。简历里的技能长这样:python,django,mysql,linux。直接比对会出问题:大小写不一致、同义词没有统一、空格不一致。
我写了一个技能归一化函数:
SKILL_SYNONYMS = { 'python3': 'python', 'py': 'python', 'nodejs': 'node.js', 'vue': 'vue.js', 'c++': 'cpp', 'c#': 'csharp', 'golang': 'go', } def normalize_skills(skill_str): if not skill_str: return set() skills = [] for part in skill_str.split(','): s = part.strip().lower().replace('.', '').replace(' ', '_') s = SKILL_SYNONYMS.get(s, s) skills.append(s) return set(skills)这个函数是所有推荐算法的基础。测试阶段我发现,不归一化的话,“Python”和“python”被当成两个技能,推荐结果会莫名漏掉很多匹配职位。花十分钟做同义词映射表,比调一百遍算法参数都管用。
3.2 基于内容的推荐:简历与职位的技能匹配
内容推荐的核心思路是:把简历的技能和职位的技能都转换成集合,然后计算两者重合度。这里我用了Jaccard相似系数:
$$J(A,B) = \frac{|A \cap B|}{|A \cup B|}$$
比如简历技能集合是{python, django, mysql},职位技能集合是{python, django, redis, docker},交集是{python, django}两个技能,并集是五个技能,相似度就是2/5=0.4。
代码实现非常轻量:
def content_similarity(resume_skills, position_skills): if not resume_skills or not position_skills: return 0.0 intersection = len(resume_skills & position_skills) union = len(resume_skills | position_skills) if union == 0: return 0.0 return intersection / union但只有这个系数还不够,我加了两个加权因子:
- 技能覆盖度:职位要求的技能里有多少是简历拥有的。如果职位要求5个技能,简历占4个,说明匹配度非常高。
- 薪资匹配度:简历期望薪资区间和职位薪资区间的重叠程度。期望15k到20k的人,推一个8k到10k的岗位,技能全中也没意义。
最终的内容推荐分:
def content_score(resume, position): resume_skills = normalize_skills(resume.skills) position_skills = normalize_skills(position.skills) if not position_skills: return 0.0 jaccard = content_similarity(resume_skills, position_skills) coverage = len(resume_skills & position_skills) / len(position_skills) if position_skills else 0.0 # 薪资重叠比例 salary_overlap = 0.0 r_min, r_max = resume.expected_salary_min, resume.expected_salary_max p_min, p_max = position.salary_min, position.salary_max overlap_min = max(r_min, p_min) overlap_max = min(r_max, p_max) if overlap_max >= overlap_min: salary_overlap = (overlap_max - overlap_min) / max(r_max - r_min, p_max - p_min, 1) return 0.5 * jaccard + 0.3 * coverage + 0.2 * salary_overlap这里用到的normalize_skills是上一节写好的归一化函数。推荐系统没有高深公式,把业务字段变成可计算的数值,问题就解决了一大半。
3.3 基于用户行为的协同过滤:让好职位浮上来
纯内容推荐有个问题:两个技能标签差不多的候选人,看到的结果完全一样,缺少个性化。比如同样是Python后端,有人偏好大厂,有人偏好创业团队,简历技能体现不出来。所以我加了基于用户行为的协同过滤。
思路是:根据用户历史行为算出“相似用户”,再把相似用户积极交互过的职位推荐给当前用户。数据量不大,我用基于物品的协同过滤,实现起来更简单,效果也够用。
步骤如下:
- 构建“用户-职位行为矩阵”,行为类型加权:投递记3分,收藏记2分,浏览记1分。
- 计算职位之间的余弦相似度。
- 根据用户历史高分的职位,找到相似职位,按得分排序输出。
我写了一个简化实现,放在reco/recommender.py里:
import math from collections import defaultdict def build_behavior_matrix(behaviors): matrix = defaultdict(dict) weight_map = {'apply': 3, 'favorite': 2, 'view': 1} for b in behaviors: w = weight_map.get(b.behavior_type, 1) matrix[b.user_id][b.position_id] = matrix[b.user_id].get(b.position_id, 0) + w return matrix def position_similarity(matrix, pos_id_a, pos_id_b): users_who_rated_a = {uid for uid, positions in matrix.items() if pos_id_a in positions} users_who_rated_b = {uid for uid, positions in matrix.items() if pos_id_b in positions} common = users_who_rated_a & users_who_rated_b if not common: return 0.0 dot = sum(matrix[u].get(pos_id_a, 0) * matrix[u].get(pos_id_b, 0) for u in common) norm_a = math.sqrt(sum(matrix[u].get(pos_id_a, 0) ** 2 for u in users_who_rated_a)) norm_b = math.sqrt(sum(matrix[u].get(pos_id_b, 0) ** 2 for u in users_who_rated_b)) if norm_a == 0 or norm_b == 0: return 0.0 return dot / (norm_a * norm_b) def recommend_by_collab(user_id, user_positions, all_positions, top_n=10): scores = defaultdict(float) for pos_id, weight in user_positions.items(): for other in all_positions: if other.id == pos_id: continue sim = position_similarity(behavior_matrix, pos_id, other.id) scores[other.id] += sim * weight ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True) return [pid for pid, _ in ranked[:top_n]]这个协同过滤有个特点:行为越丰富的用户,推荐越精准。新用户没有任何行为,矩阵里没有他的数据,协同过滤算不出来,这时就走内容推荐加热门职位。
3.4 混排策略:同时照顾准确性和时效性
实际推荐不能用单一算法的结果,我把内容推荐和协同过滤混合起来,再叠加职位新鲜度因子。
最终推荐分公式:
final_score = 0.55 * content_score + 0.35 * collab_score + 0.10 * freshness_score其中freshness_score根据职位发布时间衰减:
from datetime import datetime, timedelta def freshness_score(position, days_half_life=15): age = (datetime.now() - position.created_at).days return 0.5 ** (age / days_half_life)这个衰减公式的含义是:15天内发布的职位得分为1,过了15天降到0.5,过了30天降到0.25。招聘职位的生命周期短,新发布的职位往往最需要曝光。
在混排之后,还有一道强过滤规则:已经投递过的、已经下架的、薪资范围完全不搭的职位,直接剔除,不参与排序。这些规则在最后加一个filter就行:
excluded_ids = Application.objects.filter(user=user).values_list('position_id', flat=True) candidates = Position.objects.filter(is_active=True).exclude(id__in=excluded_ids)这里逻辑非常朴素,但能有效防止用户产生“这系统有毛病吧,我刚投过还给推荐”的体验。
3.5 冷启动问题:新用户看什么,新职位怎么推
每个推荐系统都会遇到冷启动,这个项目里有两个场景:
- 新注册用户,简历没填,行为没有:直接推热门职位,按
view_count倒序,外加最近一周发布的岗位。等用户填了简历技能,内容推荐就立刻生效。 - 新发布的职位,还没有任何行为数据:协同过滤算不出它的分数,但它有内容标签,可以靠内容推荐匹配,所以给新职位一个“新职位加权”,在混排时额外加0.05分。
if position.created_at >= datetime.now() - timedelta(days=3): final_score += 0.05这样冷启动不算完满解决,但至少不会让新内容永远沉底。
4. 视图、URL路由与模板渲染:推荐结果怎么送达到浏览器
4.1 视图与推荐引擎解耦
推荐引擎是纯Python模块,不依赖Django请求周期。视图只负责调用推荐函数,拿到职位ID列表后再查询数据库,渲染到模板。这样做的好处是可以单独给推荐算法写测试,未来想换成其他框架也能复用。
首页推荐视图:
from django.shortcuts import render, get_object_or_404 from django.core.paginator import Paginator from .recommender import recommend_for_user def index(request): user = request.user recommended_ids = [] if user.is_authenticated: recommended_ids = recommend_for_user(user, top_n=30) else: # 未登录先按热度推荐 recommended_ids = list( Position.objects.filter(is_active=True) .order_by('-view_count')[:30] .values_list('id', flat=True) ) # 保持数据库查询顺序与推荐排序一致 positions = [] if recommended_ids: pos_map = {p.id: p for p in Position.objects.filter(id__in=recommended_ids)} positions = [pos_map[pid] for pid in recommended_ids if pid in pos_map] paginator = Paginator(positions, 10) page_number = request.GET.get('page') page_obj = paginator.get_page(page_number) return render(request, 'index.html', {'page_obj': page_obj})有一个细节:recommend_for_user返回的是有序的职位ID列表,如果直接用Position.objects.filter(id__in=recommended_ids)查询,数据库返回的顺序是ID顺序,不是推荐顺序,所以需要一个字典映射把结果重新排一下。这个顺序问题很隐蔽,我第一次没处理,页面上推荐排序是乱的,排查了很久才发现是这里丢的序。
4.2 路由设计与反向解析
URL路由我按功能模块拆,路由配置要记得用app_name,方便在模板里做反向解析:
# itrecruit/urls.py from django.urls import path, include from django.contrib import admin urlpatterns = [ path('admin/', admin.site.urls), path('', include('users.urls')), path('positions/', include('positions.urls')), path('applications/', include('applications.urls')), ] # positions/urls.py app_name = 'positions' urlpatterns = [ path('', views.index, name='index'), path('search/', views.position_search, name='position_search'), path('<int:pk>/', views.position_detail, name='position_detail'), path('<int:pk>/apply/', views.apply_position, name='apply_position'), ]用reverse('positions:position_detail', args=[p.id])生成链接,哪怕后面路由变了也不用改模板。这也是为什么推荐列表在模板里循环时应使用{% url %}而不是硬编码路径。
4.3 职位详情页与投递行为记录
职位详情页是整个系统里唯一一个“必写行为记录”的地方。用户浏览一个职位,这本身就是推荐算法的原材料:
def position_detail(request, pk): position = get_object_or_404(Position, pk=pk, is_active=True) if request.user.is_authenticated: # 记录浏览行为 UserBehavior.objects.create( user=request.user, position=position, behavior_type='view' ) # 浏览次数自增 Position.objects.filter(pk=pk).update(view_count=F('view_count') + 1) # 推荐相似职位 similar_ids = recommend_for_position(position, top_n=6) return render(request, 'positions/detail.html', { 'position': position, 'similar_positions': similar_ids, })投递操作需要事务保护,并且要判断是否重复投递:
from django.db import transaction from django.contrib import messages def apply_position(request, pk): position = get_object_or_404(Position, pk=pk, is_active=True) if request.method == 'POST': try: with transaction.atomic(): app, created = Application.objects.get_or_create( user=request.user, position=position, defaults={'status': 'submitted'} ) if created: UserBehavior.objects.create( user=request.user, position=position, behavior_type='apply' ) messages.success(request, f'已投递:{position.title}') else: messages.warning(request, '你已经投递过这个职位了') except Exception as e: messages.error(request, '投递失败,请稍后重试') return redirect('positions:position_detail', pk=pk)用get_or_create加unique_together的双重保护,基本杜绝了重复投递问题。而且投递行为一旦产生,立即写入行为表,下一次推荐就能用到这个新信号。
5. 跑通全流程后的性能问题与推荐效果调优记录
5.1 记一次典型的N+1查询问题
系统功能全部跑通后,我发现职位列表页变慢了,一个页面上要显示职位、公司、投递状态,Django ORM默认懒加载,每显示一条职位就多查一次数据库。职位列表页有10条职位,每条查一次投递状态,这就是10次额外查询,再加上推荐列表也要查简历,页面总耗时直接飙到1.2秒。
解决办法是用select_related和prefetch_related:
positions = Position.objects.filter(is_active=True).select_related('company').prefetch_related('applications')优化之后,页面总查询次数从20多次降到了3次以内,响应时间降到250毫秒左右。这套系统数据量在1万条职位以内时,这个优化就够了;如果职位量到百万级,那要考虑缓存和搜索引擎,已经不是同一个量级的问题。
5.2 推荐计算的缓存策略
推荐引擎每次访问都实时计算所有候选职位的相似度,职位量少无所谓,但职位到5000条以上时,每次首页打开都要算几十毫秒甚至上百毫秒的相似度矩阵,用户体验会明显变差。
我的处理方式是用Django缓存框架,对每个用户的推荐结果做缓存,缓存键带用户ID:
from django.core.cache import cache RECOMMEND_CACHE_TTL = 60 * 30 # 30分钟 def get_recommendations_for_user(user, top_n=30): cache_key = f'reco_user_{user.id}' cached_ids = cache.get(cache_key) if cached_ids: return cached_ids ids = recommend_for_user(user, top_n=top_n) cache.set(cache_key, ids, RECOMMEND_CACHE_TTL) return ids缓存过期时间设为30分钟,意味着用户投递、收藏后,最多半小时内推荐结果会更新。如果想要即时反馈,可以在投递行为发生时主动删掉该用户的缓存:
cache.delete(f'reco_user_{request.user.id}')这样既保证了推荐结果的实时性,又不用每次请求都全量计算。后来我在部署时还加了一层Redis缓存,比Django自带的本地缓存更适合多进程部署环境,效果更稳定。
5.3 中文分词和技能词库的坑
推荐系统里最让我意外的是中文职位描述的处理。
一开始我想把职位描述做TF-IDF向量化,用余弦相似度计算相似职位。结果发现中文分词如果不用jieba,整段描述会被拆成奇怪的单字:
import jieba description = "负责公司核心业务的后端开发,要求熟悉Python和Django框架" words = jieba.lcut(description) # 输出: ['负责', '公司', '核心', '业务', '的', '后端', '开发', '要求', '熟悉', 'Python', '和', 'Django', '框架']加了jieba分词后,职位描述才能算相似度。但对于技能匹配来说,直接解析职位里的skills字段比解析描述文本靠谱得多。我的建议是:描述文本的分词结果可以用于辅助计算,但推荐主路径必须以结构化的技能标签为准,因为技能标签是录入时人工整理过的,噪声小,匹配更准。
还有一个细节:IT技能词库要自己维护。jieba自带词典不认识Django、FastAPI、Kubernetes这些词,第一次分词会把Django切成Django的英文单词还好,但像K8s这类缩写就会出问题。可以在初始化时加载自定义词典:
jieba.add_word('Kubernetes') jieba.add_word('FastAPI') jieba.add_word('devops')维护一个技能词库文件,每新增一个热门技术就加进去,推荐质量会逐步提升。
5.4 用Django测试框架保证推荐算法稳定
推荐算法改动频繁,我后来补了简单的单元测试,防止改一个问题引出另一个问题:
from django.test import TestCase from reco.recommender import content_similarity, normalize_skills class RecommenderTestCase(TestCase): def test_normalize_skills(self): self.assertEqual(normalize_skills('Python,Django,MySQL'), {'python', 'django', 'mysql'}) self.assertEqual(normalize_skills('Python, python, py'), {'python'}) def test_content_similarity(self): a = {'python', 'django', 'mysql'} b = {'python', 'django', 'redis'} self.assertAlmostEqual(content_similarity(a, b), 0.5) def test_salary_overlap(self): # 在这里构造简历和职位数据,验证分数计算的边界情况 pass测试的价值在后期开始凸显。推荐系统看起来不复杂,但改一处往往会牵连另一处:改动薪资权重,可能影响内容分数;改动同义词表,可能改变一批职位的匹配结果。没有测试兜底,每次改动都提心吊胆。
6. 从单体Django到前后端分离的扩展思路
这个项目目前的形态是Django模板渲染的经典单体架构,部署起来最省事:宝塔面板配置好Python环境,安装依赖,收集静态文件,配置WSGI,绑定域名就能上线。
如果你打算把它做得更像一个商业产品,可以考虑前后端分离:
- 后端用Django REST Framework暴露接口,加入JWT认证。
- 前端用Vue或React做单页应用。
- 推荐引擎继续保持独立模块,通过API调用。
我个人的建议是:如果只是学习、毕设或作品集项目,用Django模板渲染完全够了,先跑通业务闭环,把推荐算法和业务逻辑调好,这才是项目的核心价值。前后端分离只是形式,不能掩盖推荐逻辑本身的单薄。一旦把前端拆出去,你就要同时维护两套工程,开发和部署成本翻倍,对于实战项目有点得不偿失。
7. 部署到Linux服务器时的实操要点
最后补充部署相关的内容。我是用宝塔面板部署到一台2核4G的Linux服务器上,路径大致是:
- 服务器安装Python 3.10,创建虚拟环境。
- 把项目代码拉取到
/www/wwwroot/itrecruit。 - 在虚拟环境里
pip install -r requirements.txt。 - 创建MySQL数据库,修改
settings.py数据库配置。 - 迁移数据:
python manage.py migrate。 - 静态文件处理:
python manage.py collectstatic。 - 在宝塔的Python项目管理器里配置Django项目,选择运行方式为
gunicorn或uwsgi。 - 配置Nginx反向代理到本地端口如
127.0.0.1:8001,设置好上传大小限制和静态文件路径。
部署过程中最容易出问题的几个点:
- MySQL版本兼容:宝塔自带的MySQL默认可能是5.7或8.0,
mysqlclient需要系统有对应版本的头文件,装不上就换PyMySQL。 - 静态文件404:Django的静态文件收集路径和Nginx的
alias路径不对应,页面CSS样式全丢。启动项目后先用curl http://127.0.0.1:8001/static/css/style.css测试一下本地能不能访问静态文件,能访问再排查Nginx配置。 - 启动失败看日志:宝塔面板有运行日志入口,Django报错信息比终端里要隐藏,把
DEBUG=False改为DEBUG=True临时查看具体报错,解决后再改回去。
部署完成后,用浏览器跑一遍完整流程:注册账号、填简历、首页看推荐结果、投递职位、企业端查看投递记录。每个环节都走通,说明系统线下的开发质量过关了。
我实际测试时的一个经验是:冷启动阶段推荐效果看起来“不惊艳”很正常,因为用户行为数据还没积累起来。连续录入200条IT职位、注册5个不同技能方向的测试账号、每个账号手动投递几份简历之后,推荐结果会越来越像回事。这个积累过程本身就是招聘推荐系统的业务壁垒,也是这个项目最有含金量的地方。