微信小程序 + springboot + uniapp 的大学生兼职推荐系统,这个题目我盯了很久。说实话,大学生兼职类小程序市面上已经有不少,但真正把"推荐"两个字做扎实的并不多。多数产品就是做了一个职位列表加搜索,用户来了自己翻,翻不到合适的就走了。而这篇博文要拆解的这个系统,核心不只是把兼职信息搬到微信里,而是围绕大学生的专业、空闲时间、技能标签、通勤距离等信息,用一套可落地的推荐逻辑把合适的岗位推给合适的人。后端用 Spring Boot 提供接口服务,前端用 UniApp 一套代码同时覆盖微信小程序和 App 端,整个项目从前端页面到后端接口再到推荐算法,是一条完整的全栈链路。
这篇内容适合正在做毕设、准备接外包项目,或者想从零搭建一个小程序兼职平台的开发者参考。下文会按我实际做这个项目时的思路走:先讲清楚为什么要这么设计业务,再分别拆前端和后端的核心实现,然后重点讲推荐系统和工程化落地,最后聊聊上线阶段踩到的几个坑。
1. 为什么选择"兼职推荐系统"作为小程序切入点
1.1 大学生兼职市场的真实痛点
先聊一个现象。大学里找兼职,最常见的路径是加各种兼职群、关注学校勤工助学公众号,或者在 58 同城、BOSS 直聘上搜"大学生兼职"。这些渠道都存在明显的问题:兼职群里信息杂乱,广告和诈骗信息混在一起;综合招聘平台上的岗位面向社会人群,很多要求全职或者有工作经验,学生筛选成本极高。
我在项目启动前做过一轮调研,找了 30 多个在校学生聊他们的找兼职经历。反馈最集中的问题有三个:一是不知道哪些岗位是真的适合学生做的,很多岗位要求"每周五天到岗",跟课表直接冲突;二是信息匹配效率太低,翻了几十页列表才能找到一两个能投的;三是缺少一个可信的信用机制,学生不敢投,企业也不敢招,双方都有顾虑。
这正是"推荐系统"切入的价值点。如果把学生的空闲时间、所在校区、技能标签、期望薪资这些信息结构化,再给每个兼职岗位打上对应的标签,系统就可以主动计算"哪些岗位最适合这个学生",而不是让学生自己去大海捞针。
1.2 系统核心角色与业务闭环
这个系统的用户角色分三类:学生、企业管理员、平台管理员。
学生端是微信小程序,做四件事:完善个人简历(技能、空闲时间、校区信息)、浏览和搜索兼职岗位、投递简历并跟踪状态、查看录用结果和评价。企业端本来也应该有小程序,但为了控制开发量,第一版做成了 Spring Boot 管理的后台网页,企业管理员登录后发布岗位、查看收到的简历、标记录用或拒绝。平台管理员负责审核岗位信息,同时对整个系统的运行状态做管理。
业务闭环是这样的:企业发布岗位 -> 管理员审核通过 -> 推荐系统把岗位推送给匹配的学生 -> 学生投递简历 -> 企业查看并操作状态 -> 学生收到通知 -> 兼职结束互相评价。推荐系统不是孤立存在的,它依赖整个业务链路的数据积累。学生投递行为、收藏行为、企业录用行为,这些数据都会被记录并反馈到推荐逻辑里,形成逐步优化的闭环。
2. 技术选型:UniApp 与 Spring Boot 的组合逻辑
2.1 前端选型:为什么用 UniApp 而不是原生小程序
技术选型是这类项目最开始要定的事,也是最容易反复摇摆的地方。一开始我有两个备选方案:原生微信小程序 + WeUI,或者 UniApp + uni-ui。最终选了 UniApp,核心原因是"一套代码多端复用"这个收益在真实项目里确实能吃到。
具体说,这个项目的用户端不只是微信小程序,很多学校的学生会习惯用 App 访问,企业管理员也可能在手机上查看简历。如果用原生小程序开发,微信端是没问题了,但 App 端还得再写一套,开发量直接翻倍。UniApp 底层把 Vue 语法编译成各端代码,页面的点击事件、请求逻辑、组件渲染逻辑只要写一遍。工程上的代价是调试时偶尔会遇到编译差异的问题,但整体收益远大于成本。
UniApp 对微信小程序的兼容性在这个项目里也用到了不少细节。比如uni.login对接微信登录、uni.request封装网络请求、uni.setStorage做本地缓存、uni.customShare自定义转发按钮,这些都是跨端 API,不需要额外引 SDK。页面结构上使用<template>+<script>+<style>的 Vue 单文件组件结构,写过 Vue 的人几乎零成本上手。
2.2 后端选型:Spring Boot 项目结构怎么搭
后端没有太多悬念,直接用了 Spring Boot 2.7.x + MyBatis-Plus + MySQL 8.0。选 MyBatis-Plus 而不是 JPA,是因为这个项目的查询场景比较复杂,推荐算法需要很多自定义 SQL,MyBatis-Plus 的灵活度更合适。
项目按模块分包,不是传统的按三层架构堆文件,而是按业务域划分:controller/service/mapper只作为技术层存在,业务上分成 user、job、application、recommend、message、admin 六个模块。每个模块有自己的 controller、service、entity、mapper,这样后期加功能时定位代码非常快。
一个值得说的设计是统一返回值结构。所有接口统一返回Result<T>,包含 code、message、data 三个字段,前端用同一套逻辑处理成功和失败。这个看似简单的约定,在前后端联调阶段省了大量时间。前端再也不用从各种不同的返回结构里去猜当前是成功还是失败。
2.3 接口约定与推荐服务的衔接设计
前后端接口约定上,考虑到推荐模块本身是从职位列表上叠加的一层逻辑,我没有把推荐做得太重。做法是:列表接口GET /api/job/list支持传入recommend=true参数,后端在推荐模块开启时走推荐排序逻辑,关闭时走普通的分页时间排序。这样一是方便对照验证推荐效果,二是即便推荐引擎出问题,列表功能依然可用。
接口设计上还有一个关键点:所有接口统一走/api/前缀,登录后返回 token,后续请求在 header 里带Authorization: Bearer token。Spring Boot 写一个拦截器统一校验,微信小程序端在uni.request的 header 中自动附加 token。这套机制简洁可靠,容易出现的问题都在 Token 的过期和失效处理上,后面会细说。
3. UniApp 前端设计与实现的关键节点
3.1 页面结构划分与导航设计
微信小程序的页面导航和传统 Web 不一样,底部 tabBar 是固定的几个入口,页面跳转是栈的方式。这个项目底部分五个 tab:首页、职位、消息、我的。
首页是推荐位,默认展示推荐算法算出来的 10 个岗位卡片,这页是为"推荐"这个核心功能服务的。职位页是完整列表,按分类筛选、按关键词搜索、按距离排序,这部分是给有明确目标的学生用的。消息页集成系统通知和投递状态变更通知,学生能在这里看到"简历被查看"、"已被录用"等状态变化。我的页面是个人中心,管理简历、设置空闲时间、查看收藏等。
tabBar 在pages.json里配置,需要注意图标文件只能使用本地路径,不能使用网络图片,否则小程序编译会报错。这个细节很容易忽略,我第一次配置时直接填了 CDN 链接,运行时 tabBar 的图标一直显示不出来,排查了很久才发现是微信小程序对 tabBar 图标的限制。
3.2 登录鉴权与用户角色切换
微信小程序登录流程比 Web 登录多了一层。前端调uni.login拿到临时code,把 code 传给后端,后端拿 code 去微信接口换取openid,然后生成自己的 token 返回。因为微信官方限制,code 只能使用一次,而且有效期很短,所以这一步必须由后端完成,前端不能直接拿 openid 来用。
用户角色切换是这类系统的特殊需求。同一个微信账号,既可能是学生,也可能是企业管理员。我不会把角色写死成一个字段,而是设计了user表和student_profile/employer_profile两张扩展表,用户在登录后选择身份,然后跳转到对应的信息完善页面。后续接口根据当前 token 对应用户的身份,加载对应角色的数据。
这里有个容易被忽略的问题:微信小程序的 code2session 接口要求服务端具备小程序的 appid 和 secret,这些信息放到 Spring Boot 的application.yml里,不同环境用不同配置。为防止泄露,我用了@ConfigurationProperties读取,避免硬编码在代码里。
3.3 职位列表、搜索与详情页实现细节
职位列表页是这个项目前端代码量最大的页面。列表用scroll-view实现,配合上拉加载更多。这里的核心是分页逻辑:每页 10 条,往下滚动时触发加载下一页。踩过一个坑,scroll-view的滚动事件在小程序端触发频率很高,如果不加防抖,一个快速滑动可能触发十几次请求,直接把后端打懵。解决方案是加锁,用一个loading标志位判断当前是否已在请求中,未完成前不再发请求。
搜索功能支持两个维度:关键词搜索和分类筛选。关键词匹配的是岗位名称和岗位描述,分类则是一级分类(家教、餐饮、促销、实习等)。这两个条件在后端拼成动态 SQL,前端只需要把参数拼到请求里。排序上除了默认推荐排序,还支持按发布时间、薪资待遇排序。
详情页就比较简单了,展示岗位描述、薪资、工作地点、时间要求、联系人信息,底部是"立即投递"按钮。投递后按钮变灰,后端做幂等校验防止同一个学生重复投递同一个岗位。
4. Spring Boot 后端核心模块拆解
4.1 数据库表设计与关键字段说明
数据库是这个系统的根基,我设计了十张核心表,按业务域分组:
| 表名 | 用途 | 关键字段 |
|---|---|---|
| tb_user | 用户账号 | openid, role, phone, status |
| tb_student | 学生扩展信息 | user_id, school, major, grade, district_id |
| tb_employer | 企业扩展信息 | user_id, company_name, credit_code |
| tb_job | 兼职岗位 | title, category, salary, workplace, job_time, head_count, status |
| tb_resume | 学生简历 | user_id, skills, self_intro, free_time |
| tb_application | 投递记录 | job_id, student_id, status, apply_time |
| tb_collection | 收藏记录 | job_id, student_id, create_time |
| tb_feedback | 评价反馈 | application_id, rating, comment |
| tb_notification | 通知消息 | user_id, content, type, read_flag |
| tb_category | 职位分类 | parent_id, name, icon |
其中tb_job的status字段控制岗位状态:0待审核、1招聘中、2已招满、3已下架。企业发布的岗位默认是待审核状态,管理员在后台审核通过后才会在前端露出。这个状态机设计让平台对信息质量有控制力,避免学生看到一堆未经验证的兼职信息。
head_count字段值得一提。它表示岗位总招聘人数,每次有人被录用就扣减。这个字段不是简单的int而是设置了version乐观锁字段,防止并发投递时超录。具体实现后面详述。
4.2 用户画像与岗位标签化处理
推荐系统能跑起来的前提是数据要结构化。我给每个岗位打标签,标签分为两类:一类是"硬属性",如工作地点、工作时段、薪资范围,另一类是"软技能",如沟通能力、文字功底、编程能力、设计能力。硬属性可以直接通过字段匹配,软技能则在发布岗位时由企业勾选。
学生的数据同样做标签化。注册时让学闹勾选自己的技能标签(最大选 5 个),完善简历时填写空闲时间段(比如每周二下午、周六全天),同时后台通过学生账号的学校信息自动计算所在校区。这部分数据是推荐系统的输入,如果学生不填,推荐效果会很差,所以前端在注册流程中把它设计为必填步骤,没有完整简历的学生进入首页时会被引导去完善资料。
这个设计在实操中有一个改进点:不要让学生填太多选项,每类问题控制在 5 个以内,否则用户会失去耐心。我第一版问卷里技能标签列了 15 个,测试时有学生反馈"选不过来",后来精简到两类各 5 个选项,完成率明显上来了。
4.3 推荐算法落地:基于标签匹配与行为反馈的加权排序
推荐模块是技术含量最高的部分,也最容易写过头。我没有选择一开始就上协同过滤或者深度学习模型,而是先用一个可解释、可调试的标签加权算法跑通全流程,后续有数据积累再迭代。
推荐得分由三部分组成:
推荐得分 = 标签匹配分(60%) + 行为反馈分(30%) + 新鲜度加分(10%)
标签匹配分:学生标签与职位标签的交集数量,每个命中标签得 20 分,满分 100 再乘 0.6 权重。行为反馈分:如果学生收藏过同分类的职位,加 30 分;投递过同类的职位,加 50 分;被企业录用过同类职位,加 70 分。这部分模拟"用户对这类岗位的真实偏好"。新鲜度加分:岗位发布时间在 3 天内加 10 分,7 天内加 5 分,超过 15 天不加分。
计算逻辑放在 service 层,先用 SQL 查出当前用户的标签和空闲时间,再查出所有招聘中的岗位,在内存中计算得分并按分数降序排列。首次跑通后我做了评测:随机选了 50 个学生,让系统各推荐 10 个岗位,然后人工核对合理性。刚开始发现一个严重问题——位置偏远但标签全中的岗位排序太靠前,学生根本不会去。于是我又加入一个距离惩罚因子:如果岗位地点与学生校区距离超过 5 公里,总分乘以 0.7。加上这个约束后,推荐结果的合理性提升了一大截。
4.4 投递流程与通知触达的实现
投递记录表tb_application的状态流转是:已投递 -> 已查看 -> 已录用 / 已拒绝。企业在后台点击"录用"时,后端除了更新投递状态,还要做两件事:扣减岗位head_count,生成一条通知写入tb_notification表。
学生端消息页的消息列表从哪里来?就是查tb_notification表。这里没有用 WebSocket 实时推送,因为第一版需求没有强实时性要求,学生在消息页下拉刷新或者进入页面时拉取最新通知即可。微信小程序有一个订阅消息能力,可以实现审核结果、录用结果的通知触达,但需要用户主动订阅且每次订阅只能推送一次。我在投递按钮上加了一个订阅请求,用户点击投递时弹窗让用户勾选"允许发送录用通知",这样可以做到录用结果的实时触达,是一个体验提升点。
5. 微信小程序特有的工程化与兼容性处理
5.1 微信登录与 Spring Boot 令牌机制的对接
这一节容易踩坑,单独拿出来说。微信小程序的登录流程:
- 前端
uni.login获取临时 code - 前端把 code 通过
uni.request发送到后端POST /api/auth/wx-login - 后端调用微信接口
https://api.weixin.qq.com/sns/jscode2session,传 appid、secret、code - 微信返回
openid和session_key - 后端用
openid查用户,没有则创建,有则更新最后登录时间 - 后端生成一个 UUID token,存入 Redis 并设置过期时间,返回给前端
- 前端把 token 存入
uni.setStorageSync('token', token)
后续所有请求在uni.request的 header 中带上 token。Spring Boot 端用一个拦截器JwtInterceptor,检查 header 中的 token 是否在 Redis 中存在且未过期,不存在返回 401,前端收到 401 后清理本地 token 并跳转登录页。
这个链路里最容易出问题的点:一是code只能用一次,如果前端在请求超时后重试,同一个 code 传给后端两次,第二次一定失败;二是session_key不能暴露给前端,它涉及微信敏感数据的解密,必须留在后端。
5.2 网络请求封装与多环境域名切换
UniApp 开发环境和生产环境的接口地址不同,这是多环境域名问题的根源。我把request封装为一个公共模块,用环境变量控制 baseURL。
// utils/request.js const BASE_URL = { development: 'http://127.0.0.1:8080/api', production: 'https://api.example.com/api' } function request(path, method = 'GET', data = {}) { return new Promise((resolve, reject) => { uni.request({ url: BASE_URL[process.env.NODE_ENV] + path, method, data, header: { 'Authorization': 'Bearer ' + uni.getStorageSync('token') }, success: (res) => { if (res.data.code === 200) { resolve(res.data.data) } else { uni.showToast({ title: res.data.message, icon: 'none' }) reject(res.data) } }, fail: (err) => reject(err) }) }) }开发环境可以在微信开发者工具里勾选 "不校验合法域名",但上线前必须在微信公众平台配置 request 合法域名,且必须是 HTTPS 且完成备案。这个流程不复杂,但经常被忽略,导致真机预览时所有请求都报fail url not in domain list。另外在 H5 端做多域名切换时,也可以在 manifest.json 的 h5 配置里修改 devServer.proxy 来代理接口,减少跨域问题。
5.3 页面性能优化与缓存策略
小程序包体积限制是 2MB(主包),超过就需要分包。我第一版把所有页面都放主包,打包后发现体积 3.2MB,提示超限。后来把消息模块和反馈模块拆成独立分包,主包减到 1.6MB。分包加载确实会有一点切换延迟,但能接受。
图片资源是体积大头,项目里做了三件事控制:所有图片压缩后上传到对象存储,不打包进小程序;列表页的图片懒加载,使用微信小程序的lazy-load属性;icon 统一用 iconfont 字体图标,而不是切图。
列表页的数据缓存也做了处理:首页推荐列表存入本地缓存,缓存时间 5 分钟,学生打开小程序时先渲染缓存,再请求新数据。这样既保证了首屏速度,又不会让数据太旧。uni.setStorageSync存的是 JSON 字符串,读出来时要JSON.parse,这个 API 的同步阻塞在小数据量时没什么感觉,但注意不要存大数据。
6. 测试结果与上线阶段发现的几个问题
6.1 并发场景下的名额超录问题
测试阶段最让我头疼的是名额超录。模拟 200 个学生同时投递一个仅剩 5 个名额的岗位,居然有 23 个人投递成功,远超预期。排查后发现是扣减head_count的 SQL 写法有问题。
最初的代码是:
// 先查出当前名额 Job job = jobMapper.selectById(jobId); if (job.getHeadCount() > 0) { // 扣减 job.setHeadCount(job.getHeadCount() - 1); jobMapper.updateById(job); // 创建投递记录 }这段代码在单线程下没问题,但高并发下多个请求同时读到head_count > 0,同时执行扣减,就会超录。解决方案是两层保护:投递表加唯一索引(student_id + job_id),保证同一个学生不能重复投递;名额扣减改用乐观锁更新:
UPDATE tb_job SET head_count = head_count - 1, version = version + 1 WHERE id = #{jobId} AND head_count > 0 AND version = #{version}如果更新影响行数为 0,说明名额已被抢完或者版本冲突,直接返回"岗位已满"。改造后我用 JMeter 再次测试 200 并发,投递成功数正确落在 5 以内。
6.2 推荐冷启动问题的临时方案
系统刚上线时,每个学生都是新用户,没有任何行为数据,行为反馈分全部为 0,推荐结果完全由标签匹配和新鲜度决定。结果就是所有学生看到的推荐列表几乎一样,没有个性化差异。
这个问题的本质是冷启动。我没有从头去研究复杂的冷启动方案,而是用了两个临时手段过渡:一是上线前从学校就业办导入了过往几届学生的兼职偏好数据,给每个新学生的标签相似度做个初始画像;二是推荐列表的每个分类下保留一个固定位给"精选岗位",由平台管理员手动推荐,保证即使算法还没跑起来,首页也不会是空的。
过渡手段虽然土,但在冷启动阶段是有效的,之后随着用户行为数据积累,行为反馈分的权重才逐渐发挥作用。
6.3 慢查询定位与索引优化
系统运行两周后,职位列表页的响应时间出现了明显劣化,高峰期超过 4 秒。查看 MySQL 慢查询日志,发现一个高频慢 SQL 是tb_application表按student_id查投递记录,这个表数据量只有几千行,但全表扫描耗时居然超过 1 秒。原因是student_id字段没有建索引。
这类问题的排查思路值得分享:先开启 MySQL 慢查询日志,找到long_query_time超过阈值的 SQL,然后EXPLAIN分析 SQL 的执行计划,看 key 是否为 NULL(代表全表扫描)。两个高频操作都加了索引:
tb_application表的student_id加普通索引tb_job表的status + category加联合索引,因为列表页筛选最常走这两个字段
优化后同样接口的响应时间从 4 秒降到 300 毫秒以内。这提醒我,索引不是可有可无的,尤其在数据量增长之后,没有索引的查询会变成系统的定时炸弹。
6.4 测试过程中发现的移动端兼容性问题
最后说说兼容性。我用 UniApp 编译到微信小程序、H5、Android App 三端分别测试,发现了一些差异:
uni.showToast在 H5 端默认显示时间比小程序短,需要显式设置 duration- 部分 CSS 的
position: fixed在 iOS 小程序端会出现遮挡底部导航栏的问题,需要用bottom: calc(env(safe-area-inset-bottom))适配 - Android 端使用的是 X5 内核,
border-radius在个别低版本机型上会失效,用overflow: hidden包裹可以规避 - 使用
canvas绘制分享图片时,iOS Safari 上有时导出白图,需要把 canvas 的宽高设置成两倍物理像素
移动端兼容性的核心经验是:不要只看微信开发者工具里的效果,一定要在至少一台 Android 真机和一台 iOS 真机上测试,尤其是涉及底部安全区、键盘弹出、图片上传这些交互时,开发者工具的模拟器和真机的行为差异非常大。
对我来说,这个项目给我最重要的收获不是某个具体技术,而是一个完整系统的工程化思维:前端不是单纯的页面堆叠,后端不是单纯的增删改查,推荐算法也不是孤立的模型。它们被一个核心业务目标串联起来:让大学生更快找到合适的兼职,让企业更快找到靠谱的学生。每一次技术选型、每一处代码优化,都是在为这个目标服务。如果你也要做一个类似的全栈项目,建议先把这个业务闭环理清楚,再去动代码,你会发现后面的路顺畅得多。