This commit is contained in:
tamakyi
2026-09-08 10:10:44 +08:00
commit 2ed27dd55e
288 changed files with 67061 additions and 0 deletions
+970
View File
@@ -0,0 +1,970 @@
package question
import (
"bytes"
stdctx "context"
"crypto/md5"
"fmt"
"image"
"image/color"
"image/draw"
"image/jpeg"
"net"
"net/http"
"net/url"
"os"
"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"
"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 = 315
QRCardX = ContentX + ContentW - QRCardW - 15
QRCardY = ContentY + (ContentH-QRCardH)/2
)
// 二维码尺寸
const (
QRCodeSize = 165
QRCodeX = (QRCardW - QRCodeSize) / 2
)
// 社交信息
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
)
// 颜色系统
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}
)
// 全局字体缓存
var (
fontOnce sync.Once
fontCache *truetype.Font
fontErr error
)
func getFont() (*truetype.Font, error) {
fontOnce.Do(func() {
fontBytes, err := os.ReadFile(conf.App.FontPath)
if err != nil {
fontErr = err
return
}
fontCache, fontErr = freetype.ParseFont(fontBytes)
})
return fontCache, fontErr
}
// 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
}
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 30 分钟。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
}
// 命中缓存直接返回,避免重复下载图片与重新绘制
cacheKey := shareCardCacheKey(user)
if cached, ok := shareCardCache.Get(cacheKey); ok {
writeShareCardResponse(ctx, cached.([]byte))
return
}
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, user, avImg)
if err != nil {
http.Error(ctx.ResponseWriter(), "二维码生成失败", http.StatusInternalServerError)
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to generate QR code")
return
}
qrResized := imaging.Resize(qr, QRCodeSize, QRCodeSize, imaging.NearestNeighbor)
const (
qrToHintGap = 26
hintLineHeight = 17
hintToSepGap = 21
sepHeight = 1
sepToLinkGap = 21
linkLineHeight = 15
)
totalContentH := QRCodeSize + qrToHintGap + hintLineHeight + hintToSepGap + sepHeight + sepToLinkGap + linkLineHeight
startY := QRCardY + (QRCardH-totalContentH)/2
qrDrawX := QRCardX + QRCodeX
qrDrawY := startY
draw.Draw(canvas, image.Rect(qrDrawX, qrDrawY, qrDrawX+QRCodeSize, qrDrawY+QRCodeSize), qrResized, image.Point{}, draw.Over)
hintY := qrDrawY + QRCodeSize + 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
linkText := boxURL
linkFontSize := float64(FontSizeLink)
availableW := QRCardW - 45
actualW := measureTextWidth(linkText, linkFontSize)
if actualW > float64(availableW) {
linkFontSize = linkFontSize * float64(availableW) / actualW
}
drawTextCentered(canvas, linkText, cardCenterX, linkY, 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 变化,缓存自动失效。
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 {
return cached.(image.Image), nil
}
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 用逐字符宽度估算(与 splitText 的 charPixelWidth 一致),
// 避免每次测量都创建 freetype.Context 并执行 DrawString(显著降低生成耗时)。
func measureTextWidth(text string, fontSize float64) float64 {
return measureTextEstimate(text, fontSize)
}
func measureTextEstimate(text string, fontSize float64) float64 {
total := 0.0
for _, r := range text {
total += charPixelWidth(r, fontSize)
}
return total
}
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))
}
func generateQR(content string, user *db.User, logoImg image.Image) (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}
qrPaletted := qr.Image(256)
rgba := image.NewRGBA(qrPaletted.Bounds())
draw.Draw(rgba, rgba.Bounds(), qrPaletted, image.Point{}, draw.Src)
if logoImg != nil {
logo := imaging.Fill(logoImg, 48, 48, imaging.Center, imaging.Lanczos)
bounds := rgba.Bounds()
logoPos := image.Pt(
bounds.Min.X+(bounds.Dx()-logo.Bounds().Dx())/2,
bounds.Min.Y+(bounds.Dy()-logo.Bounds().Dy())/2,
)
draw.Draw(rgba, logo.Bounds().Add(logoPos), logo, image.Point{}, draw.Over)
}
return rgba, 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 = 210
QQRCardH = 255
QQRCardX = QContentX + QContentW - QQRCardW
QQRCardY = QContentY + (QContentH-QQRCardH)/2
QQRSize = 128
QQRCodeX = (QQRCardW - QQRSize) / 2
// 内容区最大宽度(左侧,避开二维码卡片)
QTextMaxW = QQRCardX - QContentX - 30
QWatermarkY = CardHeight - 30
)
// 单题分享图结果缓存,TTL 30 分钟,上限 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
}
cacheKey := questionShareCardCacheKey(user, question)
if cached, ok := questionShareCache.Get(cacheKey); ok {
writeShareCardResponse(ctx, cached.([]byte))
return
}
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
// 右侧二维码卡片
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, user, avImg)
if 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
}
qrResized := imaging.Resize(qr, QQRSize, QQRSize, imaging.NearestNeighbor)
qrDrawX := QQRCardX + QQRCodeX
// 二维码与提示语整体垂直居中(已删除底部冗余的长链接,卡片更简洁)
qrDrawY := QQRCardY + 40
draw.Draw(canvas, image.Rect(qrDrawX, qrDrawY, qrDrawX+QQRSize, qrDrawY+QQRSize), qrResized, image.Point{}, draw.Over)
hintY := qrDrawY + QQRSize + 30
drawTextCentered(canvas, "扫码查看回答", QQRCardX+QQRCardW/2, hintY, 17, ColorHintGray, fontCtx)
// 底部水印
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 基于问题内容与用户资料生成缓存指纹,内容/资料变更自动失效。
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)
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
}