Files
kefu/im/backend/internal/app/payment_gateway.go
T
2026-09-03 08:38:17 +08:00

348 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, &notice) != 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, planID int64
var amountCent int
var status, channel string
if err = tx.QueryRowContext(ctx, `SELECT id,user_id,product_id,amount_cent,status,channel FROM orders WHERE order_no=? AND deleted_at IS NULL FOR UPDATE`, notice.OrderNo).Scan(&orderID, &userID, &planID, &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 = tx.ExecContext(ctx, `UPDATE subscriptions SET status=0 WHERE user_id=? AND status=1 AND source IN (?,?)`, userID, fmt.Sprintf("order:%d", orderID), fmt.Sprintf("admin_order:%d", orderID)); err != nil {
return err
}
if err = a.recomputeMembershipTx(ctx, tx, userID); 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()
}
var durationDays, level int
if err = tx.QueryRowContext(ctx, `SELECT duration_days,level FROM membership_plans WHERE id=? AND deleted_at IS NULL`, planID).Scan(&durationDays, &level); err != nil {
return fmt.Errorf("membership plan does not exist")
}
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 = grantOrderMembershipTx(ctx, tx, orderID, userID, planID, durationDays, level); err != nil {
return err
}
return tx.Commit()
}
func (a *App) createGatewayRefund(ctx context.Context, orderNo, providerOrderNo 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")
}
payload, err := json.Marshal(map[string]any{
"orderNo": orderNo, "providerOrderNo": providerOrderNo, "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", "refund:"+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 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, 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
}