代喂改走真实订单,并补上打卡纪律与地址明文清除

支付原来是个占位:调一下接口任务就算"已付"。现在任务和喂养者保证金都
走会员那套订单与支付网关,一笔订单买到什么由 applyPaidOrderTx 统一决定,
沙箱和真实回调走同一段逻辑。退款能释放托管中的任务,但不会去动已经结算
的那一笔——钱已经在喂养者账上,那种情况必须有人看过再说。

通道接通之前,客服可以在管理端手工标记已托管,必须写明钱怎么收的,进审计日志。

另外补上三件方案里写了但代码没做的事:打卡必须按到达、喂食、离开的顺序,
喂食离到达太近视为摆拍;地址新增紧急联系人与常去医院,随门牌一起只发给
被选中的喂养者;任务结束七天后由后台任务抹掉地址明文,只留城市与街道。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-07 09:07:38 +08:00
co-authored by Claude Opus 5
parent 97351777ec
commit 9345805133
9 changed files with 889 additions and 81 deletions
+48 -1
View File
@@ -5,6 +5,7 @@ import (
"database/sql"
"encoding/json"
"fmt"
"log"
"net/http"
"strings"
"time"
@@ -76,6 +77,12 @@ func (a *App) checkinFeedVisit(w http.ResponseWriter, r *http.Request) {
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 {
@@ -129,12 +136,52 @@ func (a *App) checkinFeedVisit(w http.ResponseWriter, r *http.Request) {
var owner int64
_ = a.db.QueryRowContext(r.Context(), `SELECT owner_user_id FROM pet_feed_tasks WHERE id=?`, taskID).Scan(&owner)
if owner > 0 {
a.notifyUser(r.Context(), owner, "system", "喂养者报告了异常", "本次上门有异常情况,请尽快查看照片与说明", "feed_task", taskID)
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}
}