退款原来只有会员那条路,而且只能全额、只改本地状态。现在代喂任务的退款 能分几次退到退完:每一笔单独记一行(谁退的、多少、走哪条通道、为什么), 订单上的 refunded_cent 跟着累加,退完且未结算的任务才关闭。渠道接通时按 金额原路退回并带独立幂等键,没接通或沙箱支付的登记为"线下已退"——假装 调用了渠道比说实话更糟。已结算的任务不能顺手退:钱在喂养者账上,要退只能 由平台承担,必须显式勾选。 后台从"只能通过或驳回"变成能改能删:宠物档案可代建、可编辑、可软删除 (有进行中任务的删不掉),送养与配种能改文案和城市、能下架能删,代喂任务 能改备注、能取消(托管中的钱必须先退)。每一个写操作都进审计日志。 悬赏按方案里的 A 做:主人自愿加的钱跟着任务一起托管,结算时全额给喂养者, 平台服务费只算在基础报酬上。抽悬赏是喂养者一眼能算出来的事。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
406 lines
14 KiB
Go
406 lines
14 KiB
Go
package app
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
)
|
|
|
|
// Until now the console could only approve or reject. That means a listing with
|
|
// one bad sentence had to be bounced back to the user to redo, and a wrong
|
|
// profile could not be corrected at all. These handlers close that gap — every
|
|
// one of them writes an audit entry, because editing someone else's data
|
|
// without a trace is how a support tool turns into a liability.
|
|
|
|
type adminPetRequest struct {
|
|
OwnerUserID int64 `json:"ownerUserId"`
|
|
Name string `json:"name"`
|
|
Species string `json:"species"`
|
|
Breed string `json:"breed"`
|
|
Gender int `json:"gender"`
|
|
Birthday string `json:"birthday"`
|
|
Neutered bool `json:"neutered"`
|
|
WeightG int `json:"weightG"`
|
|
ChipNo string `json:"chipNo"`
|
|
VaccinatedAt string `json:"vaccinatedAt"`
|
|
DewormedAt string `json:"dewormedAt"`
|
|
Temperament string `json:"temperament"`
|
|
Photos []string `json:"photos"`
|
|
}
|
|
|
|
func (a *App) adminValidatePet(w http.ResponseWriter, req *adminPetRequest) bool {
|
|
req.Name = strings.TrimSpace(req.Name)
|
|
req.Breed = strings.TrimSpace(req.Breed)
|
|
req.ChipNo = strings.TrimSpace(req.ChipNo)
|
|
req.Temperament = strings.TrimSpace(req.Temperament)
|
|
if req.Name == "" || len([]rune(req.Name)) > 30 {
|
|
fail(w, http.StatusBadRequest, 20001, "宠物名字为 1 到 30 个字")
|
|
return false
|
|
}
|
|
if _, ok := petSpecies[req.Species]; !ok {
|
|
fail(w, http.StatusBadRequest, 20001, "宠物类型无效")
|
|
return false
|
|
}
|
|
if req.Gender < 0 || req.Gender > 2 {
|
|
fail(w, http.StatusBadRequest, 20001, "性别无效")
|
|
return false
|
|
}
|
|
if req.WeightG < 0 || req.WeightG > 200000 {
|
|
fail(w, http.StatusBadRequest, 20001, "体重超出范围")
|
|
return false
|
|
}
|
|
// 日期用客户端那套同样的校验,免得后台能存进去一个前台打不开的档案。
|
|
for _, field := range []struct {
|
|
label string
|
|
value string
|
|
}{{"出生日期", req.Birthday}, {"最近免疫", req.VaccinatedAt}, {"最近驱虫", req.DewormedAt}} {
|
|
if _, err := petDate(field.value, field.label, false); err != nil {
|
|
fail(w, http.StatusBadRequest, 20001, err.Error())
|
|
return false
|
|
}
|
|
}
|
|
if len(req.Photos) > 9 {
|
|
fail(w, http.StatusBadRequest, 20001, "照片最多 9 张")
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func nullableDate(value string) any {
|
|
if strings.TrimSpace(value) == "" {
|
|
return nil
|
|
}
|
|
return value
|
|
}
|
|
|
|
func (a *App) adminCreatePet(w http.ResponseWriter, r *http.Request) {
|
|
var req adminPetRequest
|
|
if decode(r, &req) != nil {
|
|
fail(w, http.StatusBadRequest, 20001, "格式错误")
|
|
return
|
|
}
|
|
if req.OwnerUserID == 0 {
|
|
fail(w, http.StatusBadRequest, 20001, "请指定这只宠物属于哪个用户")
|
|
return
|
|
}
|
|
var exists int
|
|
if a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM users WHERE id=?`, req.OwnerUserID).Scan(&exists) != nil || exists == 0 {
|
|
fail(w, http.StatusNotFound, 30001, "用户不存在")
|
|
return
|
|
}
|
|
if !a.adminValidatePet(w, &req) {
|
|
return
|
|
}
|
|
avatar := ""
|
|
if len(req.Photos) > 0 {
|
|
avatar = req.Photos[0]
|
|
}
|
|
tx, err := a.db.BeginTx(r.Context(), nil)
|
|
if err != nil {
|
|
fail(w, http.StatusInternalServerError, 50001, "创建失败")
|
|
return
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
result, err := tx.ExecContext(r.Context(), `INSERT INTO pets
|
|
(owner_user_id,name,species,breed,gender,birthday,neutered,weight_g,chip_no,vaccinated_at,dewormed_at,temperament,avatar_url,status,moderation_status)
|
|
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,1,1)`,
|
|
req.OwnerUserID, req.Name, req.Species, req.Breed, req.Gender, nullableDate(req.Birthday), req.Neutered,
|
|
req.WeightG, req.ChipNo, nullableDate(req.VaccinatedAt), nullableDate(req.DewormedAt), req.Temperament, avatar)
|
|
if err != nil {
|
|
fail(w, http.StatusInternalServerError, 50001, "创建失败")
|
|
return
|
|
}
|
|
petID, _ := result.LastInsertId()
|
|
for index, url := range req.Photos {
|
|
if _, err = tx.ExecContext(r.Context(), `INSERT INTO pet_media(pet_id,media_url,sort_order) VALUES(?,?,?)`, petID, url, index); err != nil {
|
|
fail(w, http.StatusInternalServerError, 50001, "创建失败")
|
|
return
|
|
}
|
|
}
|
|
if tx.Commit() != nil {
|
|
fail(w, http.StatusInternalServerError, 50001, "创建失败")
|
|
return
|
|
}
|
|
a.audit(r, "create", "pet", petID, map[string]any{"ownerUserId": req.OwnerUserID, "name": req.Name})
|
|
reply(w, map[string]any{"id": petID})
|
|
}
|
|
|
|
func (a *App) adminUpdatePet(w http.ResponseWriter, r *http.Request) {
|
|
id, err := pathID(r)
|
|
if err != nil {
|
|
fail(w, http.StatusBadRequest, 20001, "编号无效")
|
|
return
|
|
}
|
|
var req adminPetRequest
|
|
if decode(r, &req) != nil {
|
|
fail(w, http.StatusBadRequest, 20001, "格式错误")
|
|
return
|
|
}
|
|
if !a.adminValidatePet(w, &req) {
|
|
return
|
|
}
|
|
var owner int64
|
|
if a.db.QueryRowContext(r.Context(), `SELECT owner_user_id FROM pets WHERE id=? AND deleted_at IS NULL`, id).Scan(&owner) != nil {
|
|
fail(w, http.StatusNotFound, 30001, "宠物不存在")
|
|
return
|
|
}
|
|
avatar := ""
|
|
if len(req.Photos) > 0 {
|
|
avatar = req.Photos[0]
|
|
}
|
|
tx, err := a.db.BeginTx(r.Context(), nil)
|
|
if err != nil {
|
|
fail(w, http.StatusInternalServerError, 50001, "保存失败")
|
|
return
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
if _, err = tx.ExecContext(r.Context(), `UPDATE pets SET name=?,species=?,breed=?,gender=?,birthday=?,neutered=?,
|
|
weight_g=?,chip_no=?,vaccinated_at=?,dewormed_at=?,temperament=?,avatar_url=IF(?='',avatar_url,?) WHERE id=?`,
|
|
req.Name, req.Species, req.Breed, req.Gender, nullableDate(req.Birthday), req.Neutered, req.WeightG,
|
|
req.ChipNo, nullableDate(req.VaccinatedAt), nullableDate(req.DewormedAt), req.Temperament, avatar, avatar, id); err != nil {
|
|
fail(w, http.StatusInternalServerError, 50001, "保存失败")
|
|
return
|
|
}
|
|
// 照片按整组替换:传了就以传来的为准,没传就保留原来的。
|
|
if len(req.Photos) > 0 {
|
|
if _, err = tx.ExecContext(r.Context(), `DELETE FROM pet_media WHERE pet_id=?`, id); err != nil {
|
|
fail(w, http.StatusInternalServerError, 50001, "保存失败")
|
|
return
|
|
}
|
|
for index, url := range req.Photos {
|
|
if _, err = tx.ExecContext(r.Context(), `INSERT INTO pet_media(pet_id,media_url,sort_order) VALUES(?,?,?)`, id, url, index); err != nil {
|
|
fail(w, http.StatusInternalServerError, 50001, "保存失败")
|
|
return
|
|
}
|
|
}
|
|
}
|
|
if tx.Commit() != nil {
|
|
fail(w, http.StatusInternalServerError, 50001, "保存失败")
|
|
return
|
|
}
|
|
a.audit(r, "update", "pet", id, map[string]any{"ownerUserId": owner, "name": req.Name})
|
|
a.notifyUser(r.Context(), owner, "system", "宠物档案被平台修改", "客服调整了「"+req.Name+"」的档案,如有疑问可以联系客服。", "pet", id)
|
|
reply(w, map[string]bool{"success": true})
|
|
}
|
|
|
|
func (a *App) adminDeletePet(w http.ResponseWriter, r *http.Request) {
|
|
id, err := pathID(r)
|
|
if err != nil {
|
|
fail(w, http.StatusBadRequest, 20001, "编号无效")
|
|
return
|
|
}
|
|
// 有进行中的任务时不能删:那只宠物正被人照顾着。
|
|
var live int
|
|
_ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM pet_feed_tasks WHERE pet_id=?
|
|
AND status IN ('CREATED','ESCROWED','ASSIGNED','SERVING','COMPLETED','DISPUTED')`, id).Scan(&live)
|
|
if live > 0 {
|
|
fail(w, http.StatusBadRequest, 20001, "这只宠物还有进行中的代喂任务,先处理完再删除")
|
|
return
|
|
}
|
|
result, err := a.db.ExecContext(r.Context(), `UPDATE pets SET status=0,deleted_at=NOW(3) WHERE id=? AND deleted_at IS NULL`, id)
|
|
if err != nil {
|
|
fail(w, http.StatusInternalServerError, 50001, "删除失败")
|
|
return
|
|
}
|
|
if affected, _ := result.RowsAffected(); affected == 0 {
|
|
fail(w, http.StatusNotFound, 30001, "宠物不存在")
|
|
return
|
|
}
|
|
// 档案没了,挂在它上面的送养与配种也一起下架,免得点进去是空的。
|
|
_, _ = a.db.ExecContext(r.Context(), `UPDATE pet_adoptions SET status=0 WHERE pet_id=? AND status=1`, id)
|
|
_, _ = a.db.ExecContext(r.Context(), `UPDATE pet_mating_listings SET status=0 WHERE pet_id=? AND status=1`, id)
|
|
a.audit(r, "delete", "pet", id, nil)
|
|
reply(w, map[string]bool{"success": true})
|
|
}
|
|
|
|
// --- 送养 / 配种 ----------------------------------------------------------
|
|
|
|
func (a *App) adminUpdatePetListing(w http.ResponseWriter, r *http.Request) {
|
|
id, err := pathID(r)
|
|
if err != nil {
|
|
fail(w, http.StatusBadRequest, 20001, "编号无效")
|
|
return
|
|
}
|
|
var req struct {
|
|
Kind string `json:"kind"`
|
|
CityCode string `json:"cityCode"`
|
|
CityName string `json:"cityName"`
|
|
Body string `json:"body"`
|
|
Requirements string `json:"requirements"`
|
|
Status *int `json:"status"`
|
|
}
|
|
if decode(r, &req) != nil {
|
|
fail(w, http.StatusBadRequest, 20001, "格式错误")
|
|
return
|
|
}
|
|
table, ok := petListingTables[strings.TrimSpace(req.Kind)]
|
|
if !ok {
|
|
fail(w, http.StatusBadRequest, 20001, "类型无效")
|
|
return
|
|
}
|
|
req.Body = strings.TrimSpace(req.Body)
|
|
req.Requirements = strings.TrimSpace(req.Requirements)
|
|
if len([]rune(req.Body)) > 500 || len([]rune(req.Requirements)) > 500 {
|
|
fail(w, http.StatusBadRequest, 20001, "内容过长")
|
|
return
|
|
}
|
|
if req.Status != nil && (*req.Status < 0 || *req.Status > 2) {
|
|
fail(w, http.StatusBadRequest, 20001, "状态无效")
|
|
return
|
|
}
|
|
var owner int64
|
|
if a.db.QueryRowContext(r.Context(), fmt.Sprintf(`SELECT owner_user_id FROM %s WHERE id=? AND deleted_at IS NULL`, table), id).Scan(&owner) != nil {
|
|
fail(w, http.StatusNotFound, 30001, "记录不存在")
|
|
return
|
|
}
|
|
// 两张表的正文字段名不同,其余更新是一样的。
|
|
bodyColumn := "reason"
|
|
if req.Kind == "mating" {
|
|
bodyColumn = "expectation"
|
|
}
|
|
query := fmt.Sprintf(`UPDATE %s SET city_code=IF(?='',city_code,?),city_name=IF(?='',city_name,?),%s=IF(?='',%s,?)`,
|
|
table, bodyColumn, bodyColumn)
|
|
args := []any{req.CityCode, req.CityCode, req.CityName, req.CityName, req.Body, req.Body}
|
|
if req.Kind == "adoption" {
|
|
query += `,requirements=IF(?='',requirements,?)`
|
|
args = append(args, req.Requirements, req.Requirements)
|
|
}
|
|
if req.Status != nil {
|
|
query += `,status=?`
|
|
args = append(args, *req.Status)
|
|
}
|
|
query += ` WHERE id=?`
|
|
args = append(args, id)
|
|
if _, err = a.db.ExecContext(r.Context(), query, args...); err != nil {
|
|
fail(w, http.StatusInternalServerError, 50001, "保存失败")
|
|
return
|
|
}
|
|
a.audit(r, "update", "pet_"+req.Kind, id, map[string]any{"ownerUserId": owner, "status": req.Status})
|
|
reply(w, map[string]bool{"success": true})
|
|
}
|
|
|
|
func (a *App) adminDeletePetListing(w http.ResponseWriter, r *http.Request) {
|
|
id, err := pathID(r)
|
|
if err != nil {
|
|
fail(w, http.StatusBadRequest, 20001, "编号无效")
|
|
return
|
|
}
|
|
kind := strings.TrimSpace(r.URL.Query().Get("kind"))
|
|
table, ok := petListingTables[kind]
|
|
if !ok {
|
|
fail(w, http.StatusBadRequest, 20001, "类型无效")
|
|
return
|
|
}
|
|
result, err := a.db.ExecContext(r.Context(), fmt.Sprintf(`UPDATE %s SET status=0,deleted_at=NOW(3) WHERE id=? AND deleted_at IS NULL`, table), id)
|
|
if err != nil {
|
|
fail(w, http.StatusInternalServerError, 50001, "删除失败")
|
|
return
|
|
}
|
|
if affected, _ := result.RowsAffected(); affected == 0 {
|
|
fail(w, http.StatusNotFound, 30001, "记录不存在")
|
|
return
|
|
}
|
|
a.audit(r, "delete", "pet_"+kind, id, nil)
|
|
reply(w, map[string]bool{"success": true})
|
|
}
|
|
|
|
// --- 代喂任务 -------------------------------------------------------------
|
|
|
|
func (a *App) adminUpdateFeedTask(w http.ResponseWriter, r *http.Request) {
|
|
id, err := pathID(r)
|
|
if err != nil {
|
|
fail(w, http.StatusBadRequest, 20001, "编号无效")
|
|
return
|
|
}
|
|
var req struct {
|
|
Note string `json:"note"`
|
|
Cancel bool `json:"cancel"`
|
|
Reason string `json:"reason"`
|
|
}
|
|
if decode(r, &req) != nil {
|
|
fail(w, http.StatusBadRequest, 20001, "格式错误")
|
|
return
|
|
}
|
|
var owner, sitter int64
|
|
var status string
|
|
var refunded, gross int64
|
|
if a.db.QueryRowContext(r.Context(), `SELECT owner_user_id,COALESCE(sitter_user_id,0),status,refunded_cent,gross_cent
|
|
FROM pet_feed_tasks WHERE id=?`, id).Scan(&owner, &sitter, &status, &refunded, &gross) != nil {
|
|
fail(w, http.StatusNotFound, 30001, "任务不存在")
|
|
return
|
|
}
|
|
|
|
if req.Cancel {
|
|
if strings.TrimSpace(req.Reason) == "" {
|
|
fail(w, http.StatusBadRequest, 20001, "取消任务必须写明原因")
|
|
return
|
|
}
|
|
if status == "SETTLED" || status == "REFUNDED" || status == "CANCELLED" {
|
|
fail(w, http.StatusBadRequest, 20001, "这个状态的任务不能取消")
|
|
return
|
|
}
|
|
// 钱还在托管里就先退,再取消;否则会留下一笔没有归属的钱。
|
|
if gross > refunded && status != "CREATED" {
|
|
fail(w, http.StatusBadRequest, 20001, "请先把托管中的金额退给主人,再取消任务")
|
|
return
|
|
}
|
|
if _, err = a.db.ExecContext(r.Context(), `UPDATE pet_feed_tasks SET status='CANCELLED',confirm_due_at=NULL WHERE id=?`, id); err != nil {
|
|
fail(w, http.StatusInternalServerError, 50001, "操作失败")
|
|
return
|
|
}
|
|
a.audit(r, "cancel", "pet_feed_task", id, map[string]any{"reason": req.Reason, "previousStatus": status})
|
|
for _, party := range []int64{owner, sitter} {
|
|
if party > 0 {
|
|
a.notifyUser(r.Context(), party, "system", "代喂任务已被取消", strings.TrimSpace(req.Reason), "feed_task", id)
|
|
}
|
|
}
|
|
reply(w, map[string]bool{"success": true})
|
|
return
|
|
}
|
|
|
|
note := strings.TrimSpace(req.Note)
|
|
if len([]rune(note)) > 500 {
|
|
fail(w, http.StatusBadRequest, 20001, "备注过长")
|
|
return
|
|
}
|
|
if _, err = a.db.ExecContext(r.Context(), `UPDATE pet_feed_tasks SET note=? WHERE id=?`, note, id); err != nil {
|
|
fail(w, http.StatusInternalServerError, 50001, "保存失败")
|
|
return
|
|
}
|
|
a.audit(r, "update", "pet_feed_task", id, map[string]any{"note": note})
|
|
reply(w, map[string]bool{"success": true})
|
|
}
|
|
|
|
// adminPetOwners powers the "which user does this pet belong to" picker when an
|
|
// operator creates a profile on someone's behalf.
|
|
func (a *App) adminPetOwners(w http.ResponseWriter, r *http.Request) {
|
|
keyword := strings.TrimSpace(r.URL.Query().Get("keyword"))
|
|
if keyword == "" {
|
|
reply(w, map[string]any{"items": []any{}})
|
|
return
|
|
}
|
|
if len([]rune(keyword)) > 50 {
|
|
fail(w, http.StatusBadRequest, 20001, "搜索关键词最多 50 字")
|
|
return
|
|
}
|
|
like := "%" + keyword + "%"
|
|
rows, err := a.db.QueryContext(r.Context(), `SELECT u.id,u.public_id,COALESCE(p.nickname,''),COALESCE(p.avatar_url,'')
|
|
FROM users u LEFT JOIN user_profiles p ON p.user_id=u.id
|
|
WHERE u.deleted_at IS NULL AND (u.public_id LIKE ? OR p.nickname LIKE ?) ORDER BY u.id DESC LIMIT 20`, like, like)
|
|
if err != nil {
|
|
fail(w, http.StatusInternalServerError, 50001, "查询用户失败")
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
items := []map[string]any{}
|
|
for rows.Next() {
|
|
var id int64
|
|
var publicID, nickname, avatar string
|
|
if rows.Scan(&id, &publicID, &nickname, &avatar) != nil {
|
|
continue
|
|
}
|
|
items = append(items, map[string]any{"id": id, "publicId": publicID, "nickname": nickname, "avatar": avatarThumbnailURL(avatar)})
|
|
}
|
|
reply(w, map[string]any{"items": items})
|
|
}
|