☰
从零搭建巴黎时装周预约系统:Spring Boot+Thymeleaf+MySQL实战
2026/10/7 10:19:23 网站建设 项目流程

最近社交平台上“大衣男回归,出发巴黎时装周”的话题热度不低,很多读者私信问我能不能出一篇技术向的拆解文章。时装周相关的业务场景,其实非常适合用来练手 Web 开发:它包含内容展示、活动排期、预约报名、款式管理和后台维护等典型模块,几乎就是一个迷你版的企业级内容管理系统。本文就以“巴黎时装周”为主题背景,带你从零搭建一套完整的时装周内容展示与预约系统,顺便聊清楚 Spring Boot + Thymeleaf + MySQL 这套组合在实际项目里的配置思路、代码组织和排错方法。

这套教程不仅适合刚学完 Java Web 的初学者,也适合需要快速交付内容型管理系统的开发者。跟着文章走,你能掌握项目结构设计、数据表建模、业务接口开发、页面渲染、表单校验和常见异常排查,最后还能用一套可复制的方案去迁移到其他类似场景,比如展会预约、新品发布、线下活动报名等。

1. 背景与核心概念

1.1 时装周内容展示系统是什么

抛开娱乐新闻的热度不谈,单看业务需求,“巴黎时装周”背后其实是一个典型的内容运营场景:主办方需要展示活动日程、设计师信息、参展品牌、单品款式,还要支持媒体或买手在线预约,后台则需要一套管理界面来维护这些内容。

这类系统通常叫“活动内容管理系统”或“展会预约平台”,和电商系统最大的区别在于:它的核心不是交易闭环,而是“内容发布 + 用户预约 + 后台维护”三段式结构。理解这个边界很重要,因为很多初学者容易把这类项目做成大杂烩,结果前后台业务边界混乱,代码难以维护。

本文要实现的系统,定位就是内容展示加预约,不涉及支付、库存、物流等电商复杂逻辑,这让我们可以把更多精力放在架构清晰度和代码规范上。

1.2 为什么选择 Spring Boot + Thymeleaf + MySQL

技术选型不追求最热门,而追求适合业务规模和团队能力。这里选用 Spring Boot,是因为它解决了 Spring 生态里最头疼的配置问题,内置 Tomcat,打 jar 包就能跑,非常适合快速交付这类内容型项目。

Thymeleaf 是服务端渲染模板引擎,它允许我们在 HTML 页面中直接使用${}、th:each这类语法渲染数据,对 SEO 友好,首屏加载也快。由于系统规模不大,没必要拆前后端分离架构,用服务端渲染可以让代码量和部署成本都降下来。

MySQL 负责数据持久化,存储活动、款式、预约记录等结构化信息。三者的组合,是 Java Web 开发中最经典、资料最丰富、排错最容易的一套搭配。

1.3 核心功能拆解

为了让技术点集中,也方便读者理解,我们把系统拆成五个模块:

  • 活动日程展示:巴黎时装周会有多场次发布活动,每场活动有标题、时间、地点、设计师简介等字段。
  • 款式图库管理:维护每场活动下的代表单品,包括服装名称、风格标签、衣长描述、材质、展示图地址等。
  • 预约报名:用户选择到场日期和场次,填写姓名和联系方式,系统写入预约记录。
  • 后台登录校验:使用简单的 Spring Boot 拦截器做权限校验,避免未登录人员直接访问后台管理接口。
  • 后台维护:支持添加新活动、维护款式、查看预约列表。

“大衣男回归”这个点,可以理解成一种单品风格回归——即大衣类男装重新成为本季时装周的重点。我们正好在款式模块里加入“风格标签”字段,用大衣、复古、极简等标签演示数据筛选场景。

2. 环境准备与版本说明

2.1 开发环境

本文示例以常见的 Java 开发环境为例,具体版本可以结合你本机的实际环境微调:

软件建议版本用途
JDK11 或 17编译运行 Spring Boot 项目
Maven3.6+依赖管理和项目构建
Spring Boot2.7.x 或 3.x应用开发框架
MySQL5.7 及以上数据存储
IDEIntelliJ IDEA开发和调试
Thymeleaf跟随 Spring Boot 版本服务端页面渲染

如果你使用的是 Spring Boot 3.x,需要注意 JDK 必须 17 及以上,而且部分配置写法会有调整。文章中的代码以 Spring Boot 2.7 为主,依赖坐标基本都是通用的,升级到 3.x 时把javax.servlet换成jakarta.servlet即可。

2.2 初始化项目

最简单的初始化方式是通过 Spring Initializr。在 start.spring.io 页面选择 Maven、Java 11,然后添加 Web、Thymeleaf、Spring Data JPA、MySQL Driver、Validation、Lombok 这几个依赖,生成 zip 包后导入 IDE。

如果你更习惯手动创建,也可以直接新建 Maven 项目,在pom.xml里引入依赖,后面章节会给出完整的 pom 配置。

2.3 数据库准备

先在 MySQL 中创建库:

CREATE DATABASE IF NOT EXISTS paris_fashion DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE paris_fashion;

使用utf8mb4是为了让数据库能正确存储中文和一些特殊字符,比如品牌名中的é或 emoji 风格标签。

3. 核心设计:数据模型与后台架构

3.1 数据表设计

我们设计三张核心业务表:fashion_show、outfit、appointment,另外加一张admin_user用于后台登录。

表名用途关键字段
fashion_show时装周活动场次title, show_time, location, designer, description, status
outfit款式单品show_id, name, style_tag, material, cover_url, description
appointment现场预约show_id, visitor_name, phone, visit_date, status
admin_user后台用户username, password_hash

outfit表通过show_id关联到fashion_show,这样每场活动都能维护多个代表款式。appointment也通过show_id关联活动,避免用户预约时还需要手动输入活动名称。

3.2 为什么把样式字段单独建表

很多初学者习惯于把服装款式字段直接写在活动表里,用逗号分隔多个风格标签。这样做在前期看起来省事,但后续一旦要做“按风格筛选款式”或“统计大衣类单品数量”,SQL 会变得非常难写,而且数据容易脏。

独立建表后,我们可以用一条普通的多表关联查询实现筛选,还能在 Java 层用实体关系直观映射。这其实是在强调一个通用原则:一对多关系不要用逗号拼字符串,单独建表才是规范做法。

3.3 项目整体结构

为了让代码分层清晰,采用常见的包结构:

com.paris.fashion ├── FashionApplication.java ├── controller │ ├── PageController.java │ └── AdminController.java ├── entity │ ├── FashionShow.java │ ├── Outfit.java │ ├── Appointment.java │ └── AdminUser.java ├── repository │ ├── FashionShowRepository.java │ ├── OutfitRepository.java │ ├── AppointmentRepository.java │ └── AdminUserRepository.java ├── service │ ├── FashionShowService.java │ ├── OutfitService.java │ └── AppointmentService.java ├── interceptor │ └── AdminInterceptor.java └── web └── WebConfig.java

Controller 只负责接收请求和返回视图,业务逻辑放到 Service,数据访问放到 Repository。这种分层的价值在项目变大后会体现得非常明显:每个类职责单一,出问题时可以快速定位。

4. 完整实战:搭建时装周预约系统

4.1 编写 pom.xml 依赖

创建项目后,先检查pom.xml。下面是一份可用的核心依赖配置:

<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent> <groupId>com.paris</groupId> <artifactId>paris-fashion</artifactId> <version>1.0.0</version> <name>paris-fashion</name> <description>巴黎时装周内容展示与预约系统</description> <properties> <java.version>11</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <configuration> <excludes> <exclude> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> </exclude> </excludes> </configuration> </plugin> </plugins> </build> </project>

MySQL 驱动在 Spring Boot 2.7 中坐标是mysql:mysql-connector-java,到了 Spring Boot 3.x 推荐用com.mysql:mysql-connector-j,这一点升级时要注意。

4.2 配置 application.yml

在src/main/resources/application.yml中写入如下配置:

server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/paris_fashion?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 你自己的密码 jpa: hibernate: ddl-auto: update show-sql: true properties: hibernate: format_sql: true thymeleaf: cache: false mode: HTML encoding: UTF-8

ddl-auto: update表示 Hibernate 会根据实体类自动更新表结构,开发阶段比较方便。但生产环境不建议使用这个配置,最好由 DBA 维护 SQL 脚本,避免实体字段调整时自动改表引发事故。

thymeleaf.cache: false可以在开发调试时让页面修改即时生效,不用重启应用。

4.3 编写实体类

活动实体

文件路径:src/main/java/com/paris/fashion/entity/FashionShow.java

package com.paris.fashion.entity; import lombok.Data; import javax.persistence.*; import javax.validation.constraints.NotBlank; import java.time.LocalDateTime; @Data @Entity @Table(name = "fashion_show") public class FashionShow { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @NotBlank(message = "活动标题不能为空") private String title; private LocalDateTime showTime; private String location; private String designer; @Column(columnDefinition = "TEXT") private String description; private String status; }

这里用@JsonFormat可以做而是把 LocalDateTime 交给前端格式化的思路更常见。模板渲染时用 Thymeleaf 的#temporals.format输出即可。

款式实体

文件路径:src/main/java/com/paris/fashion/entity/Outfit.java

package com.paris.fashion.entity; import lombok.Data; import javax.persistence.*; @Data @Entity @Table(name = "outfit") public class Outfit { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private Long showId; private String name; private String styleTag; private String material; private String coverUrl; @Column(columnDefinition = "TEXT") private String description; }

styleTag字段用来存储“大衣”“复古”“极简”这类标签,后期可以在页面上做筛选查询。

预约实体

文件路径:src/main/java/com/paris/fashion/entity/Appointment.java

package com.paris.fashion.entity; import lombok.Data; import javax.persistence.*; import javax.validation.constraints.NotBlank; import javax.validation.constraints.Pattern; import java.time.LocalDateTime; @Data @Entity @Table(name = "appointment") public class Appointment { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private Long showId; @NotBlank(message = "姓名不能为空") private String visitorName; @NotBlank(message = "手机号不能为空") @Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确") private String phone; private LocalDateTime visitDate; private String status; @Column(updatable = false) private LocalDateTime createTime; }

手机号校验使用正则表达式,前端和后端都可以复用。这里提醒一下,@Pattern只能做格式校验,不能保证号码真实有效,实际项目中应该配合短信验证码服务。

后台用户实体

文件路径:src/main/java/com/paris/fashion/entity/AdminUser.java

package com.paris.fashion.entity; import lombok.Data; import javax.persistence.*; @Data @Entity @Table(name = "admin_user") public class AdminUser { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(unique = true, nullable = false) private String username; @Column(nullable = false) private String passwordHash; }

4.4 编写 Repository

Spring Data JPA 可以通过方法名自动生成查询,不需要手写 SQL,适合这个体量的项目。

文件路径:src/main/java/com/paris/fashion/repository/FashionShowRepository.java

package com.paris.fashion.repository; import com.paris.fashion.entity.FashionShow; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; public interface FashionShowRepository extends JpaRepository<FashionShow, Long> { List<FashionShow> findByStatusOrderByShowTimeDesc(String status); }

文件路径:src/main/java/com/paris/fashion/repository/OutfitRepository.java

package com.paris.fashion.repository; import com.paris.fashion.entity.Outfit; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; public interface OutfitRepository extends JpaRepository<Outfit, Long> { List<Outfit> findByShowId(Long showId); List<Outfit> findByStyleTagContaining(String styleTag); }

文件路径:src/main/java/com/paris/fashion/repository/AppointmentRepository.java

package com.paris.fashion.repository; import com.paris.fashion.entity.Appointment; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; public interface AppointmentRepository extends JpaRepository<Appointment, Long> { List<Appointment> findByShowIdOrderByCreateTimeDesc(Long showId); }

文件路径:src/main/java/com/paris/fashion/repository/AdminUserRepository.java

package com.paris.fashion.repository; import com.paris.fashion.entity.AdminUser; import org.springframework.data.jpa.repository.JpaRepository; import java.util.Optional; public interface AdminUserRepository extends JpaRepository<AdminUser, Long> { Optional<AdminUser> findByUsername(String username); }

4.5 编写 Service 层

Service 层做两件事:业务校验和事务管理。先看活动与款式服务。

文件路径:src/main/java/com/paris/fashion/service/FashionShowService.java

package com.paris.fashion.service; import com.paris.fashion.entity.FashionShow; import com.paris.fashion.repository.FashionShowRepository; import org.springframework.stereotype.Service; import java.util.List; @Service public class FashionShowService { private final FashionShowRepository fashionShowRepository; public FashionShowService(FashionShowRepository fashionShowRepository) { this.fashionShowRepository = fashionShowRepository; } public List<FashionShow> listPublishedShows() { return fashionShowRepository.findByStatusOrderByShowTimeDesc("published"); } public FashionShow getShow(Long id) { return fashionShowRepository.findById(id) .orElseThrow(() -> new IllegalArgumentException("活动不存在")); } public FashionShow saveShow(FashionShow show) { if (show.getStatus() == null) { show.setStatus("published"); } return fashionShowRepository.save(show); } }

文件路径:src/main/java/com/paris/fashion/service/AppointmentService.java

package com.paris.fashion.service; import com.paris.fashion.entity.Appointment; import com.paris.fashion.repository.AppointmentRepository; import org.springframework.stereotype.Service; import java.time.LocalDateTime; import java.util.List; @Service public class AppointmentService { private final AppointmentRepository appointmentRepository; public AppointmentService(AppointmentRepository appointmentRepository) { this.appointmentRepository = appointmentRepository; } public Appointment createAppointment(Appointment appointment) { appointment.setStatus("pending"); appointment.setCreateTime(LocalDateTime.now()); return appointmentRepository.save(appointment); } public List<Appointment> listAppointments() { return appointmentRepository.findAll(); } }

构造函数注入是 Spring 官方推荐的注入方式,以后写单元测试时可以直接传 mock 对象,不需要依赖反射。

4.6 编写 Controller

页面控制器

文件路径:src/main/java/com/paris/fashion/controller/PageController.java

package com.paris.fashion.controller; import com.paris.fashion.entity.Appointment; import com.paris.fashion.entity.FashionShow; import com.paris.fashion.entity.Outfit; import com.paris.fashion.service.AppointmentService; import com.paris.fashion.service.FashionShowService; import com.paris.fashion.service.OutfitService; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.validation.BindingResult; import org.springframework.web.bind.annotation.*; import javax.validation.Valid; @Controller public class PageController { private final FashionShowService fashionShowService; private final OutfitService outfitService; private final AppointmentService appointmentService; public PageController(FashionShowService fashionShowService, OutfitService outfitService, AppointmentService appointmentService) { this.fashionShowService = fashionShowService; this.outfitService = outfitService; this.appointmentService = appointmentService; } @GetMapping("/") public String index(Model model) { model.addAttribute("shows", fashionShowService.listPublishedShows()); return "index"; } @GetMapping("/show/{id}") public String showDetail(@PathVariable Long id, Model model) { FashionShow show = fashionShowService.getShow(id); model.addAttribute("show", show); model.addAttribute("outfits", outfitService.getOutfitsByShow(id)); model.addAttribute("appointment", new Appointment()); return "detail"; } @PostMapping("/appointment") public String submitAppointment(@Valid @ModelAttribute Appointment appointment, BindingResult bindingResult, Model model) { if (bindingResult.hasErrors()) { FashionShow show = fashionShowService.getShow(appointment.getShowId()); model.addAttribute("show", show); model.addAttribute("outfits", outfitService.getOutfitsByShow(appointment.getShowId())); return "detail"; } appointmentService.createAppointment(appointment); return "redirect:/show/" + appointment.getShowId() + "?booked=true"; } @GetMapping("/style") public String styleFilter(@RequestParam(value = "tag", required = false) String tag, Model model) { model.addAttribute("shows", fashionShowService.listPublishedShows()); model.addAttribute("outfits", outfitService.filterByTag(tag)); model.addAttribute("currentTag", tag); return "style"; } }

这里有几个值得注意的地方:

  • 预约提交失败时不能直接刷新页面,而是通过 Model 回传错误信息,再渲染同一个详情页。
  • 成功预约后使用redirect:重定向,避免用户刷新浏览器导致表单重复提交。
  • @Valid和BindingResult必须成对出现,否则校验失败时会直接抛出异常。
后台管理控制器

文件路径:src/main/java/com/paris/fashion/controller/AdminController.java

package com.paris.fashion.controller; import com.paris.fashion.entity.FashionShow; import com.paris.fashion.service.AppointmentService; import com.paris.fashion.service.FashionShowService; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.*; @Controller @RequestMapping("/admin") public class AdminController { private final FashionShowService fashionShowService; private final AppointmentService appointmentService; public AdminController(FashionShowService fashionShowService, AppointmentService appointmentService) { this.fashionShowService = fashionShowService; this.appointmentService = appointmentService; } @GetMapping("/login") public String loginPage() { return "admin/login"; } @GetMapping("/shows") public String listShows(Model model) { model.addAttribute("shows", fashionShowService.listPublishedShows()); model.addAttribute("newShow", new FashionShow()); return "admin/shows"; } @PostMapping("/shows") public String createShow(@ModelAttribute FashionShow show) { fashionShowService.saveShow(show); return "redirect:/admin/shows"; } @GetMapping("/appointments") public String listAppointments(Model model) { model.addAttribute("appointments", appointmentService.listAppointments()); return "admin/appointments"; } }

考虑到文章篇幅,后台登录部分的完整 Session 代码可以参照前面提到的拦截器思路自行扩展,下文会用一段代码演示核心拦截逻辑。

4.7 编写 Thymeleaf 页面

首页模板

文件路径:src/main/resources/templates/index.html

<!DOCTYPE html> <html lang="zh" xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="UTF-8"> <title>巴黎时装周专场</title> <link rel="stylesheet" href="/webjars/bootstrap/5.1.3/css/bootstrap.min.css"> </head> <body> <div class="container mt-4"> <h1>巴黎时装周 · 本季重点回归</h1> <p class="text-muted">大衣男风格回归,出发巴黎时装周,第一时间掌握活动场次与代表款式。</p> <h2 class="mt-4">活动日程</h2> <div class="row"> <div class="col-md-4 mt-3" th:each="show : ${shows}"> <div class="card"> <div class="card-body"> <h5 class="card-title" th:text="${show.title}">活动标题</h5> <p class="card-text" th:text="${#temporals.format(show.showTime, 'yyyy-MM-dd HH:mm')}"></p> <p class="card-text" th:text="${show.location}">地点</p> <a class="btn btn-primary" th:href="@{/show/{id}(id=${show.id})}">查看详情</a> </div> </div> </div> </div> </div> </body> </html>

th:each是 Thymeleaf 循环渲染的语法,#temporals.format用来格式化时间,这两个是服务端渲染中最高频的用法。

活动详情与预约页

文件路径:src/main/resources/templates/detail.html

<!DOCTYPE html> <html lang="zh" xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="UTF-8"> <title th:text="${show.title}">活动详情</title> </head> <body> <div class="container mt-4"> <a href="/" class="btn btn-outline-secondary mb-3">返回首页</a> <h1 th:text="${show.title}">活动标题</h1> <p th:text="'设计师:' + ${show.designer}"></p> <p th:text="${show.description}"></p> <h2 class="mt-4">代表款式</h2> <div class="row"> <div class="col-md-3 mt-3" th:each="outfit : ${outfits}"> <div class="card"> <img th:src="${outfit.coverUrl}" class="card-img-top" alt="款式图片"> <div class="card-body"> <h5 class="card-title" th:text="${outfit.name}">款式名称</h5> <p class="card-text" th:text="'风格标签:' + ${outfit.styleTag}"></p> <p class="card-text" th:text="'材质:' + ${outfit.material}"></p> </div> </div> </div> </div> <h2 class="mt-4">我要预约</h2> <form th:action="@{/appointment}" method="post" th:object="${appointment}"> <input type="hidden" th:field="*{showId}" /> <div class="mb-3"> <label>姓名</label> <input type="text" class="form-control" th:field="*{visitorName}" /> <span th:if="${#fields.hasErrors('visitorName')}" th:errors="*{visitorName}" class="text-danger"></span> </div> <div class="mb-3"> <label>手机号</label> <input type="text" class="form-control" th:field="*{phone}" /> <span th:if="${#fields.hasErrors('phone')}" th:errors="*{phone}" class="text-danger"></span> </div> <div class="mb-3"> <label>到访时间</label> <input type="datetime-local" class="form-control" th:field="*{visitDate}" /> </div> <button type="submit" class="btn btn-success">提交预约</button> </form> </div> </body> </html>

使用th:field="*{showId}"时,实体类中必须有对应字段,渲染时会自动把当前活动 ID 写入隐藏域。这里要注意datetime-local输入的值格式是yyyy-MM-ddTHH:mm,需要自行转换或使用@DateTimeFormat注解做格式化,实际项目中更推荐传时间戳,减少前端格式耦合。

4.8 添加测试数据

用以下 SQL 插入两条演示数据,数据内容尽量贴近“大衣男回归”和“巴黎时装周”主题:

USE paris_fashion; INSERT INTO fashion_show (title, show_time, location, designer, description, status) VALUES ('2025 巴黎男装秋冬发布:大衣回归专场', '2025-01-15 10:00:00', '巴黎大皇宫', 'Jean Moreau', '本季聚焦大衣类男装,重现经典轮廓与当代面料工艺的结合。', 'published'), ('新锐设计师联合秀', '2025-01-16 14:00:00', '巴黎玛黑区艺术中心', 'Chen Wei', '多位亚洲设计师联合展示极简风格与街头元素的碰撞。', 'published'); INSERT INTO outfit (show_id, name, style_tag, material, cover_url, description) VALUES (1, '双排扣羊毛大衣', '大衣, 复古', '羊毛混纺', 'https://example.com/coat1.jpg', '经典双排扣剪裁,宽松廓形设计。'), (1, '驼色系带大衣', '大衣, 极简', '羊绒', 'https://example.com/coat2.jpg', '高支羊绒面料,适合秋冬通勤场景。'), (1, '黑色连帽派克大衣', '大衣, 街头', '棉质帆布', 'https://example.com/coat3.jpg', '兼具街头感与实用性的轻量级大衣。'), (2, '解构西装外套', '极简, 解构', '亚麻混纺', 'https://example.com/suit1.jpg', '不对称剪裁,体现面料的自然肌理。');

这里有两个字段值得说明:

  • show_time使用DATETIME类型,把时间语义存清楚。
  • cover_url在真实项目中应该存可访问的图片地址,开发环境可直接放测试图片链接。

4.9 运行与验证

在项目根目录执行:

mvn spring-boot:run

看到类似Tomcat started on port(s): 8080的日志后,访问:

http://localhost:8080/

预期效果:

  • 首页展示两条活动卡片,按照发布时间倒序显示。
  • 点击“查看详情”进入活动页,可以看到 3 到 4 个款式卡片。
  • 填写姓名和手机号约谈后提交,返回详情页并提示预约成功。
  • 管理员访问/admin/shows可以看到活动维护入口;访问/admin/appointments查看预约列表。

打开后台预约列表时,因为目前没有登录校验,理论上任何人都能访问。下一节补上拦截器做权限控制。

4.10 补一个最小登录拦截器

文件路径:src/main/java/com/paris/fashion/interceptor/AdminInterceptor.java

package com.paris.fashion.interceptor; import org.springframework.web.servlet.HandlerInterceptor; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; public class AdminInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session = request.getSession(); Object adminUser = session.getAttribute("adminUser"); if (adminUser == null) { response.sendRedirect("/admin/login"); return false; } return true; } }

文件路径:src/main/java/com/paris/fashion/web/WebConfig.java

package com.paris.fashion.web; import com.paris.fashion.interceptor.AdminInterceptor; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new AdminInterceptor()) .addPathPatterns("/admin/**") .excludePathPatterns("/admin/login"); } }

登录接口本身不复杂,核心思路是:接收表单用户名和密码,查库比对哈希值,比对通过后把用户对象存入session。完整实现建议参考 Spring Security 或 BCrypt 工具类,不要明文存储密码。

5. 常见问题与排查思路

在实际开发中,下面几个问题出现频率最高,整理成表格方便收藏查阅。

问题现象常见原因解决思路
启动报“Unable to open JDBC Connection”数据库未启动或密码错误检查 MySQL 服务状态、连接 URL、账号密码
页面显示中文乱码Thymeleaf 编码或数据库字符集错误确认application.yml中 UTF-8 配置,库表使用 utf8mb4
表单校验不生效缺少@Valid或 BindingResult 位置错误校验注解必须在实体字段上,Controller 参数中@Valid后紧跟BindingResult
预约提交刷新后重复预约表单直接返回视图而非重定向Post/Redirect/Get 模式,提交成功后redirect:
后台页面未登录可直接访问缺少拦截器或拦截器路径配置错误配置拦截器.addPathPatterns("/admin/**")
升级 Spring Boot 3.x 后编译失败javax.servlet已被jakarta.servlet替换全量替换为jakarta包名即可
showTime前端显示不正确LocalDateTime 前端直接输出序列化问题使用 Thymeleaf 的#temporals.format或用字符串参数

每条问题背后都对应一个开发阶段容易踩中的点。像乱码问题,很多人只在页面设置了 UTF-8,却忽略了数据库连接串里的characterEncoding=utf8,这一点值得格外注意。

6. 最佳实践与工程建议

6.1 数据层面:不要在生产用 ddl-auto

spring.jpa.hibernate.ddl-auto=update只适合开发环境快速迭代。正式部署时,表结构变更应该走数据库迁移脚本,推荐引入 Flyway 或 Liquibase。这类工具能把每次变更记录下来,团队协作时不会出现“我本地能跑,你本地报错”的尴尬。

对于预约系统,还要给appointment表的phone字段和show_id字段建立索引,SQL 如下:

CREATE INDEX idx_appointment_phone ON appointment(phone); CREATE INDEX idx_appointment_show ON appointment(show_id);

索引不是越多越好,但高频查询字段值得建立,尤其是按手机号检索预约记录这种典型场景。

6.2 安全层面:密码必须哈希存储

后台用户的密码绝对不允许明文存储。推荐使用 BCrypt。Spring Security 自带的BCryptPasswordEncoder是一个稳妥选择,你也可以在工具类中引入spring-security-crypto依赖。实际项目中,登录逻辑加入失败次数限制,连续错误后锁定账号一段时间,能有效避免暴力破解。

6.3 业务层面:前端校验和后端校验都要做

Thymeleaf 页面中可以直接用 HTML 的required属性做一些前端提示,但后端一定要再次校验。原因很简单:攻击者可以绕过浏览器直接向后端接口提交数据,如果后端不校验,脏数据就会进入数据库。本项目中@Valid和BindingResult就是最低限度的后端防线。

6.4 代码层面:分层职责要清晰

Controller 不要出现业务代码,比如不要直接在 Controller 里查数据库、拼接字符串。这种代码在小项目里很省事,但一旦项目超过十个接口,维护成本会急剧上升。建议所有 SQL 查询只出现在 Repository,业务规则只出现在 Service,Controller 只负责参数接收和视图跳转。

6.5 体验层面:走出“比赛项目感”

很多初学者做完系统后会发现“功能都有,但就是不像正式项目”。差距通常出在三个细节:

  • 错误提示文案不够具体。
  • 页面空数据时没有友好占位。
  • 时间日期没有统一格式化。

这些看起来不是核心技术问题,却直接影响系统可用性。建议在模板中处理空集合:

<div th:if="${#lists.isEmpty(outfits)}"> <p class="text-muted">该场次暂未上传代表款式,请稍后再来。</p> </div>

6.6 部署建议

生产环境建议用java -jar配合 Systemd 或容器化部署,同时配置 JVM 参数:

java -Xms512m -Xmx1024m -jar paris-fashion-1.0.0.jar --spring.profiles.active=prod

不同环境用不同application-{profile}.yml文件,数据库连接、日志级别、缓存开关都放进对应配置文件,避免改代码。

7. 总结与学习路线

本文围绕“大衣男回归,出发巴黎时装周”这个话题,完成了一套可以运行的时装周内容展示与预约系统。你学到了 Spring Boot 的项目结构、JPA 实体和 Repository 的编写方式、Thymeleaf 服务端渲染、表单校验、重定向避免重复提交、以及一个最小的后台登录拦截器。这套代码里的很多模式,可以直接迁移到其他活动报名类项目中。

接下来如果你要继续深入,推荐按以下顺序学习:

  • 用 Spring Security 替换自写拦截器,理解认证与授权的关系。
  • 引入 Flyway 管理数据库脚本,理解迁移工具的价值。
  • 把图片存储改为云存储,体验文件访问鉴权和 CDN 加速。
  • 给预约接口设计分页查询,学习 Spring Data JPA 分页用法。
  • 尝试用 Redis 做验证码存储和限流,理解高并发场景的应对思路。

如果你按照文章把项目跑起来,相信会对 Spring Boot 后端开发的完整链路有更直观的认识。代码能跑只是第一步,把结构写清晰、把边界想清楚才是真正提升的过程。收藏这篇文章,下次写同类系统时直接对照操作就好。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询