package app import ( "fmt" "net/http" "strings" ) // Until now the console could only approve or reject. That means a listing with // one bad sentence had to be bounced back to the user to redo, and a wrong // profile could not be corrected at all. These handlers close that gap — every // one of them writes an audit entry, because editing someone else's data // without a trace is how a support tool turns into a liability. type adminPetRequest struct { OwnerUserID int64 `json:"ownerUserId"` 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"` Photos []string `json:"photos"` } func (a *App) adminValidatePet(w http.ResponseWriter, req *adminPetRequest) bool { req.Name = strings.TrimSpace(req.Name) req.Breed = strings.TrimSpace(req.Breed) req.ChipNo = strings.TrimSpace(req.ChipNo) req.Temperament = strings.TrimSpace(req.Temperament) if req.Name == "" || len([]rune(req.Name)) > 30 { fail(w, http.StatusBadRequest, 20001, "宠物名字为 1 到 30 个字") return false } if _, ok := petSpecies[req.Species]; !ok { fail(w, http.StatusBadRequest, 20001, "宠物类型无效") return false } if req.Gender < 0 || req.Gender > 2 { fail(w, http.StatusBadRequest, 20001, "性别无效") return false } if req.WeightG < 0 || req.WeightG > 200000 { fail(w, http.StatusBadRequest, 20001, "体重超出范围") return false } // 日期用客户端那套同样的校验,免得后台能存进去一个前台打不开的档案。 for _, field := range []struct { label string value string }{{"出生日期", req.Birthday}, {"最近免疫", req.VaccinatedAt}, {"最近驱虫", req.DewormedAt}} { if _, err := petDate(field.value, field.label, false); err != nil { fail(w, http.StatusBadRequest, 20001, err.Error()) return false } } if len(req.Photos) > 9 { fail(w, http.StatusBadRequest, 20001, "照片最多 9 张") return false } return true } func nullableDate(value string) any { if strings.TrimSpace(value) == "" { return nil } return value } func (a *App) adminCreatePet(w http.ResponseWriter, r *http.Request) { var req adminPetRequest if decode(r, &req) != nil { fail(w, http.StatusBadRequest, 20001, "格式错误") return } if req.OwnerUserID == 0 { fail(w, http.StatusBadRequest, 20001, "请指定这只宠物属于哪个用户") return } var exists int if a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM users WHERE id=?`, req.OwnerUserID).Scan(&exists) != nil || exists == 0 { fail(w, http.StatusNotFound, 30001, "用户不存在") return } if !a.adminValidatePet(w, &req) { return } avatar := "" if len(req.Photos) > 0 { avatar = req.Photos[0] } tx, err := a.db.BeginTx(r.Context(), nil) if err != nil { fail(w, http.StatusInternalServerError, 50001, "创建失败") return } defer func() { _ = tx.Rollback() }() result, err := 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,status,moderation_status) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,1,1)`, req.OwnerUserID, req.Name, req.Species, req.Breed, req.Gender, nullableDate(req.Birthday), req.Neutered, req.WeightG, req.ChipNo, nullableDate(req.VaccinatedAt), nullableDate(req.DewormedAt), req.Temperament, avatar) if err != nil { fail(w, http.StatusInternalServerError, 50001, "创建失败") return } petID, _ := result.LastInsertId() for index, url := range req.Photos { if _, err = tx.ExecContext(r.Context(), `INSERT INTO pet_media(pet_id,media_url,sort_order) VALUES(?,?,?)`, petID, url, index); err != nil { fail(w, http.StatusInternalServerError, 50001, "创建失败") return } } if tx.Commit() != nil { fail(w, http.StatusInternalServerError, 50001, "创建失败") return } a.audit(r, "create", "pet", petID, map[string]any{"ownerUserId": req.OwnerUserID, "name": req.Name}) reply(w, map[string]any{"id": petID}) } func (a *App) adminUpdatePet(w http.ResponseWriter, r *http.Request) { id, err := pathID(r) if err != nil { fail(w, http.StatusBadRequest, 20001, "编号无效") return } var req adminPetRequest if decode(r, &req) != nil { fail(w, http.StatusBadRequest, 20001, "格式错误") return } if !a.adminValidatePet(w, &req) { return } var owner int64 if a.db.QueryRowContext(r.Context(), `SELECT owner_user_id FROM pets WHERE id=? AND deleted_at IS NULL`, id).Scan(&owner) != nil { fail(w, http.StatusNotFound, 30001, "宠物不存在") return } avatar := "" if len(req.Photos) > 0 { avatar = req.Photos[0] } tx, err := a.db.BeginTx(r.Context(), nil) if err != nil { fail(w, http.StatusInternalServerError, 50001, "保存失败") return } defer func() { _ = tx.Rollback() }() if _, err = tx.ExecContext(r.Context(), `UPDATE pets SET name=?,species=?,breed=?,gender=?,birthday=?,neutered=?, weight_g=?,chip_no=?,vaccinated_at=?,dewormed_at=?,temperament=?,avatar_url=IF(?='',avatar_url,?) WHERE id=?`, req.Name, req.Species, req.Breed, req.Gender, nullableDate(req.Birthday), req.Neutered, req.WeightG, req.ChipNo, nullableDate(req.VaccinatedAt), nullableDate(req.DewormedAt), req.Temperament, avatar, avatar, id); err != nil { fail(w, http.StatusInternalServerError, 50001, "保存失败") return } // 照片按整组替换:传了就以传来的为准,没传就保留原来的。 if len(req.Photos) > 0 { if _, err = tx.ExecContext(r.Context(), `DELETE FROM pet_media WHERE pet_id=?`, id); err != nil { fail(w, http.StatusInternalServerError, 50001, "保存失败") return } for index, url := range req.Photos { if _, err = tx.ExecContext(r.Context(), `INSERT INTO pet_media(pet_id,media_url,sort_order) VALUES(?,?,?)`, id, url, index); err != nil { fail(w, http.StatusInternalServerError, 50001, "保存失败") return } } } if tx.Commit() != nil { fail(w, http.StatusInternalServerError, 50001, "保存失败") return } a.audit(r, "update", "pet", id, map[string]any{"ownerUserId": owner, "name": req.Name}) a.notifyUser(r.Context(), owner, "system", "宠物档案被平台修改", "客服调整了「"+req.Name+"」的档案,如有疑问可以联系客服。", "pet", id) reply(w, map[string]bool{"success": true}) } func (a *App) adminDeletePet(w http.ResponseWriter, r *http.Request) { id, err := pathID(r) if err != nil { fail(w, http.StatusBadRequest, 20001, "编号无效") return } // 有进行中的任务时不能删:那只宠物正被人照顾着。 var live int _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM pet_feed_tasks WHERE pet_id=? AND status IN ('CREATED','ESCROWED','ASSIGNED','SERVING','COMPLETED','DISPUTED')`, id).Scan(&live) if live > 0 { 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 deleted_at IS NULL`, id) if err != nil { fail(w, http.StatusInternalServerError, 50001, "删除失败") return } if affected, _ := result.RowsAffected(); affected == 0 { fail(w, http.StatusNotFound, 30001, "宠物不存在") return } // 档案没了,挂在它上面的送养与配种也一起下架,免得点进去是空的。 _, _ = a.db.ExecContext(r.Context(), `UPDATE pet_adoptions SET status=0 WHERE pet_id=? AND status=1`, id) _, _ = a.db.ExecContext(r.Context(), `UPDATE pet_mating_listings SET status=0 WHERE pet_id=? AND status=1`, id) a.audit(r, "delete", "pet", id, nil) reply(w, map[string]bool{"success": true}) } // --- 送养 / 配种 ---------------------------------------------------------- func (a *App) adminUpdatePetListing(w http.ResponseWriter, r *http.Request) { id, err := pathID(r) if err != nil { fail(w, http.StatusBadRequest, 20001, "编号无效") return } var req struct { Kind string `json:"kind"` CityCode string `json:"cityCode"` CityName string `json:"cityName"` Body string `json:"body"` Requirements string `json:"requirements"` Status *int `json:"status"` } if decode(r, &req) != nil { fail(w, http.StatusBadRequest, 20001, "格式错误") return } table, ok := petListingTables[strings.TrimSpace(req.Kind)] if !ok { fail(w, http.StatusBadRequest, 20001, "类型无效") return } req.Body = strings.TrimSpace(req.Body) req.Requirements = strings.TrimSpace(req.Requirements) if len([]rune(req.Body)) > 500 || len([]rune(req.Requirements)) > 500 { fail(w, http.StatusBadRequest, 20001, "内容过长") return } if req.Status != nil && (*req.Status < 0 || *req.Status > 2) { fail(w, http.StatusBadRequest, 20001, "状态无效") return } var owner int64 if a.db.QueryRowContext(r.Context(), fmt.Sprintf(`SELECT owner_user_id FROM %s WHERE id=? AND deleted_at IS NULL`, table), id).Scan(&owner) != nil { fail(w, http.StatusNotFound, 30001, "记录不存在") return } // 两张表的正文字段名不同,其余更新是一样的。 bodyColumn := "reason" if req.Kind == "mating" { bodyColumn = "expectation" } query := fmt.Sprintf(`UPDATE %s SET city_code=IF(?='',city_code,?),city_name=IF(?='',city_name,?),%s=IF(?='',%s,?)`, table, bodyColumn, bodyColumn) args := []any{req.CityCode, req.CityCode, req.CityName, req.CityName, req.Body, req.Body} if req.Kind == "adoption" { query += `,requirements=IF(?='',requirements,?)` args = append(args, req.Requirements, req.Requirements) } if req.Status != nil { query += `,status=?` args = append(args, *req.Status) } query += ` WHERE id=?` args = append(args, id) if _, err = a.db.ExecContext(r.Context(), query, args...); err != nil { fail(w, http.StatusInternalServerError, 50001, "保存失败") return } a.audit(r, "update", "pet_"+req.Kind, id, map[string]any{"ownerUserId": owner, "status": req.Status}) reply(w, map[string]bool{"success": true}) } func (a *App) adminDeletePetListing(w http.ResponseWriter, r *http.Request) { id, err := pathID(r) if err != nil { fail(w, http.StatusBadRequest, 20001, "编号无效") return } kind := strings.TrimSpace(r.URL.Query().Get("kind")) table, ok := petListingTables[kind] if !ok { fail(w, http.StatusBadRequest, 20001, "类型无效") return } result, err := a.db.ExecContext(r.Context(), fmt.Sprintf(`UPDATE %s SET status=0,deleted_at=NOW(3) WHERE id=? AND deleted_at IS NULL`, table), 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, "delete", "pet_"+kind, id, nil) reply(w, map[string]bool{"success": true}) } // --- 代喂任务 ------------------------------------------------------------- func (a *App) adminUpdateFeedTask(w http.ResponseWriter, r *http.Request) { id, err := pathID(r) if err != nil { fail(w, http.StatusBadRequest, 20001, "编号无效") return } var req struct { Note string `json:"note"` Cancel bool `json:"cancel"` Reason string `json:"reason"` } if decode(r, &req) != nil { fail(w, http.StatusBadRequest, 20001, "格式错误") return } var owner, sitter int64 var status string var refunded, gross int64 if a.db.QueryRowContext(r.Context(), `SELECT owner_user_id,COALESCE(sitter_user_id,0),status,refunded_cent,gross_cent FROM pet_feed_tasks WHERE id=?`, id).Scan(&owner, &sitter, &status, &refunded, &gross) != nil { fail(w, http.StatusNotFound, 30001, "任务不存在") return } if req.Cancel { if strings.TrimSpace(req.Reason) == "" { fail(w, http.StatusBadRequest, 20001, "取消任务必须写明原因") return } if status == "SETTLED" || status == "REFUNDED" || status == "CANCELLED" { fail(w, http.StatusBadRequest, 20001, "这个状态的任务不能取消") return } // 钱还在托管里就先退,再取消;否则会留下一笔没有归属的钱。 if gross > refunded && status != "CREATED" { fail(w, http.StatusBadRequest, 20001, "请先把托管中的金额退给主人,再取消任务") return } if _, err = a.db.ExecContext(r.Context(), `UPDATE pet_feed_tasks SET status='CANCELLED',confirm_due_at=NULL WHERE id=?`, id); err != nil { fail(w, http.StatusInternalServerError, 50001, "操作失败") return } a.audit(r, "cancel", "pet_feed_task", id, map[string]any{"reason": req.Reason, "previousStatus": status}) for _, party := range []int64{owner, sitter} { if party > 0 { a.notifyUser(r.Context(), party, "system", "代喂任务已被取消", strings.TrimSpace(req.Reason), "feed_task", id) } } reply(w, map[string]bool{"success": true}) return } note := strings.TrimSpace(req.Note) if len([]rune(note)) > 500 { fail(w, http.StatusBadRequest, 20001, "备注过长") return } if _, err = a.db.ExecContext(r.Context(), `UPDATE pet_feed_tasks SET note=? WHERE id=?`, note, id); err != nil { fail(w, http.StatusInternalServerError, 50001, "保存失败") return } a.audit(r, "update", "pet_feed_task", id, map[string]any{"note": note}) reply(w, map[string]bool{"success": true}) } // adminPetOwners powers the "which user does this pet belong to" picker when an // operator creates a profile on someone's behalf. func (a *App) adminPetOwners(w http.ResponseWriter, r *http.Request) { keyword := strings.TrimSpace(r.URL.Query().Get("keyword")) if keyword == "" { reply(w, map[string]any{"items": []any{}}) return } if len([]rune(keyword)) > 50 { fail(w, http.StatusBadRequest, 20001, "搜索关键词最多 50 字") return } like := "%" + keyword + "%" rows, err := a.db.QueryContext(r.Context(), `SELECT u.id,u.public_id,COALESCE(p.nickname,''),COALESCE(p.avatar_url,'') FROM users u LEFT JOIN user_profiles p ON p.user_id=u.id WHERE u.deleted_at IS NULL AND (u.public_id LIKE ? OR p.nickname LIKE ?) ORDER BY u.id DESC LIMIT 20`, like, like) if err != nil { fail(w, http.StatusInternalServerError, 50001, "查询用户失败") return } defer rows.Close() items := []map[string]any{} for rows.Next() { var id int64 var publicID, nickname, avatar string if rows.Scan(&id, &publicID, &nickname, &avatar) != nil { continue } items = append(items, map[string]any{"id": id, "publicId": publicID, "nickname": nickname, "avatar": avatarThumbnailURL(avatar)}) } reply(w, map[string]any{"items": items}) }