Files
kefu/im/backend/internal/app/pet_feed.go
T
Your NameandClaude Opus 5 46a60ffac3 宠物板块二到四期:领养、配种、上门代喂与收益账户
领养是信息服务:没有金额字段,文案里出现收费话术或短期内连续送养都会转人工,
送出时按 30/90 天建好回访任务。配种同样只做信息展示,且默认关闭。

代喂是这个模块里唯一动钱的部分,顺序是它的安全边界:先付款再进大厅,选定
之后才解密门牌与电话,每次上门按到达/喂食/离开留照片,主人确认或超时自动确认
后才结算。结算只走账本,(user, biz_type, biz_id) 唯一键让重复结算付不出第二次;
申诉会冻结自动确认,由客服裁定全付、部分付或全退。

提现按方案默认关闭:收益先只记账,等分账通道与资质到位再在管理端开闸。

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

703 lines
26 KiB
Go

package app
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"math"
"net/http"
"strings"
"time"
)
// The paid half of the module. The order of operations matters more than any
// single rule here: money is taken before a sitter is chosen, the address is
// revealed only after one is, the work leaves evidence, and the payout happens
// once — from a ledger that can be audited afterwards.
const (
feedVisitSteps = "arrive,feed,water,litter,leave"
)
type feedTaskView struct {
ID int64 `json:"id"`
OwnerUserID int64 `json:"ownerUserId"`
OwnerName string `json:"ownerName"`
PetID int64 `json:"petId"`
PetName string `json:"petName"`
Species string `json:"species"`
SpeciesLabel string `json:"speciesLabel"`
PetAvatar string `json:"petAvatar"`
CityName string `json:"cityName"`
AreaText string `json:"areaText"`
StartDate string `json:"startDate"`
EndDate string `json:"endDate"`
VisitsPerDay int `json:"visitsPerDay"`
MinMinutes int `json:"minMinutes"`
PriceCent int64 `json:"priceCent"`
GrossCent int64 `json:"grossCent"`
PayoutCent int64 `json:"payoutCent"`
Note string `json:"note"`
Status string `json:"status"`
SitterUserID int64 `json:"sitterUserId"`
SitterName string `json:"sitterName"`
Applications int `json:"applicationsCount"`
TotalVisits int `json:"totalVisits"`
Done int `json:"completedVisits"`
Items []string `json:"items"`
Mine bool `json:"mine"`
IsSitter bool `json:"isSitter"`
CreatedAt string `json:"createdAt"`
}
var feedItemKinds = map[string]string{
"feed": "喂粮", "water": "换水", "litter": "铲砂", "walk": "遛狗", "medicine": "喂药",
}
func (a *App) feedFeeSplit(ctx context.Context, gross int64) (fee int64, payout int64) {
percent := int64(a.configInt(ctx, "pet.feed_platform_fee_percent", 15))
if percent < 0 || percent > 50 {
percent = 15
}
fee = gross * percent / 100
return fee, gross - fee
}
// --- 地址 -----------------------------------------------------------------
func (a *App) createPetAddress(w http.ResponseWriter, r *http.Request) {
var req struct {
CityCode string `json:"cityCode"`
CityName string `json:"cityName"`
AreaText string `json:"areaText"`
Detail string `json:"detail"`
Contact string `json:"contact"`
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
}
if decode(r, &req) != nil {
fail(w, http.StatusBadRequest, 20001, "地址格式错误")
return
}
req.AreaText = strings.TrimSpace(req.AreaText)
req.Detail = strings.TrimSpace(req.Detail)
req.Contact = strings.TrimSpace(req.Contact)
if req.AreaText == "" || req.Detail == "" || req.Contact == "" {
fail(w, http.StatusBadRequest, 20001, "请填写完整的地址与联系方式")
return
}
if len([]rune(req.AreaText)) > 100 || len([]rune(req.Detail)) > 200 || len([]rune(req.Contact)) > 100 {
fail(w, http.StatusBadRequest, 20001, "地址内容过长")
return
}
if req.Latitude == 0 || req.Longitude == 0 {
fail(w, http.StatusBadRequest, 20001, "请先获取定位,打卡需要与地址比对")
return
}
// Door number and phone are encrypted at rest; only the coarse area is ever
// shown to people browsing the hall.
detail, err := a.encryptPhone(req.Detail)
if err != nil {
fail(w, http.StatusInternalServerError, 50001, "保存失败")
return
}
contact, err := a.encryptPhone(req.Contact)
if err != nil {
fail(w, http.StatusInternalServerError, 50001, "保存失败")
return
}
result, err := a.db.ExecContext(r.Context(), `INSERT INTO pet_addresses(user_id,city_code,city_name,area_text,detail_cipher,contact_cipher,latitude,longitude)
VALUES(?,?,?,?,?,?,?,?)`, current(r).ID, strings.TrimSpace(req.CityCode), strings.TrimSpace(req.CityName), req.AreaText, detail, contact, req.Latitude, req.Longitude)
if err != nil {
fail(w, http.StatusInternalServerError, 50001, "保存失败")
return
}
id, _ := result.LastInsertId()
reply(w, map[string]any{"id": id})
}
func (a *App) petAddresses(w http.ResponseWriter, r *http.Request) {
rows, err := a.db.QueryContext(r.Context(), `SELECT id,city_name,area_text FROM pet_addresses WHERE user_id=? ORDER BY id DESC`, current(r).ID)
if err != nil {
fail(w, http.StatusInternalServerError, 50001, "查询地址失败")
return
}
defer rows.Close()
items := []map[string]any{}
for rows.Next() {
var id int64
var city, area string
if rows.Scan(&id, &city, &area) == nil {
items = append(items, map[string]any{"id": id, "cityName": city, "areaText": area})
}
}
reply(w, map[string]any{"items": items})
}
// --- 喂养者 ---------------------------------------------------------------
func (a *App) sitterProfile(w http.ResponseWriter, r *http.Request) {
var cityCode, cityName, intro, note string
var radius, deposit, ratingTotal, ratingCount, completed, status int
err := a.db.QueryRowContext(r.Context(), `SELECT city_code,city_name,radius_m,intro,deposit_cent,rating_total,rating_count,completed_count,status,status_note
FROM pet_sitters WHERE user_id=?`, current(r).ID).Scan(&cityCode, &cityName, &radius, &intro, &deposit, &ratingTotal, &ratingCount, &completed, &status, &note)
if err == sql.ErrNoRows {
reply(w, map[string]any{"registered": false, "depositRequiredCent": a.configInt(r.Context(), "pet.sitter_deposit_cent", 20000)})
return
}
if err != nil {
fail(w, http.StatusInternalServerError, 50001, "查询失败")
return
}
rating := 0.0
if ratingCount > 0 {
rating = math.Round(float64(ratingTotal)/float64(ratingCount)*10) / 10
}
reply(w, map[string]any{
"registered": true, "cityCode": cityCode, "cityName": cityName, "radiusM": radius, "intro": intro,
"depositCent": deposit, "depositRequiredCent": a.configInt(r.Context(), "pet.sitter_deposit_cent", 20000),
"rating": rating, "ratingCount": ratingCount, "completedCount": completed,
"status": status, "statusNote": note,
})
}
func (a *App) applySitter(w http.ResponseWriter, r *http.Request) {
var req struct {
CityCode string `json:"cityCode"`
CityName string `json:"cityName"`
RadiusM int `json:"radiusM"`
Intro string `json:"intro"`
Certs []string `json:"certs"`
}
if decode(r, &req) != nil {
fail(w, http.StatusBadRequest, 20001, "资料格式错误")
return
}
req.Intro = strings.TrimSpace(req.Intro)
if len([]rune(req.Intro)) < 10 || len([]rune(req.Intro)) > 500 {
fail(w, http.StatusBadRequest, 20001, "请介绍一下你的养宠经验,10 到 500 字")
return
}
if req.RadiusM < 1000 || req.RadiusM > 50000 {
req.RadiusM = 5000
}
// Entering someone's home is not something an anonymous account may do.
if _, verified := a.verifiedRealName(r.Context(), current(r).ID); !verified {
fail(w, http.StatusForbidden, 30003, "上门服务需要先完成实名认证")
return
}
certs, _ := json.Marshal(req.Certs)
_, err := a.db.ExecContext(r.Context(), `INSERT INTO pet_sitters(user_id,city_code,city_name,radius_m,intro,certs_json,status)
VALUES(?,?,?,?,?,?,0) ON DUPLICATE KEY UPDATE city_code=VALUES(city_code),city_name=VALUES(city_name),
radius_m=VALUES(radius_m),intro=VALUES(intro),certs_json=VALUES(certs_json),status=IF(status=1,1,0),status_note=''`,
current(r).ID, strings.TrimSpace(req.CityCode), strings.TrimSpace(req.CityName), req.RadiusM, req.Intro, string(certs))
if err != nil {
fail(w, http.StatusInternalServerError, 50001, "提交失败")
return
}
reply(w, map[string]bool{"success": true})
}
// --- 任务 -----------------------------------------------------------------
func (a *App) createFeedTask(w http.ResponseWriter, r *http.Request) {
if !a.configBool(r.Context(), "pet.feed_enabled", true) {
fail(w, http.StatusForbidden, 30002, "上门代喂暂未开放")
return
}
var req struct {
PetID int64 `json:"petId"`
AddressID int64 `json:"addressId"`
StartDate string `json:"startDate"`
EndDate string `json:"endDate"`
VisitsPerDay int `json:"visitsPerDay"`
MinMinutes int `json:"minMinutes"`
PriceCent int64 `json:"priceCent"`
Note string `json:"note"`
Items []struct {
Kind string `json:"kind"`
Amount string `json:"amount"`
} `json:"items"`
}
if decode(r, &req) != nil {
fail(w, http.StatusBadRequest, 20001, "任务格式错误")
return
}
start, err := time.Parse("2006-01-02", strings.TrimSpace(req.StartDate))
if err != nil {
fail(w, http.StatusBadRequest, 20001, "开始日期格式应为 2006-01-02")
return
}
end, err := time.Parse("2006-01-02", strings.TrimSpace(req.EndDate))
if err != nil {
fail(w, http.StatusBadRequest, 20001, "结束日期格式应为 2006-01-02")
return
}
if end.Before(start) {
fail(w, http.StatusBadRequest, 20001, "结束日期不能早于开始日期")
return
}
days := int(end.Sub(start).Hours()/24) + 1
// Beyond a couple of weeks this stops being "feed my cat while I travel" and
// becomes boarding, which is a different product with a different licence.
if maxDays := a.configInt(r.Context(), "pet.feed_max_days", 14); days > maxDays {
fail(w, http.StatusBadRequest, 20001, fmt.Sprintf("单个任务最长 %d 天", maxDays))
return
}
if req.VisitsPerDay < 1 || req.VisitsPerDay > 4 {
fail(w, http.StatusBadRequest, 20001, "每天上门次数应在 1 到 4 之间")
return
}
if req.MinMinutes < 10 || req.MinMinutes > 180 {
req.MinMinutes = 20
}
minPrice := int64(a.configInt(r.Context(), "pet.feed_min_price_cent", 2000))
maxPrice := int64(a.configInt(r.Context(), "pet.feed_max_price_cent", 50000))
if req.PriceCent < minPrice || req.PriceCent > maxPrice {
fail(w, http.StatusBadRequest, 20001, fmt.Sprintf("单次报酬应在 %.0f 到 %.0f 元之间", float64(minPrice)/100, float64(maxPrice)/100))
return
}
if len(req.Items) == 0 {
fail(w, http.StatusBadRequest, 20001, "请至少添加一项任务内容")
return
}
for _, item := range req.Items {
if _, ok := feedItemKinds[item.Kind]; !ok {
fail(w, http.StatusBadRequest, 20001, "任务内容类型无效")
return
}
}
var cityCode string
if a.db.QueryRowContext(r.Context(), `SELECT city_code FROM pet_addresses WHERE id=? AND user_id=?`, req.AddressID, current(r).ID).Scan(&cityCode) != nil {
fail(w, http.StatusNotFound, 30001, "服务地址不存在")
return
}
var petName string
if a.db.QueryRowContext(r.Context(), `SELECT name FROM pets WHERE id=? AND owner_user_id=? AND status=1 AND deleted_at IS NULL`, req.PetID, current(r).ID).Scan(&petName) != nil {
fail(w, http.StatusNotFound, 30001, "宠物不存在")
return
}
totalVisits := days * req.VisitsPerDay
gross := req.PriceCent * int64(totalVisits)
fee, payout := a.feedFeeSplit(r.Context(), gross)
tx, err := a.db.BeginTx(r.Context(), nil)
if err != nil {
fail(w, http.StatusInternalServerError, 50001, "创建失败")
return
}
defer func() { _ = tx.Rollback() }()
result, err := tx.ExecContext(r.Context(), `INSERT INTO pet_feed_tasks
(owner_user_id,pet_id,address_id,city_code,start_date,end_date,visits_per_day,min_minutes,price_cent,gross_cent,platform_fee_cent,payout_cent,note,total_visits)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
current(r).ID, req.PetID, req.AddressID, cityCode, start, end, req.VisitsPerDay, req.MinMinutes,
req.PriceCent, gross, fee, payout, strings.TrimSpace(req.Note), totalVisits)
if err != nil {
fail(w, http.StatusInternalServerError, 50001, "创建失败")
return
}
taskID, _ := result.LastInsertId()
for _, item := range req.Items {
if _, err = tx.ExecContext(r.Context(), `INSERT INTO pet_feed_task_items(task_id,kind,amount_text) VALUES(?,?,?)`,
taskID, item.Kind, strings.TrimSpace(item.Amount)); err != nil {
fail(w, http.StatusInternalServerError, 50001, "创建失败")
return
}
}
// Every visit is planned up front, so both sides can see the schedule before
// any money moves.
for day := 0; day < days; day++ {
date := start.AddDate(0, 0, day)
for sequence := 1; sequence <= req.VisitsPerDay; sequence++ {
if _, err = tx.ExecContext(r.Context(), `INSERT INTO pet_feed_visits(task_id,visit_date,sequence) VALUES(?,?,?)`, taskID, date, sequence); err != nil {
fail(w, http.StatusInternalServerError, 50001, "创建失败")
return
}
}
}
if tx.Commit() != nil {
fail(w, http.StatusInternalServerError, 50001, "创建失败")
return
}
reply(w, map[string]any{"id": taskID, "grossCent": gross, "platformFeeCent": fee, "payoutCent": payout, "totalVisits": totalVisits})
}
// escrowFeedTask stands in for the payment callback: the task only becomes
// visible to sitters once the money is held. Wiring it to the real gateway
// replaces the body, not the state machine.
func (a *App) escrowFeedTask(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_feed_tasks SET status='ESCROWED' WHERE id=? AND owner_user_id=? AND status='CREATED'`, id, current(r).ID)
if err != nil {
fail(w, http.StatusInternalServerError, 50001, "操作失败")
return
}
if affected, _ := result.RowsAffected(); affected == 0 {
fail(w, http.StatusBadRequest, 20001, "任务状态不允许支付")
return
}
reply(w, map[string]bool{"success": true})
}
const feedTaskColumns = `t.id,t.owner_user_id,owner.nickname,t.pet_id,p.name,p.species,p.avatar_url,addr.city_name,addr.area_text,
t.start_date,t.end_date,t.visits_per_day,t.min_minutes,t.price_cent,t.gross_cent,t.payout_cent,t.note,t.status,
COALESCE(t.sitter_user_id,0),COALESCE(sitter.nickname,''),t.applications_count,t.total_visits,t.completed_visits,t.created_at`
func (a *App) scanFeedTasks(ctx context.Context, rows *sql.Rows, viewer int64) []feedTaskView {
items := []feedTaskView{}
ids := []any{}
placeholders := []string{}
for rows.Next() {
var item feedTaskView
var start, end time.Time
var created time.Time
if rows.Scan(&item.ID, &item.OwnerUserID, &item.OwnerName, &item.PetID, &item.PetName, &item.Species, &item.PetAvatar,
&item.CityName, &item.AreaText, &start, &end, &item.VisitsPerDay, &item.MinMinutes, &item.PriceCent,
&item.GrossCent, &item.PayoutCent, &item.Note, &item.Status, &item.SitterUserID, &item.SitterName,
&item.Applications, &item.TotalVisits, &item.Done, &created) != nil {
continue
}
item.SpeciesLabel = petSpecies[item.Species]
item.StartDate = start.Format("2006-01-02")
item.EndDate = end.Format("2006-01-02")
item.CreatedAt = created.Format(time.RFC3339)
item.Mine = item.OwnerUserID == viewer
item.IsSitter = item.SitterUserID == viewer
item.Items = []string{}
items = append(items, item)
ids = append(ids, item.ID)
placeholders = append(placeholders, "?")
}
if len(ids) == 0 {
return items
}
itemRows, err := a.db.QueryContext(ctx, `SELECT task_id,kind,amount_text FROM pet_feed_task_items WHERE task_id IN (`+strings.Join(placeholders, ",")+`) ORDER BY id`, ids...)
if err == nil {
defer itemRows.Close()
for itemRows.Next() {
var taskID int64
var kind, amount string
if itemRows.Scan(&taskID, &kind, &amount) != nil {
continue
}
label := feedItemKinds[kind]
if amount != "" {
label += " " + amount
}
for index := range items {
if items[index].ID == taskID {
items[index].Items = append(items[index].Items, label)
}
}
}
}
return items
}
func (a *App) feedTasks(w http.ResponseWriter, r *http.Request) {
_, size, offset := pageOptions(r)
scope := strings.TrimSpace(r.URL.Query().Get("scope"))
where := ""
args := []any{}
switch scope {
case "mine":
where = " WHERE t.owner_user_id=?"
args = append(args, current(r).ID)
case "sitting":
where = " WHERE t.sitter_user_id=?"
args = append(args, current(r).ID)
default:
// The hall only shows funded, unassigned tasks: nobody should spend time
// on a task that has not been paid for.
where = " WHERE t.status='ESCROWED'"
if city := strings.TrimSpace(r.URL.Query().Get("cityCode")); city != "" {
where += " AND t.city_code=?"
args = append(args, city)
}
}
rows, err := a.db.QueryContext(r.Context(), `SELECT `+feedTaskColumns+` FROM pet_feed_tasks t
JOIN pets p ON p.id=t.pet_id JOIN pet_addresses addr ON addr.id=t.address_id
JOIN user_profiles owner ON owner.user_id=t.owner_user_id
LEFT JOIN user_profiles sitter ON sitter.user_id=t.sitter_user_id`+where+` ORDER BY t.id DESC LIMIT ? OFFSET ?`,
append(args, size+1, offset)...)
if err != nil {
fail(w, http.StatusInternalServerError, 50001, "查询任务失败")
return
}
defer rows.Close()
items := a.scanFeedTasks(r.Context(), rows, current(r).ID)
hasMore := len(items) > size
if hasMore {
items = items[:size]
}
reply(w, map[string]any{"items": items, "hasMore": hasMore})
}
func (a *App) feedTaskDetail(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 `+feedTaskColumns+` FROM pet_feed_tasks t
JOIN pets p ON p.id=t.pet_id JOIN pet_addresses addr ON addr.id=t.address_id
JOIN user_profiles owner ON owner.user_id=t.owner_user_id
LEFT JOIN user_profiles sitter ON sitter.user_id=t.sitter_user_id WHERE t.id=?`, id)
if queryErr != nil {
fail(w, http.StatusInternalServerError, 50001, "查询失败")
return
}
defer rows.Close()
items := a.scanFeedTasks(r.Context(), rows, current(r).ID)
if len(items) == 0 {
fail(w, http.StatusNotFound, 30001, "任务不存在")
return
}
task := items[0]
view := map[string]any{"task": task}
// The exact address and phone number are released only to the person who has
// to go there, and only once they have been chosen.
if task.IsSitter && (task.Status == "ASSIGNED" || task.Status == "SERVING" || task.Status == "COMPLETED") {
detail, contact := a.decryptAddress(r.Context(), task.ID)
view["address"] = map[string]any{"detail": detail, "contact": contact}
}
if task.Mine {
applications, _ := a.feedApplications(r.Context(), id)
view["applications"] = applications
}
visits, _ := a.feedVisits(r.Context(), id)
view["visits"] = visits
reply(w, view)
}
func (a *App) decryptAddress(ctx context.Context, taskID int64) (string, string) {
var detailCipher, contactCipher []byte
if a.db.QueryRowContext(ctx, `SELECT addr.detail_cipher,addr.contact_cipher FROM pet_feed_tasks t JOIN pet_addresses addr ON addr.id=t.address_id WHERE t.id=?`, taskID).
Scan(&detailCipher, &contactCipher) != nil {
return "", ""
}
detail, err := a.decryptPhone(detailCipher)
if err != nil {
return "", ""
}
contact, err := a.decryptPhone(contactCipher)
if err != nil {
return detail, ""
}
return detail, contact
}
func (a *App) feedApplications(ctx context.Context, taskID int64) ([]map[string]any, error) {
rows, err := a.db.QueryContext(ctx, `SELECT ap.id,ap.sitter_user_id,profile.nickname,profile.avatar_url,ap.message,ap.status,ap.created_at,
COALESCE(s.completed_count,0),COALESCE(s.rating_total,0),COALESCE(s.rating_count,0)
FROM pet_feed_applications ap JOIN user_profiles profile ON profile.user_id=ap.sitter_user_id
LEFT JOIN pet_sitters s ON s.user_id=ap.sitter_user_id WHERE ap.task_id=? ORDER BY ap.id DESC`, taskID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []map[string]any{}
for rows.Next() {
var id, userID int64
var nickname, avatar, message string
var status, completed, ratingTotal, ratingCount int
var created time.Time
if rows.Scan(&id, &userID, &nickname, &avatar, &message, &status, &created, &completed, &ratingTotal, &ratingCount) != nil {
continue
}
rating := 0.0
if ratingCount > 0 {
rating = math.Round(float64(ratingTotal)/float64(ratingCount)*10) / 10
}
items = append(items, map[string]any{
"id": id, "sitterUserId": userID, "nickname": nickname, "avatar": avatarThumbnailURL(avatar),
"message": message, "status": status, "completedCount": completed, "rating": rating,
"createdAt": created.Format(time.RFC3339),
})
}
return items, nil
}
func (a *App) feedVisits(ctx context.Context, taskID int64) ([]map[string]any, error) {
rows, err := a.db.QueryContext(ctx, `SELECT id,visit_date,sequence,started_at,finished_at,status,abnormal FROM pet_feed_visits WHERE task_id=? ORDER BY visit_date,sequence`, taskID)
if err != nil {
return nil, err
}
defer rows.Close()
visits := []map[string]any{}
ids := []any{}
placeholders := []string{}
for rows.Next() {
var id int64
var date time.Time
var sequence, status, abnormal int
var started, finished sql.NullTime
if rows.Scan(&id, &date, &sequence, &started, &finished, &status, &abnormal) != nil {
continue
}
visit := map[string]any{
"id": id, "date": date.Format("2006-01-02"), "sequence": sequence, "status": status,
"abnormal": abnormal == 1, "checkins": []map[string]any{},
}
if started.Valid {
visit["startedAt"] = started.Time.Format(time.RFC3339)
}
if finished.Valid {
visit["finishedAt"] = finished.Time.Format(time.RFC3339)
}
visits = append(visits, visit)
ids = append(ids, id)
placeholders = append(placeholders, "?")
}
if len(ids) == 0 {
return visits, nil
}
checkinRows, err := a.db.QueryContext(ctx, `SELECT visit_id,step,media_url,distance_m,captured_at,verdict FROM pet_feed_checkins WHERE visit_id IN (`+strings.Join(placeholders, ",")+`) ORDER BY id`, ids...)
if err != nil {
return visits, nil
}
defer checkinRows.Close()
for checkinRows.Next() {
var visitID int64
var step, media, verdict string
var distance sql.NullInt64
var captured time.Time
if checkinRows.Scan(&visitID, &step, &media, &distance, &captured, &verdict) != nil {
continue
}
for index := range visits {
if visits[index]["id"] == visitID {
list := visits[index]["checkins"].([]map[string]any)
visits[index]["checkins"] = append(list, map[string]any{
"step": step, "media": media, "distanceM": distance.Int64,
"capturedAt": captured.Format(time.RFC3339), "verdict": verdict,
})
}
}
}
return visits, nil
}
func (a *App) applyFeedTask(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
fail(w, http.StatusBadRequest, 20001, "编号无效")
return
}
var req struct {
Message string `json:"message"`
}
if decode(r, &req) != nil {
fail(w, http.StatusBadRequest, 20001, "格式错误")
return
}
var status int
var deposit int
if a.db.QueryRowContext(r.Context(), `SELECT status,deposit_cent FROM pet_sitters WHERE user_id=?`, current(r).ID).Scan(&status, &deposit) != nil {
fail(w, http.StatusForbidden, 30003, "请先申请成为喂养者")
return
}
if status != 1 {
fail(w, http.StatusForbidden, 30003, "喂养者资格审核通过后才能接单")
return
}
if required := a.configInt(r.Context(), "pet.sitter_deposit_cent", 20000); deposit < required {
fail(w, http.StatusForbidden, 30003, fmt.Sprintf("请先缴纳 %.0f 元保证金", float64(required)/100))
return
}
var owner int64
var taskStatus string
if a.db.QueryRowContext(r.Context(), `SELECT owner_user_id,status FROM pet_feed_tasks WHERE id=?`, id).Scan(&owner, &taskStatus) != nil {
fail(w, http.StatusNotFound, 30001, "任务不存在")
return
}
if owner == current(r).ID {
fail(w, http.StatusBadRequest, 20001, "不能接自己发布的任务")
return
}
if taskStatus != "ESCROWED" {
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(), `INSERT INTO pet_feed_applications(task_id,sitter_user_id,message) VALUES(?,?,?)`,
id, current(r).ID, strings.TrimSpace(req.Message)); err != nil {
fail(w, http.StatusBadRequest, 20001, "你已经报名过这个任务")
return
}
if _, err = tx.ExecContext(r.Context(), `UPDATE pet_feed_tasks SET applications_count=applications_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", "有人接单了", "有喂养者报名了你的代喂任务,去看看是否合适", "feed_task", id)
reply(w, map[string]bool{"success": true})
}
func (a *App) assignFeedTask(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"`
}
if decode(r, &req) != nil || req.ApplicationID == 0 {
fail(w, http.StatusBadRequest, 20001, "参数错误")
return
}
var owner int64
var status string
if a.db.QueryRowContext(r.Context(), `SELECT owner_user_id,status FROM pet_feed_tasks WHERE id=?`, id).Scan(&owner, &status) != nil || owner != current(r).ID {
fail(w, http.StatusNotFound, 30001, "任务不存在")
return
}
if status != "ESCROWED" {
fail(w, http.StatusBadRequest, 20001, "任务状态不允许选定")
return
}
var sitter int64
if a.db.QueryRowContext(r.Context(), `SELECT sitter_user_id FROM pet_feed_applications WHERE id=? AND task_id=?`, req.ApplicationID, id).Scan(&sitter) != nil {
fail(w, http.StatusNotFound, 30001, "报名不存在")
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_feed_applications SET status=1 WHERE id=?`, req.ApplicationID); err != nil {
fail(w, http.StatusInternalServerError, 50001, "操作失败")
return
}
if _, err = tx.ExecContext(r.Context(), `UPDATE pet_feed_applications SET status=2 WHERE task_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_feed_tasks SET status='ASSIGNED',sitter_user_id=? WHERE id=? AND status='ESCROWED'`, sitter, id); err != nil {
fail(w, http.StatusInternalServerError, 50001, "操作失败")
return
}
if tx.Commit() != nil {
fail(w, http.StatusInternalServerError, 50001, "操作失败")
return
}
a.notifyUser(r.Context(), sitter, "system", "你被选中了", "主人选择了你,任务详情里现在可以看到门牌与联系方式", "feed_task", id)
reply(w, map[string]bool{"success": true})
}