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

195 lines
5.5 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 (
"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
}