退款原来只有会员那条路,而且只能全额、只改本地状态。现在代喂任务的退款 能分几次退到退完:每一笔单独记一行(谁退的、多少、走哪条通道、为什么), 订单上的 refunded_cent 跟着累加,退完且未结算的任务才关闭。渠道接通时按 金额原路退回并带独立幂等键,没接通或沙箱支付的登记为"线下已退"——假装 调用了渠道比说实话更糟。已结算的任务不能顺手退:钱在喂养者账上,要退只能 由平台承担,必须显式勾选。 后台从"只能通过或驳回"变成能改能删:宠物档案可代建、可编辑、可软删除 (有进行中任务的删不掉),送养与配种能改文案和城市、能下架能删,代喂任务 能改备注、能取消(托管中的钱必须先退)。每一个写操作都进审计日志。 悬赏按方案里的 A 做:主人自愿加的钱跟着任务一起托管,结算时全额给喂养者, 平台服务费只算在基础报酬上。抽悬赏是喂养者一眼能算出来的事。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
785 lines
30 KiB
Go
785 lines
30 KiB
Go
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
|
|
}
|