简介
该项目采用微服务架构,实现了前后端分离的系统设计。在前端,我们选择了 Vue3 配合 TypeScript 和 ElementUi 框架,以提升开发效率和用户体验。而在后端,则是运用 SpringBoot 和 Mybatis-plus 进行开发,保证了系统的高效和稳定。此外,我们引入了 ElasticSearch 作为全文检索服务,以加快搜索速度和提高检索效率。同时,通过 WebSocket 技术实现了实时聊天和消息推送功能,增强了用户的互动体验。
代码下载
https://url21.ctfile.com/f/15432821-1020751544-c0e7e4?p=8418
(访问密码: 8418)
项目目录
- yanhuo-web 前段页面
- yanhuo-auth 认证服务
- yanhuo-common 公共模块,存放一些工具类或公用类
- yanhuo-platform 烟火 app 主要功能模块
- yanhuo-im 聊天模块
- yanhuo-search 搜索模块
- yanhuo-util 第三方服务模块,邮箱短信,oss 对象存储服务
- yanhuo-xo 对象存放模块
源码讲解
Controller 源码示例
首先来看 AlbumController 中的代码,主要讲解八个容易误解的方面。
-
@RestController:这是一个组合注解,它表示这个类是一个控制器,并且其中的所有方法都会返回数据而不是视图。
-
@RequestMapping(“/album”):这个注解用于将 HTTP 请求映射到 MVC 和 REST 控制器的处理方法上。在这里,它将所有的请求映射到以/album 为前缀的 URL。
-
@Autowired:这个注解用于自动注入 Spring 容器中的 bean。在这里,它注入了一个 AlbumService 的实例,这个实例提供了专辑的增删改查操作。
-
getAlbumPageByUserId 方法:这个方法用于根据用户 ID 获取专辑列表,并支持分页。它接受三个参数:当前页(currentPage),分页数(pageSize)和用户 ID(userId)。它调用 albumService 的 getAlbumPageByUserId 方法来获取数据,并返回一个 Result<?>对象。
-
saveAlbumByDTO 方法:这个方法用于保存专辑。它接受一个 AlbumDTO 对象作为参数,并使用 ValidatorUtils.validateEntity 方法进行数据校验。然后,它调用 albumService 的 saveAlbumByDTO 方法来保存专辑,并返回一个 Result<?>对象。
-
getAlbumById 方法:这个方法用于根据专辑 ID 获取专辑。它接受一个 albumId 作为参数,并调用 albumService 的 getAlbumById 方法来获取数据,并返回一个 Result<?>对象。
-
deleteAlbumById 方法:这个方法用于根据专辑 ID 删除专辑。它接受一个 albumId 作为参数,并调用 albumService 的 deleteAlbumById 方法来删除数据,并返回一个 Result<?>对象。
-
updateAlbumByDTO 方法:这个方法用于更新专辑。它接受一个 AlbumDTO 对象作为参数,并使用 ValidatorUtils.validateEntity 方法进行数据校验。然后,它调用 albumService 的 updateAlbumByDTO 方法来更新专辑,并返回一个 Result<?>对象。
package com.yanhuo.platform.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.yanhuo.common.result.Result;
import com.yanhuo.common.validator.ValidatorUtils;
import com.yanhuo.common.validator.group.AddGroup;
import com.yanhuo.common.validator.group.UpdateGroup;
import com.yanhuo.platform.service.AlbumService;
import com.yanhuo.xo.dto.AlbumDTO;
import com.yanhuo.xo.entity.Album;
import com.yanhuo.xo.vo.AlbumVo;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RequestMapping("/album")
@RestController
public class AlbumController {
@Autowired
AlbumService albumService;
/**
* 根据用户id获取专辑
* @param currentPage 当前页
* @param pageSize 分页数
* @param userId 用户id
* @return 专辑数
*/
@RequestMapping("getAlbumPageByUserId/{currentPage}/{pageSize}")
public Result<?> getAlbumPageByUserId(@PathVariable long currentPage, @PathVariable long pageSize,String userId){
Page<Album> page = albumService.getAlbumPageByUserId(currentPage,pageSize,userId);
return Result.ok(page);
}
/**
* 保存专辑
* @param albumDTO 专辑实体
* @return success
*/
@RequestMapping("saveAlbumByDTO")
public Result<?> saveAlbumByDTO(@RequestBody AlbumDTO albumDTO) {
ValidatorUtils.validateEntity(albumDTO, AddGroup.class);
albumService.saveAlbumByDTO(albumDTO);
return Result.ok();
}
/**
* 根据专辑id获取专辑
* @param albumId 专辑id
* @return 专辑实体
*/
@RequestMapping("getAlbumById")
public Result<?> getAlbumById(String albumId) {
AlbumVo albumVo = albumService.getAlbumById(albumId);
return Result.ok(albumVo);
}
/**
* 根据专辑id删除专辑
* @param albumId 专辑id
* @return success
*/
@RequestMapping("deleteAlbumById")
public Result<?> deleteAlbumById(String albumId) {
albumService.deleteAlbumById(albumId);
return Result.ok();
}
/**
* 更新专辑
* @param albumDTO 专辑实体
* @return success
*/
@RequestMapping("updateAlbumByDTO")
public Result<?> updateAlbumByDTO(@RequestBody AlbumDTO albumDTO) {
ValidatorUtils.validateEntity(albumDTO, UpdateGroup.class);
albumService.updateAlbumByDTO(albumDTO);
return Result.ok();
}
}
Service 源码示例
下面代码是一个服务实现类,用于处理与笔记相关的业务逻辑。这个类使用了 MyBatis-Plus 框架来简化数据库操作,并使用了 Hutool 工具类库来处理 JSON 数据。下面是这段代码的详细解释:
-
NoteServiceImpl
类继承了ServiceImpl<NoteDao, Note>
,这意味着它继承了 MyBatis-Plus 提供的基本 CRUD 操作。NoteDao
是一个接口,用于定义与笔记相关的数据库操作方法。Note
是一个实体类,用于映射数据库中的笔记表。 -
类中注入了多个服务,包括用户服务、标签服务、分类服务、ES 客户端、关注服务、点赞或收藏服务以及 OSS 客户端。这些服务将在后面的方法中被使用。
-
getNoteById
方法用于根据笔记 ID 获取笔记详情。该方法首先通过getById
方法从数据库中获取笔记对象,然后将浏览次数加 1 并更新到数据库。接下来,通过用户 ID 获取用户对象,并将用户名、头像和更新时间设置到NoteVo
对象中。然后,检查当前用户是否关注了该笔记的作者,并设置NoteVo
对象的isFollow
属性。接着,检查当前用户是否点赞或收藏了该笔记,并设置NoteVo
对象的isLike
和isCollection
属性。最后,通过标签笔记关系服务获取该笔记的所有标签,并设置到NoteVo
对象中。 -
saveNoteByDTO
方法用于保存新的笔记。该方法首先从当前用户上下文中获取用户 ID,然后将传入的 JSON 字符串转换为NoteDTO
对象,并将其转换为Note
对象。然后,将用户 ID 设置到Note
对象中,并保存到数据库。接下来,更新用户的动态数量。然后,处理标签关系,将标签 ID 和笔记 ID 保存到标签笔记关系表中。然后,通过 OSS 客户端上传图片,并将图片 URL 保存到Note
对象中。最后,将Note
对象更新到数据库,并将笔记信息添加到 ES 搜索引擎中。 -
deleteNoteByIds
、updateNoteByDTO
和getHotPage
方法目前没有实现具体的业务逻辑。这段代码的主要功能是处理与笔记相关的业务逻辑,包括获取笔记详情、保存新的笔记、更新笔记等。代码中使用了 MyBatis-Plus 框架来简化数据库操作,并使用了 Hutool 工具类库来处理 JSON 数据。此外,代码中还使用了 ES 搜索引擎和 OSS 对象存储服务来处理笔记的搜索和图片存储。
package com.yanhuo.platform.service.impl;
import cn.hutool.json.JSONUtil;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.yanhuo.common.auth.AuthContextHolder;
import com.yanhuo.common.exception.YanHuoException;
import com.yanhuo.common.result.Result;
import com.yanhuo.common.utils.ConvertUtils;
import com.yanhuo.platform.client.EsClient;
import com.yanhuo.platform.client.OssClient;
import com.yanhuo.platform.service.*;
import com.yanhuo.xo.dao.NoteDao;
import com.yanhuo.xo.dto.NoteDTO;
import com.yanhuo.xo.entity.*;
import com.yanhuo.xo.vo.NoteSearchVo;
import com.yanhuo.xo.vo.NoteVo;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
@Service
public class NoteServiceImpl extends ServiceImpl<NoteDao, Note> implements NoteService {
@Autowired
UserService userService;
@Autowired
TagNoteRelationService tagNoteRelationService;
@Autowired
TagService tagService;
@Autowired
CategoryService categoryService;
@Autowired
EsClient esClient;
@Autowired
FollowerService followerService;
@Autowired
LikeOrCollectionService likeOrCollectionService;
@Autowired
OssClient ossClient;
@Override
public NoteVo getNoteById(String noteId) {
Note note = this.getById(noteId);
note.setViewCount(note.getViewCount() + 1);
User user = userService.getById(note.getUid());
NoteVo noteVo = ConvertUtils.sourceToTarget(note, NoteVo.class);
noteVo.setUsername(user.getUsername())
.setAvatar(user.getAvatar())
.setTime(note.getUpdateDate().getTime());
boolean follow = followerService.isFollow(user.getId());
noteVo.setIsFollow(follow);
String currentUid = AuthContextHolder.getUserId();
List<LikeOrCollection> likeOrCollectionList = likeOrCollectionService.list(new QueryWrapper<LikeOrCollection>().eq("like_or_collection_id", noteId).eq("uid", currentUid));
if(!likeOrCollectionList.isEmpty()) {
Set<Integer> types = likeOrCollectionList.stream().map(LikeOrCollection::getType).collect(Collectors.toSet());
noteVo.setIsLike(types.contains(1));
noteVo.setIsCollection(types.contains(3));
}
//得到标签
List<TagNoteRelation> tagNoteRelationList = tagNoteRelationService.list(new QueryWrapper<TagNoteRelation>().eq("nid", noteId));
List<String> tids = tagNoteRelationList.stream().map(TagNoteRelation::getTid).collect(Collectors.toList());
if (!tids.isEmpty()) {
List<Tag> tagList = tagService.listByIds(tids);
noteVo.setTagList(tagList);
}
this.updateById(note);
return noteVo;
}
@Transactional(rollbackFor = Exception.class)
@Override
public String saveNoteByDTO(String noteData, MultipartFile[] files) {
String currentUid = AuthContextHolder.getUserId();
NoteDTO noteDTO = JSONUtil.toBean(noteData, NoteDTO.class);
Note note =ConvertUtils.sourceToTarget(noteDTO, Note.class);
note.setUid(currentUid);
boolean save = this.save(note);
if(!save){
return null;
}
// TODO 需要往专辑中添加
User user = userService.getById(currentUid);
user.setTrendCount(user.getTrendCount() + 1);
userService.updateById(user);
List<String> tids = noteDTO.getTagList();
List<TagNoteRelation> tagNoteRelationList = new ArrayList<>();
String tags="";
if(!tids.isEmpty()){
for (String tid : tids) {
TagNoteRelation tagNoteRelation = new TagNoteRelation();
tagNoteRelation.setTid(tid);
tagNoteRelation.setNid(note.getId());
tagNoteRelationList.add(tagNoteRelation);
}
tagNoteRelationService.saveBatch(tagNoteRelationList);
tags = tagService.listByIds(tids).stream().map(Tag::getTitle).collect(Collectors.joining(","));
}
Category category = categoryService.getById(note.getCid());
Category parentCategory = categoryService.getById(note.getCpid());
List<String> dataList;
try {
Result<List<String>> result = ossClient.saveBatch(files, 1);
dataList = result.getData();
}catch (Exception e){
throw new YanHuoException("添加图片失败");
}
String[] urlArr = dataList.toArray(new String[dataList.size()]);
String urls = JSONUtil.toJsonStr(urlArr);
note.setUrls(urls);
note.setNoteCover(urlArr[0]);
this.updateById(note);
// 往es中添加数据
NoteSearchVo noteSearchVo = ConvertUtils.sourceToTarget(note, NoteSearchVo.class);
noteSearchVo.setUsername(user.getUsername())
.setAvatar(user.getAvatar())
.setLikeCount(0L)
.setCategoryName(category.getTitle())
.setCategoryParentName(parentCategory.getTitle())
.setTags(tags)
.setTime(note.getUpdateDate().getTime());
esClient.addNote(noteSearchVo);
return note.getId();
}
@Override
public void deleteNoteByIds(List<String> noteIds) {
}
@Override
public String updateNoteByDTO(NoteDTO noteDTO) {
return null;
}
@Override
public Page<NoteVo> getHotPage(long currentPage, long pageSize) {
return null;
}
}
Vue3 源码示例
这段代码是一个Vue3组件的模板和脚本部分,用于展示和加载关注者的动态(trends)。下面是对代码的详细解释:
模板部分(Template)
<template>
标签内定义了组件的结构。- 使用了Vue的
v-infinite-scroll
指令来实现无限滚动加载更多数据。 - 使用
v-for
指令循环渲染trendData
数组中的每个动态项。 - 每个动态项包含用户头像、用户信息、动态内容、图片和交互按钮(点赞、评论等)。
- 使用
v-if
和v-else
指令来条件渲染加载中的图片和已加载的图片。 - 事件绑定(
@click
)用于处理用户交互,如跳转到用户页面、点赞、查看动态等。
脚本部分(Script)
- 导入了必要的Vue组件和图标。
- 使用Vue 3的Composition API(
<script setup>
)定义组件的逻辑。 - 响应式变量,如
currentPage
、pageSize
、trendData
等,用于控制分页和存储数据。 handleLoad
函数,用于处理图片加载完成的事件。toUser
函数,用于导航到用户页面。getFollowTrends
函数,用于获取关注者的动态数据。loadMoreData
函数,用于加载更多动态数据。toMain
函数,用于显示动态的详细内容。close
函数,用于关闭动态详细内容的显示。refresh
函数,用于刷新动态数据。like
函数,用于处理点赞和取消点赞的操作。
12.initData
函数,用于初始化动态数据。- 在组件挂载时调用
initData
函数。
样式部分(Style)
- 使用了Less预处理器编写样式。
- 定义了
.container
、.trend-container
、.trend-item
等类,用于设置组件的布局和样式。 - 使用了
flex
布局来排列用户头像、信息和图片。 - 使用了
scoped
属性,确保样式只应用于当前组件。
<template>
<div class="container" v-infinite-scroll="loadMoreData">
<ul class="trend-container">
<li class="trend-item" v-for="(item, index) in trendData" :key="index">
<a class="user-avatar">
<img class="avatar-item" :src="item.avatar" @click="toUser(item.uid)" />
</a>
<div class="main">
<div class="info">
<div class="user-info">
<a class>{{ item.username }}</a>
</div>
<div class="interaction-hint">
<span>{{ item.time }}</span>
</div>
<div class="interaction-content" @click="toMain(item.nid)">
{{ item.content }}
</div>
<div class="interaction-imgs" @click="toMain(item.nid)">
<div class="details-box" v-for="(url, index) in item.imgUrls" :key="index">
<el-image
v-if="!item.isLoading"
:src="url"
@load="handleLoad(item)"
style="height: 230px; width: 100%"
>
</el-image>
<el-image
v-else
:src="url"
class="note-img animate__animated animate__fadeIn animate__delay-0.5s"
fit="cover"
></el-image>
</div>
</div>
<div class="interaction-footer">
<div class="icon-item">
<i
class="iconfont icon-follow-fill"
style="width: 1em; height: 1em"
@click="like(item.nid, item.uid, index, -1)"
v-if="item.isLike"
></i>
<i
class="iconfont icon-follow"
style="width: 1em; height: 1em"
@click="like(item.nid, item.uid, index, 1)"
v-else
></i
><span class="count">{{ item.likeCount }}</span>
</div>
<div class="icon-item">
<ChatRound style="width: 0.9em; height: 0.9em" /><span class="count">{{
item.commentCount
}}</span>
</div>
<div class="icon-item"><More style="width: 1em; height: 1em" /></div>
</div>
</div>
</div>
</li>
</ul>
<div class="feeds-loading">
<Refresh style="width: 1.2em; height: 1.2em" color="rgba(51, 51, 51, 0.8)" />
</div>
<FloatingBtn @click-refresh="refresh"></FloatingBtn>
<Main
v-show="mainShow"
:nid="nid"
class="animate__animated animate__zoomIn animate__delay-0.5s"
@click-main="close"
></Main>
</div>
</template>
<script lang="ts" setup>
import { ChatRound, More, Refresh } from "@element-plus/icons-vue";
import { ref } from "vue";
import { getFollowTrendPage } from "@/api/follower";
import { formateTime } from "@/utils/util";
import FloatingBtn from "@/components/FloatingBtn.vue";
import Main from "@/pages/main/main.vue";
import type { LikeOrCollectionDTO } from "@/type/likeOrCollection";
import { likeOrCollectionByDTO } from "@/api/likeOrCollection";
import { useRouter } from "vue-router";
const router = useRouter();
const currentPage = ref(1);
const pageSize = ref(5);
const trendData = ref<Array<any>>([]);
const trendTotal = ref(0);
const topLoading = ref(false);
const mainShow = ref(false);
const nid = ref("");
const likeOrCollectionDTO = ref<LikeOrCollectionDTO>({
likeOrCollectionId: "",
publishUid: "",
type: 0,
});
const handleLoad = (item: any) => {
item.isLoading = true;
};
const toUser = (uid: string) => {
router.push({ name: "user", state: { uid: uid } });
};
const getFollowTrends = () => {
getFollowTrendPage(currentPage.value, pageSize.value).then((res) => {
const { records, total } = res.data;
console.log(records, total);
records.forEach((item: any) => {
item.time = formateTime(item.time);
trendData.value.push(item);
});
trendTotal.value = total;
});
};
const loadMoreData = () => {
currentPage.value += 1;
getFollowTrends();
};
const toMain = (noteId: string) => {
nid.value = noteId;
mainShow.value = true;
};
const close = (nid: string, isLike: boolean) => {
const index = trendData.value.findIndex((item) => item.nid === nid);
trendData.value[index].isLike = isLike;
mainShow.value = false;
};
const refresh = () => {
let scrollTop =
window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop;
const clientHeight =
window.innerHeight ||
Math.min(document.documentElement.clientHeight, document.body.clientHeight);
if (scrollTop <= clientHeight * 2) {
const timeTop = setInterval(() => {
document.documentElement.scrollTop = document.body.scrollTop = scrollTop -= 100;
if (scrollTop <= 0) {
clearInterval(timeTop);
topLoading.value = true;
setTimeout(() => {
currentPage.value = 1;
trendData.value = [];
getFollowTrends();
topLoading.value = false;
}, 500);
}
}, 10); //定时调用函数使其更顺滑
} else {
document.documentElement.scrollTop = 0;
topLoading.value = true;
setTimeout(() => {
currentPage.value = 1;
trendData.value = [];
getFollowTrends();
topLoading.value = false;
}, 500);
}
};
const like = (nid: string, uid: string, index: number, val: number) => {
likeOrCollectionDTO.value.likeOrCollectionId = nid;
likeOrCollectionDTO.value.publishUid = uid;
likeOrCollectionDTO.value.type = 1;
likeOrCollectionByDTO(likeOrCollectionDTO.value).then(() => {
trendData.value[index].isLike = val == 1;
trendData.value[index].likeCount += val;
});
};
const initData = () => {
getFollowTrends();
};
initData();
</script>
<style lang="less" scoped>
.container {
flex: 1;
padding: 0 24px;
padding-top: 72px;
width: 67%;
height: 100vh;
margin: 0 auto;
.feeds-loading {
margin: 3vh;
text-align: center;
}
.trend-container {
.trend-item {
display: flex;
flex-direction: row;
padding-top: 24px;
max-width: 100vw;
.user-avatar {
margin-right: 24px;
flex-shrink: 0;
.avatar-item {
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border-radius: 100%;
border: 1px solid rgba(0, 0, 0, 0.08);
object-fit: cover;
}
}
.main {
flex-grow: 1;
flex-shrink: 1;
display: flex;
flex-direction: row;
padding-bottom: 12px;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
.info {
flex-grow: 1;
flex-shrink: 1;
.user-info {
display: flex;
flex-direction: row;
align-items: center;
font-size: 16px;
font-weight: 600;
margin-bottom: 4px;
a {
color: #333;
}
}
.interaction-hint {
font-size: 14px;
color: rgba(51, 51, 51, 0.6);
margin-bottom: 8px;
}
.interaction-content {
display: flex;
font-size: 14px;
color: #333;
margin-bottom: 12px;
line-height: 140%;
cursor: pointer;
}
.interaction-imgs {
display: flex;
.details-box {
width: 25%;
border-radius: 4px;
margin: 8px 12px 0 0;
cursor: pointer;
.note-img {
width: 100%;
height: 230px;
display: flex;
border-radius: 4px;
align-items: center;
justify-content: center;
cursor: pointer;
object-fit: cover;
}
}
}
.interaction-footer {
margin: 8px 12px 0 0;
padding: 0 12px;
display: flex;
justify-content: space-between;
align-items: center;
.icon-item {
display: flex;
justify-content: left;
align-items: center;
color: rgba(51, 51, 51, 0.929);
.count {
margin-left: 3px;
}
}
}
}
}
}
}
}
</style>