feat: rebuild pet app and backend around core care flows

This commit is contained in:
2026-10-01 13:11:30 +08:00
parent 51ead8f411
commit bfcf8788a4
321 changed files with 4918 additions and 25199 deletions
+140
View File
@@ -0,0 +1,140 @@
package httpapi
import (
"context"
"crypto/md5"
"encoding/hex"
"io"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/sundynix/pets-be/internal/model"
)
func (a *API) fileExists(id string) bool {
if !validID(id) {
return false
}
var count int64
a.db.Model(&model.File{}).Where("id = ?", id).Count(&count)
return count == 1
}
const maxUploadSize = 10 << 20
func (a *API) uploadFile(c *gin.Context) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxUploadSize+1024)
header, err := c.FormFile("file")
if err != nil || header.Size <= 0 || header.Size > maxUploadSize {
fail(c, http.StatusBadRequest, "请选择不超过 10 MB 的图片")
return
}
reader, err := header.Open()
if err != nil {
fail(c, http.StatusBadRequest, "读取图片失败")
return
}
defer reader.Close()
signature := make([]byte, 512)
n, _ := io.ReadFull(reader, signature)
contentType := http.DetectContentType(signature[:n])
extensions := map[string]string{"image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp"}
ext, allowed := extensions[contentType]
if !allowed {
fail(c, http.StatusBadRequest, "仅支持 JPG、PNG、WebP 图片")
return
}
if _, err := reader.Seek(0, io.SeekStart); err != nil {
fail(c, http.StatusBadRequest, "读取图片失败")
return
}
hasher := md5.New()
if _, err := io.Copy(hasher, reader); err != nil {
fail(c, http.StatusBadRequest, "读取图片失败")
return
}
if _, err := reader.Seek(0, io.SeekStart); err != nil {
fail(c, http.StatusBadRequest, "读取图片失败")
return
}
digest := hex.EncodeToString(hasher.Sum(nil))
var existing model.File
if a.db.Where("md5 = ?", digest).First(&existing).Error == nil {
if existing.Size != header.Size {
fail(c, http.StatusConflict, "文件校验冲突")
return
}
ok(c, a.fileView(existing))
return
}
key := "files/" + digest[:2] + "/" + digest + ext
ctx, cancel := context.WithTimeout(c.Request.Context(), 45*time.Second)
defer cancel()
if err := a.storage.Put(ctx, key, reader, header.Size, contentType); err != nil {
fail(c, http.StatusBadGateway, "上传对象存储失败")
return
}
file := model.File{
Bucket: a.storage.Bucket(), ObjectKey: key, MD5: digest, URL: a.storage.PublicURL(key),
Name: strings.TrimSpace(header.Filename), ContentType: contentType,
Size: header.Size,
}
if err := a.db.Create(&file).Error; err != nil {
if a.db.Where("md5 = ?", digest).First(&existing).Error == nil && existing.Size == header.Size {
ok(c, a.fileView(existing))
return
}
fail(c, http.StatusInternalServerError, "保存文件信息失败")
return
}
created(c, a.fileView(file))
}
func (a *API) getFile(c *gin.Context) {
if !validID(c.Param("id")) {
fail(c, http.StatusBadRequest, "文件 ID 无效")
return
}
var file model.File
if err := a.db.Where("id = ?", c.Param("id")).First(&file).Error; err != nil {
fail(c, http.StatusNotFound, "文件不存在")
return
}
ok(c, a.fileView(file))
}
func (a *API) fileView(file model.File) gin.H {
return gin.H{"id": file.ID, "name": file.Name, "content_type": file.ContentType,
"size": file.Size, "md5": file.MD5, "url": file.URL}
}
func (a *API) fileURL(id *string) string {
if id == nil || !validID(*id) {
return ""
}
var file model.File
if a.db.Select("url").First(&file, "id = ?", *id).Error != nil {
return ""
}
return file.URL
}
func (a *API) userView(user model.User) gin.H {
return gin.H{"id": user.ID, "nickname": user.Nickname, "avatar_file_id": user.AvatarFileID,
"avatar_url": a.fileURL(user.AvatarFileID), "created_at": user.CreatedAt}
}
func (a *API) petView(pet model.Pet) gin.H {
var birthDate string
if pet.BirthDate != nil {
birthDate = pet.BirthDate.Format("2006-01-02")
}
return gin.H{"id": pet.ID, "name": pet.Name, "species": pet.Species, "breed": pet.Breed,
"gender": pet.Gender, "birth_date": birthDate, "weight_kg": pet.WeightKg,
"avatar_file_id": pet.AvatarFileID, "avatar_url": a.fileURL(pet.AvatarFileID),
"coat": pet.Coat, "allergies": pet.Allergies, "care_notes": pet.CareNotes,
"created_at": pet.CreatedAt}
}