Files
kefu/im/backend/internal/app/pets.go
T
Your NameandClaude Opus 5 a889cb42e4 整改三期:退款、后台增改删、悬赏
退款原来只有会员那条路,而且只能全额、只改本地状态。现在代喂任务的退款
能分几次退到退完:每一笔单独记一行(谁退的、多少、走哪条通道、为什么),
订单上的 refunded_cent 跟着累加,退完且未结算的任务才关闭。渠道接通时按
金额原路退回并带独立幂等键,没接通或沙箱支付的登记为"线下已退"——假装
调用了渠道比说实话更糟。已结算的任务不能顺手退:钱在喂养者账上,要退只能
由平台承担,必须显式勾选。

后台从"只能通过或驳回"变成能改能删:宠物档案可代建、可编辑、可软删除
(有进行中任务的删不掉),送养与配种能改文案和城市、能下架能删,代喂任务
能改备注、能取消(托管中的钱必须先退)。每一个写操作都进审计日志。

悬赏按方案里的 A 做:主人自愿加的钱跟着任务一起托管,结算时全额给喂养者,
平台服务费只算在基础报酬上。抽悬赏是喂养者一眼能算出来的事。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 11:25:02 +08:00

498 lines
18 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,
p.birthday,p.dewormed_at,p.weight_g,p.chip_no,p.temperament`+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{}
ids := []any{}
placeholders := []string{}
for rows.Next() {
var id, ownerID int64
var nickname, publicID, name, species, breed, avatar, note, chipNo, temperament string
var gender, neutered, moderation, weight int
var vaccinated, birthday, dewormed sql.NullTime
var created time.Time
if rows.Scan(&id, &ownerID, &nickname, &publicID, &name, &species, &breed, &gender, &neutered,
&vaccinated, &avatar, &moderation, &note, &created, &birthday, &dewormed, &weight, &chipNo, &temperament) != nil {
continue
}
day := func(value sql.NullTime) string {
if !value.Valid {
return ""
}
return value.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": day(vaccinated), "avatar": avatar,
"moderationStatus": moderation, "moderationNote": note, "createdAt": created.Format(time.RFC3339),
"birthday": day(birthday), "dewormedAt": day(dewormed), "weightG": weight,
"chipNo": chipNo, "temperament": temperament, "photos": []string{},
})
ids = append(ids, id)
placeholders = append(placeholders, "?")
}
// 照片单独取一遍,编辑弹窗要按整组替换,列表里就得先有整组。
if len(ids) > 0 {
mediaRows, mediaErr := a.db.QueryContext(r.Context(),
`SELECT pet_id,media_url FROM pet_media WHERE pet_id IN (`+strings.Join(placeholders, ",")+`) ORDER BY pet_id,sort_order,id`, ids...)
if mediaErr == nil {
defer mediaRows.Close()
for mediaRows.Next() {
var petID int64
var url string
if mediaRows.Scan(&petID, &url) != nil {
continue
}
for index := range items {
if items[index]["id"] == petID {
items[index]["photos"] = append(items[index]["photos"].([]string), url)
}
}
}
}
}
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})
}