package app import ( "context" "database/sql" "encoding/json" "fmt" "log" "net/http" "strings" "time" ) // Performing the task, proving it happened, and paying for it. Split from // pet_feed.go because this half is where the money is: everything here has to // survive being called twice. // --- 打卡 ----------------------------------------------------------------- // checkinFeedVisit records one step of one visit. The verdict is advisory: we // never block a sitter who is standing in the right kitchen with a bad GPS fix, // but we do write down what the phone reported, so a later dispute has facts // instead of two conflicting stories. func (a *App) checkinFeedVisit(w http.ResponseWriter, r *http.Request) { visitID, err := pathID(r) if err != nil { fail(w, http.StatusBadRequest, 20001, "编号无效") return } var req struct { Step string `json:"step"` MediaURL string `json:"mediaUrl"` Latitude float64 `json:"latitude"` Longitude float64 `json:"longitude"` CapturedAt string `json:"capturedAt"` Note string `json:"note"` Abnormal bool `json:"abnormal"` } if decode(r, &req) != nil { fail(w, http.StatusBadRequest, 20001, "打卡格式错误") return } req.Step = strings.TrimSpace(req.Step) if !strings.Contains(","+feedVisitSteps+",", ","+req.Step+",") { fail(w, http.StatusBadRequest, 20001, "打卡环节无效") return } if !validMessageMediaURL(req.MediaURL, a.config.Environment == "production") { fail(w, http.StatusBadRequest, 20001, "请上传本次打卡的照片") return } var taskID, sitter int64 var status string var visitStatus int var visitDate time.Time var addressLat, addressLng sql.NullFloat64 if a.db.QueryRowContext(r.Context(), `SELECT v.task_id,COALESCE(t.sitter_user_id,0),t.status,v.status,v.visit_date,addr.latitude,addr.longitude FROM pet_feed_visits v JOIN pet_feed_tasks t ON t.id=v.task_id JOIN pet_addresses addr ON addr.id=t.address_id WHERE v.id=?`, visitID).Scan(&taskID, &sitter, &status, &visitStatus, &visitDate, &addressLat, &addressLng) != nil { fail(w, http.StatusNotFound, 30001, "服务记录不存在") return } if sitter != current(r).ID { fail(w, http.StatusForbidden, 30003, "只有接单的喂养者可以打卡") return } if status != "ASSIGNED" && status != "SERVING" { fail(w, http.StatusBadRequest, 20001, "当前任务状态不能打卡") return } if visitStatus == 2 { fail(w, http.StatusBadRequest, 20001, "这次上门已经完成") return } captured := time.Now() if parsed, parseErr := time.Parse(time.RFC3339, strings.TrimSpace(req.CapturedAt)); parseErr == nil { captured = parsed } // 顺序与间隔:一次上门要按到达、喂食、离开走完,喂食离到达太近 // 说明照片是一口气摆拍的,而不是真的照顾了一会儿。 if message := a.checkinOrderError(r.Context(), visitID, req.Step, captured); message != "" { fail(w, http.StatusBadRequest, 20001, message) return } verdict := "ok" var distance sql.NullInt64 if addressLat.Valid && addressLng.Valid && req.Latitude != 0 && req.Longitude != 0 { metres := int64(haversine(req.Latitude, req.Longitude, addressLat.Float64, addressLng.Float64) * 1000) distance = sql.NullInt64{Int64: metres, Valid: true} if metres > int64(a.configInt(r.Context(), "pet.feed_checkin_max_distance_m", 300)) { verdict = "far" } } // A photo taken this morning and uploaded tonight is not evidence of tonight's // visit; flag it rather than reject it, and let the owner see the flag. delay := time.Since(captured) if delay < 0 { delay = -delay } if verdict == "ok" && delay > time.Duration(a.configInt(r.Context(), "pet.feed_checkin_max_delay_min", 5))*time.Minute { verdict = "stale" } 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_checkins(visit_id,task_id,step,media_url,latitude,longitude,distance_m,captured_at,verdict) VALUES(?,?,?,?,?,?,?,?,?)`, visitID, taskID, req.Step, strings.TrimSpace(req.MediaURL), nullableFloat(req.Latitude), nullableFloat(req.Longitude), distance, captured, verdict); err != nil { if isDuplicateKeyError(err) { fail(w, http.StatusBadRequest, 20001, "这个环节已经打过卡了") return } fail(w, http.StatusInternalServerError, 50001, "打卡失败") return } if _, err = tx.ExecContext(r.Context(), `UPDATE pet_feed_visits SET status=IF(status=0,1,status),started_at=COALESCE(started_at,?), abnormal=IF(?,1,abnormal),abnormal_note=IF(?,?,abnormal_note) WHERE id=?`, captured, req.Abnormal, req.Abnormal, strings.TrimSpace(req.Note), visitID); err != nil { fail(w, http.StatusInternalServerError, 50001, "打卡失败") return } if _, err = tx.ExecContext(r.Context(), `UPDATE pet_feed_tasks SET status='SERVING' WHERE id=? AND status='ASSIGNED'`, taskID); err != nil { fail(w, http.StatusInternalServerError, 50001, "打卡失败") return } if tx.Commit() != nil { fail(w, http.StatusInternalServerError, 50001, "打卡失败") return } if req.Abnormal { var owner int64 _ = a.db.QueryRowContext(r.Context(), `SELECT owner_user_id FROM pet_feed_tasks WHERE id=?`, taskID).Scan(&owner) if owner > 0 { body := "本次上门有异常情况,请尽快查看照片与说明" if note := strings.TrimSpace(req.Note); note != "" { body = note } a.notifyUser(r.Context(), owner, "system", "喂养者报告了异常", body, "feed_task", taskID) // 主人可能在开会、在飞机上,所以异常同时落日志,值班的人能看到。 log.Printf("pet feed abnormal: task=%d visit=%d sitter=%d note=%q", taskID, visitID, sitter, strings.TrimSpace(req.Note)) } } reply(w, map[string]any{"verdict": verdict, "distanceM": distance.Int64}) } // checkinOrderError returns the reason this step cannot be photographed yet, or // an empty string when it can. func (a *App) checkinOrderError(ctx context.Context, visitID int64, step string, captured time.Time) string { done := map[string]time.Time{} rows, err := a.db.QueryContext(ctx, `SELECT step,captured_at FROM pet_feed_checkins WHERE visit_id=?`, visitID) if err != nil { return "" } defer rows.Close() for rows.Next() { var name string var at time.Time if rows.Scan(&name, &at) == nil { done[name] = at } } arrived, hasArrived := done["arrive"] if step != "arrive" && !hasArrived { return "请先在门口完成到达打卡" } if step == "leave" { if _, fed := done["feed"]; !fed { return "离开前请先完成喂食打卡" } } if step == "feed" && hasArrived { gap := a.configInt(ctx, "pet.feed_checkin_min_gap_seconds", 120) if captured.Sub(arrived) < time.Duration(gap)*time.Second { return fmt.Sprintf("到达后请至少间隔 %d 分钟再拍喂食照片", (gap+59)/60) } } return "" } func nullableFloat(value float64) sql.NullFloat64 { return sql.NullFloat64{Float64: value, Valid: value != 0} } // finishFeedVisit closes one visit once every required step has a photo. The // last visit moves the whole task to COMPLETED and starts the confirmation // clock. func (a *App) finishFeedVisit(w http.ResponseWriter, r *http.Request) { visitID, err := pathID(r) if err != nil { fail(w, http.StatusBadRequest, 20001, "编号无效") return } var taskID, sitter, owner int64 var status string var visitStatus, totalVisits int if a.db.QueryRowContext(r.Context(), `SELECT v.task_id,COALESCE(t.sitter_user_id,0),t.owner_user_id,t.status,v.status,t.total_visits FROM pet_feed_visits v JOIN pet_feed_tasks t ON t.id=v.task_id WHERE v.id=?`, visitID). Scan(&taskID, &sitter, &owner, &status, &visitStatus, &totalVisits) != nil { fail(w, http.StatusNotFound, 30001, "服务记录不存在") return } if sitter != current(r).ID { fail(w, http.StatusForbidden, 30003, "只有接单的喂养者可以提交") return } if visitStatus == 2 { fail(w, http.StatusBadRequest, 20001, "这次上门已经完成") return } var steps int _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM pet_feed_checkins WHERE visit_id=? AND step IN ('arrive','feed','leave')`, visitID).Scan(&steps) if steps < 3 { 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() }() result, err := tx.ExecContext(r.Context(), `UPDATE pet_feed_visits SET status=2,finished_at=NOW(3) WHERE id=? AND status<>2`, visitID) if err != nil { fail(w, http.StatusInternalServerError, 50001, "提交失败") return } if affected, _ := result.RowsAffected(); affected == 0 { fail(w, http.StatusBadRequest, 20001, "这次上门已经完成") return } if _, err = tx.ExecContext(r.Context(), `UPDATE pet_feed_tasks SET completed_visits=completed_visits+1 WHERE id=?`, taskID); err != nil { fail(w, http.StatusInternalServerError, 50001, "提交失败") return } var done int if err = tx.QueryRowContext(r.Context(), `SELECT completed_visits FROM pet_feed_tasks WHERE id=?`, taskID).Scan(&done); err != nil { fail(w, http.StatusInternalServerError, 50001, "提交失败") return } finished := done >= totalVisits if finished { hours := a.configInt(r.Context(), "pet.feed_confirm_hours", 24) if _, err = tx.ExecContext(r.Context(), `UPDATE pet_feed_tasks SET status='COMPLETED',confirm_due_at=DATE_ADD(NOW(3),INTERVAL ? HOUR) WHERE id=? AND status IN ('ASSIGNED','SERVING')`, hours, taskID); err != nil { fail(w, http.StatusInternalServerError, 50001, "提交失败") return } } if tx.Commit() != nil { fail(w, http.StatusInternalServerError, 50001, "提交失败") return } if finished { a.notifyUser(r.Context(), owner, "system", "代喂任务已完成", "所有上门都已完成,请查看照片并确认,超时会自动确认", "feed_task", taskID) } reply(w, map[string]any{"completedVisits": done, "totalVisits": totalVisits, "taskCompleted": finished}) } // --- 结算 ----------------------------------------------------------------- // settleFeedTask is the only path money takes to a sitter. It is written so // that calling it twice — by the owner and by the auto-confirm worker at the // same moment — pays exactly once: the row lock plus status guard rejects the // loser, and the ledger's unique key catches anything the guard misses. func (a *App) settleFeedTask(ctx context.Context, taskID int64, reason string) error { tx, err := a.db.BeginTx(ctx, nil) if err != nil { return err } defer func() { _ = tx.Rollback() }() var sitter sql.NullInt64 var payout int64 var status string if err = tx.QueryRowContext(ctx, `SELECT sitter_user_id,payout_cent,status FROM pet_feed_tasks WHERE id=? FOR UPDATE`, taskID).Scan(&sitter, &payout, &status); err != nil { return err } if status != "COMPLETED" && status != "ARBITRATED" { return fmt.Errorf("任务状态不允许结算") } if !sitter.Valid || sitter.Int64 == 0 || payout <= 0 { return fmt.Errorf("任务没有可结算的喂养者") } if err = bookLedger(ctx, tx, walletEntry{ UserID: sitter.Int64, BizType: "feed_settle", BizID: taskID, Direction: 1, AmountCen: payout, Memo: reason, }); err != nil && err != errLedgerDuplicate { return err } if _, err = tx.ExecContext(ctx, `UPDATE pet_feed_tasks SET status='SETTLED',settled_at=NOW(3) WHERE id=?`, taskID); err != nil { return err } if _, err = tx.ExecContext(ctx, `UPDATE pet_sitters SET completed_count=completed_count+1 WHERE user_id=?`, sitter.Int64); err != nil { return err } return tx.Commit() } func (a *App) confirmFeedTask(w http.ResponseWriter, r *http.Request) { id, err := pathID(r) if err != nil { fail(w, http.StatusBadRequest, 20001, "编号无效") return } var owner, sitter int64 var status string var payout int64 if a.db.QueryRowContext(r.Context(), `SELECT owner_user_id,COALESCE(sitter_user_id,0),status,payout_cent FROM pet_feed_tasks WHERE id=?`, id). Scan(&owner, &sitter, &status, &payout) != nil { fail(w, http.StatusNotFound, 30001, "任务不存在") return } if owner != current(r).ID { fail(w, http.StatusForbidden, 30003, "只有发布任务的人可以确认") return } if status != "COMPLETED" { fail(w, http.StatusBadRequest, 20001, "任务状态不允许确认") return } if err = a.settleFeedTask(r.Context(), id, "代喂服务收入"); err != nil { fail(w, http.StatusInternalServerError, 50001, "结算失败") return } a.notifyUser(r.Context(), sitter, "system", "代喂报酬已到账", fmt.Sprintf("主人已确认服务,%.2f 元已记入你的收益账户", float64(payout)/100), "wallet", id) reply(w, map[string]bool{"success": true}) } // autoConfirmFeedTasks settles what the owner never got around to confirming. // Without it a sitter's money would sit hostage to an owner who simply stopped // opening the app. func (a *App) autoConfirmFeedTasks(ctx context.Context) int { rows, err := a.db.QueryContext(ctx, `SELECT id,COALESCE(sitter_user_id,0),payout_cent FROM pet_feed_tasks WHERE status='COMPLETED' AND confirm_due_at IS NOT NULL AND confirm_due_at<=NOW(3) LIMIT 100`) if err != nil { return 0 } type pending struct { id int64 sitter int64 payout int64 } tasks := []pending{} for rows.Next() { var item pending if rows.Scan(&item.id, &item.sitter, &item.payout) == nil { tasks = append(tasks, item) } } rows.Close() settled := 0 for _, task := range tasks { if a.settleFeedTask(ctx, task.id, "代喂服务收入(超时自动确认)") == nil { settled++ a.notifyUser(ctx, task.sitter, "system", "代喂报酬已到账", fmt.Sprintf("确认期已过,%.2f 元已自动记入你的收益账户", float64(task.payout)/100), "wallet", task.id) } } return settled } // closeStaleUnpaidTasks clears out tasks that were created but never paid for. // Without it they sit in "我的任务" forever looking published, which is exactly // the confusion the pay-then-publish change is meant to remove. func (a *App) closeStaleUnpaidTasks(ctx context.Context) int64 { hours := a.configInt(ctx, "pet.feed_unpaid_close_hours", 24) if hours < 1 { hours = 24 } result, err := a.db.ExecContext(ctx, `UPDATE pet_feed_tasks SET status='CANCELLED' WHERE status='CREATED' AND created_at 0 { log.Printf("pet feed: 关闭了 %d 个超过 %d 小时未支付的任务", affected, hours) } return affected } func (a *App) startFeedSettlementWorker(ctx context.Context) { go func() { ticker := time.NewTicker(5 * time.Minute) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: a.autoConfirmFeedTasks(ctx) a.closeStaleUnpaidTasks(ctx) } } }() } // --- 取消与申诉 ----------------------------------------------------------- func (a *App) cancelFeedTask(w http.ResponseWriter, r *http.Request) { id, err := pathID(r) if err != nil { 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 } // Once someone has been sent to your door, cancelling is a negotiation, not // a button. if status != "CREATED" && status != "ESCROWED" { fail(w, http.StatusBadRequest, 20001, "已经选定喂养者的任务请通过申诉处理") return } next := "CANCELLED" if status == "ESCROWED" { next = "REFUNDED" } if _, err = a.db.ExecContext(r.Context(), `UPDATE pet_feed_tasks SET status=? WHERE id=? AND status=?`, next, id, status); err != nil { fail(w, http.StatusInternalServerError, 50001, "操作失败") return } reply(w, map[string]any{"status": next}) } func (a *App) raiseFeedDispute(w http.ResponseWriter, r *http.Request) { id, err := pathID(r) if err != nil { fail(w, http.StatusBadRequest, 20001, "编号无效") return } var req struct { Reason string `json:"reason"` Evidence []string `json:"evidence"` } if decode(r, &req) != nil { fail(w, http.StatusBadRequest, 20001, "格式错误") return } req.Reason = strings.TrimSpace(req.Reason) if len([]rune(req.Reason)) < 10 || len([]rune(req.Reason)) > 500 { fail(w, http.StatusBadRequest, 20001, "请把情况说清楚,10 到 500 字") return } var owner, sitter int64 var status string if a.db.QueryRowContext(r.Context(), `SELECT owner_user_id,COALESCE(sitter_user_id,0),status FROM pet_feed_tasks WHERE id=?`, id).Scan(&owner, &sitter, &status) != nil { fail(w, http.StatusNotFound, 30001, "任务不存在") return } if current(r).ID != owner && current(r).ID != sitter { fail(w, http.StatusForbidden, 30003, "只有任务双方可以申诉") return } // After settlement the money is already in someone's account; that is a // refund conversation with support, not an escrow decision. if status != "ASSIGNED" && status != "SERVING" && status != "COMPLETED" { fail(w, http.StatusBadRequest, 20001, "当前状态不能发起申诉") return } evidence, _ := json.Marshal(req.Evidence) 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_disputes(task_id,raised_by,reason,evidence_json) VALUES(?,?,?,?)`, id, current(r).ID, req.Reason, string(evidence)); err != nil { if isDuplicateKeyError(err) { fail(w, http.StatusBadRequest, 20001, "这个任务已经有一条申诉在处理中") return } fail(w, http.StatusInternalServerError, 50001, "提交失败") return } // Freezing the task stops the auto-confirm clock: nothing pays out while a // human still has to look at it. if _, err = tx.ExecContext(r.Context(), `UPDATE pet_feed_tasks SET status='DISPUTED',confirm_due_at=NULL WHERE id=?`, id); err != nil { fail(w, http.StatusInternalServerError, 50001, "提交失败") return } if tx.Commit() != nil { fail(w, http.StatusInternalServerError, 50001, "提交失败") return } reply(w, map[string]bool{"success": true}) } // --- 评价 ----------------------------------------------------------------- func (a *App) reviewFeedTask(w http.ResponseWriter, r *http.Request) { id, err := pathID(r) if err != nil { fail(w, http.StatusBadRequest, 20001, "编号无效") return } var req struct { Punctual int `json:"punctual"` Accurate int `json:"accurate"` Communication int `json:"communication"` Content string `json:"content"` } if decode(r, &req) != nil { fail(w, http.StatusBadRequest, 20001, "格式错误") return } for _, score := range []int{req.Punctual, req.Accurate, req.Communication} { if score < 1 || score > 5 { fail(w, http.StatusBadRequest, 20001, "评分应在 1 到 5 之间") return } } req.Content = strings.TrimSpace(req.Content) if len([]rune(req.Content)) > 500 { fail(w, http.StatusBadRequest, 20001, "评价内容过长") return } var owner, sitter int64 var status string if a.db.QueryRowContext(r.Context(), `SELECT owner_user_id,COALESCE(sitter_user_id,0),status FROM pet_feed_tasks WHERE id=?`, id).Scan(&owner, &sitter, &status) != nil { fail(w, http.StatusNotFound, 30001, "任务不存在") return } if status != "COMPLETED" && status != "SETTLED" && status != "ARBITRATED" { fail(w, http.StatusBadRequest, 20001, "服务结束后才能评价") return } target := sitter if current(r).ID == sitter { target = owner } else if current(r).ID != owner { fail(w, http.StatusForbidden, 30003, "只有任务双方可以评价") 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_reviews(task_id,from_user_id,to_user_id,punctual,accurate,communication,content) VALUES(?,?,?,?,?,?,?)`, id, current(r).ID, target, req.Punctual, req.Accurate, req.Communication, req.Content); err != nil { if isDuplicateKeyError(err) { fail(w, http.StatusBadRequest, 20001, "你已经评价过了") return } fail(w, http.StatusInternalServerError, 50001, "提交失败") return } // Double-blind: neither side sees the other's rating until both have written // one, so nobody writes a retaliation. var count int if err = tx.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM pet_feed_reviews WHERE task_id=?`, id).Scan(&count); err != nil { fail(w, http.StatusInternalServerError, 50001, "提交失败") return } if count >= 2 { if _, err = tx.ExecContext(r.Context(), `UPDATE pet_feed_reviews SET visible_at=NOW(3) WHERE task_id=? AND visible_at IS NULL`, id); err != nil { fail(w, http.StatusInternalServerError, 50001, "提交失败") return } } if target == sitter { average := (req.Punctual + req.Accurate + req.Communication) / 3 if _, err = tx.ExecContext(r.Context(), `UPDATE pet_sitters SET rating_total=rating_total+?,rating_count=rating_count+1 WHERE user_id=?`, average, sitter); err != nil { fail(w, http.StatusInternalServerError, 50001, "提交失败") return } } if tx.Commit() != nil { fail(w, http.StatusInternalServerError, 50001, "提交失败") return } reply(w, map[string]any{"success": true, "bothSubmitted": count >= 2}) } func (a *App) sitterReviews(w http.ResponseWriter, r *http.Request) { userID, err := pathID(r) if err != nil { fail(w, http.StatusBadRequest, 20001, "编号无效") return } _, size, offset := pageOptions(r) rows, queryErr := a.db.QueryContext(r.Context(), `SELECT rv.punctual,rv.accurate,rv.communication,rv.content,rv.created_at,profile.nickname,profile.avatar_url FROM pet_feed_reviews rv JOIN user_profiles profile ON profile.user_id=rv.from_user_id WHERE rv.to_user_id=? AND rv.visible_at IS NOT NULL ORDER BY rv.id DESC LIMIT ? OFFSET ?`, userID, size, offset) if queryErr != nil { fail(w, http.StatusInternalServerError, 50001, "查询评价失败") return } defer rows.Close() items := []map[string]any{} for rows.Next() { var punctual, accurate, communication int var content, nickname, avatar string var created time.Time if rows.Scan(&punctual, &accurate, &communication, &content, &created, &nickname, &avatar) != nil { continue } items = append(items, map[string]any{ "punctual": punctual, "accurate": accurate, "communication": communication, "content": content, "nickname": nickname, "avatar": avatarThumbnailURL(avatar), "createdAt": created.Format(time.RFC3339), }) } reply(w, map[string]any{"items": items}) }