From 3ad1075227225ffcfc4df9581f54c60ec6be5d7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8E=86=E5=86=B0=E9=83=81-hermes=E7=89=88?= Date: Sat, 9 May 2026 19:56:59 +0800 Subject: [PATCH] feat: add work-level play tracking --- .hermes/shared-memory/development-workflow.md | 2 + ...KEND_TRACKING_EVENT_COVERAGE_2026-05-09.md | 243 ++++++++ ..._LOGIN_TRACKING_AUTH_CLOSURE_2026-05-08.md | 6 +- ...ILE_TASK_AND_TRACKING_SYSTEM_2026-05-03.md | 17 +- server-rs/Cargo.lock | 1 + server-rs/crates/api-server/src/app.rs | 61 +- server-rs/crates/api-server/src/assets.rs | 166 +++-- server-rs/crates/api-server/src/auth.rs | 13 +- .../crates/api-server/src/auth_session.rs | 39 +- server-rs/crates/api-server/src/big_fish.rs | 16 +- .../crates/api-server/src/custom_world.rs | 21 +- server-rs/crates/api-server/src/main.rs | 2 + server-rs/crates/api-server/src/match3d.rs | 26 +- server-rs/crates/api-server/src/puzzle.rs | 22 +- .../crates/api-server/src/square_hole.rs | 19 +- server-rs/crates/api-server/src/tracking.rs | 588 ++++++++++++++++++ .../crates/api-server/src/visual_novel.rs | 28 +- .../api-server/src/work_play_tracking.rs | 111 ++++ server-rs/crates/spacetime-client/Cargo.toml | 1 + .../src/module_bindings/mod.rs | 4 + ...ord_tracking_event_and_return_procedure.rs | 59 ++ .../runtime_tracking_event_input_type.rs | 26 + .../crates/spacetime-client/src/runtime.rs | 68 +- .../spacetime-module/src/runtime/profile.rs | 18 + 24 files changed, 1452 insertions(+), 105 deletions(-) create mode 100644 docs/technical/BACKEND_TRACKING_EVENT_COVERAGE_2026-05-09.md create mode 100644 server-rs/crates/api-server/src/tracking.rs create mode 100644 server-rs/crates/api-server/src/work_play_tracking.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/record_tracking_event_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_tracking_event_input_type.rs diff --git a/.hermes/shared-memory/development-workflow.md b/.hermes/shared-memory/development-workflow.md index 48edd942..3d8781ba 100644 --- a/.hermes/shared-memory/development-workflow.md +++ b/.hermes/shared-memory/development-workflow.md @@ -91,6 +91,8 @@ npm run spacetime:generate ## 常用检查命令 +- 后端通用用户行为埋点统一通过 `record_tracking_event_and_return` procedure、`SpacetimeRuntimeClient::record_tracking_event(...)` 与 api-server `tracking` 中间件写入 `tracking_event` / `tracking_daily_stat`;后台、RPG、大鱼吃小鱼、Visual Novel、Story、Combat 默认排除;作品级游玩埋点统一使用 `work_play_start`,详细事件清单见 `docs/technical/BACKEND_TRACKING_EVENT_COVERAGE_2026-05-09.md`。 + 编码检查: ```bash diff --git a/docs/technical/BACKEND_TRACKING_EVENT_COVERAGE_2026-05-09.md b/docs/technical/BACKEND_TRACKING_EVENT_COVERAGE_2026-05-09.md new file mode 100644 index 00000000..adec3097 --- /dev/null +++ b/docs/technical/BACKEND_TRACKING_EVENT_COVERAGE_2026-05-09.md @@ -0,0 +1,243 @@ +# 后端用户行为埋点覆盖方案 + +更新时间:`2026-05-09` + +## 1. 范围 + +本方案用于补齐后端可直接观测的用户行为埋点入口,统一写入 SpacetimeDB 的 `tracking_event` 与 `tracking_daily_stat`,为任务系统、运营看板与后续漏斗分析提供事实数据。 + +本轮明确不纳入以下范围: + +- 后台管理入口:`/admin/...` +- RPG 相关入口 +- 大鱼吃小鱼相关入口 +- Visual Novel 相关入口 +- Story 相关入口 +- Combat 相关入口 + +上述范围后续若需要埋点,应单独定义事件口径,避免把后台运营审计或特定玩法内行为混入本轮通用用户行为埋点。 + +## 2. 写入链路 + +### 2.1 SpacetimeDB 通用 procedure + +新增通用 procedure: + +- `record_tracking_event_and_return(input: RuntimeTrackingEventInput)` + +该入口复用既有运行态埋点写入能力: + +1. 写入原始事实 `tracking_event`。 +2. 更新聚合投影 `tracking_daily_stat`。 +3. 触发依赖事件进度的个人任务刷新。 + +每日登录 `daily_login` 也必须走该通用 procedure:认证链路仍保留 `record_daily_login_tracking_event_after_auth_success(...)` 作为业务语义 helper,但 helper 内部构造 `TrackingEventDraft` 后调用 `record_tracking_event_after_success(...)`,不再绕到每日登录专用 SpacetimeDB procedure。 + +### 2.2 spacetime-client 封装 + +`spacetime-client` 提供薄封装: + +- `SpacetimeRuntimeClient::record_tracking_event(...)` + +API Server 只依赖该 facade,不在 handler 中直接拼接 SpacetimeDB procedure 调用。 + +### 2.3 api-server helper 与中间件 + +API Server 新增统一 helper: + +- `tracking::TrackingEventDraft` +- `tracking::record_tracking_event_after_success(...)` +- `tracking::record_route_tracking_event_after_success(...)` + +路由级中间件 `record_api_tracking_after_success` 挂在最终响应链路上,只在最终 HTTP status 为 2xx 时写入埋点。埋点失败只写 `warn` 日志,不阻断认证、充值、发布、任务领取等主业务流程。 + +## 3. metadata 口径 + +当前通用路由埋点仅记录低敏字段: + +| 字段 | 含义 | +| --- | --- | +| `route` | 请求路径,不包含 query string | +| `method` | HTTP Method | +| `status` | 最终成功响应状态码 | +| `operation` | `RequestContext` 中的操作名 | +| `asset` | 仅资产类事件写入的低敏资产/操作信息,包含 `operation`、`operationFamily`、`assetObjectId`、`assetKind`、`objectKey`、`bucket`、`contentType`、`contentLength`、`version`、`bindingId`、`entityKind`、`entityId`、`slot`、`ownerUserId`、`profileId` 等可用于定位资产事实的字段;不写签名 URL、表单签名、OSS policy、token 或完整请求体。 | +| `assetOperation` | 资产类路由兜底事件的操作 key,用于不读取请求体时仍能按操作族聚合。 | + +禁止在通用埋点 metadata 中写入手机号、token、cookie、邀请码、请求体、密钥、连接串、外部凭证、OSS 签名 URL、PostObject policy 或签名表单字段。 + +### 3.1 作品级游玩埋点 + +所有已接入后端正式试玩/播放入口的作品类型统一写 `work_play_start`: + +- `scope_kind = work`。 +- `scope_id = 稳定作品 ID`,优先使用 `profile_id`;大鱼吃小鱼沿用 `session_id` 作为作品 ID。 +- `user_id = 当前认证用户`。 +- `owner_user_id = 作品作者/拥有者`,无法从入口直接确认作者时可为空,但 `metadata.userId` 仍保留当前玩家。 +- `profile_id = 作品 profile_id`,大鱼吃小鱼这类 session 型作品可为空。 +- `module_key = play_type`,例如 `puzzle`、`match3d`、`square-hole`、`custom-world`、`big-fish`、`visual-novel`。 +- `metadata` 固定包含 `operation = work_play_start`、`playType`、`workId`、`sourceRoute`,并按入口补充 `runId`、`ownerUserId`、`profileId`、`levelId`、`mode` 等低敏字段。 + +该事件用于“某个作品被多少不同用户玩过”等作品级分析;权威去重统计仍建议优先使用业务投影(如 `profile_played_world`),埋点侧用于分析与漏斗联动。 + +## 4. 事件清单 + +### 4.1 认证与会话 + +| 事件 | 入口 | +| --- | --- | +| `auth_login_options_view` | `GET /api/auth/login-options` | +| `auth_phone_code_send` | `POST /api/auth/phone/send-code` | +| `daily_login` | 认证成功与 refresh 续期后由 `record_daily_login_tracking_event_after_auth_success(...)` 主动写入,事件 ID 按 `daily-login:{user_id}:{day_key}` 幂等 | +| `auth_phone_login_success` | `POST /api/auth/phone/login` | +| `auth_me_view` | `GET /api/auth/me` | +| `auth_sessions_view` | `GET /api/auth/sessions` | +| `auth_refresh_success` | `POST /api/auth/refresh` | +| `auth_logout` | `POST /api/auth/logout` | +| `auth_logout_all` | `POST /api/auth/logout-all` | +| `auth_wechat_bind_phone_success` | `POST /api/auth/wechat/bind-phone` | + +### 4.2 个人中心、账户运营与任务 + +| 事件 | 入口 | +| --- | --- | +| `profile_identity_update` | `PATCH /api/profile/me` | +| `profile_dashboard_view` | `GET /api/profile/dashboard` | +| `wallet_ledger_view` | `GET /api/profile/wallet-ledger` | +| `recharge_center_view` | `GET /api/profile/recharge-center` | +| `recharge_order_create` | `POST /api/profile/recharge/orders` | +| `feedback_submit` | `POST /api/profile/feedback` | +| `invite_center_view` | `GET /api/profile/referrals/invite-center` | +| `referral_invite_code_redeem` | `POST /api/profile/referrals/redeem-code` | +| `redeem_code_submit` | `POST /api/profile/redeem-codes/redeem` | +| `task_center_view` | `GET /api/profile/tasks` | +| `task_reward_claim` | `POST /api/profile/tasks/{task_id}/claim` | +| `save_archive_list_view` | `GET /api/profile/save-archives` | +| `save_archive_detail_view` | `GET /api/profile/save-archives/{archive_id}` | +| `browse_history_view` | `GET /api/profile/browse-history` | +| `browse_history_record` | `POST /api/profile/browse-history` | +| `browse_history_clear` | `DELETE /api/profile/browse-history` | +| `play_stats_view` | `GET /api/profile/play-stats` | +| `profile_analytics_metric_view` | `GET /api/profile/analytics/metric` | + +### 4.3 AI、资产、LLM 与语音 + +资产操作统一按用户级事件写入:`scope_kind = user`、`scope_id = 当前认证 user_id`、`user_id/owner_user_id = 当前认证 user_id`。其中 `asset_upload_ticket_create`、`asset_upload_confirm`、`asset_bind` 在 handler 成功后主动记录资产 metadata,避免只依赖路由兜底;其余资产工坊入口通过路由级兜底保留用户级操作事实。 + +| 事件 | 入口 | +| --- | --- | +| `ai_task_create` | `POST /api/ai/tasks` | +| `ai_task_start` | `POST /api/ai/tasks/{task_id}/start` | +| `ai_task_stage_start` | `POST /api/ai/tasks/{task_id}/stages/{stage_id}/start` | +| `ai_task_chunk_append` | `POST /api/ai/tasks/{task_id}/chunks` | +| `ai_task_stage_complete` | `POST /api/ai/tasks/{task_id}/stages/{stage_id}/complete` | +| `ai_task_reference_attach` | `POST /api/ai/tasks/{task_id}/references` | +| `ai_task_complete` | `POST /api/ai/tasks/{task_id}/complete` | +| `ai_task_fail` | `POST /api/ai/tasks/{task_id}/fail` | +| `ai_task_cancel` | `POST /api/ai/tasks/{task_id}/cancel` | +| `asset_upload_ticket_create` | `POST /api/assets/direct-upload-tickets` | +| `asset_sts_credentials_create` | `POST /api/assets/sts-upload-credentials` | +| `asset_upload_confirm` | `POST /api/assets/objects/confirm` | +| `asset_bind` | `POST /api/assets/objects/bind` | +| `asset_character_visual_generate` | `POST /api/assets/character-visual/generate` | +| `asset_character_visual_publish` | `POST /api/assets/character-visual/publish` | +| `asset_character_animation_generate` | `POST /api/assets/character-animation/generate` | +| `asset_character_animation_publish` | `POST /api/assets/character-animation/publish` | +| `asset_character_animation_import` | `POST /api/assets/character-animation/import-video` | +| `asset_character_workflow_cache_save` | `POST /api/assets/character-workflow-cache` | +| `asset_history_view` | `GET /api/assets/history` | +| `llm_request` | `POST /api/llm/chat/completions` | +| `speech_config_view` | `GET /api/speech/volcengine/config` | +| `asr_stream_start` | `GET /api/speech/volcengine/asr/stream` | +| `tts_bidirection_start` | `GET /api/speech/volcengine/tts/bidirection` | +| `tts_sse_start` | `POST /api/speech/volcengine/tts/sse` | + +### 4.4 运行态与创作入口 + +| 事件 | 入口 | +| --- | --- | +| `runtime_settings_view` | `GET /api/runtime/settings` | +| `runtime_settings_update` | `PUT /api/runtime/settings` | +| `runtime_snapshot_view` | `GET /api/runtime/save/snapshot` | +| `runtime_snapshot_save` | `PUT /api/runtime/save/snapshot` | +| `runtime_snapshot_delete` | `DELETE /api/runtime/save/snapshot` | +| `puzzle_route_success` | `/api/runtime/puzzle/...` 成功响应兜底 | +| `match3d_route_success` | `/api/creation/match3d/...` 与 `/api/runtime/match3d/...` 成功响应兜底 | +| `square_hole_route_success` | `/api/creation/square-hole/...` 与 `/api/runtime/square-hole/...` 成功响应兜底 | +| `custom_world_route_success` | `/api/runtime/custom-world...` 成功响应兜底 | +| `creative_agent_route_success` | `/api/runtime/creative-agent...` 成功响应兜底 | +| `work_play_start` | 拼图、抓大鹅、方洞挑战、自定义世界、大鱼吃小鱼、Visual Novel 的正式开始游玩/播放入口;写 `scope_kind = work`、`scope_id = 作品 ID` | + +2048、Survivor、Moku 等未被排除的模板/玩法,如果经由上述 runtime、creative、custom-world、puzzle、match3d 或 square-hole 后端入口,会被路由级兜底事件覆盖。 + +## 5. 查询与验收建议 + +按每日登录核查原始事实: + +```sql +SELECT event_id, event_key, scope_kind, scope_id, user_id, module_key, metadata_json, occurred_at +FROM tracking_event +WHERE event_key = 'daily_login' +ORDER BY occurred_at DESC +LIMIT 20; +``` + +按作品级游玩核查原始事实: + +```sql +SELECT event_key, scope_kind, scope_id, user_id, owner_user_id, profile_id, module_key, metadata_json, occurred_at +FROM tracking_event +WHERE event_key = 'work_play_start' +ORDER BY occurred_at DESC +LIMIT 20; +``` + +按某个作品统计不同游玩用户: + +```sql +SELECT scope_id, COUNT(DISTINCT user_id) AS player_count +FROM tracking_event +WHERE event_key = 'work_play_start' + AND scope_kind = 'work' + AND scope_id = '' +GROUP BY scope_id; +``` + +按资产操作核查原始事实: + +```sql +SELECT event_key, scope_kind, scope_id, user_id, owner_user_id, module_key, metadata_json, occurred_at +FROM tracking_event +WHERE module_key = 'asset' +ORDER BY occurred_at DESC +LIMIT 20; +``` + +按事件核查原始事实: + +```sql +SELECT event_key, scope_kind, scope_id, user_id, module_key, metadata_json, occurred_at +FROM tracking_event +WHERE event_key = 'task_center_view' +ORDER BY occurred_at DESC +LIMIT 20; +``` + +按日聚合核查: + +```sql +SELECT day_key, event_key, scope_kind, scope_id, count +FROM tracking_daily_stat +WHERE event_key = 'task_center_view' +ORDER BY day_key DESC +LIMIT 20; +``` + +验收重点: + +1. 成功请求写入 `tracking_event` 并刷新 `tracking_daily_stat`。 +2. `daily_login` 由认证成功/refresh 续期链路主动写入,且走 `record_tracking_event_and_return` 通用 procedure。 +3. 非 2xx 响应不记录通用成功事件。 +4. 后台、RPG、大鱼吃小鱼、Visual Novel、Story、Combat 路由不写入本轮通用埋点。 +5. 埋点写入失败时主接口仍返回原业务结果,只记录后端 warning。 +6. metadata 不包含凭证、请求体或敏感业务字段。 diff --git a/docs/technical/DAILY_LOGIN_TRACKING_AUTH_CLOSURE_2026-05-08.md b/docs/technical/DAILY_LOGIN_TRACKING_AUTH_CLOSURE_2026-05-08.md index 82d4018a..ab62e31d 100644 --- a/docs/technical/DAILY_LOGIN_TRACKING_AUTH_CLOSURE_2026-05-08.md +++ b/docs/technical/DAILY_LOGIN_TRACKING_AUTH_CLOSURE_2026-05-08.md @@ -7,7 +7,7 @@ - `record_daily_login_tracking_event_and_return` - `spacetime-client` 方法:`record_daily_login_tracking_event(user_id)` -但认证成功链路还没有调用该方法,因此当前只完成了“任务中心读取不污染登录埋点”,没有完成“用户真实登录写入每日登录埋点”。 +但认证成功链路当时还没有调用该方法,因此当时只完成了“任务中心读取不污染登录埋点”,没有完成“用户真实登录写入每日登录埋点”。后续后端通用埋点能力落地后,`daily_login` 已进一步改为通过统一 `record_tracking_event_and_return(RuntimeTrackingEventInput)` procedure 写入,旧 `record_daily_login_tracking_event_and_return` 不再作为认证链路的目标入口。 ## 现象 @@ -53,7 +53,9 @@ record_daily_login_tracking_event_after_auth_success( 该 helper: -- 调用 `state.spacetime_client().record_daily_login_tracking_event(user_id.to_string()).await` +- 构造 `TrackingEventDraft::user("daily_login", "profile", user_id)` +- 使用 `daily-login:{user_id}:{day_key}` 作为事件 ID,保持北京时间自然日幂等 +- 调用统一 `record_tracking_event_after_success(...)`,最终进入 `record_tracking_event_and_return(RuntimeTrackingEventInput)` - 成功时记录 info - 失败时记录 warn,并明确“登录流程继续” diff --git a/docs/technical/PROFILE_TASK_AND_TRACKING_SYSTEM_2026-05-03.md b/docs/technical/PROFILE_TASK_AND_TRACKING_SYSTEM_2026-05-03.md index b8999455..41013165 100644 --- a/docs/technical/PROFILE_TASK_AND_TRACKING_SYSTEM_2026-05-03.md +++ b/docs/technical/PROFILE_TASK_AND_TRACKING_SYSTEM_2026-05-03.md @@ -44,7 +44,7 @@ | reward_points | `10` | | enabled | `true` | -用户打开任务中心时,后端会幂等记录当日 `daily_login` 埋点并刷新任务进度。用户点击领取时,后端校验当日进度、领奖记录和配置状态,然后同事务写入领奖记录与钱包流水。 +用户成功登录时,认证链路会通过统一后端埋点 helper 幂等记录当日 `daily_login` 并刷新任务进度;用户打开任务中心只记录 `task_center_view` 浏览事件,不再承担每日登录事实写入。用户点击领取时,后端校验当日进度、领奖记录和配置状态,然后同事务写入领奖记录与钱包流水。 后台任务配置页的 `Event Key` 使用可搜索下拉控件,选项来自前端后台的埋点定义注册表。当前注册表默认包含 `daily_login`,展示中文名称和备注;后续新增任务依赖的埋点时,应先补充注册表,再开放运营配置。 @@ -52,8 +52,8 @@ ### 用户侧 -- `GET /api/profile/tasks`:读取任务中心,同时记录当日登录埋点。 -- `POST /api/profile/tasks/{task_id}/claim`:领取任务奖励。 +- `GET /api/profile/tasks`:读取任务中心,并记录 `task_center_view` 浏览事件;不在此入口写入 `daily_login`。 +- `POST /api/profile/tasks/{task_id}/claim`:领取任务奖励,并记录 `task_reward_claim` 成功事件。 ### 后台侧 @@ -70,9 +70,14 @@ 不要把任务进度、领奖记录或钱包对账查询塞进 `docs/tracking/`,它们不是埋点系统本身。 -## 8. 验收 +## 8. 通用后端埋点覆盖 + +后端用户行为埋点统一按 `docs/technical/BACKEND_TRACKING_EVENT_COVERAGE_2026-05-09.md` 执行。该文档维护通用 procedure、api-server 中间件、事件清单、排除范围与查询验收口径;每日登录也走该统一路径,仅保留认证 helper 作为业务语义入口。 + +## 9. 验收 1. `profile_task_config` 默认存在 `daily_login`,后台可修改奖励、阈值、标题和启用状态。 2. “我的”页可以打开每日任务面板,登录后任务可领取 `10` 光点。 -3. 重复打开任务中心不会重复增加领取资格,重复领奖不会重复发放。 -4. 表目录、迁移白名单、Rust/TypeScript 契约和前端入口同步更新。 +3. 登录成功会幂等记录 `daily_login`;重复打开任务中心只记录 `task_center_view`,不会重复增加领取资格。 +4. 重复领奖不会重复发放。 +5. 表目录、迁移白名单、Rust/TypeScript 契约和前端入口同步更新。 diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock index ec124622..70c8739f 100644 --- a/server-rs/Cargo.lock +++ b/server-rs/Cargo.lock @@ -3124,6 +3124,7 @@ dependencies = [ "shared-contracts", "shared-kernel", "spacetimedb-sdk", + "time", "tokio", ] diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 9ba8e7c5..129a34ae 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -4,6 +4,7 @@ use axum::{ extract::{DefaultBodyLimit, Extension}, http::Request, middleware, + response::Response, routing::{delete, get, post}, }; use tower_http::{ @@ -26,8 +27,8 @@ use crate::{ create_sts_upload_credentials, get_asset_history, get_asset_read_url, }, auth::{ - attach_refresh_session_token, inspect_auth_claims, inspect_refresh_session_cookie, - require_bearer_auth, + AuthenticatedAccessToken, attach_refresh_session_token, inspect_auth_claims, + inspect_refresh_session_cookie, require_bearer_auth, }, auth_me::auth_me, auth_public_user::{get_public_user_by_code, get_public_user_by_id}, @@ -105,7 +106,7 @@ use crate::{ update_puzzle_run_pause, use_puzzle_runtime_prop, }, refresh_session::refresh_session, - request_context::{attach_request_context, resolve_request_id}, + request_context::{RequestContext, attach_request_context, resolve_request_id}, response_headers::propagate_request_id_header, runtime_browse_history::{ delete_runtime_browse_history, get_runtime_browse_history, post_runtime_browse_history, @@ -149,6 +150,7 @@ use crate::{ begin_story_runtime_session, begin_story_session, continue_story, get_story_runtime_projection, get_story_session_state, resolve_story_runtime_action, }, + tracking::record_route_tracking_event_after_success, vector_engine_audio_generation::{ create_visual_novel_background_music_task, create_visual_novel_sound_effect_task, publish_visual_novel_background_music_asset, publish_visual_novel_sound_effect_asset, @@ -499,16 +501,31 @@ pub fn build_router(state: AppState) -> Router { ) .route( "/api/assets/direct-upload-tickets", - post(create_direct_upload_ticket), + post(create_direct_upload_ticket).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), ) .route( "/api/assets/sts-upload-credentials", - post(create_sts_upload_credentials), + post(create_sts_upload_credentials).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/assets/objects/confirm", + post(confirm_asset_object).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), ) - .route("/api/assets/objects/confirm", post(confirm_asset_object)) .route( "/api/assets/objects/bind", - post(bind_asset_object_to_entity), + post(bind_asset_object_to_entity).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), ) .route( "/api/assets/character-visual/generate", @@ -1479,6 +1496,11 @@ pub fn build_router(state: AppState) -> Router { .layer(middleware::from_fn(normalize_error_response)) // 响应头回写放在错误归一化外侧,确保最终写回的是归一化后的最终响应。 .layer(middleware::from_fn(propagate_request_id_header)) + // 用户行为埋点放在错误归一化外侧,只观察最终成功响应,不阻断主链路。 + .layer(middleware::from_fn_with_state( + state.clone(), + record_api_tracking_after_success, + )) // 当前阶段先统一挂接 HTTP tracing,后续 request_id、响应头与错误中间件继续在这里扩展。 .layer( TraceLayer::new_for_http() @@ -1541,6 +1563,31 @@ pub fn build_router(state: AppState) -> Router { .with_state(state) } +async fn record_api_tracking_after_success( + axum::extract::State(state): axum::extract::State, + Extension(request_context): Extension, + request: Request, + next: middleware::Next, +) -> Response { + let method = request.method().clone(); + let path = request.uri().path().to_string(); + let response = next.run(request).await; + let authenticated = response + .extensions() + .get::() + .cloned(); + record_route_tracking_event_after_success( + &state, + &request_context, + &method, + &path, + response.status(), + authenticated.as_ref(), + ) + .await; + response +} + fn creative_agent_router(state: AppState) -> Router { Router::new() .route( diff --git a/server-rs/crates/api-server/src/assets.rs b/server-rs/crates/api-server/src/assets.rs index 76fcbd96..b3d2dc25 100644 --- a/server-rs/crates/api-server/src/assets.rs +++ b/server-rs/crates/api-server/src/assets.rs @@ -23,8 +23,13 @@ use shared_contracts::assets::{ use spacetime_client::SpacetimeClientError; use crate::{ - api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError, - platform_errors::map_oss_error, request_context::RequestContext, state::AppState, + api_response::json_success_body, + auth::AuthenticatedAccessToken, + http_error::AppError, + platform_errors::map_oss_error, + request_context::RequestContext, + state::AppState, + tracking::{TrackingEventDraft, record_tracking_event_after_success}, }; // 历史素材类型需要与 SpacetimeDB 侧白名单保持同一口径,避免新增素材类型时 HTTP 门面漏同步。 @@ -41,6 +46,7 @@ const SUPPORTED_ASSET_HISTORY_KINDS: [&str; 7] = [ pub async fn create_direct_upload_ticket( State(state): State, Extension(request_context): Extension, + Extension(authenticated): Extension, Json(payload): Json, ) -> Result, AppError> { let oss_client = state.oss_client().ok_or_else(|| { @@ -75,12 +81,33 @@ pub async fn create_direct_upload_ticket( "message": error.to_string(), })) })?; + let upload = DirectUploadTicketPayload::from(signed); + + record_asset_tracking_event( + &state, + &request_context, + &authenticated, + "asset_upload_ticket_create", + json!({ + "asset": { + "operation": "asset_upload_ticket_create", + "operationFamily": "upload_ticket", + "objectKey": upload.object_key.clone(), + "legacyPublicPath": upload.legacy_public_path.clone(), + "bucket": upload.bucket.clone(), + "contentType": upload.content_type.clone(), + "access": upload.access, + "keyPrefix": upload.key_prefix.clone(), + "maxSizeBytes": upload.max_size_bytes, + "successActionStatus": upload.success_action_status, + } + }), + ) + .await; Ok(json_success_body( Some(&request_context), - CreateDirectUploadTicketResponse { - upload: DirectUploadTicketPayload::from(signed), - }, + CreateDirectUploadTicketResponse { upload }, )) } @@ -190,6 +217,7 @@ pub async fn create_sts_upload_credentials( pub async fn confirm_asset_object( State(state): State, Extension(request_context): Extension, + Extension(authenticated): Extension, Json(payload): Json, ) -> Result, AppError> { let oss_client = state.oss_client().ok_or_else(|| { @@ -209,33 +237,60 @@ pub async fn confirm_asset_object( .await .map_err(map_confirm_asset_object_error)?; + let asset_object = AssetObjectPayload { + asset_object_id: result.asset_object_id, + bucket: result.bucket, + object_key: result.object_key, + access_policy: result.access_policy.as_str().to_string(), + content_type: result.content_type, + content_length: result.content_length, + content_hash: result.content_hash, + version: result.version, + source_job_id: result.source_job_id, + owner_user_id: result.owner_user_id, + profile_id: result.profile_id, + entity_id: result.entity_id, + asset_kind: result.asset_kind, + created_at: result.created_at, + updated_at: result.updated_at, + }; + + record_asset_tracking_event( + &state, + &request_context, + &authenticated, + "asset_upload_confirm", + json!({ + "asset": { + "operation": "asset_upload_confirm", + "operationFamily": "object_confirm", + "assetObjectId": asset_object.asset_object_id, + "assetKind": asset_object.asset_kind, + "objectKey": asset_object.object_key, + "bucket": asset_object.bucket, + "accessPolicy": asset_object.access_policy, + "contentType": asset_object.content_type, + "contentLength": asset_object.content_length, + "version": asset_object.version, + "sourceJobId": asset_object.source_job_id, + "ownerUserId": asset_object.owner_user_id, + "profileId": asset_object.profile_id, + "entityId": asset_object.entity_id, + } + }), + ) + .await; + Ok(json_success_body( Some(&request_context), - ConfirmAssetObjectResponse { - asset_object: AssetObjectPayload { - asset_object_id: result.asset_object_id, - bucket: result.bucket, - object_key: result.object_key, - access_policy: result.access_policy.as_str().to_string(), - content_type: result.content_type, - content_length: result.content_length, - content_hash: result.content_hash, - version: result.version, - source_job_id: result.source_job_id, - owner_user_id: result.owner_user_id, - profile_id: result.profile_id, - entity_id: result.entity_id, - asset_kind: result.asset_kind, - created_at: result.created_at, - updated_at: result.updated_at, - }, - }, + ConfirmAssetObjectResponse { asset_object }, )) } pub async fn bind_asset_object_to_entity( State(state): State, Extension(request_context): Extension, + Extension(authenticated): Extension, Json(payload): Json, ) -> Result, AppError> { let now_micros = current_utc_micros(); @@ -258,25 +313,60 @@ pub async fn bind_asset_object_to_entity( .await .map_err(map_confirm_asset_object_error)?; + let asset_binding = AssetBindingPayload { + binding_id: result.binding_id, + asset_object_id: result.asset_object_id, + entity_kind: result.entity_kind, + entity_id: result.entity_id, + slot: result.slot, + asset_kind: result.asset_kind, + owner_user_id: result.owner_user_id, + profile_id: result.profile_id, + created_at: result.created_at, + updated_at: result.updated_at, + }; + + record_asset_tracking_event( + &state, + &request_context, + &authenticated, + "asset_bind", + json!({ + "asset": { + "operation": "asset_bind", + "operationFamily": "object_bind", + "bindingId": asset_binding.binding_id, + "assetObjectId": asset_binding.asset_object_id, + "assetKind": asset_binding.asset_kind, + "entityKind": asset_binding.entity_kind, + "entityId": asset_binding.entity_id, + "slot": asset_binding.slot, + "ownerUserId": asset_binding.owner_user_id, + "profileId": asset_binding.profile_id, + } + }), + ) + .await; + Ok(json_success_body( Some(&request_context), - BindAssetObjectResponse { - asset_binding: AssetBindingPayload { - binding_id: result.binding_id, - asset_object_id: result.asset_object_id, - entity_kind: result.entity_kind, - entity_id: result.entity_id, - slot: result.slot, - asset_kind: result.asset_kind, - owner_user_id: result.owner_user_id, - profile_id: result.profile_id, - created_at: result.created_at, - updated_at: result.updated_at, - }, - }, + BindAssetObjectResponse { asset_binding }, )) } +async fn record_asset_tracking_event( + state: &AppState, + request_context: &RequestContext, + authenticated: &AuthenticatedAccessToken, + event_key: &'static str, + metadata: Value, +) { + let user_id = authenticated.claims().user_id().to_string(); + let mut draft = TrackingEventDraft::user(event_key, "asset", user_id.as_str()); + draft.metadata = metadata; + record_tracking_event_after_success(state, request_context, draft).await; +} + fn resolve_object_key_from_query(query: &GetReadUrlQuery) -> Option { if let Some(object_key) = query .object_key diff --git a/server-rs/crates/api-server/src/auth.rs b/server-rs/crates/api-server/src/auth.rs index 3500961e..78c2e815 100644 --- a/server-rs/crates/api-server/src/auth.rs +++ b/server-rs/crates/api-server/src/auth.rs @@ -63,9 +63,13 @@ pub async fn require_bearer_auth( && let Some(claims) = try_build_internal_forwarded_claims(&state, request.headers()) { request + .extensions_mut() + .insert(AuthenticatedAccessToken::new(claims.clone())); + let mut response = next.run(request).await; + response .extensions_mut() .insert(AuthenticatedAccessToken::new(claims)); - return Ok(next.run(request).await); + return Ok(response); } let bearer_token = extract_bearer_token(request.headers())?; @@ -114,10 +118,15 @@ pub async fn require_bearer_auth( } request + .extensions_mut() + .insert(AuthenticatedAccessToken::new(claims.clone())); + + let mut response = next.run(request).await; + response .extensions_mut() .insert(AuthenticatedAccessToken::new(claims)); - Ok(next.run(request).await) + Ok(response) } pub async fn inspect_auth_claims( diff --git a/server-rs/crates/api-server/src/auth_session.rs b/server-rs/crates/api-server/src/auth_session.rs index 601ca8f4..e06c27f3 100644 --- a/server-rs/crates/api-server/src/auth_session.rs +++ b/server-rs/crates/api-server/src/auth_session.rs @@ -10,7 +10,10 @@ use platform_auth::{ use time::OffsetDateTime; use crate::session_client::SessionClientContext; -use crate::{http_error::AppError, state::AppState}; +use crate::{ + http_error::AppError, request_context::RequestContext, state::AppState, + tracking::record_daily_login_tracking_event_after_success as record_daily_login_tracking_event_via_unified_path, +}; #[derive(Debug, Clone)] pub struct SignedAuthSession { @@ -29,38 +32,24 @@ pub fn create_password_auth_session( #[cfg(not(test))] pub async fn record_daily_login_tracking_event_after_auth_success( state: &AppState, - request_context: &crate::request_context::RequestContext, + request_context: &RequestContext, user_id: &str, login_method: AuthLoginMethod, ) { - // 登录埋点是运营数据,不应反向阻断已经成功的认证会话签发。 - match state - .spacetime_client() - .record_daily_login_tracking_event(user_id.to_string()) - .await - { - Ok(()) => tracing::info!( - request_id = request_context.request_id(), - operation = request_context.operation(), - user_id = %user_id, - login_method = %login_method.as_str(), - "登录成功每日登录埋点已记录" - ), - Err(error) => tracing::warn!( - request_id = request_context.request_id(), - operation = request_context.operation(), - user_id = %user_id, - login_method = %login_method.as_str(), - error = %error, - "登录成功每日登录埋点记录失败,登录流程继续" - ), - } + // 登录埋点是运营数据,不应反向阻断已经成功的认证会话签发;每日登录也走统一埋点 helper/procedure。 + record_daily_login_tracking_event_via_unified_path( + state, + request_context, + user_id, + login_method, + ) + .await; } #[cfg(test)] pub async fn record_daily_login_tracking_event_after_auth_success( _state: &AppState, - _request_context: &crate::request_context::RequestContext, + _request_context: &RequestContext, _user_id: &str, _login_method: AuthLoginMethod, ) { diff --git a/server-rs/crates/api-server/src/big_fish.rs b/server-rs/crates/api-server/src/big_fish.rs index 488991aa..dcf1928d 100644 --- a/server-rs/crates/api-server/src/big_fish.rs +++ b/server-rs/crates/api-server/src/big_fish.rs @@ -65,6 +65,7 @@ use crate::{ request_context::RequestContext, state::AppState, work_author::resolve_work_author_by_user_id, + work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success}, }; pub async fn create_big_fish_session( @@ -235,7 +236,7 @@ pub async fn record_big_fish_play( let items = state .spacetime_client() .record_big_fish_play(BigFishPlayReportRecordInput { - session_id, + session_id: session_id.clone(), user_id: authenticated.claims().user_id().to_string(), elapsed_ms: payload.elapsed_ms.unwrap_or(0), reported_at_micros: current_utc_micros(), @@ -245,6 +246,19 @@ pub async fn record_big_fish_play( big_fish_error_response(&request_context, map_big_fish_client_error(error)) })?; + record_work_play_start_after_success( + &state, + &request_context, + WorkPlayTrackingDraft::new( + "big-fish", + session_id.clone(), + &authenticated, + "/api/runtime/big-fish/sessions/{session_id}/play", + ) + .run_id(session_id.clone()), + ) + .await; + Ok(json_success_body( Some(&request_context), BigFishWorksResponse { diff --git a/server-rs/crates/api-server/src/custom_world.rs b/server-rs/crates/api-server/src/custom_world.rs index 678de17d..7101e47b 100644 --- a/server-rs/crates/api-server/src/custom_world.rs +++ b/server-rs/crates/api-server/src/custom_world.rs @@ -74,6 +74,7 @@ use crate::{ request_context::RequestContext, state::AppState, work_author::resolve_work_author_by_user_id, + work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success}, }; const DRAFT_ASSET_GENERATION_MAX_ATTEMPTS: u32 = 3; @@ -827,7 +828,7 @@ pub async fn record_custom_world_gallery_play( State(state): State, Path((owner_user_id, profile_id)): Path<(String, String)>, Extension(request_context): Extension, - Extension(_authenticated): Extension, + Extension(authenticated): Extension, ) -> Result, Response> { if owner_user_id.trim().is_empty() || profile_id.trim().is_empty() { return Err(custom_world_error_response( @@ -842,8 +843,8 @@ pub async fn record_custom_world_gallery_play( let mutation = state .spacetime_client() .record_custom_world_profile_play(CustomWorldProfilePlayReportRecordInput { - owner_user_id, - profile_id, + owner_user_id: owner_user_id.clone(), + profile_id: profile_id.clone(), played_at_micros: current_utc_micros(), }) .await @@ -851,6 +852,20 @@ pub async fn record_custom_world_gallery_play( custom_world_error_response(&request_context, map_custom_world_client_error(error)) })?; + record_work_play_start_after_success( + &state, + &request_context, + WorkPlayTrackingDraft::new( + "custom-world", + profile_id.clone(), + &authenticated, + "/api/runtime/custom-world-gallery/{owner_user_id}/{profile_id}/play", + ) + .owner_user_id(owner_user_id.clone()) + .profile_id(profile_id.clone()), + ) + .await; + Ok(json_success_body( Some(&request_context), CustomWorldGalleryDetailResponse { diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index 9a0bc43e..88942aed 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -69,12 +69,14 @@ mod square_hole_agent_turn; mod state; mod story_battles; mod story_sessions; +mod tracking; mod vector_engine_audio_generation; mod visual_novel; mod volcengine_speech; mod wechat_auth; mod wechat_provider; mod work_author; +mod work_play_tracking; use shared_logging::init_tracing; use std::{collections::HashSet, env, fs, io, panic, thread}; diff --git a/server-rs/crates/api-server/src/match3d.rs b/server-rs/crates/api-server/src/match3d.rs index 65606337..c2a78215 100644 --- a/server-rs/crates/api-server/src/match3d.rs +++ b/server-rs/crates/api-server/src/match3d.rs @@ -48,8 +48,12 @@ use spacetime_client::{ }; use crate::{ - api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError, - request_context::RequestContext, state::AppState, + api_response::json_success_body, + auth::AuthenticatedAccessToken, + http_error::AppError, + request_context::RequestContext, + state::AppState, + work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success}, }; const MATCH3D_AGENT_PROVIDER: &str = "match3d-agent"; @@ -574,7 +578,7 @@ pub async fn start_match3d_run( .start_match3d_run(Match3DRunStartRecordInput { run_id: build_prefixed_uuid_id(MATCH3D_RUN_ID_PREFIX), owner_user_id: authenticated.claims().user_id().to_string(), - profile_id, + profile_id: profile_id.clone(), started_at_ms: current_utc_ms(), }) .await @@ -586,6 +590,22 @@ pub async fn start_match3d_run( ) })?; + record_work_play_start_after_success( + &state, + &request_context, + WorkPlayTrackingDraft::new( + "match3d", + profile_id.clone(), + &authenticated, + "/api/runtime/match3d/...", + ) + .profile_id(profile_id.clone()) + .extra(json!({ + "runId": run.run_id, + })), + ) + .await; + Ok(json_success_body( Some(&request_context), Match3DRunResponse { diff --git a/server-rs/crates/api-server/src/puzzle.rs b/server-rs/crates/api-server/src/puzzle.rs index fd6851fd..76f53bac 100644 --- a/server-rs/crates/api-server/src/puzzle.rs +++ b/server-rs/crates/api-server/src/puzzle.rs @@ -100,6 +100,7 @@ use crate::{ request_context::RequestContext, state::AppState, work_author::resolve_work_author_by_user_id, + work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success}, }; const PUZZLE_AGENT_API_BASE_PROVIDER: &str = "puzzle-agent"; @@ -1539,8 +1540,8 @@ pub async fn start_puzzle_run( .start_puzzle_run(PuzzleRunStartRecordInput { run_id: build_prefixed_uuid_id("puzzle-run-"), owner_user_id: authenticated.claims().user_id().to_string(), - profile_id: payload.profile_id, - level_id: payload.level_id, + profile_id: payload.profile_id.clone(), + level_id: payload.level_id.clone(), started_at_micros: current_utc_micros(), }) .await @@ -1552,6 +1553,23 @@ pub async fn start_puzzle_run( ) })?; + record_work_play_start_after_success( + &state, + &request_context, + WorkPlayTrackingDraft::new( + "puzzle", + payload.profile_id.clone(), + &authenticated, + "/api/runtime/puzzle/...", + ) + .profile_id(payload.profile_id.clone()) + .extra(json!({ + "levelId": payload.level_id, + "runId": run.run_id, + })), + ) + .await; + Ok(json_success_body( Some(&request_context), PuzzleRunResponse { diff --git a/server-rs/crates/api-server/src/square_hole.rs b/server-rs/crates/api-server/src/square_hole.rs index 4c9bbcfb..e2fa232a 100644 --- a/server-rs/crates/api-server/src/square_hole.rs +++ b/server-rs/crates/api-server/src/square_hole.rs @@ -76,6 +76,7 @@ use crate::{ SquareHoleAgentTurnRequest, build_finalize_record_input, run_square_hole_agent_turn, }, state::AppState, + work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success}, }; const SQUARE_HOLE_AGENT_PROVIDER: &str = "square-hole-agent"; @@ -747,7 +748,7 @@ pub async fn start_square_hole_run( .start_square_hole_run(SquareHoleRunStartRecordInput { run_id: build_prefixed_uuid_id(SQUARE_HOLE_RUN_ID_PREFIX), owner_user_id: authenticated.claims().user_id().to_string(), - profile_id, + profile_id: profile_id.clone(), started_at_ms: current_utc_ms(), }) .await @@ -759,6 +760,22 @@ pub async fn start_square_hole_run( ) })?; + record_work_play_start_after_success( + &state, + &request_context, + WorkPlayTrackingDraft::new( + "square-hole", + profile_id.clone(), + &authenticated, + "/api/runtime/square-hole/...", + ) + .profile_id(profile_id.clone()) + .extra(json!({ + "runId": run.run_id, + })), + ) + .await; + Ok(json_success_body( Some(&request_context), SquareHoleRunResponse { diff --git a/server-rs/crates/api-server/src/tracking.rs b/server-rs/crates/api-server/src/tracking.rs new file mode 100644 index 00000000..65bc6888 --- /dev/null +++ b/server-rs/crates/api-server/src/tracking.rs @@ -0,0 +1,588 @@ +use axum::http::{Method, StatusCode}; +use module_auth::AuthLoginMethod; +use module_runtime::RuntimeTrackingScopeKind; +use serde_json::{Value, json}; +use time::OffsetDateTime; +use uuid::Uuid; + +use crate::{auth::AuthenticatedAccessToken, request_context::RequestContext, state::AppState}; + +/// 后端用户行为埋点入口统一走这里:写入失败只记录日志,不反向阻断主业务。 +#[derive(Clone, Debug)] +pub struct TrackingEventDraft { + pub event_key: &'static str, + pub scope_kind: RuntimeTrackingScopeKind, + pub scope_id: String, + pub user_id: Option, + pub owner_user_id: Option, + pub profile_id: Option, + pub module_key: Option<&'static str>, + pub metadata: Value, +} + +impl TrackingEventDraft { + pub fn new(event_key: &'static str, module_key: &'static str) -> Self { + Self { + event_key, + scope_kind: RuntimeTrackingScopeKind::Site, + scope_id: "site".to_string(), + user_id: None, + owner_user_id: None, + profile_id: None, + module_key: Some(module_key), + metadata: json!({}), + } + } + + pub fn user(event_key: &'static str, module_key: &'static str, user_id: &str) -> Self { + let normalized_user_id = user_id.trim().to_string(); + let mut draft = Self::new(event_key, module_key); + draft.scope_kind = RuntimeTrackingScopeKind::User; + draft.scope_id = normalized_user_id.clone(); + draft.user_id = Some(normalized_user_id.clone()); + draft.owner_user_id = Some(normalized_user_id); + draft + } +} + +#[derive(Clone, Debug)] +struct RouteTrackingSpec { + event_key: &'static str, + module_key: &'static str, + scope_kind: RuntimeTrackingScopeKind, + scope_id: &'static str, +} + +pub async fn record_route_tracking_event_after_success( + state: &AppState, + request_context: &RequestContext, + method: &Method, + path: &str, + status: StatusCode, + authenticated: Option<&AuthenticatedAccessToken>, +) { + if !status.is_success() { + return; + } + let Some(spec) = resolve_route_tracking_spec(method, path) else { + return; + }; + + let user_id = authenticated.map(|auth| auth.claims().user_id().to_string()); + let scope_id = match spec.scope_kind { + RuntimeTrackingScopeKind::User => { + user_id.clone().unwrap_or_else(|| spec.scope_id.to_string()) + } + RuntimeTrackingScopeKind::Site => spec.scope_id.to_string(), + _ => spec.scope_id.to_string(), + }; + let mut draft = TrackingEventDraft::new(spec.event_key, spec.module_key); + draft.scope_kind = spec.scope_kind; + draft.scope_id = scope_id; + draft.user_id = user_id; + draft.metadata = build_route_tracking_metadata(&spec, request_context, method, path, status); + if draft.user_id.is_some() { + draft.owner_user_id = draft.user_id.clone(); + } + + record_tracking_event_after_success(state, request_context, draft).await; +} + +fn resolve_route_tracking_spec(method: &Method, path: &str) -> Option { + use RuntimeTrackingScopeKind::{Site, User}; + + // 后台、RPG、大鱼吃小鱼、Visual Novel、Story、Combat 明确排除,不走通用用户行为埋点。 + if path.starts_with("/admin/") + || path.contains("/big-fish") + || path.contains("/visual-novel") + || path.contains("/story") + || path.contains("/combat") + || path.contains("/rpg") + || path.starts_with("/api/runtime/chat/") + { + return None; + } + + let route = normalize_route_path(path); + match (method.as_str(), route.as_str()) { + ("GET", "/api/auth/login-options") => { + Some(route_spec("auth_login_options_view", "auth", Site, "site")) + } + ("POST", "/api/auth/phone/send-code") => { + Some(route_spec("auth_phone_code_send", "auth", Site, "site")) + } + ("POST", "/api/auth/phone/login") => Some(route_spec( + "auth_phone_login_success", + "auth", + User, + "anonymous", + )), + ("GET", "/api/auth/me") => Some(route_spec("auth_me_view", "auth", User, "anonymous")), + ("GET", "/api/auth/sessions") => { + Some(route_spec("auth_sessions_view", "auth", User, "anonymous")) + } + ("POST", "/api/auth/refresh") => { + Some(route_spec("auth_refresh_success", "auth", Site, "site")) + } + ("POST", "/api/auth/logout") => Some(route_spec("auth_logout", "auth", User, "anonymous")), + ("POST", "/api/auth/logout-all") => { + Some(route_spec("auth_logout_all", "auth", User, "anonymous")) + } + ("POST", "/api/auth/wechat/bind-phone") => Some(route_spec( + "auth_wechat_bind_phone_success", + "auth", + User, + "anonymous", + )), + ("PATCH", "/api/profile/me") => Some(route_spec( + "profile_identity_update", + "profile", + User, + "anonymous", + )), + ("GET", "/api/profile/dashboard") => Some(route_spec( + "profile_dashboard_view", + "profile", + User, + "anonymous", + )), + ("GET", "/api/profile/wallet-ledger") => Some(route_spec( + "wallet_ledger_view", + "profile", + User, + "anonymous", + )), + ("GET", "/api/profile/recharge-center") => Some(route_spec( + "recharge_center_view", + "profile", + User, + "anonymous", + )), + ("POST", "/api/profile/recharge/orders") => Some(route_spec( + "recharge_order_create", + "profile", + User, + "anonymous", + )), + ("POST", "/api/profile/feedback") => { + Some(route_spec("feedback_submit", "profile", User, "anonymous")) + } + ("GET", "/api/profile/referrals/invite-center") => Some(route_spec( + "invite_center_view", + "profile", + User, + "anonymous", + )), + ("POST", "/api/profile/referrals/redeem-code") => Some(route_spec( + "referral_invite_code_redeem", + "profile", + User, + "anonymous", + )), + ("POST", "/api/profile/redeem-codes/redeem") => Some(route_spec( + "redeem_code_submit", + "profile", + User, + "anonymous", + )), + ("GET", "/api/profile/tasks") => { + Some(route_spec("task_center_view", "profile", User, "anonymous")) + } + ("POST", "/api/profile/tasks/{id}/claim") => Some(route_spec( + "task_reward_claim", + "profile", + User, + "anonymous", + )), + ("GET", "/api/profile/save-archives") => Some(route_spec( + "save_archive_list_view", + "profile", + User, + "anonymous", + )), + ("GET", "/api/profile/save-archives/{id}") => Some(route_spec( + "save_archive_detail_view", + "profile", + User, + "anonymous", + )), + ("GET", "/api/profile/browse-history") => Some(route_spec( + "browse_history_view", + "profile", + User, + "anonymous", + )), + ("POST", "/api/profile/browse-history") => Some(route_spec( + "browse_history_record", + "profile", + User, + "anonymous", + )), + ("DELETE", "/api/profile/browse-history") => Some(route_spec( + "browse_history_clear", + "profile", + User, + "anonymous", + )), + ("GET", "/api/profile/play-stats") => { + Some(route_spec("play_stats_view", "profile", User, "anonymous")) + } + ("GET", "/api/profile/analytics/metric") => Some(route_spec( + "profile_analytics_metric_view", + "profile", + User, + "anonymous", + )), + ("POST", "/api/ai/tasks") => Some(route_spec("ai_task_create", "ai", User, "anonymous")), + ("POST", "/api/ai/tasks/{id}/start") => { + Some(route_spec("ai_task_start", "ai", User, "anonymous")) + } + ("POST", "/api/ai/tasks/{id}/stages/{id}/start") => { + Some(route_spec("ai_task_stage_start", "ai", User, "anonymous")) + } + ("POST", "/api/ai/tasks/{id}/chunks") => { + Some(route_spec("ai_task_chunk_append", "ai", User, "anonymous")) + } + ("POST", "/api/ai/tasks/{id}/stages/{id}/complete") => Some(route_spec( + "ai_task_stage_complete", + "ai", + User, + "anonymous", + )), + ("POST", "/api/ai/tasks/{id}/references") => Some(route_spec( + "ai_task_reference_attach", + "ai", + User, + "anonymous", + )), + ("POST", "/api/ai/tasks/{id}/complete") => { + Some(route_spec("ai_task_complete", "ai", User, "anonymous")) + } + ("POST", "/api/ai/tasks/{id}/fail") => { + Some(route_spec("ai_task_fail", "ai", User, "anonymous")) + } + ("POST", "/api/ai/tasks/{id}/cancel") => { + Some(route_spec("ai_task_cancel", "ai", User, "anonymous")) + } + ("POST", "/api/assets/sts-upload-credentials") => Some(route_spec( + "asset_sts_credentials_create", + "asset", + User, + "anonymous", + )), + ("POST", "/api/assets/character-visual/generate") => Some(route_spec( + "asset_character_visual_generate", + "asset", + User, + "anonymous", + )), + ("POST", "/api/assets/character-visual/publish") => Some(route_spec( + "asset_character_visual_publish", + "asset", + User, + "anonymous", + )), + ("POST", "/api/assets/character-animation/generate") => Some(route_spec( + "asset_character_animation_generate", + "asset", + User, + "anonymous", + )), + ("POST", "/api/assets/character-animation/publish") => Some(route_spec( + "asset_character_animation_publish", + "asset", + User, + "anonymous", + )), + ("POST", "/api/assets/character-animation/import-video") => Some(route_spec( + "asset_character_animation_import", + "asset", + User, + "anonymous", + )), + ("POST", "/api/assets/character-workflow-cache") => Some(route_spec( + "asset_character_workflow_cache_save", + "asset", + User, + "anonymous", + )), + ("GET", "/api/assets/history") => { + Some(route_spec("asset_history_view", "asset", User, "anonymous")) + } + ("POST", "/api/llm/chat/completions") => { + Some(route_spec("llm_request", "llm", User, "anonymous")) + } + ("GET", "/api/speech/volcengine/config") => Some(route_spec( + "speech_config_view", + "speech", + User, + "anonymous", + )), + ("GET", "/api/speech/volcengine/asr/stream") => { + Some(route_spec("asr_stream_start", "speech", User, "anonymous")) + } + ("GET", "/api/speech/volcengine/tts/bidirection") => Some(route_spec( + "tts_bidirection_start", + "speech", + User, + "anonymous", + )), + ("POST", "/api/speech/volcengine/tts/sse") => { + Some(route_spec("tts_sse_start", "speech", User, "anonymous")) + } + ("GET", "/api/runtime/settings") => Some(route_spec( + "runtime_settings_view", + "runtime", + User, + "anonymous", + )), + ("PUT", "/api/runtime/settings") => Some(route_spec( + "runtime_settings_update", + "runtime", + User, + "anonymous", + )), + ("GET", "/api/runtime/save/snapshot") => Some(route_spec( + "runtime_snapshot_view", + "runtime", + User, + "anonymous", + )), + ("PUT", "/api/runtime/save/snapshot") => Some(route_spec( + "runtime_snapshot_save", + "runtime", + User, + "anonymous", + )), + ("DELETE", "/api/runtime/save/snapshot") => Some(route_spec( + "runtime_snapshot_delete", + "runtime", + User, + "anonymous", + )), + _ if route.starts_with("/api/runtime/puzzle/") => Some(route_spec( + "puzzle_route_success", + "puzzle", + user_scope_for(method), + "anonymous", + )), + _ if route.starts_with("/api/creation/match3d/") + || route.starts_with("/api/runtime/match3d/") => + { + Some(route_spec( + "match3d_route_success", + "match3d", + user_scope_for(method), + "anonymous", + )) + } + _ if route.starts_with("/api/creation/square-hole/") + || route.starts_with("/api/runtime/square-hole/") => + { + Some(route_spec( + "square_hole_route_success", + "square-hole", + user_scope_for(method), + "anonymous", + )) + } + _ if route.starts_with("/api/runtime/custom-world") => Some(route_spec( + "custom_world_route_success", + "custom-world", + user_scope_for(method), + "anonymous", + )), + _ if route.starts_with("/api/runtime/creative-agent") => Some(route_spec( + "creative_agent_route_success", + "creative-agent", + user_scope_for(method), + "anonymous", + )), + _ => None, + } +} + +fn route_spec( + event_key: &'static str, + module_key: &'static str, + scope_kind: RuntimeTrackingScopeKind, + scope_id: &'static str, +) -> RouteTrackingSpec { + RouteTrackingSpec { + event_key, + module_key, + scope_kind, + scope_id, + } +} + +fn user_scope_for(method: &Method) -> RuntimeTrackingScopeKind { + if matches!(*method, Method::GET) { + RuntimeTrackingScopeKind::Site + } else { + RuntimeTrackingScopeKind::User + } +} + +fn build_route_tracking_metadata( + spec: &RouteTrackingSpec, + request_context: &RequestContext, + method: &Method, + path: &str, + status: StatusCode, +) -> Value { + let mut metadata = json!({ + "route": path, + "method": method.as_str(), + "status": status.as_u16(), + "operation": request_context.operation(), + }); + + if spec.module_key == "asset" { + metadata["asset"] = build_asset_route_metadata(spec.event_key, path); + metadata["assetOperation"] = json!(spec.event_key); + } + + metadata +} + +fn build_asset_route_metadata(event_key: &str, path: &str) -> Value { + json!({ + "operation": event_key, + "operationFamily": resolve_asset_operation_family(event_key), + "route": path, + }) +} + +fn resolve_asset_operation_family(event_key: &str) -> &'static str { + match event_key { + "asset_upload_ticket_create" => "upload_ticket", + "asset_sts_credentials_create" => "sts_credentials", + "asset_upload_confirm" => "object_confirm", + "asset_bind" => "object_bind", + "asset_character_visual_generate" => "character_visual_generate", + "asset_character_visual_publish" => "character_visual_publish", + "asset_character_animation_generate" => "character_animation_generate", + "asset_character_animation_publish" => "character_animation_publish", + "asset_character_animation_import" => "character_animation_import", + "asset_character_workflow_cache_save" => "character_workflow_cache_save", + "asset_history_view" => "history_view", + _ => "asset_operation", + } +} + +fn normalize_route_path(path: &str) -> String { + let mut normalized = String::new(); + for segment in path.trim_end_matches('/').split('/') { + if segment.is_empty() { + continue; + } + normalized.push('/'); + normalized.push_str(if is_dynamic_path_segment(segment) { + "{id}" + } else { + segment + }); + } + if normalized.is_empty() { + "/".to_string() + } else { + normalized + } +} + +fn is_dynamic_path_segment(segment: &str) -> bool { + let lower = segment.to_ascii_lowercase(); + segment.len() >= 8 + || segment.chars().any(|ch| ch.is_ascii_digit()) + || lower.starts_with("world") + || lower.starts_with("task") + || lower.starts_with("profile") + || lower.starts_with("session") +} + +pub async fn record_daily_login_tracking_event_after_success( + state: &AppState, + request_context: &RequestContext, + user_id: &str, + login_method: AuthLoginMethod, +) { + let mut draft = TrackingEventDraft::user("daily_login", "profile", user_id); + draft.metadata = json!({ + "operation": request_context.operation(), + "loginMethod": login_method.as_str(), + }); + + record_tracking_event_after_success(state, request_context, draft).await; +} + +pub async fn record_tracking_event_after_success( + state: &AppState, + request_context: &RequestContext, + draft: TrackingEventDraft, +) { + let occurred_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000; + let event_id = build_tracking_event_id(&draft, occurred_at_micros); + let event_key = draft.event_key.to_string(); + let scope_kind = draft.scope_kind; + let scope_id = draft.scope_id; + let metadata_json = draft.metadata.to_string(); + + match state + .spacetime_client() + .record_tracking_event( + event_id, + event_key.clone(), + scope_kind, + scope_id.clone(), + draft.user_id, + draft.owner_user_id, + draft.profile_id, + draft.module_key.map(str::to_string), + metadata_json, + occurred_at_micros as i64, + ) + .await + { + Ok(()) => tracing::info!( + request_id = request_context.request_id(), + operation = request_context.operation(), + event_key = %event_key, + scope_kind = %scope_kind.as_str(), + scope_id = %scope_id, + "后端埋点已记录" + ), + Err(error) => tracing::warn!( + request_id = request_context.request_id(), + operation = request_context.operation(), + event_key = %event_key, + scope_kind = %scope_kind.as_str(), + scope_id = %scope_id, + error = %error, + "后端埋点记录失败,主业务流程继续" + ), + } +} + +fn build_tracking_event_id(draft: &TrackingEventDraft, occurred_at_micros: i128) -> String { + if draft.event_key == "daily_login" + && draft.scope_kind == RuntimeTrackingScopeKind::User + && !draft.scope_id.trim().is_empty() + { + let day_key = runtime_profile_beijing_day_key(occurred_at_micros as i64); + return format!("daily-login:{}:{}", draft.scope_id.trim(), day_key); + } + + format!( + "api:{}:{}:{}", + draft.event_key, + occurred_at_micros, + Uuid::new_v4() + ) +} + +fn runtime_profile_beijing_day_key(occurred_at_micros: i64) -> i64 { + const PROFILE_TASK_BEIJING_OFFSET_MICROS: i64 = 28_800_000_000; + const PROFILE_RUNTIME_DAY_MICROS: i64 = 86_400_000_000; + (occurred_at_micros + PROFILE_TASK_BEIJING_OFFSET_MICROS).div_euclid(PROFILE_RUNTIME_DAY_MICROS) +} diff --git a/server-rs/crates/api-server/src/visual_novel.rs b/server-rs/crates/api-server/src/visual_novel.rs index 4b137b0f..08c1749b 100644 --- a/server-rs/crates/api-server/src/visual_novel.rs +++ b/server-rs/crates/api-server/src/visual_novel.rs @@ -29,9 +29,14 @@ use spacetime_client::{ use time::OffsetDateTime; use crate::{ - api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError, - prompt::visual_novel as vn_prompt, request_context::RequestContext, state::AppState, + api_response::json_success_body, + auth::AuthenticatedAccessToken, + http_error::AppError, + prompt::visual_novel as vn_prompt, + request_context::RequestContext, + state::AppState, work_author::resolve_work_author_by_user_id, + work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success}, }; const VISUAL_NOVEL_PROVIDER: &str = "visual-novel"; @@ -445,7 +450,7 @@ pub async fn start_visual_novel_run( .start_visual_novel_run(VisualNovelRunStartRecordInput { run_id: build_prefixed_uuid_id(domain::VISUAL_NOVEL_RUN_ID_PREFIX), owner_user_id: authenticated.claims().user_id().to_string(), - profile_id, + profile_id: profile_id.clone(), mode: run_mode_to_wire(&payload.mode).to_string(), snapshot_json: None, started_at_micros: current_utc_micros(), @@ -455,6 +460,23 @@ pub async fn start_visual_novel_run( visual_novel_error_response(&request_context, map_spacetime_error(error)) })?; + record_work_play_start_after_success( + &state, + &request_context, + WorkPlayTrackingDraft::new( + "visual-novel", + profile_id.clone(), + &authenticated, + "/api/runtime/visual-novel/...", + ) + .profile_id(profile_id.clone()) + .extra(json!({ + "mode": run_mode_to_wire(&payload.mode), + "runId": run.run_id, + })), + ) + .await; + Ok(json_success_body( Some(&request_context), contract::VisualNovelRunResponse { diff --git a/server-rs/crates/api-server/src/work_play_tracking.rs b/server-rs/crates/api-server/src/work_play_tracking.rs new file mode 100644 index 00000000..cad8eb56 --- /dev/null +++ b/server-rs/crates/api-server/src/work_play_tracking.rs @@ -0,0 +1,111 @@ +use module_runtime::RuntimeTrackingScopeKind; +use serde_json::{Value, json}; + +use crate::{ + auth::AuthenticatedAccessToken, + request_context::RequestContext, + state::AppState, + tracking::{TrackingEventDraft, record_tracking_event_after_success}, +}; + +pub(crate) const WORK_PLAY_START_EVENT_KEY: &str = "work_play_start"; + +pub(crate) struct WorkPlayTrackingDraft { + pub play_type: &'static str, + pub work_id: String, + pub user_id: String, + pub owner_user_id: Option, + pub profile_id: Option, + pub run_id: Option, + pub source_route: &'static str, + pub extra: Value, +} + +impl WorkPlayTrackingDraft { + pub(crate) fn new( + play_type: &'static str, + work_id: impl Into, + authenticated: &AuthenticatedAccessToken, + source_route: &'static str, + ) -> Self { + let user_id = authenticated.claims().user_id().to_string(); + Self { + play_type, + work_id: work_id.into(), + user_id, + owner_user_id: None, + profile_id: None, + run_id: None, + source_route, + extra: json!({}), + } + } + + pub(crate) fn owner_user_id(mut self, owner_user_id: impl Into) -> Self { + self.owner_user_id = Some(owner_user_id.into()); + self + } + + pub(crate) fn profile_id(mut self, profile_id: impl Into) -> Self { + self.profile_id = Some(profile_id.into()); + self + } + + pub(crate) fn run_id(mut self, run_id: impl Into) -> Self { + self.run_id = Some(run_id.into()); + self + } + + pub(crate) fn extra(mut self, extra: Value) -> Self { + self.extra = extra; + self + } +} + +/// 作品级正式游玩埋点:scope 固定为 work,scope_id 固定为稳定作品 ID。 +/// 中文注释:该埋点用于“某作品被多少用户玩过”等分析,写入失败不阻断 runtime 主流程。 +pub(crate) async fn record_work_play_start_after_success( + state: &AppState, + request_context: &RequestContext, + draft: WorkPlayTrackingDraft, +) { + let mut metadata = json!({ + "operation": WORK_PLAY_START_EVENT_KEY, + "playType": draft.play_type, + "workId": draft.work_id, + "sourceRoute": draft.source_route, + }); + metadata["userId"] = json!(draft.user_id); + if let Some(owner_user_id) = draft.owner_user_id.as_deref() { + metadata["ownerUserId"] = json!(owner_user_id); + } + if let Some(profile_id) = draft.profile_id.as_deref() { + metadata["profileId"] = json!(profile_id); + } + if let Some(run_id) = draft.run_id.as_deref() { + metadata["runId"] = json!(run_id); + } + if !draft.extra.is_null() { + metadata["extra"] = draft.extra; + } + + let mut tracking = TrackingEventDraft::new(WORK_PLAY_START_EVENT_KEY, draft.play_type); + tracking.scope_kind = RuntimeTrackingScopeKind::Work; + tracking.scope_id = draft.work_id; + tracking.user_id = Some(draft.user_id); + tracking.owner_user_id = draft.owner_user_id; + tracking.profile_id = draft.profile_id; + tracking.metadata = metadata; + + record_tracking_event_after_success(state, request_context, tracking).await; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn work_play_event_key_is_stable() { + assert_eq!(WORK_PLAY_START_EVENT_KEY, "work_play_start"); + } +} diff --git a/server-rs/crates/spacetime-client/Cargo.toml b/server-rs/crates/spacetime-client/Cargo.toml index 2727f0d8..4499f545 100644 --- a/server-rs/crates/spacetime-client/Cargo.toml +++ b/server-rs/crates/spacetime-client/Cargo.toml @@ -25,4 +25,5 @@ serde_json = { workspace = true } shared-contracts = { workspace = true } shared-kernel = { workspace = true } spacetimedb-sdk = { workspace = true } +time = { workspace = true } tokio = { workspace = true, features = ["rt", "sync", "time"] } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/mod.rs b/server-rs/crates/spacetime-client/src/module_bindings/mod.rs index 8182e68d..b290d456 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/mod.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/mod.rs @@ -529,6 +529,7 @@ pub mod record_custom_world_profile_like_procedure; pub mod record_custom_world_profile_play_procedure; pub mod record_daily_login_tracking_event_and_return_procedure; pub mod record_puzzle_work_like_procedure; +pub mod record_tracking_event_and_return_procedure; pub mod record_visual_novel_runtime_event_procedure; pub mod redeem_profile_referral_invite_code_procedure; pub mod redeem_profile_reward_code_procedure; @@ -662,6 +663,7 @@ pub mod runtime_snapshot_row_type; pub mod runtime_snapshot_table; pub mod runtime_snapshot_type; pub mod runtime_snapshot_upsert_input_type; +pub mod runtime_tracking_event_input_type; pub mod runtime_tracking_event_procedure_result_type; pub mod runtime_tracking_scope_kind_type; pub mod save_puzzle_form_draft_procedure; @@ -1323,6 +1325,7 @@ pub use record_custom_world_profile_like_procedure::record_custom_world_profile_ pub use record_custom_world_profile_play_procedure::record_custom_world_profile_play; pub use record_daily_login_tracking_event_and_return_procedure::record_daily_login_tracking_event_and_return; pub use record_puzzle_work_like_procedure::record_puzzle_work_like; +pub use record_tracking_event_and_return_procedure::record_tracking_event_and_return; pub use record_visual_novel_runtime_event_procedure::record_visual_novel_runtime_event; pub use redeem_profile_referral_invite_code_procedure::redeem_profile_referral_invite_code; pub use redeem_profile_reward_code_procedure::redeem_profile_reward_code; @@ -1456,6 +1459,7 @@ pub use runtime_snapshot_row_type::RuntimeSnapshotRow; pub use runtime_snapshot_table::*; pub use runtime_snapshot_type::RuntimeSnapshot; pub use runtime_snapshot_upsert_input_type::RuntimeSnapshotUpsertInput; +pub use runtime_tracking_event_input_type::RuntimeTrackingEventInput; pub use runtime_tracking_event_procedure_result_type::RuntimeTrackingEventProcedureResult; pub use runtime_tracking_scope_kind_type::RuntimeTrackingScopeKind; pub use save_puzzle_form_draft_procedure::save_puzzle_form_draft; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/record_tracking_event_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/record_tracking_event_and_return_procedure.rs new file mode 100644 index 00000000..01361ec7 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/record_tracking_event_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_tracking_event_input_type::RuntimeTrackingEventInput; +use super::runtime_tracking_event_procedure_result_type::RuntimeTrackingEventProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct RecordTrackingEventAndReturnArgs { + pub input: RuntimeTrackingEventInput, +} + +impl __sdk::InModule for RecordTrackingEventAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `record_tracking_event_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait record_tracking_event_and_return { + fn record_tracking_event_and_return(&self, input: RuntimeTrackingEventInput) { + self.record_tracking_event_and_return_then(input, |_, _| {}); + } + + fn record_tracking_event_and_return_then( + &self, + input: RuntimeTrackingEventInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl record_tracking_event_and_return for super::RemoteProcedures { + fn record_tracking_event_and_return_then( + &self, + input: RuntimeTrackingEventInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, RuntimeTrackingEventProcedureResult>( + "record_tracking_event_and_return", + RecordTrackingEventAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_tracking_event_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_tracking_event_input_type.rs new file mode 100644 index 00000000..386be4af --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_tracking_event_input_type.rs @@ -0,0 +1,26 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_tracking_scope_kind_type::RuntimeTrackingScopeKind; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeTrackingEventInput { + pub event_id: String, + pub event_key: String, + pub scope_kind: RuntimeTrackingScopeKind, + pub scope_id: String, + pub user_id: Option, + pub owner_user_id: Option, + pub profile_id: Option, + pub module_key: Option, + pub metadata_json: String, + pub occurred_at_micros: i64, +} + +impl __sdk::InModule for RuntimeTrackingEventInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/runtime.rs b/server-rs/crates/spacetime-client/src/runtime.rs index d59ed1cd..b21a87db 100644 --- a/server-rs/crates/spacetime-client/src/runtime.rs +++ b/server-rs/crates/spacetime-client/src/runtime.rs @@ -339,22 +339,60 @@ impl SpacetimeClient { &self, user_id: String, ) -> Result<(), SpacetimeClientError> { - let procedure_input = build_runtime_profile_task_center_get_input(user_id) - .map_err(SpacetimeClientError::validation_failed)? - .into(); + let normalized_user_id = user_id.trim().to_string(); + let occurred_at_micros = + shared_kernel::offset_datetime_to_unix_micros(time::OffsetDateTime::now_utc()); + let day_key = runtime_profile_beijing_day_key(occurred_at_micros); + self.record_tracking_event( + format!("daily-login:{}:{}", normalized_user_id, day_key), + "daily_login".to_string(), + DomainRuntimeTrackingScopeKind::User, + normalized_user_id.clone(), + Some(normalized_user_id.clone()), + Some(normalized_user_id), + None, + Some("profile".to_string()), + "{}".to_string(), + occurred_at_micros, + ) + .await + } + + pub async fn record_tracking_event( + &self, + event_id: String, + event_key: String, + scope_kind: DomainRuntimeTrackingScopeKind, + scope_id: String, + user_id: Option, + owner_user_id: Option, + profile_id: Option, + module_key: Option, + metadata_json: String, + occurred_at_micros: i64, + ) -> Result<(), SpacetimeClientError> { + let procedure_input = crate::module_bindings::RuntimeTrackingEventInput { + event_id, + event_key, + scope_kind: map_runtime_tracking_scope_kind(scope_kind), + scope_id, + user_id, + owner_user_id, + profile_id, + module_key, + metadata_json, + occurred_at_micros, + }; self.call_after_connect(move |connection, sender| { connection .procedures() - .record_daily_login_tracking_event_and_return_then( - procedure_input, - move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_runtime_tracking_event_procedure_result); - send_once(&sender, mapped); - }, - ); + .record_tracking_event_and_return_then(procedure_input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_runtime_tracking_event_procedure_result); + send_once(&sender, mapped); + }); }) .await } @@ -885,3 +923,9 @@ impl SpacetimeClient { .await } } + +fn runtime_profile_beijing_day_key(occurred_at_micros: i64) -> i64 { + const PROFILE_TASK_BEIJING_OFFSET_MICROS: i64 = 28_800_000_000; + const PROFILE_RUNTIME_DAY_MICROS: i64 = 86_400_000_000; + (occurred_at_micros + PROFILE_TASK_BEIJING_OFFSET_MICROS).div_euclid(PROFILE_RUNTIME_DAY_MICROS) +} diff --git a/server-rs/crates/spacetime-module/src/runtime/profile.rs b/server-rs/crates/spacetime-module/src/runtime/profile.rs index a995f717..7ba91942 100644 --- a/server-rs/crates/spacetime-module/src/runtime/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/profile.rs @@ -512,6 +512,24 @@ pub fn query_analytics_metric( } } +// 通用埋点入口开放给 Axum 调用;具体入口仍在业务 handler 成功后显式触发。 +#[spacetimedb::procedure] +pub fn record_tracking_event_and_return( + ctx: &mut ProcedureContext, + input: RuntimeTrackingEventInput, +) -> RuntimeTrackingEventProcedureResult { + match ctx.try_with_tx(|tx| record_tracking_event(tx, input.clone())) { + Ok(()) => RuntimeTrackingEventProcedureResult { + ok: true, + error_message: None, + }, + Err(message) => RuntimeTrackingEventProcedureResult { + ok: false, + error_message: Some(message), + }, + } +} + // 登录成功埋点由认证链路主动调用;任务中心只负责读取和刷新任务进度。 #[spacetimedb::procedure] pub fn record_daily_login_tracking_event_and_return(