需求分析及技术选型

核心业务场景

  1. 参会者注册/登录:用户可以创建新账户或使用现有账户登录系统。
  2. 会议选择与加入:用户可以根据需求选择会议或使用邀请码加入特邀会议。
  3. 个人信息填写:参会者需提供参会时间、住宿要求等信息。
  4. 虚拟缴费:参会者通过系统完成会议费用的支付。
  5. 会议信息管理:组织者填写会议信息,生成新会议,并管理特邀码。
  6. 会议管理:组织者对会议进行管理,包括签到和入住。
  7. 审核管理:管理员审核会议信息,管理注册人员。

系统角色

  1. 参会者:注册账户并参与会议的用户。
  2. 会务组织者:负责创建和管理会议的用户。
  3. 管理员:负责系统维护和用户管理的高级用户。

流程

  1. 用户注册/登录流程:用户填写信息注册账户或使用账户信息登录。
  2. 会议加入流程:参会者选择会议并填写相关信息。
  3. 会议创建流程:组织者填写会议信息,生成会议,并提供特邀码。
  4. 会议管理流程:组织者进行会议的签到和入住管理。
  5. 审核流程:管理员审核会议信息和注册人员。

核心业务对象

  1. 用户账户:存储用户信息,包括个人资料和登录凭证。
  2. 会议:包含会议信息,如名称、时间、地点、特邀码等。
  3. 参会信息:记录参会者的参会时间、住宿要求等。
  4. 支付记录:存储参会者的缴费信息。
  5. 审核状态:记录会议和用户的审核状态。

场景验证

为了验证这些场景,设计以下测试用例:

  1. 用户注册/登录测试:验证用户能否成功注册和登录,包括密码找回和账户锁定机制。
  2. 会议选择与加入测试:验证用户能否根据需求选择会议,以及特邀码的使用是否正确。
  3. 个人信息填写测试:验证参会者能否正确填写并提交参会信息。
  4. 虚拟缴费测试:验证缴费流程是否顺畅,包括支付成功和失败的情况。
  5. 会议信息管理测试:验证组织者能否正确创建和管理会议信息。
  6. 会议管理测试:验证组织者能否进行有效的签到和入住管理。
  7. 审核管理测试:验证管理员的审核流程是否符合预期,包括审核通过和拒绝的情况。

核心技术选型

选择 作用 说明
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;

//默认角色是Organizer,省事
@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 RouterRouterRoutesRoute 组件来定义不同路径对应的页面组件。
Login.js 提供用户登录功能,验证并判断用户身份并跳转到相应的主页。 使用 CardInputButton等组件构建登录表单。
Register.js 提供用户注册功能,用户填写信息并获取验证码,验证邮箱正确后完成注册。 使用 Steps构建步骤条,使用CardForm等组件构建注册表单。
Welcome.js 注册成功后的欢迎页面,用户填写个人信息后进入用户主页。 使用 Typography 组件显示欢迎标题。使用 Input 组件创建输入框,用户可以填写用户名和电话号码。使用 ConfigProvider 组件定制按钮的主题,特别是使用了渐变色按钮。使用 Button 组件创建提交按钮。
UserHome.js 用户主页,用户可以通过侧边栏菜单选择不同功能模块。 使用 LayoutMenuBreadcrumb 组件构建菜单、导航以及面包屑。使用``Avatar来显示头像。动态加载 HostCardComponentJoinCardComponentConferenceHallPaymentListComponentUserInfo` 组件。
HostCardComponent.js 展示用户创建的会议,提供创建会议、导出二维码和删除会议的功能。 使用 CardButtonModalFormInputDatePickerRadio 组件构建会议卡片和创建会议表单以及两个功能按钮。使用 CardCollapse和``Avatar构建与会人员具体需求详情。使用RowCol`来对卡片进行排版。
JoinCardComponent.js 展示用户加入的会议,提供加入会议、付款和签到的功能。 使用 CardButtonModalFormInputRadio 组件构建会议卡片、加入会议表单、两个功能按钮和查看会议需求。使用RowCol来对卡片进行排版。
PaymentListComponent.js 展示用户的缴费记录。 使用 List组件构建缴费记录列表。
UserInfo.js 展示和修改用户信息,用户可以修改用户名、电话号码和密码,还可以上传新头像。 使用 Form 组件构建用户信息表单。使用AvatarModalUpload构建头像上传框和头像展示框。
AdminHome.js 管理员主页,管理员可以通过侧边栏菜单选择不同功能模块。 使用 LayoutMenuBreadcrumb 组件构建菜单、导航以及面包屑。动态加载 AuditConferenceUserSearchAdminConferenceRecordAdminPaymentRecord 组件。
AuditConference.js 审核会议申请,展示提交审核的会议列表,并提供审核通过和删除会议的功能。 使用ListButton 组件构建会议列表。
UserSearch.js 查询系统中的所有用户,并提供导出用户列表的功能。 使用 ButtonList 组件构建用户列表和导出按钮。
AdminConferenceRecord.js 查询系统中的所有会议记录,并提供导出会议记录的功能。 使用 ButtonList 组件构建会议记录列表和导出按钮。
AdminPaymentRecord.js 查询系统中的所有支付记录,并提供导出支付记录的功能。 使用 ButtonList 组件构建支付记录列表和导出按钮。
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);	//存储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);

//生成token
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 {
// 调用后端提供的 register 接口
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("验证通过!");

}
//emailService.invalidateVerificationCode(email);
return Result.fail("验证失败!验证码错误或过期!请重新发送.");

}

@CacheEvict(value = "verificationCodes", key = "#to")
public void invalidateVerificationCode(String to) {
// 方法体可以为空,因为 @CacheEvict 注解会自动清除缓存
}
@Cacheable(value = "verificationCodes", key = "#to")
public String getVerificationCode(String to) {
// 这里直接返回 null,因为如果缓存中没有对应的验证码,Spring 会返回 null
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); // 修改 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);

// 在OSS中存储文件
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) {
// Ignore this exception as the response might be already committed
}
}
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
//UserExcelDTO
@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 {

/**
* 核心抽象类,负责不同类的数据类型装换
* @param <T> LocalXXX类泛型
*/
private static abstract class CoreConverter<T extends Enum<T>> implements Converter<T> {

private Class<T> clazz;

/**
* 指定Class类型,接收UserRole.class.MeetType.class
*/
public CoreConverter(Class<T> clazz) {
this.clazz = clazz;
}

/**
* 导入支持的数据类型
*/
@Override
public CellDataTypeEnum supportExcelTypeKey() {
return CellDataTypeEnum.STRING;
}

/**
* 导出支持的数据类型
*/
@Override
public Class supportJavaTypeKey() {
return clazz;
}

/**
* 导入时,数据类型转换
* @param cellData excel单元格数据
* @param property 单元格样式
* @param config 全局配置
* @return
*/
@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;
}

/**
* 导出时,数据类型转换
* @param obj 当前数据
* @param property 单元格样式
* @param config 全局配置
* @return
*/
@Override
public WriteCellData<?> convertToExcelData(T obj, ExcelContentProperty property, GlobalConfiguration config) throws Exception {

return new WriteCellData<>(obj.name());
}
}

/**
* UserRole数据转换器
*/
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) => {
// 获取本地保存的 token
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); // 将 id 转为 BigInteger

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); // 将 meetId 转为整数

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
//前端无体现效果
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 函数
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;

// 生成 Collapse 面板项
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;
}

查看支付记录

查看用户的参会记录即索引数据库,查询未被删除的支付记录,过于简单,此处不赘述

查看创会记录

查看用户的参会记录即索引数据库,查询未被删除的创会记录,过于简单,此处不赘述

项目部署

项目部署要求

硬件要求

  • CPU:双核
  • 内存:2G
  • 存储:30G

软件要求

  • CentOS 7
  • 数据库 MySQL 8.0
  • JDK 17
  • Nginx 1.24
  • Node.js 8.12
  • 缓存数据库 Redis 7.2
  • 消息队列 RabbitMQ 3

项目部署方案

SpringBoot项目打包

  1. 修改pom.xml,将skip修改为false

    1
    2
    3
    4
    <configuration>
    <mainClass>ncu.sunyu.meetmanager.MeetManagerApplication</mainClass>
    <skip>false</skip>
    </configuration>
  2. 修改项目配置文件,具体关注数据库,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

    #Redis
    spring.redis.host=localhost
    spring.redis.port=6379
    spring.cache.type=redis
    #需要加入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
  3. 利用maven clean和package命令,快速生成jar包

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

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

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

  6. 项目部署结果

React项目打包

  1. 添加配置:

  2. npm run build

  3. 利用宝塔上传build文件至服务器

  4. 修改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. 创建新用户账户(注:为了方便测试,创建用户皆为组织者角色)

  2. 创建新会议,生成会议二维码

二.管理员使用现有账号登录,审核会议创建记录,导出用户,会议,支付信息

  1. 管理员登录账号
  2. 审核会议创建记录

三. 参会者可以创建或使用现有账号登录,选择会议大厅的会议,填写参加表单参加会议;或扫描会议二维码获得会议信息来加入会议;参会者在规定时间可以签到,并且支付会议花费:

  1. 用户登录账户
  2. 参加会议
  3. 签到,支付

四.管理员使用现有账号登录,导出用户,会议,支付信息:

  1. 管理员登录账号
  2. 导出数据库表单

错误记录与解决办法

1.直接访问主页报错

  • 描述:直接访问 /userhome 页面或/adminhome时,如果用户未登录,会导致“用户信息获取失败”的错误。这是因为获取用户信息的 API 请求需要一个有效的 token,而未登录的用户没有 token 存储在 localStorage 中。

  • 解决方案:返回到/login页面登录以获得token,再自动跳转至/userhome/adminhome进行操作。

  • 相关代码:

    1
    2
    3
    4
    5
    6
    7
    8
    localStorage.setItem('token', token);  //保存token到本地

    const userLogin = () => { //登录成功后会自动跳转至主页
    message.success("登录成功");
    setTimeout(() => {
    navigate(`/userhome`); // 2 秒后跳转
    }, 2000);
    };
  • 预防措施:可以给程序添加路由保护、重定向等功能防止用户直接访问需要token的页面。

2.兼容性问题

  • 描述:某些功能或组件在不同浏览器或设备上表现不一致,例如缩放时导致组件变形严重,从而导致用户体验不佳。

  • 解决方案:

    • 1.使用 Ant Design 提供的响应式栅格系统(如 RowCol 组件)来创建响应式布局。
    • 2.使用 flexbox 布局来创建灵活的布局结构。
  • 相关代码:

    1
    style={{display: 'flex'}}	//用flexbox来调整布局
  • 预防措施:在开发过程中,定期在不同浏览器和设备上进行测试,确保兼容性。

3.API调用出错

  • 描述:在前端开发过程中,经常会遇到前端调用后端接口时,传递的变量名称与后端接收的变量名称不一致的问题。例如,前端传递的变量名称为 meetId,而后端接收的变量名称为 Id,导致接口调用失败并返回错误结果。

  • 解决方案:

  • 统一变量名称:

    • 前后端团队在项目初期应统一命名规范,确保前后端使用一致的变量名称。
    • 在接口文档中明确规定每个接口的参数名称和类型。
  • 采用映射的方法。

  • 相关代码:

    1
    body: JSON.stringify({ userName:username, phoneNumber:phone}),
  • 预防措施:

    • 编写详细的接口文档。
    • 前后端联合调试排除错误。

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. 跨域请求问题

  • 描述:前端应用报告CORS错误。

  • 解决方案:配置了适当的CORS设置以允许跨域请求。

  • 相关代码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    @Configuration
    public class CorsConfig {

    // 当前跨域请求最大有效时长,默认设置1天
    private static final long MAX_AGE = 24 * 60 * 60;

    @Bean
    public CorsFilter corsFilter(){
    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    CorsConfiguration corsConfiguration = new CorsConfiguration();
    // 配置访问原源地址,你自己的服务器ip地址
    corsConfiguration.addAllowedOrigin("http://47.121.129.33");
    // 设置访问源请求头
    corsConfiguration.addAllowedHeader("*");
    // 设置请求方法
    corsConfiguration.addAllowedMethod("*");
    corsConfiguration.setMaxAge(MAX_AGE);
    // 对接口配置跨域设置
    source.registerCorsConfiguration("/**",corsConfiguration);
    return new CorsFilter(source);
    }
    }

7.访问图库失败

  • 描述:前端应用访问图库失败

  • 复现步骤:按照设计输入http://我的服务器ip地址/文件路径/default.png应该就可以直接得到图片,但是页面直接跳转到React页面

  • 解决方案:nginx设置代理,与前端项目区分开

  • 相关代码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    server {
    listen 7900;
    server_name your-frontend-domain.com;

    root /www/wwwroot/picture/;

    location /images/ {
    alias /www/wwwroot/picture/; # 映射到实际的图片目录
    try_files $uri =404; # 如果文件不存在,则返回404
    }

9.redis数据转换失败

  • 描述:redis.serializer.SerializationException: Could not write JSON: class java.time.LocalDate cannot be cast to class java.time.LocalDateTime

  • 解决方案:对LocalDateTime类字段添加类转换器注释

  • 相关代码:

    1
    2
    3
    4
    5
    @NotNull
    @JsonDeserialize(using = LocalDateDeserializer.class)
    @JsonSerialize(using = LocalDateSerializer.class)
    @JsonFormat( pattern="yyyy-MM-dd")
    private LocalDate startDate;