From 675cecb21d6745fa386ff44adac14052c8b9ddb5 Mon Sep 17 00:00:00 2001 From: tamakyi Date: Fri, 23 Oct 2026 00:58:31 +0800 Subject: [PATCH] =?UTF-8?q?fix(share):=20=E5=8D=95=E9=A2=98=E5=8D=A1?= =?UTF-8?q?=E7=BC=93=E5=AD=98=20key=20=E8=A1=A5=20user.Domain=EF=BC=9B?= =?UTF-8?q?=E7=BA=A0=E6=AD=A3=E8=BF=87=E6=9C=9F=E7=9A=84=20TTL=20=E6=B3=A8?= =?UTF-8?q?=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 先查了「缓存 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"。 --- route/question/share.go | 13 ++++--- route/question/share_layout_test.go | 53 +++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 4 deletions(-) diff --git a/route/question/share.go b/route/question/share.go index 9e6189e..7cb348a 100644 --- a/route/question/share.go +++ b/route/question/share.go @@ -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)) } diff --git a/route/question/share_layout_test.go b/route/question/share_layout_test.go index 8d54e21..251046f 100644 --- a/route/question/share_layout_test.go +++ b/route/question/share_layout_test.go @@ -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") == "" {