我第一次见到有人把“装饰公司网站”这个题目当成一个带图的官网来做时,就知道这个题目里藏着一个特别容易被低估的坑。你只看字面意思,会以为要写一个展示页;可当你真正去梳理业主、设计师、运营这几类人的使用场景后会发现,它本质上是基于 Spring Boot 构建的家居装潢企业数字化运营系统——前台承载案例展示、室内外装修需求提交、在线预约,后台承载线索跟进、设计师派单、案例发布和账号权限管理。如果你正在准备计算机毕业设计,或者想拿 Java Web 做一套能讲清楚业务闭环的项目,这篇文章就是按我可复现的思路来写的。
我带的某同学,最初给我的方案就是“首页放几张效果图、案例页放一些图、后台能增删改查案例”,我听完直接让他重做。原因很简单:这样的系统,答辩时评委随便问一句“业主提交的装修需求你打算怎么处理?”就会卡住。而真正能拿到高分的毕设,至少要让数据在“访客提交 → 管理员处理 → 设计师介入 → 状态更新”这条链路里流动起来。下面我以自己实际开发的一套装饰公司网站为例,把系统拆开讲清楚:业务边界怎么划、表结构怎么设计、核心流程怎么写、权限怎么控制,以及最后演示时会踩的哪些坑。
1. 先想清楚业务闭环:这不是企业官网,是装修业务管理系统
1.1 别急着写登录注册,先把角色和使用场景列出来
很多人在动手写代码之前,第一件事就是搭 Spring Boot 工程、建用户表、写登录页。这个顺序其实反了。装饰公司网站的核心不是登录,而是“需求”这条线索怎么被采集、处理和推进。你要先把自己代入三种角色去思考。
我先说业主,也就是访客。他打开网站的目的非常直接:看到真实的装修案例,觉得这家公司靠谱,然后留下联系方式,要么预约量房,要么提交一个“我家几室几厅、想全包/半包、预算多少”的装修需求。他几乎不会注册之后再慢慢浏览,因为注册是有门槛的,会挫伤转化意愿。
再看公司内部的运营人员和设计师。运营人员每天打开后台,第一个想看到的是“今天新增了多少条需求、几条还没处理、哪些预约需要回电确认”。设计师则更关心“分给我哪几个工地、我的案例上传了没有”。最后是管理员,他不仅仅要看业务数据,还要管理账号、分配角色、审核案例和文章。
把这些角色列成一张表,系统的功能边界就非常清晰了:
| 角色 | 核心痛点 | 系统对应功能 |
|---|---|---|
| 业主/访客 | 想找靠谱装修公司、想获取报价/方案 | 案例展示、需求提交、在线预约、资讯阅读 |
| 运营/客服 | 线索多而杂,容易漏单、跟进无记录 | 需求单管理、预约管理、状态跟进、派单 |
| 设计师 | 案例作品分散、缺少展示渠道 | 个人介绍、案例上传、分配需求单 |
| 管理员 | 账号权限混乱、内容审核麻烦 | 用户管理、角色权限、案例/文章审核、数据看板 |
做完这一步,你根本不需要“硬凑功能”,因为每个模块都是被业务场景推着走出来的。尤其是“需求单管理”这一块,是整个系统的命脉,后面第 4 部分我会给出完整的代码链路。
1.2 我最终留下的功能清单:前台要轻,后台要重
很多人把功夫花在前台样式上,页面做得很漂亮,后台却只有两个可怜的 CRUD 界面。但实际评项目时,后台业务的完整度远比前台颜值重要。前台你只需要保证“信息结构清楚、浏览流畅”就行,真正花时间的地方是后台模块。
我最终做出来的功能清单如下:
- 前台门户:首页 banner、公司简介、装修案例列表与详情、设计师团队、新闻资讯、在线预约表单、装修需求提交表单。
- 后台管理:管理员登录、后台首页数据统计(今日新增需求、待处理数量、案例总数)、需求单列表(筛选、详情、跟进、指派设计师)、预约管理(确认、取消、完成)、案例管理(发布、编辑、上架/下架)、设计师管理(新增、排序、简介编辑)、文章管理、系统用户管理与角色分配。
这个清单本质上就是一个轻量级 CRM 加内容管理系统的结合体。对于毕设来说,它已经覆盖了 Spring Boot、MyBatis Plus、权限控制、文件上传、分页查询、状态机这几个核心考点,足够撑起一次完整的答辩。
1.3 哪些功能可以明确不做?系统边界要提前划好
同样重要的是“明确不做什么”。我在设计的时候主动砍掉了三块:在线支付、自动报价计算、ERP 进销存。
原因不是难,而是它们会引入大量异常分支。比如在线支付一旦涉及退款、回调,你需要处理很多极端情况,容易把主体业务淹没;自动报价则需要维护复杂的材料价格库,和装修公司实际线下报价的模式也不太相符。我在答辩时是这样解释的:系统定位为“数字化运营系统”,重点解决获客、线索管理、案例展示和内部协作问题,报价和支付属于线下业务闭环,系统预留了字段和扩展接口,避免越做越臃肿。这个说法既显得你有取舍能力,也让项目的技术范围足够聚焦。
2. 技术选型:Spring Boot 为主,但别把技术栈堆成全家桶
2.1 Spring Boot 版本怎么选?JDK 版本先定下来
这个项目是以 Java Web 为基础的,所以主角必然是 Spring Boot。但在选具体版本时,很多人犯过同一个错:电脑上明明是 JDK 8,却从网上复制了一段 Spring Boot 3.x 的配置,结果一启动就报错。
Spring Boot 2.7.x 支持 JDK 8 到 JDK 17;Spring Boot 3.x 最低要求 JDK 17。网上很多教程已经是 3.x 了,但如果你不熟悉 Java 模块化、不太会处理新版依赖变化,我建议按自己本机环境来定版本。我实际使用的是 Spring Boot 2.7 加 JDK 8,倒不是因为追求老旧,而是那台演示机的环境最稳。如果你用的是 JDK 17 或更高,那直接用 Spring Boot 3.x 也没问题,但注意 javax 包名要改成 jakarta,这在老代码迁移时特别容易踩。
| 本机 JDK | 推荐 Spring Boot 版本 | 注意事项 |
|---|---|---|
| JDK 8 | 2.7.x | 需连接池、MyBatis Plus 等依赖选兼容版 |
| JDK 11 | 2.7.x | 基本同上,稳定优先 |
| JDK 17+ | 2.7.x 或 3.x | 3.x 使用 jakarta.,2.7 可继续用 javax. |
| JDK 21 | 3.x | 编译参数、Lombok 版本需更新 |
一句话总结:先看本机 Java 版本再选 Spring Boot 版本,整个项目依赖统一按这个主线去配,能少折腾很多。
2.2 MyBatis Plus 带来的便利,以及不能无脑用的地方
持久层我用了 MyBatis Plus。它提供的 BaseMapper 和 LambdaQueryWrapper 能让我少写大量样板 SQL,分页插件也是一行配置搞定。对毕设项目来说,开发效率是第一位的,你不需要像 SSM 时代那样手写 XML 里的每个 select 和 update。
但我也要说清楚它的边界。有些人误以为用了 MyBatis Plus 就完全不用写 SQL 了,真到了要做“今日新增需求数”这种统计,或者多表联查(比如查询案例时带出设计师姓名)时,你还是要老老实实写自定义 SQL。我的做法是:单表简单 CRUD 用 BaseMapper,涉及 JOIN 或聚合统计的写在 XML 里,这样代码既干净又不会在答辩时被问倒。
还有一个容易漏的细节:MyBatis Plus 的逻辑删除是全局配置logic-delete-field: deleted,如果你业务表里没有这个字段,千万不要全局开启,否则所有查询都会自动拼上AND deleted=0,数据都查不出来。我一般只在核心业务表上加 deleted 字段,并把删除字段的类型定为tinyint,这属于典型的“安全兜底”设计。
2.3 权限处理:这可能是答辩时被问到的第一个技术点
装饰公司网站天然分成访客区和管理后台,访客不需要登录,管理后台必须有权限限制。很多人第一反应是上 Spring Security 或者 Shiro,但我不建议毕设这么做,除非你能把它的过滤器链原理讲得非常清楚,否则自己写的代码里一旦出了奇奇怪怪的 403,排查起来会很痛苦。
我当时采用的是“登录用户 + 角色 + 自定义注解 + 拦截器”的轻量权限方案:用户登录后把用户信息放进 Session,管理后台的请求经过拦截器校验,再通过自定义@RequireRole注解限制接口只能由管理员或运营人员访问。这套方案代码量不大,但你所用的知识点都看得见摸得着,面试或答辩时也能把“基于 RBAC 的轻量权限模型”讲明白。第 5 部分我会给拦截器代码,这里先记住一个核心原则:权限控制要放在服务端,前端隐藏按钮只能算辅助。
3. 数据库设计:装修业务数据怎么落库,直接决定项目上限
3.1 核心表结构一览:我用到的十张核心表
数据库设计我是这样规划的:用户体系独立成组,业务数据围绕“需求”和“案例”两条主线展开,内容类数据单独建表。最终落地的核心表如下:
| 表名 | 作用 | 关键字段 |
|---|---|---|
| sys_user | 登录账号 | id, username, password, real_name, phone, avatar, status |
| sys_role | 角色表 | id, role_code, role_name |
| sys_user_role | 用户角色关联 | user_id, role_id |
| biz_demand | 装修需求单 | id, owner_name, phone, house_area, budget_range, decoration_type, content, status, designer_id, handle_user_id, create_time |
| biz_reservation | 在线预约 | id, user_id, designer_id, reserve_date, remark, status |
| biz_case | 装修案例 | id, title, cover_image, designer_id, style, area, budget, description, content, publish_status |
| biz_designer | 设计师 | id, name, avatar, title, specialty, introduction, sort |
| biz_article | 新闻资讯/装修知识 | id, title, cover_image, summary, content, publish_status |
| biz_demand_log | 需求跟进日志 | id, demand_id, operator_id, from_status, to_status, remark, create_time |
| biz_feedback | 留言/反馈 | id, contact_name, phone, content, status |
这套结构最核心的一点,是把“需求”和“案例”分开,而不是让它们混在一张表里。需求是客户尚未成交的线索,案例是已经完成的项目展示,两者生命周期完全不同。如果非要在案例表里塞需求状态,后面你就会陷入“到底该改哪张表”的混乱。
3.2 装修需求单是核心:status 字段要有“状态机”思维
在众多表里,biz_demand是最值得花心思的。它的status字段不能随便用字符串存“待处理”“已签约”这类中文词,万一前端传了个“已签单”,后端和数据库里的“已签约”对不上,查出来的数据就全是空的。我的方案是用tinyint存状态码,并通过一个 Java 枚举统一管理:
| 状态码 | 状态名称 | 含义 | 谁可以操作 |
|---|---|---|---|
| 0 | 待处理 | 用户刚提交,还没有人跟进 | 所有人可见,运营人员接收 |
| 1 | 沟通中 | 客服/运营已电话联系业主 | 运营、管理员 |
| 2 | 设计中 | 已派给设计师,正在出方案 | 设计师、运营、管理员 |
| 3 | 已签约 | 客户认可方案,签订装修合同 | 运营、管理员 |
| 4 | 已完成 | 施工/服务已完成,可归档案例 | 管理员 |
| 5 | 已关闭 | 客户流失或无效线索 | 管理员 |
这样设计的好处有三个:第一,排序和筛选都很方便,where status = 2比字符串比较更可靠;第二,状态码完全由后端控制,前端拿到数字后映射到中文标签即可,不会出现“手写文字不一致”的低级错误;第三,你可以基于状态码设计一个小状态机,防止需求单被随意跳转(比如没指派设计师就直接变成“设计中”),这在第 4 部分会具体演示。
3.3 图片、富文本和软删除:三个拉开档次的设计细节
第一个细节是图片存储。装修案例和设计师头像都需要图片,最省事的方式是把图片文件传到服务器指定目录,数据库只存 URL。很多人喜欢把图片直接转 base64 长字符串存进数据库,这会让表体积迅速膨胀,性能也差。我用的字段是cover_image varchar(255),上传接口返回/upload/2025/xxx.jpg,前端用这个相对地址展示。
第二个细节是富文本内容。案例详情和文章正文会很长,用varchar很可能不够,建议直接用longtext。如果你担心编辑器传回来的 HTML 带样式隐患,可以在后端做白名单过滤,只保留 p、img、h2、ul、li 这些基础标签。
第三个细节是软删除。我不会直接物理删除需求单和案例,因为业务数据即便标记为“已关闭”或“已下架”,也需要保留历史记录。所以我在核心表里统一加deleted tinyint default 0,用 MyBatis Plus 的逻辑删除功能处理。答辩时被问“为什么不用外键、为什么逻辑删除”,你就可以把“保留历史、方便恢复、避免级联删除复杂度”这几个理由讲清楚。
4. 业务闭环实现:需求提交、状态流转、后台派单的代码链路
4.1 需求提交接口:参数校验和防重复必做
业主在前台填一张装修需求表单,提交到后端。这个接口是整个系统的入口,也是演示时最容易出效果的地方。我在代码里只做了两件“额外”的事:参数校验和防重复提交。
@PostMapping("/api/demand/submit") public Result<Void> submit(@RequestBody @Valid DemandSubmitDTO dto) { String key = "demand:repeat:" + dto.getPhone(); Boolean isFirst = redisTemplate.opsForValue() .setIfAbsent(key, "1", Duration.ofSeconds(60)); if (!Boolean.TRUE.equals(isFirst)) { return Result.error("您的需求已收到,请勿重复提交"); } Demand demand = new Demand(); demand.setOwnerName(dto.getOwnerName()); demand.setPhone(dto.getPhone()); demand.setHouseArea(dto.getHouseArea()); demand.setBudgetRange(dto.getBudgetRange()); demand.setDecorationType(dto.getDecorationType()); demand.setContent(dto.getContent()); demand.setStatus(DemandStatus.PENDING.getCode()); demand.setDeleted(0); demandService.save(demand); return Result.ok(); }这段代码最值钱的是setIfAbsent这一行。它利用 Redis 的原子操作,保证同一个手机号在 60 秒内只能提交一次。如果没有 Redis,也可以用本地ConcurrentHashMap做时间窗口,但用 Redis 会让演示效果更完整,而且能自然引出“为什么用 Redis”的追问。DTO 上加@NotBlank、@Pattern校验手机号,由全局异常处理器统一返回友好提示,这样接口层就非常干净。
4.2 后台需求列表:筛选、分页和排序一步到位
后台运营人员打开需求列表时,需要按状态筛选,也需要搜索手机号。实现上我用 MyBatis Plus 的 LambdaQueryWrapper 很顺手:
Page<Demand> page = demandService.page( new Page<>(pageNum, pageSize), new LambdaQueryWrapper<Demand>() .eq(StringUtils.hasText(phone), Demand::getPhone, phone) .eq(status != null, Demand::getStatus, status) .orderByDesc(Demand::getCreateTime) );这里有两个小坑。一是“手机号筛选”不要写成like然后传%,这会破坏索引,除非你真的需要模糊搜索;二是分页参数要设置合理上限,我一般限制每页最大 50 条,告诉前端不要一次拿太多。你可以在返回结果里顺便带上total、pages,前端分页组件才能正常工作。这个接口看起来简单,但它覆盖了“条件构造器 + 分页插件 + 排序规则”三个考点,答辩时完全可以展开讲。
4.3 状态机更新:权限校验和业务校验一个都不能少
需求单不能谁想改状态就改,也不能乱序跳转。我单独写了一个状态变更方法,放在 Service 层:
@Transactional(rollbackFor = Exception.class) public void changeStatus(Long demandId, Integer targetStatus, Long operatorId) { Demand demand = demandService.getById(demandId); if (demand == null) { throw new BizException("需求单不存在"); } DemandStatus target = DemandStatus.codeOf(targetStatus); if (!allowedOperator(operatorId, target)) { throw new BizException("当前角色无权执行该状态变更"); } if (target == DemandStatus.DESIGNING && demand.getDesignerId() == null) { throw new BizException("请先指派设计师,再进入设计中"); } if (target == DemandStatus.CLOSED && demand.getStatus() != DemandStatus.PENDING.getCode()) { throw new BizException("只有待处理的需求才能直接关闭"); } DemandLog log = new DemandLog(); log.setDemandId(demandId); log.setOperatorId(operatorId); log.setFromStatus(demand.getStatus()); log.setToStatus(targetStatus); log.setRemark("状态变更"); demand.setStatus(targetStatus); demandService.updateById(demand); demandLogService.save(log); }这个方法把三件事融为一体:状态权限校验、业务前置条件校验、操作日志记录。尤其是日志表,很多人会忽略,但它恰恰是答辩时的加分项。比如你演示“把一个待处理的需求改成设计中”,系统首先会校验是否已指派设计师,然后记录“从 0 到 2”的完整操作日志。相比普通的管理后台增删改查,这个模块已经具备工作流引擎的雏形了。
5. 前后端交互与权限控制:管理后台不能“裸奔”
5.1 服务端渲染还是前后端分离?我最终选了“混合模式”
这个问题几乎每个做毕设的人都会纠结。前后端分离确实显得技术先进,但它会给毕业设计带来额外的工程复杂度:跨域、Token 管理、前端打包、部署路径,每一个都能让人折腾很久。我当时的前台页面用的是 Thymeleaf 服务端渲染,首页和案例详情直接返回页面;后台管理同样使用 Thymeleaf 模板,表格区域通过 AJAX 调接口拿 JSON 数据,再局部刷新。这样既保留了服务端渲染对 SEO 友好、首屏快的优点,又能避免为了一个小后台单独起一个 Vue 工程。
如果你希望锻炼前端技能,可以在后台管理里引入 Vue 3 加 Element Plus,做成独立的前端工程。但我个人的建议是:除非你已经很熟练,否则不要为了“用新技术”而把部署复杂度提上去。答辩评委更关心你是否真的理解数据怎么在前后端之间流动,而不在乎你的前端是不是 SPA。
5.2 登录拦截器加注解权限:挡住不该进的页面
管理后台的接口必须统一做登录校验。我实现了一个AuthInterceptor:
@Component public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { Object loginUser = request.getSession().getAttribute("loginUser"); if (loginUser == null) { response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"msg\":\"未登录\"}"); return false; } // 这里可以继续校验角色,或者配合自定义注解做更细粒度控制 return true; } }配置类里只拦截/admin/**和部分/api/**写操作,放行静态资源、登录接口和前台公开接口。用拦截器有一个好处:你可以在答辩时清楚地说出“哪些路径允许匿名访问、哪些必须登录”,这是很多人答不上来的细节。如果还需要细化角色,我配合@RequireRole注解实现,拦截器里读取注解值再和用户角色比对,逻辑很直接。
5.3 文件上传的三个细节:重命名、校验、外链播放
后台发布案例时会上传封面图,这里我做了一个通用上传接口。实际代码比较短,但有三个细节很重要:
@PostMapping("/api/upload") public Result<String> upload(@RequestParam("file") MultipartFile file) throws IOException { String original = file.getOriginalFilename(); String ext = original == null ? "" : original.substring(original.lastIndexOf('.')).toLowerCase(); List<String> allowed = Arrays.asList(".jpg", ".jpeg", ".png", ".webp"); if (!allowed.contains(ext)) { return Result.error("图片格式不支持"); } if (file.getSize() > 5 * 1024 * 1024) { return Result.error("图片不能超过5MB"); } String datePath = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMM")); String filename = UUID.randomUUID().toString().replace("-", "") + ext; Path dir = Paths.get(uploadDir, datePath); Files.createDirectories(dir); file.transferTo(dir.resolve(filename).toFile()); return Result.ok("/upload/" + datePath + "/" + filename); }第一点是文件名重命名,用 UUID,而不是用户上传时可能带中文名字的文件名,避免乱码和路径穿越;第二点是扩展名白名单和大小限制,直接挡住不合规文件;第三点是按月份分目录,防止所有文件堆在一个目录里导致查找困难。演示时哪怕只上传一张图,你也能顺带讲出这三个设计理由,比单纯说“我把图存上去了”要专业得多。
6. 从“能跑”到“能演示”:毕设通关避坑清单
6.1 环境问题:本地跑不起来,多半是版本和字符集
我见过太多项目在写代码阶段没毛病,一到演示环境就“翻车”。最常见的问题有三个。第一个是数据库连接 URL 没配characterEncoding=utf8,导致表单提交的中文变成问号;第二个是 Maven 依赖版本冲突,比如 MyBatis Plus 版本和 Spring Boot 版本不匹配,一启动就报NoSuchMethodError;第三个是 JDK 版本不对,Spring Boot 3.x 项目跑到 JDK 8 上根本无法启动。
我强烈建议在项目交付前做一次“干净环境验证”:把本地target目录清掉,重新mvn clean package,然后用java -jar跑一遍。这能暴露出那些平时靠 IDE 缓存掩盖的问题。数据库连接串我通常这样配:
jdbc:mysql://localhost:3306/fitment_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false6.2 演示时容易被追问的问题,提前准备标准回答
答辩和演示是一个“主动展示 + 防御追问”的过程。我把评委大概率问的问题提前整理了一遍,这里直接放参考答案:
| 追问问题 | 建议切入角度 |
|---|---|
| 为什么用 Spring Boot 而不是传统 SSM? | 自动配置、内嵌容器、约定优于配置,降低工程搭建成本 |
| 权限是怎么做的? | Session 登录 + 拦截器 + 角色注解,讲清 RBAC 模型 |
| 为什么需求表要设计状态机? | 保证流程合规,避免任意跳转,后续可扩展为工作流 |
| 图片为什么存 URL 而不存数据库? | 数据库只存元数据,文件放磁盘/CDN,降低读写压力 |
| 系统用户量大了怎么办? | 先加索引、引入 Redis 缓存热点数据,再谈横向扩展 |
这些问题没有标准答案,但你只要能把“为什么这么设计”讲清楚,就已经赢过大多数只会背代码的选手了。尤其是“状态机”和“逻辑删除”这两个点,技术含量不高,却是区分用心程度的关键。
6.3 如果这个项目继续扩展,我会优先加什么
做完这套装饰公司网站之后,我很明确如果继续往下做,优先级最高的不是花里胡哨的功能,而是消息通知与跟进闭环。比如:业主提交需求后,系统给运营人员弹一条待办;运营把状态改成“沟通中”,自动提醒设计师接单;需求进入“设计中”后,给业主发一条进度短信。这些能力相当于把线下装修公司的“跟单”动作搬到线上,才是真正意义上的数字化运营。
其次是数据看板,用 ECharts 展示每月需求数量变化、各状态占比、设计师案例量排行。这个功能在技术上新意不大,但视觉效果最好,也最容易形成项目亮点。至于小程序端和在线支付,我认为可以作为后续方向提一嘴,不需要真的实现,因为那已经超出毕设合理周期了。
最后说句实在话:这类 Java Web 项目能不能打动人,不在于你用了多新的框架,而在于你有没有把一条业务线从头走通。我当年做完这套系统,最深的体会是把状态机想清楚之后,后面所有需求单相关功能都变得特别好写。如果你正准备动手,建议先不要急着敲代码,花一个下午把你项目里的角色、状态、权限画一遍,你会收获比写十个接口更大的进步。