整改三期:退款、后台增改删、悬赏
退款原来只有会员那条路,而且只能全额、只改本地状态。现在代喂任务的退款 能分几次退到退完:每一笔单独记一行(谁退的、多少、走哪条通道、为什么), 订单上的 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
@@ -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})
|
||||
}
|
||||
Reference in New Issue
Block a user