diff --git a/internal/fqapi/audio.go b/internal/fqapi/audio.go index 6089466..1410dd0 100644 --- a/internal/fqapi/audio.go +++ b/internal/fqapi/audio.go @@ -132,4 +132,3 @@ func HandleAudio(itemIDs interface{}, toneID interface{}, output interface{}) (s audioLogger.Error(fmt.Sprintf("获取音频失败: code=%v, msg=%s", respCode, errMsg)) return BuildErrorResponse(errMsg), nil } - diff --git a/internal/fqapi/cache_manager.go b/internal/fqapi/cache_manager.go index c4e9180..217de69 100644 --- a/internal/fqapi/cache_manager.go +++ b/internal/fqapi/cache_manager.go @@ -379,7 +379,7 @@ func CleanupAllCache() error { freeBytes := freelistCount * pageSize // 阈值:空闲空间超过 5MB 时才执行压缩 const vacuumThreshold = 5 * 1024 * 1024 // 5MB - + if freeBytes >= vacuumThreshold { cacheLogger.Info(fmt.Sprintf("空闲空间 %.2f MB,开始执行压缩...", float64(freeBytes)/(1024*1024))) _, err = cacheDB.Exec("VACUUM") @@ -550,7 +550,7 @@ func GetDatabaseStats() (map[string]interface{}, error) { if err == nil { stats["page_count"] = pageCount } - + err = cacheDB.QueryRow("PRAGMA page_size").Scan(&pageSize) if err == nil { stats["page_size"] = pageSize @@ -589,7 +589,7 @@ func SaveStatistics(stats map[string]int64) error { `), stats["total"], stats["search"], stats["detail"], stats["catalog"], stats["content"], stats["manga"], stats["audio"], stats["video"], stats["category"], time.Now().Unix()) - + if err != nil { cacheLogger.Error("保存统计数据失败: " + err.Error()) return err @@ -605,13 +605,13 @@ func LoadStatistics() (map[string]int64, error) { } var total, search, detail, catalog, content, manga, audio, video, category int64 - + err := cacheDB.QueryRow(` SELECT total_requests, search_requests, detail_requests, catalog_requests, content_requests, manga_requests, audio_requests, video_requests, category_requests FROM statistics WHERE id = 1 `).Scan(&total, &search, &detail, &catalog, &content, &manga, &audio, &video, &category) - + if err == sql.ErrNoRows { // 没有数据,返回空统计 return map[string]int64{ @@ -619,7 +619,7 @@ func LoadStatistics() (map[string]int64, error) { "content": 0, "manga": 0, "audio": 0, "video": 0, "category": 0, }, nil } - + if err != nil { cacheLogger.Error("加载统计数据失败: " + err.Error()) return nil, err @@ -638,4 +638,4 @@ func LoadStatistics() (map[string]int64, error) { } return stats, nil -} \ No newline at end of file +} diff --git a/internal/fqapi/catalog.go b/internal/fqapi/catalog.go index 68ef370..5cbd711 100644 --- a/internal/fqapi/catalog.go +++ b/internal/fqapi/catalog.go @@ -12,7 +12,7 @@ var catalogLogger = common.NewLogger("catalog", "INFO", "") // HandleGetCatalog 处理获取书籍目录,目录数据基本不变,缓存 24 小时 func HandleGetCatalog(bookID string) (string, error) { - return withCache("catalog_" + bookID, 86400, func() (string, error) { + return withCache("catalog_"+bookID, 86400, func() (string, error) { return fetchCatalog(bookID) }) } @@ -210,4 +210,3 @@ func itemMultiDetail(itemList []string) ([]map[string]interface{}, error) { return nil, fmt.Errorf("请求失败") } - diff --git a/internal/fqapi/category_front.go b/internal/fqapi/category_front.go index 0bdd1a7..a91ad54 100644 --- a/internal/fqapi/category_front.go +++ b/internal/fqapi/category_front.go @@ -47,4 +47,3 @@ func fetchCategoryFront(tab interface{}) (string, error) { return BuildErrorResponse("获取失败"), nil } - diff --git a/internal/fqapi/category_landing.go b/internal/fqapi/category_landing.go index f99eeae..8742bb8 100644 --- a/internal/fqapi/category_landing.go +++ b/internal/fqapi/category_landing.go @@ -59,4 +59,3 @@ func fetchCategoryLanding(categoryID, offset, wordNumber, bookStatus, sortBy int return BuildErrorResponse("获取失败"), nil } - diff --git a/internal/fqapi/content.go b/internal/fqapi/content.go index c75f004..2a83314 100644 --- a/internal/fqapi/content.go +++ b/internal/fqapi/content.go @@ -66,7 +66,7 @@ func fetchContentWithRetry(itemID string, textMode, imageMode bool, retryCount i contentLogger.Warning(fmt.Sprintf("已重试 %d 次,尝试备用接口", maxRetries)) return tryBackupAPI(itemID, textMode, imageMode) } - + // 从设备密钥池获取设备 pool := GetDeviceKeyPool() item, err := pool.GetNext() @@ -74,7 +74,7 @@ func fetchContentWithRetry(itemID string, textMode, imageMode bool, retryCount i contentLogger.Error("从设备池获取设备失败: " + err.Error()) return `{"code": -1, "msg": "设备池暂无可用设备"}`, err } - + header := item.DeviceHeader currentKeyInfo := &KeyInfo{ Key: item.Key, @@ -142,7 +142,7 @@ func fetchContentWithRetry(itemID string, textMode, imageMode bool, retryCount i keyVersion, _ := data["key_version"].(float64) if currentKeyInfo != nil && int(keyVersion) != currentKeyInfo.Ver { contentLogger.Warning("密钥版本不匹配,重新获取密钥") - + // 更新池中的密钥 newKeyInfo, err := GetKey(header) if err == nil { @@ -276,4 +276,3 @@ func tryBackupAPI(itemID string, textMode, imageMode bool) (string, error) { contentLogger.Warning("备用接口1获取正文出错!") return `{"code": -1, "msg": "获取失败"}`, fmt.Errorf("所有接口都失败") } - diff --git a/internal/fqapi/content_parser.go b/internal/fqapi/content_parser.go index da82eac..22bdd9e 100644 --- a/internal/fqapi/content_parser.go +++ b/internal/fqapi/content_parser.go @@ -13,7 +13,6 @@ var ( parserLogger = common.NewLogger("content_parser", "INFO", "") ) - // ContentParser 内容解析器 type ContentParser struct { cfg config.ContentConfig @@ -143,13 +142,13 @@ func (p *ContentParser) extractText(content string) string { // 拼接结果 var result string - + // 根据配置决定段落间的换行符 separator := "\n" if p.cfg.DoubleNewline { separator = "\n\n" } - + if p.cfg.AddParagraphIndent && len(finalResult) > 0 { // 添加段落缩进(两个全角空格) result = "\u3000\u3000" + strings.Join(finalResult, separator+"\u3000\u3000") @@ -170,10 +169,10 @@ func (p *ContentParser) extractText(content string) string { func (p *ContentParser) processHTMLMode(content string) string { // 标准化img属性 content = normalizeImgAttributes(content) - + // 解码HTML实体 content = decodeHTMLEntities(content) - + // 根据配置决定是否移除空行 if p.cfg.RemoveBlankLines { // 对HTML内容移除空的

标签 @@ -184,4 +183,3 @@ func (p *ContentParser) processHTMLMode(content string) string { return content } - diff --git a/internal/fqapi/content_utils.go b/internal/fqapi/content_utils.go index 1a735bf..3bfca8a 100644 --- a/internal/fqapi/content_utils.go +++ b/internal/fqapi/content_utils.go @@ -57,10 +57,10 @@ func removeBlankLines(content string) string { for strings.Contains(content, "\n\n\n") { content = strings.ReplaceAll(content, "\n\n\n", "\n\n") } - + // 移除首尾的空白 content = strings.TrimSpace(content) - + return content } @@ -127,22 +127,22 @@ func BuildURL(fullURL string, customParams ...string) string { "os_version=0", "version_code=58932", } - + // 添加设备header header := getDeviceHeader() if header != "" { allParams = append(allParams, header) } - + // 添加自定义参数 allParams = append(allParams, customParams...) - + // 合并、拆分、排序 joined := strings.Join(allParams, "&") paramList := strings.Split(joined, "&") sort.Strings(paramList) params := strings.Join(paramList, "&") - + return fullURL + "?" + params } @@ -218,11 +218,11 @@ func parseStringToInt(s string) (int, error) { // 简单实现,避免导入strconv result := 0 negative := false - + if len(s) == 0 { return 0, nil } - + start := 0 if s[0] == '-' { negative = true @@ -230,18 +230,18 @@ func parseStringToInt(s string) (int, error) { } else if s[0] == '+' { start = 1 } - + for i := start; i < len(s); i++ { if s[i] < '0' || s[i] > '9' { break } result = result*10 + int(s[i]-'0') } - + if negative { result = -result } - + return result, nil } @@ -250,7 +250,7 @@ func ParseStringParam(param interface{}, defaultVal string) string { if param == nil || param == "" { return defaultVal } - + switch v := param.(type) { case string: if v != "" { @@ -262,4 +262,3 @@ func ParseStringParam(param interface{}, defaultVal string) string { } return defaultVal } - diff --git a/internal/fqapi/detail.go b/internal/fqapi/detail.go index c973800..485f7d4 100644 --- a/internal/fqapi/detail.go +++ b/internal/fqapi/detail.go @@ -10,7 +10,7 @@ var detailLogger = common.NewLogger("detail", "INFO", "") // HandleGetDetail 处理获取书籍详情,详情数据基本不变,缓存 24 小时 func HandleGetDetail(bookID string) (string, error) { - return withCache("detail_" + bookID, 86400, func() (string, error) { + return withCache("detail_"+bookID, 86400, func() (string, error) { return fetchDetail(bookID) }) } @@ -58,4 +58,3 @@ func fetchDetail(bookID string) (string, error) { return BuildErrorResponse("获取失败"), nil } - diff --git a/internal/fqapi/device_key_pool.go b/internal/fqapi/device_key_pool.go index 4afd5e4..1a4e6ec 100644 --- a/internal/fqapi/device_key_pool.go +++ b/internal/fqapi/device_key_pool.go @@ -1,547 +1,543 @@ -package fqapi - -import ( - "fanqie/internal/common" - "fanqie/internal/config" - "fmt" - "sync" - "time" -) - -var ( - poolLogger = common.NewLogger("device_key_pool", "INFO", "") - globalPool *DeviceKeyPool - poolOnce sync.Once -) - -// DeviceKeyItem 设备密钥项 -type DeviceKeyItem struct { - ID int `json:"id"` - DeviceHeader string `json:"device_header"` - Key string `json:"key"` - KeyVer int `json:"key_ver"` - IsValid bool `json:"is_valid"` - CreatedAt time.Time `json:"created_at"` - LastUsedAt time.Time `json:"last_used_at"` -} - -// DeviceKeyPool 设备密钥池 -type DeviceKeyPool struct { - mu sync.RWMutex - currentIndex int - items []*DeviceKeyItem - enabled bool -} - -// GetDeviceKeyPool 获取全局设备密钥池实例 -func GetDeviceKeyPool() *DeviceKeyPool { - poolOnce.Do(func() { - globalPool = &DeviceKeyPool{ - items: make([]*DeviceKeyItem, 0), - enabled: false, - } - }) - return globalPool -} - -// Initialize 初始化设备密钥池 -func (p *DeviceKeyPool) Initialize() error { - cfg := config.GetConfig() - if cfg == nil || !cfg.DeviceKeyPool.Enabled { - poolLogger.Info("设备密钥池未启用") - p.enabled = false - return nil - } - - p.enabled = true - poolLogger.Info("开始初始化设备密钥池...") - - // 从数据库加载已有的设备 - if err := p.loadFromDB(); err != nil { - poolLogger.Warning("从数据库加载设备失败: " + err.Error()) - } - - // 检查并补充设备到初始化数量 - validCount := p.getValidCount() - initialSize := cfg.DeviceKeyPool.InitialSize - - if validCount < initialSize { - needCount := initialSize - validCount - poolLogger.Info(fmt.Sprintf("当前有效设备数: %d, 需要补充: %d", validCount, needCount)) - - for i := 0; i < needCount; i++ { - if err := p.addNewDevice(); err != nil { - poolLogger.Error(fmt.Sprintf("添加设备失败: %v", err)) - } - } - } - - poolLogger.Info(fmt.Sprintf("设备密钥池初始化完成,当前有效设备数: %d", p.getValidCount())) - return nil -} - -// IsEnabled 检查设备密钥池是否启用 -func (p *DeviceKeyPool) IsEnabled() bool { - return p.enabled -} - -// GetNext 获取下一个可用的设备密钥(轮询方式) -func (p *DeviceKeyPool) GetNext() (*DeviceKeyItem, error) { - if !p.enabled { - return nil, fmt.Errorf("设备密钥池未启用") - } - - p.mu.Lock() - defer p.mu.Unlock() - - // 过滤有效设备 - validItems := make([]*DeviceKeyItem, 0) - for _, item := range p.items { - if item.IsValid { - validItems = append(validItems, item) - } - } - - if len(validItems) == 0 { - return nil, fmt.Errorf("没有可用的设备") - } - - // 轮询获取下一个设备 - item := validItems[p.currentIndex%len(validItems)] - p.currentIndex++ - - // 更新最后使用时间 - item.LastUsedAt = time.Now() - p.updateDeviceInDB(item) - - // poolLogger.Debug(fmt.Sprintf("使用设备 ID=%d", item.ID)) - - // 异步检查是否需要补充 - go p.checkAndRefill() - - return item, nil -} - -// MarkInvalid 标记设备为无效 -func (p *DeviceKeyPool) MarkInvalid(deviceHeader string) error { - if !p.enabled { - return nil - } - - p.mu.Lock() - defer p.mu.Unlock() - - for _, item := range p.items { - if item.DeviceHeader == deviceHeader { - item.IsValid = false - // poolLogger.Warning(fmt.Sprintf("设备 ID=%d 已标记为无效", item.ID)) - - // 更新数据库 - if err := p.updateDeviceInDB(item); err != nil { - return err - } - - // 异步补充新设备 - go p.checkAndRefill() - - return nil - } - } - - return fmt.Errorf("未找到设备") -} - -// RemoveInvalid 移除所有无效设备并自动补充 -func (p *DeviceKeyPool) RemoveInvalid() error { - if !p.enabled { - return nil - } - - p.mu.Lock() - - // 从数据库中删除无效设备 - if err := p.removeInvalidFromDB(); err != nil { - p.mu.Unlock() - return err - } - - // 从内存中移除无效设备 - validItems := make([]*DeviceKeyItem, 0) - for _, item := range p.items { - if item.IsValid { - validItems = append(validItems, item) - } - } - - removedCount := len(p.items) - len(validItems) - p.items = validItems - - if removedCount > 0 { - poolLogger.Info(fmt.Sprintf("已移除 %d 个无效设备", removedCount)) - - // 立即检查并补充设备(使用已持有锁的版本) - p.checkAndRefillLocked() - } - - p.mu.Unlock() - return nil -} - -// GetStats 获取池统计信息 -func (p *DeviceKeyPool) GetStats() map[string]interface{} { - p.mu.RLock() - defer p.mu.RUnlock() - - validCount := 0 - invalidCount := 0 - - for _, item := range p.items { - if item.IsValid { - validCount++ - } else { - invalidCount++ - } - } - - return map[string]interface{}{ - "enabled": p.enabled, - "total": len(p.items), - "valid": validCount, - "invalid": invalidCount, - "current_index": p.currentIndex, - } -} - -// checkAndRefill 检查并补充设备(私有方法,会自动加锁) -func (p *DeviceKeyPool) checkAndRefill() { - if !p.enabled { - return - } - - cfg := config.GetConfig() - if cfg == nil { - return - } - - p.mu.Lock() - validCount := p.getValidCount() - p.mu.Unlock() - - minSize := cfg.DeviceKeyPool.MinSize - maxSize := cfg.DeviceKeyPool.MaxSize - - if validCount < minSize { - needCount := minSize - validCount - // 限制不超过最大值 - if validCount+needCount > maxSize { - needCount = maxSize - validCount - } - - if needCount > 0 { - poolLogger.Info(fmt.Sprintf("有效设备数 %d 低于最小值 %d,开始补充 %d 个设备", validCount, minSize, needCount)) - - for i := 0; i < needCount; i++ { - if err := p.addNewDevice(); err != nil { - poolLogger.Error(fmt.Sprintf("补充设备失败: %v", err)) - } - } - } - } -} - -// checkAndRefillLocked 检查并补充设备(已持有锁的内部版本) -func (p *DeviceKeyPool) checkAndRefillLocked() { - if !p.enabled { - return - } - - cfg := config.GetConfig() - if cfg == nil { - return - } - - validCount := p.getValidCount() - minSize := cfg.DeviceKeyPool.MinSize - maxSize := cfg.DeviceKeyPool.MaxSize - - if validCount < minSize { - needCount := minSize - validCount - // 限制不超过最大值 - if validCount+needCount > maxSize { - needCount = maxSize - validCount - } - - if needCount > 0 { - poolLogger.Info(fmt.Sprintf("有效设备数 %d 低于最小值 %d,开始补充 %d 个设备", validCount, minSize, needCount)) - - // 临时释放锁,因为addNewDevice需要加锁 - p.mu.Unlock() - for i := 0; i < needCount; i++ { - if err := p.addNewDevice(); err != nil { - poolLogger.Error(fmt.Sprintf("补充设备失败: %v", err)) - } - } - p.mu.Lock() - } - } -} - -// getValidCount 获取有效设备数量(调用前需已加锁或在RLock内) -func (p *DeviceKeyPool) getValidCount() int { - count := 0 - for _, item := range p.items { - if item.IsValid { - count++ - } - } - return count -} - -// addNewDevice 添加新设备(会自动加锁) -func (p *DeviceKeyPool) addNewDevice() error { - // 注册新设备 - deviceHeader, err := Register() - if err != nil { - return fmt.Errorf("注册设备失败: %w", err) - } - - // 获取密钥 - keyInfo, err := GetKey(deviceHeader) - if err != nil { - return fmt.Errorf("获取密钥失败: %w", err) - } - - // 创建设备项 - item := &DeviceKeyItem{ - DeviceHeader: deviceHeader, - Key: keyInfo.Key, - KeyVer: keyInfo.Ver, - IsValid: true, - CreatedAt: time.Now(), - LastUsedAt: time.Now(), - } - - // 保存到数据库 - if err := p.saveDeviceToDB(item); err != nil { - return fmt.Errorf("保存设备到数据库失败: %w", err) - } - - // 添加到内存 - p.mu.Lock() - p.items = append(p.items, item) - p.mu.Unlock() - - // poolLogger.Info(fmt.Sprintf("已添加新设备 ID=%d", item.ID)) - return nil -} - -// ===== 数据库操作方法 ===== - -// loadFromDB 从数据库加载设备 -func (p *DeviceKeyPool) loadFromDB() error { - if cacheDB == nil { - return fmt.Errorf("数据库未初始化") - } - - rows, err := cacheDB.Query(` - SELECT id, device_header, key, key_ver, is_valid, created_at, last_used_at - FROM device_pool - ORDER BY id - `) - if err != nil { - return err - } - defer rows.Close() - - items := make([]*DeviceKeyItem, 0) - for rows.Next() { - var item DeviceKeyItem - var createdAt, lastUsedAt int64 - - err := rows.Scan( - &item.ID, - &item.DeviceHeader, - &item.Key, - &item.KeyVer, - &item.IsValid, - &createdAt, - &lastUsedAt, - ) - if err != nil { - poolLogger.Error("扫描行失败: " + err.Error()) - continue - } - - item.CreatedAt = time.Unix(createdAt, 0) - item.LastUsedAt = time.Unix(lastUsedAt, 0) - items = append(items, &item) - } - - p.mu.Lock() - p.items = items - p.mu.Unlock() - - poolLogger.Info(fmt.Sprintf("从数据库加载了 %d 个设备", len(items))) - return nil -} - -// boolToInt 布尔转整数存储(is_valid 列为 INTEGER,PostgreSQL 严格要求类型匹配) -func boolToInt(b bool) int { - if b { - return 1 - } - return 0 -} - -// saveDeviceToDB 保存设备到数据库 -func (p *DeviceKeyPool) saveDeviceToDB(item *DeviceKeyItem) error { - if cacheDB == nil { - return fmt.Errorf("数据库未初始化") - } - - - // RETURNING id 同时兼容 SQLite(3.35+) 与 PostgreSQL,替代 LastInsertId() - err := cacheDB.QueryRow(q(` - INSERT INTO device_pool (device_header, key, key_ver, is_valid, created_at, last_used_at) - VALUES (?, ?, ?, ?, ?, ?) - RETURNING id - `), item.DeviceHeader, item.Key, item.KeyVer, boolToInt(item.IsValid), - item.CreatedAt.Unix(), item.LastUsedAt.Unix()).Scan(&item.ID) - - return err -} - -// updateDeviceInDB 更新设备信息 -func (p *DeviceKeyPool) updateDeviceInDB(item *DeviceKeyItem) error { - if cacheDB == nil { - return fmt.Errorf("数据库未初始化") - } - - - _, err := cacheDB.Exec(q(` - UPDATE device_pool - SET device_header = ?, key = ?, key_ver = ?, is_valid = ?, last_used_at = ? - WHERE id = ? - `), item.DeviceHeader, item.Key, item.KeyVer, boolToInt(item.IsValid), - item.LastUsedAt.Unix(), item.ID) - - return err -} - -// removeInvalidFromDB 从数据库移除无效设备 -func (p *DeviceKeyPool) removeInvalidFromDB() error { - if cacheDB == nil { - return fmt.Errorf("数据库未初始化") - } - - - result, err := cacheDB.Exec(`DELETE FROM device_pool WHERE is_valid = 0`) - if err != nil { - return err - } - - affected, _ := result.RowsAffected() - if affected > 0 { - poolLogger.Info(fmt.Sprintf("从数据库删除了 %d 个无效设备", affected)) - } - - return nil -} - -// GetDeviceHeaderFromPool 从池中获取设备header(兼容旧接口) -func GetDeviceHeaderFromPool() string { - pool := GetDeviceKeyPool() - if !pool.IsEnabled() { - return "" - } - - item, err := pool.GetNext() - if err != nil { - poolLogger.Error("从池中获取设备失败: " + err.Error()) - return "" - } - - return item.DeviceHeader -} - -// GetKeyInfoFromPool 从池中获取密钥信息(兼容旧接口) -func GetKeyInfoFromPool(deviceHeader string) *KeyInfo { - pool := GetDeviceKeyPool() - if !pool.IsEnabled() { - return nil - } - - pool.mu.RLock() - defer pool.mu.RUnlock() - - for _, item := range pool.items { - if item.DeviceHeader == deviceHeader && item.IsValid { - return &KeyInfo{ - Key: item.Key, - Ver: item.KeyVer, - } - } - } - - return nil -} - -// UpdateKeyInPool 更新池中设备的密钥 -func UpdateKeyInPool(deviceHeader string, key string, ver int) error { - pool := GetDeviceKeyPool() - if !pool.IsEnabled() { - return nil - } - - pool.mu.Lock() - defer pool.mu.Unlock() - - for _, item := range pool.items { - if item.DeviceHeader == deviceHeader { - item.Key = key - item.KeyVer = ver - return pool.updateDeviceInDB(item) - } - } - - return fmt.Errorf("未找到设备") -} - -// GetAnyValidDeviceHeader 获取任意一个有效设备的header(用于辅助功能) -func GetAnyValidDeviceHeader() string { - pool := GetDeviceKeyPool() - if !pool.IsEnabled() { - return "" - } - - pool.mu.RLock() - defer pool.mu.RUnlock() - - for _, item := range pool.items { - if item.IsValid { - return item.DeviceHeader - } - } - - return "" -} - -// GetValidDevice 获取一个有效的设备 -func GetValidDevice() (*DeviceKeyItem, error) { - pool := GetDeviceKeyPool() - if !pool.IsEnabled() { - return nil, fmt.Errorf("设备池未启用") - } - return pool.GetNext() -} - -// GetCurrentDevice 获取当前使用的设备(和GetValidDevice一样,返回下一个可用设备) -func GetCurrentDevice() *DeviceKeyItem { - pool := GetDeviceKeyPool() - if !pool.IsEnabled() { - return nil - } - item, _ := pool.GetNext() - return item -} - +package fqapi + +import ( + "fanqie/internal/common" + "fanqie/internal/config" + "fmt" + "sync" + "time" +) + +var ( + poolLogger = common.NewLogger("device_key_pool", "INFO", "") + globalPool *DeviceKeyPool + poolOnce sync.Once +) + +// DeviceKeyItem 设备密钥项 +type DeviceKeyItem struct { + ID int `json:"id"` + DeviceHeader string `json:"device_header"` + Key string `json:"key"` + KeyVer int `json:"key_ver"` + IsValid bool `json:"is_valid"` + CreatedAt time.Time `json:"created_at"` + LastUsedAt time.Time `json:"last_used_at"` +} + +// DeviceKeyPool 设备密钥池 +type DeviceKeyPool struct { + mu sync.RWMutex + currentIndex int + items []*DeviceKeyItem + enabled bool +} + +// GetDeviceKeyPool 获取全局设备密钥池实例 +func GetDeviceKeyPool() *DeviceKeyPool { + poolOnce.Do(func() { + globalPool = &DeviceKeyPool{ + items: make([]*DeviceKeyItem, 0), + enabled: false, + } + }) + return globalPool +} + +// Initialize 初始化设备密钥池 +func (p *DeviceKeyPool) Initialize() error { + cfg := config.GetConfig() + if cfg == nil || !cfg.DeviceKeyPool.Enabled { + poolLogger.Info("设备密钥池未启用") + p.enabled = false + return nil + } + + p.enabled = true + poolLogger.Info("开始初始化设备密钥池...") + + // 从数据库加载已有的设备 + if err := p.loadFromDB(); err != nil { + poolLogger.Warning("从数据库加载设备失败: " + err.Error()) + } + + // 检查并补充设备到初始化数量 + validCount := p.getValidCount() + initialSize := cfg.DeviceKeyPool.InitialSize + + if validCount < initialSize { + needCount := initialSize - validCount + poolLogger.Info(fmt.Sprintf("当前有效设备数: %d, 需要补充: %d", validCount, needCount)) + + for i := 0; i < needCount; i++ { + if err := p.addNewDevice(); err != nil { + poolLogger.Error(fmt.Sprintf("添加设备失败: %v", err)) + } + } + } + + poolLogger.Info(fmt.Sprintf("设备密钥池初始化完成,当前有效设备数: %d", p.getValidCount())) + return nil +} + +// IsEnabled 检查设备密钥池是否启用 +func (p *DeviceKeyPool) IsEnabled() bool { + return p.enabled +} + +// GetNext 获取下一个可用的设备密钥(轮询方式) +func (p *DeviceKeyPool) GetNext() (*DeviceKeyItem, error) { + if !p.enabled { + return nil, fmt.Errorf("设备密钥池未启用") + } + + p.mu.Lock() + defer p.mu.Unlock() + + // 过滤有效设备 + validItems := make([]*DeviceKeyItem, 0) + for _, item := range p.items { + if item.IsValid { + validItems = append(validItems, item) + } + } + + if len(validItems) == 0 { + return nil, fmt.Errorf("没有可用的设备") + } + + // 轮询获取下一个设备 + item := validItems[p.currentIndex%len(validItems)] + p.currentIndex++ + + // 更新最后使用时间 + item.LastUsedAt = time.Now() + p.updateDeviceInDB(item) + + // poolLogger.Debug(fmt.Sprintf("使用设备 ID=%d", item.ID)) + + // 异步检查是否需要补充 + go p.checkAndRefill() + + return item, nil +} + +// MarkInvalid 标记设备为无效 +func (p *DeviceKeyPool) MarkInvalid(deviceHeader string) error { + if !p.enabled { + return nil + } + + p.mu.Lock() + defer p.mu.Unlock() + + for _, item := range p.items { + if item.DeviceHeader == deviceHeader { + item.IsValid = false + // poolLogger.Warning(fmt.Sprintf("设备 ID=%d 已标记为无效", item.ID)) + + // 更新数据库 + if err := p.updateDeviceInDB(item); err != nil { + return err + } + + // 异步补充新设备 + go p.checkAndRefill() + + return nil + } + } + + return fmt.Errorf("未找到设备") +} + +// RemoveInvalid 移除所有无效设备并自动补充 +func (p *DeviceKeyPool) RemoveInvalid() error { + if !p.enabled { + return nil + } + + p.mu.Lock() + + // 从数据库中删除无效设备 + if err := p.removeInvalidFromDB(); err != nil { + p.mu.Unlock() + return err + } + + // 从内存中移除无效设备 + validItems := make([]*DeviceKeyItem, 0) + for _, item := range p.items { + if item.IsValid { + validItems = append(validItems, item) + } + } + + removedCount := len(p.items) - len(validItems) + p.items = validItems + + if removedCount > 0 { + poolLogger.Info(fmt.Sprintf("已移除 %d 个无效设备", removedCount)) + + // 立即检查并补充设备(使用已持有锁的版本) + p.checkAndRefillLocked() + } + + p.mu.Unlock() + return nil +} + +// GetStats 获取池统计信息 +func (p *DeviceKeyPool) GetStats() map[string]interface{} { + p.mu.RLock() + defer p.mu.RUnlock() + + validCount := 0 + invalidCount := 0 + + for _, item := range p.items { + if item.IsValid { + validCount++ + } else { + invalidCount++ + } + } + + return map[string]interface{}{ + "enabled": p.enabled, + "total": len(p.items), + "valid": validCount, + "invalid": invalidCount, + "current_index": p.currentIndex, + } +} + +// checkAndRefill 检查并补充设备(私有方法,会自动加锁) +func (p *DeviceKeyPool) checkAndRefill() { + if !p.enabled { + return + } + + cfg := config.GetConfig() + if cfg == nil { + return + } + + p.mu.Lock() + validCount := p.getValidCount() + p.mu.Unlock() + + minSize := cfg.DeviceKeyPool.MinSize + maxSize := cfg.DeviceKeyPool.MaxSize + + if validCount < minSize { + needCount := minSize - validCount + // 限制不超过最大值 + if validCount+needCount > maxSize { + needCount = maxSize - validCount + } + + if needCount > 0 { + poolLogger.Info(fmt.Sprintf("有效设备数 %d 低于最小值 %d,开始补充 %d 个设备", validCount, minSize, needCount)) + + for i := 0; i < needCount; i++ { + if err := p.addNewDevice(); err != nil { + poolLogger.Error(fmt.Sprintf("补充设备失败: %v", err)) + } + } + } + } +} + +// checkAndRefillLocked 检查并补充设备(已持有锁的内部版本) +func (p *DeviceKeyPool) checkAndRefillLocked() { + if !p.enabled { + return + } + + cfg := config.GetConfig() + if cfg == nil { + return + } + + validCount := p.getValidCount() + minSize := cfg.DeviceKeyPool.MinSize + maxSize := cfg.DeviceKeyPool.MaxSize + + if validCount < minSize { + needCount := minSize - validCount + // 限制不超过最大值 + if validCount+needCount > maxSize { + needCount = maxSize - validCount + } + + if needCount > 0 { + poolLogger.Info(fmt.Sprintf("有效设备数 %d 低于最小值 %d,开始补充 %d 个设备", validCount, minSize, needCount)) + + // 临时释放锁,因为addNewDevice需要加锁 + p.mu.Unlock() + for i := 0; i < needCount; i++ { + if err := p.addNewDevice(); err != nil { + poolLogger.Error(fmt.Sprintf("补充设备失败: %v", err)) + } + } + p.mu.Lock() + } + } +} + +// getValidCount 获取有效设备数量(调用前需已加锁或在RLock内) +func (p *DeviceKeyPool) getValidCount() int { + count := 0 + for _, item := range p.items { + if item.IsValid { + count++ + } + } + return count +} + +// addNewDevice 添加新设备(会自动加锁) +func (p *DeviceKeyPool) addNewDevice() error { + // 注册新设备 + deviceHeader, err := Register() + if err != nil { + return fmt.Errorf("注册设备失败: %w", err) + } + + // 获取密钥 + keyInfo, err := GetKey(deviceHeader) + if err != nil { + return fmt.Errorf("获取密钥失败: %w", err) + } + + // 创建设备项 + item := &DeviceKeyItem{ + DeviceHeader: deviceHeader, + Key: keyInfo.Key, + KeyVer: keyInfo.Ver, + IsValid: true, + CreatedAt: time.Now(), + LastUsedAt: time.Now(), + } + + // 保存到数据库 + if err := p.saveDeviceToDB(item); err != nil { + return fmt.Errorf("保存设备到数据库失败: %w", err) + } + + // 添加到内存 + p.mu.Lock() + p.items = append(p.items, item) + p.mu.Unlock() + + // poolLogger.Info(fmt.Sprintf("已添加新设备 ID=%d", item.ID)) + return nil +} + +// ===== 数据库操作方法 ===== + +// loadFromDB 从数据库加载设备 +func (p *DeviceKeyPool) loadFromDB() error { + if cacheDB == nil { + return fmt.Errorf("数据库未初始化") + } + + rows, err := cacheDB.Query(` + SELECT id, device_header, key, key_ver, is_valid, created_at, last_used_at + FROM device_pool + ORDER BY id + `) + if err != nil { + return err + } + defer rows.Close() + + items := make([]*DeviceKeyItem, 0) + for rows.Next() { + var item DeviceKeyItem + var createdAt, lastUsedAt int64 + + err := rows.Scan( + &item.ID, + &item.DeviceHeader, + &item.Key, + &item.KeyVer, + &item.IsValid, + &createdAt, + &lastUsedAt, + ) + if err != nil { + poolLogger.Error("扫描行失败: " + err.Error()) + continue + } + + item.CreatedAt = time.Unix(createdAt, 0) + item.LastUsedAt = time.Unix(lastUsedAt, 0) + items = append(items, &item) + } + + p.mu.Lock() + p.items = items + p.mu.Unlock() + + poolLogger.Info(fmt.Sprintf("从数据库加载了 %d 个设备", len(items))) + return nil +} + +// boolToInt 布尔转整数存储(is_valid 列为 INTEGER,PostgreSQL 严格要求类型匹配) +func boolToInt(b bool) int { + if b { + return 1 + } + return 0 +} + +// saveDeviceToDB 保存设备到数据库 +func (p *DeviceKeyPool) saveDeviceToDB(item *DeviceKeyItem) error { + if cacheDB == nil { + return fmt.Errorf("数据库未初始化") + } + + // RETURNING id 同时兼容 SQLite(3.35+) 与 PostgreSQL,替代 LastInsertId() + err := cacheDB.QueryRow(q(` + INSERT INTO device_pool (device_header, key, key_ver, is_valid, created_at, last_used_at) + VALUES (?, ?, ?, ?, ?, ?) + RETURNING id + `), item.DeviceHeader, item.Key, item.KeyVer, boolToInt(item.IsValid), + item.CreatedAt.Unix(), item.LastUsedAt.Unix()).Scan(&item.ID) + + return err +} + +// updateDeviceInDB 更新设备信息 +func (p *DeviceKeyPool) updateDeviceInDB(item *DeviceKeyItem) error { + if cacheDB == nil { + return fmt.Errorf("数据库未初始化") + } + + _, err := cacheDB.Exec(q(` + UPDATE device_pool + SET device_header = ?, key = ?, key_ver = ?, is_valid = ?, last_used_at = ? + WHERE id = ? + `), item.DeviceHeader, item.Key, item.KeyVer, boolToInt(item.IsValid), + item.LastUsedAt.Unix(), item.ID) + + return err +} + +// removeInvalidFromDB 从数据库移除无效设备 +func (p *DeviceKeyPool) removeInvalidFromDB() error { + if cacheDB == nil { + return fmt.Errorf("数据库未初始化") + } + + result, err := cacheDB.Exec(`DELETE FROM device_pool WHERE is_valid = 0`) + if err != nil { + return err + } + + affected, _ := result.RowsAffected() + if affected > 0 { + poolLogger.Info(fmt.Sprintf("从数据库删除了 %d 个无效设备", affected)) + } + + return nil +} + +// GetDeviceHeaderFromPool 从池中获取设备header(兼容旧接口) +func GetDeviceHeaderFromPool() string { + pool := GetDeviceKeyPool() + if !pool.IsEnabled() { + return "" + } + + item, err := pool.GetNext() + if err != nil { + poolLogger.Error("从池中获取设备失败: " + err.Error()) + return "" + } + + return item.DeviceHeader +} + +// GetKeyInfoFromPool 从池中获取密钥信息(兼容旧接口) +func GetKeyInfoFromPool(deviceHeader string) *KeyInfo { + pool := GetDeviceKeyPool() + if !pool.IsEnabled() { + return nil + } + + pool.mu.RLock() + defer pool.mu.RUnlock() + + for _, item := range pool.items { + if item.DeviceHeader == deviceHeader && item.IsValid { + return &KeyInfo{ + Key: item.Key, + Ver: item.KeyVer, + } + } + } + + return nil +} + +// UpdateKeyInPool 更新池中设备的密钥 +func UpdateKeyInPool(deviceHeader string, key string, ver int) error { + pool := GetDeviceKeyPool() + if !pool.IsEnabled() { + return nil + } + + pool.mu.Lock() + defer pool.mu.Unlock() + + for _, item := range pool.items { + if item.DeviceHeader == deviceHeader { + item.Key = key + item.KeyVer = ver + return pool.updateDeviceInDB(item) + } + } + + return fmt.Errorf("未找到设备") +} + +// GetAnyValidDeviceHeader 获取任意一个有效设备的header(用于辅助功能) +func GetAnyValidDeviceHeader() string { + pool := GetDeviceKeyPool() + if !pool.IsEnabled() { + return "" + } + + pool.mu.RLock() + defer pool.mu.RUnlock() + + for _, item := range pool.items { + if item.IsValid { + return item.DeviceHeader + } + } + + return "" +} + +// GetValidDevice 获取一个有效的设备 +func GetValidDevice() (*DeviceKeyItem, error) { + pool := GetDeviceKeyPool() + if !pool.IsEnabled() { + return nil, fmt.Errorf("设备池未启用") + } + return pool.GetNext() +} + +// GetCurrentDevice 获取当前使用的设备(和GetValidDevice一样,返回下一个可用设备) +func GetCurrentDevice() *DeviceKeyItem { + pool := GetDeviceKeyPool() + if !pool.IsEnabled() { + return nil + } + item, _ := pool.GetNext() + return item +} diff --git a/internal/fqapi/device_register.go b/internal/fqapi/device_register.go index d127a20..c2002d5 100644 --- a/internal/fqapi/device_register.go +++ b/internal/fqapi/device_register.go @@ -40,7 +40,7 @@ func currentMillis() int64 { // custom_bt = current_millis - elapsed_ms_since_boot func getCustomBT() string { var uptimeSeconds float64 - + if runtime.GOOS == "linux" { // 在Linux上读取/proc/uptime data, err := os.ReadFile("/proc/uptime") @@ -54,7 +54,7 @@ func getCustomBT() string { } } } - + // 如果读取失败,使用time.monotonic作为备选 if uptimeSeconds == 0 { // Go没有直接的monotonic API,使用启动时间估算 @@ -65,17 +65,17 @@ func getCustomBT() string { // 这里可以使用进程启动时间作为近似 uptimeSeconds = float64(time.Now().Unix() % 86400) } - + return fmt.Sprintf("%d", currentMillis()-int64(uptimeSeconds*1000)) } // generateDeviceInfo 随机生成设备及操作系统信息 func generateDeviceInfo() map[string]interface{} { osVersion := androidVersions[randInt(len(androidVersions))] - + // 随机选择品牌 deviceBrand := deviceBrands[randInt(len(deviceBrands))] - + // 根据品牌选择对应的型号 models := brandModels[deviceBrand] deviceModel := models[randInt(len(models))] @@ -88,19 +88,19 @@ func generateDeviceInfo() map[string]interface{} { rand.Read(openUDIDBytes) return map[string]interface{}{ - "os": "Android", - "os_version": osVersion[0], - "os_api": osVersion[1], - "device_model": deviceModel, - "device_brand": deviceBrand, - "device_manufacturer": deviceBrand, - "cpu_abi": "arm64-v8a", - "release_build": releaseBuild, - "cdid": generateUUID(), - "sig_hash": "a4a27c2633195374c15651ffc3c4a497", - "openudid": hex.EncodeToString(openUDIDBytes), - "clientudid": generateUUID(), - "req_id": generateUUID(), + "os": "Android", + "os_version": osVersion[0], + "os_api": osVersion[1], + "device_model": deviceModel, + "device_brand": deviceBrand, + "device_manufacturer": deviceBrand, + "cpu_abi": "arm64-v8a", + "release_build": releaseBuild, + "cdid": generateUUID(), + "sig_hash": "a4a27c2633195374c15651ffc3c4a497", + "openudid": hex.EncodeToString(openUDIDBytes), + "clientudid": generateUUID(), + "req_id": generateUUID(), } } @@ -130,9 +130,9 @@ func generateOptionalFields() (map[string]interface{}, map[string]int64) { genTime := currentMillis() return map[string]interface{}{ - "rom": rom, - "rom_version": fmt.Sprintf("coloros__%s.%s.%s release-keys", part1, part2, part3), - "apk_first_install_time": genTime - int64(randInt(365))*86400000, + "rom": rom, + "rom_version": fmt.Sprintf("coloros__%s.%s.%s release-keys", part1, part2, part3), + "apk_first_install_time": genTime - int64(randInt(365))*86400000, }, map[string]int64{ "_gen_time": genTime, } @@ -307,7 +307,7 @@ func Register() (string, error) { // 生成完整请求体数据 data := GenerateFullRequestBody() - + // 只记录关键设备信息 header := data["header"].(map[string]interface{}) logger.Info(fmt.Sprintf("生成设备信息 - 品牌: %s, 型号: %s, 系统: Android %v", @@ -370,7 +370,7 @@ func Register() (string, error) { deviceModel, respData["install_id_str"]) - logger.Info("设备注册成功!" ) + logger.Info("设备注册成功!") // 尝试激活特权 tryActivatePremium(header) @@ -381,4 +381,3 @@ func Register() (string, error) { logger.Warning("设备注册失败!") return "", fmt.Errorf("设备注册失败") } - diff --git a/internal/fqapi/dhcry.go b/internal/fqapi/dhcry.go index 10589a8..8efd71a 100644 --- a/internal/fqapi/dhcry.go +++ b/internal/fqapi/dhcry.go @@ -1,185 +1,184 @@ -package fqapi - -import ( - "crypto/aes" - "crypto/cipher" - "crypto/rand" - "encoding/base64" - "fmt" - "math/big" -) - -// CM 加密管理器 -type CM struct { - bigC *big.Int // 大素数C - bigB int64 // 底数B - arryD []byte // 固定密钥 - thisD [2]*big.Int // 密钥对(私钥, 公钥) - thisC []byte // 随机IV -} - -// NewCM 创建新的CM实例 -func NewCM() *CM { - // 大素数C - bigCStr := "2410312426921032588552076022197566074856950548502459942654116941958108831682612228890093858261341614673227141477904012196503648957050582631942730706805009223062734745341073406696246014589361659774041027169249453200378729434170325843778659198143763193776859869524088940195577346119843545301547043747207749969763750084308926339295559968882457872412993810129130294592999947926365264059284647209730384947211681434464714438488520940127459844288859336526896320919633919" - bigC := new(big.Int) - bigC.SetString(bigCStr, 10) - - // 固定密钥(base64解码) - arryD, _ := base64.StdEncoding.DecodeString("rCXGfd2POMGzeiNIgo4iLg==") - - return &CM{ - bigC: bigC, - bigB: 2, - arryD: arryD, - } -} - -// generateKeyPair 生成密钥对 -func (cm *CM) generateKeyPair() [2]*big.Int { - // 生成随机私钥 - randomBytes := make([]byte, 32) - rand.Read(randomBytes) - - x := new(big.Int).SetBytes(randomBytes) - x = x.Mod(x, new(big.Int).Sub(cm.bigC, big.NewInt(1))) - - // 计算公钥: y = b^x mod c - y := new(big.Int).Exp(big.NewInt(cm.bigB), x, cm.bigC) - - cm.thisD = [2]*big.Int{x, y} - return cm.thisD -} - -// pairToBytes 将公钥转换为字节数组 -func (cm *CM) pairToBytes(keyPair [2]*big.Int) []byte { - return keyPair[1].Bytes() -} - -// randIV 生成随机IV -func (cm *CM) randIV() []byte { - iv := make([]byte, 16) - rand.Read(iv) - return iv -} - -// encodeAES AES-CBC加密 -func (cm *CM) encodeAES(key, iv, data []byte) ([]byte, error) { - block, err := aes.NewCipher(key) - if err != nil { - return nil, err - } - - // PKCS7填充 - padding := aes.BlockSize - len(data)%aes.BlockSize - padText := make([]byte, padding) - for i := range padText { - padText[i] = byte(padding) - } - paddedData := append(data, padText...) - - ciphertext := make([]byte, len(paddedData)) - mode := cipher.NewCBCEncrypter(block, iv) - mode.CryptBlocks(ciphertext, paddedData) - - return ciphertext, nil -} - -// decodeAES AES-CBC解密 -func (cm *CM) decodeAES(key, iv, data []byte) ([]byte, error) { - block, err := aes.NewCipher(key) - if err != nil { - return nil, err - } - - mode := cipher.NewCBCDecrypter(block, iv) - decrypted := make([]byte, len(data)) - mode.CryptBlocks(decrypted, data) - - // 移除PKCS7填充 - padding := int(decrypted[len(decrypted)-1]) - if padding > len(decrypted) || padding > aes.BlockSize { - return nil, fmt.Errorf("无效的填充") - } - - return decrypted[:len(decrypted)-padding], nil -} - -// ClientHandshake 客户端握手,生成请求头y -func (cm *CM) ClientHandshake() string { - keyPair := cm.generateKeyPair() - cm.thisC = cm.randIV() - - publicKeyBytes := cm.pairToBytes(keyPair) - encrypted, _ := cm.encodeAES(cm.arryD, cm.thisC, publicKeyBytes) - - combined := append(cm.thisC, encrypted...) - return base64.StdEncoding.EncodeToString(combined) -} - -// decryptF 解密函数 -func (cm *CM) decryptF(peerPublicKey []byte, keyPair [2]*big.Int, iv, data []byte) ([]byte, error) { - x := keyPair[0] - - // 计算共享密钥: shared_secret = peer_public_key^x mod c - peerPubInt := new(big.Int).SetBytes(peerPublicKey) - sharedSecret := new(big.Int).Exp(peerPubInt, x, cm.bigC) - - key := sharedSecret.Bytes() - // 确保key长度为32字节 - if len(key) < 32 { - // 前面补0 - paddedKey := make([]byte, 32) - copy(paddedKey[32-len(key):], key) - key = paddedKey - } else if len(key) > 32 { - key = key[:32] - } - - return cm.decodeAES(key, iv, data) -} - -// Decrypt 解密响应内容 -func (cm *CM) Decrypt(c, y, content string) (string, error) { - // c 应该为 "1" 表示启用加密 - // y 是服务器返回的公钥 - // content 是加密的内容 - - if c != "1" || y == "" { - return content, nil - } - - // 解码content - contentDecoded, err := base64.StdEncoding.DecodeString(content) - if err != nil { - return "", err - } - - if len(contentDecoded) < 16 { - return "", fmt.Errorf("内容太短") - } - - // 提取IV和数据 - iv := contentDecoded[:16] - data := contentDecoded[16:] - - // 解码服务器公钥 - peerPublicKey, err := base64.StdEncoding.DecodeString(y) - if err != nil { - return "", err - } - - // 检查密钥对是否已初始化 - if cm.thisD[0] == nil { - return "", fmt.Errorf("密钥对未初始化") - } - - // 解密 - decrypted, err := cm.decryptF(peerPublicKey, cm.thisD, iv, data) - if err != nil { - return "", err - } - - return string(decrypted), nil -} - +package fqapi + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "fmt" + "math/big" +) + +// CM 加密管理器 +type CM struct { + bigC *big.Int // 大素数C + bigB int64 // 底数B + arryD []byte // 固定密钥 + thisD [2]*big.Int // 密钥对(私钥, 公钥) + thisC []byte // 随机IV +} + +// NewCM 创建新的CM实例 +func NewCM() *CM { + // 大素数C + bigCStr := "2410312426921032588552076022197566074856950548502459942654116941958108831682612228890093858261341614673227141477904012196503648957050582631942730706805009223062734745341073406696246014589361659774041027169249453200378729434170325843778659198143763193776859869524088940195577346119843545301547043747207749969763750084308926339295559968882457872412993810129130294592999947926365264059284647209730384947211681434464714438488520940127459844288859336526896320919633919" + bigC := new(big.Int) + bigC.SetString(bigCStr, 10) + + // 固定密钥(base64解码) + arryD, _ := base64.StdEncoding.DecodeString("rCXGfd2POMGzeiNIgo4iLg==") + + return &CM{ + bigC: bigC, + bigB: 2, + arryD: arryD, + } +} + +// generateKeyPair 生成密钥对 +func (cm *CM) generateKeyPair() [2]*big.Int { + // 生成随机私钥 + randomBytes := make([]byte, 32) + rand.Read(randomBytes) + + x := new(big.Int).SetBytes(randomBytes) + x = x.Mod(x, new(big.Int).Sub(cm.bigC, big.NewInt(1))) + + // 计算公钥: y = b^x mod c + y := new(big.Int).Exp(big.NewInt(cm.bigB), x, cm.bigC) + + cm.thisD = [2]*big.Int{x, y} + return cm.thisD +} + +// pairToBytes 将公钥转换为字节数组 +func (cm *CM) pairToBytes(keyPair [2]*big.Int) []byte { + return keyPair[1].Bytes() +} + +// randIV 生成随机IV +func (cm *CM) randIV() []byte { + iv := make([]byte, 16) + rand.Read(iv) + return iv +} + +// encodeAES AES-CBC加密 +func (cm *CM) encodeAES(key, iv, data []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + + // PKCS7填充 + padding := aes.BlockSize - len(data)%aes.BlockSize + padText := make([]byte, padding) + for i := range padText { + padText[i] = byte(padding) + } + paddedData := append(data, padText...) + + ciphertext := make([]byte, len(paddedData)) + mode := cipher.NewCBCEncrypter(block, iv) + mode.CryptBlocks(ciphertext, paddedData) + + return ciphertext, nil +} + +// decodeAES AES-CBC解密 +func (cm *CM) decodeAES(key, iv, data []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + + mode := cipher.NewCBCDecrypter(block, iv) + decrypted := make([]byte, len(data)) + mode.CryptBlocks(decrypted, data) + + // 移除PKCS7填充 + padding := int(decrypted[len(decrypted)-1]) + if padding > len(decrypted) || padding > aes.BlockSize { + return nil, fmt.Errorf("无效的填充") + } + + return decrypted[:len(decrypted)-padding], nil +} + +// ClientHandshake 客户端握手,生成请求头y +func (cm *CM) ClientHandshake() string { + keyPair := cm.generateKeyPair() + cm.thisC = cm.randIV() + + publicKeyBytes := cm.pairToBytes(keyPair) + encrypted, _ := cm.encodeAES(cm.arryD, cm.thisC, publicKeyBytes) + + combined := append(cm.thisC, encrypted...) + return base64.StdEncoding.EncodeToString(combined) +} + +// decryptF 解密函数 +func (cm *CM) decryptF(peerPublicKey []byte, keyPair [2]*big.Int, iv, data []byte) ([]byte, error) { + x := keyPair[0] + + // 计算共享密钥: shared_secret = peer_public_key^x mod c + peerPubInt := new(big.Int).SetBytes(peerPublicKey) + sharedSecret := new(big.Int).Exp(peerPubInt, x, cm.bigC) + + key := sharedSecret.Bytes() + // 确保key长度为32字节 + if len(key) < 32 { + // 前面补0 + paddedKey := make([]byte, 32) + copy(paddedKey[32-len(key):], key) + key = paddedKey + } else if len(key) > 32 { + key = key[:32] + } + + return cm.decodeAES(key, iv, data) +} + +// Decrypt 解密响应内容 +func (cm *CM) Decrypt(c, y, content string) (string, error) { + // c 应该为 "1" 表示启用加密 + // y 是服务器返回的公钥 + // content 是加密的内容 + + if c != "1" || y == "" { + return content, nil + } + + // 解码content + contentDecoded, err := base64.StdEncoding.DecodeString(content) + if err != nil { + return "", err + } + + if len(contentDecoded) < 16 { + return "", fmt.Errorf("内容太短") + } + + // 提取IV和数据 + iv := contentDecoded[:16] + data := contentDecoded[16:] + + // 解码服务器公钥 + peerPublicKey, err := base64.StdEncoding.DecodeString(y) + if err != nil { + return "", err + } + + // 检查密钥对是否已初始化 + if cm.thisD[0] == nil { + return "", fmt.Errorf("密钥对未初始化") + } + + // 解密 + decrypted, err := cm.decryptF(peerPublicKey, cm.thisD, iv, data) + if err != nil { + return "", err + } + + return string(decrypted), nil +} diff --git a/internal/fqapi/emoji.go b/internal/fqapi/emoji.go index 3f5c756..2c760c0 100644 --- a/internal/fqapi/emoji.go +++ b/internal/fqapi/emoji.go @@ -1,230 +1,229 @@ -package fqapi - -import ( - "encoding/json" - "fmt" - "io/ioutil" - "net/http" - "os" - "path/filepath" - "strings" - "sync" - "time" -) - -// EmojiManager 表情管理器 -type EmojiManager struct { - emojiMap map[string]string // 表情文本 -> 图片URL的映射 - mutex sync.RWMutex - lastUpdate time.Time - cacheExpiry time.Duration -} - -// DouyinEmojiResponse 抖音表情API响应结构 -type DouyinEmojiResponse struct { - EmojiList []DouyinEmoji `json:"emoji_list"` -} - -// DouyinEmoji 抖音单个表情结构 -type DouyinEmoji struct { - OriginURI string `json:"origin_uri"` - DisplayName string `json:"display_name"` - Hide int `json:"hide"` - EmojiURL struct { - URI string `json:"uri"` - URLList []string `json:"url_list"` - } `json:"emoji_url"` -} - -// LocalEmojiData 本地表情数据结构 -type LocalEmojiData struct { - Version int `json:"version"` - Size int `json:"size"` - Datas map[string]string `json:"datas"` -} - -var ( - globalEmojiManager *EmojiManager - emojiOnce sync.Once -) - -// GetEmojiManager 获取全局表情管理器实例 -func GetEmojiManager() *EmojiManager { - emojiOnce.Do(func() { - globalEmojiManager = &EmojiManager{ - emojiMap: make(map[string]string), - cacheExpiry: 24 * time.Hour, // 缓存24小时 - } - // 初始化时加载表情 - globalEmojiManager.LoadEmojis() - }) - return globalEmojiManager -} - -// LoadEmojis 加载表情映射(优先API,其次本地) -func (em *EmojiManager) LoadEmojis() error { - em.mutex.Lock() - defer em.mutex.Unlock() - - // 检查缓存是否过期 - if time.Since(em.lastUpdate) < em.cacheExpiry && len(em.emojiMap) > 0 { - return nil - } - - newMap := make(map[string]string) - - // 1. 先尝试从抖音API获取表情 - apiEmojis, err := em.fetchDouyinEmojis() - if err == nil && len(apiEmojis) > 0 { - for displayName, url := range apiEmojis { - newMap[displayName] = url - } - } - - // 2. 加载本地表情作为补充 - localEmojis, err := em.loadLocalEmojis() - if err == nil { - for displayName, url := range localEmojis { - // 如果API中没有,则使用本地的 - if _, exists := newMap[displayName]; !exists { - newMap[displayName] = url - } - } - } - - // 3. 更新缓存 - if len(newMap) > 0 { - em.emojiMap = newMap - em.lastUpdate = time.Now() - } - - return nil -} - -// fetchDouyinEmojis 从抖音API获取表情映射 -func (em *EmojiManager) fetchDouyinEmojis() (map[string]string, error) { - client := &http.Client{ - Timeout: 10 * time.Second, - } - - resp, err := client.Get("https://www.douyin.com/aweme/v1/web/emoji/list") - if err != nil { - return nil, fmt.Errorf("获取抖音表情API失败: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("抖音表情API返回状态码: %d", resp.StatusCode) - } - - body, err := ioutil.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("读取响应失败: %w", err) - } - - var apiResp DouyinEmojiResponse - if err := json.Unmarshal(body, &apiResp); err != nil { - return nil, fmt.Errorf("解析JSON失败: %w", err) - } - - emojiMap := make(map[string]string) - for _, emoji := range apiResp.EmojiList { - if len(emoji.EmojiURL.URLList) > 0 { - // 使用第一个URL - emojiMap[emoji.DisplayName] = emoji.EmojiURL.URLList[0] - } - } - - return emojiMap, nil -} - -// loadLocalEmojis 从本地文件加载表情映射 -func (em *EmojiManager) loadLocalEmojis() (map[string]string, error) { - // 读取 data.json - dataPath := filepath.Join("emoji", "data.json") - data, err := ioutil.ReadFile(dataPath) - if err != nil { - return nil, fmt.Errorf("读取emoji/data.json失败: %w", err) - } - - var localData LocalEmojiData - if err := json.Unmarshal(data, &localData); err != nil { - return nil, fmt.Errorf("解析emoji/data.json失败: %w", err) - } - - emojiMap := make(map[string]string) - for id, displayName := range localData.Datas { - // 生成本地图片路径:emoji/images/emoji_dr_{id}.png - // 转换为相对URL路径 - localPath := fmt.Sprintf("/emoji/images/emoji_dr_%s.png", id) - emojiMap[displayName] = localPath - } - - return emojiMap, nil -} - -// GetEmojiURL 获取表情图片URL -func (em *EmojiManager) GetEmojiURL(displayName string) (string, bool) { - em.mutex.RLock() - defer em.mutex.RUnlock() - - url, exists := em.emojiMap[displayName] - return url, exists -} - -// GetAllEmojis 获取所有表情映射(用于生成JavaScript代码) -func (em *EmojiManager) GetAllEmojis() map[string]string { - em.mutex.RLock() - defer em.mutex.RUnlock() - - // 返回副本 - result := make(map[string]string, len(em.emojiMap)) - for k, v := range em.emojiMap { - result[k] = v - } - return result -} - -// RefreshEmojis 手动刷新表情缓存 -func (em *EmojiManager) RefreshEmojis() error { - em.mutex.Lock() - em.lastUpdate = time.Time{} // 重置更新时间 - em.mutex.Unlock() - - return em.LoadEmojis() -} - -// CheckLocalEmojiFiles 检查本地表情文件是否存在 -func CheckLocalEmojiFiles() error { - dataPath := filepath.Join("emoji", "data.json") - if _, err := os.Stat(dataPath); os.IsNotExist(err) { - return fmt.Errorf("emoji/data.json 文件不存在") - } - - imagesDir := filepath.Join("emoji", "images") - if _, err := os.Stat(imagesDir); os.IsNotExist(err) { - return fmt.Errorf("emoji/images 目录不存在") - } - - return nil -} - -// ReplaceEmojisInText 替换文本中的表情为HTML img标签 -func ReplaceEmojisInText(text string) string { - em := GetEmojiManager() - - // 匹配 [表情] 格式 - result := text - for displayName, url := range em.GetAllEmojis() { - // 转义特殊字符 - escapedName := strings.ReplaceAll(displayName, "[", "\\[") - escapedName = strings.ReplaceAll(escapedName, "]", "\\]") - - // 替换为 img 标签 - imgTag := fmt.Sprintf(`%s`, url, displayName) - result = strings.ReplaceAll(result, displayName, imgTag) - } - - return result -} - +package fqapi + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +// EmojiManager 表情管理器 +type EmojiManager struct { + emojiMap map[string]string // 表情文本 -> 图片URL的映射 + mutex sync.RWMutex + lastUpdate time.Time + cacheExpiry time.Duration +} + +// DouyinEmojiResponse 抖音表情API响应结构 +type DouyinEmojiResponse struct { + EmojiList []DouyinEmoji `json:"emoji_list"` +} + +// DouyinEmoji 抖音单个表情结构 +type DouyinEmoji struct { + OriginURI string `json:"origin_uri"` + DisplayName string `json:"display_name"` + Hide int `json:"hide"` + EmojiURL struct { + URI string `json:"uri"` + URLList []string `json:"url_list"` + } `json:"emoji_url"` +} + +// LocalEmojiData 本地表情数据结构 +type LocalEmojiData struct { + Version int `json:"version"` + Size int `json:"size"` + Datas map[string]string `json:"datas"` +} + +var ( + globalEmojiManager *EmojiManager + emojiOnce sync.Once +) + +// GetEmojiManager 获取全局表情管理器实例 +func GetEmojiManager() *EmojiManager { + emojiOnce.Do(func() { + globalEmojiManager = &EmojiManager{ + emojiMap: make(map[string]string), + cacheExpiry: 24 * time.Hour, // 缓存24小时 + } + // 初始化时加载表情 + globalEmojiManager.LoadEmojis() + }) + return globalEmojiManager +} + +// LoadEmojis 加载表情映射(优先API,其次本地) +func (em *EmojiManager) LoadEmojis() error { + em.mutex.Lock() + defer em.mutex.Unlock() + + // 检查缓存是否过期 + if time.Since(em.lastUpdate) < em.cacheExpiry && len(em.emojiMap) > 0 { + return nil + } + + newMap := make(map[string]string) + + // 1. 先尝试从抖音API获取表情 + apiEmojis, err := em.fetchDouyinEmojis() + if err == nil && len(apiEmojis) > 0 { + for displayName, url := range apiEmojis { + newMap[displayName] = url + } + } + + // 2. 加载本地表情作为补充 + localEmojis, err := em.loadLocalEmojis() + if err == nil { + for displayName, url := range localEmojis { + // 如果API中没有,则使用本地的 + if _, exists := newMap[displayName]; !exists { + newMap[displayName] = url + } + } + } + + // 3. 更新缓存 + if len(newMap) > 0 { + em.emojiMap = newMap + em.lastUpdate = time.Now() + } + + return nil +} + +// fetchDouyinEmojis 从抖音API获取表情映射 +func (em *EmojiManager) fetchDouyinEmojis() (map[string]string, error) { + client := &http.Client{ + Timeout: 10 * time.Second, + } + + resp, err := client.Get("https://www.douyin.com/aweme/v1/web/emoji/list") + if err != nil { + return nil, fmt.Errorf("获取抖音表情API失败: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("抖音表情API返回状态码: %d", resp.StatusCode) + } + + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("读取响应失败: %w", err) + } + + var apiResp DouyinEmojiResponse + if err := json.Unmarshal(body, &apiResp); err != nil { + return nil, fmt.Errorf("解析JSON失败: %w", err) + } + + emojiMap := make(map[string]string) + for _, emoji := range apiResp.EmojiList { + if len(emoji.EmojiURL.URLList) > 0 { + // 使用第一个URL + emojiMap[emoji.DisplayName] = emoji.EmojiURL.URLList[0] + } + } + + return emojiMap, nil +} + +// loadLocalEmojis 从本地文件加载表情映射 +func (em *EmojiManager) loadLocalEmojis() (map[string]string, error) { + // 读取 data.json + dataPath := filepath.Join("emoji", "data.json") + data, err := ioutil.ReadFile(dataPath) + if err != nil { + return nil, fmt.Errorf("读取emoji/data.json失败: %w", err) + } + + var localData LocalEmojiData + if err := json.Unmarshal(data, &localData); err != nil { + return nil, fmt.Errorf("解析emoji/data.json失败: %w", err) + } + + emojiMap := make(map[string]string) + for id, displayName := range localData.Datas { + // 生成本地图片路径:emoji/images/emoji_dr_{id}.png + // 转换为相对URL路径 + localPath := fmt.Sprintf("/emoji/images/emoji_dr_%s.png", id) + emojiMap[displayName] = localPath + } + + return emojiMap, nil +} + +// GetEmojiURL 获取表情图片URL +func (em *EmojiManager) GetEmojiURL(displayName string) (string, bool) { + em.mutex.RLock() + defer em.mutex.RUnlock() + + url, exists := em.emojiMap[displayName] + return url, exists +} + +// GetAllEmojis 获取所有表情映射(用于生成JavaScript代码) +func (em *EmojiManager) GetAllEmojis() map[string]string { + em.mutex.RLock() + defer em.mutex.RUnlock() + + // 返回副本 + result := make(map[string]string, len(em.emojiMap)) + for k, v := range em.emojiMap { + result[k] = v + } + return result +} + +// RefreshEmojis 手动刷新表情缓存 +func (em *EmojiManager) RefreshEmojis() error { + em.mutex.Lock() + em.lastUpdate = time.Time{} // 重置更新时间 + em.mutex.Unlock() + + return em.LoadEmojis() +} + +// CheckLocalEmojiFiles 检查本地表情文件是否存在 +func CheckLocalEmojiFiles() error { + dataPath := filepath.Join("emoji", "data.json") + if _, err := os.Stat(dataPath); os.IsNotExist(err) { + return fmt.Errorf("emoji/data.json 文件不存在") + } + + imagesDir := filepath.Join("emoji", "images") + if _, err := os.Stat(imagesDir); os.IsNotExist(err) { + return fmt.Errorf("emoji/images 目录不存在") + } + + return nil +} + +// ReplaceEmojisInText 替换文本中的表情为HTML img标签 +func ReplaceEmojisInText(text string) string { + em := GetEmojiManager() + + // 匹配 [表情] 格式 + result := text + for displayName, url := range em.GetAllEmojis() { + // 转义特殊字符 + escapedName := strings.ReplaceAll(displayName, "[", "\\[") + escapedName = strings.ReplaceAll(escapedName, "]", "\\]") + + // 替换为 img 标签 + imgTag := fmt.Sprintf(`%s`, url, displayName) + result = strings.ReplaceAll(result, displayName, imgTag) + } + + return result +} diff --git a/internal/fqapi/gorgon_utils.go b/internal/fqapi/gorgon_utils.go index 0207359..593f121 100644 --- a/internal/fqapi/gorgon_utils.go +++ b/internal/fqapi/gorgon_utils.go @@ -114,4 +114,3 @@ func ContentRequest(urlStr string, options map[string]interface{}) (*common.Clie client := common.NewHTTPClient() return client.Request(urlStr, options) } - diff --git a/internal/fqapi/item_summary.go b/internal/fqapi/item_summary.go index d2f54cd..d9946dc 100644 --- a/internal/fqapi/item_summary.go +++ b/internal/fqapi/item_summary.go @@ -9,7 +9,7 @@ var itemSummaryLogger = common.NewLogger("item_summary", "INFO", "") // HandleItemSummary 处理获取章节摘要,章节摘要数据基本不变,缓存 24 小时 func HandleItemSummary(itemIDs, bookID string) (string, error) { - return withCache("item_summary_" + bookID + "_" + itemIDs, 86400, func() (string, error) { + return withCache("item_summary_"+bookID+"_"+itemIDs, 86400, func() (string, error) { return fetchItemSummary(itemIDs, bookID) }) } @@ -48,4 +48,3 @@ func fetchItemSummary(itemIDs, bookID string) (string, error) { return BuildErrorResponse("获取失败"), nil } - diff --git a/internal/fqapi/manga.go b/internal/fqapi/manga.go index 20bd570..5680549 100644 --- a/internal/fqapi/manga.go +++ b/internal/fqapi/manga.go @@ -71,14 +71,14 @@ func getMangaWithRetry(itemID string, showHTML bool, urlOutputMode string, retry mangaLogger.Warning(fmt.Sprintf("已重试 %d 次,获取漫画失败", maxRetries)) return nil, fmt.Errorf("已重试 %d 次,所有设备均失败", maxRetries) } - + // 1. 从设备密钥池获取设备 pool := GetDeviceKeyPool() item, err := pool.GetNext() if err != nil { return nil, fmt.Errorf("从设备池获取设备失败: %w", err) } - + header := item.DeviceHeader keyInfo := &KeyInfo{ Key: item.Key, @@ -171,18 +171,18 @@ func getMangaWithRetry(itemID string, showHTML bool, urlOutputMode string, retry // 是漫画,决定输出模式 // 优先级:URL参数 > 配置文件 > 默认值 outputMode := "proxy" // 默认值 - + // 从配置文件读取 cfg := config.GetConfig() if cfg != nil && cfg.Fanqie.Manga.OutputMode != "" { outputMode = cfg.Fanqie.Manga.OutputMode } - + // URL参数优先级最高 if urlOutputMode != "" { outputMode = urlOutputMode } - + // 解析漫画图片(传入keyInfo用于解密模式) images, err := parseMangaImages(decrypted, showHTML, outputMode, itemID, keyInfo) if err != nil { @@ -279,7 +279,7 @@ func buildMangaResponse(images []ImageResult, showHTML bool) interface{} { "content": htmlContent.String(), } } - + return map[string]interface{}{ "images": images, } @@ -289,7 +289,7 @@ func buildMangaResponse(images []ImageResult, showHTML bool) interface{} { func processURLMode(mangaData *MangaData) []ImageResult { baseDomain := "https://p6-novel.byteimg.com/origin/" validPath := detectMangaPath(baseDomain, mangaData.PicInfos[0].Md5) - + images := make([]ImageResult, len(mangaData.PicInfos)) for i, pic := range mangaData.PicInfos { imageURL := baseDomain + validPath + pic.Md5 @@ -309,7 +309,7 @@ func processProxyMode(mangaData *MangaData, itemID string) []ImageResult { images := make([]ImageResult, len(mangaData.PicInfos)) for i, pic := range mangaData.PicInfos { cacheKey := fmt.Sprintf("%s_%d", itemID, i) - + // 存储图片信息到缓存 mangaCacheMutex.Lock() mangaImageCache[cacheKey] = &MangaImageInfo{ @@ -320,7 +320,7 @@ func processProxyMode(mangaData *MangaData, itemID string) []ImageResult { CachedAt: time.Now(), } mangaCacheMutex.Unlock() - + // 返回短的代理URL imageData := fmt.Sprintf("/manga/image/%s/%d", itemID, i) images[i] = ImageResult{ @@ -390,7 +390,7 @@ func processDataURIMode(mangaData *MangaData) []ImageResult { // Base64编码图片数据 base64Data := base64.StdEncoding.EncodeToString(decryptedData) - + // Data URI模式:返回完整的Base64编码 mimeType := fmt.Sprintf("image/%s", format) if format == "jpg" { @@ -542,31 +542,31 @@ func decryptImage(encryptedData, keyBytes []byte) ([]byte, error) { func detectMangaPath(baseDomain, firstMd5 string) string { // 优先尝试 novel-images/ testURL := baseDomain + "novel-images/" + firstMd5 - + client := &http.Client{ Timeout: 8 * time.Second, } - + resp, err := client.Get(testURL) if err != nil { // 请求失败,使用novel-pic/ return "novel-pic/" } defer resp.Body.Close() - + // 读取响应内容 body, err := io.ReadAll(resp.Body) if err != nil { return "novel-pic/" } - + // 检查是否为JSON响应(错误响应通常是JSON) var jsonData interface{} if json.Unmarshal(body, &jsonData) == nil { // 是JSON,说明路径无效,切换到novel-pic/ return "novel-pic/" } - + // 不是JSON,说明返回的是图片,路径有效 return "novel-images/" } @@ -605,12 +605,11 @@ func getImageFormat(data []byte, url string) string { } // WEBP: RIFF...WEBP - if bytes.HasPrefix(data, []byte{0x52, 0x49, 0x46, 0x46}) && - len(data) >= 12 && - bytes.Equal(data[8:12], []byte{0x57, 0x45, 0x42, 0x50}) { + if bytes.HasPrefix(data, []byte{0x52, 0x49, 0x46, 0x46}) && + len(data) >= 12 && + bytes.Equal(data[8:12], []byte{0x57, 0x45, 0x42, 0x50}) { return "webp" } return "png" } - diff --git a/internal/fqapi/manga_proxy.go b/internal/fqapi/manga_proxy.go index 13b5ab3..b10d240 100644 --- a/internal/fqapi/manga_proxy.go +++ b/internal/fqapi/manga_proxy.go @@ -1,72 +1,71 @@ -package fqapi - -import ( - "encoding/hex" - "fmt" - "time" -) - -// HandleMangaImageProxy 处理漫画图片代理请求 -func HandleMangaImageProxy(itemID string, index int) ([]byte, string, error) { - // 从缓存获取图片信息 - cacheKey := fmt.Sprintf("%s_%d", itemID, index) - - mangaCacheMutex.RLock() - imageInfo, exists := mangaImageCache[cacheKey] - mangaCacheMutex.RUnlock() - - if !exists { - return nil, "", fmt.Errorf("图片信息不存在,请先调用 /manga 接口") - } - - // 检查缓存是否过期(5分钟) - if time.Since(imageInfo.CachedAt) > 5*time.Minute { - // 清理过期缓存 - mangaCacheMutex.Lock() - delete(mangaImageCache, cacheKey) - mangaCacheMutex.Unlock() - return nil, "", fmt.Errorf("图片信息已过期,请重新调用 /manga 接口") - } - - // 下载加密图片 - encryptedData, err := downloadImageWithRetry(imageInfo.PicURL, 2) - if err != nil { - return nil, "", fmt.Errorf("下载图片失败: %w", err) - } - - // 解密图片 - keyBytes, err := hex.DecodeString(imageInfo.EncryptKey) - if err != nil { - return nil, "", fmt.Errorf("密钥解码失败: %w", err) - } - - decryptedData, err := decryptImage(encryptedData, keyBytes) - if err != nil { - return nil, "", fmt.Errorf("解密失败: %w", err) - } - - // 识别格式 - format := getImageFormat(decryptedData, imageInfo.PicURL) - - // 返回图片数据和MIME类型 - mimeType := fmt.Sprintf("image/%s", format) - if format == "jpg" { - mimeType = "image/jpeg" - } - - return decryptedData, mimeType, nil -} - -// CleanExpiredMangaCache 清理过期的漫画图片缓存 -func CleanExpiredMangaCache() { - mangaCacheMutex.Lock() - defer mangaCacheMutex.Unlock() - - now := time.Now() - for key, info := range mangaImageCache { - if now.Sub(info.CachedAt) > 5*time.Minute { - delete(mangaImageCache, key) - } - } -} - +package fqapi + +import ( + "encoding/hex" + "fmt" + "time" +) + +// HandleMangaImageProxy 处理漫画图片代理请求 +func HandleMangaImageProxy(itemID string, index int) ([]byte, string, error) { + // 从缓存获取图片信息 + cacheKey := fmt.Sprintf("%s_%d", itemID, index) + + mangaCacheMutex.RLock() + imageInfo, exists := mangaImageCache[cacheKey] + mangaCacheMutex.RUnlock() + + if !exists { + return nil, "", fmt.Errorf("图片信息不存在,请先调用 /manga 接口") + } + + // 检查缓存是否过期(5分钟) + if time.Since(imageInfo.CachedAt) > 5*time.Minute { + // 清理过期缓存 + mangaCacheMutex.Lock() + delete(mangaImageCache, cacheKey) + mangaCacheMutex.Unlock() + return nil, "", fmt.Errorf("图片信息已过期,请重新调用 /manga 接口") + } + + // 下载加密图片 + encryptedData, err := downloadImageWithRetry(imageInfo.PicURL, 2) + if err != nil { + return nil, "", fmt.Errorf("下载图片失败: %w", err) + } + + // 解密图片 + keyBytes, err := hex.DecodeString(imageInfo.EncryptKey) + if err != nil { + return nil, "", fmt.Errorf("密钥解码失败: %w", err) + } + + decryptedData, err := decryptImage(encryptedData, keyBytes) + if err != nil { + return nil, "", fmt.Errorf("解密失败: %w", err) + } + + // 识别格式 + format := getImageFormat(decryptedData, imageInfo.PicURL) + + // 返回图片数据和MIME类型 + mimeType := fmt.Sprintf("image/%s", format) + if format == "jpg" { + mimeType = "image/jpeg" + } + + return decryptedData, mimeType, nil +} + +// CleanExpiredMangaCache 清理过期的漫画图片缓存 +func CleanExpiredMangaCache() { + mangaCacheMutex.Lock() + defer mangaCacheMutex.Unlock() + + now := time.Now() + for key, info := range mangaImageCache { + if now.Sub(info.CachedAt) > 5*time.Minute { + delete(mangaImageCache, key) + } + } +} diff --git a/internal/fqapi/multi_content.go b/internal/fqapi/multi_content.go index 7753ab4..c88f0fb 100644 --- a/internal/fqapi/multi_content.go +++ b/internal/fqapi/multi_content.go @@ -116,7 +116,7 @@ func MultiContent(itemIDs interface{}, bookID string, textMode, imageMode, ignor if err != nil { return `{"code": -1, "msg": "从设备池获取设备失败"}`, err } - + header := item.DeviceHeader currentKeyInfo := &KeyInfo{ Key: item.Key, @@ -270,4 +270,3 @@ func MultiContent(itemIDs interface{}, bookID string, textMode, imageMode, ignor func HandleMultiContent(itemIDs interface{}, bookID string, textMode, imageMode, ignoreFailed interface{}) (string, error) { return MultiContent(itemIDs, bookID, textMode, imageMode, ignoreFailed, 0) } - diff --git a/internal/fqapi/multi_detail.go b/internal/fqapi/multi_detail.go index c404247..7fc4176 100644 --- a/internal/fqapi/multi_detail.go +++ b/internal/fqapi/multi_detail.go @@ -218,4 +218,3 @@ func fetchMultiDetail(bookID interface{}) (string, error) { resultBytes, _ := json.Marshal(response) return string(resultBytes), nil } - diff --git a/internal/fqapi/register_key.go b/internal/fqapi/register_key.go index 5831af0..fb30e06 100644 --- a/internal/fqapi/register_key.go +++ b/internal/fqapi/register_key.go @@ -246,4 +246,3 @@ func GetKeyInfo() *KeyInfo { func init() { rand.Seed(time.Now().UnixNano()) } - diff --git a/internal/fqapi/search.go b/internal/fqapi/search.go index 8c2a248..152e392 100644 --- a/internal/fqapi/search.go +++ b/internal/fqapi/search.go @@ -97,4 +97,3 @@ func filterSearchTabs(respJSON map[string]interface{}, tabType int) { respJSON["search_tabs"] = filtered } } - diff --git a/internal/fqapi/ttencrypt.go b/internal/fqapi/ttencrypt.go index 23fd07f..a1c7e56 100644 --- a/internal/fqapi/ttencrypt.go +++ b/internal/fqapi/ttencrypt.go @@ -193,4 +193,3 @@ func Decrypt(encryptedStr string) ([]byte, error) { } return TTDecrypt(encryptedData) } - diff --git a/internal/fqapi/video.go b/internal/fqapi/video.go index 72d43b1..2d7f825 100644 --- a/internal/fqapi/video.go +++ b/internal/fqapi/video.go @@ -144,10 +144,10 @@ func decodeVideoModelAsJSON(videoModelStr string) interface{} { // 解析失败,返回空对象 return map[string]interface{}{} } - + // 构建精简的返回结果,只包含关键字段 result := make(map[string]interface{}) - + // 提取关键字段 if videoID, ok := videoModel["video_id"].(string); ok { result["video_id"] = videoID @@ -158,16 +158,16 @@ func decodeVideoModelAsJSON(videoModelStr string) interface{} { if posterURL, ok := videoModel["poster_url"].(string); ok { result["poster_url"] = posterURL } - + // 处理 video_list if videoList, ok := videoModel["video_list"].(map[string]interface{}); ok { processedVideoList := make(map[string]interface{}) - + for quality, qualityData := range videoList { if qualityMap, ok := qualityData.(map[string]interface{}); ok { // 只保留关键字段 qualityInfo := make(map[string]interface{}) - + if definition, ok := qualityMap["definition"].(string); ok { qualityInfo["definition"] = definition } @@ -189,39 +189,39 @@ func decodeVideoModelAsJSON(videoModelStr string) interface{} { if fps, ok := qualityMap["fps"].(float64); ok { qualityInfo["fps"] = int(fps) } - + // 解码 main_url if mainURL, ok := qualityMap["main_url"].(string); ok { if decoded, err := base64.StdEncoding.DecodeString(mainURL); err == nil { qualityInfo["main_url"] = string(decoded) } } - + // 解码 backup_url_1 if backupURL, ok := qualityMap["backup_url_1"].(string); ok { if decoded, err := base64.StdEncoding.DecodeString(backupURL); err == nil { qualityInfo["backup_url_1"] = string(decoded) } } - + processedVideoList[quality] = qualityInfo } } - + result["video_list"] = processedVideoList } - + return result } // formatDuration 将秒数转换为可读的时间格式 func formatDuration(seconds float64) string { totalSeconds := int(math.Round(seconds)) - + hours := totalSeconds / 3600 minutes := (totalSeconds % 3600) / 60 secs := totalSeconds % 60 - + if hours > 0 { return fmt.Sprintf("%d小时%d分%d秒", hours, minutes, secs) } else if minutes > 0 { @@ -236,17 +236,17 @@ func formatFileSize(bytes float64) string { if bytes < unit { return fmt.Sprintf("%.0f B", bytes) } - + kb := bytes / unit if kb < unit { return fmt.Sprintf("%.2f KB", kb) } - + mb := kb / unit if mb < unit { return fmt.Sprintf("%.2f MB", mb) } - + gb := mb / unit return fmt.Sprintf("%.2f GB", gb) } @@ -255,12 +255,11 @@ func formatFileSize(bytes float64) string { func formatBitrate(bitrate float64) string { const unit = 1000.0 kbps := bitrate / unit - + if kbps < unit { return fmt.Sprintf("%.0f Kbps", kbps) } - + mbps := kbps / unit return fmt.Sprintf("%.2f Mbps", mbps) } - diff --git a/internal/handlers/book.go b/internal/handlers/book.go index 7d84829..314ed95 100644 --- a/internal/handlers/book.go +++ b/internal/handlers/book.go @@ -103,4 +103,3 @@ func HandleMultiDetail(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, result) } - diff --git a/internal/handlers/category.go b/internal/handlers/category.go index 3549d05..462b18f 100644 --- a/internal/handlers/category.go +++ b/internal/handlers/category.go @@ -41,4 +41,3 @@ func HandleCategoryLanding(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, result) } - diff --git a/internal/handlers/content.go b/internal/handlers/content.go index 5cd6d23..5f1f3ae 100644 --- a/internal/handlers/content.go +++ b/internal/handlers/content.go @@ -20,9 +20,9 @@ func HandleContent(w http.ResponseWriter, r *http.Request) { itemID := params["item_id"] // 获取URL参数(支持动态覆盖配置) - textMode := r.URL.Query().Get("text_mode") // 1=纯文本模式 - imageMode := r.URL.Query().Get("image_mode") // 0=移除图片, 1=保留图片 - reviewParam := r.URL.Query().Get("review") // 1=启用段评, 0=禁用段评 + textMode := r.URL.Query().Get("text_mode") // 1=纯文本模式 + imageMode := r.URL.Query().Get("image_mode") // 0=移除图片, 1=保留图片 + reviewParam := r.URL.Query().Get("review") // 1=启用段评, 0=禁用段评 result, err := fqapi.HandleGetContent(itemID, textMode, imageMode) if err != nil { @@ -103,19 +103,19 @@ func addReviewToContent(jsonResult, itemID string, r *http.Request) string { if err := fqapi.ParseJSON([]byte(jsonResult), &resultData); err != nil { return jsonResult } - + // 获取 data 字段 data, ok := resultData["data"].(map[string]interface{}) if !ok { return jsonResult } - + // 获取 content 字段 content, ok := data["content"].(string) if !ok || content == "" { return jsonResult } - + // 从响应数据中提取 book_id (data.novel_data.book_id) bookID := "" if novelData, ok := data["novel_data"].(map[string]interface{}); ok { @@ -125,7 +125,7 @@ func addReviewToContent(jsonResult, itemID string, r *http.Request) string { bookID = fmt.Sprintf("%.0f", bookIDVal) } } - + // 获取基础 URL cfg := config.GetConfig() baseURL := "" @@ -136,7 +136,7 @@ func addReviewToContent(jsonResult, itemID string, r *http.Request) string { } port := cfg.Server.Port scheme := "http" - + // 检查是否来自外部请求 if r.Host != "" { baseURL = scheme + "://" + r.Host @@ -144,24 +144,23 @@ func addReviewToContent(jsonResult, itemID string, r *http.Request) string { baseURL = scheme + "://" + host + ":" + port } } - + // 生成带段评标记的内容 newContent, err := fqapi.GenerateParagraphsWithComments(itemID, bookID, content, baseURL) if err != nil { // 如果失败,返回原始内容 return jsonResult } - + // 替换 content 字段 data["content"] = newContent resultData["data"] = data - + // 重新序列化为 JSON newJSON, err := fqapi.ToJSON(resultData) if err != nil { return jsonResult } - + return newJSON } - diff --git a/internal/handlers/manga_proxy.go b/internal/handlers/manga_proxy.go index 69067b9..3fce4b6 100644 --- a/internal/handlers/manga_proxy.go +++ b/internal/handlers/manga_proxy.go @@ -13,32 +13,31 @@ func HandleMangaImageProxy(w http.ResponseWriter, r *http.Request) { // 从URL路径提取参数: /manga/image/{itemID}/{index} path := strings.TrimPrefix(r.URL.Path, "/manga/image/") parts := strings.Split(path, "/") - + if len(parts) != 2 { http.Error(w, "Invalid URL format. Expected: /manga/image/{itemID}/{index}", http.StatusBadRequest) return } - + itemID := parts[0] index, err := strconv.Atoi(parts[1]) if err != nil { http.Error(w, "Invalid index parameter", http.StatusBadRequest) return } - + // 获取解密后的图片数据 imageData, mimeType, err := fqapi.HandleMangaImageProxy(itemID, index) if err != nil { http.Error(w, fmt.Sprintf("Failed to get image: %s", err.Error()), http.StatusInternalServerError) return } - + // 设置响应头 w.Header().Set("Content-Type", mimeType) w.Header().Set("Cache-Control", "public, max-age=3600") // 缓存1小时 w.Header().Set("Content-Length", strconv.Itoa(len(imageData))) - + // 返回图片数据 w.Write(imageData) } - diff --git a/internal/handlers/review.go b/internal/handlers/review.go index 51d59b8..9fd00d1 100644 --- a/internal/handlers/review.go +++ b/internal/handlers/review.go @@ -1,9 +1,11 @@ package handlers import ( + _ "embed" "encoding/json" "fanqie/internal/fqapi" "fmt" + "html/template" "net/http" "strconv" "strings" @@ -48,11 +50,11 @@ func parseIntParam(value string, paramName string) (int, error) { func handleReviewError(w http.ResponseWriter, errType string, err error) { status := http.StatusInternalServerError msg := fmt.Sprintf("%s失败: %s", errType, err.Error()) - + if strings.Contains(err.Error(), "缺少") || strings.Contains(err.Error(), "格式错误") { status = http.StatusBadRequest } - + respondJSON(w, status, map[string]interface{}{ "code": -1, "msg": msg, @@ -71,38 +73,38 @@ func handleReviewSuccess(w http.ResponseWriter, data interface{}) { // HandleChapterReview 处理章节评论请求(混合数据) func HandleChapterReview(w http.ResponseWriter, r *http.Request) { incrementStat("total") - + // 验证必需参数 params, err := validateParams(r, "item_id") if err != nil { handleReviewError(w, "参数验证", err) return } - + // 获取可选参数 bookID := getOptionalParam(r, "book_id", "") - + // 调用 API data, err := fqapi.GetChapterMixData(bookID, params["item_id"]) if err != nil { handleReviewError(w, "获取章节评论", err) return } - + handleReviewSuccess(w, data) } // HandleReview 处理段落评论请求 func HandleReview(w http.ResponseWriter, r *http.Request) { incrementStat("total") - + // 验证必需参数 params, err := validateParams(r, "item_id") if err != nil { handleReviewError(w, "参数验证", err) return } - + // 获取可选参数 bookID := getOptionalParam(r, "book_id", "") commentID := getOptionalParam(r, "comment_id", "") @@ -110,39 +112,39 @@ func HandleReview(w http.ResponseWriter, r *http.Request) { sortStr := getOptionalParam(r, "sort", "1") offsetStr := getOptionalParam(r, "offset", "0") countStr := getOptionalParam(r, "count", "20") - + // 解析整数参数 para, err := parseIntParam(paraStr, "para") if err != nil { handleReviewError(w, "参数解析", err) return } - + sort, err := parseIntParam(sortStr, "sort") if err != nil { sort = 1 // 默认热门 } - + offset, _ := parseIntParam(offsetStr, "offset") count, _ := parseIntParam(countStr, "count") if count <= 0 { count = 20 } - + // 调用 API data, err := fqapi.GetParaReview(params["item_id"], bookID, commentID, para, sort, offset, count) if err != nil { handleReviewError(w, "获取段落评论", err) return } - + handleReviewSuccess(w, data) } // HandleReviewHTML 处理段落评论 HTML 显示 func HandleReviewHTML(w http.ResponseWriter, r *http.Request) { incrementStat("total") - + // 验证必需参数 params, err := validateParams(r, "item_id", "para") if err != nil { @@ -150,7 +152,7 @@ func HandleReviewHTML(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, "

%s

", err.Error()) return } - + // 解析整数参数 para, err := parseIntParam(params["para"], "para") if err != nil { @@ -158,13 +160,13 @@ func HandleReviewHTML(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, "

%s

", err.Error()) return } - + // 获取可选参数 bookID := getOptionalParam(r, "book_id", "") pageStr := getOptionalParam(r, "page", "1") pageSizeStr := getOptionalParam(r, "page_size", "20") sortStr := getOptionalParam(r, "sort", "1") - + // 解析分页参数 page, _ := parseIntParam(pageStr, "page") if page < 1 { @@ -174,16 +176,16 @@ func HandleReviewHTML(w http.ResponseWriter, r *http.Request) { if pageSize < 1 || pageSize > 100 { pageSize = 20 } - + // 解析排序参数 sort, _ := parseIntParam(sortStr, "sort") if sort != 1 && sort != 3 { sort = 1 // 默认热门 } - + // 计算 offset(基于页码) offset := (page - 1) * pageSize - + // 调用 API data, err := fqapi.GetParaReview(params["item_id"], bookID, "", para, sort, offset, pageSize) if err != nil { @@ -191,7 +193,7 @@ func HandleReviewHTML(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, "

获取评论失败: %s

", err.Error()) return } - + // 如果是 AJAX 请求,直接返回 JSON 数据 if r.Header.Get("X-Requested-With") == "XMLHttpRequest" { respondJSON(w, http.StatusOK, map[string]interface{}{ @@ -200,11 +202,11 @@ func HandleReviewHTML(w http.ResponseWriter, r *http.Request) { }) return } - + // 生成完整 HTML 页面(仅首次加载) w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusOK) - + html := generateReviewHTML(data, params["item_id"], bookID, para, page, pageSize, sort) fmt.Fprint(w, html) } @@ -216,8 +218,6 @@ func init() { em.LoadEmojis() } - - // replaceEmojiWithImages 将表情文本替换为图片标签 func replaceEmojiWithImages(text string) string { return fqapi.ReplaceEmojisInText(text) @@ -231,6 +231,7 @@ type CommentInfo struct { StickerImageURL string CreateTime string CommentText string + CommentTextHTML template.HTML // 预计算:转义+表情替换后的正文 CommentID string DiggCount int ReplyCount int @@ -242,12 +243,12 @@ func parseCommentData(comment map[string]interface{}) CommentInfo { info := CommentInfo{ UserName: "匿名用户", } - + // 提取 comment_id if cid, ok := comment["comment_id"].(string); ok { info.CommentID = cid } - + // 用户信息 if userInfo, ok := comment["user_info"].(map[string]interface{}); ok { if name, ok := userInfo["user_name"].(string); ok { @@ -260,18 +261,18 @@ func parseCommentData(comment map[string]interface{}) CommentInfo { info.IsAuthor = author } } - + // 创建时间 if timestamp, ok := comment["create_timestamp"].(float64); ok { t := time.Unix(int64(timestamp), 0) info.CreateTime = t.Format("2006-01-02 15:04") } - + // 评论内容 if text, ok := comment["text"].(string); ok { info.CommentText = text } - + // 提取图片 if imageData, ok := comment["image_data"].([]interface{}); ok && len(imageData) > 0 { if imgMap, ok := imageData[0].(map[string]interface{}); ok { @@ -285,14 +286,14 @@ func parseCommentData(comment map[string]interface{}) CommentInfo { } } } - + // 统计信息 if count, ok := comment["digg_count"].(float64); ok { info.DiggCount = int(count) } else if count, ok := comment["digg_count"].(int); ok { info.DiggCount = count } - + // 回复数量 if replyCountVal, ok := comment["reply_count"].(float64); ok { info.ReplyCount = int(replyCountVal) @@ -303,361 +304,40 @@ func parseCommentData(comment map[string]interface{}) CommentInfo { } else if replyCnt, ok := comment["reply_cnt"].(int); ok { info.ReplyCount = replyCnt } - + return info } +// reviewTmpl 评论页模板(embed 内嵌;用户内容由 html/template 按上下文自动转义) +// +//go:embed templates/review.html +var reviewHTML string + +var reviewTmpl = template.Must(template.New("review").Parse(reviewHTML)) + +// reviewPageData 评论页模板数据 +type reviewPageData struct { + ItemID, BookID string + ParaPlus1, Para, Sort int + Total, NextOffset int + PageSize, CommentCount int + HasMore bool + EmojiMapJS template.JS + Comments []CommentInfo +} + // generateReviewHTML 生成评论 HTML 页面 func generateReviewHTML(data map[string]interface{}, itemID, bookID string, para, page, pageSize, sort int) string { - var html strings.Builder - - html.WriteString(` - - - - - 番茄段评 · 第 `) - html.WriteString(strconv.Itoa(para + 1)) - html.WriteString(` 段 - - - - - -
-

段落评论 · `) - - // 提取评论总数和是否有更多 total := 0 hasMore := false nextOffset := 0 - - // 新接口返回 count 字段表示总数 + + // 提取评论总数(新接口 count 字段) if countVal, ok := data["count"].(float64); ok { total = int(countVal) } else if countVal, ok := data["count"].(int); ok { total = countVal } - - // 检查是否有更多数据 if hasMoreVal, ok := data["has_more"].(bool); ok { hasMore = hasMoreVal } else if hasMoreVal, ok := data["has_more"].(float64); ok { @@ -665,35 +345,13 @@ func generateReviewHTML(data map[string]interface{}, itemID, bookID string, para } else if hasMoreVal, ok := data["has_more"].(int); ok { hasMore = hasMoreVal > 0 } - - // 获取下一页的 offset if nextOffsetVal, ok := data["next_offset"].(float64); ok { nextOffset = int(nextOffsetVal) } else if nextOffsetVal, ok := data["next_offset"].(int); ok { nextOffset = nextOffsetVal } - - html.WriteString(strconv.Itoa(total)) - html.WriteString(` 条

- - - -
-
最热
-
最新
-
-
-`) - - // 解析评论数据 + + // 解析评论数据 comments := []map[string]interface{}{} if commentsList, ok := data["comments"].([]interface{}); ok { for _, item := range commentsList { @@ -702,540 +360,39 @@ func generateReviewHTML(data map[string]interface{}, itemID, bookID string, para } } } - + // 计算分页 - totalComments := len(comments) startIdx := (page - 1) * pageSize endIdx := startIdx + pageSize - if endIdx > totalComments { - endIdx = totalComments + if endIdx > len(comments) { + endIdx = len(comments) } - - // 获取当前页的评论 var pageComments []map[string]interface{} - if startIdx < totalComments { + if startIdx < len(comments) { pageComments = comments[startIdx:endIdx] } - - html.WriteString(`
-`) - - if len(pageComments) == 0 { - html.WriteString(`
暂无评论
`) - } else { - for idx, comment := range pageComments { - info := parseCommentData(comment) - - // 生成 HTML - html.WriteString(`
- -
`) - html.WriteString(replaceEmojiWithImages(htmlEscape(info.CommentText))) - html.WriteString(`
-`) - - if info.ImageURL != "" { - html.WriteString(` 评论图片 -`) - } - - html.WriteString(`
`) - html.WriteString(info.CreateTime) - html.WriteString(`
-
- `) - html.WriteString(strconv.Itoa(info.DiggCount)) - html.WriteString(` - `) - html.WriteString(strconv.Itoa(info.ReplyCount)) - html.WriteString(` -
-
-
-`) - } - } - - // 如果初始加载就没有更多数据了,显示提示 - if !hasMore && len(pageComments) > 0 { - html.WriteString(` -
没有更多评论了
`) - } - - html.WriteString(` -
- -
- × - 图片预览 -
- -
- -
- - -`) - - return html.String() + + var b strings.Builder + if err := reviewTmpl.Execute(&b, reviewPageData{ + ItemID: itemID, BookID: bookID, + ParaPlus1: para + 1, Para: para, Sort: sort, + Total: total, NextOffset: nextOffset, + PageSize: pageSize, CommentCount: len(pageComments), + HasMore: hasMore, + EmojiMapJS: template.JS(generateEmojiMapJS()), + Comments: pageInfos, + }); err != nil { + return "

评论页渲染失败: " + htmlEscape(err.Error()) + "

" + } + return b.String() } // htmlEscape HTML 转义 @@ -1252,7 +409,7 @@ func htmlEscape(s string) string { func generateEmojiMapJS() string { em := fqapi.GetEmojiManager() emojiMap := em.GetAllEmojis() - + var sb strings.Builder sb.WriteString("{") first := true @@ -1278,4 +435,3 @@ func respondJSON(w http.ResponseWriter, statusCode int, data interface{}) { w.WriteHeader(statusCode) json.NewEncoder(w).Encode(data) } - diff --git a/internal/handlers/status.go b/internal/handlers/status.go index 0276dd1..722aa31 100644 --- a/internal/handlers/status.go +++ b/internal/handlers/status.go @@ -1,11 +1,13 @@ package handlers import ( + _ "embed" "encoding/json" "fanqie/internal/algos" "fanqie/internal/config" "fanqie/internal/fqapi" "fmt" + "html/template" "net/http" "time" ) @@ -13,7 +15,7 @@ import ( // HandleVacuum 处理数据库压缩请求 func HandleVacuum(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json; charset=utf-8") - + // 只允许 POST 请求 if r.Method != http.MethodPost { json.NewEncoder(w).Encode(map[string]interface{}{ @@ -22,7 +24,7 @@ func HandleVacuum(w http.ResponseWriter, r *http.Request) { }) return } - + // 执行 VACUUM err := fqapi.VacuumDatabase() if err != nil { @@ -32,18 +34,25 @@ func HandleVacuum(w http.ResponseWriter, r *http.Request) { }) return } - + json.NewEncoder(w).Encode(map[string]interface{}{ "success": true, "message": "数据库压缩完成", }) } +// 状态页模板(embed 内嵌,动态数据由 html/template 自动转义) +// +//go:embed templates/status.html +var statusHTML string + +var statusTmpl = template.Must(template.New("status").Parse(statusHTML)) + // HandleStatus 状态页面处理 func HandleStatus(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") uptime := time.Since(startTime) - + // 格式化运行时间 days := int(uptime.Hours() / 24) hours := int(uptime.Hours()) % 24 @@ -59,10 +68,10 @@ func HandleStatus(w http.ResponseWriter, r *http.Request) { } else { uptimeStr = fmt.Sprintf("%d秒", seconds) } - + // 获取签名信息 currentSign := algos.GlobalSignatureSwitcher.GetCurrentMethod() - + // 生成签名详情显示 var signatureDetails string if currentSign == "sign2" { @@ -70,800 +79,40 @@ func HandleStatus(w http.ResponseWriter, r *http.Request) { } else { signatureDetails = "X-Gorgon + X-Ladon + X-Argus + X-Khronos" } - + // 获取统计数据 stats := getStatsSnapshot() - + // 获取设备密钥池状态 poolStatus := getDeviceKeyPoolStatus() - - html := fmt.Sprintf(` - - - - - - 番茄小说API - 运行状态 - - - - -
-
-

番茄小说API

-

运行状态监控面板

-
- -
-
-
● 运行状态
-
- 服务器时间 - %s -
-
- 运行时长 - %s -
-
- 启动时间 - %s -
-
- 签名方法 - %s -
-
- 数据库大小 - %s -
-
- 已用空间 - %s -
-
- 空闲空间 - %s 立即压缩 -
-
- -
-
◉ 设备密钥池
-
- 池状态 - %s -
-
- 总设备数 - %d -
-
- 有效设备 - %d 可用 -
-
- 无效设备 - %d -
-
- 当前索引 - %d -
-
- -
-
■ 请求统计
-
- 总请求数 - %d -
-
- 搜索请求 - %d -
-
- 详情请求 - %d -
-
- 目录请求 - %d -
-
- 内容请求 - %d -
-
- 漫画请求 - %d -
-
- 音频请求 - %d -
-
- 视频请求 - %d -
-
- 分类请求 - %d -
-
- -
-
◆ 缓存统计
-
- 正文缓存数 - %d -
-
- 正文缓存 - %s -
-
- 自动清理 - %s -
-
- 清理间隔 - %s -
-
-
- -
-
◈ API 接口文档
- - -
-
书籍相关
- -
-
GET/search
-
◆ 搜索书籍
-
-
- query - 必需 - 搜索关键字 -
-
- offset - 可选 - 偏移量,默认0 -
-
- tab_type - 可选 - 类型,默认3 -
-
-
- -
-
GET/detail
-
◆ 获取书籍详情
-
-
- book_id - 必需 - 书籍ID -
-
-
- -
-
GET/catalog
-
◆ 获取书籍目录
-
-
- book_id - 必需 - 书籍ID -
-
-
- -
-
GET/multi-detail
-
◆ 批量获取书籍详情
-
-
- book_id - 必需 - 书籍ID列表,逗号分隔 -
-
-
-
- - -
-
内容相关
- -
-
GET/content
-
◆ 获取章节正文内容
-
-
- item_id - 必需 - 章节ID,支持路径参数或查询参数 -
-
- text_mode - 可选 - 纯文本模式:1/true=纯文本,0/false=HTML -
-
- image_mode - 可选 - 图片处理:1/true=保留图片,0/false=移除图片 -
-
- review - 可选 - 段评功能:1=启用,0=禁用 -
-
-
- -
-
GET/multi-content
-
◆ 批量获取多个章节正文
-
-
- book_id - 必需 - 书籍ID -
-
- item_ids - 必需 - 章节ID列表,多个ID用逗号分隔,如:123,456,789 -
-
- text_mode - 可选 - 纯文本模式:1/true=纯文本,0/false=HTML -
-
- image_mode - 可选 - 图片处理:1/true=保留图片,0/false=移除图片 -
-
- ignore_failed - 可选 - 忽略失败章节:1/true=忽略,0/false=不忽略 -
-
-
- -
-
GET/item-summary
-
◆ 获取章节摘要信息
-
-
- book_id - 必需 - 书籍ID -
-
- item_ids - 必需 - 章节ID列表,逗号分隔 -
-
-
-
- - -
-
段评相关
- -
-
GET/review
-
◆ 获取段落评论、回复
-
-
- item_id - 必需 - 章节ID -
-
- book_id - 必需 - 书籍ID -
-
- para - 必需 - 段落索引(从0开始) -
-
- comment_id - 可选 - 评论ID(获取回复时需要) -
-
-
- -
-
GET/review_html
-
◆ 获取段落评论HTML页面
-
-
- item_id - 必需 - 章节ID -
-
- book_id - 必需 - 书籍ID -
-
- para - 必需 - 段落索引(从0开始) -
-
-
-
- - -
-
多媒体
- -
-
GET/manga
-
◆ 获取漫画内容
-
-
- item_ids - 必需 - 章节ID -
-
- output - 可选 - 输出格式:url/proxy/data_uri,默认url -
-
-
- -
-
GET/audio
-
◆ 获取音频信息
-
-
- item_ids - 必需 - 章节ID -
-
- tone_id - 可选 - 音色ID,默认1 -
-
-
- -
-
GET/video
-
◆ 获取视频信息
-
-
- item_ids - 必需 - 章节ID -
-
-
-
- - -
-
分类
- -
-
GET/category-front
-
◆ 获取分类首页
-
-
- tab - 可选 - 标签页,默认0 -
-
-
- -
-
GET/category-landing
-
◆ 获取分类详情页
-
-
- category_id - 必需 - 分类ID -
-
- offset - 可选 - 偏移量,默认0 -
-
- limit - 可选 - 每页数量,默认20 -
-
-
-
- - -
-
系统
- -
-
GET/status
-
◆ 系统状态页面(当前页面)
-
- -
-
GET/self-info
-
◆ 设备信息(包含敏感数据)
-
-
-
-
- - -`, - time.Now().Format("2006-01-02 15:04:05"), - uptimeStr, - startTime.Format("2006-01-02 15:04:05"), - signatureDetails, - getDBTotalSize(), - getDBUsedSize(), - getDBFreeSize(), - poolStatus["status"], - poolStatus["total"], - poolStatus["valid"], - poolStatus["invalid"], - poolStatus["current_index"], - stats["total"], - stats["search"], - stats["detail"], - stats["catalog"], - stats["content"], - stats["manga"], - stats["audio"], - stats["video"], - stats["category"], - getDBCacheCount(), - getCacheStatus(), - getCleanupStatus(), - getCleanupInterval(), - ) - fmt.Fprint(w, html) + + statusTmpl.Execute(w, map[string]interface{}{ + "NowTime": time.Now().Format("2006-01-02 15:04:05"), + "Uptime": uptimeStr, + "StartTime": startTime.Format("2006-01-02 15:04:05"), + "Signature": signatureDetails, + "DBTotal": getDBTotalSize(), + "DBUsed": getDBUsedSize(), + "DBFree": getDBFreeSize(), + "PoolStatus": poolStatus["status"], + "PoolTotal": poolStatus["total"], + "PoolValid": poolStatus["valid"], + "PoolInvalid": poolStatus["invalid"], + "PoolIndex": poolStatus["current_index"], + "StatTotal": stats["total"], + "StatSearch": stats["search"], + "StatDetail": stats["detail"], + "StatCatalog": stats["catalog"], + "StatContent": stats["content"], + "StatManga": stats["manga"], + "StatAudio": stats["audio"], + "StatVideo": stats["video"], + "StatCategory": stats["category"], + "CacheCount": getDBCacheCount(), + "CacheStatus": getCacheStatus(), + "CleanupStatus": getCleanupStatus(), + "CleanupInterval": getCleanupInterval(), + }) } // getCacheStatus 获取缓存状态 @@ -960,23 +209,23 @@ func getDBFreeSize() string { } freeSize, okFree := stats["free_size_bytes"].(int64) totalSize, okTotal := stats["total_size_bytes"].(int64) - + if !okFree { return "未知" } - + if freeSize == 0 { return "0 B" } - + freeSizeStr := formatBytes(freeSize) - + // 如果有总大小,计算并显示百分比 if okTotal && totalSize > 0 { percentage := float64(freeSize) * 100 / float64(totalSize) return fmt.Sprintf("%s (%.1f%%)", freeSizeStr, percentage) } - + return freeSizeStr } @@ -988,18 +237,18 @@ func getDBUsagePercentage() float64 { } usedSize, okUsed := stats["used_size_bytes"].(int64) totalSize, okTotal := stats["total_size_bytes"].(int64) - + if !okUsed || !okTotal || totalSize == 0 { return 0 } - + return float64(usedSize) * 100 / float64(totalSize) } // getDeviceKeyPoolStatus 获取设备密钥池状态 func getDeviceKeyPoolStatus() map[string]interface{} { pool := fqapi.GetDeviceKeyPool() - + if !pool.IsEnabled() { return map[string]interface{}{ "status": "未启用", @@ -1009,7 +258,7 @@ func getDeviceKeyPoolStatus() map[string]interface{} { "current_index": 0, } } - + stats := pool.GetStats() return map[string]interface{}{ "status": "已启用", @@ -1019,4 +268,3 @@ func getDeviceKeyPoolStatus() map[string]interface{} { "current_index": stats["current_index"], } } -