【抽奖系统开发实战】Spring Boot 活动模块设计:事务保障、缓存优化与列表展示

文章目录

    • 一、活动创建
      • [1.1 时序图](#1.1 时序图)
      • [1.2 约定前后端交互接口](#1.2 约定前后端交互接口)
      • [1.3 Controller层接口设计](#1.3 Controller层接口设计)
      • [1.4 Service层接口设计](#1.4 Service层接口设计)
      • [1.4 活动创建页面前端实现](#1.4 活动创建页面前端实现)
    • 二、活动列表展示
      • [2.1 时序图](#2.1 时序图)
      • [2.1 约定前后端交互接口](#2.1 约定前后端交互接口)
      • [2.2 Controller层接口设计](#2.2 Controller层接口设计)
      • [2.3 Service层接口设计](#2.3 Service层接口设计)
      • [2.4 活动列表页面前端实现](#2.4 活动列表页面前端实现)
    • 三、Redis缓存实现
      • [3.1 活动创建时的缓存写入](#3.1 活动创建时的缓存写入)
      • [3.2 活动详情查询的缓存优先策略](#3.2 活动详情查询的缓存优先策略)
      • [3.3 活动状态扭转时的缓存更新](#3.3 活动状态扭转时的缓存更新)
      • [3.4 缓存失效与容错处理](#3.4 缓存失效与容错处理)
      • [3.5 缓存数据结构](#3.5 缓存数据结构)

一、活动创建

1.1 时序图

1.2 约定前后端交互接口

请求

请求地址/activity/create
请求方法POST
请求体

json 复制代码
{
    "activityName": "抽奖测试",
    "description": "年会抽奖活动",
    "activityPrizeList": [
        {
            "prizeId": 13,
            "prizeAmount": 1,
            "prizeTiers": "FIRST_PRIZE"
        },
        {
            "prizeId": 12,
            "prizeAmount": 1,
            "prizeTiers": "SECOND_PRIZE"
        }
    ],
    "activityUserList": [
        {
            "userId": 25,
            "userName": "郭靖"
        },
        {
            "userId": 23,
            "userName": "杨康"
        }
    ]
}

响应

响应体

json 复制代码
{
    "code": 200,
    "data": {
        "activityId": 23
    },
    "msg": ""
}

1.3 Controller层接口设计

  • 定义ActivityController类,使用@RestController注解。
  • 提供/activity/create接口,接收@Validated修饰的CreateActivityParam参数。
  • 调用ActivityService.createActivity()方法处理业务逻辑,返回CommonResult<CreateActivityResult>
  • 异常时抛出ControllerException

接口代码示例:

java 复制代码
	/**
     * 创建活动
     *
     * @param param
     * @return
     */
    @PostMapping("/activity/create")
    public CommonResult<CreateActivityResult> createActivity(
            @Validated @RequestBody CreateActivityParam param){

        log.info("方法: createActivity 参数: param : {}", JacksonUtil.writeValueAsString(param));
        return CommonResult.success(
                converToCreateActivityResult(
                        activityService.createActivity(param)));
    }

请求参数封装:CreateActivityParam

  • 包含活动名称(activityName)、描述(description)、奖品列表(activityPrizeList)、人员列表(activityUserList)。
  • 使用@NotBlank@NotEmpty@Valid等注解进行参数校验。
  • 奖品列表项CreatePrizeByActivityParam:奖品ID、数量、等级(枚举ActivityPrizeTiersEnum)。
  • 人员列表项CreateUserByActivityParam:用户ID、用户名。

奖品等级枚举:ActivityPrizeTiersEnum

  • 定义一等奖(FIRST_PRIZE)、二等奖(SECOND_PRIZE)、三等奖(THIRD_PRIZE)。
  • 提供code和message,以及根据name或message获取枚举的静态方法。

响应结果封装:CreateActivityResult

  • 仅包含活动ID(activityId)字段。

1.4 Service层接口设计

  • 定义ActivityService接口,声明createActivity(CreateActivityParam request)方法,返回CreateActivityDTO

数据传输对象:CreateActivityDTO

  • 仅包含活动ID字段,用于Service层返回给Controller。

接口实现:ActivityServiceImpl

  • 实现createActivity方法,核心逻辑如下:
    • 校验活动信息(具体校验内容在源码中,如活动名称非空等)。
    • 创建活动记录:构建ActivityDO,设置名称、描述、状态为"进行中"(RUNNING),调用ActivityMapper.insert()入库。
    • 关联奖品:遍历activityPrizeList,构建ActivityPrizeDO列表,设置活动ID、奖品ID、数量、等级、状态为"初始化"(INIT),调用ActivityPrizeMapper.batchInsert()批量插入。
    • 关联人员:遍历activityUserList,构建ActivityUserDO列表,设置活动ID、用户ID、用户名、状态为"初始化",调用ActivityUserMapper.batchInsert()批量插入。
    • 查询奖品详细信息:从奖品表中根据奖品ID列表查询奖品完整信息。
    • 缓存活动完整信息:将活动、关联奖品、关联人员整合为ActivityDetailDTO,存入Redis(key前缀ACTIVITY_,过期时间3天)。
    • 返回活动ID。
  • 使用@Transactional保证上述操作的事务一致性。

接口实现代码示例:

java 复制代码
	@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.setStatus(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 缓存中
        // 规定 key:activityId: ActivityDetailDTO: 活动+奖品+人员

        // 需要先获取奖品基本属性列表
        List<Long> prizeIds = param.getActivityPrizeList()
                .stream()
                .map(CreatePrizeByActivityParam::getPrizeId)
                .distinct()
                .collect(Collectors.toList());
        List<PrizeDO> prizeDOList = prizeMapper.batchSelectByIds(prizeIds);

        ActivityDetailDTO detailDTO = converToActivityDetailDTO(activityDO,activityUserDOList
                ,activityPrizeDOList,prizeDOList);

        // 存放进缓存
        cacheActivity(detailDTO);

        // 构造返回
        CreateActivityDTO createActivityDTO = new CreateActivityDTO();
        createActivityDTO.setActivityId(activityDO.getId());
        return createActivityDTO;
    }

状态枚举类

  • ActivityStatusEnum :活动状态,包括进行中(RUNNING)、已完成(COMPLETED)。
  • ActivityPrizeStatusEnum :活动奖品状态,包括初始化(INIT)、已被抽取(COMPLETED)。
  • ActivityUserStatusEnum :活动人员状态,包括初始化(INIT)、已被抽取(COMPLETED)。

Dao层接口设计

  • ActivityMapper :提供insert(ActivityDO)方法,使用@Options获取自动生成的主键。
  • ActivityPrizeMapper :提供batchInsert(List<ActivityPrizeDO>)方法,批量插入活动奖品关联数据。
  • ActivityUserMapper :提供batchInsert(List<ActivityUserDO>)方法,批量插入活动人员关联数据。
  • PrizeMapper :提供selectByIdList(List<Long>)方法,根据奖品ID列表查询奖品详情。

数据对象(DO)

  • ActivityDO :继承BaseDO,包含活动名称、描述、状态字段。
  • ActivityPrizeDO :继承BaseDO,包含活动ID、奖品ID、奖品数量、奖品等级、状态字段。
  • ActivityUserDO :继承BaseDO,包含活动ID、用户ID、用户名、状态字段。

1.4 活动创建页面前端实现

  • 页面加载时通过AJAX请求获取奖品列表和人员列表(普通用户),并填充至模态框。
  • 用户可在模态框中勾选奖品并设置数量、等级,勾选参与人员。
  • 点击"创建活动"按钮时,通过jQuery Validate插件验证表单必填项。
  • 验证通过后,将选中的奖品和人员数据与活动名称、描述组合,通过POST /activity/create接口提交。
  • 成功创建后,通过window.parent.postMessage通知父页面(活动列表页)刷新列表。

二、活动列表展示

2.1 时序图

2.1 约定前后端交互接口

请求接口:

请求地址/activity/find-list?currentPage=1&pageSize=10
请求方法GET

响应数据:

响应体

json 复制代码
{
    "code": 200,
    "data": {
        "total": 10,
        "records": [
            {
                "activityId": 23,
                "activityName": "抽奖测试",
                "description": "年会抽奖活动",
                "valid": true
            }
        ]
    },
    "msg": ""
}

2.2 Controller层接口设计

  • ActivityController中提供/activity/find-list接口,接收PageListParam参数(当前页、每页大小)。
  • 调用ActivityService.findActivityList()获取分页数据,返回CommonResult<FindActivityListResult>
  • PageListDTO<ActivityDTO>转换为FindActivityListResult,其中ActivityDTO转换为ActivityInfo(包含活动ID、名称、描述、是否有效)。

接口代码示例:

java 复制代码
	@RequestMapping("/activity/find-list")
    public CommonResult<FIndActivityListResult> findActivityList(PageParam param){
        log.info("findActivityList param: {}",JacksonUtil.writeValueAsString(param));

        return CommonResult.success(
                convetToFIndActivityListResult(
                        activityService.findActivityList(param)));
    }

分页查询参数:PageListParam

  • 包含currentPage(默认1)和pageSize(默认10)。
  • 提供offset()方法计算SQL偏移量。

响应结果封装:FindActivityListResult

  • 包含total(总记录数)和records(当前页活动信息列表)。
  • 内部类ActivityInfo:活动ID、名称、描述、是否有效(isValid)。

2.3 Service层接口设计

  • ActivityService中增加findActivityList(PageListParam request)方法,返回PageListDTO<ActivityDTO>

数据传输对象:ActivityDTO

  • 包含活动ID、名称、描述、状态(ActivityStatusEnum)。
  • 提供valid()方法判断活动是否有效(状态为RUNNING)。

接口实现:ActivityServiceImpl

  • 实现findActivityList方法:
    • 调用ActivityMapper.count()获取总记录数。
    • 调用ActivityMapper.queryActivitiesByPage(offset, pageSize)获取当前页活动记录。
    • ActivityDO列表转换为ActivityDTO列表,设置状态枚举。
    • 返回PageListDTO封装总记录数和当前页数据。

接口实现示例:

java 复制代码
	@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层接口设计

  • ActivityMapper 新增方法:
    • int count():统计活动总数。
    • List<ActivityDO> queryActivitiesByPage(@Param("offset") Integer offset, @Param("pageSize") Integer pageSize):分页查询,按ID降序排列。

2.4 活动列表页面前端实现

  • 页面加载时通过AJAX请求获取第一页活动数据。
  • 根据活动状态动态生成链接:进行中活动显示"活动进行中,去抽奖"链接,已完成活动显示"活动已完成,查看中奖名单"链接。
  • 实现分页控件:上一页、下一页、跳转输入框,点击或回车时重新请求对应页码数据。
  • 请求时在header中添加user_token进行身份验证,未登录时跳转登录页。

三、Redis缓存实现

3.1 活动创建时的缓存写入

  • 在活动创建完成后,将活动完整信息(包括活动基本信息、关联的奖品列表、参与人员列表)整合为ActivityDetailDTO对象。
  • 调用RedisUtil.set(ACTIVITY_PREFIX + activityId, detailJson, ACTIVITY_EFFECTIVE_TIME)将该对象序列化为JSON后存入Redis。
  • 缓存key以ACTIVITY_为前缀,后跟活动ID,过期时间设置为3天,平衡缓存命中率与数据实时性。

缓存写入示例:

java 复制代码
	/**
     * 缓存完整的活动信息: ActivityDetailDTO
     *
     * @param detailDTO
     */
    private void cacheActivity(ActivityDetailDTO detailDTO) {
        // key: ACTIVITY_activityId
        // value: ActivityDetailDTO(JSON)

        if(null == detailDTO || null == detailDTO.getActivityId()){
            log.warn("要缓存的信息不存在");
            return;
        }

        try {
            redisUtil.set(Constants.ACTIVITY_PREFIS + detailDTO.getActivityId()
                    , JacksonUtil.writeValueAsString(detailDTO)
                    , Constants.ACTIVITY_TIMEOUT);
        }catch (Exception e){
            log.error("缓存活动异常: ActivityDetailDTO={}"
                    ,JacksonUtil.writeValueAsString(detailDTO)
                    ,e);
        }
    }

3.2 活动详情查询的缓存优先策略

  • getActivityDetail接口中,首先根据活动ID构造缓存key,尝试从Redis获取缓存的ActivityDetailDTO
  • 若缓存命中,直接返回,避免数据库查询。
  • 若缓存未命中,则查询数据库,组装活动详情后,再通过cacheActivity方法将结果写入Redis,供后续请求使用。

查询活动缓存示例:

java 复制代码
	/**
     * 根据活动ID 从缓存中获取活动详细信息
     * @param activityId
     * @return
     */
    private ActivityDetailDTO getActivityFromCache(Long activityId){
        if(null == activityId){
            log.warn("获取缓存活动数据activityId为空");
            return null;
        }
        try {
            String str = redisUtil.get(Constants.ACTIVITY_PREFIS + activityId);
            if(!StringUtils.hasLength(str)){
                log.warn("获取缓存活动数据为空! key: {}",Constants.ACTIVITY_PREFIS);
                return null;
            }
            return JacksonUtil.readValue(str,ActivityDetailDTO.class);
        }catch (Exception e){
            log.error("从缓存中获取活动信息异常,key: {}",Constants.ACTIVITY_PREFIS);
            return null;
        }
    }

3.3 活动状态扭转时的缓存更新

  • 当活动状态发生改变(如奖品被抽取、活动完成)时,在数据库事务成功提交后,调用cacheActivity(activityId)方法强制刷新Redis中的活动详情。
  • 确保缓存中的数据与数据库保持最终一致性,避免用户看到过期状态(如已抽取的奖品仍显示为可抽)。
  • 缓存更新操作放在事务外部,避免因Redis异常导致事务回滚。

3.4 缓存失效与容错处理

  • 所有缓存操作均通过RedisUtil封装,其内部对异常进行捕获并记录错误日志,但不会抛出异常影响主业务流程。
  • 当Redis服务不可用或操作超时时,业务自动降级为直接查询数据库,保证核心功能的可用性。
  • 缓存数据均设置合理的过期时间,防止内存泄漏,同时允许数据在过期后重新从数据库加载,实现冷热数据分离。

3.5 缓存数据结构

  • 活动详情缓存使用JSON字符串格式存储,内容包含活动ID、名称、描述、状态,以及奖品列表(每个奖品包含ID、名称、图片、数量、等级、是否有效)和人员列表(每个人员包含ID、姓名、是否有效)。
  • 这种结构化的存储方式便于前端直接使用,减少多次查询开销。
相关推荐
BioRunYiXue2 小时前
甘油不够了,能用植物油保存菌种吗?
java·linux·运维·服务器·网络·人工智能·eclipse
y = xⁿ2 小时前
【黑马点评二刷日记】分布式锁和Redisson
java·redis·分布式·缓存
程序员爱钓鱼2 小时前
Go图像处理基础: image包深度指南
后端·面试·go
空空kkk2 小时前
JVM面试知识点总结
java·jvm·面试
zdl6862 小时前
spring Profile
java·数据库·spring
m0_706653232 小时前
数据倾斜全面解析与解决方案探析
java
程序员飞哥2 小时前
有个同事因为恐惧 AI 要离职了
java·后端·程序员
vanvivo2 小时前
springboot3.X 无法解析parameter参数问题
java
CodeSheep2 小时前
魔幻!MiniMax市值正式超越百度,老板曾是百度实习生,网友一针见血。
前端·后端·程序员