mirror of
https://github.com/ctwj/urldb.git
synced 2025-11-26 03:44:55 +08:00
Compare commits
69 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5f3b434b2 | ||
|
|
25c8611fe3 | ||
|
|
f949f755ce | ||
|
|
533b5dacc6 | ||
|
|
9371eaebf5 | ||
|
|
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 | ||
|
|
0e88374905 | ||
|
|
ca175ec59d | ||
|
|
ec4e0762d5 | ||
|
|
081a3a7222 | ||
|
|
6b8d2b3cf0 | ||
|
|
9333f9da94 | ||
|
|
806a724fb5 | ||
|
|
487f5c9559 | ||
|
|
18b7f89c49 | ||
|
|
db902f3742 | ||
|
|
42baa891f8 | ||
|
|
02d5d00510 | ||
|
|
d95c69142a | ||
|
|
2638ccb1e4 | ||
|
|
886d91ab10 | ||
|
|
ddad95be41 | ||
|
|
273800459f | ||
|
|
dbe24af4ac | ||
|
|
a598ef508c | ||
|
|
1ca4cce6bc | ||
|
|
270022188e | ||
|
|
dbde0e1675 | ||
|
|
b840680df0 | ||
|
|
651987731b | ||
|
|
8baf5c6c3d |
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,9 @@
|
||||
### v1.3.4
|
||||
1. 添加详情页
|
||||
|
||||
### v1.3.3
|
||||
1. 公众号自动回复
|
||||
|
||||
### v1.3.2
|
||||
1. 二维码美化
|
||||
2. TelegramBot参数调整
|
||||
|
||||
@@ -39,11 +39,11 @@
|
||||
- [服务器要求](https://ecn5khs4t956.feishu.cn/wiki/W8YBww1Mmiu4Cdkp5W4c8pFNnMf?from=from_copylink)
|
||||
- [QQ机器人](https://github.com/ctwj/astrbot_plugin_urldb)
|
||||
- [Telegram机器人](https://ecn5khs4t956.feishu.cn/wiki/SwkQw6AzRiFes7kxJXac3pd2ncb?from=from_copylink)
|
||||
- [微信公众号自动回复](https://ecn5khs4t956.feishu.cn/wiki/APOEwOyDYicKGHk7gTzcQKpynkf?from=from_copylink)
|
||||
|
||||
### v1.3.2
|
||||
1. 二维码美化
|
||||
2. TelegramBot参数调整
|
||||
3. 修复一些问题
|
||||
### v1.3.3
|
||||
1. 新增公众号自动回复
|
||||
2. 修复一些问题
|
||||
|
||||
|
||||
[详细改动记录](https://github.com/ctwj/urldb/blob/main/ChangeLog.md)
|
||||
|
||||
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)
|
||||
@@ -209,7 +211,27 @@ func createIndexes(db *gorm.DB) {
|
||||
db.Exec("CREATE INDEX IF NOT EXISTS idx_resource_tags_resource_id ON resource_tags(resource_id)")
|
||||
db.Exec("CREATE INDEX IF NOT EXISTS idx_resource_tags_tag_id ON resource_tags(tag_id)")
|
||||
|
||||
utils.Info("数据库索引创建完成(已移除全文搜索索引,准备使用Meilisearch)")
|
||||
// API访问日志表索引 - 高性能查询优化
|
||||
db.Exec("CREATE INDEX IF NOT EXISTS idx_api_access_logs_created_at ON api_access_logs(created_at DESC)")
|
||||
db.Exec("CREATE INDEX IF NOT EXISTS idx_api_access_logs_endpoint_status ON api_access_logs(endpoint, response_status)")
|
||||
db.Exec("CREATE INDEX IF NOT EXISTS idx_api_access_logs_ip_created ON api_access_logs(ip, created_at DESC)")
|
||||
db.Exec("CREATE INDEX IF NOT EXISTS idx_api_access_logs_method_endpoint ON api_access_logs(method, endpoint)")
|
||||
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")
|
||||
|
||||
// 任务和任务项表索引 - 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,
|
||||
}
|
||||
}
|
||||
|
||||
88
db/converter/wechat_bot_converter.go
Normal file
88
db/converter/wechat_bot_converter.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package converter
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/ctwj/urldb/db/dto"
|
||||
"github.com/ctwj/urldb/db/entity"
|
||||
)
|
||||
|
||||
// WechatBotConfigRequestToSystemConfigs 将微信机器人配置请求转换为系统配置实体
|
||||
func WechatBotConfigRequestToSystemConfigs(req dto.WechatBotConfigRequest) []entity.SystemConfig {
|
||||
configs := []entity.SystemConfig{
|
||||
{Key: entity.ConfigKeyWechatBotEnabled, Value: wechatBoolToString(req.Enabled)},
|
||||
{Key: entity.ConfigKeyWechatAppId, Value: req.AppID},
|
||||
{Key: entity.ConfigKeyWechatAppSecret, Value: req.AppSecret},
|
||||
{Key: entity.ConfigKeyWechatToken, Value: req.Token},
|
||||
{Key: entity.ConfigKeyWechatEncodingAesKey, Value: req.EncodingAesKey},
|
||||
{Key: entity.ConfigKeyWechatWelcomeMessage, Value: req.WelcomeMessage},
|
||||
{Key: entity.ConfigKeyWechatAutoReplyEnabled, Value: wechatBoolToString(req.AutoReplyEnabled)},
|
||||
{Key: entity.ConfigKeyWechatSearchLimit, Value: wechatIntToString(req.SearchLimit)},
|
||||
}
|
||||
return configs
|
||||
}
|
||||
|
||||
// SystemConfigToWechatBotConfig 将系统配置转换为微信机器人配置响应
|
||||
func SystemConfigToWechatBotConfig(configs []entity.SystemConfig) dto.WechatBotConfigResponse {
|
||||
resp := dto.WechatBotConfigResponse{
|
||||
Enabled: false,
|
||||
AppID: "",
|
||||
AppSecret: "",
|
||||
Token: "",
|
||||
EncodingAesKey: "",
|
||||
WelcomeMessage: "欢迎关注老九网盘资源库!发送关键词即可搜索资源。",
|
||||
AutoReplyEnabled: true,
|
||||
SearchLimit: 5,
|
||||
}
|
||||
|
||||
for _, config := range configs {
|
||||
switch config.Key {
|
||||
case entity.ConfigKeyWechatBotEnabled:
|
||||
resp.Enabled = config.Value == "true"
|
||||
case entity.ConfigKeyWechatAppId:
|
||||
resp.AppID = config.Value
|
||||
case entity.ConfigKeyWechatAppSecret:
|
||||
resp.AppSecret = config.Value
|
||||
case entity.ConfigKeyWechatToken:
|
||||
resp.Token = config.Value
|
||||
case entity.ConfigKeyWechatEncodingAesKey:
|
||||
resp.EncodingAesKey = config.Value
|
||||
case entity.ConfigKeyWechatWelcomeMessage:
|
||||
if config.Value != "" {
|
||||
resp.WelcomeMessage = config.Value
|
||||
}
|
||||
case entity.ConfigKeyWechatAutoReplyEnabled:
|
||||
resp.AutoReplyEnabled = config.Value == "true"
|
||||
case entity.ConfigKeyWechatSearchLimit:
|
||||
if config.Value != "" {
|
||||
resp.SearchLimit = wechatStringToInt(config.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
|
||||
// 辅助函数 - 使用大写名称避免与其他文件中的函数冲突
|
||||
func wechatBoolToString(b bool) string {
|
||||
if b {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
}
|
||||
|
||||
func wechatIntToString(i int) string {
|
||||
return strconv.Itoa(i)
|
||||
}
|
||||
|
||||
func wechatStringToInt(s string) int {
|
||||
if s == "" {
|
||||
return 0
|
||||
}
|
||||
|
||||
i, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return i
|
||||
}
|
||||
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"`
|
||||
@@ -72,19 +74,20 @@ type PanResponse struct {
|
||||
|
||||
// CksResponse Cookie响应
|
||||
type CksResponse struct {
|
||||
ID uint `json:"id"`
|
||||
PanID uint `json:"pan_id"`
|
||||
Idx int `json:"idx"`
|
||||
Ck string `json:"ck"`
|
||||
IsValid bool `json:"is_valid"`
|
||||
Space int64 `json:"space"`
|
||||
LeftSpace int64 `json:"left_space"`
|
||||
UsedSpace int64 `json:"used_space"`
|
||||
Username string `json:"username"`
|
||||
VipStatus bool `json:"vip_status"`
|
||||
ServiceType string `json:"service_type"`
|
||||
Remark string `json:"remark"`
|
||||
Pan *PanResponse `json:"pan,omitempty"`
|
||||
ID uint `json:"id"`
|
||||
PanID uint `json:"pan_id"`
|
||||
Idx int `json:"idx"`
|
||||
Ck string `json:"ck"`
|
||||
IsValid bool `json:"is_valid"`
|
||||
Space int64 `json:"space"`
|
||||
LeftSpace int64 `json:"left_space"`
|
||||
UsedSpace int64 `json:"used_space"`
|
||||
Username string `json:"username"`
|
||||
VipStatus bool `json:"vip_status"`
|
||||
ServiceType string `json:"service_type"`
|
||||
Remark string `json:"remark"`
|
||||
TransferredCount int64 `json:"transferred_count"` // 已转存资源数
|
||||
Pan *PanResponse `json:"pan,omitempty"`
|
||||
}
|
||||
|
||||
// ReadyResourceResponse 待处理资源响应
|
||||
|
||||
@@ -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 单个配置项
|
||||
|
||||
25
db/dto/wechat_bot.go
Normal file
25
db/dto/wechat_bot.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package dto
|
||||
|
||||
// WechatBotConfigRequest 微信公众号机器人配置请求
|
||||
type WechatBotConfigRequest struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
AppID string `json:"app_id"`
|
||||
AppSecret string `json:"app_secret"`
|
||||
Token string `json:"token"`
|
||||
EncodingAesKey string `json:"encoding_aes_key"`
|
||||
WelcomeMessage string `json:"welcome_message"`
|
||||
AutoReplyEnabled bool `json:"auto_reply_enabled"`
|
||||
SearchLimit int `json:"search_limit"`
|
||||
}
|
||||
|
||||
// WechatBotConfigResponse 微信公众号机器人配置响应
|
||||
type WechatBotConfigResponse struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
AppID string `json:"app_id"`
|
||||
AppSecret string `json:"app_secret"`
|
||||
Token string `json:"token"`
|
||||
EncodingAesKey string `json:"encoding_aes_key"`
|
||||
WelcomeMessage string `json:"welcome_message"`
|
||||
AutoReplyEnabled bool `json:"auto_reply_enabled"`
|
||||
SearchLimit int `json:"search_limit"`
|
||||
}
|
||||
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"
|
||||
}
|
||||
@@ -57,6 +57,16 @@ const (
|
||||
ConfigKeyTelegramProxyUsername = "telegram_proxy_username"
|
||||
ConfigKeyTelegramProxyPassword = "telegram_proxy_password"
|
||||
|
||||
// 微信公众号配置
|
||||
ConfigKeyWechatBotEnabled = "wechat_bot_enabled"
|
||||
ConfigKeyWechatAppId = "wechat_app_id"
|
||||
ConfigKeyWechatAppSecret = "wechat_app_secret"
|
||||
ConfigKeyWechatToken = "wechat_token"
|
||||
ConfigKeyWechatEncodingAesKey = "wechat_encoding_aes_key"
|
||||
ConfigKeyWechatWelcomeMessage = "wechat_welcome_message"
|
||||
ConfigKeyWechatAutoReplyEnabled = "wechat_auto_reply_enabled"
|
||||
ConfigKeyWechatSearchLimit = "wechat_search_limit"
|
||||
|
||||
// 界面配置
|
||||
ConfigKeyEnableAnnouncements = "enable_announcements"
|
||||
ConfigKeyAnnouncements = "announcements"
|
||||
@@ -64,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 配置类型常量
|
||||
@@ -135,6 +153,16 @@ const (
|
||||
ConfigResponseFieldTelegramProxyUsername = "telegram_proxy_username"
|
||||
ConfigResponseFieldTelegramProxyPassword = "telegram_proxy_password"
|
||||
|
||||
// 微信公众号配置字段
|
||||
ConfigResponseFieldWechatBotEnabled = "wechat_bot_enabled"
|
||||
ConfigResponseFieldWechatAppId = "wechat_app_id"
|
||||
ConfigResponseFieldWechatAppSecret = "wechat_app_secret"
|
||||
ConfigResponseFieldWechatToken = "wechat_token"
|
||||
ConfigResponseFieldWechatEncodingAesKey = "wechat_encoding_aes_key"
|
||||
ConfigResponseFieldWechatWelcomeMessage = "wechat_welcome_message"
|
||||
ConfigResponseFieldWechatAutoReplyEnabled = "wechat_auto_reply_enabled"
|
||||
ConfigResponseFieldWechatSearchLimit = "wechat_search_limit"
|
||||
|
||||
// 界面配置字段
|
||||
ConfigResponseFieldEnableAnnouncements = "enable_announcements"
|
||||
ConfigResponseFieldAnnouncements = "announcements"
|
||||
@@ -142,6 +170,9 @@ const (
|
||||
ConfigResponseFieldWechatSearchImage = "wechat_search_image"
|
||||
ConfigResponseFieldTelegramQrImage = "telegram_qr_image"
|
||||
ConfigResponseFieldQrCodeStyle = "qr_code_style"
|
||||
|
||||
// 网站URL配置字段
|
||||
ConfigResponseFieldWebsiteURL = "site_url"
|
||||
)
|
||||
|
||||
// ConfigDefaultValue 配置默认值常量
|
||||
@@ -200,6 +231,16 @@ const (
|
||||
ConfigDefaultTelegramProxyUsername = ""
|
||||
ConfigDefaultTelegramProxyPassword = ""
|
||||
|
||||
// 微信公众号配置默认值
|
||||
ConfigDefaultWechatBotEnabled = "false"
|
||||
ConfigDefaultWechatAppId = ""
|
||||
ConfigDefaultWechatAppSecret = ""
|
||||
ConfigDefaultWechatToken = ""
|
||||
ConfigDefaultWechatEncodingAesKey = ""
|
||||
ConfigDefaultWechatWelcomeMessage = "欢迎关注老九网盘资源库!发送关键词即可搜索资源。"
|
||||
ConfigDefaultWechatAutoReplyEnabled = "true"
|
||||
ConfigDefaultWechatSearchLimit = "5"
|
||||
|
||||
// 界面配置默认值
|
||||
ConfigDefaultEnableAnnouncements = "false"
|
||||
ConfigDefaultAnnouncements = ""
|
||||
@@ -207,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"
|
||||
@@ -44,6 +46,13 @@ type ResourceRepository interface {
|
||||
MarkAllAsUnsyncedToMeilisearch() error
|
||||
FindAllWithPagination(page, limit int) ([]entity.Resource, int64, error)
|
||||
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实现
|
||||
@@ -240,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)
|
||||
@@ -277,6 +303,20 @@ func (r *ResourceRepositoryImpl) SearchWithFilters(params map[string]interface{}
|
||||
db = db.Where("pan_id = ?", panEntity.ID)
|
||||
}
|
||||
}
|
||||
case "exclude_ids": // 添加exclude_ids参数支持
|
||||
if excludeIDs, ok := value.([]uint); ok && len(excludeIDs) > 0 {
|
||||
// 限制排除ID的数量,避免SQL语句过长
|
||||
maxExcludeIDs := 5000 // 限制排除ID数量,避免SQL语句过长
|
||||
if len(excludeIDs) > maxExcludeIDs {
|
||||
// 只取最近的maxExcludeIDs个ID进行排除
|
||||
startIndex := len(excludeIDs) - maxExcludeIDs
|
||||
truncatedExcludeIDs := excludeIDs[startIndex:]
|
||||
db = db.Where("id NOT IN ?", truncatedExcludeIDs)
|
||||
utils.Debug("SearchWithFilters: 排除ID数量过多,截取最近%d个ID", len(truncatedExcludeIDs))
|
||||
} else {
|
||||
db = db.Where("id NOT IN ?", excludeIDs)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,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
|
||||
}
|
||||
|
||||
@@ -650,3 +715,104 @@ func (r *ResourceRepositoryImpl) GetRandomResourceWithFilters(categoryFilter, ta
|
||||
|
||||
return &resource, nil
|
||||
}
|
||||
|
||||
// DeleteRelatedResources 删除关联资源,清空 fid、ck_id 和 save_url 三个字段
|
||||
func (r *ResourceRepositoryImpl) DeleteRelatedResources(ckID uint) (int64, error) {
|
||||
result := r.db.Model(&entity.Resource{}).
|
||||
Where("ck_id = ?", ckID).
|
||||
Updates(map[string]interface{}{
|
||||
"fid": nil, // 清空 fid 字段
|
||||
"ck_id": 0, // 清空 ck_id 字段
|
||||
"save_url": "", // 清空 save_url 字段
|
||||
})
|
||||
|
||||
if result.Error != nil {
|
||||
return 0, result.Error
|
||||
}
|
||||
|
||||
return result.RowsAffected, nil
|
||||
}
|
||||
|
||||
// CountResourcesByCkID 统计指定账号ID的资源数量
|
||||
func (r *ResourceRepositoryImpl) CountResourcesByCkID(ckID uint) (int64, error) {
|
||||
var count int64
|
||||
err := r.db.Model(&entity.Resource{}).
|
||||
Where("ck_id = ?", ckID).
|
||||
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
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ services:
|
||||
- app-network
|
||||
|
||||
backend:
|
||||
image: ctwj/urldb-backend:1.3.2
|
||||
image: ctwj/urldb-backend:1.3.4
|
||||
environment:
|
||||
DB_HOST: postgres
|
||||
DB_PORT: 5432
|
||||
@@ -38,7 +38,7 @@ services:
|
||||
- app-network
|
||||
|
||||
frontend:
|
||||
image: ctwj/urldb-frontend:1.3.2
|
||||
image: ctwj/urldb-frontend:1.3.4
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
NUXT_PUBLIC_API_SERVER: http://backend:8080/api
|
||||
|
||||
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.
44
go.mod
44
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,23 +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/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/sirupsen/logrus v1.9.0 // 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 (
|
||||
@@ -39,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
|
||||
@@ -49,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
|
||||
@@ -61,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
|
||||
)
|
||||
|
||||
247
go.sum
247
go.sum
@@ -1,21 +1,56 @@
|
||||
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=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bradfitz/gomemcache v0.0.0-20220106215444-fb4bf637b56d h1:pVrfxiGfwelyab6n21ZBkbkmbevaf+WvMIiR7sr97hw=
|
||||
github.com/bradfitz/gomemcache v0.0.0-20220106215444-fb4bf637b56d/go.mod h1:H0wQNHz2YrLsuXOZozoeDmnHXkNCRmMW0gwFWDfEZDA=
|
||||
github.com/bytedance/sonic v1.13.3 h1:MS8gmaH16Gtirygw7jV91pDCN33NyMrPbN7qiYhEsF0=
|
||||
github.com/bytedance/sonic v1.13.3/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
|
||||
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/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=
|
||||
github.com/gin-contrib/cors v1.4.0 h1:oJ6gwtUl3lqV0WEIwM/LxPF1QZ5qe2lGWdY2+bz7y0g=
|
||||
@@ -26,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=
|
||||
@@ -38,8 +78,11 @@ github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91
|
||||
github.com/go-playground/validator/v10 v10.10.0/go.mod h1:74x4gJWsvQexRdW8Pn3dXSGrTK4nAUsbPlLADvpJkos=
|
||||
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
|
||||
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||
github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
|
||||
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
|
||||
github.com/go-resty/resty/v2 v2.16.5 h1:hBKqmWrr7uRc3euHVqmh1HTHcKn99Smr7o5spptdhTM=
|
||||
github.com/go-resty/resty/v2 v2.16.5/go.mod h1:hkJtXbA2iKHzJheXYvQ8snQES5ZLGKMwQ07xAwp/fiA=
|
||||
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
|
||||
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc=
|
||||
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8=
|
||||
github.com/goccy/go-json v0.9.7/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
@@ -49,11 +92,49 @@ 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=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
@@ -70,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=
|
||||
@@ -83,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=
|
||||
@@ -100,6 +185,21 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
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=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
@@ -108,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=
|
||||
@@ -118,20 +219,32 @@ 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/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=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
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=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
|
||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go v1.2.7/go.mod h1:nF9osbDWLy6bDVv/Rtoh6QgnvNDpmCalQV5urGCCS6M=
|
||||
@@ -140,46 +253,135 @@ github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA
|
||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
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=
|
||||
golang.org/x/arch v0.19.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
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.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.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
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.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
|
||||
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
|
||||
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.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
|
||||
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
|
||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
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.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=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
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=
|
||||
@@ -187,6 +389,15 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
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=
|
||||
@@ -196,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"
|
||||
@@ -22,7 +23,49 @@ func GetCks(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
responses := converter.ToCksResponseList(cks)
|
||||
// 使用新的逻辑创建 CksResponse
|
||||
var responses []dto.CksResponse
|
||||
for _, ck := range cks {
|
||||
// 获取平台信息
|
||||
var pan *dto.PanResponse
|
||||
if ck.PanID != 0 {
|
||||
panEntity, err := repoManager.PanRepository.FindByID(ck.PanID)
|
||||
if err == nil && panEntity != nil {
|
||||
pan = &dto.PanResponse{
|
||||
ID: panEntity.ID,
|
||||
Name: panEntity.Name,
|
||||
Key: panEntity.Key,
|
||||
Icon: panEntity.Icon,
|
||||
Remark: panEntity.Remark,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 统计转存资源数
|
||||
count, err := repoManager.ResourceRepository.CountResourcesByCkID(ck.ID)
|
||||
if err != nil {
|
||||
count = 0 // 统计失败时设为0
|
||||
}
|
||||
|
||||
response := dto.CksResponse{
|
||||
ID: ck.ID,
|
||||
PanID: ck.PanID,
|
||||
Idx: ck.Idx,
|
||||
Ck: ck.Ck,
|
||||
IsValid: ck.IsValid,
|
||||
Space: ck.Space,
|
||||
LeftSpace: ck.LeftSpace,
|
||||
UsedSpace: ck.UsedSpace,
|
||||
Username: ck.Username,
|
||||
VipStatus: ck.VipStatus,
|
||||
ServiceType: ck.ServiceType,
|
||||
Remark: ck.Remark,
|
||||
TransferredCount: count,
|
||||
Pan: pan,
|
||||
}
|
||||
responses = append(responses, response)
|
||||
}
|
||||
|
||||
SuccessResponse(c, responses)
|
||||
}
|
||||
|
||||
@@ -68,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,
|
||||
}
|
||||
@@ -346,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
|
||||
@@ -380,3 +474,25 @@ func RefreshCapacity(c *gin.Context) {
|
||||
"cks": converter.ToCksResponse(cks),
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteRelatedResources 删除关联资源
|
||||
func DeleteRelatedResources(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
ErrorResponse(c, "无效的ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 调用资源库删除关联资源
|
||||
affectedRows, err := repoManager.ResourceRepository.DeleteRelatedResources(uint(id))
|
||||
if err != nil {
|
||||
ErrorResponse(c, "删除关联资源失败: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
SuccessResponse(c, gin.H{
|
||||
"message": "关联资源删除成功",
|
||||
"affected_rows": affectedRows,
|
||||
})
|
||||
}
|
||||
|
||||
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) // 获取资源版权申述列表
|
||||
}
|
||||
}
|
||||
@@ -440,3 +440,80 @@ func (h *FileHandler) calculateFileHash(filePath string) (string, error) {
|
||||
}
|
||||
return fmt.Sprintf("%x", hash.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// UploadWechatVerifyFile 上传微信公众号验证文件(TXT文件)
|
||||
// 无需认证,仅支持TXT文件,不记录数据库,直接保存到uploads目录
|
||||
func (h *FileHandler) UploadWechatVerifyFile(c *gin.Context) {
|
||||
// 获取上传的文件
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
ErrorResponse(c, "未提供文件", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 验证文件扩展名必须是.txt
|
||||
ext := strings.ToLower(filepath.Ext(file.Filename))
|
||||
if ext != ".txt" {
|
||||
ErrorResponse(c, "仅支持TXT文件", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 验证文件大小(限制1MB)
|
||||
if file.Size > 1*1024*1024 {
|
||||
ErrorResponse(c, "文件大小不能超过1MB", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 生成文件名(使用原始文件名,但确保是安全的)
|
||||
originalName := filepath.Base(file.Filename)
|
||||
safeFileName := h.makeSafeFileName(originalName)
|
||||
|
||||
// 确保uploads目录存在
|
||||
uploadsDir := "./uploads"
|
||||
if err := os.MkdirAll(uploadsDir, 0755); err != nil {
|
||||
ErrorResponse(c, "创建上传目录失败", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 构建完整文件路径
|
||||
filePath := filepath.Join(uploadsDir, safeFileName)
|
||||
|
||||
// 保存文件
|
||||
if err := c.SaveUploadedFile(file, filePath); err != nil {
|
||||
ErrorResponse(c, "保存文件失败", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 设置文件权限
|
||||
if err := os.Chmod(filePath, 0644); err != nil {
|
||||
utils.Warn("设置文件权限失败: %v", err)
|
||||
}
|
||||
|
||||
// 返回成功响应
|
||||
accessURL := fmt.Sprintf("/%s", safeFileName)
|
||||
response := map[string]interface{}{
|
||||
"success": true,
|
||||
"message": "验证文件上传成功",
|
||||
"file_name": safeFileName,
|
||||
"access_url": accessURL,
|
||||
}
|
||||
|
||||
SuccessResponse(c, response)
|
||||
}
|
||||
|
||||
// makeSafeFileName 生成安全的文件名,移除危险字符
|
||||
func (h *FileHandler) makeSafeFileName(filename string) string {
|
||||
// 移除路径分隔符和特殊字符
|
||||
safeName := strings.ReplaceAll(filename, "/", "_")
|
||||
safeName = strings.ReplaceAll(safeName, "\\", "_")
|
||||
safeName = strings.ReplaceAll(safeName, "..", "_")
|
||||
|
||||
// 限制文件名长度
|
||||
if len(safeName) > 100 {
|
||||
ext := filepath.Ext(safeName)
|
||||
name := safeName[:100-len(ext)]
|
||||
safeName = name + ext
|
||||
}
|
||||
|
||||
return safeName
|
||||
}
|
||||
|
||||
1355
handlers/google_index_handler.go
Normal file
1355
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
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -91,30 +92,9 @@ func (h *PublicAPIHandler) logAPIAccess(c *gin.Context, startTime time.Time, pro
|
||||
}
|
||||
}
|
||||
|
||||
// 异步记录日志,避免影响API响应时间
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
utils.Error("记录API访问日志时发生panic: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
err := repoManager.APIAccessLogRepository.RecordAccess(
|
||||
ip,
|
||||
userAgent,
|
||||
endpoint,
|
||||
method,
|
||||
requestParams,
|
||||
c.Writer.Status(),
|
||||
responseData,
|
||||
processCount,
|
||||
errorMessage,
|
||||
processingTime,
|
||||
)
|
||||
if err != nil {
|
||||
utils.Error("记录API访问日志失败: %v", err)
|
||||
}
|
||||
}()
|
||||
// 记录API访问日志 - 使用简单日志记录
|
||||
h.recordAPIAccessToDB(ip, userAgent, endpoint, method, requestParams,
|
||||
c.Writer.Status(), responseData, processCount, errorMessage, processingTime)
|
||||
}
|
||||
|
||||
// AddBatchResources godoc
|
||||
@@ -466,3 +446,89 @@ func (h *PublicAPIHandler) GetHotDramas(c *gin.Context) {
|
||||
h.logAPIAccess(c, startTime, len(hotDramaResponses), responseData, "")
|
||||
SuccessResponse(c, responseData)
|
||||
}
|
||||
|
||||
// recordAPIAccessToDB 记录API访问日志到数据库
|
||||
func (h *PublicAPIHandler) recordAPIAccessToDB(ip, userAgent, endpoint, method string,
|
||||
requestParams interface{}, responseStatus int, responseData interface{},
|
||||
processCount int, errorMessage string, processingTime int64) {
|
||||
|
||||
// 判断是否为关键端点,需要强制记录日志
|
||||
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字符串
|
||||
var requestParamsStr, responseDataStr string
|
||||
if requestParams != nil {
|
||||
if jsonBytes, err := json.Marshal(requestParams); err == nil {
|
||||
requestParamsStr = string(jsonBytes)
|
||||
}
|
||||
}
|
||||
if responseData != nil {
|
||||
if jsonBytes, err := json.Marshal(responseData); err == nil {
|
||||
responseDataStr = string(jsonBytes)
|
||||
}
|
||||
}
|
||||
|
||||
// 创建日志记录
|
||||
logEntry := &entity.APIAccessLog{
|
||||
IP: ip,
|
||||
UserAgent: userAgent,
|
||||
Endpoint: endpoint,
|
||||
Method: method,
|
||||
RequestParams: requestParamsStr,
|
||||
ResponseStatus: responseStatus,
|
||||
ResponseData: responseDataStr,
|
||||
ProcessCount: processCount,
|
||||
ErrorMessage: errorMessage,
|
||||
ProcessingTime: processingTime,
|
||||
}
|
||||
|
||||
// 异步保存到数据库(避免影响API性能)
|
||||
go func() {
|
||||
if err := repoManager.APIAccessLogRepository.Create(logEntry); err != nil {
|
||||
// 记录失败只输出到系统日志,不影响API
|
||||
utils.Error("保存API访问日志失败: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// 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": "手动触发待处理资源自动处理任务成功"})
|
||||
|
||||
436
handlers/sitemap_handler.go
Normal file
436
handlers/sitemap_handler.go
Normal file
@@ -0,0 +1,436 @@
|
||||
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
|
||||
}
|
||||
|
||||
// 构建主机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)
|
||||
|
||||
var urls []Url
|
||||
for _, resource := range resources {
|
||||
// 使用资源的创建时间作为 lastmod,因为资源内容创建后很少改变
|
||||
lastMod := resource.CreatedAt
|
||||
|
||||
urls = append(urls, Url{
|
||||
Loc: fmt.Sprintf("%s/r/%s", baseURL, 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)
|
||||
}
|
||||
@@ -145,27 +145,26 @@ func UpdateSystemConfig(c *gin.Context) {
|
||||
utils.Info("当前配置数量: %d", len(currentConfigs))
|
||||
}
|
||||
|
||||
// 验证参数 - 只验证提交的字段
|
||||
utils.Info("开始验证参数")
|
||||
// 验证参数 - 只验证提交的字段,仅在验证失败时记录日志
|
||||
if req.SiteTitle != nil {
|
||||
utils.Info("验证SiteTitle: '%s', 长度: %d", *req.SiteTitle, len(*req.SiteTitle))
|
||||
if len(*req.SiteTitle) < 1 || len(*req.SiteTitle) > 100 {
|
||||
utils.Warn("配置验证失败 - SiteTitle长度无效: %d", len(*req.SiteTitle))
|
||||
ErrorResponse(c, "网站标题长度必须在1-100字符之间", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if req.AutoProcessInterval != nil {
|
||||
utils.Info("验证AutoProcessInterval: %d", *req.AutoProcessInterval)
|
||||
if *req.AutoProcessInterval < 1 || *req.AutoProcessInterval > 1440 {
|
||||
utils.Warn("配置验证失败 - AutoProcessInterval超出范围: %d", *req.AutoProcessInterval)
|
||||
ErrorResponse(c, "自动处理间隔必须在1-1440分钟之间", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if req.PageSize != nil {
|
||||
utils.Info("验证PageSize: %d", *req.PageSize)
|
||||
if *req.PageSize < 10 || *req.PageSize > 500 {
|
||||
utils.Warn("配置验证失败 - PageSize超出范围: %d", *req.PageSize)
|
||||
ErrorResponse(c, "每页显示数量必须在10-500之间", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
@@ -173,36 +172,34 @@ func UpdateSystemConfig(c *gin.Context) {
|
||||
|
||||
// 验证自动转存配置
|
||||
if req.AutoTransferLimitDays != nil {
|
||||
utils.Info("验证AutoTransferLimitDays: %d", *req.AutoTransferLimitDays)
|
||||
if *req.AutoTransferLimitDays < 0 || *req.AutoTransferLimitDays > 365 {
|
||||
utils.Warn("配置验证失败 - AutoTransferLimitDays超出范围: %d", *req.AutoTransferLimitDays)
|
||||
ErrorResponse(c, "自动转存限制天数必须在0-365之间", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if req.AutoTransferMinSpace != nil {
|
||||
utils.Info("验证AutoTransferMinSpace: %d", *req.AutoTransferMinSpace)
|
||||
if *req.AutoTransferMinSpace < 100 || *req.AutoTransferMinSpace > 1024 {
|
||||
ErrorResponse(c, "最小存储空间必须在100-1024GB之间", http.StatusBadRequest)
|
||||
if *req.AutoTransferMinSpace < 5 || *req.AutoTransferMinSpace > 1024 {
|
||||
utils.Warn("配置验证失败 - AutoTransferMinSpace超出范围: %d", *req.AutoTransferMinSpace)
|
||||
ErrorResponse(c, "最小存储空间必须在5-1024GB之间", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 验证公告相关字段
|
||||
if req.Announcements != nil {
|
||||
utils.Info("验证Announcements: '%s'", *req.Announcements)
|
||||
// 可以在这里添加更详细的验证逻辑
|
||||
// 简化验证,仅在需要时添加逻辑
|
||||
}
|
||||
|
||||
// 转换为实体
|
||||
configs := converter.RequestToSystemConfig(&req)
|
||||
if configs == nil {
|
||||
utils.Error("配置数据转换失败")
|
||||
ErrorResponse(c, "数据转换失败", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
utils.Info("准备更新配置,配置项数量: %d", len(configs))
|
||||
|
||||
// 保存配置
|
||||
err = repoManager.SystemConfigRepository.UpsertConfigs(configs)
|
||||
if err != nil {
|
||||
@@ -211,7 +208,7 @@ func UpdateSystemConfig(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
utils.Info("配置保存成功")
|
||||
utils.Info("系统配置更新成功 - 更新项数: %d", len(configs))
|
||||
|
||||
// 安全刷新系统配置缓存
|
||||
if err := repoManager.SystemConfigRepository.SafeRefreshConfigCache(); err != nil {
|
||||
@@ -243,6 +240,8 @@ func UpdateSystemConfig(c *gin.Context) {
|
||||
repoManager.CksRepository,
|
||||
repoManager.TagRepository,
|
||||
repoManager.CategoryRepository,
|
||||
repoManager.TaskItemRepository,
|
||||
repoManager.TaskRepository,
|
||||
)
|
||||
if scheduler != nil {
|
||||
// 只更新被设置的配置
|
||||
@@ -284,6 +283,7 @@ func GetPublicSystemConfig(c *gin.Context) {
|
||||
SuccessResponse(c, configResponse)
|
||||
}
|
||||
|
||||
|
||||
// 新增:配置监控端点
|
||||
func GetConfigStatus(c *gin.Context) {
|
||||
// 获取配置统计信息
|
||||
@@ -369,6 +369,8 @@ func ToggleAutoProcess(c *gin.Context) {
|
||||
repoManager.CksRepository,
|
||||
repoManager.TagRepository,
|
||||
repoManager.CategoryRepository,
|
||||
repoManager.TaskItemRepository,
|
||||
repoManager.TaskRepository,
|
||||
)
|
||||
if scheduler != nil {
|
||||
// 获取其他配置值
|
||||
|
||||
@@ -80,16 +80,40 @@ func (h *TelegramHandler) UpdateBotConfig(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 配置更新完成后,尝试启动机器人(如果未运行且配置有效)
|
||||
if startErr := h.telegramBotService.Start(); startErr != nil {
|
||||
utils.Warn("[TELEGRAM:HANDLER] 配置更新后尝试启动机器人失败: %v", startErr)
|
||||
// 启动失败不影响配置保存,只记录警告
|
||||
// 根据配置状态决定启动或停止机器人
|
||||
botEnabled := false
|
||||
for _, config := range configs {
|
||||
if config.Key == "telegram_bot_enabled" {
|
||||
botEnabled = config.Value == "true"
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if botEnabled {
|
||||
// 机器人已启用,尝试启动机器人
|
||||
if startErr := h.telegramBotService.Start(); startErr != nil {
|
||||
utils.Warn("[TELEGRAM:HANDLER] 配置更新后尝试启动机器人失败: %v", startErr)
|
||||
// 启动失败不影响配置保存,只记录警告
|
||||
}
|
||||
} else {
|
||||
// 机器人已禁用,停止机器人服务
|
||||
if stopErr := h.telegramBotService.Stop(); stopErr != nil {
|
||||
utils.Warn("[TELEGRAM:HANDLER] 配置更新后停止机器人失败: %v", stopErr)
|
||||
// 停止失败不影响配置保存,只记录警告
|
||||
}
|
||||
}
|
||||
|
||||
// 返回成功
|
||||
var message string
|
||||
if botEnabled {
|
||||
message = "配置更新成功,机器人已尝试启动"
|
||||
} else {
|
||||
message = "配置更新成功,机器人已停止"
|
||||
}
|
||||
|
||||
SuccessResponse(c, map[string]interface{}{
|
||||
"success": true,
|
||||
"message": "配置更新成功,机器人已尝试启动",
|
||||
"message": message,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -487,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")
|
||||
|
||||
286
handlers/wechat_handler.go
Normal file
286
handlers/wechat_handler.go
Normal file
@@ -0,0 +1,286 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/sha1"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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/services"
|
||||
"github.com/ctwj/urldb/utils"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/silenceper/wechat/v2/officialaccount/message"
|
||||
)
|
||||
|
||||
// WechatHandler 微信公众号处理器
|
||||
type WechatHandler struct {
|
||||
wechatService services.WechatBotService
|
||||
systemConfigRepo repo.SystemConfigRepository
|
||||
}
|
||||
|
||||
// NewWechatHandler 创建微信公众号处理器
|
||||
func NewWechatHandler(
|
||||
wechatService services.WechatBotService,
|
||||
systemConfigRepo repo.SystemConfigRepository,
|
||||
) *WechatHandler {
|
||||
return &WechatHandler{
|
||||
wechatService: wechatService,
|
||||
systemConfigRepo: systemConfigRepo,
|
||||
}
|
||||
}
|
||||
|
||||
// HandleWechatMessage 处理微信消息推送
|
||||
func (h *WechatHandler) HandleWechatMessage(c *gin.Context) {
|
||||
// 验证微信消息签名
|
||||
if !h.validateSignature(c) {
|
||||
utils.Error("[WECHAT:VALIDATE] 签名验证失败")
|
||||
c.String(http.StatusForbidden, "签名验证失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 处理微信验证请求
|
||||
if c.Request.Method == "GET" {
|
||||
echostr := c.Query("echostr")
|
||||
utils.Info("[WECHAT:VERIFY] 微信服务器验证成功, echostr=%s", echostr)
|
||||
c.String(http.StatusOK, echostr)
|
||||
return
|
||||
}
|
||||
|
||||
// 读取请求体
|
||||
body, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
utils.Error("[WECHAT:MESSAGE] 读取请求体失败: %v", err)
|
||||
c.String(http.StatusBadRequest, "读取请求体失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 解析微信消息
|
||||
var msg message.MixMessage
|
||||
if err := xml.Unmarshal(body, &msg); err != nil {
|
||||
utils.Error("[WECHAT:MESSAGE] 解析微信消息失败: %v", err)
|
||||
c.String(http.StatusBadRequest, "消息格式错误")
|
||||
return
|
||||
}
|
||||
|
||||
// 处理消息
|
||||
reply, err := h.wechatService.HandleMessage(&msg)
|
||||
if err != nil {
|
||||
utils.Error("[WECHAT:MESSAGE] 处理微信消息失败: %v", err)
|
||||
c.String(http.StatusInternalServerError, "处理失败")
|
||||
return
|
||||
}
|
||||
|
||||
utils.Info("[WECHAT:MESSAGE] 回复对象: %v", reply)
|
||||
|
||||
// 如果有回复内容,发送回复
|
||||
if reply != nil {
|
||||
// 为微信消息设置正确的ToUserName和FromUserName
|
||||
switch v := reply.(type) {
|
||||
case *message.Text:
|
||||
if v.CommonToken.ToUserName == "" {
|
||||
v.CommonToken.ToUserName = msg.FromUserName
|
||||
}
|
||||
if v.CommonToken.FromUserName == "" {
|
||||
v.CommonToken.FromUserName = msg.ToUserName
|
||||
}
|
||||
if v.CommonToken.CreateTime == 0 {
|
||||
v.CommonToken.CreateTime = time.Now().Unix()
|
||||
}
|
||||
// 确保MsgType正确设置
|
||||
if v.CommonToken.MsgType == "" {
|
||||
v.CommonToken.MsgType = message.MsgTypeText
|
||||
}
|
||||
case *message.Image:
|
||||
if v.CommonToken.ToUserName == "" {
|
||||
v.CommonToken.ToUserName = msg.FromUserName
|
||||
}
|
||||
if v.CommonToken.FromUserName == "" {
|
||||
v.CommonToken.FromUserName = msg.ToUserName
|
||||
}
|
||||
if v.CommonToken.CreateTime == 0 {
|
||||
v.CommonToken.CreateTime = time.Now().Unix()
|
||||
}
|
||||
// 确保MsgType正确设置
|
||||
if v.CommonToken.MsgType == "" {
|
||||
v.CommonToken.MsgType = message.MsgTypeImage
|
||||
}
|
||||
}
|
||||
|
||||
responseXML, err := xml.Marshal(reply)
|
||||
if err != nil {
|
||||
utils.Error("[WECHAT:MESSAGE] 序列化回复消息失败: %v", err)
|
||||
c.String(http.StatusInternalServerError, "回复失败")
|
||||
return
|
||||
}
|
||||
utils.Info("[WECHAT:MESSAGE] 回复XML: %s", string(responseXML))
|
||||
c.Data(http.StatusOK, "application/xml", responseXML)
|
||||
} else {
|
||||
utils.Warn("[WECHAT:MESSAGE] 没有回复内容,返回success")
|
||||
c.String(http.StatusOK, "success")
|
||||
}
|
||||
}
|
||||
|
||||
// GetBotConfig 获取微信机器人配置
|
||||
func (h *WechatHandler) GetBotConfig(c *gin.Context) {
|
||||
configs, err := h.systemConfigRepo.GetOrCreateDefault()
|
||||
if err != nil {
|
||||
ErrorResponse(c, "获取配置失败", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
botConfig := converter.SystemConfigToWechatBotConfig(configs)
|
||||
SuccessResponse(c, botConfig)
|
||||
}
|
||||
|
||||
// UpdateBotConfig 更新微信机器人配置
|
||||
func (h *WechatHandler) UpdateBotConfig(c *gin.Context) {
|
||||
var req dto.WechatBotConfigRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
ErrorResponse(c, "请求参数错误", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为系统配置实体
|
||||
configs := converter.WechatBotConfigRequestToSystemConfigs(req)
|
||||
|
||||
// 保存配置
|
||||
if len(configs) > 0 {
|
||||
err := h.systemConfigRepo.UpsertConfigs(configs)
|
||||
if err != nil {
|
||||
ErrorResponse(c, "保存配置失败", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 重新加载配置缓存
|
||||
if err := h.systemConfigRepo.SafeRefreshConfigCache(); err != nil {
|
||||
ErrorResponse(c, "刷新配置缓存失败", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 重新加载机器人服务配置
|
||||
if err := h.wechatService.ReloadConfig(); err != nil {
|
||||
ErrorResponse(c, "重新加载机器人配置失败", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 配置更新完成后,尝试启动机器人(如果未运行且配置有效)
|
||||
if startErr := h.wechatService.Start(); startErr != nil {
|
||||
utils.Warn("[WECHAT:HANDLER] 配置更新后尝试启动机器人失败: %v", startErr)
|
||||
// 启动失败不影响配置保存,只记录警告
|
||||
}
|
||||
|
||||
// 返回成功
|
||||
SuccessResponse(c, map[string]interface{}{
|
||||
"success": true,
|
||||
"message": "配置更新成功,机器人已尝试启动",
|
||||
})
|
||||
}
|
||||
|
||||
// GetBotStatus 获取机器人状态
|
||||
func (h *WechatHandler) GetBotStatus(c *gin.Context) {
|
||||
// 获取机器人运行时状态
|
||||
runtimeStatus := h.wechatService.GetRuntimeStatus()
|
||||
|
||||
// 获取配置状态
|
||||
configs, err := h.systemConfigRepo.GetOrCreateDefault()
|
||||
if err != nil {
|
||||
ErrorResponse(c, "获取配置失败", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 解析配置状态
|
||||
configStatus := map[string]interface{}{
|
||||
"enabled": false,
|
||||
"auto_reply_enabled": false,
|
||||
"app_id_configured": false,
|
||||
"token_configured": false,
|
||||
}
|
||||
|
||||
for _, config := range configs {
|
||||
switch config.Key {
|
||||
case entity.ConfigKeyWechatBotEnabled:
|
||||
configStatus["enabled"] = config.Value == "true"
|
||||
case entity.ConfigKeyWechatAutoReplyEnabled:
|
||||
configStatus["auto_reply_enabled"] = config.Value == "true"
|
||||
case entity.ConfigKeyWechatAppId:
|
||||
configStatus["app_id_configured"] = config.Value != ""
|
||||
case entity.ConfigKeyWechatToken:
|
||||
configStatus["token_configured"] = config.Value != ""
|
||||
}
|
||||
}
|
||||
|
||||
// 合并状态信息
|
||||
status := map[string]interface{}{
|
||||
"config": configStatus,
|
||||
"runtime": runtimeStatus,
|
||||
"overall_status": runtimeStatus["is_running"].(bool),
|
||||
"status_text": func() string {
|
||||
if runtimeStatus["is_running"].(bool) {
|
||||
return "运行中"
|
||||
} else if configStatus["enabled"].(bool) {
|
||||
return "已启用但未运行"
|
||||
} else {
|
||||
return "已停止"
|
||||
}
|
||||
}(),
|
||||
}
|
||||
|
||||
SuccessResponse(c, status)
|
||||
}
|
||||
|
||||
// validateSignature 验证微信消息签名
|
||||
func (h *WechatHandler) validateSignature(c *gin.Context) bool {
|
||||
// 获取配置中的Token
|
||||
configs, err := h.systemConfigRepo.GetOrCreateDefault()
|
||||
if err != nil {
|
||||
utils.Error("[WECHAT:VALIDATE] 获取配置失败: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
var token string
|
||||
for _, config := range configs {
|
||||
if config.Key == entity.ConfigKeyWechatToken {
|
||||
token = config.Value
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
utils.Debug("[WECHAT:VALIDATE] Token配置状态: %t", token != "")
|
||||
|
||||
if token == "" {
|
||||
// 如果没有配置Token,跳过签名验证(开发模式)
|
||||
utils.Warn("[WECHAT:VALIDATE] 未配置Token,跳过签名验证")
|
||||
return true
|
||||
}
|
||||
|
||||
signature := c.Query("signature")
|
||||
timestamp := c.Query("timestamp")
|
||||
nonce := c.Query("nonce")
|
||||
|
||||
utils.Debug("[WECHAT:VALIDATE] 接收到的参数 - signature: %s, timestamp: %s, nonce: %s", signature, timestamp, nonce)
|
||||
|
||||
// 验证签名
|
||||
tmpArr := []string{token, timestamp, nonce}
|
||||
sort.Strings(tmpArr)
|
||||
tmpStr := strings.Join(tmpArr, "")
|
||||
tmpStr = fmt.Sprintf("%x", sha1.Sum([]byte(tmpStr)))
|
||||
|
||||
utils.Debug("[WECHAT:VALIDATE] 计算出的签名: %s, 微信提供的签名: %s", tmpStr, signature)
|
||||
|
||||
if tmpStr == signature {
|
||||
utils.Info("[WECHAT:VALIDATE] 签名验证成功")
|
||||
return true
|
||||
} else {
|
||||
utils.Error("[WECHAT:VALIDATE] 签名验证失败 - 计算出的签名: %s, 微信提供的签名: %s", tmpStr, signature)
|
||||
return false
|
||||
}
|
||||
}
|
||||
187
main.go
187
main.go
@@ -3,8 +3,11 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/ctwj/urldb/config"
|
||||
@@ -38,7 +41,7 @@ func main() {
|
||||
}
|
||||
|
||||
// 初始化日志系统
|
||||
if err := utils.InitLogger(nil); err != nil {
|
||||
if err := utils.InitLogger(); err != nil {
|
||||
log.Fatal("初始化日志系统失败:", err)
|
||||
}
|
||||
|
||||
@@ -84,6 +87,8 @@ func main() {
|
||||
utils.Fatal("数据库连接失败: %v", err)
|
||||
}
|
||||
|
||||
// 日志系统已简化,无需额外初始化
|
||||
|
||||
// 创建Repository管理器
|
||||
repoManager := repo.NewRepositoryManager(db.DB)
|
||||
|
||||
@@ -152,9 +157,27 @@ func main() {
|
||||
// 将Repository管理器注入到handlers中
|
||||
handlers.SetRepositoryManager(repoManager)
|
||||
|
||||
// 将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)
|
||||
|
||||
// 设置Meilisearch管理器到services中
|
||||
services.SetMeilisearchManager(meilisearchManager)
|
||||
|
||||
// 设置全局调度器的Meilisearch管理器
|
||||
scheduler.SetGlobalMeilisearchManager(meilisearchManager)
|
||||
|
||||
@@ -168,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,
|
||||
@@ -181,6 +208,21 @@ func main() {
|
||||
autoTransferEnabled,
|
||||
)
|
||||
|
||||
// 根据系统配置启动Sitemap调度器
|
||||
if autoSitemapEnabled {
|
||||
globalScheduler.StartSitemapScheduler()
|
||||
utils.Info("系统配置启用Sitemap自动生成功能,启动定时任务")
|
||||
} else {
|
||||
utils.Info("系统配置禁用Sitemap自动生成功能")
|
||||
}
|
||||
|
||||
// Google索引调度器现在由Sitemap调度器管理,不再独立启动
|
||||
if autoGoogleIndexEnabled {
|
||||
utils.Info("系统配置启用Google索引自动提交功能,将由Sitemap调度器管理")
|
||||
} else {
|
||||
utils.Info("系统配置禁用Google索引自动提交功能")
|
||||
}
|
||||
|
||||
utils.Info("调度器初始化完成")
|
||||
|
||||
// 设置公开API中间件的Repository管理器
|
||||
@@ -198,6 +240,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")
|
||||
{
|
||||
@@ -220,13 +280,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)
|
||||
|
||||
// 分类管理
|
||||
@@ -259,6 +324,7 @@ func main() {
|
||||
api.DELETE("/cks/:id", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.DeleteCks)
|
||||
api.GET("/cks/:id", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.GetCksByID)
|
||||
api.POST("/cks/:id/refresh-capacity", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.RefreshCapacity)
|
||||
api.POST("/cks/:id/delete-related-resources", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.DeleteRelatedResources)
|
||||
|
||||
// 标签管理
|
||||
api.GET("/tags", handlers.GetTags)
|
||||
@@ -318,6 +384,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)
|
||||
@@ -363,6 +430,8 @@ func main() {
|
||||
api.GET("/files", middleware.AuthMiddleware(), fileHandler.GetFileList)
|
||||
api.DELETE("/files", middleware.AuthMiddleware(), fileHandler.DeleteFiles)
|
||||
api.PUT("/files", middleware.AuthMiddleware(), fileHandler.UpdateFile)
|
||||
// 微信公众号验证文件上传(无需认证,仅支持TXT文件)
|
||||
api.POST("/wechat/verify-file", fileHandler.UploadWechatVerifyFile)
|
||||
|
||||
// 创建Telegram Bot服务
|
||||
telegramBotService := services.NewTelegramBotService(
|
||||
@@ -377,6 +446,18 @@ func main() {
|
||||
utils.Error("启动Telegram Bot服务失败: %v", err)
|
||||
}
|
||||
|
||||
// 创建微信公众号机器人服务
|
||||
wechatBotService := services.NewWechatBotService(
|
||||
repoManager.SystemConfigRepository,
|
||||
repoManager.ResourceRepository,
|
||||
repoManager.ReadyResourceRepository,
|
||||
)
|
||||
|
||||
// 启动微信公众号机器人服务
|
||||
if err := wechatBotService.Start(); err != nil {
|
||||
utils.Error("启动微信公众号机器人服务失败: %v", err)
|
||||
}
|
||||
|
||||
// Telegram相关路由
|
||||
telegramHandler := handlers.NewTelegramHandler(
|
||||
repoManager.TelegramChannelRepository,
|
||||
@@ -398,6 +479,88 @@ 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(
|
||||
wechatBotService,
|
||||
repoManager.SystemConfigRepository,
|
||||
)
|
||||
api.GET("/wechat/bot-config", middleware.AuthMiddleware(), middleware.AdminMiddleware(), wechatHandler.GetBotConfig)
|
||||
api.PUT("/wechat/bot-config", middleware.AuthMiddleware(), middleware.AdminMiddleware(), wechatHandler.UpdateBotConfig)
|
||||
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)
|
||||
api.POST("/google-index/diagnose-permissions", middleware.AuthMiddleware(), middleware.AdminMiddleware(), googleIndexHandler.DiagnosePermissions)
|
||||
api.POST("/google-index/urls/submit-to-index", middleware.AuthMiddleware(), middleware.AdminMiddleware(), googleIndexHandler.SubmitURLsToIndex)
|
||||
}
|
||||
|
||||
// 设置监控系统
|
||||
@@ -415,10 +578,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")
|
||||
@@ -431,6 +595,21 @@ func main() {
|
||||
port = "8080"
|
||||
}
|
||||
|
||||
utils.Info("服务器启动在端口 %s", port)
|
||||
r.Run(":" + port)
|
||||
// 设置优雅关闭
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
// 在goroutine中启动服务器
|
||||
go func() {
|
||||
utils.Info("服务器启动在端口 %s", port)
|
||||
if err := r.Run(":" + port); err != nil && err.Error() != "http: Server closed" {
|
||||
utils.Fatal("服务器启动失败: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// 等待信号
|
||||
<-quit
|
||||
utils.Info("收到关闭信号,开始优雅关闭...")
|
||||
|
||||
utils.Info("服务器已优雅关闭")
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ func AuthMiddleware() gin.HandlerFunc {
|
||||
c.Request.Method, c.Request.URL.Path, clientIP, userAgent)
|
||||
|
||||
if authHeader == "" {
|
||||
utils.Warn("AuthMiddleware - 未提供认证令牌 - IP: %s, Path: %s", clientIP, c.Request.URL.Path)
|
||||
// utils.Warn("AuthMiddleware - 未提供认证令牌 - IP: %s, Path: %s", clientIP, c.Request.URL.Path)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "未提供认证令牌"})
|
||||
c.Abort()
|
||||
return
|
||||
@@ -59,8 +59,8 @@ func AuthMiddleware() gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
utils.Info("AuthMiddleware - 认证成功 - 用户: %s(ID:%d), 角色: %s, IP: %s",
|
||||
claims.Username, claims.UserID, claims.Role, clientIP)
|
||||
// utils.Info("AuthMiddleware - 认证成功 - 用户: %s(ID:%d), 角色: %s, IP: %s",
|
||||
// claims.Username, claims.UserID, claims.Role, clientIP)
|
||||
|
||||
// 将用户信息存储到上下文中
|
||||
c.Set("user_id", claims.UserID)
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ctwj/urldb/utils"
|
||||
@@ -55,41 +56,71 @@ 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)
|
||||
|
||||
// 获取用户代理
|
||||
userAgent := r.UserAgent()
|
||||
if userAgent == "" {
|
||||
userAgent = "Unknown"
|
||||
}
|
||||
// 判断是否需要详细记录日志的条件
|
||||
shouldDetailLog := rw.statusCode >= 400 || // 错误状态码
|
||||
duration > 5*time.Second || // 耗时过长
|
||||
shouldLogPath(r.URL.Path) || // 关键路径
|
||||
isAdminPath(r.URL.Path) // 管理员路径
|
||||
|
||||
// 记录请求信息
|
||||
utils.Info("HTTP请求 - %s %s - IP: %s - User-Agent: %s - 状态码: %d - 耗时: %v",
|
||||
r.Method, r.URL.Path, clientIP, userAgent, rw.statusCode, duration)
|
||||
|
||||
// 如果是错误状态码,记录详细信息
|
||||
// 所有API请求都记录基本信息,但详细日志只记录重要请求
|
||||
if rw.statusCode >= 400 {
|
||||
utils.Error("HTTP错误 - %s %s - 状态码: %d - 响应体: %s",
|
||||
r.Method, r.URL.Path, rw.statusCode, rw.body.String())
|
||||
// 错误请求记录详细信息
|
||||
utils.Error("HTTP异常 - %s %s - IP: %s - 状态码: %d - 耗时: %v",
|
||||
r.Method, r.URL.Path, clientIP, rw.statusCode, duration)
|
||||
|
||||
// 仅在错误状态下记录简要的请求信息
|
||||
if len(requestBody) > 0 && len(requestBody) <= 500 {
|
||||
utils.Error("请求详情: %s", string(requestBody))
|
||||
}
|
||||
} else if duration > 5*time.Second {
|
||||
// 慢请求警告
|
||||
utils.Warn("HTTP慢请求 - %s %s - IP: %s - 耗时: %v",
|
||||
r.Method, r.URL.Path, clientIP, duration)
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
|
||||
// shouldLogPath 判断路径是否需要记录日志
|
||||
func shouldLogPath(path string) bool {
|
||||
// 定义需要记录日志的关键路径
|
||||
keyPaths := []string{
|
||||
"/api/public/resources",
|
||||
"/api/admin/config",
|
||||
"/api/admin/users",
|
||||
"/telegram/webhook",
|
||||
"/api/resources",
|
||||
"/api/version",
|
||||
"/api/cks",
|
||||
"/api/pans",
|
||||
"/api/categories",
|
||||
"/api/tags",
|
||||
"/api/tasks",
|
||||
}
|
||||
|
||||
// 记录请求参数(仅对POST/PUT请求)
|
||||
if (r.Method == "POST" || r.Method == "PUT") && len(requestBody) > 0 {
|
||||
// 限制日志长度,避免日志文件过大
|
||||
if len(requestBody) > 1000 {
|
||||
utils.Debug("请求体(截断): %s...", string(requestBody[:1000]))
|
||||
} else {
|
||||
utils.Debug("请求体: %s", string(requestBody))
|
||||
for _, keyPath := range keyPaths {
|
||||
if strings.HasPrefix(path, keyPath) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 记录查询参数
|
||||
if len(r.URL.RawQuery) > 0 {
|
||||
utils.Debug("查询参数: %s", r.URL.RawQuery)
|
||||
}
|
||||
// isAdminPath 判断是否为管理员路径
|
||||
func isAdminPath(path string) bool {
|
||||
return strings.HasPrefix(path, "/api/admin/") ||
|
||||
strings.HasPrefix(path, "/admin/")
|
||||
}
|
||||
|
||||
// getClientIP 获取客户端真实IP地址
|
||||
|
||||
@@ -17,6 +17,22 @@ 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;
|
||||
|
||||
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/sitemap-$1.xml;
|
||||
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;
|
||||
@@ -39,6 +55,21 @@ 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,22 +84,25 @@ 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目录
|
||||
set $uploads_path /uploads$uri;
|
||||
if (-f $uploads_path) {
|
||||
proxy_pass http://backend;
|
||||
|
||||
# 缓存设置
|
||||
expires 1h;
|
||||
add_header Cache-Control "public";
|
||||
|
||||
# 允许跨域访问
|
||||
add_header Access-Control-Allow-Origin "*";
|
||||
add_header Access-Control-Allow-Methods "GET, OPTIONS";
|
||||
add_header Access-Control-Allow-Headers "Origin, Content-Type, Accept";
|
||||
break;
|
||||
}
|
||||
# 如果文件不存在,返回404
|
||||
return 404;
|
||||
}
|
||||
|
||||
# 健康检查
|
||||
|
||||
593
pkg/google/client.go
Normal file
593
pkg/google/client.go
Normal file
@@ -0,0 +1,593 @@
|
||||
package google
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/oauth2/google"
|
||||
"google.golang.org/api/option"
|
||||
"google.golang.org/api/indexing/v3"
|
||||
"google.golang.org/api/searchconsole/v1"
|
||||
"google.golang.org/api/googleapi"
|
||||
)
|
||||
|
||||
// Client Google Search Console API客户端
|
||||
type Client struct {
|
||||
service *searchconsole.Service
|
||||
indexingService *indexing.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()
|
||||
|
||||
fmt.Printf("[GOOGLE-CLIENT] 初始化Google客户端,凭据文件: %s\n", config.CredentialsFile)
|
||||
fmt.Printf("[GOOGLE-CLIENT] 目标站点URL: %s\n", config.SiteURL)
|
||||
|
||||
// 读取认证文件
|
||||
credentials, err := os.ReadFile(config.CredentialsFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取认证文件失败: %v", err)
|
||||
}
|
||||
fmt.Printf("[GOOGLE-CLIENT] 成功读取凭据文件,大小: %d bytes\n", len(credentials))
|
||||
|
||||
// 检查凭据类型
|
||||
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("未知的凭据类型")
|
||||
}
|
||||
fmt.Printf("[GOOGLE-CLIENT] 凭据类型: %s\n", credType)
|
||||
|
||||
// 提取服务账号邮箱(如果有的话)
|
||||
if credType == "service_account" {
|
||||
if email, exists := credentialsMap["client_email"]; exists {
|
||||
fmt.Printf("[GOOGLE-CLIENT] 服务账号邮箱: %s\n", email)
|
||||
}
|
||||
}
|
||||
|
||||
// 组合作用域,包含Search Console和Indexing API
|
||||
scopes := []string{
|
||||
searchconsole.WebmastersScope,
|
||||
indexing.IndexingScope,
|
||||
}
|
||||
fmt.Printf("[GOOGLE-CLIENT] 使用的作用域: %v\n", scopes)
|
||||
|
||||
var client *http.Client
|
||||
if credType == "service_account" {
|
||||
// 服务账号凭据
|
||||
fmt.Printf("[GOOGLE-CLIENT] 创建服务账号JWT配置...\n")
|
||||
jwtConfig, err := google.JWTConfigFromJSON(credentials, scopes...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建JWT配置失败: %v", err)
|
||||
}
|
||||
client = jwtConfig.Client(ctx)
|
||||
fmt.Printf("[GOOGLE-CLIENT] 服务账号客户端创建成功\n")
|
||||
} else {
|
||||
// OAuth2客户端凭据
|
||||
fmt.Printf("[GOOGLE-CLIENT] 创建OAuth2配置...\n")
|
||||
oauthConfig, err := google.ConfigFromJSON(credentials, scopes...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建OAuth配置失败: %v", err)
|
||||
}
|
||||
|
||||
// 尝试从文件读取token
|
||||
token, err := tokenFromFile(config.TokenFile)
|
||||
if err != nil {
|
||||
// 如果没有token,启动web认证流程
|
||||
fmt.Printf("[GOOGLE-CLIENT] 未找到token文件,启动web认证流程...\n")
|
||||
token = getTokenFromWeb(oauthConfig)
|
||||
saveToken(config.TokenFile, token)
|
||||
}
|
||||
|
||||
client = oauthConfig.Client(ctx, token)
|
||||
fmt.Printf("[GOOGLE-CLIENT] OAuth2客户端创建成功\n")
|
||||
}
|
||||
|
||||
// 测试客户端连接
|
||||
fmt.Printf("[GOOGLE-CLIENT] 测试API客户端连接...\n")
|
||||
testURL := "https://www.googleapis.com/auth/webmasters"
|
||||
resp, err := client.Get(testURL)
|
||||
if err != nil {
|
||||
fmt.Printf("[GOOGLE-CLIENT] API客户端连接测试失败: %v\n", err)
|
||||
return nil, fmt.Errorf("API客户端连接测试失败: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
fmt.Printf("[GOOGLE-CLIENT] API客户端连接测试成功\n")
|
||||
|
||||
// 创建Search Console服务
|
||||
fmt.Printf("[GOOGLE-CLIENT] 创建Search Console服务...\n")
|
||||
service, err := searchconsole.NewService(ctx, option.WithHTTPClient(client))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建Search Console服务失败: %v", err)
|
||||
}
|
||||
fmt.Printf("[GOOGLE-CLIENT] Search Console服务创建成功\n")
|
||||
|
||||
// 创建Indexing API服务
|
||||
fmt.Printf("[GOOGLE-CLIENT] 创建Indexing API服务...\n")
|
||||
indexingService, err := indexing.NewService(ctx, option.WithHTTPClient(client))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建Indexing服务失败: %v", err)
|
||||
}
|
||||
fmt.Printf("[GOOGLE-CLIENT] Indexing API服务创建成功\n")
|
||||
|
||||
// 验证站点访问权限
|
||||
if config.SiteURL != "" {
|
||||
fmt.Printf("[GOOGLE-CLIENT] 验证站点访问权限: %s\n", config.SiteURL)
|
||||
sites, err := service.Sites.List().Do()
|
||||
if err != nil {
|
||||
fmt.Printf("[GOOGLE-CLIENT] 获取站点列表失败: %v\n", err)
|
||||
return nil, fmt.Errorf("无法访问Google Search Console API,请检查服务账号权限: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf("[GOOGLE-CLIENT] 可访问的站点数量: %d\n", len(sites.SiteEntry))
|
||||
for i, site := range sites.SiteEntry {
|
||||
if i < 3 { // 只显示前3个站点
|
||||
fmt.Printf("[GOOGLE-CLIENT] 站点 %d: %s (权限级别: %s)\n", i+1, site.SiteUrl, site.PermissionLevel)
|
||||
}
|
||||
}
|
||||
|
||||
// 检查目标站点是否在列表中
|
||||
targetSiteFound := false
|
||||
for _, site := range sites.SiteEntry {
|
||||
if strings.Contains(site.SiteUrl, strings.TrimPrefix(config.SiteURL, "https://")) ||
|
||||
strings.Contains(strings.TrimPrefix(config.SiteURL, "https://"), site.SiteUrl) {
|
||||
targetSiteFound = true
|
||||
fmt.Printf("[GOOGLE-CLIENT] 目标站点权限验证成功: %s\n", site.SiteUrl)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !targetSiteFound {
|
||||
fmt.Printf("[GOOGLE-CLIENT] 警告: 目标站点 %s 未在已验证站点列表中\n", config.SiteURL)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("[GOOGLE-CLIENT] Google客户端初始化完成\n")
|
||||
return &Client{
|
||||
service: service,
|
||||
indexingService: indexingService,
|
||||
SiteURL: config.SiteURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// InspectURL 检查URL索引状态
|
||||
func (c *Client) InspectURL(url string) (*URLInspectionResult, error) {
|
||||
fmt.Printf("[GOOGLE-CLIENT] 检查URL索引状态: %s\n", url)
|
||||
fmt.Printf("[GOOGLE-CLIENT] 使用站点URL: %s\n", c.SiteURL)
|
||||
|
||||
// 验证URL格式
|
||||
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
|
||||
return nil, fmt.Errorf("URL格式错误,必须以http://或https://开头: %s", url)
|
||||
}
|
||||
|
||||
// 验证站点URL格式
|
||||
siteURL := c.SiteURL
|
||||
if !strings.HasSuffix(siteURL, "/") {
|
||||
siteURL = siteURL + "/"
|
||||
}
|
||||
if !strings.HasPrefix(siteURL, "sc-domain:") && !strings.HasPrefix(siteURL, "http://") && !strings.HasPrefix(siteURL, "https://") {
|
||||
siteURL = "https://" + siteURL
|
||||
}
|
||||
|
||||
request := &searchconsole.InspectUrlIndexRequest{
|
||||
InspectionUrl: url,
|
||||
SiteUrl: siteURL,
|
||||
LanguageCode: "zh-CN",
|
||||
}
|
||||
|
||||
fmt.Printf("[GOOGLE-CLIENT] 发送URL检查请求...\n")
|
||||
call := c.service.UrlInspection.Index.Inspect(request)
|
||||
response, err := call.Do()
|
||||
|
||||
if err != nil {
|
||||
fmt.Printf("[GOOGLE-CLIENT] URL检查请求失败: %v\n", err)
|
||||
|
||||
// 尝试解析Google API错误
|
||||
if googleErr, ok := err.(*googleapi.Error); ok {
|
||||
fmt.Printf("[GOOGLE-CLIENT] Google API错误详情: 代码=%d, 消息=%s\n", googleErr.Code, googleErr.Message)
|
||||
for _, e := range googleErr.Errors {
|
||||
fmt.Printf("[GOOGLE-CLIENT] 错误项: 原因=%s, 消息=%s\n", e.Reason, e.Message)
|
||||
}
|
||||
|
||||
// 根据错误代码提供更具体的错误信息
|
||||
switch googleErr.Code {
|
||||
case 403:
|
||||
if strings.Contains(googleErr.Message, "forbidden") || strings.Contains(googleErr.Message, "permission") {
|
||||
return nil, fmt.Errorf("权限不足: 请确保服务账号已被授予该站点的访问权限。详情: %s", googleErr.Message)
|
||||
} else if strings.Contains(googleErr.Message, "indexing") {
|
||||
return nil, fmt.Errorf("Indexing API权限不足: 请确保服务账号已获得Indexing API访问权限。详情: %s", googleErr.Message)
|
||||
}
|
||||
case 404:
|
||||
return nil, fmt.Errorf("站点未找到: 请确保站点URL正确且已在Google Search Console中验证。详情: %s", googleErr.Message)
|
||||
case 400:
|
||||
return nil, fmt.Errorf("请求参数错误: %s", googleErr.Message)
|
||||
case 429:
|
||||
return nil, fmt.Errorf("请求过于频繁,请稍后重试: %s", googleErr.Message)
|
||||
default:
|
||||
return nil, fmt.Errorf("Google API错误 (代码: %d): %s", googleErr.Code, googleErr.Message)
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("检查URL失败: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf("[GOOGLE-CLIENT] URL检查请求成功\n")
|
||||
|
||||
// 转换响应
|
||||
result := &URLInspectionResult{}
|
||||
if response.InspectionResult != nil {
|
||||
fmt.Printf("[GOOGLE-CLIENT] 解析检查结果...\n")
|
||||
|
||||
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
|
||||
fmt.Printf("[GOOGLE-CLIENT] 最后抓取时间: %s\n", result.IndexStatusResult.LastCrawled)
|
||||
}
|
||||
fmt.Printf("[GOOGLE-CLIENT] 索引状态: %s\n", result.IndexStatusResult.IndexingState)
|
||||
}
|
||||
|
||||
if response.InspectionResult.MobileUsabilityResult != nil {
|
||||
result.MobileUsabilityResult.MobileFriendly = response.InspectionResult.MobileUsabilityResult.Verdict == "MOBILE_USABILITY_VERdict_PASS"
|
||||
fmt.Printf("[GOOGLE-CLIENT] 移动友好性: %t\n", result.MobileUsabilityResult.MobileFriendly)
|
||||
}
|
||||
|
||||
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",
|
||||
})
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("[GOOGLE-CLIENT] 警告: 响应中没有检查结果\n")
|
||||
}
|
||||
|
||||
fmt.Printf("[GOOGLE-CLIENT] URL检查完成: %s\n", url)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// SubmitSitemap 提交网站地图
|
||||
func (c *Client) SubmitSitemap(sitemapURL string) error {
|
||||
fmt.Printf("[GOOGLE-CLIENT] 提交网站地图: %s\n", sitemapURL)
|
||||
fmt.Printf("[GOOGLE-CLIENT] 目标站点: %s\n", c.SiteURL)
|
||||
|
||||
// 验证站点URL格式
|
||||
siteURL := c.SiteURL
|
||||
if !strings.HasSuffix(siteURL, "/") {
|
||||
siteURL = siteURL + "/"
|
||||
}
|
||||
if !strings.HasPrefix(siteURL, "sc-domain:") && !strings.HasPrefix(siteURL, "http://") && !strings.HasPrefix(siteURL, "https://") {
|
||||
siteURL = "https://" + siteURL
|
||||
}
|
||||
|
||||
fmt.Printf("[GOOGLE-CLIENT] 格式化后的站点URL: %s\n", siteURL)
|
||||
|
||||
call := c.service.Sitemaps.Submit(siteURL, sitemapURL)
|
||||
fmt.Printf("[GOOGLE-CLIENT] 发送网站地图提交请求...\n")
|
||||
err := call.Do()
|
||||
|
||||
if err != nil {
|
||||
fmt.Printf("[GOOGLE-CLIENT] 网站地图提交失败: %v\n", err)
|
||||
|
||||
// 尝试解析Google API错误
|
||||
if googleErr, ok := err.(*googleapi.Error); ok {
|
||||
fmt.Printf("[GOOGLE-CLIENT] Google API错误详情: 代码=%d, 消息=%s\n", googleErr.Code, googleErr.Message)
|
||||
for _, e := range googleErr.Errors {
|
||||
fmt.Printf("[GOOGLE-CLIENT] 错误项: 原因=%s, 消息=%s\n", e.Reason, e.Message)
|
||||
}
|
||||
|
||||
// 根据错误代码提供更具体的错误信息
|
||||
switch googleErr.Code {
|
||||
case 403:
|
||||
return fmt.Errorf("网站地图提交权限不足: 请确保服务账号已被授予该站点的完整权限。详情: %s", googleErr.Message)
|
||||
case 404:
|
||||
return fmt.Errorf("站点或网站地图未找到: 请确保站点URL正确且网站地图可访问。详情: %s", googleErr.Message)
|
||||
case 400:
|
||||
if strings.Contains(googleErr.Message, "sitemap") {
|
||||
return fmt.Errorf("网站地图格式错误或无法访问: %s", googleErr.Message)
|
||||
}
|
||||
return fmt.Errorf("请求参数错误: %s", googleErr.Message)
|
||||
case 429:
|
||||
return fmt.Errorf("请求过于频繁,请稍后重试: %s", googleErr.Message)
|
||||
default:
|
||||
return fmt.Errorf("Google API错误 (代码: %d): %s", googleErr.Code, googleErr.Message)
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("提交网站地图失败: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf("[GOOGLE-CLIENT] 网站地图提交成功: %s\n", sitemapURL)
|
||||
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{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// PublishURL 提交URL到Google索引
|
||||
func (c *Client) PublishURL(url string, urlType string) error {
|
||||
fmt.Printf("[GOOGLE-CLIENT] 提交URL到Google索引: %s (类型: %s)\n", url, urlType)
|
||||
fmt.Printf("[GOOGLE-CLIENT] 目标站点: %s\n", c.SiteURL)
|
||||
|
||||
if c.indexingService == nil {
|
||||
return fmt.Errorf("Indexing服务未初始化")
|
||||
}
|
||||
|
||||
// 验证URL格式
|
||||
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
|
||||
return fmt.Errorf("URL格式错误,必须以http://或https://开头: %s", url)
|
||||
}
|
||||
|
||||
// 验证URL类型
|
||||
if urlType != "URL_UPDATED" && urlType != "URL_DELETED" {
|
||||
return fmt.Errorf("URL类型必须是 'URL_UPDATED' 或 'URL_DELETED',当前: %s", urlType)
|
||||
}
|
||||
|
||||
// 检查URL和站点域名的匹配关系(放宽验证,只要域名相同即可)
|
||||
siteDomain := extractDomain(c.SiteURL)
|
||||
urlDomain := extractDomain(url)
|
||||
|
||||
if siteDomain != urlDomain {
|
||||
fmt.Printf("[GOOGLE-CLIENT] 警告: URL域名 (%s) 与站点域名 (%s) 不匹配\n", urlDomain, siteDomain)
|
||||
// 不返回错误,只是警告,因为有时子域名也可以提交
|
||||
}
|
||||
|
||||
// 创建发布请求
|
||||
publication := &indexing.UrlNotification{
|
||||
Url: url,
|
||||
Type: urlType, // "URL_UPDATED" 或 "URL_DELETED"
|
||||
}
|
||||
|
||||
fmt.Printf("[GOOGLE-CLIENT] 发送URL索引提交请求...\n")
|
||||
call := c.indexingService.UrlNotifications.Publish(publication)
|
||||
response, err := call.Do()
|
||||
|
||||
if err != nil {
|
||||
fmt.Printf("[GOOGLE-CLIENT] URL索引提交失败: %v\n", err)
|
||||
|
||||
// 尝试解析Google API错误
|
||||
if googleErr, ok := err.(*googleapi.Error); ok {
|
||||
fmt.Printf("[GOOGLE-CLIENT] Google API错误详情: 代码=%d, 消息=%s\n", googleErr.Code, googleErr.Message)
|
||||
for _, e := range googleErr.Errors {
|
||||
fmt.Printf("[GOOGLE-CLIENT] 错误项: 原因=%s, 消息=%s\n", e.Reason, e.Message)
|
||||
}
|
||||
|
||||
// 根据错误代码提供更具体的错误信息
|
||||
switch googleErr.Code {
|
||||
case 403:
|
||||
if strings.Contains(googleErr.Message, "Indexing API") {
|
||||
return fmt.Errorf("Indexing API权限不足: 请确保服务账号已获得Indexing API访问权限。这需要在Google Cloud Console中启用Indexing API并授权服务账号。详情: %s", googleErr.Message)
|
||||
}
|
||||
return fmt.Errorf("URL索引提交权限不足: 请确保服务账号已被授予该站点的索引提交权限。详情: %s", googleErr.Message)
|
||||
case 429:
|
||||
return fmt.Errorf("URL索引提交请求过于频繁,Indexing API有严格的速率限制。请稍后重试。详情: %s", googleErr.Message)
|
||||
case 400:
|
||||
if strings.Contains(googleErr.Message, "url") {
|
||||
return fmt.Errorf("URL格式错误或无法访问: %s", googleErr.Message)
|
||||
}
|
||||
return fmt.Errorf("请求参数错误: %s", googleErr.Message)
|
||||
case 404:
|
||||
return fmt.Errorf("URL未找到或站点未验证: 请确保URL可访问且站点已在Google Search Console中验证。详情: %s", googleErr.Message)
|
||||
default:
|
||||
return fmt.Errorf("Google Indexing API错误 (代码: %d): %s", googleErr.Code, googleErr.Message)
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("提交URL到索引失败: %v", err)
|
||||
}
|
||||
|
||||
if response != nil {
|
||||
fmt.Printf("[GOOGLE-CLIENT] URL索引提交响应: %+v\n", response)
|
||||
if response.UrlNotificationMetadata != nil {
|
||||
fmt.Printf("[GOOGLE-CLIENT] 提交状态: %s\n", response.UrlNotificationMetadata.LatestUpdate.Type)
|
||||
if response.UrlNotificationMetadata.LatestUpdate.Url != "" {
|
||||
fmt.Printf("[GOOGLE-CLIENT] 提交的URL: %s\n", response.UrlNotificationMetadata.LatestUpdate.Url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("[GOOGLE-CLIENT] URL索引提交成功: %s\n", url)
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractDomain 从URL中提取域名
|
||||
func extractDomain(url string) string {
|
||||
// 移除协议
|
||||
if strings.HasPrefix(url, "http://") {
|
||||
url = url[7:]
|
||||
} else if strings.HasPrefix(url, "https://") {
|
||||
url = url[8:]
|
||||
}
|
||||
|
||||
// 移除路径
|
||||
if idx := strings.Index(url, "/"); idx != -1 {
|
||||
url = url[:idx]
|
||||
}
|
||||
|
||||
// 移除sc-domain:前缀
|
||||
if strings.HasPrefix(url, "sc-domain:") {
|
||||
url = url[11:]
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
// BatchPublishURLs 批量提交URL到Google索引
|
||||
func (c *Client) BatchPublishURLs(urls []string, urlType string, callback func(url string, success bool, err error)) {
|
||||
semaphore := make(chan struct{}, 3) // Indexing API限制更严格,降低并发数
|
||||
|
||||
for _, url := range urls {
|
||||
go func(u string) {
|
||||
semaphore <- struct{}{} // 获取信号量
|
||||
defer func() { <-semaphore }() // 释放信号量
|
||||
|
||||
err := c.PublishURL(u, urlType)
|
||||
callback(u, err == nil, err)
|
||||
|
||||
// Indexing API有更严格的频率限制
|
||||
time.Sleep(1 * time.Second)
|
||||
}(url)
|
||||
}
|
||||
|
||||
// 等待所有goroutine完成
|
||||
for i := 0; i < cap(semaphore); i++ {
|
||||
semaphore <- struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// GetURLNotificationStatus 获取URL通知状态
|
||||
func (c *Client) GetURLNotificationStatus(url string) (*indexing.UrlNotificationMetadata, error) {
|
||||
if c.indexingService == nil {
|
||||
return nil, fmt.Errorf("Indexing服务未初始化")
|
||||
}
|
||||
|
||||
call := c.indexingService.UrlNotifications.GetMetadata()
|
||||
response, err := call.Do()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取URL通知状态失败: %v", err)
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
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()
|
||||
}
|
||||
|
||||
645
scheduler/google_index.go
Normal file
645
scheduler/google_index.go
Normal file
@@ -0,0 +1,645 @@
|
||||
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()
|
||||
now := time.Now()
|
||||
|
||||
// 任务0: 清理旧记录
|
||||
if err := s.taskItemRepo.CleanupOldRecords(); err != nil {
|
||||
utils.Error("清理旧记录失败: %v", err)
|
||||
}
|
||||
|
||||
// 任务1: 智能sitemap提交策略
|
||||
if s.shouldSubmitSitemap(now) {
|
||||
if err := s.submitSitemapToGoogle(ctx); err != nil {
|
||||
utils.Error("提交sitemap失败: %v", err)
|
||||
} else {
|
||||
s.updateLastSitemapSubmitTime()
|
||||
}
|
||||
}
|
||||
|
||||
// 任务2: 检查新URL状态(仅在白天执行,避免夜间消耗配额)
|
||||
if s.shouldCheckURLStatus(now) {
|
||||
if err := s.checkNewURLsStatus(ctx); err != nil {
|
||||
utils.Error("检查新URL状态失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 任务3: 刷新待处理的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
|
||||
}
|
||||
|
||||
// shouldSubmitSitemap 判断是否应该提交sitemap
|
||||
func (s *GoogleIndexScheduler) shouldSubmitSitemap(now time.Time) bool {
|
||||
// 获取上次提交时间
|
||||
lastSubmitStr, err := s.systemConfigRepo.GetConfigValue("google_index_last_sitemap_submit")
|
||||
if err != nil {
|
||||
// 如果没有记录,允许提交
|
||||
return true
|
||||
}
|
||||
|
||||
lastSubmit, err := time.Parse("2006-01-02 15:04:05", lastSubmitStr)
|
||||
if err != nil {
|
||||
// 如果解析失败,允许提交
|
||||
return true
|
||||
}
|
||||
|
||||
// 每天只提交一次sitemap
|
||||
hoursSinceLastSubmit := now.Sub(lastSubmit).Hours()
|
||||
return hoursSinceLastSubmit >= 24
|
||||
}
|
||||
|
||||
// updateLastSitemapSubmitTime 更新最后sitemap提交时间
|
||||
func (s *GoogleIndexScheduler) updateLastSitemapSubmitTime() {
|
||||
now := time.Now().Format("2006-01-02 15:04:05")
|
||||
configs := []entity.SystemConfig{
|
||||
{
|
||||
Key: "google_index_last_sitemap_submit",
|
||||
Value: now,
|
||||
Type: entity.ConfigTypeString,
|
||||
},
|
||||
}
|
||||
|
||||
if err := s.systemConfigRepo.UpsertConfigs(configs); err != nil {
|
||||
utils.Error("更新sitemap提交时间失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// shouldCheckURLStatus 判断是否应该检查URL状态
|
||||
func (s *GoogleIndexScheduler) shouldCheckURLStatus(now time.Time) bool {
|
||||
// 只在白天执行(8:00-22:00),避免夜间消耗API配额
|
||||
hour := now.Hour()
|
||||
if hour < 8 || hour >= 22 {
|
||||
return false
|
||||
}
|
||||
|
||||
// 获取上次检查时间
|
||||
lastCheckStr, err := s.systemConfigRepo.GetConfigValue("google_index_last_url_check")
|
||||
if err != nil {
|
||||
// 如果没有记录,允许检查
|
||||
return true
|
||||
}
|
||||
|
||||
lastCheck, err := time.Parse("2006-01-02 15:04:05", lastCheckStr)
|
||||
if err != nil {
|
||||
// 如果解析失败,允许检查
|
||||
return true
|
||||
}
|
||||
|
||||
// 每6小时检查一次URL状态
|
||||
hoursSinceLastCheck := now.Sub(lastCheck).Hours()
|
||||
return hoursSinceLastCheck >= 6
|
||||
}
|
||||
|
||||
// updateLastURLCheckTime 更新最后URL检查时间
|
||||
func (s *GoogleIndexScheduler) updateLastURLCheckTime() {
|
||||
now := time.Now().Format("2006-01-02 15:04:05")
|
||||
configs := []entity.SystemConfig{
|
||||
{
|
||||
Key: "google_index_last_url_check",
|
||||
Value: now,
|
||||
Type: entity.ConfigTypeString,
|
||||
},
|
||||
}
|
||||
|
||||
if err := s.systemConfigRepo.UpsertConfigs(configs); err != nil {
|
||||
utils.Error("更新URL检查时间失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// checkNewURLsStatus 检查新URL的状态
|
||||
func (s *GoogleIndexScheduler) checkNewURLsStatus(ctx context.Context) error {
|
||||
utils.Info("开始检查新URL状态...")
|
||||
|
||||
// 暂时跳过新URL检查,因为GetRecentResources方法不存在
|
||||
// TODO: 后续可以添加获取最近资源的逻辑
|
||||
utils.Info("新URL检查功能暂时跳过")
|
||||
|
||||
// 仍然更新检查时间,避免重复尝试
|
||||
s.updateLastURLCheckTime()
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
416
scheduler/sitemap.go
Normal file
416
scheduler/sitemap.go
Normal file
@@ -0,0 +1,416 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ctwj/urldb/db/entity"
|
||||
"github.com/ctwj/urldb/pkg/google"
|
||||
"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)
|
||||
|
||||
// 检查是否启用了Google索引自动提交功能
|
||||
if s.shouldTriggerGoogleIndex() {
|
||||
utils.Info("将在5分钟后自动提交sitemap到Google")
|
||||
go s.scheduleGoogleIndexSubmission()
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 获取网站基础URL
|
||||
baseURL, err := s.BaseScheduler.systemConfigRepo.GetConfigValue(entity.ConfigKeyWebsiteURL)
|
||||
if err != nil || baseURL == "" {
|
||||
baseURL = "https://yoursite.com" // 默认值
|
||||
}
|
||||
// 移除URL末尾的斜杠
|
||||
baseURL = strings.TrimSuffix(baseURL, "/")
|
||||
|
||||
var urls []Url
|
||||
for _, resource := range resources {
|
||||
// 使用资源的创建时间作为 lastmod,因为资源内容创建后很少改变
|
||||
lastMod := resource.CreatedAt
|
||||
|
||||
urls = append(urls, Url{
|
||||
Loc: fmt.Sprintf("%s/r/%s", baseURL, 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"`
|
||||
}
|
||||
|
||||
// shouldTriggerGoogleIndex 检查是否应该触发Google索引提交
|
||||
func (s *SitemapScheduler) shouldTriggerGoogleIndex() bool {
|
||||
// 获取Google索引启用状态
|
||||
enabledStr, err := s.BaseScheduler.systemConfigRepo.GetConfigValue(entity.GoogleIndexConfigKeyEnabled)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return enabledStr == "1" || enabledStr == "true"
|
||||
}
|
||||
|
||||
// scheduleGoogleIndexSubmission 安排Google索引提交(延迟5分钟)
|
||||
func (s *SitemapScheduler) scheduleGoogleIndexSubmission() {
|
||||
// 等待5分钟
|
||||
time.Sleep(5 * time.Minute)
|
||||
|
||||
utils.Info("开始自动提交sitemap到Google...")
|
||||
|
||||
// 直接实现Google索引提交逻辑
|
||||
if err := s.submitSitemapToGoogle(); err != nil {
|
||||
utils.Error("自动提交sitemap失败: %v", err)
|
||||
} else {
|
||||
utils.Info("自动提交sitemap成功")
|
||||
// 更新最后提交时间
|
||||
s.updateLastSitemapSubmitTime()
|
||||
}
|
||||
}
|
||||
|
||||
// submitSitemapToGoogle 提交sitemap给Google
|
||||
func (s *SitemapScheduler) submitSitemapToGoogle() error {
|
||||
utils.Info("开始提交sitemap给Google...")
|
||||
|
||||
// 获取站点URL构建sitemap URL
|
||||
siteURL, err := s.BaseScheduler.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不能为空")
|
||||
}
|
||||
|
||||
// 创建Google客户端配置
|
||||
config := &google.Config{
|
||||
SiteURL: siteURL,
|
||||
}
|
||||
|
||||
// 获取凭据文件路径
|
||||
credentialsFile, err := s.BaseScheduler.systemConfigRepo.GetConfigValue(entity.GoogleIndexConfigKeyCredentialsFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取凭据文件路径失败: %v", err)
|
||||
}
|
||||
config.CredentialsFile = credentialsFile
|
||||
|
||||
// 创建Google客户端
|
||||
client, err := google.NewClient(config)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建Google客户端失败: %v", err)
|
||||
}
|
||||
|
||||
// 提交sitemap给Google
|
||||
err = client.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
|
||||
}
|
||||
|
||||
// updateLastSitemapSubmitTime 更新最后sitemap提交时间
|
||||
func (s *SitemapScheduler) updateLastSitemapSubmitTime() {
|
||||
now := time.Now().Format("2006-01-02 15:04:05")
|
||||
configs := []entity.SystemConfig{
|
||||
{
|
||||
Key: "google_index_last_sitemap_submit",
|
||||
Value: now,
|
||||
Type: entity.ConfigTypeString,
|
||||
},
|
||||
}
|
||||
|
||||
if err := s.BaseScheduler.systemConfigRepo.UpsertConfigs(configs); err != nil {
|
||||
utils.Error("更新sitemap提交时间失败: %v", err)
|
||||
}
|
||||
}
|
||||
102
services/base.go
Normal file
102
services/base.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"github.com/ctwj/urldb/db/entity"
|
||||
"github.com/ctwj/urldb/db/repo"
|
||||
"github.com/ctwj/urldb/utils"
|
||||
)
|
||||
|
||||
var repoManager *repo.RepositoryManager
|
||||
var meilisearchManager *MeilisearchManager
|
||||
|
||||
// SetRepositoryManager 设置Repository管理器
|
||||
func SetRepositoryManager(manager *repo.RepositoryManager) {
|
||||
repoManager = manager
|
||||
}
|
||||
|
||||
// SetMeilisearchManager 设置Meilisearch管理器
|
||||
func SetMeilisearchManager(manager *MeilisearchManager) {
|
||||
meilisearchManager = manager
|
||||
}
|
||||
|
||||
// UnifiedSearchResources 执行统一搜索(优先使用Meilisearch,否则使用数据库搜索)并处理违禁词
|
||||
func UnifiedSearchResources(keyword string, limit int, systemConfigRepo repo.SystemConfigRepository, resourceRepo repo.ResourceRepository) ([]entity.Resource, error) {
|
||||
var resources []entity.Resource
|
||||
var total int64
|
||||
var err error
|
||||
|
||||
// 如果启用了Meilisearch,优先使用Meilisearch搜索
|
||||
if meilisearchManager != nil && meilisearchManager.IsEnabled() {
|
||||
// 构建MeiliSearch过滤器
|
||||
filters := make(map[string]interface{})
|
||||
|
||||
// 使用Meilisearch搜索
|
||||
service := meilisearchManager.GetService()
|
||||
if service != nil {
|
||||
docs, docTotal, err := service.Search(keyword, filters, 1, limit)
|
||||
if err == nil {
|
||||
// 将Meilisearch文档转换为Resource实体
|
||||
for _, doc := range docs {
|
||||
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,
|
||||
CreatedAt: doc.CreatedAt,
|
||||
UpdatedAt: doc.UpdatedAt,
|
||||
}
|
||||
resources = append(resources, resource)
|
||||
}
|
||||
total = docTotal
|
||||
|
||||
// 获取违禁词配置并处理违禁词
|
||||
cleanWords, err := utils.GetForbiddenWordsFromConfig(func() (string, error) {
|
||||
return systemConfigRepo.GetConfigValue(entity.ConfigKeyForbiddenWords)
|
||||
})
|
||||
if err != nil {
|
||||
utils.Error("获取违禁词配置失败: %v", err)
|
||||
cleanWords = []string{} // 如果获取失败,使用空列表
|
||||
}
|
||||
|
||||
// 处理违禁词替换
|
||||
if len(cleanWords) > 0 {
|
||||
resources = utils.ProcessResourcesForbiddenWords(resources, cleanWords)
|
||||
}
|
||||
|
||||
return resources, nil
|
||||
} else {
|
||||
utils.Error("MeiliSearch搜索失败,回退到数据库搜索: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果MeiliSearch未启用、搜索失败或没有搜索关键词,使用数据库搜索
|
||||
resources, total, err = resourceRepo.Search(keyword, nil, 1, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if total == 0 {
|
||||
return []entity.Resource{}, nil
|
||||
}
|
||||
|
||||
// 获取违禁词配置并处理违禁词
|
||||
cleanWords, err := utils.GetForbiddenWordsFromConfig(func() (string, error) {
|
||||
return systemConfigRepo.GetConfigValue(entity.ConfigKeyForbiddenWords)
|
||||
})
|
||||
if err != nil {
|
||||
utils.Error("获取违禁词配置失败: %v", err)
|
||||
cleanWords = []string{} // 如果获取失败,使用空列表
|
||||
}
|
||||
|
||||
// 处理违禁词替换
|
||||
if len(cleanWords) > 0 {
|
||||
resources = utils.ProcessResourcesForbiddenWords(resources, cleanWords)
|
||||
}
|
||||
|
||||
return resources, nil
|
||||
}
|
||||
231
services/search_session.go
Normal file
231
services/search_session.go
Normal file
@@ -0,0 +1,231 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/ctwj/urldb/db/entity"
|
||||
)
|
||||
|
||||
// SearchSession 搜索会话
|
||||
type SearchSession struct {
|
||||
UserID string // 用户ID
|
||||
Keyword string // 搜索关键字
|
||||
Resources []entity.Resource // 搜索结果
|
||||
PageSize int // 每页数量
|
||||
CurrentPage int // 当前页码
|
||||
TotalPages int // 总页数
|
||||
LastAccess time.Time // 最后访问时间
|
||||
}
|
||||
|
||||
// SearchSessionManager 搜索会话管理器
|
||||
type SearchSessionManager struct {
|
||||
sessions map[string]*SearchSession // 用户ID -> 搜索会话
|
||||
mutex sync.RWMutex
|
||||
}
|
||||
|
||||
// NewSearchSessionManager 创建搜索会话管理器
|
||||
func NewSearchSessionManager() *SearchSessionManager {
|
||||
manager := &SearchSessionManager{
|
||||
sessions: make(map[string]*SearchSession),
|
||||
}
|
||||
|
||||
// 启动清理过期会话的goroutine
|
||||
go manager.cleanupExpiredSessions()
|
||||
|
||||
return manager
|
||||
}
|
||||
|
||||
// CreateSession 创建或更新搜索会话
|
||||
func (m *SearchSessionManager) CreateSession(userID, keyword string, resources []entity.Resource, pageSize int) *SearchSession {
|
||||
m.mutex.Lock()
|
||||
defer m.mutex.Unlock()
|
||||
|
||||
session := &SearchSession{
|
||||
UserID: userID,
|
||||
Keyword: keyword,
|
||||
Resources: resources,
|
||||
PageSize: pageSize,
|
||||
CurrentPage: 1,
|
||||
TotalPages: (len(resources) + pageSize - 1) / pageSize,
|
||||
LastAccess: time.Now(),
|
||||
}
|
||||
|
||||
m.sessions[userID] = session
|
||||
return session
|
||||
}
|
||||
|
||||
// GetSession 获取搜索会话
|
||||
func (m *SearchSessionManager) GetSession(userID string) *SearchSession {
|
||||
m.mutex.RLock()
|
||||
defer m.mutex.RUnlock()
|
||||
|
||||
session, exists := m.sessions[userID]
|
||||
if !exists {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 更新最后访问时间
|
||||
m.mutex.RUnlock()
|
||||
m.mutex.Lock()
|
||||
session.LastAccess = time.Now()
|
||||
m.mutex.Unlock()
|
||||
m.mutex.RLock()
|
||||
|
||||
return session
|
||||
}
|
||||
|
||||
// SetCurrentPage 设置当前页
|
||||
func (m *SearchSessionManager) SetCurrentPage(userID string, page int) bool {
|
||||
m.mutex.Lock()
|
||||
defer m.mutex.Unlock()
|
||||
|
||||
session, exists := m.sessions[userID]
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
|
||||
if page < 1 || page > session.TotalPages {
|
||||
return false
|
||||
}
|
||||
|
||||
session.CurrentPage = page
|
||||
session.LastAccess = time.Now()
|
||||
return true
|
||||
}
|
||||
|
||||
// GetPageResources 获取指定页的资源
|
||||
func (m *SearchSessionManager) GetPageResources(userID string, page int) []entity.Resource {
|
||||
m.mutex.RLock()
|
||||
session, exists := m.sessions[userID]
|
||||
m.mutex.RUnlock()
|
||||
|
||||
if !exists {
|
||||
return nil
|
||||
}
|
||||
|
||||
if page < 1 || page > session.TotalPages {
|
||||
return nil
|
||||
}
|
||||
|
||||
start := (page - 1) * session.PageSize
|
||||
end := start + session.PageSize
|
||||
if end > len(session.Resources) {
|
||||
end = len(session.Resources)
|
||||
}
|
||||
|
||||
// 更新当前页和最后访问时间
|
||||
m.mutex.Lock()
|
||||
session.CurrentPage = page
|
||||
session.LastAccess = time.Now()
|
||||
m.mutex.Unlock()
|
||||
|
||||
return session.Resources[start:end]
|
||||
}
|
||||
|
||||
// GetCurrentPageResources 获取当前页的资源
|
||||
func (m *SearchSessionManager) GetCurrentPageResources(userID string) []entity.Resource {
|
||||
m.mutex.RLock()
|
||||
session, exists := m.sessions[userID]
|
||||
m.mutex.RUnlock()
|
||||
|
||||
if !exists {
|
||||
return nil
|
||||
}
|
||||
|
||||
return m.GetPageResources(userID, session.CurrentPage)
|
||||
}
|
||||
|
||||
// HasNextPage 是否有下一页
|
||||
func (m *SearchSessionManager) HasNextPage(userID string) bool {
|
||||
m.mutex.RLock()
|
||||
defer m.mutex.RUnlock()
|
||||
|
||||
session, exists := m.sessions[userID]
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
|
||||
return session.CurrentPage < session.TotalPages
|
||||
}
|
||||
|
||||
// HasPrevPage 是否有上一页
|
||||
func (m *SearchSessionManager) HasPrevPage(userID string) bool {
|
||||
m.mutex.RLock()
|
||||
defer m.mutex.RUnlock()
|
||||
|
||||
session, exists := m.sessions[userID]
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
|
||||
return session.CurrentPage > 1
|
||||
}
|
||||
|
||||
// NextPage 下一页
|
||||
func (m *SearchSessionManager) NextPage(userID string) []entity.Resource {
|
||||
m.mutex.Lock()
|
||||
session, exists := m.sessions[userID]
|
||||
m.mutex.Unlock()
|
||||
|
||||
if !exists {
|
||||
return nil
|
||||
}
|
||||
|
||||
if session.CurrentPage >= session.TotalPages {
|
||||
return nil
|
||||
}
|
||||
|
||||
return m.GetPageResources(userID, session.CurrentPage+1)
|
||||
}
|
||||
|
||||
// PrevPage 上一页
|
||||
func (m *SearchSessionManager) PrevPage(userID string) []entity.Resource {
|
||||
m.mutex.Lock()
|
||||
session, exists := m.sessions[userID]
|
||||
m.mutex.Unlock()
|
||||
|
||||
if !exists {
|
||||
return nil
|
||||
}
|
||||
|
||||
if session.CurrentPage <= 1 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return m.GetPageResources(userID, session.CurrentPage-1)
|
||||
}
|
||||
|
||||
// GetPageInfo 获取分页信息
|
||||
func (m *SearchSessionManager) GetPageInfo(userID string) (currentPage, totalPages int, hasPrev, hasNext bool) {
|
||||
m.mutex.RLock()
|
||||
defer m.mutex.RUnlock()
|
||||
|
||||
session, exists := m.sessions[userID]
|
||||
if !exists {
|
||||
return 0, 0, false, false
|
||||
}
|
||||
|
||||
return session.CurrentPage, session.TotalPages, session.CurrentPage > 1, session.CurrentPage < session.TotalPages
|
||||
}
|
||||
|
||||
// cleanupExpiredSessions 清理过期会话(超过1小时未访问)
|
||||
func (m *SearchSessionManager) cleanupExpiredSessions() {
|
||||
ticker := time.NewTicker(10 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
m.mutex.Lock()
|
||||
now := time.Now()
|
||||
for userID, session := range m.sessions {
|
||||
// 如果超过1小时未访问,清理该会话
|
||||
if now.Sub(session.LastAccess) > time.Hour {
|
||||
delete(m.sessions, userID)
|
||||
}
|
||||
}
|
||||
m.mutex.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// GlobalSearchSessionManager 全局搜索会话管理器
|
||||
var GlobalSearchSessionManager = NewSearchSessionManager()
|
||||
@@ -39,6 +39,7 @@ type TelegramBotService interface {
|
||||
IsChannelRegistered(chatID int64) bool
|
||||
HandleWebhookUpdate(c interface{})
|
||||
CleanupDuplicateChannels() error
|
||||
ManualPushToChannel(channelID uint) error
|
||||
}
|
||||
|
||||
type TelegramBotServiceImpl struct {
|
||||
@@ -52,6 +53,7 @@ type TelegramBotServiceImpl struct {
|
||||
config *TelegramBotConfig
|
||||
pushHistory map[int64][]uint // 每个频道的推送历史记录,最多100条
|
||||
mu sync.RWMutex // 用于保护pushHistory的读写锁
|
||||
stopChan chan struct{} // 用于停止消息循环的channel
|
||||
}
|
||||
|
||||
type TelegramBotConfig struct {
|
||||
@@ -84,6 +86,7 @@ func NewTelegramBotService(
|
||||
cronScheduler: cron.New(),
|
||||
config: &TelegramBotConfig{},
|
||||
pushHistory: make(map[int64][]uint),
|
||||
stopChan: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,67 +114,72 @@ func (s *TelegramBotServiceImpl) loadConfig() error {
|
||||
s.config.ProxyUsername = ""
|
||||
s.config.ProxyPassword = ""
|
||||
|
||||
// 统计配置项数量,用于汇总日志
|
||||
configCount := 0
|
||||
|
||||
for _, config := range configs {
|
||||
switch config.Key {
|
||||
case entity.ConfigKeyTelegramBotEnabled:
|
||||
s.config.Enabled = config.Value == "true"
|
||||
utils.Info("[TELEGRAM:CONFIG] 加载配置 %s = %s (Enabled: %v)", config.Key, config.Value, s.config.Enabled)
|
||||
case entity.ConfigKeyTelegramBotApiKey:
|
||||
s.config.ApiKey = config.Value
|
||||
utils.Info("[TELEGRAM:CONFIG] 加载配置 %s = [HIDDEN]", config.Key)
|
||||
case entity.ConfigKeyTelegramAutoReplyEnabled:
|
||||
s.config.AutoReplyEnabled = config.Value == "true"
|
||||
utils.Info("[TELEGRAM:CONFIG] 加载配置 %s = %s (AutoReplyEnabled: %v)", config.Key, config.Value, s.config.AutoReplyEnabled)
|
||||
case entity.ConfigKeyTelegramAutoReplyTemplate:
|
||||
if config.Value != "" {
|
||||
s.config.AutoReplyTemplate = config.Value
|
||||
}
|
||||
utils.Info("[TELEGRAM:CONFIG] 加载配置 %s = %s", config.Key, config.Value)
|
||||
case entity.ConfigKeyTelegramAutoDeleteEnabled:
|
||||
s.config.AutoDeleteEnabled = config.Value == "true"
|
||||
utils.Info("[TELEGRAM:CONFIG] 加载配置 %s = %s (AutoDeleteEnabled: %v)", config.Key, config.Value, s.config.AutoDeleteEnabled)
|
||||
case entity.ConfigKeyTelegramAutoDeleteInterval:
|
||||
if config.Value != "" {
|
||||
fmt.Sscanf(config.Value, "%d", &s.config.AutoDeleteInterval)
|
||||
}
|
||||
utils.Info("[TELEGRAM:CONFIG] 加载配置 %s = %s (AutoDeleteInterval: %d)", config.Key, config.Value, s.config.AutoDeleteInterval)
|
||||
case entity.ConfigKeyTelegramProxyEnabled:
|
||||
s.config.ProxyEnabled = config.Value == "true"
|
||||
utils.Info("[TELEGRAM:CONFIG] 加载配置 %s = %s (ProxyEnabled: %v)", config.Key, config.Value, s.config.ProxyEnabled)
|
||||
case entity.ConfigKeyTelegramProxyType:
|
||||
s.config.ProxyType = config.Value
|
||||
utils.Info("[TELEGRAM:CONFIG] 加载配置 %s = %s (ProxyType: %s)", config.Key, config.Value, s.config.ProxyType)
|
||||
case entity.ConfigKeyTelegramProxyHost:
|
||||
s.config.ProxyHost = config.Value
|
||||
utils.Info("[TELEGRAM:CONFIG] 加载配置 %s = %s", config.Key, "[HIDDEN]")
|
||||
case entity.ConfigKeyTelegramProxyPort:
|
||||
if config.Value != "" {
|
||||
fmt.Sscanf(config.Value, "%d", &s.config.ProxyPort)
|
||||
}
|
||||
utils.Info("[TELEGRAM:CONFIG] 加载配置 %s = %s (ProxyPort: %d)", config.Key, config.Value, s.config.ProxyPort)
|
||||
case entity.ConfigKeyTelegramProxyUsername:
|
||||
s.config.ProxyUsername = config.Value
|
||||
utils.Info("[TELEGRAM:CONFIG] 加载配置 %s = %s", config.Key, "[HIDDEN]")
|
||||
case entity.ConfigKeyTelegramProxyPassword:
|
||||
s.config.ProxyPassword = config.Value
|
||||
utils.Info("[TELEGRAM:CONFIG] 加载配置 %s = %s", config.Key, "[HIDDEN]")
|
||||
default:
|
||||
utils.Debug("未知配置: %s = %s", config.Key, config.Value)
|
||||
utils.Debug("未知Telegram配置: %s", config.Key)
|
||||
}
|
||||
configCount++
|
||||
}
|
||||
|
||||
utils.Info("[TELEGRAM:SERVICE] Telegram Bot 配置加载完成: Enabled=%v, AutoReplyEnabled=%v, ApiKey长度=%d",
|
||||
s.config.Enabled, s.config.AutoReplyEnabled, len(s.config.ApiKey))
|
||||
// 汇总输出配置加载结果,避免逐项日志
|
||||
proxyStatus := "禁用"
|
||||
if s.config.ProxyEnabled {
|
||||
proxyStatus = "启用"
|
||||
}
|
||||
|
||||
utils.TelegramInfo("配置加载完成 - Bot启用: %v, 自动回复: %v, 代理: %s, 配置项数: %d",
|
||||
s.config.Enabled, s.config.AutoReplyEnabled, proxyStatus, configCount)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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)
|
||||
@@ -185,6 +193,11 @@ func (s *TelegramBotServiceImpl) Start() error {
|
||||
|
||||
if !s.config.Enabled || s.config.ApiKey == "" {
|
||||
utils.Info("[TELEGRAM:SERVICE] Telegram Bot 未启用或 API Key 未配置")
|
||||
// 如果机器人当前正在运行,需要停止它
|
||||
if s.isRunning {
|
||||
utils.Info("[TELEGRAM:SERVICE] 机器人已被禁用,停止正在运行的服务")
|
||||
s.Stop()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -259,6 +272,9 @@ func (s *TelegramBotServiceImpl) Start() error {
|
||||
s.bot = bot
|
||||
s.isRunning = true
|
||||
|
||||
// 重置停止信号channel
|
||||
s.stopChan = make(chan struct{})
|
||||
|
||||
utils.Info("[TELEGRAM:SERVICE] Telegram Bot (@%s) 已启动", s.GetBotUsername())
|
||||
|
||||
// 启动推送调度器
|
||||
@@ -281,12 +297,26 @@ func (s *TelegramBotServiceImpl) Stop() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
utils.Info("[TELEGRAM:SERVICE] 开始停止 Telegram Bot 服务")
|
||||
|
||||
s.isRunning = false
|
||||
|
||||
// 安全地发送停止信号给消息循环
|
||||
select {
|
||||
case <-s.stopChan:
|
||||
// channel 已经关闭
|
||||
default:
|
||||
// channel 未关闭,安全关闭
|
||||
close(s.stopChan)
|
||||
}
|
||||
|
||||
if s.cronScheduler != nil {
|
||||
s.cronScheduler.Stop()
|
||||
}
|
||||
|
||||
// 清理机器人实例以避免冲突
|
||||
s.bot = nil
|
||||
|
||||
utils.Info("[TELEGRAM:SERVICE] Telegram Bot 服务已停止")
|
||||
return nil
|
||||
}
|
||||
@@ -507,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
|
||||
|
||||
@@ -514,20 +550,39 @@ func (s *TelegramBotServiceImpl) messageLoop() {
|
||||
|
||||
utils.Info("[TELEGRAM:MESSAGE] 消息监听循环已启动,等待消息...")
|
||||
|
||||
for update := range updates {
|
||||
if update.Message != nil {
|
||||
utils.Info("[TELEGRAM:MESSAGE] 接收到新消息更新")
|
||||
s.handleMessage(update.Message)
|
||||
} else {
|
||||
utils.Debug("[TELEGRAM:MESSAGE] 接收到其他类型更新: %v", update)
|
||||
for {
|
||||
select {
|
||||
case <-s.stopChan:
|
||||
utils.Info("[TELEGRAM:MESSAGE] 收到停止信号,退出消息监听循环")
|
||||
return
|
||||
case update, ok := <-updates:
|
||||
if !ok {
|
||||
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)
|
||||
} else {
|
||||
utils.Debug("[TELEGRAM:MESSAGE] 接收到其他类型更新: %v", update)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
utils.Info("[TELEGRAM:MESSAGE] 消息监听循环已结束")
|
||||
}
|
||||
|
||||
// handleMessage 处理接收到的消息
|
||||
func (s *TelegramBotServiceImpl) handleMessage(message *tgbotapi.Message) {
|
||||
// 检查机器人是否正在运行且已启用
|
||||
if !s.isRunning || !s.config.Enabled {
|
||||
utils.Info("[TELEGRAM:MESSAGE] 机器人已停止或禁用,跳过消息处理: ChatID=%d", message.Chat.ID)
|
||||
return
|
||||
}
|
||||
|
||||
chatID := message.Chat.ID
|
||||
text := strings.TrimSpace(message.Text)
|
||||
|
||||
@@ -965,8 +1020,17 @@ func (s *TelegramBotServiceImpl) pushToChannel(channel entity.TelegramChannel) {
|
||||
|
||||
// 5. 记录推送的资源ID到历史记录,避免重复推送
|
||||
for _, resource := range resources {
|
||||
resourceEntity := resource.(entity.Resource)
|
||||
s.addPushedResourceID(channel.ChatID, resourceEntity.ID)
|
||||
var resourceID uint
|
||||
switch r := resource.(type) {
|
||||
case *entity.Resource:
|
||||
resourceID = r.ID
|
||||
case entity.Resource:
|
||||
resourceID = r.ID
|
||||
default:
|
||||
utils.Error("[TELEGRAM:PUSH] 无效的资源类型: %T", resource)
|
||||
continue
|
||||
}
|
||||
s.addPushedResourceID(channel.ChatID, resourceID)
|
||||
}
|
||||
|
||||
utils.Info("[TELEGRAM:PUSH:SUCCESS] 成功推送内容到频道: %s (%d 条资源)", channel.ChatName, len(resources))
|
||||
@@ -1009,16 +1073,20 @@ 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
|
||||
}
|
||||
|
||||
// 使用现有的搜索功能,按更新时间倒序获取最新资源
|
||||
resources, _, err := s.resourceRepo.SearchWithFilters(params)
|
||||
if err != nil {
|
||||
utils.Error("[TELEGRAM:PUSH] 获取最新资源失败: %v", err)
|
||||
return []interface{}{}
|
||||
}
|
||||
|
||||
// 排除最近推送过的资源
|
||||
if len(excludeResourceIDs) > 0 {
|
||||
resources = s.excludePushedResources(resources, excludeResourceIDs)
|
||||
return s.findRandomResources(channel, excludeResourceIDs) // 回退到随机策略
|
||||
}
|
||||
|
||||
// 应用时间限制
|
||||
@@ -1027,13 +1095,13 @@ func (s *TelegramBotServiceImpl) findLatestResources(channel entity.TelegramChan
|
||||
}
|
||||
|
||||
if len(resources) == 0 {
|
||||
utils.Info("[TELEGRAM:PUSH] 没有找到符合条件的最新资源")
|
||||
return []interface{}{}
|
||||
utils.Info("[TELEGRAM:PUSH] 没有找到符合条件的最新资源,尝试获取随机资源")
|
||||
return s.findRandomResources(channel, excludeResourceIDs) // 回退到随机策略
|
||||
}
|
||||
|
||||
// 返回最新资源(第一条)
|
||||
utils.Info("[TELEGRAM:PUSH] 成功获取最新资源: %s", resources[0].Title)
|
||||
return []interface{}{resources[0]}
|
||||
return []interface{}{&resources[0]}
|
||||
}
|
||||
|
||||
// findTransferredResources 查找已转存资源
|
||||
@@ -1043,6 +1111,11 @@ func (s *TelegramBotServiceImpl) findTransferredResources(channel entity.Telegra
|
||||
// 添加转存链接条件
|
||||
params["has_save_url"] = true
|
||||
|
||||
// 在数据库查询中排除已推送的资源
|
||||
if len(excludeResourceIDs) > 0 {
|
||||
params["exclude_ids"] = excludeResourceIDs
|
||||
}
|
||||
|
||||
// 优先获取有转存链接的资源
|
||||
resources, _, err := s.resourceRepo.SearchWithFilters(params)
|
||||
if err != nil {
|
||||
@@ -1050,11 +1123,6 @@ func (s *TelegramBotServiceImpl) findTransferredResources(channel entity.Telegra
|
||||
return []interface{}{}
|
||||
}
|
||||
|
||||
// 排除最近推送过的资源
|
||||
if len(excludeResourceIDs) > 0 {
|
||||
resources = s.excludePushedResources(resources, excludeResourceIDs)
|
||||
}
|
||||
|
||||
// 应用时间限制
|
||||
if channel.TimeLimit != "none" && len(resources) > 0 {
|
||||
resources = s.applyTimeFilter(resources, channel.TimeLimit)
|
||||
@@ -1068,7 +1136,7 @@ func (s *TelegramBotServiceImpl) findTransferredResources(channel entity.Telegra
|
||||
|
||||
// 返回第一个有转存链接的资源
|
||||
utils.Info("[TELEGRAM:PUSH] 成功获取已转存资源: %s", resources[0].Title)
|
||||
return []interface{}{resources[0]}
|
||||
return []interface{}{&resources[0]}
|
||||
}
|
||||
|
||||
// findRandomResources 查找随机资源(原有逻辑)
|
||||
@@ -1078,23 +1146,19 @@ func (s *TelegramBotServiceImpl) findRandomResources(channel entity.TelegramChan
|
||||
// 如果是已转存优先策略但没有找到转存资源,这里会回退到随机策略
|
||||
// 此时不需要额外的转存链接条件,让随机函数处理
|
||||
|
||||
// 先尝试获取候选资源列表,然后从中排除已推送的资源
|
||||
var candidateResources []entity.Resource
|
||||
var err error
|
||||
// 在数据库查询中排除已推送的资源
|
||||
if len(excludeResourceIDs) > 0 {
|
||||
params["exclude_ids"] = excludeResourceIDs
|
||||
}
|
||||
|
||||
// 使用搜索功能获取候选资源,然后过滤
|
||||
params["limit"] = 100 // 获取更多候选资源
|
||||
candidateResources, _, err = s.resourceRepo.SearchWithFilters(params)
|
||||
candidateResources, _, err := s.resourceRepo.SearchWithFilters(params)
|
||||
if err != nil {
|
||||
utils.Error("[TELEGRAM:PUSH] 获取候选资源失败: %v", err)
|
||||
return []interface{}{}
|
||||
}
|
||||
|
||||
// 排除最近推送过的资源
|
||||
if len(excludeResourceIDs) > 0 {
|
||||
candidateResources = s.excludePushedResources(candidateResources, excludeResourceIDs)
|
||||
}
|
||||
|
||||
// 应用时间限制
|
||||
if channel.TimeLimit != "none" && len(candidateResources) > 0 {
|
||||
candidateResources = s.applyTimeFilter(candidateResources, channel.TimeLimit)
|
||||
@@ -1108,7 +1172,7 @@ func (s *TelegramBotServiceImpl) findRandomResources(channel entity.TelegramChan
|
||||
|
||||
utils.Info("[TELEGRAM:PUSH] 成功获取随机资源: %s (从 %d 个候选资源中选择)",
|
||||
selectedResource.Title, len(candidateResources))
|
||||
return []interface{}{selectedResource}
|
||||
return []interface{}{&selectedResource}
|
||||
}
|
||||
|
||||
// 如果候选资源不足,回退到数据库随机函数
|
||||
@@ -1184,7 +1248,18 @@ func (s *TelegramBotServiceImpl) buildFilterParams(channel entity.TelegramChanne
|
||||
|
||||
// buildPushMessage 构建推送消息
|
||||
func (s *TelegramBotServiceImpl) buildPushMessage(channel entity.TelegramChannel, resources []interface{}) (string, string) {
|
||||
resource := resources[0].(entity.Resource)
|
||||
var resource *entity.Resource
|
||||
|
||||
// 处理两种可能的类型:*entity.Resource 或 entity.Resource
|
||||
switch r := resources[0].(type) {
|
||||
case *entity.Resource:
|
||||
resource = r
|
||||
case entity.Resource:
|
||||
resource = &r
|
||||
default:
|
||||
utils.Error("[TELEGRAM:PUSH] 无效的资源类型: %T", resources[0])
|
||||
return "", ""
|
||||
}
|
||||
|
||||
message := fmt.Sprintf("🆕 <b>%s</b>\n", s.cleanMessageTextForHTML(resource.Title))
|
||||
|
||||
@@ -1243,6 +1318,12 @@ func (s *TelegramBotServiceImpl) GetBotUsername() string {
|
||||
|
||||
// SendMessage 发送消息(默认使用 HTML 格式)
|
||||
func (s *TelegramBotServiceImpl) SendMessage(chatID int64, text string, img string) error {
|
||||
// 检查机器人是否正在运行且已启用
|
||||
if !s.isRunning || !s.config.Enabled {
|
||||
utils.Info("[TELEGRAM:MESSAGE] 机器人已停止或禁用,跳过发送消息: ChatID=%d", chatID)
|
||||
return fmt.Errorf("机器人已停止或禁用")
|
||||
}
|
||||
|
||||
if img == "" {
|
||||
msg := tgbotapi.NewMessage(chatID, text)
|
||||
msg.ParseMode = "HTML"
|
||||
@@ -1254,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 是否可用
|
||||
@@ -1273,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 {
|
||||
@@ -1752,11 +1914,12 @@ func (s *TelegramBotServiceImpl) addPushedResourceID(chatID int64, resourceID ui
|
||||
history = []uint{}
|
||||
}
|
||||
|
||||
// 检查是否已经超过100条记录
|
||||
if len(history) >= 100 {
|
||||
// 清空历史记录,重新开始
|
||||
history = []uint{}
|
||||
utils.Info("[TELEGRAM:PUSH] 频道 %d 推送历史记录已满(100条),清空重置", chatID)
|
||||
// 检查是否已经超过5000条记录
|
||||
if len(history) >= 5000 {
|
||||
// 移除旧的2500条记录,保留最新的2500条记录
|
||||
startIndex := len(history) - 2500
|
||||
history = history[startIndex:]
|
||||
utils.Info("[TELEGRAM:PUSH] 频道 %d 推送历史记录已满(5000条),移除旧的2500条记录,保留最新的2500条", chatID)
|
||||
}
|
||||
|
||||
// 添加新的资源ID到历史记录
|
||||
@@ -1839,10 +2002,11 @@ func (s *TelegramBotServiceImpl) loadPushHistory() error {
|
||||
resourceIDs = append(resourceIDs, uint(resourceID))
|
||||
}
|
||||
|
||||
// 只保留最多100条记录
|
||||
if len(resourceIDs) > 100 {
|
||||
// 保留最新的100条记录
|
||||
resourceIDs = resourceIDs[len(resourceIDs)-100:]
|
||||
// 只保留最多5000条记录
|
||||
if len(resourceIDs) > 5000 {
|
||||
// 保留最新的5000条记录
|
||||
startIndex := len(resourceIDs) - 5000
|
||||
resourceIDs = resourceIDs[startIndex:]
|
||||
}
|
||||
|
||||
s.pushHistory[chatID] = resourceIDs
|
||||
@@ -1939,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
|
||||
}
|
||||
|
||||
58
services/wechat_bot_service.go
Normal file
58
services/wechat_bot_service.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"github.com/ctwj/urldb/db/repo"
|
||||
"github.com/silenceper/wechat/v2/officialaccount"
|
||||
"github.com/silenceper/wechat/v2/officialaccount/message"
|
||||
)
|
||||
|
||||
// WechatBotService 微信公众号机器人服务接口
|
||||
type WechatBotService interface {
|
||||
Start() error
|
||||
Stop() error
|
||||
IsRunning() bool
|
||||
ReloadConfig() error
|
||||
HandleMessage(msg *message.MixMessage) (interface{}, error)
|
||||
SendWelcomeMessage(openID string) error
|
||||
GetRuntimeStatus() map[string]interface{}
|
||||
GetConfig() *WechatBotConfig
|
||||
}
|
||||
|
||||
// WechatBotConfig 微信公众号机器人配置
|
||||
type WechatBotConfig struct {
|
||||
Enabled bool
|
||||
AppID string
|
||||
AppSecret string
|
||||
Token string
|
||||
EncodingAesKey string
|
||||
WelcomeMessage string
|
||||
AutoReplyEnabled bool
|
||||
SearchLimit int
|
||||
}
|
||||
|
||||
// WechatBotServiceImpl 微信公众号机器人服务实现
|
||||
type WechatBotServiceImpl struct {
|
||||
isRunning bool
|
||||
systemConfigRepo repo.SystemConfigRepository
|
||||
resourceRepo repo.ResourceRepository
|
||||
readyRepo repo.ReadyResourceRepository
|
||||
config *WechatBotConfig
|
||||
wechatClient *officialaccount.OfficialAccount
|
||||
searchSessionManager *SearchSessionManager
|
||||
}
|
||||
|
||||
// NewWechatBotService 创建微信公众号机器人服务
|
||||
func NewWechatBotService(
|
||||
systemConfigRepo repo.SystemConfigRepository,
|
||||
resourceRepo repo.ResourceRepository,
|
||||
readyResourceRepo repo.ReadyResourceRepository,
|
||||
) WechatBotService {
|
||||
return &WechatBotServiceImpl{
|
||||
isRunning: false,
|
||||
systemConfigRepo: systemConfigRepo,
|
||||
resourceRepo: resourceRepo,
|
||||
readyRepo: readyResourceRepo,
|
||||
config: &WechatBotConfig{},
|
||||
searchSessionManager: GlobalSearchSessionManager,
|
||||
}
|
||||
}
|
||||
524
services/wechat_bot_service_impl.go
Normal file
524
services/wechat_bot_service_impl.go
Normal file
@@ -0,0 +1,524 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/ctwj/urldb/db/entity"
|
||||
"github.com/ctwj/urldb/utils"
|
||||
"github.com/silenceper/wechat/v2/cache"
|
||||
"github.com/silenceper/wechat/v2/officialaccount"
|
||||
"github.com/silenceper/wechat/v2/officialaccount/config"
|
||||
"github.com/silenceper/wechat/v2/officialaccount/message"
|
||||
)
|
||||
|
||||
// loadConfig 加载微信配置
|
||||
func (s *WechatBotServiceImpl) loadConfig() error {
|
||||
configs, err := s.systemConfigRepo.GetOrCreateDefault()
|
||||
if err != nil {
|
||||
return fmt.Errorf("加载配置失败: %v", err)
|
||||
}
|
||||
|
||||
utils.Info("[WECHAT] 从数据库加载到 %d 个配置项", len(configs))
|
||||
|
||||
// 初始化默认值
|
||||
s.config.Enabled = false
|
||||
s.config.AppID = ""
|
||||
s.config.AppSecret = ""
|
||||
s.config.Token = ""
|
||||
s.config.EncodingAesKey = ""
|
||||
s.config.WelcomeMessage = "欢迎关注老九网盘资源库!发送关键词即可搜索资源。"
|
||||
s.config.AutoReplyEnabled = true
|
||||
s.config.SearchLimit = 5
|
||||
|
||||
for _, config := range configs {
|
||||
switch config.Key {
|
||||
case entity.ConfigKeyWechatBotEnabled:
|
||||
s.config.Enabled = config.Value == "true"
|
||||
utils.Info("[WECHAT:CONFIG] 加载配置 %s = %s (Enabled: %v)", config.Key, config.Value, s.config.Enabled)
|
||||
case entity.ConfigKeyWechatAppId:
|
||||
s.config.AppID = config.Value
|
||||
utils.Info("[WECHAT:CONFIG] 加载配置 %s = [HIDDEN]", config.Key)
|
||||
case entity.ConfigKeyWechatAppSecret:
|
||||
s.config.AppSecret = config.Value
|
||||
utils.Info("[WECHAT:CONFIG] 加载配置 %s = [HIDDEN]", config.Key)
|
||||
case entity.ConfigKeyWechatToken:
|
||||
s.config.Token = config.Value
|
||||
utils.Info("[WECHAT:CONFIG] 加载配置 %s = [HIDDEN]", config.Key)
|
||||
case entity.ConfigKeyWechatEncodingAesKey:
|
||||
s.config.EncodingAesKey = config.Value
|
||||
utils.Info("[WECHAT:CONFIG] 加载配置 %s = [HIDDEN]", config.Key)
|
||||
case entity.ConfigKeyWechatWelcomeMessage:
|
||||
if config.Value != "" {
|
||||
s.config.WelcomeMessage = config.Value
|
||||
}
|
||||
utils.Info("[WECHAT:CONFIG] 加载配置 %s = %s", config.Key, config.Value)
|
||||
case entity.ConfigKeyWechatAutoReplyEnabled:
|
||||
s.config.AutoReplyEnabled = config.Value == "true"
|
||||
utils.Info("[WECHAT:CONFIG] 加载配置 %s = %s (AutoReplyEnabled: %v)", config.Key, config.Value, s.config.AutoReplyEnabled)
|
||||
case entity.ConfigKeyWechatSearchLimit:
|
||||
if config.Value != "" {
|
||||
limit, err := strconv.Atoi(config.Value)
|
||||
if err == nil && limit > 0 {
|
||||
s.config.SearchLimit = limit
|
||||
}
|
||||
}
|
||||
utils.Info("[WECHAT:CONFIG] 加载配置 %s = %s (SearchLimit: %d)", config.Key, config.Value, s.config.SearchLimit)
|
||||
}
|
||||
}
|
||||
|
||||
utils.Info("[WECHAT:SERVICE] 微信公众号机器人配置加载完成: Enabled=%v, AutoReplyEnabled=%v",
|
||||
s.config.Enabled, s.config.AutoReplyEnabled)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Start 启动微信公众号机器人服务
|
||||
func (s *WechatBotServiceImpl) Start() error {
|
||||
if s.isRunning {
|
||||
utils.Info("[WECHAT:SERVICE] 微信公众号机器人服务已经在运行中")
|
||||
return nil
|
||||
}
|
||||
|
||||
// 加载配置
|
||||
if err := s.loadConfig(); err != nil {
|
||||
return fmt.Errorf("加载配置失败: %v", err)
|
||||
}
|
||||
|
||||
if !s.config.Enabled || s.config.AppID == "" || s.config.AppSecret == "" {
|
||||
utils.Info("[WECHAT:SERVICE] 微信公众号机器人未启用或配置不完整")
|
||||
return nil
|
||||
}
|
||||
|
||||
// 创建微信客户端
|
||||
cfg := &config.Config{
|
||||
AppID: s.config.AppID,
|
||||
AppSecret: s.config.AppSecret,
|
||||
Token: s.config.Token,
|
||||
EncodingAESKey: s.config.EncodingAesKey,
|
||||
Cache: cache.NewMemory(),
|
||||
}
|
||||
s.wechatClient = officialaccount.NewOfficialAccount(cfg)
|
||||
|
||||
s.isRunning = true
|
||||
utils.Info("[WECHAT:SERVICE] 微信公众号机器人服务已启动")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop 停止微信公众号机器人服务
|
||||
func (s *WechatBotServiceImpl) Stop() error {
|
||||
if !s.isRunning {
|
||||
return nil
|
||||
}
|
||||
|
||||
s.isRunning = false
|
||||
utils.Info("[WECHAT:SERVICE] 微信公众号机器人服务已停止")
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsRunning 检查微信公众号机器人服务是否正在运行
|
||||
func (s *WechatBotServiceImpl) IsRunning() bool {
|
||||
return s.isRunning
|
||||
}
|
||||
|
||||
// ReloadConfig 重新加载微信公众号机器人配置
|
||||
func (s *WechatBotServiceImpl) ReloadConfig() error {
|
||||
utils.Info("[WECHAT:SERVICE] 开始重新加载配置...")
|
||||
|
||||
// 重新加载配置
|
||||
if err := s.loadConfig(); err != nil {
|
||||
utils.Error("[WECHAT:SERVICE] 重新加载配置失败: %v", err)
|
||||
return fmt.Errorf("重新加载配置失败: %v", err)
|
||||
}
|
||||
|
||||
utils.Info("[WECHAT:SERVICE] 配置重新加载完成: Enabled=%v, AutoReplyEnabled=%v",
|
||||
s.config.Enabled, s.config.AutoReplyEnabled)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetRuntimeStatus 获取微信公众号机器人运行时状态
|
||||
func (s *WechatBotServiceImpl) GetRuntimeStatus() map[string]interface{} {
|
||||
status := map[string]interface{}{
|
||||
"is_running": s.IsRunning(),
|
||||
"config_loaded": s.config != nil,
|
||||
"app_id": s.config.AppID,
|
||||
}
|
||||
|
||||
return status
|
||||
}
|
||||
|
||||
// GetConfig 获取当前配置
|
||||
func (s *WechatBotServiceImpl) GetConfig() *WechatBotConfig {
|
||||
return s.config
|
||||
}
|
||||
|
||||
// HandleMessage 处理微信消息
|
||||
func (s *WechatBotServiceImpl) HandleMessage(msg *message.MixMessage) (interface{}, error) {
|
||||
utils.Info("[WECHAT:MESSAGE] 收到消息: FromUserName=%s, MsgType=%s, Event=%s, Content=%s",
|
||||
msg.FromUserName, msg.MsgType, msg.Event, msg.Content)
|
||||
|
||||
switch msg.MsgType {
|
||||
case message.MsgTypeText:
|
||||
return s.handleTextMessage(msg)
|
||||
case message.MsgTypeEvent:
|
||||
return s.handleEventMessage(msg)
|
||||
default:
|
||||
return nil, nil // 不处理其他类型消息
|
||||
}
|
||||
}
|
||||
|
||||
// handleTextMessage 处理文本消息
|
||||
func (s *WechatBotServiceImpl) handleTextMessage(msg *message.MixMessage) (interface{}, error) {
|
||||
utils.Debug("[WECHAT:MESSAGE] 处理文本消息 - AutoReplyEnabled: %v", s.config.AutoReplyEnabled)
|
||||
if !s.config.AutoReplyEnabled {
|
||||
utils.Info("[WECHAT:MESSAGE] 自动回复未启用")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
keyword := strings.TrimSpace(msg.Content)
|
||||
utils.Info("[WECHAT:MESSAGE] 搜索关键词: '%s'", keyword)
|
||||
|
||||
// 检查是否是分页命令
|
||||
if keyword == "上一页" || keyword == "prev" {
|
||||
return s.handlePrevPage(string(msg.FromUserName))
|
||||
}
|
||||
|
||||
if keyword == "下一页" || keyword == "next" {
|
||||
return s.handleNextPage(string(msg.FromUserName))
|
||||
}
|
||||
|
||||
// 检查是否是获取命令(例如:获取 1, 获取2等)
|
||||
if strings.HasPrefix(keyword, "获取") || strings.HasPrefix(keyword, "get") {
|
||||
return s.handleGetResource(string(msg.FromUserName), keyword)
|
||||
}
|
||||
|
||||
if keyword == "" {
|
||||
utils.Info("[WECHAT:MESSAGE] 关键词为空,返回提示消息")
|
||||
return message.NewText("请输入搜索关键词"), nil
|
||||
}
|
||||
|
||||
// 检查搜索关键词是否包含违禁词
|
||||
cleanWords, err := utils.GetForbiddenWordsFromConfig(func() (string, error) {
|
||||
return s.systemConfigRepo.GetConfigValue(entity.ConfigKeyForbiddenWords)
|
||||
})
|
||||
if err != nil {
|
||||
utils.Error("获取违禁词配置失败: %v", err)
|
||||
cleanWords = []string{} // 如果获取失败,使用空列表
|
||||
}
|
||||
|
||||
// 检查关键词是否包含违禁词
|
||||
if len(cleanWords) > 0 {
|
||||
containsForbidden, matchedWords := utils.CheckContainsForbiddenWords(keyword, cleanWords)
|
||||
if containsForbidden {
|
||||
utils.Info("[WECHAT:MESSAGE] 搜索关键词包含违禁词: %v", matchedWords)
|
||||
return message.NewText("您的搜索关键词包含违禁内容,不予处理"), nil
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索资源
|
||||
utils.Debug("[WECHAT:MESSAGE] 开始搜索资源,限制数量: %d", s.config.SearchLimit)
|
||||
resources, err := s.SearchResources(keyword)
|
||||
if err != nil {
|
||||
utils.Error("[WECHAT:SEARCH] 搜索失败: %v", err)
|
||||
return message.NewText("搜索服务暂时不可用,请稍后重试"), nil
|
||||
}
|
||||
|
||||
utils.Info("[WECHAT:MESSAGE] 搜索完成,找到 %d 个资源", len(resources))
|
||||
if len(resources) == 0 {
|
||||
utils.Info("[WECHAT:MESSAGE] 未找到相关资源,返回提示消息")
|
||||
return message.NewText(fmt.Sprintf("未找到关键词\"%s\"相关的资源,请尝试其他关键词", keyword)), nil
|
||||
}
|
||||
|
||||
// 创建搜索会话并保存第一页结果
|
||||
s.searchSessionManager.CreateSession(string(msg.FromUserName), keyword, resources, 4)
|
||||
pageResources := s.searchSessionManager.GetCurrentPageResources(string(msg.FromUserName))
|
||||
|
||||
// 格式化第一页搜索结果
|
||||
resultText := s.formatSearchResultsWithPagination(keyword, pageResources, string(msg.FromUserName))
|
||||
utils.Info("[WECHAT:MESSAGE] 格式化搜索结果,返回文本长度: %d", len(resultText))
|
||||
return message.NewText(resultText), nil
|
||||
}
|
||||
|
||||
// handlePrevPage 处理上一页命令
|
||||
func (s *WechatBotServiceImpl) handlePrevPage(userID string) (interface{}, error) {
|
||||
session := s.searchSessionManager.GetSession(userID)
|
||||
if session == nil {
|
||||
return message.NewText("没有找到搜索记录,请先进行搜索"), nil
|
||||
}
|
||||
|
||||
if !s.searchSessionManager.HasPrevPage(userID) {
|
||||
return message.NewText("已经是第一页了"), nil
|
||||
}
|
||||
|
||||
prevResources := s.searchSessionManager.PrevPage(userID)
|
||||
if prevResources == nil {
|
||||
return message.NewText("获取上一页失败"), nil
|
||||
}
|
||||
|
||||
currentPage, totalPages, _, _ := s.searchSessionManager.GetPageInfo(userID)
|
||||
resultText := s.formatPageResources(session.Keyword, prevResources, currentPage, totalPages, userID)
|
||||
return message.NewText(resultText), nil
|
||||
}
|
||||
|
||||
// handleNextPage 处理下一页命令
|
||||
func (s *WechatBotServiceImpl) handleNextPage(userID string) (interface{}, error) {
|
||||
session := s.searchSessionManager.GetSession(userID)
|
||||
if session == nil {
|
||||
return message.NewText("没有找到搜索记录,请先进行搜索"), nil
|
||||
}
|
||||
|
||||
if !s.searchSessionManager.HasNextPage(userID) {
|
||||
return message.NewText("已经是最后一页了"), nil
|
||||
}
|
||||
|
||||
nextResources := s.searchSessionManager.NextPage(userID)
|
||||
if nextResources == nil {
|
||||
return message.NewText("获取下一页失败"), nil
|
||||
}
|
||||
|
||||
currentPage, totalPages, _, _ := s.searchSessionManager.GetPageInfo(userID)
|
||||
resultText := s.formatPageResources(session.Keyword, nextResources, currentPage, totalPages, userID)
|
||||
return message.NewText(resultText), nil
|
||||
}
|
||||
|
||||
// handleGetResource 处理获取资源命令
|
||||
func (s *WechatBotServiceImpl) handleGetResource(userID, command string) (interface{}, error) {
|
||||
session := s.searchSessionManager.GetSession(userID)
|
||||
if session == nil {
|
||||
return message.NewText("没有找到搜索记录,请先进行搜索"), nil
|
||||
}
|
||||
|
||||
// 检查是否只输入了"获取"或"get",没有指定编号
|
||||
if command == "获取" || command == "get" {
|
||||
return message.NewText("📌 请输入要获取的资源编号\n\n💡 提示:回复\"获取 1\"或\"get 1\"获取第一个资源的详细信息"), nil
|
||||
}
|
||||
|
||||
// 解析命令,例如:"获取 1" 或 "get 2"
|
||||
// 支持"获取4"这种没有空格的格式
|
||||
var index int
|
||||
_, err := fmt.Sscanf(command, "获取%d", &index)
|
||||
if err != nil {
|
||||
_, err = fmt.Sscanf(command, "获取 %d", &index)
|
||||
if err != nil {
|
||||
_, err = fmt.Sscanf(command, "get%d", &index)
|
||||
if err != nil {
|
||||
_, err = fmt.Sscanf(command, "get %d", &index)
|
||||
if err != nil {
|
||||
return message.NewText("❌ 命令格式错误\n\n📌 正确格式:\n • 获取 1\n • get 1\n • 获取1\n • get1"), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if index < 1 || index > len(session.Resources) {
|
||||
return message.NewText(fmt.Sprintf("❌ 资源编号超出范围\n\n📌 请输入 1-%d 之间的数字\n💡 提示:回复\"获取 %d\"获取第%d个资源", len(session.Resources), index, index)), nil
|
||||
}
|
||||
|
||||
// 获取指定资源
|
||||
resource := session.Resources[index-1]
|
||||
|
||||
// 格式化资源详细信息(美化输出)
|
||||
var result strings.Builder
|
||||
// result.WriteString(fmt.Sprintf("📌 资源详情\n\n"))
|
||||
|
||||
// 标题
|
||||
result.WriteString(fmt.Sprintf("📌 标题: %s\n", resource.Title))
|
||||
|
||||
// 描述
|
||||
if resource.Description != "" {
|
||||
result.WriteString(fmt.Sprintf("\n📝 描述:\n %s\n", resource.Description))
|
||||
}
|
||||
|
||||
// 文件大小
|
||||
if resource.FileSize != "" {
|
||||
result.WriteString(fmt.Sprintf("\n📊 大小: %s\n", resource.FileSize))
|
||||
}
|
||||
|
||||
// 作者
|
||||
if resource.Author != "" {
|
||||
result.WriteString(fmt.Sprintf("\n👤 作者: %s\n", resource.Author))
|
||||
}
|
||||
|
||||
// 分类
|
||||
if resource.Category.Name != "" {
|
||||
result.WriteString(fmt.Sprintf("\n📂 分类: %s\n", resource.Category.Name))
|
||||
}
|
||||
|
||||
// 标签
|
||||
if len(resource.Tags) > 0 {
|
||||
result.WriteString("\n🏷️ 标签: ")
|
||||
var tags []string
|
||||
for _, tag := range resource.Tags {
|
||||
tags = append(tags, tag.Name)
|
||||
}
|
||||
result.WriteString(fmt.Sprintf("%s\n", strings.Join(tags, " ")))
|
||||
}
|
||||
|
||||
// 链接(美化)
|
||||
if resource.SaveURL != "" {
|
||||
result.WriteString(fmt.Sprintf("\n📥 转存链接:\n %s", resource.SaveURL))
|
||||
} else if resource.URL != "" {
|
||||
result.WriteString(fmt.Sprintf("\n🔗 资源链接:\n %s", resource.URL))
|
||||
}
|
||||
|
||||
// 添加操作提示
|
||||
result.WriteString(fmt.Sprintf("\n\n💡 提示:回复\"获取 %d\"可再次查看此资源", index))
|
||||
|
||||
return message.NewText(result.String()), nil
|
||||
}
|
||||
|
||||
// formatSearchResultsWithPagination 格式化带分页的搜索结果
|
||||
func (s *WechatBotServiceImpl) formatSearchResultsWithPagination(keyword string, resources []entity.Resource, userID string) string {
|
||||
currentPage, totalPages, _, _ := s.searchSessionManager.GetPageInfo(userID)
|
||||
return s.formatPageResources(keyword, resources, currentPage, totalPages, userID)
|
||||
}
|
||||
|
||||
// formatPageResources 格式化页面资源
|
||||
// 根据用户需求,搜索结果中不显示资源链接,只显示标题和描述
|
||||
func (s *WechatBotServiceImpl) formatPageResources(keyword string, resources []entity.Resource, currentPage, totalPages int, userID string) string {
|
||||
var result strings.Builder
|
||||
result.WriteString(fmt.Sprintf("🔍 搜索\"%s\"的结果(第%d/%d页):\n\n", keyword, currentPage, totalPages))
|
||||
|
||||
for i, resource := range resources {
|
||||
// 构建当前资源的文本表示
|
||||
var resourceText strings.Builder
|
||||
|
||||
// 计算全局索引(当前页的第i个资源在整个结果中的位置)
|
||||
globalIndex := (currentPage-1)*4 + i + 1
|
||||
resourceText.WriteString(fmt.Sprintf("%d. 📌 %s\n", globalIndex, resource.Title))
|
||||
|
||||
if resource.Description != "" {
|
||||
// 限制描述长度以避免消息过长(正确处理中文字符)
|
||||
desc := resource.Description
|
||||
// 将字符串转换为 rune 切片以正确处理中文字符
|
||||
runes := []rune(desc)
|
||||
if len(runes) > 50 {
|
||||
desc = string(runes[:50]) + "..."
|
||||
}
|
||||
resourceText.WriteString(fmt.Sprintf(" 📝 %s\n", desc))
|
||||
}
|
||||
|
||||
// 添加标签显示(格式:🏷️标签,空格,再接别的标签)
|
||||
if len(resource.Tags) > 0 {
|
||||
var tags []string
|
||||
for _, tag := range resource.Tags {
|
||||
tags = append(tags, "🏷️"+tag.Name)
|
||||
}
|
||||
// 限制标签数量以避免消息过长
|
||||
if len(tags) > 5 {
|
||||
tags = tags[:5]
|
||||
}
|
||||
resourceText.WriteString(fmt.Sprintf(" %s\n", strings.Join(tags, " ")))
|
||||
}
|
||||
|
||||
resourceText.WriteString(fmt.Sprintf(" 👉 回复\"获取 %d\"查看详细信息\n", globalIndex))
|
||||
resourceText.WriteString("\n")
|
||||
|
||||
// 预计算添加当前资源后的消息长度
|
||||
tempMessage := result.String() + resourceText.String()
|
||||
|
||||
// 添加分页提示和预留空间
|
||||
if currentPage > 1 || currentPage < totalPages {
|
||||
tempMessage += "💡 提示:回复\""
|
||||
if currentPage > 1 && currentPage < totalPages {
|
||||
tempMessage += "上一页\"或\"下一页"
|
||||
} else if currentPage > 1 {
|
||||
tempMessage += "上一页"
|
||||
} else {
|
||||
tempMessage += "下一页"
|
||||
}
|
||||
tempMessage += "\"翻页\n"
|
||||
}
|
||||
|
||||
// 检查添加当前资源后是否会超过微信限制
|
||||
tempRunes := []rune(tempMessage)
|
||||
if len(tempRunes) > 550 {
|
||||
result.WriteString("💡 内容较多,请翻页查看更多\n")
|
||||
break
|
||||
}
|
||||
|
||||
// 如果不会超过限制,则添加当前资源到结果中
|
||||
result.WriteString(resourceText.String())
|
||||
}
|
||||
|
||||
// 添加分页提示
|
||||
var pageTips []string
|
||||
if currentPage > 1 {
|
||||
pageTips = append(pageTips, "上一页")
|
||||
}
|
||||
if currentPage < totalPages {
|
||||
pageTips = append(pageTips, "下一页")
|
||||
}
|
||||
|
||||
if len(pageTips) > 0 {
|
||||
result.WriteString(fmt.Sprintf("💡 提示:回复\"%s\"翻页\n", strings.Join(pageTips, "\"或\"")))
|
||||
}
|
||||
|
||||
// 确保消息不超过微信限制(正确处理中文字符)
|
||||
message := result.String()
|
||||
// 将字符串转换为 rune 切片以正确处理中文字符
|
||||
runes := []rune(message)
|
||||
if len(runes) > 600 {
|
||||
// 如果还是超过限制,截断消息(微信建议不超过600个字符)
|
||||
message = string(runes[:597]) + "..."
|
||||
}
|
||||
|
||||
return message
|
||||
}
|
||||
|
||||
// handleEventMessage 处理事件消息
|
||||
func (s *WechatBotServiceImpl) handleEventMessage(msg *message.MixMessage) (interface{}, error) {
|
||||
if msg.Event == message.EventSubscribe {
|
||||
// 新用户关注
|
||||
return message.NewText(s.config.WelcomeMessage), nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// SearchResources 搜索资源
|
||||
func (s *WechatBotServiceImpl) SearchResources(keyword string) ([]entity.Resource, error) {
|
||||
// 使用统一搜索函数(包含Meilisearch优先搜索和违禁词处理)
|
||||
return UnifiedSearchResources(keyword, s.config.SearchLimit, s.systemConfigRepo, s.resourceRepo)
|
||||
}
|
||||
|
||||
// formatSearchResults 格式化搜索结果
|
||||
func (s *WechatBotServiceImpl) formatSearchResults(keyword string, resources []entity.Resource) string {
|
||||
var result strings.Builder
|
||||
result.WriteString(fmt.Sprintf("🔍 搜索\"%s\"的结果(共%d条):\n\n", keyword, len(resources)))
|
||||
|
||||
for i, resource := range resources {
|
||||
result.WriteString(fmt.Sprintf("%d. %s\n", i+1, resource.Title))
|
||||
if resource.Cover != "" {
|
||||
result.WriteString(fmt.Sprintf(" \n", resource.Cover))
|
||||
}
|
||||
if resource.Description != "" {
|
||||
desc := resource.Description
|
||||
if len(desc) > 50 {
|
||||
desc = desc[:50] + "..."
|
||||
}
|
||||
result.WriteString(fmt.Sprintf(" %s\n", desc))
|
||||
}
|
||||
if resource.SaveURL != "" {
|
||||
result.WriteString(fmt.Sprintf(" 转存链接:%s\n", resource.SaveURL))
|
||||
} else if resource.URL != "" {
|
||||
result.WriteString(fmt.Sprintf(" 资源链接:%s\n", resource.URL))
|
||||
}
|
||||
result.WriteString("\n")
|
||||
}
|
||||
|
||||
result.WriteString("💡 提示:回复资源编号可获取详细信息")
|
||||
return result.String()
|
||||
}
|
||||
|
||||
// SendWelcomeMessage 发送欢迎消息(预留接口,实际通过事件处理)
|
||||
func (s *WechatBotServiceImpl) SendWelcomeMessage(openID string) error {
|
||||
// 实际上欢迎消息是通过关注事件自动发送的
|
||||
// 这里提供一个手动发送的接口
|
||||
if !s.isRunning || s.wechatClient == nil {
|
||||
return fmt.Errorf("微信客户端未初始化")
|
||||
}
|
||||
|
||||
// 注意:Customer API 需要额外的权限,这里仅作示例
|
||||
// 实际应用中可能需要使用模板消息或其他方式
|
||||
return nil
|
||||
}
|
||||
534
task/google_index_processor.go
Normal file
534
task/google_index_processor.go
Normal file
@@ -0,0 +1,534 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"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"
|
||||
)
|
||||
|
||||
// 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 "url_submit":
|
||||
return gip.processURLSubmit(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("[GOOGLE-PROCESSOR] 开始网站地图提交任务: %s", input.SitemapURL)
|
||||
|
||||
if input.SitemapURL == "" {
|
||||
errorMsg := "网站地图URL不能为空"
|
||||
utils.Error("[GOOGLE-PROCESSOR] %s", errorMsg)
|
||||
gip.updateTaskItemStatus(item, entity.TaskItemStatusFailed, "", false, nil, 400, errorMsg)
|
||||
return fmt.Errorf(errorMsg)
|
||||
}
|
||||
|
||||
// 验证网站地图URL格式
|
||||
if !strings.HasPrefix(input.SitemapURL, "http://") && !strings.HasPrefix(input.SitemapURL, "https://") {
|
||||
errorMsg := fmt.Sprintf("网站地图URL格式错误,必须以http://或https://开头: %s", input.SitemapURL)
|
||||
utils.Error("[GOOGLE-PROCESSOR] %s", errorMsg)
|
||||
gip.updateTaskItemStatus(item, entity.TaskItemStatusFailed, "", false, nil, 400, errorMsg)
|
||||
return fmt.Errorf(errorMsg)
|
||||
}
|
||||
|
||||
utils.Info("[GOOGLE-PROCESSOR] 提交网站地图到Google...")
|
||||
// 提交网站地图
|
||||
err := client.SubmitSitemap(input.SitemapURL)
|
||||
if err != nil {
|
||||
utils.Error("[GOOGLE-PROCESSOR] 提交网站地图失败: %s, 错误: %v", input.SitemapURL, err)
|
||||
errorMessage := fmt.Sprintf("网站地图提交失败: %v", err)
|
||||
gip.updateTaskItemStatus(item, entity.TaskItemStatusFailed, "", false, nil, 500, errorMessage)
|
||||
return fmt.Errorf("提交网站地图失败: %v", err)
|
||||
}
|
||||
|
||||
// 更新任务项状态
|
||||
now := time.Now()
|
||||
gip.updateTaskItemStatus(item, entity.TaskItemStatusSuccess, "SUBMITTED", false, &now, 200, "")
|
||||
|
||||
utils.Info("[GOOGLE-PROCESSOR] 网站地图提交任务完成: %s", input.SitemapURL)
|
||||
return nil
|
||||
}
|
||||
|
||||
// processURLSubmit 处理URL提交到索引
|
||||
func (gip *GoogleIndexProcessor) processURLSubmit(ctx context.Context, client *google.Client, taskID uint, item *entity.TaskItem, input GoogleIndexTaskInput) error {
|
||||
utils.Info("[GOOGLE-PROCESSOR] 开始URL提交到索引任务,URL数量: %d", len(input.URLs))
|
||||
|
||||
submittedCount := 0
|
||||
failedCount := 0
|
||||
|
||||
for i, url := range input.URLs {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
utils.Error("[GOOGLE-PROCESSOR] URL提交任务被取消")
|
||||
gip.updateTaskItemStatus(item, entity.TaskItemStatusFailed, "", false, nil, 0, "任务被取消")
|
||||
return ctx.Err()
|
||||
default:
|
||||
utils.Info("[GOOGLE-PROCESSOR] 处理URL %d/%d: %s", i+1, len(input.URLs), url)
|
||||
|
||||
// 提交URL到索引
|
||||
err := client.PublishURL(url, "URL_UPDATED")
|
||||
if err != nil {
|
||||
utils.Error("[GOOGLE-PROCESSOR] 提交URL到索引失败: %s, 错误: %v", url, err)
|
||||
|
||||
// 更新失败状态,但继续处理其他URL
|
||||
errorMessage := fmt.Sprintf("URL %s 提交失败: %v", url, err)
|
||||
gip.updateTaskItemStatus(item, entity.TaskItemStatusFailed, "", false, nil, 500, errorMessage)
|
||||
failedCount++
|
||||
|
||||
// 即使失败也要等待,避免触发更多频率限制
|
||||
time.Sleep(2 * time.Second)
|
||||
continue
|
||||
}
|
||||
|
||||
submittedCount++
|
||||
utils.Info("[GOOGLE-PROCESSOR] URL提交成功: %s", url)
|
||||
|
||||
// Indexing API有严格的频率限制,增加延迟
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
// 更新任务项状态
|
||||
now := time.Now()
|
||||
statusMessage := fmt.Sprintf("成功提交: %d, 失败: %d", submittedCount, failedCount)
|
||||
|
||||
// 根据提交结果确定任务项状态
|
||||
var finalStatus entity.TaskItemStatus
|
||||
var statusCode int
|
||||
if submittedCount > 0 && failedCount == 0 {
|
||||
// 全部成功
|
||||
finalStatus = entity.TaskItemStatusSuccess
|
||||
statusCode = 200
|
||||
utils.Info("[GOOGLE-PROCESSOR] URL提交任务全部成功: %s", statusMessage)
|
||||
} else if submittedCount == 0 && failedCount > 0 {
|
||||
// 全部失败
|
||||
finalStatus = entity.TaskItemStatusFailed
|
||||
statusCode = 500
|
||||
utils.Error("[GOOGLE-PROCESSOR] URL提交任务全部失败: %s", statusMessage)
|
||||
} else {
|
||||
// 部分成功
|
||||
finalStatus = entity.TaskItemStatusSuccess // 部分成功算作完成,但错误消息会显示失败数量
|
||||
statusCode = 206 // 206 Partial Content
|
||||
utils.Info("[GOOGLE-PROCESSOR] URL提交任务部分成功: %s", statusMessage)
|
||||
}
|
||||
|
||||
gip.updateTaskItemStatus(item, finalStatus, "SUBMITTED", false, &now, statusCode, statusMessage)
|
||||
|
||||
utils.Info("[GOOGLE-PROCESSOR] URL提交任务完成: %s", statusMessage)
|
||||
|
||||
// 如果所有URL都提交失败,返回错误
|
||||
if submittedCount == 0 && failedCount > 0 {
|
||||
return fmt.Errorf("所有URL提交失败,失败数量: %d", failedCount)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
successCount := 0
|
||||
failedCount := 0
|
||||
|
||||
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)
|
||||
failedCount++
|
||||
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, "")
|
||||
successCount++
|
||||
|
||||
utils.Info("URL状态检查完成: %s, 状态: %s", url, result.IndexStatusResult.IndexingState)
|
||||
}
|
||||
}
|
||||
|
||||
// 如果所有URL都检查失败,返回错误
|
||||
if successCount == 0 && failedCount > 0 {
|
||||
errorMsg := fmt.Sprintf("所有URL状态检查失败,失败数量: %d", failedCount)
|
||||
gip.updateTaskItemStatus(item, entity.TaskItemStatusFailed, "", false, nil, 500, errorMsg)
|
||||
return fmt.Errorf(errorMsg)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// initGoogleClient 初始化Google客户端
|
||||
func (gip *GoogleIndexProcessor) initGoogleClient() (*google.Client, error) {
|
||||
utils.Info("[GOOGLE-PROCESSOR] 开始初始化Google客户端")
|
||||
|
||||
// 使用固定的凭据文件路径,与验证逻辑保持一致
|
||||
credentialsFile := "data/google_credentials.json"
|
||||
utils.Info("[GOOGLE-PROCESSOR] 检查凭据文件: %s", credentialsFile)
|
||||
|
||||
// 检查凭据文件是否存在
|
||||
if _, err := os.Stat(credentialsFile); os.IsNotExist(err) {
|
||||
utils.Error("[GOOGLE-PROCESSOR] Google凭据文件不存在: %s", credentialsFile)
|
||||
return nil, fmt.Errorf("Google凭据文件不存在: %s", credentialsFile)
|
||||
}
|
||||
utils.Info("[GOOGLE-PROCESSOR] 凭据文件存在")
|
||||
|
||||
// 从配置中获取网站URL
|
||||
siteURL, err := gip.repoMgr.SystemConfigRepository.GetConfigValue(entity.ConfigKeyWebsiteURL)
|
||||
if err != nil {
|
||||
utils.Error("[GOOGLE-PROCESSOR] 获取网站URL配置失败: %v", err)
|
||||
return nil, fmt.Errorf("获取网站URL配置失败: %v", err)
|
||||
}
|
||||
|
||||
if siteURL == "" {
|
||||
utils.Error("[GOOGLE-PROCESSOR] 网站URL配置为空")
|
||||
return nil, fmt.Errorf("网站URL配置为空,请在站点配置中设置正确的网站URL")
|
||||
}
|
||||
|
||||
if siteURL == "https://example.com" {
|
||||
utils.Error("[GOOGLE-PROCESSOR] 网站URL仍为默认值,请更新为实际网站URL")
|
||||
return nil, fmt.Errorf("网站URL仍为默认值,请在站点配置中设置正确的网站URL")
|
||||
}
|
||||
|
||||
utils.Info("[GOOGLE-PROCESSOR] 使用网站URL: %s", siteURL)
|
||||
|
||||
config := &google.Config{
|
||||
CredentialsFile: credentialsFile,
|
||||
SiteURL: siteURL,
|
||||
TokenFile: "data/google_token.json", // 使用固定token文件名,放在data目录下
|
||||
}
|
||||
|
||||
utils.Info("[GOOGLE-PROCESSOR] 开始创建Google客户端...")
|
||||
client, err := google.NewClient(config)
|
||||
if err != nil {
|
||||
utils.Error("[GOOGLE-PROCESSOR] 创建Google客户端失败: %v", err)
|
||||
return nil, fmt.Errorf("创建Google客户端失败: %v", err)
|
||||
}
|
||||
|
||||
utils.Info("[GOOGLE-PROCESSOR] Google客户端初始化成功")
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// inspectURL 检查URL索引状态
|
||||
func (gip *GoogleIndexProcessor) inspectURL(client *google.Client, url string) (*google.URLInspectionResult, error) {
|
||||
utils.Info("[GOOGLE-PROCESSOR] 开始检查URL索引状态: %s", url)
|
||||
|
||||
// 重试机制
|
||||
var result *google.URLInspectionResult
|
||||
var err error
|
||||
|
||||
for attempt := 0; attempt <= gip.config.RetryAttempts; attempt++ {
|
||||
utils.Info("[GOOGLE-PROCESSOR] URL检查尝试 %d/%d: %s", attempt+1, gip.config.RetryAttempts+1, url)
|
||||
result, err = client.InspectURL(url)
|
||||
if err == nil {
|
||||
utils.Info("[GOOGLE-PROCESSOR] URL检查成功: %s", url)
|
||||
break // 成功则退出重试循环
|
||||
}
|
||||
|
||||
if attempt < gip.config.RetryAttempts {
|
||||
utils.Info("[GOOGLE-PROCESSOR] URL检查失败,第%d次重试: %s, 错误: %v", attempt+1, url, err)
|
||||
time.Sleep(gip.config.RetryDelay)
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
utils.Error("[GOOGLE-PROCESSOR] URL检查最终失败: %s, 错误: %v", url, err)
|
||||
return nil, fmt.Errorf("检查URL失败: %v", err)
|
||||
}
|
||||
|
||||
if result != nil {
|
||||
utils.Info("[GOOGLE-PROCESSOR] URL检查结果: %s - 索引状态: %s", url, result.IndexStatusResult.IndexingState)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -189,12 +189,22 @@ func (tm *TaskManager) StopTask(taskID uint) error {
|
||||
// processTask 处理任务
|
||||
func (tm *TaskManager) processTask(ctx context.Context, task *entity.Task, processor TaskProcessor) {
|
||||
startTime := utils.GetCurrentTime()
|
||||
|
||||
// 记录任务开始
|
||||
utils.Info("任务开始 - ID: %d, 类型: %s", task.ID, task.Type)
|
||||
|
||||
defer func() {
|
||||
tm.mu.Lock()
|
||||
delete(tm.running, task.ID)
|
||||
tm.mu.Unlock()
|
||||
|
||||
elapsedTime := time.Since(startTime)
|
||||
utils.Info("processTask: 任务 %d 处理完成,耗时: %v,清理资源", task.ID, elapsedTime)
|
||||
// 使用业务事件记录任务完成,只有异常情况才输出详细日志
|
||||
if elapsedTime > 30*time.Second {
|
||||
utils.Warn("任务处理耗时较长 - ID: %d, 类型: %s, 耗时: %v", task.ID, task.Type, elapsedTime)
|
||||
}
|
||||
|
||||
utils.Info("任务完成 - ID: %d, 类型: %s, 耗时: %v", task.ID, task.Type, elapsedTime)
|
||||
}()
|
||||
|
||||
utils.InfoWithFields(map[string]interface{}{
|
||||
@@ -545,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)
|
||||
}
|
||||
}
|
||||
@@ -130,7 +130,7 @@ func (p *ForbiddenWordsProcessor) ProcessForbiddenWords(text string, forbiddenWo
|
||||
|
||||
// ParseForbiddenWordsConfig 解析违禁词配置字符串
|
||||
// 参数:
|
||||
// - config: 违禁词配置字符串,多个词用逗号分隔
|
||||
// - config: 违禁词配置字符串,多个词用逗号或换行符分隔
|
||||
//
|
||||
// 返回:
|
||||
// - []string: 处理后的违禁词列表
|
||||
@@ -139,16 +139,21 @@ func (p *ForbiddenWordsProcessor) ParseForbiddenWordsConfig(config string) []str
|
||||
return nil
|
||||
}
|
||||
|
||||
words := strings.Split(config, ",")
|
||||
var cleanWords []string
|
||||
for _, word := range words {
|
||||
word = strings.TrimSpace(word)
|
||||
if word != "" {
|
||||
cleanWords = append(cleanWords, word)
|
||||
var words []string
|
||||
// 首先尝试用换行符分割
|
||||
lines := strings.Split(config, "\n")
|
||||
for _, line := range lines {
|
||||
// 对每一行再用逗号分割(兼容两种格式)
|
||||
parts := strings.Split(line, ",")
|
||||
for _, part := range parts {
|
||||
word := strings.TrimSpace(part)
|
||||
if word != "" {
|
||||
words = append(words, word)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return cleanWords
|
||||
return words
|
||||
}
|
||||
|
||||
// 全局实例,方便直接调用
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
514
utils/logger.go
514
utils/logger.go
@@ -1,7 +1,6 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
@@ -10,7 +9,6 @@ import (
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LogLevel 日志级别
|
||||
@@ -24,7 +22,7 @@ const (
|
||||
FATAL
|
||||
)
|
||||
|
||||
// String 返回日志级别的字符串表示
|
||||
// String 返回级别的字符串表示
|
||||
func (l LogLevel) String() string {
|
||||
switch l {
|
||||
case DEBUG:
|
||||
@@ -42,280 +40,76 @@ func (l LogLevel) String() string {
|
||||
}
|
||||
}
|
||||
|
||||
// StructuredLogEntry 结构化日志条目
|
||||
type StructuredLogEntry struct {
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Level string `json:"level"`
|
||||
Message string `json:"message"`
|
||||
Caller string `json:"caller"`
|
||||
Module string `json:"module"`
|
||||
Fields map[string]interface{} `json:"fields,omitempty"`
|
||||
}
|
||||
|
||||
// Logger 统一日志器
|
||||
// Logger 简化的日志器
|
||||
type Logger struct {
|
||||
debugLogger *log.Logger
|
||||
infoLogger *log.Logger
|
||||
warnLogger *log.Logger
|
||||
errorLogger *log.Logger
|
||||
fatalLogger *log.Logger
|
||||
|
||||
level LogLevel
|
||||
logger *log.Logger
|
||||
file *os.File
|
||||
mu sync.Mutex
|
||||
config *LogConfig
|
||||
}
|
||||
|
||||
// LogConfig 日志配置
|
||||
type LogConfig struct {
|
||||
LogDir string // 日志目录
|
||||
LogLevel LogLevel // 日志级别
|
||||
MaxFileSize int64 // 单个日志文件最大大小(MB)
|
||||
MaxBackups int // 最大备份文件数
|
||||
MaxAge int // 日志文件最大保留天数
|
||||
EnableConsole bool // 是否启用控制台输出
|
||||
EnableFile bool // 是否启用文件输出
|
||||
EnableRotation bool // 是否启用日志轮转
|
||||
StructuredLog bool // 是否启用结构化日志格式
|
||||
}
|
||||
|
||||
// DefaultConfig 默认配置
|
||||
func DefaultConfig() *LogConfig {
|
||||
// 从环境变量获取日志级别,默认为INFO
|
||||
logLevel := getLogLevelFromEnv()
|
||||
|
||||
return &LogConfig{
|
||||
LogDir: "logs",
|
||||
LogLevel: logLevel,
|
||||
MaxFileSize: 100, // 100MB
|
||||
MaxBackups: 5,
|
||||
MaxAge: 30, // 30天
|
||||
EnableConsole: true,
|
||||
EnableFile: true,
|
||||
EnableRotation: true,
|
||||
StructuredLog: os.Getenv("STRUCTURED_LOG") == "true", // 从环境变量控制结构化日志
|
||||
}
|
||||
}
|
||||
|
||||
// getLogLevelFromEnv 从环境变量获取日志级别
|
||||
func getLogLevelFromEnv() LogLevel {
|
||||
envLogLevel := os.Getenv("LOG_LEVEL")
|
||||
envDebug := os.Getenv("DEBUG")
|
||||
|
||||
// 如果设置了DEBUG环境变量为true,则使用DEBUG级别
|
||||
if envDebug == "true" || envDebug == "1" {
|
||||
return DEBUG
|
||||
}
|
||||
|
||||
// 根据LOG_LEVEL环境变量设置日志级别
|
||||
switch strings.ToUpper(envLogLevel) {
|
||||
case "DEBUG":
|
||||
return DEBUG
|
||||
case "INFO":
|
||||
return INFO
|
||||
case "WARN", "WARNING":
|
||||
return WARN
|
||||
case "ERROR":
|
||||
return ERROR
|
||||
case "FATAL":
|
||||
return FATAL
|
||||
default:
|
||||
// 根据运行环境设置默认级别:开发环境DEBUG,生产环境INFO
|
||||
if isDevelopment() {
|
||||
return DEBUG
|
||||
}
|
||||
return INFO
|
||||
}
|
||||
}
|
||||
|
||||
// isDevelopment 判断是否为开发环境
|
||||
func isDevelopment() bool {
|
||||
env := os.Getenv("GO_ENV")
|
||||
return env == "development" || env == "dev" || env == "local" || env == "test"
|
||||
}
|
||||
|
||||
// getEnvironment 获取当前环境类型
|
||||
func (l *Logger) getEnvironment() string {
|
||||
if isDevelopment() {
|
||||
return "development"
|
||||
}
|
||||
return "production"
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
var (
|
||||
globalLogger *Logger
|
||||
onceLogger sync.Once
|
||||
loggerOnce sync.Once
|
||||
)
|
||||
|
||||
// InitLogger 初始化全局日志器
|
||||
func InitLogger(config *LogConfig) error {
|
||||
// InitLogger 初始化日志器
|
||||
func InitLogger() error {
|
||||
var err error
|
||||
onceLogger.Do(func() {
|
||||
if config == nil {
|
||||
config = DefaultConfig()
|
||||
loggerOnce.Do(func() {
|
||||
globalLogger = &Logger{
|
||||
level: INFO,
|
||||
logger: log.New(os.Stdout, "", log.LstdFlags),
|
||||
}
|
||||
|
||||
globalLogger, err = NewLogger(config)
|
||||
// 创建日志目录
|
||||
logDir := "logs"
|
||||
if err = os.MkdirAll(logDir, 0755); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 创建日志文件
|
||||
logFile := filepath.Join(logDir, "app.log")
|
||||
globalLogger.file, err = os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 同时输出到控制台和文件
|
||||
globalLogger.logger = log.New(io.MultiWriter(os.Stdout, globalLogger.file), "", log.LstdFlags)
|
||||
})
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// GetLogger 获取全局日志器
|
||||
func GetLogger() *Logger {
|
||||
if globalLogger == nil {
|
||||
InitLogger(nil)
|
||||
InitLogger()
|
||||
}
|
||||
return globalLogger
|
||||
}
|
||||
|
||||
// NewLogger 创建新的日志器
|
||||
func NewLogger(config *LogConfig) (*Logger, error) {
|
||||
if config == nil {
|
||||
config = DefaultConfig()
|
||||
}
|
||||
|
||||
logger := &Logger{
|
||||
config: config,
|
||||
}
|
||||
|
||||
// 创建日志目录
|
||||
if config.EnableFile {
|
||||
if err := os.MkdirAll(config.LogDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("创建日志目录失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化日志文件
|
||||
if err := logger.initLogFile(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 初始化日志器
|
||||
logger.initLoggers()
|
||||
|
||||
// 启动日志轮转检查
|
||||
if config.EnableRotation {
|
||||
go logger.startRotationCheck()
|
||||
}
|
||||
|
||||
// 打印日志配置信息
|
||||
logger.Info("日志系统初始化完成 - 级别: %s, 环境: %s",
|
||||
config.LogLevel.String(),
|
||||
logger.getEnvironment())
|
||||
|
||||
return logger, nil
|
||||
}
|
||||
|
||||
// initLogFile 初始化日志文件
|
||||
func (l *Logger) initLogFile() error {
|
||||
if !l.config.EnableFile {
|
||||
return nil
|
||||
}
|
||||
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
// 关闭现有文件
|
||||
if l.file != nil {
|
||||
l.file.Close()
|
||||
}
|
||||
|
||||
// 创建新的日志文件
|
||||
logFile := filepath.Join(l.config.LogDir, fmt.Sprintf("app_%s.log", GetCurrentTime().Format("2006-01-02")))
|
||||
file, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建日志文件失败: %v", err)
|
||||
}
|
||||
|
||||
l.file = file
|
||||
return nil
|
||||
}
|
||||
|
||||
// initLoggers 初始化各个级别的日志器
|
||||
func (l *Logger) initLoggers() {
|
||||
var writers []io.Writer
|
||||
|
||||
// 添加控制台输出
|
||||
if l.config.EnableConsole {
|
||||
writers = append(writers, os.Stdout)
|
||||
}
|
||||
|
||||
// 添加文件输出
|
||||
if l.config.EnableFile && l.file != nil {
|
||||
writers = append(writers, l.file)
|
||||
}
|
||||
|
||||
multiWriter := io.MultiWriter(writers...)
|
||||
|
||||
// 创建各个级别的日志器
|
||||
l.debugLogger = log.New(multiWriter, "[DEBUG] ", log.LstdFlags)
|
||||
l.infoLogger = log.New(multiWriter, "[INFO] ", log.LstdFlags)
|
||||
l.warnLogger = log.New(multiWriter, "[WARN] ", log.LstdFlags)
|
||||
l.errorLogger = log.New(multiWriter, "[ERROR] ", log.LstdFlags)
|
||||
l.fatalLogger = log.New(multiWriter, "[FATAL] ", log.LstdFlags)
|
||||
}
|
||||
|
||||
// log 内部日志方法
|
||||
func (l *Logger) log(level LogLevel, format string, args ...interface{}) {
|
||||
if level < l.config.LogLevel {
|
||||
if level < l.level {
|
||||
return
|
||||
}
|
||||
|
||||
// 获取调用者信息
|
||||
_, file, line, ok := runtime.Caller(2)
|
||||
if !ok {
|
||||
file = "unknown"
|
||||
line = 0
|
||||
caller := "unknown"
|
||||
if ok {
|
||||
caller = fmt.Sprintf("%s:%d", filepath.Base(file), line)
|
||||
}
|
||||
|
||||
// 提取文件名作为模块名
|
||||
fileName := filepath.Base(file)
|
||||
moduleName := strings.TrimSuffix(fileName, filepath.Ext(fileName))
|
||||
|
||||
// 格式化消息
|
||||
message := fmt.Sprintf(format, args...)
|
||||
logMessage := fmt.Sprintf("[%s] [%s] %s", level.String(), caller, message)
|
||||
|
||||
// 添加调用位置信息
|
||||
caller := fmt.Sprintf("%s:%d", fileName, line)
|
||||
l.logger.Println(logMessage)
|
||||
|
||||
if l.config.StructuredLog {
|
||||
// 结构化日志格式
|
||||
entry := StructuredLogEntry{
|
||||
Timestamp: GetCurrentTime(),
|
||||
Level: level.String(),
|
||||
Message: message,
|
||||
Caller: caller,
|
||||
Module: moduleName,
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(entry)
|
||||
if err != nil {
|
||||
// 如果JSON序列化失败,回退到普通格式
|
||||
fullMessage := fmt.Sprintf("[%s] [%s:%d] %s", level.String(), fileName, line, message)
|
||||
l.logToLevel(level, fullMessage)
|
||||
return
|
||||
}
|
||||
|
||||
l.logToLevel(level, string(jsonBytes))
|
||||
} else {
|
||||
// 普通文本格式
|
||||
fullMessage := fmt.Sprintf("[%s] [%s:%d] %s", level.String(), fileName, line, message)
|
||||
l.logToLevel(level, fullMessage)
|
||||
}
|
||||
}
|
||||
|
||||
// logToLevel 根据级别输出日志
|
||||
func (l *Logger) logToLevel(level LogLevel, message string) {
|
||||
switch level {
|
||||
case DEBUG:
|
||||
l.debugLogger.Println(message)
|
||||
case INFO:
|
||||
l.infoLogger.Println(message)
|
||||
case WARN:
|
||||
l.warnLogger.Println(message)
|
||||
case ERROR:
|
||||
l.errorLogger.Println(message)
|
||||
case FATAL:
|
||||
l.fatalLogger.Println(message)
|
||||
// Fatal级别终止程序
|
||||
if level == FATAL {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -345,162 +139,72 @@ func (l *Logger) Fatal(format string, args ...interface{}) {
|
||||
l.log(FATAL, format, args...)
|
||||
}
|
||||
|
||||
// startRotationCheck 启动日志轮转检查
|
||||
func (l *Logger) startRotationCheck() {
|
||||
ticker := time.NewTicker(1 * time.Hour) // 每小时检查一次
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
l.checkRotation()
|
||||
}
|
||||
// TelegramDebug Telegram调试日志
|
||||
func (l *Logger) TelegramDebug(format string, args ...interface{}) {
|
||||
l.log(DEBUG, "[TELEGRAM] "+format, args...)
|
||||
}
|
||||
|
||||
// checkRotation 检查是否需要轮转日志
|
||||
func (l *Logger) checkRotation() {
|
||||
if !l.config.EnableFile || l.file == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 检查文件大小
|
||||
fileInfo, err := l.file.Stat()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 如果文件超过最大大小,进行轮转
|
||||
if fileInfo.Size() > l.config.MaxFileSize*1024*1024 {
|
||||
l.rotateLog()
|
||||
}
|
||||
|
||||
// 清理旧日志文件
|
||||
l.cleanOldLogs()
|
||||
// TelegramInfo Telegram信息日志
|
||||
func (l *Logger) TelegramInfo(format string, args ...interface{}) {
|
||||
l.log(INFO, "[TELEGRAM] "+format, args...)
|
||||
}
|
||||
|
||||
// rotateLog 轮转日志文件
|
||||
func (l *Logger) rotateLog() {
|
||||
// TelegramWarn Telegram警告日志
|
||||
func (l *Logger) TelegramWarn(format string, args ...interface{}) {
|
||||
l.log(WARN, "[TELEGRAM] "+format, args...)
|
||||
}
|
||||
|
||||
// TelegramError Telegram错误日志
|
||||
func (l *Logger) TelegramError(format string, args ...interface{}) {
|
||||
l.log(ERROR, "[TELEGRAM] "+format, args...)
|
||||
}
|
||||
|
||||
// DebugWithFields 带字段的调试日志
|
||||
func (l *Logger) DebugWithFields(fields map[string]interface{}, format string, args ...interface{}) {
|
||||
message := fmt.Sprintf(format, args...)
|
||||
if len(fields) > 0 {
|
||||
var fieldStrs []string
|
||||
for k, v := range fields {
|
||||
fieldStrs = append(fieldStrs, fmt.Sprintf("%s=%v", k, v))
|
||||
}
|
||||
message = fmt.Sprintf("%s [%s]", message, strings.Join(fieldStrs, ", "))
|
||||
}
|
||||
l.log(DEBUG, message)
|
||||
}
|
||||
|
||||
// InfoWithFields 带字段的信息日志
|
||||
func (l *Logger) InfoWithFields(fields map[string]interface{}, format string, args ...interface{}) {
|
||||
message := fmt.Sprintf(format, args...)
|
||||
if len(fields) > 0 {
|
||||
var fieldStrs []string
|
||||
for k, v := range fields {
|
||||
fieldStrs = append(fieldStrs, fmt.Sprintf("%s=%v", k, v))
|
||||
}
|
||||
message = fmt.Sprintf("%s [%s]", message, strings.Join(fieldStrs, ", "))
|
||||
}
|
||||
l.log(INFO, message)
|
||||
}
|
||||
|
||||
// ErrorWithFields 带字段的错误日志
|
||||
func (l *Logger) ErrorWithFields(fields map[string]interface{}, format string, args ...interface{}) {
|
||||
message := fmt.Sprintf(format, args...)
|
||||
if len(fields) > 0 {
|
||||
var fieldStrs []string
|
||||
for k, v := range fields {
|
||||
fieldStrs = append(fieldStrs, fmt.Sprintf("%s=%v", k, v))
|
||||
}
|
||||
message = fmt.Sprintf("%s [%s]", message, strings.Join(fieldStrs, ", "))
|
||||
}
|
||||
l.log(ERROR, message)
|
||||
}
|
||||
|
||||
// Close 关闭日志文件
|
||||
func (l *Logger) Close() {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
// 关闭当前文件
|
||||
if l.file != nil {
|
||||
l.file.Close()
|
||||
}
|
||||
|
||||
// 重命名当前日志文件
|
||||
currentLogFile := filepath.Join(l.config.LogDir, fmt.Sprintf("app_%s.log", GetCurrentTime().Format("2006-01-02")))
|
||||
backupLogFile := filepath.Join(l.config.LogDir, fmt.Sprintf("app_%s_%s.log", GetCurrentTime().Format("2006-01-02"), GetCurrentTime().Format("15-04-05")))
|
||||
|
||||
if _, err := os.Stat(currentLogFile); err == nil {
|
||||
os.Rename(currentLogFile, backupLogFile)
|
||||
}
|
||||
|
||||
// 创建新的日志文件
|
||||
l.initLogFile()
|
||||
l.initLoggers()
|
||||
}
|
||||
|
||||
// cleanOldLogs 清理旧日志文件
|
||||
func (l *Logger) cleanOldLogs() {
|
||||
if l.config.MaxAge <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
files, err := filepath.Glob(filepath.Join(l.config.LogDir, "app_*.log"))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
cutoffTime := GetCurrentTime().AddDate(0, 0, -l.config.MaxAge)
|
||||
|
||||
for _, file := range files {
|
||||
fileInfo, err := os.Stat(file)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if fileInfo.ModTime().Before(cutoffTime) {
|
||||
os.Remove(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Min 返回两个整数中的较小值
|
||||
func Min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// 结构化日志方法
|
||||
func (l *Logger) DebugWithFields(fields map[string]interface{}, format string, args ...interface{}) {
|
||||
l.logWithFields(DEBUG, fields, format, args...)
|
||||
}
|
||||
|
||||
func (l *Logger) InfoWithFields(fields map[string]interface{}, format string, args ...interface{}) {
|
||||
l.logWithFields(INFO, fields, format, args...)
|
||||
}
|
||||
|
||||
func (l *Logger) WarnWithFields(fields map[string]interface{}, format string, args ...interface{}) {
|
||||
l.logWithFields(WARN, fields, format, args...)
|
||||
}
|
||||
|
||||
func (l *Logger) ErrorWithFields(fields map[string]interface{}, format string, args ...interface{}) {
|
||||
l.logWithFields(ERROR, fields, format, args...)
|
||||
}
|
||||
|
||||
func (l *Logger) FatalWithFields(fields map[string]interface{}, format string, args ...interface{}) {
|
||||
l.logWithFields(FATAL, fields, format, args...)
|
||||
}
|
||||
|
||||
// logWithFields 带字段的结构化日志方法
|
||||
func (l *Logger) logWithFields(level LogLevel, fields map[string]interface{}, format string, args ...interface{}) {
|
||||
if level < l.config.LogLevel {
|
||||
return
|
||||
}
|
||||
|
||||
// 获取调用者信息
|
||||
_, file, line, ok := runtime.Caller(2)
|
||||
if !ok {
|
||||
file = "unknown"
|
||||
line = 0
|
||||
}
|
||||
|
||||
// 提取文件名作为模块名
|
||||
fileName := filepath.Base(file)
|
||||
moduleName := strings.TrimSuffix(fileName, filepath.Ext(fileName))
|
||||
|
||||
// 格式化消息
|
||||
message := fmt.Sprintf(format, args...)
|
||||
|
||||
// 添加调用位置信息
|
||||
caller := fmt.Sprintf("%s:%d", fileName, line)
|
||||
|
||||
if l.config.StructuredLog {
|
||||
// 结构化日志格式
|
||||
entry := StructuredLogEntry{
|
||||
Timestamp: GetCurrentTime(),
|
||||
Level: level.String(),
|
||||
Message: message,
|
||||
Caller: caller,
|
||||
Module: moduleName,
|
||||
Fields: fields,
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(entry)
|
||||
if err != nil {
|
||||
// 如果JSON序列化失败,回退到普通格式
|
||||
fullMessage := fmt.Sprintf("[%s] [%s:%d] %s - Fields: %v", level.String(), fileName, line, message, fields)
|
||||
l.logToLevel(level, fullMessage)
|
||||
return
|
||||
}
|
||||
|
||||
l.logToLevel(level, string(jsonBytes))
|
||||
} else {
|
||||
// 普通文本格式
|
||||
fullMessage := fmt.Sprintf("[%s] [%s:%d] %s - Fields: %v", level.String(), fileName, line, message, fields)
|
||||
l.logToLevel(level, fullMessage)
|
||||
}
|
||||
}
|
||||
|
||||
// 全局便捷函数
|
||||
@@ -524,7 +228,22 @@ func Fatal(format string, args ...interface{}) {
|
||||
GetLogger().Fatal(format, args...)
|
||||
}
|
||||
|
||||
// 全局结构化日志便捷函数
|
||||
func TelegramDebug(format string, args ...interface{}) {
|
||||
GetLogger().TelegramDebug(format, args...)
|
||||
}
|
||||
|
||||
func TelegramInfo(format string, args ...interface{}) {
|
||||
GetLogger().TelegramInfo(format, args...)
|
||||
}
|
||||
|
||||
func TelegramWarn(format string, args ...interface{}) {
|
||||
GetLogger().TelegramWarn(format, args...)
|
||||
}
|
||||
|
||||
func TelegramError(format string, args ...interface{}) {
|
||||
GetLogger().TelegramError(format, args...)
|
||||
}
|
||||
|
||||
func DebugWithFields(fields map[string]interface{}, format string, args ...interface{}) {
|
||||
GetLogger().DebugWithFields(fields, format, args...)
|
||||
}
|
||||
@@ -533,14 +252,15 @@ func InfoWithFields(fields map[string]interface{}, format string, args ...interf
|
||||
GetLogger().InfoWithFields(fields, format, args...)
|
||||
}
|
||||
|
||||
func WarnWithFields(fields map[string]interface{}, format string, args ...interface{}) {
|
||||
GetLogger().WarnWithFields(fields, format, args...)
|
||||
}
|
||||
|
||||
func ErrorWithFields(fields map[string]interface{}, format string, args ...interface{}) {
|
||||
GetLogger().ErrorWithFields(fields, format, args...)
|
||||
}
|
||||
|
||||
func FatalWithFields(fields map[string]interface{}, format string, args ...interface{}) {
|
||||
GetLogger().FatalWithFields(fields, format, args...)
|
||||
// Min 返回两个整数中的较小值
|
||||
func Min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
|
||||
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']
|
||||
|
||||
530
web/components/Admin/GoogleIndexTab.vue
Normal file
530
web/components/Admin/GoogleIndexTab.vue
Normal file
@@ -0,0 +1,530 @@
|
||||
<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>
|
||||
|
||||
|
||||
<!-- 外部工具链接 -->
|
||||
<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="warning"
|
||||
@click="$emit('manual-submit-urls')"
|
||||
:loading="manualSubmitLoading"
|
||||
size="large"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="fas fa-paper-plane"></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="error"
|
||||
@click="$emit('diagnose-permissions')"
|
||||
:loading="diagnoseLoading"
|
||||
size="large"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="fas fa-stethoscope"></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
|
||||
tasks: any[]
|
||||
credentialsStatus: string | null
|
||||
credentialsStatusMessage: string
|
||||
configLoading: boolean
|
||||
manualCheckLoading: boolean
|
||||
manualSubmitLoading: boolean
|
||||
submitSitemapLoading: boolean
|
||||
tasksLoading: boolean
|
||||
diagnoseLoading: boolean
|
||||
pagination: any
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
systemConfig: null,
|
||||
googleIndexConfig: () => ({}),
|
||||
tasks: () => [],
|
||||
credentialsStatus: null,
|
||||
credentialsStatusMessage: '',
|
||||
configLoading: false,
|
||||
manualCheckLoading: false,
|
||||
manualSubmitLoading: false,
|
||||
submitSitemapLoading: false,
|
||||
tasksLoading: false,
|
||||
diagnoseLoading: false,
|
||||
pagination: () => ({})
|
||||
})
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits<{
|
||||
'update:google-index-config': []
|
||||
'show-verification': []
|
||||
'show-credentials-guide': []
|
||||
'select-credentials-file': []
|
||||
'manual-check-urls': []
|
||||
'manual-submit-urls': []
|
||||
'refresh-status': []
|
||||
'diagnose-permissions': []
|
||||
}>()
|
||||
|
||||
// 获取消息组件
|
||||
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 {
|
||||
|
||||
456
web/components/WechatBotTab.vue
Normal file
456
web/components/WechatBotTab.vue
Normal file
@@ -0,0 +1,456 @@
|
||||
<template>
|
||||
<div class="tab-content-container">
|
||||
<div class="space-y-6">
|
||||
<!-- 基础配置 -->
|
||||
<n-card title="基础配置" class="mb-6">
|
||||
<n-form :model="configForm" label-placement="left" label-width="120px">
|
||||
<n-form-item label="AppID">
|
||||
<n-input v-model:value="configForm.app_id" placeholder="请输入微信公众号AppID" />
|
||||
</n-form-item>
|
||||
<n-form-item label="AppSecret">
|
||||
<n-input v-model:value="configForm.app_secret" type="password" placeholder="请输入AppSecret" show-password-on="click" />
|
||||
</n-form-item>
|
||||
<n-form-item label="Token">
|
||||
<n-input v-model:value="configForm.token" placeholder="请输入Token(用于消息验证)" />
|
||||
</n-form-item>
|
||||
<n-form-item label="EncodingAESKey">
|
||||
<n-input v-model:value="configForm.encoding_aes_key" type="password" placeholder="请输入EncodingAESKey(可选,用于消息加密)" show-password-on="click" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
|
||||
<!-- 功能配置 -->
|
||||
<n-card title="功能配置" class="mb-6">
|
||||
<n-form :model="configForm" label-placement="left" label-width="120px">
|
||||
<n-form-item label="启用机器人">
|
||||
<n-switch v-model:value="configForm.enabled" />
|
||||
</n-form-item>
|
||||
<n-form-item label="自动回复">
|
||||
<n-switch v-model:value="configForm.auto_reply_enabled" />
|
||||
</n-form-item>
|
||||
<n-form-item label="欢迎消息">
|
||||
<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="100" placeholder="搜索结果返回数量" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
|
||||
<!-- 微信公众号验证文件上传 -->
|
||||
<n-card title="微信公众号验证文件" class="mb-6">
|
||||
<div class="space-y-4">
|
||||
<div class="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4">
|
||||
<h4 class="font-medium text-blue-800 dark:text-blue-200 mb-2">验证文件上传说明</h4>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300 mb-2">
|
||||
微信公众号需要上传一个TXT验证文件到网站根目录。请按照以下步骤操作:
|
||||
</p>
|
||||
<ol class="text-sm text-gray-700 dark:text-gray-300 list-decimal list-inside space-y-1">
|
||||
<li>点击下方"选择文件"按钮,选择微信提供的TXT验证文件</li>
|
||||
<li>点击"上传验证文件"按钮上传文件</li>
|
||||
<li>上传成功后,文件将可通过网站根目录直接访问</li>
|
||||
<li>在微信公众平台完成域名验证</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-4">
|
||||
<n-upload
|
||||
ref="uploadRef"
|
||||
:show-file-list="false"
|
||||
:accept="'.txt'"
|
||||
:max="1"
|
||||
:custom-request="handleUpload"
|
||||
@before-upload="beforeUpload"
|
||||
@change="handleFileChange"
|
||||
>
|
||||
<n-button type="primary">
|
||||
<template #icon>
|
||||
<i class="fas fa-file-upload"></i>
|
||||
</template>
|
||||
选择TXT文件
|
||||
</n-button>
|
||||
</n-upload>
|
||||
<n-button
|
||||
type="success"
|
||||
@click="triggerUpload"
|
||||
:disabled="!selectedFile"
|
||||
:loading="uploading"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="fas fa-cloud-upload-alt"></i>
|
||||
</template>
|
||||
上传验证文件
|
||||
</n-button>
|
||||
</div>
|
||||
|
||||
<div v-if="uploadResult" class="p-3 rounded-md bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-check-circle mr-2"></i>
|
||||
<span>文件上传成功!</span>
|
||||
</div>
|
||||
<p class="text-xs mt-1">文件名: {{ uploadResult.file_name }}</p>
|
||||
<p class="text-xs">访问地址: {{ getFullUrl(uploadResult.access_url) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</n-card>
|
||||
|
||||
<!-- 微信公众号平台配置说明 -->
|
||||
<n-card title="微信公众号平台配置" class="mb-6">
|
||||
<div class="space-y-4">
|
||||
<div class="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4">
|
||||
<h4 class="font-medium text-blue-800 dark:text-blue-200 mb-2">服务器配置</h4>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300 mb-2">
|
||||
在微信公众平台后台的<strong>开发 > 基本配置 > 服务器配置</strong>中设置:
|
||||
</p>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-2">
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1 block">URL</label>
|
||||
<div class="flex items-center space-x-2">
|
||||
<n-input :value="serverUrl" readonly class="flex-1" />
|
||||
<n-button size="small" @click="copyToClipboard(serverUrl)" type="primary">
|
||||
<template #icon>
|
||||
<i class="fas fa-copy"></i>
|
||||
</template>
|
||||
复制
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1 block">Token</label>
|
||||
<div class="flex items-center space-x-2">
|
||||
<n-input :value="configForm.token" readonly class="flex-1" />
|
||||
<n-button size="small" @click="copyToClipboard(configForm.token)" type="primary">
|
||||
<template #icon>
|
||||
<i class="fas fa-copy"></i>
|
||||
</template>
|
||||
复制
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-green-50 dark:bg-green-900/20 rounded-lg p-4">
|
||||
<h4 class="font-medium text-green-800 dark:text-green-200 mb-2">消息加解密配置</h4>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||
如果需要启用消息加密,请在微信公众平台选择<strong>安全模式</strong>,并填写上面的EncodingAESKey。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-yellow-50 dark:bg-yellow-900/20 rounded-lg p-4">
|
||||
<h4 class="font-medium text-yellow-800 dark:text-yellow-200 mb-2">注意事项</h4>
|
||||
<ul class="text-sm text-gray-700 dark:text-gray-300 list-disc list-inside space-y-1">
|
||||
<li>服务器必须支持HTTPS(微信要求)</li>
|
||||
<li>域名必须已备案</li>
|
||||
<li>首次配置时,微信会发送GET请求验证服务器</li>
|
||||
<li>配置完成后记得点击"启用"按钮</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</n-card>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="flex justify-end space-x-4">
|
||||
<n-button @click="resetForm">重置</n-button>
|
||||
<n-button type="primary" @click="saveConfig" :loading="loading">保存配置</n-button>
|
||||
</div>
|
||||
|
||||
<!-- 状态信息 -->
|
||||
<n-card title="运行状态" class="mt-6">
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center space-x-4">
|
||||
<n-tag :type="botStatus.overall_status ? 'success' : 'default'">
|
||||
{{ botStatus.status_text || '未知状态' }}
|
||||
</n-tag>
|
||||
<n-tag v-if="botStatus.config" :type="botStatus.config.enabled ? 'success' : 'default'">
|
||||
配置状态: {{ botStatus.config.enabled ? '已启用' : '已禁用' }}
|
||||
</n-tag>
|
||||
<n-tag v-if="botStatus.config" :type="botStatus.config.app_id_configured ? 'success' : 'warning'">
|
||||
AppID: {{ botStatus.config.app_id_configured ? '已配置' : '未配置' }}
|
||||
</n-tag>
|
||||
</div>
|
||||
|
||||
<div v-if="!botStatus.overall_status && botStatus.config && botStatus.config.enabled" class="bg-orange-50 dark:bg-orange-900/20 rounded-lg p-3">
|
||||
<p class="text-sm text-orange-800 dark:text-orange-200">
|
||||
<i class="fas fa-exclamation-triangle mr-2"></i>
|
||||
机器人已启用但未运行,请检查配置是否正确或查看系统日志。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</n-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { useNotification } from 'naive-ui'
|
||||
import { useWechatApi } from '~/composables/useApi'
|
||||
|
||||
// 定义配置表单类型
|
||||
interface WechatBotConfigForm {
|
||||
enabled: boolean
|
||||
app_id: string
|
||||
app_secret: string
|
||||
token: string
|
||||
encoding_aes_key: string
|
||||
welcome_message: string
|
||||
auto_reply_enabled: boolean
|
||||
search_limit: number
|
||||
}
|
||||
|
||||
const notification = useNotification()
|
||||
const loading = ref(false)
|
||||
const wechatApi = useWechatApi()
|
||||
const botStatus = ref({
|
||||
overall_status: false,
|
||||
status_text: '',
|
||||
config: null as any,
|
||||
runtime: null as any
|
||||
})
|
||||
// 验证文件上传相关
|
||||
const uploadRef = ref()
|
||||
const selectedFile = ref<File | null>(null)
|
||||
const uploading = ref(false)
|
||||
const uploadResult = ref<any>(null)
|
||||
|
||||
// 配置表单 - 直接使用 reactive
|
||||
const configForm = reactive<WechatBotConfigForm>({
|
||||
enabled: false,
|
||||
app_id: '',
|
||||
app_secret: '',
|
||||
token: '',
|
||||
encoding_aes_key: '',
|
||||
welcome_message: '欢迎关注老九网盘资源库!发送关键词即可搜索资源。',
|
||||
auto_reply_enabled: true,
|
||||
search_limit: 5
|
||||
})
|
||||
|
||||
// 计算服务器URL
|
||||
const serverUrl = computed(() => {
|
||||
if (process.client) {
|
||||
return `${window.location.origin}/api/wechat/callback`
|
||||
}
|
||||
return 'https://yourdomain.com/api/wechat/callback'
|
||||
})
|
||||
|
||||
// 获取机器人配置
|
||||
const fetchBotConfig = async () => {
|
||||
try {
|
||||
const response = await wechatApi.getBotConfig()
|
||||
|
||||
if (response) {
|
||||
// 直接更新 configForm
|
||||
configForm.enabled = response.enabled || false
|
||||
configForm.app_id = response.app_id || ''
|
||||
configForm.app_secret = response.app_secret || '' // 现在所有字段都不敏感
|
||||
configForm.token = response.token || ''
|
||||
configForm.encoding_aes_key = response.encoding_aes_key || ''
|
||||
configForm.welcome_message = response.welcome_message || '欢迎关注老九网盘资源库!发送关键词即可搜索资源。'
|
||||
configForm.auto_reply_enabled = response.auto_reply_enabled || true
|
||||
configForm.search_limit = response.search_limit || 5
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取微信机器人配置失败:', error)
|
||||
notification.error({
|
||||
content: '获取配置失败',
|
||||
duration: 3000
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 获取机器人状态
|
||||
const fetchBotStatus = async () => {
|
||||
try {
|
||||
const response = await wechatApi.getBotStatus()
|
||||
|
||||
if (response) {
|
||||
botStatus.value = response
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取微信机器人状态失败:', error)
|
||||
notification.error({
|
||||
content: '获取状态失败',
|
||||
duration: 3000
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 保存配置
|
||||
const saveConfig = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
// 直接保存所有字段,不检测变更
|
||||
const payload = {
|
||||
enabled: configForm.enabled,
|
||||
app_id: configForm.app_id,
|
||||
app_secret: configForm.app_secret,
|
||||
token: configForm.token,
|
||||
encoding_aes_key: configForm.encoding_aes_key,
|
||||
welcome_message: configForm.welcome_message,
|
||||
auto_reply_enabled: configForm.auto_reply_enabled,
|
||||
search_limit: configForm.search_limit
|
||||
}
|
||||
|
||||
const response = await wechatApi.updateBotConfig(payload)
|
||||
|
||||
if (response.success) {
|
||||
notification.success({
|
||||
content: '配置保存成功',
|
||||
duration: 3000
|
||||
})
|
||||
// 重新获取状态和配置
|
||||
await fetchBotConfig()
|
||||
await fetchBotStatus()
|
||||
} else {
|
||||
throw new Error(response.message || '保存失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('保存微信机器人配置失败:', error)
|
||||
notification.error({
|
||||
content: error.message || '保存配置失败',
|
||||
duration: 3000
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
// 重新获取原始配置
|
||||
fetchBotConfig()
|
||||
}
|
||||
|
||||
// 复制到剪贴板
|
||||
const copyToClipboard = async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
notification.success({
|
||||
content: '已复制到剪贴板',
|
||||
duration: 2000
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('复制失败:', error)
|
||||
notification.error({
|
||||
content: '复制失败',
|
||||
duration: 2000
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 验证文件上传相关函数
|
||||
const beforeUpload = (options: { file: any, fileList: any[] }) => {
|
||||
// 从 options 中提取文件
|
||||
const file = options?.file?.file || options?.file
|
||||
|
||||
// 检查文件对象是否有效
|
||||
if (!file || !file.name) {
|
||||
notification.error({
|
||||
content: '文件选择失败,请重试',
|
||||
duration: 2000
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
// 验证文件类型 - 使用多重检查确保是TXT文件
|
||||
const isValid = file.type === 'text/plain' ||
|
||||
file.name.toLowerCase().endsWith('.txt') ||
|
||||
file.type === 'text/plain;charset=utf-8'
|
||||
|
||||
if (!isValid) {
|
||||
notification.error({
|
||||
content: '请上传TXT文件',
|
||||
duration: 2000
|
||||
})
|
||||
selectedFile.value = null // 清空之前的选择
|
||||
return false // 阻止上传无效文件
|
||||
}
|
||||
|
||||
// 保存选中的文件并更新状态
|
||||
selectedFile.value = file
|
||||
|
||||
return false // 阻止自动上传
|
||||
}
|
||||
|
||||
const handleUpload = ({ file, onSuccess, onError }: any) => {
|
||||
// 这个函数不会被调用,因为我们阻止了自动上传
|
||||
}
|
||||
|
||||
// 文件选择变化时的处理函数
|
||||
const handleFileChange = (options: { file: any, fileList: any[] }) => {
|
||||
// 从 change 事件中提取文件信息
|
||||
const file = options?.file?.file || options?.file
|
||||
|
||||
if (file && file.name) {
|
||||
// 更新选中的文件
|
||||
selectedFile.value = file
|
||||
}
|
||||
}
|
||||
|
||||
const triggerUpload = async () => {
|
||||
if (!selectedFile.value) {
|
||||
notification.warning({
|
||||
content: '请选择要上传的TXT文件',
|
||||
duration: 2000
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
uploading.value = true
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', selectedFile.value)
|
||||
|
||||
const response = await wechatApi.uploadVerifyFile(formData)
|
||||
|
||||
if (response.success) {
|
||||
uploadResult.value = response
|
||||
notification.success({
|
||||
content: '验证文件上传成功',
|
||||
duration: 3000
|
||||
})
|
||||
// 清空选择的文件
|
||||
selectedFile.value = null
|
||||
if (uploadRef.value) {
|
||||
uploadRef.value.clear()
|
||||
}
|
||||
} else {
|
||||
throw new Error(response.message || '上传失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('上传验证文件失败:', error)
|
||||
notification.error({
|
||||
content: error.message || '上传验证文件失败',
|
||||
duration: 3000
|
||||
})
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getFullUrl = (path: string) => {
|
||||
if (process.client) {
|
||||
return `${window.location.origin}${path}`
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// 页面加载时获取配置和状态
|
||||
onMounted(async () => {
|
||||
await fetchBotConfig()
|
||||
await fetchBotStatus()
|
||||
// 定期刷新状态
|
||||
const interval = setInterval(fetchBotStatus, 30000)
|
||||
onUnmounted(() => clearInterval(interval))
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 微信公众号机器人标签样式 */
|
||||
.tab-content-container {
|
||||
height: calc(100vh - 240px);
|
||||
overflow-y: auto;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
</style>
|
||||
@@ -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 = () => {
|
||||
@@ -96,7 +128,8 @@ export const useCksApi = () => {
|
||||
const updateCks = (id: number, data: any) => useApiFetch(`/cks/${id}`, { method: 'PUT', body: data }).then(parseApiResponse)
|
||||
const deleteCks = (id: number) => useApiFetch(`/cks/${id}`, { method: 'DELETE' }).then(parseApiResponse)
|
||||
const refreshCapacity = (id: number) => useApiFetch(`/cks/${id}/refresh-capacity`, { method: 'POST' }).then(parseApiResponse)
|
||||
return { getCks, getCksByID, createCks, updateCks, deleteCks, refreshCapacity }
|
||||
const deleteRelatedResources = (id: number) => useApiFetch(`/cks/${id}/delete-related-resources`, { method: 'POST' }).then(parseApiResponse)
|
||||
return { getCks, getCksByID, createCks, updateCks, deleteCks, refreshCapacity, deleteRelatedResources }
|
||||
}
|
||||
|
||||
export const useTagApi = () => {
|
||||
@@ -273,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)
|
||||
@@ -288,6 +322,7 @@ export const useTelegramApi = () => {
|
||||
debugBotConnection,
|
||||
reloadBotConfig,
|
||||
testBotMessage,
|
||||
manualPushToChannel,
|
||||
getChannels,
|
||||
createChannel,
|
||||
updateChannel,
|
||||
@@ -354,4 +389,66 @@ export const useSystemLogApi = () => {
|
||||
getSystemLogSummary,
|
||||
clearSystemLogs
|
||||
}
|
||||
}
|
||||
|
||||
// 微信机器人管理API
|
||||
export const useWechatApi = () => {
|
||||
const getBotConfig = () => useApiFetch('/wechat/bot-config').then(parseApiResponse)
|
||||
const updateBotConfig = (data: any) => useApiFetch('/wechat/bot-config', { method: 'PUT', body: data }).then(parseApiResponse)
|
||||
const getBotStatus = () => useApiFetch('/wechat/bot-status').then(parseApiResponse)
|
||||
const uploadVerifyFile = (formData: FormData) => useApiFetch('/wechat/verify-file', { method: 'POST', body: formData }).then(parseApiResponse)
|
||||
return {
|
||||
getBotConfig,
|
||||
updateBotConfig,
|
||||
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
|
||||
}
|
||||
}
|
||||
254
web/composables/useGoogleIndexApi.ts
Normal file
254
web/composables/useGoogleIndexApi.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
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>)
|
||||
|
||||
const submitURLsToIndex = (data: { urls: string[] }) =>
|
||||
useApiFetch('/google-index/urls/submit-to-index', { 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>)
|
||||
|
||||
// 诊断权限API
|
||||
const diagnosePermissions = (data: any) =>
|
||||
useApiFetch('/google-index/diagnose-permissions', { 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,
|
||||
diagnosePermissions,
|
||||
|
||||
// 任务管理
|
||||
getGoogleIndexTasks,
|
||||
getGoogleIndexTask,
|
||||
createGoogleIndexTask,
|
||||
startGoogleIndexTask,
|
||||
stopGoogleIndexTask,
|
||||
deleteGoogleIndexTask,
|
||||
|
||||
// 任务项管理
|
||||
getGoogleIndexTaskItems,
|
||||
|
||||
// URL状态管理
|
||||
getGoogleIndexURLStatus,
|
||||
getGoogleIndexURLStatusByURL,
|
||||
checkGoogleIndexURLStatus,
|
||||
submitGoogleIndexURL,
|
||||
submitURLsToIndex,
|
||||
|
||||
// 批量操作
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ interface VersionResponse {
|
||||
|
||||
export const useVersion = () => {
|
||||
const versionInfo = ref<VersionInfo>({
|
||||
version: '1.3.2',
|
||||
version: '1.3.4',
|
||||
build_time: '',
|
||||
git_commit: 'unknown',
|
||||
git_branch: 'unknown',
|
||||
|
||||
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)
|
||||
|
||||
@@ -64,10 +67,11 @@ const injectRawScript = (rawScriptString: string) => {
|
||||
const container = document.createElement('div');
|
||||
container.innerHTML = rawScriptString.trim();
|
||||
|
||||
// 获取解析后的 script 元素
|
||||
const script = container.querySelector('script');
|
||||
// 获取解析后的所有 script 元素
|
||||
const scripts = container.querySelectorAll('script');
|
||||
|
||||
if (script) {
|
||||
// 遍历并注入所有脚本
|
||||
scripts.forEach((script) => {
|
||||
// 创建新的 script 元素
|
||||
const newScript = document.createElement('script');
|
||||
|
||||
@@ -83,7 +87,7 @@ const injectRawScript = (rawScriptString: string) => {
|
||||
|
||||
// 插入到 DOM
|
||||
document.head.appendChild(newScript);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
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: {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user