Files
kefu/im/backend/internal/app/pet_admin_crud.go
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

406 lines
14 KiB
Go

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})
}