type
status
date
slug
summary
tags
category
titleIcon
password
icon
calloutIcon
2️⃣
技术重点在三个方面:幂等性保障|延迟任务|合并写,除去这一部分,其他主要为基本业务,简单且量大,对初学友好,如果不是第一次做微服务项目,选看这三个方面就可以跳了。文中图片与代码块较多,善用展开懒加载。 接口幂等性(数据库唯一约束);分库分表的主键id(雪花/Redis);高并发写优化(MQ改异步写/Redis合并写请求);RabbitMQ;SpringTask单机定时任务;@PostConstruct生命周期Hook;Spring Data Redis逆天API;定时任务持久化播放记录;区分Spring与Swagger的@RequestBody注解;Interface常量类;Delayed单机延迟任务;Caffeine本地缓存;自动装配

接口设计通用思路

根据产品原型确定:
  • 请求方式(如果公司没有要求POSTful,而是RESTful实践)
  • 请求路径
  • 请求参数(分页的Query,传参的DTO)
  • 请求返回(VO)

接口幂等性保障

数据库唯一约束

  • 数据库添加唯一约束,多次调用会触发数据库异常。
notion image

Redis保存唯一标识

  • 保存当前订单id于Redis中,保存一段时间(如60s),业务先判断当前订单id是否在Redis中,如果有数据就不再调用业务。

业务表主键id选取

  • 雪花id/Redis生成(考虑分库分表)
  • 自增id(不考虑分库分表)

高并发优化思路

提高单机并发

  • 通过减小业务接口响应时间,提升单机性能与并发。

缩短业务响应时间

读优化
  • 优化代码,SQL
  • Redis缓存数据
写优化
  • 优化代码,SQL
  • 同步写改异步写(MQ实现),仅发送MQ通知,不等待业务执行。
    • 优点
    • 不需要等待业务执行,可以显著减少响应时间。
    • 利用MQ暂存消息,流量削峰。
    • 降低数据库写频率,减轻数据库压力。
    • 缺点
    • 依赖MQ可靠性。
    • 不减少写次数。
    • 场景
    • 业务复杂,链路较长,多次写操作的业务。
  • 合并写请求,先将每次数据更新到Redis,定期将缓存数据批量写入数据库。
    • 优点
    • 写入Redis显著快于MySQL,可以减少响应时间。
    • 降低数据库写频率与减少写次数,减轻数据库压力。
    • 缺点
    • 不支持事务。
    • 复杂业务实现复杂。
    • 依赖Redis可靠性。
    • 场景
    • 写频率高,写业务简单的场景。

水平扩展

  • 对热点服务进行水平扩展,负载均衡,提高整个集群并发能力。

服务保护

  • 通过服务熔断与降级保护提高服务高可用性。

延时任务(写合并)

业务场景

  • 订单超时自动取消
  • 下单成功短信通知

实现方式

  • Java API,DelayQueue(√)|ScheduledExecutorService (单机)
  • Redisson,Redis数据结构模拟DelayQueue
  • MQ延迟消息 (分布式)
  • 分布式调度XXL-JOB (分布式)
  • Netty时间轮

项目使用

业务情景

  • 合并写,合并多次播放记录写,仅写入用户最后一次提交的播放记录。
  • 延迟任务,更新Redis后(前端15s提交一次更新请求)提交延迟任务,在20s后获得的延迟任务里查询Redis检查本次与上回数据是否一致,一致则为最后一次,写回数据库。
notion image

业务实现

展开
notion image

java

package com.tianji.learning.task; import lombok.Data; import java.time.Duration; import java.util.concurrent.Delayed; import java.util.concurrent.TimeUnit; /** * @author CamelliaV * @since 2024/11/8 / 16:23 */ @Data public class DelayTask<D> implements Delayed { private D data; // * nanoseconds private long activeTime; public DelayTask(D data, Duration delayTime) { this.data = data; this.activeTime = System.nanoTime() + delayTime.toNanos(); } // * 返回任务执行剩余时间 @Override public long getDelay(TimeUnit unit) { return unit.convert(Math.max(0, activeTime - System.nanoTime()), TimeUnit.NANOSECONDS); } // * 排序 @Override public int compareTo(Delayed o) { long l = getDelay(TimeUnit.NANOSECONDS) - o.getDelay(TimeUnit.NANOSECONDS); if (l > 0) { return 1; } else if (l < 0) { return -1; } else { return 0; } } }
Java
notion image
notion image
notion image
notion image

java

package com.tianji.learning.task; import com.tianji.common.exceptions.DbException; import com.tianji.common.utils.CollUtils; import com.tianji.common.utils.JsonUtils; import com.tianji.common.utils.StringUtils; import com.tianji.learning.domain.po.LearningLesson; import com.tianji.learning.domain.po.LearningRecord; import com.tianji.learning.mapper.LearningRecordMapper; import com.tianji.learning.service.ILearningLessonService; import com.tianji.learning.service.ILearningRecordService; import lombok.Data; import lombok.NoArgsConstructor; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.dao.DataAccessException; import org.springframework.data.redis.core.RedisOperations; import org.springframework.data.redis.core.SessionCallback; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import javax.annotation.PostConstruct; import javax.annotation.PreDestroy; import java.time.Duration; import java.time.LocalDateTime; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.concurrent.CompletableFuture; import java.util.concurrent.DelayQueue; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.stream.Collectors; @Slf4j @RequiredArgsConstructor @Component public class LearningRecordDelayTaskHandler { private final static String RECORD_KEY_TEMPLATE = "learning:record:{}"; // * 实际核心数,非逻辑处理器数 private final static int CPU_ACTUAL_CORES = 8; private static volatile boolean begin = true; private final StringRedisTemplate redisTemplate; private final DelayQueue<DelayTask<RecordTaskData>> queue = new DelayQueue<>(); private final LearningRecordMapper recordMapper; private final ILearningLessonService lessonService; // * 涉及循环依赖 修改了spring.main.allow-circular-references,使用autowired自动确定注入时机 // * 偷懒复用批量更新 @Autowired private final ILearningRecordService recordService; // * 仅为实现定时任务(不采用)添加,key为记录id,value为记录 private final Map<Long, LearningRecord> lastRecords; @PostConstruct public void init() { ExecutorService threadPool = Executors.newFixedThreadPool(CPU_ACTUAL_CORES); CompletableFuture.runAsync(this::handleDelayTask, threadPool); } @PreDestroy public void destroy() { log.debug("关闭学习记录处理的延迟任务"); begin = false; } /** * (仅实现,不采用)定时任务,每隔20秒检查Redis缓存是否有需要持久化的学习记录 */ @Scheduled(fixedRate = 20_000) private void checkAndPersistRecords() { log.debug("定时持久化学习记录开始"); // * 没有需要更新的数据(Redis数据为空) if (CollUtils.isEmpty(lastRecords)) { return; } List<LearningRecord> recordList = readRecordCacheBatch(); // * 健壮性检查,Redis中无数据,原则上lastRecords不为空Redis数据也不为空 if (CollUtils.isEmpty(recordList)) { return; } // * (可选)仅更新播放进度相比上次写入时没有变化的数据,但可能出现刚写入Redis就触发定时更新的情况,效果未必好 List<LearningRecord> recordsToUpdate = recordList.stream() .filter(record -> Objects.equals(record.getMoment(), lastRecords.get(record.getId()) .getMoment())) .collect(Collectors.toList()); // * finished不修改,此处必定为非第一次完成 recordsToUpdate.forEach(record -> record.setFinished(null)); // * 更新学习记录 boolean success = recordService.updateBatchById(recordsToUpdate); if (!success) { throw new DbException("定时任务更新学习记录失败"); } // * 更新课表 最近学习小节id与时间 // * Redis中数据只有三部分,需要使用更完全的数据 List<LearningLesson> lessons = recordsToUpdate.stream() .map(record -> { LearningRecord fullRecord = lastRecords.get(record.getId()); LearningLesson lesson = new LearningLesson(); lesson.setId(fullRecord.getLessonId()); lesson.setLatestLearnTime(LocalDateTime.now()); lesson.setLatestSectionId(fullRecord.getSectionId()); return lesson; }) .collect(Collectors.toList()); success = lessonService.updateBatchById(lessons); if (!success) { throw new DbException("定时任务更新课表失败"); } // * 更新结束,清空暂存区 lastRecords.clear(); log.info("定时持久化学习记录任务成功"); } private void handleDelayTask() { while (begin) { try { // 1.尝试获取任务 DelayTask<RecordTaskData> task = queue.take(); log.debug("获取到要处理的播放记录任务"); RecordTaskData data = task.getData(); // 2.读取Redis缓存 LearningRecord record = readRecordCache(data.getLessonId(), data.getSectionId()); if (record == null) { continue; } // 3.比较数据 if (!Objects.equals(data.getMoment(), record.getMoment())) { // 4.如果不一致,播放进度在变化,无需持久化 continue; } // 5.如果一致,证明用户离开了视频,需要持久化 // 5.1.更新学习记录 record.setFinished(null); recordMapper.updateById(record); // 5.2.更新课表 LearningLesson lesson = new LearningLesson(); lesson.setId(data.getLessonId()); lesson.setLatestSectionId(data.getSectionId()); lesson.setLatestLearnTime(LocalDateTime.now()); lessonService.updateById(lesson); log.debug("准备持久化学习记录信息"); } catch (Exception e) { log.error("处理播放记录任务发生异常", e); } } } // * 替换addLearningRecordTask采用定时任务方案 public void addLearningRecordTaskScheduled(LearningRecord record) { // 1.添加数据到Redis缓存 writeRecordCache(record); // 2.添加后续需要更新数据库的数据 lastRecords.putIfAbsent(record.getId(), record); } // * 定时方案使用 private List<LearningRecord> readRecordCacheBatch() { try { // * 1.批量读取Redis数据,因为没有根据lessonId聚类,这里只一次传输多条单field读取 // * 逆天API三个泛型只给传一个 List<Object> results = redisTemplate.executePipelined(new SessionCallback<Object>() { @Override public Object execute(RedisOperations operations) throws DataAccessException { for (Long recordKey : lastRecords.keySet()) { LearningRecord record = lastRecords.get(recordKey); String key = StringUtils.format(RECORD_KEY_TEMPLATE, record.getLessonId()); // * 完全虚假的类型安全 // * 18年的issue 2024还没close😅 // * https://github.com/spring-projects/spring-data-redis/issues/1431 //noinspection unchecked operations.opsForHash() .get(key, record.getSectionId() .toString()); } return null; } }); // * Redis中无数据,不进行更新 if (CollUtils.isEmpty(results)) { return null; } // * 反序列化Redis数据用于后续Service数据库数据更新 List<LearningRecord> recordList = results.stream() .map(record -> JsonUtils.toBean(record.toString(), LearningRecord.class)) .collect(Collectors.toList()); return recordList; } catch (Exception e) { log.error("缓存读取异常", e); return null; } } public void addLearningRecordTask(LearningRecord record) { // 1.添加数据到Redis缓存 writeRecordCache(record); // 2.提交延迟任务到延迟队列 DelayQueue queue.add(new DelayTask<>(new RecordTaskData(record), Duration.ofSeconds(20))); } public void writeRecordCache(LearningRecord record) { log.debug("更新学习记录的缓存数据"); try { // 1.数据转换 String json = JsonUtils.toJsonStr(new RecordCacheData(record)); // 2.写入Redis String key = StringUtils.format(RECORD_KEY_TEMPLATE, record.getLessonId()); redisTemplate.opsForHash() .put(key, record.getSectionId() .toString(), json); // 3.添加缓存过期时间 redisTemplate.expire(key, Duration.ofMinutes(1)); } catch (Exception e) { log.error("更新学习记录缓存异常", e); } } public LearningRecord readRecordCache(Long lessonId, Long sectionId) { try { // 1.读取Redis数据 String key = StringUtils.format(RECORD_KEY_TEMPLATE, lessonId); Object cacheData = redisTemplate.opsForHash() .get(key, sectionId.toString()); if (cacheData == null) { return null; } // 2.数据检查和转换 return JsonUtils.toBean(cacheData.toString(), LearningRecord.class); } catch (Exception e) { log.error("缓存读取异常", e); return null; } } public void cleanRecordCache(Long lessonId, Long sectionId) { // 删除数据 String key = StringUtils.format(RECORD_KEY_TEMPLATE, lessonId); redisTemplate.opsForHash() .delete(key, sectionId.toString()); } @Data @NoArgsConstructor private static class RecordCacheData { private Long id; private Integer moment; private Boolean finished; public RecordCacheData(LearningRecord record) { this.id = record.getId(); this.moment = record.getMoment(); this.finished = record.getFinished(); } } @Data @NoArgsConstructor private static class RecordTaskData { private Long lessonId; private Long sectionId; private Integer moment; public RecordTaskData(LearningRecord record) { this.lessonId = record.getLessonId(); this.sectionId = record.getSectionId(); this.moment = record.getMoment(); } } }
Java

ES与MySQL同步方式

  • MQ方式,实时性好,不能批量。
  • Canal伪装MySQL从库,数据同步。
  • (LogStash)定时任务同步,不即时,可批量更新。
  • RPC方式,响应时间长。

Day02课表练习参考实现

删除课表中课程

展开

退款消息处理

notion image

java

@RabbitListener( bindings = @QueueBinding( value = @Queue(name = MqConstants.Queue.ORDER_REFUND_QUEUE, durable = "true"), exchange = @Exchange(name = MqConstants.Exchange.ORDER_EXCHANGE, type = ExchangeTypes.TOPIC), key = MqConstants.Key.ORDER_REFUND_KEY ) ) public void listenLessonRefund(OrderBasicDTO dto) { // * 健壮性检查 if (dto == null || dto.getUserId() == null || CollUtils.isEmpty(dto.getCourseIds())) { log.error("MQ消息错误,退款数据为空"); return; } // * 为对应用户删除课程 Long userId = dto.getUserId(); if (userId == null) { return; } Long courseId = dto.getCourseIds() .get(0); if (courseId == null) { return; } lessonService.deleteLessonByCourse(userId, courseId); }
Java

用户删除课程

notion image

java

/** * 删除对应用户的对应课程(userId来源于MQ或上下文) */ @Override public void deleteLessonByCourse(Long userId, Long courseId) { // * 健壮性保障 if (courseId == null) { return; } // * MQ中确保userId不为null,所以此时必为controller直接调用 if (userId == null) { userId = UserContext.getUser(); } // * 根据唯一标识(userId, courseId)删除对应课表项 boolean success = remove(new QueryWrapper<LearningLesson>() .lambda() .eq(LearningLesson::getUserId, userId) .eq(LearningLesson::getCourseId, courseId)); if (!success) { throw new DbException("删除课表失败"); } }
Java

检查课程是否有效

展开
notion image

java

/** * 查询是否购买了某课程 */ @Override public Long isLessonValid(Long courseId) { // * 健壮性检查 if (courseId == null) { return null; } // * 查询对应课表项(用户id,课程id)唯一确定 Long userId = UserContext.getUser(); LearningLesson lesson = lambdaQuery() .eq(LearningLesson::getUserId, userId) .eq(LearningLesson::getCourseId, courseId) .one(); // * 未购买此课程,返回 if (lesson == null) { return null; } // * 已购买,返回对应课表id return lesson.getId(); }
Java

查询用户课表中指定课程状态

展开
notion image

java

/** * 根据课程id查相关信息 */ @Override public LearningLessonVO queryByCourseId(Long courseId) { // * 用户id,课程id唯一查出课表 Long userId = UserContext.getUser(); LearningLesson lesson = lambdaQuery() .eq(LearningLesson::getCourseId, courseId) .eq(LearningLesson::getUserId, userId) .one(); if (lesson == null) { return null; } // * 课程id查课程信息 CourseFullInfoDTO course = courseClient.getCourseInfoById(lesson.getCourseId(), false, false); if (course == null) { throw new DbException("课程信息不存在"); } // * 封装vo LearningLessonVO vo = BeanUtils.copyBean(lesson, LearningLessonVO.class); vo.setCourseName(course.getName()); vo.setCourseCoverUrl(course.getCoverUrl()); vo.setSections(course.getSectionNum()); return vo; }
Java

统计课程的学习人数

展开
notion image

java

/** * 统计当前课程对应学习人数(统计对应课程id的learninglesson条目数) */ @Override public Integer countLearningLessonByCourse(Long courseId) { if (courseId == null) { return null; } Integer count = lambdaQuery() .eq(LearningLesson::getCourseId, courseId) .count(); return count; }
Java

Day03练习参考实现

课程过期SpringTask定时任务

展开
notion image

java

package com.tianji.learning.task; import com.tianji.learning.service.ILearningLessonService; import lombok.RequiredArgsConstructor; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; /** * @author CamelliaV * @since 2024/11/8 / 21:30 */ @Component @RequiredArgsConstructor public class LessonExpireTask { private final ILearningLessonService lessonService; @Scheduled(cron = "0 0 2 * * ?") public void checkAndExpireLessons() { lessonService.checkAndExpireLessons(); } }
Java

Day04练习参考实现

延迟任务线程池改造

展开
notion image
notion image

java

// * 实际核心数,非逻辑处理器数 private final static int CPU_ACTUAL_CORES = 8; @PostConstruct public void init() { ExecutorService threadPool = Executors.newFixedThreadPool(CPU_ACTUAL_CORES); CompletableFuture.runAsync(this::handleDelayTask, threadPool); }
Java

定时任务持久化播放记录

展开
notion image
notion image
notion image
notion image
notion image
notion image

java

// * 仅为实现定时任务(不采用)添加,key为记录id,value为记录 private final Map<Long, LearningRecord> lastRecords; /** * (仅实现,不采用)定时任务,每隔20秒检查Redis缓存是否有需要持久化的学习记录 */ @Scheduled(fixedRate = 20_000) private void checkAndPersistRecords() { log.debug("定时持久化学习记录开始"); // * 没有需要更新的数据(Redis数据为空) if (CollUtils.isEmpty(lastRecords)) { return; } List<LearningRecord> recordList = readRecordCacheBatch(); // * 健壮性检查,Redis中无数据,原则上lastRecords不为空Redis数据也不为空 if (CollUtils.isEmpty(recordList)) { return; } // * (可选)仅更新播放进度相比上次写入时没有变化的数据,但可能出现刚写入Redis就触发定时更新的情况,效果未必好 List<LearningRecord> recordsToUpdate = recordList.stream() .filter(record -> Objects.equals(record.getMoment(), lastRecords.get(record.getId()) .getMoment())) .collect(Collectors.toList()); // * finished不修改,此处必定为非第一次完成 recordsToUpdate.forEach(record -> record.setFinished(null)); // * 更新学习记录 boolean success = recordService.updateBatchById(recordsToUpdate); if (!success) { throw new DbException("定时任务更新学习记录失败"); } // * 更新课表 最近学习小节id与时间 // * Redis中数据只有三部分,需要使用更完全的数据 List<LearningLesson> lessons = recordsToUpdate.stream() .map(record -> { LearningRecord fullRecord = lastRecords.get(record.getId()); LearningLesson lesson = new LearningLesson(); lesson.setId(fullRecord.getLessonId()); lesson.setLatestLearnTime(LocalDateTime.now()); lesson.setLatestSectionId(fullRecord.getSectionId()); return lesson; }) .collect(Collectors.toList()); success = lessonService.updateBatchById(lessons); if (!success) { throw new DbException("定时任务更新课表失败"); } // * 更新结束,清空暂存区 lastRecords.clear(); log.info("定时持久化学习记录任务成功"); } // * 替换addLearningRecordTask采用定时任务方案 public void addLearningRecordTaskScheduled(LearningRecord record) { // 1.添加数据到Redis缓存 writeRecordCache(record); // 2.添加后续需要更新数据库的数据 lastRecords.putIfAbsent(record.getId(), record); } // * 定时方案使用 private List<LearningRecord> readRecordCacheBatch() { try { // * 1.批量读取Redis数据,因为没有根据lessonId聚类,这里只一次传输多条单field读取 // * 逆天API三个泛型只给传一个 List<Object> results = redisTemplate.executePipelined(new SessionCallback<Object>() { @Override public Object execute(RedisOperations operations) throws DataAccessException { for (Long recordKey : lastRecords.keySet()) { LearningRecord record = lastRecords.get(recordKey); String key = StringUtils.format(RECORD_KEY_TEMPLATE, record.getLessonId()); // * 完全虚假的类型安全 // * 18年的issue 2024还没close😅 // * https://github.com/spring-projects/spring-data-redis/issues/1431 //noinspection unchecked operations.opsForHash() .get(key, record.getSectionId() .toString()); } return null; } }); // * Redis中无数据,不进行更新 if (CollUtils.isEmpty(results)) { return null; } // * 反序列化Redis数据用于后续Service数据库数据更新 List<LearningRecord> recordList = results.stream() .map(record -> JsonUtils.toBean(record.toString(), LearningRecord.class)) .collect(Collectors.toList()); return recordList; } catch (Exception e) { log.error("缓存读取异常", e); return null; } }
Java

java

} } else { // * 非首次完成,缓存数据至Redis中并提交延迟任务 // * 更新课表在延迟任务中完成 record.setId(learningRecord.getId()); record.setFinished(learningRecord.getFinished()); taskHandler.addLearningRecordTask(record); // * 定时任务方案 // taskHandler.addLearningRecordTaskScheduled(record); return; }
Java

Day05练习参考实现

修改问题

展开
notion image

java

/** * 更新问题 */ @Override public void updateQuestionById(Long id, QuestionFormDTO dto) { // * 没有要更新的数据 if (dto == null || id == null) { return; } // ? 如果没有数据也会返回false,没法区分无数据更新与实际数据库操作失败 lambdaUpdate() .eq(InteractionQuestion::getId, id) .eq(InteractionQuestion::getUserId, UserContext.getUser()) .set(dto.getAnonymity() != null, InteractionQuestion::getAnonymity, dto.getAnonymity()) .set(dto.getTitle() != null, InteractionQuestion::getTitle, dto.getTitle()) .set(dto.getDescription() != null, InteractionQuestion::getDescription, dto.getDescription()) .update(); }
Java

删除问题

展开
notion image

java

/** * 删除问题 */ @Override public void deleteQuestionById(Long id) { // * 待删除问题不存在 if (id == null) { return; } Long userId = UserContext.getUser(); // * 问题必须为当前用户提出的 InteractionQuestion question = lambdaQuery() .eq(InteractionQuestion::getId, id) .eq(InteractionQuestion::getUserId, userId) .one(); if (question == null) { throw new BizIllegalException("不能删除不是自己的问题"); } // * 删除问题 boolean success = removeById(id); if (!success) { throw new DbException("删除用户问题失败"); } // * 删除回复 replyService.lambdaUpdate() .eq(InteractionReply::getQuestionId, id) .remove(); }
Java

管理端隐藏或显示问题

展开
notion image

java

/** * 更新问题隐藏状态(管理端) */ @Override public void updateQuestionHiddenById(Long id, Boolean hidden) { // * 无更新信息 if (id == null || hidden == null) { return; } boolean success = lambdaUpdate() .eq(InteractionQuestion::getId, id) .set(InteractionQuestion::getHidden, hidden) .update(); if (!success) { throw new DbException("更新问题隐藏状态失败"); } }
Java

管理端根据id查询问题详情

展开
notion image
notion image
notion image

java

/** * 根据问题id查询问题 * 一个接口带你了解CRUD为什么是体力活😅 */ @Override public QuestionAdminVO queryQuestionByIdAdmin(Long id) { // * 不存在待查询数据 if (id == null) { return null; } InteractionQuestion question = lambdaQuery() .eq(InteractionQuestion::getId, id) .one(); // * 数据库不存在此数据 if (question == null) { return null; } // * 查询过后标记问题状态为已查看 if (question.getStatus() == QuestionStatus.UN_CHECK) { lambdaUpdate() .eq(InteractionQuestion::getId, id) .set(InteractionQuestion::getStatus, QuestionStatus.CHECKED) .update(); } question.setStatus(QuestionStatus.CHECKED); QuestionAdminVO vo = BeanUtils.copyBean(question, QuestionAdminVO.class); // * 补全课程相关数据 if (question.getCourseId() != null) { CourseFullInfoDTO course = courseClient.getCourseInfoById(question.getCourseId(), true, true); if (course != null) { List<Long> teacherIds = course.getTeacherIds(); // * 健壮性检查,存在对应教师数据 if (CollUtils.isNotEmpty(teacherIds)) { List<UserDTO> teachers = userClient.queryUserByIds(teacherIds); // * 补全教师用户名数据 if (CollUtils.isNotEmpty(teachers)) { vo.setTeacherName(teachers.stream() .map(UserDTO::getName) .collect(Collectors.joining("/"))); } } // * 补全课程名数据 vo.setCourseName(course.getName()); // * 补全分类相关数据 List<Long> categoryIds = course.getCategoryIds(); if (CollUtils.isNotEmpty(categoryIds)) { String categoryNames = categoryCache.getCategoryNames(categoryIds); if (categoryNames != null) { vo.setCategoryName(categoryNames); } } } } // * 补全用户相关数据 if (question.getUserId() != null) { UserDTO user = userClient.queryUserById(question.getUserId()); if (user != null) { vo.setUserName(user.getUsername()); } } // * 补全章节相关数据 List<Long> catalogueIds = new ArrayList<>(); if (question.getChapterId() != null) { catalogueIds.add(question.getChapterId()); } if (question.getSectionId() != null) { catalogueIds.add(question.getSectionId()); } if (CollUtils.isNotEmpty(catalogueIds)) { List<CataSimpleInfoDTO> catalogueInfoList = catalogueClient.batchQueryCatalogue(catalogueIds); if (CollUtils.isNotEmpty(catalogueInfoList)) { Map<Long, String> catalogueNameMap = catalogueInfoList.stream() .collect(Collectors.toMap(CataSimpleInfoDTO::getId, CataSimpleInfoDTO::getName)); vo.setChapterName(catalogueNameMap.getOrDefault(question.getChapterId(), "")); vo.setSectionName(catalogueNameMap.getOrDefault(question.getSectionId(), "")); } } return vo; }
Java

新增回答或评论

展开
notion image

java

@Override @Transactional public void saveReply(ReplyDTO dto) { // * 健壮性校验 Long questionId = dto.getQuestionId(); Long answerId = dto.getAnswerId(); Long userId = UserContext.getUser(); if (questionId == null && answerId == null) { throw new BizIllegalException("问题id和回答id不可同时为空"); } // * 补全评论者id InteractionReply reply = BeanUtils.copyBean(dto, InteractionReply.class); reply.setUserId(userId); // * 保存评论 boolean success = save(reply); if (!success) { throw new DbException("新增回复/评论失败"); } // * 是否是评论 // * 是评论(问题下二级) if (answerId != null) { success = lambdaUpdate() .setSql("reply_times = reply_times + 1") .eq(InteractionReply::getId, reply.getAnswerId()) .update(); if (!success) { throw new DbException("新增评论更新评论表失败"); } } // * 不是评论,属于回复(问题下一级) questionService.lambdaUpdate() .set(answerId == null, InteractionQuestion::getLatestAnswerId, reply.getId()) .setSql(answerId == null, "answer_times = answer_times + 1") .set(BooleanUtils.isTrue(dto.getIsStudent()), InteractionQuestion::getStatus, QuestionStatus.UN_CHECK) .eq(InteractionQuestion::getId, reply.getQuestionId()) .update(); // * 回答获得积分,推送mq mqHelper.send(MqConstants.Exchange.LEARNING_EXCHANGE, MqConstants.Key.WRITE_REPLY, PointsMessage.of(userId, PointsRecordType.QA.getRewardPoints())); }
Java

分页查询回答或评论列表|管理端分页查询回答或评论列表

展开
notion image
notion image
notion image

java

/** * 分页查询回答/评论 */ @Override public PageDTO<ReplyVO> queryReplyPage(ReplyPageQuery query, boolean isAdmin) { // * 健壮性校验 Long questionId = query.getQuestionId(); Long answerId = query.getAnswerId(); if (questionId == null && answerId == null) { throw new BizIllegalException("问题id和回答id不可同时为空"); } // * 查询数据库 Page<InteractionReply> page = lambdaQuery() .eq(InteractionReply::getAnswerId, answerId == null ? 0L : answerId) .eq(questionId != null, InteractionReply::getQuestionId, questionId) .eq(!isAdmin, InteractionReply::getHidden, false) .page(query.toMpPage(new OrderItem(Constant.DATA_FIELD_NAME_LIKED_TIME, false), new OrderItem(Constant.DATA_FIELD_NAME_CREATE_TIME, true))); List<InteractionReply> records = page.getRecords(); if (CollUtils.isEmpty(records)) { return PageDTO.empty(page); } // * 填充信息,构造用户id(对评论而言是部分)集合,目标回答id集合查询 Set<Long> targetReplies = new HashSet<>(); Set<Long> userIds = new HashSet<>(); for (InteractionReply record : records) { // * 管理端或未匿名 if (isAdmin || !record.getAnonymity()) { userIds.add(record.getUserId()); } // * 评论特有 if (answerId != null) { targetReplies.add(record.getTargetReplyId()); } } // * 去除不带目标用户引入的null值 targetReplies.remove(null); // * 查询目标回答,获取目标用户id(非匿名部分) List<InteractionReply> targetReplyList = new ArrayList<>(); // * 查评论情况下才查数据库 if (answerId != null) { targetReplyList = listByIds(targetReplies); } if (CollUtils.isNotEmpty(targetReplyList)) { for (InteractionReply reply : targetReplyList) { // * 非管理端且匿名或隐藏不加入 if (!isAdmin && (reply.getAnonymity() || reply.getHidden())) continue; userIds.add(reply.getTargetUserId()); } } // * 根据完整的用户id集合查询用户信息 List<UserDTO> users = userClient.queryUserByIds(userIds); Map<Long, UserDTO> userMap = users.stream() .collect(Collectors.toMap(UserDTO::getId, Function.identity())); // * 查询点赞过的replyIds List<Long> likedReplyIds = records.stream() .map(InteractionReply::getId) .collect(Collectors.toList()); Set<Long> likedReplyIdSet = remarkClient.queryLikedListByUserIdsAndBizIds(bizType, likedReplyIds); // * 补全vo信息 List<ReplyVO> voList = new ArrayList<>(); for (InteractionReply record : records) { ReplyVO vo = BeanUtils.copyBean(record, ReplyVO.class); Long userId = record.getUserId(); UserDTO user = userMap.get(userId); // * 匿名不加入信息 if (user != null) { vo.setUserName(user.getUsername()); vo.setUserIcon(user.getIcon()); vo.setUserType(user.getType()); } // * 评论补全目标用户信息 if (answerId != null) { UserDTO targetUser = userMap.get(record.getTargetUserId()); // * 匿名下返回null if (targetUser != null) { vo.setTargetUserName(targetUser.getUsername()); } } // * 设置点赞高亮 if (likedReplyIdSet.contains(record.getId())) { vo.setLiked(true); } voList.add(vo); } return PageDTO.of(page, voList); }
Java

管理端显示或隐藏评论

展开
notion image

java

/** * 修改评论显示状态(管理端) */ @Override public void updateReplyHiddenById(Long id, Boolean hidden) { // * 无状态更新 if (hidden == null) { return; } // * 如果隐藏了回答,由于评论是懒加载,隐藏的回答点不进触发评论的加载,也就间接隐藏了 // * 回答的隐藏不应修改评论的隐藏,否则此操作不可恢复 lambdaUpdate() .eq(InteractionReply::getId, id) .set(InteractionReply::getHidden, hidden) .update(); }
Java

其他注意事项

区分Spring与Swagger的@RequestBody注解

notion image

代码参考模板

RabbitMQ消费者模板
notion image

java

package com.tianji.learning.mq; import com.tianji.api.dto.trade.OrderBasicDTO; import com.tianji.common.constants.MqConstants; import com.tianji.common.utils.CollUtils; import com.tianji.learning.service.ILearningLessonService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.amqp.core.ExchangeTypes; import org.springframework.amqp.rabbit.annotation.Exchange; import org.springframework.amqp.rabbit.annotation.Queue; import org.springframework.amqp.rabbit.annotation.QueueBinding; import org.springframework.amqp.rabbit.annotation.RabbitListener; import org.springframework.stereotype.Component; /** * @author CamelliaV * @since 2024/10/30 / 21:48 */ @Component @Slf4j @RequiredArgsConstructor public class LessonChangeListener { private final ILearningLessonService lessonService; @RabbitListener( bindings = @QueueBinding( value = @Queue(name = MqConstants.Queue.ORDER_PAY_QUEUE, durable = "true"), exchange = @Exchange(name = MqConstants.Exchange.ORDER_EXCHANGE, type = ExchangeTypes.TOPIC), key = MqConstants.Key.ORDER_PAY_KEY ) ) public void listenLessonPay(OrderBasicDTO dto) { // * 健壮性检查 if (dto == null || dto.getUserId() == null || CollUtils.isEmpty(dto.getCourseIds())) { log.error("MQ消息错误,订单数据为空"); return; } // * 为对应用户添加课程(信息均存放于dto中) lessonService.addLesson(dto); } @RabbitListener( bindings = @QueueBinding( value = @Queue(name = MqConstants.Queue.ORDER_REFUND_QUEUE, durable = "true"), exchange = @Exchange(name = MqConstants.Exchange.ORDER_EXCHANGE, type = ExchangeTypes.TOPIC), key = MqConstants.Key.ORDER_REFUND_KEY ) ) public void listenLessonRefund(OrderBasicDTO dto) { // * 健壮性检查 if (dto == null || dto.getUserId() == null || CollUtils.isEmpty(dto.getCourseIds())) { log.error("MQ消息错误,退款数据为空"); return; } // * 为对应用户删除课程 Long userId = dto.getUserId(); if (userId == null) { return; } Long courseId = dto.getCourseIds() .get(0); if (courseId == null) { return; } lessonService.deleteLessonByCourse(userId, courseId); } }
Java
Interface常量类模板
notion image

java

package com.tianji.common.constants; public interface MqConstants { interface Exchange { /*课程有关的交换机*/ String COURSE_EXCHANGE = "course.topic"; /*订单有关的交换机*/ String ORDER_EXCHANGE = "order.topic"; /*学习有关的交换机*/ String LEARNING_EXCHANGE = "learning.topic"; /*信息中心短信相关的交换机*/ String SMS_EXCHANGE = "sms.direct"; /*异常信息的交换机*/ String ERROR_EXCHANGE = "error.topic"; /*支付有关的交换机*/ String PAY_EXCHANGE = "pay.topic"; /*交易服务延迟任务交换机*/ String TRADE_DELAY_EXCHANGE = "trade.delay.topic"; /*点赞记录有关的交换机*/ String LIKE_RECORD_EXCHANGE = "like.record.topic"; } interface Queue { String ERROR_QUEUE_TEMPLATE = "error.{}.queue"; String ORDER_PAY_QUEUE = "learning.lesson.pay.queue"; String ORDER_REFUND_QUEUE = "learning.lesson.refund.queue"; } interface Key { /*课程有关的 RoutingKey*/ String COURSE_NEW_KEY = "course.new"; String COURSE_UP_KEY = "course.up"; String COURSE_DOWN_KEY = "course.down"; String COURSE_EXPIRE_KEY = "course.expire"; String COURSE_DELETE_KEY = "course.delete"; /*订单有关的RoutingKey*/ String ORDER_PAY_KEY = "order.pay"; String ORDER_REFUND_KEY = "order.refund"; /*积分相关RoutingKey*/ /* 写回答 */ String WRITE_REPLY = "reply.new"; /* 签到 */ String SIGN_IN = "sign.in"; /* 学习视频 */ String LEARN_SECTION = "section.learned"; /* 写笔记 */ String WRITE_NOTE = "note.new"; /* 笔记被采集 */ String NOTE_GATHERED = "note.gathered"; /*点赞的RoutingKey*/ String LIKED_TIMES_KEY_TEMPLATE = "{}.times.changed"; /*问答*/ String QA_LIKED_TIMES_KEY = "QA.times.changed"; /*笔记*/ String NOTE_LIKED_TIMES_KEY = "NOTE.times.changed"; /*短信系统发送短信*/ String SMS_MESSAGE = "sms.message"; /*异常RoutingKey的前缀*/ String ERROR_KEY_PREFIX = "error."; String DEFAULT_ERROR_KEY = "error.#"; /*支付有关的key*/ String PAY_SUCCESS = "pay.success"; String REFUND_CHANGE = "refund.status.change"; String ORDER_DELAY_KEY = "delay.order.query"; } }
Java
SpringTask定时任务模版
启动类
notion image

java

package com.tianji.learning; import lombok.extern.slf4j.Slf4j; import org.mybatis.spring.annotation.MapperScan; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.builder.SpringApplicationBuilder; import org.springframework.core.env.Environment; import org.springframework.scheduling.annotation.EnableScheduling; import java.net.InetAddress; import java.net.UnknownHostException; @SpringBootApplication @EnableScheduling @MapperScan("com.tianji.learning.mapper") @Slf4j public class LearningApplication { public static void main(String[] args) throws UnknownHostException { SpringApplication app = new SpringApplicationBuilder(LearningApplication.class).build(args); Environment env = app.run(args).getEnvironment(); String protocol = "http"; if (env.getProperty("server.ssl.key-store") != null) { protocol = "https"; } log.info("--/\n---------------------------------------------------------------------------------------\n\t" + "Application '{}' is running! Access URLs:\n\t" + "Local: \t\t{}://localhost:{}\n\t" + "External: \t{}://{}:{}\n\t" + "Profile(s): \t{}" + "\n---------------------------------------------------------------------------------------", env.getProperty("spring.application.name"), protocol, env.getProperty("server.port"), protocol, InetAddress.getLocalHost().getHostAddress(), env.getProperty("server.port"), env.getActiveProfiles()); } }
Java
定时任务类
notion image

java

package com.tianji.learning.task; import com.tianji.learning.service.ILearningLessonService; import lombok.RequiredArgsConstructor; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; /** * @author CamelliaV * @since 2024/11/8 / 21:30 */ @Component @RequiredArgsConstructor public class LessonExpireTask { private final ILearningLessonService lessonService; @Scheduled(cron = "0 0 2 * * ?") public void checkAndExpireLessons() { lessonService.checkAndExpireLessons(); } }
Java
DelayQueue单机延迟任务模板
DelayTask任务定义
notion image

java

package com.tianji.learning.task; import lombok.Data; import java.time.Duration; import java.util.concurrent.Delayed; import java.util.concurrent.TimeUnit; /** * @author CamelliaV * @since 2024/11/8 / 16:23 */ @Data public class DelayTask<D> implements Delayed { private D data; // * nanoseconds private long activeTime; public DelayTask(D data, Duration delayTime) { this.data = data; this.activeTime = System.nanoTime() + delayTime.toNanos(); } // * 返回任务执行剩余时间 @Override public long getDelay(TimeUnit unit) { return unit.convert(Math.max(0, activeTime - System.nanoTime()), TimeUnit.NANOSECONDS); } // * 排序 @Override public int compareTo(Delayed o) { long l = getDelay(TimeUnit.NANOSECONDS) - o.getDelay(TimeUnit.NANOSECONDS); if (l > 0) { return 1; } else if (l < 0) { return -1; } else { return 0; } } }
Java
notion image
notion image
notion image
notion image

java

package com.tianji.learning.task; import com.tianji.common.exceptions.DbException; import com.tianji.common.utils.CollUtils; import com.tianji.common.utils.JsonUtils; import com.tianji.common.utils.StringUtils; import com.tianji.learning.domain.po.LearningLesson; import com.tianji.learning.domain.po.LearningRecord; import com.tianji.learning.mapper.LearningRecordMapper; import com.tianji.learning.service.ILearningLessonService; import com.tianji.learning.service.ILearningRecordService; import lombok.Data; import lombok.NoArgsConstructor; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.dao.DataAccessException; import org.springframework.data.redis.core.RedisOperations; import org.springframework.data.redis.core.SessionCallback; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import javax.annotation.PostConstruct; import javax.annotation.PreDestroy; import java.time.Duration; import java.time.LocalDateTime; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.concurrent.CompletableFuture; import java.util.concurrent.DelayQueue; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.stream.Collectors; @Slf4j @RequiredArgsConstructor @Component public class LearningRecordDelayTaskHandler { private final static String RECORD_KEY_TEMPLATE = "learning:record:{}"; // * 实际核心数,非逻辑处理器数 private final static int CPU_ACTUAL_CORES = 8; private static volatile boolean begin = true; private final StringRedisTemplate redisTemplate; private final DelayQueue<DelayTask<RecordTaskData>> queue = new DelayQueue<>(); private final LearningRecordMapper recordMapper; private final ILearningLessonService lessonService; // * 涉及循环依赖 修改了spring.main.allow-circular-references,使用autowired自动确定注入时机 // * 偷懒复用批量更新 @Autowired private final ILearningRecordService recordService; // * 仅为实现定时任务(不采用)添加,key为记录id,value为记录 private final Map<Long, LearningRecord> lastRecords; @PostConstruct public void init() { ExecutorService threadPool = Executors.newFixedThreadPool(CPU_ACTUAL_CORES); CompletableFuture.runAsync(this::handleDelayTask, threadPool); } @PreDestroy public void destroy() { log.debug("关闭学习记录处理的延迟任务"); begin = false; } /** * (仅实现,不采用)定时任务,每隔20秒检查Redis缓存是否有需要持久化的学习记录 */ @Scheduled(fixedRate = 20_000) private void checkAndPersistRecords() { log.debug("定时持久化学习记录开始"); // * 没有需要更新的数据(Redis数据为空) if (CollUtils.isEmpty(lastRecords)) { return; } List<LearningRecord> recordList = readRecordCacheBatch(); // * 健壮性检查,Redis中无数据,原则上lastRecords不为空Redis数据也不为空 if (CollUtils.isEmpty(recordList)) { return; } // * (可选)仅更新播放进度相比上次写入时没有变化的数据,但可能出现刚写入Redis就触发定时更新的情况,效果未必好 List<LearningRecord> recordsToUpdate = recordList.stream() .filter(record -> Objects.equals(record.getMoment(), lastRecords.get(record.getId()) .getMoment())) .collect(Collectors.toList()); // * finished不修改,此处必定为非第一次完成 recordsToUpdate.forEach(record -> record.setFinished(null)); // * 更新学习记录 boolean success = recordService.updateBatchById(recordsToUpdate); if (!success) { throw new DbException("定时任务更新学习记录失败"); } // * 更新课表 最近学习小节id与时间 // * Redis中数据只有三部分,需要使用更完全的数据 List<LearningLesson> lessons = recordsToUpdate.stream() .map(record -> { LearningRecord fullRecord = lastRecords.get(record.getId()); LearningLesson lesson = new LearningLesson(); lesson.setId(fullRecord.getLessonId()); lesson.setLatestLearnTime(LocalDateTime.now()); lesson.setLatestSectionId(fullRecord.getSectionId()); return lesson; }) .collect(Collectors.toList()); success = lessonService.updateBatchById(lessons); if (!success) { throw new DbException("定时任务更新课表失败"); } // * 更新结束,清空暂存区 lastRecords.clear(); log.info("定时持久化学习记录任务成功"); } private void handleDelayTask() { while (begin) { try { // 1.尝试获取任务 DelayTask<RecordTaskData> task = queue.take(); log.debug("获取到要处理的播放记录任务"); RecordTaskData data = task.getData(); // 2.读取Redis缓存 LearningRecord record = readRecordCache(data.getLessonId(), data.getSectionId()); if (record == null) { continue; } // 3.比较数据 if (!Objects.equals(data.getMoment(), record.getMoment())) { // 4.如果不一致,播放进度在变化,无需持久化 continue; } // 5.如果一致,证明用户离开了视频,需要持久化 // 5.1.更新学习记录 record.setFinished(null); recordMapper.updateById(record); // 5.2.更新课表 LearningLesson lesson = new LearningLesson(); lesson.setId(data.getLessonId()); lesson.setLatestSectionId(data.getSectionId()); lesson.setLatestLearnTime(LocalDateTime.now()); lessonService.updateById(lesson); log.debug("准备持久化学习记录信息"); } catch (Exception e) { log.error("处理播放记录任务发生异常", e); } } } // * 替换addLearningRecordTask采用定时任务方案 public void addLearningRecordTaskScheduled(LearningRecord record) { // 1.添加数据到Redis缓存 writeRecordCache(record); // 2.添加后续需要更新数据库的数据 lastRecords.putIfAbsent(record.getId(), record); } // * 定时方案使用 private List<LearningRecord> readRecordCacheBatch() { try { // * 1.批量读取Redis数据,因为没有根据lessonId聚类,这里只一次传输多条单field读取 // * 逆天API三个泛型只给传一个 List<Object> results = redisTemplate.executePipelined(new SessionCallback<Object>() { @Override public Object execute(RedisOperations operations) throws DataAccessException { for (Long recordKey : lastRecords.keySet()) { LearningRecord record = lastRecords.get(recordKey); String key = StringUtils.format(RECORD_KEY_TEMPLATE, record.getLessonId()); // * 完全虚假的类型安全 // * 18年的issue 2024还没close😅 // * https://github.com/spring-projects/spring-data-redis/issues/1431 //noinspection unchecked operations.opsForHash() .get(key, record.getSectionId() .toString()); } return null; } }); // * Redis中无数据,不进行更新 if (CollUtils.isEmpty(results)) { return null; } // * 反序列化Redis数据用于后续Service数据库数据更新 List<LearningRecord> recordList = results.stream() .map(record -> JsonUtils.toBean(record.toString(), LearningRecord.class)) .collect(Collectors.toList()); return recordList; } catch (Exception e) { log.error("缓存读取异常", e); return null; } } public void addLearningRecordTask(LearningRecord record) { // 1.添加数据到Redis缓存 writeRecordCache(record); // 2.提交延迟任务到延迟队列 DelayQueue queue.add(new DelayTask<>(new RecordTaskData(record), Duration.ofSeconds(20))); } public void writeRecordCache(LearningRecord record) { log.debug("更新学习记录的缓存数据"); try { // 1.数据转换 String json = JsonUtils.toJsonStr(new RecordCacheData(record)); // 2.写入Redis String key = StringUtils.format(RECORD_KEY_TEMPLATE, record.getLessonId()); redisTemplate.opsForHash() .put(key, record.getSectionId() .toString(), json); // 3.添加缓存过期时间 redisTemplate.expire(key, Duration.ofMinutes(1)); } catch (Exception e) { log.error("更新学习记录缓存异常", e); } } public LearningRecord readRecordCache(Long lessonId, Long sectionId) { try { // 1.读取Redis数据 String key = StringUtils.format(RECORD_KEY_TEMPLATE, lessonId); Object cacheData = redisTemplate.opsForHash() .get(key, sectionId.toString()); if (cacheData == null) { return null; } // 2.数据检查和转换 return JsonUtils.toBean(cacheData.toString(), LearningRecord.class); } catch (Exception e) { log.error("缓存读取异常", e); return null; } } public void cleanRecordCache(Long lessonId, Long sectionId) { // 删除数据 String key = StringUtils.format(RECORD_KEY_TEMPLATE, lessonId); redisTemplate.opsForHash() .delete(key, sectionId.toString()); } @Data @NoArgsConstructor private static class RecordCacheData { private Long id; private Integer moment; private Boolean finished; public RecordCacheData(LearningRecord record) { this.id = record.getId(); this.moment = record.getMoment(); this.finished = record.getFinished(); } } @Data @NoArgsConstructor private static class RecordTaskData { private Long lessonId; private Long sectionId; private Integer moment; public RecordTaskData(LearningRecord record) { this.lessonId = record.getLessonId(); this.sectionId = record.getSectionId(); this.moment = record.getMoment(); } } }
Java
Caffeine模板|自动装配
notion image
notion image
notion image

java

package com.tianji.api.cache; import com.github.benmanes.caffeine.cache.Cache; import com.tianji.api.client.course.CategoryClient; import com.tianji.api.dto.course.CategoryBasicDTO; import com.tianji.common.utils.CollUtils; import lombok.RequiredArgsConstructor; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.function.Function; import java.util.stream.Collectors; @RequiredArgsConstructor public class CategoryCache { private final Cache<String, Map<Long, CategoryBasicDTO>> categoryCaches; private final CategoryClient categoryClient; public Map<Long, CategoryBasicDTO> getCategoryMap() { return categoryCaches.get("CATEGORY", key -> { // 1.从CategoryClient查询 List<CategoryBasicDTO> list = categoryClient.getAllOfOneLevel(); if (list == null || list.isEmpty()) { return CollUtils.emptyMap(); } // 2.转换数据 return list.stream().collect(Collectors.toMap(CategoryBasicDTO::getId, Function.identity())); }); } public String getCategoryNames(List<Long> ids) { if (ids == null || ids.size() == 0) { return ""; } // 1.读取分类缓存 Map<Long, CategoryBasicDTO> map = getCategoryMap(); // 2.根据id查询分类名称并组装 StringBuilder sb = new StringBuilder(); for (Long id : ids) { sb.append(map.get(id).getName()).append("/"); } // 3.返回结果 return sb.deleteCharAt(sb.length() - 1).toString(); } public List<String> getCategoryNameList(List<Long> ids) { if (ids == null || ids.size() == 0) { return CollUtils.emptyList(); } // 1.读取分类缓存 Map<Long, CategoryBasicDTO> map = getCategoryMap(); // 2.根据id查询分类名称并组装 List<String> list = new ArrayList<>(ids.size()); for (Long id : ids) { list.add(map.get(id).getName()); } // 3.返回结果 return list; } public List<CategoryBasicDTO> queryCategoryByIds(List<Long> ids) { if (ids == null || ids.size() == 0) { return CollUtils.emptyList(); } Map<Long, CategoryBasicDTO> map = getCategoryMap(); return ids.stream() .map(map::get) .collect(Collectors.toList()); } public List<String> getNameByLv3Ids(List<Long> lv3Ids) { Map<Long, CategoryBasicDTO> map = getCategoryMap(); List<String> list = new ArrayList<>(lv3Ids.size()); for (Long lv3Id : lv3Ids) { CategoryBasicDTO lv3 = map.get(lv3Id); CategoryBasicDTO lv2 = map.get(lv3.getParentId()); CategoryBasicDTO lv1 = map.get(lv2.getParentId()); list.add(lv1.getName() + "/" + lv2.getName() + "/" + lv3.getName()); } return list; } public String getNameByLv3Id(Long lv3Id) { Map<Long, CategoryBasicDTO> map = getCategoryMap(); CategoryBasicDTO lv3 = map.get(lv3Id); CategoryBasicDTO lv2 = map.get(lv3.getParentId()); CategoryBasicDTO lv1 = map.get(lv2.getParentId()); return lv1.getName() + "/" + lv2.getName() + "/" + lv3.getName(); } }
Java
notion image

java

package com.tianji.api.config; import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; import com.tianji.api.cache.CategoryCache; import com.tianji.api.client.course.CategoryClient; import com.tianji.api.dto.course.CategoryBasicDTO; import org.springframework.context.annotation.Bean; import java.time.Duration; import java.util.Map; public class CategoryCacheConfig { /** * 课程分类的caffeine缓存 */ @Bean public Cache<String, Map<Long, CategoryBasicDTO>> categoryCaches(){ return Caffeine.newBuilder() .initialCapacity(1) // 容量限制 .maximumSize(10_000) // 最大内存限制 .expireAfterWrite(Duration.ofMinutes(30)) // 有效期 .build(); } /** * 课程分类的缓存工具类 */ @Bean public CategoryCache categoryCache( Cache<String, Map<Long, CategoryBasicDTO>> categoryCaches, CategoryClient categoryClient){ return new CategoryCache(categoryCaches, categoryClient); } }
Java
自动装配模板
notion image
notion image

java

org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ com.tianji.api.config.RequestIdRelayConfiguration, \ com.tianji.api.config.RoleCacheConfig, \ com.tianji.api.config.FallbackConfig, \ com.tianji.api.config.CategoryCacheConfig
Java
Openfeign模板|自动装配
notion image

java

package com.tianji.api.config; import com.tianji.api.client.learning.fallback.LearningClientFallback; import com.tianji.api.client.promotion.fallback.PromotionClientFallback; import com.tianji.api.client.remark.fallback.RemarkClientFallback; import com.tianji.api.client.trade.fallback.TradeClientFallback; import com.tianji.api.client.user.fallback.UserClientFallback; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class FallbackConfig { @Bean public LearningClientFallback learningClientFallback() { return new LearningClientFallback(); } @Bean public TradeClientFallback tradeClientFallback() { return new TradeClientFallback(); } @Bean public UserClientFallback userClientFallback() { return new UserClientFallback(); } @Bean public RemarkClientFallback remarkClientFallback() { return new RemarkClientFallback(); } @Bean public PromotionClientFallback promotionClientFallback() { return new PromotionClientFallback(); } }
Java

了解更多

 
记一次debug天机学堂Day06-Day08复盘-点赞|积分|排行榜业务
Loading...
CamelliaV
CamelliaV
Java;CV;ACGN
最新发布
单例模式的四种写法
2025-4-24
体验MCP
2025-4-24
MetingJS使用自定义音乐源-CF+Huggingface部署
2025-4-2
博客访问站点测速分析与对比
2025-3-26
前端模块化
2025-3-16
Voxel2Mesh相关论文精读与代码复现
2025-3-15
公告
计划:
  • LLM相关
  • 支付业务 & 双token无感刷新
  • (线程池计算优惠方案)天机学堂Day09-Day12复盘-优惠劵业务
  • (业务复盘,技术汇总)天机学堂完结复盘
  • hot 100
 
2024-2025CamelliaV.

CamelliaV | Java;CV;ACGN


  1. 1 给予你的爱 Xi YuaN/Digital Vengeance/唢清
  2. 2 スペルビア帝国/夜 平松建治
  3. 3 Imagination QQHHh
  4. 4 virtues QQHHh
  5. 5 Tricolor (short ver.) Digital Vengeance/44
  6. 6 港口夜 - 四周年 月代彩
  7. 7 神よ、その黄昏よ 金﨑猛
  8. 8 絆炎 (English Ver) Katherine Eames
  9. 9 ラストエンゲージ~祈りの呪文 馬場泰久
  10. 10 an evening calm fripSide
  11. 11 フレスベルグの少女~風花雪月~ Caro
  12. 12 Answer 北原春希/小木曽雪菜
  13. 13 Kiss Kiss Kiss BENI
  14. 14 远航高歌 染音若蔡/阿南
  15. 15 Sentimental Blue Trident
  16. 16 目指す先にあるもの Falcom Sound Team J.D.K.
  17. 17 Night City r e l/Artemis Delta
  18. 18 Gimme×Gimme P*Light/Giga/初音ミク/鏡音リン
  19. 19 桃幻浪漫 Airots/Active Planets & AUGUST
  20. 20 DESIRE 美郷あき
  21. 21 镜花堂(feat.芬璃尔) 幻塔手游/Rux
  22. 22 she was sitting under the osmanthus tree 梶浦由記
给予你的爱 - Xi YuaN/Digital Vengeance/唢清
00:00 / 00:00