Track public content acquisition events
This commit is contained in:
@@ -324,6 +324,9 @@ function InstitutionalLandingScreen({ locale }: { locale: LandingLocale }) {
|
|||||||
<Link href="/docs/chart-guide" className="hover:text-slate-950">
|
<Link href="/docs/chart-guide" className="hover:text-slate-950">
|
||||||
{isEn ? "Guide" : "读图"}
|
{isEn ? "Guide" : "读图"}
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link href="/briefs" className="hover:text-slate-950">
|
||||||
|
Weather Market Brief
|
||||||
|
</Link>
|
||||||
<a href="#pricing" className="hover:text-slate-950">
|
<a href="#pricing" className="hover:text-slate-950">
|
||||||
{isEn ? "Pricing" : "定价"}
|
{isEn ? "Pricing" : "定价"}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -104,6 +104,8 @@ export function runTests() {
|
|||||||
"landing supported cities must render names and station codes from the generated city groups",
|
"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("#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('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("yhrsc30@gmail.com"), "landing page must show the operator contact email");
|
||||||
assert(source.includes("mailto:${CONTACT_EMAIL}"), "landing contact email must be clickable");
|
assert(source.includes("mailto:${CONTACT_EMAIL}"), "landing contact email must be clickable");
|
||||||
|
|||||||
@@ -25,16 +25,21 @@ export function runTests() {
|
|||||||
|
|
||||||
assert(
|
assert(
|
||||||
opsApi.includes('"landing_view", "enter_terminal", "login_start", "signup_success", "trial_created", "payment_start", "payment_success"') &&
|
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("diagnostics?:") &&
|
||||||
opsApi.includes("traffic?:") &&
|
opsApi.includes("traffic?:") &&
|
||||||
opsApi.includes("uniqueActors"),
|
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(
|
assert(
|
||||||
analyticsPage.includes("落地页访问") &&
|
analyticsPage.includes("落地页访问") &&
|
||||||
analyticsPage.includes("进入终端") &&
|
analyticsPage.includes("进入终端") &&
|
||||||
analyticsPage.includes("注册成功") &&
|
analyticsPage.includes("注册成功") &&
|
||||||
analyticsPage.includes("鉴权降级") &&
|
analyticsPage.includes("鉴权降级") &&
|
||||||
|
analyticsPage.includes("公开内容获客") &&
|
||||||
|
analyticsPage.includes("Brief 浏览") &&
|
||||||
|
analyticsPage.includes("briefCtaClick?.total") &&
|
||||||
analyticsPage.includes("来源与设备") &&
|
analyticsPage.includes("来源与设备") &&
|
||||||
analyticsPage.includes("paymentSuccess?.count") &&
|
analyticsPage.includes("paymentSuccess?.count") &&
|
||||||
!analyticsPage.includes("总注册") &&
|
!analyticsPage.includes("总注册") &&
|
||||||
|
|||||||
@@ -28,10 +28,17 @@ type AuthDiagnostic = {
|
|||||||
unique_actors?: number;
|
unique_actors?: number;
|
||||||
by_reason?: TopItem[];
|
by_reason?: TopItem[];
|
||||||
};
|
};
|
||||||
|
type ContentEventSummary = {
|
||||||
|
total?: number;
|
||||||
|
unique_users?: number;
|
||||||
|
unique_actors?: number;
|
||||||
|
};
|
||||||
|
|
||||||
export function AnalyticsPageClient() {
|
export function AnalyticsPageClient() {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [funnel, setFunnel] = useState<FunnelStep[]>([]);
|
const [funnel, setFunnel] = useState<FunnelStep[]>([]);
|
||||||
|
const [contentEvents, setContentEvents] = useState<Record<string, ContentEventSummary>>({});
|
||||||
|
const [content, setContent] = useState<Record<string, TopItem[]>>({});
|
||||||
const [diagnostics, setDiagnostics] = useState<Record<string, AuthDiagnostic>>({});
|
const [diagnostics, setDiagnostics] = useState<Record<string, AuthDiagnostic>>({});
|
||||||
const [rates, setRates] = useState<Record<string, number> | null>(null);
|
const [rates, setRates] = useState<Record<string, number> | null>(null);
|
||||||
const [traffic, setTraffic] = useState<Record<string, TopItem[]>>({});
|
const [traffic, setTraffic] = useState<Record<string, TopItem[]>>({});
|
||||||
@@ -42,6 +49,8 @@ export function AnalyticsPageClient() {
|
|||||||
try {
|
try {
|
||||||
const data = await opsApi.funnel(days);
|
const data = await opsApi.funnel(days);
|
||||||
setFunnel(data.steps);
|
setFunnel(data.steps);
|
||||||
|
setContentEvents(data.contentEvents ?? {});
|
||||||
|
setContent(data.content ?? {});
|
||||||
setDiagnostics(data.diagnostics ?? {});
|
setDiagnostics(data.diagnostics ?? {});
|
||||||
setRates(data.rates ?? null);
|
setRates(data.rates ?? null);
|
||||||
setTraffic(data.traffic ?? {});
|
setTraffic(data.traffic ?? {});
|
||||||
@@ -65,6 +74,10 @@ export function AnalyticsPageClient() {
|
|||||||
const trialCreated = stepByKey.trial_created;
|
const trialCreated = stepByKey.trial_created;
|
||||||
const paymentStart = stepByKey.payment_start;
|
const paymentStart = stepByKey.payment_start;
|
||||||
const paymentSuccess = stepByKey.payment_success;
|
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 =
|
const overallRate =
|
||||||
landingView?.uniqueActors && paymentSuccess?.uniqueActors
|
landingView?.uniqueActors && paymentSuccess?.uniqueActors
|
||||||
? ((paymentSuccess.uniqueActors / landingView.uniqueActors) * 100).toFixed(1)
|
? ((paymentSuccess.uniqueActors / landingView.uniqueActors) * 100).toFixed(1)
|
||||||
@@ -141,6 +154,56 @@ export function AnalyticsPageClient() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle>公开内容获客</CardTitle></CardHeader>
|
||||||
|
<CardContent className="space-y-5">
|
||||||
|
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||||
|
<ContentMetricCard
|
||||||
|
label="Brief 浏览"
|
||||||
|
total={briefView?.total}
|
||||||
|
uniqueActors={briefView?.unique_actors}
|
||||||
|
/>
|
||||||
|
<ContentMetricCard
|
||||||
|
label="Brief CTA"
|
||||||
|
total={briefCtaClick?.total}
|
||||||
|
uniqueActors={briefCtaClick?.unique_actors}
|
||||||
|
/>
|
||||||
|
<ContentMetricCard
|
||||||
|
label="方法论阅读"
|
||||||
|
total={methodologyView?.total}
|
||||||
|
uniqueActors={methodologyView?.unique_actors}
|
||||||
|
/>
|
||||||
|
<ContentMetricCard
|
||||||
|
label="社交外链"
|
||||||
|
total={socialOutboundClick?.total}
|
||||||
|
uniqueActors={socialOutboundClick?.unique_actors}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-4 text-sm md:grid-cols-2">
|
||||||
|
{[
|
||||||
|
["内容路径", content.paths ?? []],
|
||||||
|
["Brief 城市", content.cities ?? []],
|
||||||
|
].map(([title, rows]) => (
|
||||||
|
<div key={String(title)} className="space-y-2 rounded-lg border border-white/10 p-3">
|
||||||
|
<div className="text-xs font-bold text-slate-500">{String(title)}</div>
|
||||||
|
{(rows as TopItem[]).length === 0 ? (
|
||||||
|
<div className="text-xs text-slate-500">暂无数据</div>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{(rows as TopItem[]).slice(0, 5).map((item) => (
|
||||||
|
<li key={item.name} className="flex items-center gap-2">
|
||||||
|
<span className="min-w-0 flex-1 truncate" title={item.name}>{item.name}</span>
|
||||||
|
<span className="font-mono text-blue-600">{item.count}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[minmax(0,1.4fr)_minmax(320px,0.6fr)]">
|
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[minmax(0,1.4fr)_minmax(320px,0.6fr)]">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader><CardTitle>来源与设备</CardTitle></CardHeader>
|
<CardHeader><CardTitle>来源与设备</CardTitle></CardHeader>
|
||||||
@@ -243,3 +306,21 @@ export function AnalyticsPageClient() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ContentMetricCard({
|
||||||
|
label,
|
||||||
|
total,
|
||||||
|
uniqueActors,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
total?: number;
|
||||||
|
uniqueActors?: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-white/10 p-4">
|
||||||
|
<div className="text-xs text-slate-500">{label}</div>
|
||||||
|
<div className="text-xl font-bold text-white">{total ?? 0}</div>
|
||||||
|
<div className="mt-0.5 text-xs text-slate-500">独立 {uniqueActors ?? 0}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,6 +34,11 @@ export const opsApi = {
|
|||||||
async funnel(days = 30) {
|
async funnel(days = 30) {
|
||||||
const raw = await opsFetch<{
|
const raw = await opsFetch<{
|
||||||
events?: Record<string, { total?: number; unique_users?: number; unique_actors?: number }>;
|
events?: Record<string, { total?: number; unique_users?: number; unique_actors?: number }>;
|
||||||
|
content_events?: Record<string, { total?: number; unique_users?: number; unique_actors?: number }>;
|
||||||
|
content?: {
|
||||||
|
paths?: { name: string; count: number }[];
|
||||||
|
cities?: { name: string; count: number }[];
|
||||||
|
};
|
||||||
diagnostics?: Record<string, { total?: number; unique_actors?: number; by_reason?: { name: string; count: number }[] }>;
|
diagnostics?: Record<string, { total?: number; unique_actors?: number; by_reason?: { name: string; count: number }[] }>;
|
||||||
rates?: Record<string, number>;
|
rates?: Record<string, number>;
|
||||||
traffic?: {
|
traffic?: {
|
||||||
@@ -68,6 +73,8 @@ export const opsApi = {
|
|||||||
return { key, label: stepLabels[key] ?? key, count, uniqueActors, pct_of_prev };
|
return { key, label: stepLabels[key] ?? key, count, uniqueActors, pct_of_prev };
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
|
content: raw?.content ?? {},
|
||||||
|
contentEvents: raw?.content_events ?? {},
|
||||||
diagnostics: raw?.diagnostics ?? {},
|
diagnostics: raw?.diagnostics ?? {},
|
||||||
rates: raw?.rates,
|
rates: raw?.rates,
|
||||||
steps,
|
steps,
|
||||||
|
|||||||
@@ -2672,6 +2672,12 @@ class DBManager:
|
|||||||
"payment_start",
|
"payment_start",
|
||||||
"payment_success",
|
"payment_success",
|
||||||
]
|
]
|
||||||
|
content_event_names = [
|
||||||
|
"brief_view",
|
||||||
|
"brief_cta_click",
|
||||||
|
"methodology_view",
|
||||||
|
"social_outbound_click",
|
||||||
|
]
|
||||||
diagnostic_event_names = ["degraded_auth_profile"]
|
diagnostic_event_names = ["degraded_auth_profile"]
|
||||||
event_aliases = {
|
event_aliases = {
|
||||||
"landing_view": ("landing_view",),
|
"landing_view": ("landing_view",),
|
||||||
@@ -2697,6 +2703,20 @@ class DBManager:
|
|||||||
}
|
}
|
||||||
actor_sets: Dict[str, set[str]] = {name: set() 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}
|
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]] = {
|
diagnostics: Dict[str, Dict[str, Any]] = {
|
||||||
name: {"total": 0, "unique_actors": 0, "by_reason": []}
|
name: {"total": 0, "unique_actors": 0, "by_reason": []}
|
||||||
for name in diagnostic_event_names
|
for name in diagnostic_event_names
|
||||||
@@ -2711,6 +2731,8 @@ class DBManager:
|
|||||||
country_counts: Counter = Counter()
|
country_counts: Counter = Counter()
|
||||||
device_counts: Counter = Counter()
|
device_counts: Counter = Counter()
|
||||||
landing_path_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]:
|
def _payload(row: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
payload = row.get("payload")
|
payload = row.get("payload")
|
||||||
@@ -2756,6 +2778,19 @@ class DBManager:
|
|||||||
diagnostic_reason_counts[raw_event_type][reason[:120] or "unknown"] += 1
|
diagnostic_reason_counts[raw_event_type][reason[:120] or "unknown"] += 1
|
||||||
continue
|
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)
|
event_type = alias_to_event.get(raw_event_type)
|
||||||
if not event_type:
|
if not event_type:
|
||||||
continue
|
continue
|
||||||
@@ -2778,6 +2813,9 @@ class DBManager:
|
|||||||
for name in event_names:
|
for name in event_names:
|
||||||
summary[name]["unique_users"] = len(user_sets[name])
|
summary[name]["unique_users"] = len(user_sets[name])
|
||||||
summary[name]["unique_actors"] = len(actor_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:
|
for name in diagnostic_event_names:
|
||||||
diagnostics[name]["unique_actors"] = len(diagnostic_actor_sets[name])
|
diagnostics[name]["unique_actors"] = len(diagnostic_actor_sets[name])
|
||||||
diagnostics[name]["by_reason"] = _top(diagnostic_reason_counts[name], limit=6)
|
diagnostics[name]["by_reason"] = _top(diagnostic_reason_counts[name], limit=6)
|
||||||
@@ -2793,6 +2831,11 @@ class DBManager:
|
|||||||
"window_days": safe_days,
|
"window_days": safe_days,
|
||||||
"since": since_dt.isoformat(),
|
"since": since_dt.isoformat(),
|
||||||
"events": summary,
|
"events": summary,
|
||||||
|
"content_events": content_summary,
|
||||||
|
"content": {
|
||||||
|
"paths": _top(content_path_counts),
|
||||||
|
"cities": _top(content_city_counts),
|
||||||
|
},
|
||||||
"diagnostics": diagnostics,
|
"diagnostics": diagnostics,
|
||||||
"traffic": {
|
"traffic": {
|
||||||
"referrers": _top(referrer_counts),
|
"referrers": _top(referrer_counts),
|
||||||
|
|||||||
@@ -287,6 +287,10 @@ def test_standard_growth_funnel_events_are_trackable():
|
|||||||
"payment_start",
|
"payment_start",
|
||||||
"payment_success",
|
"payment_success",
|
||||||
"degraded_auth_profile",
|
"degraded_auth_profile",
|
||||||
|
"brief_view",
|
||||||
|
"brief_cta_click",
|
||||||
|
"methodology_view",
|
||||||
|
"social_outbound_click",
|
||||||
}.issubset(city_runtime.TRACKABLE_ANALYTICS_EVENTS)
|
}.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"]
|
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):
|
def test_ops_source_health_flags_expected_official_sources(monkeypatch):
|
||||||
class FakeCache:
|
class FakeCache:
|
||||||
def get_city_cache(self, kind, city):
|
def get_city_cache(self, kind, city):
|
||||||
|
|||||||
@@ -82,6 +82,10 @@ TRACKABLE_ANALYTICS_EVENTS = {
|
|||||||
"paywall_viewed",
|
"paywall_viewed",
|
||||||
"checkout_started",
|
"checkout_started",
|
||||||
"checkout_succeeded",
|
"checkout_succeeded",
|
||||||
|
"brief_view",
|
||||||
|
"brief_cta_click",
|
||||||
|
"methodology_view",
|
||||||
|
"social_outbound_click",
|
||||||
}
|
}
|
||||||
|
|
||||||
DEFAULT_STATUS_CITIES = [
|
DEFAULT_STATUS_CITIES = [
|
||||||
|
|||||||
Reference in New Issue
Block a user