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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 11:25:02 +08:00

177 lines
6.9 KiB
Go

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})
}