以下文章来源Java架构师技术,回复”Spring“获惊喜礼包大家好,我是Java架构师
0.前言
本文基于Springboot利用Redis实现点赞功能的缓存和定时持久化接口。
用户对浏览内容进行【点赞/取赞】,并发送【点赞/取赞】请求到后端,这些信息先存入Redis中缓存,再每隔两小时将Redis中的内容直接写入数据库持久化存储。
1.项目目录结构
2.Redis缓存点赞消息
1.设计思路
用户点赞一条数据,设置状态为0,并且更新被点赞内容的likeCount+1
用户取消点赞一条数据,设置状态为1,并且更新被点赞内容的likeCount+0
1.1 Redis键值对设计
选用Hash(散列)存储
点赞信息
- key: (String) 浏览信息id和点赞用户id拼接而成, 分隔符为::
- value: (HashMap) 存储点赞状态(0: 点赞 1:取消点赞)和更新时间的时间戳
- key: "浏览信息id::点赞用户id" value:
{status: Integer, updateTime: long}
点赞数量
1.2 点赞
- 【不存在(没有对应key) 】|| 【取消点赞(即取出的status为1)】
- 【点赞(即取出的status为0,此时相当于重复点赞行为)】
- 另外,搜索公众号Java后端栈后台回复“私活”,获取一份惊喜礼包。
1.3 取消点赞
- 【取消点赞(即取出的status为1,此时相当于重复取消点赞行为)】
2.核心代码实现
2.1 Redis封装
具体实现参考该博客,不在赘述。
- https://www.cnblogs.com/caizhaokai/p/11037610.html
2.2 工具类
1.时间戳转化为LocalDateTime
import java.time.Instant;import java.time.LocalDateTime;import java.time.ZoneId;/** * 工具类:将时间戳转化为LocalDateTime * 主要是因为redis不好存储LocalDateTime,存储timestamp方便一点,而且格式可以随意改变 */public class LocalDateTimeConvertUtil { public static LocalDateTime getDateTimeOfTimestamp(long timestamp) { Instant instant = Instant.ofEpochMilli(timestamp); ZoneId zone = ZoneId.systemDefault(); return LocalDateTime.ofInstant(instant, zone); }}
2.RedisKey处理类
public class RedisKeyUtils { /** * 保存用户点赞内容数据的key * @date 2021/9/26 14:44 */ public static final String MAP_KEY_USER_LIKED = "MAP_USER_LIKED"; /** * 保存内容被点赞数量的key * @date 2021/9/26 14:44 */ public static final String MAP_KEY_USER_LIKED_COUNT = "MAP_USER_LIKED_COUNT"; /** * 拼接被点赞的内容id和点赞的人的id作为key。格式 222222::333333 * @param infoId 被点赞的内容 id * @param likeUserId 点赞的人的id * @return */ public static String getLikedKey(String infoId, String likeUserId){ return infoId + "::" + likeUserId; }}
2.3 DTO
// UserLikesDTO.java@Data@AllArgsConstructor@NoArgsConstructorpublic class UserLikesDTO { private String infoId; private String likeUserId; private Integer status; private LocalDateTime updateTime;}// UserLikeCountDTO.java@Data@AllArgsConstructor@NoArgsConstructorpublic class UserLikeCountDTO { private String infoId; private Integer likeCount;}
2.4 Service
// RedisService.javaimport com.csu.edu.redisLikeDemo.domain.DTO.UserLikeCountDTO;import com.csu.edu.redisLikeDemo.domain.DTO.UserLikesDTO;import java.util.List;/** * 负责将数据写入Redis缓存 */public interface RedisService { /** * 获取点赞状态 * @param infoId * @param likeUserId */ Integer getLikeStatus(String infoId, String likeUserId); /** * 点赞。状态为1 * @param infoId * @param likeUserId */ void saveLiked2Redis(String infoId, String likeUserId); /** * 取消点赞。将状态改变为0 * @param infoId * @param likeUserId */ void unlikeFromRedis(String infoId, String likeUserId); /** * 从Redis中删除一条点赞数据 * @param infoId * @param likeUserId */ void deleteLikedFromRedis(String infoId, String likeUserId); /** * 该内容的点赞数变化Δdelta * @param infoId */ void in_decrementLikedCount(String infoId, Integer delta); /** * 获取Redis中存储的所有点赞数据 * @return */ List<UserLikesDTO> getLikedDataFromRedis(); /** * 获取Redis中存储的所有点赞数量 * @return */ List<UserLikeCountDTO> getLikedCountFromRedis();}
import com.csu.edu.redisLikeDemo.common.CONSTANT;import com.csu.edu.redisLikeDemo.domain.DTO.UserLikeCountDTO;import com.csu.edu.redisLikeDemo.domain.DTO.UserLikesDTO;import com.csu.edu.redisLikeDemo.service.RedisService;import com.csu.edu.redisLikeDemo.util.LocalDateTimeConvertUtil;import com.csu.edu.redisLikeDemo.util.RedisKeyUtils;import lombok.extern.slf4j.Slf4j;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.data.redis.core.Cursor;import org.springframework.data.redis.core.HashOperations;import org.springframework.data.redis.core.ScanOptions;import org.springframework.stereotype.Service;import java.time.LocalDateTime;import java.util.ArrayList;import java.util.HashMap;import java.util.List;import java.util.Map;@Service("redisService")@Slf4jpublic class RedisServiceImpl implements RedisService { @Autowired private HashOperations<String, String, Object> redisHash;// Redis Hash @Override public Integer getLikeStatus(String infoId, String likeUserId) { if (redisHash.hasKey(RedisKeyUtils.MAP_KEY_USER_LIKED, RedisKeyUtils.getLikedKey(infoId, likeUserId))){ HashMap<String, Object> map = (HashMap<String, Object>) redisHash.get(RedisKeyUtils.MAP_KEY_USER_LIKED, RedisKeyUtils.getLikedKey(infoId, likeUserId)); return (Integer) map.get("status"); } return CONSTANT.LikedStatusEum.NOT_EXIST.getCode(); } @Override public void saveLiked2Redis(String infoId, String likeUserId) { // 生成key String key = RedisKeyUtils.getLikedKey(infoId, likeUserId); // 封装value 喜欢状态 更新时间 HashMap<String,Object> map = new HashMap<>(); map.put("status",CONSTANT.LikedStatusEum.LIKE.getCode()); map.put("updateTime", System.currentTimeMillis()); redisHash.put(RedisKeyUtils.MAP_KEY_USER_LIKED, key, map); } @Override public void unlikeFromRedis(String infoId, String likeUserId) { // 生成key String key = RedisKeyUtils.getLikedKey(infoId, likeUserId); // 封装value 喜欢状态 更新时间 HashMap<String,Object> map = new HashMap<>(); map.put("status",CONSTANT.LikedStatusEum.UNLIKE.getCode()); map.put("updateTime", System.currentTimeMillis());// 存入当前时间戳 redisHash.put(RedisKeyUtils.MAP_KEY_USER_LIKED, key, map); } @Override public void deleteLikedFromRedis(String infoId, String likeUserId) { String key = RedisKeyUtils.getLikedKey(infoId, likeUserId); redisHash.delete(RedisKeyUtils.MAP_KEY_USER_LIKED, key); } @Override public void in_decrementLikedCount(String infoId, Integer delta) { redisHash.increment(RedisKeyUtils.MAP_KEY_USER_LIKED_COUNT, infoId, delta); } @Override public List<UserLikesDTO> getLikedDataFromRedis() { // scan 读取数据,比key匹配优雅 Cursor<Map.Entry<String, Object>> cursor = redisHash.scan(RedisKeyUtils.MAP_KEY_USER_LIKED, ScanOptions.NONE); List<UserLikesDTO> list = new ArrayList<>(); while (cursor.hasNext()){ Map.Entry<String, Object> entry = cursor.next(); String key = (String) entry.getKey(); //分离出 infoId,likedPostId, 解析value String[] split = key.split("::"); String infoId = split[0]; String likeUserId = split[1]; HashMap<String, Object> map = (HashMap<String, Object>) entry.getValue(); Integer status = (Integer) map.get("status"); long updateTimeStamp = (long) map.get("updateTime"); LocalDateTime updateTime = LocalDateTimeConvertUtil.getDateTimeOfTimestamp(updateTimeStamp);// 时间戳转为LocalDateTime //组装成 UserLike 对象 UserLikesDTO userLikesDTO = new UserLikesDTO(infoId, likeUserId, status, updateTime); list.add(userLikesDTO); //存到 list 后从 Redis 中清理缓存 redisHash.delete(RedisKeyUtils.MAP_KEY_USER_LIKED, key); } return list; } @Override public List<UserLikeCountDTO> getLikedCountFromRedis() { // scan 读取数据,比key匹配优雅 Cursor<Map.Entry<String, Object>> cursor = redisHash.scan(RedisKeyUtils.MAP_KEY_USER_LIKED_COUNT, ScanOptions.NONE); List<UserLikeCountDTO> list = new ArrayList<>(); while (cursor.hasNext()){ Map.Entry<String, Object> map = cursor.next(); //将点赞数量存储在 LikedCountDT String key = (String)map.getKey(); UserLikeCountDTO dto = new UserLikeCountDTO(key, (Integer) map.getValue()); list.add(dto); //从Redis中删除这条记录 redisHash.delete(RedisKeyUtils.MAP_KEY_USER_LIKED_COUNT, key); } return list; }}
2.5 controller & API
import com.csu.edu.redisLikeDemo.common.CommonResponse;import com.csu.edu.redisLikeDemo.service.LikeService;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.web.bind.annotation.PostMapping;import org.springframework.web.bind.annotation.RequestMapping;import org.springframework.web.bind.annotation.RestController;@RestController@RequestMapping("/test/")public class LikeController { @Autowired private LikeService likeService; @PostMapping("like") public CommonResponse<Object> likeInfo( String infoId, String userId){ return likeService.likeInfo(infoId, userId); } @PostMapping("dislike") public CommonResponse<Object> dislikeInfo( String infoId, String userId){ return likeService.dislikeInfo(infoId, userId); }}
牛逼啊!接私活必备的 N 个开源项目!赶快收藏
3.Redis定时持久化
1.设计思路
1.1 数据库设计
# 浏览内容表DROP TABLE IF EXISTS `view_item`;CREATE TABLE `view_item` ( `id` varchar(32) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '内容id(如文章、短视频等等)', `create_user` varchar(50) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '创建者', `like_count` int(11) NULL DEFAULT NULL COMMENT '点赞数', `cmt_count` int(11) NULL DEFAULT NULL COMMENT '评论数', `share_count` int(11) NULL DEFAULT NULL COMMENT '分享数', `create_time` datetime(0) NULL DEFAULT NULL COMMENT '创建时间', `update_time` datetime(0) NULL DEFAULT NULL COMMENT '更新时间', PRIMARY KEY (`id`) USING BTREE) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;SET FOREIGN_KEY_CHECKS = 1;# 点赞-用户表DROP TABLE IF EXISTS `user_likes`;CREATE TABLE `user_likes` ( `id` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '点赞信息ID', `info_id` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '点赞对象id', `like_user_id` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '点赞人ID', `status` tinyint(4) NULL DEFAULT 0 COMMENT '0 点赞 1 取消 ', `create_time` datetime(0) NULL DEFAULT NULL COMMENT '创建时间', `update_time` datetime(0) NULL DEFAULT NULL COMMENT '更新时间', PRIMARY KEY (`id`) USING BTREE, UNIQUE INDEX `agdkey`(`like_user_id`, `info_id`) USING BTREE) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '点赞记录表' ROW_FORMAT = Dynamic;
1.2 流程
- 遍历Redis的【点赞信息】,仅改变数据库中点赞信息的状态
- 判断数据库中的点赞状态与缓存中的点赞状态(status)
- 点赞数量-1(两种情况逻辑分析有差异,但是最终结果均为-1)
- 更新缓存中的点赞数量-1(减去数据库中持久化的一个点赞量,一会儿缓存会和数据库点赞总量加和)
- 无需更新缓存中的点赞数量,因为缓存中已经+1(即该点赞数据的点赞量)
- 将缓存【点赞数量】持久化并清理缓存 此处修改数据库中的点赞数量
1.3定时写入
使用 Quartz redis
定时任务持久化存储到数据库
<!-- Quartz redis定时任务持久化存储到数据库 --><dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-quartz</artifactId></dependency>
2.核心代码实现
2.1Bean
import com.baomidou.mybatisplus.annotation.TableField;import com.baomidou.mybatisplus.annotation.TableId;import com.baomidou.mybatisplus.annotation.TableName;import lombok.Data;import java.time.LocalDateTime;@Data@TableName("user_likes")public class UserLikes { @TableId private String id; @TableField("info_id") private String infoId; @TableField("like_user_id") private String likeUserId; private Integer status; @TableField("create_time") private LocalDateTime createTime; @TableField("update_time") private LocalDateTime updateTime;}
import com.baomidou.mybatisplus.annotation.TableField;import com.baomidou.mybatisplus.annotation.TableId;import com.baomidou.mybatisplus.annotation.TableName;import lombok.Data;import java.time.LocalDateTime;@Data@TableName("view_item")public class ViewItem { @TableId private String id; @TableField("create_user") private String createUser; @TableField("like_count") private Integer likeCount; @TableField("cmt_count") private Integer cmtCount; @TableField("share_count") private Integer shareCount; @TableField("create_time") private LocalDateTime createTime; @TableField("update_time") private LocalDateTime updateTime;}
2.2 Service
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;import com.csu.edu.redisLikeDemo.domain.UserLikes;/** * 负责将Redis缓存中的数据持久化到数据库中 */public interface DBService { /** * 保存点赞记录 * @param userLike * @return */ Boolean save(UserLikes userLike); /** * 更新点赞记录 * @param userLike * @return */ Boolean update(UserLikes userLike); /** * 根据内容的id查询点赞列表(即查询都谁给这个内容点赞过) * @param infoId 内容的id * @return */ Page<UserLikes> getLikedListByInfoId(String infoId, int pageNum, int pageSize); /** * 根据点赞人的id查询点赞列表(即查询这个人都给哪些内容点赞过) * @param likeUserId * @return */ Page<UserLikes> getLikedListByLikeUserId(String likeUserId, int pageNum, int pageSize); /** * 通过被点赞内容和点赞人id查询是否存在点赞记录 * @param infoId * @param likeUserId * @return */ UserLikes getByInfoIdAndLikeUserId(String infoId, String likeUserId); /** * 将Redis里的点赞数据存入数据库中,True 表示还需要进一步持久化, False表示数据库中已存在该数据,无需进一步持久化 */ void transLikedFromRedis2DB(); /** * 将Redis中的点赞数量数据存入数据库 */ void transLikedCountFromRedis2DB();}
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;import com.baomidou.mybatisplus.extension.plugins.pagination.Page;import com.csu.edu.redisLikeDemo.common.CONSTANT;import com.csu.edu.redisLikeDemo.domain.DTO.UserLikeCountDTO;import com.csu.edu.redisLikeDemo.domain.DTO.UserLikesDTO;import com.csu.edu.redisLikeDemo.domain.UserLikes;import com.csu.edu.redisLikeDemo.domain.ViewItem;import com.csu.edu.redisLikeDemo.mapper.UserLikesMapper;import com.csu.edu.redisLikeDemo.mapper.ViewItemMapper;import com.csu.edu.redisLikeDemo.service.DBService;import com.csu.edu.redisLikeDemo.service.RedisService;import lombok.extern.slf4j.Slf4j;import org.apache.commons.collections4.CollectionUtils;import org.n3r.idworker.Sid;import org.springframework.beans.BeanUtils;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.stereotype.Service;import java.time.LocalDateTime;import java.util.HashMap;import java.util.List;@Service("dbService")@Slf4jpublic class DBServiceImpl implements DBService { @Autowired private RedisService redisService; @Autowired private UserLikesMapper userLikesMapper; @Autowired private ViewItemMapper viewItemMapper; @Autowired private Sid sid;// Id生成器,利用idWorker产生唯一(不重复)自增式的id,可以根据需求选用其他方式,比如MyBatisPlus的自增 @Override public Boolean save(UserLikes userLike) { int rows = userLikesMapper.insert(userLike); return rows > 0; } @Override public Boolean update(UserLikes userLike) { UpdateWrapper<UserLikes> updateWrapper = new UpdateWrapper<>(); updateWrapper.set("status", userLike.getStatus()); updateWrapper.set("update_time",userLike.getUpdateTime()); updateWrapper.eq("id",userLike.getId()); int rows = userLikesMapper.update(userLike,updateWrapper); return rows > 0; } @Override public Page<UserLikes> getLikedListByInfoId(String infoId, int pageNum, int pageSize) { // 分页获取喜欢列表信息 Page<UserLikes> result = new Page<>(); result.setCurrent(pageNum); result.setSize(pageSize); // 获取内容的id查询点赞列表 QueryWrapper<UserLikes> queryWrapper = new QueryWrapper(); queryWrapper.eq("info_id",infoId); result = userLikesMapper.selectPage(result, queryWrapper); log.info("获得内容的id查询点赞列表(即查询都有谁给这个内容点赞过)"); return result; } @Override public Page<UserLikes> getLikedListByLikeUserId (String likeUserId, int pageNum, int pageSize) { // 分页获取喜欢列表信息 Page<UserLikes> result = new Page<>(); result.setCurrent(pageNum); result.setSize(pageSize); // 获取用户的id查询点赞列表 QueryWrapper<UserLikes> queryWrapper = new QueryWrapper(); queryWrapper.eq("like_user_id", likeUserId); result = userLikesMapper.selectPage(result, queryWrapper); log.info("获取点赞人的id查询点赞列表(即查询这个人都给哪些内容点赞过)"); return result; } @Override public UserLikes getByInfoIdAndLikeUserId (String infoId, String likeUserId) { HashMap<String, Object> map = new HashMap<>(); map.put("info_id",infoId); map.put("like_user_id",likeUserId); try{ UserLikes userLikes = userLikesMapper.selectByMap(map).get(0); log.info("通过被点赞人和点赞人id查询是否存在点赞记录"); return userLikes; }catch (Exception e){ log.info("当前查询的被点赞人和点赞人id不存在点赞记录"); return null; } } @Override public void transLikedFromRedis2DB() { // 批量获取缓存中的点赞数据 List<UserLikesDTO> list = redisService.getLikedDataFromRedis(); if (CollectionUtils.isEmpty(list))// 为空,不写入 return; for (UserLikesDTO item: list){ UserLikes userLikes = getByInfoIdAndLikeUserId(item.getInfoId(), item.getLikeUserId());// 在数据库中查询 if (userLikes == null) {// 无记录,新增 if(!save(userLikesDTOtoUserLikes(item))){ log.info("新增点赞数据失败!"); return; // System.out.println("缓存记录写入数据库失败!请重试"); } }else {// 有记录,更新 // 判断数据库中点赞状态与缓存中点赞状态一致性 if (userLikes.getStatus()==item.getStatus()){// 一致,无需持久化,点赞数量-1 redisService.in_decrementLikedCount(item.getInfoId(), -1); }else{// 不一致 if (userLikes.getStatus()== CONSTANT.LikedStatusEum.LIKE.getCode()){// 在数据库中已经是点赞,则取消点赞,同时记得redis中的count-1 // 之前是点赞,现在改为取消点赞 1.设置更改status 2. redis中的count要-1(消除在数据库中自己的记录) userLikes.setStatus(CONSTANT.LikedStatusEum.UNLIKE.getCode()); redisService.in_decrementLikedCount(item.getInfoId(), -1); } else if (userLikes.getStatus()== CONSTANT.LikedStatusEum.UNLIKE.getCode()) {// 未点赞,则点赞,修改点赞状态和点赞数据+1 userLikes.setStatus(CONSTANT.LikedStatusEum.LIKE.getCode()); redisService.in_decrementLikedCount(item.getInfoId(), 1); } userLikes.setUpdateTime(LocalDateTime.now()); if(!update(userLikes)){// 更新点赞数据 log.info("更新点赞数据失败!"); return; // System.out.println("缓存记录更新数据库失败!请重试"); } } } } } @Override public void transLikedCountFromRedis2DB() {// 获取缓存中内容的点赞数列表 List<UserLikeCountDTO> list = redisService.getLikedCountFromRedis(); if (CollectionUtils.isEmpty(list))// 为空,不写入 return; for (UserLikeCountDTO item: list){ ViewItem viewItem = viewItemMapper.selectById(item.getInfoId()); if (viewItem != null) {// 新增点赞数 Integer likeCount = viewItem.getLikeCount() + item.getLikeCount(); System.out.println("内容id不为空,更新内容点赞数量"); viewItem.setLikeCount(likeCount); UpdateWrapper<ViewItem> updateWrapper = new UpdateWrapper<>(); updateWrapper.set("like_count", viewItem.getLikeCount()); updateWrapper.eq("id", viewItem.getId()); int rows = viewItemMapper.update(viewItem, updateWrapper); if (rows > 0) { System.out.println("成功更新内容点赞数!"); return; } } System.out.println("内容id不存在,无法将缓存数据持久化!"); } } private UserLikes userLikesDTOtoUserLikes(UserLikesDTO userLikesDTO){ UserLikes userLikes = new UserLikes(); userLikes.setId(sid.nextShort()); BeanUtils.copyProperties(userLikesDTO,userLikes); userLikes.setCreateTime(LocalDateTime.now()); userLikes.setUpdateTime(LocalDateTime.now()); return userLikes; }}
3. 定时更新数据库
3.1 定时任务
import com.csu.edu.redisLikeDemo.service.DBService; import lombok.extern.slf4j.Slf4j;import org.quartz.JobExecutionContext;import org.quartz.JobExecutionException;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.scheduling.quartz.QuartzJobBean;import java.text.SimpleDateFormat;import java.util.Date;/** * 定时任务 */@Slf4jpublic class CronUtil extends QuartzJobBean { @Autowired private DBService dbService; private SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); /** * 执行的定时任务 */ @Override protected void executeInternal(JobExecutionContext context) throws JobExecutionException { log.info("LikeTask-------- {}", sdf.format(new Date())); //将 Redis 里的点赞信息同步到数据库里 dbService.transLikedFromRedis2DB(); dbService.transLikedCountFromRedis2DB(); }}
3.2 定时任务配置
设置每两个小时更新一次数据库
import com.csu.edu.redisLikeDemo.util.CronUtil;import org.quartz.*;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;/** * 开启定时任务持久化存储到数据库 */@Configurationpublic class QuartzConfig { private static final String LIKE_TASK_IDENTITY = "LikeTaskQuartz"; @Bean public JobDetail quartzDetail(){ return JobBuilder.newJob(CronUtil.class).withIdentity(LIKE_TASK_IDENTITY).storeDurably().build(); } @Bean public Trigger quartzTrigger(){ SimpleScheduleBuilder scheduleBuilder = SimpleScheduleBuilder.simpleSchedule()// .withIntervalInSeconds(20) //设置时间周期单位秒 .withIntervalInHours(2) //两个小时执行一次 .repeatForever(); return TriggerBuilder.newTrigger().forJob(quartzDetail()) .withIdentity(LIKE_TASK_IDENTITY) .withSchedule(scheduleBuilder) .build(); }}
4.项目源码地址 & 参考
项目源码:
- https://github.com/WuYiheng-Og/redislike
参考:
https://cloud.tencent.com/developer/article/1445905
https://www.cnblogs.com/caizhaokai/p/11037610.html
欢迎有需要的同学试试,如果本文对您有帮助,也请帮忙点个 赞 + 在看 啦!❤️在 GitHub猿 还有更多优质项目系统学习资源,欢迎分享给其他同学吧!
最后,整理了400多套项目,赠送读者。扫码下方二维码,后台回复【赚钱】即可获取。
--END--