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(¬e) 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}) }