✨ 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:
1 parent
8ca372050b
commit
84bd3238ab
28 files changed
+1180
-270
No files matched your search
+97
-68
@@ -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获取正文出错!")
|
||||
|
||||
Reference in new issue
Block a user