整改三期:退款、后台增改删、悬赏

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-07 11:25:02 +08:00
co-authored by Claude Opus 5
parent ebf7189533
commit a889cb42e4
8 changed files with 1011 additions and 22 deletions
+11 -5
View File
@@ -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