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

270 lines
7.3 KiB
Go
Raw Permalink 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 (
"bytes"
"encoding/json"
"fanqie/internal/common"
"fmt"
"net/url"
"regexp"
"strconv"
"time"
)
var searchLogger = common.NewLogger("search", "INFO", "")
// 搜索分页与上游续传参数(对齐书源 20261012:上游翻页依赖上一页返回的
// passback/search_id/next_offset/corrected_query 续传,仅传数字 offset 深翻页会拿到重复/错序结果)
const (
searchPageSize = 10 // 对外每页书数
searchMaxUpstreams = 24 // 单次搜索累计上游翻页上限(对齐书源)
)
// bookIDKeyRe 纯数字 15~20 位关键词按书 ID 直搜详情
var bookIDKeyRe = regexp.MustCompile(`^\d{15,20}$`)
// HandleBookSearch 处理书籍搜索,搜索结果短缓存 10 分钟,减轻上游压力
func HandleBookSearch(key string, offset int, tabType int) (string, error) {
return withCache(fmt.Sprintf("search_%s_%d_%d", key, offset, tabType), 600, func() (string, error) {
return fetchBookSearch(key, offset, tabType)
})
}
func fetchBookSearch(key string, offset int, tabType int) (string, error) {
if key == "" {
return BuildErrorResponseWithCode(1, "需要参数 key"), nil
}
if tabType == 0 {
tabType = 3
}
if offset < 0 {
offset = 0
}
// 书 ID 直搜:跳过搜索接口直接查详情,精确命中单本
if bookIDKeyRe.MatchString(key) {
return idSearchResult(key)
}
// 累计翻页直到凑够本页书:上游单页 count=20,按 book_id 去重后切出 offset 起的 10 本
session := strconv.FormatInt(time.Now().UnixNano(), 10)
var (
rows []interface{} // 上游搜索行(含 book_data 包装),原样透传
seen = map[string]bool{}
passback = "0"
searchID = ""
corrected = ""
nextOff = 0
hasMore = true
)
want := offset + searchPageSize
for i := 0; i < searchMaxUpstreams && hasMore && len(rows) < want; i++ {
params := []string{
fmt.Sprintf("tab_type=%d", tabType),
"update_version_code=" + ClientVersionCode,
"query=" + url.QueryEscape(key),
"passback=" + passback,
fmt.Sprintf("offset=%d", nextOff),
"count=20",
fmt.Sprintf("is_first_enter_search=%t", i == 0),
"normal_session_id=" + session,
"cold_start_session_id=" + session,
}
if searchID != "" {
params = append(params, "search_id="+searchID)
}
if corrected != "" {
params = append(params, "corrected_query="+corrected)
}
tab, err := fetchSearchTab(params, tabType)
if err != nil {
if i == 0 {
return BuildErrorResponseWithCode(1, "搜索请求失败"), err
}
break // 已有数据时后续页失败,降级返回现有结果
}
if tab == nil {
break
}
if data, ok := tab["data"].([]interface{}); ok {
for _, r := range data {
row, _ := r.(map[string]interface{})
if row == nil {
continue
}
// 行 ID 取 book_data[0].book_id;无包装的行按行本身的 book_id
bd, _ := row["book_data"].([]interface{})
if len(bd) > 0 {
if b, ok := bd[0].(map[string]interface{}); ok {
if id := searchRowID(b); id != "" && !seen[id] {
seen[id] = true
rows = append(rows, row)
}
continue
}
}
if id := searchRowID(row); id != "" && !seen[id] {
seen[id] = true
rows = append(rows, row)
}
}
}
hasMore = searchHasMore(tab["has_more"])
nextOff = searchInt(tab["next_offset"])
passback = searchStr(tab["passback"])
searchID = searchStr(tab["search_id"])
corrected = searchStr(tab["corrected_query"])
}
start, end := offset, offset+searchPageSize
if start > len(rows) {
start = len(rows)
}
if end > len(rows) {
end = len(rows)
}
return marshalNoEscape(UnwrapJSONStrings(map[string]interface{}{
"code": 0,
"message": "SUCCESS",
"data": rows[start:end],
"has_more": hasMore || end < len(rows),
"next_offset": offset + searchPageSize,
})), nil
}
// fetchSearchTab 请求一页搜索并返回目标 tab_type 的 tab;nil 表示该页无匹配 tab
func fetchSearchTab(params []string, tabType int) (map[string]interface{}, error) {
reqURL := BuildURL("https://reading.snssdk.com/reading/bookapi/search/tab/v/", params...)
resp, err := GorgonRequest(reqURL, map[string]interface{}{
"method": "GET",
"headers": map[string]string{
"User-Agent": "com.dragon.read",
},
})
if err != nil {
searchLogger.Error(fmt.Sprintf("搜索请求失败: %v", err))
return nil, err
}
// UseNumber 重新解析后按 tab_type 过滤:
// 上游 search/tab/v/ 固定返回所有 tab 的 search_tabs;
// 大整数 ID 经 float64 重组会丢精度,必须 UseNumber
dec := json.NewDecoder(bytes.NewReader(resp.Content))
dec.UseNumber()
var pageJSON map[string]interface{}
if err := dec.Decode(&pageJSON); err != nil {
searchLogger.Error(fmt.Sprintf("解析响应失败: %v", err))
return nil, fmt.Errorf("解析失败")
}
if code, _ := pageJSON["code"].(float64); code != 0 {
return nil, fmt.Errorf("搜索失败 code=%v", pageJSON["code"])
}
filterSearchTabs(pageJSON, tabType)
tabs, _ := pageJSON["search_tabs"].([]interface{})
for _, t := range tabs {
m, ok := t.(map[string]interface{})
if !ok {
continue
}
if tt, ok := m["tab_type"].(json.Number); ok {
if v, err := tt.Int64(); err == nil && int(v) == tabType {
return m, nil
}
}
}
return nil, nil
}
// idSearchResult 书 ID 直搜:复用详情接口(24h 缓存),包成上游搜索行形状(book_data 包装)
func idSearchResult(bookID string) (string, error) {
detailJSON, err := HandleGetDetail(bookID)
if err != nil {
return BuildErrorResponseWithCode(1, "ID 搜索失败"), err
}
var detail map[string]interface{}
if err := json.Unmarshal([]byte(detailJSON), &detail); err != nil {
return detailJSON, nil // 透传上游错误响应
}
if code, _ := detail["code"].(float64); code != 0 {
return detailJSON, nil
}
return marshalNoEscape(UnwrapJSONStrings(map[string]interface{}{
"code": 0,
"message": "SUCCESS",
"data": []interface{}{map[string]interface{}{"book_data": []interface{}{detail["data"]}}},
"has_more": false,
"next_offset": 0,
})), nil
}
// filterSearchTabs 按 tab_type 只保留 search_tabs 中匹配的项;无匹配时保留原样
func filterSearchTabs(respJSON map[string]interface{}, tabType int) {
tabs, ok := respJSON["search_tabs"].([]interface{})
if !ok {
return
}
filtered := make([]interface{}, 0, 1)
for _, t := range tabs {
m, ok := t.(map[string]interface{})
if !ok {
continue
}
if tt, ok := m["tab_type"].(json.Number); ok {
if v, err := tt.Int64(); err == nil && int(v) == tabType {
filtered = append(filtered, m)
}
}
}
if len(filtered) > 0 {
respJSON["search_tabs"] = filtered
}
}
// searchRowID 取行的 book_id(UseNumber 下为 json.Number,也可能是字符串)
func searchRowID(m map[string]interface{}) string {
if m == nil {
return ""
}
if s, _ := m["book_id"].(string); s != "" {
return s
}
if n, ok := m["book_id"].(json.Number); ok {
return n.String()
}
return ""
}
func searchStr(v interface{}) string {
s, _ := v.(string)
return s
}
func searchInt(v interface{}) int {
switch t := v.(type) {
case json.Number:
n, _ := t.Int64()
return int(n)
case string:
n, _ := strconv.Atoi(t)
return n
}
return 0
}
func searchHasMore(v interface{}) bool {
switch t := v.(type) {
case bool:
return t
case json.Number:
n, _ := t.Int64()
return n != 0
}
return false
}