需求分析及技术选型
核心业务场景
- 参会者注册/登录:用户可以创建新账户或使用现有账户登录系统。
- 会议选择与加入:用户可以根据需求选择会议或使用邀请码加入特邀会议。
- 个人信息填写:参会者需提供参会时间、住宿要求等信息。
- 虚拟缴费:参会者通过系统完成会议费用的支付。
- 会议信息管理:组织者填写会议信息,生成新会议,并管理特邀码。
- 会议管理:组织者对会议进行管理,包括签到和入住。
- 审核管理:管理员审核会议信息,管理注册人员。
系统角色
- 参会者:注册账户并参与会议的用户。
- 会务组织者:负责创建和管理会议的用户。
- 管理员:负责系统维护和用户管理的高级用户。
流程
- 用户注册/登录流程:用户填写信息注册账户或使用账户信息登录。
- 会议加入流程:参会者选择会议并填写相关信息。
- 会议创建流程:组织者填写会议信息,生成会议,并提供特邀码。
- 会议管理流程:组织者进行会议的签到和入住管理。
- 审核流程:管理员审核会议信息和注册人员。
核心业务对象
- 用户账户:存储用户信息,包括个人资料和登录凭证。
- 会议:包含会议信息,如名称、时间、地点、特邀码等。
- 参会信息:记录参会者的参会时间、住宿要求等。
- 支付记录:存储参会者的缴费信息。
- 审核状态:记录会议和用户的审核状态。
场景验证
为了验证这些场景,设计以下测试用例:
- 用户注册/登录测试:验证用户能否成功注册和登录,包括密码找回和账户锁定机制。
- 会议选择与加入测试:验证用户能否根据需求选择会议,以及特邀码的使用是否正确。
- 个人信息填写测试:验证参会者能否正确填写并提交参会信息。
- 虚拟缴费测试:验证缴费流程是否顺畅,包括支付成功和失败的情况。
- 会议信息管理测试:验证组织者能否正确创建和管理会议信息。
- 会议管理测试:验证组织者能否进行有效的签到和入住管理。
- 审核管理测试:验证管理员的审核流程是否符合预期,包括审核通过和拒绝的情况。
核心技术选型
选择 |
作用 |
说明 |
SpringBoot |
Web开发框架 |
|
Spring JPA Data |
ORM框架 |
|
JWT+Shiro |
鉴权框架 |
|
GitHub |
源代码管理 |
|
Intelij IDEA |
开发IDE |
|
MySQL |
数据库 |
|
Redis |
缓存数据库 |
|
Nginx |
代理 |
|
React+AntDesign |
前端开发组件 |
|
搭建项目
定义模型
实体说明 |
实体名称 |
备注 |
会议 |
Meet |
|
参与者 |
User |
|
参会记录 |
MeetRecord |
|
审核记录 |
AuditRecord |
|
支付记录 |
PayRecord |
|
签到记录 |
CheckInRecord |
|
业务规划
会议管理
操作 |
管理员 |
组织者 |
用户 |
筛选查询 |
✅ |
✅ |
✅ |
详情 |
✅ |
✅ |
✅ |
添加 |
✅ |
✅ |
❌ |
修改 |
✅ |
❌ |
❌ |
删除 |
✅ |
❌ |
❌ |
会议申请
操作 |
管理员 |
组织者 |
用户 |
筛选查询 |
✅ |
✅ |
❌ |
详情 |
✅ |
✅ |
❌ |
添加 |
❌ |
✅ |
❌ |
修改 |
✅ |
✅ |
❌ |
删除 |
✅ |
❌ |
❌ |
参加记录
操作 |
管理员 |
组织者 |
用户 |
筛选查询 |
✅ |
✅ |
✅ |
详情 |
✅ |
✅ |
✅ |
添加 |
✅ |
✅ |
✅ |
修改 |
✅ |
❌ |
❌ |
删除 |
✅ |
❌ |
❌ |
支付记录
操作 |
管理员 |
组织者 |
用户 |
筛选查询 |
✅ |
✅ |
✅ |
详情 |
✅ |
✅ |
✅ |
添加 |
✅ |
✅ |
✅ |
修改 |
❌ |
❌ |
❌ |
删除 |
✅ |
❌ |
❌ |
用户信息
操作 |
用户 |
组织者 |
注册 |
✅ |
✅ |
登录 |
✅ |
✅ |
补全信息 |
✅ |
✅ |
修改个人信息 |
✅ |
✅ |
数据库设计
ER设计

数据字典

详细设计
User表
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89
| @Data @Entity @Table(name = "user") public class User {
@Id @GeneratedValue private BigInteger id;
@Size(min = 2, max = 40) private String userName; @Enumerated(EnumType.STRING) private UserRole role=UserRole.Organizer;
@Size(min = 5, max = 100) @Email @NotNull private String email;
private boolean emailConfirmed;
@JsonIgnore @Size(max = 100) private String passwordHash;
@JsonIgnore @Size(max = 60) private String passwordSalt;
@Pattern(regexp = "^[0-9]+$") private String phoneNumber;
private boolean phoneNumberConfirmed;
private boolean twoFactorEnabled;
private OffsetDateTime lockoutEnd;
private boolean lockoutEnabled;
private int accessFailedCount;
private int retryCount;
private OffsetDateTime lastLoginTime;
private String avatar;
@Column(nullable = false, updatable = false) private OffsetDateTime createdTime;
@Column(nullable = false) private OffsetDateTime updatedTime;
@Column(nullable = false) private boolean isDeleted;
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) @JsonIgnore private List<MeetRecord> meetRecords;
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) @JsonIgnore private List<PayRecord> payRecords;
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) @JsonIgnore private List<AuditRecord> auditRecords;
@PrePersist protected void onCreate() { createdTime = OffsetDateTime.now(); updatedTime = OffsetDateTime.now(); isDeleted = false; }
@PreUpdate protected void onUpdate() { updatedTime = OffsetDateTime.now(); } }
public enum UserRole { Normal, Organizer, Admin }
|
meet表
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113
| @Data @Entity @Table(name = "meet") public class Meet {
@Id @GeneratedValue private BigInteger id;
@Column(name = "password") @JsonIgnore private BigInteger password;
@Column(nullable = false, updatable = false,name = "created_time") private OffsetDateTime createdTime;
@Column(nullable = false,name = "updated_time") private OffsetDateTime updatedTime;
@Column(nullable = false,name="is_deleted") @JsonIgnore private boolean isDeleted;
@Column(nullable = false,name="is_audited") private boolean isAudited;
@Column(nullable = false) private boolean needCheckIn;
@Size(min = 2, max = 30) @NotBlank @Column(name = "name") private String name;
@Size(max = 200) @Column(name ="description") private String description;
@Enumerated(EnumType.STRING) @Column(name = "meet_type") private MeetType meetType;
@Size(max = 50) @NotBlank @Column(name = "place") private String place;
@Column(name = "cost") private BigDecimal cost;
@NotNull @Column(name = "max_nums") private BigDecimal maxNums;
@NotNull @Column(name="start_date") private LocalDate startDate;
@NotNull @Column(name="end_date") private LocalDate endDate;
@Column(name = "check_in_start_time") private OffsetDateTime checkInStartTime;
@Column(name = "check_in_end_time") private OffsetDateTime checkInEndTime;
@OneToMany(mappedBy = "meet", cascade = CascadeType.ALL, orphanRemoval = true) @JsonIgnore private List<MeetRecord> meetRecords;
@OneToMany(mappedBy = "meet", cascade = CascadeType.ALL, orphanRemoval = true) @JsonIgnore private List<PayRecord> payRecords;
@OneToMany(mappedBy = "meet", cascade = CascadeType.ALL, orphanRemoval = true) @JsonIgnore private List<CheckInRecord> CheckInRecords;
@OneToOne(mappedBy = "meet", cascade = CascadeType.ALL, orphanRemoval = true) @JsonIgnore private AuditRecord auditRecord;
public Meet() {
}
@PrePersist protected void onCreate() { createdTime = OffsetDateTime.now(); updatedTime = OffsetDateTime.now(); isAudited = false; needCheckIn = true; isDeleted = false; }
@PreUpdate protected void onUpdate() { updatedTime = OffsetDateTime.now(); this.checkInStartTime = this.startDate.atStartOfDay().atOffset(OffsetDateTime.now().getOffset()); this.checkInEndTime = this.endDate.atStartOfDay().atOffset(OffsetDateTime.now().getOffset()); }
}
public enum MeetType { BUSINESS, ENTERTAINMENT }
|
meetRecord表
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| @Data @Entity @Table(name = "meet_record") public class MeetRecord {
@Id @GeneratedValue private BigInteger id;
private boolean needHotel;
private boolean isDeleted;
@ManyToOne(targetEntity = Meet.class) @JoinColumn(name = "meet_id") private Meet meet;
@ManyToOne(targetEntity = User.class) @JoinColumn(name = "user_id") private User user;
}
|
payRecord表
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| @Data @Entity @Table(name = "pay_record") public class PayRecord {
@Id @GeneratedValue private BigInteger id;
@Column(nullable = false, updatable = false) private OffsetDateTime createdTime;
private boolean isDeleted = false;
@ManyToOne @JoinColumn(name = "meet_id") @JsonIgnore private Meet meet;
@ManyToOne @JoinColumn(name = "user_id") @JsonIgnore private User user;
@PrePersist protected void onCreate() { createdTime = OffsetDateTime.now(); } }
|
AuditRecord
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| @Data @Entity @Table(name = "audit_record") public class AuditRecord {
@Id @GeneratedValue private BigInteger id;
boolean isDeleted = false;
@ManyToOne(targetEntity = User.class) @JoinColumn(name = "user_id") private User user;
@OneToOne @JoinColumn(name = "meet_id") private Meet meet; }
|
CheckInRecord
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| @Data @Entity @Table(name = "check_in_record") public class CheckInRecord {
@Id @GeneratedValue private BigInteger id;
@ManyToOne @JoinColumn(name = "meet_id", nullable = false) private Meet meet;
@ManyToOne @JoinColumn(name = "user_id", nullable = false) private User user;
@Column(nullable = false) private OffsetDateTime checkInTime;
@PrePersist protected void onCreate() { checkInTime = OffsetDateTime.now(); } }
|
网站结构说明
整个系统采用前后端分离的开发模式,前端包含管理模块和会议模块,而后端划分为如下几个服务:
- 文件服务:处理用户上传的文件,并把文件上传到云存储服务器,
备份服务器,导出表单等文件
- 认证服务:登录验证,颁发JWT等
- 会议管理网站的后台服务:对会议的添加,加入等的管理
- 会议管理网站的前台服务:提供对会议信息的查看接口
项目结构说明
项目分为Common,Controller,Entity,service,DAO,DTO,Common中是一些公共的工具类,和基本配置;Controller就是提供接口;Entity是一些实体类;Service就是处理业务逻辑;DAO提供基本的操作表单的接口;DTO就是一些数据传输对象;
项目具体结构
类的功能简述
Common层
类名 |
说明 |
注释 |
AliOssProperties |
获得Alioss的配置 |
|
AliOssUtil |
上传文件到alioss的工具 |
|
AsyncThreadPoolConfig |
线程池配置 |
|
BaseDateConverter |
基本日期类型转换器 |
|
BaseEnumConverter |
基本枚举类型转换器 |
|
CodeGenerator |
验证码生成工具 |
|
ControllerExceptionHandler |
shiro,token异常捕获 |
|
CorsConfig |
跨域配置 |
|
CustomAsyncExceptionHandler |
常见异常处理 |
|
EmailConsumer |
验证邮件的消费者 |
|
EmailMessage |
邮件类 |
|
EmailProducer |
验证邮件的生产者 |
|
EmailRabbitMQConfig |
邮件队列 |
|
JWTFilter |
jwt过滤器 |
|
JWTRealm |
处理一些jwt的认证与授权 |
|
JWTToken |
实现shiroShiro 框架认证令牌接口 |
|
JWTUtils |
JWT工具类,生产token。解析token |
|
OssConfiguration |
AliOss配置类 |
|
QRCodeGenerator |
二维码生成工具 |
|
RedisConfig |
redis配置 |
|
Result |
返回结果类 |
|
ShiroConfig |
shiro配置 |
|
UrgeEmailConsumer |
督促邮件生产者 |
|
Controller层
类名 |
说明 |
注释 |
AuditRecordController |
会议创建申请控制器 |
|
CheckInRecordController |
会议签到控制器 |
|
EmailController |
邮件控制器 |
|
ExcelController |
导出excel控制器 |
|
MeetController |
会议管理控制器 |
|
MeetRecordController |
参会记录控制器 |
|
PayRecordController |
支付记录控制器 |
|
QRController |
二维码控制器 |
|
UploadController |
文件上传控制器 |
|
UserController |
用户管理控制器 |
|
DAO层
类名 |
说明 |
注释 |
AuditRecordDao |
会议创建记录表数据访问对象 |
|
CheckInRecordDao |
签到记录表数据访问对象 |
|
MeetDao |
会议表数据访问对象 |
|
MeetRecordDao |
参会记录表数据访问对象 |
|
PayRecordDao |
支付记录表数据访问对象 |
|
UserDao |
用户表数据访问对象 |
|
[!NOTE]
DTO与Entity层不详述
Service层
类名 |
说明 |
注释 |
AuditRecordService |
会议创建记录业务处理 |
|
CheckInRecordService |
签到业务处理 |
|
EmailService |
邮箱验证业务处理 |
|
MeetRecordService |
参加会议业务处理 |
|
MeetService |
会议管理业务处理 |
|
PayRecordService |
支付业务处理 |
|
UserService |
用户管理业务处理 |
|
页面简述
页面名 |
功能概述 |
组件概述 |
App.js |
应用顶层,设置应用的路由,管理不同页面的导航。 |
使用 React Router 的 Router 、Routes 和 Route 组件来定义不同路径对应的页面组件。 |
Login.js |
提供用户登录功能,验证并判断用户身份并跳转到相应的主页。 |
使用 Card 、Input 和 Button 等组件构建登录表单。 |
Register.js |
提供用户注册功能,用户填写信息并获取验证码,验证邮箱正确后完成注册。 |
使用 Steps 构建步骤条,使用Card 和Form 等组件构建注册表单。 |
Welcome.js |
注册成功后的欢迎页面,用户填写个人信息后进入用户主页。 |
使用 Typography 组件显示欢迎标题。使用 Input 组件创建输入框,用户可以填写用户名和电话号码。使用 ConfigProvider 组件定制按钮的主题,特别是使用了渐变色按钮。使用 Button 组件创建提交按钮。 |
UserHome.js |
用户主页,用户可以通过侧边栏菜单选择不同功能模块。 |
使用 Layout 、Menu 和Breadcrumb 组件构建菜单、导航以及面包屑。使用``Avatar来显示头像。动态加载 HostCardComponent、 JoinCardComponent、 ConferenceHall、 PaymentListComponent和 UserInfo` 组件。 |
HostCardComponent.js |
展示用户创建的会议,提供创建会议、导出二维码和删除会议的功能。 |
使用 Card 、Button 、Modal 、Form 、Input 、DatePicker 、Radio 组件构建会议卡片和创建会议表单以及两个功能按钮。使用 Card 、Collapse 和``Avatar构建与会人员具体需求详情。使用 Row、 Col`来对卡片进行排版。 |
JoinCardComponent.js |
展示用户加入的会议,提供加入会议、付款和签到的功能。 |
使用 Card 、Button 、Modal 、Form 、Input 、Radio 组件构建会议卡片、加入会议表单、两个功能按钮和查看会议需求。使用Row 、Col 来对卡片进行排版。 |
PaymentListComponent.js |
展示用户的缴费记录。 |
使用 List 组件构建缴费记录列表。 |
UserInfo.js |
展示和修改用户信息,用户可以修改用户名、电话号码和密码,还可以上传新头像。 |
使用 Form 组件构建用户信息表单。使用Avatar 、Modal 和 Upload 构建头像上传框和头像展示框。 |
AdminHome.js |
管理员主页,管理员可以通过侧边栏菜单选择不同功能模块。 |
使用 Layout 、Menu 和Breadcrumb 组件构建菜单、导航以及面包屑。动态加载 AuditConference 、UserSearch 、AdminConferenceRecord 和 AdminPaymentRecord 组件。 |
AuditConference.js |
审核会议申请,展示提交审核的会议列表,并提供审核通过和删除会议的功能。 |
使用List 和Button 组件构建会议列表。 |
UserSearch.js |
查询系统中的所有用户,并提供导出用户列表的功能。 |
使用 Button 和List 组件构建用户列表和导出按钮。 |
AdminConferenceRecord.js |
查询系统中的所有会议记录,并提供导出会议记录的功能。 |
使用 Button 和List 组件构建会议记录列表和导出按钮。 |
AdminPaymentRecord.js |
查询系统中的所有支付记录,并提供导出支付记录的功能。 |
使用 Button 和List 组件构建支付记录列表和导出按钮。 |
Letter.js |
显示会议邀请函,用户可以查看会议详细信息并跳转到登录界面。 |
使用 Input 组件创建输入框,用户可以填写用户名和电话号码。使用 ConfigProvider 组件定制按钮的主题,特别是使用了渐变色按钮。使用 Button 组件创建提交按钮。 |
路由简述
路由 |
所在页面 |
跳转逻辑 |
/login |
Login.js |
用户点击“注册”按钮时,跳转到 /register 页面。 用户登录成功后,根据用户角色跳转到 /userhome 或 /adminhome 页面。 |
/register |
Register.js |
用户点击“返回”按钮时,跳转到 / login 页面。 用户注册成功后,自动登录并跳转到 /welcome 页面。 |
/welcome |
Welcome.js |
用户填写完个人信息后,点击“进入主页”按钮,跳转到 /userhome 页面。 |
/userhome |
UserHome.js |
用户点击侧边栏菜单项,动态加载相应的组件。 用户点击“登出”按钮时,跳转到 /login 页面。 |
/adminhome |
AdminHome.js |
管理员点击侧边栏菜单项,动态加载相应的组件。 管理员点击“登出”按钮时,跳转到 /login 页面。 |
/letter |
Letter.js |
用户点击“登录以加入会议”按钮时,跳转到 /login 页面。 |
功能实现
认证服务开发
用户登录认证
用户根据邮箱和密码登录,登录过程中将验证用户账号是否被删除,是否因多次登录失败而被锁定,若成功则返回token,失败则再决定是否封锁账号,避免恶性登录.该服务主要位于UserService中由于代码过多,此处只列出主要代码
1 2 3 4 5 6 7 8 9 10 11 12
| localStorage.setItem('token', token);
switch(role) { case 'Organizer': userLogin(); break; case 'Admin': adminLogin(); break; default: message.error('未知角色'); }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
| public Result login(String email, String password) { User user = userDao.findByEmail(email); if (user != null) { if (user.isLockoutEnabled() && user.getLockoutEnd() != null && user.getLockoutEnd().isAfter(OffsetDateTime.now())) { return Result.fail(403, "账号被锁定"); }
if (password .equals(user.getPasswordHash())) { user.setAccessFailedCount(0); user.setLockoutEnabled(false); user.setLastLoginTime(OffsetDateTime.now()); userDao.save(user); String token=JWTUtils.getToken(user); String role=user.getRole().toString(); tokenAndRoleDto tokenDto=new tokenAndRoleDto(); tokenDto.setToken(token); tokenDto.setRole(role); tokenDto.setEmail(email);
return Result.success(tokenDto);
} else { user.setAccessFailedCount(user.getAccessFailedCount() + 1); if (user.getAccessFailedCount() >= 3) { user.setLockoutEnabled(true); user.setLockoutEnd(OffsetDateTime.now().plusMinutes(10)); }
userDao.save(user); return Result.fail(401, "错误密码"); } } return Result.fail(404, "用户不存在"); }
|
邮箱注册验证
为了验证用户提供的电子邮件地址是否有效,确保用户拥有对该邮箱的访问权限,需要对用户注册的邮箱发送验证码,用户先输入正确的验证码才可以继续注册,否则注册失败,该服务主要利用RabbitMQ进行邮件的发送,生成的邮箱验证码记录在Redis缓存中,当用户提交注册请求时,先对比验证码,再进行注册处理。对于RabbitMQ的处理主要在Common层的EmailRabbitMQConfig类中,具体代码不赘述,详细请自己查看随文代码。以下是邮箱验证的核心代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65
| const sendEmail = async () => { try { const response = await fetch(`http://47.121.129.33:5201/email/sendCode?email=${email}`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(), }); const data = await response.json();
if (data.state) { message.success(data.data); } else { message.error(data.data); } } catch (error) { message.error('邮件发送失败'); } };
const handleRegisterClick = async () => { if (password !== confirmpassword) { passwordNotMatch(); } else { try { const response = await fetch(`http://47.121.129.33:5201/email/verifyCode?email=${email}&code=${verfy}`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(), }); const data = await response.json(); if (data.state) { try { const response = await fetch('http://47.121.129.33:5201/user/register', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ email, password}), }); const data = await response.json(); if (data.state) { success(); } else { message.error(data.data); } } catch (error) { message.error('注册失败,请稍后再试'); } } else { message.error(data.data); } } catch (error) { message.error('邮箱验证失败,请稍后再试'); } } };
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| @PostMapping("/verifyCode") public Result verifyCode(@RequestParam String email, @RequestParam String code) { String storedCode=emailService.getVerificationCode(email); log.info("storedCode:{}",storedCode); if (storedCode != null && storedCode.equals(code)) { return Result.success("验证通过!");
} return Result.fail("验证失败!验证码错误或过期!请重新发送.");
}
@CacheEvict(value = "verificationCodes", key = "#to") public void invalidateVerificationCode(String to) { } @Cacheable(value = "verificationCodes", key = "#to") public String getVerificationCode(String to) { return null; }
|
文件服务开发
文件服务主要用于将用户上传的头像文件上传到文件服务器,此处使用AliOSS文件云服务器来存储头像文件,关于AliOSS的基本配置按照操作文档复现即可
用户头像上传
为了保证文件上传安全,防止恶意文件上传,在上传前需要先验证文件类型,只允许安全的文件类型上传,还需要设置文件大小限制,避免大文件上传,病使用随机的文件名来取代原始文件名,核心代码如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60
| const beforeUpload = (file) => { const isJpgOrPng = file.type === 'image/jpeg' || file.type === 'image/png'; if (!isJpgOrPng) { message.error('只能上传 JPG/PNG 文件!'); } const isLt2M = file.size / 1024 / 1024 < 2; if (!isLt2M) { message.error('图片必须小于 2MB!'); } return isJpgOrPng && isLt2M; };
const handleUploadChange = (info) => { if (info.file.status === 'done') { message.success(`${info.file.name} 文件上传成功`); setFetchTrigger(!fetchTrigger); } else if (info.file.status === 'error') { message.error(`${info.file.name} 文件上传失败`); } };
const uploadProps = { name: 'file', multiple: false, action: 'http://47.121.129.33:5201/upload', headers: { 'token': localStorage.getItem('token'), }, beforeUpload, onChange: handleUploadChange, showUploadList: false, };
<Avatar size={150} icon={!avatarUrl && <UserOutlined />} src={avatarUrl && `${avatarBaseUrl}${avatarUrl}`} onClick={handleAvatarClick} style={{ cursor: 'pointer' }} />
<Modal open={isModalVisible} onCancel={handleModalClose} footer={null} closable={true} > <Upload.Dragger {...uploadProps}> <p className="ant-upload-drag-icon"> <InboxOutlined /> </p> <p className="ant-upload-text">拖拽以上传文件</p> <p className="ant-upload-hint">上传内容为jpg、png,小于等于2MB</p> </Upload.Dragger> </Modal>
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42
| @PostMapping() @RequiresRoles(value = {"Normal","Organizer"},logical = Logical.OR) public Result upload(@RequestHeader("token") String token, @Size(max = 10 * 1024 * 1024) MultipartFile file) {
String sId = Objects.requireNonNull(JWTUtils.verify(token)).getClaim("id").asString(); BigInteger id= BigInteger.valueOf(Integer.parseInt(sId));
try { String originalFilename = file.getOriginalFilename(); if (originalFilename == null || originalFilename.isEmpty()) { return Result.fail("文件名为空"); }
String extension = originalFilename.substring(originalFilename.lastIndexOf(".") + 1).toLowerCase();
if(!allowedExtensions.contains(extension)){ return Result.fail("错误的文件类型"); }
String newFileName = UUID.randomUUID().toString()+"." + extension; Path localFilePath = Files.createFile(Path.of(UPLOAD_DIR, newFileName)); file.transferTo(localFilePath);
String result= aliOssUtil.upload(file.getBytes(), newFileName);
userService.changeAvatar(id,newFileName);
String resultURL ="本地路径:"+ localFilePath.toString() + " | OSS路径: "+result; return Result.success( "上传成功"); } catch (IOException e) { throw new RuntimeException(e); } }
|
会议邀请二维码生成
利用zxing库生成二维码,具体工具类在Common层的QRCodeGenerator中,此处不赘述。当组织者选择生成会议邀请二维码时,将先得到具体的会议数据,并伴随会议邀请页面的url一起被整合至二维码中,当用户扫描二维码,将直接重定向到邀请界面,核心代码如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| const downQR = async (meetId) => { message.info("下载中...");
const token = localStorage.getItem('token');
try { const response = await fetch(`http://47.121.129.33:5201/QR/code?meetId=${meetId}`, { method: 'GET', headers: { 'token': `${token}` } }); if (!response.ok) { throw new Error('网络响应失败'); }
const blob = await response.blob(); const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'QR.png'; document.body.appendChild(a); a.click(); a.remove(); window.URL.revokeObjectURL(url);
message.success('导出成功'); } catch (error) { message.error('导出失败'); } };
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| @GetMapping("/code") public ResponseEntity<StreamingResponseBody> generateQRCode(@RequestParam String meetId, HttpServletResponse response) { log.info("Start downloading QR code for text: {}", meetId); try { response.setContentType("image/png"); String fileName = "qrcode.png"; response.setHeader("Content-Disposition", "attachment; filename=\"" + fileName + "\"");
BitMatrix bitMatrix = QRCodeGenerator.createQRCode("https://feemfater.github.io?meetId="+meetId); if (bitMatrix != null) { try (OutputStream os = response.getOutputStream()) { MatrixToImageWriter.writeToStream(bitMatrix, "PNG", os); os.flush(); } } else { response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); } } catch (Exception e) { log.error("Error generating QR code", e); response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); try { response.getWriter().write("Error generating QR code"); } catch (IOException ioException) { } } return null; }
|
导出表单excel文件
excel导出功能直接使用阿里的EasyExcel包,具体使用方法请参照官方文档,使用的一般流程是,先获得具体的表单数据,再利用方便的映射工具ModelMapper,把Entity转化为ExcelDto,再利用easyexcel导出。其中需要注意的是枚举类,日期类需要单独定义转换类,否则将导出excel失败,此处将展示管理员导出用户数据的excel表单的核心代码,已经自定义的枚举转化器核心代码,具体代码请查看Common层BaseEnumConverter和BaseDateConverter
仅给出一个导出代码的示例
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| const handleButtonClick = async () => { const token = localStorage.getItem('token'); try { const response = await fetch('http://47.121.129.33:5201/excel/user', { method: 'GET', headers: { 'token': `${token}`, } });
if (!response.ok) { throw new Error('网络响应失败'); }
const blob = await response.blob(); const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'user.xlsx'; document.body.appendChild(a); a.click(); a.remove(); window.URL.revokeObjectURL(url);
message.success('导出成功'); } catch (error) { message.error('导出失败'); } };
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95
| @Data public class UserExcelDto {
@ExcelProperty("用户编号") @ColumnWidth(20) private BigInteger id; @ExcelProperty(value = "角色",converter = BaseEnumConverter.UserRoleConverter.class) @ColumnWidth(20) private UserRole role=UserRole.Organizer; @ColumnWidth(20) @DateTimeFormat("yyyy-MM-dd HH:mm:ss") private OffsetDateTime updatedTime;
}
public class BaseEnumConverter {
private static abstract class CoreConverter<T extends Enum<T>> implements Converter<T> {
private Class<T> clazz;
public CoreConverter(Class<T> clazz) { this.clazz = clazz; }
@Override public CellDataTypeEnum supportExcelTypeKey() { return CellDataTypeEnum.STRING; }
@Override public Class supportJavaTypeKey() { return clazz; }
@Override public T convertToJavaData(ReadCellData<?> cellData, ExcelContentProperty property, GlobalConfiguration config) throws Exception { if (cellData.getData() instanceof String) { return Enum.valueOf(clazz, (String) cellData.getData()); } return null; }
@Override public WriteCellData<?> convertToExcelData(T obj, ExcelContentProperty property, GlobalConfiguration config) throws Exception {
return new WriteCellData<>(obj.name()); } }
public static class UserRoleConverter extends CoreConverter<UserRole> { public UserRoleConverter() { super(UserRole.class); } }
}
|
具体的服务核心代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| @GetMapping("/user") @RequiresRoles("Admin") public void exportUserExcel(HttpServletResponse response) {
log.info("exportUserExcel"); try{ this.setExcelResponseProp(response,"users"); List<UserExcelDto>userExcelDtos=this.getUserList(); EasyExcel.write(response.getOutputStream()) .head(UserExcelDto.class) .excelType(ExcelTypeEnum.XLSX) .sheet("users") .doWrite(userExcelDtos); }catch (IOException e){ log.error(e.getMessage());
} }
|
会议管理网站的后台服务
会议管理网站后台服务包括会议的增删改查,会议创建的审核,参加会议,支付,会议签到,提醒参会等
添加会议
组织者发出一条会议创建申请,即添加会议,期间需要验证用户的组织者角色,检查创建的会议是否重名,会议开始结束时间是否错乱,如无误,将于数据库添加一条会议创建审核信息和会议信息,且会议的Audited字段将置false,来避免其它用户检索到未经过审核的会议。具体代码在MeetService中,此处展示核心代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49
| const handleOk = (values) => { form.validateFields().then(values => { const moment = require('moment'); const { name, description, password, temp1, temp2, meetType, place, cost, maxNums } = values;
const startDate = formatDate(temp1.toString()); const endDate = formatDate(temp2.toString());
const token = localStorage.getItem('token'); const userId = localStorage.getItem('email');
fetch('http://47.121.129.33:5201/meet/add', { method: 'POST', headers: { 'Content-Type': 'application/json', 'token':`${token}`
}, body: JSON.stringify({ name, password, description, meetType, place, cost, maxNums, startDate, endDate }), }) .then(response => response.json()) .then(result => { if (result.state) { message.success('会议创建申请发送成功'); } else { message.error('会议创建申请发送失败'); } setVisible(false); setFetchTrigger(!fetchTrigger); }) .catch(() => { message.error('发送失败'); }); }).catch(info => { message.error("验证失败"); }); };
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
| @CacheEvict(value = "meets",key="'allUnAuditMeets'") public Result addMeet(MeetAddDto meetAddDto) {
Optional<User> userOptional = userRepository.findById(meetAddDto.getUserId()); if (userOptional.isPresent()) {
User user = userOptional.get(); Meet meet = new Meet(); ModelMapper modelMapper = new ModelMapper(); modelMapper.map(meetAddDto, meet); if(!meetAddDto.isNeedCheckIn())meet.setNeedCheckIn(false); meet.setCheckInStartTime(meetAddDto.getStartDate().atStartOfDay().atOffset(OffsetDateTime.now().getOffset())); meet.setCheckInEndTime(meetAddDto.getEndDate().atStartOfDay().atOffset(OffsetDateTime.now().getOffset())); meet.setAudited(false);
Meet savedMeet = meetDao.save(meet);
AuditRecord auditRecord = new AuditRecord();
auditRecord.setUser(user); auditRecord.setMeet(savedMeet);
auditRecordRepository.save(auditRecord);
return Result.success(savedMeet);
}
return Result.fail("错误的用户");
}
|
删除会议
删除会议即将会议的IsDeleted字段置为false,进行一个软删除的操作,此处选择软删除是因为软删除方便又省事,且数据库设计不周,外键过多,导致物理删除比较困难。核心代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| const deleteMeet = async (meetId) => { const token = localStorage.getItem('token');
try { const response = await fetch(`http://47.121.129.33:5201/meet/${meetId}`, { method: 'DELETE', headers: { 'token': `${token}`, } }); const result = await response.json(); if (result.state) { setFetchTrigger(!fetchTrigger); message.success('会议删除成功'); } else{ message.error("删除失败"); } } catch (error) { message.error('删除会议失败'); } };
|
1 2 3 4 5
| public void deleteMeet(BigInteger id) { Meet meet=meetDao.getById(id); meet.setDeleted(true); meetDao.save(meet); }
|
修改会议
修改会议直接根据会议id检索,先判断是否删除,是否审核,再进行修改操作,过于简单,不赘述
会议审核
会议审核需对操作者进行管理员身份的验证(在Controller中进行),若通过,将根据审核记录号索引会议,若存在则置会议字段Audited为true,否则返回失败
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| const function1 = async (id) => { const token = localStorage.getItem('token'); const intId = parseInt(id, 10);
try { const response = await fetch(`http://47.121.129.33:5201/meet/audit?id=${intId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'token': `${token}` }, body: JSON.stringify(), }); const result = await response.json(); if (result.state && result.code === 1) { message.success(`审核通过: ${id}`); setFetchTrigger(!fetchTrigger); } else { message.error('审核失败'); } } catch (error) { message.error('审核失败'); } };
|
1 2 3 4 5 6 7 8 9 10 11
| @CacheEvict(value = "meets",allEntries=true) public void auditMeet(BigInteger meetId, boolean isApproved) {
Optional<Meet> meetOptional = meetDao.findById(meetId);
if (meetOptional.isPresent()) { Meet meet = meetOptional.get(); meet.setAudited(isApproved); meetDao.save(meet); } }
|
参加会议
参加会议即经过认证的用户通过提交参加会议的表单,首先验证会议号码,检索会议是否存在或可加入,再对比会议密码,若正确,则在数据库添加一条会议参加记录,参加会议的表单不需要ModelMapper处理,因为比较简单,核心代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| const handleJoinSubmit = (values) => { const { meetId, password, needHotel, phoneNumber } = values; const token = localStorage.getItem('token'); fetch('http://47.121.129.33:5201/meetRecord/add', { method: 'POST', headers: { 'Content-Type': 'application/json', 'token':`${token}` }, body: JSON.stringify({ meetId, password, needHotel, phoneNumber }), }) .then(response => response.json()) .then(result => { if (result.state) { message.success('提交成功'); setIsJoinModalVisible(false); setFetchTrigger(!fetchTrigger); } else { message.error('提交失败'); } }) .catch(() => { message.error('提交失败'); }); };
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
| public Result addMeetRecord(MeetRecordAddDto meetRecordAddDto) {
Optional<Meet> meetOptional = meetRepository.findById(meetRecordAddDto.getMeetId()); Optional<User> userOptional = userRepository.findById(meetRecordAddDto.getUserId());
if (meetOptional.isPresent() && userOptional.isPresent()) { MeetRecord meetRecord = new MeetRecord(); Meet meet = meetOptional.get(); User user = userOptional.get();
if(Objects.equals(meet.getAuditRecord().getUser().getId(), user.getId())){ return Result.fail("不可自给自足"); } if(meetRecordDao.existsByMeetIdAndUserId(meet.getId(), user.getId())) { return Result.fail("不可重复参会"); }
if(meet.getPassword()!=null){ if(!meet.getPassword().toString().equals(meetRecordAddDto.getPassword())){ return Result.fail("错误的入会口令"); } } if(meet.isAudited()&& !meet.isDeleted()){ meetRecord.setMeet(meet); meetRecord.setUser(user); meetRecord.setNeedHotel(meetRecordAddDto.isNeedHotel()); meetRecordDao.save(meetRecord); return Result.success("成功入会"); }
} return Result.fail("未知会议或用户");
}
|
简单支付
根据要求支付无需真的支付,直接往数据库中添加一条支付记录即可,但需验证会议id是否正确,且支付记录不可重复,即不可重复支付,核心代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| const pay = async (meetId) => { const token = localStorage.getItem('token'); const intId = parseInt(meetId, 10);
try { const response = await fetch(`http://47.121.129.33:5201/payRecord/add?meetId=${intId}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'token': ` ${token}`, }, body: JSON.stringify(), }); const result = await response.json();
if (result.state) { message.success('付款成功'); } else { message.error(result.data); } } catch (error) { message.error('付款失败'); } };
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| public PayRecord addPayRecord(PayRecordAddDto payRecordAddDto) {
Optional<Meet> meetOptional = meetRepository.findById(payRecordAddDto.getMeetId()); Optional<User> userOptional = userRepository.findById(payRecordAddDto.getUserId());
if (meetOptional.isPresent() && userOptional.isPresent()) { Meet meet = meetOptional.get(); User user = userOptional.get(); boolean exists = payRecordDao.existsByMeetIdAndUserId(meet.getId(), user.getId()); if (!exists) { PayRecord payRecord = new PayRecord(); payRecord.setMeet(meet); payRecord.setUser(user); return payRecordDao.save(payRecord); } } return null;
}
|
会议签到
会议签到即往数据库添加一条签到记录,经过认证用户提交签到请求,首先根据会议号索引会议是否存在,若存在则判断是否重复签到,再判断是否错过签到时间,逻辑清楚,核心代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| const signin = async (meetId) => { const token = localStorage.getItem('token'); const intId = parseInt(meetId, 10); try { const response = await fetch(`http://47.121.129.33:5201/checkIn?meetId=${intId}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'token': ` ${token}`, }, body: JSON.stringify(), }); const result = await response.json();
if (result.state) { message.success('签到成功'); } else { message.error(result.data); } } catch (error) { message.error('签到失败'); } };
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| public Result checkIn(BigInteger userId, BigInteger meetId) { Optional<User> userOptional = userRepository.findById(userId); Optional<Meet> meetOptional = meetRepository.findById(meetId);
if(userOptional.isPresent() && meetOptional.isPresent()){ User user = userOptional.get(); Meet meet = meetOptional.get();
OffsetDateTime now = OffsetDateTime.now(); if (now.isBefore(meet.getCheckInStartTime()) || now.isAfter(meet.getCheckInEndTime())) { return Result.fail("签到失败:错误的签到时间"); } boolean existed = checkInRecordDao.existsByMeetIdAndUserId(meetId,userId); if(existed){ return Result.fail("签到失败:不可重复签到"); }
CheckInRecord checkInRecord = new CheckInRecord(); checkInRecord.setUser(user); checkInRecord.setMeet(meet); checkInRecordDao.save(checkInRecord); return Result.success("签到成功"); }
return Result.fail("会议失踪");
}
|
提醒参会
提醒参会即每日定时检索会议表,若存在会议明天就要开始,服务器需自动为所有参会人员发送提醒邮件,督促参会。定时后台服务需使用Spring Task Scheduling,添加定时服务注解,服务器发送邮件,需使用使用RabbitMQ,具体使用请查询Common层的EmailRabbitMQConfig,消息生产者为EmailProducer,消费者为UrgeEmailProducer的具体代码,此处不赘述。提醒服务核心代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
| @Scheduled(cron = "0 15 10 ? * *",zone = "Asia/Shanghai") public void SendMeetMessage(){
meetDao.findByIsAuditedAndIsDeleted(true,false) .stream() .filter(meet -> meet.getStartDate().isEqual(LocalDate.now().plusDays(1))) .forEach(meet -> { meet.getMeetRecords().forEach(meetRecord -> { User user=meetRecord.getUser(); if (!user.isDeleted()){
EmailMessage emailMessage = new EmailMessage(); emailMessage.setTo(user.getEmail()); emailMessage.setSubject("距离会议开始还有一天"); String htmlText = "<html><head></head><body>" + "<h1>会议提醒</h1>" + "<p>亲爱的用户,您有一场即将开始的会议。</p>" + "<p><strong>会议名称:</strong>" + meet.getName() + "</p>" + "<p><strong>地点:</strong>" + meet.getPlace() + "</p>" + "<p>请准时参加。</p>" + "</body></html>"; emailMessage.setText(htmlText); emailProducer.sendUrgeMessage(emailMessage); } });
}); }
|
会议管理网站的前台服务
前台服务需提供对会议信息,用户信息,会议参加记录,支付记录,创建会议记录的查看,主要是在处理Entity到DTO的转换,多应用redis缓存来降低服务器压力
查看会议记录
查看会议记录即索引数据库,查询已经审核,未被删除且未结束的会议Meet,并将其转化为MeetShowDto,转化可以直接利用ModelMapper,也可以自己写映射方法。核心代码实现如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106
| useEffect(() => { const fetchData = async () => {
const token = localStorage.getItem('token');
try { const response = await fetch('http://47.121.129.33:5201/user/audit-records', { method: 'GET', headers: { 'token':`${token}` }, }); const result = await response.json(); setLoading(false);
if (result.state && result.code === 1) { const newdata = sortdata(result.data); setData(newdata); } else{ message.warning("没有数据"); } } catch (error) { setLoading(false); message.error('数据加载失败'); } };
fetchData(); }, [fetchTrigger]);
<Row gutter={[24, 24]} justify="start"> <Col span={6}> <Card> {/* 第一张固定卡片,用于创建会议 */} </Card> </Col> {/* 动态生成的卡片 */} {data.map((item, index) => ( <Col key={item.meetId} span={6}> <Card hoverable onClick={() => function2(item.meetId)} style={{ height: '200px', border: '2px solid lightgray', borderRadius: '15px', backgroundColor: item.audited===true ? '#e8f8ed' : '#fffbef', position: 'relative', }} > <Meta title={item.meetName} description={ <div style={{ fontSize: '14px', lineHeight: '1.5', whiteSpace: 'pre' }}> <div>会议ID: {item.meetId}</div> <div>会议时间: {item.meetTime.split('T')[0]}</div> <div style={{ display: 'flex', justifyContent: 'space-between' }}> <span>会议类型: {item.meetType === 'BUSINESS' ? '商业会议' : '宴会'}</span> <span>会议费用: {item.cost}</span> </div> <div style={{ display: 'flex', justifyContent: 'space-between' }}> <span>密码: {item.meetPassword}</span> <span>审核状态: {item.audited===true ? '已审核' : '未审核'}</span> </div> <div>描述: {item.meetDescription}</div> </div> } /> <Button type="primary" icon={<QrcodeOutlined />} style={{ position: 'absolute', top: '10px', right: '50px', fontSize: '18px', }} onClick={(e) => { e.stopPropagation(); downQR(item.meetId); }} /> <Button type="primary" icon={<CloseSquareOutlined />} style={{ position: 'absolute', top: '10px', right: '10px', fontSize: '18px', backgroundColor: '#ff4d4d', borderColor: '#ff4d4d', }} onClick={(e) => { e.stopPropagation(); deleteMeet(item.meetId); }} /> </Card> </Col> ))} </Row>
|
1 2 3 4 5 6 7 8 9
| @Cacheable(value = "meets",key="'allUnAuditMeets'") public List<MeetShowDto> getUnAuditedAndActiveMeets() {
return meetDao.findByIsAuditedAndIsDeleted(false,false) .stream() .filter(meet -> meet.getEndDate().isAfter(LocalDate.now())) .map(meet -> modelMapper.map(meet,MeetShowDto.class)) .collect(Collectors.toList()); }
|
查看参会用户
查看参加会议的用户信息即根据会议Id查找会议,再得到会议的参加记录列表,根据参加记录来查找用户信息,并将其转化为UserShowDto,转化可以直接利用ModelMapper,也可以自己写映射方法。核心代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48
| const function2 = async (meetId) => { const token = localStorage.getItem('token'); try { const response = await fetch(`http://47.121.129.33:5201/meet/getUser/${meetId}`, { method: 'GET', headers: { 'Content-Type': 'application/json', 'token': `${token}` }, }); const result = await response.json();
if (result.state && result.code === 1) { const members = result.data;
const items = members.map((member, index) => ({ key: index.toString(), label: ( <div style={{ display: 'flex', alignItems: 'center' }}> <Avatar size="small" icon={!member.avatar && <UserOutlined />} src={member.avatar && `${avatarBaseUrl}${member.avatar}`} style={{ marginRight: '8px' }} /> <span>{member.userName}</span> </div> ), children: ( <div> <p>电话号码: {member.phoneNumber}</p> <p>是否需要住宿: {member.needHotel ? '是' : '否'}</p> <p>是否签到: {member.hasCheckIn ? '是' : '否'}</p> </div> ), }));
setNewModalContent(items); setIsNewModalVisible(true); } else { message.error('被遗忘的会议'); } } catch (error) { message.error('查询失败'); } };
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| public List<UserShowDto> getMeetUsers(BigInteger meetId) { Optional<Meet> meetOptional=meetDao.findById(meetId); if (meetOptional.isPresent()) { Meet meet=meetOptional.get(); return meet.getMeetRecords().stream() .map(meetRecord -> convertToUserShowDto(meetRecord, meetId)) .collect(Collectors.toList()); } return List.of(); }
private UserShowDto convertToUserShowDto(MeetRecord meetRecord,BigInteger meetId) { UserShowDto userShowDto=new UserShowDto(); User user=meetRecord.getUser();
userShowDto.setUserName(user.getUserName()); userShowDto.setPhoneNumber(user.getPhoneNumber()); userShowDto.setNeedHotel(meetRecord.isNeedHotel());
Boolean hasCheckIn = checkInRecordRepository.existsByMeetIdAndUserId(meetId, user.getId());
return userShowDto;
}
|
查看参会记录
查看用户的参会记录即索引数据库,查询未被删除的,且尚未过期的参会记录,并将其转化为MeetRecordShowDto,转化可以直接利用ModelMapper,也可以自己写映射方法。核心代码实现如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| useEffect(() => { const fetchData = async () => { const token = localStorage.getItem('token'); try { const response = await fetch('http://47.121.129.33:5201/meet/all', { method: 'GET', headers: { 'token': `${token}`, }, }); const result = await response.json(); setLoading(false); if (result.state && result.code === 1) { setData(result.data); } else { message.warning('没有会议'); } } catch (error) { setLoading(false); message.error('数据加载失败'); } };
fetchData(); },[]);
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| public List<MeetRecordShowDto> getUserMeetRecords(BigInteger userId) { Optional<User> userOptional = userDao.findById(userId); if (userOptional.isPresent()) { User user = userOptional.get(); return user.getMeetRecords().stream() .filter(meetRecord -> !meetRecord.isDeleted()) .map(this::convertToDto) .collect(Collectors.toList()); } return List.of(); }
private MeetRecordShowDto convertToDto(MeetRecord meetRecord) { MeetRecordShowDto dto = new MeetRecordShowDto(); dto.setMeetId(meetRecord.getMeet().getId()); dto.setMeetName(meetRecord.getMeet().getName()); dto.setMeetTime(meetRecord.getMeet().getStartDate().toString()); dto.setMeetPlace(meetRecord.getMeet().getPlace()); return dto; }
|
查看支付记录
查看用户的参会记录即索引数据库,查询未被删除的支付记录,过于简单,此处不赘述
查看创会记录
查看用户的参会记录即索引数据库,查询未被删除的创会记录,过于简单,此处不赘述
项目部署
项目部署要求
硬件要求
软件要求
- CentOS 7
- 数据库 MySQL 8.0
- JDK 17
- Nginx 1.24
- Node.js 8.12
- 缓存数据库 Redis 7.2
- 消息队列 RabbitMQ 3
项目部署方案
SpringBoot项目打包
修改pom.xml,将skip修改为false
1 2 3 4
| <configuration> <mainClass>ncu.sunyu.meetmanager.MeetManagerApplication</mainClass> <skip>false</skip> </configuration>
|
修改项目配置文件,具体关注数据库,Redis,rabbitMq配置,将其修改为服务器配置
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| spring.datasource.username=root(修改为服务器数据库用户) spring.datasource.password=123456 spring.datasource.url=jdbc:mysql://localhost:3306/needtemp?useSSL=false
spring.redis.host=localhost spring.redis.port=6379 spring.cache.type=redis
spring.redis.password=密码 spring.redis.timeout=5000 logging.level.org.springframework.data.redis=DEBUG
spring.rabbitmq.host=localhost spring.rabbitmq.port=5672
spring.rabbitmq.username=name spring.rabbitmq.password=pwd
|
利用maven clean和package命令,快速生成jar包

使用宝塔将jar包上传到服务器

在宝塔网站服务中部署springboot后端项目

- 选择上传的jar包作为项目jar路径
- 项目端口设置为5201,选择放行端口
- 项目JDK选择JDK17
- 项目用户选择root
- 设置开机启动

项目部署结果

React项目打包
添加配置:
npm run build
利用宝塔上传build文件至服务器
修改Nginx配置,添加如下
1 2 3 4 5 6 7 8 9 10 11
| server { listen 80; server_name your-frontend-domain.com;
root build文件的上传路径; index index.html index.htm;
location / { try_files $uri $uri/ /index.html; } }
|
代码版本管理
代码版本管理工具 git
代码仓库 github

项目测试
核心业务测试
一. 组织者可以创建新账户或使用现有账户登录系统,创建会议,生成会议邀请二维码,查询会议参加记录,支付记录;
创建新用户账户(注:为了方便测试,创建用户皆为组织者角色)
创建新会议,生成会议二维码
二.管理员使用现有账号登录,审核会议创建记录,导出用户,会议,支付信息
- 管理员登录账号
- 审核会议创建记录
三. 参会者可以创建或使用现有账号登录,选择会议大厅的会议,填写参加表单参加会议;或扫描会议二维码获得会议信息来加入会议;参会者在规定时间可以签到,并且支付会议花费:
- 用户登录账户
- 参加会议
- 签到,支付
四.管理员使用现有账号登录,导出用户,会议,支付信息:
- 管理员登录账号
- 导出数据库表单
错误记录与解决办法
1.直接访问主页报错
描述:直接访问 /userhome
页面或/adminhome
时,如果用户未登录,会导致“用户信息获取失败”的错误。这是因为获取用户信息的 API 请求需要一个有效的 token
,而未登录的用户没有 token
存储在 localStorage
中。
解决方案:返回到/login
页面登录以获得token
,再自动跳转至/userhome
或/adminhome
进行操作。
相关代码:
1 2 3 4 5 6 7 8
| localStorage.setItem('token', token);
const userLogin = () => { message.success("登录成功"); setTimeout(() => { navigate(`/userhome`); }, 2000); };
|
预防措施:可以给程序添加路由保护、重定向等功能防止用户直接访问需要token
的页面。
2.兼容性问题
3.API调用出错
4. 数据库连接超时
描述:在高负载测试期间,出现了数据库连接超时的问题。
复现步骤:在并发请求达到100时,数据库连接开始超时。
解决方案:增加了数据库连接池的大小,并优化了连接超时设置。
相关代码:
1 2
| spring.datasource.hikari.maximum-pool-size=20 spring.datasource.hikari.connection-timeout=30000
|
预防措施:建议在生产环境中监控数据库连接使用情况。
5. 文件上传大小限制
描述:用户无法上传大于特定大小的文件。
解决方案:调整了文件上传的大小限制配置。
相关代码:
1
| @Size(max = 10 * 1024 * 1024) MultipartFile file
|
6. 跨域请求问题
7.访问图库失败
9.redis数据转换失败