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