✨ 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
@@ -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 天未访问的解密文件)
|
||||
|
||||
Reference in new issue
Block a user