diff --git a/backend/internal/provider/adobe/client.go b/backend/internal/provider/adobe/client.go index ceaf531..769088f 100644 --- a/backend/internal/provider/adobe/client.go +++ b/backend/internal/provider/adobe/client.go @@ -9,6 +9,7 @@ import ( "errors" "fmt" "io" + "math/rand" "net/url" "strings" "time" @@ -60,11 +61,11 @@ func (c *Client) SetProxy(proxy string) { } func (c *Client) ExchangeCookie(ctx context.Context, cookie string) (*CookieExchangeResult, error) { - client, err := c.newTLSClient() + sess, err := c.newTLSClient() if err != nil { return nil, err } - return exchangeCookieWithTLSClient(ctx, client, cookie) + return exchangeCookieWithTLSClient(ctx, sess, cookie) } // uploadMaxRetries is how many extra in-place attempts a transient upload @@ -106,7 +107,7 @@ func (c *Client) UploadImage(ctx context.Context, token string, content []byte, // body plus whether a failure is retryable (transport error / 429/451/5xx). // Auth failures (401/403) and other non-200s are not retryable. func (c *Client) uploadImageOnce(ctx context.Context, token string, content []byte, contentType, engine string) ([]byte, error, bool) { - client, err := c.newTLSClient() + sess, err := c.newTLSClient() if err != nil { return nil, err, false } @@ -125,7 +126,7 @@ func (c *Client) uploadImageOnce(ctx context.Context, token string, content []by "x-api-key": {c.apiKey}, "content-type": {defaultString(contentType, "image/png")}, "accept": {"*/*"}, - "user-agent": {defaultUserAgent}, + "user-agent": {sess.fp.userAgent}, http.HeaderOrderKey: { "authorization", "x-api-key", @@ -135,7 +136,7 @@ func (c *Client) uploadImageOnce(ctx context.Context, token string, content []by }, } - resp, err := client.Do(req) + resp, err := sess.client.Do(req) if err != nil { // Transport error (incl. Client.Timeout on the storage endpoint) — a // network blip, retryable on a fresh connection. @@ -160,7 +161,7 @@ func (c *Client) uploadImageOnce(ctx context.Context, token string, content []by } func (c *Client) GenerateImage(ctx context.Context, token, modelID, prompt, aspectRatio, resolution string, blobIDs []string) ([]byte, map[string]any, error) { - client, err := c.newTLSClient() + sess, err := c.newTLSClient() if err != nil { return nil, nil, err } @@ -178,9 +179,9 @@ func (c *Client) GenerateImage(ctx context.Context, token, modelID, prompt, aspe candidates = BuildImagePayloadCandidates(modelID, prompt, aspectRatio, resolution, blobIDs) } for _, payload := range candidates { - respBody, pollURL, err := c.submitImage(ctx, client, token, prompt, endpoint, payload) + respBody, pollURL, err := c.submitImage(ctx, sess, token, prompt, endpoint, payload) if err == nil { - meta, data, pollErr := c.pollImage(ctx, client, token, pollURL) + meta, data, pollErr := c.pollImage(ctx, sess, token, pollURL) if pollErr != nil { return nil, nil, pollErr } @@ -208,7 +209,7 @@ func (c *Client) GenerateImage(ctx context.Context, token, modelID, prompt, aspe // in meta["video_url"] — used by the async /v1/videos job, which proxies that URL // on /content instead of persisting the file. func (c *Client) GenerateVideo(ctx context.Context, token, engine, prompt, aspectRatio string, durationSeconds int, resolution, referenceMode, upstreamModel string, blobIDs []string, downloadResult bool) ([]byte, map[string]any, error) { - client, err := c.newTLSClient() + sess, err := c.newTLSClient() if err != nil { return nil, nil, err } @@ -218,12 +219,12 @@ func (c *Client) GenerateVideo(ctx context.Context, token, engine, prompt, aspec if engine == "firefly-video" { endpoint = fireflyVideoSubmitURL } - respBody, pollURL, err := c.submitVideo(ctx, client, token, endpoint, payload) + respBody, pollURL, err := c.submitVideo(ctx, sess, token, endpoint, payload) if err != nil { return nil, nil, err } _ = respBody - meta, data, pollErr := c.pollVideo(ctx, client, token, pollURL, downloadResult) + meta, data, pollErr := c.pollVideo(ctx, sess, token, pollURL, downloadResult) if pollErr != nil { return nil, nil, pollErr } @@ -235,7 +236,7 @@ func (c *Client) FetchAccountProfile(ctx context.Context, token string) (map[str if token == "" { return map[string]any{}, nil } - client, err := c.newTLSClient() + sess, err := c.newTLSClient() if err != nil { return nil, err } @@ -249,7 +250,7 @@ func (c *Client) FetchAccountProfile(ctx context.Context, token string) (map[str req.Header = http.Header{ "authorization": {"Bearer " + token}, "accept": {"application/json"}, - "user-agent": {defaultUserAgent}, + "user-agent": {sess.fp.userAgent}, http.HeaderOrderKey: { "authorization", "accept", @@ -257,7 +258,7 @@ func (c *Client) FetchAccountProfile(ctx context.Context, token string) (map[str }, } - resp, err := client.Do(req) + resp, err := sess.client.Do(req) if err != nil { continue } @@ -320,7 +321,7 @@ func (c *Client) FetchCreditsBalance(ctx context.Context, token string) (map[str }, nil } - client, err := c.newTLSClient() + sess, err := c.newTLSClient() if err != nil { return nil, err } @@ -335,7 +336,7 @@ func (c *Client) FetchCreditsBalance(ctx context.Context, token string) (map[str "x-account-id": {accountID}, "accept": {"application/json"}, "content-type": {"application/json"}, - "user-agent": {defaultUserAgent}, + "user-agent": {sess.fp.userAgent}, http.HeaderOrderKey: { "authorization", "x-api-key", @@ -346,7 +347,7 @@ func (c *Client) FetchCreditsBalance(ctx context.Context, token string) (map[str }, } - resp, err := client.Do(req) + resp, err := sess.client.Do(req) if err != nil { return map[string]any{ "remaining": nil, @@ -401,7 +402,7 @@ func (c *Client) FetchCreditsBalance(ctx context.Context, token string) (map[str }, nil } -func (c *Client) submitImage(ctx context.Context, client tlsclient.HttpClient, token, prompt, endpoint string, payload map[string]any) ([]byte, string, error) { +func (c *Client) submitImage(ctx context.Context, sess *tlsSession, token, prompt, endpoint string, payload map[string]any) ([]byte, string, error) { body, err := json.Marshal(payload) if err != nil { return nil, "", err @@ -419,13 +420,13 @@ func (c *Client) submitImage(ctx context.Context, client tlsclient.HttpClient, t "origin": {"https://firefly.adobe.com"}, "referer": {"https://firefly.adobe.com/"}, "accept-language": {"en-US,en;q=0.9"}, - "sec-ch-ua": {defaultSecCHUA}, + "sec-ch-ua": {sess.fp.secCHUA}, "sec-ch-ua-mobile": {"?0"}, - "sec-ch-ua-platform": {`"Windows"`}, + "sec-ch-ua-platform": {sess.fp.platform}, "sec-fetch-site": {"same-site"}, "sec-fetch-mode": {"cors"}, "sec-fetch-dest": {"empty"}, - "user-agent": {defaultUserAgent}, + "user-agent": {sess.fp.userAgent}, "x-arp-session-id": {buildARPSessionID()}, http.HeaderOrderKey: { "authorization", @@ -450,7 +451,7 @@ func (c *Client) submitImage(ctx context.Context, client tlsclient.HttpClient, t req.Header.Set("x-nonce", nonce) } - resp, err := client.Do(req) + resp, err := sess.client.Do(req) if err != nil { return nil, "", fmt.Errorf("%w: %v", ErrTemporaryUpstream, err) } @@ -497,7 +498,7 @@ func (c *Client) submitImage(ctx context.Context, client tlsclient.HttpClient, t return respBody, "", errors.New("submit ok but no poll url") } -func (c *Client) pollImage(ctx context.Context, client tlsclient.HttpClient, token, pollURL string) (map[string]any, []byte, error) { +func (c *Client) pollImage(ctx context.Context, sess *tlsSession, token, pollURL string) (map[string]any, []byte, error) { start := time.Now() for { if time.Since(start) > 3*time.Minute { @@ -514,7 +515,7 @@ func (c *Client) pollImage(ctx context.Context, client tlsclient.HttpClient, tok "accept": {"*/*"}, "origin": {"https://firefly.adobe.com"}, "referer": {"https://firefly.adobe.com/"}, - "user-agent": {defaultUserAgent}, + "user-agent": {sess.fp.userAgent}, http.HeaderOrderKey: { "authorization", "accept", @@ -524,7 +525,7 @@ func (c *Client) pollImage(ctx context.Context, client tlsclient.HttpClient, tok }, } - resp, err := client.Do(req) + resp, err := sess.client.Do(req) if err != nil { return nil, nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err) } @@ -551,7 +552,7 @@ func (c *Client) pollImage(ctx context.Context, client tlsclient.HttpClient, tok if first, ok := outputs[0].(map[string]any); ok { if image, ok := first["image"].(map[string]any); ok { if url := strings.TrimSpace(stringValue(image["presignedUrl"])); url != "" { - data, err := c.download(ctx, client, url) + data, err := c.download(ctx, sess, url) if err != nil { return nil, nil, err } @@ -569,7 +570,7 @@ func (c *Client) pollImage(ctx context.Context, client tlsclient.HttpClient, tok } } -func (c *Client) submitVideo(ctx context.Context, client tlsclient.HttpClient, token, endpoint string, payload map[string]any) ([]byte, string, error) { +func (c *Client) submitVideo(ctx context.Context, sess *tlsSession, token, endpoint string, payload map[string]any) ([]byte, string, error) { body, err := json.Marshal(payload) if err != nil { return nil, "", err @@ -587,13 +588,13 @@ func (c *Client) submitVideo(ctx context.Context, client tlsclient.HttpClient, t "origin": {"https://firefly.adobe.com"}, "referer": {"https://firefly.adobe.com/"}, "accept-language": {"en-US,en;q=0.9"}, - "sec-ch-ua": {defaultSecCHUA}, + "sec-ch-ua": {sess.fp.secCHUA}, "sec-ch-ua-mobile": {"?0"}, - "sec-ch-ua-platform": {`"Windows"`}, + "sec-ch-ua-platform": {sess.fp.platform}, "sec-fetch-site": {"same-site"}, "sec-fetch-mode": {"cors"}, "sec-fetch-dest": {"empty"}, - "user-agent": {defaultUserAgent}, + "user-agent": {sess.fp.userAgent}, "x-arp-session-id": {buildARPSessionID()}, http.HeaderOrderKey: { "authorization", @@ -621,7 +622,7 @@ func (c *Client) submitVideo(ctx context.Context, client tlsclient.HttpClient, t } } - resp, err := client.Do(req) + resp, err := sess.client.Do(req) if err != nil { return nil, "", fmt.Errorf("%w: %v", ErrTemporaryUpstream, err) } @@ -671,7 +672,7 @@ func (c *Client) submitVideo(ctx context.Context, client tlsclient.HttpClient, t return respBody, "", errors.New("video submit ok but no poll url") } -func (c *Client) pollVideo(ctx context.Context, client tlsclient.HttpClient, token, pollURL string, downloadResult bool) (map[string]any, []byte, error) { +func (c *Client) pollVideo(ctx context.Context, sess *tlsSession, token, pollURL string, downloadResult bool) (map[string]any, []byte, error) { start := time.Now() for { if time.Since(start) > 10*time.Minute { @@ -688,7 +689,7 @@ func (c *Client) pollVideo(ctx context.Context, client tlsclient.HttpClient, tok "accept": {"*/*"}, "origin": {"https://firefly.adobe.com"}, "referer": {"https://firefly.adobe.com/"}, - "user-agent": {defaultUserAgent}, + "user-agent": {sess.fp.userAgent}, http.HeaderOrderKey: { "authorization", "accept", @@ -698,7 +699,7 @@ func (c *Client) pollVideo(ctx context.Context, client tlsclient.HttpClient, tok }, } - resp, err := client.Do(req) + resp, err := sess.client.Do(req) if err != nil { return nil, nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err) } @@ -732,7 +733,7 @@ func (c *Client) pollVideo(ctx context.Context, client tlsclient.HttpClient, tok if !downloadResult { return payload, nil, nil } - data, err := c.download(ctx, client, raw) + data, err := c.download(ctx, sess, raw) if err != nil { return nil, nil, err } @@ -754,7 +755,7 @@ func (c *Client) pollVideo(ctx context.Context, client tlsclient.HttpClient, tok // asset is already produced at this point, so a transient network hiccup // (connection EOF/reset mid-body, S3 accelerate 5xx) must not fail the whole // generation — retry with backoff before giving up. -func (c *Client) download(ctx context.Context, client tlsclient.HttpClient, url string) ([]byte, error) { +func (c *Client) download(ctx context.Context, sess *tlsSession, url string) ([]byte, error) { var data []byte var err error for _, wait := range []time.Duration{0, 1 * time.Second, 2 * time.Second, 5 * time.Second, 10 * time.Second} { @@ -765,7 +766,7 @@ func (c *Client) download(ctx context.Context, client tlsclient.HttpClient, url case <-time.After(wait): } } - data, err = c.downloadOnce(ctx, client, url) + data, err = c.downloadOnce(ctx, sess, url) if err == nil || !errors.Is(err, ErrTemporaryUpstream) { break } @@ -773,7 +774,7 @@ func (c *Client) download(ctx context.Context, client tlsclient.HttpClient, url return data, err } -func (c *Client) downloadOnce(ctx context.Context, client tlsclient.HttpClient, url string) ([]byte, error) { +func (c *Client) downloadOnce(ctx context.Context, sess *tlsSession, url string) ([]byte, error) { req, err := http.NewRequest(http.MethodGet, url, nil) if err != nil { return nil, err @@ -781,13 +782,13 @@ func (c *Client) downloadOnce(ctx context.Context, client tlsclient.HttpClient, req = req.WithContext(ctx) req.Header = http.Header{ "accept": {"*/*"}, - "user-agent": {defaultUserAgent}, + "user-agent": {sess.fp.userAgent}, http.HeaderOrderKey: { "accept", "user-agent", }, } - resp, err := client.Do(req) + resp, err := sess.client.Do(req) if err != nil { // Network-level failure (EOF, reset, timeout) — transient, worth a retry. return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err) @@ -808,20 +809,78 @@ func (c *Client) downloadOnce(ctx context.Context, client tlsclient.HttpClient, return data, nil } -func (c *Client) newTLSClient() (tlsclient.HttpClient, error) { +// fingerprint bundles a TLS ClientProfile with the browser headers that match +// it, so the JA3/JA4 handshake and the advertised User-Agent / client hints +// stay internally consistent within a single request. +type fingerprint struct { + profile profiles.ClientProfile + userAgent string + secCHUA string + platform string // quoted sec-ch-ua-platform value, e.g. `"Windows"` +} + +const ( + osWindows = iota + osMac +) + +func newChromeFingerprint(profile profiles.ClientProfile, major, osKind int) fingerprint { + ua := fmt.Sprintf("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.0.0 Safari/537.36", major) + platform := `"Windows"` + if osKind == osMac { + ua = fmt.Sprintf("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.0.0 Safari/537.36", major) + platform = `"macOS"` + } + return fingerprint{ + profile: profile, + userAgent: ua, + secCHUA: fmt.Sprintf(`"Not?A_Brand";v="99", "Google Chrome";v="%d", "Chromium";v="%d"`, major, major), + platform: platform, + } +} + +// adobeFingerprints is a small pool of recent desktop Chrome builds across +// Windows/macOS. Each request draws one at random so the outbound TLS +// fingerprint and headers vary from call to call instead of presenting a single +// static signature to Adobe's anti-bot layer. +var adobeFingerprints = []fingerprint{ + newChromeFingerprint(profiles.Chrome_133, 133, osWindows), + newChromeFingerprint(profiles.Chrome_131, 131, osWindows), + newChromeFingerprint(profiles.Chrome_124, 124, osWindows), + newChromeFingerprint(profiles.Chrome_133, 133, osMac), + newChromeFingerprint(profiles.Chrome_131, 131, osMac), +} + +func randomFingerprint() fingerprint { + return adobeFingerprints[rand.Intn(len(adobeFingerprints))] +} + +// tlsSession pairs a configured TLS client with the fingerprint it was built +// for, so downstream header builders advertise the matching UA / client hints. +type tlsSession struct { + client tlsclient.HttpClient + fp fingerprint +} + +func (c *Client) newTLSClient() (*tlsSession, error) { + fp := randomFingerprint() options := []tlsclient.HttpClientOption{ tlsclient.WithTimeoutSeconds(60), - tlsclient.WithClientProfile(profiles.Chrome_133), + tlsclient.WithClientProfile(fp.profile), tlsclient.WithNotFollowRedirects(), tlsclient.WithRandomTLSExtensionOrder(), } if c.proxy != "" { options = append(options, tlsclient.WithProxyUrl(c.proxy)) } - return tlsclient.NewHttpClient(tlsclient.NewNoopLogger(), options...) + client, err := tlsclient.NewHttpClient(tlsclient.NewNoopLogger(), options...) + if err != nil { + return nil, err + } + return &tlsSession{client: client, fp: fp}, nil } -func exchangeCookieWithTLSClient(ctx context.Context, client tlsclient.HttpClient, cookie string) (*CookieExchangeResult, error) { +func exchangeCookieWithTLSClient(ctx context.Context, sess *tlsSession, cookie string) (*CookieExchangeResult, error) { cookie = normalizeCookie(cookie) if cookie == "" { return nil, ErrAdobeCookieEmpty @@ -840,7 +899,7 @@ func exchangeCookieWithTLSClient(ctx context.Context, client tlsclient.HttpClien "cookie": {cookie}, "origin": {"https://firefly.adobe.com"}, "referer": {"https://firefly.adobe.com/"}, - "user-agent": {defaultUserAgent}, + "user-agent": {sess.fp.userAgent}, http.HeaderOrderKey: { "accept", "accept-language", @@ -851,7 +910,7 @@ func exchangeCookieWithTLSClient(ctx context.Context, client tlsclient.HttpClien "user-agent", }, } - resp, err := client.Do(req) + resp, err := sess.client.Do(req) if err != nil { return nil, fmt.Errorf("adobe cookie exchange network error: %w", err) } diff --git a/backend/internal/provider/adobe/util.go b/backend/internal/provider/adobe/util.go index 273cf9d..80f4954 100644 --- a/backend/internal/provider/adobe/util.go +++ b/backend/internal/provider/adobe/util.go @@ -12,11 +12,6 @@ import ( "github.com/google/uuid" ) -const ( - defaultUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36" - defaultSecCHUA = `"Not:A-Brand";v="99", "Google Chrome";v="145", "Chromium";v="145"` -) - func stringValue(v any) string { switch x := v.(type) { case string: diff --git a/backend/internal/service/admin_read.go b/backend/internal/service/admin_read.go index b49e72b..1528c7e 100644 --- a/backend/internal/service/admin_read.go +++ b/backend/internal/service/admin_read.go @@ -256,7 +256,23 @@ func (s *AdminReadService) Dashboard(ctx context.Context) (map[string]any, error now := time.Now() dayCut := now.Add(-24 * time.Hour) weekCut := now.Add(-3 * 24 * time.Hour) // "week" key = last 3 days (per admin request) + // "today" = since local midnight (Asia/Shanghai, the app's business tz), not a + // rolling 24h — so 今日消耗/生成 resets at 00:00 the way the admin expects. + loc, lerr := time.LoadLocation("Asia/Shanghai") + if lerr != nil { + loc = time.Local + } + nowLocal := now.In(loc) + todayCut := time.Date(nowLocal.Year(), nowLocal.Month(), nowLocal.Day(), 0, 0, 0, 0, loc) + today, err := s.events.WindowStats(ctx, todayCut) + if err != nil { + return nil, err + } + todayDau, err := s.events.DistinctUsersSince(ctx, todayCut) + if err != nil { + return nil, err + } day, err := s.events.WindowStats(ctx, dayCut) if err != nil { return nil, err @@ -345,6 +361,8 @@ func (s *AdminReadService) Dashboard(ctx context.Context) (map[string]any, error } return map[string]any{ + "today": today, + "today_dau": todayDau, "day": day, "week": week, "prev_day_total": prevDay, diff --git a/frontend/src/views/OverviewView.vue b/frontend/src/views/OverviewView.vue index 2d45052..7165383 100644 --- a/frontend/src/views/OverviewView.vue +++ b/frontend/src/views/OverviewView.vue @@ -35,6 +35,8 @@ async function refreshAll() { // ---- windowed event aggregates (from /dashboard) ---- const EMPTY_WINDOW = { total: 0, success: 0, failed: 0, pending: 0, image: 0, video: 0, api: 0, web: 0, spent: 0 } +const today = computed(() => dash.value?.today || EMPTY_WINDOW) +const todayDau = computed(() => dash.value?.today_dau || 0) const day = computed(() => dash.value?.day || EMPTY_WINDOW) const week = computed(() => dash.value?.week || EMPTY_WINDOW) // All-time persistent counters (stat_counters) — independent of log retention. @@ -155,7 +157,7 @@ onUnmounted(() => clearInterval(timer)) -
+
@@ -172,6 +174,25 @@ onUnmounted(() => clearInterval(timer))
+ +
+
+ 今日生成 + + + +
+
{{ fmtInt(today.total) }}
+
+ {{ today.success }} 成功 + {{ today.failed }} 失败 + {{ today.pending }} 进行中 +
+
+ 消耗 {{ fmtCredits(today.spent) }} 积分 · {{ fmtInt(todayDau) }} 活跃 +
+
+