Merge pull request #16 from ctwj/feat_expansion

update: expansion
This commit is contained in:
ctwj
2025-09-27 16:15:39 +08:00
committed by GitHub
6 changed files with 354 additions and 67 deletions

View File

@@ -1,6 +1,8 @@
package repo package repo
import ( import (
"time"
"github.com/ctwj/urldb/db/entity" "github.com/ctwj/urldb/db/entity"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -15,6 +17,8 @@ type TaskRepository interface {
UpdateProgress(id uint, progress float64, progressData string) error UpdateProgress(id uint, progress float64, progressData string) error
UpdateStatusAndMessage(id uint, status, message string) error UpdateStatusAndMessage(id uint, status, message string) error
UpdateTaskStats(id uint, processed, success, failed int) error UpdateTaskStats(id uint, processed, success, failed int) error
UpdateStartedAt(id uint) error
UpdateCompletedAt(id uint) error
} }
// TaskRepositoryImpl 任务仓库实现 // TaskRepositoryImpl 任务仓库实现
@@ -134,3 +138,15 @@ func (r *TaskRepositoryImpl) UpdateTaskStats(id uint, processed, success, failed
"failed_items": failed, "failed_items": failed,
}).Error }).Error
} }
// UpdateStartedAt 更新任务开始时间
func (r *TaskRepositoryImpl) UpdateStartedAt(id uint) error {
now := time.Now()
return r.db.Model(&entity.Task{}).Where("id = ?", id).Update("started_at", now).Error
}
// UpdateCompletedAt 更新任务完成时间
func (r *TaskRepositoryImpl) UpdateCompletedAt(id uint) error {
now := time.Now()
return r.db.Model(&entity.Task{}).Where("id = ?", id).Update("completed_at", now).Error
}

View File

@@ -545,3 +545,75 @@ func (h *TaskHandler) GetExpansionAccounts(c *gin.Context) {
"message": "获取支持扩容账号列表成功", "message": "获取支持扩容账号列表成功",
}) })
} }
// GetExpansionOutput 获取账号扩容输出数据
func (h *TaskHandler) GetExpansionOutput(c *gin.Context) {
accountIDStr := c.Param("accountId")
accountID, err := strconv.ParseUint(accountIDStr, 10, 32)
if err != nil {
ErrorResponse(c, "无效的账号ID: "+err.Error(), http.StatusBadRequest)
return
}
utils.Debug("获取账号扩容输出数据: 账号ID %d", accountID)
// 获取该账号的所有扩容任务
tasks, _, err := h.repoMgr.TaskRepository.GetList(1, 1000, "expansion", "completed")
if err != nil {
utils.Error("获取扩容任务列表失败: %v", err)
ErrorResponse(c, "获取扩容任务列表失败: "+err.Error(), http.StatusInternalServerError)
return
}
// 查找该账号的扩容任务
var targetTask *entity.Task
for _, task := range tasks {
if task.Config != "" {
var taskConfig map[string]interface{}
if err := json.Unmarshal([]byte(task.Config), &taskConfig); err == nil {
if configAccountID, ok := taskConfig["pan_account_id"].(float64); ok {
if uint(configAccountID) == uint(accountID) {
targetTask = task
break
}
}
}
}
}
if targetTask == nil {
ErrorResponse(c, "该账号没有完成扩容任务", http.StatusNotFound)
return
}
// 获取任务项,获取输出数据
items, _, err := h.repoMgr.TaskItemRepository.GetListByTaskID(targetTask.ID, 1, 10, "completed")
if err != nil {
utils.Error("获取任务项失败: %v", err)
ErrorResponse(c, "获取任务输出数据失败: "+err.Error(), http.StatusInternalServerError)
return
}
if len(items) == 0 {
ErrorResponse(c, "任务项不存在", http.StatusNotFound)
return
}
// 返回第一个完成的任务项的输出数据
taskItem := items[0]
var outputData map[string]interface{}
if taskItem.OutputData != "" {
if err := json.Unmarshal([]byte(taskItem.OutputData), &outputData); err != nil {
utils.Error("解析输出数据失败: %v", err)
ErrorResponse(c, "解析输出数据失败: "+err.Error(), http.StatusInternalServerError)
return
}
}
SuccessResponse(c, gin.H{
"task_id": targetTask.ID,
"account_id": accountID,
"output_data": outputData,
"message": "获取扩容输出数据成功",
})
}

View File

@@ -5,7 +5,6 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"math/rand" "math/rand"
"net/http"
"time" "time"
pan "github.com/ctwj/urldb/common" pan "github.com/ctwj/urldb/common"
@@ -295,7 +294,7 @@ func (ep *ExpansionProcessor) performExpansion(ctx context.Context, panAccountID
} }
// 执行转存 // 执行转存
saveURL, err := ep.transferResource(ctx, service, resource) saveURL, err := ep.transferResource(ctx, service, resource, *account)
if err != nil { if err != nil {
utils.Error("转存资源失败: %s, 错误: %v", resource.Title, err) utils.Error("转存资源失败: %s, 错误: %v", resource.Title, err)
totalFailed++ totalFailed++
@@ -347,12 +346,14 @@ func (ep *ExpansionProcessor) getResourcesByHot(
// getResourcesFromInternalDB 根据 HotDrama 的title 获取数据库中资源,并且资源的类型和 account 的资源类型一致 // getResourcesFromInternalDB 根据 HotDrama 的title 获取数据库中资源,并且资源的类型和 account 的资源类型一致
func (ep *ExpansionProcessor) getResourcesFromInternalDB(HotDrama *entity.HotDrama, account entity.Cks, service pan.PanService) (*entity.Resource, error) { func (ep *ExpansionProcessor) getResourcesFromInternalDB(HotDrama *entity.HotDrama, account entity.Cks, service pan.PanService) (*entity.Resource, error) {
// 获取账号对应的平台ID // 修改配置 isType = 1 只检测,不转存
panIDInt, err := ep.repoMgr.PanRepository.FindIdByServiceType(account.ServiceType) service.UpdateConfig(&pan.PanConfig{
if err != nil { URL: "",
return nil, fmt.Errorf("获取平台ID失败: %v", err) ExpiredType: 0,
} IsType: 1,
panID := uint(panIDInt) Cookie: account.Ck,
})
panID := account.PanID
// 1. 搜索标题 // 1. 搜索标题
params := map[string]interface{}{ params := map[string]interface{}{
@@ -413,29 +414,9 @@ func (ep *ExpansionProcessor) getHotResources(category string) ([]*entity.HotDra
// getResourcesFromThirdPartyAPI 从第三方API获取资源 // getResourcesFromThirdPartyAPI 从第三方API获取资源
func (ep *ExpansionProcessor) getResourcesFromThirdPartyAPI(resource *entity.HotDrama, apiURL string) (*entity.Resource, error) { func (ep *ExpansionProcessor) getResourcesFromThirdPartyAPI(resource *entity.HotDrama, apiURL string) (*entity.Resource, error) {
// 构建API请求URL添加分类参数 // 构建API请求URL添加分类参数
requestURL := fmt.Sprintf("%s?category=%s&limit=20", apiURL, resource) // requestURL := fmt.Sprintf("%s?category=%s&limit=20", apiURL, resource)
// 发送HTTP请求 // TODO 使用第三方API接口请求资源
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Get(requestURL)
if err != nil {
return nil, fmt.Errorf("请求第三方API失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("第三方API返回错误状态码: %d", resp.StatusCode)
}
// 解析响应数据假设API返回JSON格式的资源列表
var apiResponse struct {
Data []*entity.HotDrama `json:"data"`
}
err = json.NewDecoder(resp.Body).Decode(&apiResponse)
if err != nil {
return nil, fmt.Errorf("解析第三方API响应失败: %v", err)
}
return nil, nil return nil, nil
} }
@@ -463,7 +444,15 @@ func (ep *ExpansionProcessor) checkStorageSpace(service pan.PanService, ck *stri
} }
// transferResource 执行单个资源的转存 // transferResource 执行单个资源的转存
func (ep *ExpansionProcessor) transferResource(ctx context.Context, service pan.PanService, res *entity.Resource) (string, error) { func (ep *ExpansionProcessor) transferResource(ctx context.Context, service pan.PanService, res *entity.Resource, account entity.Cks) (string, error) {
// 修改配置 isType = 0 转存
service.UpdateConfig(&pan.PanConfig{
URL: "",
ExpiredType: 0,
IsType: 0,
Cookie: account.Ck,
})
// 如果没有URL跳过转存 // 如果没有URL跳过转存
if res.URL == "" { if res.URL == "" {
return "", fmt.Errorf("资源 %s 没有有效的URL", res.URL) return "", fmt.Errorf("资源 %s 没有有效的URL", res.URL)
@@ -510,35 +499,35 @@ func (ep *ExpansionProcessor) transferResource(ctx context.Context, service pan.
} }
// recordTransferredResource 记录转存成功的资源 // recordTransferredResource 记录转存成功的资源
func (ep *ExpansionProcessor) recordTransferredResource(drama *entity.HotDrama, accountID uint, saveURL string) error { // func (ep *ExpansionProcessor) recordTransferredResource(drama *entity.HotDrama, accountID uint, saveURL string) error {
// 获取夸克网盘的平台ID // // 获取夸克网盘的平台ID
panIDInt, err := ep.repoMgr.PanRepository.FindIdByServiceType("quark") // panIDInt, err := ep.repoMgr.PanRepository.FindIdByServiceType("quark")
if err != nil { // if err != nil {
utils.Error("获取夸克网盘平台ID失败: %v", err) // utils.Error("获取夸克网盘平台ID失败: %v", err)
return err // return err
} // }
// 转换为uint // // 转换为uint
panID := uint(panIDInt) // panID := uint(panIDInt)
// 创建资源记录 // // 创建资源记录
resource := &entity.Resource{ // resource := &entity.Resource{
Title: drama.Title, // Title: drama.Title,
URL: drama.PosterURL, // URL: drama.PosterURL,
SaveURL: saveURL, // SaveURL: saveURL,
PanID: &panID, // PanID: &panID,
CreatedAt: time.Now(), // CreatedAt: time.Now(),
UpdatedAt: time.Now(), // UpdatedAt: time.Now(),
IsValid: true, // IsValid: true,
IsPublic: false, // 扩容资源默认不公开 // IsPublic: false, // 扩容资源默认不公开
} // }
// 保存到数据库 // // 保存到数据库
err = ep.repoMgr.ResourceRepository.Create(resource) // err = ep.repoMgr.ResourceRepository.Create(resource)
if err != nil { // if err != nil {
return fmt.Errorf("保存资源记录失败: %v", err) // return fmt.Errorf("保存资源记录失败: %v", err)
} // }
utils.Info("成功记录转存资源: %s (ID: %d)", drama.Title, resource.ID) // utils.Info("成功记录转存资源: %s (ID: %d)", drama.Title, resource.ID)
return nil // return nil
} // }

View File

@@ -201,6 +201,12 @@ func (tm *TaskManager) processTask(ctx context.Context, task *entity.Task, proce
return return
} }
// 更新任务开始时间
err = tm.repoMgr.TaskRepository.UpdateStartedAt(task.ID)
if err != nil {
utils.Error("更新任务开始时间失败: %v", err)
}
// 获取任务项统计信息,用于计算正确的进度 // 获取任务项统计信息,用于计算正确的进度
stats, err := tm.repoMgr.TaskItemRepository.GetStatsByTaskID(task.ID) stats, err := tm.repoMgr.TaskItemRepository.GetStatsByTaskID(task.ID)
if err != nil { if err != nil {
@@ -294,6 +300,14 @@ func (tm *TaskManager) processTask(ctx context.Context, task *entity.Task, proce
utils.Error("更新任务状态失败: %v", err) utils.Error("更新任务状态失败: %v", err)
} }
// 如果任务完成,更新完成时间
if status == "completed" || status == "failed" || status == "partial_success" {
err = tm.repoMgr.TaskRepository.UpdateCompletedAt(task.ID)
if err != nil {
utils.Error("更新任务完成时间失败: %v", err)
}
}
utils.Info("任务 %d 处理完成: %s", task.ID, message) utils.Info("任务 %d 处理完成: %s", task.ID, message)
} }
@@ -324,13 +338,21 @@ func (tm *TaskManager) processTaskItem(ctx context.Context, taskID uint, item *e
} }
// 处理成功 // 处理成功
outputData := map[string]interface{}{ // 如果处理器已经设置了 output_data(比如 ExpansionProcessor则不覆盖
"success": true, var outputJSON string
"time": utils.GetCurrentTime(), if item.OutputData == "" {
outputData := map[string]interface{}{
"success": true,
"time": utils.GetCurrentTime(),
}
outputBytes, _ := json.Marshal(outputData)
outputJSON = string(outputBytes)
} else {
// 使用处理器设置的 output_data
outputJSON = item.OutputData
} }
outputJSON, _ := json.Marshal(outputData)
err = tm.repoMgr.TaskItemRepository.UpdateStatusAndOutput(item.ID, "completed", string(outputJSON)) err = tm.repoMgr.TaskItemRepository.UpdateStatusAndOutput(item.ID, "completed", outputJSON)
if err != nil { if err != nil {
utils.Error("更新成功任务项状态失败: %v", err) utils.Error("更新成功任务项状态失败: %v", err)
} }
@@ -369,6 +391,12 @@ func (tm *TaskManager) markTaskFailed(taskID uint, message string) {
if err != nil { if err != nil {
utils.Error("标记任务失败状态失败: %v", err) utils.Error("标记任务失败状态失败: %v", err)
} }
// 更新任务完成时间
err = tm.repoMgr.TaskRepository.UpdateCompletedAt(taskID)
if err != nil {
utils.Error("更新任务完成时间失败: %v", err)
}
} }
// GetTaskStatus 获取任务状态 // GetTaskStatus 获取任务状态

View File

@@ -253,7 +253,8 @@ export const useTaskApi = () => {
const pauseTask = (id: number) => useApiFetch(`/tasks/${id}/pause`, { method: 'POST' }).then(parseApiResponse) const pauseTask = (id: number) => useApiFetch(`/tasks/${id}/pause`, { method: 'POST' }).then(parseApiResponse)
const deleteTask = (id: number) => useApiFetch(`/tasks/${id}`, { method: 'DELETE' }).then(parseApiResponse) const deleteTask = (id: number) => useApiFetch(`/tasks/${id}`, { method: 'DELETE' }).then(parseApiResponse)
const getTaskItems = (id: number, params?: any) => useApiFetch(`/tasks/${id}/items`, { params }).then(parseApiResponse) const getTaskItems = (id: number, params?: any) => useApiFetch(`/tasks/${id}/items`, { params }).then(parseApiResponse)
return { createBatchTransferTask, createExpansionTask, getExpansionAccounts, getTasks, getTaskStatus, startTask, stopTask, pauseTask, deleteTask, getTaskItems } const getExpansionOutput = (accountId: number) => useApiFetch(`/tasks/expansion/accounts/${accountId}/output`).then(parseApiResponse)
return { createBatchTransferTask, createExpansionTask, getExpansionAccounts, getTasks, getTaskStatus, startTask, stopTask, pauseTask, deleteTask, getTaskItems, getExpansionOutput }
} }
// 日志函数:只在开发环境打印 // 日志函数:只在开发环境打印

View File

@@ -113,6 +113,19 @@
</template> </template>
{{ item.expanded ? '已扩容' : '扩容' }} {{ item.expanded ? '已扩容' : '扩容' }}
</n-button> </n-button>
<!-- 提取按钮仅对已扩容账号显示 -->
<n-button
v-if="item.expanded"
size="small"
type="success"
@click="handleExtract(item)"
>
<template #icon>
<i class="fas fa-download"></i>
</template>
提取
</n-button>
</div> </div>
</div> </div>
</div> </div>
@@ -218,6 +231,62 @@
</div> </div>
</n-card> </n-card>
</n-modal> </n-modal>
<!-- 提取内容弹窗 -->
<n-modal v-model:show="showExtractDialog" title="提取数据" size="large" style="width: 800px; max-width: 95vw;">
<n-card title="提取扩容数据" size="small">
<div class="space-y-4">
<!-- 加载状态 -->
<div v-if="loadingExtractData" class="flex items-center justify-center py-12">
<n-spin size="large">
<template #description>
<span class="text-gray-500">获取数据中...</span>
</template>
</n-spin>
</div>
<!-- 数据加载完成后的内容 -->
<div v-else class="flex flex-col gap-2">
<!-- 显示模式选择 -->
<div>
<n-radio-group v-model:value="selectedDisplayMode">
<n-space>
<n-radio value="links-only">
<span class="font-medium">仅链接</span>
<!-- <div class="text-sm text-gray-500 mt-1">只显示链接一行一个</div> -->
</n-radio>
<n-radio value="title-link">
<span class="font-medium">标题|链接</span>
<!-- <div class="text-sm text-gray-500 mt-1">显示标题连接用|连接一个一行</div> -->
</n-radio>
<n-radio value="title-newline-link">
<span class="font-medium">标题/n链接</span>
<!-- <div class="text-sm text-gray-500 mt-1">标题和链接两行一个</div> -->
</n-radio>
</n-space>
</n-radio-group>
</div>
<!-- 文本显示区域 -->
<div>
<n-input
v-model:value="extractedText"
type="textarea"
:rows="15"
readonly
placeholder="选择显示模式后,内容将显示在此处"
/>
</div>
<!-- 操作按钮 -->
<div class="flex justify-end space-x-2">
<n-button @click="copyToClipboard">复制</n-button>
<n-button type="primary" @click="showExtractDialog = false">关闭</n-button>
</div>
</div>
</div>
</n-card>
</n-modal>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -226,7 +295,7 @@ definePageMeta({
middleware: ['auth'] middleware: ['auth']
}) })
import { ref, onMounted, computed, h } from 'vue' import { ref, onMounted, computed, h, watch } from 'vue'
import { useTaskApi } from '~/composables/useApi' import { useTaskApi } from '~/composables/useApi'
import { useNotification, useDialog } from 'naive-ui' import { useNotification, useDialog } from 'naive-ui'
@@ -241,6 +310,12 @@ const showDataSourceDialog = ref(false) // 数据源选择弹窗
const selectedDataSource = ref('internal') // internal or third-party const selectedDataSource = ref('internal') // internal or third-party
const thirdPartyUrl = ref('https://so.252035.xyz/') const thirdPartyUrl = ref('https://so.252035.xyz/')
const pendingAccount = ref<any>(null) // 待处理的账号 const pendingAccount = ref<any>(null) // 待处理的账号
const showExtractDialog = ref(false) // 提取内容弹窗
const selectedDisplayMode = ref('links-only') // 显示模式: links-only, title-link, title-newline-link
const extractedText = ref('') // 提取的文本内容
const currentExtractAccount = ref<any>(null) // 当前提取的账号
const loadingExtractData = ref(false) // 提取数据加载状态
const extractedResources = ref([]) // 保存获取到的资源数据
// API实例 // API实例
const taskApi = useTaskApi() const taskApi = useTaskApi()
@@ -251,7 +326,7 @@ const notification = useNotification()
const fetchExpansionAccounts = async () => { const fetchExpansionAccounts = async () => {
loading.value = true loading.value = true
try { try {
const response = await taskApi.getExpansionAccounts() const response = await taskApi.getExpansionAccounts() as any
expansionAccounts.value = response.accounts || [] expansionAccounts.value = response.accounts || []
} catch (error) { } catch (error) {
console.error('获取扩容账号列表失败:', error) console.error('获取扩容账号列表失败:', error)
@@ -355,6 +430,112 @@ const formatCapacity = (used, total) => {
return `${formatBytes(used || 0)} / ${formatBytes(total)}` return `${formatBytes(used || 0)} / ${formatBytes(total)}`
} }
// 处理提取操作
const handleExtract = async (account) => {
currentExtractAccount.value = account
showExtractDialog.value = true
loadingExtractData.value = true
extractedText.value = ''
try {
// 获取账号的扩容任务输出数据
const response = await taskApi.getExpansionOutput(account.id)
const resources = response.output_data?.transferred_resources || []
// 保存获取到的数据
extractedResources.value = resources
// 根据当前选择的模式格式化文本
formatExtractedText(resources, selectedDisplayMode.value)
} catch (error) {
console.error('获取提取数据失败:', error)
notification.error({
title: '失败',
content: '获取提取数据失败: ' + (error.data?.message || '未知错误'),
duration: 3000
})
// 如果获取失败,使用模拟数据
const mockData = [
{ title: "示例电影1", url: "https://example.com/1" },
{ title: "示例电影2", url: "https://example.com/2" },
{ title: "示例电影3", url: "https://example.com/3" }
]
extractedResources.value = mockData
formatExtractedText(mockData, selectedDisplayMode.value)
} finally {
loadingExtractData.value = false
}
}
// 过滤标题文本,移除换行、| 和不可见字符
const cleanTitle = (title) => {
if (!title) return ''
return title
.replace(/[\r\n\t]/g, ' ') // 移除换行符和制表符,替换为空格
.replace(/[|]/g, ' ') // 移除|符号,替换为空格
.replace(/[\x00-\x1F\x7F-\x9F]/g, '') // 移除不可见字符
.replace(/\s+/g, ' ') // 多个空格合并为一个
.trim() // 移除首尾空格
}
// 格式化提取的文本
const formatExtractedText = (resources, mode) => {
if (!resources || resources.length === 0) {
extractedText.value = '暂无数据'
return
}
let text = ''
switch (mode) {
case 'links-only':
// 仅链接,一行一个
text = resources.map(item => item.url).join('\n')
break
case 'title-link':
// 标题|链接,一个一行
text = resources.map(item => `${cleanTitle(item.title)}|${item.url}`).join('\n')
break
case 'title-newline-link':
// 标题/n链接两行一个
text = resources.map(item => `${cleanTitle(item.title)}\n${item.url}`).join('\n')
break
default:
text = '请选择显示模式'
}
extractedText.value = text
}
// 复制到剪贴板
const copyToClipboard = async () => {
try {
await navigator.clipboard.writeText(extractedText.value)
notification.success({
title: '成功',
content: '内容已复制到剪贴板',
duration: 3000
})
} catch (error) {
console.error('复制失败:', error)
notification.error({
title: '失败',
content: '复制失败,请手动选择文本复制',
duration: 3000
})
}
}
// 监听显示模式变化
watch(selectedDisplayMode, (newMode) => {
if (extractedResources.value && extractedResources.value.length > 0) {
// 使用已保存的数据重新格式化文本
formatExtractedText(extractedResources.value, newMode)
}
})
// 页面加载 // 页面加载
onMounted(async () => { onMounted(async () => {
await fetchExpansionAccounts() await fetchExpansionAccounts()