BOSS 直聘仿写项目实战:管理端企业列表与认证审核(前后端闭环)

BOSS 直聘仿写项目实战:管理端企业列表与认证审核(前后端闭环)

本文是 Boss-API 系列的「企业审核篇」,承接前两篇(三端架构总览、求职者管理与忘记密码)。前两篇把「人」侧的业务跑通了;本篇聚焦「企业」侧最核心的一块:管理端怎么拉企业列表、怎么审核认证、审核结果又怎么回流到企业端

涉及今天真实改过的代码:后端enterprise_api.py/enterprise_service.py/models/enterprise.py,前端boss-manage-uirequest.js/api/enterprise.js/Companies.vue/CertificationList.vue/CertificationAudit.vue,以及企业端boss-company-ui的认证页。所有代码均来自联调现场,含踩坑记录。


一、这个 feature 要解决什么

一个招聘平台得让企业来「认证」。整条链路是:

企业端提交认证资料 ──> 写「企业信息表」(t_enterprise_info) │ ▼ 管理端看到待审企业列表 ──> 审核员点开详情、看资质材料 │ ▼ 审核通过 / 驳回 ──> 写「企业审核表」(t_enterprise_review) + 更新企业主表状态 │ ▼ 审核结果回流企业端 ──> 企业登录后在认证页看到「已通过 / 已驳回 + 原因」

这里有两张关键表,职责完全不同,一定要分清楚(这也是今天联调时反复澄清的点):

谁写什么时候写作用
t_enterprise_info(企业信息表)企业端企业提交认证申请时存营业执照、法人、注册资本等工商资料
t_enterprise_review(企业审核表)管理端审核员点「通过 / 驳回」时存审核结果、驳回原因、审核时间

前端页面和它们的关系:管理端「企业管理 / 企业认证审核」列表展示的是审核视角的数据;管理端审核操作落库到审核表;企业端只负责填资料、查自己这份审核结果。


二、后端:四张表与账号状态枚举

1. 模型(app/models/enterprise.py

一张企业主表 + 三张扩展表,主表用enterprise_id关联其余三张(当前实现是IntField关联,不是外键约束,灵活但要求代码里自己判空):

classAccountStatus(IntEnum):"""账号状态"""NORMAL=0# 正常PENDING_AUDIT=1# 待审核BANNED=2# 封禁REJECTED=3# 已驳回(审核不通过)classEnterprise(Model):"""企业表(主表)"""id=fields.IntField(pk=True)enterprise_name=fields.CharField(max_length=255)enterprise_code=fields.CharField(max_length=100,unique=True)# 企业编码account_status=fields.IntEnumField(enum_type=AccountStatus)# 0/1/2/3audit_type=fields.IntEnumField(enum_type=AuditType)# 1新认证/2资质更新/3信息变更auth_time=fields.DatetimeField(null=True)submit_time=fields.DatetimeField(null=True)# 企业提交认证时间# ... 其余字段classEnterpriseInfo(Model):"""企业信息表:营业执照 / 法人 / 注册资本 / 行业 / 总部地址 ..."""enterprise_id=fields.IntField(null=True)unified_social_credit_code=fields.CharField(max_length=50,unique=True)legal_representative=fields.CharField(max_length=100)industry=fields.ForeignKeyField("models.IndustryPosition",related_name="enterprise_info_list")headquarters_address=fields.CharField(max_length=512)# ...classEnterpriseQualification(Model):"""资质材料表:营业执照图片 / 法人身份证正反面 / 联系人 ..."""enterprise_id=fields.IntField(null=True)business_license_url=fields.CharField(max_length=512,null=True)legal_id_front_url=fields.CharField(max_length=512,null=True)legal_id_back_url=fields.CharField(max_length=512,null=True)# ...classEnterpriseReview(Model):"""企业审核表:审核结果 / 驳回原因 / 审核时间 / 审核人"""enterprise_id=fields.IntField(null=True)review_result=fields.IntField(null=True)# 1通过 / 2驳回review_reason=fields.CharField(max_length=512,null=True)audit_time=fields.DatetimeField(null=True)audit_user=fields.CharField(max_length=100,null=True)

重点:AccountStatus有 4 个值(0/1/2/3)。今天联调时踩过一个大坑——审核驳回后,前端枚举只画了 0/1/2 三态,导致「已驳回」的企业在列表/详情里状态显示成「未知」。枚举一加全,前端映射也要同步加3: 已驳回

2. 三个核心接口(app/apis/enterprise_api.py

enterprise_router=APIRouter(prefix="/enterprise",tags=["企业管理"])@enterprise_router.get("/list")# 企业列表(管理端/审核页共用)asyncdefselect_enterprise_list(page:int=Query(1,ge=1),page_size:int=Query(10,ge=10,le=50),# 注意约束:最小 10、最大 50enterprise_name:str=Query(None),submit_time_start:str=Query(None),submit_time_end:str=Query(None),):res=awaitEnterpriseService.select_enterprise_list(...)return{"code":1,"message":"查询成功","data":res}@enterprise_router.get("/detail/{enterprise_id}")# 企业详情(含审核记录)asyncdefselect_enterprise_by_id(enterprise_id:int):...@enterprise_router.post("/review")# 企业审核(通过/驳回)asyncdefenterprise_review(req:EnterpriseReviewCreateRequest):awaitEnterpriseService.enterprise_review(req)return{"code":1,"message":"审核完成"}

返回统一用code: 1表示成功——和求职者端一致,但和前两篇里管理端求职者查询用的code: 200不一样。这点下文明坑。

3. 列表查询(enterprise_service.py

列表接口要做三件事:分页、按需关联三张扩展表、把行业名算出来。关键在industry必须判空,否则缺资料的企业会 500:

@staticmethodasyncdefselect_enterprise_list(page,page_size,enterprise_name,submit_time_start,submit_time_end):query=Enterprise.all()ifenterprise_name:query=query.filter(enterprise_name__icontains=enterprise_name)ifsubmit_time_start:query=query.filter(submit_time__gte=submit_time_start)ifsubmit_time_end:query=query.filter(submit_time__lte=submit_time_end)total_count=awaitquery.count()total_page=math.ceil(total_count/page_size)rows=awaitquery.offset((page-1)*page_size).limit(page_size)enterprise_list=[]forenterpriseinrows:info=awaitEnterpriseInfo.get_or_none(enterprise_id=enterprise.id).prefetch_related("industry")qual=awaitEnterpriseQualification.get_or_none(enterprise_id=enterprise.id)# 行业名:info 或 info.industry 任一为空都不能直接 .nameindustry_name=info.industry.nameif(infoandgetattr(info,"industry",None))elseNoneenterprise_list.append({"enterprise":enterprise,"enterpriseinfo":info,"enterprise_qualification":qual,"industry":industry_name,})return{"total_count":total_count,"total_page":total_page,"page":page,"page_size":page_size,"enterprise_list":enterprise_list,}

前端真正用到的就这几个字段:total_count / total_page / page / page_size(分页),enterprise_name / enterprise_code(主表),industry(行业名),enterpriseinfo.headquarters_address(总部地址)。

4. 审核落库(enterprise_review)—— 写入「企业审核表」

这是「审核成功后将数据写企业审核表」的那一步。注意它同时维护审核表和企业主表状态:

@staticmethodasyncdefenterprise_review(req:EnterpriseReviewCreateRequest):enterprise=awaitEnterprise.get_or_none(id=req.enterprise_id)ifenterpriseisNone:raiseException(f"企业不存在(enterprise_id={req.enterprise_id})")review=awaitEnterpriseReview.get_or_none(enterprise_id=req.enterprise_id)ifreviewisNone:awaitEnterpriseReview.create(enterprise_id=req.enterprise_id,review_result=req.review_result,review_reason=req.review_reason,remark=req.remark,audit_time=now(),)else:review.review_result=req.review_resultorreview.review_result review.review_reason=req.review_reasonorreview.review_reason review.remark=req.remarkorreview.remark review.audit_time=now()awaitreview.save()ifreq.review_result==1:# 通过enterprise.account_status=AccountStatus.NORMAL enterprise.auth_time=now()elifreq.review_result==2:# 驳回enterprise.account_status=AccountStatus.REJECTEDawaitenterprise.save()

审核通过 → 主表变「正常」且写认证时间;审核驳回 → 主表变「已驳回」。审核结果本身落在t_enterprise_review。前端拿detail接口里的review字段就能知道这家企业处于什么审核状态。


三、前端:从「写死 mock」到「接真实接口」

管理端是 Vue3 + Element Plus + Vite4,请求统一走封装好的request.js,由 Vite 的/api代理转发到后端 8000(前两篇已讲,这里不再展开代理配置)。

1. 第一步永远是「成功码对齐」(utils/request.js

今天第一坑:响应拦截器原来写if (res.code !== 200) reject(...),但企业接口返回的是code: 1,于是所有企业相关请求都被当成失败,列表永远拿不到数据。改成和后端约定一致:

service.interceptors.response.use((response)=>{constres=response.data// 后端业务约定:code === 1 表示成功if(res.code!==1){if(res.code===401){/* 跳登录 */}elseElMessage.error(res.message||'请求失败')returnPromise.reject(newError(res.message||'Error'))}returnres})

经验:前后端成功码必须一对一定死。本项目里求职者接口、企业接口用code:1,前两篇的管理端求职者查询用code:200——同一套前端不同模块约定不同,全靠request.js拦截器统一兜底,别在业务页面里各写各的。

2. 抽一层 API(api/enterprise.js

所有企业接口集中在一个文件,页面只调语义化方法,不关心 URL:

importrequestfrom'@/utils/request'exportfunctiongetEnterpriseList(params){returnrequest({url:'/enterprise/list',method:'get',params})}exportfunctiongetEnterpriseDetail(enterpriseId){returnrequest({url:`/enterprise/detail/${enterpriseId}`,method:'get'})}exportfunctionenterpriseReview(data){returnrequest({url:'/enterprise/review',method:'post',data})}

3. 企业列表页(Companies.vue/CertificationList.vue

「企业管理」和「企业认证审核」两个列表页,都接入同一个/enterprise/list。进页面onMounted就拉数据,映射后端返回的嵌套结构:

constfetchList=async()=>{loading.value=truetry{constres=awaitgetEnterpriseList({page:query.page,page_size:query.page_size,enterprise_name:query.enterprise_name||undefined,submit_time_start:query.submit_time_start||undefined,submit_time_end:query.submit_time_end||undefined,})constdata=res.data tableData.value=data.enterprise_list||[]// 嵌套数组pagination.total=data.total_count pagination.currentPage=data.page pagination.pageSize=data.page_size}finally{loading.value=false}}onMounted(fetchList)

表格列直接吃后端的字段:

<el-table-columnlabel="企业名称"><template#default="{ row }">{{ row.enterprise.enterprise_name }}</template></el-table-column><el-table-columnlabel="企业编码"><template#default="{ row }">{{ row.enterprise.enterprise_code }}</template></el-table-column><el-table-columnlabel="行业"><template#default="{ row }">{{ row.industry || '-' }}</template></el-table-column><el-table-columnlabel="总部地址"><template#default="{ row }">{{ row.enterpriseinfo?.headquarters_address || '-' }}</template></el-table-column>

注意row.enterpriseinfo?.headquarters_address用了可选链——因为enterpriseinfo可能为null(企业没填资料时)。这和后端industry判空是一对,前后端都要兜底。

4. 审核详情页(CertificationAudit.vue)—— 审核操作 + 回执

点列表里的「审核」,路由带enterprise_id跳到详情页,先拉detail接口,再展示资质材料(营业执照 / 法人身份证正反面用el-image预览)和「当前审核记录」横幅(来自review字段)。通过 / 驳回后不再直接跳走,而是展示一张回执卡:

consthandleApprove=async()=>{awaitElMessageBox.confirm(`确定要通过企业"${detail.enterprise.enterprise_name}"的认证申请吗?`,'审核通过确认')awaitenterpriseReview({enterprise_id:Number(route.params.id),review_result:1,// 通过remark:auditForm.remark||undefined,})buildReceipt('通过')// 展示回执:操作/企业/时间 + "已通知企业"}

驳回类似,多带review_reason


四、打通「管理端审核 → 企业端查看」闭环

审核不是管理端自嗨,结果得让企业端看到。链路是:

  1. 企业端提交认证 →t_enterprise_info落库,主表account_status = 1(待审核);
  2. 管理端审核通过/驳回 →t_enterprise_review落库 + 主表状态变0/3
  3. 企业端登录后,认证页用 JWT 里的enterprise_idGET /enterprise/detail/{id},读review.review_resultaccount_status决定显示什么:
// boss-company-ui/.../Certification.vueconstfetchAuthStatus=async()=>{constid=getEnterpriseIdFromToken()// JWT payload.user_id = enterprise_idconst{data}=awaitgetEnterpriseDetail(id)constr=data.reviewif(r&&r.review_result===2)showRejected(r.review_reason)// 已驳回 + 原因elseif(data.enterprise.account_status===0||(r&&r.review_result===1))showApproved(data.enterprise.auth_time)elseif(data.enterprise.account_status===1)showPending()// 待审核}

这样企业被驳回后重新提交、被通过后立即看到认证时间,整条审核业务就闭环了。


五、本期踩坑合集(全是真事)

  1. 成功码不一致,所有请求被当失败
    拦截器按code !== 200判错,但企业接口返回code: 1,列表永不渲染。前后端成功码必须约定一致(本项目企业/求职者接口用1,管理端另一查询用200,靠拦截器统一兜底)。

  2. 关联查询取到 None 还取属性 → 500
    enterpriseinfo.industry.name在「缺企业信息 / 行业没关联」时直接AttributeError。列表和详情都要先判空:industry_name = info.industry.name if (info and info.industry) else None。前端对应用row.enterpriseinfo?.xxx可选链。

  3. 审核驳回状态没进枚举 → 前端显示「未知」
    AccountStatus加了REJECTED = 3,但前端状态映射只画了 0/1/2,已驳回企业状态列变「未知」。枚举一改,前端三处映射(列表 / 详情 / 企业端)都要同步加3: 已驳回

  4. 孤儿数据导致审核接口 500
    企业主表记录丢失(只剩审核表孤儿记录),Enterprise.get_or_none(id)返回None,后面enterprise.account_status = ...直接AttributeError。修复:在enterprise_review/detail里把get_or_none提前、企业不存在就raise Exception("企业不存在"),不再触发空指针;并清理了孤儿审核记录。

  5. 列表页残留 mock 数据,数据「全部不对」
    CertificationList.vue(企业认证审核列表)一度还是写死的假数组(auditList里 “某某信息科技有限公司” 之类),从不调接口,所以页面数据全是假的。排查顺序:先看页面用的是不是真实接口、再看接口成功码、再看字段映射、最后看是否 mock 没清。

  6. 分页参数约束
    后端page_size约束ge=10, le=50,前端默认 10、可选 [10,20,50]。前端若传 1 或 100 会被后端Query校验直接拒,列表拉不到分页。


六、整条链路串一遍

【企业提交】企业端填表 → POST /enterprise/save → t_enterprise_info + t_enterprise_qualification;主表 account_status=1(待审核) ↓ 【管理端看列表】GET /enterprise/list?page=&enterprise_name=&submit_time_* → 分页列表(真实数据) ↓ 【管理端审核】点「审核」→ GET /enterprise/detail/{id} 看资质材料 → POST /enterprise/review {review_result:1|2} ↓ 后端:写 t_enterprise_review + 主表 account_status=0(通过)/3(驳回) ↓ 【企业端查看】企业登录 → GET /enterprise/detail/{id} → 读 review.review_result / account_status ↓ 展示:已通过(认证时间) / 已驳回(原因) / 待审核

七、小结

企业侧审核功能,本质是「两表分工 + 一个状态机」:

  • 两表分工:企业信息表(t_enterprise_info)由企业端写、存资料;企业审核表(t_enterprise_review)由管理端写、存结果。
  • 一个状态机:企业主表account_status待审核(1) → 正常(0) / 已驳回(3)之间流转,封禁(2) 是运营后续动作。

落到代码上,还是前两篇反复说的那套范式:接口成功码对齐 → 关联查询判空兜底 → 枚举前后端同步 → mock 数据必须清零 → 孤儿/不一致数据提前拦。把企业列表和审核这个闭环吃透,一个招聘平台「企业侧」最硬的一块骨头就啃下来了。

本文基于真实 FastAPI + Vue3 项目整理,敏感密钥已脱敏。FastAPI / Tortoise-ORM / Element Plus / Vite 文档以官方最新版为准。