整改一期:发布这条链路先跑顺

四条改动都在"发布一个代喂任务"这条路上:

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-07 10:31:39 +08:00
co-authored by Claude Opus 5
parent 9345805133
commit ebf7189533
6 changed files with 287 additions and 15 deletions
@@ -24,12 +24,13 @@ func TestPetFeedTaskMySQL(t *testing.T) {
petID := petWithPhotos(t, a, owner, "咪咪", 3)
var addressID int64
t.Run("an address without a location is refused", func(t *testing.T) {
t.Run("an address keeps its door number encrypted", func(t *testing.T) {
// 定位拿不到也要能存下来,只是这条地址此后打卡不做距离核验。
w := httptest.NewRecorder()
a.createPetAddress(w, imTestRequest(owner, "POST", "/api/v1/pet/addresses",
`{"cityName":"深圳","areaText":"南山区科技园","detail":"3 栋 1201","contact":"张先生 13800000000"}`))
if w.Code != 400 {
t.Fatalf("没有定位的地址应当被拒绝,打卡要靠它比对: HTTP %d %s", w.Code, w.Body.String())
if w.Code != 200 || !strings.Contains(w.Body.String(), `"located":false`) {
t.Fatalf("没有定位也该能保存并标记未核验: HTTP %d %s", w.Code, w.Body.String())
}
ok := httptest.NewRecorder()
a.createPetAddress(ok, imTestRequest(owner, "POST", "/api/v1/pet/addresses",
@@ -898,3 +899,113 @@ func TestPetAddressLifecycleMySQL(t *testing.T) {
t.Fatalf("清空后的地址不该能继续下单: HTTP %d %s", reuse.Code, reuse.Body.String())
}
}
// 一期的三条服务端约定:定位拿不到也要能存地址、推荐价按物种与城市给、
// 没付款的任务不会永远挂在那里。
func TestPetFeedPhaseOneMySQL(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)
t.Run("an address saves without coordinates and says so", func(t *testing.T) {
w := httptest.NewRecorder()
a.createPetAddress(w, imTestRequest(owner, "POST", "/api/v1/pet/addresses",
`{"cityCode":"440300","cityName":"深圳","areaText":"南山区科技园","detail":"3 栋 1201","contact":"张先生 13800000000"}`))
if w.Code != 200 {
t.Fatalf("定位失败不该挡住地址保存: HTTP %d %s", w.Code, w.Body.String())
}
var envelope struct {
Data struct {
ID int64 `json:"id"`
Located bool `json:"located"`
} `json:"data"`
}
_ = json.Unmarshal(w.Body.Bytes(), &envelope)
if envelope.Data.Located {
t.Fatal("没有坐标就不能说自己定位过")
}
var latitude sql.NullFloat64
if err := db.QueryRow(`SELECT latitude FROM pet_addresses WHERE id=?`, envelope.Data.ID).Scan(&latitude); err != nil {
t.Fatal(err)
}
if latitude.Valid {
t.Fatal("坐标应当留空,而不是写成 0")
}
var list struct {
Items []map[string]any `json:"items"`
}
if err := json.Unmarshal(imTestCall(t, a.petAddresses, owner, "GET", "/api/v1/pet/addresses", "", 200), &list); err != nil {
t.Fatal(err)
}
if len(list.Items) != 1 || list.Items[0]["located"] != false || list.Items[0]["cityCode"] != "440300" {
t.Fatalf("地址列表要带上城市码与核验状态: %+v", list.Items)
}
})
t.Run("the suggested price follows species and city", func(t *testing.T) {
read := func(query string) map[string]any {
t.Helper()
var view map[string]any
if err := json.Unmarshal(imTestCall(t, a.feedPriceSuggestion, owner, "GET",
"/api/v1/pet/feed-price"+query, "", 200), &view); err != nil {
t.Fatal(err)
}
return view
}
cat := read("?species=cat&cityCode=440300&minMinutes=20")
dog := read("?species=dog&cityCode=440300&minMinutes=20")
small := read("?species=cat&cityCode=620100&minMinutes=20")
longer := read("?species=cat&cityCode=440300&minMinutes=45")
if dog["priceCent"].(float64) <= cat["priceCent"].(float64) {
t.Fatalf("狗要遛,单价应当高于猫: %v vs %v", dog["priceCent"], cat["priceCent"])
}
if small["priceCent"].(float64) >= cat["priceCent"].(float64) {
t.Fatalf("没有系数的城市不该比深圳贵: %v vs %v", small["priceCent"], cat["priceCent"])
}
if longer["priceCent"].(float64) <= cat["priceCent"].(float64) {
t.Fatalf("每次待更久应当更贵: %v", longer["priceCent"])
}
// 推荐价必须落在服务端自己会接受的区间里,否则填进去就提交不了。
for _, view := range []map[string]any{cat, dog, small, longer} {
price := view["priceCent"].(float64)
if price < view["minPriceCent"].(float64) || price > view["maxPriceCent"].(float64) {
t.Fatalf("推荐价超出可提交区间: %+v", view)
}
}
// 认识的物种要按自己的档位走,不认识的落到 other。
unknown := read("?species=dragon&cityCode=440300")
if unknown["species"] != "other" {
t.Fatalf("未知物种应当归到 other: %v", unknown["species"])
}
})
t.Run("unpaid tasks are closed after the grace period", func(t *testing.T) {
taskID := seedTaskAwaitingPayment(t, a, db, owner)
if closed := a.closeStaleUnpaidTasks(context.Background()); closed != 0 {
t.Fatalf("刚建的任务不该被关掉: %d", closed)
}
if _, err := db.Exec(`UPDATE pet_feed_tasks SET created_at=DATE_SUB(NOW(3),INTERVAL 30 HOUR) WHERE id=?`, taskID); err != nil {
t.Fatal(err)
}
if closed := a.closeStaleUnpaidTasks(context.Background()); closed != 1 {
t.Fatalf("超过 24 小时未支付应当关闭: %d", closed)
}
var status string
_ = db.QueryRow(`SELECT status FROM pet_feed_tasks WHERE id=?`, taskID).Scan(&status)
if status != "CANCELLED" {
t.Fatalf("状态 = %s", status)
}
// 已经付过款的任务不受影响。
paid := seedTaskAwaitingPayment(t, a, db, owner)
payFeedTask(t, a, owner, paid)
if _, err := db.Exec(`UPDATE pet_feed_tasks SET created_at=DATE_SUB(NOW(3),INTERVAL 30 HOUR) WHERE id=?`, paid); err != nil {
t.Fatal(err)
}
a.closeStaleUnpaidTasks(context.Background())
_ = db.QueryRow(`SELECT status FROM pet_feed_tasks WHERE id=?`, paid).Scan(&status)
if status != "ESCROWED" {
t.Fatalf("已托管的任务被误关了: %s", status)
}
})
}