Files
tamabox/route/question/share.go
T
tamakyi 0124e392c7 fix(correctness): 三处幂等缺陷、私聊首条事务化、验证码失败不再丢正文
四处都属于「并发/失败路径下状态不一致」,用户侧表现为「点了没反应」「发了没收到」。

1) 删除会话重复回退配额
   MarkDeletedByOwner 是 check-then-act:双击时两个请求都读到 is_deleted_by_owner=false,
   都执行 UPDATE、都执行一次 IncrementQuota(-1)。计数不会变负,但会白吃掉其他会话的额度,
   表现为定量收集提前关闭,箱主重置计数也校准不回来。
   → 改条件更新 + RowsAffected 决定是否回退。

2) 双击产生两条重复追问
   「上一条追问必须先被回复」原来只在 handler 里判断(事务外):两个请求都能通过那次检查,
   随后被 CreateIfBelowLimit 的问题行锁串行化,各自 count 都 < 3 —— 两条相同追问都建成,
   吃掉 2/3 名额,箱主还收两封邮件。
   → 判定挪进事务,新增 ErrFollowUpAwaitingReply;handler 的预检查保留作为快速失败。

3) 双击置顶结果与预期相反
   SetPinned(id, !question.IsPinned) 是 read-modify-write:两个请求都读到 false 都写 true,
   用户点两次期望「置顶后取消」,实际仍是「置顶」。
   → 新增 SetPinnedIfCurrent(current, next),第二次因状态已变而 0 行,语义是「只切换一次」。

4) 私聊首条三步写入无事务
   建会话 → 写首条消息 → 计数是三次独立写入,第二步失败会留下 message_count=0 的空会话,
   箱主列表里能点进去却什么都没有;而 BeforeCreate 已占掉一个 6 字符 token(唯一索引),
   孤儿会话永久占用它。
   → 新增 CreateWithFirstMessage,三步收进一个事务。

5) 验证码失败丢正文
   该分支原来走 SetErrorFlash + Redirect,而其它失败分支都是 SetError + Success 回填表单。
   验证码失败恰恰是最常见的失败(token 过期、网络抖动),结果用户写好的正文整段丢失。
   → 改为同请求渲染并回填;错误文案不再透传 err.Error()(可能含上游原始信息)。

6) 5 处裸类型断言改带 ok(share.go ×3 / context.go ×1 / user/vote.go ×1),
   断言失败时丢弃脏缓存并走重建,而不是 panic 掉整个请求。share.go 的 ttlCache 补 Delete。

验证:内部 DB 测试 3 项(并发删除只回退一次配额 5→4、双击只产生一条追问、
双击只切换一次置顶);verify_round3.py 6/6(验证码失败 200+正文回填+不建提问、
私聊首条 message_count 与实际消息数一致)。
2026-10-29 16:28:34 +08:00

1255 lines
43 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package question
import (
"bytes"
stdctx "context"
"crypto/md5"
"errors"
"fmt"
"image"
"image/color"
"image/draw"
"image/jpeg"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"unicode"
"github.com/disintegration/imaging"
"github.com/golang/freetype"
"github.com/golang/freetype/truetype"
"github.com/sirupsen/logrus"
qrcode "github.com/skip2/go-qrcode"
"golang.org/x/image/math/fixed"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/context"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/shared"
"github.com/tamakyi/TamaBox/internal/template"
)
// 卡片尺寸与留白
const (
CardWidth = 960
CardHeight = 540
Margin = 15
)
// 内容区域边界
const (
ContentX = Margin
ContentY = Margin
ContentW = CardWidth - 2*Margin
ContentH = CardHeight - 2*Margin
)
// 头像参数
const (
AvatarSize = 112
AvatarCX = ContentX + 90
AvatarCY = ContentY + 90
AvatarRingW = 6
AvatarRingR = AvatarSize/2 + AvatarRingW
)
// 左侧文字区域
const (
TextStartX = AvatarCX + AvatarRingR + 30
TextStartY = ContentY + 75
)
// 右侧二维码卡片尺寸
const (
QRCardW = 270
QRCardH = 335
QRCardX = ContentX + ContentW - QRCardW - 15
QRCardY = ContentY + (ContentH-QRCardH)/2
)
// 二维码尺寸。190 在 37 模块(High 纠错、55 字符链接)下取整为 5px/模块:
// 实测 5px/模块的卡缩到 480、320 宽仍可解码,4px 时 480 就解不出。
const (
QRCodeSize = 190
)
// 社交信息
const (
SocialIconW = 24
SocialIconH = 24
SocialTextX = TextStartX + SocialIconW + 8
SocialLineH = 32
)
// 底部水印
const (
FooterY = ContentY + ContentH - 20
)
// 字体大小
const (
FontSizeName = 36
FontSizeIntro = 20
FontSizeLink = 15
FontSizeSocial = 17
FontSizeQRHint = 17
FontSizeFooter = 14
)
// 链接区排版:可用宽度按卡片内边距 20px 计算;字号下限保证长域名仍然可读,
// 单行放不下时改为 host/path 两行,而不是把字号一路缩到看不清。
const (
LinkMaxWidth = QRCardW - 40
LinkSingleMinSize = 13.0
LinkMinSize = 11.0
LinkSizeStep = 0.5
)
// 颜色系统
var (
ColorWhite = color.RGBA{255, 255, 255, 255}
ColorBlack = color.RGBA{0, 0, 0, 255}
ColorDarkText = color.RGBA{30, 30, 30, 255}
ColorGrayText = color.RGBA{100, 100, 100, 255}
ColorSocialGray = color.RGBA{80, 80, 80, 255}
ColorHintGray = color.RGBA{90, 90, 90, 255}
ColorFooterGray = color.RGBA{150, 150, 150, 255}
OverlayColor = color.NRGBA{R: 255, G: 255, B: 255, A: 165}
)
// 全局字体缓存。
//
// fontFail* 记录"上一次加载失败"与时间:失败结果要短期缓存,不能每次请求都重来。
// 缺字体时单次尝试的代价是遍历候选路径 + 读进 19 MB 的 .ttc 再解析失败(实测 0.7–4 秒纯 CPU),
// 而分享卡是**公开端点**——不缓存失败就等于给任何人一个"发一个 GET 烧几秒 CPU"的放大器。
// 仍保留 1 分钟重试窗口:字体文件是部署产物,补上后无需重启即可恢复。
var (
fontMu sync.Mutex
fontCache *truetype.Font
fontFailAt time.Time
fontFailErr error
)
// fontFailRetry 是加载失败后的重试间隔。
const fontFailRetry = time.Minute
// getFont 返回绘图字体。成功结果永久缓存;失败结果缓存 fontFailRetry 后重试。
func getFont() (*truetype.Font, error) {
fontMu.Lock()
defer fontMu.Unlock()
if fontCache != nil {
return fontCache, nil
}
if fontFailErr != nil && time.Since(fontFailAt) < fontFailRetry {
return nil, fontFailErr
}
font, err := loadFont()
if err != nil {
fontFailAt, fontFailErr = time.Now(), err
return nil, err
}
fontCache = font
fontFailErr = nil
return font, nil
}
// loadFont 遍历候选路径,返回第一个能被解析的字体。
func loadFont() (*truetype.Font, error) {
ft, _, err := loadFontWithPath()
return ft, err
}
// loadFontWithPath 同 loadFont,但额外返回命中的文件路径(启动日志要打出来)。
func loadFontWithPath() (*truetype.Font, string, error) {
var tried []string
for _, path := range fontCandidates() {
tried = append(tried, path)
data, err := os.ReadFile(path)
if err != nil {
continue
}
if ft, err := freetype.ParseFont(data); err == nil {
return ft, path, nil
}
// .ttc(TrueType Collection)是「字体集合」,ParseFont 读不了它;
// 先提取第一个字面再试。注意:这只对 TrueType 轮廓(glyf)的集合有效,
// Noto Sans CJK 那种 CFF/OTTO 轮廓 freetype 本身就不支持,提出来照样失败。
if extracted, ttcErr := ttcFirstFont(data); ttcErr == nil {
if ft, err := freetype.ParseFont(extracted); err == nil {
return ft, path + "(ttc 首字面)", nil
}
}
}
if len(tried) == 0 {
return nil, "", errors.New("font_path 未配置,且 static/fonts 下没有 .otf/.ttf/.ttc")
}
return nil, "", fmt.Errorf("没有可用字体(需 TrueType 轮廓的 ttf/otf/ttc,Noto Sans CJK 是 CFF 轮廓不支持),已尝试:%s", strings.Join(tried, "、"))
}
// CheckShareCardFont 在启动时探一次字体并把结果写进缓存,返回命中的路径。
//
// 为什么要提前探:分享卡是公开端点,缺字体只会在「第一个真实请求」时才 500,
// 而那已经是线上事故;启动日志里一条 WARN + 补救命令能让人当场发现。
// 顺带把首次请求的解析开销(读十几 MB + 解析)挪到启动阶段。
func CheckShareCardFont() (string, error) {
fontMu.Lock()
defer fontMu.Unlock()
ft, path, err := loadFontWithPath()
if err != nil {
fontFailAt, fontFailErr = time.Now(), err
return "", err
}
fontCache, fontFailErr = ft, nil
return path, nil
}
// fontCandidates 字体候选路径:配置项优先,其次是部署目录里的字体文件
// (换过文件名也能用上),最后是系统里能用的 CJK 字体。
//
// 顺序是有意的:
// - static/fonts 是部署产物,最可能就是「运维补的那个」;
// - 系统候选把 glyf 轮廓的放前面,Noto Sans CJK 压到最后 —— 后者是 CFF/OTTO,
// freetype 一定解析失败,只是偶尔会有 glyf 变体才留着当兜底。
//
// 相对路径按「当前工作目录」与「可执行文件所在目录」各查一次:systemd 单元
// 未必配 WorkingDirectory,只按 CWD 找会出现「文件明明在却找不到」。
func fontCandidates() []string {
var paths []string
if p := strings.TrimSpace(conf.App.FontPath); p != "" {
paths = append(paths, p)
}
for _, dir := range staticFontDirs() {
for _, pattern := range []string{"*.ttf", "*.otf", "*.ttc"} {
if matches, err := filepath.Glob(filepath.Join(dir, pattern)); err == nil {
paths = append(paths, matches...)
}
}
}
return append(paths,
// glyf 轮廓的 CJK 字体:apt install fonts-wqy-zenhei / fonts-wqy-microhei /
// fonts-droid-fallback / fonts-arphic-uming 任装一个即可工作
"/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
"/usr/share/fonts/truetype/wqy/wqy-microhei.ttc",
"/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf",
"/usr/share/fonts/truetype/arphic/uming.ttc",
// 兜底:CFF 轮廓,通常会解析失败,仅在存在 glyf 变体时有意义
"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/opentype/noto/NotoSansCJKsc-Regular.otf",
)
}
// staticFontDirs 返回要去扫字体文件的目录(去重、跳过不存在的)。
func staticFontDirs() []string {
dirs := []string{filepath.Join("static", "fonts")}
if exe, err := os.Executable(); err == nil {
dirs = append(dirs, filepath.Join(filepath.Dir(exe), "static", "fonts"))
}
seen := make(map[string]struct{}, len(dirs))
out := make([]string, 0, len(dirs))
for _, d := range dirs {
if _, ok := seen[d]; ok {
continue
}
seen[d] = struct{}{}
if st, err := os.Stat(d); err == nil && st.IsDir() {
out = append(out, d)
}
}
return out
}
// shareDialer 与 shareDialContext:SSRF 防护——解析域名后校验所有解析 IP 均非内网,
// 再交由 shared.DialFirstReachable 对公网候选 IP 并发拨号取最先成功者(happy-eyeballs 风格)。
// 防 DNS rebinding(IP 字面量直连、无二次解析)的同时,避免固定拨单一 IP 抽中劣化边缘
// 导致分享卡缺图(分享图源同样在 Cloudflare/R2,链路质量差时多 IP 容错尤为必要)。
var shareDialer = &net.Dialer{Timeout: 8 * time.Second, KeepAlive: 30 * time.Second}
// shareDialMaxCandidates 限制并发拨号扇出上限。
const shareDialMaxCandidates = 6
func shareDialContext(ctx stdctx.Context, network, addr string) (net.Conn, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, err
}
ips, err := net.LookupIP(host)
if err != nil {
return nil, err
}
var candidates []net.IP
for _, ip := range ips {
if isBlockedIP(ip) {
return nil, fmt.Errorf("blocked ip address: %s", ip)
}
candidates = append(candidates, ip)
}
if len(candidates) == 0 {
return nil, fmt.Errorf("no public ip address resolved for %s", host)
}
return shared.DialFirstReachable(ctx, shareDialer, network, candidates, port, shareDialMaxCandidates)
}
// 全局 HTTP 客户端:连接池复用,避免每次请求都重新建立 TCP/TLS 连接。
// 超时预算说明:分享卡图源(头像/背景/社交图标)同样位于 Cloudflare/R2,服务器到该链路
// 质量差(实测单次约 6.5s),总预算放宽以容纳多 IP 并发拨号与慢速首字节;R2 图缺失时
// 分享卡会静默降级(无头像/背景),宁可等待也不轻易放弃。
var shareHTTPClient = &http.Client{
Timeout: 20 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 8 * time.Second,
ResponseHeaderTimeout: 8 * time.Second,
DialContext: shareDialContext,
},
// SSRF 防护:重定向逐跳校验,防止 302 跳转到内网地址(如 169.254.169.254 云元数据)绕过初始校验
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 5 {
return fmt.Errorf("too many redirects")
}
if isBlockedImageURL(req.URL.String()) {
return fmt.Errorf("blocked redirect to %s", req.URL)
}
return nil
},
}
// ttlCache 是一个简单的并发安全带 TTL 缓存。
// 带 maxItems 条数上限,超限时先清过期、再淘汰最早过期的一条,
// 防止低配服务器(1C1G)上缓存无限膨胀。
type ttlCache struct {
mu sync.Mutex
items map[string]ttlCacheItem
maxItems int
}
type ttlCacheItem struct {
value interface{}
expiresAt time.Time
}
func newTTLCache(maxItems int) *ttlCache {
c := &ttlCache{items: make(map[string]ttlCacheItem), maxItems: maxItems}
// 常驻清理循环:每轮迭代单独 recover,panic 后循环不中断
go func() {
for range time.Tick(5 * time.Minute) {
func() {
defer func() {
if r := recover(); r != nil {
logrus.WithField("panic", r).Error("ttlCache cleanup panic recovered")
}
}()
c.cleanup()
}()
}
}()
return c
}
func (c *ttlCache) Get(key string) (interface{}, bool) {
c.mu.Lock()
defer c.mu.Unlock()
item, ok := c.items[key]
if !ok {
return nil, false
}
if time.Now().After(item.expiresAt) {
delete(c.items, key)
return nil, false
}
return item.value, true
}
// Delete 移除单条缓存。
// 用于读到「类型不符的脏数据」时丢弃这一条 —— 没有它就只能等下次 Set 覆盖,
// 而重建路径万一再次失败,这条脏数据会一直留在缓存里被反复读到。
func (c *ttlCache) Delete(key string) {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.items, key)
}
func (c *ttlCache) Set(key string, value interface{}, ttl time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
// 超限时先清过期,仍满则淘汰最早过期的一条
if len(c.items) >= c.maxItems {
now := time.Now()
for k, item := range c.items {
if now.After(item.expiresAt) {
delete(c.items, k)
}
}
if len(c.items) >= c.maxItems {
var victim string
var earliest time.Time
first := true
for k, item := range c.items {
if first || item.expiresAt.Before(earliest) {
earliest = item.expiresAt
victim = k
first = false
}
}
if victim != "" {
delete(c.items, victim)
}
}
}
c.items[key] = ttlCacheItem{value: value, expiresAt: time.Now().Add(ttl)}
}
func (c *ttlCache) cleanup() {
c.mu.Lock()
defer c.mu.Unlock()
now := time.Now()
for k, item := range c.items {
if now.After(item.expiresAt) {
delete(c.items, k)
}
}
}
// 远程图片缓存(背景图、头像、社交图标),TTL 1 小时。
// 存的是解码后的 image.Image,最吃内存(1280×720 RGBA ≈ 3.7MB/张),
// 1C1G 环境下限制为 24 条:正常站点同一时刻活跃用户远小于此值。
// 社交图标 URL 固定不变,基本永远命中;头像/背景变更时最多延迟 1 小时生效。
var imageCache = newTTLCache(24)
// 分享图结果缓存,TTL 1 小时。key 含用户资料指纹,资料变更后自动失效。
// 存的是 PNG 编码字节(约 0.2~1MB/张),限制 32 条,总内存 ≤ 32MB。
var shareCardCache = newTTLCache(32)
func ShareCard(ctx context.Context) {
domain := ctx.Param("domain")
user, err := db.Users.GetByDomain(ctx.Request().Context(), domain)
if err != nil {
ctx.Redirect("/")
return
}
// 站方处置:任何级别都不再生成整箱分享图(与单题分享卡一致)。
// 放在缓存之前 —— 拦截必须即时生效,否则处置后 30 分钟内仍能拿到旧图。
// DB 异常时按已处置处理(fail-closed):这张图会带上箱主信息与二维码,是外传载体。
if level, _, err := db.Restrictions.ActiveLevel(ctx.Request().Context(), user.ID); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to query user restriction")
ctx.Redirect("/")
return
} else if level != "" {
logrus.WithContext(ctx.Request().Context()).WithField("domain", domain).WithField("level", level).
Info("Profile share card blocked by restriction")
ctx.Redirect("/")
return
}
// 命中缓存直接返回,避免重复下载图片与重新绘制
cacheKey := shareCardCacheKey(user)
if cached, ok := shareCardCache.Get(cacheKey); ok {
if data, isBytes := cached.([]byte); isBytes {
writeShareCardResponse(ctx, data)
return
}
// 类型不符:理论上不会发生(key 由本文件独占写入),但裸断言遇到不符就是 panic。
// 丢弃这条脏缓存,走下面重新绘制。
shareCardCache.Delete(cacheKey)
}
canvas := image.NewRGBA(image.Rect(0, 0, CardWidth, CardHeight))
// 1. 背景
draw.Draw(canvas, canvas.Bounds(), &image.Uniform{ColorWhite}, image.Point{}, draw.Src)
innerRect := image.Rect(ContentX, ContentY, ContentX+ContentW, ContentY+ContentH)
// ---- 并发下载所有远程图片 ----
var (
bgImg image.Image
avImg image.Image
)
icons := []struct {
show bool
url string
icon string
}{
{user.Acfunlink != "" && user.Acfunlink != "none", user.Acfunlink, "https://r2.337845818.best/tamabox/icon/social/ailicili.png"},
{user.Bililink != "" && user.Bililink != "none", user.Bililink, "https://r2.337845818.best/tamabox/icon/social/bili.png"},
{user.Weibolink != "" && user.Weibolink != "none", user.Weibolink, "https://r2.337845818.best/tamabox/icon/social/weibo.png"},
{user.Bloglink != "" && user.Bloglink != "none", user.Bloglink, "https://r2.337845818.best/tamabox/icon/social/blog.png"},
{user.Xiaohongshulink != "" && user.Xiaohongshulink != "none", user.Xiaohongshulink, "https://r2.337845818.best/tamabox/icon/social/xiaohongshu.png"},
}
iconImgs := make([]image.Image, len(icons))
var wg sync.WaitGroup
// 背景图
bgURL := user.Background
if bgURL == "" {
bgURL = conf.App.ShareBackground
}
wg.Add(1)
shared.SafeGo(func() {
defer wg.Done()
img, err := loadImage(bgURL, ContentW, ContentH)
if err == nil {
bgImg = img
}
})
// 头像
wg.Add(1)
shared.SafeGo(func() {
defer wg.Done()
img, err := loadImage(user.Avatar, AvatarSize, AvatarSize)
if err == nil {
avImg = img
}
})
// 社交图标
for i, item := range icons {
if !item.show {
continue
}
idx, iconURL := i, item.icon
wg.Add(1)
shared.SafeGo(func() {
defer wg.Done()
img, err := loadImage(iconURL, SocialIconW, SocialIconH)
if err == nil {
iconImgs[idx] = img
}
})
}
wg.Wait()
// ---- 下载结束 ----
// 绘制背景
if bgImg != nil {
draw.Draw(canvas, innerRect, bgImg, image.Point{}, draw.Src)
}
overlay := image.NewUniform(OverlayColor)
draw.Draw(canvas, innerRect, overlay, image.Point{}, draw.Over)
// 2. 加载字体(复用缓存)
ft, err := getFont()
if err != nil {
http.Error(ctx.ResponseWriter(), "分享图生成失败:字体文件未配置或无法加载", http.StatusInternalServerError)
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to load font for share card")
return
}
fontCtx := freetype.NewContext()
fontCtx.SetFont(ft)
fontCtx.SetFontSize(24)
fontCtx.SetDPI(72)
// 3. 头像
drawCircleFilled(canvas, AvatarCX, AvatarCY, AvatarRingR, ColorWhite)
if avImg != nil {
avImg = imaging.Fill(avImg, AvatarSize, AvatarSize, imaging.Center, imaging.Lanczos)
mask := image.NewAlpha(image.Rect(0, 0, AvatarSize, AvatarSize))
drawCircle(mask, AvatarSize/2, AvatarSize/2, AvatarSize/2, color.Alpha{255})
draw.DrawMask(canvas,
image.Rect(AvatarCX-AvatarSize/2, AvatarCY-AvatarSize/2, AvatarCX+AvatarSize/2, AvatarCY+AvatarSize/2),
avImg, image.Point{}, mask, image.Point{}, draw.Over)
} else {
mask := image.NewAlpha(image.Rect(0, 0, AvatarSize, AvatarSize))
drawCircle(mask, AvatarSize/2, AvatarSize/2, AvatarSize/2, color.Alpha{255})
draw.DrawMask(canvas,
image.Rect(AvatarCX-AvatarSize/2, AvatarCY-AvatarSize/2, AvatarCX+AvatarSize/2, AvatarCY+AvatarSize/2),
&image.Uniform{color.Gray{180}}, image.Point{}, mask, image.Point{}, draw.Over)
}
// 4. 左侧文字
// 文字区裁剪边界:左侧文字不得侵入右侧二维码卡片(QRCardX 左侧留 20px 间距)。
leftClip := image.Rect(TextStartX, ContentY, QRCardX-20, ContentY+ContentH)
leftMaxW := QRCardX - TextStartX - 60
y := TextStartY
drawTextWithShadow(canvas, user.Name, TextStartX, y, FontSizeName, ColorDarkText, color.NRGBA{0, 0, 0, 50}, 1, fontCtx)
y += int(FontSizeName) + 15
// 简介最多 4 行,超出截断加省略号,避免长简介向下覆盖社交区/水印
y = drawTextMultilineLimited(canvas, user.Intro, TextStartX, y, leftMaxW, FontSizeIntro, ColorGrayText, fontCtx, 46, 4, leftClip)
y += 30
// 5. 社交信息
socialY := y + 22
for i, item := range icons {
if !item.show || iconImgs[i] == nil {
continue
}
draw.Draw(canvas,
image.Rect(TextStartX, socialY, TextStartX+SocialIconW, socialY+SocialIconH),
iconImgs[i], image.Point{}, draw.Over)
drawTextClipped(canvas, item.url, SocialTextX, socialY+SocialIconH-6, FontSizeSocial, ColorSocialGray, fontCtx, leftClip)
socialY += SocialLineH
}
// 6. 右侧二维码卡片
cardRect := image.Rect(QRCardX, QRCardY, QRCardX+QRCardW, QRCardY+QRCardH)
cardCenterX := QRCardX + QRCardW/2
drawRoundedRect(canvas, cardRect, color.NRGBA{245, 245, 245, 255}, 18)
drawRoundedRect(canvas, cardRect, ColorWhite, 18)
boxURL := fmt.Sprintf("%s/_/%s", conf.App.ExternalURL, user.Domain)
qr, err := generateQR(boxURL, avImg, QRCodeSize)
if err != nil {
http.Error(ctx.ResponseWriter(), "二维码生成失败", http.StatusInternalServerError)
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to generate QR code")
return
}
qrSize := qr.Bounds().Dx()
const (
qrToHintGap = 24
hintLineHeight = 17
hintToSepGap = 20
sepHeight = 1
sepToLinkGap = 20
linkLineHeight = 16
)
linkLines, linkFontSize := layoutLink(displayURL(boxURL))
totalContentH := qrSize + qrToHintGap + hintLineHeight + hintToSepGap + sepHeight + sepToLinkGap + linkLineHeight*len(linkLines)
startY := QRCardY + (QRCardH-totalContentH)/2
qrDrawX := QRCardX + (QRCardW-qrSize)/2
qrDrawY := startY
draw.Draw(canvas, image.Rect(qrDrawX, qrDrawY, qrDrawX+qrSize, qrDrawY+qrSize), qr, image.Point{}, draw.Over)
hintY := qrDrawY + qrSize + qrToHintGap + hintLineHeight
drawTextCentered(canvas, "扫码向我匿名提问", cardCenterX, hintY, FontSizeQRHint, ColorHintGray, fontCtx)
sepY := hintY + hintToSepGap
drawLine(canvas, cardCenterX-90, sepY, cardCenterX+90, sepY, color.NRGBA{200, 200, 200, 180})
linkY := sepY + sepToLinkGap + linkLineHeight
for i, line := range linkLines {
drawTextCentered(canvas, line, cardCenterX, linkY+i*linkLineHeight, linkFontSize, ColorDarkText, fontCtx)
}
// 7. 底部水印
drawTextCentered(canvas, "Powered by 狼的提问箱", CardWidth/2, FooterY, FontSizeFooter, ColorFooterGray, fontCtx)
// 8. 输出(缓存 + 响应)
buf := new(bytes.Buffer)
if err := jpeg.Encode(buf, canvas, &jpeg.Options{Quality: 88}); err != nil {
http.Error(ctx.ResponseWriter(), "图片生成失败", http.StatusInternalServerError)
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to encode share card PNG")
return
}
shareCardCache.Set(cacheKey, buf.Bytes(), time.Hour)
writeShareCardResponse(ctx, buf.Bytes())
}
// writeShareCardResponse 统一输出分享图响应。
// 加 Cache-Control 让浏览器与 CDN(如 Cloudflare)缓存,进一步减轻服务器压力。
func writeShareCardResponse(ctx context.Context, data []byte) {
ctx.ResponseWriter().Header().Set("Content-Type", "image/jpeg")
ctx.ResponseWriter().Header().Set("Content-Disposition", "attachment; filename=\"share.jpg\"")
ctx.ResponseWriter().Header().Set("Content-Length", strconv.Itoa(len(data)))
ctx.ResponseWriter().Header().Set("Cache-Control", "public, max-age=300")
ctx.ResponseWriter().WriteHeader(http.StatusOK)
_, _ = ctx.ResponseWriter().Write(data)
}
// shareCardCacheKey 基于用户资料生成缓存指纹。
// 资料(域名/名称/简介/头像/背景/社交链接)任何一项变更都会导致 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",
user.Domain, user.Name, user.Intro, user.Avatar, user.Background,
user.Acfunlink, user.Bililink, user.Weibolink, user.Bloglink, user.Xiaohongshulink)
return fmt.Sprintf("sharecard:%x", h.Sum(nil))
}
// ---------- 工具函数 ----------
// maxImagePixels 限制远程图片解码后的最大像素总量(4096×4096≈67MB RGBA),
// 防止恶意超大图耗尽服务器内存。
const maxImagePixels = 4096 * 4096
// isBlockedImageURL SSRF 防护:仅允许 http/https,且主机不能解析到私网/环回等内网地址。
func isBlockedImageURL(rawURL string) bool {
u, err := url.Parse(rawURL)
if err != nil {
return true
}
if u.Scheme != "http" && u.Scheme != "https" {
return true
}
host := u.Hostname()
if host == "" {
return true
}
// 直接是 IP 字面量:私网/环回/链路本地/未指定/组播一律拒绝
if ip := net.ParseIP(host); ip != nil {
return isBlockedIP(ip)
}
// 域名:解析出任一内网 IP 即拒绝
ips, err := net.LookupIP(host)
if err != nil {
// 解析失败交给 HTTP 层报错(不阻断),避免 DNS 临时故障误伤正常图片
return false
}
for _, ip := range ips {
if isBlockedIP(ip) {
return true
}
}
return false
}
func isBlockedIP(ip net.IP) bool {
return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() || ip.IsUnspecified() || ip.IsMulticast()
}
func loadImage(url string, w, h int) (image.Image, error) {
// SSRF 防护:拒绝非 http/https 与内网地址(127.0.0.1、10.x、192.168.x 等)
if isBlockedImageURL(url) {
return nil, fmt.Errorf("blocked image url: %s", url)
}
key := fmt.Sprintf("%s|%dx%d", url, w, h)
if cached, ok := imageCache.Get(key); ok {
if img, isImage := cached.(image.Image); isImage {
return img, nil
}
imageCache.Delete(key)
}
resp, err := shareHTTPClient.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("bad status: %s", resp.Status)
}
img, _, err := image.Decode(resp.Body)
if err != nil {
return nil, err
}
// 解码后限制像素总量,防止超大图耗尽内存
b := img.Bounds()
if b.Dx()*b.Dy() > maxImagePixels {
return nil, fmt.Errorf("image too large: %dx%d", b.Dx(), b.Dy())
}
filled := imaging.Fill(img, w, h, imaging.Center, imaging.Lanczos)
imageCache.Set(key, filled, time.Hour)
return filled, nil
}
func drawCircle(mask *image.Alpha, cx, cy, r int, c color.Alpha) {
for x := -r; x <= r; x++ {
for y := -r; y <= r; y++ {
if x*x+y*y <= r*r {
mask.SetAlpha(cx+x, cy+y, c)
}
}
}
}
func drawCircleFilled(dst *image.RGBA, cx, cy, r int, clr color.Color) {
for x := -r; x <= r; x++ {
for y := -r; y <= r; y++ {
if x*x+y*y <= r*r {
dst.Set(cx+x, cy+y, clr)
}
}
}
}
func drawRoundedRect(dst *image.RGBA, rect image.Rectangle, clr color.Color, radius int) {
draw.Draw(dst, image.Rect(rect.Min.X+radius, rect.Min.Y, rect.Max.X-radius, rect.Max.Y),
&image.Uniform{clr}, image.Point{}, draw.Over)
draw.Draw(dst, image.Rect(rect.Min.X, rect.Min.Y+radius, rect.Max.X, rect.Max.Y-radius),
&image.Uniform{clr}, image.Point{}, draw.Over)
drawCircleFilledRGBA(dst, rect.Min.X+radius, rect.Min.Y+radius, radius, clr)
drawCircleFilledRGBA(dst, rect.Max.X-radius, rect.Min.Y+radius, radius, clr)
drawCircleFilledRGBA(dst, rect.Min.X+radius, rect.Max.Y-radius, radius, clr)
drawCircleFilledRGBA(dst, rect.Max.X-radius, rect.Max.Y-radius, radius, clr)
}
func drawCircleFilledRGBA(dst *image.RGBA, cx, cy, r int, clr color.Color) {
for x := -r; x <= r; x++ {
for y := -r; y <= r; y++ {
if x*x+y*y <= r*r {
dst.Set(cx+x, cy+y, clr)
}
}
}
}
func drawLine(dst *image.RGBA, x1, y1, x2, y2 int, clr color.Color) {
if y1 != y2 {
return
}
for x := x1; x <= x2; x++ {
dst.Set(x, y1, clr)
}
}
func drawText(dst *image.RGBA, text string, x, y int, fontSize float64, col color.Color, ftCtx *freetype.Context) {
ftCtx.SetClip(dst.Bounds())
ftCtx.SetSrc(image.NewUniform(col))
ftCtx.SetFontSize(fontSize)
ftCtx.SetDst(dst)
_, _ = ftCtx.DrawString(text, freetype.Pt(x, y))
}
// drawTextClipped 同 drawText,但将绘制裁剪到 clip 区域。
// 用于左侧文字区:即使宽度估算偏差导致某行文字偏宽,也只会被裁掉,不会侵入右侧二维码卡片。
func drawTextClipped(dst *image.RGBA, text string, x, y int, fontSize float64, col color.Color, ftCtx *freetype.Context, clip image.Rectangle) {
ftCtx.SetClip(clip)
ftCtx.SetSrc(image.NewUniform(col))
ftCtx.SetFontSize(fontSize)
ftCtx.SetDst(dst)
_, _ = ftCtx.DrawString(text, freetype.Pt(x, y))
}
// drawTextWithShadow 先绘制偏移阴影再绘制原色文字,增强在复杂背景上的可读性。
func drawTextWithShadow(dst *image.RGBA, text string, x, y int, fontSize float64, col, shadow color.Color, offset int, ftCtx *freetype.Context) {
drawText(dst, text, x+offset, y+offset, fontSize, shadow, ftCtx)
drawText(dst, text, x, y, fontSize, col, ftCtx)
}
func splitText(text string, fontSize float64, maxWidth int) []string {
var lines []string
var currentLine []rune
var currentWidth float64
for _, r := range text {
charW := charPixelWidth(r, fontSize)
if currentWidth+charW > float64(maxWidth) && len(currentLine) > 0 {
lines = append(lines, string(currentLine))
currentLine = []rune{r}
currentWidth = charW
} else {
currentLine = append(currentLine, r)
currentWidth += charW
}
}
if len(currentLine) > 0 {
lines = append(lines, string(currentLine))
}
return lines
}
func charPixelWidth(r rune, fontSize float64) float64 {
// 宽度系数取「保守上界」:中文字体是方形,实际渲染宽度≈fontSize;
// 若估算偏窄,splitText 换行后的行宽会超出 maxWidth,文字侵入相邻区域。
// 这里按实际宽度的上界估算,保证 splitText 的行数是安全的(宁可多换行,配合行数截断)。
if unicode.Is(unicode.Han, r) || unicode.Is(unicode.Punct, r) || unicode.Is(unicode.Symbol, r) {
return fontSize * 1.0
}
return fontSize * 0.62
}
// measureTextWidth 返回文本在给定字号下的真实推进宽度(像素)。
// 逐字累加字形 advance 与 kern,scale 与 freetype.Context 在 72 DPI 下的换算一致
// (fontSize * 64);用它做居中,误差从原来估算的几十像素降到亚像素。
// 字体不可用时回退到字符宽度估算,保证分享卡不会因字体缺失而完全不可用。
func measureTextWidth(text string, fontSize float64) float64 {
ft, err := getFont()
if err != nil || ft == nil {
return measureTextEstimate(text, fontSize)
}
scale := fixed.Int26_6(fontSize * 64.0)
total := fixed.Int26_6(0)
var prev truetype.Index
hasPrev := false
for _, r := range text {
idx := ft.Index(r)
if hasPrev {
total += ft.Kern(scale, prev, idx)
}
total += ft.HMetric(scale, idx).AdvanceWidth
prev, hasPrev = idx, true
}
return float64(total) / 64.0
}
// measureTextEstimate 按字符宽度系数粗估,仅作字体不可用时的回退。
// 注意:标点/符号按 1em 计,对 URL 会高估 50% 以上,不能用于居中定位。
func measureTextEstimate(text string, fontSize float64) float64 {
total := 0.0
for _, r := range text {
total += charPixelWidth(r, fontSize)
}
return total
}
// displayURL 去掉协议前缀与结尾斜杠:二维码里仍编码完整地址,
// 人眼需要辨认的只是 host 与路径。
func displayURL(rawURL string) string {
s := strings.TrimPrefix(rawURL, "https://")
s = strings.TrimPrefix(s, "http://")
return strings.TrimSuffix(s, "/")
}
// layoutLink 把展示用地址收进卡片宽度,返回逐行文本与统一字号。
// 顺序:单行 15→13px;仍放不下按 host/path 拆两行(13→11px,而不是在
// host 中间的连字符处断开);host 自身超宽才做中间省略。
func layoutLink(text string) ([]string, float64) {
maxW := float64(LinkMaxWidth)
for size := float64(FontSizeLink); size >= LinkSingleMinSize; size -= LinkSizeStep {
if measureTextWidth(text, size) <= maxW {
return []string{text}, size
}
}
if i := strings.Index(text, "/"); i > 0 {
host, path := text[:i], text[i:]
for size := LinkSingleMinSize; size >= LinkMinSize; size -= LinkSizeStep {
if measureTextWidth(host, size) <= maxW && measureTextWidth(path, size) <= maxW {
return []string{host, path}, size
}
}
return []string{
ellipsizeMiddle(host, maxW, LinkMinSize),
ellipsizeMiddle(path, maxW, LinkMinSize),
}, LinkMinSize
}
return []string{ellipsizeMiddle(text, maxW, LinkMinSize)}, LinkMinSize
}
// ellipsizeMiddle 中间省略:保住开头与结尾(域名结尾通常是 TLD,最需要留下)。
func ellipsizeMiddle(text string, maxWidth, fontSize float64) string {
if measureTextWidth(text, fontSize) <= maxWidth {
return text
}
r := []rune(text)
for keep := len(r) - 1; keep >= 3; keep-- {
head := keep * 6 / 10
tail := keep - head
s := string(r[:head]) + "…" + string(r[len(r)-tail:])
if measureTextWidth(s, fontSize) <= maxWidth {
return s
}
}
return text
}
func drawTextCentered(dst *image.RGBA, text string, cx, y int, fontSize float64, col color.Color, ftCtx *freetype.Context) {
ftCtx.SetClip(dst.Bounds())
ftCtx.SetSrc(image.NewUniform(col))
ftCtx.SetFontSize(fontSize)
actualW := measureTextWidth(text, fontSize)
x := cx - int(actualW/2)
if x < 0 {
x = 0
}
ftCtx.SetDst(dst)
_, _ = ftCtx.DrawString(text, freetype.Pt(x, y))
}
// generateQR 生成边长固定为 size 的二维码:按模块整数倍出图再居中留白,
// 避免直接缩放到任意尺寸造成的模块宽窄相差 1px(小尺寸下最影响可扫性)。
func generateQR(content string, logoImg image.Image, size int) (image.Image, error) {
qr, err := qrcode.New(content, qrcode.High)
if err != nil {
return nil, err
}
// 强制传统黑白,忽略用户所有颜色设置
qr.BackgroundColor = color.RGBA{255, 255, 255, 255}
qr.ForegroundColor = color.RGBA{0, 0, 0, 255}
// 静默区由承载二维码的白色卡片提供(四周留白远超 4 模块),图像本身只输出符号:
// 若把静默区画进图里,再叠加居中留白,目标 165px 实际只剩 111px,模块被压到 3px,
// 微信/QQ 二次压缩后极易扫不动。
qr.DisableBorder = true
modules := len(qr.Bitmap())
perModule := size / modules
if perModule < 1 {
perModule = 1
}
symbol := qr.Image(-perModule)
dst := image.NewRGBA(symbol.Bounds())
draw.Draw(dst, dst.Bounds(), symbol, image.Point{}, draw.Src)
if logoImg != nil {
w := dst.Bounds().Dx()
logoSize := w * 3 / 16
logo := imaging.Fill(logoImg, logoSize, logoSize, imaging.Center, imaging.Lanczos)
// 白边保证头像与模块之间有对比度,H 级容错(30%)足以覆盖这点面积
holder := image.Rect(0, 0, logoSize+4, logoSize+4)
holder = holder.Add(image.Pt((w-logoSize-4)/2, (w-logoSize-4)/2))
draw.Draw(dst, holder, &image.Uniform{ColorWhite}, image.Point{}, draw.Src)
draw.Draw(dst, logo.Bounds().Add(holder.Min.Add(image.Pt(2, 2))), logo, image.Point{}, draw.Over)
}
return dst, nil
}
// ---------- 单题分享图 ----------
// 单题分享图布局常量(1280x720)
const (
QContentX = 45
QContentY = 45
QContentW = CardWidth - 2*QContentX
QContentH = CardHeight - 2*QContentY
QAvatarSize = 33
QAvatarX = QContentX + 2
QAvatarY = QContentY + 2
QNameX = QAvatarX + QAvatarSize + 12
QNameY = QContentY + 28
QSepY = QContentY + 50
QLabelFont = 20.0
QQuestionFont = 26.0
QQuestionLineH = 34
QQuestionMaxLine = 3
QAnswerFont = 22.0
QAnswerLineH = 30
QAnswerMaxLine = 7
// 右侧二维码卡片
QQRCardW = 236
QQRCardH = 300
QQRCardX = QContentX + QContentW - QQRCardW
QQRCardY = QContentY + (QContentH-QQRCardH)/2
QQRSize = 190
// 内容区最大宽度(左侧,避开二维码卡片)
QTextMaxW = QQRCardX - QContentX - 30
QWatermarkY = CardHeight - 30
)
// drawQuestionQRCard 绘制单题卡右侧的二维码卡片:二维码与提示语整体垂直居中。
func drawQuestionQRCard(canvas *image.RGBA, user *db.User, question *db.Question, logo image.Image, fontCtx *freetype.Context) error {
cardRect := image.Rect(QQRCardX, QQRCardY, QQRCardX+QQRCardW, QQRCardY+QQRCardH)
drawRoundedRect(canvas, cardRect, color.NRGBA{245, 245, 245, 255}, 20)
drawRoundedRect(canvas, cardRect, ColorWhite, 20)
questionURL := fmt.Sprintf("%s/_/%s/%d", conf.App.ExternalURL, user.Domain, question.ID)
qr, err := generateQR(questionURL, logo, QQRSize)
if err != nil {
return err
}
const qrToHintGap, hintLineHeight = 30, 17
qrSize := qr.Bounds().Dx()
contentH := qrSize + qrToHintGap + hintLineHeight
qrX := QQRCardX + (QQRCardW-qrSize)/2
qrY := QQRCardY + (QQRCardH-contentH)/2
draw.Draw(canvas, image.Rect(qrX, qrY, qrX+qrSize, qrY+qrSize), qr, image.Point{}, draw.Over)
drawTextCentered(canvas, "扫码查看回答", QQRCardX+QQRCardW/2, qrY+qrSize+qrToHintGap+hintLineHeight, FontSizeQRHint, ColorHintGray, fontCtx)
return nil
}
// 单题分享图结果缓存,TTL 1 小时,上限 16 条(1C1G 内存友好)。
var questionShareCache = newTTLCache(16)
// QuestionShareCard 生成单个问题(问题+回答)的分享卡片图。
func QuestionShareCard(ctx context.Context) {
domain := ctx.Param("domain")
user, err := db.Users.GetByDomain(ctx.Request().Context(), domain)
if err != nil {
ctx.Redirect("/")
return
}
questionID := uint(ctx.ParamInt("questionID"))
question, err := db.Questions.GetByID(ctx.Request().Context(), questionID)
if err != nil {
ctx.Redirect("/")
return
}
// 仅公开且已回答的问题可以生成分享图(Questioner 中间件已挡住私密/未回答,这里再兜底一次)
if question.UserID != user.ID || question.Answer == "" || question.IsPrivate {
ctx.Redirect("/")
return
}
// 站方处置:任何级别都禁止生成分享图。
// 分享卡会把「提问 + 回答」渲染成一张带箱主水印与二维码的图片,是挂人内容外流的主要通道
// —— 图片离开原页面后没有上下文、没有原链接、无法回溯,比页面本身传播得更远。
// 因此 L1「停止分发」首先就落在这里。查询失败按已处置处理(fail-closed):
// 宁可少出一张卡,也不要让处置在 DB 抖动时静默失效。
level, _, err := db.Restrictions.ActiveLevel(ctx.Request().Context(), user.ID)
if err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to query user restriction")
ctx.Redirect("/")
return
}
if level != "" {
logrus.WithContext(ctx.Request().Context()).
WithField("domain", domain).
WithField("level", level).
Info("Share card blocked by restriction")
ctx.Redirect("/")
return
}
cacheKey := questionShareCardCacheKey(user, question)
if cached, ok := questionShareCache.Get(cacheKey); ok {
if data, isBytes := cached.([]byte); isBytes {
writeShareCardResponse(ctx, data)
return
}
questionShareCache.Delete(cacheKey)
}
canvas := image.NewRGBA(image.Rect(0, 0, CardWidth, CardHeight))
innerRect := image.Rect(QContentX, QContentY, QContentX+QContentW, QContentY+QContentH)
// ---- 并发加载背景与头像 ----
var (
bgImg image.Image
avImg image.Image
)
var wg sync.WaitGroup
bgURL := user.Background
if bgURL == "" {
bgURL = conf.App.ShareBackground
}
wg.Add(1)
shared.SafeGo(func() {
defer wg.Done()
img, err := loadImage(bgURL, QContentW, QContentH)
if err == nil {
bgImg = img
}
})
wg.Add(1)
shared.SafeGo(func() {
defer wg.Done()
img, err := loadImage(user.Avatar, QAvatarSize, QAvatarSize)
if err == nil {
avImg = img
}
})
wg.Wait()
// 背景 + 白色遮罩
draw.Draw(canvas, canvas.Bounds(), &image.Uniform{ColorWhite}, image.Point{}, draw.Src)
if bgImg != nil {
draw.Draw(canvas, innerRect, bgImg, image.Point{}, draw.Src)
}
draw.Draw(canvas, innerRect, image.NewUniform(OverlayColor), image.Point{}, draw.Over)
// 字体
ft, err := getFont()
if err != nil {
http.Error(ctx.ResponseWriter(), "分享图生成失败:字体文件未配置或无法加载", http.StatusInternalServerError)
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to load font for question share card")
return
}
fontCtx := freetype.NewContext()
fontCtx.SetFont(ft)
fontCtx.SetFontSize(24)
fontCtx.SetDPI(72)
// 头部:头像 + 名字
drawCircleFilled(canvas, QAvatarX+QAvatarSize/2, QAvatarY+QAvatarSize/2, QAvatarSize/2+2, ColorWhite)
if avImg != nil {
avImg = imaging.Fill(avImg, QAvatarSize, QAvatarSize, imaging.Center, imaging.Lanczos)
mask := image.NewAlpha(image.Rect(0, 0, QAvatarSize, QAvatarSize))
drawCircle(mask, QAvatarSize/2, QAvatarSize/2, QAvatarSize/2, color.Alpha{255})
draw.DrawMask(canvas,
image.Rect(QAvatarX, QAvatarY, QAvatarX+QAvatarSize, QAvatarY+QAvatarSize),
avImg, image.Point{}, mask, image.Point{}, draw.Over)
} else {
mask := image.NewAlpha(image.Rect(0, 0, QAvatarSize, QAvatarSize))
drawCircle(mask, QAvatarSize/2, QAvatarSize/2, QAvatarSize/2, color.Alpha{255})
draw.DrawMask(canvas,
image.Rect(QAvatarX, QAvatarY, QAvatarX+QAvatarSize, QAvatarY+QAvatarSize),
&image.Uniform{color.Gray{180}}, image.Point{}, mask, image.Point{}, draw.Over)
}
drawTextWithShadow(canvas, user.Name+" 的提问箱", QNameX, QNameY, 22, ColorDarkText, color.NRGBA{0, 0, 0, 45}, 1, fontCtx)
// 分隔线
drawLine(canvas, QContentX+2, QSepY, QContentX+QContentW-2, QSepY, color.NRGBA{190, 190, 190, 200})
// 左侧文字区裁剪边界:问题/回答文字不得侵入右侧二维码卡片(QQRCardX 左侧留 10px 间距)。
qLeftClip := image.Rect(QContentX, QContentY, QQRCardX-10, QContentY+QContentH)
// 问题区
labelY := QSepY + 32
drawText(canvas, "提问", QContentX+2, labelY, QLabelFont, ColorGrayText, fontCtx)
textY := labelY + 30
textY = drawTextMultilineLimited(canvas, sanitizeText(template.MarkdownToPlainText(question.Content)), QContentX+2, textY, QTextMaxW, QQuestionFont, ColorDarkText, fontCtx, QQuestionLineH, QQuestionMaxLine, qLeftClip)
// 回答区
labelY = textY + 22
drawText(canvas, "回答", QContentX+2, labelY, QLabelFont, ColorGrayText, fontCtx)
textY = labelY + 28
textY = drawTextMultilineLimited(canvas, sanitizeText(template.MarkdownToPlainText(question.Answer)), QContentX+2, textY, QTextMaxW, QAnswerFont, ColorDarkText, fontCtx, QAnswerLineH, QAnswerMaxLine, qLeftClip)
_ = textY
// 右侧二维码卡片
if err := drawQuestionQRCard(canvas, user, question, avImg, fontCtx); err != nil {
http.Error(ctx.ResponseWriter(), "二维码生成失败", http.StatusInternalServerError)
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to generate QR code for question share card")
return
}
// 底部水印
drawTextCentered(canvas, "Powered by 狼的提问箱", CardWidth/2, QWatermarkY, FontSizeFooter, ColorFooterGray, fontCtx)
// 输出(缓存 + 响应)
buf := new(bytes.Buffer)
if err := jpeg.Encode(buf, canvas, &jpeg.Options{Quality: 88}); err != nil {
http.Error(ctx.ResponseWriter(), "图片生成失败", http.StatusInternalServerError)
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to encode question share card PNG")
return
}
questionShareCache.Set(cacheKey, buf.Bytes(), time.Hour)
writeShareCardResponse(ctx, buf.Bytes())
}
// 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|%s",
question.ID, question.Content, question.Answer, user.Domain, user.Name, user.Avatar, user.Background)
return fmt.Sprintf("qshare:%x", h.Sum(nil))
}
// sanitizeText 将换行符替换为空格,避免绘图时排版错乱。
func sanitizeText(text string) string {
return strings.ReplaceAll(text, "\n", " ")
}
// drawTextMultilineLimited 多行绘制,超过 maxLines 行时截断并追加省略号。
// clip 限定绘制区域,防止估算换行偏差导致文字溢出到相邻元素(如右侧二维码卡片)。
func drawTextMultilineLimited(dst *image.RGBA, text string, x, y int, maxWidth int, fontSize float64, col color.Color, ftCtx *freetype.Context, lineHeight int, maxLines int, clip image.Rectangle) int {
lines := splitText(text, fontSize, maxWidth)
if len(lines) > maxLines {
lines = lines[:maxLines]
lines[maxLines-1] = lines[maxLines-1] + "…"
}
curY := y
for _, line := range lines {
drawTextClipped(dst, line, x, curY, fontSize, col, ftCtx, clip)
curY += lineHeight
}
return curY
}