我们学校的学生资助管理中心,前两年还靠一个三千人的微信群管勤工助学。岗位信息往群里一甩,学生靠手速抢,没过五分钟报名就满了;月底各用工单位交上来的工时表格式五花八门,不是漏了签名就是日期对不上。这套基于ThinkPHP的勤工助学系统,就是从那个混乱状态里长出来的。全文的设计与实现思路以ThinkPHP6为例,覆盖了岗位发布、学生申请、单位录用、工时填报、工资结算一整条链路。无论你是要做毕业设计,还是想给学校、中职或企业内部搭一套类似的勤工助学管理工具,这篇文章都能当作一个可落地的参考。
我不会单讲“怎么敲代码”,更多会聊我实际踩过的坑:字段设计为什么要那样定,路由跳转为什么总配不对,并发报名是怎么把小项目打挂的。这些内容比单纯粘一份代码更值钱。
1. 从“岗位登记靠微信群”到系统化:这个项目到底在解决什么问题
1.1 传统勤工助学管理的三个痛点
先说痛点,不然你没法理解系统的边界在哪。
第一是岗位信息不透明。勤工助学岗位分散在图书馆、行政楼各个科室,发布渠道要么是微信群,要么是辅导员口头通知。学生能看到什么岗位,完全取决于自己有没有在正确的时间打开群消息。更麻烦的是,很多岗位的申请状态是滞后的——岗位已经招满了,群里还在被反复转发,学生白跑一趟甚至重复投递。
第二是工时统计困难。勤工助学的薪资结算,通常按小时计算,流程是学生每次工作后登记时长,用工单位月底签字确认,资助中心再审核汇总。这个流程在线下跑起来非常痛苦:纸质表容易丢,签字确认可能拖一个星期,不同单位用的表单格式还不一样,汇总的时候光是对名字就得花大半天。
第三是工资结算缺乏可回溯依据。传统模式下,工资明细靠Excel,如果学生或老师对某个月的工时有疑问,很难查证具体是哪一天、哪个岗位、谁确认的这个数据。没有过程记录,就没有审计价值,这也是后来我坚持在系统里保留每一步操作状态和操作时间的原因。
1.2 系统的目标用户与核心价值
这套勤工助学系统面向三类角色:学生、用工单位(图书馆、后勤、各行政科室的老师)、资助中心管理员。
学生的核心动作是:浏览在招岗位、在线提交申请、查看录用结果、填报工时、查看工资单。用工单位老师的核心动作是:发布岗位、审核学生申请、确认工时、对已录用学生做评价。资助中心管理员的动作则更重一些:审核岗位是否合规、管理用户和部门、批量核定工资、导出报表。
系统的核心价值,是把过去零散的“群通知+纸质表+Excel”流程,收敛成“发布—申请—录用—考勤—结算”的在线闭环。数据一旦进了系统,每个环节都有状态和时间戳,月底工资结算就有据可查。比如某学生说“我上个月做了20个小时”,管理员直接拉出该学生的工时记录,每一条都有对应的用工单位确认人和确认时间,问题当场就能说清楚。
这套模式不只适用于高校。公司内部的勤工助学补贴、实训基地的岗位管理、公益活动时长记录,本质都是同一套流程,改改角色名称和字段就能复用。
2. 需求拆解与模块边界:学生、用工单位、管理员三方各自要什么
2.1 用例视角:学生端功能
我习惯先列用例,再画表结构。学生端最核心的用例是“岗位申请”。要注意,申请不是简单插入一条记录,它要处理几个真实场景:同一岗位只能申请一次,重复申请要提示;岗位有状态管理,只有“招聘中”的岗位才能被申请;岗位人数已满时,前端要提前灰掉按钮,后端也要拦截。
除了申请,学生端还有一个高频动作是“工时填报”。这块的坑主要在于时间维度。学生可能跨月补录,比如5月的工作,6月才想起来填报;也可能在一天内有多个不同岗位的零散工作记录。所以工时表不能简单以“月份”为唯一维度,必须按“某学生 + 某岗位 + 某日”尽量拆分,月末结算时再做汇总。
学生端还应该有一个“消息通知”入口,录用结果、工时被退回、工资已确认这些事件都应当通过站内消息推送给学生。不要迷信短信或邮件,校园场景下站内信加微信公众号模板消息最实用。
2.2 用工单位端的操作边界
用工单位端的功能被很多人忽略,但它恰恰是系统能落地多少的关键。单位老师不是天天有空打开系统看申请的,所以审核入口必须足够显眼,最好在首页直接展示“待审核申请数”和“待确认工时数”。
单位端的核心用例有三个:发布岗位、审批申请、确认工时。发布岗位时要填的信息包括岗位标题、工作地点、岗位描述、需求人数、薪资标准(时薪或月薪)、工作时间要求。这里要注意,岗位需要经过管理员审核才能上架,否则老师随手发个“招助理”就挂出去,很容易出问题。
审批申请的场景稍微复杂一些。一个热门岗位可能有几十份申请,老师需要逐个查看学生信息并给出“录用”或“不录用”的结论。录用动作会触发岗位名额扣减,这里有一个典型的并发问题,后面我会专门讲。所以“录用”绝不能写成“查询岗位剩余名额,判断大于0,然后update”,必须用原子更新处理。
一个容易遗漏的点是:用工单位端能看到的数据范围必须限制在本单位。很多初学者做系统时,把所有岗位列表直接拉到单位端,老师能看见其他部门的岗位和学生明细,这从数据安全和业务逻辑上都是不对的。
2.3 管理员端与权限模型
管理员端面向的是资助中心的老师和财务人员,负责整体把控。功能上至少要有:用户管理(启用、禁用、重置密码)、部门管理、岗位审核、工资结算、数据导出。
工资结算是管理员端最有价值也最容易翻车的地方。流程通常是:月初,管理员选择上月结算周期,系统自动汇总该周期内学生所有已被确认的工时,按岗位薪资标准计算金额,生成结算单。管理员核对无误后提交审核,进入“待发放”状态。
这里要注意权限模型的设计。我见过最简单的做法是在user表里加一个role字段,分别用1、2、3代表学生、单位、管理员。这在角色单一的学校足够用,但现实里会出现“兼职管理员”,比如某学生干部同时是学生身份,也是部门的勤工助学联系人。所以我更推荐用角色表或“角色字段存多个标识”的方式处理。下面列的模块清单,是我最后敲定使用的范围,你在自己的项目里可以根据体量裁剪:
| 模块 | 学生端 | 单位端 | 管理员端 |
|---|---|---|---|
| 岗位管理 | 浏览、申请 | 发布、编辑、结束 | 审核、下架 |
| 申请管理 | 查看状态、撤销 | 录用/驳回 | 查看汇总 |
| 工时管理 | 填报、申诉 | 确认、退回 | 纠错、导出 |
| 工资结算 | 查看工资单 | 查看部门工资单 | 生成结算单、审核 |
| 系统管理 | 修改个人信息 | 修改单位信息 | 用户、部门、日志 |
3. 数据库设计:岗位、申请、工时、工资这几张核心表的建模思路
3.1 表结构总览与命名规范
数据库设计决定了系统写到后期会不会乱。我的建议是:所有业务表以业务含义命名,主线表围绕“岗位—申请—工时—结算”四条链路展开,用户体系单独拆开。
具体我用了这些表,字段是精简过的,但足够跑通业务:
user:账号表,字段含id、username、password、real_name、phone、role、status。role不单纯是int,而是用逗号分隔的字符串,例如“1,2”表示该用户既是学生又是单位联系人,这为双角色切换留了口子。student_profile:学生扩展表,字段含id、user_id、student_no、college、major、class_name、bank_account、card_no。因为账号体系里已经有姓名和电话,学生特有的学号、院系、银行卡信息单独放,避免主表字段膨胀。dept:用工部门表,字段含id、user_id、dept_name、contact_name、contact_phone。这里的user_id是单位联系人的账号ID,每个单位可以绑定多个联系人,但主联系人只有一个。post:岗位表,字段含id、dept_id、title、content、require_num、hired_num、salary_type、salary_value、work_time_desc、status、publish_time、expire_time。application:申请表,字段含id、post_id、student_id、status、apply_remark、review_user_id、review_time、review_remark。work_log:工时表,字段含id、post_id、student_id、work_date、start_time、end_time、hours、content、status、confirm_user_id、confirm_time、confirm_remark。salary_settlement:工资结算表,字段含id、student_id、period_start、period_end、total_hours、total_amount、status、audit_user_id、audit_time、pay_time。salary_detail:结算明细表,关联结算单与工时记录,字段含settlement_id、work_log_id、amount。
命名上我全部采用小写加下划线,因为在Linux服务器上部署时,混合大小写表名容易踩到大小写敏感的问题。ThinkPHP默认会做字段转驼峰处理,但表名我仍然建议全小写。
3.2 岗位申请表的唯一索引设计
先讲application表。这个表最关键的一条约束是:同一学生不能重复申请同一岗位。从业务上当然可以在控制器里先查再判断,但高并发下会有极小概率同时通过检查,结果产生两条数据。最稳的方式是数据库层面加联合唯一索引:
ALTER TABLE `application` ADD UNIQUE KEY `uk_post_student` (`post_id`, `student_id`);加了唯一索引后,重复申请会直接抛SQL异常,控制器里捕获这个异常并转成“您已申请过该岗位”的提示,比并发下慢慢查快得多,也稳得多。
post表里还有一个容易被忽略的字段,就是hired_num(已录用人数)。它不是用来冗余好看的,而是为了在录用审核时做条件更新。每次录用一名学生,就执行:
UPDATE `post` SET `hired_num` = `hired_num` + 1 WHERE `id` = ? AND `hired_num` < `require_num`;如果影响行数为0,说明岗位已经满了,整个录用操作要回滚。这个原子更新,是并发报名的最后一道防线,比在事务里加锁好写且不容易死锁。
3.3 工时表与结算表的字段取舍
work_log表我一开始设计得过复杂,曾经给它放了多个冗余字段,比如岗位名称、部门名称、薪资标准。后来发现这些信息完全可以通过post_id关联查询获得,完全没必要冗余。真正需要冗余在工时表里的,只有hours和work_date,因为它们要参与高频统计和汇总,冗余后可以少一次JOIN。
工时表有一个隐性坑:学生可能把一条work_date的同一岗位工时记录提交两次。处理方式同样是唯一索引,不过这次不是简单的student_id + work_date,因为同一个学生确实可能在同一天的不同岗位工作。正确唯一键应是student_id + post_id + work_date:
ALTER TABLE `work_log` ADD UNIQUE KEY `uk_student_post_date` (`student_id`, `post_id`, `work_date`);这里还有一个关于金额的硬伤必须提醒:薪资和工资金额一律用DECIMAL(10,2),绝对不要用FLOAT。浮点数在累加时会产生0.1这类无法精确表示的误差,月底对账时一旦差几分钱,财务老师是会抓狂的。
salary_settlement表里我放了total_hours和total_amount两个汇总字段,并不是为了省事,而是为了冻结结算快照。工资在月初结算后,哪怕后续工时表数据被修改,结算单上的金额也不再变动,这符合财务系统的“账面不可篡改”基本要求。
4. ThinkPHP6编码实现:控制器、模型与验证器怎么配合才不臃肿
4.1 先按角色拆目录,别把所有控制器堆在一起
项目初始化时,我建议先用Composer创建ThinkPHP6项目:
composer create-project topthink/think tp-workstudy创建完成后,默认是单应用模式,所有Controller都在app/controller目录下平铺。如果学生端、单位端、管理员端各十几个控制器全放一起,文件会非常乱。我直接装上了多应用模式扩展:
composer require topthink/think-multi-app然后把app目录改成按应用拆分:
app/ ├── admin/Controller/ ├── dept/Controller/ ├── student/Controller/ ├── common/... └── middleware/这样目录一看就明白,而且各自应用可以配置独立的中间件。很多毕业设计项目就是因为没拆应用,后续加权限逻辑时只能在每个控制器里重复写判断,代码味道很快就不对了。
4.2 岗位申请接口:验证器 + 唯一索引 + 友好提示
拿“学生申请岗位”这个典型的写操作举例,完整链路应该是:参数校验 → 鉴权 → 业务判断 → 入库 → 返回结果。
控制器里的代码尽量只负责接收参数和返回结果,不要写大段业务逻辑:
<?php declare(strict_types=1); namespace app\student\controller; use app\common\BaseController; use app\student\validate\ApplyValidate; use think\exception\ValidateException; use think\facade\Db; class Post extends BaseController { public function apply() { // 1. 参数校验 try { $data = $this->request->only(['post_id', 'apply_remark']); validate(ApplyValidate::class)->check($data); } catch (ValidateException $e) { return json(['code' => 0, 'msg' => $e->getError()]); } $studentId = session('user.id'); // 2. 检查岗位状态和名额 $post = Db::name('post') ->where('id', $data['post_id']) ->where('status', 1) ->find(); if (!$post) { return json(['code' => 0, 'msg' => '该岗位不存在或未开放']); } // 3. 入库,用唯一索引兜底防重 try { Db::name('application')->insert([ 'post_id' => $data['post_id'], 'student_id' => $studentId, 'status' => 0, 'apply_remark' => $data['apply_remark'] ?? '', 'apply_time' => date('Y-m-d H:i:s'), ]); } catch (\Throwable $e) { // 通过异常字符串判断重复不优雅,也可以先查错误码 if ($e instanceof \PDOException && $e->getCode() == 23000) { return json(['code' => 0, 'msg' => '您已经申请过该岗位']); } return json(['code' => 0, 'msg' => '申请失败,请稍后重试']); } return json(['code' => 1, 'msg' => '申请成功']); } }这个接口里有几个值得说的细节。
第一,不要把session('user.id')直接当成可信数据,所有面向学生的操作都必须在登录中间件里保证session里有用户且角色包含学生。第二,状态判断放在申请前,否则已下架的岗位还能继续申请,这在业务上是漏洞。第三,唯一索引异常处理不能省,即便大多数场景下控制器先查已经有了拦截,并发死磕时还是要靠这层兜底。
4.3 录用审核:事务与原子更新怎么配合
单位端录用学生,是最需要谨慎的接口。我当初第一版写成了“先查询岗位剩余名额,再判断,再更新”,上线后第一次抢热门岗位,就出现了超录:十几名学生同时被录用,但岗位名额只有5个。
后来改成事务+条件更新双保险:
use think\facade\Db; public function accept() { $id = (int)$this->request->param('id'); $deptId = session('user.dept_id'); Db::startTrans(); try { // 锁定申请记录并确认属于本单位 $application = Db::name('application') ->alias('a') ->join('post p', 'a.post_id = p.id') ->where('a.id', $id) ->where('p.dept_id', $deptId) ->lock(true) ->find(); if (!$application || $application['status'] != 0) { throw new \Exception('申请记录不存在或已被处理'); } // 岗位名额条件更新 $updateNum = Db::name('post') ->where('id', $application['post_id']) ->where('hired_num', '<', Db::raw('require_num')) ->inc('hired_num') ->update(); if (!$updateNum) { throw new \Exception('岗位名额已满'); } // 更新申请状态 Db::name('application') ->where('id', $id) ->update([ 'status' => 1, 'review_user_id' => session('user.id'), 'review_time' => date('Y-m-d H:i:s'), 'review_remark' => '录用', ]); Db::commit(); return json(['code' => 1, 'msg' => '录用成功']); } catch (\Throwable $e) { Db::rollback(); return json(['code' => 0, 'msg' => $e->getMessage()]); } }lock(true)加的是行锁,where('hired_num', '<', Db::raw('require_num'))条件更新则是最终防线。两者结合后,即便两个管理员同时操作同一个岗位,也只会有一个人成功。
4.4 模型关联与列表查询优化
ThinkPHP6中可以用模型关联把查询简化。例如岗位列表要展示部门名称和已录用人头数:
class Post extends Model { public function dept() { return $this->belongsTo(Dept::class, 'dept_id', 'id'); } public function applications() { return $this->hasMany(Application::class, 'post_id', 'id'); } }但在真正的高频列表接口里,我很少在循环中调用模型关联。更稳妥的做法是先用with预加载,再配合hidden隐藏多余字段:
$list = Post::with(['dept']) ->where('status', 1) ->where('expire_time', '>', time()) ->order('publish_time', 'desc') ->paginate(10);如果你习惯用查询构造器,也要注意别写出N+1查询。最简单的检查方法是打开app_debug,页面右下角的SQL日志里如果看到同一个表不断重复查询,就该考虑with或JOIN了。
5. ThinkPHP路由地址跳转配置的坑:从伪静态到后台单入口
5.1 路由定义方式与分组思路
ThinkPHP6的路由默认在route/app.php中定义。多应用模式下,我习惯按照应用名做分组,方便加中间件:
use think\facade\Route; Route::group('student', function () { Route::get('post/list', 'student.Post/index'); Route::post('post/apply', 'student.Post/apply'); Route::get('worklog/index', 'student.WorkLog/index'); Route::post('worklog/store', 'student.WorkLog/store'); })->middleware(\app\middleware\StudentAuth::class); Route::group('dept', function () { Route::get('post/create', 'dept.Post/create'); Route::post('post/save', 'dept.Post/save'); Route::post('application/accept', 'dept.Application/accept'); Route::post('worklog/confirm', 'dept.WorkLog/confirm'); })->middleware(\app\middleware\DeptAuth::class);这样写的好处是,路由规则一眼能看出哪些动作需要学生登录、哪些需要单位权限。如果你不改路由,ThinkPHP默认是控制器/方法的自动匹配,也能跑,但配上自定义路由后地址会干净不少,也能在后续做权限时统一挂中间件。
5.2 地址跳转配置:登录后按角色跳转的正确姿势
“thinkphp route 地址跳转配置”是这个项目里被问到最多的问题之一。很多人写完登录接口后,直接写return redirect('/student/index');,结果换一套部署目录就飞了。
正确的做法是给需要跳转的路由起别名,然后用url()生成完整地址:
use think\facade\Route; Route::get('login', 'auth.Login/index')->name('login'); Route::get('student/index', 'student.Index/index')->name('student.index'); Route::get('dept/index', 'dept.Index/index')->name('dept.index'); Route::get('admin/index', 'admin.Index/index')->name('admin.index');登录控制器里按角色跳转:
public function login() { // ...校验用户名密码... $roleList = explode(',', $user['role']); if (session('user.current_role') == 'dept') { return redirect(url('dept.index')); } if (session('user.current_role') == 'admin') { return redirect(url('admin.index')); } return redirect(url('student.index')); }使用url('别名')的好处是,即使你之后调整了完整路径,只要路由别名不变,跳转地址就自动更新,不会出现“部署后地址多了个index.php导致404”的尴尬问题。
5.3 伪静态配置与默认路由的坑
ThinkPHP默认入口是public/index.php,URL形如/index.php/student/post/list。为了好看,大家都会配伪静态隐藏index.php。
Nginx下的典型配置:
server { listen 80; server_name workstudy.example.com; root /www/wwwroot/workstudy/public; index index.php index.html; location / { if (!-e $request_filename) { rewrite ^(.*)$ /index.php?s=$1 last; } } location ~ \.php$ { fastcgi_pass 127.0.0.1:9000; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; } }Apache则用项目自带的.htaccess即可。
这里有个常见的坑:开启伪静态后,如果你在路由里配置了Route::get('login', ...),但实际URL可能是/login.html,这就涉及URL后缀。ThinkPHP配置文件位于config/route.php,默认url_html_suffix为空,不建议为了追求.html后缀把全局后缀改成固定值,因为一个项目里总有不需要后缀的接口地址。我最后的做法是保持默认,只做伪静态去掉index.php,后缀问题不再纠结。
5.4 路由缓存导致“改了不生效”
这个坑必须单独拿出来说。ThinkPHP6在生产环境开启路由缓存后,修改route/app.php不会立刻生效,甚至会出现路由404。执行命令是:
php think route:cache如果改了路由不生效,先别怀疑代码,执行一下:
php think route:clear这个命令会清掉runtime/route.php下的缓存文件。上线发布流程里,我经常在看板写提醒:更新路由定义后必须route:clear,否则就是“我明明改了为什么没反应”的灵异事件。
6. 权限控制与学生端/用工单位端的双角色切换实现
6.1 基于中间件的登录与权限校验
我先自己做了一个轻量中间件,而不是一上来就引入think-auth或RBAC扩展,因为勤工助学系统的权限层级并不复杂,核心就是“角色+数据范围”。
学生应用下的中间件:
<?php declare(strict_types=1); namespace app\middleware; use think\Request; class StudentAuth { public function handle(Request $request, \Closure $next) { $user = session('user'); if (!$user) { return redirect((string)url('login')); } $roles = explode(',', $user['role']); if (!in_array('student', $roles)) { return json(['code' => 0, 'msg' => '无权限访问']); } // 本次会话当前角色如果是单位或管理员,则跳回对应首页 if (session('current_role') !== 'student') { return redirect((string)url(session('current_role') . '.index')); } return $next($request); } }这里面current_role是比user.role更重要的一层状态。因为有的用户确实同时具备学生和单位联系人两种身份,登录后不强制指定当前身份的话,中间件不知道该按哪个角色给菜单权限。
6.2 双角色切换:登录后身份选择与不丢业务状态
我遇到的真实情况是一位辅导员,他既是某个勤工助学岗位的用工单位联系人,同时也是继续教育学院的在读学生。他需要既能发布岗位、确认工时,也能像普通学生一样报名其他勤工助学岗位。
所以我设计了一个“当前角色”的会话变量:
public function switchRole() { $role = $this->request->param('role'); $allowed = explode(',', session('user.role')); if (!in_array($role, $allowed)) { return json(['code' => 0, 'msg' => '无权切换到此角色']); } session('current_role', $role); // 根据角色跳转不同首页 $menuMap = [ 'student' => 'student.index', 'dept' => 'dept.index', 'admin' => 'admin.index', ]; return redirect((string)url($menuMap[$role])); }前端只需要在用户头像下拉菜单里显示“切换到学生端”“切换到单位端”两个入口。登录时默认取第一个角色作为current_role,也可以提供角色选择页。
这个设计的核心是:不要在登录时把角色定死,而是把登录和理解业务动作分开。user.role是“能力集”,current_role是“当前操作上下文”,这样切换身份时,不会把已经录入的申请、待办等业务状态弄丢。
6.3 数据范围隔离才是权限的重头戏
很多权限系统只解决了“能不能进这个页面”,但没解决“能不能看这些数据”。勤工助学系统里,单位端老师绝对不能看到其他部门的岗位申请,否则会引发严重的数据泄露。
数据范围隔离我一般在查询层加统一条件。比如单位端岗位列表:
$list = Db::name('post') ->where('dept_id', session('user.dept_id')) ->where('status', '<>', 99) ->select();dept_id在用户登录时从dept表查出并放入session,所有单位端业务查询都必须带这个条件。我在中间件里还做了一个防御:如果session里没有dept_id,直接拒绝访问单位端所有接口。
这个做法虽然朴素,但比单纯依赖控制器里“逐个写条件”可靠得多。只要某个查询漏写了dept_id,就会越权,建议在代码评审时把每一个单位端查询方法都过一遍。
7. 上线后的真实问题盘点:并发报名、工时统计误差和文件上传细节
7.1 并发报名把系统打挂的瞬间
系统上线第三个月,遇到一次热门岗位开放:某部门招3名学生助理,发布出去的一瞬间有200多人同时点申请。第一版代码在“检查是否已申请”和“插入申请记录”之间没有唯一索引,结果出现了二十多条重复申请。学生的界面提示“申请中”,后台却能查出同一个人多条记录,手工清理就花了一上午。
后来我做的两处改动上面已经提到:一是application表加uk_post_student联合唯一索引;二是把“申请前查一次状态”改成“异常捕获兜底”。改完之后,同样的并发场景再也没有出现重复数据。
并发场景下还有一个容易忽略的问题:报名按钮前端要防重复点击。学生可能会因为页面卡顿连点三次“提交申请”,导致三个请求同时进入后端。解决方案很简单,提交按钮在发送请求后立即置灰,同时给接口设置一个极简防抖:
let submitting = false; function submitApply() { if (submitting) return; submitting = true; // 发送请求... }但这只是体验优化,真正的数据一致性还是要靠唯一索引兜底。前端防重复和后端防重复是两件事,缺一不可。
7.2 工时统计误差:从“谁记的”到“怎么改的”
工时统计误差是月度结算时最头疼的问题。常见情况有学生填错日期、填错时长、同一岗位同一天填了两次、老师月底忘了确认。
我对工时表加了对应唯一索引后,重复填报的问题解决了。但另一个问题逐渐暴露:一条已经被确认过的工时记录,如果学生发现填错了,应该允许编辑还是只能通过申诉?我的做法是:确认前允许学生自己修改,确认后只能走“工时申诉”流程,而且申诉必须由单位管理员退回后学生才能改。这个状态机其实很简单:
0待确认:学生可编辑、可删除。1已确认:学生不可编辑,单位可在结算前退回。2已退回:学生修改后重新提交,回到待确认。3已结算:不可再变,只能管理员手工纠错。
这样做的好处是,薪资结算时,只需汇总status=1的工时,不会出现“学生自己改数字导致上个月已确认金额变化”的问题。
统计SQL我放在一个模型方法里统一维护:
public static function getMonthlyHours(int $studentId, string $start, string $end): array { return self::where('student_id', $studentId) ->where('status', 1) ->where('work_date', '>=', $start) ->where('work_date', '<=', $end) ->fieldRaw('SUM(hours) AS total_hours') ->fieldRaw('COUNT(DISTINCT post_id) AS post_count') ->find() ->toArray(); }注意我用的COUNT(DISTINCT post_id),因为同一个月内一个学生可以在多个岗位工作,统计岗位数量不能直接COUNT(*)。
7.3 文件上传与附件管理的安全细节
勤工助学申请经常需要学生上传贫困证明、课表截图或申请表。我把上传功能放在公共应用里,统一用ThinkPHP6的Filesystem处理。
存储配置:
// config/filesystem.php 'disks' => [ 'public' => [ 'type' => 'local', 'root' => public_path() . 'uploads', 'url' => '/uploads', 'visibility' => 'public', ], ],上传处理时,最重要的是文件名校验和路径生成:
$file = $this->request->file('attachment'); $ext = strtolower($file->getOriginalExtension()); $allowed = ['jpg', 'jpeg', 'png', 'pdf', 'doc', 'docx']; if (!in_array($ext, $allowed)) { return json(['code' => 0, 'msg' => '不支持的文件类型']); } if ($file->getSize() > 5 * 1024 * 1024) { return json(['code' => 0, 'msg' => '文件不能超过5M']); } $newName = date('Ymd') . '/' . md5(uniqid((string)mt_rand(), true)) . '.' . $ext; $file->move(public_path() . 'uploads/' . date('Ymd'), basename($newName));上传的附件只把相对路径存进数据库,不要存原始文件名,避免路径穿越和特殊字符问题。move方法会重新生成一个随机文件名,业务表里再单独加一个字段保存原始文件名用于展示。目录按月分,避免一个目录下文件过多影响IO性能。
如果你对安全要求更高,可以把附件根目录放到runtime或public之外,再单独做一个权限校验的下载控制器。学校场景下,贫困证明属于个人敏感信息,我不建议直接放在public/uploads下公开访问。我用的是上面的方式,但生产环境会更推荐受控下载。
7.4 最后一个小提示:上线前记得清理路由缓存
这套系统最终跑起来,稳定性关键不在某个神乎其神的框架特性,而在于把边界条件都堵住。岗位申请防重复、录用名额原子更新、工时状态机明确、单位数据范围隔离、附件不公开可下载,这几件事做好了,勤工助学系统就已经超过大部分同类作业级项目的水平了。
如果你是按这篇文章复现一套,我的建议是:先把数据库表和唯一索引建对,再写核心接口,最后调路由和权限。实操过程中如果遇到“路由跳转不对”,先跑php think route:clear;遇到并发重复数据,先查有没有唯一索引;遇到金额对不上,先看有没有用浮点类型。这些坑我一个不落地踩过,希望你走得更顺。