feat(payments): Robokassa direct-rail payment intake #223
@@ -121,5 +121,6 @@ func (s *Server) handleRobokassaResult(c *gin.Context) {
|
||||
s.log.Error("record payment event failed", zap.String("order", orderID.String()), zap.Error(err))
|
||||
}
|
||||
}
|
||||
c.String(http.StatusOK, "OK"+v.Get("InvId"))
|
||||
// The gateway echoes response verbatim to Robokassa, which requires the body "OK<InvId>".
|
||||
c.JSON(http.StatusOK, gin.H{"response": "OK" + v.Get("InvId")})
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
# The game SPA and the Connect edge are served by the gateway. Strip any
|
||||
# client-supplied X-Scrabble-Honeypot here so the gateway only ever honours the
|
||||
# tag the honeypot block sets below (a client cannot self-tag a real request).
|
||||
@gateway path /app /app/* /telegram /telegram/* /vk /vk/* /dict/* /dl/* /metrics/* /telemetry/* /scrabble.edge.v1.Gateway/*
|
||||
@gateway path /app /app/* /telegram /telegram/* /vk /vk/* /dict/* /dl/* /pay/* /metrics/* /telemetry/* /scrabble.edge.v1.Gateway/*
|
||||
handle @gateway {
|
||||
reverse_proxy gateway:8081 {
|
||||
header_up -X-Scrabble-Honeypot
|
||||
|
||||
@@ -414,6 +414,20 @@ func (c *Client) WalletOrder(ctx context.Context, userID, productID string) (Wal
|
||||
return out, err
|
||||
}
|
||||
|
||||
// robokassaResultResp is the backend intake's reply: the body to echo back to Robokassa.
|
||||
type robokassaResultResp struct {
|
||||
Response string `json:"response"`
|
||||
}
|
||||
|
||||
// RobokassaResult forwards a Robokassa Result callback's parameters to the backend intake (the
|
||||
// single writer, which verifies the signature and credits) and returns the body to echo to
|
||||
// Robokassa ("OK<InvId>"). params carries the provider's raw form fields.
|
||||
func (c *Client) RobokassaResult(ctx context.Context, params map[string]string) (string, error) {
|
||||
var out robokassaResultResp
|
||||
err := c.do(ctx, http.MethodPost, "/api/v1/internal/payments/robokassa/result", "", "", params, &out)
|
||||
return out.Response, err
|
||||
}
|
||||
|
||||
// CatalogAtomResp is one atom line of a storefront product: the value type it grants and quantity.
|
||||
type CatalogAtomResp struct {
|
||||
AtomType string `json:"atom_type"`
|
||||
|
||||
@@ -196,6 +196,11 @@ func (s *Server) HTTPHandler() http.Handler {
|
||||
// through this session-gated route (not public); see dictBytesHandler.
|
||||
mux.Handle("/dict/", s.dictBytesHandler())
|
||||
mux.Handle("/dl/", s.exportDownloadHandler())
|
||||
// Direct-rail (Robokassa) return + callback routes: the server Result callback (the single
|
||||
// crediting signal, proxied to the backend intake) and the browser Success/Fail redirects.
|
||||
mux.Handle("/pay/robokassa/result", s.robokassaResultHandler())
|
||||
mux.Handle("/pay/robokassa/success", s.robokassaRedirectHandler())
|
||||
mux.Handle("/pay/robokassa/fail", s.robokassaRedirectHandler())
|
||||
// The client posts its local-move-preview adoption telemetry here (session-gated).
|
||||
mux.Handle("/metrics/local-eval", s.localEvalMetricsHandler())
|
||||
// The index.html boot guard beacons here when it turns a client away on the unsupported-engine
|
||||
@@ -482,6 +487,50 @@ func (s *Server) exportDownloadHandler() http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
// robokassaResultHandler proxies the Robokassa Result callback to the backend intake (the single
|
||||
// writer). It rate-limits per IP, forwards the provider's form parameters, and echoes the backend's
|
||||
// "OK<InvId>" to Robokassa on success; any error tells Robokassa the notification was not accepted,
|
||||
// so it retries. The gateway does not verify the signature — the backend holds the secret.
|
||||
func (s *Server) robokassaResultHandler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if s.backend == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
ip := peerIP(r.RemoteAddr, r.Header)
|
||||
if !s.limiter.Allow("public:"+ip, s.publicPolicy) {
|
||||
s.noteRateLimited(r.Context(), classPublic, ip, "robokassa-result")
|
||||
http.Error(w, "rate limited", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
params := make(map[string]string, len(r.Form))
|
||||
for k := range r.Form {
|
||||
params[k] = r.Form.Get(k)
|
||||
}
|
||||
resp, err := s.backend.RobokassaResult(r.Context(), params)
|
||||
if err != nil {
|
||||
s.log.Warn("robokassa result proxy failed", zap.Error(err))
|
||||
http.Error(w, "not accepted", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
_, _ = w.Write([]byte(resp))
|
||||
})
|
||||
}
|
||||
|
||||
// robokassaRedirectHandler redirects the customer's browser back into the app after Robokassa's
|
||||
// Success/Fail return. The credit rides the server Result callback, never these redirects, so this
|
||||
// only navigates home; the wallet reflects the credit once the callback lands.
|
||||
func (s *Server) robokassaRedirectHandler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/app/", http.StatusSeeOther)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) dictBytesHandler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if s.backend == nil {
|
||||
|
||||
Reference in New Issue
Block a user