针对于很多刚开始上手设计系统的小伙伴,很多东西都是一个不断踩坑优化的过程,回头看看自己做过的很多东西,是否还有很多感想
我们也是在开发中不断学习成长,从一开始的只会写业务的curd,到慢慢有了封装的思想,到完整架构设计,独当一面,一路走来虽磕磕绊绊但依然再不断坚持进不
一、技术选型,选择当下合适的方案
技术选型:以下面为例
| 层级 | 技术选型 | 理由 |
|---|---|---|
| 前端框架 | Vue 3 + Composition API | 最熟悉,快速出活 |
| 构建工具 | Vite | 快,开发体验好 |
| UI 组件库 | Ant Design Vue | 企业级后台标配 |
| 状态管理 | Pinia | Vue 3 官方推荐,比 Vuex 轻量 |
| HTTP 客户端 | Axios | 拦截器机制适合统一处理 |
| 图表 | ECharts + vue-echarts | 监控看板离不开图表 |
| 后端框架 | Spring Boot 3 | Java 生态成熟稳定 |
| 数据库 | MySQL | 轻量,够用 |
| ORM | Spring Data JPA | 少写 SQL,专注业务 |
| 数据库迁移 | Flyway | 版本化管理数据库变更 |
| 认证 | JWT | 无状态,适合前后端分离 |
| API 文档 | SpringDoc OpenAPI | 自动生成 Swagger 文档 |
后端运行在 8080 端口,前端通过 Vite 代理 /api 到后端。分开部署,互不干扰。
二、后端先行:打好地基
2.1 第一个版本的"草稿"
最开始写后端的时候,我没有想太多架构,先让接口跑起来。我写了几个 Controller,每个 Controller 里直接注入 Repository,在 Controller 里直接写业务逻辑:
@RestController
@RequestMapping("/api/users")
public class UserController {
private final UserRepository userRepository;
@GetMapping
public List<UserEntity> list() {
return userRepository.findAll();
}
}
这样做在项目初期完全没有问题——代码少,逻辑简单,改起来快。但当业务逻辑开始变多(角色管理、权限分配、树形结构查询),Controller 里的代码迅速膨胀。
2.2 引入 Service 层
大概在第二个模块写完的时候,Controller 已经到了一个难以维护的体积。我决定引入 Service 层:
@Service
public class RoleServiceImpl implements RoleService {
private final RoleRepository roleRepository;
private final RoleMenuRepository roleMenuRepository;
private final MenuRepository menuRepository;
public RoleServiceImpl(...) { /* 构造器注入 */ }
@Transactional
public RoleView create(RoleSaveRequest request) {
// 校验公司是否存在
if (!companyRepository.existsById(request.getCompanyId())) {
throw new EntityNotFoundException("公司不存在");
}
// 校验编码唯一性
if (roleRepository.existsByCodeAndCompanyId(...)) {
throw new IllegalArgumentException("该公司下角色编码已存在");
}
// 创建实体
RoleEntity role = new RoleEntity();
applyRequest(role, request);
return toView(roleRepository.save(role));
}
}
Controller 变成了这样:
@GetMapping("/{id}")
public ApiResponse<RoleView> getById(@PathVariable Long id) {
return ApiResponse.success(roleService.getById(id));
}
这里学到的:Controller 负责接收请求和返回响应,业务逻辑必须下沉到 Service 层。这不是过度设计,而是随着业务复杂度增长必然会发生的事情。
2.3 数据库迁移:Flyway 的引入
一开始我用的是 JPA 的 ddl-auto: update,改实体类字段直接自动更新表结构。听起来很方便,但上线后问题来了:
- 测试环境改了字段,生产环境没有对应的变更记录
- 不知道哪些 SQL 执行过,哪些没执行过
- 团队协作时,各自改的数据库不一样
Flyway 解决了这个问题。每个数据库变更都写成一个版本化的 SQL 文件:
db/migration/
V1__init_schema.sql # 初始建表
V2__add_user_display_name.sql # 后续字段变更
应用启动时自动按顺序执行,确保每个环境的数据库版本一致。
2.4 统一响应格式
最早各个接口返回的数据格式不统一,有的返回实体,有的返回 Map,有的返回 List。前端 axios 拦截器里每次都要处理各种格式。
后来统一了响应格式:
public class ApiResponse<T> {
private final boolean success;
private final int code;
private final String message;
private final T data;
public static <T> ApiResponse<T> success(T data) {
return new ApiResponse<>(true, 0, "ok", data);
}
public static <T> ApiResponse<T> fail(int code, String message) {
return new ApiResponse<>(false, code, message, null);
}
}
从此所有接口返回格式一致,前端 axios 拦截器只需要处理一种情况:
api.interceptors.response.use((response) => {
const payload = response.data;
if (payload.success === false) {
return Promise.reject(new Error(payload.message));
}
return payload.data ?? null;
});
三、JWT 认证:走过的弯路
3.1 最开始的"朴素"方案
最开始我用的是 Spring Security 的表单登录,在后端维护 Session。这种方式在小项目里很常见,但我很快遇到了问题:
- 前端和后端分开部署,跨域情况下 Session 需要额外处理 Redis 存储
- 如果前端是 SPA,Session 的管理很麻烦
- 移动端或第三方接入时,Session 方案不友好
3.2 切换到 JWT
换成 JWT 无状态认证后,整个认证流程清晰了很多:
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/login").permitAll()
.requestMatchers("/v3/api-docs/**", "/swagger-ui/**").permitAll()
.requestMatchers("/api/**").authenticated()
)
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
JWT 过滤器在每个请求里提取 Token 并验证:
public class JwtAuthFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) {
String authHeader = request.getHeader("Authorization");
if (authHeader != null && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7);
try {
Claims claims = jwtUtil.parseToken(token);
String username = claims.get("username", String.class);
if (username != null && SecurityContextHolder.getContext()
.getAuthentication() == null) {
UserDetails userDetails =
userDetailsService.loadUserByUsername(username);
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
authToken.setDetails(
new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext()
.setAuthentication(authToken);
}
} catch (Exception ignored) { }
}
filterChain.doFilter(request, response);
}
}
踩的坑:第一次实现时,我把 Token 验证失败当作正常流程处理(直接忽略异常继续过滤链),这导致即使 Token 无效,请求也能通过过滤器进入 Controller。后来在 SecurityConfig 里明确配置了 authenticationEntryPoint 和 accessDeniedHandler,才把未认证和未授权两种情况正确区分开来。
3.3 前端 Token 处理
前端的 axios 拦截器和后端 JWT 配合:
api.interceptors.request.use((config) => ({
...config,
headers: {
...(getToken() ? { Authorization: `Bearer ${getToken()}` } : {}),
...(config.headers || {}),
},
}));
api.interceptors.response.use(
(response) => { /* 正常返回 */ },
(error) => {
if (status === 401 || status === 403) {
clearToken();
window.location.href = `/login?redirect=${encodeURIComponent(currentPath)}`;
}
return Promise.reject(error);
}
);
四、前端:Vue 3 的 Composition API 之旅
4.1 最开始的复制粘贴
后端接口ready后,我开始写前端。第一版写表格的方式是这样的——每个页面写一套:
const columns = [...];
const dataSource = ref([]);
const loading = ref(false);
const pagination = reactive({ current: 1, pageSize: 10, total: 0 });
async function load() {
loading.value = true;
try {
const res = await fetchUserList({ ...pagination, ...searchForm });
dataSource.value = res.data.records;
pagination.total = res.data.total;
} finally {
loading.value = false;
}
}
五个页面,每页这套逻辑写了约 50 行,加起来就是 250 行重复代码。当时觉得完全没问题——复制粘贴不累。
这是我踩的第一个前端坑:当时没有意识到"重复"在软件工程里的真正代价。当页面数量翻倍,这套逻辑开始变得难以维护。
4.2 useTable 组合式函数
写到第五个页面的时候,终于受不了了。每次写新页面都需要:复制粘贴状态和逻辑、修改 API 调用、修改响应解析、处理分页器事件、处理搜索重置。
我把这套逻辑抽成了一个通用的 composable:
export function useTable(fetchFn, options = {}) {
const queryParams = ref({ page: 1, pageSize: 10 });
const searchForm = ref({});
const dataSource = ref([]);
const total = ref(0);
const loading = ref(false);
async function load() {
loading.value = true;
const result = await fetchFn(buildParams());
// 智能解析各种响应格式...
dataSource.value = list;
total.value = totalCount;
}
watch(searchForm, () => {
resetPage();
load();
}, { deep: true });
return {
searchForm, dataSource, loading, pagination,
refresh, resetSearch, setPage, load,
};
}
有了这个之后,每个页面的代码从约 80 行缩减到 20 行:
const { searchForm, dataSource, loading, pagination, refresh } =
useTable((params) => fetchUserList(params), { immediate: true });
教训:重复代码的真正成本不在于"写的时候费劲",而在于"改的时候要改 N 处并且很容易漏改"。消除重复永远是第一优先级。
五、前后端的接口设计博弈
5.1 后端先写还是前端先写?
这是一个没有标准答案的问题。在实际开发中,我是交替进行的:
- 先在白纸上定义好数据结构和接口契约
- 后端先实现一个模块的接口
- 前端对接这个模块,同时后端实现下一个模块
- 发现契约问题及时调整
这样交替进行的最大好处是:前后端始终有一端是可用的,不会因为一方没完成而完全卡住。
5.2 分页格式的统一
后端的分页格式是 PageResponse:
public class PageResponse<T> {
private final List<T> records;
private final long total;
private final int page;
private final int pageSize;
}
前端 useTable 里的响应解析必须兼容这种格式:
if (result?.data != null) {
const pageData = result.data;
const list = Array.isArray(pageData)
? pageData
: (pageData.records ?? pageData.list ?? pageData.items ?? []);
dataSource.value = Array.isArray(list) ? list : [];
total.value = pageData.total ?? pageData.totalCount ?? list.length ?? 0;
}
这里踩的坑:后端早期用的是 Page<> 对象返回给前端,前端以为返回的是 { records, total } 的格式,但 JPA 的 Page 对象里字段名是 content 而不是 records。排查了半天才发现是字段名不对应。
5.3 树形结构的处理
菜单是树形结构,后端返回嵌套的 JSON:
[
{
"id": 1,
"path": "/system",
"title": "系统管理",
"children": [
{ "id": 2, "path": "/system/menu", "title": "菜单管理", "children": [] }
]
}
]
前端在 menu-registry.js 里用递归扁平化树:
function flattenEntries(nodes) {
const result = [];
for (const node of nodes) {
result.push(normalizeEntry(node));
if (node.children?.length) {
result.push(...flattenEntries(node.children));
}
}
return result;
}
同时后端也用递归构建树。这里有一个权衡:菜单数据量不大,两端都做树构建没有问题。但如果数据量大,应该只在后端做一次构建,前端直接使用。
六、后端角色权限系统的实现
6.1 权限模型的设计
权限系统的核心是角色-菜单的关联关系。我设计了三层结构:
用户 <--N:N--> 角色 <--N:N--> 菜单
对应的数据库表是 sys_user_role 和 sys_role_menu。这种多对多设计比直接在用户上挂权限灵活得多——一个用户可以有多个角色,权限继承关系自然形成。
6.2 角色菜单的分配
角色分配菜单权限时,后端采用"覆盖式"策略:
@Transactional
public void assignMenus(Long roleId, AssignMenuRequest request) {
if (!roleRepository.existsById(roleId)) {
throw new EntityNotFoundException("角色不存在");
}
// 先删除旧关联
roleMenuRepository.deleteByRoleId(roleId);
// 再插入新关联
List<RoleMenuEntity> relations = request.getMenuIds().stream()
.filter(menuRepository::existsById)
.map(menuId -> {
RoleMenuEntity relation = new RoleMenuEntity();
relation.setRoleId(roleId);
relation.setMenuId(menuId);
return relation;
})
.toList();
roleMenuRepository.saveAll(relations);
}
踩的坑:一开始没有加事务注解,deleteByRoleId 和 saveAll 不在同一个事务里,导致删除成功但插入失败时数据不一致。加上 @Transactional 后问题解决。
七、路由系统的反复折腾
7.1 最开始的手动配置
最开始,所有前端路由是手动写的:
const routes = [
{ path: '/system/users', component: () => import('../views/system/users/index.vue') },
{ path: '/system/roles', component: () => import('../views/system/roles/index.vue') },
// 每新增一个页面都要加一条...
];
页面一多,配置和实际文件之间很容易不同步——改了文件名但忘了改路由,或者删了页面但忘了删路由。
### 7.2 文件驱动的自动路由
我写了 `auto-routes.js`,通过 `import.meta.glob` 扫描 `views/` 目录自动生成路由:
```javascript
const viewModules = import.meta.glob("../views/**/*.vue");
function buildGeneratedRoutes() {
return Object.entries(viewModules)
.filter(([filePath]) => isRoutableView(filePath))
.map(([filePath, loader]) => buildRouteRecord(filePath, loader));
}
每个页面通过 routeMeta 导出元数据:
export const routeMeta = {
title: "角色管理",
order: 2,
affix: true, // 固定在标签页
};
这个设计的精妙之处在于:路由信息不需要写在配置文件里,它就长在页面文件自己身上。
7.3 后端菜单数据与前端路由的桥接
菜单从哪里来?这个问题我想了很久。前端路由定义了"有哪些页面",后端菜单定义了"用户能看到哪些页面"。两者需要桥接。
menu-registry.js 承担了这个职责:
export async function refreshMenuEntries() {
const remoteTree = await fetchMenuTree(); // 从后端拉菜单树
state.entries = flattenEntries(remoteTree); // 扁平化存储
state.initialized = true;
}
// 将菜单元数据合并到路由配置
function resolveRouteMeta(path, fallbackMeta = {}) {
const current = getEntry(normalizedPath);
return {
title: current?.title ?? fallbackMeta.title,
icon: current?.icon ?? fallbackMeta.icon,
order: current?.order ?? fallbackMeta.order ?? 0,
affix: current?.affix ?? fallbackMeta.affix === true,
// ...
};
}
八、后端菜单初始化:鸡生蛋的问题
8.1 启动时的数据初始化
菜单管理本身也是系统的一部分,但菜单数据需要先存在才能管理。这形成了一个"鸡生蛋"的问题。
我的解决方案是:在应用启动时自动初始化默认菜单。
@Bean
CommandLineRunner seedMenus(MenuService menuService, DataSeeder dataSeeder) {
return args -> {
menuService.initializeDefaultsIfEmpty();
dataSeeder.seedBaseData();
};
}
initializeDefaultsIfEmpty 检查数据库是否为空,为空则写入默认菜单数据:
@Transactional
public void initializeDefaultsIfEmpty() {
if (menuRepository.count() > 0) {
return; // 已有数据,跳过
}
saveDefaults(); // 写入 /home, /system, /system/menu
}
这样系统第一次启动时就有了一个可用的菜单骨架,后续可以通过管理页面继续扩展。
8.2 DataSeeder 的全量种子数据
除了菜单,我还用 DataSeeder 预置了完整的测试数据:
- 公司:智行科技、华东智能装备、先达自动化三家示例公司
- 设备:20 台不同类型的工业设备,覆盖压力容器、塑料机械、气体设备、机器人等
- 字典:告警级别(紧急/重要/次要/提示)、设备状态、设备类型、厂商等完整字典数据
- 用户:默认管理员账户
admin / 123456 - 角色:ADMIN 超级管理员角色,关联所有菜单权限
这样做的好处是:clone 代码后,数据库迁移完就能直接跑起来,不需要手动造数据。
九、前端弹窗系统的演进
9.1 从散弹枪到通用弹窗
最早的弹窗,每个页面自己写 <a-modal>,样式各不相同。后来抽成了两层:
- CommonModal:封装 Ant Design Modal 的统一样式(圆角、阴影、头部、关闭按钮)
- FormModal:在 CommonModal 基础上,用
fields配置驱动表单渲染
const roleFormFields = [
{ key: "name", label: "角色名称", type: "input", required: true },
{ key: "code", label: "角色编码", type: "input", required: true },
{ key: "enabled", label: "启用状态", type: "switch" },
];
新增一个表单页面只需要定义字段配置,不需要写新的组件。
9.2 特殊场景的弹窗
但通用弹窗不能覆盖所有场景:
- 角色分配弹窗:需要按公司分组展示角色列表
- 权限分配弹窗:需要树形菜单展示和半选中状态
这两个弹窗各自独立实现,因为它们的交互模式完全不同。
这里暴露了一个设计权衡:过度抽象会让代码难以理解,适当的重复反而更健康。
十、前端标签页导航
10.1 从没想到到离不开
标签页不是一开始就规划的功能。第一版做的是单页导航,后来在实际使用时发现一个问题:在"用户管理"页面编辑表单,想去看一眼首页数据,再点回来——表单没了。
标签页成了必需品。
10.2 标签页状态管理
const state = reactive({ visitedTabs: [] });
function addTab(route) {
ensureHomeTab();
if (!route?.path || route.meta?.hidden) return;
const existing = state.visitedTabs.find((t) => t.path === route.path);
if (existing) {
existing.title = route.meta?.title ?? existing.title;
return; // 已存在则只更新标题
}
state.visitedTabs.push(buildTab(route));
}
几个细节:
- 固定标签页:首页永远固定,不能关闭
- 自动滚动:新标签添加后,自动滚动使当前标签可见
- 删除回退:关闭当前标签时自动跳转到前一个标签
十一、样式系统:一直在纠结
样式这块我折腾了很久。最早用纯 CSS 变量管理主题色,后来引入了 Tailwind CSS 做工具类,最后又加上了 Less 做组件级样式。
:root {
--color-primary: #2f78e6;
--color-primary-strong: #1e5fc7;
--color-primary-soft: rgba(47, 120, 230, 0.1);
--radius-md: 8px;
--shadow-highlight: 0 2px 8px rgba(47, 120, 230, 0.08);
}
设计风格上,最终选择了"玻璃拟态"方向:半透明卡片、毛玻璃背景、柔和的阴影和渐变。这个审美是在看了大量后台模板之后慢慢形成的,不是最初就确定的。
十二、后端缓存策略
菜单数据在 MenuServiceImpl 里使用了 Spring Cache:
@Cacheable(cacheNames = "menus:list", key = "#visibleOnly")
public List<MenuView> list(boolean visibleOnly) {
return menuRepository.findAll().stream().map(this::toView).toList();
}
@CacheEvict(cacheNames = {"menus:list", "menus:tree"}, allEntries = true)
public MenuView create(MenuSaveRequest request) { ... }
菜单属于读多写少的数据,缓存命中率很高。但要注意 allEntries = true 的清理策略——写操作时清除整个缓存,虽然简单但不够精细。对于菜单这种数据量不大的场景,足够了。
十三、回过头来看,哪些做对了
后端
-
Service 接口与实现分离。接口定义业务契约,实现负责具体逻辑。这让我在重构
RoleServiceImpl的树构建算法时,不需要动 Controller 一行代码。 -
统一响应格式
ApiResponse。所有接口返回格式一致,前后端对接时减少了很多不必要的沟通。 -
Flyway 数据库迁移。版本化管理数据库变更,团队协作和多环境部署不再是问题。
-
DataSeeder 种子数据。新环境启动后就有完整数据,不需要手动造,开发者体验大大提升。
-
JWT 无状态认证。配合前端 axios 拦截器,认证逻辑清晰可靠。
前端
-
文件驱动的路由系统。新增页面只需要在
views/下创建.vue文件,路由配置自动生成。这是整个前端最满意的设计之一。 -
useTable组合式函数。把表格相关的状态和逻辑封装成一个可复用单元,页面代码从 80 行缩减到 20 行。 -
routeMeta元数据约定。页面自身参与系统的元数据构建,而不是把所有信息写在配置里。 -
menu-registry作为中间层。它桥接了前端文件路由和后端菜单数据,解决了"谁来定义菜单"这个架构问题。
十四、哪些地方留下了遗憾
后端
-
没有全局异常处理器。目前每个 Service 方法里的异常都是自己
throw,没有统一的@ControllerAdvice来兜底。如果某个异常没被捕获,就会变成 500 堆栈信息暴露给前端。 -
没有单元测试。Service 层的核心逻辑(如树构建、权限分配)应该有单元测试覆盖。
-
权限校验是空的。目前的 SecurityConfig 只做了"认证"(是否登录),没有做"授权"(是否有权限访问某个接口)。理论上每个接口都应该校验当前用户是否拥有对应的菜单权限。
-
数据Seeder的幂等性检查不够精细。
seedBaseData()用的是"如果存在就跳过"策略,当数据量大了之后,性能会下降。
前端
-
登录页的 mock 数据。品牌展示面板里的数据全是写死的 mock,没有接入真实后端。
-
字典管理模块。左右布局是正确的,但部分数据还没有完全与后端打通。
-
样式管理混乱。CSS 文件散落在多处,没有统一的规范。重来一次会强制使用 Scoped CSS + CSS Variables。
-
ECharts 配置重复。每个图表的 ECharts 配置写法类似但没有充分抽成工厂函数。
十五、写在最后
开发这套系统让我深刻体会到一件事:架构是被问题驱动着长出来的,不是设计出来的。
后端从最早的直接在 Controller 里写逻辑,到引入 Service 层、DVO 转换、统一响应格式;前端从复制粘贴表格代码,到 useTable 组合式函数、文件驱动路由、菜单注册中心。每一步重构的背后,都是"这里不舒服"的真实痛点在驱动。
前后端分离的协作模式也教会了我一件事:接口契约的重要性远超实现细节。后端定义好 ApiResponse<T> 的格式后,前端可以完全不关心后端用什么框架实现。接口是前后端之间的"宪法",稳定比灵活更重要。
MVP 优先跑起来,在真实使用中发现问题,用重构代替空想设计。这是我做这个项目的核心方法论,也是我认为最务实的个人项目开发路径。
希望这篇记录对你有所启发。代码从来不完美,完美的是那些让代码越来越好的思考和行动。