Files
kefu/im/backend/internal/app/pet_feed_service.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

549 lines
20 KiB
Go

package app
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"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
}
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 {
a.notifyUser(r.Context(), owner, "system", "喂养者报告了异常", "本次上门有异常情况,请尽快查看照片与说明", "feed_task", taskID)
}
}
reply(w, map[string]any{"verdict": verdict, "distanceM": distance.Int64})
}
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
}
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)
}
}
}()
}
// --- 取消与申诉 -----------------------------------------------------------
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})
}