先来聊一个很常见的需求场景。任何一场线下嘉宾秀、发布会、校园晚会或公司年会,除了内容本身之外,总是绕不开“观众如何了解嘉宾、如何报名、如何查看流程”这类问题。标题里提到的 KISS N TELL 嘉宾秀(KDC 26.8.8),如果给它做一个官网,最核心的功能不是花哨的动画,而是活动信息、嘉宾展示、场次安排和在线预约记录。
这篇文章就从零开始,带大家落地一个“活动嘉宾展示 + 在线预约报名”的示例系统。我会把 KDC 26.8.8 当作活动项目的版本号或场次编号来贯穿整个数据设计,这样后续如果要支持多期活动,也能直接复用同一套系统结构。
本文适合有一定 Java 或 Vue 基础的读者,也适合想学习前后端分离项目如何组织代码的同学。学习完以后,你能掌握一套最小可运行的活动官网开发流程,包括数据库设计、后端接口、前端页面联调,以及部署和上线前需要留意的合规与安全问题。
需要说明的是,本文中的嘉宾名称、时间、地点均为演示数据,不指向任何真实活动。
1. 从一个活动官网看技术本质
很多新手同学第一次接到“活动展示系统”需求时,会本能地想到先做用户注册、权限管理、支付系统。其实对于一个单场活动而言,这样设计往往过于复杂。
一场嘉宾秀的核心信息通常只有几条:
- 活动名称、时间、地点、介绍。
- 有哪些嘉宾,每个嘉宾长什么样、负责什么内容。
- 当晚或当天的流程安排。
- 观众如何报名或预约。
- 运营人员如何在后台维护嘉宾和数据。
如果把这个需求拆开,你会发现它本质上是“内容展示 + 一条低并发预约接口 + 一个简单的管理界面”,并不需要一开始就引入微服务。用 Spring Boot 3 + Vue 3 + MySQL 这样一套主流技术栈,已经足够应付绝大多数类似活动官网场景。
使用前后端分离架构还有一个好处:即使以后要做独立的运营后台、公众号 H5 页面、小程序,后端接口依然可以复用。活动数据的改动不涉及前端页面重新发版,这种灵活性在做活动运营时非常关键。
为了避免文章过长,这篇实战只完成前端观众侧页面和后端预约接口,但数据库和项目结构会预留后台管理能力,你可以很容易在此基础上扩展。
2. 需求分析与系统模块
2.1 观众侧功能
观众打开活动页面后,最希望在首页看到的是以下几个信息:
| 功能模块 | 说明 |
|---|---|
| 活动 Banner 与时间地点 | 让用户快速确认活动是否值得参加 |
| 嘉宾介绍列表 | 展示嘉宾头像、昵称、角色、简介 |
| 活动流程时间线 | 展示每个节目、环节的时间段 |
| 在线预约报名 | 填写昵称、手机号、人数等,完成预约 |
| 预约成功反馈 | 提示用户预约成功,避免重复提交 |
2.2 运营后台功能预留
后台不是本文核心,但在表设计时需要提前考虑。后台通常包含这些能力:
- 维护活动场次,例如
kdc-26.8.8作为活动编号。 - 维护嘉宾信息,新增、排序、隐藏下架。
- 导出预约名单,按预约时间或手机号筛选。
- 设置活动状态,包括草稿、报名中、已结束。
前后端联调阶段,只要接口路径和返回结构保持稳定,后台页面可以后续再补,不需要推翻重新设计。
2.3 系统角色边界
复杂系统的权限要分管理员、运营人员、普通用户,但这个项目场景相对简单。我更推荐一开始就用活动编码区分数据:
kdc-26.8.8 是一期活动 kdc-26.9.9 是另一期活动每次活动都以独立编码存储,某一期活动的数据被误删或修改,不会影响其他场次。这是运营类系统最常见的隔离手段,而不是直接去建多套数据库。
3. 环境准备与项目结构
3.1 开发环境说明
下面是我使用到的基础环境,建议保持一致或接近:
| 环境 | 版本建议 | 说明 |
|---|---|---|
| JDK | 17+ | Spring Boot 3 要求 JDK 17 及以上 |
| Maven | 3.8+ | 用于后端依赖与启动 |
| MySQL | 8.0 | 使用 InnoDB 和 utf8mb4 |
| Node.js | 18+ | Vite 5 需要较新版本 |
| npm | 9+ | 安装前端依赖使用 |
| IDE | IDEA / VS Code | 后端与前端分开打开 |
版本建议只是大多数人使用的稳定方案。如果你本机安装的是更新的版本,通常也不影响。文中涉及版本的地方,请根据实际环境灵活调整。
3.2 工程目录规划
我建议把后端和前端放在同一个根目录下,这样便于 Git 管理:
kiss-n-tell-show/ ├── backend/ │ ├── pom.xml │ └── src/main/java/com/example/kisstell/ │ └── src/main/resources/ └── frontend/ ├── package.json ├── vite.config.js └── src/后端只负责提供接口和数据库交互,前端只负责渲染页面。两个人协作时,一个人可以专注 Vue 页面,另一个人专注 Java 接口,互相不被代码阻塞。
4. 数据库设计
4.1 数据表拆分思路
对于一个活动嘉宾展示系统,我设计了四张表:
kdc_activity:活动主表,存放活动名称、时间、地点、状态。kdc_guest:嘉宾表,通过activity_code关联到具体活动。kdc_schedule:场次安排表,记录每个时间段的活动内容。kdc_booking:预约报名表,记录观众提交的报名信息。
这里并没有把活动作为“机构”去设计,因为单场活动就是一个独立实体。如果以后同一个品牌下有多期活动,只需要插入多条activity_code不同的记录。
4.2 初始化 SQL
实际项目中建议使用数据库迁移工具管理表结构,这里为了让新手一眼看懂,直接提供一份可执行的初始化 SQL。
CREATE DATABASE IF NOT EXISTS kdc_show DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE kdc_show; CREATE TABLE IF NOT EXISTS `kdc_activity` ( `id` bigint NOT NULL AUTO_INCREMENT, `activity_code` varchar(32) NOT NULL COMMENT '活动编号,如 kdc-26.8.8', `title` varchar(128) NOT NULL COMMENT '活动标题', `sub_title` varchar(256) DEFAULT NULL COMMENT '副标题', `start_time` datetime DEFAULT NULL COMMENT '开始时间', `end_time` datetime DEFAULT NULL COMMENT '结束时间', `address` varchar(255) DEFAULT NULL COMMENT '活动地点', `cover_url` varchar(512) DEFAULT NULL COMMENT '封面图', `status` tinyint NOT NULL DEFAULT '0' COMMENT '0草稿 1报名中 2已结束', `description` text COMMENT '活动详细介绍', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_activity_code` (`activity_code`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='活动表'; CREATE TABLE IF NOT EXISTS `kdc_guest` ( `id` bigint NOT NULL AUTO_INCREMENT, `activity_code` varchar(32) NOT NULL, `name` varchar(64) NOT NULL COMMENT '嘉宾昵称或姓名', `role` varchar(64) DEFAULT NULL COMMENT '身份角色', `intro` text COMMENT '简介', `avatar_url` varchar(512) DEFAULT NULL COMMENT '头像图片地址', `sort_no` int DEFAULT '0' COMMENT '排序', `hidden` tinyint DEFAULT '0' COMMENT '1隐藏', PRIMARY KEY (`id`), KEY `idx_activity_code` (`activity_code`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='嘉宾表'; CREATE TABLE IF NOT EXISTS `kdc_schedule` ( `id` bigint NOT NULL AUTO_INCREMENT, `activity_code` varchar(32) NOT NULL, `start_time` datetime DEFAULT NULL, `end_time` datetime DEFAULT NULL, `item_name` varchar(128) NOT NULL COMMENT '环节名称', `detail` varchar(512) DEFAULT NULL COMMENT '环节说明', `sort_no` int DEFAULT '0', PRIMARY KEY (`id`), KEY `idx_activity_code` (`activity_code`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='活动流程表'; CREATE TABLE IF NOT EXISTS `kdc_booking` ( `id` bigint NOT NULL AUTO_INCREMENT, `activity_code` varchar(32) NOT NULL, `guest_name` varchar(64) NOT NULL COMMENT '预约人昵称', `phone` varchar(32) NOT NULL COMMENT '手机号', `ticket_count` int DEFAULT '1' COMMENT '预约人数', `remark` varchar(256) DEFAULT NULL, `status` tinyint DEFAULT '0' COMMENT '0待确认 1已确认 2已取消', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_activity_phone` (`activity_code`, `phone`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='预约表'; INSERT INTO `kdc_activity` (`activity_code`, `title`, `sub_title`, `start_time`, `end_time`, `address`, `status`, `description`) VALUES ('kdc-26.8.8', 'KISS N TELL 嘉宾秀', 'KISSTELL DEMO SHOW', '2026-08-08 19:30:00', '2026-08-08 21:30:00', '上海·示例剧场', 1, '这是一个用于技术演示的示例活动。'); INSERT INTO `kdc_guest` (`activity_code`, `name`, `role`, `intro`, `avatar_url`, `sort_no`) VALUES ('kdc-26.8.8', '阿伦', '主持人', '负责开场串讲与嘉宾互动。', '', 1), ('kdc-26.8.8', '小鹿', '神秘嘉宾', '擅长音乐现场即兴表演。', '', 2), ('kdc-26.8.8', '米娅', '嘉宾', '负责分享个人创作故事。', '', 3); INSERT INTO `kdc_schedule` (`activity_code`, `start_time`, `end_time`, `item_name`, `detail`, `sort_no`) VALUES ('kdc-26.8.8', '2026-08-08 19:30:00', '2026-08-08 19:40:00', '开场介绍', '主持人与观众见面', 1), ('kdc-26.8.8', '2026-08-08 19:40:00', '2026-08-08 20:20:00', '主题分享', '嘉宾分享故事与作品', 2), ('kdc-26.8.8', '2026-08-08 20:20:00', '2026-08-08 21:00:00', '现场互动', '观众提问与即兴表演', 3), ('kdc-26.8.8', '2026-08-08 21:00:00', '2026-08-08 21:30:00', '自由交流', '活动结束与合影', 4);这里给预约表建了activity_code + phone的唯一索引,避免同一个手机号对同一场活动无限提交。它不算复杂的秒杀拦截,但能挡住最明显的“重复点击”问题。
4.3 为什么用活动编号而不是只靠 id
数据库里主键id只是一个物理唯一标识,运营人员关心的是“哪一场活动”。如果后端接口只暴露自增 id,操作人员很容易填错。
将activity_code作为业务主键之外的关键查询条件,也会更安全。例如:
Optional<Activity> findByActivityCode(String activityCode);前端页面可以直接把链接设计成/activity/kdc-26.8.8,比/activity?id=1更直观,也更方便做缓存和统计。
5. 后端接口实现
5.1 创建 Spring Boot 工程
后端使用 Spring Boot 3,项目坐标可以命名为com.example:kisstell-backend。
在backend/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>3.2.5</version> <relativePath/> </parent> <groupId>com.example</groupId> <artifactId>kisstell-backend</artifactId> <version>1.0.0</version> <name>kisstell-backend</name> <description>KISS N TELL 嘉宾秀示例后端</description> <properties> <java.version>17</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-data-jpa</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project>如果你不使用 Lombok,可以手动为实体类生成 getter、setter 和构造方法。我习惯用 Lombok 精简代码,但项目中有多条数据一定要避免“toString 递归调用”,所以设计实体关联时不要直接使用@Data做双向关联。本文的表之间不建立 JPA 对象级强关联,全部通过activity_code查询,这能减少很多新手常见问题。
5.2 配置文件
在src/main/resources/application.properties中写入:
spring.application.name=kisstell-backend server.port=8080 spring.datasource.url=jdbc:mysql://localhost:3306/kdc_show?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true spring.datasource.username=root spring.datasource.password=你的数据库密码 spring.jpa.hibernate.ddl-auto=none spring.jpa.show-sql=true spring.jpa.open-in-view=false spring.jackson.default-property-inclusion=non_null这里ddl-auto=none是因为我们已经手动执行了初始化 SQL。如果改成update,Hibernate 会自动帮你修改表结构,但生产环境发生过太多“自动加字段导致锁表”的事故,不建议在非学习项目里使用。
5.3 统一结果封装
为了让前后端接口风格统一,定义一个简单的ApiResult:
package com.example.kisstell.common; public class ApiResult<T> { private int code; private String message; private T data; public ApiResult() { } public ApiResult(int code, String message, T data) { this.code = code; this.message = message; this.data = data; } public static <T> ApiResult<T> ok(T data) { return new ApiResult<>(0, "success", data); } public static <T> ApiResult<T> fail(String message) { return new ApiResult<>(-1, message, null); } public int getCode() { return code; } public void setCode(int code) { this.code = code; } public String getMessage() { return message; } public void setMessage(String message) { this.message = message; } public T getData() { return data; } public void setData(T data) { this.data = data; } }业务异常可以单独定义,为了减少篇幅,这里只在 Service 层直接记录错误并返回结果。更严谨的做法是定义全局异常处理器,把校验异常统一翻译成ApiResult.fail。
5.4 实体类
实体类对应数据库中的四张表。这里给出Activity的完整代码,其余实体写法类似:
package com.example.kisstell.entity; import jakarta.persistence.*; import java.time.LocalDateTime; @Entity @Table(name = "kdc_activity") public class Activity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(name = "activity_code", nullable = false, length = 32) private String activityCode; @Column(name = "title", nullable = false, length = 128) private String title; @Column(name = "sub_title", length = 256) private String subTitle; @Column(name = "start_time") private LocalDateTime startTime; @Column(name = "end_time") private LocalDateTime endTime; @Column(name = "address", length = 255) private String address; @Column(name = "cover_url", length = 512) private String coverUrl; @Column(name = "status") private Integer status; @Column(name = "description", columnDefinition = "text") private String description; @Column(name = "create_time") private LocalDateTime createTime; // getter 和 setter 省略,实际代码请补全 }用 JPA 时要注意,字段名统一使用驼峰命名,数据库列名使用下划线。这不算强制规范,但团队协作时如果能保持一致,能减少大量低级错误。
5.5 Repository 数据访问层
定义ActivityRepository和GuestRepository、BookingRepository:
package com.example.kisstell.repository; import com.example.kisstell.entity.Activity; import org.springframework.data.jpa.repository.JpaRepository; import java.util.Optional; public interface ActivityRepository extends JpaRepository<Activity, Long> { Optional<Activity> findByActivityCode(String activityCode); }package com.example.kisstell.repository; import com.example.kisstell.entity.Guest; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; public interface GuestRepository extends JpaRepository<Guest, Long> { List<Guest> findByActivityCodeAndHiddenOrderBySortNoAsc(String activityCode, Integer hidden); }Spring Data JPA 的方法名解析看起来像“魔法”,其实遵循一套固定规则:findBy字段、And、OrderBy字段Asc。调试接口时如果发现查询条件不对,第一步是检查实体字段名是否拼写正确。
5.6 Service 业务层
我把逻辑写在 Service 中,避免 Controller 里堆大段代码。ActivityQueryService负责查询活动、嘉宾和流程:
package com.example.kisstell.service; import com.example.kisstell.entity.Activity; import com.example.kisstell.entity.Guest; import com.example.kisstell.repository.ActivityRepository; import com.example.kisstell.repository.GuestRepository; import com.example.kisstell.repository.ScheduleRepository; import org.springframework.stereotype.Service; import java.util.List; import java.util.Optional; @Service public class ActivityQueryService { private final ActivityRepository activityRepository; private final GuestRepository guestRepository; private final ScheduleRepository scheduleRepository; public ActivityQueryService(ActivityRepository activityRepository, GuestRepository guestRepository, ScheduleRepository scheduleRepository) { this.activityRepository = activityRepository; this.guestRepository = guestRepository; this.scheduleRepository = scheduleRepository; } public Activity getActivityByCode(String activityCode) { return activityRepository.findByActivityCode(activityCode) .orElseThrow(() -> new RuntimeException("活动不存在或已下线")); } public List<Guest> listGuests(String activityCode) { return guestRepository.findByActivityCodeAndHiddenOrderBySortNoAsc(activityCode, 0); } }这里直接抛出RuntimeException只是一个教学简化。真实项目中,应自定义资源不存在异常,并由全局异常处理器转换成 HTTP 404。
预约相关的逻辑稍微复杂一点,需要先检查活动是否存在,再做重复手机号拦截,最后保存预约记录。同时,返回给前端的手机号要做脱敏处理。
这里提供一个简化接口:
package com.example.kisstell.service; import com.example.kisstell.dto.BookingCreateRequest; import com.example.kisstell.entity.Booking; import com.example.kisstell.repository.BookingRepository; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import java.time.LocalDateTime; @Service public class BookingService { private final BookingRepository bookingRepository; public BookingService(BookingRepository bookingRepository) { this.bookingRepository = bookingRepository; } public Booking create(BookingCreateRequest request) { Booking booking = new Booking(); booking.setActivityCode(request.getActivityCode()); booking.setGuestName(request.getGuestName()); booking.setPhone(request.getPhone()); booking.setTicketCount(request.getTicketCount() == null ? 1 : request.getTicketCount()); booking.setRemark(request.getRemark()); booking.setStatus(0); booking.setCreateTime(LocalDateTime.now()); try { return bookingRepository.save(booking); } catch (DataIntegrityViolationException e) { throw new RuntimeException("该手机号已预约过本场活动"); } } }5.7 Controller 对外接口
PublicController提供三个接口:
package com.example.kisstell.controller; import com.example.kisstell.common.ApiResult; import com.example.kisstell.dto.BookingCreateRequest; import com.example.kisstell.entity.Activity; import com.example.kisstell.entity.Booking; import com.example.kisstell.entity.Guest; import com.example.kisstell.service.ActivityQueryService; import com.example.kisstell.service.BookingService; import jakarta.validation.Valid; import org.springframework.web.bind.annotation.*; import java.util.List; @RestController @RequestMapping("/api") public class PublicController { private final ActivityQueryService activityQueryService; private final BookingService bookingService; public PublicController(ActivityQueryService activityQueryService, BookingService bookingService) { this.activityQueryService = activityQueryService; this.bookingService = bookingService; } @GetMapping("/activities/{activityCode}") public ApiResult<Activity> activity(@PathVariable String activityCode) { return ApiResult.ok(activityQueryService.getActivityByCode(activityCode)); } @GetMapping("/activities/{activityCode}/guests") public ApiResult<List<Guest>> guests(@PathVariable String activityCode) { return ApiResult.ok(activityQueryService.listGuests(activityCode)); } @PostMapping("/bookings") public ApiResult<Booking> book(@Valid @RequestBody BookingCreateRequest request) { return ApiResult.ok(bookingService.create(request)); } }如果前端和后端属于不同域名,还需要配置跨域。开发阶段最简单的方法是使用代理,但如果你选择直接通过 IP 访问后端,则需要添加跨域配置:
package com.example.kisstell.config; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/api/**") .allowedOrigins("http://localhost:5173") .allowedMethods("GET", "POST", "OPTIONS") .allowedHeaders("*"); } }注意生产环境不要用星号允许所有来源,更不要把后台管理接口和公开接口放在同一个安全级别。
5.8 参数校验
BookingCreateRequest可以对手机号格式做前置校验,而不是等数据入库后再报错:
package com.example.kisstell.dto; import jakarta.validation.constraints.*; public class BookingCreateRequest { @NotBlank(message = "活动编号不能为空") private String activityCode; @NotBlank(message = "请填写称呼") private String guestName; @NotBlank(message = "手机号不能为空") @Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确") private String phone; @Min(value = 1, message = "至少预约1人") @Max(value = 5, message = "单次最多预约5人") private Integer ticketCount; @Size(max = 200, message = "备注不能超过200字") private String remark; // getter 和 setter 省略 }这种校验逻辑在正式项目中建议一直保留在 Service 层或独立领域校验里,而不完全依赖前端不可信输入。
5.9 后端接口总览
启动后可访问的接口如下:
| 方法 | 路径 | 作用 |
|---|---|---|
| GET | /api/activities/kdc-26.8.8 | 获取活动基本信息 |
| GET | /api/activities/kdc-26.8.8/guests | 获取嘉宾列表 |
| GET | /api/activities/kdc-26.8.8/schedules | 获取活动流程,可继续补充 |
| POST | /api/bookings | 提交预约 |
在写正式项目时,建议再增加查询预约和取消预约的接口。取消预约并不是直接删除数据库,而是把状态改成已取消,便于后续统计实际到场率。
6. 前端页面实现
前端使用 Vue 3 + Vite 编写。如果你已经做好了后端接口,前端的工作就是把后端数据渲染成用户能看懂的页面。
6.1 Vite 项目初始化
在frontend目录下创建package.json:
{ "name": "kiss-n-tell-show-frontend", "private": true, "version": "1.0.0", "scripts": { "dev": "vite", "build": "vite build", "preview": "vite preview" }, "dependencies": { "axios": "^1.6.0", "vue": "^3.4.0", "vue-router": "^4.3.0" }, "devDependencies": { "@vitejs/plugin-vue": "^5.0.0", "vite": "^5.0.0" } }执行:
npm install如果网络下载较慢,可以使用 npm 镜像,但不要随意安装来历不明的第三方包。
6.2 Vite 代理配置
开发环境直接请求后端会碰到跨域问题,最简单的办法是在vite.config.js中配置代理。
import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' export default defineConfig({ plugins: [vue()], server: { host: 'localhost', port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } })这样前端请求/api/activities/kdc-26.8.8时,Vite 会把请求转发到http://localhost:8080/api/activities/kdc-26.8.8。
6.3 首页活动展示
首页是整个系统的门面。这里用一个组合式 API 实现数据加载,代码结构如下:
<template> <div v-if="loading">活动加载中...</div> <div v-else-if="error">{{ error }}</div> <div v-else class="activity-page"> <h1>{{ activity.title }}</h1> <p class="subtitle">{{ activity.subTitle }}</p> <p>时间:{{ formatTime(activity.startTime) }}</p> <p>地点:{{ activity.address }}</p> <h2>嘉宾阵容</h2> <div class="guest-list"> <div v-for="guest in guests" :key="guest.id" class="guest-card"> <h3>{{ guest.name }}</h3> <span>{{ guest.role }}</span> <p>{{ guest.intro }}</p> </div> </div> <h2>活动流程</h2> <ul> <li v-for="item in schedules" :key="item.id"> {{ item.startTime }} - {{ item.itemName }} </li> </ul> <form @submit.prevent="submitBooking"> <input v-model="form.guestName" placeholder="怎么称呼你" required /> <input v-model="form.phone" placeholder="手机号" required /> <input v-model.number="form.ticketCount" type="number" min="1" max="5" /> <textarea v-model="form.remark" placeholder="备注"></textarea> <button type="submit" :disabled="submitting">提交预约</button> </form> </div> </template>对应的脚本部分:
<script setup> import { ref, onMounted } from 'vue' import axios from 'axios' const activityCode = 'kdc-26.8.8' const loading = ref(true) const error = ref('') const activity = ref({}) const guests = ref([]) const schedules = ref([]) const submitting = ref(false) const form = ref({ guestName: '', phone: '', ticketCount: 1, remark: '' }) async function loadData() { try { const activityResp = await axios.get(`/api/activities/${activityCode}`) activity.value = activityResp.data.data const guestsResp = await axios.get(`/api/activities/${activityCode}/guests`) guests.value = guestsResp.data.data loading.value = false } catch (e) { error.value = '活动信息加载失败' loading.value = false } } async function submitBooking() { submitting.value = true try { await axios.post('/api/bookings', { activityCode, ...form.value }) alert('预约成功') } catch (e) { alert(e.response?.data?.message || '预约失败') } finally { submitting.value = false } } function formatTime(timeStr) { if (!timeStr) return '' return timeStr.replace('T', ' ').substring(0, 16) } onMounted(() => { loadData() }) </script>这里的activityCode相当于整个页面的业务主键。以后要做第二期活动,只需要新增一个活动页面或把activityCode替换为kdc-27.1.1,不需要对系统做大型修改。
前端还应该增加一点基本样式,但这不是核心逻辑,可以按自己的产品风格来设计。需要注意的是,不要把手机号等预约数据直接输入到 JavaScript 日志中,避免隐私泄露。
7. 运行与验证
7.1 启动 MySQL
先确保本机 MySQL 已启动,然后执行:
mysql -uroot -p < init.sql执行成功后再启动后端:
cd backend mvn spring-boot:run如果看到类似下面的日志,说明后端启动成功:
Tomcat started on port 8080 Started KisstellApplication in 2.1 seconds7.2 启动前端
打开一个终端:
cd frontend npm install npm run dev浏览器访问http://localhost:5173,应该能看到活动标题、嘉宾列表和预约表单。
7.3 联调测试预约接口
先用 curl 做一个接口验证:
curl -X POST http://localhost:8080/api/bookings \ -H "Content-Type: application/json" \ -d '{"activityCode":"kdc-26.8.8","guestName":"测试用户","phone":"13800138000","ticketCount":1,"remark":"测试预约"}'预期返回:
{ "code": 0, "message": "success", "data": { "id": 1, "activityCode": "kdc-26.8.8", "guestName": "测试用户", "phone": "13800138000", "ticketCount": 1, "status": 0, "createTime": "2026-07-01T12:00:00" } }再执行一次同样请求,会触发唯一索引,返回错误提示:
{ "code": -1, "message": "该手机号已预约过本场活动", "data": null }8. 常见问题与排查思路
我把后台和前端联调时最容易遇到的问题整理成下表:
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 启动类报错“Unable to find class” | JDK 版本过低 | 使用 JDK 17+ |
| Maven 依赖下载慢 | 网络受限 | 使用国内 Maven 镜像 |
| MySQL 连接失败 | 密码错误或库不存在 | 检查 application.properties |
| 访问接口报 404 | Controller 包扫描路径不对 | 将启动类放在根包下 |
| 前端访问接口跨域 | 缺少 CORS 配置或代理失效 | 推荐使用 Vite proxy 方案 |
| 同一手机号重复提交 | 数据库唯一索引没有生效 | 检查表结构是否执行成功 |
| 日期显示 T 字符串 | 后端 LocalDateTime 默认格式化 | 前端做格式化处理 |
| 页面有数据但嘉宾展示为空 | 嘉宾表 activity_code 不一致 | 确认前后端使用同一个活动编号 |
| npm run dev 报 Node 版本不支持 | Node 版本低于 18 | 升级 Node.js |
排查时按“网络层 → 数据层 → 代码层”的顺序来。先看浏览器 Network 里的请求状态码,如果请求是 500,只看前端控制台是不够的,必须去后端日志中找异常堆栈。
9. 工程化建议与生产环境注意事项
9.1 配置分层与隔离
开发和生产的数据库连接串、密码、日志级别不应该完全一样。建议通过spring.profiles.active=dev/prod来区分:
# application-prod.properties spring.datasource.password=${DB_PASSWORD}密码不要硬编码在项目中,而是从环境变量或配置中心读取。对小型项目而言,最节省成本的方式是服务器环境变量。
9.2 接口安全
公开接口最容易忽略以下安全问题:
- 预约接口没有限流,导致恶意刷票。
- 管理后台没有认证,任何人可访问。
- 收集手机号但页面没有隐私说明。
- 返回数据未脱敏,后台查看预约名单直接显示完整手机号。
- 日志包含手机号,存在泄露风险。
正式上线时,至少要给管理接口增加登录认证,给公开接口增加简单频控。如果使用了 Spring Security,不要把静态资源和后端 API 放在同一套全放行规则中。
9.3 数据备份和回滚意识
本次项目虽然简单,但是属于生产活动时也会储存用户真实提交的报名数据。上线前要约定好:
- 数据库每天备份一次。
- 活动开始后,不要随意删除预约记录。
- 如果修改嘉宾资料,要记录更新时间。
- 在删除或导出手机号前,评估是否有必要保留。
我见过一些运营同学为了“清掉测试数据”直接执行全表 DELETE,结果误删正式报名的案例。正确做法是先 SELECT 确认数据量,再在备份库中验证删除 SQL,最后按活动编码删除。
9.4 活动合规提醒
如果这个活动是真实线下活动,尤其是售票或营业性演出,运营方需要自行了解当地活动审批和内容安全管理要求。技术系统只负责信息展示和预约,并不能代替活动本身的报批义务。
从技术侧来看,系统若面向公众收集手机号,必须避免过度收集,建议在表单中明确说明用途,并在页面上提供隐私政策入口或活动主办方联系方式。对于公开活动页面,中国大陆服务器部署的网站仍需按平台要求完成 ICP 备案,这里不详细展开,但正式上线前必须确认。
9.5 如何优雅地扩展成多期活动
这套系统最大的