动物园管理系统,听起来像是个课程设计或者毕业设计的题目,但真在行业里做过你就知道,这东西远比想象中复杂。它表面上是对着动物档案做增删改查,实际上要同时搞定饲养流程、库存管理、票务销售、人员排班,甚至还有游客高峰期检票这种高并发场景。
我去年完整落地过一套这样的系统,从数据库设计到后端接口,再到前端页面、部署上线,走了不少弯路,也总结了不少可以直接复用的经验。这篇博文就把整个项目的核心设计和实操过程摊开来讲,重点说清楚每个模块为什么这么设计、表结构怎么搭建、代码怎么组织、上线后又踩了哪些坑,给准备做类似管理系统的同学一份能直接对照参考的完整方案。
1. 项目概述与核心需求拆解
1.1 动物园日常管理到底在管什么
在写第一行代码之前,必须先搞清楚一个问题:动物园的日常管理,到底有哪些事?
我一开始也觉得管理系统的核心就是"动物信息管理",无非是登记一下物种、名字、年龄、产地,做点查询分页。但真去梳理业务才发现,动物园是一项非常复杂的多线程运营业务,至少包含这几条线:
- 动物线:每只动物都有档案,包括身份ID、物种、性别、出生日期、来源、入馆时间、所在场馆、健康状况、是否在繁殖期、是否隔离观察等。这些信息一旦出错,后果很严重,比如把两只同物种但不同谱系的动物放一起,可能引发近亲繁殖风险。
- 饲养线:不同动物有不同的食性和喂食频率,比如猛兽类一般是晚间喂食一次,灵长类可能一天两到三次,草食动物几乎全天可以采食。饲养员每天要按照计划的饲喂单去执行,还要记录每只动物的进食情况,及时发现食欲异常。
- 健康线:兽医需要定期给动物做体检、打疫苗、驱虫,生病了要有病历记录和用药记录。这条线和动物档案线是强关联的,一只动物如果处于生病隔离状态,它的展示场馆和饲养方式都要联动调整。
- 运营线:门票定价(成人票、儿童票、学生票、团队票、年卡)、售票渠道(窗口、线上)、检票入园(高峰期人流控制)、场馆开放时间、夜间活动等。
- 后勤线:饲料库存管理(冻肉、干草、果蔬、昆虫各有不同的存储条件)、药品库存、物料采购,这部分又和饲养计划紧密关联。
所以所谓"动物园管理系统",本质上是一个多角色、多业务线协同的ERP系统,动物档案只是其中最核心但不是唯一的基础数据。理解了这一点,下面的设计才不会跑偏。
1.2 核心角色与业务流程梳理
梳理好业务之后,紧接着要做的就是把系统中的角色和核心流程固定下来。这套系统的角色,我是这样划分的:
| 角色 | 主要职责 | 关心的核心功能 |
|---|---|---|
| 系统管理员 | 账号管理、角色权限分配、系统配置 | 权限管理、日志审计 |
| 动物管理员(档案员) | 维护动物档案、场馆信息 | 动物档案CRUD、档案查询 |
| 饲养员 | 查看饲喂计划、执行喂食、记录进食情况 | 饲养任务、饲喂记录 |
| 兽医 | 体检、疫苗、诊治、健康记录 | 健康档案、病历管理、用药记录 |
| 售票员 | 售卖门票、处理退票 | 售票、订单管理 |
| 检票员 | 核验票据、控制入园人流 | 检票、客流统计 |
| 饲料管理员 | 管理饲料和药品库存、采购入库 | 库存管理、预警 |
角色定下来,核心业务流程也就能顺出来了。比如饲养这条线:系统根据动物的食性配置自动生成每日饲喂计划,推送给对应场馆的饲养员,饲养员按计划执行,执行时如果动物食欲不振,顺便在系统里做异常标记,兽医端就能看到提醒,安排进一步检查。
另一个重要的流程是售票检票:线上或者窗口售票后生成订单和票据二维码,游客在入园闸机处出示,检票员扫码核验,核验通过后记录入园时间和客流,所有数据实时汇总到管理后台。
这些流程确定了,系统的功能模块和数据库表结构,就有了清晰的边界。
2. 技术选型与系统架构设计
2.1 技术栈选择:为什么是Spring Boot + Vue这套组合
技术栈的选型,其实是需求和团队现状共同决定的。我当时对比过几套方案,最终还是选了最主流的Spring Boot + MyBatis-Plus + Vue + Element UI + MySQL + Redis组合。原因有三点:
第一,生态成熟、招人容易。这套组合在中国软件开发圈子里普及率极高,不管后期是团队维护还是找人接手,成本都低。我曾经也考虑过用Python的Django,写起来确实快,但后面做权限控制、对接支付渠道时,Spring Security和现成的支付SDK生态明显更省事。
第二,前后端分离更适合多端适配。动物园管理系统后期大概率不只是在PC管理后台用,还可能要出移动端给饲养员在外面喂食时记录操作、给游客做小程序购票。前后端分离后,后端只需要提供一套标准RESTful API,不同端各自适配,扩展成本最低。
第三,MyBatis-Plus的开发效率足够高。业务系统里大部分是单表CRUD加一些关联查询,MyBatis-Plus的BaseMapper直接提供了通用方法,代码量能省一半以上。虽然网上有人吐槽它不够"优雅",但做业务系统,快速交付才是硬道理,优化留给真正有瓶颈的地方。
2.2 系统架构与模块划分
系统整体采用经典的前后端分离模式,结构大致如下:
- 前端:Vue 3 + Element Plus + Axios + Vue Router + Pinia。管理后台是典型的单页应用,按角色动态渲染菜单。
- 后端:Spring Boot 2.7 + Spring Security + JWT + MyBatis-Plus + Redis + MySQL。分层架构按 Controller / Service / Mapper 划分,项目结构用 Maven 管理。
- 基础设施:MySQL 8.0存业务数据,Redis做缓存(热点场馆信息、验证码、Token黑名单),Nginx做静态资源托管和API反向代理。
模块划分上,一共拆了7个核心模块:
- 系统管理模块:用户、角色、菜单权限
- 动物档案模块:动物基本信息、场馆、种群管理
- 饲养管理模块:饲喂计划、饲喂记录、饲料库存联动
- 健康管理模块:体检、疫苗、病历、药品关联
- 票务管理模块:票价配置、订单、退票
- 检票管理模块:扫码核验、客流统计
- 数据统计模块:各类运营指标报表
模块化设计带来的最大好处是,每个模块可以独立开发、独立测试,后期功能迭代也不会互相干扰。比如我先把动物档案和饲养管理做通了,票务模块边做边接入,完全不影响前面已上线功能的使用。
3. 数据库建模:一张表一张表抠出来的细节
数据库设计是整个系统中最需要下功夫的部分,我在这里花的时间几乎占了全项目的一半。表结构设计得不好,后面写代码全是坑。
3.1 动物档案表:核心中的核心
动物档案是整个系统的核心数据。这张表设计得是否合理,直接决定了后续所有模块的稳定性。
CREATE TABLE `animal_info` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', `animal_code` varchar(32) NOT NULL COMMENT '动物编号,业务唯一', `animal_name` varchar(64) NOT NULL COMMENT '动物名称/呼名', `species_id` bigint NOT NULL COMMENT '物种id,关联物种字典表', `gender` tinyint NOT NULL DEFAULT 0 COMMENT '性别 1-雄 2-雌 0-未知', `birth_date` date DEFAULT NULL COMMENT '出生日期', `source_type` tinyint DEFAULT NULL COMMENT '来源 1-野外救助 2-其他动物园引进 3-人工繁育', `coming_date` date DEFAULT NULL COMMENT '入馆日期', `enclosure_id` bigint NOT NULL COMMENT '所在场馆id', `health_status` tinyint NOT NULL DEFAULT 1 COMMENT '健康状态 1-健康 2-观察 3-生病隔离', `breeding_status` tinyint NOT NULL DEFAULT 0 COMMENT '繁殖状态 0-非繁殖期 1-繁殖期', `is_isolated` tinyint NOT NULL DEFAULT 0 COMMENT '是否隔离 0-否 1-是', `remark` varchar(500) DEFAULT NULL COMMENT '备注', `status` tinyint NOT NULL DEFAULT 1 COMMENT '状态 1-在馆 0-已离馆', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_animal_code` (`animal_code`), KEY `idx_species` (`species_id`), KEY `idx_enclosure` (`enclosure_id`), KEY `idx_status` (`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='动物档案表';几个设计上的关键点说一下。
animal_code 动物编号,建议用业务规则生成,比如L-2024-0001,L是物种缩写(Lion),2024是入馆年份,0001是当年序号。这样哪怕不看系统,听广播喊编号也能大概猜到是哪只动物。数据库层面必须建唯一索引,防止并发情况下生成重复编号造成档案串号。
species_id 为什么单独建物种字典表,而不是直接存物种名字符串?因为物种涉及后续很多联动的逻辑,比如食性配置、标准体重范围、寿命预期、保护等级。存字典ID,再通过关联表把食性和喂食频率挂在物种上,后面做饲养计划自动生成时会非常方便。
enclosure_id 关联场馆表,这个字段也很关键。动物在哪个场馆展示,决定饲养员和兽医的日常任务推送范围。场馆表里还会存容纳能力,方便高峰期统计客流量时做承载预警。
3.2 饲养与饲料库存表:业务联动的关键
饲养模块的表设计,核心思路是把"计划"和"记录"分开。
CREATE TABLE `feeding_plan` ( `id` bigint NOT NULL AUTO_INCREMENT, `animal_id` bigint NOT NULL COMMENT '动物id', `feed_time` datetime NOT NULL COMMENT '计划喂食时间', `feed_type` tinyint NOT NULL COMMENT '餐次 1-早餐 2-午餐 3-晚餐', `feed_items` varchar(500) NOT NULL COMMENT '饲喂内容,存JSON [{feedId, name, quantity, unit}]', `status` tinyint NOT NULL DEFAULT 0 COMMENT '状态 0-待执行 1-已完成 2-异常', `operator_id` bigint DEFAULT NULL COMMENT '执行人', `execute_time` datetime DEFAULT NULL COMMENT '实际执行时间', `remark` varchar(255) DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_animal_date` (`animal_id`, `feed_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='饲喂计划表'; CREATE TABLE `feed_stock` ( `id` bigint NOT NULL AUTO_INCREMENT, `feed_name` varchar(64) NOT NULL COMMENT '饲料名称', `category` tinyint NOT NULL COMMENT '类别 1-肉类 2-果蔬 3-干草 4-昆虫 5-营养剂', `stock_quantity` decimal(10,2) NOT NULL DEFAULT 0 COMMENT '当前库存', `unit` varchar(10) NOT NULL COMMENT '计量单位 kg/g/个', `safety_stock` decimal(10,2) NOT NULL DEFAULT 0 COMMENT '安全库存阈值', `storage_condition` varchar(100) DEFAULT NULL COMMENT '存储条件', `expire_date` date DEFAULT NULL COMMENT '保质期', `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='饲料库存表';feed_plan 里的 feed_items 用JSON字符串存储,这是个实用经验。因为不同动物的饲喂内容差异极大,比如一只老虎的晚餐可能是5kg牛肉加1kg鸡肉,而一只兔子早上的饲喂内容是苜蓿草200g加胡萝卜50g。如果用传统关系表去拆"饲喂明细",每张明细表都要根据饲喂类型做动态扩展,反而麻烦。JSON存储加前端动态表单渲染,匹配度很高,需要统计饲料消耗时再通过JSON解析函数处理即可。
饲料库存表里的 safety_stock 安全库存字段,看起来不起眼,实战中极其好用。我给它配了一个定时任务,每天晚上检查所有饲料的库存,低于安全库存的自动生成采购提醒推给管理员。这个功能上线后,饲料组再也没出现过周末喂食时发现冻肉不够的情况。
3.3 票务与游客表:支撑高峰期的设计细节
票务这块要重点考虑高并发场景。周末和节假日,游客量会陡增到平日的五倍以上,数据库如果扛不住,体验会非常差。
CREATE TABLE `ticket_sale_order` ( `id` bigint NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL COMMENT '订单号', `ticket_date` date NOT NULL COMMENT '游玩日期', `ticket_type` tinyint NOT NULL COMMENT '票种 1-成人票 2-儿童票 3-学生票 4-团队票 5-年卡', `quantity` int NOT NULL COMMENT '购票数量', `total_amount` decimal(10,2) NOT NULL COMMENT '总金额', `pay_status` tinyint NOT NULL DEFAULT 0 COMMENT '支付状态 0-待支付 1-已支付 2-已退款', `pay_time` datetime DEFAULT NULL COMMENT '支付时间', `buyer_phone` varchar(20) DEFAULT NULL COMMENT '购票人手机号', `channel` tinyint NOT NULL DEFAULT 1 COMMENT '销售渠道 1-窗口 2-线上', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_ticket_date` (`ticket_date`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='门票订单表'; CREATE TABLE `admission_record` ( `id` bigint NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL COMMENT '订单号', `ticket_code` varchar(32) NOT NULL COMMENT '票据唯一码', `admission_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '入园时间', `entrance_id` tinyint NOT NULL COMMENT '入口编号', `operator_id` bigint NOT NULL COMMENT '检票员', PRIMARY KEY (`id`), UNIQUE KEY `uk_ticket_code` (`ticket_code`), KEY `idx_admission_time` (`admission_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='入园记录表';订单号和票据码分开设计,是我比较坚持的一点。订单号对应一笔支付,一张订单可能买了3张票,对应3个独立的 ticket_code。这样门票即使被转赠,每张票也能独立核销,不会因为一张票退了导致整单失效。
admission_record 只做插入,不做更新,这是专门为检票高峰期设计的。扫码检票的核心操作就是一条INSERT,配合Redis先判断票据码是否存在,可以做到非常高的吞吐。客流的实时统计直接基于这张表做计数查询。
4. 核心功能模块实战实现
4.1 动物档案模块:从列表到新建,把细节做完整
动物档案模块看起来就是标准的CRUD,但实际实现时有不少细节需要考虑。
查询接口是使用频率最高的,所以一定要做好条件组合查询和分页。我的Controller大致长这样:
@RestController @RequestMapping("/api/animal") public class AnimalController { @Autowired private AnimalService animalService; @GetMapping("/page") public Result<IPage<AnimalVO>> page(AnimalQueryDTO dto) { return Result.success(animalService.queryPage(dto)); } @GetMapping("/{id}") public Result<AnimalVO> detail(@PathVariable Long id) { return Result.success(animalService.getDetail(id)); } }重点是 Service 里的查询逻辑。因为列表通常要展示物种名称、场馆名称,所以不能只查animal_info一张表。我用MyBatis-Plus的Wrapper做条件拼接,再通过自定义SQL联查两张字典表:
public IPage<AnimalVO> queryPage(AnimalQueryDTO dto) { Page<AnimalVO> page = new Page<>(dto.getPageNum(), dto.getPageSize()); QueryWrapper<AnimalVO> wrapper = new QueryWrapper<>(); wrapper.eq(dto.getSpeciesId() != null, "a.species_id", dto.getSpeciesId()) .eq(dto.getEnclosureId() != null, "a.enclosure_id", dto.getEnclosureId()) .eq(dto.getHealthStatus() != null, "a.health_status", dto.getHealthStatus()) .eq(dto.getStatus() != null, "a.status", dto.getStatus()) .like(StringUtils.hasText(dto.getAnimalName()), "a.animal_name", dto.getAnimalName()) .eq(StringUtils.hasText(dto.getAnimalCode()), "a.animal_code", dto.getAnimalCode()) .orderByDesc("a.create_time"); return animalMapper.selectAnimalPage(page, wrapper); }这里要注意,QueryWrapper中的条件字段如果是拼的字符串,一定不能允许用户直接传入排序字段和排序方向,否则容易产生SQL注入风险。我在这里只允许排序字段映射到白名单内的列。
新增动物的关键逻辑是生成动物编号。这一步我用了一个独立的服务方法,加锁来保证并发安全:
@Transactional public Long createAnimal(AnimalCreateDTO dto) { String animalCode = generateAnimalCode(dto.getSpeciesId()); AnimalInfo animal = new AnimalInfo(); BeanUtils.copyProperties(dto, animal); animal.setAnimalCode(animalCode); animalMapper.insert(animal); // 同时初始化健康档案 healthService.initHealthRecord(animal.getId()); return animal.getId(); } private String generateAnimalCode(Long speciesId) { // 加分布式锁,防止并发生成重复编号 String lockKey = "animal:code:lock:" + speciesId; String code = stringRedisTemplate.opsForValue().get(lockKey); Species species = speciesMapper.selectById(speciesId); String prefix = species.getCodePrefix(); // 比如 L- int seq = counterService.increment(speciesId); // 从Redis或数据库序列取值 return String.format("%s%04d-%04d", prefix, LocalDate.now().getYear(), seq); }countService.increment我利用了Redis的INCR命令保持原子性,避免多个人同时录入动物时编号冲突。如果Redis挂了,就回退到数据库的MAX(animal_code)+ 1 方案。
4.2 饲养任务与饲料库存联动:定时生成,执行扣减
饲养任务模块的实现,我先做了一步"配置基础数据":在物种字典表里,给每个物种配置了默认的饲喂频率和每餐标准量。比如:
- 东北虎:每日2餐,早餐5kg牛肉,晚餐6kg牛肉+1kg鸡架
- 环尾狐猴:每日3餐,每餐水果200g+蔬菜150g
- 长颈鹿:每日4餐,每餐干草3kg+专用颗粒饲料1kg
有了这个配置后,系统每天凌晨自动为所有在馆动物生成当天的饲喂计划:
@Component public class FeedingPlanGenerator { @Scheduled(cron = "0 30 4 * * ?") public void generateDailyPlans() { List<AnimalInfo> animals = animalMapper.selectAllActive(); for (AnimalInfo animal : animals) { SpeciesFeedConfig config = feedConfigMapper.queryBySpecies(animal.getSpeciesId()); if (config == null) { continue; } FeedPlan plan = new FeedPlan(); plan.setAnimalId(animal.getId()); plan.setFeedTime(buildTodayTime(config.getFirstFeedTime())); plan.setFeedItems(config.getDefaultItems()); feedingPlanMapper.insert(plan); } } }饲养员在手机上打开待办任务,看到今天负责的动物有哪些、每个时间点要喂什么,执行后点一下"完成"按钮,系统自动扣减对应饲料库存:
@Transactional public void finishFeedPlan(Long planId, Long operatorId) { FeedPlan plan = feedingPlanMapper.selectById(planId); if (plan == null || plan.getStatus() != 0) { throw new BizException("计划不存在或已处理"); } // 解析feed_items里的每种饲料 List<FeedItem> items = JSON.parseArray(plan.getFeedItems(), FeedItem.class); for (FeedItem item : items) { feedStockService.deductStock(item.getFeedId(), item.getQuantity()); } plan.setStatus(1); plan.setOperatorId(operatorId); plan.setExecuteTime(LocalDateTime.now()); feedingPlanMapper.updateById(plan); }这个逻辑看起来简单,但有个隐藏问题:如果库存不够,还要不要扣减?我的处理是,执行时如果对应饲料库存不足,先允许完成饲喂记录,把完成状态标记上,同时生产一条"库存不足待补货"提醒。因为动物不能不吃,但可以通过紧急调拨解决,系统不能让饲养员死等在系统上操作不了。
这样设计联动的好处是:任何一天的饲料消耗,都能追溯到是哪只动物、哪顿饭消耗的。财务月度核算饲料成本时,直接按feeding_record汇总就行,非常省力。
4.3 门票销售与扫码检票:订单、二维码与高并发
售票模块最核心的接口是创建订单。考虑到游客在窗口排队时网络不稳定,我的设计是"先下单、暂不支付",给一个15分钟的支付超时时间。超时未支付的订单由定时任务自动取消。
@PostMapping("/createOrder") public Result<OrderVO> createOrder(@RequestBody OrderCreateDTO dto) { String orderNo = "T" + System.currentTimeMillis() + RandomUtil.randomNumbers(6); BigDecimal amount = calcAmount(dto.getTicketType(), dto.getQuantity(), dto.getTicketDate()); TicketSaleOrder order = new TicketSaleOrder(); order.setOrderNo(orderNo); order.setTicketDate(dto.getTicketDate()); order.setTicketType(dto.getTicketType()); order.setQuantity(dto.getQuantity()); order.setTotalAmount(amount); order.setPayStatus(0); order.setChannel(dto.getChannel()); order.setBuyerPhone(dto.getBuyerPhone()); ticketOrderMapper.insert(order); return Result.success(buildOrderVO(order)); }支付回调后,为订单里每张票生成一个唯一的 ticket_code,同时把票码写入Redis,方便检票时快速校验:
public void onPaySuccess(String orderNo) { TicketSaleOrder order = ticketOrderMapper.selectByOrderNo(orderNo); if (order.getPayStatus() != 0) { return; // 幂等处理,防止回调重复 } order.setPayStatus(1); order.setPayTime(LocalDateTime.now()); ticketOrderMapper.updateById(order); for (int i = 0; i < order.getQuantity(); i++) { String ticketCode = "T" + order.getOrderNo() + "-" + (i + 1); stringRedisTemplate.opsForValue() .set("ticket:code:" + ticketCode, orderNo, 1, TimeUnit.DAYS); } }检票接口走的是扫码枪,输入票码后先查Redis,如果有就直接写入入园记录,并删除Redis中的票码。如果Redis没有,再回查数据库,防止Redis宕机时检票没法用。
@PostMapping("/admission") public Result admission(@RequestBody AdmissionDTO dto) { String ticketCode = dto.getTicketCode(); // 先查Redis String orderNo = stringRedisTemplate.opsForValue().get("ticket:code:" + ticketCode); if (orderNo == null) { // Redis中没有,查数据库 AdmissionRecord record = admissionMapper.selectByTicketCode(ticketCode); if (record != null) { throw new BizException("该票已使用过"); } TicketSaleOrder order = ticketOrderMapper.selectByTicketCode(ticketCode); if (order == null || order.getPayStatus() != 1) { throw new BizException("票码无效或未支付"); } orderNo = order.getOrderNo(); } AdmissionRecord record = new AdmissionRecord(); record.setOrderNo(orderNo); record.setTicketCode(ticketCode); record.setEntranceId(dto.getEntranceId()); record.setOperatorId(dto.getOperatorId()); admissionMapper.insert(record); stringRedisTemplate.delete("ticket:code:" + ticketCode); return Result.success(); }这里有个细节,防止同一张票被同时并发扫两次导致重复入园,admission_record表唯一索引兜底。即便两条请求同时进来,两条INSERT也只有一条能成功。这种"Redis加速 + 数据库兜底"的方案,实测不仅在高峰期稳定,还保证了数据绝对准确。
4.4 基于RBAC的权限控制:Spring Security + JWT
管理后台有多类角色,权限控制必须从第一天就设计好。我使用的是经典的RBAC模型:用户表、角色表、菜单权限表、用户角色关联表、角色菜单关联表。
CREATE TABLE `user_account` ( `id` bigint NOT NULL AUTO_INCREMENT, `username` varchar(64) NOT NULL, `password` varchar(128) NOT NULL COMMENT 'BCrypt加密', `employee_id` bigint DEFAULT NULL COMMENT '关联员工表', `status` tinyint NOT NULL DEFAULT 1, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ); CREATE TABLE `sys_role` ( `id` bigint NOT NULL AUTO_INCREMENT, `role_code` varchar(32) NOT NULL, `role_name` varchar(64) NOT NULL, PRIMARY KEY (`id`) ); CREATE TABLE `sys_menu` ( `id` bigint NOT NULL AUTO_INCREMENT, `menu_name` varchar(64) NOT NULL, `parent_id` bigint NOT NULL DEFAULT 0, `route_path` varchar(128) DEFAULT NULL, `perm_code` varchar(64) DEFAULT NULL COMMENT '权限标识,如 animal:add', PRIMARY KEY (`id`) );后端用Spring Security做认证和鉴权。登录成功后发JWT,之后每次请求在网关层解析Token,再根据用户的角色去查询他能访问的权限码。
一个重要的经验是,URL级别的粗粒度控制用注解或配置文件写好,按钮级别的细粒度控制前后端都要做。比如饲养员角色能访问"饲喂计划列表"和"完成饲喂"接口,但不能访问"新增饲料"接口。后端用@PreAuthorize("hasAuthority('feed:finish')")保证安全,前端根据用户权限列表动态渲染按钮,没有权限就直接隐藏。如果不做前端控制,普通用户虽然调不动接口,但打开页面看到一堆灰色按钮,体验也很奇怪。
5. 常见问题与排查技巧实录
系统上线后,日常运行中一定会碰到各种问题。这里把我实际遇到过的几个高频问题和排查思路整理一下,直接照着排查能省不少事。
5.1 动物编号重复导致档案串号
现象:新增动物时,明明按规则生成编号,但还是生成了重复的L-2024-0012,导致查询时两只动物共用一个编号。
排查过程:先查数据库里animal_code字段有没有唯一索引,发现没有,这正是问题的根源。编号是在应用层生成的,即使加锁,如果锁的范围没覆盖到整个"生成-插入"流程,并发下依然可能重复。比如我原来的generateAnimalCode里,先去查物种表拿前缀,再查计数器,中间如果有两次请求同时走到计数器查询,拿到的就是同一个序号。
解决方案:
- 数据库层给
animal_code建唯一索引,保证任何情况下都不可能插入重复值。 - 生成编号时使用Redis的INCR保证原子性,Redis不可用则回退到
SELECT MAX(animal_code)加锁。
5.2 饲料库存出现负数
现象:饲养员执行饲喂计划后,发现报表里某种饲料的库存变成了负数,比如当前库存是5kg,一次喂食扣了8kg。
排查过程:库存扣减的SQL写的是:
UPDATE feed_stock SET stock_quantity = stock_quantity - #{quantity} WHERE id = #{id}如果前一秒已经有人批了采购单,但采购单的入库还没执行,库存记录还是旧值,这时候扣减就可能出现负数。更深层的原因是没有做"扣减前校验 + 数据库乐观锁"。
解决方案:扣减前增加一层前置校验,同时给feed_stock表加version字段,扣减时带上版本号做CAS更新。如果更新行数为0,说明库存被别人修改过,重新读取后再试。另外,配置安全库存预警,库存低于阈值时主动阻止扣减操作并提示紧急补货。
5.3 高峰期扫码检票越来越慢
现象:周末上午10点到12点,扫码检票的闸机反应明显变慢,有时候扫码后要等两三秒才弹出结果。
排查过程:一开始以为是网络问题,后来看了监控发现数据库的admission_record表在高并发插入时存在大量锁等待。原因是我在检票接口里先查Redis再插数据库,但查询Redis的ticket:code后还要查一次订单表,等于一次检票要访问两次Redis和一次数据库,请求量大后延迟自然上来了。
优化方案:把"订单号关联"逻辑简化,Redis的value直接存票码对应的可用状态,检票时只查一次Redis做确认,然后异步把入园记录写入消息队列,由队列消费者批量写库。这样检票接口的主链路只访问一次Redis + 一次消息队列,吞吐量提升非常明显。数据库的写入由批量操作消化,也不再出现锁等待。
5.4 角色权限配置后不生效
现象:管理员给新来的饲养员配了角色,但该用户登录后仍然是空白菜单,看不到任何内容。
排查过程:先看用户角色关联表,发现关联关系是配好了的。再看角色菜单关联表,发现角色只关联了部门菜单,没关联按钮权限,但前端渲染时按perm_code精确匹配,导致菜单级别有,按钮级别的渲染全被过滤掉了。
解决方案:排查权限问题时,要按"用户 - 角色 - 菜单 - 权限码"四级逐级打印,看到底是哪一级断了。而且要注意,前后端权限码的命名规则必须完全一致,比如后端是feed:finish,前端如果写成feed:finishBtn就匹配不上。建议在系统管理里加一个"权限码校验"功能,用来自动扫描前后端权限码的匹配情况。
6. 项目经验总结与优化方向
6.1 这套系统值得借鉴的几个设计
这套系统做完,我最满意的不是某个单一功能,而是几个跨模块的设计思路。
第一是库存与业务的联动。饲养计划执行时自动扣库存,这一环直接打通了"动物养得好不好"和"采购预算花了多少"之间的数据链路。以前这些数据是割裂的,要么库存部门单独记账,要么饲养员手工填表,月底对账全靠加班。现在系统自动生成消耗明细,财务拿到的数据完全可追溯。
第二是Redis在关键链路的使用。票务检票、动物编号生成、在线购票订单状态,这三处都用Redis做了加速或者原子性保障。项目上线后遇到节假日客流高峰,系统表现稳定,这套缓存方案功不可没。
第三是JSON字段的灵活应用。饲喂明细、场馆设施清单这类"结构不固定但业务关联密切"的数据,用JSON存储,避免了大量动态列和中间表的拼接,开发效率高,维护也简单。
6.2 如果让你从零开始做,这几个建议直接照搬
先总结一下,如果现在让你从零开始做一套类似的管理系统,照着这几条能少走弯路:
- 第一步先梳理业务,再碰代码。至少把角色、流程、核心表列出来,和实际负责人过一遍再动工。没有业务梳理的设计都是空中楼阁。
- 数据库设计花一半的精力都不为过。索引、唯一约束、外键逻辑,宁可前期多花点时间设计,不要上线后再来改表。改表成本往往是原计划的三倍以上。
- 权限系统从第一天就做好。哪怕只有管理员一个角色,也要按RBAC模型去设计,否则后面加角色的时候会非常痛苦。
- 核心流程都要有日志。谁在什么时间改了哪只动物的档案、谁执行了喂食扣减库存、谁退了一张票,全部留痕。出了纠纷时,日志就是你的护身符。
我在实际开发中还有一个习惯,就是在每个模块的核心操作里埋一条操作日志。看起来额外花一点存储,但对排查线上问题帮助极大。有一次场馆负责人反馈某只动物的饲喂记录莫名多了一条,翻日志发现是另一个饲养员试用APP时误触了"补录"按钮。这种问题如果没有日志,排查起来真的无从下手。
这套系统后续还可以扩展的方向也不少,比如对接线上购票小程序、接入智能摄像头做动物行为分析、通过环境传感器联动场馆温湿度控制,但这些都是在核心数据稳定之后的事了。先把基础的数据流打通,后面的想象空间自然就有了。