退款原来只有会员那条路,而且只能全额、只改本地状态。现在代喂任务的退款 能分几次退到退完:每一笔单独记一行(谁退的、多少、走哪条通道、为什么), 订单上的 refunded_cent 跟着累加,退完且未结算的任务才关闭。渠道接通时按 金额原路退回并带独立幂等键,没接通或沙箱支付的登记为"线下已退"——假装 调用了渠道比说实话更糟。已结算的任务不能顺手退:钱在喂养者账上,要退只能 由平台承担,必须显式勾选。 后台从"只能通过或驳回"变成能改能删:宠物档案可代建、可编辑、可软删除 (有进行中任务的删不掉),送养与配种能改文案和城市、能下架能删,代喂任务 能改备注、能取消(托管中的钱必须先退)。每一个写操作都进审计日志。 悬赏按方案里的 A 做:主人自愿加的钱跟着任务一起托管,结算时全额给喂养者, 平台服务费只算在基础报酬上。抽悬赏是喂养者一眼能算出来的事。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
347 lines
14 KiB
Go
347 lines
14 KiB
Go
package app
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type paymentGatewayOrder struct {
|
|
OrderNo string
|
|
AmountCent int
|
|
Channel string
|
|
Subject string
|
|
UserID int64
|
|
}
|
|
|
|
type paymentGatewayResult struct {
|
|
ProviderOrderNo string `json:"providerOrderNo"`
|
|
CheckoutURL string `json:"checkoutUrl"`
|
|
AppPayload map[string]any `json:"appPayload"`
|
|
}
|
|
|
|
type paymentNotifyRequest struct {
|
|
EventID string `json:"eventId"`
|
|
OrderNo string `json:"orderNo"`
|
|
Channel string `json:"channel"`
|
|
ProviderOrderNo string `json:"providerOrderNo"`
|
|
Status string `json:"status"`
|
|
AmountCent int `json:"amountCent"`
|
|
}
|
|
|
|
func validHTTPSURL(raw string) bool {
|
|
parsed, err := url.Parse(strings.TrimSpace(raw))
|
|
return err == nil && parsed.Scheme == "https" && parsed.Host != ""
|
|
}
|
|
|
|
func (a *App) paymentGatewayConfigured(ctx context.Context) bool {
|
|
createURL := a.configPlain(ctx, "payment.gateway.create_url", "")
|
|
refundURL := a.configPlain(ctx, "payment.gateway.refund_url", "")
|
|
notifyURL := a.configPlain(ctx, "payment.gateway.notify_url", "")
|
|
secret := a.configPlain(ctx, "payment.gateway.notify_secret", "")
|
|
token := a.configPlain(ctx, "payment.gateway.token", "")
|
|
if createURL == "" || refundURL == "" || notifyURL == "" || len(secret) < 32 || token == "" {
|
|
return false
|
|
}
|
|
if a.config.Environment == "production" && (!validHTTPSURL(createURL) || !validHTTPSURL(refundURL) || !validHTTPSURL(notifyURL)) {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (a *App) createGatewayPayment(ctx context.Context, order paymentGatewayOrder) (paymentGatewayResult, error) {
|
|
if !a.paymentGatewayConfigured(ctx) {
|
|
return paymentGatewayResult{}, fmt.Errorf("payment gateway is not completely configured")
|
|
}
|
|
payload, err := json.Marshal(map[string]any{
|
|
"orderNo": order.OrderNo, "amountCent": order.AmountCent, "currency": "CNY",
|
|
"channel": order.Channel, "subject": order.Subject, "userId": order.UserID,
|
|
"notifyUrl": a.configPlain(ctx, "payment.gateway.notify_url", ""),
|
|
"returnUrl": a.configPlain(ctx, "payment.gateway.return_url", ""),
|
|
})
|
|
if err != nil {
|
|
return paymentGatewayResult{}, err
|
|
}
|
|
request, err := http.NewRequestWithContext(ctx, http.MethodPost, a.configPlain(ctx, "payment.gateway.create_url", ""), bytes.NewReader(payload))
|
|
if err != nil {
|
|
return paymentGatewayResult{}, err
|
|
}
|
|
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", order.OrderNo)
|
|
|
|
timeout, _ := strconv.Atoi(a.configPlain(ctx, "payment.gateway.timeout_seconds", "10"))
|
|
if timeout < 3 || timeout > 30 {
|
|
timeout = 10
|
|
}
|
|
response, err := (&http.Client{Timeout: time.Duration(timeout) * time.Second}).Do(request)
|
|
if err != nil {
|
|
return paymentGatewayResult{}, fmt.Errorf("payment gateway request failed: %w", err)
|
|
}
|
|
defer response.Body.Close()
|
|
body, err := io.ReadAll(io.LimitReader(response.Body, 512<<10))
|
|
if err != nil {
|
|
return paymentGatewayResult{}, fmt.Errorf("read payment gateway response: %w", err)
|
|
}
|
|
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
|
return paymentGatewayResult{}, fmt.Errorf("payment gateway returned HTTP %d", response.StatusCode)
|
|
}
|
|
|
|
var envelope struct {
|
|
Code int `json:"code"`
|
|
Message string `json:"message"`
|
|
Data json.RawMessage `json:"data"`
|
|
}
|
|
var result paymentGatewayResult
|
|
if err = json.Unmarshal(body, &envelope); err == nil && len(envelope.Data) > 0 && string(envelope.Data) != "null" {
|
|
if envelope.Code != 0 {
|
|
return paymentGatewayResult{}, fmt.Errorf("payment gateway rejected request: %s", envelope.Message)
|
|
}
|
|
err = json.Unmarshal(envelope.Data, &result)
|
|
} else {
|
|
err = json.Unmarshal(body, &result)
|
|
}
|
|
if err != nil {
|
|
return paymentGatewayResult{}, fmt.Errorf("invalid payment gateway response: %w", err)
|
|
}
|
|
if strings.TrimSpace(result.ProviderOrderNo) == "" || (result.CheckoutURL == "" && len(result.AppPayload) == 0) {
|
|
return paymentGatewayResult{}, fmt.Errorf("payment gateway response is incomplete")
|
|
}
|
|
if result.CheckoutURL != "" && a.config.Environment == "production" && !validHTTPSURL(result.CheckoutURL) {
|
|
return paymentGatewayResult{}, fmt.Errorf("payment gateway returned a non-HTTPS checkout URL")
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (a *App) paymentNotify(w http.ResponseWriter, r *http.Request) {
|
|
if !a.paymentGatewayConfigured(r.Context()) {
|
|
fail(w, http.StatusServiceUnavailable, 50003, "支付网关未配置")
|
|
return
|
|
}
|
|
body, err := io.ReadAll(io.LimitReader(r.Body, 128<<10))
|
|
if err != nil || len(body) == 0 {
|
|
fail(w, http.StatusBadRequest, 20001, "支付通知内容无效")
|
|
return
|
|
}
|
|
timestamp := strings.TrimSpace(r.Header.Get("X-Xingyu-Timestamp"))
|
|
signature := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("X-Xingyu-Signature"), "sha256="))
|
|
unixSeconds, parseErr := strconv.ParseInt(timestamp, 10, 64)
|
|
if parseErr != nil || time.Since(time.Unix(unixSeconds, 0)) > 5*time.Minute || time.Until(time.Unix(unixSeconds, 0)) > 5*time.Minute {
|
|
fail(w, http.StatusUnauthorized, 10006, "支付通知时间戳无效")
|
|
return
|
|
}
|
|
mac := hmac.New(sha256.New, []byte(a.configPlain(r.Context(), "payment.gateway.notify_secret", "")))
|
|
_, _ = mac.Write([]byte(timestamp + "."))
|
|
_, _ = mac.Write(body)
|
|
expected := hex.EncodeToString(mac.Sum(nil))
|
|
if len(signature) != len(expected) || subtle.ConstantTimeCompare([]byte(strings.ToLower(signature)), []byte(expected)) != 1 {
|
|
fail(w, http.StatusUnauthorized, 10006, "支付通知签名无效")
|
|
return
|
|
}
|
|
var notice paymentNotifyRequest
|
|
if json.Unmarshal(body, ¬ice) != nil || notice.EventID == "" || notice.OrderNo == "" || notice.Channel == "" || notice.ProviderOrderNo == "" || notice.AmountCent <= 0 {
|
|
fail(w, http.StatusBadRequest, 20001, "支付通知字段不完整")
|
|
return
|
|
}
|
|
notice.Status = strings.ToUpper(strings.TrimSpace(notice.Status))
|
|
if notice.Status != "PAID" && notice.Status != "FAILED" && notice.Status != "CLOSED" && notice.Status != "REFUNDED" && notice.Status != "REFUND_FAILED" {
|
|
fail(w, http.StatusBadRequest, 20001, "支付通知状态无效")
|
|
return
|
|
}
|
|
if err = a.settlePayment(r.Context(), notice, string(body)); err != nil {
|
|
fail(w, http.StatusConflict, 20001, err.Error())
|
|
return
|
|
}
|
|
reply(w, map[string]any{"success": true, "eventId": notice.EventID})
|
|
}
|
|
|
|
func (a *App) settlePayment(ctx context.Context, notice paymentNotifyRequest, raw string) error {
|
|
tx, err := a.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
result, err := tx.ExecContext(ctx, `INSERT IGNORE INTO payment_events(event_id,order_no,channel,provider_order_no,event_status,amount_cent,raw_payload) VALUES(?,?,?,?,?,?,?)`, notice.EventID, notice.OrderNo, notice.Channel, notice.ProviderOrderNo, notice.Status, notice.AmountCent, raw)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
affected, _ := result.RowsAffected()
|
|
if affected == 0 {
|
|
return tx.Commit()
|
|
}
|
|
|
|
var orderID, userID, productID int64
|
|
var amountCent int
|
|
var status, channel, productType string
|
|
if err = tx.QueryRowContext(ctx, `SELECT id,user_id,product_type,product_id,amount_cent,status,channel FROM orders WHERE order_no=? AND deleted_at IS NULL FOR UPDATE`, notice.OrderNo).Scan(&orderID, &userID, &productType, &productID, &amountCent, &status, &channel); err != nil {
|
|
return fmt.Errorf("order does not exist")
|
|
}
|
|
if channel != notice.Channel || amountCent != notice.AmountCent {
|
|
return fmt.Errorf("payment amount or channel does not match the order")
|
|
}
|
|
if notice.Status == "REFUNDED" {
|
|
if status == "REFUNDED" {
|
|
return tx.Commit()
|
|
}
|
|
if status != "PAID" && status != "REFUNDING" {
|
|
return fmt.Errorf("order status does not allow refund")
|
|
}
|
|
if _, err = tx.ExecContext(ctx, `UPDATE orders SET status='REFUNDED',payment_notified_at=NOW(3) WHERE id=?`, orderID); err != nil {
|
|
return err
|
|
}
|
|
if err = a.revokePaidOrderTx(ctx, tx, orderID, userID, productType, productID, amountCent); err != nil {
|
|
return err
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
if notice.Status == "REFUND_FAILED" {
|
|
if status == "REFUNDING" {
|
|
if _, err = tx.ExecContext(ctx, `UPDATE orders SET status='PAID',payment_notified_at=NOW(3) WHERE id=?`, orderID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
if notice.Status == "PAID" && (status == "PAID" || status == "REFUNDING" || status == "REFUNDED") {
|
|
return tx.Commit()
|
|
}
|
|
if status != "CREATED" {
|
|
return fmt.Errorf("order status does not allow payment")
|
|
}
|
|
if notice.Status != "PAID" {
|
|
return tx.Commit()
|
|
}
|
|
|
|
if _, err = tx.ExecContext(ctx, `UPDATE orders SET status='PAID',paid_at=NOW(3),paid_amount_cent=?,provider_order_no=?,payment_notified_at=NOW(3) WHERE id=?`, notice.AmountCent, notice.ProviderOrderNo, orderID); err != nil {
|
|
return err
|
|
}
|
|
if err = a.applyPaidOrderTx(ctx, tx, orderID, userID, productType, productID, notice.AmountCent); err != nil {
|
|
return err
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
// 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, "refundNo": refundNo,
|
|
"amountCent": amountCent, "currency": "CNY", "reason": "admin_requested",
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
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", refundNo)
|
|
timeout, _ := strconv.Atoi(a.configPlain(ctx, "payment.gateway.timeout_seconds", "10"))
|
|
if timeout < 3 || timeout > 30 {
|
|
timeout = 10
|
|
}
|
|
response, err := (&http.Client{Timeout: time.Duration(timeout) * time.Second}).Do(request)
|
|
if err != nil {
|
|
return fmt.Errorf("refund gateway request failed: %w", err)
|
|
}
|
|
defer response.Body.Close()
|
|
body, err := io.ReadAll(io.LimitReader(response.Body, 256<<10))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
|
return fmt.Errorf("refund gateway returned HTTP %d", response.StatusCode)
|
|
}
|
|
var gatewayResponse struct {
|
|
Code *int `json:"code"`
|
|
Success *bool `json:"success"`
|
|
Message string `json:"message"`
|
|
}
|
|
if len(bytes.TrimSpace(body)) > 0 {
|
|
if err = json.Unmarshal(body, &gatewayResponse); err != nil {
|
|
return fmt.Errorf("refund gateway returned invalid JSON: %w", err)
|
|
}
|
|
if (gatewayResponse.Code != nil && *gatewayResponse.Code != 0) || (gatewayResponse.Success != nil && !*gatewayResponse.Success) {
|
|
return fmt.Errorf("refund gateway rejected request: %s", gatewayResponse.Message)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (a *App) requestLiveRefund(w http.ResponseWriter, r *http.Request, orderID int64) {
|
|
tx, err := a.db.BeginTx(r.Context(), &sql.TxOptions{Isolation: sql.LevelReadCommitted})
|
|
if err != nil {
|
|
fail(w, http.StatusInternalServerError, 50001, "创建退款申请失败")
|
|
return
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
var userID int64
|
|
var amountCent int
|
|
var orderNo, providerOrderNo, status string
|
|
if err = tx.QueryRowContext(r.Context(), `SELECT user_id,amount_cent,order_no,provider_order_no,status FROM orders WHERE id=? AND deleted_at IS NULL FOR UPDATE`, orderID).Scan(&userID, &amountCent, &orderNo, &providerOrderNo, &status); err != nil {
|
|
fail(w, http.StatusNotFound, 30001, "订单不存在")
|
|
return
|
|
}
|
|
if status != "PAID" && status != "REFUND_REQUESTED" && status != "REFUNDING" {
|
|
fail(w, http.StatusBadRequest, 20001, "只有已支付、用户已申请退款或退款处理中的订单可发起退款")
|
|
return
|
|
}
|
|
if providerOrderNo == "" {
|
|
fail(w, http.StatusBadRequest, 20001, "订单缺少支付渠道流水号,不能自动退款")
|
|
return
|
|
}
|
|
if status == "PAID" || status == "REFUND_REQUESTED" {
|
|
if _, err = tx.ExecContext(r.Context(), `UPDATE orders SET status='REFUNDING' WHERE id=?`, orderID); err != nil {
|
|
fail(w, http.StatusInternalServerError, 50001, "更新退款状态失败")
|
|
return
|
|
}
|
|
}
|
|
if err = tx.Commit(); err != nil {
|
|
fail(w, http.StatusInternalServerError, 50001, "创建退款申请失败")
|
|
return
|
|
}
|
|
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
|
|
}
|
|
a.audit(r, "refund_requested", "order", orderID, map[string]any{"userId": userID, "amountCent": amountCent})
|
|
reply(w, map[string]any{"success": true, "status": "REFUNDING"})
|
|
}
|
|
|
|
func grantOrderMembershipTx(ctx context.Context, tx *sql.Tx, orderID, userID, planID int64, durationDays, level int) error {
|
|
var base time.Time
|
|
if err := tx.QueryRowContext(ctx, `SELECT GREATEST(NOW(3),COALESCE(MAX(expires_at),NOW(3))) FROM subscriptions WHERE user_id=? AND status=1 AND expires_at>NOW(3)`, userID).Scan(&base); err != nil {
|
|
return err
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `INSERT INTO subscriptions(user_id,plan_id,source,status,started_at,expires_at) VALUES(?,?,?,1,NOW(3),DATE_ADD(?,INTERVAL ? DAY))`, userID, planID, fmt.Sprintf("order:%d", orderID), base, durationDays); err != nil {
|
|
return err
|
|
}
|
|
_, err := tx.ExecContext(ctx, `UPDATE user_profiles SET is_vip=1,vip_level=GREATEST(vip_level,?) WHERE user_id=?`, level, userID)
|
|
return err
|
|
}
|