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
+99
View File
@@ -0,0 +1,99 @@
package httpapi
import (
"github.com/gin-gonic/gin"
"github.com/sundynix/pets-be/internal/model"
"net/http"
)
func (a *API) adminStats(c *gin.Context) {
counts := gin.H{}
for _, pair := range []struct {
name string
value any
}{{"users", &model.User{}}, {"pets", &model.Pet{}}, {"walks", &model.Walk{}}, {"meetups", &model.Meetup{}}} {
var n int64
a.db.Model(pair.value).Count(&n)
counts[pair.name] = n
}
ok(c, counts)
}
func (a *API) adminUsers(c *gin.Context) {
var rows []model.User
if a.db.Order("created_at DESC").Limit(200).Find(&rows).Error != nil {
fail(c, 500, "读取用户失败")
return
}
items := make([]gin.H, 0, len(rows))
for _, u := range rows {
v := a.userView(u)
v["disabled"] = u.Disabled
items = append(items, v)
}
ok(c, items)
}
func (a *API) adminPets(c *gin.Context) {
var rows []model.Pet
if a.db.Order("created_at DESC").Limit(200).Find(&rows).Error != nil {
fail(c, 500, "读取宠物失败")
return
}
items := make([]gin.H, 0, len(rows))
for _, p := range rows {
v := a.petView(p)
v["user_id"] = p.UserID
v["archived"] = p.Archived
items = append(items, v)
}
ok(c, items)
}
func (a *API) adminWalks(c *gin.Context) {
var rows []model.Walk
if a.db.Order("started_at DESC").Limit(200).Find(&rows).Error != nil {
fail(c, 500, "读取遛狗失败")
return
}
ok(c, rows)
}
func (a *API) adminMeetups(c *gin.Context) {
var rows []model.Meetup
if a.db.Order("created_at DESC").Limit(200).Find(&rows).Error != nil {
fail(c, 500, "读取约遛失败")
return
}
items := make([]gin.H, 0, len(rows))
for _, m := range rows {
items = append(items, a.meetupView(m))
}
ok(c, items)
}
func (a *API) adminSetUserDisabled(c *gin.Context) {
var in struct {
Disabled bool `json:"disabled"`
}
if !validID(c.Param("id")) || c.ShouldBindJSON(&in) != nil {
fail(c, 400, "参数无效")
return
}
r := a.db.Model(&model.User{}).Where("id = ?", c.Param("id")).Update("disabled", in.Disabled)
if r.Error != nil || r.RowsAffected == 0 {
fail(c, 404, "用户不存在")
return
}
ok(c, gin.H{"disabled": in.Disabled})
}
func (a *API) adminSetMeetupStatus(c *gin.Context) {
var in struct {
Status string `json:"status"`
}
if !validID(c.Param("id")) || c.ShouldBindJSON(&in) != nil || (in.Status != "open" && in.Status != "cancelled") {
fail(c, http.StatusBadRequest, "状态无效")
return
}
r := a.db.Model(&model.Meetup{}).Where("id = ?", c.Param("id")).Update("status", in.Status)
if r.Error != nil || r.RowsAffected == 0 {
fail(c, 404, "约遛不存在")
return
}
ok(c, gin.H{"status": in.Status})
}
+174
View File
@@ -0,0 +1,174 @@
package httpapi
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"github.com/sundynix/pets-be/internal/model"
)
func (a *API) SeedAdmin() error {
if a.cfg.Admin.Password == "" {
if a.cfg.Server.Mode == "release" {
return errors.New("admin password is required in release mode")
}
return nil
}
var admin model.Admin
err := a.db.Where("username = ?", a.cfg.Admin.Username).First(&admin).Error
missing := errors.Is(err, gorm.ErrRecordNotFound)
if err != nil && !missing {
return err
}
if !missing && bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(a.cfg.Admin.Password)) == nil {
return nil
}
hash, err := bcrypt.GenerateFromPassword([]byte(a.cfg.Admin.Password), bcrypt.DefaultCost)
if err != nil {
return err
}
if missing {
return a.db.Create(&model.Admin{Username: a.cfg.Admin.Username, PasswordHash: string(hash)}).Error
}
return a.db.Model(&admin).Update("password_hash", string(hash)).Error
}
func (a *API) adminLogin(c *gin.Context) {
var in struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
if err := c.ShouldBindJSON(&in); err != nil {
fail(c, http.StatusBadRequest, "请输入用户名和密码")
return
}
var admin model.Admin
if err := a.db.Where("username = ?", in.Username).First(&admin).Error; err != nil || bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(in.Password)) != nil {
fail(c, http.StatusUnauthorized, "用户名或密码错误")
return
}
token, err := a.token(admin.ID, "admin")
if err != nil {
fail(c, http.StatusInternalServerError, "签发令牌失败")
return
}
ok(c, gin.H{"token": token, "admin": gin.H{"id": admin.ID, "username": admin.Username}})
}
func (a *API) devLogin(c *gin.Context) {
if !a.cfg.Auth.DevLogin || a.cfg.Server.Mode == "release" {
fail(c, http.StatusNotFound, "接口不存在")
return
}
var in struct {
Key string `json:"key" binding:"required"`
Nickname string `json:"nickname"`
}
if err := c.ShouldBindJSON(&in); err != nil || len(in.Key) > 64 {
fail(c, http.StatusBadRequest, "开发登录参数无效")
return
}
name := strings.TrimSpace(in.Nickname)
if name == "" {
name = "柯基家长"
}
user, err := a.findOrCreateUser("dev:"+in.Key, name)
if err != nil {
fail(c, http.StatusInternalServerError, "登录失败")
return
}
a.loginUser(c, user)
}
func (a *API) login(c *gin.Context) {
var in struct {
Code string `json:"code" binding:"required"`
}
if err := c.ShouldBindJSON(&in); err != nil || a.cfg.WeChat.AppID == "" || a.cfg.WeChat.AppSecret == "" {
fail(c, http.StatusBadRequest, "微信登录配置或参数无效")
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 6*time.Second)
defer cancel()
endpoint := "https://api.weixin.qq.com/sns/jscode2session?" + url.Values{
"appid": {a.cfg.WeChat.AppID}, "secret": {a.cfg.WeChat.AppSecret},
"js_code": {in.Code}, "grant_type": {"authorization_code"},
}.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
fail(c, http.StatusInternalServerError, "微信登录失败")
return
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
fail(c, http.StatusBadGateway, "微信登录暂不可用")
return
}
defer resp.Body.Close()
var wx struct {
OpenID string `json:"openid"`
ErrCode int `json:"errcode"`
}
if resp.StatusCode != http.StatusOK || json.NewDecoder(resp.Body).Decode(&wx) != nil || wx.ErrCode != 0 || wx.OpenID == "" {
fail(c, http.StatusUnauthorized, "微信登录凭证无效")
return
}
user, err := a.findOrCreateUser(wx.OpenID, "柯基家长")
if err != nil {
fail(c, http.StatusInternalServerError, "登录失败")
return
}
a.loginUser(c, user)
}
func (a *API) findOrCreateUser(openID, nickname string) (model.User, error) {
var user model.User
err := a.db.Where("open_id = ?", openID).First(&user).Error
if err == nil {
return user, nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return user, err
}
user = model.User{OpenID: openID, Nickname: nickname}
if err := a.db.Create(&user).Error; err != nil {
// A concurrent login may have inserted the same open_id.
if lookup := a.db.Where("open_id = ?", openID).First(&user).Error; lookup == nil {
return user, nil
}
return user, fmt.Errorf("create user: %w", err)
}
return user, nil
}
func (a *API) loginUser(c *gin.Context, user model.User) {
if user.Disabled {
fail(c, http.StatusForbidden, "账号已停用")
return
}
token, err := a.token(user.ID, "user")
if err != nil {
fail(c, http.StatusInternalServerError, "签发令牌失败")
return
}
ok(c, gin.H{"token": token, "user": a.userView(user)})
}
func (a *API) me(c *gin.Context) {
var user model.User
if err := a.db.First(&user, "id = ?", actor(c)).Error; err != nil {
fail(c, http.StatusNotFound, "用户不存在")
return
}
ok(c, a.userView(user))
}
+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}
}
@@ -0,0 +1,38 @@
package httpapi
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/sundynix/pets-be/internal/config"
)
func TestLoginRoute(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
New(nil, nil, &config.Config{}).Register(router)
for _, route := range router.Routes() {
if !strings.HasPrefix(route.Path, "/api/") {
t.Errorf("API route %q does not have /api/ prefix", route.Path)
}
}
for _, test := range []struct {
path string
want int
}{
{path: "/api/auth/login", want: http.StatusBadRequest},
{path: "/api/auth/wechat", want: http.StatusNotFound},
} {
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, test.path, strings.NewReader(`{}`))
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(recorder, request)
if recorder.Code != test.want {
t.Errorf("POST %s: got %d, want %d", test.path, recorder.Code, test.want)
}
}
}
+197
View File
@@ -0,0 +1,197 @@
package httpapi
import (
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/sundynix/pets-be/internal/model"
"gorm.io/gorm"
)
func (a *API) meetupView(m model.Meetup) gin.H {
var count int64
a.db.Model(&model.MeetupParticipant{}).Where("meetup_id = ?", m.ID).Count(&count)
return gin.H{"id": m.ID, "host_user_id": m.HostUserID, "title": m.Title, "description": m.Description, "place_name": m.PlaceName, "latitude": m.Latitude, "longitude": m.Longitude, "starts_at": m.StartsAt, "max_people": m.MaxPeople, "status": m.Status, "participants": count, "cover_file_id": m.CoverFileID, "cover_url": a.fileURL(m.CoverFileID)}
}
func (a *API) listMeetups(c *gin.Context) {
var rows []model.Meetup
if a.db.Where("starts_at > ? AND status = ?", time.Now().Add(-time.Hour), "open").Order("starts_at ASC").Limit(100).Find(&rows).Error != nil {
fail(c, 500, "读取约遛失败")
return
}
items := make([]gin.H, 0, len(rows))
for _, m := range rows {
items = append(items, a.meetupView(m))
}
ok(c, items)
}
func (a *API) getMeetup(c *gin.Context) {
var m model.Meetup
if !validID(c.Param("id")) || a.db.First(&m, "id = ?", c.Param("id")).Error != nil {
fail(c, 404, "约遛不存在")
return
}
view := a.meetupView(m)
var joins []model.MeetupParticipant
a.db.Where("meetup_id = ?", m.ID).Find(&joins)
people := make([]gin.H, 0, len(joins))
for _, j := range joins {
var pets []model.MeetupPet
a.db.Where("participant_id = ?", j.ID).Find(&pets)
ids := []string{}
for _, p := range pets {
ids = append(ids, p.PetID)
}
people = append(people, gin.H{"user_id": j.UserID, "pet_ids": ids})
}
view["people"] = people
ok(c, view)
}
type meetupInput struct {
Title string `json:"title" binding:"required"`
Description string `json:"description"`
PlaceName string `json:"place_name" binding:"required"`
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
StartsAt time.Time `json:"starts_at" binding:"required"`
MaxPeople int `json:"max_people"`
PetIDs []string `json:"pet_ids" binding:"required"`
CoverFileID *string `json:"cover_file_id"`
}
func (a *API) createMeetup(c *gin.Context) {
var in meetupInput
if c.ShouldBindJSON(&in) != nil || strings.TrimSpace(in.Title) == "" || strings.TrimSpace(in.PlaceName) == "" || !in.StartsAt.After(time.Now()) || len(in.PetIDs) == 0 {
fail(c, 400, "约遛资料无效")
return
}
if in.MaxPeople == 0 {
in.MaxPeople = 8
}
if in.MaxPeople < 2 || in.MaxPeople > 100 {
fail(c, 400, "人数应为 2 到 100")
return
}
for _, id := range in.PetIDs {
if _, err := a.ownedPet(actor(c), id); err != nil {
fail(c, 400, "宠物无效")
return
}
}
if in.CoverFileID != nil && !a.fileExists(*in.CoverFileID) {
fail(c, 400, "封面文件无效")
return
}
m := model.Meetup{HostUserID: actor(c), Title: strings.TrimSpace(in.Title), Description: strings.TrimSpace(in.Description), PlaceName: strings.TrimSpace(in.PlaceName), Latitude: in.Latitude, Longitude: in.Longitude, StartsAt: in.StartsAt, MaxPeople: in.MaxPeople, Status: "open", CoverFileID: in.CoverFileID}
err := a.db.Transaction(func(tx *gorm.DB) error {
if e := tx.Create(&m).Error; e != nil {
return e
}
j := model.MeetupParticipant{MeetupID: m.ID, UserID: actor(c)}
if e := tx.Create(&j).Error; e != nil {
return e
}
for _, id := range in.PetIDs {
if e := tx.Create(&model.MeetupPet{ParticipantID: j.ID, PetID: id}).Error; e != nil {
return e
}
}
return nil
})
if err != nil {
fail(c, 500, "发布失败")
return
}
created(c, a.meetupView(m))
}
func (a *API) joinMeetup(c *gin.Context) {
var in struct {
PetIDs []string `json:"pet_ids" binding:"required"`
}
if c.ShouldBindJSON(&in) != nil || len(in.PetIDs) == 0 {
fail(c, 400, "请选择宠物")
return
}
for _, id := range in.PetIDs {
if _, err := a.ownedPet(actor(c), id); err != nil {
fail(c, 400, "宠物无效")
return
}
}
var m model.Meetup
if !validID(c.Param("id")) || a.db.First(&m, "id = ?", c.Param("id")).Error != nil {
fail(c, 404, "约遛不存在")
return
}
if m.Status != "open" || !m.StartsAt.After(time.Now()) {
fail(c, 409, "约遛已关闭")
return
}
var count int64
a.db.Model(&model.MeetupParticipant{}).Where("meetup_id = ?", m.ID).Count(&count)
if count >= int64(m.MaxPeople) {
fail(c, 409, "人数已满")
return
}
var existing int64
a.db.Model(&model.MeetupParticipant{}).Where("meetup_id = ? AND user_id = ?", m.ID, actor(c)).Count(&existing)
if existing > 0 {
fail(c, 409, "已经参加")
return
}
j := model.MeetupParticipant{MeetupID: m.ID, UserID: actor(c)}
err := a.db.Transaction(func(tx *gorm.DB) error {
if e := tx.Create(&j).Error; e != nil {
return e
}
for _, id := range in.PetIDs {
if e := tx.Create(&model.MeetupPet{ParticipantID: j.ID, PetID: id}).Error; e != nil {
return e
}
}
return nil
})
if err != nil {
fail(c, 500, "报名失败")
return
}
ok(c, a.meetupView(m))
}
func (a *API) leaveMeetup(c *gin.Context) {
var j model.MeetupParticipant
if !validID(c.Param("id")) || a.db.Where("meetup_id = ? AND user_id = ?", c.Param("id"), actor(c)).First(&j).Error != nil {
fail(c, 404, "未参加")
return
}
var m model.Meetup
a.db.First(&m, "id = ?", j.MeetupID)
if m.HostUserID == actor(c) {
fail(c, http.StatusConflict, "发起人请取消约遛")
return
}
if a.db.Transaction(func(tx *gorm.DB) error {
if e := tx.Where("participant_id = ?", j.ID).Delete(&model.MeetupPet{}).Error; e != nil {
return e
}
return tx.Delete(&j).Error
}) != nil {
fail(c, 500, "退出失败")
return
}
ok(c, gin.H{"left": true})
}
func (a *API) cancelMeetup(c *gin.Context) {
if !validID(c.Param("id")) {
fail(c, 400, "ID 无效")
return
}
result := a.db.Model(&model.Meetup{}).Where("id = ? AND host_user_id = ? AND status = ?", c.Param("id"), actor(c), "open").Update("status", "cancelled")
if result.Error != nil || result.RowsAffected == 0 {
fail(c, 404, "约遛不存在或已关闭")
return
}
ok(c, gin.H{"status": "cancelled"})
}
+149
View File
@@ -0,0 +1,149 @@
package httpapi
import (
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/sundynix/pets-be/internal/model"
)
type petInput struct {
Name string `json:"name" binding:"required"`
Species string `json:"species"`
Breed string `json:"breed"`
Gender string `json:"gender"`
BirthDate string `json:"birth_date"`
WeightKg float64 `json:"weight_kg"`
AvatarFileID *string `json:"avatar_file_id"`
Coat string `json:"coat"`
Allergies string `json:"allergies"`
CareNotes string `json:"care_notes"`
}
func (a *API) listPets(c *gin.Context) {
var pets []model.Pet
if err := a.db.Where("user_id = ? AND archived = false", actor(c)).Order("created_at ASC").Find(&pets).Error; err != nil {
fail(c, http.StatusInternalServerError, "读取宠物失败")
return
}
items := make([]gin.H, 0, len(pets))
for _, pet := range pets {
items = append(items, a.petView(pet))
}
ok(c, items)
}
func (a *API) getPet(c *gin.Context) {
pet, err := a.ownedPet(actor(c), c.Param("id"))
if err != nil {
fail(c, http.StatusNotFound, "宠物不存在")
return
}
ok(c, a.petView(pet))
}
func (a *API) createPet(c *gin.Context) {
var in petInput
if c.ShouldBindJSON(&in) != nil {
fail(c, http.StatusBadRequest, "宠物资料无效")
return
}
pet, valid := a.buildPet(c, in)
if !valid {
return
}
pet.UserID = actor(c)
if err := a.db.Create(&pet).Error; err != nil {
fail(c, http.StatusInternalServerError, "创建宠物失败")
return
}
created(c, a.petView(pet))
}
func (a *API) updatePet(c *gin.Context) {
pet, err := a.ownedPet(actor(c), c.Param("id"))
if err != nil {
fail(c, http.StatusNotFound, "宠物不存在")
return
}
var in petInput
if c.ShouldBindJSON(&in) != nil {
fail(c, http.StatusBadRequest, "宠物资料无效")
return
}
updated, valid := a.buildPet(c, in)
if !valid {
return
}
pet.Name, pet.Species, pet.Breed, pet.Gender = updated.Name, updated.Species, updated.Breed, updated.Gender
pet.BirthDate, pet.WeightKg, pet.AvatarFileID = updated.BirthDate, updated.WeightKg, updated.AvatarFileID
pet.Coat, pet.Allergies, pet.CareNotes = updated.Coat, updated.Allergies, updated.CareNotes
if err := a.db.Save(&pet).Error; err != nil {
fail(c, http.StatusInternalServerError, "更新宠物失败")
return
}
ok(c, a.petView(pet))
}
func (a *API) buildPet(c *gin.Context, in petInput) (model.Pet, bool) {
pet := model.Pet{}
in.Name = strings.TrimSpace(in.Name)
if in.Name == "" || len([]rune(in.Name)) > 64 || in.WeightKg < 0 || in.WeightKg > 200 {
fail(c, http.StatusBadRequest, "名字或体重无效")
return pet, false
}
if in.Species == "" {
in.Species = "dog"
}
if in.Species != "dog" && in.Species != "cat" && in.Species != "other" {
fail(c, http.StatusBadRequest, "宠物类型无效")
return pet, false
}
if in.AvatarFileID != nil {
var count int64
if !validID(*in.AvatarFileID) || a.db.Model(&model.File{}).Where("id = ?", *in.AvatarFileID).Count(&count).Error != nil || count == 0 {
fail(c, http.StatusBadRequest, "头像文件无效")
return pet, false
}
}
var birthday *time.Time
if in.BirthDate != "" {
d, err := parseDay(in.BirthDate)
if err != nil || d.After(time.Now()) {
fail(c, http.StatusBadRequest, "生日无效")
return pet, false
}
birthday = &d
}
pet = model.Pet{Name: in.Name, Species: in.Species, Breed: strings.TrimSpace(in.Breed),
Gender: in.Gender, BirthDate: birthday, WeightKg: in.WeightKg,
AvatarFileID: in.AvatarFileID, Coat: in.Coat, Allergies: in.Allergies, CareNotes: in.CareNotes}
return pet, true
}
func (a *API) archivePet(c *gin.Context) {
pet, err := a.ownedPet(actor(c), c.Param("id"))
if err != nil {
fail(c, http.StatusNotFound, "宠物不存在")
return
}
var active int64
if err := a.db.Table("sundynix_walk_pet AS wp").Joins("JOIN sundynix_walk AS w ON w.id = wp.walk_id").
Where("wp.pet_id = ? AND w.status IN ?", pet.ID, []string{"active", "paused"}).Count(&active).Error; err != nil {
fail(c, http.StatusInternalServerError, "检查遛狗状态失败")
return
}
if active > 0 {
fail(c, http.StatusConflict, "这只宠物正在遛狗,结束后再归档")
return
}
if err := a.db.Model(&model.Pet{}).Where("id = ? AND user_id = ?", pet.ID, actor(c)).Update("archived", true).Error; err != nil && err != gorm.ErrRecordNotFound {
fail(c, http.StatusInternalServerError, "归档失败")
return
}
ok(c, gin.H{"archived": true})
}
+160
View File
@@ -0,0 +1,160 @@
package httpapi
import (
"errors"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"gorm.io/gorm"
"github.com/sundynix/pets-be/internal/config"
"github.com/sundynix/pets-be/internal/model"
"github.com/sundynix/pets-be/internal/storage"
)
type API struct {
db *gorm.DB
storage *storage.Storage
cfg *config.Config
}
func New(db *gorm.DB, storage *storage.Storage, cfg *config.Config) *API {
return &API{db: db, storage: storage, cfg: cfg}
}
func (a *API) Register(r *gin.Engine) {
r.Use(gin.Logger(), gin.Recovery())
_ = r.SetTrustedProxies(nil)
api := r.Group("/api")
api.GET("/ping", func(c *gin.Context) { ok(c, gin.H{"pong": true}) })
api.GET("/share/walk/:token", a.publicWalkLocation)
api.POST("/auth/login", a.login)
api.POST("/auth/dev", a.devLogin)
api.POST("/admin/login", a.adminLogin)
user := api.Group("")
user.Use(a.requireRole("user"))
user.GET("/me", a.me)
user.GET("/pets", a.listPets)
user.POST("/pets", a.createPet)
user.GET("/pets/:id", a.getPet)
user.PUT("/pets/:id", a.updatePet)
user.DELETE("/pets/:id", a.archivePet)
user.POST("/files", a.uploadFile)
user.GET("/files/:id", a.getFile)
user.GET("/tasks", a.listTasks)
user.POST("/tasks", a.createTask)
user.PUT("/tasks/:id", a.updateTask)
user.DELETE("/tasks/:id", a.deleteTask)
user.PUT("/tasks/:id/completion", a.setTaskCompletion)
user.GET("/walks", a.listWalks)
user.POST("/walks", a.startWalk)
user.GET("/walks/:id", a.getWalk)
user.POST("/walks/:id/points", a.addWalkPoint)
user.POST("/walks/:id/pause", a.pauseWalk)
user.POST("/walks/:id/resume", a.resumeWalk)
user.POST("/walks/:id/finish", a.finishWalk)
user.PUT("/walks/:id/share", a.setWalkShare)
user.GET("/meetups", a.listMeetups)
user.POST("/meetups", a.createMeetup)
user.GET("/meetups/:id", a.getMeetup)
user.POST("/meetups/:id/join", a.joinMeetup)
user.DELETE("/meetups/:id/join", a.leaveMeetup)
user.POST("/meetups/:id/cancel", a.cancelMeetup)
admin := api.Group("/admin")
admin.Use(a.requireRole("admin"))
admin.GET("/stats", a.adminStats)
admin.GET("/users", a.adminUsers)
admin.GET("/pets", a.adminPets)
admin.GET("/walks", a.adminWalks)
admin.GET("/meetups", a.adminMeetups)
admin.PUT("/users/:id/disabled", a.adminSetUserDisabled)
admin.PUT("/meetups/:id/status", a.adminSetMeetupStatus)
}
type claims struct {
Role string `json:"role"`
jwt.RegisteredClaims
}
func (a *API) token(id, role string) (string, error) {
expire := a.cfg.JWT.ExpireHours
if expire <= 0 {
expire = 168
}
now := time.Now()
t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims{Role: role, RegisteredClaims: jwt.RegisteredClaims{
Subject: id, IssuedAt: jwt.NewNumericDate(now), ExpiresAt: jwt.NewNumericDate(now.Add(time.Duration(expire) * time.Hour)),
}})
return t.SignedString([]byte(a.cfg.JWT.Secret))
}
func (a *API) requireRole(role string) gin.HandlerFunc {
return func(c *gin.Context) {
header := c.GetHeader("Authorization")
if !strings.HasPrefix(header, "Bearer ") {
fail(c, http.StatusUnauthorized, "请先登录")
c.Abort()
return
}
t, err := jwt.ParseWithClaims(strings.TrimPrefix(header, "Bearer "), &claims{}, func(t *jwt.Token) (any, error) {
if t.Method != jwt.SigningMethodHS256 {
return nil, errors.New("unexpected signing method")
}
return []byte(a.cfg.JWT.Secret), nil
})
if err != nil || !t.Valid {
fail(c, http.StatusUnauthorized, "登录已过期")
c.Abort()
return
}
cl, valid := t.Claims.(*claims)
if !valid || cl.Role != role || uuid.Validate(cl.Subject) != nil {
fail(c, http.StatusForbidden, "没有访问权限")
c.Abort()
return
}
if role == "user" {
var user model.User
if err := a.db.Select("id", "disabled").First(&user, "id = ?", cl.Subject).Error; err != nil || user.Disabled {
fail(c, http.StatusForbidden, "账号不可用")
c.Abort()
return
}
}
c.Set("actor_id", cl.Subject)
c.Next()
}
}
func actor(c *gin.Context) string { return c.GetString("actor_id") }
func ok(c *gin.Context, data any) {
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "ok", "data": data})
}
func created(c *gin.Context, data any) {
c.JSON(http.StatusCreated, gin.H{"code": 0, "message": "ok", "data": data})
}
func fail(c *gin.Context, status int, message string) {
c.JSON(status, gin.H{"code": status, "message": message})
}
func validID(id string) bool { return uuid.Validate(id) == nil }
func parseDay(value string) (time.Time, error) {
return time.ParseInLocation("2006-01-02", value, time.Local)
}
func (a *API) ownedPet(userID, petID string) (model.Pet, error) {
var pet model.Pet
if !validID(petID) {
return pet, gorm.ErrRecordNotFound
}
err := a.db.Where("id = ? AND user_id = ? AND archived = false", petID, userID).First(&pet).Error
return pet, err
}
+211
View File
@@ -0,0 +1,211 @@
package httpapi
import (
"net/http"
"regexp"
"strings"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/sundynix/pets-be/internal/model"
)
var clockPattern = regexp.MustCompile(`^([01][0-9]|2[0-3]):[0-5][0-9]$`)
type taskInput struct {
PetID string `json:"pet_id" binding:"required"`
Title string `json:"title" binding:"required"`
Category string `json:"category"`
TimeOfDay string `json:"time_of_day"`
RepeatRule string `json:"repeat_rule"`
ReminderEnabled bool `json:"reminder_enabled"`
}
func due(task model.CareTask, date time.Time) bool {
switch task.RepeatRule {
case "daily":
return true
case "weekdays":
return date.Weekday() != time.Saturday && date.Weekday() != time.Sunday
case "weekends":
return date.Weekday() == time.Saturday || date.Weekday() == time.Sunday
default:
return false
}
}
func (a *API) listTasks(c *gin.Context) {
dateString := c.DefaultQuery("date", time.Now().Format("2006-01-02"))
date, err := parseDay(dateString)
if err != nil {
fail(c, http.StatusBadRequest, "日期格式应为 YYYY-MM-DD")
return
}
query := a.db.Where("user_id = ?", actor(c))
if petID := c.Query("pet_id"); petID != "" {
if _, err := a.ownedPet(actor(c), petID); err != nil {
fail(c, http.StatusNotFound, "宠物不存在")
return
}
query = query.Where("pet_id = ?", petID)
}
var tasks []model.CareTask
if err := query.Order("time_of_day ASC").Find(&tasks).Error; err != nil {
fail(c, http.StatusInternalServerError, "读取任务失败")
return
}
var completions []model.TaskCompletion
if err := a.db.Where("user_id = ? AND date = ?", actor(c), date).Find(&completions).Error; err != nil {
fail(c, http.StatusInternalServerError, "读取完成状态失败")
return
}
done := make(map[string]bool, len(completions))
for _, item := range completions {
done[item.TaskID] = true
}
items := make([]gin.H, 0, len(tasks))
for _, task := range tasks {
if due(task, date) {
items = append(items, taskView(task, done[task.ID]))
}
}
ok(c, gin.H{"date": dateString, "items": items})
}
func taskView(task model.CareTask, completed bool) gin.H {
return gin.H{"id": task.ID, "pet_id": task.PetID, "title": task.Title,
"category": task.Category, "time_of_day": task.TimeOfDay,
"repeat_rule": task.RepeatRule, "reminder_enabled": task.ReminderEnabled,
"completed": completed}
}
func (a *API) buildTask(c *gin.Context, in taskInput) (model.CareTask, bool) {
task := model.CareTask{}
if _, err := a.ownedPet(actor(c), in.PetID); err != nil {
fail(c, http.StatusNotFound, "宠物不存在")
return task, false
}
in.Title = strings.TrimSpace(in.Title)
if in.Title == "" || len([]rune(in.Title)) > 128 {
fail(c, http.StatusBadRequest, "任务名称无效")
return task, false
}
if in.Category == "" {
in.Category = "other"
}
if in.TimeOfDay == "" {
in.TimeOfDay = "09:00"
}
if !clockPattern.MatchString(in.TimeOfDay) {
fail(c, http.StatusBadRequest, "时间格式应为 HH:MM")
return task, false
}
if in.RepeatRule == "" {
in.RepeatRule = "daily"
}
if in.RepeatRule != "daily" && in.RepeatRule != "weekdays" && in.RepeatRule != "weekends" {
fail(c, http.StatusBadRequest, "重复规则无效")
return task, false
}
task = model.CareTask{UserID: actor(c), PetID: in.PetID, Title: in.Title,
Category: in.Category, TimeOfDay: in.TimeOfDay, RepeatRule: in.RepeatRule,
ReminderEnabled: in.ReminderEnabled}
return task, true
}
func (a *API) createTask(c *gin.Context) {
var in taskInput
if c.ShouldBindJSON(&in) != nil {
fail(c, http.StatusBadRequest, "任务资料无效")
return
}
task, valid := a.buildTask(c, in)
if !valid {
return
}
if err := a.db.Create(&task).Error; err != nil {
fail(c, http.StatusInternalServerError, "添加任务失败")
return
}
created(c, taskView(task, false))
}
func (a *API) updateTask(c *gin.Context) {
var task model.CareTask
if !validID(c.Param("id")) || a.db.Where("id = ? AND user_id = ?", c.Param("id"), actor(c)).First(&task).Error != nil {
fail(c, http.StatusNotFound, "任务不存在")
return
}
var in taskInput
if c.ShouldBindJSON(&in) != nil {
fail(c, http.StatusBadRequest, "任务资料无效")
return
}
updated, valid := a.buildTask(c, in)
if !valid {
return
}
task.PetID, task.Title, task.Category = updated.PetID, updated.Title, updated.Category
task.TimeOfDay, task.RepeatRule, task.ReminderEnabled = updated.TimeOfDay, updated.RepeatRule, updated.ReminderEnabled
if a.db.Save(&task).Error != nil {
fail(c, http.StatusInternalServerError, "更新任务失败")
return
}
ok(c, taskView(task, false))
}
func (a *API) deleteTask(c *gin.Context) {
if !validID(c.Param("id")) {
fail(c, http.StatusBadRequest, "任务 ID 无效")
return
}
err := a.db.Transaction(func(tx *gorm.DB) error {
var task model.CareTask
if err := tx.Where("id = ? AND user_id = ?", c.Param("id"), actor(c)).First(&task).Error; err != nil {
return err
}
if err := tx.Where("task_id = ?", task.ID).Delete(&model.TaskCompletion{}).Error; err != nil {
return err
}
return tx.Delete(&task).Error
})
if err != nil {
fail(c, http.StatusNotFound, "任务不存在或删除失败")
return
}
ok(c, gin.H{"deleted": true})
}
func (a *API) setTaskCompletion(c *gin.Context) {
var task model.CareTask
if !validID(c.Param("id")) || a.db.Where("id = ? AND user_id = ?", c.Param("id"), actor(c)).First(&task).Error != nil {
fail(c, http.StatusNotFound, "任务不存在")
return
}
var in struct {
Date string `json:"date" binding:"required"`
Completed bool `json:"completed"`
}
if c.ShouldBindJSON(&in) != nil {
fail(c, http.StatusBadRequest, "完成状态参数无效")
return
}
date, err := parseDay(in.Date)
if err != nil || !due(task, date) {
fail(c, http.StatusBadRequest, "该日期没有这项任务")
return
}
if in.Completed {
item := model.TaskCompletion{TaskID: task.ID, UserID: actor(c), Date: date, CompletedAt: time.Now()}
if err := a.db.Where("task_id = ? AND date = ?", task.ID, date).FirstOrCreate(&item).Error; err != nil {
fail(c, http.StatusInternalServerError, "保存完成状态失败")
return
}
} else if err := a.db.Where("task_id = ? AND date = ?", task.ID, date).Delete(&model.TaskCompletion{}).Error; err != nil {
fail(c, http.StatusInternalServerError, "取消完成失败")
return
}
ok(c, gin.H{"id": task.ID, "date": in.Date, "completed": in.Completed})
}
+254
View File
@@ -0,0 +1,254 @@
package httpapi
import (
"math"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/sundynix/pets-be/internal/model"
"gorm.io/gorm"
)
func (a *API) walk(c *gin.Context) (model.Walk, bool) {
var w model.Walk
if !validID(c.Param("id")) || a.db.Where("id = ? AND user_id = ?", c.Param("id"), actor(c)).First(&w).Error != nil {
fail(c, 404, "遛狗记录不存在")
return w, false
}
return w, true
}
func (a *API) walkView(w model.Walk) gin.H {
var links []model.WalkPet
var points []model.WalkPoint
a.db.Where("walk_id = ?", w.ID).Find(&links)
a.db.Where("walk_id = ?", w.ID).Order("recorded_at ASC").Find(&points)
ids := make([]string, 0, len(links))
for _, p := range links {
ids = append(ids, p.PetID)
}
return gin.H{"id": w.ID, "pet_ids": ids, "status": w.Status, "started_at": w.StartedAt, "ended_at": w.EndedAt, "paused_at": w.PausedAt, "paused_seconds": w.PausedSeconds, "duration_seconds": w.DurationSeconds, "distance_meters": w.DistanceMeters, "share_enabled": w.ShareEnabled, "share_token": func() string {
if w.ShareEnabled {
return w.ShareToken
}
return ""
}(), "share_expires_at": w.ShareExpiresAt, "points": points}
}
func (a *API) listWalks(c *gin.Context) {
var walks []model.Walk
if a.db.Where("user_id = ?", actor(c)).Order("started_at DESC").Limit(50).Find(&walks).Error != nil {
fail(c, 500, "读取记录失败")
return
}
items := make([]gin.H, 0, len(walks))
for _, w := range walks {
items = append(items, a.walkView(w))
}
ok(c, items)
}
func (a *API) getWalk(c *gin.Context) {
if w, yes := a.walk(c); yes {
ok(c, a.walkView(w))
}
}
func (a *API) startWalk(c *gin.Context) {
var in struct {
PetIDs []string `json:"pet_ids" binding:"required"`
}
if c.ShouldBindJSON(&in) != nil || len(in.PetIDs) == 0 {
fail(c, 400, "请至少选择一只宠物")
return
}
seen := map[string]bool{}
for _, id := range in.PetIDs {
if seen[id] {
fail(c, 400, "宠物重复")
return
}
seen[id] = true
if _, err := a.ownedPet(actor(c), id); err != nil {
fail(c, 400, "选择的宠物不存在")
return
}
}
var active int64
a.db.Model(&model.Walk{}).Where("user_id = ? AND status IN ?", actor(c), []string{"active", "paused"}).Count(&active)
if active > 0 {
fail(c, 409, "已有正在进行的遛狗")
return
}
now := time.Now()
w := model.Walk{UserID: actor(c), Status: "active", StartedAt: now, SegmentStartedAt: now, ShareToken: uuid.NewString()}
if err := a.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&w).Error; err != nil {
return err
}
for _, id := range in.PetIDs {
if err := tx.Create(&model.WalkPet{WalkID: w.ID, PetID: id}).Error; err != nil {
return err
}
}
return nil
}); err != nil {
fail(c, 500, "开始遛狗失败")
return
}
created(c, a.walkView(w))
}
func (a *API) addWalkPoint(c *gin.Context) {
w, yes := a.walk(c)
if !yes {
return
}
if w.Status != "active" {
fail(c, 409, "遛狗未进行中")
return
}
var in struct {
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
}
if c.ShouldBindJSON(&in) != nil || math.Abs(in.Latitude) > 90 || math.Abs(in.Longitude) > 180 || (in.Latitude == 0 && in.Longitude == 0) {
fail(c, 400, "位置无效")
return
}
point := model.WalkPoint{WalkID: w.ID, Latitude: in.Latitude, Longitude: in.Longitude, RecordedAt: time.Now()}
var last model.WalkPoint
err := a.db.Where("walk_id = ?", w.ID).Order("recorded_at DESC").First(&last).Error
if err == nil && !last.RecordedAt.Before(w.SegmentStartedAt) {
d := haversine(last.Latitude, last.Longitude, in.Latitude, in.Longitude)
if d > 1000 {
fail(c, 400, "位置跳变过大")
return
}
if d < 5 {
ok(c, a.walkView(w))
return
}
w.DistanceMeters += d
}
if txErr := a.db.Transaction(func(tx *gorm.DB) error {
if e := tx.Create(&point).Error; e != nil {
return e
}
return tx.Model(&w).Update("distance_meters", w.DistanceMeters).Error
}); txErr != nil {
fail(c, 500, "保存位置失败")
return
}
ok(c, a.walkView(w))
}
func haversine(a, b, c, d float64) float64 {
const r = 6371000.
x1, x2 := a*math.Pi/180, c*math.Pi/180
dx, dy := (c-a)*math.Pi/180, (d-b)*math.Pi/180
h := math.Pow(math.Sin(dx/2), 2) + math.Cos(x1)*math.Cos(x2)*math.Pow(math.Sin(dy/2), 2)
return 2 * r * math.Asin(math.Sqrt(h))
}
func (a *API) pauseWalk(c *gin.Context) {
w, yes := a.walk(c)
if !yes {
return
}
if w.Status != "active" {
fail(c, 409, "当前不能暂停")
return
}
now := time.Now()
w.Status = "paused"
w.PausedAt = &now
if a.db.Save(&w).Error != nil {
fail(c, 500, "暂停失败")
return
}
ok(c, a.walkView(w))
}
func (a *API) resumeWalk(c *gin.Context) {
w, yes := a.walk(c)
if !yes {
return
}
if w.Status != "paused" || w.PausedAt == nil {
fail(c, 409, "当前不能继续")
return
}
now := time.Now()
w.PausedSeconds += int(now.Sub(*w.PausedAt).Seconds())
w.PausedAt = nil
w.SegmentStartedAt = now
w.Status = "active"
if a.db.Save(&w).Error != nil {
fail(c, 500, "继续失败")
return
}
ok(c, a.walkView(w))
}
func (a *API) finishWalk(c *gin.Context) {
w, yes := a.walk(c)
if !yes {
return
}
if w.Status == "finished" {
fail(c, 409, "已结束")
return
}
now := time.Now()
if w.PausedAt != nil {
w.PausedSeconds += int(now.Sub(*w.PausedAt).Seconds())
w.PausedAt = nil
}
w.Status = "finished"
w.EndedAt = &now
w.DurationSeconds = int(now.Sub(w.StartedAt).Seconds()) - w.PausedSeconds
w.ShareEnabled = false
w.ShareExpiresAt = nil
if a.db.Save(&w).Error != nil {
fail(c, 500, "结束遛狗失败")
return
}
ok(c, a.walkView(w))
}
func (a *API) setWalkShare(c *gin.Context) {
w, yes := a.walk(c)
if !yes {
return
}
if w.Status == "finished" {
fail(c, 409, "已结束的遛狗不能分享")
return
}
var in struct {
Enabled bool `json:"enabled"`
}
if c.ShouldBindJSON(&in) != nil {
fail(c, 400, "参数无效")
return
}
w.ShareEnabled = in.Enabled
if in.Enabled {
expire := time.Now().Add(4 * time.Hour)
w.ShareExpiresAt = &expire
w.ShareToken = uuid.NewString()
} else {
w.ShareExpiresAt = nil
}
if a.db.Save(&w).Error != nil {
fail(c, 500, "分享设置失败")
return
}
ok(c, a.walkView(w))
}
func (a *API) publicWalkLocation(c *gin.Context) {
var w model.Walk
if !validID(c.Param("token")) || a.db.Where("share_token = ? AND share_enabled = true AND status IN ? AND share_expires_at > ?", c.Param("token"), []string{"active", "paused"}, time.Now()).First(&w).Error != nil {
fail(c, http.StatusNotFound, "分享已失效")
return
}
var p model.WalkPoint
if a.db.Where("walk_id = ?", w.ID).Order("recorded_at DESC").First(&p).Error != nil {
fail(c, 404, "暂无位置")
return
}
ok(c, gin.H{"latitude": p.Latitude, "longitude": p.Longitude, "recorded_at": p.RecordedAt, "status": w.Status})
}