package app import ( "context" "database/sql" "encoding/json" "fmt" "log" "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"` TipCent int64 `json:"tipCent"` GrossCent int64 `json:"grossCent"` PayoutCent int64 `json:"payoutCent"` RefundedCent int64 `json:"refundedCent"` Note string `json:"note"` Status string `json:"status"` SitterUserID int64 `json:"sitterUserId"` SitterName string `json:"sitterName"` Conversation int64 `json:"conversationId"` Applications int `json:"applicationsCount"` TotalVisits int `json:"totalVisits"` Done int `json:"completedVisits"` Items []string `json:"items"` Located bool `json:"addressLocated"` 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"` Emergency string `json:"emergency"` VetHospital string `json:"vetHospital"` 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) req.Emergency = strings.TrimSpace(req.Emergency) req.VetHospital = strings.TrimSpace(req.VetHospital) 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 || len([]rune(req.Emergency)) > 100 || len([]rune(req.VetHospital)) > 100 { fail(w, http.StatusBadRequest, 20001, "地址内容过长") return } // 定位可能因为权限、系统或没有地图 SDK 而拿不到。与其把人卡在这一步, // 不如让他把地址存下来,并记住这条地址没有坐标:打卡照常,只是不做距离核验。 located := req.Latitude != 0 && req.Longitude != 0 // 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 } // 紧急联系人是选填的,但填了就和门牌一样加密。 var emergency []byte if req.Emergency != "" { if emergency, err = a.encryptPhone(req.Emergency); err != nil { fail(w, http.StatusInternalServerError, 50001, "保存失败") return } } var latitude, longitude any if located { latitude, longitude = req.Latitude, req.Longitude } result, err := a.db.ExecContext(r.Context(), `INSERT INTO pet_addresses(user_id,city_code,city_name,area_text,detail_cipher,contact_cipher,emergency_cipher,vet_hospital,latitude,longitude) VALUES(?,?,?,?,?,?,?,?,?,?)`, current(r).ID, strings.TrimSpace(req.CityCode), strings.TrimSpace(req.CityName), req.AreaText, detail, contact, emergency, req.VetHospital, latitude, longitude) if err != nil { fail(w, http.StatusInternalServerError, 50001, "保存失败") return } id, _ := result.LastInsertId() reply(w, map[string]any{"id": id, "located": located}) } func (a *App) petAddresses(w http.ResponseWriter, r *http.Request) { rows, err := a.db.QueryContext(r.Context(), `SELECT id,city_code,city_name,area_text,latitude IS NOT NULL FROM pet_addresses WHERE user_id=? AND LENGTH(detail_cipher)>0 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 cityCode, city, area string var located bool if rows.Scan(&id, &cityCode, &city, &area, &located) == nil { items = append(items, map[string]any{ "id": id, "cityCode": cityCode, "cityName": city, "areaText": area, "located": located, }) } } 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, ¬e) 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"` TipCent int64 `json:"tipCent"` 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 var purgeAt sql.NullTime if a.db.QueryRowContext(r.Context(), `SELECT city_code,purge_at FROM pet_addresses WHERE id=? AND user_id=? AND LENGTH(detail_cipher)>0`, req.AddressID, current(r).ID).Scan(&cityCode, &purgeAt) != nil { fail(w, http.StatusNotFound, 30001, "服务地址不存在,请重新填写") return } // 又用上了就别再排队清除。 if purgeAt.Valid { _, _ = a.db.ExecContext(r.Context(), `UPDATE pet_addresses SET purge_at=NULL WHERE id=?`, req.AddressID) } 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 } if req.TipCent < 0 { req.TipCent = 0 } if maxTip := int64(a.configInt(r.Context(), "pet.feed_tip_max_cent", 20000)); req.TipCent > maxTip { fail(w, http.StatusBadRequest, 20001, fmt.Sprintf("悬赏最多 %.0f 元", float64(maxTip)/100)) return } totalVisits := days * req.VisitsPerDay base := req.PriceCent * int64(totalVisits) // 服务费只算在基础报酬上:悬赏是主人给喂养者的,平台不从中抽成。 fee, basePayout := a.feedFeeSplit(r.Context(), base) gross := base + req.TipCent payout := basePayout + req.TipCent 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,tip_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, req.TipCent, 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, "tipCent": req.TipCent, "platformFeeCent": fee, "payoutCent": payout, "totalVisits": totalVisits}) } 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,addr.latitude IS NOT NULL, t.start_date,t.end_date,t.visits_per_day,t.min_minutes,t.price_cent,t.tip_cent,t.gross_cent,t.payout_cent,t.refunded_cent,t.note,t.status, COALESCE(t.sitter_user_id,0),COALESCE(sitter.nickname,''),COALESCE(t.conversation_id,0),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, &item.Located, &start, &end, &item.VisitsPerDay, &item.MinMinutes, &item.PriceCent, &item.TipCent, &item.GrossCent, &item.PayoutCent, &item.RefundedCent, &item.Note, &item.Status, &item.SitterUserID, &item.SitterName, &item.Conversation, &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") { address := a.decryptAddress(r.Context(), task.ID) view["address"] = address } if task.Mine { applications, _ := a.feedApplications(r.Context(), id) view["applications"] = applications } visits, _ := a.feedVisits(r.Context(), id) view["visits"] = visits reply(w, view) } // decryptAddress hands the door number, the phone number and the emergency // contact to the one person who has to go there. An address whose plaintext has // already been purged comes back empty rather than as an error: by then the // task is long over. func (a *App) decryptAddress(ctx context.Context, taskID int64) map[string]any { var detailCipher, contactCipher, emergencyCipher []byte var hospital string if a.db.QueryRowContext(ctx, `SELECT addr.detail_cipher,addr.contact_cipher,addr.emergency_cipher,addr.vet_hospital FROM pet_feed_tasks t JOIN pet_addresses addr ON addr.id=t.address_id WHERE t.id=?`, taskID). Scan(&detailCipher, &contactCipher, &emergencyCipher, &hospital) != nil { return map[string]any{} } address := map[string]any{"vetHospital": hospital} if detail, err := a.decryptPhone(detailCipher); err == nil { address["detail"] = detail } if contact, err := a.decryptPhone(contactCipher); err == nil { address["contact"] = contact } if len(emergencyCipher) > 0 { if emergency, err := a.decryptPhone(emergencyCipher); err == nil { address["emergency"] = emergency } } return address } 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 } // 双方的沟通要留在站内:出了纠纷,聊天记录和打卡照片是同一套证据。 conversationID, convErr := a.bindTaskConversation(r.Context(), id, current(r).ID, sitter) if convErr != nil { log.Printf("pet feed: 绑定会话失败 task=%d: %v", id, convErr) } a.notifyUser(r.Context(), sitter, "system", "你被选中了", "主人选择了你,任务详情里现在可以看到门牌与联系方式", "feed_task", id) reply(w, map[string]any{"success": true, "conversationId": conversationID}) } // bindTaskConversation makes sure the two sides have a direct conversation and // remembers it on the task. It deliberately skips the stranger and block rules // of the normal chat entry point: these two have a contract with each other, // and the platform needs the exchange to happen where it can be reviewed. func (a *App) bindTaskConversation(ctx context.Context, taskID, owner, sitter int64) (int64, error) { first, second := owner, sitter if first > second { first, second = second, first } var id int64 err := a.db.QueryRowContext(ctx, `SELECT conversation_id FROM im_direct_conversations WHERE user1_id=? AND user2_id=?`, first, second).Scan(&id) if err != nil { tx, beginErr := a.db.BeginTx(ctx, nil) if beginErr != nil { return 0, beginErr } defer func() { _ = tx.Rollback() }() result, execErr := tx.ExecContext(ctx, `INSERT INTO im_conversations(conversation_type)VALUES(1)`) if execErr != nil { return 0, execErr } id, _ = result.LastInsertId() if _, execErr = tx.ExecContext(ctx, `INSERT INTO im_direct_conversations(conversation_id,user1_id,user2_id)VALUES(?,?,?)`, id, first, second); execErr != nil { // 并发下另一边可能刚建好,直接用那一条。 if a.db.QueryRowContext(ctx, `SELECT conversation_id FROM im_direct_conversations WHERE user1_id=? AND user2_id=?`, first, second).Scan(&id) != nil { return 0, execErr } return id, a.rememberTaskConversation(ctx, taskID, id) } if _, execErr = tx.ExecContext(ctx, `INSERT INTO im_conversation_members(conversation_id,user_id)VALUES(?,?),(?,?)`, id, first, id, second); execErr != nil { return 0, execErr } if execErr = tx.Commit(); execErr != nil { return 0, execErr } } return id, a.rememberTaskConversation(ctx, taskID, id) } func (a *App) rememberTaskConversation(ctx context.Context, taskID, conversationID int64) error { _, err := a.db.ExecContext(ctx, `UPDATE pet_feed_tasks SET conversation_id=? WHERE id=?`, conversationID, taskID) return err }