feat: 完成头像上传、站点主题配置与UI优化

- 新增附件模型与上传服务,拆分头像上传/管理逻辑
- 重构用户资料接口,移除直接修改头像字段,改用专用上传接口
- 添加站点公开设置API与管理员外观配置页
- 全局替换hover背景为accent-soft,优化交互一致性
- 完善头像组件,支持图片加载失败回退与src更新重置
- 新增用户状态事件总线,解决同页头像更新不及时问题
- 调整依赖包顺序与CI依赖版本
- 重构板块列表接口,新增帖子计数
- 迁移账号设置到用户主页tab,合并重复代码
This commit is contained in:
2026-09-15 01:06:12 +08:00
parent f58b2af993
commit 24c6dea38c
48 changed files with 2513 additions and 432 deletions

View File

@@ -40,10 +40,11 @@ require (
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/image v0.46.0 // indirect
golang.org/x/net v0.45.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.37.0 // indirect
golang.org/x/text v0.30.0 // indirect
golang.org/x/sync v0.23.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/text v0.42.0 // indirect
google.golang.org/protobuf v1.34.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

View File

@@ -91,16 +91,24 @@ golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ=
golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew=
golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM=
golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=

View File

@@ -17,4 +17,6 @@ type Handlers struct {
OverviewSvc *service.OverviewService
Checkin *service.CheckinService
Announcement *service.AnnouncementService
Upload *service.UploadService
Setting *service.SettingService
}

View File

@@ -0,0 +1,44 @@
package handler
import (
"errors"
"net/http"
"github.com/freefire/jiang13-bbs/service"
"github.com/gin-gonic/gin"
)
// PublicSettings 公开站点设置(无需登录):前端 SSR 据此注入主题 CSS 变量
// GET /api/settings
func (h *Handlers) PublicSettings(c *gin.Context) {
accent, err := h.Setting.AccentColor()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取站点设置失败"})
return
}
c.JSON(http.StatusOK, gin.H{"accent": accent})
}
type updateSettingsRequest struct {
// Accent 主题色 #RRGGBB;空串恢复默认
Accent string `json:"accent"`
}
// UpdateSettings 更新站点设置(管理员)
// PUT /api/admin/settings
func (h *Handlers) UpdateSettings(c *gin.Context) {
var req updateSettingsRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
return
}
if err := h.Setting.SetAccent(req.Accent); err != nil {
if errors.Is(err, service.ErrInvalidAccent) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "accent": req.Accent})
}

100
backend/handler/upload.go Normal file
View File

@@ -0,0 +1,100 @@
package handler
import (
"errors"
"io"
"net/http"
"strconv"
"github.com/freefire/jiang13-bbs/middleware"
"github.com/freefire/jiang13-bbs/service"
"github.com/gin-gonic/gin"
)
// UploadAvatar 上传裁剪后的头像(multipart 字段 file,内容必须是 WebP)
func (h *Handlers) UploadAvatar(c *gin.Context) {
claims := middleware.CurrentUser(c)
// 限制请求体:以裁剪后文件为依据,允许少量 multipart 边界开销
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, service.AvatarMaxBytes+4096)
fh, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "图片过大(裁剪后不能超过 2MB)或格式不正确"})
return
}
if fh.Size > service.AvatarMaxBytes {
c.JSON(http.StatusBadRequest, gin.H{"error": "裁剪后的图片不能超过 2MB"})
return
}
f, err := fh.Open()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "读取图片失败"})
return
}
defer f.Close()
// fh.Size 已受 MaxBytesReader 约束;读取时二次防御
data, err := io.ReadAll(io.LimitReader(f, service.AvatarMaxBytes+1))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "读取图片失败"})
return
}
att, err := h.Upload.SaveAvatar(claims.ID, data)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"url": att.URL, "attachment": att})
}
// UseAvatarRequest 选用历史头像请求
type UseAvatarRequest struct {
URL string `json:"url"`
}
// UseAvatar 选用本人历史上传的头像
func (h *Handlers) UseAvatar(c *gin.Context) {
claims := middleware.CurrentUser(c)
var req UseAvatarRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数格式错误"})
return
}
if err := h.Upload.UseAvatar(claims.ID, req.URL); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"url": req.URL})
}
// MyMedia 媒体库:本人上传的全部图片(头像、帖子插图等)
func (h *Handlers) MyMedia(c *gin.Context) {
claims := middleware.CurrentUser(c)
list, err := h.Upload.ListMedia(claims.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"attachments": list})
}
// DeleteAttachment 彻底删除本人附件(DB + 物理文件)
func (h *Handlers) DeleteAttachment(c *gin.Context) {
claims := middleware.CurrentUser(c)
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的附件 ID"})
return
}
if err := h.Upload.DeleteAttachment(claims.ID, uint(id)); err != nil {
if errors.Is(err, service.ErrAttachmentInUse) {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}

View File

@@ -8,15 +8,14 @@ import (
"github.com/gin-gonic/gin"
)
// UpdateProfileRequest 更新资料请求
// UpdateProfileRequest 更新资料请求(头像不在此通道,见 /api/upload/avatar)
type UpdateProfileRequest struct {
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
Email string `json:"email"`
Signature string `json:"signature"`
}
// UpdateProfile 更新当前登录用户的资料(昵称、头像、邮箱、签名)
// UpdateProfile 更新当前登录用户的资料(昵称、邮箱、签名)
func (h *Handlers) UpdateProfile(c *gin.Context) {
claims := middleware.CurrentUser(c)
var req UpdateProfileRequest
@@ -25,7 +24,7 @@ func (h *Handlers) UpdateProfile(c *gin.Context) {
return
}
user, err := h.Auth.UpdateProfile(claims.ID, req.Nickname, req.Avatar, req.Email, req.Signature)
user, err := h.Auth.UpdateProfile(claims.ID, req.Nickname, req.Email, req.Signature)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return

View File

@@ -31,7 +31,7 @@ func InitDB(dsn string) error {
if err := db.AutoMigrate(
&User{}, &Board{}, &Post{}, &Comment{}, &RefreshToken{}, &Like{}, &Notification{}, &Checkin{},
&Announcement{}, &SiteSetting{},
&Announcement{}, &SiteSetting{}, &Attachment{},
); err != nil {
return fmt.Errorf("自动迁移失败: %w", err)
}

View File

@@ -176,3 +176,22 @@ type SiteSetting struct {
Value string `gorm:"size:255;not null;default:''" json:"value"`
UpdatedAt time.Time `json:"updated_at"`
}
// 附件类型
const (
AttachmentKindAvatar = "avatar" // 用户头像(正方形 WebP)
AttachmentKindImage = "image" // 通用图片(帖子插图等,媒体库统一管理)
)
// Attachment 用户上传的附件(当前仅头像,全部为 WebP)
type Attachment struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"index:idx_attachment_user_kind,priority:1;not null" json:"user_id"`
Kind string `gorm:"size:16;index:idx_attachment_user_kind,priority:2;not null" json:"kind"`
URL string `gorm:"size:512;not null" json:"url"`
MIME string `gorm:"size:32;not null;default:image/webp" json:"mime"`
Size int `gorm:"not null;default:0" json:"size"` // 裁剪后文件字节数(大小限制以此为准)
Width int `gorm:"not null;default:0" json:"width"`
Height int `gorm:"not null;default:0" json:"height"`
CreatedAt time.Time `gorm:"index" json:"created_at"`
}

View File

@@ -1,6 +1,7 @@
package router
import (
"path/filepath"
"time"
"github.com/freefire/jiang13-bbs/config"
@@ -46,6 +47,11 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
overviewSvc := service.NewOverviewService(model.DB)
checkinSvc := service.NewCheckinService(model.DB)
announcementSvc := service.NewAnnouncementService(model.DB)
uploadSvc := service.NewUploadService(model.DB, filepath.Join(cfg.DataDir, "uploads"))
settingSvc := service.NewSettingService(model.DB)
if err := uploadSvc.EnsureDir(); err != nil {
return nil, err
}
limiter := service.DefaultRateLimiter()
h := &handler.Handlers{
@@ -59,10 +65,15 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
OverviewSvc: overviewSvc,
Checkin: checkinSvc,
Announcement: announcementSvc,
Upload: uploadSvc,
Setting: settingSvc,
}
authMW := middleware.NewAuthMiddleware(authSvc)
// 上传文件静态服务(data/uploads → /uploads)
r.Static("/uploads", filepath.Join(cfg.DataDir, "uploads"))
// 健康检查 & SEO
r.GET("/health", h.Health)
r.GET("/robots.txt", h.RobotsTxt)
@@ -81,6 +92,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
pubAPI.GET("/users/:id/comments", h.UserComments)
pubAPI.GET("/announcements", h.AnnouncementsList)
pubAPI.GET("/announcements/:id", h.AnnouncementDetail)
pubAPI.GET("/settings", h.PublicSettings)
pubAPI.POST("/register", middleware.RateLimitMiddleware(limiter, service.RateRegister), h.Register)
pubAPI.POST("/login", middleware.RateLimitMiddleware(limiter, service.RateLogin), h.Login)
// refresh token 端点:access 过期后用 refresh 换新 token(需 CSRF 防护)
@@ -109,6 +121,11 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
// 每日签到
api.GET("/checkin", h.GetCheckin)
api.POST("/checkin", middleware.RateLimitMiddleware(limiter, service.RateComment), h.DoCheckin)
// 头像上传(裁剪后的 WebP)/ 媒体库 / 附件删除
api.POST("/upload/avatar", h.UploadAvatar)
api.PUT("/avatar/use", h.UseAvatar)
api.GET("/my/media", h.MyMedia)
api.DELETE("/my/attachments/:id", h.DeleteAttachment)
}
// 管理员 API(同样需要 CSRF 防护)
@@ -122,6 +139,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
adminAPI.POST("/announcements", h.AdminCreateAnnouncement)
adminAPI.PUT("/announcements/:id", h.AdminUpdateAnnouncement)
adminAPI.DELETE("/announcements/:id", h.AdminDeleteAnnouncement)
// 站点外观设置(主题色)
adminAPI.PUT("/settings", h.UpdateSettings)
}
r.NoRoute(func(c *gin.Context) {

View File

@@ -493,12 +493,12 @@ func (s *AuthService) ChangePassword(userID uint, oldPassword, newPassword strin
return s.IncrementTokenVersion(userID)
}
// UpdateProfile 更新用户资料(昵称、头像、邮箱、签名)
// UpdateProfile 更新昵称/邮箱/签名。头像只能通过上传接口(/api/upload/avatar)
// 或历史头像选用接口(/api/avatar/use)修改,这里不接收头像字段,防止写入任意外链。
// - nickname 非空且不超过 64 字符
// - email 可为空;非空时需符合邮箱格式且不与他人重复
// - avatar 可为空 URL
// - signature 不超过 255 字符
func (s *AuthService) UpdateProfile(userID uint, nickname, avatar, email, signature string) (*model.User, error) {
func (s *AuthService) UpdateProfile(userID uint, nickname, email, signature string) (*model.User, error) {
nickname = strings.TrimSpace(nickname)
if nickname == "" {
return nil, errors.New("昵称不能为空")
@@ -518,16 +518,12 @@ func (s *AuthService) UpdateProfile(userID uint, nickname, avatar, email, signat
return nil, errors.New("该邮箱已被使用")
}
}
if len(avatar) > 512 {
return nil, errors.New("头像链接过长")
}
if len(signature) > 255 {
return nil, errors.New("签名不能超过 255 个字符")
}
updates := map[string]any{
"nickname": nickname,
"avatar": avatar,
"email": email,
"signature": signature,
}

View File

@@ -14,11 +14,38 @@ func NewBoardService(db *gorm.DB) *BoardService {
return &BoardService{db: db}
}
// List 获取所有板块(按 sort_order 排序)
func (s *BoardService) List() ([]model.Board, error) {
// BoardWithCount 板块及其已发布帖子计数(Board 字段被 encoding/json 展平)
type BoardWithCount struct {
model.Board
PostCount int64 `json:"post_count"`
}
// List 获取所有板块(按 sort_order 排序),附带各板块已发布帖子数(板块目录页展示用)
func (s *BoardService) List() ([]BoardWithCount, error) {
var boards []model.Board
err := s.db.Order("sort_order ASC, id ASC").Find(&boards).Error
return boards, err
if err := s.db.Order("sort_order ASC, id ASC").Find(&boards).Error; err != nil {
return nil, err
}
type countRow struct {
BoardID uint
Cnt int64
}
var counts []countRow
if err := s.db.Model(&model.Post{}).
Select("board_id, COUNT(*) AS cnt").
Where("status = ?", model.ContentStatusPublished).
Group("board_id").Scan(&counts).Error; err != nil {
return nil, err
}
countMap := make(map[uint]int64, len(counts))
for _, c := range counts {
countMap[c.BoardID] = c.Cnt
}
items := make([]BoardWithCount, 0, len(boards))
for _, b := range boards {
items = append(items, BoardWithCount{Board: b, PostCount: countMap[b.ID]})
}
return items, nil
}
// Get 根据 ID 获取板块

View File

@@ -0,0 +1,57 @@
package service
import (
"errors"
"regexp"
"strings"
"github.com/freefire/jiang13-bbs/model"
"gorm.io/gorm"
)
// 站点设置键
const (
// SettingKeyAccent 站点主题色(浅色基准,#rrggbb);为空表示使用内置默认色
SettingKeyAccent = "theme_accent"
)
// ErrInvalidAccent 主题色格式非法
var ErrInvalidAccent = errors.New("主题色格式不正确,应为 #RRGGBB")
var accentHexRe = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
// SettingService 站点级键值设置(主题色等外观配置)
type SettingService struct {
db *gorm.DB
}
func NewSettingService(db *gorm.DB) *SettingService {
return &SettingService{db: db}
}
// AccentColor 返回配置的主题色(小写 #rrggbb);未配置时返回空串,表示走前端默认色
func (s *SettingService) AccentColor() (string, error) {
var st model.SiteSetting
err := s.db.Where("key = ?", SettingKeyAccent).First(&st).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return "", nil
}
if err != nil {
return "", err
}
return strings.ToLower(st.Value), nil
}
// SetAccent 校验并保存主题色;hex 传空串表示恢复默认(删除该键)
func (s *SettingService) SetAccent(hex string) error {
hex = strings.TrimSpace(strings.ToLower(hex))
if hex == "" {
return s.db.Where("key = ?", SettingKeyAccent).Delete(&model.SiteSetting{}).Error
}
if !accentHexRe.MatchString(hex) {
return ErrInvalidAccent
}
// UPSERT:键冲突时更新值与时间戳
st := model.SiteSetting{Key: SettingKeyAccent, Value: hex}
return s.db.Save(&st).Error
}

166
backend/service/upload.go Normal file
View File

@@ -0,0 +1,166 @@
package service
import (
"bytes"
"crypto/rand"
"encoding/hex"
"errors"
"log"
"os"
"path/filepath"
"strings"
"github.com/freefire/jiang13-bbs/model"
"golang.org/x/image/webp"
"gorm.io/gorm"
)
// 头像限制:大小限制以【裁剪后】的 WebP 文件为准(前端裁剪为正方形后上传)
const (
AvatarMaxBytes = 2 << 20 // 2 MiB
AvatarMinDim = 64
AvatarMaxDim = 512
)
// UploadService 附件上传:落盘到 data/uploads,元信息入库 attachments
type UploadService struct {
db *gorm.DB
dir string // 上传根目录(如 data/uploads)
}
func NewUploadService(db *gorm.DB, uploadDir string) *UploadService {
return &UploadService{db: db, dir: uploadDir}
}
// EnsureDir 启动时确保上传目录存在
func (s *UploadService) EnsureDir() error {
return os.MkdirAll(filepath.Join(s.dir, "avatars"), 0o755)
}
// SaveAvatar 保存裁剪后的 WebP 头像:校验魔数/大小/尺寸 → 落盘 → 写附件记录 → 更新用户头像
func (s *UploadService) SaveAvatar(userID uint, data []byte) (*model.Attachment, error) {
if len(data) == 0 {
return nil, errors.New("文件为空")
}
if len(data) > AvatarMaxBytes {
return nil, errors.New("裁剪后的图片不能超过 2MB")
}
// WebP 魔数:RIFF????WEBP
if len(data) < 12 || string(data[0:4]) != "RIFF" || string(data[8:12]) != "WEBP" {
return nil, errors.New("仅支持 WebP 格式图片")
}
// 解码尺寸(服务端不信任前端传入的宽高)
cfg, err := webp.DecodeConfig(bytes.NewReader(data))
if err != nil {
return nil, errors.New("无法解析 WebP 图片")
}
if cfg.Width != cfg.Height {
return nil, errors.New("头像必须为正方形")
}
if cfg.Width < AvatarMinDim || cfg.Width > AvatarMaxDim {
return nil, errors.New("头像尺寸需在 64~512px 之间")
}
nameBytes := make([]byte, 16)
if _, err := rand.Read(nameBytes); err != nil {
return nil, err
}
filename := hex.EncodeToString(nameBytes) + ".webp"
fullPath := filepath.Join(s.dir, "avatars", filename)
if err := os.WriteFile(fullPath, data, 0o644); err != nil {
return nil, err
}
att := &model.Attachment{
UserID: userID,
Kind: model.AttachmentKindAvatar,
URL: "/uploads/avatars/" + filename,
MIME: "image/webp",
Size: len(data),
Width: cfg.Width,
Height: cfg.Height,
}
// 附件记录与用户头像更新在同一事务内完成
err = s.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(att).Error; err != nil {
return err
}
return tx.Model(&model.User{}).Where("id = ?", userID).Update("avatar", att.URL).Error
})
if err != nil {
_ = os.Remove(fullPath) // DB 失败时清理已落盘文件
return nil, err
}
return att, nil
}
// UseAvatar 选用一张【本人历史上传】的头像
func (s *UploadService) UseAvatar(userID uint, url string) error {
url = strings.TrimSpace(url)
if !strings.HasPrefix(url, "/uploads/avatars/") || len(url) > 512 {
return errors.New("无效的头像地址")
}
var count int64
s.db.Model(&model.Attachment{}).
Where("user_id = ? AND kind = ? AND url = ?", userID, model.AttachmentKindAvatar, url).
Count(&count)
if count == 0 {
return errors.New("该图片不存在或不属于你")
}
return s.db.Model(&model.User{}).Where("id = ?", userID).Update("avatar", url).Error
}
// mediaListSize 媒体库最多返回的图片数
const mediaListSize = 60
// ListMedia 媒体库:本人上传的【全部】图片(头像、帖子插图等所有 kind),最新在前
func (s *UploadService) ListMedia(userID uint) ([]model.Attachment, error) {
var list []model.Attachment
err := s.db.Where("user_id = ?", userID).
Order("id DESC").Limit(mediaListSize).Find(&list).Error
return list, err
}
// ErrAttachmentInUse 图片正被帖子内容引用,不可物理删除
var ErrAttachmentInUse = errors.New("该图片已被帖子使用,无法删除")
// DeleteAttachment 彻底删除本人附件:DB 记录 + 物理文件。
// - 若该图正被某篇未删除帖子的正文引用,拒绝删除(防止帖子里出现裂图)
// - 若该图正是当前头像,同步把 user.avatar 置空(回到字母头像)
func (s *UploadService) DeleteAttachment(userID, attachmentID uint) error {
var att model.Attachment
if err := s.db.Where("id = ? AND user_id = ?", attachmentID, userID).First(&att).Error; err != nil {
return errors.New("图片不存在或不属于你")
}
// 帖子正文引用保护(GORM 默认过滤已删除帖子)
var refCount int64
s.db.Model(&model.Post{}).
Where("content LIKE ?", "%"+att.URL+"%").
Count(&refCount)
if refCount > 0 {
return ErrAttachmentInUse
}
err := s.db.Transaction(func(tx *gorm.DB) error {
// 当前头像正指向该文件:置空,避免悬挂引用
if err := tx.Model(&model.User{}).
Where("id = ? AND avatar = ?", userID, att.URL).
Update("avatar", "").Error; err != nil {
return err
}
return tx.Delete(&model.Attachment{}, att.ID).Error
})
if err != nil {
return err
}
// DB 已清理后再删物理文件;文件删除失败只记日志(不影响用户侧结果)
abs := filepath.Join(s.dir, filepath.FromSlash(strings.TrimPrefix(att.URL, "/uploads/")))
if err := os.Remove(abs); err != nil && !os.IsNotExist(err) {
log.Printf("[upload] 删除附件文件失败 id=%d path=%s: %v", att.ID, abs, err)
}
return nil
}