From a889cb42e418ea273fa50ad9f00efe0ba5389d59 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 7 Sep 2026 11:25:02 +0800 Subject: [PATCH] =?UTF-8?q?=E6=95=B4=E6=94=B9=E4=B8=89=E6=9C=9F=EF=BC=9A?= =?UTF-8?q?=E9=80=80=E6=AC=BE=E3=80=81=E5=90=8E=E5=8F=B0=E5=A2=9E=E6=94=B9?= =?UTF-8?q?=E5=88=A0=E3=80=81=E6=82=AC=E8=B5=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 退款原来只有会员那条路,而且只能全额、只改本地状态。现在代喂任务的退款 能分几次退到退完:每一笔单独记一行(谁退的、多少、走哪条通道、为什么), 订单上的 refunded_cent 跟着累加,退完且未结算的任务才关闭。渠道接通时按 金额原路退回并带独立幂等键,没接通或沙箱支付的登记为"线下已退"——假装 调用了渠道比说实话更糟。已结算的任务不能顺手退:钱在喂养者账上,要退只能 由平台承担,必须显式勾选。 后台从"只能通过或驳回"变成能改能删:宠物档案可代建、可编辑、可软删除 (有进行中任务的删不掉),送养与配种能改文案和城市、能下架能删,代喂任务 能改备注、能取消(托管中的钱必须先退)。每一个写操作都进审计日志。 悬赏按方案里的 A 做:主人自愿加的钱跟着任务一起托管,结算时全额给喂养者, 平台服务费只算在基础报酬上。抽悬赏是喂养者一眼能算出来的事。 Co-Authored-By: Claude Opus 5 --- im/backend/internal/app/app.go | 9 + im/backend/internal/app/payment_gateway.go | 16 +- im/backend/internal/app/pet_admin_crud.go | 405 ++++++++++++++++++ im/backend/internal/app/pet_feed.go | 30 +- .../internal/app/pet_feed_integration_test.go | 315 ++++++++++++++ im/backend/internal/app/pet_feed_refund.go | 176 ++++++++ im/backend/internal/app/pets.go | 48 ++- .../migrations/041_pet_feed_tip_refund.sql | 34 ++ 8 files changed, 1011 insertions(+), 22 deletions(-) create mode 100644 im/backend/internal/app/pet_admin_crud.go create mode 100644 im/backend/internal/app/pet_feed_refund.go create mode 100644 im/backend/migrations/041_pet_feed_tip_refund.sql diff --git a/im/backend/internal/app/app.go b/im/backend/internal/app/app.go index e9abf98..1240749 100644 --- a/im/backend/internal/app/app.go +++ b/im/backend/internal/app/app.go @@ -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)}, diff --git a/im/backend/internal/app/payment_gateway.go b/im/backend/internal/app/payment_gateway.go index c409ffa..103f121 100644 --- a/im/backend/internal/app/payment_gateway.go +++ b/im/backend/internal/app/payment_gateway.go @@ -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 diff --git a/im/backend/internal/app/pet_admin_crud.go b/im/backend/internal/app/pet_admin_crud.go new file mode 100644 index 0000000..7c47a90 --- /dev/null +++ b/im/backend/internal/app/pet_admin_crud.go @@ -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}) +} diff --git a/im/backend/internal/app/pet_feed.go b/im/backend/internal/app/pet_feed.go index 9503d7c..3c163a5 100644 --- a/im/backend/internal/app/pet_feed.go +++ b/im/backend/internal/app/pet_feed.go @@ -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 } diff --git a/im/backend/internal/app/pet_feed_integration_test.go b/im/backend/internal/app/pet_feed_integration_test.go index 835384c..55bfedf 100644 --- a/im/backend/internal/app/pet_feed_integration_test.go +++ b/im/backend/internal/app/pet_feed_integration_test.go @@ -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(¬ified); 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) + } + }) +} diff --git a/im/backend/internal/app/pet_feed_refund.go b/im/backend/internal/app/pet_feed_refund.go new file mode 100644 index 0000000..9e1f5fc --- /dev/null +++ b/im/backend/internal/app/pet_feed_refund.go @@ -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}) +} diff --git a/im/backend/internal/app/pets.go b/im/backend/internal/app/pets.go index 4f205aa..b59d051 100644 --- a/im/backend/internal/app/pets.go +++ b/im/backend/internal/app/pets.go @@ -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, ¬e, &created) != nil { + &vaccinated, &avatar, &moderation, ¬e, &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}) } diff --git a/im/backend/migrations/041_pet_feed_tip_refund.sql b/im/backend/migrations/041_pet_feed_tip_refund.sql new file mode 100644 index 0000000..f90948d --- /dev/null +++ b/im/backend/migrations/041_pet_feed_tip_refund.sql @@ -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);