mirror of
https://github.com/fish2018/pansou.git
synced 2025-11-25 03:14:59 +08:00
23 KiB
23 KiB
PanSou 服务层设计详解
1. 服务层概述
服务层是PanSou系统的核心业务逻辑层,负责整合不同来源的搜索结果,并进行过滤、排序和分类处理。该层是连接API层和插件系统的桥梁,实现了搜索功能的核心逻辑。
2. 目录结构
pansou/service/
└── search_service.go # 搜索服务实现
3. 搜索服务设计
3.1 搜索服务结构
搜索服务是服务层的核心组件,负责协调不同来源的搜索操作,并处理搜索结果。
// 全局缓存实例和缓存是否初始化标志
var (
twoLevelCache *cache.TwoLevelCache
cacheInitialized bool
)
// 优先关键词列表
var priorityKeywords = []string{"全", "合集", "系列", "完", "最新", "附", "花园墙外"}
// 初始化缓存
func init() {
if config.AppConfig != nil && config.AppConfig.CacheEnabled {
var err error
twoLevelCache, err = cache.NewTwoLevelCache()
if err == nil {
cacheInitialized = true
}
}
}
// SearchService 搜索服务
type SearchService struct{
pluginManager *plugin.PluginManager
}
// NewSearchService 创建搜索服务实例并确保缓存可用
func NewSearchService(pluginManager *plugin.PluginManager) *SearchService {
// 检查缓存是否已初始化,如果未初始化则尝试重新初始化
if !cacheInitialized && config.AppConfig != nil && config.AppConfig.CacheEnabled {
var err error
twoLevelCache, err = cache.NewTwoLevelCache()
if err == nil {
cacheInitialized = true
}
}
return &SearchService{
pluginManager: pluginManager,
}
}
3.2 搜索方法实现
搜索方法是搜索服务的核心方法,实现了搜索逻辑、缓存管理和结果处理。
// Search 执行搜索
func (s *SearchService) Search(keyword string, channels []string, concurrency int, forceRefresh bool, resultType string, sourceType string, plugins []string) (model.SearchResponse, error) {
// 立即生成缓存键并检查缓存
cacheKey := cache.GenerateCacheKey(keyword, channels, resultType, sourceType, plugins)
// 如果未启用强制刷新,尝试从缓存获取结果
if !forceRefresh && twoLevelCache != nil && config.AppConfig.CacheEnabled {
data, hit, err := twoLevelCache.Get(cacheKey)
if err == nil && hit {
var response model.SearchResponse
if err := json.Unmarshal(data, &response); err == nil {
// 根据resultType过滤返回结果
return filterResponseByType(response, resultType), nil
}
}
}
// 获取所有可用插件
var availablePlugins []plugin.SearchPlugin
if s.pluginManager != nil && (sourceType == "all" || sourceType == "plugin") {
allPlugins := s.pluginManager.GetPlugins()
// 确保plugins不为nil并且有非空元素
hasPlugins := plugins != nil && len(plugins) > 0
hasNonEmptyPlugin := false
if hasPlugins {
for _, p := range plugins {
if p != "" {
hasNonEmptyPlugin = true
break
}
}
}
// 只有当plugins数组包含非空元素时才进行过滤
if hasPlugins && hasNonEmptyPlugin {
pluginMap := make(map[string]bool)
for _, p := range plugins {
if p != "" { // 忽略空字符串
pluginMap[strings.ToLower(p)] = true
}
}
for _, p := range allPlugins {
if pluginMap[strings.ToLower(p.Name())] {
availablePlugins = append(availablePlugins, p)
}
}
} else {
// 如果plugins为nil、空数组或只包含空字符串,视为未指定,使用所有插件
availablePlugins = allPlugins
}
}
// 控制并发数:如果用户没有指定有效值,则默认使用"频道数+插件数+10"的并发数
pluginCount := len(availablePlugins)
// 根据sourceType决定是否搜索Telegram频道
channelCount := 0
if sourceType == "all" || sourceType == "tg" {
channelCount = len(channels)
}
if concurrency <= 0 {
concurrency = channelCount + pluginCount + 10
if concurrency < 1 {
concurrency = 1
}
}
// 计算任务总数(频道数 + 插件数)
totalTasks := channelCount + pluginCount
// 如果没有任务要执行,返回空结果
if totalTasks == 0 {
return model.SearchResponse{
Total: 0,
Results: []model.SearchResult{},
MergedByType: make(model.MergedLinks),
}, nil
}
// 使用工作池执行并行搜索
tasks := make([]pool.Task, 0, totalTasks)
// 添加频道搜索任务(如果需要)
if sourceType == "all" || sourceType == "tg" {
for _, channel := range channels {
ch := channel // 创建副本,避免闭包问题
tasks = append(tasks, func() interface{} {
results, err := s.searchChannel(keyword, ch)
if err != nil {
return nil
}
return results
})
}
}
// 添加插件搜索任务(如果需要)
for _, p := range availablePlugins {
plugin := p // 创建副本,避免闭包问题
tasks = append(tasks, func() interface{} {
results, err := plugin.Search(keyword)
if err != nil {
return nil
}
return results
})
}
// 使用带超时控制的工作池执行所有任务并获取结果
results := pool.ExecuteBatchWithTimeout(tasks, concurrency, config.AppConfig.PluginTimeout)
// 预估每个任务平均返回22个结果
allResults := make([]model.SearchResult, 0, totalTasks*22)
// 合并所有结果
for _, result := range results {
if result != nil {
channelResults := result.([]model.SearchResult)
allResults = append(allResults, channelResults...)
}
}
// 过滤结果,确保标题包含搜索关键词
filteredResults := filterResultsByKeyword(allResults, keyword)
// 按照优化后的规则排序结果
sortResultsByTimeAndKeywords(filteredResults)
// 过滤结果,只保留有时间的结果或包含优先关键词的结果到Results中
filteredForResults := make([]model.SearchResult, 0, len(filteredResults))
for _, result := range filteredResults {
// 有时间的结果或包含优先关键词的结果保留在Results中
if !result.Datetime.IsZero() || getKeywordPriority(result.Title) > 0 {
filteredForResults = append(filteredForResults, result)
}
}
// 合并链接按网盘类型分组(使用所有过滤后的结果)
mergedLinks := mergeResultsByType(filteredResults)
// 构建响应
var total int
if resultType == "merged_by_type" {
// 计算所有类型链接的总数
total = 0
for _, links := range mergedLinks {
total += len(links)
}
} else {
// 只计算filteredForResults的数量
total = len(filteredForResults)
}
response := model.SearchResponse{
Total: total,
Results: filteredForResults, // 使用进一步过滤的结果
MergedByType: mergedLinks,
}
// 异步缓存搜索结果(缓存完整结果,以便后续可以根据不同resultType过滤)
if twoLevelCache != nil && config.AppConfig.CacheEnabled {
go func(resp model.SearchResponse) {
data, err := json.Marshal(resp)
if err != nil {
return
}
ttl := time.Duration(config.AppConfig.CacheTTLMinutes) * time.Minute
twoLevelCache.Set(cacheKey, data, ttl)
}(response)
}
// 根据resultType过滤返回结果
return filterResponseByType(response, resultType), nil
}
3.3 缓存键生成优化
为了提高缓存命中率,搜索服务使用了优化的缓存键生成方法,确保相同语义的查询能够命中相同的缓存。
// GenerateCacheKey 根据所有影响搜索结果的参数生成缓存键
func GenerateCacheKey(keyword string, channels []string, resultType string, sourceType string, plugins []string) string {
// 关键词标准化
normalizedKeyword := strings.TrimSpace(keyword)
// 处理channels参数
var channelsStr string
if channels == nil || len(channels) == 0 {
channelsStr = "all"
} else {
// 复制并排序channels,确保顺序一致性
channelsCopy := make([]string, 0, len(channels))
for _, ch := range channels {
if ch != "" { // 忽略空字符串
channelsCopy = append(channelsCopy, ch)
}
}
if len(channelsCopy) == 0 {
channelsStr = "all"
} else {
sort.Strings(channelsCopy)
channelsStr = strings.Join(channelsCopy, ",")
}
}
// 处理resultType参数
if resultType == "" {
resultType = "all"
}
// 处理sourceType参数
if sourceType == "" {
sourceType = "all"
}
// 处理plugins参数
var pluginsStr string
if plugins == nil || len(plugins) == 0 {
pluginsStr = "all"
} else {
// 复制并排序plugins,确保顺序一致性
pluginsCopy := make([]string, 0, len(plugins))
for _, p := range plugins {
if p != "" { // 忽略空字符串
pluginsCopy = append(pluginsCopy, p)
}
}
if len(pluginsCopy) == 0 {
pluginsStr = "all"
} else {
sort.Strings(pluginsCopy)
pluginsStr = strings.Join(pluginsCopy, ",")
}
}
// 生成最终缓存键
keyStr := normalizedKeyword + ":" + channelsStr + ":" + resultType + ":" + sourceType + ":" + pluginsStr
// 计算MD5哈希
hash := md5.Sum([]byte(keyStr))
return hex.EncodeToString(hash[:])
}
主要优化包括:
- 关键词标准化:去除前后空格
- 参数排序:对数组参数进行排序,确保不同顺序的相同参数产生相同的缓存键
- 空值处理:统一处理null、空数组和只包含空字符串的数组
- 特殊值处理:为空参数设置默认值,确保一致性
- 忽略空字符串:在数组参数中忽略空字符串元素
这些优化确保了不同形式但语义相同的查询能够命中相同的缓存,显著提高了缓存命中率。
3.4 辅助方法实现
搜索服务包含多个辅助方法,用于处理搜索结果的过滤、排序和分类。
3.4.1 结果过滤方法
// filterResponseByType 根据结果类型过滤响应
func filterResponseByType(response model.SearchResponse, resultType string) model.SearchResponse {
switch resultType {
case "results":
// 只返回Results
return model.SearchResponse{
Total: response.Total,
Results: response.Results,
}
case "merged_by_type":
// 只返回MergedByType,Results设为nil,结合omitempty标签,JSON序列化时会忽略此字段
return model.SearchResponse{
Total: response.Total,
MergedByType: response.MergedByType,
Results: nil,
}
default:
// 默认返回全部
return response
}
}
// 过滤结果,确保标题包含搜索关键词
func filterResultsByKeyword(results []model.SearchResult, keyword string) []model.SearchResult {
// 预估过滤后会保留80%的结果
filteredResults := make([]model.SearchResult, 0, len(results)*8/10)
// 将关键词转为小写,用于不区分大小写的比较
lowerKeyword := strings.ToLower(keyword)
// 将关键词按空格分割,用于支持多关键词搜索
keywords := strings.Fields(lowerKeyword)
for _, result := range results {
// 将标题和内容转为小写
lowerTitle := strings.ToLower(result.Title)
lowerContent := strings.ToLower(result.Content)
// 检查每个关键词是否在标题或内容中
matched := true
for _, kw := range keywords {
// 如果关键词是"pwd",特殊处理,只要标题、内容或链接中包含即可
if kw == "pwd" {
// 检查标题、内容
pwdInTitle := strings.Contains(lowerTitle, kw)
pwdInContent := strings.Contains(lowerContent, kw)
// 检查链接中是否包含pwd参数
pwdInLinks := false
for _, link := range result.Links {
if strings.Contains(strings.ToLower(link.URL), "pwd=") {
pwdInLinks = true
break
}
}
// 只要有一个包含pwd,就算匹配
if pwdInTitle || pwdInContent || pwdInLinks {
continue // 匹配成功,检查下一个关键词
} else {
matched = false
break
}
} else {
// 对于其他关键词,检查是否同时在标题和内容中
if !strings.Contains(lowerTitle, kw) && !strings.Contains(lowerContent, kw) {
matched = false
break
}
}
}
if matched {
filteredResults = append(filteredResults, result)
}
}
return filteredResults
}
3.4.2 结果排序方法
// 根据时间和关键词排序结果
func sortResultsByTimeAndKeywords(results []model.SearchResult) {
sort.Slice(results, func(i, j int) bool {
// 检查是否有零值时间
iZeroTime := results[i].Datetime.IsZero()
jZeroTime := results[j].Datetime.IsZero()
// 如果两者都是零值时间,按关键词优先级排序
if iZeroTime && jZeroTime {
iPriority := getKeywordPriority(results[i].Title)
jPriority := getKeywordPriority(results[j].Title)
if iPriority != jPriority {
return iPriority > jPriority
}
// 如果优先级也相同,按标题字母顺序排序
return results[i].Title < results[j].Title
}
// 如果只有一个是零值时间,将其排在后面
if iZeroTime {
return false // i排在后面
}
if jZeroTime {
return true // j排在后面,i排在前面
}
// 两者都有正常时间,使用原有逻辑
// 计算两个结果的时间差(以天为单位)
timeDiff := daysBetween(results[i].Datetime, results[j].Datetime)
// 如果时间差超过30天,按时间排序(新的在前面)
if abs(timeDiff) > 30 {
return results[i].Datetime.After(results[j].Datetime)
}
// 如果时间差在30天内,先检查时间差是否超过1天
if abs(timeDiff) > 1 {
return results[i].Datetime.After(results[j].Datetime)
}
// 如果时间差在1天内,检查关键词优先级
iPriority := getKeywordPriority(results[i].Title)
jPriority := getKeywordPriority(results[j].Title)
// 如果优先级不同,优先级高的排在前面
if iPriority != jPriority {
return iPriority > jPriority
}
// 如果优先级相同且时间差在1天内,仍然按时间排序(新的在前面)
return results[i].Datetime.After(results[j].Datetime)
})
}
// 计算两个时间之间的天数差
func daysBetween(t1, t2 time.Time) float64 {
duration := t1.Sub(t2)
return duration.Hours() / 24
}
// 绝对值
func abs(x float64) float64 {
if x < 0 {
return -x
}
return x
}
// 获取标题中包含优先关键词的优先级
func getKeywordPriority(title string) int {
title = strings.ToLower(title)
for i, keyword := range priorityKeywords {
if strings.Contains(title, keyword) {
// 返回优先级(数组索引越小,优先级越高)
return len(priorityKeywords) - i
}
}
return 0
}
3.4.3 频道搜索方法
// 搜索单个频道
func (s *SearchService) searchChannel(keyword string, channel string) ([]model.SearchResult, error) {
// 构建搜索URL
url := util.BuildSearchURL(channel, keyword, "")
// 使用全局HTTP客户端(已配置代理)
client := util.GetHTTPClient()
// 创建一个带超时的上下文
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second)
defer cancel()
// 创建请求
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
}
// 发送请求
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
// 读取响应体
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, err
}
// 解析响应
results, _, err := util.ParseSearchResults(string(body), channel)
if err != nil {
return nil, err
}
return results, nil
}
3.4.4 结果合并方法
// 将搜索结果按网盘类型分组
func mergeResultsByType(results []model.SearchResult) model.MergedLinks {
// 创建合并结果的映射
mergedLinks := make(model.MergedLinks, 10) // 预分配容量,假设有10种不同的网盘类型
// 用于去重的映射,键为URL
uniqueLinks := make(map[string]model.MergedLink)
// 遍历所有搜索结果
for _, result := range results {
for _, link := range result.Links {
// 创建合并后的链接
mergedLink := model.MergedLink{
URL: link.URL,
Password: link.Password,
Note: result.Title,
Datetime: result.Datetime,
}
// 检查是否已存在相同URL的链接
if existingLink, exists := uniqueLinks[link.URL]; exists {
// 如果已存在,只有当当前链接的时间更新时才替换
if mergedLink.Datetime.After(existingLink.Datetime) {
uniqueLinks[link.URL] = mergedLink
}
} else {
// 如果不存在,直接添加
uniqueLinks[link.URL] = mergedLink
}
}
}
// 将去重后的链接按类型分组
for url, mergedLink := range uniqueLinks {
// 获取链接类型
linkType := ""
for _, result := range results {
for _, link := range result.Links {
if link.URL == url {
linkType = link.Type
break
}
}
if linkType != "" {
break
}
}
// 如果没有找到类型,使用"unknown"
if linkType == "" {
linkType = "unknown"
}
// 添加到对应类型的列表中
mergedLinks[linkType] = append(mergedLinks[linkType], mergedLink)
}
// 对每种类型的链接按时间排序(新的在前面)
for linkType, links := range mergedLinks {
sort.Slice(links, func(i, j int) bool {
return links[i].Datetime.After(links[j].Datetime)
})
mergedLinks[linkType] = links
}
return mergedLinks
}
4. 核心设计思想
4.1 高性能设计
- 并发搜索:使用工作池模式实现高效并发搜索,充分利用系统资源
- 缓存机制:利用两级缓存(内存+磁盘)提高重复查询的响应速度
- 异步缓存写入:使用goroutine异步写入缓存,避免阻塞主流程
- 内存预分配:为结果集预分配内存,减少动态扩容带来的开销
- 超时控制:对搜索操作设置严格的超时限制,避免长时间阻塞
4.2 智能排序策略
搜索服务实现了复杂的多级排序策略,综合考虑时间和关键词权重:
- 时间优先:优先展示最新的结果,保证用户获取最新资源
- 关键词权重:对包含"全"、"合集"、"系列"等关键词的结果给予更高权重
- 多级排序:根据时间差的大小采用不同的排序策略
- 时间差超过30天:纯粹按时间排序
- 时间差在1-30天:仍然按时间排序
- 时间差在1天内:优先考虑关键词权重,再考虑时间
- 零值时间处理:对没有时间信息的结果进行特殊处理,按关键词权重排序
4.3 结果过滤策略
- 关键词匹配:确保结果的标题或内容包含搜索关键词
- 多关键词支持:支持空格分隔的多关键词搜索,要求所有关键词都匹配
- 特殊关键词处理:"pwd"关键词特殊处理,检查标题、内容和链接URL
- 质量过滤:只保留有时间信息或包含优先关键词的结果到Results中
4.4 结果分类策略
- 网盘类型分类:按网盘类型(如百度网盘、阿里云盘等)分类展示结果
- 链接去重:对相同URL的链接进行去重,保留最新的信息
- 类型内排序:每种网盘类型内部按时间排序,新的在前面
5. 错误处理策略
- 优雅降级:单个搜索源失败不影响整体结果,保证服务可用性
- 超时控制:对每个搜索任务设置超时限制,避免因单个任务阻塞整体搜索
- 错误隔离:搜索错误不会传播到上层,保证API层的稳定性
- 空结果处理:当没有搜索任务或所有任务失败时,返回空结果而非错误
6. 缓存策略
- 缓存键生成:基于搜索关键词生成缓存键
- 缓存命中检查:在执行搜索前检查缓存,提高响应速度
- 强制刷新:支持forceRefresh参数,便于获取最新结果
- 异步缓存更新:使用goroutine异步写入缓存,避免阻塞主流程
- 缓存TTL:缓存项有明确的过期时间,确保数据不会过时
7. 可扩展性设计
- 插件系统集成:通过插件管理器集成各种搜索插件,便于扩展搜索源
- 参数化控制:通过参数控制搜索行为,如并发数、搜索源等
- 结果类型过滤:支持不同类型的结果返回,满足不同场景需求
- 源类型过滤:支持按源类型(Telegram、插件)过滤搜索结果
8. 性能优化措施
-
内存优化:
- 预分配结果集容量,减少动态扩容
- 创建副本避免闭包问题
- 使用指针减少大对象复制
-
并发优化:
- 使用工作池控制并发数量
- 智能设置默认并发数(频道数+插件数+10)
- 并发任务超时控制
-
缓存优化:
- 两级缓存机制(内存+磁盘)
- 异步写入缓存,避免阻塞主流程
- 缓存完整结果,支持不同resultType过滤
-
算法优化:
- 使用map进行插件名称匹配,提高查找效率
- 优化排序算法,减少比较次数
- 链接去重使用map实现,提高效率