项目实践FastAPI 职位投递与招聘团队FastAPI Tortoise-ORM 阿里云 OSS 实战职位投递链路与招聘团队模块的设计一、前言介绍1.1 背景与功能定位1.2 数据模型总览1.3 投递流程总览二、环境准备2.1 依赖与工具2.2 路由与鉴权2.3 模型注册三、知识点讲解3.1 招聘团队为什么要独立成表3.2 投递记录为什么复制简历而非引用3.3 OSS 同桶拷贝的注意点3.4 雪花 ID 做文件名的意义四、代码逻辑拆解4.1 招聘团队登录 team_login4.2 审核通过时自动建招聘成员4.3 职位投递核心 resume_submission4.4 招聘方简历中心 resume_center4.5 投递接口签名FastAPI Tortoise-ORM 阿里云 OSS 实战职位投递链路与招聘团队模块的设计一、前言介绍1.1 背景与功能定位招聘平台走到职位之后真正的业务闭环是求职者把简历投给某个职位招聘方在后台收到并筛选。这一段要解决两件事其一招聘方不是只有一个企业账号而是有若干个招聘团队成员HR、用人经理他们各自发布职位、查看收到的简历其二求职者点击投递系统要把他的附件简历复制一份快照存到投递记录里避免原简历被改后投递记录跟着失真。1.2 数据模型总览Enterprise企业主表 └── 1 : N ── RecruitTeam招聘团队member 账号软删除 └── recruit_team_id ── Job职位由团队成员发布 JobSeeker求职者 └── 1 : N ── AttachmentResume附件简历含 file_url └── job_seeker_id ── ResumeSubmissionRecords投递记录 ├── job_id → Job └── job_seeker_id → JobSeeker要点职位归属到团队而非企业投递记录用job_id job_seeker_id双列定位一次投递附件简历通过job_seeker_id关联但投递时只复制其 URL不建立强外键。1.3 投递流程总览求职者持 JWTPOST /job/resume_submission?job_idxxx → 取该求职者默认/已删除附件简历的 file_url → 用雪花 ID 生成新文件名OSS 同桶拷贝出一份副本 → 写 ResumeSubmissionRecords状态已投递 招聘方持团队 JWTGET /enterprise/resume_center → 查该团队发布的所有职位 → 每个职位下的投递记录 → 关联求职者 求职意向 工作经历 → 拼成简历中心列表二、环境准备2.1 依赖与工具投递链路依赖两个工具类与之前模块同源app.utils.oss_util.AliyunOSSTool阿里云 OSS 封装本次用到同桶拷贝copy_single_fileapp.utils.snowflake_util.SnowflakeSingleton雪花算法单例生成全局唯一文件 IDapp.models.job.ResumeStatusIntEnum投递状态枚举。2.2 路由与鉴权求职者投递/job/resume_submission鉴权依赖get_job_info从 JWT 解析出job_seeker_id招聘方简历中心/enterprise/resume_center鉴权依赖同一个get_job_infoJWT 里user_id实际装的是团队 ID团队登录/enterprise/team_login手机号 短信验证码通过后签发双 Token。2.3 模型注册app/models/__init__.py中新增导出fromapp.models.enterpriseimportEnterprise,EnterpriseInfo,EnterpriseQualification,EnterpriseReview,RecruitTeamfromapp.models.jobimportJob,ResumeSubmissionRecords第 1 行把RecruitTeam纳入 ORM 注册迁移才认得这张表第 2 行ResumeSubmissionRecords同理漏掉这一步会触发Model not registered之类的启动报错是新模型最容易被遗忘的一处。三、知识点讲解3.1 招聘团队为什么要独立成表企业账号和发布职位的 HR是两回事一个企业要多个 HR 协作HR 离职要能禁用而不影响企业本身。所以把RecruitTeam单独建表用enterprise_id整型关联企业并带status正常/禁用与is_deleted软删除。classRecruitTeam(Model):mobilefields.CharField(max_length20,description手机号)passwordfields.CharField(max_length256,description登录密码加密存储,nullTrue)statusfields.IntEnumField(enum_typeTeamMemberStatus,description状态1正常 2禁用)enterprise_idfields.IntField(nullTrue,description企业ID)is_deletedfields.IntEnumField(enum_typeDeleteStatus,defaultDeleteStatus.NOT_DELETED,...)status用枚举禁用后该成员无法登录职位仍归属企业不丢失is_deleted软删除不真删行列表查询加is_deletedNOT_DELETED过滤即可隐身。3.2 投递记录为什么复制简历而非引用附件简历是会被用户替换、删除的。如果投递记录只存attachment_resume_id用户改了原简历历史投递记录也跟着变招聘方看到的就不是当时投的那份。所以投递时把简历文件在 OSS 上拷贝一份副本记录副本 URLcopy_resume_urlfields.CharField(max_length256,description附件简历URL)字段只存 URL 字符串不建外键断开了与原简历的生命周期耦合即便原简历被删投递记录里的副本 URL 依然有效。3.3 OSS 同桶拷贝的注意点复制不是把文件下载再上传而是调用 OSS 的copy_object服务端内部搬数据节省带宽也更快resultself.bucket.copy_object(src_bucket,source_oss_key,target_oss_key)src_bucket与目标桶是同一个同桶拷贝直接传当前bucket_name目标已存在且overwriteFalse会拒绝覆盖避免误冲掉别人已投的副本。3.4 雪花 ID 做文件名的意义投递副本的文件名如果用uuid4每次都不同没问题但这里选了雪花算法除了唯一还带时间有序snowflakeSnowflakeSingleton.get_instance(worker_id1)file_idsnowflake.get_id()单例保证全进程只有一个生成器worker_id首次必须传入、之后锁定生成的 ID 是 64 位整数毫秒级有序便于按文件名粗略排序、排查问题。四、代码逻辑拆解4.1 招聘团队登录 team_loginteamawaitRecruitTeam.filter(mobilelogin.mobile,statusTeamMemberStatus.NORMAL,is_deletedDeleteStatus.NOT_DELETED).first()ifnotteam:raiseException(手机号不存在或账号存在异常)keyfboss-api:enterprise-login:sms:{login.mobile}redis_coderedis_client.get(key)ifredis_codeisNone:raiseException(验证码已过期)ifredis_code!login.code:raiseException(验证码错误)access_token,refresh_tokencreate_tokens(str(team.id),login.mobile)redis_client.delete(key)第 1–3 行按手机号查成员同时要求状态正常 未软删除被禁用或已删的账号直接查不到第 4–8 行验证码从 Redis 取过期/错误分别报错流程与企业登录一致create_tokens(str(team.id), ...)user_id装的是团队 ID所以后续get_job_info解析出来的就是团队成员身份能查到他发布的职位与收到的简历最后一行验证码用后即焚防重放。4.2 审核通过时自动建招聘成员企业审核通过的副作用是给联系人自动生成一个招聘团队成员账号entawaitEnterpriseQualification.filter(enterprise_identerprise_id).first()awaitRecruitTeam.create(nameent.contact_name,mobileent.contact_phone,emailent.contact_email,enterprise_identerprise_id,statusTeamMemberStatus.NORMAL,)用资质表里的联系人信息建号省去企业方再单独录入 HRstatusNORMAL建好即可登录不需要二次激活。4.3 职位投递核心 resume_submission这是整条链路的核心逐段看ossAliyunOSSTool()attawaitAttachmentResume.filter(job_seeker_idjob_seeker_id,is_deletedTrue).first()file_urlatt.file_url source_keyfile_url.replace(fhttps://{oss.bucket_name}.{oss.endpoint}/,)第 1 行初始化 OSS 工具第 2 行取该求职者的附件简历。注意这里过滤条件是is_deletedTrue——与原简历表未删除0的约定相反是个易踩的坑见问题排查 5.1第 3–4 行从完整 URL 里把 OSS key 抠出来把https://桶名.域名/前缀替换掉剩下的就是对象路径拷贝接口只认 key。snowflakeSnowflakeSingleton.get_instance(worker_id1)file_idsnowflake.get_id()source_filenameos.path.basename(source_key)target_keyfjob_seeker_avatar/copy/{file_id}-{source_filename}copy_ok,copy_dataoss.copy_single_file(source_oss_keysource_key,target_oss_keytarget_key,overwriteFalse)第 1–2 行拿雪花 ID 当文件前缀保证副本名全局唯一os.path.basename(source_key)只取文件名避免把原目录结构拼进新路径造成重复嵌套target_key落在job_seeker_avatar/copy/下和原简历目录分开语义清晰overwriteFalse同名不覆盖配合唯一前缀基本不会撞。awaitResumeSubmissionRecords.create(job_idjob_id,job_seeker_idjob_seeker_id,copy_resume_urlcopy_data[target_url],submit_timenow(),resume_statusResumeStatus.SUBMITTED)落库四要素job_id投给哪个职位、job_seeker_id谁投的、copy_resume_url副本地址、resume_status初始已投递submit_timenow()写入投递时间后续简历中心按它排序展示。4.4 招聘方简历中心 resume_centerjobsawaitJob.filter(recruit_team_idteam_id)data_dict_list[]foriinjobs:resume_submission_recordsawaitResumeSubmissionRecords.filter(job_idi.id)forjinresume_submission_records:job_seekerawaitJobSeeker.filter(idj.job_seeker_id).first()jobIntentionawaitJobIntention.filter(job_seeker_idj.job_seeker_id).first()workawaitWorkExperience.filter(job_seeker_idj.job_seeker_id).order_by(-entry_time)info_dict{job_seeker:job_seeker,jobIntention:jobIntention,workExperience:work,resume_status:j.resume_status,submit_time:j.submit_time,}data_dict_list.append(info_dict)第 1 行先取我团队发布的所有职位内层循环每个职位下的投递记录再逐条关联求职者、求职意向、工作经历order_by(-entry_time)工作经历按入职时间倒序最新一段排前面每条投递拼成字典最终是职位 → 投递者 → 简历内容的扁平列表。4.5 投递接口签名job_router.post(/resume_submission,summary职位简历提交)asyncdefresume_submission(idDepends(get_job_info),job_id:intQuery(...,description职位简历)):awaitJobService.resume_submission(id,job_id)return{code:1,message:职位简历提交成功}idDepends(get_job_info)从 JWT 拿到求职者 ID前端无法伪造替别人投递job_idQuery(...)...表示必填不传直接 422投递动作本身无请求体参数走查询字符串即可。