做过几个SSM项目之后,你会发现这类老牌技术栈确实稳定,但一旦业务场景里塞进了检索、推荐、统计这些偏算法的需求,单靠Java硬顶会写得非常别扭。今年帮一个儿童福利方向的机构落地孩童收养信息管理系统,我直接采用了Java+SSM+Flask的双后端架构:SSM负责收养登记、儿童档案、审核流程这些核心业务,Flask负责政策检索、收养匹配推荐和数据统计。整套系统跑下来,最深的感受是:收养管理系统的难点从来不是增删改查,而是业务状态怎么流转、两个后端怎么协作、数据怎么保持一致。这篇文章我会从需求拆解、选型逻辑、表设计、核心代码思路到部署踩坑完整过一遍,给正在做同类系统(尤其是课程设计、毕业设计或者小机构内部信息管理系统)的朋友一个可以直接参考的落地方案。
1. 这个系统要管的不是儿童档案,而是“儿童-家庭-机构”之间的关系流转
1.1 三个角色和四条业务主线
很多同学拿到“孩童收养信息管理系统”这个题目,第一反应就是做一个儿童信息登记表,再加个收养家庭申请表,然后对着表格写CRUD。但实际把业务捋清楚之后会发现,这个系统管理的不只是儿童本身,而是儿童、收养家庭、福利机构三者之间的关系变化过程。
系统里最基本的角色有三个:
- 机构工作人员(也就是民政窗口经办人、福利院管理员),负责录入儿童档案、审核收养申请、办理收养登记、维护政策文档。
- 收养申请人,也就是想要收养儿童的家庭或个人,负责提交申请、上传资质材料、查看审核进度、发起政策咨询。
- 系统管理员,负责用户分配、角色授权、字典项维护、数据备份这些运维层面的事。
围绕这三个角色,业务上要打通四条主线:第一条是儿童档案从入库、匹配到完成收养的信息变更;第二条是收养家庭从提交申请、材料审核、家庭评估到登记办理的完整流程;第三条是收养登记完成后的归档与追溯;第四条是政策咨询与信息发布。前两条是大家都会做的,后两条特别容易在需求设计阶段被漏掉。
我当时就是先只写了前两条主线,等到需求评审被问了一嘴“收养登记之后,档案是删掉还是保留?后续回访怎么办?”,才发现系统必须支持历史追溯。所以后来在儿童表里加了一个状态字段,登记完成之后儿童不是被删除,而是状态变成已收养,所有关联的登记记录都要能按条件查出来。
1.2 状态变更比增删改查难得多
传统课设项目倾向于把儿童管理做成纯档案管理,但我做完这个项目之后可以很明确地说:收养系统的核心是状态机,不是表单。
一个儿童从进入机构到完成收养,大致会经历几个状态:
- 在院养育(刚录入系统)
- 待收养公示(允许申请人查看和匹配)
- 匹配审核中(已经有家庭发起申请且进入评估)
- 收养登记完成(法定登记办结)
- 已离院(完成交接)
每一笔状态变更,还要记录操作时间、操作人、变更原因。这个“审计要求”一开始我没做,后来补了很痛苦,因为涉及历史数据的字段改动。建议各位在第一版设计里就给每张核心表加上操作日志表,或者至少留够备注字段。
2. 技术选型逻辑:为什么用SSM管业务,用Flask管“偏算法”的活
2.1 让Java做它擅长的事,让Python做它擅长的事
先回答一个很多人会问的问题:SSM一个框架都能做,为什么要再加一个Flask?
我的答案是:能,但会很累。SSM在事务控制、权限拦截、企业级分层上是强项,这一点不需要怀疑。但在下面这几个场景里,Flask背后Python生态的优势太明显了:
- 政策文档检索。Java做全文检索得上Lucene或者Elasticsearch,对小系统来说太重了;而Python生态里有现成的中文分词库和文本相似度计算工具,几十行代码就能做一个够用的检索接口。
- 儿童与收养家庭的匹配推荐。这个功能本质上是打分排序,Python里处理数据、写加权算法、快速调整因子都比Java方便。
- 统计报表。福利机构非常看重季度、年度的收养数据统计,Flask配合ECharts做数据接口和可视化展示,开发效率高出一截。
所以整体的架构思路是:SSM作为主后端,处理用户登录、权限、儿童档案、收养登记流程这些核心业务;Flask作为辅助后端,处理政策检索、匹配推荐、统计接口。两个后端通过HTTP接口通信,部署在同一台服务器或者同一内网环境里,前端对用户只暴露SSM的地址,Flask接口不直接对外开放,安全性和权限管理也都集中在SSM这边。
2.2 两个后端之间怎么协作
协作模式我最终选的是“SSM转发”而不是“前端双调用”。原因很简单:如果前端同时调两个后端,第一个要处理跨域,第二个要处理两套登录态,第三个是权限控制会被打散。全部由SSM转发的话,前端只跟SSM打交道,Flask只信任来自SSM的请求,用内网IP加白名单控制就行。
在SSM侧,我用RestTemplate封装了一个简单的HTTP客户端工具类,服务层需要调用Flask接口时直接注入使用。Flask侧则通过CORS配置允许来自SSM服务端的跨域调用,同时用请求头里一个自定义的token做服务间鉴权。这个token在两边配置一致,虽然简单,但对内网系统来说已经够用了。
2.3 技术分工对照表
下面的表格是我最后定下来的模块归属,写进设计文档里,开发时照着分工走,避免两个人改到同一块代码:
| 功能模块 | 归属后端 | 选型理由 |
|---|---|---|
| 用户登录与权限拦截 | SSM | Shiro权限框架和SpringMVC拦截器处理起来成熟稳定 |
| 儿童档案增删改查 | SSM | MyBatis写复杂联表查询很顺手,事务可控 |
| 收养登记流程与审核 | SSM | 状态机+事务是Java的强项,不容易出并发问题 |
| 政策文档入库与更新 | SSM | 只是普通CRUD,不需要额外框架 |
| 政策关键词检索和问答 | Flask | jieba分词+相似度计算,代码量小见效快 |
| 儿童与收养家庭匹配推荐 | Flask | 打分算法迭代快,调试方便 |
| 统计报表数据接口 | Flask | 数据聚合逻辑和JSON输出简洁,前端ECharts直接消费 |
这里有一个前提要提醒:这套分工是基于项目体量来的。如果是大型省级平台,我不会这么分,而是统一用Java微服务体系,检索直接上Elasticsearch。但如果是一个市级福利机构或者内部管理系统,双后端这种“轻量混搭”反而比引入一堆重型组件更务实。
3. 数据库设计:五张核心表加一条状态链
3.1 核心表结构和字段说明
数据库我用的MySQL 8.x,字符集utf8mb4。表设计上没有搞太复杂的范式,业务上够用就好。下面这几张核心表是这个系统的基础。
第一张是儿童档案表,字段覆盖基本信息、健康情况、入院信息和当前状态:
CREATE TABLE t_child ( child_id BIGINT PRIMARY KEY AUTO_INCREMENT, child_no VARCHAR(32) NOT NULL COMMENT '儿童编号', name VARCHAR(50) NOT NULL, gender TINYINT COMMENT '1男 2女', birth_date DATE, health_status VARCHAR(255) COMMENT '健康状况描述', guardian_name VARCHAR(50) COMMENT '入院前监护人', entry_date DATE COMMENT '入院日期', status TINYINT COMMENT '1在院 2待收养 3匹配中 4已收养 5离院', photo_url VARCHAR(255), story_desc TEXT COMMENT '儿童简介', create_by BIGINT, create_time DATETIME, update_time DATETIME, UNIQUE KEY uk_child_no (child_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;第二张是收养申请表,存申请家庭的基本信息和材料路径:
CREATE TABLE t_adoption_apply ( apply_id BIGINT PRIMARY KEY AUTO_INCREMENT, apply_no VARCHAR(32) NOT NULL, adopter_name VARCHAR(50) NOT NULL COMMENT '申请人姓名', id_card VARCHAR(18) NOT NULL, spouse_name VARCHAR(50) COMMENT '配偶姓名', income_level VARCHAR(50) COMMENT '家庭收入等级', family_address VARCHAR(255), house_area DECIMAL(10,2) COMMENT '住房面积', family_member_count INT COMMENT '家庭已有子女数', marriage_cert_url VARCHAR(255), income_cert_url VARCHAR(255), health_cert_url VARCHAR(255), police_cert_url VARCHAR(255), apply_date DATETIME, status VARCHAR(32) COMMENT '申请状态', create_by BIGINT, create_time DATETIME, UNIQUE KEY uk_apply_no (apply_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;第三张是收养登记表,用来记录“哪个儿童被哪个家庭收养”这个核心事件:
CREATE TABLE t_adoption_registration ( registration_id BIGINT PRIMARY KEY AUTO_INCREMENT, registration_no VARCHAR(32) NOT NULL COMMENT '登记编号', child_id BIGINT NOT NULL, apply_id BIGINT NOT NULL, review_date DATETIME COMMENT '审核日期', evaluate_date DATETIME COMMENT '评估日期', match_date DATETIME COMMENT '匹配日期', register_date DATETIME COMMENT '登记办结日期', status VARCHAR(32) COMMENT '登记状态', remark VARCHAR(500), create_time DATETIME, UNIQUE KEY uk_registration_no (registration_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;另外两张表分别是政策文档表和咨询记录表,我就不把完整SQL都贴出来了思路都是文档内容加发布时间,咨询记录是申请人ID、问题、答复、咨询时间。用户表用常规的id、username、password、role字段就够了,密码存MD5加盐后的值。
3.2 登记流程的状态链怎么设计
收养登记是整个系统里最容易出bug的部分,因为每一步都可能驳回、回退、终止。如果你只用一个小数字字段表示状态,代码很快就堆成一团乱麻。
我是这样定义的,状态字段直接用字符串枚举,可读性优先:
- PENDING_REVIEW:申请人提交,等待初审
- FIRST_PASS:初审通过,进入家庭评估
- FAMILY_ASSESS:正在做家庭评估,可能上门走访
- PENDING_MATCH:评估通过,等待匹配儿童
- MATCHED:已匹配儿童,等待办理登记
- REGISTERED:登记完成
- TERMINATED:流程终止,可能是驳回、放弃或评估不合格
状态流转的规则是:PENDING_REVIEW可以到FIRST_PASS或TERMINATED;FIRST_PASS可以到FAMILY_ASSESS或TERMINATED;FAMILY_ASSESS可以到PENDING_MATCH或TERMINATED;PENDING_MATCH到MATCHED;MATCHED到REGISTERED。正常流程里不允许跳过中间状态,比如不能直接从未审核跳到已登记。
这里有一个设计细节很关键:t_adoption_registration表里的状态和t_child表里的状态要联动更新。比如匹配成功的那一刻,申请表中的状态变成MATCHED,同时儿童表中的status要从“待收养”变成“匹配中”。这个联动必须在同一个数据库事务里完成,否则就会出现“家庭已经匹配成功了,儿童在系统里还是待收养”这种数据不一致。
我在第一版就犯了这个错,写成了两个独立的方法调用,结果测试时发现儿童状态没变,查了半天才发现是忘了加事务。后面在4.3节我会详细说这个问题的排查思路。
4. SSM侧核心模块落地:档案管理、材料上传和审核流控制
4.1 儿童档案模块的实现结构
SSM侧我按标准的Controller-Service-Mapper三层走:
- Controller层只做参数接收、调用服务、返回ModelAndView或者JSON。
- Service层写业务逻辑,比如状态机判断、事务控制。
- Mapper层用MyBatis写SQL和动态查询。
儿童档案模块本身不难,但有两个地方值得注意。第一是列表查询要做分页,我用PageHelper插件,一行代码搞定count查询和limit拼接;第二是儿童照片上传,我用的本地磁盘存储,路径存数据库,没有接OSS这类对象存储。拍照上传后后端做类型校验,只允许jpg、png,大小限制在2MB以内,图片文件按日期目录存放,文件名用UUID重新生成,避免中文文件名乱码和重名覆盖。
4.2 收养申请的材料管理
收养申请这一步收集的材料比较碎,身份证、结婚证、收入证明、无犯罪记录证明,每一种都是图片或者PDF。我统一用一个材料表来管理,不硬编码到申请表字段里,这样以后扩展材料类型不用改表结构。后端限制单文件不超过5MB,做过一个简单的文件大小和类型校验过滤器,超出直接返回提示,省得之后磁盘被塞满。
4.3 审核流控制:一个必踩的事务失效坑
收养申请审核是整个SSM侧最需要谨慎的地方。审核通过时,代码逻辑上要做两件事:更新申请表的状态;在审核日志表里插入一条审核记录。这两个操作必须同时成功或同时失败,所以Service方法上必须有@Transactional。
@Transactional(rollbackFor = Exception.class) public void passFirstReview(Long applyId, Long reviewerId, String remark) { AdoptionApply apply = adoptionApplyMapper.selectById(applyId); if (apply == null || !"PENDING_REVIEW".equals(apply.getStatus())) { throw new BusinessException("当前申请状态不可进行初审操作"); } adoptionApplyMapper.updateStatus(applyId, "FIRST_PASS", remark); auditLogMapper.insert(new AuditLog(applyId, reviewerId, "初审通过", remark)); }看着简单,但实际测试的时候我发现状态更新了,审核日志却没写进去。排查到最后发现是事务失效,原因很经典:我在同一个Service里通过内部方法调用updateStatus,不经过Spring的代理对象,导致@Transactional注解没有被激活。
解决办法有几个:第一是拆分Service,让A Service调B Service的方法,确保调用经过代理;第二是注入自身代理;第三是用TransactionTemplate手动控制事务。我最后选择的是拆分Service,把审核记录写入放到另一个AuditService里,这样代码结构也更清晰。
这个坑在SSM项目里非常经典,如果你们也是照着源码改,一定要注意别在同一个类里“自己调自己”。
4.4 权限控制与进度查询
权限控制使用的Shiro,给工作人员和管理员配了不同的角色。普通申请人登录后只能看到自己提交的申请,查询逻辑是MyBatis动态SQL,根据当前登录用户ID过滤数据。进度查询接口返回的是一个JSON树,里面包含每一轮审核的状态、操作人、时间和备注,前端用时间线组件渲染,用户能看到申请走到哪一步了。
5. Flask侧业务:政策检索、匹配推荐和统计接口的实现
5.1 政策文档检索:不用搜索引擎也能做
Flask侧第一个让我觉得“这技术栈没白加”的功能是政策检索。政策文档都是中文长文本,直接用SQL的LIKE匹配效果很差,比如用户搜“收养需要什么条件”,单靠like '%收养需要什么条件%'什么都查不出来。我用的是jieba分词加余弦相似度:
import jieba from flask import Flask, request, jsonify app = Flask(__name__) def calc_similarity(text1, text2): words1 = set(jieba.lcut(text1)) words2 = set(jieba.lcut(text2)) if not words1 or not words2: return 0.0 inter = words1 & words2 score = len(inter) / (len(words1) + len(words2) - len(inter)) return round(score, 4) @app.route("/api/search_policy", methods=["POST"]) def search_policy(): data = request.get_json() question = data.get("question", "") policies = query_all_policies() scored = [] for p in policies: s = calc_similarity(question, p["title"] + p["content"]) scored.append({"doc_id": p["id"], "title": p["title"], "score": s}) scored.sort(key=lambda x: x["score"], reverse=True) return jsonify(scored[:5])这里用的是最基础的Jaccard相似度,实际效果比LIKE好很多。测试的时候搜“多大年龄可以收养”也能匹配到包含“被收养人年龄”的文档,虽然排序可能不是最优,但对内部辅助查询来说完全够用了。如果你们想做得更精细,可以换TF-IDF向量计算余弦相似度,但小系统没必要上向量数据库。
5.2 儿童与收养家庭的匹配打分
匹配推荐是Flask侧最有业务价值的接口。逻辑不复杂,本质是给每个待收养儿童和申请家庭算一个匹配分,按分数排序输出候选名单。
打分因子我定了三个维度:
- 年龄匹配度:家庭申请意向年龄段与儿童实际年龄的接近程度,越接近得分越高。
- 家庭条件匹配:家庭已有子女数、收入等级、居住面积对抚养能力的综合评估分。
- 健康与需求匹配:儿童是否有特殊健康需求,家庭申请意向里是否明确愿意接受,匹配则加分。
每个因子分配权重,最后算加权总分。算法写在Python里非常直观:
def match_score(child, family): age_score = calc_age_match(child["birth_date"], family["preferred_age_range"]) family_score = calc_family_condition(family) need_score = calc_need_match(child["health_status"], family["accept_special_need"]) return 0.4 * age_score + 0.4 * family_score + 0.2 * need_score这个接口最初是工作人员手动在儿童列表里挑家庭,匹配全靠经验,现在系统生成推荐列表后能节省大量筛查时间。第一次联调时我发现Flask返回的JSON里日期字段是对象格式,前端解析不稳定,后来统一在Flask里转成字符串再返回。
5.3 统计报表:数据接口一次性输出
统计这块Flask做起来确实快。比如季度登记趋势,我写一个接口直接GROUP BY月份统计登记数量,返回给前端ECharts渲染:
@app.route("/api/stats/registration_trend", methods=["GET"]) def registration_trend(): rows = db.query("SELECT DATE_FORMAT(register_date, '%Y-%m') AS month, COUNT(*) AS cnt " "FROM t_adoption_registration WHERE status='REGISTERED' GROUP BY month") return jsonify({"months": [r["month"] for r in rows], "counts": [r["cnt"] for r in rows]})因为Flask连接的是同一个MySQL库,所以这里可以直接读业务表,不需要SSM专门暴露统计接口。ECharts那边拿到这两个数组直接就能画折线图,效率很高。
6. 联调部署中的踩坑记录
6.1 端口规划与跨域问题
系统部署在一台内网服务器上,SSM运行在8080端口,Flask运行在5000端口。最开始开发时,前端页面直接访问Flask接口遇到跨域问题,我临时在Flask里开了CORS中间件:
from flask_cors import CORS CORS(app)这个只用于本地开发调试。部署到生产环境后,我把Flask的host绑定到127.0.0.1,同时关闭CORS,所有外部请求都走SSM的8080端口,由SSM服务端转发到Flask,避免Flask接口被外部直接扫描调用。
6.2 日期格式和空值问题
联调时遇到的最多问题就是日期格式。Java的LocalDateTime序列化成JSON后默认是一个数组或ISO字符串,而Python那边解析时经常出格式错误。后来约定所有接口传递日期时间统一用“yyyy-MM-dd HH:mm:ss”字符串,Java侧加@JsonFormat注解:
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") private LocalDateTime createTime;建议大家在项目一开始就在接口文档里把这个约定写清楚,否则两边各按各的格式解析,联调阶段会浪费大量时间。
6.3 部署脚本和运维建议
最后写了一个简单的启动脚本,一条命令拉起两个服务:
#!/bin/bash nohup java -jar adoption-ssm.jar --server.port=8080 > logs/ssm.log 2>&1 & nohup python app.py > logs/flask.log 2>&1 & echo "services started"实测过程中建议把两个服务的日志分开存放,出了问题先看Flask日志再看SSM日志,排查效率高很多。另一个坑是MySQL连接池配置:Flask这边我用的PyMySQL,默认连接超时时间比较短,长时间空闲后首次请求会报连接丢失,解决方案是连接池里加自动重连参数,或者每次请求重新获取连接。
埋伏了两天才发现的接口幂等
最后补充一个印象最深的经验:收养登记确认接口,如果前端双击提交或者网络重试,会出现一条数据被插入两次的情况。处理方式是数据库加唯一约束,registration_no全局唯一,同时后端业务方法里先查再插,用唯一键冲突兜底。这个思路同样可以套用在儿童编号、申请编号这些核心业务字段上,比单纯靠前端禁按钮可靠得多。
我当时在测试环境里才发现这个问题,因为手动点击不容易触发,但用脚本模拟并发请求就会遇到。如果你要接手这类系统,建议把涉及登记编号、申请编号的插入操作全部检查一遍唯一约束,凡是没加的都补上。
做完这个系统,最大的体会是:信息管理系统的复杂度,不在代码量,而在业务规则的建模和跨技术栈的协作设计。SSM作为Java老牌组合,做核心事务流程依然让人放心;Flask像一把轻快的小刀,正好补上检索、推荐、统计这些Java做起来笨重的场景。如果你也准备做类似的项目,可以先照着这个需求拆解思路把自己的业务角色和状态链列清楚,再决定哪些功能用Java扛、哪些交给Python,最后才会发现联调没有想象中那么可怕。