Files
2026-10-13 05:31:30 +08:00

192 lines
6.2 KiB
Go

package auth
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"errors"
"image"
"image/color"
_ "image/jpeg"
"image/png"
"io"
"mime/multipart"
"net/url"
"time"
"yingce/backend/internal/kernel"
"yingce/backend/internal/model"
"yingce/backend/internal/repository"
"gorm.io/gorm"
)
const MaxProfileAvatarBytes int64 = 5 << 20
const maxProfileAvatarPixels = 16_000_000
const maxProfileAvatarSide = 512
func profileAvatarURL(user *model.User) string {
return "/api/auth/profile/avatar/" + url.PathEscape(user.ID) + "?v=" + url.QueryEscape(user.AvatarVersion)
}
func (s *Service) avatarActor(actor *model.User) (*model.User, error) {
if actor == nil || actor.ID == "" {
return nil, kernel.Unauthorized("请先登录")
}
if actor.Status != model.UserStatusActive {
return nil, kernel.Forbidden("该账号已被禁用")
}
user, err := s.repo.User(actor.ID)
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, kernel.Unauthorized("登录状态已失效")
}
if err != nil {
return nil, err
}
if user.Status != model.UserStatusActive {
return nil, kernel.Forbidden("该账号已被禁用")
}
return user, nil
}
func (s *Service) UpdateProfileAvatar(actor *model.User, header *multipart.FileHeader) (*AuthUser, error) {
user, err := s.avatarActor(actor)
if err != nil {
return nil, err
}
if header == nil {
return nil, kernel.BadAuthRequest("请选择头像图片")
}
if header.Size > MaxProfileAvatarBytes {
return nil, kernel.BadAuthRequest("头像图片不能超过 5 MiB")
}
file, err := header.Open()
if err != nil {
return nil, kernel.WrapAppError(400, "无法读取头像图片", err)
}
data, readErr := io.ReadAll(io.LimitReader(file, MaxProfileAvatarBytes+1))
closeErr := file.Close()
if readErr != nil {
return nil, kernel.WrapAppError(400, "无法读取头像图片", readErr)
}
if closeErr != nil {
return nil, kernel.WrapAppError(500, "关闭头像文件失败", closeErr)
}
if int64(len(data)) > MaxProfileAvatarBytes {
return nil, kernel.BadAuthRequest("头像图片不能超过 5 MiB")
}
normalized, err := normalizeProfileAvatar(data)
if err != nil {
return nil, err
}
hash := sha256.Sum256(normalized)
return s.saveProfileAvatar(user, normalized, hex.EncodeToString(hash[:16]), "")
}
func (s *Service) UseDefaultProfileAvatar(actor *model.User) (*AuthUser, error) {
return s.SelectDefaultProfileAvatar(actor, "person")
}
func (s *Service) SelectDefaultProfileAvatar(actor *model.User, preset string) (*AuthUser, error) {
user, err := s.avatarActor(actor)
if err != nil {
return nil, err
}
switch preset {
case "person", "director", "camera", "cat", "rabbit", "robot", "rocket", "sparkles":
return s.saveProfileAvatar(user, nil, "", preset)
default:
return nil, kernel.BadAuthRequest("请选择有效的系统头像")
}
}
func (s *Service) saveProfileAvatar(user *model.User, data []byte, version string, preset string) (*AuthUser, error) {
// Read linked identity metadata before writing, so an identity query failure
// cannot report an unsuccessful write after the avatar already committed.
result, err := s.PublicAuthUser(user)
if err != nil {
return nil, err
}
updated, err := s.repo.UpdateUserAvatar(user.ID, data, version, preset, time.Now())
if errors.Is(err, repository.ErrProfileAvatarUserUnavailable) {
return nil, kernel.Forbidden("该账号已被禁用或不存在")
}
if err != nil {
return nil, err
}
result.User = *updated
result.AvatarURL = ""
if updated.AvatarMode == "custom" {
result.AvatarURL = profileAvatarURL(updated)
}
return &result, nil
}
func (s *Service) ProfileAvatarContent(actor *model.User, targetUserID string) ([]byte, error) {
user, err := s.avatarActor(actor)
if err != nil {
return nil, err
}
if targetUserID != user.ID {
return nil, kernel.Forbidden("无权读取其他用户头像")
}
if user.AvatarMode != "custom" {
return nil, kernel.NotFound("未设置自定义头像")
}
avatar, err := s.repo.UserAvatar(user.ID)
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, kernel.NotFound("头像图片不存在")
}
if err != nil {
return nil, err
}
return avatar.Data, nil
}
func normalizeProfileAvatar(data []byte) ([]byte, error) {
config, format, err := image.DecodeConfig(bytes.NewReader(data))
if err != nil || (format != "png" && format != "jpeg") {
return nil, kernel.BadAuthRequest("头像仅支持有效的 PNG 或 JPEG 图片")
}
if config.Width <= 0 || config.Height <= 0 || config.Width > maxProfileAvatarPixels/config.Height {
return nil, kernel.BadAuthRequest("头像图片不能超过 1600 万像素")
}
source, decodedFormat, err := image.Decode(bytes.NewReader(data))
if err != nil || decodedFormat != format || source.Bounds().Dx() != config.Width || source.Bounds().Dy() != config.Height {
return nil, kernel.BadAuthRequest("头像图片已损坏")
}
span := min(config.Width, config.Height)
side := min(span, maxProfileAvatarSide)
left := source.Bounds().Min.X + (config.Width-span)/2
top := source.Bounds().Min.Y + (config.Height-span)/2
output := image.NewRGBA(image.Rect(0, 0, side, side))
// Interpolate premultiplied colors at pixel centers, matching a centered
// object-cover preview while preserving transparent PNG edges.
for y := 0; y < side; y++ {
fy := max(0, (float64(y)+0.5)*float64(span)/float64(side)-0.5)
y0 := int(fy)
y1 := min(y0+1, span-1)
wy := fy - float64(y0)
for x := 0; x < side; x++ {
fx := max(0, (float64(x)+0.5)*float64(span)/float64(side)-0.5)
x0 := int(fx)
x1 := min(x0+1, span-1)
wx := fx - float64(x0)
r0, g0, b0, a0 := source.At(left+x0, top+y0).RGBA()
r1, g1, b1, a1 := source.At(left+x1, top+y0).RGBA()
r2, g2, b2, a2 := source.At(left+x0, top+y1).RGBA()
r3, g3, b3, a3 := source.At(left+x1, top+y1).RGBA()
channel := func(c0, c1, c2, c3 uint32) uint8 {
value := (float64(c0)*(1-wx)+float64(c1)*wx)*(1-wy) + (float64(c2)*(1-wx)+float64(c3)*wx)*wy
return uint8(value/257 + 0.5)
}
output.SetRGBA(x, y, color.RGBA{R: channel(r0, r1, r2, r3), G: channel(g0, g1, g2, g3), B: channel(b0, b1, b2, b3), A: channel(a0, a1, a2, a3)})
}
}
var encoded bytes.Buffer
if err := png.Encode(&encoded, output); err != nil {
return nil, kernel.WrapAppError(500, "头像图片处理失败", err)
}
return encoded.Bytes(), nil
}