做学生资助这一块系统的开发,最容易被低估的就是"它看起来只是个表单系统"。我第一次接到这类需求的时候也这么想——无非是学生填表、老师审核、导出名单,几个增删改查的事。真正做进去才发现,难点压根不在功能列表上:认定流程有明确的阶段划分,每个阶段能看数据的人不一样,材料涉及身份证、家庭收入、银行卡这类高度敏感的信息,一份名单在公示期内不允许有任何变动,学期中途还会有学生转专业、休学、复学导致归属关系变化。这些东西在需求文档里通常只有一句话,落到代码里全是坑。
这篇内容围绕一个家庭经济困难学生认定与资助管理的网站项目展开,讲的是从数据模型、权限控制、材料存储、审核工作流到统计导出这一整条链路上,我在实际开发中踩过的具体问题和给出的处理方式。适合正在做类似管理系统的后端、全栈开发者看,也适合刚接手这类项目、想知道"哪些地方别硬来"的同学。代码示例以常见的 Java + Spring Boot + MySQL 组合为主,思路换成 Node.js、Python 一样成立。全文不涉及任何具体单位,案例统一用"某高校""某院系"代称。
1. 先把需求边界划清楚,别一上来就建表
我见过太多这类项目死在第一步:拿到一句"做个贫困生管理系统",直接开工建了张几十个字段的大宽表,三个月后流程一改,整张表推倒重来。这类系统的需求有几个天然特征——角色多、状态多、周期性强、敏感数据多,动手前必须先把这四件事拆明白。
1.1 四类角色与他们在系统里的真实操作路径
别看需求里只写"学生和管理员",实际跑起来至少四类人:学生本人、班级评议小组(通常是班委或班主任)、院系经办人、校级管理员。这四类人的操作路径差异极大,不是简单的权限开关能覆盖的。
学生侧的核心动作是"填一学期一次的表 + 传材料 + 看进度 + 被退回后改"。听起来简单,但真实情况是:提交前一稿改十几次、材料拍歪了要重传、截止那天晚上集中提交导致并发上升、有人提交完才发现填错了想撤回。所以学生侧必须有草稿态、可撤回窗口、材料预览。
班级评议小组的核心动作是"批量看本班申请 + 打评议分 + 写意见"。他们往往在会议室里一次性过几十份,最需要的是列表页的批量操作和键盘可操作的审核界面,而不是一个个点进去看详情。
院系经办人关心的是"本院的汇总、退回、催办、上报",他们对单份材料的关注度低于对整体进度的关注度,所以数据看板和筛选器的权重远高于详情页。
校级管理员负责"全校口径的统计、公示名单生成、历史数据追溯",他们几乎不看单条申请,只看汇总表和差异对比。
把这四条路径画出来你会发现,同一个"申请单"实体在四个人眼里的字段集完全不同。这就是为什么后面权限必须做到字段级,而不是菜单级。
1.2 为什么"一张表搞定"必然在第二个月崩掉
普通的业务表单,一张主表加几个字典表就够了。但认定流程的表单有个特点:字段会随学期变。今年统计家庭人口数,明年可能要拆成劳动力人口和赡养人口;今年问家庭年收入,明年可能要求分项填工资性收入、经营性收入、转移性收入。如果一开始把所有字段拍平进主表,每次表单调整就是一次加字段 + 改代码 + 改导出的连锁反应。
我的做法是把"申请单"和"申请内容"拆开:主表只留流程相关的字段(学生ID、学期ID、状态、提交时间、当前处理人、版本号),业务内容以 JSON 形式存在副表里,同时配一张"表单模板表",记录每个学期用哪套字段定义、每个字段的类型、是否必填、是否敏感。
CREATE TABLE apply_form ( id BIGINT PRIMARY KEY AUTO_INCREMENT, student_id BIGINT NOT NULL, term_id BIGINT NOT NULL, template_id BIGINT NOT NULL, status TINYINT NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 1, current_node VARCHAR(32) NULL, submit_time DATETIME NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_stu_term (student_id, term_id) ); CREATE TABLE apply_content ( apply_id BIGINT PRIMARY KEY, payload JSON NOT NULL, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP );这么拆的好处很直接:表单字段变化只影响模板和 JSON 解析层,流程引擎、权限过滤、统计口径全部不受影响。代价是统计查询麻烦一点,需要用 MySQL 的 JSON 函数或者提前把关键指标抽到一张指标表里。我的选择是后者——把"家庭人口数""家庭年收入区间""是否低保"这几个高频统计项单独冗余成列,其余留在 JSON 里。
提示:JSON 字段不要一开始就全量塞。凡是需要在列表页展示、需要参与筛选、需要出现在统计报表里的字段,一律抽成正式列并建索引。留在 JSON 里的只放长文本、补充说明、动态扩展项。
1.3 敏感字段清单必须在建表前定死
这一步很多人跳过,后面补起来非常痛苦。凡是涉及身份证号、银行卡号、家庭住址、家庭成员姓名、收入金额、病历或残疾信息的字段,在建表时就要打标记。我在项目里用一张元数据表维护这个清单,同时在实体类的字段上加自定义注解,序列化时自动走脱敏逻辑。
public class ApplyVO { @Sensitive(type = SensitiveType.ID_CARD) private String idCard; @Sensitive(type = SensitiveType.BANK_CARD) private String bankCard; @Sensitive(type = SensitiveType.AMOUNT) private BigDecimal familyIncome; }脱敏规则分两档:列表页和导出走强脱敏(身份证保留前3后2,银行卡只留后4位,金额转成区间),详情页对有权查看的角色走弱脱敏(完整显示但记日志)。这个区分很关键——如果详情页也强脱敏,经办人没法核对材料,会绕过系统去看纸质件,系统就白做了。
2. 把"认定"这件事拆成可追溯的状态机
流程类系统写多了会发现一个规律:凡是只改主表 status 字段、不记录流转历史的实现,上线三个月内必定被要求加一份"这个学生为什么被退回、谁操作的、什么时候"的追溯功能,然后就得从头补。
2.1 状态定义别贪多,六个节点够用
我最终定下来的状态是这样的:
| 状态码 | 名称 | 可操作角色 | 是否可编辑内容 |
|---|---|---|---|
| 0 | 草稿 | 学生 | 是 |
| 1 | 已提交待评议 | 学生(仅撤回)、评议组 | 否 |
| 2 | 评议完成待复核 | 院系经办人 | 否 |
| 3 | 复核完成待公示 | 校级管理员 | 否 |
| 4 | 公示中 | 校级管理员 | 否 |
| 5 | 已归档 | - | 否 |
| 9 | 已退回 | 学生(修改后重提) | 是 |
一开始我设计了十一个状态,加了"评议中""复核中""待补材料"这类中间态,结果前端画流程图要画两屏,用户完全记不住自己在哪一步。后来砍到七个,把"评议中"这种事用 current_node 字段表达,问题立刻清爽了。
状态码我刻意留了间隔(0到5,然后跳到9表示退回),后面加新状态时有空位,不用改存量数据。
2.2 状态流转必须有独立事件表
主表上的 status 只是"当前值",事件表才是"怎么来的"。这张表我一般这么设计:
CREATE TABLE apply_event ( id BIGINT PRIMARY KEY AUTO_INCREMENT, apply_id BIGINT NOT NULL, from_status TINYINT NOT NULL, to_status TINYINT NOT NULL, action VARCHAR(32) NOT NULL, operator_id BIGINT NOT NULL, operator_role VARCHAR(32) NOT NULL, remark VARCHAR(500) NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), KEY idx_apply (apply_id, created_at) );几个细节值得说:
时间精度用毫秒。同一秒内多次操作在批量审核场景下很常见,秒级精度排序会乱。
remark 在退回和否决时强制非空。这一点是踩坑换来的——早期允许空备注,结果学生收到退回通知却不知道该改什么,电话打爆了经办人。加了非空校验之后,退回通知的二次咨询量下降非常明显。
operator_role 单独存一份,不通过 join 用户表反查。因为角色会变,半年后某人调岗了,你还原着历史记录去 join,看到的角色是错的,追溯就失真了。
2.3 退回重提的版本管理,别用覆盖写
学生被退回后修改再提交,如果把内容直接覆盖掉,评议组就看不到"他改了什么",遇到争议没法说清。我的做法是每次提交生成一个新版本,apply_content 里以 version 为键存多份:
CREATE TABLE apply_content_version ( apply_id BIGINT NOT NULL, version INT NOT NULL, payload JSON NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (apply_id, version) );每次提交时 version + 1,同时把版本号写进事件表的 remark 里。详情页给经办人加一个"对比上一版"的按钮,把两个 JSON 展开做字段级 diff,高亮变化项。这个功能实现成本不高(前端一个 diff 库就够),但在争议处理时非常好用——有据可查比口头解释管用一百倍。
注意:版本表会随时间膨胀。一个学生一学期改十次很正常,全校几千人就是几万条 JSON。建议加个定时任务,已归档超过一个学年的申请,只保留最终版和第一版,中间版本归档到冷存储。
3. 权限做到行级还不够,得做到字段级
这类系统最怕的不是功能少,是数据泄露。一个院系的经办人看到别的院系的学生家庭收入,或者一个学生看到同班同学的材料,这种事出一次,整个系统的信任就没了。
3.1 角色-资源-动作三要素的落法
RBAC 本身没什么好讲的,关键是怎么让它和业务数据范围结合。我的表结构是这样的:
CREATE TABLE role ( id BIGINT PRIMARY KEY AUTO_INCREMENT, code VARCHAR(32) NOT NULL UNIQUE, name VARCHAR(64) NOT NULL ); CREATE TABLE permission ( id BIGINT PRIMARY KEY AUTO_INCREMENT, resource VARCHAR(64) NOT NULL, -- apply / material / report action VARCHAR(32) NOT NULL, -- read / write / audit / export UNIQUE KEY uk_res_act (resource, action) ); CREATE TABLE role_permission ( role_id BIGINT NOT NULL, permission_id BIGINT NOT NULL, data_scope TINYINT NOT NULL, -- 1本人 2班级 3院系 4全校 PRIMARY KEY (role_id, permission_id) );注意 data_scope 是挂在"角色-权限"关联上的,不是挂在角色上。这个设计很关键:同一个"院系经办人"角色,对申请单有 READ 权限且范围是本院系,对统计报表有 EXPORT 权限但范围也是本院系,对系统配置则完全没权限。如果把范围挂在角色上,就变成"这个人只能看本院系的一切",粒度太粗,遇到"辅导员能看本班学生但不能看本班学生的银行卡号"这种需求就抓瞎了。
3.2 数据范围过滤的三种实现,我为什么选拦截器
行级过滤有三种常见做法,各有代价:
第一种是在每个 Service 方法里手动拼条件,比如给 SQL 加AND dept_id = #{currentDeptId}。写起来最直观,但漏一处就是漏洞,而且代码里到处是权限判断,可读性极差。
第二种是数据库层的行级安全策略(部分数据库支持)。最安全,但迁移和调试都很麻烦,团队里新人根本看不懂为什么查出来少了一半数据。
第三种是 ORM 层拦截,把当前用户的上下文放在 ThreadLocal 里,在 SQL 生成阶段统一注入过滤条件。我选的是这个,用 MyBatis 的拦截器实现,思路是在解析 SQL 时判断涉及的表名,命中受控表就追加条件。
@Intercepts({ @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}) }) public class DataScopeInterceptor implements Interceptor { @Override public Object intercept(Invocation invocation) throws Throwable { MappedStatement ms = (MappedStatement) invocation.getArgs()[0]; String sqlId = ms.getId(); if (!ScopeTableRegistry.match(sqlId)) { return invocation.proceed(); } UserContext ctx = UserContextHolder.get(); // 交由 MyBatis-Plus 的 DataPermissionInterceptor 处理 return invocation.proceed(); } }说实话,最省事的是直接用成熟框架自带的数据权限插件(MyBatis-Plus 的 DataPermissionInterceptor 就能干这事),自己写拦截器主要在需要处理复杂 join 和多表过滤时才值得。判断标准很简单:如果你的受控表少于五张,用框架;超过五张且涉及多表关联,自己写一层更可控。
3.3 字段级可见性:一个注解解决问题
行级解决"能看到哪些行",字段级解决"这一行里哪些列能看"。我在返回值序列化阶段做处理,用 Jackson 的定制序列化器识别 @Sensitive 注解,结合当前用户的角色决定脱敏强度。
public class SensitiveSerializer extends JsonSerializer<Object> implements ContextualSerializer { private SensitiveType type; @Override public void serialize(Object value, JsonGenerator gen, SerializerProvider sp) throws IOException { int level = SensitiveMasker.currentLevel(type); gen.writeString(SensitiveMasker.mask(type, String.valueOf(value), level)); } @Override public JsonSerializer<?> createContextual(SerializerProvider sp, BeanProperty property) { Sensitive anno = property.getAnnotation(Sensitive.class); if (anno == null) return sp.findNullValueSerializer(property); SensitiveSerializer s = new SensitiveSerializer(); s.type = anno.type(); return s; } }这段代码有个坑要提醒:如果你同时用了缓存(比如把 VO 序列化成 JSON 存 Redis),脱敏结果会被缓存下来,不同角色读到同一份脱敏数据。解决办法是把角色等级拼进缓存 key,或者干脆只缓存原始数据、在出口处脱敏。我选后者,简单且不会出错。
3.4 越权自查的几条硬线
上线前我通常会跑一遍这几条检查,每次都能揪出问题:
- 详情接口是否只凭 ID 就能查?必须校验"当前用户对该 ID 是否有范围权限",不能只靠前端不显示入口。
- 附件下载链接是否可直接分享?签名 URL 必须带有效期和用户绑定。
- 导出接口是否复用了列表的过滤逻辑?很多系统的导出是单独写的 SQL,忘了加范围条件,这是最典型的数据泄露源。
- 批量操作接口是否逐条校验?批量审核时如果只校验第一条,后面几十条就全放过去了。
4. 材料上传这块,隐私问题比技术问题多
学生上传的材料通常包括身份证照片、户口本、低保证明、医疗单据。这些东西一旦泄露,后果比普通业务数据严重得多。我在这个模块上花的时间比预期多一倍。
4.1 文件绝不能落在 Web 目录下
这是最基础也最常被忽略的一点。很多教程教你把文件存到static/upload下然后直接拼路径返回,开发阶段跑得飞快,上线就是裸奔——任何人猜到文件名就能下载,搜索引擎爬到就是公开泄露。
正确做法是文件放对象存储或独立的文件服务器,权限设为私有,访问时由后端签发临时 URL:
public String buildTempUrl(String objectKey, Long currentUserId) { if (!materialAuthService.canRead(objectKey, currentUserId)) { throw new ForbiddenException("无权访问该材料"); } return storageClient.presignGet(objectKey, Duration.ofMinutes(5)); }有效期给 5 分钟足够。有人会问"学生看自己的材料要每次都请求吗",是的,每次都请求,因为你需要那一次鉴权。前端的图片标签每次重新拉签名,成本可以忽略。
文件命名不要用学号、姓名、身份证号。我用的是"业务ID + 随机UUID + 扩展名",原始文件名存在数据库里单独一列,只在下载时作为 Content-Disposition 返回。这样即使对象存储的 key 泄露,也拿不到任何身份信息。
4.2 图片压缩和水印,两个都不能省
学生用手机拍的材料动辄 5MB 到 10MB,一个学期几千份,存储和流量都是钱。我在上传后异步做一次压缩:长边压到 2048px,质量 80%,一般能压到 300KB 以内,肉眼几乎看不出差异。
水印这事一开始我没做,后来发现有学生把材料图片发给同学"参考",图片就流出去了。加了水印之后情况好转。水印内容用"仅用于XX系统审核 + 时间戳",斜向铺满,透明度 15% 左右,不影响阅读但截图后能被追溯。
public void watermark(BufferedImage src, String text, OutputStream out) { Graphics2D g = src.createGraphics(); g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); g.setFont(new Font("SansSerif", Font.PLAIN, 24)); g.setColor(new Color(120, 120, 120, 38)); g.rotate(Math.toRadians(-25), src.getWidth() / 2.0, src.getHeight() / 2.0); for (int y = -src.getHeight(); y < src.getHeight() * 2; y += 120) { for (int x = -src.getWidth(); x < src.getWidth() * 2; x += 260) { g.drawString(text, x, y); } } g.dispose(); ImageIO.write(src, "jpg", out); }注意:压缩和水印一定走异步。同步处理会让上传接口在弱网环境下超时,学生反复重试,产生一堆重复文件。我的做法是上传接口只做"存原件 + 写任务表",立刻返回,后台线程池处理衍生文件,前端轮询状态。
4.3 类型校验要防两头
文件类型校验有两层:前端 accept 属性只是体验优化,后端必须重新判断。判断不能只看扩展名,要看文件头魔数。我用 Tika 或者自己读前几个字节都行:
public boolean isRealImage(InputStream in) throws IOException { byte[] head = new byte[8]; if (in.read(head) != 8) return false; return (head[0] & 0xFF) == 0xFF && (head[1] & 0xFF) == 0xD8 // jpg || (head[0] & 0xFF) == 0x89 && head[1] == 0x50 // png || head[0] == 0x47 && head[1] == 0x49 && head[2] == 0x46; // gif } public boolean isRealPdf(InputStream in) throws IOException { byte[] head = new byte[4]; return in.read(head) == 4 && head[0] == 0x25 && head[1] == 0x50 && head[2] == 0x44 && head[3] == 0x46; }只允许 jpg、png、pdf 三种格式。别的格式一律拒掉,包括后缀改成 .jpg 的可执行文件——这一步拦下来,后面省掉无数麻烦。
单文件大小限制 20MB,单个申请的材料数量限制 10 个。这两个数字要根据实际业务定,定得太紧学生会来找你,定得太松存储会爆炸。
5. 审核工作流里那些"想到了但没做"的细节
流程跑通不难,难的是跑得让经办人愿意用。下面几个点都是我上线后根据反馈补的,补之前用户的抱怨很集中。
5.1 批量审核必须做幂等
评议组开一次会要过几十份申请,一个个点进去审是不可能的,必须有批量按钮。批量接口最大的问题是重复提交——网络卡了,用户又点了一次,结果状态被推了两遍。
处理方式是在请求里带一个客户端生成的幂等键,服务端用 Redis 做 5 分钟内的去重:
public BatchResult batchAudit(BatchAuditRequest req) { String key = "audit:idem:" + req.getIdempotentKey(); Boolean first = redis.opsForValue() .setIfAbsent(key, "1", Duration.ofMinutes(5)); if (Boolean.FALSE.equals(first)) { return resultCache.get(req.getIdempotentKey()); } BatchResult result = doBatchAudit(req); resultCache.put(req.getIdempotentKey(), result, Duration.ofMinutes(5)); return result; }除了幂等,批量接口还要处理"部分成功"。五十条里有三条状态已经变了(比如被别的老师先审了),不能整体回滚,也不能整体成功。我的返回结构是每条一个结果码,前端用红色标记失败的几条,让用户单独处理。
5.2 乐观锁比分布式锁更合适
单个审核动作的并发冲突概率很低,用分布式锁属于过度设计。我用的是主表加 version 字段,更新时带条件:
UPDATE apply_form SET status = #{toStatus}, version = version + 1, current_node = #{node} WHERE id = #{id} AND version = #{expectVersion} AND status = #{fromStatus};影响行数为 0 就抛"数据已被他人修改,请刷新重试"。这句提示文案我改过三次,最后定的是这个——说清楚问题、给出动作、不用技术术语。早期写的是"乐观锁冲突",用户完全看不懂,只会打电话问。
5.3 公示期的数据快照,别存引用
公示是要出名单的。如果你的公示页面是实时查库,那公示期内任何一次数据变动都会让名单变样,而公示的语义就是"这期间不许变"。早期我直接查主表,结果有人在公示第二天改了一条数据,名单和前一天下载的 PDF 对不上,被追问了很久。
后来改成在进入公示状态时生成一份快照,整批数据序列化成一个 JSON 存到快照表,公示页面只读快照。快照同时在进入公示时锁定相关记录(给主表加个 frozen 标记,所有写操作接口前先校验)。
@Transactional public void freezeForPublicity(Long termId) { List<ApplySnapshotDTO> list = applyMapper.selectForPublicity(termId); PublicitySnapshot snapshot = new PublicitySnapshot(); snapshot.setTermId(termId); snapshot.setPayload(objectMapper.writeValueAsString(list)); snapshot.setGeneratedAt(LocalDateTime.now()); snapshotMapper.insert(snapshot); applyMapper.markFrozen(termId); }快照还有一个额外好处:哪天有人质疑"公示的名单和最后归档的不一样",把快照调出来一对就清楚了,省掉大量解释成本。
6. 统计导出,是这类系统的性能天花板
管理系统的用户对列表页的容忍度很高,对导出的容忍度极低。导出慢一点,反馈就来了;导出的数据和页面上的数量对不上,那就要被叫去开会。
6.1 先定口径,再写 SQL
"我们院有多少困难学生"这句话至少有四种理解:按申请数、按通过数、按当前学期、按归档结果。这四种口径出来的数字能差出百分之十。所以我在写任何统计 SQL 之前,会先跟使用方确认三件事:时间范围怎么算、用哪个状态作为"有效"、跨院系转专业的学生算哪边。
确认完了把口径写成文档放在系统里,每个报表页面右上角挂个"口径说明"的小链接。这个动作看起来跟写代码无关,但它省掉的扯皮时间比写一个月代码都多。
6.2 导出一定要异步化
同步导出在数据量小的时候没问题,全校上万条一带上材料统计就撑不住了,请求超时、页面卡死、用户狂点。我改成异步任务模式:
提交导出请求时写一条任务记录,返回任务 ID;前端轮询任务状态;后台线程池分批查询、流式写文件、完成后更新状态并通知。
@Async("exportExecutor") public void runExport(Long taskId, ExportQuery q) { try { taskMapper.updateStatus(taskId, RUNNING); Path tmp = Files.createTempFile("export-", ".xlsx"); try (SXSSFWorkbook wb = new SXSSFWorkbook(500); OutputStream os = Files.newOutputStream(tmp)) { Sheet sheet = wb.createSheet("数据"); writeHeader(sheet); int page = 0, row = 1; while (true) { List<ApplyExportDTO> batch = applyMapper.selectExportPage(q, page * 1000, 1000); if (batch.isEmpty()) break; for (ApplyExportDTO d : batch) { writeRow(sheet, row++, d); } page++; taskMapper.updateProgress(taskId, row - 1); } wb.write(os); } String url = storageClient.upload(tmp, "export/" + taskId + ".xlsx"); taskMapper.finish(taskId, url); } catch (Exception e) { taskMapper.fail(taskId, e.getMessage()); } }几个要点:用 SXSSFWorkbook 而不是 XSSFWorkbook,前者只保留滑动窗口内的行在内存里,导出十万行也不会 OOM;分页大小 1000 左右比较稳,太小了查询次数多,太大了单次内存高;进度要写回任务表,让用户看到百分比,等待感会大幅下降。
导出的文件同样走签名 URL,有效期给 24 小时,过期自动清理临时文件。
6.3 列表查询的三个索引和一个习惯
这类系统的慢查询九成集中在列表页。我一般会建这几个索引:
| 索引 | 字段组合 | 覆盖的查询场景 |
|---|---|---|
| idx_term_status | (term_id, status) | 按学期筛状态 |
| idx_dept_term | (dept_id, term_id, status) | 院系维度列表 |
| idx_stu_term | (student_id, term_id) | 学生查自己的 |
| idx_submit | (term_id, submit_time) | 按提交时间排序 |
另外一个习惯是:列表接口坚决不写SELECT *。这不是洁癖,是因为这类表的 JSON 字段和长文本字段很大,SELECT *会把它们全捞出来,白白消耗 IO 和网络。我通常只查列表页展示的十来列,详情页再按 ID 单独查。
7. 上线前后那些只有跑起来才知道的事
代码写完只是开始,真正让人头大的是数据迁移、并发和用户培训。
7.1 历史数据迁移,先做清洗再做导入
老系统里的数据一般不能直接用。我遇到过的典型问题:身份证号有 15 位的、有带空格的、有 X 写成小写的;院系名称有简称有全称;学期标识有三种格式混用。如果直接导进去,后面所有统计都会错。
我的流程是三步:先把老数据导到一张临时表里,完全不改;然后跑清洗脚本,做号码规范化、名称映射、学期统一,每条记录打上"清洗结果"标记;最后人工抽查清洗失败的记录,确认后再写入正式表。整个过程中临时表一直保留,方便回溯。
学号作为主关联键这里要特别注意:有的学校存在学号复用(退学后新生用了同一个号),迁移时必须加上入学年份一起判断,否则会把两个学生的数据串到一起。
7.2 高峰期并发,提前压一遍
这类系统的流量特征特别明显:平时几乎没量,截止日晚上八点到十二点集中涌入。我们做过一次压测,模拟 2000 人同时提交,早期版本的连接池和文件上传接口直接被打满。
调优主要做了三件事:上传接口从同步转异步(前面提过)、数据库连接池上限根据实际并发调高并加了慢查询熔断、提交接口加了简单的限流(按用户维度,每分钟 10 次)。限流不是为了防攻击,是为了防止学生手抖连点把数据库打崩。
还有一个容易被忽略的点:提交时会触发消息通知(站内信、短信)。通知一定要走队列异步发,同步发的话第三方接口一慢,提交就超时。我最早就是同步发的,某次第三方服务抖动,导致提交接口大面积超时,那次的教训记到现在。
7.3 培训成本,其实是交互设计问题
系统上线后最常见的问题不是"功能不会用",而是"我以为提交了其实没提交"。根因是提交按钮的反馈不够强。改了三个地方之后咨询量明显下降:提交成功后跳转到独立的结果页,而不是弹个 toast 就留在原地;草稿态在列表页用醒目的标签标出来,并在截止前一天给未提交的人发提醒;所有不可逆操作(提交、撤回、批量审核)都加二次确认弹窗,弹窗里写清楚"提交后不可修改,如需修改需等待退回"。
经办人侧的培训重点是批量操作和筛选器。我给他们做了一页纸的操作卡片,把最常用的五个动作截图配文字,贴在办公桌上。比开一小时培训会管用得多。
提示:上线第一个周期一定要留人值守。截止日当晚的咨询量能顶平时一个月,很多问题不是 bug,是偶发的网络、浏览器兼容、材料格式问题。值守时把每个问题记下来,第二学期改。
我个人在这个项目上最深的一条体会是:这类系统的技术难度其实不高,难的是把"流程的严谨"和"使用的顺手"同时做到。流程上少一个校验,后面就要花十倍时间去补数据;交互上多一个弹窗,用户就多一分烦躁。每次改需求的时候我都会问自己一句——这个限制是为了防错,还是为了省事?如果是后者,宁可多想一个替代方案,也别直接甩给用户。