原来 107 个后台接口配的是一套只能看的界面:风险中心 6 行代码全是只读, 动态和消息只能一条一条点,运营想发一句公告没有任何入口。 公告与通知:按全体、会员、城市、喂养者、有宠物的用户或指定名单发系统通知, 发之前能看到会发给多少人,可同时走离线推送。撤回只删还没读的那些——已经 读过的假装收回去只会让统计说谎。 资金总览:把"平台账上哪些钱不是自己的"单独摆出来(任务托管中 + 喂养者余额 + 申诉冻结),旁边才是收入、退款和待打款,最后是按产品和按日的收款。 退款统一到一个入口:会员、代喂、保证金都能分几次退到退完,每笔记一行; 退完最后一分钱才撤销买到的东西,半份会员仍然是会员。 风险中心能动手了:按等级和状态筛,行内直接封禁、冻结、加白(必须写理由)、 跳用户详情,事件可以标记跟进完成。 批量处置:动态、消息、举报支持勾选后批量下架/恢复/驳回,一次最多 100 条。 处罚仍然一条一条来——批量封禁是错封人最快的方式。 敏感词统一词库:消息、动态、送养共用一套,命中是拦截还是转人工由运营定; 转人工的命中会在风险中心留一条,管理端还能拿真实文案先试一遍。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
542 lines
21 KiB
Go
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(¬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})
|
|
}
|