整改三期:退款、后台增改删、悬赏

退款原来只有会员那条路,而且只能全额、只改本地状态。现在代喂任务的退款
能分几次退到退完:每一笔单独记一行(谁退的、多少、走哪条通道、为什么),
订单上的 refunded_cent 跟着累加,退完且未结算的任务才关闭。渠道接通时按
金额原路退回并带独立幂等键,没接通或沙箱支付的登记为"线下已退"——假装
调用了渠道比说实话更糟。已结算的任务不能顺手退:钱在喂养者账上,要退只能
由平台承担,必须显式勾选。

后台从"只能通过或驳回"变成能改能删:宠物档案可代建、可编辑、可软删除
(有进行中任务的删不掉),送养与配种能改文案和城市、能下架能删,代喂任务
能改备注、能取消(托管中的钱必须先退)。每一个写操作都进审计日志。

悬赏按方案里的 A 做:主人自愿加的钱跟着任务一起托管,结算时全额给喂养者,
平台服务费只算在基础报酬上。抽悬赏是喂养者一眼能算出来的事。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-07 11:25:02 +08:00
co-authored by Claude Opus 5
parent ebf7189533
commit a889cb42e4
8 changed files with 1011 additions and 22 deletions
+9
View File
@@ -370,6 +370,15 @@ func (a *App) adminRoutes() []rest.Route {
{Method: http.MethodGet, Path: "/admin/v1/pet/listings", Handler: permit("users:view", a.adminPetListings)},
{Method: http.MethodPost, Path: "/admin/v1/pet/listings/:id/moderate", Handler: permit("users:manage", a.adminModeratePetListing)},
{Method: http.MethodPost, Path: "/admin/v1/pets/:id/moderate", Handler: permit("users:manage", a.adminModeratePet)},
{Method: http.MethodPost, Path: "/admin/v1/pets", Handler: permit("users:manage", a.adminCreatePet)},
{Method: http.MethodPut, Path: "/admin/v1/pets/:id", Handler: permit("users:manage", a.adminUpdatePet)},
{Method: http.MethodDelete, Path: "/admin/v1/pets/:id", Handler: permit("users:manage", a.adminDeletePet)},
{Method: http.MethodGet, Path: "/admin/v1/pet/owners", Handler: permit("users:view", a.adminPetOwners)},
{Method: http.MethodPut, Path: "/admin/v1/pet/listings/:id", Handler: permit("users:manage", a.adminUpdatePetListing)},
{Method: http.MethodDelete, Path: "/admin/v1/pet/listings/:id", Handler: permit("users:manage", a.adminDeletePetListing)},
{Method: http.MethodPut, Path: "/admin/v1/pet/feed-tasks/:id", Handler: permit("users:manage", a.adminUpdateFeedTask)},
{Method: http.MethodPost, Path: "/admin/v1/pet/feed-tasks/:id/refund", Handler: permit("users:manage", a.adminRefundFeedTask)},
{Method: http.MethodGet, Path: "/admin/v1/pet/feed-tasks/:id/refunds", Handler: permit("users:view", a.adminFeedRefunds)},
{Method: http.MethodGet, Path: "/admin/v1/pet/overview", Handler: permit("users:view", a.adminPetOverview)},
{Method: http.MethodGet, Path: "/admin/v1/pet/sitters", Handler: permit("users:view", a.adminSitters)},
{Method: http.MethodPost, Path: "/admin/v1/pet/sitters/:id/review", Handler: permit("users:manage", a.adminReviewSitter)},
+11 -5
View File
@@ -234,14 +234,20 @@ func (a *App) settlePayment(ctx context.Context, notice paymentNotifyRequest, ra
return tx.Commit()
}
func (a *App) createGatewayRefund(ctx context.Context, orderNo, providerOrderNo string, amountCent int) error {
// createGatewayRefund asks the channel to send money back. refundNo is the
// idempotency key: partial refunds happen more than once on the same order, so
// keying on the order alone would make the second one a no-op at the provider.
func (a *App) createGatewayRefund(ctx context.Context, orderNo, providerOrderNo, refundNo string, amountCent int) error {
endpoint := a.configPlain(ctx, "payment.gateway.refund_url", "")
if endpoint == "" || (a.config.Environment == "production" && !validHTTPSURL(endpoint)) {
return fmt.Errorf("payment refund gateway is not configured")
}
if refundNo == "" {
refundNo = "refund:" + orderNo
}
payload, err := json.Marshal(map[string]any{
"orderNo": orderNo, "providerOrderNo": providerOrderNo, "amountCent": amountCent,
"currency": "CNY", "reason": "admin_requested",
"orderNo": orderNo, "providerOrderNo": providerOrderNo, "refundNo": refundNo,
"amountCent": amountCent, "currency": "CNY", "reason": "admin_requested",
})
if err != nil {
return err
@@ -253,7 +259,7 @@ func (a *App) createGatewayRefund(ctx context.Context, orderNo, providerOrderNo
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Accept", "application/json")
request.Header.Set("Authorization", "Bearer "+a.configPlain(ctx, "payment.gateway.token", ""))
request.Header.Set("Idempotency-Key", "refund:"+orderNo)
request.Header.Set("Idempotency-Key", refundNo)
timeout, _ := strconv.Atoi(a.configPlain(ctx, "payment.gateway.timeout_seconds", "10"))
if timeout < 3 || timeout > 30 {
timeout = 10
@@ -318,7 +324,7 @@ func (a *App) requestLiveRefund(w http.ResponseWriter, r *http.Request, orderID
fail(w, http.StatusInternalServerError, 50001, "创建退款申请失败")
return
}
if err = a.createGatewayRefund(r.Context(), orderNo, providerOrderNo, amountCent); err != nil {
if err = a.createGatewayRefund(r.Context(), orderNo, providerOrderNo, fmt.Sprintf("refund:%d:full", orderID), amountCent); err != nil {
a.audit(r, "refund_request_failed", "order", orderID, map[string]any{"userId": userID, "error": err.Error()})
fail(w, http.StatusBadGateway, 50003, "退款网关请求失败,订单已保留为退款处理中,可安全重试")
return
+405
View File
@@ -0,0 +1,405 @@
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})
}
+22 -8
View File
@@ -37,8 +37,10 @@ type feedTaskView struct {
VisitsPerDay int `json:"visitsPerDay"`
MinMinutes int `json:"minMinutes"`
PriceCent int64 `json:"priceCent"`
TipCent int64 `json:"tipCent"`
GrossCent int64 `json:"grossCent"`
PayoutCent int64 `json:"payoutCent"`
RefundedCent int64 `json:"refundedCent"`
Note string `json:"note"`
Status string `json:"status"`
SitterUserID int64 `json:"sitterUserId"`
@@ -238,6 +240,7 @@ func (a *App) createFeedTask(w http.ResponseWriter, r *http.Request) {
VisitsPerDay int `json:"visitsPerDay"`
MinMinutes int `json:"minMinutes"`
PriceCent int64 `json:"priceCent"`
TipCent int64 `json:"tipCent"`
Note string `json:"note"`
Items []struct {
Kind string `json:"kind"`
@@ -308,9 +311,19 @@ func (a *App) createFeedTask(w http.ResponseWriter, r *http.Request) {
fail(w, http.StatusNotFound, 30001, "宠物不存在")
return
}
if req.TipCent < 0 {
req.TipCent = 0
}
if maxTip := int64(a.configInt(r.Context(), "pet.feed_tip_max_cent", 20000)); req.TipCent > maxTip {
fail(w, http.StatusBadRequest, 20001, fmt.Sprintf("悬赏最多 %.0f 元", float64(maxTip)/100))
return
}
totalVisits := days * req.VisitsPerDay
gross := req.PriceCent * int64(totalVisits)
fee, payout := a.feedFeeSplit(r.Context(), gross)
base := req.PriceCent * int64(totalVisits)
// 服务费只算在基础报酬上:悬赏是主人给喂养者的,平台不从中抽成。
fee, basePayout := a.feedFeeSplit(r.Context(), base)
gross := base + req.TipCent
payout := basePayout + req.TipCent
tx, err := a.db.BeginTx(r.Context(), nil)
if err != nil {
@@ -319,10 +332,10 @@ func (a *App) createFeedTask(w http.ResponseWriter, r *http.Request) {
}
defer func() { _ = tx.Rollback() }()
result, err := tx.ExecContext(r.Context(), `INSERT INTO pet_feed_tasks
(owner_user_id,pet_id,address_id,city_code,start_date,end_date,visits_per_day,min_minutes,price_cent,gross_cent,platform_fee_cent,payout_cent,note,total_visits)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
(owner_user_id,pet_id,address_id,city_code,start_date,end_date,visits_per_day,min_minutes,price_cent,tip_cent,gross_cent,platform_fee_cent,payout_cent,note,total_visits)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
current(r).ID, req.PetID, req.AddressID, cityCode, start, end, req.VisitsPerDay, req.MinMinutes,
req.PriceCent, gross, fee, payout, strings.TrimSpace(req.Note), totalVisits)
req.PriceCent, req.TipCent, gross, fee, payout, strings.TrimSpace(req.Note), totalVisits)
if err != nil {
fail(w, http.StatusInternalServerError, 50001, "创建失败")
return
@@ -350,11 +363,12 @@ func (a *App) createFeedTask(w http.ResponseWriter, r *http.Request) {
fail(w, http.StatusInternalServerError, 50001, "创建失败")
return
}
reply(w, map[string]any{"id": taskID, "grossCent": gross, "platformFeeCent": fee, "payoutCent": payout, "totalVisits": totalVisits})
reply(w, map[string]any{"id": taskID, "grossCent": gross, "tipCent": req.TipCent,
"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,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,
t.start_date,t.end_date,t.visits_per_day,t.min_minutes,t.price_cent,t.tip_cent,t.gross_cent,t.payout_cent,t.refunded_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`
func (a *App) scanFeedTasks(ctx context.Context, rows *sql.Rows, viewer int64) []feedTaskView {
@@ -367,7 +381,7 @@ func (a *App) scanFeedTasks(ctx context.Context, rows *sql.Rows, viewer int64) [
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, &item.Located, &start, &end, &item.VisitsPerDay, &item.MinMinutes, &item.PriceCent,
&item.GrossCent, &item.PayoutCent, &item.Note, &item.Status, &item.SitterUserID, &item.SitterName,
&item.TipCent, &item.GrossCent, &item.PayoutCent, &item.RefundedCent, &item.Note, &item.Status, &item.SitterUserID, &item.SitterName,
&item.Conversation, &item.Applications, &item.TotalVisits, &item.Done, &created) != nil {
continue
}
@@ -1009,3 +1009,318 @@ func TestPetFeedPhaseOneMySQL(t *testing.T) {
}
})
}
// 三期的三件事:悬赏跟着任务走且不被抽成、退款能分几次退且退不动已结算的钱、
// 管理端能改能删但每一步都留痕。
func TestPetFeedTipAndRefundMySQL(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, sitter := int64(1), int64(2)
address := httptest.NewRecorder()
a.createPetAddress(address, imTestRequest(owner, "POST", "/api/v1/pet/addresses",
`{"cityCode":"440300","cityName":"深圳","areaText":"南山区","detail":"3 栋 1201","contact":"张先生 13800000000","latitude":22.5411,"longitude":113.9345}`))
var addressEnvelope struct {
Data struct {
ID int64 `json:"id"`
} `json:"data"`
}
_ = json.Unmarshal(address.Body.Bytes(), &addressEnvelope)
petID := petWithPhotos(t, a, owner, "元宝", 3)
var taskID int64
t.Run("the tip rides along and the platform does not take a cut of it", func(t *testing.T) {
w := httptest.NewRecorder()
a.createFeedTask(w, imTestRequest(owner, "POST", "/api/v1/pet/feed-tasks",
fmt.Sprintf(`{"petId":%d,"addressId":%d,"startDate":"2026-10-01","endDate":"2026-10-02","visitsPerDay":2,
"minMinutes":20,"priceCent":3000,"tipCent":2000,"note":"","items":[{"kind":"feed","amount":"40 克"}]}`,
petID, addressEnvelope.Data.ID)))
if w.Code != 200 {
t.Fatalf("HTTP %d: %s", w.Code, w.Body.String())
}
var envelope struct {
Data struct {
ID int64 `json:"id"`
GrossCent int64 `json:"grossCent"`
FeeCent int64 `json:"platformFeeCent"`
PayoutCent int64 `json:"payoutCent"`
TipCent int64 `json:"tipCent"`
} `json:"data"`
}
_ = json.Unmarshal(w.Body.Bytes(), &envelope)
taskID = envelope.Data.ID
// 4 次 × 30 元 = 120 元基础,抽成 15% 是 18 元,悬赏 20 元原样给喂养者。
if envelope.Data.GrossCent != 14000 || envelope.Data.FeeCent != 1800 || envelope.Data.PayoutCent != 12200 {
t.Fatalf("悬赏不该被抽成: %+v", envelope.Data)
}
if envelope.Data.TipCent != 2000 {
t.Fatalf("悬赏没有记下来: %+v", envelope.Data)
}
over := httptest.NewRecorder()
a.createFeedTask(over, imTestRequest(owner, "POST", "/api/v1/pet/feed-tasks",
fmt.Sprintf(`{"petId":%d,"addressId":%d,"startDate":"2026-10-01","endDate":"2026-10-01","visitsPerDay":1,
"minMinutes":20,"priceCent":3000,"tipCent":9999999,"note":"","items":[{"kind":"feed","amount":"40 克"}]}`,
petID, addressEnvelope.Data.ID)))
if over.Code != 400 {
t.Fatalf("超出上限的悬赏应当被拒绝: HTTP %d", over.Code)
}
})
t.Run("a refund can be taken in pieces and stops at the total", func(t *testing.T) {
payFeedTask(t, a, owner, taskID)
thin := httptest.NewRecorder()
a.adminRefundFeedTask(thin, imTestRequest(1, "POST", fmt.Sprintf("/admin/v1/pet/feed-tasks/%d/refund", taskID), `{"reason":"退"}`))
if thin.Code != 400 {
t.Fatalf("退款必须写原因: HTTP %d", thin.Code)
}
part := httptest.NewRecorder()
a.adminRefundFeedTask(part, imTestRequest(1, "POST", fmt.Sprintf("/admin/v1/pet/feed-tasks/%d/refund", taskID),
`{"amountCent":4000,"reason":"主人临时改期,先退一次上门的钱"}`))
if part.Code != 200 {
t.Fatalf("HTTP %d: %s", part.Code, part.Body.String())
}
var status string
var refunded int64
if err := db.QueryRow(`SELECT status,refunded_cent FROM pet_feed_tasks WHERE id=?`, taskID).Scan(&status, &refunded); err != nil {
t.Fatal(err)
}
// 部分退款不改状态:服务还要继续。
if status != "ESCROWED" || refunded != 4000 {
t.Fatalf("部分退款之后 status=%s refunded=%d", status, refunded)
}
tooMuch := httptest.NewRecorder()
a.adminRefundFeedTask(tooMuch, imTestRequest(1, "POST", fmt.Sprintf("/admin/v1/pet/feed-tasks/%d/refund", taskID),
`{"amountCent":999999,"reason":"想退超过收到的钱"}`))
if tooMuch.Code != 400 {
t.Fatalf("不能退超过收到的金额: HTTP %d %s", tooMuch.Code, tooMuch.Body.String())
}
rest := httptest.NewRecorder()
a.adminRefundFeedTask(rest, imTestRequest(1, "POST", fmt.Sprintf("/admin/v1/pet/feed-tasks/%d/refund", taskID),
`{"reason":"主人取消行程,余下全退"}`))
if rest.Code != 200 {
t.Fatalf("HTTP %d: %s", rest.Code, rest.Body.String())
}
if err := db.QueryRow(`SELECT status,refunded_cent FROM pet_feed_tasks WHERE id=?`, taskID).Scan(&status, &refunded); err != nil {
t.Fatal(err)
}
if status != "REFUNDED" || refunded != 14000 {
t.Fatalf("退完之后 status=%s refunded=%d", status, refunded)
}
var orderRefunded int64
if err := db.QueryRow(`SELECT refunded_cent FROM orders WHERE product_type='pet_feed' AND product_id=?`, taskID).Scan(&orderRefunded); err != nil {
t.Fatal(err)
}
if orderRefunded != 14000 {
t.Fatalf("订单上的已退金额对不上: %d", orderRefunded)
}
var rows int
if err := db.QueryRow(`SELECT COUNT(*) FROM pet_feed_refunds WHERE task_id=?`, taskID).Scan(&rows); err != nil {
t.Fatal(err)
}
if rows != 2 {
t.Fatalf("两次退款应当各留一行: %d", rows)
}
done := httptest.NewRecorder()
a.adminRefundFeedTask(done, imTestRequest(1, "POST", fmt.Sprintf("/admin/v1/pet/feed-tasks/%d/refund", taskID),
`{"reason":"再退一次看看会不会重复退"}`))
if done.Code != 400 {
t.Fatalf("退完之后不该还能退: HTTP %d", done.Code)
}
})
t.Run("a settled task cannot be refunded by accident", func(t *testing.T) {
settled := seedAssignedTask(t, a, db, owner, sitter)
if _, err := db.Exec(`UPDATE pet_feed_tasks SET status='SETTLED',order_id=NULL WHERE id=?`, settled); err != nil {
t.Fatal(err)
}
careless := httptest.NewRecorder()
a.adminRefundFeedTask(careless, imTestRequest(1, "POST", fmt.Sprintf("/admin/v1/pet/feed-tasks/%d/refund", settled),
`{"reason":"主人事后反悔要退款"}`))
if careless.Code != 400 || !strings.Contains(careless.Body.String(), "平台承担") {
t.Fatalf("已结算的任务不该顺手退掉: HTTP %d %s", careless.Code, careless.Body.String())
}
deliberate := httptest.NewRecorder()
a.adminRefundFeedTask(deliberate, imTestRequest(1, "POST", fmt.Sprintf("/admin/v1/pet/feed-tasks/%d/refund", settled),
`{"platformBears":true,"amountCent":3000,"reason":"服务确有问题,这笔由平台补给主人"}`))
if deliberate.Code != 200 {
t.Fatalf("HTTP %d: %s", deliberate.Code, deliberate.Body.String())
}
var status string
var bears bool
if err := db.QueryRow(`SELECT t.status,f.platform_bears FROM pet_feed_tasks t
JOIN pet_feed_refunds f ON f.task_id=t.id WHERE t.id=?`, settled).Scan(&status, &bears); err != nil {
t.Fatal(err)
}
if status != "SETTLED" || !bears {
t.Fatalf("平台承担的退款不该改任务状态: status=%s bears=%v", status, bears)
}
// 喂养者已经拿到的钱不能被这条退款倒扣回去。
var balance int64
_ = db.QueryRow(`SELECT COALESCE(available_cent,0) FROM wallet_accounts WHERE user_id=?`, sitter).Scan(&balance)
if balance < 0 {
t.Fatalf("喂养者余额被倒扣了: %d", balance)
}
})
}
func TestPetAdminCrudMySQL(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)
var petID int64
t.Run("an operator can create a profile on someone else's behalf", func(t *testing.T) {
bad := httptest.NewRecorder()
a.adminCreatePet(bad, imTestRequest(1, "POST", "/admin/v1/pets",
`{"ownerUserId":1,"name":"测试","species":"cat","birthday":"2026-13-45"}`))
if bad.Code != 400 {
t.Fatalf("日期不合法应当被拒: HTTP %d %s", bad.Code, bad.Body.String())
}
w := httptest.NewRecorder()
a.adminCreatePet(w, imTestRequest(1, "POST", "/admin/v1/pets",
`{"ownerUserId":1,"name":"客服建的","species":"cat","breed":"狸花","gender":2,"birthday":"2023-01-01",
"weightG":4000,"vaccinatedAt":"2026-06-01","photos":["https://cdn.example.com/a.jpg","https://cdn.example.com/b.jpg"]}`))
if w.Code != 200 {
t.Fatalf("HTTP %d: %s", w.Code, w.Body.String())
}
var envelope struct {
Data struct {
ID int64 `json:"id"`
} `json:"data"`
}
_ = json.Unmarshal(w.Body.Bytes(), &envelope)
petID = envelope.Data.ID
var name, avatar string
var photos int
if err := db.QueryRow(`SELECT p.name,p.avatar_url,(SELECT COUNT(*) FROM pet_media m WHERE m.pet_id=p.id)
FROM pets p WHERE p.id=?`, petID).Scan(&name, &avatar, &photos); err != nil {
t.Fatal(err)
}
if name != "客服建的" || photos != 2 || avatar == "" {
t.Fatalf("name=%s photos=%d avatar=%q", name, photos, avatar)
}
var logged int
if err := db.QueryRow(`SELECT COUNT(*) FROM admin_audit_logs WHERE action='create' AND target_type='pet' AND target_id=?`, petID).Scan(&logged); err != nil {
t.Fatal(err)
}
if logged != 1 {
t.Fatalf("代建档案要留审计: %d", logged)
}
})
t.Run("editing replaces the photo set and tells the owner", func(t *testing.T) {
w := httptest.NewRecorder()
a.adminUpdatePet(w, imTestRequest(1, "PUT", fmt.Sprintf("/admin/v1/pets/%d", petID),
`{"name":"改过的名字","species":"cat","breed":"英短","gender":2,"weightG":4500,
"vaccinatedAt":"2026-07-01","photos":["https://cdn.example.com/c.jpg"]}`))
if w.Code != 200 {
t.Fatalf("HTTP %d: %s", w.Code, w.Body.String())
}
var name string
var photos int
if err := db.QueryRow(`SELECT p.name,(SELECT COUNT(*) FROM pet_media m WHERE m.pet_id=p.id) FROM pets p WHERE p.id=?`, petID).
Scan(&name, &photos); err != nil {
t.Fatal(err)
}
if name != "改过的名字" || photos != 1 {
t.Fatalf("name=%s photos=%d", name, photos)
}
var notified int
if err := db.QueryRow(`SELECT COUNT(*) FROM notifications WHERE user_id=? AND biz_type='pet'`, owner).Scan(&notified); err != nil {
t.Fatal(err)
}
if notified == 0 {
t.Fatal("后台改了用户的档案,用户应当收到通知")
}
})
t.Run("a listing can be corrected and taken down", func(t *testing.T) {
listed := petWithPhotos(t, a, owner, "待送养", 3)
publish := httptest.NewRecorder()
a.createAdoption(publish, imTestRequest(owner, "POST", "/api/v1/pet/adoptions",
fmt.Sprintf(`{"petId":%d,"cityCode":"440300","cityName":"深圳","reason":"工作变动实在照顾不了","requirements":"希望定期反馈"}`, listed)))
if publish.Code != 200 {
t.Fatalf("HTTP %d: %s", publish.Code, publish.Body.String())
}
var adoptionID int64
if err := db.QueryRow(`SELECT id FROM pet_adoptions WHERE pet_id=?`, listed).Scan(&adoptionID); err != nil {
t.Fatal(err)
}
edit := httptest.NewRecorder()
a.adminUpdatePetListing(edit, imTestRequest(1, "PUT", fmt.Sprintf("/admin/v1/pet/listings/%d", adoptionID),
`{"kind":"adoption","body":"客服代为整理过的送养说明","cityName":"深圳市"}`))
if edit.Code != 200 {
t.Fatalf("HTTP %d: %s", edit.Code, edit.Body.String())
}
var reason, city string
if err := db.QueryRow(`SELECT reason,city_name FROM pet_adoptions WHERE id=?`, adoptionID).Scan(&reason, &city); err != nil {
t.Fatal(err)
}
if reason != "客服代为整理过的送养说明" || city != "深圳市" {
t.Fatalf("reason=%s city=%s", reason, city)
}
remove := httptest.NewRecorder()
a.adminDeletePetListing(remove, imTestRequest(1, "DELETE", fmt.Sprintf("/admin/v1/pet/listings/%d?kind=adoption", adoptionID), ``))
if remove.Code != 200 {
t.Fatalf("HTTP %d: %s", remove.Code, remove.Body.String())
}
var deleted sql.NullTime
if err := db.QueryRow(`SELECT deleted_at FROM pet_adoptions WHERE id=?`, adoptionID).Scan(&deleted); err != nil {
t.Fatal(err)
}
if !deleted.Valid {
t.Fatal("删除应当是软删除,记录还要留着")
}
})
t.Run("a pet with a live task cannot be deleted", func(t *testing.T) {
busy := seedTaskAwaitingPayment(t, a, db, owner)
var busyPet int64
if err := db.QueryRow(`SELECT pet_id FROM pet_feed_tasks WHERE id=?`, busy).Scan(&busyPet); err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
a.adminDeletePet(w, imTestRequest(1, "DELETE", fmt.Sprintf("/admin/v1/pets/%d", busyPet), ``))
if w.Code != 400 {
t.Fatalf("有进行中任务的宠物不该能删: HTTP %d %s", w.Code, w.Body.String())
}
// 任务结束之后就可以删了,送养信息跟着下架。
if _, err := db.Exec(`UPDATE pet_feed_tasks SET status='CANCELLED' WHERE id=?`, busy); err != nil {
t.Fatal(err)
}
ok := httptest.NewRecorder()
a.adminDeletePet(ok, imTestRequest(1, "DELETE", fmt.Sprintf("/admin/v1/pets/%d", busyPet), ``))
if ok.Code != 200 {
t.Fatalf("HTTP %d: %s", ok.Code, ok.Body.String())
}
})
t.Run("cancelling a task needs a reason and refuses to strand money", func(t *testing.T) {
funded := seedTaskAwaitingPayment(t, a, db, owner)
payFeedTask(t, a, owner, funded)
silent := httptest.NewRecorder()
a.adminUpdateFeedTask(silent, imTestRequest(1, "PUT", fmt.Sprintf("/admin/v1/pet/feed-tasks/%d", funded), `{"cancel":true}`))
if silent.Code != 400 {
t.Fatalf("取消要写原因: HTTP %d", silent.Code)
}
holding := httptest.NewRecorder()
a.adminUpdateFeedTask(holding, imTestRequest(1, "PUT", fmt.Sprintf("/admin/v1/pet/feed-tasks/%d", funded),
`{"cancel":true,"reason":"主人来电取消"}`))
if holding.Code != 400 || !strings.Contains(holding.Body.String(), "退给主人") {
t.Fatalf("还托管着钱就取消,应当先要求退款: HTTP %d %s", holding.Code, holding.Body.String())
}
refund := httptest.NewRecorder()
a.adminRefundFeedTask(refund, imTestRequest(1, "POST", fmt.Sprintf("/admin/v1/pet/feed-tasks/%d/refund", funded),
`{"reason":"主人来电取消,全额退回"}`))
if refund.Code != 200 {
t.Fatalf("HTTP %d: %s", refund.Code, refund.Body.String())
}
var status string
_ = db.QueryRow(`SELECT status FROM pet_feed_tasks WHERE id=?`, funded).Scan(&status)
if status != "REFUNDED" {
t.Fatalf("全额退款后状态 = %s", status)
}
})
}
+176
View File
@@ -0,0 +1,176 @@
package app
import (
"database/sql"
"fmt"
"net/http"
"strings"
"time"
)
// Refunds for feeding tasks. Three things make this different from the
// membership refund next door: it can happen more than once on the same order
// (part now, part later), it has to say which way the money went when no
// channel is connected, and it must refuse to quietly reverse a payout that has
// already reached a sitter.
type feedRefundRequest struct {
AmountCent int64 `json:"amountCent"` // 0 表示退全部剩余
Reason string `json:"reason"`
Offline bool `json:"offline"` // 渠道没接通时,登记为线下已退
PlatformBears bool `json:"platformBears"` // 已结算的任务由平台承担
}
func (a *App) adminRefundFeedTask(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
fail(w, http.StatusBadRequest, 20001, "编号无效")
return
}
var req feedRefundRequest
if decode(r, &req) != nil {
fail(w, http.StatusBadRequest, 20001, "参数错误")
return
}
req.Reason = strings.TrimSpace(req.Reason)
if len([]rune(req.Reason)) < 5 {
fail(w, http.StatusBadRequest, 20001, "请写明退款原因,双方都会收到")
return
}
var owner, sitter int64
var orderID sql.NullInt64
var gross, refunded int64
var status string
if a.db.QueryRowContext(r.Context(), `SELECT owner_user_id,COALESCE(sitter_user_id,0),order_id,gross_cent,refunded_cent,status
FROM pet_feed_tasks WHERE id=?`, id).Scan(&owner, &sitter, &orderID, &gross, &refunded, &status) != nil {
fail(w, http.StatusNotFound, 30001, "任务不存在")
return
}
remaining := gross - refunded
if remaining <= 0 {
fail(w, http.StatusBadRequest, 20001, "这笔任务已经退完了")
return
}
amount := req.AmountCent
if amount <= 0 {
amount = remaining
}
if amount > remaining {
fail(w, http.StatusBadRequest, 20001, fmt.Sprintf("最多还能退 %.2f 元", float64(remaining)/100))
return
}
// 已结算意味着钱在喂养者账上,退给主人的那份只能由平台承担 —— 这是一次
// 支出决定,不能顺手做掉,所以要显式勾选。
settled := status == "SETTLED" || status == "ARBITRATED"
if settled && !req.PlatformBears {
fail(w, http.StatusBadRequest, 20001, "任务已结算,报酬在喂养者账上。要退给主人只能由平台承担,请勾选后再提交")
return
}
if status == "CREATED" {
fail(w, http.StatusBadRequest, 20001, "任务还没有支付,直接取消即可")
return
}
var orderNo, providerOrderNo, orderStatus string
if orderID.Valid && orderID.Int64 > 0 {
_ = a.db.QueryRowContext(r.Context(), `SELECT order_no,provider_order_no,status FROM orders WHERE id=?`, orderID.Int64).
Scan(&orderNo, &providerOrderNo, &orderStatus)
}
// 只有真实支付过的订单才谈得上原路退回:手工托管进来的没有渠道流水号,
// 沙箱支付的流水号是假的,这两种都只能登记为线下已退。
live := a.configPlain(r.Context(), "payment.mode", "sandbox") == "live"
channel := "gateway"
if req.Offline || providerOrderNo == "" || strings.HasPrefix(providerOrderNo, "sandbox:") || !live {
channel = "offline"
}
refundNo := fmt.Sprintf("feed:%d:%d", id, time.Now().UnixMilli())
if channel == "gateway" {
if err = a.createGatewayRefund(r.Context(), orderNo, providerOrderNo, refundNo, int(amount)); err != nil {
a.audit(r, "feed_refund_failed", "pet_feed_task", id, map[string]any{"amountCent": amount, "error": err.Error()})
fail(w, http.StatusBadGateway, 50003, "退款网关请求失败,未记账,可以重试或改为线下退款登记")
return
}
}
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(), `INSERT INTO pet_feed_refunds
(task_id,order_id,refund_no,user_id,amount_cent,channel,platform_bears,reason,handled_by)
VALUES(?,?,?,?,?,?,?,?,?)`, id, orderID, refundNo, owner, amount, channel, settled, req.Reason, current(r).ID); err != nil {
fail(w, http.StatusInternalServerError, 50001, "退款记账失败")
return
}
if _, err = tx.ExecContext(r.Context(), `UPDATE pet_feed_tasks SET refunded_cent=refunded_cent+? WHERE id=?`, amount, id); err != nil {
fail(w, http.StatusInternalServerError, 50001, "退款记账失败")
return
}
if orderID.Valid && orderID.Int64 > 0 {
if _, err = tx.ExecContext(r.Context(), `UPDATE orders SET refunded_cent=refunded_cent+?,
status=IF(refunded_cent+?>=amount_cent,'REFUNDED',status) WHERE id=?`, amount, amount, orderID.Int64); err != nil {
fail(w, http.StatusInternalServerError, 50001, "退款记账失败")
return
}
}
// 退完并且还没结算的任务就此关闭;部分退款保留原状态,服务还得继续。
if refunded+amount >= gross && !settled {
if _, err = tx.ExecContext(r.Context(), `UPDATE pet_feed_tasks SET status='REFUNDED',confirm_due_at=NULL WHERE id=?`, id); err != nil {
fail(w, http.StatusInternalServerError, 50001, "退款记账失败")
return
}
}
if tx.Commit() != nil {
fail(w, http.StatusInternalServerError, 50001, "退款记账失败")
return
}
a.audit(r, "feed_refund", "pet_feed_task", id, map[string]any{
"amountCent": amount, "channel": channel, "platformBears": settled, "reason": req.Reason, "refundNo": refundNo,
})
body := fmt.Sprintf("已退款 %.2f 元:%s", float64(amount)/100, req.Reason)
a.notifyUser(r.Context(), owner, "system", "代喂任务已退款", body, "feed_task", id)
if sitter > 0 {
a.notifyUser(r.Context(), sitter, "system", "代喂任务已退款", req.Reason, "feed_task", id)
}
reply(w, map[string]any{
"success": true, "amountCent": amount, "channel": channel,
"refundedCent": refunded + amount, "remainingCent": remaining - amount,
})
}
func (a *App) adminFeedRefunds(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
fail(w, http.StatusBadRequest, 20001, "编号无效")
return
}
rows, queryErr := a.db.QueryContext(r.Context(), `SELECT f.id,f.amount_cent,f.channel,f.platform_bears,f.reason,
f.created_at,COALESCE(admin.username,'') FROM pet_feed_refunds f
LEFT JOIN admin_users admin ON admin.id=f.handled_by WHERE f.task_id=? ORDER BY f.id DESC`, id)
if queryErr != nil {
fail(w, http.StatusInternalServerError, 50001, "查询退款记录失败")
return
}
defer rows.Close()
items := []map[string]any{}
for rows.Next() {
var refundID, amount int64
var channel, reason, operator string
var platformBears bool
var created time.Time
if rows.Scan(&refundID, &amount, &channel, &platformBears, &reason, &created, &operator) != nil {
continue
}
items = append(items, map[string]any{
"id": refundID, "amountCent": amount, "channel": channel, "platformBears": platformBears,
"reason": reason, "operator": operator, "createdAt": created.Format(time.RFC3339),
})
}
reply(w, map[string]any{"items": items})
}
+39 -9
View File
@@ -395,8 +395,10 @@ func (a *App) adminPets(w http.ResponseWriter, r *http.Request) {
fail(w, http.StatusInternalServerError, 50001, "查询宠物失败")
return
}
// 后台要能改档案,所以这里返回完整字段,而不是只够列表显示的那几个。
rows, err := a.db.QueryContext(r.Context(), `SELECT p.id,p.owner_user_id,profile.nickname,u.public_id,p.name,p.species,p.breed,p.gender,
p.neutered,p.vaccinated_at,p.avatar_url,p.moderation_status,p.moderation_note,p.created_at`+from+` ORDER BY p.id DESC LIMIT ? OFFSET ?`,
p.neutered,p.vaccinated_at,p.avatar_url,p.moderation_status,p.moderation_note,p.created_at,
p.birthday,p.dewormed_at,p.weight_g,p.chip_no,p.temperament`+from+` ORDER BY p.id DESC LIMIT ? OFFSET ?`,
append(append([]any{}, args...), size, offset)...)
if err != nil {
fail(w, http.StatusInternalServerError, 50001, "查询宠物失败")
@@ -404,26 +406,54 @@ func (a *App) adminPets(w http.ResponseWriter, r *http.Request) {
}
defer rows.Close()
items := []map[string]any{}
ids := []any{}
placeholders := []string{}
for rows.Next() {
var id, ownerID int64
var nickname, publicID, name, species, breed, avatar, note string
var gender, neutered, moderation int
var vaccinated sql.NullTime
var nickname, publicID, name, species, breed, avatar, note, chipNo, temperament string
var gender, neutered, moderation, weight int
var vaccinated, birthday, dewormed sql.NullTime
var created time.Time
if rows.Scan(&id, &ownerID, &nickname, &publicID, &name, &species, &breed, &gender, &neutered,
&vaccinated, &avatar, &moderation, &note, &created) != nil {
&vaccinated, &avatar, &moderation, &note, &created, &birthday, &dewormed, &weight, &chipNo, &temperament) != nil {
continue
}
vaccinatedAt := ""
if vaccinated.Valid {
vaccinatedAt = vaccinated.Time.Format("2006-01-02")
day := func(value sql.NullTime) string {
if !value.Valid {
return ""
}
return value.Time.Format("2006-01-02")
}
items = append(items, map[string]any{
"id": id, "ownerUserId": ownerID, "ownerNickname": nickname, "ownerPublicId": publicID,
"name": name, "species": species, "speciesLabel": petSpecies[species], "breed": breed,
"gender": gender, "neutered": neutered == 1, "vaccinatedAt": vaccinatedAt, "avatar": avatar,
"gender": gender, "neutered": neutered == 1, "vaccinatedAt": day(vaccinated), "avatar": avatar,
"moderationStatus": moderation, "moderationNote": note, "createdAt": created.Format(time.RFC3339),
"birthday": day(birthday), "dewormedAt": day(dewormed), "weightG": weight,
"chipNo": chipNo, "temperament": temperament, "photos": []string{},
})
ids = append(ids, id)
placeholders = append(placeholders, "?")
}
// 照片单独取一遍,编辑弹窗要按整组替换,列表里就得先有整组。
if len(ids) > 0 {
mediaRows, mediaErr := a.db.QueryContext(r.Context(),
`SELECT pet_id,media_url FROM pet_media WHERE pet_id IN (`+strings.Join(placeholders, ",")+`) ORDER BY pet_id,sort_order,id`, ids...)
if mediaErr == nil {
defer mediaRows.Close()
for mediaRows.Next() {
var petID int64
var url string
if mediaRows.Scan(&petID, &url) != nil {
continue
}
for index := range items {
if items[index]["id"] == petID {
items[index]["photos"] = append(items[index]["photos"].([]string), url)
}
}
}
}
}
reply(w, map[string]any{"items": items, "total": total, "page": page, "size": size})
}
@@ -0,0 +1,34 @@
SET NAMES utf8mb4;
-- 悬赏:主人愿意在标准报酬之外多给一点来吸引人接单。它跟着任务一起托管,
-- 结算时全额给喂养者——平台服务费只算在基础报酬上,抽悬赏是喂养者一眼就
-- 能算出来的事,不值得。
ALTER TABLE pet_feed_tasks
ADD COLUMN tip_cent INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '主人自愿加的悬赏(分),不参与平台抽成' AFTER price_cent,
ADD COLUMN refunded_cent INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已退给主人的金额(分)' AFTER payout_cent;
ALTER TABLE orders
ADD COLUMN refunded_cent INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已退金额(分),支持多次部分退款' AFTER paid_amount_cent;
-- 每一次退款都单独记一行:谁退的、退了多少、走的哪条通道、为什么。
-- 部分退款要能退好几次,所以这里不是一条订单一行。
CREATE TABLE IF NOT EXISTS pet_feed_refunds (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
task_id BIGINT UNSIGNED NOT NULL,
order_id BIGINT UNSIGNED NULL,
refund_no VARCHAR(60) NOT NULL COMMENT '幂等键,重复通知不会退第二次',
user_id BIGINT UNSIGNED NOT NULL COMMENT '收到退款的人,通常是主人',
amount_cent INT UNSIGNED NOT NULL,
channel VARCHAR(20) NOT NULL DEFAULT 'gateway' COMMENT 'gateway 原路退回 / offline 线下已退',
platform_bears TINYINT(1) NOT NULL DEFAULT 0 COMMENT '已结算的任务由平台承担时为 1',
reason VARCHAR(500) NOT NULL DEFAULT '',
handled_by BIGINT UNSIGNED NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_refund_no (refund_no),
KEY idx_refunds_task (task_id, id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT INTO system_configs(config_key,config_value,value_type,description) VALUES
('pet.feed_tip_max_cent','20000','integer','单个任务的悬赏上限(分)')
ON DUPLICATE KEY UPDATE description=VALUES(description);