七月初那阵子,我带的计算机专业学生刚结束顶岗实习。收上来的《实习鉴定表》,有交Word的、有交PDF的、有直接微信发截图的,50多个学生,文件夹里已经分不清哪个是最新版。当时我就想,这种场景再传统线下管下去,老师和学生都得被表格淹了。后来我花了两周多时间,用微信小程序做前端、Python Flask做后端,把整条顶岗实习管理流程搬上了线,这个项目的完整源码我命名成了730735g5。如果你正在做类似的毕业设计,或者想给学院搭一套内部管理系统,这篇笔记应该能帮你少走不少弯路。下面我从需求拆解、技术选型、数据库设计、接口实现、小程序端踩坑到部署上线一条线讲清楚,每一步都给你可以直接抄的细节。
1. 顶岗实习管理到底在管什么:从一次“收表事故”说起
1.1 三方角色的真实痛点
做系统之前,先得搞清楚谁是用户、他们各自在痛什么。
学生这边的痛点最直观:找岗位靠老师在群里发Excel,自己填完申请表不知道有没有人看,实习期间写周报全凭自觉,最后要交实习鉴定表时又找不到模板。企业导师那边的感受也好不到哪去,带实习生没有统一入口,考勤记录散落在微信聊天记录里,到了月底要给实习生写评价,全靠回忆。校内指导教师则要面对几十个学生的过程材料,一一核对、整理归档,一个学期下来光催材料就能把人催崩溃。
所以这套系统表面上是“实习管理”,本质上是把学生、企业、校内教师、院系管理员之间的信息流从微信群和Excel里解放出来,变成一条有状态、有记录、可追踪的业务流水线。
1.2 业务主流程梳理
我在动手写代码之前,先把整个业务主流程画在了纸上。这里不用画多复杂的流程图,但逻辑必须闭环。一个典型的顶岗实习周期是这样的:
- 学院发布实习计划,企业同步发布实习岗位。
- 学生通过小程序浏览岗位库,提交实习申请。
- 企业导师或校内教师审核申请,通过后学生进入“待报到”状态。
- 实习正式开始:学生到岗签到、每周提交周报、企业导师在关键节点给出评价。
- 实习结束:企业导师评定实习成绩,校内教师复核确认,系统归档。
- 管理员可以随时导出统计报表,查看实习率、周报提交率、成绩分布。
这套流程里,最容易被人忽略但又最关键的是“状态”。如果一个学生中途换了企业、或者提前结束实习,流程必须能正确处理。所以数据库里一定要有一张贯穿始终的状态字段,而不是把状态散落在各个业务表里。
1.3 角色与权限模型
我把用户分成了五类,每类权限边界非常清晰:
| 角色 | 核心权限 |
|---|---|
| 学生 | 查看岗位、提交申请、签到、写周报、查看成绩 |
| 企业导师 | 发布岗位、审核申请、查看学生签到/周报、评定成绩 |
| 校内教师 | 审核实习申请、查看学生动态、复核成绩 |
| 院系管理员 | 管理学生和岗位、导出报表、审批异常 |
| 系统管理员 | 用户管理、角色分配、系统配置 |
这里要特别说一句:别把权限设计得过于复杂。顶岗实习系统属于典型的中小型内部管理系统,不需要引入独立的权限框架,Flask里用一个user_role字段配合装饰器就足够了。我在早期版本里曾经试图模仿企业内部系统做细粒度权限点控制,结果把自己绕晕了,后来全部砍掉,换成角色级别控制,代码量减少三分之一,业务方反而觉得更清晰。
2. 为什么是 Flask + 微信小程序这个组合:技术选型背后的考虑
2.1 小程序端是“轻”的胜利
很多人在做这类系统时,第一反应是做一个网页管理后台,学生用浏览器访问就行了。但实际场景里,学生和企业导师的使用习惯完全不一样:学生的信息获取入口基本都在微信里,企业导师更不可能为了给你写评语专门打开一个网页系统。
微信小程序的最大优势就是“扫一扫即用”。学生不用安装App、不用记网址、不用做账号密码注册,通过微信授权就能完成身份绑定。这对于顶岗实习这种“周期性使用、低频但强刚需”的场景极其合适。而且小程序自带消息订阅能力,周报提醒、审核结果通知都可以通过订阅消息触达,省掉了短信费用。
2.2 Flask的“巧”体现在哪里
后端选Python Flask,首先是因为学生和课程老师最熟悉Python。其次,Flask足够简单——路由写个装饰器就能用,配合 SQLAlchemy 操作数据库也很顺手,整个项目的代码量不会失控,适合一个人短时间搞定。
更实际的原因是:毕业设计答辩时,评委大概率会追问“你每个接口是怎么实现的”。Flask 的路由和视图函数是一一对应的,每个接口都清清楚楚,讲代码的时候很容易表达。如果换成 Django,虽然有自带Admin后台和ORM,但中间藏着太多框架帮你做的事情,被问到底层原理时反而容易卡壳。
对于内部管理系统来说,Flask这种微框架的自由度也是个优点。你可以用自己的习惯组织项目结构,不用遵守框架的强迫性规范,踩坑时解决问题的路径也短。
2.3 Flask、Django、FastAPI 到底怎么选
我知道很多人包括我在内,一开始都会纠结框架选型。当时我还专门做了一轮对比:
| 维度 | Flask | Django | FastAPI |
|---|---|---|---|
| 上手难度 | 低 | 中 | 中 |
| 自带功能 | 少而精 | 全家桶 | 较少 |
| 异步支持 | 需扩展 | 需扩展 | 原生 |
| 学习资料量 | 多 | 多 | 中等 |
| 开发速度(中小系统) | 快 | 中等 | 较快 |
| 适合场景 | API/中小系统 | 中大型全栈 | 高并发/API服务 |
最终我选择Flask,还有一个很现实的因素:希望遇到问题时能快速搜到答案。“flask部署”“flask登录”“flask上传文件”这类问题,在社区里基本都有成熟的解决方案,不会像相对小众的框架那样查半天还在看官方文档。
2.4 前后端分离的接口约定
这个小程序项目本质上是纯前后端分离:小程序端只负责页面展示和用户交互,所有数据操作都通过JSON接口交给Flask处理。所以从一开始,我就统一了后端的返回格式:
{ "code": 0, "msg": "success", "data": {} }这个约定非常重要。小程序端封装一个request.js,统一处理响应拦截,如果code非0就弹出错误信息。后端的每个接口都遵守这个格式返回,前后端联调时的摩擦成本会小很多。
3. 数据库设计:把实习流程抽象成表结构
3.1 核心表设计与建模思路
数据库是整个系统的地基。我先说结论:顶岗实习管理系统不需要几十张表,核心就是学生、教师、企业岗位、实习关系、签到、周报、成绩这七张主表,再加两三张关联表。
我在设计数据库时坚持一条原则:一页纸能讲清楚所有表。如果一个表的字段超过十五个,要么设计有问题,要么业务边界没划清。
| 表名 | 用途 | 核心字段 |
|---|---|---|
| student | 学生信息 | id, name, student_no, class_name, phone, user_id |
| teacher | 教师/企业导师 | id, name, role_type, company, phone, user_id |
| job | 岗位信息 | id, title, company, location, requirement, quota, status |
| internship | 实习关系表 | id, student_id, job_id, teacher_id, status, begin_date, end_date |
| checkin | 到岗签到 | id, internship_id, check_date, location, remark |
| weekly_report | 周报 | id, internship_id, week_no, content, file_url, teacher_comment, status |
| evaluation | 成绩评定 | id, internship_id, company_score, teacher_score, final_grade, comment |
3.2 实习状态机的流转设计
这是整张数据模型里最核心的部分,也是很多毕设最容易做错的地方。实习状态status字段我定义如下:
1 = 申请中 2 = 已通过 3 = 已驳回 4 = 待报到 5 = 实习中 6 = 已结束 7 = 已终止这个状态机的关键点是:状态只能按业务规则流转,不允许跳变。比如申请中的记录不能直接变成实习中,必须先通过审核再报到。我建议在代码层做一次状态合法性校验,而不是完全信任前端传来的数据。
3.3 建表SQL示例
这里给出我最核心的三张表的建表SQL,其他表可以参考同样的风格扩展。强调一下注释一定要写清楚,不然过两个月你自己回来看都不知道这个字段是干什么的。
CREATE TABLE student ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', user_id INT COMMENT '关联用户表ID', student_no VARCHAR(20) UNIQUE COMMENT '学号', name VARCHAR(50) COMMENT '姓名', class_name VARCHAR(50) COMMENT '班级', phone VARCHAR(20) COMMENT '手机号', created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间' ) COMMENT '学生信息表'; CREATE TABLE internship ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', student_id INT COMMENT '学生ID', job_id INT COMMENT '岗位ID', teacher_id INT COMMENT '企业导师/校内教师ID', status TINYINT DEFAULT 1 COMMENT '状态: 1申请中 2已通过 3已驳回 4待报到 5实习中 6已结束 7已终止', begin_date DATE COMMENT '实习开始日期', end_date DATE COMMENT '实习结束日期', remark VARCHAR(255) COMMENT '备注' ) COMMENT '实习关系表'; CREATE TABLE weekly_report ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', internship_id INT COMMENT '实习关系ID', week_no INT COMMENT '第几周', content TEXT COMMENT '周报内容', file_url VARCHAR(255) COMMENT '附件地址', teacher_comment VARCHAR(500) COMMENT '导师评语', status TINYINT DEFAULT 0 COMMENT '审核状态 0待审核 1通过 2打回' ) COMMENT '周报表';3.4 几个容易忽略的数据库细节
时间字段统一用DATETIME,别用字符串存时间,配合Python的datetime模块操作特别顺手。状态字段用TINYINT并加注释,不要直接用字符串像“pending/approved”这种写进表里,查询效率差不说,还容易因为大小写不一致出错。图片和附件地址只存相对路径,不存完整URL,这样后来换域名或迁移服务器时不用改数据库。
另外一个我踩过的坑:关联字段一定要建索引。实习管理系统的核心查询路径是“学生 → 实习关系 → 周报/签到”,如果internship表的student_id和weekly_report表的internship_id不加索引,等数据量到几千条以后,查询速度会明显变慢。
4. 后端接口实现:从登录到业务流程
4.1 微信登录态管理:openid 才是真正的用户标识
这一步是整个系统最容易踩坑的地方。很多人一开始会想用手机号作为用户唯一标识,或者直接让用户注册账号密码,在微信小程序场景下这都是在给自己找麻烦。
微信小程序登录的正确姿势是:前端调用wx.login()拿到一个临时code,后端用这个code加上appid和appsecret去向微信服务器换openid。openid对每个用户都是唯一且不变的,这就是用户的天然账号。
from flask import request, jsonify import requests, hashlib APPID = "你的appid" SECRET = "你的appsecret" @app.route('/api/login', methods=['POST']) def login(): code = request.json.get('code') url = 'https://api.weixin.qq.com/sns/jscode2session' params = { 'appid': APPID, 'secret': SECRET, 'js_code': code, 'grant_type': 'authorization_code' } resp = requests.get(url, params=params).json() openid = resp.get('openid') if not openid: return jsonify({"code": 1, "msg": "登录失败", "data": None}) # 根据openid查找或创建用户 user = find_or_create_user_by_openid(openid) token = hashlib.sha256(f"{openid}{timestamp}".encode()).hexdigest() save_token(token, user['id']) return jsonify({"code": 0, "msg": "ok", "data": {"token": token, "role": user['role']}})拿到token之后,小程序后续的每个请求都在 Header 里带上Authorization: token,后端再根据 token 解析出用户ID。这里不要用微信官方建议的session_key去做自建登录态,调试起来非常绕。
4.2 岗位申报与审核接口
岗位申报是最典型的“学生发起、教师审批”流程。接口我拆成了两部分:学生端提交申请、教师端列表审核。
@app.route('/api/apply', methods=['POST']) @login_required def apply_job(): user = get_current_user() if user.role != 'student': return jsonify({"code": 1, "msg": "仅学生可申请", "data": None}) job_id = request.json.get('job_id') # 检查是否已申请过该岗位 existing = find_applied(user.student_id, job_id) if existing: return jsonify({"code": 1, "msg": "你已经申请过该岗位", "data": None}) create_internship(student_id=user.student_id, job_id=job_id, status=1) return jsonify({"code": 0, "msg": "申请成功", "data": None})这里有个很容易犯的错误:学生端退出小程序再进来,前后端状态校验可能不一致,所以每个需要身份的接口都必须从token解析用户,绝对不能信任前端传过来的student_id。我有个朋友在答辩前三天发生过一个bug:学生A申请了岗位,记录里的student_id却是学生B的,就是因为直接在请求体里传ID。
4.3 周报与签到接口:图片上传方案的取舍
周报和签到都涉及一个共同需求:上传图片。比如周报附件截图、现场签到照片。Flask处理图片上传,我用的是最传统的方式:
- 前端
wx.uploadFile上传到后端/api/upload - 后端保存到服务器本地
static/uploads/ - 返回相对路径
/static/uploads/xxx.jpg - 前端再把路径随表单内容一起提交
@app.route('/api/upload', methods=['POST']) @login_required def upload(): file = request.files.get('file') if not file: return jsonify({"code": 1, "msg": "未上传文件", "data": None}) ext = file.filename.rsplit('.', 1)[-1].lower() if ext not in ['jpg', 'jpeg', 'png']: return jsonify({"code": 1, "msg": "仅支持图片", "data": None}) filename = f"{uuid4().hex}.{ext}" file.save(f"static/uploads/{filename}") return jsonify({"code": 0, "msg": "ok", "data": {"url": f"/static/uploads/{filename}"}})上传功能放到正式环境前一定要做三件事:限制文件类型和大小、重命名文件名避免直接使用用户原始文件名、设置好static/uploads目录的写权限。我第一次部署到Linux服务器时,一直报写入失败,查了半天才发现是目录权限是755,改成775就正常了。
4.4 统一封装响应工具
为了方便所有接口复用,我还封装了一个响应类:
def ok(data=None, msg="success"): return jsonify({"code": 0, "msg": msg, "data": data}) def fail(msg, code=1): return jsonify({"code": code, "msg": msg, "data": None})这看起来是小事,但真的能帮你省很多事。前后端一旦约定好这种返回格式,前端request.js里写一个拦截器,所有接口的错误提示都走同一条逻辑,后端也不需要每个视图重复写return jsonify(...)了。
5. 小程序端实现与踩坑记录
5.1 顶部导航栏高度适配:每个机型都不一样
很多毕设小程序最容易忽略的就是顶部导航栏。默认导航栏虽然省事,但如果你想做自定义导航栏(比如在顶部放一个搜索框或品牌名),就会遇到一个非常经典的适配问题:不同型号手机的顶部安全区域不同。
我当时用了一个通用方案,在app.js里面统一计算:
App({ onLaunch() { const menuButton = wx.getMenuButtonBoundingClientRect(); const systemInfo = wx.getSystemInfoSync(); this.globalData.navBarHeight = menuButton.bottom + 8; this.globalData.statusBarHeight = systemInfo.statusBarHeight; } })然后在自定义导航栏组件的json配置文件里设置:
{ "navigationStyle": "custom" }页面里给导航栏占位视图设置style="height: {{navBarHeight}}px",就不会出现按钮重叠或者内容被刘海屏吃掉的问题。这个方案我记得是当时搜“微信小程序顶部导航栏高度”的时候看别人分享的,实测在iPhone、安卓和老机型上都正常。
5.2 登录获取手机号:2023年后有隐藏门槛
关于登录,还有一个很多旧教程不会告诉你的坑:wx.getPhoneNumber按钮获取手机号,从某个基础库版本开始,已经要求小程序必须完成微信认证,个人开发者主体基本无法使用这个能力。很多网上的旧教程会说“点击按钮一键获取手机号”,但你在自己的开发者工具里试就会报错。
我的处理方案是:不要把手机号作为登录的必要条件。手机号只在第一次打开时,提示用户手动填写,作为备用联系方式存到student表里;登录认证一律走wx.login+ openid。这样既绕开了手机号授权的限制,又不会影响核心业务。实习系统本身也不需要手机号作为身份凭证,openid就已经天然唯一了。
5.3 表单组件的几个细节点
小程序端的表单交互,跟普通网页不一样的地方不少。我印象最深的有三个:
第一,radio-group的change事件回调里取到的e.detail.value是字符串,不是对象,要手动转成数字再传给后端,不然数据库里多出一个"2"和数字2混淆的坑。
第二,picker组件在部分安卓机型上,首次点击会出现选项列表闪烁的问题。这是因为range数据是异步加载的,最好在onLoad里提前请求并缓存下来,不要在每次打开时才加载。
第三,表单提交前一定要做二次确认。小程序端输入框没有类似required的强校验,如果你在后端没有做校验,空数据也会写进数据库。那段时间我修得最多的问题就是用户把空周报提交上来了。
5.4 包体积超过 2MB 限制怎么办
我看到不少做毕设的同学卡在一个报错上:source size 2612kb exceed max limit 2mb。小程序主包大小限制2MB,一旦超了就无法上传预览。
解决办法按优先级排:先压缩本地图片资源,很多包体积超标其实是 UI 稿里的背景图、图标太大造成的;然后把vant-weapp这类组件库改成按需引入,别一次性usingComponents注册所有组件;最后才是做分包加载,把“签约流程”“实习记录”这些低频页面放进subpackages分包中。
其实只要第一阶段做得好,大部分项目都能压到2MB以内。我当时把一个3MB多的项目最终压到1.6MB,主要就是靠压缩图片和精简组件。
5.5 小程序性能体验上的两个小建议
关于性能测试,我额外说两个点:
用户在小程序里滑动长列表时,如果数据量超过50条,建议用recycle-view组件做列表复用,普通wx:for渲染几百条数据明显卡顿。此外,请求接口时一定要加加载态和防重复点击,不然网络慢的时候用户会连续点几次提交,后端会收到好几条重复的周报记录。我在后端加了唯一索引来挡重复数据,这是最后的兜底,前端也要配合。
6. 部署与上线:Flask项目能上线跑起来的完整路径
6.1 Linux 服务器环境准备
开发完成后,项目要真正跑起来,还得过部署这一关。我用的服务器是 CentOS 7,基本环境配置流程如下:
# 安装 Python3.8 yum install -y python38 python38-devel # 创建虚拟环境 python3.8 -m venv venv source venv/bin/activate # 安装依赖 pip install flask flask-cors flask-sqlalchemy pymysql gunicorn # 启动测试 python app.py这里要注意,千万别在系统Python环境里直接装依赖。虚拟环境是必须的,不然以后升级Python或换项目时,依赖冲突能让你怀疑人生。另外pymysql需要加一行pymysql.install_as_MySQLdb(),不然SQLAlchemy连MySQL会报模块找不到。
6.2 gunicorn + nginx 的经典组合
Flask自带的开发服务器app.run()在调试时没问题,但并发一上来就扛不住,而且默认不支持多进程。生产环境推荐用gunicorn作为WSGI服务器:
gunicorn -w 4 -b 127.0.0.1:5000 app:app然后用 nginx 做反向代理,把80端口的请求转发给5000端口。配置文件核心部分:
server { listen 80; server_name yourdomain.com; client_max_body_size 10m; location / { proxy_pass http://127.0.0.1:5000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location /static/ { alias /var/www/yourproject/static/; } }这里有两个细节容易翻车。第一是client_max_body_size 10m;必须加上,否则默认1MB的限制会导致上传照片失败。第二是static目录的alias路径要写对,我在这一步也栽过一次,页面加载图片全是404,排查了半天发现是alias路径少了最后的斜杠。
6.3 HTTPS 与小程序合法域名配置
小程序和普通网页有个最大的区别:所有wx.request请求的域名,必须在微信公众平台后台配置为合法域名,而且必须是HTTPS协议,不能用IP加端口。
所以部署后要做的第一件事就是申请SSL证书。如果是在阿里云买域名,可以申请免费的数字证书,大概一两天就能下发。nginx启用HTTPS的片段:
server { listen 443 ssl; server_name yourdomain.com; ssl_certificate /etc/ssl/yourdomain.pem; ssl_certificate_key /etc/ssl/yourdomain.key; }配置完成后,在微信公众平台的“开发管理-服务器域名”里,把https://yourdomain.com添加进request合法域名。注意,这里最好不要带端口,如果服务器不是80/443端口,小程序默认是不允许的,这也是为什么一定要用nginx反代而不是直接暴露5000端口。
6.4 部署后还容易翻车的几个点
有一点我之前没经验,上线后没关debug模式,结果用户一遇到报错,页面直接弹出Python的完整堆栈信息,连服务器文件路径和数据库语句都暴露了。上线前必须确保app.debug = False,同时配置日志文件记录错误堆栈。
还有环境变量的问题。数据库密码、appsecret这类敏感信息不要写死在代码里,用环境变量加载。secret泄漏的后果很严重,有人可以拿你的appid加secret去调用微信接口。
最后是数据库备份。实习系统里面有学生、企业的真实数据,数据没了就是事故。我后来简单写了一个crontab任务,每天凌晨把MySQL的internship_db库dump成一个sql文件保留最近7天的备份。这个习惯强烈建议从第一天就养成,不要等到数据丢了再后悔。
整个项目做完,我最意外的不是接口写得多顺手,而是小程序端的各种机型适配和微信平台的规则限制,几乎占据了总开发时间的一半。如果你的目标也是做一套能真正上线使用的实习管理系统,一定要提前把微信认证、HTTPS、合法域名这些和业务无关但绕不开的环境问题排进时间计划里。这个系统后续还可以接地图定位做自动签到、接订阅消息做周报提醒、加Excel导出做院系统计报表,每一块都能单独写一篇经验贴。希望这篇笔记能帮正在做类似项目的你省下几个熬夜通宵。