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

@@ -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
}