方案第一期的后端部分。宠物档案是整个板块的根——代喂任务、送养帖、 配种信息都会挂在它上面,所以免疫、绝育、体重这些事实只在这里存一份, 不在各业务表里重复填写与互相矛盾。 - pets / pet_media 两张表(迁移 036),另加三个可在管理端调整的配置: 总开关、单账号档案上限、单只宠物照片上限。 - 客户端接口:档案增删改查。日期在校验阶段解析,格式或"未来的免疫日期" 这类问题返回 400 并说明是哪个字段,而不是从写库深处抛出 500。 - 软删除:档案从列表消失但记录保留,将来代喂订单与送养记录仍能追溯。 - 后台审核:列表带主人昵称与星遇号;驳回必须填原因;被驳回的档案对外 不可见,但主人仍能看到并读到原因。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
468 lines
17 KiB
Go
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(¬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})
|
|
}
|