200 lines
6.2 KiB
Go
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)
|
|
}
|