From 9345805133aad21b902532b3a31b7d6b503bf26d Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 7 Sep 2026 09:07:38 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BB=A3=E5=96=82=E6=94=B9=E8=B5=B0=E7=9C=9F?= =?UTF-8?q?=E5=AE=9E=E8=AE=A2=E5=8D=95=EF=BC=8C=E5=B9=B6=E8=A1=A5=E4=B8=8A?= =?UTF-8?q?=E6=89=93=E5=8D=A1=E7=BA=AA=E5=BE=8B=E4=B8=8E=E5=9C=B0=E5=9D=80?= =?UTF-8?q?=E6=98=8E=E6=96=87=E6=B8=85=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 支付原来是个占位:调一下接口任务就算"已付"。现在任务和喂养者保证金都 走会员那套订单与支付网关,一笔订单买到什么由 applyPaidOrderTx 统一决定, 沙箱和真实回调走同一段逻辑。退款能释放托管中的任务,但不会去动已经结算 的那一笔——钱已经在喂养者账上,那种情况必须有人看过再说。 通道接通之前,客服可以在管理端手工标记已托管,必须写明钱怎么收的,进审计日志。 另外补上三件方案里写了但代码没做的事:打卡必须按到达、喂食、离开的顺序, 喂食离到达太近视为摆拍;地址新增紧急联系人与常去医院,随门牌一起只发给 被选中的喂养者;任务结束七天后由后台任务抹掉地址明文,只留城市与街道。 Co-Authored-By: Claude Opus 5 --- im/backend/internal/app/app.go | 5 +- im/backend/internal/app/membership.go | 15 +- im/backend/internal/app/order_products.go | 233 ++++++++++ im/backend/internal/app/payment_gateway.go | 17 +- im/backend/internal/app/pet_address_purge.go | 71 +++ im/backend/internal/app/pet_feed.go | 160 ++++--- .../internal/app/pet_feed_integration_test.go | 407 +++++++++++++++++- im/backend/internal/app/pet_feed_service.go | 49 ++- im/backend/migrations/039_pet_feed_safety.sql | 13 + 9 files changed, 889 insertions(+), 81 deletions(-) create mode 100644 im/backend/internal/app/order_products.go create mode 100644 im/backend/internal/app/pet_address_purge.go create mode 100644 im/backend/migrations/039_pet_feed_safety.sql diff --git a/im/backend/internal/app/app.go b/im/backend/internal/app/app.go index 883a5a7..842bacb 100644 --- a/im/backend/internal/app/app.go +++ b/im/backend/internal/app/app.go @@ -118,6 +118,7 @@ func (a *App) Run() { go a.runAIMaintenance(workerContext) go a.runChatMediaCleanupWorker(workerContext) a.startFeedSettlementWorker(workerContext) + a.startAddressPurgeWorker(workerContext) server := rest.MustNewServer(rest.RestConf{ Host: a.config.Host, Port: a.config.Port, @@ -267,7 +268,8 @@ func (a *App) userRoutes() []rest.Route { {Method: http.MethodGet, Path: "/api/v1/pet/feed-tasks", Handler: auth(a.feedTasks)}, {Method: http.MethodPost, Path: "/api/v1/pet/feed-tasks", Handler: auth(a.createFeedTask)}, {Method: http.MethodGet, Path: "/api/v1/pet/feed-tasks/:id", Handler: auth(a.feedTaskDetail)}, - {Method: http.MethodPost, Path: "/api/v1/pet/feed-tasks/:id/pay", Handler: auth(a.escrowFeedTask)}, + {Method: http.MethodPost, Path: "/api/v1/pet/feed-tasks/:id/order", Handler: auth(a.createFeedTaskOrder)}, + {Method: http.MethodPost, Path: "/api/v1/pet/sitter/deposit", Handler: auth(a.createDepositOrder)}, {Method: http.MethodPost, Path: "/api/v1/pet/feed-tasks/:id/apply", Handler: auth(a.applyFeedTask)}, {Method: http.MethodPost, Path: "/api/v1/pet/feed-tasks/:id/assign", Handler: auth(a.assignFeedTask)}, {Method: http.MethodPost, Path: "/api/v1/pet/feed-tasks/:id/confirm", Handler: auth(a.confirmFeedTask)}, @@ -371,6 +373,7 @@ func (a *App) adminRoutes() []rest.Route { {Method: http.MethodGet, Path: "/admin/v1/pet/sitters", Handler: permit("users:view", a.adminSitters)}, {Method: http.MethodPost, Path: "/admin/v1/pet/sitters/:id/review", Handler: permit("users:manage", a.adminReviewSitter)}, {Method: http.MethodGet, Path: "/admin/v1/pet/feed-tasks", Handler: permit("users:view", a.adminFeedTasks)}, + {Method: http.MethodPost, Path: "/admin/v1/pet/feed-tasks/:id/escrow", Handler: permit("users:manage", a.adminEscrowFeedTask)}, {Method: http.MethodGet, Path: "/admin/v1/pet/disputes", Handler: permit("users:view", a.adminFeedDisputes)}, {Method: http.MethodPost, Path: "/admin/v1/pet/disputes/:id/handle", Handler: permit("users:manage", a.adminHandleFeedDispute)}, {Method: http.MethodGet, Path: "/admin/v1/wallet/withdrawals", Handler: permit("users:view", a.adminWithdrawals)}, diff --git a/im/backend/internal/app/membership.go b/im/backend/internal/app/membership.go index f139e75..638c354 100644 --- a/im/backend/internal/app/membership.go +++ b/im/backend/internal/app/membership.go @@ -193,11 +193,10 @@ func (a *App) payOrder(w http.ResponseWriter, r *http.Request) { return } defer func() { _ = tx.Rollback() }() - var userID, planID int64 + var userID, productID int64 var amountCent int - var orderNo string - var status string - err = tx.QueryRowContext(r.Context(), `SELECT user_id,product_id,amount_cent,order_no,status FROM orders WHERE id=? AND user_id=? AND deleted_at IS NULL FOR UPDATE`, id, current(r).ID).Scan(&userID, &planID, &amountCent, &orderNo, &status) + var orderNo, productType, status string + err = tx.QueryRowContext(r.Context(), `SELECT user_id,product_type,product_id,amount_cent,order_no,status FROM orders WHERE id=? AND user_id=? AND deleted_at IS NULL FOR UPDATE`, id, current(r).ID).Scan(&userID, &productType, &productID, &amountCent, &orderNo, &status) if err != nil { fail(w, 404, 30001, "订单不存在") return @@ -210,13 +209,9 @@ func (a *App) payOrder(w http.ResponseWriter, r *http.Request) { fail(w, 400, 20001, "当前订单状态无法支付") return } - var durationDays, level int - if err = tx.QueryRowContext(r.Context(), `SELECT duration_days,level FROM membership_plans WHERE id=? AND status=1 AND deleted_at IS NULL`, planID).Scan(&durationDays, &level); err != nil { - fail(w, 400, 20001, "会员套餐已下架") - return - } if _, err = tx.ExecContext(r.Context(), `UPDATE orders SET status='PAID',paid_at=NOW(3),paid_amount_cent=?,provider_order_no=?,payment_notified_at=NOW(3) WHERE id=?`, amountCent, "sandbox:"+orderNo, id); err == nil { - err = grantOrderMembershipTx(r.Context(), tx, id, userID, planID, durationDays, level) + // 一笔订单买到什么,沙箱和真实回调走同一段逻辑,免得两边跑偏。 + err = a.applyPaidOrderTx(r.Context(), tx, id, userID, productType, productID, amountCent) } if err != nil || tx.Commit() != nil { fail(w, 500, 50001, "支付入账失败") diff --git a/im/backend/internal/app/order_products.go b/im/backend/internal/app/order_products.go new file mode 100644 index 0000000..2613619 --- /dev/null +++ b/im/backend/internal/app/order_products.go @@ -0,0 +1,233 @@ +package app + +import ( + "context" + "database/sql" + "fmt" + "net/http" + "strings" + "time" +) + +// One order table now buys three different things, so what an order *does* when +// its money arrives lives here instead of being spelled out again in the +// sandbox path and in the gateway callback. Both call the same two functions, +// which is what keeps a sandbox payment and a real one from drifting apart. + +const ( + productMembership = "membership" + productPetFeed = "pet_feed" + productPetDeposit = "pet_deposit" +) + +func (a *App) applyPaidOrderTx(ctx context.Context, tx *sql.Tx, orderID, userID int64, productType string, productID int64, amountCent int) error { + switch productType { + case productMembership: + var durationDays, level int + if err := tx.QueryRowContext(ctx, `SELECT duration_days,level FROM membership_plans WHERE id=? AND deleted_at IS NULL`, productID).Scan(&durationDays, &level); err != nil { + return fmt.Errorf("membership plan does not exist") + } + return grantOrderMembershipTx(ctx, tx, orderID, userID, productID, durationDays, level) + + case productPetFeed: + // The task only becomes visible to sitters once the money is held, and it + // remembers which order is holding it — a refund has to find its way back. + var owner int64 + var gross int + var status string + if err := tx.QueryRowContext(ctx, `SELECT owner_user_id,gross_cent,status FROM pet_feed_tasks WHERE id=? FOR UPDATE`, productID).Scan(&owner, &gross, &status); err != nil { + return fmt.Errorf("feeding task does not exist") + } + if owner != userID || gross != amountCent { + return fmt.Errorf("payment does not match the feeding task") + } + if status == "ESCROWED" { + return nil + } + if status != "CREATED" { + return fmt.Errorf("feeding task status does not allow payment") + } + _, err := tx.ExecContext(ctx, `UPDATE pet_feed_tasks SET status='ESCROWED',order_id=? WHERE id=? AND status='CREATED'`, orderID, productID) + return err + + case productPetDeposit: + result, err := tx.ExecContext(ctx, `UPDATE pet_sitters SET deposit_cent=deposit_cent+? WHERE user_id=?`, amountCent, userID) + if err != nil { + return err + } + if affected, _ := result.RowsAffected(); affected == 0 { + return fmt.Errorf("sitter profile does not exist") + } + return nil + } + return fmt.Errorf("unknown product type: %s", productType) +} + +// revokePaidOrderTx undoes what the money bought. A feeding task that has +// already been settled is deliberately not refundable here: the payout has left +// the escrow, so that case has to be looked at by a person rather than quietly +// reversed. +func (a *App) revokePaidOrderTx(ctx context.Context, tx *sql.Tx, orderID, userID int64, productType string, productID int64, amountCent int) error { + switch productType { + case productMembership: + if _, err := tx.ExecContext(ctx, `UPDATE subscriptions SET status=0 WHERE user_id=? AND status=1 AND source IN (?,?)`, + userID, fmt.Sprintf("order:%d", orderID), fmt.Sprintf("admin_order:%d", orderID)); err != nil { + return err + } + return a.recomputeMembershipTx(ctx, tx, userID) + + case productPetFeed: + var status string + if err := tx.QueryRowContext(ctx, `SELECT status FROM pet_feed_tasks WHERE id=? FOR UPDATE`, productID).Scan(&status); err != nil { + return fmt.Errorf("feeding task does not exist") + } + if status == "SETTLED" || status == "ARBITRATED" { + return fmt.Errorf("feeding task is already settled; refund it through the dispute flow") + } + if status == "REFUNDED" { + return nil + } + _, err := tx.ExecContext(ctx, `UPDATE pet_feed_tasks SET status='REFUNDED',confirm_due_at=NULL WHERE id=?`, productID) + return err + + case productPetDeposit: + // Never below zero: part of a deposit may already have paid a claim. + _, err := tx.ExecContext(ctx, `UPDATE pet_sitters SET deposit_cent=GREATEST(CAST(deposit_cent AS SIGNED)-?,0) WHERE user_id=?`, amountCent, userID) + return err + } + return fmt.Errorf("unknown product type: %s", productType) +} + +// --- 下单 ----------------------------------------------------------------- + +// startOrder creates (or hands back) the pending order for one purchase. It is +// shared by the pet products; membership keeps its own entry point because it +// also has to look the plan up. +func (a *App) startOrder(w http.ResponseWriter, r *http.Request, productType string, productID int64, amountCent int, channel string) { + channels := a.availablePaymentChannels(r.Context()) + if channel == "" && len(channels) > 0 { + channel, _ = channels[0]["code"].(string) + } + allowed := false + for _, item := range channels { + if item["code"] == channel { + allowed = item["configured"] == true + } + } + if !allowed { + fail(w, http.StatusBadRequest, 20001, "支付渠道未启用或配置不完整") + return + } + // A second tap on 支付 should reuse the order it already made, not stack up + // unpaid orders for the same task. + var existingID int64 + var existingNo string + if a.db.QueryRowContext(r.Context(), `SELECT id,order_no FROM orders WHERE user_id=? AND product_type=? AND product_id=? AND status='CREATED' AND deleted_at IS NULL ORDER BY id DESC LIMIT 1`, + current(r).ID, productType, productID).Scan(&existingID, &existingNo) == nil { + reply(w, map[string]any{"id": existingID, "orderNo": existingNo, "amountCent": amountCent, "status": "CREATED", "channel": channel}) + return + } + orderNo := fmt.Sprintf("XY%d%d%s", time.Now().UnixMilli(), current(r).ID, randomToken()[:8]) + result, err := a.db.ExecContext(r.Context(), `INSERT INTO orders(order_no,user_id,product_type,product_id,amount_cent,status,channel)VALUES(?,?,?,?,?,'CREATED',?)`, + orderNo, current(r).ID, productType, productID, amountCent, channel) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "创建订单失败") + return + } + id, _ := result.LastInsertId() + reply(w, map[string]any{"id": id, "orderNo": orderNo, "amountCent": amountCent, "status": "CREATED", "channel": channel}) +} + +func (a *App) createFeedTaskOrder(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, http.StatusBadRequest, 20001, "编号无效") + return + } + var req struct { + Channel string `json:"channel"` + } + if r.ContentLength > 0 && decode(r, &req) != nil { + fail(w, http.StatusBadRequest, 20001, "参数错误") + return + } + var owner int64 + var gross int + var status string + if a.db.QueryRowContext(r.Context(), `SELECT owner_user_id,gross_cent,status FROM pet_feed_tasks WHERE id=?`, id).Scan(&owner, &gross, &status) != nil { + fail(w, http.StatusNotFound, 30001, "任务不存在") + return + } + if owner != current(r).ID { + fail(w, http.StatusForbidden, 30003, "只有发布任务的人可以支付") + return + } + if status != "CREATED" { + fail(w, http.StatusBadRequest, 20001, "任务状态不允许支付") + return + } + a.startOrder(w, r, productPetFeed, id, gross, strings.TrimSpace(req.Channel)) +} + +func (a *App) createDepositOrder(w http.ResponseWriter, r *http.Request) { + var req struct { + Channel string `json:"channel"` + } + if r.ContentLength > 0 && decode(r, &req) != nil { + fail(w, http.StatusBadRequest, 20001, "参数错误") + return + } + var paid int + if a.db.QueryRowContext(r.Context(), `SELECT deposit_cent FROM pet_sitters WHERE user_id=?`, current(r).ID).Scan(&paid) != nil { + fail(w, http.StatusForbidden, 30003, "请先申请成为喂养者") + return + } + required := a.configInt(r.Context(), "pet.sitter_deposit_cent", 20000) + if paid >= required { + fail(w, http.StatusBadRequest, 20001, "保证金已缴纳") + return + } + a.startOrder(w, r, productPetDeposit, current(r).ID, required-paid, strings.TrimSpace(req.Channel)) +} + +// adminEscrowFeedTask is the grey-release escape hatch: until a payment channel +// is connected, an operator who has taken the money some other way marks the +// task as funded. It is deliberately an admin action with an audit entry rather +// than a button in the app. +func (a *App) adminEscrowFeedTask(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, http.StatusBadRequest, 20001, "编号无效") + return + } + var req struct { + Note string `json:"note"` + } + if decode(r, &req) != nil { + fail(w, http.StatusBadRequest, 20001, "参数错误") + return + } + req.Note = strings.TrimSpace(req.Note) + if len([]rune(req.Note)) < 5 { + fail(w, http.StatusBadRequest, 20001, "请写明这笔钱是怎么收到的,至少 5 个字") + return + } + var owner int64 + var gross int + if a.db.QueryRowContext(r.Context(), `SELECT owner_user_id,gross_cent FROM pet_feed_tasks WHERE id=? AND status='CREATED'`, id).Scan(&owner, &gross) != nil { + fail(w, http.StatusNotFound, 30001, "任务不存在或状态不允许") + return + } + result, err := a.db.ExecContext(r.Context(), `UPDATE pet_feed_tasks SET status='ESCROWED' WHERE id=? AND status='CREATED'`, id) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "操作失败") + return + } + if affected, _ := result.RowsAffected(); affected == 0 { + fail(w, http.StatusBadRequest, 20001, "任务状态不允许") + return + } + a.audit(r, "manual_escrow", "pet_feed_task", id, map[string]any{"grossCent": gross, "note": req.Note}) + a.notifyUser(r.Context(), owner, "system", "代喂任务已发布", "任务已进入代喂大厅,等待喂养者报名", "feed_task", id) + reply(w, map[string]bool{"success": true}) +} diff --git a/im/backend/internal/app/payment_gateway.go b/im/backend/internal/app/payment_gateway.go index 0abef90..c409ffa 100644 --- a/im/backend/internal/app/payment_gateway.go +++ b/im/backend/internal/app/payment_gateway.go @@ -183,10 +183,10 @@ func (a *App) settlePayment(ctx context.Context, notice paymentNotifyRequest, ra return tx.Commit() } - var orderID, userID, planID int64 + var orderID, userID, productID int64 var amountCent int - var status, channel string - if err = tx.QueryRowContext(ctx, `SELECT id,user_id,product_id,amount_cent,status,channel FROM orders WHERE order_no=? AND deleted_at IS NULL FOR UPDATE`, notice.OrderNo).Scan(&orderID, &userID, &planID, &amountCent, &status, &channel); err != nil { + var status, channel, productType string + if err = tx.QueryRowContext(ctx, `SELECT id,user_id,product_type,product_id,amount_cent,status,channel FROM orders WHERE order_no=? AND deleted_at IS NULL FOR UPDATE`, notice.OrderNo).Scan(&orderID, &userID, &productType, &productID, &amountCent, &status, &channel); err != nil { return fmt.Errorf("order does not exist") } if channel != notice.Channel || amountCent != notice.AmountCent { @@ -202,10 +202,7 @@ func (a *App) settlePayment(ctx context.Context, notice paymentNotifyRequest, ra if _, err = tx.ExecContext(ctx, `UPDATE orders SET status='REFUNDED',payment_notified_at=NOW(3) WHERE id=?`, orderID); err != nil { return err } - if _, err = tx.ExecContext(ctx, `UPDATE subscriptions SET status=0 WHERE user_id=? AND status=1 AND source IN (?,?)`, userID, fmt.Sprintf("order:%d", orderID), fmt.Sprintf("admin_order:%d", orderID)); err != nil { - return err - } - if err = a.recomputeMembershipTx(ctx, tx, userID); err != nil { + if err = a.revokePaidOrderTx(ctx, tx, orderID, userID, productType, productID, amountCent); err != nil { return err } return tx.Commit() @@ -228,14 +225,10 @@ func (a *App) settlePayment(ctx context.Context, notice paymentNotifyRequest, ra return tx.Commit() } - var durationDays, level int - if err = tx.QueryRowContext(ctx, `SELECT duration_days,level FROM membership_plans WHERE id=? AND deleted_at IS NULL`, planID).Scan(&durationDays, &level); err != nil { - return fmt.Errorf("membership plan does not exist") - } if _, err = tx.ExecContext(ctx, `UPDATE orders SET status='PAID',paid_at=NOW(3),paid_amount_cent=?,provider_order_no=?,payment_notified_at=NOW(3) WHERE id=?`, notice.AmountCent, notice.ProviderOrderNo, orderID); err != nil { return err } - if err = grantOrderMembershipTx(ctx, tx, orderID, userID, planID, durationDays, level); err != nil { + if err = a.applyPaidOrderTx(ctx, tx, orderID, userID, productType, productID, notice.AmountCent); err != nil { return err } return tx.Commit() diff --git a/im/backend/internal/app/pet_address_purge.go b/im/backend/internal/app/pet_address_purge.go new file mode 100644 index 0000000..fb33363 --- /dev/null +++ b/im/backend/internal/app/pet_address_purge.go @@ -0,0 +1,71 @@ +package app + +import ( + "context" + "log" + "time" +) + +// The address table holds a stranger's door number and phone. Keeping that +// after the job is over buys nothing and costs a lot if the database ever +// leaks, so a finished task schedules its address for erasure and this worker +// carries it out. The city and street stay: they are what the history reads +// from, and they were public on the listing anyway. + +func (a *App) scheduleAddressPurge(ctx context.Context) int64 { + days := a.configInt(ctx, "pet.address_purge_days", 7) + if days < 1 { + days = 7 + } + // 只给"没有任何进行中任务"的地址排期;主人常用的地址不会因为一次任务结束就被清空。 + result, err := a.db.ExecContext(ctx, `UPDATE pet_addresses addr + JOIN (SELECT address_id,MAX(updated_at) AS finished FROM pet_feed_tasks + WHERE status IN ('SETTLED','REFUNDED','CANCELLED','ARBITRATED') GROUP BY address_id) ended + ON ended.address_id=addr.id + SET addr.purge_at=DATE_ADD(ended.finished,INTERVAL ? DAY) + WHERE addr.purge_at IS NULL + AND NOT EXISTS (SELECT 1 FROM pet_feed_tasks live WHERE live.address_id=addr.id + AND live.status IN ('CREATED','ESCROWED','ASSIGNED','SERVING','COMPLETED','DISPUTED'))`, days) + if err != nil { + log.Printf("pet address purge scheduling failed: %v", err) + return 0 + } + affected, _ := result.RowsAffected() + return affected +} + +func (a *App) purgeDueAddresses(ctx context.Context) int64 { + // 一个地址重新被用于新任务时会清掉 purge_at;这里再确认一次没有活任务, + // 免得排期和复用之间抢到一起。 + result, err := a.db.ExecContext(ctx, `UPDATE pet_addresses addr + SET addr.detail_cipher='',addr.contact_cipher='',addr.emergency_cipher=NULL,addr.vet_hospital='', + addr.latitude=NULL,addr.longitude=NULL,addr.purge_at=NULL + WHERE addr.purge_at IS NOT NULL AND addr.purge_at<=NOW(3) + AND NOT EXISTS (SELECT 1 FROM pet_feed_tasks live WHERE live.address_id=addr.id + AND live.status IN ('CREATED','ESCROWED','ASSIGNED','SERVING','COMPLETED','DISPUTED'))`) + if err != nil { + log.Printf("pet address purge failed: %v", err) + return 0 + } + affected, _ := result.RowsAffected() + if affected > 0 { + log.Printf("pet address purge: cleared plaintext for %d addresses", affected) + } + return affected +} + +func (a *App) startAddressPurgeWorker(ctx context.Context) { + go func() { + ticker := time.NewTicker(time.Hour) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + a.scheduleAddressPurge(ctx) + a.purgeDueAddresses(ctx) + } + } + }() +} diff --git a/im/backend/internal/app/pet_feed.go b/im/backend/internal/app/pet_feed.go index 43f846f..5c28582 100644 --- a/im/backend/internal/app/pet_feed.go +++ b/im/backend/internal/app/pet_feed.go @@ -5,6 +5,7 @@ import ( "database/sql" "encoding/json" "fmt" + "log" "math" "net/http" "strings" @@ -42,6 +43,7 @@ type feedTaskView struct { 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"` @@ -68,13 +70,15 @@ func (a *App) feedFeeSplit(ctx context.Context, gross int64) (fee int64, payout 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"` - Latitude float64 `json:"latitude"` - Longitude float64 `json:"longitude"` + 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, "地址格式错误") @@ -83,11 +87,14 @@ func (a *App) createPetAddress(w http.ResponseWriter, r *http.Request) { 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 { + 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 } @@ -107,8 +114,17 @@ func (a *App) createPetAddress(w http.ResponseWriter, r *http.Request) { fail(w, http.StatusInternalServerError, 50001, "保存失败") return } - result, err := a.db.ExecContext(r.Context(), `INSERT INTO pet_addresses(user_id,city_code,city_name,area_text,detail_cipher,contact_cipher,latitude,longitude) - VALUES(?,?,?,?,?,?,?,?)`, current(r).ID, strings.TrimSpace(req.CityCode), strings.TrimSpace(req.CityName), req.AreaText, detail, contact, req.Latitude, req.Longitude) + // 紧急联系人是选填的,但填了就和门牌一样加密。 + var emergency []byte + if req.Emergency != "" { + if emergency, err = a.encryptPhone(req.Emergency); err != nil { + fail(w, http.StatusInternalServerError, 50001, "保存失败") + return + } + } + 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, req.Latitude, req.Longitude) if err != nil { fail(w, http.StatusInternalServerError, 50001, "保存失败") return @@ -269,10 +285,16 @@ func (a *App) createFeedTask(w http.ResponseWriter, r *http.Request) { } } var cityCode string - if a.db.QueryRowContext(r.Context(), `SELECT city_code FROM pet_addresses WHERE id=? AND user_id=?`, req.AddressID, current(r).ID).Scan(&cityCode) != nil { - fail(w, http.StatusNotFound, 30001, "服务地址不存在") + 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, "宠物不存在") @@ -323,30 +345,9 @@ func (a *App) createFeedTask(w http.ResponseWriter, r *http.Request) { reply(w, map[string]any{"id": taskID, "grossCent": gross, "platformFeeCent": fee, "payoutCent": payout, "totalVisits": totalVisits}) } -// escrowFeedTask stands in for the payment callback: the task only becomes -// visible to sitters once the money is held. Wiring it to the real gateway -// replaces the body, not the state machine. -func (a *App) escrowFeedTask(w http.ResponseWriter, r *http.Request) { - id, err := pathID(r) - if err != nil { - fail(w, http.StatusBadRequest, 20001, "编号无效") - return - } - result, err := a.db.ExecContext(r.Context(), `UPDATE pet_feed_tasks SET status='ESCROWED' WHERE id=? AND owner_user_id=? AND status='CREATED'`, id, current(r).ID) - if err != nil { - fail(w, http.StatusInternalServerError, 50001, "操作失败") - return - } - if affected, _ := result.RowsAffected(); affected == 0 { - fail(w, http.StatusBadRequest, 20001, "任务状态不允许支付") - return - } - reply(w, map[string]bool{"success": true}) -} - 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, t.start_date,t.end_date,t.visits_per_day,t.min_minutes,t.price_cent,t.gross_cent,t.payout_cent,t.note,t.status, - COALESCE(t.sitter_user_id,0),COALESCE(sitter.nickname,''),t.applications_count,t.total_visits,t.completed_visits,t.created_at` + 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{} @@ -359,7 +360,7 @@ func (a *App) scanFeedTasks(ctx context.Context, rows *sql.Rows, viewer int64) [ if rows.Scan(&item.ID, &item.OwnerUserID, &item.OwnerName, &item.PetID, &item.PetName, &item.Species, &item.PetAvatar, &item.CityName, &item.AreaText, &start, &end, &item.VisitsPerDay, &item.MinMinutes, &item.PriceCent, &item.GrossCent, &item.PayoutCent, &item.Note, &item.Status, &item.SitterUserID, &item.SitterName, - &item.Applications, &item.TotalVisits, &item.Done, &created) != nil { + &item.Conversation, &item.Applications, &item.TotalVisits, &item.Done, &created) != nil { continue } item.SpeciesLabel = petSpecies[item.Species] @@ -463,8 +464,8 @@ func (a *App) feedTaskDetail(w http.ResponseWriter, r *http.Request) { // 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") { - detail, contact := a.decryptAddress(r.Context(), task.ID) - view["address"] = map[string]any{"detail": detail, "contact": contact} + address := a.decryptAddress(r.Context(), task.ID) + view["address"] = address } if task.Mine { applications, _ := a.feedApplications(r.Context(), id) @@ -475,21 +476,31 @@ func (a *App) feedTaskDetail(w http.ResponseWriter, r *http.Request) { reply(w, view) } -func (a *App) decryptAddress(ctx context.Context, taskID int64) (string, string) { - var detailCipher, contactCipher []byte - if a.db.QueryRowContext(ctx, `SELECT addr.detail_cipher,addr.contact_cipher FROM pet_feed_tasks t JOIN pet_addresses addr ON addr.id=t.address_id WHERE t.id=?`, taskID). - Scan(&detailCipher, &contactCipher) != nil { - return "", "" +// 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{} } - detail, err := a.decryptPhone(detailCipher) - if err != nil { - return "", "" + address := map[string]any{"vetHospital": hospital} + if detail, err := a.decryptPhone(detailCipher); err == nil { + address["detail"] = detail } - contact, err := a.decryptPhone(contactCipher) - if err != nil { - return detail, "" + if contact, err := a.decryptPhone(contactCipher); err == nil { + address["contact"] = contact } - return detail, 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) { @@ -697,6 +708,55 @@ func (a *App) assignFeedTask(w http.ResponseWriter, r *http.Request) { 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]bool{"success": true}) + 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 } diff --git a/im/backend/internal/app/pet_feed_integration_test.go b/im/backend/internal/app/pet_feed_integration_test.go index 301cb78..2c96399 100644 --- a/im/backend/internal/app/pet_feed_integration_test.go +++ b/im/backend/internal/app/pet_feed_integration_test.go @@ -8,6 +8,7 @@ import ( "net/http/httptest" "strings" "testing" + "time" ) // The feeding module moves real money, so these tests are written around the @@ -121,11 +122,7 @@ func TestPetFeedTaskMySQL(t *testing.T) { t.Fatalf("HTTP %d: %s", again.Code, again.Body.String()) } // Pending review: still cannot take work. - pay := httptest.NewRecorder() - a.escrowFeedTask(pay, imTestRequest(owner, "POST", fmt.Sprintf("/api/v1/pet/feed-tasks/%d/pay", taskID), ``)) - if pay.Code != 200 { - t.Fatalf("支付失败: %s", pay.Body.String()) - } + payFeedTask(t, a, owner, taskID) blocked := httptest.NewRecorder() a.applyFeedTask(blocked, imTestRequest(sitter, "POST", fmt.Sprintf("/api/v1/pet/feed-tasks/%d/apply", taskID), `{"message":"我可以"}`)) if blocked.Code != 403 { @@ -215,11 +212,14 @@ func TestPetFeedTaskMySQL(t *testing.T) { }) t.Run("the whole service completes and pays exactly once", func(t *testing.T) { + // 打卡要按顺序、并且喂食离到达有间隔,所以时间戳是分开给的。 for _, visitID := range allVisits(t, db, taskID) { - for _, step := range []string{"arrive", "feed", "leave"} { + base := time.Now() + for index, step := range []string{"arrive", "feed", "leave"} { w := httptest.NewRecorder() a.checkinFeedVisit(w, imTestRequest(sitter, "POST", fmt.Sprintf("/api/v1/pet/feed-visits/%d/checkin", visitID), - fmt.Sprintf(`{"step":%q,"mediaUrl":"https://cdn.example.com/%s.jpg","latitude":22.5411,"longitude":113.9345}`, step, step))) + fmt.Sprintf(`{"step":%q,"mediaUrl":"https://cdn.example.com/%s.jpg","latitude":22.5411,"longitude":113.9345,"capturedAt":%q}`, + step, step, base.Add(time.Duration(index*3)*time.Minute).Format(time.RFC3339)))) // The first visit already has an 'arrive' from the previous case. if w.Code != 200 && w.Code != 400 { t.Fatalf("打卡失败: HTTP %d %s", w.Code, w.Body.String()) @@ -362,6 +362,28 @@ func TestPetFeedDisputeMySQL(t *testing.T) { } } +// payFeedTask walks the real money path: create the order, pay it in sandbox, +// and check the task is only funded because the payment landed. +func payFeedTask(t *testing.T, a *App, owner, taskID int64) { + t.Helper() + order := httptest.NewRecorder() + a.createFeedTaskOrder(order, imTestRequest(owner, "POST", fmt.Sprintf("/api/v1/pet/feed-tasks/%d/order", taskID), `{"channel":"alipay"}`)) + if order.Code != 200 { + t.Fatalf("下单失败: %s", order.Body.String()) + } + var envelope struct { + Data struct { + ID int64 `json:"id"` + } `json:"data"` + } + _ = json.Unmarshal(order.Body.Bytes(), &envelope) + paid := httptest.NewRecorder() + a.payOrder(paid, imTestRequest(owner, "POST", fmt.Sprintf("/api/v1/orders/%d/pay", envelope.Data.ID), ``)) + if paid.Code != 200 { + t.Fatalf("支付失败: %s", paid.Body.String()) + } +} + func firstVisit(t *testing.T, db *sql.DB, taskID int64) int64 { t.Helper() visits := allVisits(t, db, taskID) @@ -505,3 +527,374 @@ func TestPetOverviewMySQL(t *testing.T) { t.Fatalf("对账出现差异: %+v", settled) } } + +// Payment is what actually funds a task now, so the states either side of it +// matter: nothing before the money, no double effect after it, and a refund +// that cannot quietly reverse a payout that has already been made. +func TestPetOrderPaymentMySQL(t *testing.T) { + db := isolatedIMDatabase(t) + a := &App{db: db, hub: NewHub(), config: Config{Environment: "development", JWTSecret: "isolated-im-regression-secret-only"}} + a.config.ConfigEncryptionKey = strings.Repeat("k", 32) + owner, sitter := int64(1), int64(2) + + petID := petWithPhotos(t, a, owner, "汤圆", 3) + address := httptest.NewRecorder() + a.createPetAddress(address, imTestRequest(owner, "POST", "/api/v1/pet/addresses", + `{"cityName":"深圳","areaText":"福田区","detail":"5 栋 302","contact":"王女士 13900000000","latitude":22.5411,"longitude":113.9345}`)) + var addressEnvelope struct { + Data struct { + ID int64 `json:"id"` + } `json:"data"` + } + _ = json.Unmarshal(address.Body.Bytes(), &addressEnvelope) + task := httptest.NewRecorder() + a.createFeedTask(task, imTestRequest(owner, "POST", "/api/v1/pet/feed-tasks", + fmt.Sprintf(`{"petId":%d,"addressId":%d,"startDate":"2026-10-01","endDate":"2026-10-01","visitsPerDay":2, + "minMinutes":20,"priceCent":3000,"note":"","items":[{"kind":"feed","amount":"40 克"}]}`, petID, addressEnvelope.Data.ID))) + if task.Code != 200 { + t.Fatalf("HTTP %d: %s", task.Code, task.Body.String()) + } + var taskEnvelope struct { + Data struct { + ID int64 `json:"id"` + } `json:"data"` + } + _ = json.Unmarshal(task.Body.Bytes(), &taskEnvelope) + taskID := taskEnvelope.Data.ID + + orderID := int64(0) + t.Run("the order carries the real price of the task and is reused, not duplicated", func(t *testing.T) { + first := httptest.NewRecorder() + a.createFeedTaskOrder(first, imTestRequest(owner, "POST", fmt.Sprintf("/api/v1/pet/feed-tasks/%d/order", taskID), `{"channel":"alipay"}`)) + if first.Code != 200 { + t.Fatalf("HTTP %d: %s", first.Code, first.Body.String()) + } + var envelope struct { + Data struct { + AmountCent int64 `json:"amountCent"` + ID int64 `json:"id"` + } `json:"data"` + } + _ = json.Unmarshal(first.Body.Bytes(), &envelope) + orderID = envelope.Data.ID + if envelope.Data.AmountCent != 6000 { + t.Fatalf("订单金额 = %d,应当等于任务总价", envelope.Data.AmountCent) + } + second := httptest.NewRecorder() + a.createFeedTaskOrder(second, imTestRequest(owner, "POST", fmt.Sprintf("/api/v1/pet/feed-tasks/%d/order", taskID), `{"channel":"alipay"}`)) + var again struct { + Data struct { + ID int64 `json:"id"` + } `json:"data"` + } + _ = json.Unmarshal(second.Body.Bytes(), &again) + if again.Data.ID != orderID { + t.Fatalf("重复下单应当复用未支付的订单: %d vs %d", again.Data.ID, orderID) + } + // 别人的任务不能替他付。 + stranger := httptest.NewRecorder() + a.createFeedTaskOrder(stranger, imTestRequest(sitter, "POST", fmt.Sprintf("/api/v1/pet/feed-tasks/%d/order", taskID), `{"channel":"alipay"}`)) + if stranger.Code != 403 { + t.Fatalf("HTTP %d", stranger.Code) + } + }) + + t.Run("the task is funded by the payment, and paying twice changes nothing", func(t *testing.T) { + var status string + _ = db.QueryRow(`SELECT status FROM pet_feed_tasks WHERE id=?`, taskID).Scan(&status) + if status != "CREATED" { + t.Fatalf("下单还没付款,任务不该进入大厅: %s", status) + } + paid := httptest.NewRecorder() + a.payOrder(paid, imTestRequest(owner, "POST", fmt.Sprintf("/api/v1/orders/%d/pay", orderID), ``)) + if paid.Code != 200 { + t.Fatalf("HTTP %d: %s", paid.Code, paid.Body.String()) + } + var linked int64 + if err := db.QueryRow(`SELECT status,COALESCE(order_id,0) FROM pet_feed_tasks WHERE id=?`, taskID).Scan(&status, &linked); err != nil { + t.Fatal(err) + } + if status != "ESCROWED" || linked != orderID { + t.Fatalf("付款后任务应当托管并记住订单: status=%s order=%d", status, linked) + } + twice := httptest.NewRecorder() + a.payOrder(twice, imTestRequest(owner, "POST", fmt.Sprintf("/api/v1/orders/%d/pay", orderID), ``)) + if twice.Code != 200 { + t.Fatalf("重复支付应当幂等: HTTP %d %s", twice.Code, twice.Body.String()) + } + var orders int + if err := db.QueryRow(`SELECT COUNT(*) FROM orders WHERE product_type='pet_feed' AND product_id=?`, taskID).Scan(&orders); err != nil { + t.Fatal(err) + } + if orders != 1 { + t.Fatalf("同一个任务不该产生 %d 笔订单", orders) + } + }) + + t.Run("a refund releases an escrowed task but never a settled one", func(t *testing.T) { + tx, err := db.Begin() + if err != nil { + t.Fatal(err) + } + if err = a.revokePaidOrderTx(context.Background(), tx, orderID, owner, productPetFeed, taskID, 6000); err != nil { + t.Fatalf("退款失败: %v", err) + } + if err = tx.Commit(); err != nil { + t.Fatal(err) + } + var status string + _ = db.QueryRow(`SELECT status FROM pet_feed_tasks WHERE id=?`, taskID).Scan(&status) + if status != "REFUNDED" { + t.Fatalf("退款后任务应当关闭: %s", status) + } + // 已结算的任务:钱已经在喂养者账上,不能靠一条退款通知悄悄冲掉。 + if _, err = db.Exec(`UPDATE pet_feed_tasks SET status='SETTLED' WHERE id=?`, taskID); err != nil { + t.Fatal(err) + } + tx, err = db.Begin() + if err != nil { + t.Fatal(err) + } + defer func() { _ = tx.Rollback() }() + if err = a.revokePaidOrderTx(context.Background(), tx, orderID, owner, productPetFeed, taskID, 6000); err == nil { + t.Fatal("已结算的任务不该能直接退款") + } + }) + + t.Run("the sitter deposit is paid the same way and lands on the profile", func(t *testing.T) { + early := httptest.NewRecorder() + a.createDepositOrder(early, imTestRequest(sitter, "POST", "/api/v1/pet/sitter/deposit", `{"channel":"alipay"}`)) + if early.Code != 403 { + t.Fatalf("没有喂养者资料时不该能交保证金: HTTP %d", early.Code) + } + if _, err := db.Exec(`INSERT INTO user_verifications(user_id,verification_type,real_name,status,evidence_json) VALUES(?,'real_name','李四','VERIFIED','[]')`, sitter); err != nil { + t.Fatal(err) + } + apply := httptest.NewRecorder() + a.applySitter(apply, imTestRequest(sitter, "POST", "/api/v1/pet/sitter", + `{"cityName":"深圳","radiusM":5000,"intro":"养猫五年,会喂药和处理应激"}`)) + if apply.Code != 200 { + t.Fatalf("HTTP %d: %s", apply.Code, apply.Body.String()) + } + order := httptest.NewRecorder() + a.createDepositOrder(order, imTestRequest(sitter, "POST", "/api/v1/pet/sitter/deposit", `{"channel":"alipay"}`)) + if order.Code != 200 { + t.Fatalf("HTTP %d: %s", order.Code, order.Body.String()) + } + var envelope struct { + Data struct { + AmountCent int64 `json:"amountCent"` + ID int64 `json:"id"` + } `json:"data"` + } + _ = json.Unmarshal(order.Body.Bytes(), &envelope) + if envelope.Data.AmountCent != 20000 { + t.Fatalf("保证金金额 = %d,应当是配置的 200 元", envelope.Data.AmountCent) + } + paid := httptest.NewRecorder() + a.payOrder(paid, imTestRequest(sitter, "POST", fmt.Sprintf("/api/v1/orders/%d/pay", envelope.Data.ID), ``)) + if paid.Code != 200 { + t.Fatalf("HTTP %d: %s", paid.Code, paid.Body.String()) + } + var deposit int + if err := db.QueryRow(`SELECT deposit_cent FROM pet_sitters WHERE user_id=?`, sitter).Scan(&deposit); err != nil { + t.Fatal(err) + } + if deposit != 20000 { + t.Fatalf("保证金 = %d", deposit) + } + full := httptest.NewRecorder() + a.createDepositOrder(full, imTestRequest(sitter, "POST", "/api/v1/pet/sitter/deposit", `{"channel":"alipay"}`)) + if full.Code != 400 { + t.Fatalf("交满之后不该能重复下单: HTTP %d", full.Code) + } + }) + + t.Run("an operator can fund a task by hand, and it is written down", func(t *testing.T) { + manual := seedTaskAwaitingPayment(t, a, db, owner) + thin := httptest.NewRecorder() + a.adminEscrowFeedTask(thin, imTestRequest(1, "POST", fmt.Sprintf("/admin/v1/pet/feed-tasks/%d/escrow", manual), `{"note":"收到"}`)) + if thin.Code != 400 { + t.Fatalf("手工托管必须写清楚钱怎么收的: HTTP %d", thin.Code) + } + ok := httptest.NewRecorder() + a.adminEscrowFeedTask(ok, imTestRequest(1, "POST", fmt.Sprintf("/admin/v1/pet/feed-tasks/%d/escrow", manual), + `{"note":"灰度期线下微信收款,流水号 20260907001"}`)) + if ok.Code != 200 { + t.Fatalf("HTTP %d: %s", ok.Code, ok.Body.String()) + } + var status string + _ = db.QueryRow(`SELECT status FROM pet_feed_tasks WHERE id=?`, manual).Scan(&status) + if status != "ESCROWED" { + t.Fatalf("手工托管后状态 = %s", status) + } + }) +} + +func seedTaskAwaitingPayment(t *testing.T, a *App, db *sql.DB, owner int64) int64 { + t.Helper() + petID := petWithPhotos(t, a, owner, fmt.Sprintf("手工%d", time.Now().UnixNano()%1000), 3) + address := httptest.NewRecorder() + a.createPetAddress(address, imTestRequest(owner, "POST", "/api/v1/pet/addresses", + `{"cityName":"深圳","areaText":"罗湖区","detail":"1 栋 101","contact":"张先生 13800000000","latitude":22.5411,"longitude":113.9345}`)) + var addressEnvelope struct { + Data struct { + ID int64 `json:"id"` + } `json:"data"` + } + _ = json.Unmarshal(address.Body.Bytes(), &addressEnvelope) + task := httptest.NewRecorder() + a.createFeedTask(task, imTestRequest(owner, "POST", "/api/v1/pet/feed-tasks", + fmt.Sprintf(`{"petId":%d,"addressId":%d,"startDate":"2026-10-05","endDate":"2026-10-05","visitsPerDay":1, + "minMinutes":20,"priceCent":3000,"note":"","items":[{"kind":"feed","amount":"40 克"}]}`, petID, addressEnvelope.Data.ID))) + if task.Code != 200 { + t.Fatalf("HTTP %d: %s", task.Code, task.Body.String()) + } + var envelope struct { + Data struct { + ID int64 `json:"id"` + } `json:"data"` + } + _ = json.Unmarshal(task.Body.Bytes(), &envelope) + return envelope.Data.ID +} + +// The rules that make a check-in evidence rather than a formality: it has to +// happen in order, and the feeding photo cannot be taken in the same breath as +// the arrival one. +func TestPetCheckinDisciplineMySQL(t *testing.T) { + db := isolatedIMDatabase(t) + a := &App{db: db, hub: NewHub(), config: Config{Environment: "development", JWTSecret: "isolated-im-regression-secret-only"}} + a.config.ConfigEncryptionKey = strings.Repeat("k", 32) + owner, sitter := int64(1), int64(2) + taskID := seedTaskAwaitingPayment(t, a, db, owner) + if _, err := db.Exec(`UPDATE pet_feed_tasks SET status='ASSIGNED',sitter_user_id=? WHERE id=?`, sitter, taskID); err != nil { + t.Fatal(err) + } + visitID := firstVisit(t, db, taskID) + checkin := func(step string, at time.Time) (int, string) { + t.Helper() + w := httptest.NewRecorder() + body := fmt.Sprintf(`{"step":%q,"mediaUrl":"https://cdn.example.com/%s.jpg","latitude":22.5411,"longitude":113.9345,"capturedAt":%q}`, + step, step, at.Format(time.RFC3339)) + a.checkinFeedVisit(w, imTestRequest(sitter, "POST", fmt.Sprintf("/api/v1/pet/feed-visits/%d/checkin", visitID), body)) + return w.Code, w.Body.String() + } + + now := time.Now() + if status, body := checkin("feed", now); status != 400 || !strings.Contains(body, "到达") { + t.Fatalf("没到门口就先拍喂食,应当被拦下: HTTP %d %s", status, body) + } + if status, body := checkin("arrive", now); status != 200 { + t.Fatalf("到达打卡失败: HTTP %d %s", status, body) + } + if status, body := checkin("feed", now.Add(20*time.Second)); status != 400 || !strings.Contains(body, "间隔") { + t.Fatalf("到达后 20 秒就拍喂食,应当被拦下: HTTP %d %s", status, body) + } + if status, body := checkin("leave", now.Add(5*time.Minute)); status != 400 || !strings.Contains(body, "喂食") { + t.Fatalf("还没喂就走,应当被拦下: HTTP %d %s", status, body) + } + if status, body := checkin("feed", now.Add(3*time.Minute)); status != 200 { + t.Fatalf("间隔够了应当放行: HTTP %d %s", status, body) + } + if status, body := checkin("leave", now.Add(20*time.Minute)); status != 200 { + t.Fatalf("离开打卡失败: HTTP %d %s", status, body) + } +} + +// Two promises the schema makes and the code has to keep: the sitter gets the +// door number only while the job is live, and the plaintext goes away after it. +func TestPetAddressLifecycleMySQL(t *testing.T) { + db := isolatedIMDatabase(t) + a := &App{db: db, hub: NewHub(), config: Config{Environment: "development", JWTSecret: "isolated-im-regression-secret-only"}} + a.config.ConfigEncryptionKey = strings.Repeat("k", 32) + owner, sitter := int64(1), int64(2) + + create := httptest.NewRecorder() + a.createPetAddress(create, imTestRequest(owner, "POST", "/api/v1/pet/addresses", + `{"cityName":"深圳","areaText":"南山区","detail":"3 栋 1201","contact":"张先生 13800000000","emergency":"李阿姨 13700000000","vetHospital":"科苑宠物医院","latitude":22.5411,"longitude":113.9345}`)) + if create.Code != 200 { + t.Fatalf("HTTP %d: %s", create.Code, create.Body.String()) + } + var envelope struct { + Data struct { + ID int64 `json:"id"` + } `json:"data"` + } + _ = json.Unmarshal(create.Body.Bytes(), &envelope) + addressID := envelope.Data.ID + + petID := petWithPhotos(t, a, owner, "球球", 3) + task := httptest.NewRecorder() + a.createFeedTask(task, imTestRequest(owner, "POST", "/api/v1/pet/feed-tasks", + fmt.Sprintf(`{"petId":%d,"addressId":%d,"startDate":"2026-10-01","endDate":"2026-10-01","visitsPerDay":1, + "minMinutes":20,"priceCent":3000,"note":"","items":[{"kind":"feed","amount":"40 克"}]}`, petID, addressID))) + if task.Code != 200 { + t.Fatalf("HTTP %d: %s", task.Code, task.Body.String()) + } + var taskEnvelope struct { + Data struct { + ID int64 `json:"id"` + } `json:"data"` + } + _ = json.Unmarshal(task.Body.Bytes(), &taskEnvelope) + taskID := taskEnvelope.Data.ID + if _, err := db.Exec(`UPDATE pet_feed_tasks SET status='ASSIGNED',sitter_user_id=? WHERE id=?`, sitter, taskID); err != nil { + t.Fatal(err) + } + + var detail struct { + Address map[string]any `json:"address"` + } + if err := json.Unmarshal(imTestCall(t, a.feedTaskDetail, sitter, "GET", fmt.Sprintf("/api/v1/pet/feed-tasks/%d", taskID), "", 200), &detail); err != nil { + t.Fatal(err) + } + if detail.Address["emergency"] != "李阿姨 13700000000" || detail.Address["vetHospital"] != "科苑宠物医院" { + t.Fatalf("上门的人要能拿到紧急联系人和医院: %+v", detail.Address) + } + + // 任务还在进行时,什么都不该被清掉。 + a.scheduleAddressPurge(context.Background()) + var purge sql.NullTime + if err := db.QueryRow(`SELECT purge_at FROM pet_addresses WHERE id=?`, addressID).Scan(&purge); err != nil { + t.Fatal(err) + } + if purge.Valid { + t.Fatal("进行中的任务不该给地址排清除") + } + + if _, err := db.Exec(`UPDATE pet_feed_tasks SET status='SETTLED' WHERE id=?`, taskID); err != nil { + t.Fatal(err) + } + if scheduled := a.scheduleAddressPurge(context.Background()); scheduled != 1 { + t.Fatalf("任务结束后应当排一条清除: %d", scheduled) + } + if purged := a.purgeDueAddresses(context.Background()); purged != 0 { + t.Fatal("还没到期就不该清") + } + if _, err := db.Exec(`UPDATE pet_addresses SET purge_at=DATE_SUB(NOW(3),INTERVAL 1 HOUR) WHERE id=?`, addressID); err != nil { + t.Fatal(err) + } + if purged := a.purgeDueAddresses(context.Background()); purged != 1 { + t.Fatalf("到期应当清除: %d", purged) + } + var detailCipher []byte + var hospital, area string + if err := db.QueryRow(`SELECT detail_cipher,vet_hospital,area_text FROM pet_addresses WHERE id=?`, addressID).Scan(&detailCipher, &hospital, &area); err != nil { + t.Fatal(err) + } + if len(detailCipher) != 0 || hospital != "" { + t.Fatalf("明文应当被抹掉: detail=%d hospital=%q", len(detailCipher), hospital) + } + if area != "南山区" { + t.Fatalf("街道要留着,历史记录还要显示: %q", area) + } + // 被清空的地址不能再拿来发新任务,否则喂养者会拿到一个空门牌。 + reuse := httptest.NewRecorder() + a.createFeedTask(reuse, imTestRequest(owner, "POST", "/api/v1/pet/feed-tasks", + fmt.Sprintf(`{"petId":%d,"addressId":%d,"startDate":"2026-11-01","endDate":"2026-11-01","visitsPerDay":1, + "minMinutes":20,"priceCent":3000,"note":"","items":[{"kind":"feed","amount":"40 克"}]}`, petID, addressID))) + if reuse.Code != 404 { + t.Fatalf("清空后的地址不该能继续下单: HTTP %d %s", reuse.Code, reuse.Body.String()) + } +} diff --git a/im/backend/internal/app/pet_feed_service.go b/im/backend/internal/app/pet_feed_service.go index 4114ee5..37ef1fb 100644 --- a/im/backend/internal/app/pet_feed_service.go +++ b/im/backend/internal/app/pet_feed_service.go @@ -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} } diff --git a/im/backend/migrations/039_pet_feed_safety.sql b/im/backend/migrations/039_pet_feed_safety.sql new file mode 100644 index 0000000..2c212c1 --- /dev/null +++ b/im/backend/migrations/039_pet_feed_safety.sql @@ -0,0 +1,13 @@ +SET NAMES utf8mb4; + +-- 紧急联系人与常去的医院:喂养者在别人家里发现宠物不对劲时, +-- 需要立刻能联系到人,而不是等主人看手机。和门牌一样加密存放, +-- 只在选定喂养者之后随地址一起下发。 +ALTER TABLE pet_addresses + ADD COLUMN emergency_cipher VARBINARY(512) NULL COMMENT '紧急联系人与电话,AES-GCM' AFTER contact_cipher, + ADD COLUMN vet_hospital VARCHAR(200) NOT NULL DEFAULT '' COMMENT '常去的宠物医院' AFTER emergency_cipher; + +INSERT INTO system_configs(config_key,config_value,value_type,description) VALUES + ('pet.feed_checkin_min_gap_seconds','120','integer','到达与喂食打卡之间的最小间隔(秒),用于防止一次性摆拍'), + ('pet.address_purge_days','7','integer','任务结束后多少天抹掉地址明文') +ON DUPLICATE KEY UPDATE description=VALUES(description);