Speed up entitlement auth sync

This commit is contained in:
2569718930@qq.com
2026-06-01 22:36:52 +08:00
parent fc0a8b8ff5
commit 9d136a337c
11 changed files with 334 additions and 42 deletions
+9 -1
View File
@@ -92,6 +92,14 @@ function formatServerTiming(stages: AuthMeTimingStage[], totalMs: number) {
.join(", ");
}
function buildBackendAuthMeUrl(req: NextRequest) {
const url = new URL(`${API_BASE!.replace(/\/+$/, "")}/api/auth/me`);
if (req.nextUrl.searchParams.get("scope") === "entitlement") {
url.searchParams.set("scope", "entitlement");
}
return url.toString();
}
function finishAuthMeResponse(
response: NextResponse,
timer: AuthMeTimer,
@@ -486,7 +494,7 @@ export async function GET(req: NextRequest) {
let res: Response;
try {
res = await timer.measure("backend_fetch", async () =>
await fetch(`${API_BASE}/api/auth/me`, {
await fetch(buildBackendAuthMeUrl(req), {
headers: backendAuth.headers,
cache: "no-store",
signal: controller.signal,
+1 -1
View File
@@ -30,7 +30,7 @@ async function warmSignupTrial(accessToken: string) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 2500);
try {
await fetch(`${API_BASE}/api/auth/me`, {
await fetch(`${API_BASE.replace(/\/+$/, "")}/api/auth/me?scope=entitlement`, {
cache: "no-store",
headers,
signal: controller.signal,
@@ -0,0 +1,67 @@
import {
buildAuthMePath,
mergeAccountAuthSnapshot,
} from "@/lib/auth-snapshot";
import type { AuthSnapshotLike } from "@/lib/auth-snapshot";
function assert(condition: unknown, message: string) {
if (!condition) throw new Error(message);
}
export function runTests() {
assert(
buildAuthMePath({ scope: "entitlement" }) ===
"/api/auth/me?scope=entitlement",
"account entitlement probe must use the lightweight auth/me scope",
);
assert(
buildAuthMePath({ preferSnapshot: true, scope: "entitlement" }) ===
"/api/auth/me?prefer_snapshot=1&scope=entitlement",
"terminal auth probe must combine snapshot preference with lightweight entitlement scope",
);
const active: AuthSnapshotLike & { referral?: { code?: string } } = {
authenticated: true,
user_id: "user-1",
subscription_active: true,
subscription_plan_code: "pro_monthly",
subscription_expires_at: "2026-07-01T00:00:00Z",
subscription_total_expires_at: "2026-07-01T00:00:00Z",
subscription_queued_days: 0,
points: 120,
referral: { code: "PW-ABC" },
};
const degraded = mergeAccountAuthSnapshot(active, {
authenticated: true,
user_id: "user-1",
subscription_active: null,
subscription_plan_code: null,
subscription_expires_at: null,
subscription_total_expires_at: null,
subscription_queued_days: 0,
points: 0,
degraded_auth_profile: true,
});
assert(
degraded.subscription_active === true &&
degraded.subscription_plan_code === "pro_monthly" &&
degraded.points === 120 &&
degraded.referral?.code === "PW-ABC",
"account snapshot merge must preserve confirmed Pro status when a later auth/me response is degraded",
);
const inactive = mergeAccountAuthSnapshot(active, {
authenticated: true,
user_id: "user-1",
subscription_active: false,
subscription_plan_code: null,
points: 0,
});
assert(
inactive.subscription_active === false,
"account snapshot merge must still accept a confirmed inactive subscription response",
);
}
@@ -299,9 +299,14 @@ export function runTests() {
authMeRouteSource.includes("!auth.authUserId") &&
authMeRouteSource.includes('req.headers.get("authorization")') &&
authMeRouteSource.indexOf("authenticated: false") <
authMeRouteSource.indexOf("await fetch(`${API_BASE}/api/auth/me`"),
authMeRouteSource.indexOf("await fetch(buildBackendAuthMeUrl(req)"),
"auth profile proxy must return unauthenticated locally for no-session Supabase requests instead of forwarding the backend entitlement token",
);
assert(
authMeRouteSource.includes("function buildBackendAuthMeUrl") &&
authMeRouteSource.includes('url.searchParams.set("scope", "entitlement")'),
"auth profile proxy must forward the lightweight entitlement scope to the backend auth/me endpoint",
);
assert(
authMeRouteSource.includes('reason: "prefer_snapshot_fast_path"') &&
authMeRouteSource.indexOf('reason: "prefer_snapshot_fast_path"') <
+4
View File
@@ -19,6 +19,10 @@ export type AuthMeResponse = {
subscription_queued_count?: number | null;
telegram_pricing?: TelegramPricing | null;
referral?: ReferralSummary | null;
degraded_auth_profile?: boolean | null;
degraded_reason?: string | null;
entitlement_snapshot?: boolean | null;
entitlement_snapshot_reason?: string | null;
};
export type ReferralSummary = {
@@ -1,7 +1,12 @@
"use client";
import { useCallback, useRef, useState } from "react";
import type { Dispatch, SetStateAction } from "react";
import type { User } from "@supabase/supabase-js";
import {
buildAuthMePath,
mergeAccountAuthSnapshot,
} from "@/lib/auth-snapshot";
import { getSupabaseBrowserClient } from "@/lib/supabase/client";
import type {
@@ -25,7 +30,7 @@ export interface UseAccountPaymentParams {
backend: AuthMeResponse | null;
user: User | null;
setUser: (user: User | null) => void;
setBackend: (backend: AuthMeResponse | null) => void;
setBackend: Dispatch<SetStateAction<AuthMeResponse | null>>;
setErrorText: (text: string) => void;
setUpdatedAt: (text: string) => void;
showOverlay: boolean;
@@ -155,21 +160,45 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
.then(({ data: { session } }) => session?.user ?? null)
.catch(() => null as User | null)
: Promise.resolve(null as User | null);
const authHeadersPromise = buildAuthedHeaders(false);
const backendPromise = authHeadersPromise.then((headers) =>
fetch("/api/auth/me", { cache: "no-store", headers }),
);
const [localUser, backendResult] = await Promise.all([userPromise, backendPromise]);
const [localUser, authHeaders] = await Promise.all([
userPromise,
buildAuthedHeaders(false),
]);
setUser(localUser);
if (!backendResult.ok) {
if (retry && backendResult.status === 401) {
const readAuthSnapshot = async (
headers: Record<string, string>,
options: Parameters<typeof buildAuthMePath>[0] = {},
) => {
const response = await fetch(buildAuthMePath(options), {
cache: "no-store",
headers,
});
if (!response.ok) {
const raw = (await response.text()).slice(0, 260);
const message = copy.httpError
.replace("{status}", String(response.status))
.replace("{raw}", raw);
const error = new Error(message) as Error & { status?: number };
error.status = response.status;
throw error;
}
return (await response.json()) as AuthMeResponse;
};
let latestHeaders = authHeaders;
let backendJson: AuthMeResponse;
try {
backendJson = await readAuthSnapshot(authHeaders, {
scope: "entitlement",
});
} catch (error) {
if (retry && (error as { status?: number }).status === 401) {
await new Promise((r) => setTimeout(r, 1200));
return fetchAuthSnapshot(false);
}
const raw = (await backendResult.text()).slice(0, 260);
throw new Error(copy.httpError.replace("{status}", String(backendResult.status)).replace("{raw}", raw));
throw error;
}
let backendJson = (await backendResult.json()) as AuthMeResponse;
if (
retry &&
supabaseReady &&
@@ -184,23 +213,31 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
refreshedSession?.access_token || "",
).trim();
if (refreshedToken) {
const retriedBackendResult = await fetch("/api/auth/me", {
cache: "no-store",
headers: { Authorization: `Bearer ${refreshedToken}` },
latestHeaders = { Authorization: `Bearer ${refreshedToken}` };
const retriedBackendJson = await readAuthSnapshot(latestHeaders, {
scope: "entitlement",
});
if (retriedBackendResult.ok) {
const retriedBackendJson =
(await retriedBackendResult.json()) as AuthMeResponse;
backendJson = retriedBackendJson;
}
backendJson = retriedBackendJson;
}
} catch {
// Keep the first response; the UI treats a logged-in local user with
// an unauthenticated backend snapshot as a temporary sync state.
}
}
setBackend(backendJson);
setBackend((previous) => mergeAccountAuthSnapshot(previous, backendJson));
setUpdatedAt(new Date().toISOString());
if (backendJson.authenticated !== false) {
void readAuthSnapshot(latestHeaders)
.then((fullJson) => {
setBackend((previous) =>
mergeAccountAuthSnapshot(previous, fullJson),
);
setUpdatedAt(new Date().toISOString());
})
.catch(() => {
// The lightweight entitlement snapshot already resolved the UI.
});
}
return backendJson;
},
[
@@ -27,6 +27,7 @@ import {
requestWalletWithTimeout,
} from "./payment-utils";
import { trackAppEvent } from "@/lib/app-analytics";
import { buildAuthMePath } from "@/lib/auth-snapshot";
import {
assertExpectedPaymentReceiver,
EXPECTED_PAYMENT_RECEIVER_ADDRESS,
@@ -189,7 +190,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
};
const authRes = await fetch("/api/auth/me", {
const authRes = await fetch(buildAuthMePath({ scope: "entitlement" }), {
cache: "no-store",
headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/json" },
});
@@ -68,6 +68,7 @@ import {
createAuthProfileRequestCache,
loadTerminalAuthProfile,
} from "@/components/dashboard/scan-terminal/terminal-auth-bootstrap";
import { buildAuthMePath } from "@/lib/auth-snapshot";
import {
cityListItemsToScanRows,
mergeScanRowsWithCityFallbackRows,
@@ -1217,9 +1218,10 @@ function ScanTerminalScreen() {
: null;
try {
const response = await fetch(
options?.preferSnapshot
? "/api/auth/me?prefer_snapshot=1"
: "/api/auth/me",
buildAuthMePath({
preferSnapshot: options?.preferSnapshot,
scope: "entitlement",
}),
{
cache: "no-store",
headers,
+83
View File
@@ -0,0 +1,83 @@
export type AuthSnapshotLike = {
authenticated?: boolean;
user_id?: string | null;
subscription_active?: boolean | null;
subscription_plan_code?: string | null;
subscription_source?: string | null;
subscription_is_trial?: boolean | null;
subscription_starts_at?: string | null;
subscription_expires_at?: string | null;
subscription_total_expires_at?: string | null;
subscription_queued_days?: number | null;
subscription_queued_count?: number | null;
points?: number | null;
referral?: unknown;
telegram_pricing?: unknown;
degraded_auth_profile?: boolean | null;
entitlement_snapshot?: boolean | null;
};
type AuthMePathOptions = {
preferSnapshot?: boolean;
scope?: "entitlement";
};
export function buildAuthMePath(options: AuthMePathOptions = {}) {
const params = new URLSearchParams();
if (options.preferSnapshot) params.set("prefer_snapshot", "1");
if (options.scope === "entitlement") params.set("scope", "entitlement");
const query = params.toString();
return query ? `/api/auth/me?${query}` : "/api/auth/me";
}
export function isUnknownSubscriptionSnapshot(
snapshot: AuthSnapshotLike | null | undefined,
) {
return (
snapshot?.subscription_active === null ||
snapshot?.subscription_active === undefined ||
snapshot?.degraded_auth_profile === true
);
}
export function mergeAccountAuthSnapshot<T extends AuthSnapshotLike>(
previous: T | null | undefined,
next: T,
): T {
if (
!previous?.subscription_active ||
!next?.authenticated ||
!isUnknownSubscriptionSnapshot(next)
) {
return next;
}
return {
...next,
subscription_active: true,
subscription_plan_code:
next.subscription_plan_code ?? previous.subscription_plan_code ?? null,
subscription_source: next.subscription_source ?? previous.subscription_source ?? null,
subscription_is_trial:
next.subscription_is_trial ?? previous.subscription_is_trial ?? null,
subscription_starts_at:
next.subscription_starts_at ?? previous.subscription_starts_at ?? null,
subscription_expires_at:
next.subscription_expires_at ?? previous.subscription_expires_at ?? null,
subscription_total_expires_at:
next.subscription_total_expires_at ??
previous.subscription_total_expires_at ??
previous.subscription_expires_at ??
null,
subscription_queued_days:
next.subscription_queued_days ?? previous.subscription_queued_days ?? 0,
subscription_queued_count:
next.subscription_queued_count ?? previous.subscription_queued_count ?? 0,
points:
Number.isFinite(Number(next.points)) && Number(next.points) > 0
? next.points
: previous.points,
referral: next.referral ?? previous.referral,
telegram_pricing: next.telegram_pricing ?? previous.telegram_pricing,
};
}
+65
View File
@@ -1817,6 +1817,71 @@ def test_auth_me_preserves_required_subscription_403_from_window(monkeypatch):
assert latest_any_calls["count"] == 0
def test_auth_me_entitlement_scope_skips_non_access_profile_sections(monkeypatch):
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "enabled", True)
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "require_subscription", True)
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "supabase_url", "https://example.supabase.co")
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "anon_key", "anon-key")
monkeypatch.setattr(web_core, "_SUPABASE_AUTH_REQUIRED", True)
class _Identity:
user_id = "user-1"
email = "user@example.com"
points = 7
created_at = "2026-05-01T00:00:00+00:00"
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "get_identity", lambda token: _Identity())
monkeypatch.setattr(
web_core.SUPABASE_ENTITLEMENT,
"get_subscription_window",
lambda user_id, respect_requirement=False, bypass_cache=False, unknown_on_error=False: {
"current": {
"plan_code": "pro_monthly",
"starts_at": "2026-06-01T00:00:00+00:00",
"expires_at": "2026-07-01T00:00:00+00:00",
},
"total_expires_at": "2026-07-01T00:00:00+00:00",
"queued_days": 0,
"queued_count": 0,
},
)
monkeypatch.setattr(
web_core.SUPABASE_ENTITLEMENT,
"get_referral_summary",
lambda user_id: (_ for _ in ()).throw(
AssertionError("entitlement scope must not block on referral summary"),
),
)
monkeypatch.setattr(
routes,
"_resolve_auth_points",
lambda request: (_ for _ in ()).throw(
AssertionError("entitlement scope must not block on points summary"),
),
)
monkeypatch.setattr(
routes,
"_resolve_weekly_profile",
lambda request: (_ for _ in ()).throw(
AssertionError("entitlement scope must not block on weekly profile"),
),
)
response = client.get(
"/api/auth/me?scope=entitlement",
headers={"Authorization": "Bearer access-token"},
)
assert response.status_code == 200
payload = response.json()
assert payload["authenticated"] is True
assert payload["subscription_active"] is True
assert payload["subscription_plan_code"] == "pro_monthly"
assert payload["points"] == 7
assert payload["weekly_points"] == 0
assert payload["referral"] is None
def test_backend_entitlement_token_binds_forwarded_supabase_identity(monkeypatch):
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "enabled", True)
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "supabase_url", "https://example.supabase.co")
+35 -15
View File
@@ -92,12 +92,27 @@ def _subscription_row_is_trial(row: Any) -> bool:
return "trial" in plan_code or "trial" in source
def _is_entitlement_scope(request: Request) -> bool:
return (
str(request.query_params.get("scope") or "").strip().lower()
== "entitlement"
)
def _state_points(request: Request) -> int:
try:
return max(0, int(getattr(request.state, "auth_points", 0) or 0))
except Exception:
return 0
def get_auth_me_payload(request: Request) -> Dict[str, Any]:
timer = _AuthMeTimer(request)
authenticated_for_log: Optional[bool] = None
outcome = "ok"
status_code = 200
subscription_active_for_log: Optional[bool] = None
entitlement_scope = _is_entitlement_scope(request)
try:
request.state.skip_subscription_gate = True
@@ -230,12 +245,13 @@ def get_auth_me_payload(request: Request) -> Dict[str, Any]:
subscription_queued_count = int(
subscription_window.get("queued_count") or 0
)
referral = timer.measure(
"referral_summary",
lambda: legacy_routes.SUPABASE_ENTITLEMENT.get_referral_summary(
user_id
),
)
if not entitlement_scope:
referral = timer.measure(
"referral_summary",
lambda: legacy_routes.SUPABASE_ENTITLEMENT.get_referral_summary(
user_id
),
)
except HTTPException:
raise
except Exception:
@@ -253,14 +269,18 @@ def get_auth_me_payload(request: Request) -> Dict[str, Any]:
subscription_queued_count = 0
referral = None
points = timer.measure(
"auth_points",
lambda: legacy_routes._resolve_auth_points(request),
)
weekly_profile = timer.measure(
"weekly_profile",
lambda: legacy_routes._resolve_weekly_profile(request),
)
if entitlement_scope:
points = _state_points(request)
weekly_profile = {"weekly_points": 0, "weekly_rank": None}
else:
points = timer.measure(
"auth_points",
lambda: legacy_routes._resolve_auth_points(request),
)
weekly_profile = timer.measure(
"weekly_profile",
lambda: legacy_routes._resolve_weekly_profile(request),
)
def resolve_telegram_pricing() -> Any:
if not user_id:
@@ -275,7 +295,7 @@ def get_auth_me_payload(request: Request) -> Dict[str, Any]:
return pricing.resolve_price_for_telegram_id(telegram_id or None)
telegram_pricing = None
if user_id:
if user_id and not entitlement_scope:
try:
telegram_pricing = timer.measure(
"telegram_pricing",