This commit is contained in:
@@ -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
|
||||||
|
|||||||
243
docs/technical/BACKEND_TRACKING_EVENT_COVERAGE_2026-05-09.md
Normal file
243
docs/technical/BACKEND_TRACKING_EVENT_COVERAGE_2026-05-09.md
Normal 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 不包含凭证、请求体或敏感业务字段。
|
||||||
@@ -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,并明确“登录流程继续”
|
||||||
|
|
||||||
|
|||||||
@@ -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
1
server-rs/Cargo.lock
generated
@@ -3124,6 +3124,7 @@ dependencies = [
|
|||||||
"shared-contracts",
|
"shared-contracts",
|
||||||
"shared-kernel",
|
"shared-kernel",
|
||||||
"spacetimedb-sdk",
|
"spacetimedb-sdk",
|
||||||
|
"time",
|
||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
588
server-rs/crates/api-server/src/tracking.rs
Normal file
588
server-rs/crates/api-server/src/tracking.rs
Normal 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)
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
111
server-rs/crates/api-server/src/work_play_tracking.rs
Normal file
111
server-rs/crates/api-server/src/work_play_tracking.rs
Normal 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 固定为 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"] }
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user