整改三期:退款、后台增改删、悬赏
退款原来只有会员那条路,而且只能全额、只改本地状态。现在代喂任务的退款 能分几次退到退完:每一笔单独记一行(谁退的、多少、走哪条通道、为什么), 订单上的 refunded_cent 跟着累加,退完且未结算的任务才关闭。渠道接通时按 金额原路退回并带独立幂等键,没接通或沙箱支付的登记为"线下已退"——假装 调用了渠道比说实话更糟。已结算的任务不能顺手退:钱在喂养者账上,要退只能 由平台承担,必须显式勾选。 后台从"只能通过或驳回"变成能改能删:宠物档案可代建、可编辑、可软删除 (有进行中任务的删不掉),送养与配种能改文案和城市、能下架能删,代喂任务 能改备注、能取消(托管中的钱必须先退)。每一个写操作都进审计日志。 悬赏按方案里的 A 做:主人自愿加的钱跟着任务一起托管,结算时全额给喂养者, 平台服务费只算在基础报酬上。抽悬赏是喂养者一眼能算出来的事。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ebf7189533
commit
a889cb42e4
@@ -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)},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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(¬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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user