领养是信息服务:没有金额字段,文案里出现收费话术或短期内连续送养都会转人工, 送出时按 30/90 天建好回访任务。配种同样只做信息展示,且默认关闭。 代喂是这个模块里唯一动钱的部分,顺序是它的安全边界:先付款再进大厅,选定 之后才解密门牌与电话,每次上门按到达/喂食/离开留照片,主人确认或超时自动确认 后才结算。结算只走账本,(user, biz_type, biz_id) 唯一键让重复结算付不出第二次; 申诉会冻结自动确认,由客服裁定全付、部分付或全退。 提现按方案默认关闭:收益先只记账,等分账通道与资质到位再在管理端开闸。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
138 lines
5.4 KiB
Go
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, ¬e, &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})
|
|
}
|