mirror of
https://github.com/ctwj/urldb.git
synced 2025-11-25 11:29:37 +08:00
update: 添加logo的配置
This commit is contained in:
@@ -129,6 +129,8 @@ func (f *PanFactory) CreatePanService(url string, config *PanConfig) (PanService
|
||||
return NewBaiduPanService(config), nil
|
||||
case UC:
|
||||
return NewUCService(config), nil
|
||||
case Xunlei:
|
||||
return NewXunleiPanService(config), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("不支持的服务类型: %s", url)
|
||||
}
|
||||
@@ -145,8 +147,8 @@ func (f *PanFactory) CreatePanServiceByType(serviceType ServiceType, config *Pan
|
||||
return NewBaiduPanService(config), nil
|
||||
case UC:
|
||||
return NewUCService(config), nil
|
||||
// case Xunlei:
|
||||
// return NewXunleiService(config), nil
|
||||
case Xunlei:
|
||||
return NewXunleiPanService(config), nil
|
||||
// case Tianyi:
|
||||
// return NewTianyiService(config), nil
|
||||
default:
|
||||
@@ -178,6 +180,12 @@ func (f *PanFactory) GetUCService(config *PanConfig) PanService {
|
||||
return service
|
||||
}
|
||||
|
||||
// GetXunleiService 获取迅雷网盘服务单例
|
||||
func (f *PanFactory) GetXunleiService(config *PanConfig) PanService {
|
||||
service := NewXunleiPanService(config)
|
||||
return service
|
||||
}
|
||||
|
||||
// ExtractServiceType 从URL中提取服务类型
|
||||
func ExtractServiceType(url string) ServiceType {
|
||||
url = strings.ToLower(url)
|
||||
|
||||
@@ -13,8 +13,10 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
@@ -31,10 +33,6 @@ var (
|
||||
|
||||
// 配置化 API Host
|
||||
func (x *XunleiPanService) apiHost() string {
|
||||
// if x.config != nil && x.config.ApiHost != "" {
|
||||
// return x.config.ApiHost
|
||||
// }
|
||||
// 推荐用官方: https://api-pan.xunlei.com
|
||||
return "https://api-pan.xunlei.com"
|
||||
}
|
||||
|
||||
@@ -43,7 +41,6 @@ func (x *XunleiPanService) setCommonHeader(req *http.Request) {
|
||||
for k, v := range x.headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
// 可扩展: Authorization, x-device-id, x-client-id, x-captcha-token
|
||||
}
|
||||
|
||||
func NewXunleiPanService(config *PanConfig) *XunleiPanService {
|
||||
@@ -61,6 +58,11 @@ func NewXunleiPanService(config *PanConfig) *XunleiPanService {
|
||||
return xunleiInstance
|
||||
}
|
||||
|
||||
// GetXunleiInstance 获取迅雷网盘服务单例实例
|
||||
func GetXunleiInstance() *XunleiPanService {
|
||||
return NewXunleiPanService(nil)
|
||||
}
|
||||
|
||||
func (x *XunleiPanService) UpdateConfig(config *PanConfig) {
|
||||
if config == nil {
|
||||
return
|
||||
@@ -73,6 +75,249 @@ func (x *XunleiPanService) UpdateConfig(config *PanConfig) {
|
||||
}
|
||||
}
|
||||
|
||||
// GetServiceType 获取服务类型
|
||||
func (x *XunleiPanService) GetServiceType() ServiceType {
|
||||
return Xunlei
|
||||
}
|
||||
|
||||
// Transfer 转存分享链接 - 实现 PanService 接口
|
||||
func (x *XunleiPanService) Transfer(shareID string) (*TransferResult, error) {
|
||||
// 读取配置(线程安全)
|
||||
x.configMutex.RLock()
|
||||
config := x.config
|
||||
x.configMutex.RUnlock()
|
||||
|
||||
log.Printf("开始处理迅雷分享: %s", shareID)
|
||||
|
||||
// 检查是否为检验模式
|
||||
if config.IsType == 1 {
|
||||
// 检验模式:直接获取分享信息
|
||||
shareInfo, err := x.getShareInfo(shareID)
|
||||
if err != nil {
|
||||
return ErrorResult(fmt.Sprintf("获取分享信息失败: %v", err)), nil
|
||||
}
|
||||
|
||||
return SuccessResult("检验成功", map[string]interface{}{
|
||||
"title": shareInfo.Title,
|
||||
"shareUrl": config.URL,
|
||||
}), nil
|
||||
}
|
||||
|
||||
// 转存模式:实现完整的转存流程
|
||||
// 1. 获取分享详情
|
||||
shareDetail, err := x.GetShareFolder(shareID, "", "")
|
||||
if err != nil {
|
||||
return ErrorResult(fmt.Sprintf("获取分享详情失败: %v", err)), nil
|
||||
}
|
||||
|
||||
// 2. 提取文件ID列表
|
||||
fileIDs := make([]string, 0)
|
||||
for _, file := range shareDetail.Data.Files {
|
||||
fileIDs = append(fileIDs, file.FileID)
|
||||
}
|
||||
|
||||
if len(fileIDs) == 0 {
|
||||
return ErrorResult("分享中没有可转存的文件"), nil
|
||||
}
|
||||
|
||||
// 3. 转存文件
|
||||
restoreResult, err := x.Restore(shareID, "", fileIDs)
|
||||
if err != nil {
|
||||
return ErrorResult(fmt.Sprintf("转存失败: %v", err)), nil
|
||||
}
|
||||
|
||||
// 4. 等待转存完成
|
||||
taskID := restoreResult.Data.TaskID
|
||||
_, err = x.waitForTask(taskID)
|
||||
if err != nil {
|
||||
return ErrorResult(fmt.Sprintf("等待转存完成失败: %v", err)), nil
|
||||
}
|
||||
|
||||
// 5. 创建新的分享
|
||||
shareResult, err := x.FileBatchShare(fileIDs, false, 0) // 永久分享
|
||||
if err != nil {
|
||||
return ErrorResult(fmt.Sprintf("创建分享失败: %v", err)), nil
|
||||
}
|
||||
|
||||
// 6. 返回结果
|
||||
return SuccessResult("转存成功", map[string]interface{}{
|
||||
"shareUrl": shareResult.Data.ShareURL,
|
||||
"title": fmt.Sprintf("迅雷分享_%s", shareID),
|
||||
"fid": strings.Join(fileIDs, ","),
|
||||
}), nil
|
||||
}
|
||||
|
||||
// waitForTask 等待任务完成
|
||||
func (x *XunleiPanService) waitForTask(taskID string) (*XLTaskResult, error) {
|
||||
maxRetries := 50
|
||||
retryDelay := 2 * time.Second
|
||||
|
||||
for retryIndex := 0; retryIndex < maxRetries; retryIndex++ {
|
||||
result, err := x.getTaskStatus(taskID, retryIndex)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if result.Status == 2 { // 任务完成
|
||||
return result, nil
|
||||
}
|
||||
|
||||
time.Sleep(retryDelay)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("任务超时")
|
||||
}
|
||||
|
||||
// getTaskStatus 获取任务状态
|
||||
func (x *XunleiPanService) getTaskStatus(taskID string, retryIndex int) (*XLTaskResult, error) {
|
||||
apiURL := x.apiHost() + "/drive/v1/task"
|
||||
params := url.Values{}
|
||||
params.Set("task_id", taskID)
|
||||
params.Set("retry_index", fmt.Sprintf("%d", retryIndex))
|
||||
apiURL = apiURL + "?" + params.Encode()
|
||||
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
x.setCommonHeader(req)
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
result, _ := ioutil.ReadAll(resp.Body)
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(result))
|
||||
}
|
||||
var data XLTaskResult
|
||||
if err := json.Unmarshal(result, &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &data, nil
|
||||
}
|
||||
|
||||
// getShareInfo 获取分享信息(用于检验模式)
|
||||
func (x *XunleiPanService) getShareInfo(shareID string) (*XLShareInfo, error) {
|
||||
// 使用现有的 GetShareFolder 方法获取分享信息
|
||||
shareDetail, err := x.GetShareFolder(shareID, "", "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 构造分享信息
|
||||
shareInfo := &XLShareInfo{
|
||||
ShareID: shareID,
|
||||
Title: fmt.Sprintf("迅雷分享_%s", shareID),
|
||||
Files: make([]XLFileInfo, 0),
|
||||
}
|
||||
|
||||
// 处理文件信息
|
||||
for _, file := range shareDetail.Data.Files {
|
||||
shareInfo.Files = append(shareInfo.Files, XLFileInfo{
|
||||
FileID: file.FileID,
|
||||
Name: file.Name,
|
||||
})
|
||||
}
|
||||
|
||||
return shareInfo, nil
|
||||
}
|
||||
|
||||
// GetFiles 获取文件列表 - 实现 PanService 接口
|
||||
func (x *XunleiPanService) GetFiles(pdirFid string) (*TransferResult, error) {
|
||||
log.Printf("开始获取迅雷网盘文件列表,目录ID: %s", pdirFid)
|
||||
|
||||
// 使用现有的 GetShareList 方法获取文件列表
|
||||
shareList, err := x.GetShareList("")
|
||||
if err != nil {
|
||||
return ErrorResult(fmt.Sprintf("获取文件列表失败: %v", err)), nil
|
||||
}
|
||||
|
||||
// 转换为通用格式
|
||||
fileList := make([]interface{}, 0)
|
||||
for _, share := range shareList.Data.List {
|
||||
fileList = append(fileList, map[string]interface{}{
|
||||
"share_id": share.ShareID,
|
||||
"title": share.Title,
|
||||
})
|
||||
}
|
||||
|
||||
return SuccessResult("获取成功", fileList), nil
|
||||
}
|
||||
|
||||
// DeleteFiles 删除文件 - 实现 PanService 接口
|
||||
func (x *XunleiPanService) DeleteFiles(fileList []string) (*TransferResult, error) {
|
||||
log.Printf("开始删除迅雷网盘文件,文件数量: %d", len(fileList))
|
||||
|
||||
// 使用现有的 ShareBatchDelete 方法删除分享
|
||||
result, err := x.ShareBatchDelete(fileList)
|
||||
if err != nil {
|
||||
return ErrorResult(fmt.Sprintf("删除文件失败: %v", err)), nil
|
||||
}
|
||||
|
||||
if result.Code != 0 {
|
||||
return ErrorResult(fmt.Sprintf("删除文件失败: %s", result.Msg)), nil
|
||||
}
|
||||
|
||||
return SuccessResult("删除成功", nil), nil
|
||||
}
|
||||
|
||||
// GetUserInfo 获取用户信息 - 实现 PanService 接口
|
||||
func (x *XunleiPanService) GetUserInfo(cookie string) (*UserInfo, error) {
|
||||
log.Printf("开始获取迅雷网盘用户信息")
|
||||
|
||||
// 临时设置cookie
|
||||
originalCookie := x.GetHeader("Cookie")
|
||||
x.SetHeader("Cookie", cookie)
|
||||
defer x.SetHeader("Cookie", originalCookie) // 恢复原始cookie
|
||||
|
||||
// 获取用户信息
|
||||
apiURL := x.apiHost() + "/drive/v1/user/info"
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建请求失败: %v", err)
|
||||
}
|
||||
x.setCommonHeader(req)
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取用户信息失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
result, _ := ioutil.ReadAll(resp.Body)
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(result))
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data struct {
|
||||
Username string `json:"username"`
|
||||
VIPStatus bool `json:"vip_status"`
|
||||
UsedSpace int64 `json:"used_space"`
|
||||
TotalSpace int64 `json:"total_space"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(result, &response); err != nil {
|
||||
return nil, fmt.Errorf("解析用户信息失败: %v", err)
|
||||
}
|
||||
|
||||
if response.Code != 0 {
|
||||
return nil, fmt.Errorf("获取用户信息失败: %s", response.Msg)
|
||||
}
|
||||
|
||||
return &UserInfo{
|
||||
Username: response.Data.Username,
|
||||
VIPStatus: response.Data.VIPStatus,
|
||||
UsedSpace: response.Data.UsedSpace,
|
||||
TotalSpace: response.Data.TotalSpace,
|
||||
ServiceType: "xunlei",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetShareList 严格对齐 GET + query(xunleix实现)
|
||||
func (x *XunleiPanService) GetShareList(pageToken string) (*XLShareListResp, error) {
|
||||
api := x.apiHost() + "/drive/v1/share/list"
|
||||
@@ -82,9 +327,9 @@ func (x *XunleiPanService) GetShareList(pageToken string) (*XLShareListResp, err
|
||||
if pageToken != "" {
|
||||
params.Set("page_token", pageToken)
|
||||
}
|
||||
url := api + "?" + params.Encode()
|
||||
apiURL := api + "?" + params.Encode()
|
||||
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -108,14 +353,14 @@ func (x *XunleiPanService) GetShareList(pageToken string) (*XLShareListResp, err
|
||||
|
||||
// FileBatchShare 创建分享(POST, body)
|
||||
func (x *XunleiPanService) FileBatchShare(ids []string, needPassword bool, expirationDays int) (*XLBatchShareResp, error) {
|
||||
url := x.apiHost() + "/drive/v1/share/batch"
|
||||
apiURL := x.apiHost() + "/drive/v1/share/batch"
|
||||
body := map[string]interface{}{
|
||||
"file_ids": ids,
|
||||
"need_password": needPassword,
|
||||
"expiration_days": expirationDays,
|
||||
}
|
||||
bs, _ := json.Marshal(body)
|
||||
req, err := http.NewRequest("POST", url, bytes.NewReader(bs))
|
||||
req, err := http.NewRequest("POST", apiURL, bytes.NewReader(bs))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -139,12 +384,12 @@ func (x *XunleiPanService) FileBatchShare(ids []string, needPassword bool, expir
|
||||
|
||||
// ShareBatchDelete 取消分享(POST, body)
|
||||
func (x *XunleiPanService) ShareBatchDelete(ids []string) (*XLCommonResp, error) {
|
||||
url := x.apiHost() + "/drive/v1/share/batch/delete"
|
||||
apiURL := x.apiHost() + "/drive/v1/share/batch/delete"
|
||||
body := map[string]interface{}{
|
||||
"share_ids": ids,
|
||||
}
|
||||
bs, _ := json.Marshal(body)
|
||||
req, err := http.NewRequest("POST", url, bytes.NewReader(bs))
|
||||
req, err := http.NewRequest("POST", apiURL, bytes.NewReader(bs))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -168,7 +413,7 @@ func (x *XunleiPanService) ShareBatchDelete(ids []string) (*XLCommonResp, error)
|
||||
|
||||
// GetShareFolder 获取分享内容(POST, body)
|
||||
func (x *XunleiPanService) GetShareFolder(shareID, passCodeToken, parentID string) (*XLShareFolderResp, error) {
|
||||
url := x.apiHost() + "/drive/v1/share/detail"
|
||||
apiURL := x.apiHost() + "/drive/v1/share/detail"
|
||||
body := map[string]interface{}{
|
||||
"share_id": shareID,
|
||||
"pass_code_token": passCodeToken,
|
||||
@@ -178,7 +423,7 @@ func (x *XunleiPanService) GetShareFolder(shareID, passCodeToken, parentID strin
|
||||
"order": "6",
|
||||
}
|
||||
bs, _ := json.Marshal(body)
|
||||
req, err := http.NewRequest("POST", url, bytes.NewReader(bs))
|
||||
req, err := http.NewRequest("POST", apiURL, bytes.NewReader(bs))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -202,7 +447,7 @@ func (x *XunleiPanService) GetShareFolder(shareID, passCodeToken, parentID strin
|
||||
|
||||
// Restore 转存(POST, body)
|
||||
func (x *XunleiPanService) Restore(shareID, passCodeToken string, fileIDs []string) (*XLRestoreResp, error) {
|
||||
url := x.apiHost() + "/drive/v1/share/restore"
|
||||
apiURL := x.apiHost() + "/drive/v1/share/restore"
|
||||
body := map[string]interface{}{
|
||||
"share_id": shareID,
|
||||
"pass_code_token": passCodeToken,
|
||||
@@ -212,7 +457,7 @@ func (x *XunleiPanService) Restore(shareID, passCodeToken string, fileIDs []stri
|
||||
"parent_id": "",
|
||||
}
|
||||
bs, _ := json.Marshal(body)
|
||||
req, err := http.NewRequest("POST", url, bytes.NewReader(bs))
|
||||
req, err := http.NewRequest("POST", apiURL, bytes.NewReader(bs))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -277,3 +522,23 @@ type XLRestoreResp struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
|
||||
// 新增辅助结构体
|
||||
type XLShareInfo struct {
|
||||
ShareID string `json:"share_id"`
|
||||
Title string `json:"title"`
|
||||
Files []XLFileInfo `json:"files"`
|
||||
}
|
||||
|
||||
type XLFileInfo struct {
|
||||
FileID string `json:"file_id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type XLTaskResult struct {
|
||||
Status int `json:"status"`
|
||||
TaskID string `json:"task_id"`
|
||||
Data struct {
|
||||
ShareID string `json:"share_id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ func FileToResponse(file *entity.File) dto.FileResponse {
|
||||
FileSize: file.FileSize,
|
||||
FileType: file.FileType,
|
||||
MimeType: file.MimeType,
|
||||
FileHash: file.FileHash,
|
||||
AccessURL: file.AccessURL,
|
||||
UserID: file.UserID,
|
||||
Status: file.Status,
|
||||
|
||||
@@ -30,6 +30,8 @@ func SystemConfigToResponse(configs []entity.SystemConfig) *dto.SystemConfigResp
|
||||
response.Author = config.Value
|
||||
case entity.ConfigKeyCopyright:
|
||||
response.Copyright = config.Value
|
||||
case entity.ConfigKeySiteLogo:
|
||||
response.SiteLogo = config.Value
|
||||
case entity.ConfigKeyAutoProcessReadyResources:
|
||||
if val, err := strconv.ParseBool(config.Value); err == nil {
|
||||
response.AutoProcessReadyResources = val
|
||||
@@ -103,6 +105,7 @@ func RequestToSystemConfig(req *dto.SystemConfigRequest) []entity.SystemConfig {
|
||||
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyKeywords, Value: req.Keywords, Type: entity.ConfigTypeString})
|
||||
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyAuthor, Value: req.Author, Type: entity.ConfigTypeString})
|
||||
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyCopyright, Value: req.Copyright, Type: entity.ConfigTypeString})
|
||||
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeySiteLogo, Value: req.SiteLogo, Type: entity.ConfigTypeString})
|
||||
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyApiToken, Value: req.ApiToken, Type: entity.ConfigTypeString})
|
||||
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyForbiddenWords, Value: req.ForbiddenWords, Type: entity.ConfigTypeString})
|
||||
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyAdKeywords, Value: req.AdKeywords, Type: entity.ConfigTypeString})
|
||||
@@ -140,6 +143,7 @@ func SystemConfigToPublicResponse(configs []entity.SystemConfig) map[string]inte
|
||||
entity.ConfigResponseFieldKeywords: entity.ConfigDefaultKeywords,
|
||||
entity.ConfigResponseFieldAuthor: entity.ConfigDefaultAuthor,
|
||||
entity.ConfigResponseFieldCopyright: entity.ConfigDefaultCopyright,
|
||||
"site_logo": "",
|
||||
entity.ConfigResponseFieldAutoProcessReadyResources: false,
|
||||
entity.ConfigResponseFieldAutoProcessInterval: 30,
|
||||
entity.ConfigResponseFieldAutoTransferEnabled: false,
|
||||
@@ -167,6 +171,8 @@ func SystemConfigToPublicResponse(configs []entity.SystemConfig) map[string]inte
|
||||
response[entity.ConfigResponseFieldAuthor] = config.Value
|
||||
case entity.ConfigKeyCopyright:
|
||||
response[entity.ConfigResponseFieldCopyright] = config.Value
|
||||
case entity.ConfigKeySiteLogo:
|
||||
response["site_logo"] = config.Value
|
||||
case entity.ConfigKeyAutoProcessReadyResources:
|
||||
if val, err := strconv.ParseBool(config.Value); err == nil {
|
||||
response[entity.ConfigResponseFieldAutoProcessReadyResources] = val
|
||||
@@ -231,6 +237,7 @@ func getDefaultConfigResponse() *dto.SystemConfigResponse {
|
||||
Keywords: entity.ConfigDefaultKeywords,
|
||||
Author: entity.ConfigDefaultAuthor,
|
||||
Copyright: entity.ConfigDefaultCopyright,
|
||||
SiteLogo: "",
|
||||
AutoProcessReadyResources: false,
|
||||
AutoProcessInterval: 30,
|
||||
AutoTransferEnabled: false,
|
||||
|
||||
@@ -3,6 +3,7 @@ package dto
|
||||
// FileUploadRequest 文件上传请求
|
||||
type FileUploadRequest struct {
|
||||
IsPublic bool `json:"is_public" form:"is_public"` // 是否公开
|
||||
FileHash string `json:"file_hash" form:"file_hash"` // 文件哈希值
|
||||
}
|
||||
|
||||
// FileResponse 文件响应
|
||||
@@ -18,6 +19,7 @@ type FileResponse struct {
|
||||
FileSize int64 `json:"file_size"`
|
||||
FileType string `json:"file_type"`
|
||||
MimeType string `json:"mime_type"`
|
||||
FileHash string `json:"file_hash"`
|
||||
|
||||
// 访问信息
|
||||
AccessURL string `json:"access_url"`
|
||||
@@ -55,6 +57,7 @@ type FileUploadResponse struct {
|
||||
File FileResponse `json:"file"`
|
||||
Message string `json:"message"`
|
||||
Success bool `json:"success"`
|
||||
IsDuplicate bool `json:"is_duplicate"` // 是否为重复文件
|
||||
}
|
||||
|
||||
// FileDeleteRequest 文件删除请求
|
||||
|
||||
@@ -8,6 +8,7 @@ type SystemConfigRequest struct {
|
||||
Keywords string `json:"keywords"`
|
||||
Author string `json:"author"`
|
||||
Copyright string `json:"copyright"`
|
||||
SiteLogo string `json:"site_logo"`
|
||||
|
||||
// 自动处理配置组
|
||||
AutoProcessReadyResources bool `json:"auto_process_ready_resources"` // 自动处理待处理资源
|
||||
@@ -48,6 +49,7 @@ type SystemConfigResponse struct {
|
||||
Keywords string `json:"keywords"`
|
||||
Author string `json:"author"`
|
||||
Copyright string `json:"copyright"`
|
||||
SiteLogo string `json:"site_logo"`
|
||||
|
||||
// 自动处理配置组
|
||||
AutoProcessReadyResources bool `json:"auto_process_ready_resources"` // 自动处理待处理资源
|
||||
|
||||
@@ -17,6 +17,7 @@ type File struct {
|
||||
FileSize int64 `json:"file_size" gorm:"not null;comment:文件大小(字节)"`
|
||||
FileType string `json:"file_type" gorm:"size:100;not null;comment:文件类型"`
|
||||
MimeType string `json:"mime_type" gorm:"size:100;comment:MIME类型"`
|
||||
FileHash string `json:"file_hash" gorm:"size:64;uniqueIndex;comment:文件哈希值"`
|
||||
|
||||
// 访问信息
|
||||
AccessURL string `json:"access_url" gorm:"size:500;comment:访问URL"`
|
||||
|
||||
@@ -8,6 +8,7 @@ const (
|
||||
ConfigKeyKeywords = "keywords"
|
||||
ConfigKeyAuthor = "author"
|
||||
ConfigKeyCopyright = "copyright"
|
||||
ConfigKeySiteLogo = "site_logo"
|
||||
|
||||
// 自动处理配置组
|
||||
ConfigKeyAutoProcessReadyResources = "auto_process_ready_resources"
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
type FileRepository interface {
|
||||
BaseRepository[entity.File]
|
||||
FindByFileName(fileName string) (*entity.File, error)
|
||||
FindByHash(fileHash string) (*entity.File, error)
|
||||
FindByUserID(userID uint, page, pageSize int) ([]entity.File, int64, error)
|
||||
FindPublicFiles(page, pageSize int) ([]entity.File, int64, error)
|
||||
SearchFiles(search string, fileType, status string, userID uint, page, pageSize int) ([]entity.File, int64, error)
|
||||
@@ -142,3 +143,13 @@ func (r *FileRepositoryImpl) UpdateFileStatus(id uint, status string) error {
|
||||
func (r *FileRepositoryImpl) UpdateFilePublic(id uint, isPublic bool) error {
|
||||
return r.db.Model(&entity.File{}).Where("id = ?", id).Update("is_public", isPublic).Error
|
||||
}
|
||||
|
||||
// FindByHash 根据文件哈希查找文件
|
||||
func (r *FileRepositoryImpl) FindByHash(fileHash string) (*entity.File, error) {
|
||||
var file entity.File
|
||||
err := r.db.Where("file_hash = ? AND is_deleted = ?", fileHash, false).First(&file).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &file, nil
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package handlers
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -52,6 +53,28 @@ func (h *FileHandler) UploadFile(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 获取文件哈希值
|
||||
fileHash := c.PostForm("file_hash")
|
||||
|
||||
// 如果提供了文件哈希,先检查是否已存在
|
||||
if fileHash != "" {
|
||||
existingFile, err := h.fileRepo.FindByHash(fileHash)
|
||||
if err == nil && existingFile != nil {
|
||||
// 文件已存在,直接返回已存在的文件信息
|
||||
utils.Info("文件已存在,跳过上传 - Hash: %s, 文件名: %s", fileHash, existingFile.OriginalName)
|
||||
|
||||
response := dto.FileUploadResponse{
|
||||
File: converter.FileToResponse(existingFile),
|
||||
Message: "文件已存在,极速上传成功",
|
||||
Success: true,
|
||||
IsDuplicate: true,
|
||||
}
|
||||
|
||||
SuccessResponse(c, response)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 获取上传目录配置(从环境变量或使用默认值)
|
||||
uploadDir := os.Getenv("UPLOAD_DIR")
|
||||
if uploadDir == "" {
|
||||
@@ -127,6 +150,33 @@ func (h *FileHandler) UploadFile(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 计算文件哈希值(如果前端没有提供)
|
||||
if fileHash == "" {
|
||||
fileHash, err = h.calculateFileHash(filePath)
|
||||
if err != nil {
|
||||
ErrorResponse(c, "计算文件哈希失败", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 再次检查文件是否已存在(使用计算出的哈希)
|
||||
existingFile, err := h.fileRepo.FindByHash(fileHash)
|
||||
if err == nil && existingFile != nil {
|
||||
// 文件已存在,删除刚上传的文件,返回已存在的文件信息
|
||||
os.Remove(filePath)
|
||||
utils.Info("文件已存在,跳过上传 - Hash: %s, 文件名: %s", fileHash, existingFile.OriginalName)
|
||||
|
||||
response := dto.FileUploadResponse{
|
||||
File: converter.FileToResponse(existingFile),
|
||||
Message: "文件已存在,极速上传成功",
|
||||
Success: true,
|
||||
IsDuplicate: true,
|
||||
}
|
||||
|
||||
SuccessResponse(c, response)
|
||||
return
|
||||
}
|
||||
|
||||
// 获取文件类型
|
||||
fileType := h.getFileType(header.Filename)
|
||||
mimeType := header.Header.Get("Content-Type")
|
||||
@@ -150,6 +200,7 @@ func (h *FileHandler) UploadFile(c *gin.Context) {
|
||||
FileSize: header.Size,
|
||||
FileType: fileType,
|
||||
MimeType: mimeType,
|
||||
FileHash: fileHash,
|
||||
AccessURL: accessURL,
|
||||
UserID: currentUser.ID,
|
||||
Status: entity.FileStatusActive,
|
||||
@@ -380,3 +431,18 @@ func (h *FileHandler) getFileType(filename string) string {
|
||||
|
||||
return "image" // 默认返回image,因为只支持图片格式
|
||||
}
|
||||
|
||||
// calculateFileHash 计算文件哈希值
|
||||
func (h *FileHandler) calculateFileHash(filePath string) (string, error) {
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
hash := sha256.New()
|
||||
if _, err := io.Copy(hash, file); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%x", hash.Sum(nil)), nil
|
||||
}
|
||||
|
||||
1
web/components/FileSelector.vue
Normal file
1
web/components/FileSelector.vue
Normal file
@@ -0,0 +1 @@
|
||||
<template>
|
||||
@@ -19,7 +19,7 @@
|
||||
点击或者拖动文件到该区域来上传
|
||||
</n-text>
|
||||
<n-p depth="3" style="margin: 8px 0 0 0">
|
||||
请不要上传敏感数据,比如你的银行卡号和密码,信用卡号有效期和安全码
|
||||
支持极速上传,相同文件将直接返回已上传的文件信息
|
||||
</n-p>
|
||||
</n-upload-dragger>
|
||||
</n-upload>
|
||||
@@ -39,6 +39,7 @@ interface FileItem {
|
||||
file_size: number
|
||||
file_type: string
|
||||
mime_type: string
|
||||
file_hash: string
|
||||
access_url: string
|
||||
user_id: number
|
||||
user: string
|
||||
@@ -72,7 +73,28 @@ const acceptTypes = ref('image/*')
|
||||
const uploadedFiles = ref<Map<string, boolean>>(new Map()) // 文件哈希 -> 是否已上传
|
||||
const uploadingFiles = ref<Set<string>>(new Set()) // 正在上传的文件哈希
|
||||
|
||||
// 生成文件哈希值(基于文件名、大小和修改时间)
|
||||
// 计算文件SHA256哈希值
|
||||
const calculateFileHash = async (file: File): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const arrayBuffer = e.target?.result as ArrayBuffer
|
||||
crypto.subtle.digest('SHA-256', arrayBuffer).then(hashBuffer => {
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
||||
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
|
||||
resolve(hashHex)
|
||||
}).catch(reject)
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
}
|
||||
}
|
||||
reader.onerror = reject
|
||||
reader.readAsArrayBuffer(file)
|
||||
})
|
||||
}
|
||||
|
||||
// 生成文件哈希值(基于文件名、大小和修改时间,用于前端去重)
|
||||
const generateFileHash = (file: File): string => {
|
||||
return `${file.name}_${file.size}_${file.lastModified}`
|
||||
}
|
||||
@@ -133,10 +155,15 @@ const customRequest = async (options: any) => {
|
||||
console.log('开始上传文件:', file.name, file.file)
|
||||
|
||||
try {
|
||||
// 计算文件哈希值
|
||||
const fileHash = await calculateFileHash(file.file)
|
||||
console.log('文件哈希值:', fileHash)
|
||||
|
||||
// 创建FormData
|
||||
const formData = new FormData()
|
||||
formData.append('file', file.file)
|
||||
formData.append('is_public', isPublic.value.toString())
|
||||
formData.append('file_hash', fileHash)
|
||||
|
||||
// 调用统一的API接口
|
||||
const response = await fileApi.uploadFile(formData)
|
||||
@@ -146,6 +173,13 @@ const customRequest = async (options: any) => {
|
||||
// 标记文件为已上传
|
||||
markFileAsUploaded(file.file)
|
||||
|
||||
// 检查是否为重复文件
|
||||
if (response.data && response.data.is_duplicate) {
|
||||
message.success(`${file.name} 极速上传成功(文件已存在)`)
|
||||
} else {
|
||||
message.success(`${file.name} 上传成功`)
|
||||
}
|
||||
|
||||
if (onSuccess) {
|
||||
onSuccess(response)
|
||||
}
|
||||
@@ -159,7 +193,6 @@ const customRequest = async (options: any) => {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 默认文件列表(从props传入)
|
||||
const defaultFileList = ref<UploadFileInfo[]>([])
|
||||
|
||||
@@ -212,7 +245,6 @@ const handleUploadFinish = (data: { file: Required<UploadFileInfo> }) => {
|
||||
const { file } = data
|
||||
|
||||
if (file.status === 'finished') {
|
||||
message.success(`${file.name} 上传成功`)
|
||||
// 确保文件被标记为已上传
|
||||
if (file.file) {
|
||||
markFileAsUploaded(file.file)
|
||||
|
||||
@@ -70,6 +70,40 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 网站Logo -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center space-x-2">
|
||||
<label class="text-base font-semibold text-gray-800 dark:text-gray-200">网站Logo</label>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">选择网站Logo图片,建议使用正方形图片</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<div v-if="configForm.site_logo" class="flex-shrink-0">
|
||||
<n-image
|
||||
:src="configForm.site_logo"
|
||||
alt="网站Logo"
|
||||
width="80"
|
||||
height="80"
|
||||
object-fit="cover"
|
||||
class="rounded-lg border"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<n-button type="primary" @click="openLogoSelector">
|
||||
<template #icon>
|
||||
<i class="fas fa-image"></i>
|
||||
</template>
|
||||
{{ configForm.site_logo ? '更换Logo' : '选择Logo' }}
|
||||
</n-button>
|
||||
<n-button v-if="configForm.site_logo" @click="clearLogo" class="ml-2">
|
||||
<template #icon>
|
||||
<i class="fas fa-times"></i>
|
||||
</template>
|
||||
清除
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 版权信息 -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center space-x-2">
|
||||
@@ -143,6 +177,101 @@
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</n-card>
|
||||
|
||||
<!-- Logo选择模态框 -->
|
||||
<n-modal v-model:show="showLogoSelector" preset="card" title="选择Logo图片" style="width: 90vw; max-width: 1200px; max-height: 80vh;">
|
||||
<div class="space-y-4">
|
||||
<!-- 搜索 -->
|
||||
<div class="flex gap-4">
|
||||
<n-input
|
||||
v-model:value="searchKeyword"
|
||||
placeholder="搜索文件名..."
|
||||
@keyup.enter="handleSearch"
|
||||
class="flex-1"
|
||||
>
|
||||
<template #prefix>
|
||||
<i class="fas fa-search"></i>
|
||||
</template>
|
||||
</n-input>
|
||||
|
||||
<n-button type="primary" @click="handleSearch">
|
||||
<template #icon>
|
||||
<i class="fas fa-search"></i>
|
||||
</template>
|
||||
搜索
|
||||
</n-button>
|
||||
</div>
|
||||
|
||||
<!-- 文件列表 -->
|
||||
<div v-if="loading" class="flex items-center justify-center py-8">
|
||||
<n-spin size="large" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="fileList.length === 0" class="text-center py-8">
|
||||
<i class="fas fa-file-upload text-4xl text-gray-400 mb-4"></i>
|
||||
<p class="text-gray-500">暂无图片文件</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="file-grid">
|
||||
<div
|
||||
v-for="file in fileList"
|
||||
:key="file.id"
|
||||
class="file-item cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-lg p-3 transition-colors"
|
||||
:class="{ 'bg-blue-50 dark:bg-blue-900/20 border-2 border-blue-300 dark:border-blue-600': selectedFileId === file.id }"
|
||||
@click="selectFile(file)"
|
||||
>
|
||||
<div class="image-preview">
|
||||
<n-image
|
||||
:src="file.access_url"
|
||||
:alt="file.original_name"
|
||||
:lazy="true"
|
||||
:intersection-observer-options="{
|
||||
root: null,
|
||||
rootMargin: '50px',
|
||||
threshold: 0.1
|
||||
}"
|
||||
object-fit="cover"
|
||||
class="preview-image rounded"
|
||||
/>
|
||||
<div class="image-info mt-2">
|
||||
<div class="file-name text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||
{{ file.original_name }}
|
||||
</div>
|
||||
<div class="file-size text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ formatFileSize(file.file_size) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination-wrapper">
|
||||
<n-pagination
|
||||
v-model:page="pagination.page"
|
||||
v-model:page-size="pagination.pageSize"
|
||||
:page-count="Math.ceil(pagination.total / pagination.pageSize)"
|
||||
:page-sizes="pagination.pageSizes"
|
||||
show-size-picker
|
||||
@update:page="handlePageChange"
|
||||
@update:page-size="handlePageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<n-space justify="end">
|
||||
<n-button @click="showLogoSelector = false">取消</n-button>
|
||||
<n-button
|
||||
type="primary"
|
||||
@click="confirmSelection"
|
||||
:disabled="!selectedFileId"
|
||||
>
|
||||
确认选择
|
||||
</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</n-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -158,12 +287,28 @@ const formRef = ref()
|
||||
const saving = ref(false)
|
||||
const activeTab = ref('basic')
|
||||
|
||||
// Logo选择器相关数据
|
||||
const showLogoSelector = ref(false)
|
||||
const loading = ref(false)
|
||||
const fileList = ref<any[]>([])
|
||||
const selectedFileId = ref<number | null>(null)
|
||||
const searchKeyword = ref('')
|
||||
|
||||
// 分页
|
||||
const pagination = ref({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 0,
|
||||
pageSizes: [10, 20, 50, 100]
|
||||
})
|
||||
|
||||
// 配置表单数据
|
||||
const configForm = ref<{
|
||||
site_title: string
|
||||
site_description: string
|
||||
keywords: string
|
||||
copyright: string
|
||||
site_logo: string
|
||||
maintenance_mode: boolean
|
||||
enable_register: boolean
|
||||
forbidden_words: string
|
||||
@@ -174,6 +319,7 @@ const configForm = ref<{
|
||||
site_description: '',
|
||||
keywords: '',
|
||||
copyright: '',
|
||||
site_logo: '',
|
||||
maintenance_mode: false,
|
||||
enable_register: false, // 新增:开启注册开关
|
||||
forbidden_words: '',
|
||||
@@ -210,6 +356,7 @@ const fetchConfig = async () => {
|
||||
site_description: response.site_description || '',
|
||||
keywords: response.keywords || '',
|
||||
copyright: response.copyright || '',
|
||||
site_logo: response.site_logo || '',
|
||||
maintenance_mode: response.maintenance_mode || false,
|
||||
enable_register: response.enable_register || false, // 新增:获取开启注册开关
|
||||
forbidden_words: response.forbidden_words || '',
|
||||
@@ -240,6 +387,7 @@ const saveConfig = async () => {
|
||||
site_description: configForm.value.site_description,
|
||||
keywords: configForm.value.keywords,
|
||||
copyright: configForm.value.copyright,
|
||||
site_logo: configForm.value.site_logo,
|
||||
maintenance_mode: configForm.value.maintenance_mode,
|
||||
enable_register: configForm.value.enable_register, // 新增:保存开启注册开关
|
||||
forbidden_words: configForm.value.forbidden_words,
|
||||
@@ -267,6 +415,84 @@ const saveConfig = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Logo选择器方法
|
||||
const openLogoSelector = () => {
|
||||
showLogoSelector.value = true
|
||||
loadFileList()
|
||||
}
|
||||
|
||||
const clearLogo = () => {
|
||||
configForm.value.site_logo = ''
|
||||
}
|
||||
|
||||
const loadFileList = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const { useFileApi } = await import('~/composables/useFileApi')
|
||||
const fileApi = useFileApi()
|
||||
|
||||
const response = await fileApi.getFileList({
|
||||
page: pagination.value.page,
|
||||
pageSize: pagination.value.pageSize,
|
||||
search: searchKeyword.value,
|
||||
fileType: 'image', // 只获取图片文件
|
||||
status: 'active' // 只获取正常状态的文件
|
||||
}) as any
|
||||
|
||||
if (response && response.data) {
|
||||
fileList.value = response.data.files || []
|
||||
pagination.value.total = response.data.total || 0
|
||||
console.log('获取到的图片文件:', fileList.value) // 调试信息
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取文件列表失败:', error)
|
||||
notification.error({
|
||||
content: '获取文件列表失败',
|
||||
duration: 3000
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
pagination.value.page = 1
|
||||
loadFileList()
|
||||
}
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
pagination.value.page = page
|
||||
loadFileList()
|
||||
}
|
||||
|
||||
const handlePageSizeChange = (pageSize: number) => {
|
||||
pagination.value.pageSize = pageSize
|
||||
pagination.value.page = 1
|
||||
loadFileList()
|
||||
}
|
||||
|
||||
const selectFile = (file: any) => {
|
||||
selectedFileId.value = file.id
|
||||
}
|
||||
|
||||
const confirmSelection = () => {
|
||||
if (selectedFileId.value) {
|
||||
const file = fileList.value.find(f => f.id === selectedFileId.value)
|
||||
if (file) {
|
||||
configForm.value.site_logo = file.access_url
|
||||
showLogoSelector.value = false
|
||||
selectedFileId.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const formatFileSize = (size: number) => {
|
||||
if (size < 1024) return size + ' B'
|
||||
if (size < 1024 * 1024) return (size / 1024).toFixed(1) + ' KB'
|
||||
if (size < 1024 * 1024 * 1024) return (size / (1024 * 1024)).toFixed(1) + ' MB'
|
||||
return (size / (1024 * 1024 * 1024)).toFixed(1) + ' GB'
|
||||
}
|
||||
|
||||
// 页面加载时获取配置
|
||||
onMounted(() => {
|
||||
fetchConfig()
|
||||
@@ -277,4 +503,33 @@ onMounted(() => {
|
||||
|
||||
<style scoped>
|
||||
/* 自定义样式 */
|
||||
.file-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.file-item {
|
||||
border: 1px solid #e5e7eb;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.file-item:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
width: 100%;
|
||||
height: 120px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.pagination-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user