470 lines
14 KiB
Go
470 lines
14 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/sundynix/pets-be/internal/model"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type adminPage struct {
|
|
Page int `json:"page"`
|
|
PageSize int `json:"page_size"`
|
|
Total int64 `json:"total"`
|
|
Items any `json:"items"`
|
|
}
|
|
|
|
func pageParams(c *gin.Context) (adminPage, bool) {
|
|
page, err := strconv.Atoi(c.DefaultQuery("page", "1"))
|
|
size, sizeErr := strconv.Atoi(c.DefaultQuery("page_size", "10"))
|
|
if err != nil || sizeErr != nil || page < 1 || page > 1000000 || size < 1 || size > 100 || len([]rune(c.Query("q"))) > 128 {
|
|
fail(c, http.StatusBadRequest, "分页或搜索参数无效")
|
|
return adminPage{}, false
|
|
}
|
|
return adminPage{Page: page, PageSize: size}, true
|
|
}
|
|
func adminRows[T any](c *gin.Context, query *gorm.DB, order string) ([]T, adminPage, bool) {
|
|
page, valid := pageParams(c)
|
|
if !valid {
|
|
return nil, page, false
|
|
}
|
|
if err := query.Count(&page.Total).Error; err != nil {
|
|
fail(c, 500, "读取记录数量失败")
|
|
return nil, page, false
|
|
}
|
|
rows := make([]T, 0)
|
|
if err := query.Order(order).Offset((page.Page - 1) * page.PageSize).Limit(page.PageSize).Find(&rows).Error; err != nil {
|
|
fail(c, 500, "读取记录失败")
|
|
return nil, page, false
|
|
}
|
|
return rows, page, true
|
|
}
|
|
func search(query *gorm.DB, c *gin.Context, columns ...string) *gorm.DB {
|
|
q := strings.TrimSpace(c.Query("q"))
|
|
if q == "" {
|
|
return query
|
|
}
|
|
// Only allow literal substring search. Columns are constants supplied by handlers.
|
|
value := "%" + strings.NewReplacer("\\", "\\\\", "%", "\\%", "_", "\\_").Replace(q) + "%"
|
|
clauses := make([]string, 0, len(columns))
|
|
args := make([]any, 0, len(columns))
|
|
for _, column := range columns {
|
|
clauses = append(clauses, column+" ILIKE ?")
|
|
args = append(args, value)
|
|
}
|
|
return query.Where("("+strings.Join(clauses, " OR ")+")", args...)
|
|
}
|
|
func filterStatus(c *gin.Context, query *gorm.DB, column string, allowed ...string) (*gorm.DB, bool) {
|
|
status := c.Query("status")
|
|
if status == "" {
|
|
return query, true
|
|
}
|
|
for _, value := range allowed {
|
|
if status == value {
|
|
return query.Where(column+" = ?", status), true
|
|
}
|
|
}
|
|
fail(c, 400, "筛选状态无效")
|
|
return query, false
|
|
}
|
|
func (a *API) userNames(ids []string) (map[string]string, error) {
|
|
names := map[string]string{}
|
|
if len(ids) == 0 {
|
|
return names, nil
|
|
}
|
|
var users []model.User
|
|
err := a.db.Select("id", "nickname").Where("id IN ?", ids).Find(&users).Error
|
|
for _, user := range users {
|
|
names[user.ID] = user.Nickname
|
|
}
|
|
return names, err
|
|
}
|
|
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{}}, {"tasks", &model.CareTask{}}, {"files", &model.File{}}} {
|
|
var n int64
|
|
if err := a.db.Model(pair.value).Count(&n).Error; err != nil {
|
|
fail(c, 500, "读取统计失败")
|
|
return
|
|
}
|
|
counts[pair.name] = n
|
|
}
|
|
now := time.Now()
|
|
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
|
for _, pair := range []struct {
|
|
name string
|
|
query *gorm.DB
|
|
}{
|
|
{"today_walks", a.db.Model(&model.Walk{}).Where("started_at >= ?", today)},
|
|
{"active_walks", a.db.Model(&model.Walk{}).Where("status IN ?", []string{"active", "paused"})},
|
|
{"upcoming_meetups", a.db.Model(&model.Meetup{}).Where("status = ? AND starts_at > ?", "open", now)},
|
|
{"today_completions", a.db.Model(&model.TaskCompletion{}).Where("date = ?", today)},
|
|
} {
|
|
var n int64
|
|
if pair.query.Count(&n).Error != nil {
|
|
fail(c, 500, "读取统计失败")
|
|
return
|
|
}
|
|
counts[pair.name] = n
|
|
}
|
|
var metrics struct {
|
|
Distance float64
|
|
Duration int64
|
|
Storage int64
|
|
}
|
|
if a.db.Model(&model.Walk{}).Select("COALESCE(SUM(distance_meters),0) AS distance, COALESCE(SUM(duration_seconds),0) AS duration").Scan(&metrics).Error != nil || a.db.Model(&model.File{}).Select("COALESCE(SUM(size),0) AS storage").Scan(&metrics).Error != nil {
|
|
fail(c, 500, "读取统计失败")
|
|
return
|
|
}
|
|
counts["distance_meters"] = metrics.Distance
|
|
counts["duration_seconds"] = metrics.Duration
|
|
counts["storage_bytes"] = metrics.Storage
|
|
type dayCount struct {
|
|
Date string `json:"date"`
|
|
Count int64 `json:"count"`
|
|
}
|
|
var grouped []dayCount
|
|
since := today.AddDate(0, 0, -6)
|
|
if a.db.Model(&model.Walk{}).Select("to_char(started_at AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM-DD') AS date, COUNT(*) AS count").Where("started_at >= ?", since).Group("date").Scan(&grouped).Error != nil {
|
|
fail(c, 500, "读取趋势失败")
|
|
return
|
|
}
|
|
daily := make([]dayCount, 0, 7)
|
|
for day := since; !day.After(today); day = day.AddDate(0, 0, 1) {
|
|
entry := dayCount{Date: day.Format("2006-01-02")}
|
|
for _, row := range grouped {
|
|
if row.Date == entry.Date {
|
|
entry.Count = row.Count
|
|
}
|
|
}
|
|
daily = append(daily, entry)
|
|
}
|
|
counts["daily_walks"] = daily
|
|
ok(c, counts)
|
|
}
|
|
func (a *API) adminUsers(c *gin.Context) {
|
|
query := search(a.db.Model(&model.User{}), c, "nickname", "id::text")
|
|
switch c.Query("status") {
|
|
case "":
|
|
case "enabled":
|
|
query = query.Where("disabled = false")
|
|
case "disabled":
|
|
query = query.Where("disabled = true")
|
|
default:
|
|
fail(c, 400, "筛选状态无效")
|
|
return
|
|
}
|
|
rows, page, valid := adminRows[model.User](c, query, "created_at DESC, id DESC")
|
|
if !valid {
|
|
return
|
|
}
|
|
ids := []string{}
|
|
for _, row := range rows {
|
|
ids = append(ids, row.ID)
|
|
}
|
|
var totals []struct {
|
|
UserID string
|
|
Count int64
|
|
}
|
|
if len(ids) > 0 && a.db.Model(&model.Pet{}).Select("user_id, COUNT(*) AS count").Where("user_id IN ?", ids).Group("user_id").Scan(&totals).Error != nil {
|
|
fail(c, 500, "读取宠物数量失败")
|
|
return
|
|
}
|
|
counts := map[string]int64{}
|
|
for _, row := range totals {
|
|
counts[row.UserID] = row.Count
|
|
}
|
|
items := make([]gin.H, 0, len(rows))
|
|
for _, u := range rows {
|
|
view := a.userView(u)
|
|
view["disabled"] = u.Disabled
|
|
view["pet_count"] = counts[u.ID]
|
|
items = append(items, view)
|
|
}
|
|
page.Items = items
|
|
ok(c, page)
|
|
}
|
|
func (a *API) adminUserPets(c *gin.Context) {
|
|
id := c.Param("id")
|
|
if !validID(id) {
|
|
fail(c, 400, "用户 ID 无效")
|
|
return
|
|
}
|
|
var user model.User
|
|
if err := a.db.First(&user, "id = ?", id).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
fail(c, 404, "用户不存在")
|
|
} else {
|
|
fail(c, 500, "读取用户失败")
|
|
}
|
|
return
|
|
}
|
|
var rows []model.Pet
|
|
if a.db.Where("user_id = ?", id).Order("archived ASC, created_at DESC, id DESC").Find(&rows).Error != nil {
|
|
fail(c, 500, "读取宠物失败")
|
|
return
|
|
}
|
|
items := make([]gin.H, 0, len(rows))
|
|
for _, pet := range rows {
|
|
view := a.petView(pet)
|
|
view["archived"] = pet.Archived
|
|
view["user_id"] = id
|
|
view["user_name"] = user.Nickname
|
|
items = append(items, view)
|
|
}
|
|
ok(c, items)
|
|
}
|
|
func (a *API) adminPets(c *gin.Context) {
|
|
query := search(a.db.Model(&model.Pet{}), c, "name", "breed", "id::text", "user_id::text")
|
|
switch c.Query("status") {
|
|
case "":
|
|
case "active":
|
|
query = query.Where("archived = false")
|
|
case "archived":
|
|
query = query.Where("archived = true")
|
|
default:
|
|
fail(c, 400, "筛选状态无效")
|
|
return
|
|
}
|
|
rows, page, valid := adminRows[model.Pet](c, query, "created_at DESC, id DESC")
|
|
if !valid {
|
|
return
|
|
}
|
|
ids := []string{}
|
|
for _, p := range rows {
|
|
ids = append(ids, p.UserID)
|
|
}
|
|
names, err := a.userNames(ids)
|
|
if err != 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["user_name"] = names[p.UserID]
|
|
v["archived"] = p.Archived
|
|
items = append(items, v)
|
|
}
|
|
page.Items = items
|
|
ok(c, page)
|
|
}
|
|
func (a *API) adminWalks(c *gin.Context) {
|
|
query := search(a.db.Model(&model.Walk{}), c, "id::text", "user_id::text")
|
|
query, valid := filterStatus(c, query, "status", "active", "paused", "finished")
|
|
if !valid {
|
|
return
|
|
}
|
|
rows, page, valid := adminRows[model.Walk](c, query, "started_at DESC, id DESC")
|
|
if !valid {
|
|
return
|
|
}
|
|
ids := []string{}
|
|
walkIDs := []string{}
|
|
for _, w := range rows {
|
|
ids = append(ids, w.UserID)
|
|
walkIDs = append(walkIDs, w.ID)
|
|
}
|
|
names, err := a.userNames(ids)
|
|
if err != nil {
|
|
fail(c, 500, "读取用户失败")
|
|
return
|
|
}
|
|
var links []struct {
|
|
WalkID string
|
|
PetName string
|
|
}
|
|
if len(walkIDs) > 0 && a.db.Model(&model.WalkPet{}).Select("sundynix_walk_pet.walk_id, p.name AS pet_name").Joins("JOIN sundynix_pet p ON p.id = sundynix_walk_pet.pet_id").Where("walk_id IN ?", walkIDs).Order("p.created_at ASC").Scan(&links).Error != nil {
|
|
fail(c, 500, "读取同行宠物失败")
|
|
return
|
|
}
|
|
pets := map[string][]string{}
|
|
for _, link := range links {
|
|
pets[link.WalkID] = append(pets[link.WalkID], link.PetName)
|
|
}
|
|
items := make([]gin.H, 0, len(rows))
|
|
for _, w := range rows {
|
|
items = append(items, gin.H{"id": w.ID, "user_id": w.UserID, "user_name": names[w.UserID], "pet_names": pets[w.ID], "status": w.Status, "started_at": w.StartedAt, "ended_at": w.EndedAt, "duration_seconds": w.DurationSeconds, "distance_meters": w.DistanceMeters, "share_enabled": w.ShareEnabled})
|
|
}
|
|
page.Items = items
|
|
ok(c, page)
|
|
}
|
|
func (a *API) adminWalkDetail(c *gin.Context) {
|
|
if !validID(c.Param("id")) {
|
|
fail(c, 400, "记录 ID 无效")
|
|
return
|
|
}
|
|
var walk model.Walk
|
|
if err := a.db.First(&walk, "id = ?", c.Param("id")).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
fail(c, 404, "遛狗记录不存在")
|
|
} else {
|
|
fail(c, 500, "读取记录失败")
|
|
}
|
|
return
|
|
}
|
|
var points []model.WalkPoint
|
|
if a.db.Where("walk_id = ?", walk.ID).Order("recorded_at ASC, id ASC").Find(&points).Error != nil {
|
|
fail(c, 500, "读取足迹失败")
|
|
return
|
|
}
|
|
ok(c, gin.H{"id": walk.ID, "points": points})
|
|
}
|
|
func (a *API) adminMeetups(c *gin.Context) {
|
|
query := search(a.db.Model(&model.Meetup{}), c, "title", "place_name", "id::text")
|
|
query, valid := filterStatus(c, query, "status", "open", "cancelled")
|
|
if !valid {
|
|
return
|
|
}
|
|
rows, page, valid := adminRows[model.Meetup](c, query, "created_at DESC, id DESC")
|
|
if !valid {
|
|
return
|
|
}
|
|
ids := []string{}
|
|
for _, m := range rows {
|
|
ids = append(ids, m.HostUserID)
|
|
}
|
|
names, err := a.userNames(ids)
|
|
if err != nil {
|
|
fail(c, 500, "读取发起人失败")
|
|
return
|
|
}
|
|
items := make([]gin.H, 0, len(rows))
|
|
for _, m := range rows {
|
|
v := a.meetupView(m)
|
|
v["user_name"] = names[m.HostUserID]
|
|
items = append(items, v)
|
|
}
|
|
page.Items = items
|
|
ok(c, page)
|
|
}
|
|
func (a *API) adminTasks(c *gin.Context) {
|
|
query := search(a.db.Model(&model.CareTask{}), c, "title", "id::text", "user_id::text", "pet_id::text")
|
|
query, valid := filterStatus(c, query, "category", "food", "water", "walk", "medicine", "grooming", "other")
|
|
if !valid {
|
|
return
|
|
}
|
|
rows, page, valid := adminRows[model.CareTask](c, query, "created_at DESC, id DESC")
|
|
if !valid {
|
|
return
|
|
}
|
|
ids, petIDs, taskIDs := []string{}, []string{}, []string{}
|
|
for _, t := range rows {
|
|
ids = append(ids, t.UserID)
|
|
if t.PetID != nil {
|
|
petIDs = append(petIDs, *t.PetID)
|
|
}
|
|
taskIDs = append(taskIDs, t.ID)
|
|
}
|
|
names, err := a.userNames(ids)
|
|
if err != nil {
|
|
fail(c, 500, "读取用户失败")
|
|
return
|
|
}
|
|
var pets []model.Pet
|
|
if len(petIDs) > 0 && a.db.Select("id", "name").Where("id IN ?", petIDs).Find(&pets).Error != nil {
|
|
fail(c, 500, "读取宠物失败")
|
|
return
|
|
}
|
|
petNames := map[string]string{}
|
|
for _, p := range pets {
|
|
petNames[p.ID] = p.Name
|
|
}
|
|
var completions []model.TaskCompletion
|
|
if len(taskIDs) > 0 && a.db.Where("task_id IN ? AND date = ?", taskIDs, time.Now().Format("2006-01-02")).Find(&completions).Error != nil {
|
|
fail(c, 500, "读取任务完成情况失败")
|
|
return
|
|
}
|
|
done := map[string]bool{}
|
|
for _, completion := range completions {
|
|
done[completion.TaskID] = true
|
|
}
|
|
items := make([]gin.H, 0, len(rows))
|
|
for _, t := range rows {
|
|
petName := "全部宠物"
|
|
if t.PetID != nil {
|
|
petName = petNames[*t.PetID]
|
|
}
|
|
items = append(items, gin.H{"id": t.ID, "user_id": t.UserID, "user_name": names[t.UserID], "pet_id": t.PetID, "all_pets": t.PetID == nil, "pet_name": petName, "title": t.Title, "category": t.Category, "time_of_day": t.TimeOfDay, "repeat_rule": t.RepeatRule, "reminder_enabled": t.ReminderEnabled, "completed": done[t.ID], "created_at": t.CreatedAt})
|
|
}
|
|
page.Items = items
|
|
ok(c, page)
|
|
}
|
|
func (a *API) adminFiles(c *gin.Context) {
|
|
query := search(a.db.Model(&model.File{}), c, "name", "md5", "id::text")
|
|
rows, page, valid := adminRows[model.File](c, query, "created_at DESC, id DESC")
|
|
if !valid {
|
|
return
|
|
}
|
|
items := make([]gin.H, 0, len(rows))
|
|
for _, file := range rows {
|
|
v := a.fileView(file)
|
|
v["created_at"] = file.CreatedAt
|
|
v["bucket"] = file.Bucket
|
|
v["object_key"] = file.ObjectKey
|
|
items = append(items, v)
|
|
}
|
|
page.Items = items
|
|
ok(c, page)
|
|
}
|
|
func (a *API) adminSetUserDisabled(c *gin.Context) {
|
|
var in struct {
|
|
Disabled *bool `json:"disabled"`
|
|
}
|
|
if !validID(c.Param("id")) || c.ShouldBindJSON(&in) != nil || in.Disabled == nil {
|
|
fail(c, 400, "参数无效")
|
|
return
|
|
}
|
|
err := a.db.Transaction(func(tx *gorm.DB) error {
|
|
result := tx.Model(&model.User{}).Where("id = ?", c.Param("id")).Update("disabled", *in.Disabled)
|
|
if result.Error != nil {
|
|
return result.Error
|
|
}
|
|
if result.RowsAffected == 0 {
|
|
return gorm.ErrRecordNotFound
|
|
}
|
|
// Disabling an account also revokes any publicly shared locations.
|
|
if *in.Disabled {
|
|
return tx.Model(&model.Walk{}).Where("user_id = ?", c.Param("id")).Updates(map[string]any{"share_enabled": false, "share_expires_at": nil}).Error
|
|
}
|
|
return nil
|
|
})
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
fail(c, 404, "用户不存在")
|
|
return
|
|
}
|
|
if err != nil {
|
|
fail(c, 500, "更新用户失败")
|
|
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 != "cancelled" {
|
|
fail(c, 400, "状态无效")
|
|
return
|
|
}
|
|
result := a.db.Model(&model.Meetup{}).Where("id = ? AND status = ?", c.Param("id"), "open").Update("status", in.Status)
|
|
if result.Error != nil {
|
|
fail(c, 500, "更新约遛失败")
|
|
return
|
|
}
|
|
if result.RowsAffected == 0 {
|
|
fail(c, 409, "约遛不存在或已取消")
|
|
return
|
|
}
|
|
ok(c, gin.H{"status": in.Status})
|
|
}
|