From 51b0e41c0f46eeaae1a0217d3b24fb1116fa7479 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Wed, 24 Jun 2026 16:45:47 +0800 Subject: [PATCH] Track public content acquisition events --- .../landing/InstitutionalLandingPage.tsx | 3 + .../__tests__/landingPricingReferral.test.ts | 2 + .../ops/__tests__/opsAnalyticsFunnel.test.ts | 7 +- .../ops/analytics/AnalyticsPageClient.tsx | 81 +++++++++++++++++++ frontend/lib/ops-api.ts | 7 ++ src/database/db_manager.py | 43 ++++++++++ tests/test_web_observability.py | 59 ++++++++++++++ web/services/city_runtime.py | 4 + 8 files changed, 205 insertions(+), 1 deletion(-) diff --git a/frontend/components/landing/InstitutionalLandingPage.tsx b/frontend/components/landing/InstitutionalLandingPage.tsx index 25c08d8a..0d1fbfbc 100644 --- a/frontend/components/landing/InstitutionalLandingPage.tsx +++ b/frontend/components/landing/InstitutionalLandingPage.tsx @@ -324,6 +324,9 @@ function InstitutionalLandingScreen({ locale }: { locale: LandingLocale }) { {isEn ? "Guide" : "读图"} + + Weather Market Brief + {isEn ? "Pricing" : "定价"} diff --git a/frontend/components/landing/__tests__/landingPricingReferral.test.ts b/frontend/components/landing/__tests__/landingPricingReferral.test.ts index bb567f53..5ccfebda 100644 --- a/frontend/components/landing/__tests__/landingPricingReferral.test.ts +++ b/frontend/components/landing/__tests__/landingPricingReferral.test.ts @@ -104,6 +104,8 @@ export function runTests() { "landing supported cities must render names and station codes from the generated city groups", ); assert(source.includes("#contact"), "landing navigation must expose the contact section"); + assert(source.includes('href="/briefs"'), "landing navigation must expose public Weather Market Brief assets"); + assert(source.includes("Weather Market Brief"), "landing page must label the public brief entry clearly"); assert(source.includes('id="contact"'), "landing page must include a contact section"); assert(source.includes("yhrsc30@gmail.com"), "landing page must show the operator contact email"); assert(source.includes("mailto:${CONTACT_EMAIL}"), "landing contact email must be clickable"); diff --git a/frontend/components/ops/__tests__/opsAnalyticsFunnel.test.ts b/frontend/components/ops/__tests__/opsAnalyticsFunnel.test.ts index b700f97e..fd007cbd 100644 --- a/frontend/components/ops/__tests__/opsAnalyticsFunnel.test.ts +++ b/frontend/components/ops/__tests__/opsAnalyticsFunnel.test.ts @@ -25,16 +25,21 @@ export function runTests() { assert( opsApi.includes('"landing_view", "enter_terminal", "login_start", "signup_success", "trial_created", "payment_start", "payment_success"') && + opsApi.includes("content_events?:") && + opsApi.includes("contentEvents") && opsApi.includes("diagnostics?:") && opsApi.includes("traffic?:") && opsApi.includes("uniqueActors"), - "ops funnel API client must preserve the full standard funnel and expose diagnostics/traffic dimensions", + "ops funnel API client must preserve the standard funnel and expose content/diagnostics/traffic dimensions", ); assert( analyticsPage.includes("落地页访问") && analyticsPage.includes("进入终端") && analyticsPage.includes("注册成功") && analyticsPage.includes("鉴权降级") && + analyticsPage.includes("公开内容获客") && + analyticsPage.includes("Brief 浏览") && + analyticsPage.includes("briefCtaClick?.total") && analyticsPage.includes("来源与设备") && analyticsPage.includes("paymentSuccess?.count") && !analyticsPage.includes("总注册") && diff --git a/frontend/components/ops/analytics/AnalyticsPageClient.tsx b/frontend/components/ops/analytics/AnalyticsPageClient.tsx index f73ada8a..de004547 100644 --- a/frontend/components/ops/analytics/AnalyticsPageClient.tsx +++ b/frontend/components/ops/analytics/AnalyticsPageClient.tsx @@ -28,10 +28,17 @@ type AuthDiagnostic = { unique_actors?: number; by_reason?: TopItem[]; }; +type ContentEventSummary = { + total?: number; + unique_users?: number; + unique_actors?: number; +}; export function AnalyticsPageClient() { const [loading, setLoading] = useState(true); const [funnel, setFunnel] = useState([]); + const [contentEvents, setContentEvents] = useState>({}); + const [content, setContent] = useState>({}); const [diagnostics, setDiagnostics] = useState>({}); const [rates, setRates] = useState | null>(null); const [traffic, setTraffic] = useState>({}); @@ -42,6 +49,8 @@ export function AnalyticsPageClient() { try { const data = await opsApi.funnel(days); setFunnel(data.steps); + setContentEvents(data.contentEvents ?? {}); + setContent(data.content ?? {}); setDiagnostics(data.diagnostics ?? {}); setRates(data.rates ?? null); setTraffic(data.traffic ?? {}); @@ -65,6 +74,10 @@ export function AnalyticsPageClient() { const trialCreated = stepByKey.trial_created; const paymentStart = stepByKey.payment_start; const paymentSuccess = stepByKey.payment_success; + const briefView = contentEvents.brief_view; + const briefCtaClick = contentEvents.brief_cta_click; + const methodologyView = contentEvents.methodology_view; + const socialOutboundClick = contentEvents.social_outbound_click; const overallRate = landingView?.uniqueActors && paymentSuccess?.uniqueActors ? ((paymentSuccess.uniqueActors / landingView.uniqueActors) * 100).toFixed(1) @@ -141,6 +154,56 @@ export function AnalyticsPageClient() { )} + + 公开内容获客 + +
+ + + + +
+
+ {[ + ["内容路径", content.paths ?? []], + ["Brief 城市", content.cities ?? []], + ].map(([title, rows]) => ( +
+
{String(title)}
+ {(rows as TopItem[]).length === 0 ? ( +
暂无数据
+ ) : ( +
    + {(rows as TopItem[]).slice(0, 5).map((item) => ( +
  • + {item.name} + {item.count} +
  • + ))} +
+ )} +
+ ))} +
+
+
+
来源与设备 @@ -243,3 +306,21 @@ export function AnalyticsPageClient() {
); } + +function ContentMetricCard({ + label, + total, + uniqueActors, +}: { + label: string; + total?: number; + uniqueActors?: number; +}) { + return ( +
+
{label}
+
{total ?? 0}
+
独立 {uniqueActors ?? 0}
+
+ ); +} diff --git a/frontend/lib/ops-api.ts b/frontend/lib/ops-api.ts index d5d11c48..7a9a8085 100644 --- a/frontend/lib/ops-api.ts +++ b/frontend/lib/ops-api.ts @@ -34,6 +34,11 @@ export const opsApi = { async funnel(days = 30) { const raw = await opsFetch<{ events?: Record; + content_events?: Record; + content?: { + paths?: { name: string; count: number }[]; + cities?: { name: string; count: number }[]; + }; diagnostics?: Record; rates?: Record; traffic?: { @@ -68,6 +73,8 @@ export const opsApi = { return { key, label: stepLabels[key] ?? key, count, uniqueActors, pct_of_prev }; }); return { + content: raw?.content ?? {}, + contentEvents: raw?.content_events ?? {}, diagnostics: raw?.diagnostics ?? {}, rates: raw?.rates, steps, diff --git a/src/database/db_manager.py b/src/database/db_manager.py index 768a38c0..4b50013f 100644 --- a/src/database/db_manager.py +++ b/src/database/db_manager.py @@ -2672,6 +2672,12 @@ class DBManager: "payment_start", "payment_success", ] + content_event_names = [ + "brief_view", + "brief_cta_click", + "methodology_view", + "social_outbound_click", + ] diagnostic_event_names = ["degraded_auth_profile"] event_aliases = { "landing_view": ("landing_view",), @@ -2697,6 +2703,20 @@ class DBManager: } 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} + content_summary: Dict[str, Dict[str, Any]] = { + name: { + "total": 0, + "unique_users": 0, + "unique_actors": 0, + } + for name in content_event_names + } + content_actor_sets: Dict[str, set[str]] = { + name: set() for name in content_event_names + } + content_user_sets: Dict[str, set[str]] = { + name: set() for name in content_event_names + } diagnostics: Dict[str, Dict[str, Any]] = { name: {"total": 0, "unique_actors": 0, "by_reason": []} for name in diagnostic_event_names @@ -2711,6 +2731,8 @@ class DBManager: country_counts: Counter = Counter() device_counts: Counter = Counter() landing_path_counts: Counter = Counter() + content_path_counts: Counter = Counter() + content_city_counts: Counter = Counter() def _payload(row: Dict[str, Any]) -> Dict[str, Any]: payload = row.get("payload") @@ -2756,6 +2778,19 @@ class DBManager: diagnostic_reason_counts[raw_event_type][reason[:120] or "unknown"] += 1 continue + if raw_event_type in content_summary: + content_summary[raw_event_type]["total"] += 1 + user_id = str(row.get("user_id") or "").strip().lower() + if user_id: + content_user_sets[raw_event_type].add(user_id) + content_actor_sets[raw_event_type].add(_actor_key(row)) + path = str(payload.get("path") or "/").strip()[:120] + content_path_counts[path or "/"] += 1 + city = str(payload.get("city") or "").strip().lower() + if city: + content_city_counts[city[:80]] += 1 + continue + event_type = alias_to_event.get(raw_event_type) if not event_type: continue @@ -2778,6 +2813,9 @@ class DBManager: for name in event_names: summary[name]["unique_users"] = len(user_sets[name]) summary[name]["unique_actors"] = len(actor_sets[name]) + for name in content_event_names: + content_summary[name]["unique_users"] = len(content_user_sets[name]) + content_summary[name]["unique_actors"] = len(content_actor_sets[name]) for name in diagnostic_event_names: diagnostics[name]["unique_actors"] = len(diagnostic_actor_sets[name]) diagnostics[name]["by_reason"] = _top(diagnostic_reason_counts[name], limit=6) @@ -2793,6 +2831,11 @@ class DBManager: "window_days": safe_days, "since": since_dt.isoformat(), "events": summary, + "content_events": content_summary, + "content": { + "paths": _top(content_path_counts), + "cities": _top(content_city_counts), + }, "diagnostics": diagnostics, "traffic": { "referrers": _top(referrer_counts), diff --git a/tests/test_web_observability.py b/tests/test_web_observability.py index 869bd224..b702d62c 100644 --- a/tests/test_web_observability.py +++ b/tests/test_web_observability.py @@ -287,6 +287,10 @@ def test_standard_growth_funnel_events_are_trackable(): "payment_start", "payment_success", "degraded_auth_profile", + "brief_view", + "brief_cta_click", + "methodology_view", + "social_outbound_click", }.issubset(city_runtime.TRACKABLE_ANALYTICS_EVENTS) @@ -372,6 +376,61 @@ def test_growth_funnel_summarizes_traffic_sources(monkeypatch): assert {"name": "mobile", "count": 1} in summary["traffic"]["devices"] +def test_growth_funnel_summarizes_public_content_events(monkeypatch): + from src.database.db_manager import DBManager + + rows = [ + { + "id": 1, + "event_type": "brief_view", + "user_id": "", + "client_id": "c1", + "session_id": "s1", + "payload": {"path": "/briefs/ankara/2026-06-24", "city": "ankara"}, + }, + { + "id": 2, + "event_type": "brief_cta_click", + "user_id": "", + "client_id": "c1", + "session_id": "s1", + "payload": {"path": "/briefs/ankara/2026-06-24", "cta": "terminal"}, + }, + { + "id": 3, + "event_type": "methodology_view", + "user_id": "", + "client_id": "c2", + "session_id": "s2", + "payload": {"path": "/methodology/deb", "slug": "deb"}, + }, + { + "id": 4, + "event_type": "social_outbound_click", + "user_id": "", + "client_id": "c3", + "session_id": "s3", + "payload": {"path": "/briefs/ankara/2026-06-24", "destination": "x_intent"}, + }, + ] + monkeypatch.setattr( + DBManager, + "list_app_analytics_events", + lambda self, limit=20000, since_iso=None: rows, + ) + + summary = DBManager().get_app_analytics_funnel_summary(days=7) + + assert summary["content_events"]["brief_view"]["total"] == 1 + assert summary["content_events"]["brief_cta_click"]["unique_actors"] == 1 + assert summary["content_events"]["methodology_view"]["total"] == 1 + assert summary["content_events"]["social_outbound_click"]["total"] == 1 + assert summary["content"]["paths"][0] == { + "name": "/briefs/ankara/2026-06-24", + "count": 3, + } + + def test_ops_source_health_flags_expected_official_sources(monkeypatch): class FakeCache: def get_city_cache(self, kind, city): diff --git a/web/services/city_runtime.py b/web/services/city_runtime.py index dffbf0fd..0cc06a5a 100644 --- a/web/services/city_runtime.py +++ b/web/services/city_runtime.py @@ -82,6 +82,10 @@ TRACKABLE_ANALYTICS_EVENTS = { "paywall_viewed", "checkout_started", "checkout_succeeded", + "brief_view", + "brief_cta_click", + "methodology_view", + "social_outbound_click", } DEFAULT_STATUS_CITIES = [