Merge pull request 'hermes/hermes-996d586b' (#9) from hermes/hermes-996d586b into master
Some checks failed
CI / verify (push) Has been cancelled
Some checks failed
CI / verify (push) Has been cancelled
Reviewed-on: http://82.157.175.59:3000/GenarrativeAI/Genarrative/pulls/9
This commit was merged in pull request #9.
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -31,3 +31,4 @@ temp*build*/
|
|||||||
/target/
|
/target/
|
||||||
/logs
|
/logs
|
||||||
.worktrees/
|
.worktrees/
|
||||||
|
.env.secrets.local
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# 登录成功每日登录埋点闭环方案(2026-05-08)
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
后台“埋点数据”需要能看到真实登录触发的 `daily_login` 埋点。此前方案 A 已把“读取任务中心时顺手写每日登录埋点”拆成独立 SpacetimeDB procedure:
|
||||||
|
|
||||||
|
- `record_daily_login_tracking_event_and_return`
|
||||||
|
- `spacetime-client` 方法:`record_daily_login_tracking_event(user_id)`
|
||||||
|
|
||||||
|
但认证成功链路还没有调用该方法,因此当前只完成了“任务中心读取不污染登录埋点”,没有完成“用户真实登录写入每日登录埋点”。
|
||||||
|
|
||||||
|
## 现象
|
||||||
|
|
||||||
|
用户已经登录、cookie 未过期时,直接打开网页并不会触发每日登录埋点。原因是前端恢复登录态只读取 `/api/auth/me`,这条链路不会主动走 refresh cookie 续期,因此后端新的埋点写入点不会被触发。
|
||||||
|
|
||||||
|
## 修复思路
|
||||||
|
|
||||||
|
在 `AuthGate` 恢复已登录会话时,先主动调用一次 refresh 接口轮换 refresh cookie,再调用 `/api/auth/me` 读取当前会话。这样无论本地 access token 是否仍然有效,打开页面都会进入 refresh 续期链路,从而触发后端的 `daily_login` 埋点写入。
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
在用户认证成功并创建 refresh session / access token 后,异步尝试写入每日登录埋点。
|
||||||
|
|
||||||
|
覆盖入口:
|
||||||
|
|
||||||
|
- 手机验证码登录:`POST /api/auth/phone/login`
|
||||||
|
- 密码入口登录:`POST /api/auth/entry`
|
||||||
|
- 重置密码后自动登录:`POST /api/auth/password/reset`
|
||||||
|
- 微信 OAuth callback 登录:`GET /api/auth/wechat/callback`
|
||||||
|
- 微信绑定手机号后激活/登录态刷新:`POST /api/auth/wechat/bind-phone`
|
||||||
|
- refresh cookie 续期:`POST /api/auth/session/refresh`
|
||||||
|
|
||||||
|
## 设计约束
|
||||||
|
|
||||||
|
1. 埋点写入不能阻断登录成功响应。
|
||||||
|
2. 只有认证成功并已创建会话后,或 refresh session rotate 成功并签发新 access token 后才记录。
|
||||||
|
3. 失败只记 warning,继续返回 token / cookie。
|
||||||
|
4. 写入统一收口,避免多个登录 handler 各自拼 procedure 调用。
|
||||||
|
5. 不修改 SpacetimeDB 表结构,不需要更新 `migration.rs`。
|
||||||
|
|
||||||
|
## 实现方案
|
||||||
|
|
||||||
|
新增 `api-server` 内部 helper:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
record_daily_login_tracking_event_after_auth_success(
|
||||||
|
state: &AppState,
|
||||||
|
request_context: &RequestContext,
|
||||||
|
user_id: &str,
|
||||||
|
login_method: AuthLoginMethod,
|
||||||
|
).await;
|
||||||
|
```
|
||||||
|
|
||||||
|
该 helper:
|
||||||
|
|
||||||
|
- 调用 `state.spacetime_client().record_daily_login_tracking_event(user_id.to_string()).await`
|
||||||
|
- 成功时记录 info
|
||||||
|
- 失败时记录 warn,并明确“登录流程继续”
|
||||||
|
|
||||||
|
在各登录入口 `create_auth_session` 成功后调用该 helper。refresh cookie 续期在 `rotate_session` 和 `sign_access_token_for_user` 成功后调用同一个 helper,`login_method` 使用 refresh session 上保存的 `issued_by_provider`,避免把续期统一误标成 password。
|
||||||
|
|
||||||
|
## 验收
|
||||||
|
|
||||||
|
- `cargo test -p api-server auth_session -- --nocapture`
|
||||||
|
- `cargo check -p api-server`
|
||||||
|
- `cargo check -p spacetime-client`
|
||||||
|
- `npm run check:encoding`
|
||||||
|
- `git diff --check`
|
||||||
|
- `npm run test -- AuthGate.test.tsx`
|
||||||
|
|
||||||
|
## 注意
|
||||||
|
|
||||||
|
`npm run dev` 是长期运行进程;如需本地 smoke,应启动后用 `/healthz` 和后台页面验证,不要等待该命令退出。
|
||||||
@@ -26,6 +26,47 @@ pub fn create_password_auth_session(
|
|||||||
create_auth_session(state, user, session_client, AuthLoginMethod::Password)
|
create_auth_session(state, user, session_client, AuthLoginMethod::Password)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(test))]
|
||||||
|
pub async fn record_daily_login_tracking_event_after_auth_success(
|
||||||
|
state: &AppState,
|
||||||
|
request_context: &crate::request_context::RequestContext,
|
||||||
|
user_id: &str,
|
||||||
|
login_method: AuthLoginMethod,
|
||||||
|
) {
|
||||||
|
// 登录埋点是运营数据,不应反向阻断已经成功的认证会话签发。
|
||||||
|
match state
|
||||||
|
.spacetime_client()
|
||||||
|
.record_daily_login_tracking_event(user_id.to_string())
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(()) => tracing::info!(
|
||||||
|
request_id = request_context.request_id(),
|
||||||
|
operation = request_context.operation(),
|
||||||
|
user_id = %user_id,
|
||||||
|
login_method = %login_method.as_str(),
|
||||||
|
"登录成功每日登录埋点已记录"
|
||||||
|
),
|
||||||
|
Err(error) => tracing::warn!(
|
||||||
|
request_id = request_context.request_id(),
|
||||||
|
operation = request_context.operation(),
|
||||||
|
user_id = %user_id,
|
||||||
|
login_method = %login_method.as_str(),
|
||||||
|
error = %error,
|
||||||
|
"登录成功每日登录埋点记录失败,登录流程继续"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub async fn record_daily_login_tracking_event_after_auth_success(
|
||||||
|
_state: &AppState,
|
||||||
|
_request_context: &crate::request_context::RequestContext,
|
||||||
|
_user_id: &str,
|
||||||
|
_login_method: AuthLoginMethod,
|
||||||
|
) {
|
||||||
|
// 单元测试默认不启动 SpacetimeDB;这里仅验证登录链路调用点能通过编译并保持非阻断语义。
|
||||||
|
}
|
||||||
|
|
||||||
pub fn create_auth_session(
|
pub fn create_auth_session(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
user: &AuthUser,
|
user: &AuthUser,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use axum::{
|
|||||||
http::{HeaderMap, StatusCode},
|
http::{HeaderMap, StatusCode},
|
||||||
response::IntoResponse,
|
response::IntoResponse,
|
||||||
};
|
};
|
||||||
use module_auth::{PasswordEntryError, PasswordEntryInput};
|
use module_auth::{AuthLoginMethod, PasswordEntryError, PasswordEntryInput};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use shared_contracts::auth::{PasswordEntryRequest, PasswordEntryResponse};
|
use shared_contracts::auth::{PasswordEntryRequest, PasswordEntryResponse};
|
||||||
|
|
||||||
@@ -12,7 +12,8 @@ use crate::{
|
|||||||
api_response::json_success_body,
|
api_response::json_success_body,
|
||||||
auth_payload::map_auth_user_payload,
|
auth_payload::map_auth_user_payload,
|
||||||
auth_session::{
|
auth_session::{
|
||||||
attach_set_cookie_header, build_refresh_session_cookie_header, create_password_auth_session,
|
attach_set_cookie_header, build_refresh_session_cookie_header,
|
||||||
|
create_password_auth_session, record_daily_login_tracking_event_after_auth_success,
|
||||||
},
|
},
|
||||||
http_error::AppError,
|
http_error::AppError,
|
||||||
request_context::RequestContext,
|
request_context::RequestContext,
|
||||||
@@ -49,6 +50,13 @@ pub async fn password_entry(
|
|||||||
}
|
}
|
||||||
let session_client = resolve_session_client_context(&headers);
|
let session_client = resolve_session_client_context(&headers);
|
||||||
let signed_session = create_password_auth_session(&state, &result.user, &session_client)?;
|
let signed_session = create_password_auth_session(&state, &result.user, &session_client)?;
|
||||||
|
record_daily_login_tracking_event_after_auth_success(
|
||||||
|
&state,
|
||||||
|
&request_context,
|
||||||
|
&result.user.id,
|
||||||
|
AuthLoginMethod::Password,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
state
|
state
|
||||||
.sync_auth_store_snapshot_to_spacetime()
|
.sync_auth_store_snapshot_to_spacetime()
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ use crate::{
|
|||||||
auth_payload::map_auth_user_payload,
|
auth_payload::map_auth_user_payload,
|
||||||
auth_session::{
|
auth_session::{
|
||||||
attach_set_cookie_header, build_refresh_session_cookie_header, create_auth_session,
|
attach_set_cookie_header, build_refresh_session_cookie_header, create_auth_session,
|
||||||
|
record_daily_login_tracking_event_after_auth_success,
|
||||||
},
|
},
|
||||||
http_error::AppError,
|
http_error::AppError,
|
||||||
phone_auth::map_phone_auth_error,
|
phone_auth::map_phone_auth_error,
|
||||||
@@ -79,6 +80,13 @@ pub async fn reset_password(
|
|||||||
&session_client,
|
&session_client,
|
||||||
module_auth::AuthLoginMethod::Password,
|
module_auth::AuthLoginMethod::Password,
|
||||||
)?;
|
)?;
|
||||||
|
record_daily_login_tracking_event_after_auth_success(
|
||||||
|
&state,
|
||||||
|
&request_context,
|
||||||
|
&result.user.id,
|
||||||
|
module_auth::AuthLoginMethod::Password,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
let mut headers = HeaderMap::new();
|
let mut headers = HeaderMap::new();
|
||||||
attach_set_cookie_header(
|
attach_set_cookie_header(
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ use crate::{
|
|||||||
auth_payload::map_auth_user_payload,
|
auth_payload::map_auth_user_payload,
|
||||||
auth_session::{
|
auth_session::{
|
||||||
attach_set_cookie_header, build_refresh_session_cookie_header, create_auth_session,
|
attach_set_cookie_header, build_refresh_session_cookie_header, create_auth_session,
|
||||||
|
record_daily_login_tracking_event_after_auth_success,
|
||||||
},
|
},
|
||||||
http_error::AppError,
|
http_error::AppError,
|
||||||
platform_errors::{attach_retry_after, map_phone_auth_platform_store_error},
|
platform_errors::{attach_retry_after, map_phone_auth_platform_store_error},
|
||||||
@@ -176,6 +177,13 @@ pub async fn phone_login(
|
|||||||
&session_client,
|
&session_client,
|
||||||
AuthLoginMethod::Phone,
|
AuthLoginMethod::Phone,
|
||||||
)?;
|
)?;
|
||||||
|
record_daily_login_tracking_event_after_auth_success(
|
||||||
|
&state,
|
||||||
|
&request_context,
|
||||||
|
&result.user.id,
|
||||||
|
AuthLoginMethod::Phone,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
state
|
state
|
||||||
.sync_auth_store_snapshot_to_spacetime()
|
.sync_auth_store_snapshot_to_spacetime()
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ use crate::{
|
|||||||
auth::RefreshSessionToken,
|
auth::RefreshSessionToken,
|
||||||
auth_session::{
|
auth_session::{
|
||||||
attach_set_cookie_header, build_clear_refresh_session_cookie_header,
|
attach_set_cookie_header, build_clear_refresh_session_cookie_header,
|
||||||
build_refresh_session_cookie_header, map_refresh_session_error, sign_access_token_for_user,
|
build_refresh_session_cookie_header, map_refresh_session_error,
|
||||||
|
record_daily_login_tracking_event_after_auth_success, sign_access_token_for_user,
|
||||||
},
|
},
|
||||||
http_error::AppError,
|
http_error::AppError,
|
||||||
request_context::RequestContext,
|
request_context::RequestContext,
|
||||||
@@ -54,6 +55,13 @@ pub async fn refresh_session(
|
|||||||
&rotated.session.session_id,
|
&rotated.session.session_id,
|
||||||
Some(&rotated.session.issued_by_provider),
|
Some(&rotated.session.issued_by_provider),
|
||||||
)?;
|
)?;
|
||||||
|
record_daily_login_tracking_event_after_auth_success(
|
||||||
|
&state,
|
||||||
|
&request_context,
|
||||||
|
&rotated.user.id,
|
||||||
|
rotated.session.issued_by_provider.clone(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
state
|
state
|
||||||
.sync_auth_store_snapshot_to_spacetime()
|
.sync_auth_store_snapshot_to_spacetime()
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ use crate::{
|
|||||||
auth_payload::map_auth_user_payload,
|
auth_payload::map_auth_user_payload,
|
||||||
auth_session::{
|
auth_session::{
|
||||||
attach_set_cookie_header, build_refresh_session_cookie_header, create_auth_session,
|
attach_set_cookie_header, build_refresh_session_cookie_header, create_auth_session,
|
||||||
|
record_daily_login_tracking_event_after_auth_success,
|
||||||
},
|
},
|
||||||
http_error::AppError,
|
http_error::AppError,
|
||||||
platform_errors::{attach_retry_after, map_wechat_provider_error},
|
platform_errors::{attach_retry_after, map_wechat_provider_error},
|
||||||
@@ -74,6 +75,7 @@ pub async fn start_wechat_login(
|
|||||||
|
|
||||||
pub async fn handle_wechat_callback(
|
pub async fn handle_wechat_callback(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
Extension(request_context): Extension<RequestContext>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
Query(query): Query<WechatCallbackQuery>,
|
Query(query): Query<WechatCallbackQuery>,
|
||||||
) -> Result<impl IntoResponse, AppError> {
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
@@ -141,6 +143,13 @@ pub async fn handle_wechat_callback(
|
|||||||
&session_client,
|
&session_client,
|
||||||
AuthLoginMethod::Wechat,
|
AuthLoginMethod::Wechat,
|
||||||
)?;
|
)?;
|
||||||
|
record_daily_login_tracking_event_after_auth_success(
|
||||||
|
&state,
|
||||||
|
&request_context,
|
||||||
|
&result.user.id,
|
||||||
|
AuthLoginMethod::Wechat,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
state
|
state
|
||||||
.sync_auth_store_snapshot_to_spacetime()
|
.sync_auth_store_snapshot_to_spacetime()
|
||||||
.await
|
.await
|
||||||
@@ -208,6 +217,13 @@ pub async fn bind_wechat_phone(
|
|||||||
&session_client,
|
&session_client,
|
||||||
AuthLoginMethod::Wechat,
|
AuthLoginMethod::Wechat,
|
||||||
)?;
|
)?;
|
||||||
|
record_daily_login_tracking_event_after_auth_success(
|
||||||
|
&state,
|
||||||
|
&request_context,
|
||||||
|
&result.user.id,
|
||||||
|
AuthLoginMethod::Wechat,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
state
|
state
|
||||||
.sync_auth_store_snapshot_to_spacetime()
|
.sync_auth_store_snapshot_to_spacetime()
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -544,6 +544,13 @@ pub struct RuntimeTrackingEventInput {
|
|||||||
pub occurred_at_micros: i64,
|
pub occurred_at_micros: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct RuntimeTrackingEventProcedureResult {
|
||||||
|
pub ok: bool,
|
||||||
|
pub error_message: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct RuntimeProfileTaskConfigSnapshot {
|
pub struct RuntimeProfileTaskConfigSnapshot {
|
||||||
|
|||||||
@@ -894,6 +894,16 @@ pub(crate) fn map_runtime_profile_reward_code_redeem_procedure_result(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn map_runtime_tracking_event_procedure_result(
|
||||||
|
result: RuntimeTrackingEventProcedureResult,
|
||||||
|
) -> Result<(), SpacetimeClientError> {
|
||||||
|
if !result.ok {
|
||||||
|
return Err(SpacetimeClientError::procedure_failed(result.error_message));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn map_runtime_profile_task_center_procedure_result(
|
pub(crate) fn map_runtime_profile_task_center_procedure_result(
|
||||||
result: RuntimeProfileTaskCenterProcedureResult,
|
result: RuntimeProfileTaskCenterProcedureResult,
|
||||||
) -> Result<RuntimeProfileTaskCenterRecord, SpacetimeClientError> {
|
) -> Result<RuntimeProfileTaskCenterRecord, SpacetimeClientError> {
|
||||||
|
|||||||
@@ -514,6 +514,7 @@ pub mod record_big_fish_like_procedure;
|
|||||||
pub mod record_big_fish_play_procedure;
|
pub mod record_big_fish_play_procedure;
|
||||||
pub mod record_custom_world_profile_like_procedure;
|
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_puzzle_work_like_procedure;
|
pub mod record_puzzle_work_like_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;
|
||||||
@@ -643,6 +644,7 @@ 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_scope_kind_type;
|
pub mod runtime_tracking_scope_kind_type;
|
||||||
|
pub mod runtime_tracking_event_procedure_result_type;
|
||||||
pub mod save_puzzle_form_draft_procedure;
|
pub mod save_puzzle_form_draft_procedure;
|
||||||
pub mod save_puzzle_generated_images_procedure;
|
pub mod save_puzzle_generated_images_procedure;
|
||||||
pub mod seed_analytics_date_dimensions_reducer;
|
pub mod seed_analytics_date_dimensions_reducer;
|
||||||
@@ -1248,6 +1250,7 @@ pub use record_big_fish_like_procedure::record_big_fish_like;
|
|||||||
pub use record_big_fish_play_procedure::record_big_fish_play;
|
pub use record_big_fish_play_procedure::record_big_fish_play;
|
||||||
pub use record_custom_world_profile_like_procedure::record_custom_world_profile_like;
|
pub use record_custom_world_profile_like_procedure::record_custom_world_profile_like;
|
||||||
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_puzzle_work_like_procedure::record_puzzle_work_like;
|
pub use record_puzzle_work_like_procedure::record_puzzle_work_like;
|
||||||
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;
|
||||||
@@ -1377,6 +1380,7 @@ 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_scope_kind_type::RuntimeTrackingScopeKind;
|
pub use runtime_tracking_scope_kind_type::RuntimeTrackingScopeKind;
|
||||||
|
pub use runtime_tracking_event_procedure_result_type::RuntimeTrackingEventProcedureResult;
|
||||||
pub use save_puzzle_form_draft_procedure::save_puzzle_form_draft;
|
pub use save_puzzle_form_draft_procedure::save_puzzle_form_draft;
|
||||||
pub use save_puzzle_generated_images_procedure::save_puzzle_generated_images;
|
pub use save_puzzle_generated_images_procedure::save_puzzle_generated_images;
|
||||||
pub use seed_analytics_date_dimensions_reducer::seed_analytics_date_dimensions;
|
pub use seed_analytics_date_dimensions_reducer::seed_analytics_date_dimensions;
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
// 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_profile_task_center_get_input_type::RuntimeProfileTaskCenterGetInput;
|
||||||
|
use super::runtime_tracking_event_procedure_result_type::RuntimeTrackingEventProcedureResult;
|
||||||
|
|
||||||
|
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||||
|
#[sats(crate = __lib)]
|
||||||
|
struct RecordDailyLoginTrackingEventAndReturnArgs {
|
||||||
|
pub input: RuntimeProfileTaskCenterGetInput,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl __sdk::InModule for RecordDailyLoginTrackingEventAndReturnArgs {
|
||||||
|
type Module = super::RemoteModule;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(non_camel_case_types)]
|
||||||
|
/// Extension trait for access to the procedure `record_daily_login_tracking_event_and_return`.
|
||||||
|
///
|
||||||
|
/// Implemented for [`super::RemoteProcedures`].
|
||||||
|
pub trait record_daily_login_tracking_event_and_return {
|
||||||
|
fn record_daily_login_tracking_event_and_return(
|
||||||
|
&self,
|
||||||
|
input: RuntimeProfileTaskCenterGetInput,
|
||||||
|
) {
|
||||||
|
self.record_daily_login_tracking_event_and_return_then(input, |_, _| {});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_daily_login_tracking_event_and_return_then(
|
||||||
|
&self,
|
||||||
|
input: RuntimeProfileTaskCenterGetInput,
|
||||||
|
|
||||||
|
__callback: impl FnOnce(
|
||||||
|
&super::ProcedureEventContext,
|
||||||
|
Result<RuntimeTrackingEventProcedureResult, __sdk::InternalError>,
|
||||||
|
) + Send
|
||||||
|
+ 'static,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
impl record_daily_login_tracking_event_and_return for super::RemoteProcedures {
|
||||||
|
fn record_daily_login_tracking_event_and_return_then(
|
||||||
|
&self,
|
||||||
|
input: RuntimeProfileTaskCenterGetInput,
|
||||||
|
|
||||||
|
__callback: impl FnOnce(
|
||||||
|
&super::ProcedureEventContext,
|
||||||
|
Result<RuntimeTrackingEventProcedureResult, __sdk::InternalError>,
|
||||||
|
) + Send
|
||||||
|
+ 'static,
|
||||||
|
) {
|
||||||
|
self.imp
|
||||||
|
.invoke_procedure_with_callback::<_, RuntimeTrackingEventProcedureResult>(
|
||||||
|
"record_daily_login_tracking_event_and_return",
|
||||||
|
RecordDailyLoginTrackingEventAndReturnArgs { input },
|
||||||
|
__callback,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
// 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};
|
||||||
|
|
||||||
|
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||||
|
#[sats(crate = __lib)]
|
||||||
|
pub struct RuntimeTrackingEventProcedureResult {
|
||||||
|
pub ok: bool,
|
||||||
|
pub error_message: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl __sdk::InModule for RuntimeTrackingEventProcedureResult {
|
||||||
|
type Module = super::RemoteModule;
|
||||||
|
}
|
||||||
@@ -304,6 +304,30 @@ impl SpacetimeClient {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn record_daily_login_tracking_event(
|
||||||
|
&self,
|
||||||
|
user_id: String,
|
||||||
|
) -> Result<(), SpacetimeClientError> {
|
||||||
|
let procedure_input = build_runtime_profile_task_center_get_input(user_id)
|
||||||
|
.map_err(SpacetimeClientError::validation_failed)?
|
||||||
|
.into();
|
||||||
|
|
||||||
|
self.call_after_connect(move |connection, sender| {
|
||||||
|
connection
|
||||||
|
.procedures()
|
||||||
|
.record_daily_login_tracking_event_and_return_then(
|
||||||
|
procedure_input,
|
||||||
|
move |_, result| {
|
||||||
|
let mapped = result
|
||||||
|
.map_err(SpacetimeClientError::from_sdk_error)
|
||||||
|
.and_then(map_runtime_tracking_event_procedure_result);
|
||||||
|
send_once(&sender, mapped);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get_profile_task_center(
|
pub async fn get_profile_task_center(
|
||||||
&self,
|
&self,
|
||||||
user_id: String,
|
user_id: String,
|
||||||
|
|||||||
@@ -491,13 +491,36 @@ pub fn query_analytics_metric(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 任务中心读取会顺手记录当日登录埋点,确保“每日登录”只依赖后端事实。
|
// 登录成功埋点由认证链路主动调用;任务中心只负责读取和刷新任务进度。
|
||||||
|
#[spacetimedb::procedure]
|
||||||
|
pub fn record_daily_login_tracking_event_and_return(
|
||||||
|
ctx: &mut ProcedureContext,
|
||||||
|
input: RuntimeProfileTaskCenterGetInput,
|
||||||
|
) -> RuntimeTrackingEventProcedureResult {
|
||||||
|
match ctx.try_with_tx(|tx| {
|
||||||
|
let validated_input = build_runtime_profile_task_center_get_input(input.user_id.clone())
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
ensure_default_profile_task_config(tx);
|
||||||
|
record_daily_login_tracking_event(tx, &validated_input.user_id)
|
||||||
|
}) {
|
||||||
|
Ok(()) => RuntimeTrackingEventProcedureResult {
|
||||||
|
ok: true,
|
||||||
|
error_message: None,
|
||||||
|
},
|
||||||
|
Err(message) => RuntimeTrackingEventProcedureResult {
|
||||||
|
ok: false,
|
||||||
|
error_message: Some(message),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 任务中心读取会刷新进度;每日登录埋点应由登录成功链路提前记录。
|
||||||
#[spacetimedb::procedure]
|
#[spacetimedb::procedure]
|
||||||
pub fn get_profile_task_center(
|
pub fn get_profile_task_center(
|
||||||
ctx: &mut ProcedureContext,
|
ctx: &mut ProcedureContext,
|
||||||
input: RuntimeProfileTaskCenterGetInput,
|
input: RuntimeProfileTaskCenterGetInput,
|
||||||
) -> RuntimeProfileTaskCenterProcedureResult {
|
) -> RuntimeProfileTaskCenterProcedureResult {
|
||||||
match ctx.try_with_tx(|tx| get_profile_task_center_snapshot(tx, input.clone(), true)) {
|
match ctx.try_with_tx(|tx| get_profile_task_center_snapshot(tx, input.clone(), false)) {
|
||||||
Ok(record) => RuntimeProfileTaskCenterProcedureResult {
|
Ok(record) => RuntimeProfileTaskCenterProcedureResult {
|
||||||
ok: true,
|
ok: true,
|
||||||
record: Some(record),
|
record: Some(record),
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const authMocks = vi.hoisted(() => ({
|
|||||||
authEntry: vi.fn(),
|
authEntry: vi.fn(),
|
||||||
changePassword: vi.fn(),
|
changePassword: vi.fn(),
|
||||||
ensureStoredAccessToken: vi.fn(),
|
ensureStoredAccessToken: vi.fn(),
|
||||||
|
refreshStoredAccessToken: vi.fn(),
|
||||||
getAuthLoginOptions: vi.fn(),
|
getAuthLoginOptions: vi.fn(),
|
||||||
getCurrentAuthUser: vi.fn(),
|
getCurrentAuthUser: vi.fn(),
|
||||||
loginWithPhoneCode: vi.fn(),
|
loginWithPhoneCode: vi.fn(),
|
||||||
@@ -28,6 +29,7 @@ const authMocks = vi.hoisted(() => ({
|
|||||||
vi.mock('../../services/apiClient', () => ({
|
vi.mock('../../services/apiClient', () => ({
|
||||||
AUTH_STATE_EVENT: 'genarrative-auth-state-changed',
|
AUTH_STATE_EVENT: 'genarrative-auth-state-changed',
|
||||||
ensureStoredAccessToken: authMocks.ensureStoredAccessToken,
|
ensureStoredAccessToken: authMocks.ensureStoredAccessToken,
|
||||||
|
refreshStoredAccessToken: authMocks.refreshStoredAccessToken,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../services/authService', () => ({
|
vi.mock('../../services/authService', () => ({
|
||||||
@@ -94,6 +96,7 @@ beforeEach(() => {
|
|||||||
window.history.replaceState(null, '', '/');
|
window.history.replaceState(null, '', '/');
|
||||||
authMocks.consumeAuthCallbackResult.mockReturnValue(null);
|
authMocks.consumeAuthCallbackResult.mockReturnValue(null);
|
||||||
authMocks.ensureStoredAccessToken.mockResolvedValue('jwt-existing-token');
|
authMocks.ensureStoredAccessToken.mockResolvedValue('jwt-existing-token');
|
||||||
|
authMocks.refreshStoredAccessToken.mockResolvedValue('jwt-refreshed-token');
|
||||||
authMocks.getCurrentAuthUser.mockResolvedValue({
|
authMocks.getCurrentAuthUser.mockResolvedValue({
|
||||||
user: null,
|
user: null,
|
||||||
availableLoginMethods: ['phone'],
|
availableLoginMethods: ['phone'],
|
||||||
@@ -204,12 +207,12 @@ test('auth gate keeps platform content visible when phone login is available', a
|
|||||||
expect(screen.queryByText('先登录账号,再同步你的冒险进度。')).toBeNull();
|
expect(screen.queryByText('先登录账号,再同步你的冒险进度。')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('auth gate waits for access token refresh before exposing restored user content', async () => {
|
test('auth gate waits for refresh cookie rotation before exposing restored user content', async () => {
|
||||||
let resolveToken!: (token: string) => void;
|
let resolveToken!: (token: string) => void;
|
||||||
const tokenPromise = new Promise<string>((resolve) => {
|
const tokenPromise = new Promise<string>((resolve) => {
|
||||||
resolveToken = resolve;
|
resolveToken = resolve;
|
||||||
});
|
});
|
||||||
authMocks.ensureStoredAccessToken.mockReturnValue(tokenPromise);
|
authMocks.refreshStoredAccessToken.mockReturnValue(tokenPromise);
|
||||||
authMocks.getCurrentAuthUser.mockResolvedValue({
|
authMocks.getCurrentAuthUser.mockResolvedValue({
|
||||||
user: mockUser,
|
user: mockUser,
|
||||||
availableLoginMethods: ['phone'],
|
availableLoginMethods: ['phone'],
|
||||||
@@ -224,10 +227,11 @@ test('auth gate waits for access token refresh before exposing restored user con
|
|||||||
expect(screen.getByText('正在校验登录状态...')).toBeTruthy();
|
expect(screen.getByText('正在校验登录状态...')).toBeTruthy();
|
||||||
expect(authMocks.getCurrentAuthUser).not.toHaveBeenCalled();
|
expect(authMocks.getCurrentAuthUser).not.toHaveBeenCalled();
|
||||||
|
|
||||||
resolveToken('jwt-restored-token');
|
resolveToken('jwt-refreshed-token');
|
||||||
|
|
||||||
expect(await screen.findByText('应用内容')).toBeTruthy();
|
expect(await screen.findByText('应用内容')).toBeTruthy();
|
||||||
expect(authMocks.ensureStoredAccessToken).toHaveBeenCalledTimes(1);
|
expect(authMocks.refreshStoredAccessToken).toHaveBeenCalledTimes(1);
|
||||||
|
expect(authMocks.ensureStoredAccessToken).not.toHaveBeenCalled();
|
||||||
expect(authMocks.getCurrentAuthUser).toHaveBeenCalledTimes(1);
|
expect(authMocks.getCurrentAuthUser).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -440,7 +444,7 @@ test('auth state refresh keeps mounted platform content and local tab state', as
|
|||||||
const tokenPromise = new Promise<string>((resolve) => {
|
const tokenPromise = new Promise<string>((resolve) => {
|
||||||
resolveToken = resolve;
|
resolveToken = resolve;
|
||||||
});
|
});
|
||||||
authMocks.ensureStoredAccessToken.mockReturnValueOnce(tokenPromise);
|
authMocks.refreshStoredAccessToken.mockReturnValueOnce(tokenPromise);
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
window.dispatchEvent(new Event('genarrative-auth-state-changed'));
|
window.dispatchEvent(new Event('genarrative-auth-state-changed'));
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
import { useGameSettings } from '../../hooks/useGameSettings';
|
import { useGameSettings } from '../../hooks/useGameSettings';
|
||||||
import {
|
import {
|
||||||
AUTH_STATE_EVENT,
|
AUTH_STATE_EVENT,
|
||||||
ensureStoredAccessToken,
|
refreshStoredAccessToken,
|
||||||
} from '../../services/apiClient';
|
} from '../../services/apiClient';
|
||||||
import {
|
import {
|
||||||
type AuthAuditLogEntry,
|
type AuthAuditLogEntry,
|
||||||
@@ -311,7 +311,10 @@ export function AuthGate({ children }: AuthGateProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await ensureStoredAccessToken();
|
// 中文注释:打开已登录页面也要主动轮换 refresh cookie。
|
||||||
|
// 后端只在 refresh/session 成功续期时写每日登录埋点;如果本地 access token 尚未过期,
|
||||||
|
// 仅调用 /auth/me 不会进入续期链路,导致“打开网页”没有登录埋点。
|
||||||
|
await refreshStoredAccessToken();
|
||||||
const nextSession = await getCurrentAuthUser();
|
const nextSession = await getCurrentAuthUser();
|
||||||
if (!isActive) {
|
if (!isActive) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -513,6 +513,10 @@ export async function ensureStoredAccessToken() {
|
|||||||
return refreshAccessToken();
|
return refreshAccessToken();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function refreshStoredAccessToken() {
|
||||||
|
return refreshAccessToken();
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchWithApiAuth(
|
export async function fetchWithApiAuth(
|
||||||
input: string,
|
input: string,
|
||||||
init: RequestInit = {},
|
init: RequestInit = {},
|
||||||
|
|||||||
Reference in New Issue
Block a user