整改三期:退款、后台增改删、悬赏
退款原来只有会员那条路,而且只能全额、只改本地状态。现在代喂任务的退款 能分几次退到退完:每一笔单独记一行(谁退的、多少、走哪条通道、为什么), 订单上的 refunded_cent 跟着累加,退完且未结算的任务才关闭。渠道接通时按 金额原路退回并带独立幂等键,没接通或沙箱支付的登记为"线下已退"——假装 调用了渠道比说实话更糟。已结算的任务不能顺手退:钱在喂养者账上,要退只能 由平台承担,必须显式勾选。 后台从"只能通过或驳回"变成能改能删:宠物档案可代建、可编辑、可软删除 (有进行中任务的删不掉),送养与配种能改文案和城市、能下架能删,代喂任务 能改备注、能取消(托管中的钱必须先退)。每一个写操作都进审计日志。 悬赏按方案里的 A 做:主人自愿加的钱跟着任务一起托管,结算时全额给喂养者, 平台服务费只算在基础报酬上。抽悬赏是喂养者一眼能算出来的事。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ebf7189533
commit
a889cb42e4
@@ -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(¬ified); 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user