- 新增 /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 补充新接口文档
308 lines
9.6 KiB
Go
308 lines
9.6 KiB
Go
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("所有接口都失败")
|
||
}
|