把“实习招聘系统”这类题目做成什么程度才算过关?这是我被问得最多的问题。很多同学拿到类似题目,第一反应是“用户管理、岗位管理、投递管理、简历管理”全堆上去,功能表写得密密麻麻,结果答辩时讲不清楚数据怎么流动,业务怎么闭环,代码里一堆互相矛盾的逻辑。我自己的经验是:与其堆功能,不如先把“到底谁在用、用的时候经历了什么、每一步该有什么状态变化”想明白。这篇博文就以我自己完整做过的一个基于Java Web的实习招聘系统为例,把这个项目从需求梳理、技术选型、数据库设计、核心功能实现到部署上线踩坑的完整链路展开讲一遍。如果你是正准备做同类系统的同学,或者想把自己的课设项目做得更像一个“正经系统”而不是作业,这篇内容可以直接参考。
1. 实习招聘系统的需求边界:先想清楚“谁在用、怎么用”
1.1 目标用户与角色拆解
实习招聘系统本质上是一个“三方撮合平台”:学生要找实习,企业要招实习生,中间还需要一个管理者来保证流程合规、处理异常。大多数同学在做这类系统时只做了“学生端”和“企业端”,少了管理端,导致整个系统缺少一个能“兜底”的角色。我在设计时把角色分成三类,每个角色的权限边界非常清晰:
- 学生端:注册登录、完善简历、浏览岗位、投递实习岗位、查看投递状态、接收面试/录用通知。
- 企业端:注册登录、发布实习岗位、查看投递学生列表、筛选简历、发起面试、发送录用或不通过结论。
- 管理端:审核企业和学生的注册资质、审核岗位是否合规、处理用户投诉、统计整体数据(岗位数量、投递量、录用率等)。
这个三角色的设计并非我原创,而是参考了主流招聘平台的通用业务模型。关键点在于:管理端的存在不是让系统显得复杂,而是解决“企业乱发岗位怎么办”“学生简历信息造假谁处理”这类真实问题。 没有管理端审核,整个系统就是学生和企业互相对着发消息,数据质量会迅速失控。
1.2 核心业务流程梳理
需求梳理阶段最容易犯的错是“把每个功能独立看待”,比如“学生能投递”“企业能发岗位”,单看都没问题,串起来就断了。我的做法是先画出主流程:学生注册登录 → 完善简历 → 浏览岗位 → 投递 → 企业查看投递 → 筛选/面试 → 录用/婉拒 → 学生确认实习 → 流程结束。在这个主流程中,每个环节都与上一环节的状态关联,任何一步缺了,系统就会像“投递之后什么都没发生”一样成为死胡同。
我还额外梳理了两个分支流程:一是企业发布岗位需要经过管理端审核,未审核的岗位在前端不可见;二是学生被录用后需要确认,确认之后岗位的招聘名额才真正消耗。这两个分支看似增加了工作量,实际上让整个系统的数据可信度大幅提升,也给答辩时讲“业务闭环”提供了很实际的案例。
1.3 功能清单的取舍原则
一份功能需求表如果超过20项,大概率是在自欺欺人。我最终采用的功能清单是:学生端12项、企业端10项、管理端8项,总计30项,但其中有近一半是支撑性功能(登录、注册、审核、分页、文件上传等)。取舍的原则只有一条:每一个功能都必须能在主流程或分支流程上找到一个明确的触发位置。 比如“学生收藏岗位”是个加分项,但收藏之后如果没有任何后续动作(比如“收藏夹一键投递”),它就只是数据堆积,我选择不做。同理,“聊天功能”虽然看着很炫,但实现复杂度远超课题范围,而且与简历投递的核心路径没有强关联,我也砍掉了。
这种做减法的方式,让我在后端开发和答辩时都轻松了很多。功能少而每个都跑得通,远比功能多但每个都半吊子要更有说服力。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术选型与项目骨架:为什么我依然选择经典Java Web分层
2.1 前后端分离与传统MVC的取舍
坦白说,现在做一个新项目,大多数团队会选前后端分离:Vue/React + Spring Boot + REST接口。但我的这个实习招聘系统选择了更“传统”的JSP + Spring MVC + MyBatis + MySQL的组合。为什么?不是不会前后端分离,而是从课设/毕设的评审角度和单人开发效率出发,这套方案有不可替代的优势:
- 代码量相对集中,便于通篇自查。前后端分离意味着要维护两套工程,单人开发时排查一个跨域问题可能要同时调试前端口和接口端,工作量翻倍。
- JSP + Spring MVC天然支持服务端渲染,页面跳转、表单提交、权限拦截都可以在一个工程里闭环,答辩演示时不容易出现“接口通但页面挂”的尴尬。
- 这门课的评审侧重点往往在Java后端能力(Spring容器管理、MyBatis映射、事务控制、MVC路由),太过于前端的方案反而弱化了核心展示点。
当然,我也在实际项目中遇到了JSP的痛点:前后端代码耦合度高、页面调试不便、静态资源缓存问题。但这些在单人开发、规模不大的场景下完全可控。如果你准备用前后端分离,也不是不行,但一定要预留足够的时间处理跨域、Token鉴权和接口文档,否则工期很容易失控。
2.2 技术栈清单与关键版本
我的技术栈清单如下,版本我给出的是当时实测稳定的组合:
| 组件 | 选择 | 版本与说明 |
|---|---|---|
| 开发语言 | Java | JDK 1.8(稳定、兼容性最好,避免新版本语法对老环境的坑) |
| 后端框架 | Spring MVC + MyBatis | Spring 5.x / MyBatis 3.5.x |
| 数据库 | MySQL | 5.7(InnoDB引擎) |
| 前端 | JSP + Bootstrap + jQuery | Bootstrap 3.x做布局,jQuery负责简单交互 |
| 服务器 | Tomcat | 8.5版本,JDK1.8对应推荐8.5.x |
| 依赖管理 | Maven | Maven 3.6.x |
| 文件存储 | 本地磁盘目录 | 配合Tomcat虚拟路径映射 |
有必要说明的是:这个选型不是我为了“复古”而选,而是我实际踩过JDK17 + Spring Boot 3 + Jakarta EE 9的坑之后,回头选择了更稳妥的组合。对课设项目来说,稳定、可控、资料多比技术新更重要。
2.3 项目分层结构与包设计
包结构我采用了最常见的四层模式:controller / service / mapper / entity。非常朴素,但逻辑清晰:
code复制com.example.internship
├── controller // 接收请求、参数校验、返回视图或JSON
├── service // 业务逻辑、事务控制、状态流转
├── mapper // MyBatis的Mapper接口
├── entity // 数据库实体类
├── config // 配置类(拦截器、文件上传配置等)
├── common // 统一返回结果、常量类、工具类
└── interceptor // 登录拦截器
这里要特别强调common包的重要性。早期我开发时把常量散落在各个类里,用户角色、投递状态、审核状态到处写数字,改一处漏三处。后来统一建了Constants类,定义UserRoleEnum、ApplyStatusEnum等枚举,整个系统的可维护性立刻上了一个台阶。例如投递状态,我定义了五个值:待审核、已查看、已面试、已录用、已不通过。这些状态会贯穿数据库、Service层和前端展示,如果没有统一的枚举,后期改状态逻辑基本就是灾难。
3. 数据库设计:把实习招聘的实体关系落成表结构
3.1 用户、角色与权限的表设计
用户表我选择了“单表 + 角色字段”的方式,而不是常见的“用户表 + 角色表 + 关联表”。原因是本系统角色只有三种,且每种角色的字段重叠度很高。一张t_user表加一个role字段,再冗余一些各自的扩展字段(如学生的学校专业、企业的统一社会信用代码),完全能覆盖需求,还省去了联表查询的麻烦。
核心字段设计如下:
id:主键,自增;username:登录用户名,唯一索引;password:MD5 + 盐值加密存储;role:tinyint,1学生、2企业、3管理员;status:账号状态,0待审核、1正常、2禁用;student_info相关字段:姓名、学校、专业、毕业时间、手机号、邮箱;company_info相关字段:企业名称、统一社会信用代码、行业、规模、简介。
需要特别强调的是:角色字段与用户状态分开设计。很多系统把“审核状态”和“账号状态”混在一起,导致数据库里一个字段既要表达“学生资料是否审核通过”,又要表达“账号是否被封禁”,逻辑很容易混乱。
3.2 岗位、投递、录取的业务表关联设计
业务核心是t_position(实习岗位表)和t_apply(投递表)。先看岗位表:
id、company_id(关联用户表)、title、description、salary_range、work_city、edu_require、duration_months、max_applicants(名额)、status(0待审核、1招聘中、2已下架);create_time、update_time;view_count(冗余的浏览量,方便列表排序)。
这里设置max_applicants非常关键。它代表该岗位计划招收的实习生人数,在录用确认逻辑中会用到。如果忽略这个字段,后面实现“名额满了自动下架”时会非常痛苦。
投递表t_apply是我认为整个数据库设计中最需要动脑的部分:
id、student_id、position_id;apply_status:1待查看、2已查看、3已面试、4已录用、5未通过、6已拒绝;interview_time、interview_place、interview_notice(面试通知内容);apply_time、update_time;- 唯一索引
uniq_student_position(student_id, position_id),保证同一学生同一岗位只能投递一次。
apply_status字段我用一个tinyint存多种状态,目的很明确:所有投递进展都收敛在这一列上,前端只需要根据这个值渲染不同提示。另外,分出一列interview_time单独管理面试时间,而不是塞进一个通用备注里,是为了方便后续发邮件或站内信提醒时直接取用。
3.3 关键字段的细节设计与冗余策略
三个容易被忽略的细节:
- 所有时间字段用
datetime而不用timestamp。虽然timestamp占用空间更小,但在Java中处理时区问题和MySQL默认行为都更容易踩坑,datetime更直观可控。 - 金额字段不存字符串。实习补贴我采用
salary_range作为展示用字符串(如“150-200元/天”),同时增加salary_min和salary_max两个整数字段用于按薪资排序筛选。字符串负责展示,整数负责计算,互不干扰。 - 逻辑删除优于物理删除。岗位和用户都没有真正执行过
DELETE,都是通过status字段下架或禁用。原因是实习招聘系统会沉淀大量投递关系,一旦物理删除岗位,关联的投递数据就变成了悬空引用,影响历史数据统计。
冗余方面,我在投递表里直接冗余了position_title和company_name。理由很实际:学生查看“我的投递”时,如果每一条都要通过position_id去联表查另一张表,列表页的性能和SQL复杂度都会上升。既然这两个字段在投递时就已经确定,冗余存储换来的是查询时少两次JOIN,这笔账很划算。
4. 核心功能模块实现细节:从登录鉴权到双向选择流程
4.1 登录注册与验证码的完整链路
登录模块是每个Java Web项目的门面,也是最容易出安全问题的模块。我的实现链路是:
- 前端提交用户名、密码、验证码;
- 后端先校验验证码(Session中存储的验证码文本,完成一次校验后立即清空,避免重放攻击);
- 再根据用户名查库,将提交的密码加盐后MD5,与数据库中存储的哈希值比对;
- 校验用户
status是否为正常状态; - 全部通过后写入Session,保存用户ID、角色和昵称;
- 登录成功后,跳转到对应角色的首页。
这里有几个值得注意的细节。验证码我使用的是Java原生BufferedImage生成,再手动写一个干扰线和噪点的工具类,并没有引入第三方验证码库,灵活性反而更高,答辩时可以现场讲解生成原理。密码的“盐值”我用了每个用户注册时随机生成的8位字符串,与密码一起参与MD5计算。这样即使两个用户的密码明文相同,库里的哈希值也不同,同行多张表也不会暴露公共密码规律。
4.2 岗位发布与检索:SQL与分页的实践
岗位检索是实习招聘系统的核心场景。学生首页会根据城市、学历要求、薪资范围、关键词等条件筛选岗位。Spring MVC接收查询参数后,我采用MyBatis的Map传参方式,在XML中动态拼接SQL:
xml复制<select id="searchPositions" resultType="Position">
SELECT * FROM t_position
<where>
<if test="status != null">AND status = #{status}</if>
<if test="keyword != null">AND (title LIKE CONCAT('%', #{keyword}, '%') OR description LIKE CONCAT('%', #{keyword}, '%'))</if>
<if test="city != null">AND work_city = #{city}</if>
<if test="eduRequire != null">AND edu_require <= #{eduRequire}</if>
<if test="salaryMin != null">AND salary_min >= #{salaryMin}</if>
</where>
ORDER BY (view_count + create_weight) DESC, create_time DESC
LIMIT #{offset}, #{pageSize}
</select>
我在这个模块里坚持的一条原则是:凡是用户可控的输入,一律进参数占位符,绝不拼接SQL字符串。edu_require我用的是数值等级(1大专、2本科、3硕士),筛选时用小于等于匹配,保证学历要求为硕士的岗位不会被本科生搜到,但硕士可以看到大专和本科的岗位。
分页方案没有用PageHelper插件,而是手动计算offset = (pageNum - 1) * pageSize,在SQL末尾用LIMIT实现。说实话,PageHelper在简单场景下确实方便,但它有时候在复杂动态SQL中的分页拦截行为不可控,尤其嵌套子查询时会截错SQL。手动写分页虽然多几行代码,但过程完全透明,排查问题也更高效。
发布岗位的逻辑相对简单,但有一道前置校验:企业账号状态必须为“1正常”,并且该企业下当前“招聘中”状态的岗位数量不能超过20条。设置这个上限的目的是防止企业批量刷岗位,把列表页质量拉低。
4.3 投递-审核-录取状态机的实现
状态机是这个系统里最有业务含量、也最能在答辩中展示思考深度的部分。我把投递表t_apply.apply_status的变化严格定义为单向流转:
待查看(1) → 已查看(2) → 已面试(3) → 已录用(4) → 学生确认(最终态)
已查看(2) → 未通过(5)
已面试(3) → 未通过(5)
已面试(3) → 已录用(4)
从代码实现上看,我做了一个ApplyStatusService.checkAndUpdate(studentId, positionId, fromStatuses, toStatus)方法,用数据库更新条件去约束流转:
java复制public boolean updateStatus(Integer applyId, List<Integer> allowedFromStatuses, Integer targetStatus) {
Apply apply = applyMapper.selectById(applyId);
if (apply == null) {
throw new BusinessException("投递记录不存在");
}
if (!allowedFromStatuses.contains(apply.getApplyStatus())) {
throw new BusinessException("当前状态无法执行该操作");
}
// 乐观锁更新,防止并发重复处理
int rows = applyMapper.compareAndSetStatus(applyId, apply.getApplyStatus(), targetStatus);
return rows == 1;
}
对应的Mapper语句使用UPDATE ... WHERE id=?#{id} AND apply_status=#{expectStatus},用MySQL的行级锁保证同一时间只有一个请求能真正改变状态。否则两个面试官同时点击“录用”时,可能会出现状态被第二次覆盖为“已不通过”的竞态问题。
“学生确认录用”这一步我特别加了处理。因为max_applicants名额的限制,只有当学生点了“确认实习”后,t_position.cur_applicants才加一,并判断是否达到名额上限,达到则把岗位状态置为“已下架”。这个逻辑让企业端的招聘名额不再是摆设,整个流程形成真正的闭环。
4.4 简历上传与文件存储的常见方案
简历上传是实习招聘系统绕不开的需求。我的方案是:学生将PDF或Word简历上传到本地服务器指定目录,数据库中仅保存文件的访问路径。具体的目录结构为:
code复制/var/www/uploads/
├── student_id_1023/
│ ├── resume_original.pdf
│ └── resume_20250601.pdf
Spring MVC的文件上传配置只需要在spring-context.xml中声明一个MultipartResolver,设置上传大小上限,比如10MB。代码层面用一个工具类统一生成文件名,采用UUID + 原文件扩展名,避免中文文件名在下载时出现乱码。
这里要特别提醒一个坑:Tomcat运行时的工作目录webapps下的子路径在重启后可能会被清空或覆盖,千万不要把上传文件直接放到webapp/upload/。正确的做法是把上传根目录配置为服务器的一个绝对路径(如/var/www/uploads/),然后通过Tomcat配置虚拟目录映射,让/upload/**访问到该绝对路径。具体在server.xml的<Host>节点下加入<Context>配置,或者使用Spring MVC的资源处理器映射到file:协议:
xml复制<mvc:resources mapping="/upload/**" location="file:/var/www/uploads/" />
这条配置解决了我在本地测试时一切正常、部署到服务器后上传文件404的大麻烦。文件存储路径必须保持稳定,与Web应用生命周期解耦,这是所有课设中容易忽略的一环,也是面试官非常爱问的点。
5. 我踩过的几个坑:从报错到修复的完整排查过程
5.1 中文乱码:一条链路三层配置,缺一不可
第一个大坑是中文乱码,典型症状是页面显示“汤姪”,数据库里存的却是问号“??”。这个问题的本质是编码不一致:浏览器以UTF-8发送请求,Servlet容器以ISO-8859-1解码参数,MySQL连接又以latin1读取读写。我排查的顺序是:
- 请求编码:在
web.xml中配置CharacterEncodingFilter,强制所有请求和响应使用UTF-8。这是第一个配置点。 - 响应编码:JSP页面顶部必须写
<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %>,否则JSP引擎渲染时默认ISO-8859-1。 - 数据库编码:MySQL建库语句必须显式指定
DEFAULT CHARSET=utf8mb4,JDBC连接URL上追加characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai。
这三个设置缺一不可。尤其是数据库的utf8mb4,它是MySQL中真正支持完整Unicode(包括emoji)的字符集,老版本的utf8最多只有3字节,遇到特殊字符还是会炸。我把这三层全部配齐后,乱码问题才在部署环境中彻底消失。
5.2 数据库连接池超时与连接泄漏
系统在本地跑得好好的,放到测试环境运行一天后,隔几分钟一个请求就报Connection is not available, request timed out after 30000ms。一开始我怀疑是并发量太大,后来通过日志和排查发现是连接池被占满了。
根因有两个:第一,我早期在service层里写数据库查询时,忘记在finally中关闭Connection资源,虽然MyBatis的SqlSessionTemplate会自动管理,但有部分封装的工具类偷懒直接使用了原生JDBC,连接打开后没有归还。第二,MySQL服务器默认的wait_timeout是8小时,我的连接池初始连接一直空闲,MySQL主动断开后连接池没有校验可用性,继续下发状态异常的连接。
修复方案是:对所有原生JDBC操作统一使用try-with-resources确保连接关闭;连接池参数中设置testWhileIdle=true和validationQuery=SELECT 1,以及timeBetweenEvictionRunsMillis=60000,让池子及时发现并剔除失效连接。这个坑教会我的经验是:连接池不是配完就完,还要配合数据库的超时策略做活跃检测。
5.3 文件上传路径与Tomcat部署环境的差异
这个问题之前简单提到过,但值得展开。本地开发时,我用相对路径upload/存文件,一切都正常。部署到服务器后,通过FileOutputStream写入的文件虽然成功,但浏览器访问时始终404。后来我打印了System.getProperty("user.dir"),发现Tomcat以服务方式启动时,工作目录是Tomcat的bin/目录,而不是webapps/下的应用目录。
所以文件路径设计的正确做法是:把上传存储目录做成绝对路径,并写入到配置文件中。我最终采用Spring的@Value("${upload.dir}")注入方式,在application.properties中配置upload.dir=/var/www/uploads/。这样换服务器部署时只改配置就行,而不用改代码。同时利用/upload/**的静态资源映射,把绝对路径与URL访问路径解耦。
5.4 分页查询与多条件联查的参数陷阱
分页模块的典型报错是“SQL语法错误”或者“结果总比预期少几条”。排查后发现是因为多条件联查时,我在拼接动态SQL时顺着书写顺序把WHERE、AND、LIMIT的位置搞混了。更隐蔽的一个坑是:offset参数在传入Mapper时,如果不小心写成了int类型,而前端传过来的pageNum是一个字符串,Spring MVC类型转换失败会直接抛异常。
我的解决方案是:所有分页参数统一封装成一个PageParam类,包含pageNum、pageSize、offset(由前两者计算),在Controller层完成类型转换和参数校验。SQL的LIMIT只接受offset和pageSize两个整数,不允许在XML中做任何计算。前端的分页组件也统一传pageNum,后端计算offset,避免两套公式不一致。
6. 部署上线与性能优化的实战建议
6.1 从本地调试到云服务器部署的完整流程
部署到云服务器(Linux)时,我的流程是:
- 在服务器安装JDK 1.8、MySQL 5.7、Tomcat 8.5;
- 创建独立的
www用户,运行Tomcat和Java进程,不直接使用root; - 上传项目的
war包到Tomcat的webapps目录; - 修改
server.xml端口为80,并配置上传目录虚拟映射; - 通过
systemctl配置Tomcat为开机自启; - 执行数据库初始化脚本,导入全量表结构;
- 启动后先通过
curl检查首页接口状态,再在前端页面逐条走主流程。
这里有个经验想分享:不要在本机把项目开发完后才部署,而应该在开发中途就尽早部署一次。哪怕只完成一个登录功能,也先上服务器跑通全链路,这样后面大部分部署问题已经被拆散解决掉,最后交付时会非常从容。
6.2 数据量上来后的索引优化
系统运行初期记录少,查询都很快。但岗位表和投递表数据量过万之后,我在企业端“查看某岗位收到的投递列表”时明显感觉到卡顿。用EXPLAIN查看执行计划,发现t_apply表全表扫描。
优化手段很直接:给t_apply表添加联合索引(position_id, apply_status),给t_position表添加(status, create_time)复合索引。投递列表页也顺手加了一个(student_id, apply_status)联合索引,支撑学生端“我的投递”状态的快速筛选。优化之后,核心查询的扫描行数从几万降到了几十行。
这里也要提醒一句:索引不是越多越好。每多一个索引,数据插入和更新时就要多维护一份B+树,写性能会下降。我给了三张核心表共6个索引,全部依据实际查询条件建立,不接受“顺手加索引”的偷懒做法。
6.3 安全加固的基础项:SQL注入、XSS与接口防刷
课设项目虽然不像商业系统那样面对激烈的攻击,但安全基础还得有。我做了四件成本低、效果好的事:
- 全局拦截SQL注入:MyBatis的所有
#{}参数绑定天然防注入,但少部分表名、排序字段使用了${}拼接,我通过一个自定义白名单校验工具,只允许传入预设的排序字段名,从源头杜绝注入风险。 - XSS过滤:通过一个HandlerInterceptor对请求参数中的
<script>标签、javascript:协议等危险字符做统一替换和转义。学生的简历内容和企业岗位描述都允许富文本,但必须经过过滤后再入库。 - 接口防刷:对登录接口和验证码接口做了简单的频率限制,同一IP在5分钟内最多尝试10次登录,超过后锁定30分钟。这个逻辑可以用一个内存计数器实现,体量小也不必引入Redis。
- 角色权限校验:除了前端隐藏按钮,后端每个修改类接口都通过拦截器或注解校验当前Session中的角色是否匹配,绝不信任前端传入的
role参数。例如企业端接口,只允许Session中的角色为“2”的用户调用。
这些安全措施加起来大约一周的工时,但对系统完整性的提升是质变。答辩时如果要展示亮点,这四项就是很好的切入点。
6.4 答辩演示时的重点串联思路
最后说一句关于演示的问题。我见过很多同学程序写完,答辩时东点一个按钮西点一个链接,讲了十分钟评委还看不出系统业务闭环。我的建议是:准备一条“黄金演示路径”——从学生注册、登录、投递开始,切到企业端登录,看到投递记录,把状态变为“已面试”,然后点“录用”,再切回学生端,看到“录用通知”,确认实习,最后切到管理端看统计页面,说明整个流程数据打通。
这条路径把所有核心模块串成一条线,每一步都有“上一操作带来下一结果”的可感知变化。评委看完这条链路,对系统的完成度就有了整体认知。剩下的简历上传、岗位审核、权限拦截等功能,再作为补充点展开也不迟。
写在最后的个人体会
这个基于Java Web的实习招聘系统我前后做了大约三周半,包含需求梳理、编码、调试、部署和文档。做完后最大的体会是:技术栈本身并不复杂,Spring MVC那套东西翻来覆去就是Controller-Service-Mapper调用流,真正拉开差距的是你在数据库设计阶段有没有把业务状态想清楚,以及调试时能不能顺着日志把问题收敛到具体一层。我也建议看到的你,不要急着炫技术,先把流程画出来,把状态列清楚,再把代码一段段填进去。后面如果还想扩展,可以往消息通知(面试提醒邮件)、简历解析、统计分析报表这些方向走,表结构和分层骨架都不需要大改。希望这篇内容能帮你少踩几个坑,把项目做得扎实。
