Files
kefu/im/backend/internal/app/pets.go
T
Your NameandClaude Opus 5 aee1881e69 宠物板块第一期:宠物档案与后台审核
方案第一期的后端部分。宠物档案是整个板块的根——代喂任务、送养帖、
配种信息都会挂在它上面,所以免疫、绝育、体重这些事实只在这里存一份,
不在各业务表里重复填写与互相矛盾。

- pets / pet_media 两张表(迁移 036),另加三个可在管理端调整的配置:
  总开关、单账号档案上限、单只宠物照片上限。
- 客户端接口:档案增删改查。日期在校验阶段解析,格式或"未来的免疫日期"
  这类问题返回 400 并说明是哪个字段,而不是从写库深处抛出 500。
- 软删除:档案从列表消失但记录保留,将来代喂订单与送养记录仍能追溯。
- 后台审核:列表带主人昵称与星遇号;驳回必须填原因;被驳回的档案对外
  不可见,但主人仍能看到并读到原因。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 16:53:55 +08:00

468 lines
17 KiB
Go

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(&note)
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, &note, &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})
}