抽奖系统(4——活动模块)
1. 活动创建
需求回顾
创建的活动信息包含:
- 活动名称
- 活动描述
- 关联的一批奖品,关联时需要选择奖品等级(一等奖、二等奖、三等奖),及奖品库存
- 圈选一批人员参与抽奖
tip:什么时候设置奖品数量和奖品等级?
肯定是在创建活动的时候,这两项不能作为基本属性放到奖品表中(那样无意义,每次创建活动数量和等级可能都不一样),因此需要将其放到活动奖品奖品关联表中,如下:
时序图
tip:为了快速获取抽奖信息,所以将其存放到 Redis 缓存中
约定前后端交互接口
[请求] /activity/create POST
{
"activityName":"测试活动抽奖",
"description":"测试活动抽奖",
"activityPrizeList":[{"prizeId":2, "prizeAmount":1, "prizeTiers":"FIRST_PRIZE"}, {"prizeId":3, "prizeAmount":2, "prizeTiers":"SECOND_PRIZE"}],
"activityUserList":[{"userId":4, "userName":"郭靖"}, {"userId":5, "userName":"黄蓉"}, {"userId":6, "userName":"杨康"}]
}
[响应]
{
"code": 200,
"data": {
"activityId": 1
},
"msg": ""
}
Controller 层接口设计
package com.example.lotterysystem.controller;
import com.example.lotterysystem.common.errorcode.ControllerErrorCodeConstants;
import com.example.lotterysystem.common.exception.ControllerException;
import com.example.lotterysystem.common.pojo.CommonResult;
import com.example.lotterysystem.common.utils.JacksonUtil;
import com.example.lotterysystem.controller.param.CreateActivityParam;
import com.example.lotterysystem.controller.result.CreateActivityResult;
import com.example.lotterysystem.service.ActivityService;
import com.example.lotterysystem.service.dto.CreateActivityDTO;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class ActivityController {
@Autowired
private ActivityService activityService;
private static final Logger logger = LoggerFactory.getLogger(ActivityController.class);
@RequestMapping("/activity/create")
public CommonResult<CreateActivityResult> createActivity(@Validated @RequestBody CreateActivityParam param) {
logger.info("createActivity CreateActivityParam:{}", JacksonUtil.writeValueAsString(param));
return CommonResult.success(convertToCreateActivityResult(activityService.createActivity(param)));
}
private CreateActivityResult convertToCreateActivityResult(CreateActivityDTO createActivityDTO) {
if (null == createActivityDTO) {
throw new ControllerException(ControllerErrorCodeConstants.CREATE_ACTIVITY_ERROR);
}
CreateActivityResult result = new CreateActivityResult();
result.setActivityId(createActivityDTO.getActivityId());
return result;
}
}
CreateActivityParam
package com.example.lotterysystem.controller.param;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import lombok.Data;
import java.io.Serializable;
import java.util.List;
@Data
public class CreateActivityParam implements Serializable {
// 活动名称
@NotBlank(message = "活动名称不能为空!")
private String activityName;
// 活动描述
@NotBlank(message = "活动描述不能为空!")
private String description;
// 活动关联奖品列表
@NotEmpty(message = "活动关联奖品列表不能为空!")
@Valid // 上面 NotEmpty 只能确保 list 不能为空,想要 list 里面 CreatePrizeByActivityParam 的字段不能为空,需要加上 Valid
private List<CreatePrizeByActivityParam> activityPrizelist;
// 活动关联人员列表
@NotEmpty(message = "活动关联人员列表不能为空!")
@Valid
private List<CreateUserByActivityParam> activityUserlist;
}
CreateUserByActivityParam
package com.example.lotterysystem.controller.param;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.io.Serializable;
@Data
public class CreateUserByActivityParam implements Serializable {
// 活动关联的人员id
@NotNull(message = "活动关联的人员 id 不能为空!")
private Long userId;
// 活动关联的人员姓名
@NotBlank(message = "活动关联的人员姓名不能为空!")
private String userName;
}
ControllerErrorCodeConstants
// package com.example.lotterysystem.common.errorcode;
// ------ 活动模块错误码 ------
ErrorCode CREATE_ACTIVITY_ERROR = new ErrorCode(300, "活动创建失败!");
Service 层接口设计
package com.example.lotterysystem.service;
import com.example.lotterysystem.controller.param.CreateActivityParam;
import com.example.lotterysystem.service.dto.CreateActivityDTO;
public interface ActivityService {
// 创建活动
CreateActivityDTO createActivity(CreateActivityParam param);
}
CreateActivityDTO
package com.example.lotterysystem.service.dto;
import lombok.Data;
@Data
public class CreateActivityDTO {
// 活动id
private Long activityId;
}
接口实现
package com.example.lotterysystem.service.impl;
import com.example.lotterysystem.common.errorcode.ServiceErrorCodeConstants;
import com.example.lotterysystem.common.exception.ServiceException;
import com.example.lotterysystem.common.utils.JacksonUtil;
import com.example.lotterysystem.common.utils.RedisUtil;
import com.example.lotterysystem.controller.param.CreateActivityParam;
import com.example.lotterysystem.controller.param.CreatePrizeByActivityParam;
import com.example.lotterysystem.controller.param.CreateUserByActivityParam;
import com.example.lotterysystem.dao.dataobject.ActivityDO;
import com.example.lotterysystem.dao.dataobject.ActivityPrizeDO;
import com.example.lotterysystem.dao.dataobject.ActivityUserDO;
import com.example.lotterysystem.dao.dataobject.PrizeDO;
import com.example.lotterysystem.dao.mapper.*;
import com.example.lotterysystem.service.ActivityService;
import com.example.lotterysystem.service.dto.ActivityDetailDTO;
import com.example.lotterysystem.service.dto.CreateActivityDTO;
import com.example.lotterysystem.service.enums.ActivityPrizeStatusEnum;
import com.example.lotterysystem.service.enums.ActivityPrizeTiersEnum;
import com.example.lotterysystem.service.enums.ActivityStatusEnum;
import com.example.lotterysystem.service.enums.ActivityUserStatusEnum;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.web.servlet.View;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
@Service
public class ActivityServiceImpl implements ActivityService {
private static final Logger logger = LoggerFactory.getLogger(ActivityServiceImpl.class);
// 为了区分业务,约定活动缓存前缀
private final String ACTIVITY_PREFIX = "ACTIVITY_";
// 活动缓存过期时间
private final Long ACTIVITY_TIMEOUT = 60 * 60 * 24 * 3L;
@Autowired
private UserMapper userMapper;
@Autowired
private PrizeMapper prizeMapper;
@Autowired
private ActivityMapper activityMapper;
@Autowired
private ActivityUserMapper activityUserMapper;
@Autowired
private ActivityPrizeMapper activityPrizeMapper;
@Autowired
private RedisUtil redisUtil;
@Autowired
private View error;
@Override
@Transactional(rollbackFor = Exception.class) // 由于该方法涉及到多表,因此添加事务
public CreateActivityDTO createActivity(CreateActivityParam param) {
// 校验活动信息是否正确
checkActivityInfo(param);
// 保存活动信息
ActivityDO activityDO = new ActivityDO();
activityDO.setActivityName(param.getActivityName());
activityDO.setDescription(param.getDescription());
activityDO.setStaus(ActivityStatusEnum.RUNNING.name());
activityMapper.insert(activityDO);
// 保存活动关联的奖品信息
List<CreatePrizeByActivityParam> prizeParams = param.getActivityPrizelist();
List<ActivityPrizeDO> activityPrizeDOList = prizeParams.stream()
.map(prizeParam -> {
ActivityPrizeDO activityPrizeDO = new ActivityPrizeDO();
activityPrizeDO.setActivityId(activityDO.getId());
activityPrizeDO.setPrizeId(prizeParam.getPrizeId());
activityPrizeDO.setPrizeAmount(prizeParam.getPrizeAmount());
activityPrizeDO.setPrizeTiers(prizeParam.getPrizeTiers());
activityPrizeDO.setStatus(ActivityPrizeStatusEnum.INIT.name());
return activityPrizeDO;
}).collect(Collectors.toList());
activityPrizeMapper.batchInsert(activityPrizeDOList);
// 保存活动关联的人员信息
List<CreateUserByActivityParam> userParams = param.getActivityUserlist();
List<ActivityUserDO> activityUserDOList = userParams.stream()
.map(userParam -> {
ActivityUserDO activityUserDO = new ActivityUserDO();
activityUserDO.setActivityId(activityDO.getId());
activityUserDO.setUserId(userParam.getUserId());
activityUserDO.setUserName(userParam.getUserName());
activityUserDO.setStatus(ActivityUserStatusEnum.INIT.name());
return activityUserDO;
}).collect(Collectors.toList());
activityUserMapper.batchInsert(activityUserDOList);
// 整合完整的活动信息,存放 redis
// ActivityDetailDTO 用于存放完整的活动信息(包括 活动数据、奖品数据、人员数据)
// 使用 activityId 这个键,来找到响应的值(ActivityDetailDTO)
// 先获取奖品基本属性表
// 获取需要查询的奖品id
List<Long> prizeIds = param.getActivityPrizelist().stream()
.map(CreatePrizeByActivityParam::getPrizeId)
.distinct()
.collect(Collectors.toList());
List<PrizeDO> prizeDOList = prizeMapper.batchSelectByIds(prizeIds);
ActivityDetailDTO detailDTO = convertToActivityDetailDTO(activityDO, activityUserDOList, prizeDOList, activityPrizeDOList);
cacheActivity(detailDTO);
// 构造返回
CreateActivityDTO createActivityDTO = new CreateActivityDTO();
createActivityDTO.setActivityId(activityDO.getId());
return createActivityDTO;
}
// 缓存完整的活动信息 ActivityDetailDTO
private void cacheActivity(ActivityDetailDTO detailDTO) {
// key: ACTIVITY_12
// value: ActivityDetailDTO(需先转为 json)
if (null == detailDTO || null == detailDTO.getActivityId()) {
logger.warn("要缓存的活动信息不存在!");
return;
}
/**
* 前面创建三张表时,遇到异常会进行回滚,但这里缓存完整活动信息时遇到异常是不需要回滚的
* 因为即使 redis 没有缓存成功,当抽奖端查询活动信息时依然能根据前面创建好的三张表重新将活动缓存到 redis 中
* 因此此处用 try catch 包住该段代码,使其即使抛出异常,也不会触发回滚
*/
try {
redisUtil.set(ACTIVITY_PREFIX+detailDTO.getActivityId(),
JacksonUtil.writeValueAsString(detailDTO),
ACTIVITY_TIMEOUT);
} catch (Exception e) {
logger.error("缓存活动异常,ActivityDetailDTO={}", JacksonUtil.writeValueAsString(detailDTO), e);
}
}
// 根据活动 id 从缓存中获取活动详细信息
private ActivityDetailDTO getActivityFromCache(Long activityId) {
if (null == activityId) {
logger.warn("获取缓存活动数据的 activityId 为空!");
return null;
}
try {
String str = redisUtil.get(ACTIVITY_PREFIX + activityId);
if (!StringUtils.hasText(str)) {
logger.info("获取的缓存活动数据为空!key={}", ACTIVITY_PREFIX + activityId);
return null;
}
return JacksonUtil.readValue(str, ActivityDetailDTO.class);
} catch (Exception e) {
logger.error("从缓存中获取活动信息异常,key={}", ACTIVITY_PREFIX + activityId, e);
return null;
}
}
private ActivityDetailDTO convertToActivityDetailDTO(ActivityDO activityDO,
List<ActivityUserDO> activityUserDOList,
List<PrizeDO> prizeDOList,
List<ActivityPrizeDO> activityPrizeDOList) {
ActivityDetailDTO detailDTO = new ActivityDetailDTO();
detailDTO.setActivityId(activityDO.getId());
detailDTO.setActivityName(activityDO.getActivityName());
detailDTO.setDesc(activityDO.getDescription());
detailDTO.setStatus(ActivityStatusEnum.forName(activityDO.getStaus()));
// apDO(活动关联奖品属性): {prizeId, amount, status}, {prizeId, amount, status}
// pDO(奖品基础属性): {prizeId, name...}, {prizeId, name...}, {prizeId, name...}
List<ActivityDetailDTO.PrizeDTO> prizeDTOList = activityPrizeDOList.stream()
.map(apDO -> {
ActivityDetailDTO.PrizeDTO prizeDTO = new ActivityDetailDTO.PrizeDTO();
prizeDTO.setPrizeId(apDO.getPrizeId());
// Optional<> 防止对象存在空指针异常
//.stream() 对 prizeDOList 进行流式处理,拿到每一个 prizeDO 和外层 apDO 的 id 去进行对比
// .findFirst() 返回遇到的第一个 id 相等的对象
Optional<PrizeDO> optionalPrizeDO = prizeDOList.stream()
.filter(prizeDO -> prizeDO.getId().equals(apDO.getPrizeId()))
.findFirst();
// ifPresent:如果 PrizeDO 不为空,才执行该方法,不用自己写 if 判断 PrizeDO 是否为空了
optionalPrizeDO.ifPresent(prizeDO -> {
// 下面四个属性需要遍历 pDO,根据 apDO 中的 prizeId 和 pDO 中的 prizeId 找到对应的属性进行设置
prizeDTO.setName(prizeDO.getName());
prizeDTO.setImageUrl(prizeDO.getImageUrl());
prizeDTO.setPrice(prizeDO.getPrice());
prizeDTO.setDescription(prizeDO.getDescription());
});
prizeDTO.setTiers(ActivityPrizeTiersEnum.forName(apDO.getPrizeTiers()));
prizeDTO.setPrizeAmount(apDO.getPrizeAmount());
prizeDTO.setStatus(ActivityPrizeStatusEnum.forName(apDO.getStatus()));
return prizeDTO;
}).collect(Collectors.toList());
detailDTO.setPrizeDTOList(prizeDTOList);
// auDO 就是 activityUserDO
List<ActivityDetailDTO.UserDTO> userDTOList = activityUserDOList.stream()
.map(auDO -> {
ActivityDetailDTO.UserDTO userDTO = new ActivityDetailDTO.UserDTO();
userDTO.setUserId(auDO.getUserId());
userDTO.setUserName(auDO.getUserName());
userDTO.setStatus(ActivityUserStatusEnum.forName(auDO.getStatus()));
return userDTO;
}).collect(Collectors.toList());
detailDTO.setUserDTOList(userDTOList);
return detailDTO;
}
// 校验活动有效性
private void checkActivityInfo(CreateActivityParam param) {
if(null == param) {
throw new ServiceException(ServiceErrorCodeConstants.CREATE_ACTIVITY_INFO_IS_EMPTY);
}
// 人员 id 在人员表中是否存在
List<Long> userIds = param.getActivityUserlist()
.stream()
.map(CreateUserByActivityParam::getUserId)
.distinct() // 去重
.collect(Collectors.toList());
// 假设传过去的参数为 1 2 3,若人员表中只存在 1 2,则只返回 1 2
List<Long> existUserIds = userMapper.selectExistByIds(userIds);
userIds.forEach(id -> {
// 若 userMapper 返回的人员表参数中不包含某个创建的活动关联人员id,则抛出异常
if (!existUserIds.contains(id)) {
throw new ServiceException(ServiceErrorCodeConstants.ACTIVITY_USER_ERROR);
}
});
// 奖品 id 在奖品表中是否存在
List<Long> prizeIds = param.getActivityPrizelist()
.stream()
.map(CreatePrizeByActivityParam::getPrizeId)
.distinct()
.collect(Collectors.toList());
List<Long> existPrizeIds = prizeMapper.selectExistByIds(prizeIds);
prizeIds.forEach(id -> {
if (!existPrizeIds.contains(id)) {
throw new ServiceException(ServiceErrorCodeConstants.ACTIVITY_PRIZE_ERROR);
}
});
// 人员数量是否大于等于奖品数量
int userAmount = param.getActivityUserlist().size();
long prizeAmount = param.getActivityPrizelist()
.stream()
.mapToLong(CreatePrizeByActivityParam::getPrizeAmount)
.sum();
if (userAmount < prizeAmount) {
throw new ServiceException(ServiceErrorCodeConstants.USER_PRIZE_AMOUNT_ERROR);
}
// 校验活动奖品等级有效性(看传入参数是否为一等二等三等奖)
param.getActivityPrizelist().forEach(prize -> {
if (null == ActivityPrizeTiersEnum.forName(prize.getPrizeTiers())) {
throw new ServiceException(ServiceErrorCodeConstants.ACTIVITY_PRIZE_TIERS_ERROR);
}
});
}
}
插件:GenerateAllSetter 快速生成 Set 方法
活动/活动奖品/活动人员状态/奖品等级 枚举类(Enum)
package com.example.lotterysystem.service.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
// 活动状态
@AllArgsConstructor
@Getter
public enum ActivityStatusEnum {
RUNNING(1, "活动进行中"),
COMPLETED(2, "活动已完成");
private final Integer code;
private final String message;
public static ActivityStatusEnum forName(String name) {
for (ActivityStatusEnum activityStatusEnum : ActivityStatusEnum.values()) {
if (activityStatusEnum.name().equalsIgnoreCase(name)) {
return activityStatusEnum;
}
}
return null;
}
}
package com.example.lotterysystem.service.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
// 活动奖品状态
@AllArgsConstructor
@Getter
public enum ActivityPrizeStatusEnum {
INIT(1, "初始状态"),
COMPLETED(2, "已被抽取");
private final Integer code;
private final String message;
public static ActivityPrizeStatusEnum forName(String name) {
for (ActivityPrizeStatusEnum activityPrizeStatusEnum : ActivityPrizeStatusEnum.values()) {
if (activityPrizeStatusEnum.name().equalsIgnoreCase(name)) {
return activityPrizeStatusEnum;
}
}
return null;
}
}
package com.example.lotterysystem.service.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
// 活动人员状态
@AllArgsConstructor
@Getter
public enum ActivityUserStatusEnum {
INIT(1, "初始状态"),
COMPLETED(2, "已中奖");
private final Integer code;
private final String message;
public static ActivityUserStatusEnum forName(String name) {
for (ActivityUserStatusEnum activityUserStatusEnum : ActivityUserStatusEnum.values()) {
if (activityUserStatusEnum.name().equalsIgnoreCase(name)) {
return activityUserStatusEnum;
}
}
return null;
}
}
package com.example.lotterysystem.service.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
// 奖品等级
@AllArgsConstructor
@Getter
public enum ActivityPrizeTiersEnum {
FIRST_PRIZE(1, "一等奖"),
SECOND_PRIZE(2, "二等奖"),
THIRD_PRIZE(3, "三等奖");
private final Integer code;
private final String message;
public static ActivityPrizeTiersEnum forName(String name) {
for (ActivityPrizeTiersEnum activityPrizeTiersEnum : ActivityPrizeTiersEnum.values()) {
if (activityPrizeTiersEnum.name().equalsIgnoreCase(name)) {
return activityPrizeTiersEnum;
}
}
return null;
}
}
ActivityDetailDTO
package com.example.lotterysystem.service.dto;
import com.example.lotterysystem.service.enums.ActivityPrizeStatusEnum;
import com.example.lotterysystem.service.enums.ActivityPrizeTiersEnum;
import com.example.lotterysystem.service.enums.ActivityStatusEnum;
import com.example.lotterysystem.service.enums.ActivityUserStatusEnum;
import lombok.Data;
import java.math.BigDecimal;
import java.util.List;
@Data
public class ActivityDetailDTO {
// 活动信息
// 活动id
private Long activityId;
// 活动名称
private String activityName;
// 活动描述
private String desc;
// 活动状态
private ActivityStatusEnum status;
// 通过该方法判断当前活动是否有效
public Boolean valid() {
return status.equals(ActivityStatusEnum.RUNNING);
}
// 奖品信息(列表)
private List<PrizeDTO> prizeDTOList;
// 人员信息(列表)
private List<UserDTO> userDTOList;
@Data
public static class PrizeDTO {
// 奖品id
private Long prizeId;
// 奖品名
private String name;
// 图片索引
private String imageUrl;
// 价格
private BigDecimal price;
// 描述
private String description;
// 奖品等级
private ActivityPrizeTiersEnum tiers;
// 奖品数量
private Long prizeAmount;
// 奖品状态
private ActivityPrizeStatusEnum status;
// 通过该方法判断当前奖品是否被抽取
public Boolean valid() {
return status.equals(ActivityPrizeStatusEnum.INIT);
}
}
@Data
public static class UserDTO {
// 用户id
private Long userId;
// 姓名
private String userName;
// 状态
private ActivityUserStatusEnum status;
// 通过该方法判断当前人员是否中奖
public Boolean valid() {
return status.equals(ActivityUserStatusEnum.INIT);
}
}
}
ServiceErrorCodeConstants
// package com.example.lotterysystem.common.errorcode;
// ------ 活动模块错误码 ------
ErrorCode CREATE_ACTIVITY_INFO_IS_EMPTY = new ErrorCode(300, "创建的活动信息为空!");
ErrorCode ACTIVITY_USER_ERROR = new ErrorCode(301, "活动关联的人员不存在!");
ErrorCode ACTIVITY_PRIZE_ERROR = new ErrorCode(302, "活动关联的奖品不存在!");
ErrorCode USER_PRIZE_AMOUNT_ERROR = new ErrorCode(303, "活动关联的人员数量必须等于或大于奖品数量!");
ErrorCode ACTIVITY_PRIZE_TIERS_ERROR = new ErrorCode(304, "活动奖品等级设置错误!");
Dao 层接口设计
ActivityMapper
package com.example.lotterysystem.dao.mapper;
import com.example.lotterysystem.dao.dataobject.ActivityDO;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Options;
@Mapper
public interface ActivityMapper {
@Insert("insert into activity (activity_name, description, status) + values (#{activityName}, #{description}, #{status})")
@Options(useGeneratedKeys = true, keyProperty = "id", keyColumn = "id")
int insert(ActivityDO activityDO);
}
ActivityDO
package com.example.lotterysystem.dao.dataobject;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
public class ActivityDO extends BaseDO{
// 活动名称
private String activityName;
// 活动描述
private String description;
// 活动状态
private String staus;
}
ActivityPrizeMapper
package com.example.lotterysystem.dao.mapper;
import com.example.lotterysystem.dao.dataobject.ActivityPrizeDO;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Options;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface ActivityPrizeMapper {
@Insert("<script>" +
" insert into activity_prize (activity_id, prize_id, prize_amount, prize_tiers, status)" +
" values <foreach collection = 'items' item='item' index='index' separator=','" +
" (#{item.activityId}, #{item.prizeId}, #{item.prizeAmount}, #{item.prizeTiers} #{item.status})" +
" </foreach>" +
" </script>")
@Options(useGeneratedKeys = true, keyProperty = "id", keyColumn = "id")
int batchInsert(@Param("items") List<ActivityPrizeDO> activityPrizeDOList);
}
ActivityPrizeDO
package com.example.lotterysystem.dao.dataobject;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
public class ActivityPrizeDO extends BaseDO{
// 关联的活动id
private Long activityId;
// 关联的奖品id
private Long prizeId;
// 奖品数量
private Long prizeAmount;
// 奖品等级
private String prizeTiers;
// 奖品状态
private String status;
}
PrizeMapper 新增接口
// package com.example.lotterysystem.dao.mapper;
@Select("<script>" +
" select id from prize" +
" where id in" +
" <foreach item='item' collection='items' open='(' separator=',' close=')'>" +
" #{item}" +
" </foreach>" +
" </script>")
List<Long> selectExistByIds(@Param("items") List<Long> ids);
@Select("<script>" +
" select * from prize" +
" where id in" +
" <foreach item='item' collection='items' open='(' separator=',' close=')'>" +
" #{item}" +
" </foreach>" +
" </script>")
List<PrizeDO> batchSelectByIds(@Param("items") List<Long> ids);
ActivityUserMapper
package com.example.lotterysystem.dao.mapper;
import com.example.lotterysystem.dao.dataobject.ActivityUserDO;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Options;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface ActivityUserMapper {
@Insert("<script>" +
" insert into activity_user (activity_id, user_id, user_name, status)" +
" values <foreach collection = 'items' item='item' index='index' separator=','" +
" (#{item.activityId}, #{item.userId}, #{item.userName}, #{item.status})" +
" </foreach>" +
" </script>")
@Options(useGeneratedKeys = true, keyProperty = "id", keyColumn = "id")
int batchInsert(@Param("items") List<ActivityUserDO> activityUserDOList);
}
ActivityUserDO
package com.example.lotterysystem.dao.dataobject;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
public class ActivityUserDO extends BaseDO{
// 关联的活动id
private Long activityId;
// 关联的人员id
private Long userId;
// 姓名
private String userName;
// 关联人员状态
private String status;
}
Postman 测试
先启动 redis 服务
使用无效的人员数据进行测试:
使用无效的奖品数据测试:
使用抽奖人员比奖品少的数据进行测试:
使用有效数据测试:
查看数据库
活动表里存放了新建的活动:
活动奖品关联表中存放了活动关联的奖品:
活动人员关联表中存放了活动关联的人员:
查看 Redis,存放了新建的活动
活动创建页面前端实现
在创建活动之前,需要先查询到奖品列表和用户列表才能提供给前端进行圈选
// create-activity.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>创建抽奖活动</title>
<link rel="stylesheet" href="https://cdn.staticfile.org/twitter-bootstrap/4.5.2/css/bootstrap.min.css">
<link rel="stylesheet" href="./css/base.css">
<link rel="stylesheet" href="./css/toastr.min.css">
<style>
body {
font-family: Arial, sans-serif;
background-color: #fff;
margin: 0;
padding: 0;
}
.container {
max-width: 800px;
margin: 30px auto;
padding: 20px 30px;
background-color: #fff;
}
.prize-checkbox {
margin-bottom: 10px;
}
.modal {
display: none; /* 初始状态下模态框不可见 */
position: fixed;
z-index: 1;
left: 0;
top: 0;
bottom: 0;
right: 0;
overflow: hidden;
overflow-y: auto;
background-color: rgba(0, 0, 0, 0.1);
}
.modal-content {
background-color: #fefefe;
margin: 5% auto;
padding: 20px;
border: 1px solid #888;
width: 610px;
}
.modal-content h2{
display: flex;
justify-content: space-between;
align-items: center;
font-weight: 600;
font-size: 18px;
color: #000000;
height: 50px;
border-bottom: 1px solid #DEDEDE;
margin-bottom: 30px;
}
#prizesContainer{
height: 406px;
margin-bottom: 40px;
overflow-y: auto;
padding: 0 26px;
}
.close {
color: #000;
float: right;
font-size: 28px;
cursor: pointer;
}
.close:hover,
.close:focus {
color: black;
text-decoration: none;
cursor: pointer;
}
.prize-item,
.user-item {
display: flex;
align-items: center;
margin-bottom: 10px;
justify-content: center;
}
.custom-p {
font-size: 16px;
/* 设置字体粗细 */
font-weight: bold;
margin-right: 90px; /* 右侧外边距 */
}
.prize-item input[type="checkbox"],
.user-item input[type="checkbox"] {
margin-right: 99px;
}
.prize-item label,
.user-item label {
margin-right: 11px;
margin-bottom: 0;
width: 200px;
}
.prize-item select {
margin-left: auto; /* 将下拉选择框放置在末尾 */
}
.prize-item .form-control {
width: 96px;
height: 36px;
line-height: 36px;
margin-right: 56px;
}
.h-title{
font-weight: 600;
font-size: 30px;
letter-spacing: 1px;
color: #000000;
line-height: 50px;
text-align: center;
margin-bottom: 40px;
}
.desc-row{
margin-bottom: 60px;
}
.form-btn-box{
display: flex;
align-items: center;
justify-content: center;
}
.form-btn-box button{
width: 148px;
height: 48px;
}
.pre-btn{
margin-right: 20px;
}
</style>
</head>
<body>
<div class="container">
<h2 class="h-title">创建抽奖活动</h2>
<form id="activityForm">
<div class="form-group">
<label for="activityName">活动名称</label>
<input type="text" placeholder="请输入活动名称" class="form-control" class="form-control" id="activityName" name="activityName" required>
</div>
<div class="form-group desc-row">
<label for="description">活动描述</label>
<textarea id="description" placeholder="请输入活动描述" rows="5" cols="33" class="form-control" name="description" required></textarea>
</div>
<div class="form-btn-box">
<button id="buttonPrizes" type="button" class="btn btn-primary pre-btn" onclick="showPrizesModal()">圈选奖品</button>
<button id="buttonUsers" type="button" class="btn btn-primary pre-btn" onclick="showUsersModal()">圈选人员</button>
<button type="submit" class="btn btn-primary" id="createActivity">创建活动</button>
</div>
</form>
</div>
<!-- toast提示 -->
<div class="toast"></div>
<!-- 奖品选择模态框 -->
<div id="prizesModal" class="modal">
<div class="modal-content">
<h2>奖品列表<span class="close" onclick="hidePrizesModal()">×</span></h2>
<div class="prize-item">
<p class="custom-p">勾选</p>
<p class="custom-p">奖品名</p>
<p class="custom-p">数量</p>
<p class="custom-p">奖品等级</p>
</div>
<div id="prizesContainer">
<!-- 奖品列表将动态插入这里 -->
</div>
<div class="form-btn-box">
<button type="button" class="btn btn-secondary pre-btn" onclick="hidePrizesModal()">取消</button>
<button type="button" class="btn btn-primary" onclick="submitPrizes()">确定</button>
</div>
</div>
</div>
<!-- 人员选择模态框 -->
<div id="usersModal" class="modal">
<div class="modal-content">
<h2>人员列表<span class="close" onclick="hideUsersModal()">×</span></h2>
<div id="usersContainer">
<!-- 奖品列表将动态插入这里 -->
</div>
<div class="form-btn-box">
<button type="button" class="btn btn-secondary pre-btn" onclick="hideUsersModal()">取消</button>
<button type="button" class="btn btn-primary" onclick="submitUsers()">确定</button>
</div>
</div>
</div>
<!-- JavaScript代码 -->
<script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/jquery-validate/1.19.3/jquery.validate.min.js"></script>
<script src="./js/toastr.min.js"></script>
<script>
var userToken = localStorage.getItem("user_token");
// 初始时奖品列表为空,勾选的奖品会存放到这里
var selectedPrizes = [];
// 显示奖品选择模态框
function showPrizesModal() {
$('#prizesModal').css('display', 'block');
}
// 隐藏奖品选择模态框
function hidePrizesModal() {
$('#prizesModal').css('display', 'none');
}
// 获取奖品列表的函数
function fetchPrizes() {
$.ajax({
url: '/prize/find-list',
type: 'GET',
dataType: 'json',
data: { currentPage: 1, pageSize: 100},
headers: {
// jwt
"user_token": userToken
},
success: function(result) {
var prizes = result.data.records;
var prizesContainer = $('#prizesContainer');
prizesContainer.empty(); // 清空当前奖品列表
// 遍历完奖品列表后,再一行一行插入到模态框中
prizes.forEach(function(prize) {
prizesContainer.append(
$('<div class="prize-item">').append(`
<input type="checkbox" id="prize-${prize.prizeId}" name="prize-${prize.prizeId}" value="${prize.prizeId}">
<label for="prize-${prize.prizeId}">${prize.prizeName}</label>
<input class="form-control" type="number" name="quantity-${prize.prizeId}" min="1" value="1">
<select class="form-control" name="level-${prize.prizeId}">
<option value="FIRST_PRIZE" selected>一等奖</option>
<option value="SECOND_PRIZE">二等奖</option>
<option value="THIRD_PRIZE">三等奖</option>
</select>
`)
);
});
},
error: function(err){
console.log(err);
if(err!=null && err.status==401){
alert("用户未登录, 即将跳转到登录页!");
// 跳转登录页
window.location.href = "/blogin.html";
window.parent.location.href = "/blogin.html";//让父页面一起跳转
}
}
});
}
// 提交奖品数据的函数
function submitPrizes() {
selectedPrizes = [];
// 将选中的奖品信息存储在selectedPrizes
$('.prize-item input[type="checkbox"]:checked').each(function() {
var prizeId = +$(this).val();
var prizeAmount = +$('input[name="quantity-' + prizeId + '"]').val();
var prizeTiers = $('select[name="level-' + prizeId + '"]').val();
selectedPrizes.push({
prizeId: prizeId,
prizeAmount: prizeAmount,
prizeTiers: prizeTiers
});
});
// 关闭模态框
hidePrizesModal();
// 修改按钮
var nextButton = document.getElementById('buttonPrizes');
if (selectedPrizes.length > 0) {
nextButton.textContent = '圈选奖品(已选)';
} else {
nextButton.textContent = '圈选奖品';
}
}
// 初始时人员列表为空
var selectedUsers = [];
// 显示人员选择模态框
function showUsersModal() {
$('#usersModal').css('display', 'block');
}
// 隐藏人员选择模态框
function hideUsersModal() {
$('#usersModal').css('display', 'none');
}
// 获取人员列表的函数
function fetchUsers() {
$.ajax({
url: '/base-user/find-list',
type: 'GET',
dataType: 'json',
data: { identity: 'NORMAL' },
headers: {
// jwt
"user_token": userToken
},
success: function(result) {
var users = result.data;
var usersContainer = $('#usersContainer');
usersContainer.empty(); // 清空当前人员列表
users.forEach(function(user) {
console.info(user);
usersContainer.append(
$('<div class="user-item">').append(`
<input type="checkbox" id="user-${user.userId}" name="user-${user.userId}" value="${user.userId}">
<label for="user-${user.userId}">${user.userName}</label>
`)
);
});
},
error:function(err){
console.log(err);
if(err!=null && err.status==401){
alert("用户未登录, 即将跳转到登录页!");
// 跳转登录页
window.location.href = "/blogin.html";
window.parent.location.href = "/blogin.html";//让父页面一起跳转
}
}
});
}
// 提交用户数据的函数
function submitUsers() {
selectedUsers = [];
// 将选中的奖品信息存储在selectedUsers
$('.user-item input[type="checkbox"]:checked').each(function() {
var userId = +$(this).val();
var userName = $(this).next('label').text();
selectedUsers.push({
userId: userId,
userName: userName
});
});
// 关闭模态框
hideUsersModal();
// 修改按钮
var nextButton = document.getElementById('buttonUsers');
if (selectedUsers.length > 0) {
nextButton.textContent = '圈选人员(已选)';
} else {
nextButton.textContent = '圈选人员';
}
}
// 绑定表单提交事件
$('#createActivity').click(function(event){
event.stopPropagation()
$('#activityForm').validate({
rules:{
activityName:"required",
description:{
required:true,
}
},
messages:{
activityName:"请输入活动名称",
description:"请输入活动描述"
},
// 验证通过才会触发
submitHandler:function(form){
console.log('selectedPrizes',selectedPrizes)
console.log('selectedUsers',selectedUsers)
// 如果未选择奖品则进行toast提示
if(selectedPrizes.length==0){
alert('请至少选择一个奖品')
return false
}
// 如果未选择人员则进行toast提示
if(selectedUsers.length==0){
alert('请至少选择一个人员, 人员数量应大于等于奖品总量')
return false
}
// 获取提交表单信息
var data = {
activityName:'',
description:'',
activityPrizeList:[],
activityUserList:[]
}
data.activityName = $('#activityName').val()
data.description = $('#description').val()
data.activityPrizeList = selectedPrizes
data.activityUserList = selectedUsers
submitActivity(data)
}
})
})
// 提交活动信息接口
function submitActivity(data){
$.ajax({
url: '/activity/create',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify(data),
headers: {
// jwt
"user_token": userToken
},
success: function(result) {
if (result.code != 200) {
alert("创建失败!" + result.msg);
} else {
alert("创建成功!");
// 向父页面传值 https://developer.mozilla.org/zh-CN/docs/Web/API/Window/postMessage
window.parent.postMessage({
from:'activities-list.html',
id:'#activitiesList'
},'*');
}
},
error:function(err){
console.log(err);
if(err!=null && err.status==401){
alert("用户未登录, 即将跳转到登录页!");
// 跳转登录页
window.location.href = "/blogin.html";
window.parent.location.href = "/blogin.html";//让父页面一起跳转
}
}
});
}
// 获取奖品/人员列表并填充模态框
$(document).ready(function() {
fetchPrizes();
fetchUsers();
});
// 显示奖品选择模态框
$(document).ready(function() {
$('#activityForm').on('click', 'button圈选奖品', function() {
showPrizesModal();
});
});
// 显示人员选择模态框
$(document).ready(function() {
$('#activityForm').on('click', 'button圈选人员', function() {
showUsersModal();
});
});
</script>
</body>
</html>
测试
2. 活动列表展示(翻页)
时序图
约定前后端交互接口
[请求] /activity/find-list?currentPage=1&pageSize=10 GET
{}
[响应]
{
}
Controller 层接口设计
// 查询活动列表
@RequestMapping("/activity/find-list")
public CommonResult<FindActivityListResult> findActivityLIst(PageParam param) {
logger.info("findActivityLIst PageParam:{}", JacksonUtil.writeValueAsString(param));
return CommonResult.success(
convertToFindActivityListResult(
activityService.findActivityList(param)));
}
private FindActivityListResult convertToFindActivityListResult(PageListDTO<ActivityDTO> activityList) {
if (null == activityList) {
throw new ControllerException(ControllerErrorCodeConstants.FIND_ACTIVITY_LIST_ERROR);
}
FindActivityListResult result = new FindActivityListResult();
result.setTotal(activityList.getTotal());
result.setRecords(
activityList.getRecords()
.stream()
.map(activityDTO -> {
FindActivityListResult.ActivityInfo activityInfo = new FindActivityListResult.ActivityInfo();
activityInfo.setActivityId(activityDTO.getActivityId());
activityInfo.setActivityName(activityDTO.getActivityName());
activityInfo.setDescription(activityDTO.getDescription());
activityInfo.setValid(activityDTO.valid());
return activityInfo;
}).collect(Collectors.toList())
);
return result;
}
FindActivityListResult
package com.example.lotterysystem.controller.result;
import lombok.Data;
import java.io.Serializable;
import java.util.List;
// 查询活动列表
@Data
public class FindActivityListResult implements Serializable {
// 奖品总量
private Integer total;
// 当前列表
private List<ActivityInfo> records;
@Data
public static class ActivityInfo implements Serializable {
// 活动id
private Long activityId;
// 活动名称
private String activityName;
// 活动描述
private String description;
// 活动是否有效
private Boolean valid;
}
}
Service 层接口设计
// package com.example.lotterysystem.service;
// 翻页查询活动(摘要)列表
PageListDTO<ActivityDTO> findActivityList(PageParam param);
ActivityDTO
package com.example.lotterysystem.service.dto;
import com.example.lotterysystem.service.enums.ActivityStatusEnum;
import lombok.Data;
@Data
public class ActivityDTO {
// 活动id
private Long activityId;
// 活动名称
private String activityName;
// 活动描述
private String description;
// 活动状态
private ActivityStatusEnum status;
// 判断当前的活动是否有效
public Boolean valid() {
return status.equals(ActivityStatusEnum.RUNNING);
}
}
接口实现
// package com.example.lotterysystem.service.impl;
@Override
public PageListDTO<ActivityDTO> findActivityList(PageParam param) {
// 获取总量
int total = activityMapper.count();
// 获取当前页列表
List<ActivityDO> activityDOList = activityMapper.selectActivityList(param.offset(), param.getPageSize());
List<ActivityDTO> activityDTOList = activityDOList.stream()
.map(activityDO -> {
ActivityDTO activityDTO = new ActivityDTO();
activityDTO.setActivityId(activityDO.getId());
activityDTO.setActivityName(activityDO.getActivityName());
activityDTO.setDescription(activityDO.getDescription());
activityDTO.setStatus(ActivityStatusEnum.forName(activityDO.getStatus()));
return activityDTO;
}).collect(Collectors.toList());
return new PageListDTO<>(total, activityDTOList);
}
Dao 层接口设计
// package com.example.lotterysystem.dao.mapper;
// 查询总量
@Select("select count(1) from activity")
int count();
/**
* 先根据 id 进行降序排序,然后根据偏移量获取数据库中对应大小的数据
* 通过 list 返回
*
* @param offset
* @param pageSize
* @return
*/
@Select("select * from activity order by id desc limit #{offset}, #{pageSize}")
List<ActivityDO> selectActivityList(@Param("offset") Integer offset, @Param("pageSize") Integer pageSize);
Postman 测试
tip:测试时别忘了在拦截器中忽略该路径
活动列表页前端实现
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>活动列表</title>
<link rel="stylesheet" href="https://cdn.bootcdn.net/ajax/libs/bootstrap/4.5.2/css/bootstrap.min.css">
<link rel="stylesheet" href="./css/base.css">
<style>
body {
font-family: Arial, sans-serif;
background-color: #f2f2f2;
}
.activity-list {
padding:0 30px;
}
#activities{
height: calc(100vh - 134px);
overflow-y: auto;
padding-right: 10px;
}
.activity-item {
display: flex;
align-items: center;
justify-content: space-between;
background-color: #f7f7f7;
padding: 24px;
border-radius: 4px;
overflow: hidden;
margin-bottom: 10px;
border-radius: 8px;
padding-bottom: 12px;
}
.activity-info{
width: calc(100% - 120px);
}
.activity-info h4{
width: 100%;
font-weight: 600;
font-size: 15px;
color: #000000;
margin-bottom: 4px;
}
.activity-info p{
font-weight: 400;
font-size: 14px;
color: #666666;
margin: 0;
line-height: 28px;
}
.active a{
font-weight: 400;
font-size: 15px;
color: red;
margin-bottom: 0;
display: block;
width: 250px;
}
.inactive a{
font-weight: 400;
font-size: 15px;
color: gray;
margin-bottom: 0;
display: block;
width: 250px;
}
.pagination {
display: flex;
justify-content: flex-end;
margin-top: 18px;
padding-right: 16px;
}
.pagination button {
margin: 0 5px; /* 按钮之间的间距保持不变 */
border-radius: 5px; /* 设置圆角为20像素,可以根据需要调整 */
border: 1px solid #007bff;
background-color: #fff;
padding: 0px 8px; /* 可以添加一些内边距,使按钮看起来更饱满 */
cursor: pointer; /* 将鼠标光标改为指针形状,提升用户体验 */
font-size: 13px;
}
.pagination span{
margin: 0 10px;
font-size: 14px;
}
.pagination input{
width: 80px;
text-align: center;
}
.activity-list h2 {
font-weight: 600;
font-size: 18px;
color: #000000;
height: 70px;
display: flex;
align-items: center;
margin-bottom: 0;
}
</style>
</head>
<body style="background-color: white">
<div class="activity-list">
<h2>活动列表</h2>
<div id="activities">
<!-- 活动列表将动态插入这里 -->
</div>
<div class="pagination">
<button class="btn-outline-primary" onclick="fetchActivities(1)">首页</button>
<button class="btn-outline-primary" onclick="previousPage()">上一页</button>
<span>第 <input type="number" id="pageInput" min="1" value="1" /> 页</span>
<button class="btn-outline-primary" onclick="nextPage()">下一页</button>
<button class="btn-outline-primary" onclick="fetchActivities(totalPages)">尾页</button>
</div>
</div>
<script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
<script src="./js/toastr.min.js"></script>
<script>
var currentPage = 1;
var pageSize = 10;
var totalPages;
var userToken = localStorage.getItem("user_token");
// 发送AJAX请求的函数
function fetchActivities(page) {
// 如果页码小于1,则重置为1
if (page < 1) {
page = 1;
}
// 更新当前页码
currentPage = page;
// 构建要发送的数据对象
var dataToSend = {
currentPage: currentPage,
pageSize: pageSize
};
// 发送AJAX请求
$.ajax({
url: '/activity/find-list',
type: 'GET',
data: dataToSend, // 将分页参数作为请求数据发送
dataType: 'json',
headers: {
// jwt
"user_token": userToken
},
success: function(result) {
if (result.code != 200) {
alert("查询活动列表失败!" + result.msg);
} else {
var activities = result.data.records; // 假设返回的数据中活动列表字段为 'records'
var activitiesHtml = '';
var listContainer = document.getElementById('activities');
// 在添加新内容前,先清空listContainer
listContainer.innerHTML = '';
activities.forEach(function(activity) {
var url = 'draw.html?activityName='+ encodeURIComponent(activity.activityName)
+'&activityId=' + encodeURIComponent(activity.activityId)
+'&valid=' + encodeURIComponent(activity.valid);
var linkTextActive = `<a href="${url}" target="_blank">活动进行中,去抽奖</a>`;
var linkTextInactive = `<a href="${url}" target="_blank">活动已完成,查看中奖名单</a>`;
var validClass = activity.valid ? 'active' : 'inactive';
var link = activity.valid ? linkTextActive : linkTextInactive;
activitiesHtml += `
<div class="activity-item">
<div class="activity-info">
<h4>${activity.activityName}</h4>
<p>${activity.description}</p>
</div>
<div class="${validClass}">
<p>${link}</p>
</div>
</div>
`;
});
$('#activities').html(activitiesHtml);
// 更新分页控件的总页数
totalPages = Math.ceil(result.data.total / pageSize);
// 更新输入框的值
$('#pageInput').val(currentPage);
} // else end
},
error:function(err){
console.log(err);
if(err!=null && err.status==401){
alert("用户未登录, 即将跳转到登录页!");
// 跳转登录页
window.location.href = "/blogin.html";
window.parent.location.href = "/blogin.html";//让父页面一起跳转
}
}
});
}
function previousPage() {
if (currentPage > 1) {
fetchActivities(currentPage - 1);
} else {
alert("已经是第一页");
}
}
function nextPage() {
if (currentPage < totalPages) {
fetchActivities(currentPage + 1);
} else {
alert("已经是最后一页");
}
}
$(document).ready(function() {
fetchActivities(1);
});
// 绑定输入框回车事件
$('#pageInput').on('keypress', function(e) {
if (e.key === 'Enter') {
var page = parseInt(this.value);
if(page > totalPages){
page = totalPages
$('#pageInput').val(totalPages);
}
if (!isNaN(page) && page >= 1 && page <= totalPages) {
fetchActivities(page);
}
}
});
</script>
</body>
</html>