348 lines
14 KiB
Go
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, ¬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, 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
|
|
}
|