diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index 94d4fce1..4b84b645 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -6,6 +6,8 @@ import type { AdminLoginResponse, AdminMeResponse, AdminOverviewResponse, + AdminTrackingEventListQuery, + AdminTrackingEventListResponse, AdminUpsertProfileInviteCodeRequest, AdminUpsertProfileRedeemCodeRequest, AdminUpsertProfileTaskConfigRequest, @@ -135,6 +137,16 @@ export function debugAdminHttp(token: string, payload: AdminDebugHttpRequest) { }); } +export function listAdminTrackingEvents( + token: string, + query: AdminTrackingEventListQuery = {}, +) { + return request( + `/admin/api/tracking/events${buildQueryString(query)}`, + {token}, + ); +} + export function listProfileRedeemCodes(token: string) { return request( '/admin/api/profile/redeem-codes', @@ -232,6 +244,30 @@ function buildRequestUrl(path: string) { return `${ADMIN_API_BASE_URL}${normalizedPath}`; } +function buildQueryString(query: AdminTrackingEventListQuery) { + const params = new URLSearchParams(); + appendQueryParam(params, 'eventKey', query.eventKey); + appendQueryParam(params, 'userId', query.userId); + appendQueryParam(params, 'scopeKind', query.scopeKind); + appendQueryParam(params, 'scopeId', query.scopeId); + if (typeof query.limit === 'number' && Number.isFinite(query.limit)) { + params.set('limit', String(query.limit)); + } + const queryString = params.toString(); + return queryString ? `?${queryString}` : ''; +} + +function appendQueryParam( + params: URLSearchParams, + key: string, + value: string | null | undefined, +) { + const trimmed = value?.trim(); + if (trimmed) { + params.set(key, trimmed); + } +} + function parseJsonResponse(responseText: string): unknown { if (!responseText.trim()) { return null; diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 60be224b..63ffbacf 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -109,6 +109,14 @@ export type ProfileRedeemCodeMode = 'public' | 'unique' | 'private'; export type ProfileTaskCycle = 'daily'; export type TrackingScopeKind = 'site' | 'work' | 'module' | 'user'; +export interface AdminTrackingEventListQuery { + eventKey?: string; + userId?: string; + scopeKind?: TrackingScopeKind | ''; + scopeId?: string; + limit?: number; +} + export interface AdminUpsertProfileRedeemCodeRequest { code: string; mode: ProfileRedeemCodeMode; @@ -199,3 +207,22 @@ export interface ProfileTaskConfigAdminResponse { export interface ProfileTaskConfigAdminListResponse { entries: ProfileTaskConfigAdminResponse[]; } + +export interface AdminTrackingEventEntryPayload { + eventId: string; + eventKey: string; + eventTitle: string; + scopeKind: TrackingScopeKind | string; + scopeId: string; + dayKey: number; + userId?: string | null; + ownerUserId?: string | null; + profileId?: string | null; + moduleKey?: string | null; + metadataJson: string; + occurredAt: string; +} + +export interface AdminTrackingEventListResponse { + entries: AdminTrackingEventEntryPayload[]; +} diff --git a/apps/admin-web/src/app/AdminApp.tsx b/apps/admin-web/src/app/AdminApp.tsx index a200d35d..d1499ef0 100644 --- a/apps/admin-web/src/app/AdminApp.tsx +++ b/apps/admin-web/src/app/AdminApp.tsx @@ -23,6 +23,7 @@ import {AdminLoginPage} from '../pages/AdminLoginPage'; import {AdminOverviewPage} from '../pages/AdminOverviewPage'; import {AdminRedeemCodePage} from '../pages/AdminRedeemCodePage'; import {AdminTaskConfigPage} from '../pages/AdminTaskConfigPage'; +import {AdminTrackingEventsPage} from '../pages/AdminTrackingEventsPage'; import {AdminShell} from './AdminShell'; import type {AdminRouteId} from './adminRoutes'; import {resolveAdminRoute, routeHash} from './adminRoutes'; @@ -162,6 +163,12 @@ export function AdminApp() { {routeId === 'debug' ? ( ) : null} + {routeId === 'tracking' ? ( + + ) : null} {routeId === 'redeem' ? ( void; +} + +const scopeKindOptions: Array<{value: TrackingScopeKind | ''; label: string}> = [ + {value: '', label: '全部'}, + {value: 'site', label: 'site'}, + {value: 'work', label: 'work'}, + {value: 'module', label: 'module'}, + {value: 'user', label: 'user'}, +]; + +const exportColumns: Array<{ + key: keyof AdminTrackingEventEntryPayload; + label: string; +}> = [ + {key: 'eventId', label: '事件 ID'}, + {key: 'eventKey', label: 'Event Key'}, + {key: 'eventTitle', label: '事件名称'}, + {key: 'scopeKind', label: 'Scope Kind'}, + {key: 'scopeId', label: 'Scope ID'}, + {key: 'dayKey', label: 'Day Key'}, + {key: 'userId', label: 'User ID'}, + {key: 'ownerUserId', label: 'Owner User ID'}, + {key: 'profileId', label: 'Profile ID'}, + {key: 'moduleKey', label: 'Module Key'}, + {key: 'metadataJson', label: 'Metadata JSON'}, + {key: 'occurredAt', label: '发生时间'}, +]; + +export function AdminTrackingEventsPage({ + token, + onUnauthorized, +}: AdminTrackingEventsPageProps) { + const [entries, setEntries] = useState([]); + const [eventKey, setEventKey] = useState(''); + const [userId, setUserId] = useState(''); + const [scopeKind, setScopeKind] = useState(''); + const [scopeId, setScopeId] = useState(''); + const [limit, setLimit] = useState('200'); + const [errorMessage, setErrorMessage] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [detailEntry, setDetailEntry] = + useState(null); + + useEffect(() => { + void refreshTrackingEvents(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [token]); + + const filteredEventDefinitions = useMemo( + () => filterAdminTrackingEventDefinitions(eventKey), + [eventKey], + ); + + async function refreshTrackingEvents() { + setIsLoading(true); + setErrorMessage(''); + try { + const response = await listAdminTrackingEvents(token, { + eventKey, + userId, + scopeKind, + scopeId, + limit: parseLimit(limit), + }); + setEntries(response.entries); + } catch (error: unknown) { + handlePageError(error, onUnauthorized, setErrorMessage); + } finally { + setIsLoading(false); + } + } + + function handleSearch(event: FormEvent) { + event.preventDefault(); + void refreshTrackingEvents(); + } + + function handleExport() { + if (!entries.length) { + setErrorMessage('当前没有可导出的埋点数据'); + return; + } + exportTrackingEventsAsExcel(entries); + } + + return ( +
+
+
+

埋点数据

+

原始事件明细

+
+
+ + +
+
+ +
+
+ + + + + + +
+
+ + {errorMessage ? ( +
+ {errorMessage} +
+ ) : null} + +
+
+

事件明细

+ {entries.length} 条 +
+
+ + + + + + + + + + + + + + {entries.length ? ( + entries.map((entry) => ( + + + + + + + + + + )) + ) : ( + + + + )} + +
事件Scope用户归属Metadata时间详情
+ {resolveEventTitle(entry)} + {entry.eventKey} + {entry.eventId} + + + {entry.scopeKind} + + {entry.scopeId || '-'} + dayKey: {entry.dayKey} + + {entry.userId || '-'} + owner: {entry.ownerUserId || '-'} + + {entry.profileId || '-'} + module: {entry.moduleKey || '-'} + +
+                        {formatMetadataJson(entry.metadataJson)}
+                      
+
{entry.occurredAt || '-'} + +
{isLoading ? '正在加载' : '暂无数据'}
+
+
+ + {detailEntry ? ( + setDetailEntry(null)} + /> + ) : null} +
+ ); +} + +function TrackingEventDetailPanel({ + entry, + onClose, +}: { + entry: AdminTrackingEventEntryPayload; + onClose: () => void; +}) { + return ( +
+
+
+

{resolveEventTitle(entry)}

+ +
+
+ {exportColumns.map((column) => ( +
+
{column.label}
+
+ {column.key === 'metadataJson' ? ( +
+                    {formatMetadataJson(entry.metadataJson)}
+                  
+ ) : ( + formatExportCell(entry[column.key]) || '-' + )} +
+
+ ))} +
+
+
+ ); +} + +function parseLimit(value: string) { + const parsed = Number.parseInt(value.trim(), 10); + if (!Number.isFinite(parsed)) { + return 200; + } + return Math.min(Math.max(parsed, 1), 1000); +} + +function resolveEventTitle(entry: AdminTrackingEventEntryPayload) { + return ( + findAdminTrackingEventDefinition(entry.eventKey)?.title || + entry.eventTitle || + entry.eventKey + ); +} + +function formatMetadataJson(value: string) { + const trimmed = value.trim(); + if (!trimmed) { + return '-'; + } + try { + return JSON.stringify(JSON.parse(trimmed), null, 2); + } catch { + return trimmed; + } +} + +function exportTrackingEventsAsExcel(entries: AdminTrackingEventEntryPayload[]) { + const tableRows = [ + exportColumns.map((column) => `${escapeHtml(column.label)}`).join(''), + ...entries.map((entry) => + exportColumns + .map((column) => `${escapeHtml(formatExportCell(entry[column.key]))}`) + .join(''), + ), + ]; + const html = `\uFEFF${tableRows + .map((row) => `${row}`) + .join('')}
`; + const blob = new Blob([html], {type: 'application/vnd.ms-excel;charset=utf-8'}); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `tracking-events-${buildTimestamp()}.xls`; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); +} + +function formatExportCell(value: unknown) { + if (value === null || typeof value === 'undefined') { + return ''; + } + return String(value); +} + +function escapeHtml(value: string) { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function buildTimestamp() { + const now = new Date(); + const pad = (value: number) => String(value).padStart(2, '0'); + return [ + now.getFullYear(), + pad(now.getMonth() + 1), + pad(now.getDate()), + '-', + pad(now.getHours()), + pad(now.getMinutes()), + pad(now.getSeconds()), + ].join(''); +} diff --git a/apps/admin-web/src/styles/admin.css b/apps/admin-web/src/styles/admin.css index f889baa5..50f08553 100644 --- a/apps/admin-web/src/styles/admin.css +++ b/apps/admin-web/src/styles/admin.css @@ -216,6 +216,10 @@ button:disabled { margin: 0 auto; } +.admin-page-wide { + max-width: 1480px; +} + .admin-page-heading, .admin-panel-heading, .admin-subsection-heading { @@ -291,6 +295,20 @@ button:disabled { align-items: end; } +.admin-filter-grid { + display: grid; + grid-template-columns: repeat(5, minmax(120px, 1fr)) auto; + gap: 12px; + align-items: end; +} + +.admin-action-row { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 10px; +} + .admin-field { display: grid; min-width: 0; @@ -478,6 +496,23 @@ button:disabled { padding: 18px; } +.admin-detail-panel { + display: grid; + width: min(100%, 760px); + max-height: min(90dvh, 760px); + gap: 16px; + overflow: auto; + border: 1px solid #d8e2e8; + border-radius: 10px; + background: #ffffff; + box-shadow: 0 22px 60px rgba(23, 33, 43, 0.24); + padding: 18px; +} + +.admin-detail-list .admin-code-block { + max-height: 280px; +} + .admin-confirm-warning { border: 1px solid #efc894; border-radius: 8px; @@ -602,6 +637,24 @@ button:disabled { min-width: 360px; } +.admin-table-wide { + min-width: 1180px; +} + +.admin-json-preview { + max-width: 360px; + max-height: 160px; + margin: 0; + overflow: auto; + color: #2f4550; + font-family: + "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; + font-size: 12px; + line-height: 1.45; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + .admin-status { display: inline-flex; max-width: 460px; @@ -757,7 +810,8 @@ button:disabled { .admin-overview-grid, .admin-two-column, .admin-two-column-wide, - .admin-form-row { + .admin-form-row, + .admin-filter-grid { grid-template-columns: 1fr; } diff --git a/docs/technical/ADMIN_TRACKING_EVENT_DETAIL_EXPORT_2026-05-07.md b/docs/technical/ADMIN_TRACKING_EVENT_DETAIL_EXPORT_2026-05-07.md new file mode 100644 index 00000000..b7ae603e --- /dev/null +++ b/docs/technical/ADMIN_TRACKING_EVENT_DETAIL_EXPORT_2026-05-07.md @@ -0,0 +1,170 @@ +# 后台埋点数据明细与 Excel 导出方案 + +> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task. + +**Goal:** 在百梦后台新增“埋点数据”页,展示每条埋点原始事件的详细字段,并支持导出为 Excel 可直接打开的表格文件。 + +**Architecture:** 后端继续由 `api-server` 作为后台 BFF,经 SpacetimeDB HTTP SQL 只读查询 `tracking_event`,不改变表结构和 reducer。前端在 `apps/admin-web` 中新增独立路由与页面,页面渲染后端返回的原始明细,并在浏览器侧导出 Excel 兼容的 `.xls` HTML 表格,避免新增依赖。 + +**Tech Stack:** Rust Axum、SpacetimeDB HTTP SQL、shared-contracts、React 19、TypeScript、Vite。 + +--- + +## 范围 + +本次只做后台只读能力: + +- 展示 `tracking_event` 原始事件明细。 +- 每条埋点展示:事件 ID、Event Key、事件名称、Scope、Scope ID、Day Key、用户 ID、作品拥有者、Profile ID、模块、metadata、发生时间。 +- 支持按 Event Key、用户 ID、Scope Kind、Scope ID 筛选。 +- 支持导出当前筛选结果为 Excel 可打开文件。 + +不做: + +- 不新增或修改 SpacetimeDB 表结构。 +- 不在后台写入或删除埋点。 +- 不把埋点聚合口径下沉到前端计算。 + +## 后端契约 + +新增接口: + +```text +GET /admin/api/tracking/events?eventKey=&userId=&scopeKind=&scopeId=&limit= +``` + +鉴权:复用后台 `require_admin_auth`。 + +返回: + +```json +{ + "entries": [ + { + "eventId": "daily-login:user:xxx:123", + "eventKey": "daily_login", + "eventTitle": "每日登录", + "scopeKind": "user", + "scopeId": "xxx", + "dayKey": 20580, + "userId": "xxx", + "ownerUserId": null, + "profileId": null, + "moduleKey": "profile", + "metadataJson": "{}", + "occurredAt": "2026-05-07T00:00:00Z" + } + ] +} +``` + +后端实现要点: + +1. DTO 放在 `shared-contracts/src/admin.rs`,避免 Rust 与前端口径分叉。 +2. Handler 放在 `api-server/src/admin.rs`,使用当前已有 SpacetimeDB HTTP SQL helper 思路。 +3. SQL 只读 `tracking_event`,固定白名单列,按 `occurred_at DESC` 排序,默认 200 条,最大 1000 条。 +4. 查询条件只通过字符串转义函数拼接,禁止直接拼接未转义用户输入。 +5. `eventTitle` 由后端根据已知事件 key 映射,未知事件返回 `eventKey`。 + +## 前端页面 + +新增路由:`#tracking`,导航标题为“埋点数据”。 + +页面能力: + +1. 顶部筛选区:Event Key、用户 ID、Scope Kind、Scope ID、刷新、导出 Excel。 +2. 列表区:移动端可横向滚动,桌面端表格展示。 +3. 详情区:每行有“详情”按钮,弹出独立面板展示完整字段与格式化后的 metadata JSON。 +4. 导出:导出当前页面已加载结果,文件名形如 `tracking-events-2026-05-07.xls`。 + +导出实现: + +- 使用 HTML table + Excel MIME:`application/vnd.ms-excel;charset=utf-8`。 +- 文件扩展名使用 `.xls`,Excel/WPS 可直接打开。 +- 所有单元格做 HTML 转义。 +- metadata 保留原始 JSON 文本,便于运营继续筛选。 + +## 验收命令 + +```bash +npm run check:encoding +npm run admin-web:typecheck +cargo test -p shared-contracts -p api-server admin_tracking -- --nocapture +``` + +如后端接口改动较大,再补充: + +```bash +npm run api-server +curl http://127.0.0.1:/healthz +``` + +## 实施任务 + +### Task 1: 补充 shared-contracts 后台埋点 DTO + +**Files:** +- Modify: `server-rs/crates/shared-contracts/src/admin.rs` + +**Steps:** +1. 新增 `AdminTrackingEventListQuery`。 +2. 新增 `AdminTrackingEventEntryPayload`。 +3. 新增 `AdminTrackingEventListResponse`。 +4. 为 DTO 添加中文注释。 + +### Task 2: 增加后端后台埋点查询接口 + +**Files:** +- Modify: `server-rs/crates/api-server/src/admin.rs` +- Modify: `server-rs/crates/api-server/src/app.rs` + +**Steps:** +1. 在 `admin.rs` 新增 query 解析与 SQL 构造。 +2. 复用 SpacetimeDB HTTP SQL 调用风格读取 rows。 +3. 新增 `admin_list_tracking_events` handler。 +4. 在 `app.rs` 挂载 `/admin/api/tracking/events`。 +5. 添加单元测试覆盖 SQL 字符串转义、limit clamp、SQL 响应解析。 + +### Task 3: 增加前端 API 类型与客户端方法 + +**Files:** +- Modify: `apps/admin-web/src/api/adminApiTypes.ts` +- Modify: `apps/admin-web/src/api/adminApiClient.ts` + +**Steps:** +1. 新增埋点 entry/list/query 类型。 +2. 新增 `listAdminTrackingEvents(token, query)`。 +3. 使用 `URLSearchParams` 拼接非空查询字段。 + +### Task 4: 新增后台埋点数据页面 + +**Files:** +- Create: `apps/admin-web/src/pages/AdminTrackingEventsPage.tsx` +- Modify: `apps/admin-web/src/styles/admin.css` + +**Steps:** +1. 实现筛选、刷新、错误状态。 +2. 实现明细表格。 +3. 实现独立详情面板。 +4. 实现 Excel `.xls` 导出。 +5. 保持 UI 简洁,不添加说明类大段文案。 + +### Task 5: 接入后台路由与导航 + +**Files:** +- Modify: `apps/admin-web/src/app/adminRoutes.ts` +- Modify: `apps/admin-web/src/app/AdminShell.tsx` +- Modify: `apps/admin-web/src/app/AdminApp.tsx` + +**Steps:** +1. 增加 `tracking` 路由。 +2. 导航增加图标。 +3. `AdminApp` 渲染新页面。 + +### Task 6: 验证并提交 + +**Steps:** +1. 运行 `npm run check:encoding`。 +2. 运行 `npm run admin-web:typecheck`。 +3. 运行后端相关 cargo test。 +4. 修复问题后提交并推送当前分支。 diff --git a/server-rs/crates/api-server/src/admin.rs b/server-rs/crates/api-server/src/admin.rs index 00153464..beaeab01 100644 --- a/server-rs/crates/api-server/src/admin.rs +++ b/server-rs/crates/api-server/src/admin.rs @@ -5,7 +5,7 @@ use std::{ use axum::{ Json, - extract::{Extension, Request, State}, + extract::{Extension, Query, Request, State}, http::{ HeaderMap, HeaderName, HeaderValue, Method, StatusCode, header::{AUTHORIZATION, CONTENT_TYPE}, @@ -20,6 +20,7 @@ use shared_contracts::admin::{ AdminDatabaseOverviewPayload, AdminDatabaseTableStatPayload, AdminDebugHeaderInput, AdminDebugHttpRequest, AdminDebugHttpResponse, AdminLoginRequest, AdminLoginResponse, AdminMeResponse, AdminOverviewResponse, AdminServiceOverviewPayload, AdminSessionPayload, + AdminTrackingEventEntryPayload, AdminTrackingEventListQuery, AdminTrackingEventListResponse, }; use time::{OffsetDateTime, format_description::well_known::Rfc3339}; @@ -42,6 +43,8 @@ const BLOCKED_DEBUG_HEADERS: &[&str] = &[ // SpacetimeDB 2.x 的 schema HTTP API 要求显式传入 BSATN JSON 版本。 // 后台总览只读取表名,固定使用当前 CLI 2.1.0 兼容的版本参数即可。 const SPACETIME_SCHEMA_VERSION_QUERY: &str = "version=9"; +const ADMIN_TRACKING_EVENT_DEFAULT_LIMIT: u32 = 200; +const ADMIN_TRACKING_EVENT_MAX_LIMIT: u32 = 1000; #[derive(Clone, Debug)] pub struct AuthenticatedAdmin { @@ -153,6 +156,19 @@ pub async fn admin_debug_http( Ok(json_success_body(Some(&request_context), response)) } +pub async fn admin_list_tracking_events( + State(state): State, + Extension(request_context): Extension, + Extension(_admin): Extension, + Query(query): Query, +) -> Result, AppError> { + let entries = fetch_admin_tracking_events(&state, query).await?; + Ok(json_success_body( + Some(&request_context), + AdminTrackingEventListResponse { entries }, + )) +} + pub async fn require_admin_auth( State(state): State, mut request: Request, @@ -488,6 +504,216 @@ fn parse_count_value(value: &Value) -> Result { } } +async fn fetch_admin_tracking_events( + state: &AppState, + query: AdminTrackingEventListQuery, +) -> Result, AppError> { + let client = Client::new(); + let server_root = state.config.spacetime_server_url.trim_end_matches('/'); + let database = state.config.spacetime_database.trim(); + let token = state + .config + .spacetime_token + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + let sql = build_admin_tracking_events_sql(&query) + .map_err(|error| AppError::from_status(StatusCode::BAD_REQUEST).with_message(error))?; + + let payload = fetch_spacetime_sql_json(&client, server_root, database, token, &sql) + .await + .map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY) + .with_message(format!("埋点数据读取失败:{error}")) + })?; + parse_admin_tracking_events_sql_response(payload).map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY) + .with_message(format!("埋点数据解析失败:{error}")) + }) +} + +fn build_admin_tracking_events_sql(query: &AdminTrackingEventListQuery) -> Result { + let mut conditions = Vec::new(); + if let Some(value) = normalized_non_empty(query.event_key.as_deref()) { + conditions.push(format!("event_key = {}", quote_sql_string(value))); + } + if let Some(value) = normalized_non_empty(query.user_id.as_deref()) { + conditions.push(format!("user_id = {}", quote_sql_string(value))); + } + if let Some(value) = normalized_non_empty(query.scope_kind.as_deref()) { + let scope_kind = normalize_admin_tracking_scope_kind(value)?; + conditions.push(format!("scope_kind = {}", quote_sql_string(scope_kind))); + } + if let Some(value) = normalized_non_empty(query.scope_id.as_deref()) { + conditions.push(format!("scope_id = {}", quote_sql_string(value))); + } + + let where_clause = if conditions.is_empty() { + String::new() + } else { + format!(" WHERE {}", conditions.join(" AND ")) + }; + let limit = clamp_admin_tracking_event_limit(query.limit); + Ok(format!( + "SELECT event_id, event_key, scope_kind, scope_id, day_key, user_id, owner_user_id, profile_id, module_key, metadata_json, occurred_at FROM tracking_event{where_clause} ORDER BY occurred_at DESC LIMIT {limit}" + )) +} + +fn normalized_non_empty(value: Option<&str>) -> Option<&str> { + value.map(str::trim).filter(|value| !value.is_empty()) +} + +fn quote_sql_string(value: &str) -> String { + format!("'{}'", value.replace('\'', "''")) +} + +fn normalize_admin_tracking_scope_kind(value: &str) -> Result<&'static str, String> { + match value.trim().to_ascii_lowercase().as_str() { + "site" => Ok("site"), + "work" => Ok("work"), + "module" => Ok("module"), + "user" => Ok("user"), + _ => Err("scopeKind 必须是 site/work/module/user".to_string()), + } +} + +fn clamp_admin_tracking_event_limit(limit: Option) -> u32 { + limit + .unwrap_or(ADMIN_TRACKING_EVENT_DEFAULT_LIMIT) + .clamp(1, ADMIN_TRACKING_EVENT_MAX_LIMIT) +} + +async fn fetch_spacetime_sql_json( + client: &Client, + server_root: &str, + database: &str, + token: Option<&str>, + sql: &str, +) -> Result { + let mut request = client + .post(format!("{server_root}/v1/database/{database}/sql")) + .header(CONTENT_TYPE, "text/plain; charset=utf-8") + .body(sql.to_string()); + if let Some(token) = token { + request = request.bearer_auth(token); + } + + let response = request + .send() + .await + .map_err(|error| format!("SQL 请求失败:{error}"))?; + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(format!("HTTP {}:{}", status.as_u16(), trim_preview(&body))); + } + + response + .json::() + .await + .map_err(|error| format!("SQL 响应解析失败:{error}")) +} + +fn parse_admin_tracking_events_sql_response( + payload: Value, +) -> Result, String> { + let rows = extract_first_sql_rows(payload)?; + rows.iter() + .map(parse_admin_tracking_event_row) + .collect::, _>>() +} + +fn extract_first_sql_rows(payload: Value) -> Result, String> { + let statement = match payload { + Value::Array(statements) => statements + .into_iter() + .next() + .ok_or_else(|| "SQL 结果为空".to_string())?, + Value::Object(statement) => Value::Object(statement), + _ => return Err("SQL 响应格式非法".to_string()), + }; + let Value::Object(mut statement) = statement else { + return Err("SQL statement 结果格式非法".to_string()); + }; + let rows = statement + .remove("rows") + .ok_or_else(|| "SQL 响应缺少 rows 字段".to_string())?; + match rows { + Value::Array(rows) => Ok(rows), + _ => Err("SQL rows 字段格式非法".to_string()), + } +} + +fn parse_admin_tracking_event_row(row: &Value) -> Result { + let columns = row.as_array().ok_or_else(|| "埋点行格式非法".to_string())?; + let event_key = required_string_column(columns, 1, "event_key")?; + Ok(AdminTrackingEventEntryPayload { + event_id: required_string_column(columns, 0, "event_id")?, + event_title: admin_tracking_event_title(&event_key).to_string(), + event_key, + scope_kind: required_string_column(columns, 2, "scope_kind")?, + scope_id: required_string_column(columns, 3, "scope_id")?, + day_key: required_i64_column(columns, 4, "day_key")?, + user_id: optional_string_column(columns, 5), + owner_user_id: optional_string_column(columns, 6), + profile_id: optional_string_column(columns, 7), + module_key: optional_string_column(columns, 8), + metadata_json: required_string_column(columns, 9, "metadata_json")?, + occurred_at: required_string_column(columns, 10, "occurred_at")?, + }) +} + +fn required_string_column( + columns: &[Value], + index: usize, + field_name: &str, +) -> Result { + value_to_string( + columns + .get(index) + .ok_or_else(|| format!("埋点行缺少 {field_name}"))?, + ) + .ok_or_else(|| format!("埋点行 {field_name} 不是字符串")) +} + +fn optional_string_column(columns: &[Value], index: usize) -> Option { + columns.get(index).and_then(value_to_string) +} + +fn required_i64_column(columns: &[Value], index: usize, field_name: &str) -> Result { + let value = columns + .get(index) + .ok_or_else(|| format!("埋点行缺少 {field_name}"))?; + match value { + Value::Number(number) => number + .as_i64() + .ok_or_else(|| format!("埋点行 {field_name} 不是整数")), + Value::String(text) => text + .trim() + .parse::() + .map_err(|error| format!("埋点行 {field_name} 解析失败:{error}")), + _ => Err(format!("埋点行 {field_name} 类型非法")), + } +} + +fn value_to_string(value: &Value) -> Option { + match value { + Value::Null => None, + Value::String(text) => Some(text.clone()), + Value::Object(object) => object.get("some").and_then(value_to_string), + Value::Number(number) => Some(number.to_string()), + Value::Bool(value) => Some(value.to_string()), + _ => Some(value.to_string()), + } +} + +fn admin_tracking_event_title(event_key: &str) -> &str { + match event_key { + "daily_login" => "每日登录", + _ => event_key, + } +} + async fn execute_admin_debug_http( state: &AppState, payload: AdminDebugHttpRequest, @@ -648,12 +874,14 @@ fn build_admin_session_payload(session: crate::state::AdminSession) -> AdminSess #[cfg(test)] mod tests { use super::{ - build_body_preview, build_debug_base_url, build_spacetime_schema_url, - is_safe_spacetime_table_name, normalize_debug_path, normalize_table_count_error, - parse_spacetime_sql_count_response, trim_preview, + build_admin_tracking_events_sql, build_body_preview, build_debug_base_url, + build_spacetime_schema_url, clamp_admin_tracking_event_limit, is_safe_spacetime_table_name, + normalize_debug_path, normalize_table_count_error, + parse_admin_tracking_events_sql_response, parse_spacetime_sql_count_response, trim_preview, }; use axum::{http::StatusCode, response::IntoResponse}; use serde_json::json; + use shared_contracts::admin::AdminTrackingEventListQuery; #[test] fn normalize_debug_path_rejects_absolute_url() { @@ -816,6 +1044,61 @@ mod tests { assert_eq!(count, 3); } + #[test] + fn build_admin_tracking_events_sql_quotes_filters_and_clamps_limit() { + let sql = build_admin_tracking_events_sql(&AdminTrackingEventListQuery { + event_key: Some("daily'login".to_string()), + user_id: Some("user-1".to_string()), + scope_kind: Some("USER".to_string()), + scope_id: Some("scope-1".to_string()), + limit: Some(2000), + }) + .expect("tracking sql should build"); + + assert!(sql.contains("event_key = 'daily''login'")); + assert!(sql.contains("user_id = 'user-1'")); + assert!(sql.contains("scope_kind = 'user'")); + assert!(sql.contains("scope_id = 'scope-1'")); + assert!(sql.ends_with("LIMIT 1000")); + } + + #[test] + fn clamp_admin_tracking_event_limit_uses_default_and_bounds() { + assert_eq!(clamp_admin_tracking_event_limit(None), 200); + assert_eq!(clamp_admin_tracking_event_limit(Some(0)), 1); + assert_eq!(clamp_admin_tracking_event_limit(Some(1001)), 1000); + } + + #[test] + fn parse_admin_tracking_events_sql_response_accepts_statement_array_rows() { + let payload = json!([ + { + "rows": [[ + "event-1", + "daily_login", + "user", + "user-1", + 20580, + {"some": "user-1"}, + null, + {"some": "profile-1"}, + "profile", + "{\"source\":\"task\"}", + "2026-05-07T00:00:00Z" + ]] + } + ]); + + let entries = + parse_admin_tracking_events_sql_response(payload).expect("tracking rows should parse"); + + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].event_id, "event-1"); + assert_eq!(entries[0].event_title, "每日登录"); + assert_eq!(entries[0].user_id.as_deref(), Some("user-1")); + assert_eq!(entries[0].profile_id.as_deref(), Some("profile-1")); + } + #[test] fn build_body_preview_handles_utf8() { let preview = build_body_preview("后台测试".as_bytes()); diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 6691a4d9..ee0f9139 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -13,7 +13,10 @@ use tower_http::{ use tracing::{Level, Span, error, info, info_span, warn}; use crate::{ - admin::{admin_debug_http, admin_login, admin_me, admin_overview, require_admin_auth}, + admin::{ + admin_debug_http, admin_list_tracking_events, admin_login, admin_me, admin_overview, + require_admin_auth, + }, ai_tasks::{ append_ai_text_chunk, attach_ai_result_reference, cancel_ai_task, complete_ai_stage, complete_ai_task, create_ai_task, fail_ai_task, start_ai_task, start_ai_task_stage, @@ -168,6 +171,13 @@ pub fn build_router(state: AppState) -> Router { require_admin_auth, )), ) + .route( + "/admin/api/tracking/events", + get(admin_list_tracking_events).route_layer(middleware::from_fn_with_state( + state.clone(), + require_admin_auth, + )), + ) .route( "/admin/api/profile/redeem-codes", get(admin_list_profile_redeem_codes) diff --git a/server-rs/crates/shared-contracts/src/admin.rs b/server-rs/crates/shared-contracts/src/admin.rs index 66ea8486..81fe5e33 100644 --- a/server-rs/crates/shared-contracts/src/admin.rs +++ b/server-rs/crates/shared-contracts/src/admin.rs @@ -105,3 +105,39 @@ pub struct AdminDebugHttpResponse { pub body_text: String, pub body_json: Option, } + +// 后台埋点明细查询参数只保留运营筛选需要的只读字段。 +#[derive(Clone, Debug, Serialize, Deserialize, Default, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AdminTrackingEventListQuery { + pub event_key: Option, + pub user_id: Option, + pub scope_kind: Option, + pub scope_id: Option, + pub limit: Option, +} + +// 单条埋点原始事件明细,字段与 tracking_event 表一一对应并补充事件名称。 +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AdminTrackingEventEntryPayload { + pub event_id: String, + pub event_key: String, + pub event_title: String, + pub scope_kind: String, + pub scope_id: String, + pub day_key: i64, + pub user_id: Option, + pub owner_user_id: Option, + pub profile_id: Option, + pub module_key: Option, + pub metadata_json: String, + pub occurred_at: String, +} + +// 后台埋点明细列表响应,前端导出 Excel 时直接使用 entries。 +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AdminTrackingEventListResponse { + pub entries: Vec, +}