✨ 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:
chenxin committed 2026-10-12 14:49:14 +08:00
1 parent 8ca372050b
commit 84bd3238ab
28 files changed
+1180 -270

No files matched your search

+96 -29
View File
@@ -16,8 +16,31 @@ import (
_ "github.com/jackc/pgx/v5/stdlib"
_ "github.com/mattn/go-sqlite3"
"github.com/redis/go-redis/v9"
"golang.org/x/sync/singleflight"
)
const (
// sqlCacheTTLSeconds SQL 持久层保留时长:不可变内容(正文/详情/目录)与业务 TTL 解耦,
// Redis 过期或重启后由 SQL 回填,避免重新打上游
sqlCacheTTLSeconds = 30 * 86400
)
// volatileCachePrefixes 动态短缓存(值会变)只走 Redis/短 TTL,不进 SQL 长期层
var volatileCachePrefixes = []string{"search_", "category_", "rank_"}
// isVolatileCacheKey 判断是否为动态短缓存 key
func isVolatileCacheKey(key string) bool {
for _, p := range volatileCachePrefixes {
if strings.HasPrefix(key, p) {
return true
}
}
return false
}
// cacheSF 合并并发 miss 的回源请求,防止缓存击穿
var cacheSF singleflight.Group
var (
cacheDB *sql.DB
dbDriver string // "sqlite3" | "pgx"
@@ -56,6 +79,11 @@ func InitRedis() error {
Addr: fmt.Sprintf("%s:%d", cfg.Cache.Redis.Host, cfg.Cache.Redis.Port),
Password: cfg.Cache.Redis.Password,
DB: cfg.Cache.Redis.DB,
// 缓存场景失败不重试:miss 直接降级 SQL,重试只会放大网络慢时的请求延迟
MaxRetries: 0,
ReadTimeout: 3 * time.Second,
WriteTimeout: 3 * time.Second,
MinRetryBackoff: 0,
})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
@@ -149,12 +177,14 @@ func createTables() error {
`CREATE TABLE IF NOT EXISTS cache (
key TEXT PRIMARY KEY,
value TEXT,
expi INTEGER DEFAULT 0,
created_at INTEGER DEFAULT (strftime('%s', 'now')),
updated_at INTEGER DEFAULT (strftime('%s', 'now'))
)`,
`CREATE TABLE IF NOT EXISTS cache (
key TEXT PRIMARY KEY,
value TEXT,
expi BIGINT DEFAULT 0,
created_at BIGINT DEFAULT extract(epoch FROM now())::bigint,
updated_at BIGINT DEFAULT extract(epoch FROM now())::bigint
)`},
@@ -259,8 +289,10 @@ func createTables() error {
// 旧表补 created_at/updated_at 列(新表 DDL 自带,此处仅老库生效)
for _, m := range []struct{ table, col, ddl string }{
{"cache", "updated_at", "INTEGER DEFAULT 0"}, // SQLite ALTER 不允许非常量默认值,先补 0 再回填
{"cache", "expi", "INTEGER DEFAULT 0"},
{"statistics", "created_at", "INTEGER DEFAULT 0"},
{"device_pool", "updated_at", "INTEGER DEFAULT 0"},
{"device_pool", "key_ts", "INTEGER DEFAULT 0"},
{"audio_cache", "updated_at", "INTEGER DEFAULT 0"},
} {
if dbDriver == "pgx" {
@@ -278,6 +310,7 @@ func createTables() error {
// 旧行回填(幂等:仅 0 值行受影响)
for _, backfill := range []string{
`UPDATE cache SET updated_at = created_at WHERE updated_at = 0`,
`UPDATE cache SET expi = created_at + 86400 WHERE expi = 0`,
`UPDATE statistics SET created_at = updated_at WHERE created_at = 0`,
`UPDATE device_pool SET updated_at = last_used_at WHERE updated_at = 0`,
`UPDATE audio_cache SET updated_at = created_at WHERE updated_at = 0`,
@@ -299,7 +332,8 @@ func isCacheEnabled() bool {
return cfg.Cache.Enabled
}
// withCache 只读接口的缓存包装:命中直接返回,miss 回源后写入(仅缓存成功响应)
// withCache 只读接口的缓存包装:命中直接返回,miss 回源后写入(仅缓存成功响应)。
// singleflight 合并并发 miss,同一 key 只有一个请求打上游
// ponytail: 空参等防御分支若漏到此处,其错误 JSON 也会被缓存到 TTL 到期——handler 层 validateParams 已拦截
func withCache(key string, ttl int, fetch func() (string, error)) (string, error) {
if c := GetCache(key); c != "" {
@@ -307,21 +341,36 @@ func withCache(key string, ttl int, fetch func() (string, error)) (string, error
// (对新写入的已解包数据无影响)
return reencodeUnwrapped(c), nil
}
result, err := fetch()
if err == nil {
UpdateCache(key, result, ttl)
v, err, _ := cacheSF.Do(key, func() (interface{}, error) {
// 双重检查:等锁期间可能已被并发请求填充
if c := GetCache(key); c != "" {
return c, nil
}
result, err := fetch()
if err == nil {
UpdateCache(key, result, ttl)
}
return result, err
})
if err != nil {
return "", err
}
return result, err
return v.(string), nil
}
// GetCache 获取正文内容缓存
// GetCache 获取缓存:Redis 热层 → SQL 持久层(命中回填 Redis)→ 未命中
func GetCache(key string) string {
if !isCacheEnabled() {
return ""
}
if redisCache != nil {
return redisGetCache(key)
if v := redisGetCache(key); v != "" {
return v
}
if isVolatileCacheKey(key) {
return "" // 搜索/分类等动态数据不落库,Redis 未命中即回源
}
}
if cacheDB == nil {
@@ -329,7 +378,8 @@ func GetCache(key string) string {
}
var value string
err := cacheDB.QueryRow(q("SELECT value FROM cache WHERE key = ?"), key).Scan(&value)
var expi int64
err := cacheDB.QueryRow(q("SELECT value, expi FROM cache WHERE key = ?"), key).Scan(&value, &expi)
if err != nil {
if err != sql.ErrNoRows {
cacheLogger.Error("查询缓存失败: " + err.Error())
@@ -337,33 +387,46 @@ func GetCache(key string) string {
return ""
}
// 检查是否过期(expi 为 0 的旧行由启动回填兜底,此处防御)
if time.Now().Unix() >= expi {
cacheLogger.Debug("缓存已过期(SQL): " + key)
return ""
}
var cacheData CacheData
err = json.Unmarshal([]byte(value), &cacheData)
if err != nil {
if err := json.Unmarshal([]byte(value), &cacheData); err != nil {
cacheLogger.Error("解析缓存数据失败: " + err.Error())
return ""
}
cacheLogger.Debug("缓存命中: " + key)
cacheLogger.Debug("缓存命中(SQL): " + key)
// 检查是否过期
if time.Now().Unix() < int64(cacheData.Expi) {
cacheLogger.Debug("缓存有效!")
return cacheData.Data
// SQL 命中而 Redis 未命中(过期/重启):异步回填热层,剩余时长为 TTL,
// 回填(尤其大 value)不阻塞本次请求
if redisCache != nil {
remain := int(expi - time.Now().Unix())
if remain < 60 {
remain = 60
}
go redisUpdateCache(key, cacheData.Data, remain)
}
cacheLogger.Debug("缓存已过期!")
return ""
return cacheData.Data
}
// UpdateCache 更新或插入正文内容缓存
// UpdateCache 写入缓存:Redis 热层(TTL=业务过期时间)+ SQL 持久层双写。
// 不可变内容在 SQL 固定保留 30 天,动态短缓存(search/category)只走 Redis。
// Redis 写失败只降级不阻断:持久层照常落库,热层下次请求回填
func UpdateCache(key string, value string, expi int) error {
if !isCacheEnabled() {
return nil
}
if redisCache != nil {
return redisUpdateCache(key, value, expi)
if err := redisUpdateCache(key, value, expi); err != nil {
cacheLogger.Warning(fmt.Sprintf("Redis 写入失败(降级为仅 SQL 落库): %v", err))
} else if isVolatileCacheKey(key) {
return nil // 动态短缓存不落库(Redis 成功即止)
}
}
if cacheDB == nil {
@@ -380,10 +443,17 @@ func UpdateCache(key string, value string, expi int) error {
return err
}
// SQL 保留时长:不可变内容固定 30 天(Redis 未启用时沿用业务 TTL,保持纯 SQL 模式原行为)
sqlTTL := int64(sqlCacheTTLSeconds)
if redisCache == nil || isVolatileCacheKey(key) {
sqlTTL = int64(expi)
}
// UPSERT:ON CONFLICT 语法 SQLite 3.24+ 与 PostgreSQL 通用
_, err = cacheDB.Exec(q(`INSERT INTO cache (key, value, created_at, updated_at) VALUES (?, ?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`),
key, string(jsonData), time.Now().Unix(), time.Now().Unix())
now := time.Now().Unix()
_, err = cacheDB.Exec(q(`INSERT INTO cache (key, value, expi, created_at, updated_at) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, expi = excluded.expi, updated_at = excluded.updated_at`),
key, string(jsonData), now+sqlTTL, now, now)
if err != nil {
cacheLogger.Error("更新缓存失败: " + err.Error())
return err
@@ -465,11 +535,8 @@ func CleanupExpiredCache() error {
return fmt.Errorf("数据库未初始化")
}
// 计算过期时间点(当前时间 - cleanup_seconds)
expiredTime := time.Now().Unix() - int64(cfg.Cache.CleanupSeconds)
// 删除过期的缓存(created_at < expiredTime)
result, err := cacheDB.Exec(q("DELETE FROM cache WHERE created_at < ?"), expiredTime)
// 删除已过期的缓存(按行内过期时间 expi,而非创建时间)
result, err := cacheDB.Exec(q("DELETE FROM cache WHERE expi < ?"), time.Now().Unix())
if err != nil {
cacheLogger.Error("清理过期缓存失败: " + err.Error())
return err
@@ -480,7 +547,7 @@ func CleanupExpiredCache() error {
if err != nil {
cacheLogger.Warning("获取删除行数失败: " + err.Error())
} else if rowsAffected > 0 {
cacheLogger.Info(fmt.Sprintf("已清理 %d 条过期缓存(创建时间早于 %d 秒前)", rowsAffected, cfg.Cache.CleanupSeconds))
cacheLogger.Info(fmt.Sprintf("已清理 %d 条过期缓存(SQL 持久层)", rowsAffected))
}
// 顺带清理磁盘视频缓存(7 天未访问的解密文件)