宠物板块第一期:宠物档案与后台审核
方案第一期的后端部分。宠物档案是整个板块的根——代喂任务、送养帖、 配种信息都会挂在它上面,所以免疫、绝育、体重这些事实只在这里存一份, 不在各业务表里重复填写与互相矛盾。 - pets / pet_media 两张表(迁移 036),另加三个可在管理端调整的配置: 总开关、单账号档案上限、单只宠物照片上限。 - 客户端接口:档案增删改查。日期在校验阶段解析,格式或"未来的免疫日期" 这类问题返回 400 并说明是哪个字段,而不是从写库深处抛出 500。 - 软删除:档案从列表消失但记录保留,将来代喂订单与送养记录仍能追溯。 - 后台审核:列表带主人昵称与星遇号;驳回必须填原因;被驳回的档案对外 不可见,但主人仍能看到并读到原因。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ce429afcf2
commit
aee1881e69
@@ -247,6 +247,11 @@ func (a *App) userRoutes() []rest.Route {
|
||||
{Method: http.MethodGet, Path: "/api/v1/feed", Handler: auth(a.feed)},
|
||||
{Method: http.MethodGet, Path: "/api/v1/posts/:id", Handler: auth(a.postDetail)},
|
||||
{Method: http.MethodGet, Path: "/api/v1/users/:id/posts", Handler: auth(a.userPosts)},
|
||||
{Method: http.MethodGet, Path: "/api/v1/pets", Handler: auth(a.myPets)},
|
||||
{Method: http.MethodPost, Path: "/api/v1/pets", Handler: auth(a.createPet)},
|
||||
{Method: http.MethodGet, Path: "/api/v1/pets/:id", Handler: auth(a.petDetail)},
|
||||
{Method: http.MethodPut, Path: "/api/v1/pets/:id", Handler: auth(a.updatePet)},
|
||||
{Method: http.MethodDelete, Path: "/api/v1/pets/:id", Handler: auth(a.deletePet)},
|
||||
{Method: http.MethodPost, Path: "/api/v1/posts", Handler: auth(a.createPost)},
|
||||
{Method: http.MethodPatch, Path: "/api/v1/posts/:id", Handler: auth(a.updatePost)},
|
||||
{Method: http.MethodDelete, Path: "/api/v1/posts/:id", Handler: auth(a.deleteOwnPost)},
|
||||
@@ -323,6 +328,8 @@ func (a *App) adminRoutes() []rest.Route {
|
||||
{Method: http.MethodPost, Path: "/admin/v1/orders/:id/refund", Handler: permit("orders:manage", a.adminRefund)},
|
||||
{Method: http.MethodGet, Path: "/admin/v1/messages", Handler: permit("messages:view", a.adminMessages)},
|
||||
{Method: http.MethodPost, Path: "/admin/v1/messages/:id/moderate", Handler: permit("messages:manage", a.adminModerateMessage)},
|
||||
{Method: http.MethodGet, Path: "/admin/v1/pets", Handler: permit("users:view", a.adminPets)},
|
||||
{Method: http.MethodPost, Path: "/admin/v1/pets/:id/moderate", Handler: permit("users:manage", a.adminModeratePet)},
|
||||
{Method: http.MethodGet, Path: "/admin/v1/chat-media", Handler: permit("messages:view", a.adminChatMedia)},
|
||||
{Method: http.MethodPut, Path: "/admin/v1/chat-media/retention", Handler: permit("messages:manage", a.adminUpdateChatMediaRetention)},
|
||||
{Method: http.MethodPost, Path: "/admin/v1/chat-media/cleanup", Handler: permit("messages:manage", a.adminCleanupDueChatMedia)},
|
||||
|
||||
@@ -0,0 +1,467 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The pet profile is the shared root of the whole module: a feeding task, an
|
||||
// adoption listing and a mating listing all point at one of these. Facts about
|
||||
// the animal — vaccination, neutering, weight — therefore live here once,
|
||||
// instead of being retyped (and contradicted) in each of those places.
|
||||
|
||||
const (
|
||||
petMaxPhotosDefault = 9
|
||||
petMaxPerUser = 10
|
||||
petMinBirthYears = 40
|
||||
)
|
||||
|
||||
var petSpecies = map[string]string{"cat": "猫", "dog": "狗", "other": "其他"}
|
||||
|
||||
type petView struct {
|
||||
ID int64 `json:"id"`
|
||||
OwnerUserID int64 `json:"ownerUserId"`
|
||||
Name string `json:"name"`
|
||||
Species string `json:"species"`
|
||||
SpeciesLabel string `json:"speciesLabel"`
|
||||
Breed string `json:"breed"`
|
||||
Gender int `json:"gender"`
|
||||
Birthday string `json:"birthday,omitempty"`
|
||||
AgeMonths int `json:"ageMonths"`
|
||||
Neutered bool `json:"neutered"`
|
||||
WeightG int `json:"weightG"`
|
||||
ChipNo string `json:"chipNo,omitempty"`
|
||||
VaccinatedAt string `json:"vaccinatedAt,omitempty"`
|
||||
DewormedAt string `json:"dewormedAt,omitempty"`
|
||||
Temperament string `json:"temperament"`
|
||||
Avatar string `json:"avatar"`
|
||||
Photos []string `json:"photos"`
|
||||
Moderation int `json:"moderationStatus"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
type petRequest struct {
|
||||
Name string `json:"name"`
|
||||
Species string `json:"species"`
|
||||
Breed string `json:"breed"`
|
||||
Gender int `json:"gender"`
|
||||
Birthday string `json:"birthday"`
|
||||
Neutered bool `json:"neutered"`
|
||||
WeightG int `json:"weightG"`
|
||||
ChipNo string `json:"chipNo"`
|
||||
VaccinatedAt string `json:"vaccinatedAt"`
|
||||
DewormedAt string `json:"dewormedAt"`
|
||||
Temperament string `json:"temperament"`
|
||||
Avatar string `json:"avatar"`
|
||||
Photos []string `json:"photos"`
|
||||
|
||||
// Parsed during validation so a bad date is a 400 about that date, not a
|
||||
// 500 from deep inside the write.
|
||||
birthday sql.NullTime
|
||||
vaccinated sql.NullTime
|
||||
dewormed sql.NullTime
|
||||
}
|
||||
|
||||
// petDate accepts an empty string as "not recorded". A date the owner cannot
|
||||
// know yet — a vaccination in the future — is a typo, not a fact.
|
||||
func petDate(value, label string, allowFuture bool) (sql.NullTime, error) {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return sql.NullTime{}, nil
|
||||
}
|
||||
parsed, err := time.Parse("2006-01-02", trimmed)
|
||||
if err != nil {
|
||||
return sql.NullTime{}, fmt.Errorf("%s格式应为 2006-01-02", label)
|
||||
}
|
||||
if !allowFuture && parsed.After(time.Now()) {
|
||||
return sql.NullTime{}, fmt.Errorf("%s不能晚于今天", label)
|
||||
}
|
||||
if parsed.Before(time.Now().AddDate(-petMinBirthYears, 0, 0)) {
|
||||
return sql.NullTime{}, fmt.Errorf("%s过于久远,请检查", label)
|
||||
}
|
||||
return sql.NullTime{Time: parsed, Valid: true}, nil
|
||||
}
|
||||
|
||||
func (a *App) validatePet(ctx context.Context, req *petRequest, ownerID int64) error {
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
req.Breed = strings.TrimSpace(req.Breed)
|
||||
req.ChipNo = strings.TrimSpace(req.ChipNo)
|
||||
req.Temperament = strings.TrimSpace(req.Temperament)
|
||||
req.Species = strings.ToLower(strings.TrimSpace(req.Species))
|
||||
if req.Name == "" || len([]rune(req.Name)) > 30 {
|
||||
return fmt.Errorf("请填写宠物名字,最多 30 个字")
|
||||
}
|
||||
if _, ok := petSpecies[req.Species]; !ok {
|
||||
return fmt.Errorf("请选择宠物类型")
|
||||
}
|
||||
if len([]rune(req.Breed)) > 50 || len([]rune(req.Temperament)) > 200 || len(req.ChipNo) > 40 {
|
||||
return fmt.Errorf("品种、芯片号或性格描述过长")
|
||||
}
|
||||
if req.Gender < 0 || req.Gender > 2 {
|
||||
return fmt.Errorf("性别取值无效")
|
||||
}
|
||||
// 200 kg covers every companion animal; a larger number is a unit mistake.
|
||||
if req.WeightG < 0 || req.WeightG > 200_000 {
|
||||
return fmt.Errorf("体重应在 0 到 200 公斤之间")
|
||||
}
|
||||
limit := a.configInt(ctx, "pet.max_photos", petMaxPhotosDefault)
|
||||
if len(req.Photos) > limit {
|
||||
return fmt.Errorf("最多上传 %d 张照片", limit)
|
||||
}
|
||||
for _, photo := range req.Photos {
|
||||
if !validMessageMediaURL(photo, a.config.Environment == "production") {
|
||||
return fmt.Errorf("照片地址无效")
|
||||
}
|
||||
}
|
||||
if req.Avatar != "" && !validMessageMediaURL(req.Avatar, a.config.Environment == "production") {
|
||||
return fmt.Errorf("头像地址无效")
|
||||
}
|
||||
if req.Avatar == "" && len(req.Photos) > 0 {
|
||||
req.Avatar = req.Photos[0]
|
||||
}
|
||||
var err error
|
||||
if req.birthday, err = petDate(req.Birthday, "出生日期", false); err != nil {
|
||||
return err
|
||||
}
|
||||
if req.vaccinated, err = petDate(req.VaccinatedAt, "免疫日期", false); err != nil {
|
||||
return err
|
||||
}
|
||||
if req.dewormed, err = petDate(req.DewormedAt, "驱虫日期", false); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) scanPets(ctx context.Context, rows *sql.Rows) ([]petView, error) {
|
||||
items := []petView{}
|
||||
ids := []any{}
|
||||
placeholders := []string{}
|
||||
for rows.Next() {
|
||||
var item petView
|
||||
var birthday, vaccinated, dewormed sql.NullTime
|
||||
var created time.Time
|
||||
var neutered int
|
||||
if err := rows.Scan(&item.ID, &item.OwnerUserID, &item.Name, &item.Species, &item.Breed, &item.Gender,
|
||||
&birthday, &neutered, &item.WeightG, &item.ChipNo, &vaccinated, &dewormed, &item.Temperament,
|
||||
&item.Avatar, &item.Moderation, &created); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.SpeciesLabel = petSpecies[item.Species]
|
||||
item.Neutered = neutered == 1
|
||||
if birthday.Valid {
|
||||
item.Birthday = birthday.Time.Format("2006-01-02")
|
||||
item.AgeMonths = monthsSince(birthday.Time)
|
||||
}
|
||||
if vaccinated.Valid {
|
||||
item.VaccinatedAt = vaccinated.Time.Format("2006-01-02")
|
||||
}
|
||||
if dewormed.Valid {
|
||||
item.DewormedAt = dewormed.Time.Format("2006-01-02")
|
||||
}
|
||||
item.CreatedAt = created.Format(time.RFC3339)
|
||||
item.Photos = []string{}
|
||||
items = append(items, item)
|
||||
ids = append(ids, item.ID)
|
||||
placeholders = append(placeholders, "?")
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return items, nil
|
||||
}
|
||||
// One extra query for the whole page rather than one per pet.
|
||||
mediaRows, err := a.db.QueryContext(ctx, `SELECT pet_id,media_url FROM pet_media WHERE pet_id IN (`+strings.Join(placeholders, ",")+`) ORDER BY pet_id,sort_order,id`, ids...)
|
||||
if err != nil {
|
||||
return items, nil
|
||||
}
|
||||
defer mediaRows.Close()
|
||||
index := map[int64]int{}
|
||||
for position := range items {
|
||||
index[items[position].ID] = position
|
||||
}
|
||||
for mediaRows.Next() {
|
||||
var petID int64
|
||||
var url string
|
||||
if mediaRows.Scan(&petID, &url) != nil {
|
||||
continue
|
||||
}
|
||||
if position, ok := index[petID]; ok {
|
||||
items[position].Photos = append(items[position].Photos, url)
|
||||
}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const petColumns = `id,owner_user_id,name,species,breed,gender,birthday,neutered,weight_g,chip_no,vaccinated_at,dewormed_at,temperament,avatar_url,moderation_status,created_at`
|
||||
|
||||
func (a *App) myPets(w http.ResponseWriter, r *http.Request) {
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT `+petColumns+` FROM pets WHERE owner_user_id=? AND status=1 AND deleted_at IS NULL ORDER BY id DESC`, current(r).ID)
|
||||
if err != nil {
|
||||
fail(w, http.StatusInternalServerError, 50001, "查询宠物失败")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
items, err := a.scanPets(r.Context(), rows)
|
||||
if err != nil {
|
||||
fail(w, http.StatusInternalServerError, 50001, "查询宠物失败")
|
||||
return
|
||||
}
|
||||
reply(w, map[string]any{"items": items, "limit": a.configInt(r.Context(), "pet.max_per_user", petMaxPerUser)})
|
||||
}
|
||||
|
||||
func (a *App) petDetail(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
fail(w, http.StatusBadRequest, 20001, "宠物编号无效")
|
||||
return
|
||||
}
|
||||
// A profile that failed moderation stays visible to its owner, so the
|
||||
// rejection reason has somewhere to be read.
|
||||
rows, queryErr := a.db.QueryContext(r.Context(), `SELECT `+petColumns+` FROM pets
|
||||
WHERE id=? AND status=1 AND deleted_at IS NULL AND (owner_user_id=? OR moderation_status=1)`, id, current(r).ID)
|
||||
if queryErr != nil {
|
||||
fail(w, http.StatusInternalServerError, 50001, "查询宠物失败")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
items, scanErr := a.scanPets(r.Context(), rows)
|
||||
if scanErr != nil || len(items) == 0 {
|
||||
fail(w, http.StatusNotFound, 30001, "宠物不存在")
|
||||
return
|
||||
}
|
||||
item := items[0]
|
||||
view := map[string]any{"pet": item}
|
||||
if item.OwnerUserID == current(r).ID {
|
||||
var note string
|
||||
_ = a.db.QueryRowContext(r.Context(), `SELECT moderation_note FROM pets WHERE id=?`, id).Scan(¬e)
|
||||
view["moderationNote"] = note
|
||||
}
|
||||
reply(w, view)
|
||||
}
|
||||
|
||||
func (a *App) createPet(w http.ResponseWriter, r *http.Request) {
|
||||
var req petRequest
|
||||
if decode(r, &req) != nil {
|
||||
fail(w, http.StatusBadRequest, 20001, "宠物档案格式错误")
|
||||
return
|
||||
}
|
||||
if err := a.validatePet(r.Context(), &req, current(r).ID); err != nil {
|
||||
fail(w, http.StatusBadRequest, 20001, err.Error())
|
||||
return
|
||||
}
|
||||
var owned int
|
||||
_ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM pets WHERE owner_user_id=? AND status=1 AND deleted_at IS NULL`, current(r).ID).Scan(&owned)
|
||||
if limit := a.configInt(r.Context(), "pet.max_per_user", petMaxPerUser); limit > 0 && owned >= limit {
|
||||
fail(w, http.StatusBadRequest, 20001, fmt.Sprintf("最多建立 %d 只宠物档案", limit))
|
||||
return
|
||||
}
|
||||
id, err := a.writePet(r, 0, req)
|
||||
if err != nil {
|
||||
fail(w, http.StatusInternalServerError, 50001, err.Error())
|
||||
return
|
||||
}
|
||||
reply(w, map[string]any{"id": id})
|
||||
}
|
||||
|
||||
func (a *App) updatePet(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
fail(w, http.StatusBadRequest, 20001, "宠物编号无效")
|
||||
return
|
||||
}
|
||||
var owner int64
|
||||
if a.db.QueryRowContext(r.Context(), `SELECT owner_user_id FROM pets WHERE id=? AND status=1 AND deleted_at IS NULL`, id).Scan(&owner) != nil || owner != current(r).ID {
|
||||
fail(w, http.StatusNotFound, 30001, "宠物不存在")
|
||||
return
|
||||
}
|
||||
var req petRequest
|
||||
if decode(r, &req) != nil {
|
||||
fail(w, http.StatusBadRequest, 20001, "宠物档案格式错误")
|
||||
return
|
||||
}
|
||||
if err := a.validatePet(r.Context(), &req, current(r).ID); err != nil {
|
||||
fail(w, http.StatusBadRequest, 20001, err.Error())
|
||||
return
|
||||
}
|
||||
if _, err := a.writePet(r, id, req); err != nil {
|
||||
fail(w, http.StatusInternalServerError, 50001, err.Error())
|
||||
return
|
||||
}
|
||||
reply(w, map[string]bool{"success": true})
|
||||
}
|
||||
|
||||
// writePet creates or replaces one profile together with its photos, so a
|
||||
// half-written record — a pet whose photos belong to the previous edit — cannot
|
||||
// exist even if the request dies midway.
|
||||
func (a *App) writePet(r *http.Request, id int64, req petRequest) (int64, error) {
|
||||
birthday, vaccinated, dewormed := req.birthday, req.vaccinated, req.dewormed
|
||||
tx, err := a.db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("保存宠物档案失败")
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
neutered := 0
|
||||
if req.Neutered {
|
||||
neutered = 1
|
||||
}
|
||||
if id == 0 {
|
||||
result, execErr := tx.ExecContext(r.Context(), `INSERT INTO pets
|
||||
(owner_user_id,name,species,breed,gender,birthday,neutered,weight_g,chip_no,vaccinated_at,dewormed_at,temperament,avatar_url)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
current(r).ID, req.Name, req.Species, req.Breed, req.Gender, birthday, neutered, req.WeightG,
|
||||
req.ChipNo, vaccinated, dewormed, req.Temperament, req.Avatar)
|
||||
if execErr != nil {
|
||||
return 0, fmt.Errorf("保存宠物档案失败")
|
||||
}
|
||||
if id, err = result.LastInsertId(); err != nil {
|
||||
return 0, fmt.Errorf("保存宠物档案失败")
|
||||
}
|
||||
} else if _, execErr := tx.ExecContext(r.Context(), `UPDATE pets SET name=?,species=?,breed=?,gender=?,birthday=?,neutered=?,weight_g=?,chip_no=?,vaccinated_at=?,dewormed_at=?,temperament=?,avatar_url=? WHERE id=?`,
|
||||
req.Name, req.Species, req.Breed, req.Gender, birthday, neutered, req.WeightG,
|
||||
req.ChipNo, vaccinated, dewormed, req.Temperament, req.Avatar, id); execErr != nil {
|
||||
return 0, fmt.Errorf("保存宠物档案失败")
|
||||
}
|
||||
if _, err = tx.ExecContext(r.Context(), `DELETE FROM pet_media WHERE pet_id=?`, id); err != nil {
|
||||
return 0, fmt.Errorf("保存宠物照片失败")
|
||||
}
|
||||
for order, photo := range req.Photos {
|
||||
if _, err = tx.ExecContext(r.Context(), `INSERT INTO pet_media(pet_id,media_url,sort_order) VALUES(?,?,?)`, id, strings.TrimSpace(photo), order); err != nil {
|
||||
return 0, fmt.Errorf("保存宠物照片失败")
|
||||
}
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return 0, fmt.Errorf("保存宠物档案失败")
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (a *App) deletePet(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
fail(w, http.StatusBadRequest, 20001, "宠物编号无效")
|
||||
return
|
||||
}
|
||||
result, err := a.db.ExecContext(r.Context(), `UPDATE pets SET status=0,deleted_at=NOW(3) WHERE id=? AND owner_user_id=? AND status=1`, id, current(r).ID)
|
||||
if err != nil {
|
||||
fail(w, http.StatusInternalServerError, 50001, "删除失败")
|
||||
return
|
||||
}
|
||||
if affected, _ := result.RowsAffected(); affected == 0 {
|
||||
fail(w, http.StatusNotFound, 30001, "宠物不存在")
|
||||
return
|
||||
}
|
||||
reply(w, map[string]bool{"success": true})
|
||||
}
|
||||
|
||||
func monthsSince(from time.Time) int {
|
||||
now := time.Now()
|
||||
months := (now.Year()-from.Year())*12 + int(now.Month()) - int(from.Month())
|
||||
if now.Day() < from.Day() {
|
||||
months--
|
||||
}
|
||||
if months < 0 {
|
||||
return 0
|
||||
}
|
||||
return months
|
||||
}
|
||||
|
||||
// --- 管理端 ---------------------------------------------------------------
|
||||
|
||||
func (a *App) adminPets(w http.ResponseWriter, r *http.Request) {
|
||||
page, size, offset := pagination(r)
|
||||
where := " WHERE p.status=1 AND p.deleted_at IS NULL"
|
||||
args := []any{}
|
||||
if status := strings.TrimSpace(r.URL.Query().Get("moderation")); status != "" {
|
||||
where += " AND p.moderation_status=?"
|
||||
args = append(args, status)
|
||||
}
|
||||
if keyword := strings.TrimSpace(r.URL.Query().Get("keyword")); keyword != "" {
|
||||
if len([]rune(keyword)) > 50 {
|
||||
fail(w, http.StatusBadRequest, 20001, "搜索关键词最多 50 字")
|
||||
return
|
||||
}
|
||||
where += " AND (p.name LIKE ? OR profile.nickname LIKE ? OR u.public_id LIKE ?)"
|
||||
like := "%" + keyword + "%"
|
||||
args = append(args, like, like, like)
|
||||
}
|
||||
from := ` FROM pets p JOIN users u ON u.id=p.owner_user_id JOIN user_profiles profile ON profile.user_id=p.owner_user_id` + where
|
||||
var total int
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*)`+from, args...).Scan(&total); err != nil {
|
||||
fail(w, http.StatusInternalServerError, 50001, "查询宠物失败")
|
||||
return
|
||||
}
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT p.id,p.owner_user_id,profile.nickname,u.public_id,p.name,p.species,p.breed,p.gender,
|
||||
p.neutered,p.vaccinated_at,p.avatar_url,p.moderation_status,p.moderation_note,p.created_at`+from+` ORDER BY p.id DESC LIMIT ? OFFSET ?`,
|
||||
append(append([]any{}, args...), size, offset)...)
|
||||
if err != nil {
|
||||
fail(w, http.StatusInternalServerError, 50001, "查询宠物失败")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []map[string]any{}
|
||||
for rows.Next() {
|
||||
var id, ownerID int64
|
||||
var nickname, publicID, name, species, breed, avatar, note string
|
||||
var gender, neutered, moderation int
|
||||
var vaccinated sql.NullTime
|
||||
var created time.Time
|
||||
if rows.Scan(&id, &ownerID, &nickname, &publicID, &name, &species, &breed, &gender, &neutered,
|
||||
&vaccinated, &avatar, &moderation, ¬e, &created) != nil {
|
||||
continue
|
||||
}
|
||||
vaccinatedAt := ""
|
||||
if vaccinated.Valid {
|
||||
vaccinatedAt = vaccinated.Time.Format("2006-01-02")
|
||||
}
|
||||
items = append(items, map[string]any{
|
||||
"id": id, "ownerUserId": ownerID, "ownerNickname": nickname, "ownerPublicId": publicID,
|
||||
"name": name, "species": species, "speciesLabel": petSpecies[species], "breed": breed,
|
||||
"gender": gender, "neutered": neutered == 1, "vaccinatedAt": vaccinatedAt, "avatar": avatar,
|
||||
"moderationStatus": moderation, "moderationNote": note, "createdAt": created.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
reply(w, map[string]any{"items": items, "total": total, "page": page, "size": size})
|
||||
}
|
||||
|
||||
func (a *App) adminModeratePet(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
fail(w, http.StatusBadRequest, 20001, "宠物编号无效")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Status int `json:"status"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
if decode(r, &req) != nil || req.Status < 0 || req.Status > 2 {
|
||||
fail(w, http.StatusBadRequest, 20001, "审核状态无效")
|
||||
return
|
||||
}
|
||||
// A rejection the owner cannot understand is a support ticket waiting to
|
||||
// happen, so the reason is required when saying no.
|
||||
req.Note = strings.TrimSpace(req.Note)
|
||||
if req.Status == 2 && req.Note == "" {
|
||||
fail(w, http.StatusBadRequest, 20001, "驳回时必须填写原因")
|
||||
return
|
||||
}
|
||||
if len([]rune(req.Note)) > 100 {
|
||||
fail(w, http.StatusBadRequest, 20001, "审核备注最多 100 字")
|
||||
return
|
||||
}
|
||||
result, err := a.db.ExecContext(r.Context(), `UPDATE pets SET moderation_status=?,moderation_note=? WHERE id=? AND status=1 AND deleted_at IS NULL`, req.Status, req.Note, id)
|
||||
if err != nil {
|
||||
fail(w, http.StatusInternalServerError, 50001, "保存审核结果失败")
|
||||
return
|
||||
}
|
||||
if affected, _ := result.RowsAffected(); affected == 0 {
|
||||
fail(w, http.StatusNotFound, 30001, "宠物不存在")
|
||||
return
|
||||
}
|
||||
a.audit(r, "moderate", "pet", id, map[string]any{"status": req.Status, "note": req.Note})
|
||||
reply(w, map[string]bool{"success": true})
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The profile is the root every other pet feature hangs off, so what it accepts
|
||||
// decides what a feeding task or an adoption listing can rely on later.
|
||||
func TestPetProfileMySQL(t *testing.T) {
|
||||
db := isolatedIMDatabase(t)
|
||||
a := &App{db: db, hub: NewHub(), config: Config{Environment: "development", JWTSecret: "isolated-im-regression-secret-only"}}
|
||||
create := func(user int64, body string) (int, string) {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
a.createPet(w, imTestRequest(user, "POST", "/api/v1/pets", body))
|
||||
return w.Code, w.Body.String()
|
||||
}
|
||||
list := func(user int64) []petView {
|
||||
t.Helper()
|
||||
var payload struct {
|
||||
Items []petView `json:"items"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
if err := json.Unmarshal(imTestCall(t, a.myPets, user, "GET", "/api/v1/pets", "", 200), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return payload.Items
|
||||
}
|
||||
valid := `{"name":"团子","species":"cat","breed":"英短","gender":2,"birthday":"2023-05-01",
|
||||
"neutered":true,"weightG":4200,"vaccinatedAt":"2026-03-10","dewormedAt":"2026-06-01",
|
||||
"temperament":"怕生,喜欢躲床底","photos":["https://cdn.example.com/p1.jpg","https://cdn.example.com/p2.jpg"]}`
|
||||
|
||||
t.Run("a complete profile round-trips with its photos in order", func(t *testing.T) {
|
||||
if status, body := create(1, valid); status != 200 {
|
||||
t.Fatalf("HTTP %d: %s", status, body)
|
||||
}
|
||||
items := list(1)
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("items = %d", len(items))
|
||||
}
|
||||
pet := items[0]
|
||||
if pet.Name != "团子" || pet.SpeciesLabel != "猫" || !pet.Neutered || pet.WeightG != 4200 {
|
||||
t.Fatalf("pet = %+v", pet)
|
||||
}
|
||||
if len(pet.Photos) != 2 || pet.Photos[0] != "https://cdn.example.com/p1.jpg" {
|
||||
t.Fatalf("photos = %v", pet.Photos)
|
||||
}
|
||||
// The first photo becomes the avatar when none was chosen.
|
||||
if pet.Avatar != "https://cdn.example.com/p1.jpg" {
|
||||
t.Fatalf("avatar = %q", pet.Avatar)
|
||||
}
|
||||
if pet.AgeMonths < 30 {
|
||||
t.Fatalf("ageMonths = %d,应当由生日推算", pet.AgeMonths)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("dates that cannot be true are refused with a reason", func(t *testing.T) {
|
||||
tomorrow := time.Now().AddDate(0, 0, 1).Format("2006-01-02")
|
||||
for _, test := range []struct{ body, want string }{
|
||||
{fmt.Sprintf(`{"name":"未来猫","species":"cat","vaccinatedAt":%q}`, tomorrow), "免疫日期不能晚于今天"},
|
||||
{fmt.Sprintf(`{"name":"未来猫","species":"cat","birthday":%q}`, tomorrow), "出生日期不能晚于今天"},
|
||||
{`{"name":"格式错","species":"cat","birthday":"2023/05/01"}`, "出生日期格式应为"},
|
||||
{`{"name":"太老了","species":"cat","birthday":"1900-01-01"}`, "过于久远"},
|
||||
} {
|
||||
status, body := create(1, test.body)
|
||||
if status != 400 || !strings.Contains(body, test.want) {
|
||||
t.Fatalf("%s → HTTP %d %s", test.body, status, body)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the rest of the form is validated too", func(t *testing.T) {
|
||||
for _, test := range []struct{ body, want string }{
|
||||
{`{"name":"","species":"cat"}`, "请填写宠物名字"},
|
||||
{`{"name":"鹦鹉","species":"bird"}`, "请选择宠物类型"},
|
||||
{`{"name":"胖胖","species":"dog","weightG":300000}`, "体重应在"},
|
||||
{`{"name":"图多","species":"cat","photos":["https://a/1.jpg","https://a/2.jpg","https://a/3.jpg","https://a/4.jpg","https://a/5.jpg","https://a/6.jpg","https://a/7.jpg","https://a/8.jpg","https://a/9.jpg","https://a/10.jpg"]}`, "最多上传"},
|
||||
{`{"name":"坏图","species":"cat","photos":["javascript:alert(1)"]}`, "照片地址无效"},
|
||||
} {
|
||||
status, body := create(1, test.body)
|
||||
if status != 400 || !strings.Contains(body, test.want) {
|
||||
t.Fatalf("%s → HTTP %d %s", test.body, status, body)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("editing replaces the photos instead of accumulating them", func(t *testing.T) {
|
||||
pet := list(1)[0]
|
||||
w := httptest.NewRecorder()
|
||||
a.updatePet(w, imTestRequest(1, "PUT", fmt.Sprintf("/api/v1/pets/%d", pet.ID),
|
||||
`{"name":"团子","species":"cat","photos":["https://cdn.example.com/new.jpg"]}`))
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("HTTP %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
updated := list(1)[0]
|
||||
if len(updated.Photos) != 1 || updated.Photos[0] != "https://cdn.example.com/new.jpg" {
|
||||
t.Fatalf("photos = %v", updated.Photos)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a profile belongs to its owner alone", func(t *testing.T) {
|
||||
pet := list(1)[0]
|
||||
w := httptest.NewRecorder()
|
||||
a.updatePet(w, imTestRequest(2, "PUT", fmt.Sprintf("/api/v1/pets/%d", pet.ID), valid))
|
||||
if w.Code != 404 {
|
||||
t.Fatalf("别人不该改得动: HTTP %d", w.Code)
|
||||
}
|
||||
del := httptest.NewRecorder()
|
||||
a.deletePet(del, imTestRequest(2, "DELETE", fmt.Sprintf("/api/v1/pets/%d", pet.ID), ""))
|
||||
if del.Code != 404 {
|
||||
t.Fatalf("别人不该删得掉: HTTP %d", del.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the per-account limit is enforced", func(t *testing.T) {
|
||||
if _, err := db.Exec(`UPDATE system_configs SET config_value='2' WHERE config_key='pet.max_per_user'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
create(1, `{"name":"第二只","species":"dog"}`)
|
||||
status, body := create(1, `{"name":"第三只","species":"dog"}`)
|
||||
if status != 400 || !strings.Contains(body, "最多建立 2 只") {
|
||||
t.Fatalf("HTTP %d: %s", status, body)
|
||||
}
|
||||
if _, err := db.Exec(`UPDATE system_configs SET config_value='10' WHERE config_key='pet.max_per_user'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a deleted profile disappears from the list but keeps its row", func(t *testing.T) {
|
||||
before := list(1)
|
||||
w := httptest.NewRecorder()
|
||||
a.deletePet(w, imTestRequest(1, "DELETE", fmt.Sprintf("/api/v1/pets/%d", before[0].ID), ""))
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("HTTP %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if len(list(1)) != len(before)-1 {
|
||||
t.Fatal("删除后列表数量未变")
|
||||
}
|
||||
var kept int
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM pets WHERE id=?`, before[0].ID).Scan(&kept); err != nil || kept != 1 {
|
||||
t.Fatalf("软删除应保留记录: %d %v", kept, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAdminPetModerationMySQL(t *testing.T) {
|
||||
db := isolatedIMDatabase(t)
|
||||
a := &App{db: db, hub: NewHub(), config: Config{Environment: "development", JWTSecret: "isolated-im-regression-secret-only"}}
|
||||
w := httptest.NewRecorder()
|
||||
a.createPet(w, imTestRequest(1, "POST", "/api/v1/pets", `{"name":"待审核","species":"dog","breed":"柯基"}`))
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("HTTP %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var created struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
var envelope struct {
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &envelope)
|
||||
_ = json.Unmarshal(envelope.Data, &created)
|
||||
|
||||
t.Run("the list carries the owner so a moderator knows whose pet this is", func(t *testing.T) {
|
||||
var payload struct {
|
||||
Items []map[string]any `json:"items"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
if err := json.Unmarshal(imTestCall(t, a.adminPets, 1, "GET", "/admin/v1/pets?keyword=待审核", "", 200), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload.Total != 1 || len(payload.Items) != 1 {
|
||||
t.Fatalf("payload = %+v", payload)
|
||||
}
|
||||
if payload.Items[0]["ownerNickname"] == "" || payload.Items[0]["speciesLabel"] != "狗" {
|
||||
t.Fatalf("item = %v", payload.Items[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejecting without a reason is refused", func(t *testing.T) {
|
||||
reject := httptest.NewRecorder()
|
||||
a.adminModeratePet(reject, imTestRequest(1, "POST", fmt.Sprintf("/admin/v1/pets/%d/moderate", created.ID), `{"status":2}`))
|
||||
if reject.Code != 400 || !strings.Contains(reject.Body.String(), "必须填写原因") {
|
||||
t.Fatalf("HTTP %d: %s", reject.Code, reject.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a rejected profile stays visible to its owner with the reason", func(t *testing.T) {
|
||||
reject := httptest.NewRecorder()
|
||||
a.adminModeratePet(reject, imTestRequest(1, "POST", fmt.Sprintf("/admin/v1/pets/%d/moderate", created.ID), `{"status":2,"note":"照片看不清宠物"}`))
|
||||
if reject.Code != 200 {
|
||||
t.Fatalf("HTTP %d: %s", reject.Code, reject.Body.String())
|
||||
}
|
||||
var owner struct {
|
||||
Pet petView `json:"pet"`
|
||||
ModerationNote string `json:"moderationNote"`
|
||||
}
|
||||
if err := json.Unmarshal(imTestCall(t, a.petDetail, 1, "GET", fmt.Sprintf("/api/v1/pets/%d", created.ID), "", 200), &owner); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if owner.Pet.Moderation != 2 || owner.ModerationNote != "照片看不清宠物" {
|
||||
t.Fatalf("owner view = %+v", owner)
|
||||
}
|
||||
// Everyone else stops seeing it until it passes.
|
||||
stranger := httptest.NewRecorder()
|
||||
a.petDetail(stranger, imTestRequest(2, "GET", fmt.Sprintf("/api/v1/pets/%d", created.ID), ""))
|
||||
if stranger.Code != 404 {
|
||||
t.Fatalf("被驳回的档案不该对外可见: HTTP %d", stranger.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
-- 宠物档案。代喂任务、送养帖、配种信息都挂在这张表上,所以免疫与绝育状态
|
||||
-- 放在档案里而不是各自的业务表里——同一只宠物的这些事实只应有一份。
|
||||
CREATE TABLE IF NOT EXISTS pets (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
owner_user_id BIGINT UNSIGNED NOT NULL,
|
||||
name VARCHAR(30) NOT NULL,
|
||||
species VARCHAR(20) NOT NULL COMMENT 'cat / dog / other',
|
||||
breed VARCHAR(50) NOT NULL DEFAULT '',
|
||||
gender TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 未知 1 公 2 母',
|
||||
birthday DATE NULL,
|
||||
neutered TINYINT(1) NOT NULL DEFAULT 0,
|
||||
weight_g INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
chip_no VARCHAR(40) NOT NULL DEFAULT '',
|
||||
vaccinated_at DATE NULL COMMENT '最近一次免疫日期',
|
||||
dewormed_at DATE NULL COMMENT '最近一次驱虫日期',
|
||||
temperament VARCHAR(200) NOT NULL DEFAULT '',
|
||||
avatar_url VARCHAR(500) NOT NULL DEFAULT '',
|
||||
status TINYINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '0 已删除 1 正常',
|
||||
moderation_status TINYINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '0 待审核 1 通过 2 驳回',
|
||||
moderation_note VARCHAR(255) NOT NULL DEFAULT '',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
deleted_at DATETIME(3) NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_pets_owner (owner_user_id, status, id),
|
||||
KEY idx_pets_moderation (moderation_status, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS pet_media (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
pet_id BIGINT UNSIGNED NOT NULL,
|
||||
media_url VARCHAR(500) NOT NULL,
|
||||
sort_order INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_pet_media_pet (pet_id, sort_order, id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
INSERT INTO system_configs(config_key,config_value,value_type,description) VALUES
|
||||
('pet.enabled','true','boolean','宠物板块总开关,关闭后客户端不显示宠物入口'),
|
||||
('pet.max_per_user','10','integer','单个账号可建立的宠物档案数量上限'),
|
||||
('pet.max_photos','9','integer','单只宠物的照片数量上限')
|
||||
ON DUPLICATE KEY UPDATE description=VALUES(description);
|
||||
Reference in New Issue
Block a user