ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

SpringBoot+Uniapp构建幼教集团系统:租户隔离与三端适配实践

SpringBoot+Uniapp构建幼教集团系统:租户隔离与三端适配实践 简介基于SpringBoot和Uniapp架构的幼教集团一站式智慧幼儿园管理系统源码面向幼儿园信息化建设者、Java全栈开发者及教育行业软件从业人员。系统覆盖保健工作、待办事项、教务管理、智能考勤、财务管理、OA处理、招生管理、健康档案、校区管理等核心模块内置教师、园长、保健医生、助理四类角色权限并为家长提供友好的考勤、健康与学习进度跟踪界面同时通过园长信箱和班级活动等功能强化家校沟通。资源包共312个文件以257个Java源文件为主辅以23个XML配置文件、21张PNG图片、2个yml和2个properties配置以及gitignore、license等说明文件压缩包大小7.75MB目录结构清晰。阅读源码可深入理解SpringBoot与Uniapp前后端分离架构下的角色权限控制、业务模块划分及前后端数据交互方式适合用于研究幼儿教育类SaaS系统的设计思路。目前已有255人学习下载适合需要参考完整幼儿园业务系统设计、进行二次开发或学习成熟项目架构的开发者。1. 幼教集团的一站式难点不在页面而在数据口径幼教集团和单园管理系统最大的差异是每一张表都要多回答一个问题这条数据归哪个园、归哪个班集团能不能跨园看见。家长端要做打卡、食谱、相册教师端要做晨检、排班、成长记录园长端要看跨园出勤率和收费进度三个端口若各写一套接口需求一变动就得改三处。基于 SpringBoot 做统一后端先把组织模型和租户边界立住再用 Uniapp 一套代码同时出微信小程序、App 和 H5是这类项目里最划算的工程组合。下面按组织模型与租户隔离、三端适配、核心链路、交付打包四个层次展开表结构、拦截器、请求封装和上架前的配置都能直接照着落。2. SpringBoot 后端组织模型与租户隔离是源码的地基整个后端跑在 SpringBoot 框架上第一件事不是写业务接口而是把集团的组织关系设计成可查询、可隔离的数据结构。这里的数据口径一旦定错后面统计报表全是错的。2.1 一张 sys_org 表承载集团—园所—班级三级单园系统通常把园所表和班级表分开建班级表挂一个园所 ID 就够了。但集团化之后多出集团总部这一层分表会导致每个接口都要 join 两次跨园统计时条件越拼越长。常见做法是只建一张组织表用 org_type 区分层级parent_id 挂树业务表统一只存 class_id 和 tenant_id。CREATE TABLE IF NOT EXISTS sys_org ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主键, org_name VARCHAR(64) NOT NULL COMMENT 机构名称, org_type TINYINT NOT NULL COMMENT 1集团 2园所 3班级, parent_id BIGINT NOT NULL DEFAULT 0 COMMENT 父级ID, tenant_id BIGINT NOT NULL COMMENT 集团ID数据隔离维度, sort_no INT NOT NULL DEFAULT 0 COMMENT 同层排序, status TINYINT NOT NULL DEFAULT 1 COMMENT 1启用 0停用, PRIMARY KEY (id), KEY idx_parent (parent_id), KEY idx_tenant (tenant_id, org_type) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT组织表;说明parent_id 指向上一级 idorg_type 决定这行记录是集团、园所还是班级tenant_id 统一存最顶层的集团 ID。后续所有业务表都落到班级维度园长看本园、集团看全局各只需一条过滤条件园长视角class_id in (select id from sys_org where parent_id 本园id)集团视角tenant_id 集团id这样设计还有一个好处新增一所园不需要改代码往 sys_org 插两条数据园所加第一批班级就算接入完成这是源码里开园功能最常用的实现方式。2.2 MyBatis-Plus 租户插件把 tenant_id 变成基础设施如果每个查询都手写 tenant_id 条件漏写一次就是跨园数据泄露。MyBatis-Plus 提供的 TenantLineInnerInterceptor 能自动处理在配置类里注册即可Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new TenantLineHandler() { Override public Expression getTenantId() { LoginUser user SecurityUtils.getLoginUser(); Long tid user ! null ? user.getTenantId() : 0L; return new LongValue(tid); } Override public String getTenantIdColumn() { return tenant_id; } Override public boolean ignoreTable(String tableName) { return tableName.equals(sys_org) || tableName.equals(sys_dict) || tableName.equals(sys_user); } })); return interceptor; } }逻辑说明插件会对所有 select、update、delete 自动拼上tenant_id ?条件insert 时自动补充租户字段值业务代码完全感知不到。sys_org 必须放行因为它本身带 tenant_id 且要按 org_type 做过滤sys_dict 这类集团级共享字典也要放行否则字典查询会莫名少数据。边界要清楚一旦子查询、union、多表 join 且表起了别名插件解析偶尔会失效相关报错多为列名带前缀后条件拼错。排查时直接打开 SQL 日志看是否出现重复的 tenant_id 条件比猜快得多。想深究拼接逻辑可以直接读 mybatis-plus 里 TenantLineInnerInterceptor 的源码这个拦截器不大两百行内能把规则看完。2.3 SpringBoot 3 MyBatis-Plus 的版本对应SpringBoot 的版本选择影响依赖坐标这一步不对项目大概率起不来。给出一个常见的最低骨架parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version3.2.5/version /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-spring-boot3-starter/artifactId version3.5.5/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency /dependencies说明Spring Boot 3 切到 jakarta 命名空间后老的 mybatis-plus-boot-starter 只认 javax直接依赖会在启动时抛 ClassNotFoundException。若公司内部公共依赖还锁在 Spring Boot 2.7就保持 2.x 坐标和 JDK 8/11从 3.x 降回 2.7 时代码里 jakarta.servlet 的引用要全部改回 javax.servlet。热词里springboot版本太高常见于新脚手架默认 3.x但内部基础包和运维基线都是 2.7 的情况。处理顺序是先降 parent再处理 servlet 包引用最后检查 Redis 和连接池的自动配置是否被新版本改过名。2.4 表不存在自动建表开发用 schema.sql交付用 Flywayspringboot mybatis 当表不存在自动建表这个需求本质是建表策略问题和 MyBatis 本身关系不大。最轻量的做法是 Spring Boot 自带的 SQL 初始化spring: sql: init: mode: always schema-locations: classpath:db/schema.sql continue-on-error: false说明mode: always 表示每次启动都执行 schema.sql所以里面的建表语句必须全部写成CREATE TABLE IF NOT EXISTS否则第二次启动直接报错。这个方案适合内测和给客户演示但列变更无法幂等一旦出现 alter 需求就赶紧切换到 Flyway。方案适用阶段幂等性列变更管理schema.sql开发/演示需手动 IF NOT EXISTS不友好Flyway生产/交付版本化天然幂等V 脚本即可手动执行 SQL一次性割接取决于人容易漏执行Flyway 的落地配置spring: flyway: enabled: true locations: classpath:db/migration baseline-on-migrate: truebaseline-on-migrate 的作用是老库已存在表时先打基线版本不删表重建避免第一次上 Flyway 就把生产数据弄丢。建表脚本里主键、索引、注释一次写全后面运维查表结构时省大量口舌。2.5 登录与鉴权JWT 签发 Redis 按端互踢家长端和教师端都有一个账号多端登录的场景源码里常见做法是 JWT 签发后把 token 同步存进 Redis区分端类型PostMapping(/auth/login) public RLoginVO login(RequestBody LoginDTO dto, RequestHeader(value device, defaultValue H5) String device) { Teacher teacher teacherService.login(dto.getUsername(), dto.getPassword()); if (teacher null) { return R.fail(账号或密码错误); } String token jwtService.create( teacher.getId(), teacher.getTenantId(), device); redisTemplate.opsForValue().set( login: device : teacher.getId(), token, Duration.ofDays(7)); return R.ok(new LoginVO(token, teacher)); }说明device 区分 APP、小程序、H5。同一个家长在小程序登录不会顶掉 App 端同端重复登录才互踢。后端拦截器解析 Authorization 里的 token 后与 Redis 中保存的值比对不一致返回 401前端统一跳登录页。相比裸 JWT 多一次 Redis 读但换来了修改密码全端下线和运营端踢人两个实打实的功能。3. Uniapp 三端适配家长小程序、教师 App、园长 H5 共用一套代码Uniapp 的可维护性由两件事决定条件编译写得规不规整请求层封得厚不厚。这两件做好了后续加一个抖音小程序只需要半天。3.1 条件编译决定平台差异请求封装决定切换成本页面结构上建议把 pages 拆成 student、teacher、principal 三个目录对应角色tabBar 各配各的公共组件、store、api 放根目录。平台差异代码用条件编译包起来// #ifdef MP-WEIXIN const loginChannel MP-WEIXIN // #endif // #ifdef APP-PLUS const loginChannel APP // #endif // #ifdef H5 const loginChannel H5 // #endif说明loginChannel 在编译期就被固定真机上跑起来是一个字符串不会多一次运行时判断。获取定位、扫码这类 API 在不同平台行为差异大全部收敛到 api/device.js 里页面不直接调 uni 原生方法。请求封装是另一个关键点统一在这里处理 token 和 401// utils/request.js import { getToken } from /utils/auth const BASE_URL import.meta.env.VITE_API_BASE_URL export function request(options) { return new Promise((resolve, reject) { uni.request({ url: BASE_URL options.url, method: options.method || GET, data: options.data || {}, header: { Authorization: Bearer getToken() }, success: (res) { if (res.data.code 401) { uni.navigateTo({ url: /pages/login/index }) return } resolve(res.data) }, fail: reject }) }) }说明BASE_URL 在 Vue3 工程里用 import.meta.envVue2 工程是 process.env.VUE_APP_API_BASE_URL框架迁移时要全局替换。401 在这里统一处理避免登录过期还在刷新列表的体验问题。token 过期后跳登录页之前建议先清掉本地缓存防止登录页读到一个半失效状态。3.2 manifest.json 里影响上架与定位的配置manifest.json 在 Uniapp 里不只是普通配置文件它决定云打包时打进哪些原生模块。很多项目功能写完了上架前才发现定位模块没勾、蓝牙权限没配重新打包又得等一轮。配置位置配置项影响常见坑基础配置应用标识 appid云打包、uni 统计、推送绑定换账号后推送和统计全失效基础配置版本号/版本构建号应用市场版本管理提审构建号必须大于线上已上架版本App 模块配置定位/蓝牙/NFC原生模块打包裁剪少勾一个功能直接不可用隐私提示弹窗首次启动授权国内安卓市场审核不配置或弹窗时机不对会被打回说明iOS 端同步检查 Info.plist 权限描述NSLocationWhenInUseUsageDescription 里要写清楚用途一句话带过会被审核追问。安卓各市场对 targetSdkVersion 有要求打包前先看最新规范再定。这些配置改完就随仓库走不用单独维护一份打包文档。3.3 家长端打卡扫码、NFC 与蓝牙读卡器的封装差异幼儿园门口常见三种考勤方案二维码立牌、NFC 手环读写器、蓝牙读卡器。扫码是最稳的兜底方案成本也最低export function readGuardianCard() { return new Promise((resolve, reject) { // #ifdef MP-WEIXIN uni.scanCode({ scanType: [qrCode, barCode], success: (res) resolve({ source: scan, data: res.result }) }) // #endif // #ifdef APP-PLUS uni.scanCode({ onlyFromCamera: true, success: (res) resolve({ source: scan, data: res.result }) }) // #endif }) }说明小程序里 scanCode 只能扫本平台的码App 端可以限制只从摄像头取避免误扫相册里的旧码。NFC 的情况要单独说plus.nfc 只在 Android 端提供 APIiOS 端 Uniapp 没有内置读卡能力要么写 UTS 原生插件要么改用蓝牙读卡器。所以源码里通常把读卡抽象成一个 device 模块扫码、NFC、蓝牙三个实现类切换页面层只调 readGuardianCard。蓝牙链路是 uni.openBluetoothAdapter 扫描设备、连接、监听特征值、写指令最耗时的是协议解析每家厂商的指令格式都不一样建议单独建一个 docs 目录放协议文档不然换一个读卡器品牌就要重新对着文档猜一遍。3.4 H5 嵌微信公众号里拿定位JSSDK 签名链路页面嵌入微信公众号后台时uni.getLocation 在微信内置浏览器里拿不到位置因为 H5 定位走的是浏览器 Geolocation API微信会拦截。正确做法是引微信 JSSDK由后端生成签名export function weixinLocation() { return new Promise((resolve, reject) { if (!window.wx) { const s document.createElement(script) s.src https://res.wx.qq.com/open/js/jweixin-1.6.0.js s.onload init document.head.appendChild(s) } else { init() } function init() { getJsSdkSignature(location.href.split(#)[0]).then((conf) { wx.config({ debug: false, appId: conf.appId, timestamp: conf.timestamp, nonceStr: conf.nonceStr, signature: conf.signature, jsApiList: [getLocation] }) wx.ready(() { wx.getLocation({ type: gcj02, success: resolve, fail: reject }) }) wx.error(reject) }) } }) }说明前置条件有三个。公众号后台要配 JS 接口安全域名后端签名接口要用 appid 加 secret 生成 signature页面的 url 必须取 location.href.split(#)[0]。三个缺一个都会报 invalid signature。坐标类型必须用 gcj02这是国测局坐标和腾讯地图、高德地图直接对齐传 wgs84 时地图上会偏移几十米。很多人在定位成功后问地图如何重置回中心点其实就是把地图组件的 latitude 和 longitude 绑定到定位结果手动改一次即可。4. 考勤、食谱与园长驾驶舱三条核心链路的可复现写法4.1 考勤链路从刷卡到家长收到通知考勤接口的入参设计影响后续所有统计建议只暴露三个字段参数类型说明deviceNoString闸机或手环设备编号tenantIdLong集团 ID由网关或拦截器注入campusIdLong园所 ID多园共用一台设备时必传后端实现用上一次状态取反的方式记进出PostMapping(/checkin) public RCheckinVO checkin(RequestBody CheckinDTO dto, RequestAttribute(userId) Long userId) { Student student studentService.lookupByDeviceNo( dto.getDeviceNo(), dto.getTenantId()); if (student null) { return R.fail(设备未绑定幼儿); } AttendanceLog log attendanceService.checkin( student.getId(), dto.getDeviceNo(), LocalDate.now()); if (log.isRepeated()) { return R.ok(CheckinVO.already(student)); } // 异步推送不阻塞门口闸机 pushService.asyncSendToGuardian(student, 宝宝在 log.getCheckinTime() 已入园); return R.ok(CheckinVO.of(log)); }service 层判断进出public AttendanceLog checkin(Long studentId, String deviceNo, LocalDate today) { AttendanceLog last baseMapper.selectOne(new LambdaQueryWrapperAttendanceLog() .eq(AttendanceLog::getStudentId, studentId) .eq(AttendanceLog::getCheckDate, today) .orderByDesc(AttendanceLog::getId) .last(limit 1)); AttendanceLog log new AttendanceLog(); log.setStudentId(studentId); log.setDeviceNo(deviceNo); log.setCheckDate(today); log.setType(last null || last.getType() OUT ? IN : OUT); save(log); return log; }说明进出转换比只记入园更贴近幼儿园实际。同一天最后一次记录是离园再刷就按入园记门口设备 double 触发时第二次只追加一条记录推送是否重复由调用方用 isRepeated 判断。出勤率统计按 checkDate 加 type 聚合进和出分开看。4.2 食谱周计划集团默认食谱与园所覆盖食谱是幼教集团最容易复用的数据。集团出一套统一的周计划各园可以覆盖表结构这样设计CREATE TABLE IF NOT EXISTS recipe ( id BIGINT PRIMARY KEY AUTO_INCREMENT, recipe_name VARCHAR(64) NOT NULL, meal_type TINYINT NOT NULL COMMENT 1早餐 2午餐 3下午点心 4晚托, ingredients_json JSON NOT NULL COMMENT 食材及克重, calories INT COMMENT 千卡, tenant_id BIGINT NOT NULL ); CREATE TABLE IF NOT EXISTS recipe_week_plan ( id BIGINT PRIMARY KEY AUTO_INCREMENT, campus_id BIGINT NOT NULL COMMENT 园所ID0 表示集团默认, week_start DATE NOT NULL COMMENT 周一的日期, day_of_week TINYINT NOT NULL, recipe_id BIGINT NOT NULL, tenant_id BIGINT NOT NULL );说明查询时先取本园记录取不到再取 campus_id0 的集团默认食谱这个逻辑写在一个 mapper 里用 UNION 实现。家长端按周展示数据量不大但要给早高峰做缓存key 用 week:campus:date。食谱和班级相册都有转发给家庭成员的需求小程序端用 onShareAppMessage 引导好友分享App 端用 uni.share 调起系统面板分享入口建议做进组件 prop不写死在页面逻辑里。4.3 推送统一抽象小程序订阅消息与 App 厂商通道家长端要实时收到入园通知小程序走订阅消息App 走厂商推送通道接口完全不一样。源码里建议先抽象一个接口public interface PushService { void sendToTeacher(String openId, String title, String content); void sendToParent(Guardian guardian, String content, String bizId); }说明小程序订阅消息要求家长先授权一次才能推一条App 端走 uni-push 或各厂商通道两个实现类内部差异极大但业务侧只依赖 sendToParent。这个抽象层值得单独建包后续加短信通知、企业微信机器人时只加实现类不动调用方。4.4 园长驾驶舱跨园统计 SQL 与租户插件的坑园长驾驶舱最常见的报表是近 7 天各园在园人数和迟到数SELECT c.org_name AS class_name, DATE_FORMAT(al.check_date, %Y-%m-%d) AS day, COUNT(DISTINCT al.student_id) AS real_count, SUM(CASE WHEN al.type 1 THEN 1 ELSE 0 END) AS in_count FROM attendance_log al JOIN sys_org c ON al.class_id c.id AND c.org_type 3 WHERE al.tenant_id #{tenantId} AND al.check_date BETWEEN #{startDate} AND #{endDate} GROUP BY c.org_name, DATE_FORMAT(al.check_date, %Y-%m-%d) ORDER BY day DESC, real_count DESC说明这条 SQL 依赖 2.2 里的 ignoreTable 配置。sys_org 没有参与租户插件过滤所以 join 后能正常按 org_type 过滤班级如果 sys_org 没放行插件会给它拼第二段 tenant_id集团视角的数据会被重复过滤统计结果偏少。排查这类问题打开 MyBatis 日志看 SQL 里是否出现两个 tenant_id 条件即可。COUNT(DISTINCT student_id) 是为了防门口设备重复上报同一幼儿同一天有多条进出记录统计出勤人数时必须先去重。5. 交付阶段的三件小事签名打包、yml 密文与 Vue2 转 Vue35.1 安卓上架前把签名和启动页一次配齐安卓上架应用市场前先生成自有签名证书keytool -genkey -alias kidergarten -keyalg RSA -keysize 2048 \ -validity 36500 -keystore kidergarten.keystore说明证书和密码要留存备份应用市场包名与签名一旦不一致后续升级版本全部装不上。签名信息填进 manifest.json 后云打包才会用自有证书。iOS 打包走证书加描述文件同样在 manifest 里配置注意开发证书和发布证书不能混用。启动图和加载页在App 启动界面里配置冷启动体验差时先看这里默认配置的加载页往往不是想要的品牌图。上架前再检查一遍隐私弹窗首次启动必须弹说明用户同意后才初始化推送和统计 SDK顺序反了会被市场打回这是修改刚进入的加载页面之外最容易被忽略的一步。5.2 SpringBoot 生产配置用 Jasypt 写密文数据库口令、Redis 密码直接写在 yml 里源码一泄露全部跟着漏。引入 jasypt-spring-boot-starter 后把敏感值加密jasypt: encryptor: password: ${JASYPT_PWD} algorithm: PBEWITHHMACSHA512ANDAES_256 spring: datasource: password: ENC(3uN8Y1xQcB9y5dKpF7wLv0...)说明ENC() 括号里是加密后的密文解密密钥从环境变量 JASYPT_PWD 注入不落仓库。业务侧代码不用改SpringBoot 启动时自动解密配置文件里 README 只写一句需要设置 JASYPT_PWD 环境变量即可。5.3 Vue2 转 Vue3 的兼容清单新项目建议直接 Vue3老源码迁移时按这四个点排查main.js 里 Vue.prototype.$toast 改为 app.config.globalProperties.$toastfilters 在 Vue3 已移除改成 computed 或在模板里调用方法多值 v-model 拆成 :modelValue 加 update:modelValue$parent、$listeners 的写法不再可靠改用 provide/inject迁移时先在仓库里全局搜索 Vue.prototype、filters、$set 三个关键字按出现次数排优先级。Uniapp 切换 Vue3 编译器后manifest.json 要重新选编译版本页面样式和组件树顺序在 iOS 上可能出现差异建议切完后在真机上把考勤、食谱、推送三条关键链路各过一遍再发版。本文还有配套的精品资源点击获取
返回列表