补齐后台埋点导出和任务事件选择

后台埋点查询支持日期筛选和导出全部模式

新增后台真实 event key 列表接口

任务配置页改用真实 user 埋点 key 候选

更新后台埋点查询文档和定向测试
This commit is contained in:
2026-06-26 16:49:33 +08:00
parent 607042a250
commit a8c3f8cf0f
10 changed files with 613 additions and 88 deletions
+249 -12
View File
@@ -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>,
}