Complete admin dashboard with paginated user pet trees and walk relations
This commit is contained in:
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
BIN
Binary file not shown.
|
After Width: | Height: | Size: 88 KiB |
-1
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 9.3 KiB |
-24
@@ -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
@@ -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>
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user