优化TMDB搜索,支持电影,先展示结果列表页

This commit is contained in:
www.xueximeng.com
2025-07-16 15:31:27 +08:00
parent 590b3b0de7
commit f98bf36e25
16 changed files with 1944 additions and 70 deletions

View File

@@ -142,6 +142,9 @@ docker run -d --name dongman \
---
# 更新日志
-202507161526
✅ 优化TMDB搜索显示结果列表页选择具体资源后再显示详情支持电影搜索
✅ 变更访问人数统计工具
-202507091318
✅ 新增贴纸功能,可以在详情页显示透明贴纸,用户自由拖拽、旋转
-202507061001

View File

@@ -530,7 +530,8 @@ onMounted(() => {
// 添加不蒜子访问统计脚本
const bszScript = document.createElement('script');
bszScript.async = true;
bszScript.src = "//busuanzi.ibruce.info/busuanzi/2.3/busuanzi.pure.mini.js";
// bszScript.src = "//busuanzi.ibruce.info/busuanzi/2.3/busuanzi.pure.mini.js";
bszScript.src = "//events.vercount.one/js";
document.head.appendChild(bszScript);
// 加载TMDB配置

View File

@@ -2298,4 +2298,244 @@
.view-resource-link:hover {
background-color: rgba(37, 99, 235, 0.1);
text-decoration: underline;
}
/* 搜索结果列表样式 */
.search-results-container {
margin-bottom: 40px;
}
.results-title {
font-size: 1.8rem;
margin-bottom: 20px;
color: var(--text-color);
font-weight: 600;
}
.results-count {
font-size: 1.2rem;
color: var(--text-color-secondary);
font-weight: 400;
}
.results-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.result-card {
background: var(--card-bg);
border-radius: 12px;
overflow: hidden;
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
cursor: pointer;
position: relative;
}
.result-card:hover {
transform: translateY(-5px);
box-shadow: 0 8px 15px rgba(0, 0, 0, 0.15);
}
.result-poster {
position: relative;
padding-top: 150%; /* 2:3 aspect ratio */
overflow: hidden;
}
.poster-image {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
}
.result-card:hover .poster-image {
transform: scale(1.05);
}
.media-type-badge {
position: absolute;
top: 10px;
right: 10px;
background: rgba(0, 0, 0, 0.7);
color: #fff;
font-size: 0.75rem;
padding: 3px 8px;
border-radius: 4px;
z-index: 2;
}
.result-info {
padding: 12px;
}
.result-title {
font-size: 1rem;
font-weight: 600;
margin: 0 0 5px;
color: var(--text-color);
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.result-original-title {
font-size: 0.85rem;
color: var(--text-color-secondary);
margin: 0 0 8px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.result-year {
font-size: 0.85rem;
color: var(--text-color-secondary);
margin: 0 0 5px;
}
.result-rating {
display: flex;
align-items: center;
gap: 5px;
color: #f5c518; /* IMDB yellow */
}
.result-rating i {
font-size: 0.9rem;
}
/* 分页控件样式 */
.pagination-container {
display: flex;
justify-content: center;
align-items: center;
margin-top: 30px;
gap: 10px;
}
.pagination-btn {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.7);
border: 1px solid rgba(124, 58, 237, 0.2);
color: var(--primary-color);
cursor: pointer;
transition: all 0.3s ease;
}
.pagination-btn:hover:not(:disabled) {
background: rgba(124, 58, 237, 0.1);
transform: translateY(-3px);
box-shadow: 0 5px 15px rgba(124, 58, 237, 0.15);
}
.pagination-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.page-numbers {
display: flex;
gap: 8px;
}
.page-number {
min-width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.7);
border: 1px solid rgba(124, 58, 237, 0.2);
color: var(--dark-color);
cursor: pointer;
transition: all 0.3s ease;
padding: 0 10px;
font-weight: 500;
font-size: 0.95rem;
}
.page-number.active {
background: var(--primary-gradient);
color: white;
border: none;
box-shadow: 0 8px 20px rgba(124, 58, 237, 0.25);
}
.page-number:hover:not(.active) {
background: rgba(124, 58, 237, 0.1);
transform: translateY(-3px);
box-shadow: 0 5px 15px rgba(124, 58, 237, 0.15);
}
/* 无搜索结果样式 */
.no-results {
text-align: center;
padding: 60px 0;
background-color: var(--card-bg);
border-radius: 12px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08);
margin-bottom: 40px;
color: var(--text-color-secondary);
}
.no-results i {
font-size: 4rem;
margin-bottom: 20px;
display: block;
opacity: 0.9;
}
/* 响应式调整 */
@media (max-width: 768px) {
.results-grid {
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 15px;
}
.results-title {
font-size: 1.5rem;
}
.results-count {
font-size: 1rem;
}
}
@media (max-width: 576px) {
.results-grid {
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 12px;
}
.result-info {
padding: 10px 8px;
}
.result-title {
font-size: 0.9rem;
}
.pagination-btn, .page-number {
width: 36px;
height: 36px;
min-width: 36px;
font-size: 0.9rem;
}
}

View File

@@ -35,6 +35,15 @@
<input type="number" class="form-control custom-input" id="tmdbId" v-model="editForm.tmdb_id" placeholder="输入TMDB ID">
</div>
<div class="form-group">
<label for="mediaType" class="form-label">媒体类型 <small class="text-muted">(movie或tv)</small></label>
<select class="form-control custom-input" id="mediaType" v-model="editForm.media_type">
<option value="">未指定</option>
<option value="movie">电影</option>
<option value="tv">剧集</option>
</select>
</div>
<div class="form-group">
<label for="titleEn" class="form-label">英文标题</label>
<input type="text" class="form-control custom-input" id="titleEn" v-model="editForm.title_en">
@@ -441,7 +450,7 @@
</button>
<button class="btn-custom btn-episode" @click="toggleEpisodeExplorer"
:class="{'active': showingEpisodeExplorer}"
v-if="tmdbEnabled">
v-if="tmdbEnabled && (!resource.media_type || resource.media_type === 'tv')">
<i class="bi bi-film"></i><span class="btn-text">{{ showingEpisodeExplorer ? '返回详情' : '剧集探索' }}</span>
</button>
<button
@@ -718,11 +727,46 @@ const loadTMDBConfig = async () => {
}
// 切换剧集探索显示状态
const toggleEpisodeExplorer = () => {
const toggleEpisodeExplorer = async () => {
if (!resource.value?.tmdb_id && !resource.value?.title_en) {
alert('该资源没有关联的TMDB ID或英文标题无法查看剧集信息');
return;
}
// 如果没有tmdb_id但有title_en尝试更新tmdb_id和media_type
if (!resource.value.tmdb_id && resource.value.title_en) {
try {
const response = await axios.post(`/api/resources/${resource.value.id}/update-tmdb`, {
title_en: resource.value.title_en,
media_type: 'tv' // 剧集探索按钮只对tv类型有效
});
if (response.data && response.data.tmdb_id) {
resource.value.tmdb_id = response.data.tmdb_id;
resource.value.media_type = response.data.media_type || 'tv';
console.log(`已更新资源TMDB ID: ${resource.value.tmdb_id}, 媒体类型: ${resource.value.media_type}`);
}
} catch (err) {
console.error('更新TMDB ID失败:', err);
}
}
// 如果有tmdb_id但没有media_type设置默认media_type为tv
if (resource.value.tmdb_id && !resource.value.media_type) {
try {
const response = await axios.put(`/api/resources/${resource.value.id}`, {
media_type: 'tv'
});
if (response.data) {
resource.value.media_type = 'tv';
console.log(`已更新资源媒体类型为: tv`);
}
} catch (err) {
console.error('更新媒体类型失败:', err);
}
}
showingEpisodeExplorer.value = !showingEpisodeExplorer.value;
}
@@ -738,7 +782,8 @@ const editForm = reactive({
poster_image: '',
images: [], // 添加images数组存储所有图片
tmdb_id: null, // 添加TMDB ID字段
stickers: [] // 添加贴纸数组
stickers: [], // 添加贴纸数组
media_type: '' // 添加媒体类型字段
})
// 链接编辑相关数据
@@ -964,6 +1009,7 @@ const startEdit = () => {
editForm.poster_image = resource.value.poster_image || ''
editForm.images = [...(resource.value.images || [])] // 复制所有图片
editForm.tmdb_id = resource.value.tmdb_id || null // 复制TMDB ID
editForm.media_type = resource.value.media_type || '' // 复制媒体类型
// 确保贴纸数据为数组
if (Array.isArray(resource.value.stickers)) {
@@ -1209,7 +1255,8 @@ const saveChanges = async () => {
images: editForm.images, // 提交所有图片
links: hasLinks ? linksToSubmit : undefined, // 提交链接数据
tmdb_id: editForm.tmdb_id === '' || editForm.tmdb_id === null ? 0 : editForm.tmdb_id, // 确保清空时传递数字0
stickers: stickersMap // 提交贴纸数据为JsonMap格式
stickers: stickersMap, // 提交贴纸数据为JsonMap格式
media_type: editForm.media_type // 提交媒体类型
})
// 更新本地资源数据

View File

@@ -41,6 +41,83 @@
<p>资源已成功导入</p>
</div>
<!-- 搜索结果列表 -->
<div v-else-if="searchResults && !tmdbResource" class="search-results-container">
<h2 class="results-title">
搜索结果 <span class="results-count">({{ searchResults.total_results }})</span>
</h2>
<!-- 无结果提示 -->
<div v-if="searchResults.results && searchResults.results.length === 0" class="no-results">
<i class="bi bi-search"></i>
<p>未找到相关资源请尝试其他关键词</p>
</div>
<!-- 结果网格 -->
<div v-else class="results-grid">
<div
v-for="item in searchResults.results"
:key="`${item.media_type}-${item.id}`"
class="result-card"
@click="viewMediaDetails(item)"
>
<div class="result-poster">
<img
:src="item.poster_path ? `https://image.tmdb.org/t/p/w300${item.poster_path}` : 'https://via.placeholder.com/300x450?text=No+Image'"
class="poster-image"
:alt="item.title || item.name"
>
<div class="media-type-badge">
{{ item.media_type === 'movie' ? '电影' : '电视剧' }}
</div>
</div>
<div class="result-info">
<h3 class="result-title">{{ item.title || item.name }}</h3>
<p v-if="item.original_title && item.original_title !== item.title" class="result-original-title">
{{ item.original_title || item.original_name }}
</p>
<p class="result-year" v-if="getYear(item)">{{ getYear(item) }}</p>
<div class="result-rating" v-if="item.vote_average">
<i class="bi bi-star-fill"></i>
<span>{{ item.vote_average.toFixed(1) }}</span>
</div>
</div>
</div>
</div>
<!-- 分页控件 -->
<div v-if="searchResults.total_pages > 1" class="pagination-container">
<button
class="pagination-btn"
@click="changePage(currentPage - 1)"
:disabled="currentPage <= 1"
>
<i class="bi bi-chevron-left"></i>
</button>
<div class="page-numbers">
<button
v-for="page in displayedPages"
:key="page"
class="page-number"
:class="{ active: page === currentPage }"
@click="page !== '...' && changePage(page)"
:disabled="page === '...'"
>
{{ page }}
</button>
</div>
<button
class="pagination-btn"
@click="changePage(currentPage + 1)"
:disabled="currentPage >= searchResults.total_pages"
>
<i class="bi bi-chevron-right"></i>
</button>
</div>
</div>
<!-- 搜索结果和预览 -->
<div v-else-if="tmdbResource">
<div class="resource-detail">
@@ -568,6 +645,11 @@ export default {
showImagePreview: false,
previewImageUrl: '',
// 搜索结果相关
searchResults: null,
currentPage: 1,
totalPages: 0,
// 编辑模式相关
isEditing: false,
_resourceWasEdited: false, // 内部标记,用于跟踪资源是否被编辑过
@@ -651,6 +733,31 @@ export default {
isValidImageUrl() {
const url = this.imageUrlInput.trim();
return url.startsWith('http://') || url.startsWith('https://');
},
// 计算要显示的页码
displayedPages() {
const pages = [];
if (!this.totalPages) return pages;
if (this.totalPages <= 5) {
for (let i = 1; i <= this.totalPages; i++) {
pages.push(i);
}
} else {
// 当前页在前面
if (this.currentPage <= 3) {
pages.push(1, 2, 3, 4, 5, '...', this.totalPages);
}
// 当前页在后面
else if (this.currentPage >= this.totalPages - 2) {
pages.push(1, '...', this.totalPages - 4, this.totalPages - 3, this.totalPages - 2, this.totalPages - 1, this.totalPages);
}
// 当前页在中间
else {
pages.push(1, '...', this.currentPage - 1, this.currentPage, this.currentPage + 1, '...', this.totalPages);
}
}
return pages;
}
},
methods: {
@@ -660,22 +767,84 @@ export default {
return;
}
this.resetState();
this.loading = true;
// 只有在新搜索时重置状态分页操作时保留tmdbResource为null
if (this.currentPage === 1) {
this.resetState();
} else {
// 分页操作时只重置部分状态
this.tmdbResource = null;
this.error = null;
}
try {
// 调用TMDB搜索API
const response = await axios.get(`/api/tmdb/search`, {
params: { query: this.searchQuery.trim() }
// 调用TMDB Multi Search API
const response = await axios.get(`/api/tmdb/multi_search`, {
params: {
query: this.searchQuery.trim(),
page: this.currentPage
}
});
this.tmdbResource = response.data;
if (this.tmdbResource && this.tmdbResource.images && this.tmdbResource.images.length > 0) {
this.currentImage = this.tmdbResource.poster_image || this.tmdbResource.images[0];
this.searchResults = response.data;
this.totalPages = this.searchResults.total_pages;
this.currentPage = this.searchResults.page;
this.hasSearched = true;
} catch (error) {
console.error('TMDB搜索失败:', error);
this.error = error.response?.data?.error || '搜索失败,请稍后重试';
} finally {
this.loading = false;
}
},
// 获取年份显示
getYear(item) {
if (item.media_type === 'movie' && item.release_date) {
return item.release_date.substring(0, 4);
} else if (item.media_type === 'tv' && item.first_air_date) {
return item.first_air_date.substring(0, 4);
}
return '';
},
// 媒体详情点击事件
async viewMediaDetails(item) {
this.loading = true;
try {
// 保存搜索结果中的中文数据
const chineseData = {
title: item.title || item.name || '',
overview: item.overview || ''
};
// 调用详情API获取完整信息
const response = await axios.get(`/api/tmdb/details/${item.media_type}/${item.id}`);
const detailData = response.data;
// 合并数据,优先使用中文数据
this.tmdbResource = {
...detailData,
// 使用搜索结果中的中文标题作为主标题
title: chineseData.title,
// 使用详情中的原始标题作为英文标题
title_en: detailData.original_title || detailData.original_name || '',
// 使用搜索结果中的中文简介
description: chineseData.overview || detailData.overview || '',
// 保留后端返回的中文分类
resource_type: detailData.resource_type || '',
// 保存媒体类型
media_type: item.media_type || 'tv'
};
// 设置当前图片
if (this.tmdbResource.images && this.tmdbResource.images.length > 0) {
this.currentImage = this.tmdbResource.poster_path || this.tmdbResource.images[0];
}
// 设置默认的活动链接类别
if (this.tmdbResource && this.tmdbResource.links) {
if (this.tmdbResource.links) {
const categories = Object.keys(this.tmdbResource.links);
for (const category of categories) {
if (this.tmdbResource.links[category] && this.tmdbResource.links[category].length > 0) {
@@ -686,12 +855,12 @@ export default {
}
// 检查资源是否已存在
if (this.tmdbResource && this.tmdbResource.id) {
if (this.tmdbResource.id) {
try {
const checkResponse = await axios.get(`/api/tmdb/check-exists`, {
params: {
tmdb_id: this.tmdbResource.id,
title: this.tmdbResource.title
title: this.tmdbResource.title || this.tmdbResource.name
}
});
@@ -704,14 +873,11 @@ export default {
}
} catch (checkError) {
console.error('检查资源是否存在失败:', checkError);
// 检查失败不影响主流程,继续显示搜索结果
}
}
this.hasSearched = true;
} catch (error) {
console.error('TMDB搜索失败:', error);
this.error = error.response?.data?.error || '搜索失败,请稍后重试';
console.error('获取媒体详情失败:', error);
this.error = error.response?.data?.error || '获取详情失败,请稍后重试';
} finally {
this.loading = false;
}
@@ -762,6 +928,8 @@ export default {
links: this.tmdbResource.links,
// 添加TMDB ID
id: this.tmdbResource.id,
// 添加媒体类型
media_type: this.tmdbResource.media_type,
// 检查资源是否已被编辑过
is_custom: this.hasBeenEdited
};
@@ -790,11 +958,16 @@ export default {
this.activeCategory = null;
this.resourceExists = false;
this.existingResource = null;
// 不重置searchResults和分页相关状态
},
resetSearch() {
// 完全重置,包括搜索结果和分页
this.resetState();
this.searchQuery = '';
this.searchResults = null;
this.currentPage = 1;
this.totalPages = 0;
},
// 编辑模式相关方法
@@ -1006,7 +1179,9 @@ export default {
resource_type: this.editForm.resource_type.join(','),
poster_image: this.editForm.poster_image,
images: [...this.editForm.images],
links: linksToSubmit
links: linksToSubmit,
// 保留原来的media_type
media_type: this.tmdbResource.media_type
};
// 标记资源已被编辑
@@ -1120,6 +1295,11 @@ export default {
// 清空输入框
this.imageUrlInput = '';
},
changePage(page) {
if (page < 1 || page > this.totalPages) return;
this.currentPage = page;
this.searchTMDB(); // 重新搜索以更新结果
}
},
beforeDestroy() {

View File

@@ -0,0 +1,382 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"strings"
"time"
"dongman/internal/models"
)
func main() {
log.Println("开始数据库诊断...")
// 初始化数据库
db, err := models.InitDB()
if err != nil {
log.Fatalf("数据库初始化失败: %v", err)
}
defer db.Close()
// 检查数据库位置
workDir, err := os.Getwd()
if err != nil {
log.Fatalf("获取工作目录失败: %v", err)
}
log.Printf("当前工作目录: %s", workDir)
// 检查表结构
log.Println("\n===== 检查数据库表结构 =====")
pragmaRows, err := db.Query("PRAGMA table_info(resources)")
if err != nil {
log.Fatalf("查询表结构失败: %v", err)
}
defer pragmaRows.Close()
fmt.Println("资源表结构:")
fmt.Printf("%-4s %-20s %-10s %-8s %-10s\n", "序号", "字段名", "类型", "可空", "默认值")
fmt.Println(strings.Repeat("-", 60))
for pragmaRows.Next() {
var cid int
var name, type_ string
var notnull, dfltValue, pk interface{}
pragmaRows.Scan(&cid, &name, &type_, &notnull, &dfltValue, &pk)
fmt.Printf("%-4d %-20s %-10s %-8v %-10v\n", cid, name, type_, notnull, dfltValue)
}
fmt.Println()
// 检查资源数量
log.Println("\n===== 检查资源数量 =====")
var totalCount int
err = db.Get(&totalCount, "SELECT COUNT(*) FROM resources")
if err != nil {
log.Fatalf("查询资源总数失败: %v", err)
}
log.Printf("数据库中总共有 %d 条资源记录", totalCount)
// 检查资源状态分布
log.Println("\n===== 检查资源状态分布 =====")
rows, err := db.Query("SELECT status, COUNT(*) FROM resources GROUP BY status")
if err != nil {
log.Fatalf("查询资源状态分布失败: %v", err)
}
defer rows.Close()
for rows.Next() {
var status string
var count int
rows.Scan(&status, &count)
log.Printf("状态 [%s]: %d 条记录", status, count)
}
// 检查是否存在approved且非supplement的资源
log.Println("\n===== 检查已批准的非补充资源 =====")
var approvedCount int
err = db.Get(&approvedCount,
"SELECT COUNT(*) FROM resources WHERE status = ? AND is_supplement_approval = 0",
"APPROVED")
if err != nil {
log.Fatalf("查询已批准的非补充资源数量失败: %v", err)
}
log.Printf("已批准的非补充资源数量: %d", approvedCount)
// 显示几条样例资源
log.Println("\n===== 样例资源数据 =====")
var sampleResources []struct {
ID int `db:"id"`
Title string `db:"title"`
Status string `db:"status"`
IsSupplementApproval bool `db:"is_supplement_approval"`
LikesCount int `db:"likes_count"`
CreatedAt time.Time `db:"created_at"`
}
err = db.Select(&sampleResources, "SELECT id, title, status, is_supplement_approval, likes_count, created_at FROM resources LIMIT 3")
if err != nil {
log.Fatalf("查询样例资源失败: %v", err)
}
for i, resource := range sampleResources {
log.Printf("样例 #%d:", i+1)
log.Printf(" ID: %d", resource.ID)
log.Printf(" 标题: %s", resource.Title)
log.Printf(" 状态: %s", resource.Status)
log.Printf(" 是否补充批准: %v", resource.IsSupplementApproval)
log.Printf(" 点赞数: %d", resource.LikesCount)
log.Printf(" 创建时间: %v", resource.CreatedAt)
}
// 尝试执行前端请求的查询
log.Println("\n===== 测试前端查询 =====")
var frontendResources []struct {
ID int `db:"id"`
Title string `db:"title"`
LikesCount int `db:"likes_count"`
}
query := `SELECT id, title, likes_count FROM resources WHERE status = ? ORDER BY likes_count DESC LIMIT ? OFFSET ?`
args := []interface{}{"APPROVED", 4, 0}
err = db.Select(&frontendResources, query, args...)
if err != nil {
log.Fatalf("执行前端查询失败: %v", err)
}
log.Printf("前端查询找到 %d 条资源", len(frontendResources))
for i, resource := range frontendResources {
log.Printf("结果 #%d: [ID: %d] %s (点赞: %d)",
i+1, resource.ID, resource.Title, resource.LikesCount)
}
log.Println("\n===== 检查users表结构 =====")
userPragmaRows, err := db.Query("PRAGMA table_info(users)")
if err != nil {
log.Fatalf("查询users表结构失败: %v", err)
}
defer userPragmaRows.Close()
fmt.Println("用户表结构:")
fmt.Printf("%-4s %-20s %-10s %-8s %-10s\n", "序号", "字段名", "类型", "可空", "默认值")
fmt.Println(strings.Repeat("-", 60))
for userPragmaRows.Next() {
var cid int
var name, type_ string
var notnull, dfltValue, pk interface{}
userPragmaRows.Scan(&cid, &name, &type_, &notnull, &dfltValue, &pk)
fmt.Printf("%-4d %-20s %-10s %-8v %-10v\n", cid, name, type_, notnull, dfltValue)
}
fmt.Println()
// 检查是否有用户
log.Println("\n===== 检查用户数量 =====")
var userCount int
err = db.Get(&userCount, "SELECT COUNT(*) FROM users")
if err != nil {
log.Fatalf("查询用户总数失败: %v", err)
}
log.Printf("数据库中总共有 %d 个用户", userCount)
// 如果有用户,显示第一个用户信息(不显示密码)
if userCount > 0 {
var users []struct {
ID int `db:"id"`
Username string `db:"username"`
IsAdmin bool `db:"is_admin"`
CreatedAt time.Time `db:"created_at"`
}
err = db.Select(&users, "SELECT id, username, is_admin, created_at FROM users LIMIT 3")
if err != nil {
log.Fatalf("查询用户信息失败: %v", err)
}
for i, user := range users {
log.Printf("用户 #%d:", i+1)
log.Printf(" ID: %d", user.ID)
log.Printf(" 用户名: %s", user.Username)
log.Printf(" 是否管理员: %v", user.IsAdmin)
log.Printf(" 创建时间: %v", user.CreatedAt)
}
}
// 尝试获取admin用户信息
log.Println("\n===== 检查admin用户 =====")
// 分开获取admin用户信息和密码
var adminUser struct {
ID int `db:"id"`
Username string `db:"username"`
IsAdmin bool `db:"is_admin"`
CreatedAt time.Time `db:"created_at"`
}
err = db.Get(&adminUser, "SELECT id, username, is_admin, created_at FROM users WHERE username = ?", "admin")
if err != nil {
log.Printf("查询admin用户失败: %v", err)
} else {
// 单独获取密码哈希长度
var hashedPassword string
err = db.Get(&hashedPassword, "SELECT hashed_password FROM users WHERE username = ?", "admin")
if err != nil {
log.Printf("获取admin密码哈希失败: %v", err)
}
log.Printf("找到admin用户:")
log.Printf(" ID: %d", adminUser.ID)
log.Printf(" 用户名: %s", adminUser.Username)
log.Printf(" 密码哈希长度: %d", len(hashedPassword))
log.Printf(" 是否管理员: %v", adminUser.IsAdmin)
log.Printf(" 创建时间: %v", adminUser.CreatedAt)
}
// 查询前端请求的表单
log.Println("\n===== 前端登录请求格式参考 =====")
log.Printf(`请确保前端发送的登录请求格式如下:
{
"username": "admin",
"password": "admin123"
}
登录API地址: POST /api/auth/token`)
// 测试登录请求
log.Println("\n===== 测试登录API =====")
log.Printf("现在将使用默认账号 admin/admin123 测试登录API")
// 创建测试HTTP客户端
log.Printf("请确保API服务器正在运行测试将向 http://localhost:8000/api/auth/token 发送请求")
log.Printf("请求头: Content-Type: application/json")
log.Printf("请求体: {\"username\":\"admin\",\"password\":\"admin123\"}")
// 构建请求体
loginReq := struct {
Username string `json:"username"`
Password string `json:"password"`
}{
Username: "admin",
Password: "admin123",
}
loginReqJSON, err := json.Marshal(loginReq)
if err != nil {
log.Printf("序列化登录请求失败: %v", err)
} else {
// 发送HTTP请求
resp, err := http.Post(
"http://localhost:8000/api/auth/token",
"application/json",
bytes.NewBuffer(loginReqJSON),
)
if err != nil {
log.Printf("发送登录请求失败: %v", err)
log.Printf("这可能是因为API服务器未运行请确保服务器已启动")
} else {
defer resp.Body.Close()
// 读取响应
var respBody map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&respBody)
log.Printf("HTTP状态码: %d", resp.StatusCode)
if err != nil {
log.Printf("解析响应失败: %v", err)
// 尝试读取原始响应
buf := new(bytes.Buffer)
buf.ReadFrom(resp.Body)
log.Printf("原始响应: %s", buf.String())
} else {
log.Printf("响应体: %v", respBody)
if resp.StatusCode == 200 {
log.Printf("登录成功!获取到令牌")
} else {
log.Printf("登录失败,请检查用户名和密码")
}
}
}
}
log.Printf(`要在命令行测试登录,请执行以下命令:
curl -X POST http://localhost:8000/api/auth/token \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"admin123"}'`)
log.Printf(`如果前端使用fetch或axios发送请求请确保格式如下:
fetch('http://localhost:8000/api/auth/token', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
username: 'admin',
password: 'admin123'
})
})`)
log.Println("\n诊断完成!")
}
func resetResourceData() error {
// 删除所有现有资源
_, err := models.DB.Exec("DELETE FROM resources")
if err != nil {
return fmt.Errorf("清空资源表失败: %w", err)
}
// 重置自增ID
_, err = models.DB.Exec("DELETE FROM sqlite_sequence WHERE name='resources'")
if err != nil {
log.Printf("重置资源表自增ID失败 (非致命错误): %v", err)
}
// 创建新的示例数据
return createSampleResources()
}
func createSampleResources() error {
sampleResources := []models.Resource{
{
Title: "进击的巨人",
TitleEn: "Attack on Titan",
Description: "人类与巨人的生存之战",
ResourceType: "anime",
Status: models.ResourceStatusApproved,
Images: []string{"/assets/imgs/1/sample1.jpg"},
Links: models.JsonMap{"官网": "https://example.com/aot"},
LikesCount: 120,
IsSupplementApproval: false,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
{
Title: "鬼灭之刃",
TitleEn: "Demon Slayer",
Description: "少年与恶魔的战斗故事",
ResourceType: "anime",
Status: models.ResourceStatusApproved,
Images: []string{"/assets/imgs/2/sample2.jpg"},
Links: models.JsonMap{"官网": "https://example.com/ds"},
LikesCount: 150,
IsSupplementApproval: false,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
{
Title: "海贼王",
TitleEn: "One Piece",
Description: "寻找传说中的大秘宝「ONE PIECE」的海洋冒险故事",
ResourceType: "anime",
Status: models.ResourceStatusApproved,
Images: []string{"/assets/imgs/3/sample3.jpg"},
Links: models.JsonMap{"官网": "https://example.com/op"},
LikesCount: 200,
IsSupplementApproval: false,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
}
// 插入示例数据
for _, resource := range sampleResources {
_, err := models.DB.Exec(
`INSERT INTO resources (
title, title_en, description, resource_type, images, links,
status, likes_count, is_supplement_approval, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
resource.Title, resource.TitleEn, resource.Description, resource.ResourceType,
resource.Images, resource.Links, resource.Status, resource.LikesCount,
resource.IsSupplementApproval, resource.CreatedAt, resource.UpdatedAt,
)
if err != nil {
return fmt.Errorf("插入示例资源失败: %w", err)
}
}
return nil
}

View File

@@ -0,0 +1,118 @@
package main
import (
"database/sql"
"fmt"
"log"
"os"
"path/filepath"
_ "github.com/mattn/go-sqlite3"
)
func main() {
// 获取当前工作目录
wd, err := os.Getwd()
if err != nil {
log.Fatalf("获取工作目录失败: %v", err)
}
log.Printf("当前工作目录: %s", wd)
// 构建数据库路径 - 尝试在多个位置查找
possiblePaths := []string{
"./resource_hub.db", // 当前目录
"../../resource_hub.db", // 项目根目录
filepath.Join(wd, "resource_hub.db"), // 绝对路径
filepath.Join(wd, "../../resource_hub.db"), // 绝对路径到项目根目录
}
var db *sql.DB
var dbPath string
// 尝试打开数据库
for _, path := range possiblePaths {
log.Printf("尝试打开数据库: %s", path)
if _, err := os.Stat(path); err == nil {
log.Printf("找到数据库文件: %s", path)
db, err = sql.Open("sqlite3", path)
if err == nil {
dbPath = path
log.Printf("成功打开数据库: %s", path)
break
} else {
log.Printf("打开数据库失败: %v", err)
}
} else {
log.Printf("数据库文件不存在: %s", path)
}
}
if db == nil {
log.Fatalf("未能找到或打开任何数据库文件")
}
log.Printf("使用数据库文件: %s", dbPath)
// 测试数据库连接
if err := db.Ping(); err != nil {
log.Fatalf("数据库连接测试失败: %v", err)
}
log.Printf("数据库连接测试成功")
// 检查资源表结构
log.Println("检查资源表结构:")
rows, err := db.Query("PRAGMA table_info(resources)")
if err != nil {
log.Fatalf("查询表结构失败: %v", err)
}
defer rows.Close()
var (
cid int
name string
dataType string
notNull int
dfltValue interface{}
primaryKey int
)
for rows.Next() {
if err := rows.Scan(&cid, &name, &dataType, &notNull, &dfltValue, &primaryKey); err != nil {
log.Fatalf("扫描表结构数据失败: %v", err)
}
log.Printf("列: %s, 类型: %s, 非空: %d, 默认值: %v, 主键: %d", name, dataType, notNull, dfltValue, primaryKey)
}
// 统计资源数量
var count int
if err := db.QueryRow("SELECT COUNT(*) FROM resources").Scan(&count); err != nil {
log.Fatalf("统计资源失败: %v", err)
}
log.Printf("资源总数: %d", count)
// 查询已批准的资源数量
if err := db.QueryRow("SELECT COUNT(*) FROM resources WHERE status = 'APPROVED'").Scan(&count); err != nil {
log.Fatalf("统计已批准资源失败: %v", err)
}
log.Printf("已批准资源数: %d", count)
// 检查ID为1的资源记录
log.Println("查询ID为1的资源记录:")
rows, err = db.Query("SELECT id, title, title_en, description, resource_type, status FROM resources WHERE id = 1")
if err != nil {
log.Fatalf("查询资源失败: %v", err)
}
defer rows.Close()
for rows.Next() {
var id int
var title, titleEn, description, resourceType, status string
if err := rows.Scan(&id, &title, &titleEn, &description, &resourceType, &status); err != nil {
log.Fatalf("扫描资源数据失败: %v", err)
}
log.Printf("资源ID: %d, 标题: %s, 英文标题: %s, 类型: %s, 状态: %s", id, title, titleEn, resourceType, status)
log.Printf("描述: %s", description)
}
fmt.Println("SQLite 数据库测试完成")
}

View File

@@ -0,0 +1,85 @@
package main
import (
"fmt"
"log"
"os"
"path/filepath"
"dongman/internal/models"
"dongman/internal/utils"
)
func main() {
// 初始化数据库连接以确保可以正常使用utils包
db, err := models.InitDB()
if err != nil {
log.Fatalf("数据库初始化失败: %v", err)
}
defer db.Close()
log.Println("WebP转换工具测试")
// 示例图片路径 - 如果该路径不存在,请替换为实际存在的图片路径
testImgPath := "imgs/1/test.jpg"
// 获取工作目录构建assets目录路径
workDir, err := os.Getwd()
if err != nil {
log.Fatalf("获取工作目录失败: %v", err)
}
// 构建测试目录
assetsDir := filepath.Join(workDir, "..", "..", "assets")
testDir := filepath.Join(assetsDir, "imgs", "1")
// 确保测试目录存在
if err := os.MkdirAll(testDir, 0755); err != nil {
log.Fatalf("创建测试目录失败: %v", err)
}
// 将测试图片从实例目录复制到测试目录 - 请确保原图片存在
srcImagePath := filepath.Join(workDir, "..", "..", "assets", "uploads")
files, err := filepath.Glob(filepath.Join(srcImagePath, "*", "*.jpg"))
if err != nil || len(files) == 0 {
log.Fatalf("未找到测试图片: %v", err)
}
// 使用找到的第一张图片
testSrcPath := files[0]
testDstPath := filepath.Join(testDir, "test.jpg")
// 复制测试图片
if err := utils.CopyFile(testSrcPath, testDstPath); err != nil {
log.Fatalf("复制测试图片失败: %v", err)
}
log.Printf("已复制测试图片: %s -> %s", testSrcPath, testDstPath)
// 测试固定尺寸的转换
log.Println("测试1: 固定尺寸转换(600x900)")
webpPath, err := utils.ConvertToWebP(testImgPath)
if err != nil {
log.Fatalf("WebP转换失败: %v", err)
}
log.Printf("转换成功! WebP文件路径: %s", webpPath)
// 测试保持宽高比的转换
log.Println("测试2: 保持宽高比转换(最大600x900)")
webpRatioPath, err := utils.ConvertToWebPWithRatio(testImgPath, 600, 900, true)
if err != nil {
log.Fatalf("保持宽高比的WebP转换失败: %v", err)
}
log.Printf("转换成功! 保持宽高比的WebP文件路径: %s", webpRatioPath)
// 测试完整路径转换
log.Println("测试3: 完整路径转换")
fullPath := filepath.Join(assetsDir, testImgPath)
webpFullPath, err := utils.ConvertToWebPFromPath(fullPath)
if err != nil {
log.Fatalf("完整路径的WebP转换失败: %v", err)
}
log.Printf("转换成功! 完整路径的WebP文件: %s", webpFullPath)
fmt.Println("所有测试完成!")
}

View File

@@ -8,6 +8,8 @@ import (
"strings"
"time"
"path/filepath"
"net/url"
"encoding/json"
"github.com/gin-gonic/gin"
@@ -699,6 +701,13 @@ func UpdateResource(c *gin.Context) {
log.Printf("更新TMDB ID: %v", resource.TmdbID)
}
if resourceUpdate.MediaType != nil {
// 处理媒体类型更新
resource.MediaType = resourceUpdate.MediaType
updated = true
log.Printf("更新媒体类型: %v", *resource.MediaType)
}
if resourceUpdate.Stickers != nil {
// 检查贴纸中是否有需要移动的图片从临时uploads目录到永久目录
imagesToMove := make([]string, 0)
@@ -985,4 +994,137 @@ func UpdateResourceStickers(c *gin.Context) {
log.Printf("贴纸更新成功资源ID: %d", resourceID)
c.JSON(http.StatusOK, gin.H{"message": "贴纸更新成功", "resource": resource})
}
// UpdateResourceTMDBInfo 更新资源的TMDB ID和媒体类型 - 通过英文标题自动更新
func UpdateResourceTMDBInfo(c *gin.Context) {
// 获取路径参数
resourceID, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的资源ID"})
return
}
// 解析请求
var request struct {
TitleEn string `json:"title_en"`
MediaType string `json:"media_type"`
}
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的请求参数"})
return
}
// 检查英文标题是否存在
if request.TitleEn == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "英文标题不能为空"})
return
}
// 检查媒体类型
if request.MediaType == "" {
request.MediaType = "tv" // 默认为电视剧
}
// 验证媒体类型是否有效
if request.MediaType != "tv" && request.MediaType != "movie" {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的媒体类型,必须是 tv 或 movie"})
return
}
// 检查资源是否存在
var resource models.Resource
err = models.DB.Get(&resource, `SELECT * FROM resources WHERE id = ?`, resourceID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "资源未找到"})
return
}
// 通过TMDB API查询资源ID
tmdbResults, err := SearchTMDBByQuery(request.TitleEn, request.MediaType)
if err != nil || len(tmdbResults) == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "在TMDB中未找到匹配的资源"})
return
}
// 使用第一个结果的ID
tmdbID := tmdbResults[0].ID
// 更新资源的TMDB ID和媒体类型
resource.TmdbID = &tmdbID
mediaType := request.MediaType
resource.MediaType = &mediaType
resource.UpdatedAt = time.Now()
// 保存更新
err = models.UpdateResourceWithStickers(&resource)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("更新资源失败: %v", err)})
return
}
// 返回更新后的资源
c.JSON(http.StatusOK, resource)
}
// SearchTMDBByQuery 通过查询字符串搜索TMDB资源
func SearchTMDBByQuery(query string, mediaType string) ([]TMDBSearchResult, error) {
if !IsTMDBEnabled() {
return nil, fmt.Errorf("TMDB未启用")
}
// 构建请求URL
baseURL := "https://api.themoviedb.org/3/search/" + mediaType
requestURL := fmt.Sprintf("%s?api_key=%s&query=%s&language=zh-CN",
baseURL, utils.GetTMDBAPIKey(), url.QueryEscape(query))
// 发送请求
response, err := http.Get(requestURL)
if err != nil {
return nil, err
}
defer response.Body.Close()
// 解析响应
var searchResponse struct {
Results []TMDBSearchResult `json:"results"`
}
err = json.NewDecoder(response.Body).Decode(&searchResponse)
if err != nil {
return nil, err
}
return searchResponse.Results, nil
}
// TMDBSearchResult TMDB搜索结果结构
type TMDBSearchResult struct {
ID int `json:"id"`
Title string `json:"title,omitempty"`
Name string `json:"name,omitempty"`
Overview string `json:"overview"`
PosterPath string `json:"poster_path"`
BackdropPath string `json:"backdrop_path"`
MediaType string `json:"media_type,omitempty"`
}
// IsTMDBEnabled 检查TMDB功能是否启用
func IsTMDBEnabled() bool {
var settings models.SiteSettings
err := models.GetDB().Get(&settings, "SELECT * FROM site_settings WHERE setting_key = ?", "tmdb_config")
if err != nil {
// 如果找不到配置,返回未启用状态
return false
}
// 从配置中提取enabled字段
enabled := false
if val, ok := settings.SettingValue["enabled"].(bool); ok {
enabled = val
}
return enabled
}

View File

@@ -82,6 +82,12 @@ func SetupRoutes(router *gin.Engine) {
// 更新资源的TMDB ID
tmdb.PUT("/update-resource-id/:id/:tmdb_id", UpdateResourceTmdbID)
// 添加新的多类型搜索API
tmdb.GET("/multi_search", MultiSearchTMDB)
// 添加新的媒体详情API
tmdb.GET("/details/:media_type/:media_id", GetMediaDetails)
}
// 资源路由 - 需要认证
@@ -94,7 +100,8 @@ func SetupRoutes(router *gin.Engine) {
resources.POST("/:id/unlike", UnlikeResource)
resources.PUT("/:id/supplement", SupplementResource)
resources.PUT("/:id/stickers", UpdateResourceStickers)
resources.POST("/:id/update-tmdb", UpdateResourceTMDBInfo)
resources.POST("/", CreateResource)
// 图片上传API - 处理不同URL路径格式

View File

@@ -26,6 +26,7 @@ type TMDBSearchRequest struct {
PosterImage string `json:"poster_image"`
Images []string `json:"images"`
Links map[string][]map[string]string `json:"links"`
MediaType string `json:"media_type"` // 媒体类型movie, tv
IsCustom bool `json:"is_custom"` // 标识是否为自定义资源
}
@@ -119,6 +120,12 @@ func CreateResourceFromTMDB(c *gin.Context) {
tmdbID = &req.ID
}
// 处理媒体类型
var mediaType *string
if req.MediaType != "" {
mediaType = &req.MediaType
}
// 创建自定义资源对象
resource = &models.Resource{
Title: req.Title,
@@ -130,35 +137,61 @@ func CreateResourceFromTMDB(c *gin.Context) {
Links: linksMap,
Status: defaultStatus,
TmdbID: tmdbID,
MediaType: mediaType,
CreatedAt: now,
UpdatedAt: now,
}
} else {
// 标准TMDB资源处理逻辑
tmdbResource, err := utils.SearchTMDB(req.Query)
if err != nil {
log.Printf("TMDB搜索失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
// 标准TMDB资源处理逻辑 - 使用前端传递的数据而不是重新搜索
log.Printf("处理TMDB资源导入请求: ID=%d, 标题=%s, 类型=%s",
req.ID, req.Title, req.MediaType)
// 验证必要字段
if req.ID <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "TMDB ID不能为空"})
return
}
if req.Title == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "资源标题不能为空"})
return
}
// 确保PosterImage不为空
posterImage := tmdbResource.PosterImage
var posterImage *string
if req.PosterImage != "" {
posterImage = &req.PosterImage
} else if len(req.Images) > 0 {
posterImage = &req.Images[0]
}
// 设置TMDB ID
tmdbID := tmdbResource.ID
// 将 req.Links 转换为 models.JsonMap 类型
linksMap := make(models.JsonMap)
for key, value := range req.Links {
linksMap[key] = value
}
// 创建资源对象
// 处理TMDB ID
tmdbID := req.ID
// 处理媒体类型
var mediaType *string
if req.MediaType != "" {
mediaType = &req.MediaType
}
// 创建资源对象 - 直接使用前端提供的数据
resource = &models.Resource{
Title: tmdbResource.Title,
TitleEn: tmdbResource.TitleEn,
Description: tmdbResource.Description,
ResourceType: tmdbResource.ResourceType,
PosterImage: &posterImage,
Images: tmdbResource.Images,
Links: models.JsonMap(tmdbResource.Links),
Title: req.Title,
TitleEn: req.TitleEn,
Description: req.Description,
ResourceType: req.ResourceType,
PosterImage: posterImage,
Images: req.Images,
Links: linksMap,
Status: defaultStatus,
TmdbID: &tmdbID,
MediaType: mediaType,
CreatedAt: now,
UpdatedAt: now,
}
@@ -168,10 +201,10 @@ func CreateResourceFromTMDB(c *gin.Context) {
result, err := models.DB.NamedExec(`
INSERT INTO resources (
title, title_en, description, resource_type, poster_image,
images, links, status, tmdb_id, created_at, updated_at
images, links, status, tmdb_id, media_type, created_at, updated_at
) VALUES (
:title, :title_en, :description, :resource_type, :poster_image,
:images, :links, :status, :tmdb_id, :created_at, :updated_at
:images, :links, :status, :tmdb_id, :media_type, :created_at, :updated_at
)
`, resource)

View File

@@ -0,0 +1,93 @@
package handlers
import (
"log"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"dongman/internal/utils"
)
// MultiSearchTMDB 多类型搜索TMDB API
// @Summary 多类型搜索TMDB API
// @Description 根据查询字符串搜索TMDB API获取电影和电视剧信息
// @Tags TMDB
// @Accept json
// @Produce json
// @Param query query string true "搜索关键词"
// @Param page query int false "页码默认为1"
// @Success 200 {object} utils.TMDBMultiSearchResponse
// @Failure 400 {object} gin.H
// @Failure 500 {object} gin.H
// @Router /api/tmdb/multi_search [get]
func MultiSearchTMDB(c *gin.Context) {
// 从URL查询参数获取查询字符串
query := c.Query("query")
// 检查查询字符串是否为空
if strings.TrimSpace(query) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "查询字符串不能为空"})
return
}
// 获取页码参数默认为1
pageStr := c.DefaultQuery("page", "1")
page, err := strconv.Atoi(pageStr)
if err != nil || page < 1 {
page = 1
}
// 使用TMDB工具搜索
response, err := utils.MultiSearch(query, page)
if err != nil {
log.Printf("TMDB多类型搜索失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "TMDB多类型搜索失败"})
return
}
c.JSON(http.StatusOK, response)
}
// GetMediaDetails 获取媒体详情
// @Summary 获取媒体详情
// @Description 根据媒体类型和ID获取详细信息包括海报和剧照
// @Tags TMDB
// @Accept json
// @Produce json
// @Param media_type path string true "媒体类型(movie或tv)"
// @Param media_id path int true "媒体ID"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} gin.H
// @Failure 500 {object} gin.H
// @Router /api/tmdb/details/{media_type}/{media_id} [get]
func GetMediaDetails(c *gin.Context) {
// 获取路径参数
mediaType := c.Param("media_type")
mediaIDStr := c.Param("media_id")
// 验证媒体类型
if mediaType != "movie" && mediaType != "tv" {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的媒体类型,必须是 movie 或 tv"})
return
}
// 解析媒体ID
mediaID, err := strconv.Atoi(mediaIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的媒体ID"})
return
}
// 获取媒体详情
details, err := utils.GetMediaDetails(mediaType, mediaID)
if err != nil {
log.Printf("获取媒体详情失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取媒体详情失败"})
return
}
c.JSON(http.StatusOK, details)
}

View File

@@ -109,7 +109,7 @@ func InitDB() (*sqlx.DB, error) {
db.SetConnMaxLifetime(time.Minute * 30)
// 分步执行初始化表结构,避免一次性执行可能导致的错误
// 1. 创建resources表不包含tmdb_id字段
// 1. 创建resources表不包含media_type字段该字段将通过迁移添加
_, err = db.Exec(`
CREATE TABLE IF NOT EXISTS resources (
id INTEGER NOT NULL,
@@ -136,7 +136,7 @@ func InitDB() (*sqlx.DB, error) {
return nil, fmt.Errorf("创建resources表失败: %w", err)
}
// 2. 创建基本索引
// 2. 创建基本索引不包含media_type的索引该索引将在迁移中创建
_, err = db.Exec(`
CREATE INDEX IF NOT EXISTS ix_resources_id ON resources (id);
CREATE INDEX IF NOT EXISTS ix_resources_title ON resources (title);
@@ -219,6 +219,7 @@ func MigrateDatabase() error {
}{
{"添加tmdb_id列", AddTmdbIDColumn},
{"添加stickers列", AddStickersColumn},
{"添加media_type列", AddMediaTypeColumn},
// 未来可以在这里添加更多迁移
}
@@ -646,6 +647,51 @@ func AddStickersColumn() error {
return nil
}
// AddMediaTypeColumn 添加media_type字段到resources表
func AddMediaTypeColumn() error {
log.Printf("检查resources表是否需要添加media_type字段...")
// 先检查resources表是否存在
var tableExists int
err := DB.Get(&tableExists, `SELECT count(*) FROM sqlite_master WHERE type='table' AND name='resources'`)
if err != nil {
return fmt.Errorf("检查resources表是否存在失败: %w", err)
}
if tableExists == 0 {
log.Printf("resources表不存在无需添加media_type字段")
return nil
}
// 检查media_type字段是否已存在
var count int
err = DB.Get(&count, `SELECT COUNT(*) FROM pragma_table_info('resources') WHERE name = 'media_type'`)
if err != nil {
return fmt.Errorf("检查media_type字段是否存在失败: %w", err)
}
// 如果字段不存在,则添加
if count == 0 {
log.Printf("media_type字段不存在正在添加...")
_, err = DB.Exec(`ALTER TABLE resources ADD COLUMN media_type VARCHAR`)
if err != nil {
return fmt.Errorf("添加media_type字段失败: %w", err)
}
// 创建索引
_, err = DB.Exec(`CREATE INDEX IF NOT EXISTS ix_resources_media_type ON resources (media_type)`)
if err != nil {
return fmt.Errorf("创建media_type索引失败: %w", err)
}
log.Printf("media_type字段添加成功")
} else {
log.Printf("media_type字段已存在无需添加")
}
return nil
}
// UpdateResourceWithStickers 更新资源并支持贴纸数据
func UpdateResourceWithStickers(resource *Resource) error {
// 更新时间戳
@@ -656,11 +702,11 @@ func UpdateResourceWithStickers(resource *Resource) error {
`UPDATE resources SET
title = ?, title_en = ?, description = ?, resource_type = ?,
images = ?, poster_image = ?, links = ?, updated_at = ?,
tmdb_id = ?, stickers = ?
tmdb_id = ?, media_type = ?, stickers = ?
WHERE id = ?`,
resource.Title, resource.TitleEn, resource.Description, resource.ResourceType,
resource.Images, resource.PosterImage, resource.Links, resource.UpdatedAt,
resource.TmdbID, resource.Stickers, resource.ID,
resource.TmdbID, resource.MediaType, resource.Stickers, resource.ID,
)
if err != nil {

View File

@@ -194,6 +194,7 @@ type Resource struct {
IsSupplementApproval bool `db:"is_supplement_approval" json:"is_supplement_approval"`
LikesCount int `db:"likes_count" json:"likes_count"`
TmdbID *int `db:"tmdb_id" json:"tmdb_id"`
MediaType *string `db:"media_type" json:"media_type"`
Stickers JsonMap `db:"stickers" json:"stickers"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
@@ -231,6 +232,7 @@ type ResourceUpdate struct {
PosterImage *string `json:"poster_image"`
Links JsonMap `json:"links"`
TmdbID *int `json:"tmdb_id"`
MediaType *string `json:"media_type"`
Stickers JsonMap `json:"stickers"`
}

View File

@@ -52,6 +52,7 @@ func SetTMDBAPIKey(apiKey string) {
// TMDB中的类型ID映射到我们系统中的类型名称
var GENRES = map[int]string{
// 原有映射
16: "幽默",
35: "讽刺",
10759: "冒险",
@@ -60,13 +61,31 @@ var GENRES = map[int]string{
80: "犯罪",
9648: "悬疑",
18: "浪漫",
// 添加电影常用分类
12: "冒险", // Adventure
28: "动作", // Action
878: "科幻", // Science Fiction
14: "奇幻", // Fantasy
36: "历史", // History
10751: "家庭", // Family
10749: "爱情", // Romance
53: "惊悚", // Thriller
10752: "战争", // War
37: "西部", // Western
99: "纪录片", // Documentary
10402: "音乐", // Music
10770: "电视电影", // TV Movie
10762: "儿童", // Kids
}
// TMDBSearchResult TMDB搜索结果
type TMDBSearchResult struct {
ID int `json:"id"`
Name string `json:"name"`
OriginalName string `json:"original_name"`
Name string `json:"name"` // 电视剧名称
Title string `json:"title"` // 电影标题
OriginalName string `json:"original_name"` // 电视剧原名
OriginalTitle string `json:"original_title"` // 电影原标题
Overview string `json:"overview"`
}
@@ -87,8 +106,10 @@ type TMDBGenre struct {
// TMDBDetails TMDB详情
type TMDBDetails struct {
ID int `json:"id"`
Name string `json:"name"`
OriginalName string `json:"original_name"`
Name string `json:"name"` // 电视剧名称
Title string `json:"title"` // 电影标题
OriginalName string `json:"original_name"` // 电视剧原名
OriginalTitle string `json:"original_title"` // 电影原标题
Overview string `json:"overview"`
Genres []TMDBGenre `json:"genres"`
}
@@ -109,13 +130,14 @@ type TMDBImageResponse struct {
// TMDBResource 最终整合的TMDB结果用于替代models.ResourceCreate
type TMDBResource struct {
ID int `json:"id"` // TMDB ID
Title string `json:"title"`
TitleEn string `json:"title_en"`
Description string `json:"description"`
ResourceType string `json:"resource_type"`
PosterImage string `json:"poster_image"`
Images []string `json:"images"`
Links map[string]interface{} `json:"links"`
Title string `json:"title"` // 标题电影Title或电视剧Name
TitleEn string `json:"title_en"` // 英文标题电影OriginalTitle或电视剧OriginalName
Description string `json:"description"` // 描述
ResourceType string `json:"resource_type"` // 资源类型,逗号分隔的类型名称
PosterImage string `json:"poster_image"` // 海报图片URL
Images []string `json:"images"` // 所有图片URL列表
Links map[string]interface{} `json:"links"` // 链接信息
MediaType string `json:"media_type"` // 媒体类型movie, tv
}
// SearchAnime 搜索动画返回动画ID
@@ -264,27 +286,213 @@ func GetImages(animeID int) (string, []string, error) {
return posterURL, backdropURLs, nil
}
// SearchMovie 搜索电影返回电影ID
func SearchMovie(query string, language string) (int, error) {
if language == "" {
language = "zh-CN"
}
// URL编码查询参数
encodedQuery := url.QueryEscape(query)
// 构建URL使用GetTMDBAPIKey()获取API密钥
requestURL := fmt.Sprintf("%s/search/movie?api_key=%s&query=%s&language=%s", BASE_URL, GetTMDBAPIKey(), encodedQuery, language)
// 创建HTTP客户端并设置超时
client := &http.Client{
Timeout: 3 * time.Second,
}
// 发送请求
resp, err := client.Get(requestURL)
if err != nil {
return 0, fmt.Errorf("搜索失败: %w", err)
}
defer resp.Body.Close()
// 检查响应状态码
if resp.StatusCode != http.StatusOK {
return 0, fmt.Errorf("API返回错误状态码: %d", resp.StatusCode)
}
// 解析响应
var searchResp TMDBSearchResponse
err = json.NewDecoder(resp.Body).Decode(&searchResp)
if err != nil {
return 0, fmt.Errorf("解析搜索结果失败: %w", err)
}
// 检查是否有结果
if len(searchResp.Results) == 0 {
return 0, errors.New("未找到匹配的电影")
}
// 返回第一个结果的ID
return searchResp.Results[0].ID, nil
}
// 获取电影详情
func GetMovieDetails(movieID int) (TMDBDetails, error) {
// 构建URL使用GetTMDBAPIKey()获取API密钥
requestURL := fmt.Sprintf("%s/movie/%d?api_key=%s&language=zh-CN", BASE_URL, movieID, GetTMDBAPIKey())
// 发送请求
resp, err := http.Get(requestURL)
if err != nil {
return TMDBDetails{}, fmt.Errorf("获取详情失败: %w", err)
}
defer resp.Body.Close()
// 检查响应状态码
if resp.StatusCode != http.StatusOK {
return TMDBDetails{}, fmt.Errorf("API返回错误状态码: %d", resp.StatusCode)
}
// 解析响应
var details TMDBDetails
err = json.NewDecoder(resp.Body).Decode(&details)
if err != nil {
return TMDBDetails{}, fmt.Errorf("解析详情失败: %w", err)
}
return details, nil
}
// 获取电影图片
func GetMovieImages(movieID int) (string, []string, error) {
// 构建URL使用GetTMDBAPIKey()获取API密钥
requestURL := fmt.Sprintf("%s/movie/%d/images?api_key=%s", BASE_URL, movieID, GetTMDBAPIKey())
// 发送请求
resp, err := http.Get(requestURL)
if err != nil {
return "", nil, fmt.Errorf("获取图片失败: %w", err)
}
defer resp.Body.Close()
// 检查响应状态码
if resp.StatusCode != http.StatusOK {
return "", nil, fmt.Errorf("API返回错误状态码: %d", resp.StatusCode)
}
// 解析响应
var imageResp TMDBImageResponse
err = json.NewDecoder(resp.Body).Decode(&imageResp)
if err != nil {
return "", nil, fmt.Errorf("解析图片数据失败: %w", err)
}
// 处理海报图片
var posterURL string
if len(imageResp.Posters) > 0 {
// 按投票数排序
sortedPosters := imageResp.Posters
// 简单的冒泡排序
for i := 0; i < len(sortedPosters)-1; i++ {
for j := 0; j < len(sortedPosters)-i-1; j++ {
if sortedPosters[j].VoteCount < sortedPosters[j+1].VoteCount {
sortedPosters[j], sortedPosters[j+1] = sortedPosters[j+1], sortedPosters[j]
}
}
}
posterURL = fmt.Sprintf("%s/%s%s", IMAGE_BASE_URL, POSTER_W, sortedPosters[0].FilePath)
}
// 处理背景图片
var backdropURLs []string
if len(imageResp.Backdrops) > 0 {
// 按投票数排序
sortedBackdrops := imageResp.Backdrops
// 简单的冒泡排序
for i := 0; i < len(sortedBackdrops)-1; i++ {
for j := 0; j < len(sortedBackdrops)-i-1; j++ {
if sortedBackdrops[j].VoteCount < sortedBackdrops[j+1].VoteCount {
sortedBackdrops[j], sortedBackdrops[j+1] = sortedBackdrops[j+1], sortedBackdrops[j]
}
}
}
// 取前10张背景图片
count := len(sortedBackdrops)
if count > 10 {
count = 10
}
backdropURLs = make([]string, count+1) // +1 是为了加入海报图片
backdropURLs[0] = posterURL // 将海报图片加入到第一位
for i := 0; i < count; i++ {
backdropURLs[i+1] = fmt.Sprintf("%s/%s%s", IMAGE_BASE_URL, BACKDROP_W, sortedBackdrops[i].FilePath)
}
} else {
// 如果没有背景图片,至少返回海报图片
backdropURLs = []string{posterURL}
}
return posterURL, backdropURLs, nil
}
// SearchTMDB 搜索TMDB并返回适合资源表结构的结果
func SearchTMDB(query string) (*TMDBResource, error) {
// 1. 根据查询搜索动画ID
animeID, err := SearchAnime(query, "zh-CN")
if err != nil {
return nil, fmt.Errorf("TMDB搜索失败: %w", err)
// 尝试作为电影搜索
movieID, movieErr := SearchMovie(query, "zh-CN")
// 如果电影搜索失败,尝试作为电视剧搜索
if movieErr != nil {
animeID, err := SearchAnime(query, "zh-CN")
if err != nil {
return nil, fmt.Errorf("TMDB搜索失败: %w", err)
}
// 获取电视剧详情
details, err := GetAnimeDetails(animeID)
if err != nil {
return nil, fmt.Errorf("获取TMDB详情失败: %w", err)
}
// 获取海报和背景图片
posterURL, imageURLs, err := GetImages(animeID)
if err != nil {
return nil, fmt.Errorf("获取TMDB图片失败: %w", err)
}
// 处理类型
var genres []string
for _, genre := range details.Genres {
if genreName, ok := GENRES[genre.ID]; ok {
genres = append(genres, genreName)
}
}
// 构建适合资源表的结构
resource := &TMDBResource{
ID: animeID,
Title: details.Name,
TitleEn: details.OriginalName,
Description: details.Overview,
ResourceType: strings.Join(genres, ","),
PosterImage: posterURL,
Images: imageURLs,
Links: map[string]interface{}{},
MediaType: "tv", // 电视剧类型
}
return resource, nil
}
// 2. 获取动画详情
details, err := GetAnimeDetails(animeID)
// 电影搜索成功,获取电影详情
details, err := GetMovieDetails(movieID)
if err != nil {
return nil, fmt.Errorf("获取TMDB详情失败: %w", err)
return nil, fmt.Errorf("获取电影详情失败: %w", err)
}
// 3. 获取海报和背景图片
posterURL, imageURLs, err := GetImages(animeID)
// 获取海报和背景图片
posterURL, imageURLs, err := GetMovieImages(movieID)
if err != nil {
return nil, fmt.Errorf("获取TMDB图片失败: %w", err)
return nil, fmt.Errorf("获取电影图片失败: %w", err)
}
// 4. 处理类型
// 处理类型
var genres []string
for _, genre := range details.Genres {
if genreName, ok := GENRES[genre.ID]; ok {
@@ -292,16 +500,17 @@ func SearchTMDB(query string) (*TMDBResource, error) {
}
}
// 5. 构建适合资源表的结构
// 构建适合资源表的结构
resource := &TMDBResource{
ID: animeID,
Title: details.Name,
TitleEn: details.OriginalName,
ID: movieID,
Title: details.Title, // 使用电影标题
TitleEn: details.OriginalTitle, // 使用电影原标题
Description: details.Overview,
ResourceType: strings.Join(genres, ","),
PosterImage: posterURL,
Images: imageURLs,
Links: map[string]interface{}{},
MediaType: "movie", // 电影类型
}
return resource, nil

View File

@@ -0,0 +1,286 @@
package utils
import (
"encoding/json"
"fmt"
"log"
"net/http"
"net/url"
"strings"
"time"
)
// TMDBMultiSearchResult 表示Multi Search API的单个结果项
type TMDBMultiSearchResult struct {
Adult bool `json:"adult"`
BackdropPath string `json:"backdrop_path"`
ID int `json:"id"`
Title string `json:"title,omitempty"` // 电影标题
OriginalTitle string `json:"original_title,omitempty"` // 电影原始标题
Name string `json:"name,omitempty"` // 电视剧标题
OriginalName string `json:"original_name,omitempty"` // 电视剧原始标题
Overview string `json:"overview"`
PosterPath string `json:"poster_path"`
MediaType string `json:"media_type"` // movie 或 tv
OriginalLanguage string `json:"original_language"`
GenreIDs []int `json:"genre_ids"`
Popularity float64 `json:"popularity"`
ReleaseDate string `json:"release_date,omitempty"` // 电影发行日期
FirstAirDate string `json:"first_air_date,omitempty"` // 电视剧首播日期
Video bool `json:"video,omitempty"` // 仅电影有此字段
VoteAverage float64 `json:"vote_average"`
VoteCount int `json:"vote_count"`
OriginCountry []string `json:"origin_country,omitempty"` // 仅电视剧有此字段
}
// TMDBMultiSearchResponse 表示Multi Search API的响应
type TMDBMultiSearchResponse struct {
Page int `json:"page"`
Results []TMDBMultiSearchResult `json:"results"`
TotalPages int `json:"total_pages"`
TotalResults int `json:"total_results"`
}
// MultiSearch 执行多类型搜索调用TMDB API获取结果
// 参数:
// - query: 搜索关键词
// - page: 页码默认为1
// 返回:
// - *TMDBMultiSearchResponse: 搜索结果
// - error: 错误信息
func MultiSearch(query string, page ...int) (*TMDBMultiSearchResponse, error) {
// URL编码查询参数
encodedQuery := url.QueryEscape(query)
// 处理页码参数
pageNum := 1
if len(page) > 0 && page[0] > 0 {
pageNum = page[0]
}
// 构建URL使用GetTMDBAPIKey()获取API密钥
requestURL := fmt.Sprintf("%s/search/multi?api_key=%s&query=%s&language=zh-CN&page=%d",
BASE_URL, GetTMDBAPIKey(), encodedQuery, pageNum)
// 创建HTTP客户端并设置超时
client := &http.Client{
Timeout: 5 * time.Second,
}
// 发送请求
log.Printf("发送TMDB Multi Search请求: %s", requestURL)
resp, err := client.Get(requestURL)
if err != nil {
return nil, fmt.Errorf("TMDB API请求失败: %w", err)
}
defer resp.Body.Close()
// 检查响应状态码
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("TMDB API返回错误状态码: %d", resp.StatusCode)
}
// 解析响应
var searchResp TMDBMultiSearchResponse
err = json.NewDecoder(resp.Body).Decode(&searchResp)
if err != nil {
return nil, fmt.Errorf("解析TMDB API响应失败: %w", err)
}
// 过滤结果,只保留电影和电视剧
var filteredResults []TMDBMultiSearchResult
for _, result := range searchResp.Results {
if result.MediaType == "movie" || result.MediaType == "tv" {
filteredResults = append(filteredResults, result)
}
}
searchResp.Results = filteredResults
log.Printf("TMDB Multi Search成功找到 %d 个结果,当前页 %d总页数 %d",
len(searchResp.Results), searchResp.Page, searchResp.TotalPages)
return &searchResp, nil
}
// TMDBMediaDetails 表示媒体详情响应
type TMDBMediaDetails struct {
ID int `json:"id"`
Images TMDBMediaImages `json:"images"`
Genres []TMDBGenre `json:"genres"`
// 其他字段根据需要添加
}
// TMDBMediaImages 表示媒体图片集合
type TMDBMediaImages struct {
Backdrops []TMDBImage `json:"backdrops"`
Posters []TMDBImage `json:"posters"`
}
// GetMediaDetails 获取媒体详情
// 参数:
// - mediaType: 媒体类型 (movie 或 tv)
// - mediaID: 媒体ID
// 返回:
// - map[string]interface{}: 详情数据
// - error: 错误信息
func GetMediaDetails(mediaType string, mediaID int) (map[string]interface{}, error) {
// 构建URL使用正确的API接口路径
requestURL := fmt.Sprintf("%s/%s/%d?api_key=%s&append_to_response=images",
BASE_URL, mediaType, mediaID, GetTMDBAPIKey())
// 创建HTTP客户端并设置超时
client := &http.Client{
Timeout: 10 * time.Second,
}
// 发送请求
log.Printf("发送TMDB %s详情请求: %s", mediaType, requestURL)
resp, err := client.Get(requestURL)
if err != nil {
return nil, fmt.Errorf("TMDB API请求失败: %w", err)
}
defer resp.Body.Close()
// 检查响应状态码
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("TMDB API返回错误状态码: %d", resp.StatusCode)
}
// 解析响应
var details TMDBMediaDetails
err = json.NewDecoder(resp.Body).Decode(&details)
if err != nil {
return nil, fmt.Errorf("解析TMDB API响应失败: %w", err)
}
// 处理图片数据
var imageURLs []string
if len(details.Images.Backdrops) > 0 {
// 按照投票数排序
for i := 0; i < len(details.Images.Backdrops); i++ {
for j := i + 1; j < len(details.Images.Backdrops); j++ {
if details.Images.Backdrops[i].VoteCount < details.Images.Backdrops[j].VoteCount {
details.Images.Backdrops[i], details.Images.Backdrops[j] = details.Images.Backdrops[j], details.Images.Backdrops[i]
}
}
}
// 最多取10张图片
count := min(10, len(details.Images.Backdrops))
for i := 0; i < count; i++ {
imageURL := fmt.Sprintf("https://image.tmdb.org/t/p/w1280%s", details.Images.Backdrops[i].FilePath)
imageURLs = append(imageURLs, imageURL)
}
}
// 获取海报URL
posterURL := ""
if len(details.Images.Posters) > 0 {
posterURL = fmt.Sprintf("https://image.tmdb.org/t/p/w500%s", details.Images.Posters[0].FilePath)
}
// 处理类型,转换为中文分类
var genres []string
// 创建中文化的genres数组替换原有的英文genres
var chineseGenres []map[string]interface{}
for _, genre := range details.Genres {
// 对于resource_type字段使用中文名称拼接
if genreName, ok := GENRES[genre.ID]; ok {
genres = append(genres, genreName)
// 创建一个新的genre对象但使用中文名称
chineseGenre := map[string]interface{}{
"id": genre.ID,
"name": genreName,
}
chineseGenres = append(chineseGenres, chineseGenre)
} else {
// 如果在映射表中找不到对应的中文名称,保留原始英文名称
chineseGenres = append(chineseGenres, map[string]interface{}{
"id": genre.ID,
"name": genre.Name,
})
}
}
// 构建返回结果
result := map[string]interface{}{
"id": details.ID,
"genres": chineseGenres, // 使用中文化的genres数组
"images": imageURLs,
"poster_path": posterURL,
"media_type": mediaType,
"resource_type": strings.Join(genres, ","), // 添加中文分类字段
}
// 根据媒体类型添加不同的字段
if mediaType == "movie" {
// 再次发起一个multi_search请求获取中文标题和简介
searchResp, err := MultiSearch(fmt.Sprintf("id:%d", mediaID), 1)
if err == nil && len(searchResp.Results) > 0 {
for _, item := range searchResp.Results {
if item.MediaType == "movie" && item.ID == mediaID {
result["title"] = item.Title
result["original_title"] = item.OriginalTitle
result["overview"] = item.Overview
break
}
}
} else {
// 如果无法通过multi_search获取直接从详情API解析
var movieDetails struct {
Title string `json:"title"`
OriginalTitle string `json:"original_title"`
Overview string `json:"overview"`
}
resp, err := client.Get(requestURL)
if err == nil {
defer resp.Body.Close()
json.NewDecoder(resp.Body).Decode(&movieDetails)
result["title"] = movieDetails.Title
result["original_title"] = movieDetails.OriginalTitle
result["overview"] = movieDetails.Overview
}
}
} else if mediaType == "tv" {
// 再次发起一个multi_search请求获取中文标题和简介
searchResp, err := MultiSearch(fmt.Sprintf("id:%d", mediaID), 1)
if err == nil && len(searchResp.Results) > 0 {
for _, item := range searchResp.Results {
if item.MediaType == "tv" && item.ID == mediaID {
result["name"] = item.Name
result["original_name"] = item.OriginalName
result["overview"] = item.Overview
break
}
}
} else {
// 如果无法通过multi_search获取直接从详情API解析
var tvDetails struct {
Name string `json:"name"`
OriginalName string `json:"original_name"`
Overview string `json:"overview"`
Seasons []interface{} `json:"seasons"`
}
resp, err := client.Get(requestURL)
if err == nil {
defer resp.Body.Close()
json.NewDecoder(resp.Body).Decode(&tvDetails)
result["name"] = tvDetails.Name
result["original_name"] = tvDetails.OriginalName
result["overview"] = tvDetails.Overview
result["seasons"] = tvDetails.Seasons
}
}
}
return result, nil
}
// min 返回两个整数中的较小值
func min(a, b int) int {
if a < b {
return a
}
return b
}