feat: add work-level play tracking
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-05-09 19:56:59 +08:00
parent 32a1530ab1
commit 3ad1075227
24 changed files with 1452 additions and 105 deletions

View File

@@ -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 ```bash

View File

@@ -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 = '<profile_id_or_work_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 不包含凭证、请求体或敏感业务字段。

View File

@@ -7,7 +7,7 @@
- `record_daily_login_tracking_event_and_return` - `record_daily_login_tracking_event_and_return`
- `spacetime-client` 方法:`record_daily_login_tracking_event(user_id)` - `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 该 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 - 成功时记录 info
- 失败时记录 warn并明确“登录流程继续” - 失败时记录 warn并明确“登录流程继续”

View File

@@ -44,7 +44,7 @@
| reward_points | `10` | | reward_points | `10` |
| enabled | `true` | | enabled | `true` |
用户打开任务中心时,后端会幂等记录当日 `daily_login` 埋点并刷新任务进度。用户点击领取时,后端校验当日进度、领奖记录和配置状态,然后同事务写入领奖记录与钱包流水。 用户成功登录时,认证链路会通过统一后端埋点 helper 幂等记录当日 `daily_login` 并刷新任务进度;用户打开任务中心只记录 `task_center_view` 浏览事件,不再承担每日登录事实写入。用户点击领取时,后端校验当日进度、领奖记录和配置状态,然后同事务写入领奖记录与钱包流水。
后台任务配置页的 `Event Key` 使用可搜索下拉控件,选项来自前端后台的埋点定义注册表。当前注册表默认包含 `daily_login`,展示中文名称和备注;后续新增任务依赖的埋点时,应先补充注册表,再开放运营配置。 后台任务配置页的 `Event Key` 使用可搜索下拉控件,选项来自前端后台的埋点定义注册表。当前注册表默认包含 `daily_login`,展示中文名称和备注;后续新增任务依赖的埋点时,应先补充注册表,再开放运营配置。
@@ -52,8 +52,8 @@
### 用户侧 ### 用户侧
- `GET /api/profile/tasks`:读取任务中心,同时记录当日登录埋点 - `GET /api/profile/tasks`:读取任务中心,并记录 `task_center_view` 浏览事件;不在此入口写入 `daily_login`
- `POST /api/profile/tasks/{task_id}/claim`:领取任务奖励。 - `POST /api/profile/tasks/{task_id}/claim`:领取任务奖励,并记录 `task_reward_claim` 成功事件
### 后台侧 ### 后台侧
@@ -70,9 +70,14 @@
不要把任务进度、领奖记录或钱包对账查询塞进 `docs/tracking/`,它们不是埋点系统本身。 不要把任务进度、领奖记录或钱包对账查询塞进 `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`,后台可修改奖励、阈值、标题和启用状态。 1. `profile_task_config` 默认存在 `daily_login`,后台可修改奖励、阈值、标题和启用状态。
2. “我的”页可以打开每日任务面板,登录后任务可领取 `10` 光点。 2. “我的”页可以打开每日任务面板,登录后任务可领取 `10` 光点。
3. 重复打开任务中心不会重复增加领取资格,重复领奖不会重复发放 3. 登录成功会幂等记录 `daily_login`;重复打开任务中心只记录 `task_center_view`,不会重复增加领取资格
4. 表目录、迁移白名单、Rust/TypeScript 契约和前端入口同步更新。 4. 重复领奖不会重复发放。
5. 表目录、迁移白名单、Rust/TypeScript 契约和前端入口同步更新。

1
server-rs/Cargo.lock generated
View File

@@ -3124,6 +3124,7 @@ dependencies = [
"shared-contracts", "shared-contracts",
"shared-kernel", "shared-kernel",
"spacetimedb-sdk", "spacetimedb-sdk",
"time",
"tokio", "tokio",
] ]

View File

@@ -4,6 +4,7 @@ use axum::{
extract::{DefaultBodyLimit, Extension}, extract::{DefaultBodyLimit, Extension},
http::Request, http::Request,
middleware, middleware,
response::Response,
routing::{delete, get, post}, routing::{delete, get, post},
}; };
use tower_http::{ use tower_http::{
@@ -26,8 +27,8 @@ use crate::{
create_sts_upload_credentials, get_asset_history, get_asset_read_url, create_sts_upload_credentials, get_asset_history, get_asset_read_url,
}, },
auth::{ auth::{
attach_refresh_session_token, inspect_auth_claims, inspect_refresh_session_cookie, AuthenticatedAccessToken, attach_refresh_session_token, inspect_auth_claims,
require_bearer_auth, inspect_refresh_session_cookie, require_bearer_auth,
}, },
auth_me::auth_me, auth_me::auth_me,
auth_public_user::{get_public_user_by_code, get_public_user_by_id}, 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, update_puzzle_run_pause, use_puzzle_runtime_prop,
}, },
refresh_session::refresh_session, 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, response_headers::propagate_request_id_header,
runtime_browse_history::{ runtime_browse_history::{
delete_runtime_browse_history, get_runtime_browse_history, post_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, begin_story_runtime_session, begin_story_session, continue_story,
get_story_runtime_projection, get_story_session_state, resolve_story_runtime_action, get_story_runtime_projection, get_story_session_state, resolve_story_runtime_action,
}, },
tracking::record_route_tracking_event_after_success,
vector_engine_audio_generation::{ vector_engine_audio_generation::{
create_visual_novel_background_music_task, create_visual_novel_sound_effect_task, create_visual_novel_background_music_task, create_visual_novel_sound_effect_task,
publish_visual_novel_background_music_asset, publish_visual_novel_sound_effect_asset, publish_visual_novel_background_music_asset, publish_visual_novel_sound_effect_asset,
@@ -499,16 +501,31 @@ pub fn build_router(state: AppState) -> Router {
) )
.route( .route(
"/api/assets/direct-upload-tickets", "/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( .route(
"/api/assets/sts-upload-credentials", "/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( .route(
"/api/assets/objects/bind", "/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( .route(
"/api/assets/character-visual/generate", "/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(normalize_error_response))
// 响应头回写放在错误归一化外侧,确保最终写回的是归一化后的最终响应。 // 响应头回写放在错误归一化外侧,确保最终写回的是归一化后的最终响应。
.layer(middleware::from_fn(propagate_request_id_header)) .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、响应头与错误中间件继续在这里扩展。 // 当前阶段先统一挂接 HTTP tracing后续 request_id、响应头与错误中间件继续在这里扩展。
.layer( .layer(
TraceLayer::new_for_http() TraceLayer::new_for_http()
@@ -1541,6 +1563,31 @@ pub fn build_router(state: AppState) -> Router {
.with_state(state) .with_state(state)
} }
async fn record_api_tracking_after_success(
axum::extract::State(state): axum::extract::State<AppState>,
Extension(request_context): Extension<RequestContext>,
request: Request<Body>,
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::<AuthenticatedAccessToken>()
.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<AppState> { fn creative_agent_router(state: AppState) -> Router<AppState> {
Router::new() Router::new()
.route( .route(

View File

@@ -23,8 +23,13 @@ use shared_contracts::assets::{
use spacetime_client::SpacetimeClientError; use spacetime_client::SpacetimeClientError;
use crate::{ use crate::{
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError, api_response::json_success_body,
platform_errors::map_oss_error, request_context::RequestContext, state::AppState, auth::AuthenticatedAccessToken,
http_error::AppError,
platform_errors::map_oss_error,
request_context::RequestContext,
state::AppState,
tracking::{TrackingEventDraft, record_tracking_event_after_success},
}; };
// 历史素材类型需要与 SpacetimeDB 侧白名单保持同一口径,避免新增素材类型时 HTTP 门面漏同步。 // 历史素材类型需要与 SpacetimeDB 侧白名单保持同一口径,避免新增素材类型时 HTTP 门面漏同步。
@@ -41,6 +46,7 @@ const SUPPORTED_ASSET_HISTORY_KINDS: [&str; 7] = [
pub async fn create_direct_upload_ticket( pub async fn create_direct_upload_ticket(
State(state): State<AppState>, State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>, Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<CreateDirectUploadTicketRequest>, Json(payload): Json<CreateDirectUploadTicketRequest>,
) -> Result<Json<Value>, AppError> { ) -> Result<Json<Value>, AppError> {
let oss_client = state.oss_client().ok_or_else(|| { let oss_client = state.oss_client().ok_or_else(|| {
@@ -75,12 +81,33 @@ pub async fn create_direct_upload_ticket(
"message": error.to_string(), "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( Ok(json_success_body(
Some(&request_context), Some(&request_context),
CreateDirectUploadTicketResponse { CreateDirectUploadTicketResponse { upload },
upload: DirectUploadTicketPayload::from(signed),
},
)) ))
} }
@@ -190,6 +217,7 @@ pub async fn create_sts_upload_credentials(
pub async fn confirm_asset_object( pub async fn confirm_asset_object(
State(state): State<AppState>, State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>, Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<ConfirmAssetObjectRequest>, Json(payload): Json<ConfirmAssetObjectRequest>,
) -> Result<Json<Value>, AppError> { ) -> Result<Json<Value>, AppError> {
let oss_client = state.oss_client().ok_or_else(|| { let oss_client = state.oss_client().ok_or_else(|| {
@@ -209,33 +237,60 @@ pub async fn confirm_asset_object(
.await .await
.map_err(map_confirm_asset_object_error)?; .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( Ok(json_success_body(
Some(&request_context), Some(&request_context),
ConfirmAssetObjectResponse { ConfirmAssetObjectResponse { asset_object },
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,
},
},
)) ))
} }
pub async fn bind_asset_object_to_entity( pub async fn bind_asset_object_to_entity(
State(state): State<AppState>, State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>, Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<BindAssetObjectRequest>, Json(payload): Json<BindAssetObjectRequest>,
) -> Result<Json<Value>, AppError> { ) -> Result<Json<Value>, AppError> {
let now_micros = current_utc_micros(); let now_micros = current_utc_micros();
@@ -258,25 +313,60 @@ pub async fn bind_asset_object_to_entity(
.await .await
.map_err(map_confirm_asset_object_error)?; .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( Ok(json_success_body(
Some(&request_context), Some(&request_context),
BindAssetObjectResponse { BindAssetObjectResponse { asset_binding },
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,
},
},
)) ))
} }
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<String> { fn resolve_object_key_from_query(query: &GetReadUrlQuery) -> Option<String> {
if let Some(object_key) = query if let Some(object_key) = query
.object_key .object_key

View File

@@ -63,9 +63,13 @@ pub async fn require_bearer_auth(
&& let Some(claims) = try_build_internal_forwarded_claims(&state, request.headers()) && let Some(claims) = try_build_internal_forwarded_claims(&state, request.headers())
{ {
request request
.extensions_mut()
.insert(AuthenticatedAccessToken::new(claims.clone()));
let mut response = next.run(request).await;
response
.extensions_mut() .extensions_mut()
.insert(AuthenticatedAccessToken::new(claims)); .insert(AuthenticatedAccessToken::new(claims));
return Ok(next.run(request).await); return Ok(response);
} }
let bearer_token = extract_bearer_token(request.headers())?; let bearer_token = extract_bearer_token(request.headers())?;
@@ -114,10 +118,15 @@ pub async fn require_bearer_auth(
} }
request request
.extensions_mut()
.insert(AuthenticatedAccessToken::new(claims.clone()));
let mut response = next.run(request).await;
response
.extensions_mut() .extensions_mut()
.insert(AuthenticatedAccessToken::new(claims)); .insert(AuthenticatedAccessToken::new(claims));
Ok(next.run(request).await) Ok(response)
} }
pub async fn inspect_auth_claims( pub async fn inspect_auth_claims(

View File

@@ -10,7 +10,10 @@ use platform_auth::{
use time::OffsetDateTime; use time::OffsetDateTime;
use crate::session_client::SessionClientContext; 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)] #[derive(Debug, Clone)]
pub struct SignedAuthSession { pub struct SignedAuthSession {
@@ -29,38 +32,24 @@ pub fn create_password_auth_session(
#[cfg(not(test))] #[cfg(not(test))]
pub async fn record_daily_login_tracking_event_after_auth_success( pub async fn record_daily_login_tracking_event_after_auth_success(
state: &AppState, state: &AppState,
request_context: &crate::request_context::RequestContext, request_context: &RequestContext,
user_id: &str, user_id: &str,
login_method: AuthLoginMethod, login_method: AuthLoginMethod,
) { ) {
// 登录埋点是运营数据,不应反向阻断已经成功的认证会话签发。 // 登录埋点是运营数据,不应反向阻断已经成功的认证会话签发;每日登录也走统一埋点 helper/procedure
match state record_daily_login_tracking_event_via_unified_path(
.spacetime_client() state,
.record_daily_login_tracking_event(user_id.to_string()) request_context,
.await user_id,
{ login_method,
Ok(()) => tracing::info!( )
request_id = request_context.request_id(), .await;
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,
"登录成功每日登录埋点记录失败,登录流程继续"
),
}
} }
#[cfg(test)] #[cfg(test)]
pub async fn record_daily_login_tracking_event_after_auth_success( pub async fn record_daily_login_tracking_event_after_auth_success(
_state: &AppState, _state: &AppState,
_request_context: &crate::request_context::RequestContext, _request_context: &RequestContext,
_user_id: &str, _user_id: &str,
_login_method: AuthLoginMethod, _login_method: AuthLoginMethod,
) { ) {

View File

@@ -65,6 +65,7 @@ use crate::{
request_context::RequestContext, request_context::RequestContext,
state::AppState, state::AppState,
work_author::resolve_work_author_by_user_id, 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( pub async fn create_big_fish_session(
@@ -235,7 +236,7 @@ pub async fn record_big_fish_play(
let items = state let items = state
.spacetime_client() .spacetime_client()
.record_big_fish_play(BigFishPlayReportRecordInput { .record_big_fish_play(BigFishPlayReportRecordInput {
session_id, session_id: session_id.clone(),
user_id: authenticated.claims().user_id().to_string(), user_id: authenticated.claims().user_id().to_string(),
elapsed_ms: payload.elapsed_ms.unwrap_or(0), elapsed_ms: payload.elapsed_ms.unwrap_or(0),
reported_at_micros: current_utc_micros(), 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)) 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( Ok(json_success_body(
Some(&request_context), Some(&request_context),
BigFishWorksResponse { BigFishWorksResponse {

View File

@@ -74,6 +74,7 @@ use crate::{
request_context::RequestContext, request_context::RequestContext,
state::AppState, state::AppState,
work_author::resolve_work_author_by_user_id, 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; const DRAFT_ASSET_GENERATION_MAX_ATTEMPTS: u32 = 3;
@@ -827,7 +828,7 @@ pub async fn record_custom_world_gallery_play(
State(state): State<AppState>, State(state): State<AppState>,
Path((owner_user_id, profile_id)): Path<(String, String)>, Path((owner_user_id, profile_id)): Path<(String, String)>,
Extension(request_context): Extension<RequestContext>, Extension(request_context): Extension<RequestContext>,
Extension(_authenticated): Extension<AuthenticatedAccessToken>, Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> { ) -> Result<Json<Value>, Response> {
if owner_user_id.trim().is_empty() || profile_id.trim().is_empty() { if owner_user_id.trim().is_empty() || profile_id.trim().is_empty() {
return Err(custom_world_error_response( return Err(custom_world_error_response(
@@ -842,8 +843,8 @@ pub async fn record_custom_world_gallery_play(
let mutation = state let mutation = state
.spacetime_client() .spacetime_client()
.record_custom_world_profile_play(CustomWorldProfilePlayReportRecordInput { .record_custom_world_profile_play(CustomWorldProfilePlayReportRecordInput {
owner_user_id, owner_user_id: owner_user_id.clone(),
profile_id, profile_id: profile_id.clone(),
played_at_micros: current_utc_micros(), played_at_micros: current_utc_micros(),
}) })
.await .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)) 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( Ok(json_success_body(
Some(&request_context), Some(&request_context),
CustomWorldGalleryDetailResponse { CustomWorldGalleryDetailResponse {

View File

@@ -69,12 +69,14 @@ mod square_hole_agent_turn;
mod state; mod state;
mod story_battles; mod story_battles;
mod story_sessions; mod story_sessions;
mod tracking;
mod vector_engine_audio_generation; mod vector_engine_audio_generation;
mod visual_novel; mod visual_novel;
mod volcengine_speech; mod volcengine_speech;
mod wechat_auth; mod wechat_auth;
mod wechat_provider; mod wechat_provider;
mod work_author; mod work_author;
mod work_play_tracking;
use shared_logging::init_tracing; use shared_logging::init_tracing;
use std::{collections::HashSet, env, fs, io, panic, thread}; use std::{collections::HashSet, env, fs, io, panic, thread};

View File

@@ -48,8 +48,12 @@ use spacetime_client::{
}; };
use crate::{ use crate::{
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError, api_response::json_success_body,
request_context::RequestContext, state::AppState, 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"; const MATCH3D_AGENT_PROVIDER: &str = "match3d-agent";
@@ -574,7 +578,7 @@ pub async fn start_match3d_run(
.start_match3d_run(Match3DRunStartRecordInput { .start_match3d_run(Match3DRunStartRecordInput {
run_id: build_prefixed_uuid_id(MATCH3D_RUN_ID_PREFIX), run_id: build_prefixed_uuid_id(MATCH3D_RUN_ID_PREFIX),
owner_user_id: authenticated.claims().user_id().to_string(), owner_user_id: authenticated.claims().user_id().to_string(),
profile_id, profile_id: profile_id.clone(),
started_at_ms: current_utc_ms(), started_at_ms: current_utc_ms(),
}) })
.await .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( Ok(json_success_body(
Some(&request_context), Some(&request_context),
Match3DRunResponse { Match3DRunResponse {

View File

@@ -100,6 +100,7 @@ use crate::{
request_context::RequestContext, request_context::RequestContext,
state::AppState, state::AppState,
work_author::resolve_work_author_by_user_id, 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"; const PUZZLE_AGENT_API_BASE_PROVIDER: &str = "puzzle-agent";
@@ -1539,8 +1540,8 @@ pub async fn start_puzzle_run(
.start_puzzle_run(PuzzleRunStartRecordInput { .start_puzzle_run(PuzzleRunStartRecordInput {
run_id: build_prefixed_uuid_id("puzzle-run-"), run_id: build_prefixed_uuid_id("puzzle-run-"),
owner_user_id: authenticated.claims().user_id().to_string(), owner_user_id: authenticated.claims().user_id().to_string(),
profile_id: payload.profile_id, profile_id: payload.profile_id.clone(),
level_id: payload.level_id, level_id: payload.level_id.clone(),
started_at_micros: current_utc_micros(), started_at_micros: current_utc_micros(),
}) })
.await .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( Ok(json_success_body(
Some(&request_context), Some(&request_context),
PuzzleRunResponse { PuzzleRunResponse {

View File

@@ -76,6 +76,7 @@ use crate::{
SquareHoleAgentTurnRequest, build_finalize_record_input, run_square_hole_agent_turn, SquareHoleAgentTurnRequest, build_finalize_record_input, run_square_hole_agent_turn,
}, },
state::AppState, state::AppState,
work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success},
}; };
const SQUARE_HOLE_AGENT_PROVIDER: &str = "square-hole-agent"; 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 { .start_square_hole_run(SquareHoleRunStartRecordInput {
run_id: build_prefixed_uuid_id(SQUARE_HOLE_RUN_ID_PREFIX), run_id: build_prefixed_uuid_id(SQUARE_HOLE_RUN_ID_PREFIX),
owner_user_id: authenticated.claims().user_id().to_string(), owner_user_id: authenticated.claims().user_id().to_string(),
profile_id, profile_id: profile_id.clone(),
started_at_ms: current_utc_ms(), started_at_ms: current_utc_ms(),
}) })
.await .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( Ok(json_success_body(
Some(&request_context), Some(&request_context),
SquareHoleRunResponse { SquareHoleRunResponse {

View File

@@ -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<String>,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
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<RouteTrackingSpec> {
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)
}

View File

@@ -29,9 +29,14 @@ use spacetime_client::{
use time::OffsetDateTime; use time::OffsetDateTime;
use crate::{ use crate::{
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError, api_response::json_success_body,
prompt::visual_novel as vn_prompt, request_context::RequestContext, state::AppState, 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_author::resolve_work_author_by_user_id,
work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success},
}; };
const VISUAL_NOVEL_PROVIDER: &str = "visual-novel"; const VISUAL_NOVEL_PROVIDER: &str = "visual-novel";
@@ -445,7 +450,7 @@ pub async fn start_visual_novel_run(
.start_visual_novel_run(VisualNovelRunStartRecordInput { .start_visual_novel_run(VisualNovelRunStartRecordInput {
run_id: build_prefixed_uuid_id(domain::VISUAL_NOVEL_RUN_ID_PREFIX), run_id: build_prefixed_uuid_id(domain::VISUAL_NOVEL_RUN_ID_PREFIX),
owner_user_id: authenticated.claims().user_id().to_string(), 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(), mode: run_mode_to_wire(&payload.mode).to_string(),
snapshot_json: None, snapshot_json: None,
started_at_micros: current_utc_micros(), 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)) 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( Ok(json_success_body(
Some(&request_context), Some(&request_context),
contract::VisualNovelRunResponse { contract::VisualNovelRunResponse {

View File

@@ -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<String>,
pub profile_id: Option<String>,
pub run_id: Option<String>,
pub source_route: &'static str,
pub extra: Value,
}
impl WorkPlayTrackingDraft {
pub(crate) fn new(
play_type: &'static str,
work_id: impl Into<String>,
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<String>) -> Self {
self.owner_user_id = Some(owner_user_id.into());
self
}
pub(crate) fn profile_id(mut self, profile_id: impl Into<String>) -> Self {
self.profile_id = Some(profile_id.into());
self
}
pub(crate) fn run_id(mut self, run_id: impl Into<String>) -> Self {
self.run_id = Some(run_id.into());
self
}
pub(crate) fn extra(mut self, extra: Value) -> Self {
self.extra = extra;
self
}
}
/// 作品级正式游玩埋点scope 固定为 workscope_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");
}
}

View File

@@ -25,4 +25,5 @@ serde_json = { workspace = true }
shared-contracts = { workspace = true } shared-contracts = { workspace = true }
shared-kernel = { workspace = true } shared-kernel = { workspace = true }
spacetimedb-sdk = { workspace = true } spacetimedb-sdk = { workspace = true }
time = { workspace = true }
tokio = { workspace = true, features = ["rt", "sync", "time"] } tokio = { workspace = true, features = ["rt", "sync", "time"] }

View File

@@ -529,6 +529,7 @@ pub mod record_custom_world_profile_like_procedure;
pub mod record_custom_world_profile_play_procedure; pub mod record_custom_world_profile_play_procedure;
pub mod record_daily_login_tracking_event_and_return_procedure; pub mod record_daily_login_tracking_event_and_return_procedure;
pub mod record_puzzle_work_like_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 record_visual_novel_runtime_event_procedure;
pub mod redeem_profile_referral_invite_code_procedure; pub mod redeem_profile_referral_invite_code_procedure;
pub mod redeem_profile_reward_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_table;
pub mod runtime_snapshot_type; pub mod runtime_snapshot_type;
pub mod runtime_snapshot_upsert_input_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_event_procedure_result_type;
pub mod runtime_tracking_scope_kind_type; pub mod runtime_tracking_scope_kind_type;
pub mod save_puzzle_form_draft_procedure; 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_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_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_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 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_referral_invite_code_procedure::redeem_profile_referral_invite_code;
pub use redeem_profile_reward_code_procedure::redeem_profile_reward_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_table::*;
pub use runtime_snapshot_type::RuntimeSnapshot; pub use runtime_snapshot_type::RuntimeSnapshot;
pub use runtime_snapshot_upsert_input_type::RuntimeSnapshotUpsertInput; 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_event_procedure_result_type::RuntimeTrackingEventProcedureResult;
pub use runtime_tracking_scope_kind_type::RuntimeTrackingScopeKind; pub use runtime_tracking_scope_kind_type::RuntimeTrackingScopeKind;
pub use save_puzzle_form_draft_procedure::save_puzzle_form_draft; pub use save_puzzle_form_draft_procedure::save_puzzle_form_draft;

View File

@@ -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<RuntimeTrackingEventProcedureResult, __sdk::InternalError>,
) + 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<RuntimeTrackingEventProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, RuntimeTrackingEventProcedureResult>(
"record_tracking_event_and_return",
RecordTrackingEventAndReturnArgs { input },
__callback,
);
}
}

View File

@@ -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<String>,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub module_key: Option<String>,
pub metadata_json: String,
pub occurred_at_micros: i64,
}
impl __sdk::InModule for RuntimeTrackingEventInput {
type Module = super::RemoteModule;
}

View File

@@ -339,22 +339,60 @@ impl SpacetimeClient {
&self, &self,
user_id: String, user_id: String,
) -> Result<(), SpacetimeClientError> { ) -> Result<(), SpacetimeClientError> {
let procedure_input = build_runtime_profile_task_center_get_input(user_id) let normalized_user_id = user_id.trim().to_string();
.map_err(SpacetimeClientError::validation_failed)? let occurred_at_micros =
.into(); 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<String>,
owner_user_id: Option<String>,
profile_id: Option<String>,
module_key: Option<String>,
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| { self.call_after_connect(move |connection, sender| {
connection connection
.procedures() .procedures()
.record_daily_login_tracking_event_and_return_then( .record_tracking_event_and_return_then(procedure_input, move |_, result| {
procedure_input, let mapped = result
move |_, result| { .map_err(SpacetimeClientError::from_sdk_error)
let mapped = result .and_then(map_runtime_tracking_event_procedure_result);
.map_err(SpacetimeClientError::from_sdk_error) send_once(&sender, mapped);
.and_then(map_runtime_tracking_event_procedure_result); });
send_once(&sender, mapped);
},
);
}) })
.await .await
} }
@@ -885,3 +923,9 @@ impl SpacetimeClient {
.await .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)
}

View File

@@ -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] #[spacetimedb::procedure]
pub fn record_daily_login_tracking_event_and_return( pub fn record_daily_login_tracking_event_and_return(