又是一年毕业设计季节Java方向的选题里“基于Spring Boot的公司门户网站”绝对算得上一个经典中的经典。这个题目为什么经久不衰因为它恰到好处地覆盖了Java Web开发的核心链路后端框架应用、数据库设计、前端页面渲染、权限控制、文件上传甚至还涉及部署上线。无论你的导师是偏工程实践还是偏业务逻辑这个题目都能拿得出手。从我开始带毕设这些年见过的案例来看这个题目做得好不好关键不在于用了多高级的技术而在于你把“门户网站”这件事想明白了没有。很多同学一上来就堆砌技术分布式、缓存、消息队列全往上塞最后反而把项目搞得不伦不类。公司门户网站的核心诉求就两个对外展示企业形象和信息对内管理内容发布。把这套逻辑梳理清楚技术选型自然就清晰了。这篇文章我会把整个从设计到实现再到部署的完整链路拆开讲透包括每一层为什么要这样做、代码怎么写、坑在哪里希望能帮到正在做类似选题的小伙伴。1. 项目全貌理解与设计思考1.1 为什么是公司门户网站先想一个问题为什么公司需要门户网站这个问题的答案直接决定了你的设计方向。公司的门户网站是面向公众的第一张脸。客户、合作伙伴、潜在求职者可能都是通过这个网站来了解公司的业务范围、新闻动态、产品信息。所以这个网站有两大核心诉求第一得好看给访客一个专业、可信的形象第二内容得及时新闻动态、通知公告要能频繁更新。但这里有个矛盾更新频繁意味着需要懂技术的人去改页面可通常负责发布内容的是行政部门或者市场部同事不可能每次都找开发人员。所以门户网站必须要具备一个清晰的后台管理系统让业务人员能够独立地发布内容、管理页面。这就是这个项目最底层的业务逻辑。从毕设的角度看这个题目好在哪它既不是那种CRUD味太浓、答辩时拿不出手的管理系统也不是那种复杂度太高、容易做不完的前后端分离大项目。它就卡在一个恰到好处的位置前台展示要用心做后台管理要功能完整权限控制要逻辑清晰技术栈又是Java方向最标准的组合。我第一次带这个题目的时候给A组学生的建议是不要在功能上贪多把新闻模块、产品展示、用户权限、文件上传这四块吃透再打磨一下前端页面模板的细节这已经足够拿到不错的成绩了。事实证明做得好不好和功能多少没有直接关系和一个模块是否做得深入有关系。1.2 技术选型背后的逻辑技术选型是很多同学最纠结的地方我先直接给出一个久经考验的推荐组合再逐个解释为什么要做这样的选择。后端框架Spring Boot 2.x MavenORM框架MyBatis-Plus数据库MySQL 8.0前端模板Thymeleaf Bootstrap 5权限框架Spring Security 或者拦截器方案富文本编辑器wangEditor 5Spring Boot选择2.x版本而不是3.x核心原因是2.x的生态资料最多各种问题的解决方案随手一搜就有对毕设来说稳定性最重要。不要为了追求新版本去踩那些本来可以避免的坑你的时间应该花在把项目做扎实上。前后端不分离这件事我要特别解释一下。很多毕设都会选择Vue Spring Boot做前后端分离这看起来更“时髦”。但公司门户网站这个场景回归到实际业务来看它需要的是SEO搜索引擎能直接抓取到页面上首页、新闻的静态文本内容。服务端渲染对SEO友好得多。用Thymeleaf做服务端渲染可以在控制器里直接返回视图不需要处理跨域、不需要搭两套环境、不需要自己实现一套认证的接口对接对毕设项目来说开发和维护成本都更低。一个真实的项目应该用什么思路你就要有把场景掰开了分析的能力。这就是我想表达的一个核心逻辑不是技术越新越好而是技术匹配业务才算好设计。权限框架方面如果时间充裕建议尝试自己用拦截器或AOP写一套权限控制。为什么因为Spring Security虽然功能强大但配置繁琐对于一个门户网站这种场景我们需要的是判断“用户是否登录”“当前用户是否有某个角色的权限”。对于这种简单的需求拦截器思路直观而且容易在答辩时讲清楚逻辑这是考场上的加分项。2. 数据库设计与模块规划2.1 核心表结构设计表结构设计直接决定项目写起来是否顺畅。这里我结合做过的多个同类型项目把最核心的几张表拿出来逐个拆解。第一张是sys_user用户表这张表管理后台登录用户CREATE TABLE sys_user ( id BIGINT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, nickname VARCHAR(50), role_id BIGINT, avatar VARCHAR(255), status TINYINT DEFAULT 1, create_time DATETIME, update_time DATETIME );这里的status字段用来控制账号是否可用做禁用功能的时候直接改这个值就行不需要物理删除。用户密码存储必须加密推荐使用BCrypt加密方式。毕设答辩时不时有人问“如何保证系统安全”密码加密就是一个可以讲的点。第二张是content表这是整个系统的灵魂。我建议不要分太多表——新闻表、公告表、产品表、招聘表各一张看似清晰实际上会增加大量重复代码和模板。更好的做法是采用一张通用的内容表CREATE TABLE content ( id BIGINT AUTO_INCREMENT PRIMARY KEY, type VARCHAR(20) NOT NULL COMMENT news/product/recruit, title VARCHAR(200) NOT NULL, summary VARCHAR(500), cover_image VARCHAR(255), content TEXT, author VARCHAR(50), status TINYINT DEFAULT 1, sort_order INT DEFAULT 0, create_time DATETIME, update_time DATETIME );type字段区分这个内容属于新闻动态、产品中心还是人才招聘。这样做的好处是你只需要写一套内容管理的CRUD配一个类型字段就通用了。这个思路在答辩时也是一个亮点你用一个通用设计减少了大量重复代码体现了对业务抽象的理解。我见过太多同学面向单表写代码一张表一套增删改查那不是在写系统是在复制粘贴这样写几十张表就要崩溃了。第三张是category栏目分类表CREATE TABLE category ( id BIGINT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(50) NOT NULL, code VARCHAR(50) NOT NULL UNIQUE, parent_id BIGINT DEFAULT 0, sort_order INT DEFAULT 0 );分类表可以为内容提供分组。比如产品中心下面可以分“核心产品”“解决方案”“典型案例”几个子栏目。为了让毕设项目不至于复杂到失控分类层级做两级就足够一级栏目、二级子栏目大部门企业站的导航也不外乎这两级。2.2 模块划分与权限模型数据库表定了之后再回来规划功能模块整个系统的脉络就清晰了。前台门户展示模块主要解决“用户能看到什么”的问题首页展示公司介绍、核心产品、最新动态新闻列表和详情页要有分页要能展示封面图和摘要产品展示页要能按分类筛选。后台管理模块主要解决“运营人员怎么维护”的问题登录验证、仪表盘展示内容总量和最近更新、内容管理包括文章的新增、编辑、删除、上下架、分类管理维护栏目的新增、排序、禁用、用户管理给运营人员开账号。在权限模型上我推荐一个简单够用的方案系统只分两种角色——超级管理员和内容编辑员。超级管理员有全部权限可以管理用户和所有内容内容编辑员只能管理内容不能碰用户。用role_id字段区分权限判断在拦截器里做。这个模型在答辩时很容易讲清楚也符合多数公司的真实场景。这里有一个关键设计公司门户网站的“前台”是向全网公开的所以前台页面根本不需要登录。数据库中的user表是为后台管理人员准备的这一点在答辩开场介绍项目背景时建议主动讲一嘴避免评委混淆。我在模拟答辩时发现很多同学自己都没有把“前台游客”和“后台管理员”这两个角色在脑子里分开讲到权限控制就含含糊糊这是需要提前想清楚的逻辑。3. 核心功能实现与代码细节3.1 用户登录与权限控制用户登录直接基于Spring Boot的Session机制就好。不做JWT、不做Token因为我们不做前后端分离Session配合Thymeleaf已经非常顺畅自然这是最贴合项目架构的选择。我用拦截器来实现权限控制。先定义一个注解Target({ElementType.METHOD, ElementType.TYPE}) Retention(RetentionPolicy.RUNTIME) public interface RequiresPermission { String value(); }然后写一个拦截器在preHandle里做校验Component public class PermissionInterceptor implements HandlerInterceptor { Autowired private SysUserService userService; Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (!(handler instanceof HandlerMethod)) { return true; } HandlerMethod handlerMethod (HandlerMethod) handler; RequiresPermission permission handlerMethod.getMethodAnnotation(RequiresPermission.class); if (permission null) { return true; } SysUser user (SysUser) request.getSession().getAttribute(loginUser); if (user null) { response.sendRedirect(/admin/login); return false; } // 查询用户角色判断是否具备权限 if (userService.hasPermission(user.getId(), permission.value())) { return true; } response.setStatus(HttpServletResponse.SC_FORBIDDEN); return false; } }这段逻辑在答辩时可以讲得很有条理首先校验访问的方法是否被注解标注了权限要求其次从Session中取出登录用户最后根据用户ID去查角色权限判断是否能访问。不用写得很复杂思路清晰比代码炫技重要得多。登录接口的逻辑其实也很简单从前端接收用户名和密码根据用户名找到用户用BCrypt校验密码成功就把用户对象扔进Session。密码校验代码如下PostMapping(/admin/doLogin) public String doLogin(String username, String password, HttpSession session) { LambdaQueryWrapperSysUser wrapper new LambdaQueryWrapper(); wrapper.eq(SysUser::getUsername, username); SysUser user userMapper.selectOne(wrapper); if (user null) { return redirect:/admin/login?error1; } if (!BCryptPasswordEncoder.matches(password, user.getPassword())) { return redirect:/admin/login?error1; } session.setAttribute(loginUser, user); return redirect:/admin/dashboard; }有个很容易踩的坑BCryptPasswordEncoder.matches()方法接收的第一个参数是原始密码第二个参数是数据库里的密文这两个参数反了就会一直登录失败而且控制台还不报错。我曾见过小伙伴排查了很久最后发现就是这个参数顺序问题。注册拦截器需要实现WebMvcConfigurer接口Configuration public class WebConfig implements WebMvcConfigurer { Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new PermissionInterceptor()) .addPathPatterns(/admin/**) .excludePathPatterns(/admin/login, /admin/doLogin); } }/admin/**下面的所有请求都会被拦截登录页和登录接口放行。前端页面和资源文件因为路径不在/admin下天然不需要拦截这个映射逻辑很清晰。3.2 新闻动态模块的实现内容管理模块要做得有“支撑感”分页查询是不可或缺的部分。使用MyBatis-Plus的分页插件先配置Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }然后在Service层做一个通用的分页方法public PageContent getPageList(int pageNum, int pageSize, String type, String keyword) { PageContent page new Page(pageNum, pageSize); LambdaQueryWrapperContent wrapper new LambdaQueryWrapper(); wrapper.eq(StringUtils.hasText(type), Content::getType, type) .like(StringUtils.hasText(keyword), Content::getTitle, keyword) .orderByDesc(Content::getSortOrder) .orderByDesc(Content::getCreateTime); return contentMapper.selectPage(page, wrapper); }这里值得留意的细节是排序规则先用sort_order倒序再用create_time倒序。排序优先级数字大的排前面、发布时间越新的排前面这样的组合在实际运营中非常常见。当运营人员手动置顶某条重要内容时只需要调整sort_order置顶效果就能立刻体现。前台展示的Controller可能是这样的GetMapping(/news) public String newsList(RequestParam(defaultValue 1) int pageNum, Model model) { PageContent page contentService.getPageList(pageNum, 8, news, null); model.addAttribute(page, page); model.addAttribute(pageNum, pageNum); return front/news/list; } GetMapping(/news/detail/{id}) public String newsDetail(PathVariable Long id, Model model) { Content content contentService.getById(id); model.addAttribute(content, content); return front/news/detail; }列表页的翻页思路要提前规划好Controller接收pageNum参数传给Service层Service层算好分页数据存到Page对象里前端再用Thymeleaf循环渲染。3.3 文件上传与富文本处理门户网站离不开图片上传。产品图片、新闻封面图、富文本编辑器里的插图全都要走文件上传这条路。早期的方案是传文件到项目本地目录这里我想纠正一个非常重要的认知文件要上传到项目之外的一个独立目录。Spring Boot的静态资源默认从classpath:/static/读取如果上传文件时直接写到项目的/static/images/下开发环境下没问题但一旦打包成jar放到服务器上这个写入路径就不存在了。即使能写进去重启后也可能会因为重新解压而丢失那就很尴尬了。正确做法是配置一个独立的绝对路径比如D:/company-site/uploads/Linux服务器上是/data/uploads/然后配置一个虚拟路径映射把/uploads/**的URL映射到那个真实的物理路径。配置代码Configuration public class FileUploadConfig implements WebMvcConfigurer { Value(${file.upload-dir}) private String uploadDir; Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/uploads/**) .addResourceHandler(file: uploadDir); } }上传文件的ControllerPostMapping(/admin/upload) ResponseBody public MapString, Object upload(RequestParam(file) MultipartFile file) { MapString, Object result new HashMap(); if (file.isEmpty()) { result.put(code, 1); result.put(message, 文件为空); return result; } String originalFilename file.getOriginalFilename(); String suffix originalFilename.substring(originalFilename.lastIndexOf(.)); String newFilename UUID.randomUUID().toString().replace(-, ) suffix; File dest new File(uploadDir newFilename); File parentDir dest.getParentFile(); if (!parentDir.exists()) { parentDir.mkdirs(); } file.transferTo(dest); result.put(code, 0); result.put(data, /uploads/ newFilename); return result; }为什么不直接用用户原来的文件名因为存在中文乱码和重名覆盖两个隐患。用UUID重命名可以保证文件名的唯一性而且该项目中还有路径安全问题如果用户上传文件名里有../之类的特殊字符直接拼接路径可能造成目录跨越。富文本编辑器我用wangEditor它在本地化方面对中文项目支持比较好不像一些编辑器要从国外CDN加载。在页面里初始化编辑器只需要几行代码const editor new wangEditor(#editor-container); editor.config.uploadImgServer /admin/upload; editor.config.uploadFileName file; editor.create();然后表单提交的时候把编辑器的HTML内容放到隐藏域里一起提交。有一个细节提醒一下富文本内容里包含图片、表格、样式等等如果后台用like去模糊搜索富文本内容往往效果不好。我在实际项目里的做法是内容搜索只搜标题和摘要正文不参与搜索。这个取舍在答辩时也可以作为一个话题聊一聊说明你对用户体验有过真实思考。4. 页面渲染方式与前端整合4.1 Thymeleaf模板布局Thymeleaf最大的一个实用功能是模板布局这个必须用起来。如果不用模板布局每个页面都要复制一遍导航和底部代码改一个页面所有页面都要手动同步。用了布局改导航只需改一个文件。在templates/common/下面建一个layout.html!DOCTYPE html html xmlns:thhttp://www.thymeleaf.org th:fragmentlayout(content) head meta charsetUTF-8 title公司门户网站/title link relstylesheet th:href{/css/bootstrap.min.css} link relstylesheet th:href{/css/common.css} /head body div th:replace~{common/header :: header}/div main div th:insert~{${content}}/div /main div th:replace~{common/footer :: footer}/div /body /html然后业务页面就只需要写自己的那部分内容!DOCTYPE html html xmlns:thhttp://www.thymeleaf.org head meta charsetUTF-8 title新闻列表/title /head body th:fragmentcontent div th:eachitem : ${page.records} h3 th:text${item.title}/h3 p th:text${item.summary}/p a th:href{/news/detail/{id}(id${item.id})}阅读全文/a /div /body /html把导航和底部抽出成fragment页面结构就是一块积木搭一块积木清爽得很。分页组件也可以做成一个公共的fragment放在templates/common/pagination.html里传两个参数就能复用nav th:fragmentpagination(page, baseUrl) ul classpagination li classpage-item th:classappend${page.current 1} ? disabled a classpage-link th:href{${baseUrl}(pageNum1)}上一页/a /li li classpage-item th:eachi : ${#numbers.sequence(1, page.pages)} th:classappend${i page.current} ? active a classpage-link th:href{${baseUrl}(pageNum${i})} th:text${i}1/a /li li classpage-item th:classappend${page.current page.pages} ? disabled a classpage-link th:href{${baseUrl}(pageNum${page.pages})}下一页/a /li /ul /nav这里注意使用#numbers.sequence遍历页码它接收1和page.pages两个参数生成一个从1到最大页数的数字序列。Thymeleaf的这个语法在一些老教程里不太常见很多人写分页都是直接用JS分页插件但服务端渲染用一个模板fragment其实更轻量。4.2 Bootstrap响应式布局公司门户网站需要适配各种尺寸的屏幕手机端现在占的流量比重相当高必须考虑响应式布局。Bootstrap的栅格系统是最成熟的选择。举个典型的首页案例产品展示区使用卡片网格。div classcontainer my-5 div classrow div classcol-md-4 col-sm-6 th:eachproduct : ${productList} div classcard h-100 shadow-sm img th:src${product.coverImage} classcard-img-top altcover div classcard-body h5 classcard-title th:text${product.title}/h5 p classcard-text th:text${product.summary}/p a th:href{/product/detail/{id}(id${product.id})} classbtn btn-outline-primary查看详情/a /div /div /div /div /divcol-md-4 col-sm-6的语义是中等屏幕以上一行显示3个小屏一行显示2个手机屏就自然变成单列。这套栅格系统你不需要记住每一个类名理解了这个由宽到窄的排列逻辑做响应式页面就顺手多了。图片加载的优化也要注意。如果封面图片是一张很大的原图直接显示手机端会非常卡加载也慢。一个务实的方案是上传时用Thumbnailator这个库生成缩略图Thumbnails.of(file.getInputStream()) .size(800, 600) .outputFormat(jpg) .toFile(new File(uploadDir thumb_ newFilename));显示列表页时用缩略图详情页才加载原图。这个细节别忽略它的优化效果在大图多的项目里非常直观。5. 部署上线与常见问题排查实录5.1 使用Maven打包与服务器部署毕设项目通常不需要走复杂的CI/CD能本地打包再放到服务器上用即可。打包之前确认几个配置项application.yml里的数据源地址要改成服务器的数据库地址file.upload-dir改成服务器上的绝对路径配置的端口如果服务器上被占了就要更换端口。server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/company_site?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 你的密码 servlet: multipart: max-file-size: 10MB file: upload-dir: /data/uploads/打包用Maven命令mvn clean package生成的target/company-site-0.0.1-SNAPSHOT.jar就是可执行的Spring Boot工程。扔到服务器上之后用nohup java -jar company-site-0.0.1-SNAPSHOT.jar app.log 21 启动后记得检查app.log里有没有报错。Spring Boot的日志在启动阶段会打印大段的启动信息确认最后出现了Started Application in 3.5 seconds这样一行字样才算真正的启动成功。nohup和的用法我解释一下nohup保证关闭SSH终端后进程依然在跑是把标准输出和错误输出都重定向到app.log最后的表示放在后台执行。这套操作在你的开发机Windows上也能模拟但生产环境多数是Linux服务器如果你在答辩时需要演示线上环境这套命令会帮上大忙。5.2 运行问题与排查技巧快查这部分整理平时被问得最多的问题基本涵盖了这类项目从开发到上线最常遇见的坑。第一个问题是“数据库连不上报Access denied”。绝大多数情况是MySQL的用户名密码和application.yml里的不匹配或者MySQL的账号只允许本机登录没有授权远程访问。检查思路分两层第一确认密码正确第二确认MySQL授权了对应IP的访问。答辩前的模拟检查我会建议他们把这两个层面依次验证一遍项目不是死里排查而是按层次缩小范围。第二个问题是“中文乱码”。注意是“数据库里是不是UTF-8”而不是“编辑器里是不是UTF-8”。如果建表时没有指定字符集MySQL某些版本默认就是latin1中文写入就会变成问号。建表命令加上DEFAULT CHARSETutf8mb4这是最稳妥的做法。utf8mb4跟utf8的关系可以这样理解utf8mb4是完整的UTF-8实现连emoji都能存而MySQL的utf8其实是个阉割版只支持部分字符。所以凡是涉及用户输入内容的表建议都直接上utf8mb4。第三个问题是“上传图片404”。原因集中在两点虚拟路径没配置、物理目录不存在。排查顺序建议是先确认浏览器地址栏里的图片URL是什么再用curl测试这个URL返回什么然后是那套虚拟映射对应的物理路径是否真的存在那个文件名。注意addResourceHandler指向的路径必须以file:开头加上后Windows/Linux都能正确识别。第四个问题是“登录成功后刷新页面就退出”。这是Session丢失的典型表现。先检查server.session.timeout配置默认30分钟再确认拦截器是否把登录页或静态资源也拦掉了。如果静态资源被拦截到重定向到登录页页面加载时就会出现各种资源的404页面看起来就像“崩了”。这里的经验是拦截路径的范围宁可精确到/admin/**也不要图省事用/**加白名单白名单一长就容易漏。因为我是带毕设的踩过的坑实在不少有时发现问题根本不在代码里而是浏览器缓存改完CSS刷新不出来。这个不用慌按住CtrlF5强制刷新一下或者顺手打开开发者工具的Network面板勾选Disable cache边改边查效率和心情都会好很多。补充分支观点这位同学的项目做扎实还能怎么延伸前面讲的内容对完成一个能交的毕设完全足够了。但如果你想把项目做得更有亮点、在答辩时更有底气我提供几个低成本扩展的思路。一个是网站访问统计。Spring Boot框架加上一个简单的过滤器把访客的IP、访问路径、访问时间记录下来后台页面直接展示访问量趋势图表。这个功能看似简单但它让你项目的数据分析能力和真实性都提升了还能顺便讲“通过IP统计访问来源”体现你对网站运营的理解。一个是缓存优化。在content表的查询上为首页和新闻列表页添加Spring Cache或者简单的ConcurrentHashMap缓存。为什么把这个作为延伸选项因为门槛低代码改动不大但能提取出一个“缓存优化提高了系统响应速度”的技术点评委几乎必然感兴趣。还有一个方向是关键词搜索。当前面的内容量积累到一定程度你可以为门户增加一个站内搜索。基于LIKE模糊查询即可实现基本版进阶一点还可以引入分词搜索。别急着问要不要上Elasticsearch——在这个体量的项目里用数据库自带能力解决问题就足够它考验的是你是否理解“技术选型要匹配业务规模”这个原则。结尾最后分享一点我个人的经验总结。带了这么多届毕设我发现做这类项目最容易犯的错误就是“过度设计”。明明是一个企业门户网站非要引入Redis、MQ、ES全家桶。技术秀得再花哨如果连基础的业务逻辑都讲不清楚答辩依然不会轻松。真正做得好的同学往往是把业务理解、技术实现和部署运维这三件事都踏踏实实做透了的人。如果你现在正在做这个题目建议按这个节奏推进第一周梳理需求、建库建表第二周完成后台登录和内容管理第三周做前台页面渲染最后一周集中测试和部署。遇到报错时别慌先看控制台关键报错信息把异常堆栈贴到搜索引擎里你会发现这个问题的解决方案大概率早就有人写好了你只是需要去找到它而已。祝顺利。