整改一期:发布这条链路先跑顺
四条改动都在"发布一个代喂任务"这条路上: 定位拿不到不再把人卡死。地址可以不带坐标保存,界面标成"未核验定位", 这条地址的打卡照常,只是不比对距离——总比逼着用户放弃发布要好。客户端 另外给了两条退路:去系统设置开权限,或者地图选点。 价格从写死的 30 元改成服务端按物种、城市、每次时长算的推荐价,规则放在 配置里,运营改一次不用等发版;用户改过之后就以他填的为准,不再被覆盖。 发布与支付并成一步:底部常驻"合计 ¥x = n 次 × ¥y",主按钮是"支付并发布"。 没付成的任务留在"我的任务"里能接着付,超过 24 小时由后台关掉,不再变成 既看不见又删不掉的孤儿。 选宠物换成带头像的卡片,缺免疫记录或照片的当场标出来,不用等提交才知道。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
9345805133
commit
ebf7189533
@@ -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)},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user