Files
chenxin 84bd3238ab ✨ feat(api): 新增榜单书架历史与搜索续传并重构正文缓存链路
- 新增 /rank 免签名榜单接口(多域名容错)、/bookshelf 与 /read-history(sessionid 登录态,书架支持分组过滤)
- 搜索重写:翻页透传上游 passback/search_id/next_offset/corrected_query 并按 book_id 去重,15~20 位纯数字关键词直查详情(ID 直搜)
- 删除 /login/check 登录校验端点(login.go、路由与文档同步移除,sessionid 说明改为直接供书架/历史使用)
- 正文链路重构:singleflight 合并同章节并发回源防击穿,缓存与解析解耦统一在出口处理,decodeContentBody 按 crypt_status 分支并兜底 500003 明文
- 缓存层:SQL 持久层 TTL 与业务 TTL 解耦(不可变内容过期后由 SQL 回填),动态短缓存只走 Redis,withCache 合并并发 miss
- 密钥池:KeyTs 随正文请求上传降低密钥不匹配重试,密钥超 24 小时异步刷新并防刷新风暴,last_used_at 批量落库
- 签名补 X-Helios(与 X-Ladon 同值,对齐书源实现);设备注册版本号统一走 ClientVersionCode/Name
- 分类 landing 透传新版标签 selected_items;multi-detail 批量详情供书架/历史复用
- README 与 openapi.yaml 补充新接口文档
2026-10-12 14:49:14 +08:00

238 lines
7.0 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package fqapi
import (
"encoding/json"
"fanqie/internal/common"
"fmt"
"strings"
"sync"
)
var (
multiContentLogger = common.NewLogger("multi_content", "INFO", "")
)
// handleDecryptTask 处理解密任务(crypt_status 分支见 decodeContentBody)
func handleDecryptTask(itemID string, itemData map[string]interface{}, keyInfo *KeyInfo, textMode, imageMode bool) (string, map[string]interface{}) {
if code, ok := itemData["code"].(float64); !ok || code != 0 {
return itemID, itemData
}
if _, ok := itemData["content"].(string); !ok {
return itemID, itemData
}
// 解密(含压缩与明文分支)
contentStr, err := decodeContentBody(itemData, keyInfo)
if err != nil || contentStr == "" {
// 解密失败保留上游原样(ignore_failed 默认跳过失败章节)
return itemID, itemData
}
// 更新内容
bodyCopy := make(map[string]interface{})
for k, v := range itemData {
bodyCopy[k] = v
}
bodyCopy["content"] = contentStr
// 缓存
cacheData := buildCacheData(bodyCopy)
saveCacheJSON("content_"+itemID, cacheData, 86400)
// 解析内容
parser := NewContentParser()
bodyCopy["content"] = parser.Parse(contentStr, textMode, imageMode)
return itemID, bodyCopy
}
// MultiContent 批量获取内容
func MultiContent(itemIDs interface{}, bookID string, textMode, imageMode, ignoreFailed interface{}, depth int) (string, error) {
// 参数转换(URL 参数优先,未传时回退配置)
textModeBool := parseBoolParamDefault(textMode, getContentConfig().RemoveHTMLTags)
imageModeBool := parseBoolParamDefault(imageMode, !getContentConfig().RemoveImages)
ignoreFailedBool := parseBoolParamDefault(ignoreFailed, true)
if depth > 3 {
return BuildErrorResponse("获取失败,请重试!"), nil
}
if bookID == "" {
return BuildErrorResponse("需要book_id!"), nil
}
if itemIDs == nil || itemIDs == "" {
return BuildSuccessResponse([]interface{}{}), nil
}
// 转换itemIDs
var itemIDStr string
switch v := itemIDs.(type) {
case string:
itemIDStr = v
case []string:
itemIDStr = strings.Join(v, ",")
default:
itemIDStr = fmt.Sprintf("%v", v)
}
// 从设备密钥池获取设备
pool := GetDeviceKeyPool()
item, err := pool.GetNext()
if err != nil {
return `{"code": -1, "msg": "从设备池获取设备失败"}`, err
}
header := item.DeviceHeader
currentKeyInfo := &KeyInfo{
Key: item.Key,
Ver: item.KeyVer,
Ts: item.KeyTs,
}
multiContentLogger.Debug(fmt.Sprintf("使用池中设备 ID=%d (depth: %d)", item.ID, depth))
// 构建参数(form 编码,见 buildSortedQuery 注释)
headerWithoutVersion := removeVersionFromHeader(header)
params := []string{
"aid=1967",
"app_name=novelapp",
"channel=0",
"device_platform=android",
"os_version=0",
"version_code=" + ClientVersionCode,
"book_id=" + bookID,
"item_ids=" + itemIDStr,
"novel_text_type=1",
"req_type=1",
fmt.Sprintf("key_register_ts=%d", currentKeyInfo.Ts),
headerWithoutVersion,
}
sortedParams := buildSortedQuery(params...)
url := "https://reading.snssdk.com/reading/reader/batch_full/v/?" + sortedParams
resp, err := ContentRequest(url, map[string]interface{}{
"method": "GET",
"headers": map[string]string{
"User-Agent": "com.dragon.read",
},
})
if err != nil {
multiContentLogger.Warning(fmt.Sprintf("批量请求失败,标记设备无效并切换设备 (depth: %d): %v", depth, err))
pool.MarkInvalid(header)
pool.RemoveInvalid()
return MultiContent(itemIDs, bookID, textModeBool, imageModeBool, ignoreFailedBool, depth+1)
}
var data map[string]interface{}
json.Unmarshal(resp.Content, &data)
if code, ok := data["code"].(float64); ok && code == 0 {
dataMap, ok := data["data"].(map[string]interface{})
if !ok {
multiContentLogger.Warning(fmt.Sprintf("响应data字段无效,切换设备重试 (depth: %d)", depth))
pool.MarkInvalid(header)
pool.RemoveInvalid()
return MultiContent(itemIDs, bookID, textModeBool, imageModeBool, ignoreFailedBool, depth+1)
}
// 检查是否有Invalid内容或密钥版本不匹配
needRetry := false
for _, itemData := range dataMap {
itemMap, ok := itemData.(map[string]interface{})
if !ok {
continue
}
itemCode, _ := itemMap["code"].(float64)
if itemCode != 0 && !ignoreFailedBool {
multiContentLogger.Warning(fmt.Sprintf("item code异常: %.0f,切换设备重试 (depth: %d)", itemCode, depth))
pool.MarkInvalid(header)
pool.RemoveInvalid()
return MultiContent(itemIDs, bookID, textModeBool, imageModeBool, ignoreFailedBool, depth+1)
}
if content, ok := itemMap["content"].(string); ok && content == "Invalid" {
// 标记设备无效并移除
multiContentLogger.Warning(fmt.Sprintf("内容返回 Invalid,标记设备无效并切换设备 (depth: %d)", depth))
pool.MarkInvalid(header)
pool.RemoveInvalid()
needRetry = true
break
}
if keyVersion, ok := itemMap["key_version"].(float64); ok {
if currentKeyInfo != nil && int(keyVersion) != currentKeyInfo.Ver {
// 更新密钥版本
multiContentLogger.Warning("密钥版本不匹配,重新获取密钥")
newKeyInfo, err := GetKey(header)
if err == nil {
UpdateKeyInPool(header, newKeyInfo)
} else {
// 获取密钥失败,切换设备
multiContentLogger.Warning(fmt.Sprintf("获取密钥失败,切换设备重试 (depth: %d)", depth))
pool.MarkInvalid(header)
pool.RemoveInvalid()
}
needRetry = true
break
}
}
}
if needRetry {
return MultiContent(itemIDs, bookID, textModeBool, imageModeBool, ignoreFailedBool, depth+1)
}
// 并发解密
results := make(map[string]map[string]interface{})
var mutex sync.Mutex
var wg sync.WaitGroup
for itemID, itemData := range dataMap {
itemMap, ok := itemData.(map[string]interface{})
if !ok {
continue
}
wg.Add(1)
go func(id string, data map[string]interface{}) {
defer wg.Done()
_, decrypted := handleDecryptTask(id, data, currentKeyInfo, textModeBool, imageModeBool)
mutex.Lock()
results[id] = decrypted
mutex.Unlock()
}(itemID, itemMap)
}
wg.Wait()
// 检查结果数量
itemCount := len(strings.Split(itemIDStr, ","))
if len(results) == 1 && itemCount != 1 {
multiContentLogger.Warning(fmt.Sprintf("结果数量异常,切换设备重试 (depth: %d)", depth))
pool.MarkInvalid(header)
pool.RemoveInvalid()
return MultiContent(itemIDs, bookID, textModeBool, imageModeBool, ignoreFailedBool, depth+1)
}
// 构建响应
response := buildCacheData(results)
return buildJSONResponse(UnwrapJSONStrings(response)), nil
}
// 响应code不为0,切换设备重试
multiContentLogger.Warning(fmt.Sprintf("响应code异常,切换设备重试 (depth: %d)", depth))
pool.MarkInvalid(header)
pool.RemoveInvalid()
return MultiContent(itemIDs, bookID, textModeBool, imageModeBool, ignoreFailedBool, depth+1)
}
// HandleMultiContent 处理批量获取内容
func HandleMultiContent(itemIDs interface{}, bookID string, textMode, imageMode, ignoreFailed interface{}) (string, error) {
return MultiContent(itemIDs, bookID, textMode, imageMode, ignoreFailed, 0)
}