1. 项目缘起:从单体应用到SaaS化日报管理的思考
最近几年,我接触了不少中小企业的内部管理系统需求,其中“日报/周报管理”几乎是每个团队都绕不开的话题。一开始,大家可能就是用Excel表格在群里传来传去,或者用共享文档。但随着团队规模扩大,这种方式的弊端就暴露无遗:格式混乱、难以统计、无法关联任务、领导批阅不便,更别提移动端填写了。很多公司会考虑采购现成的OA系统,但往往功能臃肿、价格不菲,而且一些定制化需求(比如和自家项目管理工具打通)很难实现。
于是,自己动手搭建一个轻量、灵活、可多租户使用的SaaS化日报管理系统,就成了一个性价比很高的选择。这不仅仅是做一个CRUD(增删改查)应用,更涉及到如何设计一个支持多企业、数据隔离、可配置的后台架构,以及如何提供一个体验流畅、响应迅速的前端界面。Vue 3凭借其组合式API带来的极致开发体验和性能优势,Spring Boot以其“约定大于配置”的快速开发能力,成为了实现这个想法的最佳技术组合。这个项目,就是一次将这套技术栈应用于一个真实业务场景的完整实践,我会把从技术选型、架构设计到具体编码、部署上线的全过程细节,以及其中踩过的坑和总结的经验,毫无保留地分享出来。
2. 技术栈深度选型:为什么是Vue 3 + Spring Boot?
在启动任何项目前,理性的技术选型是成功的基石。面对琳琅满目的前端框架和后端技术,我最终锚定了Vue 3和Spring Boot,这背后是一系列务实的考量,而不仅仅是追逐热门。
2.1 前端:Vue 3的组合式API与生态成熟度
首先看前端。Vue 3相较于Vue 2是一次巨大的革新,其核心优势在于组合式API。在开发企业级应用,尤其是像日报系统这样带有复杂表单、状态管理和交互的模块时,选项式API的代码组织方式会显得有些力不从心。相关的逻辑(如一个日报表单的验证、提交、草稿保存)可能会被分散在data、methods、computed等多个选项中,当组件变得复杂时,理解和维护成本会急剧上升。
组合式API允许我们将与同一个功能相关的所有代码(响应式状态、计算属性、方法、生命周期钩子)聚合在一个setup函数或<script setup>语法糖中。例如,处理日报提交的逻辑可以封装成一个独立的useDailyReport组合式函数,里面包含了表单数据、验证规则、提交方法等。这种基于逻辑而非选项的组织方式,使得代码的可读性和可复用性得到了质的提升,非常适合中大型项目。
其次,性能与体积。Vue 3通过重写响应式系统(使用Proxy)、优化编译器(生成更高效的渲染函数)和引入Tree-shaking支持,使得打包后的体积更小,运行速度更快。对于需要快速加载的SaaS应用,这一点至关重要。
再者,完整的TypeScript支持。Vue 3的源码就是用TypeScript重写的,提供了完美的类型推断。在日报系统中,定义清晰的日报接口、用户接口、审批流接口,能极大减少低级错误,提升开发效率和代码质量。配合VSCode和Volar插件,开发体验非常流畅。
最后,生态与社区。Vue的生态已经非常成熟,对于管理系统,UI库有Element Plus、Ant Design Vue等优秀选择;路由有Vue Router 4;状态管理有Pinia(官方推荐,比Vuex更简洁)。这些库都对Vue 3提供了优先支持,整合起来非常顺畅。
2.2 后端:Spring Boot的“开箱即用”与微服务亲和性
后端选择Spring Boot,理由同样充分。Spring Boot的核心哲学是约定大于配置,它通过自动配置和起步依赖,极大地简化了基于Spring的应用初始搭建和开发过程。对于日报系统后端,我们可能需要Web服务、数据访问(MyBatis/JPA)、安全控制(Spring Security)、缓存(Redis)、消息队列(可选,用于异步发送通知)等一系列组件。如果从零开始整合这些,会耗费大量时间在XML或Java配置上。
Spring Boot的spring-boot-starter-*依赖可以让我们一键引入所需功能。例如,添加spring-boot-starter-data-jpa和mysql-connector-java,配置好数据库连接,就可以立刻开始编写实体类和Repository,几乎无需额外配置。这让我们能快速将精力聚焦在业务逻辑,也就是日报的增删改查、多租户数据隔离、权限校验等核心功能上。
其次,Spring Boot内嵌了Tomcat、Jetty或Undertow等Servlet容器,这意味着我们可以将应用打包成一个可执行的JAR文件,部署时只需要Java运行环境,简化了部署流程。这对于后期可能的Docker容器化部署也非常友好。
更重要的是,对微服务架构的天然支持。虽然我们初版的日报系统可能是一个单体应用,但SaaS系统随着租户增多、功能复杂,未来有向微服务演进的可能。Spring Boot是Spring Cloud微服务套件的基石,其简洁的配置和模块化设计,使得未来拆分成用户服务、日报服务、审批服务等独立模块时,迁移成本相对较低。
技术栈协同考量:Vue 3构建的前端是纯静态资源,可以独立部署在Nginx或CDN上。Spring Boot后端提供纯净的RESTful API。前后端通过HTTP/HTTPS协议通信,完全解耦。这种模式便于团队前后端分离开发、独立部署和水平扩展。在日报系统中,前端负责渲染复杂的表单、时间线视图,后端则专注于数据一致性、业务规则和安全保障。
3. 核心架构设计:多租户SaaS系统的数据隔离与扩展性
搭建一个企业级SaaS系统,区别于单企业部署系统的核心在于多租户架构。我们需要让多个企业(租户)共享同一套应用程序实例,但他们的数据必须严格隔离,互不可见。同时,系统需要具备良好的可配置性和扩展性,以应对不同企业的个性化需求。
3.1 多租户数据隔离方案选型
常见的多租户数据隔离方案有三种:
- 独立数据库:每个租户拥有自己独立的数据库。安全性最高,性能最好,但运维成本和资源消耗也最大。
- 共享数据库,独立Schema:所有租户共享同一个数据库实例,但每个租户有自己的一套表(Schema)。在MySQL中,Schema基本等同于Database。这种方式在隔离性和资源利用率间取得平衡。
- 共享数据库,共享Schema:所有租户的数据都存放在同一套表结构中,通过一个
tenant_id字段来区分数据。成本最低,但数据隔离完全依赖应用层代码,设计和查询复杂度较高,且有潜在的数据泄露风险。
对于中小型日报管理系统,我推荐采用方案三,即共享Schema,通过tenant_id区分。理由如下:
- 成本可控:初创期租户数量不多,数据量不大,单一数据库实例完全足够。
- 开发敏捷:无需动态创建数据库或Schema,简化了租户 onboarding(入驻)流程。新企业注册,无非是在用户表里多了一条带
tenant_id的记录。 - 运维简单:备份、监控、升级都只需要针对一个数据库。
注意:选择共享Schema方案,必须在每一次数据库查询中,都强制带上
tenant_id条件。这是一个铁律,任何疏忽都可能导致数据越权访问。这需要通过技术手段在框架层面解决,而不是依赖开发人员手动添加。
3.2 基于Spring Boot的租户上下文管理
为了实现透明的、无侵入的租户数据隔离,我们需要在请求进入后端时,就识别出当前请求属于哪个租户,并将这个租户ID存储在一个“上下文”中,供后续的数据访问层自动使用。
实现思路:
租户标识获取:通常,租户信息可以通过以下方式传递:
- 子域名:如
companyA.daily-system.com。利用Nginx或网关解析子域名,将其作为租户标识放入请求头(如X-Tenant-ID)。 - 请求路径:如
/api/tenant/companyA/daily-reports。但这会污染API路径,不推荐。 - JWT令牌:用户登录后,颁发的JWT令牌中直接包含
tenant_id声明。这是最常用、最安全的方式,因为它与用户身份绑定。 在本项目中,我采用JWT方案。用户登录时,后端根据其所属企业生成包含tenant_id的JWT令牌。前端在后续所有请求的Authorization头中携带此令牌。
- 子域名:如
创建租户上下文:定义一个
TenantContext类,内部使用ThreadLocal来存储当前线程的租户ID。因为Web请求通常是一个线程处理一个,ThreadLocal可以完美实现请求级别的租户隔离。public class TenantContext { private static final ThreadLocal<String> CURRENT_TENANT = new ThreadLocal<>(); public static void setCurrentTenant(String tenantId) { CURRENT_TENANT.set(tenantId); } public static String getCurrentTenant() { return CURRENT_TENANT.get(); } public static void clear() { CURRENT_TENANT.remove(); } }拦截器解析租户:创建一个Spring MVC拦截器或Spring Security过滤器,在请求到达Controller之前,从JWT令牌中解析出
tenant_id,并调用TenantContext.setCurrentTenant(tenantId)将其存入上下文。数据层自动过滤:这是最关键的一步。我们需要让MyBatis或JPA在生成SQL时,自动为查询条件加上
tenant_id = ?。以MyBatis为例,可以使用其提供的插件机制。- 实现一个
Interceptor,拦截Executor的query和update方法。 - 通过反射,获取原始的SQL语句和参数对象。
- 分析SQL语句,如果是针对需要租户隔离的表(如
daily_report,user等),则修改SQL,在WHERE条件中动态添加tenant_id = #{tenantId}。这个tenantId就从TenantContext.getCurrentTenant()获取。 - 同时,在插入(INSERT)数据时,插件也需要自动将
tenant_id字段的值设置上。
- 实现一个
通过这套机制,业务开发人员在编写DAO层代码时,几乎可以完全忘记多租户的存在,就像在开发单租户系统一样。所有的数据隔离工作,都在框架层面自动、强制地完成了,极大地提升了开发效率并降低了出错风险。
3.3 前后端分离下的API与状态管理设计
前端采用Vue 3,我们使用Pinia作为状态管理库。对于日报系统,需要全局管理的状态包括:用户信息(含tenant_id)、登录状态、当前企业的配置信息等。
API设计遵循RESTful风格,但需要为多租户做一点适配。虽然数据隔离由后端自动完成,但API路径本身可以保持简洁。例如:
GET /api/daily-reports:获取当前用户(其租户上下文已确定)的日报列表。POST /api/daily-reports:创建一篇日报,请求体中包含日报内容,租户ID由后端自动从上下文中获取并填充。GET /api/admin/tenants:这是一个系统级管理接口,只有超级管理员能访问,用于管理所有租户企业。这类接口需要特殊的权限控制,与租户隔离的业务接口分开。
前端通过Axios发起请求,并配置请求拦截器,自动为每个请求加上携带JWT的Authorization头。响应拦截器则统一处理常见的错误,如401(未授权/令牌过期)跳转登录页,403(无权限)提示用户,500服务器错误进行友好提示等。
4. 核心功能模块实现详解
有了稳固的架构基础,我们就可以开始实现具体的业务功能了。日报管理系统的核心模块主要包括:用户认证与权限、日报的CRUD与查询、以及审批流程。
4.1 用户体系、JWT认证与权限控制
用户是系统的入口。我们需要区分系统管理员(管理所有租户企业)、企业管理员(管理本企业内的用户和角色)和普通员工。这里采用经典的RBAC(基于角色的权限控制)模型。
后端实现(Spring Security + JWT):
- 实体设计:
User实体关联Tenant(租户),Role角色表,以及User_Role关联表。Role关联Permission权限表。 - 登录流程:
- 用户提交用户名、密码。
UserDetailsService根据用户名加载用户信息,并验证密码。- 验证通过后,使用JJWT等库生成JWT令牌。令牌的Payload中应包含关键信息:
userId,username,tenantId,authorities(权限列表)。 - 将JWT返回给前端,同时可以将用户基本信息(如昵称、角色)也一并返回,方便前端直接展示。
- 认证过滤器:自定义一个
JwtAuthenticationFilter,放在Spring Security过滤器链中。它负责:- 从请求头
Authorization中提取JWT。 - 验证JWT的签名和有效期。
- 解析JWT,重新构建
Authentication对象(包含用户信息和权限),并存入SecurityContextHolder。这样,在后续的Controller中,就可以通过@AuthenticationPrincipal注解获取当前用户。
- 从请求头
- 权限校验:使用Spring Security的注解式控制。
@PreAuthorize("hasRole('ADMIN')"):要求具有ADMIN角色。@PreAuthorize("hasAuthority('daily:write')"):要求具有“日报:写”的权限。- 对于租户隔离的接口,我们不需要在注解中显式检查
tenant_id,因为数据层插件已经保证了数据安全。但有时需要业务逻辑上的校验,例如“企业管理员只能管理自己企业的用户”,这需要在Service层代码中,比较当前用户的tenantId和操作目标的tenantId是否一致。
前端实现(Vue 3 + Pinia + 路由守卫):
- 登录与令牌存储:用户登录成功后,将后端返回的JWT令牌和用户信息存储到Pinia的
authStore中,同时持久化到localStorage或sessionStorage,防止页面刷新后状态丢失。 - 请求拦截:在Axios的请求拦截器中,从
authStore读取token,并添加到每个请求的Header中。 - 路由守卫:使用Vue Router的导航守卫,在进入需要权限的路由前进行检查。
// 在路由守卫中 const isAuthenticated = authStore.isAuthenticated; // 检查Pinia中是否有token/user const requiredRole = to.meta.requiredRole; // 路由元信息中定义的所需角色 if (!isAuthenticated) { // 跳转到登录页 return { path: '/login' }; } if (requiredRole && !authStore.user.roles.includes(requiredRole)) { // 无权限,跳转到403页面 return { path: '/403' }; } - 按钮级权限:可以封装一个权限判断的函数或自定义指令
v-permission,在模板中控制按钮或组件的显示/隐藏。
4.2 日报实体设计与富文本编辑集成
日报的核心是内容。一个典型的日报实体可能包含以下字段:
public class DailyReport { private Long id; private String tenantId; // 租户ID,由插件自动填充 private Long authorId; // 作者ID private String title; // 日报标题 private String content; // 日报内容(富文本HTML) private LocalDate reportDate; // 汇报日期 private ReportStatus status; // 状态:草稿、已提交、已审阅... private Long reviewerId; // 审阅人ID private String reviewComment; // 审阅意见 private LocalDateTime submitTime; // 提交时间 private LocalDateTime reviewTime; // 审阅时间 // ... 其他字段如关联的项目、任务等 }其中,content字段存储富文本。前端我们需要一个强大的富文本编辑器。WangEditor或Quill都是不错的选择,它们轻量、开源、易于定制。以集成WangEditor V5(Vue 3版本)为例:
- 安装与引入:
npm install @wangeditor/editor @wangeditor/editor-for-vue - 封装成Vue组件:创建一个
RichTextEditor.vue组件,在其setup中初始化编辑器实例,并通过v-model与父组件的日报内容数据双向绑定。 - 图片上传处理:富文本编辑器通常需要上传图片。我们需要配置编辑器的图片上传菜单,将图片上传到后端的一个接口(如
/api/upload/image)。这个接口需要:- 验证用户权限。
- 将图片文件保存到服务器磁盘或对象存储(如阿里云OSS、MinIO)。
- 生成一个可以公开访问的URL(或带有时效性的签名URL)返回给前端。
- 前端编辑器用这个URL替换图片的
src。
重要经验:绝对不要让富文本编辑器直接提交Base64格式的图片到
content字段!这会导致数据库字段巨大,且难以管理和迁移。一定要走单独的文件上传接口。 - 内容清理与安全:后端在保存
content之前,必须进行HTML净化,防止XSS攻击。可以使用像Jsoup这样的库,只允许安全的HTML标签和属性通过。
4.3 复杂查询、分页与统计功能
日报列表页面通常需要支持多种查询条件:按日期范围、按状态(我的草稿、已提交、待我审阅)、按关键词(标题/内容搜索)、按提交人等。
后端实现:
- 使用MyBatis动态SQL:在Mapper XML中,使用
<if>标签根据传入的参数动态拼接WHERE条件。这是处理复杂查询最灵活的方式。<select id="selectReportList" resultMap="ReportResultMap"> SELECT * FROM daily_report WHERE tenant_id = #{tenantId} <if test="params.authorId != null"> AND author_id = #{params.authorId} </if> <if test="params.status != null"> AND status = #{params.status} </if> <if test="params.startDate != null"> AND report_date >= #{params.startDate} </if> <if test="params.endDate != null"> AND report_date <= #{params.endDate} </if> <if test="params.keyword != null and params.keyword != ''"> AND (title LIKE CONCAT('%', #{params.keyword}, '%') OR content LIKE CONCAT('%', #{params.keyword}, '%')) </if> ORDER BY report_date DESC, submit_time DESC </select> - 分页:使用PageHelper插件。在Service层方法开始处调用
PageHelper.startPage(pageNum, pageSize),后续的查询就会自动进行物理分页。返回的对象包含了分页数据(列表)和分页信息(总条数、总页数等),非常适合返回给前端。 - 统计功能:例如,首页需要展示“本周已提交日报数”、“待审阅日报数”。这类统计查询需要编写单独的SQL,通常使用
COUNT、GROUP BY等聚合函数。为了避免N+1查询问题,应尽量在一个SQL中完成,或者使用MyBatis的关联查询。
前端实现:
- 查询表单:使用Element Plus的Form组件,绑定一个查询参数对象。
- 表格与分页:使用Element Plus的Table和Pagination组件。表格数据来自后端分页接口的返回。当页码或每页条数改变时,重新发起请求。
- 日期范围选择:使用
el-date-picker组件,设置type="daterange",可以方便地选择开始和结束日期。提交给后端时,需要将其拆分成两个参数。
4.4 审批流程的状态机设计
日报的审批是一个简单的状态流转:草稿 -> 已提交 -> 已审阅/已驳回。我们可以用一个枚举ReportStatus来定义状态。但更严谨的做法是引入一个轻量级的状态机,明确定义状态之间的转换关系和触发条件。
后端状态机实现: 可以使用Spring State Machine或更轻量的squirrel-foundation。这里以简单的枚举+Service层逻辑为例:
public enum ReportStatus { DRAFT, SUBMITTED, REVIEWED, REJECTED; // 判断是否可以从当前状态转换到目标状态 public boolean canTransitionTo(ReportStatus targetStatus, User currentUser, Report report) { switch (this) { case DRAFT: return targetStatus == SUBMITTED && report.getAuthorId().equals(currentUser.getId()); case SUBMITTED: return (targetStatus == REVIEWED || targetStatus == REJECTED) && report.getReviewerId().equals(currentUser.getId()); case REVIEWED: case REJECTED: return false; // 终态,不允许再转换 default: return false; } } }在Service层的submitReport、reviewReport方法中,先获取日报的当前状态,然后调用currentStatus.canTransitionTo(targetStatus, currentUser, report)进行校验,校验通过才更新状态,并记录操作日志。同时,状态变更可以触发事件,例如,当日报被审阅后,发送一条系统通知或邮件给提交者。
前端状态流转: 前端根据日报的status字段,显示不同的标签和操作按钮。例如,状态为SUBMITTED且当前用户是审阅人时,才显示“通过”和“驳回”按钮。点击按钮后,调用对应的后端API。
5. 开发、部署与运维实战要点
5.1 前后端分离的联调与跨域问题
开发阶段,前端运行在localhost:5173(Vite默认端口),后端运行在localhost:8080。浏览器出于安全考虑,会阻止跨域请求。解决方案有两种:
- 后端配置CORS:在Spring Boot的配置类或全局过滤器中添加CORS配置,允许前端的源、方法和头信息。
@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/api/**") // 针对所有/api开头的接口 .allowedOrigins("http://localhost:5173") // 允许的前端地址 .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true); // 允许携带cookie等凭证 } } - 前端代理(推荐):在Vite的配置文件
vite.config.js中配置代理,将/api开头的请求转发到后端服务器。这样前端代码中请求的URL可以写成相对路径/api/xxx,避免了硬编码后端地址,也更接近生产环境。export default defineConfig({ server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, // rewrite: (path) => path.replace(/^\/api/, '') // 如果需要重写路径 } } } })
5.2 生产环境部署:Nginx配置与HTTPS
生产环境部署,通常采用以下架构:
用户 -> Nginx (HTTPS/负载均衡/静态资源) -> Spring Boot应用 (JAR)- 前端:执行
npm run build生成dist目录下的静态文件(HTML, JS, CSS)。将这些文件放到Nginx的某个目录下(如/usr/share/nginx/html/daily-admin)。 - 后端:使用
mvn clean package打包生成可执行的JAR文件(如daily-system-1.0.0.jar)。通过java -jar命令启动,或使用 systemd、Docker 等方式托管。 - Nginx配置:一个关键的Nginx配置示例如下:
这个配置实现了:HTTP自动跳转HTTPS、静态文件服务、API请求转发。如果采用多租户子域名模式,需要配置通配符SSL证书,并将server { listen 80; server_name your-domain.com; # 或 *.your-domain.com 用于多租户子域名 # 强制跳转HTTPS return 301 https://$server_name$request_uri; } server { listen 443 ssl http2; server_name your-domain.com; # SSL证书配置 ssl_certificate /path/to/your/cert.pem; ssl_certificate_key /path/to/your/key.pem; # 前端静态资源 location / { root /usr/share/nginx/html/daily-admin; index index.html; try_files $uri $uri/ /index.html; # 支持Vue Router的history模式 } # 后端API代理 location /api/ { proxy_pass http://localhost:8080; # 后端Spring Boot应用地址 proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; # 如果使用WebSocket,可能需要以下配置 # proxy_http_version 1.1; # proxy_set_header Upgrade $http_upgrade; # proxy_set_header Connection "upgrade"; } # 可能还有上传文件的代理 location /upload/ { proxy_pass http://localhost:8080/upload/; # ... 同上proxy_set_header配置 } }server_name改为*.your-domain.com,并在后端解析请求头中的Host或自定义头来获取租户标识。
5.3 数据库优化与缓存策略
随着数据量增长,一些查询可能会变慢。
- 索引优化:在经常用于查询条件的字段上建立索引,如
daily_report表的(tenant_id, report_date)、(tenant_id, author_id, status)联合索引。使用EXPLAIN命令分析慢查询SQL的执行计划。 - 引入缓存:对于不经常变化但频繁访问的数据,可以使用Redis进行缓存。例如:
- 用户信息缓存:用户登录后,将其信息缓存一段时间,避免每次鉴权都查数据库。
- 企业配置缓存:每个租户的系统配置(如日报提交截止时间、可用字段等)。
- 首页统计看板数据:可以定时任务计算并缓存,避免每次打开首页都执行复杂的聚合查询。 在Spring Boot中,整合Redis并使用
@Cacheable、@CacheEvict注解可以轻松实现声明式缓存。
5.4 监控、日志与问题排查
一个健壮的系统离不开可观测性。
- 应用日志:使用SLF4J + Logback,合理设置日志级别(INFO, WARN, ERROR)。日志中要包含请求ID(或Trace ID)、用户ID、租户ID,方便串联一次请求的所有日志。将日志输出到文件,并使用Logstash等工具收集到ELK(Elasticsearch, Logstash, Kibana)栈进行集中查看和分析。
- 健康检查:Spring Boot Actuator提供了
/actuator/health、/actuator/metrics等端点,可以监控应用状态、JVM内存、数据库连接池等。在生产环境,应通过Spring Security保护这些端点,或仅允许内网访问。 - APM工具:对于更深入的性能监控,可以考虑集成SkyWalking、Pinpoint等APM工具,它们可以追踪分布式请求链路,定位慢SQL、慢方法。
6. 典型问题排查与进阶思考
在实际开发和运维中,总会遇到一些“坑”。这里分享几个我遇到过的典型问题及其解决方案。
6.1 前端路由在刷新后404的问题
这是Vue Router使用history模式部署到Nginx时的经典问题。用户直接访问/daily-reports或刷新该页面时,Nginx会把这个路径当作一个真实的文件请求去root目录下查找,当然找不到,于是返回404。解决方案:正如前面Nginx配置所示,关键是在处理前端路由的location /块中,加入try_files $uri $uri/ /index.html;这行配置。它的意思是:先尝试找请求的文件($uri),再尝试找对应的目录($uri/),如果都找不到,最后返回/index.html文件。Vue应用加载后,路由器就能根据URL路径正确渲染对应的组件了。
6.2 MyBatis多租户插件与复杂SQL的冲突
我们实现了自动添加tenant_id条件的MyBatis插件。但在某些复杂场景下,插件可能“误伤”或“遗漏”。
- 场景一:LEFT JOIN查询。插件可能只在主表的WHERE条件后添加了
tenant_id,但如果关联的子表也需要租户隔离,就会漏掉。解决方案是,在插件中更精细地解析SQL,识别出所有需要隔离的表别名,并为其添加条件。这需要较强的SQL解析能力。 - 场景二:UNION查询。插件可能无法正确处理UNION两边的SELECT语句。一个更稳妥的方案是,在编写这类复杂SQL时,手动在Mapper XML中显式地加上
tenant_id = #{tenantId}条件,并告诉插件跳过对该语句的修改(可以通过在Mapper方法上加自定义注解来实现)。 - 场景三:系统级查询。有些查询需要跨租户,比如超级管理员查看所有企业的活跃度统计。这类查询应该使用独立的Mapper方法,并显式标注(如
@IgnoreTenantFilter),让插件跳过处理。
实操心得:多租户插件是保证数据安全的利器,但必须对其进行充分的单元测试和集成测试,覆盖各种复杂的SQL场景。在项目初期,可以采取“插件自动过滤 + 关键处手动校验”的双保险策略。
6.3 大文件上传与断点续传
日报系统如果支持附件上传,可能会遇到大文件(如几十兆的视频或设计图)上传超时或失败的问题。
- 前端分片:使用库(如
simple-uploader.js)将文件切割成多个小块(chunk)。 - 后端合并:为每个文件生成一个唯一标识(如UUID)。前端上传每个分片时,都携带这个标识和分片序号。后端将分片临时存储。全部分片上传完成后,前端发送一个合并请求,后端按序号将临时文件合并成最终文件。
- 断点续传:在上传前,前端先计算文件的MD5等哈希值作为标识。上传每个分片前,先询问后端该分片是否已上传(秒传)。这样即使网络中断,恢复后也可以只传剩余的分片。
- Spring Boot后端实现:需要两个关键接口:
/api/upload/chunk(上传分片) 和/api/upload/merge(合并文件)。注意处理并发上传和临时文件清理。
6.4 系统扩展性思考:从单体到微服务
当租户数量达到数百上千,日报量巨大时,单体应用可能会遇到性能瓶颈。此时可以考虑向微服务演进。
- 拆分维度:可以按业务域拆分,例如:
- 用户中心服务:负责认证、授权、用户管理。
- 日报核心服务:负责日报的CRUD、查询、统计。
- 审批流程服务:负责状态流转、通知发送。
- 文件服务:专门处理文件上传、下载、存储。
- 技术挑战:
- 分布式事务:比如“提交日报”需要同时更新日报状态和发送通知,可能涉及两个服务。可以考虑使用最终一致性方案,如基于消息队列(RabbitMQ/RocketMQ)的事件驱动架构。
- 服务发现与通信:使用Nacos、Consul作为注册中心,服务间通过OpenFeign进行声明式HTTP调用。
- 统一网关:使用Spring Cloud Gateway作为API网关,统一处理路由、认证、限流、日志。
- 配置中心:将各服务的配置(如数据库连接、Redis地址)统一管理在Nacos Config或Apollo中。 这个演进过程是循序渐进的,初期可以先将压力最大的模块(如文件服务)独立出去,逐步积累微服务治理的经验。
搭建这样一个系统,是一个不断权衡、决策和迭代的过程。没有完美的架构,只有适合当前阶段和未来预期的架构。从Vue 3和Spring Boot的基础使用,到多租户架构的设计,再到生产环境的部署优化,每一步都需要结合具体的业务需求和技术团队的实际情况来做出选择。这个项目不仅是一个工具的实现,更是一次对现代Web全栈开发、SaaS模式理解的深度实践。希望这些详实的步骤和经验,能为你带来切实的帮助。