更新grok
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<suffix>" — 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]
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -263,12 +276,13 @@ async function quickCredits(u, delta) {
|
||||
<button v-if="!items.length" @click="showAdd = true" class="btn-soft mt-1">新建第一个</button>
|
||||
</div>
|
||||
|
||||
<table v-else class="w-full text-sm table-fixed">
|
||||
<div v-else class="overflow-x-auto">
|
||||
<table class="w-full text-sm table-fixed min-w-[1360px]">
|
||||
<colgroup>
|
||||
<col class="w-9" /> <!-- select -->
|
||||
<col class="w-40" /> <!-- username -->
|
||||
<col class="w-36" /> <!-- username -->
|
||||
<col /> <!-- email (flex) -->
|
||||
<col class="w-36" /> <!-- notes -->
|
||||
<col class="w-24" /> <!-- notes -->
|
||||
<col class="w-28" /> <!-- concurrency -->
|
||||
<col class="w-20" /> <!-- role -->
|
||||
<col class="w-16" /> <!-- status switch -->
|
||||
@@ -279,7 +293,7 @@ async function quickCredits(u, delta) {
|
||||
<col class="w-28" /> <!-- registered -->
|
||||
<col class="w-28" /> <!-- last login -->
|
||||
<col class="w-32" /> <!-- login IP -->
|
||||
<col class="w-24" /> <!-- actions -->
|
||||
<col class="w-32" /> <!-- actions -->
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr class="text-[10px] uppercase tracking-[0.2em] text-white/40 border-b border-white/[0.06]">
|
||||
@@ -287,7 +301,7 @@ async function quickCredits(u, delta) {
|
||||
<input type="checkbox" :checked="allSelected" @change="toggleSelectAll"
|
||||
class="chk" title="全选" />
|
||||
</th>
|
||||
<th class="text-left px-5 py-3 font-medium">用户名</th>
|
||||
<th class="text-left px-3 py-3 font-medium">用户名</th>
|
||||
<th class="text-left px-3 py-3 font-medium">邮箱</th>
|
||||
<th class="text-left px-3 py-3 font-medium">备注</th>
|
||||
<th class="text-left px-3 py-3 font-medium">并发</th>
|
||||
@@ -310,7 +324,7 @@ async function quickCredits(u, delta) {
|
||||
<input type="checkbox" :checked="selected.has(u.id)" @change="toggleSelect(u.id)" @click.stop
|
||||
class="chk" />
|
||||
</td>
|
||||
<td class="px-5 py-3.5 align-middle text-sm font-medium text-white/90 truncate" :title="u.name || '—'">
|
||||
<td class="px-3 py-3.5 align-middle text-sm font-medium text-white/90 truncate" :title="u.name || '—'">
|
||||
{{ u.name || '—' }}
|
||||
</td>
|
||||
<td class="px-3 py-3.5 align-middle text-xs text-white/75 truncate" :title="u.email">
|
||||
@@ -376,6 +390,9 @@ async function quickCredits(u, delta) {
|
||||
</td>
|
||||
<td class="px-3 py-3.5 align-middle text-right whitespace-nowrap">
|
||||
<div class="inline-flex items-center gap-1">
|
||||
<button @click="openRecharge(u)" class="act" title="充值">
|
||||
<Icon name="spark" class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button @click="editing = JSON.parse(JSON.stringify(u))" class="act" title="编辑">
|
||||
<Icon name="config" class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
@@ -387,6 +404,7 @@ async function quickCredits(u, delta) {
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- pagination -->
|
||||
<div v-if="!loading && totalPages > 1"
|
||||
@@ -484,10 +502,6 @@ async function quickCredits(u, delta) {
|
||||
<input v-if="editing.role === 'admin'" value="管理员(唯一,不可更改)" disabled class="field" />
|
||||
<SelectMenu v-else v-model="editing.role" :options="ROLE_OPTIONS" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="lbl">积分</label>
|
||||
<input v-model.number="editing.credits" type="number" min="0" step="1" class="field" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="lbl">并发分组 <span class="text-white/35">(限制同时生成数)</span></label>
|
||||
<SelectMenu v-model="editing.concurrency_group_id" :options="cgroupOptions" placeholder="选择分组" />
|
||||
@@ -508,6 +522,38 @@ async function quickCredits(u, delta) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recharge modal -->
|
||||
<div v-if="recharging"
|
||||
class="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-start justify-center p-4 overflow-y-auto"
|
||||
@click.self="recharging = null">
|
||||
<div class="card !shadow-2xl my-12 w-full max-w-sm">
|
||||
<div class="px-5 py-4 border-b border-white/[0.06] flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold">充值积分</h2>
|
||||
<button @click="recharging = null" class="text-white/40 hover:text-white">
|
||||
<Icon name="close" class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-5 space-y-3">
|
||||
<div>
|
||||
<label class="lbl">用户</label>
|
||||
<input :value="recharging.email || recharging.name || recharging.id" disabled class="field font-mono" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="lbl">当前积分</label>
|
||||
<input :value="points(recharging.credits).toLocaleString('en-US')" disabled class="field tabular-nums" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="lbl">充值数量 <span class="text-white/35">(正数增加,负数扣除)</span></label>
|
||||
<input v-model.number="rechargeAmt" type="number" step="1" class="field" placeholder="例如 1000" autofocus />
|
||||
</div>
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<button @click="recharging = null" class="btn-soft">取消</button>
|
||||
<button @click="doRecharge" class="btn-primary">确认充值</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast -->
|
||||
<transition name="fade">
|
||||
<div v-if="toast"
|
||||
|
||||
Reference in New Issue
Block a user