Files
kefu/im/backend/internal/app/pet_admin_listings.go
T
Your NameandClaude Opus 5 46a60ffac3 宠物板块二到四期:领养、配种、上门代喂与收益账户
领养是信息服务:没有金额字段,文案里出现收费话术或短期内连续送养都会转人工,
送出时按 30/90 天建好回访任务。配种同样只做信息展示,且默认关闭。

代喂是这个模块里唯一动钱的部分,顺序是它的安全边界:先付款再进大厅,选定
之后才解密门牌与电话,每次上门按到达/喂食/离开留照片,主人确认或超时自动确认
后才结算。结算只走账本,(user, biz_type, biz_id) 唯一键让重复结算付不出第二次;
申诉会冻结自动确认,由客服裁定全付、部分付或全退。

提现按方案默认关闭:收益先只记账,等分账通道与资质到位再在管理端开闸。

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

138 lines
5.4 KiB
Go

package app
import (
"database/sql"
"fmt"
"net/http"
"strings"
"time"
)
// Adoption and mating listings are reviewed the same way and by the same
// person, so they share one queue and one decision endpoint. The table they
// live in is the only difference, and it is validated rather than interpolated
// blindly.
var petListingTables = map[string]string{"adoption": "pet_adoptions", "mating": "pet_mating_listings"}
func (a *App) adminPetListings(w http.ResponseWriter, r *http.Request) {
kind := strings.TrimSpace(r.URL.Query().Get("kind"))
if kind == "" {
kind = "adoption"
}
table, ok := petListingTables[kind]
if !ok {
fail(w, http.StatusBadRequest, 20001, "类型无效")
return
}
page, size, offset := pagination(r)
where := " WHERE l.deleted_at IS NULL"
args := []any{}
if status := strings.TrimSpace(r.URL.Query().Get("moderation")); status != "" {
where += " AND l.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 owner.nickname LIKE ? OR u.public_id LIKE ?)"
like := "%" + keyword + "%"
args = append(args, like, like, like)
}
from := fmt.Sprintf(` FROM %s l JOIN pets p ON p.id=l.pet_id JOIN users u ON u.id=l.owner_user_id
JOIN user_profiles owner ON owner.user_id=l.owner_user_id%s`, table, 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
}
// The two tables differ by one column each; selecting them separately keeps
// the scan honest instead of padding with NULLs.
extra := "l.reason,l.requirements,l.review_reason,l.application_count"
if kind == "mating" {
extra = "l.expectation,'',l.vaccine_media,0"
}
rows, err := a.db.QueryContext(r.Context(), `SELECT l.id,l.owner_user_id,owner.nickname,u.public_id,l.city_name,l.status,l.moderation_status,l.moderation_note,l.created_at,
p.id,p.name,p.species,p.breed,p.avatar_url,p.vaccinated_at,p.neutered,`+extra+from+` ORDER BY l.moderation_status=0 DESC,l.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, petID int64
var nickname, publicID, cityName, note, petName, species, breed, avatar string
var body, requirements, flag string
var status, moderation, neutered, applications int
var vaccinated sql.NullTime
var created time.Time
if rows.Scan(&id, &ownerID, &nickname, &publicID, &cityName, &status, &moderation, &note, &created,
&petID, &petName, &species, &breed, &avatar, &vaccinated, &neutered, &body, &requirements, &flag, &applications) != nil {
continue
}
vaccinatedAt := ""
if vaccinated.Valid {
vaccinatedAt = vaccinated.Time.Format("2006-01-02")
}
items = append(items, map[string]any{
"id": id, "kind": kind, "ownerUserId": ownerID, "ownerNickname": nickname, "ownerPublicId": publicID,
"cityName": cityName, "status": status, "moderationStatus": moderation, "moderationNote": note,
"petId": petID, "petName": petName, "species": species, "speciesLabel": petSpecies[species],
"breed": breed, "avatar": avatar, "vaccinatedAt": vaccinatedAt, "neutered": neutered == 1,
"body": body, "requirements": requirements, "flag": flag, "applicationCount": applications,
"createdAt": created.Format(time.RFC3339),
})
}
reply(w, map[string]any{"items": items, "total": total, "page": page, "size": size})
}
func (a *App) adminModeratePetListing(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"`
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
}
table, ok := petListingTables[strings.TrimSpace(req.Kind)]
if !ok {
fail(w, http.StatusBadRequest, 20001, "类型无效")
return
}
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
}
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
}
if _, err = a.db.ExecContext(r.Context(), fmt.Sprintf(`UPDATE %s SET moderation_status=?,moderation_note=? WHERE id=?`, table), req.Status, req.Note, id); err != nil {
fail(w, http.StatusInternalServerError, 50001, "保存审核结果失败")
return
}
title, body := "发布已通过", "你的信息已经通过审核,现在可以被其他人看到了"
if req.Status == 2 {
title, body = "发布未通过", req.Note
}
a.notifyUser(r.Context(), owner, "system", title, body, req.Kind, id)
a.audit(r, "moderate", "pet_"+req.Kind, id, map[string]any{"status": req.Status, "note": req.Note})
reply(w, map[string]bool{"success": true})
}