Complete admin dashboard with paginated user pet trees and walk relations
This commit is contained in:
@@ -1,98 +1,462 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sundynix/pets-be/internal/model"
|
||||
"net/http"
|
||||
"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{}}} {
|
||||
}{{"users", &model.User{}}, {"pets", &model.Pet{}}, {"walks", &model.Walk{}}, {"meetups", &model.Meetup{}}, {"tasks", &model.CareTask{}}, {"files", &model.File{}}} {
|
||||
var n int64
|
||||
a.db.Model(pair.value).Count(&n)
|
||||
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) {
|
||||
var rows []model.User
|
||||
if a.db.Order("created_at DESC").Limit(200).Find(&rows).Error != nil {
|
||||
fail(c, 500, "读取用户失败")
|
||||
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 {
|
||||
v := a.userView(u)
|
||||
v["disabled"] = u.Disabled
|
||||
items = append(items, v)
|
||||
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) {
|
||||
var rows []model.Pet
|
||||
if a.db.Order("created_at DESC").Limit(200).Find(&rows).Error != nil {
|
||||
fail(c, 500, "读取宠物失败")
|
||||
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)
|
||||
}
|
||||
ok(c, items)
|
||||
page.Items = items
|
||||
ok(c, page)
|
||||
}
|
||||
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, "读取遛狗失败")
|
||||
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
|
||||
}
|
||||
ok(c, rows)
|
||||
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) {
|
||||
var rows []model.Meetup
|
||||
if a.db.Order("created_at DESC").Limit(200).Find(&rows).Error != nil {
|
||||
fail(c, 500, "读取约遛失败")
|
||||
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 {
|
||||
items = append(items, a.meetupView(m))
|
||||
v := a.meetupView(m)
|
||||
v["user_name"] = names[m.HostUserID]
|
||||
items = append(items, v)
|
||||
}
|
||||
ok(c, items)
|
||||
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)
|
||||
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 {
|
||||
items = append(items, gin.H{"id": t.ID, "user_id": t.UserID, "user_name": names[t.UserID], "pet_id": t.PetID, "pet_name": petNames[t.PetID], "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"`
|
||||
Disabled *bool `json:"disabled"`
|
||||
}
|
||||
if !validID(c.Param("id")) || c.ShouldBindJSON(&in) != nil {
|
||||
if !validID(c.Param("id")) || c.ShouldBindJSON(&in) != nil || in.Disabled == 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 {
|
||||
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
|
||||
}
|
||||
ok(c, gin.H{"disabled": in.Disabled})
|
||||
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 != "open" && in.Status != "cancelled") {
|
||||
fail(c, http.StatusBadRequest, "状态无效")
|
||||
if !validID(c.Param("id")) || c.ShouldBindJSON(&in) != nil || in.Status != "cancelled" {
|
||||
fail(c, 400, "状态无效")
|
||||
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, "约遛不存在")
|
||||
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})
|
||||
|
||||
Reference in New Issue
Block a user