diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index 5df358f76..c08b03d85 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -15,6 +15,7 @@ import type { AdminMeResponse, AdminOverviewResponse, AdminTrackingEventListQuery, + AdminTrackingEventKeyListResponse, AdminTrackingEventListResponse, AdminUpdateWorkVisibilityRequest, AdminUpdateWorkVisibilityResponse, @@ -192,6 +193,13 @@ export function listAdminTrackingEvents( ); } +export function listAdminTrackingEventKeys(token: string) { + return request( + '/admin/api/tracking/event-keys', + { token }, + ); +} + export function getAdminCreationEntryConfig(token: string) { return request( '/admin/api/creation-entry/config', @@ -430,9 +438,14 @@ function buildQueryString(query: AdminTrackingEventListQuery) { appendQueryParam(params, 'userId', query.userId); appendQueryParam(params, 'scopeKind', query.scopeKind); appendQueryParam(params, 'scopeId', query.scopeId); + appendQueryParam(params, 'startDate', query.startDate); + appendQueryParam(params, 'endDate', query.endDate); if (typeof query.limit === 'number' && Number.isFinite(query.limit)) { params.set('limit', String(query.limit)); } + if (query.exportAll) { + params.set('exportAll', 'true'); + } const queryString = params.toString(); return queryString ? `?${queryString}` : ''; } diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 7e4617954..6cc8bd9f2 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -206,7 +206,10 @@ export interface AdminTrackingEventListQuery { userId?: string; scopeKind?: TrackingScopeKind | ''; scopeId?: string; + startDate?: string; + endDate?: string; limit?: number; + exportAll?: boolean; } /** 后台创作入口配置响应,同时包含模板入口和独立公告配置。 */ @@ -503,3 +506,13 @@ export interface AdminTrackingEventEntryPayload { export interface AdminTrackingEventListResponse { entries: AdminTrackingEventEntryPayload[]; } + +export interface AdminTrackingEventKeyPayload { + eventKey: string; + eventTitle: string; + scopeKinds: string[]; +} + +export interface AdminTrackingEventKeyListResponse { + eventKeys: AdminTrackingEventKeyPayload[]; +} diff --git a/apps/admin-web/src/config/trackingEventDefinitions.test.ts b/apps/admin-web/src/config/trackingEventDefinitions.test.ts index 0c04ba473..77523d548 100644 --- a/apps/admin-web/src/config/trackingEventDefinitions.test.ts +++ b/apps/admin-web/src/config/trackingEventDefinitions.test.ts @@ -1,16 +1,20 @@ -import {describe, expect, test} from 'vitest'; +import { describe, expect, test } from 'vitest'; import { adminProfileTaskTrackingEventDefinitions, adminTrackingEventDefinitions, + buildAdminTrackingEventKeyOptions, filterAdminProfileTaskTrackingEventDefinitions, + filterAdminTrackingEventKeyOptions, filterAdminTrackingEventDefinitions, findAdminTrackingEventDefinition, } from './trackingEventDefinitions'; describe('admin tracking event definitions', () => { test('后台埋点筛选候选包含后端通用埋点清单', () => { - const keys = adminTrackingEventDefinitions.map((definition) => definition.key); + const keys = adminTrackingEventDefinitions.map( + (definition) => definition.key, + ); expect(keys.length).toBeGreaterThan(40); expect(keys).toContain('daily_login'); @@ -22,21 +26,48 @@ describe('admin tracking event definitions', () => { }); test('任务配置候选只开放适合个人任务的事件', () => { - expect(adminProfileTaskTrackingEventDefinitions.map(({key}) => key)).toEqual([ - 'daily_login', - ]); - expect(filterAdminProfileTaskTrackingEventDefinitions('').map(({key}) => key)).toEqual([ - 'daily_login', + expect( + adminProfileTaskTrackingEventDefinitions.map(({ key }) => key), + ).toEqual(['daily_login']); + expect( + filterAdminProfileTaskTrackingEventDefinitions('').map(({ key }) => key), + ).toEqual(['daily_login']); + }); + + test('后台埋点 key 候选从真实库响应合并静态标题', () => { + const options = buildAdminTrackingEventKeyOptions([ + { + eventKey: 'work_play_start', + eventTitle: 'work_play_start', + scopeKinds: ['work', 'user'], + }, + { + eventKey: 'unknown_event', + eventTitle: '未知事件', + scopeKinds: ['user'], + }, ]); + + expect(options.find(({ key }) => key === 'work_play_start')?.title).toBe( + '作品开始游玩', + ); + expect(options.find(({ key }) => key === 'unknown_event')?.title).toBe( + '未知事件', + ); + expect( + filterAdminTrackingEventKeyOptions(options, '作品').map(({ key }) => key), + ).toEqual(['work_play_start']); }); test('后台埋点筛选支持按中文名称和 key 搜索', () => { - expect(filterAdminTrackingEventDefinitions('上传票据').map(({key}) => key)).toEqual([ - 'asset_upload_ticket_create', - ]); - expect(filterAdminTrackingEventDefinitions('work_play').map(({key}) => key)).toEqual([ - 'work_play_start', - ]); - expect(findAdminTrackingEventDefinition(' daily_login ')?.title).toBe('每日登录'); + expect( + filterAdminTrackingEventDefinitions('上传票据').map(({ key }) => key), + ).toEqual(['asset_upload_ticket_create']); + expect( + filterAdminTrackingEventDefinitions('work_play').map(({ key }) => key), + ).toEqual(['work_play_start']); + expect(findAdminTrackingEventDefinition(' daily_login ')?.title).toBe( + '每日登录', + ); }); }); diff --git a/apps/admin-web/src/config/trackingEventDefinitions.ts b/apps/admin-web/src/config/trackingEventDefinitions.ts index db7cf8fd2..43de6e7e5 100644 --- a/apps/admin-web/src/config/trackingEventDefinitions.ts +++ b/apps/admin-web/src/config/trackingEventDefinitions.ts @@ -1,4 +1,7 @@ -import type {TrackingScopeKind} from '../api/adminApiTypes'; +import type { + AdminTrackingEventKeyPayload, + TrackingScopeKind, +} from '../api/adminApiTypes'; export interface AdminTrackingEventDefinition { key: string; @@ -8,6 +11,14 @@ export interface AdminTrackingEventDefinition { taskConfigEligible?: boolean; } +export interface AdminTrackingEventKeyOption { + key: string; + title: string; + scopeKind: TrackingScopeKind | string; + scopeKinds: string[]; + remark: string; +} + export const adminTrackingEventDefinitions: AdminTrackingEventDefinition[] = [ { key: 'auth_login_options_view', @@ -25,7 +36,8 @@ export const adminTrackingEventDefinitions: AdminTrackingEventDefinition[] = [ key: 'daily_login', title: '每日登录', scopeKind: 'user', - remark: '认证成功或 refresh 续期后由后端幂等记录,用于每日登录任务进度校验。', + remark: + '认证成功或 refresh 续期后由后端幂等记录,用于每日登录任务进度校验。', taskConfigEligible: true, }, { @@ -368,7 +380,8 @@ export const adminTrackingEventDefinitions: AdminTrackingEventDefinition[] = [ key: 'match3d_route_success', title: '抓大鹅路由成功', scopeKind: 'user', - remark: '抓大鹅创作或运行接口成功响应后兜底记录;GET 入口可能按 site 统计。', + remark: + '抓大鹅创作或运行接口成功响应后兜底记录;GET 入口可能按 site 统计。', }, { key: 'square_hole_route_success', @@ -392,12 +405,15 @@ export const adminTrackingEventDefinitions: AdminTrackingEventDefinition[] = [ key: 'work_play_start', title: '作品开始游玩', scopeKind: 'work', - remark: '拼图、抓大鹅、方洞、自定义世界、大鱼吃小鱼、Visual Novel 正式开始游玩时记录。', + remark: + '拼图、抓大鹅、方洞、自定义世界、大鱼吃小鱼、Visual Novel 正式开始游玩时记录。', }, ]; export const adminProfileTaskTrackingEventDefinitions = - adminTrackingEventDefinitions.filter((definition) => definition.taskConfigEligible); + adminTrackingEventDefinitions.filter( + (definition) => definition.taskConfigEligible, + ); export function findAdminTrackingEventDefinition(eventKey: string) { const normalizedEventKey = eventKey.trim(); @@ -412,6 +428,41 @@ export function filterAdminTrackingEventDefinitions(query: string) { return filterTrackingEventDefinitions(adminTrackingEventDefinitions, query); } +export function buildAdminTrackingEventKeyOptions( + eventKeys: AdminTrackingEventKeyPayload[], +): AdminTrackingEventKeyOption[] { + const options = new Map(); + for (const payload of eventKeys) { + const key = payload.eventKey.trim(); + if (!key || options.has(key)) { + continue; + } + const definition = findAdminTrackingEventDefinition(key); + const scopeKinds = payload.scopeKinds.length + ? payload.scopeKinds + : definition + ? [definition.scopeKind] + : []; + options.set(key, { + key, + title: definition?.title ?? (payload.eventTitle || key), + scopeKind: definition?.scopeKind ?? scopeKinds[0] ?? '', + scopeKinds, + remark: definition?.remark ?? '真实埋点库中已出现的 event key。', + }); + } + return [...options.values()].sort((left, right) => + left.key.localeCompare(right.key), + ); +} + +export function filterAdminTrackingEventKeyOptions( + options: AdminTrackingEventKeyOption[], + query: string, +) { + return filterTrackingEventDefinitions(options, query); +} + export function filterAdminProfileTaskTrackingEventDefinitions(query: string) { return filterTrackingEventDefinitions( adminProfileTaskTrackingEventDefinitions, @@ -420,7 +471,9 @@ export function filterAdminProfileTaskTrackingEventDefinitions(query: string) { } function filterTrackingEventDefinitions( - definitions: AdminTrackingEventDefinition[], + definitions: Array< + AdminTrackingEventDefinition | AdminTrackingEventKeyOption + >, query: string, ) { const normalizedQuery = query.trim().toLowerCase(); diff --git a/apps/admin-web/src/pages/AdminTaskConfigPage.tsx b/apps/admin-web/src/pages/AdminTaskConfigPage.tsx index cb146085a..1c34d6a6c 100644 --- a/apps/admin-web/src/pages/AdminTaskConfigPage.tsx +++ b/apps/admin-web/src/pages/AdminTaskConfigPage.tsx @@ -1,22 +1,25 @@ -import {ChevronDown, PowerOff, RefreshCcw, Save} from 'lucide-react'; -import {FormEvent, useEffect, useMemo, useState} from 'react'; +import { ChevronDown, PowerOff, RefreshCcw, Save } from 'lucide-react'; +import { FormEvent, useEffect, useMemo, useState } from 'react'; import { disableProfileTaskConfig, + listAdminTrackingEventKeys, listProfileTaskConfigs, upsertProfileTaskConfig, } from '../api/adminApiClient'; import type { + AdminTrackingEventKeyPayload, ProfileTaskConfigAdminResponse, ProfileTaskCycle, TrackingScopeKind, } from '../api/adminApiTypes'; -import {useAdminWriteConfirm} from '../components/useAdminWriteConfirm'; +import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm'; import { - filterAdminProfileTaskTrackingEventDefinitions, + buildAdminTrackingEventKeyOptions, + filterAdminTrackingEventKeyOptions, findAdminTrackingEventDefinition, } from '../config/trackingEventDefinitions'; -import {handlePageError} from './pageUtils'; +import { handlePageError } from './pageUtils'; interface AdminTaskConfigPageProps { token: string; @@ -25,8 +28,8 @@ interface AdminTaskConfigPageProps { onResultChange: (result: ProfileTaskConfigAdminResponse) => void; } -const taskCycles: Array<{value: ProfileTaskCycle; label: string}> = [ - {value: 'daily', label: '每日'}, +const taskCycles: Array<{ value: ProfileTaskCycle; label: string }> = [ + { value: 'daily', label: '每日' }, ]; const profileTaskScopeKind = 'user' satisfies TrackingScopeKind; @@ -43,6 +46,9 @@ export function AdminTaskConfigPage({ const [description, setDescription] = useState(''); const [eventKey, setEventKey] = useState('daily_login'); const [eventKeySearch, setEventKeySearch] = useState('每日登录'); + const [eventKeys, setEventKeys] = useState( + [], + ); const [isEventKeyPickerOpen, setIsEventKeyPickerOpen] = useState(false); const [cycle, setCycle] = useState('daily'); const [threshold, setThreshold] = useState('1'); @@ -56,22 +62,53 @@ export function AdminTaskConfigPage({ const [isSaving, setIsSaving] = useState(false); const [isDisabling, setIsDisabling] = useState(false); const [isLoading, setIsLoading] = useState(false); - const {confirmWrite, confirmDialog} = useAdminWriteConfirm(); + const { confirmWrite, confirmDialog } = useAdminWriteConfirm(); useEffect(() => { void refreshTaskConfigs(); + void refreshTrackingEventKeys(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [token]); - const selectedEventDefinition = useMemo( - () => findAdminTrackingEventDefinition(eventKey), - [eventKey], + const eventKeyOptions = useMemo( + () => + buildAdminTrackingEventKeyOptions(eventKeys).filter((option) => + option.scopeKinds.includes(profileTaskScopeKind), + ), + [eventKeys], + ); + const selectedEventOption = useMemo( + () => + eventKeyOptions.find((option) => option.key === eventKey) ?? + findAdminTrackingEventDefinition(eventKey), + [eventKey, eventKeyOptions], ); const filteredEventDefinitions = useMemo( - () => filterAdminProfileTaskTrackingEventDefinitions(eventKeySearch), - [eventKeySearch], + () => filterAdminTrackingEventKeyOptions(eventKeyOptions, eventKeySearch), + [eventKeyOptions, eventKeySearch], ); + useEffect(() => { + if (eventKeySearch !== eventKey) { + return; + } + const nextOption = + eventKeyOptions.find((option) => option.key === eventKey) ?? + findAdminTrackingEventDefinition(eventKey); + if (nextOption) { + setEventKeySearch(nextOption.title); + } + }, [eventKey, eventKeyOptions, eventKeySearch]); + + async function refreshTrackingEventKeys() { + try { + const response = await listAdminTrackingEventKeys(token); + setEventKeys(response.eventKeys); + } catch { + setEventKeys([]); + } + } + async function refreshTaskConfigs() { setIsLoading(true); setListErrorMessage(''); @@ -182,16 +219,20 @@ export function AdminTaskConfigPage({ setSortOrder(String(entry.sortOrder)); setEnabled(entry.enabled); setDisableTaskId(entry.taskId); - const nextDefinition = findAdminTrackingEventDefinition(entry.eventKey); - setEventKeySearch(nextDefinition?.title ?? entry.eventKey); + const nextOption = + eventKeyOptions.find((option) => option.key === entry.eventKey) ?? + findAdminTrackingEventDefinition(entry.eventKey); + setEventKeySearch(nextOption?.title ?? entry.eventKey); setIsEventKeyPickerOpen(false); } function selectEventKey(nextEventKey: string) { - const nextDefinition = findAdminTrackingEventDefinition(nextEventKey); + const nextOption = + eventKeyOptions.find((option) => option.key === nextEventKey) ?? + findAdminTrackingEventDefinition(nextEventKey); setEventKey(nextEventKey); - if (nextDefinition) { - setEventKeySearch(nextDefinition.title); + if (nextOption) { + setEventKeySearch(nextOption.title); } else { setEventKeySearch(nextEventKey); } @@ -322,9 +363,9 @@ export function AdminTaskConfigPage({ ) : null} - {selectedEventDefinition ? ( + {selectedEventOption ? ( - {selectedEventDefinition.remark} + {selectedEventOption.remark} ) : ( diff --git a/apps/admin-web/src/pages/AdminTrackingEventsPage.tsx b/apps/admin-web/src/pages/AdminTrackingEventsPage.tsx index a0b79c5fc..3c9f5d2e7 100644 --- a/apps/admin-web/src/pages/AdminTrackingEventsPage.tsx +++ b/apps/admin-web/src/pages/AdminTrackingEventsPage.tsx @@ -1,46 +1,55 @@ -import {Download, Eye, RefreshCcw, Search, X} from 'lucide-react'; -import {FormEvent, useEffect, useMemo, useState} from 'react'; +import { Download, Eye, RefreshCcw, Search, X } from 'lucide-react'; +import { FormEvent, useEffect, useMemo, useState } from 'react'; -import {listAdminTrackingEvents} from '../api/adminApiClient'; +import { + listAdminTrackingEventKeys, + listAdminTrackingEvents, +} from '../api/adminApiClient'; import type { AdminTrackingEventEntryPayload, + AdminTrackingEventKeyPayload, TrackingScopeKind, } from '../api/adminApiTypes'; import { + buildAdminTrackingEventKeyOptions, + filterAdminTrackingEventKeyOptions, filterAdminTrackingEventDefinitions, findAdminTrackingEventDefinition, } from '../config/trackingEventDefinitions'; -import {handlePageError} from './pageUtils'; +import { handlePageError } from './pageUtils'; interface AdminTrackingEventsPageProps { token: string; onUnauthorized: (message?: string) => 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 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: '发生时间'}, + { 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({ @@ -52,21 +61,47 @@ export function AdminTrackingEventsPage({ const [userId, setUserId] = useState(''); const [scopeKind, setScopeKind] = useState(''); const [scopeId, setScopeId] = useState(''); + const [startDate, setStartDate] = useState(''); + const [endDate, setEndDate] = useState(''); const [limit, setLimit] = useState('200'); + const [eventKeys, setEventKeys] = useState( + [], + ); const [errorMessage, setErrorMessage] = useState(''); const [isLoading, setIsLoading] = useState(false); + const [isExportingAll, setIsExportingAll] = useState(false); const [detailEntry, setDetailEntry] = useState(null); useEffect(() => { + void refreshTrackingEventKeys(); void refreshTrackingEvents(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [token]); - const filteredEventDefinitions = useMemo( - () => filterAdminTrackingEventDefinitions(eventKey), - [eventKey], + const eventKeyOptions = useMemo( + () => buildAdminTrackingEventKeyOptions(eventKeys), + [eventKeys], ); + const filteredEventDefinitions = useMemo(() => { + const dynamicOptions = filterAdminTrackingEventKeyOptions( + eventKeyOptions, + eventKey, + ); + if (dynamicOptions.length) { + return dynamicOptions; + } + return filterAdminTrackingEventDefinitions(eventKey); + }, [eventKey, eventKeyOptions]); + + async function refreshTrackingEventKeys() { + try { + const response = await listAdminTrackingEventKeys(token); + setEventKeys(response.eventKeys); + } catch { + setEventKeys([]); + } + } async function refreshTrackingEvents() { setIsLoading(true); @@ -77,6 +112,8 @@ export function AdminTrackingEventsPage({ userId, scopeKind, scopeId, + startDate, + endDate, limit: parseLimit(limit), }); setEntries(response.entries); @@ -100,6 +137,32 @@ export function AdminTrackingEventsPage({ exportTrackingEventsAsExcel(entries); } + async function handleExportAll() { + setIsExportingAll(true); + setErrorMessage(''); + try { + const response = await listAdminTrackingEvents(token, { + eventKey, + userId, + scopeKind, + scopeId, + startDate, + endDate, + limit: 100_000, + exportAll: true, + }); + if (!response.entries.length) { + setErrorMessage('当前没有可导出的埋点数据'); + return; + } + exportTrackingEventsAsExcel(response.entries); + } catch (error: unknown) { + handlePageError(error, onUnauthorized, setErrorMessage); + } finally { + setIsExportingAll(false); + } + } + return (
@@ -110,7 +173,7 @@ export function AdminTrackingEventsPage({
+
@@ -186,7 +258,27 @@ export function AdminTrackingEventsPage({ onChange={(event) => setLimit(event.target.value)} /> - @@ -353,9 +445,13 @@ function formatMetadataJson(value: string) { } } -function exportTrackingEventsAsExcel(entries: AdminTrackingEventEntryPayload[]) { +function exportTrackingEventsAsExcel( + entries: AdminTrackingEventEntryPayload[], +) { const tableRows = [ - exportColumns.map((column) => `${escapeHtml(column.label)}`).join(''), + exportColumns + .map((column) => `${escapeHtml(column.label)}`) + .join(''), ...entries.map((entry) => exportColumns .map( @@ -368,7 +464,9 @@ function exportTrackingEventsAsExcel(entries: AdminTrackingEventEntryPayload[]) const html = `\uFEFF${tableRows .map((row) => `${row}`) .join('')}
`; - const blob = new Blob([html], {type: 'application/vnd.ms-excel;charset=utf-8'}); + 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; @@ -379,7 +477,10 @@ function exportTrackingEventsAsExcel(entries: AdminTrackingEventEntryPayload[]) URL.revokeObjectURL(url); } -function formatExportCell(value: unknown, key?: keyof AdminTrackingEventEntryPayload) { +function formatExportCell( + value: unknown, + key?: keyof AdminTrackingEventEntryPayload, +) { if (value === null || typeof value === 'undefined') { return ''; } diff --git a/docs/technical/【后台管理】Dashboard运营看板方案-2026-06-23.md b/docs/technical/【后台管理】Dashboard运营看板方案-2026-06-23.md index d9a618bc5..79ef1e63d 100644 --- a/docs/technical/【后台管理】Dashboard运营看板方案-2026-06-23.md +++ b/docs/technical/【后台管理】Dashboard运营看板方案-2026-06-23.md @@ -35,6 +35,13 @@ - 前端页面:`apps/admin-web/src/pages/AdminDashboardPage.tsx` - 后台路由:`apps/admin-web/src/app/adminRoutes.ts` +## 埋点后台查询 + +- `GET /admin/api/tracking/events` 读取 `tracking_event` 原始事实,支持 `eventKey`、`userId`、`scopeKind`、`scopeId`、`startDate`、`endDate` 和 `limit` 筛选。日期使用北京时间日历日期 `YYYY-MM-DD`,后端转换为 `day_key` 闭区间。 +- 日常列表查询仍限制最多 1000 条;后台“导出全部”传 `exportAll=true`,上限放宽到 100000 条,仍复用当前筛选条件和日期范围。 +- `GET /admin/api/tracking/event-keys` 从已落库的 `tracking_event` 中扫描真实 `event_key` 和出现过的 `scope_kind`,由 api-server 去重后返回,前端只用静态清单补中文标题和备注。 +- 任务配置页的 Event Key 候选使用同一个真实 key 列表,并只默认展示出现过 `user` scope 的 key;自定义 key 入口保留给提前配置未写入的新埋点。 + ## 验证 - `cargo test -p api-server --manifest-path server-rs/Cargo.toml admin` diff --git a/server-rs/crates/api-server/src/admin.rs b/server-rs/crates/api-server/src/admin.rs index bc0f39f7c..37c1e5a4f 100644 --- a/server-rs/crates/api-server/src/admin.rs +++ b/server-rs/crates/api-server/src/admin.rs @@ -27,7 +27,8 @@ use shared_contracts::admin::{ AdminDatabaseTableStatPayload, AdminDebugHeaderInput, AdminDebugHttpRequest, AdminDebugHttpResponse, AdminLoginRequest, AdminLoginResponse, AdminMeResponse, AdminOverviewResponse, AdminServiceOverviewPayload, AdminSessionPayload, - AdminTrackingEventEntryPayload, AdminTrackingEventListQuery, AdminTrackingEventListResponse, + AdminTrackingEventEntryPayload, AdminTrackingEventKeyListResponse, + AdminTrackingEventKeyPayload, AdminTrackingEventListQuery, AdminTrackingEventListResponse, AdminUpdateWorkVisibilityRequest, AdminUpdateWorkVisibilityResponse, AdminUpsertCreationEntryEventBannersRequest, AdminUpsertCreationEntryTypeConfigRequest, AdminUpsertPublicWorkInteractionConfigRequest, AdminWorkVisibilityListResponse, @@ -59,6 +60,8 @@ const BLOCKED_DEBUG_HEADERS: &[&str] = &[ const SPACETIME_SCHEMA_VERSION_QUERY: &str = "version=9"; const ADMIN_TRACKING_EVENT_DEFAULT_LIMIT: u32 = 200; const ADMIN_TRACKING_EVENT_MAX_LIMIT: u32 = 1000; +const ADMIN_TRACKING_EVENT_EXPORT_LIMIT: u32 = 100_000; +const ADMIN_TRACKING_EVENT_KEY_SCAN_LIMIT: u32 = 50_000; const ADMIN_DATABASE_TABLE_DEFAULT_LIMIT: u32 = 100; const ADMIN_DATABASE_TABLE_MAX_LIMIT: u32 = 500; const ADMIN_DASHBOARD_ROW_LIMIT: u32 = 50_000; @@ -198,6 +201,18 @@ pub async fn admin_list_tracking_events( )) } +pub async fn admin_list_tracking_event_keys( + State(state): State, + Extension(request_context): Extension, + Extension(_admin): Extension, +) -> Result, AppError> { + let event_keys = fetch_admin_tracking_event_keys(&state).await?; + Ok(json_success_body( + Some(&request_context), + AdminTrackingEventKeyListResponse { event_keys }, + )) +} + pub async fn admin_list_database_tables( State(state): State, Extension(request_context): Extension, @@ -1936,8 +1951,49 @@ async fn fetch_admin_tracking_events( }) } +async fn fetch_admin_tracking_event_keys( + state: &AppState, +) -> 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()) + .map(str::to_string) + .or_else(load_local_spacetime_cli_token); + let sql = build_admin_tracking_event_keys_sql(); + + let payload = fetch_spacetime_sql_json(&client, server_root, database, token.as_deref(), &sql) + .await + .map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY) + .with_message(format!("埋点 key 读取失败:{error}")) + })?; + parse_admin_tracking_event_keys_sql_response(payload).map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY) + .with_message(format!("埋点 key 解析失败:{error}")) + }) +} + fn build_admin_tracking_events_sql(query: &AdminTrackingEventListQuery) -> Result { let mut conditions = Vec::new(); + let start_date_key = normalized_non_empty(query.start_date.as_deref()) + .map(|value| parse_admin_tracking_date_key(value, "startDate")) + .transpose()?; + let end_date_key = normalized_non_empty(query.end_date.as_deref()) + .map(|value| parse_admin_tracking_date_key(value, "endDate")) + .transpose()?; + if matches!( + (start_date_key, end_date_key), + (Some(start_date_key), Some(end_date_key)) if start_date_key > end_date_key + ) { + return Err("startDate 不能晚于 endDate".to_string()); + } + if let Some(value) = normalized_non_empty(query.event_key.as_deref()) { conditions.push(format!("event_key = {}", quote_sql_string(value))); } @@ -1951,18 +2007,36 @@ fn build_admin_tracking_events_sql(query: &AdminTrackingEventListQuery) -> Resul if let Some(value) = normalized_non_empty(query.scope_id.as_deref()) { conditions.push(format!("scope_id = {}", quote_sql_string(value))); } + if let Some(value) = start_date_key { + conditions.push(format!("day_key >= {value}")); + } + if let Some(value) = end_date_key { + conditions.push(format!("day_key <= {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); + let limit = clamp_admin_tracking_event_limit(query.limit, query.export_all.unwrap_or(false)); 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} LIMIT {limit}" )) } +fn build_admin_tracking_event_keys_sql() -> String { + format!( + "SELECT event_key, scope_kind FROM tracking_event LIMIT {}", + ADMIN_TRACKING_EVENT_KEY_SCAN_LIMIT + ) +} + +fn parse_admin_tracking_date_key(value: &str, field_name: &str) -> Result { + module_runtime::parse_analytics_calendar_date_key(value) + .map_err(|_| format!("{field_name} 必须是 YYYY-MM-DD")) +} + fn normalized_non_empty(value: Option<&str>) -> Option<&str> { value.map(str::trim).filter(|value| !value.is_empty()) } @@ -1992,10 +2066,15 @@ fn normalize_admin_tracking_scope_kind(value: &str) -> Result<&'static str, Stri } } -fn clamp_admin_tracking_event_limit(limit: Option) -> u32 { +fn clamp_admin_tracking_event_limit(limit: Option, export_all: bool) -> u32 { + let max_limit = if export_all { + ADMIN_TRACKING_EVENT_EXPORT_LIMIT + } else { + ADMIN_TRACKING_EVENT_MAX_LIMIT + }; limit .unwrap_or(ADMIN_TRACKING_EVENT_DEFAULT_LIMIT) - .clamp(1, ADMIN_TRACKING_EVENT_MAX_LIMIT) + .clamp(1, max_limit) } async fn fetch_spacetime_sql_json( @@ -2043,6 +2122,38 @@ fn parse_admin_tracking_events_sql_response( }) } +fn parse_admin_tracking_event_keys_sql_response( + payload: Value, +) -> Result, String> { + let rows = extract_first_sql_rows(payload)?; + let mut by_event_key: BTreeMap> = BTreeMap::new(); + for row in rows { + let columns = row + .as_array() + .ok_or_else(|| "埋点 key 行格式非法".to_string())?; + let event_key = required_string_column(columns, 0, "event_key")?; + let scope_kind = tracking_scope_kind_to_string( + columns + .get(1) + .ok_or_else(|| "埋点 key 行缺少 scope_kind".to_string())?, + ) + .ok_or_else(|| "埋点 key 行 scope_kind 类型非法".to_string())?; + by_event_key + .entry(event_key) + .or_default() + .insert(scope_kind); + } + + Ok(by_event_key + .into_iter() + .map(|(event_key, scope_kinds)| AdminTrackingEventKeyPayload { + event_title: admin_tracking_event_title(&event_key).to_string(), + event_key, + scope_kinds: scope_kinds.into_iter().collect(), + }) + .collect()) +} + fn extract_first_sql_rows(payload: Value) -> Result, String> { let statement = match payload { Value::Array(statements) => statements @@ -2286,7 +2397,72 @@ fn timestamp_to_display_string(value: &Value) -> Option { fn admin_tracking_event_title(event_key: &str) -> &str { match event_key { + "auth_login_options_view" => "登录方式查看", + "auth_phone_code_send" => "发送手机验证码", "daily_login" => "每日登录", + "auth_phone_login_success" => "手机号登录成功", + "auth_me_view" => "当前账号查看", + "auth_sessions_view" => "登录会话查看", + "auth_revoke_session" => "撤销登录会话", + "auth_refresh_success" => "登录续期成功", + "auth_logout" => "退出登录", + "auth_logout_all" => "退出全部会话", + "auth_wechat_bind_phone_success" => "微信绑定手机成功", + "profile_identity_update" => "资料更新", + "profile_dashboard_view" => "个人看板查看", + "wallet_ledger_view" => "钱包流水查看", + "recharge_center_view" => "充值中心查看", + "recharge_order_create" => "充值订单创建", + "feedback_submit" => "反馈提交", + "invite_center_view" => "邀请中心查看", + "referral_invite_code_redeem" => "邀请码绑定", + "redeem_code_submit" => "兑换码提交", + "task_center_view" => "任务中心查看", + "task_reward_claim" => "任务奖励领取", + "save_archive_list_view" => "存档列表查看", + "save_archive_detail_view" => "存档详情查看", + "browse_history_view" => "浏览历史查看", + "browse_history_record" => "浏览历史写入", + "browse_history_clear" => "浏览历史清空", + "play_stats_view" => "游玩统计查看", + "profile_analytics_metric_view" => "个人指标查看", + "ai_task_create" => "AI 任务创建", + "ai_task_start" => "AI 任务启动", + "ai_task_stage_start" => "AI 阶段启动", + "ai_task_chunk_append" => "AI 分片追加", + "ai_task_stage_complete" => "AI 阶段完成", + "ai_task_reference_attach" => "AI 结果引用绑定", + "ai_task_complete" => "AI 任务完成", + "ai_task_fail" => "AI 任务失败标记", + "ai_task_cancel" => "AI 任务取消", + "asset_upload_ticket_create" => "资产上传票据创建", + "asset_sts_credentials_create" => "资产 STS 凭证创建", + "asset_upload_confirm" => "资产上传确认", + "asset_bind" => "资产绑定", + "asset_character_visual_generate" => "角色形象生成", + "asset_character_visual_publish" => "角色形象发布", + "asset_character_animation_generate" => "角色动画生成", + "asset_character_animation_publish" => "角色动画发布", + "asset_character_animation_import" => "角色动画视频导入", + "asset_character_workflow_cache_save" => "角色工作流缓存保存", + "asset_history_view" => "资产历史查看", + "llm_request" => "LLM 请求", + "speech_config_view" => "语音配置查看", + "asr_stream_start" => "ASR 流启动", + "tts_bidirection_start" => "TTS 双向流启动", + "tts_sse_start" => "TTS SSE 启动", + "runtime_settings_view" => "运行设置查看", + "runtime_settings_update" => "运行设置更新", + "runtime_snapshot_view" => "运行快照查看", + "runtime_snapshot_save" => "运行快照保存", + "runtime_snapshot_delete" => "运行快照删除", + "puzzle_route_success" => "拼图路由成功", + "match3d_route_success" => "抓大鹅路由成功", + "square_hole_route_success" => "方洞路由成功", + "custom_world_route_success" => "自定义世界路由成功", + "creative_agent_route_success" => "创意 Agent 路由成功", + "work_play_start" => "作品开始游玩", + "external_generation_run" => "外部生成执行", _ => event_key, } } @@ -2453,13 +2629,14 @@ mod tests { use super::{ AdminDashboardGranularity, AdminDashboardRegistrationStats, AdminDashboardSeries, apply_admin_dashboard_registration_rows, apply_admin_database_table_filters, - build_admin_database_table_row, build_admin_tracking_events_sql, build_body_preview, - build_debug_base_url, build_spacetime_schema_url, clamp_admin_database_table_limit, + build_admin_database_table_row, build_admin_tracking_event_keys_sql, + build_admin_tracking_events_sql, build_body_preview, build_debug_base_url, + build_spacetime_schema_url, clamp_admin_database_table_limit, clamp_admin_tracking_event_limit, is_safe_spacetime_table_name, normalize_debug_path, normalize_table_count_error, parse_admin_database_table_rows_sql_response, - parse_admin_tracking_events_sql_response, parse_spacetime_sql_count_response, - resolve_admin_dashboard_range, timestamp_value_to_micros, trim_preview, - wallet_ledger_source_type_to_string, + parse_admin_tracking_event_keys_sql_response, parse_admin_tracking_events_sql_response, + parse_spacetime_sql_count_response, resolve_admin_dashboard_range, + timestamp_value_to_micros, trim_preview, wallet_ledger_source_type_to_string, }; use axum::{http::StatusCode, response::IntoResponse}; use serde_json::json; @@ -2894,7 +3071,10 @@ mod tests { user_id: Some("user-1".to_string()), scope_kind: Some("USER".to_string()), scope_id: Some("scope-1".to_string()), + start_date: Some("2026-06-01".to_string()), + end_date: Some("2026-06-30".to_string()), limit: Some(2000), + export_all: None, }) .expect("tracking sql should build"); @@ -2902,15 +3082,72 @@ mod tests { assert!(sql.contains("user_id = 'user-1'")); assert!(sql.contains("scope_kind = 'user'")); assert!(sql.contains("scope_id = 'scope-1'")); + assert!(sql.contains(&format!( + "day_key >= {}", + module_runtime::parse_analytics_calendar_date_key("2026-06-01").unwrap() + ))); + assert!(sql.contains(&format!( + "day_key <= {}", + module_runtime::parse_analytics_calendar_date_key("2026-06-30").unwrap() + ))); assert!(!sql.contains("ORDER BY")); assert!(sql.ends_with("LIMIT 1000")); } + #[test] + fn build_admin_tracking_events_sql_uses_export_limit_when_requested() { + let sql = build_admin_tracking_events_sql(&AdminTrackingEventListQuery { + limit: Some(1_000_000), + export_all: Some(true), + ..Default::default() + }) + .expect("tracking export sql should build"); + + assert!(sql.ends_with("LIMIT 100000")); + } + #[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); + assert_eq!(clamp_admin_tracking_event_limit(None, false), 200); + assert_eq!(clamp_admin_tracking_event_limit(Some(0), false), 1); + assert_eq!(clamp_admin_tracking_event_limit(Some(1001), false), 1000); + assert_eq!( + clamp_admin_tracking_event_limit(Some(1_000_000), true), + 100_000 + ); + } + + #[test] + fn build_admin_tracking_event_keys_sql_scans_tracking_event_keys() { + let sql = build_admin_tracking_event_keys_sql(); + + assert_eq!( + sql, + "SELECT event_key, scope_kind FROM tracking_event LIMIT 50000" + ); + } + + #[test] + fn parse_admin_tracking_event_keys_sql_response_dedupes_scope_kinds() { + let payload = json!([ + { + "rows": [ + ["daily_login", "user"], + ["daily_login", [3, []]], + ["work_play_start", "work"] + ] + } + ]); + + let event_keys = parse_admin_tracking_event_keys_sql_response(payload) + .expect("tracking event keys should parse"); + + assert_eq!(event_keys.len(), 2); + assert_eq!(event_keys[0].event_key, "daily_login"); + assert_eq!(event_keys[0].event_title, "每日登录"); + assert_eq!(event_keys[0].scope_kinds, vec!["user"]); + assert_eq!(event_keys[1].event_key, "work_play_start"); + assert_eq!(event_keys[1].scope_kinds, vec!["work"]); } #[test] diff --git a/server-rs/crates/api-server/src/modules/admin.rs b/server-rs/crates/api-server/src/modules/admin.rs index 0c8ee2748..5f4c5558e 100644 --- a/server-rs/crates/api-server/src/modules/admin.rs +++ b/server-rs/crates/api-server/src/modules/admin.rs @@ -7,11 +7,11 @@ use crate::{ admin::{ admin_dashboard, admin_debug_http, admin_get_creation_entry_config, admin_get_editor_generation_pricing, admin_list_database_table_rows, - admin_list_database_tables, admin_list_tracking_events, admin_list_work_visibility, - admin_login, admin_me, admin_overview, admin_update_work_visibility, - admin_upsert_creation_entry_config, admin_upsert_creation_entry_event_banners_config, - admin_upsert_editor_generation_pricing, admin_upsert_public_work_interaction_config, - require_admin_auth, + admin_list_database_tables, admin_list_tracking_event_keys, admin_list_tracking_events, + admin_list_work_visibility, admin_login, admin_me, admin_overview, + admin_update_work_visibility, admin_upsert_creation_entry_config, + admin_upsert_creation_entry_event_banners_config, admin_upsert_editor_generation_pricing, + admin_upsert_public_work_interaction_config, require_admin_auth, }, runtime_profile::{ admin_disable_profile_redeem_code, admin_disable_profile_task_config, @@ -62,6 +62,13 @@ pub fn router(state: AppState) -> Router { require_admin_auth, )), ) + .route( + "/admin/api/tracking/event-keys", + get(admin_list_tracking_event_keys).route_layer(middleware::from_fn_with_state( + state.clone(), + require_admin_auth, + )), + ) .route( "/admin/api/database/tables", get(admin_list_database_tables).route_layer(middleware::from_fn_with_state( diff --git a/server-rs/crates/shared-contracts/src/admin.rs b/server-rs/crates/shared-contracts/src/admin.rs index 346ee1607..db2325df9 100644 --- a/server-rs/crates/shared-contracts/src/admin.rs +++ b/server-rs/crates/shared-contracts/src/admin.rs @@ -362,7 +362,13 @@ pub struct AdminTrackingEventListQuery { pub user_id: Option, pub scope_kind: Option, pub scope_id: Option, + /// 北京时间日历日期 YYYY-MM-DD,按 tracking_event.day_key 闭区间过滤。 + pub start_date: Option, + /// 北京时间日历日期 YYYY-MM-DD,按 tracking_event.day_key 闭区间过滤。 + pub end_date: Option, pub limit: Option, + /// 后台导出专用开关;列表查询仍使用较小上限,避免日常误刷拖慢数据库。 + pub export_all: Option, } // 单条埋点原始事件明细,字段与 tracking_event 表一一对应并补充事件名称。 @@ -389,3 +395,19 @@ pub struct AdminTrackingEventEntryPayload { pub struct AdminTrackingEventListResponse { pub entries: Vec, } + +// 后台埋点 key 候选来自真实库中已经落库的 tracking_event,静态标题仅用于展示补充。 +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AdminTrackingEventKeyPayload { + pub event_key: String, + pub event_title: String, + pub scope_kinds: Vec, +} + +// 后台埋点 key 列表响应,任务配置页和埋点明细页共用。 +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AdminTrackingEventKeyListResponse { + pub event_keys: Vec, +}