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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-07 09:07:38 +08:00
co-authored by Claude Opus 5
parent 97351777ec
commit 9345805133
9 changed files with 889 additions and 81 deletions
@@ -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())
}
}