Files
kefu/im/backend/internal/app/pet_feed_service.go
Your NameandClaude Opus 5 ebf7189533 整改一期:发布这条链路先跑顺
四条改动都在"发布一个代喂任务"这条路上:

定位拿不到不再把人卡死。地址可以不带坐标保存,界面标成"未核验定位",
这条地址的打卡照常,只是不比对距离——总比逼着用户放弃发布要好。客户端
另外给了两条退路:去系统设置开权限,或者地图选点。

价格从写死的 30 元改成服务端按物种、城市、每次时长算的推荐价,规则放在
配置里,运营改一次不用等发版;用户改过之后就以他填的为准,不再被覆盖。

发布与支付并成一步:底部常驻"合计 ¥x = n 次 × ¥y",主按钮是"支付并发布"。
没付成的任务留在"我的任务"里能接着付,超过 24 小时由后台关掉,不再变成
既看不见又删不掉的孤儿。

选宠物换成带头像的卡片,缺免疫记录或照片的当场标出来,不用等提交才知道。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 10:31:39 +08:00

618 lines
23 KiB
Go

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<DATE_SUB(NOW(3),INTERVAL ? HOUR)`, hours)
if err != nil {
log.Printf("pet feed: 关闭超时未支付任务失败: %v", err)
return 0
}
affected, _ := result.RowsAffected()
if affected > 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})
}