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