mirror of
https://github.com/ctwj/urldb.git
synced 2025-11-25 03:15:04 +08:00
Compare commits
41 Commits
0e88374905
...
feat_googl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c6211d130 | ||
|
|
6f5b6c3f40 | ||
|
|
b893f30558 | ||
|
|
09baf0cb21 | ||
|
|
4e3f9017ac | ||
|
|
8a79e7e87b | ||
|
|
f67f820ef8 | ||
|
|
1af7fbd355 | ||
|
|
8e35a6e507 | ||
|
|
d113dcd926 | ||
|
|
8708e869a4 | ||
|
|
6c84b8d7b7 | ||
|
|
fc4cf8ecfb | ||
|
|
447512d809 | ||
|
|
64e7169140 | ||
|
|
99d2c7f20f | ||
|
|
9e6b5a58c4 | ||
|
|
040e6bc6bf | ||
|
|
3370f75d5e | ||
|
|
11a3204c18 | ||
|
|
5276112e48 | ||
|
|
3bd0fde82f | ||
|
|
61e5cbf80d | ||
|
|
57f7bab443 | ||
|
|
242e12c29c | ||
|
|
f9a1043431 | ||
|
|
5dc431ab24 | ||
|
|
c50282bec8 | ||
|
|
b99a97c0a9 | ||
|
|
5c1aaf245d | ||
|
|
30448841f6 | ||
|
|
7cddb243bc | ||
|
|
c15132b45a | ||
|
|
04b3838cea | ||
|
|
70276b68ee | ||
|
|
fe8aaff92e | ||
|
|
236051f6c4 | ||
|
|
01bc8f0450 | ||
|
|
5b7e7b73ad | ||
|
|
ca175ec59d | ||
|
|
ec4e0762d5 |
9
.gitignore
vendored
9
.gitignore
vendored
@@ -123,4 +123,11 @@ dist/
|
||||
.dockerignore
|
||||
|
||||
# Air live reload
|
||||
tmp/
|
||||
tmp/
|
||||
|
||||
#
|
||||
data/
|
||||
.claude/
|
||||
main
|
||||
output.zip
|
||||
CLAUDE.md
|
||||
@@ -1,3 +1,6 @@
|
||||
### v1.3.4
|
||||
1. 添加详情页
|
||||
|
||||
### v1.3.3
|
||||
1. 公众号自动回复
|
||||
|
||||
|
||||
295
cmd/google-index/main.go
Normal file
295
cmd/google-index/main.go
Normal file
@@ -0,0 +1,295 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ctwj/urldb/pkg/google"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
printUsage()
|
||||
return
|
||||
}
|
||||
|
||||
command := os.Args[1]
|
||||
config := &google.Config{
|
||||
CredentialsFile: "credentials.json",
|
||||
SiteURL: "https://your-site.com/", // 替换为你的网站
|
||||
TokenFile: "token.json",
|
||||
}
|
||||
|
||||
client, err := google.NewClient(config)
|
||||
if err != nil {
|
||||
log.Fatalf("创建Google客户端失败: %v", err)
|
||||
}
|
||||
|
||||
switch command {
|
||||
case "inspect":
|
||||
if len(os.Args) < 3 {
|
||||
fmt.Println("请提供要检查的URL")
|
||||
return
|
||||
}
|
||||
inspectSingleURL(client, os.Args[2])
|
||||
|
||||
case "batch":
|
||||
if len(os.Args) < 3 {
|
||||
fmt.Println("请提供包含URL列表的文件")
|
||||
return
|
||||
}
|
||||
batchInspectURLs(client, os.Args[2])
|
||||
|
||||
case "sites":
|
||||
listSites(client)
|
||||
|
||||
case "analytics":
|
||||
getAnalytics(client)
|
||||
|
||||
case "sitemap":
|
||||
if len(os.Args) < 3 {
|
||||
fmt.Println("请提供网站地图URL")
|
||||
return
|
||||
}
|
||||
submitSitemap(client, os.Args[2])
|
||||
|
||||
default:
|
||||
fmt.Printf("未知命令: %s\n", command)
|
||||
printUsage()
|
||||
}
|
||||
}
|
||||
|
||||
func printUsage() {
|
||||
fmt.Println("Google Search Console API 工具")
|
||||
fmt.Println()
|
||||
fmt.Println("用法:")
|
||||
fmt.Println(" google-index inspect <url> - 检查单个URL的索引状态")
|
||||
fmt.Println(" google-index batch <file> - 批量检查URL状态")
|
||||
fmt.Println(" google-index sites - 列出已验证的网站")
|
||||
fmt.Println(" google-index analytics - 获取搜索分析数据")
|
||||
fmt.Println(" google-index sitemap <url> - 提交网站地图")
|
||||
fmt.Println()
|
||||
fmt.Println("配置:")
|
||||
fmt.Println(" - 创建 credentials.json 文件 (Google Cloud Console 下载)")
|
||||
fmt.Println(" - 修改 config.SiteURL 为你的网站URL")
|
||||
}
|
||||
|
||||
func inspectSingleURL(client *google.Client, url string) {
|
||||
fmt.Printf("正在检查URL: %s\n", url)
|
||||
|
||||
result, err := client.InspectURL(url)
|
||||
if err != nil {
|
||||
log.Printf("检查失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
printResult(url, result)
|
||||
}
|
||||
|
||||
func batchInspectURLs(client *google.Client, filename string) {
|
||||
urls, err := readURLsFromFile(filename)
|
||||
if err != nil {
|
||||
log.Fatalf("读取URL文件失败: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf("开始批量检查 %d 个URL...\n", len(urls))
|
||||
|
||||
results := make(chan struct {
|
||||
url string
|
||||
result *google.URLInspectionResult
|
||||
err error
|
||||
}, len(urls))
|
||||
|
||||
client.BatchInspectURL(urls, func(url string, result *google.URLInspectionResult, err error) {
|
||||
results <- struct {
|
||||
url string
|
||||
result *google.URLInspectionResult
|
||||
err error
|
||||
}{url, result, err}
|
||||
})
|
||||
|
||||
// 收集并打印结果
|
||||
fmt.Println("\n检查结果:")
|
||||
fmt.Println(strings.Repeat("-", 100))
|
||||
fmt.Printf("%-50s %-15s %-15s %-20s\n", "URL", "索引状态", "移动友好", "最后抓取")
|
||||
fmt.Println(strings.Repeat("-", 100))
|
||||
|
||||
for i := 0; i < len(urls); i++ {
|
||||
res := <-results
|
||||
if res.err != nil {
|
||||
fmt.Printf("%-50s %-15s\n", truncate(res.url, 47), "ERROR")
|
||||
continue
|
||||
}
|
||||
|
||||
indexStatus := res.result.IndexStatusResult.IndexingState
|
||||
mobileFriendly := "否"
|
||||
if res.result.MobileUsabilityResult.MobileFriendly {
|
||||
mobileFriendly = "是"
|
||||
}
|
||||
lastCrawl := res.result.IndexStatusResult.LastCrawled
|
||||
if lastCrawl == "" {
|
||||
lastCrawl = "未知"
|
||||
}
|
||||
|
||||
fmt.Printf("%-50s %-15s %-15s %-20s\n",
|
||||
truncate(res.url, 47), indexStatus, mobileFriendly, lastCrawl)
|
||||
}
|
||||
|
||||
fmt.Println(strings.Repeat("-", 100))
|
||||
}
|
||||
|
||||
func listSites(client *google.Client) {
|
||||
sites, err := client.GetSites()
|
||||
if err != nil {
|
||||
log.Printf("获取网站列表失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("已验证的网站:")
|
||||
fmt.Println(strings.Repeat("-", 80))
|
||||
fmt.Printf("%-50s %-15s %-15s\n", "网站URL", "权限级别", "验证状态")
|
||||
fmt.Println(strings.Repeat("-", 80))
|
||||
|
||||
for _, site := range sites {
|
||||
permissionLevel := string(site.PermissionLevel)
|
||||
verified := "否"
|
||||
if site.SiteUrl == client.SiteURL {
|
||||
verified = "是"
|
||||
}
|
||||
|
||||
fmt.Printf("%-50s %-15s %-15s\n",
|
||||
truncate(site.SiteUrl, 47), permissionLevel, verified)
|
||||
}
|
||||
|
||||
fmt.Println(strings.Repeat("-", 80))
|
||||
}
|
||||
|
||||
func getAnalytics(client *google.Client) {
|
||||
endDate := time.Now().Format("2006-01-02")
|
||||
startDate := time.Now().AddDate(0, -1, 0).Format("2006-01-02") // 最近30天
|
||||
|
||||
fmt.Printf("获取搜索分析数据 (%s 到 %s)...\n", startDate, endDate)
|
||||
|
||||
analytics, err := client.GetSearchAnalytics(startDate, endDate)
|
||||
if err != nil {
|
||||
log.Printf("获取分析数据失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 计算总计数据
|
||||
var totalClicks, totalImpressions float64
|
||||
var totalPosition float64
|
||||
|
||||
for _, row := range analytics.Rows {
|
||||
totalClicks += row.Clicks
|
||||
totalImpressions += row.Impressions
|
||||
totalPosition += row.Position
|
||||
}
|
||||
|
||||
avgCTR := float64(0)
|
||||
if totalImpressions > 0 {
|
||||
avgCTR = float64(totalClicks) / float64(totalImpressions) * 100
|
||||
}
|
||||
|
||||
avgPosition := float64(0)
|
||||
if len(analytics.Rows) > 0 {
|
||||
avgPosition = totalPosition / float64(len(analytics.Rows))
|
||||
}
|
||||
|
||||
fmt.Println("\n搜索分析摘要:")
|
||||
fmt.Println(strings.Repeat("-", 60))
|
||||
fmt.Printf("总点击数: %.0f\n", totalClicks)
|
||||
fmt.Printf("总展示次数: %.0f\n", totalImpressions)
|
||||
fmt.Printf("平均点击率: %.2f%%\n", avgCTR)
|
||||
fmt.Printf("平均排名: %.1f\n", avgPosition)
|
||||
fmt.Println(strings.Repeat("-", 60))
|
||||
|
||||
// 显示前10个页面
|
||||
if len(analytics.Rows) > 0 {
|
||||
fmt.Println("\n热门页面 (前10):")
|
||||
fmt.Printf("%-50s %-10s %-10s %-10s\n", "页面", "点击", "展示", "排名")
|
||||
fmt.Println(strings.Repeat("-", 80))
|
||||
|
||||
maxRows := len(analytics.Rows)
|
||||
if maxRows > 10 {
|
||||
maxRows = 10
|
||||
}
|
||||
|
||||
for i := 0; i < maxRows; i++ {
|
||||
row := analytics.Rows[i]
|
||||
fmt.Printf("%-50s %-10d %-10d %-10.1f\n",
|
||||
truncate(row.Keys[0], 47), row.Clicks, row.Impressions, row.Position)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func submitSitemap(client *google.Client, sitemapURL string) {
|
||||
fmt.Printf("正在提交网站地图: %s\n", sitemapURL)
|
||||
|
||||
err := client.SubmitSitemap(sitemapURL)
|
||||
if err != nil {
|
||||
log.Printf("提交网站地图失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("网站地图提交成功!")
|
||||
}
|
||||
|
||||
func readURLsFromFile(filename string) ([]string, error) {
|
||||
data, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
lines := strings.Split(string(data), "\n")
|
||||
var urls []string
|
||||
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" && !strings.HasPrefix(line, "#") {
|
||||
urls = append(urls, line)
|
||||
}
|
||||
}
|
||||
|
||||
return urls, nil
|
||||
}
|
||||
|
||||
func printResult(url string, result *google.URLInspectionResult) {
|
||||
fmt.Println("\nURL检查结果:")
|
||||
fmt.Println(strings.Repeat("=", 80))
|
||||
fmt.Printf("URL: %s\n", url)
|
||||
fmt.Println(strings.Repeat("-", 80))
|
||||
|
||||
fmt.Printf("索引状态: %s\n", result.IndexStatusResult.IndexingState)
|
||||
if result.IndexStatusResult.LastCrawled != "" {
|
||||
fmt.Printf("最后抓取: %s\n", result.IndexStatusResult.LastCrawled)
|
||||
}
|
||||
|
||||
fmt.Printf("移动友好: %t\n", result.MobileUsabilityResult.MobileFriendly)
|
||||
|
||||
if len(result.RichResultsResult.Detected.Items) > 0 {
|
||||
fmt.Println("富媒体结果:")
|
||||
for _, item := range result.RichResultsResult.Detected.Items {
|
||||
fmt.Printf(" - %s\n", item.RichResultType)
|
||||
}
|
||||
}
|
||||
|
||||
if len(result.IndexStatusResult.CrawlErrors) > 0 {
|
||||
fmt.Println("抓取错误:")
|
||||
for _, err := range result.IndexStatusResult.CrawlErrors {
|
||||
fmt.Printf(" - %s\n", err.ErrorCode)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println(strings.Repeat("=", 80))
|
||||
}
|
||||
|
||||
func truncate(s string, length int) string {
|
||||
if len(s) <= length {
|
||||
return s
|
||||
}
|
||||
return s[:length-3] + "..."
|
||||
}
|
||||
39
common/xunlei_credentials.go
Normal file
39
common/xunlei_credentials.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package pan
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// XunleiAccountCredentials 迅雷账号凭据结构
|
||||
type XunleiAccountCredentials struct {
|
||||
Username string `json:"username"` // 手机号(不包含+86前缀)
|
||||
Password string `json:"password"` // 密码
|
||||
RefreshToken string `json:"refresh_token"` // 当前有效的refresh_token
|
||||
}
|
||||
|
||||
// ParseCredentialsFromCk 从ck字段解析账号凭据
|
||||
func ParseCredentialsFromCk(ck string) (*XunleiAccountCredentials, error) {
|
||||
var credentials XunleiAccountCredentials
|
||||
if err := json.Unmarshal([]byte(ck), &credentials); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &credentials, nil
|
||||
}
|
||||
|
||||
// IsAccountCredentials 检查ck是否包含账号密码信息
|
||||
func IsAccountCredentials(ck string) bool {
|
||||
var credentials map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(ck), &credentials); err != nil {
|
||||
return false
|
||||
}
|
||||
_, hasUsername := credentials["username"]
|
||||
_, hasPassword := credentials["password"]
|
||||
return hasUsername && hasPassword
|
||||
}
|
||||
|
||||
// ToJsonString 转换为JSON字符串
|
||||
func (c *XunleiAccountCredentials) ToJsonString() (string, error) {
|
||||
data, err := json.Marshal(c)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
232
common/xunlei_login.go
Normal file
232
common/xunlei_login.go
Normal file
@@ -0,0 +1,232 @@
|
||||
package pan
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 新增常量定义
|
||||
const (
|
||||
XLUSER_CLIENT_ID = "XW5SkOhLDjnOZP7J" // 登录
|
||||
PAN_CLIENT_ID = "Xqp0kJBXWhwaTpB6" // 获取文件列表
|
||||
CLIENT_SECRET = "Og9Vr1L8Ee6bh0olFxFDRg"
|
||||
CLIENT_VERSION = "1.92.9" // 更新为与xunlei_3项目相同的版本
|
||||
PACKAG_ENAME = "pan.xunlei.com"
|
||||
)
|
||||
|
||||
var SALTS = []string{
|
||||
"QG3/GhopO+5+T",
|
||||
"1Sv94+ANND3lDmmw",
|
||||
"q2eTxRva8b3B5d",
|
||||
"m2",
|
||||
"VIc5CZRBMU71ENfbOh0+RgWIuzLy",
|
||||
"66M8Wpw6nkBEekOtL6e",
|
||||
"N0rucK7S8W/vrRkfPto5urIJJS8dVY0S",
|
||||
"oLAR7pdUVUAp9xcuHWzrU057aUhdCJrt",
|
||||
"6lxcykBSsfI//GR9",
|
||||
"r50cz+1I4gbU/fk8",
|
||||
"tdwzrTc4SNFC4marNGTgf05flC85A",
|
||||
"qvNVUDFjfsOMqvdi2gB8gCvtaJAIqxXs",
|
||||
}
|
||||
|
||||
// captchaSign 生成验证码签名 - 完全复制自xunlei_3项目
|
||||
func (x *XunleiPanService) captchaSign(clientId string, deviceID string, timestamp string) string {
|
||||
sign := clientId + CLIENT_VERSION + PACKAG_ENAME + deviceID + timestamp
|
||||
log.Printf("urldb 签名基础字符串: %s", sign)
|
||||
for _, salt := range SALTS { // salt =
|
||||
hash := md5.Sum([]byte(sign + salt))
|
||||
sign = hex.EncodeToString(hash[:])
|
||||
}
|
||||
log.Printf("urldb 最终签名: 1.%s", sign)
|
||||
return fmt.Sprintf("1.%s", sign)
|
||||
}
|
||||
|
||||
// getTimestamp 获取当前时间戳
|
||||
func (x *XunleiPanService) getTimestamp() int64 {
|
||||
return time.Now().UnixMilli()
|
||||
}
|
||||
|
||||
// LoginWithCredentials 使用账号密码登录
|
||||
func (x *XunleiPanService) LoginWithCredentials(username, password string) (XunleiTokenData, error) {
|
||||
loginURL := "https://xluser-ssl.xunlei.com/v1/auth/signin"
|
||||
|
||||
// 初始化验证码 - 完全模仿xunlei_3的CaptchaInit方法
|
||||
captchaURL := "https://xluser-ssl.xunlei.com/v1/shield/captcha/init"
|
||||
|
||||
// 构造meta参数(完全模仿xunlei_3,只包含phone_number)
|
||||
meta := map[string]interface{}{
|
||||
"phone_number": "+86" + username,
|
||||
}
|
||||
|
||||
// 构造验证码请求(完全模仿xunlei_3)
|
||||
captchaBody := map[string]interface{}{
|
||||
"client_id": XLUSER_CLIENT_ID,
|
||||
"action": "POST:/v1/auth/signin",
|
||||
"device_id": x.deviceId,
|
||||
"meta": meta,
|
||||
}
|
||||
|
||||
log.Printf("发送验证码初始化请求: %+v", captchaBody)
|
||||
resp, err := x.sendCaptchaRequest(captchaURL, captchaBody)
|
||||
if err != nil {
|
||||
return XunleiTokenData{}, fmt.Errorf("获取验证码失败: %v", err)
|
||||
}
|
||||
|
||||
if resp["captcha_token"] == nil {
|
||||
return XunleiTokenData{}, fmt.Errorf("获取验证码失败: 响应中没有captcha_token")
|
||||
}
|
||||
|
||||
captchaToken, ok := resp["captcha_token"].(string)
|
||||
if !ok {
|
||||
return XunleiTokenData{}, fmt.Errorf("获取验证码失败: captcha_token格式错误")
|
||||
}
|
||||
log.Printf("成功获取captcha_token: %s", captchaToken)
|
||||
|
||||
// 构造登录请求数据
|
||||
loginData := map[string]interface{}{
|
||||
"client_id": XLUSER_CLIENT_ID,
|
||||
"client_secret": CLIENT_SECRET,
|
||||
"password": password,
|
||||
"username": "+86 " + username,
|
||||
"captcha_token": captchaToken,
|
||||
}
|
||||
|
||||
// 发送登录请求
|
||||
userInfo, err := x.sendCaptchaRequest(loginURL, loginData)
|
||||
if err != nil {
|
||||
return XunleiTokenData{}, fmt.Errorf("登录请求失败: %v", err)
|
||||
}
|
||||
|
||||
// 提取token信息
|
||||
accessToken, ok := userInfo["access_token"].(string)
|
||||
if !ok {
|
||||
return XunleiTokenData{}, fmt.Errorf("登录响应中没有access_token")
|
||||
}
|
||||
|
||||
refreshToken, ok := userInfo["refresh_token"].(string)
|
||||
if !ok {
|
||||
return XunleiTokenData{}, fmt.Errorf("登录响应中没有refresh_token")
|
||||
}
|
||||
|
||||
sub, ok := userInfo["sub"].(string)
|
||||
if !ok {
|
||||
sub = ""
|
||||
}
|
||||
|
||||
// 计算过期时间
|
||||
expiresIn := int64(3600) // 默认1小时
|
||||
if exp, ok := userInfo["expires_in"].(float64); ok {
|
||||
expiresIn = int64(exp)
|
||||
}
|
||||
expiresAt := time.Now().Unix() + expiresIn - 60 // 减去60秒缓冲
|
||||
|
||||
log.Printf("登录成功,获取到token")
|
||||
return XunleiTokenData{
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
ExpiresIn: expiresIn,
|
||||
ExpiresAt: expiresAt,
|
||||
Sub: sub,
|
||||
TokenType: "Bearer",
|
||||
UserId: sub,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// sendCaptchaRequest 发送验证码请求 - 完全复制xunlei_3的sendRequest实现
|
||||
func (x *XunleiPanService) sendCaptchaRequest(url string, data map[string]interface{}) (map[string]interface{}, error) {
|
||||
jsonData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Printf("发送验证码请求URL: %s", url)
|
||||
log.Printf("发送验证码请求数据: %s", string(jsonData))
|
||||
|
||||
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 完全复制xunlei_3的请求头设置
|
||||
reqHeaders := x.getHeadersForRequest(nil)
|
||||
// 添加特定的headers
|
||||
reqHeaders["Content-Type"] = "application/x-www-form-urlencoded"
|
||||
reqHeaders["User-Agent"] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
|
||||
|
||||
for k, v := range reqHeaders {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
|
||||
// 根据URL确定使用哪个client_id
|
||||
if strings.Contains(url, "shield/captcha/init") {
|
||||
// 对于验证码初始化,如果数据中指定了client_id,则使用该client_id
|
||||
if clientID, ok := data["client_id"].(string); ok {
|
||||
req.Header.Set("X-Client-Id", clientID)
|
||||
} else {
|
||||
// 默认使用PAN_CLIENT_ID用于API相关的验证码
|
||||
req.Header.Set("X-Client-Id", PAN_CLIENT_ID)
|
||||
}
|
||||
} else if strings.Contains(url, "auth/") {
|
||||
// 对于认证相关的请求,使用登录相关的client_id
|
||||
req.Header.Set("X-Client-Id", XLUSER_CLIENT_ID)
|
||||
} else {
|
||||
// 对于一般的API请求,使用PAN_CLIENT_ID
|
||||
req.Header.Set("X-Client-Id", PAN_CLIENT_ID)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Printf("验证码响应状态码: %d", resp.StatusCode)
|
||||
log.Printf("验证码响应内容: %s", string(body))
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("JSON 解析失败: %v, raw: %s", err, string(body))
|
||||
}
|
||||
|
||||
log.Printf("解析后的响应: %+v", result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// getHeadersForRequest 获取请求头
|
||||
func (x *XunleiPanService) getHeadersForRequest(accessToken *string) map[string]string {
|
||||
headers := map[string]string{
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
}
|
||||
|
||||
// 这里我们简化处理,因为验证码请求不需要这些
|
||||
// if x.CaptchaToken != nil {
|
||||
// headers["User-Agent"] = x.buildCustomUserAgent()
|
||||
// headers["X-Captcha-Token"] = *x.CaptchaToken
|
||||
// } else {
|
||||
headers["User-Agent"] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
|
||||
// }
|
||||
|
||||
// if accessToken != nil {
|
||||
// headers["Authorization"] = fmt.Sprintf("Bearer %s", *accessToken)
|
||||
// }
|
||||
|
||||
// if x.DeviceID != "" {
|
||||
// headers["X-Device-Id"] = x.DeviceID
|
||||
// }
|
||||
|
||||
return headers
|
||||
}
|
||||
897
common/xunlei_pan.bak
Normal file
897
common/xunlei_pan.bak
Normal file
@@ -0,0 +1,897 @@
|
||||
package pan
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/ctwj/urldb/db/entity"
|
||||
"github.com/ctwj/urldb/db/repo"
|
||||
)
|
||||
|
||||
// CaptchaData 存储在数据库中的验证码令牌数据
|
||||
type CaptchaData struct {
|
||||
CaptchaToken string `json:"captcha_token"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
}
|
||||
|
||||
// XunleiExtraData 所有额外数据的容器
|
||||
type XunleiTokenData struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
Sub string `json:"sub"`
|
||||
TokenType string `json:"token_type"`
|
||||
UserId string `json:"user_id"`
|
||||
}
|
||||
|
||||
type XunleiExtraData struct {
|
||||
Captcha *CaptchaData
|
||||
Token *XunleiTokenData
|
||||
}
|
||||
|
||||
type XunleiPanService struct {
|
||||
*BasePanService
|
||||
configMutex sync.RWMutex
|
||||
clientId string
|
||||
deviceId string
|
||||
entity entity.Cks
|
||||
cksRepo repo.CksRepository
|
||||
extra XunleiExtraData // 需要保存到数据库的token信息
|
||||
}
|
||||
|
||||
// 配置化 API Host
|
||||
func (x *XunleiPanService) apiHost(apiType string) string {
|
||||
if apiType == "user" {
|
||||
return "https://xluser-ssl.xunlei.com"
|
||||
}
|
||||
return "https://api-pan.xunlei.com"
|
||||
}
|
||||
|
||||
func (x *XunleiPanService) setCommonHeader(req *http.Request) {
|
||||
for k, v := range x.headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
// NewXunleiPanService 创建迅雷网盘服务
|
||||
func NewXunleiPanService(config *PanConfig) *XunleiPanService {
|
||||
xunleiInstance := &XunleiPanService{
|
||||
BasePanService: NewBasePanService(config),
|
||||
clientId: "Xqp0kJBXWhwaTpB6",
|
||||
deviceId: "925b7631473a13716b791d7f28289cad",
|
||||
extra: XunleiExtraData{}, // Initialize extra with zero values
|
||||
}
|
||||
xunleiInstance.SetHeaders(map[string]string{
|
||||
"Accept": "*/;",
|
||||
"Accept-Encoding": "deflate",
|
||||
"Accept-Language": "zh-CN,zh;q=0.9",
|
||||
"Cache-Control": "no-cache",
|
||||
"Content-Type": "application/json",
|
||||
"Origin": "https://pan.xunlei.com",
|
||||
"Pragma": "no-cache",
|
||||
"Priority": "u=1,i",
|
||||
"Referer": "https://pan.xunlei.com/",
|
||||
"sec-ch-ua": `"Not;A=Brand";v="99", "Google Chrome";v="139", "Chromium";v="139"`,
|
||||
"sec-ch-ua-mobile": "?0",
|
||||
"sec-ch-ua-platform": `"Windows"`,
|
||||
"sec-fetch-dest": "empty",
|
||||
"sec-fetch-mode": "cors",
|
||||
"sec-fetch-site": "same-site",
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36",
|
||||
"Authorization": "",
|
||||
"x-captcha-token": "",
|
||||
"x-client-id": xunleiInstance.clientId,
|
||||
"x-device-id": xunleiInstance.deviceId,
|
||||
})
|
||||
|
||||
xunleiInstance.UpdateConfig(config)
|
||||
return xunleiInstance
|
||||
}
|
||||
|
||||
// SetCKSRepository 设置 CksRepository 和 entity
|
||||
func (x *XunleiPanService) SetCKSRepository(cksRepo repo.CksRepository, entity entity.Cks) {
|
||||
x.cksRepo = cksRepo
|
||||
x.entity = entity
|
||||
var extra XunleiExtraData
|
||||
if err := json.Unmarshal([]byte(x.entity.Extra), &extra); err != nil {
|
||||
log.Printf("解析 extra 数据失败: %v,使用空数据", err)
|
||||
}
|
||||
x.extra = extra
|
||||
}
|
||||
|
||||
// GetXunleiInstance 获取迅雷网盘服务单例实例
|
||||
func GetXunleiInstance() *XunleiPanService {
|
||||
return NewXunleiPanService(nil)
|
||||
}
|
||||
|
||||
func (x *XunleiPanService) GetAccessTokenByRefreshToken(refreshToken string) (XunleiTokenData, error) {
|
||||
// 构造请求体
|
||||
body := map[string]interface{}{
|
||||
"client_id": x.clientId,
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": refreshToken,
|
||||
}
|
||||
|
||||
// 过滤 headers(移除 Authorization 和 x-captcha-token)
|
||||
filteredHeaders := make(map[string]string)
|
||||
for k, v := range x.headers {
|
||||
if k != "Authorization" && k != "x-captcha-token" {
|
||||
filteredHeaders[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
// 调用 API 获取新的 token
|
||||
resp, err := x.requestXunleiApi("https://xluser-ssl.xunlei.com/v1/auth/token", "POST", body, nil, filteredHeaders)
|
||||
if err != nil {
|
||||
return XunleiTokenData{}, fmt.Errorf("获取 access_token 请求失败: %v", err)
|
||||
}
|
||||
|
||||
// 正确做法:用 exists 判断
|
||||
if _, exists := resp["access_token"]; exists {
|
||||
// 会输出,即使值为 nil
|
||||
} else {
|
||||
return XunleiTokenData{}, fmt.Errorf("获取 access_token 请求失败: %v 不存在", "access_token")
|
||||
}
|
||||
|
||||
// 计算过期时间(当前时间 + expires_in - 60 秒缓冲)
|
||||
currentTime := time.Now().Unix()
|
||||
expiresAt := currentTime + int64(resp["expires_in"].(float64)) - 60
|
||||
resp["expires_at"] = expiresAt
|
||||
jsonBytes, _ := json.Marshal(resp)
|
||||
|
||||
var result XunleiTokenData
|
||||
json.Unmarshal(jsonBytes, &result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// getAccessToken 获取 Access Token(内部包含缓存判断、刷新、保存)- 匹配 PHP 版本
|
||||
func (x *XunleiPanService) getAccessToken() (string, error) {
|
||||
// 检查 Access Token 是否有效
|
||||
currentTime := time.Now().Unix()
|
||||
if x.extra.Token != nil && x.extra.Token.AccessToken != "" && x.extra.Token.ExpiresAt > currentTime {
|
||||
return x.extra.Token.AccessToken, nil
|
||||
}
|
||||
newData, err := x.GetAccessTokenByRefreshToken(x.extra.Token.RefreshToken)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("获取 access_token 失败: %v", err)
|
||||
}
|
||||
|
||||
x.extra.Token.AccessToken = newData.AccessToken
|
||||
x.extra.Token.ExpiresAt = newData.ExpiresAt
|
||||
|
||||
// 保存到数据库
|
||||
extraBytes, err := json.Marshal(x.extra)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("序列化 extra 数据失败: %v", err)
|
||||
}
|
||||
x.entity.Extra = string(extraBytes)
|
||||
if err := x.cksRepo.UpdateWithAllFields(&x.entity); err != nil {
|
||||
return "", fmt.Errorf("保存 access_token 到数据库失败: %v", err)
|
||||
}
|
||||
return newData.AccessToken, nil
|
||||
}
|
||||
|
||||
// getCaptchaToken 获取 captcha_token - 匹配 PHP 版本
|
||||
func (x *XunleiPanService) getCaptchaToken() (string, error) {
|
||||
// 检查 Captcha Token 是否有效
|
||||
currentTime := time.Now().Unix()
|
||||
if x.extra.Captcha != nil && x.extra.Captcha.CaptchaToken != "" && x.extra.Captcha.ExpiresAt > currentTime {
|
||||
return x.extra.Captcha.CaptchaToken, nil
|
||||
}
|
||||
|
||||
// 构造请求体
|
||||
body := map[string]interface{}{
|
||||
"client_id": x.clientId,
|
||||
"action": "get:/drive/v1/share",
|
||||
"device_id": x.deviceId,
|
||||
"meta": map[string]interface{}{
|
||||
"username": "",
|
||||
"phone_number": "",
|
||||
"email": "",
|
||||
"package_name": "pan.xunlei.com",
|
||||
"client_version": "1.45.0",
|
||||
"captcha_sign": "1.fe2108ad808a74c9ac0243309242726c",
|
||||
"timestamp": "1645241033384",
|
||||
"user_id": "0",
|
||||
},
|
||||
}
|
||||
|
||||
captchaHeaders := map[string]string{
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
|
||||
}
|
||||
|
||||
// 调用 API 获取 captcha_token
|
||||
resp, err := x.requestXunleiApi("https://xluser-ssl.xunlei.com/v1/shield/captcha/init", "POST", body, nil, captchaHeaders)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("获取 captcha_token 请求失败: %v", err)
|
||||
}
|
||||
|
||||
if resp["captcha_token"] != nil && resp["captcha_token"] != "" {
|
||||
//
|
||||
} else {
|
||||
return "", fmt.Errorf("获取 captcha_token 失败: %v", resp)
|
||||
}
|
||||
|
||||
// 计算过期时间(当前时间 + expires_in - 10 秒缓冲)
|
||||
expiresAt := currentTime + int64(resp["expires_in"].(float64)) - 10
|
||||
|
||||
// 更新 extra 数据
|
||||
if x.extra.Captcha == nil {
|
||||
x.extra.Captcha = &CaptchaData{}
|
||||
}
|
||||
x.extra.Captcha.CaptchaToken = resp["captcha_token"].(string)
|
||||
x.extra.Captcha.ExpiresAt = expiresAt
|
||||
|
||||
// 保存到数据库
|
||||
extraBytes, err := json.Marshal(x.extra)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("序列化 extra 数据失败: %v", err)
|
||||
}
|
||||
x.entity.Extra = string(extraBytes)
|
||||
if err := x.cksRepo.UpdateWithAllFields(&x.entity); err != nil {
|
||||
return "", fmt.Errorf("保存 captcha_token 到数据库失败: %v", err)
|
||||
}
|
||||
|
||||
return resp["captcha_token"].(string), nil
|
||||
}
|
||||
|
||||
// requestXunleiApi 迅雷 API 通用请求方法 - 使用 BasePanService 方法
|
||||
func (x *XunleiPanService) requestXunleiApi(url string, method string, data map[string]interface{}, queryParams map[string]string, headers map[string]string) (map[string]interface{}, error) {
|
||||
var respData []byte
|
||||
var err error
|
||||
|
||||
// 先更新当前请求的 headers
|
||||
originalHeaders := make(map[string]string)
|
||||
for k, v := range x.headers {
|
||||
originalHeaders[k] = v
|
||||
}
|
||||
|
||||
// 临时设置请求的 headers
|
||||
for k, v := range headers {
|
||||
x.SetHeader(k, v)
|
||||
}
|
||||
defer func() {
|
||||
// 恢复原始 headers
|
||||
for k, v := range originalHeaders {
|
||||
x.SetHeader(k, v)
|
||||
}
|
||||
}()
|
||||
|
||||
// 根据方法调用相应的 BasePanService 方法
|
||||
if method == "GET" {
|
||||
respData, err = x.HTTPGet(url, queryParams)
|
||||
} else if method == "POST" {
|
||||
respData, err = x.HTTPPost(url, data, queryParams)
|
||||
} else {
|
||||
return nil, fmt.Errorf("不支持的HTTP方法: %s", method)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(respData, &result); err != nil {
|
||||
return nil, fmt.Errorf("JSON 解析失败: %v, raw: %s", err, string(respData))
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (x *XunleiPanService) UpdateConfig(config *PanConfig) {
|
||||
if config == nil {
|
||||
return
|
||||
}
|
||||
x.configMutex.Lock()
|
||||
defer x.configMutex.Unlock()
|
||||
x.config = config
|
||||
if config.Cookie != "" {
|
||||
x.SetHeader("Cookie", config.Cookie)
|
||||
}
|
||||
}
|
||||
|
||||
// GetServiceType 获取服务类型
|
||||
func (x *XunleiPanService) GetServiceType() ServiceType {
|
||||
return Xunlei
|
||||
}
|
||||
|
||||
func extractCode(url string) string {
|
||||
// 查找 pwd= 的位置
|
||||
if pwdIndex := strings.Index(url, "pwd="); pwdIndex != -1 {
|
||||
code := url[pwdIndex+4:]
|
||||
// 移除 # 及后面的内容(如果存在)
|
||||
if hashIndex := strings.Index(code, "#"); hashIndex != -1 {
|
||||
code = code[:hashIndex]
|
||||
}
|
||||
return code
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Transfer 转存分享链接 - 实现 PanService 接口,匹配 XunleiPan.php 的逻辑
|
||||
func (x *XunleiPanService) Transfer(shareID string) (*TransferResult, error) {
|
||||
// 读取配置(线程安全)
|
||||
x.configMutex.RLock()
|
||||
config := x.config
|
||||
x.configMutex.RUnlock()
|
||||
|
||||
log.Printf("开始处理迅雷分享: %s", shareID)
|
||||
|
||||
// 1️⃣ 获取 AccessToken 和 CaptchaToken
|
||||
accessToken, err := x.getAccessToken()
|
||||
if err != nil {
|
||||
return ErrorResult(fmt.Sprintf("获取accessToken失败: %v", err)), nil
|
||||
}
|
||||
|
||||
captchaToken, err := x.getCaptchaToken()
|
||||
if err != nil {
|
||||
return ErrorResult(fmt.Sprintf("获取captchaToken失败: %v", err)), nil
|
||||
}
|
||||
|
||||
// 转存模式:实现完整的转存流程
|
||||
thisCode := extractCode(config.URL)
|
||||
|
||||
// 获取分享详情
|
||||
shareDetail, err := x.getShare(shareID, thisCode, accessToken, captchaToken)
|
||||
if err != nil {
|
||||
return ErrorResult(fmt.Sprintf("获取分享详情失败: %v", err)), nil
|
||||
}
|
||||
if shareDetail["share_status"].(string) != "OK" {
|
||||
return ErrorResult(fmt.Sprintf("获取分享详情失败: %v", "分享状态异常")), nil
|
||||
}
|
||||
if shareDetail["file_num"].(string) == "0" {
|
||||
return ErrorResult(fmt.Sprintf("获取分享详情失败: %v", "文件列表为空")), nil
|
||||
}
|
||||
|
||||
parent_id := "" // 默认存储路径
|
||||
|
||||
// 检查是否为检验模式
|
||||
if config.IsType == 1 {
|
||||
// 检验模式:直接获取分享信息
|
||||
urls := map[string]interface{}{
|
||||
"title": shareDetail["title"],
|
||||
"share_url": config.URL,
|
||||
"stoken": "",
|
||||
}
|
||||
return SuccessResult("检验成功", urls), nil
|
||||
}
|
||||
|
||||
// files := shareDetail["files"].([]interface{})
|
||||
// fileIDs := make([]string, 0)
|
||||
// for _, file := range files {
|
||||
// fileMap := file.(map[string]interface{})
|
||||
// if fid, ok := fileMap["id"].(string); ok {
|
||||
// fileIDs = append(fileIDs, fid)
|
||||
// }
|
||||
// }
|
||||
|
||||
// 处理广告过滤(这里简化处理)
|
||||
// TODO: 添加广告文件过滤逻辑
|
||||
|
||||
// 转存资源
|
||||
restoreResult, err := x.getRestore(shareID, shareDetail, accessToken, captchaToken, parent_id)
|
||||
if err != nil {
|
||||
return ErrorResult(fmt.Sprintf("转存失败: %v", err)), nil
|
||||
}
|
||||
|
||||
// 获取转存任务信息
|
||||
taskID := restoreResult["restore_task_id"].(string)
|
||||
|
||||
// 等待转存完成
|
||||
taskResp, err := x.waitForTask(taskID, accessToken, captchaToken)
|
||||
if err != nil {
|
||||
return ErrorResult(fmt.Sprintf("等待转存完成失败: %v", err)), nil
|
||||
}
|
||||
|
||||
// 获取任务结果以获取文件ID
|
||||
existingFileIds := make([]string, 0)
|
||||
if params, ok2 := taskResp["params"].(map[string]interface{}); ok2 {
|
||||
if traceIds, ok3 := params["trace_file_ids"].(string); ok3 {
|
||||
traceData := make(map[string]interface{})
|
||||
json.Unmarshal([]byte(traceIds), &traceData)
|
||||
for _, fid := range traceData {
|
||||
existingFileIds = append(existingFileIds, fid.(string))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 创建分享链接
|
||||
expirationDays := "-1"
|
||||
if config.ExpiredType == 2 {
|
||||
expirationDays = "2"
|
||||
}
|
||||
|
||||
// 根据share_id获取到分享链接
|
||||
shareResult, err := x.getSharePassword(existingFileIds, accessToken, captchaToken, expirationDays)
|
||||
if err != nil {
|
||||
return ErrorResult(fmt.Sprintf("创建分享链接失败: %v", err)), nil
|
||||
}
|
||||
|
||||
var fid string
|
||||
if len(existingFileIds) > 1 {
|
||||
fid = strings.Join(existingFileIds, ",")
|
||||
} else {
|
||||
fid = existingFileIds[0]
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"title": "",
|
||||
"shareUrl": shareResult["share_url"].(string) + "?pwd=" + shareResult["pass_code"].(string),
|
||||
"code": shareResult["pass_code"].(string),
|
||||
"fid": fid,
|
||||
}
|
||||
|
||||
return SuccessResult("转存成功", result), nil
|
||||
}
|
||||
|
||||
// waitForTask 等待任务完成 - 使用 HTTPGet 方法
|
||||
func (x *XunleiPanService) waitForTask(taskID string, accessToken, captchaToken string) (map[string]interface{}, error) {
|
||||
maxRetries := 50
|
||||
retryDelay := 2 * time.Second
|
||||
|
||||
for retryIndex := 0; retryIndex < maxRetries; retryIndex++ {
|
||||
result, err := x.getTaskStatus(taskID, retryIndex, accessToken, captchaToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if int64(result["progress"].(float64)) == 100 { // 任务完成
|
||||
return result, nil
|
||||
}
|
||||
|
||||
time.Sleep(retryDelay)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("任务超时")
|
||||
}
|
||||
|
||||
// getTaskStatus 获取任务状态 - 使用 HTTPGet 方法
|
||||
func (x *XunleiPanService) getTaskStatus(taskID string, retryIndex int, accessToken, captchaToken string) (map[string]interface{}, error) {
|
||||
apiURL := x.apiHost("") + "/drive/v1/tasks/" + taskID
|
||||
queryParams := map[string]string{}
|
||||
|
||||
// 设置 request 所需的 headers
|
||||
headers := map[string]string{
|
||||
"Authorization": "Bearer " + accessToken,
|
||||
"x-captcha-token": captchaToken,
|
||||
}
|
||||
|
||||
resp, err := x.requestXunleiApi(apiURL, "GET", nil, queryParams, headers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// GetUserInfoByEntity 根据 entity.Cks 获取用户信息(待实现)
|
||||
func (x *XunleiPanService) GetUserInfoByEntity(cks entity.Cks) (*UserInfo, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// getShare 获取分享详情 - 匹配 PHP 版本
|
||||
func (x *XunleiPanService) getShare(shareID, passCode, accessToken, captchaToken string) (map[string]interface{}, error) {
|
||||
// 设置 headers
|
||||
headers := make(map[string]string)
|
||||
for k, v := range x.headers {
|
||||
headers[k] = v
|
||||
}
|
||||
headers["Authorization"] = "Bearer " + accessToken
|
||||
headers["x-captcha-token"] = captchaToken
|
||||
|
||||
queryParams := map[string]string{
|
||||
"share_id": shareID,
|
||||
"pass_code": passCode,
|
||||
"limit": "100",
|
||||
"pass_code_token": "",
|
||||
"page_token": "",
|
||||
"thumbnail_size": "SIZE_SMALL",
|
||||
}
|
||||
|
||||
return x.requestXunleiApi("https://api-pan.xunlei.com/drive/v1/share", "GET", nil, queryParams, headers)
|
||||
}
|
||||
|
||||
// getRestore 转存到网盘 - 匹配 PHP 版本
|
||||
func (x *XunleiPanService) getRestore(shareID string, infoData map[string]interface{}, accessToken, captchaToken, parentID string) (map[string]interface{}, error) {
|
||||
ids := make([]string, 0)
|
||||
if files, ok := infoData["files"].([]interface{}); ok {
|
||||
for _, file := range files {
|
||||
if fileMap, ok2 := file.(map[string]interface{}); ok2 {
|
||||
if id, ok3 := fileMap["id"].(string); ok3 {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
passCodeToken := ""
|
||||
if token, ok := infoData["pass_code_token"]; ok {
|
||||
if tokenStr, ok2 := token.(string); ok2 {
|
||||
passCodeToken = tokenStr
|
||||
}
|
||||
}
|
||||
|
||||
data := map[string]interface{}{
|
||||
"parent_id": parentID,
|
||||
"share_id": shareID,
|
||||
"pass_code_token": passCodeToken,
|
||||
"ancestor_ids": []string{},
|
||||
"specify_parent_id": true,
|
||||
"file_ids": ids,
|
||||
}
|
||||
|
||||
headers := make(map[string]string)
|
||||
for k, v := range x.headers {
|
||||
headers[k] = v
|
||||
}
|
||||
headers["Authorization"] = "Bearer " + accessToken
|
||||
headers["x-captcha-token"] = captchaToken
|
||||
|
||||
return x.requestXunleiApi("https://api-pan.xunlei.com/drive/v1/share/restore", "POST", data, nil, headers)
|
||||
}
|
||||
|
||||
// getTasks 获取转存任务状态 - 匹配 PHP 版本
|
||||
func (x *XunleiPanService) getTasks(taskID, accessToken, captchaToken string) (map[string]interface{}, error) {
|
||||
headers := make(map[string]string)
|
||||
for k, v := range x.headers {
|
||||
headers[k] = v
|
||||
}
|
||||
headers["Authorization"] = "Bearer " + accessToken
|
||||
headers["x-captcha-token"] = captchaToken
|
||||
|
||||
return x.requestXunleiApi("https://api-pan.xunlei.com/drive/v1/tasks/"+taskID, "GET", nil, nil, headers)
|
||||
}
|
||||
|
||||
// getSharePassword 创建分享链接 - 匹配 PHP 版本
|
||||
func (x *XunleiPanService) getSharePassword(fileIDs []string, accessToken, captchaToken, expirationDays string) (map[string]interface{}, error) {
|
||||
data := map[string]interface{}{
|
||||
"file_ids": fileIDs,
|
||||
"share_to": "copy",
|
||||
"params": map[string]interface{}{
|
||||
"subscribe_push": "false",
|
||||
"WithPassCodeInLink": "true",
|
||||
},
|
||||
"title": "云盘资源分享",
|
||||
"restore_limit": "-1",
|
||||
"expiration_days": expirationDays,
|
||||
}
|
||||
|
||||
headers := make(map[string]string)
|
||||
for k, v := range x.headers {
|
||||
headers[k] = v
|
||||
}
|
||||
headers["Authorization"] = "Bearer " + accessToken
|
||||
headers["x-captcha-token"] = captchaToken
|
||||
|
||||
return x.requestXunleiApi("https://api-pan.xunlei.com/drive/v1/share", "POST", data, nil, headers)
|
||||
}
|
||||
|
||||
// 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 获取文件列表 - 匹配 PHP 版本接口调用
|
||||
func (x *XunleiPanService) GetFiles(pdirFid string) (*TransferResult, error) {
|
||||
log.Printf("开始获取迅雷网盘文件列表,目录ID: %s", pdirFid)
|
||||
|
||||
// 获取 tokens
|
||||
accessToken, err := x.getAccessToken()
|
||||
if err != nil {
|
||||
return ErrorResult(fmt.Sprintf("获取accessToken失败: %v", err)), nil
|
||||
}
|
||||
|
||||
captchaToken, err := x.getCaptchaToken()
|
||||
if err != nil {
|
||||
return ErrorResult(fmt.Sprintf("获取captchaToken失败: %v", err)), nil
|
||||
}
|
||||
|
||||
// 设置 headers
|
||||
headers := make(map[string]string)
|
||||
for k, v := range x.headers {
|
||||
headers[k] = v
|
||||
}
|
||||
headers["Authorization"] = "Bearer " + accessToken
|
||||
headers["x-captcha-token"] = captchaToken
|
||||
|
||||
filters := map[string]interface{}{
|
||||
"phase": map[string]interface{}{
|
||||
"eq": "PHASE_TYPE_COMPLETE",
|
||||
},
|
||||
"trashed": map[string]interface{}{
|
||||
"eq": false,
|
||||
},
|
||||
}
|
||||
|
||||
filtersStr, _ := json.Marshal(filters)
|
||||
queryParams := map[string]string{
|
||||
"parent_id": pdirFid,
|
||||
"filters": string(filtersStr),
|
||||
"with_audit": "true",
|
||||
"thumbnail_size": "SIZE_SMALL",
|
||||
"limit": "50",
|
||||
}
|
||||
|
||||
result, err := x.requestXunleiApi("https://api-pan.xunlei.com/drive/v1/files", "GET", nil, queryParams, headers)
|
||||
if err != nil {
|
||||
return ErrorResult(fmt.Sprintf("获取文件列表失败: %v", err)), nil
|
||||
}
|
||||
|
||||
if code, ok := result["code"].(float64); ok && code != 0 {
|
||||
return ErrorResult("获取文件列表失败"), nil
|
||||
}
|
||||
|
||||
if data, ok := result["data"].(map[string]interface{}); ok {
|
||||
if files, ok2 := data["files"]; ok2 {
|
||||
return SuccessResult("获取成功", files), nil
|
||||
}
|
||||
}
|
||||
|
||||
return SuccessResult("获取成功", []interface{}{}), 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 接口,cookie 参数为 refresh_token,先获取 access_token 再访问 API
|
||||
func (x *XunleiPanService) GetUserInfo(cookie *string) (*UserInfo, error) {
|
||||
userInfo := &UserInfo{}
|
||||
accessToken, err := x.getAccessToken()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
captchaToken, err := x.getCaptchaToken()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
headers := make(map[string]string)
|
||||
for k, v := range x.headers {
|
||||
headers[k] = v
|
||||
}
|
||||
headers["Authorization"] = "Bearer " + accessToken
|
||||
headers["x-captcha-token"] = captchaToken
|
||||
|
||||
resp, err := x.requestXunleiApi("https://api-pan.xunlei.com/drive/v1/about", "GET", nil, nil, headers)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取用户信息失败: %v", err)
|
||||
}
|
||||
limit := resp["quota"].(map[string]interface{})["limit"].(string)
|
||||
limitInt, _ := strconv.ParseInt(limit, 10, 64)
|
||||
used := resp["quota"].(map[string]interface{})["usage"].(string)
|
||||
usedInt, _ := strconv.ParseInt(used, 10, 64)
|
||||
userInfo.TotalSpace = limitInt
|
||||
userInfo.UsedSpace = usedInt
|
||||
|
||||
// 获取用户信息
|
||||
respData, err := x.requestXunleiApi(x.apiHost("user")+"/v1/user/me", "GET", nil, nil, headers)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取用户信息失败: %v", err)
|
||||
}
|
||||
|
||||
vipInfo := respData["vip_info"].([]interface{})
|
||||
isVip := vipInfo[0].(map[string]interface{})["is_vip"].(string) != "0"
|
||||
|
||||
userInfo.Username = respData["name"].(string)
|
||||
userInfo.ServiceType = x.GetServiceType().String()
|
||||
userInfo.VIPStatus = isVip
|
||||
return userInfo, nil
|
||||
}
|
||||
|
||||
// GetShareList 严格对齐 GET + query(使用 BasePanService)
|
||||
func (x *XunleiPanService) GetShareList(pageToken string) (*XLShareListResp, error) {
|
||||
api := x.apiHost("") + "/drive/v1/share/list"
|
||||
queryParams := map[string]string{
|
||||
"limit": "100",
|
||||
"thumbnail_size": "SIZE_SMALL",
|
||||
}
|
||||
if pageToken != "" {
|
||||
queryParams["page_token"] = pageToken
|
||||
}
|
||||
|
||||
respData, err := x.HTTPGet(api, queryParams)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取分享列表失败: %v", err)
|
||||
}
|
||||
|
||||
var data XLShareListResp
|
||||
if err := json.Unmarshal(respData, &data); err != nil {
|
||||
return nil, fmt.Errorf("解析分享列表失败: %v", err)
|
||||
}
|
||||
return &data, nil
|
||||
}
|
||||
|
||||
// FileBatchShare 创建分享(使用 BasePanService)
|
||||
func (x *XunleiPanService) FileBatchShare(ids []string, needPassword bool, expirationDays int) (*XLBatchShareResp, error) {
|
||||
apiURL := x.apiHost("") + "/drive/v1/share/batch"
|
||||
body := map[string]interface{}{
|
||||
"file_ids": ids,
|
||||
"need_password": needPassword,
|
||||
"expiration_days": expirationDays,
|
||||
}
|
||||
|
||||
respData, err := x.HTTPPost(apiURL, body, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建分享失败: %v", err)
|
||||
}
|
||||
|
||||
var data XLBatchShareResp
|
||||
if err := json.Unmarshal(respData, &data); err != nil {
|
||||
return nil, fmt.Errorf("解析分享响应失败: %v", err)
|
||||
}
|
||||
return &data, nil
|
||||
}
|
||||
|
||||
// ShareBatchDelete 取消分享(使用 BasePanService)
|
||||
func (x *XunleiPanService) ShareBatchDelete(ids []string) (*XLCommonResp, error) {
|
||||
apiURL := x.apiHost("") + "/drive/v1/share/batch/delete"
|
||||
body := map[string]interface{}{
|
||||
"share_ids": ids,
|
||||
}
|
||||
|
||||
respData, err := x.HTTPPost(apiURL, body, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("删除分享失败: %v", err)
|
||||
}
|
||||
|
||||
var data XLCommonResp
|
||||
if err := json.Unmarshal(respData, &data); err != nil {
|
||||
return nil, fmt.Errorf("解析删除响应失败: %v", err)
|
||||
}
|
||||
return &data, nil
|
||||
}
|
||||
|
||||
// GetShareFolder 获取分享内容(使用 BasePanService)
|
||||
func (x *XunleiPanService) GetShareFolder(shareID, passCodeToken, parentID string) (*XLShareFolderResp, error) {
|
||||
apiURL := x.apiHost("") + "/drive/v1/share/detail"
|
||||
body := map[string]interface{}{
|
||||
"share_id": shareID,
|
||||
"pass_code_token": passCodeToken,
|
||||
"parent_id": parentID,
|
||||
"limit": 100,
|
||||
"thumbnail_size": "SIZE_LARGE",
|
||||
"order": "6",
|
||||
}
|
||||
|
||||
respData, err := x.HTTPPost(apiURL, body, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取分享文件夹失败: %v", err)
|
||||
}
|
||||
|
||||
var data XLShareFolderResp
|
||||
if err := json.Unmarshal(respData, &data); err != nil {
|
||||
return nil, fmt.Errorf("解析分享文件夹失败: %v", err)
|
||||
}
|
||||
return &data, nil
|
||||
}
|
||||
|
||||
// Restore 转存(使用 BasePanService)
|
||||
func (x *XunleiPanService) Restore(shareID, passCodeToken string, fileIDs []string) (*XLRestoreResp, error) {
|
||||
apiURL := x.apiHost("") + "/drive/v1/share/restore"
|
||||
body := map[string]interface{}{
|
||||
"share_id": shareID,
|
||||
"pass_code_token": passCodeToken,
|
||||
"file_ids": fileIDs,
|
||||
"folder_type": "NORMAL",
|
||||
"specify_parent_id": true,
|
||||
"parent_id": "",
|
||||
}
|
||||
|
||||
respData, err := x.HTTPPost(apiURL, body, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("转存失败: %v", err)
|
||||
}
|
||||
|
||||
var data XLRestoreResp
|
||||
if err := json.Unmarshal(respData, &data); err != nil {
|
||||
return nil, fmt.Errorf("解析转存响应失败: %v", err)
|
||||
}
|
||||
return &data, nil
|
||||
}
|
||||
|
||||
// 结构体完全对齐 xunleix
|
||||
type XLShareListResp struct {
|
||||
Data struct {
|
||||
List []struct {
|
||||
ShareID string `json:"share_id"`
|
||||
Title string `json:"title"`
|
||||
} `json:"list"`
|
||||
} `json:"data"`
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
|
||||
type XLBatchShareResp struct {
|
||||
Data struct {
|
||||
ShareURL string `json:"share_url"`
|
||||
} `json:"data"`
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
|
||||
type XLCommonResp struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
|
||||
type XLShareFolderResp struct {
|
||||
Data struct {
|
||||
Files []struct {
|
||||
FileID string `json:"file_id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"files"`
|
||||
} `json:"data"`
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
|
||||
type XLRestoreResp struct {
|
||||
Data struct {
|
||||
TaskID string `json:"task_id"`
|
||||
} `json:"data"`
|
||||
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"`
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package pan
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -32,8 +33,9 @@ type XunleiTokenData struct {
|
||||
}
|
||||
|
||||
type XunleiExtraData struct {
|
||||
Captcha *CaptchaData
|
||||
Token *XunleiTokenData
|
||||
Captcha *CaptchaData `json:"captcha,omitempty"`
|
||||
Token *XunleiTokenData `json:"token,omitempty"`
|
||||
Credentials *XunleiAccountCredentials `json:"credentials,omitempty"` // 账号密码信息
|
||||
}
|
||||
|
||||
type XunleiPanService struct {
|
||||
@@ -100,9 +102,19 @@ func (x *XunleiPanService) SetCKSRepository(cksRepo repo.CksRepository, entity e
|
||||
x.cksRepo = cksRepo
|
||||
x.entity = entity
|
||||
var extra XunleiExtraData
|
||||
if err := json.Unmarshal([]byte(x.entity.Extra), &extra); err != nil {
|
||||
log.Printf("解析 extra 数据失败: %v,使用空数据", err)
|
||||
|
||||
// 解析extra字段
|
||||
if x.entity.Extra != "" {
|
||||
if err := json.Unmarshal([]byte(x.entity.Extra), &extra); err != nil {
|
||||
log.Printf("解析 extra 数据失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 从ck字段解析账号密码
|
||||
if credentials, err := ParseCredentialsFromCk(x.entity.Ck); err == nil {
|
||||
extra.Credentials = credentials
|
||||
}
|
||||
|
||||
x.extra = extra
|
||||
}
|
||||
|
||||
@@ -151,20 +163,66 @@ func (x *XunleiPanService) GetAccessTokenByRefreshToken(refreshToken string) (Xu
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// getAccessToken 获取 Access Token(内部包含缓存判断、刷新、保存)- 匹配 PHP 版本
|
||||
// reloginWithCredentials 使用账号密码重新登录
|
||||
func (x *XunleiPanService) reloginWithCredentials() (XunleiTokenData, error) {
|
||||
if x.extra.Credentials == nil {
|
||||
return XunleiTokenData{}, fmt.Errorf("无账号密码信息")
|
||||
}
|
||||
|
||||
tokenData, err := x.LoginWithCredentials(x.extra.Credentials.Username, x.extra.Credentials.Password)
|
||||
if err != nil {
|
||||
return XunleiTokenData{}, fmt.Errorf("账号密码登录失败: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("账号 %s 重新登录成功", x.extra.Credentials.Username)
|
||||
return tokenData, nil
|
||||
}
|
||||
|
||||
// getAccessToken 获取 Access Token(内部包含缓存判断、刷新、重新登录、保存)
|
||||
func (x *XunleiPanService) getAccessToken() (string, error) {
|
||||
// 检查 Access Token 是否有效
|
||||
currentTime := time.Now().Unix()
|
||||
if x.extra.Token != nil && x.extra.Token.AccessToken != "" && x.extra.Token.ExpiresAt > currentTime {
|
||||
return x.extra.Token.AccessToken, nil
|
||||
}
|
||||
newData, err := x.GetAccessTokenByRefreshToken(x.extra.Token.RefreshToken)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("获取 access_token 失败: %v", err)
|
||||
|
||||
// 尝试使用refresh_token刷新
|
||||
var newData XunleiTokenData
|
||||
var err error
|
||||
|
||||
if x.extra.Token != nil && x.extra.Token.RefreshToken != "" {
|
||||
newData, err = x.GetAccessTokenByRefreshToken(x.extra.Token.RefreshToken)
|
||||
if err != nil {
|
||||
log.Printf("refresh_token刷新失败: %v,尝试使用账号密码重新登录", err)
|
||||
|
||||
// 如果refresh_token失效且有账号密码信息,尝试重新登录
|
||||
if x.extra.Credentials != nil && x.extra.Credentials.Username != "" && x.extra.Credentials.Password != "" {
|
||||
newData, err = x.reloginWithCredentials()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("重新登录失败: %v", err)
|
||||
}
|
||||
} else {
|
||||
return "", fmt.Errorf("refresh_token失效且无账号密码信息,无法重新登录: %v", err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return "", fmt.Errorf("无有效的refresh_token")
|
||||
}
|
||||
|
||||
// 更新token信息
|
||||
if x.extra.Token == nil {
|
||||
x.extra.Token = &XunleiTokenData{}
|
||||
}
|
||||
x.extra.Token.AccessToken = newData.AccessToken
|
||||
x.extra.Token.RefreshToken = newData.RefreshToken
|
||||
x.extra.Token.ExpiresAt = newData.ExpiresAt
|
||||
x.extra.Token.ExpiresIn = newData.ExpiresIn
|
||||
x.extra.Token.Sub = newData.Sub
|
||||
x.extra.Token.TokenType = newData.TokenType
|
||||
x.extra.Token.UserId = newData.UserId
|
||||
|
||||
// 更新ck字段中的refresh_token(保持向后兼容)
|
||||
x.entity.Ck = newData.RefreshToken
|
||||
|
||||
// 保存到数据库
|
||||
extraBytes, err := json.Marshal(x.extra)
|
||||
@@ -175,6 +233,7 @@ func (x *XunleiPanService) getAccessToken() (string, error) {
|
||||
if err := x.cksRepo.UpdateWithAllFields(&x.entity); err != nil {
|
||||
return "", fmt.Errorf("保存 access_token 到数据库失败: %v", err)
|
||||
}
|
||||
|
||||
return newData.AccessToken, nil
|
||||
}
|
||||
|
||||
@@ -248,6 +307,12 @@ func (x *XunleiPanService) requestXunleiApi(url string, method string, data map[
|
||||
var respData []byte
|
||||
var err error
|
||||
|
||||
// 检查是否是验证码初始化请求
|
||||
if strings.Contains(url, "shield/captcha/init") {
|
||||
// 对于验证码初始化,直接发送HTTP请求,不使用BasePanService,使用sendCaptchaRequestForGeneralAPI
|
||||
return x.sendCaptchaRequestForGeneralAPI(url, data)
|
||||
}
|
||||
|
||||
// 先更新当前请求的 headers
|
||||
originalHeaders := make(map[string]string)
|
||||
for k, v := range x.headers {
|
||||
@@ -832,6 +897,51 @@ func (x *XunleiPanService) Restore(shareID, passCodeToken string, fileIDs []stri
|
||||
return &data, nil
|
||||
}
|
||||
|
||||
// sendCaptchaRequestForGeneralAPI 发送验证码请求 - 用于非登录场景的验证码请求
|
||||
func (x *XunleiPanService) sendCaptchaRequestForGeneralAPI(url string, data map[string]interface{}) (map[string]interface{}, error) {
|
||||
jsonData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Printf("发送验证码请求URL: %s", url)
|
||||
log.Printf("发送验证码请求数据: %s", string(jsonData))
|
||||
|
||||
req, err := http.NewRequest("POST", url, strings.NewReader(string(jsonData)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 设置请求头
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36")
|
||||
req.Header.Set("X-Client-Id", x.clientId)
|
||||
req.Header.Set("X-Device-Id", x.deviceId)
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Printf("验证码响应状态码: %d", resp.StatusCode)
|
||||
log.Printf("验证码响应内容: %s", string(body))
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("JSON 解析失败: %v, raw: %s", err, string(body))
|
||||
}
|
||||
|
||||
log.Printf("解析后的响应: %+v", result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// 结构体完全对齐 xunleix
|
||||
type XLShareListResp struct {
|
||||
Data struct {
|
||||
@@ -894,4 +1004,4 @@ type XLTaskResult struct {
|
||||
Data struct {
|
||||
ShareID string `json:"share_id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
}
|
||||
@@ -109,6 +109,8 @@ func InitDB() error {
|
||||
&entity.APIAccessLog{},
|
||||
&entity.APIAccessLogStats{},
|
||||
&entity.APIAccessLogSummary{},
|
||||
&entity.Report{},
|
||||
&entity.CopyrightClaim{},
|
||||
)
|
||||
if err != nil {
|
||||
utils.Fatal("数据库迁移失败: %v", err)
|
||||
@@ -217,7 +219,19 @@ func createIndexes(db *gorm.DB) {
|
||||
db.Exec("CREATE INDEX IF NOT EXISTS idx_api_access_logs_response_time ON api_access_logs(processing_time)")
|
||||
db.Exec("CREATE INDEX IF NOT EXISTS idx_api_access_logs_error_logs ON api_access_logs(response_status, created_at DESC) WHERE response_status >= 400")
|
||||
|
||||
utils.Info("数据库索引创建完成(已移除全文搜索索引,准备使用Meilisearch,新增API访问日志性能索引)")
|
||||
// 任务和任务项表索引 - Google索引功能优化
|
||||
db.Exec("CREATE INDEX IF NOT EXISTS idx_tasks_type_status ON tasks(type, status)")
|
||||
db.Exec("CREATE INDEX IF NOT EXISTS idx_tasks_created_at ON tasks(created_at DESC)")
|
||||
|
||||
// task_items表的关键索引 - 支持高效去重和状态查询
|
||||
db.Exec("CREATE INDEX IF NOT EXISTS idx_task_items_url ON task_items(url)")
|
||||
db.Exec("CREATE INDEX IF NOT EXISTS idx_task_items_url_status ON task_items(url, status)")
|
||||
db.Exec("CREATE INDEX IF NOT EXISTS idx_task_items_task_id ON task_items(task_id)")
|
||||
db.Exec("CREATE INDEX IF NOT EXISTS idx_task_items_status ON task_items(status)")
|
||||
db.Exec("CREATE INDEX IF NOT EXISTS idx_task_items_created_at ON task_items(created_at DESC)")
|
||||
db.Exec("CREATE INDEX IF NOT EXISTS idx_task_items_status_created ON task_items(status, created_at)")
|
||||
|
||||
utils.Info("数据库索引创建完成(已移除全文搜索索引,准备使用Meilisearch,新增API访问日志性能索引,任务项表索引优化)")
|
||||
}
|
||||
|
||||
// insertDefaultDataIfEmpty 只在数据库为空时插入默认数据
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package converter
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"time"
|
||||
|
||||
@@ -12,6 +13,7 @@ import (
|
||||
func ToResourceResponse(resource *entity.Resource) dto.ResourceResponse {
|
||||
response := dto.ResourceResponse{
|
||||
ID: resource.ID,
|
||||
Key: resource.Key,
|
||||
Title: resource.Title,
|
||||
Description: resource.Description,
|
||||
URL: resource.URL,
|
||||
@@ -36,6 +38,18 @@ func ToResourceResponse(resource *entity.Resource) dto.ResourceResponse {
|
||||
response.CategoryName = resource.Category.Name
|
||||
}
|
||||
|
||||
// 设置平台信息
|
||||
if resource.Pan.ID != 0 {
|
||||
panResponse := dto.PanResponse{
|
||||
ID: resource.Pan.ID,
|
||||
Name: resource.Pan.Name,
|
||||
Key: resource.Pan.Key,
|
||||
Icon: resource.Pan.Icon,
|
||||
Remark: resource.Pan.Remark,
|
||||
}
|
||||
response.Pan = &panResponse
|
||||
}
|
||||
|
||||
// 转换标签
|
||||
response.Tags = make([]dto.TagResponse, len(resource.Tags))
|
||||
for i, tag := range resource.Tags {
|
||||
@@ -83,7 +97,7 @@ func ToResourceResponseFromMeilisearch(doc interface{}) dto.ResourceResponse {
|
||||
response.FileSize = fileSizeField.String()
|
||||
}
|
||||
if keyField := docValue.FieldByName("Key"); keyField.IsValid() {
|
||||
// Key字段在ResourceResponse中不存在,跳过
|
||||
response.Key = keyField.String()
|
||||
}
|
||||
if categoryField := docValue.FieldByName("Category"); categoryField.IsValid() {
|
||||
response.CategoryName = categoryField.String()
|
||||
@@ -312,3 +326,38 @@ func ToReadyResourceResponseList(resources []entity.ReadyResource) []dto.ReadyRe
|
||||
// Key: req.Key,
|
||||
// }
|
||||
// }
|
||||
|
||||
// TaskToGoogleIndexTaskOutput 将Task实体转换为GoogleIndexTaskOutput
|
||||
func TaskToGoogleIndexTaskOutput(task *entity.Task, stats map[string]int) dto.GoogleIndexTaskOutput {
|
||||
output := dto.GoogleIndexTaskOutput{
|
||||
ID: task.ID,
|
||||
Name: task.Name,
|
||||
Description: task.Description,
|
||||
Type: string(task.Type),
|
||||
Status: string(task.Status),
|
||||
Progress: task.Progress,
|
||||
TotalItems: stats["total"],
|
||||
ProcessedItems: stats["completed"] + stats["failed"],
|
||||
SuccessfulItems: stats["completed"],
|
||||
FailedItems: stats["failed"],
|
||||
PendingItems: stats["pending"],
|
||||
ProcessingItems: stats["processing"],
|
||||
IndexedURLs: task.IndexedURLs,
|
||||
FailedURLs: task.FailedURLs,
|
||||
ConfigID: task.ConfigID,
|
||||
CreatedAt: task.CreatedAt,
|
||||
UpdatedAt: task.UpdatedAt,
|
||||
StartedAt: task.StartedAt,
|
||||
CompletedAt: task.CompletedAt,
|
||||
}
|
||||
|
||||
// 设置进度数据
|
||||
if task.ProgressData != "" {
|
||||
var progressData map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(task.ProgressData), &progressData); err == nil {
|
||||
output.ProgressData = progressData
|
||||
}
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
95
db/converter/copyright_claim_converter.go
Normal file
95
db/converter/copyright_claim_converter.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package converter
|
||||
|
||||
import (
|
||||
"time"
|
||||
"github.com/ctwj/urldb/db/dto"
|
||||
"github.com/ctwj/urldb/db/entity"
|
||||
)
|
||||
|
||||
// CopyrightClaimToResponseWithResources 将版权申述实体和关联资源转换为响应对象
|
||||
func CopyrightClaimToResponseWithResources(claim *entity.CopyrightClaim, resources []*entity.Resource) *dto.CopyrightClaimResponse {
|
||||
if claim == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 转换关联的资源信息
|
||||
var resourceInfos []dto.ResourceInfo
|
||||
for _, resource := range resources {
|
||||
categoryName := ""
|
||||
if resource.Category.ID != 0 {
|
||||
categoryName = resource.Category.Name
|
||||
}
|
||||
|
||||
panName := ""
|
||||
if resource.Pan.ID != 0 {
|
||||
panName = resource.Pan.Name
|
||||
}
|
||||
|
||||
resourceInfo := dto.ResourceInfo{
|
||||
ID: resource.ID,
|
||||
Title: resource.Title,
|
||||
Description: resource.Description,
|
||||
URL: resource.URL,
|
||||
SaveURL: resource.SaveURL,
|
||||
FileSize: resource.FileSize,
|
||||
Category: categoryName,
|
||||
PanName: panName,
|
||||
ViewCount: resource.ViewCount,
|
||||
IsValid: resource.IsValid,
|
||||
CreatedAt: resource.CreatedAt.Format(time.RFC3339),
|
||||
}
|
||||
resourceInfos = append(resourceInfos, resourceInfo)
|
||||
}
|
||||
|
||||
return &dto.CopyrightClaimResponse{
|
||||
ID: claim.ID,
|
||||
ResourceKey: claim.ResourceKey,
|
||||
Identity: claim.Identity,
|
||||
ProofType: claim.ProofType,
|
||||
Reason: claim.Reason,
|
||||
ContactInfo: claim.ContactInfo,
|
||||
ClaimantName: claim.ClaimantName,
|
||||
ProofFiles: claim.ProofFiles,
|
||||
UserAgent: claim.UserAgent,
|
||||
IPAddress: claim.IPAddress,
|
||||
Status: claim.Status,
|
||||
Note: claim.Note,
|
||||
CreatedAt: claim.CreatedAt.Format(time.RFC3339),
|
||||
UpdatedAt: claim.UpdatedAt.Format(time.RFC3339),
|
||||
Resources: resourceInfos,
|
||||
}
|
||||
}
|
||||
|
||||
// CopyrightClaimToResponse 将版权申述实体转换为响应对象(不包含资源详情)
|
||||
func CopyrightClaimToResponse(claim *entity.CopyrightClaim) *dto.CopyrightClaimResponse {
|
||||
if claim == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &dto.CopyrightClaimResponse{
|
||||
ID: claim.ID,
|
||||
ResourceKey: claim.ResourceKey,
|
||||
Identity: claim.Identity,
|
||||
ProofType: claim.ProofType,
|
||||
Reason: claim.Reason,
|
||||
ContactInfo: claim.ContactInfo,
|
||||
ClaimantName: claim.ClaimantName,
|
||||
ProofFiles: claim.ProofFiles,
|
||||
UserAgent: claim.UserAgent,
|
||||
IPAddress: claim.IPAddress,
|
||||
Status: claim.Status,
|
||||
Note: claim.Note,
|
||||
CreatedAt: claim.CreatedAt.Format(time.RFC3339),
|
||||
UpdatedAt: claim.UpdatedAt.Format(time.RFC3339),
|
||||
Resources: []dto.ResourceInfo{}, // 空的资源列表
|
||||
}
|
||||
}
|
||||
|
||||
// CopyrightClaimsToResponse 将版权申述实体列表转换为响应对象列表
|
||||
func CopyrightClaimsToResponse(claims []*entity.CopyrightClaim) []*dto.CopyrightClaimResponse {
|
||||
var responses []*dto.CopyrightClaimResponse
|
||||
for _, claim := range claims {
|
||||
responses = append(responses, CopyrightClaimToResponse(claim))
|
||||
}
|
||||
return responses
|
||||
}
|
||||
89
db/converter/report_converter.go
Normal file
89
db/converter/report_converter.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package converter
|
||||
|
||||
import (
|
||||
"time"
|
||||
"github.com/ctwj/urldb/db/dto"
|
||||
"github.com/ctwj/urldb/db/entity"
|
||||
)
|
||||
|
||||
// ReportToResponseWithResources 将举报实体和关联资源转换为响应对象
|
||||
func ReportToResponseWithResources(report *entity.Report, resources []*entity.Resource) *dto.ReportResponse {
|
||||
if report == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 转换关联的资源信息
|
||||
var resourceInfos []dto.ResourceInfo
|
||||
for _, resource := range resources {
|
||||
categoryName := ""
|
||||
if resource.Category.ID != 0 {
|
||||
categoryName = resource.Category.Name
|
||||
}
|
||||
|
||||
panName := ""
|
||||
if resource.Pan.ID != 0 {
|
||||
panName = resource.Pan.Name
|
||||
}
|
||||
|
||||
resourceInfo := dto.ResourceInfo{
|
||||
ID: resource.ID,
|
||||
Title: resource.Title,
|
||||
Description: resource.Description,
|
||||
URL: resource.URL,
|
||||
SaveURL: resource.SaveURL,
|
||||
FileSize: resource.FileSize,
|
||||
Category: categoryName,
|
||||
PanName: panName,
|
||||
ViewCount: resource.ViewCount,
|
||||
IsValid: resource.IsValid,
|
||||
CreatedAt: resource.CreatedAt.Format(time.RFC3339),
|
||||
}
|
||||
resourceInfos = append(resourceInfos, resourceInfo)
|
||||
}
|
||||
|
||||
return &dto.ReportResponse{
|
||||
ID: report.ID,
|
||||
ResourceKey: report.ResourceKey,
|
||||
Reason: report.Reason,
|
||||
Description: report.Description,
|
||||
Contact: report.Contact,
|
||||
UserAgent: report.UserAgent,
|
||||
IPAddress: report.IPAddress,
|
||||
Status: report.Status,
|
||||
Note: report.Note,
|
||||
CreatedAt: report.CreatedAt.Format(time.RFC3339),
|
||||
UpdatedAt: report.UpdatedAt.Format(time.RFC3339),
|
||||
Resources: resourceInfos,
|
||||
}
|
||||
}
|
||||
|
||||
// ReportToResponse 将举报实体转换为响应对象(不包含资源详情)
|
||||
func ReportToResponse(report *entity.Report) *dto.ReportResponse {
|
||||
if report == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &dto.ReportResponse{
|
||||
ID: report.ID,
|
||||
ResourceKey: report.ResourceKey,
|
||||
Reason: report.Reason,
|
||||
Description: report.Description,
|
||||
Contact: report.Contact,
|
||||
UserAgent: report.UserAgent,
|
||||
IPAddress: report.IPAddress,
|
||||
Status: report.Status,
|
||||
Note: report.Note,
|
||||
CreatedAt: report.CreatedAt.Format(time.RFC3339),
|
||||
UpdatedAt: report.UpdatedAt.Format(time.RFC3339),
|
||||
Resources: []dto.ResourceInfo{}, // 空的资源列表
|
||||
}
|
||||
}
|
||||
|
||||
// ReportsToResponse 将举报实体列表转换为响应对象列表
|
||||
func ReportsToResponse(reports []*entity.Report) []*dto.ReportResponse {
|
||||
var responses []*dto.ReportResponse
|
||||
for _, report := range reports {
|
||||
responses = append(responses, ReportToResponse(report))
|
||||
}
|
||||
return responses
|
||||
}
|
||||
@@ -112,6 +112,8 @@ func SystemConfigToResponse(configs []entity.SystemConfig) *dto.SystemConfigResp
|
||||
response.TelegramQrImage = config.Value
|
||||
case entity.ConfigKeyQrCodeStyle:
|
||||
response.QrCodeStyle = config.Value
|
||||
case entity.ConfigKeyWebsiteURL:
|
||||
response.SiteURL = config.Value
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,6 +273,10 @@ func RequestToSystemConfig(req *dto.SystemConfigRequest) []entity.SystemConfig {
|
||||
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyQrCodeStyle, Value: *req.QrCodeStyle, Type: entity.ConfigTypeString})
|
||||
updatedKeys = append(updatedKeys, entity.ConfigKeyQrCodeStyle)
|
||||
}
|
||||
if req.SiteURL != nil {
|
||||
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyWebsiteURL, Value: *req.SiteURL, Type: entity.ConfigTypeString})
|
||||
updatedKeys = append(updatedKeys, entity.ConfigKeyWebsiteURL)
|
||||
}
|
||||
|
||||
// 记录更新的配置项
|
||||
if len(updatedKeys) > 0 {
|
||||
@@ -280,39 +286,28 @@ func RequestToSystemConfig(req *dto.SystemConfigRequest) []entity.SystemConfig {
|
||||
return configs
|
||||
}
|
||||
|
||||
// SystemConfigToPublicResponse 返回不含 api_token 的系统配置响应
|
||||
// SystemConfigToPublicResponse 返回不含敏感配置的系统配置响应
|
||||
func SystemConfigToPublicResponse(configs []entity.SystemConfig) map[string]interface{} {
|
||||
response := map[string]interface{}{
|
||||
entity.ConfigResponseFieldID: 0,
|
||||
entity.ConfigResponseFieldCreatedAt: utils.GetCurrentTimeString(),
|
||||
entity.ConfigResponseFieldUpdatedAt: utils.GetCurrentTimeString(),
|
||||
entity.ConfigResponseFieldSiteTitle: entity.ConfigDefaultSiteTitle,
|
||||
entity.ConfigResponseFieldSiteDescription: entity.ConfigDefaultSiteDescription,
|
||||
entity.ConfigResponseFieldKeywords: entity.ConfigDefaultKeywords,
|
||||
entity.ConfigResponseFieldAuthor: entity.ConfigDefaultAuthor,
|
||||
entity.ConfigResponseFieldCopyright: entity.ConfigDefaultCopyright,
|
||||
"site_logo": "",
|
||||
entity.ConfigResponseFieldAutoProcessReadyResources: false,
|
||||
entity.ConfigResponseFieldAutoProcessInterval: 30,
|
||||
entity.ConfigResponseFieldAutoTransferEnabled: false,
|
||||
entity.ConfigResponseFieldAutoTransferLimitDays: 0,
|
||||
entity.ConfigResponseFieldAutoTransferMinSpace: 100,
|
||||
entity.ConfigResponseFieldAutoFetchHotDramaEnabled: false,
|
||||
entity.ConfigResponseFieldForbiddenWords: "",
|
||||
entity.ConfigResponseFieldAdKeywords: "",
|
||||
entity.ConfigResponseFieldAutoInsertAd: "",
|
||||
entity.ConfigResponseFieldPageSize: 100,
|
||||
entity.ConfigResponseFieldMaintenanceMode: false,
|
||||
entity.ConfigResponseFieldEnableRegister: true, // 默认开启注册功能
|
||||
entity.ConfigResponseFieldThirdPartyStatsCode: "",
|
||||
entity.ConfigResponseFieldMeilisearchEnabled: false,
|
||||
entity.ConfigResponseFieldMeilisearchHost: "localhost",
|
||||
entity.ConfigResponseFieldMeilisearchPort: "7700",
|
||||
entity.ConfigResponseFieldMeilisearchMasterKey: "",
|
||||
entity.ConfigResponseFieldMeilisearchIndexName: "resources",
|
||||
}
|
||||
entity.ConfigResponseFieldID: 0,
|
||||
entity.ConfigResponseFieldCreatedAt: utils.GetCurrentTimeString(),
|
||||
entity.ConfigResponseFieldUpdatedAt: utils.GetCurrentTimeString(),
|
||||
entity.ConfigResponseFieldSiteTitle: entity.ConfigDefaultSiteTitle,
|
||||
entity.ConfigResponseFieldSiteDescription: entity.ConfigDefaultSiteDescription,
|
||||
entity.ConfigResponseFieldKeywords: entity.ConfigDefaultKeywords,
|
||||
entity.ConfigResponseFieldAuthor: entity.ConfigDefaultAuthor,
|
||||
entity.ConfigResponseFieldCopyright: entity.ConfigDefaultCopyright,
|
||||
"site_logo": "",
|
||||
entity.ConfigResponseFieldAdKeywords: "",
|
||||
entity.ConfigResponseFieldAutoInsertAd: "",
|
||||
entity.ConfigResponseFieldPageSize: 100,
|
||||
entity.ConfigResponseFieldMaintenanceMode: false,
|
||||
entity.ConfigResponseFieldEnableRegister: true, // 默认开启注册功能
|
||||
entity.ConfigResponseFieldThirdPartyStatsCode: "",
|
||||
entity.ConfigResponseFieldWebsiteURL: "",
|
||||
}
|
||||
|
||||
// 将键值对转换为map
|
||||
// 将键值对转换为map,过滤掉敏感配置
|
||||
for _, config := range configs {
|
||||
switch config.Key {
|
||||
case entity.ConfigKeySiteTitle:
|
||||
@@ -327,32 +322,6 @@ func SystemConfigToPublicResponse(configs []entity.SystemConfig) map[string]inte
|
||||
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
|
||||
}
|
||||
case entity.ConfigKeyAutoProcessInterval:
|
||||
if val, err := strconv.Atoi(config.Value); err == nil {
|
||||
response[entity.ConfigResponseFieldAutoProcessInterval] = val
|
||||
}
|
||||
case entity.ConfigKeyAutoTransferEnabled:
|
||||
if val, err := strconv.ParseBool(config.Value); err == nil {
|
||||
response[entity.ConfigResponseFieldAutoTransferEnabled] = val
|
||||
}
|
||||
case entity.ConfigKeyAutoTransferLimitDays:
|
||||
if val, err := strconv.Atoi(config.Value); err == nil {
|
||||
response[entity.ConfigResponseFieldAutoTransferLimitDays] = val
|
||||
}
|
||||
case entity.ConfigKeyAutoTransferMinSpace:
|
||||
if val, err := strconv.Atoi(config.Value); err == nil {
|
||||
response[entity.ConfigResponseFieldAutoTransferMinSpace] = val
|
||||
}
|
||||
case entity.ConfigKeyAutoFetchHotDramaEnabled:
|
||||
if val, err := strconv.ParseBool(config.Value); err == nil {
|
||||
response[entity.ConfigResponseFieldAutoFetchHotDramaEnabled] = val
|
||||
}
|
||||
case entity.ConfigKeyForbiddenWords:
|
||||
response[entity.ConfigResponseFieldForbiddenWords] = config.Value
|
||||
case entity.ConfigKeyAdKeywords:
|
||||
response[entity.ConfigResponseFieldAdKeywords] = config.Value
|
||||
case entity.ConfigKeyAutoInsertAd:
|
||||
@@ -371,18 +340,6 @@ func SystemConfigToPublicResponse(configs []entity.SystemConfig) map[string]inte
|
||||
}
|
||||
case entity.ConfigKeyThirdPartyStatsCode:
|
||||
response[entity.ConfigResponseFieldThirdPartyStatsCode] = config.Value
|
||||
case entity.ConfigKeyMeilisearchEnabled:
|
||||
if val, err := strconv.ParseBool(config.Value); err == nil {
|
||||
response[entity.ConfigResponseFieldMeilisearchEnabled] = val
|
||||
}
|
||||
case entity.ConfigKeyMeilisearchHost:
|
||||
response[entity.ConfigResponseFieldMeilisearchHost] = config.Value
|
||||
case entity.ConfigKeyMeilisearchPort:
|
||||
response[entity.ConfigResponseFieldMeilisearchPort] = config.Value
|
||||
case entity.ConfigKeyMeilisearchMasterKey:
|
||||
response[entity.ConfigResponseFieldMeilisearchMasterKey] = config.Value
|
||||
case entity.ConfigKeyMeilisearchIndexName:
|
||||
response[entity.ConfigResponseFieldMeilisearchIndexName] = config.Value
|
||||
case entity.ConfigKeyEnableAnnouncements:
|
||||
if val, err := strconv.ParseBool(config.Value); err == nil {
|
||||
response["enable_announcements"] = val
|
||||
@@ -403,6 +360,29 @@ func SystemConfigToPublicResponse(configs []entity.SystemConfig) map[string]inte
|
||||
response["telegram_qr_image"] = config.Value
|
||||
case entity.ConfigKeyQrCodeStyle:
|
||||
response["qr_code_style"] = config.Value
|
||||
case entity.ConfigKeyWebsiteURL:
|
||||
response[entity.ConfigResponseFieldWebsiteURL] = config.Value
|
||||
case entity.ConfigKeyAutoProcessReadyResources:
|
||||
if val, err := strconv.ParseBool(config.Value); err == nil {
|
||||
response["auto_process_ready_resources"] = val
|
||||
}
|
||||
case entity.ConfigKeyAutoTransferEnabled:
|
||||
if val, err := strconv.ParseBool(config.Value); err == nil {
|
||||
response["auto_transfer_enabled"] = val
|
||||
}
|
||||
// 跳过不需要返回给公众的配置
|
||||
case entity.ConfigKeyAutoProcessInterval:
|
||||
case entity.ConfigKeyAutoTransferLimitDays:
|
||||
case entity.ConfigKeyAutoTransferMinSpace:
|
||||
case entity.ConfigKeyAutoFetchHotDramaEnabled:
|
||||
case entity.ConfigKeyMeilisearchEnabled:
|
||||
case entity.ConfigKeyMeilisearchHost:
|
||||
case entity.ConfigKeyMeilisearchPort:
|
||||
case entity.ConfigKeyMeilisearchMasterKey:
|
||||
case entity.ConfigKeyMeilisearchIndexName:
|
||||
case entity.ConfigKeyForbiddenWords:
|
||||
// 这些配置不返回给公众
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
@@ -449,5 +429,6 @@ func getDefaultConfigResponse() *dto.SystemConfigResponse {
|
||||
WechatSearchImage: entity.ConfigDefaultWechatSearchImage,
|
||||
TelegramQrImage: entity.ConfigDefaultTelegramQrImage,
|
||||
QrCodeStyle: entity.ConfigDefaultQrCodeStyle,
|
||||
SiteURL: entity.ConfigDefaultWebsiteURL,
|
||||
}
|
||||
}
|
||||
|
||||
46
db/dto/copyright_claim.go
Normal file
46
db/dto/copyright_claim.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package dto
|
||||
|
||||
// CopyrightClaimCreateRequest 版权申述创建请求
|
||||
type CopyrightClaimCreateRequest struct {
|
||||
ResourceKey string `json:"resource_key" validate:"required,max=255"`
|
||||
Identity string `json:"identity" validate:"required,max=50"`
|
||||
ProofType string `json:"proof_type" validate:"required,max=50"`
|
||||
Reason string `json:"reason" validate:"required,max=2000"`
|
||||
ContactInfo string `json:"contact_info" validate:"required,max=255"`
|
||||
ClaimantName string `json:"claimant_name" validate:"required,max=100"`
|
||||
ProofFiles string `json:"proof_files" validate:"omitempty,max=2000"`
|
||||
UserAgent string `json:"user_agent" validate:"omitempty,max=1000"`
|
||||
IPAddress string `json:"ip_address" validate:"omitempty,max=45"`
|
||||
}
|
||||
|
||||
// CopyrightClaimUpdateRequest 版权申述更新请求
|
||||
type CopyrightClaimUpdateRequest struct {
|
||||
Status string `json:"status" validate:"required,oneof=pending approved rejected"`
|
||||
Note string `json:"note" validate:"omitempty,max=1000"`
|
||||
}
|
||||
|
||||
// CopyrightClaimResponse 版权申述响应
|
||||
type CopyrightClaimResponse struct {
|
||||
ID uint `json:"id"`
|
||||
ResourceKey string `json:"resource_key"`
|
||||
Identity string `json:"identity"`
|
||||
ProofType string `json:"proof_type"`
|
||||
Reason string `json:"reason"`
|
||||
ContactInfo string `json:"contact_info"`
|
||||
ClaimantName string `json:"claimant_name"`
|
||||
ProofFiles string `json:"proof_files"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
IPAddress string `json:"ip_address"`
|
||||
Status string `json:"status"`
|
||||
Note string `json:"note"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
Resources []ResourceInfo `json:"resources"`
|
||||
}
|
||||
|
||||
// CopyrightClaimListRequest 版权申述列表请求
|
||||
type CopyrightClaimListRequest struct {
|
||||
Page int `query:"page" validate:"omitempty,min=1"`
|
||||
PageSize int `query:"page_size" validate:"omitempty,min=1,max=100"`
|
||||
Status string `query:"status" validate:"omitempty,oneof=pending approved rejected"`
|
||||
}
|
||||
188
db/dto/google_index.go
Normal file
188
db/dto/google_index.go
Normal file
@@ -0,0 +1,188 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// GoogleIndexConfigInput Google索引配置输入
|
||||
type GoogleIndexConfigInput struct {
|
||||
Group string `json:"group" binding:"required"`
|
||||
Key string `json:"key" binding:"required"`
|
||||
Value string `json:"value" binding:"required"`
|
||||
Type string `json:"type" default:"string"`
|
||||
}
|
||||
|
||||
// GoogleIndexConfigOutput Google索引配置输出
|
||||
type GoogleIndexConfigOutput struct {
|
||||
ID uint `json:"id"`
|
||||
Group string `json:"group"`
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
Type string `json:"type"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// GoogleIndexConfigGeneral 通用配置
|
||||
type GoogleIndexConfigGeneral struct {
|
||||
Enabled bool `json:"enabled" binding:"required"`
|
||||
SiteURL string `json:"site_url" binding:"required"`
|
||||
SiteName string `json:"site_name"`
|
||||
}
|
||||
|
||||
// GoogleIndexConfigAuth 认证配置
|
||||
type GoogleIndexConfigAuth struct {
|
||||
CredentialsFile string `json:"credentials_file"`
|
||||
ClientEmail string `json:"client_email"`
|
||||
ClientID string `json:"client_id"`
|
||||
PrivateKey string `json:"private_key"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
// GoogleIndexConfigSchedule 调度配置
|
||||
type GoogleIndexConfigSchedule struct {
|
||||
CheckInterval int `json:"check_interval" binding:"required,min=1,max=1440"` // 检查间隔(分钟),1-24小时
|
||||
BatchSize int `json:"batch_size" binding:"required,min=1,max=1000"` // 批处理大小
|
||||
Concurrency int `json:"concurrency" binding:"required,min=1,max=10"` // 并发数
|
||||
RetryAttempts int `json:"retry_attempts" binding:"required,min=0,max=10"` // 重试次数
|
||||
RetryDelay int `json:"retry_delay" binding:"required,min=1,max=60"` // 重试延迟(秒)
|
||||
}
|
||||
|
||||
// GoogleIndexConfigSitemap 网站地图配置
|
||||
type GoogleIndexConfigSitemap struct {
|
||||
AutoSitemap bool `json:"auto_sitemap"`
|
||||
SitemapPath string `json:"sitemap_path" default:"/sitemap.xml"`
|
||||
SitemapSchedule string `json:"sitemap_schedule" default:"@daily"` // cron表达式
|
||||
}
|
||||
|
||||
// GoogleIndexTaskInput Google索引任务输入
|
||||
type GoogleIndexTaskInput struct {
|
||||
Title string `json:"title" binding:"required"`
|
||||
Type string `json:"type" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
URLs []string `json:"urls,omitempty"` // 用于URL索引检查任务
|
||||
SitemapURL string `json:"sitemap_url,omitempty"` // 用于网站地图提交任务
|
||||
ConfigID *uint `json:"config_id,omitempty"`
|
||||
}
|
||||
|
||||
// GoogleIndexTaskOutput Google索引任务输出
|
||||
type GoogleIndexTaskOutput struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Type string `json:"type"`
|
||||
Status string `json:"status"`
|
||||
Progress float64 `json:"progress"`
|
||||
TotalItems int `json:"total_items"`
|
||||
ProcessedItems int `json:"processed_items"`
|
||||
SuccessfulItems int `json:"successful_items"`
|
||||
FailedItems int `json:"failed_items"`
|
||||
PendingItems int `json:"pending_items"`
|
||||
ProcessingItems int `json:"processing_items"`
|
||||
IndexedURLs int `json:"indexed_urls"`
|
||||
FailedURLs int `json:"failed_urls"`
|
||||
ConfigID *uint `json:"config_id"`
|
||||
ProgressData map[string]interface{} `json:"progress_data"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
StartedAt *time.Time `json:"started_at"`
|
||||
CompletedAt *time.Time `json:"completed_at"`
|
||||
}
|
||||
|
||||
// GoogleIndexTaskItemInput Google索引任务项输入
|
||||
type GoogleIndexTaskItemInput struct {
|
||||
TaskID uint `json:"task_id" binding:"required"`
|
||||
URL string `json:"url" binding:"required,url"`
|
||||
}
|
||||
|
||||
// GoogleIndexTaskItemOutput Google索引任务项输出
|
||||
type GoogleIndexTaskItemOutput struct {
|
||||
ID uint `json:"id"`
|
||||
TaskID uint `json:"task_id"`
|
||||
URL string `json:"url"`
|
||||
Status string `json:"status"`
|
||||
IndexStatus string `json:"index_status"`
|
||||
ErrorMessage string `json:"error_message"`
|
||||
InspectResult string `json:"inspect_result"`
|
||||
MobileFriendly bool `json:"mobile_friendly"`
|
||||
LastCrawled *time.Time `json:"last_crawled"`
|
||||
StatusCode int `json:"status_code"`
|
||||
StartedAt *time.Time `json:"started_at"`
|
||||
CompletedAt *time.Time `json:"completed_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// GoogleIndexURLStatusInput Google索引URL状态输入
|
||||
type GoogleIndexURLStatusInput struct {
|
||||
URL string `json:"url" binding:"required,url"`
|
||||
IndexStatus string `json:"index_status" binding:"required"`
|
||||
}
|
||||
|
||||
// GoogleIndexURLStatusOutput Google索引URL状态输出
|
||||
type GoogleIndexURLStatusOutput struct {
|
||||
ID uint `json:"id"`
|
||||
URL string `json:"url"`
|
||||
IndexStatus string `json:"index_status"`
|
||||
LastChecked time.Time `json:"last_checked"`
|
||||
CanonicalURL *string `json:"canonical_url"`
|
||||
LastCrawled *time.Time `json:"last_crawled"`
|
||||
ChangeFreq *string `json:"change_freq"`
|
||||
Priority *float64 `json:"priority"`
|
||||
MobileFriendly bool `json:"mobile_friendly"`
|
||||
RobotsBlocked bool `json:"robots_blocked"`
|
||||
LastError *string `json:"last_error"`
|
||||
StatusCode int `json:"status_code"`
|
||||
StatusCodeText string `json:"status_code_text"`
|
||||
CheckCount int `json:"check_count"`
|
||||
SuccessCount int `json:"success_count"`
|
||||
FailureCount int `json:"failure_count"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// GoogleIndexBatchRequest 批量处理请求
|
||||
type GoogleIndexBatchRequest struct {
|
||||
URLs []string `json:"urls" binding:"required,min=1,max=1000"`
|
||||
Operation string `json:"operation" binding:"required,oneof=check_index submit_sitemap ping"` // 操作类型
|
||||
}
|
||||
|
||||
// GoogleIndexBatchResponse 批量处理响应
|
||||
type GoogleIndexBatchResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Results []string `json:"results,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Total int `json:"total"`
|
||||
Processed int `json:"processed"`
|
||||
Failed int `json:"failed"`
|
||||
}
|
||||
|
||||
// GoogleIndexStatusResponse 索引状态响应
|
||||
type GoogleIndexStatusResponse struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
SiteURL string `json:"site_url"`
|
||||
LastCheckTime time.Time `json:"last_check_time"`
|
||||
TotalURLs int `json:"total_urls"`
|
||||
IndexedURLs int `json:"indexed_urls"`
|
||||
NotIndexedURLs int `json:"not_indexed_urls"`
|
||||
ErrorURLs int `json:"error_urls"`
|
||||
LastSitemapSubmit time.Time `json:"last_sitemap_submit"`
|
||||
AuthValid bool `json:"auth_valid"`
|
||||
}
|
||||
|
||||
// GoogleIndexTaskListResponse 任务列表响应
|
||||
type GoogleIndexTaskListResponse struct {
|
||||
Tasks []GoogleIndexTaskOutput `json:"tasks"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
}
|
||||
|
||||
// GoogleIndexTaskItemPageResponse 任务项分页响应
|
||||
type GoogleIndexTaskItemPageResponse struct {
|
||||
Items []GoogleIndexTaskItemOutput `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
Size int `json:"size"`
|
||||
}
|
||||
55
db/dto/report.go
Normal file
55
db/dto/report.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package dto
|
||||
|
||||
// ReportCreateRequest 举报创建请求
|
||||
type ReportCreateRequest struct {
|
||||
ResourceKey string `json:"resource_key" validate:"required,max=255"`
|
||||
Reason string `json:"reason" validate:"required,max=100"`
|
||||
Description string `json:"description" validate:"required,max=1000"`
|
||||
Contact string `json:"contact" validate:"omitempty,max=255"`
|
||||
UserAgent string `json:"user_agent" validate:"omitempty,max=1000"`
|
||||
IPAddress string `json:"ip_address" validate:"omitempty,max=45"`
|
||||
}
|
||||
|
||||
// ReportUpdateRequest 举报更新请求
|
||||
type ReportUpdateRequest struct {
|
||||
Status string `json:"status" validate:"required,oneof=pending approved rejected"`
|
||||
Note string `json:"note" validate:"omitempty,max=1000"`
|
||||
}
|
||||
|
||||
// ResourceInfo 资源信息
|
||||
type ResourceInfo struct {
|
||||
ID uint `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
URL string `json:"url"`
|
||||
SaveURL string `json:"save_url"`
|
||||
FileSize string `json:"file_size"`
|
||||
Category string `json:"category"`
|
||||
PanName string `json:"pan_name"`
|
||||
ViewCount int `json:"view_count"`
|
||||
IsValid bool `json:"is_valid"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// ReportResponse 举报响应
|
||||
type ReportResponse struct {
|
||||
ID uint `json:"id"`
|
||||
ResourceKey string `json:"resource_key"`
|
||||
Reason string `json:"reason"`
|
||||
Description string `json:"description"`
|
||||
Contact string `json:"contact"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
IPAddress string `json:"ip_address"`
|
||||
Status string `json:"status"`
|
||||
Note string `json:"note"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
Resources []ResourceInfo `json:"resources"` // 关联的资源列表
|
||||
}
|
||||
|
||||
// ReportListRequest 举报列表请求
|
||||
type ReportListRequest struct {
|
||||
Page int `query:"page" validate:"omitempty,min=1"`
|
||||
PageSize int `query:"page_size" validate:"omitempty,min=1,max=100"`
|
||||
Status string `query:"status" validate:"omitempty,oneof=pending approved rejected"`
|
||||
}
|
||||
@@ -132,3 +132,32 @@ type SearchRequest struct {
|
||||
Page int `json:"page"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
|
||||
// CreateTaskRequest 创建任务请求
|
||||
type CreateTaskRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
TaskType string `json:"task_type" binding:"required"`
|
||||
ConfigID *uint `json:"config_id"`
|
||||
}
|
||||
|
||||
// CreateTaskItemRequest 创建任务项请求
|
||||
type CreateTaskItemRequest struct {
|
||||
URL string `json:"url"`
|
||||
InputData map[string]interface{} `json:"input_data"`
|
||||
}
|
||||
|
||||
// QueryTaskRequest 查询任务请求
|
||||
type QueryTaskRequest struct {
|
||||
Page int `json:"page" form:"page"`
|
||||
PageSize int `json:"page_size" form:"page_size"`
|
||||
Status string `json:"status" form:"status"`
|
||||
Type string `json:"type" form:"type"`
|
||||
}
|
||||
|
||||
// QueryTaskItemRequest 查询任务项请求
|
||||
type QueryTaskItemRequest struct {
|
||||
Page int `json:"page" form:"page"`
|
||||
PageSize int `json:"page_size" form:"page_size"`
|
||||
Status string `json:"status" form:"status"`
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ type SearchResponse struct {
|
||||
// ResourceResponse 资源响应
|
||||
type ResourceResponse struct {
|
||||
ID uint `json:"id"`
|
||||
Key string `json:"key"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
URL string `json:"url"`
|
||||
@@ -32,6 +33,7 @@ type ResourceResponse struct {
|
||||
ErrorMsg string `json:"error_msg"`
|
||||
SyncedToMeilisearch bool `json:"synced_to_meilisearch"`
|
||||
SyncedAt *time.Time `json:"synced_at"`
|
||||
Pan *PanResponse `json:"pan,omitempty"` // 平台信息
|
||||
// 高亮字段
|
||||
TitleHighlight string `json:"title_highlight,omitempty"`
|
||||
DescriptionHighlight string `json:"description_highlight,omitempty"`
|
||||
|
||||
@@ -50,6 +50,9 @@ type SystemConfigRequest struct {
|
||||
WechatSearchImage *string `json:"wechat_search_image,omitempty"`
|
||||
TelegramQrImage *string `json:"telegram_qr_image,omitempty"`
|
||||
QrCodeStyle *string `json:"qr_code_style,omitempty"`
|
||||
|
||||
// 网站URL配置
|
||||
SiteURL *string `json:"site_url,omitempty"`
|
||||
}
|
||||
|
||||
// SystemConfigResponse 系统配置响应
|
||||
@@ -106,6 +109,9 @@ type SystemConfigResponse struct {
|
||||
WechatSearchImage string `json:"wechat_search_image"`
|
||||
TelegramQrImage string `json:"telegram_qr_image"`
|
||||
QrCodeStyle string `json:"qr_code_style"`
|
||||
|
||||
// 网站URL配置
|
||||
SiteURL string `json:"site_url"`
|
||||
}
|
||||
|
||||
// SystemConfigItem 单个配置项
|
||||
|
||||
32
db/entity/copyright_claim.go
Normal file
32
db/entity/copyright_claim.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package entity
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CopyrightClaim 版权申述实体
|
||||
type CopyrightClaim struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
ResourceKey string `gorm:"type:varchar(255);not null;index" json:"resource_key"` // 资源唯一标识
|
||||
Identity string `gorm:"type:varchar(50);not null" json:"identity"` // 申述人身份
|
||||
ProofType string `gorm:"type:varchar(50);not null" json:"proof_type"` // 证明类型
|
||||
Reason string `gorm:"type:text;not null" json:"reason"` // 申述理由
|
||||
ContactInfo string `gorm:"type:varchar(255);not null" json:"contact_info"` // 联系信息
|
||||
ClaimantName string `gorm:"type:varchar(100);not null" json:"claimant_name"` // 申述人姓名
|
||||
ProofFiles string `gorm:"type:text" json:"proof_files"` // 证明文件(JSON格式)
|
||||
UserAgent string `gorm:"type:text" json:"user_agent"` // 用户代理
|
||||
IPAddress string `gorm:"type:varchar(45)" json:"ip_address"` // IP地址
|
||||
Status string `gorm:"type:varchar(20);default:'pending'" json:"status"` // 处理状态: pending, approved, rejected
|
||||
ProcessedAt *time.Time `json:"processed_at"` // 处理时间
|
||||
ProcessedBy *uint `json:"processed_by"` // 处理人ID
|
||||
Note string `gorm:"type:text" json:"note"` // 处理备注
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"deleted_at"`
|
||||
}
|
||||
|
||||
// TableName 表名
|
||||
func (CopyrightClaim) TableName() string {
|
||||
return "copyright_claims"
|
||||
}
|
||||
28
db/entity/google_index_config_constants.go
Normal file
28
db/entity/google_index_config_constants.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package entity
|
||||
|
||||
// GoogleIndexConfigKeys Google索引配置键常量
|
||||
const (
|
||||
// 通用配置
|
||||
GoogleIndexConfigKeyEnabled = "google_index_enabled" // 是否启用Google索引功能
|
||||
GoogleIndexConfigKeySiteURL = "google_index_site_url" // 网站URL
|
||||
GoogleIndexConfigKeySiteName = "google_index_site_name" // 网站名称
|
||||
|
||||
// 认证配置
|
||||
GoogleIndexConfigKeyCredentialsFile = "google_index_credentials_file" // 凭证文件路径
|
||||
GoogleIndexConfigKeyClientEmail = "google_index_client_email" // 客户端邮箱
|
||||
GoogleIndexConfigKeyClientID = "google_index_client_id" // 客户端ID
|
||||
GoogleIndexConfigKeyPrivateKey = "google_index_private_key" // 私钥(加密存储)
|
||||
GoogleIndexConfigKeyToken = "google_index_token" // 访问令牌
|
||||
|
||||
// 调度配置
|
||||
GoogleIndexConfigKeyCheckInterval = "google_index_check_interval" // 检查间隔(分钟)
|
||||
GoogleIndexConfigKeyBatchSize = "google_index_batch_size" // 批处理大小
|
||||
GoogleIndexConfigKeyConcurrency = "google_index_concurrency" // 并发数
|
||||
GoogleIndexConfigKeyRetryAttempts = "google_index_retry_attempts" // 重试次数
|
||||
GoogleIndexConfigKeyRetryDelay = "google_index_retry_delay" // 重试延迟(秒)
|
||||
|
||||
// 网站地图配置
|
||||
GoogleIndexConfigKeyAutoSitemap = "google_index_auto_sitemap" // 自动提交网站地图
|
||||
GoogleIndexConfigKeySitemapPath = "google_index_sitemap_path" // 网站地图路径
|
||||
GoogleIndexConfigKeySitemapSchedule = "google_index_sitemap_schedule" // 网站地图调度
|
||||
)
|
||||
29
db/entity/report.go
Normal file
29
db/entity/report.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package entity
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Report 举报实体
|
||||
type Report struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
ResourceKey string `gorm:"type:varchar(255);not null;index" json:"resource_key"` // 资源唯一标识
|
||||
Reason string `gorm:"type:varchar(100);not null" json:"reason"` // 举报原因
|
||||
Description string `gorm:"type:text" json:"description"` // 详细描述
|
||||
Contact string `gorm:"type:varchar(255)" json:"contact"` // 联系方式
|
||||
UserAgent string `gorm:"type:text" json:"user_agent"` // 用户代理
|
||||
IPAddress string `gorm:"type:varchar(45)" json:"ip_address"` // IP地址
|
||||
Status string `gorm:"type:varchar(20);default:'pending'" json:"status"` // 处理状态: pending, approved, rejected
|
||||
ProcessedAt *time.Time `json:"processed_at"` // 处理时间
|
||||
ProcessedBy *uint `json:"processed_by"` // 处理人ID
|
||||
Note string `gorm:"type:text" json:"note"` // 处理备注
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"deleted_at"`
|
||||
}
|
||||
|
||||
// TableName 表名
|
||||
func (Report) TableName() string {
|
||||
return "reports"
|
||||
}
|
||||
@@ -74,6 +74,14 @@ const (
|
||||
ConfigKeyWechatSearchImage = "wechat_search_image"
|
||||
ConfigKeyTelegramQrImage = "telegram_qr_image"
|
||||
ConfigKeyQrCodeStyle = "qr_code_style"
|
||||
|
||||
// Sitemap配置
|
||||
ConfigKeySitemapConfig = "sitemap_config"
|
||||
ConfigKeySitemapLastGenerateTime = "sitemap_last_generate_time"
|
||||
ConfigKeySitemapAutoGenerateEnabled = "sitemap_auto_generate_enabled"
|
||||
|
||||
// 网站URL配置
|
||||
ConfigKeyWebsiteURL = "website_url"
|
||||
)
|
||||
|
||||
// ConfigType 配置类型常量
|
||||
@@ -162,6 +170,9 @@ const (
|
||||
ConfigResponseFieldWechatSearchImage = "wechat_search_image"
|
||||
ConfigResponseFieldTelegramQrImage = "telegram_qr_image"
|
||||
ConfigResponseFieldQrCodeStyle = "qr_code_style"
|
||||
|
||||
// 网站URL配置字段
|
||||
ConfigResponseFieldWebsiteURL = "site_url"
|
||||
)
|
||||
|
||||
// ConfigDefaultValue 配置默认值常量
|
||||
@@ -237,4 +248,7 @@ const (
|
||||
ConfigDefaultWechatSearchImage = ""
|
||||
ConfigDefaultTelegramQrImage = ""
|
||||
ConfigDefaultQrCodeStyle = "Plain"
|
||||
|
||||
// 网站URL配置默认值
|
||||
ConfigDefaultWebsiteURL = "https://example.com"
|
||||
)
|
||||
|
||||
@@ -24,21 +24,24 @@ type TaskType string
|
||||
const (
|
||||
TaskTypeBatchTransfer TaskType = "batch_transfer" // 批量转存
|
||||
TaskTypeExpansion TaskType = "expansion" // 账号扩容
|
||||
TaskTypeGoogleIndex TaskType = "google_index" // Google索引
|
||||
)
|
||||
|
||||
// Task 任务表
|
||||
type Task struct {
|
||||
ID uint `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
Title string `json:"title" gorm:"size:255;not null;comment:任务标题"`
|
||||
Name string `json:"name" gorm:"size:255;not null;default:'';comment:任务名称"`
|
||||
Type TaskType `json:"type" gorm:"size:50;not null;comment:任务类型"`
|
||||
Status TaskStatus `json:"status" gorm:"size:20;not null;default:pending;comment:任务状态"`
|
||||
Description string `json:"description" gorm:"type:text;comment:任务描述"`
|
||||
|
||||
// 进度信息
|
||||
TotalItems int `json:"total_items" gorm:"not null;default:0;comment:总项目数"`
|
||||
ProcessedItems int `json:"processed_items" gorm:"not null;default:0;comment:已处理项目数"`
|
||||
SuccessItems int `json:"success_items" gorm:"not null;default:0;comment:成功项目数"`
|
||||
FailedItems int `json:"failed_items" gorm:"not null;default:0;comment:失败项目数"`
|
||||
TotalItems int `json:"total_items" gorm:"not null;default:0;comment:总项目数"`
|
||||
ProcessedItems int `json:"processed_items" gorm:"not null;default:0;comment:已处理项目数"`
|
||||
SuccessItems int `json:"success_items" gorm:"not null;default:0;comment:成功项目数"`
|
||||
FailedItems int `json:"failed_items" gorm:"not null;default:0;comment:失败项目数"`
|
||||
Progress float64 `json:"progress" gorm:"not null;default:0.0;comment:任务进度"`
|
||||
|
||||
// 任务配置 (JSON格式存储)
|
||||
Config string `json:"config" gorm:"type:text;comment:任务配置"`
|
||||
@@ -46,6 +49,16 @@ type Task struct {
|
||||
// 任务消息
|
||||
Message string `json:"message" gorm:"type:text;comment:任务消息"`
|
||||
|
||||
// 进度数据 (JSON格式存储)
|
||||
ProgressData string `json:"progress_data" gorm:"type:text;comment:进度数据"`
|
||||
|
||||
// Google索引特有字段 (当Type为google_index时使用)
|
||||
IndexedURLs int `json:"indexed_urls" gorm:"default:0;comment:已索引URL数量"`
|
||||
FailedURLs int `json:"failed_urls" gorm:"default:0;comment:失败URL数量"`
|
||||
|
||||
// 配置关联 (用于Google索引任务)
|
||||
ConfigID *uint `json:"config_id" gorm:"comment:配置ID"`
|
||||
|
||||
// 时间信息
|
||||
StartedAt *time.Time `json:"started_at" gorm:"comment:开始时间"`
|
||||
CompletedAt *time.Time `json:"completed_at" gorm:"comment:完成时间"`
|
||||
|
||||
@@ -35,6 +35,14 @@ type TaskItem struct {
|
||||
// 处理日志 (可选,用于记录详细的处理过程)
|
||||
ProcessLog string `json:"process_log" gorm:"type:text;comment:处理日志"`
|
||||
|
||||
// Google索引特有字段 (当任务类型为google_index时使用)
|
||||
URL string `json:"url" gorm:"size:2048;comment:URL (Google索引专用)"`
|
||||
IndexStatus string `json:"index_status" gorm:"size:50;comment:索引状态 (Google索引专用)"`
|
||||
InspectResult string `json:"inspect_result" gorm:"type:text;comment:检查结果 (Google索引专用)"`
|
||||
MobileFriendly bool `json:"mobile_friendly" gorm:"default:false;comment:是否移动友好 (Google索引专用)"`
|
||||
LastCrawled *time.Time `json:"last_crawled" gorm:"comment:最后抓取时间 (Google索引专用)"`
|
||||
StatusCode int `json:"status_code" gorm:"default:0;comment:HTTP状态码 (Google索引专用)"`
|
||||
|
||||
// 时间信息
|
||||
ProcessedAt *time.Time `json:"processed_at" gorm:"comment:处理时间"`
|
||||
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
|
||||
|
||||
@@ -12,6 +12,7 @@ type BaseRepository[T any] interface {
|
||||
Update(entity *T) error
|
||||
Delete(id uint) error
|
||||
FindWithPagination(page, limit int) ([]T, int64, error)
|
||||
GetDB() *gorm.DB
|
||||
}
|
||||
|
||||
// BaseRepositoryImpl 基础Repository实现
|
||||
|
||||
87
db/repo/copyright_claim_repository.go
Normal file
87
db/repo/copyright_claim_repository.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
"github.com/ctwj/urldb/db/entity"
|
||||
)
|
||||
|
||||
// CopyrightClaimRepository 版权申述Repository接口
|
||||
type CopyrightClaimRepository interface {
|
||||
BaseRepository[entity.CopyrightClaim]
|
||||
GetByResourceKey(resourceKey string) ([]*entity.CopyrightClaim, error)
|
||||
List(status string, page, pageSize int) ([]*entity.CopyrightClaim, int64, error)
|
||||
UpdateStatus(id uint, status string, processedBy *uint, note string) error
|
||||
// 兼容原有方法名
|
||||
GetByID(id uint) (*entity.CopyrightClaim, error)
|
||||
}
|
||||
|
||||
// CopyrightClaimRepositoryImpl 版权申述Repository实现
|
||||
type CopyrightClaimRepositoryImpl struct {
|
||||
BaseRepositoryImpl[entity.CopyrightClaim]
|
||||
}
|
||||
|
||||
// NewCopyrightClaimRepository 创建版权申述Repository
|
||||
func NewCopyrightClaimRepository(db *gorm.DB) CopyrightClaimRepository {
|
||||
return &CopyrightClaimRepositoryImpl{
|
||||
BaseRepositoryImpl: BaseRepositoryImpl[entity.CopyrightClaim]{db: db},
|
||||
}
|
||||
}
|
||||
|
||||
// Create 创建版权申述
|
||||
func (r *CopyrightClaimRepositoryImpl) Create(claim *entity.CopyrightClaim) error {
|
||||
return r.GetDB().Create(claim).Error
|
||||
}
|
||||
|
||||
// GetByID 根据ID获取版权申述
|
||||
func (r *CopyrightClaimRepositoryImpl) GetByID(id uint) (*entity.CopyrightClaim, error) {
|
||||
var claim entity.CopyrightClaim
|
||||
err := r.GetDB().Where("id = ?", id).First(&claim).Error
|
||||
return &claim, err
|
||||
}
|
||||
|
||||
// GetByResourceKey 获取某个资源的所有版权申述
|
||||
func (r *CopyrightClaimRepositoryImpl) GetByResourceKey(resourceKey string) ([]*entity.CopyrightClaim, error) {
|
||||
var claims []*entity.CopyrightClaim
|
||||
err := r.GetDB().Where("resource_key = ?", resourceKey).Find(&claims).Error
|
||||
return claims, err
|
||||
}
|
||||
|
||||
// List 获取版权申述列表
|
||||
func (r *CopyrightClaimRepositoryImpl) List(status string, page, pageSize int) ([]*entity.CopyrightClaim, int64, error) {
|
||||
var claims []*entity.CopyrightClaim
|
||||
var total int64
|
||||
|
||||
query := r.GetDB().Model(&entity.CopyrightClaim{})
|
||||
|
||||
if status != "" {
|
||||
query = query.Where("status = ?", status)
|
||||
}
|
||||
|
||||
// 获取总数
|
||||
query.Count(&total)
|
||||
|
||||
// 分页查询
|
||||
offset := (page - 1) * pageSize
|
||||
err := query.Offset(offset).Limit(pageSize).Order("created_at DESC").Find(&claims).Error
|
||||
return claims, total, err
|
||||
}
|
||||
|
||||
// Update 更新版权申述
|
||||
func (r *CopyrightClaimRepositoryImpl) Update(claim *entity.CopyrightClaim) error {
|
||||
return r.GetDB().Save(claim).Error
|
||||
}
|
||||
|
||||
// UpdateStatus 更新版权申述状态
|
||||
func (r *CopyrightClaimRepositoryImpl) UpdateStatus(id uint, status string, processedBy *uint, note string) error {
|
||||
return r.GetDB().Model(&entity.CopyrightClaim{}).Where("id = ?", id).Updates(map[string]interface{}{
|
||||
"status": status,
|
||||
"processed_at": gorm.Expr("NOW()"),
|
||||
"processed_by": processedBy,
|
||||
"note": note,
|
||||
}).Error
|
||||
}
|
||||
|
||||
// Delete 删除版权申述
|
||||
func (r *CopyrightClaimRepositoryImpl) Delete(id uint) error {
|
||||
return r.GetDB().Delete(&entity.CopyrightClaim{}, id).Error
|
||||
}
|
||||
@@ -22,6 +22,8 @@ type RepositoryManager struct {
|
||||
FileRepository FileRepository
|
||||
TelegramChannelRepository TelegramChannelRepository
|
||||
APIAccessLogRepository APIAccessLogRepository
|
||||
ReportRepository ReportRepository
|
||||
CopyrightClaimRepository CopyrightClaimRepository
|
||||
}
|
||||
|
||||
// NewRepositoryManager 创建Repository管理器
|
||||
@@ -43,5 +45,7 @@ func NewRepositoryManager(db *gorm.DB) *RepositoryManager {
|
||||
FileRepository: NewFileRepository(db),
|
||||
TelegramChannelRepository: NewTelegramChannelRepository(db),
|
||||
APIAccessLogRepository: NewAPIAccessLogRepository(db),
|
||||
ReportRepository: NewReportRepository(db),
|
||||
CopyrightClaimRepository: NewCopyrightClaimRepository(db),
|
||||
}
|
||||
}
|
||||
|
||||
87
db/repo/report_repository.go
Normal file
87
db/repo/report_repository.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
"github.com/ctwj/urldb/db/entity"
|
||||
)
|
||||
|
||||
// ReportRepository 举报Repository接口
|
||||
type ReportRepository interface {
|
||||
BaseRepository[entity.Report]
|
||||
GetByResourceKey(resourceKey string) ([]*entity.Report, error)
|
||||
List(status string, page, pageSize int) ([]*entity.Report, int64, error)
|
||||
UpdateStatus(id uint, status string, processedBy *uint, note string) error
|
||||
// 兼容原有方法名
|
||||
GetByID(id uint) (*entity.Report, error)
|
||||
}
|
||||
|
||||
// ReportRepositoryImpl 举报Repository实现
|
||||
type ReportRepositoryImpl struct {
|
||||
BaseRepositoryImpl[entity.Report]
|
||||
}
|
||||
|
||||
// NewReportRepository 创建举报Repository
|
||||
func NewReportRepository(db *gorm.DB) ReportRepository {
|
||||
return &ReportRepositoryImpl{
|
||||
BaseRepositoryImpl: BaseRepositoryImpl[entity.Report]{db: db},
|
||||
}
|
||||
}
|
||||
|
||||
// Create 创建举报
|
||||
func (r *ReportRepositoryImpl) Create(report *entity.Report) error {
|
||||
return r.GetDB().Create(report).Error
|
||||
}
|
||||
|
||||
// GetByID 根据ID获取举报
|
||||
func (r *ReportRepositoryImpl) GetByID(id uint) (*entity.Report, error) {
|
||||
var report entity.Report
|
||||
err := r.GetDB().Where("id = ?", id).First(&report).Error
|
||||
return &report, err
|
||||
}
|
||||
|
||||
// GetByResourceKey 获取某个资源的所有举报
|
||||
func (r *ReportRepositoryImpl) GetByResourceKey(resourceKey string) ([]*entity.Report, error) {
|
||||
var reports []*entity.Report
|
||||
err := r.GetDB().Where("resource_key = ?", resourceKey).Find(&reports).Error
|
||||
return reports, err
|
||||
}
|
||||
|
||||
// List 获取举报列表
|
||||
func (r *ReportRepositoryImpl) List(status string, page, pageSize int) ([]*entity.Report, int64, error) {
|
||||
var reports []*entity.Report
|
||||
var total int64
|
||||
|
||||
query := r.GetDB().Model(&entity.Report{})
|
||||
|
||||
if status != "" {
|
||||
query = query.Where("status = ?", status)
|
||||
}
|
||||
|
||||
// 获取总数
|
||||
query.Count(&total)
|
||||
|
||||
// 分页查询
|
||||
offset := (page - 1) * pageSize
|
||||
err := query.Offset(offset).Limit(pageSize).Order("created_at DESC").Find(&reports).Error
|
||||
return reports, total, err
|
||||
}
|
||||
|
||||
// Update 更新举报
|
||||
func (r *ReportRepositoryImpl) Update(report *entity.Report) error {
|
||||
return r.GetDB().Save(report).Error
|
||||
}
|
||||
|
||||
// UpdateStatus 更新举报状态
|
||||
func (r *ReportRepositoryImpl) UpdateStatus(id uint, status string, processedBy *uint, note string) error {
|
||||
return r.GetDB().Model(&entity.Report{}).Where("id = ?", id).Updates(map[string]interface{}{
|
||||
"status": status,
|
||||
"processed_at": gorm.Expr("NOW()"),
|
||||
"processed_by": processedBy,
|
||||
"note": note,
|
||||
}).Error
|
||||
}
|
||||
|
||||
// Delete 删除举报
|
||||
func (r *ReportRepositoryImpl) Delete(id uint) error {
|
||||
return r.GetDB().Delete(&entity.Report{}, id).Error
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package repo
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ctwj/urldb/db/entity"
|
||||
@@ -46,6 +48,11 @@ type ResourceRepository interface {
|
||||
GetRandomResourceWithFilters(categoryFilter, tagFilter string, isPushSavedInfo bool) (*entity.Resource, error)
|
||||
DeleteRelatedResources(ckID uint) (int64, error)
|
||||
CountResourcesByCkID(ckID uint) (int64, error)
|
||||
FindByResourceKey(key string) ([]entity.Resource, error)
|
||||
FindByKey(key string) ([]entity.Resource, error)
|
||||
GetHotResources(limit int) ([]entity.Resource, error)
|
||||
GetTotalCount() (int64, error)
|
||||
GetAllValidResources() ([]entity.Resource, error)
|
||||
}
|
||||
|
||||
// ResourceRepositoryImpl Resource的Repository实现
|
||||
@@ -242,6 +249,23 @@ func (r *ResourceRepositoryImpl) SearchWithFilters(params map[string]interface{}
|
||||
Where("resource_tags.tag_id = ?", tagEntity.ID)
|
||||
}
|
||||
}
|
||||
case "tag_ids": // 添加tag_ids参数支持(标签ID列表)
|
||||
if tagIdsStr, ok := value.(string); ok && tagIdsStr != "" {
|
||||
// 将逗号分隔的标签ID字符串转换为整数ID数组
|
||||
tagIdStrs := strings.Split(tagIdsStr, ",")
|
||||
var tagIds []uint
|
||||
for _, idStr := range tagIdStrs {
|
||||
idStr = strings.TrimSpace(idStr) // 去除空格
|
||||
if id, err := strconv.ParseUint(idStr, 10, 32); err == nil {
|
||||
tagIds = append(tagIds, uint(id))
|
||||
}
|
||||
}
|
||||
if len(tagIds) > 0 {
|
||||
// 通过中间表查找包含任一标签的资源
|
||||
db = db.Joins("JOIN resource_tags ON resources.id = resource_tags.resource_id").
|
||||
Where("resource_tags.tag_id IN ?", tagIds)
|
||||
}
|
||||
}
|
||||
case "pan_id": // 添加pan_id参数支持
|
||||
if panID, ok := value.(uint); ok {
|
||||
db = db.Where("pan_id = ?", panID)
|
||||
@@ -335,12 +359,37 @@ func (r *ResourceRepositoryImpl) SearchWithFilters(params map[string]interface{}
|
||||
// 计算偏移量
|
||||
offset := (page - 1) * pageSize
|
||||
|
||||
// 获取分页数据,按更新时间倒序
|
||||
// 处理排序参数
|
||||
orderBy := "updated_at"
|
||||
orderDir := "DESC"
|
||||
|
||||
if orderByVal, ok := params["order_by"].(string); ok && orderByVal != "" {
|
||||
// 验证排序字段,防止SQL注入
|
||||
validOrderByFields := map[string]bool{
|
||||
"created_at": true,
|
||||
"updated_at": true,
|
||||
"view_count": true,
|
||||
"title": true,
|
||||
"id": true,
|
||||
}
|
||||
if validOrderByFields[orderByVal] {
|
||||
orderBy = orderByVal
|
||||
}
|
||||
}
|
||||
|
||||
if orderDirVal, ok := params["order_dir"].(string); ok && orderDirVal != "" {
|
||||
// 验证排序方向
|
||||
if orderDirVal == "ASC" || orderDirVal == "DESC" {
|
||||
orderDir = orderDirVal
|
||||
}
|
||||
}
|
||||
|
||||
// 获取分页数据,应用排序
|
||||
queryStart := utils.GetCurrentTime()
|
||||
err := db.Order("updated_at DESC").Offset(offset).Limit(pageSize).Find(&resources).Error
|
||||
err := db.Order(fmt.Sprintf("%s %s", orderBy, orderDir)).Offset(offset).Limit(pageSize).Find(&resources).Error
|
||||
queryDuration := time.Since(queryStart)
|
||||
totalDuration := time.Since(startTime)
|
||||
utils.Debug("SearchWithFilters完成: 总数=%d, 当前页数据量=%d, 查询耗时=%v, 总耗时=%v", total, len(resources), queryDuration, totalDuration)
|
||||
utils.Debug("SearchWithFilters完成: 总数=%d, 当前页数据量=%d, 排序=%s %s, 查询耗时=%v, 总耗时=%v", total, len(resources), orderBy, orderDir, queryDuration, totalDuration)
|
||||
return resources, total, err
|
||||
}
|
||||
|
||||
@@ -692,3 +741,78 @@ func (r *ResourceRepositoryImpl) CountResourcesByCkID(ckID uint) (int64, error)
|
||||
Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
// FindByKey 根据Key查找资源(同一组资源)
|
||||
func (r *ResourceRepositoryImpl) FindByKey(key string) ([]entity.Resource, error) {
|
||||
var resources []entity.Resource
|
||||
err := r.db.Where("key = ?", key).
|
||||
Preload("Category").
|
||||
Preload("Pan").
|
||||
Preload("Tags").
|
||||
Order("pan_id ASC").
|
||||
Find(&resources).Error
|
||||
return resources, err
|
||||
}
|
||||
|
||||
// GetHotResources 获取热门资源(按查看次数排序,去重,限制数量)
|
||||
func (r *ResourceRepositoryImpl) GetHotResources(limit int) ([]entity.Resource, error) {
|
||||
var resources []entity.Resource
|
||||
|
||||
// 按key分组,获取每个key中查看次数最高的资源,然后按查看次数排序
|
||||
err := r.db.Table("resources").
|
||||
Select(`
|
||||
resources.*,
|
||||
ROW_NUMBER() OVER (PARTITION BY key ORDER BY view_count DESC) as rn
|
||||
`).
|
||||
Where("is_public = ? AND view_count > 0", true).
|
||||
Preload("Category").
|
||||
Preload("Pan").
|
||||
Preload("Tags").
|
||||
Order("view_count DESC").
|
||||
Limit(limit * 2). // 获取更多数据以确保去重后有足够的结果
|
||||
Find(&resources).Error
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 按key去重,保留每个key的第一个(即查看次数最高的)
|
||||
seenKeys := make(map[string]bool)
|
||||
var hotResources []entity.Resource
|
||||
for _, resource := range resources {
|
||||
if !seenKeys[resource.Key] {
|
||||
seenKeys[resource.Key] = true
|
||||
hotResources = append(hotResources, resource)
|
||||
if len(hotResources) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return hotResources, nil
|
||||
}
|
||||
|
||||
// FindByResourceKey 根据资源Key查找资源
|
||||
func (r *ResourceRepositoryImpl) FindByResourceKey(key string) ([]entity.Resource, error) {
|
||||
var resources []entity.Resource
|
||||
err := r.GetDB().Where("key = ?", key).Find(&resources).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resources, nil
|
||||
}
|
||||
|
||||
// GetTotalCount 获取资源总数
|
||||
func (r *ResourceRepositoryImpl) GetTotalCount() (int64, error) {
|
||||
var count int64
|
||||
err := r.GetDB().Model(&entity.Resource{}).Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
// GetAllValidResources 获取所有有效的公开资源
|
||||
func (r *ResourceRepositoryImpl) GetAllValidResources() ([]entity.Resource, error) {
|
||||
var resources []entity.Resource
|
||||
err := r.GetDB().Where("is_valid = ? AND is_public = ?", true, true).
|
||||
Find(&resources).Error
|
||||
return resources, err
|
||||
}
|
||||
|
||||
@@ -143,6 +143,17 @@ func (r *SystemConfigRepositoryImpl) GetOrCreateDefault() ([]entity.SystemConfig
|
||||
{Key: entity.ConfigKeyWechatSearchImage, Value: entity.ConfigDefaultWechatSearchImage, Type: entity.ConfigTypeString},
|
||||
{Key: entity.ConfigKeyTelegramQrImage, Value: entity.ConfigDefaultTelegramQrImage, Type: entity.ConfigTypeString},
|
||||
{Key: entity.ConfigKeyQrCodeStyle, Value: entity.ConfigDefaultQrCodeStyle, Type: entity.ConfigTypeString},
|
||||
{Key: entity.ConfigKeyWebsiteURL, Value: entity.ConfigDefaultWebsiteURL, Type: entity.ConfigTypeString},
|
||||
// Google索引配置
|
||||
{Key: entity.GoogleIndexConfigKeyEnabled, Value: "false", Type: entity.ConfigTypeBool},
|
||||
{Key: entity.GoogleIndexConfigKeySiteName, Value: entity.ConfigDefaultSiteTitle, Type: entity.ConfigTypeString},
|
||||
{Key: entity.GoogleIndexConfigKeyCheckInterval, Value: "60", Type: entity.ConfigTypeInt},
|
||||
{Key: entity.GoogleIndexConfigKeyBatchSize, Value: "10", Type: entity.ConfigTypeInt},
|
||||
{Key: entity.GoogleIndexConfigKeyConcurrency, Value: "2", Type: entity.ConfigTypeInt},
|
||||
{Key: entity.GoogleIndexConfigKeyRetryAttempts, Value: "3", Type: entity.ConfigTypeInt},
|
||||
{Key: entity.GoogleIndexConfigKeyRetryDelay, Value: "2", Type: entity.ConfigTypeInt},
|
||||
{Key: entity.GoogleIndexConfigKeyAutoSitemap, Value: "false", Type: entity.ConfigTypeBool},
|
||||
{Key: entity.GoogleIndexConfigKeySitemapPath, Value: "/sitemap.xml", Type: entity.ConfigTypeString},
|
||||
}
|
||||
|
||||
createStart := utils.GetCurrentTime()
|
||||
@@ -189,6 +200,17 @@ func (r *SystemConfigRepositoryImpl) GetOrCreateDefault() ([]entity.SystemConfig
|
||||
entity.ConfigKeyEnableFloatButtons: {Key: entity.ConfigKeyEnableFloatButtons, Value: entity.ConfigDefaultEnableFloatButtons, Type: entity.ConfigTypeBool},
|
||||
entity.ConfigKeyWechatSearchImage: {Key: entity.ConfigKeyWechatSearchImage, Value: entity.ConfigDefaultWechatSearchImage, Type: entity.ConfigTypeString},
|
||||
entity.ConfigKeyTelegramQrImage: {Key: entity.ConfigKeyTelegramQrImage, Value: entity.ConfigDefaultTelegramQrImage, Type: entity.ConfigTypeString},
|
||||
entity.ConfigKeyWebsiteURL: {Key: entity.ConfigKeyWebsiteURL, Value: entity.ConfigDefaultWebsiteURL, Type: entity.ConfigTypeString},
|
||||
// Google索引配置
|
||||
entity.GoogleIndexConfigKeyEnabled: {Key: entity.GoogleIndexConfigKeyEnabled, Value: "false", Type: entity.ConfigTypeBool},
|
||||
entity.GoogleIndexConfigKeySiteName: {Key: entity.GoogleIndexConfigKeySiteName, Value: entity.ConfigDefaultSiteTitle, Type: entity.ConfigTypeString},
|
||||
entity.GoogleIndexConfigKeyCheckInterval: {Key: entity.GoogleIndexConfigKeyCheckInterval, Value: "60", Type: entity.ConfigTypeInt},
|
||||
entity.GoogleIndexConfigKeyBatchSize: {Key: entity.GoogleIndexConfigKeyBatchSize, Value: "10", Type: entity.ConfigTypeInt},
|
||||
entity.GoogleIndexConfigKeyConcurrency: {Key: entity.GoogleIndexConfigKeyConcurrency, Value: "2", Type: entity.ConfigTypeInt},
|
||||
entity.GoogleIndexConfigKeyRetryAttempts: {Key: entity.GoogleIndexConfigKeyRetryAttempts, Value: "3", Type: entity.ConfigTypeInt},
|
||||
entity.GoogleIndexConfigKeyRetryDelay: {Key: entity.GoogleIndexConfigKeyRetryDelay, Value: "2", Type: entity.ConfigTypeInt},
|
||||
entity.GoogleIndexConfigKeyAutoSitemap: {Key: entity.GoogleIndexConfigKeyAutoSitemap, Value: "false", Type: entity.ConfigTypeBool},
|
||||
entity.GoogleIndexConfigKeySitemapPath: {Key: entity.GoogleIndexConfigKeySitemapPath, Value: "/sitemap.xml", Type: entity.ConfigTypeString},
|
||||
}
|
||||
|
||||
// 检查现有配置中是否有缺失的配置项
|
||||
@@ -302,6 +324,9 @@ func (r *SystemConfigRepositoryImpl) ValidateConfigIntegrity() error {
|
||||
entity.ConfigKeyMaintenanceMode,
|
||||
entity.ConfigKeyEnableRegister,
|
||||
entity.ConfigKeyThirdPartyStatsCode,
|
||||
// Google索引配置
|
||||
entity.GoogleIndexConfigKeyEnabled,
|
||||
entity.GoogleIndexConfigKeySiteName,
|
||||
}
|
||||
|
||||
existingKeys := make(map[string]bool)
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
type TaskItemRepository interface {
|
||||
GetByID(id uint) (*entity.TaskItem, error)
|
||||
Create(item *entity.TaskItem) error
|
||||
Update(item *entity.TaskItem) error
|
||||
Delete(id uint) error
|
||||
DeleteByTaskID(taskID uint) error
|
||||
GetByTaskIDAndStatus(taskID uint, status string) ([]*entity.TaskItem, error)
|
||||
@@ -19,7 +20,25 @@ type TaskItemRepository interface {
|
||||
UpdateStatus(id uint, status string) error
|
||||
UpdateStatusAndOutput(id uint, status, outputData string) error
|
||||
GetStatsByTaskID(taskID uint) (map[string]int, error)
|
||||
GetIndexStats() (map[string]int, error)
|
||||
ResetProcessingItems(taskID uint) error
|
||||
|
||||
// Google索引专用方法
|
||||
GetDistinctProcessedURLs() ([]string, error)
|
||||
GetLatestURLStatus(url string) (*entity.TaskItem, error)
|
||||
UpsertURLStatusRecords(taskID uint, urlResults []*URLStatusResult) error
|
||||
CleanupOldRecords() error
|
||||
}
|
||||
|
||||
// URLStatusResult 用于批量处理的结果
|
||||
type URLStatusResult struct {
|
||||
URL string
|
||||
IndexStatus string
|
||||
InspectResult string
|
||||
MobileFriendly bool
|
||||
StatusCode int
|
||||
LastCrawled *time.Time
|
||||
ErrorMessage string
|
||||
}
|
||||
|
||||
// TaskItemRepositoryImpl 任务项仓库实现
|
||||
@@ -49,6 +68,33 @@ func (r *TaskItemRepositoryImpl) Create(item *entity.TaskItem) error {
|
||||
return r.db.Create(item).Error
|
||||
}
|
||||
|
||||
// Update 更新任务项
|
||||
func (r *TaskItemRepositoryImpl) Update(item *entity.TaskItem) error {
|
||||
startTime := utils.GetCurrentTime()
|
||||
err := r.db.Model(&entity.TaskItem{}).Where("id = ?", item.ID).Updates(map[string]interface{}{
|
||||
"status": item.Status,
|
||||
"error_message": item.ErrorMessage,
|
||||
"index_status": item.IndexStatus,
|
||||
"mobile_friendly": item.MobileFriendly,
|
||||
"last_crawled": item.LastCrawled,
|
||||
"status_code": item.StatusCode,
|
||||
"input_data": item.InputData,
|
||||
"output_data": item.OutputData,
|
||||
"process_log": item.ProcessLog,
|
||||
"url": item.URL,
|
||||
"inspect_result": item.InspectResult,
|
||||
"processed_at": item.ProcessedAt,
|
||||
"updated_at": time.Now(),
|
||||
}).Error
|
||||
updateDuration := time.Since(startTime)
|
||||
if err != nil {
|
||||
utils.Error("Update任务项失败: ID=%d, 错误=%v, 更新耗时=%v", item.ID, err, updateDuration)
|
||||
return err
|
||||
}
|
||||
utils.Debug("Update任务项成功: ID=%d, 更新耗时=%v", item.ID, updateDuration)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete 删除任务项
|
||||
func (r *TaskItemRepositoryImpl) Delete(id uint) error {
|
||||
return r.db.Delete(&entity.TaskItem{}, id).Error
|
||||
@@ -182,3 +228,180 @@ func (r *TaskItemRepositoryImpl) ResetProcessingItems(taskID uint) error {
|
||||
utils.Debug("ResetProcessingItems成功: 任务ID=%d, 更新耗时=%v", taskID, updateDuration)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetIndexStats 获取索引统计信息
|
||||
func (r *TaskItemRepositoryImpl) GetIndexStats() (map[string]int, error) {
|
||||
stats := make(map[string]int)
|
||||
|
||||
// 统计各种状态的数量
|
||||
statuses := []string{"completed", "failed", "pending"}
|
||||
|
||||
for _, status := range statuses {
|
||||
var count int64
|
||||
err := r.db.Model(&entity.TaskItem{}).Where("status = ?", status).Count(&count).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
switch status {
|
||||
case "completed":
|
||||
stats["indexed"] = int(count)
|
||||
case "failed":
|
||||
stats["error"] = int(count)
|
||||
case "pending":
|
||||
stats["not_indexed"] = int(count)
|
||||
}
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// GetDistinctProcessedURLs 获取所有已处理的URL(去重)
|
||||
func (r *TaskItemRepositoryImpl) GetDistinctProcessedURLs() ([]string, error) {
|
||||
startTime := utils.GetCurrentTime()
|
||||
var urls []string
|
||||
|
||||
// 只返回成功处理的URL,避免处理失败的URL重复尝试
|
||||
err := r.db.Model(&entity.TaskItem{}).
|
||||
Where("status = ? AND url != ?", "completed", "").
|
||||
Distinct("url").
|
||||
Pluck("url", &urls).Error
|
||||
|
||||
queryDuration := time.Since(startTime)
|
||||
if err != nil {
|
||||
utils.Error("GetDistinctProcessedURLs失败: 错误=%v, 查询耗时=%v", err, queryDuration)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
utils.Debug("GetDistinctProcessedURLs成功: URL数量=%d, 查询耗时=%v", len(urls), queryDuration)
|
||||
return urls, nil
|
||||
}
|
||||
|
||||
// GetLatestURLStatus 获取URL的最新处理状态
|
||||
func (r *TaskItemRepositoryImpl) GetLatestURLStatus(url string) (*entity.TaskItem, error) {
|
||||
startTime := utils.GetCurrentTime()
|
||||
var item entity.TaskItem
|
||||
|
||||
err := r.db.Where("url = ?", url).
|
||||
Order("created_at DESC").
|
||||
First(&item).Error
|
||||
|
||||
queryDuration := time.Since(startTime)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
utils.Debug("GetLatestURLStatus: URL未找到=%s, 查询耗时=%v", url, queryDuration)
|
||||
return nil, nil
|
||||
}
|
||||
utils.Error("GetLatestURLStatus失败: URL=%s, 错误=%v, 查询耗时=%v", url, err, queryDuration)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
utils.Debug("GetLatestURLStatus成功: URL=%s, 状态=%s, 查询耗时=%v", url, item.Status, queryDuration)
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
// UpsertURLStatusRecords 批量创建或更新URL状态
|
||||
func (r *TaskItemRepositoryImpl) UpsertURLStatusRecords(taskID uint, urlResults []*URLStatusResult) error {
|
||||
startTime := utils.GetCurrentTime()
|
||||
|
||||
if len(urlResults) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 批量操作,减少数据库查询次数
|
||||
for _, result := range urlResults {
|
||||
// 查找现有记录
|
||||
existing, err := r.GetLatestURLStatus(result.URL)
|
||||
|
||||
if err != nil && err != gorm.ErrRecordNotFound {
|
||||
utils.Error("UpsertURLStatusRecords查询失败: URL=%s, 错误=%v", result.URL, err)
|
||||
continue
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
if existing != nil && existing.ID > 0 {
|
||||
// 更新现有记录(只更新状态变化的)
|
||||
if existing.IndexStatus != result.IndexStatus || existing.StatusCode != result.StatusCode {
|
||||
existing.IndexStatus = result.IndexStatus
|
||||
existing.InspectResult = result.InspectResult
|
||||
existing.MobileFriendly = result.MobileFriendly
|
||||
existing.StatusCode = result.StatusCode
|
||||
existing.LastCrawled = result.LastCrawled
|
||||
existing.ErrorMessage = result.ErrorMessage
|
||||
existing.ProcessedAt = &now
|
||||
|
||||
if err := r.Update(existing); err != nil {
|
||||
utils.Error("UpsertURLStatusRecords更新失败: URL=%s, 错误=%v", result.URL, err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 创建新记录
|
||||
newItem := &entity.TaskItem{
|
||||
TaskID: taskID,
|
||||
URL: result.URL,
|
||||
Status: "completed",
|
||||
IndexStatus: result.IndexStatus,
|
||||
InspectResult: result.InspectResult,
|
||||
MobileFriendly: result.MobileFriendly,
|
||||
StatusCode: result.StatusCode,
|
||||
LastCrawled: result.LastCrawled,
|
||||
ErrorMessage: result.ErrorMessage,
|
||||
ProcessedAt: &now,
|
||||
}
|
||||
|
||||
if err := r.Create(newItem); err != nil {
|
||||
utils.Error("UpsertURLStatusRecords创建失败: URL=%s, 错误=%v", result.URL, err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
totalDuration := time.Since(startTime)
|
||||
utils.Info("UpsertURLStatusRecords完成: 数量=%d, 耗时=%v", len(urlResults), totalDuration)
|
||||
return nil
|
||||
}
|
||||
|
||||
// CleanupOldRecords 清理旧记录,保留每个URL的最新记录
|
||||
func (r *TaskItemRepositoryImpl) CleanupOldRecords() error {
|
||||
startTime := utils.GetCurrentTime()
|
||||
|
||||
// 1. 找出每个URL的最新记录ID
|
||||
var latestIDs []uint
|
||||
err := r.db.Table("task_items").
|
||||
Select("MAX(id) as id").
|
||||
Where("url != '' AND status = ?", "completed").
|
||||
Group("url").
|
||||
Pluck("id", &latestIDs).Error
|
||||
|
||||
if err != nil {
|
||||
utils.Error("CleanupOldRecords获取最新ID失败: 错误=%v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// 2. 删除所有非最新的已完成记录
|
||||
deleteResult := r.db.Where("status = ? AND id NOT IN (?)", "completed", latestIDs).
|
||||
Delete(&entity.TaskItem{})
|
||||
|
||||
if deleteResult.Error != nil {
|
||||
utils.Error("CleanupOldRecords删除旧记录失败: 错误=%v", deleteResult.Error)
|
||||
return deleteResult.Error
|
||||
}
|
||||
|
||||
// 3. 清理失败的旧记录(保留1周)
|
||||
failureCutoff := time.Now().AddDate(0, 0, -7)
|
||||
failureDeleteResult := r.db.Where("status = ? AND created_at < ?", "failed", failureCutoff).
|
||||
Delete(&entity.TaskItem{})
|
||||
|
||||
if failureDeleteResult.Error != nil {
|
||||
utils.Error("CleanupOldRecords删除失败记录失败: 错误=%v", failureDeleteResult.Error)
|
||||
return failureDeleteResult.Error
|
||||
}
|
||||
|
||||
totalDuration := time.Since(startTime)
|
||||
utils.Info("CleanupOldRecords完成: 删除完成记录=%d, 删除失败记录=%d, 耗时=%v",
|
||||
deleteResult.RowsAffected, failureDeleteResult.RowsAffected, totalDuration)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ type TaskRepository interface {
|
||||
UpdateTaskStats(id uint, processed, success, failed int) error
|
||||
UpdateStartedAt(id uint) error
|
||||
UpdateCompletedAt(id uint) error
|
||||
UpdateTotalItems(id uint, totalItems int) error
|
||||
}
|
||||
|
||||
// TaskRepositoryImpl 任务仓库实现
|
||||
@@ -243,3 +244,16 @@ func (r *TaskRepositoryImpl) UpdateCompletedAt(id uint) error {
|
||||
utils.Debug("UpdateCompletedAt成功: ID=%d, 更新耗时=%v", id, updateDuration)
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateTotalItems 更新任务总项目数
|
||||
func (r *TaskRepositoryImpl) UpdateTotalItems(id uint, totalItems int) error {
|
||||
startTime := utils.GetCurrentTime()
|
||||
err := r.db.Model(&entity.Task{}).Where("id = ?", id).Update("total_items", totalItems).Error
|
||||
updateDuration := time.Since(startTime)
|
||||
if err != nil {
|
||||
utils.Error("UpdateTotalItems失败: ID=%d, 总项目数=%d, 错误=%v, 更新耗时=%v", id, totalItems, err, updateDuration)
|
||||
return err
|
||||
}
|
||||
utils.Debug("UpdateTotalItems成功: ID=%d, 总项目数=%d, 更新耗时=%v", id, totalItems, updateDuration)
|
||||
return nil
|
||||
}
|
||||
|
||||
BIN
font/SourceHanSansSC-Bold.otf
Normal file
BIN
font/SourceHanSansSC-Bold.otf
Normal file
Binary file not shown.
BIN
font/SourceHanSansSC-Regular.otf
Normal file
BIN
font/SourceHanSansSC-Regular.otf
Normal file
Binary file not shown.
40
go.mod
40
go.mod
@@ -1,10 +1,9 @@
|
||||
module github.com/ctwj/urldb
|
||||
|
||||
go 1.23.0
|
||||
|
||||
toolchain go1.23.3
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/fogleman/gg v1.3.0
|
||||
github.com/gin-contrib/cors v1.4.0
|
||||
github.com/gin-gonic/gin v1.10.1
|
||||
github.com/go-resty/resty/v2 v2.16.5
|
||||
@@ -12,33 +11,53 @@ require (
|
||||
github.com/golang-jwt/jwt/v5 v5.2.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/meilisearch/meilisearch-go v0.33.1
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/silenceper/wechat/v2 v2.1.10
|
||||
golang.org/x/crypto v0.41.0
|
||||
golang.org/x/oauth2 v0.30.0
|
||||
google.golang.org/api v0.197.0
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/gorm v1.30.0
|
||||
)
|
||||
|
||||
require (
|
||||
cloud.google.com/go/auth v0.9.3 // indirect
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.4 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.5.0 // indirect
|
||||
github.com/andybalholm/brotli v1.1.1 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bradfitz/gomemcache v0.0.0-20220106215444-fb4bf637b56d // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/fatih/structs v1.1.0 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/go-logr/logr v1.4.2 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-redis/redis/v8 v8.11.5 // indirect
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||
github.com/google/s2a-go v0.1.8 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.13.0 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/prometheus/client_golang v1.23.2 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.66.1 // indirect
|
||||
github.com/prometheus/procfs v0.16.1 // indirect
|
||||
github.com/silenceper/wechat/v2 v2.1.10 // indirect
|
||||
github.com/sirupsen/logrus v1.9.0 // indirect
|
||||
github.com/spf13/cast v1.4.1 // indirect
|
||||
github.com/tidwall/gjson v1.14.1 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.0 // indirect
|
||||
go.opencensus.io v0.24.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect
|
||||
go.opentelemetry.io/otel v1.29.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.29.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.29.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||
golang.org/x/image v0.32.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect
|
||||
google.golang.org/grpc v1.66.1 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -49,7 +68,7 @@ require (
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
||||
github.com/go-playground/validator/v10 v10.27.0
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
@@ -59,7 +78,6 @@ require (
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/kr/pretty v0.3.1 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/matoous/go-nanoid/v2 v2.1.0
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
@@ -71,9 +89,9 @@ require (
|
||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||
golang.org/x/arch v0.19.0 // indirect
|
||||
golang.org/x/net v0.43.0
|
||||
golang.org/x/sync v0.16.0 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/sys v0.35.0 // indirect
|
||||
golang.org/x/text v0.28.0 // indirect
|
||||
golang.org/x/text v0.30.0 // indirect
|
||||
google.golang.org/protobuf v1.36.8 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
143
go.sum
143
go.sum
@@ -1,4 +1,14 @@
|
||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go/auth v0.9.3 h1:VOEUIAADkkLtyfr3BLa3R8Ed/j6w1jTBmARx+wb5w5U=
|
||||
cloud.google.com/go/auth v0.9.3/go.mod h1:7z6VY+7h3KUdRov5F1i8NDP5ZzWKYmEPO842BgCsmTk=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.4 h1:0GWE/FUsXhf6C+jAkWgYm7X9tK8cuEIfy19DBn6B6bY=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.4/go.mod h1:jC/jOpwFP6JBxhB3P5Rr0a9HLMC/Pe3eaL4NmdvqPtc=
|
||||
cloud.google.com/go/compute/metadata v0.5.0 h1:Zr0eK8JbFv6+Wi4ilXAR8FJ3wyNdpxHKJNPos6LTZOY=
|
||||
cloud.google.com/go/compute/metadata v0.5.0/go.mod h1:aHnloV2TPI38yx4s9+wAZhHykWvVCfu7hQbF+9CWoiY=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a h1:HbKu58rmZpUGpz5+4FfNmIU+FmZg2P3Xaj2v2bfNWmk=
|
||||
github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc=
|
||||
github.com/alicebob/miniredis/v2 v2.30.0 h1:uA3uhDbCxfO9+DI/DuGeAMr9qI+noVWwGPNTFuKID5M=
|
||||
github.com/alicebob/miniredis/v2 v2.30.0/go.mod h1:84TWKZlxYkfgMucPBf5SOQBYJceZeQRFIaQgNMiCX6Q=
|
||||
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
|
||||
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
|
||||
@@ -11,24 +21,35 @@ github.com/bytedance/sonic v1.13.3/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
|
||||
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
|
||||
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
|
||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8=
|
||||
github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
|
||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
|
||||
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
|
||||
@@ -40,6 +61,11 @@ github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fq
|
||||
github.com/gin-gonic/gin v1.8.1/go.mod h1:ji8BvRH1azfM+SYow9zQ6SZMvR8qOMZHmsCuWR9tTTk=
|
||||
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
|
||||
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
@@ -66,23 +92,46 @@ github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXe
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
|
||||
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||
github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM=
|
||||
github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA=
|
||||
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA=
|
||||
github.com/googleapis/gax-go/v2 v2.13.0 h1:yitjD5f7jQHhyDsnhKEBU52NdvvdSeGzlAnDPT0hH1s=
|
||||
github.com/googleapis/gax-go/v2 v2.13.0/go.mod h1:Z/fvTZXF8/uw7Xu5GuslPw+bplx6SS338j1Is2S+B7A=
|
||||
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw=
|
||||
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||
@@ -102,6 +151,8 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
@@ -115,6 +166,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
@@ -134,15 +187,18 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms=
|
||||
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
|
||||
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
|
||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
|
||||
github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
|
||||
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
|
||||
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
|
||||
github.com/onsi/ginkgo/v2 v2.0.0/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c=
|
||||
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
|
||||
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
|
||||
github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
|
||||
github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE=
|
||||
github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs=
|
||||
github.com/pelletier/go-toml/v2 v2.0.1/go.mod h1:r9LEWfGN8R5k0VXJ+0BkIe7MYkRdwZOjgMj2KwnJFUo=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
@@ -152,6 +208,7 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
|
||||
@@ -162,14 +219,12 @@ github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
|
||||
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
|
||||
github.com/silenceper/wechat/v2 v2.1.10 h1:jMg0//CZBIuogEvuXgxJQuJ47SsPPAqFrrbOtro2pko=
|
||||
github.com/silenceper/wechat/v2 v2.1.10/go.mod h1:7Iu3EhQYVtDUJAj+ZVRy8yom75ga7aDWv8RurLkVm0s=
|
||||
github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0=
|
||||
github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/spf13/cast v1.4.1 h1:s0hze+J0196ZfEMTs80N7UlFt0BDuQ7Q+JDnHiMWKdA=
|
||||
github.com/spf13/cast v1.4.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
@@ -182,8 +237,8 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tidwall/gjson v1.14.1 h1:iymTbGkQBhveq21bEvAQ81I0LEBork8BFe1CUZXdyuo=
|
||||
github.com/tidwall/gjson v1.14.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||
@@ -199,7 +254,20 @@ github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2W
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/gopher-lua v0.0.0-20220504180219-658193537a64 h1:5mLPGnFdSsevFRFc9q3yYbBkB6tsm4aCwwQV/j1JQAQ=
|
||||
github.com/yuin/gopher-lua v0.0.0-20220504180219-658193537a64/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
|
||||
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
|
||||
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
|
||||
go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
|
||||
go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8=
|
||||
go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc=
|
||||
go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8=
|
||||
go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4=
|
||||
go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
|
||||
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
|
||||
golang.org/x/arch v0.19.0 h1:LmbDQUodHThXE+htjrnmVD73M//D9GTH6wFZjyDkjyU=
|
||||
@@ -209,28 +277,40 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
|
||||
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
|
||||
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
|
||||
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/image v0.32.0 h1:6lZQWq75h7L5IWNk0r+SCpUJ6tUVd3v4ZHnbRKLkUDQ=
|
||||
golang.org/x/image v0.32.0/go.mod h1:/R37rrQmKXtO6tYXAjtDLwQgFLHmhW+V6ayXlxzP2Pc=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
|
||||
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
|
||||
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
|
||||
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190204203706-41f3e6584952/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
@@ -250,38 +330,58 @@ golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
|
||||
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
|
||||
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
|
||||
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||
golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
|
||||
golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/api v0.197.0 h1:x6CwqQLsFiA5JKAiGyGBjc2bNtHtLddhJCE2IKuhhcQ=
|
||||
google.golang.org/api v0.197.0/go.mod h1:AuOuo20GoQ331nq7DquGHlU6d+2wN2fZ8O0ta60nRNw=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
||||
google.golang.org/genproto v0.0.0-20240903143218-8af14fe29dc1 h1:BulPr26Jqjnd4eYDVe+YvyR7Yc2vJGkO5/0UxD0/jZU=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20240711142825-46eb208f015d h1:kHjw/5UfflP/L5EbledDrcG4C2597RtymmGRZvHiCuY=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20240711142825-46eb208f015d/go.mod h1:mw8MG/Qz5wfgYr6VqVCiZcHe/GJEfI+oGGDCohaVgB0=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 h1:pPJltXNxVzT4pK9yD8vR9X75DaWYYmLGMsEvBfFQZzQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
|
||||
google.golang.org/grpc v1.66.1 h1:hO5qAXR19+/Z44hmvIM4dQFMSYX9XcWsByfoxutBpAM=
|
||||
google.golang.org/grpc v1.66.1/go.mod h1:s3/l6xSSCURdVfAnL+TqCNMyTDAGN6+lZeVxnZR128Y=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
|
||||
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
@@ -290,11 +390,14 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||
gopkg.in/h2non/gock.v1 v1.1.2 h1:jBbHXgGBK/AoPVfJh5x4r/WxIrElvbLel8TCZkkZJoY=
|
||||
gopkg.in/h2non/gock.v1 v1.1.2/go.mod h1:n7UGz/ckNChHiK05rDoiC4MYSunEC/lyaUm2WWaDva0=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -304,4 +407,6 @@ gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
||||
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
|
||||
gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs=
|
||||
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
|
||||
@@ -2,6 +2,7 @@ package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -110,32 +111,81 @@ func CreateCks(c *gin.Context) {
|
||||
}
|
||||
|
||||
var cks *entity.Cks
|
||||
// 迅雷网盘,添加的时候 只获取token就好, 然后刷新的时候, 再补充用户信息等
|
||||
// 迅雷网盘,使用账号密码登录
|
||||
if serviceType == panutils.Xunlei {
|
||||
xunleiService := service.(*panutils.XunleiPanService)
|
||||
tokenData, err := xunleiService.GetAccessTokenByRefreshToken(req.Ck)
|
||||
// 解析账号密码信息
|
||||
credentials, err := panutils.ParseCredentialsFromCk(req.Ck)
|
||||
if err != nil {
|
||||
ErrorResponse(c, "无法获取有效token: "+err.Error(), http.StatusBadRequest)
|
||||
ErrorResponse(c, "账号密码格式错误: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 验证账号密码
|
||||
if credentials.Username == "" || credentials.Password == "" {
|
||||
ErrorResponse(c, "请提供完整的账号和密码", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var tokenData *panutils.XunleiTokenData
|
||||
var username string
|
||||
|
||||
// 使用账号密码登录
|
||||
xunleiService := service.(*panutils.XunleiPanService)
|
||||
token, err := xunleiService.LoginWithCredentials(credentials.Username, credentials.Password)
|
||||
if err != nil {
|
||||
ErrorResponse(c, "账号密码登录失败: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
tokenData = &token
|
||||
username = credentials.Username
|
||||
|
||||
// 构建extra数据
|
||||
extra := panutils.XunleiExtraData{
|
||||
Token: &tokenData,
|
||||
Token: tokenData,
|
||||
Captcha: &panutils.CaptchaData{},
|
||||
}
|
||||
|
||||
// 如果有账号密码信息,保存到extra中
|
||||
if credentials.Username != "" && credentials.Password != "" {
|
||||
extra.Credentials = credentials
|
||||
}
|
||||
|
||||
extraStr, _ := json.Marshal(extra)
|
||||
|
||||
// 声明userInfo变量
|
||||
var userInfo *panutils.UserInfo
|
||||
|
||||
// 设置CKSRepository以便获取用户信息
|
||||
xunleiService.SetCKSRepository(repoManager.CksRepository, entity.Cks{})
|
||||
|
||||
// 获取用户信息
|
||||
userInfo, err = xunleiService.GetUserInfo(nil)
|
||||
if err != nil {
|
||||
log.Printf("获取迅雷用户信息失败,使用默认值: %v", err)
|
||||
// 如果获取失败,使用默认值
|
||||
userInfo = &panutils.UserInfo{
|
||||
Username: username,
|
||||
VIPStatus: false,
|
||||
ServiceType: "xunlei",
|
||||
TotalSpace: 0,
|
||||
UsedSpace: 0,
|
||||
}
|
||||
}
|
||||
|
||||
leftSpaceBytes := userInfo.TotalSpace - userInfo.UsedSpace
|
||||
|
||||
// 创建Cks实体
|
||||
cks = &entity.Cks{
|
||||
PanID: req.PanID,
|
||||
Idx: req.Idx,
|
||||
Ck: tokenData.RefreshToken,
|
||||
IsValid: true, // 根据VIP状态设置有效性
|
||||
Space: 0,
|
||||
LeftSpace: 0,
|
||||
UsedSpace: 0,
|
||||
Username: "-",
|
||||
VipStatus: false,
|
||||
ServiceType: "xunlei",
|
||||
Ck: req.Ck, // 保持原始输入
|
||||
IsValid: userInfo.VIPStatus, // 根据VIP状态设置有效性
|
||||
Space: userInfo.TotalSpace,
|
||||
LeftSpace: leftSpaceBytes,
|
||||
UsedSpace: userInfo.UsedSpace,
|
||||
Username: userInfo.Username,
|
||||
VipStatus: userInfo.VIPStatus,
|
||||
ServiceType: userInfo.ServiceType,
|
||||
Extra: string(extraStr),
|
||||
Remark: req.Remark,
|
||||
}
|
||||
@@ -388,14 +438,16 @@ func RefreshCapacity(c *gin.Context) {
|
||||
|
||||
var userInfo *panutils.UserInfo
|
||||
service.SetCKSRepository(repoManager.CksRepository, *cks) // 迅雷需要初始化 token 后才能获取,
|
||||
userInfo, err = service.GetUserInfo(&cks.Ck)
|
||||
// switch s := service.(type) {
|
||||
// case *panutils.XunleiPanService:
|
||||
|
||||
// userInfo, err = s.GetUserInfo(nil)
|
||||
// default:
|
||||
// userInfo, err = service.GetUserInfo(&cks.Ck)
|
||||
// }
|
||||
// 根据服务类型调用不同的GetUserInfo方法
|
||||
switch s := service.(type) {
|
||||
case *panutils.XunleiPanService:
|
||||
// 迅雷网盘使用存储在extra中的token,不需要传递ck参数
|
||||
userInfo, err = s.GetUserInfo(nil)
|
||||
default:
|
||||
// 其他网盘使用ck参数
|
||||
userInfo, err = service.GetUserInfo(&cks.Ck)
|
||||
}
|
||||
if err != nil {
|
||||
ErrorResponse(c, "无法获取用户信息,刷新失败: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
|
||||
312
handlers/copyright_claim_handler.go
Normal file
312
handlers/copyright_claim_handler.go
Normal file
@@ -0,0 +1,312 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"github.com/ctwj/urldb/db/converter"
|
||||
"github.com/ctwj/urldb/db/dto"
|
||||
"github.com/ctwj/urldb/db/entity"
|
||||
"github.com/ctwj/urldb/db/repo"
|
||||
"github.com/ctwj/urldb/middleware"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-playground/validator/v10"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type CopyrightClaimHandler struct {
|
||||
copyrightClaimRepo repo.CopyrightClaimRepository
|
||||
resourceRepo repo.ResourceRepository
|
||||
validate *validator.Validate
|
||||
}
|
||||
|
||||
func NewCopyrightClaimHandler(copyrightClaimRepo repo.CopyrightClaimRepository, resourceRepo repo.ResourceRepository) *CopyrightClaimHandler {
|
||||
return &CopyrightClaimHandler{
|
||||
copyrightClaimRepo: copyrightClaimRepo,
|
||||
resourceRepo: resourceRepo,
|
||||
validate: validator.New(),
|
||||
}
|
||||
}
|
||||
|
||||
// CreateCopyrightClaim 创建版权申述
|
||||
// @Summary 创建版权申述
|
||||
// @Description 提交资源版权申述
|
||||
// @Tags CopyrightClaim
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body dto.CopyrightClaimCreateRequest true "版权申述信息"
|
||||
// @Success 200 {object} Response{data=dto.CopyrightClaimResponse}
|
||||
// @Failure 400 {object} Response
|
||||
// @Failure 500 {object} Response
|
||||
// @Router /copyright-claims [post]
|
||||
func (h *CopyrightClaimHandler) CreateCopyrightClaim(c *gin.Context) {
|
||||
var req dto.CopyrightClaimCreateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
ErrorResponse(c, "参数错误: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 验证参数
|
||||
if err := h.validate.Struct(req); err != nil {
|
||||
ErrorResponse(c, "参数验证失败: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 创建版权申述实体
|
||||
claim := &entity.CopyrightClaim{
|
||||
ResourceKey: req.ResourceKey,
|
||||
Identity: req.Identity,
|
||||
ProofType: req.ProofType,
|
||||
Reason: req.Reason,
|
||||
ContactInfo: req.ContactInfo,
|
||||
ClaimantName: req.ClaimantName,
|
||||
ProofFiles: req.ProofFiles,
|
||||
UserAgent: req.UserAgent,
|
||||
IPAddress: req.IPAddress,
|
||||
Status: "pending", // 默认为待处理
|
||||
}
|
||||
|
||||
// 保存到数据库
|
||||
if err := h.copyrightClaimRepo.Create(claim); err != nil {
|
||||
ErrorResponse(c, "创建版权申述失败: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 返回响应
|
||||
response := converter.CopyrightClaimToResponse(claim)
|
||||
SuccessResponse(c, response)
|
||||
}
|
||||
|
||||
// GetCopyrightClaim 获取版权申述详情
|
||||
// @Summary 获取版权申述详情
|
||||
// @Description 根据ID获取版权申述详情
|
||||
// @Tags CopyrightClaim
|
||||
// @Produce json
|
||||
// @Param id path int true "版权申述ID"
|
||||
// @Success 200 {object} Response{data=dto.CopyrightClaimResponse}
|
||||
// @Failure 400 {object} Response
|
||||
// @Failure 404 {object} Response
|
||||
// @Failure 500 {object} Response
|
||||
// @Router /copyright-claims/{id} [get]
|
||||
func (h *CopyrightClaimHandler) GetCopyrightClaim(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
ErrorResponse(c, "无效的ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
claim, err := h.copyrightClaimRepo.GetByID(uint(id))
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
ErrorResponse(c, "版权申述不存在", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
ErrorResponse(c, "获取版权申述失败: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
SuccessResponse(c, converter.CopyrightClaimToResponse(claim))
|
||||
}
|
||||
|
||||
// ListCopyrightClaims 获取版权申述列表
|
||||
// @Summary 获取版权申述列表
|
||||
// @Description 获取版权申述列表(支持分页和状态筛选)
|
||||
// @Tags CopyrightClaim
|
||||
// @Produce json
|
||||
// @Param page query int false "页码" default(1)
|
||||
// @Param page_size query int false "每页数量" default(10)
|
||||
// @Param status query string false "处理状态"
|
||||
// @Success 200 {object} Response{data=object{items=[]dto.CopyrightClaimResponse,total=int}}
|
||||
// @Failure 400 {object} Response
|
||||
// @Failure 500 {object} Response
|
||||
// @Router /copyright-claims [get]
|
||||
func (h *CopyrightClaimHandler) ListCopyrightClaims(c *gin.Context) {
|
||||
var req dto.CopyrightClaimListRequest
|
||||
if err := c.ShouldBindQuery(&req); err != nil {
|
||||
ErrorResponse(c, "参数错误: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 设置默认值
|
||||
if req.Page == 0 {
|
||||
req.Page = 1
|
||||
}
|
||||
if req.PageSize == 0 {
|
||||
req.PageSize = 10
|
||||
}
|
||||
|
||||
// 验证参数
|
||||
if err := h.validate.Struct(req); err != nil {
|
||||
ErrorResponse(c, "参数验证失败: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
claims, total, err := h.copyrightClaimRepo.List(req.Status, req.Page, req.PageSize)
|
||||
if err != nil {
|
||||
ErrorResponse(c, "获取版权申述列表失败: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为包含资源信息的响应
|
||||
var responses []*dto.CopyrightClaimResponse
|
||||
for _, claim := range claims {
|
||||
// 查询关联的资源信息
|
||||
resources, err := h.getResourcesByResourceKey(claim.ResourceKey)
|
||||
if err != nil {
|
||||
// 如果查询资源失败,使用空资源列表
|
||||
responses = append(responses, converter.CopyrightClaimToResponse(claim))
|
||||
} else {
|
||||
// 使用包含资源详情的转换函数
|
||||
responses = append(responses, converter.CopyrightClaimToResponseWithResources(claim, resources))
|
||||
}
|
||||
}
|
||||
|
||||
PageResponse(c, responses, total, req.Page, req.PageSize)
|
||||
}
|
||||
|
||||
// getResourcesByResourceKey 根据资源key获取关联的资源列表
|
||||
func (h *CopyrightClaimHandler) getResourcesByResourceKey(resourceKey string) ([]*entity.Resource, error) {
|
||||
// 从资源仓库获取与key关联的所有资源
|
||||
resources, err := h.resourceRepo.FindByResourceKey(resourceKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 将 []entity.Resource 转换为 []*entity.Resource
|
||||
var resourcePointers []*entity.Resource
|
||||
for i := range resources {
|
||||
resourcePointers = append(resourcePointers, &resources[i])
|
||||
}
|
||||
|
||||
return resourcePointers, nil
|
||||
}
|
||||
|
||||
// UpdateCopyrightClaim 更新版权申述状态
|
||||
// @Summary 更新版权申述状态
|
||||
// @Description 更新版权申述处理状态
|
||||
// @Tags CopyrightClaim
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "版权申述ID"
|
||||
// @Param request body dto.CopyrightClaimUpdateRequest true "更新信息"
|
||||
// @Success 200 {object} Response{data=dto.CopyrightClaimResponse}
|
||||
// @Failure 400 {object} Response
|
||||
// @Failure 404 {object} Response
|
||||
// @Failure 500 {object} Response
|
||||
// @Router /copyright-claims/{id} [put]
|
||||
func (h *CopyrightClaimHandler) UpdateCopyrightClaim(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
ErrorResponse(c, "无效的ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var req dto.CopyrightClaimUpdateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
ErrorResponse(c, "参数错误: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 验证参数
|
||||
if err := h.validate.Struct(req); err != nil {
|
||||
ErrorResponse(c, "参数验证失败: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 获取当前版权申述
|
||||
_, err = h.copyrightClaimRepo.GetByID(uint(id))
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
ErrorResponse(c, "版权申述不存在", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
ErrorResponse(c, "获取版权申述失败: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 更新状态
|
||||
processedBy := uint(0) // 从上下文获取当前用户ID,如果存在的话
|
||||
if currentUser := c.GetUint("user_id"); currentUser > 0 {
|
||||
processedBy = currentUser
|
||||
}
|
||||
|
||||
if err := h.copyrightClaimRepo.UpdateStatus(uint(id), req.Status, &processedBy, req.Note); err != nil {
|
||||
ErrorResponse(c, "更新版权申述状态失败: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 获取更新后的版权申述信息
|
||||
updatedClaim, err := h.copyrightClaimRepo.GetByID(uint(id))
|
||||
if err != nil {
|
||||
ErrorResponse(c, "获取更新后版权申述信息失败: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
SuccessResponse(c, converter.CopyrightClaimToResponse(updatedClaim))
|
||||
}
|
||||
|
||||
// DeleteCopyrightClaim 删除版权申述
|
||||
// @Summary 删除版权申述
|
||||
// @Description 删除版权申述记录
|
||||
// @Tags CopyrightClaim
|
||||
// @Produce json
|
||||
// @Param id path int true "版权申述ID"
|
||||
// @Success 200 {object} Response
|
||||
// @Failure 400 {object} Response
|
||||
// @Failure 500 {object} Response
|
||||
// @Router /copyright-claims/{id} [delete]
|
||||
func (h *CopyrightClaimHandler) DeleteCopyrightClaim(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
ErrorResponse(c, "无效的ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.copyrightClaimRepo.Delete(uint(id)); err != nil {
|
||||
ErrorResponse(c, "删除版权申述失败: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
SuccessResponse(c, nil)
|
||||
}
|
||||
|
||||
// GetCopyrightClaimByResource 获取某个资源的版权申述列表
|
||||
// @Summary 获取资源版权申述列表
|
||||
// @Description 获取某个资源的所有版权申述记录
|
||||
// @Tags CopyrightClaim
|
||||
// @Produce json
|
||||
// @Param resource_key path string true "资源Key"
|
||||
// @Success 200 {object} Response{data=[]dto.CopyrightClaimResponse}
|
||||
// @Failure 400 {object} Response
|
||||
// @Failure 500 {object} Response
|
||||
// @Router /copyright-claims/resource/{resource_key} [get]
|
||||
func (h *CopyrightClaimHandler) GetCopyrightClaimByResource(c *gin.Context) {
|
||||
resourceKey := c.Param("resource_key")
|
||||
if resourceKey == "" {
|
||||
ErrorResponse(c, "资源Key不能为空", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := h.copyrightClaimRepo.GetByResourceKey(resourceKey)
|
||||
if err != nil {
|
||||
ErrorResponse(c, "获取资源版权申述失败: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
SuccessResponse(c, converter.CopyrightClaimsToResponse(claims))
|
||||
}
|
||||
|
||||
// RegisterCopyrightClaimRoutes 注册版权申述相关路由
|
||||
func RegisterCopyrightClaimRoutes(router *gin.RouterGroup, copyrightClaimRepo repo.CopyrightClaimRepository, resourceRepo repo.ResourceRepository) {
|
||||
handler := NewCopyrightClaimHandler(copyrightClaimRepo, resourceRepo)
|
||||
|
||||
claims := router.Group("/copyright-claims")
|
||||
{
|
||||
claims.POST("", handler.CreateCopyrightClaim) // 创建版权申述
|
||||
claims.GET("/:id", handler.GetCopyrightClaim) // 获取版权申述详情
|
||||
claims.GET("", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handler.ListCopyrightClaims) // 获取版权申述列表
|
||||
claims.PUT("/:id", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handler.UpdateCopyrightClaim) // 更新版权申述状态
|
||||
claims.DELETE("/:id", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handler.DeleteCopyrightClaim) // 删除版权申述
|
||||
claims.GET("/resource/:resource_key", handler.GetCopyrightClaimByResource) // 获取资源版权申述列表
|
||||
}
|
||||
}
|
||||
1030
handlers/google_index_handler.go
Normal file
1030
handlers/google_index_handler.go
Normal file
File diff suppressed because it is too large
Load Diff
565
handlers/og_image.go
Normal file
565
handlers/og_image.go
Normal file
@@ -0,0 +1,565 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/ctwj/urldb/utils"
|
||||
"github.com/ctwj/urldb/db"
|
||||
"github.com/ctwj/urldb/db/entity"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/fogleman/gg"
|
||||
"image/color"
|
||||
"image"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
)
|
||||
|
||||
// OGImageHandler 处理OG图片生成请求
|
||||
type OGImageHandler struct{}
|
||||
|
||||
// NewOGImageHandler 创建新的OG图片处理器
|
||||
func NewOGImageHandler() *OGImageHandler {
|
||||
return &OGImageHandler{}
|
||||
}
|
||||
|
||||
// Resource 简化的资源结构体
|
||||
type Resource struct {
|
||||
Title string
|
||||
Description string
|
||||
Cover string
|
||||
Key string
|
||||
}
|
||||
|
||||
// getResourceByKey 通过key获取资源信息
|
||||
func (h *OGImageHandler) getResourceByKey(key string) (*Resource, error) {
|
||||
// 这里简化处理,实际应该从数据库查询
|
||||
// 为了演示,我们先返回一个模拟的资源
|
||||
// 在实际应用中,您需要连接数据库并查询
|
||||
|
||||
// 模拟数据库查询 - 实际应用中请替换为真实的数据库查询
|
||||
dbInstance := db.DB
|
||||
if dbInstance == nil {
|
||||
return nil, fmt.Errorf("数据库连接失败")
|
||||
}
|
||||
|
||||
var resource entity.Resource
|
||||
result := dbInstance.Where("key = ?", key).First(&resource)
|
||||
if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
|
||||
return &Resource{
|
||||
Title: resource.Title,
|
||||
Description: resource.Description,
|
||||
Cover: resource.Cover,
|
||||
Key: resource.Key,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GenerateOGImage 生成OG图片
|
||||
func (h *OGImageHandler) GenerateOGImage(c *gin.Context) {
|
||||
// 获取请求参数
|
||||
key := strings.TrimSpace(c.Query("key"))
|
||||
title := strings.TrimSpace(c.Query("title"))
|
||||
description := strings.TrimSpace(c.Query("description"))
|
||||
siteName := strings.TrimSpace(c.Query("site_name"))
|
||||
theme := strings.TrimSpace(c.Query("theme"))
|
||||
coverUrl := strings.TrimSpace(c.Query("cover"))
|
||||
|
||||
width, _ := strconv.Atoi(c.Query("width"))
|
||||
height, _ := strconv.Atoi(c.Query("height"))
|
||||
|
||||
// 如果提供了key,从数据库获取资源信息
|
||||
if key != "" {
|
||||
resource, err := h.getResourceByKey(key)
|
||||
if err == nil && resource != nil {
|
||||
if title == "" {
|
||||
title = resource.Title
|
||||
}
|
||||
if description == "" {
|
||||
description = resource.Description
|
||||
}
|
||||
if coverUrl == "" && resource.Cover != "" {
|
||||
coverUrl = resource.Cover
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 设置默认值
|
||||
if title == "" {
|
||||
title = "老九网盘资源数据库"
|
||||
}
|
||||
if siteName == "" {
|
||||
siteName = "老九网盘"
|
||||
}
|
||||
if width <= 0 || width > 2000 {
|
||||
width = 1200
|
||||
}
|
||||
if height <= 0 || height > 2000 {
|
||||
height = 630
|
||||
}
|
||||
|
||||
// 获取当前请求的域名
|
||||
scheme := "http"
|
||||
if c.Request.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
host := c.Request.Host
|
||||
domain := scheme + "://" + host
|
||||
|
||||
// 生成图片
|
||||
imageBuffer, err := createOGImage(title, description, siteName, theme, width, height, coverUrl, key, domain)
|
||||
if err != nil {
|
||||
utils.Error("生成OG图片失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Failed to generate image: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 返回图片
|
||||
c.Data(http.StatusOK, "image/png", imageBuffer.Bytes())
|
||||
c.Header("Content-Type", "image/png")
|
||||
c.Header("Cache-Control", "public, max-age=3600")
|
||||
}
|
||||
|
||||
// createOGImage 创建OG图片
|
||||
func createOGImage(title, description, siteName, theme string, width, height int, coverUrl, key, domain string) (*bytes.Buffer, error) {
|
||||
dc := gg.NewContext(width, height)
|
||||
|
||||
// 设置圆角裁剪区域
|
||||
cornerRadius := 20.0
|
||||
dc.DrawRoundedRectangle(0, 0, float64(width), float64(height), cornerRadius)
|
||||
|
||||
// 设置背景色
|
||||
bgColor := getBackgroundColor(theme)
|
||||
dc.SetColor(bgColor)
|
||||
dc.Fill()
|
||||
|
||||
// 绘制渐变效果
|
||||
gradient := gg.NewLinearGradient(0, 0, float64(width), float64(height))
|
||||
gradient.AddColorStop(0, getGradientStartColor(theme))
|
||||
gradient.AddColorStop(1, getGradientEndColor(theme))
|
||||
dc.SetFillStyle(gradient)
|
||||
dc.Fill()
|
||||
|
||||
// 定义布局区域
|
||||
imageAreaWidth := width / 3 // 左侧1/3用于图片
|
||||
textAreaWidth := width * 2 / 3 // 右侧2/3用于文案
|
||||
textAreaX := imageAreaWidth // 文案区域起始X坐标
|
||||
|
||||
// 统一的字体加载函数,确保中文显示正常
|
||||
loadChineseFont := func(fontSize float64) bool {
|
||||
// 优先使用项目字体
|
||||
if err := dc.LoadFontFace("font/SourceHanSansSC-Regular.otf", fontSize); err == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
// Windows系统常见字体,按优先级顺序尝试
|
||||
commonFonts := []string{
|
||||
"C:/Windows/Fonts/msyh.ttc", // 微软雅黑
|
||||
"C:/Windows/Fonts/simhei.ttf", // 黑体
|
||||
"C:/Windows/Fonts/simsun.ttc", // 宋体
|
||||
}
|
||||
|
||||
for _, fontPath := range commonFonts {
|
||||
if err := dc.LoadFontFace(fontPath, fontSize); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// 如果都失败了,尝试使用粗体版本
|
||||
boldFonts := []string{
|
||||
"C:/Windows/Fonts/msyhbd.ttc", // 微软雅黑粗体
|
||||
"C:/Windows/Fonts/simhei.ttf", // 黑体
|
||||
}
|
||||
|
||||
for _, fontPath := range boldFonts {
|
||||
if err := dc.LoadFontFace(fontPath, fontSize); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// 加载基础字体(24px)
|
||||
fontLoaded := loadChineseFont(24)
|
||||
dc.SetHexColor("#ffffff")
|
||||
|
||||
// 绘制封面图片(如果存在)
|
||||
if coverUrl != "" {
|
||||
if err := drawCoverImageInLeftArea(dc, coverUrl, width, height, imageAreaWidth); err != nil {
|
||||
utils.Error("绘制封面图片失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 设置站点标识
|
||||
dc.DrawStringAnchored(siteName, float64(textAreaX)+60, 50, 0, 0.5)
|
||||
|
||||
// 绘制标题
|
||||
dc.SetHexColor("#ffffff")
|
||||
|
||||
// 标题在右侧区域显示,考虑文案宽度限制
|
||||
maxTitleWidth := float64(textAreaWidth - 120) // 右侧区域减去左右边距
|
||||
|
||||
// 动态调整字体大小以适应文案区域,使用统一的字体加载逻辑
|
||||
fontSize := 48.0
|
||||
titleFontLoaded := false
|
||||
for fontSize > 24 { // 最小字体24
|
||||
// 优先使用项目粗体字体
|
||||
if err := dc.LoadFontFace("font/SourceHanSansSC-Bold.otf", fontSize); err == nil {
|
||||
titleWidth, _ := dc.MeasureString(title)
|
||||
if titleWidth <= maxTitleWidth {
|
||||
titleFontLoaded = true
|
||||
break
|
||||
}
|
||||
} else {
|
||||
// 尝试系统粗体字体
|
||||
boldFonts := []string{
|
||||
"C:/Windows/Fonts/msyhbd.ttc", // 微软雅黑粗体
|
||||
"C:/Windows/Fonts/simhei.ttf", // 黑体
|
||||
}
|
||||
for _, fontPath := range boldFonts {
|
||||
if err := dc.LoadFontFace(fontPath, fontSize); err == nil {
|
||||
titleWidth, _ := dc.MeasureString(title)
|
||||
if titleWidth <= maxTitleWidth {
|
||||
titleFontLoaded = true
|
||||
break
|
||||
}
|
||||
break // 找到可用字体就跳出内层循环
|
||||
}
|
||||
}
|
||||
if titleFontLoaded {
|
||||
break
|
||||
}
|
||||
}
|
||||
fontSize -= 4
|
||||
}
|
||||
|
||||
// 如果粗体字体都失败了,使用常规字体
|
||||
if !titleFontLoaded {
|
||||
loadChineseFont(36) // 使用稍大的常规字体
|
||||
}
|
||||
|
||||
// 标题左对齐显示在右侧区域
|
||||
titleX := float64(textAreaX) + 60
|
||||
titleY := float64(height)/2 - 80
|
||||
dc.DrawString(title, titleX, titleY)
|
||||
|
||||
// 绘制描述
|
||||
if description != "" {
|
||||
dc.SetHexColor("#e5e7eb")
|
||||
// 使用统一的字体加载逻辑
|
||||
loadChineseFont(28)
|
||||
|
||||
// 自动换行处理,适配右侧区域宽度
|
||||
wrappedDesc := wrapText(dc, description, float64(textAreaWidth-120))
|
||||
descY := titleY + 60 // 标题下方
|
||||
|
||||
for i, line := range wrappedDesc {
|
||||
y := descY + float64(i)*30 // 行高30像素
|
||||
dc.DrawString(line, titleX, y)
|
||||
}
|
||||
}
|
||||
|
||||
// 添加装饰性元素
|
||||
drawDecorativeElements(dc, width, height, theme)
|
||||
|
||||
// 绘制底部URL访问地址
|
||||
if key != "" && domain != "" {
|
||||
resourceURL := domain + "/r/" + key
|
||||
dc.SetHexColor("#d1d5db") // 浅灰色
|
||||
|
||||
// 使用统一的字体加载逻辑
|
||||
loadChineseFont(20)
|
||||
|
||||
// URL位置:底部居中,距离底部边缘40像素,给更多空间
|
||||
urlY := float64(height) - 40
|
||||
|
||||
dc.DrawStringAnchored(resourceURL, float64(width)/2, urlY, 0.5, 0.5)
|
||||
}
|
||||
|
||||
// 添加调试信息(仅在开发环境)
|
||||
if title == "DEBUG" {
|
||||
dc.SetHexColor("#ff0000")
|
||||
dc.DrawString("Font loaded: "+strconv.FormatBool(fontLoaded), 50, float64(height)-80)
|
||||
}
|
||||
|
||||
// 生成图片
|
||||
buf := &bytes.Buffer{}
|
||||
err := dc.EncodePNG(buf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return buf, nil
|
||||
}
|
||||
|
||||
// getBackgroundColor 获取背景色
|
||||
func getBackgroundColor(theme string) color.RGBA {
|
||||
switch theme {
|
||||
case "dark":
|
||||
return color.RGBA{31, 41, 55, 255} // slate-800
|
||||
case "blue":
|
||||
return color.RGBA{29, 78, 216, 255} // blue-700
|
||||
case "green":
|
||||
return color.RGBA{6, 95, 70, 255} // emerald-800
|
||||
case "purple":
|
||||
return color.RGBA{109, 40, 217, 255} // violet-700
|
||||
default:
|
||||
return color.RGBA{55, 65, 81, 255} // gray-800
|
||||
}
|
||||
}
|
||||
|
||||
// getGradientStartColor 获取渐变起始色
|
||||
func getGradientStartColor(theme string) color.Color {
|
||||
switch theme {
|
||||
case "dark":
|
||||
return color.RGBA{15, 23, 42, 255} // slate-900
|
||||
case "blue":
|
||||
return color.RGBA{30, 58, 138, 255} // blue-900
|
||||
case "green":
|
||||
return color.RGBA{6, 78, 59, 255} // emerald-900
|
||||
case "purple":
|
||||
return color.RGBA{91, 33, 182, 255} // violet-800
|
||||
default:
|
||||
return color.RGBA{31, 41, 55, 255} // gray-800
|
||||
}
|
||||
}
|
||||
|
||||
// getGradientEndColor 获取渐变结束色
|
||||
func getGradientEndColor(theme string) color.Color {
|
||||
switch theme {
|
||||
case "dark":
|
||||
return color.RGBA{55, 65, 81, 255} // slate-700
|
||||
case "blue":
|
||||
return color.RGBA{59, 130, 246, 255} // blue-500
|
||||
case "green":
|
||||
return color.RGBA{16, 185, 129, 255} // emerald-500
|
||||
case "purple":
|
||||
return color.RGBA{139, 92, 246, 255} // violet-500
|
||||
default:
|
||||
return color.RGBA{75, 85, 99, 255} // gray-600
|
||||
}
|
||||
}
|
||||
|
||||
// wrapText 文本自动换行处理
|
||||
func wrapText(dc *gg.Context, text string, maxWidth float64) []string {
|
||||
var lines []string
|
||||
words := []rune(text)
|
||||
|
||||
currentLine := ""
|
||||
for _, word := range words {
|
||||
testLine := currentLine + string(word)
|
||||
width, _ := dc.MeasureString(testLine)
|
||||
|
||||
if width > maxWidth && len(currentLine) > 0 {
|
||||
lines = append(lines, currentLine)
|
||||
currentLine = string(word)
|
||||
} else {
|
||||
currentLine = testLine
|
||||
}
|
||||
}
|
||||
|
||||
if currentLine != "" {
|
||||
lines = append(lines, currentLine)
|
||||
}
|
||||
|
||||
// 最多显示3行
|
||||
if len(lines) > 3 {
|
||||
lines = lines[:3]
|
||||
// 在最后一行添加省略号
|
||||
if len(lines[2]) > 3 {
|
||||
lines[2] = lines[2][:len(lines[2])-3] + "..."
|
||||
}
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
// drawDecorativeElements 绘制装饰性元素
|
||||
func drawDecorativeElements(dc *gg.Context, width, height int, theme string) {
|
||||
// 绘制装饰性圆点
|
||||
dc.SetHexColor("#ffffff")
|
||||
dc.SetLineWidth(2)
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
x := float64(100 + i*150)
|
||||
y := float64(100 + (i%2)*200)
|
||||
dc.DrawCircle(x, y, 8)
|
||||
dc.Stroke()
|
||||
}
|
||||
|
||||
// 绘制底部装饰线
|
||||
dc.DrawLine(60, float64(height-80), float64(width-60), float64(height-80))
|
||||
dc.Stroke()
|
||||
}
|
||||
|
||||
// drawCoverImageInLeftArea 在左侧1/3区域绘制封面图片
|
||||
func drawCoverImageInLeftArea(dc *gg.Context, coverUrl string, width, height int, imageAreaWidth int) error {
|
||||
// 下载封面图片
|
||||
resp, err := http.Get(coverUrl)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 读取图片数据
|
||||
imgData, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 解码图片
|
||||
img, _, err := image.Decode(bytes.NewReader(imgData))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 获取图片尺寸和宽高比
|
||||
bounds := img.Bounds()
|
||||
imgWidth := bounds.Dx()
|
||||
imgHeight := bounds.Dy()
|
||||
aspectRatio := float64(imgWidth) / float64(imgHeight)
|
||||
|
||||
// 计算图片区域的可显示尺寸,留出边距
|
||||
padding := 40
|
||||
maxImageWidth := imageAreaWidth - padding*2
|
||||
maxImageHeight := height - padding*2
|
||||
|
||||
var scaledImg image.Image
|
||||
var drawWidth, drawHeight, drawX, drawY int
|
||||
|
||||
// 判断是竖图还是横图,采用不同的缩放策略
|
||||
if aspectRatio < 1.0 {
|
||||
// 竖图:充满整个左侧区域(去掉边距)
|
||||
drawHeight = height - padding*2 // 留上下边距
|
||||
drawWidth = int(float64(drawHeight) * aspectRatio)
|
||||
|
||||
// 如果宽度超出左侧区域,则以宽度为准充满整个区域宽度
|
||||
if drawWidth > imageAreaWidth - padding*2 {
|
||||
drawWidth = imageAreaWidth - padding*2
|
||||
drawHeight = int(float64(drawWidth) / aspectRatio)
|
||||
}
|
||||
|
||||
// 缩放图片
|
||||
scaledImg = scaleImage(img, drawWidth, drawHeight)
|
||||
|
||||
// 垂直居中,水平居左
|
||||
drawX = padding
|
||||
drawY = (height - drawHeight) / 2
|
||||
} else {
|
||||
// 横图:优先占满宽度
|
||||
drawWidth = maxImageWidth
|
||||
drawHeight = int(float64(drawWidth) / aspectRatio)
|
||||
|
||||
// 如果高度超出限制,则以高度为准
|
||||
if drawHeight > maxImageHeight {
|
||||
drawHeight = maxImageHeight
|
||||
drawWidth = int(float64(drawHeight) * aspectRatio)
|
||||
}
|
||||
|
||||
// 缩放图片
|
||||
scaledImg = scaleImage(img, drawWidth, drawHeight)
|
||||
|
||||
// 水平居中,垂直居中
|
||||
drawX = (imageAreaWidth - drawWidth) / 2
|
||||
drawY = (height - drawHeight) / 2
|
||||
}
|
||||
|
||||
// 绘制图片
|
||||
dc.DrawImage(scaledImg, drawX, drawY)
|
||||
|
||||
// 添加半透明遮罩效果,让文字更清晰(仅在有图片时添加)
|
||||
maskColor := color.RGBA{0, 0, 0, 80} // 半透明黑色,透明度稍低
|
||||
dc.SetColor(maskColor)
|
||||
dc.DrawRectangle(float64(drawX), float64(drawY), float64(drawWidth), float64(drawHeight))
|
||||
dc.Fill()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// scaleImage 图片缩放函数
|
||||
func scaleImage(img image.Image, width, height int) image.Image {
|
||||
// 使用 gg 库的 Scale 变换来实现缩放
|
||||
srcWidth := img.Bounds().Dx()
|
||||
srcHeight := img.Bounds().Dy()
|
||||
|
||||
// 创建目标尺寸的画布
|
||||
dc := gg.NewContext(width, height)
|
||||
|
||||
// 计算缩放比例
|
||||
scaleX := float64(width) / float64(srcWidth)
|
||||
scaleY := float64(height) / float64(srcHeight)
|
||||
|
||||
// 应用缩放变换并绘制图片
|
||||
dc.Scale(scaleX, scaleY)
|
||||
dc.DrawImage(img, 0, 0)
|
||||
|
||||
return dc.Image()
|
||||
}
|
||||
|
||||
// drawCoverImage 绘制封面图片(保留原函数作为备用)
|
||||
func drawCoverImage(dc *gg.Context, coverUrl string, width, height int) error {
|
||||
// 下载封面图片
|
||||
resp, err := http.Get(coverUrl)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 读取图片数据
|
||||
imgData, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 解码图片
|
||||
img, _, err := image.Decode(bytes.NewReader(imgData))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 计算封面图片的位置和大小,放置在左侧
|
||||
coverWidth := 200 // 封面图宽度
|
||||
coverHeight := 280 // 封面图高度
|
||||
coverX := 50
|
||||
coverY := (height - coverHeight) / 2
|
||||
|
||||
// 绘制封面图片(按比例缩放)
|
||||
bounds := img.Bounds()
|
||||
imgWidth := bounds.Dx()
|
||||
imgHeight := bounds.Dy()
|
||||
|
||||
// 计算缩放比例,保持宽高比
|
||||
scaleX := float64(coverWidth) / float64(imgWidth)
|
||||
scaleY := float64(coverHeight) / float64(imgHeight)
|
||||
scale := scaleX
|
||||
if scaleY < scaleX {
|
||||
scale = scaleY
|
||||
}
|
||||
|
||||
// 计算缩放后的尺寸
|
||||
newWidth := int(float64(imgWidth) * scale)
|
||||
newHeight := int(float64(imgHeight) * scale)
|
||||
|
||||
// 居中绘制
|
||||
offsetX := coverX + (coverWidth-newWidth)/2
|
||||
offsetY := coverY + (coverHeight-newHeight)/2
|
||||
|
||||
dc.DrawImage(img, offsetX, offsetY)
|
||||
|
||||
// 添加半透明遮罩效果,让文字更清晰
|
||||
maskColor := color.RGBA{0, 0, 0, 120} // 半透明黑色
|
||||
dc.SetColor(maskColor)
|
||||
dc.DrawRectangle(float64(coverX), float64(coverY), float64(coverWidth), float64(coverHeight))
|
||||
dc.Fill()
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -452,9 +452,16 @@ func (h *PublicAPIHandler) recordAPIAccessToDB(ip, userAgent, endpoint, method s
|
||||
requestParams interface{}, responseStatus int, responseData interface{},
|
||||
processCount int, errorMessage string, processingTime int64) {
|
||||
|
||||
// 只记录重要的API访问(有错误或处理时间较长的)
|
||||
if errorMessage == "" && processingTime < 1000 && responseStatus < 400 {
|
||||
return // 跳过正常的快速请求
|
||||
// 判断是否为关键端点,需要强制记录日志
|
||||
isKeyEndpoint := strings.Contains(endpoint, "/api/public/resources/batch-add") ||
|
||||
strings.Contains(endpoint, "/api/admin/") ||
|
||||
strings.Contains(endpoint, "/telegram/webhook") ||
|
||||
strings.Contains(endpoint, "/api/public/resources/search") ||
|
||||
strings.Contains(endpoint, "/api/public/hot-drama")
|
||||
|
||||
// 只记录重要的API访问(有错误或处理时间较长的)或者是关键端点
|
||||
if errorMessage == "" && processingTime < 1000 && responseStatus < 400 && !isKeyEndpoint {
|
||||
return // 跳过正常的快速请求,但记录关键端点
|
||||
}
|
||||
|
||||
// 转换参数为JSON字符串
|
||||
@@ -492,3 +499,36 @@ func (h *PublicAPIHandler) recordAPIAccessToDB(ip, userAgent, endpoint, method s
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// GetPublicSiteVerificationCode 获取网站验证代码(公开访问)
|
||||
func GetPublicSiteVerificationCode(c *gin.Context) {
|
||||
// 获取站点URL配置
|
||||
siteURL, err := repoManager.SystemConfigRepository.GetConfigValue("site_url")
|
||||
if err != nil || siteURL == "" {
|
||||
c.JSON(400, gin.H{
|
||||
"success": false,
|
||||
"message": "站点URL未配置",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 生成Google Search Console验证代码示例
|
||||
verificationCode := map[string]interface{}{
|
||||
"site_url": siteURL,
|
||||
"verification_methods": map[string]string{
|
||||
"html_tag": `<meta name="google-site-verification" content="your-verification-code">`,
|
||||
"dns_txt": `google-site-verification=your-verification-code`,
|
||||
"html_file": `google1234567890abcdef.html`,
|
||||
},
|
||||
"instructions": map[string]string{
|
||||
"html_tag": "请将以下meta标签添加到您网站的首页<head>部分中",
|
||||
"dns_txt": "请添加以下TXT记录到您的DNS配置中",
|
||||
"html_file": "请在网站根目录创建包含指定内容的HTML文件",
|
||||
},
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
"success": true,
|
||||
"data": verificationCode,
|
||||
})
|
||||
}
|
||||
|
||||
310
handlers/report_handler.go
Normal file
310
handlers/report_handler.go
Normal file
@@ -0,0 +1,310 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"github.com/ctwj/urldb/db/converter"
|
||||
"github.com/ctwj/urldb/db/dto"
|
||||
"github.com/ctwj/urldb/db/entity"
|
||||
"github.com/ctwj/urldb/db/repo"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-playground/validator/v10"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ReportHandler struct {
|
||||
reportRepo repo.ReportRepository
|
||||
resourceRepo repo.ResourceRepository
|
||||
validate *validator.Validate
|
||||
}
|
||||
|
||||
func NewReportHandler(reportRepo repo.ReportRepository, resourceRepo repo.ResourceRepository) *ReportHandler {
|
||||
return &ReportHandler{
|
||||
reportRepo: reportRepo,
|
||||
resourceRepo: resourceRepo,
|
||||
validate: validator.New(),
|
||||
}
|
||||
}
|
||||
|
||||
// CreateReport 创建举报
|
||||
// @Summary 创建举报
|
||||
// @Description 提交资源举报
|
||||
// @Tags Report
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body dto.ReportCreateRequest true "举报信息"
|
||||
// @Success 200 {object} Response{data=dto.ReportResponse}
|
||||
// @Failure 400 {object} Response
|
||||
// @Failure 500 {object} Response
|
||||
// @Router /reports [post]
|
||||
func (h *ReportHandler) CreateReport(c *gin.Context) {
|
||||
var req dto.ReportCreateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
ErrorResponse(c, "参数错误: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 验证参数
|
||||
if err := h.validate.Struct(req); err != nil {
|
||||
ErrorResponse(c, "参数验证失败: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 创建举报实体
|
||||
report := &entity.Report{
|
||||
ResourceKey: req.ResourceKey,
|
||||
Reason: req.Reason,
|
||||
Description: req.Description,
|
||||
Contact: req.Contact,
|
||||
UserAgent: req.UserAgent,
|
||||
IPAddress: req.IPAddress,
|
||||
Status: "pending", // 默认为待处理
|
||||
}
|
||||
|
||||
// 保存到数据库
|
||||
if err := h.reportRepo.Create(report); err != nil {
|
||||
ErrorResponse(c, "创建举报失败: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 返回响应
|
||||
response := converter.ReportToResponse(report)
|
||||
SuccessResponse(c, response)
|
||||
}
|
||||
|
||||
// GetReport 获取举报详情
|
||||
// @Summary 获取举报详情
|
||||
// @Description 根据ID获取举报详情
|
||||
// @Tags Report
|
||||
// @Produce json
|
||||
// @Param id path int true "举报ID"
|
||||
// @Success 200 {object} Response{data=dto.ReportResponse}
|
||||
// @Failure 400 {object} Response
|
||||
// @Failure 404 {object} Response
|
||||
// @Failure 500 {object} Response
|
||||
// @Router /reports/{id} [get]
|
||||
func (h *ReportHandler) GetReport(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
ErrorResponse(c, "无效的ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
report, err := h.reportRepo.GetByID(uint(id))
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
ErrorResponse(c, "举报不存在", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
ErrorResponse(c, "获取举报失败: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
response := converter.ReportToResponse(report)
|
||||
SuccessResponse(c, response)
|
||||
}
|
||||
|
||||
// ListReports 获取举报列表
|
||||
// @Summary 获取举报列表
|
||||
// @Description 获取举报列表(支持分页和状态筛选)
|
||||
// @Tags Report
|
||||
// @Produce json
|
||||
// @Param page query int false "页码" default(1)
|
||||
// @Param page_size query int false "每页数量" default(10)
|
||||
// @Param status query string false "处理状态"
|
||||
// @Success 200 {object} Response{data=object{items=[]dto.ReportResponse,total=int}}
|
||||
// @Failure 400 {object} Response
|
||||
// @Failure 500 {object} Response
|
||||
// @Router /reports [get]
|
||||
func (h *ReportHandler) ListReports(c *gin.Context) {
|
||||
var req dto.ReportListRequest
|
||||
if err := c.ShouldBindQuery(&req); err != nil {
|
||||
ErrorResponse(c, "参数错误: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 设置默认值
|
||||
if req.Page == 0 {
|
||||
req.Page = 1
|
||||
}
|
||||
if req.PageSize == 0 {
|
||||
req.PageSize = 10
|
||||
}
|
||||
|
||||
// 验证参数
|
||||
if err := h.validate.Struct(req); err != nil {
|
||||
ErrorResponse(c, "参数验证失败: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
reports, total, err := h.reportRepo.List(req.Status, req.Page, req.PageSize)
|
||||
if err != nil {
|
||||
ErrorResponse(c, "获取举报列表失败: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 获取每个举报关联的资源
|
||||
var reportResponses []*dto.ReportResponse
|
||||
for _, report := range reports {
|
||||
// 通过资源key查找关联的资源
|
||||
resources, err := h.getResourcesByResourceKey(report.ResourceKey)
|
||||
if err != nil {
|
||||
// 如果获取资源失败,仍然返回基本的举报信息
|
||||
reportResponses = append(reportResponses, converter.ReportToResponse(report))
|
||||
} else {
|
||||
// 使用包含资源详情的转换函数
|
||||
response := converter.ReportToResponseWithResources(report, resources)
|
||||
reportResponses = append(reportResponses, response)
|
||||
}
|
||||
}
|
||||
|
||||
PageResponse(c, reportResponses, total, req.Page, req.PageSize)
|
||||
}
|
||||
|
||||
// getResourcesByResourceKey 根据资源key获取关联的资源列表
|
||||
func (h *ReportHandler) getResourcesByResourceKey(resourceKey string) ([]*entity.Resource, error) {
|
||||
// 从资源仓库获取与key关联的所有资源
|
||||
resources, err := h.resourceRepo.FindByResourceKey(resourceKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 将 []entity.Resource 转换为 []*entity.Resource
|
||||
var resourcePointers []*entity.Resource
|
||||
for i := range resources {
|
||||
resourcePointers = append(resourcePointers, &resources[i])
|
||||
}
|
||||
|
||||
return resourcePointers, nil
|
||||
}
|
||||
|
||||
// UpdateReport 更新举报状态
|
||||
// @Summary 更新举报状态
|
||||
// @Description 更新举报处理状态
|
||||
// @Tags Report
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "举报ID"
|
||||
// @Param request body dto.ReportUpdateRequest true "更新信息"
|
||||
// @Success 200 {object} Response{data=dto.ReportResponse}
|
||||
// @Failure 400 {object} Response
|
||||
// @Failure 404 {object} Response
|
||||
// @Failure 500 {object} Response
|
||||
// @Router /reports/{id} [put]
|
||||
func (h *ReportHandler) UpdateReport(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
ErrorResponse(c, "无效的ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var req dto.ReportUpdateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
ErrorResponse(c, "参数错误: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 验证参数
|
||||
if err := h.validate.Struct(req); err != nil {
|
||||
ErrorResponse(c, "参数验证失败: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 获取当前举报
|
||||
_, err = h.reportRepo.GetByID(uint(id))
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
ErrorResponse(c, "举报不存在", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
ErrorResponse(c, "获取举报失败: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 更新状态
|
||||
processedBy := uint(0) // 从上下文获取当前用户ID,如果存在的话
|
||||
if currentUser := c.GetUint("user_id"); currentUser > 0 {
|
||||
processedBy = currentUser
|
||||
}
|
||||
|
||||
if err := h.reportRepo.UpdateStatus(uint(id), req.Status, &processedBy, req.Note); err != nil {
|
||||
ErrorResponse(c, "更新举报状态失败: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 获取更新后的举报信息
|
||||
updatedReport, err := h.reportRepo.GetByID(uint(id))
|
||||
if err != nil {
|
||||
ErrorResponse(c, "获取更新后举报信息失败: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
SuccessResponse(c, converter.ReportToResponse(updatedReport))
|
||||
}
|
||||
|
||||
// DeleteReport 删除举报
|
||||
// @Summary 删除举报
|
||||
// @Description 删除举报记录
|
||||
// @Tags Report
|
||||
// @Produce json
|
||||
// @Param id path int true "举报ID"
|
||||
// @Success 200 {object} Response
|
||||
// @Failure 400 {object} Response
|
||||
// @Failure 500 {object} Response
|
||||
// @Router /reports/{id} [delete]
|
||||
func (h *ReportHandler) DeleteReport(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
ErrorResponse(c, "无效的ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.reportRepo.Delete(uint(id)); err != nil {
|
||||
ErrorResponse(c, "删除举报失败: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
SuccessResponse(c, nil)
|
||||
}
|
||||
|
||||
// GetReportByResource 获取某个资源的举报列表
|
||||
// @Summary 获取资源举报列表
|
||||
// @Description 获取某个资源的所有举报记录
|
||||
// @Tags Report
|
||||
// @Produce json
|
||||
// @Param resource_key path string true "资源Key"
|
||||
// @Success 200 {object} Response{data=[]dto.ReportResponse}
|
||||
// @Failure 400 {object} Response
|
||||
// @Failure 500 {object} Response
|
||||
// @Router /reports/resource/{resource_key} [get]
|
||||
func (h *ReportHandler) GetReportByResource(c *gin.Context) {
|
||||
resourceKey := c.Param("resource_key")
|
||||
if resourceKey == "" {
|
||||
ErrorResponse(c, "资源Key不能为空", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
reports, err := h.reportRepo.GetByResourceKey(resourceKey)
|
||||
if err != nil {
|
||||
ErrorResponse(c, "获取资源举报失败: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
SuccessResponse(c, converter.ReportsToResponse(reports))
|
||||
}
|
||||
|
||||
// RegisterReportRoutes 注册举报相关路由
|
||||
func RegisterReportRoutes(router *gin.RouterGroup, reportRepo repo.ReportRepository, resourceRepo repo.ResourceRepository) {
|
||||
handler := NewReportHandler(reportRepo, resourceRepo)
|
||||
|
||||
reports := router.Group("/reports")
|
||||
{
|
||||
reports.POST("", handler.CreateReport) // 创建举报
|
||||
reports.GET("/:id", handler.GetReport) // 获取举报详情
|
||||
reports.GET("", handler.ListReports) // 获取举报列表
|
||||
reports.PUT("/:id", handler.UpdateReport) // 更新举报状态
|
||||
reports.DELETE("/:id", handler.DeleteReport) // 删除举报
|
||||
reports.GET("/resource/:resource_key", handler.GetReportByResource) // 获取资源举报列表
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,11 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
pan "github.com/ctwj/urldb/common"
|
||||
panutils "github.com/ctwj/urldb/common"
|
||||
commonutils "github.com/ctwj/urldb/common/utils"
|
||||
"github.com/ctwj/urldb/db/converter"
|
||||
"github.com/ctwj/urldb/db/dto"
|
||||
@@ -162,6 +165,7 @@ func GetResources(c *gin.Context) {
|
||||
|
||||
resourceResponse := gin.H{
|
||||
"id": processedResource.ID,
|
||||
"key": processedResource.Key, // 添加key字段
|
||||
"title": forbiddenInfo.ProcessedTitle, // 使用处理后的标题
|
||||
"url": processedResource.URL,
|
||||
"description": forbiddenInfo.ProcessedDesc, // 使用处理后的描述
|
||||
@@ -223,6 +227,51 @@ func GetResourceByID(c *gin.Context) {
|
||||
SuccessResponse(c, response)
|
||||
}
|
||||
|
||||
// GetResourcesByKey 根据Key获取资源组
|
||||
func GetResourcesByKey(c *gin.Context) {
|
||||
key := c.Param("key")
|
||||
if key == "" {
|
||||
ErrorResponse(c, "Key参数不能为空", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
resources, err := repoManager.ResourceRepository.FindByKey(key)
|
||||
if err != nil {
|
||||
ErrorResponse(c, "资源不存在", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if len(resources) == 0 {
|
||||
ErrorResponse(c, "资源不存在", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为响应格式并处理违禁词
|
||||
cleanWords, err := utils.GetForbiddenWordsFromConfig(func() (string, error) {
|
||||
return repoManager.SystemConfigRepository.GetConfigValue(entity.ConfigKeyForbiddenWords)
|
||||
})
|
||||
if err != nil {
|
||||
utils.Error("获取违禁词配置失败: %v", err)
|
||||
cleanWords = []string{}
|
||||
}
|
||||
|
||||
var responses []dto.ResourceResponse
|
||||
for _, resource := range resources {
|
||||
response := converter.ToResourceResponse(&resource)
|
||||
// 检查违禁词
|
||||
forbiddenInfo := utils.CheckResourceForbiddenWords(response.Title, response.Description, cleanWords)
|
||||
response.HasForbiddenWords = forbiddenInfo.HasForbiddenWords
|
||||
response.ForbiddenWords = forbiddenInfo.ForbiddenWords
|
||||
responses = append(responses, response)
|
||||
}
|
||||
|
||||
SuccessResponse(c, gin.H{
|
||||
"resources": responses,
|
||||
"total": len(responses),
|
||||
"key": key,
|
||||
})
|
||||
}
|
||||
|
||||
// CheckResourceExists 检查资源是否存在(测试FindExists函数)
|
||||
func CheckResourceExists(c *gin.Context) {
|
||||
url := c.Query("url")
|
||||
@@ -855,6 +904,591 @@ func transferSingleResource(resource *entity.Resource, account entity.Cks, facto
|
||||
}
|
||||
}
|
||||
|
||||
// GetHotResources 获取热门资源
|
||||
func GetHotResources(c *gin.Context) {
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
|
||||
|
||||
utils.Info("获取热门资源请求 - limit: %d", limit)
|
||||
|
||||
// 限制最大请求数量
|
||||
if limit > 20 {
|
||||
limit = 20
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
}
|
||||
|
||||
// 使用公共缓存机制
|
||||
cacheKey := fmt.Sprintf("hot_resources_%d", limit)
|
||||
ttl := time.Hour // 1小时缓存
|
||||
cacheManager := utils.GetHotResourcesCache()
|
||||
|
||||
// 尝试从缓存获取
|
||||
if cachedData, found := cacheManager.Get(cacheKey, ttl); found {
|
||||
utils.Info("使用热门资源缓存 - key: %s", cacheKey)
|
||||
c.Header("Cache-Control", "public, max-age=3600")
|
||||
c.Header("ETag", fmt.Sprintf("hot-resources-%d", len(cachedData.([]gin.H))))
|
||||
|
||||
// 转换为正确的类型
|
||||
if data, ok := cachedData.([]gin.H); ok {
|
||||
SuccessResponse(c, gin.H{
|
||||
"data": data,
|
||||
"total": len(data),
|
||||
"limit": limit,
|
||||
"cached": true,
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 缓存未命中,从数据库获取
|
||||
resources, err := repoManager.ResourceRepository.GetHotResources(limit)
|
||||
if err != nil {
|
||||
utils.Error("获取热门资源失败: %v", err)
|
||||
ErrorResponse(c, "获取热门资源失败", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 获取违禁词配置
|
||||
cleanWords, err := utils.GetForbiddenWordsFromConfig(func() (string, error) {
|
||||
return repoManager.SystemConfigRepository.GetConfigValue(entity.ConfigKeyForbiddenWords)
|
||||
})
|
||||
if err != nil {
|
||||
utils.Error("获取违禁词配置失败: %v", err)
|
||||
cleanWords = []string{}
|
||||
}
|
||||
|
||||
// 处理违禁词并转换为响应格式
|
||||
var resourceResponses []gin.H
|
||||
for _, resource := range resources {
|
||||
// 检查违禁词
|
||||
forbiddenInfo := utils.CheckResourceForbiddenWords(resource.Title, resource.Description, cleanWords)
|
||||
|
||||
resourceResponse := gin.H{
|
||||
"id": resource.ID,
|
||||
"key": resource.Key,
|
||||
"title": forbiddenInfo.ProcessedTitle,
|
||||
"url": resource.URL,
|
||||
"description": forbiddenInfo.ProcessedDesc,
|
||||
"pan_id": resource.PanID,
|
||||
"view_count": resource.ViewCount,
|
||||
"created_at": resource.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
"updated_at": resource.UpdatedAt.Format("2006-01-02 15:04:05"),
|
||||
"cover": resource.Cover,
|
||||
"author": resource.Author,
|
||||
"file_size": resource.FileSize,
|
||||
}
|
||||
|
||||
// 添加违禁词标记
|
||||
resourceResponse["has_forbidden_words"] = forbiddenInfo.HasForbiddenWords
|
||||
resourceResponse["forbidden_words"] = forbiddenInfo.ForbiddenWords
|
||||
|
||||
// 添加标签信息
|
||||
var tagResponses []gin.H
|
||||
if len(resource.Tags) > 0 {
|
||||
for _, tag := range resource.Tags {
|
||||
tagResponse := gin.H{
|
||||
"id": tag.ID,
|
||||
"name": tag.Name,
|
||||
"description": tag.Description,
|
||||
}
|
||||
tagResponses = append(tagResponses, tagResponse)
|
||||
}
|
||||
}
|
||||
resourceResponse["tags"] = tagResponses
|
||||
|
||||
resourceResponses = append(resourceResponses, resourceResponse)
|
||||
}
|
||||
|
||||
// 存储到缓存
|
||||
cacheManager.Set(cacheKey, resourceResponses)
|
||||
utils.Info("热门资源已缓存 - key: %s, count: %d", cacheKey, len(resourceResponses))
|
||||
|
||||
// 设置缓存头
|
||||
c.Header("Cache-Control", "public, max-age=3600")
|
||||
c.Header("ETag", fmt.Sprintf("hot-resources-%d", len(resourceResponses)))
|
||||
|
||||
SuccessResponse(c, gin.H{
|
||||
"data": resourceResponses,
|
||||
"total": len(resourceResponses),
|
||||
"limit": limit,
|
||||
"cached": false,
|
||||
})
|
||||
}
|
||||
|
||||
// GetRelatedResources 获取相关资源
|
||||
func GetRelatedResources(c *gin.Context) {
|
||||
// 获取查询参数
|
||||
key := c.Query("key") // 当前资源的key
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "8"))
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
|
||||
utils.Info("获取相关资源请求 - key: %s, limit: %d", key, limit)
|
||||
|
||||
if key == "" {
|
||||
ErrorResponse(c, "缺少资源key参数", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 首先通过key获取当前资源信息
|
||||
currentResources, err := repoManager.ResourceRepository.FindByKey(key)
|
||||
if err != nil {
|
||||
utils.Error("获取当前资源失败: %v", err)
|
||||
ErrorResponse(c, "资源不存在", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if len(currentResources) == 0 {
|
||||
ErrorResponse(c, "资源不存在", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
currentResource := ¤tResources[0] // 取第一个资源作为当前资源
|
||||
|
||||
var resources []entity.Resource
|
||||
var total int64
|
||||
|
||||
// 获取当前资源的标签ID列表
|
||||
var tagIDsList []string
|
||||
if currentResource.Tags != nil {
|
||||
for _, tag := range currentResource.Tags {
|
||||
tagIDsList = append(tagIDsList, strconv.Itoa(int(tag.ID)))
|
||||
}
|
||||
}
|
||||
|
||||
utils.Info("当前资源标签: %v", tagIDsList)
|
||||
|
||||
// 1. 优先使用Meilisearch进行标签搜索
|
||||
if meilisearchManager != nil && meilisearchManager.IsEnabled() && len(tagIDsList) > 0 {
|
||||
service := meilisearchManager.GetService()
|
||||
if service != nil {
|
||||
// 使用标签进行搜索
|
||||
filters := make(map[string]interface{})
|
||||
filters["tag_ids"] = tagIDsList
|
||||
|
||||
// 使用当前资源的标题作为搜索关键词,提高相关性
|
||||
searchQuery := currentResource.Title
|
||||
if searchQuery == "" {
|
||||
searchQuery = strings.Join(tagIDsList, " ") // 如果没有标题,使用标签作为搜索词
|
||||
}
|
||||
|
||||
docs, docTotal, err := service.Search(searchQuery, filters, page, limit)
|
||||
if err == nil && len(docs) > 0 {
|
||||
// 转换为Resource实体
|
||||
for _, doc := range docs {
|
||||
// 排除当前资源
|
||||
if doc.Key == key {
|
||||
continue
|
||||
}
|
||||
resource := entity.Resource{
|
||||
ID: doc.ID,
|
||||
Title: doc.Title,
|
||||
Description: doc.Description,
|
||||
URL: doc.URL,
|
||||
SaveURL: doc.SaveURL,
|
||||
FileSize: doc.FileSize,
|
||||
Key: doc.Key,
|
||||
PanID: doc.PanID,
|
||||
ViewCount: 0, // Meilisearch文档中没有ViewCount字段,设为默认值
|
||||
CreatedAt: doc.CreatedAt,
|
||||
UpdatedAt: doc.UpdatedAt,
|
||||
Cover: doc.Cover,
|
||||
Author: doc.Author,
|
||||
}
|
||||
resources = append(resources, resource)
|
||||
}
|
||||
total = docTotal
|
||||
utils.Info("Meilisearch搜索到 %d 个相关资源", len(resources))
|
||||
} else {
|
||||
utils.Error("Meilisearch搜索失败,回退到标签搜索: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 如果Meilisearch未启用、搜索失败或没有结果,使用数据库标签搜索
|
||||
if len(resources) == 0 {
|
||||
params := map[string]interface{}{
|
||||
"page": page,
|
||||
"page_size": limit,
|
||||
"is_public": true,
|
||||
"order_by": "updated_at",
|
||||
"order_dir": "desc",
|
||||
}
|
||||
|
||||
// 使用当前资源的标签进行搜索
|
||||
if len(tagIDsList) > 0 {
|
||||
params["tag_ids"] = strings.Join(tagIDsList, ",")
|
||||
} else {
|
||||
// 如果没有标签,使用当前资源的分类作为搜索条件
|
||||
if currentResource.CategoryID != nil && *currentResource.CategoryID > 0 {
|
||||
params["category_id"] = *currentResource.CategoryID
|
||||
}
|
||||
}
|
||||
|
||||
var err error
|
||||
resources, total, err = repoManager.ResourceRepository.SearchWithFilters(params)
|
||||
if err != nil {
|
||||
utils.Error("搜索相关资源失败: %v", err)
|
||||
ErrorResponse(c, "搜索相关资源失败", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 排除当前资源
|
||||
var filteredResources []entity.Resource
|
||||
for _, resource := range resources {
|
||||
if resource.Key != key {
|
||||
filteredResources = append(filteredResources, resource)
|
||||
}
|
||||
}
|
||||
resources = filteredResources
|
||||
total = int64(len(filteredResources))
|
||||
}
|
||||
|
||||
utils.Info("标签搜索到 %d 个相关资源", len(resources))
|
||||
|
||||
// 获取违禁词配置
|
||||
cleanWords, err := utils.GetForbiddenWordsFromConfig(func() (string, error) {
|
||||
return repoManager.SystemConfigRepository.GetConfigValue(entity.ConfigKeyForbiddenWords)
|
||||
})
|
||||
if err != nil {
|
||||
utils.Error("获取违禁词配置失败: %v", err)
|
||||
cleanWords = []string{}
|
||||
}
|
||||
|
||||
// 处理违禁词并转换为响应格式
|
||||
var resourceResponses []gin.H
|
||||
for _, resource := range resources {
|
||||
// 检查违禁词
|
||||
forbiddenInfo := utils.CheckResourceForbiddenWords(resource.Title, resource.Description, cleanWords)
|
||||
|
||||
resourceResponse := gin.H{
|
||||
"id": resource.ID,
|
||||
"key": resource.Key,
|
||||
"title": forbiddenInfo.ProcessedTitle,
|
||||
"url": resource.URL,
|
||||
"description": forbiddenInfo.ProcessedDesc,
|
||||
"pan_id": resource.PanID,
|
||||
"view_count": resource.ViewCount,
|
||||
"created_at": resource.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
"updated_at": resource.UpdatedAt.Format("2006-01-02 15:04:05"),
|
||||
"cover": resource.Cover,
|
||||
"author": resource.Author,
|
||||
"file_size": resource.FileSize,
|
||||
}
|
||||
|
||||
// 添加违禁词标记
|
||||
resourceResponse["has_forbidden_words"] = forbiddenInfo.HasForbiddenWords
|
||||
resourceResponse["forbidden_words"] = forbiddenInfo.ForbiddenWords
|
||||
|
||||
// 添加标签信息
|
||||
var tagResponses []gin.H
|
||||
if len(resource.Tags) > 0 {
|
||||
for _, tag := range resource.Tags {
|
||||
tagResponse := gin.H{
|
||||
"id": tag.ID,
|
||||
"name": tag.Name,
|
||||
"description": tag.Description,
|
||||
}
|
||||
tagResponses = append(tagResponses, tagResponse)
|
||||
}
|
||||
}
|
||||
resourceResponse["tags"] = tagResponses
|
||||
|
||||
resourceResponses = append(resourceResponses, resourceResponse)
|
||||
}
|
||||
|
||||
// 构建响应数据
|
||||
responseData := gin.H{
|
||||
"data": resourceResponses,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": limit,
|
||||
"source": "database",
|
||||
}
|
||||
|
||||
if meilisearchManager != nil && meilisearchManager.IsEnabled() && len(tagIDsList) > 0 {
|
||||
responseData["source"] = "meilisearch"
|
||||
}
|
||||
|
||||
SuccessResponse(c, responseData)
|
||||
}
|
||||
|
||||
// CheckResourceValidity 检查资源链接有效性
|
||||
func CheckResourceValidity(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
ErrorResponse(c, "无效的资源ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 查询资源信息
|
||||
resource, err := repoManager.ResourceRepository.FindByID(uint(id))
|
||||
if err != nil {
|
||||
ErrorResponse(c, "资源不存在", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
utils.Info("开始检测资源有效性 - ID: %d, URL: %s", resource.ID, resource.URL)
|
||||
|
||||
// 检查缓存
|
||||
cacheKey := fmt.Sprintf("resource_validity_%d", resource.ID)
|
||||
cacheManager := utils.GetResourceValidityCache()
|
||||
ttl := 5 * time.Minute // 5分钟缓存
|
||||
|
||||
if cachedData, found := cacheManager.Get(cacheKey, ttl); found {
|
||||
if result, ok := cachedData.(gin.H); ok {
|
||||
utils.Info("使用资源有效性缓存 - ID: %d", resource.ID)
|
||||
result["cached"] = true
|
||||
SuccessResponse(c, result)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 执行检测:只使用深度检测实现
|
||||
isValid, detectionMethod, err := performAdvancedValidityCheck(resource)
|
||||
|
||||
if err != nil {
|
||||
utils.Error("深度检测资源链接失败 - ID: %d, Error: %v", resource.ID, err)
|
||||
|
||||
// 深度检测失败,但不标记为无效(用户可自行验证)
|
||||
result := gin.H{
|
||||
"resource_id": resource.ID,
|
||||
"url": resource.URL,
|
||||
"is_valid": resource.IsValid, // 保持原始状态
|
||||
"last_checked": time.Now().Format(time.RFC3339),
|
||||
"error": err.Error(),
|
||||
"detection_method": detectionMethod,
|
||||
"cached": false,
|
||||
"note": "当前网盘暂不支持自动检测,建议用户自行验证",
|
||||
}
|
||||
cacheManager.Set(cacheKey, result)
|
||||
SuccessResponse(c, result)
|
||||
return
|
||||
}
|
||||
|
||||
// 只有明确检测出无效的资源才更新数据库状态
|
||||
// 如果检测成功且结果与数据库状态不同,则更新
|
||||
if detectionMethod == "quark_deep" && isValid != resource.IsValid {
|
||||
resource.IsValid = isValid
|
||||
updateErr := repoManager.ResourceRepository.Update(resource)
|
||||
if updateErr != nil {
|
||||
utils.Error("更新资源有效性状态失败 - ID: %d, Error: %v", resource.ID, updateErr)
|
||||
} else {
|
||||
utils.Info("更新资源有效性状态 - ID: %d, Status: %v, Method: %s", resource.ID, isValid, detectionMethod)
|
||||
}
|
||||
}
|
||||
|
||||
// 构建检测结果
|
||||
result := gin.H{
|
||||
"resource_id": resource.ID,
|
||||
"url": resource.URL,
|
||||
"is_valid": isValid,
|
||||
"last_checked": time.Now().Format(time.RFC3339),
|
||||
"detection_method": detectionMethod,
|
||||
"cached": false,
|
||||
}
|
||||
|
||||
// 缓存检测结果
|
||||
cacheManager.Set(cacheKey, result)
|
||||
|
||||
utils.Info("资源有效性检测完成 - ID: %d, Valid: %v, Method: %s", resource.ID, isValid, detectionMethod)
|
||||
SuccessResponse(c, result)
|
||||
}
|
||||
|
||||
// performAdvancedValidityCheck 执行深度检测(只使用具体网盘服务)
|
||||
func performAdvancedValidityCheck(resource *entity.Resource) (bool, string, error) {
|
||||
// 提取分享ID和服务类型
|
||||
shareID, serviceType := panutils.ExtractShareId(resource.URL)
|
||||
if serviceType == panutils.NotFound {
|
||||
return false, "unsupported", fmt.Errorf("不支持的网盘服务: %s", resource.URL)
|
||||
}
|
||||
|
||||
utils.Info("开始深度检测 - Service: %s, ShareID: %s", serviceType.String(), shareID)
|
||||
|
||||
// 根据服务类型选择检测策略
|
||||
switch serviceType {
|
||||
case panutils.Quark:
|
||||
return performQuarkValidityCheck(resource, shareID)
|
||||
case panutils.Alipan:
|
||||
return performAlipanValidityCheck(resource, shareID)
|
||||
case panutils.BaiduPan, panutils.UC, panutils.Xunlei, panutils.Tianyi, panutils.Pan123, panutils.Pan115:
|
||||
// 这些网盘暂未实现深度检测,返回不支持提示
|
||||
return false, "unsupported", fmt.Errorf("当前网盘类型 %s 暂不支持深度检测,请等待后续更新", serviceType.String())
|
||||
default:
|
||||
return false, "unsupported", fmt.Errorf("未知的网盘服务类型: %s", serviceType.String())
|
||||
}
|
||||
}
|
||||
|
||||
// performQuarkValidityCheck 夸克网盘深度检测
|
||||
func performQuarkValidityCheck(resource *entity.Resource, shareID string) (bool, string, error) {
|
||||
// 获取夸克网盘账号
|
||||
panID, err := getQuarkPanID()
|
||||
if err != nil {
|
||||
return false, "quark_failed", fmt.Errorf("获取夸克平台ID失败: %v", err)
|
||||
}
|
||||
|
||||
accounts, err := repoManager.CksRepository.FindByPanID(panID)
|
||||
if err != nil {
|
||||
return false, "quark_failed", fmt.Errorf("获取夸克网盘账号失败: %v", err)
|
||||
}
|
||||
|
||||
if len(accounts) == 0 {
|
||||
return false, "quark_failed", fmt.Errorf("没有可用的夸克网盘账号")
|
||||
}
|
||||
|
||||
// 选择第一个有效账号
|
||||
var selectedAccount *entity.Cks
|
||||
for _, account := range accounts {
|
||||
if account.IsValid {
|
||||
selectedAccount = &account
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if selectedAccount == nil {
|
||||
return false, "quark_failed", fmt.Errorf("没有有效的夸克网盘账号")
|
||||
}
|
||||
|
||||
// 创建网盘服务配置
|
||||
config := &pan.PanConfig{
|
||||
URL: resource.URL,
|
||||
Code: "",
|
||||
IsType: 1, // 只获取基本信息,不转存
|
||||
ExpiredType: 1,
|
||||
AdFid: "",
|
||||
Stoken: "",
|
||||
Cookie: selectedAccount.Ck,
|
||||
}
|
||||
|
||||
// 创建夸克网盘服务
|
||||
factory := pan.NewPanFactory()
|
||||
panService, err := factory.CreatePanService(resource.URL, config)
|
||||
if err != nil {
|
||||
return false, "quark_failed", fmt.Errorf("创建夸克网盘服务失败: %v", err)
|
||||
}
|
||||
|
||||
// 执行深度检测(Transfer方法)
|
||||
utils.Info("执行夸克网盘深度检测 - ShareID: %s", shareID)
|
||||
result, err := panService.Transfer(shareID)
|
||||
if err != nil {
|
||||
return false, "quark_failed", fmt.Errorf("夸克网盘检测失败: %v", err)
|
||||
}
|
||||
|
||||
if !result.Success {
|
||||
return false, "quark_failed", fmt.Errorf("夸克网盘链接无效: %s", result.Message)
|
||||
}
|
||||
|
||||
utils.Info("夸克网盘深度检测成功 - ShareID: %s", shareID)
|
||||
return true, "quark_deep", nil
|
||||
}
|
||||
|
||||
// performAlipanValidityCheck 阿里云盘深度检测
|
||||
func performAlipanValidityCheck(resource *entity.Resource, shareID string) (bool, string, error) {
|
||||
// 阿里云盘深度检测暂未实现
|
||||
utils.Info("阿里云盘暂不支持深度检测 - ShareID: %s", shareID)
|
||||
return false, "unsupported", fmt.Errorf("阿里云盘暂不支持深度检测,请等待后续更新")
|
||||
}
|
||||
|
||||
|
||||
// BatchCheckResourceValidity 批量检查资源链接有效性
|
||||
func BatchCheckResourceValidity(c *gin.Context) {
|
||||
var req struct {
|
||||
IDs []uint `json:"ids" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
ErrorResponse(c, "参数错误: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.IDs) == 0 {
|
||||
ErrorResponse(c, "ID列表不能为空", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.IDs) > 20 {
|
||||
ErrorResponse(c, "单次最多检测20个资源", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
utils.Info("开始批量检测资源有效性 - Count: %d", len(req.IDs))
|
||||
|
||||
cacheManager := utils.GetResourceValidityCache()
|
||||
ttl := 5 * time.Minute
|
||||
results := make([]gin.H, 0, len(req.IDs))
|
||||
|
||||
for _, id := range req.IDs {
|
||||
// 查询资源信息
|
||||
resource, err := repoManager.ResourceRepository.FindByID(id)
|
||||
if err != nil {
|
||||
results = append(results, gin.H{
|
||||
"resource_id": id,
|
||||
"is_valid": false,
|
||||
"error": "资源不存在",
|
||||
"cached": false,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查缓存
|
||||
cacheKey := fmt.Sprintf("resource_validity_%d", id)
|
||||
if cachedData, found := cacheManager.Get(cacheKey, ttl); found {
|
||||
if result, ok := cachedData.(gin.H); ok {
|
||||
result["cached"] = true
|
||||
results = append(results, result)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// 执行深度检测
|
||||
isValid, detectionMethod, err := performAdvancedValidityCheck(resource)
|
||||
|
||||
if err != nil {
|
||||
// 深度检测失败,但不标记为无效(用户可自行验证)
|
||||
result := gin.H{
|
||||
"resource_id": id,
|
||||
"url": resource.URL,
|
||||
"is_valid": resource.IsValid, // 保持原始状态
|
||||
"last_checked": time.Now().Format(time.RFC3339),
|
||||
"error": err.Error(),
|
||||
"detection_method": detectionMethod,
|
||||
"cached": false,
|
||||
"note": "当前网盘暂不支持自动检测,建议用户自行验证",
|
||||
}
|
||||
cacheManager.Set(cacheKey, result)
|
||||
results = append(results, result)
|
||||
continue
|
||||
}
|
||||
|
||||
// 只有明确检测出无效的资源才更新数据库状态
|
||||
if detectionMethod == "quark_deep" && isValid != resource.IsValid {
|
||||
resource.IsValid = isValid
|
||||
updateErr := repoManager.ResourceRepository.Update(resource)
|
||||
if updateErr != nil {
|
||||
utils.Error("更新资源有效性状态失败 - ID: %d, Error: %v", id, updateErr)
|
||||
}
|
||||
}
|
||||
|
||||
result := gin.H{
|
||||
"resource_id": id,
|
||||
"url": resource.URL,
|
||||
"is_valid": isValid,
|
||||
"last_checked": time.Now().Format(time.RFC3339),
|
||||
"detection_method": detectionMethod,
|
||||
"cached": false,
|
||||
}
|
||||
|
||||
cacheManager.Set(cacheKey, result)
|
||||
results = append(results, result)
|
||||
}
|
||||
|
||||
utils.Info("批量检测资源有效性完成 - Count: %d", len(results))
|
||||
SuccessResponse(c, gin.H{
|
||||
"results": results,
|
||||
"total": len(results),
|
||||
})
|
||||
}
|
||||
|
||||
// getQuarkPanID 获取夸克网盘ID
|
||||
func getQuarkPanID() (uint, error) {
|
||||
// 通过FindAll方法查找所有平台,然后过滤出quark平台
|
||||
|
||||
@@ -18,11 +18,15 @@ func GetSchedulerStatus(c *gin.Context) {
|
||||
repoManager.CksRepository,
|
||||
repoManager.TagRepository,
|
||||
repoManager.CategoryRepository,
|
||||
repoManager.TaskItemRepository,
|
||||
repoManager.TaskRepository,
|
||||
)
|
||||
|
||||
status := gin.H{
|
||||
"hot_drama_scheduler_running": scheduler.IsHotDramaSchedulerRunning(),
|
||||
"ready_resource_scheduler_running": scheduler.IsReadyResourceRunning(),
|
||||
"google_index_scheduler_running": scheduler.IsGoogleIndexSchedulerRunning(),
|
||||
"sitemap_scheduler_running": scheduler.IsSitemapSchedulerRunning(),
|
||||
}
|
||||
|
||||
SuccessResponse(c, status)
|
||||
@@ -39,6 +43,8 @@ func StartHotDramaScheduler(c *gin.Context) {
|
||||
repoManager.CksRepository,
|
||||
repoManager.TagRepository,
|
||||
repoManager.CategoryRepository,
|
||||
repoManager.TaskItemRepository,
|
||||
repoManager.TaskRepository,
|
||||
)
|
||||
if scheduler.IsHotDramaSchedulerRunning() {
|
||||
ErrorResponse(c, "热播剧定时任务已在运行中", http.StatusBadRequest)
|
||||
@@ -59,6 +65,8 @@ func StopHotDramaScheduler(c *gin.Context) {
|
||||
repoManager.CksRepository,
|
||||
repoManager.TagRepository,
|
||||
repoManager.CategoryRepository,
|
||||
repoManager.TaskItemRepository,
|
||||
repoManager.TaskRepository,
|
||||
)
|
||||
if !scheduler.IsHotDramaSchedulerRunning() {
|
||||
ErrorResponse(c, "热播剧定时任务未在运行", http.StatusBadRequest)
|
||||
@@ -79,6 +87,8 @@ func TriggerHotDramaScheduler(c *gin.Context) {
|
||||
repoManager.CksRepository,
|
||||
repoManager.TagRepository,
|
||||
repoManager.CategoryRepository,
|
||||
repoManager.TaskItemRepository,
|
||||
repoManager.TaskRepository,
|
||||
)
|
||||
scheduler.StartHotDramaScheduler() // 直接启动一次
|
||||
SuccessResponse(c, gin.H{"message": "手动触发热播剧定时任务成功"})
|
||||
@@ -95,6 +105,8 @@ func FetchHotDramaNames(c *gin.Context) {
|
||||
repoManager.CksRepository,
|
||||
repoManager.TagRepository,
|
||||
repoManager.CategoryRepository,
|
||||
repoManager.TaskItemRepository,
|
||||
repoManager.TaskRepository,
|
||||
)
|
||||
names, err := scheduler.GetHotDramaNames()
|
||||
if err != nil {
|
||||
@@ -115,6 +127,8 @@ func StartReadyResourceScheduler(c *gin.Context) {
|
||||
repoManager.CksRepository,
|
||||
repoManager.TagRepository,
|
||||
repoManager.CategoryRepository,
|
||||
repoManager.TaskItemRepository,
|
||||
repoManager.TaskRepository,
|
||||
)
|
||||
if scheduler.IsReadyResourceRunning() {
|
||||
ErrorResponse(c, "待处理资源自动处理任务已在运行中", http.StatusBadRequest)
|
||||
@@ -135,6 +149,8 @@ func StopReadyResourceScheduler(c *gin.Context) {
|
||||
repoManager.CksRepository,
|
||||
repoManager.TagRepository,
|
||||
repoManager.CategoryRepository,
|
||||
repoManager.TaskItemRepository,
|
||||
repoManager.TaskRepository,
|
||||
)
|
||||
if !scheduler.IsReadyResourceRunning() {
|
||||
ErrorResponse(c, "待处理资源自动处理任务未在运行", http.StatusBadRequest)
|
||||
@@ -155,6 +171,8 @@ func TriggerReadyResourceScheduler(c *gin.Context) {
|
||||
repoManager.CksRepository,
|
||||
repoManager.TagRepository,
|
||||
repoManager.CategoryRepository,
|
||||
repoManager.TaskItemRepository,
|
||||
repoManager.TaskRepository,
|
||||
)
|
||||
scheduler.StartReadyResourceScheduler() // 直接启动一次
|
||||
SuccessResponse(c, gin.H{"message": "手动触发待处理资源自动处理任务成功"})
|
||||
|
||||
427
handlers/sitemap_handler.go
Normal file
427
handlers/sitemap_handler.go
Normal file
@@ -0,0 +1,427 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/ctwj/urldb/db/entity"
|
||||
"github.com/ctwj/urldb/db/repo"
|
||||
"github.com/ctwj/urldb/scheduler"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var (
|
||||
resourceRepo repo.ResourceRepository
|
||||
systemConfigRepo repo.SystemConfigRepository
|
||||
hotDramaRepo repo.HotDramaRepository
|
||||
readyResourceRepo repo.ReadyResourceRepository
|
||||
panRepo repo.PanRepository
|
||||
cksRepo repo.CksRepository
|
||||
tagRepo repo.TagRepository
|
||||
categoryRepo repo.CategoryRepository
|
||||
)
|
||||
|
||||
// SetSitemapDependencies 注册Sitemap处理器依赖
|
||||
func SetSitemapDependencies(
|
||||
resourceRepository repo.ResourceRepository,
|
||||
systemConfigRepository repo.SystemConfigRepository,
|
||||
hotDramaRepository repo.HotDramaRepository,
|
||||
readyResourceRepository repo.ReadyResourceRepository,
|
||||
panRepository repo.PanRepository,
|
||||
cksRepository repo.CksRepository,
|
||||
tagRepository repo.TagRepository,
|
||||
categoryRepository repo.CategoryRepository,
|
||||
) {
|
||||
resourceRepo = resourceRepository
|
||||
systemConfigRepo = systemConfigRepository
|
||||
hotDramaRepo = hotDramaRepository
|
||||
readyResourceRepo = readyResourceRepository
|
||||
panRepo = panRepository
|
||||
cksRepo = cksRepository
|
||||
tagRepo = tagRepository
|
||||
categoryRepo = categoryRepository
|
||||
}
|
||||
|
||||
|
||||
const SITEMAP_MAX_URLS = 50000 // 每个sitemap最多5万个URL
|
||||
|
||||
// SitemapIndex sitemap索引结构
|
||||
type SitemapIndex struct {
|
||||
XMLName xml.Name `xml:"sitemapindex"`
|
||||
XMLNS string `xml:"xmlns,attr"`
|
||||
Sitemaps []Sitemap `xml:"sitemap"`
|
||||
}
|
||||
|
||||
// Sitemap 单个sitemap信息
|
||||
type Sitemap struct {
|
||||
Loc string `xml:"loc"`
|
||||
LastMod string `xml:"lastmod"`
|
||||
}
|
||||
|
||||
// UrlSet sitemap内容
|
||||
type UrlSet struct {
|
||||
XMLName xml.Name `xml:"urlset"`
|
||||
XMLNS string `xml:"xmlns,attr"`
|
||||
URLs []Url `xml:"url"`
|
||||
}
|
||||
|
||||
// Url 单个URL信息
|
||||
type Url struct {
|
||||
Loc string `xml:"loc"`
|
||||
LastMod string `xml:"lastmod"`
|
||||
ChangeFreq string `xml:"changefreq"`
|
||||
Priority float64 `xml:"priority"`
|
||||
}
|
||||
|
||||
// SitemapConfig sitemap配置
|
||||
type SitemapConfig struct {
|
||||
AutoGenerate bool `json:"auto_generate"`
|
||||
LastGenerate time.Time `json:"last_generate"`
|
||||
LastUpdate time.Time `json:"last_update"`
|
||||
}
|
||||
|
||||
// GetSitemapConfig 获取sitemap配置
|
||||
func GetSitemapConfig(c *gin.Context) {
|
||||
// 从全局调度器获取配置
|
||||
enabled, err := scheduler.GetGlobalScheduler(
|
||||
hotDramaRepo, readyResourceRepo, resourceRepo, systemConfigRepo,
|
||||
panRepo, cksRepo, tagRepo, categoryRepo,
|
||||
repoManager.TaskItemRepository,
|
||||
repoManager.TaskRepository,
|
||||
).GetSitemapConfig()
|
||||
if err != nil && err != gorm.ErrRecordNotFound {
|
||||
// 如果获取失败,尝试从配置表中获取
|
||||
configStr, err := systemConfigRepo.GetConfigValue(entity.ConfigKeySitemapAutoGenerateEnabled)
|
||||
if err != nil && err != gorm.ErrRecordNotFound {
|
||||
ErrorResponse(c, "获取配置失败", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
enabled = configStr == "1" || configStr == "true"
|
||||
}
|
||||
|
||||
// 获取最后生成时间(从配置中获取)
|
||||
configStr, err := systemConfigRepo.GetConfigValue(entity.ConfigKeySitemapLastGenerateTime)
|
||||
if err != nil && err != gorm.ErrRecordNotFound {
|
||||
// 如果获取失败,只返回启用状态
|
||||
config := SitemapConfig{
|
||||
AutoGenerate: enabled,
|
||||
LastGenerate: time.Time{}, // 空时间
|
||||
LastUpdate: time.Now(),
|
||||
}
|
||||
SuccessResponse(c, config)
|
||||
return
|
||||
}
|
||||
|
||||
var lastGenerateTime time.Time
|
||||
if configStr != "" {
|
||||
lastGenerateTime, _ = time.Parse("2006-01-02 15:04:05", configStr)
|
||||
}
|
||||
|
||||
config := SitemapConfig{
|
||||
AutoGenerate: enabled,
|
||||
LastGenerate: lastGenerateTime,
|
||||
LastUpdate: time.Now(),
|
||||
}
|
||||
|
||||
SuccessResponse(c, config)
|
||||
}
|
||||
|
||||
// UpdateSitemapConfig 更新sitemap配置
|
||||
func UpdateSitemapConfig(c *gin.Context) {
|
||||
var config SitemapConfig
|
||||
if err := c.ShouldBindJSON(&config); err != nil {
|
||||
ErrorResponse(c, "参数错误", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 更新调度器配置
|
||||
if err := scheduler.GetGlobalScheduler(
|
||||
hotDramaRepo, readyResourceRepo, resourceRepo, systemConfigRepo,
|
||||
panRepo, cksRepo, tagRepo, categoryRepo,
|
||||
repoManager.TaskItemRepository,
|
||||
repoManager.TaskRepository,
|
||||
).UpdateSitemapConfig(config.AutoGenerate); err != nil {
|
||||
ErrorResponse(c, "更新调度器配置失败", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 保存自动生成功能状态
|
||||
autoGenerateStr := "0"
|
||||
if config.AutoGenerate {
|
||||
autoGenerateStr = "1"
|
||||
}
|
||||
autoGenerateConfig := entity.SystemConfig{
|
||||
Key: entity.ConfigKeySitemapAutoGenerateEnabled,
|
||||
Value: autoGenerateStr,
|
||||
Type: "bool",
|
||||
}
|
||||
|
||||
// 保存最后生成时间
|
||||
lastGenerateStr := config.LastGenerate.Format("2006-01-02 15:04:05")
|
||||
lastGenerateConfig := entity.SystemConfig{
|
||||
Key: entity.ConfigKeySitemapLastGenerateTime,
|
||||
Value: lastGenerateStr,
|
||||
Type: "string",
|
||||
}
|
||||
|
||||
configs := []entity.SystemConfig{autoGenerateConfig, lastGenerateConfig}
|
||||
if err := systemConfigRepo.UpsertConfigs(configs); err != nil {
|
||||
ErrorResponse(c, "保存配置失败", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 根据配置启动或停止调度器
|
||||
if config.AutoGenerate {
|
||||
scheduler.GetGlobalScheduler(
|
||||
hotDramaRepo, readyResourceRepo, resourceRepo, systemConfigRepo,
|
||||
panRepo, cksRepo, tagRepo, categoryRepo,
|
||||
repoManager.TaskItemRepository,
|
||||
repoManager.TaskRepository,
|
||||
).StartSitemapScheduler()
|
||||
} else {
|
||||
scheduler.GetGlobalScheduler(
|
||||
hotDramaRepo, readyResourceRepo, resourceRepo, systemConfigRepo,
|
||||
panRepo, cksRepo, tagRepo, categoryRepo,
|
||||
repoManager.TaskItemRepository,
|
||||
repoManager.TaskRepository,
|
||||
).StopSitemapScheduler()
|
||||
}
|
||||
|
||||
SuccessResponse(c, config)
|
||||
}
|
||||
|
||||
// GenerateSitemap 手动生成sitemap
|
||||
func GenerateSitemap(c *gin.Context) {
|
||||
// 获取资源总数
|
||||
var total int64
|
||||
if err := resourceRepo.GetDB().Model(&entity.Resource{}).Count(&total).Error; err != nil {
|
||||
ErrorResponse(c, "获取资源总数失败", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
totalPages := int((total + SITEMAP_MAX_URLS - 1) / SITEMAP_MAX_URLS)
|
||||
|
||||
// 获取全局调度器并立即执行sitemap生成
|
||||
globalScheduler := scheduler.GetGlobalScheduler(
|
||||
hotDramaRepo, readyResourceRepo, resourceRepo, systemConfigRepo,
|
||||
panRepo, cksRepo, tagRepo, categoryRepo,
|
||||
repoManager.TaskItemRepository,
|
||||
repoManager.TaskRepository,
|
||||
)
|
||||
|
||||
// 手动触发sitemap生成
|
||||
globalScheduler.TriggerSitemapGeneration()
|
||||
|
||||
// 记录最后生成时间为当前时间
|
||||
lastGenerateStr := time.Now().Format("2006-01-02 15:04:05")
|
||||
lastGenerateConfig := entity.SystemConfig{
|
||||
Key: entity.ConfigKeySitemapLastGenerateTime,
|
||||
Value: lastGenerateStr,
|
||||
Type: "string",
|
||||
}
|
||||
|
||||
if err := systemConfigRepo.UpsertConfigs([]entity.SystemConfig{lastGenerateConfig}); err != nil {
|
||||
ErrorResponse(c, "更新最后生成时间失败", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"total_resources": total,
|
||||
"total_pages": totalPages,
|
||||
"status": "started",
|
||||
"message": fmt.Sprintf("开始生成 %d 个sitemap文件", totalPages),
|
||||
}
|
||||
|
||||
SuccessResponse(c, result)
|
||||
}
|
||||
|
||||
// GetSitemapStatus 获取sitemap生成状态
|
||||
func GetSitemapStatus(c *gin.Context) {
|
||||
// 获取资源总数
|
||||
var total int64
|
||||
if err := resourceRepo.GetDB().Model(&entity.Resource{}).Count(&total).Error; err != nil {
|
||||
ErrorResponse(c, "获取资源总数失败", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 计算需要生成的sitemap文件数量
|
||||
totalPages := int((total + SITEMAP_MAX_URLS - 1) / SITEMAP_MAX_URLS)
|
||||
|
||||
// 获取最后生成时间
|
||||
lastGenerateStr, err := systemConfigRepo.GetConfigValue(entity.ConfigKeySitemapLastGenerateTime)
|
||||
if err != nil {
|
||||
// 如果没有记录,使用当前时间
|
||||
lastGenerateStr = time.Now().Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
lastGenerate, err := time.Parse("2006-01-02 15:04:05", lastGenerateStr)
|
||||
if err != nil {
|
||||
lastGenerate = time.Now()
|
||||
}
|
||||
|
||||
// 检查调度器是否运行
|
||||
isRunning := scheduler.GetGlobalScheduler(
|
||||
hotDramaRepo, readyResourceRepo, resourceRepo, systemConfigRepo,
|
||||
panRepo, cksRepo, tagRepo, categoryRepo,
|
||||
repoManager.TaskItemRepository,
|
||||
repoManager.TaskRepository,
|
||||
).IsSitemapSchedulerRunning()
|
||||
|
||||
// 获取自动生成功能状态
|
||||
autoGenerateEnabled, err := scheduler.GetGlobalScheduler(
|
||||
hotDramaRepo, readyResourceRepo, resourceRepo, systemConfigRepo,
|
||||
panRepo, cksRepo, tagRepo, categoryRepo,
|
||||
repoManager.TaskItemRepository,
|
||||
repoManager.TaskRepository,
|
||||
).GetSitemapConfig()
|
||||
if err != nil {
|
||||
// 如果调度器获取失败,从配置中获取
|
||||
configStr, err := systemConfigRepo.GetConfigValue(entity.ConfigKeySitemapAutoGenerateEnabled)
|
||||
if err != nil {
|
||||
autoGenerateEnabled = false
|
||||
} else {
|
||||
autoGenerateEnabled = configStr == "1" || configStr == "true"
|
||||
}
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"total_resources": total,
|
||||
"total_pages": totalPages,
|
||||
"last_generate": lastGenerate.Format("2006-01-02 15:04:05"),
|
||||
"status": "ready",
|
||||
"is_running": isRunning,
|
||||
"auto_generate": autoGenerateEnabled,
|
||||
}
|
||||
|
||||
SuccessResponse(c, result)
|
||||
}
|
||||
|
||||
// SitemapIndexHandler sitemap索引文件处理器
|
||||
func SitemapIndexHandler(c *gin.Context) {
|
||||
// 获取资源总数
|
||||
var total int64
|
||||
if err := resourceRepo.GetDB().Model(&entity.Resource{}).Count(&total).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取资源总数失败"})
|
||||
return
|
||||
}
|
||||
|
||||
totalPages := int((total + SITEMAP_MAX_URLS - 1) / SITEMAP_MAX_URLS)
|
||||
|
||||
// 构建主机URL
|
||||
scheme := "http"
|
||||
if c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https" {
|
||||
scheme = "https"
|
||||
}
|
||||
host := c.Request.Host
|
||||
if host == "" {
|
||||
host = "localhost:8080" // 默认值
|
||||
}
|
||||
baseURL := fmt.Sprintf("%s://%s", scheme, host)
|
||||
|
||||
// 创建sitemap列表 - 现在文件保存在data/sitemap目录,通过/file/sitemap/路径访问
|
||||
var sitemaps []Sitemap
|
||||
for i := 0; i < totalPages; i++ {
|
||||
sitemapURL := fmt.Sprintf("%s/file/sitemap/sitemap-%d.xml", baseURL, i)
|
||||
sitemaps = append(sitemaps, Sitemap{
|
||||
Loc: sitemapURL,
|
||||
LastMod: time.Now().Format("2006-01-02"),
|
||||
})
|
||||
}
|
||||
|
||||
sitemapIndex := SitemapIndex{
|
||||
XMLNS: "http://www.sitemaps.org/schemas/sitemap/0.9",
|
||||
Sitemaps: sitemaps,
|
||||
}
|
||||
|
||||
c.Header("Content-Type", "application/xml")
|
||||
c.XML(http.StatusOK, sitemapIndex)
|
||||
}
|
||||
|
||||
// SitemapPageHandler sitemap页面处理器
|
||||
func SitemapPageHandler(c *gin.Context) {
|
||||
pageStr := c.Param("page")
|
||||
page, err := strconv.Atoi(pageStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的页面参数"})
|
||||
return
|
||||
}
|
||||
|
||||
offset := page * SITEMAP_MAX_URLS
|
||||
limit := SITEMAP_MAX_URLS
|
||||
|
||||
var resources []entity.Resource
|
||||
if err := resourceRepo.GetDB().Offset(offset).Limit(limit).Find(&resources).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取资源数据失败"})
|
||||
return
|
||||
}
|
||||
|
||||
var urls []Url
|
||||
for _, resource := range resources {
|
||||
lastMod := resource.UpdatedAt
|
||||
if resource.CreatedAt.After(lastMod) {
|
||||
lastMod = resource.CreatedAt
|
||||
}
|
||||
|
||||
urls = append(urls, Url{
|
||||
Loc: fmt.Sprintf("/r/%s", resource.Key),
|
||||
LastMod: lastMod.Format("2006-01-01"), // 只保留日期部分
|
||||
ChangeFreq: "weekly",
|
||||
Priority: 0.8,
|
||||
})
|
||||
}
|
||||
|
||||
urlSet := UrlSet{
|
||||
XMLNS: "http://www.sitemaps.org/schemas/sitemap/0.9",
|
||||
URLs: urls,
|
||||
}
|
||||
|
||||
c.Header("Content-Type", "application/xml")
|
||||
c.XML(http.StatusOK, urlSet)
|
||||
}
|
||||
|
||||
// 手动生成完整sitemap文件
|
||||
func GenerateFullSitemap(c *gin.Context) {
|
||||
// 获取资源总数
|
||||
var total int64
|
||||
if err := resourceRepo.GetDB().Model(&entity.Resource{}).Count(&total).Error; err != nil {
|
||||
ErrorResponse(c, "获取资源总数失败", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 获取全局调度器并立即执行sitemap生成
|
||||
globalScheduler := scheduler.GetGlobalScheduler(
|
||||
hotDramaRepo, readyResourceRepo, resourceRepo, systemConfigRepo,
|
||||
panRepo, cksRepo, tagRepo, categoryRepo,
|
||||
repoManager.TaskItemRepository,
|
||||
repoManager.TaskRepository,
|
||||
)
|
||||
|
||||
// 手动触发sitemap生成
|
||||
globalScheduler.TriggerSitemapGeneration()
|
||||
|
||||
// 记录最后生成时间为当前时间
|
||||
lastGenerateStr := time.Now().Format("2006-01-02 15:04:05")
|
||||
lastGenerateConfig := entity.SystemConfig{
|
||||
Key: entity.ConfigKeySitemapLastGenerateTime,
|
||||
Value: lastGenerateStr,
|
||||
Type: "string",
|
||||
}
|
||||
|
||||
if err := systemConfigRepo.UpsertConfigs([]entity.SystemConfig{lastGenerateConfig}); err != nil {
|
||||
ErrorResponse(c, "更新最后生成时间失败", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"message": "Sitemap生成任务已启动",
|
||||
"total_resources": total,
|
||||
"status": "processing",
|
||||
"estimated_time": fmt.Sprintf("%d秒", total/1000), // 估算时间
|
||||
}
|
||||
|
||||
SuccessResponse(c, result)
|
||||
}
|
||||
@@ -180,9 +180,9 @@ func UpdateSystemConfig(c *gin.Context) {
|
||||
}
|
||||
|
||||
if req.AutoTransferMinSpace != nil {
|
||||
if *req.AutoTransferMinSpace < 100 || *req.AutoTransferMinSpace > 1024 {
|
||||
if *req.AutoTransferMinSpace < 5 || *req.AutoTransferMinSpace > 1024 {
|
||||
utils.Warn("配置验证失败 - AutoTransferMinSpace超出范围: %d", *req.AutoTransferMinSpace)
|
||||
ErrorResponse(c, "最小存储空间必须在100-1024GB之间", http.StatusBadRequest)
|
||||
ErrorResponse(c, "最小存储空间必须在5-1024GB之间", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -240,6 +240,8 @@ func UpdateSystemConfig(c *gin.Context) {
|
||||
repoManager.CksRepository,
|
||||
repoManager.TagRepository,
|
||||
repoManager.CategoryRepository,
|
||||
repoManager.TaskItemRepository,
|
||||
repoManager.TaskRepository,
|
||||
)
|
||||
if scheduler != nil {
|
||||
// 只更新被设置的配置
|
||||
@@ -281,6 +283,7 @@ func GetPublicSystemConfig(c *gin.Context) {
|
||||
SuccessResponse(c, configResponse)
|
||||
}
|
||||
|
||||
|
||||
// 新增:配置监控端点
|
||||
func GetConfigStatus(c *gin.Context) {
|
||||
// 获取配置统计信息
|
||||
@@ -366,6 +369,8 @@ func ToggleAutoProcess(c *gin.Context) {
|
||||
repoManager.CksRepository,
|
||||
repoManager.TagRepository,
|
||||
repoManager.CategoryRepository,
|
||||
repoManager.TaskItemRepository,
|
||||
repoManager.TaskRepository,
|
||||
)
|
||||
if scheduler != nil {
|
||||
// 获取其他配置值
|
||||
|
||||
@@ -511,6 +511,27 @@ func (h *TelegramHandler) GetTelegramLogStats(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// ManualPushToChannel 手动推送到频道
|
||||
func (h *TelegramHandler) ManualPushToChannel(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
channelID, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
ErrorResponse(c, "无效的频道ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
err = h.telegramBotService.ManualPushToChannel(uint(channelID))
|
||||
if err != nil {
|
||||
ErrorResponse(c, "手动推送失败: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
SuccessResponse(c, map[string]interface{}{
|
||||
"success": true,
|
||||
"message": "手动推送请求已提交",
|
||||
})
|
||||
}
|
||||
|
||||
// ClearTelegramLogs 清理旧的Telegram日志
|
||||
func (h *TelegramHandler) ClearTelegramLogs(c *gin.Context) {
|
||||
daysStr := c.DefaultQuery("days", "30")
|
||||
|
||||
129
main.go
129
main.go
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
@@ -159,6 +160,18 @@ func main() {
|
||||
// 将Repository管理器注入到services中
|
||||
services.SetRepositoryManager(repoManager)
|
||||
|
||||
// 设置Sitemap处理器依赖
|
||||
handlers.SetSitemapDependencies(
|
||||
repoManager.ResourceRepository,
|
||||
repoManager.SystemConfigRepository,
|
||||
repoManager.HotDramaRepository,
|
||||
repoManager.ReadyResourceRepository,
|
||||
repoManager.PanRepository,
|
||||
repoManager.CksRepository,
|
||||
repoManager.TagRepository,
|
||||
repoManager.CategoryRepository,
|
||||
)
|
||||
|
||||
// 设置Meilisearch管理器到handlers中
|
||||
handlers.SetMeilisearchManager(meilisearchManager)
|
||||
|
||||
@@ -178,12 +191,16 @@ func main() {
|
||||
repoManager.CksRepository,
|
||||
repoManager.TagRepository,
|
||||
repoManager.CategoryRepository,
|
||||
repoManager.TaskItemRepository,
|
||||
repoManager.TaskRepository,
|
||||
)
|
||||
|
||||
// 根据系统配置启动相应的调度任务
|
||||
autoFetchHotDrama, _ := repoManager.SystemConfigRepository.GetConfigBool(entity.ConfigKeyAutoFetchHotDramaEnabled)
|
||||
autoProcessReadyResources, _ := repoManager.SystemConfigRepository.GetConfigBool(entity.ConfigKeyAutoProcessReadyResources)
|
||||
autoTransferEnabled, _ := repoManager.SystemConfigRepository.GetConfigBool(entity.ConfigKeyAutoTransferEnabled)
|
||||
autoSitemapEnabled, _ := repoManager.SystemConfigRepository.GetConfigBool(entity.ConfigKeySitemapAutoGenerateEnabled)
|
||||
autoGoogleIndexEnabled, _ := repoManager.SystemConfigRepository.GetConfigBool(entity.GoogleIndexConfigKeyEnabled)
|
||||
|
||||
globalScheduler.UpdateSchedulerStatusWithAutoTransfer(
|
||||
autoFetchHotDrama,
|
||||
@@ -191,6 +208,22 @@ func main() {
|
||||
autoTransferEnabled,
|
||||
)
|
||||
|
||||
// 根据系统配置启动Sitemap调度器
|
||||
if autoSitemapEnabled {
|
||||
globalScheduler.StartSitemapScheduler()
|
||||
utils.Info("系统配置启用Sitemap自动生成功能,启动定时任务")
|
||||
} else {
|
||||
utils.Info("系统配置禁用Sitemap自动生成功能")
|
||||
}
|
||||
|
||||
// 根据系统配置启动Google索引调度器
|
||||
if autoGoogleIndexEnabled {
|
||||
globalScheduler.StartGoogleIndexScheduler()
|
||||
utils.Info("系统配置启用Google索引自动提交功能,启动定时任务")
|
||||
} else {
|
||||
utils.Info("系统配置禁用Google索引自动提交功能")
|
||||
}
|
||||
|
||||
utils.Info("调度器初始化完成")
|
||||
|
||||
// 设置公开API中间件的Repository管理器
|
||||
@@ -208,6 +241,24 @@ func main() {
|
||||
// 创建Meilisearch处理器
|
||||
meilisearchHandler := handlers.NewMeilisearchHandler(meilisearchManager)
|
||||
|
||||
// 创建OG图片处理器
|
||||
ogImageHandler := handlers.NewOGImageHandler()
|
||||
|
||||
// 创建举报和版权申述处理器
|
||||
reportHandler := handlers.NewReportHandler(repoManager.ReportRepository, repoManager.ResourceRepository)
|
||||
copyrightClaimHandler := handlers.NewCopyrightClaimHandler(repoManager.CopyrightClaimRepository, repoManager.ResourceRepository)
|
||||
|
||||
// 创建Google索引任务处理器
|
||||
googleIndexProcessor := task.NewGoogleIndexProcessor(repoManager)
|
||||
|
||||
// 创建Google索引处理器
|
||||
googleIndexHandler := handlers.NewGoogleIndexHandler(repoManager, taskManager)
|
||||
|
||||
// 注册Google索引处理器到任务管理器
|
||||
taskManager.RegisterProcessor(googleIndexProcessor)
|
||||
|
||||
utils.Info("Google索引功能已启用,注册到任务管理器")
|
||||
|
||||
// API路由
|
||||
api := r.Group("/api")
|
||||
{
|
||||
@@ -230,13 +281,18 @@ func main() {
|
||||
|
||||
// 资源管理
|
||||
api.GET("/resources", handlers.GetResources)
|
||||
api.GET("/resources/hot", handlers.GetHotResources)
|
||||
api.POST("/resources", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.CreateResource)
|
||||
api.PUT("/resources/:id", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.UpdateResource)
|
||||
api.DELETE("/resources/:id", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.DeleteResource)
|
||||
api.GET("/resources/:id", handlers.GetResourceByID)
|
||||
api.GET("/resources/key/:key", handlers.GetResourcesByKey)
|
||||
api.GET("/resources/check-exists", handlers.CheckResourceExists)
|
||||
api.GET("/resources/related", handlers.GetRelatedResources)
|
||||
api.POST("/resources/:id/view", handlers.IncrementResourceViewCount)
|
||||
api.GET("/resources/:id/link", handlers.GetResourceLink)
|
||||
api.GET("/resources/:id/validity", handlers.CheckResourceValidity)
|
||||
api.POST("/resources/validity/batch", handlers.BatchCheckResourceValidity)
|
||||
api.DELETE("/resources/batch", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.BatchDeleteResources)
|
||||
|
||||
// 分类管理
|
||||
@@ -329,6 +385,7 @@ func main() {
|
||||
api.GET("/system/config/status", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.GetConfigStatus)
|
||||
api.POST("/system/config/toggle-auto-process", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.ToggleAutoProcess)
|
||||
api.GET("/public/system-config", handlers.GetPublicSystemConfig)
|
||||
api.GET("/public/site-verification", handlers.GetPublicSiteVerificationCode) // 网站验证代码(公开访问)
|
||||
|
||||
// 热播剧管理路由(查询接口无需认证)
|
||||
api.GET("/hot-dramas", handlers.GetHotDramaList)
|
||||
@@ -423,6 +480,7 @@ func main() {
|
||||
api.GET("/telegram/logs/stats", middleware.AuthMiddleware(), middleware.AdminMiddleware(), telegramHandler.GetTelegramLogStats)
|
||||
api.POST("/telegram/logs/clear", middleware.AuthMiddleware(), middleware.AdminMiddleware(), telegramHandler.ClearTelegramLogs)
|
||||
api.POST("/telegram/webhook", telegramHandler.HandleWebhook)
|
||||
api.POST("/telegram/manual-push/:id", middleware.AuthMiddleware(), middleware.AdminMiddleware(), telegramHandler.ManualPushToChannel)
|
||||
|
||||
// 微信公众号相关路由
|
||||
wechatHandler := handlers.NewWechatHandler(
|
||||
@@ -434,6 +492,74 @@ func main() {
|
||||
api.GET("/wechat/bot-status", middleware.AuthMiddleware(), middleware.AdminMiddleware(), wechatHandler.GetBotStatus)
|
||||
api.POST("/wechat/callback", wechatHandler.HandleWechatMessage)
|
||||
api.GET("/wechat/callback", wechatHandler.HandleWechatMessage)
|
||||
|
||||
// OG图片生成路由
|
||||
api.GET("/og-image", ogImageHandler.GenerateOGImage)
|
||||
|
||||
// 举报和版权申述路由
|
||||
api.POST("/reports", reportHandler.CreateReport)
|
||||
api.GET("/reports/:id", reportHandler.GetReport)
|
||||
api.GET("/reports", middleware.AuthMiddleware(), middleware.AdminMiddleware(), reportHandler.ListReports)
|
||||
api.PUT("/reports/:id", middleware.AuthMiddleware(), middleware.AdminMiddleware(), reportHandler.UpdateReport)
|
||||
api.DELETE("/reports/:id", middleware.AuthMiddleware(), middleware.AdminMiddleware(), reportHandler.DeleteReport)
|
||||
api.GET("/reports/resource/:resource_key", reportHandler.GetReportByResource)
|
||||
|
||||
api.POST("/copyright-claims", copyrightClaimHandler.CreateCopyrightClaim)
|
||||
api.GET("/copyright-claims/:id", copyrightClaimHandler.GetCopyrightClaim)
|
||||
api.GET("/copyright-claims", middleware.AuthMiddleware(), middleware.AdminMiddleware(), copyrightClaimHandler.ListCopyrightClaims)
|
||||
api.PUT("/copyright-claims/:id", middleware.AuthMiddleware(), middleware.AdminMiddleware(), copyrightClaimHandler.UpdateCopyrightClaim)
|
||||
api.DELETE("/copyright-claims/:id", middleware.AuthMiddleware(), middleware.AdminMiddleware(), copyrightClaimHandler.DeleteCopyrightClaim)
|
||||
api.GET("/copyright-claims/resource/:resource_key", copyrightClaimHandler.GetCopyrightClaimByResource)
|
||||
|
||||
// Sitemap静态文件服务(优先于API路由)
|
||||
// 提供生成的sitemap.xml索引文件
|
||||
r.StaticFile("/sitemap.xml", "./data/sitemap/sitemap.xml")
|
||||
// 提供生成的sitemap分页文件,使用通配符路由
|
||||
r.GET("/sitemap-:page", func(c *gin.Context) {
|
||||
page := c.Param("page")
|
||||
if !strings.HasSuffix(page, ".xml") {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "文件不存在"})
|
||||
return
|
||||
}
|
||||
c.File("./data/sitemap/sitemap-" + page)
|
||||
})
|
||||
|
||||
// Sitemap静态文件API路由(API兼容)
|
||||
api.GET("/sitemap.xml", func(c *gin.Context) {
|
||||
c.File("./data/sitemap/sitemap.xml")
|
||||
})
|
||||
// 提供生成的sitemap分页文件,使用API路径
|
||||
api.GET("/sitemap-:page", func(c *gin.Context) {
|
||||
page := c.Param("page")
|
||||
if !strings.HasSuffix(page, ".xml") {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "文件不存在"})
|
||||
return
|
||||
}
|
||||
c.File("./data/sitemap/sitemap-" + page)
|
||||
})
|
||||
|
||||
// Sitemap管理API(通过管理员接口进行管理)
|
||||
api.GET("/sitemap/config", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.GetSitemapConfig)
|
||||
api.POST("/sitemap/config", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.UpdateSitemapConfig)
|
||||
api.POST("/sitemap/generate", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.GenerateSitemap)
|
||||
api.GET("/sitemap/status", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.GetSitemapStatus)
|
||||
api.POST("/sitemap/full-generate", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.GenerateFullSitemap)
|
||||
|
||||
// Google索引管理API
|
||||
api.GET("/google-index/config", middleware.AuthMiddleware(), middleware.AdminMiddleware(), googleIndexHandler.GetConfig)
|
||||
api.GET("/google-index/config-all", middleware.AuthMiddleware(), middleware.AdminMiddleware(), googleIndexHandler.GetAllConfig) // 获取所有配置
|
||||
api.POST("/google-index/config", middleware.AuthMiddleware(), middleware.AdminMiddleware(), googleIndexHandler.UpdateConfig)
|
||||
api.POST("/google-index/config/update", middleware.AuthMiddleware(), middleware.AdminMiddleware(), googleIndexHandler.UpdateGoogleIndexConfig) // 分组配置更新
|
||||
api.GET("/google-index/status", middleware.AuthMiddleware(), middleware.AdminMiddleware(), googleIndexHandler.GetStatus) // 获取状态
|
||||
api.POST("/google-index/tasks", middleware.AuthMiddleware(), middleware.AdminMiddleware(), googleIndexHandler.CreateTask)
|
||||
api.GET("/google-index/tasks", middleware.AuthMiddleware(), middleware.AdminMiddleware(), googleIndexHandler.GetTasks)
|
||||
api.GET("/google-index/tasks/:id", middleware.AuthMiddleware(), middleware.AdminMiddleware(), googleIndexHandler.GetTaskStatus)
|
||||
api.POST("/google-index/tasks/:id/start", middleware.AuthMiddleware(), middleware.AdminMiddleware(), googleIndexHandler.StartTask)
|
||||
api.GET("/google-index/tasks/:id/items", middleware.AuthMiddleware(), middleware.AdminMiddleware(), googleIndexHandler.GetTaskItems)
|
||||
|
||||
// Google索引凭据上传和验证API
|
||||
api.POST("/google-index/upload-credentials", middleware.AuthMiddleware(), middleware.AdminMiddleware(), googleIndexHandler.UploadCredentials)
|
||||
api.POST("/google-index/validate-credentials", middleware.AuthMiddleware(), middleware.AdminMiddleware(), googleIndexHandler.ValidateCredentials)
|
||||
}
|
||||
|
||||
// 设置监控系统
|
||||
@@ -451,10 +577,11 @@ func main() {
|
||||
|
||||
// 静态文件服务
|
||||
r.Static("/uploads", "./uploads")
|
||||
r.Static("/data", "./data")
|
||||
|
||||
// 添加CORS头到静态文件
|
||||
r.Use(func(c *gin.Context) {
|
||||
if strings.HasPrefix(c.Request.URL.Path, "/uploads/") {
|
||||
if strings.HasPrefix(c.Request.URL.Path, "/uploads/") || strings.HasPrefix(c.Request.URL.Path, "/data/") {
|
||||
c.Header("Access-Control-Allow-Origin", "*")
|
||||
c.Header("Access-Control-Allow-Methods", "GET, OPTIONS")
|
||||
c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Accept")
|
||||
|
||||
@@ -56,22 +56,18 @@ func LoggingMiddleware(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
// logRequest 记录请求日志 - 优化后仅记录异常和关键请求
|
||||
// logRequest 记录请求日志 - 恢复正常请求日志记录
|
||||
func logRequest(r *http.Request, rw *responseWriter, duration time.Duration, requestBody []byte) {
|
||||
// 获取客户端IP
|
||||
clientIP := getClientIP(r)
|
||||
|
||||
// 判断是否需要记录日志的条件
|
||||
shouldLog := rw.statusCode >= 400 || // 错误状态码
|
||||
// 判断是否需要详细记录日志的条件
|
||||
shouldDetailLog := rw.statusCode >= 400 || // 错误状态码
|
||||
duration > 5*time.Second || // 耗时过长
|
||||
shouldLogPath(r.URL.Path) || // 关键路径
|
||||
isAdminPath(r.URL.Path) // 管理员路径
|
||||
|
||||
if !shouldLog {
|
||||
return // 正常请求不记录日志,减少日志噪音
|
||||
}
|
||||
|
||||
// 简化的日志格式,移除User-Agent以减少噪音
|
||||
// 所有API请求都记录基本信息,但详细日志只记录重要请求
|
||||
if rw.statusCode >= 400 {
|
||||
// 错误请求记录详细信息
|
||||
utils.Error("HTTP异常 - %s %s - IP: %s - 状态码: %d - 耗时: %v",
|
||||
@@ -85,10 +81,14 @@ func logRequest(r *http.Request, rw *responseWriter, duration time.Duration, req
|
||||
// 慢请求警告
|
||||
utils.Warn("HTTP慢请求 - %s %s - IP: %s - 耗时: %v",
|
||||
r.Method, r.URL.Path, clientIP, duration)
|
||||
} else {
|
||||
} else if shouldDetailLog {
|
||||
// 关键路径的正常请求
|
||||
utils.Info("HTTP关键请求 - %s %s - IP: %s - 状态码: %d - 耗时: %v",
|
||||
r.Method, r.URL.Path, clientIP, rw.statusCode, duration)
|
||||
} else {
|
||||
// 普通API请求记录简化日志 - 使用Info级别确保能被看到
|
||||
// utils.Info("HTTP请求 - %s %s - 状态码: %d - 耗时: %v",
|
||||
// r.Method, r.URL.Path, rw.statusCode, duration)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,6 +100,13 @@ func shouldLogPath(path string) bool {
|
||||
"/api/admin/config",
|
||||
"/api/admin/users",
|
||||
"/telegram/webhook",
|
||||
"/api/resources",
|
||||
"/api/version",
|
||||
"/api/cks",
|
||||
"/api/pans",
|
||||
"/api/categories",
|
||||
"/api/tags",
|
||||
"/api/tasks",
|
||||
}
|
||||
|
||||
for _, keyPath := range keyPaths {
|
||||
@@ -113,7 +120,7 @@ func shouldLogPath(path string) bool {
|
||||
// isAdminPath 判断是否为管理员路径
|
||||
func isAdminPath(path string) bool {
|
||||
return strings.HasPrefix(path, "/api/admin/") ||
|
||||
strings.HasPrefix(path, "/admin/")
|
||||
strings.HasPrefix(path, "/admin/")
|
||||
}
|
||||
|
||||
// getClientIP 获取客户端真实IP地址
|
||||
|
||||
@@ -17,7 +17,23 @@ server {
|
||||
add_header Referrer-Policy "no-referrer-when-downgrade" always;
|
||||
add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always;
|
||||
|
||||
# API 路由 - 所有 /api/ 开头的请求转发到后端
|
||||
location /sitemap.xml {
|
||||
proxy_pass http://backend/api$request_uri;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location ~ ^/sitemap-([0-9]+)\.xml$ {
|
||||
proxy_pass http://backend/api$request_uri;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# API 路由 - 所有 /api/ 开头的请求转发到后端
|
||||
location /api/ {
|
||||
proxy_pass http://backend;
|
||||
proxy_set_header Host $host;
|
||||
@@ -39,6 +55,23 @@ server {
|
||||
proxy_intercept_errors on;
|
||||
error_page 502 503 504 /50x.html;
|
||||
}
|
||||
|
||||
|
||||
location ^~ /uploads/ {
|
||||
proxy_pass http://backend/uploads/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
|
||||
add_header Access-Control-Allow-Origin "*";
|
||||
add_header Access-Control-Allow-Methods "GET, OPTIONS";
|
||||
add_header Access-Control-Allow-Headers "Origin, Content-Type, Accept";
|
||||
}
|
||||
|
||||
|
||||
# 静态文件路由 - 直接转发到前端
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
@@ -53,24 +86,6 @@ server {
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# 上传文件路由 - 直接访问后端的上传目录
|
||||
location /uploads/ {
|
||||
proxy_pass http://backend;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# 缓存设置
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
|
||||
# 允许跨域访问
|
||||
add_header Access-Control-Allow-Origin "*";
|
||||
add_header Access-Control-Allow-Methods "GET, OPTIONS";
|
||||
add_header Access-Control-Allow-Headers "Origin, Content-Type, Accept";
|
||||
}
|
||||
|
||||
# 微信公众号验证文件路由 - 根目录的TXT文件直接访问后端uploads目录
|
||||
location ~ ^/[^/]+\.txt$ {
|
||||
# 检查文件是否存在于uploads目录
|
||||
|
||||
263
pkg/google/client.go
Normal file
263
pkg/google/client.go
Normal file
@@ -0,0 +1,263 @@
|
||||
package google
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/oauth2/google"
|
||||
"google.golang.org/api/option"
|
||||
"google.golang.org/api/searchconsole/v1"
|
||||
)
|
||||
|
||||
// Client Google Search Console API客户端
|
||||
type Client struct {
|
||||
service *searchconsole.Service
|
||||
SiteURL string
|
||||
}
|
||||
|
||||
// Config 配置信息
|
||||
type Config struct {
|
||||
CredentialsFile string `json:"credentials_file"`
|
||||
SiteURL string `json:"site_url"`
|
||||
TokenFile string `json:"token_file"`
|
||||
}
|
||||
|
||||
// URLInspectionRequest URL检查请求
|
||||
type URLInspectionRequest struct {
|
||||
InspectionURL string `json:"inspectionUrl"`
|
||||
SiteURL string `json:"siteUrl"`
|
||||
LanguageCode string `json:"languageCode"`
|
||||
}
|
||||
|
||||
// URLInspectionResult URL检查结果
|
||||
type URLInspectionResult struct {
|
||||
IndexStatusResult struct {
|
||||
IndexingState string `json:"indexingState"`
|
||||
LastCrawled string `json:"lastCrawled"`
|
||||
CrawlErrors []struct {
|
||||
ErrorCode string `json:"errorCode"`
|
||||
} `json:"crawlErrors"`
|
||||
} `json:"indexStatusResult"`
|
||||
MobileUsabilityResult struct {
|
||||
MobileFriendly bool `json:"mobileFriendly"`
|
||||
} `json:"mobileUsabilityResult"`
|
||||
RichResultsResult struct {
|
||||
Detected struct {
|
||||
Items []struct {
|
||||
RichResultType string `json:"richResultType"`
|
||||
} `json:"items"`
|
||||
} `json:"detected"`
|
||||
} `json:"richResultsResult"`
|
||||
}
|
||||
|
||||
// NewClient 创建新的客户端
|
||||
func NewClient(config *Config) (*Client, error) {
|
||||
ctx := context.Background()
|
||||
|
||||
// 读取认证文件
|
||||
credentials, err := os.ReadFile(config.CredentialsFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取认证文件失败: %v", err)
|
||||
}
|
||||
|
||||
// 检查凭据类型
|
||||
var credentialsMap map[string]interface{}
|
||||
if err := json.Unmarshal(credentials, &credentialsMap); err != nil {
|
||||
return nil, fmt.Errorf("解析凭据失败: %v", err)
|
||||
}
|
||||
|
||||
// 根据凭据类型创建不同的配置
|
||||
credType, ok := credentialsMap["type"].(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("未知的凭据类型")
|
||||
}
|
||||
|
||||
var client *http.Client
|
||||
if credType == "service_account" {
|
||||
// 服务账号凭据
|
||||
jwtConfig, err := google.JWTConfigFromJSON(credentials, searchconsole.WebmastersScope)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建JWT配置失败: %v", err)
|
||||
}
|
||||
client = jwtConfig.Client(ctx)
|
||||
} else {
|
||||
// OAuth2客户端凭据
|
||||
oauthConfig, err := google.ConfigFromJSON(credentials, searchconsole.WebmastersScope)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建OAuth配置失败: %v", err)
|
||||
}
|
||||
|
||||
// 尝试从文件读取token
|
||||
token, err := tokenFromFile(config.TokenFile)
|
||||
if err != nil {
|
||||
// 如果没有token,启动web认证流程
|
||||
token = getTokenFromWeb(oauthConfig)
|
||||
saveToken(config.TokenFile, token)
|
||||
}
|
||||
|
||||
client = oauthConfig.Client(ctx, token)
|
||||
}
|
||||
|
||||
// 创建Search Console服务
|
||||
service, err := searchconsole.NewService(ctx, option.WithHTTPClient(client))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建Search Console服务失败: %v", err)
|
||||
}
|
||||
|
||||
return &Client{
|
||||
service: service,
|
||||
SiteURL: config.SiteURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// InspectURL 检查URL索引状态
|
||||
func (c *Client) InspectURL(url string) (*URLInspectionResult, error) {
|
||||
request := &searchconsole.InspectUrlIndexRequest{
|
||||
InspectionUrl: url,
|
||||
SiteUrl: c.SiteURL,
|
||||
LanguageCode: "zh-CN",
|
||||
}
|
||||
|
||||
call := c.service.UrlInspection.Index.Inspect(request)
|
||||
response, err := call.Do()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("检查URL失败: %v", err)
|
||||
}
|
||||
|
||||
// 转换响应
|
||||
result := &URLInspectionResult{}
|
||||
if response.InspectionResult != nil {
|
||||
if response.InspectionResult.IndexStatusResult != nil {
|
||||
result.IndexStatusResult.IndexingState = string(response.InspectionResult.IndexStatusResult.IndexingState)
|
||||
if response.InspectionResult.IndexStatusResult.LastCrawlTime != "" {
|
||||
result.IndexStatusResult.LastCrawled = response.InspectionResult.IndexStatusResult.LastCrawlTime
|
||||
}
|
||||
}
|
||||
|
||||
if response.InspectionResult.MobileUsabilityResult != nil {
|
||||
result.MobileUsabilityResult.MobileFriendly = response.InspectionResult.MobileUsabilityResult.Verdict == "MOBILE_USABILITY_VERdict_PASS"
|
||||
}
|
||||
|
||||
if response.InspectionResult.RichResultsResult != nil && response.InspectionResult.RichResultsResult.Verdict != "RICH_RESULTS_VERdict_PASS" {
|
||||
// 如果有富媒体结果检查信息
|
||||
result.RichResultsResult.Detected.Items = append(result.RichResultsResult.Detected.Items, struct {
|
||||
RichResultType string `json:"richResultType"`
|
||||
}{
|
||||
RichResultType: "UNKNOWN",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// SubmitSitemap 提交网站地图
|
||||
func (c *Client) SubmitSitemap(sitemapURL string) error {
|
||||
call := c.service.Sitemaps.Submit(c.SiteURL, sitemapURL)
|
||||
err := call.Do()
|
||||
if err != nil {
|
||||
return fmt.Errorf("提交网站地图失败: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSites 获取已验证的网站列表
|
||||
func (c *Client) GetSites() ([]*searchconsole.WmxSite, error) {
|
||||
call := c.service.Sites.List()
|
||||
response, err := call.Do()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取网站列表失败: %v", err)
|
||||
}
|
||||
|
||||
return response.SiteEntry, nil
|
||||
}
|
||||
|
||||
// GetSearchAnalytics 获取搜索分析数据
|
||||
func (c *Client) GetSearchAnalytics(startDate, endDate string) (*searchconsole.SearchAnalyticsQueryResponse, error) {
|
||||
request := &searchconsole.SearchAnalyticsQueryRequest{
|
||||
StartDate: startDate,
|
||||
EndDate: endDate,
|
||||
Type: "web",
|
||||
}
|
||||
|
||||
call := c.service.Searchanalytics.Query(c.SiteURL, request)
|
||||
response, err := call.Do()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取搜索分析数据失败: %v", err)
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// getTokenFromWeb 通过web流程获取token
|
||||
func getTokenFromWeb(config *oauth2.Config) *oauth2.Token {
|
||||
authURL := config.AuthCodeURL("state-token", oauth2.AccessTypeOffline)
|
||||
fmt.Printf("请在浏览器中访问以下URL进行认证:\n%s\n", authURL)
|
||||
fmt.Printf("输入授权代码: ")
|
||||
|
||||
var authCode string
|
||||
if _, err := fmt.Scan(&authCode); err != nil {
|
||||
panic(fmt.Sprintf("读取授权代码失败: %v", err))
|
||||
}
|
||||
|
||||
token, err := config.Exchange(oauth2.NoContext, authCode)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("获取token失败: %v", err))
|
||||
}
|
||||
|
||||
return token
|
||||
}
|
||||
|
||||
// tokenFromFile 从文件读取token
|
||||
func tokenFromFile(file string) (*oauth2.Token, error) {
|
||||
f, err := os.Open(file)
|
||||
defer f.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
token := &oauth2.Token{}
|
||||
err = json.NewDecoder(f).Decode(token)
|
||||
return token, err
|
||||
}
|
||||
|
||||
// saveToken 保存token到文件
|
||||
func saveToken(file string, token *oauth2.Token) {
|
||||
fmt.Printf("保存凭证文件到: %s\n", file)
|
||||
f, err := os.OpenFile(file, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("无法保存凭证文件: %v", err))
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
json.NewEncoder(f).Encode(token)
|
||||
}
|
||||
|
||||
// BatchInspectURL 批量检查URL状态
|
||||
func (c *Client) BatchInspectURL(urls []string, callback func(url string, result *URLInspectionResult, err error)) {
|
||||
semaphore := make(chan struct{}, 5) // 限制并发数
|
||||
|
||||
for _, url := range urls {
|
||||
go func(u string) {
|
||||
semaphore <- struct{}{} // 获取信号量
|
||||
defer func() { <-semaphore }() // 释放信号量
|
||||
|
||||
result, err := c.InspectURL(u)
|
||||
callback(u, result, err)
|
||||
}(url)
|
||||
|
||||
// 避免请求过快
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
|
||||
// 等待所有goroutine完成
|
||||
for i := 0; i < cap(semaphore); i++ {
|
||||
semaphore <- struct{}{}
|
||||
}
|
||||
}
|
||||
166
pkg/google/sitemap.go
Normal file
166
pkg/google/sitemap.go
Normal file
@@ -0,0 +1,166 @@
|
||||
package google
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Sitemap 网站地图结构
|
||||
type Sitemap struct {
|
||||
XMLName xml.Name `xml:"urlset"`
|
||||
Xmlns string `xml:"xmlns,attr"`
|
||||
URLs []SitemapURL `xml:"url"`
|
||||
}
|
||||
|
||||
// SitemapURL 网站地图URL项
|
||||
type SitemapURL struct {
|
||||
Loc string `xml:"loc"`
|
||||
LastMod string `xml:"lastmod,omitempty"`
|
||||
ChangeFreq string `xml:"changefreq,omitempty"`
|
||||
Priority string `xml:"priority,omitempty"`
|
||||
}
|
||||
|
||||
// SitemapIndex 网站地图索引
|
||||
type SitemapIndex struct {
|
||||
XMLName xml.Name `xml:"sitemapindex"`
|
||||
Xmlns string `xml:"xmlns,attr"`
|
||||
Sitemaps []SitemapRef `xml:"sitemap"`
|
||||
}
|
||||
|
||||
// SitemapRef 网站地图引用
|
||||
type SitemapRef struct {
|
||||
Loc string `xml:"loc"`
|
||||
LastMod string `xml:"lastmod,omitempty"`
|
||||
}
|
||||
|
||||
// GenerateSitemap 生成网站地图
|
||||
func GenerateSitemap(urls []string, filename string) error {
|
||||
sitemap := Sitemap{
|
||||
Xmlns: "http://www.sitemaps.org/schemas/sitemap/0.9",
|
||||
}
|
||||
|
||||
for _, url := range urls {
|
||||
sitemapURL := SitemapURL{
|
||||
Loc: strings.TrimSpace(url),
|
||||
LastMod: time.Now().Format("2006-01-02"),
|
||||
ChangeFreq: "weekly",
|
||||
Priority: "0.8",
|
||||
}
|
||||
sitemap.URLs = append(sitemap.URLs, sitemapURL)
|
||||
}
|
||||
|
||||
data, err := xml.MarshalIndent(sitemap, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("生成XML失败: %v", err)
|
||||
}
|
||||
|
||||
// 添加XML头部
|
||||
xmlData := []byte(xml.Header + string(data))
|
||||
|
||||
return os.WriteFile(filename, xmlData, 0644)
|
||||
}
|
||||
|
||||
// GenerateSitemapIndex 生成网站地图索引
|
||||
func GenerateSitemapIndex(sitemaps []string, filename string) error {
|
||||
index := SitemapIndex{
|
||||
Xmlns: "http://www.sitemaps.org/schemas/sitemap/0.9",
|
||||
}
|
||||
|
||||
for _, sitemap := range sitemaps {
|
||||
ref := SitemapRef{
|
||||
Loc: strings.TrimSpace(sitemap),
|
||||
LastMod: time.Now().Format("2006-01-02"),
|
||||
}
|
||||
index.Sitemaps = append(index.Sitemaps, ref)
|
||||
}
|
||||
|
||||
data, err := xml.MarshalIndent(index, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("生成XML失败: %v", err)
|
||||
}
|
||||
|
||||
// 添加XML头部
|
||||
xmlData := []byte(xml.Header + string(data))
|
||||
|
||||
return os.WriteFile(filename, xmlData, 0644)
|
||||
}
|
||||
|
||||
// SplitSitemap 将大量URL分割成多个网站地图
|
||||
func SplitSitemap(urls []string, maxURLsPerSitemap int, baseURL string) ([]string, error) {
|
||||
if maxURLsPerSitemap <= 0 {
|
||||
maxURLsPerSitemap = 50000 // Google限制
|
||||
}
|
||||
|
||||
var sitemapFiles []string
|
||||
totalURLs := len(urls)
|
||||
sitemapCount := (totalURLs + maxURLsPerSitemap - 1) / maxURLsPerSitemap
|
||||
|
||||
for i := 0; i < sitemapCount; i++ {
|
||||
start := i * maxURLsPerSitemap
|
||||
end := start + maxURLsPerSitemap
|
||||
if end > totalURLs {
|
||||
end = totalURLs
|
||||
}
|
||||
|
||||
sitemapURLs := urls[start:end]
|
||||
filename := fmt.Sprintf("sitemap_part_%d.xml", i+1)
|
||||
|
||||
err := GenerateSitemap(sitemapURLs, filename)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("生成网站地图 %s 失败: %v", filename, err)
|
||||
}
|
||||
|
||||
sitemapFiles = append(sitemapFiles, baseURL+filename)
|
||||
fmt.Printf("生成网站地图: %s (%d URLs)\n", filename, len(sitemapURLs))
|
||||
}
|
||||
|
||||
// 生成网站地图索引
|
||||
if len(sitemapFiles) > 1 {
|
||||
indexFiles := make([]string, len(sitemapFiles))
|
||||
for i, file := range sitemapFiles {
|
||||
indexFiles[i] = file
|
||||
}
|
||||
|
||||
err := GenerateSitemapIndex(indexFiles, "sitemap_index.xml")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("生成网站地图索引失败: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf("生成网站地图索引: sitemap_index.xml\n")
|
||||
}
|
||||
|
||||
return sitemapFiles, nil
|
||||
}
|
||||
|
||||
// PingSearchEngines 通知搜索引擎网站地图更新
|
||||
func PingSearchEngines(sitemapURL string) error {
|
||||
searchEngines := []string{
|
||||
"http://www.google.com/webmasters/sitemaps/ping?sitemap=",
|
||||
"http://www.bing.com/webmaster/ping.aspx?siteMap=",
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
for _, engine := range searchEngines {
|
||||
fullURL := engine + sitemapURL
|
||||
|
||||
resp, err := client.Get(fullURL)
|
||||
if err != nil {
|
||||
fmt.Printf("通知搜索引擎失败 %s: %v\n", engine, err)
|
||||
continue
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == 200 {
|
||||
fmt.Printf("成功通知: %s\n", engine)
|
||||
} else {
|
||||
fmt.Printf("通知失败: %s (状态码: %d)\n", engine, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
96
scheduler/cache_cleaner.go
Normal file
96
scheduler/cache_cleaner.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"time"
|
||||
"github.com/ctwj/urldb/utils"
|
||||
)
|
||||
|
||||
// CacheCleaner 缓存清理调度器
|
||||
type CacheCleaner struct {
|
||||
baseScheduler *BaseScheduler
|
||||
running bool
|
||||
ticker *time.Ticker
|
||||
stopChan chan bool
|
||||
}
|
||||
|
||||
// NewCacheCleaner 创建缓存清理调度器
|
||||
func NewCacheCleaner(baseScheduler *BaseScheduler) *CacheCleaner {
|
||||
return &CacheCleaner{
|
||||
baseScheduler: baseScheduler,
|
||||
running: false,
|
||||
ticker: time.NewTicker(time.Hour), // 每小时执行一次
|
||||
stopChan: make(chan bool),
|
||||
}
|
||||
}
|
||||
|
||||
// Start 启动缓存清理任务
|
||||
func (cc *CacheCleaner) Start() {
|
||||
if cc.running {
|
||||
utils.Warn("缓存清理任务已在运行中")
|
||||
return
|
||||
}
|
||||
|
||||
cc.running = true
|
||||
utils.Info("启动缓存清理任务")
|
||||
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-cc.ticker.C:
|
||||
cc.cleanCache()
|
||||
case <-cc.stopChan:
|
||||
cc.running = false
|
||||
utils.Info("缓存清理任务已停止")
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Stop 停止缓存清理任务
|
||||
func (cc *CacheCleaner) Stop() {
|
||||
if !cc.running {
|
||||
return
|
||||
}
|
||||
|
||||
close(cc.stopChan)
|
||||
cc.ticker.Stop()
|
||||
}
|
||||
|
||||
// cleanCache 执行缓存清理
|
||||
func (cc *CacheCleaner) cleanCache() {
|
||||
utils.Debug("开始清理过期缓存")
|
||||
|
||||
// 清理过期缓存(1小时TTL)
|
||||
utils.CleanAllExpiredCaches(time.Hour)
|
||||
utils.Debug("定期清理过期缓存完成")
|
||||
|
||||
// 可以在这里添加其他缓存清理逻辑,比如:
|
||||
// - 清理特定模式的缓存
|
||||
// - 记录缓存统计信息
|
||||
cc.logCacheStats()
|
||||
}
|
||||
|
||||
// logCacheStats 记录缓存统计信息
|
||||
func (cc *CacheCleaner) logCacheStats() {
|
||||
hotCacheSize := utils.GetHotResourcesCache().Size()
|
||||
relatedCacheSize := utils.GetRelatedResourcesCache().Size()
|
||||
systemConfigSize := utils.GetSystemConfigCache().Size()
|
||||
categoriesSize := utils.GetCategoriesCache().Size()
|
||||
tagsSize := utils.GetTagsCache().Size()
|
||||
|
||||
totalSize := hotCacheSize + relatedCacheSize + systemConfigSize + categoriesSize + tagsSize
|
||||
|
||||
utils.Debug("缓存统计 - 热门资源: %d, 相关资源: %d, 系统配置: %d, 分类: %d, 标签: %d, 总计: %d",
|
||||
hotCacheSize, relatedCacheSize, systemConfigSize, categoriesSize, tagsSize, totalSize)
|
||||
|
||||
// 如果缓存过多,可以记录警告
|
||||
if totalSize > 1000 {
|
||||
utils.Warn("缓存项数量过多: %d,建议检查缓存策略", totalSize)
|
||||
}
|
||||
}
|
||||
|
||||
// IsRunning 检查是否正在运行
|
||||
func (cc *CacheCleaner) IsRunning() bool {
|
||||
return cc.running
|
||||
}
|
||||
@@ -32,10 +32,10 @@ func GetGlobalMeilisearchManager() *services.MeilisearchManager {
|
||||
}
|
||||
|
||||
// GetGlobalScheduler 获取全局调度器实例(单例模式)
|
||||
func GetGlobalScheduler(hotDramaRepo repo.HotDramaRepository, readyResourceRepo repo.ReadyResourceRepository, resourceRepo repo.ResourceRepository, systemConfigRepo repo.SystemConfigRepository, panRepo repo.PanRepository, cksRepo repo.CksRepository, tagRepo repo.TagRepository, categoryRepo repo.CategoryRepository) *GlobalScheduler {
|
||||
func GetGlobalScheduler(hotDramaRepo repo.HotDramaRepository, readyResourceRepo repo.ReadyResourceRepository, resourceRepo repo.ResourceRepository, systemConfigRepo repo.SystemConfigRepository, panRepo repo.PanRepository, cksRepo repo.CksRepository, tagRepo repo.TagRepository, categoryRepo repo.CategoryRepository, taskItemRepo repo.TaskItemRepository, taskRepo repo.TaskRepository) *GlobalScheduler {
|
||||
once.Do(func() {
|
||||
globalScheduler = &GlobalScheduler{
|
||||
manager: NewManager(hotDramaRepo, readyResourceRepo, resourceRepo, systemConfigRepo, panRepo, cksRepo, tagRepo, categoryRepo),
|
||||
manager: NewManager(hotDramaRepo, readyResourceRepo, resourceRepo, systemConfigRepo, panRepo, cksRepo, tagRepo, categoryRepo, taskItemRepo, taskRepo),
|
||||
}
|
||||
})
|
||||
return globalScheduler
|
||||
@@ -148,3 +148,92 @@ func (gs *GlobalScheduler) UpdateSchedulerStatusWithAutoTransfer(autoFetchHotDra
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// StartSitemapScheduler 启动Sitemap调度任务
|
||||
func (gs *GlobalScheduler) StartSitemapScheduler() {
|
||||
gs.mutex.Lock()
|
||||
defer gs.mutex.Unlock()
|
||||
|
||||
if gs.manager.IsSitemapRunning() {
|
||||
utils.Debug("Sitemap定时任务已在运行中")
|
||||
return
|
||||
}
|
||||
|
||||
gs.manager.StartSitemapScheduler()
|
||||
utils.Debug("全局调度器已启动Sitemap定时任务")
|
||||
}
|
||||
|
||||
// StopSitemapScheduler 停止Sitemap调度任务
|
||||
func (gs *GlobalScheduler) StopSitemapScheduler() {
|
||||
gs.mutex.Lock()
|
||||
defer gs.mutex.Unlock()
|
||||
|
||||
if !gs.manager.IsSitemapRunning() {
|
||||
utils.Debug("Sitemap定时任务未在运行")
|
||||
return
|
||||
}
|
||||
|
||||
gs.manager.StopSitemapScheduler()
|
||||
utils.Debug("全局调度器已停止Sitemap定时任务")
|
||||
}
|
||||
|
||||
// IsSitemapSchedulerRunning 检查Sitemap定时任务是否在运行
|
||||
func (gs *GlobalScheduler) IsSitemapSchedulerRunning() bool {
|
||||
gs.mutex.RLock()
|
||||
defer gs.mutex.RUnlock()
|
||||
return gs.manager.IsSitemapRunning()
|
||||
}
|
||||
|
||||
// UpdateSitemapConfig 更新Sitemap配置
|
||||
func (gs *GlobalScheduler) UpdateSitemapConfig(enabled bool) error {
|
||||
return gs.manager.UpdateSitemapConfig(enabled)
|
||||
}
|
||||
|
||||
// GetSitemapConfig 获取Sitemap配置
|
||||
func (gs *GlobalScheduler) GetSitemapConfig() (bool, error) {
|
||||
return gs.manager.GetSitemapConfig()
|
||||
}
|
||||
|
||||
// TriggerSitemapGeneration 手动触发sitemap生成
|
||||
func (gs *GlobalScheduler) TriggerSitemapGeneration() {
|
||||
gs.mutex.Lock()
|
||||
defer gs.mutex.Unlock()
|
||||
|
||||
gs.manager.TriggerSitemapGeneration()
|
||||
}
|
||||
|
||||
// StartGoogleIndexScheduler 启动Google索引调度任务
|
||||
func (gs *GlobalScheduler) StartGoogleIndexScheduler() {
|
||||
gs.mutex.Lock()
|
||||
defer gs.mutex.Unlock()
|
||||
|
||||
if gs.manager.IsGoogleIndexRunning() {
|
||||
utils.Debug("Google索引调度任务已在运行中")
|
||||
return
|
||||
}
|
||||
|
||||
gs.manager.StartGoogleIndexScheduler()
|
||||
utils.Info("Google索引调度任务已启动")
|
||||
}
|
||||
|
||||
// StopGoogleIndexScheduler 停止Google索引调度任务
|
||||
func (gs *GlobalScheduler) StopGoogleIndexScheduler() {
|
||||
gs.mutex.Lock()
|
||||
defer gs.mutex.Unlock()
|
||||
|
||||
if !gs.manager.IsGoogleIndexRunning() {
|
||||
utils.Debug("Google索引调度任务未在运行")
|
||||
return
|
||||
}
|
||||
|
||||
gs.manager.StopGoogleIndexScheduler()
|
||||
utils.Info("Google索引调度任务已停止")
|
||||
}
|
||||
|
||||
// IsGoogleIndexSchedulerRunning 检查Google索引调度任务是否在运行
|
||||
func (gs *GlobalScheduler) IsGoogleIndexSchedulerRunning() bool {
|
||||
gs.mutex.RLock()
|
||||
defer gs.mutex.RUnlock()
|
||||
|
||||
return gs.manager.IsGoogleIndexRunning()
|
||||
}
|
||||
|
||||
541
scheduler/google_index.go
Normal file
541
scheduler/google_index.go
Normal file
@@ -0,0 +1,541 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ctwj/urldb/db/entity"
|
||||
"github.com/ctwj/urldb/db/repo"
|
||||
"github.com/ctwj/urldb/pkg/google"
|
||||
"github.com/ctwj/urldb/utils"
|
||||
)
|
||||
|
||||
// GoogleIndexScheduler Google索引调度器
|
||||
type GoogleIndexScheduler struct {
|
||||
*BaseScheduler
|
||||
config entity.SystemConfig
|
||||
stopChan chan bool
|
||||
isRunning bool
|
||||
enabled bool
|
||||
checkInterval time.Duration
|
||||
googleClient *google.Client
|
||||
taskItemRepo repo.TaskItemRepository
|
||||
taskRepo repo.TaskRepository
|
||||
|
||||
// 批量处理相关
|
||||
pendingURLResults []*repo.URLStatusResult
|
||||
currentTaskID uint
|
||||
}
|
||||
|
||||
// NewGoogleIndexScheduler 创建Google索引调度器
|
||||
func NewGoogleIndexScheduler(baseScheduler *BaseScheduler, taskItemRepo repo.TaskItemRepository, taskRepo repo.TaskRepository) *GoogleIndexScheduler {
|
||||
return &GoogleIndexScheduler{
|
||||
BaseScheduler: baseScheduler,
|
||||
taskItemRepo: taskItemRepo,
|
||||
taskRepo: taskRepo,
|
||||
stopChan: make(chan bool),
|
||||
isRunning: false,
|
||||
pendingURLResults: make([]*repo.URLStatusResult, 0),
|
||||
}
|
||||
}
|
||||
|
||||
// Start 启动Google索引调度任务
|
||||
func (s *GoogleIndexScheduler) Start() {
|
||||
if s.isRunning {
|
||||
utils.Debug("Google索引调度任务已在运行中")
|
||||
return
|
||||
}
|
||||
|
||||
// 加载配置
|
||||
if err := s.loadConfig(); err != nil {
|
||||
utils.Error("加载Google索引配置失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if !s.enabled {
|
||||
utils.Debug("Google索引功能未启用,跳过调度任务")
|
||||
return
|
||||
}
|
||||
|
||||
s.isRunning = true
|
||||
utils.Info("开始启动Google索引调度任务,检查间隔: %v", s.checkInterval)
|
||||
|
||||
go s.run()
|
||||
}
|
||||
|
||||
// Stop 停止Google索引调度任务
|
||||
func (s *GoogleIndexScheduler) Stop() {
|
||||
if !s.isRunning {
|
||||
return
|
||||
}
|
||||
|
||||
utils.Info("正在停止Google索引调度任务...")
|
||||
s.stopChan <- true
|
||||
s.isRunning = false
|
||||
}
|
||||
|
||||
// IsRunning 检查调度器是否正在运行
|
||||
func (s *GoogleIndexScheduler) IsRunning() bool {
|
||||
return s.isRunning
|
||||
}
|
||||
|
||||
// run 运行调度器主循环
|
||||
func (s *GoogleIndexScheduler) run() {
|
||||
ticker := time.NewTicker(s.checkInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
// 启动时立即执行一次
|
||||
s.performScheduledTasks()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-s.stopChan:
|
||||
utils.Info("Google索引调度任务已停止")
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.performScheduledTasks()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// loadConfig 加载配置
|
||||
func (s *GoogleIndexScheduler) loadConfig() error {
|
||||
// 获取启用状态
|
||||
enabledStr, err := s.systemConfigRepo.GetConfigValue(entity.GoogleIndexConfigKeyEnabled)
|
||||
if err != nil {
|
||||
s.enabled = false
|
||||
} else {
|
||||
s.enabled = enabledStr == "true" || enabledStr == "1"
|
||||
}
|
||||
|
||||
// 获取检查间隔
|
||||
intervalStr, err := s.systemConfigRepo.GetConfigValue(entity.GoogleIndexConfigKeyCheckInterval)
|
||||
if err != nil {
|
||||
s.checkInterval = 60 * time.Minute // 默认60分钟
|
||||
} else {
|
||||
if interval, parseErr := time.ParseDuration(intervalStr + "m"); parseErr == nil {
|
||||
s.checkInterval = interval
|
||||
} else {
|
||||
s.checkInterval = 60 * time.Minute
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化Google客户端
|
||||
if s.enabled {
|
||||
if err := s.initGoogleClient(); err != nil {
|
||||
utils.Error("初始化Google客户端失败: %v", err)
|
||||
s.enabled = false
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// initGoogleClient 初始化Google客户端
|
||||
func (s *GoogleIndexScheduler) initGoogleClient() error {
|
||||
// 获取凭据文件路径
|
||||
credentialsFile, err := s.systemConfigRepo.GetConfigValue(entity.GoogleIndexConfigKeyCredentialsFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取凭据文件路径失败: %v", err)
|
||||
}
|
||||
|
||||
// 获取站点URL,使用通用站点URL配置
|
||||
siteURL, err := s.systemConfigRepo.GetConfigValue(entity.ConfigKeyWebsiteURL)
|
||||
if err != nil || siteURL == "" || siteURL == "https://example.com" {
|
||||
siteURL = "https://pan.l9.lc" // 默认站点URL
|
||||
}
|
||||
|
||||
// 创建Google客户端配置
|
||||
config := &google.Config{
|
||||
CredentialsFile: credentialsFile,
|
||||
SiteURL: siteURL,
|
||||
}
|
||||
|
||||
client, err := google.NewClient(config)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建Google客户端失败: %v", err)
|
||||
}
|
||||
|
||||
s.googleClient = client
|
||||
return nil
|
||||
}
|
||||
|
||||
// performScheduledTasks 执行调度任务
|
||||
func (s *GoogleIndexScheduler) performScheduledTasks() {
|
||||
if !s.enabled {
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// 任务0: 清理旧记录
|
||||
if err := s.taskItemRepo.CleanupOldRecords(); err != nil {
|
||||
utils.Error("清理旧记录失败: %v", err)
|
||||
}
|
||||
|
||||
// 任务1: 定期提交sitemap给Google
|
||||
if err := s.submitSitemapToGoogle(ctx); err != nil {
|
||||
utils.Error("提交sitemap失败: %v", err)
|
||||
}
|
||||
|
||||
// 任务2: 刷新待处理的URL结果
|
||||
s.flushURLResults()
|
||||
|
||||
utils.Debug("Google索引调度任务执行完成")
|
||||
}
|
||||
|
||||
// submitSitemapToGoogle 提交sitemap给Google
|
||||
func (s *GoogleIndexScheduler) submitSitemapToGoogle(ctx context.Context) error {
|
||||
utils.Info("开始提交sitemap给Google...")
|
||||
|
||||
// 获取站点URL构建sitemap URL
|
||||
siteURL, err := s.systemConfigRepo.GetConfigValue(entity.ConfigKeyWebsiteURL)
|
||||
if err != nil || siteURL == "" || siteURL == "https://example.com" {
|
||||
siteURL = "https://pan.l9.lc" // 默认站点URL
|
||||
}
|
||||
|
||||
sitemapURL := siteURL
|
||||
if !strings.HasSuffix(sitemapURL, "/") {
|
||||
sitemapURL += "/"
|
||||
}
|
||||
sitemapURL += "sitemap.xml"
|
||||
|
||||
utils.Info("提交sitemap: %s", sitemapURL)
|
||||
|
||||
// 验证sitemapURL不为空
|
||||
if sitemapURL == "" || sitemapURL == "/sitemap.xml" {
|
||||
return fmt.Errorf("网站地图URL不能为空")
|
||||
}
|
||||
|
||||
// 提交sitemap给Google
|
||||
err = s.googleClient.SubmitSitemap(sitemapURL)
|
||||
if err != nil {
|
||||
utils.Error("提交sitemap失败: %s, 错误: %v", sitemapURL, err)
|
||||
return fmt.Errorf("提交sitemap失败: %v", err)
|
||||
}
|
||||
|
||||
utils.Info("sitemap提交成功: %s", sitemapURL)
|
||||
return nil
|
||||
}
|
||||
|
||||
// scanAndSubmitUnindexedURLs 扫描并提交未索引的URL
|
||||
func (s *GoogleIndexScheduler) scanAndSubmitUnindexedURLs(ctx context.Context) error {
|
||||
utils.Info("开始扫描未索引的URL...")
|
||||
|
||||
// 1. 获取所有资源URL
|
||||
resources, err := s.resourceRepo.GetAllValidResources()
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取资源列表失败: %v", err)
|
||||
}
|
||||
|
||||
// 2. 获取已索引的URL记录
|
||||
indexedURLs, err := s.getIndexedURLs()
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取已索引URL列表失败: %v", err)
|
||||
}
|
||||
|
||||
// 3. 找出未索引的URL
|
||||
var unindexedURLs []string
|
||||
indexedURLSet := make(map[string]bool)
|
||||
for _, url := range indexedURLs {
|
||||
indexedURLSet[url] = true
|
||||
}
|
||||
|
||||
for _, resource := range resources {
|
||||
if resource.IsPublic && resource.IsValid && resource.Key != "" {
|
||||
// 构建本站URL,而不是使用原始的外链URL
|
||||
siteURL, _ := s.systemConfigRepo.GetConfigValue(entity.ConfigKeyWebsiteURL)
|
||||
if siteURL == "" {
|
||||
siteURL = "https://pan.l9.lc" // 默认站点URL
|
||||
}
|
||||
localURL := fmt.Sprintf("%s/r/%s", siteURL, resource.Key)
|
||||
|
||||
if !indexedURLSet[localURL] {
|
||||
unindexedURLs = append(unindexedURLs, localURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
utils.Info("发现 %d 个未索引的URL", len(unindexedURLs))
|
||||
|
||||
// 4. 批量提交未索引的URL
|
||||
if len(unindexedURLs) > 0 {
|
||||
if err := s.batchSubmitURLs(ctx, unindexedURLs); err != nil {
|
||||
return fmt.Errorf("批量提交URL失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getIndexedURLs 获取已索引的URL列表
|
||||
func (s *GoogleIndexScheduler) getIndexedURLs() ([]string, error) {
|
||||
return s.taskItemRepo.GetDistinctProcessedURLs()
|
||||
}
|
||||
|
||||
// batchSubmitURLs 批量提交URL
|
||||
func (s *GoogleIndexScheduler) batchSubmitURLs(ctx context.Context, urls []string) error {
|
||||
utils.Info("开始批量提交 %d 个URL到Google索引...", len(urls))
|
||||
|
||||
// 获取批量大小配置
|
||||
batchSizeStr, _ := s.systemConfigRepo.GetConfigValue(entity.GoogleIndexConfigKeyBatchSize)
|
||||
batchSize := 100 // 默认值
|
||||
if batchSizeStr != "" {
|
||||
if size, err := strconv.Atoi(batchSizeStr); err == nil && size > 0 {
|
||||
batchSize = size
|
||||
}
|
||||
}
|
||||
|
||||
// 获取并发数配置
|
||||
concurrencyStr, _ := s.systemConfigRepo.GetConfigValue(entity.GoogleIndexConfigKeyConcurrency)
|
||||
concurrency := 5 // 默认值
|
||||
if concurrencyStr != "" {
|
||||
if conc, err := strconv.Atoi(concurrencyStr); err == nil && conc > 0 {
|
||||
concurrency = conc
|
||||
}
|
||||
}
|
||||
|
||||
// 分批处理
|
||||
for i := 0; i < len(urls); i += batchSize {
|
||||
end := i + batchSize
|
||||
if end > len(urls) {
|
||||
end = len(urls)
|
||||
}
|
||||
|
||||
batch := urls[i:end]
|
||||
if err := s.processBatch(ctx, batch, concurrency); err != nil {
|
||||
utils.Error("处理批次失败 (批次 %d-%d): %v", i+1, end, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// 避免API限制,批次间稍作延迟
|
||||
time.Sleep(1 * time.Second)
|
||||
}
|
||||
|
||||
utils.Info("批量URL提交完成")
|
||||
return nil
|
||||
}
|
||||
|
||||
// processBatch 处理单个批次
|
||||
func (s *GoogleIndexScheduler) processBatch(ctx context.Context, urls []string, concurrency int) error {
|
||||
semaphore := make(chan struct{}, concurrency)
|
||||
errChan := make(chan error, len(urls))
|
||||
|
||||
for _, url := range urls {
|
||||
go func(u string) {
|
||||
semaphore <- struct{}{}
|
||||
defer func() { <-semaphore }()
|
||||
|
||||
// 检查URL索引状态
|
||||
result, err := s.googleClient.InspectURL(u)
|
||||
if err != nil {
|
||||
utils.Error("检查URL失败: %s, 错误: %v", u, err)
|
||||
errChan <- err
|
||||
return
|
||||
}
|
||||
|
||||
// Google Search Console API 不直接支持URL提交
|
||||
// 这里只记录URL状态,实际的URL索引需要通过sitemap或其他方式
|
||||
if result.IndexStatusResult.IndexingState == "NOT_SUBMITTED" {
|
||||
utils.Debug("URL未提交,需要通过sitemap提交: %s", u)
|
||||
// TODO: 可以考虑将未提交的URL加入到sitemap中
|
||||
}
|
||||
|
||||
// 记录索引状态
|
||||
s.recordURLStatus(u, result)
|
||||
errChan <- nil
|
||||
}(url)
|
||||
}
|
||||
|
||||
// 等待所有goroutine完成
|
||||
for i := 0; i < len(urls); i++ {
|
||||
if err := <-errChan; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkIndexedURLsStatus 检查已索引URL的状态
|
||||
func (s *GoogleIndexScheduler) checkIndexedURLsStatus(ctx context.Context) error {
|
||||
utils.Info("开始检查已索引URL的状态...")
|
||||
|
||||
// 暂时跳过状态检查,因为需要TaskItemRepository访问权限
|
||||
// TODO: 后续通过扩展BaseScheduler来支持TaskItemRepository
|
||||
urlsToCheck := []string{}
|
||||
utils.Info("检查 %d 个已索引URL的状态", len(urlsToCheck))
|
||||
|
||||
// 并发检查状态
|
||||
concurrencyStr, _ := s.systemConfigRepo.GetConfigValue(entity.GoogleIndexConfigKeyConcurrency)
|
||||
concurrency := 3 // 状态检查使用较低并发
|
||||
if concurrencyStr != "" {
|
||||
if conc, err := strconv.Atoi(concurrencyStr); err == nil && conc > 0 {
|
||||
concurrency = conc / 2 // 状态检查并发减半
|
||||
if concurrency < 1 {
|
||||
concurrency = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 由于没有URL需要检查,跳过循环
|
||||
if len(urlsToCheck) == 0 {
|
||||
utils.Info("没有URL需要状态检查")
|
||||
return nil
|
||||
}
|
||||
|
||||
semaphore := make(chan struct{}, concurrency)
|
||||
for _, url := range urlsToCheck {
|
||||
go func(u string) {
|
||||
semaphore <- struct{}{}
|
||||
defer func() { <-semaphore }()
|
||||
|
||||
// 检查URL最新状态
|
||||
result, err := s.googleClient.InspectURL(u)
|
||||
if err != nil {
|
||||
utils.Error("检查URL状态失败: %s, 错误: %v", u, err)
|
||||
return
|
||||
}
|
||||
|
||||
// 记录状态
|
||||
s.recordURLStatus(u, result)
|
||||
}(url)
|
||||
}
|
||||
|
||||
// 等待所有检查完成
|
||||
for i := 0; i < len(urlsToCheck); i++ {
|
||||
<-semaphore
|
||||
}
|
||||
|
||||
utils.Info("索引状态检查完成")
|
||||
return nil
|
||||
}
|
||||
|
||||
// recordURLStatus 记录URL索引状态
|
||||
func (s *GoogleIndexScheduler) recordURLStatus(url string, result *google.URLInspectionResult) {
|
||||
// 构造结果对象
|
||||
urlResult := &repo.URLStatusResult{
|
||||
URL: url,
|
||||
IndexStatus: result.IndexStatusResult.IndexingState,
|
||||
InspectResult: s.formatInspectResult(result),
|
||||
MobileFriendly: s.getMobileFriendly(result),
|
||||
StatusCode: s.getStatusCode(result),
|
||||
LastCrawled: s.parseLastCrawled(result),
|
||||
ErrorMessage: s.getErrorMessage(result),
|
||||
}
|
||||
|
||||
// 暂存到批量处理列表,定期批量写入
|
||||
s.pendingURLResults = append(s.pendingURLResults, urlResult)
|
||||
|
||||
// 达到批量大小时写入数据库
|
||||
if len(s.pendingURLResults) >= 50 {
|
||||
s.flushURLResults()
|
||||
}
|
||||
}
|
||||
|
||||
// updateURLStatus 更新URL状态
|
||||
func (s *GoogleIndexScheduler) updateURLStatus(taskItem *entity.TaskItem, result *google.URLInspectionResult) {
|
||||
// 暂时只记录日志,不保存到数据库
|
||||
// TODO: 后续通过扩展BaseScheduler来支持TaskItemRepository以保存状态
|
||||
utils.Debug("更新URL状态: %s - %s", taskItem.URL, result.IndexStatusResult.IndexingState)
|
||||
}
|
||||
|
||||
// flushURLResults 批量写入URL结果
|
||||
func (s *GoogleIndexScheduler) flushURLResults() {
|
||||
if len(s.pendingURLResults) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// 如果没有当前任务,创建一个汇总任务
|
||||
if s.currentTaskID == 0 {
|
||||
task := &entity.Task{
|
||||
Title: fmt.Sprintf("自动索引检查 - %s", time.Now().Format("2006-01-02 15:04:05")),
|
||||
Type: entity.TaskTypeGoogleIndex,
|
||||
Status: entity.TaskStatusCompleted,
|
||||
Description: fmt.Sprintf("自动检查并更新 %d 个URL的索引状态", len(s.pendingURLResults)),
|
||||
TotalItems: len(s.pendingURLResults),
|
||||
Progress: 100.0,
|
||||
}
|
||||
|
||||
if err := s.taskRepo.Create(task); err != nil {
|
||||
utils.Error("创建汇总任务失败: %v", err)
|
||||
return
|
||||
}
|
||||
s.currentTaskID = task.ID
|
||||
}
|
||||
|
||||
// 批量写入URL状态
|
||||
if err := s.taskItemRepo.UpsertURLStatusRecords(s.currentTaskID, s.pendingURLResults); err != nil {
|
||||
utils.Error("批量写入URL状态失败: %v", err)
|
||||
} else {
|
||||
utils.Info("批量写入URL状态成功: %d 个", len(s.pendingURLResults))
|
||||
}
|
||||
|
||||
// 清空待处理列表
|
||||
s.pendingURLResults = s.pendingURLResults[:0]
|
||||
}
|
||||
|
||||
// 辅助方法:格式化检查结果
|
||||
func (s *GoogleIndexScheduler) formatInspectResult(result *google.URLInspectionResult) string {
|
||||
if result == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
data, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("Error: %v", err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
// 辅助方法:获取移动友好状态
|
||||
func (s *GoogleIndexScheduler) getMobileFriendly(result *google.URLInspectionResult) bool {
|
||||
if result != nil {
|
||||
return result.MobileUsabilityResult.MobileFriendly
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 辅助方法:获取状态码
|
||||
func (s *GoogleIndexScheduler) getStatusCode(result *google.URLInspectionResult) int {
|
||||
if result != nil {
|
||||
// 这里可以根据实际的Google API响应结构来获取状态码
|
||||
// 暂时返回200表示成功
|
||||
return 200
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// 辅助方法:解析最后抓取时间
|
||||
func (s *GoogleIndexScheduler) parseLastCrawled(result *google.URLInspectionResult) *time.Time {
|
||||
if result != nil && result.IndexStatusResult.LastCrawled != "" {
|
||||
// 这里需要根据实际的Google API响应结构来解析时间
|
||||
// 暂时返回当前时间
|
||||
now := time.Now()
|
||||
return &now
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 辅助方法:获取错误信息
|
||||
func (s *GoogleIndexScheduler) getErrorMessage(result *google.URLInspectionResult) string {
|
||||
if result != nil {
|
||||
// 根据索引状态判断是否有错误
|
||||
if result.IndexStatusResult.IndexingState == "ERROR" {
|
||||
return "索引状态错误"
|
||||
}
|
||||
if result.IndexStatusResult.IndexingState == "NOT_FOUND" {
|
||||
return "页面未找到"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// SetRunning 设置运行状态
|
||||
func (s *GoogleIndexScheduler) SetRunning(running bool) {
|
||||
s.isRunning = running
|
||||
}
|
||||
@@ -10,6 +10,8 @@ type Manager struct {
|
||||
baseScheduler *BaseScheduler
|
||||
hotDramaScheduler *HotDramaScheduler
|
||||
readyResourceScheduler *ReadyResourceScheduler
|
||||
sitemapScheduler *SitemapScheduler
|
||||
googleIndexScheduler *GoogleIndexScheduler
|
||||
}
|
||||
|
||||
// NewManager 创建调度器管理器
|
||||
@@ -22,6 +24,8 @@ func NewManager(
|
||||
cksRepo repo.CksRepository,
|
||||
tagRepo repo.TagRepository,
|
||||
categoryRepo repo.CategoryRepository,
|
||||
taskItemRepo repo.TaskItemRepository,
|
||||
taskRepo repo.TaskRepository,
|
||||
) *Manager {
|
||||
// 创建基础调度器
|
||||
baseScheduler := NewBaseScheduler(
|
||||
@@ -38,11 +42,15 @@ func NewManager(
|
||||
// 创建各个具体的调度器
|
||||
hotDramaScheduler := NewHotDramaScheduler(baseScheduler)
|
||||
readyResourceScheduler := NewReadyResourceScheduler(baseScheduler)
|
||||
sitemapScheduler := NewSitemapScheduler(baseScheduler)
|
||||
googleIndexScheduler := NewGoogleIndexScheduler(baseScheduler, taskItemRepo, taskRepo)
|
||||
|
||||
return &Manager{
|
||||
baseScheduler: baseScheduler,
|
||||
hotDramaScheduler: hotDramaScheduler,
|
||||
readyResourceScheduler: readyResourceScheduler,
|
||||
sitemapScheduler: sitemapScheduler,
|
||||
googleIndexScheduler: googleIndexScheduler,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +64,9 @@ func (m *Manager) StartAll() {
|
||||
// 启动待处理资源调度任务
|
||||
m.readyResourceScheduler.Start()
|
||||
|
||||
// 启动Google索引调度任务
|
||||
m.googleIndexScheduler.Start()
|
||||
|
||||
utils.Debug("所有调度任务已启动")
|
||||
}
|
||||
|
||||
@@ -69,6 +80,9 @@ func (m *Manager) StopAll() {
|
||||
// 停止待处理资源调度任务
|
||||
m.readyResourceScheduler.Stop()
|
||||
|
||||
// 停止Google索引调度任务
|
||||
m.googleIndexScheduler.Stop()
|
||||
|
||||
utils.Debug("所有调度任务已停止")
|
||||
}
|
||||
|
||||
@@ -107,10 +121,57 @@ func (m *Manager) GetHotDramaNames() ([]string, error) {
|
||||
return m.hotDramaScheduler.GetHotDramaNames()
|
||||
}
|
||||
|
||||
// StartSitemapScheduler 启动Sitemap调度任务
|
||||
func (m *Manager) StartSitemapScheduler() {
|
||||
m.sitemapScheduler.Start()
|
||||
}
|
||||
|
||||
// StopSitemapScheduler 停止Sitemap调度任务
|
||||
func (m *Manager) StopSitemapScheduler() {
|
||||
m.sitemapScheduler.Stop()
|
||||
}
|
||||
|
||||
// IsSitemapRunning 检查Sitemap调度任务是否在运行
|
||||
func (m *Manager) IsSitemapRunning() bool {
|
||||
return m.sitemapScheduler.IsRunning()
|
||||
}
|
||||
|
||||
// GetSitemapConfig 获取Sitemap配置
|
||||
func (m *Manager) GetSitemapConfig() (bool, error) {
|
||||
return m.sitemapScheduler.GetSitemapConfig()
|
||||
}
|
||||
|
||||
// UpdateSitemapConfig 更新Sitemap配置
|
||||
func (m *Manager) UpdateSitemapConfig(enabled bool) error {
|
||||
return m.sitemapScheduler.UpdateSitemapConfig(enabled)
|
||||
}
|
||||
|
||||
// TriggerSitemapGeneration 手动触发sitemap生成
|
||||
func (m *Manager) TriggerSitemapGeneration() {
|
||||
go m.sitemapScheduler.generateSitemap()
|
||||
}
|
||||
|
||||
// StartGoogleIndexScheduler 启动Google索引调度任务
|
||||
func (m *Manager) StartGoogleIndexScheduler() {
|
||||
m.googleIndexScheduler.Start()
|
||||
}
|
||||
|
||||
// StopGoogleIndexScheduler 停止Google索引调度任务
|
||||
func (m *Manager) StopGoogleIndexScheduler() {
|
||||
m.googleIndexScheduler.Stop()
|
||||
}
|
||||
|
||||
// IsGoogleIndexRunning 检查Google索引调度任务是否在运行
|
||||
func (m *Manager) IsGoogleIndexRunning() bool {
|
||||
return m.googleIndexScheduler.IsRunning()
|
||||
}
|
||||
|
||||
// GetStatus 获取所有调度任务的状态
|
||||
func (m *Manager) GetStatus() map[string]bool {
|
||||
return map[string]bool{
|
||||
"hot_drama": m.IsHotDramaRunning(),
|
||||
"ready_resource": m.IsReadyResourceRunning(),
|
||||
"sitemap": m.IsSitemapRunning(),
|
||||
"google_index": m.IsGoogleIndexRunning(),
|
||||
}
|
||||
}
|
||||
|
||||
308
scheduler/sitemap.go
Normal file
308
scheduler/sitemap.go
Normal file
@@ -0,0 +1,308 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ctwj/urldb/db/entity"
|
||||
"github.com/ctwj/urldb/utils"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
SITEMAP_MAX_URLS = 50000 // 每个sitemap最多5万个URL
|
||||
SITEMAP_DIR = "./data/sitemap" // sitemap文件目录
|
||||
)
|
||||
|
||||
// SitemapScheduler Sitemap调度器
|
||||
type SitemapScheduler struct {
|
||||
*BaseScheduler
|
||||
sitemapConfig entity.SystemConfig
|
||||
stopChan chan bool
|
||||
isRunning bool
|
||||
}
|
||||
|
||||
// NewSitemapScheduler 创建Sitemap调度器
|
||||
func NewSitemapScheduler(baseScheduler *BaseScheduler) *SitemapScheduler {
|
||||
return &SitemapScheduler{
|
||||
BaseScheduler: baseScheduler,
|
||||
stopChan: make(chan bool),
|
||||
isRunning: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Start 启动Sitemap调度任务
|
||||
func (s *SitemapScheduler) Start() {
|
||||
if s.IsRunning() {
|
||||
utils.Debug("Sitemap定时任务已在运行中")
|
||||
return
|
||||
}
|
||||
|
||||
s.SetRunning(true)
|
||||
utils.Info("开始启动Sitemap定时任务")
|
||||
|
||||
go s.run()
|
||||
}
|
||||
|
||||
// Stop 停止Sitemap调度任务
|
||||
func (s *SitemapScheduler) Stop() {
|
||||
if !s.IsRunning() {
|
||||
utils.Debug("Sitemap定时任务未在运行")
|
||||
return
|
||||
}
|
||||
|
||||
utils.Info("正在停止Sitemap定时任务")
|
||||
s.stopChan <- true
|
||||
s.SetRunning(false)
|
||||
}
|
||||
|
||||
// IsRunning 检查Sitemap调度任务是否在运行
|
||||
func (s *SitemapScheduler) IsRunning() bool {
|
||||
return s.isRunning
|
||||
}
|
||||
|
||||
// SetRunning 设置运行状态
|
||||
func (s *SitemapScheduler) SetRunning(running bool) {
|
||||
s.isRunning = running
|
||||
}
|
||||
|
||||
// GetStopChan 获取停止通道
|
||||
func (s *SitemapScheduler) GetStopChan() chan bool {
|
||||
return s.stopChan
|
||||
}
|
||||
|
||||
// run 执行调度任务的主循环
|
||||
func (s *SitemapScheduler) run() {
|
||||
utils.Info("Sitemap定时任务开始运行")
|
||||
|
||||
// 立即执行一次
|
||||
s.generateSitemap()
|
||||
|
||||
// 定时执行
|
||||
ticker := time.NewTicker(24 * time.Hour) // 每24小时执行一次
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
utils.Info("定时执行Sitemap生成任务")
|
||||
s.generateSitemap()
|
||||
case <-s.stopChan:
|
||||
utils.Info("收到停止信号,Sitemap调度任务退出")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// generateSitemap 生成sitemap
|
||||
func (s *SitemapScheduler) generateSitemap() {
|
||||
utils.Info("开始生成Sitemap...")
|
||||
|
||||
startTime := time.Now()
|
||||
|
||||
// 获取资源总数
|
||||
var total int64
|
||||
if err := s.BaseScheduler.resourceRepo.GetDB().Model(&entity.Resource{}).Count(&total).Error; err != nil {
|
||||
utils.Error("获取资源总数失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
utils.Info("需要处理的资源总数: %d", total)
|
||||
|
||||
if total == 0 {
|
||||
utils.Info("没有资源需要生成Sitemap")
|
||||
return
|
||||
}
|
||||
|
||||
// 计算需要多少个sitemap文件
|
||||
totalPages := int((total + SITEMAP_MAX_URLS - 1) / SITEMAP_MAX_URLS)
|
||||
utils.Info("需要生成 %d 个sitemap文件", totalPages)
|
||||
|
||||
// 确保目录存在
|
||||
if err := os.MkdirAll(SITEMAP_DIR, 0755); err != nil {
|
||||
utils.Error("创建sitemap目录失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 生成每个sitemap文件
|
||||
for page := 0; page < totalPages; page++ {
|
||||
if s.SleepWithStopCheck(100 * time.Millisecond) { // 避免过于频繁的检查
|
||||
utils.Info("在生成sitemap过程中收到停止信号,退出生成")
|
||||
return
|
||||
}
|
||||
|
||||
utils.Info("正在生成第 %d 个sitemap文件", page+1)
|
||||
|
||||
if err := s.generateSitemapPage(page); err != nil {
|
||||
utils.Error("生成第 %d 个sitemap文件失败: %v", page, err)
|
||||
} else {
|
||||
utils.Info("成功生成第 %d 个sitemap文件", page+1)
|
||||
}
|
||||
}
|
||||
|
||||
// 生成sitemap索引文件
|
||||
if err := s.generateSitemapIndex(totalPages); err != nil {
|
||||
utils.Error("生成sitemap索引文件失败: %v", err)
|
||||
} else {
|
||||
utils.Info("成功生成sitemap索引文件")
|
||||
}
|
||||
|
||||
// 尝试获取网站基础URL
|
||||
baseURL, err := s.BaseScheduler.systemConfigRepo.GetConfigValue(entity.ConfigKeyWebsiteURL)
|
||||
if err != nil || baseURL == "" {
|
||||
baseURL = "https://yoursite.com" // 默认值
|
||||
}
|
||||
|
||||
utils.Info("Sitemap生成完成,耗时: %v", time.Since(startTime))
|
||||
utils.Info("Sitemap地址: %s/sitemap.xml", baseURL)
|
||||
}
|
||||
|
||||
// generateSitemapPage 生成单个sitemap页面
|
||||
func (s *SitemapScheduler) generateSitemapPage(page int) error {
|
||||
offset := page * SITEMAP_MAX_URLS
|
||||
limit := SITEMAP_MAX_URLS
|
||||
|
||||
var resources []entity.Resource
|
||||
if err := s.BaseScheduler.resourceRepo.GetDB().Offset(offset).Limit(limit).Find(&resources).Error; err != nil {
|
||||
return fmt.Errorf("获取资源数据失败: %w", err)
|
||||
}
|
||||
|
||||
var urls []Url
|
||||
for _, resource := range resources {
|
||||
lastMod := resource.UpdatedAt
|
||||
if resource.CreatedAt.After(lastMod) {
|
||||
lastMod = resource.CreatedAt
|
||||
}
|
||||
|
||||
urls = append(urls, Url{
|
||||
Loc: fmt.Sprintf("/r/%s", resource.Key),
|
||||
LastMod: lastMod.Format("2006-01-02"), // 只保留日期部分
|
||||
ChangeFreq: "weekly",
|
||||
Priority: 0.8,
|
||||
})
|
||||
}
|
||||
|
||||
urlSet := UrlSet{
|
||||
XMLNS: "http://www.sitemaps.org/schemas/sitemap/0.9",
|
||||
URLs: urls,
|
||||
}
|
||||
|
||||
filename := filepath.Join(SITEMAP_DIR, fmt.Sprintf("sitemap-%d.xml", page))
|
||||
file, err := os.Create(filename)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建文件失败: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
file.WriteString(xml.Header)
|
||||
encoder := xml.NewEncoder(file)
|
||||
encoder.Indent("", " ")
|
||||
if err := encoder.Encode(urlSet); err != nil {
|
||||
return fmt.Errorf("写入XML失败: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateSitemapIndex 生成sitemap索引文件
|
||||
func (s *SitemapScheduler) generateSitemapIndex(totalPages int) error {
|
||||
// 构建主机URL - 这里使用默认URL,实际应用中应从配置获取
|
||||
baseURL, err := s.BaseScheduler.systemConfigRepo.GetConfigValue(entity.ConfigKeyWebsiteURL)
|
||||
if err != nil || baseURL == "" {
|
||||
baseURL = "https://yoursite.com" // 默认值
|
||||
}
|
||||
|
||||
// 移除URL末尾的斜杠
|
||||
baseURL = strings.TrimSuffix(baseURL, "/")
|
||||
|
||||
var sitemaps []Sitemap
|
||||
for i := 0; i < totalPages; i++ {
|
||||
sitemapURL := fmt.Sprintf("%s/sitemap-%d.xml", baseURL, i)
|
||||
sitemaps = append(sitemaps, Sitemap{
|
||||
Loc: sitemapURL,
|
||||
LastMod: time.Now().Format("2006-01-02"),
|
||||
})
|
||||
}
|
||||
|
||||
sitemapIndex := SitemapIndex{
|
||||
XMLNS: "http://www.sitemaps.org/schemas/sitemap/0.9",
|
||||
Sitemaps: sitemaps,
|
||||
}
|
||||
|
||||
filename := filepath.Join(SITEMAP_DIR, "sitemap.xml")
|
||||
file, err := os.Create(filename)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建索引文件失败: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
file.WriteString(xml.Header)
|
||||
encoder := xml.NewEncoder(file)
|
||||
encoder.Indent("", " ")
|
||||
if err := encoder.Encode(sitemapIndex); err != nil {
|
||||
return fmt.Errorf("写入索引XML失败: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSitemapConfig 获取Sitemap配置
|
||||
func (s *SitemapScheduler) GetSitemapConfig() (bool, error) {
|
||||
configStr, err := s.BaseScheduler.systemConfigRepo.GetConfigValue(entity.ConfigKeySitemapConfig)
|
||||
if err != nil && err != gorm.ErrRecordNotFound {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// 解析配置字符串,这里简化处理
|
||||
return configStr == "1" || configStr == "true", nil
|
||||
}
|
||||
|
||||
// UpdateSitemapConfig 更新Sitemap配置
|
||||
func (s *SitemapScheduler) UpdateSitemapConfig(enabled bool) error {
|
||||
configStr := "0"
|
||||
if enabled {
|
||||
configStr = "1"
|
||||
}
|
||||
|
||||
config := entity.SystemConfig{
|
||||
Key: entity.ConfigKeySitemapConfig,
|
||||
Value: configStr,
|
||||
Type: "bool",
|
||||
}
|
||||
|
||||
// 由于repository没有直接的SetConfig方法,我们使用UpsertConfigs
|
||||
configs := []entity.SystemConfig{config}
|
||||
return s.BaseScheduler.systemConfigRepo.UpsertConfigs(configs)
|
||||
}
|
||||
|
||||
// UrlSet sitemap内容
|
||||
type UrlSet struct {
|
||||
XMLName xml.Name `xml:"urlset"`
|
||||
XMLNS string `xml:"xmlns,attr"`
|
||||
URLs []Url `xml:"url"`
|
||||
}
|
||||
|
||||
// Url 单个URL信息
|
||||
type Url struct {
|
||||
Loc string `xml:"loc"`
|
||||
LastMod string `xml:"lastmod"`
|
||||
ChangeFreq string `xml:"changefreq"`
|
||||
Priority float64 `xml:"priority"`
|
||||
}
|
||||
|
||||
// SitemapIndex sitemap索引结构
|
||||
type SitemapIndex struct {
|
||||
XMLName xml.Name `xml:"sitemapindex"`
|
||||
XMLNS string `xml:"xmlns,attr"`
|
||||
Sitemaps []Sitemap `xml:"sitemap"`
|
||||
}
|
||||
|
||||
// Sitemap 单个sitemap信息
|
||||
type Sitemap struct {
|
||||
Loc string `xml:"loc"`
|
||||
LastMod string `xml:"lastmod"`
|
||||
}
|
||||
@@ -39,6 +39,7 @@ type TelegramBotService interface {
|
||||
IsChannelRegistered(chatID int64) bool
|
||||
HandleWebhookUpdate(c interface{})
|
||||
CleanupDuplicateChannels() error
|
||||
ManualPushToChannel(channelID uint) error
|
||||
}
|
||||
|
||||
type TelegramBotServiceImpl struct {
|
||||
@@ -167,11 +168,18 @@ func (s *TelegramBotServiceImpl) loadConfig() error {
|
||||
|
||||
// Start 启动机器人服务
|
||||
func (s *TelegramBotServiceImpl) Start() error {
|
||||
if s.isRunning {
|
||||
// 确保机器人完全停止状态
|
||||
if s.isRunning && s.bot != nil {
|
||||
utils.Info("[TELEGRAM:SERVICE] Telegram Bot 服务已经在运行中")
|
||||
return nil
|
||||
}
|
||||
|
||||
// 如果isRunning为true但bot为nil,说明状态不一致,需要清理
|
||||
if s.isRunning && s.bot == nil {
|
||||
utils.Info("[TELEGRAM:SERVICE] 检测到不一致状态,清理残留资源")
|
||||
s.isRunning = false
|
||||
}
|
||||
|
||||
// 加载配置
|
||||
if err := s.loadConfig(); err != nil {
|
||||
return fmt.Errorf("加载配置失败: %v", err)
|
||||
@@ -289,6 +297,8 @@ func (s *TelegramBotServiceImpl) Stop() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
utils.Info("[TELEGRAM:SERVICE] 开始停止 Telegram Bot 服务")
|
||||
|
||||
s.isRunning = false
|
||||
|
||||
// 安全地发送停止信号给消息循环
|
||||
@@ -304,6 +314,9 @@ func (s *TelegramBotServiceImpl) Stop() error {
|
||||
s.cronScheduler.Stop()
|
||||
}
|
||||
|
||||
// 清理机器人实例以避免冲突
|
||||
s.bot = nil
|
||||
|
||||
utils.Info("[TELEGRAM:SERVICE] Telegram Bot 服务已停止")
|
||||
return nil
|
||||
}
|
||||
@@ -524,6 +537,12 @@ func (s *TelegramBotServiceImpl) setupWebhook() error {
|
||||
func (s *TelegramBotServiceImpl) messageLoop() {
|
||||
utils.Info("[TELEGRAM:MESSAGE] 开始监听 Telegram 消息更新...")
|
||||
|
||||
// 确保机器人实例存在
|
||||
if s.bot == nil {
|
||||
utils.Error("[TELEGRAM:MESSAGE] 机器人实例为空,无法启动消息监听循环")
|
||||
return
|
||||
}
|
||||
|
||||
u := tgbotapi.NewUpdate(0)
|
||||
u.Timeout = 60
|
||||
|
||||
@@ -541,6 +560,11 @@ func (s *TelegramBotServiceImpl) messageLoop() {
|
||||
utils.Info("[TELEGRAM:MESSAGE] updates channel 已关闭,退出消息监听循环")
|
||||
return
|
||||
}
|
||||
// 在处理消息前检查机器人是否仍在运行
|
||||
if !s.isRunning || s.bot == nil {
|
||||
utils.Info("[TELEGRAM:MESSAGE] 机器人已停止,忽略接收到的消息")
|
||||
return
|
||||
}
|
||||
if update.Message != nil {
|
||||
utils.Info("[TELEGRAM:MESSAGE] 接收到新消息更新")
|
||||
s.handleMessage(update.Message)
|
||||
@@ -1049,6 +1073,10 @@ func (s *TelegramBotServiceImpl) findResourcesForChannel(channel entity.Telegram
|
||||
func (s *TelegramBotServiceImpl) findLatestResources(channel entity.TelegramChannel, excludeResourceIDs []uint) []interface{} {
|
||||
params := s.buildFilterParams(channel)
|
||||
|
||||
// 添加按创建时间倒序的排序参数,确保获取最新资源
|
||||
params["order_by"] = "created_at"
|
||||
params["order_dir"] = "DESC"
|
||||
|
||||
// 在数据库查询中排除已推送的资源
|
||||
if len(excludeResourceIDs) > 0 {
|
||||
params["exclude_ids"] = excludeResourceIDs
|
||||
@@ -1307,15 +1335,23 @@ func (s *TelegramBotServiceImpl) SendMessage(chatID int64, text string, img stri
|
||||
} else {
|
||||
// 如果 img 以 http 开头,则为图片URL,否则为文件remote_id
|
||||
if strings.HasPrefix(img, "http") {
|
||||
// 发送图片URL
|
||||
photoMsg := tgbotapi.NewPhoto(chatID, tgbotapi.FileURL(img))
|
||||
photoMsg.Caption = text
|
||||
photoMsg.ParseMode = "HTML"
|
||||
_, err := s.bot.Send(photoMsg)
|
||||
if err != nil {
|
||||
utils.Error("[TELEGRAM:MESSAGE:ERROR] 发送图片消息失败: %v", err)
|
||||
// 发送图片URL前先验证URL是否可访问并返回有效的图片格式
|
||||
if s.isValidImageURL(img) {
|
||||
photoMsg := tgbotapi.NewPhoto(chatID, tgbotapi.FileURL(img))
|
||||
photoMsg.Caption = text
|
||||
photoMsg.ParseMode = "HTML"
|
||||
_, err := s.bot.Send(photoMsg)
|
||||
if err != nil {
|
||||
utils.Error("[TELEGRAM:MESSAGE:ERROR] 发送图片消息失败: %v", err)
|
||||
// 如果URL方式失败,尝试将URL作为普通文本发送
|
||||
return s.sendTextMessage(chatID, text)
|
||||
}
|
||||
return err
|
||||
} else {
|
||||
utils.Warn("[TELEGRAM:MESSAGE:WARNING] 图片URL无效,仅发送文本消息: %s", img)
|
||||
// URL无效时只发送文本消息
|
||||
return s.sendTextMessage(chatID, text)
|
||||
}
|
||||
return err
|
||||
} else {
|
||||
// imgUrl := s.GetImgUrl(img)
|
||||
//todo 判断 imgUrl 是否可用
|
||||
@@ -1326,12 +1362,85 @@ func (s *TelegramBotServiceImpl) SendMessage(chatID int64, text string, img stri
|
||||
_, err := s.bot.Send(photoMsg)
|
||||
if err != nil {
|
||||
utils.Error("[TELEGRAM:MESSAGE:ERROR] 发送图片消息失败: %v", err)
|
||||
// 如果文件ID方式失败,尝试将URL作为普通文本发送
|
||||
return s.sendTextMessage(chatID, text)
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// isValidImageURL 验证图片URL是否有效
|
||||
func (s *TelegramBotServiceImpl) isValidImageURL(imageURL string) bool {
|
||||
client := &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
// 如果配置了代理,设置代理
|
||||
if s.config.ProxyEnabled && s.config.ProxyHost != "" {
|
||||
var proxyClient *http.Client
|
||||
if s.config.ProxyType == "socks5" {
|
||||
auth := &proxy.Auth{}
|
||||
if s.config.ProxyUsername != "" {
|
||||
auth.User = s.config.ProxyUsername
|
||||
auth.Password = s.config.ProxyPassword
|
||||
}
|
||||
dialer, proxyErr := proxy.SOCKS5("tcp", fmt.Sprintf("%s:%d", s.config.ProxyHost, s.config.ProxyPort), auth, proxy.Direct)
|
||||
if proxyErr != nil {
|
||||
utils.Warn("[TELEGRAM:IMAGE] 代理配置错误: %v", proxyErr)
|
||||
return false
|
||||
}
|
||||
proxyClient = &http.Client{
|
||||
Transport: &http.Transport{
|
||||
Dial: dialer.Dial,
|
||||
},
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
} else {
|
||||
proxyURL := &url.URL{
|
||||
Scheme: s.config.ProxyType,
|
||||
Host: fmt.Sprintf("%s:%d", s.config.ProxyHost, s.config.ProxyPort),
|
||||
}
|
||||
if s.config.ProxyUsername != "" {
|
||||
proxyURL.User = url.UserPassword(s.config.ProxyUsername, s.config.ProxyPassword)
|
||||
}
|
||||
proxyClient = &http.Client{
|
||||
Transport: &http.Transport{
|
||||
Proxy: http.ProxyURL(proxyURL),
|
||||
},
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
}
|
||||
client = proxyClient
|
||||
}
|
||||
|
||||
resp, err := client.Head(imageURL)
|
||||
if err != nil {
|
||||
utils.Warn("[TELEGRAM:IMAGE] 检查图片URL失败: %v, URL: %s", err, imageURL)
|
||||
return false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 检查Content-Type是否为图片格式
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
isImage := strings.HasPrefix(contentType, "image/")
|
||||
if !isImage {
|
||||
utils.Warn("[TELEGRAM:IMAGE] URL不是图片格式: %s, Content-Type: %s", imageURL, contentType)
|
||||
}
|
||||
return isImage
|
||||
}
|
||||
|
||||
// sendTextMessage 仅发送文本消息的辅助方法
|
||||
func (s *TelegramBotServiceImpl) sendTextMessage(chatID int64, text string) error {
|
||||
msg := tgbotapi.NewMessage(chatID, text)
|
||||
msg.ParseMode = "HTML"
|
||||
_, err := s.bot.Send(msg)
|
||||
if err != nil {
|
||||
utils.Error("[TELEGRAM:MESSAGE:ERROR] 发送文本消息失败: %v", err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteMessage 删除消息
|
||||
func (s *TelegramBotServiceImpl) DeleteMessage(chatID int64, messageID int) error {
|
||||
if s.bot == nil {
|
||||
@@ -1994,3 +2103,25 @@ func (s *TelegramBotServiceImpl) isChannelInPushTimeRange(channel entity.Telegra
|
||||
return currentTime >= startTime || currentTime <= endTime
|
||||
}
|
||||
}
|
||||
|
||||
// ManualPushToChannel 手动推送内容到指定频道
|
||||
func (s *TelegramBotServiceImpl) ManualPushToChannel(channelID uint) error {
|
||||
// 获取指定频道信息
|
||||
channel, err := s.channelRepo.FindByID(channelID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("找不到指定的频道: %v", err)
|
||||
}
|
||||
|
||||
utils.Info("[TELEGRAM:MANUAL_PUSH] 开始手动推送到频道: %s (ID: %d)", channel.ChatName, channel.ChatID)
|
||||
|
||||
// 检查频道是否启用推送
|
||||
if !channel.PushEnabled {
|
||||
return fmt.Errorf("频道 %s 未启用推送功能", channel.ChatName)
|
||||
}
|
||||
|
||||
// 推送内容到频道,使用频道配置的策略
|
||||
s.pushToChannel(*channel)
|
||||
|
||||
utils.Info("[TELEGRAM:MANUAL_PUSH] 手动推送请求已提交: %s (ID: %d)", channel.ChatName, channel.ChatID)
|
||||
return nil
|
||||
}
|
||||
|
||||
404
task/google_index_processor.go
Normal file
404
task/google_index_processor.go
Normal file
@@ -0,0 +1,404 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/ctwj/urldb/db/entity"
|
||||
"github.com/ctwj/urldb/db/repo"
|
||||
"github.com/ctwj/urldb/pkg/google"
|
||||
"github.com/ctwj/urldb/utils"
|
||||
)
|
||||
|
||||
// GoogleIndexProcessor Google索引任务处理器
|
||||
type GoogleIndexProcessor struct {
|
||||
repoMgr *repo.RepositoryManager
|
||||
client *google.Client
|
||||
config *GoogleIndexProcessorConfig
|
||||
}
|
||||
|
||||
// GoogleIndexProcessorConfig Google索引处理器配置
|
||||
type GoogleIndexProcessorConfig struct {
|
||||
CredentialsFile string
|
||||
SiteURL string
|
||||
TokenFile string
|
||||
Concurrency int
|
||||
RetryAttempts int
|
||||
RetryDelay time.Duration
|
||||
}
|
||||
|
||||
// GoogleIndexTaskInput Google索引任务输入数据结构
|
||||
type GoogleIndexTaskInput struct {
|
||||
URLs []string `json:"urls"`
|
||||
Operation string `json:"operation"` // indexing_check, sitemap_submit, batch_index
|
||||
SitemapURL string `json:"sitemap_url,omitempty"`
|
||||
}
|
||||
|
||||
// GoogleIndexTaskOutput Google索引任务输出数据结构
|
||||
type GoogleIndexTaskOutput struct {
|
||||
URL string `json:"url,omitempty"`
|
||||
IndexStatus string `json:"index_status,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
Time string `json:"time"`
|
||||
Result *google.URLInspectionResult `json:"result,omitempty"`
|
||||
}
|
||||
|
||||
// NewGoogleIndexProcessor 创建Google索引任务处理器
|
||||
func NewGoogleIndexProcessor(repoMgr *repo.RepositoryManager) *GoogleIndexProcessor {
|
||||
return &GoogleIndexProcessor{
|
||||
repoMgr: repoMgr,
|
||||
config: &GoogleIndexProcessorConfig{
|
||||
RetryAttempts: 3,
|
||||
RetryDelay: 2 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GetTaskType 获取任务类型
|
||||
func (gip *GoogleIndexProcessor) GetTaskType() string {
|
||||
return "google_index"
|
||||
}
|
||||
|
||||
// Process 处理Google索引任务项
|
||||
func (gip *GoogleIndexProcessor) Process(ctx context.Context, taskID uint, item *entity.TaskItem) error {
|
||||
utils.Info("开始处理Google索引任务项: %d", item.ID)
|
||||
|
||||
// 解析输入数据
|
||||
var input GoogleIndexTaskInput
|
||||
if err := json.Unmarshal([]byte(item.InputData), &input); err != nil {
|
||||
utils.Error("解析输入数据失败: %v", err)
|
||||
gip.updateTaskItemStatus(item, entity.TaskItemStatusFailed, "", false, nil, 400, err.Error())
|
||||
return fmt.Errorf("解析输入数据失败: %v", err)
|
||||
}
|
||||
|
||||
// 初始化Google客户端
|
||||
client, err := gip.initGoogleClient()
|
||||
if err != nil {
|
||||
utils.Error("初始化Google客户端失败: %v", err)
|
||||
gip.updateTaskItemStatus(item, entity.TaskItemStatusFailed, "", false, nil, 500, err.Error())
|
||||
return fmt.Errorf("初始化Google客户端失败: %v", err)
|
||||
}
|
||||
|
||||
// 根据操作类型执行不同任务
|
||||
switch input.Operation {
|
||||
case "url_indexing":
|
||||
return gip.processURLIndexing(ctx, client, taskID, item, input)
|
||||
case "sitemap_submit":
|
||||
return gip.processSitemapSubmit(ctx, client, taskID, item, input)
|
||||
case "status_check":
|
||||
return gip.processStatusCheck(ctx, client, taskID, item, input)
|
||||
default:
|
||||
errorMsg := fmt.Sprintf("不支持的操作类型: %s", input.Operation)
|
||||
gip.updateTaskItemStatus(item, entity.TaskItemStatusFailed, "", false, nil, 400, errorMsg)
|
||||
return fmt.Errorf(errorMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// processURLIndexing 处理URL索引检查
|
||||
func (gip *GoogleIndexProcessor) processURLIndexing(ctx context.Context, client *google.Client, taskID uint, item *entity.TaskItem, input GoogleIndexTaskInput) error {
|
||||
utils.Info("开始URL索引检查: %v", input.URLs)
|
||||
|
||||
for _, url := range input.URLs {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
gip.updateTaskItemStatus(item, entity.TaskItemStatusFailed, "", false, nil, 0, "任务被取消")
|
||||
return ctx.Err()
|
||||
default:
|
||||
// 检查URL索引状态
|
||||
result, err := gip.inspectURL(client, url)
|
||||
if err != nil {
|
||||
utils.Error("检查URL索引状态失败: %s, 错误: %v", url, err)
|
||||
gip.updateTaskItemStatus(item, entity.TaskItemStatusFailed, "", false, nil, 500, err.Error())
|
||||
continue
|
||||
}
|
||||
|
||||
// 更新任务项状态
|
||||
var lastCrawled *time.Time
|
||||
if result.IndexStatusResult.LastCrawled != "" {
|
||||
parsedTime, err := time.Parse(time.RFC3339, result.IndexStatusResult.LastCrawled)
|
||||
if err == nil {
|
||||
lastCrawled = &parsedTime
|
||||
}
|
||||
}
|
||||
|
||||
gip.updateTaskItemStatus(item, entity.TaskItemStatusSuccess, result.IndexStatusResult.IndexingState, result.MobileUsabilityResult.MobileFriendly, lastCrawled, 200, "")
|
||||
|
||||
// 更新URL状态记录
|
||||
gip.updateURLStatus(url, result.IndexStatusResult.IndexingState, lastCrawled)
|
||||
|
||||
// 添加延迟避免API限制
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
utils.Info("URL索引检查完成")
|
||||
return nil
|
||||
}
|
||||
|
||||
// processSitemapSubmit 处理网站地图提交
|
||||
func (gip *GoogleIndexProcessor) processSitemapSubmit(ctx context.Context, client *google.Client, taskID uint, item *entity.TaskItem, input GoogleIndexTaskInput) error {
|
||||
utils.Info("开始网站地图提交: %s", input.SitemapURL)
|
||||
|
||||
if input.SitemapURL == "" {
|
||||
errorMsg := "网站地图URL不能为空"
|
||||
gip.updateTaskItemStatus(item, entity.TaskItemStatusFailed, "", false, nil, 400, errorMsg)
|
||||
return fmt.Errorf(errorMsg)
|
||||
}
|
||||
|
||||
// 提交网站地图
|
||||
err := client.SubmitSitemap(input.SitemapURL)
|
||||
if err != nil {
|
||||
utils.Error("提交网站地图失败: %s, 错误: %v", input.SitemapURL, err)
|
||||
gip.updateTaskItemStatus(item, entity.TaskItemStatusFailed, "", false, nil, 500, err.Error())
|
||||
return fmt.Errorf("提交网站地图失败: %v", err)
|
||||
}
|
||||
|
||||
// 更新任务项状态
|
||||
now := time.Now()
|
||||
gip.updateTaskItemStatus(item, entity.TaskItemStatusSuccess, "SUBMITTED", false, &now, 200, "")
|
||||
|
||||
utils.Info("网站地图提交完成: %s", input.SitemapURL)
|
||||
return nil
|
||||
}
|
||||
|
||||
// processStatusCheck 处理状态检查
|
||||
func (gip *GoogleIndexProcessor) processStatusCheck(ctx context.Context, client *google.Client, taskID uint, item *entity.TaskItem, input GoogleIndexTaskInput) error {
|
||||
utils.Info("开始状态检查: %v", input.URLs)
|
||||
|
||||
for _, url := range input.URLs {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
gip.updateTaskItemStatus(item, entity.TaskItemStatusFailed, "", false, nil, 0, "任务被取消")
|
||||
return ctx.Err()
|
||||
default:
|
||||
// 检查URL状态
|
||||
result, err := gip.inspectURL(client, url)
|
||||
if err != nil {
|
||||
utils.Error("检查URL状态失败: %s, 错误: %v", url, err)
|
||||
gip.updateTaskItemStatus(item, entity.TaskItemStatusFailed, "", false, nil, 500, err.Error())
|
||||
continue
|
||||
}
|
||||
|
||||
// 更新任务项状态
|
||||
var lastCrawled *time.Time
|
||||
if result.IndexStatusResult.LastCrawled != "" {
|
||||
parsedTime, err := time.Parse(time.RFC3339, result.IndexStatusResult.LastCrawled)
|
||||
if err == nil {
|
||||
lastCrawled = &parsedTime
|
||||
}
|
||||
}
|
||||
|
||||
gip.updateTaskItemStatus(item, entity.TaskItemStatusSuccess, result.IndexStatusResult.IndexingState, result.MobileUsabilityResult.MobileFriendly, lastCrawled, 200, "")
|
||||
|
||||
utils.Info("URL状态检查完成: %s, 状态: %s", url, result.IndexStatusResult.IndexingState)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// initGoogleClient 初始化Google客户端
|
||||
func (gip *GoogleIndexProcessor) initGoogleClient() (*google.Client, error) {
|
||||
// 使用固定的凭据文件路径,与验证逻辑保持一致
|
||||
credentialsFile := "data/google_credentials.json"
|
||||
|
||||
// 检查凭据文件是否存在
|
||||
if _, err := os.Stat(credentialsFile); os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("Google凭据文件不存在: %s", credentialsFile)
|
||||
}
|
||||
|
||||
// 从配置中获取网站URL
|
||||
siteURL, err := gip.repoMgr.SystemConfigRepository.GetConfigValue(entity.ConfigKeyWebsiteURL)
|
||||
if err != nil || siteURL == "" || siteURL == "https://example.com" {
|
||||
return nil, fmt.Errorf("未配置网站URL或在站点配置中设置了默认值")
|
||||
}
|
||||
|
||||
config := &google.Config{
|
||||
CredentialsFile: credentialsFile,
|
||||
SiteURL: siteURL,
|
||||
TokenFile: "data/google_token.json", // 使用固定token文件名,放在data目录下
|
||||
}
|
||||
|
||||
client, err := google.NewClient(config)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建Google客户端失败: %v", err)
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// inspectURL 检查URL索引状态
|
||||
func (gip *GoogleIndexProcessor) inspectURL(client *google.Client, url string) (*google.URLInspectionResult, error) {
|
||||
// 重试机制
|
||||
var result *google.URLInspectionResult
|
||||
var err error
|
||||
|
||||
for attempt := 0; attempt <= gip.config.RetryAttempts; attempt++ {
|
||||
result, err = client.InspectURL(url)
|
||||
if err == nil {
|
||||
break // 成功则退出重试循环
|
||||
}
|
||||
|
||||
if attempt < gip.config.RetryAttempts {
|
||||
utils.Info("URL检查失败,第%d次重试: %s, 错误: %v", attempt+1, url, err)
|
||||
time.Sleep(gip.config.RetryDelay)
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("检查URL失败: %v", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// updateTaskItemStatus 更新任务项状态
|
||||
func (gip *GoogleIndexProcessor) updateTaskItemStatus(item *entity.TaskItem, status entity.TaskItemStatus, indexStatus string, mobileFriendly bool, lastCrawled *time.Time, statusCode int, errorMessage string) {
|
||||
item.Status = status
|
||||
item.ErrorMessage = errorMessage
|
||||
|
||||
// 更新Google索引特有字段
|
||||
item.IndexStatus = indexStatus
|
||||
item.MobileFriendly = mobileFriendly
|
||||
item.LastCrawled = lastCrawled
|
||||
item.StatusCode = statusCode
|
||||
|
||||
now := time.Now()
|
||||
item.ProcessedAt = &now
|
||||
|
||||
// 保存更新
|
||||
if err := gip.repoMgr.TaskItemRepository.Update(item); err != nil {
|
||||
utils.Error("更新任务项状态失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// updateURLStatus 更新URL状态记录(使用任务项存储)
|
||||
func (gip *GoogleIndexProcessor) updateURLStatus(url string, indexStatus string, lastCrawled *time.Time) {
|
||||
// 在任务项中记录URL状态,而不是使用专门的URL状态表
|
||||
// 此功能现在通过任务系统中的TaskItem记录来跟踪
|
||||
utils.Debug("URL状态已更新: %s, 状态: %s", url, indexStatus)
|
||||
}
|
||||
|
||||
// BatchProcessURLs 批量处理URLs
|
||||
func (gip *GoogleIndexProcessor) BatchProcessURLs(ctx context.Context, urls []string, operation string, taskID uint) error {
|
||||
utils.Info("开始批量处理URLs,数量: %d, 操作: %s", len(urls), operation)
|
||||
|
||||
// 根据并发数创建工作池
|
||||
semaphore := make(chan struct{}, gip.config.Concurrency)
|
||||
errChan := make(chan error, len(urls))
|
||||
|
||||
for _, url := range urls {
|
||||
go func(u string) {
|
||||
semaphore <- struct{}{} // 获取信号量
|
||||
defer func() { <-semaphore }() // 释放信号量
|
||||
|
||||
// 处理单个URL
|
||||
client, err := gip.initGoogleClient()
|
||||
if err != nil {
|
||||
errChan <- fmt.Errorf("初始化客户端失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := gip.inspectURL(client, u)
|
||||
if err != nil {
|
||||
utils.Error("处理URL失败: %s, 错误: %v", u, err)
|
||||
errChan <- err
|
||||
return
|
||||
}
|
||||
|
||||
// 更新状态
|
||||
var lastCrawled *time.Time
|
||||
if result.IndexStatusResult.LastCrawled != "" {
|
||||
parsedTime, err := time.Parse(time.RFC3339, result.IndexStatusResult.LastCrawled)
|
||||
if err == nil {
|
||||
lastCrawled = &parsedTime
|
||||
}
|
||||
}
|
||||
|
||||
// 创建任务项记录
|
||||
now := time.Now()
|
||||
inputData := map[string]interface{}{
|
||||
"urls": []string{u},
|
||||
"operation": "url_indexing",
|
||||
}
|
||||
inputDataJSON, _ := json.Marshal(inputData)
|
||||
|
||||
taskItem := &entity.TaskItem{
|
||||
TaskID: taskID,
|
||||
Status: entity.TaskItemStatusSuccess,
|
||||
InputData: string(inputDataJSON),
|
||||
URL: u,
|
||||
IndexStatus: result.IndexStatusResult.IndexingState,
|
||||
MobileFriendly: result.MobileUsabilityResult.MobileFriendly,
|
||||
LastCrawled: lastCrawled,
|
||||
StatusCode: 200,
|
||||
ProcessedAt: &now,
|
||||
}
|
||||
|
||||
if err := gip.repoMgr.TaskItemRepository.Create(taskItem); err != nil {
|
||||
utils.Error("创建任务项失败: %v", err)
|
||||
}
|
||||
|
||||
// 更新URL状态
|
||||
gip.updateURLStatus(u, result.IndexStatusResult.IndexingState, lastCrawled)
|
||||
|
||||
errChan <- nil
|
||||
}(url)
|
||||
}
|
||||
|
||||
// 等待所有goroutine完成
|
||||
for i := 0; i < len(urls); i++ {
|
||||
err := <-errChan
|
||||
if err != nil {
|
||||
utils.Error("批量处理URL时出错: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
utils.Info("批量处理URLs完成")
|
||||
return nil
|
||||
}
|
||||
|
||||
// SubmitSitemap 提交网站地图
|
||||
func (gip *GoogleIndexProcessor) SubmitSitemap(ctx context.Context, sitemapURL string, taskID uint) error {
|
||||
utils.Info("开始提交网站地图: %s", sitemapURL)
|
||||
|
||||
client, err := gip.initGoogleClient()
|
||||
if err != nil {
|
||||
return fmt.Errorf("初始化Google客户端失败: %v", err)
|
||||
}
|
||||
|
||||
err = client.SubmitSitemap(sitemapURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("提交网站地图失败: %v", err)
|
||||
}
|
||||
|
||||
// 创建任务项记录
|
||||
now := time.Now()
|
||||
inputData := map[string]interface{}{
|
||||
"sitemap_url": sitemapURL,
|
||||
"operation": "sitemap_submit",
|
||||
}
|
||||
inputDataJSON, _ := json.Marshal(inputData)
|
||||
|
||||
taskItem := &entity.TaskItem{
|
||||
TaskID: taskID,
|
||||
Status: entity.TaskItemStatusSuccess,
|
||||
InputData: string(inputDataJSON),
|
||||
URL: sitemapURL,
|
||||
IndexStatus: "SUBMITTED",
|
||||
StatusCode: 200,
|
||||
ProcessedAt: &now,
|
||||
}
|
||||
|
||||
if err := gip.repoMgr.TaskItemRepository.Create(taskItem); err != nil {
|
||||
utils.Error("创建任务项失败: %v", err)
|
||||
}
|
||||
|
||||
utils.Info("网站地图提交完成: %s", sitemapURL)
|
||||
return nil
|
||||
}
|
||||
@@ -555,3 +555,71 @@ func (tm *TaskManager) RecoverRunningTasks() error {
|
||||
utils.Info("任务恢复完成,共恢复 %d 个任务", recoveredCount)
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateTask 创建任务
|
||||
func (tm *TaskManager) CreateTask(taskType, name, description string, configID *uint) (*entity.Task, error) {
|
||||
// 验证任务类型是否有对应的处理器
|
||||
tm.mu.RLock()
|
||||
_, exists := tm.processors[taskType]
|
||||
tm.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("未找到任务类型 %s 的处理器", taskType)
|
||||
}
|
||||
|
||||
task := &entity.Task{
|
||||
Type: entity.TaskType(taskType),
|
||||
Name: name,
|
||||
Title: name, // 设置Title为相同值,保持兼容性
|
||||
Description: description,
|
||||
Status: entity.TaskStatusPending,
|
||||
ConfigID: configID,
|
||||
}
|
||||
|
||||
err := tm.repoMgr.TaskRepository.Create(task)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建任务失败: %v", err)
|
||||
}
|
||||
|
||||
utils.Info("创建任务成功: ID=%d, 类型=%s, 名称=%s", task.ID, task.Type, task.Name)
|
||||
return task, nil
|
||||
}
|
||||
|
||||
// GetTask 获取任务详情
|
||||
func (tm *TaskManager) GetTask(taskID uint) (*entity.Task, error) {
|
||||
task, err := tm.repoMgr.TaskRepository.GetByID(taskID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取任务失败: %v", err)
|
||||
}
|
||||
return task, nil
|
||||
}
|
||||
|
||||
// GetTaskItemStats 获取任务项统计信息
|
||||
func (tm *TaskManager) GetTaskItemStats(taskID uint) (map[string]int, error) {
|
||||
stats, err := tm.repoMgr.TaskItemRepository.GetStatsByTaskID(taskID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取任务项统计失败: %v", err)
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// QueryTaskItems 查询任务项
|
||||
func (tm *TaskManager) QueryTaskItems(taskID uint, page, pageSize int, status string) ([]*entity.TaskItem, int64, error) {
|
||||
items, total, err := tm.repoMgr.TaskItemRepository.GetListByTaskID(taskID, page, pageSize, status)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("查询任务项失败: %v", err)
|
||||
}
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
// CreateTaskItems 创建任务项
|
||||
func (tm *TaskManager) CreateTaskItems(taskID uint, items []*entity.TaskItem) error {
|
||||
for _, item := range items {
|
||||
item.TaskID = taskID
|
||||
if err := tm.repoMgr.TaskItemRepository.Create(item); err != nil {
|
||||
return fmt.Errorf("创建任务项失败: %v", err)
|
||||
}
|
||||
}
|
||||
utils.Info("创建任务项成功: 任务ID=%d, 数量=%d", taskID, len(items))
|
||||
return nil
|
||||
}
|
||||
|
||||
204
utils/cache.go
Normal file
204
utils/cache.go
Normal file
@@ -0,0 +1,204 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CacheData 缓存数据结构
|
||||
type CacheData struct {
|
||||
Data interface{}
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// CacheManager 通用缓存管理器
|
||||
type CacheManager struct {
|
||||
cache map[string]*CacheData
|
||||
mutex sync.RWMutex
|
||||
}
|
||||
|
||||
// NewCacheManager 创建缓存管理器
|
||||
func NewCacheManager() *CacheManager {
|
||||
return &CacheManager{
|
||||
cache: make(map[string]*CacheData),
|
||||
}
|
||||
}
|
||||
|
||||
// Set 设置缓存
|
||||
func (cm *CacheManager) Set(key string, data interface{}) {
|
||||
cm.mutex.Lock()
|
||||
defer cm.mutex.Unlock()
|
||||
cm.cache[key] = &CacheData{
|
||||
Data: data,
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// Get 获取缓存
|
||||
func (cm *CacheManager) Get(key string, ttl time.Duration) (interface{}, bool) {
|
||||
cm.mutex.RLock()
|
||||
defer cm.mutex.RUnlock()
|
||||
|
||||
if cachedData, exists := cm.cache[key]; exists {
|
||||
if time.Since(cachedData.UpdatedAt) < ttl {
|
||||
return cachedData.Data, true
|
||||
}
|
||||
// 缓存过期,删除
|
||||
delete(cm.cache, key)
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// GetWithTTL 获取缓存并返回剩余TTL
|
||||
func (cm *CacheManager) GetWithTTL(key string, ttl time.Duration) (interface{}, bool, time.Duration) {
|
||||
cm.mutex.RLock()
|
||||
defer cm.mutex.RUnlock()
|
||||
|
||||
if cachedData, exists := cm.cache[key]; exists {
|
||||
elapsed := time.Since(cachedData.UpdatedAt)
|
||||
if elapsed < ttl {
|
||||
return cachedData.Data, true, ttl - elapsed
|
||||
}
|
||||
// 缓存过期,删除
|
||||
delete(cm.cache, key)
|
||||
}
|
||||
return nil, false, 0
|
||||
}
|
||||
|
||||
// Delete 删除缓存
|
||||
func (cm *CacheManager) Delete(key string) {
|
||||
cm.mutex.Lock()
|
||||
defer cm.mutex.Unlock()
|
||||
delete(cm.cache, key)
|
||||
}
|
||||
|
||||
// DeletePattern 删除匹配模式的缓存
|
||||
func (cm *CacheManager) DeletePattern(pattern string) {
|
||||
cm.mutex.Lock()
|
||||
defer cm.mutex.Unlock()
|
||||
|
||||
for key := range cm.cache {
|
||||
// 简单的字符串匹配,可以根据需要扩展为正则表达式
|
||||
if len(pattern) > 0 && (key == pattern || (len(key) >= len(pattern) && key[:len(pattern)] == pattern)) {
|
||||
delete(cm.cache, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear 清空所有缓存
|
||||
func (cm *CacheManager) Clear() {
|
||||
cm.mutex.Lock()
|
||||
defer cm.mutex.Unlock()
|
||||
cm.cache = make(map[string]*CacheData)
|
||||
}
|
||||
|
||||
// Size 获取缓存项数量
|
||||
func (cm *CacheManager) Size() int {
|
||||
cm.mutex.RLock()
|
||||
defer cm.mutex.RUnlock()
|
||||
return len(cm.cache)
|
||||
}
|
||||
|
||||
// CleanExpired 清理过期缓存
|
||||
func (cm *CacheManager) CleanExpired(ttl time.Duration) int {
|
||||
cm.mutex.Lock()
|
||||
defer cm.mutex.Unlock()
|
||||
|
||||
cleaned := 0
|
||||
now := time.Now()
|
||||
for key, cachedData := range cm.cache {
|
||||
if now.Sub(cachedData.UpdatedAt) >= ttl {
|
||||
delete(cm.cache, key)
|
||||
cleaned++
|
||||
}
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
|
||||
// GetKeys 获取所有缓存键
|
||||
func (cm *CacheManager) GetKeys() []string {
|
||||
cm.mutex.RLock()
|
||||
defer cm.mutex.RUnlock()
|
||||
|
||||
keys := make([]string, 0, len(cm.cache))
|
||||
for key := range cm.cache {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// 全局缓存管理器实例
|
||||
var (
|
||||
// 热门资源缓存
|
||||
HotResourcesCache = NewCacheManager()
|
||||
|
||||
// 相关资源缓存
|
||||
RelatedResourcesCache = NewCacheManager()
|
||||
|
||||
// 系统配置缓存
|
||||
SystemConfigCache = NewCacheManager()
|
||||
|
||||
// 分类缓存
|
||||
CategoriesCache = NewCacheManager()
|
||||
|
||||
// 标签缓存
|
||||
TagsCache = NewCacheManager()
|
||||
|
||||
// 资源有效性检测缓存
|
||||
ResourceValidityCache = NewCacheManager()
|
||||
)
|
||||
|
||||
// GetHotResourcesCache 获取热门资源缓存管理器
|
||||
func GetHotResourcesCache() *CacheManager {
|
||||
return HotResourcesCache
|
||||
}
|
||||
|
||||
// GetRelatedResourcesCache 获取相关资源缓存管理器
|
||||
func GetRelatedResourcesCache() *CacheManager {
|
||||
return RelatedResourcesCache
|
||||
}
|
||||
|
||||
// GetSystemConfigCache 获取系统配置缓存管理器
|
||||
func GetSystemConfigCache() *CacheManager {
|
||||
return SystemConfigCache
|
||||
}
|
||||
|
||||
// GetCategoriesCache 获取分类缓存管理器
|
||||
func GetCategoriesCache() *CacheManager {
|
||||
return CategoriesCache
|
||||
}
|
||||
|
||||
// GetTagsCache 获取标签缓存管理器
|
||||
func GetTagsCache() *CacheManager {
|
||||
return TagsCache
|
||||
}
|
||||
|
||||
// GetResourceValidityCache 获取资源有效性检测缓存管理器
|
||||
func GetResourceValidityCache() *CacheManager {
|
||||
return ResourceValidityCache
|
||||
}
|
||||
|
||||
// ClearAllCaches 清空所有全局缓存
|
||||
func ClearAllCaches() {
|
||||
HotResourcesCache.Clear()
|
||||
RelatedResourcesCache.Clear()
|
||||
SystemConfigCache.Clear()
|
||||
CategoriesCache.Clear()
|
||||
TagsCache.Clear()
|
||||
ResourceValidityCache.Clear()
|
||||
}
|
||||
|
||||
// CleanAllExpiredCaches 清理所有过期缓存
|
||||
func CleanAllExpiredCaches(ttl time.Duration) {
|
||||
totalCleaned := 0
|
||||
totalCleaned += HotResourcesCache.CleanExpired(ttl)
|
||||
totalCleaned += RelatedResourcesCache.CleanExpired(ttl)
|
||||
totalCleaned += SystemConfigCache.CleanExpired(ttl)
|
||||
totalCleaned += CategoriesCache.CleanExpired(ttl)
|
||||
totalCleaned += TagsCache.CleanExpired(ttl)
|
||||
totalCleaned += ResourceValidityCache.CleanExpired(ttl)
|
||||
|
||||
if totalCleaned > 0 {
|
||||
Info("清理过期缓存完成,共清理 %d 个缓存项", totalCleaned)
|
||||
}
|
||||
}
|
||||
@@ -28,23 +28,40 @@ func GetTelegramLogs(startTime *time.Time, endTime *time.Time, limit int) ([]Tel
|
||||
return []TelegramLogEntry{}, nil
|
||||
}
|
||||
|
||||
files, err := filepath.Glob(filepath.Join(logDir, "app_*.log"))
|
||||
// 查找所有日志文件,包括当前的app.log和历史日志文件
|
||||
allFiles, err := filepath.Glob(filepath.Join(logDir, "*.log"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查找日志文件失败: %v", err)
|
||||
}
|
||||
|
||||
if len(files) == 0 {
|
||||
if len(allFiles) == 0 {
|
||||
return []TelegramLogEntry{}, nil
|
||||
}
|
||||
|
||||
// 按时间排序,最近的在前面
|
||||
sort.Sort(sort.Reverse(sort.StringSlice(files)))
|
||||
// 将app.log放在最前面,其他文件按时间排序
|
||||
var files []string
|
||||
var otherFiles []string
|
||||
|
||||
for _, file := range allFiles {
|
||||
if filepath.Base(file) == "app.log" {
|
||||
files = append(files, file) // 当前日志文件优先
|
||||
} else {
|
||||
otherFiles = append(otherFiles, file)
|
||||
}
|
||||
}
|
||||
|
||||
// 其他文件按时间排序,最近的在前面
|
||||
sort.Sort(sort.Reverse(sort.StringSlice(otherFiles)))
|
||||
files = append(files, otherFiles...)
|
||||
|
||||
// files现在已经是app.log优先,然后是其他文件按时间倒序排列
|
||||
|
||||
var allEntries []TelegramLogEntry
|
||||
|
||||
// 编译Telegram相关的正则表达式
|
||||
telegramRegex := regexp.MustCompile(`(?i)(\[TELEGRAM.*?\])`)
|
||||
messageRegex := regexp.MustCompile(`\[(\w+)\]\s+(\d{4}/\d{2}/\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[.*?\]\s+(.*)`)
|
||||
// 修正正则表达式以匹配实际的日志格式: 2025/01/20 14:30:15 [INFO] [file:line] [TELEGRAM] message
|
||||
messageRegex := regexp.MustCompile(`(\d{4}/\d{2}/\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[(\w+)\]\s+\[.*?:\d+\]\s+\[TELEGRAM.*?\]\s+(.*)`)
|
||||
|
||||
for _, file := range files {
|
||||
entries, err := parseTelegramLogsFromFile(file, telegramRegex, messageRegex, startTime, endTime)
|
||||
@@ -119,18 +136,23 @@ func parseTelegramLogsFromFile(filePath string, telegramRegex, messageRegex *reg
|
||||
|
||||
// parseLogLine 解析单行日志
|
||||
func parseLogLine(line string, messageRegex *regexp.Regexp) (TelegramLogEntry, error) {
|
||||
// 匹配日志格式: [LEVEL] 2006/01/02 15:04:05 [file:line] message
|
||||
// 匹配日志格式: 2006/01/02 15:04:05 [LEVEL] [file:line] [TELEGRAM] message
|
||||
matches := messageRegex.FindStringSubmatch(line)
|
||||
if len(matches) < 4 {
|
||||
return TelegramLogEntry{}, fmt.Errorf("无法解析日志行: %s", line)
|
||||
}
|
||||
|
||||
level := matches[1]
|
||||
timeStr := matches[2]
|
||||
timeStr := matches[1]
|
||||
level := matches[2]
|
||||
message := matches[3]
|
||||
|
||||
// 解析时间
|
||||
timestamp, err := time.Parse("2006/01/02 15:04:05", timeStr)
|
||||
// 解析时间(使用本地时区)
|
||||
location, err := time.LoadLocation("Asia/Shanghai")
|
||||
if err != nil {
|
||||
return TelegramLogEntry{}, fmt.Errorf("加载时区失败: %v", err)
|
||||
}
|
||||
|
||||
timestamp, err := time.ParseInLocation("2006/01/02 15:04:05", timeStr, location)
|
||||
if err != nil {
|
||||
return TelegramLogEntry{}, fmt.Errorf("时间解析失败: %v", err)
|
||||
}
|
||||
@@ -203,7 +225,7 @@ func ClearOldTelegramLogs(daysToKeep int) error {
|
||||
return nil // 日志目录不存在,无需清理
|
||||
}
|
||||
|
||||
files, err := filepath.Glob(filepath.Join(logDir, "app_*.log"))
|
||||
files, err := filepath.Glob(filepath.Join(logDir, "*.log"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("查找日志文件失败: %v", err)
|
||||
}
|
||||
|
||||
5
web/.env.example
Normal file
5
web/.env.example
Normal file
@@ -0,0 +1,5 @@
|
||||
# API Server Configuration
|
||||
NUXT_PUBLIC_API_SERVER=http://localhost:8080/api
|
||||
|
||||
# OG Image Service Configuration
|
||||
NUXT_PUBLIC_OG_API_URL=http://localhost:8081/api/og-image
|
||||
1
web/components.d.ts
vendored
1
web/components.d.ts
vendored
@@ -34,6 +34,7 @@ declare module 'vue' {
|
||||
NInputNumber: typeof import('naive-ui')['NInputNumber']
|
||||
NList: typeof import('naive-ui')['NList']
|
||||
NListItem: typeof import('naive-ui')['NListItem']
|
||||
NMarquee: typeof import('naive-ui')['NMarquee']
|
||||
NMessageProvider: typeof import('naive-ui')['NMessageProvider']
|
||||
NModal: typeof import('naive-ui')['NModal']
|
||||
NNotificationProvider: typeof import('naive-ui')['NNotificationProvider']
|
||||
|
||||
552
web/components/Admin/GoogleIndexTab.vue
Normal file
552
web/components/Admin/GoogleIndexTab.vue
Normal file
@@ -0,0 +1,552 @@
|
||||
<template>
|
||||
<div class="tab-content-container">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<!-- Google索引配置 -->
|
||||
<div class="mb-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">Google索引配置</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400">配置Google Search Console API和索引相关设置</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h4 class="font-medium text-gray-900 dark:text-white mb-2">Google索引功能</h4>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
开启后系统将自动检查和提交URL到Google索引
|
||||
</p>
|
||||
</div>
|
||||
<n-switch
|
||||
v-model:value="googleIndexConfig.enabled"
|
||||
@update:value="updateGoogleIndexConfig"
|
||||
:loading="configLoading"
|
||||
size="large"
|
||||
>
|
||||
<template #checked>已开启</template>
|
||||
<template #unchecked>已关闭</template>
|
||||
</n-switch>
|
||||
</div>
|
||||
|
||||
<!-- 配置详情 -->
|
||||
<div class="border-t border-gray-200 dark:border-gray-600 pt-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">站点URL</label>
|
||||
<n-input
|
||||
:value="getSiteUrlDisplay()"
|
||||
:disabled="true"
|
||||
placeholder="请先在站点配置中设置站点URL"
|
||||
>
|
||||
<template #prefix>
|
||||
<i class="fas fa-globe text-gray-400"></i>
|
||||
</template>
|
||||
</n-input>
|
||||
<!-- 所有权验证按钮 -->
|
||||
<div class="mt-3">
|
||||
<n-button
|
||||
type="info"
|
||||
size="small"
|
||||
ghost
|
||||
@click="$emit('show-verification')"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="fas fa-shield-alt"></i>
|
||||
</template>
|
||||
所有权验证
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">凭据文件路径</label>
|
||||
<div class="flex flex-col space-y-2">
|
||||
<n-input
|
||||
:value="credentialsFilePath"
|
||||
placeholder="点击上传按钮选择文件"
|
||||
:disabled="true"
|
||||
/>
|
||||
<div class="flex space-x-2">
|
||||
<!-- 申请凭据按钮 -->
|
||||
<n-button
|
||||
size="small"
|
||||
type="warning"
|
||||
ghost
|
||||
@click="$emit('show-credentials-guide')"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="fas fa-question-circle"></i>
|
||||
</template>
|
||||
申请凭据
|
||||
</n-button>
|
||||
<!-- 上传按钮 -->
|
||||
<n-button
|
||||
size="small"
|
||||
type="primary"
|
||||
ghost
|
||||
@click="$emit('select-credentials-file')"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="fas fa-upload"></i>
|
||||
</template>
|
||||
上传凭据
|
||||
</n-button>
|
||||
<!-- 验证按钮 -->
|
||||
<n-button
|
||||
size="small"
|
||||
type="info"
|
||||
ghost
|
||||
@click="validateCredentials"
|
||||
:loading="validatingCredentials"
|
||||
:disabled="!credentialsFilePath"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="fas fa-check-circle"></i>
|
||||
</template>
|
||||
验证凭据
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">检查间隔(分钟)</label>
|
||||
<n-input-number
|
||||
v-model:value="googleIndexConfig.checkInterval"
|
||||
:min="1"
|
||||
:max="1440"
|
||||
@update:value="updateGoogleIndexConfig"
|
||||
:disabled="!credentialsFilePath"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">批处理大小</label>
|
||||
<n-input-number
|
||||
v-model:value="googleIndexConfig.batchSize"
|
||||
:min="1"
|
||||
:max="1000"
|
||||
@update:value="updateGoogleIndexConfig"
|
||||
:disabled="!credentialsFilePath"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 凭据状态 -->
|
||||
<div v-if="credentialsStatus" class="mt-4 p-3 rounded-lg border"
|
||||
:class="{
|
||||
'bg-green-50 border-green-200 text-green-700 dark:bg-green-900/20 dark:border-green-800 dark:text-green-300': credentialsStatus === 'valid',
|
||||
'bg-yellow-50 border-yellow-200 text-yellow-700 dark:bg-yellow-900/20 dark:border-yellow-800 dark:text-yellow-300': credentialsStatus === 'invalid',
|
||||
'bg-blue-50 border-blue-200 text-blue-700 dark:bg-blue-900/20 dark:border-blue-800 dark:text-blue-300': credentialsStatus === 'verifying'
|
||||
}"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<i
|
||||
:class="{
|
||||
'fas fa-check-circle text-green-500 dark:text-green-400': credentialsStatus === 'valid',
|
||||
'fas fa-exclamation-circle text-yellow-500 dark:text-yellow-400': credentialsStatus === 'invalid',
|
||||
'fas fa-spinner fa-spin text-blue-500 dark:text-blue-400': credentialsStatus === 'verifying'
|
||||
}"
|
||||
class="mr-2"
|
||||
></i>
|
||||
<span>{{ credentialsStatusMessage }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Google索引统计 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<div class="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4">
|
||||
<div class="flex items-center">
|
||||
<div class="p-2 bg-blue-100 dark:bg-blue-900 rounded-lg">
|
||||
<i class="fas fa-link text-blue-600 dark:text-blue-400"></i>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">总URL数</p>
|
||||
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ googleIndexStats.totalURLs || 0 }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-green-50 dark:bg-green-900/20 rounded-lg p-4">
|
||||
<div class="flex items-center">
|
||||
<div class="p-2 bg-green-100 dark:bg-green-900 rounded-lg">
|
||||
<i class="fas fa-check-circle text-green-600 dark:text-green-400"></i>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">已索引</p>
|
||||
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ googleIndexStats.indexedURLs || 0 }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-yellow-50 dark:bg-yellow-900/20 rounded-lg p-4">
|
||||
<div class="flex items-center">
|
||||
<div class="p-2 bg-yellow-100 dark:bg-yellow-900 rounded-lg">
|
||||
<i class="fas fa-exclamation-triangle text-yellow-600 dark:text-yellow-400"></i>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">错误数</p>
|
||||
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ googleIndexStats.errorURLs || 0 }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-purple-50 dark:bg-purple-900/20 rounded-lg p-4">
|
||||
<div class="flex items-center">
|
||||
<div class="p-2 bg-purple-100 dark:bg-purple-900 rounded-lg">
|
||||
<i class="fas fa-tasks text-purple-600 dark:text-purple-400"></i>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">总任务数</p>
|
||||
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ googleIndexStats.totalTasks || 0 }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 外部工具链接 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||
<div class="bg-purple-50 dark:bg-purple-900/20 rounded-lg p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<div class="p-2 bg-purple-100 dark:bg-purple-900 rounded-lg">
|
||||
<i class="fas fa-chart-line text-purple-600 dark:text-purple-400"></i>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">Google Search Console</p>
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">查看详细分析数据</p>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
:href="getSearchConsoleUrl()"
|
||||
target="_blank"
|
||||
class="px-3 py-1 bg-purple-600 text-white rounded-md hover:bg-purple-700 transition-colors text-sm"
|
||||
>
|
||||
打开控制台
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-orange-50 dark:bg-orange-900/20 rounded-lg p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<div class="p-2 bg-orange-100 dark:bg-orange-900 rounded-lg">
|
||||
<i class="fas fa-chart-line text-orange-600 dark:text-orange-400"></i>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">Google Analytics</p>
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">网站流量分析仪表板</p>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
:href="getAnalyticsUrl()"
|
||||
target="_blank"
|
||||
class="px-3 py-1 bg-orange-600 text-white rounded-md hover:bg-orange-700 transition-colors text-sm"
|
||||
>
|
||||
查看分析
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="flex flex-wrap gap-3 mb-6">
|
||||
<n-button
|
||||
type="primary"
|
||||
@click="$emit('manual-check-urls')"
|
||||
:loading="manualCheckLoading"
|
||||
size="large"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="fas fa-search"></i>
|
||||
</template>
|
||||
手动检查URL
|
||||
</n-button>
|
||||
|
||||
<n-button
|
||||
type="success"
|
||||
@click="submitSitemap"
|
||||
:loading="submitSitemapLoading"
|
||||
size="large"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="fas fa-upload"></i>
|
||||
</template>
|
||||
提交网站地图
|
||||
</n-button>
|
||||
|
||||
<n-button
|
||||
type="info"
|
||||
@click="$emit('refresh-status')"
|
||||
size="large"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="fas fa-sync-alt"></i>
|
||||
</template>
|
||||
刷新状态
|
||||
</n-button>
|
||||
</div>
|
||||
|
||||
<!-- 任务列表 -->
|
||||
<div>
|
||||
<h4 class="text-lg font-medium text-gray-900 dark:text-white mb-4">索引任务列表</h4>
|
||||
<n-data-table
|
||||
:columns="taskColumns"
|
||||
:data="tasks"
|
||||
:pagination="pagination"
|
||||
:loading="tasksLoading"
|
||||
:bordered="false"
|
||||
striped
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useMessage } from 'naive-ui'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
import { ref, computed, h, watch } from 'vue'
|
||||
|
||||
// Props
|
||||
interface Props {
|
||||
systemConfig?: any
|
||||
googleIndexConfig: any
|
||||
googleIndexStats: any
|
||||
tasks: any[]
|
||||
credentialsStatus: string | null
|
||||
credentialsStatusMessage: string
|
||||
configLoading: boolean
|
||||
manualCheckLoading: boolean
|
||||
submitSitemapLoading: boolean
|
||||
tasksLoading: boolean
|
||||
pagination: any
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
systemConfig: null,
|
||||
googleIndexConfig: () => ({}),
|
||||
googleIndexStats: () => ({}),
|
||||
tasks: () => [],
|
||||
credentialsStatus: null,
|
||||
credentialsStatusMessage: '',
|
||||
configLoading: false,
|
||||
manualCheckLoading: false,
|
||||
submitSitemapLoading: false,
|
||||
tasksLoading: false,
|
||||
pagination: () => ({})
|
||||
})
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits<{
|
||||
'update:google-index-config': []
|
||||
'show-verification': []
|
||||
'show-credentials-guide': []
|
||||
'select-credentials-file': []
|
||||
'manual-check-urls': []
|
||||
'refresh-status': []
|
||||
}>()
|
||||
|
||||
// 获取消息组件
|
||||
const message = useMessage()
|
||||
|
||||
// 本地状态
|
||||
const validatingCredentials = ref(false)
|
||||
|
||||
// 计算属性,用于安全地访问凭据文件路径
|
||||
const credentialsFilePath = computed(() => {
|
||||
const path = props.googleIndexConfig?.credentialsFile || ''
|
||||
console.log('Component computed credentialsFilePath:', path)
|
||||
return path
|
||||
})
|
||||
|
||||
|
||||
// 任务表格列
|
||||
const taskColumns = [
|
||||
{
|
||||
title: 'ID',
|
||||
key: 'id',
|
||||
width: 80
|
||||
},
|
||||
{
|
||||
title: '标题',
|
||||
key: 'name',
|
||||
width: 200
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
key: 'type',
|
||||
width: 120,
|
||||
render: (row: any) => {
|
||||
const typeMap = {
|
||||
status_check: { text: '状态检查', class: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200' },
|
||||
sitemap_submit: { text: '网站地图', class: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' },
|
||||
url_indexing: { text: 'URL索引', class: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200' }
|
||||
}
|
||||
const type = typeMap[row.type as keyof typeof typeMap] || { text: row.type, class: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200' }
|
||||
return h('span', {
|
||||
class: `px-2 py-1 text-xs font-medium rounded ${type.class}`
|
||||
}, type.text)
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
render: (row: any) => {
|
||||
const statusMap = {
|
||||
pending: { text: '待处理', class: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200' },
|
||||
running: { text: '运行中', class: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200' },
|
||||
completed: { text: '完成', class: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' },
|
||||
failed: { text: '失败', class: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200' }
|
||||
}
|
||||
const status = statusMap[row.status as keyof typeof statusMap] || { text: row.status, class: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200' }
|
||||
return h('span', {
|
||||
class: `px-2 py-1 text-xs font-medium rounded ${status.class}`
|
||||
}, status.text)
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '总项目',
|
||||
key: 'totalItems',
|
||||
width: 100
|
||||
},
|
||||
{
|
||||
title: '成功/失败',
|
||||
key: 'progress',
|
||||
width: 120,
|
||||
render: (row: any) => {
|
||||
return h('span', `${row.successful_items} / ${row.failed_items}`)
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
key: 'created_at',
|
||||
width: 150,
|
||||
render: (row: any) => {
|
||||
return row.created_at ? new Date(row.created_at).toLocaleString('zh-CN') : 'N/A'
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 150,
|
||||
render: (row: any) => {
|
||||
return h('div', { class: 'space-x-2' }, [
|
||||
h('button', {
|
||||
class: 'text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 text-sm',
|
||||
onClick: () => emit('view-task-items', row.id)
|
||||
}, '详情'),
|
||||
h('button', {
|
||||
class: 'text-green-600 hover:text-green-800 dark:text-green-400 dark:hover:text-green-300 text-sm',
|
||||
disabled: row.status !== 'pending' && row.status !== 'running',
|
||||
onClick: () => emit('start-task', row.id)
|
||||
}, '启动')
|
||||
].filter(btn => !btn.props?.disabled))
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
// 验证凭据
|
||||
const validateCredentials = async () => {
|
||||
if (!credentialsFilePath.value) {
|
||||
message.warning('请先上传凭据文件')
|
||||
return
|
||||
}
|
||||
|
||||
validatingCredentials.value = true
|
||||
|
||||
try {
|
||||
const api = useApi()
|
||||
const response = await api.googleIndexApi.validateCredentials({})
|
||||
|
||||
if (response?.valid) {
|
||||
message.success('凭据验证成功')
|
||||
emit('update:google-index-config')
|
||||
} else {
|
||||
message.error('凭据验证失败:' + (response?.message || '凭据无效或权限不足'))
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('凭据验证失败:', error)
|
||||
message.error('凭据验证失败: ' + (error?.message || '网络错误'))
|
||||
} finally {
|
||||
validatingCredentials.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 更新Google索引配置
|
||||
const updateGoogleIndexConfig = async () => {
|
||||
emit('update:google-index-config')
|
||||
}
|
||||
|
||||
// 提交网站地图
|
||||
const submitSitemap = async () => {
|
||||
const siteUrl = props.systemConfig?.site_url || ''
|
||||
if (!siteUrl || siteUrl === 'https://example.com') {
|
||||
message.warning('请先在站点配置中设置正确的站点URL')
|
||||
return
|
||||
}
|
||||
|
||||
const sitemapUrl = siteUrl.endsWith('/') ? siteUrl + 'sitemap.xml' : siteUrl + '/sitemap.xml'
|
||||
|
||||
try {
|
||||
const api = useApi()
|
||||
const response = await api.googleIndexApi.createGoogleIndexTask({
|
||||
title: `网站地图提交任务 - ${new Date().toLocaleString('zh-CN')}`,
|
||||
type: 'sitemap_submit',
|
||||
description: `提交网站地图: ${sitemapUrl}`,
|
||||
SitemapURL: sitemapUrl
|
||||
})
|
||||
if (response) {
|
||||
message.success('网站地图提交任务已创建')
|
||||
emit('refresh-status')
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('提交网站地图失败:', error)
|
||||
const errorMsg = error?.response?.data?.message || error?.message || '提交网站地图失败'
|
||||
message.error('提交网站地图失败: ' + errorMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取Google Search Console URL
|
||||
const getSearchConsoleUrl = () => {
|
||||
const siteUrl = props.systemConfig?.site_url || ''
|
||||
if (!siteUrl) {
|
||||
return 'https://search.google.com/search-console'
|
||||
}
|
||||
|
||||
// 格式化URL用于Google Search Console
|
||||
const normalizedUrl = siteUrl.startsWith('http') ? siteUrl : `https://${siteUrl}`
|
||||
return `https://search.google.com/search-console/performance/search-analytics?resource_id=${encodeURIComponent(normalizedUrl)}`
|
||||
}
|
||||
|
||||
// 获取Google Analytics URL
|
||||
const getAnalyticsUrl = () => {
|
||||
const siteUrl = props.systemConfig?.site_url || ''
|
||||
|
||||
// 格式化URL用于Google Analytics
|
||||
const normalizedUrl = siteUrl.startsWith('http') ? siteUrl : `https://${siteUrl}`
|
||||
|
||||
// 跳转到Google Analytics
|
||||
return 'https://analytics.google.com/'
|
||||
}
|
||||
|
||||
// 获取站点URL显示文本
|
||||
const getSiteUrlDisplay = () => {
|
||||
const siteUrl = props.systemConfig?.site_url || ''
|
||||
if (!siteUrl) {
|
||||
return '站点URL未配置'
|
||||
}
|
||||
if (siteUrl === 'https://example.com') {
|
||||
return '请配置正确的站点URL'
|
||||
}
|
||||
return siteUrl
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tab-content-container {
|
||||
height: calc(100vh - 240px);
|
||||
overflow-y: auto;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
</style>
|
||||
199
web/components/Admin/LinkBuildingTab.vue
Normal file
199
web/components/Admin/LinkBuildingTab.vue
Normal file
@@ -0,0 +1,199 @@
|
||||
<template>
|
||||
<div class="tab-content-container">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<div class="mb-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">外链建设(待开发)</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400">管理和监控外部链接建设情况</p>
|
||||
</div>
|
||||
|
||||
<!-- 外链统计 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<div class="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4">
|
||||
<div class="flex items-center">
|
||||
<div class="p-2 bg-blue-100 dark:bg-blue-900 rounded-lg">
|
||||
<i class="fas fa-link text-blue-600 dark:text-blue-400"></i>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">总外链数</p>
|
||||
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ linkStats.total }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-green-50 dark:bg-green-900/20 rounded-lg p-4">
|
||||
<div class="flex items-center">
|
||||
<div class="p-2 bg-green-100 dark:bg-green-900 rounded-lg">
|
||||
<i class="fas fa-check text-green-600 dark:text-green-400"></i>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">有效外链</p>
|
||||
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ linkStats.valid }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-yellow-50 dark:bg-yellow-900/20 rounded-lg p-4">
|
||||
<div class="flex items-center">
|
||||
<div class="p-2 bg-yellow-100 dark:bg-yellow-900 rounded-lg">
|
||||
<i class="fas fa-clock text-yellow-600 dark:text-yellow-400"></i>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">待审核</p>
|
||||
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ linkStats.pending }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-red-50 dark:bg-red-900/20 rounded-lg p-4">
|
||||
<div class="flex items-center">
|
||||
<div class="p-2 bg-red-100 dark:bg-red-900 rounded-lg">
|
||||
<i class="fas fa-times text-red-600 dark:text-red-400"></i>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">失效外链</p>
|
||||
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ linkStats.invalid }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 外链列表 -->
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h4 class="text-lg font-medium text-gray-900 dark:text-white">外链列表</h4>
|
||||
<n-button type="primary" @click="$emit('add-new-link')">
|
||||
<template #icon>
|
||||
<i class="fas fa-plus"></i>
|
||||
</template>
|
||||
添加外链
|
||||
</n-button>
|
||||
</div>
|
||||
|
||||
<n-data-table
|
||||
:columns="linkColumns"
|
||||
:data="linkList"
|
||||
:pagination="pagination"
|
||||
:loading="loading"
|
||||
:bordered="false"
|
||||
striped
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, h } from 'vue'
|
||||
|
||||
// Props
|
||||
interface Props {
|
||||
linkStats: {
|
||||
total: number
|
||||
valid: number
|
||||
pending: number
|
||||
invalid: number
|
||||
}
|
||||
linkList: Array<{
|
||||
id: number
|
||||
url: string
|
||||
title: string
|
||||
status: string
|
||||
domain: string
|
||||
created_at: string
|
||||
}>
|
||||
loading: boolean
|
||||
pagination: any
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
linkStats: () => ({
|
||||
total: 0,
|
||||
valid: 0,
|
||||
pending: 0,
|
||||
invalid: 0
|
||||
}),
|
||||
linkList: () => [],
|
||||
loading: false,
|
||||
pagination: () => ({})
|
||||
})
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits<{
|
||||
'add-new-link': []
|
||||
'edit-link': [row: any]
|
||||
'delete-link': [row: any]
|
||||
'load-link-list': [page: number]
|
||||
}>()
|
||||
|
||||
// 表格列配置
|
||||
const linkColumns = [
|
||||
{
|
||||
title: 'URL',
|
||||
key: 'url',
|
||||
width: 300,
|
||||
render: (row: any) => {
|
||||
return h('a', {
|
||||
href: row.url,
|
||||
target: '_blank',
|
||||
class: 'text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300'
|
||||
}, row.url)
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '标题',
|
||||
key: 'title',
|
||||
width: 200
|
||||
},
|
||||
{
|
||||
title: '域名',
|
||||
key: 'domain',
|
||||
width: 150
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
render: (row: any) => {
|
||||
const statusMap = {
|
||||
valid: { text: '有效', class: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' },
|
||||
pending: { text: '待审核', class: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200' },
|
||||
invalid: { text: '失效', class: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200' }
|
||||
}
|
||||
const status = statusMap[row.status as keyof typeof statusMap]
|
||||
return h('span', {
|
||||
class: `px-2 py-1 text-xs font-medium rounded ${status.class}`
|
||||
}, status.text)
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
key: 'created_at',
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 120,
|
||||
render: (row: any) => {
|
||||
return h('div', { class: 'space-x-2' }, [
|
||||
h('button', {
|
||||
class: 'text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300',
|
||||
onClick: () => emit('edit-link', row)
|
||||
}, '编辑'),
|
||||
h('button', {
|
||||
class: 'text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300',
|
||||
onClick: () => emit('delete-link', row)
|
||||
}, '删除')
|
||||
])
|
||||
}
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tab-content-container {
|
||||
height: calc(100vh - 240px);
|
||||
overflow-y: auto;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
</style>
|
||||
275
web/components/Admin/SiteSubmitTab.vue
Normal file
275
web/components/Admin/SiteSubmitTab.vue
Normal file
@@ -0,0 +1,275 @@
|
||||
<template>
|
||||
<div class="tab-content-container">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<div class="mb-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">站点提交(待开发)</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400">向各大搜索引擎提交站点信息</p>
|
||||
</div>
|
||||
|
||||
<!-- 搜索引擎列表 -->
|
||||
<div class="space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<!-- 百度 -->
|
||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-8 h-8 bg-blue-500 rounded flex items-center justify-center">
|
||||
<i class="fas fa-search text-white text-sm"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-medium text-gray-900 dark:text-white">百度</h4>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">baidu.com</p>
|
||||
</div>
|
||||
</div>
|
||||
<n-button size="small" type="primary" @click="submitToBaidu">
|
||||
<template #icon>
|
||||
<i class="fas fa-paper-plane"></i>
|
||||
</template>
|
||||
提交
|
||||
</n-button>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
最后提交时间:{{ lastSubmitTime.baidu || '未提交' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 谷歌 -->
|
||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-8 h-8 bg-red-500 rounded flex items-center justify-center">
|
||||
<i class="fas fa-globe text-white text-sm"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-medium text-gray-900 dark:text-white">谷歌</h4>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">google.com</p>
|
||||
</div>
|
||||
</div>
|
||||
<n-button size="small" type="primary" @click="submitToGoogle">
|
||||
<template #icon>
|
||||
<i class="fas fa-paper-plane"></i>
|
||||
</template>
|
||||
提交
|
||||
</n-button>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
最后提交时间:{{ lastSubmitTime.google || '未提交' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 必应 -->
|
||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-8 h-8 bg-green-500 rounded flex items-center justify-center">
|
||||
<i class="fas fa-search text-white text-sm"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-medium text-gray-900 dark:text-white">必应</h4>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">bing.com</p>
|
||||
</div>
|
||||
</div>
|
||||
<n-button size="small" type="primary" @click="submitToBing">
|
||||
<template #icon>
|
||||
<i class="fas fa-paper-plane"></i>
|
||||
</template>
|
||||
提交
|
||||
</n-button>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
最后提交时间:{{ lastSubmitTime.bing || '未提交' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜狗 -->
|
||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-8 h-8 bg-orange-500 rounded flex items-center justify-center">
|
||||
<i class="fas fa-search text-white text-sm"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-medium text-gray-900 dark:text-white">搜狗</h4>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">sogou.com</p>
|
||||
</div>
|
||||
</div>
|
||||
<n-button size="small" type="primary" @click="submitToSogou">
|
||||
<template #icon>
|
||||
<i class="fas fa-paper-plane"></i>
|
||||
</template>
|
||||
提交
|
||||
</n-button>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
最后提交时间:{{ lastSubmitTime.sogou || '未提交' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 神马搜索 -->
|
||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-8 h-8 bg-purple-500 rounded flex items-center justify-center">
|
||||
<i class="fas fa-mobile-alt text-white text-sm"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-medium text-gray-900 dark:text-white">神马搜索</h4>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">sm.cn</p>
|
||||
</div>
|
||||
</div>
|
||||
<n-button size="small" type="primary" @click="submitToShenma">
|
||||
<template #icon>
|
||||
<i class="fas fa-paper-plane"></i>
|
||||
</template>
|
||||
提交
|
||||
</n-button>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
最后提交时间:{{ lastSubmitTime.shenma || '未提交' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 360搜索 -->
|
||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-8 h-8 bg-green-600 rounded flex items-center justify-center">
|
||||
<i class="fas fa-shield-alt text-white text-sm"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-medium text-gray-900 dark:text-white">360搜索</h4>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">so.com</p>
|
||||
</div>
|
||||
</div>
|
||||
<n-button size="small" type="primary" @click="submitTo360">
|
||||
<template #icon>
|
||||
<i class="fas fa-paper-plane"></i>
|
||||
</template>
|
||||
提交
|
||||
</n-button>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
最后提交时间:{{ lastSubmitTime.so360 || '未提交' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 批量提交 -->
|
||||
<div class="mt-6 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 class="font-medium text-blue-900 dark:text-blue-100">批量提交</h4>
|
||||
<p class="text-sm text-blue-700 dark:text-blue-300 mt-1">
|
||||
一键提交到所有支持的搜索引擎
|
||||
</p>
|
||||
</div>
|
||||
<n-button type="primary" @click="submitToAll">
|
||||
<template #icon>
|
||||
<i class="fas fa-rocket"></i>
|
||||
</template>
|
||||
批量提交
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useMessage } from 'naive-ui'
|
||||
import { ref } from 'vue'
|
||||
|
||||
// Props
|
||||
interface Props {
|
||||
lastSubmitTime: {
|
||||
baidu: string
|
||||
google: string
|
||||
bing: string
|
||||
sogou: string
|
||||
shenma: string
|
||||
so360: string
|
||||
}
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
lastSubmitTime: () => ({
|
||||
baidu: '',
|
||||
google: '',
|
||||
bing: '',
|
||||
sogou: '',
|
||||
shenma: '',
|
||||
so360: ''
|
||||
})
|
||||
})
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits<{
|
||||
'update:last-submit-time': [engine: string, time: string]
|
||||
}>()
|
||||
|
||||
// 获取消息组件
|
||||
const message = useMessage()
|
||||
|
||||
// 提交到百度
|
||||
const submitToBaidu = () => {
|
||||
const time = new Date().toLocaleString('zh-CN')
|
||||
emit('update:last-submit-time', 'baidu', time)
|
||||
message.success('已提交到百度')
|
||||
}
|
||||
|
||||
// 提交到谷歌
|
||||
const submitToGoogle = () => {
|
||||
const time = new Date().toLocaleString('zh-CN')
|
||||
emit('update:last-submit-time', 'google', time)
|
||||
message.success('已提交到谷歌')
|
||||
}
|
||||
|
||||
// 提交到必应
|
||||
const submitToBing = () => {
|
||||
const time = new Date().toLocaleString('zh-CN')
|
||||
emit('update:last-submit-time', 'bing', time)
|
||||
message.success('已提交到必应')
|
||||
}
|
||||
|
||||
// 提交到搜狗
|
||||
const submitToSogou = () => {
|
||||
const time = new Date().toLocaleString('zh-CN')
|
||||
emit('update:last-submit-time', 'sogou', time)
|
||||
message.success('已提交到搜狗')
|
||||
}
|
||||
|
||||
// 提交到神马搜索
|
||||
const submitToShenma = () => {
|
||||
const time = new Date().toLocaleString('zh-CN')
|
||||
emit('update:last-submit-time', 'shenma', time)
|
||||
message.success('已提交到神马搜索')
|
||||
}
|
||||
|
||||
// 提交到360搜索
|
||||
const submitTo360 = () => {
|
||||
const time = new Date().toLocaleString('zh-CN')
|
||||
emit('update:last-submit-time', 'so360', time)
|
||||
message.success('已提交到360搜索')
|
||||
}
|
||||
|
||||
// 批量提交
|
||||
const submitToAll = () => {
|
||||
const time = new Date().toLocaleString('zh-CN')
|
||||
const engines = ['baidu', 'google', 'bing', 'sogou', 'shenma', 'so360']
|
||||
|
||||
engines.forEach(engine => {
|
||||
emit('update:last-submit-time', engine, time)
|
||||
})
|
||||
|
||||
message.success('已批量提交到所有搜索引擎')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tab-content-container {
|
||||
height: calc(100vh - 240px);
|
||||
overflow-y: auto;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
</style>
|
||||
226
web/components/Admin/SitemapTab.vue
Normal file
226
web/components/Admin/SitemapTab.vue
Normal file
@@ -0,0 +1,226 @@
|
||||
<template>
|
||||
<div class="tab-content-container">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<!-- Sitemap配置 -->
|
||||
<div class="mb-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">Sitemap配置</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400">管理网站的Sitemap生成和配置</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h4 class="font-medium text-gray-900 dark:text-white mb-2">自动生成Sitemap</h4>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
开启后系统将定期自动生成Sitemap文件
|
||||
</p>
|
||||
</div>
|
||||
<n-switch
|
||||
v-model:value="sitemapConfig.autoGenerate"
|
||||
@update:value="updateSitemapConfig"
|
||||
:loading="configLoading"
|
||||
size="large"
|
||||
>
|
||||
<template #checked>已开启</template>
|
||||
<template #unchecked>已关闭</template>
|
||||
</n-switch>
|
||||
</div>
|
||||
|
||||
<!-- 配置详情 -->
|
||||
<div class="border-t border-gray-200 dark:border-gray-600 pt-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">站点URL</label>
|
||||
<n-input
|
||||
:value="systemConfig?.site_url || '站点URL未配置'"
|
||||
:disabled="true"
|
||||
placeholder="请先在站点配置中设置站点URL"
|
||||
>
|
||||
<template #prefix>
|
||||
<i class="fas fa-globe text-gray-400"></i>
|
||||
</template>
|
||||
</n-input>
|
||||
</div>
|
||||
<div class="flex flex-col justify-end">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">最后生成时间</label>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ sitemapConfig.lastGenerate || '尚未生成' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sitemap统计 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||
<div class="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4">
|
||||
<div class="flex items-center">
|
||||
<div class="p-2 bg-blue-100 dark:bg-blue-900 rounded-lg">
|
||||
<i class="fas fa-link text-blue-600 dark:text-blue-400"></i>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">资源总数</p>
|
||||
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ sitemapStats.total_resources || 0 }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-green-50 dark:bg-green-900/20 rounded-lg p-4">
|
||||
<div class="flex items-center">
|
||||
<div class="p-2 bg-green-100 dark:bg-green-900 rounded-lg">
|
||||
<i class="fas fa-sitemap text-green-600 dark:text-green-400"></i>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">页面数量</p>
|
||||
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ sitemapStats.total_pages || 0 }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-purple-50 dark:bg-purple-900/20 rounded-lg p-4">
|
||||
<div class="flex items-center">
|
||||
<div class="p-2 bg-purple-100 dark:bg-purple-900 rounded-lg">
|
||||
<i class="fas fa-history text-purple-600 dark:text-purple-400"></i>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">最后更新</p>
|
||||
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ sitemapStats.last_generate || 'N/A' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="flex flex-wrap gap-3 mb-6">
|
||||
<n-button
|
||||
type="primary"
|
||||
@click="generateSitemap"
|
||||
:loading="isGenerating"
|
||||
size="large"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="fas fa-cog"></i>
|
||||
</template>
|
||||
生成Sitemap
|
||||
</n-button>
|
||||
|
||||
<n-button
|
||||
type="success"
|
||||
@click="viewSitemap"
|
||||
size="large"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="fas fa-external-link-alt"></i>
|
||||
</template>
|
||||
查看Sitemap
|
||||
</n-button>
|
||||
|
||||
<n-button
|
||||
type="info"
|
||||
@click="$emit('refresh-status')"
|
||||
size="large"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="fas fa-sync-alt"></i>
|
||||
</template>
|
||||
刷新状态
|
||||
</n-button>
|
||||
</div>
|
||||
|
||||
<!-- 生成状态 -->
|
||||
<div v-if="generateStatus" class="mb-4 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-info-circle text-blue-500 dark:text-blue-400 mr-2"></i>
|
||||
<span class="text-blue-700 dark:text-blue-300">{{ generateStatus }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useMessage } from 'naive-ui'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
import { ref } from 'vue'
|
||||
|
||||
// Props
|
||||
interface Props {
|
||||
systemConfig?: any
|
||||
sitemapConfig: any
|
||||
sitemapStats: any
|
||||
configLoading: boolean
|
||||
isGenerating: boolean
|
||||
generateStatus: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
systemConfig: null,
|
||||
sitemapConfig: () => ({}),
|
||||
sitemapStats: () => ({}),
|
||||
configLoading: false,
|
||||
isGenerating: false,
|
||||
generateStatus: ''
|
||||
})
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits<{
|
||||
'update:sitemap-config': [value: boolean]
|
||||
'refresh-status': []
|
||||
}>()
|
||||
|
||||
// 获取消息组件
|
||||
const message = useMessage()
|
||||
|
||||
// 更新Sitemap配置
|
||||
const updateSitemapConfig = async (value: boolean) => {
|
||||
try {
|
||||
const api = useApi()
|
||||
await api.sitemapApi.updateSitemapConfig({
|
||||
autoGenerate: value,
|
||||
lastGenerate: props.sitemapConfig.lastGenerate,
|
||||
lastUpdate: new Date().toISOString()
|
||||
})
|
||||
message.success(value ? '自动生成功能已开启' : '自动生成功能已关闭')
|
||||
} catch (error) {
|
||||
message.error('更新配置失败')
|
||||
// 恢复之前的值
|
||||
props.sitemapConfig.autoGenerate = !value
|
||||
}
|
||||
}
|
||||
|
||||
// 生成Sitemap
|
||||
const generateSitemap = async () => {
|
||||
// 使用已经加载的系统配置
|
||||
const siteUrl = props.systemConfig?.site_url || ''
|
||||
if (!siteUrl) {
|
||||
message.warning('请先在站点配置中设置站点URL,然后再生成Sitemap')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const api = useApi()
|
||||
const response = await api.sitemapApi.generateSitemap({ site_url: siteUrl })
|
||||
|
||||
if (response) {
|
||||
message.success(`Sitemap生成任务已启动,使用站点URL: ${siteUrl}`)
|
||||
// 更新统计信息
|
||||
emit('refresh-status')
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error('Sitemap生成失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 查看Sitemap
|
||||
const viewSitemap = () => {
|
||||
window.open('/sitemap.xml', '_blank')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tab-content-container {
|
||||
height: calc(100vh - 240px);
|
||||
overflow-y: auto;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<div v-if="shouldShowAnnouncement" class="announcement-container px-3 py-1">
|
||||
<div class="flex items-center justify-between min-h-[24px]">
|
||||
<!-- 桌面端:显示完整公告内容 -->
|
||||
<div v-if="!isMobile" class="flex items-center justify-between min-h-[24px]">
|
||||
<div class="flex items-center gap-2 flex-1 overflow-hidden">
|
||||
<i class="fas fa-bullhorn text-blue-600 dark:text-blue-400 text-sm flex-shrink-0"></i>
|
||||
<div class="announcement-content overflow-hidden">
|
||||
@@ -16,6 +17,27 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 移动端:使用 Marquee 滚动显示 -->
|
||||
<div v-else class="flex items-center gap-2 min-h-[24px]">
|
||||
<i class="fas fa-bullhorn text-blue-600 dark:text-blue-400 text-sm flex-shrink-0"></i>
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<n-marquee
|
||||
:speed="30"
|
||||
:delay="0"
|
||||
:loop="true"
|
||||
:auto-play="true"
|
||||
:pause-on-hover="true"
|
||||
>
|
||||
<span
|
||||
v-for="(announcement, index) in validAnnouncements"
|
||||
:key="index"
|
||||
class="text-sm text-gray-700 dark:text-gray-300 inline-block mx-4"
|
||||
v-html="announcement.content"
|
||||
></span>
|
||||
</n-marquee>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -31,6 +53,25 @@ interface AnnouncementItem {
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
// 移动端检测
|
||||
const isMobile = ref(false)
|
||||
|
||||
// 检测是否为移动端
|
||||
const checkMobile = () => {
|
||||
if (process.client) {
|
||||
// 检测屏幕宽度
|
||||
isMobile.value = window.innerWidth < 768
|
||||
|
||||
// 也可以使用用户代理检测
|
||||
const userAgent = navigator.userAgent.toLowerCase()
|
||||
const mobileKeywords = ['android', 'iphone', 'ipad', 'ipod', 'blackberry', 'windows phone']
|
||||
const isMobileDevice = mobileKeywords.some(keyword => userAgent.includes(keyword))
|
||||
|
||||
// 结合屏幕宽度和设备类型判断
|
||||
isMobile.value = isMobile.value || isMobileDevice
|
||||
}
|
||||
}
|
||||
|
||||
const currentIndex = ref(0)
|
||||
const interval = ref<NodeJS.Timeout | null>(null)
|
||||
|
||||
@@ -66,12 +107,14 @@ const nextAnnouncement = () => {
|
||||
currentIndex.value = (currentIndex.value + 1) % validAnnouncements.value.length
|
||||
}
|
||||
|
||||
// 监听公告数据变化,重新开始自动切换
|
||||
// 监听公告数据变化,重新开始自动切换(仅桌面端)
|
||||
watch(() => validAnnouncements.value.length, (newLength) => {
|
||||
if (newLength > 0) {
|
||||
currentIndex.value = 0
|
||||
stopAutoSwitch()
|
||||
startAutoSwitch()
|
||||
if (!isMobile.value) {
|
||||
startAutoSwitch()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -84,13 +127,27 @@ const stopAutoSwitch = () => {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (shouldShowAnnouncement.value) {
|
||||
// 初始化移动端检测
|
||||
checkMobile()
|
||||
|
||||
// 监听窗口大小变化
|
||||
if (process.client) {
|
||||
window.addEventListener('resize', checkMobile)
|
||||
}
|
||||
|
||||
if (shouldShowAnnouncement.value && !isMobile.value) {
|
||||
// 桌面端才启动自动切换
|
||||
startAutoSwitch()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopAutoSwitch()
|
||||
|
||||
// 清理事件监听器
|
||||
if (process.client) {
|
||||
window.removeEventListener('resize', checkMobile)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -111,8 +168,31 @@ onUnmounted(() => {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* 移动端 Marquee 样式优化 */
|
||||
@media (max-width: 767px) {
|
||||
.announcement-container {
|
||||
background: linear-gradient(90deg, transparent 0%, rgba(59, 130, 246, 0.05) 50%, transparent 100%);
|
||||
border-radius: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Marquee 内文字样式 */
|
||||
:deep(.n-marquee) {
|
||||
--n-bezier: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
:deep(.n-marquee__content) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 20px;
|
||||
}
|
||||
|
||||
/* 暗色主题适配 */
|
||||
.dark-theme .announcement-container {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.dark .announcement-container {
|
||||
background: linear-gradient(90deg, transparent 0%, rgba(59, 130, 246, 0.1) 50%, transparent 100%);
|
||||
}
|
||||
</style>
|
||||
293
web/components/CopyrightModal.vue
Normal file
293
web/components/CopyrightModal.vue
Normal file
@@ -0,0 +1,293 @@
|
||||
<template>
|
||||
<n-modal
|
||||
:show="visible"
|
||||
@update:show="handleClose"
|
||||
:mask-closable="true"
|
||||
preset="card"
|
||||
title="版权申述"
|
||||
class="max-w-lg w-full"
|
||||
:style="{ maxWidth: '95vw' }"
|
||||
>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-400/30 rounded-lg p-4">
|
||||
<div class="flex items-start gap-2">
|
||||
<i class="fas fa-info-circle text-blue-600 dark:text-blue-400 mt-0.5"></i>
|
||||
<div class="text-sm text-blue-800 dark:text-blue-200">
|
||||
<p class="font-medium mb-1">版权申述说明:</p>
|
||||
<ul class="space-y-1 text-xs">
|
||||
<li>• 请确保您是版权所有者或授权代表</li>
|
||||
<li>• 提供真实准确的版权证明材料</li>
|
||||
<li>• 虚假申述可能承担法律责任</li>
|
||||
<li>• 我们会在收到申述后及时处理</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<n-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="rules"
|
||||
label-placement="top"
|
||||
require-mark-placement="right-hanging"
|
||||
>
|
||||
<n-form-item label="申述人身份" path="identity">
|
||||
<n-select
|
||||
v-model:value="formData.identity"
|
||||
:options="identityOptions"
|
||||
placeholder="请选择您的身份"
|
||||
:loading="loading"
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="权利证明" path="proof_type">
|
||||
<n-select
|
||||
v-model:value="formData.proof_type"
|
||||
:options="proofOptions"
|
||||
placeholder="请选择权利证明类型"
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="版权证明文件" path="proof_files">
|
||||
<n-upload
|
||||
v-model:file-list="formData.proof_files"
|
||||
:max="5"
|
||||
:default-upload="false"
|
||||
accept=".pdf,.jpg,.jpeg,.png,.gif"
|
||||
@change="handleFileChange"
|
||||
>
|
||||
<n-upload-dragger>
|
||||
<div class="text-center">
|
||||
<i class="fas fa-cloud-upload-alt text-4xl text-gray-400 mb-2"></i>
|
||||
<p class="text-gray-600 dark:text-gray-300">
|
||||
点击或拖拽上传版权证明文件
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
支持 PDF、JPG、PNG 格式,最多5个文件
|
||||
</p>
|
||||
</div>
|
||||
</n-upload-dragger>
|
||||
</n-upload>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="申述理由" path="reason">
|
||||
<n-input
|
||||
v-model:value="formData.reason"
|
||||
type="textarea"
|
||||
placeholder="请详细说明版权申述理由,包括具体的侵权情况..."
|
||||
:autosize="{ minRows: 4, maxRows: 8 }"
|
||||
maxlength="1000"
|
||||
show-count
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="联系信息" path="contact_info">
|
||||
<n-input
|
||||
v-model:value="formData.contact_info"
|
||||
placeholder="请提供有效的联系方式(邮箱/电话),以便我们与您联系"
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="申述人姓名" path="claimant_name">
|
||||
<n-input
|
||||
v-model:value="formData.claimant_name"
|
||||
placeholder="请填写申述人真实姓名或公司名称"
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item>
|
||||
<n-checkbox v-model:checked="formData.agreement">
|
||||
我确认以上信息真实有效,并承担相应的法律责任
|
||||
</n-checkbox>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
|
||||
<div class="text-xs text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-500/10 rounded p-3">
|
||||
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||||
请谨慎提交版权申述,虚假申述可能承担法律责任
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<n-button @click="handleClose" :disabled="submitting">
|
||||
取消
|
||||
</n-button>
|
||||
<n-button
|
||||
type="primary"
|
||||
:loading="submitting"
|
||||
:disabled="!formData.agreement"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
提交申述
|
||||
</n-button>
|
||||
</div>
|
||||
</template>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useMessage } from 'naive-ui'
|
||||
import { useResourceApi } from '~/composables/useApi'
|
||||
|
||||
const resourceApi = useResourceApi()
|
||||
|
||||
interface Props {
|
||||
visible: boolean
|
||||
resourceKey: string
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'close'): void
|
||||
(e: 'submitted'): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
// 表单数据
|
||||
const formData = ref({
|
||||
identity: '',
|
||||
proof_type: '',
|
||||
proof_files: [],
|
||||
reason: '',
|
||||
contact_info: '',
|
||||
claimant_name: '',
|
||||
agreement: false
|
||||
})
|
||||
|
||||
// 表单引用
|
||||
const formRef = ref()
|
||||
|
||||
// 状态
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
|
||||
// 身份选项
|
||||
const identityOptions = [
|
||||
{ label: '版权所有者', value: 'copyright_owner' },
|
||||
{ label: '授权代表', value: 'authorized_agent' },
|
||||
{ label: '律师事务所', value: 'law_firm' },
|
||||
{ label: '其他', value: 'other' }
|
||||
]
|
||||
|
||||
// 证明类型选项
|
||||
const proofOptions = [
|
||||
{ label: '版权登记证书', value: 'copyright_certificate' },
|
||||
{ label: '作品首发证明', value: 'first_publish_proof' },
|
||||
{ label: '授权委托书', value: 'authorization_letter' },
|
||||
{ label: '身份证明文件', value: 'identity_document' },
|
||||
{ label: '其他证明材料', value: 'other_proof' }
|
||||
]
|
||||
|
||||
// 表单验证规则
|
||||
const rules = {
|
||||
identity: {
|
||||
required: true,
|
||||
message: '请选择申述人身份',
|
||||
trigger: ['blur', 'change']
|
||||
},
|
||||
proof_type: {
|
||||
required: true,
|
||||
message: '请选择权利证明类型',
|
||||
trigger: ['blur', 'change']
|
||||
},
|
||||
reason: {
|
||||
required: true,
|
||||
message: '请详细说明申述理由',
|
||||
trigger: 'blur'
|
||||
},
|
||||
contact_info: {
|
||||
required: true,
|
||||
message: '请提供联系信息',
|
||||
trigger: 'blur'
|
||||
},
|
||||
claimant_name: {
|
||||
required: true,
|
||||
message: '请填写申述人姓名',
|
||||
trigger: 'blur'
|
||||
}
|
||||
}
|
||||
|
||||
// 处理文件变化
|
||||
const handleFileChange = (options: any) => {
|
||||
console.log('文件变化:', options)
|
||||
}
|
||||
|
||||
// 关闭模态框
|
||||
const handleClose = () => {
|
||||
if (!submitting.value) {
|
||||
emit('close')
|
||||
resetForm()
|
||||
}
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
formData.value = {
|
||||
identity: '',
|
||||
proof_type: '',
|
||||
proof_files: [],
|
||||
reason: '',
|
||||
contact_info: '',
|
||||
claimant_name: '',
|
||||
agreement: false
|
||||
}
|
||||
formRef.value?.restoreValidation()
|
||||
}
|
||||
|
||||
// 提交申述
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
|
||||
if (!formData.value.agreement) {
|
||||
message.warning('请确认申述信息真实有效并承担相应法律责任')
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
|
||||
// 构建证明文件数组(从文件列表转换为字符串)
|
||||
const proofFilesArray = formData.value.proof_files.map((file: any) => ({
|
||||
id: file.id,
|
||||
name: file.name,
|
||||
status: file.status,
|
||||
percentage: file.percentage
|
||||
}))
|
||||
|
||||
// 调用实际的版权申述API
|
||||
const copyrightData = {
|
||||
resource_key: props.resourceKey,
|
||||
identity: formData.value.identity,
|
||||
proof_type: formData.value.proof_type,
|
||||
reason: formData.value.reason,
|
||||
contact_info: formData.value.contact_info,
|
||||
claimant_name: formData.value.claimant_name,
|
||||
proof_files: JSON.stringify(proofFilesArray), // 将文件信息转换为JSON字符串
|
||||
user_agent: navigator.userAgent,
|
||||
ip_address: '' // 服务端获取IP
|
||||
}
|
||||
|
||||
const result = await resourceApi.submitCopyrightClaim(copyrightData)
|
||||
console.log('版权申述提交结果:', result)
|
||||
|
||||
message.success('版权申述提交成功,我们会在24小时内处理并回复')
|
||||
emit('submitted') // 发送提交事件
|
||||
} catch (error: any) {
|
||||
console.error('提交版权申述失败:', error)
|
||||
let errorMessage = '提交失败,请重试'
|
||||
if (error && typeof error === 'object' && error.data) {
|
||||
errorMessage = error.data.message || errorMessage
|
||||
} else if (error && typeof error === 'object' && error.message) {
|
||||
errorMessage = error.message
|
||||
}
|
||||
message.error(errorMessage)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
186
web/components/ReportModal.vue
Normal file
186
web/components/ReportModal.vue
Normal file
@@ -0,0 +1,186 @@
|
||||
<template>
|
||||
<n-modal
|
||||
:show="visible"
|
||||
@update:show="handleClose"
|
||||
:mask-closable="true"
|
||||
preset="card"
|
||||
title="举报资源失效"
|
||||
class="max-w-md w-full"
|
||||
:style="{ maxWidth: '90vw' }"
|
||||
>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="text-gray-600 dark:text-gray-300 text-sm">
|
||||
请选择举报原因,我们会尽快核实处理:
|
||||
</div>
|
||||
|
||||
<n-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="rules"
|
||||
label-placement="top"
|
||||
require-mark-placement="right-hanging"
|
||||
>
|
||||
<n-form-item label="举报原因" path="reason">
|
||||
<n-select
|
||||
v-model:value="formData.reason"
|
||||
:options="reasonOptions"
|
||||
placeholder="请选择举报原因"
|
||||
:loading="loading"
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="详细描述" path="description">
|
||||
<n-input
|
||||
v-model:value="formData.description"
|
||||
type="textarea"
|
||||
placeholder="请详细描述问题,帮助我们更好地处理..."
|
||||
:autosize="{ minRows: 3, maxRows: 6 }"
|
||||
maxlength="500"
|
||||
show-count
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="联系方式(选填)" path="contact">
|
||||
<n-input
|
||||
v-model:value="formData.contact"
|
||||
placeholder="邮箱或手机号,便于我们反馈处理结果"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
我们承诺保护您的隐私,举报信息仅用于核实处理
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<n-button @click="handleClose" :disabled="submitting">
|
||||
取消
|
||||
</n-button>
|
||||
<n-button
|
||||
type="primary"
|
||||
:loading="submitting"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
提交举报
|
||||
</n-button>
|
||||
</div>
|
||||
</template>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useMessage } from 'naive-ui'
|
||||
import { useResourceApi } from '~/composables/useApi'
|
||||
|
||||
const resourceApi = useResourceApi()
|
||||
|
||||
interface Props {
|
||||
visible: boolean
|
||||
resourceKey: string
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'close'): void
|
||||
(e: 'submitted'): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
// 表单数据
|
||||
const formData = ref({
|
||||
reason: '',
|
||||
description: '',
|
||||
contact: ''
|
||||
})
|
||||
|
||||
// 表单引用
|
||||
const formRef = ref()
|
||||
|
||||
// 状态
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
|
||||
// 举报原因选项
|
||||
const reasonOptions = [
|
||||
{ label: '链接已失效', value: 'link_invalid' },
|
||||
{ label: '资源无法下载', value: 'download_failed' },
|
||||
{ label: '资源内容不符', value: 'content_mismatch' },
|
||||
{ label: '包含恶意软件', value: 'malicious' },
|
||||
{ label: '版权问题', value: 'copyright' },
|
||||
{ label: '其他问题', value: 'other' }
|
||||
]
|
||||
|
||||
// 表单验证规则
|
||||
const rules = {
|
||||
reason: {
|
||||
required: true,
|
||||
message: '请选择举报原因',
|
||||
trigger: ['blur', 'change']
|
||||
},
|
||||
description: {
|
||||
required: true,
|
||||
message: '请详细描述问题',
|
||||
trigger: 'blur'
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭模态框
|
||||
const handleClose = () => {
|
||||
if (!submitting.value) {
|
||||
emit('close')
|
||||
resetForm()
|
||||
}
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
formData.value = {
|
||||
reason: '',
|
||||
description: '',
|
||||
contact: ''
|
||||
}
|
||||
formRef.value?.restoreValidation()
|
||||
}
|
||||
|
||||
// 提交举报
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
submitting.value = true
|
||||
|
||||
// 调用实际的举报API
|
||||
const reportData = {
|
||||
resource_key: props.resourceKey,
|
||||
reason: formData.value.reason,
|
||||
description: formData.value.description,
|
||||
contact: formData.value.contact,
|
||||
user_agent: navigator.userAgent,
|
||||
ip_address: '' // 服务端获取IP
|
||||
}
|
||||
|
||||
const result = await resourceApi.submitReport(reportData)
|
||||
console.log('举报提交结果:', result)
|
||||
|
||||
message.success('举报提交成功,我们会尽快核实处理')
|
||||
emit('submitted') // 发送提交事件
|
||||
} catch (error: any) {
|
||||
console.error('提交举报失败:', error)
|
||||
let errorMessage = '提交失败,请重试'
|
||||
if (error && typeof error === 'object' && error.data) {
|
||||
errorMessage = error.data.message || errorMessage
|
||||
} else if (error && typeof error === 'object' && error.message) {
|
||||
errorMessage = error.message
|
||||
}
|
||||
message.error(errorMessage)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
46
web/components/SearchButton.vue
Normal file
46
web/components/SearchButton.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<!-- 搜索按钮组件 -->
|
||||
<div class="search-button-container">
|
||||
<!-- 搜索按钮 -->
|
||||
<n-button
|
||||
size="tiny"
|
||||
type="tertiary"
|
||||
round
|
||||
ghost
|
||||
class="!px-2 !py-1 !text-xs !text-white dark:!text-white !border-white/30 hover:!border-white"
|
||||
@click="openSearch"
|
||||
>
|
||||
<i class="fas fa-search text-xs"></i>
|
||||
<span class="ml-1 hidden sm:inline">搜索</span>
|
||||
</n-button>
|
||||
|
||||
<!-- 完整的搜索弹窗组件 -->
|
||||
<SearchModal ref="searchModalRef" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import SearchModal from './SearchModal.vue'
|
||||
|
||||
// 搜索弹窗的引用
|
||||
const searchModalRef = ref()
|
||||
|
||||
// 打开搜索弹窗
|
||||
const openSearch = () => {
|
||||
searchModalRef.value?.show()
|
||||
}
|
||||
|
||||
// 暴露给父组件的方法
|
||||
defineExpose({
|
||||
openSearch,
|
||||
closeSearch: () => searchModalRef.value?.hide(),
|
||||
toggleSearch: () => searchModalRef.value?.toggle()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.search-button-container {
|
||||
display: inline-block;
|
||||
}
|
||||
</style>
|
||||
423
web/components/SearchModal.vue
Normal file
423
web/components/SearchModal.vue
Normal file
@@ -0,0 +1,423 @@
|
||||
<template>
|
||||
<ClientOnly>
|
||||
<!-- 自定义背景遮罩 -->
|
||||
<div
|
||||
v-if="visible"
|
||||
class="fixed inset-0 z-50 flex items-start justify-center pt-[20vh]"
|
||||
@click="handleBackdropClick"
|
||||
>
|
||||
<!-- 背景模糊遮罩 -->
|
||||
<div class="absolute inset-0 bg-black/20 backdrop-blur-sm"></div>
|
||||
|
||||
<!-- 搜索弹窗 -->
|
||||
<div
|
||||
class="relative w-full max-w-2xl mx-4 transform transition-all duration-200 ease-out"
|
||||
:class="visible ? 'scale-100 opacity-100' : 'scale-95 opacity-0'"
|
||||
@click.stop
|
||||
>
|
||||
<!-- 搜索输入区域 -->
|
||||
<div class="relative bg-white dark:bg-gray-900 rounded-2xl shadow-2xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<!-- 顶部装饰条 -->
|
||||
<div class="h-1 bg-gradient-to-r from-green-500 via-emerald-500 to-teal-500"></div>
|
||||
|
||||
<!-- 搜索输入框 -->
|
||||
<div class="relative px-6 py-5">
|
||||
<div class="relative flex items-center">
|
||||
<!-- 搜索图标 -->
|
||||
<div class="absolute left-4 flex items-center pointer-events-none">
|
||||
<div class="w-5 h-5 rounded-full bg-gradient-to-r from-green-500 to-emerald-500 flex items-center justify-center">
|
||||
<svg class="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 输入框 -->
|
||||
<input
|
||||
ref="searchInput"
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="搜索资源..."
|
||||
class="w-full pl-12 pr-32 py-4 bg-transparent border-0 text-lg text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-0"
|
||||
@keyup.enter="handleSearch"
|
||||
@input="handleInputChange"
|
||||
@keydown.escape="handleClose"
|
||||
>
|
||||
|
||||
<!-- 搜索按钮 -->
|
||||
<div class="absolute right-2 flex items-center gap-2">
|
||||
<button
|
||||
v-if="searchQuery.trim()"
|
||||
type="button"
|
||||
@click="clearSearch"
|
||||
class="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="handleSearch"
|
||||
:disabled="!searchQuery.trim()"
|
||||
:loading="searching"
|
||||
class="px-4 py-2 bg-gradient-to-r from-green-500 to-emerald-500 text-white text-sm font-medium rounded-lg hover:from-green-600 hover:to-emerald-600 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 transform hover:scale-105"
|
||||
>
|
||||
<span v-if="!searching" class="flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||
</svg>
|
||||
搜索
|
||||
</span>
|
||||
<span v-else class="flex items-center gap-2">
|
||||
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
搜索中
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索建议下拉 -->
|
||||
<div v-if="showSuggestions && suggestions.length > 0" class="border-t border-gray-200 dark:border-gray-700">
|
||||
<div class="max-h-60 overflow-y-auto">
|
||||
<div class="px-6 py-3">
|
||||
<div class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">搜索建议</div>
|
||||
<div class="space-y-1">
|
||||
<button
|
||||
v-for="(suggestion, index) in suggestions"
|
||||
:key="index"
|
||||
@click="selectSuggestion(suggestion)"
|
||||
class="w-full flex items-center gap-3 px-3 py-2.5 text-left rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors group"
|
||||
>
|
||||
<div class="w-8 h-8 rounded-lg bg-gradient-to-r from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/20 flex items-center justify-center group-hover:scale-110 transition-transform">
|
||||
<svg class="w-4 h-4 text-green-500 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">{{ suggestion }}</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">点击搜索 "{{ suggestion }}"</div>
|
||||
</div>
|
||||
<svg class="w-4 h-4 text-gray-400 group-hover:text-gray-600 dark:group-hover:text-gray-300 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索历史 -->
|
||||
<div v-if="searchHistory.length > 0" class="border-t border-gray-200 dark:border-gray-700">
|
||||
<div class="px-6 py-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-6 h-6 rounded-lg bg-gray-100 dark:bg-gray-800 flex items-center justify-center">
|
||||
<svg class="w-3 h-3 text-gray-500 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-sm font-semibold text-gray-700 dark:text-gray-300">最近搜索</span>
|
||||
</div>
|
||||
<button
|
||||
@click="clearHistory"
|
||||
class="text-xs text-gray-500 hover:text-red-500 dark:text-gray-400 dark:hover:text-red-400 transition-colors"
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="(item, index) in searchHistory.slice(0, 8)"
|
||||
:key="index"
|
||||
@click="selectHistory(item)"
|
||||
class="inline-flex items-center gap-2 px-3 py-1.5 text-sm bg-gray-50 dark:bg-gray-800 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-all duration-200 group"
|
||||
>
|
||||
<svg class="w-3 h-3 text-gray-400 group-hover:text-gray-600 dark:group-hover:text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
{{ item }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索提示 -->
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 bg-gradient-to-r from-green-50 via-emerald-50 to-teal-50 dark:from-green-900/20 dark:via-emerald-900/20 dark:to-teal-900/20">
|
||||
<div class="px-6 py-4">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<div class="w-8 h-8 rounded-full bg-white dark:bg-gray-800 shadow-sm flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-green-500 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">搜索技巧</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-2 text-xs text-gray-600 dark:text-gray-400">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-green-400"></span>
|
||||
<span>支持多关键词搜索,用空格分隔</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-emerald-400"></span>
|
||||
<span>按 <kbd class="px-1.5 py-0.5 text-xs bg-white dark:bg-gray-700 rounded border border-gray-300 dark:border-gray-600 font-mono">Ctrl+K</kbd> 快速打开</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-teal-400"></span>
|
||||
<span>按 <kbd class="px-1.5 py-0.5 text-xs bg-white dark:bg-gray-700 rounded border border-gray-300 dark:border-gray-600 font-mono">Esc</kbd> 关闭弹窗</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-green-400"></span>
|
||||
<span>搜索历史自动保存,方便下次使用</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ClientOnly>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
// 组件状态 - 完全内部管理
|
||||
const visible = ref(false)
|
||||
const searchInput = ref<any>(null)
|
||||
const searchQuery = ref('')
|
||||
const searching = ref(false)
|
||||
const showSuggestions = ref(false)
|
||||
const searchHistory = ref<string[]>([])
|
||||
|
||||
// 路由器
|
||||
const router = useRouter()
|
||||
|
||||
// 计算属性
|
||||
const suggestions = computed(() => {
|
||||
if (!searchQuery.value.trim()) return []
|
||||
|
||||
const query = searchQuery.value.toLowerCase().trim()
|
||||
|
||||
return searchHistory.value
|
||||
.filter(item => item.toLowerCase().includes(query))
|
||||
.filter(item => item.toLowerCase() !== query)
|
||||
.slice(0, 5)
|
||||
})
|
||||
|
||||
// 初始化搜索历史
|
||||
const initSearchHistory = () => {
|
||||
if (process.client && typeof localStorage !== 'undefined') {
|
||||
const history = localStorage.getItem('searchHistory')
|
||||
if (history) {
|
||||
try {
|
||||
searchHistory.value = JSON.parse(history)
|
||||
} catch (e) {
|
||||
searchHistory.value = []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 保存搜索历史
|
||||
const saveSearchHistory = () => {
|
||||
if (process.client && typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem('searchHistory', JSON.stringify(searchHistory.value))
|
||||
}
|
||||
}
|
||||
|
||||
// 处理输入变化
|
||||
const handleInputChange = () => {
|
||||
showSuggestions.value = searchQuery.value.trim().length > 0
|
||||
}
|
||||
|
||||
// 处理搜索
|
||||
const handleSearch = () => {
|
||||
const query = searchQuery.value.trim()
|
||||
if (!query) return
|
||||
|
||||
searching.value = true
|
||||
|
||||
// 添加到搜索历史
|
||||
if (!searchHistory.value.includes(query)) {
|
||||
searchHistory.value.unshift(query)
|
||||
if (searchHistory.value.length > 10) {
|
||||
searchHistory.value = searchHistory.value.slice(0, 10)
|
||||
}
|
||||
saveSearchHistory()
|
||||
}
|
||||
|
||||
// 关闭弹窗
|
||||
visible.value = false
|
||||
|
||||
// 跳转到搜索页面
|
||||
nextTick(() => {
|
||||
router.push(`/?search=${encodeURIComponent(query)}`)
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
searching.value = false
|
||||
}, 500)
|
||||
}
|
||||
|
||||
// 清空搜索
|
||||
const clearSearch = () => {
|
||||
searchQuery.value = ''
|
||||
showSuggestions.value = false
|
||||
nextTick(() => {
|
||||
searchInput.value?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
// 选择搜索建议
|
||||
const selectSuggestion = (suggestion: string) => {
|
||||
searchQuery.value = suggestion
|
||||
showSuggestions.value = false
|
||||
nextTick(() => {
|
||||
searchInput.value?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
// 选择历史记录
|
||||
const selectHistory = (item: string) => {
|
||||
searchQuery.value = item
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
// 清空历史
|
||||
const clearHistory = () => {
|
||||
searchHistory.value = []
|
||||
saveSearchHistory()
|
||||
}
|
||||
|
||||
// 处理背景点击
|
||||
const handleBackdropClick = () => {
|
||||
handleClose()
|
||||
}
|
||||
|
||||
// 处理关闭
|
||||
const handleClose = () => {
|
||||
visible.value = false
|
||||
searchQuery.value = ''
|
||||
showSuggestions.value = false
|
||||
}
|
||||
|
||||
// 监听弹窗显示状态
|
||||
watch(visible, (newValue) => {
|
||||
if (newValue && process.client) {
|
||||
nextTick(() => {
|
||||
searchInput.value?.focus()
|
||||
initSearchHistory()
|
||||
})
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
searchQuery.value = ''
|
||||
showSuggestions.value = false
|
||||
}, 300)
|
||||
}
|
||||
})
|
||||
|
||||
// 键盘事件监听
|
||||
const handleKeydown = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||
e.preventDefault()
|
||||
if (!visible.value) {
|
||||
visible.value = true
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === 'Escape' && visible.value) {
|
||||
handleClose()
|
||||
}
|
||||
}
|
||||
|
||||
// 组件挂载时添加键盘事件监听器
|
||||
onMounted(() => {
|
||||
if (process.client && typeof document !== 'undefined') {
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
}
|
||||
})
|
||||
|
||||
// 组件卸载时清理事件监听器
|
||||
onUnmounted(() => {
|
||||
if (process.client && typeof document !== 'undefined') {
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
}
|
||||
})
|
||||
|
||||
// 暴露给父组件的方法
|
||||
defineExpose({
|
||||
show: () => { visible.value = true },
|
||||
hide: () => { handleClose() },
|
||||
toggle: () => { visible.value = !visible.value }
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 自定义动画 */
|
||||
.transform {
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
.overflow-y-auto::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar-thumb {
|
||||
background: rgba(156, 163, 175, 0.3);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(156, 163, 175, 0.5);
|
||||
}
|
||||
|
||||
/* 深色模式滚动条 */
|
||||
.dark .overflow-y-auto::-webkit-scrollbar-thumb {
|
||||
background: rgba(75, 85, 99, 0.3);
|
||||
}
|
||||
|
||||
.dark .overflow-y-auto::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(75, 85, 99, 0.5);
|
||||
}
|
||||
|
||||
/* 键盘快捷键样式 */
|
||||
kbd {
|
||||
box-shadow: 0 1px 0 1px rgba(0, 0, 0, 0.1), 0 1px 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* 按钮悬停效果 */
|
||||
button {
|
||||
transition: all 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
/* 输入框聚焦效果 */
|
||||
input:focus {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* 渐变动画 */
|
||||
@keyframes gradient {
|
||||
0%, 100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.bg-gradient-to-r {
|
||||
background-size: 200% 200%;
|
||||
animation: gradient 3s ease infinite;
|
||||
}
|
||||
</style>
|
||||
204
web/components/SystemConfigCacheInfo.vue
Normal file
204
web/components/SystemConfigCacheInfo.vue
Normal file
@@ -0,0 +1,204 @@
|
||||
<template>
|
||||
<div v-if="showCacheInfo && isClient" class="fixed bottom-4 right-4 z-50 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 p-4 max-w-sm">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100">系统配置缓存状态</h3>
|
||||
<button
|
||||
@click="showCacheInfo = false"
|
||||
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 text-xs">
|
||||
<!-- 初始化状态 -->
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-400">初始化状态:</span>
|
||||
<span :class="status.initialized ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'">
|
||||
{{ status.initialized ? '已初始化' : '未初始化' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-400">加载状态:</span>
|
||||
<span :class="status.isLoading ? 'text-blue-600 dark:text-blue-400' : 'text-gray-600 dark:text-gray-400'">
|
||||
{{ status.isLoading ? '加载中...' : '空闲' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 缓存状态 -->
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-400">缓存状态:</span>
|
||||
<span :class="status.isCacheValid ? 'text-green-600 dark:text-green-400' : 'text-orange-600 dark:text-orange-400'">
|
||||
{{ status.isCacheValid ? '有效' : '无效/过期' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 缓存剩余时间 -->
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-400">缓存剩余:</span>
|
||||
<span class="text-gray-900 dark:text-gray-100">
|
||||
{{ formatTime(status.cacheTimeRemaining) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 最后获取时间 -->
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-400">最后更新:</span>
|
||||
<span class="text-gray-900 dark:text-gray-100">
|
||||
{{ formatLastFetch(status.lastFetchTime) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 错误信息 -->
|
||||
<div v-if="status.error" class="flex justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-400">错误:</span>
|
||||
<span class="text-red-600 dark:text-red-400">
|
||||
{{ status.error }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="mt-4 flex gap-2">
|
||||
<button
|
||||
@click="refreshCache"
|
||||
:disabled="status.isLoading"
|
||||
class="flex-1 px-2 py-1 text-xs bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<i class="fas fa-sync-alt mr-1"></i>
|
||||
刷新
|
||||
</button>
|
||||
<button
|
||||
@click="clearCache"
|
||||
class="flex-1 px-2 py-1 text-xs bg-red-500 text-white rounded hover:bg-red-600"
|
||||
>
|
||||
<i class="fas fa-trash mr-1"></i>
|
||||
清除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 浮动按钮(仅在开发环境和客户端显示) -->
|
||||
<button
|
||||
v-if="isDev && isClient"
|
||||
@click="showCacheInfo = !showCacheInfo"
|
||||
class="fixed bottom-4 right-4 z-40 w-12 h-12 bg-purple-500 text-white rounded-full shadow-lg hover:bg-purple-600 transition-colors flex items-center justify-center"
|
||||
title="系统配置缓存信息"
|
||||
>
|
||||
<i class="fas fa-database"></i>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useSystemConfigStore } from '~/stores/systemConfig'
|
||||
|
||||
const systemConfigStore = useSystemConfigStore()
|
||||
const showCacheInfo = ref(false)
|
||||
|
||||
// 检查是否为开发环境
|
||||
const isDev = computed(() => {
|
||||
return process.env.NODE_ENV === 'development'
|
||||
})
|
||||
|
||||
// 检查是否为客户端
|
||||
const isClient = computed(() => {
|
||||
return process.client
|
||||
})
|
||||
|
||||
// 获取状态信息 - 直接访问store的响应式状态以确保正确更新
|
||||
const status = computed(() => ({
|
||||
initialized: systemConfigStore.initialized,
|
||||
isLoading: systemConfigStore.isLoading,
|
||||
error: systemConfigStore.error,
|
||||
lastFetchTime: systemConfigStore.lastFetchTime,
|
||||
cacheTimeRemaining: systemConfigStore.cacheTimeRemaining,
|
||||
isCacheValid: systemConfigStore.isCacheValid
|
||||
}))
|
||||
|
||||
// 格式化时间显示
|
||||
const formatTime = (seconds: number): string => {
|
||||
if (seconds <= 0) return '已过期'
|
||||
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const remainingSeconds = seconds % 60
|
||||
|
||||
if (minutes > 0) {
|
||||
return `${minutes}分${remainingSeconds}秒`
|
||||
} else {
|
||||
return `${remainingSeconds}秒`
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化最后获取时间
|
||||
const formatLastFetch = (timestamp: number): string => {
|
||||
if (!timestamp) return '从未'
|
||||
|
||||
const now = Date.now()
|
||||
const diff = now - timestamp
|
||||
|
||||
if (diff < 60 * 1000) {
|
||||
return '刚刚'
|
||||
} else if (diff < 60 * 60 * 1000) {
|
||||
const minutes = Math.floor(diff / (60 * 1000))
|
||||
return `${minutes}分钟前`
|
||||
} else if (diff < 24 * 60 * 60 * 1000) {
|
||||
const hours = Math.floor(diff / (60 * 60 * 1000))
|
||||
return `${hours}小时前`
|
||||
} else {
|
||||
const date = new Date(timestamp)
|
||||
return date.toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新缓存
|
||||
const refreshCache = async () => {
|
||||
try {
|
||||
await systemConfigStore.refreshConfig()
|
||||
console.log('[CacheInfo] 手动刷新缓存完成')
|
||||
} catch (error) {
|
||||
console.error('[CacheInfo] 刷新缓存失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 清除缓存
|
||||
const clearCache = () => {
|
||||
systemConfigStore.clearCache()
|
||||
console.log('[CacheInfo] 手动清除缓存完成')
|
||||
}
|
||||
|
||||
// 键盘快捷键支持
|
||||
const handleKeydown = (e: KeyboardEvent) => {
|
||||
// Ctrl+Shift+C 显示/隐藏缓存信息(仅在开发环境)
|
||||
if (isDev.value && e.ctrlKey && e.shiftKey && e.key === 'C') {
|
||||
e.preventDefault()
|
||||
showCacheInfo.value = !showCacheInfo.value
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (isClient.value) {
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (isClient.value) {
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 添加一些动画效果 */
|
||||
.transition-colors {
|
||||
transition: color 0.2s, background-color 0.2s;
|
||||
}
|
||||
</style>
|
||||
@@ -279,6 +279,16 @@
|
||||
<n-tag :type="channel.is_active ? 'success' : 'warning'" size="small">
|
||||
{{ channel.is_active ? '活跃' : '非活跃' }}
|
||||
</n-tag>
|
||||
<n-button
|
||||
v-if="channel.push_enabled"
|
||||
size="small"
|
||||
@click="manualPushToChannel(channel)"
|
||||
:loading="manualPushingChannel === channel.id"
|
||||
title="手动推送">
|
||||
<template #icon>
|
||||
<i class="fas fa-paper-plane"></i>
|
||||
</template>
|
||||
</n-button>
|
||||
<n-button size="small" @click="editChannel(channel)">
|
||||
<template #icon>
|
||||
<i class="fas fa-edit"></i>
|
||||
@@ -715,6 +725,8 @@ const loadingLogs = ref(false)
|
||||
const logHours = ref(24)
|
||||
const editingChannel = ref<any>(null)
|
||||
const savingChannel = ref(false)
|
||||
const testingPush = ref(false)
|
||||
const manualPushingChannel = ref<number | null>(null)
|
||||
|
||||
// 机器人状态相关变量
|
||||
const botStatus = ref<any>(null)
|
||||
@@ -1469,6 +1481,55 @@ const refreshBotStatus = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 手动推送内容到频道
|
||||
const manualPushToChannel = async (channel: any) => {
|
||||
if (!channel || !channel.id) {
|
||||
notification.warning({
|
||||
content: '频道信息不完整',
|
||||
duration: 2000
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!telegramBotConfig.value.bot_enabled) {
|
||||
notification.warning({
|
||||
content: '请先启用机器人并配置API Key',
|
||||
duration: 3000
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
manualPushingChannel.value = channel.id
|
||||
try {
|
||||
await telegramApi.manualPushToChannel(channel.id)
|
||||
|
||||
notification.success({
|
||||
content: `手动推送请求已提交至频道 "${channel.chat_name}"`,
|
||||
duration: 3000
|
||||
})
|
||||
|
||||
// 更新频道推送时间
|
||||
const updatedChannels = telegramChannels.value.map(c => {
|
||||
if (c.id === channel.id) {
|
||||
c.last_push_at = new Date().toISOString()
|
||||
}
|
||||
return c
|
||||
})
|
||||
telegramChannels.value = updatedChannels
|
||||
} catch (error: any) {
|
||||
console.error('手动推送失败:', error)
|
||||
notification.error({
|
||||
content: `手动推送失败: ${error?.message || '请稍后重试'}`,
|
||||
duration: 3000
|
||||
})
|
||||
} finally {
|
||||
// 只有当当前频道ID与推送中的频道ID匹配时才清除状态
|
||||
if (manualPushingChannel.value === channel.id) {
|
||||
manualPushingChannel.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 调试机器人连接
|
||||
const debugBotConnection = async () => {
|
||||
try {
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
<n-input v-model:value="configForm.welcome_message" type="textarea" :rows="3" placeholder="新用户关注时发送的欢迎消息" />
|
||||
</n-form-item>
|
||||
<n-form-item label="搜索结果限制">
|
||||
<n-input-number v-model:value="configForm.search_limit" :min="1" :max="10" placeholder="搜索结果返回数量" />
|
||||
<n-input-number v-model:value="configForm.search_limit" :min="1" :max="100" placeholder="搜索结果返回数量" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useApiFetch } from './useApiFetch'
|
||||
import { useUserStore } from '~/stores/user'
|
||||
import { useGoogleIndexApi } from './useGoogleIndexApi'
|
||||
|
||||
// 统一响应解析函数
|
||||
export const parseApiResponse = <T>(response: any): T => {
|
||||
@@ -47,7 +48,9 @@ export const parseApiResponse = <T>(response: any): T => {
|
||||
|
||||
export const useResourceApi = () => {
|
||||
const getResources = (params?: any) => useApiFetch('/resources', { params }).then(parseApiResponse)
|
||||
const getHotResources = (params?: any) => useApiFetch('/resources/hot', { params }).then(parseApiResponse)
|
||||
const getResource = (id: number) => useApiFetch(`/resources/${id}`).then(parseApiResponse)
|
||||
const getResourcesByKey = (key: string) => useApiFetch(`/resources/key/${key}`).then(parseApiResponse)
|
||||
const createResource = (data: any) => useApiFetch('/resources', { method: 'POST', body: data }).then(parseApiResponse)
|
||||
const updateResource = (id: number, data: any) => useApiFetch(`/resources/${id}`, { method: 'PUT', body: data }).then(parseApiResponse)
|
||||
const deleteResource = (id: number) => useApiFetch(`/resources/${id}`, { method: 'DELETE' }).then(parseApiResponse)
|
||||
@@ -59,7 +62,36 @@ export const useResourceApi = () => {
|
||||
const batchDeleteResources = (ids: number[]) => useApiFetch('/resources/batch', { method: 'DELETE', body: { ids } }).then(parseApiResponse)
|
||||
// 新增:获取资源链接(智能转存)
|
||||
const getResourceLink = (id: number) => useApiFetch(`/resources/${id}/link`).then(parseApiResponse)
|
||||
return { getResources, getResource, createResource, updateResource, deleteResource, searchResources, getResourcesByPan, incrementViewCount, batchDeleteResources, getResourceLink }
|
||||
// 新增:获取相关资源
|
||||
const getRelatedResources = (params?: any) => useApiFetch('/resources/related', { params }).then(parseApiResponse)
|
||||
// 新增:检查资源有效性
|
||||
const checkResourceValidity = (id: number) => useApiFetch(`/resources/${id}/validity`).then(parseApiResponse)
|
||||
// 新增:批量检查资源有效性
|
||||
const batchCheckResourceValidity = (ids: number[]) => useApiFetch('/resources/validity/batch', { method: 'POST', body: { ids } }).then(parseApiResponse)
|
||||
// 新增:提交举报
|
||||
const submitReport = (data: any) => useApiFetch('/reports', { method: 'POST', body: data }).then(parseApiResponse)
|
||||
// 新增:提交版权申述
|
||||
const submitCopyrightClaim = (data: any) => useApiFetch('/copyright-claims', { method: 'POST', body: data }).then(parseApiResponse)
|
||||
|
||||
// 新增:管理后台举报相关API
|
||||
const getReportsRaw = (params?: any) => useApiFetch('/reports', { params })
|
||||
const getReports = (params?: any) => getReportsRaw(params).then(parseApiResponse)
|
||||
const getReport = (id: number) => useApiFetch(`/reports/${id}`).then(parseApiResponse)
|
||||
const updateReport = (id: number, data: any) => useApiFetch(`/reports/${id}`, { method: 'PUT', body: data }).then(parseApiResponse)
|
||||
const deleteReport = (id: number) => useApiFetch(`/reports/${id}`, { method: 'DELETE' }).then(parseApiResponse)
|
||||
|
||||
// 新增:管理后台版权申述相关API
|
||||
const getCopyrightClaims = (params?: any) => useApiFetch('/copyright-claims', { params }).then(parseApiResponse)
|
||||
const getCopyrightClaim = (id: number) => useApiFetch(`/copyright-claims/${id}`).then(parseApiResponse)
|
||||
const updateCopyrightClaim = (id: number, data: any) => useApiFetch(`/copyright-claims/${id}`, { method: 'PUT', body: data }).then(parseApiResponse)
|
||||
const deleteCopyrightClaim = (id: number) => useApiFetch(`/copyright-claims/${id}`, { method: 'DELETE' }).then(parseApiResponse)
|
||||
|
||||
return {
|
||||
getResources, getHotResources, getResource, getResourcesByKey, createResource, updateResource, deleteResource, searchResources, getResourcesByPan, incrementViewCount, batchDeleteResources, getResourceLink, getRelatedResources, checkResourceValidity, batchCheckResourceValidity,
|
||||
submitReport, submitCopyrightClaim,
|
||||
getReports, getReport, updateReport, deleteReport, getReportsRaw,
|
||||
getCopyrightClaims, getCopyrightClaim, updateCopyrightClaim, deleteCopyrightClaim
|
||||
}
|
||||
}
|
||||
|
||||
export const useAuthApi = () => {
|
||||
@@ -274,6 +306,7 @@ export const useTelegramApi = () => {
|
||||
const debugBotConnection = () => useApiFetch('/telegram/debug-connection').then(parseApiResponse)
|
||||
const reloadBotConfig = () => useApiFetch('/telegram/reload-config', { method: 'POST' }).then(parseApiResponse)
|
||||
const testBotMessage = (data: any) => useApiFetch('/telegram/test-message', { method: 'POST', body: data }).then(parseApiResponse)
|
||||
const manualPushToChannel = (channelId: number) => useApiFetch(`/telegram/manual-push/${channelId}`, { method: 'POST' }).then(parseApiResponse)
|
||||
const getChannels = () => useApiFetch('/telegram/channels').then(parseApiResponse)
|
||||
const createChannel = (data: any) => useApiFetch('/telegram/channels', { method: 'POST', body: data }).then(parseApiResponse)
|
||||
const updateChannel = (id: number, data: any) => useApiFetch(`/telegram/channels/${id}`, { method: 'PUT', body: data }).then(parseApiResponse)
|
||||
@@ -289,6 +322,7 @@ export const useTelegramApi = () => {
|
||||
debugBotConnection,
|
||||
reloadBotConfig,
|
||||
testBotMessage,
|
||||
manualPushToChannel,
|
||||
getChannels,
|
||||
createChannel,
|
||||
updateChannel,
|
||||
@@ -369,4 +403,52 @@ export const useWechatApi = () => {
|
||||
getBotStatus,
|
||||
uploadVerifyFile
|
||||
}
|
||||
}
|
||||
|
||||
// Sitemap管理API
|
||||
export const useSitemapApi = () => {
|
||||
const getSitemapConfig = () => useApiFetch('/sitemap/config').then(parseApiResponse)
|
||||
const updateSitemapConfig = (data: any) => useApiFetch('/sitemap/config', { method: 'POST', body: data }).then(parseApiResponse)
|
||||
const generateSitemap = () => useApiFetch('/sitemap/generate', { method: 'POST' }).then(parseApiResponse)
|
||||
const getSitemapStatus = () => useApiFetch('/sitemap/status').then(parseApiResponse)
|
||||
const fullGenerateSitemap = () => useApiFetch('/sitemap/full-generate', { method: 'POST' }).then(parseApiResponse)
|
||||
const getSitemapIndex = () => useApiFetch('/sitemap.xml')
|
||||
const getSitemapPage = (page: number) => useApiFetch(`/sitemap-${page}.xml`)
|
||||
|
||||
return {
|
||||
getSitemapConfig,
|
||||
updateSitemapConfig,
|
||||
generateSitemap,
|
||||
getSitemapStatus,
|
||||
fullGenerateSitemap,
|
||||
getSitemapIndex,
|
||||
getSitemapPage
|
||||
}
|
||||
}
|
||||
|
||||
// 统一API访问函数
|
||||
export const useApi = () => {
|
||||
return {
|
||||
resourceApi: useResourceApi(),
|
||||
authApi: useAuthApi(),
|
||||
categoryApi: useCategoryApi(),
|
||||
panApi: usePanApi(),
|
||||
cksApi: useCksApi(),
|
||||
tagApi: useTagApi(),
|
||||
readyResourceApi: useReadyResourceApi(),
|
||||
statsApi: useStatsApi(),
|
||||
searchStatsApi: useSearchStatsApi(),
|
||||
systemConfigApi: useSystemConfigApi(),
|
||||
hotDramaApi: useHotDramaApi(),
|
||||
monitorApi: useMonitorApi(),
|
||||
userApi: useUserApi(),
|
||||
taskApi: useTaskApi(),
|
||||
telegramApi: useTelegramApi(),
|
||||
meilisearchApi: useMeilisearchApi(),
|
||||
apiAccessLogApi: useApiAccessLogApi(),
|
||||
systemLogApi: useSystemLogApi(),
|
||||
wechatApi: useWechatApi(),
|
||||
sitemapApi: useSitemapApi(),
|
||||
googleIndexApi: useGoogleIndexApi()
|
||||
}
|
||||
}
|
||||
68
web/composables/useGlobalSeo.ts
Normal file
68
web/composables/useGlobalSeo.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { useSeo } from './useSeo'
|
||||
|
||||
export const useGlobalSeo = () => {
|
||||
const { systemConfig, fetchSystemConfig, setPageSeo, setServerSeo } = useSeo()
|
||||
|
||||
// 初始化系统配置
|
||||
const initSystemConfig = async () => {
|
||||
if (!systemConfig.value) {
|
||||
await fetchSystemConfig()
|
||||
}
|
||||
}
|
||||
|
||||
// 为首页设置SEO
|
||||
const setHomeSeo = (customMeta?: Record<string, string>) => {
|
||||
setPageSeo('首页', {
|
||||
description: (systemConfig.value && systemConfig.value.site_description) || '老九网盘资源数据库 - 专业的网盘资源管理系统',
|
||||
keywords: (systemConfig.value && systemConfig.value.keywords) || '网盘资源,资源管理,数据库,文件分享',
|
||||
...customMeta
|
||||
})
|
||||
}
|
||||
|
||||
// 为登录页设置SEO
|
||||
const setLoginSeo = (customMeta?: Record<string, string>) => {
|
||||
setPageSeo('用户登录', {
|
||||
description: (systemConfig.value && systemConfig.value.site_description) ? `${systemConfig.value.site_description} - 用户登录页面` : '老九网盘资源数据库登录页面',
|
||||
keywords: `${(systemConfig.value && systemConfig.value.keywords) || '网盘资源,登录'},用户登录,账号登录`,
|
||||
...customMeta
|
||||
})
|
||||
}
|
||||
|
||||
// 为注册页设置SEO
|
||||
const setRegisterSeo = (customMeta?: Record<string, string>) => {
|
||||
setPageSeo('用户注册', {
|
||||
description: (systemConfig.value && systemConfig.value.site_description) ? `${systemConfig.value.site_description} - 用户注册页面` : '老九网盘资源数据库注册页面',
|
||||
keywords: `${(systemConfig.value && systemConfig.value.keywords) || '网盘资源,注册'},用户注册,账号注册,免费注册`,
|
||||
...customMeta
|
||||
})
|
||||
}
|
||||
|
||||
// 为热门剧页面设置SEO
|
||||
const setHotDramasSeo = (customMeta?: Record<string, string>) => {
|
||||
setPageSeo('热播剧榜单', {
|
||||
description: (systemConfig.value && systemConfig.value.site_description) ? `${systemConfig.value.site_description} - 实时获取豆瓣热门电影和电视剧榜单` : '实时获取豆瓣热门电影和电视剧榜单,包括热门电影、热门电视剧、热门综艺和豆瓣Top250等分类',
|
||||
keywords: `${(systemConfig.value && systemConfig.value.keywords) || '网盘资源'},热播剧,热门电影,热门电视剧,豆瓣榜单,Top250,影视推荐,电影榜单`,
|
||||
...customMeta
|
||||
})
|
||||
}
|
||||
|
||||
// 为API文档页面设置SEO
|
||||
const setApiDocsSeo = (customMeta?: Record<string, string>) => {
|
||||
setPageSeo('API文档', {
|
||||
description: (systemConfig.value && systemConfig.value.site_description) ? `${systemConfig.value.site_description} - 公开API接口文档` : '老九网盘资源数据库的公开API接口文档,支持资源添加、搜索和热门剧获取等功能',
|
||||
keywords: `${(systemConfig.value && systemConfig.value.keywords) || '网盘资源'},API,接口文档,资源搜索,批量添加,API接口,开发者`,
|
||||
...customMeta
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
initSystemConfig,
|
||||
systemConfig,
|
||||
setPageSeo,
|
||||
setHomeSeo,
|
||||
setLoginSeo,
|
||||
setRegisterSeo,
|
||||
setHotDramasSeo,
|
||||
setApiDocsSeo
|
||||
}
|
||||
}
|
||||
245
web/composables/useGoogleIndexApi.ts
Normal file
245
web/composables/useGoogleIndexApi.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
import { useApiFetch } from './useApiFetch'
|
||||
import { parseApiResponse } from './useApi'
|
||||
|
||||
// Google索引配置类型定义
|
||||
export interface GoogleIndexConfig {
|
||||
id?: number
|
||||
group: string
|
||||
key: string
|
||||
value: string
|
||||
type?: string
|
||||
}
|
||||
|
||||
// Google索引任务类型定义
|
||||
export interface GoogleIndexTask {
|
||||
id: number
|
||||
title: string
|
||||
type: string
|
||||
status: string
|
||||
description: string
|
||||
totalItems: number
|
||||
processedItems: number
|
||||
successItems: number
|
||||
failedItems: number
|
||||
indexedURLs: number
|
||||
failedURLs: number
|
||||
errorMessage?: string
|
||||
configID?: number
|
||||
startedAt?: Date
|
||||
completedAt?: Date
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
// Google索引任务项类型定义
|
||||
export interface GoogleIndexTaskItem {
|
||||
id: number
|
||||
taskID: number
|
||||
URL: string
|
||||
status: string
|
||||
indexStatus: string
|
||||
errorMessage?: string
|
||||
inspectResult?: string
|
||||
mobileFriendly: boolean
|
||||
lastCrawled?: Date
|
||||
statusCode: number
|
||||
startedAt?: Date
|
||||
completedAt?: Date
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
// URL状态类型定义
|
||||
export interface GoogleIndexURLStatus {
|
||||
id: number
|
||||
URL: string
|
||||
indexStatus: string
|
||||
lastChecked: Date
|
||||
canonicalURL?: string
|
||||
lastCrawled?: Date
|
||||
changeFreq?: string
|
||||
priority?: number
|
||||
mobileFriendly: boolean
|
||||
robotsBlocked: boolean
|
||||
lastError?: string
|
||||
statusCode: number
|
||||
statusCodeText: string
|
||||
checkCount: number
|
||||
successCount: number
|
||||
failureCount: number
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
// Google索引状态响应类型定义
|
||||
export interface GoogleIndexStatusResponse {
|
||||
enabled: boolean
|
||||
siteURL: string
|
||||
lastCheckTime: Date
|
||||
totalURLs: number
|
||||
indexedURLs: number
|
||||
notIndexedURLs: number
|
||||
errorURLs: number
|
||||
lastSitemapSubmit: Date
|
||||
authValid: boolean
|
||||
}
|
||||
|
||||
// Google索引任务列表响应类型定义
|
||||
export interface GoogleIndexTaskListResponse {
|
||||
tasks: GoogleIndexTask[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
totalPages: number
|
||||
}
|
||||
|
||||
// Google索引任务项分页响应类型定义
|
||||
export interface GoogleIndexTaskItemPageResponse {
|
||||
items: GoogleIndexTaskItem[]
|
||||
total: number
|
||||
page: number
|
||||
size: number
|
||||
}
|
||||
|
||||
// Google索引API封装
|
||||
export const useGoogleIndexApi = () => {
|
||||
// 配置管理API
|
||||
const getGoogleIndexConfig = (params?: any) =>
|
||||
useApiFetch('/google-index/config-all', { params }).then(parseApiResponse<GoogleIndexConfig[]>)
|
||||
|
||||
const getGoogleIndexConfigByKey = (key: string) =>
|
||||
useApiFetch(`/google-index/config/${key}`).then(parseApiResponse<GoogleIndexConfig>)
|
||||
|
||||
const updateGoogleIndexConfig = (data: GoogleIndexConfig) =>
|
||||
useApiFetch('/google-index/config', { method: 'POST', body: data }).then(parseApiResponse<GoogleIndexConfig>)
|
||||
|
||||
const deleteGoogleIndexConfig = (key: string) =>
|
||||
useApiFetch(`/google-index/config/${key}`, { method: 'DELETE' }).then(parseApiResponse<boolean>)
|
||||
|
||||
// 任务管理API
|
||||
const getGoogleIndexTasks = (params?: any) =>
|
||||
useApiFetch('/google-index/tasks', { params }).then(parseApiResponse<GoogleIndexTaskListResponse>)
|
||||
|
||||
const getGoogleIndexTask = (id: number) =>
|
||||
useApiFetch(`/google-index/tasks/${id}`).then(parseApiResponse<GoogleIndexTask>)
|
||||
|
||||
const createGoogleIndexTask = (data: any) =>
|
||||
useApiFetch('/google-index/tasks', { method: 'POST', body: data }).then(parseApiResponse<GoogleIndexTask>)
|
||||
|
||||
const startGoogleIndexTask = (id: number) =>
|
||||
useApiFetch(`/google-index/tasks/${id}/start`, { method: 'POST' }).then(parseApiResponse<boolean>)
|
||||
|
||||
const stopGoogleIndexTask = (id: number) =>
|
||||
useApiFetch(`/google-index/tasks/${id}/stop`, { method: 'POST' }).then(parseApiResponse<boolean>)
|
||||
|
||||
const deleteGoogleIndexTask = (id: number) =>
|
||||
useApiFetch(`/google-index/tasks/${id}`, { method: 'DELETE' }).then(parseApiResponse<boolean>)
|
||||
|
||||
// 任务项管理API
|
||||
const getGoogleIndexTaskItems = (taskId: number, params?: any) =>
|
||||
useApiFetch(`/google-index/tasks/${taskId}/items`, { params }).then(parseApiResponse<GoogleIndexTaskItemPageResponse>)
|
||||
|
||||
// URL状态管理API
|
||||
const getGoogleIndexURLStatus = (params?: any) =>
|
||||
useApiFetch('/google-index/urls/status', { params }).then(parseApiResponse<GoogleIndexURLStatus[]>)
|
||||
|
||||
const getGoogleIndexURLStatusByURL = (url: string) =>
|
||||
useApiFetch(`/google-index/urls/status/${encodeURIComponent(url)}`).then(parseApiResponse<GoogleIndexURLStatus>)
|
||||
|
||||
const checkGoogleIndexURLStatus = (data: { urls: string[] }) =>
|
||||
useApiFetch('/google-index/urls/check', { method: 'POST', body: data }).then(parseApiResponse<any>)
|
||||
|
||||
const submitGoogleIndexURL = (data: { urls: string[] }) =>
|
||||
useApiFetch('/google-index/urls/submit', { method: 'POST', body: data }).then(parseApiResponse<any>)
|
||||
|
||||
// 批量操作API
|
||||
const batchSubmitGoogleIndexURLs = (data: { urls: string[], operation: string }) =>
|
||||
useApiFetch('/google-index/batch/submit', { method: 'POST', body: data }).then(parseApiResponse<any>)
|
||||
|
||||
const batchCheckGoogleIndexURLs = (data: { urls: string[], operation: string }) =>
|
||||
useApiFetch('/google-index/batch/check', { method: 'POST', body: data }).then(parseApiResponse<any>)
|
||||
|
||||
// 网站地图提交API
|
||||
const submitGoogleIndexSitemap = (data: { sitemapURL: string }) =>
|
||||
useApiFetch('/google-index/sitemap/submit', { method: 'POST', body: data }).then(parseApiResponse<any>)
|
||||
|
||||
// 状态查询API
|
||||
const getGoogleIndexStatus = () =>
|
||||
useApiFetch('/google-index/status').then(parseApiResponse<GoogleIndexStatusResponse>)
|
||||
|
||||
// 验证凭据API
|
||||
const validateCredentials = (data: { credentialsFile: string }) =>
|
||||
useApiFetch('/google-index/validate-credentials', { method: 'POST', body: data }).then(parseApiResponse<any>)
|
||||
|
||||
// 更新Google索引分组配置API
|
||||
const updateGoogleIndexGroupConfig = (data: GoogleIndexConfig) =>
|
||||
useApiFetch('/google-index/config/update', { method: 'POST', body: data }).then(parseApiResponse<GoogleIndexConfig>)
|
||||
|
||||
// 上传凭据API
|
||||
const uploadCredentials = (file: File) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
return useApiFetch('/google-index/upload-credentials', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
// 注意:此处不应包含Authorization头,因为文件上传通常由use-upload组件处理
|
||||
}
|
||||
}).then(parseApiResponse<any>)
|
||||
}
|
||||
|
||||
// 调度器控制API
|
||||
const startGoogleIndexScheduler = () =>
|
||||
useApiFetch('/google-index/scheduler/start', { method: 'POST' }).then(parseApiResponse<boolean>)
|
||||
|
||||
const stopGoogleIndexScheduler = () =>
|
||||
useApiFetch('/google-index/scheduler/stop', { method: 'POST' }).then(parseApiResponse<boolean>)
|
||||
|
||||
const getGoogleIndexSchedulerStatus = () =>
|
||||
useApiFetch('/google-index/scheduler/status').then(parseApiResponse<any>)
|
||||
|
||||
return {
|
||||
// 配置管理
|
||||
getGoogleIndexConfig,
|
||||
getGoogleIndexConfigByKey,
|
||||
updateGoogleIndexConfig,
|
||||
updateGoogleIndexGroupConfig,
|
||||
deleteGoogleIndexConfig,
|
||||
|
||||
// 凭据验证和上传
|
||||
validateCredentials,
|
||||
uploadCredentials,
|
||||
|
||||
// 任务管理
|
||||
getGoogleIndexTasks,
|
||||
getGoogleIndexTask,
|
||||
createGoogleIndexTask,
|
||||
startGoogleIndexTask,
|
||||
stopGoogleIndexTask,
|
||||
deleteGoogleIndexTask,
|
||||
|
||||
// 任务项管理
|
||||
getGoogleIndexTaskItems,
|
||||
|
||||
// URL状态管理
|
||||
getGoogleIndexURLStatus,
|
||||
getGoogleIndexURLStatusByURL,
|
||||
checkGoogleIndexURLStatus,
|
||||
submitGoogleIndexURL,
|
||||
|
||||
// 批量操作
|
||||
batchSubmitGoogleIndexURLs,
|
||||
batchCheckGoogleIndexURLs,
|
||||
|
||||
// 网站地图提交
|
||||
submitGoogleIndexSitemap,
|
||||
|
||||
// 状态查询
|
||||
getGoogleIndexStatus,
|
||||
|
||||
// 调度器控制
|
||||
startGoogleIndexScheduler,
|
||||
stopGoogleIndexScheduler,
|
||||
getGoogleIndexSchedulerStatus
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
import { useRoute } from '#imports'
|
||||
import { usePublicSystemConfigApi } from './useApi'
|
||||
|
||||
interface SystemConfig {
|
||||
id: number
|
||||
@@ -17,12 +19,12 @@ interface SystemConfig {
|
||||
|
||||
export const useSeo = () => {
|
||||
const systemConfig = ref<SystemConfig | null>(null)
|
||||
const { getSystemConfig } = useSystemConfigApi()
|
||||
const { getPublicSystemConfig } = usePublicSystemConfigApi()
|
||||
|
||||
// 获取系统配置
|
||||
const fetchSystemConfig = async () => {
|
||||
try {
|
||||
const response = await getSystemConfig() as any
|
||||
const response = await getPublicSystemConfig() as any
|
||||
console.log('系统配置响应:', response)
|
||||
if (response && response.success && response.data) {
|
||||
systemConfig.value = response.data
|
||||
@@ -37,7 +39,7 @@ export const useSeo = () => {
|
||||
|
||||
// 生成页面标题
|
||||
const generateTitle = (pageTitle: string) => {
|
||||
if (systemConfig.value?.site_title) {
|
||||
if (systemConfig.value && systemConfig.value.site_title) {
|
||||
return `${systemConfig.value.site_title} - ${pageTitle}`
|
||||
}
|
||||
return `${pageTitle} - 老九网盘资源数据库`
|
||||
@@ -46,10 +48,10 @@ export const useSeo = () => {
|
||||
// 生成页面元数据
|
||||
const generateMeta = (customMeta?: Record<string, string>) => {
|
||||
const defaultMeta = {
|
||||
description: systemConfig.value?.site_description || '专业的老九网盘资源数据库',
|
||||
keywords: systemConfig.value?.keywords || '网盘,资源管理,文件分享',
|
||||
author: systemConfig.value?.author || '系统管理员',
|
||||
copyright: systemConfig.value?.copyright || '© 2024 老九网盘资源数据库'
|
||||
description: (systemConfig.value && systemConfig.value.site_description) || '专业的老九网盘资源数据库',
|
||||
keywords: (systemConfig.value && systemConfig.value.keywords) || '网盘,资源管理,文件分享',
|
||||
author: (systemConfig.value && systemConfig.value.author) || '系统管理员',
|
||||
copyright: (systemConfig.value && systemConfig.value.copyright) || '© 2024 老九网盘资源数据库'
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -58,27 +60,137 @@ export const useSeo = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 设置页面SEO
|
||||
const setPageSeo = (pageTitle: string, customMeta?: Record<string, string>) => {
|
||||
const title = generateTitle(pageTitle)
|
||||
const meta = generateMeta(customMeta)
|
||||
// 生成动态OG图片URL
|
||||
const generateOgImageUrl = (keyOrTitle: string, descriptionOrEmpty: string = '', theme: string = 'default') => {
|
||||
// 获取运行时配置
|
||||
const config = useRuntimeConfig()
|
||||
const ogApiUrl = config.public.ogApiUrl || '/api/og-image'
|
||||
|
||||
useHead({
|
||||
// 构建URL参数
|
||||
const params = new URLSearchParams()
|
||||
|
||||
// 检测第一个参数是key还是title(通过长度和格式判断)
|
||||
// 如果是较短的字符串且符合key格式(通常是字母数字组合),则当作key处理
|
||||
if (keyOrTitle.length <= 50 && /^[a-zA-Z0-9_-]+$/.test(keyOrTitle)) {
|
||||
// 作为key参数使用
|
||||
params.set('key', keyOrTitle)
|
||||
} else {
|
||||
// 作为title参数使用
|
||||
params.set('title', keyOrTitle)
|
||||
|
||||
if (descriptionOrEmpty) {
|
||||
// 限制描述长度
|
||||
const trimmedDesc = descriptionOrEmpty.length > 200 ? descriptionOrEmpty.substring(0, 200) + '...' : descriptionOrEmpty
|
||||
params.set('description', trimmedDesc)
|
||||
}
|
||||
}
|
||||
|
||||
params.set('site_name', (systemConfig.value && systemConfig.value.site_title) || '老九网盘资源数据库')
|
||||
params.set('theme', theme)
|
||||
params.set('width', '1200')
|
||||
params.set('height', '630')
|
||||
|
||||
// 如果是相对路径,添加当前域名
|
||||
if (ogApiUrl.startsWith('/')) {
|
||||
if (process.client) {
|
||||
const origin = window.location.origin
|
||||
return `${origin}${ogApiUrl}?${params.toString()}`
|
||||
}
|
||||
// 服务端渲染时使用配置的API基础URL
|
||||
const apiBase = config.public.apiBase || 'http://localhost:8080'
|
||||
return `${apiBase}${ogApiUrl}?${params.toString()}`
|
||||
}
|
||||
|
||||
return `${ogApiUrl}?${params.toString()}`
|
||||
}
|
||||
|
||||
// 生成动态SEO元数据
|
||||
const generateDynamicSeo = (pageTitle: string, customMeta?: Record<string, string>, routeQuery?: Record<string, any>, useRawTitle: boolean = false) => {
|
||||
const title = useRawTitle ? pageTitle : generateTitle(pageTitle)
|
||||
const meta = generateMeta(customMeta)
|
||||
const route = routeQuery || useRoute()
|
||||
|
||||
// 根据路由参数生成动态描述
|
||||
const searchKeyword = route.query?.search as string || ''
|
||||
const platformId = route.query?.platform as string || ''
|
||||
|
||||
let dynamicDescription = meta.description
|
||||
if (searchKeyword && platformId) {
|
||||
dynamicDescription = `在${platformId}中搜索"${searchKeyword}"的相关资源。${meta.description}`
|
||||
} else if (searchKeyword) {
|
||||
dynamicDescription = `搜索"${searchKeyword}"的相关资源。${meta.description}`
|
||||
}
|
||||
|
||||
// 动态关键词
|
||||
let dynamicKeywords = meta.keywords
|
||||
if (searchKeyword) {
|
||||
dynamicKeywords = `${searchKeyword},${meta.keywords}`
|
||||
}
|
||||
|
||||
// 生成动态OG图片URL,支持自定义OG图片
|
||||
let ogImageUrl = customMeta?.ogImage
|
||||
if (!ogImageUrl) {
|
||||
const theme = searchKeyword ? 'blue' : platformId ? 'green' : 'default'
|
||||
ogImageUrl = generateOgImageUrl(title, dynamicDescription, theme)
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
meta: [
|
||||
{ name: 'description', content: meta.description },
|
||||
{ name: 'keywords', content: meta.keywords },
|
||||
{ name: 'author', content: meta.author },
|
||||
{ name: 'copyright', content: meta.copyright }
|
||||
]
|
||||
description: dynamicDescription,
|
||||
keywords: dynamicKeywords,
|
||||
ogTitle: title,
|
||||
ogDescription: dynamicDescription,
|
||||
ogType: 'website',
|
||||
ogImage: ogImageUrl,
|
||||
ogSiteName: (systemConfig.value && systemConfig.value.site_title) || '老九网盘资源数据库',
|
||||
twitterCard: 'summary_large_image',
|
||||
robots: 'index, follow'
|
||||
}
|
||||
}
|
||||
|
||||
// 设置页面SEO - 使用Nuxt3最佳实践
|
||||
const setPageSeo = (pageTitle: string, customMeta?: Record<string, string>, routeQuery?: Record<string, any>) => {
|
||||
// 检测标题是否已包含站点名(以避免重复)
|
||||
const isTitleFormatted = systemConfig.value && pageTitle.includes(systemConfig.value.site_title || '');
|
||||
const seoData = generateDynamicSeo(pageTitle, customMeta, routeQuery, isTitleFormatted)
|
||||
|
||||
useSeoMeta({
|
||||
title: seoData.title,
|
||||
description: seoData.description,
|
||||
keywords: seoData.keywords,
|
||||
ogTitle: seoData.ogTitle,
|
||||
ogDescription: seoData.ogDescription,
|
||||
ogType: seoData.ogType,
|
||||
ogImage: seoData.ogImage,
|
||||
ogSiteName: seoData.ogSiteName,
|
||||
twitterCard: seoData.twitterCard,
|
||||
robots: seoData.robots
|
||||
})
|
||||
}
|
||||
|
||||
// 设置服务端SEO(适用于不需要在客户端更新的元数据)
|
||||
const setServerSeo = (pageTitle: string, customMeta?: Record<string, string>) => {
|
||||
if (import.meta.server) {
|
||||
const title = generateTitle(pageTitle)
|
||||
const meta = generateMeta(customMeta)
|
||||
|
||||
useServerSeoMeta({
|
||||
title: title,
|
||||
description: meta.description,
|
||||
keywords: meta.keywords,
|
||||
robots: 'index, follow'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
systemConfig,
|
||||
fetchSystemConfig,
|
||||
generateTitle,
|
||||
generateMeta,
|
||||
setPageSeo
|
||||
generateOgImageUrl,
|
||||
generateDynamicSeo,
|
||||
setPageSeo,
|
||||
setServerSeo
|
||||
}
|
||||
}
|
||||
21
web/ecosystem.config.cjs
Normal file
21
web/ecosystem.config.cjs
Normal file
@@ -0,0 +1,21 @@
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: 'urldb-nuxt',
|
||||
port: '3030',
|
||||
exec_mode: 'cluster',
|
||||
instances: 'max', // 使用所有可用的CPU核心
|
||||
script: './.output/server/index.mjs',
|
||||
error_file: './logs/err.log',
|
||||
out_file: './logs/out.log',
|
||||
log_file: './logs/combined.log',
|
||||
time: true,
|
||||
env: {
|
||||
NODE_ENV: 'production',
|
||||
HOST: '0.0.0.0',
|
||||
PORT: 3030,
|
||||
NUXT_PUBLIC_API_SERVER: 'http://localhost:8080/api'
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
@@ -479,6 +479,18 @@ const dataManagementItems = ref([
|
||||
label: '文件管理',
|
||||
icon: 'fas fa-file-upload',
|
||||
active: (route: any) => route.path.startsWith('/admin/files')
|
||||
},
|
||||
{
|
||||
to: '/admin/reports',
|
||||
label: '举报管理',
|
||||
icon: 'fas fa-flag',
|
||||
active: (route: any) => route.path.startsWith('/admin/reports')
|
||||
},
|
||||
{
|
||||
to: '/admin/copyright-claims',
|
||||
label: '版权申述',
|
||||
icon: 'fas fa-balance-scale',
|
||||
active: (route: any) => route.path.startsWith('/admin/copyright-claims')
|
||||
}
|
||||
])
|
||||
|
||||
@@ -559,7 +571,7 @@ const autoExpandCurrentGroup = () => {
|
||||
const currentPath = useRoute().path
|
||||
|
||||
// 检查当前页面属于哪个分组并展开
|
||||
if (currentPath.startsWith('/admin/resources') || currentPath.startsWith('/admin/ready-resources') || currentPath.startsWith('/admin/tags') || currentPath.startsWith('/admin/categories') || currentPath.startsWith('/admin/accounts') || currentPath.startsWith('/admin/files')) {
|
||||
if (currentPath.startsWith('/admin/resources') || currentPath.startsWith('/admin/ready-resources') || currentPath.startsWith('/admin/tags') || currentPath.startsWith('/admin/categories') || currentPath.startsWith('/admin/accounts') || currentPath.startsWith('/admin/files') || currentPath.startsWith('/admin/reports') || currentPath.startsWith('/admin/copyright-claims')) {
|
||||
expandedGroups.value.dataManagement = true
|
||||
} else if (currentPath.startsWith('/admin/site-config') || currentPath.startsWith('/admin/feature-config') || currentPath.startsWith('/admin/dev-config') || currentPath.startsWith('/admin/users') || currentPath.startsWith('/admin/version')) {
|
||||
expandedGroups.value.systemConfig = true
|
||||
@@ -581,7 +593,7 @@ watch(() => useRoute().path, (newPath) => {
|
||||
}
|
||||
|
||||
// 根据新路径展开对应分组
|
||||
if (newPath.startsWith('/admin/resources') || newPath.startsWith('/admin/ready-resources') || newPath.startsWith('/admin/tags') || newPath.startsWith('/admin/categories') || newPath.startsWith('/admin/accounts') || newPath.startsWith('/admin/files')) {
|
||||
if (newPath.startsWith('/admin/resources') || newPath.startsWith('/admin/ready-resources') || newPath.startsWith('/admin/tags') || newPath.startsWith('/admin/categories') || newPath.startsWith('/admin/accounts') || newPath.startsWith('/admin/files') || newPath.startsWith('/admin/reports') || newPath.startsWith('/admin/copyright-claims')) {
|
||||
expandedGroups.value.dataManagement = true
|
||||
} else if (newPath.startsWith('/admin/site-config') || newPath.startsWith('/admin/feature-config') || newPath.startsWith('/admin/dev-config') || newPath.startsWith('/admin/users') || newPath.startsWith('/admin/version')) {
|
||||
expandedGroups.value.systemConfig = true
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div>
|
||||
<!-- 暗色模式切换按钮 -->
|
||||
<button
|
||||
class="fixed top-4 right-4 z-50 w-8 h-8 flex items-center justify-center rounded-full shadow-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200 transition-all duration-200 hover:bg-blue-100 dark:hover:bg-blue-900 hover:scale-110 focus:outline-none"
|
||||
class="fixed top-4 left-4 z-50 w-8 h-8 flex items-center justify-center rounded-full shadow-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200 transition-all duration-200 hover:bg-blue-100 dark:hover:bg-blue-900 hover:scale-110 focus:outline-none"
|
||||
@click="toggleDarkMode"
|
||||
aria-label="切换明暗模式"
|
||||
>
|
||||
@@ -19,7 +19,9 @@
|
||||
|
||||
<n-notification-provider>
|
||||
<n-dialog-provider>
|
||||
<NuxtPage />
|
||||
<n-message-provider>
|
||||
<NuxtPage />
|
||||
</n-message-provider>
|
||||
</n-dialog-provider>
|
||||
</n-notification-provider>
|
||||
|
||||
@@ -30,6 +32,7 @@
|
||||
import { lightTheme } from 'naive-ui'
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
|
||||
const theme = lightTheme
|
||||
const isDark = ref(false)
|
||||
|
||||
|
||||
31
web/middleware/admin.ts
Normal file
31
web/middleware/admin.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
export default defineNuxtRouteMiddleware(async (to, from) => {
|
||||
// 只在客户端执行认证检查
|
||||
if (!process.client) {
|
||||
return
|
||||
}
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 初始化用户状态
|
||||
userStore.initAuth()
|
||||
|
||||
// 等待一小段时间确保认证状态初始化完成
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
|
||||
// 检查认证状态
|
||||
if (!userStore.isAuthenticated) {
|
||||
console.log('admin middleware - 用户未认证,重定向到登录页面')
|
||||
return navigateTo('/login')
|
||||
}
|
||||
|
||||
// 检查用户是否为管理员(通常通过用户角色或权限判断)
|
||||
// 这里可以根据具体实现来调整,例如检查 userStore.user?.is_admin 字段
|
||||
const isAdmin = userStore.user?.is_admin || userStore.user?.role === 'admin' || userStore.user?.username === 'admin'
|
||||
|
||||
if (!isAdmin) {
|
||||
console.log('admin middleware - 用户不是管理员,重定向到首页')
|
||||
return navigateTo('/')
|
||||
}
|
||||
|
||||
console.log('admin middleware - 用户已认证且为管理员,继续访问')
|
||||
})
|
||||
@@ -55,13 +55,24 @@ export default defineNuxtConfig({
|
||||
app: {
|
||||
head: {
|
||||
title: '老九网盘资源数据库',
|
||||
htmlAttrs: {
|
||||
lang: 'zh-CN'
|
||||
},
|
||||
meta: [
|
||||
{ charset: 'utf-8' },
|
||||
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
|
||||
{ name: 'description', content: '老九网盘资源管理数据庫,现代化的网盘资源数据库,支持多网盘自动化转存分享,支持百度网盘,阿里云盘,夸克网盘, 天翼云盘,迅雷云盘,123云盘,115网盘,UC网盘' }
|
||||
{ name: 'description', content: '老九网盘资源管理数据庫,现代化的网盘资源数据库,支持多网盘自动化转存分享,支持百度网盘,阿里云盘,夸克网盘, 天翼云盘,迅雷云盘,123云盘,115网盘,UC网盘' },
|
||||
{ name: 'robots', content: 'index, follow' },
|
||||
{ name: 'theme-color', content: '#3b82f6' },
|
||||
{ property: 'og:site_name', content: '老九网盘资源数据库' },
|
||||
{ property: 'og:type', content: 'website' },
|
||||
{ property: 'og:image', content: '/assets/images/og.webp' },
|
||||
{ name: 'twitter:card', content: 'summary_large_image' }
|
||||
],
|
||||
link: [
|
||||
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }
|
||||
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' },
|
||||
{ rel: 'preconnect', href: 'https://fonts.googleapis.com' },
|
||||
{ rel: 'preconnect', href: 'https://fonts.gstatic.com', crossorigin: 'anonymous' }
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -70,7 +81,11 @@ export default defineNuxtConfig({
|
||||
// 客户端API地址:开发环境通过代理,生产环境通过Nginx
|
||||
apiBase: '/api',
|
||||
// 服务端API地址:通过环境变量配置,支持不同部署方式
|
||||
apiServer: process.env.NUXT_PUBLIC_API_SERVER || (process.env.NODE_ENV === 'production' ? 'http://backend:8080/api' : '/api')
|
||||
apiServer: process.env.NUXT_PUBLIC_API_SERVER || (process.env.NODE_ENV === 'production' ? 'http://backend:8080/api' : '/api'),
|
||||
// OG图片服务API地址(集成到主服务中)
|
||||
ogApiUrl: process.env.NUXT_PUBLIC_OG_API_URL || (process.env.NODE_ENV === 'production' ? '/api/og-image' : '/api/og-image'),
|
||||
// 网站URL
|
||||
siteUrl: process.env.NUXT_PUBLIC_SITE_URL || 'https://yourdomain.com'
|
||||
}
|
||||
},
|
||||
build: {
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"@nuxtjs/tailwindcss": "^6.8.0",
|
||||
"@pinia/nuxt": "^0.5.0",
|
||||
"@vicons/ionicons5": "^0.12.0",
|
||||
"@vueuse/core": "^14.0.0",
|
||||
"chart.js": "^4.5.0",
|
||||
"naive-ui": "^2.42.0",
|
||||
"pinia": "^2.1.0",
|
||||
|
||||
@@ -218,10 +218,20 @@
|
||||
</div>
|
||||
|
||||
<div v-if="isXunlei">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
refresh_token <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<n-input v-model:value="form.ck" type="textarea" placeholder="请输入" :rows="4" required />
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
手机号 <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<n-input v-model:value="xunleiForm.username" placeholder="请输入手机号(不需要+86前缀)" required />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
密码 <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<n-input v-model:value="xunleiForm.password" type="password" placeholder="请输入密码" show-password-on="click" required />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -280,6 +290,12 @@ const form = ref({
|
||||
remark: ''
|
||||
})
|
||||
|
||||
// 迅雷专用表单数据
|
||||
const xunleiForm = ref({
|
||||
username: '',
|
||||
password: ''
|
||||
})
|
||||
|
||||
const panEnables = ref(['quark', 'xunlei'])
|
||||
// const xunleiEnable = useCookie('xunleiEnable', { default: () => false })
|
||||
// if (xunleiEnable.value && xunleiEnable.value === 'true') {
|
||||
@@ -533,6 +549,25 @@ const editCks = (cks) => {
|
||||
is_valid: cks.is_valid,
|
||||
remark: cks.remark || ''
|
||||
}
|
||||
|
||||
// 如果是迅雷账号,解析ck字段来设置表单
|
||||
if (cks.pan?.name === 'xunlei') {
|
||||
try {
|
||||
// 解析JSON格式
|
||||
const parsed = JSON.parse(cks.ck)
|
||||
xunleiForm.value = {
|
||||
username: parsed.username,
|
||||
password: parsed.password
|
||||
}
|
||||
} catch (e) {
|
||||
// 解析失败,清空表单
|
||||
xunleiForm.value = {
|
||||
username: '',
|
||||
password: ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
showEditModal.value = true
|
||||
}
|
||||
|
||||
@@ -547,10 +582,32 @@ const closeModal = () => {
|
||||
is_valid: true,
|
||||
remark: ''
|
||||
}
|
||||
// 重置迅雷表单
|
||||
xunleiForm.value = {
|
||||
username: '',
|
||||
password: ''
|
||||
}
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
// 如果是迅雷账号,需要构造账号密码的JSON格式
|
||||
if (isXunlei.value) {
|
||||
if (!xunleiForm.value.username || !xunleiForm.value.password) {
|
||||
notification.error({
|
||||
title: '失败',
|
||||
content: '请填写完整的账号和密码',
|
||||
duration: 3000
|
||||
})
|
||||
return
|
||||
}
|
||||
form.value.ck = JSON.stringify({
|
||||
username: xunleiForm.value.username,
|
||||
password: xunleiForm.value.password,
|
||||
refresh_token: '' // 初始为空,登录后会填充
|
||||
})
|
||||
}
|
||||
|
||||
if (showEditModal.value) {
|
||||
await updateCks()
|
||||
} else {
|
||||
|
||||
@@ -31,16 +31,6 @@
|
||||
<TelegramBotTab />
|
||||
</div>
|
||||
</n-tab-pane>
|
||||
|
||||
<n-tab-pane name="wechat_open" tab="微信开放平台">
|
||||
<div class="tab-content-container">
|
||||
<div class="text-center py-12">
|
||||
<i class="fas fa-lock text-4xl text-gray-400 mb-4"></i>
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">功能暂未开放</h3>
|
||||
<p class="text-gray-500 dark:text-gray-400">微信开放平台机器人功能正在开发中,敬请期待</p>
|
||||
</div>
|
||||
</div>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
|
||||
</div>
|
||||
|
||||
825
web/pages/admin/copyright-claims.vue
Normal file
825
web/pages/admin/copyright-claims.vue
Normal file
@@ -0,0 +1,825 @@
|
||||
<template>
|
||||
<AdminPageLayout>
|
||||
<template #page-header>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-balance-scale text-blue-500 mr-2"></i>
|
||||
版权申述管理
|
||||
</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400">管理用户提交的版权申述信息</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 过滤栏 - 搜索和操作 -->
|
||||
<template #filter-bar>
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex gap-2">
|
||||
<!-- 空白区域用于按钮 -->
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<div class="relative">
|
||||
<n-input
|
||||
v-model:value="filters.resourceKey"
|
||||
@input="debounceSearch"
|
||||
type="text"
|
||||
placeholder="搜索资源Key..."
|
||||
clearable
|
||||
>
|
||||
<template #prefix>
|
||||
<i class="fas fa-search text-gray-400 text-sm"></i>
|
||||
</template>
|
||||
</n-input>
|
||||
</div>
|
||||
<n-select
|
||||
v-model:value="filters.status"
|
||||
:options="[
|
||||
{ label: '全部状态', value: '' },
|
||||
{ label: '待处理', value: 'pending' },
|
||||
{ label: '已批准', value: 'approved' },
|
||||
{ label: '已拒绝', value: 'rejected' }
|
||||
]"
|
||||
placeholder="状态"
|
||||
clearable
|
||||
@update:value="fetchClaims"
|
||||
style="width: 150px"
|
||||
/>
|
||||
<n-button @click="resetFilters" type="tertiary">
|
||||
<template #icon>
|
||||
<i class="fas fa-redo"></i>
|
||||
</template>
|
||||
重置
|
||||
</n-button>
|
||||
<n-button @click="fetchClaims" type="tertiary">
|
||||
<template #icon>
|
||||
<i class="fas fa-refresh"></i>
|
||||
</template>
|
||||
刷新
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 内容区 - 版权申述数据 -->
|
||||
<template #content>
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="flex h-full items-center justify-center py-8">
|
||||
<n-spin size="large" />
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-else-if="claims.length === 0" class="text-center py-8">
|
||||
<svg class="w-16 h-16 text-gray-300 dark:text-gray-600 mb-4 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 48 48">
|
||||
<circle cx="24" cy="24" r="20" stroke-width="3" stroke-dasharray="6 6" />
|
||||
<path d="M16 24h16M24 16v16" stroke-width="3" stroke-linecap="round" />
|
||||
</svg>
|
||||
<div class="text-lg font-semibold text-gray-400 dark:text-gray-500 mb-2">暂无版权申述记录</div>
|
||||
<div class="text-sm text-gray-400 dark:text-gray-600 mb-4">目前没有用户提交的版权申述信息</div>
|
||||
</div>
|
||||
|
||||
<!-- 数据表格 - 自适应高度 -->
|
||||
<div v-else class="flex flex-col h-full overflow-auto">
|
||||
<n-data-table
|
||||
:columns="columns"
|
||||
:data="claims"
|
||||
:pagination="false"
|
||||
:bordered="false"
|
||||
:single-line="false"
|
||||
:loading="loading"
|
||||
:scroll-x="1020"
|
||||
class="h-full"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 内容区footer - 分页组件 -->
|
||||
<template #content-footer>
|
||||
<div class="p-4">
|
||||
<div class="flex justify-center">
|
||||
<n-pagination
|
||||
v-model:page="pagination.page"
|
||||
v-model:page-size="pagination.pageSize"
|
||||
:item-count="pagination.total"
|
||||
:page-sizes="[50, 100, 200, 500]"
|
||||
show-size-picker
|
||||
@update:page="fetchClaims"
|
||||
@update:page-size="handlePageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
</AdminPageLayout>
|
||||
|
||||
<!-- 查看申述详情模态框 -->
|
||||
<n-modal v-model:show="showDetailModal" :mask-closable="false" preset="card" :style="{ maxWidth: '600px', width: '90%' }" title="版权申述详情">
|
||||
<div v-if="selectedClaim" class="space-y-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">申述ID</h3>
|
||||
<p class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ selectedClaim.id }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">资源Key</h3>
|
||||
<p class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ selectedClaim.resource_key }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">申述人身份</h3>
|
||||
<p class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ getIdentityLabel(selectedClaim.identity) }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">证明类型</h3>
|
||||
<p class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ getProofTypeLabel(selectedClaim.proof_type) }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">申述理由</h3>
|
||||
<p class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ selectedClaim.reason }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">联系方式</h3>
|
||||
<p class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ selectedClaim.contact_info }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">申述人姓名</h3>
|
||||
<p class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ selectedClaim.claimant_name }}</p>
|
||||
</div>
|
||||
<div v-if="selectedClaim.proof_files">
|
||||
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">证明文件</h3>
|
||||
<div class="mt-1 space-y-2">
|
||||
<div
|
||||
v-for="(file, index) in getProofFiles(selectedClaim.proof_files)"
|
||||
:key="index"
|
||||
class="flex items-center justify-between p-2 bg-gray-50 dark:bg-gray-800 rounded hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors cursor-pointer"
|
||||
@click="downloadFile(file)"
|
||||
>
|
||||
<div class="flex items-center space-x-2">
|
||||
<i class="fas fa-file-download text-blue-500"></i>
|
||||
<span class="text-sm text-gray-900 dark:text-gray-100">{{ getFileName(file) }}</span>
|
||||
</div>
|
||||
<i class="fas fa-download text-gray-400 hover:text-blue-500 transition-colors"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">提交时间</h3>
|
||||
<p class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ formatDateTime(selectedClaim.created_at) }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">IP地址</h3>
|
||||
<p class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ selectedClaim.ip_address || '未知' }}</p>
|
||||
</div>
|
||||
<div v-if="selectedClaim.note">
|
||||
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">处理备注</h3>
|
||||
<p class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ selectedClaim.note }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 设置页面标题和元信息
|
||||
useHead({
|
||||
title: '版权申述管理 - 管理后台',
|
||||
meta: [
|
||||
{ name: 'description', content: '管理用户提交的版权申述信息' }
|
||||
]
|
||||
})
|
||||
|
||||
// 设置页面布局和认证保护
|
||||
definePageMeta({
|
||||
layout: 'admin',
|
||||
middleware: ['auth', 'admin']
|
||||
})
|
||||
|
||||
import { h } from 'vue'
|
||||
const message = useMessage()
|
||||
const notification = useNotification()
|
||||
const dialog = useDialog()
|
||||
|
||||
const { resourceApi } = useApi()
|
||||
const loading = ref(false)
|
||||
const claims = ref<any[]>([])
|
||||
const showDetailModal = ref(false)
|
||||
const selectedClaim = ref<any>(null)
|
||||
|
||||
// 分页和筛选状态
|
||||
const pagination = ref({
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
total: 0
|
||||
})
|
||||
|
||||
const filters = ref({
|
||||
status: '',
|
||||
resourceKey: ''
|
||||
})
|
||||
|
||||
// 表格列定义
|
||||
const columns = [
|
||||
{
|
||||
title: 'ID',
|
||||
key: 'id',
|
||||
width: 60,
|
||||
render: (row: any) => {
|
||||
return h('div', { class: 'space-y-1' }, [
|
||||
h('div', { class: 'font-medium text-sm' }, row.id),
|
||||
h('div', {
|
||||
class: 'text-xs text-gray-400',
|
||||
title: `IP: ${row.ip_address || '未知'}`
|
||||
}, row.ip_address ? `IP: ${row.ip_address.slice(0, 8)}...` : 'IP:未知')
|
||||
])
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '资源',
|
||||
key: 'resource_key',
|
||||
width: 200,
|
||||
render: (row: any) => {
|
||||
const resourceInfo = getResourceInfo(row);
|
||||
return h('div', { class: 'space-y-1' }, [
|
||||
// 第一行:标题(单行,省略号)
|
||||
h('div', {
|
||||
class: 'font-medium text-sm truncate max-w-[200px]',
|
||||
style: { maxWidth: '200px' },
|
||||
title: resourceInfo.title // 鼠标hover显示完整标题
|
||||
}, resourceInfo.title),
|
||||
// 第二行:详情(单行,省略号)
|
||||
h('div', {
|
||||
class: 'text-xs text-gray-500 dark:text-gray-400 truncate max-w-[200px]',
|
||||
style: { maxWidth: '200px' },
|
||||
title: resourceInfo.description // 鼠标hover显示完整描述
|
||||
}, resourceInfo.description),
|
||||
// 第三行:分类图片和链接数
|
||||
h('div', { class: 'flex items-center gap-1' }, [
|
||||
h('i', {
|
||||
class: `fas fa-${getCategoryIcon(resourceInfo.category)} text-blue-500 text-xs`,
|
||||
// 鼠标hover显示第一个资源的链接地址
|
||||
title: resourceInfo.resources.length > 0 ? `链接地址: ${resourceInfo.resources[0].save_url || resourceInfo.resources[0].url}` : `资源链接地址: ${row.resource_key}`
|
||||
}),
|
||||
h('span', { class: 'text-xs text-gray-400' }, `链接数: ${resourceInfo.resources.length}`)
|
||||
])
|
||||
])
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '申述人信息',
|
||||
key: 'claimant_info',
|
||||
width: 180,
|
||||
render: (row: any) => {
|
||||
return h('div', { class: 'space-y-1' }, [
|
||||
// 第一行:姓名和身份
|
||||
h('div', { class: 'font-medium text-sm' }, [
|
||||
h('i', { class: 'fas fa-user text-green-500 mr-1 text-xs' }),
|
||||
row.claimant_name || '未知'
|
||||
]),
|
||||
h('div', {
|
||||
class: 'text-xs text-blue-600 dark:text-blue-400 truncate max-w-[180px]',
|
||||
title: getIdentityLabel(row.identity)
|
||||
}, getIdentityLabel(row.identity)),
|
||||
// 第二行:联系方式
|
||||
h('div', {
|
||||
class: 'text-xs text-gray-500 dark:text-gray-400 truncate max-w-[180px]',
|
||||
title: row.contact_info
|
||||
}, [
|
||||
h('i', { class: 'fas fa-phone text-purple-500 mr-1' }),
|
||||
row.contact_info || '未提供'
|
||||
]),
|
||||
// 第三行:证明类型
|
||||
h('div', {
|
||||
class: 'text-xs text-orange-600 dark:text-orange-400 truncate max-w-[180px]',
|
||||
title: getProofTypeLabel(row.proof_type)
|
||||
}, [
|
||||
h('i', { class: 'fas fa-certificate text-orange-500 mr-1' }),
|
||||
getProofTypeLabel(row.proof_type)
|
||||
])
|
||||
])
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '申述详情',
|
||||
key: 'claim_details',
|
||||
width: 280,
|
||||
render: (row: any) => {
|
||||
return h('div', { class: 'space-y-1' }, [
|
||||
// 第一行:申述理由和提交时间
|
||||
h('div', { class: 'space-y-1' }, [
|
||||
h('div', { class: 'text-xs text-gray-500 dark:text-gray-400' }, '申述理由:'),
|
||||
h('div', {
|
||||
class: 'text-sm text-gray-700 dark:text-gray-300 line-clamp-2 max-h-10',
|
||||
title: row.reason
|
||||
}, row.reason || '无'),
|
||||
h('div', { class: 'text-xs text-gray-400' }, [
|
||||
h('i', { class: 'fas fa-clock mr-1' }),
|
||||
`提交时间: ${formatDateTime(row.created_at)}`
|
||||
])
|
||||
]),
|
||||
// 第二行:证明文件
|
||||
row.proof_files ?
|
||||
h('div', { class: 'space-y-1' }, [
|
||||
h('div', { class: 'text-xs text-gray-500 dark:text-gray-400' }, '证明文件:'),
|
||||
...getProofFiles(row.proof_files).slice(0, 2).map((file, index) =>
|
||||
h('div', {
|
||||
class: 'text-xs text-blue-600 dark:text-blue-400 truncate max-w-[280px] cursor-pointer hover:text-blue-500 hover:underline',
|
||||
title: `点击下载: ${file}`,
|
||||
onClick: () => downloadFile(file)
|
||||
}, [
|
||||
h('i', { class: 'fas fa-download text-blue-500 mr-1' }),
|
||||
getFileName(file)
|
||||
])
|
||||
),
|
||||
getProofFiles(row.proof_files).length > 2 ?
|
||||
h('div', { class: 'text-xs text-gray-400' }, `还有 ${getProofFiles(row.proof_files).length - 2} 个文件...`) : null
|
||||
]) :
|
||||
h('div', { class: 'text-xs text-gray-400' }, '无证明文件'),
|
||||
// 第三行:处理备注(如果有)
|
||||
row.note ?
|
||||
h('div', { class: 'space-y-1' }, [
|
||||
h('div', { class: 'text-xs text-gray-500 dark:text-gray-400' }, '处理备注:'),
|
||||
h('div', {
|
||||
class: 'text-xs text-yellow-600 dark:text-yellow-400 truncate max-w-[280px]',
|
||||
title: row.note
|
||||
}, [
|
||||
h('i', { class: 'fas fa-sticky-note text-yellow-500 mr-1' }),
|
||||
row.note.length > 30 ? `${row.note.slice(0, 30)}...` : row.note
|
||||
])
|
||||
]) : null
|
||||
].filter(Boolean))
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
render: (row: any) => {
|
||||
const type = getStatusType(row.status)
|
||||
return h('div', { class: 'space-y-1' }, [
|
||||
h('n-tag', {
|
||||
type: type,
|
||||
size: 'small',
|
||||
bordered: false
|
||||
}, { default: () => getStatusLabel(row.status) }),
|
||||
// 显示处理时间(如果已处理)
|
||||
(row.status !== 'pending' && row.updated_at) ?
|
||||
h('div', {
|
||||
class: 'text-xs text-gray-400',
|
||||
title: `处理时间: ${formatDateTime(row.updated_at)}`
|
||||
}, `更新: ${new Date(row.updated_at).toLocaleDateString()}`) : null
|
||||
].filter(Boolean))
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 160,
|
||||
render: (row: any) => {
|
||||
const buttons = [
|
||||
h('button', {
|
||||
class: 'px-2 py-1 text-xs bg-blue-100 hover:bg-blue-200 text-blue-700 dark:bg-blue-900/20 dark:text-blue-400 rounded transition-colors mb-1 w-full',
|
||||
onClick: () => viewClaim(row)
|
||||
}, [
|
||||
h('i', { class: 'fas fa-eye mr-1 text-xs' }),
|
||||
'查看详情'
|
||||
])
|
||||
]
|
||||
|
||||
if (row.status === 'pending') {
|
||||
buttons.push(
|
||||
h('button', {
|
||||
class: 'px-2 py-1 text-xs bg-green-100 hover:bg-green-200 text-green-700 dark:bg-green-900/20 dark:text-green-400 rounded transition-colors mb-1 w-full',
|
||||
onClick: () => updateClaimStatus(row, 'approved')
|
||||
}, [
|
||||
h('i', { class: 'fas fa-check mr-1 text-xs' }),
|
||||
'批准'
|
||||
]),
|
||||
h('button', {
|
||||
class: 'px-2 py-1 text-xs bg-red-100 hover:bg-red-200 text-red-700 dark:bg-red-900/20 dark:text-red-400 rounded transition-colors w-full',
|
||||
onClick: () => updateClaimStatus(row, 'rejected')
|
||||
}, [
|
||||
h('i', { class: 'fas fa-times mr-1 text-xs' }),
|
||||
'拒绝'
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
return h('div', { class: 'flex flex-col gap-1' }, buttons)
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
// 搜索防抖
|
||||
let searchTimeout: NodeJS.Timeout | null = null
|
||||
const debounceSearch = () => {
|
||||
if (searchTimeout) {
|
||||
clearTimeout(searchTimeout)
|
||||
}
|
||||
searchTimeout = setTimeout(() => {
|
||||
pagination.value.page = 1
|
||||
fetchClaims()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
// 获取版权申述列表
|
||||
const fetchClaims = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: any = {
|
||||
page: pagination.value.page,
|
||||
page_size: pagination.value.pageSize
|
||||
}
|
||||
|
||||
if (filters.value.status) params.status = filters.value.status
|
||||
if (filters.value.resourceKey) params.resource_key = filters.value.resourceKey
|
||||
|
||||
const response = await resourceApi.getCopyrightClaims(params)
|
||||
console.log(response)
|
||||
|
||||
// 检查响应格式并处理
|
||||
if (response && response.data && response.data.list !== undefined) {
|
||||
// 如果后端返回了分页格式,使用正确的字段
|
||||
claims.value = response.data.list || []
|
||||
pagination.value.total = response.data.total || 0
|
||||
} else {
|
||||
// 如果是其他格式,尝试直接使用响应
|
||||
claims.value = response || []
|
||||
pagination.value.total = response.length || 0
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取版权申述列表失败:', error)
|
||||
// 显示错误提示
|
||||
if (process.client) {
|
||||
notification.error({
|
||||
content: '获取版权申述列表失败',
|
||||
duration: 3000
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 重置筛选条件
|
||||
const resetFilters = () => {
|
||||
filters.value = {
|
||||
status: '',
|
||||
resourceKey: ''
|
||||
}
|
||||
pagination.value.page = 1
|
||||
fetchClaims()
|
||||
}
|
||||
|
||||
// 处理页面大小变化
|
||||
const handlePageSizeChange = (pageSize: number) => {
|
||||
pagination.value.pageSize = pageSize
|
||||
pagination.value.page = 1
|
||||
fetchClaims()
|
||||
}
|
||||
|
||||
// 查看申述详情
|
||||
const viewClaim = (claim: any) => {
|
||||
selectedClaim.value = claim
|
||||
showDetailModal.value = true
|
||||
}
|
||||
|
||||
// 更新申述状态
|
||||
const updateClaimStatus = async (claim: any, status: string) => {
|
||||
try {
|
||||
// 获取处理备注(如果需要)
|
||||
let note = ''
|
||||
if (status === 'rejected') {
|
||||
note = await getRejectionNote()
|
||||
if (note === null) return // 用户取消操作
|
||||
}
|
||||
|
||||
const response = await resourceApi.updateCopyrightClaim(claim.id, {
|
||||
status,
|
||||
note
|
||||
})
|
||||
|
||||
// 更新本地数据
|
||||
const index = claims.value.findIndex(c => c.id === claim.id)
|
||||
if (index !== -1) {
|
||||
claims.value[index] = response
|
||||
}
|
||||
|
||||
// 更新详情模态框中的数据
|
||||
if (selectedClaim.value && selectedClaim.value.id === claim.id) {
|
||||
selectedClaim.value = response
|
||||
}
|
||||
|
||||
if (process.client) {
|
||||
notification.success({
|
||||
content: '状态更新成功',
|
||||
duration: 3000
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('更新版权申述状态失败:', error)
|
||||
if (process.client) {
|
||||
notification.error({
|
||||
content: '状态更新失败',
|
||||
duration: 3000
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 获取拒绝原因输入
|
||||
const getRejectionNote = (): Promise<string | null> => {
|
||||
return new Promise((resolve) => {
|
||||
// 使用naive-ui的dialog API
|
||||
const { dialog } = useDialog()
|
||||
|
||||
let inputValue = ''
|
||||
|
||||
dialog.warning({
|
||||
title: '输入拒绝原因',
|
||||
content: () => h(nInput, {
|
||||
value: inputValue,
|
||||
onUpdateValue: (value) => inputValue = value,
|
||||
placeholder: '请输入拒绝的原因...',
|
||||
type: 'textarea',
|
||||
rows: 4
|
||||
}),
|
||||
positiveText: '确定',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: () => {
|
||||
if (!inputValue.trim()) {
|
||||
const { message } = useNotification()
|
||||
message.warning('请输入拒绝原因')
|
||||
return false // 不关闭对话框
|
||||
}
|
||||
resolve(inputValue)
|
||||
},
|
||||
onNegativeClick: () => {
|
||||
resolve(null)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 状态类型和标签
|
||||
const getStatusType = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending': return 'warning'
|
||||
case 'approved': return 'success'
|
||||
case 'rejected': return 'error'
|
||||
default: return 'default'
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending': return '待处理'
|
||||
case 'approved': return '已批准'
|
||||
case 'rejected': return '已拒绝'
|
||||
default: return status
|
||||
}
|
||||
}
|
||||
|
||||
// 申述人身份标签
|
||||
const getIdentityLabel = (identity: string) => {
|
||||
const identityMap: Record<string, string> = {
|
||||
'copyright_owner': '版权所有者',
|
||||
'authorized_agent': '授权代表',
|
||||
'law_firm': '律师事务所',
|
||||
'other': '其他'
|
||||
}
|
||||
return identityMap[identity] || identity
|
||||
}
|
||||
|
||||
// 证明类型标签
|
||||
const getProofTypeLabel = (proofType: string) => {
|
||||
const proofTypeMap: Record<string, string> = {
|
||||
'copyright_certificate': '版权登记证书',
|
||||
'first_publish_proof': '作品首发证明',
|
||||
'authorization_letter': '授权委托书',
|
||||
'identity_document': '身份证明文件',
|
||||
'other_proof': '其他证明材料'
|
||||
}
|
||||
return proofTypeMap[proofType] || proofType
|
||||
}
|
||||
|
||||
// 格式化日期时间
|
||||
const formatDateTime = (dateString: string) => {
|
||||
if (!dateString) return '-'
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
// 获取分类图标
|
||||
const getCategoryIcon = (category: string) => {
|
||||
if (!category) return 'folder';
|
||||
|
||||
// 根据分类名称返回对应的图标
|
||||
const categoryMap: Record<string, string> = {
|
||||
'文档': 'file-alt',
|
||||
'文档资料': 'file-alt',
|
||||
'压缩包': 'file-archive',
|
||||
'图片': 'images',
|
||||
'视频': 'film',
|
||||
'音乐': 'music',
|
||||
'电子书': 'book',
|
||||
'软件': 'cogs',
|
||||
'应用': 'mobile-alt',
|
||||
'游戏': 'gamepad',
|
||||
'资料': 'folder',
|
||||
'其他': 'file',
|
||||
'folder': 'folder',
|
||||
'file': 'file'
|
||||
};
|
||||
|
||||
return categoryMap[category] || 'folder';
|
||||
}
|
||||
|
||||
// 获取资源信息显示
|
||||
const getResourceInfo = (row: any) => {
|
||||
// 从后端返回的资源列表中获取信息
|
||||
const resources = row.resources || [];
|
||||
|
||||
if (resources.length > 0) {
|
||||
// 如果有多个资源,可以选择第一个或合并信息
|
||||
const resource = resources[0];
|
||||
return {
|
||||
title: resource.title || `资源: ${row.resource_key}`,
|
||||
description: resource.description || `资源详情: ${row.resource_key}`,
|
||||
category: resource.category || 'folder',
|
||||
resources: resources // 返回所有资源用于显示链接数量等
|
||||
}
|
||||
} else {
|
||||
// 如果没有关联资源,使用默认值
|
||||
return {
|
||||
title: `资源: ${row.resource_key}`,
|
||||
description: `资源详情: ${row.resource_key}`,
|
||||
category: 'folder',
|
||||
resources: []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 解析证明文件字符串
|
||||
const getProofFiles = (proofFiles: string) => {
|
||||
if (!proofFiles) return []
|
||||
|
||||
console.log('原始证明文件数据:', proofFiles)
|
||||
|
||||
try {
|
||||
// 尝试解析为JSON格式
|
||||
const parsed = JSON.parse(proofFiles)
|
||||
console.log('JSON解析结果:', parsed)
|
||||
|
||||
if (Array.isArray(parsed)) {
|
||||
// 处理对象数组格式:[{id: "xxx", name: "文件名.pdf", status: "pending"}]
|
||||
const fileObjects = parsed.filter(item => item && typeof item === 'object')
|
||||
if (fileObjects.length > 0) {
|
||||
// 返回原始对象,包含完整信息
|
||||
console.log('解析出文件对象数组:', fileObjects)
|
||||
return fileObjects
|
||||
}
|
||||
|
||||
// 如果不是对象数组,尝试作为字符串数组处理
|
||||
const files = parsed.filter(file => file && typeof file === 'string' && file.trim()).map(file => file.trim())
|
||||
if (files.length > 0) {
|
||||
console.log('解析出的文件字符串数组:', files)
|
||||
return files
|
||||
}
|
||||
} else if (typeof parsed === 'object' && parsed.url) {
|
||||
console.log('解析出的单个文件:', parsed.url)
|
||||
return [parsed.url]
|
||||
} else if (typeof parsed === 'object' && parsed.files) {
|
||||
// 处理 {files: ["url1", "url2"]} 格式
|
||||
if (Array.isArray(parsed.files)) {
|
||||
const files = parsed.files.filter(file => file && file.trim()).map(file => file.trim())
|
||||
console.log('解析出的files数组:', files)
|
||||
return files
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('JSON解析失败,尝试分隔符解析:', e.message)
|
||||
// 如果不是JSON格式,按分隔符解析
|
||||
// 假设文件URL以逗号、分号或换行符分隔
|
||||
const files = proofFiles.split(/[,;\n\r]+/).filter(file => file.trim()).map(file => file.trim())
|
||||
console.log('分隔符解析结果:', files)
|
||||
return files
|
||||
}
|
||||
|
||||
console.log('未解析出任何文件')
|
||||
return []
|
||||
}
|
||||
|
||||
// 获取文件名
|
||||
const getFileName = (fileInfo: any) => {
|
||||
if (!fileInfo) return '未知文件'
|
||||
|
||||
// 如果是对象,优先使用name字段
|
||||
if (typeof fileInfo === 'object') {
|
||||
return fileInfo.name || fileInfo.id || '未知文件'
|
||||
}
|
||||
|
||||
// 如果是字符串,从URL中提取文件名
|
||||
const fileName = fileInfo.split('/').pop() || fileInfo.split('\\').pop() || fileInfo
|
||||
|
||||
// 如果URL太长,截断显示
|
||||
return fileName.length > 50 ? fileName.substring(0, 47) + '...' : fileName
|
||||
}
|
||||
|
||||
// 下载文件
|
||||
const downloadFile = async (fileInfo: any) => {
|
||||
console.log('尝试下载文件:', fileInfo)
|
||||
|
||||
if (!fileInfo) {
|
||||
console.error('文件信息为空')
|
||||
if (process.client) {
|
||||
notification.warning({
|
||||
content: '文件信息无效',
|
||||
duration: 3000
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
let downloadUrl = ''
|
||||
let fileName = ''
|
||||
|
||||
// 处理文件对象格式:{id: "xxx", name: "文件名.pdf", status: "pending"}
|
||||
if (typeof fileInfo === 'object' && fileInfo.id) {
|
||||
fileName = fileInfo.name || fileInfo.id
|
||||
// 构建下载API URL,假设有 /api/files/{id} 端点
|
||||
downloadUrl = `/api/files/${fileInfo.id}`
|
||||
console.log('文件对象下载:', { id: fileInfo.id, name: fileName, url: downloadUrl })
|
||||
}
|
||||
// 处理字符串格式(直接是URL)
|
||||
else if (typeof fileInfo === 'string') {
|
||||
downloadUrl = fileInfo
|
||||
fileName = getFileName(fileInfo)
|
||||
|
||||
// 检查是否是文件名(不包含http://或https://或/开头)
|
||||
if (!fileInfo.match(/^https?:\/\//) && !fileInfo.startsWith('/')) {
|
||||
console.log('检测到纯文件名,需要通过API下载:', fileName)
|
||||
|
||||
if (process.client) {
|
||||
notification.info({
|
||||
content: `文件 "${fileName}" 需要通过API下载,功能开发中...`,
|
||||
duration: 3000
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 处理相对路径URL
|
||||
if (fileInfo.startsWith('/uploads/')) {
|
||||
downloadUrl = `${window.location.origin}${fileInfo}`
|
||||
console.log('处理本地文件URL:', downloadUrl)
|
||||
}
|
||||
}
|
||||
|
||||
if (!downloadUrl) {
|
||||
console.error('无法确定下载URL')
|
||||
if (process.client) {
|
||||
notification.warning({
|
||||
content: '无法确定下载地址',
|
||||
duration: 3000
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 创建下载链接
|
||||
const link = document.createElement('a')
|
||||
link.href = downloadUrl
|
||||
link.target = '_blank' // 在新标签页打开,避免跨域问题
|
||||
|
||||
// 设置下载文件名
|
||||
link.download = fileName.includes('.') ? fileName : fileName + '.file'
|
||||
|
||||
console.log('下载参数:', {
|
||||
originalInfo: fileInfo,
|
||||
downloadUrl: downloadUrl,
|
||||
fileName: fileName
|
||||
})
|
||||
|
||||
// 添加到页面并触发点击
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
|
||||
if (process.client) {
|
||||
notification.success({
|
||||
content: `开始下载: ${fileName}`,
|
||||
duration: 2000
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('下载文件失败:', error)
|
||||
if (process.client) {
|
||||
notification.error({
|
||||
content: `下载失败: ${error.message}`,
|
||||
duration: 3000
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化数据
|
||||
onMounted(() => {
|
||||
fetchClaims()
|
||||
})
|
||||
</script>
|
||||
@@ -22,7 +22,7 @@
|
||||
</n-card>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
<!-- 今日资源/总资源数 -->
|
||||
<n-card>
|
||||
<div class="flex items-center">
|
||||
@@ -85,6 +85,27 @@
|
||||
</n-button>
|
||||
</template>
|
||||
</n-card>
|
||||
|
||||
<!-- Google索引统计 -->
|
||||
<n-card>
|
||||
<div class="flex items-center">
|
||||
<div class="p-3 bg-red-100 dark:bg-red-900 rounded-lg">
|
||||
<i class="fab fa-google text-red-600 dark:text-red-400 text-xl"></i>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">已索引URL/总URL</p>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ googleIndexStats.indexedURLs || 0 }}/{{ googleIndexStats.totalURLs || 0 }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<n-button text type="primary" @click="navigateTo('/admin/seo#google-index')">
|
||||
查看详情
|
||||
<template #icon>
|
||||
<i class="fas fa-arrow-right"></i>
|
||||
</template>
|
||||
</n-button>
|
||||
</template>
|
||||
</n-card>
|
||||
</div>
|
||||
|
||||
<!-- 平台管理 -->
|
||||
@@ -149,6 +170,17 @@ import { useApiFetch } from '~/composables/useApiFetch'
|
||||
import { parseApiResponse } from '~/composables/useApi'
|
||||
import Chart from 'chart.js/auto'
|
||||
|
||||
// Google索引统计
|
||||
const googleIndexStats = ref({
|
||||
totalURLs: 0,
|
||||
indexedURLs: 0,
|
||||
errorURLs: 0,
|
||||
totalTasks: 0,
|
||||
runningTasks: 0,
|
||||
completedTasks: 0,
|
||||
failedTasks: 0
|
||||
})
|
||||
|
||||
// API
|
||||
const statsApi = useStatsApi()
|
||||
const panApi = usePanApi()
|
||||
@@ -370,10 +402,23 @@ watch([weeklyViews, weeklySearches], () => {
|
||||
})
|
||||
})
|
||||
|
||||
// 加载Google索引统计
|
||||
const loadGoogleIndexStats = async () => {
|
||||
try {
|
||||
const response = await useApiFetch('/google-index/status').then(parseApiResponse)
|
||||
if (response && response.data) {
|
||||
googleIndexStats.value = response.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载Google索引统计失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 组件挂载后初始化图表和数据
|
||||
onMounted(async () => {
|
||||
console.log('组件挂载,开始初始化...')
|
||||
await fetchTrendData()
|
||||
await loadGoogleIndexStats() // 加载Google索引统计
|
||||
console.log('数据获取完成,准备初始化图表...')
|
||||
nextTick(() => {
|
||||
console.log('nextTick执行,开始初始化图表...')
|
||||
|
||||
559
web/pages/admin/reports.vue
Normal file
559
web/pages/admin/reports.vue
Normal file
@@ -0,0 +1,559 @@
|
||||
<template>
|
||||
<AdminPageLayout>
|
||||
<template #page-header>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-flag text-red-500 mr-2"></i>
|
||||
举报管理
|
||||
</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400">管理用户提交的资源举报信息</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 过滤栏 - 搜索和操作 -->
|
||||
<template #filter-bar>
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex gap-2">
|
||||
<!-- 空白区域用于按钮 -->
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<div class="relative">
|
||||
<n-input
|
||||
v-model:value="filters.resourceKey"
|
||||
@input="debounceSearch"
|
||||
type="text"
|
||||
placeholder="搜索资源Key..."
|
||||
clearable
|
||||
>
|
||||
<template #prefix>
|
||||
<i class="fas fa-search text-gray-400 text-sm"></i>
|
||||
</template>
|
||||
</n-input>
|
||||
</div>
|
||||
<n-select
|
||||
v-model:value="filters.status"
|
||||
:options="[
|
||||
{ label: '全部状态', value: '' },
|
||||
{ label: '待处理', value: 'pending' },
|
||||
{ label: '已批准', value: 'approved' },
|
||||
{ label: '已拒绝', value: 'rejected' }
|
||||
]"
|
||||
placeholder="状态"
|
||||
clearable
|
||||
@update:value="fetchReports"
|
||||
style="width: 150px"
|
||||
/>
|
||||
<n-button @click="resetFilters" type="tertiary">
|
||||
<template #icon>
|
||||
<i class="fas fa-redo"></i>
|
||||
</template>
|
||||
重置
|
||||
</n-button>
|
||||
<n-button @click="fetchReports" type="tertiary">
|
||||
<template #icon>
|
||||
<i class="fas fa-refresh"></i>
|
||||
</template>
|
||||
刷新
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 内容区 - 举报数据 -->
|
||||
<template #content>
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="flex h-full items-center justify-center py-8">
|
||||
<n-spin size="large" />
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-else-if="reports.length === 0" class="text-center py-8">
|
||||
<svg class="w-16 h-16 text-gray-300 dark:text-gray-600 mb-4 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 48 48">
|
||||
<circle cx="24" cy="24" r="20" stroke-width="3" stroke-dasharray="6 6" />
|
||||
<path d="M16 24h16M24 16v16" stroke-width="3" stroke-linecap="round" />
|
||||
</svg>
|
||||
<div class="text-lg font-semibold text-gray-400 dark:text-gray-500 mb-2">暂无举报记录</div>
|
||||
<div class="text-sm text-gray-400 dark:text-gray-600 mb-4">目前没有用户提交的举报信息</div>
|
||||
</div>
|
||||
|
||||
<!-- 数据表格 - 自适应高度 -->
|
||||
<div v-else class="flex flex-col h-full overflow-auto">
|
||||
<n-data-table
|
||||
:columns="columns"
|
||||
:data="reports"
|
||||
:pagination="false"
|
||||
:bordered="false"
|
||||
:single-line="false"
|
||||
:loading="loading"
|
||||
:scroll-x="1200"
|
||||
class="h-full"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 内容区footer - 分页组件 -->
|
||||
<template #content-footer>
|
||||
<div class="p-4">
|
||||
<div class="flex justify-center">
|
||||
<n-pagination
|
||||
v-model:page="pagination.page"
|
||||
v-model:page-size="pagination.pageSize"
|
||||
:item-count="pagination.total"
|
||||
:page-sizes="[50, 100, 200, 500]"
|
||||
show-size-picker
|
||||
@update:page="fetchReports"
|
||||
@update:page-size="handlePageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
</AdminPageLayout>
|
||||
|
||||
<!-- 查看举报详情模态框 -->
|
||||
<n-modal v-model:show="showDetailModal" :mask-closable="false" preset="card" :style="{ maxWidth: '600px', width: '90%' }" title="举报详情">
|
||||
<div v-if="selectedReport" class="space-y-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">举报ID</h3>
|
||||
<p class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ selectedReport.id }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">资源Key</h3>
|
||||
<p class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ selectedReport.resource_key }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">举报原因</h3>
|
||||
<p class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ getReasonLabel(selectedReport.reason) }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">详细描述</h3>
|
||||
<p class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ selectedReport.description }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">联系方式</h3>
|
||||
<p class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ selectedReport.contact || '未提供' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">提交时间</h3>
|
||||
<p class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ formatDateTime(selectedReport.created_at) }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">IP地址</h3>
|
||||
<p class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ selectedReport.ip_address || '未知' }}</p>
|
||||
</div>
|
||||
<div v-if="selectedReport.note">
|
||||
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">处理备注</h3>
|
||||
<p class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ selectedReport.note }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 设置页面标题和元信息
|
||||
useHead({
|
||||
title: '举报管理 - 管理后台',
|
||||
meta: [
|
||||
{ name: 'description', content: '管理用户提交的资源举报信息' }
|
||||
]
|
||||
})
|
||||
|
||||
// 设置页面布局和认证保护
|
||||
definePageMeta({
|
||||
layout: 'admin',
|
||||
middleware: ['auth', 'admin']
|
||||
})
|
||||
|
||||
import { h } from 'vue'
|
||||
const message = useMessage()
|
||||
const notification = useNotification()
|
||||
const dialog = useDialog()
|
||||
|
||||
const { resourceApi } = useApi()
|
||||
const loading = ref(false)
|
||||
const reports = ref<any[]>([])
|
||||
const showDetailModal = ref(false)
|
||||
const selectedReport = ref<any>(null)
|
||||
|
||||
// 分页和筛选状态
|
||||
const pagination = ref({
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
total: 0
|
||||
})
|
||||
|
||||
const filters = ref({
|
||||
status: '',
|
||||
resourceKey: ''
|
||||
})
|
||||
|
||||
// 表格列定义
|
||||
const columns = [
|
||||
{
|
||||
title: 'ID',
|
||||
key: 'id',
|
||||
width: 30,
|
||||
render: (row: any) => {
|
||||
return h('span', { class: 'font-medium' }, row.id)
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '资源',
|
||||
key: 'resource_key',
|
||||
width: 200,
|
||||
render: (row: any) => {
|
||||
const resourceInfo = getResourceInfo(row);
|
||||
return h('div', { class: 'space-y-1' }, [
|
||||
// 第一行:标题(单行,省略号)
|
||||
h('div', {
|
||||
class: 'font-medium text-sm truncate max-w-[200px]',
|
||||
style: { maxWidth: '200px' },
|
||||
title: resourceInfo.title // 鼠标hover显示完整标题
|
||||
}, resourceInfo.title),
|
||||
// 第二行:详情(单行,省略号)
|
||||
h('div', {
|
||||
class: 'text-xs text-gray-500 dark:text-gray-400 truncate max-w-[200px]',
|
||||
style: { maxWidth: '200px' },
|
||||
title: resourceInfo.description // 鼠标hover显示完整描述
|
||||
}, resourceInfo.description),
|
||||
// 第三行:分类图片和链接数
|
||||
h('div', { class: 'flex items-center gap-1' }, [
|
||||
h('i', {
|
||||
class: `fas fa-${getCategoryIcon(resourceInfo.category)} text-blue-500 text-xs`,
|
||||
// 鼠标hover显示第一个资源的链接地址
|
||||
title: resourceInfo.resources.length > 0 ? `链接地址: ${resourceInfo.resources[0].save_url || resourceInfo.resources[0].url}` : `资源链接地址: ${row.resource_key}`
|
||||
}),
|
||||
h('span', { class: 'text-xs text-gray-400' }, `链接数: ${resourceInfo.resources.length}`)
|
||||
])
|
||||
])
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '举报原因',
|
||||
key: 'reason',
|
||||
width: 100,
|
||||
render: (row: any) => {
|
||||
return h('div', { class: 'space-y-1' }, [
|
||||
// 举报原因和描述提示
|
||||
h('div', {
|
||||
class: 'flex items-center gap-1 truncate max-w-[80px]',
|
||||
style: { maxWidth: '80px' }
|
||||
}, [
|
||||
h('span', null, getReasonLabel(row.reason)),
|
||||
// 添加描述提示图片
|
||||
h('i', {
|
||||
class: 'fas fa-info-circle text-blue-400 cursor-pointer text-xs ml-1',
|
||||
title: row.description // 鼠标hover显示描述
|
||||
})
|
||||
]),
|
||||
// 举报时间
|
||||
h('div', {
|
||||
class: 'text-xs text-gray-400 truncate max-w-[80px]',
|
||||
style: { maxWidth: '80px' }
|
||||
}, `举报时间: ${formatDateTime(row.created_at)}`),
|
||||
// 联系方式
|
||||
h('div', {
|
||||
class: 'text-xs text-gray-500 dark:text-gray-400 truncate max-w-[80px]',
|
||||
style: { maxWidth: '80px' }
|
||||
}, `联系方式: ${row.contact || '未提供'}`)
|
||||
])
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
width: 50,
|
||||
render: (row: any) => {
|
||||
const type = getStatusType(row.status)
|
||||
return h('n-tag', {
|
||||
type: type,
|
||||
size: 'small',
|
||||
bordered: false
|
||||
}, { default: () => getStatusLabel(row.status) })
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 180,
|
||||
render: (row: any) => {
|
||||
const buttons = [
|
||||
h('button', {
|
||||
class: 'px-2 py-1 text-xs bg-blue-100 hover:bg-blue-200 text-blue-700 dark:bg-blue-900/20 dark:text-blue-400 rounded transition-colors mr-1',
|
||||
onClick: () => viewReport(row)
|
||||
}, [
|
||||
h('i', { class: 'fas fa-eye mr-1 text-xs' }),
|
||||
'查看'
|
||||
])
|
||||
]
|
||||
|
||||
if (row.status === 'pending') {
|
||||
buttons.push(
|
||||
h('button', {
|
||||
class: 'px-2 py-1 text-xs bg-green-100 hover:bg-green-200 text-green-700 dark:bg-green-900/20 dark:text-green-400 rounded transition-colors mr-1',
|
||||
onClick: () => updateReportStatus(row, 'approved')
|
||||
}, [
|
||||
h('i', { class: 'fas fa-check mr-1 text-xs' }),
|
||||
'批准'
|
||||
]),
|
||||
h('button', {
|
||||
class: 'px-2 py-1 text-xs bg-red-100 hover:bg-red-200 text-red-700 dark:bg-red-900/20 dark:text-red-400 rounded transition-colors',
|
||||
onClick: () => updateReportStatus(row, 'rejected')
|
||||
}, [
|
||||
h('i', { class: 'fas fa-times mr-1 text-xs' }),
|
||||
'拒绝'
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
return h('div', { class: 'flex items-center gap-1' }, buttons)
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
// 搜索防抖
|
||||
let searchTimeout: NodeJS.Timeout | null = null
|
||||
const debounceSearch = () => {
|
||||
if (searchTimeout) {
|
||||
clearTimeout(searchTimeout)
|
||||
}
|
||||
searchTimeout = setTimeout(() => {
|
||||
pagination.value.page = 1
|
||||
fetchReports()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
// 获取举报列表
|
||||
const fetchReports = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: any = {
|
||||
page: pagination.value.page,
|
||||
page_size: pagination.value.pageSize
|
||||
}
|
||||
|
||||
if (filters.value.status) params.status = filters.value.status
|
||||
if (filters.value.resourceKey) params.resource_key = filters.value.resourceKey
|
||||
|
||||
// 使用原始API调用以获取完整的分页信息
|
||||
const rawResponse = await resourceApi.getReportsRaw(params)
|
||||
console.log(rawResponse)
|
||||
|
||||
// 检查响应格式并处理
|
||||
if (rawResponse && rawResponse.data && rawResponse.data.list !== undefined) {
|
||||
// 如果后端返回了分页格式,使用正确的字段
|
||||
reports.value = rawResponse.data.list || []
|
||||
pagination.value.total = rawResponse.data.total || 0
|
||||
} else {
|
||||
// 如果是其他格式,尝试直接使用响应
|
||||
reports.value = rawResponse || []
|
||||
pagination.value.total = rawResponse.length || 0
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取举报列表失败:', error)
|
||||
// 显示错误提示
|
||||
if (process.client) {
|
||||
notification.error({
|
||||
content: '获取举报列表失败',
|
||||
duration: 3000
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 重置筛选条件
|
||||
const resetFilters = () => {
|
||||
filters.value = {
|
||||
status: '',
|
||||
resourceKey: ''
|
||||
}
|
||||
pagination.value.page = 1
|
||||
fetchReports()
|
||||
}
|
||||
|
||||
// 处理页面大小变化
|
||||
const handlePageSizeChange = (pageSize: number) => {
|
||||
pagination.value.pageSize = pageSize
|
||||
pagination.value.page = 1
|
||||
fetchReports()
|
||||
}
|
||||
|
||||
// 查看举报详情
|
||||
const viewReport = (report: any) => {
|
||||
selectedReport.value = report
|
||||
showDetailModal.value = true
|
||||
}
|
||||
|
||||
// 更新举报状态
|
||||
const updateReportStatus = async (report: any, status: string) => {
|
||||
try {
|
||||
// 获取处理备注(如果需要)
|
||||
let note = ''
|
||||
if (status === 'rejected') {
|
||||
note = await getRejectionNote()
|
||||
if (note === null) return // 用户取消操作
|
||||
}
|
||||
|
||||
const response = await resourceApi.updateReport(report.id, {
|
||||
status,
|
||||
note
|
||||
})
|
||||
|
||||
// 更新本地数据
|
||||
const index = reports.value.findIndex(r => r.id === report.id)
|
||||
if (index !== -1) {
|
||||
reports.value[index] = response
|
||||
}
|
||||
|
||||
// 更新详情模态框中的数据
|
||||
if (selectedReport.value && selectedReport.value.id === report.id) {
|
||||
selectedReport.value = response
|
||||
}
|
||||
|
||||
if (process.client) {
|
||||
notification.success({
|
||||
content: '状态更新成功',
|
||||
duration: 3000
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('更新举报状态失败:', error)
|
||||
if (process.client) {
|
||||
notification.error({
|
||||
content: '状态更新失败',
|
||||
duration: 3000
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 获取拒绝原因输入
|
||||
const getRejectionNote = (): Promise<string | null> => {
|
||||
return new Promise((resolve) => {
|
||||
// 使用naive-ui的dialog API
|
||||
const { dialog } = useDialog()
|
||||
|
||||
let inputValue = ''
|
||||
|
||||
dialog.warning({
|
||||
title: '输入拒绝原因',
|
||||
content: () => h(nInput, {
|
||||
value: inputValue,
|
||||
onUpdateValue: (value) => inputValue = value,
|
||||
placeholder: '请输入拒绝的原因...',
|
||||
type: 'textarea',
|
||||
rows: 4
|
||||
}),
|
||||
positiveText: '确定',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: () => {
|
||||
if (!inputValue.trim()) {
|
||||
const { message } = useNotification()
|
||||
message.warning('请输入拒绝原因')
|
||||
return false // 不关闭对话框
|
||||
}
|
||||
resolve(inputValue)
|
||||
},
|
||||
onNegativeClick: () => {
|
||||
resolve(null)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 状态类型和标签
|
||||
const getStatusType = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending': return 'warning'
|
||||
case 'approved': return 'success'
|
||||
case 'rejected': return 'error'
|
||||
default: return 'default'
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending': return '待处理'
|
||||
case 'approved': return '已批准'
|
||||
case 'rejected': return '已拒绝'
|
||||
default: return status
|
||||
}
|
||||
}
|
||||
|
||||
// 举报原因标签
|
||||
const getReasonLabel = (reason: string) => {
|
||||
const reasonMap: Record<string, string> = {
|
||||
'link_invalid': '链接已失效',
|
||||
'download_failed': '资源无法下载',
|
||||
'content_mismatch': '资源内容不符',
|
||||
'malicious': '包含恶意软件',
|
||||
'copyright': '版权问题',
|
||||
'other': '其他问题'
|
||||
}
|
||||
return reasonMap[reason] || reason
|
||||
}
|
||||
|
||||
// 格式化日期时间
|
||||
const formatDateTime = (dateString: string) => {
|
||||
if (!dateString) return '-'
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
// 获取分类图标
|
||||
const getCategoryIcon = (category: string) => {
|
||||
if (!category) return 'folder';
|
||||
|
||||
// 根据分类名称返回对应的图标
|
||||
const categoryMap: Record<string, string> = {
|
||||
'文档': 'file-alt',
|
||||
'文档资料': 'file-alt',
|
||||
'压缩包': 'file-archive',
|
||||
'图片': 'images',
|
||||
'视频': 'film',
|
||||
'音乐': 'music',
|
||||
'电子书': 'book',
|
||||
'软件': 'cogs',
|
||||
'应用': 'mobile-alt',
|
||||
'游戏': 'gamepad',
|
||||
'资料': 'folder',
|
||||
'其他': 'file',
|
||||
'folder': 'folder',
|
||||
'file': 'file'
|
||||
};
|
||||
|
||||
return categoryMap[category] || 'folder';
|
||||
}
|
||||
|
||||
// 获取资源信息显示
|
||||
const getResourceInfo = (row: any) => {
|
||||
// 从后端返回的资源列表中获取信息
|
||||
const resources = row.resources || [];
|
||||
|
||||
if (resources.length > 0) {
|
||||
// 如果有多个资源,可以选择第一个或合并信息
|
||||
const resource = resources[0];
|
||||
return {
|
||||
title: resource.title || `资源: ${row.resource_key}`,
|
||||
description: resource.description || `资源详情: ${row.resource_key}`,
|
||||
category: resource.category || 'folder',
|
||||
resources: resources // 返回所有资源用于显示链接数量等
|
||||
}
|
||||
} else {
|
||||
// 如果没有关联资源,使用默认值
|
||||
return {
|
||||
title: `资源: ${row.resource_key}`,
|
||||
description: `资源详情: ${row.resource_key}`,
|
||||
category: 'folder',
|
||||
resources: []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化数据
|
||||
onMounted(() => {
|
||||
fetchReports()
|
||||
})
|
||||
</script>
|
||||
@@ -431,7 +431,7 @@ const getCategoryName = (categoryId: number) => {
|
||||
|
||||
// 获取平台名称
|
||||
const getPlatformName = (platformId: number) => {
|
||||
console.log('platformId', platformId, platformsData.value)
|
||||
// console.log('platformId', platformId, platformsData.value)
|
||||
const platform = (platformsData.value as any)?.find((plat: any) => plat.id === platformId)
|
||||
return platform?.remark || platform?.name || '未知平台'
|
||||
}
|
||||
@@ -449,22 +449,22 @@ const fetchData = async () => {
|
||||
// 添加分类筛选
|
||||
if (selectedCategory.value) {
|
||||
params.category_id = selectedCategory.value
|
||||
console.log('添加分类筛选:', selectedCategory.value)
|
||||
// console.log('添加分类筛选:', selectedCategory.value)
|
||||
}
|
||||
|
||||
// 添加平台筛选
|
||||
if (selectedPlatform.value) {
|
||||
params.pan_id = selectedPlatform.value
|
||||
console.log('添加平台筛选:', selectedPlatform.value)
|
||||
// console.log('添加平台筛选:', selectedPlatform.value)
|
||||
}
|
||||
|
||||
console.log('请求参数:', params)
|
||||
console.log('pageSize:', pageSize.value)
|
||||
console.log('selectedCategory:', selectedCategory.value)
|
||||
console.log('selectedPlatform:', selectedPlatform.value)
|
||||
// console.log('请求参数:', params)
|
||||
// console.log('pageSize:', pageSize.value)
|
||||
// console.log('selectedCategory:', selectedCategory.value)
|
||||
// console.log('selectedPlatform:', selectedPlatform.value)
|
||||
const response = await resourceApi.getResources(params) as any
|
||||
console.log('API响应:', response)
|
||||
console.log('返回的资源数量:', response?.data?.length || 0)
|
||||
// console.log('API响应:', response)
|
||||
// console.log('返回的资源数量:', response?.data?.length || 0)
|
||||
|
||||
if (response && response.data) {
|
||||
// 处理嵌套的data结构:{data: {data: [...], total: ...}}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -34,6 +34,18 @@
|
||||
require-mark-placement="right-hanging"
|
||||
>
|
||||
<div class="space-y-6">
|
||||
<!-- 站点URL -->
|
||||
<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">站点URL</label>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">网站的基础URL,用于生成sitemap等,请包含协议名(如:https://example.com)</span>
|
||||
</div>
|
||||
<n-input
|
||||
v-model:value="configForm.site_url"
|
||||
placeholder="请输入站点URL,如:https://example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 网站标题 -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center space-x-2">
|
||||
@@ -298,6 +310,7 @@ interface Announcement {
|
||||
|
||||
// 配置表单数据类型
|
||||
interface SiteConfigForm {
|
||||
site_url: string
|
||||
site_title: string
|
||||
site_description: string
|
||||
keywords: string
|
||||
@@ -362,6 +375,7 @@ const {
|
||||
},
|
||||
// 字段映射:前端字段名 -> 后端字段名
|
||||
fieldMapping: {
|
||||
site_url: 'site_url',
|
||||
site_title: 'site_title',
|
||||
site_description: 'site_description',
|
||||
keywords: 'keywords',
|
||||
@@ -383,6 +397,7 @@ const {
|
||||
|
||||
// 配置表单数据
|
||||
const configForm = ref<SiteConfigForm>({
|
||||
site_url: '',
|
||||
site_title: '',
|
||||
site_description: '',
|
||||
keywords: '',
|
||||
@@ -424,6 +439,7 @@ const fetchConfig = async () => {
|
||||
|
||||
if (response) {
|
||||
const configData = {
|
||||
site_url: response.site_url || '',
|
||||
site_title: response.site_title || '',
|
||||
site_description: response.site_description || '',
|
||||
keywords: response.keywords || '',
|
||||
|
||||
@@ -459,13 +459,12 @@ definePageMeta({
|
||||
layout: 'default'
|
||||
})
|
||||
|
||||
// 页面元数据
|
||||
useHead({
|
||||
title: 'API文档 - 老九网盘资源数据库',
|
||||
meta: [
|
||||
{ name: 'description', content: '老九网盘资源数据库的公开API接口文档' },
|
||||
{ name: 'keywords', content: 'API,接口文档,网盘资源管理' }
|
||||
]
|
||||
// 设置页面SEO
|
||||
const { initSystemConfig, setApiDocsSeo } = useGlobalSeo()
|
||||
|
||||
onBeforeMount(async () => {
|
||||
await initSystemConfig()
|
||||
setApiDocsSeo()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -166,10 +166,18 @@ definePageMeta({
|
||||
layout: 'default'
|
||||
})
|
||||
|
||||
// 设置页面SEO
|
||||
const { initSystemConfig, setHotDramasSeo } = useGlobalSeo()
|
||||
|
||||
onBeforeMount(async () => {
|
||||
await initSystemConfig()
|
||||
setHotDramasSeo()
|
||||
})
|
||||
|
||||
const hotDramaApi = useHotDramaApi()
|
||||
const { data: hotDramsaResponse, error } = await hotDramaApi.getHotDramas({
|
||||
page: 1,
|
||||
page_size: 20
|
||||
page_size: 20
|
||||
})
|
||||
|
||||
const { getPosterUrl } = hotDramaApi
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div v-if="!systemConfig.maintenance_mode" class="min-h-screen bg-gray-50 dark:bg-slate-900 text-gray-800 dark:text-slate-100 flex flex-col">
|
||||
<div v-if="!systemConfig?.maintenance_mode" class="min-h-screen bg-gray-50 dark:bg-slate-900 text-gray-800 dark:text-slate-100 flex flex-col">
|
||||
<!-- 全局加载状态 -->
|
||||
<div v-if="pageLoading" class="fixed inset-0 bg-gray-900 bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg p-8 shadow-xl">
|
||||
@@ -173,8 +173,9 @@
|
||||
<tr
|
||||
v-for="(resource, index) in safeResources"
|
||||
:key="resource.id"
|
||||
:class="isUpdatedToday(resource.updated_at) ? 'hover:bg-pink-50 dark:hover:bg-pink-500/10 bg-pink-50/30 dark:bg-pink-500/5' : 'hover:bg-gray-50 dark:hover:bg-slate-700/50'"
|
||||
:class="isUpdatedToday(resource.updated_at) ? 'hover:bg-pink-50 dark:hover:bg-pink-500/10 bg-pink-50/30 dark:bg-pink-500/5 cursor-pointer' : 'hover:bg-gray-50 dark:hover:bg-slate-700/50 cursor-pointer'"
|
||||
:data-index="index"
|
||||
@click="navigateToDetail(resource.key)"
|
||||
>
|
||||
<td class="text-xs sm:text-sm w-20 pl-2 sm:pl-3">
|
||||
<div class="flex justify-center">
|
||||
@@ -229,23 +230,25 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 flex justify-end">
|
||||
<button
|
||||
class="mobile-link-btn flex items-center gap-1 text-xs"
|
||||
@click="toggleLink(resource)"
|
||||
<NuxtLink
|
||||
:to="`/r/${resource.key}`"
|
||||
class="mobile-link-btn flex items-center gap-1 text-xs no-underline"
|
||||
@click.stop
|
||||
>
|
||||
<i class="fas fa-eye"></i> 显示链接
|
||||
</button>
|
||||
<i class="fas fa-eye"></i> 查看详情
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-2 sm:px-6 py-2 sm:py-4 text-xs sm:text-sm hidden sm:table-cell w-32">
|
||||
<button
|
||||
class="text-blue-600 hover:text-blue-800 flex items-center gap-1 show-link-btn"
|
||||
@click="toggleLink(resource)"
|
||||
<NuxtLink
|
||||
:to="`/r/${resource.key}`"
|
||||
class="text-blue-600 hover:text-blue-800 flex items-center gap-1 show-link-btn"
|
||||
@click.stop
|
||||
>
|
||||
<i class="fas fa-eye"></i> 显示链接
|
||||
</button>
|
||||
<i class="fas fa-eye"></i> 查看详情
|
||||
</NuxtLink>
|
||||
</td>
|
||||
<td class="px-2 sm:px-6 py-2 sm:py-4 text-xs sm:text-sm text-gray-500 hidden sm:table-cell w-32" :title="resource.updated_at">
|
||||
<span v-html="formatRelativeTime(resource.updated_at)"></span>
|
||||
@@ -282,7 +285,7 @@
|
||||
<!-- 悬浮按钮组件 -->
|
||||
<FloatButtons />
|
||||
</div>
|
||||
<div v-if="systemConfig.maintenance_mode" class="fixed inset-0 z-[1000000] flex items-center justify-center bg-gradient-to-br from-yellow-100/80 via-gray-900/90 to-yellow-200/80 backdrop-blur-sm">
|
||||
<div v-if="systemConfig?.maintenance_mode" class="fixed inset-0 z-[1000000] flex items-center justify-center bg-gradient-to-br from-yellow-100/80 via-gray-900/90 to-yellow-200/80 backdrop-blur-sm">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-3xl shadow-2xl px-8 py-10 flex flex-col items-center max-w-xs w-full border border-yellow-200 dark:border-yellow-700">
|
||||
<i class="fas fa-tools text-yellow-500 text-5xl mb-6 animate-bounce-slow"></i>
|
||||
<h3 class="text-2xl font-extrabold text-yellow-600 dark:text-yellow-400 mb-2 tracking-wide drop-shadow">系统维护中</h3>
|
||||
@@ -298,43 +301,52 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 开发环境缓存信息组件 -->
|
||||
<SystemConfigCacheInfo />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 获取运行时配置
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
import { useResourceApi, useStatsApi, usePanApi, useSystemConfigApi, usePublicSystemConfigApi, useSearchStatsApi } from '~/composables/useApi'
|
||||
import { useResourceApi, useStatsApi, usePanApi, useSearchStatsApi } from '~/composables/useApi'
|
||||
import SystemConfigCacheInfo from '~/components/SystemConfigCacheInfo.vue'
|
||||
|
||||
const resourceApi = useResourceApi()
|
||||
const statsApi = useStatsApi()
|
||||
const panApi = usePanApi()
|
||||
const publicSystemConfigApi = usePublicSystemConfigApi()
|
||||
|
||||
// 获取路由参数 - 提前定义以避免初始化顺序问题
|
||||
// 路由参数已通过自动导入提供,直接使用
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
// 页面元数据 - 使用系统配置的标题
|
||||
const { data: systemConfigData } = await useAsyncData('systemConfig', () => publicSystemConfigApi.getPublicSystemConfig())
|
||||
// 使用系统配置Store(带缓存支持)
|
||||
const { useSystemConfigStore } = await import('~/stores/systemConfig')
|
||||
const systemConfigStore = useSystemConfigStore()
|
||||
|
||||
// 初始化系统配置(会自动使用缓存)
|
||||
await systemConfigStore.initConfig()
|
||||
|
||||
// 检查并自动刷新即将过期的缓存
|
||||
await systemConfigStore.checkAndRefreshCache()
|
||||
|
||||
// 获取平台名称的辅助函数
|
||||
const getPlatformName = (platformId: string) => {
|
||||
if (!platformId) return ''
|
||||
const platform = platforms.value.find((p: any) => p.id == platformId)
|
||||
const platformList = (platforms.value || []) as any[]
|
||||
const platform = platformList.find((p: any) => p.id == platformId)
|
||||
return platform?.name || ''
|
||||
}
|
||||
|
||||
// 动态生成页面标题和meta信息 - 修复安全访问问题
|
||||
// 动态生成页面标题和meta信息 - 使用缓存的系统配置
|
||||
const pageTitle = computed(() => {
|
||||
try {
|
||||
const config = systemConfigData.value as any
|
||||
const siteTitle = (config?.data?.site_title) ? config.data.site_title :
|
||||
(config?.site_title) ? config.site_title : '老九网盘资源数据库'
|
||||
const searchKeyword = (route.query && route.query.search) ? route.query.search as string : ''
|
||||
const platformId = (route.query && route.query.platform) ? route.query.platform as string : ''
|
||||
const config = systemConfigStore.config
|
||||
const siteTitle = config?.site_title || '老九网盘资源数据库'
|
||||
const searchKeyword = (route.query?.search) ? route.query.search as string : ''
|
||||
const platformId = (route.query?.platform) ? route.query.platform as string : ''
|
||||
const platformName = getPlatformName(platformId)
|
||||
|
||||
let title = siteTitle
|
||||
|
||||
// 根据搜索条件组合标题
|
||||
@@ -357,9 +369,8 @@ const pageTitle = computed(() => {
|
||||
|
||||
const pageDescription = computed(() => {
|
||||
try {
|
||||
const config = systemConfigData.value as any
|
||||
const baseDescription = (config?.data?.site_description) ? config.data.site_description :
|
||||
(config?.site_description) ? config.site_description : '老九网盘资源管理系统, 一个现代化的网盘资源数据库,支持多网盘自动化转存分享,支持百度网盘,阿里云盘,夸克网盘, 天翼云盘,迅雷云盘,123云盘,115网盘,UC网盘'
|
||||
const config = systemConfigStore.config
|
||||
const baseDescription = config?.site_description || '老九网盘资源管理系统, 一个现代化的网盘资源数据库,支持多网盘自动化转存分享,支持百度网盘,阿里云盘,夸克网盘, 天翼云盘,迅雷云盘,123云盘,115网盘,UC网盘'
|
||||
|
||||
const searchKeyword = (route.query && route.query.search) ? route.query.search as string : ''
|
||||
const platformId = (route.query && route.query.platform) ? route.query.platform as string : ''
|
||||
@@ -385,9 +396,8 @@ const pageDescription = computed(() => {
|
||||
|
||||
const pageKeywords = computed(() => {
|
||||
try {
|
||||
const config = systemConfigData.value as any
|
||||
const baseKeywords = (config?.data?.keywords) ? config.data.keywords :
|
||||
(config?.keywords) ? config.keywords : '网盘资源,资源管理,数据库'
|
||||
const config = systemConfigStore.config
|
||||
const baseKeywords = config?.keywords || '网盘资源,资源管理,数据库'
|
||||
|
||||
const searchKeyword = (route.query && route.query.search) ? route.query.search as string : ''
|
||||
const platformId = (route.query && route.query.platform) ? route.query.platform as string : ''
|
||||
@@ -411,22 +421,76 @@ const pageKeywords = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
// 设置动态SEO - 修复useHead 500错误
|
||||
useHead(() => {
|
||||
// 安全地获取标题,添加默认值
|
||||
const safeTitle = pageTitle.value || '老九网盘资源数据库 - 首页'
|
||||
const safeDescription = pageDescription.value || '老九网盘资源管理系统, 一个现代化的网盘资源数据库,支持多网盘自动化转存分享'
|
||||
const safeKeywords = pageKeywords.value || '网盘资源,资源管理,数据库'
|
||||
// 设置页面SEO
|
||||
const { initSystemConfig, setPageSeo, systemConfig: seoSystemConfig } = useGlobalSeo()
|
||||
|
||||
return {
|
||||
title: safeTitle,
|
||||
// 更新页面SEO的函数 - 合并所有SEO设置到一个函数中
|
||||
const updatePageSeo = () => {
|
||||
// 使用动态计算的标题,而不是默认的"首页"
|
||||
setPageSeo(pageTitle.value, {
|
||||
description: pageDescription.value,
|
||||
keywords: pageKeywords.value,
|
||||
ogImage: '/assets/images/og.webp' // 使用默认的OG图片
|
||||
})
|
||||
|
||||
// 设置HTML属性和canonical链接
|
||||
const config = useRuntimeConfig()
|
||||
const baseUrl = config.public.siteUrl || 'https://yourdomain.com' // 从环境变量获取
|
||||
const params = new URLSearchParams()
|
||||
if (route.query?.search) params.set('search', route.query.search as string)
|
||||
if (route.query?.platform) params.set('platform', route.query.platform as string)
|
||||
const queryString = params.toString()
|
||||
const canonicalUrl = queryString ? `${baseUrl}?${queryString}` : baseUrl
|
||||
|
||||
useHead({
|
||||
htmlAttrs: {
|
||||
lang: 'zh-CN'
|
||||
},
|
||||
link: [
|
||||
{
|
||||
rel: 'canonical',
|
||||
href: canonicalUrl
|
||||
}
|
||||
],
|
||||
meta: [
|
||||
{ name: 'description', content: safeDescription },
|
||||
{ name: 'keywords', content: safeKeywords }
|
||||
{
|
||||
property: 'og:image',
|
||||
content: '/assets/images/og.webp'
|
||||
}
|
||||
],
|
||||
script: [
|
||||
{
|
||||
type: 'application/ld+json',
|
||||
innerHTML: JSON.stringify({
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebSite",
|
||||
"name": (seoSystemConfig.value && seoSystemConfig.value.site_title) || '老九网盘资源数据库',
|
||||
"description": pageDescription.value,
|
||||
"url": canonicalUrl,
|
||||
"image": '/assets/images/og.webp'
|
||||
})
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onBeforeMount(async () => {
|
||||
await initSystemConfig()
|
||||
updatePageSeo()
|
||||
})
|
||||
|
||||
// 监听路由变化和系统配置数据,当搜索条件或配置改变时更新SEO
|
||||
watch(
|
||||
() => [route.query?.search, route.query?.platform, systemConfigStore.config],
|
||||
() => {
|
||||
// 使用nextTick确保响应式数据已更新
|
||||
nextTick(() => {
|
||||
updatePageSeo()
|
||||
})
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
// 响应式数据
|
||||
const showLinkModal = ref(false)
|
||||
const selectedResource = ref<any>(null)
|
||||
@@ -474,13 +538,13 @@ const handleResourceImageError = (event: Event) => {
|
||||
|
||||
// 使用 useAsyncData 获取资源数据
|
||||
const { data: resourcesData, pending, refresh } = await useAsyncData(
|
||||
() => `resources-1-${route.query.search || ''}-${route.query.platform || ''}`,
|
||||
() => `resources-1-${route.query?.search || ''}-${route.query?.platform || ''}`,
|
||||
async () => {
|
||||
// 如果有搜索关键词,使用带搜索参数的资源接口(后端会优先使用Meilisearch)
|
||||
if (route.query.search) {
|
||||
if (route.query?.search) {
|
||||
return await resourceApi.getResources({
|
||||
page: 1,
|
||||
page_size: 200,
|
||||
page_size: 50,
|
||||
search: route.query.search as string,
|
||||
pan_id: route.query.platform as string || ''
|
||||
})
|
||||
@@ -488,8 +552,8 @@ const { data: resourcesData, pending, refresh } = await useAsyncData(
|
||||
// 没有搜索关键词时,使用普通资源接口获取最新数据
|
||||
return await resourceApi.getResources({
|
||||
page: 1,
|
||||
page_size: 200,
|
||||
pan_id: route.query.platform as string || ''
|
||||
page_size: 50,
|
||||
pan_id: route.query?.platform as string || ''
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -544,24 +608,39 @@ watch(systemConfigError, (error) => {
|
||||
// 从 SSR 数据中获取值
|
||||
const safeResources = computed(() => {
|
||||
const data = resourcesData.value as any
|
||||
// console.log('原始API数据结构:', JSON.stringify(data, null, 2))
|
||||
let resources: any[] = []
|
||||
|
||||
// 处理嵌套的data结构:{data: {data: [...], total: ...}}
|
||||
if (data?.data?.data && Array.isArray(data.data.data)) {
|
||||
const resources = data.data.data
|
||||
resources = data.data.data
|
||||
console.log('第一层嵌套资源:', resources)
|
||||
return resources
|
||||
}
|
||||
// 处理直接的data结构:{data: [...], total: ...}
|
||||
if (data?.data && Array.isArray(data.data)) {
|
||||
const resources = data.data
|
||||
else if (data?.data && Array.isArray(data.data)) {
|
||||
resources = data.data
|
||||
// console.log('第二层嵌套资源:', resources)
|
||||
return resources
|
||||
}
|
||||
// 处理直接的数组结构
|
||||
if (Array.isArray(data)) {
|
||||
else if (Array.isArray(data)) {
|
||||
resources = data
|
||||
// console.log('直接数组结构:', data)
|
||||
return data
|
||||
}
|
||||
|
||||
// 根据 key 字段去重
|
||||
if (resources.length > 0) {
|
||||
const keyMap = new Map()
|
||||
const deduplicatedResources: any[] = []
|
||||
|
||||
for (const resource of resources) {
|
||||
const key = resource.key
|
||||
if (!keyMap.has(key)) {
|
||||
keyMap.set(key, true)
|
||||
deduplicatedResources.push(resource)
|
||||
}
|
||||
}
|
||||
|
||||
// console.log(`去重前: ${resources.length} 个资源, 去重后: ${deduplicatedResources.length} 个资源`)
|
||||
return deduplicatedResources
|
||||
}
|
||||
|
||||
// console.log('未匹配到任何数据结构')
|
||||
@@ -569,13 +648,13 @@ const safeResources = computed(() => {
|
||||
})
|
||||
const safeStats = computed(() => (statsData.value as any) || { total_resources: 0, total_categories: 0, total_tags: 0, total_views: 0, today_resources: 0 })
|
||||
const platforms = computed(() => (platformsData.value as any) || [])
|
||||
const systemConfig = computed(() => (systemConfigData.value as any)?.data || { site_title: '老九网盘资源数据库' })
|
||||
const systemConfig = computed(() => systemConfigStore.config || { site_title: '老九网盘资源数据库' })
|
||||
const safeLoading = computed(() => pending.value)
|
||||
|
||||
|
||||
// 从路由参数获取当前状态
|
||||
const searchQuery = ref(route.query.search as string || '')
|
||||
const selectedPlatform = computed(() => route.query.platform as string || '')
|
||||
const searchQuery = ref(route.query?.search as string || '')
|
||||
const selectedPlatform = computed(() => route.query?.platform as string || '')
|
||||
|
||||
// 记录搜索统计的函数
|
||||
const recordSearchStats = (keyword: string) => {
|
||||
@@ -607,11 +686,11 @@ const handleSearch = () => {
|
||||
onMounted(() => {
|
||||
// 初始化认证状态
|
||||
authInitialized.value = true
|
||||
|
||||
|
||||
animateCounters()
|
||||
|
||||
|
||||
// 页面挂载完成时,如果有搜索关键词,记录搜索统计
|
||||
if (process.client && route.query.search) {
|
||||
if (process.client && route.query?.search) {
|
||||
const searchKeyword = route.query.search as string
|
||||
recordSearchStats(searchKeyword)
|
||||
} else {
|
||||
@@ -629,49 +708,14 @@ const getPlatformIcon = (panId: string | number) => {
|
||||
|
||||
// 注意:链接访问统计已整合到 getResourceLink API 中
|
||||
|
||||
// 切换链接显示
|
||||
const toggleLink = async (resource: any) => {
|
||||
// 如果包含违禁词,直接显示禁止访问,不发送请求
|
||||
if (resource.has_forbidden_words) {
|
||||
selectedResource.value = {
|
||||
...resource,
|
||||
forbidden: true,
|
||||
error: '该资源包含违禁内容,无法访问',
|
||||
forbidden_words: resource.forbidden_words || []
|
||||
}
|
||||
showLinkModal.value = true
|
||||
return
|
||||
}
|
||||
// 导航到详情页
|
||||
const navigateToDetail = (key: string) => {
|
||||
router.push(`/r/${key}`)
|
||||
}
|
||||
|
||||
// 显示加载状态
|
||||
selectedResource.value = { ...resource, loading: true }
|
||||
showLinkModal.value = true
|
||||
|
||||
try {
|
||||
// 调用新的获取链接API(同时统计访问次数)
|
||||
const linkData = await resourceApi.getResourceLink(resource.id) as any
|
||||
console.log('获取到的链接数据:', linkData)
|
||||
|
||||
// 更新资源信息,包含新的链接信息
|
||||
selectedResource.value = {
|
||||
...resource,
|
||||
url: linkData.url,
|
||||
save_url: linkData.type === 'transferred' ? linkData.url : resource.save_url,
|
||||
loading: false,
|
||||
linkType: linkData.type,
|
||||
platform: linkData.platform,
|
||||
message: linkData.message
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('获取资源链接失败:', error)
|
||||
|
||||
// 其他错误
|
||||
selectedResource.value = {
|
||||
...resource,
|
||||
loading: false,
|
||||
error: '检测有效性失败,请自行验证'
|
||||
}
|
||||
}
|
||||
// 切换链接显示(保留用于其他可能的用途)
|
||||
const toggleLink = async (resource: any) => {
|
||||
navigateToDetail(resource.key)
|
||||
}
|
||||
|
||||
// 复制到剪贴板
|
||||
|
||||
@@ -172,9 +172,12 @@ definePageMeta({
|
||||
ssr: false
|
||||
})
|
||||
|
||||
// 设置页面标题
|
||||
useHead({
|
||||
title: '管理员登录 - 老九网盘资源数据库'
|
||||
// 设置页面SEO
|
||||
const { initSystemConfig, setLoginSeo } = useGlobalSeo()
|
||||
|
||||
onBeforeMount(async () => {
|
||||
await initSystemConfig()
|
||||
setLoginSeo()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -242,6 +242,14 @@ definePageMeta({
|
||||
layout: 'default'
|
||||
})
|
||||
|
||||
// 设置页面SEO
|
||||
const { initSystemConfig, setMonitorSeo } = useGlobalSeo()
|
||||
|
||||
onBeforeMount(async () => {
|
||||
await initSystemConfig()
|
||||
setMonitorSeo()
|
||||
})
|
||||
|
||||
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||||
import { useMonitorApi } from '~/composables/useApi'
|
||||
const monitorApi = useMonitorApi()
|
||||
|
||||
1399
web/pages/r/[key].vue
Normal file
1399
web/pages/r/[key].vue
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user