✨ 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
@@ -0,0 +1,194 @@
|
||||
package fqapi
|
||||
|
||||
import (
|
||||
"fanqie/internal/common"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var shelfLogger = common.NewLogger("shelf", "INFO", "")
|
||||
|
||||
// HandleBookshelf 获取登录用户书架。个人动态数据不缓存。
|
||||
// group 非空时只返回该分组的书;书架接口对无效/过期 sessionid 也返回空书架,无法区分——
|
||||
// 空结果时提示重新登录
|
||||
func HandleBookshelf(sessionID, group string) (string, error) {
|
||||
if sessionID == "" {
|
||||
return BuildErrorResponse("需要参数 sessionid(在 fanqienovel.com 登录后从浏览器 Cookie 中获取)"), nil
|
||||
}
|
||||
|
||||
resp, err := signedBookAPIGet("/reading/bookapi/bookshelf/info/v/", nil, sessionID)
|
||||
if err != nil {
|
||||
shelfLogger.Error(fmt.Sprintf("获取书架失败: %v", err))
|
||||
return BuildErrorResponse("获取书架失败"), err
|
||||
}
|
||||
|
||||
items, err := bookAPIList(resp, "book_shelf_info")
|
||||
if err != nil {
|
||||
return BuildErrorResponse("获取书架失败: " + err.Error()), nil
|
||||
}
|
||||
|
||||
// 过滤分组并收集 book_id
|
||||
type shelfRow struct {
|
||||
id string
|
||||
groupName string
|
||||
}
|
||||
rows := make([]shelfRow, 0, len(items))
|
||||
for _, it := range items {
|
||||
m, ok := it.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
id := bookIDOf(m)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
gn, _ := m["group_name"].(string)
|
||||
if group != "" && gn != group {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, shelfRow{id: id, groupName: gn})
|
||||
}
|
||||
|
||||
if len(rows) == 0 {
|
||||
return BuildErrorResponse("书架为空:sessionid 可能无效或已过期,请重新获取"), nil
|
||||
}
|
||||
|
||||
ids := make([]string, len(rows))
|
||||
for i, row := range rows {
|
||||
ids[i] = row.id
|
||||
}
|
||||
details := detailMapByIDs(ids)
|
||||
result := make([]map[string]interface{}, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
d := details[row.id]
|
||||
if d == nil {
|
||||
d = map[string]interface{}{"book_id": row.id}
|
||||
}
|
||||
d["group_name"] = row.groupName
|
||||
result = append(result, d)
|
||||
}
|
||||
|
||||
return marshalNoEscape(UnwrapJSONStrings(buildCacheData(result))), nil
|
||||
}
|
||||
|
||||
// HandleReadHistory 获取登录用户阅读历史。个人动态数据不缓存。
|
||||
// offset 从 0 起,每页 20 条;每项合并书籍详情并保留原始记录(进度等)于 history 字段
|
||||
func HandleReadHistory(sessionID string, offset int) (string, error) {
|
||||
if sessionID == "" {
|
||||
return BuildErrorResponse("需要参数 sessionid(在 fanqienovel.com 登录后从浏览器 Cookie 中获取)"), nil
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
resp, err := signedBookAPIGet("/reading/bookapi/read_history/list/v/",
|
||||
[]string{fmt.Sprintf("offset=%d", offset), "book_type=0"}, sessionID)
|
||||
if err != nil {
|
||||
shelfLogger.Error(fmt.Sprintf("获取阅读历史失败: %v", err))
|
||||
return BuildErrorResponse("获取阅读历史失败"), err
|
||||
}
|
||||
|
||||
items, err := bookAPIList(resp, "data_list")
|
||||
if err != nil {
|
||||
return BuildErrorResponse("获取阅读历史失败: " + err.Error()), nil
|
||||
}
|
||||
|
||||
type historyRow struct {
|
||||
id string
|
||||
raw map[string]interface{}
|
||||
}
|
||||
rows := make([]historyRow, 0, len(items))
|
||||
for _, it := range items {
|
||||
m, ok := it.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
id := bookIDOf(m)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, historyRow{id: id, raw: m})
|
||||
}
|
||||
|
||||
if len(rows) == 0 {
|
||||
return marshalNoEscape(buildCacheData([]interface{}{})), nil
|
||||
}
|
||||
|
||||
ids := make([]string, len(rows))
|
||||
for i, row := range rows {
|
||||
ids[i] = row.id
|
||||
}
|
||||
details := detailMapByIDs(ids)
|
||||
result := make([]map[string]interface{}, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
d := details[row.id]
|
||||
if d == nil {
|
||||
d = map[string]interface{}{"book_id": row.id}
|
||||
}
|
||||
// 原始记录(含阅读进度/时间)挂 history 字段保留
|
||||
d["history"] = row.raw
|
||||
result = append(result, d)
|
||||
}
|
||||
|
||||
return marshalNoEscape(UnwrapJSONStrings(buildCacheData(result))), nil
|
||||
}
|
||||
|
||||
// signedBookAPIGet 个人书架/历史接口:常规签名请求 + 登录态 Cookie
|
||||
func signedBookAPIGet(path string, extraParams []string, sessionID string) (*common.ClientResponse, error) {
|
||||
reqURL := BuildURL("https://reading.snssdk.com"+path, extraParams...)
|
||||
return GorgonRequest(reqURL, map[string]interface{}{
|
||||
"method": "GET",
|
||||
"headers": map[string]string{
|
||||
"User-Agent": "com.dragon.read",
|
||||
"Cookie": "sessionid=" + sessionID,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// bookAPIList 校验上游响应并取出 data 下的指定列表字段
|
||||
func bookAPIList(resp *common.ClientResponse, field string) ([]interface{}, error) {
|
||||
respJSON, err := resp.JSON()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("响应解析失败: %w", err)
|
||||
}
|
||||
if code, ok := respJSON["code"].(float64); !ok || code != 0 {
|
||||
msg, _ := respJSON["message"].(string)
|
||||
return nil, fmt.Errorf("code=%.0f msg=%s", respJSON["code"], msg)
|
||||
}
|
||||
data, _ := respJSON["data"].(map[string]interface{})
|
||||
items, _ := data[field].([]interface{})
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// bookIDOf 兼容提取 book_id / book_id_str(数字或字符串)
|
||||
func bookIDOf(m map[string]interface{}) string {
|
||||
for _, k := range []string{"book_id_str", "book_id", "bookId"} {
|
||||
switch v := m[k].(type) {
|
||||
case string:
|
||||
if v != "" {
|
||||
return v
|
||||
}
|
||||
case float64:
|
||||
return fmt.Sprintf("%.0f", v)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// detailMapByIDs 按 book_id 建立详情映射(复用批量详情接口,分批请求)
|
||||
func detailMapByIDs(ids []string) map[string]map[string]interface{} {
|
||||
m := make(map[string]map[string]interface{}, len(ids))
|
||||
for _, chunk := range chunkArray(ids, 100) {
|
||||
list, err := multiDetail(strings.Join(chunk, ","))
|
||||
if err != nil {
|
||||
shelfLogger.Warning(fmt.Sprintf("批量获取详情失败(该批保留 book_id): %v", err))
|
||||
continue
|
||||
}
|
||||
for _, d := range list {
|
||||
if id, ok := d["book_id"].(string); ok {
|
||||
m[id] = d
|
||||
}
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
Reference in new issue
Block a user