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