From bdded3d70844e9f64edaf3b316c95fa6517dccda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8E=86=E5=86=B0=E9=83=81-hermes=E7=89=88?= Date: Fri, 8 May 2026 11:30:14 +0800 Subject: [PATCH 1/6] fix: stabilize admin tracking event display --- server-rs/crates/module-runtime/src/domain.rs | 7 ++++++ server-rs/crates/spacetime-client/src/lib.rs | 9 ++++--- .../crates/spacetime-client/src/mapper.rs | 10 ++++++++ .../crates/spacetime-client/src/runtime.rs | 24 ++++++++++++++++++ .../spacetime-module/src/runtime/profile.rs | 25 ++++++++++++++++++- 5 files changed, 70 insertions(+), 5 deletions(-) diff --git a/server-rs/crates/module-runtime/src/domain.rs b/server-rs/crates/module-runtime/src/domain.rs index 882b659c..76b0f81d 100644 --- a/server-rs/crates/module-runtime/src/domain.rs +++ b/server-rs/crates/module-runtime/src/domain.rs @@ -544,6 +544,13 @@ pub struct RuntimeTrackingEventInput { 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, +} + #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RuntimeProfileTaskConfigSnapshot { diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index 1829a452..09754238 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -155,10 +155,11 @@ use module_runtime::{ RuntimeProfileTaskStatus as DomainRuntimeProfileTaskStatus, RuntimeProfileWalletLedgerEntryRecord, RuntimeReferralInviteCenterRecord, RuntimeReferralRedeemRecord, RuntimeSettingsRecord, RuntimeSnapshotRecord, - RuntimeTrackingScopeKind as DomainRuntimeTrackingScopeKind, build_analytics_metric_query_input, - build_runtime_browse_history_clear_input, build_runtime_browse_history_list_input, - build_runtime_browse_history_record, build_runtime_browse_history_sync_input, - build_runtime_profile_dashboard_get_input, build_runtime_profile_dashboard_record, + RuntimeTrackingEventProcedureResult, RuntimeTrackingScopeKind as DomainRuntimeTrackingScopeKind, + build_analytics_metric_query_input, build_runtime_browse_history_clear_input, + build_runtime_browse_history_list_input, build_runtime_browse_history_record, + build_runtime_browse_history_sync_input, build_runtime_profile_dashboard_get_input, + build_runtime_profile_dashboard_record, build_runtime_profile_invite_code_admin_list_input, build_runtime_profile_invite_code_admin_upsert_input, build_runtime_profile_invite_code_record, build_runtime_profile_play_stats_get_input, build_runtime_profile_play_stats_record, diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index 2359bf4c..1ecc1f96 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -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( result: RuntimeProfileTaskCenterProcedureResult, ) -> Result { diff --git a/server-rs/crates/spacetime-client/src/runtime.rs b/server-rs/crates/spacetime-client/src/runtime.rs index ab41b7b0..935dc751 100644 --- a/server-rs/crates/spacetime-client/src/runtime.rs +++ b/server-rs/crates/spacetime-client/src/runtime.rs @@ -304,6 +304,30 @@ impl SpacetimeClient { .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( &self, user_id: String, diff --git a/server-rs/crates/spacetime-module/src/runtime/profile.rs b/server-rs/crates/spacetime-module/src/runtime/profile.rs index ef9f030b..7ca04a52 100644 --- a/server-rs/crates/spacetime-module/src/runtime/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/profile.rs @@ -491,7 +491,30 @@ 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) + .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] pub fn get_profile_task_center( ctx: &mut ProcedureContext, From 98be6eb0e40c4f759da827ad8a358ce2a9ff889b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8E=86=E5=86=B0=E9=83=81-hermes=E7=89=88?= Date: Fri, 8 May 2026 11:55:08 +0800 Subject: [PATCH 2/6] fix: compile daily login tracking procedure --- server-rs/crates/spacetime-client/src/lib.rs | 9 ++- .../src/module_bindings/mod.rs | 4 ++ ...gin_tracking_event_and_return_procedure.rs | 62 +++++++++++++++++++ ...me_tracking_event_procedure_result_type.rs | 16 +++++ .../spacetime-module/src/runtime/profile.rs | 2 +- 5 files changed, 87 insertions(+), 6 deletions(-) create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/record_daily_login_tracking_event_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_tracking_event_procedure_result_type.rs diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index 09754238..1829a452 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -155,11 +155,10 @@ use module_runtime::{ RuntimeProfileTaskStatus as DomainRuntimeProfileTaskStatus, RuntimeProfileWalletLedgerEntryRecord, RuntimeReferralInviteCenterRecord, RuntimeReferralRedeemRecord, RuntimeSettingsRecord, RuntimeSnapshotRecord, - RuntimeTrackingEventProcedureResult, RuntimeTrackingScopeKind as DomainRuntimeTrackingScopeKind, - build_analytics_metric_query_input, build_runtime_browse_history_clear_input, - build_runtime_browse_history_list_input, build_runtime_browse_history_record, - build_runtime_browse_history_sync_input, build_runtime_profile_dashboard_get_input, - build_runtime_profile_dashboard_record, + RuntimeTrackingScopeKind as DomainRuntimeTrackingScopeKind, build_analytics_metric_query_input, + build_runtime_browse_history_clear_input, build_runtime_browse_history_list_input, + build_runtime_browse_history_record, build_runtime_browse_history_sync_input, + build_runtime_profile_dashboard_get_input, build_runtime_profile_dashboard_record, build_runtime_profile_invite_code_admin_list_input, build_runtime_profile_invite_code_admin_upsert_input, build_runtime_profile_invite_code_record, build_runtime_profile_play_stats_get_input, build_runtime_profile_play_stats_record, diff --git a/server-rs/crates/spacetime-client/src/module_bindings/mod.rs b/server-rs/crates/spacetime-client/src/module_bindings/mod.rs index b4ffcbd4..841fd679 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/mod.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/mod.rs @@ -514,6 +514,7 @@ pub mod record_big_fish_like_procedure; pub mod record_big_fish_play_procedure; pub mod record_custom_world_profile_like_procedure; pub mod record_custom_world_profile_play_procedure; +pub mod record_daily_login_tracking_event_and_return_procedure; pub mod record_puzzle_work_like_procedure; pub mod redeem_profile_referral_invite_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_upsert_input_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_generated_images_procedure; 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_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_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 redeem_profile_referral_invite_code_procedure::redeem_profile_referral_invite_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_upsert_input_type::RuntimeSnapshotUpsertInput; 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_generated_images_procedure::save_puzzle_generated_images; pub use seed_analytics_date_dimensions_reducer::seed_analytics_date_dimensions; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/record_daily_login_tracking_event_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/record_daily_login_tracking_event_and_return_procedure.rs new file mode 100644 index 00000000..9365d335 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/record_daily_login_tracking_event_and_return_procedure.rs @@ -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, + ) + 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, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, RuntimeTrackingEventProcedureResult>( + "record_daily_login_tracking_event_and_return", + RecordDailyLoginTrackingEventAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_tracking_event_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_tracking_event_procedure_result_type.rs new file mode 100644 index 00000000..a956fa12 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_tracking_event_procedure_result_type.rs @@ -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, +} + +impl __sdk::InModule for RuntimeTrackingEventProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-module/src/runtime/profile.rs b/server-rs/crates/spacetime-module/src/runtime/profile.rs index 7ca04a52..80811159 100644 --- a/server-rs/crates/spacetime-module/src/runtime/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/profile.rs @@ -498,7 +498,7 @@ pub fn record_daily_login_tracking_event_and_return( input: RuntimeProfileTaskCenterGetInput, ) -> RuntimeTrackingEventProcedureResult { match ctx.try_with_tx(|tx| { - let validated_input = build_runtime_profile_task_center_get_input(input.user_id) + 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) From f343555a19b43e5d95b2c0429ad5d1ccdce23167 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8E=86=E5=86=B0=E9=83=81-hermes=E7=89=88?= Date: Fri, 8 May 2026 14:00:04 +0800 Subject: [PATCH 3/6] feat: record daily login tracking on auth success --- ..._LOGIN_TRACKING_AUTH_CLOSURE_2026-05-08.md | 63 +++++++++++++++++++ .../crates/api-server/src/auth_session.rs | 41 ++++++++++++ .../crates/api-server/src/password_entry.rs | 12 +++- .../api-server/src/password_management.rs | 8 +++ server-rs/crates/api-server/src/phone_auth.rs | 8 +++ .../crates/api-server/src/wechat_auth.rs | 16 +++++ 6 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 docs/technical/DAILY_LOGIN_TRACKING_AUTH_CLOSURE_2026-05-08.md diff --git a/docs/technical/DAILY_LOGIN_TRACKING_AUTH_CLOSURE_2026-05-08.md b/docs/technical/DAILY_LOGIN_TRACKING_AUTH_CLOSURE_2026-05-08.md new file mode 100644 index 00000000..ba2462e3 --- /dev/null +++ b/docs/technical/DAILY_LOGIN_TRACKING_AUTH_CLOSURE_2026-05-08.md @@ -0,0 +1,63 @@ +# 登录成功每日登录埋点闭环方案(2026-05-08) + +## 背景 + +后台“埋点数据”需要能看到真实登录触发的 `daily_login` 埋点。此前方案 A 已把“读取任务中心时顺手写每日登录埋点”拆成独立 SpacetimeDB procedure: + +- `record_daily_login_tracking_event_and_return` +- `spacetime-client` 方法:`record_daily_login_tracking_event(user_id)` + +但认证成功链路还没有调用该方法,因此当前只完成了“任务中心读取不污染登录埋点”,没有完成“用户真实登录写入每日登录埋点”。 + +## 目标 + +在用户认证成功并创建 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` + +## 设计约束 + +1. 埋点写入不能阻断登录成功响应。 +2. 只有认证成功并已创建会话后才记录。 +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。 + +## 验收 + +- `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 dev` 是长期运行进程;如需本地 smoke,应启动后用 `/healthz` 和后台页面验证,不要等待该命令退出。 diff --git a/server-rs/crates/api-server/src/auth_session.rs b/server-rs/crates/api-server/src/auth_session.rs index e27ff631..601ca8f4 100644 --- a/server-rs/crates/api-server/src/auth_session.rs +++ b/server-rs/crates/api-server/src/auth_session.rs @@ -26,6 +26,47 @@ pub fn create_password_auth_session( 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( state: &AppState, user: &AuthUser, diff --git a/server-rs/crates/api-server/src/password_entry.rs b/server-rs/crates/api-server/src/password_entry.rs index 308e3e87..d9d856b9 100644 --- a/server-rs/crates/api-server/src/password_entry.rs +++ b/server-rs/crates/api-server/src/password_entry.rs @@ -4,7 +4,7 @@ use axum::{ http::{HeaderMap, StatusCode}, response::IntoResponse, }; -use module_auth::{PasswordEntryError, PasswordEntryInput}; +use module_auth::{AuthLoginMethod, PasswordEntryError, PasswordEntryInput}; use serde_json::json; use shared_contracts::auth::{PasswordEntryRequest, PasswordEntryResponse}; @@ -12,7 +12,8 @@ use crate::{ api_response::json_success_body, auth_payload::map_auth_user_payload, 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, request_context::RequestContext, @@ -49,6 +50,13 @@ pub async fn password_entry( } let session_client = resolve_session_client_context(&headers); 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 .sync_auth_store_snapshot_to_spacetime() .await diff --git a/server-rs/crates/api-server/src/password_management.rs b/server-rs/crates/api-server/src/password_management.rs index f2895b20..560e211a 100644 --- a/server-rs/crates/api-server/src/password_management.rs +++ b/server-rs/crates/api-server/src/password_management.rs @@ -16,6 +16,7 @@ use crate::{ auth_payload::map_auth_user_payload, 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, phone_auth::map_phone_auth_error, @@ -79,6 +80,13 @@ pub async fn reset_password( &session_client, 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(); attach_set_cookie_header( diff --git a/server-rs/crates/api-server/src/phone_auth.rs b/server-rs/crates/api-server/src/phone_auth.rs index 4e49bdac..16b2ed5d 100644 --- a/server-rs/crates/api-server/src/phone_auth.rs +++ b/server-rs/crates/api-server/src/phone_auth.rs @@ -20,6 +20,7 @@ use crate::{ auth_payload::map_auth_user_payload, 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, platform_errors::{attach_retry_after, map_phone_auth_platform_store_error}, @@ -176,6 +177,13 @@ pub async fn phone_login( &session_client, AuthLoginMethod::Phone, )?; + record_daily_login_tracking_event_after_auth_success( + &state, + &request_context, + &result.user.id, + AuthLoginMethod::Phone, + ) + .await; state .sync_auth_store_snapshot_to_spacetime() .await diff --git a/server-rs/crates/api-server/src/wechat_auth.rs b/server-rs/crates/api-server/src/wechat_auth.rs index 3725caae..be1b6faa 100644 --- a/server-rs/crates/api-server/src/wechat_auth.rs +++ b/server-rs/crates/api-server/src/wechat_auth.rs @@ -21,6 +21,7 @@ use crate::{ auth_payload::map_auth_user_payload, 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, 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( State(state): State, + Extension(request_context): Extension, headers: HeaderMap, Query(query): Query, ) -> Result { @@ -141,6 +143,13 @@ pub async fn handle_wechat_callback( &session_client, AuthLoginMethod::Wechat, )?; + record_daily_login_tracking_event_after_auth_success( + &state, + &request_context, + &result.user.id, + AuthLoginMethod::Wechat, + ) + .await; state .sync_auth_store_snapshot_to_spacetime() .await @@ -208,6 +217,13 @@ pub async fn bind_wechat_phone( &session_client, AuthLoginMethod::Wechat, )?; + record_daily_login_tracking_event_after_auth_success( + &state, + &request_context, + &result.user.id, + AuthLoginMethod::Wechat, + ) + .await; state .sync_auth_store_snapshot_to_spacetime() .await From 91d993dc6b5e8732750ef53b1828306133368af0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8E=86=E5=86=B0=E9=83=81-hermes=E7=89=88?= Date: Fri, 8 May 2026 14:08:22 +0800 Subject: [PATCH 4/6] feat: record daily login tracking on session refresh --- .../DAILY_LOGIN_TRACKING_AUTH_CLOSURE_2026-05-08.md | 5 +++-- server-rs/crates/api-server/src/refresh_session.rs | 10 +++++++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/docs/technical/DAILY_LOGIN_TRACKING_AUTH_CLOSURE_2026-05-08.md b/docs/technical/DAILY_LOGIN_TRACKING_AUTH_CLOSURE_2026-05-08.md index ba2462e3..8305bdda 100644 --- a/docs/technical/DAILY_LOGIN_TRACKING_AUTH_CLOSURE_2026-05-08.md +++ b/docs/technical/DAILY_LOGIN_TRACKING_AUTH_CLOSURE_2026-05-08.md @@ -20,11 +20,12 @@ - 重置密码后自动登录:`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. 只有认证成功并已创建会话后才记录。 +2. 只有认证成功并已创建会话后,或 refresh session rotate 成功并签发新 access token 后才记录。 3. 失败只记 warning,继续返回 token / cookie。 4. 写入统一收口,避免多个登录 handler 各自拼 procedure 调用。 5. 不修改 SpacetimeDB 表结构,不需要更新 `migration.rs`。 @@ -48,7 +49,7 @@ record_daily_login_tracking_event_after_auth_success( - 成功时记录 info - 失败时记录 warn,并明确“登录流程继续” -在各登录入口 `create_auth_session` 成功后调用该 helper。 +在各登录入口 `create_auth_session` 成功后调用该 helper。refresh cookie 续期在 `rotate_session` 和 `sign_access_token_for_user` 成功后调用同一个 helper,`login_method` 使用 refresh session 上保存的 `issued_by_provider`,避免把续期统一误标成 password。 ## 验收 diff --git a/server-rs/crates/api-server/src/refresh_session.rs b/server-rs/crates/api-server/src/refresh_session.rs index 3b82c038..28d0432c 100644 --- a/server-rs/crates/api-server/src/refresh_session.rs +++ b/server-rs/crates/api-server/src/refresh_session.rs @@ -13,7 +13,8 @@ use crate::{ auth::RefreshSessionToken, auth_session::{ 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, request_context::RequestContext, @@ -54,6 +55,13 @@ pub async fn refresh_session( &rotated.session.session_id, 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 .sync_auth_store_snapshot_to_spacetime() .await From e694c6605a13d4b07a272948d87054c163cd31fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8E=86=E5=86=B0=E9=83=81-hermes=E7=89=88?= Date: Fri, 8 May 2026 14:36:56 +0800 Subject: [PATCH 5/6] fix: trigger login tracking on session restore --- ...DAILY_LOGIN_TRACKING_AUTH_CLOSURE_2026-05-08.md | 9 +++++++++ .../crates/spacetime-module/src/runtime/profile.rs | 2 +- src/components/auth/AuthGate.test.tsx | 14 +++++++++----- src/components/auth/AuthGate.tsx | 7 +++++-- src/services/apiClient.ts | 4 ++++ 5 files changed, 28 insertions(+), 8 deletions(-) diff --git a/docs/technical/DAILY_LOGIN_TRACKING_AUTH_CLOSURE_2026-05-08.md b/docs/technical/DAILY_LOGIN_TRACKING_AUTH_CLOSURE_2026-05-08.md index 8305bdda..82d4018a 100644 --- a/docs/technical/DAILY_LOGIN_TRACKING_AUTH_CLOSURE_2026-05-08.md +++ b/docs/technical/DAILY_LOGIN_TRACKING_AUTH_CLOSURE_2026-05-08.md @@ -9,6 +9,14 @@ 但认证成功链路还没有调用该方法,因此当前只完成了“任务中心读取不污染登录埋点”,没有完成“用户真实登录写入每日登录埋点”。 +## 现象 + +用户已经登录、cookie 未过期时,直接打开网页并不会触发每日登录埋点。原因是前端恢复登录态只读取 `/api/auth/me`,这条链路不会主动走 refresh cookie 续期,因此后端新的埋点写入点不会被触发。 + +## 修复思路 + +在 `AuthGate` 恢复已登录会话时,先主动调用一次 refresh 接口轮换 refresh cookie,再调用 `/api/auth/me` 读取当前会话。这样无论本地 access token 是否仍然有效,打开页面都会进入 refresh 续期链路,从而触发后端的 `daily_login` 埋点写入。 + ## 目标 在用户认证成功并创建 refresh session / access token 后,异步尝试写入每日登录埋点。 @@ -58,6 +66,7 @@ record_daily_login_tracking_event_after_auth_success( - `cargo check -p spacetime-client` - `npm run check:encoding` - `git diff --check` +- `npm run test -- AuthGate.test.tsx` ## 注意 diff --git a/server-rs/crates/spacetime-module/src/runtime/profile.rs b/server-rs/crates/spacetime-module/src/runtime/profile.rs index 80811159..e9ff09d5 100644 --- a/server-rs/crates/spacetime-module/src/runtime/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/profile.rs @@ -520,7 +520,7 @@ pub fn get_profile_task_center( ctx: &mut ProcedureContext, input: RuntimeProfileTaskCenterGetInput, ) -> 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: true, record: Some(record), diff --git a/src/components/auth/AuthGate.test.tsx b/src/components/auth/AuthGate.test.tsx index 0a743656..42c4411e 100644 --- a/src/components/auth/AuthGate.test.tsx +++ b/src/components/auth/AuthGate.test.tsx @@ -13,6 +13,7 @@ const authMocks = vi.hoisted(() => ({ authEntry: vi.fn(), changePassword: vi.fn(), ensureStoredAccessToken: vi.fn(), + refreshStoredAccessToken: vi.fn(), getAuthLoginOptions: vi.fn(), getCurrentAuthUser: vi.fn(), loginWithPhoneCode: vi.fn(), @@ -28,6 +29,7 @@ const authMocks = vi.hoisted(() => ({ vi.mock('../../services/apiClient', () => ({ AUTH_STATE_EVENT: 'genarrative-auth-state-changed', ensureStoredAccessToken: authMocks.ensureStoredAccessToken, + refreshStoredAccessToken: authMocks.refreshStoredAccessToken, })); vi.mock('../../services/authService', () => ({ @@ -94,6 +96,7 @@ beforeEach(() => { window.history.replaceState(null, '', '/'); authMocks.consumeAuthCallbackResult.mockReturnValue(null); authMocks.ensureStoredAccessToken.mockResolvedValue('jwt-existing-token'); + authMocks.refreshStoredAccessToken.mockResolvedValue('jwt-refreshed-token'); authMocks.getCurrentAuthUser.mockResolvedValue({ user: null, availableLoginMethods: ['phone'], @@ -204,12 +207,12 @@ test('auth gate keeps platform content visible when phone login is available', a 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; const tokenPromise = new Promise((resolve) => { resolveToken = resolve; }); - authMocks.ensureStoredAccessToken.mockReturnValue(tokenPromise); + authMocks.refreshStoredAccessToken.mockReturnValue(tokenPromise); authMocks.getCurrentAuthUser.mockResolvedValue({ user: mockUser, availableLoginMethods: ['phone'], @@ -224,10 +227,11 @@ test('auth gate waits for access token refresh before exposing restored user con expect(screen.getByText('正在校验登录状态...')).toBeTruthy(); expect(authMocks.getCurrentAuthUser).not.toHaveBeenCalled(); - resolveToken('jwt-restored-token'); + resolveToken('jwt-refreshed-token'); 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); }); @@ -440,7 +444,7 @@ test('auth state refresh keeps mounted platform content and local tab state', as const tokenPromise = new Promise((resolve) => { resolveToken = resolve; }); - authMocks.ensureStoredAccessToken.mockReturnValueOnce(tokenPromise); + authMocks.refreshStoredAccessToken.mockReturnValueOnce(tokenPromise); act(() => { window.dispatchEvent(new Event('genarrative-auth-state-changed')); diff --git a/src/components/auth/AuthGate.tsx b/src/components/auth/AuthGate.tsx index 99be0b9c..a853a157 100644 --- a/src/components/auth/AuthGate.tsx +++ b/src/components/auth/AuthGate.tsx @@ -10,7 +10,7 @@ import { import { useGameSettings } from '../../hooks/useGameSettings'; import { AUTH_STATE_EVENT, - ensureStoredAccessToken, + refreshStoredAccessToken, } from '../../services/apiClient'; import { type AuthAuditLogEntry, @@ -311,7 +311,10 @@ export function AuthGate({ children }: AuthGateProps) { } try { - await ensureStoredAccessToken(); + // 中文注释:打开已登录页面也要主动轮换 refresh cookie。 + // 后端只在 refresh/session 成功续期时写每日登录埋点;如果本地 access token 尚未过期, + // 仅调用 /auth/me 不会进入续期链路,导致“打开网页”没有登录埋点。 + await refreshStoredAccessToken(); const nextSession = await getCurrentAuthUser(); if (!isActive) { return; diff --git a/src/services/apiClient.ts b/src/services/apiClient.ts index c1b470a8..714625bb 100644 --- a/src/services/apiClient.ts +++ b/src/services/apiClient.ts @@ -513,6 +513,10 @@ export async function ensureStoredAccessToken() { return refreshAccessToken(); } +export async function refreshStoredAccessToken() { + return refreshAccessToken(); +} + export async function fetchWithApiAuth( input: string, init: RequestInit = {}, From 539ddbde2465481e6169b232c525863d75802331 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8E=86=E5=86=B0=E9=83=81-hermes=E7=89=88?= Date: Fri, 8 May 2026 14:41:11 +0800 Subject: [PATCH 6/6] ignore .env.secrets.local --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 91cf9856..94bcb2eb 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,4 @@ temp*build*/ /target/ /logs .worktrees/ +.env.secrets.local