✨ 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 补充新接口文档
This commit is contained in:
chenxin committed 2026-10-12 14:49:14 +08:00
1 parent 8ca372050b
commit 84bd3238ab
28 files changed
+1180 -270

No files matched your search

+97 -68
View File
@@ -5,12 +5,14 @@ import (
"encoding/json"
"fanqie/internal/common"
"fmt"
"sort"
"strings"
"golang.org/x/sync/singleflight"
)
var (
contentLogger = common.NewLogger("content", "INFO", "")
contentSF singleflight.Group // 合并同一章节的并发回源请求,防缓存击穿
)
// ContentFetcher 内容获取器
@@ -26,19 +28,27 @@ func HandleGetContent(itemID string, textMode, imageMode interface{}) (string, e
textModeBool := parseBoolParamDefault(textMode, getContentConfig().RemoveHTMLTags)
imageModeBool := parseBoolParamDefault(imageMode, !getContentConfig().RemoveImages)
// contentLogger.Info(fmt.Sprintf("获取内容 item_id=%s text_mode=%v image_mode=%v", itemID, textModeBool, imageModeBool))
// 检查缓存
cache := GetCache("content_" + itemID)
if cache != "" {
return processCachedContent(cache, textModeBool, imageModeBool)
if cache == "" {
// 同一章节并发请求只打一次上游,其余等待共享结果
v, err, _ := contentSF.Do("content_"+itemID, func() (interface{}, error) {
// 双重检查:等锁期间可能已被并发请求填充
if c := GetCache("content_" + itemID); c != "" {
return c, nil
}
return fetchContentFromAPI(itemID)
})
if err != nil {
return "", err
}
cache = v.(string)
}
// 缓存未命中,从API获取
return fetchContentFromAPI(itemID, textModeBool, imageModeBool)
// 出口按各自参数解析(缓存/回源共用同一路径)
return processCachedContent(cache, textModeBool, imageModeBool)
}
// processCachedContent 处理缓存内容
// processCachedContent 处理缓存内容(content 为解密后的原始正文)
func processCachedContent(cache string, textMode, imageMode bool) (string, error) {
var d map[string]interface{}
json.Unmarshal([]byte(cache), &d)
@@ -53,18 +63,18 @@ func processCachedContent(cache string, textMode, imageMode bool) (string, error
return buildJSONResponse(UnwrapJSONStrings(d)), nil
}
// fetchContentFromAPI 从API获取内容(带设备切换重试)
func fetchContentFromAPI(itemID string, textMode, imageMode bool) (string, error) {
return fetchContentWithRetry(itemID, textMode, imageMode, 0)
// fetchContentFromAPI 从API获取内容(带设备切换重试),返回缓存格式 JSON
func fetchContentFromAPI(itemID string) (string, error) {
return fetchContentWithRetry(itemID, 0)
}
// fetchContentWithRetry 从API获取内容,支持设备切换重试
func fetchContentWithRetry(itemID string, textMode, imageMode bool, retryCount int) (string, error) {
func fetchContentWithRetry(itemID string, retryCount int) (string, error) {
// 防止无限递归
const maxRetries = 5
if retryCount >= maxRetries {
contentLogger.Warning(fmt.Sprintf("已重试 %d 次,尝试备用接口", maxRetries))
return tryBackupAPI(itemID, textMode, imageMode)
return tryBackupAPI(itemID)
}
// 从设备密钥池获取设备
@@ -79,6 +89,7 @@ func fetchContentWithRetry(itemID string, textMode, imageMode bool, retryCount i
currentKeyInfo := &KeyInfo{
Key: item.Key,
Ver: item.KeyVer,
Ts: item.KeyTs,
}
contentLogger.Debug(fmt.Sprintf("使用池中设备 ID=%d (重试次数: %d)", item.ID, retryCount))
@@ -90,16 +101,13 @@ func fetchContentWithRetry(itemID string, textMode, imageMode bool, retryCount i
"app_name=novelapp",
"channel=0",
"device_platform=android",
"version_code=66.9",
"version_code=" + ClientVersionCode,
"item_id=" + itemID,
"os_version=0",
fmt.Sprintf("key_register_ts=%d", currentKeyInfo.Ts), // 降低上游密钥不匹配概率
headerWithoutVersion,
}
allParams := strings.Join(params, "&")
paramList := strings.Split(allParams, "&")
sort.Strings(paramList)
sortedParams := strings.Join(paramList, "&")
sortedParams := buildSortedQuery(params...)
url := "https://reading.snssdk.com/reading/reader/full/v/?" + sortedParams
@@ -114,7 +122,7 @@ func fetchContentWithRetry(itemID string, textMode, imageMode bool, retryCount i
contentLogger.Warning(fmt.Sprintf("主接口请求出错,标记设备无效并切换设备: %v", err))
pool.MarkInvalid(header)
pool.RemoveInvalid()
return fetchContentWithRetry(itemID, textMode, imageMode, retryCount+1)
return fetchContentWithRetry(itemID, retryCount+1)
}
var body map[string]interface{}
@@ -126,7 +134,7 @@ func fetchContentWithRetry(itemID string, textMode, imageMode bool, retryCount i
contentLogger.Warning("响应data字段无效,切换设备重试")
pool.MarkInvalid(header)
pool.RemoveInvalid()
return fetchContentWithRetry(itemID, textMode, imageMode, retryCount+1)
return fetchContentWithRetry(itemID, retryCount+1)
}
contentEncoded, ok := data["content"].(string)
@@ -135,76 +143,101 @@ func fetchContentWithRetry(itemID string, textMode, imageMode bool, retryCount i
contentLogger.Warning(fmt.Sprintf("内容返回 Invalid,标记设备无效并切换设备 (重试: %d)", retryCount))
pool.MarkInvalid(header)
pool.RemoveInvalid()
return fetchContentWithRetry(itemID, textMode, imageMode, retryCount+1)
return fetchContentWithRetry(itemID, retryCount+1)
}
// 检查密钥版本
keyVersion, _ := data["key_version"].(float64)
if currentKeyInfo != nil && int(keyVersion) != currentKeyInfo.Ver {
if int(keyVersion) != currentKeyInfo.Ver {
contentLogger.Warning("密钥版本不匹配,重新获取密钥")
// 更新池中的密钥
newKeyInfo, err := GetKey(header)
if err == nil {
UpdateKeyInPool(header, newKeyInfo.Key, newKeyInfo.Ver)
UpdateKeyInPool(header, newKeyInfo)
currentKeyInfo = newKeyInfo
} else {
// 获取密钥失败,切换设备
contentLogger.Warning("获取密钥失败,切换设备重试")
pool.MarkInvalid(header)
pool.RemoveInvalid()
return fetchContentWithRetry(itemID, textMode, imageMode, retryCount+1)
return fetchContentWithRetry(itemID, retryCount+1)
}
}
// 解密内容
contentBytes, _ := base64.StdEncoding.DecodeString(contentEncoded)
if len(contentBytes) < 16 {
contentLogger.Warning("内容长度不足,切换设备重试")
pool.MarkInvalid(header)
pool.RemoveInvalid()
return fetchContentWithRetry(itemID, textMode, imageMode, retryCount+1)
}
iv := contentBytes[0:16]
encryptedData := contentBytes[16:]
keyBytes, _ := base64.StdEncoding.DecodeString(currentKeyInfo.Key)
decrypted, err := De(encryptedData, keyBytes, iv)
contentStr, err := decodeContentBody(data, currentKeyInfo)
if err != nil {
contentLogger.Warning(fmt.Sprintf("解密失败,切换设备重试: %v", err))
pool.MarkInvalid(header)
pool.RemoveInvalid()
return fetchContentWithRetry(itemID, textMode, imageMode, retryCount+1)
return fetchContentWithRetry(itemID, retryCount+1)
}
// 处理内容
contentStr := string(decrypted)
contentStr = strings.Replace(contentStr, `<?xml version=\"1.0\" encoding=\"utf-8\" standalone=\"no\"?>`, "", -1)
contentStr = strings.ReplaceAll(contentStr, `<?xml version=\"1.0\" encoding=\"utf-8\" standalone=\"no\"?>`, "")
contentStr = strings.TrimSpace(contentStr)
data["content"] = contentStr
// 更新缓存
cacheData := buildCacheData(data)
saveCacheJSON("content_"+itemID, cacheData, 86400*2)
// 解析内容
parser := NewContentParser()
data["content"] = parser.Parse(contentStr, textMode, imageMode)
return buildJSONResponse(UnwrapJSONStrings(body)), nil
// 写缓存并返回缓存格式(按参数的解析统一放在出口 processCachedContent)
cacheStr := marshalNoEscape(buildCacheData(UnwrapJSONStrings(data)))
UpdateCache("content_"+itemID, cacheStr, 86400)
return cacheStr, nil
}
// 响应code不为0,切换设备重试
contentLogger.Warning(fmt.Sprintf("响应code异常: %.0f,切换设备重试", body["code"]))
pool.MarkInvalid(header)
pool.RemoveInvalid()
return fetchContentWithRetry(itemID, textMode, imageMode, retryCount+1)
return fetchContentWithRetry(itemID, retryCount+1)
}
// tryBackupAPI 尝试备用API(所有设备都失败后的最后手段)
func tryBackupAPI(itemID string, textMode, imageMode bool) (string, error) {
// decodeContentBody 按上游 crypt_status 解出正文:
// - 0:base64 密文,设备密钥 AES-CBC 解密(compress_status!=0 时解压)
// - 1:明文(可能压缩)
// - 2:密钥已失效,刷新密钥后由调用方重试
func decodeContentBody(data map[string]interface{}, keyInfo *KeyInfo) (string, error) {
contentEncoded, _ := data["content"].(string)
cryptStatus, _ := data["crypt_status"].(float64)
compressStatus, _ := data["compress_status"].(float64)
switch int(cryptStatus) {
case 1:
if compressStatus != 0 {
return string(gunzipMaybe([]byte(contentEncoded))), nil
}
return contentEncoded, nil
case 2:
return "", fmt.Errorf("密钥已失效(crypt_status=2),需刷新密钥重试")
default:
// 500003 场景(设备无需解密)仍下发密文时按明文兜底
if keyInfo == nil || keyInfo.Key == "" {
return contentEncoded, nil
}
contentBytes, err := base64.StdEncoding.DecodeString(contentEncoded)
if err != nil || len(contentBytes) < 16 {
return "", fmt.Errorf("内容密文长度异常")
}
iv := contentBytes[0:16]
encryptedData := contentBytes[16:]
keyBytes, _ := base64.StdEncoding.DecodeString(keyInfo.Key)
decrypted, err := De(encryptedData, keyBytes, iv)
if err != nil {
return "", err
}
if compressStatus != 0 {
decrypted = gunzipMaybe(decrypted)
}
return string(decrypted), nil
}
}
// tryBackupAPI 尝试备用API(所有设备都失败后的最后手段),返回缓存格式 JSON
func tryBackupAPI(itemID string) (string, error) {
contentLogger.Warning("尝试备用接口1(所有设备均失败)")
// 从设备池获取任意一个有效设备用于备用接口
@@ -217,8 +250,8 @@ func tryBackupAPI(itemID string, textMode, imageMode bool) (string, error) {
header := item.DeviceHeader
// 备用接口1:使用DH加密
backupAPI := fmt.Sprintf("https://novel.snssdk.com/api/novel/book/reader/content/v1?support_image=1&item_id=%s&aid=1319&app_name=novelapp&channel=0&device_platform=android&os_version=2&version_code=58932&%s",
itemID, header)
backupAPI := fmt.Sprintf("https://novel.snssdk.com/api/novel/book/reader/content/v1?support_image=1&item_id=%s&aid=1319&app_name=novelapp&channel=0&device_platform=android&os_version=2&version_code=%s&%s",
itemID, ClientVersionCode, header)
cm := NewCM()
resp, err := GorgonRequest(backupAPI, map[string]interface{}{
@@ -259,18 +292,14 @@ func tryBackupAPI(itemID string, textMode, imageMode bool) (string, error) {
contentLogger.Error("解密失败: " + err.Error())
return `{"code": -1, "msg": "解密失败"}`, err
}
decryptedContent = string(gunzipMaybe([]byte(decryptedContent)))
data["content"] = decryptedContent
data["content"] = strings.TrimSpace(decryptedContent)
// 更新缓存
bodyJSONStr := buildJSONResponse(body)
UpdateCache("content_"+itemID, bodyJSONStr, 86400)
// 解析内容
parser := NewContentParser()
data["content"] = parser.Parse(decryptedContent, textMode, imageMode)
return buildJSONResponse(UnwrapJSONStrings(body)), nil
// 写缓存并返回缓存格式
cacheStr := marshalNoEscape(buildCacheData(UnwrapJSONStrings(data)))
UpdateCache("content_"+itemID, cacheStr, 86400)
return cacheStr, nil
}
contentLogger.Warning("备用接口1获取正文出错!")