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

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

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

价格从写死的 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
+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),
})
}