feat: rebuild pet app and backend around core care flows
This commit is contained in:
@@ -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})
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
Reference in New Issue
Block a user