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 = `
+
`;
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)
+ }
})
}