Files
fanqie-go/internal/fqapi/content.go
T
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

308 lines
9.6 KiB
Go
Raw 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/base64"
"encoding/json"
"fanqie/internal/common"
"fmt"
"strings"
"golang.org/x/sync/singleflight"
)
var (
contentLogger = common.NewLogger("content", "INFO", "")
contentSF singleflight.Group // 合并同一章节的并发回源请求,防缓存击穿
)
// ContentFetcher 内容获取器
type ContentFetcher struct{}
// HandleGetContent 处理获取内容
func HandleGetContent(itemID string, textMode, imageMode interface{}) (string, error) {
if itemID == "" {
return `{"code": -1, "msg": "需要参数 item_id"}`, nil
}
// 参数转换(URL 参数优先,未传时回退配置)
textModeBool := parseBoolParamDefault(textMode, getContentConfig().RemoveHTMLTags)
imageModeBool := parseBoolParamDefault(imageMode, !getContentConfig().RemoveImages)
cache := GetCache("content_" + itemID)
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)
}
// 出口按各自参数解析(缓存/回源共用同一路径)
return processCachedContent(cache, textModeBool, imageModeBool)
}
// processCachedContent 处理缓存内容(content 为解密后的原始正文)
func processCachedContent(cache string, textMode, imageMode bool) (string, error) {
var d map[string]interface{}
json.Unmarshal([]byte(cache), &d)
if data, ok := d["data"].(map[string]interface{}); ok {
if content, ok := data["content"].(string); ok {
parser := NewContentParser()
data["content"] = parser.Parse(content, textMode, imageMode)
}
}
return buildJSONResponse(UnwrapJSONStrings(d)), nil
}
// fetchContentFromAPI 从API获取内容(带设备切换重试),返回缓存格式 JSON
func fetchContentFromAPI(itemID string) (string, error) {
return fetchContentWithRetry(itemID, 0)
}
// fetchContentWithRetry 从API获取内容,支持设备切换重试
func fetchContentWithRetry(itemID string, retryCount int) (string, error) {
// 防止无限递归
const maxRetries = 5
if retryCount >= maxRetries {
contentLogger.Warning(fmt.Sprintf("已重试 %d 次,尝试备用接口", maxRetries))
return tryBackupAPI(itemID)
}
// 从设备密钥池获取设备
pool := GetDeviceKeyPool()
item, err := pool.GetNext()
if err != nil {
contentLogger.Error("从设备池获取设备失败: " + err.Error())
return `{"code": -1, "msg": "设备池暂无可用设备"}`, err
}
header := item.DeviceHeader
currentKeyInfo := &KeyInfo{
Key: item.Key,
Ver: item.KeyVer,
Ts: item.KeyTs,
}
contentLogger.Debug(fmt.Sprintf("使用池中设备 ID=%d (重试次数: %d)", item.ID, retryCount))
// 移除version_name参数
headerWithoutVersion := removeVersionFromHeader(header)
params := []string{
"aid=1967",
"app_name=novelapp",
"channel=0",
"device_platform=android",
"version_code=" + ClientVersionCode,
"item_id=" + itemID,
"os_version=0",
fmt.Sprintf("key_register_ts=%d", currentKeyInfo.Ts), // 降低上游密钥不匹配概率
headerWithoutVersion,
}
sortedParams := buildSortedQuery(params...)
url := "https://reading.snssdk.com/reading/reader/full/v/?" + sortedParams
resp, err := ContentRequest(url, map[string]interface{}{
"method": "GET",
"headers": map[string]string{
"User-Agent": "com.dragon.read",
},
})
if err != nil {
contentLogger.Warning(fmt.Sprintf("主接口请求出错,标记设备无效并切换设备: %v", err))
pool.MarkInvalid(header)
pool.RemoveInvalid()
return fetchContentWithRetry(itemID, retryCount+1)
}
var body map[string]interface{}
json.Unmarshal(resp.Content, &body)
if code, ok := body["code"].(float64); ok && code == 0 {
data, ok := body["data"].(map[string]interface{})
if !ok {
contentLogger.Warning("响应data字段无效,切换设备重试")
pool.MarkInvalid(header)
pool.RemoveInvalid()
return fetchContentWithRetry(itemID, retryCount+1)
}
contentEncoded, ok := data["content"].(string)
if !ok || contentEncoded == "Invalid" {
// 内容无效,标记当前设备为无效并切换设备
contentLogger.Warning(fmt.Sprintf("内容返回 Invalid,标记设备无效并切换设备 (重试: %d)", retryCount))
pool.MarkInvalid(header)
pool.RemoveInvalid()
return fetchContentWithRetry(itemID, retryCount+1)
}
// 检查密钥版本
keyVersion, _ := data["key_version"].(float64)
if int(keyVersion) != currentKeyInfo.Ver {
contentLogger.Warning("密钥版本不匹配,重新获取密钥")
// 更新池中的密钥
newKeyInfo, err := GetKey(header)
if err == nil {
UpdateKeyInPool(header, newKeyInfo)
currentKeyInfo = newKeyInfo
} else {
// 获取密钥失败,切换设备
contentLogger.Warning("获取密钥失败,切换设备重试")
pool.MarkInvalid(header)
pool.RemoveInvalid()
return fetchContentWithRetry(itemID, retryCount+1)
}
}
contentStr, err := decodeContentBody(data, currentKeyInfo)
if err != nil {
contentLogger.Warning(fmt.Sprintf("解密失败,切换设备重试: %v", err))
pool.MarkInvalid(header)
pool.RemoveInvalid()
return fetchContentWithRetry(itemID, retryCount+1)
}
contentStr = strings.ReplaceAll(contentStr, `<?xml version=\"1.0\" encoding=\"utf-8\" standalone=\"no\"?>`, "")
contentStr = strings.TrimSpace(contentStr)
data["content"] = contentStr
// 写缓存并返回缓存格式(按参数的解析统一放在出口 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, retryCount+1)
}
// 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(所有设备均失败)")
// 从设备池获取任意一个有效设备用于备用接口
pool := GetDeviceKeyPool()
item, err := pool.GetNext()
if err != nil {
contentLogger.Error("备用接口:无法获取设备")
return `{"code": -1, "msg": "获取失败"}`, err
}
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=%s&%s",
itemID, ClientVersionCode, header)
cm := NewCM()
resp, err := GorgonRequest(backupAPI, map[string]interface{}{
"method": "GET",
"headers": map[string]string{
"User-Agent": "com.dragon.read",
"y": cm.ClientHandshake(),
},
})
if err != nil {
contentLogger.Warning("备用接口1获取正文出错!")
return `{"code": -1, "msg": "获取失败"}`, err
}
var body map[string]interface{}
json.Unmarshal(resp.Content, &body)
if code, ok := body["code"].(float64); ok && code == 0 {
delete(body, "log_id")
data, ok := body["data"].(map[string]interface{})
if !ok {
return `{"code": -1, "msg": "获取失败"}`, err
}
contentEncrypted, ok := data["content"].(string)
if !ok || contentEncrypted == "Bad Content" {
return `{"code": -1, "msg": "获取失败"}`, err
}
// 解密内容
cHeader := resp.GetHeader("c")
yHeader := resp.GetHeader("y")
decryptedContent, err := cm.Decrypt(cHeader, yHeader, contentEncrypted)
if err != nil {
contentLogger.Error("解密失败: " + err.Error())
return `{"code": -1, "msg": "解密失败"}`, err
}
decryptedContent = string(gunzipMaybe([]byte(decryptedContent)))
data["content"] = strings.TrimSpace(decryptedContent)
// 写缓存并返回缓存格式
cacheStr := marshalNoEscape(buildCacheData(UnwrapJSONStrings(data)))
UpdateCache("content_"+itemID, cacheStr, 86400)
return cacheStr, nil
}
contentLogger.Warning("备用接口1获取正文出错!")
return `{"code": -1, "msg": "获取失败"}`, fmt.Errorf("所有接口都失败")
}