☰
纯后端医院医生系统项目实战:从表结构设计到接口权限管理
2026/9/30 10:32:30 网站建设 项目流程

1. 项目概述与核心难点拆解

医院医生系统,听名字很普通,但“纯后端基础小项目”这七个字才是真正的关键词。很多人刚开始做项目,一上来就想着前端页面搞得花里胡哨,结果后端逻辑一塌糊涂。这个项目的定位恰恰相反——它是一块给后端基础能力“过筛子”的试金石,适合那些想把接口设计、数据库建模、业务逻辑分层这些底子打牢的同学。

先说清楚这个系统是干什么的。简单来说,医院需要管理医生信息,比如医生属于哪个科室、什么职称、擅长什么方向、排班情况如何,还需要支持管理员对医生信息做增删改查,患者端查询医生列表、查看医生详情、按科室筛选等。这是典型的后端管理系统,不涉及复杂的挂号流程、支付系统、电子病历这些进阶业务,所以非常适合作为纯后端练手项目。

那为什么强调“纯后端”?因为这里隐含了一个很关键的认知:一个后端初学者最大的幻觉是“我会写接口就等于会做后端”。实际上,纯后端项目对你的考验远超想象。你得多表设计、你得处理事务、你得考虑接口幂等性、你得做参数校验和异常处理、你得考虑查询性能,而这些全部是在没有前端页面帮你“兜底”的情况下完成的。前端能帮你看清数据长什么样,但它也会掩盖你后端设计上的漏洞。纯后端意味着你只能靠接口文档、日志和数据库里的数据来验证自己写得对不对——这个难度比想象中的高。

同时,这个项目的业务场景非常成熟且标准化。医院系统是典型的SaaS化、权限分级化的业务模型,医生的科室归属、职称体系、排班规则都是非常规范的数据结构。做这样一个项目,你获得的不仅仅是“会写CRUD”的技术点,更重要的是对医疗信息化这一条庞大产业链中最小却是最通用单元的认知。你做的每一个表、每一个接口,将来往大处说都可以直接平滑迁移到更复杂的HIS(医院信息系统)或RIS(放射信息系统)中。

我在实际带新人的过程中发现,一个能把这个项目写得稳、写得全、写得像个正经系统的人,后面去做企业级项目基本不会差太远。原因很简单:他的思维已经完成了从“面向页面编程”到“面向数据编程”的转换。

2. 表结构设计:把业务问题翻译成数据模型的能力

2.1 数据库选型与设计思路

这个项目我们用MySQL,理由很简单:医疗系统在国内的普及率最高、生态最成熟、招聘市场最认这个。版本建议用5.7以上或8.0,字符集用utf8mb4,排序规则用utf8mb4_general_ci。为什么不用utf8?因为utf8mb4才能完整支持一些生僻字和特殊符号,医院系统里患者或医生的名字偶尔会用到生僻字,这个坑我踩过,导航软件上遇到过“䶮”字,数据库varchar字段差点存不进去。

表的设计是纯后端项目的灵魂。你前期花多少精力在这里,后期写业务逻辑就有多轻松。我先给你看整体设计的核心逻辑:

  • 医生基本信息表(doctor):存放医生的姓名、性别、出生日期、联系方式、职称、简介等基础信息。
  • 科室表(department):存放科室名称、楼层位置、科室介绍。
  • 医生-科室关联表(doctor_department):处理多对多关系,一位医生可以挂多个科室,一个科室有多个医生。
  • 排班表(schedule):记录医生在某一天某个时间段的出诊安排。
  • 管理员表(admin):管理后台登录账号。

这里有一个需要重点思考的设计决策:医生和科室的关系是多对多还是一对多?很多初学者会拍脑袋说“一个医生当然属于一个科室啊”。但真实医院的场景是,一个心内科的医生可能同时兼顾特需门诊和普通门诊,甚至跨科室出诊。所以在表结构设计上,我给你两个方案,你根据实际场景取舍:

方案A:一对多。在doctor表里直接加一列department_id,省事但后期扩展性差。

方案B:多对多。拆出一张中间表doctor_department,多一次关联查询,但灵活性和扩展性高。

我个人建议,即使你刚开始做的是“基础小项目”,也选择方案B。原因很简单,你要在项目里展示的不仅仅是你“会建表”,而是你“理解业务关系”。多对多的设计能让你在写SQL时体会到关联查询的精髓,这是你后期进阶的必经之路。

2.2 关键字段的类型选型与细节

在设计表结构时,有几个字段需要特别注意:

  • 手机号(phone):不要用int,用varchar(20)。很多人会用int,但手机号如果涉及到前导零(虽然国内很少),或者你考虑到将来可能存储座机号码,int就会出现精度丢失的问题。更关键的是,如果你用int存手机号,前端展示时可能会遇到格式问题。
  • 出生日期(birth_date):用date,不要用datetime。日期和时间的语义不同,混用会导致查询统计时出现脏数据。
  • 性别(gender):用tinyint(1),0表示未知,1表示男,2表示女。千万不要用char或varchar存“男”“女”这种汉字,一是浪费存储,二是查询效率低,三是容易出错。这是后端设计里非常基础但很多新手会犯的错。
  • 状态(status):统一用tinyint(1),0表示禁用,1表示正常。这个字段几乎每张表都需要,用于软删除和状态控制。
  • 创建时间和更新时间(create_time、update_time):用datetime,默认值设为CURRENT_TIMESTAMP。这两个字段是审计的必要条件,纯后端项目如果连这些字段都没有,接口排查问题时你会痛苦死。

这里我还想补充一个很多新手忽略的操作:统一给每张表加上逻辑外键而不是物理外键。虽然你在数据库层面不建立真正的FOREIGN KEY约束,但在代码层面通过查询逻辑来保证关联数据的一致性。为什么?因为物理外键在高并发或分库分表的场景下会严重影响性能和扩展性,大型互联网公司普遍不用物理外键。在纯后端练手项目中,你提前养成这个习惯,远比写“好学生式”的代码更有价值。

最后把建表脚本贴出来(关键部分):

CREATE TABLE `department` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `name` varchar(50) NOT NULL COMMENT '科室名称', `location` varchar(100) DEFAULT NULL COMMENT '位置信息', `introduction` varchar(500) DEFAULT NULL COMMENT '科室介绍', `status` tinyint(1) DEFAULT '1' COMMENT '状态:0禁用 1启用', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='科室信息表'; CREATE TABLE `doctor` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `name` varchar(50) NOT NULL COMMENT '医生姓名', `gender` tinyint(1) DEFAULT '0' COMMENT '性别:0未知 1男 2女', `birth_date` date DEFAULT NULL COMMENT '出生日期', `phone` varchar(20) DEFAULT NULL COMMENT '联系电话', `title` varchar(20) DEFAULT NULL COMMENT '职称:住院医师/主治医师/副主任医师/主任医师', `specialty` varchar(200) DEFAULT NULL COMMENT '擅长方向', `introduction` text COMMENT '个人简介', `avatar_url` varchar(200) DEFAULT NULL COMMENT '头像地址', `status` tinyint(1) DEFAULT '1', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_name` (`name`), KEY `idx_title` (`title`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='医生基本信息表'; CREATE TABLE `doctor_department` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `doctor_id` bigint(20) NOT NULL, `department_id` bigint(20) NOT NULL, PRIMARY KEY (`id`), KEY `idx_doctor_id` (`doctor_id`), KEY `idx_department_id` (`department_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='医生科室关联表'; CREATE TABLE `schedule` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `doctor_id` bigint(20) NOT NULL, `work_date` date NOT NULL COMMENT '出诊日期', `work_time` varchar(20) DEFAULT NULL COMMENT '时间段:上午/下午/晚上', `maximum` int(11) DEFAULT '20' COMMENT '最大预约人数', `reserved` int(11) DEFAULT '0' COMMENT '已预约人数', `status` tinyint(1) DEFAULT '1', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_doctor_date` (`doctor_id`, `work_date`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='医生排班表';

注意我在doctor表上建了idx_name和idx_title两个索引。为什么?因为按姓名搜医生、按职称筛选医生是系统的最高频操作。小项目虽然数据量小,但好的设计习惯应该从第一天就养成。联合索引idx_doctor_date也非常重要,它保证“查某个医生某天的排班”这个操作走索引,避免全表扫描。

提示:索引不是越多越好,每个索引都会增加写操作的负担。只给高频查询字段加索引,这是最基本的取舍原则。

3. 接口设计与后端框架搭建

3.1 技术栈选型:为什么推荐Spring Boot + MyBatis Plus

纯后端项目最主流的组合就是Spring Boot + MyBatis Plus。Spring Boot解决了配置地狱问题,MyBatis Plus在MyBatis的基础上封装了单表CRUD操作,让开发效率提升一个档次。为什么不直接用MyBatis?因为它单表操作要写太多XML,很琐碎,而MyBatis Plus让你连XML都不用写就能完成80%的基本增删改查。但这不意味着你不懂SQL就行,恰恰相反, MyBatis Plus的QueryWrapper写复杂查询时,你需要对SQL的执行逻辑有清晰的理解,否则会出现SQL性能问题,比如全表扫描、隐式类型转换等。

接口设计上,我建议你遵循RESTful风格。路径用名词复数,动作交给HTTP方法。比如:

  • GET /api/doctors —— 查询医生列表(支持分页和条件筛选)
  • GET /api/doctors/{id} —— 查询医生详情
  • POST /api/doctors —— 新增医生
  • PUT /api/doctors/{id} —— 修改医生信息
  • DELETE /api/doctors/{id} —— 删除医生(逻辑删除)
  • GET /api/departments —— 查询科室列表
  • GET /api/doctors/department/{deptId} —— 按科室查医生
  • GET /api/schedules/doctor/{doctorId} —— 查医生排班
  • POST /api/schedules —— 新增排班

这里有几个设计上的细节需要你们注意一下。第一个是批量操作的接口要不要暴露?比如批量删除医生、批量排班。在真实业务里,这类批量接口需求很常见,但在练手项目中我建议你不要草率地暴露出来。因为批量接口涉及事务控制和数据一致性,一旦做得不严谨,反而暴露你的薄弱点。如果你非要写,一定要想清楚事务边界。

第二个是接口的返回格式要统一。纯后端项目没有一个统一响应体结构,你后期联调时会被疯狂吐槽。我在项目里用的统一响应类是Result ,字段包含code、message、data三个字段。code为200表示成功,400表示业务失败,500表示系统异常。这样前端(或调用方)只需要判断code是否为200即可,然后从data中取数据。这个设计极其简单又极其实用,每个纯后端项目都应该有。

3.2 核心业务接口:一个完整的“添加医生”请求是怎么走的

写接口不是为了“交差”,而是要让你真正理解一次完整的业务请求在系统中是如何流转的。我们拿最典型的“新增医生”来拆解。

第一步,接收参数。前端(或Postman)会发送一个POST请求到/api/doctors,请求体里是JSON格式的数据,包括name、gender、birthDate、phone、title、specialty、introduction、departmentIds(医生所属科室的ID数组)等。这里注意,departmentIds是一个数组,而不是单个值,因为前文说了医生和科室是多对多关系。

第二步,参数校验。这一步极其关键。很多初学者会忽略参数校验,直接拿参数去查询或插入数据库,然后等数据库报错才知道有问题。专业做法是用JSR 303的@Valid注解配合参数类中的校验注解,比如@NotBlank、@Size、@Pattern。姓名不能为空,手机号要符合正则表达式,出生日期不能晚于今天,这些都应该在进入service层之前就校验掉。

第三步,业务处理。Service层要做的事情有:检查手机号是否已被其他医生使用(唯一性校验)、生成创建时间(或依赖数据库默认值)、插入医生基础信息、根据departmentIds插入关联表记录。这里有一个事务控制的需求:插入医生信息和插入关联表记录必须在一个事务里,要么都成功,要么都失败,否则会出现医生存在但科室关系丢失的脏数据。

如下是我写的核心Service代码片段:

@Transactional(rollbackFor = Exception.class) public Long addDoctor(DoctorCreateRequest request) { // 1. 校验手机号唯一性 LambdaQueryWrapper<Doctor> wrapper = Wrappers.lambdaQuery(); wrapper.eq(Doctor::getPhone, request.getPhone()); if (this.count(wrapper) > 0) { throw new BizException("该手机号已被使用"); } // 2. 插入医生基础信息 Doctor doctor = new Doctor(); BeanUtils.copyProperties(request, doctor); this.save(doctor); // 3. 插入医生-科室关联记录 if (CollectionUtils.isNotEmpty(request.getDepartmentIds())) { List<DoctorDepartment> relations = request.getDepartmentIds().stream() .map(deptId -> new DoctorDepartment(doctor.getId(), deptId)) .collect(Collectors.toList()); doctorDepartmentMapper.insertBatchSomeColumn(relations); } return doctor.getId(); }

这段代码看起来平淡无奇,但它是整个项目里含金量最高的一块。因为你在一个方法里同时处理了主表插入、关联表插入、唯一性校验和事务控制。这就是纯后端项目里“业务闭环”的最小演示单元。

第四步,异常处理。如果校验失败或业务冲突,你需要抛出业务异常,由全局异常处理器统一捕获,转成Result.fail返回给调用方。千万不要让异常直接抛到Controller外层,否则调用方收到的是莫名其妙的500错误堆栈。

4. 查询列表的分页与条件筛选

4.1 分页设计:它是接口设计里最容易被忽略的“基本功”

分页查询几乎是所有后端项目的标配,但很多新手做出来的分页接口既不带条件又被大家疯狂吐槽。我们这里做一个像样的分页查询接口。

先看请求参数设计:pageNum(当前页,从1开始)、pageSize(每页条数)、keyword(可选,搜索姓名/擅长方向)、departmentId(可选,按科室筛选)、title(可选,按职称筛选)、status(可选,按状态筛选)。响应参数设计:total(总条数)、list(当前页数据)、pageNum、pageSize。为什么响应里要带上total?因为前端的表格组件和分页组件都需要total来计算总页数,只返回list前端没法做分页。

MyBatis Plus有一个自带的Page类,你只需要在Service层构建好查询条件,然后调用page()方法即可。但比较重要的一点是:条件构造器不能滥用。如果你什么都不传,不要拼一个“WHERE 1=1”这种无意义的条件,而是直接查全表并分页。如果有条件就动态拼接,这也是QueryWrapper的一大优势。

下面是我在项目里做的一个简洁示例:

public PageResult<DoctorVO> pageDoctors(DoctorPageQuery query) { // 构建分页对象 Page<Doctor> page = new Page<>(query.getPageNum(), query.getPageSize()); // 构建查询条件 LambdaQueryWrapper<Doctor> wrapper = Wrappers.lambdaQuery(); wrapper.eq(StringUtils.isNotBlank(query.getStatus()), Doctor::getStatus, query.getStatus()); wrapper.eq(query.getTitle() != null, Doctor::getTitle, query.getTitle()); wrapper.like(StringUtils.isNotBlank(query.getKeyword()), Doctor::getName, query.getKeyword()) .or(StringUtils.isNotBlank(query.getKeyword()), w -> w.like(Doctor::getSpecialty, query.getKeyword())); // 执行分页查询 Page<Doctor> result = this.page(page, wrapper); // 数据组装与返回 List<DoctorVO> records = result.getRecords().stream().map(doctor -> { DoctorVO vo = new DoctorVO(); BeanUtils.copyProperties(doctor, vo); // 嵌套查询科室信息 vo.setDepartmentNames(getDepartmentNamesByDoctorId(doctor.getId())); return vo; }).collect(Collectors.toList()); return PageResult.of(records, result.getTotal(), query.getPageNum(), query.getPageSize()); }

这个接口的亮点在于keyword的模糊搜索处理。我用or连接了名字和擅长方向两个字段的like查询,这样做到了“搜一个关键字,既能匹配医生姓名,又能匹配擅长方向”的效果。但你可能会踩一个坑:一旦加了or,它会打破前面条件的优先级。比如你想查“状态为正常的、姓名或擅长方向带眼的医生”,如果直接用or连接,结果就会把“不是正常状态但擅长方向带眼”的医生也查出来。正确的写法是用嵌套条件包裹or:

wrapper.eq(Doctor::getStatus, 1); wrapper.and(w -> w.like(Doctor::getName, query.getKeyword()) .or().like(Doctor::getSpecialty, query.getKeyword()));

注意这里的and()方法,它把姓名和擅长方向的or条件作为一个整体,和前面的status条件做AND连接。这种细节就是高手和新手的区别。

另一个隐藏的坑是查出来的医生列表需要返回科室信息。如果你的做法是foreach循环里一条一条查科室,会产生N+1查询问题。正确做法是先查出所有医生的ID列表,再用in查询批量查出所有关联科室,最后在内存中做组装。这个优化你可能在小数据量时感受不到区别,但数据量一上来,N+1查询会把数据库拖垮。

先批量查关联科室:

List<Long> doctorIds = records.stream().map(Doctor::getId).collect(Collectors.toList()); List<DoctorDepartment> relations = doctorDepartmentMapper.selectList( Wrappers.<DoctorDepartment>lambdaQuery().in(DoctorDepartment::getDoctorId, doctorIds) );

再批量查科室名称:

List<Long> deptIds = relations.stream().map(DoctorDepartment::getDepartmentId).distinct().collect(Collectors.toList()); List<Department> departments = departmentMapper.selectBatchIds(deptIds); Map<Long, String> deptIdNameMap = departments.stream() .collect(Collectors.toMap(Department::getId, Department::getName));

然后组装成Map<Long, List >,再set进VO。这一套流程做完,数据库的查询次数从N+1次降到了2次,接口性能直接上升一个量级。

4.2 详情接口:嵌套查询的正确打开方式

单个医生详情的接口,难点在于要一次性返回医生基本信息、所属科室列表、近期排班信息。很多同学写详情接口时喜欢分三个接口然后让前端分别请求,这是典型的“低内聚高耦合”设计。正确做法是一个聚合接口,把数据全部组装好返回。

我在这里踩过的坑是:排班信息可能没有,部门信息可能为空,VO组装时很容易出现空指针。所以你在写VO时,所有List类型的字段在初始化时要赋值为new ArrayList<>(),而不是null。这样前端拿到的永远是数组而不是null,大大减少了前端开发的负担。

更值得点赞的设计是:详情接口返回的数据结构应该比列表接口更丰富。列表接口只需展示医生基础信息+科室名称,详情接口则额外展示排班表、医生简介、擅长方向、最大挂号数等。这样既满足两种场景下不同的数据需求,又不会让列表接口因为加载太多无关数据而变慢。

5. 权限校验与安全管理

5.1 登录认证与Token机制

纯后端项目也需要考虑安全,你不能让任何人都能通过管理员的接口删除医生排班。引入一个简单的登录认证机制非常有必要。在这个项目里我用的是基于Token的认证方式,流程是这样的:

  • 管理员通过用户名密码登录。
  • 服务端验证通过后,生成一个Token(建议用UUID或JWT)。
  • Token存到Redis里,key为token,value为管理员ID,过期时间设为2小时。
  • 前端在后续请求的Header中带上Authorization: Bearer 。
  • 后端通过拦截器或AOP校验Token的合法性,获取当前管理员信息。

为什么要用Token而不用Session?这里要理解一个关键点:传统的Session机制是基于Cookie的,在有状态的服务集群里,Session同步是拓扑噩梦。而Token机制天然支持分布式部署和无状态认证。虽然小项目用Session也跑得通,但用Token能让你提前理解现代后端服务中认证的核心范式。这样说吧,你去任何一家公司面试后端,面试官大概率会问你“如果微服务架构下怎么保存登录状态”,你如果能把Token机制和Redis存储讲清楚,直接加分。

5.2 权限控制:角色与接口的对应关系

在这个项目里,权限模型不需要做得很复杂,但基本的分层必须有。至少要有管理员和普通操作员两个角色:

  • 管理员:拥有所有权限,包括医生信息的增删改、排班表的管理、管理员账号管理。
  • 操作员:只拥有查询权限和修改自己创建的信息的权限。

我的实现方式是整一个简单的注解@RequireRole("admin"),标注在需要管理员权限的Controller方法上。拦截器里读取Token中的角色信息,如果不是admin就返回403。这种方式比手写if判断要优雅得多,虽然它还很简陋,但足够让你理解“基于注解的权限控制”的思想。

这里要重点提醒你,千万不要把用户的角色信息和登录状态放到前端去控制。前端可以做UI上的隐藏和显示,但后端必须保证权限的最终验证,否则任何人都可以绕过前端直接调接口删数据。在做安全时,永远假设调用方是不可信的。

5.3 日志系统:出了事,靠数据还原现场

纯后端项目还有一个隐藏的提升点:操作日志。每次新增医生、修改排班、删除科室,都应该记录操作人、操作时间、操作类型、操作内容和IP地址。这是个“看不见的功能”,但它能让你在排查生产问题时游刃有余。在实际项目里,这个操作日志往往就是审计日志表(operation_log),有时候还要做全量数据变更记录。

可以用Spring AOP统一记录。定义一个@OperationLog注解,在需要记录日志的方法上标注,然后通过Aspect切面包裹方法的执行,记录入参和出参。这样的好处是日志逻辑和业务逻辑完全解耦,代码干净清爽。

> 注意:接口日志涉及隐私数据(如手机号)时,不要原样打印,做一下脱敏处理。这个习惯从现在就要养成。

6. 项目部署与自测建议

6.1 本地环境准备

后端项目写完,你还需要来一次完整的“本地自测闭环”。本地环境至少需要准备:

  • JDK 8或11 (不要用JDK 17太新的版本,避免一些三方框架兼容性问题)
  • Maven 3.6+
  • MySQL 5.7+
  • Redis(用于Token存储)
  • Postman或Apifox(接口测试工具)

启动顺序有讲究:先MySQL,再Redis,再Spring Boot应用。这个顺序能帮你快速分辨环境问题还是代码问题。

6.2 自测用例:一份“能打”的后端自测清单

很多新手写完接口后用Postman随便测几个就交差了,大错特错。一份合格的自测清单应该覆盖正常路径和异常路径:

正常路径场景:

  • 新增医生成功,数据库中出现对应记录。
  • 查询医生列表,返回分页数据且多条件筛选正确。
  • 修改医生信息成功,数据更新。
  • 逻辑删除医生后,列表不再展示该医生,但数据库数据保留。
  • 给医生新增排班成功,排班日期不能冲突。

异常路径场景:

  • 新增医生时手机号重复,提示业务错误,不插入任何数据。
  • 新增医生时科室ID不存在,提示参数错误,不插入关联记录。
  • 分页查询pageNum传0或负数,统一处理为默认第1页。
  • 未登录时访问受保护的接口,返回401。
  • 权限不足时访问管理员接口,返回403。
  • 修改一个不存在的医生ID,返回404。

我见过太多人只测正常路径,项目表面上是好的,一到面试官现场加需求或者故意捣乱就崩,说到底就是异常处理意识不够。把上面这份清单完整跑一遍,你的项目质感立刻不一样。

6.3 一个典型的项目本地自测过程

下面我用实际操作来演示排班查询功能的接口自测过程。

先看排班表结构,我有doctor_id、work_date、work_time、maximum、reserved这些字段。现在需求是:查某个医生某一天的排班情况。调用GET /api/schedules/doctor/1?date=2024-06-01,预期返回该医生当天的上下午排班列表。

实际接口逻辑是:先根据doctorId和workDate查询schedule表,再将医生姓名、头像、职称等信息组装进去。我用Postman调用后,返回的结果如下:

{ "code": 200, "message": "success", "data": [ { "id": 1, "doctorId": 1, "doctorName": "张伟", "title": "主任医师", "workDate": "2024-06-01", "workTime": "上午", "maximum": 30, "reserved": 8 }, { "id": 2, "doctorId": 1, "doctorName": "张伟", "title": "主任医师", "workDate": "2024-06-01", "workTime": "下午", "maximum": 20, "reserved": 20 } ] }

注意“下午”这个排班的reserved等于maximum,也就是20/20,已满。前端拿到这个状态后应该显示“约满”。在数据库里我没有加“是否约满”字段,但业务规则是可以通过比较reserved和maximum计算出来的。这种不冗余的数据库设计才是规范的。

如果你在自测时发现返回结果慢了,排查思路怎么做?第一步,确认SQL执行情况,在application.yml里配置mybatis-plus的日志输出,看打印的SQL是否有全表扫描。第二步,看是不是嵌套查询导致的N+1。第三步,看是不是关联条件缺少索引。用这套思路,90%的性能问题都能定位。

7. 常见问题与排查技巧实录

7.1 数据库层面的三个高频坑

第一个坑:时区问题。你连MySQL后查询数据,返回的时间比实际时间早8小时或晚8小时。原因一般是JDBC连接串里没配置serverTimezone=Asia/Shanghai,或者MySQL默认时区是UTC。解决办法:

spring.datasource.url=jdbc:mysql://localhost:3306/hospital?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai

第二个坑:字段名冲突。如果你在表中用了和MySQL关键字同名的字段名(比如order、desc),写SQL时就会莫名报错。解决办法:尽量避免,如果真是历史遗留,SQL中加反引号。

第三个坑:字符串与数字的隐式类型转换。查询时条件字段是varchar但传入的是数字,MySQL会做类型转换,导致索引失效,查询变慢。比如phone字段是varchar,但你写eq(Doctor::getPhone, 13800138000),MyBatis Plus会把它当成数值类型,SQL里就变成phone = 13800138000,不走索引了。一定要传字符串形式。

7.2 事务失效的三个经典场景

事务是后端开发最容易踩坑的点,而且它的坑还特别隐蔽。以下三种场景,几乎每个新人都会碰到。

场景一:事务方法被同类调用。比如Service里有方法A调用方法B,B加了@Transactional注解,但A没有。因为Spring的事务是基于AOP代理的,当A调用B时,实际是通过this调用,不会走代理,所以@Transactional直接失效。解决办法:把私有方法提取到另一个Service,或者通过AopContext.currentProxy()获取代理对象。

场景二:rollbackFor设置不当。@Transactional默认只对RuntimeException回滚,对CheckedException不生效。所以如果你只写@Transactional而没有指定rollbackFor = Exception.class,当业务抛出Exception(比如IOException)时事务不会回滚,数据就静默地写到一半留下了。这是我最常给新人纠正的问题。

场景三:数据库引擎不是InnoDB。MySQL的MyISAM引擎不支持事务,你写了@Transactional也白搭。好在这个问题在MySQL 8.0里基本不存在,默认就是InnoDB。

7.3 接口排查方法论:500错误怎么查

遇到“接口500了”,不要慌张地乱改代码。我的排查顺序是:

第一步看堆栈日志。控制台刷出来的第一行异常信息包含了类型和出错代码行,大部分时候直接定位到具体方法。

第二步看参数传递。如果是参数校验报错(MethodArgumentNotValidException),说明请求参数格式不对,去Postman里检查JSON格式和字段名是不是对上了。

第三步看SQL日志。如果核心SQL报错,把打印出来的SQL复制到Navicat里直接执行,看数据库报什么错。八成问题出在表名/字段名写错、字段类型不匹配、非空字段传了null。

第四步看事务是否回滚。如果碰到数据写了一半的情况,需要查看日志里的“Rolling back”字样,确认事务是否失效。

这套排查路径我已经用了很多年,效率很高。核心思想是:由外到内,先看参数再看得方法,先看SQL再看业务逻辑。

8. 写在最后:这个项目还能怎么延伸

医院医生系统做完了,你的纯后端基本功算是过了一遍。但同一条路上,还有很多可以由浅入深延伸的方向。

第一个方向是业务复杂度升级。给医生表加一个“预约挂号”功能,你就得考虑患者表、挂号记录表、号源扣减的并发控制——这立刻把项目从“基础CRUD”提升到了“高并发处理”。很多人在这一步会卡住,因为并发控制需要理解乐观锁和悲观锁,还需要Redis的原子操作。但如果你做过这个医院医生系统,往这个方向走是顺理成章的。

第二个方向是引入缓存。当前你每次查询医生列表都要查数据库,如果加一个Redis缓存,一来锻炼你的缓存策略设计,二来让接口响应时间从几十毫秒降到几毫秒。虽然你在本地感觉不明显,但这是一个好项目“工业化”的重要一步。

第三个方向是系统监控。给项目接入简单的健康检查接口和错误率统计,用Actuator或者自己写一个拦截器统计接口耗时。这不算难,但对你的运维思维提升巨大——你在实际工作中的第一课往往是“如何发现问题”,而不是“如何写功能”。

最后再分享一点我自己的感受:做一个“小项目”不难,但要把它做到“完整规范、经得起追问”,一点都不容易。医院医生系统恰恰是那种不大不小、边界清晰、适合反复打磨的项目。把它的每一个接口都写到位,把每一处边界情况都想清楚,你收获的远不只是一个项目,而是“像做产品一样做后端”的工作习惯。这个习惯,才是带你往上走的真正台阶。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询