Files
pansou/docs/3-服务层设计.md
www.xueximeng.com 5a3917c999 异步插件缓存
2025-07-15 00:03:02 +08:00

23 KiB
Raw Blame History

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[:])
}

主要优化包括:

  1. 关键词标准化:去除前后空格
  2. 参数排序:对数组参数进行排序,确保不同顺序的相同参数产生相同的缓存键
  3. 空值处理统一处理null、空数组和只包含空字符串的数组
  4. 特殊值处理:为空参数设置默认值,确保一致性
  5. 忽略空字符串:在数组参数中忽略空字符串元素

这些优化确保了不同形式但语义相同的查询能够命中相同的缓存,显著提高了缓存命中率。

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":
        // 只返回MergedByTypeResults设为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 高性能设计

  1. 并发搜索:使用工作池模式实现高效并发搜索,充分利用系统资源
  2. 缓存机制:利用两级缓存(内存+磁盘)提高重复查询的响应速度
  3. 异步缓存写入使用goroutine异步写入缓存避免阻塞主流程
  4. 内存预分配:为结果集预分配内存,减少动态扩容带来的开销
  5. 超时控制:对搜索操作设置严格的超时限制,避免长时间阻塞

4.2 智能排序策略

搜索服务实现了复杂的多级排序策略,综合考虑时间和关键词权重:

  1. 时间优先:优先展示最新的结果,保证用户获取最新资源
  2. 关键词权重:对包含"全"、"合集"、"系列"等关键词的结果给予更高权重
  3. 多级排序:根据时间差的大小采用不同的排序策略
    • 时间差超过30天纯粹按时间排序
    • 时间差在1-30天仍然按时间排序
    • 时间差在1天内优先考虑关键词权重再考虑时间
  4. 零值时间处理:对没有时间信息的结果进行特殊处理,按关键词权重排序

4.3 结果过滤策略

  1. 关键词匹配:确保结果的标题或内容包含搜索关键词
  2. 多关键词支持:支持空格分隔的多关键词搜索,要求所有关键词都匹配
  3. 特殊关键词处理"pwd"关键词特殊处理检查标题、内容和链接URL
  4. 质量过滤只保留有时间信息或包含优先关键词的结果到Results中

4.4 结果分类策略

  1. 网盘类型分类:按网盘类型(如百度网盘、阿里云盘等)分类展示结果
  2. 链接去重对相同URL的链接进行去重保留最新的信息
  3. 类型内排序:每种网盘类型内部按时间排序,新的在前面

5. 错误处理策略

  1. 优雅降级:单个搜索源失败不影响整体结果,保证服务可用性
  2. 超时控制:对每个搜索任务设置超时限制,避免因单个任务阻塞整体搜索
  3. 错误隔离搜索错误不会传播到上层保证API层的稳定性
  4. 空结果处理:当没有搜索任务或所有任务失败时,返回空结果而非错误

6. 缓存策略

  1. 缓存键生成:基于搜索关键词生成缓存键
  2. 缓存命中检查:在执行搜索前检查缓存,提高响应速度
  3. 强制刷新支持forceRefresh参数便于获取最新结果
  4. 异步缓存更新使用goroutine异步写入缓存避免阻塞主流程
  5. 缓存TTL:缓存项有明确的过期时间,确保数据不会过时

7. 可扩展性设计

  1. 插件系统集成:通过插件管理器集成各种搜索插件,便于扩展搜索源
  2. 参数化控制:通过参数控制搜索行为,如并发数、搜索源等
  3. 结果类型过滤:支持不同类型的结果返回,满足不同场景需求
  4. 源类型过滤支持按源类型Telegram、插件过滤搜索结果

8. 性能优化措施

  1. 内存优化

    • 预分配结果集容量,减少动态扩容
    • 创建副本避免闭包问题
    • 使用指针减少大对象复制
  2. 并发优化

    • 使用工作池控制并发数量
    • 智能设置默认并发数(频道数+插件数+10
    • 并发任务超时控制
  3. 缓存优化

    • 两级缓存机制(内存+磁盘)
    • 异步写入缓存,避免阻塞主流程
    • 缓存完整结果支持不同resultType过滤
  4. 算法优化

    • 使用map进行插件名称匹配提高查找效率
    • 优化排序算法,减少比较次数
    • 链接去重使用map实现提高效率