Files
kefu/im/backend/internal/app/pet_adoption.go
Your NameandClaude Opus 5 0031f79eb3 管理端补上运营真正要用的那几件事
原来 107 个后台接口配的是一套只能看的界面:风险中心 6 行代码全是只读,
动态和消息只能一条一条点,运营想发一句公告没有任何入口。

公告与通知:按全体、会员、城市、喂养者、有宠物的用户或指定名单发系统通知,
发之前能看到会发给多少人,可同时走离线推送。撤回只删还没读的那些——已经
读过的假装收回去只会让统计说谎。

资金总览:把"平台账上哪些钱不是自己的"单独摆出来(任务托管中 + 喂养者余额 +
申诉冻结),旁边才是收入、退款和待打款,最后是按产品和按日的收款。

退款统一到一个入口:会员、代喂、保证金都能分几次退到退完,每笔记一行;
退完最后一分钱才撤销买到的东西,半份会员仍然是会员。

风险中心能动手了:按等级和状态筛,行内直接封禁、冻结、加白(必须写理由)、
跳用户详情,事件可以标记跟进完成。

批量处置:动态、消息、举报支持勾选后批量下架/恢复/驳回,一次最多 100 条。
处罚仍然一条一条来——批量封禁是错封人最快的方式。

敏感词统一词库:消息、动态、送养共用一套,命中是拦截还是转人工由运营定;
转人工的命中会在风险中心留一条,管理端还能拿真实文案先试一遍。

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

542 lines
21 KiB
Go

package app
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
)
// Adoption is an information service, not a marketplace: there is no price
// field anywhere in it, and anything in the free text that smells like one
// sends the listing to a human instead of straight onto the shelf.
type adoptionView struct {
ID int64 `json:"id"`
PetID int64 `json:"petId"`
OwnerUserID int64 `json:"ownerUserId"`
OwnerName string `json:"ownerName"`
PetName string `json:"petName"`
Species string `json:"species"`
SpeciesLabel string `json:"speciesLabel"`
Breed string `json:"breed"`
Gender int `json:"gender"`
AgeMonths int `json:"ageMonths"`
Neutered bool `json:"neutered"`
VaccinatedAt string `json:"vaccinatedAt,omitempty"`
Avatar string `json:"avatar"`
Photos []string `json:"photos"`
CityName string `json:"cityName"`
Reason string `json:"reason"`
Requirements string `json:"requirements"`
Status int `json:"status"`
Moderation int `json:"moderationStatus"`
Applications int `json:"applicationCount"`
Mine bool `json:"mine"`
Applied bool `json:"applied"`
CreatedAt string `json:"createdAt"`
}
// The questionnaire is the only real filter a giver has. Keeping the questions
// server-side means every listing asks the same things and the answers stay
// comparable.
var adoptionQuestions = []struct {
Key string `json:"key"`
Label string `json:"label"`
}{
{"housing", "居住情况(自有/租住,房东是否允许养宠)"},
{"members", "家人或室友是否都同意"},
{"pets", "家里是否已有其他宠物"},
{"experience", "过往养宠经验"},
{"followup", "是否接受送出后的回访"},
}
func (a *App) adoptionReviewReason(ctx context.Context, ownerID int64, text string) string {
// 统一词库优先;配置里那份旧词表留着兜底,免得词库被清空后送养就没人看了。
if word, _ := a.matchKeyword(ctx, "adoption", text); word != "" {
return "文案命中敏感词:" + word
}
words := strings.Split(a.configPlain(ctx, "pet.adoption_review_words", ""), ",")
lowered := strings.ToLower(text)
for _, word := range words {
word = strings.TrimSpace(word)
if word != "" && strings.Contains(lowered, strings.ToLower(word)) {
return "文案命中敏感词:" + word
}
}
// Someone giving away a litter every month is a breeder, not an owner in
// trouble. The window and threshold are both configurable.
days := a.configInt(ctx, "pet.adoption_batch_days", 90)
limit := a.configInt(ctx, "pet.adoption_batch_limit", 3)
if days > 0 && limit > 0 {
var recent int
_ = a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM pet_adoptions WHERE owner_user_id=? AND deleted_at IS NULL AND created_at>DATE_SUB(NOW(3),INTERVAL ? DAY)`, ownerID, days).Scan(&recent)
if recent >= limit {
return fmt.Sprintf("%d 天内已发布 %d 条送养", days, recent)
}
}
return ""
}
func (a *App) createAdoption(w http.ResponseWriter, r *http.Request) {
if !a.configBool(r.Context(), "pet.adoption_enabled", true) {
fail(w, http.StatusForbidden, 30002, "送养功能暂未开放")
return
}
var req struct {
PetID int64 `json:"petId"`
CityCode string `json:"cityCode"`
CityName string `json:"cityName"`
Reason string `json:"reason"`
Requirements string `json:"requirements"`
}
if decode(r, &req) != nil || req.PetID == 0 {
fail(w, http.StatusBadRequest, 20001, "送养信息格式错误")
return
}
req.Reason = strings.TrimSpace(req.Reason)
req.Requirements = strings.TrimSpace(req.Requirements)
req.CityName = strings.TrimSpace(req.CityName)
if len([]rune(req.Reason)) < 10 || len([]rune(req.Reason)) > 500 {
fail(w, http.StatusBadRequest, 20001, "请说明送养原因,10 到 500 字")
return
}
if len([]rune(req.Requirements)) > 500 || len([]rune(req.CityName)) > 50 {
fail(w, http.StatusBadRequest, 20001, "内容过长")
return
}
// The listing inherits its facts from the profile, so the profile has to be
// complete first: photos to look at, and a vaccination record to trust.
var vaccinated sql.NullTime
var photos int
if err := a.db.QueryRowContext(r.Context(), `SELECT p.vaccinated_at,(SELECT COUNT(*) FROM pet_media m WHERE m.pet_id=p.id)
FROM pets p WHERE p.id=? AND p.owner_user_id=? AND p.status=1 AND p.deleted_at IS NULL`, req.PetID, current(r).ID).Scan(&vaccinated, &photos); err != nil {
fail(w, http.StatusNotFound, 30001, "宠物不存在")
return
}
if minPhotos := a.configInt(r.Context(), "pet.adoption_min_photos", 3); photos < minPhotos {
fail(w, http.StatusBadRequest, 20001, fmt.Sprintf("发布送养前请至少上传 %d 张宠物照片", minPhotos))
return
}
if !vaccinated.Valid {
fail(w, http.StatusBadRequest, 20001, "请先在宠物档案中填写免疫日期")
return
}
var existing int
_ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM pet_adoptions WHERE pet_id=? AND status=1 AND deleted_at IS NULL`, req.PetID).Scan(&existing)
if existing > 0 {
fail(w, http.StatusBadRequest, 20001, "这只宠物已经在送养中")
return
}
reviewReason := a.adoptionReviewReason(r.Context(), current(r).ID, req.Reason+" "+req.Requirements)
result, err := a.db.ExecContext(r.Context(), `INSERT INTO pet_adoptions(pet_id,owner_user_id,city_code,city_name,reason,requirements,review_reason)
VALUES(?,?,?,?,?,?,?)`, req.PetID, current(r).ID, strings.TrimSpace(req.CityCode), req.CityName, req.Reason, req.Requirements, reviewReason)
if err != nil {
fail(w, http.StatusInternalServerError, 50001, "发布失败")
return
}
id, _ := result.LastInsertId()
reply(w, map[string]any{"id": id, "pendingReview": true, "reviewReason": reviewReason})
}
const adoptionColumns = `a.id,a.pet_id,a.owner_user_id,owner.nickname,a.city_name,a.reason,a.requirements,a.status,a.moderation_status,a.application_count,a.created_at,
p.name,p.species,p.breed,p.gender,p.birthday,p.neutered,p.vaccinated_at,p.avatar_url`
func (a *App) scanAdoptions(ctx context.Context, rows *sql.Rows, viewer int64) []adoptionView {
items := []adoptionView{}
ids := []any{}
placeholders := []string{}
for rows.Next() {
var item adoptionView
var birthday, vaccinated sql.NullTime
var created time.Time
var neutered int
if rows.Scan(&item.ID, &item.PetID, &item.OwnerUserID, &item.OwnerName, &item.CityName, &item.Reason,
&item.Requirements, &item.Status, &item.Moderation, &item.Applications, &created,
&item.PetName, &item.Species, &item.Breed, &item.Gender, &birthday, &neutered, &vaccinated, &item.Avatar) != nil {
continue
}
item.SpeciesLabel = petSpecies[item.Species]
item.Neutered = neutered == 1
if birthday.Valid {
item.AgeMonths = monthsSince(birthday.Time)
}
if vaccinated.Valid {
item.VaccinatedAt = vaccinated.Time.Format("2006-01-02")
}
item.CreatedAt = created.Format(time.RFC3339)
item.Mine = item.OwnerUserID == viewer
item.Photos = []string{}
items = append(items, item)
ids = append(ids, item.PetID)
placeholders = append(placeholders, "?")
}
if len(ids) == 0 {
return items
}
mediaRows, err := a.db.QueryContext(ctx, `SELECT pet_id,media_url FROM pet_media WHERE pet_id IN (`+strings.Join(placeholders, ",")+`) ORDER BY pet_id,sort_order,id`, ids...)
if err == nil {
defer mediaRows.Close()
for mediaRows.Next() {
var petID int64
var url string
if mediaRows.Scan(&petID, &url) != nil {
continue
}
for index := range items {
if items[index].PetID == petID {
items[index].Photos = append(items[index].Photos, url)
}
}
}
}
// One query tells the reader which listings they already applied to, so the
// list can show "已申请" without a request per card.
appliedRows, err := a.db.QueryContext(ctx, `SELECT adoption_id FROM pet_adoption_applications WHERE applicant_user_id=? AND status IN (0,1)`, viewer)
if err == nil {
defer appliedRows.Close()
applied := map[int64]bool{}
for appliedRows.Next() {
var id int64
if appliedRows.Scan(&id) == nil {
applied[id] = true
}
}
for index := range items {
items[index].Applied = applied[items[index].ID]
}
}
return items
}
func (a *App) adoptions(w http.ResponseWriter, r *http.Request) {
_, size, offset := pageOptions(r)
where := " WHERE a.deleted_at IS NULL"
args := []any{}
if strings.TrimSpace(r.URL.Query().Get("mine")) == "1" {
where += " AND a.owner_user_id=?"
args = append(args, current(r).ID)
} else {
// The shelf only carries approved, still-open listings.
where += " AND a.status=1 AND a.moderation_status=1"
if city := strings.TrimSpace(r.URL.Query().Get("cityCode")); city != "" {
where += " AND a.city_code=?"
args = append(args, city)
}
if species := strings.TrimSpace(r.URL.Query().Get("species")); species != "" {
where += " AND p.species=?"
args = append(args, species)
}
}
query := `SELECT ` + adoptionColumns + ` FROM pet_adoptions a
JOIN pets p ON p.id=a.pet_id JOIN user_profiles owner ON owner.user_id=a.owner_user_id` + where +
` ORDER BY a.id DESC LIMIT ? OFFSET ?`
rows, err := a.db.QueryContext(r.Context(), query, append(args, size+1, offset)...)
if err != nil {
fail(w, http.StatusInternalServerError, 50001, "查询送养信息失败")
return
}
defer rows.Close()
items := a.scanAdoptions(r.Context(), rows, current(r).ID)
hasMore := len(items) > size
if hasMore {
items = items[:size]
}
reply(w, map[string]any{"items": items, "hasMore": hasMore, "questions": adoptionQuestions})
}
func (a *App) adoptionDetail(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
fail(w, http.StatusBadRequest, 20001, "编号无效")
return
}
rows, queryErr := a.db.QueryContext(r.Context(), `SELECT `+adoptionColumns+` FROM pet_adoptions a
JOIN pets p ON p.id=a.pet_id JOIN user_profiles owner ON owner.user_id=a.owner_user_id
WHERE a.id=? AND a.deleted_at IS NULL AND (a.owner_user_id=? OR (a.status<>0 AND a.moderation_status=1))`, id, current(r).ID)
if queryErr != nil {
fail(w, http.StatusInternalServerError, 50001, "查询失败")
return
}
defer rows.Close()
items := a.scanAdoptions(r.Context(), rows, current(r).ID)
if len(items) == 0 {
fail(w, http.StatusNotFound, 30001, "送养信息不存在")
return
}
view := map[string]any{"adoption": items[0], "questions": adoptionQuestions}
// Answers are private to the giver: they contain where a stranger lives.
if items[0].Mine {
applications, _ := a.adoptionApplications(r.Context(), id)
view["applications"] = applications
var note string
_ = a.db.QueryRowContext(r.Context(), `SELECT moderation_note FROM pet_adoptions WHERE id=?`, id).Scan(&note)
view["moderationNote"] = note
}
reply(w, view)
}
func (a *App) adoptionApplications(ctx context.Context, adoptionID int64) ([]map[string]any, error) {
rows, err := a.db.QueryContext(ctx, `SELECT ap.id,ap.applicant_user_id,profile.nickname,profile.avatar_url,ap.answers_json,ap.message,ap.status,ap.created_at
FROM pet_adoption_applications ap JOIN user_profiles profile ON profile.user_id=ap.applicant_user_id
WHERE ap.adoption_id=? ORDER BY ap.id DESC`, adoptionID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []map[string]any{}
for rows.Next() {
var id, userID int64
var nickname, avatar, answers, message string
var status int
var created time.Time
if rows.Scan(&id, &userID, &nickname, &avatar, &answers, &message, &status, &created) != nil {
continue
}
parsed := map[string]string{}
_ = json.Unmarshal([]byte(answers), &parsed)
items = append(items, map[string]any{
"id": id, "userId": userID, "nickname": nickname, "avatar": avatarThumbnailURL(avatar),
"answers": parsed, "message": message, "status": status, "createdAt": created.Format(time.RFC3339),
})
}
return items, nil
}
func (a *App) applyAdoption(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
fail(w, http.StatusBadRequest, 20001, "编号无效")
return
}
var req struct {
Answers map[string]string `json:"answers"`
Message string `json:"message"`
}
if decode(r, &req) != nil {
fail(w, http.StatusBadRequest, 20001, "申请格式错误")
return
}
for _, question := range adoptionQuestions {
if strings.TrimSpace(req.Answers[question.Key]) == "" {
fail(w, http.StatusBadRequest, 20001, "请回答:"+question.Label)
return
}
if len([]rune(req.Answers[question.Key])) > 200 {
fail(w, http.StatusBadRequest, 20001, "每个回答最多 200 字")
return
}
}
var owner int64
var status, moderation int
if a.db.QueryRowContext(r.Context(), `SELECT owner_user_id,status,moderation_status FROM pet_adoptions WHERE id=? AND deleted_at IS NULL`, id).Scan(&owner, &status, &moderation) != nil {
fail(w, http.StatusNotFound, 30001, "送养信息不存在")
return
}
if owner == current(r).ID {
fail(w, http.StatusBadRequest, 20001, "不能申请领养自己送养的宠物")
return
}
if status != 1 || moderation != 1 {
fail(w, http.StatusBadRequest, 20001, "这条送养信息已经不接受申请")
return
}
answers, _ := json.Marshal(req.Answers)
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(), `INSERT INTO pet_adoption_applications(adoption_id,applicant_user_id,answers_json,message)
VALUES(?,?,?,?)`, id, current(r).ID, string(answers), strings.TrimSpace(req.Message)); err != nil {
fail(w, http.StatusBadRequest, 20001, "你已经申请过这条送养信息")
return
}
if _, err = tx.ExecContext(r.Context(), `UPDATE pet_adoptions SET application_count=application_count+1 WHERE id=?`, id); err != nil {
fail(w, http.StatusInternalServerError, 50001, "提交失败")
return
}
if tx.Commit() != nil {
fail(w, http.StatusInternalServerError, 50001, "提交失败")
return
}
a.notifyUser(r.Context(), owner, "system", "收到领养申请", "有人申请领养你送养的宠物,去看看是否合适", "adoption", id)
reply(w, map[string]bool{"success": true})
}
// decideAdoption is where a listing turns into a real handover: choosing one
// applicant closes the listing, declines the rest and schedules the follow-ups.
func (a *App) decideAdoption(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
fail(w, http.StatusBadRequest, 20001, "编号无效")
return
}
var req struct {
ApplicationID int64 `json:"applicationId"`
Action string `json:"action"`
}
if decode(r, &req) != nil || req.ApplicationID == 0 {
fail(w, http.StatusBadRequest, 20001, "参数错误")
return
}
var owner int64
var status int
if a.db.QueryRowContext(r.Context(), `SELECT owner_user_id,status FROM pet_adoptions WHERE id=? AND deleted_at IS NULL`, id).Scan(&owner, &status) != nil || owner != current(r).ID {
fail(w, http.StatusNotFound, 30001, "送养信息不存在")
return
}
var applicant int64
if a.db.QueryRowContext(r.Context(), `SELECT applicant_user_id FROM pet_adoption_applications WHERE id=? AND adoption_id=?`, req.ApplicationID, id).Scan(&applicant) != nil {
fail(w, http.StatusNotFound, 30001, "申请不存在")
return
}
if req.Action == "decline" {
if _, err = a.db.ExecContext(r.Context(), `UPDATE pet_adoption_applications SET status=2,decided_at=NOW(3) WHERE id=? AND status=0`, req.ApplicationID); err != nil {
fail(w, http.StatusInternalServerError, 50001, "操作失败")
return
}
a.notifyUser(r.Context(), applicant, "system", "领养申请未通过", "送养人这次选择了其他领养人,可以再看看别的宠物", "adoption", id)
reply(w, map[string]bool{"success": true})
return
}
if status != 1 {
fail(w, http.StatusBadRequest, 20001, "这条送养信息已经结束")
return
}
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 pet_adoption_applications SET status=1,decided_at=NOW(3) WHERE id=?`, req.ApplicationID); err != nil {
fail(w, http.StatusInternalServerError, 50001, "操作失败")
return
}
if _, err = tx.ExecContext(r.Context(), `UPDATE pet_adoption_applications SET status=2,decided_at=NOW(3) WHERE adoption_id=? AND id<>? AND status=0`, id, req.ApplicationID); err != nil {
fail(w, http.StatusInternalServerError, 50001, "操作失败")
return
}
if _, err = tx.ExecContext(r.Context(), `UPDATE pet_adoptions SET status=2,adopter_user_id=?,completed_at=NOW(3) WHERE id=?`, applicant, id); err != nil {
fail(w, http.StatusInternalServerError, 50001, "操作失败")
return
}
// Follow-ups are created now, with the handover, so nothing has to remember
// to create them later.
for _, day := range strings.Split(a.configPlain(r.Context(), "pet.adoption_followup_days", "30,90"), ",") {
days, convErr := strconv.Atoi(strings.TrimSpace(day))
if convErr != nil || days <= 0 {
continue
}
if _, err = tx.ExecContext(r.Context(), `INSERT INTO pet_adoption_followups(adoption_id,adopter_user_id,due_at) VALUES(?,?,DATE_ADD(NOW(3),INTERVAL ? DAY))`, id, applicant, days); err != nil {
fail(w, http.StatusInternalServerError, 50001, "操作失败")
return
}
}
if tx.Commit() != nil {
fail(w, http.StatusInternalServerError, 50001, "操作失败")
return
}
a.notifyUser(r.Context(), applicant, "system", "领养申请通过", "送养人选择了你,请尽快联系并当面确认", "adoption", id)
reply(w, map[string]bool{"success": true})
}
func (a *App) closeAdoption(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
fail(w, http.StatusBadRequest, 20001, "编号无效")
return
}
result, err := a.db.ExecContext(r.Context(), `UPDATE pet_adoptions SET status=0 WHERE id=? AND owner_user_id=? AND status=1`, id, current(r).ID)
if err != nil {
fail(w, http.StatusInternalServerError, 50001, "操作失败")
return
}
if affected, _ := result.RowsAffected(); affected == 0 {
fail(w, http.StatusNotFound, 30001, "送养信息不存在或已结束")
return
}
reply(w, map[string]bool{"success": true})
}
func (a *App) myAdoptionFollowups(w http.ResponseWriter, r *http.Request) {
rows, err := a.db.QueryContext(r.Context(), `SELECT f.id,f.adoption_id,f.due_at,f.submitted_at,f.content,f.media_json,p.name,p.avatar_url
FROM pet_adoption_followups f JOIN pet_adoptions a ON a.id=f.adoption_id JOIN pets p ON p.id=a.pet_id
WHERE f.adopter_user_id=? ORDER BY f.submitted_at IS NOT NULL,f.due_at`, current(r).ID)
if err != nil {
fail(w, http.StatusInternalServerError, 50001, "查询回访失败")
return
}
defer rows.Close()
items := []map[string]any{}
for rows.Next() {
var id, adoptionID int64
var due time.Time
var submitted sql.NullTime
var content, petName, avatar string
var mediaJSON sql.NullString
if rows.Scan(&id, &adoptionID, &due, &submitted, &content, &mediaJSON, &petName, &avatar) != nil {
continue
}
media := []string{}
if mediaJSON.Valid {
_ = json.Unmarshal([]byte(mediaJSON.String), &media)
}
items = append(items, map[string]any{
"id": id, "adoptionId": adoptionID, "dueAt": due.Format(time.RFC3339),
"submitted": submitted.Valid, "content": content, "media": media, "petName": petName, "avatar": avatar,
"overdue": !submitted.Valid && time.Now().After(due),
})
}
reply(w, map[string]any{"items": items})
}
func (a *App) submitAdoptionFollowup(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
fail(w, http.StatusBadRequest, 20001, "编号无效")
return
}
var req struct {
Content string `json:"content"`
Media []string `json:"media"`
}
if decode(r, &req) != nil {
fail(w, http.StatusBadRequest, 20001, "回访格式错误")
return
}
req.Content = strings.TrimSpace(req.Content)
if req.Content == "" && len(req.Media) == 0 {
fail(w, http.StatusBadRequest, 20001, "写一句近况,或者上传一张照片")
return
}
if len([]rune(req.Content)) > 500 || len(req.Media) > 9 {
fail(w, http.StatusBadRequest, 20001, "内容过长或照片过多")
return
}
for _, item := range req.Media {
if !validMessageMediaURL(item, a.config.Environment == "production") {
fail(w, http.StatusBadRequest, 20001, "照片地址无效")
return
}
}
media, _ := json.Marshal(req.Media)
result, err := a.db.ExecContext(r.Context(), `UPDATE pet_adoption_followups SET content=?,media_json=?,submitted_at=NOW(3)
WHERE id=? AND adopter_user_id=? AND submitted_at IS NULL`, req.Content, string(media), id, current(r).ID)
if err != nil {
fail(w, http.StatusInternalServerError, 50001, "提交失败")
return
}
if affected, _ := result.RowsAffected(); affected == 0 {
fail(w, http.StatusNotFound, 30001, "回访不存在或已提交")
return
}
reply(w, map[string]bool{"success": true})
}