From 86658e98a16cbd92a2e09ebdac79963dfa1ef0cf Mon Sep 17 00:00:00 2001 From: chiyi Date: Mon, 13 Jul 2026 21:08:30 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0grok?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/go.mod | 7 + backend/go.sum | 14 + backend/internal/bootstrap/app.go | 33 ++ .../internal/provider/grok/statsig_refresh.go | 300 ++++++++++++++++++ backend/internal/provider/grok/video.go | 4 +- frontend/src/views/UsersView.vue | 68 +++- 6 files changed, 414 insertions(+), 12 deletions(-) create mode 100644 backend/internal/provider/grok/statsig_refresh.go diff --git a/backend/go.mod b/backend/go.mod index ab21d0d..bb681dd 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -28,16 +28,23 @@ require ( github.com/bytedance/sonic v1.14.0 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc // indirect + github.com/chromedp/chromedp v0.15.1 // indirect + github.com/chromedp/sysutil v1.1.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dlclark/regexp2/v2 v2.2.1 // indirect github.com/gabriel-vasile/mimetype v1.4.9 // indirect github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.27.0 // indirect github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect github.com/go-sql-driver/mysql v1.8.1 // indirect + github.com/gobwas/httphead v0.1.0 // indirect + github.com/gobwas/pool v0.2.1 // indirect + github.com/gobwas/ws v1.4.0 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.19.2 // indirect github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect diff --git a/backend/go.sum b/backend/go.sum index dbccde0..c640bba 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -22,6 +22,12 @@ github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZw github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc h1:wkN/LMi5vc60pBRWx6qpbk/aEvq3/ZVNpnMvsw8PVVU= +github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc/go.mod h1:cbyjALe67vDvlvdiG9369P8w5U2w6IshwtyD2f2Tvag= +github.com/chromedp/chromedp v0.15.1 h1:EJWiPm7BNqDqjYy6U0lTSL5wNH+iNt9GjC3a4gfjNyQ= +github.com/chromedp/chromedp v0.15.1/go.mod h1:CdTHtUqD/dqaFw/cvFWtTydoEQS44wLBuwbMR9EkOY4= +github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM= +github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -41,6 +47,8 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= +github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 h1:vymEbVwYFP/L05h5TKQxvkXoKxNvTpjxYKdF1Nlwuao= +github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -54,6 +62,12 @@ github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5Nq github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= +github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= +github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= +github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= +github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs= +github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= diff --git a/backend/internal/bootstrap/app.go b/backend/internal/bootstrap/app.go index b11190d..5c6256b 100644 --- a/backend/internal/bootstrap/app.go +++ b/backend/internal/bootstrap/app.go @@ -5,6 +5,7 @@ import ( "fmt" "log" "os" + "strconv" "time" "backend/internal/config" @@ -122,6 +123,11 @@ func NewApp(ctx context.Context) (*App, error) { kreaClient := krea.NewClient("") imagineClient := imagine.NewClient("") grokClient := grok.NewClient("") + // Keep grok's x-statsig-id anti-bot recipe current by reading grok's own + // headless-browser signer output. Event-driven: seed from the persisted + // recipe, capture once at startup, then re-capture only on an anti-bot 403 + // (a reship made the recipe stale). No polling. + startGrokStatsigRefresh(siteRepo) customClient := custom.NewClient() v1Svc := service.NewV1Service(cfg, modelRepo, userRepo, eventRepo, tokenRepo, siteRepo, cgroupRepo, concSvc, adobeClient, chatGPTClient, runwayClient, leonardoClient, kreaClient, imagineClient, grokClient, customClient, rustfsClient) siteSvc := service.NewSiteService(siteRepo, cfg.AppTitle) @@ -175,6 +181,33 @@ func NewApp(ctx context.Context) (*App, error) { }, nil } +// startGrokStatsigRefresh wires grok's headless x-statsig-id refresher to the +// site-settings store (persisted across restarts) with an app-lifetime context. +func startGrokStatsigRefresh(siteRepo *repo.SiteSettingRepository) { + const kHeader, kSuffix, kTrailer = "grok.statsig.header", "grok.statsig.suffix", "grok.statsig.trailer" + grok.StartStatsigAutoRefresh(context.Background(), 0, + func(ctx context.Context) (string, string, int, bool) { + h, _ := siteRepo.GetValue(ctx, kHeader) + s, _ := siteRepo.GetValue(ctx, kSuffix) + tv, _ := siteRepo.GetValue(ctx, kTrailer) + if h == "" || s == "" { + return "", "", 0, false + } + t, _ := strconv.Atoi(tv) + return h, s, t, true + }, + func(ctx context.Context, h, s string, t int) { + if err := siteRepo.UpsertValues(ctx, map[string]string{ + kHeader: h, + kSuffix: s, + kTrailer: strconv.Itoa(t), + }); err != nil { + log.Printf("grok statsig: persist recipe failed: %v", err) + } + }, + ) +} + func (a *App) Close() error { if a.maintenanceCancel != nil { a.maintenanceCancel() diff --git a/backend/internal/provider/grok/statsig_refresh.go b/backend/internal/provider/grok/statsig_refresh.go new file mode 100644 index 0000000..d63c6c9 --- /dev/null +++ b/backend/internal/provider/grok/statsig_refresh.go @@ -0,0 +1,300 @@ +package grok + +import ( + "context" + "encoding/base64" + "encoding/hex" + "errors" + "log" + "os" + "os/exec" + "strings" + "sync" + "time" + + "github.com/chromedp/cdproto/page" + "github.com/chromedp/chromedp" +) + +// The x-statsig-id recipe (49-byte header + salt suffix) encodes grok's per-load +// browser fingerprint "F". grok recomputes F server-side from the homepage +// seed+curves and the byte-indexing that derives it ROTATES on every web reship, +// so any hand-ported or baked-in recipe goes stale (403 "Request rejected by +// anti-bot rules.") within days. The durable fix is to let grok's OWN signer +// produce the recipe: we drive a headless Chrome to grok.com, read the exact +// string grok feeds to crypto.subtle.digest (reveals the current suffix) and one +// of the x-statsig-id headers its client attaches (reveals the current 49-byte +// header + trailer), then feed those into statsigID's existing per-request +// computation. Because we read grok's live output we never reverse-engineer the +// (rotating) obfuscation; a reship just needs a re-capture, which the auto-refresh +// loop does on a timer and whenever a live request hits an anti-bot 403. +// +// The captured recipe is persisted (site settings) so a restart keeps the last +// good values, and every failure is non-fatal — statsigID keeps using whatever +// recipe is currently live (persisted → last capture → static defaults). + +// hookJS is installed before grok's own scripts run. It records (a) every +// crypto.subtle.digest input that contains the statsig salt marker — the plaintext +// "METHOD!/path!counter" — and (b) every 70-byte x-statsig-id header grok +// attaches to its API calls. Both are only produced once grok's signer succeeds +// (after its keyframe animation settles), so a short wait yields valid values. +const statsigHookJS = ` +(() => { + window.__statsigDigests = []; window.__statsigCaps = []; + try { + const td = new TextDecoder('utf-8', {fatal:false}); + const orig = crypto.subtle.digest.bind(crypto.subtle); + crypto.subtle.digest = function(alg, data) { + try { + let buf = data; if (data && data.buffer) buf = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength); + const txt = td.decode(new Uint8Array(buf.slice ? buf.slice(0) : buf)); + if (txt.indexOf('` + statsigSaltPrefix + `') >= 0) window.__statsigDigests.push(txt); + } catch (e) {} + return orig(alg, data); + }; + const of = window.fetch; + window.fetch = function(input, init) { + try { + let hdrs = (init && init.headers); let sid = null; + if (hdrs) { if (typeof hdrs.get === 'function') sid = hdrs.get('x-statsig-id'); else for (const k in hdrs) if (k.toLowerCase() === 'x-statsig-id') sid = hdrs[k]; } + if (sid && sid.length === 94) window.__statsigCaps.push(sid); + } catch (e) {} + return of.apply(this, arguments); + }; + } catch (e) {} +})(); +` + +var ( + statsigRefreshTrigger = make(chan struct{}, 1) + statsigRefreshStarted sync.Once + statsigLastCapture time.Time + statsigLastCaptureMu sync.Mutex +) + +// SetStatsigRecipe atomically replaces the live (header, suffix, trailer) used by +// statsigID and drops the per-session challenge cache so the next request adopts +// the new values. headerHex must decode to 49 bytes; suffix must be non-empty. +func SetStatsigRecipe(headerHex, suffix string, trailer int) error { + h, err := hex.DecodeString(strings.TrimSpace(headerHex)) + if err != nil { + return err + } + if len(h) != 49 { + return errors.New("statsig: header must be 49 bytes") + } + if strings.TrimSpace(suffix) == "" { + return errors.New("statsig: empty suffix") + } + if trailer < 0 || trailer > 255 { + return errors.New("statsig: trailer out of range") + } + statsigMu.Lock() + statsigHeader = h + statsigSuffix = suffix + statsigTrailer = byte(trailer) + statsigCache = map[string]statsigChallenge{} + statsigMu.Unlock() + return nil +} + +// chromeExecPath resolves the Chrome/Chromium binary for headless capture, from +// GROK_STATSIG_CHROME or the usual names on PATH. Empty means none available. +func chromeExecPath() string { + if p := strings.TrimSpace(os.Getenv("GROK_STATSIG_CHROME")); p != "" { + return p + } + for _, name := range []string{"google-chrome-stable", "google-chrome", "chromium", "chromium-browser"} { + if p, err := exec.LookPath(name); err == nil { + return p + } + } + return "" +} + +type statsigCapResult struct { + Digs []string `json:"digs"` + Caps []string `json:"caps"` +} + +// CaptureStatsigRecipe drives a headless Chrome to grok.com and reads grok's own +// signer output, returning the current (headerHex, suffix, trailer). It never +// mutates global state. Returns an error if Chrome is unavailable or grok's +// signer output could not be observed (e.g. blocked before the signer ran). +func CaptureStatsigRecipe(ctx context.Context) (headerHex, suffix string, trailer int, err error) { + chromePath := chromeExecPath() + if chromePath == "" { + return "", "", 0, errors.New("statsig: no chrome binary (set GROK_STATSIG_CHROME)") + } + opts := append(chromedp.DefaultExecAllocatorOptions[:], + chromedp.ExecPath(chromePath), + chromedp.Flag("headless", "new"), + chromedp.Flag("no-sandbox", true), + chromedp.Flag("disable-gpu", true), + chromedp.Flag("disable-dev-shm-usage", true), + chromedp.UserAgent(userAgent), + ) + if proxy := strings.TrimSpace(os.Getenv("GROK_STATSIG_PROXY")); proxy != "" { + opts = append(opts, chromedp.ProxyServer(proxy)) + } + allocCtx, cancel := chromedp.NewExecAllocator(ctx, opts...) + defer cancel() + bctx, cancel2 := chromedp.NewContext(allocCtx) + defer cancel2() + + var res statsigCapResult + runErr := chromedp.Run(bctx, + chromedp.ActionFunc(func(ctx context.Context) error { + _, err := page.AddScriptToEvaluateOnNewDocument(statsigHookJS).Do(ctx) + return err + }), + chromedp.Navigate(apiBase+"/"), + chromedp.Sleep(14*time.Second), + chromedp.Evaluate(`({digs: window.__statsigDigests || [], caps: window.__statsigCaps || []})`, &res), + ) + if runErr != nil { + return "", "", 0, runErr + } + return parseStatsigCapture(res) +} + +// parseStatsigCapture extracts (headerHex, suffix, trailer) from the observed +// digest plaintexts and x-statsig-id headers of one page load. Both must be +// present and are self-consistent (same per-load fingerprint F). +func parseStatsigCapture(res statsigCapResult) (headerHex, suffix string, trailer int, err error) { + for _, d := range res.Digs { + if i := strings.Index(d, statsigSaltPrefix); i >= 0 { + suffix = d[i:] + break + } + } + if suffix == "" { + return "", "", 0, errors.New("statsig: no signer digest observed (blocked before signer ran?)") + } + var header []byte + for _, sid := range res.Caps { + raw, derr := base64.RawStdEncoding.DecodeString(sid) + if derr != nil || len(raw) != 70 { + continue + } + key := raw[0] // header[0] is 0x00, so the XOR mask key == byte 0 + r := make([]byte, 70) + for i := range raw { + r[i] = raw[i] ^ key + } + if r[0] != 0x00 { + continue + } + header = r[0:49] + trailer = int(r[69]) + break + } + if header == nil { + return "", "", 0, errors.New("statsig: no valid x-statsig-id header observed") + } + return hex.EncodeToString(header), suffix, trailer, nil +} + +// TriggerStatsigRefresh asks the auto-refresh loop to re-capture soon (coalesced). +// Called when a live request hits an anti-bot 403 — the recipe likely went stale. +func TriggerStatsigRefresh() { + select { + case statsigRefreshTrigger <- struct{}{}: + default: + } +} + +// StartStatsigAutoRefresh launches the headless capture loop once. It is +// event-driven, NOT a polling loop: it seeds the live recipe from persisted +// values (load), captures once at startup so the process starts fresh, and then +// re-captures ONLY when a live request hits an anti-bot 403 (TriggerStatsigRefresh) +// — i.e. exactly when the recipe actually went stale on a grok reship. The recipe +// has no fixed clock expiry, so there is nothing useful to poll on. An optional +// safetyInterval>0 adds a slow backstop re-capture; pass 0 to disable it (default). +// Captured recipes are handed to `save` for persistence. No-op when a manual +// GROK_STATSIG_* override is set or no Chrome binary is available. +func StartStatsigAutoRefresh(ctx context.Context, safetyInterval time.Duration, + load func(context.Context) (headerHex, suffix string, trailer int, ok bool), + save func(ctx context.Context, headerHex, suffix string, trailer int)) { + + statsigRefreshStarted.Do(func() { + if os.Getenv("GROK_STATSIG_HEADER_HEX") != "" || os.Getenv("GROK_STATSIG_SUFFIX") != "" { + log.Printf("grok statsig: manual GROK_STATSIG_* override set — headless auto-refresh disabled") + return + } + if chromeExecPath() == "" { + log.Printf("grok statsig: no chrome binary — headless auto-refresh disabled, using static recipe (set GROK_STATSIG_CHROME to enable)") + return + } + if load != nil { + if h, s, t, ok := load(ctx); ok { + if err := SetStatsigRecipe(h, s, t); err != nil { + log.Printf("grok statsig: persisted recipe invalid, ignoring: %v", err) + } else { + log.Printf("grok statsig: loaded persisted recipe header[:6]=%s", safePrefix(h, 12)) + } + } + } + go statsigRefreshLoop(ctx, safetyInterval, save) + }) +} + +func statsigRefreshLoop(ctx context.Context, safetyInterval time.Duration, save func(context.Context, string, string, int)) { + refresh := func(reason string) { + statsigLastCaptureMu.Lock() + if time.Since(statsigLastCapture) < 30*time.Second { + statsigLastCaptureMu.Unlock() + return // debounce bursts (e.g. a 403 storm across concurrent requests) + } + statsigLastCapture = time.Now() + statsigLastCaptureMu.Unlock() + + cctx, cancel := context.WithTimeout(ctx, 90*time.Second) + defer cancel() + h, s, t, err := CaptureStatsigRecipe(cctx) + if err != nil { + log.Printf("grok statsig: headless capture (%s) failed, keeping current recipe: %v", reason, err) + return + } + if err := SetStatsigRecipe(h, s, t); err != nil { + log.Printf("grok statsig: captured recipe invalid: %v", err) + return + } + if save != nil { + save(ctx, h, s, t) + } + log.Printf("grok statsig: headless recipe refreshed (%s) header[:6]=%s suffix[:24]=%s", reason, safePrefix(h, 12), safePrefix(s, 24)) + } + + initial := time.NewTimer(10 * time.Second) + defer initial.Stop() + + // Optional slow backstop only; disabled (nil channel blocks forever) when + // safetyInterval<=0 so the loop stays purely event-driven. + var tickC <-chan time.Time + if safetyInterval > 0 { + ticker := time.NewTicker(safetyInterval) + defer ticker.Stop() + tickC = ticker.C + } + + for { + select { + case <-ctx.Done(): + return + case <-initial.C: + refresh("startup") + case <-tickC: + refresh("safety-interval") + case <-statsigRefreshTrigger: + refresh("anti-bot 403") + } + } +} + +func safePrefix(s string, n int) string { + if len(s) < n { + return s + } + return s[:n] +} diff --git a/backend/internal/provider/grok/video.go b/backend/internal/provider/grok/video.go index ad4b136..697b587 100644 --- a/backend/internal/provider/grok/video.go +++ b/backend/internal/provider/grok/video.go @@ -448,7 +448,9 @@ func mapStatus(path string, status int, raw []byte) error { case status == 403 && isBotChallenge(string(raw)): // grok bot-detection or a Cloudflare challenge page ("Just a moment…"), // NOT a dead token — transient, so a good account isn't killed by an - // IP/anti-bot hiccup. + // IP/anti-bot hiccup. A statsig anti-bot rejection means the recipe likely + // went stale on a reship; kick the headless refresher to re-capture. + TriggerStatsigRefresh() return fmt.Errorf("%w: %s 403 %s", ErrTemporaryUpstream, path, clip(raw, 160)) case status == 401 || status == 403: return fmt.Errorf("%w: %s %d %s", ErrAuth, path, status, clip(raw, 160)) diff --git a/frontend/src/views/UsersView.vue b/frontend/src/views/UsersView.vue index 347ddc5..3b6634e 100644 --- a/frontend/src/views/UsersView.vue +++ b/frontend/src/views/UsersView.vue @@ -18,6 +18,8 @@ const pageSize = ref(20) const showAdd = ref(false) const editing = ref(null) +const recharging = ref(null) // the user whose credits are being topped up +const rechargeAmt = ref(0) const toast = ref('') const addForm = ref({ email: '', name: '', password: '', role: 'user', credits: 0, notes: '', concurrency_group_id: '' }) @@ -132,9 +134,11 @@ async function saveEdit() { // Email + 用户名 are intentionally NOT in the patch — they're displayed // read-only in the form, and the admin shouldn't be in the habit of // rewriting a user's identity from this page. + // 积分 intentionally NOT patched here — editing must never touch the live + // balance (a stale snapshot would clobber credits the user spent meanwhile). + // Credits are adjusted only through the atomic 充值 button (/credits). const patch = { status: u.status, - credits: u.credits, role: u.role, notes: u.notes || '', concurrency_group_id: u.concurrency_group_id || '', @@ -192,6 +196,15 @@ async function quickCredits(u, delta) { if (r.ok) { flash(`已${delta > 0 ? '增加' : '扣除'} ${Math.abs(delta).toLocaleString('en-US')} 积分`); load() } else flash(r.data?.detail || '调整失败') } + +function openRecharge(u) { recharging.value = u; rechargeAmt.value = 0 } +async function doRecharge() { + const delta = Math.round(Number(rechargeAmt.value) || 0) + if (!delta) { flash('请输入积分数量'); return } + // Atomic increment on the server — never overwrites the live balance. + await quickCredits(recharging.value, delta) + recharging.value = null; rechargeAmt.value = 0 +}