补齐后台埋点导出和任务事件选择
后台埋点查询支持日期筛选和导出全部模式 新增后台真实 event key 列表接口 任务配置页改用真实 user 埋点 key 候选 更新后台埋点查询文档和定向测试
This commit is contained in:
@@ -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<AdminTrackingEventKeyListResponse>(
|
||||
'/admin/api/tracking/event-keys',
|
||||
{ token },
|
||||
);
|
||||
}
|
||||
|
||||
export function getAdminCreationEntryConfig(token: string) {
|
||||
return request<AdminCreationEntryConfigResponse>(
|
||||
'/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}` : '';
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
'每日登录',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, AdminTrackingEventKeyOption>();
|
||||
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();
|
||||
|
||||
@@ -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<AdminTrackingEventKeyPayload[]>(
|
||||
[],
|
||||
);
|
||||
const [isEventKeyPickerOpen, setIsEventKeyPickerOpen] = useState(false);
|
||||
const [cycle, setCycle] = useState<ProfileTaskCycle>('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({
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{selectedEventDefinition ? (
|
||||
{selectedEventOption ? (
|
||||
<small className="admin-field-note">
|
||||
{selectedEventDefinition.remark}
|
||||
{selectedEventOption.remark}
|
||||
</small>
|
||||
) : (
|
||||
<small className="admin-field-note">
|
||||
|
||||
@@ -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<TrackingScopeKind | ''>('');
|
||||
const [scopeId, setScopeId] = useState('');
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [endDate, setEndDate] = useState('');
|
||||
const [limit, setLimit] = useState('200');
|
||||
const [eventKeys, setEventKeys] = useState<AdminTrackingEventKeyPayload[]>(
|
||||
[],
|
||||
);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isExportingAll, setIsExportingAll] = useState(false);
|
||||
const [detailEntry, setDetailEntry] =
|
||||
useState<AdminTrackingEventEntryPayload | null>(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 (
|
||||
<section className="admin-page admin-page-wide">
|
||||
<div className="admin-page-heading">
|
||||
@@ -110,7 +173,7 @@ export function AdminTrackingEventsPage({
|
||||
<div className="admin-action-row">
|
||||
<button
|
||||
className="admin-secondary-button"
|
||||
disabled={isLoading}
|
||||
disabled={isLoading || isExportingAll}
|
||||
type="button"
|
||||
onClick={refreshTrackingEvents}
|
||||
>
|
||||
@@ -126,6 +189,15 @@ export function AdminTrackingEventsPage({
|
||||
<Download size={17} aria-hidden="true" />
|
||||
<span>导出 Excel</span>
|
||||
</button>
|
||||
<button
|
||||
className="admin-secondary-button"
|
||||
disabled={isExportingAll}
|
||||
type="button"
|
||||
onClick={handleExportAll}
|
||||
>
|
||||
<Download size={17} aria-hidden="true" />
|
||||
<span>{isExportingAll ? '导出中' : '导出全部'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -186,7 +258,27 @@ export function AdminTrackingEventsPage({
|
||||
onChange={(event) => setLimit(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<button className="admin-secondary-button" disabled={isLoading} type="submit">
|
||||
<label className="admin-field">
|
||||
<span>开始日期</span>
|
||||
<input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(event) => setStartDate(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="admin-field">
|
||||
<span>结束日期</span>
|
||||
<input
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(event) => setEndDate(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
className="admin-secondary-button"
|
||||
disabled={isLoading}
|
||||
type="submit"
|
||||
>
|
||||
<Search size={17} aria-hidden="true" />
|
||||
<span>{isLoading ? '查询中' : '查询'}</span>
|
||||
</button>
|
||||
@@ -353,9 +445,13 @@ function formatMetadataJson(value: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function exportTrackingEventsAsExcel(entries: AdminTrackingEventEntryPayload[]) {
|
||||
function exportTrackingEventsAsExcel(
|
||||
entries: AdminTrackingEventEntryPayload[],
|
||||
) {
|
||||
const tableRows = [
|
||||
exportColumns.map((column) => `<th>${escapeHtml(column.label)}</th>`).join(''),
|
||||
exportColumns
|
||||
.map((column) => `<th>${escapeHtml(column.label)}</th>`)
|
||||
.join(''),
|
||||
...entries.map((entry) =>
|
||||
exportColumns
|
||||
.map(
|
||||
@@ -368,7 +464,9 @@ function exportTrackingEventsAsExcel(entries: AdminTrackingEventEntryPayload[])
|
||||
const html = `\uFEFF<html><head><meta charset="UTF-8" /></head><body><table>${tableRows
|
||||
.map((row) => `<tr>${row}</tr>`)
|
||||
.join('')}</table></body></html>`;
|
||||
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 '';
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(_admin): Extension<AuthenticatedAdmin>,
|
||||
) -> Result<Json<Value>, 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<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
@@ -1936,8 +1951,49 @@ async fn fetch_admin_tracking_events(
|
||||
})
|
||||
}
|
||||
|
||||
async fn fetch_admin_tracking_event_keys(
|
||||
state: &AppState,
|
||||
) -> Result<Vec<AdminTrackingEventKeyPayload>, 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<String, String> {
|
||||
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<i64, String> {
|
||||
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>) -> u32 {
|
||||
fn clamp_admin_tracking_event_limit(limit: Option<u32>, 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<Vec<AdminTrackingEventKeyPayload>, String> {
|
||||
let rows = extract_first_sql_rows(payload)?;
|
||||
let mut by_event_key: BTreeMap<String, BTreeSet<String>> = 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<Vec<Value>, String> {
|
||||
let statement = match payload {
|
||||
Value::Array(statements) => statements
|
||||
@@ -2286,7 +2397,72 @@ fn timestamp_to_display_string(value: &Value) -> Option<String> {
|
||||
|
||||
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]
|
||||
|
||||
@@ -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<AppState> {
|
||||
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(
|
||||
|
||||
@@ -362,7 +362,13 @@ pub struct AdminTrackingEventListQuery {
|
||||
pub user_id: Option<String>,
|
||||
pub scope_kind: Option<String>,
|
||||
pub scope_id: Option<String>,
|
||||
/// 北京时间日历日期 YYYY-MM-DD,按 tracking_event.day_key 闭区间过滤。
|
||||
pub start_date: Option<String>,
|
||||
/// 北京时间日历日期 YYYY-MM-DD,按 tracking_event.day_key 闭区间过滤。
|
||||
pub end_date: Option<String>,
|
||||
pub limit: Option<u32>,
|
||||
/// 后台导出专用开关;列表查询仍使用较小上限,避免日常误刷拖慢数据库。
|
||||
pub export_all: Option<bool>,
|
||||
}
|
||||
|
||||
// 单条埋点原始事件明细,字段与 tracking_event 表一一对应并补充事件名称。
|
||||
@@ -389,3 +395,19 @@ pub struct AdminTrackingEventEntryPayload {
|
||||
pub struct AdminTrackingEventListResponse {
|
||||
pub entries: Vec<AdminTrackingEventEntryPayload>,
|
||||
}
|
||||
|
||||
// 后台埋点 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<String>,
|
||||
}
|
||||
|
||||
// 后台埋点 key 列表响应,任务配置页和埋点明细页共用。
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminTrackingEventKeyListResponse {
|
||||
pub event_keys: Vec<AdminTrackingEventKeyPayload>,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user