注:项目git仓库地址:demo.lottery · 小五Z/Spring items - 码云 - 开源中国
目录
注:项目git仓库地址:demo.lottery · 小五Z/Spring items - 码云 - 开源中国
application.properties 配置上传⽂件路径
项目具体代码可参考仓库源码,本文只讲解重点代码逻辑
一、需求分析
1. 包含管理员的注册与登录。
a.
注册包含:姓名、邮箱、⼿机号、密码
b.
登录包含两种⽅式:
i.
电话+密码登录;
ii.
电话+短信登录; 验证码获取
iii.
登录需要校验管理员⾝份。
2. 人员管理: 管理员支持创建普通用户,查看用户列表
a.
创建普通用户:姓名,邮箱,⼿机号
b.
⼈员列表:⼈员id、姓名、⾝份(普通⽤⼾、管理员)
3. 管理端支持创建奖品、奖品列表展示功能。
a.
创建的奖品信息包含:奖品名称、描述、价格、奖品图(上传)
b.
奖品列表展⽰(可翻⻚):奖品id、奖品图、奖品名、奖品描述、奖品价值(元)
4. 管理端⽀持创建活动、活动列表展⽰功能。
a.
创建的活动信息包含:
i.
活动名称
ii.
活动描述
iii.
圈选奖品:勾选对应奖品,并设置奖品等级(⼀⼆三等奖),及奖品数量
iv.
圈选⼈员:勾选参与抽奖⼈员
b.
活动列表展示(可翻页):
i.
活动名称
ii.
描述
iii.
活动状态:
1.
活动状态为进⾏中:点击 "活动进⾏中, 去抽奖" 按钮跳转抽奖⻚
2.
活动状态为已完成:点击 "活动已完成, 查看中奖名单" 按钮跳转抽奖⻚查看结果
5. 抽奖页面:
a.
对于进⾏中的活动,管理员才可抽奖。
b.
每轮抽奖的中奖⼈数跟随当前奖品数量。
c.
每个⼈只能中⼀次奖
d.
多轮抽奖,每轮抽奖有3个环节:展⽰奖品信息(奖品图、份数),⼈名闪动,停⽌闪动确定中
奖名单
i.
当前⻚展⽰奖品信息, 点击‘开始抽奖’按钮, 则跳转⾄⼈名闪动画⾯
ii.
⼈员闪动画⾯,点击’点我确定‘按钮,确认中奖名单。
iii.
当前⻚展⽰中奖名单, 点击‘已抽完,下⼀步’按钮, 若还有奖品未抽取, 则展⽰下⼀个奖品
信息, 否则展⽰全部中奖名单
iv.
点击’查看上⼀奖项‘按钮,展⽰上⼀个奖品信息
e.
对于抽奖过程中的异常情况,如抽奖过程中刷新⻚⾯,要保证抽取成功的奖项不能重新抽取。
i.
刷新⻚⾯后, 若当前奖品已抽完, 点击"开始抽奖",则直接展⽰当前奖品中奖名单
f.
如该抽奖活动已完成:
i.
展⽰所有奖项的全部中奖名单
ii.
新增"分享结果"按钮, 点击可复制当前⻚链接, 打开后隐藏其他按钮, 只展⽰活动名称与中奖
结果, 保留"分享结果" 按钮
6. 通知部分: 抽奖完成需以邮件和短信⽅式通知中奖者。
a.
“Hi,xxx。恭喜你在xxx抽奖中获得⼀等奖:⼿机。中奖时间为:xx:xx。请尽快领取您的奖
品。”
7.
管理端涉及的所有页面, 包括抽奖页,需强制管理员登录后⽅可访问。
a.
未登录强制跳转登录页面
二、系统设计
1.系统架构
前端:使⽤JavaScript管理各界⾯的动态性,使⽤AJAX技术从后端API获取数据。
后端:采⽤Spring Boot3构建后端应⽤,实现业务逻辑。
数据库:使⽤MySQL作为主数据库,存储⽤⼾数据和活动信息。
缓存:使⽤Redis作为缓存层,减少数据库访问次数。
消息队列:使⽤RabbitMQ处理异步任务,如处理抽奖⾏为。
⽇志与安全:使⽤JWT进⾏⽤⼾认证,使⽤SLF4J+logback完成⽇志。
2.项目环境
编程语⾔:Java(后端),JavaScript(前端)。
开发⼯具包:JDK 17
后端框架:Spring Boot3。
数据库:MySQL。
缓存:Redis。
消息队列:RabbitMQ。
⽇志:logback。
安全:JWT + 加密。
3.数据库设计
E-R图:
三、项目启动
代码结构设计
代码结构设计参考《阿⾥巴巴Java开发⼿册》-- 第六章 ⼯程结构

四、功能模块设计
通用处理
1.错误码
定义错误码类型:
定义全局错误码:
定义业务错误码---controller层错误:
2.自定义异常类
3.统一返回格式
4.Jackson实现序列化和反序列化
import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.boot.json.JsonParseException;
import org.springframework.util.ReflectionUtils;
import java.util.List;
import java.util.concurrent.Callable;
public class JacksonUtil {
private JacksonUtil() {
}
private final static ObjectMapper OBJECT_MAPPER;
static {
OBJECT_MAPPER=new ObjectMapper();
}
private static ObjectMapper getObjectMapper () {
return OBJECT_MAPPER;
}
private static <T> T tryParse(Callable<T> parser) {
return tryParse(parser,JsonParseException.class);
}
private static <T> T tryParse(Callable<T> parser, Class<? extends Exception> check) {
try {
return parser.call();
} catch (Exception var4) {
if (check.isAssignableFrom(var4.getClass())) {
throw new JsonParseException(var4);
}
throw new IllegalStateException(var4);
}
}
/**
* 序列化
* @param object
* @return
*/
public static String writeValueAsString (Object object) {
return tryParse(()->{
return JacksonUtil.getObjectMapper().writeValueAsString(object);
});
}
/**
* 反序列化
* @param content
* @param valueType
* @return
* @param <T>
*/
public static <T> T readValue (String content,Class<T> valueType) {
return tryParse(()->{
return JacksonUtil.getObjectMapper().readValue(content,valueType);
});
}
/**
* List集合反序列化
* @param content
* @param valueType
* @return
* @param <T>
*/
public static <T> T readList (String content,Class<?> valueType) {
JavaType javaType = JacksonUtil.getObjectMapper()
.getTypeFactory()
.constructParametricType(List.class,valueType);
return tryParse(()->{
return JacksonUtil.getObjectMapper().readValue(content,javaType);
});
}
}
五、用户模块
1.注册
1.1敏感字段加密
⼀般来说,用户注册时,需要输⼊其账⼾密码及手机号,服务器应该将其保存起来,⽅便后续登录验证。但仅从道德的⻆度来说,后端不应该以明文形式存储用户密码以及其他敏感信息。
从运维层⾯看,任何操作系统漏洞、基础⼯具漏洞的发⽣,都会导致密码泄露
从开发层⾯看,任何代码逻辑有漏洞、任何依赖库的漏洞都可能导致密码泄露
从管理层⾯看,任何⼀个有权限读取数据库的⼈,都能看到所有用户的密码
1.2时序图
1.3 约定前后端交互接口
[请求] /register POST
{
"name":"张三",
"mail":"451@qq.com",
"phoneNumber":"13188888888",
"password":"123456789",
"identity":"ADMIN"
}
"code": 200,
"data": {
"userId": 22
},
"msg": ""
}
1.4 Controller层代码设计
@RestController
public class UserController {
private static final Logger logger = LoggerFactory.getLogger(UserController.class);
@Autowired
UserService userService;
@Autowired
VerificationCodeService verificationCodeService;
/**
* 注册
* @param userRegisterParam
* @return
*/
@RequestMapping("/register")
public CommonResult<UserRegisterResult> userRegister(
@Validated @RequestBody UserRegisterParam userRegisterParam){
logger.info("userRegister UserRegisterParam:{}", JacksonUtil.writeValueAsString(userRegisterParam));
UserRegisterDTO userRegisterDTO = userService.register(userRegisterParam);
return CommonResult.success(converToUserRegisterResult(userRegisterDTO));
}
private UserRegisterResult converToUserRegisterResult(UserRegisterDTO userRegisterDTO) {
if(null == userRegisterDTO) {
throw new ControllerException(ControllerErrorCodeConstants.REGISTER_ERROR);
}
UserRegisterResult result=new UserRegisterResult();
result.setUserId(userRegisterDTO.getUserId());
return result;
}
}
UserRegisterParam
@Data
public class UserRegisterParam implements Serializable {
/**
* 姓名
*/
@NotBlank(message = "姓名不能为空!")
private String name;
/**
* 邮箱
*/
@NotBlank(message = "邮箱不能为空!")
private String mail;
/**
* 电话
*/
@NotBlank(message = "电话不能为空!")
private String phoneNumber;
/**
* 密码
*/
private String password;
/**
* 身份信息
*/
@NotBlank(message = "身份信息不能为空!")
private String identity;
}
UserIdentityEnum
@Getter
@AllArgsConstructor
public enum UserIdentityEnum {
ADMIN("管理员"),
NORMAL("普通用户");
private final String massage;
public static UserIdentityEnum forName (String name) {
for(UserIdentityEnum userIdentifyEnum : UserIdentityEnum.values()) {
if(userIdentifyEnum.name().equalsIgnoreCase(name)) {
return userIdentifyEnum;
}
}
return null;
}
}
UserRegisterResult
@Data
@AllArgsConstructor
@NoArgsConstructor
public class UserRegisterResult {
private Long userId;
}
1.5 Service层接口设计
进行接口分离设计,这样设计有助于创建更加灵活多变的可维护,可扩展的软件系统。
public interface UserService {
/**
* 注册
* @param userRegisterParam
* @return
*/
UserRegisterDTO register(UserRegisterParam userRegisterParam);
}
UserRegisterDTO
@Data
public class UserRegisterDTO implements Serializable {
private Long userId;
}
接口实现
@Service
public class UserServiceImpl implements UserService {
@Autowired
UserMapper userMapper;
@Autowired
VerificationCodeService verificationCodeService;
@Override
public UserRegisterDTO register(UserRegisterParam Param) {
checkRegisterInfo(Param);
UserDO userDO=new UserDO();
userDO.setUserName(Param.getName());
userDO.setIdentity(Param.getIdentity());
userDO.setEmail(Param.getMail());
userDO.setPhoneNumber(new Encrypt(Param.getPhoneNumber()));
if(StringUtils.hasText(Param.getPassword())) {
userDO.setPassword(DigestUtil.sha256Hex(Param.getPassword()));
}
userMapper.insert(userDO);
UserRegisterDTO userRegisterDTO = new UserRegisterDTO();
userRegisterDTO.setUserId(userDO.getId());
return userRegisterDTO;
}
private void checkRegisterInfo(UserRegisterParam userRegisterParam) {
/**
* 注册信息为空
*/
if(null == userRegisterParam) {
throw new ServiceException(ServiceErrorCodeConstants.REGISTER_INFO_IS_EMPTY);
}
//检验邮箱格式
if(!RegexUtil.checkMail(userRegisterParam.getMail())) {
throw new ServiceException(ServiceErrorCodeConstants.MAIL_ERROR);
}
//检验手机号格式
if(!RegexUtil.checkMobile(userRegisterParam.getPhoneNumber())) {
throw new ServiceException(ServiceErrorCodeConstants.PHONE_NUMBER_ERROR);
}
//校验身份信息
if(null == UserIdentityEnum.forName(userRegisterParam.getIdentity())) {
throw new ServiceException(ServiceErrorCodeConstants.IDENTITY_ERROR);
}
//管理员密码不能为空
if(userRegisterParam.getIdentity().equalsIgnoreCase(UserIdentityEnum.ADMIN.name())
&& !StringUtils.hasText(userRegisterParam.getPassword())) {
throw new ServiceException(ServiceErrorCodeConstants.PASSWORD_IS_EMPTY);
}
//校验密码,至少六位
if(StringUtils.hasText(userRegisterParam.getPassword())
&&!RegexUtil.checkPassword(userRegisterParam.getPassword())) {
throw new ServiceException(ServiceErrorCodeConstants.PASSWORD_ERROR);
}
//检验邮箱是否被使用
if(checkMailUsed(userRegisterParam.getMail())) {
throw new ServiceException(ServiceErrorCodeConstants.MAIL_USED);
}
//校验手机号是否被使用
if(checkPhoneNumberUsed(userRegisterParam.getPhoneNumber())) {
throw new ServiceException(ServiceErrorCodeConstants.PHONE_NUMBER_USED);
}
}
private boolean checkPhoneNumberUsed(String phoneNumber) {
int count = userMapper.countByPhoneNumber(new Encrypt(phoneNumber));
return count > 0;
}
private boolean checkMailUsed(String mail) {
int ret=userMapper.countByMail(mail);
return ret > 0;
}
}
其中校验⽤⼾信息,例如邮箱、电话、密码格式的内容,我们封装成了⼀个 util 来完成:
RegexUtil
public class RegexUtil {
/**
* 邮箱:xxx@xx.xxx(形如:abc@qq.com)
*
* @param content
* @return
*/
public static boolean checkMail(String content) {
if (!StringUtils.hasText(content)) {
return false;
}
/**
* ^ 表示匹配字符串的开始。
* [a-z0-9]+ 表示匹配一个或多个小写字母或数字。
* ([._\\-]*[a-z0-9])* 表示匹配零次或多次下述模式:一个点、下划线、反斜杠或短横线,后面跟着一个或多个小写字母或数字。这部分是可选的,并且可以重复出现。
* @ 字符字面量,表示电子邮件地址中必须包含的"@"符号。
* ([a-z0-9]+[-a-z0-9]*[a-z0-9]+.) 表示匹配一个或多个小写字母或数字,后面可以跟着零个或多个短横线或小写字母和数字,然后是一个小写字母或数字,最后是一个点。这是匹配域名的一部分。
* {1,63} 表示前面的模式重复1到63次,这是对顶级域名长度的限制。
* [a-z0-9]+ 表示匹配一个或多个小写字母或数字,这是顶级域名的开始部分。
* $ 表示匹配字符串的结束。
*/
String regex = "^[a-z0-9]+([._\\\\-]*[a-z0-9])*@([a-z0-9]+[-a-z0-9]*[a-z0-9]+.){1,63}[a-z0-9]+$";
return Pattern.matches(regex, content);
}
/**
* 手机号码以1开头的11位数字
*
* @param content
* @return
*/
public static boolean checkMobile(String content) {
if (!StringUtils.hasText(content)) {
return false;
}
/**
* ^ 表示匹配字符串的开始。
* 1 表示手机号码以数字1开头。
* [3|4|5|6|7|8|9] 表示接下来的数字是3到9之间的任意一个数字。这是中国大陆手机号码的第二位数字,通常用来区分不同的运营商。
* [0-9]{9} 表示后面跟着9个0到9之间的任意数字,这代表手机号码的剩余部分。
* $ 表示匹配字符串的结束。
*/
String regex = "^1[3|4|5|6|7|8|9][0-9]{9}$";
return Pattern.matches(regex, content);
}
/**
* 密码强度正则,6到12位
*
* @param content
* @return
*/
public static boolean checkPassword(String content){
if (!StringUtils.hasText(content)) {
return false;
}
/**
* ^ 表示匹配字符串的开始。
* [0-9A-Za-z] 表示匹配的字符可以是:
* 0-9:任意一个数字(0到9)。
* A-Z:任意一个大写字母(从A到Z)。
* a-z:任意一个小写字母(从a到z)。
* {6,12} 表示前面的字符集合(数字、大写字母和小写字母)可以重复出现6到12次。
* $ 表示匹配字符串的结束。
*/
String regex= "^[0-9A-Za-z]{6,12}$";
return Pattern.matches(regex, content);
}
}
这里将手机号类型设置为自定义的Encrypt,是为了让我们存取手机号不需要每次都进行手动加密解密的复杂操作,所以我们使用MyBatis的TypeHandler来处理。
TypeHandler:简单理解就是当处理某些特定字段时,我们可以实现一些方法,让Mybatis遇到这些字段可以自动运行处理。
1.6 Dao层接口设计
@Mapper
public interface UserMapper {
/**
* 获取邮箱绑定的用户数
* @param email
* @return
*/
@Select("select count(*) from user where email=#{email}")
int countByMail(@Param("email") String email);
/**
* 获取手机号绑定的用户数
* @param phoneNumber
* @return
*/
@Select("select count(*) from user where phone_number=#{phoneNumber}")
int countByPhoneNumber(@Param("phoneNumber") Encrypt phoneNumber);
/**
* 注册用户
* @param userDO
*/
@Insert("insert into user (user_name,email,phone_number,identity,password)"+
" values (#{userName},#{email},#{phoneNumber},#{identity},#{password})")
@Options(useGeneratedKeys = true,keyProperty = "id",keyColumn = "id")
void insert(UserDO userDO);
}
UserDO
@Data
@EqualsAndHashCode(callSuper = true)
public class UserDO extends BaseDO {
//用户名
private String userName;
//邮箱
private String email;
//电话号
private Encrypt phoneNumber;
//密码
private String password;
//身份信息
private String identity;
}
BaseDO
@Data
public class BaseDO implements Serializable {
//主键
private Long id;
//创建时间
private Date gmtCreate;
//修改时间
private Date gmtModified;
}
Encrypt
@Data
public class Encrypt {
private String value;
public Encrypt(){}
public Encrypt(String value) {
this.value = value;
}
}
EncryptTypeHandler
@MappedTypes(Encrypt.class)
@MappedJdbcTypes(JdbcType.VARCHAR)
public class EncryptTypeHandler extends BaseTypeHandler<Encrypt> {
private final byte[] KEY = "123456789abcdefg".getBytes();
/**
* 设置参数
* @param ps SQL预编译的对象
* @param i 需要赋值的索引位置
* @param parameter 原本i位置要赋的值
* @param jdbcType jdbc类型
* @throws SQLException
*/
@Override
public void setNonNullParameter(PreparedStatement ps, int i, Encrypt parameter, JdbcType jdbcType) throws SQLException {
if(parameter == null||parameter.getValue() == null) {
ps.setString(i,null);
}
System.out.println("加密的内容:"+parameter.getValue());
AES aes = SecureUtil.aes(KEY);
String str = aes.encryptHex(parameter.getValue());
ps.setString(i,str);
}
/**
* 获取值
* @param rs 结果集合
* @param columnName 索引名
* @return
* @throws SQLException
*/
@Override
public Encrypt getNullableResult(ResultSet rs, String columnName) throws SQLException {
System.out.println("获取值得到的加密内容:" + rs.getString(columnName));
return decrypt(rs.getString(columnName));
}
/**
* 获取值
* @param rs 结果集合
* @param columnIndex 索引位置
* @return
* @throws SQLException
*/
@Override
public Encrypt getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
System.out.println("获取值得到的加密内容:" + rs.getString(columnIndex));
return decrypt(rs.getString(columnIndex));
}
/**
*
* @param cs 结果集合
* @param columnIndex 索引位置
* @return
* @throws SQLException
*/
@Override
public Encrypt getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
System.out.println("获取值得到的加密内容:" + cs.getString(columnIndex));
return decrypt(cs.getString(columnIndex));
}
/**
* 解密
* @return
*/
private Encrypt decrypt (String str) {
if(!StringUtils.hasText(str)) {
return null;
}
return new Encrypt(SecureUtil.aes(KEY).decryptStr(str));
}
}
1.7 全局异常捕获
@RestControllerAdvice
public class GlobalExceptionHandler {
private final static Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler(value = ServiceException.class)
public CommonResult<?> serviceException (ServiceException e) {
log.error("ServiceException:",e);
return CommonResult.error(GlobalErrorCodeConstants.INTERNAL_SERVER_ERROR.getCode()
, e.getMassage());
}
@ExceptionHandler(value = ControllerException.class)
public CommonResult<?> controllerException (ControllerException e) {
log.error("ControllerException:",e);
return CommonResult.error(GlobalErrorCodeConstants.INTERNAL_SERVER_ERROR.getCode()
, e.getMassage());
}
@ExceptionHandler(value = Exception.class)
public CommonResult<?> exception (Exception e) {
log.error("服务异常:",e);
return CommonResult.error(GlobalErrorCodeConstants.INTERNAL_SERVER_ERROR);
}
}
2.登录
发送验证码
在我们的登录设计中,有⼀种登录⽅式为短信验证码登录,那么在登录前,就需要先获取验证码。验证码服务使⽤阿⾥云提供的短信服务来完成。阿里云的短信服务需要自己在阿里云申请以及购买,这边只注重于逻辑实现。
2.2 时序图
2.3 配置application.properties
## 短信 ##
sms.access-key-id=填写⾃⼰申请的
sms.access-key-secret=填写⾃⼰申请的
sms.sign-name=填写⾃⼰申请的
如果不想申请短信服务可以直接砍掉这个功能
2.4 SMSUtil短信工具类
@Component
public class SMSUtil {
private static final Logger logger = LoggerFactory.getLogger(SMSUtil.class);
@Value(value = "${sms.sign-name}")
private String signName;
@Value(value = "${sms.access-key-id}")
private String accessKeyId;
@Value(value = "${sms.access-key-secret}")
private String accessKeySecret;
/**
* 发送短信
*
* @param templateCode 模板号
* @param phoneNumbers 手机号
* @param templateParam 模板参数 {"key":"value"}
*/
public void sendMessage(String templateCode, String phoneNumbers, String templateParam) {
try {
Client client = createClient();
com.aliyun.dysmsapi20170525.models.SendSmsRequest sendSmsRequest = new SendSmsRequest()
.setSignName(signName)
.setTemplateCode(templateCode)
.setPhoneNumbers(phoneNumbers)
.setTemplateParam(templateParam);
RuntimeOptions runtime = new RuntimeOptions();
SendSmsResponse response = client.sendSmsWithOptions(sendSmsRequest, runtime);
if (null != response.getBody()
&& null != response.getBody().getMessage()
&& "OK".equals(response.getBody().getMessage())) {
logger.info("向{}发送信息成功,templateCode={}", phoneNumbers, templateCode);
return;
}
logger.error("向{}发送信息失败,templateCode={},失败原因:{}",
phoneNumbers, templateCode, response.getBody().getMessage());
} catch (TeaException error) {
logger.error("向{}发送信息失败,templ