做校园信息化的朋友大概都有这种体验:一个项目名字听起来平平无奇,点进去一看,业务流程比电商订单还绕。贫困生管理系统就是典型代表。业务口径上现在多叫“家庭经济困难学生认定与资助管理”,系统名沿用旧叫法的情况很普遍。它要干的事说穿了不复杂:把学生提交的家庭情况材料收上来,经过辅导员初审、院系评审、学校复核、名单公示这几道关卡,最后把钱发下去,全程留痕可追溯。但真做起来,你会发现难点根本不在增删改查,而在流程状态、权限隔离、隐私保护和批量核算这四件事上。这套东西适合谁看?接手校园信息化项目的前后端开发、独立接单的自由开发者、带学生做课程设计的高年级同学,以及需要把线下 Excel 流程搬到线上的业务负责人。下面我按自己实际做过的一个类似项目的顺序,从需求拆解一路讲到上线运维,把中间踩过的坑和想明白的道理尽量摊开说。
1. 先想清楚这套系统到底在解决什么问题
很多人接手这类项目,第一反应是打开 IDE 建表。我吃过这个亏。第一版做出来功能全有,交付后被业务方一句话打回:评审小组说他们要看的是“这个学生为什么被定为特别困难”,系统里只有一个下拉框,没有任何依据可查。后来返工重做,把“认定依据”作为一条主线贯穿全系统,才算过关。所以动手之前,一定要把角色、时间轴、功能边界这三件事捋明白。
1.1 四类角色的诉求差异有多大
同一个页面,四类人看到的东西完全不一样,这是这类系统最核心的特征。
- 学生:只关心三件事——我要交什么材料、什么时候截止、我评上了没有。他们对表单最敏感,字段多一个少一个直接影响填报完成率。我做过统计,把“家庭主要成员情况”从按人填改成一张可增删的表格后,提交完成率明显上升,因为原来很多人填到第三行就放弃了。
- 辅导员/班主任:带几十到几百个学生,最需要的是“批量操作”和“异常提醒”。他们要一眼看出谁没交、谁交的材料缺件、谁的家庭收入和往年申报差距过大。这个“和往年对比”的功能,是我在第二版才加的,加完之后辅导员的初审工作量下降非常明显。
- 院系评审小组:通常三到七个人,要独立打分再汇总。他们的核心诉求是“我不看别人打的分”,以及“我要能导出会议纪要”。多人独立评分这件事,如果做成串行可见,评审结果基本就失真了。
- 学校资助中心管理员:全局视角,关心名额分配、进度监控、资金台账和各类报表。他们的痛点是每年这个时候要接几十个电话问“我们院进度到哪了”,所以进度看板是刚需。
把这四类人的诉求列成一张表贴在墙上,后面每一次需求变更都回来对一遍,能省掉大量返工。
| 角色 | 高频操作 | 最怕的事 | 关键设计点 |
|---|---|---|---|
| 学生 | 填表、传材料、查结果 | 表单太长、附件传不上去 | 草稿保存、分步表单、材料清单提示 |
| 辅导员 | 批量催办、初审、退回 | 一个个点开看 | 列表页显示缺件标记、批量提醒 |
| 评审小组 | 独立打分、看汇总 | 看到别人的分数 | 评分互不可见、只读材料 |
| 管理员 | 名额分配、进度监控、导出 | 数据对不上 | 看板、台账、审计日志 |
1.2 一条时间轴决定架构分层
这类系统的业务是强周期性的,一年两次,每次集中在开学后六到八周内完成,其余时间几乎没人访问。这个特征直接决定了技术选型:不需要为高并发做过度设计,但必须为“集中爆发的那两周”做好准备,同时把平时的资源成本压到最低。
流程时间轴大致是这样:开放申请(约两周)→ 辅导员初审(约一周)→ 院系评审(约一周)→ 学校复核(约三天)→ 公示(不少于五个工作日)→ 发放与归档。每一段都有硬性截止时间,超时就会影响后续环节。
我在架构上做了三层切分:流程层负责状态流转和时限控制,数据层负责业务实体和快照,报表层负责统计导出。之所以把报表单独拆出来,是因为报表的逻辑变动最频繁——今年要看这个维度,明年要看那个维度,如果和主业务表混在一起写,改一次报表就可能影响主流程。拆开之后,报表走只读副本,怎么改都不影响主链路。
提示:公示期这类有法定或规定时长要求的环节,截止时间一定要用“工作日”计算并做成可配置项,不要硬编码成自然日。我见过一个系统把五个工作日写成了五天,碰上国庆直接少算了一半时间。
1.3 功能边界:哪些该做,哪些坚决不做
这一点特别想强调。这类系统最容易失控的地方,是需求方不断往里塞东西。我遇到过三次比较典型的扩张请求:
第一次是要求加“心理健康关怀”模块。我的处理是拒绝在系统内实现,改为在评审详情页加一个“转介”按钮,点击后生成一条待办推送给对应的学生工作老师,具体记录在另一套系统里。理由很简单:这类数据的敏感级别远高于家庭经济信息,混在一起存储,一旦出事就是全线崩盘。
第二次是要求对接教务系统自动拉取成绩。这个可以做,但要做成异步同步 + 本地快照,不能在页面上实时调用。因为教务系统在开学初本身就压力巨大,你这边一开申请,几千人同时触发查询,很容易把对方拖垮。我的做法是每天晚上跑一次批量同步,成绩只作为参考字段展示,不参与任何自动判定。
第三次是要求“自动认定困难等级”。这个坚决不做。系统的定位是辅助决策,不是替代决策。所有等级最终必须由评审小组确认,系统只提供量化参考分和异常提示。这一点在需求评审会上就要说死,否则后期出任何争议,责任都会落到系统头上。
功能上还有一个隐性的边界:不要做聊天和通知的完整实现。短信、站内信这类能力,能复用学校已有的通道就复用,自己从零做一套推送,维护成本高且容易因为通道问题漏发,而漏发通知在截止日期前是会引发集体投诉的。
2. 数据模型:从六张核心表开始搭骨架
表设计这东西,做的时候觉得是体力活,出问题的时候才知道是命门。我在第二版重构时最大的收获是:业务实体表要稳定,业务过程表要可追溯。前者比如学生档案,字段一旦定下来就尽量不动;后者比如评审记录、发放记录,一定要做成“只增不改”的流水,任何修改都用新记录覆盖。
2.1 主线表结构设计与字段取舍
六张核心表:学生档案、困难认定申请、评审记录、公示批次、异议记录、发放台账。下面是我实际用的简化版结构,字段名做了通用化处理。
-- 学生档案(只存必要信息,敏感字段单独处理) CREATE TABLE student_profile ( id BIGINT PRIMARY KEY, student_no VARCHAR(32) NOT NULL UNIQUE, -- 学号 real_name VARCHAR(64) NOT NULL, dept_id BIGINT NOT NULL, -- 院系 class_id BIGINT NOT NULL, -- 班级 grade_year SMALLINT NOT NULL, -- 年级 tutor_id BIGINT, -- 辅导员 enroll_status TINYINT DEFAULT 1, -- 在校状态 created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 困难认定申请(一次申请一条主记录,附件走子表) CREATE TABLE aid_application ( id BIGINT PRIMARY KEY, batch_id BIGINT NOT NULL, -- 所属认定批次 student_id BIGINT NOT NULL, apply_level VARCHAR(16), -- 学生自评等级 form_data JSON, -- 动态表单数据 status VARCHAR(24) NOT NULL DEFAULT 'draft', submit_at DATETIME, snapshot_hash CHAR(64), -- 提交时表单快照指纹 version INT DEFAULT 1, -- 乐观锁 UNIQUE KEY uk_batch_student (batch_id, student_id) ); -- 评审记录(只增不改,独立打分互不可见) CREATE TABLE review_record ( id BIGINT PRIMARY KEY, application_id BIGINT NOT NULL, reviewer_id BIGINT NOT NULL, round_no TINYINT NOT NULL, -- 第几轮 score_detail JSON, -- 各维度得分明细 total_score DECIMAL(6,2), suggestion VARCHAR(512), created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_app_reviewer_round (application_id, reviewer_id, round_no) );几个字段值得单独说。form_data用 JSON 存动态表单,是为了应对“每年申报口径会调整”这个现实问题——去年问的是家庭年收入,今年可能改成家庭人均月收入,如果把字段写死成列,每年都要改表结构、改代码、改报表。用 JSON 存原始填报内容,再单独建一张“字段定义表”描述每个字段的类型和校验规则,前端渲染和后台校验都从定义表读,就能做到只改配置不改代码。
snapshot_hash这个字段看着多余,其实救过我一次。有一次学生对评审结果提出异议,声称自己提交的材料和系统里显示的不一样。因为申请表在提交后仍允许在特定状态下修改,光看当前数据说不清楚。有了提交时的快照指纹,加上附件版本记录,能明确回溯到当时提交的原始内容,争议当场就解决了。
version乐观锁字段是防并发覆盖的。你想象不到有多少人会同时开着两个浏览器标签页填同一个表单,最后保存时后提交的把先提交的覆盖掉,然后来找你“数据丢了”。
2.2 用状态机管住流程,别用 if-else 堆
这是我最想强调的一条经验。第一版我用散落在各处的 if-else 判断“当前状态能不能做某个操作”,代码到第三个月就没人敢动了,因为谁也不知道某个分支下会不会漏掉一个状态。第二版我把它抽成了显式状态机,所有流转集中在一个字典里,一目了然。
# 状态流转白名单:(当前状态, 目标状态) -> 允许操作的角色集合 TRANSITIONS = { ("draft", "submitted"): {"student"}, ("submitted", "tutor_passed"): {"tutor"}, ("submitted", "tutor_rejected"): {"tutor"}, ("tutor_rejected", "submitted"): {"student"}, ("tutor_passed", "reviewing"): {"dept_admin"}, ("reviewing", "dept_passed"): {"dept_admin"}, ("dept_passed", "school_checking"):{"school_admin"}, ("school_checking", "publicizing"):{"school_admin"}, ("publicizing", "published"): {"system"}, ("published", "paying"): {"school_admin"}, ("paying", "paid"): {"school_admin"}, ("paid", "archived"): {"system"}, } def transit(app, target, actor_role, actor_id): key = (app.status, target) if key not in TRANSITIONS: raise BizError(f"状态 {app.status} 不允许流转到 {target}") if actor_role not in TRANSITIONS[key]: raise BizError("当前角色无权执行该操作") # 写审计日志后再更新,保证日志不会因为事务回滚而丢失 write_audit(app.id, app.status, target, actor_id) app.status = target两个细节。一是先写日志再改状态,并且日志用独立事务提交,保证即使主事务回滚,也能看到“有人尝试过这个操作”。二是系统自动流转也要有“操作者”,我统一记成 system,否则查问题时看到状态变了却没有任何操作记录,会很抓狂。
另外,状态机的状态数量要克制。我见过一个系统把“公示中”拆成“公示准备中、公示中、公示异议处理中、公示结束待确认”,四个状态之间互相流转,最后连开发自己都说不清什么时候该进哪个状态。我的原则是:状态只描述业务阶段,不描述页面展示。页面上的进度条可以通过状态映射出来,不需要为此增加状态。
2.3 敏感字段的存、用、传三个环节
家庭经济困难认定系统里,敏感性最高的三类数据是:身份证号、银行卡号、家庭成员收入和健康状况描述。这三类数据的处理方式必须区别对待。
身份证号需要唯一性校验和后续与学籍比对,所以不能简单哈希,我的做法是分表存储 + 应用层加密,主表只存一个引用 ID 和用于查重的哈希值。银行卡号只需要在发放时使用,平时列表页一律只显示后四位,完整值加密存储且只有发放环节的服务能解密。家庭成员收入和健康描述属于自由文本,这类内容最容易被忽视——有人会在描述栏里写满各种细节,包括病史。我的处理是在录入页就给提示,引导填写结论性描述而非细节,同时在所有导出和列表页做字段级脱敏。
注意:脱敏一定要在服务端出口做,不能靠前端隐藏。前端隐藏的字段依然在接口响应里,随便打开开发者工具就能看到。我接手过一个项目,列表页看着只显示“张*”,打开网络面板一查,完整姓名和身份证号全在 JSON 里。
传输环节,所有涉及敏感字段的接口必须走加密通道,返回体里对敏感字段做掩码,审计日志里绝对不能出现明文。日志脱敏这件事我建议在框架层统一拦截做,靠每个开发者自觉写脱敏代码,迟早会漏。
3. 核心功能落地:申请、评审、公示、发放
前面都是铺垫,这一节是真正要写代码的部分。我会把每个环节的关键实现和当时踩的坑都摆出来,能直接抄的部分尽量给完整。
3.1 动态表单,让每年的口径变化不再改代码
表单定义我设计成三层结构:字段定义、分组、校验规则。字段定义描述“这个字段叫什么、什么类型、什么选项”,分组描述“哪些字段放在一起、按什么顺序”,校验规则描述“必填、长度、正则、跨字段约束”。前端拿到这三层结构,直接渲染成多步表单。
{ "fields": [ {"key": "family_size", "label": "家庭共同生活人口数", "type": "number", "required": true, "min": 1, "max": 20}, {"key": "income_per_capita", "label": "家庭人均月收入(元)", "type": "number", "required": true, "min": 0}, {"key": "hardship_types", "label": "困难情形(可多选)", "type": "multi_select", "options": [{"v": "low_income", "t": "低保边缘"}, {"v": "illness", "t": "成员重病"}, {"v": "disaster", "t": "遭遇灾害"}, {"v": "other", "t": "其他"}]} ], "rules": [ {"type": "cross_field", "expr": "income_per_capita <= 0 && hardship_types.indexOf('other') < 0", "message": "人均收入为零时请补充困难情形说明"} ] }这样做的好处在于,业务方临时想加一个“是否受灾”的勾选项,运营人员在后台配一下就行,不用走发版流程。代价是校验逻辑变得分散,部分复杂规则还是要写代码。我的折中方案是:简单规则走配置,复杂规则走插件,插件按 key 注册到后端,配置里只引用插件名。
表单还有个实用细节:分步保存 + 草稿自动暂存。移动端填长表单,中途接个电话切出去,回来页面被回收,前面全白填——这是投诉高发区。我的做法是每次字段失焦就静默存一次草稿,只存到本地缓存和一次轻量接口,提交时才做完整校验。实测下来这一项让填报完成率提升非常明显。
3.2 评审打分模型与名额分配的取整算法
评审环节的核心是量化参考分。我设计的模型是三个维度加权,权重做成可配置:
综合参考分 = 收入维度分 × w1 + 负担维度分 × w2 + 特殊情形分 × w3 其中 w1 + w2 + w3 = 1,默认 0.4 / 0.3 / 0.3每个维度的分段阈值不写死在代码里,而是放配置表,由业务方按当年口径调整。举个例子,收入维度按人均月收入分档给分,负担维度按“家庭共同生活人口数 / 家庭主要劳动力数”的供养比给分。
举个算例说明白。A 同学家庭共同生活 5 人,主要劳动力 2 人,人均月收入落在某档位对应 60 分,供养比 2.5 落在某档位对应 70 分,无特殊情形得 0 分:
A: 60×0.4 + 70×0.3 + 0×0.3 = 24 + 21 + 0 = 45.0B 同学家庭共同生活 3 人,主要劳动力 1 人,人均收入更低对应 80 分,供养比 3.0 对应 85 分,且属于单亲家庭触发特殊情形得 100 分:
B: 80×0.4 + 85×0.3 + 100×0.3 = 32 + 25.5 + 30 = 87.5这里最容易出问题的是权重的解释权。我坚持在系统里明确标注“此分数仅为排序参考,不构成认定结论”,并且在评审页把这个分数放在折叠区,默认收起,避免评审老师直接照抄分数完事。这一点在需求沟通时要说清楚,否则业务方会误以为系统在替他们做决定。
另一个高频需求是名额分配。学校层面拿到总名额,要按院系困难生库人数比例分下去。直接四舍五入会出现总数对不上,我用的最大余数法:
某次总名额 50 个,三个院系困难生库人数分别为 1230、790、480,合计 2500。
精确值:A = 50×1230/2500 = 24.6 → 取整 24,余数 0.6 B = 50×790/2500 = 15.8 → 取整 15,余数 0.8 C = 50×480/2500 = 9.6 → 取整 9,余数 0.6 已分配 48,剩余 2 个 按余数降序:B(0.8) → A(0.6) 与 C(0.6) 并列,按基数大者优先 → A 最终:A 25,B 16,C 9,合计 50并列时的兜底规则要写清楚,不然两个院系争一个名额时没法解释。我的兜底顺序是:余数大者优先 → 基数大者优先 → 院系编码升序。写进代码注释里,谁问都能答上来。
def allocate(total, bases): """最大余数法分配名额,bases: {dept_id: 人数}""" sum_base = sum(bases.values()) quota, remainders = {}, {} for d, b in bases.items(): exact = total * b / sum_base quota[d] = int(exact) remainders[d] = exact - int(exact) left = total - sum(quota.values()) # 余数降序 -> 基数降序 -> 编码升序 order = sorted(bases, key=lambda d: (-remainders[d], -bases[d], d)) for d in order[:left]: quota[d] += 1 return quota3.3 公示与异议处理:时间窗口的坑最多
公示环节看着简单,一个列表页加一个截止时间,实际上是整个系统里最容易出事故的地方。我遇到过三个问题。
第一个是公示内容泄露隐私。最初版本直接把申请详情页的链接放到公示列表里,任何人点进去能看到完整信息。后来改成公示页只显示姓名、院系、认定等级三项,其他一律不展示,并且姓名做部分掩码。
第二个是公示期跨节假日计算错误。前面提过,必须用工作日计算,而且要有一张节假日配置表。我的做法是把工作日计算封装成一个工具函数,全系统统一调用,避免有的地方算自然日有的地方算工作日。
第三个是异议提交后没有闭环。学生提了异议,系统记了一条,然后呢?没有人处理,学生天天来问。后来我加了异议处理流程:提交后自动生成待办给对应院系的负责人,处理结果必须填写并回写,处理完毕后自动通知学生,超过设定时限未处理则升级到上一级。这条链路补上之后,相关投诉基本清零。
异议处理还要注意一件事:处理人不能是原评审人。这是个基本原则,我在权限配置里做了强制约束,同一个申请的评审人和异议处理人不能是同一个人。
3.4 发放台账与对账:钱的事情必须留痕
发放环节我的原则是:系统不做转账,只做台账。实际打款走财务系统,本系统负责生成发放清单、记录批次、登记回执、提供对账依据。这样既避免了资金安全风险,也降低了对接复杂度。
台账表需要记录:批次号、学生、金额、发放状态(待发/已发/失败/退回)、外部流水号、失败原因、操作人、操作时间。关键在于失败要能重试且不重复打款,所以每次重试生成一条新记录而不是改原记录,用“批次号 + 学生 + 序号”做幂等键。
对账时最常见的差异是“系统显示已发,学生说没收到”。这时候能救命的是外部流水号。所以对接财务系统时,哪怕对方不主动返回流水号,也要在回执文件里解析出来存进去。没有流水号,对账就只能靠人工翻银行回单,一个批次几百人,翻到怀疑人生。
提示:发放金额的字段类型一定用定点小数(DECIMAL),绝对不能用浮点。我曾经在一个历史系统里见过用 double 存金额,累计汇总后出现 0.01 的误差,虽然金额不大,但对账时每一分钱都要解释清楚,非常折磨。
4. 权限体系与数据隔离,这类系统最容易翻车的地方
权限做不好,前面所有的隐私保护都是白搭。这类系统的权限难点不在于“谁能进哪个菜单”,而在于“同一个菜单里,谁能看到哪些行”。
4.1 RBAC 只解决一半问题,数据域才是关键
角色权限(RBAC)解决的是功能级控制,比如辅导员不能点“名额分配”。但真正麻烦的是数据级控制:辅导员只能看自己班的学生,院系管理员只能看本院系,评审老师只能看分配给自己的申请,学校管理员能看全部但不能改评审记录。
我的做法是在 RBAC 之上加一层数据域过滤,在数据访问层统一注入。每个请求进来先解析出当前用户的“数据域描述”,可能是一个班级列表、一个院系 ID、或者全部,然后在查询构建时自动拼上去。
-- 辅导员视角:只能看自己带的班 SELECT a.id, s.real_name, a.apply_level, a.status FROM aid_application a JOIN student_profile s ON s.id = a.student_id WHERE a.batch_id = ? AND s.class_id IN (SELECT class_id FROM tutor_class WHERE tutor_id = ?) AND a.status <> 'draft';这里有个坑我必须提醒:数据域过滤一定要在 SQL 层做,不能在应用层查出全部再过滤。应用层过滤的写法在数据量小的时候看不出问题,一旦某个院系有几千条记录,全查出来再筛,接口直接超时。而且应用层过滤一旦某个接口忘了加,就是越权。
评审阶段的隔离更严格。评审老师只能看到分配给自己的申请,且看不到其他评审人的分数。实现上,评分明细表按 reviewer_id 查询,汇总分数只在全部评审完成后才计算生成,且汇总结果不对评审老师开放,只对院系管理员开放。
| 角色 | 功能权限 | 数据域 | 敏感字段 |
|---|---|---|---|
| 学生 | 填报、撤回、查结果、提异议 | 仅本人 | 仅本人完整信息 |
| 辅导员 | 初审、退回、批量提醒 | 所带班级 | 所带班级完整信息 |
| 评审老师 | 打分、写意见 | 分配给自己且未完成的申请 | 材料全文,无联系方式 |
| 院系管理员 | 名额查看、汇总、上报 | 本院系 | 本院系脱敏信息 |
| 学校管理员 | 全局配置、复核、导出 | 全校 | 按需申请,操作留痕 |
再看这张表会发现一个细节:评审老师没有联系方式权限。这是故意的——评审过程要尽量避免评审人与学生产生直接联系,减少不必要的干扰。这类细节不写进需求文档,做的时候很容易顺手就把完整信息给出了。
4.2 审计日志怎么记才有用
日志这东西,做的时候嫌麻烦,查问题的时候恨不得它记得越细越好。我总结的经验是:记“谁在什么时候对哪条数据做了什么,结果如何”,不记业务细节。
必记的几类操作:登录登出(含失败)、状态流转、敏感字段查看、数据导出、权限变更、批量操作。每类都要带请求 ID,方便串起来看一次操作的完整链路。
CREATE TABLE audit_log ( id BIGINT PRIMARY KEY, trace_id CHAR(32) NOT NULL, -- 请求链路 ID actor_id BIGINT, actor_role VARCHAR(24), action VARCHAR(48) NOT NULL, -- view_sensitive / export / transit ... target_type VARCHAR(32), target_id BIGINT, detail JSON, -- 不含明文敏感值 result TINYINT, -- 1 成功 0 失败 ip VARCHAR(48), created_at DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_actor_time (actor_id, created_at), KEY idx_target (target_type, target_id) );有个实用技巧:敏感字段查看要单独记一条。比如某个管理员点开了某个学生的完整银行卡号,这必须留痕。我把这个能力封装成一个专用接口,任何需要展示完整敏感值的地方都走它,接口内部先写日志再返回。这样既保证覆盖,又方便统计“谁在频繁查看敏感信息”。
日志的查询界面也要做,而且要做得让业务方能自己查。我做过一个统计:80% 的日志查询需求是“某条记录被谁改过”,把这个查询做成页面上的一个“变更历史”按钮,比做一个大而全的日志中心实用得多。
4.3 导出与截图:防不住也要留痕
数据导出是这类系统的刚需——评审要打印材料,上报要交 Excel,归档要存备份。但导出也是最容易泄露的环节。
我的做法分四层。第一层是导出权限单独控制,不是所有管理员都能导,且导出范围受数据域约束。第二层是导出行数阈值告警,超过设定条数需要二次确认,并自动通知上级。第三层是导出文件加水印,水印内容包含操作人、时间、用途说明,打在页眉页脚和表格背景里。第四层是导出记录可查,谁什么时候导了哪些数据,一查就知道。
截图防不住,这是实话。所以我在导出文件的每一页底部加了一行说明,提示该文件含敏感信息、仅限特定用途、禁止转发。技术上做不到的,就用管理手段补。
注意:导出功能一定要做成异步任务,别做成同步下载。几千条记录加上附件打包,同步接口必然超时,用户体验差,还容易在超时重试时产生重复下载记录。
5. 常见问题与排查技巧实录
这一节全是我在实际运行中遇到的问题,按出现频率排的序,做的时候可以直接当检查清单用。
5.1 高频问题速查表
| 现象 | 最可能的原因 | 排查方向 | 处理方式 |
|---|---|---|---|
| 学生说提交了但列表里没有 | 提交时附件还在上传,事务未完成 | 查附件的上传完成时间与主记录创建时间 | 提交按钮加 loading 锁定,附件全部成功后才允许提交 |
| 重复提交产生两条申请 | 前端未做幂等,网络抖动重试 | 查 uk_batch_student 唯一键是否生效 | 唯一键兜底 + 提交接口幂等 token |
| 辅导员看不到自己班的学生 | 数据域配置里班级未关联 | 查 tutor_class 关联表 | 增加“数据域自检”页面,管理员可自查 |
| 评审分数算出来和别人不一致 | 权重配置在评审过程中被改过 | 查配置变更日志与记录创建时间 | 配置项按批次冻结,切换批次才生效 |
| 公示页显示的人数比实际少 | 状态过滤条件写错,把某状态漏了 | 对照状态机枚举逐个验证 | 状态过滤用白名单而非黑名单 |
| 导出 Excel 打开是乱码 | 未加 BOM 头或编码不匹配 | 检查响应头与文件编码 | 统一 UTF-8 with BOM |
| 发放对账差几分钱 | 金额字段用了浮点类型 | 查表结构字段类型 | 改 DECIMAL,历史数据做一次清洗 |
| 高峰期页面转圈很久 | 列表页 N+1 查询 / 缺索引 | 慢查询日志 + 执行计划 | 加联合索引,列表页改分页查询 |
5.2 几个踩过的坑
坑一:附件存储用了本地磁盘。一开始图省事,附件直接存应用服务器本地目录。结果做集群部署时,学生上传到节点 A,辅导员从节点 B 访问,附件 404。后来改成对象存储,数据库只存对象 key,问题解决。这个坑很经典,凡是多实例部署,本地文件系统就不能用来存业务数据。
坑二:状态改了但列表没刷新。辅导员初审通过后,列表页还显示“待初审”,需要手动刷新。原因是前端拿到操作成功响应后只更新了当前行,没有重新拉列表。改成操作成功后局部刷新 + 乐观更新,体验立刻不一样。这类问题在小规模测试时几乎发现不了,因为测试人员往往会手动刷新。
坑三:批量操作没有分批。管理员勾选全院几百人点“批量提醒”,接口一次性循环发几百条消息,直接把消息通道打满,其他通知全部延迟。后来改成按批 50 条处理,异步执行,页面显示进度。任何涉及“批量”的操作,都要先想清楚上限和分批策略。
坑四:时间字段存了字符串。早期为了省事,把提交时间存成'2024-09-01 10:00'这样的字符串。做时间范围查询和时区转换时简直是灾难,between查询的结果时对时错。全部改成标准时间类型后,这类 bug 一次性消失。这个教训很基础,但真的很多人犯。
坑五:没做数据归档,表越来越大。系统跑了几年之后,历史批次的数据一直留在主表里,列表查询越来越慢。后来加了归档策略:超过两个学年的批次数据整体迁移到归档表,主表只保留近两年,查询时按需联查。归档不是删数据,随时能查回来,但主表的压力下降非常明显。
6. 上线节奏与运维经验
这类系统的上线节奏和普通业务系统很不一样,因为它有窗口期。错过了开学那两周,就得等半年。
6.1 分批灰度与压测
我的策略是先小范围跑通一个完整周期,再全校推开。选一个规模中等、配合度高的院系先试运行,把申请、初审、评审、公示全流程走一遍。这一步的价值在于暴露流程问题,而不是技术问题——技术问题在测试环境能发现,流程问题只有真实用户走一遍才会冒出来。
压测方面,重点不是峰值 QPS,而是截止日当天的集中提交。我的经验是预估申请人数的 30% 会在截止前 24 小时内提交,据此反推需要的并发能力。压测时特别要关注附件上传这个环节,它比表单提交重得多。
# 用简单的并发脚本模拟截止日提交,重点看错误率和 P99 耗时 hey -n 2000 -c 100 -m POST \ -H "Content-Type: application/json" \ -H "Authorization: Bearer <token>" \ -d '{"batch_id":1,"form_data":{...}}' \ https://example.internal/api/applications看结果时不要只盯平均值。平均值很漂亮但 P99 是 8 秒,意味着每 100 个人里有 1 个人等到怀疑人生,而截止日那天大家都在赶时间,体验会被放大。我的验收标准是 P99 控制在 1.5 秒以内,错误率低于 0.1%。
6.2 学期制系统的运维节奏
运行两年之后,我总结出这类系统的运维节奏大致是这样的:
- 开学前两周:检查批次配置、表单定义、名额参数是否就绪,提前做一次全链路演练。这一步千万别省,我遇过配置漏了某个院系,导致该院系学生打开页面被告知“本批次未开放”。
- 申请期:每天看一次提交量和失败率,重点关注附件上传失败。这个阶段最需要的是客服支持,把常见问题的自助说明放在显眼位置,能减少大量咨询。
- 评审期:关注多人评分的完成率,对迟迟未评的老师做成提醒。这个阶段不要动代码,任何变更都可能影响正在进行的评审。
- 公示期:这是最敏感的时段,确保页面稳定、异议入口通畅、处理及时。任何页面异常都可能被解读为“系统有猫腻”,所以这个阶段要安排人值守。
- 发放与归档:做完对账后,把本批次数据整体归档,生成完整的归档包,包含申请数据、评审记录、公示记录、发放台账,作为历史凭证保存。
还有一条运维经验:每次批次结束后做一次复盘,把这一轮的业务口径变化记下来。这些变化最终都要沉淀成配置项的调整,第二年直接复用,比翻聊天记录靠谱得多。
关于扩展方向,我个人觉得比较务实的是把“往年数据对比”做得再深一点——同一个人连续两年的申报数据放在一起看,异常情况一眼就能发现;同时把整个认定过程的时长统计出来,看看卡在哪个环节,这对优化流程很有帮助。不过这些都要等基础流程稳定运行一两轮之后再考虑。做这类系统,慢就是快——流程和数据结构的每一次返工,成本都比多做两个功能高得多。