整理一级分类下的完整分类树、检索关键词自动补全、Logstash 以及专辑详情接口的学习记录。

本篇要点

  • 组装一级分类下的二三级分类
  • 实现关键词自动补全
  • 了解日志与专辑详情接口

内容回顾

1、专辑检索接口

  • 重点编写DSL语句代码

2、根据一级id查询前7个置顶三级数据

3、首页数据接口

今天内容

1、根据一级分类Id获取全部分类信息

分析

  • 在首页,点击某个一级分类,比如点击音乐,点击全部,根据一级分类id查询一级分类下面所有二级和三级分类

image-20251029090341338

  • 查询分类数据格式
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
{
一级分类id: 1
一级分类名称:音乐
child: [
{
二级分类id:11
二级分类名称:音乐音效
child: [
{
三级分类id:111
三级分类名称:运动音乐
}
]
}
]
}

# 1 根据一级分类id查询一级分类数据,进行封装

# 2 根据一级分类id查询下面的二级和三级分类数据
# 查询base_category_view视图
select * from base_category_view bcv where bcv.category1_id=1

# 3 从第二步查询出来的数据获取所有二级分类数据
# 根据二级分类id进行分组,获取每组里面二级分类id和名称

# 4 从上一步每组里面获取每个二级分类里面三级分类数据

接口实现

在service-album模块添加接口

1
2
3
4
5
6
7
8
9
10
11
/**
* 根据一级分类Id 获取全部数据
* @param category1Id
* @return
*/
@Operation(summary = "根据一级分类id获取全部分类信息")
@GetMapping("getBaseCategoryList/{category1Id}")
public Result<JSONObject> getBaseCategoryList(@PathVariable Long category1Id){
JSONObject jsonObject = baseCategoryService.getBaseCategoryList(category1Id);
return Result.ok(jsonObject);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
    //根据一级分类id查询下面所有二级和三级分类数据
@Override
public JSONObject getBaseCategoryListByCategory1Id(Long category1Id) {
//1 根据一级分类id查询一级分类数据
// 把一级分类数据封装到JSONObject
BaseCategory1 baseCategory1 = baseCategory1Mapper.selectById(category1Id);
JSONObject category1 = new JSONObject();
category1.put("categoryId", category1Id);
category1.put("categoryName", baseCategory1.getName());

//2 根据一级分类id查询下面所有二级和三级数据,查询视图实现
LambdaQueryWrapper<BaseCategoryView> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(BaseCategoryView::getCategory1Id, category1Id);
List<BaseCategoryView> baseCategoryViewList = baseCategoryViewMapper.selectList(wrapper);

//3 把第二步查询数据,根据二级分类id进行分组,返回map集合
//map的key是二级分类id,map的value是每组数据
Map<Long, List<BaseCategoryView>> map =
baseCategoryViewList.stream().collect(
Collectors.groupingBy(BaseCategoryView::getCategory2Id));

//4 遍历map集合,封装二级分类数据,最终把二级集合放到一级分类里面
//创建集合封装多个二级分类
List<JSONObject> category2Child = new ArrayList<>();
map.forEach((k, v) -> {
Long categoryId2 = k;
List<BaseCategoryView> baseCategoryViewList2 = v;

//封装二级分类数据
JSONObject category2 = new JSONObject();
category2.put("categoryId", categoryId2);
category2.put("categoryName", baseCategoryViewList2.get(0).getCategory2Name());

//5 封装三级,把三级放到二级分类里面
// List<BaseCategoryView> -- List<JSONObject>
List<JSONObject> category3Child = new ArrayList<>();
baseCategoryViewList2.stream().forEach(category3View -> {
JSONObject category3 = new JSONObject();
category3.put("categoryId", category3View.getCategory3Id());
category3.put("categoryName", category3View.getCategory3Name());
category3Child.add(category3);
});

// List<JSONObject> list = baseCategoryViewList2.stream().map(baseCategoryView -> {
// JSONObject category3 = new JSONObject();
// category3.put("categoryId", baseCategoryView.getCategory3Id());
// category3.put("categoryName", baseCategoryView.getCategory3Name());
// return category3;
// }).collect(Collectors.toList());

//把三级数据集合放到二级里面
category2.put("categoryChild",category3Child);

//多个二级分类放到集合里面
category2Child.add(category2);
});

//把二级数据list集合放到一级分类里面
category1.put("categoryChild",category2Child);

return category1;
}

2、专辑检索关键字自动补全

分析

  • 在搜索框,输入关键词,使用下拉框显示相关专辑名称

前置知识

  • completion:es一种特殊类型,把数据放到内存里面,根据前缀进行搜索,效率高

  • completion一般用于搜索提示(自动补全)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
GET test/_search
{
"_source": false,
"suggest": {
"completer": {
"prefix": "foe",
"completion": {
"field": "suggest",
"skip_duplicates": true,
"fuzzy": {
"fuzziness": "auto"
}
}
}
}
}

实现

创建并初始化索引库

  • 创建新索引库,数据映射类型completion,使用这个索引库专门实现自动补全查询

  • 实体类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Data
@Document(indexName = "suggestinfo")
@JsonIgnoreProperties(ignoreUnknown = true)//目的:防止json字符串转成实体对象时因未识别字段报错
public class SuggestIndex {

@Id
private String id;

@Field(type = FieldType.Text, analyzer = "standard")
private String title;

@CompletionField(analyzer = "standard", searchAnalyzer = "standard", maxInputLength = 20)
private Completion keyword; //小说

@CompletionField(analyzer = "standard", searchAnalyzer = "standard", maxInputLength = 20)
private Completion keywordPinyin; //xiaoshuo

@CompletionField(analyzer = "standard", searchAnalyzer = "standard", maxInputLength = 20)
private Completion keywordSequence; //xs
}
  • 创建Repository
1
2
3
public interface SuggestIndexRepository
extends ElasticsearchRepository<SuggestIndex, String> {
}
  • 在专辑上架方法添加初始化代码

image-20251029102638332

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//添加数据到suggestinfo索引库,后面实现自动补全
SuggestIndex suggestIndex = new SuggestIndex();
//id
suggestIndex.setId(UUID.randomUUID().toString().replaceAll("-",""));
//专辑标题
suggestIndex.setTitle(albumInfoIndex.getAlbumTitle());
//标题中文
suggestIndex.setKeyword(new Completion(new String[]{albumInfoIndex.getAlbumTitle()}));
//中文的完整汉语拼音,比如xiaoshuo
suggestIndex.setKeywordPinyin(new Completion(new String[]{PinYinUtils.toHanyuPinyin(albumInfoIndex.getAlbumTitle())}));
//中文的汉语拼音首字母,比如xs
suggestIndex.setKeywordSequence(new Completion(new String[]{PinYinUtils.getFirstLetter(albumInfoIndex.getAlbumTitle())}));

this.suggestIndexRepository.save(suggestIndex);
  • 调用接口添加

image-20251029103626465

编写自动补全接口

  • 在service-search模块编写
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class SearchApiController {

@Autowired
private SearchService searchService;

/**
* 自动补全功能
* @param keyword
* @return
*/
@Operation(summary = "关键字自动补全")
@GetMapping("completeSuggest/{keyword}")
public Result completeSuggest(@PathVariable String keyword) {
List<String> list = searchService.completeSuggest(keyword);
return Result.ok(list);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
//自动补全功能
@Override
public List<String> completeSuggest(String keyword) throws Exception {
SearchRequest.Builder requestBuilder = new SearchRequest.Builder();
//dsl语句
requestBuilder.index("suggestinfo").suggest(
s->s.suggesters("suggestionKeyword",
f->f.prefix(keyword).completion(
c->c.field("keyword")
.skipDuplicates(true)
.size(10)
.fuzzy(f1->f1.fuzziness("auto"))))
.suggesters("suggestionkeywordPinyin",
f->f.prefix(keyword).completion(
c->c.field("keywordPinyin")
.skipDuplicates(true)
.size(10)
.fuzzy(f1->f1.fuzziness("auto"))))
.suggesters("suggestionkeywordSequence",f->f.prefix(keyword).completion(
c->c.field("keywordSequence").skipDuplicates(true).size(10)
.fuzzy(z->z.fuzziness("auto"))
))
);
//调用elasticsearchClient里面方法搜索
SearchResponse<SuggestIndex> response =
elasticsearchClient.search(requestBuilder.build(), SuggestIndex.class);

HashSet<String> titleSet = new HashSet<>();
titleSet.addAll(this.parseResultData(response,"suggestionKeyword"));
titleSet.addAll(this.parseResultData(response,"suggestionkeywordPinyin"));
titleSet.addAll(this.parseResultData(response,"suggestionkeywordSequence"));

//如果查询Completion没有达到10条匹配,根据title做普通匹配
if(titleSet.size()<10) {
SearchResponse<SuggestIndex> searchResponse =
elasticsearchClient.search(s -> s.index("suggestinfo")
.query(f -> f.match(m -> m.field("title").query(keyword)))
, SuggestIndex.class);
// 从查询结果集中获取数据
for (Hit<SuggestIndex> hit : response.hits().hits()) {
// 获取数据结果
SuggestIndex suggestIndex = hit.source();
// 获取titile
titleSet.add(suggestIndex.getTitle());
// 判断当前这个结合的长度.
if (titleSet.size()==10){
break;
}
}
}
return new ArrayList<>(titleSet);
}
//处理结果
private List<String> parseResultData(SearchResponse<SuggestIndex> response,
String suggestionKeyword) {
List<String> suggestList = new ArrayList<>();
Map<String, List<Suggestion<SuggestIndex>>> map = response.suggest();
//根据名称获取值
List<Suggestion<SuggestIndex>> suggestions = map.get(suggestionKeyword);
suggestions.forEach(item -> {
CompletionSuggest<SuggestIndex> completionSuggest = item.completion();
completionSuggest.options().forEach(it -> {
SuggestIndex suggestIndex = it.source();
suggestList.add(suggestIndex.getTitle());
});
});
// 返回集合列表
return suggestList;
}

3、日志工具logstash(面试)

  • ELK

– E:es搜索引擎,索引库

– K:kibana连接工具,对es数据进行分析(操作es里面数据查看)

– L:logstash日志工具,收集项目的日志信息,把日志信息存储到es里面

使用logstash收集项目日志,把日志存储到es里面,之后使用kibana分析es日志数据

  • 使用logstash

1、第一步 docker安装logstash服务,配置存储es索引库路径

第一步:拉取镜像

docker pull logstash:8.5.0

第二步:需要提前在linux服务器上环境,内容如下

mkdir -p /mnt/docker/elk/logstash/pipeline

mkdir -p /mnt/docker/elk/logstash/config

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 第一步:
cat > /mnt/docker/elk/logstash/pipeline/logstash.conf << EOF
input {
tcp {
mode => "server"
host => "0.0.0.0"
port => 5044
codec => json_lines
}
}
output {
elasticsearch {
hosts => "192.168.200.130:9200"
index => "ts-%{+YYYY.MM.dd}"
}
}
EOF
# 第二步:
cat > /mnt/docker/elk/logstash/config/logstash.yml << EOF
http.host: "0.0.0.0"
xpack.monitoring.elasticsearch.hosts: [ "http://192.168.200.130:9200" ]
EOF

第三步:创建容器

1
docker run -d --name logstash -m 1000M --restart=always -p 5044:5044 -p 9600:9600 --privileged=true -e ES_JAVA_OPTS="-Duser.timezone=Asia/Shanghai" -v /mnt/docker/elk/logstash/pipeline/logstash.conf:/usr/share/logstash/pipeline/logstash.conf -v /mnt/docker/elk/logstash/config/logstash.yml:/usr/share/logstash/config/logstash.yml logstash:8.5.0

2、第二步 SpringBoot整合logstash,修改SpringBoot日志配置文件

在logback-spring.xml文件添加logstash配置

1
2
3
4
5
6
<!-- logstash日志 -->
<appender name="LOGSTASH" class="net.logstash.logback.appender.LogstashTcpSocketAppender">
<!-- logstash ip和暴露的端口,logback就是通过这个地址把日志发送给logstash -->
<destination>192.168.200.130:5044</destination>
<encoder charset="UTF-8" class="net.logstash.logback.encoder.LogstashEncoder" />
</appender>

image-20251029141744870

3、第三步 引入logstash依赖在service模块pom文件

1
2
3
4
5
<dependency>
<groupId>net.logstash.logback</groupId>
<artifactId>logstash-logback-encoder</artifactId>
<version>5.1</version>
</dependency>

4、第四步 使用kibana分析日志数据

1
2
3
4
5
6
7
8
post /ts-2025.10.29/_search
{
"query":{
"match": {
"level": "ERROR"
}
}
}

4、专辑详情(完成部分)

  • 登录
  • 搜索
  • 详情
  • 订单

分析

  • 点击某个专辑,查询专辑详情信息

image-20251029143229190

  • 点击某个声音,进行播放

image-20251029143350459

  • 调用关系

image-20251029144301620

基础功能(不带声音数据)

远程调用接口

  • 创建四个远程调用接口

  • 根据专辑id获取四个统计数据接口

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* 根据专辑Id 获取到统计信息
* @param albumId
* @return
*/
@Operation(summary = "获取到专辑统计信息")
@GetMapping("/getAlbumStatVo/{albumId}")
public Result getAlbumStatVo(@PathVariable Long albumId){
// 获取服务层方法
AlbumStatVo albumStatVo =
this.albumInfoService.getAlbumStatVoByAlbumId(albumId);
return Result.ok(albumStatVo);
}
1
2
3
4
5
@Override
public AlbumStatVo getAlbumStatVoByAlbumId(Long albumId) {

return albumStatMapper.getAlbumStatVoByAlbumId(albumId);
}
1
2
3
4
5
6
7
8
9
10
<select id="getAlbumStatVoByAlbumId" resultType="com.atguigu.tingshu.vo.album.AlbumStatVo">
select
stat.album_id,
max(if(stat.stat_type = '0401',stat.stat_num,0)) playStatNum,
max(if(stat.stat_type = '0402',stat.stat_num,0)) subscribeStatNum,
max(if(stat.stat_type = '0403',stat.stat_num,0)) buyStatNum,
max(if(stat.stat_type = '0404',stat.stat_num,0)) commentStatNum
from album_stat stat where album_id = #{albumId} and stat.is_deleted = 0
group by stat.album_id
</select>
  • 远程调用定义
1
2
3
4
5
6
7
/**
* 通过专辑Id 获取到专辑状态信息
* @param albumId
* @return
*/
@GetMapping("api/album/albumInfo/getAlbumStatVo/{albumId}")
Result<AlbumStatVo> getAlbumStatVo(@PathVariable("albumId") Long albumId);

在service-search完成接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Tag(name = "专辑详情管理")
@RestController
@RequestMapping("api/search/albumInfo")
@SuppressWarnings({"all"})
public class itemApiController {

@Autowired
private ItemService itemService;

@Operation(summary = "专辑详情")
@GetMapping("{albumId}")
public Result getItem(@PathVariable Long albumId){
// 获取到专辑详情数据
Map<String,Object> result = this.itemService.getItem(albumId);
// 返回数据
return Result.ok(result);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
//自定义线程池+并行实现
//专辑详情
@Override
public Map<String, Object> getItem(Long albumId) {
Map<String, Object> map = new HashMap<String, Object>();
//1 根据专辑id获取专辑信息
CompletableFuture<AlbumInfo> completableFuture1 =
CompletableFuture.supplyAsync(() -> {
Result<AlbumInfo> albumInfoResult =
albumInfoFeignClient.getAlbumInfo(albumId);
AlbumInfo albumInfo = albumInfoResult.getData();
Assert.notNull(albumInfo, "专辑数据为空");
//封装专辑信息到map集合
map.put("albumInfo", albumInfo);
return albumInfo;
});

//2 根据专辑里面三级id获取一级和二级分类数据
CompletableFuture<Void> completableFuture2 =
completableFuture1.thenAcceptAsync(albumInfo -> {
//获取专辑里面三级id
Long category3Id = albumInfo.getCategory3Id();
Result<BaseCategoryView> categoryViewResult = categoryFeignClient.getCategoryView(category3Id);
BaseCategoryView baseCategoryView = categoryViewResult.getData();
Assert.notNull(baseCategoryView, "分类为空");
//封装map
map.put("baseCategoryView", baseCategoryView);
});

//3 根据专辑id获取四个统计数据
CompletableFuture<Void> completableFuture3 = CompletableFuture.runAsync(() -> {
Result<AlbumStatVo> albumStatVoResult =
albumInfoFeignClient.getAlbumStatVo(albumId);
AlbumStatVo albumStatVo = albumStatVoResult.getData();
Assert.notNull(albumStatVo, "统计数据为空");
//封装map
map.put("albumStatVo", albumStatVo);
});

//4 根据用户id获取用户信息
CompletableFuture<Void> completableFuture4 =
completableFuture1.thenAcceptAsync(albumInfo -> {
Result<UserInfoVo> userInfoVoResult =
userInfoFeignClient.getUserInfoVo(albumInfo.getUserId());
UserInfoVo userInfoVo = userInfoVoResult.getData();
Assert.notNull(userInfoVo, "用户数据为空");
map.put("announcer", userInfoVo);
});

//5 多个任务都执行完成汇总
CompletableFuture.allOf(
completableFuture1,
completableFuture2,
completableFuture3,
completableFuture4
).join();
//返回map集合
return map;
}

查询专辑下面声音列表

分析

  • 点击某个专辑,进入专辑详情页面显示专辑基本信息
  • 之后,在详情页面进入之后又调用一个接口:根据专辑id查询下面声音列表数据

image-20251029153310377

  • 上面图:1255是专辑id,1 10 分页数据

  • 在声音表track_info可以查询数据

  • 简要流程

– 查询数据包含track_info和track_stat表,查询声音名称和时长、播放量和评论数

– 判断声音是否收费,查询声音所属专辑,看专辑是否免费

— 如果专辑免费,声音免费

— 如果专辑付费,声音付费,如果用户购买过免费

— 如果专辑是vip免费,判断用户,如果用户不是vip收费,如果用户是vip免费

service-album模块接口

  • TrackInfoApiController
1
2
3
4
5
6
7
8
9
10
11
12
13
@GuiguLogin(required = false)
@Operation(summary = "获取专辑声音分页列表")
@GetMapping("findAlbumTrackPage/{albumId}/{page}/{limit}")
public Result<IPage<AlbumTrackListVo>> findAlbumTrackPage(
@PathVariable Long albumId,
@PathVariable Long page,
@PathVariable Long limit) {
//用户id
Long userId = AuthContextHolder.getUserId();
Page<AlbumTrackListVo> pageParam = new Page(page,limit);
IPage<AlbumTrackListVo> pageModel = trackInfoService.findAlbumTrackPage(pageParam,albumId,userId);
return Result.ok(pageModel);
}
  • 根据专辑id获取声音数据sql语句
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
select 
info.trackId,
info.trackTitle,
info.mediaDuration,
max(if(info.statType='0701',info.statNum,0)) playStatNum,
max(if(info.statType='0704',info.statNum,0)) commentStatNum
from

(select
track.id as trackId,
track.track_title as trackTitle,
track.media_duration as mediaDuration,
track.order_num as orderNum,
track.create_time as createTime,
stat.stat_type as statType,
stat.stat_num as statNum
from
track_info track inner join track_stat stat on track.id=stat.track_id
where track.album_id=1
and track.is_open = '1'
and track.status = '0501') info

group by info.trackId
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
//根据专辑id查询专辑声音数据
@Override
public IPage<AlbumTrackListVo>
findAlbumTrackPage(Page<AlbumTrackListVo> pageParam,
Long albumId, Long userId) {
//根据专辑id分页查询得到数据
IPage<AlbumTrackListVo> pageInfo =
trackInfoMapper.findAlbumTrackPage(pageParam,albumId);

//1 根据userId判断当前是否登录
//1.1 如果没有登录
//如果专辑不是免费的,可以试看的声音免费的,其他收费
// isShowPaidMark=flase免费的 isShowPaidMark=true收费的

//1.2 如果登录状态
//判断如果专辑vip免费的
//* 如果用户没有开通vip, 收费
//* 如果用户开通VIP但是过期了,收费

//判断如果专辑付费 收费
//* 判断当前用户是否购买专辑,购买过,免费
// 如果用户购买整个专辑,专辑里面所有声音免费
// 如果用户只是购买了专辑某些声音,购买的声音免费的,其他收费

return pageInfo;
}