最近在开发一个活动管理系统的过程中,遇到了一个典型需求:如何高效、结构化地存储和展示一场大型活动(例如演唱会、庆典)的详细信息,并实现灵活的前端渲染与后台管理。这类需求的核心在于数据模型的设计。本文将以一个虚构的“六巡首场烟花 One Spark”高雄演唱会活动为例,手把手带你从零设计一套完整的活动数据模型,并基于 Spring Boot + MyBatis-Plus 实现后端 API,最后使用 Vue3 构建一个简单的前端管理页面。通过这个实战项目,你将掌握复杂业务对象的数据建模、前后端分离开发以及基础的管理功能实现。
无论你是正在学习全栈开发的学生,还是需要快速为业务搭建活动管理模块的开发者,这套从数据库设计到接口联调的完整流程都能直接复用。
1. 活动数据模型分析与设计
在设计任何系统之前,首先要对业务进行抽象。一场大型活动通常包含哪些信息?我们可以将其拆解为核心信息、时间地点、票务、阵容、媒体素材等多个维度。
1.1 核心实体分析
以“六巡首场烟花 One Spark 高雄演唱会”为例,我们可以提炼出以下关键实体及其属性:
活动(Activity):最核心的实体,承载活动的基本信息。
- 基础信息:活动唯一标识(ID)、标题、副标题、描述。
- 状态与分类:活动状态(如:预售、售票中、已结束)、活动类型(演唱会、音乐节、话剧等)。
- 时间信息:活动开始时间、活动结束时间。
- 组织方:主办方、承办方。
场次(Session):一个活动可能有多场(例如巡演的不同城市站)。场次是活动的具体发生实例。
- 关联信息:所属活动ID。
- 具体信息:场次名称、场次日期、具体开始时间、具体结束时间。
- 地点信息:场馆名称、场馆地址(省、市、区、详细地址)、经纬度(用于地图展示)。
票务(Ticket):与场次强关联,定义票价和库存。
- 关联信息:所属场次ID。
- 票档信息:票档名称(如:VIP区、看台区、早鸟票)、价格、货币单位。
- 库存与限制:总库存、已售数量、每人限购数量。
- 销售状态:票档状态(启用、停售)。
1.2 数据库表结构设计
基于以上分析,我们设计三张核心数据库表。这里以 MySQL 为例。
表1:activity(活动主表)
CREATE TABLE `activity` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID', `title` varchar(200) NOT NULL COMMENT '活动标题,如:六巡首场烟花 One Spark', `subtitle` varchar(500) DEFAULT NULL COMMENT '活动副标题', `description` text COMMENT '活动详细描述', `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '活动状态:0-待发布,1-预售,2-售票中,3-已结束,4-已取消', `type` varchar(50) DEFAULT NULL COMMENT '活动类型:concert, festival, drama 等', `start_time` datetime DEFAULT NULL COMMENT '活动开始时间(整体)', `end_time` datetime DEFAULT NULL COMMENT '活动结束时间(整体)', `organizer` varchar(200) DEFAULT NULL COMMENT '主办方', `contractor` varchar(200) DEFAULT NULL COMMENT '承办方', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (`id`), KEY `idx_status` (`status`), KEY `idx_time` (`start_time`, `end_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='活动主表';表2:activity_session(活动场次表)
CREATE TABLE `activity_session` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID', `activity_id` bigint(20) NOT NULL COMMENT '关联活动ID', `session_name` varchar(200) NOT NULL COMMENT '场次名称,如:2025-11-22 高雄首场', `session_date` date NOT NULL COMMENT '场次日期', `start_time` time NOT NULL COMMENT '具体开始时间', `end_time` time DEFAULT NULL COMMENT '具体结束时间', `venue_name` varchar(200) NOT NULL COMMENT '场馆名称', `venue_province` varchar(50) DEFAULT NULL COMMENT '场馆所在省', `venue_city` varchar(50) DEFAULT NULL COMMENT '场馆所在市', `venue_district` varchar(50) DEFAULT NULL COMMENT '场馆所在区', `venue_address` varchar(500) DEFAULT NULL COMMENT '场馆详细地址', `venue_longitude` decimal(10,7) DEFAULT NULL COMMENT '场馆经度', `venue_latitude` decimal(10,7) DEFAULT NULL COMMENT '场馆纬度', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (`id`), KEY `idx_activity_id` (`activity_id`), KEY `idx_session_date` (`session_date`), CONSTRAINT `fk_session_activity` FOREIGN KEY (`activity_id`) REFERENCES `activity` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='活动场次表';表3:session_ticket(场次票档表)
CREATE TABLE `session_ticket` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID', `session_id` bigint(20) NOT NULL COMMENT '关联场次ID', `ticket_name` varchar(100) NOT NULL COMMENT '票档名称,如:VIP尊享席', `price` decimal(10,2) NOT NULL COMMENT '价格', `currency` varchar(10) NOT NULL DEFAULT 'CNY' COMMENT '货币单位', `total_stock` int(11) NOT NULL COMMENT '总库存', `sold_stock` int(11) NOT NULL DEFAULT '0' COMMENT '已售库存', `purchase_limit` int(11) DEFAULT NULL COMMENT '每人限购数量', `status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '状态:0-停售,1-启用', `sort_order` int(11) DEFAULT '0' COMMENT '排序字段', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (`id`), KEY `idx_session_id` (`session_id`), KEY `idx_status` (`status`), CONSTRAINT `fk_ticket_session` FOREIGN KEY (`session_id`) REFERENCES `activity_session` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='场次票档表';设计要点说明:
- 外键与级联:
activity_session和session_ticket表通过外键关联到上级表,并设置了ON DELETE CASCADE。这意味着删除一个活动,其所有场次和票档会被自动删除,确保数据一致性,但生产环境需谨慎评估是否使用级联删除。 - 索引优化:为常用的查询字段(如
status,activity_id,session_date)添加了索引,提升查询效率。 - 字段冗余:
activity_session表中冗余了venue_province、venue_city等字段,避免了关联地址表,简化查询,是空间换时间的常见做法。 - 数据精度:价格使用
DECIMAL(10,2),经纬度使用DECIMAL(10,7),确保计算和定位的精度。
2. 后端开发:Spring Boot API 实现
接下来,我们使用 Spring Boot 快速搭建后端服务,提供对上述数据模型的增删改查(CRUD)API。
2.1 环境准备与项目初始化
- JDK: 17 或以上
- 构建工具: Maven 3.6+
- IDE: IntelliJ IDEA 或 Eclipse
- 数据库: MySQL 8.0
使用 Spring Initializr 或 IDE 创建 Spring Boot 项目,选择以下依赖:
- Spring Web
- MyBatis Framework
- MySQL Driver
- Lombok (简化代码)
生成的pom.xml关键依赖如下:
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>3.0.3</version> </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> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> <!-- 添加 MyBatis-Plus 简化CRUD --> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.5</version> </dependency> </dependencies>2.2 数据层实现:实体类与 Mapper
首先,创建与数据库表对应的实体类。这里使用 Lombok 注解减少 getter/setter 代码。
文件路径:src/main/java/com/example/demo/entity/Activity.java
package com.example.demo.entity; import com.baomidou.mybatisplus.annotation.*; import lombok.Data; import java.time.LocalDateTime; import java.util.Date; @Data @TableName("activity") public class Activity { @TableId(type = IdType.AUTO) private Long id; private String title; private String subtitle; private String description; private Integer status; // 状态枚举值 private String type; private Date startTime; private Date endTime; private String organizer; private String contractor; @TableField(fill = FieldFill.INSERT) private LocalDateTime createTime; @TableField(fill = FieldFill.INSERT_UPDATE) private LocalDateTime updateTime; }文件路径:src/main/java/com/example.demo/entity/ActivitySession.java
package com.example.demo.entity; import com.baomidou.mybatisplus.annotation.*; import lombok.Data; import java.math.BigDecimal; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; @Data @TableName("activity_session") public class ActivitySession { @TableId(type = IdType.AUTO) private Long id; private Long activityId; private String sessionName; private LocalDate sessionDate; private LocalTime startTime; private LocalTime endTime; private String venueName; private String venueProvince; private String venueCity; private String venueDistrict; private String venueAddress; private BigDecimal venueLongitude; private BigDecimal venueLatitude; @TableField(fill = FieldFill.INSERT) private LocalDateTime createTime; @TableField(fill = FieldFill.INSERT_UPDATE) private LocalDateTime updateTime; }SessionTicket实体类结构类似,此处省略。
接下来,创建 Mapper 接口。得益于 MyBatis-Plus,我们只需继承BaseMapper即可获得基础的 CRUD 方法。
文件路径:src/main/java/com/example/demo/mapper/ActivityMapper.java
package com.example.demo.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.example.demo.entity.Activity; import org.apache.ibatis.annotations.Mapper; @Mapper public interface ActivityMapper extends BaseMapper<Activity> { // 可以在此定义自定义的复杂SQL查询方法 // 例如:List<Activity> selectByStatusAndTimeRange(@Param("status") Integer status, ...); }ActivitySessionMapper和SessionTicketMapper的创建方式完全相同。
2.3 业务层与服务层实现
我们创建 Service 接口及其实现类来封装业务逻辑。
文件路径:src/main/java/com/example/demo/service/ActivityService.java
package com.example.demo.service; import com.baomidou.mybatisplus.extension.service.IService; import com.example.demo.entity.Activity; import com.example.demo.vo.ActivityDetailVO; public interface ActivityService extends IService<Activity> { /** * 根据活动ID获取活动详情,包含其所有场次及票档信息 * @param id 活动ID * @return 活动详情视图对象 */ ActivityDetailVO getActivityDetailById(Long id); /** * 创建活动(包含场次和票档的级联创建) * @param activityDetailVO 活动详情数据 * @return 是否创建成功 */ boolean createActivityWithSessions(ActivityDetailVO activityDetailVO); }文件路径:src/main/java/com/example/demo/service/impl/ActivityServiceImpl.java
package com.example.demo.service.impl; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.example.demo.entity.Activity; import com.example.demo.entity.ActivitySession; import com.example.demo.entity.SessionTicket; import com.example.demo.mapper.ActivityMapper; import com.example.demo.mapper.ActivitySessionMapper; import com.example.demo.mapper.SessionTicketMapper; import com.example.demo.service.ActivityService; import com.example.demo.vo.ActivityDetailVO; import com.example.demo.vo.SessionVO; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.List; import java.util.stream.Collectors; @Service @RequiredArgsConstructor public class ActivityServiceImpl extends ServiceImpl<ActivityMapper, Activity> implements ActivityService { private final ActivitySessionMapper sessionMapper; private final SessionTicketMapper ticketMapper; @Override public ActivityDetailVO getActivityDetailById(Long id) { // 1. 查询活动主体信息 Activity activity = this.getById(id); if (activity == null) { return null; } ActivityDetailVO detailVO = new ActivityDetailVO(); // 使用 Bean 拷贝工具(如 MapStruct 或 Spring BeanUtils)将 activity 属性拷贝到 detailVO // 这里为简化,手动设置 detailVO.setId(activity.getId()); detailVO.setTitle(activity.getTitle()); // ... 拷贝其他属性 // 2. 查询该活动下的所有场次 List<ActivitySession> sessions = sessionMapper.selectList( new LambdaQueryWrapper<ActivitySession>().eq(ActivitySession::getActivityId, id) ); // 3. 为每个场次查询票档信息,并组装成 SessionVO List<SessionVO> sessionVOs = sessions.stream().map(session -> { SessionVO sessionVO = new SessionVO(); // 拷贝场次属性... sessionVO.setId(session.getId()); sessionVO.setSessionName(session.getSessionName()); // 查询该场次的票档 List<SessionTicket> tickets = ticketMapper.selectList( new LambdaQueryWrapper<SessionTicket>().eq(SessionTicket::getSessionId, session.getId()) ); sessionVO.setTickets(tickets); return sessionVO; }).collect(Collectors.toList()); detailVO.setSessions(sessionVOs); return detailVO; } @Override @Transactional(rollbackFor = Exception.class) // 开启事务,确保原子性 public boolean createActivityWithSessions(ActivityDetailVO activityDetailVO) { // 1. 保存活动主体 Activity activity = new Activity(); // 将 VO 属性拷贝到 entity ... this.save(activity); // 2. 保存场次和票档 List<SessionVO> sessions = activityDetailVO.getSessions(); if (sessions != null && !sessions.isEmpty()) { for (SessionVO sessionVO : sessions) { ActivitySession session = new ActivitySession(); session.setActivityId(activity.getId()); // 设置外键 // 拷贝其他属性... sessionMapper.insert(session); // 3. 保存该场次的票档 List<SessionTicket> tickets = sessionVO.getTickets(); if (tickets != null && !tickets.isEmpty()) { for (SessionTicket ticket : tickets) { ticket.setSessionId(session.getId()); // 设置外键 ticketMapper.insert(ticket); } } } } return true; } }关键点:
@Transactional:在创建活动的方法上添加事务注解,确保活动、场次、票档要么全部保存成功,要么全部回滚,保证数据一致性。LambdaQueryWrapper:MyBatis-Plus 提供的链式查询条件构造器,使代码更简洁。- VO (View Object):
ActivityDetailVO和SessionVO是专门用于接口传输的视图对象,它们聚合了多个实体(Activity, Session, Ticket)的数据,避免直接暴露数据库实体结构,也更符合前端需求。
2.4 控制层:RESTful API 设计
最后,创建 Controller 暴露 HTTP 接口。
文件路径:src/main/java/com/example/demo/controller/ActivityController.java
package com.example.demo.controller; import com.example.demo.common.Result; import com.example.demo.service.ActivityService; import com.example.demo.vo.ActivityDetailVO; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/api/activity") @RequiredArgsConstructor public class ActivityController { private final ActivityService activityService; @GetMapping("/{id}") public Result<ActivityDetailVO> getDetail(@PathVariable Long id) { ActivityDetailVO detail = activityService.getActivityDetailById(id); if (detail == null) { return Result.error(404, "活动不存在"); } return Result.success(detail); } @PostMapping public Result<Boolean> create(@RequestBody ActivityDetailVO activityDetailVO) { // 实际项目中,需要添加参数校验,例如使用 @Valid boolean success = activityService.createActivityWithSessions(activityDetailVO); return success ? Result.success(true) : Result.error(500, "创建活动失败"); } // 其他接口:分页查询活动列表、更新活动、删除活动等... }文件路径:src/main/java/com/example/demo/common/Result.java(统一响应体)
package com.example.demo.common; import lombok.Data; @Data public class Result<T> { private Integer code; private String message; private T data; public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.setCode(200); result.setMessage("success"); result.setData(data); return result; } public static <T> Result<T> error(Integer code, String message) { Result<T> result = new Result<>(); result.setCode(code); result.setMessage(message); return result; } }2.5 应用配置
文件路径:src/main/resources/application.yml
spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/activity_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: your_username password: your_password mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 控制台打印SQL,生产环境关闭 global-config: db-config: logic-delete-field: deleted # 全局逻辑删除字段(如果表中有此字段) logic-delete-value: 1 logic-not-delete-value: 0 server: port: 8080启动 Spring Boot 应用后,即可通过http://localhost:8080/api/activity/{id}等接口访问服务。
3. 前端开发:Vue3 管理页面示例
后端 API 准备好后,我们使用 Vue3 和 Element Plus 构建一个简单的前端管理页面,用于创建和查看活动。
3.1 环境准备与项目初始化
使用 Vite 快速创建 Vue3 项目。
npm create vue@latest activity-manager-frontend # 按照提示选择 Vue Router, Pinia, Element Plus 等 cd activity-manager-frontend npm install npm run dev3.2 活动创建页面组件
文件路径:src/views/ActivityCreate.vue
<template> <div class="activity-create"> <h2>创建新活动</h2> <el-form :model="activityForm" :rules="rules" ref="formRef" label-width="120px"> <!-- 活动基础信息 --> <el-form-item label="活动标题" prop="title"> <el-input v-model="activityForm.title" placeholder="例如:六巡首场烟花 One Spark"></el-input> </el-form-item> <el-form-item label="活动副标题" prop="subtitle"> <el-input v-model="activityForm.subtitle"></el-input> </el-form-item> <el-form-item label="活动状态" prop="status"> <el-select v-model="activityForm.status" placeholder="请选择"> <el-option label="待发布" :value="0"></el-option> <el-option label="预售" :value="1"></el-option> <el-option label="售票中" :value="2"></el-option> </el-select> </el-form-item> <el-form-item label="活动时间范围" prop="timeRange"> <el-date-picker v-model="activityForm.timeRange" type="daterange" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" value-format="YYYY-MM-DD" /> </el-form-item> <!-- 场次信息,支持动态添加 --> <div v-for="(session, index) in activityForm.sessions" :key="index" class="session-block"> <h3>场次 {{ index + 1 }}</h3> <el-form-item :label="`场次名称`" :prop="`sessions[${index}].sessionName`" :rules="{ required: true, message: '请输入场次名称', trigger: 'blur' }"> <el-input v-model="session.sessionName" placeholder="例如:2025-11-22 高雄首场"></el-input> </el-form-item> <el-form-item label="场次日期" :prop="`sessions[${index}].sessionDate`" :rules="{ required: true, message: '请选择场次日期', trigger: 'change' }"> <el-date-picker v-model="session.sessionDate" type="date" placeholder="选择日期" value-format="YYYY-MM-DD"/> </el-form-item> <el-form-item label="场馆信息"> <el-input v-model="session.venueName" placeholder="场馆名称" style="width: 48%; margin-right: 2%;"></el-input> <el-input v-model="session.venueCity" placeholder="所在城市" style="width: 48%;"></el-input> <el-input v-model="session.venueAddress" placeholder="详细地址" style="margin-top: 10px;"></el-input> </el-form-item> <!-- 票档信息,支持动态添加 --> <h4>票档设置</h4> <div v-for="(ticket, tIndex) in session.tickets" :key="tIndex" class="ticket-row"> <el-input v-model="ticket.ticketName" placeholder="票档名" style="width: 30%;"></el-input> <el-input-number v-model="ticket.price" :min="0" :precision="2" placeholder="价格" style="width: 20%; margin-left: 10px;"></el-input-number> <el-input-number v-model="ticket.totalStock" :min="1" placeholder="库存" style="width: 20%; margin-left: 10px;"></el-input-number> <el-button @click="removeTicket(index, tIndex)" type="danger" size="small" style="margin-left: 10px;">删除</el-button> </div> <el-button @click="addTicket(index)" type="primary" size="small" style="margin-top: 10px;">添加票档</el-button> <el-button @click="removeSession(index)" type="danger" size="small" style="margin-top: 10px; margin-left: 10px;" v-if="activityForm.sessions.length > 1">删除场次</el-button> </div> <el-button @click="addSession" type="primary" style="margin-bottom: 20px;">添加场次</el-button> <el-form-item> <el-button type="primary" @click="submitForm">立即创建</el-button> <el-button @click="resetForm">重置</el-button> </el-form-item> </el-form> </div> </template> <script setup> import { ref, reactive } from 'vue' import { ElMessage } from 'element-plus' import axios from 'axios' const formRef = ref() const activityForm = reactive({ title: '', subtitle: '', status: 2, timeRange: [], sessions: [ { sessionName: '', sessionDate: '', venueName: '', venueCity: '高雄市', venueAddress: '', tickets: [ { ticketName: '普通票', price: 580, totalStock: 1000, currency: 'TWD' }, { ticketName: 'VIP票', price: 1280, totalStock: 200, currency: 'TWD' } ] } ] }) const rules = reactive({ title: [{ required: true, message: '请输入活动标题', trigger: 'blur' }], status: [{ required: true, message: '请选择活动状态', trigger: 'change' }], timeRange: [{ type: 'array', required: true, message: '请选择活动时间范围', trigger: 'change' }] }) const addSession = () => { activityForm.sessions.push({ sessionName: '', sessionDate: '', venueName: '', venueCity: '高雄市', venueAddress: '', tickets: [{ ticketName: '', price: 0, totalStock: 0, currency: 'TWD' }] }) } const removeSession = (index) => { activityForm.sessions.splice(index, 1) } const addTicket = (sessionIndex) => { activityForm.sessions[sessionIndex].tickets.push({ ticketName: '', price: 0, totalStock: 0, currency: 'TWD' }) } const removeTicket = (sessionIndex, ticketIndex) => { activityForm.sessions[sessionIndex].tickets.splice(ticketIndex, 1) } const submitForm = async () => { // 1. 表单验证 await formRef.value.validate((valid) => { if (!valid) { ElMessage.error('请检查表单填写是否正确') return } }) // 2. 数据转换,将前端表单数据转换为后端需要的 ActivityDetailVO 结构 const payload = { title: activityForm.title, subtitle: activityForm.subtitle, status: activityForm.status, startTime: activityForm.timeRange[0], endTime: activityForm.timeRange[1], sessions: activityForm.sessions.map(session => ({ sessionName: session.sessionName, sessionDate: session.sessionDate, venueName: session.venueName, venueCity: session.venueCity, venueAddress: session.venueAddress, tickets: session.tickets })) } // 3. 调用后端API try { const response = await axios.post('http://localhost:8080/api/activity', payload) if (response.data.code === 200) { ElMessage.success('活动创建成功!') resetForm() } else { ElMessage.error(`创建失败:${response.data.message}`) } } catch (error) { console.error('API调用失败:', error) ElMessage.error('网络请求失败,请检查后端服务') } } const resetForm = () => { formRef.value.resetFields() // 手动重置 sessions 数组 activityForm.sessions = [{ sessionName: '', sessionDate: '', venueName: '', venueCity: '高雄市', venueAddress: '', tickets: [{ ticketName: '', price: 0, totalStock: 0, currency: 'TWD' }] }] } </script> <style scoped> .session-block { border: 1px solid #e4e7ed; padding: 20px; margin-bottom: 20px; border-radius: 4px; } .ticket-row { display: flex; align-items: center; margin-bottom: 10px; } </style>这个组件实现了动态表单,可以灵活地添加/删除场次和每个场次下的票档,并将数据组装成后端需要的 JSON 格式进行提交。
4. 常见问题与排查思路
在实际开发和联调中,你可能会遇到以下典型问题:
| 问题现象 | 可能原因 | 排查步骤与解决方案 |
|---|---|---|
| 后端启动失败,数据库连接错误 | 1. 数据库服务未启动。 2. application.yml中数据库配置错误(用户名、密码、URL)。3. MySQL 驱动版本与数据库版本不匹配。 | 1. 检查 MySQL 服务状态 (systemctl status mysql或查看服务列表)。2. 仔细核对 application.yml中的url,username,password。3. 确认 pom.xml中的mysql-connector-j版本与本地 MySQL 版本兼容。 |
| 前端调用 API 报跨域错误 (CORS) | 浏览器同源策略阻止了前端(localhost:5173)请求后端(localhost:8080)。 | 后端解决:在 Spring Boot 的@Configuration类中添加全局 CORS 配置。前端解决(开发阶段):在 Vite 配置中设置代理 ( vite.config.js)。 |
| 插入数据时报外键约束失败 | 1. 插入activity_session时,提供的activity_id在activity表中不存在。2. 插入顺序错误,先插入了子表记录。 | 1. 检查插入数据中的外键 ID 值是否正确,确保父记录已存在。 2. 确保业务逻辑的保存顺序:先保存 Activity,再保存 Session,最后保存 Ticket。 |
前端表单提交后,后端接收到的日期字段为null | 前端提交的日期字符串格式与后端@RequestBody映射的 Java 对象期望的格式不一致。 | 1. 在前端确保使用value-format统一日期格式(如YYYY-MM-DD)。2. 在后端实体类的日期字段上使用 @JsonFormat注解指定格式,或配置全局的 Jackson 日期格式化。 |
| MyBatis-Plus 查询不到数据 | 1. 实体类属性名与数据库字段名未正确映射(下划线转驼峰默认开启)。 2. 表名或字段名有大小写问题。 | 1. 检查实体类@TableName和@TableField注解是否正确。2. 在 application.yml中配置mybatis-plus.configuration.map-underscore-to-camel-case: true(默认已开启)。3. 开启 SQL 日志,查看实际执行的 SQL 语句。 |
事务@Transactional未回滚 | 1. 异常未被抛出到方法外(被 catch 住了)。 2. 异常类型不是 RuntimeException或Error。 | 1. 确保异常在方法内抛出,或在@Transactional注解中指定rollbackFor = Exception.class。2. 检查方法是否是 public的,Spring AOP 代理对非 public 方法无效。 |
5. 最佳实践与工程建议
API 设计规范:
- RESTful 风格:使用合适的 HTTP 方法(GET/POST/PUT/DELETE)和资源路径。
- 版本控制:在 API 路径中加入版本号,如
/api/v1/activity,为后续迭代留有余地。 - 统一响应体:如本文的
Result<T>类,统一返回结构,包含code,message,data。 - 分页查询:列表接口务必支持分页,使用 MyBatis-Plus 的
Page对象非常方便。
数据验证与安全:
- 后端验证:在 Controller 的入参上使用
@Valid注解并结合校验注解(如@NotBlank,@Min),或在 Service 层进行业务逻辑校验。 - 防 SQL 注入:坚持使用 MyBatis-Plus 的条件构造器或
#{}预编译占位符,绝对禁止在 SQL 中拼接用户输入。 - 敏感信息脱敏:返回给前端的用户手机号、邮箱等需进行脱敏处理。
- 后端验证:在 Controller 的入参上使用
性能与可维护性:
- 索引优化:根据查询模式(如按状态、按时间范围查活动)合理设计数据库索引。
- 逻辑删除:使用
deleted字段标记删除,而非物理删除,便于数据恢复和审计。MyBatis-Plus 支持全局配置。 - DTO/VO 分离:严格区分数据库实体(Entity)、数据传输对象(DTO)和视图对象(VO)。Entity 对应表结构,DTO 用于接收参数,VO 用于返回前端。这提高了代码的清晰度和安全性。
- 服务拆分:当业务复杂后,考虑将活动服务、票务服务、用户服务等拆分为独立的微服务。
前端工程化:
- API 封装:将所有的
axios请求封装在统一的src/api/目录下,便于管理和维护。 - 状态管理:对于跨组件共享的数据(如用户登录状态),使用 Pinia 进行集中管理。
- 组件化:将场次信息块、票档信息行等可复用的 UI 部分抽离成独立的子组件。
- API 封装:将所有的
通过以上步骤,我们完成了一个从数据库设计、后端 API 开发到前端页面实现的完整活动管理模块原型。这个原型具备了核心的 CRUD 功能,并考虑了数据一致性和基本的用户体验。你可以在此基础上,继续扩展用户管理、订单系统、支付集成、座位图选座等更复杂的功能。