原来 107 个后台接口配的是一套只能看的界面:风险中心 6 行代码全是只读, 动态和消息只能一条一条点,运营想发一句公告没有任何入口。 公告与通知:按全体、会员、城市、喂养者、有宠物的用户或指定名单发系统通知, 发之前能看到会发给多少人,可同时走离线推送。撤回只删还没读的那些——已经 读过的假装收回去只会让统计说谎。 资金总览:把"平台账上哪些钱不是自己的"单独摆出来(任务托管中 + 喂养者余额 + 申诉冻结),旁边才是收入、退款和待打款,最后是按产品和按日的收款。 退款统一到一个入口:会员、代喂、保证金都能分几次退到退完,每笔记一行; 退完最后一分钱才撤销买到的东西,半份会员仍然是会员。 风险中心能动手了:按等级和状态筛,行内直接封禁、冻结、加白(必须写理由)、 跳用户详情,事件可以标记跟进完成。 批量处置:动态、消息、举报支持勾选后批量下架/恢复/驳回,一次最多 100 条。 处罚仍然一条一条来——批量封禁是错封人最快的方式。 敏感词统一词库:消息、动态、送养共用一套,命中是拦截还是转人工由运营定; 转人工的命中会在风险中心留一条,管理端还能拿真实文案先试一遍。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
121 lines
4.0 KiB
Go
121 lines
4.0 KiB
Go
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),
|
|
})
|
|
}
|