feat: 完成头像上传、站点主题配置与UI优化
- 新增附件模型与上传服务,拆分头像上传/管理逻辑 - 重构用户资料接口,移除直接修改头像字段,改用专用上传接口 - 添加站点公开设置API与管理员外观配置页 - 全局替换hover背景为accent-soft,优化交互一致性 - 完善头像组件,支持图片加载失败回退与src更新重置 - 新增用户状态事件总线,解决同页头像更新不及时问题 - 调整依赖包顺序与CI依赖版本 - 重构板块列表接口,新增帖子计数 - 迁移账号设置到用户主页tab,合并重复代码
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 获取板块
|
||||
|
||||
57
backend/service/setting.go
Normal file
57
backend/service/setting.go
Normal 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
166
backend/service/upload.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user