整改三期:退款、后台增改删、悬赏

退款原来只有会员那条路,而且只能全额、只改本地状态。现在代喂任务的退款
能分几次退到退完:每一笔单独记一行(谁退的、多少、走哪条通道、为什么),
订单上的 refunded_cent 跟着累加,退完且未结算的任务才关闭。渠道接通时按
金额原路退回并带独立幂等键,没接通或沙箱支付的登记为"线下已退"——假装
调用了渠道比说实话更糟。已结算的任务不能顺手退:钱在喂养者账上,要退只能
由平台承担,必须显式勾选。

后台从"只能通过或驳回"变成能改能删:宠物档案可代建、可编辑、可软删除
(有进行中任务的删不掉),送养与配种能改文案和城市、能下架能删,代喂任务
能改备注、能取消(托管中的钱必须先退)。每一个写操作都进审计日志。

悬赏按方案里的 A 做:主人自愿加的钱跟着任务一起托管,结算时全额给喂养者,
平台服务费只算在基础报酬上。抽悬赏是喂养者一眼能算出来的事。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-07 11:25:02 +08:00
co-authored by Claude Opus 5
parent ebf7189533
commit a889cb42e4
8 changed files with 1011 additions and 22 deletions
@@ -1009,3 +1009,318 @@ func TestPetFeedPhaseOneMySQL(t *testing.T) {
}
})
}
// 三期的三件事:悬赏跟着任务走且不被抽成、退款能分几次退且退不动已结算的钱、
// 管理端能改能删但每一步都留痕。
func TestPetFeedTipAndRefundMySQL(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)
address := httptest.NewRecorder()
a.createPetAddress(address, imTestRequest(owner, "POST", "/api/v1/pet/addresses",
`{"cityCode":"440300","cityName":"深圳","areaText":"南山区","detail":"3 栋 1201","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)
petID := petWithPhotos(t, a, owner, "元宝", 3)
var taskID int64
t.Run("the tip rides along and the platform does not take a cut of it", func(t *testing.T) {
w := httptest.NewRecorder()
a.createFeedTask(w, imTestRequest(owner, "POST", "/api/v1/pet/feed-tasks",
fmt.Sprintf(`{"petId":%d,"addressId":%d,"startDate":"2026-10-01","endDate":"2026-10-02","visitsPerDay":2,
"minMinutes":20,"priceCent":3000,"tipCent":2000,"note":"","items":[{"kind":"feed","amount":"40 克"}]}`,
petID, addressEnvelope.Data.ID)))
if w.Code != 200 {
t.Fatalf("HTTP %d: %s", w.Code, w.Body.String())
}
var envelope struct {
Data struct {
ID int64 `json:"id"`
GrossCent int64 `json:"grossCent"`
FeeCent int64 `json:"platformFeeCent"`
PayoutCent int64 `json:"payoutCent"`
TipCent int64 `json:"tipCent"`
} `json:"data"`
}
_ = json.Unmarshal(w.Body.Bytes(), &envelope)
taskID = envelope.Data.ID
// 4 次 × 30 元 = 120 元基础,抽成 15% 是 18 元,悬赏 20 元原样给喂养者。
if envelope.Data.GrossCent != 14000 || envelope.Data.FeeCent != 1800 || envelope.Data.PayoutCent != 12200 {
t.Fatalf("悬赏不该被抽成: %+v", envelope.Data)
}
if envelope.Data.TipCent != 2000 {
t.Fatalf("悬赏没有记下来: %+v", envelope.Data)
}
over := httptest.NewRecorder()
a.createFeedTask(over, 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,"tipCent":9999999,"note":"","items":[{"kind":"feed","amount":"40 克"}]}`,
petID, addressEnvelope.Data.ID)))
if over.Code != 400 {
t.Fatalf("超出上限的悬赏应当被拒绝: HTTP %d", over.Code)
}
})
t.Run("a refund can be taken in pieces and stops at the total", func(t *testing.T) {
payFeedTask(t, a, owner, taskID)
thin := httptest.NewRecorder()
a.adminRefundFeedTask(thin, imTestRequest(1, "POST", fmt.Sprintf("/admin/v1/pet/feed-tasks/%d/refund", taskID), `{"reason":"退"}`))
if thin.Code != 400 {
t.Fatalf("退款必须写原因: HTTP %d", thin.Code)
}
part := httptest.NewRecorder()
a.adminRefundFeedTask(part, imTestRequest(1, "POST", fmt.Sprintf("/admin/v1/pet/feed-tasks/%d/refund", taskID),
`{"amountCent":4000,"reason":"主人临时改期,先退一次上门的钱"}`))
if part.Code != 200 {
t.Fatalf("HTTP %d: %s", part.Code, part.Body.String())
}
var status string
var refunded int64
if err := db.QueryRow(`SELECT status,refunded_cent FROM pet_feed_tasks WHERE id=?`, taskID).Scan(&status, &refunded); err != nil {
t.Fatal(err)
}
// 部分退款不改状态:服务还要继续。
if status != "ESCROWED" || refunded != 4000 {
t.Fatalf("部分退款之后 status=%s refunded=%d", status, refunded)
}
tooMuch := httptest.NewRecorder()
a.adminRefundFeedTask(tooMuch, imTestRequest(1, "POST", fmt.Sprintf("/admin/v1/pet/feed-tasks/%d/refund", taskID),
`{"amountCent":999999,"reason":"想退超过收到的钱"}`))
if tooMuch.Code != 400 {
t.Fatalf("不能退超过收到的金额: HTTP %d %s", tooMuch.Code, tooMuch.Body.String())
}
rest := httptest.NewRecorder()
a.adminRefundFeedTask(rest, imTestRequest(1, "POST", fmt.Sprintf("/admin/v1/pet/feed-tasks/%d/refund", taskID),
`{"reason":"主人取消行程,余下全退"}`))
if rest.Code != 200 {
t.Fatalf("HTTP %d: %s", rest.Code, rest.Body.String())
}
if err := db.QueryRow(`SELECT status,refunded_cent FROM pet_feed_tasks WHERE id=?`, taskID).Scan(&status, &refunded); err != nil {
t.Fatal(err)
}
if status != "REFUNDED" || refunded != 14000 {
t.Fatalf("退完之后 status=%s refunded=%d", status, refunded)
}
var orderRefunded int64
if err := db.QueryRow(`SELECT refunded_cent FROM orders WHERE product_type='pet_feed' AND product_id=?`, taskID).Scan(&orderRefunded); err != nil {
t.Fatal(err)
}
if orderRefunded != 14000 {
t.Fatalf("订单上的已退金额对不上: %d", orderRefunded)
}
var rows int
if err := db.QueryRow(`SELECT COUNT(*) FROM pet_feed_refunds WHERE task_id=?`, taskID).Scan(&rows); err != nil {
t.Fatal(err)
}
if rows != 2 {
t.Fatalf("两次退款应当各留一行: %d", rows)
}
done := httptest.NewRecorder()
a.adminRefundFeedTask(done, imTestRequest(1, "POST", fmt.Sprintf("/admin/v1/pet/feed-tasks/%d/refund", taskID),
`{"reason":"再退一次看看会不会重复退"}`))
if done.Code != 400 {
t.Fatalf("退完之后不该还能退: HTTP %d", done.Code)
}
})
t.Run("a settled task cannot be refunded by accident", func(t *testing.T) {
settled := seedAssignedTask(t, a, db, owner, sitter)
if _, err := db.Exec(`UPDATE pet_feed_tasks SET status='SETTLED',order_id=NULL WHERE id=?`, settled); err != nil {
t.Fatal(err)
}
careless := httptest.NewRecorder()
a.adminRefundFeedTask(careless, imTestRequest(1, "POST", fmt.Sprintf("/admin/v1/pet/feed-tasks/%d/refund", settled),
`{"reason":"主人事后反悔要退款"}`))
if careless.Code != 400 || !strings.Contains(careless.Body.String(), "平台承担") {
t.Fatalf("已结算的任务不该顺手退掉: HTTP %d %s", careless.Code, careless.Body.String())
}
deliberate := httptest.NewRecorder()
a.adminRefundFeedTask(deliberate, imTestRequest(1, "POST", fmt.Sprintf("/admin/v1/pet/feed-tasks/%d/refund", settled),
`{"platformBears":true,"amountCent":3000,"reason":"服务确有问题,这笔由平台补给主人"}`))
if deliberate.Code != 200 {
t.Fatalf("HTTP %d: %s", deliberate.Code, deliberate.Body.String())
}
var status string
var bears bool
if err := db.QueryRow(`SELECT t.status,f.platform_bears FROM pet_feed_tasks t
JOIN pet_feed_refunds f ON f.task_id=t.id WHERE t.id=?`, settled).Scan(&status, &bears); err != nil {
t.Fatal(err)
}
if status != "SETTLED" || !bears {
t.Fatalf("平台承担的退款不该改任务状态: status=%s bears=%v", status, bears)
}
// 喂养者已经拿到的钱不能被这条退款倒扣回去。
var balance int64
_ = db.QueryRow(`SELECT COALESCE(available_cent,0) FROM wallet_accounts WHERE user_id=?`, sitter).Scan(&balance)
if balance < 0 {
t.Fatalf("喂养者余额被倒扣了: %d", balance)
}
})
}
func TestPetAdminCrudMySQL(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 := int64(1)
var petID int64
t.Run("an operator can create a profile on someone else's behalf", func(t *testing.T) {
bad := httptest.NewRecorder()
a.adminCreatePet(bad, imTestRequest(1, "POST", "/admin/v1/pets",
`{"ownerUserId":1,"name":"测试","species":"cat","birthday":"2026-13-45"}`))
if bad.Code != 400 {
t.Fatalf("日期不合法应当被拒: HTTP %d %s", bad.Code, bad.Body.String())
}
w := httptest.NewRecorder()
a.adminCreatePet(w, imTestRequest(1, "POST", "/admin/v1/pets",
`{"ownerUserId":1,"name":"客服建的","species":"cat","breed":"狸花","gender":2,"birthday":"2023-01-01",
"weightG":4000,"vaccinatedAt":"2026-06-01","photos":["https://cdn.example.com/a.jpg","https://cdn.example.com/b.jpg"]}`))
if w.Code != 200 {
t.Fatalf("HTTP %d: %s", w.Code, w.Body.String())
}
var envelope struct {
Data struct {
ID int64 `json:"id"`
} `json:"data"`
}
_ = json.Unmarshal(w.Body.Bytes(), &envelope)
petID = envelope.Data.ID
var name, avatar string
var photos int
if err := db.QueryRow(`SELECT p.name,p.avatar_url,(SELECT COUNT(*) FROM pet_media m WHERE m.pet_id=p.id)
FROM pets p WHERE p.id=?`, petID).Scan(&name, &avatar, &photos); err != nil {
t.Fatal(err)
}
if name != "客服建的" || photos != 2 || avatar == "" {
t.Fatalf("name=%s photos=%d avatar=%q", name, photos, avatar)
}
var logged int
if err := db.QueryRow(`SELECT COUNT(*) FROM admin_audit_logs WHERE action='create' AND target_type='pet' AND target_id=?`, petID).Scan(&logged); err != nil {
t.Fatal(err)
}
if logged != 1 {
t.Fatalf("代建档案要留审计: %d", logged)
}
})
t.Run("editing replaces the photo set and tells the owner", func(t *testing.T) {
w := httptest.NewRecorder()
a.adminUpdatePet(w, imTestRequest(1, "PUT", fmt.Sprintf("/admin/v1/pets/%d", petID),
`{"name":"改过的名字","species":"cat","breed":"英短","gender":2,"weightG":4500,
"vaccinatedAt":"2026-07-01","photos":["https://cdn.example.com/c.jpg"]}`))
if w.Code != 200 {
t.Fatalf("HTTP %d: %s", w.Code, w.Body.String())
}
var name string
var photos int
if err := db.QueryRow(`SELECT p.name,(SELECT COUNT(*) FROM pet_media m WHERE m.pet_id=p.id) FROM pets p WHERE p.id=?`, petID).
Scan(&name, &photos); err != nil {
t.Fatal(err)
}
if name != "改过的名字" || photos != 1 {
t.Fatalf("name=%s photos=%d", name, photos)
}
var notified int
if err := db.QueryRow(`SELECT COUNT(*) FROM notifications WHERE user_id=? AND biz_type='pet'`, owner).Scan(&notified); err != nil {
t.Fatal(err)
}
if notified == 0 {
t.Fatal("后台改了用户的档案,用户应当收到通知")
}
})
t.Run("a listing can be corrected and taken down", func(t *testing.T) {
listed := petWithPhotos(t, a, owner, "待送养", 3)
publish := httptest.NewRecorder()
a.createAdoption(publish, imTestRequest(owner, "POST", "/api/v1/pet/adoptions",
fmt.Sprintf(`{"petId":%d,"cityCode":"440300","cityName":"深圳","reason":"工作变动实在照顾不了","requirements":"希望定期反馈"}`, listed)))
if publish.Code != 200 {
t.Fatalf("HTTP %d: %s", publish.Code, publish.Body.String())
}
var adoptionID int64
if err := db.QueryRow(`SELECT id FROM pet_adoptions WHERE pet_id=?`, listed).Scan(&adoptionID); err != nil {
t.Fatal(err)
}
edit := httptest.NewRecorder()
a.adminUpdatePetListing(edit, imTestRequest(1, "PUT", fmt.Sprintf("/admin/v1/pet/listings/%d", adoptionID),
`{"kind":"adoption","body":"客服代为整理过的送养说明","cityName":"深圳市"}`))
if edit.Code != 200 {
t.Fatalf("HTTP %d: %s", edit.Code, edit.Body.String())
}
var reason, city string
if err := db.QueryRow(`SELECT reason,city_name FROM pet_adoptions WHERE id=?`, adoptionID).Scan(&reason, &city); err != nil {
t.Fatal(err)
}
if reason != "客服代为整理过的送养说明" || city != "深圳市" {
t.Fatalf("reason=%s city=%s", reason, city)
}
remove := httptest.NewRecorder()
a.adminDeletePetListing(remove, imTestRequest(1, "DELETE", fmt.Sprintf("/admin/v1/pet/listings/%d?kind=adoption", adoptionID), ``))
if remove.Code != 200 {
t.Fatalf("HTTP %d: %s", remove.Code, remove.Body.String())
}
var deleted sql.NullTime
if err := db.QueryRow(`SELECT deleted_at FROM pet_adoptions WHERE id=?`, adoptionID).Scan(&deleted); err != nil {
t.Fatal(err)
}
if !deleted.Valid {
t.Fatal("删除应当是软删除,记录还要留着")
}
})
t.Run("a pet with a live task cannot be deleted", func(t *testing.T) {
busy := seedTaskAwaitingPayment(t, a, db, owner)
var busyPet int64
if err := db.QueryRow(`SELECT pet_id FROM pet_feed_tasks WHERE id=?`, busy).Scan(&busyPet); err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
a.adminDeletePet(w, imTestRequest(1, "DELETE", fmt.Sprintf("/admin/v1/pets/%d", busyPet), ``))
if w.Code != 400 {
t.Fatalf("有进行中任务的宠物不该能删: HTTP %d %s", w.Code, w.Body.String())
}
// 任务结束之后就可以删了,送养信息跟着下架。
if _, err := db.Exec(`UPDATE pet_feed_tasks SET status='CANCELLED' WHERE id=?`, busy); err != nil {
t.Fatal(err)
}
ok := httptest.NewRecorder()
a.adminDeletePet(ok, imTestRequest(1, "DELETE", fmt.Sprintf("/admin/v1/pets/%d", busyPet), ``))
if ok.Code != 200 {
t.Fatalf("HTTP %d: %s", ok.Code, ok.Body.String())
}
})
t.Run("cancelling a task needs a reason and refuses to strand money", func(t *testing.T) {
funded := seedTaskAwaitingPayment(t, a, db, owner)
payFeedTask(t, a, owner, funded)
silent := httptest.NewRecorder()
a.adminUpdateFeedTask(silent, imTestRequest(1, "PUT", fmt.Sprintf("/admin/v1/pet/feed-tasks/%d", funded), `{"cancel":true}`))
if silent.Code != 400 {
t.Fatalf("取消要写原因: HTTP %d", silent.Code)
}
holding := httptest.NewRecorder()
a.adminUpdateFeedTask(holding, imTestRequest(1, "PUT", fmt.Sprintf("/admin/v1/pet/feed-tasks/%d", funded),
`{"cancel":true,"reason":"主人来电取消"}`))
if holding.Code != 400 || !strings.Contains(holding.Body.String(), "退给主人") {
t.Fatalf("还托管着钱就取消,应当先要求退款: HTTP %d %s", holding.Code, holding.Body.String())
}
refund := httptest.NewRecorder()
a.adminRefundFeedTask(refund, imTestRequest(1, "POST", fmt.Sprintf("/admin/v1/pet/feed-tasks/%d/refund", funded),
`{"reason":"主人来电取消,全额退回"}`))
if refund.Code != 200 {
t.Fatalf("HTTP %d: %s", refund.Code, refund.Body.String())
}
var status string
_ = db.QueryRow(`SELECT status FROM pet_feed_tasks WHERE id=?`, funded).Scan(&status)
if status != "REFUNDED" {
t.Fatalf("全额退款后状态 = %s", status)
}
})
}