Files
NianAIGC/backend/internal/httpapi/payments.go
T
2026-10-12 13:57:23 +08:00

200 lines
6.2 KiB
Go

package httpapi
import (
"context"
"encoding/json"
"errors"
"io"
"mime"
"net/http"
"net/url"
"regexp"
"strings"
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/payments"
)
type PaymentService interface {
Options() payments.Options
Create(context.Context, string, string, string, int64) (payments.Order, error)
Get(context.Context, string, string) (payments.Order, error)
List(context.Context, string) ([]payments.Order, error)
Notify(context.Context, *http.Request) error
}
type paymentHandler struct {
authorizer *PlatformAuthorizer
service PaymentService
publicOrigin string
}
func NewPaymentHandler(authorizer *PlatformAuthorizer, service PaymentService, publicOrigin string) http.Handler {
return &paymentHandler{authorizer: authorizer, service: service, publicOrigin: publicOrigin}
}
var paymentIdentifier = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,64}$`)
func (h *paymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
if r.URL.Path == "/api/payments/wechat/refund-notify" {
h.refundNotify(w, r)
return
}
if strings.HasPrefix(r.URL.Path, "/api/payments/admin/") {
h.adminPayments(w, r)
return
}
if r.URL.Path == "/api/payments/wechat/notify" {
if !allow(w, r, http.MethodPost) {
return
}
if h.service == nil {
writeJSON(w, 503, map[string]string{"code": "FAIL", "message": "支付服务未启用"})
return
}
r.Body = http.MaxBytesReader(w, r.Body, 64<<10)
if err := h.service.Notify(r.Context(), r); err != nil {
status := http.StatusInternalServerError
if errors.Is(err, payments.ErrVerification) {
status = http.StatusBadRequest
}
// Acknowledge only after durable settlement; never leak decrypted data.
writeJSON(w, status, map[string]string{"code": "FAIL", "message": "支付通知处理失败"})
return
}
w.WriteHeader(http.StatusNoContent)
return
}
optionsPath := r.URL.Path == "/api/payments/options"
ordersPath := r.URL.Path == "/api/payments/orders"
orderID := strings.TrimPrefix(r.URL.Path, "/api/payments/orders/")
itemPath := strings.HasPrefix(r.URL.Path, "/api/payments/orders/") && len(orderID) <= 32 && paymentIdentifier.MatchString(orderID)
if !optionsPath && !ordersPath && !itemPath {
http.NotFound(w, r)
return
}
if r.Method != http.MethodGet && !(ordersPath && r.Method == http.MethodPost) {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
session, err := h.authorizer.Authorize(r, PlatformApp)
if err != nil {
writePaymentError(w, err)
return
}
canRecharge := session.User.OrganizationID != "" && (session.User.Role == "organization_admin" || session.User.Role == "super_admin")
if optionsPath {
options := payments.Options{PresetAmountsFen: []int64{}}
if h.service != nil {
options = h.service.Options()
}
options.CanRecharge = options.Enabled && canRecharge
writeJSON(w, http.StatusOK, options)
return
}
if !canRecharge {
writeAPIError(w, http.StatusForbidden, "需要所属组织的管理员权限。")
return
}
if h.service == nil {
writePaymentError(w, payments.ErrUnavailable)
return
}
org := session.User.OrganizationID
if itemPath {
order, err := h.service.Get(r.Context(), org, orderID)
if err != nil {
writePaymentError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"order": publicPaymentOrder(order)})
return
}
if r.Method == http.MethodGet {
orders, err := h.service.List(r.Context(), org)
if err != nil {
writePaymentError(w, err)
return
}
if orders == nil {
orders = []payments.Order{}
}
for i := range orders {
orders[i] = publicPaymentOrder(orders[i])
}
writeJSON(w, http.StatusOK, map[string]any{"orders": orders})
return
}
if !h.sameOrigin(r) {
writeAPIError(w, http.StatusForbidden, "请从本站发起充值。")
return
}
mediaType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
if err != nil || mediaType != "application/json" {
writeAPIError(w, http.StatusUnsupportedMediaType, "请使用 JSON 请求。")
return
}
var body struct {
AmountFen int64 `json:"amountFen"`
RequestKey string `json:"requestKey"`
}
r.Body = http.MaxBytesReader(w, r.Body, 4096)
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(&body); err != nil {
writeAPIError(w, 400, "充值参数无效,金额必须是整数分。")
return
}
if err := decoder.Decode(&struct{}{}); err != io.EOF || !paymentIdentifier.MatchString(body.RequestKey) {
writeAPIError(w, 400, "充值请求标识无效。")
return
}
order, err := h.service.Create(r.Context(), org, session.User.ID, body.RequestKey, body.AmountFen)
if err != nil {
writePaymentError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"order": publicPaymentOrder(order)})
}
func publicPaymentOrder(order payments.Order) payments.Order {
if order.Status != payments.StatusPending {
order.CodeURL = ""
}
return order
}
func (h *paymentHandler) sameOrigin(r *http.Request) bool {
if site := r.Header.Get("Sec-Fetch-Site"); site != "" && site != "same-origin" && site != "none" {
return false
}
origin := r.Header.Get("Origin")
if origin == "" {
return true
}
actual, err := url.Parse(origin)
if err != nil || actual.User != nil || actual.RawQuery != "" || actual.Fragment != "" || actual.Path != "" {
return false
}
expected, err := url.Parse(h.publicOrigin)
return err == nil && expected.Host != "" && actual.Scheme == expected.Scheme && strings.EqualFold(actual.Host, expected.Host)
}
func writePaymentError(w http.ResponseWriter, err error) {
status, message := http.StatusInternalServerError, "支付服务暂时不可用,请稍后重试。"
var authErr *PlatformAuthError
switch {
case errors.As(err, &authErr):
status, message = authErr.Status, authErr.Message
case errors.Is(err, payments.ErrNotFound):
status, message = 404, "充值订单不存在。"
case errors.Is(err, payments.ErrConflict):
status, message = 409, "充值请求与已有订单不一致,请刷新后重试。"
case errors.Is(err, payments.ErrInvalidAmount):
status, message = 400, "充值金额不在允许范围内。"
case errors.Is(err, payments.ErrUnavailable):
status, message = 503, "微信支付尚未启用。"
}
writeAPIError(w, status, message)
}