fix(share): 单题卡缓存 key 补 user.Domain;纠正过期的 TTL 注释

先查了「缓存 key 漏项」到底有没有实际影响,结论是**当前没有**:

- 用户域名只在注册时写入(register.go / CreateUser),没有改域名的入口,
  所以两张卡的二维码内容不会在运行期变化
- ExternalURL / ShareBackground 来自配置,改了必须重启,而缓存是进程内的,
  重启即清空 —— 不存在「配置变了缓存还在」的窗口
- 头像、背景上传走 R2 随机 key(picture/日期/随机 hex,内容不可变),
  换图必然换 URL,key 随之变化,不会出现「同名不同图」

只有一条理论缺口:域名要是将来能被修改,单题卡 key 没算 user.Domain,
会出现最长一小时的「二维码指向旧域名」。补上这一项成本为零,先按事实算进去。
shareCardCacheKey 本来就含 Domain,两张卡现在一致。

顺带把两处「TTL 30 分钟」的注释改成 1 小时(代码一直是 time.Hour)。
新增 TestShareCardCacheKeysCoverDrawnFields:逐个改动画进图里的字段,
断言 key 必须随之变化;去掉 user.Domain 后立刻报
"user.Domain is drawn on the question card but not in questionShareCardCacheKey"。
This commit is contained in:
tamakyi
2026-10-23 00:58:31 +08:00
parent 8f849123f7
commit 675cecb21d
2 changed files with 62 additions and 4 deletions
+9 -4
View File
@@ -328,7 +328,7 @@ func (c *ttlCache) cleanup() {
// 社交图标 URL 固定不变,基本永远命中;头像/背景变更时最多延迟 1 小时生效。
var imageCache = newTTLCache(24)
// 分享图结果缓存,TTL 30 分钟。key 含用户资料指纹,资料变更后自动失效。
// 分享图结果缓存,TTL 1 小时。key 含用户资料指纹,资料变更后自动失效。
// 存的是 PNG 编码字节(约 0.2~1MB/张),限制 32 条,总内存 ≤ 32MB。
var shareCardCache = newTTLCache(32)
@@ -561,7 +561,9 @@ func writeShareCardResponse(ctx context.Context, data []byte) {
}
// shareCardCacheKey 基于用户资料生成缓存指纹。
// 资料(名称/简介/头像/背景/社交链接)任何一项变更都会导致 key 变化,缓存自动失效。
// 资料(域名/名称/简介/头像/背景/社交链接)任何一项变更都会导致 key 变化,缓存自动失效。
// conf 里的 ExternalURL / ShareBackground 不入 key:改配置必须重启,进程内缓存随之清空。
// 头像、背景走 R2 随机 key(内容不可变),换图即换 URL,也不会出现同名不同图。
func shareCardCacheKey(user *db.User) string {
h := md5.New()
fmt.Fprintf(h, "%s|%s|%s|%s|%s|%s|%s|%s|%s|%s",
@@ -948,7 +950,7 @@ func drawQuestionQRCard(canvas *image.RGBA, user *db.User, question *db.Question
return nil
}
// 单题分享图结果缓存,TTL 30 分钟,上限 16 条(1C1G 内存友好)。
// 单题分享图结果缓存,TTL 1 小时,上限 16 条(1C1G 内存友好)。
var questionShareCache = newTTLCache(16)
// QuestionShareCard 生成单个问题(问题+回答)的分享卡片图。
@@ -1111,9 +1113,12 @@ func QuestionShareCard(ctx context.Context) {
}
// questionShareCardCacheKey 基于问题内容与用户资料生成缓存指纹,内容/资料变更自动失效。
// user.Domain 虽然当前不可改(只在注册时写入),但它进了二维码内容:一旦将来开放改域名,
// 漏掉它就会出现「卡片二维码指向旧域名」的一小时窗口,这里先按事实把它算进去。
func questionShareCardCacheKey(user *db.User, question *db.Question) string {
h := md5.New()
fmt.Fprintf(h, "q|%d|%s|%s|%s|%s|%s", question.ID, question.Content, question.Answer, user.Name, user.Avatar, user.Background)
fmt.Fprintf(h, "q|%d|%s|%s|%s|%s|%s|%s",
question.ID, question.Content, question.Answer, user.Domain, user.Name, user.Avatar, user.Background)
return fmt.Sprintf("qshare:%x", h.Sum(nil))
}
+53
View File
@@ -349,6 +349,59 @@ func qrModules(t *testing.T, content string) int {
return len(q.Bitmap())
}
// 缓存 key 必须覆盖所有画进图里的资料:漏一项就会出现「改了资料,卡片还是一小时旧图」。
func TestShareCardCacheKeysCoverDrawnFields(t *testing.T) {
base := db.User{Domain: "d", Name: "n", Intro: "i", Avatar: "a", Background: "b",
Acfunlink: "1", Bililink: "2", Weibolink: "3", Bloglink: "4", Xiaohongshulink: "5"}
baseKey := shareCardCacheKey(&base)
userMuts := []struct {
field string
apply func(u *db.User)
}{
{"domain", func(u *db.User) { u.Domain = "x" }},
{"name", func(u *db.User) { u.Name = "x" }},
{"intro", func(u *db.User) { u.Intro = "x" }},
{"avatar", func(u *db.User) { u.Avatar = "x" }},
{"background", func(u *db.User) { u.Background = "x" }},
{"acfun", func(u *db.User) { u.Acfunlink = "x" }},
{"bili", func(u *db.User) { u.Bililink = "x" }},
{"weibo", func(u *db.User) { u.Weibolink = "x" }},
{"blog", func(u *db.User) { u.Bloglink = "x" }},
{"xiaohongshu", func(u *db.User) { u.Xiaohongshulink = "x" }},
}
for _, m := range userMuts {
u := base
m.apply(&u)
if shareCardCacheKey(&u) == baseKey {
t.Errorf("user.%s is drawn on the card but not in shareCardCacheKey", m.field)
}
}
question := db.Question{Content: "c", Answer: "a"}
question.ID = 7
qKey := questionShareCardCacheKey(&base, &question)
questionMuts := []struct {
field string
apply func(u *db.User, q *db.Question)
}{
{"question.ID", func(u *db.User, q *db.Question) { q.ID++ }},
{"question.Content", func(u *db.User, q *db.Question) { q.Content = "x" }},
{"question.Answer", func(u *db.User, q *db.Question) { q.Answer = "x" }},
{"user.Domain", func(u *db.User, q *db.Question) { u.Domain = "x" }},
{"user.Name", func(u *db.User, q *db.Question) { u.Name = "x" }},
{"user.Avatar", func(u *db.User, q *db.Question) { u.Avatar = "x" }},
{"user.Background", func(u *db.User, q *db.Question) { u.Background = "x" }},
}
for _, m := range questionMuts {
u, q := base, question
m.apply(&u, &q)
if questionShareCardCacheKey(&u, &q) == qKey {
t.Errorf("%s is drawn on the question card but not in questionShareCardCacheKey", m.field)
}
}
}
// 视觉自检:TEST_PREVIEW=1 时把链接区渲成 PNG,人工确认断行与对齐。
func TestShareCardLinkPreview(t *testing.T) {
if os.Getenv("TEST_PREVIEW") == "" {