Files
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

250 lines
5.8 KiB
Go

package fqapi
import (
"fanqie/internal/common"
"fmt"
"sort"
"strings"
"sync"
)
var multiDetailLogger = common.NewLogger("multi_detail", "INFO", "")
// singleDetail 获取单个书籍详情
func singleDetail(bookID string) (map[string]interface{}, error) {
reqURL := BuildURL("https://reading.snssdk.com/reading/bookapi/detail/v/", "book_id="+bookID)
resp, err := GorgonRequest(reqURL, map[string]interface{}{
"method": "GET",
"headers": map[string]string{
"User-Agent": "com.dragon.read",
},
})
if err != nil {
return map[string]interface{}{}, err
}
respJSON, err := resp.JSON()
if err != nil {
return map[string]interface{}{}, err
}
if code, ok := respJSON["code"].(float64); ok && code == 0 {
if data, ok := respJSON["data"].(map[string]interface{}); ok {
delete(data, "content")
return data, nil
}
}
return map[string]interface{}{}, nil
}
// multiDetail 批量获取书籍详情
func multiDetail(bookIDs string) ([]map[string]interface{}, error) {
reqURL := BuildURL("https://reading.snssdk.com/reading/bookapi/multi-detail/v/", "book_id="+bookIDs)
resp, err := GorgonRequest(reqURL, map[string]interface{}{
"method": "GET",
"headers": map[string]string{
"User-Agent": "com.dragon.read",
},
})
if err != nil {
// 返回空列表
count := len(strings.Split(bookIDs, ","))
result := make([]map[string]interface{}, count)
for i := range result {
result[i] = map[string]interface{}{}
}
return result, err
}
respJSON, err := resp.JSON()
if err != nil {
count := len(strings.Split(bookIDs, ","))
result := make([]map[string]interface{}, count)
for i := range result {
result[i] = map[string]interface{}{}
}
return result, err
}
if code, ok := respJSON["code"].(float64); ok && code == 0 {
if data, ok := respJSON["data"].([]interface{}); ok && len(data) > 0 {
result := make([]map[string]interface{}, len(data))
for i, item := range data {
if itemMap, ok := item.(map[string]interface{}); ok {
result[i] = itemMap
} else {
result[i] = map[string]interface{}{}
}
}
return result, nil
}
}
// 返回空列表
count := len(strings.Split(bookIDs, ","))
result := make([]map[string]interface{}, count)
for i := range result {
result[i] = map[string]interface{}{}
}
return result, nil
}
// chunkArray 分块数组
func chunkArray(arr []string, size int) [][]string {
chunks := make([][]string, 0)
for i := 0; i < len(arr); i += size {
end := i + size
if end > len(arr) {
end = len(arr)
}
chunks = append(chunks, arr[i:end])
}
return chunks
}
// HandleGetMultiDetail 处理批量获取书籍详情,详情数据基本不变,缓存 24 小时
func HandleGetMultiDetail(bookID interface{}) (string, error) {
// 缓存 key 归一化:ID 排序去重,相同集合不同顺序共享缓存
ids := normalizeIDList(bookID)
if ids == "" {
return fetchMultiDetail(bookID)
}
return withCache("multi_detail_"+ids, 86400, func() (string, error) {
return fetchMultiDetail(bookID)
})
}
// normalizeIDList 将 book_id 参数归一化为排序去重后的逗号串
func normalizeIDList(bookID interface{}) string {
var list []string
switch v := bookID.(type) {
case string:
list = strings.Split(v, ",")
case []string:
list = v
default:
return ""
}
seen := make(map[string]bool, len(list))
out := make([]string, 0, len(list))
for _, id := range list {
id = strings.TrimSpace(id)
if id == "" || seen[id] {
continue
}
seen[id] = true
out = append(out, id)
}
sort.Strings(out)
return strings.Join(out, ",")
}
func fetchMultiDetail(bookID interface{}) (string, error) {
if bookID == nil {
return BuildErrorResponse("需要参数 book_id"), nil
}
var bookIDList []string
switch v := bookID.(type) {
case string:
if v == "" {
return BuildErrorResponse("需要参数 book_id"), nil
}
bookIDList = strings.Split(v, ",")
case []string:
bookIDList = v
default:
return BuildErrorResponse("book_id 参数格式错误"), nil
}
// 分块处理,每批100个
chunks := chunkArray(bookIDList, 100)
// 并发请求
type result struct {
data []map[string]interface{}
err error
}
results := make([]result, len(chunks))
var wg sync.WaitGroup
for i, chunk := range chunks {
wg.Add(1)
go func(idx int, ch []string) {
defer wg.Done()
data, err := multiDetail(strings.Join(ch, ","))
results[idx] = result{data: data, err: err}
}(i, chunk)
}
wg.Wait()
// 合并结果
res := make([]map[string]interface{}, 0)
for _, r := range results {
if r.err == nil {
res = append(res, r.data...)
} else {
multiDetailLogger.Error(fmt.Sprintf("批量获取详情失败: %v", r.err))
}
}
// 处理下架书籍
offShelfBookIDs := make([]string, 0)
offShelfIndexes := make([]int, 0)
for i, item := range res {
if status, ok := item["tomato_book_status"].(string); ok && status == "3" {
if bid, ok := item["book_id"].(string); ok {
offShelfBookIDs = append(offShelfBookIDs, bid)
offShelfIndexes = append(offShelfIndexes, i)
}
}
}
// 单独获取下架书籍信息
if len(offShelfBookIDs) > 0 {
type offshelfResult struct {
data map[string]interface{}
idx int
}
offshelfResults := make([]offshelfResult, len(offShelfBookIDs))
var wg2 sync.WaitGroup
for i, bid := range offShelfBookIDs {
wg2.Add(1)
go func(idx int, bookID string, originalIdx int) {
defer wg2.Done()
data, _ := singleDetail(bookID)
offshelfResults[idx] = offshelfResult{data: data, idx: originalIdx}
}(i, bid, offShelfIndexes[i])
}
wg2.Wait()
// 更新结果
for _, r := range offshelfResults {
if r.idx < len(res) {
res[r.idx] = r.data
}
}
}
// 序列化结果
response := map[string]interface{}{
"code": 0,
"message": "SUCCESS",
"data": res,
}
return marshalNoEscape(UnwrapJSONStrings(response)), nil
}