- 新增 /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 补充新接口文档
238 lines
7.0 KiB
Go
238 lines
7.0 KiB
Go
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)
|
||
}
|