编程导航设计话题讨论

设计

96 参与
分享

快来分享你的内容吧~

点击登录,快来和大家讨论吧~
表情
图片
话题
打卡
综合
交流
文章
问答

这样设计弹框,有更多时间来摸鱼了!

大家好,我是多喝热水。 不知道大家平时在业务中都是怎么去写弹框逻辑的,今天和大家聊一聊如何在业务中优雅的使用弹框~ **假设现在有一个场景**:网站里有个评论区,这个评论区需要登录后才能使用,如果用户没登录的情况下点击了这个按钮,那么我们需要弹出一个登录框让用户先登录,这是一个非常常见的需求吧,如果是你的话你会怎么做呢? <img src="https://pic.code-nav.cn/migrate_image/fd7bc84c2dcd33a5220d326244cffae0.webp" alt="undefined" width="302" /> ## 方式一 我们先用最简单的方式来实现,假设我们在业务中使用的是 **Ant Design**,那么我们写出来的代码应该是这样的: ```jsx export default function LoginTrigger(props: LoginTriggerProps) { const [isModalOpen, setIsModalOpen] = useState(false); const showModal = () => { setIsModalOpen(true); }; const handleOk = () => { setIsModalOpen(false); }; const handleCancel = () => { setIsModalOpen(false); }; return ( <> <Button type="primary" onClick={showModal}> 登录 </Button> <Modal title="Basic Modal" open={isModalOpen} onOk={handleOk} onCancel={handleCancel} > 登录框内容,此处省略输入框.... </Modal> 此处省略一大坨业务代码... </> ); } ``` ok,我们来看一下效果如何: <img src="https://pic.code-nav.cn/migrate_image/56cf52181c8a8db452dc17474a78470d.webp" alt="" width="100%" /> 和我们预想的一样,点击登录按钮,它确实给我们弹出了登录框。但是我们得思考一下,**这样做有什么弊端?** 1)如果我们不止评论区有这个登录的需求,像发文章、私信、甚至是有些页面加载完毕后就需要弹登录,那像这样每一个场景我们都需要**再写一遍**这样的代码吗? 2)**不利于代码的维护**,因为我们业务中可能不仅仅有登录弹框,也可能有其他弹框逻辑,这么多 `Modal` 混合在一起,维护起来需要耗费大量的精力,效率极低。 <img src="https://pic.code-nav.cn/migrate_image/13998e5315c49aa32d11c94f7c0abe34.webp" alt="" width="216px" /> 所以,为了我们以后能有更多时间去摸鱼,我们必须得把这些问题都消灭! 现在问题已经抛出来了,怎么解决?有点经验的小伙伴可能会说,这些弹框大概率都是重复代码,我们把它封装成一个组件不就好了吗? ## 方式二 直接开干,我们把这个登录弹框的逻辑给封装一下,代码如下: ```jsx export default function useLoginModal() { const [isModalOpen, setIsModalOpen] = useState<boolean>(false); const showLoginModal = () => { setIsModalOpen(true); }; const closeLoginModal = () => { setIsModalOpen(false); }; const LoginModal = useCallback(() => { if (!isModalOpen) return <></>; return ReactDOM.createPortal( <Modal open={isModalOpen} onCancel={closeLoginModal} > 登录框内容,此处省略输入框.... </Modal>, document.body ); }, [isModalOpen]); return { showLoginModal, LoginModal }; } ``` 然后我们后续使用这个登录弹框只需要调用 `useLoginModal` 函数即可,如下: ```jsx export default function LoginTrigger(props: LoginTriggerProps) { const { LoginModal, showLoginModal } = useLoginModal(); return ( <> <Button type="primary" onClick={showLoginModal}> 登录 </Button> <LoginModal /> 此处省略一大坨业务代码... </> ); } ``` ok,我们再看看效果: <img src="https://pic.code-nav.cn/migrate_image/a6c0dbe79bf6785381ff7dc921b6380e.webp" alt="" width="100%" /> nice,依然是一样的效果,但是,怎么感觉还是怪怪的。 整个流程需要调用 `useLoginModal` 函数,又要使用一下 `LoginModal` 组件,最后再调一下 `showLoginModal` 才能弹框。。。。 <img src="https://pic.code-nav.cn/migrate_image/f2073eb5ea1a232db4635d7da282e2b3.webp" alt="" width="240px" /> 你干嘛!我就只想弹个框而已,你给我搞这么一堆代码。。。。 ## 最佳实践 上面的代码只是借助封装的特性,让我们少写了一些代码,但总的流程并没有改变,所以我们理想的效果,就是调用一个函数就可以弹框,能不能做到?答案是可以的,下面我们就来实践一下,我认为的最佳方案。 1)这里我们引入一个包 `@ebay/nice-modal-react`,使用方式如下: ```jsx import NiceModal from "@ebay/nice-modal-react"; import { ReactNode } from "react"; export default function GlobalProvider({ children }) { return <NiceModal.Provider>{children}</NiceModal.Provider>; } ``` 这一步我们将整个根组件包裹在 `NiceModal.Provider` 中 2)编写弹框组件内容 ```jsx import NiceModal, { useModal } from "@ebay/nice-modal-react"; import { Modal } from "antd"; export default NiceModal.create((props) => { const modalInstance = useModal(); return ( <Modal title="用户登录" open={modalInstance.visible} onOk={modalInstance.hide} onCancel={modalInstance.hide} > 登录弹框最佳实践,此处省略一些输入框 </Modal> ); }); ``` 这一步,我们使用 NiceModal 创建了一个登录弹框 3)使用弹框 ```jsx import loginModal from "@/components/globalModal/login"; import NiceModal from "@ebay/nice-modal-react"; import { Button } from "antd"; export default function LoginTrigger(props) { return <Button onClick={() => NiceModal.show(loginModal)}>登录</Button>; } ``` `loginModal` 就是我们上面创建的弹框,接下来我们看看效果如何: <img src="https://pic.code-nav.cn/migrate_image/2bc5aaa79e293a3955e94e54a136560e.webp" alt="" width="100%" /> 依旧没有任何问题,我们现在通过调用一次函数就实现了弹框,但我们依然引入了两个新组件,如果还是觉得不够优雅,我们还可以再优化一下,如下: ```jsx import NiceModal from "@ebay/nice-modal-react"; import LoginModal from "@/components/globalModal/login"; export const showLogin = () => NiceModal.show(LoginModal); ``` 这里我们将这段弹框逻辑封装成一个工具函数,后续我们直接调用 `showLogin` 即可,维护的时候也只需要维护这个工具函数。 ## 总结 最后一种方式弹框,把我们抛出的问题都消灭了,当然这里仅仅只是以登录为例,如果有其他弹框逻辑我们只需要创建新的 `xxxModal`,再通过 `NiceModal.show(xxxModal)` 即可。 而且通过发布订阅模式,我们也可以自己实现一个 `nice-modal-react`,但全部写下来篇幅有点长了,感兴趣的话我们可以后面来搞一搞。 好了,这篇文章就肝到这里,如果有更好的方式大家可以探讨一下。

文章点赞功能的Redis方案设计和前后端代码实现

## (1)前言及问题分析 点赞功能是很多社交平台和在线应用中常见的一个交互特性,它可以增强用户参与感、社交体验,并且有助于内容的推广。 ### 关键特性: 1. **唯一性:** 每个用户对同一条内容只能点赞一次,确保用户不能多次重复点赞。 2. **即时性:** 点赞的反馈应该是即时的,用户点击点赞按钮后,系统应该迅速响应,不应有明显的延迟。 3. **可见性:** 点赞状态应该及时地反映在用户界面上,以便其他用户能够看到谁给某个内容点赞了。 4. **可撤销性:** 用户应该能够取消点赞,确保用户可以更改他们的喜好。 5. **计数和排名:** 点赞数量通常会被用于衡量内容受欢迎程度,所以需要对点赞数量进行实时的计数和排名。 ## (2)功能方案图 <img src="https://pic.code-nav.cn/post_picture/1721896042632441858/png67iNbWUuzgVBF.webp" alt="image001.png" width="100%" /> ## (3)功能点详情 ### 1. 前端 在此方案中,前端涉及主要有以下几点: 1. 页面开发:每个组件库都有对应的组件可以使用,比如笔者使用的是Arco Design的卡片插槽,使用vue3集成,代码如下: ```vue <template #actions> <span class="icon-hover" style="color: #f53f3f" @click="likeOrNot" v-if="likeState === true" > <IconHeartFill size="20" /></span> <span class="icon-hover" @click="likeOrNot" v-if="likeState === false"> <IconHeart size="20" /></span ><span class="actionText">{{ parseInt(solution.solutionLikes as any, 10) + 1 }}</span> </template> ``` 1. 进入文章页面时,调用两个接口:1. 文章信息接口;2. 用户是否点赞状态查询接口 2. 用户点赞或取消点赞成功后:手动更新页面文章点赞数(因为redis和mysql同步并不实时,且点赞数是从相对滞后的mysql中查,所以,需要前端手动运算一下,确保给予用户正确的结果反馈,至于退出页面重新进入文章点赞数量不变的问题,其实无所谓,因为我们可以这么说:之所以没变,是因为其他人点赞补充了而已) 3. 承接3,同时,变更用户当前页面点赞状态,无需调用2中的点赞状态查询接口,这样可以提高一些效率 ### 2. Redis **点赞是一个频繁的操作。** 为什么使用Redis,那么首先是其必要性,以下是chatgpt给出的: 1. **快速读写:** Redis 是一个基于内存的高性能键值存储数据库,适合用于需要快速读写的场景,如点赞记录的存储和读取。 2. **计数器:** Redis 的原子性操作使其非常适合作为点赞计数器的后端存储,避免了并发操作导致的数据不一致问题。 3. **缓存:** Redis 可以用作缓存存储,可以缓解数据库负担,提高系统性能。例如,可以将点赞记录存储在 Redis 中,减轻对主数据库的访问压力。 4. **持久化:** Redis 支持数据持久化,可以在需要时将数据保存到磁盘,确保数据的可靠性。 5. **集合和排序集合:** Redis 的集合和排序集合数据结构非常适合用于存储用户点赞记录和计数。可以方便地进行添加、删除、查找等操作。 6. **分布式:** 在分布式系统中,Redis可以作为分布式锁的一部分,确保在高并发情况下点赞操作的一致性。 总的来说,使用 Redis 可以提高点赞功能的性能、可靠性和扩展性,使系统更加稳定和高效。 明确了必要性之后,本文方案主要使用了以下两个数据结构: 1. articleId-set:key=articleId,value=set(userId) 2. articleLike-hash:key=articleId,val=likesNum; 使用2主要是便于更快的查询当前文章的点赞数,提高效率,使1专注于点赞者的修改。 (相关代码在4(后端)中) ### 3. Mysql 涉及的表结构主要有两个: 1. 文章表(article):articleId、likesNum。。。 2. 文章点赞表(article_like):articleId、userId。。。 点赞信息稳定下来后,也是要持久化的,因此存到数据库是必要的。 参考代码: ```sql /* Navicat Premium Data Transfer Source Server : zzx Source Server Type : MySQL Source Server Version : 80033 (8.0.33) Source Host : localhost:3306 Source Schema : sspuoj_db_dev Target Server Type : MySQL Target Server Version : 80033 (8.0.33) File Encoding : 65001 Date: 16/12/2023 18:24:31 */ SET NAMES utf8mb4; SET FOREIGN_KEY_CHECKS = 0; -- ---------------------------- -- Table structure for question_solution -- ---------------------------- DROP TABLE IF EXISTS `question_solution`; CREATE TABLE `question_solution` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT 'id', `solutionLikes` bigint NULL DEFAULT 0 COMMENT '题解点赞数', `createTime` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `updateTime` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', `isDelete` tinyint NOT NULL DEFAULT 0 COMMENT '是否删除', PRIMARY KEY (`id`) USING BTREE, INDEX `idx_id`(`id` ASC) USING BTREE, INDEX `idx_userId`(`userId` ASC) USING BTREE ) ENGINE = InnoDB AUTO_INCREMENT = 25 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = DYNAMIC; SET FOREIGN_KEY_CHECKS = 1; -- ---------------------------- -- Table structure for article_likes -- ---------------------------- DROP TABLE IF EXISTS `article_likes`; CREATE TABLE `article_likes` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT 'id', `articleId` bigint NULL DEFAULT NULL COMMENT '文章id', `userId` bigint NULL DEFAULT NULL COMMENT '点赞人id', `createTime` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `updateTime` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', `isDelete` tinyint NOT NULL DEFAULT 0 COMMENT '是否删除', PRIMARY KEY (`id`) USING BTREE ) ENGINE = InnoDB AUTO_INCREMENT = 9 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = DYNAMIC; SET FOREIGN_KEY_CHECKS = 1; ``` ### 4. 后端 主要服务有3个: 1. redis-mysql同步的定时任务 2. 查询用户是否点过赞 3. 用户点赞/取消点赞 下面我们结合代码来细说。 首先是2、3的service代码: - 查询用户是否点过赞,只需根据articleId查到对应的set<userId>看里面有没有该用户 - 点赞和取消点赞,只需插入或删除set,增加或修改hash ```java package sspu.zzx.sspuoj.service.impl; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.HashOperations; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.SetOperations; import org.springframework.stereotype.Service; import sspu.zzx.sspuoj.mapper.ArticleLikesMapper; import sspu.zzx.sspuoj.model.entity.ArticleLikes; import sspu.zzx.sspuoj.service.ArticleLikesService; import java.util.Collections; import java.util.Set; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; /** * @author ZZX * @description 针对表【article_likes】的数据库操作Service实现 * @createDate 2023-12-12 15:14:24 */ @Service public class ArticleLikesServiceImpl extends ServiceImpl<ArticleLikesMapper, ArticleLikes> implements ArticleLikesService { private final RedisTemplate<String, Object> redisTemplate; private final SetOperations<String, Object> setOperations; private final HashOperations<String, Object, Object> hashOperations; @Autowired public ArticleLikesServiceImpl(RedisTemplate<String, Object> redisTemplate) { this.redisTemplate = redisTemplate; this.setOperations = redisTemplate.opsForSet(); this.hashOperations = redisTemplate.opsForHash(); } // 添加用户到文章的点赞集合中,同时设置集合键永不过期 public void addUserToLikeSet(Long articleId, Long userId) { Long add = setOperations.add(getArticleLikeSetKey(articleId), userId); // 设置集合键150年过期 redisTemplate.expire(getArticleLikeSetKey(articleId), 365 * 150, TimeUnit.DAYS); } // 检查用户是否已经点赞 public boolean isUserLiked(Long articleId, Long userId) { return Boolean.TRUE.equals(setOperations.isMember(getArticleLikeSetKey(articleId), userId)); } // 设置文章的点赞数 public void setArticleLikes(Long articleId, long likes) { hashOperations.put(getArticleLikesHashKey(), articleId, likes); } // 获取文章的点赞数 public Long getArticleLikes(Long articleId) { Object likes = hashOperations.get(getArticleLikesHashKey(), articleId); return likes != null ? Long.parseLong(likes.toString()) : 0L; } // 获取文章点赞的用户ID集合 public Set<Object> getArticleLikedUsers(Long articleId) { Set<Object> likedUsers = setOperations.members(getArticleLikeSetKey(articleId)); return likedUsers != null ? likedUsers : Collections.emptySet(); } // 移除用户从文章的点赞集合中 public void removeUserFromLikeSet(Long articleId, Long userId) { setOperations.remove(getArticleLikeSetKey(articleId), userId); } // 获取文章的点赞集合的键 private String getArticleLikeSetKey(Long articleId) { return "article:" + articleId + ":likes"; } // 获取文章点赞数的哈希表键 private String getArticleLikesHashKey() { return "article:likes"; } // 点赞 public void like(Long articleId, Long userId) { addUserToLikeSet(articleId, userId); Long likes = getArticleLikes(articleId); if (likes >= 0) { setArticleLikes(articleId, likes + 1); } } // 取消点赞 public void cancelLike(Long articleId, Long userId) { if (isUserLiked(articleId, userId)) { removeUserFromLikeSet(articleId, userId); Long likes = getArticleLikes(articleId); if (likes > 0) { setArticleLikes(articleId, likes - 1); } } } @Override public Boolean likeArticleOrNot(Long articleId, Long userId) { // 获得当前点赞文章的用户集合 Set<Object> likeUsers = getArticleLikedUsers(articleId); // 如果存在该用户,就取消点赞 if (likeUsers.size() > 0 && likeUsers.contains(userId)) { cancelLike(articleId, userId); return false; } // 反之,点赞 else { like(articleId, userId); return true; } } @Override public Boolean ifLiked(Long articleId, Long userId) { // 首先从redis检查,如果有,那么数据库里面最终也一定会有 Set<Long> articleLikedUsers = getArticleLikedUsers(articleId).stream().map(e -> (Long) e).collect(Collectors.toSet()); if (articleLikedUsers.contains(userId)) return true; /* 这块感觉不用,保证实时性比较好,redis宕机后再同步就好了 // 如果redis中没有,则从数据库中查,有则有,否则那确实是没有 QueryWrapper<ArticleLikes> queryWrapper = new QueryWrapper<>(); queryWrapper.eq("article_id", articleId); queryWrapper.eq("user_id", userId); List<ArticleLikes> list = this.list(queryWrapper); return !list.isEmpty(); */ return false; } } ``` 最后是,redis与mysql的同步代码(初版代码不是很优雅,但逻辑基本如此,仅供参考) ```java package sspu.zzx.sspuoj.task; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import sspu.zzx.sspuoj.model.entity.ArticleLikes; import sspu.zzx.sspuoj.model.entity.QuestionSolution; import sspu.zzx.sspuoj.service.QuestionSolutionService; import sspu.zzx.sspuoj.service.impl.ArticleLikesServiceImpl; import java.util.*; import java.util.stream.Collectors; /** * @version 1.0 * @Author ZZX * @Date 2023/12/12 16:54 */ @Component @Slf4j public class ArticleLikesSynTask { @Autowired private QuestionSolutionService questionSolutionService; @Autowired private ArticleLikesServiceImpl articleLikesService; /** * 定时同步文章点赞信息 */ @Scheduled(cron = "0 0 12 */1 * *") // 每1天 // @Scheduled(cron = "0 */1 * * * *") // 每一分钟执行一次 public void synArticleLikes() { log.info("定时同步文章点赞信息 - " + new Date()); // 获取所有title不是【外部图文】的文章 QueryWrapper<QuestionSolution> queryWrapper = new QueryWrapper<>(); queryWrapper.ne("title", "外部图文"); List<QuestionSolution> articles = questionSolutionService.list(queryWrapper); // 获取所有文章点赞集合 List<ArticleLikes> articleLikes = articleLikesService.list(); // 按文章id分组,Map的值为List<ArticleLikes> Map<Long, List<ArticleLikes>> idToArticleLikesMap = articleLikes.stream().collect(Collectors.groupingBy(ArticleLikes::getArticleId)); // 定义要更新的question_solution List<QuestionSolution> toUpdateSolution = new ArrayList<>(); // 定义最终要删除和添加的点赞记录 List<ArticleLikes> toDeleteArticleLikes = new ArrayList<>(); List<ArticleLikes> toAddArticleLikes = new ArrayList<>(); for (QuestionSolution article : articles) { // 从redis中文章id对应的点赞数 Long articleLikesFromRedis = articleLikesService.getArticleLikes(article.getId()); // 从redis中文章id对应的具体点赞用户集合 List<Long> articleLikedUserIds = articleLikesService.getArticleLikedUsers(article.getId()).stream().map(Object::toString) // 假设返回的元素是字符串类型,如果不是,可以根据实际情况调整 .map(Long::parseLong).collect(Collectors.toList()); // 获得要删除的文章点赞记录 List<ArticleLikes> articleLikesFromDB = idToArticleLikesMap.get(article.getId()); if (articleLikesFromDB == null) { articleLikesFromDB = new ArrayList<>(); } /*如果redis的点赞用户集合为空,则不执行删除和添加, 这种情况我们认为redis宕机然后刚刚重启 并将数据库中的对应数据同步至redis中 */ if (articleLikedUserIds.isEmpty()) { for (ArticleLikes likes : articleLikesFromDB) { articleLikesService.addUserToLikeSet(article.getId(), likes.getUserId()); } articleLikesService.setArticleLikes(article.getId(), articleLikesFromDB.size()); continue; } // 比较数目和结合的size,使其一致,以集合size为准,并更新article对应记录的点赞数 long articleLikeListSize = Long.parseLong(articleLikesFromRedis.toString()); if (articleLikesFromRedis.equals(articleLikeListSize)) { articleLikesService.setArticleLikes(article.getId(), articleLikeListSize); } if (!article.getSolutionLikes().equals(articleLikeListSize)) { article.setSolutionLikes(articleLikeListSize); toUpdateSolution.add(article); } Iterator<ArticleLikes> iterator = articleLikesFromDB.iterator(); while (iterator.hasNext()) { ArticleLikes likes = iterator.next(); if (!articleLikedUserIds.contains(likes.getUserId())) { toDeleteArticleLikes.add(likes); } } // 获得要添加的文章点赞记录 List<Long> collectUserIdFromDB = articleLikesFromDB.stream().map(ArticleLikes::getUserId).collect(Collectors.toList()); for (Long articleLikedUserId : articleLikedUserIds) { if (!collectUserIdFromDB.contains(articleLikedUserId)) { ArticleLikes articleLikes1 = new ArticleLikes(); articleLikes1.setArticleId(article.getId()); articleLikes1.setUserId(articleLikedUserId); toAddArticleLikes.add(articleLikes1); } } } // 更新question_solution表 if (toUpdateSolution.size() > 0) { questionSolutionService.updateBatchById(toUpdateSolution); } // 更新article_likes表中的字段(删除和添加) if (toDeleteArticleLikes.size() > 0) { articleLikesService.removeByIds(toDeleteArticleLikes.stream().map(ArticleLikes::getId).collect(Collectors.toList())); } if (toAddArticleLikes.size() > 0) { articleLikesService.saveBatch(toAddArticleLikes); } } } ``` 其中,考虑到**redis有可能宕机**(因为资源有限,redis没有集群,而且就算有集群,也有可能都挂)的问题,本方案是将redis中set不存在或为空,作为判别标志。在同步时,如果发现redis的set为空,则mysql向redis同步,否则就是redis向mysql同步。这种方案的好处是,判别方便,缺点就是,处理不了所有人对所有文章都不点赞的情况,但这种情况出现的概率比较少,且就算出现,也能容忍,于是采取该方案。 ## (4)总结 下面是更新后的要点总结,包括处理Redis宕机的逻辑: ### 前端部分: 1. 页面开发:使用组件库中的组件,如Arco Design的卡片插槽,展示点赞按钮和点赞数量。 2. 进入文章页面时,调用两个接口:获取文章信息接口和查询用户是否点赞状态接口。 3. 用户点赞或取消点赞成功后,手动更新页面上的点赞数,并变更用户当前页面的点赞状态。 ### Redis部分: 1. 使用Redis作为存储点赞信息的后端,考虑了快速读写、计数器、缓存、持久化、集合和排序集合等特性。 2. 使用两个主要的数据结构:set存储点赞用户,hash存储点赞数量。 ### MySQL部分: 1. 设计了两张表:文章表(article)和文章点赞表(article_likes)。 2. 点赞信息需要持久化到数据库,确保数据的长久保存。 ### 后端部分: 1. 提供了Redis与MySQL同步的定时任务,定期将数据从MySQL同步到Redis。 2. 实现了查询用户是否点过赞的接口、用户点赞/取消点赞的接口等服务。 3. 通过定时任务实现了文章点赞信息的同步,确保Redis中的数据与MySQL中的数据一致。 4. 处理了Redis宕机的情况,在同步任务中进行了检查,如果Redis不可用,将Mysql的数据同步至redis,保证系统的可用性。

前端-社招简历

<p></p>

改图神器

这是一款完全免费的万能图片在线处理工具,无需注册即开即用,功能包括:图片编辑、一键抠图、格式转换、水印添加、图片压缩、无损放大、漫画头像、证件照制作、转矢量图、透明背景、GIF编辑器。 地址:https://img.logosc.cn/

LOGO设计神器

地址:https://www.logosc.cn/

《亲爱的界面:让用户乐于使用、爱不释手》

地址:https://www.aliyundrive.com/s/LyctXDoXgj4

《设计师要懂沟通术》

地址:https://www.aliyundrive.com/s/RwgakUee21f

《UX设计师要懂工业设计》

地址:https://www.aliyundrive.com/s/Yi1qeNJ9hYT

《写给大家看的设计书》(第4版)

地址:https://www.aliyundrive.com/s/UjNfPfA74Fy

《用户体验设计:100堂入门课》

地址:https://www.aliyundrive.com/s/WcfdYR7NRxE

下载 APP