diff --git a/src/public/search.html b/src/public/search.html index 0b4e279..957f792 100644 --- a/src/public/search.html +++ b/src/public/search.html @@ -778,7 +778,12 @@ -
+
+ +
@@ -853,6 +858,10 @@ let totalPages = 1; let currentQuery = ''; let currentRepo = null; + + // 标签分页相关变量 + let currentTagPage = 1; + let totalTagPages = 1; document.getElementById('searchButton').addEventListener('click', () => { currentPage = 1; @@ -884,6 +893,21 @@ showSearchResults(); }); + // 使用事件委托处理分页按钮点击(避免DOM重建导致事件丢失) + document.addEventListener('click', (e) => { + if (e.target.id === 'tagPrevPage') { + if (currentTagPage > 1) { + currentTagPage--; + loadTagPage(); + } + } else if (e.target.id === 'tagNextPage') { + if (currentTagPage < totalTagPages) { + currentTagPage++; + loadTagPage(); + } + } + }); + function showLoading() { document.querySelector('.loading').style.display = 'block'; } @@ -901,71 +925,135 @@ }, 3000); } - function updatePagination() { - const prevButton = document.getElementById('prevPage'); - const nextButton = document.getElementById('nextPage'); + // 统一分页更新函数(支持搜索和标签分页) + function updatePagination(config = {}) { + const { + currentPage: page = currentPage, + totalPages: total = totalPages, + prefix = '' + } = config; + + const prevButtonId = prefix ? `${prefix}PrevPage` : 'prevPage'; + const nextButtonId = prefix ? `${prefix}NextPage` : 'nextPage'; + const paginationId = prefix ? `${prefix}Pagination` : '.pagination'; + + const prevButton = document.getElementById(prevButtonId); + const nextButton = document.getElementById(nextButtonId); + const paginationDiv = prefix ? document.getElementById(paginationId) : document.querySelector(paginationId); - prevButton.disabled = currentPage <= 1; - nextButton.disabled = currentPage >= totalPages; + if (!prevButton || !nextButton || !paginationDiv) { + return; // 静默处理,避免控制台警告 + } + + // 更新按钮状态 + prevButton.disabled = page <= 1; + nextButton.disabled = page >= total; + + // 更新或创建页面信息 + const pageInfoId = prefix ? `${prefix}PageInfo` : 'pageInfo'; + let pageInfo = document.getElementById(pageInfoId); - const paginationDiv = document.querySelector('.pagination'); - let pageInfo = document.getElementById('pageInfo'); if (!pageInfo) { - const container = document.createElement('div'); - container.id = 'pageInfo'; - container.style.margin = '0 10px'; - container.style.display = 'flex'; - container.style.alignItems = 'center'; - container.style.gap = '10px'; - - const pageText = document.createElement('span'); - pageText.id = 'pageText'; - - const jumpInput = document.createElement('input'); - jumpInput.type = 'number'; - jumpInput.min = '1'; - jumpInput.id = 'jumpPage'; - jumpInput.style.width = '60px'; - jumpInput.style.padding = '4px'; - jumpInput.style.borderRadius = '4px'; - jumpInput.style.border = '1px solid var(--border)'; - jumpInput.style.backgroundColor = 'var(--input)'; - jumpInput.style.color = 'var(--foreground)'; - - const jumpButton = document.createElement('button'); - jumpButton.textContent = '跳转'; - jumpButton.className = 'btn search-button'; - jumpButton.style.padding = '4px 8px'; - jumpButton.onclick = () => { - const page = parseInt(jumpInput.value); - if (page && page >= 1 && page <= totalPages) { - currentPage = page; - performSearch(); - } else { - showToast('请输入有效的页码'); - } - }; - - container.appendChild(pageText); - container.appendChild(jumpInput); - container.appendChild(jumpButton); - - paginationDiv.insertBefore(container, nextButton); - pageInfo = container; + pageInfo = createPageInfo(pageInfoId, prefix, total); + paginationDiv.insertBefore(pageInfo, nextButton); } - const pageText = document.getElementById('pageText'); - pageText.textContent = `第 ${currentPage} / ${totalPages || 1} 页 共 ${totalPages || 1} 页`; - - const jumpInput = document.getElementById('jumpPage'); - if (jumpInput) { - jumpInput.max = totalPages; - jumpInput.value = currentPage; - } - - paginationDiv.style.display = totalPages > 1 ? 'flex' : 'none'; + updatePageInfo(pageInfo, page, total, prefix); + paginationDiv.style.display = total > 1 ? 'flex' : 'none'; } + // 创建页面信息元素 + function createPageInfo(pageInfoId, prefix, total) { + const container = document.createElement('div'); + container.id = pageInfoId; + container.style.cssText = 'margin: 0 10px; display: flex; align-items: center; gap: 10px;'; + + const pageText = document.createElement('span'); + pageText.id = prefix ? `${prefix}PageText` : 'pageText'; + + const jumpInput = document.createElement('input'); + jumpInput.type = 'number'; + jumpInput.min = '1'; + jumpInput.max = prefix === 'tag' ? total : Math.min(total, 100); // 搜索页面限制100页 + jumpInput.id = prefix ? `${prefix}JumpPage` : 'jumpPage'; + jumpInput.style.cssText = 'width: 60px; padding: 4px; border-radius: 4px; border: 1px solid var(--border); background-color: var(--input); color: var(--foreground);'; + + const jumpButton = document.createElement('button'); + jumpButton.textContent = '跳转'; + jumpButton.className = 'btn search-button'; + jumpButton.style.padding = '4px 8px'; + jumpButton.onclick = () => handlePageJump(jumpInput, prefix, total); + + container.append(pageText, jumpInput, jumpButton); + return container; + } + + // 更新页面信息显示 + function updatePageInfo(pageInfo, page, total, prefix) { + const pageText = pageInfo.querySelector('span'); + const jumpInput = pageInfo.querySelector('input'); + + // 标签分页显示策略:根据是否确定总页数显示不同格式 + const isTagPagination = prefix === 'tag'; + const maxDisplayPages = isTagPagination ? total : Math.min(total, 100); + const pageTextContent = isTagPagination + ? `第 ${page} 页` + (total > page ? ` (至少 ${total} 页)` : ` (共 ${total} 页)`) + : `第 ${page} / ${maxDisplayPages} 页 共 ${maxDisplayPages} 页` + (total > 100 ? ' (最多100页)' : ''); + + pageText.textContent = pageTextContent; + jumpInput.max = maxDisplayPages; + jumpInput.value = page; + } + + // 处理页面跳转 + function handlePageJump(jumpInput, prefix, total) { + const inputPage = parseInt(jumpInput.value); + const maxPage = prefix === 'tag' ? total : Math.min(total, 100); + if (!inputPage || inputPage < 1 || inputPage > maxPage) { + const limitText = prefix === 'tag' ? '页码' : '页码 (最多100页)'; + showToast(`请输入有效的${limitText}`); + return; + } + + if (prefix === 'tag') { + currentTagPage = inputPage; + loadTagPage(); + } else { + currentPage = inputPage; + performSearch(); + } + } + + // 统一仓库信息处理 + function parseRepositoryInfo(repo) { + const namespace = repo.namespace || (repo.is_official ? 'library' : ''); + let name = repo.name || repo.repo_name || ''; + + // 清理名称,确保不包含命名空间前缀 + if (name.includes('/')) { + const parts = name.split('/'); + name = parts[parts.length - 1]; + } + + const cleanName = name.replace(/^library\//, ''); + const fullRepoName = repo.is_official ? cleanName : `${namespace}/${cleanName}`; + + return { + namespace, + name, + cleanName, + fullRepoName + }; + } + + // 分页更新函数 + const updateSearchPagination = () => updatePagination(); + const updateTagPagination = () => updatePagination({ + currentPage: currentTagPage, + totalPages: totalTagPages, + prefix: 'tag' + }); + function showSearchResults() { document.querySelector('.search-results').style.display = 'block'; document.querySelector('.tag-list').style.display = 'none'; @@ -1006,7 +1094,7 @@ throw new Error(data.error || '搜索请求失败'); } - totalPages = Math.ceil(data.count / 25); + totalPages = Math.min(Math.ceil(data.count / 25), 100); updatePagination(); displayResults(data.results, targetRepo); @@ -1108,23 +1196,58 @@ }); } + // 内存管理 + async function loadTags(namespace, name) { + currentTagPage = 1; + await loadTagPage(namespace, name); + } + + async function loadTagPage(namespace = null, name = null) { showLoading(); try { - if (!namespace || !name) { + // 如果传入了新的namespace和name,更新currentRepo + if (namespace && name) { + // 清理旧数据,防止内存泄露 + cleanupOldTagData(); + } + + // 获取当前仓库信息 + const repoInfo = parseRepositoryInfo(currentRepo); + const currentNamespace = namespace || repoInfo.namespace; + const currentName = name || repoInfo.name; + + // 调试日志 + console.log(`loadTagPage: namespace=${currentNamespace}, name=${currentName}, page=${currentTagPage}`); + + if (!currentNamespace || !currentName) { showToast('命名空间和镜像名称不能为空'); return; } - const response = await fetch(`/tags/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}`); + const response = await fetch(`/tags/${encodeURIComponent(currentNamespace)}/${encodeURIComponent(currentName)}?page=${currentTagPage}&page_size=100`); if (!response.ok) { const errorText = await response.text(); throw new Error(errorText || '获取标签信息失败'); } const data = await response.json(); - displayTags(data); - showTagList(); + + // 改进的总页数计算:使用更准确的分页策略 + if (data.has_more) { + // 如果还有更多页面,至少有当前页+1页,但可能更多 + totalTagPages = Math.max(currentTagPage + 1, totalTagPages); + } else { + // 如果没有更多页面,当前页就是最后一页 + totalTagPages = currentTagPage; + } + + displayTags(data.tags, data.has_more); + updateTagPagination(); + + if (namespace && name) { + showTagList(); + } } catch (error) { console.error('加载标签错误:', error); showToast(error.message || '获取标签信息失败,请稍后重试'); @@ -1133,12 +1256,24 @@ } } - function displayTags(tags) { + function cleanupOldTagData() { + // 清理全局变量,释放内存 + if (window.currentPageTags) { + window.currentPageTags.length = 0; + window.currentPageTags = null; + } + + // 清理DOM缓存 + const tagsContainer = document.getElementById('tagsContainer'); + if (tagsContainer) { + tagsContainer.innerHTML = ''; + } + } + + function displayTags(tags, hasMore = false) { const tagList = document.getElementById('tagList'); - const namespace = currentRepo.namespace || (currentRepo.is_official ? 'library' : ''); - const name = currentRepo.name || currentRepo.repo_name || ''; - const cleanName = name.replace(/^library\//, ''); - const fullRepoName = currentRepo.is_official ? cleanName : `${namespace}/${cleanName}`; + const repoInfo = parseRepositoryInfo(currentRepo); + const { fullRepoName } = repoInfo; let header = `
@@ -1165,22 +1300,60 @@
+ `; tagList.innerHTML = header; - window.allTags = tags; + // 存储当前页标签数据 + window.currentPageTags = tags; renderFilteredTags(tags); } function renderFilteredTags(filteredTags) { const tagsContainer = document.getElementById('tagsContainer'); - const namespace = currentRepo.namespace || (currentRepo.is_official ? 'library' : ''); - const name = currentRepo.name || currentRepo.repo_name || ''; - const cleanName = name.replace(/^library\//, ''); - const fullRepoName = currentRepo.is_official ? cleanName : `${namespace}/${cleanName}`; + const repoInfo = parseRepositoryInfo(currentRepo); + const { fullRepoName } = repoInfo; - let tagsHtml = filteredTags.map(tag => { + if (filteredTags.length === 0) { + tagsContainer.innerHTML = '
未找到匹配的标签
'; + return; + } + + // 渐进式渲染:分批处理大数据集 + const BATCH_SIZE = 50; + + if (filteredTags.length <= BATCH_SIZE) { + // 小数据集:直接渲染 + renderTagsBatch(filteredTags, fullRepoName, tagsContainer, true); + } else { + // 大数据集:分批渲染 + tagsContainer.innerHTML = ''; // 清空容器 + let currentBatch = 0; + + function renderNextBatch() { + const start = currentBatch * BATCH_SIZE; + const end = Math.min(start + BATCH_SIZE, filteredTags.length); + const batch = filteredTags.slice(start, end); + + renderTagsBatch(batch, fullRepoName, tagsContainer, false); + + currentBatch++; + if (end < filteredTags.length) { + // 使用requestAnimationFrame确保UI响应性 + requestAnimationFrame(renderNextBatch); + } + } + + renderNextBatch(); + } + } + + function renderTagsBatch(tags, fullRepoName, container, replaceContent = false) { + const tagsHtml = tags.map(tag => { const vulnIndicators = Object.entries(tag.vulnerabilities || {}) .map(([level, count]) => count > 0 ? `` : '') .join(''); @@ -1212,23 +1385,23 @@ `; }).join(''); - if (filteredTags.length === 0) { - tagsHtml = '
未找到匹配的标签
'; + if (replaceContent) { + container.innerHTML = tagsHtml; + } else { + container.insertAdjacentHTML('beforeend', tagsHtml); } - - tagsContainer.innerHTML = tagsHtml; } function filterTags(searchText) { - if (!window.allTags) return; + if (!window.currentPageTags) return; const searchLower = searchText.toLowerCase(); let filteredTags; if (!searchText) { - filteredTags = window.allTags; + filteredTags = window.currentPageTags; } else { - const scoredTags = window.allTags.map(tag => { + const scoredTags = window.currentPageTags.map(tag => { const name = tag.name.toLowerCase(); let score = 0; @@ -1263,6 +1436,8 @@ } } + + function copyToClipboard(text) { navigator.clipboard.writeText(text).then(() => { showToast('已复制到剪贴板'); diff --git a/src/search.go b/src/search.go index db284f4..ebf2c18 100644 --- a/src/search.go +++ b/src/search.go @@ -66,14 +66,21 @@ type Image struct { Size int64 `json:"size"` } +// TagPageResult 分页标签结果 +type TagPageResult struct { + Tags []TagInfo `json:"tags"` + HasMore bool `json:"has_more"` +} + type cacheEntry struct { data interface{} - timestamp time.Time + expiresAt time.Time // 存储过期时间 } const ( - maxCacheSize = 1000 // 最大缓存条目数 - cacheTTL = 30 * time.Minute + maxCacheSize = 1000 // 最大缓存条目数 + maxPaginationCache = 200 // 分页缓存最大条目数 + cacheTTL = 30 * time.Minute ) type Cache struct { @@ -98,7 +105,8 @@ func (c *Cache) Get(key string) (interface{}, bool) { return nil, false } - if time.Since(entry.timestamp) > cacheTTL { + // 比较过期时间 + if time.Now().After(entry.expiresAt) { c.mu.Lock() delete(c.data, key) c.mu.Unlock() @@ -109,40 +117,36 @@ func (c *Cache) Get(key string) (interface{}, bool) { } func (c *Cache) Set(key string, data interface{}) { + c.SetWithTTL(key, data, cacheTTL) +} + +func (c *Cache) SetWithTTL(key string, data interface{}, ttl time.Duration) { c.mu.Lock() defer c.mu.Unlock() - now := time.Now() - for k, v := range c.data { - if now.Sub(v.timestamp) > cacheTTL { - delete(c.data, k) - } - } - + // 惰性清理:仅在容量超限时清理过期项 if len(c.data) >= c.maxSize { - toDelete := len(c.data) / 4 - for k := range c.data { - if toDelete <= 0 { - break - } - delete(c.data, k) - toDelete-- - } + c.cleanupExpiredLocked() } + // 计算过期时间 c.data[key] = cacheEntry{ data: data, - timestamp: now, + expiresAt: time.Now().Add(ttl), } } func (c *Cache) Cleanup() { c.mu.Lock() defer c.mu.Unlock() - + c.cleanupExpiredLocked() +} + +// cleanupExpiredLocked 清理过期缓存(需要已持有锁) +func (c *Cache) cleanupExpiredLocked() { now := time.Now() for key, entry := range c.data { - if now.Sub(entry.timestamp) > cacheTTL { + if now.After(entry.expiresAt) { delete(c.data, key) } } @@ -152,6 +156,8 @@ func (c *Cache) Cleanup() { func init() { go func() { ticker := time.NewTicker(5 * time.Minute) + defer ticker.Stop() // 确保ticker资源释放 + for range ticker.C { searchCache.Cleanup() } @@ -214,8 +220,43 @@ func filterSearchResults(results []Repository, query string) []Repository { return filtered } +// normalizeRepository 统一规范化仓库信息(消除重复逻辑) +func normalizeRepository(repo *Repository) { + if repo.IsOfficial { + repo.Namespace = "library" + if !strings.Contains(repo.Name, "/") { + repo.Name = "library/" + repo.Name + } + } else { + // 处理用户仓库:设置命名空间但保持Name为纯仓库名 + if repo.Namespace == "" && repo.RepoOwner != "" { + repo.Namespace = repo.RepoOwner + } + + // 如果Name包含斜杠,提取纯仓库名 + if strings.Contains(repo.Name, "/") { + parts := strings.Split(repo.Name, "/") + if len(parts) > 1 { + if repo.Namespace == "" { + repo.Namespace = parts[0] + } + repo.Name = parts[len(parts)-1] // 取最后部分作为仓库名 + } + } + } +} + // searchDockerHub 搜索镜像 func searchDockerHub(ctx context.Context, query string, page, pageSize int) (*SearchResult, error) { + return searchDockerHubWithDepth(ctx, query, page, pageSize, 0) +} + +// searchDockerHubWithDepth 搜索镜像(带递归深度控制) +func searchDockerHubWithDepth(ctx context.Context, query string, page, pageSize int, depth int) (*SearchResult, error) { + // 防止无限递归:最多允许1次递归调用 + if depth > 1 { + return nil, fmt.Errorf("搜索请求过于复杂,请尝试更具体的关键词") + } cacheKey := fmt.Sprintf("search:%s:%d:%d", query, page, pageSize) // 尝试从缓存获取 @@ -264,11 +305,7 @@ func searchDockerHub(ctx context.Context, query string, page, pageSize int) (*Se if err != nil { return nil, fmt.Errorf("请求Docker Hub API失败: %v", err) } - defer func() { - if err := resp.Body.Close(); err != nil { - fmt.Printf("关闭搜索响应体失败: %v\n", err) - } - }() + defer safeCloseResponseBody(resp.Body, "搜索响应体") body, err := io.ReadAll(resp.Body) if err != nil { @@ -281,8 +318,8 @@ func searchDockerHub(ctx context.Context, query string, page, pageSize int) (*Se return nil, fmt.Errorf("请求过于频繁,请稍后重试") case http.StatusNotFound: if isUserRepo && namespace != "" { - // 如果用户仓库搜索失败,尝试普通搜索 - return searchDockerHub(ctx, repoName, page, pageSize) + // 如果用户仓库搜索失败,尝试普通搜索(递归调用) + return searchDockerHubWithDepth(ctx, repoName, page, pageSize, depth+1) } return nil, fmt.Errorf("未找到相关镜像") case http.StatusBadGateway, http.StatusServiceUnavailable: @@ -318,18 +355,16 @@ func searchDockerHub(ctx context.Context, query string, page, pageSize int) (*Se for _, repo := range userRepos.Results { // 如果指定了仓库名,只保留匹配的结果 if repoName == "" || strings.Contains(strings.ToLower(repo.Name), strings.ToLower(repoName)) { - // 确保设置正确的命名空间和名称 + // 设置命名空间并使用统一的规范化函数 repo.Namespace = namespace - if !strings.Contains(repo.Name, "/") { - repo.Name = fmt.Sprintf("%s/%s", namespace, repo.Name) - } + normalizeRepository(&repo) result.Results = append(result.Results, repo) } } - // 如果没有找到结果,尝试普通搜索 + // 如果没有找到结果,尝试普通搜索(递归调用) if len(result.Results) == 0 { - return searchDockerHub(ctx, repoName, page, pageSize) + return searchDockerHubWithDepth(ctx, repoName, page, pageSize, depth+1) } result.Count = len(result.Results) @@ -340,23 +375,9 @@ func searchDockerHub(ctx context.Context, query string, page, pageSize int) (*Se return nil, fmt.Errorf("解析响应失败: %v", err) } - // 处理搜索结果 + // 处理搜索结果:使用统一的规范化函数 for i := range result.Results { - if result.Results[i].IsOfficial { - if !strings.Contains(result.Results[i].Name, "/") { - result.Results[i].Name = "library/" + result.Results[i].Name - } - result.Results[i].Namespace = "library" - } else { - parts := strings.Split(result.Results[i].Name, "/") - if len(parts) > 1 { - result.Results[i].Namespace = parts[0] - result.Results[i].Name = parts[1] - } else if result.Results[i].RepoOwner != "" { - result.Results[i].Namespace = result.Results[i].RepoOwner - result.Results[i].Name = fmt.Sprintf("%s/%s", result.Results[i].RepoOwner, result.Results[i].Name) - } - } + normalizeRepository(&result.Results[i]) } // 如果是用户/仓库搜索,过滤结果 @@ -394,61 +415,150 @@ func isRetryableError(err error) bool { return false } -// getRepositoryTags 获取仓库标签信息 -func getRepositoryTags(ctx context.Context, namespace, name string) ([]TagInfo, error) { +// getRepositoryTags 获取仓库标签信息(支持分页) +func getRepositoryTags(ctx context.Context, namespace, name string, page, pageSize int) ([]TagInfo, bool, error) { if namespace == "" || name == "" { - return nil, fmt.Errorf("无效输入:命名空间和名称不能为空") + return nil, false, fmt.Errorf("无效输入:命名空间和名称不能为空") } - cacheKey := fmt.Sprintf("tags:%s:%s", namespace, name) + // 默认参数 + if page <= 0 { + page = 1 + } + if pageSize <= 0 || pageSize > 100 { + pageSize = 100 + } + + // 分页缓存key + cacheKey := fmt.Sprintf("tags:%s:%s:page_%d", namespace, name, page) if cached, ok := searchCache.Get(cacheKey); ok { - return cached.([]TagInfo), nil + result := cached.(TagPageResult) + return result.Tags, result.HasMore, nil } // 构建API URL baseURL := fmt.Sprintf("https://registry.hub.docker.com/v2/repositories/%s/%s/tags", namespace, name) params := url.Values{} - params.Set("page_size", "100") + params.Set("page", fmt.Sprintf("%d", page)) + params.Set("page_size", fmt.Sprintf("%d", pageSize)) params.Set("ordering", "last_updated") fullURL := baseURL + "?" + params.Encode() - // 使用统一的搜索HTTP客户端 - resp, err := GetSearchHTTPClient().Get(fullURL) + // 获取当前页数据 + pageResult, err := fetchTagPage(ctx, fullURL, 3) if err != nil { - return nil, fmt.Errorf("发送请求失败: %v", err) + return nil, false, fmt.Errorf("获取标签失败: %v", err) } - defer func() { - if err := resp.Body.Close(); err != nil { - fmt.Printf("关闭搜索响应体失败: %v\n", err) + + hasMore := pageResult.Next != "" + + // 缓存结果(分页缓存时间较短) + result := TagPageResult{Tags: pageResult.Results, HasMore: hasMore} + searchCache.SetWithTTL(cacheKey, result, 30*time.Minute) + + return pageResult.Results, hasMore, nil +} + +// fetchTagPage 获取单页标签数据,带重试机制 +func fetchTagPage(ctx context.Context, url string, maxRetries int) (*struct { + Count int `json:"count"` + Next string `json:"next"` + Previous string `json:"previous"` + Results []TagInfo `json:"results"` +}, error) { + var lastErr error + + for retry := 0; retry < maxRetries; retry++ { + if retry > 0 { + // 重试前等待一段时间 + time.Sleep(time.Duration(retry) * 500 * time.Millisecond) } - }() - // 读取响应体 - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("读取响应失败: %v", err) - } + resp, err := GetSearchHTTPClient().Get(url) + if err != nil { + lastErr = err + if isRetryableError(err) && retry < maxRetries-1 { + continue + } + return nil, fmt.Errorf("发送请求失败: %v", err) + } - // 检查响应状态码 - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("请求失败: 状态码=%d, 响应=%s", resp.StatusCode, string(body)) - } + // 读取响应体(立即关闭,避免defer在循环中累积) + body, err := func() ([]byte, error) { + defer safeCloseResponseBody(resp.Body, "标签响应体") + return io.ReadAll(resp.Body) + }() + + if err != nil { + lastErr = err + if retry < maxRetries-1 { + continue + } + return nil, fmt.Errorf("读取响应失败: %v", err) + } - // 解析响应 - var result struct { - Count int `json:"count"` - Next string `json:"next"` - Previous string `json:"previous"` - Results []TagInfo `json:"results"` - } - if err := json.Unmarshal(body, &result); err != nil { - return nil, fmt.Errorf("解析响应失败: %v", err) - } + // 检查响应状态码 + if resp.StatusCode != http.StatusOK { + lastErr = fmt.Errorf("状态码=%d, 响应=%s", resp.StatusCode, string(body)) + // 4xx错误通常不需要重试 + if resp.StatusCode >= 400 && resp.StatusCode < 500 && resp.StatusCode != 429 { + return nil, fmt.Errorf("请求失败: %v", lastErr) + } + if retry < maxRetries-1 { + continue + } + return nil, fmt.Errorf("请求失败: %v", lastErr) + } - // 缓存结果 - searchCache.Set(cacheKey, result.Results) - return result.Results, nil + // 解析响应 + var result struct { + Count int `json:"count"` + Next string `json:"next"` + Previous string `json:"previous"` + Results []TagInfo `json:"results"` + } + if err := json.Unmarshal(body, &result); err != nil { + lastErr = err + if retry < maxRetries-1 { + continue + } + return nil, fmt.Errorf("解析响应失败: %v", err) + } + + return &result, nil + } + + return nil, lastErr +} + +// parsePaginationParams 解析分页参数 +func parsePaginationParams(c *gin.Context, defaultPageSize int) (page, pageSize int) { + page = 1 + pageSize = defaultPageSize + + if p := c.Query("page"); p != "" { + fmt.Sscanf(p, "%d", &page) + } + if ps := c.Query("page_size"); ps != "" { + fmt.Sscanf(ps, "%d", &pageSize) + } + + return page, pageSize +} + +// safeCloseResponseBody 安全关闭HTTP响应体(统一资源管理) +func safeCloseResponseBody(body io.ReadCloser, context string) { + if body != nil { + if err := body.Close(); err != nil { + fmt.Printf("关闭%s失败: %v\n", context, err) + } + } +} + +// sendErrorResponse 统一错误响应处理 +func sendErrorResponse(c *gin.Context, message string) { + c.JSON(http.StatusBadRequest, gin.H{"error": message}) } // RegisterSearchRoute 注册搜索相关路由 @@ -457,22 +567,15 @@ func RegisterSearchRoute(r *gin.Engine) { r.GET("/search", func(c *gin.Context) { query := c.Query("q") if query == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "搜索关键词不能为空"}) + sendErrorResponse(c, "搜索关键词不能为空") return } - page := 1 - pageSize := 25 - if p := c.Query("page"); p != "" { - fmt.Sscanf(p, "%d", &page) - } - if ps := c.Query("page_size"); ps != "" { - fmt.Sscanf(ps, "%d", &pageSize) - } + page, pageSize := parsePaginationParams(c, 25) result, err := searchDockerHub(c.Request.Context(), query, page, pageSize) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + sendErrorResponse(c, err.Error()) return } @@ -485,16 +588,27 @@ func RegisterSearchRoute(r *gin.Engine) { name := c.Param("name") if namespace == "" || name == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "命名空间和名称不能为空"}) + sendErrorResponse(c, "命名空间和名称不能为空") return } - tags, err := getRepositoryTags(c.Request.Context(), namespace, name) + page, pageSize := parsePaginationParams(c, 100) + + tags, hasMore, err := getRepositoryTags(c.Request.Context(), namespace, name, page, pageSize) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + sendErrorResponse(c, err.Error()) return } - c.JSON(http.StatusOK, tags) + if c.Query("page") != "" || c.Query("page_size") != "" { + c.JSON(http.StatusOK, gin.H{ + "tags": tags, + "has_more": hasMore, + "page": page, + "page_size": pageSize, + }) + } else { + c.JSON(http.StatusOK, tags) + } }) }