Add app analytics tracking for paywall and checkout events

This commit is contained in:
2569718930@qq.com
2026-03-31 07:15:54 +08:00
parent 8c8e242753
commit c29b560401
12 changed files with 506 additions and 2 deletions
@@ -0,0 +1,45 @@
import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function POST(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
try {
const body = await req.json();
const auth = await buildBackendRequestHeaders(req);
const headers = new Headers(auth.headers);
headers.set("Content-Type", "application/json");
const res = await fetch(`${API_BASE}/api/analytics/events`, {
method: "POST",
headers,
body: JSON.stringify(body ?? {}),
cache: "no-store",
});
if (!res.ok) {
const raw = await res.text();
const response = NextResponse.json(
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 260) },
{ status: res.status },
);
return applyAuthResponseCookies(response, auth.response);
}
const data = await res.json();
const response = NextResponse.json(data);
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return NextResponse.json(
{ error: "Failed to track analytics event", detail: String(error) },
{ status: 500 },
);
}
}
@@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function GET(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
try {
const auth = await buildBackendRequestHeaders(req);
const url = new URL(`${API_BASE}/api/ops/analytics/funnel`);
const days = req.nextUrl.searchParams.get("days");
if (days) {
url.searchParams.set("days", days);
}
const res = await fetch(url.toString(), {
headers: auth.headers,
cache: "no-store",
});
const raw = await res.text();
const response = new NextResponse(raw, {
status: res.status,
headers: {
"Content-Type": res.headers.get("content-type") || "application/json",
"Cache-Control": "no-store",
},
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return NextResponse.json(
{ error: "Failed to fetch analytics funnel", detail: String(error) },
{ status: 500 },
);
}
}
+39 -1
View File
@@ -46,6 +46,7 @@ import {
getCurrentPaymentHost,
isPaymentHostAllowed,
} from "@/lib/payment-host";
import { trackAppEvent } from "@/lib/app-analytics";
import { useI18n } from "@/hooks/useI18n";
const UnlockProOverlay = dynamic(
@@ -1439,6 +1440,24 @@ export function AccountCenter() {
? `${formatTime(expiryInfo.raw, locale)} · ${copy.daysLeft.replace("{days}", String(Math.max(expiryInfo.daysLeft, 0)))}`
: "";
useEffect(() => {
if (!showOverlay || isSubscribed) return;
trackAppEvent("paywall_viewed", {
entry: "account_center",
user_state: isAuthenticated ? "logged_in" : "guest",
expired: showExpiredReminder,
expiring_soon: showExpiringSoon,
subscription_plan_code: planCode || null,
});
}, [
isAuthenticated,
isSubscribed,
planCode,
showExpiredReminder,
showExpiringSoon,
showOverlay,
]);
// Points Logic
const backendPointsRaw = Number(backend?.points);
const metadataPointsRaw = Number(
@@ -1670,6 +1689,12 @@ export function AccountCenter() {
if (status === "confirmed") {
setPaymentError("");
setPaymentInfo(`支付确认成功,交易: ${shortAddress(txHash)}`);
trackAppEvent("checkout_succeeded", {
entry: "account_center",
plan_code: selectedPlan?.plan_code || "pro_monthly",
intent_id: intentId,
tx_hash: txHash || null,
});
await loadSnapshot();
await loadPaymentSnapshot();
return;
@@ -1688,7 +1713,7 @@ export function AccountCenter() {
}
throw new Error("payment pending timeout");
},
[loadPaymentSnapshot, loadSnapshot],
[loadPaymentSnapshot, loadSnapshot, selectedPlan?.plan_code],
);
const signBindMessage = async (
@@ -2062,6 +2087,13 @@ export function AccountCenter() {
const txPayload = created.tx_payload;
if (!intentId || !txPayload?.to || !txPayload?.data)
throw new Error("intent payload invalid");
trackAppEvent("checkout_started", {
entry: "account_center",
plan_code: selectedPlan?.plan_code || "pro_monthly",
intent_id: intentId,
use_points: billing.canRedeem && usePoints,
pay_amount_usd: billing.payAmount,
});
const intentReceiver = String(txPayload.to || "").toLowerCase();
if (intentReceiver !== expectedReceiver) {
throw new Error(
@@ -2197,6 +2229,12 @@ export function AccountCenter() {
}
setPaymentInfo(`支付确认成功,交易: ${shortAddress(txHashNorm)}`);
trackAppEvent("checkout_succeeded", {
entry: "account_center",
plan_code: selectedPlan?.plan_code || "pro_monthly",
intent_id: intentId,
tx_hash: txHashNorm,
});
await loadSnapshot();
await loadPaymentSnapshot();
} catch (error) {
@@ -11,6 +11,7 @@ import { useI18n } from "@/hooks/useI18n";
import { getOfficialSourceLinks } from "@/lib/dashboard-official-sources";
import { getCityScenery } from "@/lib/dashboard-scenery";
import { CityDetail } from "@/lib/dashboard-types";
import { trackAppEvent } from "@/lib/app-analytics";
import { getTodayPolymarketUrl } from "@/lib/polymarket-market-links";
import {
getCityProfileStats,
@@ -183,6 +184,15 @@ export function DetailPanel() {
const handleFeatureAccess = (feature: "today" | "history") => {
blurActiveElement();
if (!isPro) {
trackAppEvent("paywall_feature_clicked", {
entry: "detail_panel",
feature,
city: store.selectedCity,
user_state: isAuthenticated ? "logged_in" : "guest",
});
}
if (isPro) {
if (feature === "today") {
void store.openTodayModal();
@@ -1,10 +1,11 @@
"use client";
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { useI18n } from "@/hooks/useI18n";
import { useDashboardStore } from "@/hooks/useDashboardStore";
import { UnlockProOverlay } from "@/components/subscription/UnlockProOverlay";
import { trackAppEvent } from "@/lib/app-analytics";
const TELEGRAM_GROUP_URL = String(
process.env.NEXT_PUBLIC_TELEGRAM_GROUP_URL ||
@@ -63,6 +64,14 @@ export function ProFeaturePaywall({
? "Sign In to Unlock Pro"
: "先登录再开通 Pro";
useEffect(() => {
trackAppEvent("paywall_viewed", {
entry: "feature_gate",
feature,
user_state: isAuthenticated ? "logged_in" : "guest",
});
}, [feature, isAuthenticated]);
return (
<div className="flex w-full flex-col items-center justify-center py-6 md:py-10 z-30 p-4">
<UnlockProOverlay
+38
View File
@@ -13,6 +13,7 @@ import {
getCityRevision,
toCitySummary,
} from "@/lib/dashboard-client";
import { markAnalyticsOnce, trackAppEvent } from "@/lib/app-analytics";
import {
CityDetail,
CityListItem,
@@ -71,6 +72,7 @@ function getInitialProAccessState(): ProAccessState {
return {
loading: true,
authenticated: false,
userId: null,
subscriptionActive: false,
subscriptionPlanCode: null,
subscriptionExpiresAt: null,
@@ -408,6 +410,7 @@ export function DashboardStoreProvider({
}
const payload = (await response.json()) as {
authenticated?: boolean;
user_id?: string | null;
subscription_active?: boolean | null;
subscription_plan_code?: string | null;
subscription_expires_at?: string | null;
@@ -416,6 +419,7 @@ export function DashboardStoreProvider({
setProAccess({
loading: false,
authenticated: Boolean(payload.authenticated),
userId: payload.user_id ?? null,
subscriptionActive: payload.subscription_active === true,
subscriptionPlanCode: payload.subscription_plan_code ?? null,
subscriptionExpiresAt: payload.subscription_expires_at ?? null,
@@ -426,6 +430,7 @@ export function DashboardStoreProvider({
setProAccess({
loading: false,
authenticated: false,
userId: null,
subscriptionActive: false,
subscriptionPlanCode: null,
subscriptionExpiresAt: null,
@@ -443,6 +448,39 @@ export function DashboardStoreProvider({
void refreshProAccess();
}, []);
useEffect(() => {
if (proAccess.loading || !proAccess.authenticated || !proAccess.userId) {
return;
}
if (
markAnalyticsOnce(`dashboard-active:${proAccess.userId}`, "session")
) {
trackAppEvent("dashboard_active", {
subscription_active: proAccess.subscriptionActive,
subscription_plan_code: proAccess.subscriptionPlanCode,
});
}
const isTrialPlan = /trial/i.test(
String(proAccess.subscriptionPlanCode || ""),
);
if (
isTrialPlan &&
markAnalyticsOnce(`signup-completed:${proAccess.userId}`, "local")
) {
trackAppEvent("signup_completed", {
source: "auth_me_trial",
subscription_plan_code: proAccess.subscriptionPlanCode,
});
}
}, [
proAccess.authenticated,
proAccess.loading,
proAccess.subscriptionActive,
proAccess.subscriptionPlanCode,
proAccess.userId,
]);
useEffect(() => {
if (!cities.length) return;
+91
View File
@@ -0,0 +1,91 @@
"use client";
type TrackableAnalyticsEvent =
| "signup_completed"
| "dashboard_active"
| "paywall_feature_clicked"
| "paywall_viewed"
| "checkout_started"
| "checkout_succeeded";
const CLIENT_ID_KEY = "polyweather:analytics:client-id";
const SESSION_ID_KEY = "polyweather:analytics:session-id";
function isClient() {
return typeof window !== "undefined";
}
function randomId() {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
}
function getStoredId(storage: Storage, key: string) {
let value = storage.getItem(key);
if (!value) {
value = randomId();
storage.setItem(key, value);
}
return value;
}
export function getAnalyticsClientId() {
if (!isClient()) return "";
try {
return getStoredId(window.localStorage, CLIENT_ID_KEY);
} catch {
return "";
}
}
export function getAnalyticsSessionId() {
if (!isClient()) return "";
try {
return getStoredId(window.sessionStorage, SESSION_ID_KEY);
} catch {
return "";
}
}
export function markAnalyticsOnce(key: string, scope: "local" | "session" = "session") {
if (!isClient()) return false;
const storage = scope === "local" ? window.localStorage : window.sessionStorage;
const normalizedKey = `polyweather:analytics:once:${key}`;
try {
if (storage.getItem(normalizedKey) === "1") {
return false;
}
storage.setItem(normalizedKey, "1");
return true;
} catch {
return true;
}
}
export function trackAppEvent(
eventType: TrackableAnalyticsEvent,
payload: Record<string, unknown> = {},
) {
if (!isClient()) return;
const body = {
event_type: eventType,
client_id: getAnalyticsClientId() || undefined,
session_id: getAnalyticsSessionId() || undefined,
payload: {
...payload,
path: window.location.pathname,
href: window.location.href,
captured_at: new Date().toISOString(),
},
};
void fetch("/api/analytics/events", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
keepalive: true,
}).catch(() => {});
}
+1
View File
@@ -435,6 +435,7 @@ export interface HistoryState {
export interface ProAccessState {
loading: boolean;
authenticated: boolean;
userId: string | null;
subscriptionActive: boolean;
subscriptionPlanCode: string | null;
subscriptionExpiresAt: string | null;
+1
View File
@@ -50,6 +50,7 @@ function isPublicPage(pathname: string) {
function isPublicApi(pathname: string) {
return (
pathname === "/api/auth/me" ||
pathname === "/api/analytics/events" ||
pathname === "/api/cities" ||
pathname === "/api/vitals" ||
/^\/api\/city\/[^/]+$/i.test(pathname) ||
+175
View File
@@ -104,6 +104,23 @@ class DBManager:
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_payment_audit_events_created_at ON payment_audit_events(created_at DESC)"
)
conn.execute("""
CREATE TABLE IF NOT EXISTS app_analytics_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_type TEXT NOT NULL,
user_id TEXT,
client_id TEXT,
session_id TEXT,
payload_json TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_app_analytics_events_created_at ON app_analytics_events(created_at DESC)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_app_analytics_events_type_created_at ON app_analytics_events(event_type, created_at DESC)"
)
conn.execute("""
CREATE TABLE IF NOT EXISTS supabase_bindings (
supabase_user_id TEXT PRIMARY KEY,
@@ -197,6 +214,164 @@ class DBManager:
)
conn.commit()
def append_app_analytics_event(
self,
event_type: str,
payload: Dict[str, Any],
*,
user_id: Optional[str] = None,
client_id: Optional[str] = None,
session_id: Optional[str] = None,
) -> None:
kind = str(event_type or "").strip().lower()
if not kind:
return
body = payload if isinstance(payload, dict) else {}
normalized_user_id = str(user_id or "").strip().lower() or None
normalized_client_id = str(client_id or "").strip() or None
normalized_session_id = str(session_id or "").strip() or None
with self._get_connection() as conn:
conn.execute(
"""
INSERT INTO app_analytics_events (
event_type,
user_id,
client_id,
session_id,
payload_json,
created_at
)
VALUES (?, ?, ?, ?, ?, ?)
""",
(
kind,
normalized_user_id,
normalized_client_id,
normalized_session_id,
json.dumps(body, ensure_ascii=False),
datetime.now().isoformat(),
),
)
conn.commit()
def list_app_analytics_events(
self,
*,
limit: int = 200,
event_type: Optional[str] = None,
since_iso: Optional[str] = None,
) -> List[Dict[str, Any]]:
safe_limit = max(1, min(int(limit or 200), 2000))
kind = str(event_type or "").strip().lower()
params: List[Any] = []
clauses: List[str] = []
if kind:
clauses.append("event_type = ?")
params.append(kind)
since_text = str(since_iso or "").strip()
if since_text:
clauses.append("created_at >= ?")
params.append(since_text)
where_sql = f"WHERE {' AND '.join(clauses)}" if clauses else ""
params.append(safe_limit)
with self._get_connection() as conn:
conn.row_factory = sqlite3.Row
rows = conn.execute(
f"""
SELECT id, event_type, user_id, client_id, session_id, payload_json, created_at
FROM app_analytics_events
{where_sql}
ORDER BY id DESC
LIMIT ?
""",
tuple(params),
).fetchall()
out: List[Dict[str, Any]] = []
for row in rows:
try:
payload = json.loads(str(row["payload_json"] or "{}"))
except Exception:
payload = {}
out.append(
{
"id": int(row["id"]),
"event_type": str(row["event_type"] or ""),
"user_id": str(row["user_id"] or "") or None,
"client_id": str(row["client_id"] or "") or None,
"session_id": str(row["session_id"] or "") or None,
"payload": payload if isinstance(payload, dict) else {},
"created_at": row["created_at"],
}
)
return out
def get_app_analytics_funnel_summary(self, *, days: int = 30) -> Dict[str, Any]:
safe_days = max(1, min(int(days or 30), 365))
since_dt = datetime.now() - timedelta(days=safe_days)
rows = self.list_app_analytics_events(limit=5000, since_iso=since_dt.isoformat())
event_names = [
"signup_completed",
"dashboard_active",
"paywall_feature_clicked",
"paywall_viewed",
"checkout_started",
"checkout_succeeded",
]
summary: Dict[str, Dict[str, Any]] = {
name: {
"total": 0,
"unique_users": 0,
"unique_actors": 0,
}
for name in event_names
}
actor_sets: Dict[str, set[str]] = {name: set() for name in event_names}
user_sets: Dict[str, set[str]] = {name: set() for name in event_names}
for row in rows:
event_type = str(row.get("event_type") or "").strip().lower()
if event_type not in summary:
continue
summary[event_type]["total"] += 1
user_id = str(row.get("user_id") or "").strip().lower()
client_id = str(row.get("client_id") or "").strip()
session_id = str(row.get("session_id") or "").strip()
actor_key = ""
if user_id:
actor_key = f"user:{user_id}"
user_sets[event_type].add(user_id)
elif client_id:
actor_key = f"client:{client_id}"
elif session_id:
actor_key = f"session:{session_id}"
else:
actor_key = f"event:{row.get('id')}"
actor_sets[event_type].add(actor_key)
for name in event_names:
summary[name]["unique_users"] = len(user_sets[name])
summary[name]["unique_actors"] = len(actor_sets[name])
def _rate(numerator_key: str, denominator_key: str) -> Optional[float]:
denominator = int(summary[denominator_key]["unique_actors"] or 0)
numerator = int(summary[numerator_key]["unique_actors"] or 0)
if denominator <= 0:
return None
return round((numerator / denominator) * 100, 1)
return {
"window_days": safe_days,
"since": since_dt.isoformat(),
"events": summary,
"rates": {
"login_active_rate": _rate("dashboard_active", "signup_completed"),
"paywall_click_rate": _rate("paywall_feature_clicked", "dashboard_active"),
"paywall_view_rate": _rate("paywall_viewed", "paywall_feature_clicked"),
"checkout_start_rate": _rate("checkout_started", "paywall_viewed"),
"checkout_success_rate": _rate("checkout_succeeded", "checkout_started"),
},
}
def list_payment_audit_events(
self,
limit: int = 50,
+7
View File
@@ -341,6 +341,13 @@ class ConfirmPaymentTxRequest(BaseModel):
tx_hash: Optional[str] = None
class AnalyticsEventRequest(BaseModel):
event_type: str = Field(..., min_length=3, max_length=64)
client_id: Optional[str] = Field(default=None, max_length=128)
session_id: Optional[str] = Field(default=None, max_length=128)
payload: Dict[str, Any] = Field(default_factory=dict)
class GrantPointsRequest(BaseModel):
email: str = Field(..., min_length=3)
points: int = Field(..., gt=0, le=100000)
+46
View File
@@ -21,6 +21,7 @@ from web.analysis_service import (
_build_city_summary_payload,
)
from web.core import (
AnalyticsEventRequest,
CITIES,
CITY_REGISTRY,
CITY_RISK_PROFILES,
@@ -51,6 +52,15 @@ from web.core import (
router = APIRouter()
TRACKABLE_ANALYTICS_EVENTS = {
"signup_completed",
"dashboard_active",
"paywall_feature_clicked",
"paywall_viewed",
"checkout_started",
"checkout_succeeded",
}
def _parse_snapshot_dt(value: object) -> Optional[datetime]:
raw = str(value or "").strip()
@@ -377,6 +387,32 @@ async def auth_me(request: Request):
}
@router.post("/api/analytics/events")
async def analytics_track(request: Request, body: AnalyticsEventRequest):
_bind_optional_supabase_identity(request)
event_type = str(body.event_type or "").strip().lower()
if event_type not in TRACKABLE_ANALYTICS_EVENTS:
raise HTTPException(status_code=400, detail="unsupported_event_type")
payload = body.payload if isinstance(body.payload, dict) else {}
normalized_payload = {
key: value
for key, value in payload.items()
if isinstance(key, str) and len(key) <= 64
}
from src.database.db_manager import DBManager
db = DBManager()
db.append_app_analytics_event(
event_type,
normalized_payload,
user_id=getattr(request.state, "auth_user_id", None),
client_id=body.client_id,
session_id=body.session_id,
)
return {"ok": True}
@router.get("/api/ops/users")
async def ops_search_users(request: Request, q: str = "", limit: int = 20):
_assert_entitlement(request)
@@ -509,6 +545,16 @@ async def ops_grant_points(request: Request, body: GrantPointsRequest):
return result
@router.get("/api/ops/analytics/funnel")
async def ops_analytics_funnel(request: Request, days: int = 30):
_assert_entitlement(request)
_require_ops_admin(request)
from src.database.db_manager import DBManager
db = DBManager()
return db.get_app_analytics_funnel_summary(days=days)
@router.get("/api/payments/config")
async def payment_config(request: Request):
_assert_entitlement(request)