Complete admin dashboard with paginated user pet trees and walk relations
ci-cd / verify (push) Successful in 1m40s
ci-cd / deploy (push) Skipped

This commit is contained in:
2026-10-04 11:45:15 +08:00
parent 3655a6a89e
commit 6c37785ce1
26 changed files with 3155 additions and 601 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

-24
View File
@@ -1,24 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

Before

Width:  |  Height:  |  Size: 4.9 KiB

+4 -4
View File
@@ -2,11 +2,11 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" href="/brand-logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>肉垫计划 · 管理端</title>
<script type="module" crossorigin src="/assets/index-COwxdvZQ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BQA5NViF.css">
<title>肉垫计划 · 管理后台</title>
<script type="module" crossorigin src="/assets/index-DkKuSusU.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B39ofd9P.css">
</head>
<body>
<div id="root"></div>
+1 -1
View File
@@ -39,7 +39,7 @@ func TestAdminAtRootAndAPIRoutes(t *testing.T) {
}{
{path: "/", status: http.StatusOK, body: "<div id=\"root\"></div>"},
{path: script, status: http.StatusOK},
{path: "/favicon.svg", status: http.StatusOK},
{path: "/brand-logo.png", status: http.StatusOK},
{path: "/api/ping", status: http.StatusOK, body: "pong"},
{path: "/api/missing", status: http.StatusNotFound},
{path: "/assets/missing.js", status: http.StatusNotFound},
+396 -32
View File
@@ -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})
+231
View File
@@ -0,0 +1,231 @@
package httpapi
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/sundynix/pets-be/internal/config"
"github.com/sundynix/pets-be/internal/database"
"github.com/sundynix/pets-be/internal/model"
)
func TestAdminPageValidation(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, query := range []string{"", "?page=2&page_size=10", "?page=0", "?page_size=101", "?page=abc", "?page=-1", "?page_size=0"} {
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Request = httptest.NewRequest(http.MethodGet, "/api/admin/users"+query, nil)
page, valid := pageParams(context)
expected := query == "" || query == "?page=2&page_size=10"
if valid != expected {
t.Errorf("%s: valid=%v", query, valid)
}
if query == "" && (page.Page != 1 || page.PageSize != 10) {
t.Fatalf("default pagination: %+v", page)
}
if !valid && recorder.Code != 400 {
t.Errorf("%s: status=%d", query, recorder.Code)
}
}
}
func TestAdminListsRequireAdminRole(t *testing.T) {
gin.SetMode(gin.TestMode)
cfg := &config.Config{}
cfg.JWT.Secret = "test-secret"
api := New(nil, nil, cfg)
router := gin.New()
api.Register(router)
token, err := api.token(uuid.NewString(), "user")
if err != nil {
t.Fatal(err)
}
for _, route := range []string{"users", "users/" + uuid.NewString() + "/pets", "pets", "walks", "walks/" + uuid.NewString(), "meetups", "tasks", "files", "stats"} {
for _, auth := range []bool{false, true} {
request := httptest.NewRequest(http.MethodGet, "/api/admin/"+route, nil)
if auth {
request.Header.Set("Authorization", "Bearer "+token)
}
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
expected := http.StatusUnauthorized
if auth {
expected = http.StatusForbidden
}
if recorder.Code != expected {
t.Errorf("%s auth=%v: status=%d", route, auth, recorder.Code)
}
}
}
}
// Opt-in read-only verification against the configured PostgreSQL; never seeds or changes data.
func TestAdminPostgresReadOnly(t *testing.T) {
if os.Getenv("PETS_ADMIN_INTEGRATION") != "1" {
t.Skip("set PETS_ADMIN_INTEGRATION=1 for PostgreSQL verification")
}
cfg, err := config.Load()
if err != nil {
t.Fatal(err)
}
db, err := database.Open(cfg.Postgres, true)
if err != nil {
t.Fatal(err)
}
sqlDB, _ := db.DB()
defer sqlDB.Close()
gin.SetMode(gin.TestMode)
api := New(db, nil, cfg)
router := gin.New()
api.Register(router)
token, err := api.token(uuid.NewString(), "admin")
if err != nil {
t.Fatal(err)
}
read := func(path string) json.RawMessage {
t.Helper()
request := httptest.NewRequest(http.MethodGet, path, nil)
request.Header.Set("Authorization", "Bearer "+token)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != 200 {
t.Fatalf("GET %s: %d %s", path, recorder.Code, recorder.Body.String())
}
var body struct {
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
return body.Data
}
read("/api/admin/stats")
for _, section := range []string{"users", "pets", "walks", "meetups", "tasks", "files"} {
var page struct {
Items []struct {
ID string `json:"id"`
UserID string `json:"user_id"`
PetNames []string `json:"pet_names"`
} `json:"items"`
Total int64 `json:"total"`
PageSize int `json:"page_size"`
Page int `json:"page"`
}
if err := json.Unmarshal(read("/api/admin/"+section+"?page=1&page_size=10"), &page); err != nil {
t.Fatal(err)
}
if page.Page != 1 || page.PageSize != 10 || len(page.Items) > 10 || page.Total < int64(len(page.Items)) {
t.Fatalf("invalid %s pagination: %+v", section, page)
}
read("/api/admin/" + section + "?page=2&page_size=10")
read("/api/admin/" + section + "?q=%25%27%5F")
if len(page.Items) > 0 && section == "users" {
read("/api/admin/users/" + page.Items[0].ID + "/pets")
}
if len(page.Items) > 0 && section == "walks" {
read("/api/admin/walks/" + page.Items[0].ID)
}
}
}
// Fixtures live inside a transaction and are always rolled back.
func TestAdminPaginationAndWalkRelations(t *testing.T) {
gin.SetMode(gin.TestMode)
if os.Getenv("PETS_ADMIN_INTEGRATION") != "1" {
t.Skip("PostgreSQL integration disabled")
}
cfg, err := config.Load()
if err != nil {
t.Fatal(err)
}
db, err := database.Open(cfg.Postgres, true)
if err != nil {
t.Fatal(err)
}
sqlDB, _ := db.DB()
defer sqlDB.Close()
tx := db.Begin()
if tx.Error != nil {
t.Fatal(tx.Error)
}
defer tx.Rollback()
prefix := "admin-test-" + uuid.NewString()
users := make([]model.User, 11)
for i := range users {
users[i] = model.User{Nickname: prefix, OpenID: uuid.NewString()}
}
if tx.Create(&users).Error != nil {
t.Fatal("create temporary users")
}
pets := []model.Pet{{UserID: users[0].ID, Name: "奶盖", Species: "dog"}, {UserID: users[0].ID, Name: "布丁", Species: "dog"}}
if tx.Create(&pets).Error != nil {
t.Fatal("create temporary pets")
}
now := time.Now()
walk := model.Walk{UserID: users[0].ID, Status: "finished", StartedAt: now, SegmentStartedAt: now, ShareToken: uuid.NewString()}
if err := tx.Create(&walk).Error; err != nil {
t.Fatal(err)
}
for _, pet := range pets {
if err := tx.Create(&model.WalkPet{WalkID: walk.ID, PetID: pet.ID}).Error; err != nil {
t.Fatal(err)
}
}
api := New(tx, nil, cfg)
router := gin.New()
api.Register(router)
token, _ := api.token(uuid.NewString(), "admin")
read := func(path string) json.RawMessage {
t.Helper()
request := httptest.NewRequest(http.MethodGet, path, nil)
request.Header.Set("Authorization", "Bearer "+token)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != 200 {
t.Fatalf("%s: %d %s", path, recorder.Code, recorder.Body.String())
}
var body struct {
Data json.RawMessage `json:"data"`
}
json.Unmarshal(recorder.Body.Bytes(), &body)
return body.Data
}
for _, sample := range []struct {
page string
size int
}{{"1", 10}, {"2", 1}} {
var result struct {
Items []json.RawMessage `json:"items"`
Total int64 `json:"total"`
}
if err := json.Unmarshal(read("/api/admin/users?q="+prefix+"&page="+sample.page), &result); err != nil {
t.Fatal(err)
}
if result.Total != 11 || len(result.Items) != sample.size {
t.Fatalf("pagination got %d rows / %d total", len(result.Items), result.Total)
}
}
var children []struct {
Name string `json:"name"`
}
json.Unmarshal(read("/api/admin/users/"+users[0].ID+"/pets"), &children)
if len(children) != 2 {
t.Fatalf("user tree lost pets: %+v", children)
}
var result struct {
Items []struct {
UserID string `json:"user_id"`
UserName string `json:"user_name"`
PetNames []string `json:"pet_names"`
} `json:"items"`
}
json.Unmarshal(read("/api/admin/walks?q="+walk.ID), &result)
if len(result.Items) != 1 || result.Items[0].UserID != users[0].ID || result.Items[0].UserName != prefix || len(result.Items[0].PetNames) != 2 {
t.Fatalf("walk relation incomplete: %+v", result)
}
}
+4
View File
@@ -73,9 +73,13 @@ func (a *API) Register(r *gin.Engine) {
admin.Use(a.requireRole("admin"))
admin.GET("/stats", a.adminStats)
admin.GET("/users", a.adminUsers)
admin.GET("/users/:id/pets", a.adminUserPets)
admin.GET("/pets", a.adminPets)
admin.GET("/walks", a.adminWalks)
admin.GET("/walks/:id", a.adminWalkDetail)
admin.GET("/meetups", a.adminMeetups)
admin.GET("/tasks", a.adminTasks)
admin.GET("/files", a.adminFiles)
admin.PUT("/users/:id/disabled", a.adminSetUserDisabled)
admin.PUT("/meetups/:id/status", a.adminSetMeetupStatus)
}