上个月我陪家里人去看望一位住在养老中心的长辈,前台小姑娘手忙脚乱地翻纸质台账找探访登记信息,旁边的护理员拿着手机在群里发交接班记录。那一刻我突然意识到,这就是“养老中心管理系统”要解决的问题——不只是把Excel搬到网页上,而是把一堆散落在线下流程里的管理动作统一起来。这篇文章就是基于这样一个项目:用SpringBoot从0搭建养老中心管理系统,从头梳理需求、技术栈、表结构、核心功能和踩坑过程。正在准备SpringBoot毕设、或者想用一个小而完整的业务系统练手的同学,可以照着我这条路走一遍。
1. 养老中心的业务现实:不是做产品设计,是给杂乱流程补一条主线
很多人在拿到“养老中心管理系统”这个题目后,第一反应是赶紧建SpringBoot项目、写CRUD。这是最常见的误区。管理系统的技术难度从来不在框架,而在于能不能把业务规则翻译成数据结构。我建议先花两三天时间把业务问清楚,再做任何代码。
1.1 一个养老中心每天到底在忙什么
养老中心的日常管理动作,拆开看就那么几类:
- 老人入住时登记档案,包括身份证、健康情况、家属联系方式、入住合同。
- 分配床位,床位要区分自理区、半护理区、全护理区,状态要能标识空置、占用、维修。
- 护理员每天按班次做护理记录,比如喂药、翻身、血压测量、康复训练,这些记录需要留痕。
- 财务每月生成费用账单,包含床位费、护理费、伙食费,还要记录缴费和欠费情况。
- 家属探访要登记来访时间、来访人信息,特别是疫情期间这类记录格外重要。
- 定时任务比如体检提醒、缴费到期提醒,需要系统主动推送。
把这六类动作摆出来,系统的模块边界就出来了:老人档案管理、床位管理、护理记录、缴费管理、访客管理、系统提醒。每个模块背后都有一张或几张表支撑。
1.2 模块与数据流:入住、护理、收费、探访四条主线
我梳理业务时通常画四条主流程,代码写起来就不会乱:
- 入住流:登记老人信息 → 健康初评 → 分配床位 → 生成护理计划。
- 护理流:护理员登录 → 查看责任区老人 → 填写当班护理记录 → 交班时汇总异常情况。
- 收费流:系统根据床位和护理等级生成月度账单 → 财务确认 → 记录缴费流水 → 欠费触发提醒。
- 探访流:家属到前台登记 → 探访记录关联老人档案 → 离院时签退。
这四条流有一个共同特点:都以老人档案为中心向外扩展。所以数据库设计时,elder表一定要先建好,其他表都通过elder_id关联回去。先把这个逻辑想清楚,后面写ServiceImpl的时候就顺了。
1.3 角色权限矩阵:先分清谁能看什么、能改什么
角色权限是这类系统里最容易被低估的部分。常见的角色至少包括:系统管理员、护理组长、护理员、前台、财务、家属端(查询专用)。权限矩阵长这样:
| 功能模块 | 管理员 | 护理组长 | 护理员 | 前台 | 财务 |
|---|---|---|---|---|---|
| 老人档案 | 全部增删改 | 查看/编辑 | 查看自己负责区 | 新增/查看 | 查看 |
| 护理记录 | 全部查看 | 本组增删改 | 自己的记录增改 | 只读 | 只读 |
| 床位管理 | 全部操作 | 查看/分配 | 只读 | 分配/登记 | 只读 |
| 缴费管理 | 全部操作 | 只读 | 无 | 登记 | 全部操作 |
| 访客登记 | 全部查看 | 只读 | 无 | 登记/查看 | 无 |
这个矩阵的意义不只是为了写权限校验代码,更关键的是指导数据库查询设计。比如护理员登录后只看到自己负责的床位和老人,那elder表里就需要一个nurse_id或者通过care_plan表关联,查询时多一个过滤条件。很多毕设系统败在这一步——权限按钮做了,但数据权限没做,登录之后就看到了全院的老人数据。
2. SpringBoot版本与项目骨架:版本选错,后面全是兼容性眼泪
业务梳理完,才进入SpringBoot的部分。这一节我要说一个很多人忽略的事实:SpringBoot版本选型比写接口更影响项目成败。
2.1 SpringBoot 2.7还是3.x:按教程生态来选,而不是按最新来选
现在IDEA新建项目时,Spring Initializr默认给的是SpringBoot 3.x。网上大量教程、老项目代码还停留在2.x,于是你会遇到一堆诡异问题:javax.servlet找不到、旧版Shiro配置失效、某个依赖没有Jakarta版本。不是说3.x不好,而是如果以完成一个管理系统为目标,选一个生态匹配的组合更重要。
我个人的推荐是两个组合:
- 稳妥组合:JDK 8 + SpringBoot 2.7.18,网上教程覆盖率最高,遇到问题一搜就有答案。
- 新一点组合:JDK 17 + SpringBoot 3.2.x,顺手熟悉新特性,但遇到老代码要自己做兼容迁移。
从纯教学和毕设角度看,我建议用2.7.18。它属于2.x的最后一个维护版本,修复了很多问题,学习成本最低。如果你导师要求用新版本,那再上3.x。版本确定后,pom.xml里parent坐标写死,千万不要用SpringBoot的SNAPSHOT版本。
2.2 初始化项目:从Spring Initializr到阿里云镜像的细节
创建项目时,start.spring.io在国内访问偶尔很慢。你可以切换阿里云提供的初始化地址:https://start.aliyun.com,里面同样提供SpringBoot项目模板,选择依赖、生成工程压缩包,体验基本一致。下载完解压后用IDEA打开,先别急着写代码,把Maven镜像配置好。
Maven的settings.xml里加阿里云镜像:
<mirror> <id>aliyunmaven</id> <mirrorOf>central</mirrorOf> <name>阿里云公共仓库</name> <url>https://maven.aliyun.com/repository/public</url> </mirror>这一步能省下大量等依赖的时间。再设置IDEA的Maven Runner选项,把JVM参数设为-Dfile.encoding=UTF-8,避免控制台中文乱码。这类“环境小细节”不会出现在教程里,但实际开发时几乎每个人都踩过。
2.3 目录结构约定与application.yml里的小重点
项目骨架我习惯按业务模块分包,而不是按三层架构硬分。目录长这样:
com.example.eldercare ├── common # 统一返回结果、通用工具类 ├── config # MyBatis-Plus配置、拦截器配置、跨域配置 ├── controller # 接口层 ├── entity # 数据库实体 ├── mapper # MyBatis-Plus Mapper接口 ├── service # 业务层接口与实现 ├── dto # 前端交互对象,比如登录参数、查询条件封装 ├── exception # 自定义异常与全局异常处理器 └── ElderCareApplication.java实体类不要和DTO混用。很多新手直接把entity丢给前端返回,其实会让接口暴露多余字段,比如身份证号、家庭住址。系统里我建议entity负责数据表映射,DTO负责接口出入参,两条线分开。
application.yml里几个容易漏的配置:MySQL连接要带上时区参数,否则日期时间差8小时;MyBatis-Plus的日志要打开,开发阶段能直接看到SQL;上传文件大小限制要按需求调整,默认1MB往往不够用。
spring: datasource: url: jdbc:mysql://localhost:3306/eldercare?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 123456 servlet: multipart: max-file-size: 10MB max-request-size: 20MB mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl map-underscore-to-camel-case: true server: port: 8080如果你用的是SpringBoot 3.x + MyBatis-Plus 3.5.x,数据源要引入的是mybatis-plus-spring-boot3-starter而不是老的mybatis-plus-boot-starter,这个细节卡了我一个下午,后面排错章节会展开讲。
3. 数据库表设计与持久层选型:把业务翻译成表,再把表翻译成代码
业务理清了,SpringBoot骨架搭好了,接下来是最核心的一步:设计表结构。这一步做得扎实,写接口就是翻译工作;做得糙,后面每个查询都痛苦。
3.1 九张核心表:字段怎么定、外键怎么关联
我最终落地了九张表:elder(老人档案)、bed(床位)、room(房间)、care_record(护理记录)、employee(员工)、user(登录账号)、payment(缴费流水)、visitor(访客记录)、notice(系统提醒)。核心表的建表要点如下:
CREATE TABLE elder ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, id_card VARCHAR(18) NOT NULL COMMENT '身份证号,加密存储', gender TINYINT, birth_date DATE, health_level TINYINT COMMENT '1自理 2半自理 3全护理', bed_id BIGINT, family_phone VARCHAR(20), emergency_contact VARCHAR(50), emergency_phone VARCHAR(20), entry_date DATE, status TINYINT COMMENT '1在住 2退住 3请假外出', create_time DATETIME, update_time DATETIME );注意几点:身份证号在页面展示时需要脱敏,所以数据库里存完整值,后端返回时统一处理,这也印证了DTO和entity分离的必要性;bed_id不建物理外键,只建索引,因为养老中心会有老人退住、换床、临时外出,物理外键在更新时容易锁表;health_level这个字段直接影响后续费用计算和护理计划,属于高频查询条件,一定要建索引。
护理记录表care_record是数据量增长最快的表,每天每个护理员都要写多条。设计时把多个老人批量记录的公共信息放进employee_care_batch表,单独老人每一条再进明细表,这样既能一次性交班,又不丢数据。不过毕设系统为了简化,可以只做明细表,但字段必须包含nurse_id、care_type、care_content、record_time。
3.2 选MyBatis-Plus而不是JPA或JDBC的原因
ORM选型上,我的观点很明确:这类管理系统用MyBatis-Plus,不要用JPA,更不要用原生JDBC。三者的区别我用一个表格说清楚:
| 维度 | MyBatis-Plus | Spring Data JPA | 原生JDBC |
|---|---|---|---|
| 单表CRUD | 继承BaseMapper即可 | 继承JpaRepository即可 | 手写大量模板代码 |
| 复杂SQL | XML或注解写原生SQL | JPQL/自定义查询较绕 | 灵活但代码量大 |
| 学习成本 | 低 | 中,特别是懒加载和会话管理 | 低 |
| 分页 | 分页插件,一行代码 | Pageable | 手动拼LIMIT |
| 适合场景 | 业务系统、CRUD为主 | 领域模型复杂、一对多场景多 | 追求极致SQL控制 |
JPA在权限系统、实体关系复杂的场景下很强大,但在这类业务系统的简单查询面前,反而是MyBatis-Plus的LambdaQueryWrapper最顺手。原生JDBC则要写太多样板代码,不是不可以,是没必要在毕设阶段折磨自己。
3.3 组合查询与分页:一个能直接抄的老人列表接口
以老人列表接口为例,我给它定的需求是:支持按姓名模糊查询、按健康等级筛选、按在院状态筛选、分页返回床位号。用MyBatis-Plus实现很简单。
先配置分页插件,这是很多人最容易漏的一步。没配分页插件,Page对象返回的total是0、记录数还是全量,这个坑后面还会细说。配置代码:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }然后Service层里用LambdaQueryWrapper拼条件:
@Override public Page<ElderVO> pageElder(ElderQueryDTO query) { Page<Elder> page = new Page<>(query.getPageNum(), query.getPageSize()); LambdaQueryWrapper<Elder> wrapper = Wrappers.<Elder>lambdaQuery() .like(StringUtils.hasText(query.getName()), Elder::getName, query.getName()) .eq(query.getHealthLevel() != null, Elder::getHealthLevel, query.getHealthLevel()) .eq(query.getStatus() != null, Elder::getStatus, query.getStatus()) .orderByDesc(Elder::getCreateTime); Page<Elder> result = elderMapper.selectPage(page, wrapper); // 这里把Entity转成VO,填充床位号、家属脱敏信息 return convertToVO(result); }注意eq和like第一个参数是boolean类型条件,条件成立才拼进SQL。这个写法比手写if判断干净得多,我用下来最大的体感是:接口里再也不需要一堆StringUtils.isEmpty的if分支了。
4. 核心功能的落地与取舍:权限、定时任务、文件上传
骨架和数据层就绪后,开始填业务功能。这一节讲三个绕不开的功能点,每一个都有明确的取舍逻辑。
4.1 登录与权限:不迷信Spring Security,用拦截器先跑通
很多教程一上来就要求用Spring Security + JWT。但Spring Security的过滤器链、UserDetailsService、认证管理器,每一个概念都能让新手卡一周,而且多数配置代码是在背答案。对于单体管理后台,我更推荐先用拦截器加Token的方式跑通,接口权限用自定义注解标记,后面真有需求再平滑切换。
核心思路:用户登录时校验账号密码,成功后生成一个随机Token保存到Redis(或内存ConcurrentHashMap),并把用户ID和角色放进Token关联的缓存里。每次请求带着Token,拦截器从Redis取出用户信息,判断角色是否匹配接口要求的权限。
一个简化版的拦截器核心逻辑:
@Component public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token = request.getHeader("Authorization"); if (!StringUtils.hasText(token)) { throw new BusinessException(401, "未登录"); } LoginUser user = tokenService.getUserByToken(token); if (user == null) { throw new BusinessException(401, "登录已过期"); } // 把user放到ThreadLocal,后续Service层可以直接取 UserContext.set(user); return true; } }然后给需要权限的接口加上自定义注解,比如@RequireRole("admin"),在拦截器里读取HandlerMethod上的注解做角色校验。这套方案代码量大概二三百行,但每一步都是自己写的,出了问题也很好改。如果非上Spring Security不可,建议等主体功能全部跑通后再替换,而不是一开始就引入那套复杂配置。
4.2 定时任务:体检提醒和费用到期,用@Scheduled实现
养老中心每天都有固定动作:早上提醒护理员给特定老人测血压,月底提醒缴费。这两个需求用SpringBoot的@Scheduled就能解决。
在启动类上加@EnableScheduling,然后在Service里写定时方法:
@Component public class HealthRemindTask { @Scheduled(cron = "0 0 8 * * ?") public void remindBloodPressure() { List<Elder> elders = elderMapper.selectList( Wrappers.<Elder>lambdaQuery().eq(Elder::getHealthLevel, 3)); // 插入提醒记录到notice表,或者推送消息给对应护理员 } }cron表达式用的是Spring的6位格式,网上很多五位的cron是Quartz格式,直接抄过来会报错。这个点最容易踩。再提醒一句,定时任务方法里要包try-catch,哪怕执行失败也要记录日志,否则任务异常会导致整个调度线程停止,后面的任务全部不执行。
费用到期提醒的逻辑类似:查询payment表里本月未缴费的记录,如果有,往notice表插入提醒数据,同时把待办数量放进首页统计接口。这类消息性提醒,不要直接发短信或微信推送,先做系统内通知闭环,后续接入第三方渠道再替换实现类。
4.3 文件上传:老人照片存储的路径与访问策略
老人档案需要上传证件照和健康报告,这是一块看似简单实则有坑的功能。我采用的方案是存本地磁盘加静态映射,而不是直接存数据库。数据库只存文件路径字符串,好处是上传下载速度快、数据库体积可控。
上传接口的处理逻辑:
@PostMapping("/file/upload") public Result<String> upload(@RequestParam("file") MultipartFile file) { if (file.isEmpty()) { throw new BusinessException(400, "文件不能为空"); } String originalFilename = file.getOriginalFilename(); String ext = originalFilename.substring(originalFilename.lastIndexOf(".")); String filename = UUID.randomUUID() + ext; String datePath = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd")); Path dir = Paths.get(uploadDir, datePath); Files.createDirectories(dir); file.transferTo(dir.resolve(filename).toFile()); return Result.success("/files/" + datePath + "/" + filename); }文件名一定要用UUID重命名,不然用户上传的“张三.jpg”会覆盖同名文件,而且中文文件名在跨平台部署时容易乱码。目录按日期分一层,方便后续清理和归档。
配置类里注册静态资源映射,把/files/路径映射到磁盘目录:
@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/files/**") .addResourceLocations("file:" + uploadDir + "/"); } }注意addResourceLocations最后要带斜杠,否则映射不生效。部署到Linux服务器后,uploadDir目录要手动创建并给权限,很多系统上线后图片加载不出来,查半天发现是目录不存在。
5. 开发中途踩过的三个坑:从报错到定位再到修复
这部分是我最想写的内容。这三个坑不冷门,每个都在开发过程中真实发生过,我把完整的排查链路写出来,希望你能跳过。
5.1 SpringBoot 3.x的jakarta迁移坑:NoClassDefFoundError到根因定位
现象:我从Spring Initializr默认创建了一个3.2.x项目,然后参考老教程在Controller里写import javax.servlet.http.HttpServletRequest,一启动直接报NoClassDefFoundError。
排查链路:先看控制台异常栈,发现报错来自tomcat的servlet相关类。我第一反应是缺少依赖,去pom里加了servlet-api,结果还是不行。后来完整阅读异常信息,发现类名是jakarta.servlet.http.HttpServletRequest——SpringBoot 3.x整体迁移到了Jakarta命名空间,javax前缀在Tomcat 10阶段就被移除了。
根因:SpringBoot 3.x基于Jakarta EE 9,老代码的javax.包全部需要改为jakarta.。
修复:把所有javax.servlet、javax.validation等包名批量替换为jakarta.servlet、jakarta.validation。IDEA里直接用全局替换,注意pom里老版本的spring-boot-starter-validation也要确认是3.x版本。
反思:选版本前先查一下自己看的教程是哪个SpringBoot版本。版本差异最折磨人的不是API不同,而是同样的功能在不同版本里实现类都不一样。
5.2 MyBatis-Plus分页插件没配置:total永远为0,记录全量返回
现象:我写完分页查询接口,前端传入pageNum=1, pageSize=10,结果返回的total是0,列表却把全表数据都返回了。
排查链路:先用Postman调接口,确认入参没问题。然后看控制台日志,发现执行的SQL不是SELECT * FROM elder LIMIT 10,而是无条件全表查询。这就很奇怪了,因为LambdaQueryWrapper明明有分页参数。
随后检查MyBatis-Plus版本,是3.5.3,然后翻看官方文档,发现3.5.x以后PaginationInnerInterceptor必须手动注入才能生效。如果没注入,selectPage不会拼接LIMIT语句。
根因:MyBatis-Plus的分页Interceptor是可选组件,不配置等于没有分页能力,但它不会报错,只会在返回数据时静默失效,数据库全表扫描。
修复:补上MybatisPlusConfig里PaginationInnerInterceptor的配置,重启后再看SQL,变成SELECT ... LIMIT 10了,total也正常。
这个坑隐蔽的地方在于,不报错、不明显,你只会觉得数据哪里不对。我把“分页total异常先查分页插件”这条记进了自己的排查清单。
5.3 LocalDateTime序列化格式本地正常、线上显示T分隔符
现象:老人档案的createTime字段,在本地环境接口返回的是“2025-01-15 10:00:00”,部署到服务器后变成“2025-01-15T10:00:00”,前端直接展示时多了一个字母T。
排查链路:第一反应是服务器JDK版本不同导致,检查后发现两边都是JDK17,排除。接着怀疑是Jackson版本差异,但本地和线上用的是同一个jar包。最后怀疑是环境变量或配置文件不一致,比对application.yml也没发现差异。
后来想起来,本地IDEA里我加过全局的日期格式配置,而线上打包时那个配置类所在的包被某次提交误删了。检查git历史后发现,某次重构把GlobalJacksonConfig删除但本地IDEA还留着编译文件的缓存,所以本地一切正常,线上暴露问题。
根因:LocalDateTime被Jackson默认序列化为ISO-8601格式,即带T的格式。要让所有接口统一返回“yyyy-MM-dd HH:mm:ss”,需要显式配置ObjectMapper。
修复:写一个全局Jackson配置类:
@Configuration public class JacksonConfig { @Bean public Jackson2ObjectMapperBuilderCustomizer jsonCustomizer() { return builder -> { builder.simpleDateFormat("yyyy-MM-dd HH:mm:ss"); builder.serializers(new LocalDateTimeSerializer( DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); builder.deserializers(new LocalDateTimeDeserializer( DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); }; } }教训是:线上的行为才是真实行为,本地IDE把自己的问题掩盖了。遇到“本地正常线上异常”,先查两者差异,不要凭感觉猜。
6. 部署与验收:从maven打包到Docker跑起来,再检查一遍功能闭环
功能全部跑通之后,剩下的就是部署和验收。这一节不讲太多高级运维,只讲把系统真正跑起来的完整链路。
6.1 打包、Dockerfile与docker-compose一键启动
首先确保代码可以在本地全量跑通,然后执行maven打包命令:
mvn clean package -DskipTeststarget目录下会生成一个可执行的jar包。接下来写一个简单的Dockerfile,用多阶段构建减小镜像体积:
FROM maven:3.9-eclipse-temurin-8 AS build WORKDIR /app COPY pom.xml . RUN mvn dependency:go-offline COPY src ./src RUN mvn clean package -DskipTests FROM eclipse-temurin:8-jre WORKDIR /app COPY --from=build /app/target/eldercare.jar app.jar EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"]然后用docker-compose把MySQL和SpringBoot应用一起拉起来:
version: "3" services: mysql: image: mysql:8 environment: MYSQL_ROOT_PASSWORD: root MYSQL_DATABASE: eldercare ports: - "3306:3306" volumes: - mysql-data:/var/lib/mysql app: build: . depends_on: - mysql environment: SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/eldercare?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai ports: - "8080:8080" volumes: mysql-data:docker-compose起来后,宿主机的8080端口就是系统入口。服务器上如果用宝塔面板管理,也只需要在Docker管理器里指定镜像和端口映射,其他逻辑一样。
要特别提醒:数据库初始化不要依赖开发环境的连接信息。我习惯在Dockerfile里加一个init SQL脚本,SpringBoot启动时通过spring.sql.init配置自动执行建表语句,这样部署一个新环境不需要手动导sql文件。
6.2 功能验收清单:正常流程和异常流程都要测
部署完不能立刻说“完成”,我每次都会跑一遍验收清单。除了正常业务流,还要专门测异常输入,因为答辩时老师最喜欢问的就是“如果传了非法参数会怎样”。
| 测试类型 | 测试项 | 预期结果 |
|---|---|---|
| 正常流 | 新增老人→分配床位→写护理记录→生成账单→缴费 | 全链路数据贯通 |
| 正常流 | 护理员登录后只看得到自己负责区老人 | 数据权限生效 |
| 异常流 | 不登录直接访问业务接口 | 返回401,不返回数据 |
| 异常流 | 老人重复身份证号录入 | 提示已存在,不产生脏数据 |
| 异常流 | 上传超过10MB的图片 | 返回清晰错误提示 |
| 异常流 | 分页页码传0或负数 | 返回第一页,不抛异常 |
这类问题用统一异常处理器兜底,Controller里不写try-catch,会干净很多。
6.3 给答辩或验收的建议:讲业务设计,别只讲CRUD
最后聊点评审视角的事。系统跑通只是及格,高分的关键在于你能讲清楚“为什么这样设计”。
从第一个页面到数据流,我建议的讲述逻辑是:先讲清楚养老中心的业务痛点和四条主线,然后讲角色权限矩阵如何影响表结构设计,再演示一两个组合查询来解决真实业务问题,最后用异常处理的演示收尾。中间如果被问到某个表为什么没有外键,不用慌,诚实回答“业务上有退住、换床场景,物理外键会限制灵活性,所以通过索引和应用层保证一致性”——这就是真实系统里的常见取舍,比背标准答案有用得多。
另外,准备一两个曾经踩过的坑和排查思路。评审不要求你的项目完美,但要求项目是你自己写出来的。能讲清楚一个bug从出现到定位到修复的过程,比堆十张截图更能证明你的实际动手能力。