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

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

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

价格从写死的 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
+1
View File
@@ -265,6 +265,7 @@ func (a *App) userRoutes() []rest.Route {
{Method: http.MethodGet, Path: "/api/v1/pet/sitter", Handler: auth(a.sitterProfile)},
{Method: http.MethodPost, Path: "/api/v1/pet/sitter", Handler: auth(a.applySitter)},
{Method: http.MethodGet, Path: "/api/v1/pet/sitters/:id/reviews", Handler: auth(a.sitterReviews)},
{Method: http.MethodGet, Path: "/api/v1/pet/feed-price", Handler: auth(a.feedPriceSuggestion)},
{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)},
+20 -12
View File
@@ -48,6 +48,7 @@ type feedTaskView struct {
TotalVisits int `json:"totalVisits"`
Done int `json:"completedVisits"`
Items []string `json:"items"`
Located bool `json:"addressLocated"`
Mine bool `json:"mine"`
IsSitter bool `json:"isSitter"`
CreatedAt string `json:"createdAt"`
@@ -98,10 +99,9 @@ func (a *App) createPetAddress(w http.ResponseWriter, r *http.Request) {
fail(w, http.StatusBadRequest, 20001, "地址内容过长")
return
}
if req.Latitude == 0 || req.Longitude == 0 {
fail(w, http.StatusBadRequest, 20001, "请先获取定位,打卡需要与地址比对")
return
}
// 定位可能因为权限、系统或没有地图 SDK 而拿不到。与其把人卡在这一步,
// 不如让他把地址存下来,并记住这条地址没有坐标:打卡照常,只是不做距离核验。
located := req.Latitude != 0 && req.Longitude != 0
// Door number and phone are encrypted at rest; only the coarse area is ever
// shown to people browsing the hall.
detail, err := a.encryptPhone(req.Detail)
@@ -122,19 +122,24 @@ func (a *App) createPetAddress(w http.ResponseWriter, r *http.Request) {
return
}
}
var latitude, longitude any
if located {
latitude, longitude = req.Latitude, req.Longitude
}
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)
detail, contact, emergency, req.VetHospital, latitude, longitude)
if err != nil {
fail(w, http.StatusInternalServerError, 50001, "保存失败")
return
}
id, _ := result.LastInsertId()
reply(w, map[string]any{"id": id})
reply(w, map[string]any{"id": id, "located": located})
}
func (a *App) petAddresses(w http.ResponseWriter, r *http.Request) {
rows, err := a.db.QueryContext(r.Context(), `SELECT id,city_name,area_text FROM pet_addresses WHERE user_id=? ORDER BY id DESC`, current(r).ID)
rows, err := a.db.QueryContext(r.Context(), `SELECT id,city_code,city_name,area_text,latitude IS NOT NULL
FROM pet_addresses WHERE user_id=? AND LENGTH(detail_cipher)>0 ORDER BY id DESC`, current(r).ID)
if err != nil {
fail(w, http.StatusInternalServerError, 50001, "查询地址失败")
return
@@ -143,9 +148,12 @@ func (a *App) petAddresses(w http.ResponseWriter, r *http.Request) {
items := []map[string]any{}
for rows.Next() {
var id int64
var city, area string
if rows.Scan(&id, &city, &area) == nil {
items = append(items, map[string]any{"id": id, "cityName": city, "areaText": area})
var cityCode, city, area string
var located bool
if rows.Scan(&id, &cityCode, &city, &area, &located) == nil {
items = append(items, map[string]any{
"id": id, "cityCode": cityCode, "cityName": city, "areaText": area, "located": located,
})
}
}
reply(w, map[string]any{"items": items})
@@ -345,7 +353,7 @@ 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})
}
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,
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,addr.latitude IS NOT NULL,
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,''),COALESCE(t.conversation_id,0),t.applications_count,t.total_visits,t.completed_visits,t.created_at`
@@ -358,7 +366,7 @@ func (a *App) scanFeedTasks(ctx context.Context, rows *sql.Rows, viewer int64) [
var start, end time.Time
var created time.Time
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.CityName, &item.AreaText, &item.Located, &start, &end, &item.VisitsPerDay, &item.MinMinutes, &item.PriceCent,
&item.GrossCent, &item.PayoutCent, &item.Note, &item.Status, &item.SitterUserID, &item.SitterName,
&item.Conversation, &item.Applications, &item.TotalVisits, &item.Done, &created) != nil {
continue
@@ -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)
}
})
}
+120
View File
@@ -0,0 +1,120 @@
package app
import (
"context"
"encoding/json"
"net/http"
"strconv"
"strings"
)
// A price the app fills in for you has to be defensible: a cat in a small city
// is not a dog in Shenzhen. The table lives in config so operations can move it
// without a release, and the number stays editable — the suggestion is a
// starting point, not a rule.
type priceSuggestConfig struct {
Base map[string]int `json:"base"` // 物种基准单价(分)
Cities map[string]int `json:"cities"` // 城市系数(百分比),键是城市编码前两位或全码
Minutes map[string]int `json:"minutes"` // 每次时长加价(分)
DogWalk int `json:"dogWalk"` // 含遛狗时的额外加价(分)
Rounding int `json:"rounding"` // 取整到多少分
}
const defaultPriceSuggest = `{
"base": {"cat": 3000, "dog": 4500, "other": 3000},
"cities": {"11": 120, "31": 120, "44": 115, "51": 105, "33": 110},
"minutes": {"15": -300, "20": 0, "30": 500, "45": 1200},
"dogWalk": 500,
"rounding": 100
}`
func (a *App) priceSuggestConfig(ctx context.Context) priceSuggestConfig {
parsed := priceSuggestConfig{}
raw := strings.TrimSpace(a.configPlain(ctx, "pet.feed_price_suggest_json", ""))
if raw == "" || json.Unmarshal([]byte(raw), &parsed) != nil {
_ = json.Unmarshal([]byte(defaultPriceSuggest), &parsed)
}
if parsed.Rounding < 1 {
parsed.Rounding = 100
}
return parsed
}
// suggestFeedPrice returns the per-visit price in cents, plus the band the
// server will accept, so the form can warn before the user submits.
func (a *App) suggestFeedPrice(ctx context.Context, species, cityCode string, minutes int, walking bool) (int, int, int) {
config := a.priceSuggestConfig(ctx)
price, ok := config.Base[species]
if !ok {
price = config.Base["other"]
}
if price <= 0 {
price = 3000
}
// 先按城市系数缩放:同一件事在不同城市值不同的钱。
factor := 0
if code := strings.TrimSpace(cityCode); code != "" {
if value, hit := config.Cities[code]; hit {
factor = value
} else if len(code) >= 2 {
if value, hit = config.Cities[code[:2]]; hit {
factor = value
}
}
}
if factor <= 0 {
factor = 100
}
price = price * factor / 100
if extra, hit := config.Minutes[strconv.Itoa(minutes)]; hit {
price += extra
}
if walking {
price += config.DogWalk
}
minPrice := a.configInt(ctx, "pet.feed_min_price_cent", 2000)
maxPrice := a.configInt(ctx, "pet.feed_max_price_cent", 50000)
price = (price / config.Rounding) * config.Rounding
if price < minPrice {
price = minPrice
}
if price > maxPrice {
price = maxPrice
}
return price, minPrice, maxPrice
}
func (a *App) feedPriceSuggestion(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
species := strings.TrimSpace(query.Get("species"))
if petID, err := strconv.ParseInt(strings.TrimSpace(query.Get("petId")), 10, 64); err == nil && petID > 0 {
var owned string
if a.db.QueryRowContext(r.Context(), `SELECT species FROM pets WHERE id=? AND owner_user_id=? AND deleted_at IS NULL`,
petID, current(r).ID).Scan(&owned) == nil {
species = owned
}
}
if _, ok := petSpecies[species]; !ok {
species = "other"
}
minutes, _ := strconv.Atoi(strings.TrimSpace(query.Get("minMinutes")))
if minutes <= 0 {
minutes = 20
}
cityCode := strings.TrimSpace(query.Get("cityCode"))
if cityCode == "" {
if addressID, err := strconv.ParseInt(strings.TrimSpace(query.Get("addressId")), 10, 64); err == nil && addressID > 0 {
_ = a.db.QueryRowContext(r.Context(), `SELECT city_code FROM pet_addresses WHERE id=? AND user_id=?`,
addressID, current(r).ID).Scan(&cityCode)
}
}
walking := strings.TrimSpace(query.Get("walk")) == "1" || species == "dog"
price, minPrice, maxPrice := a.suggestFeedPrice(r.Context(), species, cityCode, minutes, walking)
reply(w, map[string]any{
"priceCent": price, "minPriceCent": minPrice, "maxPriceCent": maxPrice,
"species": species, "cityCode": cityCode, "minMinutes": minutes,
"feePercent": a.configInt(r.Context(), "pet.feed_platform_fee_percent", 15),
})
}
@@ -365,6 +365,27 @@ func (a *App) autoConfirmFeedTasks(ctx context.Context) int {
return settled
}
// closeStaleUnpaidTasks clears out tasks that were created but never paid for.
// Without it they sit in "我的任务" forever looking published, which is exactly
// the confusion the pay-then-publish change is meant to remove.
func (a *App) closeStaleUnpaidTasks(ctx context.Context) int64 {
hours := a.configInt(ctx, "pet.feed_unpaid_close_hours", 24)
if hours < 1 {
hours = 24
}
result, err := a.db.ExecContext(ctx, `UPDATE pet_feed_tasks SET status='CANCELLED'
WHERE status='CREATED' AND created_at<DATE_SUB(NOW(3),INTERVAL ? HOUR)`, hours)
if err != nil {
log.Printf("pet feed: 关闭超时未支付任务失败: %v", err)
return 0
}
affected, _ := result.RowsAffected()
if affected > 0 {
log.Printf("pet feed: 关闭了 %d 个超过 %d 小时未支付的任务", affected, hours)
}
return affected
}
func (a *App) startFeedSettlementWorker(ctx context.Context) {
go func() {
ticker := time.NewTicker(5 * time.Minute)
@@ -375,6 +396,7 @@ func (a *App) startFeedSettlementWorker(ctx context.Context) {
return
case <-ticker.C:
a.autoConfirmFeedTasks(ctx)
a.closeStaleUnpaidTasks(ctx)
}
}
}()
@@ -0,0 +1,10 @@
SET NAMES utf8mb4;
-- 推荐价放配置而不是写死在代码里:不同城市、不同物种的合理价差是运营的判断,
-- 改一次不该等一次发版。cities 的键是城市编码,两位表示按省匹配。
INSERT INTO system_configs(config_key,config_value,value_type,description) VALUES
('pet.feed_price_suggest_json',
'{"base":{"cat":3000,"dog":4500,"other":3000},"cities":{"11":120,"31":120,"44":115,"51":105,"33":110},"minutes":{"15":-300,"20":0,"30":500,"45":1200},"dogWalk":500,"rounding":100}',
'string','代喂推荐单价规则:物种基准价(分)、城市系数(百分比)、时长加价、遛狗加价、取整'),
('pet.feed_unpaid_close_hours','24','integer','任务创建后多少小时未支付就自动关闭')
ON DUPLICATE KEY UPDATE description=VALUES(description);