diff --git a/im/backend/internal/app/app.go b/im/backend/internal/app/app.go index 842bacb..e9abf98 100644 --- a/im/backend/internal/app/app.go +++ b/im/backend/internal/app/app.go @@ -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)}, diff --git a/im/backend/internal/app/pet_feed.go b/im/backend/internal/app/pet_feed.go index 5c28582..9503d7c 100644 --- a/im/backend/internal/app/pet_feed.go +++ b/im/backend/internal/app/pet_feed.go @@ -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 diff --git a/im/backend/internal/app/pet_feed_integration_test.go b/im/backend/internal/app/pet_feed_integration_test.go index 2c96399..835384c 100644 --- a/im/backend/internal/app/pet_feed_integration_test.go +++ b/im/backend/internal/app/pet_feed_integration_test.go @@ -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) + } + }) +} diff --git a/im/backend/internal/app/pet_feed_pricing.go b/im/backend/internal/app/pet_feed_pricing.go new file mode 100644 index 0000000..b46346a --- /dev/null +++ b/im/backend/internal/app/pet_feed_pricing.go @@ -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), + }) +} diff --git a/im/backend/internal/app/pet_feed_service.go b/im/backend/internal/app/pet_feed_service.go index 37ef1fb..138eb1b 100644 --- a/im/backend/internal/app/pet_feed_service.go +++ b/im/backend/internal/app/pet_feed_service.go @@ -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 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) } } }() diff --git a/im/backend/migrations/040_pet_feed_pricing.sql b/im/backend/migrations/040_pet_feed_pricing.sql new file mode 100644 index 0000000..beb5072 --- /dev/null +++ b/im/backend/migrations/040_pet_feed_pricing.sql @@ -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);