Release v1.14.0 — monetization launch (E5-E8) #237

Merged
developer merged 54 commits from development into master 2026-07-10 10:11:02 +00:00
4 changed files with 66 additions and 2 deletions
Showing only changes of commit 4f6c22d669 - Show all commits
+2 -1
View File
@@ -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")})
}
+1 -1
View File
@@ -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
+14
View File
@@ -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"`
+49
View File
@@ -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 {