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