39 Commits

Author SHA1 Message Date
Kerwin
9e6b5a58c4 update: details 2025-11-20 17:29:33 +08:00
Kerwin
040e6bc6bf update: log 2025-11-20 16:05:41 +08:00
ctwj
3370f75d5e update check 2025-11-20 08:34:56 +08:00
Kerwin
11a3204c18 update: check 2025-11-19 16:50:47 +08:00
Kerwin
5276112e48 update: copyright-claims 2025-11-19 13:40:13 +08:00
ctwj
3bd0fde82f update: report 2025-11-19 08:32:01 +08:00
ctwj
61e5cbf80d add report 2025-11-19 02:22:04 +08:00
Kerwin
57f7bab443 add share 2025-11-18 23:51:49 +08:00
Kerwin
242e12c29c update: cache 2025-11-18 18:14:25 +08:00
Kerwin
f9a1043431 update: og image 2025-11-18 15:28:08 +08:00
ctwj
5dc431ab24 update: detail ui 2025-11-18 00:49:57 +08:00
Kerwin
c50282bec8 add: details 2025-11-17 18:51:04 +08:00
Kerwin
b99a97c0a9 update: ui 2025-11-14 17:49:09 +08:00
Kerwin
5c1aaf245d update: index page resource change to 50 2025-11-14 17:46:28 +08:00
Kerwin
30448841f6 fix: 优化ui 2025-11-13 15:25:29 +08:00
ctwj
7cddb243bc Merge pull request #21 from ctwj/feat_seo
Feat seo
2025-11-13 01:02:49 +08:00
ctwj
c15132b45a update: seo 2025-11-13 00:34:26 +08:00
ctwj
04b3838cea update: seo 2025-11-12 08:39:25 +08:00
ctwj
70276b68ee update: seo 2025-11-12 08:26:56 +08:00
ctwj
fe8aaff92e update: seo 2025-11-12 00:57:41 +08:00
ctwj
236051f6c4 Merge pull request #20 from ctwj/feat_xunlei_opt
Feat xunlei opt
2025-11-11 23:35:42 +08:00
ctwj
01bc8f0450 update: ui 2025-11-11 23:01:49 +08:00
ctwj
5b7e7b73ad update: xunlei 2025-11-11 01:53:11 +08:00
ctwj
0e88374905 Merge branch 'main' of https://github.com/ctwj/urldb 2025-11-11 01:37:45 +08:00
ctwj
ca175ec59d update: xunlei 2025-11-11 01:36:33 +08:00
Kerwin
ec4e0762d5 update: 迅雷使用账密方式登录 2025-11-10 14:29:28 +08:00
Kerwin
081a3a7222 fix: 修复机器人停止了还能回复消息的问题 2025-11-10 10:51:33 +08:00
ctwj
6b8d2b3cf0 update: 优化推送策略 2025-11-07 23:21:04 +08:00
ctwj
9333f9da94 fix: 修复多个三方统计只生效一个的问题 2025-11-07 22:35:06 +08:00
Kerwin
806a724fb5 fix: 优化日志 2025-11-07 18:52:27 +08:00
Kerwin
487f5c9559 update: 日志优化 2025-11-07 18:50:08 +08:00
Kerwin
18b7f89c49 update: version 1.3.4 2025-11-06 20:02:29 +08:00
Kerwin
db902f3742 chore: bump version to v1.3.4 2025-11-06 19:09:48 +08:00
Kerwin
42baa891f8 fix: 修复应为推送导致的程序崩溃 2025-11-06 19:07:03 +08:00
Kerwin
02d5d00510 update: 优化平台账号管理 2025-11-05 20:52:32 +08:00
ctwj
d95c69142a Update README with WeChat auto-reply link
Added link for WeChat official account auto-reply.
2025-11-04 16:11:38 +08:00
Kerwin
2638ccb1e4 fix: 修复nginx启动失败的问题 2025-11-03 14:11:10 +08:00
ctwj
886d91ab10 Update version history to v1.3.3 2025-11-03 14:00:21 +08:00
Kerwin
ddad95be41 update: version to 1.3.3 2025-11-03 12:29:55 +08:00
76 changed files with 9307 additions and 876 deletions

View File

@@ -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)

View File

@@ -1 +1 @@
1.3.3
1.3.4

View 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
View 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
View 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"`
}

View File

@@ -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"`
}
}

View File

@@ -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,15 @@ 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")
utils.Info("数据库索引创建完成已移除全文搜索索引准备使用Meilisearch新增API访问日志性能索引")
}
// insertDefaultDataIfEmpty 只在数据库为空时插入默认数据

View File

@@ -12,6 +12,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 +37,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 {

View 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
}

View 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
}

View File

@@ -280,39 +280,27 @@ 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: "",
}
// 将键值对转换为map
// 将键值对转换为map,过滤掉敏感配置
for _, config := range configs {
switch config.Key {
case entity.ConfigKeySiteTitle:
@@ -327,32 +315,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 +333,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 +353,21 @@ 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.ConfigKeyAutoProcessReadyResources:
case entity.ConfigKeyAutoProcessInterval:
case entity.ConfigKeyAutoTransferEnabled:
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
}
}

46
db/dto/copyright_claim.go Normal file
View 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"`
}

55
db/dto/report.go Normal file
View 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"`
}

View File

@@ -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 待处理资源响应

View 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"
}

29
db/entity/report.go Normal file
View 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"
}

View 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
}

View File

@@ -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),
}
}

View 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
}

View File

@@ -2,6 +2,8 @@ package repo
import (
"fmt"
"strconv"
"strings"
"time"
"github.com/ctwj/urldb/db/entity"
@@ -44,6 +46,11 @@ 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)
}
// ResourceRepositoryImpl Resource的Repository实现
@@ -240,6 +247,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 +301,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 +357,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 +713,89 @@ 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
}

View File

@@ -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

Binary file not shown.

Binary file not shown.

20
go.mod
View File

@@ -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,7 +11,9 @@ 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
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.30.0
@@ -24,21 +25,19 @@ require (
github.com/bradfitz/gomemcache v0.0.0-20220106215444-fb4bf637b56d // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/fatih/structs v1.1.0 // indirect
github.com/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/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/prometheus/client_golang v1.23.2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/silenceper/wechat/v2 v2.1.10 // indirect
github.com/sirupsen/logrus v1.9.0 // indirect
github.com/spf13/cast v1.4.1 // indirect
github.com/tidwall/gjson v1.14.1 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/image v0.32.0 // indirect
)
require (
@@ -49,7 +48,7 @@ require (
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.27.0 // indirect
github.com/go-playground/validator/v10 v10.27.0
github.com/goccy/go-json v0.10.5 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
@@ -59,7 +58,6 @@ require (
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/matoous/go-nanoid/v2 v2.1.0
github.com/mattn/go-isatty v0.0.20 // indirect
@@ -71,9 +69,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
)

48
go.sum
View File

@@ -1,4 +1,6 @@
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=
@@ -26,9 +28,11 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
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=
@@ -66,6 +70,8 @@ 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/protobuf v1.2.0/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=
@@ -83,6 +89,7 @@ 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/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw=
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
@@ -102,6 +109,8 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
@@ -115,6 +124,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
@@ -134,15 +145,18 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
github.com/onsi/ginkgo/v2 v2.0.0/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c=
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE=
github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs=
github.com/pelletier/go-toml/v2 v2.0.1/go.mod h1:r9LEWfGN8R5k0VXJ+0BkIe7MYkRdwZOjgMj2KwnJFUo=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
@@ -162,14 +176,12 @@ github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
github.com/silenceper/wechat/v2 v2.1.10 h1:jMg0//CZBIuogEvuXgxJQuJ47SsPPAqFrrbOtro2pko=
github.com/silenceper/wechat/v2 v2.1.10/go.mod h1:7Iu3EhQYVtDUJAj+ZVRy8yom75ga7aDWv8RurLkVm0s=
github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0=
github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/spf13/cast v1.4.1 h1:s0hze+J0196ZfEMTs80N7UlFt0BDuQ7Q+JDnHiMWKdA=
github.com/spf13/cast v1.4.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
@@ -182,8 +194,8 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tidwall/gjson v1.14.1 h1:iymTbGkQBhveq21bEvAQ81I0LEBork8BFe1CUZXdyuo=
github.com/tidwall/gjson v1.14.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
@@ -199,7 +211,10 @@ github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2W
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/gopher-lua v0.0.0-20220504180219-658193537a64 h1:5mLPGnFdSsevFRFc9q3yYbBkB6tsm4aCwwQV/j1JQAQ=
github.com/yuin/gopher-lua v0.0.0-20220504180219-658193537a64/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
golang.org/x/arch v0.19.0 h1:LmbDQUodHThXE+htjrnmVD73M//D9GTH6wFZjyDkjyU=
@@ -209,10 +224,10 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/image v0.32.0 h1:6lZQWq75h7L5IWNk0r+SCpUJ6tUVd3v4ZHnbRKLkUDQ=
golang.org/x/image v0.32.0/go.mod h1:/R37rrQmKXtO6tYXAjtDLwQgFLHmhW+V6ayXlxzP2Pc=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
@@ -222,15 +237,13 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190204203706-41f3e6584952/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -250,18 +263,14 @@ golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -280,8 +289,6 @@ google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -290,11 +297,14 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/h2non/gock.v1 v1.1.2 h1:jBbHXgGBK/AoPVfJh5x4r/WxIrElvbLel8TCZkkZJoY=
gopkg.in/h2non/gock.v1 v1.1.2/go.mod h1:n7UGz/ckNChHiK05rDoiC4MYSunEC/lyaUm2WWaDva0=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -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,
})
}

View 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) // 获取资源版权申述列表
}
}

565
handlers/og_image.go Normal file
View 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
}

View File

@@ -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,49 @@ 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) {
// 只记录重要的API访问有错误或处理时间较长的
if errorMessage == "" && processingTime < 1000 && responseStatus < 400 {
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)
}
}()
}

310
handlers/report_handler.go Normal file
View 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) // 获取资源举报列表
}
}

View File

@@ -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 := &currentResources[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平台

View File

@@ -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 {

View File

@@ -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,
})
}

56
main.go
View File

@@ -4,7 +4,9 @@ import (
"fmt"
"log"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/ctwj/urldb/config"
@@ -38,7 +40,7 @@ func main() {
}
// 初始化日志系统
if err := utils.InitLogger(nil); err != nil {
if err := utils.InitLogger(); err != nil {
log.Fatal("初始化日志系统失败:", err)
}
@@ -84,6 +86,8 @@ func main() {
utils.Fatal("数据库连接失败: %v", err)
}
// 日志系统已简化,无需额外初始化
// 创建Repository管理器
repoManager := repo.NewRepositoryManager(db.DB)
@@ -204,6 +208,13 @@ 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)
// API路由
api := r.Group("/api")
{
@@ -226,13 +237,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)
// 分类管理
@@ -265,6 +281,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)
@@ -429,6 +446,24 @@ func main() {
api.GET("/wechat/bot-status", middleware.AuthMiddleware(), middleware.AdminMiddleware(), wechatHandler.GetBotStatus)
api.POST("/wechat/callback", wechatHandler.HandleWechatMessage)
api.GET("/wechat/callback", wechatHandler.HandleWechatMessage)
// OG图片生成路由
api.GET("/og-image", ogImageHandler.GenerateOGImage)
// 举报和版权申述路由
api.POST("/reports", reportHandler.CreateReport)
api.GET("/reports/:id", reportHandler.GetReport)
api.GET("/reports", middleware.AuthMiddleware(), middleware.AdminMiddleware(), reportHandler.ListReports)
api.PUT("/reports/:id", middleware.AuthMiddleware(), middleware.AdminMiddleware(), reportHandler.UpdateReport)
api.DELETE("/reports/:id", middleware.AuthMiddleware(), middleware.AdminMiddleware(), reportHandler.DeleteReport)
api.GET("/reports/resource/:resource_key", reportHandler.GetReportByResource)
api.POST("/copyright-claims", copyrightClaimHandler.CreateCopyrightClaim)
api.GET("/copyright-claims/:id", copyrightClaimHandler.GetCopyrightClaim)
api.GET("/copyright-claims", middleware.AuthMiddleware(), middleware.AdminMiddleware(), copyrightClaimHandler.ListCopyrightClaims)
api.PUT("/copyright-claims/:id", middleware.AuthMiddleware(), middleware.AdminMiddleware(), copyrightClaimHandler.UpdateCopyrightClaim)
api.DELETE("/copyright-claims/:id", middleware.AuthMiddleware(), middleware.AdminMiddleware(), copyrightClaimHandler.DeleteCopyrightClaim)
api.GET("/copyright-claims/resource/:resource_key", copyrightClaimHandler.GetCopyrightClaimByResource)
}
// 设置监控系统
@@ -462,6 +497,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("服务器已优雅关闭")
}

View File

@@ -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)

View File

@@ -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地址

View File

@@ -77,9 +77,6 @@ server {
set $uploads_path /uploads$uri;
if (-f $uploads_path) {
proxy_pass http://backend;
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 1h;

View 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
}

View File

@@ -52,6 +52,7 @@ type TelegramBotServiceImpl struct {
config *TelegramBotConfig
pushHistory map[int64][]uint // 每个频道的推送历史记录最多100条
mu sync.RWMutex // 用于保护pushHistory的读写锁
stopChan chan struct{} // 用于停止消息循环的channel
}
type TelegramBotConfig struct {
@@ -84,6 +85,7 @@ func NewTelegramBotService(
cronScheduler: cron.New(),
config: &TelegramBotConfig{},
pushHistory: make(map[int64][]uint),
stopChan: make(chan struct{}),
}
}
@@ -111,67 +113,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 +192,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 +271,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 +296,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 +536,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 +549,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 +1019,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 +1072,16 @@ func (s *TelegramBotServiceImpl) findResourcesForChannel(channel entity.Telegram
func (s *TelegramBotServiceImpl) findLatestResources(channel entity.TelegramChannel, excludeResourceIDs []uint) []interface{} {
params := s.buildFilterParams(channel)
// 在数据库查询中排除已推送的资源
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 +1090,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 +1106,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 +1118,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 +1131,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 +1141,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 +1167,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 +1243,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 +1313,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"
@@ -1752,11 +1828,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 +1916,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

View File

@@ -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{}{

204
utils/cache.go Normal file
View 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)
}
}

View File

@@ -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)
}

View File

@@ -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
View 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
View File

@@ -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']

View File

@@ -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>

View 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">
支持 PDFJPGPNG 格式最多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>

View 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>

View 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>

View 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>

View File

@@ -32,7 +32,7 @@
<n-input v-model:value="configForm.welcome_message" type="textarea" :rows="3" placeholder="新用户关注时发送的欢迎消息" />
</n-form-item>
<n-form-item label="搜索结果限制">
<n-input-number v-model:value="configForm.search_limit" :min="1" :max="10" placeholder="搜索结果返回数量" />
<n-input-number v-model:value="configForm.search_limit" :min="1" :max="100" placeholder="搜索结果返回数量" />
</n-form-item>
</n-form>
</n-card>

View File

@@ -47,7 +47,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 +61,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 +127,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 = () => {
@@ -368,4 +400,29 @@ export const useWechatApi = () => {
getBotStatus,
uploadVerifyFile
}
}
// 统一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()
}
}

View 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
}
}

View File

@@ -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
}
}

View File

@@ -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
View 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'
}
}
]
};

View File

@@ -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

View File

@@ -19,7 +19,9 @@
<n-notification-provider>
<n-dialog-provider>
<NuxtPage />
<n-message-provider>
<NuxtPage />
</n-message-provider>
</n-dialog-provider>
</n-notification-provider>
@@ -64,10 +66,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 +86,7 @@ const injectRawScript = (rawScriptString: string) => {
// 插入到 DOM
document.head.appendChild(newScript);
}
});
}
};

31
web/middleware/admin.ts Normal file
View 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 - 用户已认证且为管理员,继续访问')
})

View File

@@ -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: {

View File

@@ -1,6 +1,6 @@
{
"name": "res-db-web",
"version": "1.3.2",
"version": "1.3.4",
"private": true,
"type": "module",
"scripts": {
@@ -29,6 +29,7 @@
"@nuxtjs/tailwindcss": "^6.8.0",
"@pinia/nuxt": "^0.5.0",
"@vicons/ionicons5": "^0.12.0",
"@vueuse/core": "^14.0.0",
"chart.js": "^4.5.0",
"naive-ui": "^2.42.0",
"pinia": "^2.1.0",

View File

@@ -133,6 +133,9 @@
<span class="text-xs text-gray-500 dark:text-gray-400">
剩余: {{ formatFileSize(Math.max(0, item.left_space)) }}
</span>
<span class="text-xs text-gray-500 dark:text-gray-400">
已转存: {{ item.transferred_count || 0 }}
</span>
</div>
<!-- 备注 -->
@@ -146,25 +149,20 @@
<!-- 右侧操作按钮 -->
<div class="flex items-center space-x-2 ml-4">
<n-button size="small" :type="item.is_valid ? 'warning' : 'success'" @click="toggleStatus(item)"
:title="item.is_valid ? '禁用账号' : '启用账号'">
<template #icon>
<i :class="item.is_valid ? 'fas fa-ban' : 'fas fa-check'"></i>
</template>
:title="item.is_valid ? '禁用账号' : '启用账号'" text>
{{ item.is_valid ? '禁用' : '启用' }}
</n-button>
<n-button size="small" type="info" @click="refreshCapacity(item.id)" title="刷新容量">
<template #icon>
<i class="fas fa-sync-alt"></i>
</template>
<n-button size="small" type="info" @click="refreshCapacity(item.id)" title="刷新容量" text>
刷新容量
</n-button>
<n-button size="small" type="primary" @click="editCks(item)" title="编辑账号">
<template #icon>
<i class="fas fa-edit"></i>
</template>
<n-button size="small" type="primary" @click="editCks(item)" title="编辑账号" text>
编辑
</n-button>
<n-button size="small" type="error" @click="deleteCks(item.id)" title="删除账号">
<template #icon>
<i class="fas fa-trash"></i>
</template>
<n-button size="small" type="error" @click="deleteCks(item.id)" title="删除账号" text>
删除
</n-button>
<n-button size="small" type="warning" @click="deleteRelatedResources(item.id)" title="删除关联资源" text>
删除关联
</n-button>
</div>
</div>
@@ -220,10 +218,20 @@
</div>
<div v-if="isXunlei">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
refresh_token <span class="text-red-500">*</span>
</label>
<n-input v-model:value="form.ck" type="textarea" placeholder="请输入" :rows="4" required />
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
手机号 <span class="text-red-500">*</span>
</label>
<n-input v-model:value="xunleiForm.username" placeholder="请输入手机号不需要+86前缀" required />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
密码 <span class="text-red-500">*</span>
</label>
<n-input v-model:value="xunleiForm.password" type="password" placeholder="请输入密码" show-password-on="click" required />
</div>
</div>
</div>
<div>
@@ -241,9 +249,15 @@
<template #footer>
<div class="flex justify-end space-x-3">
<n-button type="tertiary" @click="closeModal">
<template #icon>
<i class="fas fa-times"></i>
</template>
取消
</n-button>
<n-button type="primary" :loading="submitting" @click="handleSubmit">
<template #icon>
<i class="fas fa-check"></i>
</template>
{{ showEditModal ? '更新' : '创建' }}
</n-button>
</div>
@@ -276,6 +290,12 @@ const form = ref({
remark: ''
})
// 迅雷专用表单数据
const xunleiForm = ref({
username: '',
password: ''
})
const panEnables = ref(['quark', 'xunlei'])
// const xunleiEnable = useCookie('xunleiEnable', { default: () => false })
// if (xunleiEnable.value && xunleiEnable.value === 'true') {
@@ -428,6 +448,36 @@ const deleteCks = async (id) => {
})
}
// 删除关联资源
const deleteRelatedResources = async (id) => {
dialog.warning({
title: '警告',
content: '确定要删除与此账号关联的所有资源吗?这将清空这些资源的转存信息,变为未转存状态。',
positiveText: '确定',
negativeText: '取消',
draggable: true,
onPositiveClick: async () => {
try {
// 调用API删除关联资源
await cksApi.deleteRelatedResources(id)
await fetchCks()
notification.success({
title: '成功',
content: '关联资源已删除!',
duration: 3000
})
} catch (error) {
console.error('删除关联资源失败:', error)
notification.error({
title: '失败',
content: '删除关联资源失败: ' + (error.message || '未知错误'),
duration: 3000
})
}
}
})
}
// 刷新容量
const refreshCapacity = async (id) => {
dialog.warning({
@@ -499,6 +549,25 @@ const editCks = (cks) => {
is_valid: cks.is_valid,
remark: cks.remark || ''
}
// 如果是迅雷账号解析ck字段来设置表单
if (cks.pan?.name === 'xunlei') {
try {
// 解析JSON格式
const parsed = JSON.parse(cks.ck)
xunleiForm.value = {
username: parsed.username,
password: parsed.password
}
} catch (e) {
// 解析失败,清空表单
xunleiForm.value = {
username: '',
password: ''
}
}
}
showEditModal.value = true
}
@@ -513,10 +582,32 @@ const closeModal = () => {
is_valid: true,
remark: ''
}
// 重置迅雷表单
xunleiForm.value = {
username: '',
password: ''
}
}
// 提交表单
const handleSubmit = async () => {
// 如果是迅雷账号需要构造账号密码的JSON格式
if (isXunlei.value) {
if (!xunleiForm.value.username || !xunleiForm.value.password) {
notification.error({
title: '失败',
content: '请填写完整的账号和密码',
duration: 3000
})
return
}
form.value.ck = JSON.stringify({
username: xunleiForm.value.username,
password: xunleiForm.value.password,
refresh_token: '' // 初始为空,登录后会填充
})
}
if (showEditModal.value) {
await updateCks()
} else {

View File

@@ -31,16 +31,6 @@
<TelegramBotTab />
</div>
</n-tab-pane>
<n-tab-pane name="wechat_open" tab="微信开放平台">
<div class="tab-content-container">
<div class="text-center py-12">
<i class="fas fa-lock text-4xl text-gray-400 mb-4"></i>
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">功能暂未开放</h3>
<p class="text-gray-500 dark:text-gray-400">微信开放平台机器人功能正在开发中敬请期待</p>
</div>
</div>
</n-tab-pane>
</n-tabs>
</div>

View File

@@ -0,0 +1,825 @@
<template>
<AdminPageLayout>
<template #page-header>
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-balance-scale text-blue-500 mr-2"></i>
版权申述管理
</h1>
<p class="text-gray-600 dark:text-gray-400">管理用户提交的版权申述信息</p>
</div>
</template>
<!-- 过滤栏 - 搜索和操作 -->
<template #filter-bar>
<div class="flex justify-between items-center">
<div class="flex gap-2">
<!-- 空白区域用于按钮 -->
</div>
<div class="flex gap-2">
<div class="relative">
<n-input
v-model:value="filters.resourceKey"
@input="debounceSearch"
type="text"
placeholder="搜索资源Key..."
clearable
>
<template #prefix>
<i class="fas fa-search text-gray-400 text-sm"></i>
</template>
</n-input>
</div>
<n-select
v-model:value="filters.status"
:options="[
{ label: '全部状态', value: '' },
{ label: '待处理', value: 'pending' },
{ label: '已批准', value: 'approved' },
{ label: '已拒绝', value: 'rejected' }
]"
placeholder="状态"
clearable
@update:value="fetchClaims"
style="width: 150px"
/>
<n-button @click="resetFilters" type="tertiary">
<template #icon>
<i class="fas fa-redo"></i>
</template>
重置
</n-button>
<n-button @click="fetchClaims" type="tertiary">
<template #icon>
<i class="fas fa-refresh"></i>
</template>
刷新
</n-button>
</div>
</div>
</template>
<!-- 内容区 - 版权申述数据 -->
<template #content>
<!-- 加载状态 -->
<div v-if="loading" class="flex h-full items-center justify-center py-8">
<n-spin size="large" />
</div>
<!-- 空状态 -->
<div v-else-if="claims.length === 0" class="text-center py-8">
<svg class="w-16 h-16 text-gray-300 dark:text-gray-600 mb-4 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 48 48">
<circle cx="24" cy="24" r="20" stroke-width="3" stroke-dasharray="6 6" />
<path d="M16 24h16M24 16v16" stroke-width="3" stroke-linecap="round" />
</svg>
<div class="text-lg font-semibold text-gray-400 dark:text-gray-500 mb-2">暂无版权申述记录</div>
<div class="text-sm text-gray-400 dark:text-gray-600 mb-4">目前没有用户提交的版权申述信息</div>
</div>
<!-- 数据表格 - 自适应高度 -->
<div v-else class="flex flex-col h-full overflow-auto">
<n-data-table
:columns="columns"
:data="claims"
:pagination="false"
:bordered="false"
:single-line="false"
:loading="loading"
:scroll-x="1020"
class="h-full"
/>
</div>
</template>
<!-- 内容区footer - 分页组件 -->
<template #content-footer>
<div class="p-4">
<div class="flex justify-center">
<n-pagination
v-model:page="pagination.page"
v-model:page-size="pagination.pageSize"
:item-count="pagination.total"
:page-sizes="[50, 100, 200, 500]"
show-size-picker
@update:page="fetchClaims"
@update:page-size="handlePageSizeChange"
/>
</div>
</div>
</template>
</AdminPageLayout>
<!-- 查看申述详情模态框 -->
<n-modal v-model:show="showDetailModal" :mask-closable="false" preset="card" :style="{ maxWidth: '600px', width: '90%' }" title="版权申述详情">
<div v-if="selectedClaim" class="space-y-4">
<div>
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">申述ID</h3>
<p class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ selectedClaim.id }}</p>
</div>
<div>
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">资源Key</h3>
<p class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ selectedClaim.resource_key }}</p>
</div>
<div>
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">申述人身份</h3>
<p class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ getIdentityLabel(selectedClaim.identity) }}</p>
</div>
<div>
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">证明类型</h3>
<p class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ getProofTypeLabel(selectedClaim.proof_type) }}</p>
</div>
<div>
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">申述理由</h3>
<p class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ selectedClaim.reason }}</p>
</div>
<div>
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">联系方式</h3>
<p class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ selectedClaim.contact_info }}</p>
</div>
<div>
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">申述人姓名</h3>
<p class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ selectedClaim.claimant_name }}</p>
</div>
<div v-if="selectedClaim.proof_files">
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">证明文件</h3>
<div class="mt-1 space-y-2">
<div
v-for="(file, index) in getProofFiles(selectedClaim.proof_files)"
:key="index"
class="flex items-center justify-between p-2 bg-gray-50 dark:bg-gray-800 rounded hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors cursor-pointer"
@click="downloadFile(file)"
>
<div class="flex items-center space-x-2">
<i class="fas fa-file-download text-blue-500"></i>
<span class="text-sm text-gray-900 dark:text-gray-100">{{ getFileName(file) }}</span>
</div>
<i class="fas fa-download text-gray-400 hover:text-blue-500 transition-colors"></i>
</div>
</div>
</div>
<div>
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">提交时间</h3>
<p class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ formatDateTime(selectedClaim.created_at) }}</p>
</div>
<div>
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">IP地址</h3>
<p class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ selectedClaim.ip_address || '未知' }}</p>
</div>
<div v-if="selectedClaim.note">
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">处理备注</h3>
<p class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ selectedClaim.note }}</p>
</div>
</div>
</n-modal>
</template>
<script setup lang="ts">
// 设置页面标题和元信息
useHead({
title: '版权申述管理 - 管理后台',
meta: [
{ name: 'description', content: '管理用户提交的版权申述信息' }
]
})
// 设置页面布局和认证保护
definePageMeta({
layout: 'admin',
middleware: ['auth', 'admin']
})
import { h } from 'vue'
const message = useMessage()
const notification = useNotification()
const dialog = useDialog()
const { resourceApi } = useApi()
const loading = ref(false)
const claims = ref<any[]>([])
const showDetailModal = ref(false)
const selectedClaim = ref<any>(null)
// 分页和筛选状态
const pagination = ref({
page: 1,
pageSize: 50,
total: 0
})
const filters = ref({
status: '',
resourceKey: ''
})
// 表格列定义
const columns = [
{
title: 'ID',
key: 'id',
width: 60,
render: (row: any) => {
return h('div', { class: 'space-y-1' }, [
h('div', { class: 'font-medium text-sm' }, row.id),
h('div', {
class: 'text-xs text-gray-400',
title: `IP: ${row.ip_address || '未知'}`
}, row.ip_address ? `IP: ${row.ip_address.slice(0, 8)}...` : 'IP:未知')
])
}
},
{
title: '资源',
key: 'resource_key',
width: 200,
render: (row: any) => {
const resourceInfo = getResourceInfo(row);
return h('div', { class: 'space-y-1' }, [
// 第一行:标题(单行,省略号)
h('div', {
class: 'font-medium text-sm truncate max-w-[200px]',
style: { maxWidth: '200px' },
title: resourceInfo.title // 鼠标hover显示完整标题
}, resourceInfo.title),
// 第二行:详情(单行,省略号)
h('div', {
class: 'text-xs text-gray-500 dark:text-gray-400 truncate max-w-[200px]',
style: { maxWidth: '200px' },
title: resourceInfo.description // 鼠标hover显示完整描述
}, resourceInfo.description),
// 第三行:分类图片和链接数
h('div', { class: 'flex items-center gap-1' }, [
h('i', {
class: `fas fa-${getCategoryIcon(resourceInfo.category)} text-blue-500 text-xs`,
// 鼠标hover显示第一个资源的链接地址
title: resourceInfo.resources.length > 0 ? `链接地址: ${resourceInfo.resources[0].save_url || resourceInfo.resources[0].url}` : `资源链接地址: ${row.resource_key}`
}),
h('span', { class: 'text-xs text-gray-400' }, `链接数: ${resourceInfo.resources.length}`)
])
])
}
},
{
title: '申述人信息',
key: 'claimant_info',
width: 180,
render: (row: any) => {
return h('div', { class: 'space-y-1' }, [
// 第一行:姓名和身份
h('div', { class: 'font-medium text-sm' }, [
h('i', { class: 'fas fa-user text-green-500 mr-1 text-xs' }),
row.claimant_name || '未知'
]),
h('div', {
class: 'text-xs text-blue-600 dark:text-blue-400 truncate max-w-[180px]',
title: getIdentityLabel(row.identity)
}, getIdentityLabel(row.identity)),
// 第二行:联系方式
h('div', {
class: 'text-xs text-gray-500 dark:text-gray-400 truncate max-w-[180px]',
title: row.contact_info
}, [
h('i', { class: 'fas fa-phone text-purple-500 mr-1' }),
row.contact_info || '未提供'
]),
// 第三行:证明类型
h('div', {
class: 'text-xs text-orange-600 dark:text-orange-400 truncate max-w-[180px]',
title: getProofTypeLabel(row.proof_type)
}, [
h('i', { class: 'fas fa-certificate text-orange-500 mr-1' }),
getProofTypeLabel(row.proof_type)
])
])
}
},
{
title: '申述详情',
key: 'claim_details',
width: 280,
render: (row: any) => {
return h('div', { class: 'space-y-1' }, [
// 第一行:申述理由和提交时间
h('div', { class: 'space-y-1' }, [
h('div', { class: 'text-xs text-gray-500 dark:text-gray-400' }, '申述理由:'),
h('div', {
class: 'text-sm text-gray-700 dark:text-gray-300 line-clamp-2 max-h-10',
title: row.reason
}, row.reason || '无'),
h('div', { class: 'text-xs text-gray-400' }, [
h('i', { class: 'fas fa-clock mr-1' }),
`提交时间: ${formatDateTime(row.created_at)}`
])
]),
// 第二行:证明文件
row.proof_files ?
h('div', { class: 'space-y-1' }, [
h('div', { class: 'text-xs text-gray-500 dark:text-gray-400' }, '证明文件:'),
...getProofFiles(row.proof_files).slice(0, 2).map((file, index) =>
h('div', {
class: 'text-xs text-blue-600 dark:text-blue-400 truncate max-w-[280px] cursor-pointer hover:text-blue-500 hover:underline',
title: `点击下载: ${file}`,
onClick: () => downloadFile(file)
}, [
h('i', { class: 'fas fa-download text-blue-500 mr-1' }),
getFileName(file)
])
),
getProofFiles(row.proof_files).length > 2 ?
h('div', { class: 'text-xs text-gray-400' }, `还有 ${getProofFiles(row.proof_files).length - 2} 个文件...`) : null
]) :
h('div', { class: 'text-xs text-gray-400' }, '无证明文件'),
// 第三行:处理备注(如果有)
row.note ?
h('div', { class: 'space-y-1' }, [
h('div', { class: 'text-xs text-gray-500 dark:text-gray-400' }, '处理备注:'),
h('div', {
class: 'text-xs text-yellow-600 dark:text-yellow-400 truncate max-w-[280px]',
title: row.note
}, [
h('i', { class: 'fas fa-sticky-note text-yellow-500 mr-1' }),
row.note.length > 30 ? `${row.note.slice(0, 30)}...` : row.note
])
]) : null
].filter(Boolean))
}
},
{
title: '状态',
key: 'status',
width: 100,
render: (row: any) => {
const type = getStatusType(row.status)
return h('div', { class: 'space-y-1' }, [
h('n-tag', {
type: type,
size: 'small',
bordered: false
}, { default: () => getStatusLabel(row.status) }),
// 显示处理时间(如果已处理)
(row.status !== 'pending' && row.updated_at) ?
h('div', {
class: 'text-xs text-gray-400',
title: `处理时间: ${formatDateTime(row.updated_at)}`
}, `更新: ${new Date(row.updated_at).toLocaleDateString()}`) : null
].filter(Boolean))
}
},
{
title: '操作',
key: 'actions',
width: 160,
render: (row: any) => {
const buttons = [
h('button', {
class: 'px-2 py-1 text-xs bg-blue-100 hover:bg-blue-200 text-blue-700 dark:bg-blue-900/20 dark:text-blue-400 rounded transition-colors mb-1 w-full',
onClick: () => viewClaim(row)
}, [
h('i', { class: 'fas fa-eye mr-1 text-xs' }),
'查看详情'
])
]
if (row.status === 'pending') {
buttons.push(
h('button', {
class: 'px-2 py-1 text-xs bg-green-100 hover:bg-green-200 text-green-700 dark:bg-green-900/20 dark:text-green-400 rounded transition-colors mb-1 w-full',
onClick: () => updateClaimStatus(row, 'approved')
}, [
h('i', { class: 'fas fa-check mr-1 text-xs' }),
'批准'
]),
h('button', {
class: 'px-2 py-1 text-xs bg-red-100 hover:bg-red-200 text-red-700 dark:bg-red-900/20 dark:text-red-400 rounded transition-colors w-full',
onClick: () => updateClaimStatus(row, 'rejected')
}, [
h('i', { class: 'fas fa-times mr-1 text-xs' }),
'拒绝'
])
)
}
return h('div', { class: 'flex flex-col gap-1' }, buttons)
}
}
]
// 搜索防抖
let searchTimeout: NodeJS.Timeout | null = null
const debounceSearch = () => {
if (searchTimeout) {
clearTimeout(searchTimeout)
}
searchTimeout = setTimeout(() => {
pagination.value.page = 1
fetchClaims()
}, 300)
}
// 获取版权申述列表
const fetchClaims = async () => {
loading.value = true
try {
const params: any = {
page: pagination.value.page,
page_size: pagination.value.pageSize
}
if (filters.value.status) params.status = filters.value.status
if (filters.value.resourceKey) params.resource_key = filters.value.resourceKey
const response = await resourceApi.getCopyrightClaims(params)
console.log(response)
// 检查响应格式并处理
if (response && response.data && response.data.list !== undefined) {
// 如果后端返回了分页格式,使用正确的字段
claims.value = response.data.list || []
pagination.value.total = response.data.total || 0
} else {
// 如果是其他格式,尝试直接使用响应
claims.value = response || []
pagination.value.total = response.length || 0
}
} catch (error) {
console.error('获取版权申述列表失败:', error)
// 显示错误提示
if (process.client) {
notification.error({
content: '获取版权申述列表失败',
duration: 3000
})
}
} finally {
loading.value = false
}
}
// 重置筛选条件
const resetFilters = () => {
filters.value = {
status: '',
resourceKey: ''
}
pagination.value.page = 1
fetchClaims()
}
// 处理页面大小变化
const handlePageSizeChange = (pageSize: number) => {
pagination.value.pageSize = pageSize
pagination.value.page = 1
fetchClaims()
}
// 查看申述详情
const viewClaim = (claim: any) => {
selectedClaim.value = claim
showDetailModal.value = true
}
// 更新申述状态
const updateClaimStatus = async (claim: any, status: string) => {
try {
// 获取处理备注(如果需要)
let note = ''
if (status === 'rejected') {
note = await getRejectionNote()
if (note === null) return // 用户取消操作
}
const response = await resourceApi.updateCopyrightClaim(claim.id, {
status,
note
})
// 更新本地数据
const index = claims.value.findIndex(c => c.id === claim.id)
if (index !== -1) {
claims.value[index] = response
}
// 更新详情模态框中的数据
if (selectedClaim.value && selectedClaim.value.id === claim.id) {
selectedClaim.value = response
}
if (process.client) {
notification.success({
content: '状态更新成功',
duration: 3000
})
}
} catch (error) {
console.error('更新版权申述状态失败:', error)
if (process.client) {
notification.error({
content: '状态更新失败',
duration: 3000
})
}
}
}
// 获取拒绝原因输入
const getRejectionNote = (): Promise<string | null> => {
return new Promise((resolve) => {
// 使用naive-ui的dialog API
const { dialog } = useDialog()
let inputValue = ''
dialog.warning({
title: '输入拒绝原因',
content: () => h(nInput, {
value: inputValue,
onUpdateValue: (value) => inputValue = value,
placeholder: '请输入拒绝的原因...',
type: 'textarea',
rows: 4
}),
positiveText: '确定',
negativeText: '取消',
onPositiveClick: () => {
if (!inputValue.trim()) {
const { message } = useNotification()
message.warning('请输入拒绝原因')
return false // 不关闭对话框
}
resolve(inputValue)
},
onNegativeClick: () => {
resolve(null)
}
})
})
}
// 状态类型和标签
const getStatusType = (status: string) => {
switch (status) {
case 'pending': return 'warning'
case 'approved': return 'success'
case 'rejected': return 'error'
default: return 'default'
}
}
const getStatusLabel = (status: string) => {
switch (status) {
case 'pending': return '待处理'
case 'approved': return '已批准'
case 'rejected': return '已拒绝'
default: return status
}
}
// 申述人身份标签
const getIdentityLabel = (identity: string) => {
const identityMap: Record<string, string> = {
'copyright_owner': '版权所有者',
'authorized_agent': '授权代表',
'law_firm': '律师事务所',
'other': '其他'
}
return identityMap[identity] || identity
}
// 证明类型标签
const getProofTypeLabel = (proofType: string) => {
const proofTypeMap: Record<string, string> = {
'copyright_certificate': '版权登记证书',
'first_publish_proof': '作品首发证明',
'authorization_letter': '授权委托书',
'identity_document': '身份证明文件',
'other_proof': '其他证明材料'
}
return proofTypeMap[proofType] || proofType
}
// 格式化日期时间
const formatDateTime = (dateString: string) => {
if (!dateString) return '-'
const date = new Date(dateString)
return date.toLocaleString('zh-CN')
}
// 获取分类图标
const getCategoryIcon = (category: string) => {
if (!category) return 'folder';
// 根据分类名称返回对应的图标
const categoryMap: Record<string, string> = {
'文档': 'file-alt',
'文档资料': 'file-alt',
'压缩包': 'file-archive',
'图片': 'images',
'视频': 'film',
'音乐': 'music',
'电子书': 'book',
'软件': 'cogs',
'应用': 'mobile-alt',
'游戏': 'gamepad',
'资料': 'folder',
'其他': 'file',
'folder': 'folder',
'file': 'file'
};
return categoryMap[category] || 'folder';
}
// 获取资源信息显示
const getResourceInfo = (row: any) => {
// 从后端返回的资源列表中获取信息
const resources = row.resources || [];
if (resources.length > 0) {
// 如果有多个资源,可以选择第一个或合并信息
const resource = resources[0];
return {
title: resource.title || `资源: ${row.resource_key}`,
description: resource.description || `资源详情: ${row.resource_key}`,
category: resource.category || 'folder',
resources: resources // 返回所有资源用于显示链接数量等
}
} else {
// 如果没有关联资源,使用默认值
return {
title: `资源: ${row.resource_key}`,
description: `资源详情: ${row.resource_key}`,
category: 'folder',
resources: []
}
}
}
// 解析证明文件字符串
const getProofFiles = (proofFiles: string) => {
if (!proofFiles) return []
console.log('原始证明文件数据:', proofFiles)
try {
// 尝试解析为JSON格式
const parsed = JSON.parse(proofFiles)
console.log('JSON解析结果:', parsed)
if (Array.isArray(parsed)) {
// 处理对象数组格式:[{id: "xxx", name: "文件名.pdf", status: "pending"}]
const fileObjects = parsed.filter(item => item && typeof item === 'object')
if (fileObjects.length > 0) {
// 返回原始对象,包含完整信息
console.log('解析出文件对象数组:', fileObjects)
return fileObjects
}
// 如果不是对象数组,尝试作为字符串数组处理
const files = parsed.filter(file => file && typeof file === 'string' && file.trim()).map(file => file.trim())
if (files.length > 0) {
console.log('解析出的文件字符串数组:', files)
return files
}
} else if (typeof parsed === 'object' && parsed.url) {
console.log('解析出的单个文件:', parsed.url)
return [parsed.url]
} else if (typeof parsed === 'object' && parsed.files) {
// 处理 {files: ["url1", "url2"]} 格式
if (Array.isArray(parsed.files)) {
const files = parsed.files.filter(file => file && file.trim()).map(file => file.trim())
console.log('解析出的files数组:', files)
return files
}
}
} catch (e) {
console.log('JSON解析失败尝试分隔符解析:', e.message)
// 如果不是JSON格式按分隔符解析
// 假设文件URL以逗号、分号或换行符分隔
const files = proofFiles.split(/[,;\n\r]+/).filter(file => file.trim()).map(file => file.trim())
console.log('分隔符解析结果:', files)
return files
}
console.log('未解析出任何文件')
return []
}
// 获取文件名
const getFileName = (fileInfo: any) => {
if (!fileInfo) return '未知文件'
// 如果是对象优先使用name字段
if (typeof fileInfo === 'object') {
return fileInfo.name || fileInfo.id || '未知文件'
}
// 如果是字符串从URL中提取文件名
const fileName = fileInfo.split('/').pop() || fileInfo.split('\\').pop() || fileInfo
// 如果URL太长截断显示
return fileName.length > 50 ? fileName.substring(0, 47) + '...' : fileName
}
// 下载文件
const downloadFile = async (fileInfo: any) => {
console.log('尝试下载文件:', fileInfo)
if (!fileInfo) {
console.error('文件信息为空')
if (process.client) {
notification.warning({
content: '文件信息无效',
duration: 3000
})
}
return
}
try {
let downloadUrl = ''
let fileName = ''
// 处理文件对象格式:{id: "xxx", name: "文件名.pdf", status: "pending"}
if (typeof fileInfo === 'object' && fileInfo.id) {
fileName = fileInfo.name || fileInfo.id
// 构建下载API URL假设有 /api/files/{id} 端点
downloadUrl = `/api/files/${fileInfo.id}`
console.log('文件对象下载:', { id: fileInfo.id, name: fileName, url: downloadUrl })
}
// 处理字符串格式直接是URL
else if (typeof fileInfo === 'string') {
downloadUrl = fileInfo
fileName = getFileName(fileInfo)
// 检查是否是文件名不包含http://或https://或/开头)
if (!fileInfo.match(/^https?:\/\//) && !fileInfo.startsWith('/')) {
console.log('检测到纯文件名需要通过API下载:', fileName)
if (process.client) {
notification.info({
content: `文件 "${fileName}" 需要通过API下载功能开发中...`,
duration: 3000
})
}
return
}
// 处理相对路径URL
if (fileInfo.startsWith('/uploads/')) {
downloadUrl = `${window.location.origin}${fileInfo}`
console.log('处理本地文件URL:', downloadUrl)
}
}
if (!downloadUrl) {
console.error('无法确定下载URL')
if (process.client) {
notification.warning({
content: '无法确定下载地址',
duration: 3000
})
}
return
}
// 创建下载链接
const link = document.createElement('a')
link.href = downloadUrl
link.target = '_blank' // 在新标签页打开,避免跨域问题
// 设置下载文件名
link.download = fileName.includes('.') ? fileName : fileName + '.file'
console.log('下载参数:', {
originalInfo: fileInfo,
downloadUrl: downloadUrl,
fileName: fileName
})
// 添加到页面并触发点击
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
if (process.client) {
notification.success({
content: `开始下载: ${fileName}`,
duration: 2000
})
}
} catch (error) {
console.error('下载文件失败:', error)
if (process.client) {
notification.error({
content: `下载失败: ${error.message}`,
duration: 3000
})
}
}
}
// 初始化数据
onMounted(() => {
fetchClaims()
})
</script>

559
web/pages/admin/reports.vue Normal file
View File

@@ -0,0 +1,559 @@
<template>
<AdminPageLayout>
<template #page-header>
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-flag text-red-500 mr-2"></i>
举报管理
</h1>
<p class="text-gray-600 dark:text-gray-400">管理用户提交的资源举报信息</p>
</div>
</template>
<!-- 过滤栏 - 搜索和操作 -->
<template #filter-bar>
<div class="flex justify-between items-center">
<div class="flex gap-2">
<!-- 空白区域用于按钮 -->
</div>
<div class="flex gap-2">
<div class="relative">
<n-input
v-model:value="filters.resourceKey"
@input="debounceSearch"
type="text"
placeholder="搜索资源Key..."
clearable
>
<template #prefix>
<i class="fas fa-search text-gray-400 text-sm"></i>
</template>
</n-input>
</div>
<n-select
v-model:value="filters.status"
:options="[
{ label: '全部状态', value: '' },
{ label: '待处理', value: 'pending' },
{ label: '已批准', value: 'approved' },
{ label: '已拒绝', value: 'rejected' }
]"
placeholder="状态"
clearable
@update:value="fetchReports"
style="width: 150px"
/>
<n-button @click="resetFilters" type="tertiary">
<template #icon>
<i class="fas fa-redo"></i>
</template>
重置
</n-button>
<n-button @click="fetchReports" type="tertiary">
<template #icon>
<i class="fas fa-refresh"></i>
</template>
刷新
</n-button>
</div>
</div>
</template>
<!-- 内容区 - 举报数据 -->
<template #content>
<!-- 加载状态 -->
<div v-if="loading" class="flex h-full items-center justify-center py-8">
<n-spin size="large" />
</div>
<!-- 空状态 -->
<div v-else-if="reports.length === 0" class="text-center py-8">
<svg class="w-16 h-16 text-gray-300 dark:text-gray-600 mb-4 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 48 48">
<circle cx="24" cy="24" r="20" stroke-width="3" stroke-dasharray="6 6" />
<path d="M16 24h16M24 16v16" stroke-width="3" stroke-linecap="round" />
</svg>
<div class="text-lg font-semibold text-gray-400 dark:text-gray-500 mb-2">暂无举报记录</div>
<div class="text-sm text-gray-400 dark:text-gray-600 mb-4">目前没有用户提交的举报信息</div>
</div>
<!-- 数据表格 - 自适应高度 -->
<div v-else class="flex flex-col h-full overflow-auto">
<n-data-table
:columns="columns"
:data="reports"
:pagination="false"
:bordered="false"
:single-line="false"
:loading="loading"
:scroll-x="1200"
class="h-full"
/>
</div>
</template>
<!-- 内容区footer - 分页组件 -->
<template #content-footer>
<div class="p-4">
<div class="flex justify-center">
<n-pagination
v-model:page="pagination.page"
v-model:page-size="pagination.pageSize"
:item-count="pagination.total"
:page-sizes="[50, 100, 200, 500]"
show-size-picker
@update:page="fetchReports"
@update:page-size="handlePageSizeChange"
/>
</div>
</div>
</template>
</AdminPageLayout>
<!-- 查看举报详情模态框 -->
<n-modal v-model:show="showDetailModal" :mask-closable="false" preset="card" :style="{ maxWidth: '600px', width: '90%' }" title="举报详情">
<div v-if="selectedReport" class="space-y-4">
<div>
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">举报ID</h3>
<p class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ selectedReport.id }}</p>
</div>
<div>
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">资源Key</h3>
<p class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ selectedReport.resource_key }}</p>
</div>
<div>
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">举报原因</h3>
<p class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ getReasonLabel(selectedReport.reason) }}</p>
</div>
<div>
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">详细描述</h3>
<p class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ selectedReport.description }}</p>
</div>
<div>
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">联系方式</h3>
<p class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ selectedReport.contact || '未提供' }}</p>
</div>
<div>
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">提交时间</h3>
<p class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ formatDateTime(selectedReport.created_at) }}</p>
</div>
<div>
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">IP地址</h3>
<p class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ selectedReport.ip_address || '未知' }}</p>
</div>
<div v-if="selectedReport.note">
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">处理备注</h3>
<p class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ selectedReport.note }}</p>
</div>
</div>
</n-modal>
</template>
<script setup lang="ts">
// 设置页面标题和元信息
useHead({
title: '举报管理 - 管理后台',
meta: [
{ name: 'description', content: '管理用户提交的资源举报信息' }
]
})
// 设置页面布局和认证保护
definePageMeta({
layout: 'admin',
middleware: ['auth', 'admin']
})
import { h } from 'vue'
const message = useMessage()
const notification = useNotification()
const dialog = useDialog()
const { resourceApi } = useApi()
const loading = ref(false)
const reports = ref<any[]>([])
const showDetailModal = ref(false)
const selectedReport = ref<any>(null)
// 分页和筛选状态
const pagination = ref({
page: 1,
pageSize: 50,
total: 0
})
const filters = ref({
status: '',
resourceKey: ''
})
// 表格列定义
const columns = [
{
title: 'ID',
key: 'id',
width: 30,
render: (row: any) => {
return h('span', { class: 'font-medium' }, row.id)
}
},
{
title: '资源',
key: 'resource_key',
width: 200,
render: (row: any) => {
const resourceInfo = getResourceInfo(row);
return h('div', { class: 'space-y-1' }, [
// 第一行:标题(单行,省略号)
h('div', {
class: 'font-medium text-sm truncate max-w-[200px]',
style: { maxWidth: '200px' },
title: resourceInfo.title // 鼠标hover显示完整标题
}, resourceInfo.title),
// 第二行:详情(单行,省略号)
h('div', {
class: 'text-xs text-gray-500 dark:text-gray-400 truncate max-w-[200px]',
style: { maxWidth: '200px' },
title: resourceInfo.description // 鼠标hover显示完整描述
}, resourceInfo.description),
// 第三行:分类图片和链接数
h('div', { class: 'flex items-center gap-1' }, [
h('i', {
class: `fas fa-${getCategoryIcon(resourceInfo.category)} text-blue-500 text-xs`,
// 鼠标hover显示第一个资源的链接地址
title: resourceInfo.resources.length > 0 ? `链接地址: ${resourceInfo.resources[0].save_url || resourceInfo.resources[0].url}` : `资源链接地址: ${row.resource_key}`
}),
h('span', { class: 'text-xs text-gray-400' }, `链接数: ${resourceInfo.resources.length}`)
])
])
}
},
{
title: '举报原因',
key: 'reason',
width: 100,
render: (row: any) => {
return h('div', { class: 'space-y-1' }, [
// 举报原因和描述提示
h('div', {
class: 'flex items-center gap-1 truncate max-w-[80px]',
style: { maxWidth: '80px' }
}, [
h('span', null, getReasonLabel(row.reason)),
// 添加描述提示图片
h('i', {
class: 'fas fa-info-circle text-blue-400 cursor-pointer text-xs ml-1',
title: row.description // 鼠标hover显示描述
})
]),
// 举报时间
h('div', {
class: 'text-xs text-gray-400 truncate max-w-[80px]',
style: { maxWidth: '80px' }
}, `举报时间: ${formatDateTime(row.created_at)}`),
// 联系方式
h('div', {
class: 'text-xs text-gray-500 dark:text-gray-400 truncate max-w-[80px]',
style: { maxWidth: '80px' }
}, `联系方式: ${row.contact || '未提供'}`)
])
}
},
{
title: '状态',
key: 'status',
width: 50,
render: (row: any) => {
const type = getStatusType(row.status)
return h('n-tag', {
type: type,
size: 'small',
bordered: false
}, { default: () => getStatusLabel(row.status) })
}
},
{
title: '操作',
key: 'actions',
width: 180,
render: (row: any) => {
const buttons = [
h('button', {
class: 'px-2 py-1 text-xs bg-blue-100 hover:bg-blue-200 text-blue-700 dark:bg-blue-900/20 dark:text-blue-400 rounded transition-colors mr-1',
onClick: () => viewReport(row)
}, [
h('i', { class: 'fas fa-eye mr-1 text-xs' }),
'查看'
])
]
if (row.status === 'pending') {
buttons.push(
h('button', {
class: 'px-2 py-1 text-xs bg-green-100 hover:bg-green-200 text-green-700 dark:bg-green-900/20 dark:text-green-400 rounded transition-colors mr-1',
onClick: () => updateReportStatus(row, 'approved')
}, [
h('i', { class: 'fas fa-check mr-1 text-xs' }),
'批准'
]),
h('button', {
class: 'px-2 py-1 text-xs bg-red-100 hover:bg-red-200 text-red-700 dark:bg-red-900/20 dark:text-red-400 rounded transition-colors',
onClick: () => updateReportStatus(row, 'rejected')
}, [
h('i', { class: 'fas fa-times mr-1 text-xs' }),
'拒绝'
])
)
}
return h('div', { class: 'flex items-center gap-1' }, buttons)
}
}
]
// 搜索防抖
let searchTimeout: NodeJS.Timeout | null = null
const debounceSearch = () => {
if (searchTimeout) {
clearTimeout(searchTimeout)
}
searchTimeout = setTimeout(() => {
pagination.value.page = 1
fetchReports()
}, 300)
}
// 获取举报列表
const fetchReports = async () => {
loading.value = true
try {
const params: any = {
page: pagination.value.page,
page_size: pagination.value.pageSize
}
if (filters.value.status) params.status = filters.value.status
if (filters.value.resourceKey) params.resource_key = filters.value.resourceKey
// 使用原始API调用以获取完整的分页信息
const rawResponse = await resourceApi.getReportsRaw(params)
console.log(rawResponse)
// 检查响应格式并处理
if (rawResponse && rawResponse.data && rawResponse.data.list !== undefined) {
// 如果后端返回了分页格式,使用正确的字段
reports.value = rawResponse.data.list || []
pagination.value.total = rawResponse.data.total || 0
} else {
// 如果是其他格式,尝试直接使用响应
reports.value = rawResponse || []
pagination.value.total = rawResponse.length || 0
}
} catch (error) {
console.error('获取举报列表失败:', error)
// 显示错误提示
if (process.client) {
notification.error({
content: '获取举报列表失败',
duration: 3000
})
}
} finally {
loading.value = false
}
}
// 重置筛选条件
const resetFilters = () => {
filters.value = {
status: '',
resourceKey: ''
}
pagination.value.page = 1
fetchReports()
}
// 处理页面大小变化
const handlePageSizeChange = (pageSize: number) => {
pagination.value.pageSize = pageSize
pagination.value.page = 1
fetchReports()
}
// 查看举报详情
const viewReport = (report: any) => {
selectedReport.value = report
showDetailModal.value = true
}
// 更新举报状态
const updateReportStatus = async (report: any, status: string) => {
try {
// 获取处理备注(如果需要)
let note = ''
if (status === 'rejected') {
note = await getRejectionNote()
if (note === null) return // 用户取消操作
}
const response = await resourceApi.updateReport(report.id, {
status,
note
})
// 更新本地数据
const index = reports.value.findIndex(r => r.id === report.id)
if (index !== -1) {
reports.value[index] = response
}
// 更新详情模态框中的数据
if (selectedReport.value && selectedReport.value.id === report.id) {
selectedReport.value = response
}
if (process.client) {
notification.success({
content: '状态更新成功',
duration: 3000
})
}
} catch (error) {
console.error('更新举报状态失败:', error)
if (process.client) {
notification.error({
content: '状态更新失败',
duration: 3000
})
}
}
}
// 获取拒绝原因输入
const getRejectionNote = (): Promise<string | null> => {
return new Promise((resolve) => {
// 使用naive-ui的dialog API
const { dialog } = useDialog()
let inputValue = ''
dialog.warning({
title: '输入拒绝原因',
content: () => h(nInput, {
value: inputValue,
onUpdateValue: (value) => inputValue = value,
placeholder: '请输入拒绝的原因...',
type: 'textarea',
rows: 4
}),
positiveText: '确定',
negativeText: '取消',
onPositiveClick: () => {
if (!inputValue.trim()) {
const { message } = useNotification()
message.warning('请输入拒绝原因')
return false // 不关闭对话框
}
resolve(inputValue)
},
onNegativeClick: () => {
resolve(null)
}
})
})
}
// 状态类型和标签
const getStatusType = (status: string) => {
switch (status) {
case 'pending': return 'warning'
case 'approved': return 'success'
case 'rejected': return 'error'
default: return 'default'
}
}
const getStatusLabel = (status: string) => {
switch (status) {
case 'pending': return '待处理'
case 'approved': return '已批准'
case 'rejected': return '已拒绝'
default: return status
}
}
// 举报原因标签
const getReasonLabel = (reason: string) => {
const reasonMap: Record<string, string> = {
'link_invalid': '链接已失效',
'download_failed': '资源无法下载',
'content_mismatch': '资源内容不符',
'malicious': '包含恶意软件',
'copyright': '版权问题',
'other': '其他问题'
}
return reasonMap[reason] || reason
}
// 格式化日期时间
const formatDateTime = (dateString: string) => {
if (!dateString) return '-'
const date = new Date(dateString)
return date.toLocaleString('zh-CN')
}
// 获取分类图标
const getCategoryIcon = (category: string) => {
if (!category) return 'folder';
// 根据分类名称返回对应的图标
const categoryMap: Record<string, string> = {
'文档': 'file-alt',
'文档资料': 'file-alt',
'压缩包': 'file-archive',
'图片': 'images',
'视频': 'film',
'音乐': 'music',
'电子书': 'book',
'软件': 'cogs',
'应用': 'mobile-alt',
'游戏': 'gamepad',
'资料': 'folder',
'其他': 'file',
'folder': 'folder',
'file': 'file'
};
return categoryMap[category] || 'folder';
}
// 获取资源信息显示
const getResourceInfo = (row: any) => {
// 从后端返回的资源列表中获取信息
const resources = row.resources || [];
if (resources.length > 0) {
// 如果有多个资源,可以选择第一个或合并信息
const resource = resources[0];
return {
title: resource.title || `资源: ${row.resource_key}`,
description: resource.description || `资源详情: ${row.resource_key}`,
category: resource.category || 'folder',
resources: resources // 返回所有资源用于显示链接数量等
}
} else {
// 如果没有关联资源,使用默认值
return {
title: `资源: ${row.resource_key}`,
description: `资源详情: ${row.resource_key}`,
category: 'folder',
resources: []
}
}
}
// 初始化数据
onMounted(() => {
fetchReports()
})
</script>

View File

@@ -150,6 +150,10 @@
<i class="fas fa-eye mr-1"></i>
{{ resource.view_count || 0 }}
</span>
<span>
<i class="fas fa-clock mr-1"></i>
{{ resource.updated_at }}
</span>
</div>
<div v-if="resource.tags && resource.tags.length > 0" class="mt-2">
@@ -670,20 +674,20 @@ const handleEditSubmit = async () => {
try {
editing.value = true
await editFormRef.value?.validate()
await resourceApi.updateResource(editingResource.value!.id, editForm.value)
notification.success({
content: '更新成功',
duration: 3000
})
// 更新本地数据
const resourceId = editingResource.value?.id
const index = resources.value.findIndex(r => r.id === resourceId)
if (index > -1) {
resources.value[index] = {
...resources.value[index],
resources.value[index] = {
...resources.value[index],
title: editForm.value.title,
description: editForm.value.description,
url: editForm.value.url,
@@ -692,7 +696,7 @@ const handleEditSubmit = async () => {
tag_ids: editForm.value.tag_ids
}
}
showEditModal.value = false
editingResource.value = null
} catch (error) {

View File

@@ -459,13 +459,12 @@ definePageMeta({
layout: 'default'
})
// 页面元数据
useHead({
title: 'API文档 - 老九网盘资源数据库',
meta: [
{ name: 'description', content: '老九网盘资源数据库的公开API接口文档' },
{ name: 'keywords', content: 'API,接口文档,网盘资源管理' }
]
// 设置页面SEO
const { initSystemConfig, setApiDocsSeo } = useGlobalSeo()
onBeforeMount(async () => {
await initSystemConfig()
setApiDocsSeo()
})
</script>

View File

@@ -166,10 +166,18 @@ definePageMeta({
layout: 'default'
})
// 设置页面SEO
const { initSystemConfig, setHotDramasSeo } = useGlobalSeo()
onBeforeMount(async () => {
await initSystemConfig()
setHotDramasSeo()
})
const hotDramaApi = useHotDramaApi()
const { data: hotDramsaResponse, error } = await hotDramaApi.getHotDramas({
page: 1,
page_size: 20
page_size: 20
})
const { getPosterUrl } = hotDramaApi

View File

@@ -1,5 +1,5 @@
<template>
<div v-if="!systemConfig.maintenance_mode" class="min-h-screen bg-gray-50 dark:bg-slate-900 text-gray-800 dark:text-slate-100 flex flex-col">
<div v-if="!systemConfig?.maintenance_mode" class="min-h-screen bg-gray-50 dark:bg-slate-900 text-gray-800 dark:text-slate-100 flex flex-col">
<!-- 全局加载状态 -->
<div v-if="pageLoading" class="fixed inset-0 bg-gray-900 bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-white dark:bg-gray-800 rounded-lg p-8 shadow-xl">
@@ -173,8 +173,9 @@
<tr
v-for="(resource, index) in safeResources"
:key="resource.id"
:class="isUpdatedToday(resource.updated_at) ? 'hover:bg-pink-50 dark:hover:bg-pink-500/10 bg-pink-50/30 dark:bg-pink-500/5' : 'hover:bg-gray-50 dark:hover:bg-slate-700/50'"
:class="isUpdatedToday(resource.updated_at) ? 'hover:bg-pink-50 dark:hover:bg-pink-500/10 bg-pink-50/30 dark:bg-pink-500/5 cursor-pointer' : 'hover:bg-gray-50 dark:hover:bg-slate-700/50 cursor-pointer'"
:data-index="index"
@click="navigateToDetail(resource.key)"
>
<td class="text-xs sm:text-sm w-20 pl-2 sm:pl-3">
<div class="flex justify-center">
@@ -229,23 +230,25 @@
</div>
</div>
<div class="flex-1 flex justify-end">
<button
class="mobile-link-btn flex items-center gap-1 text-xs"
@click="toggleLink(resource)"
<NuxtLink
:to="`/r/${resource.key}`"
class="mobile-link-btn flex items-center gap-1 text-xs no-underline"
@click.stop
>
<i class="fas fa-eye"></i> 显示链接
</button>
<i class="fas fa-eye"></i> 查看详情
</NuxtLink>
</div>
</div>
</div>
</td>
<td class="px-2 sm:px-6 py-2 sm:py-4 text-xs sm:text-sm hidden sm:table-cell w-32">
<button
class="text-blue-600 hover:text-blue-800 flex items-center gap-1 show-link-btn"
@click="toggleLink(resource)"
<NuxtLink
:to="`/r/${resource.key}`"
class="text-blue-600 hover:text-blue-800 flex items-center gap-1 show-link-btn"
@click.stop
>
<i class="fas fa-eye"></i> 显示链接
</button>
<i class="fas fa-eye"></i> 查看详情
</NuxtLink>
</td>
<td class="px-2 sm:px-6 py-2 sm:py-4 text-xs sm:text-sm text-gray-500 hidden sm:table-cell w-32" :title="resource.updated_at">
<span v-html="formatRelativeTime(resource.updated_at)"></span>
@@ -282,7 +285,7 @@
<!-- 悬浮按钮组件 -->
<FloatButtons />
</div>
<div v-if="systemConfig.maintenance_mode" class="fixed inset-0 z-[1000000] flex items-center justify-center bg-gradient-to-br from-yellow-100/80 via-gray-900/90 to-yellow-200/80 backdrop-blur-sm">
<div v-if="systemConfig?.maintenance_mode" class="fixed inset-0 z-[1000000] flex items-center justify-center bg-gradient-to-br from-yellow-100/80 via-gray-900/90 to-yellow-200/80 backdrop-blur-sm">
<div class="bg-white dark:bg-gray-800 rounded-3xl shadow-2xl px-8 py-10 flex flex-col items-center max-w-xs w-full border border-yellow-200 dark:border-yellow-700">
<i class="fas fa-tools text-yellow-500 text-5xl mb-6 animate-bounce-slow"></i>
<h3 class="text-2xl font-extrabold text-yellow-600 dark:text-yellow-400 mb-2 tracking-wide drop-shadow">系统维护中</h3>
@@ -311,7 +314,7 @@ const statsApi = useStatsApi()
const panApi = usePanApi()
const publicSystemConfigApi = usePublicSystemConfigApi()
// 获取路由参数 - 提前定义以避免初始化顺序问题
// 路由参数已通过自动导入提供,直接使用
const route = useRoute()
const router = useRouter()
@@ -321,7 +324,8 @@ const { data: systemConfigData } = await useAsyncData('systemConfig', () => publ
// 获取平台名称的辅助函数
const getPlatformName = (platformId: string) => {
if (!platformId) return ''
const platform = platforms.value.find((p: any) => p.id == platformId)
const platformList = (platforms.value || []) as any[]
const platform = platformList.find((p: any) => p.id == platformId)
return platform?.name || ''
}
@@ -331,10 +335,9 @@ const pageTitle = computed(() => {
const config = systemConfigData.value as any
const siteTitle = (config?.data?.site_title) ? config.data.site_title :
(config?.site_title) ? config.site_title : '老九网盘资源数据库'
const searchKeyword = (route.query && route.query.search) ? route.query.search as string : ''
const platformId = (route.query && route.query.platform) ? route.query.platform as string : ''
const searchKeyword = (route.query?.search) ? route.query.search as string : ''
const platformId = (route.query?.platform) ? route.query.platform as string : ''
const platformName = getPlatformName(platformId)
let title = siteTitle
// 根据搜索条件组合标题
@@ -411,22 +414,76 @@ const pageKeywords = computed(() => {
}
})
// 设置动态SEO - 修复useHead 500错误
useHead(() => {
// 安全地获取标题,添加默认值
const safeTitle = pageTitle.value || '老九网盘资源数据库 - 首页'
const safeDescription = pageDescription.value || '老九网盘资源管理系统, 一个现代化的网盘资源数据库,支持多网盘自动化转存分享'
const safeKeywords = pageKeywords.value || '网盘资源,资源管理,数据库'
// 设置页面SEO
const { initSystemConfig, setPageSeo, systemConfig: seoSystemConfig } = useGlobalSeo()
return {
title: safeTitle,
// 更新页面SEO的函数 - 合并所有SEO设置到一个函数中
const updatePageSeo = () => {
// 使用动态计算的标题,而不是默认的"首页"
setPageSeo(pageTitle.value, {
description: pageDescription.value,
keywords: pageKeywords.value,
ogImage: '/assets/images/og.webp' // 使用默认的OG图片
})
// 设置HTML属性和canonical链接
const config = useRuntimeConfig()
const baseUrl = config.public.siteUrl || 'https://yourdomain.com' // 从环境变量获取
const params = new URLSearchParams()
if (route.query?.search) params.set('search', route.query.search as string)
if (route.query?.platform) params.set('platform', route.query.platform as string)
const queryString = params.toString()
const canonicalUrl = queryString ? `${baseUrl}?${queryString}` : baseUrl
useHead({
htmlAttrs: {
lang: 'zh-CN'
},
link: [
{
rel: 'canonical',
href: canonicalUrl
}
],
meta: [
{ name: 'description', content: safeDescription },
{ name: 'keywords', content: safeKeywords }
{
property: 'og:image',
content: '/assets/images/og.webp'
}
],
script: [
{
type: 'application/ld+json',
innerHTML: JSON.stringify({
"@context": "https://schema.org",
"@type": "WebSite",
"name": (seoSystemConfig.value && seoSystemConfig.value.site_title) || '老九网盘资源数据库',
"description": pageDescription.value,
"url": canonicalUrl,
"image": '/assets/images/og.webp'
})
}
]
}
})
}
onBeforeMount(async () => {
await initSystemConfig()
updatePageSeo()
})
// 监听路由变化和系统配置数据当搜索条件或配置改变时更新SEO
watch(
() => [route.query?.search, route.query?.platform, systemConfigData.value],
() => {
// 使用nextTick确保响应式数据已更新
nextTick(() => {
updatePageSeo()
})
},
{ deep: true }
)
// 响应式数据
const showLinkModal = ref(false)
const selectedResource = ref<any>(null)
@@ -474,13 +531,13 @@ const handleResourceImageError = (event: Event) => {
// 使用 useAsyncData 获取资源数据
const { data: resourcesData, pending, refresh } = await useAsyncData(
() => `resources-1-${route.query.search || ''}-${route.query.platform || ''}`,
() => `resources-1-${route.query?.search || ''}-${route.query?.platform || ''}`,
async () => {
// 如果有搜索关键词使用带搜索参数的资源接口后端会优先使用Meilisearch
if (route.query.search) {
if (route.query?.search) {
return await resourceApi.getResources({
page: 1,
page_size: 200,
page_size: 50,
search: route.query.search as string,
pan_id: route.query.platform as string || ''
})
@@ -488,8 +545,8 @@ const { data: resourcesData, pending, refresh } = await useAsyncData(
// 没有搜索关键词时,使用普通资源接口获取最新数据
return await resourceApi.getResources({
page: 1,
page_size: 200,
pan_id: route.query.platform as string || ''
page_size: 50,
pan_id: route.query?.platform as string || ''
})
}
}
@@ -574,8 +631,8 @@ const safeLoading = computed(() => pending.value)
// 从路由参数获取当前状态
const searchQuery = ref(route.query.search as string || '')
const selectedPlatform = computed(() => route.query.platform as string || '')
const searchQuery = ref(route.query?.search as string || '')
const selectedPlatform = computed(() => route.query?.platform as string || '')
// 记录搜索统计的函数
const recordSearchStats = (keyword: string) => {
@@ -607,11 +664,11 @@ const handleSearch = () => {
onMounted(() => {
// 初始化认证状态
authInitialized.value = true
animateCounters()
// 页面挂载完成时,如果有搜索关键词,记录搜索统计
if (process.client && route.query.search) {
if (process.client && route.query?.search) {
const searchKeyword = route.query.search as string
recordSearchStats(searchKeyword)
} else {
@@ -629,49 +686,14 @@ const getPlatformIcon = (panId: string | number) => {
// 注意:链接访问统计已整合到 getResourceLink API 中
// 切换链接显示
const toggleLink = async (resource: any) => {
// 如果包含违禁词,直接显示禁止访问,不发送请求
if (resource.has_forbidden_words) {
selectedResource.value = {
...resource,
forbidden: true,
error: '该资源包含违禁内容,无法访问',
forbidden_words: resource.forbidden_words || []
}
showLinkModal.value = true
return
}
// 导航到详情页
const navigateToDetail = (key: string) => {
router.push(`/r/${key}`)
}
// 显示加载状态
selectedResource.value = { ...resource, loading: true }
showLinkModal.value = true
try {
// 调用新的获取链接API同时统计访问次数
const linkData = await resourceApi.getResourceLink(resource.id) as any
console.log('获取到的链接数据:', linkData)
// 更新资源信息,包含新的链接信息
selectedResource.value = {
...resource,
url: linkData.url,
save_url: linkData.type === 'transferred' ? linkData.url : resource.save_url,
loading: false,
linkType: linkData.type,
platform: linkData.platform,
message: linkData.message
}
} catch (error: any) {
console.error('获取资源链接失败:', error)
// 其他错误
selectedResource.value = {
...resource,
loading: false,
error: '检测有效性失败,请自行验证'
}
}
// 切换链接显示(保留用于其他可能的用途)
const toggleLink = async (resource: any) => {
navigateToDetail(resource.key)
}
// 复制到剪贴板

View File

@@ -172,9 +172,12 @@ definePageMeta({
ssr: false
})
// 设置页面标题
useHead({
title: '管理员登录 - 老九网盘资源数据库'
// 设置页面SEO
const { initSystemConfig, setLoginSeo } = useGlobalSeo()
onBeforeMount(async () => {
await initSystemConfig()
setLoginSeo()
})
</script>

View File

@@ -242,6 +242,14 @@ definePageMeta({
layout: 'default'
})
// 设置页面SEO
const { initSystemConfig, setMonitorSeo } = useGlobalSeo()
onBeforeMount(async () => {
await initSystemConfig()
setMonitorSeo()
})
import { ref, onMounted, onUnmounted, computed } from 'vue'
import { useMonitorApi } from '~/composables/useApi'
const monitorApi = useMonitorApi()

1389
web/pages/r/[key].vue Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -279,16 +279,18 @@ definePageMeta({
ssr: false
})
// 设置页面SEO
const { initSystemConfig, setRegisterSeo } = useGlobalSeo()
onBeforeMount(async () => {
await initSystemConfig()
setRegisterSeo()
})
// 页面加载时获取系统配置
onMounted(() => {
fetchSystemConfig()
})
// 设置页面标题
useHead({
title: '用户注册 - 老九网盘资源数据库'
})
</script>
<style scoped>

39
web/pnpm-lock.yaml generated
View File

@@ -26,6 +26,9 @@ importers:
'@vicons/ionicons5':
specifier: ^0.12.0
version: 0.12.0
'@vueuse/core':
specifier: ^14.0.0
version: 14.0.0(vue@3.5.18(typescript@5.8.3))
chart.js:
specifier: ^4.5.0
version: 4.5.0
@@ -74,7 +77,7 @@ importers:
version: 5.8.3
unplugin-auto-import:
specifier: ^19.3.0
version: 19.3.0(@nuxt/kit@3.17.7(magicast@0.3.5))
version: 19.3.0(@nuxt/kit@3.17.7(magicast@0.3.5))(@vueuse/core@14.0.0(vue@3.5.18(typescript@5.8.3)))
unplugin-vue-components:
specifier: ^28.8.0
version: 28.8.0(@babel/parser@7.28.0)(@nuxt/kit@3.17.7(magicast@0.3.5))(vue@3.5.18(typescript@5.8.3))
@@ -1265,6 +1268,9 @@ packages:
'@types/uglify-js@3.17.5':
resolution: {integrity: sha512-TU+fZFBTBcXj/GpDpDaBmgWk/gn96kMZ+uocaFUlV2f8a6WdMzzI44QBCmGcCiYR0Y6ZlNRiyUyKKt5nl/lbzQ==}
'@types/web-bluetooth@0.0.21':
resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
'@types/webpack-bundle-analyzer@3.9.5':
resolution: {integrity: sha512-QlyDyX7rsOIJHASzXWlih8DT9fR+XCG9cwIV/4pKrtScdHv4XFshdEf/7iiqLqG0lzWcoBdzG8ylMHQ5XLNixw==}
@@ -1401,6 +1407,19 @@ packages:
'@vue/shared@3.5.18':
resolution: {integrity: sha512-cZy8Dq+uuIXbxCZpuLd2GJdeSO/lIzIspC2WtkqIpje5QyFbvLaI5wZtdUjLHjGZrlVX6GilejatWwVYYRc8tA==}
'@vueuse/core@14.0.0':
resolution: {integrity: sha512-d6tKRWkZE8IQElX2aHBxXOMD478fHIYV+Dzm2y9Ag122ICBpNKtGICiXKOhWU3L1kKdttDD9dCMS4bGP3jhCTQ==}
peerDependencies:
vue: ^3.5.0
'@vueuse/metadata@14.0.0':
resolution: {integrity: sha512-6yoGqbJcMldVCevkFiHDBTB1V5Hq+G/haPlGIuaFZHpXC0HADB0EN1ryQAAceiW+ryS3niUwvdFbGiqHqBrfVA==}
'@vueuse/shared@14.0.0':
resolution: {integrity: sha512-mTCA0uczBgurRlwVaQHfG0Ja7UdGe4g9mwffiJmvLiTtp1G4AQyIjej6si/k8c8pUwTfVpNufck+23gXptPAkw==}
peerDependencies:
vue: ^3.5.0
'@webassemblyjs/ast@1.14.1':
resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==}
@@ -5904,6 +5923,8 @@ snapshots:
dependencies:
source-map: 0.6.1
'@types/web-bluetooth@0.0.21': {}
'@types/webpack-bundle-analyzer@3.9.5':
dependencies:
'@types/webpack': 4.41.40
@@ -6133,6 +6154,19 @@ snapshots:
'@vue/shared@3.5.18': {}
'@vueuse/core@14.0.0(vue@3.5.18(typescript@5.8.3))':
dependencies:
'@types/web-bluetooth': 0.0.21
'@vueuse/metadata': 14.0.0
'@vueuse/shared': 14.0.0(vue@3.5.18(typescript@5.8.3))
vue: 3.5.18(typescript@5.8.3)
'@vueuse/metadata@14.0.0': {}
'@vueuse/shared@14.0.0(vue@3.5.18(typescript@5.8.3))':
dependencies:
vue: 3.5.18(typescript@5.8.3)
'@webassemblyjs/ast@1.14.1':
dependencies:
'@webassemblyjs/helper-numbers': 1.13.2
@@ -9257,7 +9291,7 @@ snapshots:
dependencies:
normalize-path: 2.1.1
unplugin-auto-import@19.3.0(@nuxt/kit@3.17.7(magicast@0.3.5)):
unplugin-auto-import@19.3.0(@nuxt/kit@3.17.7(magicast@0.3.5))(@vueuse/core@14.0.0(vue@3.5.18(typescript@5.8.3))):
dependencies:
local-pkg: 1.1.1
magic-string: 0.30.17
@@ -9267,6 +9301,7 @@ snapshots:
unplugin-utils: 0.2.4
optionalDependencies:
'@nuxt/kit': 3.17.7(magicast@0.3.5)
'@vueuse/core': 14.0.0(vue@3.5.18(typescript@5.8.3))
unplugin-utils@0.2.4:
dependencies:

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

View File

@@ -14,13 +14,13 @@ export const useSystemConfigStore = defineStore('systemConfig', {
// 根据上下文选择API管理员页面使用管理员API其他页面使用公开API
const apiUrl = useAdminApi ? '/system/config' : '/public/system-config'
const response = await useApiFetch(apiUrl)
console.log('Store API响应:', response) // 调试信息
// console.log('Store API响应:', response) // 调试信息
// 使用parseApiResponse正确解析API响应
const data = parseApiResponse(response)
console.log('Store 处理后的数据:', data) // 调试信息
console.log('Store 自动处理状态:', data.auto_process_ready_resources)
console.log('Store 自动转存状态:', data.auto_transfer_enabled)
// console.log('Store 处理后的数据:', data) // 调试信息
// console.log('Store 自动处理状态:', data.auto_process_ready_resources)
// console.log('Store 自动转存状态:', data.auto_transfer_enabled)
this.config = data
this.initialized = true