增加支付逻辑
This commit is contained in:
1 parent
4ac786aafb
commit
dfb2c91b8d
77 files changed
+6370
-45
No files matched your search
@@ -12,8 +12,8 @@ import (
|
||||
|
||||
func TestRouteMethodCompatibilityDerivesEverySurfacePath(t *testing.T) {
|
||||
patterns := routeMethodPatterns(GoRouteSurface())
|
||||
if len(patterns) != 50 {
|
||||
t.Fatalf("route patterns=%d want 50", len(patterns))
|
||||
if len(patterns) != 58 {
|
||||
t.Fatalf("route patterns=%d want 58", len(patterns))
|
||||
}
|
||||
for _, pattern := range patterns {
|
||||
if _, ok := pattern.methods[http.MethodOptions]; !ok {
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/payments"
|
||||
)
|
||||
|
||||
type PaymentRefundService interface {
|
||||
AdminList(context.Context, string, int, int) (payments.AdminOrdersPage, error)
|
||||
CreateRefund(context.Context, string, string, string, string, int64) (payments.Refund, error)
|
||||
GetRefund(context.Context, string) (payments.Refund, error)
|
||||
ListRefunds(context.Context, string) ([]payments.Refund, error)
|
||||
RefundNotify(context.Context, *http.Request) error
|
||||
}
|
||||
|
||||
func (h *paymentHandler) adminPayments(w http.ResponseWriter, r *http.Request) {
|
||||
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/payments/admin/"), "/")
|
||||
list := len(parts) == 1 && parts[0] == "orders"
|
||||
order := len(parts) == 3 && parts[0] == "orders" && parts[2] == "refunds" && len(parts[1]) <= 32 && paymentIdentifier.MatchString(parts[1])
|
||||
refund := len(parts) == 2 && parts[0] == "refunds" && len(parts[1]) <= 64 && paymentIdentifier.MatchString(parts[1])
|
||||
if !list && !order && !refund {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodGet && !(order && r.Method == http.MethodPost) {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
session, err := h.authorizer.Authorize(r, PlatformSuperAdmin)
|
||||
if err != nil {
|
||||
writePaymentError(w, err)
|
||||
return
|
||||
}
|
||||
service, ok := h.service.(PaymentRefundService)
|
||||
if !ok {
|
||||
writeRefundError(w, payments.ErrUnavailable)
|
||||
return
|
||||
}
|
||||
if list {
|
||||
limit, offset := 50, 0
|
||||
if raw := r.URL.Query().Get("limit"); raw != "" {
|
||||
limit, err = strconv.Atoi(raw)
|
||||
if err != nil || limit < 1 || limit > 100 {
|
||||
writeAPIError(w, 400, "分页参数无效。")
|
||||
return
|
||||
}
|
||||
}
|
||||
if raw := r.URL.Query().Get("offset"); raw != "" {
|
||||
offset, err = strconv.Atoi(raw)
|
||||
if err != nil || offset < 0 || offset > 10000000 {
|
||||
writeAPIError(w, 400, "分页参数无效。")
|
||||
return
|
||||
}
|
||||
}
|
||||
org := r.URL.Query().Get("organizationId")
|
||||
if len(org) > 128 {
|
||||
writeAPIError(w, 400, "组织参数无效。")
|
||||
return
|
||||
}
|
||||
page, err := service.AdminList(r.Context(), org, limit, offset)
|
||||
if err != nil {
|
||||
writeRefundError(w, err)
|
||||
return
|
||||
}
|
||||
if page.Orders == nil {
|
||||
page.Orders = []payments.AdminOrder{}
|
||||
}
|
||||
for i := range page.Orders {
|
||||
page.Orders[i].CodeURL = ""
|
||||
}
|
||||
writeJSON(w, 200, page)
|
||||
return
|
||||
}
|
||||
if refund {
|
||||
value, err := service.GetRefund(r.Context(), parts[1])
|
||||
if err != nil {
|
||||
writeRefundError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"refund": value})
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodGet {
|
||||
values, err := service.ListRefunds(r.Context(), parts[1])
|
||||
if err != nil {
|
||||
writeRefundError(w, err)
|
||||
return
|
||||
}
|
||||
if values == nil {
|
||||
values = []payments.Refund{}
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"refunds": values})
|
||||
return
|
||||
}
|
||||
if !h.sameOrigin(r) {
|
||||
writeAPIError(w, 403, "请从本站发起退款。")
|
||||
return
|
||||
}
|
||||
mediaType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
||||
if err != nil || mediaType != "application/json" {
|
||||
writeAPIError(w, 415, "请使用 JSON 请求。")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
AmountFen int64 `json:"amountFen"`
|
||||
Reason string `json:"reason"`
|
||||
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
|
||||
}
|
||||
body.Reason = strings.TrimSpace(body.Reason)
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF || body.AmountFen <= 0 || !paymentIdentifier.MatchString(body.RequestKey) || body.Reason == "" || len(body.Reason) > 80 || !utf8.ValidString(body.Reason) {
|
||||
writeAPIError(w, 400, "退款金额、请求标识或原因无效(原因最多 80 字节)。")
|
||||
return
|
||||
}
|
||||
value, err := service.CreateRefund(r.Context(), parts[1], session.User.ID, body.RequestKey, body.Reason, body.AmountFen)
|
||||
if err != nil {
|
||||
writeRefundError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"refund": value})
|
||||
}
|
||||
|
||||
func (h *paymentHandler) refundNotify(w http.ResponseWriter, r *http.Request) {
|
||||
if !allow(w, r, http.MethodPost) {
|
||||
return
|
||||
}
|
||||
service, ok := h.service.(PaymentRefundService)
|
||||
if !ok {
|
||||
writeJSON(w, 503, map[string]string{"code": "FAIL", "message": "退款服务未启用"})
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 64<<10)
|
||||
if err := service.RefundNotify(r.Context(), r); err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, payments.ErrVerification) {
|
||||
status = http.StatusBadRequest
|
||||
}
|
||||
writeJSON(w, status, map[string]string{"code": "FAIL", "message": "退款通知处理失败"})
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func writeRefundError(w http.ResponseWriter, err error) {
|
||||
status, message := 500, "退款服务暂时不可用,请稍后刷新记录。"
|
||||
switch {
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/identity"
|
||||
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/payments"
|
||||
)
|
||||
|
||||
type refundHTTPStub struct {
|
||||
paymentStub
|
||||
refundCalls, notifyCalls, readCalls int
|
||||
order, operator, key, reason, filter string
|
||||
refundAmount int64
|
||||
limit, offset int
|
||||
refundErr error
|
||||
}
|
||||
|
||||
func (s *refundHTTPStub) AdminList(_ context.Context, org string, limit, offset int) (payments.AdminOrdersPage, error) {
|
||||
s.readCalls++
|
||||
s.filter, s.limit, s.offset = org, limit, offset
|
||||
return payments.AdminOrdersPage{Orders: []payments.AdminOrder{{Order: payments.Order{ID: "cross-org", OrganizationID: "org2", Status: payments.StatusPending, CodeURL: "weixin://private"}}}}, s.refundErr
|
||||
}
|
||||
func (s *refundHTTPStub) CreateRefund(_ context.Context, order, operator, key, reason string, amount int64) (payments.Refund, error) {
|
||||
s.refundCalls++
|
||||
s.order, s.operator, s.key, s.reason, s.refundAmount = order, operator, key, reason, amount
|
||||
return payments.Refund{ID: "refund1", Status: payments.RefundPending, AmountFen: amount}, s.refundErr
|
||||
}
|
||||
func (s *refundHTTPStub) GetRefund(_ context.Context, id string) (payments.Refund, error) {
|
||||
s.readCalls++
|
||||
return payments.Refund{ID: id, Status: payments.RefundSuccess}, s.refundErr
|
||||
}
|
||||
func (s *refundHTTPStub) ListRefunds(_ context.Context, id string) ([]payments.Refund, error) {
|
||||
s.readCalls++
|
||||
s.order = id
|
||||
return nil, s.refundErr
|
||||
}
|
||||
func (s *refundHTTPStub) RefundNotify(_ context.Context, _ *http.Request) error {
|
||||
s.notifyCalls++
|
||||
return s.refundErr
|
||||
}
|
||||
|
||||
func TestAdminRefundRoutesRequireSuperAdmin(t *testing.T) {
|
||||
for _, role := range []string{"user", "organization_admin", "super_admin"} {
|
||||
for _, authenticated := range []bool{false, true} {
|
||||
for _, route := range []struct{ method, path string }{
|
||||
{"GET", "/api/payments/admin/orders"},
|
||||
{"GET", "/api/payments/admin/orders/order1/refunds"},
|
||||
{"POST", "/api/payments/admin/orders/order1/refunds"},
|
||||
{"GET", "/api/payments/admin/refunds/refund1"},
|
||||
} {
|
||||
s := &refundHTTPStub{}
|
||||
got := paymentRequest(paymentTestHandler(t, role, s), route.method, route.path, `{"amountFen":100,"requestKey":"retry-1","reason":"用户申请"}`, "https://pay.example.test", authenticated)
|
||||
want := 200
|
||||
if !authenticated {
|
||||
want = 401
|
||||
} else if role != "super_admin" {
|
||||
want = 403
|
||||
}
|
||||
if got.Code != want {
|
||||
t.Fatalf("%s %s role=%s auth=%t got=%d body=%s", route.method, route.path, role, authenticated, got.Code, got.Body.String())
|
||||
}
|
||||
if want != 200 && s.refundCalls+s.readCalls != 0 {
|
||||
t.Fatal("unauthorized service call")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminWithoutOrganizationCanViewAllOrdersAndRefund(t *testing.T) {
|
||||
s := &refundHTTPStub{}
|
||||
auth, err := NewPlatformAuthorizer(AuthState{Required: true, Configured: true}, &fixedSessionResolver{session: identity.Session{AuthMode: identity.AuthModeAdmin, User: identity.User{ID: "global-admin", Role: "super_admin"}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := NewPaymentHandler(auth, s, "https://pay.example.test")
|
||||
got := paymentRequest(h, "GET", "/api/payments/admin/orders?organizationId=org2&limit=10&offset=20", "", "", true)
|
||||
if got.Code != 200 || s.filter != "org2" || s.limit != 10 || s.offset != 20 || strings.Contains(got.Body.String(), "weixin:") {
|
||||
t.Fatalf("status=%d body=%s", got.Code, got.Body.String())
|
||||
}
|
||||
got = paymentRequest(h, "POST", "/api/payments/admin/orders/order2/refunds", `{"amountFen":123,"requestKey":"retry-1","reason":" 用户申请 "}`, "", true)
|
||||
if got.Code != 200 || s.operator != "global-admin" || s.order != "order2" || s.refundAmount != 123 || s.reason != "用户申请" || s.key != "retry-1" {
|
||||
t.Fatalf("status=%d stub=%+v", got.Code, s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminRefundRejectsTamperedBodyAndCrossOrigin(t *testing.T) {
|
||||
s := &refundHTTPStub{}
|
||||
h := paymentTestHandler(t, "super_admin", s)
|
||||
for _, body := range []string{
|
||||
`{"amountFen":1.1,"requestKey":"x","reason":"ok"}`,
|
||||
`{"amountFen":0,"requestKey":"x","reason":"ok"}`,
|
||||
`{"amountFen":-1,"requestKey":"x","reason":"ok"}`,
|
||||
`{"amountFen":100,"requestKey":"x","reason":"ok","operatorId":"victim"}`,
|
||||
`{"amountFen":100,"requestKey":"x","reason":"ok","organizationId":"victim"}`,
|
||||
`{"amountFen":100,"requestKey":"a/b","reason":"ok"}`,
|
||||
`{"amountFen":100,"requestKey":"x","reason":" "}`,
|
||||
`{"amountFen":100,"requestKey":"x","reason":"` + strings.Repeat("退", 27) + `"}`,
|
||||
`{"amountFen":100,"requestKey":"x","reason":"ok"} {}`,
|
||||
} {
|
||||
got := paymentRequest(h, "POST", "/api/payments/admin/orders/order1/refunds", body, "", true)
|
||||
if got.Code != 400 {
|
||||
t.Fatalf("body=%s status=%d", body, got.Code)
|
||||
}
|
||||
}
|
||||
got := paymentRequest(h, "POST", "/api/payments/admin/orders/order1/refunds", `{"amountFen":100,"requestKey":"x","reason":"ok"}`, "https://evil.test", true)
|
||||
if got.Code != 403 || s.refundCalls != 0 {
|
||||
t.Fatalf("status=%d calls=%d", got.Code, s.refundCalls)
|
||||
}
|
||||
for _, query := range []string{"limit=0", "limit=101", "limit=abc", "offset=-1", "offset=10000001"} {
|
||||
if got := paymentRequest(h, "GET", "/api/payments/admin/orders?"+query, "", "", true); got.Code != 400 {
|
||||
t.Fatalf("query=%s status=%d", query, got.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefundCallbackHasNoSessionAndOnlyAcknowledgesDurableSuccess(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
err error
|
||||
want int
|
||||
}{{nil, 204}, {payments.ErrVerification, 400}, {errors.New("secret database details"), 500}} {
|
||||
s := &refundHTTPStub{refundErr: test.err}
|
||||
h := paymentTestHandler(t, "user", s)
|
||||
got := paymentRequest(h, "POST", "/api/payments/wechat/refund-notify", `{}`, "", false)
|
||||
if got.Code != test.want || s.notifyCalls != 1 || strings.Contains(got.Body.String(), "secret") {
|
||||
t.Fatalf("status=%d body=%s", got.Code, got.Body.String())
|
||||
}
|
||||
got = paymentRequest(h, "GET", "/api/payments/wechat/refund-notify", "", "", false)
|
||||
if got.Code != 405 || s.notifyCalls != 1 {
|
||||
t.Fatal("GET invoked refund callback")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefundErrorMappingAndStrictRoutes(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
err error
|
||||
want int
|
||||
}{{payments.ErrNotFound, 404}, {payments.ErrConflict, 409}, {payments.ErrInvalidAmount, 400}, {payments.ErrUnavailable, 503}, {errors.New("secret"), 500}} {
|
||||
s := &refundHTTPStub{refundErr: test.err}
|
||||
got := paymentRequest(paymentTestHandler(t, "super_admin", s), "GET", "/api/payments/admin/refunds/refund1", "", "", true)
|
||||
if got.Code != test.want || strings.Contains(got.Body.String(), "secret") {
|
||||
t.Fatalf("status=%d body=%s", got.Code, got.Body.String())
|
||||
}
|
||||
}
|
||||
h := paymentTestHandler(t, "super_admin", &refundHTTPStub{})
|
||||
for _, path := range []string{"/api/payments/admin/", "/api/payments/admin/orders/a/b/refunds", "/api/payments/admin/orders/order1/refunds/"} {
|
||||
if got := paymentRequest(h, "GET", path, "", "", true); got.Code != 404 {
|
||||
t.Fatalf("%s status=%d", path, got.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/identity"
|
||||
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/payments"
|
||||
)
|
||||
|
||||
type paymentStub struct {
|
||||
org, account, requestKey string
|
||||
amount int64
|
||||
notifications int
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *paymentStub) Options() payments.Options {
|
||||
return payments.Options{Enabled: true, PresetAmountsFen: []int64{10000}, MinAmountFen: 100, MaxAmountFen: 1000000}
|
||||
}
|
||||
func (s *paymentStub) Create(_ context.Context, org, account, key string, amount int64) (payments.Order, error) {
|
||||
s.org, s.account, s.requestKey, s.amount = org, account, key, amount
|
||||
return payments.Order{ID: "order1", OrganizationID: org, AmountFen: amount, Status: payments.StatusPending}, s.err
|
||||
}
|
||||
func (s *paymentStub) Get(_ context.Context, org, id string) (payments.Order, error) {
|
||||
s.org = org
|
||||
return payments.Order{ID: id, Status: payments.StatusPaid, CodeURL: "weixin://stale"}, s.err
|
||||
}
|
||||
func (s *paymentStub) List(_ context.Context, org string) ([]payments.Order, error) {
|
||||
s.org = org
|
||||
return nil, s.err
|
||||
}
|
||||
func (s *paymentStub) Notify(_ context.Context, _ *http.Request) error {
|
||||
s.notifications++
|
||||
return s.err
|
||||
}
|
||||
|
||||
func paymentTestHandler(t *testing.T, role string, service PaymentService) http.Handler {
|
||||
t.Helper()
|
||||
auth, err := NewPlatformAuthorizer(AuthState{Required: true, Configured: true}, &fixedSessionResolver{session: identity.Session{AuthMode: identity.AuthModeAdmin, User: identity.User{ID: "account1", OrganizationID: "org1", Role: role}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return NewPaymentHandler(auth, service, "https://pay.example.test")
|
||||
}
|
||||
func paymentRequest(h http.Handler, method, path, body, origin string, authenticated bool) *httptest.ResponseRecorder {
|
||||
r := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
if origin != "" {
|
||||
r.Header.Set("Origin", origin)
|
||||
}
|
||||
if authenticated {
|
||||
r.AddCookie(&http.Cookie{Name: identity.SessionCookieName, Value: "valid"})
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
func TestPaymentCreateUsesSessionAndRejectsMoneyTampering(t *testing.T) {
|
||||
s := &paymentStub{}
|
||||
h := paymentTestHandler(t, "organization_admin", s)
|
||||
got := paymentRequest(h, "POST", "/api/payments/orders", `{"amountFen":12345,"requestKey":"retry-1"}`, "https://pay.example.test", true)
|
||||
if got.Code != 200 || s.org != "org1" || s.account != "account1" || s.amount != 12345 {
|
||||
t.Fatalf("status=%d scope=%s/%s amount=%d", got.Code, s.org, s.account, s.amount)
|
||||
}
|
||||
for _, body := range []string{
|
||||
`{"amountFen":1.01,"requestKey":"x"}`, `{"amountFen":1e4,"requestKey":"x"}`,
|
||||
`{"amountFen":100,"requestKey":"x","organizationId":"victim"}`,
|
||||
`{"amountFen":100,"requestKey":"x"} {}`, `{"amountFen":100,"requestKey":"a/b"}`,
|
||||
} {
|
||||
if got := paymentRequest(h, "POST", "/api/payments/orders", body, "", true); got.Code != 400 {
|
||||
t.Fatalf("body=%s status=%d", body, got.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestPaymentAuthorizationAndOrigin(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
role, origin string
|
||||
authenticated bool
|
||||
want int
|
||||
}{
|
||||
{"user", "", true, 403}, {"organization_admin", "https://evil.example", true, 403},
|
||||
{"organization_admin", "null", true, 403}, {"organization_admin", "", false, 401},
|
||||
} {
|
||||
s := &paymentStub{}
|
||||
h := paymentTestHandler(t, test.role, s)
|
||||
got := paymentRequest(h, "POST", "/api/payments/orders", `{"amountFen":100,"requestKey":"x"}`, test.origin, test.authenticated)
|
||||
if got.Code != test.want || s.account != "" {
|
||||
t.Fatalf("test=%+v status=%d", test, got.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestPaymentCallbackUsesVerifierWithoutBrowserSessionAndRetriesFailures(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
err error
|
||||
want int
|
||||
}{
|
||||
{nil, 204}, {payments.ErrVerification, 400}, {errors.New("secret error detail"), 500},
|
||||
} {
|
||||
s := &paymentStub{err: test.err}
|
||||
h := paymentTestHandler(t, "user", s)
|
||||
got := paymentRequest(h, "POST", "/api/payments/wechat/notify", `{}`, "", false)
|
||||
if got.Code != test.want || s.notifications != 1 || strings.Contains(got.Body.String(), "secret") {
|
||||
t.Fatalf("status=%d body=%s", got.Code, got.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestPaymentDisabledOptionsAndScopedReads(t *testing.T) {
|
||||
h := paymentTestHandler(t, "organization_admin", nil)
|
||||
got := paymentRequest(h, "GET", "/api/payments/options", "", "", true)
|
||||
if got.Code != 200 || !strings.Contains(got.Body.String(), `"enabled":false`) || got.Header().Get("Cache-Control") != "no-store" {
|
||||
t.Fatal(got.Body.String())
|
||||
}
|
||||
s := &paymentStub{}
|
||||
h = paymentTestHandler(t, "organization_admin", s)
|
||||
got = paymentRequest(h, "GET", "/api/payments/orders/order1?organizationId=victim", "", "", true)
|
||||
if got.Code != 200 || s.org != "org1" || strings.Contains(got.Body.String(), "weixin") {
|
||||
t.Fatalf("status=%d body=%s", got.Code, got.Body.String())
|
||||
}
|
||||
h = paymentTestHandler(t, "user", s)
|
||||
got = paymentRequest(h, "GET", "/api/payments/options", "", "", true)
|
||||
if !strings.Contains(got.Body.String(), `"canRecharge":false`) {
|
||||
t.Fatal(got.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,8 @@ type RouteSurface struct {
|
||||
}
|
||||
|
||||
var goRouteSurface = []RouteSurface{
|
||||
{"GET", "/api/payments/options"}, {"GET", "/api/payments/orders"}, {"POST", "/api/payments/orders"}, {"GET", "/api/payments/orders/{id}"}, {"POST", "/api/payments/wechat/notify"},
|
||||
{"GET", "/api/payments/admin/orders"}, {"GET", "/api/payments/admin/orders/{id}/refunds"}, {"POST", "/api/payments/admin/orders/{id}/refunds"}, {"GET", "/api/payments/admin/refunds/{id}"}, {"POST", "/api/payments/wechat/refund-notify"},
|
||||
{"DELETE", "/api/admin/accounts"}, {"GET", "/api/admin/accounts"}, {"PATCH", "/api/admin/accounts"}, {"POST", "/api/admin/accounts"}, {"PUT", "/api/admin/accounts"}, {"POST", "/api/admin/accounts/groups"}, {"POST", "/api/admin/accounts/password"}, {"GET", "/api/admin/billing"}, {"PATCH", "/api/admin/billing/account"}, {"POST", "/api/admin/billing/adjustments"}, {"GET", "/api/admin/billing/prices"}, {"PATCH", "/api/admin/billing/prices"}, {"PATCH", "/api/admin/billing/prices/{id}"}, {"DELETE", "/api/admin/organizations"}, {"GET", "/api/admin/organizations"}, {"PATCH", "/api/admin/organizations"}, {"POST", "/api/admin/organizations"}, {"GET", "/api/admin/usage"},
|
||||
{"GET", "/api/assets"}, {"POST", "/api/assets"}, {"POST", "/api/assets/upload"}, {"POST", "/api/assets/import-image"}, {"DELETE", "/api/assets/{id}"}, {"GET", "/api/assets/{id}/download"},
|
||||
{"GET", "/api/auth/callback"}, {"GET", "/api/auth/captcha"}, {"GET", "/api/auth/login"}, {"GET", "/api/auth/logout"}, {"POST", "/api/auth/logout"}, {"GET", "/api/auth/me"}, {"POST", "/api/auth/password"}, {"POST", "/api/auth/password/change"},
|
||||
|
||||
Reference in new issue
Block a user