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") == "" {