fix
Some checks failed
部署后端服务 / 🧪 测试后端 (push) Failing after 5m8s
部署后端服务 / 🚀 构建并部署 (push) Has been skipped
部署后端服务 / 🔄 回滚部署 (push) Has been skipped

This commit is contained in:
xujiang
2025-07-10 18:09:11 +08:00
parent 35004f224e
commit 010fe2a8c7
96 changed files with 23709 additions and 19 deletions

Binary file not shown.

View File

@ -3,6 +3,7 @@ package main
import (
"flag"
"fmt"
"net/http"
"photography-backend/internal/config"
"photography-backend/internal/handler"
@ -26,6 +27,15 @@ func main() {
ctx := svc.NewServiceContext(c)
handler.RegisterHandlers(server, ctx)
// 添加静态文件服务
server.AddRoute(rest.Route{
Method: http.MethodGet,
Path: "/uploads/:path",
Handler: func(w http.ResponseWriter, r *http.Request) {
http.StripPrefix("/uploads/", http.FileServer(http.Dir(c.FileUpload.UploadDir))).ServeHTTP(w, r)
},
})
fmt.Printf("Starting server at %s:%d...\n", c.Host, c.Port)
server.Start()
}

View File

@ -17,6 +17,7 @@ require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/disintegration/imaging v1.6.2 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
@ -56,6 +57,7 @@ require (
go.opentelemetry.io/otel/trace v1.24.0 // indirect
go.opentelemetry.io/proto/otlp v1.3.1 // indirect
go.uber.org/automaxprocs v1.6.0 // indirect
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 // indirect
golang.org/x/net v0.35.0 // indirect
golang.org/x/sync v0.11.0 // indirect
golang.org/x/sys v0.30.0 // indirect

View File

@ -11,6 +11,8 @@ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
@ -129,6 +131,8 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
@ -137,6 +141,7 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
google.golang.org/genproto/googleapis/api v0.0.0-20240711142825-46eb208f015d h1:kHjw/5UfflP/L5EbledDrcG4C2597RtymmGRZvHiCuY=

View File

@ -1,6 +1,7 @@
package photo
import (
"fmt"
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
@ -12,14 +13,37 @@ import (
// 上传照片
func UploadPhotoHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.UploadPhotoRequest
if err := httpx.Parse(r, &req); err != nil {
// 解析 multipart form
err := r.ParseMultipartForm(32 << 20) // 32MB
if err != nil {
httpx.ErrorCtx(r.Context(), w, err)
return
}
// 获取文件
file, header, err := r.FormFile("file")
if err != nil {
httpx.ErrorCtx(r.Context(), w, err)
return
}
defer file.Close()
// 获取表单参数
req := types.UploadPhotoRequest{
Title: r.FormValue("title"),
Description: r.FormValue("description"),
}
// 解析 category_id
if categoryIdStr := r.FormValue("category_id"); categoryIdStr != "" {
var categoryId int64
if _, err := fmt.Sscanf(categoryIdStr, "%d", &categoryId); err == nil {
req.CategoryId = categoryId
}
}
l := photo.NewUploadPhotoLogic(r.Context(), svcCtx)
resp, err := l.UploadPhoto(&req)
resp, err := l.UploadPhoto(&req, file, header)
if err != nil {
httpx.ErrorCtx(r.Context(), w, err)
} else {

View File

@ -4,11 +4,13 @@ import (
"context"
"database/sql"
"errors"
"mime/multipart"
"time"
"photography-backend/internal/model"
"photography-backend/internal/svc"
"photography-backend/internal/types"
fileUtil "photography-backend/pkg/utils/file"
"github.com/zeromicro/go-zero/core/logx"
)
@ -28,13 +30,15 @@ func NewUploadPhotoLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Uploa
}
}
func (l *UploadPhotoLogic) UploadPhoto(req *types.UploadPhotoRequest) (resp *types.UploadPhotoResponse, err error) {
func (l *UploadPhotoLogic) UploadPhoto(req *types.UploadPhotoRequest, file multipart.File, header *multipart.FileHeader) (resp *types.UploadPhotoResponse, err error) {
// 1. 从上下文中获取当前用户 ID
// 这里假设从 JWT 中间件中获取到了用户 ID
// 实际中需要从 context 中获取用户信息
userId := l.ctx.Value("userId")
if userId == nil {
return nil, errors.New("未登录或登录已过期")
// 临时解决方案如果没有用户ID使用默认值1
// 后续需要实现JWT中间件
userId = int64(1)
}
// 2. 验证分类是否存在
@ -43,24 +47,39 @@ func (l *UploadPhotoLogic) UploadPhoto(req *types.UploadPhotoRequest) (resp *typ
return nil, errors.New("分类不存在")
}
// 3. 创建照片记录
// 注意:这里的文件上传和处理需要在 handler 层处理
// 业务逻辑层只处理数据库操作
photo := &model.Photo{
Title: req.Title,
Description: sql.NullString{String: req.Description, Valid: req.Description != ""},
UserId: userId.(int64),
CategoryId: req.CategoryId,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
// 3. 处理文件上传
fileConfig := fileUtil.Config{
MaxSize: l.svcCtx.Config.FileUpload.MaxSize,
UploadDir: l.svcCtx.Config.FileUpload.UploadDir,
AllowedTypes: l.svcCtx.Config.FileUpload.AllowedTypes,
}
_, err = l.svcCtx.PhotoModel.Insert(l.ctx, photo)
uploadResult, err := fileUtil.UploadPhoto(file, header, fileConfig)
if err != nil {
return nil, err
}
// 4. 返回上传结果
// 4. 创建照片记录
photo := &model.Photo{
Title: req.Title,
Description: sql.NullString{String: req.Description, Valid: req.Description != ""},
FilePath: uploadResult.Original.FilePath,
ThumbnailPath: uploadResult.Thumbnail.FilePath,
UserId: userId.(int64),
CategoryId: req.CategoryId,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
_, err = l.svcCtx.PhotoModel.Insert(l.ctx, photo)
if err != nil {
// 如果数据库保存失败,删除已上传的文件
fileUtil.DeleteFile(uploadResult.Original.FilePath)
fileUtil.DeleteFile(uploadResult.Thumbnail.FilePath)
return nil, err
}
// 5. 返回上传结果
return &types.UploadPhotoResponse{
BaseResponse: types.BaseResponse{
Code: 200,
@ -70,8 +89,8 @@ func (l *UploadPhotoLogic) UploadPhoto(req *types.UploadPhotoRequest) (resp *typ
Id: photo.Id,
Title: photo.Title,
Description: photo.Description.String,
FilePath: photo.FilePath,
ThumbnailPath: photo.ThumbnailPath,
FilePath: uploadResult.Original.URL,
ThumbnailPath: uploadResult.Thumbnail.URL,
UserId: photo.UserId,
CategoryId: photo.CategoryId,
CreatedAt: photo.CreatedAt.Unix(),

View File

@ -0,0 +1,76 @@
package middleware
import (
"context"
"net/http"
"strings"
"photography-backend/pkg/utils/jwt"
"github.com/zeromicro/go-zero/rest/httpx"
)
// AuthMiddleware JWT 认证中间件
type AuthMiddleware struct {
secret string
}
// NewAuthMiddleware 创建认证中间件
func NewAuthMiddleware(secret string) *AuthMiddleware {
return &AuthMiddleware{
secret: secret,
}
}
// Handle 处理认证
func (m *AuthMiddleware) Handle(next http.HandlerFunc) http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 获取 Authorization header
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
httpx.ErrorCtx(r.Context(), w, NewUnauthorizedError("缺少认证头"))
return
}
// 检查 Bearer 前缀
const bearerPrefix = "Bearer "
if !strings.HasPrefix(authHeader, bearerPrefix) {
httpx.ErrorCtx(r.Context(), w, NewUnauthorizedError("无效的认证头格式"))
return
}
// 提取 token
tokenString := authHeader[len(bearerPrefix):]
if tokenString == "" {
httpx.ErrorCtx(r.Context(), w, NewUnauthorizedError("缺少认证令牌"))
return
}
// 解析和验证 JWT
claims, err := jwt.ParseToken(tokenString, m.secret)
if err != nil {
httpx.ErrorCtx(r.Context(), w, NewUnauthorizedError("无效的认证令牌"))
return
}
// 将用户信息存入请求上下文
ctx := context.WithValue(r.Context(), "userId", claims.UserId)
ctx = context.WithValue(ctx, "username", claims.Username)
// 继续执行下一个处理器
next(w, r.WithContext(ctx))
})
}
// UnauthorizedError 未授权错误
type UnauthorizedError struct {
Message string
}
func (e UnauthorizedError) Error() string {
return e.Message
}
func NewUnauthorizedError(message string) UnauthorizedError {
return UnauthorizedError{Message: message}
}

View File

@ -0,0 +1,204 @@
package file
import (
"fmt"
"image"
_ "image/jpeg"
_ "image/png"
"io"
"mime/multipart"
"os"
"path/filepath"
"strings"
"time"
"github.com/disintegration/imaging"
"github.com/google/uuid"
)
// Config 文件上传配置
type Config struct {
MaxSize int64 // 最大文件大小 (bytes)
UploadDir string // 上传目录
AllowedTypes []string // 允许的文件类型
}
// FileInfo 文件信息
type FileInfo struct {
OriginalName string `json:"original_name"`
FileName string `json:"file_name"`
FilePath string `json:"file_path"`
FileSize int64 `json:"file_size"`
ContentType string `json:"content_type"`
URL string `json:"url"`
}
// UploadResult 上传结果
type UploadResult struct {
Original FileInfo `json:"original"`
Thumbnail FileInfo `json:"thumbnail"`
}
// IsAllowedType 检查文件类型是否允许
func IsAllowedType(contentType string, allowedTypes []string) bool {
for _, allowedType := range allowedTypes {
if contentType == allowedType {
return true
}
}
return false
}
// GenerateFileName 生成唯一文件名
func GenerateFileName(originalName string) string {
ext := filepath.Ext(originalName)
name := strings.TrimSuffix(originalName, ext)
timestamp := time.Now().Unix()
uuid := uuid.New().String()[:8]
return fmt.Sprintf("%s_%d_%s%s", name, timestamp, uuid, ext)
}
// SaveFile 保存文件
func SaveFile(file multipart.File, header *multipart.FileHeader, config Config) (*FileInfo, error) {
// 检查文件大小
if header.Size > config.MaxSize {
return nil, fmt.Errorf("文件大小超过限制: %d bytes", config.MaxSize)
}
// 检查文件类型
contentType := header.Header.Get("Content-Type")
if !IsAllowedType(contentType, config.AllowedTypes) {
return nil, fmt.Errorf("不支持的文件类型: %s", contentType)
}
// 生成文件名
fileName := GenerateFileName(header.Filename)
// 创建上传目录
uploadPath := filepath.Join(config.UploadDir, "photos")
if err := os.MkdirAll(uploadPath, 0755); err != nil {
return nil, fmt.Errorf("创建上传目录失败: %v", err)
}
// 完整文件路径
filePath := filepath.Join(uploadPath, fileName)
// 创建目标文件
dst, err := os.Create(filePath)
if err != nil {
return nil, fmt.Errorf("创建文件失败: %v", err)
}
defer dst.Close()
// 复制文件内容
file.Seek(0, 0) // 重置文件指针
_, err = io.Copy(dst, file)
if err != nil {
return nil, fmt.Errorf("保存文件失败: %v", err)
}
// 返回文件信息
return &FileInfo{
OriginalName: header.Filename,
FileName: fileName,
FilePath: filePath,
FileSize: header.Size,
ContentType: contentType,
URL: "/uploads/photos/" + fileName,
}, nil
}
// CreateThumbnail 创建缩略图
func CreateThumbnail(originalPath string, config Config) (*FileInfo, error) {
// 打开原图
src, err := imaging.Open(originalPath)
if err != nil {
return nil, fmt.Errorf("打开图片失败: %v", err)
}
// 生成缩略图文件名
ext := filepath.Ext(originalPath)
baseName := strings.TrimSuffix(filepath.Base(originalPath), ext)
thumbnailName := baseName + "_thumb" + ext
// 创建缩略图目录
thumbnailDir := filepath.Join(config.UploadDir, "thumbnails")
if err := os.MkdirAll(thumbnailDir, 0755); err != nil {
return nil, fmt.Errorf("创建缩略图目录失败: %v", err)
}
thumbnailPath := filepath.Join(thumbnailDir, thumbnailName)
// 调整图片大小 (最大宽度 300px保持比例)
thumbnail := imaging.Resize(src, 300, 0, imaging.Lanczos)
// 保存缩略图
err = imaging.Save(thumbnail, thumbnailPath)
if err != nil {
return nil, fmt.Errorf("保存缩略图失败: %v", err)
}
// 获取文件大小
stat, err := os.Stat(thumbnailPath)
if err != nil {
return nil, fmt.Errorf("获取缩略图信息失败: %v", err)
}
return &FileInfo{
OriginalName: thumbnailName,
FileName: thumbnailName,
FilePath: thumbnailPath,
FileSize: stat.Size(),
ContentType: "image/jpeg",
URL: "/uploads/thumbnails/" + thumbnailName,
}, nil
}
// UploadPhoto 上传照片(包含原图和缩略图)
func UploadPhoto(file multipart.File, header *multipart.FileHeader, config Config) (*UploadResult, error) {
// 保存原图
original, err := SaveFile(file, header, config)
if err != nil {
return nil, err
}
// 创建缩略图
thumbnail, err := CreateThumbnail(original.FilePath, config)
if err != nil {
return nil, err
}
return &UploadResult{
Original: *original,
Thumbnail: *thumbnail,
}, nil
}
// DeleteFile 删除文件
func DeleteFile(filePath string) error {
if filePath == "" {
return nil
}
if _, err := os.Stat(filePath); os.IsNotExist(err) {
return nil // 文件不存在,认为删除成功
}
return os.Remove(filePath)
}
// GetImageDimensions 获取图片尺寸
func GetImageDimensions(filePath string) (width, height int, err error) {
file, err := os.Open(filePath)
if err != nil {
return 0, 0, err
}
defer file.Close()
img, _, err := image.DecodeConfig(file)
if err != nil {
return 0, 0, err
}
return img.Width, img.Height, nil
}